mobygate 0.8.0 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,111 @@ All notable changes to mobygate are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version numbers are
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.8.1] — 2026-04-27
8
+
9
+ Diagnostic visibility release. Adds a request/response capture system,
10
+ a session inspector UI, a `mobygate tail` CLI, and fixes the v0.8.0
11
+ connector idempotency bug. Born out of an OpenClaw debugging session
12
+ where it took an hour of forensics to discover OpenClaw was using
13
+ OpenAI shape (no cache_control) instead of `/v1/messages` — a one-line
14
+ config flip — because mobygate had no visibility into what its
15
+ upstream clients were actually sending.
16
+
17
+ ### Added
18
+
19
+ - **Request/response capture system** (`lib/request-capture.js`).
20
+ - Off by default. Three ways to enable:
21
+ 1. Env var: `MOBY_CAPTURE=1 mobygate start`
22
+ 2. Touch file: `touch ~/.mobygate/.capture-enabled` (survives
23
+ restarts, dashboard-toggleable)
24
+ 3. Dashboard inspector toggle button
25
+ - Writes paired files per request to `~/.mobygate/captures/`:
26
+ - `{ts}_{path}_{requestId}.json` — raw inbound body
27
+ - `{ts}_{path}_{requestId}.summary.txt` — human-readable analysis
28
+ (system blocks, cache_control markers, message timeline, tool
29
+ definitions, token breakdown)
30
+ - On response: appends actual usage from the SDK including
31
+ `cache_read_input_tokens`, `cache_creation_input_tokens`,
32
+ `input_tokens`, `output_tokens`, plus computed cache hit % and
33
+ "savings vs uncached" estimate.
34
+ - Auto-rotation: keeps last `MOBY_CAPTURE_KEEP=100` captures (200
35
+ files), prunes oldest in the background.
36
+
37
+ - **Session inspector UI** at `/inspector`. New focused dashboard for
38
+ browsing captures:
39
+ - List view: all recent captures sorted newest-first, one-line stats
40
+ (timestamp, path, model, msgs, tokens, cache hit %), live polling
41
+ every 3s.
42
+ - Detail view: full request body decoded — system blocks (with
43
+ cache_control highlighted in green), messages timeline (color-coded
44
+ by role, tool_use/tool_result expanded with previews), tool
45
+ definitions, response usage stats.
46
+ - Capture toggle in header — flips the touch file live without
47
+ restarting mobygate.
48
+ - Linked from the main dashboard's endpoints row.
49
+
50
+ - **`mobygate tail` CLI** — `tail -f` style live view of captures as
51
+ they land. Shows last 10 historical entries, then watches the
52
+ captures dir for new files. Color-codes `/v1/messages` (green,
53
+ native shape) vs `/v1/chat/completions` (yellow, OpenAI shape) so
54
+ shape mismatches jump off the screen.
55
+
56
+ - **`extractSdkUsage()` helper** in `lib/anthropic.js` — defensive
57
+ extraction of cache + token fields from SDK result messages,
58
+ handles both flat (`message.input_tokens`) and nested
59
+ (`message.usage.input_tokens`) shapes from the Claude Agent SDK.
60
+ Used by all 4 mobygate handlers (the OpenAI streaming handler also
61
+ now tracks usage where it didn't before).
62
+
63
+ - **Connector migration detection** — `openclawConnector.inspect()`
64
+ now reports "shadow providers": entries in OpenClaw's config that
65
+ point at mobygate's base URL but aren't registered under our
66
+ canonical `moby` / `moby-native` names. Catches pre-v0.8.0
67
+ hand-rolled `claude-max-proxy`-style configs (which silently bypass
68
+ the native surface). Returned as `shadowProviders: [{ name, api,
69
+ baseUrl, recommendation }]` in inspect output.
70
+
71
+ - **Cache endpoints for the dashboard:**
72
+ - `GET /dashboard/captures` — list captures with summary stats
73
+ - `GET /dashboard/captures/:filename` — full body + summary for one
74
+ capture
75
+ - `GET /dashboard/captures-state` — current toggle state
76
+ - `POST /dashboard/captures-toggle` — flip toggle live
77
+ - All require local-origin (DNS-rebinding protection).
78
+
79
+ ### Fixed
80
+
81
+ - **Connector idempotency** (v0.8.0 OPEN bug). `mobygate connect <id>`
82
+ run twice with no real change was producing spurious "(changed)"
83
+ diffs and rewriting byte-identical files. Root cause: `diffSummary`
84
+ walks objects structurally (sensitive to key order); the actual
85
+ serialized JSON output was identical. Fix: `writeConfigSafe` now
86
+ byte-compares the rendered output against the existing file content
87
+ before deciding to write. Returns `unchanged: true` if no real
88
+ change. Both `hermesConnector.apply()` and
89
+ `openclawConnector.apply()` updated to surface this flag.
90
+
91
+ - **Tool name display in capture analyzer**. OpenAI-shape tools nest
92
+ the name under `tool.function.name`; the analyzer was only checking
93
+ `tool.name` and showing `(unnamed)` for everything. Now handles
94
+ both shapes via the new `toolName(t)` helper.
95
+
96
+ ### Notes
97
+
98
+ The capture system was the lever that made the entire OpenClaw
99
+ debugging session productive. Without it we had no visibility into
100
+ which wire format clients were using, whether `cache_control` markers
101
+ were being set, where in the request the breakpoints landed, or how
102
+ the conversation was growing turn-over-turn. With it, the diagnosis
103
+ took 60 seconds: "this is `/v1/chat/completions`, no `system` array,
104
+ no cache markers, 33k tokens per turn."
105
+
106
+ The migration detection in `openclawConnector.inspect()` is a small
107
+ prevent-the-next-occurrence add. If `inspect()` had reported
108
+ `claude-max-proxy` as a shadow provider with a "flip api to
109
+ anthropic-messages" recommendation, today's hour-long forensics would
110
+ have been a single CLI command.
111
+
7
112
  ## [0.8.0] — 2026-04-25
8
113
 
9
114
  Auto-wire third-party clients to use mobygate. The "find your client's
package/bin/mobygate.js CHANGED
@@ -480,6 +480,78 @@ function cmdLogs() {
480
480
  spawnSync(cmd, args, { stdio: 'inherit' });
481
481
  }
482
482
 
483
+ // `mobygate tail` — watch captures live, print a one-line summary as
484
+ // each new request lands. Reads the .summary.txt file paired with each
485
+ // new .json file in ~/.mobygate/captures/.
486
+ async function cmdTail() {
487
+ const { homedir } = await import('os');
488
+ const { watch, existsSync, readFileSync, mkdirSync, statSync } = await import('fs');
489
+ const { readdir } = await import('fs/promises');
490
+ const { join } = await import('path');
491
+
492
+ const dir = process.env.MOBYGATE_CAPTURE_DIR
493
+ || join(process.env.MOBYGATE_HOME || join(homedir(), '.mobygate'), 'captures');
494
+
495
+ if (!existsSync(dir)) {
496
+ print(`Captures directory does not exist yet: ${dir}`);
497
+ print(`Enable capture (touch ~/.mobygate/.capture-enabled OR set MOBY_CAPTURE=1) and send a request.`);
498
+ return;
499
+ }
500
+
501
+ // Print recent history first (last 10), then watch.
502
+ const initial = (await readdir(dir))
503
+ .filter(n => n.endsWith('.summary.txt'))
504
+ .map(n => ({ name: n, mtime: statSync(join(dir, n)).mtimeMs }))
505
+ .sort((a, b) => a.mtime - b.mtime)
506
+ .slice(-10);
507
+
508
+ for (const { name } of initial) {
509
+ printSummaryLine(join(dir, name));
510
+ }
511
+
512
+ print(''); // blank line separator before live tail
513
+ print('--- watching for new captures (Ctrl-C to stop) ---');
514
+
515
+ const seen = new Set(initial.map(x => x.name));
516
+ watch(dir, (_event, name) => {
517
+ if (!name || !name.endsWith('.summary.txt')) return;
518
+ if (seen.has(name)) return;
519
+ seen.add(name);
520
+ // Wait briefly so the file is flushed (fs.watch can fire on the
521
+ // creation rename before content is fully written).
522
+ setTimeout(() => printSummaryLine(join(dir, name)), 50);
523
+ });
524
+ }
525
+
526
+ function printSummaryLine(summaryPath) {
527
+ try {
528
+ const text = readFileSync(summaryPath, 'utf8');
529
+ const grab = (re) => { const m = text.match(re); return m ? m[1].trim() : '?'; };
530
+ const ts = grab(/timestamp:\s+(\S+)/).slice(11, 19);
531
+ const path = grab(/^path:\s+(\S+)/m);
532
+ const model = grab(/^model:\s+(\S+)/m);
533
+ const msgs = grab(/^messages:\s+(\d+)/m);
534
+ const tokens = grab(/grand total:\s+(\d+)/);
535
+ const hit = grab(/cache hit rate:\s+([\d.]+)%/);
536
+ const status = grab(/^status:\s+(\S+)/m);
537
+ const dur = grab(/^duration:\s+(\d+) ms/m);
538
+
539
+ const hitDisplay = hit === '?' ? '─' : `${hit}% hit`;
540
+ const statusDisplay = status === '?' ? '(in flight)' : `${status} ${dur}ms`;
541
+
542
+ // Color-code the path: green for /v1/messages (native), yellow for /v1/chat/completions
543
+ const isNative = path === '/v1/messages';
544
+ const pathColored = isNative ? `\x1b[32m${path}\x1b[0m` : `\x1b[33m${path}\x1b[0m`;
545
+ const hitColored = hit === '?' ? hitDisplay
546
+ : parseFloat(hit) > 50 ? `\x1b[32m${hitDisplay}\x1b[0m`
547
+ : `\x1b[33m${hitDisplay}\x1b[0m`;
548
+
549
+ print(`${ts} ${pathColored.padEnd(35)} ${model.padEnd(22)} msgs=${msgs.padEnd(4)} tok=${tokens.padEnd(6)} ${hitColored.padEnd(20)} ${statusDisplay}`);
550
+ } catch (e) {
551
+ print(`(failed to read ${summaryPath}: ${e.message})`);
552
+ }
553
+ }
554
+
483
555
  async function cmdAuth() {
484
556
  section('mobygate auth');
485
557
  const status = await getAuthStatus();
@@ -869,6 +941,7 @@ Usage:
869
941
  mobygate restart Stop + start
870
942
  mobygate status Show service + auth + /health state
871
943
  mobygate logs Tail the server log
944
+ mobygate tail Watch captures live (request/response inspector)
872
945
  mobygate auth Show auth status + run a refresh probe
873
946
  mobygate connect Auto-wire detected clients (Hermes, OpenClaw) to
874
947
  use mobygate. Add: <id> for one client, --all for
@@ -900,6 +973,7 @@ const COMMANDS = {
900
973
  restart: cmdRestart,
901
974
  status: cmdStatus,
902
975
  logs: cmdLogs,
976
+ tail: cmdTail,
903
977
  auth: cmdAuth,
904
978
  uninstall: cmdUninstall,
905
979
  version: cmdVersion,
package/index.html CHANGED
@@ -361,6 +361,7 @@
361
361
  <a href="/auth/status?quick=1" class="py-[3px] px-2.5 border border-[#2A2A1F] text-[#C9D9A8] text-[11px] leading-[14px] hover:border-[#5A5F54]">/auth/status</a>
362
362
  <a href="/v1/models" class="py-[3px] px-2.5 border border-[#2A2A1F] text-[#C9D9A8] text-[11px] leading-[14px] hover:border-[#5A5F54]">/v1/models</a>
363
363
  <a href="/events" class="py-[3px] px-2.5 border border-[#2A2A1F] text-[#C9D9A8] text-[11px] leading-[14px] hover:border-[#5A5F54]">/events</a>
364
+ <a href="/inspector" class="py-[3px] px-2.5 border border-[#B7E56D] text-[#B7E56D] text-[11px] leading-[14px] hover:bg-[#1A1F12]">/inspector</a>
364
365
  </div>
365
366
  <div class="flex items-center gap-3">
366
367
  <span class="uppercase text-[#5A5F54] text-[10px] leading-3 tracking-[0.12em]" id="f-stream">stream · connecting</span>
package/inspector.html ADDED
@@ -0,0 +1,422 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>mobygate — inspector</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=VT323&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --bg: #0B0B09;
13
+ --bg-2: #14140F;
14
+ --bg-3: #1F1F17;
15
+ --fg: #F3EFE4;
16
+ --muted: #5A5F54;
17
+ --dim: #8A8F84;
18
+ --accent: #B7E56D;
19
+ --warn: #F1B05A;
20
+ --crit: #E66B5C;
21
+ --hit: #6CC994;
22
+ --border: #2A2A1F;
23
+ }
24
+ * { box-sizing: border-box; }
25
+ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; font-size: 13px; height: 100vh; overflow: hidden; }
26
+ .display { font-family: 'VT323', monospace; letter-spacing: 0.02em; }
27
+ a { color: var(--accent); text-decoration: none; }
28
+ a:hover { text-decoration: underline; }
29
+ button { font-family: inherit; font-size: inherit; }
30
+ pre, code { font-family: 'JetBrains Mono', ui-monospace, monospace; }
31
+
32
+ header {
33
+ display: flex; align-items: center; gap: 16px;
34
+ padding: 12px 20px; border-bottom: 1px solid var(--border);
35
+ background: var(--bg-2);
36
+ }
37
+ header .title { font-size: 28px; line-height: 1; color: var(--accent); }
38
+ header .breadcrumb { color: var(--dim); font-size: 12px; }
39
+ header .spacer { flex: 1; }
40
+ header .toggle {
41
+ display: flex; align-items: center; gap: 8px;
42
+ padding: 6px 12px; border: 1px solid var(--border);
43
+ background: var(--bg-3); cursor: pointer; color: var(--fg);
44
+ }
45
+ header .toggle.on { border-color: var(--accent); color: var(--accent); }
46
+ header .toggle .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
47
+ header .toggle.on .dot { background: var(--accent); animation: pulse 1.8s infinite; }
48
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
49
+
50
+ .layout { display: grid; grid-template-columns: 460px 1fr; height: calc(100vh - 53px); }
51
+
52
+ .list-pane { border-right: 1px solid var(--border); overflow-y: auto; background: var(--bg); }
53
+ .list-stats { padding: 10px 16px; color: var(--dim); font-size: 11px; border-bottom: 1px solid var(--border); }
54
+ .row {
55
+ padding: 10px 16px; border-bottom: 1px solid var(--border);
56
+ cursor: pointer; transition: background .1s;
57
+ }
58
+ .row:hover { background: var(--bg-2); }
59
+ .row.active { background: var(--bg-3); border-left: 3px solid var(--accent); padding-left: 13px; }
60
+ .row .r1 { display: flex; gap: 8px; align-items: baseline; margin-bottom: 4px; }
61
+ .row .r1 .ts { color: var(--dim); font-size: 11px; }
62
+ .row .r1 .path { color: var(--fg); font-size: 12px; }
63
+ .row .r1 .path.native { color: var(--accent); }
64
+ .row .r2 { color: var(--dim); font-size: 11px; display: flex; gap: 12px; flex-wrap: wrap; }
65
+ .row .r2 .pill { padding: 1px 6px; background: var(--bg-3); border-radius: 2px; }
66
+ .row .r2 .hit { color: var(--hit); }
67
+ .row .r2 .nohit { color: var(--warn); }
68
+
69
+ .detail-pane { overflow-y: auto; padding: 20px 28px; }
70
+ .detail-pane.empty { display: flex; align-items: center; justify-content: center; color: var(--dim); }
71
+
72
+ .stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
73
+ .stat {
74
+ padding: 12px 14px; border: 1px solid var(--border); background: var(--bg-2);
75
+ }
76
+ .stat .label { color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
77
+ .stat .val { color: var(--fg); font-family: 'VT323', monospace; font-size: 24px; line-height: 1; }
78
+ .stat .val.accent { color: var(--accent); }
79
+ .stat .val.warn { color: var(--warn); }
80
+ .stat .val.crit { color: var(--crit); }
81
+ .stat .sub { color: var(--dim); font-size: 11px; margin-top: 2px; }
82
+
83
+ .section { margin-bottom: 24px; }
84
+ .section h3 { color: var(--accent); font-size: 12px; margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.05em; }
85
+
86
+ .sys-block {
87
+ padding: 8px 12px; margin-bottom: 4px;
88
+ border-left: 2px solid var(--border); background: var(--bg-2);
89
+ font-size: 11px;
90
+ }
91
+ .sys-block.cached { border-left-color: var(--hit); }
92
+ .sys-block .head { color: var(--dim); margin-bottom: 4px; }
93
+ .sys-block .head .marker { color: var(--hit); margin-left: 8px; }
94
+ .sys-block .preview { color: var(--fg); white-space: pre-wrap; max-height: 80px; overflow: hidden; opacity: 0.7; }
95
+
96
+ .msg-row { padding: 6px 0; border-bottom: 1px solid var(--border); display: grid; grid-template-columns: 40px 80px 70px 1fr; gap: 12px; align-items: center; font-size: 11px; }
97
+ .msg-row .idx { color: var(--dim); text-align: right; }
98
+ .msg-row .role { padding: 1px 6px; background: var(--bg-3); text-align: center; }
99
+ .msg-row .role.user { color: #88B4FF; }
100
+ .msg-row .role.assistant { color: var(--accent); }
101
+ .msg-row .role.tool { color: var(--warn); }
102
+ .msg-row .role.system { color: var(--crit); }
103
+ .msg-row .bytes { color: var(--dim); text-align: right; }
104
+ .msg-row .preview { color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.85; }
105
+ .msg-row.cached { background: rgba(108, 201, 148, 0.04); }
106
+
107
+ .tool-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
108
+ .tool { padding: 6px 10px; background: var(--bg-2); border: 1px solid var(--border); font-size: 11px; }
109
+
110
+ .usage-grid { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 8px; font-size: 11px; }
111
+ .usage-grid > div { padding: 8px 12px; background: var(--bg-2); border: 1px solid var(--border); }
112
+ .usage-grid .label { color: var(--dim); text-transform: uppercase; letter-spacing: 0.05em; font-size: 10px; }
113
+ .usage-grid .val { color: var(--fg); font-size: 18px; font-family: 'VT323', monospace; line-height: 1; margin-top: 4px; }
114
+ .usage-grid .val.hit { color: var(--hit); }
115
+ .usage-grid .val.dim { color: var(--dim); }
116
+
117
+ .placeholder { color: var(--dim); font-size: 12px; padding: 16px; text-align: center; }
118
+
119
+ /* Scrollbar */
120
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
121
+ ::-webkit-scrollbar-track { background: var(--bg); }
122
+ ::-webkit-scrollbar-thumb { background: var(--border); }
123
+ ::-webkit-scrollbar-thumb:hover { background: var(--muted); }
124
+ </style>
125
+ </head>
126
+ <body>
127
+ <header>
128
+ <div class="title display">mobygate :: inspector</div>
129
+ <div class="breadcrumb">~/.mobygate/captures/</div>
130
+ <div class="spacer"></div>
131
+ <button id="toggle" class="toggle">
132
+ <span class="dot"></span>
133
+ <span id="toggle-label">capture: ?</span>
134
+ </button>
135
+ <a href="/">← dashboard</a>
136
+ </header>
137
+
138
+ <div class="layout">
139
+ <div class="list-pane" id="list-pane">
140
+ <div class="list-stats" id="list-stats">loading…</div>
141
+ <div id="list"></div>
142
+ </div>
143
+ <div class="detail-pane empty" id="detail">
144
+ <div>Select a capture from the left to inspect.</div>
145
+ </div>
146
+ </div>
147
+
148
+ <script>
149
+ let selectedFilename = null;
150
+ let captureCache = []; // last fetch result, used to compute deltas
151
+
152
+ // ──────────── helpers ────────────
153
+
154
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
155
+ const fmt = (n) => Number(n || 0).toLocaleString();
156
+ const fmtBytes = (b) => {
157
+ if (b < 1024) return b + ' B';
158
+ if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
159
+ return (b/(1024*1024)).toFixed(1) + ' MB';
160
+ };
161
+ const fmtTime = (ms) => {
162
+ const d = new Date(ms);
163
+ return d.toLocaleTimeString('en-US', { hour12: false });
164
+ };
165
+
166
+ // ──────────── capture toggle ────────────
167
+
168
+ async function refreshToggleState() {
169
+ try {
170
+ const r = await fetch('/dashboard/captures-state');
171
+ const s = await r.json();
172
+ const btn = document.getElementById('toggle');
173
+ const lbl = document.getElementById('toggle-label');
174
+ btn.classList.toggle('on', !!s.enabled);
175
+ lbl.textContent = 'capture: ' + (s.enabled ? 'ON' : 'off') + (s.envVar ? ' (env)' : '');
176
+ } catch {}
177
+ }
178
+
179
+ document.getElementById('toggle').addEventListener('click', async () => {
180
+ const cur = document.getElementById('toggle').classList.contains('on');
181
+ try {
182
+ await fetch('/dashboard/captures-toggle', {
183
+ method: 'POST',
184
+ headers: { 'content-type': 'application/json' },
185
+ body: JSON.stringify({ enabled: !cur }),
186
+ });
187
+ refreshToggleState();
188
+ } catch {}
189
+ });
190
+
191
+ // ──────────── list rendering ────────────
192
+
193
+ async function refreshList() {
194
+ try {
195
+ const r = await fetch('/dashboard/captures?limit=200');
196
+ const data = await r.json();
197
+ captureCache = data.captures || [];
198
+ renderList(captureCache, data);
199
+ } catch (e) {
200
+ document.getElementById('list-stats').textContent = 'failed: ' + e.message;
201
+ }
202
+ }
203
+
204
+ function renderList(captures, meta) {
205
+ const stats = document.getElementById('list-stats');
206
+ if (!captures.length) {
207
+ stats.textContent = 'no captures yet — toggle capture on and send a request';
208
+ document.getElementById('list').innerHTML = '';
209
+ return;
210
+ }
211
+ stats.textContent = `${captures.length} captures · ${meta.total} total`;
212
+
213
+ const html = captures.map(c => {
214
+ const isNative = c.path === '/v1/messages';
215
+ const hitClass = c.cacheHitPct == null ? '' : (parseFloat(c.cacheHitPct) > 50 ? 'hit' : 'nohit');
216
+ const hitText = c.cacheHitPct == null ? 'no resp yet' : `${c.cacheHitPct}% cache`;
217
+ const sel = c.filename === selectedFilename ? ' active' : '';
218
+ return `<div class="row${sel}" data-fn="${esc(c.filename)}">
219
+ <div class="r1">
220
+ <span class="ts">${fmtTime(c.ts)}</span>
221
+ <span class="path${isNative ? ' native' : ''}">${esc(c.path)}</span>
222
+ <span class="ts" style="margin-left:auto">${esc(c.model || '')}</span>
223
+ </div>
224
+ <div class="r2">
225
+ <span class="pill">${c.msgCount} msgs</span>
226
+ <span class="pill">${fmt(c.grandTokens)} tok</span>
227
+ <span class="pill ${hitClass}">${hitText}</span>
228
+ ${c.cacheControlSystem ? `<span class="pill">sys-cache ${esc(c.cacheControlSystem)}</span>` : ''}
229
+ </div>
230
+ </div>`;
231
+ }).join('');
232
+ document.getElementById('list').innerHTML = html;
233
+
234
+ // Wire row clicks
235
+ document.querySelectorAll('.row').forEach(el => {
236
+ el.addEventListener('click', () => loadDetail(el.dataset.fn));
237
+ });
238
+ }
239
+
240
+ // ──────────── detail rendering ────────────
241
+
242
+ async function loadDetail(filename) {
243
+ selectedFilename = filename;
244
+ document.querySelectorAll('.row').forEach(r => r.classList.toggle('active', r.dataset.fn === filename));
245
+ const detail = document.getElementById('detail');
246
+ detail.classList.remove('empty');
247
+ detail.innerHTML = `<div class="placeholder">loading ${esc(filename)}…</div>`;
248
+ try {
249
+ const r = await fetch('/dashboard/captures/' + encodeURIComponent(filename));
250
+ const data = await r.json();
251
+ renderDetail(data);
252
+ } catch (e) {
253
+ detail.innerHTML = `<div class="placeholder">failed: ${esc(e.message)}</div>`;
254
+ }
255
+ }
256
+
257
+ function renderDetail({ filename, body, summary }) {
258
+ const sysBlocks = Array.isArray(body.system) ? body.system : (typeof body.system === 'string' ? [{ type: 'text', text: body.system }] : []);
259
+ const msgs = body.messages || [];
260
+ const tools = body.tools || [];
261
+ const sysBytes = sysBlocks.reduce((a, b) => a + (b?.text?.length || 0), 0);
262
+ const msgBytes = msgs.reduce((a, m) => {
263
+ if (typeof m.content === 'string') return a + m.content.length;
264
+ if (Array.isArray(m.content)) return a + m.content.reduce((aa, b) => aa + (b?.text?.length || 0) + (b?.input ? JSON.stringify(b.input).length : 0) + (typeof b?.content === 'string' ? b.content.length : 0), 0);
265
+ return a;
266
+ }, 0);
267
+ const toolBytes = JSON.stringify(tools).length;
268
+ const grand = sysBytes + msgBytes + toolBytes;
269
+
270
+ // Pull response stats out of the appended summary text
271
+ const grab = (re) => { const m = summary.match(re); return m ? m[1].trim() : null; };
272
+ const inputUncached = parseInt(grab(/input_tokens \(uncached\):\s+(\d+)/) || '0', 10);
273
+ const cacheRead = parseInt(grab(/cache_read_input_tokens:\s+(\d+)/) || '0', 10);
274
+ const cacheCreate = parseInt(grab(/cache_creation_input_tokens:\s+(\d+)/) || '0', 10);
275
+ const outputTokens = parseInt(grab(/output_tokens:\s+(\d+)/) || '0', 10);
276
+ const cacheHit = grab(/cache hit rate:\s+([\d.]+)%/);
277
+ const savings = grab(/savings from cache:\s+([\d.]+)%/);
278
+ const duration = grab(/duration:\s+(\d+) ms/);
279
+
280
+ const sysHtml = sysBlocks.length === 0
281
+ ? '<div class="placeholder">no system block</div>'
282
+ : sysBlocks.map((b, i) => {
283
+ const cached = !!b.cache_control;
284
+ const preview = (b.text || '').slice(0, 600);
285
+ return `<div class="sys-block${cached ? ' cached' : ''}">
286
+ <div class="head">[${i}] ${esc(b.type || '?')} · ${fmt((b.text||'').length)} bytes${cached ? `<span class="marker">cache_control: ${esc(JSON.stringify(b.cache_control))}</span>` : ''}</div>
287
+ <div class="preview">${esc(preview)}${(b.text||'').length > 600 ? '…' : ''}</div>
288
+ </div>`;
289
+ }).join('');
290
+
291
+ const msgHtml = msgs.length > 60
292
+ ? renderTrimmedMessages(msgs)
293
+ : msgs.map((m, i) => renderMessageRow(m, i)).join('');
294
+
295
+ const toolsHtml = tools.length === 0
296
+ ? '<div class="placeholder">no tools declared</div>'
297
+ : `<div class="tool-list">${tools.map(t => {
298
+ const name = t.name || t.function?.name || '(unnamed)';
299
+ return `<div class="tool">${esc(name)}</div>`;
300
+ }).join('')}</div>`;
301
+
302
+ const usageHtml = (cacheRead + cacheCreate + inputUncached + outputTokens === 0)
303
+ ? '<div class="placeholder">response not captured yet (request still in flight, or capture was off when response landed)</div>'
304
+ : `<div class="usage-grid">
305
+ <div>
306
+ <div class="label">cache hit rate</div>
307
+ <div class="val hit">${cacheHit || '0'}%</div>
308
+ <div class="label" style="margin-top:6px">savings</div>
309
+ <div class="val ${parseFloat(savings)>50?'hit':''}" style="font-size:14px">${savings || '0'}%</div>
310
+ </div>
311
+ <div>
312
+ <div class="label">input (uncached)</div>
313
+ <div class="val">${fmt(inputUncached)}</div>
314
+ </div>
315
+ <div>
316
+ <div class="label">cache read</div>
317
+ <div class="val hit">${fmt(cacheRead)}</div>
318
+ </div>
319
+ <div>
320
+ <div class="label">cache create</div>
321
+ <div class="val ${cacheCreate>0?'':'dim'}">${fmt(cacheCreate)}</div>
322
+ <div class="label" style="margin-top:6px">output</div>
323
+ <div class="val" style="font-size:14px">${fmt(outputTokens)}</div>
324
+ </div>
325
+ </div>`;
326
+
327
+ document.getElementById('detail').innerHTML = `
328
+ <div class="stat-grid">
329
+ <div class="stat">
330
+ <div class="label">model</div>
331
+ <div class="val accent">${esc(body.model || '?')}</div>
332
+ <div class="sub">${body.stream ? 'streaming' : 'sync'} · ${duration ? duration + ' ms' : '—'}</div>
333
+ </div>
334
+ <div class="stat">
335
+ <div class="label">messages</div>
336
+ <div class="val">${msgs.length}</div>
337
+ <div class="sub">${fmtBytes(msgBytes)}</div>
338
+ </div>
339
+ <div class="stat">
340
+ <div class="label">total wire</div>
341
+ <div class="val">${fmt(Math.round(grand/4))}</div>
342
+ <div class="sub">~tokens · ${fmtBytes(grand)}</div>
343
+ </div>
344
+ <div class="stat">
345
+ <div class="label">system block</div>
346
+ <div class="val ${sysBlocks.some(b=>b.cache_control)?'accent':'warn'}">${fmtBytes(sysBytes)}</div>
347
+ <div class="sub">${sysBlocks.length} block(s) · ${sysBlocks.filter(b=>b.cache_control).length} cached</div>
348
+ </div>
349
+ </div>
350
+
351
+ <div class="section">
352
+ <h3>Response usage</h3>
353
+ ${usageHtml}
354
+ </div>
355
+
356
+ <div class="section">
357
+ <h3>System blocks</h3>
358
+ ${sysHtml}
359
+ </div>
360
+
361
+ <div class="section">
362
+ <h3>Messages timeline (${msgs.length})</h3>
363
+ ${msgHtml}
364
+ </div>
365
+
366
+ <div class="section">
367
+ <h3>Tools (${tools.length})</h3>
368
+ ${toolsHtml}
369
+ </div>
370
+
371
+ <div class="section">
372
+ <h3>Raw summary</h3>
373
+ <pre style="background:var(--bg-2);padding:12px;border:1px solid var(--border);font-size:11px;color:var(--dim);white-space:pre-wrap;max-height:300px;overflow:auto">${esc(summary)}</pre>
374
+ </div>
375
+ `;
376
+ }
377
+
378
+ function renderMessageRow(m, i) {
379
+ const role = m.role || '?';
380
+ const cached = Array.isArray(m.content) && m.content.some(b => b?.cache_control);
381
+ let preview = '';
382
+ let bytes = 0;
383
+ if (typeof m.content === 'string') {
384
+ preview = m.content.slice(0, 200);
385
+ bytes = m.content.length;
386
+ } else if (Array.isArray(m.content)) {
387
+ const parts = m.content.map(b => {
388
+ if (b?.type === 'text') return b.text || '';
389
+ if (b?.type === 'tool_use') return `🔧 ${b.name || '?'}(${JSON.stringify(b.input || {}).slice(0, 80)})`;
390
+ if (b?.type === 'tool_result') {
391
+ const tc = Array.isArray(b.content) ? (b.content.find(x => x.type === 'text')?.text || '') : (b.content || '');
392
+ return `← ${String(tc).slice(0, 100)}`;
393
+ }
394
+ if (b?.type === 'image') return '🖼️ [image]';
395
+ return JSON.stringify(b).slice(0, 80);
396
+ });
397
+ preview = parts.join(' · ').slice(0, 250);
398
+ bytes = m.content.reduce((a, b) => a + (b?.text?.length || 0) + (b?.input ? JSON.stringify(b.input).length : 0) + (typeof b?.content === 'string' ? b.content.length : 0), 0);
399
+ }
400
+ return `<div class="msg-row${cached ? ' cached' : ''}">
401
+ <span class="idx">${i}</span>
402
+ <span class="role ${role}">${role}</span>
403
+ <span class="bytes">${fmtBytes(bytes)}</span>
404
+ <span class="preview">${esc(preview)}${cached ? ' 🔒' : ''}</span>
405
+ </div>`;
406
+ }
407
+
408
+ function renderTrimmedMessages(msgs) {
409
+ // Show first 5 and last 10 for long conversations
410
+ const head = msgs.slice(0, 5).map((m, i) => renderMessageRow(m, i)).join('');
411
+ const tail = msgs.slice(-10).map((m, i) => renderMessageRow(m, msgs.length - 10 + i)).join('');
412
+ return head + `<div class="placeholder">… ${msgs.length - 15} messages collapsed …</div>` + tail;
413
+ }
414
+
415
+ // ──────────── kickoff ────────────
416
+
417
+ refreshToggleState();
418
+ refreshList();
419
+ setInterval(() => { refreshList(); refreshToggleState(); }, 3000);
420
+ </script>
421
+ </body>
422
+ </html>
package/lib/anthropic.js CHANGED
@@ -27,6 +27,29 @@
27
27
 
28
28
  import { v4 as uuidv4 } from 'uuid';
29
29
 
30
+ // ---------------------------------------------------------------------------
31
+ // SDK usage extraction
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Pulls the full token usage from an SDK 'result' message — defensive
36
+ * against shape variations (the Claude Agent SDK sometimes nests these
37
+ * under `.usage`, sometimes places them flat on the message). Returns
38
+ * a complete usage shape with cache_read / cache_creation fields zeroed
39
+ * out if absent. Used by the 4 mobygate handlers to populate response
40
+ * captures and dashboard cache-hit metrics.
41
+ */
42
+ export function extractSdkUsage(message) {
43
+ if (!message) return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
44
+ const u = message.usage || message;
45
+ return {
46
+ input_tokens: u.input_tokens || 0,
47
+ output_tokens: u.output_tokens || 0,
48
+ cache_read_input_tokens: u.cache_read_input_tokens || 0,
49
+ cache_creation_input_tokens: u.cache_creation_input_tokens || 0,
50
+ };
51
+ }
52
+
30
53
  // ---------------------------------------------------------------------------
31
54
  // Content extraction — read individual block types out of an Anthropic message
32
55
  // ---------------------------------------------------------------------------