mobygate 0.7.3 → 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 +180 -0
- package/bin/mobygate.js +292 -12
- package/index.html +1 -0
- package/inspector.html +422 -0
- package/lib/anthropic.js +23 -0
- package/lib/connectors/hermes.js +188 -0
- package/lib/connectors/index.js +80 -0
- package/lib/connectors/openclaw.js +290 -0
- package/lib/connectors/safety.js +141 -0
- package/lib/request-capture.js +394 -0
- package/package.json +2 -1
- package/server.js +248 -6
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
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hermes connector.
|
|
3
|
+
*
|
|
4
|
+
* Hermes (the agent harness) lives at `~/.hermes/` with a `config.yaml`
|
|
5
|
+
* holding `model:` (the active model + provider) and `providers:` (a map
|
|
6
|
+
* of named providers keyed by id). Hermes only speaks the OpenAI-compat
|
|
7
|
+
* wire format, so we register a single provider entry: `moby`.
|
|
8
|
+
*
|
|
9
|
+
* Hermes references custom providers via `model.provider: custom:<id>`,
|
|
10
|
+
* so wiring "use mobygate as default" means setting:
|
|
11
|
+
* model.default: claude-opus-4-7
|
|
12
|
+
* model.provider: custom:moby
|
|
13
|
+
*
|
|
14
|
+
* Caveats:
|
|
15
|
+
* - We use js-yaml to parse + re-emit. js-yaml does NOT preserve
|
|
16
|
+
* comments or blank lines. If the user has hand-edited their YAML
|
|
17
|
+
* with comments, those will be lost in the rewritten file. The
|
|
18
|
+
* auto-backup (safety.js) catches this — we also warn on detection.
|
|
19
|
+
* - We only touch `providers.moby` (always) and `model.*` (only with
|
|
20
|
+
* `setDefault: true`). Everything else in the YAML is preserved.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync, existsSync } from 'fs';
|
|
24
|
+
import { join } from 'path';
|
|
25
|
+
import { homedir } from 'os';
|
|
26
|
+
import yaml from 'js-yaml';
|
|
27
|
+
import { backup, writeConfigSafe, diffSummary } from './safety.js';
|
|
28
|
+
import { DEFAULT_BASE_URL, DEFAULT_API_KEY, PROVIDER_NAME_OPENAI } from './index.js';
|
|
29
|
+
|
|
30
|
+
const HERMES_HOME = process.env.HERMES_HOME || join(homedir(), '.hermes');
|
|
31
|
+
const HERMES_CONFIG = join(HERMES_HOME, 'config.yaml');
|
|
32
|
+
|
|
33
|
+
function buildProviderEntry({ baseUrl, apiKey }) {
|
|
34
|
+
return {
|
|
35
|
+
api: `${baseUrl.replace(/\/$/, '')}/v1`,
|
|
36
|
+
name: 'Moby (Claude Max via mobygate)',
|
|
37
|
+
api_key: apiKey,
|
|
38
|
+
default_model: 'claude-opus-4-7',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const hermesConnector = {
|
|
43
|
+
id: 'hermes',
|
|
44
|
+
displayName: 'Hermes',
|
|
45
|
+
|
|
46
|
+
async detect() {
|
|
47
|
+
if (!existsSync(HERMES_CONFIG)) return null;
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = readFileSync(HERMES_CONFIG, 'utf8');
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = yaml.load(raw);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// Config exists but is unparseable — surface that to the user via
|
|
59
|
+
// detection metadata rather than silently treating as not-installed.
|
|
60
|
+
return { configPath: HERMES_CONFIG, parseError: e.message };
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
configPath: HERMES_CONFIG,
|
|
64
|
+
version: parsed?._config_version ?? null,
|
|
65
|
+
hasComments: /(^|\n)\s*#/.test(raw),
|
|
66
|
+
parsed,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async inspect() {
|
|
71
|
+
const det = await this.detect();
|
|
72
|
+
if (!det) return { installed: false };
|
|
73
|
+
if (det.parseError) return { installed: true, parseError: det.parseError };
|
|
74
|
+
|
|
75
|
+
const providers = det.parsed?.providers || {};
|
|
76
|
+
const existing = providers[PROVIDER_NAME_OPENAI];
|
|
77
|
+
const currentDefault = det.parsed?.model?.provider || null;
|
|
78
|
+
return {
|
|
79
|
+
installed: true,
|
|
80
|
+
configPath: det.configPath,
|
|
81
|
+
mobyProviderExists: !!existing,
|
|
82
|
+
mobyProviderMatches: existing
|
|
83
|
+
? JSON.stringify(existing) === JSON.stringify(buildProviderEntry({
|
|
84
|
+
baseUrl: existing.api?.replace(/\/v1$/, '') || DEFAULT_BASE_URL,
|
|
85
|
+
apiKey: existing.api_key,
|
|
86
|
+
}))
|
|
87
|
+
: false,
|
|
88
|
+
currentDefaultProvider: currentDefault,
|
|
89
|
+
hasComments: det.hasComments,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async plan({ baseUrl = DEFAULT_BASE_URL, apiKey = DEFAULT_API_KEY, setDefault = true } = {}) {
|
|
94
|
+
const det = await this.detect();
|
|
95
|
+
if (!det) {
|
|
96
|
+
return { skip: true, reason: 'Hermes not detected (no ~/.hermes/config.yaml)' };
|
|
97
|
+
}
|
|
98
|
+
if (det.parseError) {
|
|
99
|
+
return { skip: true, reason: `Hermes config is unparseable: ${det.parseError}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const before = det.parsed || {};
|
|
103
|
+
const after = JSON.parse(JSON.stringify(before)); // deep clone
|
|
104
|
+
|
|
105
|
+
if (!after.providers) after.providers = {};
|
|
106
|
+
after.providers[PROVIDER_NAME_OPENAI] = buildProviderEntry({ baseUrl, apiKey });
|
|
107
|
+
|
|
108
|
+
if (setDefault) {
|
|
109
|
+
if (!after.model) after.model = {};
|
|
110
|
+
after.model.default = after.model.default || 'claude-opus-4-7';
|
|
111
|
+
after.model.provider = `custom:${PROVIDER_NAME_OPENAI}`;
|
|
112
|
+
after.model.context_length = after.model.context_length || 1000000;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const summary = diffSummary(
|
|
116
|
+
{ providers: before.providers, model: before.model },
|
|
117
|
+
{ providers: after.providers, model: after.model },
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
skip: false,
|
|
122
|
+
configPath: det.configPath,
|
|
123
|
+
before,
|
|
124
|
+
after,
|
|
125
|
+
summary,
|
|
126
|
+
warnings: det.hasComments
|
|
127
|
+
? ['Your config.yaml contains comments. js-yaml will drop them on re-emit. ' +
|
|
128
|
+
'A timestamped backup is saved before the write — restore from it if needed.']
|
|
129
|
+
: [],
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async apply(plan) {
|
|
134
|
+
if (plan.skip) return { applied: false, reason: plan.reason };
|
|
135
|
+
// js-yaml emit options: quote strings that need it, but use block
|
|
136
|
+
// style for maps (keeps it readable, matches Hermes's existing style).
|
|
137
|
+
const yamlOut = yaml.dump(plan.after, {
|
|
138
|
+
indent: 2,
|
|
139
|
+
lineWidth: 0,
|
|
140
|
+
noRefs: true,
|
|
141
|
+
sortKeys: false,
|
|
142
|
+
});
|
|
143
|
+
const result = writeConfigSafe(plan.configPath, yamlOut);
|
|
144
|
+
return {
|
|
145
|
+
applied: !result.unchanged,
|
|
146
|
+
unchanged: !!result.unchanged,
|
|
147
|
+
reason: result.unchanged ? 'config already up-to-date (byte-identical)' : null,
|
|
148
|
+
configPath: result.path,
|
|
149
|
+
backupPath: result.backupPath,
|
|
150
|
+
bytesWritten: result.bytesWritten,
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async disconnect() {
|
|
155
|
+
const det = await this.detect();
|
|
156
|
+
if (!det) return { applied: false, reason: 'Hermes not installed' };
|
|
157
|
+
if (det.parseError) return { applied: false, reason: `parse error: ${det.parseError}` };
|
|
158
|
+
const before = det.parsed || {};
|
|
159
|
+
const after = JSON.parse(JSON.stringify(before));
|
|
160
|
+
let changed = false;
|
|
161
|
+
|
|
162
|
+
if (after.providers && after.providers[PROVIDER_NAME_OPENAI]) {
|
|
163
|
+
delete after.providers[PROVIDER_NAME_OPENAI];
|
|
164
|
+
changed = true;
|
|
165
|
+
}
|
|
166
|
+
// Reset default provider if it was pointing at us.
|
|
167
|
+
if (after.model?.provider === `custom:${PROVIDER_NAME_OPENAI}`) {
|
|
168
|
+
// Don't pick a replacement — leave it for the user to re-set.
|
|
169
|
+
// Better than silently switching them to anthropic-direct (which
|
|
170
|
+
// would burn API tokens) or whatever else we'd guess.
|
|
171
|
+
after.model.provider = 'anthropic';
|
|
172
|
+
changed = true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!changed) return { applied: false, reason: 'No moby provider entry in Hermes config' };
|
|
176
|
+
|
|
177
|
+
const yamlOut = yaml.dump(after, { indent: 2, lineWidth: 0, noRefs: true, sortKeys: false });
|
|
178
|
+
const result = writeConfigSafe(det.configPath, yamlOut);
|
|
179
|
+
return {
|
|
180
|
+
applied: true,
|
|
181
|
+
configPath: result.path,
|
|
182
|
+
backupPath: result.backupPath,
|
|
183
|
+
note: after.model?.provider === 'anthropic'
|
|
184
|
+
? 'Reset model.provider to "anthropic" — verify ANTHROPIC_TOKEN is set if you intend to use direct API.'
|
|
185
|
+
: null,
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
};
|