@occasiolabs/occasio 0.8.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.
Files changed (92) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +216 -0
  4. package/bin/occasio-mcp.js +5 -0
  5. package/bin/occasio.js +2 -0
  6. package/bin/supervisor/README.md +90 -0
  7. package/bin/supervisor/com.occasio.proxy.plist.template +36 -0
  8. package/bin/supervisor/install-windows-task.ps1 +48 -0
  9. package/bin/supervisor/occasio.service +18 -0
  10. package/docs/AUDIT.md +120 -0
  11. package/docs/attest_verify.py +283 -0
  12. package/docs/audit_walker.py +65 -0
  13. package/docs/canonicalize.py +99 -0
  14. package/docs/compliance-mapping.md +93 -0
  15. package/docs/demos/mcp-block.md +148 -0
  16. package/docs/edr-calibration.md +73 -0
  17. package/docs/edr-demo.md +83 -0
  18. package/docs/python-verifier.md +74 -0
  19. package/docs/reference-pipeline.md +140 -0
  20. package/package.json +69 -0
  21. package/policy-templates/dev-default.yml +84 -0
  22. package/policy-templates/finance.yml +61 -0
  23. package/policy-templates/strict.yml +49 -0
  24. package/schemas/agent-attestation-v1.json +190 -0
  25. package/schemas/occasio-policy.schema.json +99 -0
  26. package/spec/agent-attestation/v1/README.md +137 -0
  27. package/src/adapters/claude-code.js +518 -0
  28. package/src/adapters/cline.js +161 -0
  29. package/src/adapters/computer-use-cli.js +198 -0
  30. package/src/adapters/computer-use.js +227 -0
  31. package/src/analyzer.js +170 -0
  32. package/src/anomaly/cli.js +143 -0
  33. package/src/anomaly/detectors/deny-rate.js +84 -0
  34. package/src/anomaly/detectors/file-read-volume.js +109 -0
  35. package/src/anomaly/detectors/secret-redact-rate.js +107 -0
  36. package/src/anomaly/detectors/unknown-tool-input.js +83 -0
  37. package/src/anomaly/index.js +169 -0
  38. package/src/attest/canonicalize.js +97 -0
  39. package/src/attest/index.js +355 -0
  40. package/src/attest/run-slice.js +57 -0
  41. package/src/attest/sign.js +186 -0
  42. package/src/attest/verify.js +192 -0
  43. package/src/audit/errors.js +21 -0
  44. package/src/audit/input-normalizer.js +121 -0
  45. package/src/audit/jsonl-auditor.js +178 -0
  46. package/src/audit/verifier.js +152 -0
  47. package/src/baseline.js +507 -0
  48. package/src/boundary.js +238 -0
  49. package/src/budget.js +42 -0
  50. package/src/classifier.js +115 -0
  51. package/src/context-budget.js +77 -0
  52. package/src/core/boundary-event.js +75 -0
  53. package/src/core/decision.js +61 -0
  54. package/src/core/pipeline.js +66 -0
  55. package/src/core/tool-names.js +105 -0
  56. package/src/dashboard.js +892 -0
  57. package/src/demo/README.md +31 -0
  58. package/src/demo/anomalies-demo.js +211 -0
  59. package/src/demo/attest-demo.js +198 -0
  60. package/src/distiller.js +155 -0
  61. package/src/embeddings.json +72 -0
  62. package/src/executor/dispatcher.js +230 -0
  63. package/src/harness.js +817 -0
  64. package/src/index.js +1711 -0
  65. package/src/inspect.js +329 -0
  66. package/src/interceptor.js +1198 -0
  67. package/src/lao.js +185 -0
  68. package/src/lao_prep.py +119 -0
  69. package/src/ledger.js +209 -0
  70. package/src/mcp-experiment.js +140 -0
  71. package/src/mcp-normalize.js +139 -0
  72. package/src/mcp-server.js +320 -0
  73. package/src/outbound-policy.js +433 -0
  74. package/src/policy/built-in-classifiers.js +78 -0
  75. package/src/policy/doctor.js +226 -0
  76. package/src/policy/engine.js +339 -0
  77. package/src/policy/init.js +153 -0
  78. package/src/policy/loader.js +448 -0
  79. package/src/policy/rules-default.js +36 -0
  80. package/src/policy/shell-path.js +135 -0
  81. package/src/policy/show.js +196 -0
  82. package/src/policy/validate.js +310 -0
  83. package/src/preflight/cli.js +164 -0
  84. package/src/preflight/miner.js +329 -0
  85. package/src/proxy/agent-router.js +93 -0
  86. package/src/redteam.js +428 -0
  87. package/src/replay.js +446 -0
  88. package/src/report/index.js +224 -0
  89. package/src/runtime.js +595 -0
  90. package/src/scanner/index.js +49 -0
  91. package/src/selftest.js +192 -0
  92. package/src/session.js +36 -0
