getpatter 0.4.0 → 0.4.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.
Files changed (37) hide show
  1. package/README.md +185 -587
  2. package/dist/chunk-35EVXMGB.mjs +4472 -0
  3. package/dist/chunk-AFUYSNDH.mjs +31 -0
  4. package/dist/chunk-JO5C35FM.mjs +65 -0
  5. package/dist/chunk-OOIUSZB4.mjs +37 -0
  6. package/dist/cli.js +1139 -0
  7. package/dist/index.d.mts +1063 -85
  8. package/dist/index.d.ts +1063 -85
  9. package/dist/index.js +8969 -3904
  10. package/dist/index.mjs +2382 -3354
  11. package/dist/lib-4WCAS54J.mjs +830 -0
  12. package/dist/node-cron-373UVDIO.mjs +935 -0
  13. package/dist/persistence-CYIGNHSU.mjs +7 -0
  14. package/dist/resources/audio/NOTICE +2 -0
  15. package/dist/resources/audio/city-ambience.ogg +0 -0
  16. package/dist/resources/audio/crowded-room.ogg +0 -0
  17. package/dist/resources/audio/forest-ambience.ogg +0 -0
  18. package/dist/resources/audio/hold_music.ogg +0 -0
  19. package/dist/resources/audio/keyboard-typing.ogg +0 -0
  20. package/dist/resources/audio/keyboard-typing2.ogg +0 -0
  21. package/dist/resources/audio/office-ambience.ogg +0 -0
  22. package/dist/resources/silero_vad.onnx +0 -0
  23. package/dist/{test-mode-JMXZSAJS.mjs → test-mode-RH65MMSP.mjs} +2 -1
  24. package/dist/{tunnel-HYSU7EF2.mjs → tunnel-BL7A7GXW.mjs} +2 -1
  25. package/package.json +25 -8
  26. package/src/resources/audio/NOTICE +2 -0
  27. package/src/resources/audio/city-ambience.ogg +0 -0
  28. package/src/resources/audio/crowded-room.ogg +0 -0
  29. package/src/resources/audio/forest-ambience.ogg +0 -0
  30. package/src/resources/audio/hold_music.ogg +0 -0
  31. package/src/resources/audio/keyboard-typing.ogg +0 -0
  32. package/src/resources/audio/keyboard-typing2.ogg +0 -0
  33. package/src/resources/audio/office-ambience.ogg +0 -0
  34. package/dist/chunk-KB57IV4K.mjs +0 -410
  35. package/dist/chunk-TAATEHKF.mjs +0 -396
  36. package/dist/chunk-VNU4GNW3.mjs +0 -45
  37. package/dist/test-mode-RTQAK5CP.mjs +0 -6
