mobygate 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +172 -0
- package/bin/mobygate.js +74 -0
- package/index.html +1 -0
- package/inspector.html +422 -0
- package/lib/anthropic.js +23 -0
- package/lib/connectors/hermes.js +3 -1
- package/lib/connectors/openclaw.js +39 -2
- package/lib/connectors/safety.js +18 -1
- package/lib/request-capture.js +394 -0
- package/lib/session-derive.js +20 -1
- package/package.json +2 -1
- package/server.js +263 -10
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
// ---------------------------------------------------------------------------
|
package/lib/connectors/hermes.js
CHANGED
|
@@ -142,7 +142,9 @@ export const hermesConnector = {
|
|
|
142
142
|
});
|
|
143
143
|
const result = writeConfigSafe(plan.configPath, yamlOut);
|
|
144
144
|
return {
|
|
145
|
-
applied:
|
|
145
|
+
applied: !result.unchanged,
|
|
146
|
+
unchanged: !!result.unchanged,
|
|
147
|
+
reason: result.unchanged ? 'config already up-to-date (byte-identical)' : null,
|
|
146
148
|
configPath: result.path,
|
|
147
149
|
backupPath: result.backupPath,
|
|
148
150
|
bytesWritten: result.bytesWritten,
|
|
@@ -124,12 +124,46 @@ export const openclawConnector = {
|
|
|
124
124
|
return { configPath, parsed };
|
|
125
125
|
},
|
|
126
126
|
|
|
127
|
-
async inspect() {
|
|
127
|
+
async inspect({ baseUrl = DEFAULT_BASE_URL } = {}) {
|
|
128
128
|
const det = await this.detect();
|
|
129
129
|
if (!det) return { installed: false };
|
|
130
130
|
if (det.parseError) return { installed: true, parseError: det.parseError };
|
|
131
131
|
|
|
132
132
|
const providers = det.parsed?.models?.providers || {};
|
|
133
|
+
|
|
134
|
+
// Detect "shadow" providers: ones that point at our base URL but
|
|
135
|
+
// aren't registered under our canonical names. This catches the
|
|
136
|
+
// pre-v0.8.0 hand-rolled `claude-max-proxy` style configs that
|
|
137
|
+
// would otherwise silently bypass `mobygate connect`'s native
|
|
138
|
+
// surface — exactly the situation that caused OpenClaw to keep
|
|
139
|
+
// sending OpenAI-shape requests in the v0.8.0 → v0.8.1 era despite
|
|
140
|
+
// the connector having registered moby-native.
|
|
141
|
+
//
|
|
142
|
+
// For each shadow provider we report its name, current api type,
|
|
143
|
+
// and a recommendation. Surfacing this in inspect() (and the CLI)
|
|
144
|
+
// turns "why is the shape wrong?" from a forensics task into a
|
|
145
|
+
// single command.
|
|
146
|
+
const shadowProviders = [];
|
|
147
|
+
const baseHost = String(baseUrl).replace(/\/+$/, '');
|
|
148
|
+
for (const [name, p] of Object.entries(providers)) {
|
|
149
|
+
if (name === PROVIDER_NAME_OPENAI || name === PROVIDER_NAME_ANTHROPIC) continue;
|
|
150
|
+
if (!p?.baseUrl) continue;
|
|
151
|
+
const provHost = String(p.baseUrl).replace(/\/+$/, '');
|
|
152
|
+
// Match exact or with /v1 suffix; tolerate localhost vs 127.0.0.1.
|
|
153
|
+
const norm = (s) => s.replace('localhost', '127.0.0.1').replace(/\/v1$/, '');
|
|
154
|
+
if (norm(provHost) === norm(baseHost)) {
|
|
155
|
+
shadowProviders.push({
|
|
156
|
+
name,
|
|
157
|
+
api: p.api || '(unset)',
|
|
158
|
+
baseUrl: p.baseUrl,
|
|
159
|
+
recommendation: p.api === 'anthropic-messages'
|
|
160
|
+
? `OK — already on native shape. Could rename to "${PROVIDER_NAME_ANTHROPIC}" for clarity.`
|
|
161
|
+
: `Flip api: "${p.api}" → "anthropic-messages" to enable cache_control + native blocks. ` +
|
|
162
|
+
`Or run \`mobygate connect openclaw\` to register canonical providers.`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
133
167
|
return {
|
|
134
168
|
installed: true,
|
|
135
169
|
configPath: det.configPath,
|
|
@@ -137,6 +171,7 @@ export const openclawConnector = {
|
|
|
137
171
|
mobyNativeProviderExists: !!providers[PROVIDER_NAME_ANTHROPIC],
|
|
138
172
|
currentMain: det.parsed?.models?.main || null,
|
|
139
173
|
currentDefault: det.parsed?.models?.default || null,
|
|
174
|
+
shadowProviders, // pre-v0.8.0 entries pointing at our base URL
|
|
140
175
|
};
|
|
141
176
|
},
|
|
142
177
|
|
|
@@ -205,7 +240,9 @@ export const openclawConnector = {
|
|
|
205
240
|
const jsonOut = JSON.stringify(plan.after, null, 2) + '\n';
|
|
206
241
|
const result = writeConfigSafe(plan.configPath, jsonOut);
|
|
207
242
|
return {
|
|
208
|
-
applied:
|
|
243
|
+
applied: !result.unchanged,
|
|
244
|
+
unchanged: !!result.unchanged,
|
|
245
|
+
reason: result.unchanged ? 'config already up-to-date (byte-identical)' : null,
|
|
209
246
|
configPath: result.path,
|
|
210
247
|
backupPath: result.backupPath,
|
|
211
248
|
bytesWritten: result.bytesWritten,
|
package/lib/connectors/safety.js
CHANGED
|
@@ -64,6 +64,23 @@ export function writeConfigSafe(path, content) {
|
|
|
64
64
|
const dir = dirname(path);
|
|
65
65
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
66
66
|
|
|
67
|
+
// Idempotency guard: if the existing on-disk content is byte-identical
|
|
68
|
+
// to what we'd write, skip the rewrite entirely. This prevents
|
|
69
|
+
// `mobygate connect <client>` from producing spurious "(changed)" diffs
|
|
70
|
+
// when re-run with no real change — a real bug seen in v0.8.0 where
|
|
71
|
+
// diffSummary's structural comparison disagreed with actual file bytes.
|
|
72
|
+
if (existsSync(path)) {
|
|
73
|
+
try {
|
|
74
|
+
const current = readFileSync(path, 'utf8');
|
|
75
|
+
if (current === content) {
|
|
76
|
+
return { path, backupPath: null, bytesWritten: 0, unchanged: true };
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Read failure is non-fatal — we'll fall through to the normal write
|
|
80
|
+
// path which will surface any real I/O problem.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
const backupPath = backup(path);
|
|
68
85
|
const tempPath = `${path}.mobygate-tmp-${ISO_SAFE()}`;
|
|
69
86
|
|
|
@@ -89,7 +106,7 @@ export function writeConfigSafe(path, content) {
|
|
|
89
106
|
(backupPath ? ` (original preserved at ${backupPath})` : ''));
|
|
90
107
|
}
|
|
91
108
|
|
|
92
|
-
return { path, backupPath, bytesWritten: Buffer.byteLength(content, 'utf8') };
|
|
109
|
+
return { path, backupPath, bytesWritten: Buffer.byteLength(content, 'utf8'), unchanged: false };
|
|
93
110
|
}
|
|
94
111
|
|
|
95
112
|
/**
|