@@ -0,0 +1,892 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * dashboard.js — Occasio web dashboard on port 3001.
5
+ *
6
+ * Serves a single-page HTML dashboard that receives live updates
7
+ * via Server-Sent Events pushed from the proxy's /api/session endpoint.
8
+ *
9
+ * Usage (standalone): node dashboard.js
10
+ * Usage (sidecar): spawned by index.js when --dashboard flag is set
11
+ */
12
+
13
+ const http = require('http');
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const os = require('os');
17
+
18
+ const DASHBOARD_PORT = 3001;
19
+ const PROXY_PORT = 8081;
20
+ const LOG_DIR = path.join(os.homedir(), '.occasio');
21
+ const SESSION_FILE = path.join(LOG_DIR, 'session.json');
22
+
23
+ function todayLogFile() {
24
+ const d = new Date();
25
+ const ds = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
26
+ return path.join(LOG_DIR, 'logs', `${ds}.jsonl`);
27
+ }
28
+
29
+ // ── SSE clients ────────────────────────────────────────────────────────────────
30
+
31
+ const clients = new Set();
32
+
33
+ function broadcast(data) {
34
+ const payload = `data: ${JSON.stringify(data)}\n\n`;
35
+ for (const res of clients) {
36
+ try { res.write(payload); } catch { clients.delete(res); }
37
+ }
38
+ }
39
+
40
+ // Poll session.json every second and push diffs to SSE clients
41
+ let lastSession = null;
42
+ setInterval(() => {
43
+ try {
44
+ const raw = fs.readFileSync(SESSION_FILE, 'utf8');
45
+ if (raw === lastSession) return;
46
+ lastSession = raw;
47
+ const session = JSON.parse(raw);
48
+ const entries = readLog();
49
+ broadcast({ type: 'update', session, entries });
50
+ } catch { /* file may not exist yet */ }
51
+ }, 1000);
52
+
53
+ function readLog() {
54
+ try {
55
+ const logFile = todayLogFile();
56
+ if (!fs.existsSync(logFile)) return [];
57
+ const lines = fs.readFileSync(logFile, 'utf8').split('\n');
58
+ const result = [];
59
+ for (const raw of lines) {
60
+ const line = raw.trim();
61
+ if (!line) continue;
62
+ try {
63
+ const obj = JSON.parse(line);
64
+ if (typeof obj.input_tokens === 'number') result.push(obj);
65
+ } catch { /* skip */ }
66
+ }
67
+ return result.slice(-200);
68
+ } catch { return []; }
69
+ }
70
+
71
+ // ── HTTP server ────────────────────────────────────────────────────────────────
72
+
73
+ const server = http.createServer((req, res) => {
74
+ if (req.url === '/events') {
75
+ res.writeHead(200, {
76
+ 'Content-Type': 'text/event-stream',
77
+ 'Cache-Control': 'no-cache',
78
+ 'Connection': 'keep-alive',
79
+ 'Access-Control-Allow-Origin': '*',
80
+ });
81
+ res.write('retry: 2000\n\n');
82
+ clients.add(res);
83
+ try {
84
+ const session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
85
+ const entries = readLog();
86
+ res.write(`data: ${JSON.stringify({ type: 'update', session, entries })}\n\n`);
87
+ } catch { /* no session yet */ }
88
+ req.on('close', () => clients.delete(res));
89
+ return;
90
+ }
91
+
92
+ if (req.url === '/api/session') {
93
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
94
+ try { res.end(fs.readFileSync(SESSION_FILE, 'utf8')); }
95
+ catch { res.end('{}'); }
96
+ return;
97
+ }
98
+
99
+ if (req.url === '/api/clear' && req.method === 'POST') {
100
+ try { fs.writeFileSync(todayLogFile(), ''); } catch {}
101
+ try { fs.writeFileSync(SESSION_FILE, '{}'); } catch {}
102
+ res.writeHead(200, { 'Content-Type': 'application/json' });
103
+ res.end('{"ok":true}');
104
+ broadcast({ type: 'update', session: {}, entries: [] });
105
+ return;
106
+ }
107
+
108
+ if (req.url === '/' || req.url === '/index.html') {
109
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
110
+ res.end(getDashboardHtml());
111
+ return;
112
+ }
113
+
114
+ res.writeHead(404); res.end('Not found');
115
+ });
116
+
117
+ function getDashboardHtml() {
118
+ return `<!DOCTYPE html>
119
+ <html lang="en">
120
+ <head>
121
+ <meta charset="UTF-8">
122
+ <meta name="viewport" content="width=device-width, initial-scale=1">
123
+ <title>Occasio Dashboard</title>
124
+ <style>
125
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
126
+
127
+ body {
128
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
129
+ font-size: 13px;
130
+ background: #1e1e1e;
131
+ color: #d4d4d4;
132
+ padding: 24px;
133
+ min-height: 100vh;
134
+ }
135
+
136
+ header {
137
+ display: flex;
138
+ align-items: center;
139
+ justify-content: space-between;
140
+ margin-bottom: 24px;
141
+ }
142
+
143
+ h1 { font-size: 18px; font-weight: 600; letter-spacing: 0.01em; }
144
+ h1 span { color: #4ec9b0; }
145
+
146
+ .header-right { display: flex; align-items: center; gap: 12px; }
147
+
148
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: #4ec9b0; animation: pulse 2s infinite; }
149
+ @keyframes pulse { 0%,100% { opacity:1 } 50% { opacity:.4 } }
150
+
151
+ .btn {
152
+ background: #2d2d2d;
153
+ color: #d4d4d4;
154
+ border: 1px solid #444;
155
+ padding: 6px 14px;
156
+ border-radius: 5px;
157
+ cursor: pointer;
158
+ font-size: 12px;
159
+ }
160
+ .btn:hover { background: #3a3a3a; }
161
+
162
+ .scope-toggle { display: flex; }
163
+ .scope-btn {
164
+ background: #2d2d2d;
165
+ color: #666;
166
+ border: 1px solid #444;
167
+ padding: 5px 14px;
168
+ cursor: pointer;
169
+ font-size: 12px;
170
+ }
171
+ .scope-btn:first-child { border-radius: 5px 0 0 5px; }
172
+ .scope-btn:last-child { border-radius: 0 5px 5px 0; border-left: none; }
173
+ .scope-btn.active { background: #3a3a3a; color: #d4d4d4; border-color: #555; }
174
+
175
+ .hero {
176
+ background: rgba(78, 201, 176, 0.10);
177
+ border: 1px solid rgba(78, 201, 176, 0.25);
178
+ border-radius: 8px;
179
+ padding: 14px 18px;
180
+ margin-bottom: 14px;
181
+ }
182
+ .hero-saved {
183
+ font-size: 20px;
184
+ font-weight: 600;
185
+ color: #4ec9b0;
186
+ letter-spacing: 0.2px;
187
+ }
188
+ .hero-sub {
189
+ font-size: 13px;
190
+ color: #888;
191
+ margin-top: 4px;
192
+ }
193
+
194
+ .cards {
195
+ display: grid;
196
+ grid-template-columns: repeat(5, 1fr);
197
+ gap: 12px;
198
+ margin-bottom: 20px;
199
+ }
200
+ @media (max-width: 800px) { .cards { grid-template-columns: repeat(3, 1fr); } }
201
+
202
+ .card {
203
+ background: #252526;
204
+ border-radius: 8px;
205
+ padding: 14px 16px;
206
+ text-align: center;
207
+ }
208
+
209
+ .card-value {
210
+ font-size: 24px;
211
+ font-weight: 700;
212
+ line-height: 1.1;
213
+ color: #d4d4d4;
214
+ }
215
+ .card-value.green { color: #4ec9b0; }
216
+ .card-value.yellow { color: #dcdcaa; }
217
+ .card-value.local { color: #9cdcfe; }
218
+
219
+ .card-label {
220
+ font-size: 10px;
221
+ color: #888;
222
+ margin-top: 6px;
223
+ text-transform: uppercase;
224
+ letter-spacing: 0.06em;
225
+ }
226
+ .card-sub { font-size: 10px; color: #666; margin-top: 3px; }
227
+
228
+ .insights {
229
+ display: flex;
230
+ gap: 12px;
231
+ margin-bottom: 20px;
232
+ flex-wrap: wrap;
233
+ }
234
+ .insight {
235
+ background: #252526;
236
+ border-radius: 8px;
237
+ padding: 10px 14px;
238
+ font-size: 12px;
239
+ flex: 1;
240
+ min-width: 180px;
241
+ }
242
+ .insight-label { font-size: 10px; color: #888; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
243
+ .insight-value { font-weight: 600; }
244
+
245
+ .graph-wrap {
246
+ background: #252526;
247
+ border-radius: 8px;
248
+ padding: 14px 16px;
249
+ margin-bottom: 20px;
250
+ }
251
+ .graph-title { font-size: 10px; color: #888; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
252
+ .graph-svg { width: 100%; display: block; }
253
+
254
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
255
+
256
+ th {
257
+ text-align: left;
258
+ padding: 7px 10px;
259
+ color: #888;
260
+ font-weight: 500;
261
+ font-size: 10px;
262
+ text-transform: uppercase;
263
+ letter-spacing: 0.05em;
264
+ border-bottom: 1px solid #333;
265
+ }
266
+
267
+ td {
268
+ padding: 7px 10px;
269
+ border-bottom: 1px solid #2a2a2a;
270
+ vertical-align: middle;
271
+ white-space: nowrap;
272
+ }
273
+
274
+ tr.data-row { cursor: pointer; }
275
+ tr.data-row:hover td { background: #2a2a2a; }
276
+ tr.data-row.expanded td { background: #262626; }
277
+
278
+ /* ── Detail panel (Level 1 + 2 + 3) ─────────────────────────────────────── */
279
+
280
+ tr.detail-row td { padding: 0; border-bottom: 1px solid #333; }
281
+
282
+ .detail-panel {
283
+ display: flex;
284
+ gap: 0;
285
+ background: #1a1a1a;
286
+ border-left: 2px solid #333;
287
+ }
288
+
289
+ .detail-section {
290
+ flex: 1;
291
+ padding: 12px 14px;
292
+ border-right: 1px solid #2a2a2a;
293
+ min-width: 0;
294
+ }
295
+ .detail-section:last-child { border-right: none; }
296
+
297
+ .detail-title {
298
+ font-size: 10px;
299
+ text-transform: uppercase;
300
+ letter-spacing: 0.06em;
301
+ color: #666;
302
+ margin-bottom: 8px;
303
+ font-weight: 600;
304
+ }
305
+
306
+ /* Level 1: file bars */
307
+ .file-row {
308
+ display: flex;
309
+ align-items: center;
310
+ gap: 6px;
311
+ margin-bottom: 4px;
312
+ font-size: 11px;
313
+ }
314
+ .file-name {
315
+ width: 160px;
316
+ overflow: hidden;
317
+ text-overflow: ellipsis;
318
+ color: #9cdcfe;
319
+ flex-shrink: 0;
320
+ font-family: 'Consolas', 'Menlo', monospace;
321
+ }
322
+ .file-bar-wrap {
323
+ flex: 1;
324
+ background: #2a2a2a;
325
+ border-radius: 2px;
326
+ height: 4px;
327
+ overflow: hidden;
328
+ }
329
+ .file-bar-fill { height: 100%; background: #4a7fa5; border-radius: 2px; }
330
+ .file-tok {
331
+ width: 38px;
332
+ text-align: right;
333
+ color: #888;
334
+ font-size: 10px;
335
+ flex-shrink: 0;
336
+ }
337
+
338
+ /* Level 2: secrets */
339
+ .secret-row {
340
+ margin-bottom: 5px;
341
+ font-size: 11px;
342
+ line-height: 1.4;
343
+ }
344
+ .secret-label { color: #f48771; font-weight: 600; }
345
+ .secret-line { color: #888; font-size: 10px; }
346
+ .secret-snip { color: #555; font-family: 'Consolas', 'Menlo', monospace; font-size: 10px; word-break: break-all; white-space: normal; }
347
+
348
+ /* Level 3: tools */
349
+ .tool-row {
350
+ margin-bottom: 5px;
351
+ font-size: 11px;
352
+ line-height: 1.4;
353
+ }
354
+ .tool-cmd { color: #4ec9b0; font-family: 'Consolas', 'Menlo', monospace; }
355
+ .tool-meta { color: #666; font-size: 10px; }
356
+
357
+ /* ── Tags ─────────────────────────────────────────────────────────────────── */
358
+
359
+ .tag-ok { color: #4ec9b0; }
360
+ .tag-secret { color: #f48771; }
361
+ .tag-local { color: #9cdcfe; }
362
+
363
+ .empty { text-align: center; color: #555; padding: 48px 0; }
364
+ .footer { margin-top: 16px; font-size: 11px; color: #555; }
365
+
366
+ .ctx-bar { display:flex; height:5px; border-radius:3px; overflow:hidden; width:70px; background:#333; }
367
+ .ctx-new { background: #4ec9b0; }
368
+ .ctx-old { background: #444; }
369
+
370
+ #status { font-size: 11px; color: #666; }
371
+ #status.connected { color: #4ec9b0; }
372
+ </style>
373
+ </head>
374
+ <body>
375
+
376
+ <header>
377
+ <h1>⚡ <span>Occasio</span> Dashboard</h1>
378
+ <div class="header-right">
379
+ <div class="scope-toggle">
380
+ <button class="scope-btn active" id="scope-session" onclick="setScope('session')">Session</button>
381
+ <button class="scope-btn" id="scope-today" onclick="setScope('today')">Today</button>
382
+ </div>
383
+ <span id="status">connecting…</span>
384
+ <div class="dot" id="dot" style="background:#666"></div>
385
+ <button class="btn" id="clearBtn">Clear</button>
386
+ </div>
387
+ </header>
388
+
389
+ <div class="hero" id="hero" style="display:none">
390
+ <div class="hero-saved" id="hero-saved">—</div>
391
+ <div class="hero-sub" id="hero-sub">—</div>
392
+ </div>
393
+
394
+ <div class="cards">
395
+ <div class="card">
396
+ <div class="card-value" id="c-req">—</div>
397
+ <div class="card-label">Requests</div>
398
+ </div>
399
+ <div class="card">
400
+ <div class="card-value yellow" id="c-in">—</div>
401
+ <div class="card-label">Tokens In</div>
402
+ <div class="card-sub" id="c-in-sub"></div>
403
+ </div>
404
+ <div class="card">
405
+ <div class="card-value yellow" id="c-out">—</div>
406
+ <div class="card-label">Tokens Out</div>
407
+ </div>
408
+ <div class="card">
409
+ <div class="card-value green" id="c-cost">—</div>
410
+ <div class="card-label">Cost</div>
411
+ <div class="card-sub" id="c-cost-sub"></div>
412
+ </div>
413
+ <div class="card">
414
+ <div class="card-value local" id="c-local">—</div>
415
+ <div class="card-label">Run locally</div>
416
+ <div class="card-sub" id="c-saved"></div>
417
+ </div>
418
+ </div>
419
+
420
+ <div class="insights">
421
+ <div class="insight">
422
+ <div class="insight-label">Largest request</div>
423
+ <div class="insight-value" id="i-peak">—</div>
424
+ </div>
425
+ <div class="insight">
426
+ <div class="insight-label">Context overhead</div>
427
+ <div class="insight-value" id="i-ctx">—</div>
428
+ </div>
429
+ <div class="insight">
430
+ <div class="insight-label">Projected / hr</div>
431
+ <div class="insight-value" id="i-proj">—</div>
432
+ </div>
433
+ <div class="insight">
434
+ <div class="insight-label">Breakdown</div>
435
+ <div class="insight-value" id="i-saved">—</div>
436
+ </div>
437
+ <div class="insight">
438
+ <div class="insight-label">Tools intercepted</div>
439
+ <div class="insight-value" id="i-intercepted">—</div>
440
+ </div>
441
+ </div>
442
+
443
+ <div class="graph-wrap" id="graph-wrap" style="display:none">
444
+ <div class="graph-title">Input tokens per request — current session</div>
445
+ <svg class="graph-svg" id="graph" height="60" viewBox="0 0 600 60" preserveAspectRatio="none"></svg>
446
+ </div>
447
+
448
+ <table>
449
+ <thead>
450
+ <tr>
451
+ <th>Time</th>
452
+ <th>Model</th>
453
+ <th>Tokens In</th>
454
+ <th>Tokens Out</th>
455
+ <th>Cost</th>
456
+ <th>New vs Context</th>
457
+ <th>Status</th>
458
+ </tr>
459
+ </thead>
460
+ <tbody id="rows">
461
+ <tr><td colspan="7" class="empty">Waiting for Occasio proxy…</td></tr>
462
+ </tbody>
463
+ </table>
464
+
465
+ <div class="footer" id="footer"></div>
466
+
467
+ <script>
468
+ // ── Helpers ──────────────────────────────────────────────────────────────────
469
+
470
+ function fmt(n) {
471
+ if (n >= 1e6) return (n/1e6).toFixed(1)+'M';
472
+ if (n >= 1e3) return (n/1e3).toFixed(1)+'k';
473
+ return String(n||0);
474
+ }
475
+
476
+ function shortModel(m) {
477
+ return (m||'?').replace('claude-','').replace(/-\\d{8}$/,'');
478
+ }
479
+
480
+ function enrich(entries) {
481
+ return entries.map((e, i) => {
482
+ const prev = i === 0 ? 0 : entries[i-1].input_tokens;
483
+ const newTok = Math.max(0, e.input_tokens - prev);
484
+ const newPct = e.input_tokens > 0 ? Math.round((newTok / e.input_tokens) * 100) : 100;
485
+ return { ...e, newPct };
486
+ });
487
+ }
488
+
489
+ function parseTs(ts) {
490
+ if (!ts) return 0;
491
+ const p = ts.split(':').map(Number);
492
+ return p[0]*3600 + p[1]*60 + (p[2]||0);
493
+ }
494
+
495
+ // Filter log entries to only those belonging to the current session.
496
+ // Prefers entry.iso (full ISO string) when available for cross-midnight safety;
497
+ // falls back to HH:MM:SS string comparison for older entries.
498
+ function toSessionEntries(entries, session) {
499
+ if (!session || !session.start || !entries.length) return entries;
500
+ try {
501
+ const startIso = session.start;
502
+ const startHms = new Date(startIso).toTimeString().slice(0, 8);
503
+ const filtered = entries.filter(e => {
504
+ if (e.iso) return e.iso >= startIso;
505
+ return (e.ts || '') >= startHms;
506
+ });
507
+ return filtered.length > 0 ? filtered : entries;
508
+ } catch { return entries; }
509
+ }
510
+
511
+ function drawGraph(entries) {
512
+ const wrap = document.getElementById('graph-wrap');
513
+ const svg = document.getElementById('graph');
514
+ if (entries.length < 2) { wrap.style.display='none'; return; }
515
+ wrap.style.display = '';
516
+ const W=600, H=60, P=4;
517
+ const vals = entries.map(e => e.input_tokens);
518
+ const maxV = Math.max(...vals), minV = Math.min(...vals), range = maxV-minV||1;
519
+ const pts = vals.map((v,i) => {
520
+ const x = P+(i/(vals.length-1))*(W-P*2);
521
+ const y = P+(1-(v-minV)/range)*(H-P*2);
522
+ return x+','+y;
523
+ }).join(' ');
524
+ const f0x = P, f0y = P+(1-(vals[0]-minV)/range)*(H-P*2);
525
+ const fNx = P+(W-P*2), fNy = P+(1-(vals[vals.length-1]-minV)/range)*(H-P*2);
526
+ const area = \`M \${f0x},\${f0y} L \${pts.split(' ').join(' L ')} L \${fNx},\${H-P} L \${P},\${H-P} Z\`;
527
+ const pi = vals.indexOf(maxV);
528
+ const px = P+(pi/(vals.length-1))*(W-P*2);
529
+ const py = P+(1-(maxV-minV)/range)*(H-P*2);
530
+ svg.innerHTML =
531
+ '<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1">'+
532
+ '<stop offset="0%" stop-color="#4ec9b0" stop-opacity="0.25"/>'+
533
+ '<stop offset="100%" stop-color="#4ec9b0" stop-opacity="0"/>'+
534
+ '</linearGradient></defs>'+
535
+ '<path d="'+area+'" fill="url(#g)"/>'+
536
+ '<polyline points="'+pts+'" fill="none" stroke="#4ec9b0" stroke-width="1.5"/>'+
537
+ '<circle cx="'+px+'" cy="'+py+'" r="3" fill="#dcdcaa"/>';
538
+ }
539
+
540
+ // ── Detail panel (Levels 1 + 2 + 3) ──────────────────────────────────────────
541
+
542
+ /**
543
+ * Normalise secrets to [{label, line, snippet}] regardless of log version.
544
+ * Old entries stored secrets as string[] (pattern source slices).
545
+ */
546
+ function normaliseSecrets(secrets) {
547
+ if (!Array.isArray(secrets) || !secrets.length) return [];
548
+ if (typeof secrets[0] === 'string') {
549
+ return secrets.map((s, i) => ({ label: s, line: null, snippet: null }));
550
+ }
551
+ return secrets;
552
+ }
553
+
554
+ function buildDetailHtml(e) {
555
+ const sections = [];
556
+ const secrets = normaliseSecrets(e.secrets);
557
+ const tools = Array.isArray(e.tools) ? e.tools : [];
558
+ const fileToks = Array.isArray(e.file_tokens) ? e.file_tokens : [];
559
+
560
+ // Level 1 — file token breakdown
561
+ if (fileToks.length) {
562
+ const maxTok = Math.max(...fileToks.map(f => f.tokens), 1);
563
+ const rows = fileToks.slice(0, 12).map(f => {
564
+ const pct = Math.round((f.tokens / maxTok) * 100);
565
+ const name = f.name.split('/').slice(-2).join('/');
566
+ return '<div class="file-row">' +
567
+ '<div class="file-name" title="' + escHtml(f.name) + '">' + escHtml(name) + '</div>' +
568
+ '<div class="file-bar-wrap"><div class="file-bar-fill" style="width:' + pct + '%"></div></div>' +
569
+ '<div class="file-tok">' + fmt(f.tokens) + 't</div>' +
570
+ '</div>';
571
+ }).join('');
572
+ sections.push(
573
+ '<div class="detail-section">' +
574
+ '<div class="detail-title">Files (' + fileToks.length + ')</div>' +
575
+ rows +
576
+ '</div>'
577
+ );
578
+ }
579
+
580
+ // Level 2 — secret details with line numbers
581
+ if (secrets.length) {
582
+ const rows = secrets.map(s =>
583
+ '<div class="secret-row">' +
584
+ '<span class="secret-label">⚠ ' + escHtml(s.label) + '</span>' +
585
+ (s.line ? ' <span class="secret-line">line ' + s.line + '</span>' : '') +
586
+ (s.snippet ? '<br><span class="secret-snip">' + escHtml(s.snippet) + '</span>' : '') +
587
+ '</div>'
588
+ ).join('');
589
+ sections.push(
590
+ '<div class="detail-section">' +
591
+ '<div class="detail-title">Secrets (' + secrets.length + ')</div>' +
592
+ rows +
593
+ '</div>'
594
+ );
595
+ }
596
+
597
+ // Level 3 — local tool runs
598
+ if (tools.length) {
599
+ const rows = tools.map(t => {
600
+ const cmd = (t.cmd || '').length > 70 ? t.cmd.slice(0, 70) + '…' : t.cmd;
601
+ const meta = 'exit ' + t.exitCode + ' · ' + fmt(t.bytes) + 'B';
602
+ // Per-tool transform badges — only shown when a transform actually ran.
603
+ let badges = '';
604
+ if (t.secretsRedacted > 0) {
605
+ const n = t.secretsRedacted;
606
+ badges += ' <span style="color:#f48771;font-size:10px">⚠ ' + n + ' secret' + (n > 1 ? 's' : '') + ' redacted</span>';
607
+ }
608
+ if (t.distilled) {
609
+ const saved = t.distillSaved > 0 ? ' −' + fmt(t.distillSaved) + 't' : '';
610
+ badges += ' <span style="color:#dcdcaa;font-size:10px" title="' + escHtml(t.distillLabel || '') + '">✂' + saved + '</span>';
611
+ }
612
+ if (!badges && t.transform) {
613
+ badges = ' <span style="color:#9cdcfe;font-size:10px">→ ' + escHtml(t.transform) + '</span>';
614
+ }
615
+ return '<div class="tool-row">' +
616
+ '<span class="tool-cmd">' + escHtml(cmd) + '</span> ' +
617
+ '<span class="tool-meta">(' + meta + ')</span>' +
618
+ badges +
619
+ '</div>';
620
+ }).join('');
621
+ sections.push(
622
+ '<div class="detail-section">' +
623
+ '<div class="detail-title">Local tools (' + tools.length + ')</div>' +
624
+ rows +
625
+ '</div>'
626
+ );
627
+ }
628
+
629
+ return sections.length ? sections.join('') : null;
630
+ }
631
+
632
+ function escHtml(s) {
633
+ return String(s || '')
634
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
635
+ .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
636
+ }
637
+
638
+ function toggleDetail(tr) {
639
+ // Collapse if already open
640
+ const next = tr.nextElementSibling;
641
+ if (next && next.classList.contains('detail-row')) {
642
+ next.remove();
643
+ tr.classList.remove('expanded');
644
+ return;
645
+ }
646
+
647
+ let e;
648
+ try { e = JSON.parse(tr.dataset.entry); } catch { return; }
649
+
650
+ const html = buildDetailHtml(e);
651
+ if (!html) return;
652
+
653
+ tr.classList.add('expanded');
654
+ const detail = document.createElement('tr');
655
+ detail.className = 'detail-row';
656
+ detail.innerHTML = '<td colspan="7"><div class="detail-panel">' + html + '</div></td>';
657
+ tr.parentNode.insertBefore(detail, tr.nextSibling);
658
+ }
659
+
660
+ // ── Scope ─────────────────────────────────────────────────────────────────────
661
+
662
+ let activeScope = 'session';
663
+ let lastPayload = null;
664
+
665
+ function setScope(s) {
666
+ activeScope = s;
667
+ document.getElementById('scope-session').classList.toggle('active', s === 'session');
668
+ document.getElementById('scope-today').classList.toggle('active', s === 'today');
669
+ if (lastPayload) render(lastPayload);
670
+ }
671
+
672
+ // Aggregate card totals from raw log entries (used for Today scope).
673
+ // Reads pre-computed cost/savings fields — same values the proxy wrote via calcCost().
674
+ function computeTodayTotals(entries) {
675
+ const r = { requests: entries.length, input_tokens: 0, output_tokens: 0, cost: 0,
676
+ cache_savings: 0, lao_cost_saved: 0, distill_cost_saved: 0,
677
+ tools_local_count: 0, intercepted_count: 0 };
678
+ for (const e of entries) {
679
+ r.input_tokens += e.input_tokens || 0;
680
+ r.output_tokens += e.output_tokens || 0;
681
+ r.cost += e.cost || 0;
682
+ r.cache_savings += e.cache_savings || 0;
683
+ r.lao_cost_saved += e.lao_cost_saved || 0;
684
+ r.distill_cost_saved += e.distill_cost_saved || 0;
685
+ r.tools_local_count += e.tools_local_count || 0;
686
+ if (e.intercepted) r.intercepted_count++;
687
+ }
688
+ return r;
689
+ }
690
+
691
+ // ── Render ────────────────────────────────────────────────────────────────────
692
+
693
+ function render({ session, entries }) {
694
+ const sEntries = toSessionEntries(entries, session);
695
+ const isToday = activeScope === 'today';
696
+ const displayEntries = isToday ? entries : sEntries;
697
+ const displayTotals = isToday ? computeTodayTotals(entries) : session;
698
+ const scopeLabel = isToday ? 'Today' : 'Current session';
699
+
700
+ const req = displayTotals.requests || 0;
701
+ const tin = displayTotals.input_tokens || 0;
702
+ const tout = displayTotals.output_tokens || 0;
703
+ const cost = displayTotals.cost || 0;
704
+ const toolsLocal = displayTotals.tools_local_count || 0;
705
+ const toolsMcp = displayTotals.tools_mcp_count || 0;
706
+ const attempted = displayTotals.tools_attempted || 0;
707
+ const ranLocal = toolsLocal + toolsMcp;
708
+ const cacheSaved = displayTotals.cache_savings || 0;
709
+ const laoSaved = displayTotals.lao_cost_saved || 0;
710
+ const distillSaved = displayTotals.distill_cost_saved || 0;
711
+ const payloadSaved = laoSaved + distillSaved;
712
+ const totalSaved = cacheSaved + payloadSaved;
713
+ const broaderCf = cost + totalSaved;
714
+ const savedPct = broaderCf > 0.00001 ? Math.round(totalSaved / broaderCf * 100) : 0;
715
+ const intCount = displayTotals.intercepted_count || 0;
716
+
717
+ // Hero — single headline savings number
718
+ const hero = document.getElementById('hero');
719
+ if (totalSaved > 0.00001) {
720
+ document.getElementById('hero-saved').textContent =
721
+ 'Saved $'+totalSaved.toFixed(4)+' this session — '+savedPct+'% off';
722
+ document.getElementById('hero-sub').textContent =
723
+ 'Would have cost $'+broaderCf.toFixed(4)+' without Occasio';
724
+ hero.style.display = 'block';
725
+ } else {
726
+ hero.style.display = 'none';
727
+ }
728
+
729
+ document.getElementById('c-req').textContent = req || '—';
730
+ document.getElementById('c-in').textContent = req ? fmt(tin) : '—';
731
+ document.getElementById('c-out').textContent = req ? fmt(tout) : '—';
732
+ document.getElementById('c-cost').textContent = req ? '$'+cost.toFixed(4) : '—';
733
+ document.getElementById('c-local').textContent = ranLocal > 0 ? ranLocal : '—';
734
+ document.getElementById('c-saved').textContent =
735
+ attempted > 0 ? ranLocal+' of '+attempted+' ('+Math.round(ranLocal/attempted*100)+'%)' : '';
736
+ document.getElementById('c-in-sub').textContent = req > 1 ? fmt(Math.round(tin/req))+' avg/req' : '';
737
+
738
+ const savedParts = [];
739
+ if (payloadSaved > 0.00001) savedParts.push('$'+payloadSaved.toFixed(4)+' payload');
740
+ if (cacheSaved > 0.00001) savedParts.push('$'+cacheSaved.toFixed(4)+' cache');
741
+ document.getElementById('i-saved').textContent =
742
+ savedParts.length ? savedParts.join(' + ') : 'none yet';
743
+
744
+ document.getElementById('i-intercepted').textContent =
745
+ ranLocal > 0 ? ranLocal+' tools ('+intCount+' requests)' : 'none yet';
746
+
747
+ document.querySelector('.graph-title').textContent =
748
+ 'Input tokens per request — '+scopeLabel.toLowerCase();
749
+ document.getElementById('c-cost-sub').textContent = '';
750
+ document.getElementById('i-ctx').textContent = '—';
751
+ document.getElementById('i-proj').textContent = '—';
752
+
753
+ if (!displayEntries.length) {
754
+ document.getElementById('rows').innerHTML =
755
+ '<tr><td colspan="7" class="empty">Waiting for Occasio proxy…</td></tr>';
756
+ document.getElementById('footer').textContent = '';
757
+ ['i-peak','i-ctx','i-proj'].forEach(id => document.getElementById(id).textContent='—');
758
+ document.getElementById('graph-wrap').style.display='none';
759
+ return;
760
+ }
761
+
762
+ const rich = enrich(displayEntries);
763
+
764
+ // Insights
765
+ const peak = rich.reduce((a,b) => b.input_tokens>a.input_tokens?b:a);
766
+ document.getElementById('i-peak').textContent =
767
+ 'req #'+(rich.indexOf(peak)+1)+' — '+fmt(peak.input_tokens)+' tokens at '+peak.ts;
768
+
769
+ const ctxRows = rich.slice(1).filter(e=>e.input_tokens>0);
770
+ if (ctxRows.length) {
771
+ const avg = ctxRows.reduce((s,e)=>s+(100-e.newPct),0)/ctxRows.length;
772
+ document.getElementById('i-ctx').textContent = Math.round(avg)+'% carry-over per request';
773
+ }
774
+
775
+ let elapsed = 0;
776
+ if (isToday && displayEntries.length >= 2) {
777
+ elapsed = parseTs(displayEntries[displayEntries.length-1].ts) - parseTs(displayEntries[0].ts);
778
+ } else if (!isToday && session.start) {
779
+ elapsed = (Date.now() - new Date(session.start).getTime()) / 1000;
780
+ }
781
+ if (elapsed > 30 && cost > 0) {
782
+ document.getElementById('i-proj').textContent = '~$'+(cost/elapsed*3600).toFixed(3)+'/hr';
783
+ document.getElementById('c-cost-sub').textContent = '~$'+(cost/elapsed*3600).toFixed(3)+'/hr';
784
+ }
785
+
786
+ drawGraph(displayEntries);
787
+
788
+ // Table rows
789
+ const rows = [...rich].reverse().slice(0, 200);
790
+ document.getElementById('rows').innerHTML = rows.map(e => {
791
+ const barNew = e.newPct, barOld = 100-e.newPct;
792
+ const ctx = '<div style="display:flex;align-items:center;gap:5px">'+
793
+ '<div class="ctx-bar"><div class="ctx-new" style="width:'+barNew+'%"></div>'+
794
+ '<div class="ctx-old" style="width:'+barOld+'%"></div></div>'+
795
+ '<span style="font-size:10px;color:#888">'+e.newPct+'%</span></div>';
796
+
797
+ const secrets = normaliseSecrets(e.secrets);
798
+ const tools = Array.isArray(e.tools) ? e.tools : [];
799
+ const fileTok = Array.isArray(e.file_tokens) ? e.file_tokens : [];
800
+ const hasDetail = secrets.length || tools.length || fileTok.length;
801
+
802
+ // Status tag — uses event_type when present, falls back to legacy flags
803
+ let tag;
804
+ const evType = e.event_type;
805
+ if (evType === 'blocked') {
806
+ tag = '<span class="tag-secret">🛑 blocked</span>';
807
+ } else if (secrets.length) {
808
+ const lineInfo = secrets[0].line ? ' line '+secrets[0].line : '';
809
+ tag = '<span class="tag-secret">⚠ ' + escHtml(secrets[0].label || 'secret') + lineInfo + '</span>';
810
+ } else if (evType === 'local_only' || e.intercepted) {
811
+ const toolNote = tools.length ? ' ×'+tools.length : '';
812
+ tag = '<span class="tag-local">⚡ local' + toolNote + '</span>';
813
+ } else if (evType === 'trimmed') {
814
+ tag = '<span class="tag-ok">✂ cloud·trimmed</span>';
815
+ } else if (evType === 'cloud_sent') {
816
+ tag = '<span class="tag-ok">☁ cloud</span>';
817
+ } else {
818
+ tag = '<span class="tag-ok">✓ ok</span>';
819
+ }
820
+
821
+ // Hint when row is expandable
822
+ const expandHint = hasDetail ? ' title="Click to expand"' : '';
823
+
824
+ return '<tr class="data-row' + (e.intercepted ? ' intercepted' : '') + '"' +
825
+ (hasDetail ? ' onclick="toggleDetail(this)"' : '') +
826
+ ' data-entry="' + JSON.stringify(e).replace(/"/g,'&quot;') + '"' +
827
+ expandHint + '>' +
828
+ '<td>'+(e.ts||'—')+'</td>' +
829
+ '<td style="color:#888">'+shortModel(e.model)+'</td>' +
830
+ '<td>'+fmt(e.input_tokens)+'</td>' +
831
+ '<td>'+fmt(e.output_tokens)+'</td>' +
832
+ '<td>$'+(e.cost||0).toFixed(4)+'</td>' +
833
+ '<td>'+ctx+'</td>' +
834
+ '<td>'+tag+(hasDetail ? ' <span style="color:#555;font-size:10px">▾</span>' : '')+'</td>' +
835
+ '</tr>';
836
+ }).join('');
837
+
838
+ const hiddenCount = isToday ? 0 : (entries.length - sEntries.length);
839
+ document.getElementById('footer').textContent =
840
+ \`\${scopeLabel} · \${rows.length} request\${rows.length===1?'':'s'}\${hiddenCount>0?' · '+hiddenCount+' earlier today not shown':''} · click row to expand · live via SSE\`;
841
+ }
842
+
843
+ // ── SSE connection ─────────────────────────────────────────────────────────────
844
+
845
+ const dot = document.getElementById('dot');
846
+ const status = document.getElementById('status');
847
+
848
+ function connect() {
849
+ const es = new EventSource('/events');
850
+
851
+ es.onopen = () => {
852
+ dot.style.background = '#4ec9b0';
853
+ status.textContent = 'live';
854
+ status.className = 'connected';
855
+ };
856
+
857
+ es.onmessage = ({ data }) => {
858
+ try { lastPayload = JSON.parse(data); render(lastPayload); } catch (err) { console.error('[Occasio]', err); }
859
+ };
860
+
861
+ es.onerror = () => {
862
+ dot.style.background = '#666';
863
+ status.textContent = 'reconnecting…';
864
+ status.className = '';
865
+ es.close();
866
+ setTimeout(connect, 3000);
867
+ };
868
+ }
869
+
870
+ connect();
871
+
872
+ document.getElementById('clearBtn').addEventListener('click', () => {
873
+ fetch('/api/clear', { method: 'POST' });
874
+ });
875
+ </script>
876
+ </body>
877
+ </html>`;
878
+ }
879
+
880
+ // ── Start ──────────────────────────────────────────────────────────────────────
881
+
882
+ server.listen(DASHBOARD_PORT, '127.0.0.1', () => {
883
+ process.stderr.write(` dashboard: http://localhost:${DASHBOARD_PORT}\n`);
884
+ });
885
+
886
+ server.on('error', e => {
887
+ if (e.code === 'EADDRINUSE') {
888
+ process.stderr.write(` [dashboard] port ${DASHBOARD_PORT} in use — skipping\n`);
889
+ }
890
+ });
891
+
892
+ module.exports = { server };