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/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
  // ---------------------------------------------------------------------------
@@ -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
+ };