getpatter 0.6.0 → 0.6.1

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/cli.js CHANGED
@@ -50,6 +50,19 @@ var MetricsStore = class extends import_events.EventEmitter {
50
50
  maxCalls;
51
51
  calls = [];
52
52
  activeCalls = /* @__PURE__ */ new Map();
53
+ /**
54
+ * User-driven soft delete: call_ids the operator removed from the
55
+ * dashboard view. The on-disk artefacts written by ``CallLogger``
56
+ * (``metadata.json``, ``transcript.jsonl``) are intentionally NOT
57
+ * touched — they serve as the durable backup. All read paths
58
+ * (``getCalls`` / ``getCall`` / ``getAggregates`` / ``getCallsInRange``
59
+ * / ``hydrate``) filter against this set so the call is invisible
60
+ * to the UI and excluded from rolling metrics. Populated from
61
+ * ``<logRoot>/.deleted_call_ids.json`` on hydrate so deletions
62
+ * survive a process restart. Parity with Python.
63
+ */
64
+ deletedCallIds = /* @__PURE__ */ new Set();
65
+ deletedIdsPath = null;
53
66
  /**
54
67
  * Accepts either a numeric ``maxCalls`` (legacy positional — matches the
55
68
  * original TS API) or an options object ``{ maxCalls }`` to align with the
@@ -142,6 +155,8 @@ var MetricsStore = class extends import_events.EventEmitter {
142
155
  ended_at: Date.now() / 1e3,
143
156
  status,
144
157
  metrics: null,
158
+ ...active.turns && active.turns.length > 0 ? { turns: active.turns } : {},
159
+ ...active.transcript && active.transcript.length > 0 ? { transcript: active.transcript } : {},
145
160
  ...extra
146
161
  };
147
162
  this.activeCalls.delete(callId);
@@ -170,6 +185,21 @@ var MetricsStore = class extends import_events.EventEmitter {
170
185
  if (active) {
171
186
  if (!active.turns) active.turns = [];
172
187
  active.turns.push(turn);
188
+ if (!active.transcript) active.transcript = [];
189
+ const turnRecord = turn;
190
+ const userText = typeof turnRecord.user_text === "string" ? turnRecord.user_text : "";
191
+ const agentText = typeof turnRecord.agent_text === "string" ? turnRecord.agent_text : "";
192
+ const ts = typeof turnRecord.timestamp === "number" ? turnRecord.timestamp : Date.now() / 1e3;
193
+ if (userText.length > 0) {
194
+ active.transcript.push({ role: "user", text: userText, timestamp: ts });
195
+ }
196
+ if (agentText.length > 0 && agentText !== "[interrupted]") {
197
+ active.transcript.push({
198
+ role: "assistant",
199
+ text: agentText,
200
+ timestamp: ts
201
+ });
202
+ }
173
203
  }
174
204
  this.publish("turn_complete", { call_id: callId, turn });
175
205
  }
@@ -179,40 +209,140 @@ var MetricsStore = class extends import_events.EventEmitter {
179
209
  if (!callId) return;
180
210
  const active = this.activeCalls.get(callId);
181
211
  this.activeCalls.delete(callId);
182
- const activeStatus = active?.status;
183
- const resolvedStatus = activeStatus && activeStatus !== "in-progress" ? activeStatus : "completed";
212
+ let existingIdx = -1;
213
+ if (active === void 0) {
214
+ for (let i = this.calls.length - 1; i >= 0; i--) {
215
+ if (this.calls[i].call_id === callId) {
216
+ existingIdx = i;
217
+ break;
218
+ }
219
+ }
220
+ }
221
+ const existing = existingIdx >= 0 ? this.calls[existingIdx] : void 0;
222
+ const priorStatus = active?.status ?? existing?.status;
223
+ const resolvedStatus = priorStatus && priorStatus !== "in-progress" ? priorStatus : "completed";
224
+ const dataTranscript = data.transcript;
225
+ const resolvedTranscript = dataTranscript && dataTranscript.length > 0 ? dataTranscript : active?.transcript && active.transcript.length > 0 ? active.transcript : existing?.transcript && existing.transcript.length > 0 ? existing.transcript : [];
226
+ const resolvedTurns = active?.turns && active.turns.length > 0 ? active.turns : existing?.turns && existing.turns.length > 0 ? existing.turns : void 0;
184
227
  const entry = {
185
228
  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,
229
+ caller: data.caller || active?.caller || existing?.caller || "",
230
+ callee: data.callee || active?.callee || existing?.callee || "",
231
+ direction: active?.direction || existing?.direction || data.direction || "inbound",
232
+ started_at: active?.started_at || existing?.started_at || 0,
190
233
  ended_at: Date.now() / 1e3,
191
- transcript: data.transcript || [],
234
+ transcript: resolvedTranscript,
235
+ ...resolvedTurns ? { turns: resolvedTurns } : {},
192
236
  status: resolvedStatus,
193
- metrics: metrics ?? null
237
+ metrics: metrics ?? existing?.metrics ?? null
194
238
  };
195
- this.calls.push(entry);
196
- if (this.calls.length > this.maxCalls) {
197
- this.calls = this.calls.slice(-this.maxCalls);
239
+ if (existingIdx >= 0) {
240
+ this.calls[existingIdx] = entry;
241
+ } else {
242
+ this.calls.push(entry);
243
+ if (this.calls.length > this.maxCalls) {
244
+ this.calls = this.calls.slice(-this.maxCalls);
245
+ }
198
246
  }
199
247
  this.publish("call_end", {
200
248
  call_id: callId,
201
249
  metrics: entry.metrics ?? null
202
250
  });
203
251
  }
204
- /** Return a window of completed calls in newest-first order. */
252
+ /**
253
+ * Return a window of completed calls in newest-first order.
254
+ *
255
+ * Soft-deleted call_ids (see ``deleteCalls``) are filtered out so the
256
+ * dashboard never re-shows a row the user removed. The on-disk
257
+ * artefacts are intentionally preserved as a backup.
258
+ */
205
259
  getCalls(limit = 50, offset = 0) {
206
- const ordered = [...this.calls].reverse();
260
+ const visible = this.calls.filter((c) => !this.deletedCallIds.has(c.call_id));
261
+ const ordered = visible.reverse();
207
262
  return ordered.slice(offset, offset + limit);
208
263
  }
209
- /** Look up a completed call by id (newest match wins). */
264
+ /**
265
+ * Look up a completed call by id (newest match wins).
266
+ *
267
+ * Soft-deleted call_ids resolve to ``null`` so the SPA's detail pane
268
+ * cannot render a row the user removed.
269
+ */
210
270
  getCall(callId) {
271
+ if (this.deletedCallIds.has(callId)) return null;
211
272
  for (let i = this.calls.length - 1; i >= 0; i--) {
212
273
  if (this.calls[i].call_id === callId) return this.calls[i];
213
274
  }
214
275
  return null;
215
276
  }
277
+ /**
278
+ * Soft-delete one or more calls from the dashboard view.
279
+ *
280
+ * Adds each ``call_id`` to an in-memory set. Subsequent reads via
281
+ * ``getCalls`` / ``getCall`` / ``getAggregates`` / ``getCallsInRange``
282
+ * exclude the deleted ids, so rolling metrics (avg latency, total
283
+ * spend) are recomputed without them. The on-disk
284
+ * ``metadata.json`` / ``transcript.jsonl`` files written by
285
+ * ``CallLogger`` are NOT touched — they serve as a durable backup
286
+ * the operator can audit outside the dashboard.
287
+ *
288
+ * Active calls are never deletable. A call_id that is currently
289
+ * in ``activeCalls`` is silently skipped so a mid-call delete
290
+ * from the UI cannot orphan the live transcript pane.
291
+ *
292
+ * Persisted to ``<logRoot>/.deleted_call_ids.json`` (best-effort)
293
+ * when ``hydrate()`` has been called with a log root. Parity with
294
+ * Python ``delete_calls``.
295
+ *
296
+ * @returns The list of call_ids actually accepted as deleted.
297
+ */
298
+ deleteCalls(callIds) {
299
+ const ids = /* @__PURE__ */ new Set();
300
+ for (const cid of callIds || []) {
301
+ if (typeof cid === "string" && cid && !this.activeCalls.has(cid)) {
302
+ ids.add(cid);
303
+ }
304
+ }
305
+ if (ids.size === 0) return [];
306
+ const accepted = [];
307
+ for (const cid of ids) {
308
+ if (!this.deletedCallIds.has(cid)) {
309
+ this.deletedCallIds.add(cid);
310
+ accepted.push(cid);
311
+ }
312
+ }
313
+ if (accepted.length === 0) return [];
314
+ accepted.sort();
315
+ this.persistDeletedIds();
316
+ this.publish("calls_deleted", { call_ids: accepted });
317
+ return accepted;
318
+ }
319
+ /** Whether ``callId`` was soft-deleted from the dashboard. */
320
+ isDeleted(callId) {
321
+ return this.deletedCallIds.has(callId);
322
+ }
323
+ /** Snapshot of soft-deleted call_ids (sorted). */
324
+ getDeletedCallIds() {
325
+ return Array.from(this.deletedCallIds).sort();
326
+ }
327
+ /** Atomically persist the deleted-ids set to disk. Best-effort. */
328
+ persistDeletedIds() {
329
+ if (this.deletedIdsPath === null) return;
330
+ try {
331
+ const dir = path.dirname(this.deletedIdsPath);
332
+ fs.mkdirSync(dir, { recursive: true });
333
+ const tmp = this.deletedIdsPath + ".tmp";
334
+ const payload = {
335
+ version: 1,
336
+ deleted_call_ids: Array.from(this.deletedCallIds).sort()
337
+ };
338
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf8");
339
+ fs.renameSync(tmp, this.deletedIdsPath);
340
+ } catch (err) {
341
+ getLogger().debug(
342
+ `MetricsStore.persistDeletedIds: ${String(err)}`
343
+ );
344
+ }
345
+ }
216
346
  /** Look up an active call by id (returns undefined if not active or unknown). */
217
347
  getActive(callId) {
218
348
  return this.activeCalls.get(callId);
@@ -221,9 +351,17 @@ var MetricsStore = class extends import_events.EventEmitter {
221
351
  getActiveCalls() {
222
352
  return Array.from(this.activeCalls.values());
223
353
  }
224
- /** Compute summary statistics across the buffered call history. */
354
+ /**
355
+ * Compute summary statistics across the buffered call history.
356
+ *
357
+ * Soft-deleted calls are excluded so rolling metrics (avg latency,
358
+ * total spend) match exactly what the operator sees in the call list.
359
+ */
225
360
  getAggregates() {
226
- const totalCalls = this.calls.length;
361
+ const visible = this.calls.filter(
362
+ (c) => !this.deletedCallIds.has(c.call_id)
363
+ );
364
+ const totalCalls = visible.length;
227
365
  if (totalCalls === 0) {
228
366
  return {
229
367
  total_calls: 0,
@@ -242,7 +380,7 @@ var MetricsStore = class extends import_events.EventEmitter {
242
380
  let costTts = 0;
243
381
  let costLlm = 0;
244
382
  let costTel = 0;
245
- for (const call of this.calls) {
383
+ for (const call of visible) {
246
384
  const m = call.metrics;
247
385
  if (!m) continue;
248
386
  const cost = m.cost || {};
@@ -253,7 +391,7 @@ var MetricsStore = class extends import_events.EventEmitter {
253
391
  costTel += cost.telephony || 0;
254
392
  totalDuration += m.duration_seconds || 0;
255
393
  const avgLat = m.latency_avg || {};
256
- const tMs = avgLat.total_ms || 0;
394
+ const tMs = avgLat.agent_response_ms || avgLat.total_ms || 0;
257
395
  if (tMs > 0) {
258
396
  totalLatency += tMs;
259
397
  latencyCount++;
@@ -273,18 +411,26 @@ var MetricsStore = class extends import_events.EventEmitter {
273
411
  active_calls: this.activeCalls.size
274
412
  };
275
413
  }
276
- /** Return calls whose `started_at` falls within `[fromTs, toTs]` (Unix seconds). */
414
+ /**
415
+ * Return calls whose `started_at` falls within `[fromTs, toTs]` (Unix
416
+ * seconds). Soft-deleted calls are filtered out.
417
+ */
277
418
  getCallsInRange(fromTs = 0, toTs = 0) {
278
419
  return this.calls.filter((call) => {
420
+ if (this.deletedCallIds.has(call.call_id)) return false;
279
421
  const started = call.started_at || 0;
280
422
  if (fromTs && started < fromTs) return false;
281
423
  if (toTs && started > toTs) return false;
282
424
  return true;
283
425
  });
284
426
  }
285
- /** Number of completed calls currently in the ring buffer. */
427
+ /** Number of completed (non-deleted) calls currently in the ring buffer. */
286
428
  get callCount() {
287
- return this.calls.length;
429
+ let n = 0;
430
+ for (const c of this.calls) {
431
+ if (!this.deletedCallIds.has(c.call_id)) n++;
432
+ }
433
+ return n;
288
434
  }
289
435
  /**
290
436
  * Rebuild the in-memory call list from `metadata.json` files written by
@@ -298,6 +444,24 @@ var MetricsStore = class extends import_events.EventEmitter {
298
444
  */
299
445
  hydrate(logRoot) {
300
446
  if (!logRoot) return 0;
447
+ const deletedIdsPath = path.join(logRoot, ".deleted_call_ids.json");
448
+ this.deletedIdsPath = deletedIdsPath;
449
+ if (fs.existsSync(deletedIdsPath)) {
450
+ try {
451
+ const raw = fs.readFileSync(deletedIdsPath, "utf8");
452
+ const payload = JSON.parse(raw);
453
+ const arr = Array.isArray(payload.deleted_call_ids) ? payload.deleted_call_ids : [];
454
+ for (const cid of arr) {
455
+ if (typeof cid === "string" && cid.length > 0) {
456
+ this.deletedCallIds.add(cid);
457
+ }
458
+ }
459
+ } catch (err) {
460
+ getLogger().debug(
461
+ `MetricsStore.hydrate: skipping ${deletedIdsPath}: ${String(err)}`
462
+ );
463
+ }
464
+ }
301
465
  const callsRoot = path.join(logRoot, "calls");
302
466
  if (!fs.existsSync(callsRoot)) return 0;
303
467
  const collected = [];
@@ -332,6 +496,12 @@ var MetricsStore = class extends import_events.EventEmitter {
332
496
  );
333
497
  continue;
334
498
  }
499
+ if (!record.transcript || record.transcript.length === 0) {
500
+ const fromJsonl = loadTranscriptJsonl(
501
+ path.join(childPath, "transcript.jsonl")
502
+ );
503
+ if (fromJsonl.length > 0) record.transcript = fromJsonl;
504
+ }
335
505
  collected.push(record);
336
506
  seen.add(callId);
337
507
  } catch (err) {
@@ -353,12 +523,45 @@ var MetricsStore = class extends import_events.EventEmitter {
353
523
  return collected.length;
354
524
  }
355
525
  };
526
+ function metricsFromTopLevel(meta) {
527
+ const cost = meta.cost && typeof meta.cost === "object" ? meta.cost : null;
528
+ const latency = meta.latency && typeof meta.latency === "object" ? meta.latency : null;
529
+ const durationMs = meta.duration_ms;
530
+ const telephony = meta.telephony_provider;
531
+ if (cost === null && latency === null && durationMs == null && !telephony) {
532
+ return null;
533
+ }
534
+ const out = {};
535
+ if (cost !== null) out.cost = cost;
536
+ if (latency !== null) {
537
+ const fullAvg = latency.avg && typeof latency.avg === "object" ? latency.avg : null;
538
+ const fullP50 = latency.p50 && typeof latency.p50 === "object" ? latency.p50 : null;
539
+ const fullP95 = latency.p95 && typeof latency.p95 === "object" ? latency.p95 : null;
540
+ const fullP99 = latency.p99 && typeof latency.p99 === "object" ? latency.p99 : null;
541
+ if (fullAvg) out.latency_avg = fullAvg;
542
+ if (fullP50) out.latency_p50 = fullP50;
543
+ if (fullP95) out.latency_p95 = fullP95;
544
+ if (fullP99) out.latency_p99 = fullP99;
545
+ if (!fullAvg && !fullP50 && !fullP95) {
546
+ const totalMs = typeof latency.p95_ms === "number" && latency.p95_ms || typeof latency.p50_ms === "number" && latency.p50_ms || 0;
547
+ out.latency_avg = { total_ms: totalMs };
548
+ }
549
+ out.latency = latency;
550
+ }
551
+ if (typeof durationMs === "number" && durationMs > 0) {
552
+ out.duration_seconds = durationMs / 1e3;
553
+ }
554
+ if (typeof telephony === "string" && telephony) {
555
+ out.telephony_provider = telephony;
556
+ }
557
+ return Object.keys(out).length > 0 ? out : null;
558
+ }
356
559
  function metadataToCallRecord(callId, meta) {
357
560
  const startedAt = parseTimestamp(meta.started_at);
358
561
  if (startedAt === null) return null;
359
562
  const endedAt = parseTimestamp(meta.ended_at);
360
563
  const status = meta.status || "completed";
361
- const metrics = meta.metrics && typeof meta.metrics === "object" ? meta.metrics : null;
564
+ const metrics = meta.metrics && typeof meta.metrics === "object" ? meta.metrics : metricsFromTopLevel(meta);
362
565
  const transcript = Array.isArray(meta.transcript) ? meta.transcript : [];
363
566
  return {
364
567
  call_id: callId,
@@ -372,6 +575,36 @@ function metadataToCallRecord(callId, meta) {
372
575
  transcript
373
576
  };
374
577
  }
578
+ function loadTranscriptJsonl(filePath) {
579
+ try {
580
+ if (!fs.existsSync(filePath)) return [];
581
+ const raw = fs.readFileSync(filePath, "utf8");
582
+ const lines = raw.split("\n").filter((l) => l.trim().length > 0);
583
+ const out = [];
584
+ for (const line of lines) {
585
+ let row;
586
+ try {
587
+ row = JSON.parse(line);
588
+ } catch {
589
+ continue;
590
+ }
591
+ const tsIso = typeof row.ts === "string" ? Date.parse(row.ts) : NaN;
592
+ const tsNumeric = typeof row.timestamp === "number" ? row.timestamp * 1e3 : NaN;
593
+ const timestamp = Number.isFinite(tsIso) ? tsIso : Number.isFinite(tsNumeric) ? tsNumeric : 0;
594
+ const userText = typeof row.user_text === "string" ? row.user_text : "";
595
+ const agentText = typeof row.agent_text === "string" ? row.agent_text : "";
596
+ if (userText.length > 0) {
597
+ out.push({ role: "user", text: userText, timestamp });
598
+ }
599
+ if (agentText.length > 0 && agentText !== "[interrupted]") {
600
+ out.push({ role: "assistant", text: agentText, timestamp });
601
+ }
602
+ }
603
+ return out;
604
+ } catch {
605
+ return [];
606
+ }
607
+ }
375
608
  function parseTimestamp(raw) {
376
609
  if (typeof raw === "number") {
377
610
  return Number.isFinite(raw) ? raw : null;
@@ -512,7 +745,8 @@ function mountDashboard(app, store, token = "") {
512
745
  res.json(store.getCalls(limit, offset));
513
746
  });
514
747
  app.get("/api/dashboard/calls/:callId", auth, (req, res) => {
515
- const call = store.getCall(String(req.params.callId));
748
+ const callId = String(req.params.callId);
749
+ const call = store.getCall(callId) ?? store.getActive(callId);
516
750
  if (!call) {
517
751
  res.status(404).json({ error: "Not found" });
518
752
  return;
@@ -525,6 +759,24 @@ function mountDashboard(app, store, token = "") {
525
759
  app.get("/api/dashboard/aggregates", auth, (_req, res) => {
526
760
  res.json(store.getAggregates());
527
761
  });
762
+ app.delete("/api/dashboard/calls/:callId", auth, (req, res) => {
763
+ const callId = String(req.params.callId);
764
+ const accepted = store.deleteCalls([callId]);
765
+ res.json({ deleted: accepted, count: accepted.length });
766
+ });
767
+ app.post("/api/dashboard/calls/delete", auth, (req, res) => {
768
+ const body = req.body ?? {};
769
+ const raw = body.call_ids;
770
+ if (!Array.isArray(raw)) {
771
+ res.status(400).json({ error: "Expected JSON body { 'call_ids': [...] }" });
772
+ return;
773
+ }
774
+ const ids = raw.filter(
775
+ (cid) => typeof cid === "string" && cid.length > 0
776
+ );
777
+ const accepted = store.deleteCalls(ids);
778
+ res.json({ deleted: accepted, count: accepted.length });
779
+ });
528
780
  app.get("/api/dashboard/events", auth, (req, res) => {
529
781
  res.writeHead(200, {
530
782
  "Content-Type": "text/event-stream",
@@ -597,7 +849,8 @@ function mountApi(app, store, token = "") {
597
849
  res.json({ data: active, count: active.length });
598
850
  });
599
851
  app.get("/api/v1/calls/:callId", auth, (req, res) => {
600
- const call = store.getCall(String(req.params.callId));
852
+ const callId = String(req.params.callId);
853
+ const call = store.getCall(callId) ?? store.getActive(callId);
601
854
  if (!call) {
602
855
  res.status(404).json({ error: "Call not found" });
603
856
  return;