package/dist/cli.js ADDED
@@ -0,0 +1,1139 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/cli.ts
27
+ var import_node_http = require("http");
28
+ var import_express = __toESM(require("express"));
29
+
30
+ // src/dashboard/store.ts
31
+ var import_events = require("events");
32
+ var MetricsStore = class extends import_events.EventEmitter {
33
+ maxCalls;
34
+ calls = [];
35
+ activeCalls = /* @__PURE__ */ new Map();
36
+ constructor(maxCalls = 500) {
37
+ super();
38
+ this.maxCalls = maxCalls;
39
+ }
40
+ publish(eventType, data) {
41
+ this.emit("sse", { type: eventType, data });
42
+ }
43
+ recordCallStart(data) {
44
+ const callId = data.call_id || "";
45
+ if (!callId) return;
46
+ const record = {
47
+ call_id: callId,
48
+ caller: data.caller || "",
49
+ callee: data.callee || "",
50
+ direction: data.direction || "inbound",
51
+ started_at: Date.now() / 1e3,
52
+ turns: []
53
+ };
54
+ this.activeCalls.set(callId, record);
55
+ this.publish("call_start", {
56
+ call_id: callId,
57
+ caller: record.caller,
58
+ callee: record.callee,
59
+ direction: record.direction
60
+ });
61
+ }
62
+ recordTurn(data) {
63
+ const callId = data.call_id || "";
64
+ const turn = data.turn;
65
+ if (!callId || turn == null) return;
66
+ const active = this.activeCalls.get(callId);
67
+ if (active) {
68
+ if (!active.turns) active.turns = [];
69
+ active.turns.push(turn);
70
+ }
71
+ this.publish("turn_complete", { call_id: callId, turn });
72
+ }
73
+ recordCallEnd(data, metrics) {
74
+ const callId = data.call_id || "";
75
+ if (!callId) return;
76
+ const active = this.activeCalls.get(callId);
77
+ this.activeCalls.delete(callId);
78
+ const entry = {
79
+ call_id: callId,
80
+ caller: data.caller || active?.caller || "",
81
+ callee: data.callee || active?.callee || "",
82
+ direction: active?.direction || data.direction || "inbound",
83
+ started_at: active?.started_at || 0,
84
+ ended_at: Date.now() / 1e3,
85
+ transcript: data.transcript || [],
86
+ metrics: metrics ?? null
87
+ };
88
+ this.calls.push(entry);
89
+ if (this.calls.length > this.maxCalls) {
90
+ this.calls = this.calls.slice(-this.maxCalls);
91
+ }
92
+ this.publish("call_end", {
93
+ call_id: callId,
94
+ metrics: entry.metrics ?? null
95
+ });
96
+ }
97
+ getCalls(limit = 50, offset = 0) {
98
+ const ordered = [...this.calls].reverse();
99
+ return ordered.slice(offset, offset + limit);
100
+ }
101
+ getCall(callId) {
102
+ for (let i = this.calls.length - 1; i >= 0; i--) {
103
+ if (this.calls[i].call_id === callId) return this.calls[i];
104
+ }
105
+ return null;
106
+ }
107
+ getActiveCalls() {
108
+ return Array.from(this.activeCalls.values());
109
+ }
110
+ getAggregates() {
111
+ const totalCalls = this.calls.length;
112
+ if (totalCalls === 0) {
113
+ return {
114
+ total_calls: 0,
115
+ total_cost: 0,
116
+ avg_duration: 0,
117
+ avg_latency_ms: 0,
118
+ cost_breakdown: { stt: 0, tts: 0, llm: 0, telephony: 0 },
119
+ active_calls: this.activeCalls.size
120
+ };
121
+ }
122
+ let totalCost = 0;
123
+ let totalDuration = 0;
124
+ let totalLatency = 0;
125
+ let latencyCount = 0;
126
+ let costStt = 0;
127
+ let costTts = 0;
128
+ let costLlm = 0;
129
+ let costTel = 0;
130
+ for (const call of this.calls) {
131
+ const m = call.metrics;
132
+ if (!m) continue;
133
+ const cost = m.cost || {};
134
+ totalCost += cost.total || 0;
135
+ costStt += cost.stt || 0;
136
+ costTts += cost.tts || 0;
137
+ costLlm += cost.llm || 0;
138
+ costTel += cost.telephony || 0;
139
+ totalDuration += m.duration_seconds || 0;
140
+ const avgLat = m.latency_avg || {};
141
+ const tMs = avgLat.total_ms || 0;
142
+ if (tMs > 0) {
143
+ totalLatency += tMs;
144
+ latencyCount++;
145
+ }
146
+ }
147
+ return {
148
+ total_calls: totalCalls,
149
+ total_cost: Math.round(totalCost * 1e6) / 1e6,
150
+ avg_duration: Math.round(totalDuration / totalCalls * 100) / 100,
151
+ avg_latency_ms: latencyCount > 0 ? Math.round(totalLatency / latencyCount * 10) / 10 : 0,
152
+ cost_breakdown: {
153
+ stt: Math.round(costStt * 1e6) / 1e6,
154
+ tts: Math.round(costTts * 1e6) / 1e6,
155
+ llm: Math.round(costLlm * 1e6) / 1e6,
156
+ telephony: Math.round(costTel * 1e6) / 1e6
157
+ },
158
+ active_calls: this.activeCalls.size
159
+ };
160
+ }
161
+ getCallsInRange(fromTs = 0, toTs = 0) {
162
+ return this.calls.filter((call) => {
163
+ const started = call.started_at || 0;
164
+ if (fromTs && started < fromTs) return false;
165
+ if (toTs && started > toTs) return false;
166
+ return true;
167
+ });
168
+ }
169
+ get callCount() {
170
+ return this.calls.length;
171
+ }
172
+ };
173
+
174
+ // src/dashboard/auth.ts
175
+ var import_node_crypto = __toESM(require("crypto"));
176
+ function timingSafeCompare(a, b) {
177
+ const aBuf = Buffer.from(a);
178
+ const bBuf = Buffer.from(b);
179
+ if (aBuf.length !== bBuf.length) {
180
+ import_node_crypto.default.timingSafeEqual(aBuf, aBuf);
181
+ return false;
182
+ }
183
+ return import_node_crypto.default.timingSafeEqual(aBuf, bBuf);
184
+ }
185
+ function makeAuthMiddleware(token = "") {
186
+ return (req, res, next) => {
187
+ if (!token) {
188
+ next();
189
+ return;
190
+ }
191
+ const auth = req.headers.authorization || "";
192
+ const expected = `Bearer ${token}`;
193
+ if (timingSafeCompare(auth, expected)) {
194
+ next();
195
+ return;
196
+ }
197
+ const queryToken = String(req.query.token ?? "");
198
+ if (timingSafeCompare(queryToken, token)) {
199
+ next();
200
+ return;
201
+ }
202
+ res.status(401).json({ error: "Unauthorized" });
203
+ };
204
+ }
205
+
206
+ // src/dashboard/export.ts
207
+ function callsToCsv(calls) {
208
+ const header = [
209
+ "call_id",
210
+ "caller",
211
+ "callee",
212
+ "direction",
213
+ "started_at",
214
+ "ended_at",
215
+ "duration_s",
216
+ "cost_total",
217
+ "cost_stt",
218
+ "cost_tts",
219
+ "cost_llm",
220
+ "cost_telephony",
221
+ "avg_latency_ms",
222
+ "turns_count",
223
+ "provider_mode"
224
+ ];
225
+ const rows = [header.join(",")];
226
+ for (const call of calls) {
227
+ const m = call.metrics || {};
228
+ const cost = m.cost || {};
229
+ const latencyAvg = m.latency_avg || {};
230
+ const turns = m.turns;
231
+ const turnsCount = Array.isArray(turns) ? turns.length : "";
232
+ const row = [
233
+ csvEscape(call.call_id || ""),
234
+ csvEscape(call.caller || ""),
235
+ csvEscape(call.callee || ""),
236
+ csvEscape(call.direction || ""),
237
+ call.started_at ?? "",
238
+ call.ended_at ?? "",
239
+ m.duration_seconds ?? "",
240
+ cost.total ?? "",
241
+ cost.stt ?? "",
242
+ cost.tts ?? "",
243
+ cost.llm ?? "",
244
+ cost.telephony ?? "",
245
+ latencyAvg.total_ms ?? "",
246
+ turnsCount,
247
+ m.provider_mode ?? ""
248
+ ];
249
+ rows.push(row.map(String).join(","));
250
+ }
251
+ return rows.join("\n") + "\n";
252
+ }
253
+ function callsToJson(calls) {
254
+ return JSON.stringify(calls);
255
+ }
256
+ function csvEscape(value) {
257
+ if (value.includes(",") || value.includes('"') || value.includes("\n")) {
258
+ return `"${value.replace(/"/g, '""')}"`;
259
+ }
260
+ return value;
261
+ }
262
+
263
+ // src/dashboard/ui.ts
264
+ var DASHBOARD_HTML = `<!DOCTYPE html>
265
+ <html lang="en">
266
+ <head>
267
+ <meta charset="utf-8">
268
+ <meta name="viewport" content="width=device-width, initial-scale=1">
269
+ <title>Patter | Dashboard</title>
270
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1188 1773' fill='none'%3E%3Cstyle%3Epath%7Bstroke:%2309090b%7D@media(prefers-color-scheme:dark)%7Bpath%7Bstroke:%23e4e4e7%7D%7D%3C/style%3E%3Cpath d='M25 561L245 694M25 561V818M245 694V951M25 961V1218M25 1357V1614M245 1489V1747M245 1093V1351M942 823V1080M1161 955V1213M1162 555V812M942 422V679M669 585V843L787 913M942 25V282M1162 158V415M25 818L245 951M244 1094L464 962M25 961L143 890M244 1352L464 1219M942 823L1162 956M942 679L1162 812M721 811L942 679M669 842L724 809M669 586L724 553M1041 883L1162 812M245 1747L1161 1213M244 1490L942 1080M25 1357L142 1289M518 1071L942 823M721 555L942 422M942 422L1162 556M942 282L1162 415M942 25L1162 158M942 1080L1161 1213M25 1218L245 1351M25 961L245 1094M464 962L519 929M464 1219L519 1186V928L403 859M25 1357L245 1490M25 1614L245 1747M25 561L942 25M244 694L941 282M1043 484L1162 415M245 951L668 704' stroke-width='50' stroke-linecap='round'/%3E%3C/svg%3E">
271
+ <link rel="preconnect" href="https://fonts.googleapis.com">
272
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
273
+ <link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
274
+ <style>
275
+ :root {
276
+ --bg: #fdfcfc;
277
+ --fg: #09090b;
278
+ --card: #ffffff;
279
+ --primary: #18181b;
280
+ --primary-fg: #fafafa;
281
+ --secondary: #f4f4f5;
282
+ --muted: #71717b;
283
+ --border: #e4e4e7;
284
+ --border-d: #d4d4d8;
285
+ --green: #22c55e;
286
+ --red: #ef4444;
287
+ --blue: #3b82f6;
288
+ --purple: #a78bfa;
289
+ --orange: #fb923c;
290
+ --yellow: #eab308;
291
+ --radius: 12px;
292
+ --font: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif;
293
+ --mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
294
+ --header-bg: #fff;
295
+ --assistant-bubble: #f0eeff;
296
+ }
297
+ @media (prefers-color-scheme: dark) {
298
+ :root {
299
+ --bg: #151518;
300
+ --fg: #e4e4e7;
301
+ --card: #1c1c21;
302
+ --primary: #e4e4e7;
303
+ --primary-fg: #18181b;
304
+ --secondary: #232329;
305
+ --muted: #8b8b95;
306
+ --border: #2c2c33;
307
+ --border-d: #3a3a44;
308
+ --green: #34d399;
309
+ --red: #f87171;
310
+ --blue: #60a5fa;
311
+ --purple: #c4b5fd;
312
+ --orange: #fdba74;
313
+ --yellow: #fbbf24;
314
+ --header-bg: #1a1a1f;
315
+ --assistant-bubble: #252230;
316
+ }
317
+ }
318
+ * { margin:0; padding:0; box-sizing:border-box; }
319
+ html { -webkit-font-smoothing: antialiased; }
320
+ body {
321
+ font-family: var(--font);
322
+ font-size: 15px;
323
+ line-height: 1.6;
324
+ color: var(--fg);
325
+ background: var(--bg);
326
+ min-height: 100vh;
327
+ }
328
+
329
+ /* Header */
330
+ header {
331
+ position: sticky; top: 0; z-index: 100;
332
+ background: var(--header-bg);
333
+ border-bottom: 1px solid var(--border);
334
+ padding: 0 24px;
335
+ height: 56px;
336
+ display: flex; align-items: center; gap: 14px;
337
+ }
338
+ .logo {
339
+ display: flex; align-items: center; gap: 10px;
340
+ font-weight: 700; font-size: 18px; letter-spacing: -0.02em;
341
+ text-decoration: none; color: var(--fg);
342
+ }
343
+ .logo svg { width: 22px; height: 22px; }
344
+ .header-sep {
345
+ width: 1px; height: 20px; background: var(--border-d); margin: 0 2px;
346
+ }
347
+ .header-title {
348
+ font-size: 14px; font-weight: 500; color: var(--muted);
349
+ }
350
+ .badge-beta {
351
+ font-size: 10px; font-weight: 600; letter-spacing: 0.5px;
352
+ color: #e67e22; background: rgba(230,126,34,0.1);
353
+ border: 1px solid rgba(230,126,34,0.25);
354
+ padding: 2px 8px; border-radius: 100px; text-transform: uppercase;
355
+ }
356
+ .status {
357
+ margin-left: auto; font-size: 13px; color: var(--muted);
358
+ display: flex; align-items: center; gap: 6px;
359
+ }
360
+ .dot {
361
+ width: 7px; height: 7px; border-radius: 50%;
362
+ background: var(--green); display: inline-block;
363
+ }
364
+
365
+ /* Layout */
366
+ .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
367
+
368
+ /* Stat cards */
369
+ .cards {
370
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
371
+ gap: 14px; margin-bottom: 28px;
372
+ }
373
+ .card {
374
+ background: var(--card);
375
+ border: 1px solid var(--border);
376
+ border-radius: var(--radius);
377
+ padding: 18px 20px;
378
+ }
379
+ .card .label {
380
+ font-size: 12px; color: var(--muted);
381
+ text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500;
382
+ }
383
+ .card .value {
384
+ font-size: 28px; font-weight: 700; margin-top: 4px;
385
+ font-family: var(--mono); letter-spacing: -0.02em;
386
+ }
387
+ .card .sub { font-size: 12px; color: var(--muted); margin-top: 2px; }
388
+
389
+ /* Tabs */
390
+ .nav-tabs {
391
+ display: flex; gap: 0; margin-bottom: 16px;
392
+ border-bottom: 1px solid var(--border);
393
+ }
394
+ .nav-tab {
395
+ padding: 10px 20px; font-size: 13px; font-weight: 500;
396
+ color: var(--muted); cursor: pointer;
397
+ border: none; background: none;
398
+ border-bottom: 2px solid transparent;
399
+ margin-bottom: -1px; font-family: var(--font);
400
+ transition: color .15s;
401
+ }
402
+ .nav-tab:hover { color: var(--fg); }
403
+ .nav-tab.active { color: var(--fg); border-bottom-color: var(--primary); }
404
+
405
+ .tab-content { display: none; }
406
+ .tab-content.active { display: block; }
407
+
408
+ /* Tables */
409
+ table {
410
+ width: 100%; border-collapse: collapse;
411
+ background: var(--card);
412
+ border: 1px solid var(--border);
413
+ border-radius: var(--radius);
414
+ overflow: hidden;
415
+ }
416
+ th {
417
+ text-align: left; font-size: 11px; text-transform: uppercase;
418
+ color: var(--muted); padding: 12px 16px;
419
+ border-bottom: 1px solid var(--border);
420
+ letter-spacing: 0.5px; font-weight: 600;
421
+ background: var(--secondary);
422
+ }
423
+ td {
424
+ padding: 12px 16px; border-bottom: 1px solid var(--border);
425
+ font-size: 13px;
426
+ }
427
+ tr:last-child td { border-bottom: none; }
428
+ tr.clickable { cursor: pointer; transition: background .1s; }
429
+ tr.clickable:hover { background: var(--secondary); }
430
+
431
+ code {
432
+ font-family: var(--mono); font-size: 12px;
433
+ background: var(--secondary); padding: 2px 6px;
434
+ border-radius: 4px;
435
+ }
436
+
437
+ /* Badges */
438
+ .badge {
439
+ display: inline-block; padding: 3px 10px; border-radius: 100px;
440
+ font-size: 11px; font-weight: 600;
441
+ }
442
+ .badge-active { background: rgba(34,197,94,0.1); color: #16a34a; }
443
+ .badge-ended { background: var(--secondary); color: var(--muted); }
444
+ .badge-pipeline { background: rgba(167,139,250,0.1); color: #7c3aed; }
445
+ .badge-realtime { background: rgba(59,130,246,0.1); color: #2563eb; }
446
+
447
+ .cost { color: #16a34a; font-family: var(--mono); font-size: 13px; }
448
+ .latency { color: #ca8a04; font-family: var(--mono); font-size: 13px; }
449
+ @media (prefers-color-scheme: dark) {
450
+ .cost { color: var(--green); }
451
+ .latency { color: var(--yellow); }
452
+ code { background: var(--secondary); color: var(--fg); }
453
+ }
454
+ .empty {
455
+ text-align: center; padding: 48px; color: var(--muted);
456
+ font-size: 14px;
457
+ }
458
+
459
+ /* Modal */
460
+ .modal-overlay {
461
+ display: none; position: fixed; inset: 0;
462
+ background: rgba(0,0,0,0.4); backdrop-filter: blur(6px);
463
+ z-index: 200;
464
+ justify-content: center; align-items: flex-start;
465
+ padding: 48px 20px; overflow-y: auto;
466
+ }
467
+ .modal-overlay.open { display: flex; }
468
+ .modal {
469
+ background: var(--card);
470
+ border: 1px solid var(--border);
471
+ border-radius: 16px;
472
+ max-width: 820px; width: 100%;
473
+ padding: 0;
474
+ box-shadow: 0 24px 64px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.03);
475
+ overflow: hidden;
476
+ }
477
+ .modal-header {
478
+ display: flex; justify-content: space-between; align-items: center;
479
+ padding: 20px 28px;
480
+ border-bottom: 1px solid var(--border);
481
+ background: var(--bg);
482
+ }
483
+ .modal-header h2 { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
484
+ .modal-close {
485
+ background: none; border: 1px solid var(--border);
486
+ color: var(--muted); width: 30px; height: 30px;
487
+ border-radius: 8px; font-size: 16px; cursor: pointer;
488
+ display: flex; align-items: center; justify-content: center;
489
+ transition: all .15s;
490
+ }
491
+ .modal-close:hover { background: var(--secondary); color: var(--fg); }
492
+ .modal-body { padding: 24px 28px; }
493
+
494
+ .detail-grid {
495
+ display: grid; grid-template-columns: 1fr 1fr;
496
+ gap: 14px; margin-bottom: 20px;
497
+ }
498
+ .detail-card {
499
+ background: var(--bg);
500
+ border: 1px solid var(--border);
501
+ border-radius: var(--radius); padding: 16px 18px;
502
+ }
503
+ .detail-card h3 {
504
+ font-size: 11px; color: var(--muted);
505
+ text-transform: uppercase; letter-spacing: 0.5px;
506
+ margin-bottom: 10px; font-weight: 600;
507
+ }
508
+ .detail-row {
509
+ display: flex; justify-content: space-between; align-items: baseline;
510
+ font-size: 13px; padding: 5px 0;
511
+ }
512
+ .detail-row .k { color: var(--muted); font-weight: 500; }
513
+ .detail-row span:last-child { font-weight: 500; text-align: right; }
514
+ .detail-row .mono { font-family: var(--mono); font-size: 12px; }
515
+ .detail-sep {
516
+ border-top: 1px solid var(--border); padding-top: 8px; margin-top: 6px;
517
+ }
518
+
519
+ .transcript-box {
520
+ border: 1px solid var(--border);
521
+ border-radius: var(--radius);
522
+ padding: 16px; max-height: 340px; overflow-y: auto;
523
+ background: var(--bg);
524
+ }
525
+ .transcript-box .msg {
526
+ padding: 8px 12px; border-radius: 10px; font-size: 13px;
527
+ max-width: 85%; margin-bottom: 6px; line-height: 1.5;
528
+ }
529
+ .transcript-box .msg.user {
530
+ background: var(--secondary); margin-left: auto;
531
+ border-bottom-right-radius: 4px;
532
+ }
533
+ .transcript-box .msg.assistant {
534
+ background: var(--assistant-bubble); margin-right: auto;
535
+ border-bottom-left-radius: 4px;
536
+ }
537
+ .transcript-box .role {
538
+ font-weight: 600; font-size: 11px; text-transform: uppercase;
539
+ letter-spacing: 0.3px; display: block; margin-bottom: 2px;
540
+ }
541
+ .transcript-box .msg.user .role { color: var(--blue); }
542
+ .transcript-box .msg.assistant .role { color: #7c3aed; }
543
+
544
+ /* Turn bars */
545
+ .turns-table { margin-top: 16px; }
546
+ .turns-table table { border: 1px solid var(--border); }
547
+ .bar-container { display: flex; height: 14px; border-radius: 4px; overflow: hidden; min-width: 120px; }
548
+ .bar-stt { background: var(--blue); }
549
+ .bar-llm { background: var(--purple); }
550
+ .bar-tts { background: var(--orange); }
551
+ </style>
552
+ </head>
553
+ <body>
554
+ <header>
555
+ <a href="/" class="logo">
556
+ <svg viewBox="0 0 1188 1773" fill="none" xmlns="http://www.w3.org/2000/svg">
557
+ <path d="M25 561L245 694M25 561V818M245 694V951M25 961V1218M25 1357V1614M245 1489V1747M245 1093V1351M942 823V1080M1161 955V1213M1162 555V812M942 422V679M669 585V843L787 913M942 25V282M1162 158V415M25 818L245 951M244 1094L464 962M25 961L143 890M244 1352L464 1219M942 823L1162 956M942 679L1162 812M721 811L942 679M669 842L724 809M669 586L724 553M1041 883L1162 812M245 1747L1161 1213M244 1490L942 1080M25 1357L142 1289M518 1071L942 823M721 555L942 422M942 422L1162 556M942 282L1162 415M942 25L1162 158M942 1080L1161 1213M25 1218L245 1351M25 961L245 1094M464 962L519 929M464 1219L519 1186V928L403 859M25 1357L245 1490M25 1614L245 1747M25 561L942 25M244 694L941 282M1043 484L1162 415M245 951L668 704" stroke="currentColor" stroke-width="50" stroke-linecap="round"/>
558
+ </svg>
559
+ Patter
560
+ </a>
561
+ <div class="header-sep"></div>
562
+ <span class="header-title">Dashboard</span>
563
+ <span class="badge-beta">Beta</span>
564
+ <div class="status"><span class="dot"></span> <span id="status-text">Listening</span></div>
565
+ </header>
566
+
567
+ <div class="container">
568
+ <div class="cards">
569
+ <div class="card">
570
+ <div class="label">Total Calls</div>
571
+ <div class="value" id="stat-total">0</div>
572
+ <div class="sub"><span id="stat-active">0</span> active</div>
573
+ </div>
574
+ <div class="card">
575
+ <div class="label">Total Cost</div>
576
+ <div class="value cost" id="stat-cost">$0.00</div>
577
+ <div class="sub" id="stat-cost-breakdown">-</div>
578
+ </div>
579
+ <div class="card">
580
+ <div class="label">Avg Duration</div>
581
+ <div class="value" id="stat-duration">0s</div>
582
+ </div>
583
+ <div class="card">
584
+ <div class="label">Avg Latency</div>
585
+ <div class="value latency" id="stat-latency">0ms</div>
586
+ <div class="sub">end-to-end response</div>
587
+ </div>
588
+ </div>
589
+
590
+ <div class="nav-tabs">
591
+ <button class="nav-tab active" data-tab="calls">Calls</button>
592
+ <button class="nav-tab" data-tab="active">Active</button>
593
+ </div>
594
+
595
+ <div class="tab-content active" id="tab-calls">
596
+ <div class="section">
597
+ <table id="calls-table">
598
+ <thead>
599
+ <tr>
600
+ <th>Call ID</th><th>Direction</th><th>From / To</th>
601
+ <th>Duration</th><th>Mode</th><th>Cost</th><th>Avg Latency</th><th>Turns</th>
602
+ </tr>
603
+ </thead>
604
+ <tbody id="calls-body">
605
+ <tr><td colspan="8" class="empty">No calls yet. Waiting for incoming calls...</td></tr>
606
+ </tbody>
607
+ </table>
608
+ </div>
609
+ </div>
610
+
611
+ <div class="tab-content" id="tab-active">
612
+ <div class="section">
613
+ <table>
614
+ <thead>
615
+ <tr><th>Call ID</th><th>Caller</th><th>Callee</th><th>Direction</th><th>Duration</th><th>Turns</th></tr>
616
+ </thead>
617
+ <tbody id="active-body">
618
+ <tr><td colspan="6" class="empty">No active calls</td></tr>
619
+ </tbody>
620
+ </table>
621
+ </div>
622
+ </div>
623
+ </div>
624
+
625
+ <div class="modal-overlay" id="modal">
626
+ <div class="modal">
627
+ <div class="modal-header">
628
+ <h2 id="modal-title">Call Detail</h2>
629
+ <button class="modal-close" onclick="closeModal()">&times;</button>
630
+ </div>
631
+ <div class="modal-body" id="modal-body"></div>
632
+ </div>
633
+ </div>
634
+
635
+ <script>
636
+ var _$ = function(s) { return document.querySelector(s); };
637
+ var _$$ = function(s) { return document.querySelectorAll(s); };
638
+
639
+ _$$('.nav-tab').forEach(function(tab) {
640
+ tab.addEventListener('click', function() {
641
+ _$$('.nav-tab').forEach(function(t) { t.classList.remove('active'); });
642
+ _$$('.tab-content').forEach(function(t) { t.classList.remove('active'); });
643
+ tab.classList.add('active');
644
+ document.querySelector('#tab-'+tab.dataset.tab).classList.add('active');
645
+ });
646
+ });
647
+
648
+ function esc(s) {
649
+ if (!s) return '';
650
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
651
+ }
652
+ function fmtCost(v) { return v >= 0.01 ? '$'+v.toFixed(4) : v > 0 ? '$'+v.toFixed(6) : '$0.00'; }
653
+ function fmtMs(v) { return v != null && v >= 0 ? Math.round(v)+'ms' : '-'; }
654
+ function fmtDur(s) {
655
+ if (s == null || s < 0) return '-';
656
+ if (s < 60) return Math.round(s)+'s';
657
+ return Math.floor(s/60)+'m '+Math.round(s%60)+'s';
658
+ }
659
+ function shortId(id) { return id ? esc(id.length > 16 ? id.slice(0,8)+'...'+id.slice(-4) : id) : '-'; }
660
+
661
+ function fetchJSON(url) {
662
+ return fetch(url).then(function(r) { return r.json(); });
663
+ }
664
+
665
+ function refreshAggregates() {
666
+ return fetchJSON('/api/dashboard/aggregates').then(function(d) {
667
+ _$('#stat-total').textContent = d.total_calls;
668
+ _$('#stat-active').textContent = d.active_calls;
669
+ _$('#stat-cost').textContent = fmtCost(d.total_cost);
670
+ var cb = d.cost_breakdown;
671
+ _$('#stat-cost-breakdown').textContent =
672
+ 'STT '+fmtCost(cb.stt)+' | LLM '+fmtCost(cb.llm)+' | TTS '+fmtCost(cb.tts)+' | Tel '+fmtCost(cb.telephony);
673
+ _$('#stat-duration').textContent = fmtDur(d.avg_duration);
674
+ _$('#stat-latency').textContent = fmtMs(d.avg_latency_ms);
675
+ });
676
+ }
677
+
678
+ function refreshCalls() {
679
+ return fetchJSON('/api/dashboard/calls?limit=50').then(function(calls) {
680
+ var body = _$('#calls-body');
681
+ if (!calls.length) {
682
+ body.innerHTML = '<tr><td colspan="8" class="empty">No calls yet. Waiting for incoming calls...</td></tr>';
683
+ return;
684
+ }
685
+ body.innerHTML = calls.map(function(c) {
686
+ var m = c.metrics || {};
687
+ var cost = m.cost || {};
688
+ var lat = m.latency_avg || {};
689
+ var mode = m.provider_mode || '-';
690
+ var turns = m.turns ? m.turns.length : 0;
691
+ var modeClass = mode === 'pipeline' ? 'badge-pipeline' : 'badge-realtime';
692
+ return '<tr class="clickable" onclick="showCall(\\''+esc(c.call_id)+'\\')">'+
693
+ '<td><code>'+shortId(c.call_id)+'</code></td>'+
694
+ '<td>'+(esc(c.direction) || '-')+'</td>'+
695
+ '<td>'+(esc(c.caller) || '-')+' &rarr; '+(esc(c.callee) || '-')+'</td>'+
696
+ '<td>'+fmtDur(m.duration_seconds)+'</td>'+
697
+ '<td><span class="badge '+modeClass+'">'+esc(mode)+'</span></td>'+
698
+ '<td class="cost">'+fmtCost(cost.total || 0)+'</td>'+
699
+ '<td class="latency">'+fmtMs(lat.total_ms || 0)+'</td>'+
700
+ '<td>'+turns+'</td></tr>';
701
+ }).join('');
702
+ });
703
+ }
704
+
705
+ function refreshActive() {
706
+ return fetchJSON('/api/dashboard/active').then(function(active) {
707
+ var body = _$('#active-body');
708
+ if (!active.length) {
709
+ body.innerHTML = '<tr><td colspan="6" class="empty">No active calls</td></tr>';
710
+ return;
711
+ }
712
+ var now = Date.now() / 1000;
713
+ body.innerHTML = active.map(function(c) {
714
+ var dur = c.started_at ? Math.round(now - c.started_at) : 0;
715
+ var turns = c.turns ? c.turns.length : 0;
716
+ return '<tr>'+
717
+ '<td><code>'+shortId(c.call_id)+'</code></td>'+
718
+ '<td>'+(esc(c.caller) || '-')+'</td>'+
719
+ '<td>'+(esc(c.callee) || '-')+'</td>'+
720
+ '<td>'+(esc(c.direction) || '-')+'</td>'+
721
+ '<td data-started="'+(c.started_at || 0)+'">'+fmtDur(dur)+'</td>'+
722
+ '<td>'+turns+'</td></tr>';
723
+ }).join('');
724
+ });
725
+ }
726
+
727
+ function showCall(callId) {
728
+ fetchJSON('/api/dashboard/calls/'+encodeURIComponent(callId)).then(function(c) {
729
+ if (c.error) return;
730
+ var m = c.metrics || {};
731
+ var cost = m.cost || {};
732
+ var latAvg = m.latency_avg || {};
733
+ var latP95 = m.latency_p95 || {};
734
+ var turns = m.turns || [];
735
+
736
+ var modeLabel = (m.provider_mode || '').replace(/_/g, ' ');
737
+ var modeBadgeClass = (m.provider_mode || '').indexOf('pipeline') !== -1 ? 'badge-pipeline' : 'badge-realtime';
738
+ _$('#modal-title').innerHTML = 'Call <code>'+shortId(c.call_id)+'</code> <span class="badge '+modeBadgeClass+'" style="font-size:10px">'+esc(modeLabel)+'</span>';
739
+
740
+ var isRealtime = (m.provider_mode || '').indexOf('realtime') !== -1;
741
+
742
+ var html = '<div class="detail-grid">'+
743
+ '<div class="detail-card">'+
744
+ '<h3>Overview</h3>'+
745
+ '<div class="detail-row"><span class="k">Direction</span><span>'+(esc(c.direction) || '-')+'</span></div>'+
746
+ '<div class="detail-row"><span class="k">From</span><span class="mono">'+(esc(c.caller) || '-')+'</span></div>'+
747
+ '<div class="detail-row"><span class="k">To</span><span class="mono">'+(esc(c.callee) || '-')+'</span></div>'+
748
+ '<div class="detail-row"><span class="k">Duration</span><span style="font-weight:600">'+fmtDur(m.duration_seconds)+'</span></div>'+
749
+ (isRealtime ? '' :
750
+ '<div class="detail-row"><span class="k">STT</span><span>'+(esc(m.stt_provider) || '-')+'</span></div>'+
751
+ '<div class="detail-row"><span class="k">TTS</span><span>'+(esc(m.tts_provider) || '-')+'</span></div>'+
752
+ '<div class="detail-row"><span class="k">LLM</span><span>'+(esc(m.llm_provider) || '-')+'</span></div>'
753
+ )+
754
+ '<div class="detail-row"><span class="k">Telephony</span><span>'+(esc(m.telephony_provider) || '-')+'</span></div>'+
755
+ '</div>'+
756
+ '<div class="detail-card">'+
757
+ '<h3>Cost</h3>'+
758
+ (isRealtime ?
759
+ '<div class="detail-row"><span class="k">OpenAI</span><span class="cost">'+fmtCost(cost.llm || 0)+'</span></div>' :
760
+ '<div class="detail-row"><span class="k">STT</span><span class="cost">'+fmtCost(cost.stt || 0)+'</span></div>'+
761
+ '<div class="detail-row"><span class="k">LLM</span><span class="cost">'+fmtCost(cost.llm || 0)+'</span></div>'+
762
+ '<div class="detail-row"><span class="k">TTS</span><span class="cost">'+fmtCost(cost.tts || 0)+'</span></div>'
763
+ )+
764
+ '<div class="detail-row"><span class="k">Telephony</span><span class="cost">'+fmtCost(cost.telephony || 0)+'</span></div>'+
765
+ '<div class="detail-row detail-sep">'+
766
+ '<span class="k" style="font-weight:600">Total</span><span class="cost" style="font-weight:700;font-size:14px">'+fmtCost(cost.total || 0)+'</span>'+
767
+ '</div>'+
768
+ '<h3 style="margin-top:16px">Latency <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--muted)">(avg / p95)</span></h3>'+
769
+ (isRealtime ? '' :
770
+ '<div class="detail-row"><span class="k">STT</span><span class="latency">'+fmtMs(latAvg.stt_ms)+' / '+fmtMs(latP95.stt_ms)+'</span></div>'+
771
+ '<div class="detail-row"><span class="k">LLM</span><span class="latency">'+fmtMs(latAvg.llm_ms)+' / '+fmtMs(latP95.llm_ms)+'</span></div>'+
772
+ '<div class="detail-row"><span class="k">TTS</span><span class="latency">'+fmtMs(latAvg.tts_ms)+' / '+fmtMs(latP95.tts_ms)+'</span></div>'
773
+ )+
774
+ '<div class="detail-row"><span class="k">'+(isRealtime ? 'End-to-end' : 'Total')+'</span><span class="latency" style="font-weight:700;font-size:14px">'+fmtMs(latAvg.total_ms)+' / '+fmtMs(latP95.total_ms)+'</span></div>'+
775
+ '</div></div>';
776
+
777
+ if (turns.length) {
778
+ var maxMs = Math.max.apply(null, turns.map(function(t) {
779
+ var l = t.latency || {};
780
+ return (l.stt_ms||0) + (l.llm_ms||0) + (l.tts_ms||0) + (l.total_ms||0);
781
+ }).concat([1]));
782
+ html += '<div class="detail-card turns-table"><h3>Turns ('+turns.length+')</h3>'+
783
+ '<table><thead><tr><th>#</th><th>User</th><th>Agent</th><th>Latency</th><th>Breakdown</th></tr></thead><tbody>';
784
+ turns.forEach(function(t, i) {
785
+ var l = t.latency || {};
786
+ var total = l.total_ms || ((l.stt_ms||0) + (l.llm_ms||0) + (l.tts_ms||0));
787
+ var scale = total > 0 ? 120 / maxMs : 0;
788
+ var sttW = (l.stt_ms||0) * scale;
789
+ var llmW = (l.llm_ms||0) * scale;
790
+ var ttsW = (l.tts_ms||0) * scale;
791
+ var totalW = total > 0 && sttW === 0 && llmW === 0 && ttsW === 0 ? total * scale : 0;
792
+ html += '<tr>'+
793
+ '<td>'+(t.turn_index !== undefined ? t.turn_index : i)+'</td>'+
794
+ '<td title="'+esc(t.user_text||'')+'">'+esc((t.user_text||'').slice(0,40))+((t.user_text||'').length>40?'...':'')+'</td>'+
795
+ '<td title="'+esc(t.agent_text||'')+'">'+esc((t.agent_text||'').slice(0,40))+((t.agent_text||'').length>40?'...':'')+'</td>'+
796
+ '<td class="latency">'+fmtMs(total)+'</td>'+
797
+ '<td><div class="bar-container">'+
798
+ (sttW > 0 ? '<div class="bar-stt" style="width:'+sttW+'px" title="STT '+fmtMs(l.stt_ms)+'"></div>' : '')+
799
+ (llmW > 0 ? '<div class="bar-llm" style="width:'+llmW+'px" title="LLM '+fmtMs(l.llm_ms)+'"></div>' : '')+
800
+ (ttsW > 0 ? '<div class="bar-tts" style="width:'+ttsW+'px" title="TTS '+fmtMs(l.tts_ms)+'"></div>' : '')+
801
+ (totalW > 0 ? '<div class="bar-llm" style="width:'+totalW+'px" title="Total '+fmtMs(total)+'"></div>' : '')+
802
+ '</div></td></tr>';
803
+ });
804
+ html += '</tbody></table>'+
805
+ '<div style="margin-top:10px;font-size:11px;color:var(--muted)">'+
806
+ (isRealtime ?
807
+ '<span style="color:var(--purple)">&#9632;</span> End-to-end' :
808
+ '<span style="color:var(--blue)">&#9632;</span> STT &nbsp;'+
809
+ '<span style="color:var(--purple)">&#9632;</span> LLM &nbsp;'+
810
+ '<span style="color:var(--orange)">&#9632;</span> TTS'
811
+ )+
812
+ '</div></div>';
813
+ }
814
+
815
+ var transcript = c.transcript || [];
816
+ if (transcript.length) {
817
+ html += '<div class="detail-card" style="margin-top:16px"><h3>Transcript</h3><div class="transcript-box">';
818
+ transcript.forEach(function(msg) {
819
+ var role = esc(msg.role || 'unknown');
820
+ html += '<div class="msg '+role+'"><span class="role">'+role+'</span>'+esc(msg.text || '')+'</div>';
821
+ });
822
+ html += '</div></div>';
823
+ }
824
+
825
+ _$('#modal-body').innerHTML = html;
826
+ _$('#modal').classList.add('open');
827
+ });
828
+ }
829
+
830
+ function closeModal() { _$('#modal').classList.remove('open'); }
831
+ _$('#modal').addEventListener('click', function(e) { if (e.target === _$('#modal')) closeModal(); });
832
+ document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeModal(); });
833
+
834
+ function refresh() {
835
+ return Promise.all([refreshAggregates(), refreshCalls(), refreshActive()]).then(function() {
836
+ _$('#status-text').textContent = 'Listening';
837
+ }).catch(function() {
838
+ _$('#status-text').textContent = 'Connection error';
839
+ });
840
+ }
841
+
842
+ refresh();
843
+
844
+ // Update active call durations every second
845
+ setInterval(function() {
846
+ var cells = document.querySelectorAll('#active-body td[data-started]');
847
+ if (!cells.length) return;
848
+ var now = Date.now() / 1000;
849
+ cells.forEach(function(td) {
850
+ var started = parseFloat(td.getAttribute('data-started'));
851
+ if (started) td.textContent = fmtDur(Math.round(now - started));
852
+ });
853
+ }, 1000);
854
+
855
+ if (typeof EventSource !== 'undefined') {
856
+ var sseUrl = '/api/dashboard/events';
857
+ var sseBackoff = 1000;
858
+ var sseFailures = 0;
859
+ var SSE_MAX_BACKOFF = 30000;
860
+ var SSE_MAX_FAILURES = 5;
861
+
862
+ function connectSSE() {
863
+ var es = new EventSource(sseUrl);
864
+ function onEvent() { sseBackoff = 1000; sseFailures = 0; }
865
+ es.addEventListener('call_start', function() { onEvent(); refresh(); });
866
+ es.addEventListener('turn_complete', function() { onEvent(); refreshAggregates(); });
867
+ es.addEventListener('call_end', function() { onEvent(); refresh(); });
868
+ es.onerror = function() {
869
+ es.close();
870
+ sseFailures++;
871
+ if (sseFailures >= SSE_MAX_FAILURES) {
872
+ _$('#status-text').textContent = 'Polling';
873
+ setInterval(refresh, 5000);
874
+ return;
875
+ }
876
+ _$('#status-text').textContent = 'Reconnecting...';
877
+ setTimeout(connectSSE, sseBackoff);
878
+ sseBackoff = Math.min(sseBackoff * 2, SSE_MAX_BACKOFF);
879
+ };
880
+ }
881
+ connectSSE();
882
+ } else {
883
+ setInterval(refresh, 3000);
884
+ }
885
+ </script>
886
+ </body>
887
+ </html>`;
888
+
889
+ // src/dashboard/routes.ts
890
+ function mountDashboard(app, store, token = "") {
891
+ const auth = makeAuthMiddleware(token);
892
+ app.get("/", auth, (_req, res) => {
893
+ res.type("text/html").send(DASHBOARD_HTML);
894
+ });
895
+ app.get("/api/dashboard/calls", auth, (req, res) => {
896
+ const limit = Math.min(parseInt(req.query.limit || "50", 10) || 50, 1e3);
897
+ const offset = parseInt(req.query.offset || "0", 10) || 0;
898
+ res.json(store.getCalls(limit, offset));
899
+ });
900
+ app.get("/api/dashboard/calls/:callId", auth, (req, res) => {
901
+ const call = store.getCall(String(req.params.callId));
902
+ if (!call) {
903
+ res.status(404).json({ error: "Not found" });
904
+ return;
905
+ }
906
+ res.json(call);
907
+ });
908
+ app.get("/api/dashboard/active", auth, (_req, res) => {
909
+ res.json(store.getActiveCalls());
910
+ });
911
+ app.get("/api/dashboard/aggregates", auth, (_req, res) => {
912
+ res.json(store.getAggregates());
913
+ });
914
+ app.get("/api/dashboard/events", auth, (req, res) => {
915
+ res.writeHead(200, {
916
+ "Content-Type": "text/event-stream",
917
+ "Cache-Control": "no-cache",
918
+ "Connection": "keep-alive"
919
+ });
920
+ const listener = (event) => {
921
+ const data = JSON.stringify(event.data);
922
+ const safeType = String(event.type ?? "message").replace(/[\r\n]/g, "");
923
+ res.write(`event: ${safeType}
924
+ data: ${data}
925
+
926
+ `);
927
+ };
928
+ store.on("sse", listener);
929
+ const keepalive = setInterval(() => {
930
+ res.write(": keepalive\n\n");
931
+ }, 3e4);
932
+ req.on("close", () => {
933
+ clearInterval(keepalive);
934
+ store.off("sse", listener);
935
+ });
936
+ });
937
+ app.get("/api/dashboard/export/calls", auth, (req, res) => {
938
+ const fmt = req.query.format || "json";
939
+ const fromDate = req.query.from || "";
940
+ const toDate = req.query.to || "";
941
+ let fromTs = 0;
942
+ let toTs = 0;
943
+ if (fromDate) {
944
+ const d = new Date(fromDate);
945
+ if (!isNaN(d.getTime())) fromTs = d.getTime() / 1e3;
946
+ }
947
+ if (toDate) {
948
+ const d = new Date(toDate);
949
+ if (!isNaN(d.getTime())) toTs = d.getTime() / 1e3;
950
+ }
951
+ const calls = fromTs || toTs ? store.getCallsInRange(fromTs, toTs) : store.getCalls(1e4);
952
+ if (fmt === "csv") {
953
+ const csvData = callsToCsv(calls);
954
+ res.setHeader("Content-Type", "text/csv");
955
+ res.setHeader("Content-Disposition", "attachment; filename=patter_calls.csv");
956
+ res.send(csvData);
957
+ } else {
958
+ const jsonData = callsToJson(calls);
959
+ res.setHeader("Content-Type", "application/json");
960
+ res.setHeader("Content-Disposition", "attachment; filename=patter_calls.json");
961
+ res.send(jsonData);
962
+ }
963
+ });
964
+ }
965
+ function mountApi(app, store, token = "") {
966
+ const auth = makeAuthMiddleware(token);
967
+ app.get("/api/v1/calls", auth, (req, res) => {
968
+ const limit = Math.min(parseInt(req.query.limit || "50", 10) || 50, 1e3);
969
+ const offset = parseInt(req.query.offset || "0", 10) || 0;
970
+ const calls = store.getCalls(limit, offset);
971
+ res.json({
972
+ data: calls,
973
+ pagination: {
974
+ limit,
975
+ offset,
976
+ count: calls.length,
977
+ total: store.callCount
978
+ }
979
+ });
980
+ });
981
+ app.get("/api/v1/calls/active", auth, (_req, res) => {
982
+ const active = store.getActiveCalls();
983
+ res.json({ data: active, count: active.length });
984
+ });
985
+ app.get("/api/v1/calls/:callId", auth, (req, res) => {
986
+ const call = store.getCall(String(req.params.callId));
987
+ if (!call) {
988
+ res.status(404).json({ error: "Call not found" });
989
+ return;
990
+ }
991
+ res.json({ data: call });
992
+ });
993
+ app.get("/api/v1/analytics/overview", auth, (_req, res) => {
994
+ res.json({ data: store.getAggregates() });
995
+ });
996
+ app.get("/api/v1/analytics/costs", auth, (req, res) => {
997
+ const fromDate = req.query.from || "";
998
+ const toDate = req.query.to || "";
999
+ let fromTs = 0;
1000
+ let toTs = 0;
1001
+ if (fromDate) {
1002
+ const d = new Date(fromDate);
1003
+ if (!isNaN(d.getTime())) fromTs = d.getTime() / 1e3;
1004
+ }
1005
+ if (toDate) {
1006
+ const d = new Date(toDate);
1007
+ if (!isNaN(d.getTime())) toTs = d.getTime() / 1e3;
1008
+ }
1009
+ const calls = fromTs || toTs ? store.getCallsInRange(fromTs, toTs) : store.getCalls(1e4);
1010
+ let totalCost = 0;
1011
+ let costStt = 0;
1012
+ let costTts = 0;
1013
+ let costLlm = 0;
1014
+ let costTelephony = 0;
1015
+ let callsWithCost = 0;
1016
+ for (const call of calls) {
1017
+ const m = call.metrics;
1018
+ if (!m) continue;
1019
+ const cost = m.cost || {};
1020
+ totalCost += cost.total || 0;
1021
+ costStt += cost.stt || 0;
1022
+ costTts += cost.tts || 0;
1023
+ costLlm += cost.llm || 0;
1024
+ costTelephony += cost.telephony || 0;
1025
+ callsWithCost++;
1026
+ }
1027
+ res.json({
1028
+ data: {
1029
+ total_cost: Math.round(totalCost * 1e6) / 1e6,
1030
+ breakdown: {
1031
+ stt: Math.round(costStt * 1e6) / 1e6,
1032
+ tts: Math.round(costTts * 1e6) / 1e6,
1033
+ llm: Math.round(costLlm * 1e6) / 1e6,
1034
+ telephony: Math.round(costTelephony * 1e6) / 1e6
1035
+ },
1036
+ calls_analyzed: callsWithCost,
1037
+ period: {
1038
+ from: fromDate || null,
1039
+ to: toDate || null
1040
+ }
1041
+ }
1042
+ });
1043
+ });
1044
+ }
1045
+
1046
+ // src/logger.ts
1047
+ var defaultLogger = {
1048
+ info: (msg, ...args) => console.log(`[PATTER] ${msg}`, ...args),
1049
+ warn: (msg, ...args) => console.warn(`[PATTER] WARNING: ${msg}`, ...args),
1050
+ error: (msg, ...args) => console.error(`[PATTER] ERROR: ${msg}`, ...args),
1051
+ debug: () => {
1052
+ }
1053
+ };
1054
+ var currentLogger = defaultLogger;
1055
+ function getLogger() {
1056
+ return currentLogger;
1057
+ }
1058
+
1059
+ // src/cli.ts
1060
+ var BANNER = `
1061
+ \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557
1062
+ \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557
1063
+ \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D
1064
+ \u2588\u2588\u2554\u2550\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557
1065
+ \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551
1066
+ \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D
1067
+
1068
+ Connect AI agents to phone numbers in 4 lines of code
1069
+ `;
1070
+ function parseArgs(argv) {
1071
+ const args = argv.slice(2);
1072
+ let port = 8e3;
1073
+ for (let i = 0; i < args.length; i++) {
1074
+ if (args[i] === "dashboard") continue;
1075
+ if (args[i] === "--port" && args[i + 1]) {
1076
+ port = parseInt(args[i + 1], 10);
1077
+ i++;
1078
+ } else if (args[i] === "--help" || args[i] === "-h") {
1079
+ console.log("Usage: getpatter dashboard [--port 8000]");
1080
+ process.exit(0);
1081
+ }
1082
+ }
1083
+ return { port };
1084
+ }
1085
+ async function main() {
1086
+ const command = process.argv[2];
1087
+ if (command !== "dashboard") {
1088
+ console.log("Usage: getpatter dashboard [--port 8000]");
1089
+ process.exit(command ? 1 : 0);
1090
+ }
1091
+ const { port } = parseArgs(process.argv);
1092
+ console.log(BANNER);
1093
+ const store = new MetricsStore();
1094
+ console.log(` Dashboard: http://localhost:${port}/`);
1095
+ console.log(` API: http://localhost:${port}/api/v1/calls`);
1096
+ console.log();
1097
+ console.log(" Waiting for calls\u2026 Press Ctrl+C to stop.\n");
1098
+ const app = (0, import_express.default)();
1099
+ app.use(import_express.default.json());
1100
+ mountDashboard(app, store);
1101
+ mountApi(app, store);
1102
+ app.get("/health", (_req, res) => {
1103
+ res.json({ status: "ok", mode: "dashboard" });
1104
+ });
1105
+ app.post("/api/dashboard/ingest", (req, res) => {
1106
+ const data = req.body;
1107
+ const callId = data.call_id || "";
1108
+ if (!callId) {
1109
+ res.json({ ok: false, error: "missing call_id" });
1110
+ return;
1111
+ }
1112
+ store.recordCallStart(data);
1113
+ if (data.ended_at) {
1114
+ store.recordCallEnd(data, data.metrics ?? null);
1115
+ }
1116
+ res.json({ ok: true, call_id: callId });
1117
+ });
1118
+ const server = (0, import_node_http.createServer)(app);
1119
+ const connections = /* @__PURE__ */ new Set();
1120
+ server.on("connection", (conn) => {
1121
+ connections.add(conn);
1122
+ conn.on("close", () => connections.delete(conn));
1123
+ });
1124
+ server.listen(port, "127.0.0.1", () => {
1125
+ getLogger().info(`Dashboard server listening on port ${port}`);
1126
+ });
1127
+ const shutdown = () => {
1128
+ console.log("\nShutting down dashboard...");
1129
+ for (const conn of connections) conn.destroy();
1130
+ server.close(() => process.exit(0));
1131
+ setTimeout(() => process.exit(0), 1e3);
1132
+ };
1133
+ process.on("SIGINT", shutdown);
1134
+ process.on("SIGTERM", shutdown);
1135
+ }
1136
+ main().catch((err) => {
1137
+ console.error("Failed to start dashboard:", err);
1138
+ process.exit(1);
1139
+ });