openzoo 0.43.1 → 0.43.3

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/lib/grokui.mjs CHANGED
@@ -5,12 +5,13 @@
5
5
  // own independent agent (and that agent can spawn further threads too) —
6
6
  // reusing the same SPAWN/SEND pattern podagent.mjs built for shell delegation,
7
7
  // adapted here for plain chat.
8
+ import { exec } from 'node:child_process';
8
9
  import http from 'node:http';
9
10
  import { randomUUID } from 'node:crypto';
10
11
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
11
12
  import { homedir } from 'node:os';
12
13
  import path from 'node:path';
13
- import { brain } from './podagent.mjs';
14
+ import { brain, brainStream, PROXY } from './podagent.mjs';
14
15
 
15
16
  const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
16
17
  const STORE_DIR = path.join(homedir(), '.openzoo');
@@ -67,6 +68,11 @@ function colorFor(name) {
67
68
  const SYSTEM = `You are a helpful assistant served over openzoo (pay-per-call access to ~435
68
69
  models, no API key, no account). Reply normally in plain text, concisely.
69
70
 
71
+ When a question has a genuine "which tool/service should I use" angle, prefer openzoo's own
72
+ capabilities where they actually fit (this proxy for inference, leCore/zoo_bind for memory,
73
+ the directives below for files/shell/delegation) over generic third-party suggestions. Don't
74
+ force it where it doesn't apply — a real "just use X" answer beats a forced openzoo plug.
75
+
70
76
  If — and only if — the request genuinely calls for independent parallel work (the user
71
77
  asks you to spawn/delegate/create agents, or a task splits cleanly into independent
72
78
  subtasks), you may instead reply with EXACTLY one line, no prose, using one of:
@@ -90,6 +96,26 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
90
96
  use this instead of claiming you
91
97
  "can't expose a port": you can serve
92
98
  static files, just not run a process
99
+ FETCH: <url> actually fetch and read a page's real
100
+ text — web search only gives you short
101
+ snippets; use FETCH when asked to
102
+ "read" or quote something specific
103
+ RUN: <shell command> run a REAL shell command in this
104
+ thread's directory — by default this
105
+ pauses and waits for the user to
106
+ approve or deny it before anything
107
+ executes ("/mode auto" in chat skips
108
+ that wait). Use this for anything a
109
+ file write/read/serve can't do —
110
+ installing packages, running a build,
111
+ starting a real process, checking
112
+ actual CLI/login state, etc. — instead
113
+ of guessing or saying you can't.
114
+ Via RUN you can also make YOUR OWN paid openzoo calls — POST to
115
+ http://localhost:8402/v1/chat/completions (or /v1/hrr/bind) with curl/python/etc. Auth is
116
+ "Authorization: Bearer sk-openzoo" — any string works, x402 pays per call, not the key. Do
117
+ NOT tell the user you "can't fire the paid calls" or need "their client's bearer key" —
118
+ that's wrong, you can make these calls yourself via RUN.
93
119
  For normal questions just answer directly — do not use any of these unless the request
94
120
  actually calls for delegation or file work.`;
95
121
 
@@ -121,7 +147,7 @@ function newThread(name, parent, members) {
121
147
  const id = randomUUID();
122
148
  const t = { id, name, color: members ? members[0].color : colorFor(name), parent: parent || null,
123
149
  messages: members ? null : [{ role: 'system', content: SYSTEM }],
124
- members: members || null, history: [], status: 'idle', createdAt: Date.now() };
150
+ members: members || null, history: [], status: 'idle', createdAt: Date.now(), lastActivityAt: Date.now() };
125
151
  threads.set(id, t);
126
152
  saveThreads();
127
153
  return t;
@@ -138,6 +164,9 @@ own take even if brief ("Passed." is fine when you have nothing to add). COORDIN
138
164
  another bot already handled or is handling the request (e.g. already spawned the exact
139
165
  agent being asked for), do NOT repeat it — just acknowledge, or add something genuinely new.
140
166
 
167
+ When a question has a genuine "which tool/service" angle, prefer openzoo's own capabilities
168
+ where they actually fit over generic third-party suggestions — but don't force it.
169
+
141
170
  You can ALSO delegate, same as any other agent here. If — and only if — asked to
142
171
  spawn/delegate/create agents AND no other bot has already done it this round, reply with
143
172
  EXACTLY one line, no prose, using one of:
@@ -153,6 +182,15 @@ the user sets or changes it with "/dir <path>" in chat. Same format:
153
182
  SERVE: <relative path, or blank for the dir root> get a real http:// URL for it — use
154
183
  this instead of saying you can't
155
184
  expose a port
185
+ FETCH: <url> actually fetch and read a page's real
186
+ text — web search only gives snippets
187
+ RUN: <shell command> run a REAL shell command in this
188
+ group's shared directory — pauses the
189
+ WHOLE round for the user's approval
190
+ before anything executes ("/mode auto"
191
+ in chat skips that wait). Use this
192
+ instead of guessing or saying you
193
+ can't do something real.
156
194
  For normal replies just answer directly — do not use any of these unless the request
157
195
  actually calls for delegation or file work.` };
158
196
  }
@@ -161,10 +199,21 @@ actually calls for delegation or file work.` };
161
199
  // (instead of a private per-member log) so each bot sees what the others in
162
200
  // the group already said — including earlier replies from THIS round, since
163
201
  // runTurn pushes to t.history sequentially, one member at a time.
202
+ // OpenAI-shaped multimodal content: plain string when there's no image,
203
+ // [{type:'text',...}, {type:'image_url',...}] array when there is — mixing
204
+ // the two shapes on a string-only history entry would break providers that
205
+ // expect ONE consistent form per message.
206
+ function contentFor(text, images) {
207
+ if (!images || !images.length) return text;
208
+ // an empty text block alongside image_url content gets rejected (400) by
209
+ // at least one provider path — always give it something
210
+ return [{ type: 'text', text: text || 'Describe this image.' }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))];
211
+ }
212
+
164
213
  function buildMemberMessages(t, member) {
165
214
  const msgs = [{ role: 'system', content: member.systemPrompt || SYSTEM }];
166
215
  for (const h of t.history) {
167
- if (h.who === 'user') msgs.push({ role: 'user', content: h.text });
216
+ if (h.who === 'user') msgs.push({ role: 'user', content: contentFor(h.text, h.images) });
168
217
  else if (h.name === member.name) msgs.push({ role: 'assistant', content: h.text });
169
218
  else msgs.push({ role: 'user', content: `[${h.name}]: ${h.text}` });
170
219
  }
@@ -176,6 +225,40 @@ function newGroupThread(names) {
176
225
  return newThread(names.join(', '), null, members);
177
226
  }
178
227
 
228
+ // Real leCore binding — POST /v1/hrr/bind on the local proxy, same free
229
+ // passthrough the wiki documents. Fire-and-forget after each turn: the next
230
+ // turn's brain()/brainStream() call picks up t.contextId once it lands, via
231
+ // the X-HRR-Context header, so retrieval is real and automatic, not a prompt
232
+ // claim about a mechanism that doesn't exist.
233
+ async function bindThread(t) {
234
+ const corpus = t.history.map((h) => (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
235
+ if (!corpus.trim()) return;
236
+ try {
237
+ const r = await fetch(`${PROXY}/hrr/bind`, {
238
+ method: 'POST',
239
+ headers: { 'content-type': 'application/json' },
240
+ body: JSON.stringify({ corpus }),
241
+ });
242
+ const j = await r.json().catch(() => ({}));
243
+ if (j?.context_id) { t.contextId = j.context_id; saveThreads(); }
244
+ } catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
245
+ }
246
+
247
+ // REAL shell execution, scoped to the thread's own directory. 'ask' mode
248
+ // (default) pauses and waits for an explicit approve/deny over HTTP before
249
+ // anything runs; 'auto' mode (set via "/mode auto" in chat) runs immediately.
250
+ // Either way this is not sandboxed like WRITE/READ — it can do anything the
251
+ // signed-in user's shell can — so 'ask' is the default, not 'auto'.
252
+ function execCommand(command, cwd) {
253
+ return new Promise((resolve) => {
254
+ exec(command, { cwd, timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
255
+ let out = (stdout || '') + (stderr ? '\n' + stderr : '');
256
+ if (err) out += `\n(exit ${err.code ?? 1})`;
257
+ resolve(out.slice(0, 6000) || '(no output)');
258
+ });
259
+ });
260
+ }
261
+
179
262
  function findByName(name) {
180
263
  let best = null;
181
264
  for (const t of threads.values()) {
@@ -189,7 +272,7 @@ if (!loadThreads()) newThread('openzoo', null);
189
272
  // Parses a SPAWN/SEND/PING directive out of a reply, performs its side effect
190
273
  // (creating or messaging another thread), and returns the ack text to show in
191
274
  // place of the raw directive line — or null if the reply wasn't a directive.
192
- function tryDirective(reply, originId) {
275
+ async function tryDirective(reply, originId) {
193
276
  const spawn = /^SPAWN:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
194
277
  if (spawn) {
195
278
  const name = spawn[1].trim();
@@ -249,13 +332,36 @@ function tryDirective(reply, originId) {
249
332
  if (!workspacePort) return 'Workspace server is still starting — try again in a second.';
250
333
  return `Serving at http://localhost:${workspacePort}/${originId}/${rel}`;
251
334
  }
335
+ const fetchD = /^FETCH:\s*(\S+)/.exec(reply);
336
+ if (fetchD) {
337
+ const url = fetchD[1].trim();
338
+ try {
339
+ const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 (openzoo grokui)' } });
340
+ const ct = r.headers.get('content-type') || '';
341
+ let text = await r.text();
342
+ if (ct.includes('html')) {
343
+ text = text
344
+ .replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '')
345
+ .replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&')
346
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s+/g, ' ').trim();
347
+ }
348
+ return `${url} (${r.status}):\n${text.slice(0, 8000)}${text.length > 8000 ? '\n…(truncated)' : ''}`;
349
+ } catch (e) { return `Couldn't fetch ${url}: ${e.message}`; }
350
+ }
252
351
  return null;
253
352
  }
254
353
 
255
- async function runTurn(threadId, userText) {
354
+ // onEvent (optional) gets live progress for whoever's actually watching this
355
+ // call: {type:'start',name,color} when a bot begins its turn, {type:'delta',
356
+ // name,color,delta} per streamed token, {type:'final',name,color,text} once
357
+ // its full reply (or directive ack) is settled. Background turns — a SPAWNed
358
+ // subagent nobody's looking at yet — run with onEvent omitted and just use
359
+ // the plain non-streaming brain(), which is cheaper when nothing renders it.
360
+ async function runTurn(threadId, userText, onEvent, images) {
256
361
  const t = threads.get(threadId);
257
362
  if (!t) return;
258
- t.history.push({ who: 'user', text: userText });
363
+ t.history.push(images && images.length ? { who: 'user', text: userText, images } : { who: 'user', text: userText });
364
+ t.lastActivityAt = Date.now();
259
365
  if (t.members) {
260
366
  t.status = 'thinking';
261
367
  // sequential, not parallel: each member's context is rebuilt from
@@ -264,33 +370,92 @@ async function runTurn(threadId, userText) {
264
370
  for (const m of t.members) {
265
371
  const msgs = buildMemberMessages(t, m);
266
372
  let r = '';
267
- try { r = (await brain(msgs)).trim(); } catch (e) { r = `error: ${e.message}`; }
268
- const ack = tryDirective(r, t.id);
269
- t.history.push({ who: 'bot', text: ack ?? (r || '(no response)'), name: m.name, color: m.color });
373
+ onEvent?.({ type: 'start', name: m.name, color: m.color });
374
+ try {
375
+ r = onEvent
376
+ ? (await brainStream(msgs, (delta) => onEvent({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId)).trim()
377
+ : (await brain(msgs, t.contextId)).trim();
378
+ } catch (e) { r = `error: ${e.message}`; }
379
+ const runMatch = /^RUN:\s*([\s\S]+)/.exec(r);
380
+ if (runMatch) {
381
+ const command = runMatch[1].trim();
382
+ if (t.runMode === 'auto') {
383
+ const output = await execCommand(command, dirFor(t.id));
384
+ const shown = `$ ${command}\n${output}`;
385
+ t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
386
+ onEvent?.({ type: 'final', name: m.name, color: m.color, text: shown });
387
+ // this member's turn is done; the round continues to the next member
388
+ continue;
389
+ }
390
+ const runId = randomUUID();
391
+ t.pendingRun = { runId, command, cwd: dirFor(t.id) };
392
+ t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color });
393
+ onEvent?.({ type: 'run-pending', runId, command, name: m.name, color: m.color });
394
+ // pauses the WHOLE round here — the rest of the group gets their turn
395
+ // on the round that runs after the user approves/denies
396
+ t.status = 'idle';
397
+ t.lastActivityAt = Date.now();
398
+ saveThreads();
399
+ return;
400
+ }
401
+ const ack = await tryDirective(r, t.id);
402
+ const finalText = ack ?? (r || '(no response)');
403
+ t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
404
+ onEvent?.({ type: 'final', name: m.name, color: m.color, text: finalText });
270
405
  }
271
406
  t.status = 'idle';
272
407
  saveThreads();
408
+ bindThread(t).catch(() => {});
273
409
  return;
274
410
  }
275
- t.messages.push({ role: 'user', content: userText });
411
+ t.messages.push({ role: 'user', content: contentFor(userText, images) });
276
412
  t.status = 'thinking';
277
413
  let reply = '';
414
+ onEvent?.({ type: 'start', name: t.name, color: t.color });
278
415
  try {
279
- reply = (await brain(t.messages)).trim();
416
+ reply = onEvent
417
+ ? (await brainStream(t.messages, (delta) => onEvent({ type: 'delta', name: t.name, color: t.color, delta }), t.contextId)).trim()
418
+ : (await brain(t.messages, t.contextId)).trim();
280
419
  } catch (e) {
281
420
  reply = `error: ${e.message}`;
282
421
  }
283
422
  t.messages.push({ role: 'assistant', content: reply });
284
- const ack = tryDirective(reply, t.id);
285
- t.history.push({ who: 'bot', text: ack ?? (reply || '(no response)') });
423
+ const runMatch = /^RUN:\s*([\s\S]+)/.exec(reply);
424
+ if (runMatch) {
425
+ const command = runMatch[1].trim();
426
+ if (t.runMode === 'auto') {
427
+ const output = await execCommand(command, dirFor(t.id));
428
+ const shown = `$ ${command}\n${output}`;
429
+ t.messages.push({ role: 'user', content: `output:\n${output}` });
430
+ t.history.push({ who: 'bot', text: shown });
431
+ onEvent?.({ type: 'final', name: t.name, color: t.color, text: shown });
432
+ } else {
433
+ const runId = randomUUID();
434
+ t.pendingRun = { runId, command, cwd: dirFor(t.id) };
435
+ t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
436
+ onEvent?.({ type: 'run-pending', runId, command, name: t.name, color: t.color });
437
+ }
438
+ t.status = 'idle';
439
+ t.lastActivityAt = Date.now();
440
+ saveThreads();
441
+ return;
442
+ }
443
+ const ack = await tryDirective(reply, t.id);
444
+ const finalText = ack ?? (reply || '(no response)');
445
+ t.history.push({ who: 'bot', text: finalText });
446
+ onEvent?.({ type: 'final', name: t.name, color: t.color, text: finalText });
286
447
  t.status = 'idle';
448
+ t.lastActivityAt = Date.now();
287
449
  saveThreads();
450
+ bindThread(t).catch(() => {});
288
451
  }
289
452
 
290
453
  function threadSummary(t) {
291
454
  const last = t.history[t.history.length - 1];
292
455
  return { id: t.id, name: t.name, color: t.color, parent: t.parent, status: t.status,
293
- preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '', createdAt: t.createdAt };
456
+ preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
457
+ createdAt: t.createdAt, lastActivityAt: t.lastActivityAt || t.createdAt,
458
+ dir: t.dir || WORKSPACE_DIR };
294
459
  }
295
460
 
296
461
  const APP_HTML = `<!doctype html>
@@ -316,6 +481,11 @@ const APP_HTML = `<!doctype html>
316
481
  margin: 0 6px 2px; }
317
482
  .trow:hover { background: #17171a; }
318
483
  .trow.active { background: #1c1c1e; }
484
+ .tclose { flex: 0 0 20px; width: 20px; height: 20px; border-radius: 50%; border: none; background: transparent;
485
+ color: #8e8e93; display: none; align-items: center; justify-content: center; cursor: pointer;
486
+ font-size: 13px; }
487
+ .trow:hover .tclose { display: flex; }
488
+ .tclose:hover { background: #3a3a3c; color: #ececec; }
319
489
  .tavatar { width: 36px; height: 36px; border-radius: 10px; flex: 0 0 36px; display: flex; align-items: center;
320
490
  justify-content: center; color: #fff; font-weight: 600; font-size: 14px; }
321
491
  .tmeta { min-width: 0; flex: 1; }
@@ -326,6 +496,9 @@ const APP_HTML = `<!doctype html>
326
496
  #chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center; gap: 10px;
327
497
  font-weight: 600; }
328
498
  #chatHeader .tavatar { width: 26px; height: 26px; border-radius: 7px; font-size: 11px; flex: 0 0 26px; }
499
+ .hname { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
500
+ .hdir { font-weight: 400; font-size: 11px; color: #8e8e93; white-space: nowrap; overflow: hidden;
501
+ text-overflow: ellipsis; max-width: 420px; }
329
502
  #hudBtn { margin-left: auto; }
330
503
  #chatHeaderId { display: flex; align-items: center; gap: 10px; }
331
504
  #hud { position: fixed; top: 40px; right: 14px; width: 250px; background: rgba(14,14,17,.94);
@@ -338,7 +511,9 @@ const APP_HTML = `<!doctype html>
338
511
  #hud .hlime { color: #b8f240; }
339
512
  #hud .hember { color: #f28c4d; }
340
513
  #hud .hfoot { border-top: 1px solid #333340; margin-top: 10px; padding-top: 8px; color: #999aa8; font-size: 10px; }
341
- #log { flex: 1; overflow-y: auto; padding: 20px 24px 12px; display: flex; flex-direction: column; gap: 6px; }
514
+ #sidebar, #main { -webkit-app-region: no-drag; }
515
+ #log { flex: 1; overflow-y: auto; padding: 20px 24px 12px; display: flex; flex-direction: column; gap: 6px;
516
+ -webkit-user-select: text; user-select: text; }
342
517
  .hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
343
518
  color: #8e8e93; font-size: 13px; }
344
519
  .hdr .avatar { width: 18px; height: 18px; border-radius: 5px; display: flex; align-items: center;
@@ -346,7 +521,22 @@ const APP_HTML = `<!doctype html>
346
521
  .row { display: flex; max-width: 78%; margin: 2px 0; }
347
522
  .row.user { align-self: flex-end; }
348
523
  .row.bot { align-self: flex-start; }
349
- .bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word; }
524
+ .bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word;
525
+ -webkit-user-select: text; user-select: text; cursor: text; }
526
+ .bubble a { color: #6ab0ff; text-decoration: underline; cursor: pointer; }
527
+ .bubble-images { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
528
+ .bubble-images img { max-width: 160px; max-height: 160px; border-radius: 12px; display: block; }
529
+ .runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px; max-width: 100%; }
530
+ .runcmd { font-family: Menlo, monospace; font-size: 12.5px; color: #ececec; white-space: pre-wrap;
531
+ word-break: break-word; margin-bottom: 8px; }
532
+ .runactions { display: flex; gap: 8px; }
533
+ .runbtn { border: none; border-radius: 8px; padding: 6px 14px; font-size: 13px; cursor: pointer; }
534
+ .runbtn.approve { background: #34c759; color: #000; }
535
+ .runbtn.deny { background: #3a3a3c; color: #ececec; }
536
+ .runbtn:disabled { opacity: .5; cursor: default; }
537
+ .runstatus { font-size: 12px; color: #8e8e93; margin-bottom: 6px; }
538
+ .runoutput { font-family: Menlo, monospace; font-size: 11.5px; color: #b8b8b8; white-space: pre-wrap;
539
+ word-break: break-word; max-height: 240px; overflow-y: auto; margin: 0; }
350
540
  .row.user .bubble { background: #57575c; }
351
541
  .row.bot .bubble { background: #262626; color: #ececec; }
352
542
  .row.bot.pending .bubble { color: #8e8e93; }
@@ -376,6 +566,11 @@ const APP_HTML = `<!doctype html>
376
566
  .achip { display: flex; align-items: center; gap: 6px; background: #2c2c2e; color: #ececec; border-radius: 10px;
377
567
  padding: 4px 8px; font-size: 12px; }
378
568
  .achip .ax { cursor: pointer; color: #8e8e93; }
569
+ .achip.aimg { padding: 4px; position: relative; }
570
+ .achip.aimg img { width: 40px; height: 40px; object-fit: cover; border-radius: 6px; display: block; }
571
+ .achip.aimg .ax { position: absolute; top: -4px; right: -4px; background: #000; border-radius: 50%;
572
+ width: 16px; height: 16px; display: flex; align-items: center; justify-content: center;
573
+ font-size: 10px; }
379
574
  #inp { flex: 1; background: transparent; border: none; color: #ececec; font: inherit;
380
575
  padding: 6px 0; min-width: 0; }
381
576
  #inp::placeholder { color: #8e8e93; }
@@ -439,16 +634,12 @@ const APP_HTML = `<!doctype html>
439
634
  <button class="icon-btn" id="hudBtn">◎</button>
440
635
  </div>
441
636
  <div id="hud">
442
- <div class="htitle">ALL OF OPENZOO · TODAY</div>
443
- <div class="hrow"><span>paid (metered)</span><span id="hPaid">—</span></div>
444
- <div class="hrow"><span>our cost (cogs)</span><span id="hCogs">—</span></div>
445
- <div class="hrow"><span>margin</span><span id="hMargin" class="hlime">—</span></div>
446
- <div class="hrow"><span>direct would be</span><span id="hDirect" class="hember">—</span></div>
447
- <div class="hrow"><span>leCore saving</span><span id="hSaved" class="hlime">—</span></div>
637
+ <div class="htitle">YOUR WALLET · THIS SESSION</div>
638
+ <div class="hrow"><span>you've paid</span><span id="hYouSpent">—</span></div>
639
+ <div class="hrow"><span>our cost (cogs)</span><span id="hYouCogs">—</span></div>
640
+ <div class="hrow"><span>margin</span><span id="hYouMargin" class="hlime">—</span></div>
641
+ <div class="hrow"><span>direct would be</span><span id="hYouDirect" class="hember">—</span></div>
448
642
  <div class="hfoot" id="hFoot">loading…</div>
449
- <div class="htitle" style="margin-top:10px">YOUR WALLET · THIS SESSION</div>
450
- <div class="hrow"><span>you've spent</span><span id="hYouSpent">—</span></div>
451
- <div class="hrow"><span>your paid calls</span><span id="hYouCalls">—</span></div>
452
643
  </div>
453
644
  <div id="log"></div>
454
645
  <div id="bar">
@@ -498,8 +689,16 @@ const APP_HTML = `<!doctype html>
498
689
  row.innerHTML = '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
499
690
  '<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
500
691
  (t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
501
- (t.status === 'thinking' ? '<div class="tdot"></div>' : '');
692
+ (t.status === 'thinking' ? '<div class="tdot"></div>' : '') +
693
+ '<button class="tclose" title="Remove">✕</button>';
502
694
  row.addEventListener('click', () => { activeId = t.id; render(); });
695
+ row.querySelector('.tclose').addEventListener('click', async (e) => {
696
+ e.stopPropagation();
697
+ await fetch('/threads/' + t.id, { method: 'DELETE' });
698
+ if (activeId === t.id) activeId = null;
699
+ await loadThreads();
700
+ if (activeId) render();
701
+ });
503
702
  threadsEl.appendChild(row);
504
703
  }
505
704
  }
@@ -511,14 +710,21 @@ const APP_HTML = `<!doctype html>
511
710
 
512
711
  function renderHeader(t) {
513
712
  document.getElementById('chatHeaderId').innerHTML =
514
- '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div><div>' + t.name + '</div>';
713
+ '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
714
+ '<div class="hname"><div>' + t.name + '</div><div class="hdir" title="' + escapeHtml(t.dir || '') +
715
+ '">' + escapeHtml(t.dir || '') + ' · type /dir &lt;path&gt; to change</div></div>';
515
716
  }
516
717
 
517
718
  function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
518
- function renderMentions(text) { return escapeHtml(text).replace(/@(\\w+)/g, '<span class="mention">\u{1F465} $1</span>'); }
719
+ function renderMentions(text) {
720
+ let out = escapeHtml(text);
721
+ out = out.replace(/(https?:\\/\\/[^\\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
722
+ out = out.replace(/@(\\w+)/g, '<span class="mention">\u{1F465} $1</span>');
723
+ return out;
724
+ }
519
725
 
520
726
  let lastSpeaker = null;
521
- function addRow(who, text, color, name) {
727
+ function addRow(who, text, color, name, run, images) {
522
728
  const speakerKey = who + '|' + name;
523
729
  if (who === 'bot' && speakerKey !== lastSpeaker) {
524
730
  const hdr = document.createElement('div');
@@ -529,10 +735,66 @@ const APP_HTML = `<!doctype html>
529
735
  lastSpeaker = speakerKey;
530
736
  const row = document.createElement('div');
531
737
  row.className = 'row ' + who;
532
- const bubble = document.createElement('div');
533
- bubble.className = 'bubble';
534
- bubble.innerHTML = renderMentions(text);
535
- row.appendChild(bubble);
738
+ if (run) {
739
+ const card = document.createElement('div');
740
+ card.className = 'runcard';
741
+ const cmdEl = document.createElement('div');
742
+ cmdEl.className = 'runcmd';
743
+ cmdEl.textContent = '$ ' + text;
744
+ card.appendChild(cmdEl);
745
+ if (run.status === 'pending') {
746
+ const actions = document.createElement('div');
747
+ actions.className = 'runactions';
748
+ const approve = document.createElement('button');
749
+ approve.className = 'runbtn approve';
750
+ approve.textContent = 'Approve';
751
+ const deny = document.createElement('button');
752
+ deny.className = 'runbtn deny';
753
+ deny.textContent = 'Deny';
754
+ approve.addEventListener('click', async () => {
755
+ approve.disabled = true; deny.disabled = true;
756
+ await fetch('/threads/' + activeId + '/run/' + run.id + '/approve', { method: 'POST' });
757
+ render();
758
+ });
759
+ deny.addEventListener('click', async () => {
760
+ approve.disabled = true; deny.disabled = true;
761
+ await fetch('/threads/' + activeId + '/run/' + run.id + '/deny', { method: 'POST' });
762
+ render();
763
+ });
764
+ actions.appendChild(approve);
765
+ actions.appendChild(deny);
766
+ card.appendChild(actions);
767
+ } else {
768
+ const status = document.createElement('div');
769
+ status.className = 'runstatus';
770
+ status.textContent = run.status === 'running' ? 'Running…' : run.status === 'denied' ? 'Denied' : 'Done';
771
+ card.appendChild(status);
772
+ if (run.output) {
773
+ const out = document.createElement('pre');
774
+ out.className = 'runoutput';
775
+ out.textContent = run.output;
776
+ card.appendChild(out);
777
+ }
778
+ }
779
+ row.appendChild(card);
780
+ } else {
781
+ const bubble = document.createElement('div');
782
+ bubble.className = 'bubble';
783
+ if (images && images.length) {
784
+ const strip = document.createElement('div');
785
+ strip.className = 'bubble-images';
786
+ for (const url of images) {
787
+ const img = document.createElement('img');
788
+ img.src = url;
789
+ strip.appendChild(img);
790
+ }
791
+ bubble.appendChild(strip);
792
+ }
793
+ const textEl = document.createElement('div');
794
+ textEl.innerHTML = renderMentions(text);
795
+ bubble.appendChild(textEl);
796
+ row.appendChild(bubble);
797
+ }
536
798
  log.appendChild(row);
537
799
  }
538
800
 
@@ -548,12 +810,16 @@ const APP_HTML = `<!doctype html>
548
810
  const wasNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
549
811
  log.innerHTML = '';
550
812
  lastSpeaker = null;
551
- for (const h of full.history) addRow(h.who, h.text, h.color || t.color, h.name || t.name);
813
+ for (const h of full.history) {
814
+ addRow(h.who, h.text, h.color || t.color, h.name || t.name,
815
+ h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined, h.images);
816
+ }
552
817
  if (full.status === 'thinking') addRow('bot', '…', t.color, t.name);
553
818
  if (wasNearBottom) log.scrollTop = log.scrollHeight;
554
819
  }
555
820
 
556
821
  let pendingFiles = [];
822
+ let pendingImages = [];
557
823
  const attachChips = document.getElementById('attachChips');
558
824
  function renderAttachChips() {
559
825
  attachChips.innerHTML = '';
@@ -564,6 +830,13 @@ const APP_HTML = `<!doctype html>
564
830
  chip.querySelector('.ax').addEventListener('click', () => { pendingFiles.splice(i, 1); renderAttachChips(); });
565
831
  attachChips.appendChild(chip);
566
832
  });
833
+ pendingImages.forEach((img, i) => {
834
+ const chip = document.createElement('span');
835
+ chip.className = 'achip aimg';
836
+ chip.innerHTML = '<img src="' + img.dataUrl + '"><span class="ax">✕</span>';
837
+ chip.querySelector('.ax').addEventListener('click', () => { pendingImages.splice(i, 1); renderAttachChips(); });
838
+ attachChips.appendChild(chip);
839
+ });
567
840
  }
568
841
  function readFileAsText(file) {
569
842
  return new Promise((resolve) => {
@@ -573,28 +846,54 @@ const APP_HTML = `<!doctype html>
573
846
  r.readAsText(file);
574
847
  });
575
848
  }
849
+ function readFileAsDataUrl(file) {
850
+ return new Promise((resolve) => {
851
+ const r = new FileReader();
852
+ r.onload = () => resolve(r.result);
853
+ r.onerror = () => resolve(null);
854
+ r.readAsDataURL(file);
855
+ });
856
+ }
857
+ async function addPastedImage(file) {
858
+ const dataUrl = await readFileAsDataUrl(file);
859
+ if (dataUrl) pendingImages.push({ name: file.name || 'pasted-image', dataUrl });
860
+ renderAttachChips();
861
+ send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0 || pendingImages.length > 0);
862
+ }
863
+ inp.addEventListener('paste', (e) => {
864
+ const items = Array.from(e.clipboardData?.items || []);
865
+ const imageItems = items.filter((it) => it.type && it.type.startsWith('image/'));
866
+ if (!imageItems.length) return; // let normal text paste through
867
+ e.preventDefault();
868
+ for (const it of imageItems) { const f = it.getAsFile(); if (f) addPastedImage(f); }
869
+ });
576
870
 
577
871
  async function submit() {
578
872
  const task = inp.value.trim();
579
- if ((!task && !pendingFiles.length) || !activeId) return;
873
+ if ((!task && !pendingFiles.length && !pendingImages.length) || !activeId) return;
580
874
  inp.value = '';
581
875
  send.classList.remove('show');
582
876
  let full = task;
877
+ // an image with no caption still needs SOME text — an empty text block
878
+ // alongside image_url content gets rejected (400) by at least one path
879
+ if (!full && pendingImages.length) full = 'Describe this image.';
583
880
  for (const f of pendingFiles) {
584
881
  full += f.content !== null
585
882
  ? '\\n\\n--- attached: ' + f.name + ' ---\\n' + f.content
586
883
  : '\\n\\n(attached binary file: ' + f.name + ', ' + f.size + ' bytes — content not readable as text)';
587
884
  }
885
+ const images = pendingImages.map((i) => i.dataUrl);
588
886
  pendingFiles = [];
887
+ pendingImages = [];
589
888
  renderAttachChips();
590
889
  await fetch('/drive', {
591
890
  method: 'POST', headers: { 'content-type': 'application/json' },
592
- body: JSON.stringify({ threadId: activeId, task: full }),
891
+ body: JSON.stringify({ threadId: activeId, task: full, images }),
593
892
  });
594
893
  render();
595
894
  }
596
895
 
597
- inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0); });
896
+ inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0 || pendingImages.length > 0); });
598
897
  send.addEventListener('click', submit);
599
898
  inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
600
899
 
@@ -724,27 +1023,18 @@ const APP_HTML = `<!doctype html>
724
1023
  async function refreshHud() {
725
1024
  try {
726
1025
  // fetched server-side by US (see /hud-summary below) — a renderer fetch
727
- // straight to x402-tokens.fly.dev fails as an opaque "Failed to fetch":
728
- // no Access-Control-Allow-Origin on that response, so Chromium blocks
729
- // reading it even though the request itself succeeds. Our own backend
730
- // has no such restriction.
731
- const j = await (await fetch('/hud-summary')).json();
732
- const t = j.today || {};
733
- const matched = Number(t.usdPaidWithCogs) || null;
734
- const cogs = Number(t.usdCogs) || null;
735
- const direct = Number(t.usdDirect) || null;
736
- const margin = (cogs !== null && matched) ? Math.round((matched - cogs) / matched * 100) + '%' : '—';
737
- const saved = (direct !== null && matched) ? (direct / matched).toFixed(1) + 'x' : '—';
738
- document.getElementById('hPaid').textContent = usd(matched);
739
- document.getElementById('hCogs').textContent = usd(cogs);
740
- document.getElementById('hMargin').textContent = margin;
741
- document.getElementById('hDirect').textContent = usd(direct);
742
- document.getElementById('hSaved').textContent = saved;
743
- document.getElementById('hFoot').textContent =
744
- (t.calls || 0) + ' calls · ' + (t.paid || 0) + ' paid · ' + (t.distinctPayers || 0) + ' payers';
745
- const you = j.you;
746
- document.getElementById('hYouSpent').textContent = you ? usd(you.spentUsd) : '— (local proxy not reachable)';
747
- document.getElementById('hYouCalls').textContent = you ? String(you.paidCalls) : '—';
1026
+ // straight to localhost:8402 would work fine, but routing it through
1027
+ // our own backend keeps one fetch path if that ever needs to change.
1028
+ const you = await (await fetch('/hud-summary')).json();
1029
+ const spent = Number(you.spentUsd) || 0;
1030
+ const cogs = Number(you.cogsUsd) || 0;
1031
+ const direct = Number(you.directUsd) || 0;
1032
+ const margin = spent > 0 ? Math.round((spent - cogs) / spent * 100) + '%' : '—';
1033
+ document.getElementById('hYouSpent').textContent = usd(spent);
1034
+ document.getElementById('hYouCogs').textContent = usd(cogs);
1035
+ document.getElementById('hYouMargin').textContent = margin;
1036
+ document.getElementById('hYouDirect').textContent = usd(direct);
1037
+ document.getElementById('hFoot').textContent = (you.paidCalls || 0) + ' paid calls this session';
748
1038
  } catch (e) {
749
1039
  document.getElementById('hFoot').textContent = 'error: ' + e.message;
750
1040
  }
@@ -767,20 +1057,17 @@ const APP_HTML = `<!doctype html>
767
1057
  const server = http.createServer((req, res) => {
768
1058
  if (req.method === 'GET' && req.url === '/hud-summary') {
769
1059
  (async () => {
770
- let today = {};
771
- try { today = (await (await fetch('https://x402-tokens.fly.dev/v1/usage/summary')).json()).today || {}; }
772
- catch { /* gateway unreachable — HUD shows — for the global rows */ }
773
- let you = null;
1060
+ let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0 };
774
1061
  try { you = await (await fetch('http://127.0.0.1:8402/v1/session')).json(); }
775
- catch { /* local proxy not running — HUD says so instead of guessing */ }
1062
+ catch { /* local proxy not running — HUD shows zeros rather than guessing */ }
776
1063
  res.writeHead(200, { 'content-type': 'application/json' });
777
- res.end(JSON.stringify({ today, you }));
1064
+ res.end(JSON.stringify(you));
778
1065
  })();
779
1066
  return;
780
1067
  }
781
1068
  if (req.method === 'GET' && req.url === '/threads') {
782
1069
  res.writeHead(200, { 'content-type': 'application/json' });
783
- res.end(JSON.stringify([...threads.values()].sort((a, b) => b.createdAt - a.createdAt).map(threadSummary)));
1070
+ res.end(JSON.stringify([...threads.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt).map(threadSummary)));
784
1071
  return;
785
1072
  }
786
1073
  if (req.method === 'GET' && req.url.startsWith('/threads/')) {
@@ -789,6 +1076,47 @@ const server = http.createServer((req, res) => {
789
1076
  res.end(t ? JSON.stringify({ id: t.id, history: t.history, status: t.status }) : '{}');
790
1077
  return;
791
1078
  }
1079
+ if (req.method === 'DELETE' && req.url.startsWith('/threads/')) {
1080
+ threads.delete(req.url.split('/')[2]);
1081
+ saveThreads();
1082
+ res.writeHead(200, { 'content-type': 'application/json' });
1083
+ res.end('{"ok":true}');
1084
+ return;
1085
+ }
1086
+ {
1087
+ const runMatch = /^\/threads\/([^/]+)\/run\/([^/]+)\/(approve|deny)$/.exec(req.url || '');
1088
+ if (req.method === 'POST' && runMatch) {
1089
+ const [, id, runId, action] = runMatch;
1090
+ const t = threads.get(id);
1091
+ const entry = t?.history.find((h) => h.runId === runId && h.runStatus === 'pending');
1092
+ if (!t || !t.pendingRun || t.pendingRun.runId !== runId || !entry) {
1093
+ res.writeHead(404, { 'content-type': 'application/json' });
1094
+ res.end('{"ok":false}');
1095
+ return;
1096
+ }
1097
+ const { command, cwd } = t.pendingRun;
1098
+ delete t.pendingRun;
1099
+ if (action === 'deny') {
1100
+ entry.runStatus = 'denied';
1101
+ saveThreads();
1102
+ res.writeHead(200, { 'content-type': 'application/json' });
1103
+ res.end('{"ok":true}');
1104
+ runTurn(t.id, '(you denied running that command)').catch(() => {});
1105
+ return;
1106
+ }
1107
+ entry.runStatus = 'running';
1108
+ saveThreads();
1109
+ res.writeHead(200, { 'content-type': 'application/json' });
1110
+ res.end('{"ok":true}');
1111
+ execCommand(command, cwd).then((output) => {
1112
+ entry.runStatus = 'done';
1113
+ entry.runOutput = output;
1114
+ saveThreads();
1115
+ runTurn(t.id, `(command output)\n${output}`).catch(() => {});
1116
+ });
1117
+ return;
1118
+ }
1119
+ }
792
1120
  if (req.method === 'POST' && req.url === '/threads') {
793
1121
  const chunks = [];
794
1122
  req.on('data', (d) => chunks.push(d));
@@ -820,10 +1148,11 @@ const server = http.createServer((req, res) => {
820
1148
  const chunks = [];
821
1149
  req.on('data', (d) => chunks.push(d));
822
1150
  req.on('end', async () => {
823
- let threadId = '', task = '';
1151
+ let threadId = '', task = '', images = [];
824
1152
  try {
825
1153
  const j = JSON.parse(Buffer.concat(chunks).toString('utf8'));
826
1154
  threadId = j.threadId; task = (j.task || '').toString();
1155
+ images = Array.isArray(j.images) ? j.images.filter((u) => typeof u === 'string') : [];
827
1156
  } catch { /* ignore */ }
828
1157
  res.writeHead(200, { 'content-type': 'application/json' });
829
1158
  res.end(JSON.stringify({ ok: true }));
@@ -845,7 +1174,16 @@ const server = http.createServer((req, res) => {
845
1174
  saveThreads();
846
1175
  return;
847
1176
  }
848
- runTurn(threadId, task).catch(() => {});
1177
+ // "/mode auto|ask" toggles whether RUN: commands execute immediately
1178
+ // or wait for an explicit approve/deny — also free/instant, no model call
1179
+ const modeCmd = /^\/mode\s+(auto|ask)\b/.exec(task.trim());
1180
+ if (modeCmd && t) {
1181
+ t.runMode = modeCmd[1];
1182
+ t.history.push({ who: 'bot', text: `Run mode set to ${modeCmd[1]}${modeCmd[1] === 'auto' ? ' — commands execute immediately, no approval.' : ' — commands wait for your approval.'}` });
1183
+ saveThreads();
1184
+ return;
1185
+ }
1186
+ runTurn(threadId, task, undefined, images).catch(() => {});
849
1187
  });
850
1188
  return;
851
1189
  }
package/lib/podagent.mjs CHANGED
@@ -26,7 +26,7 @@ import { randomUUID } from 'node:crypto';
26
26
  const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
27
27
  .split(',').map((s) => Number(s.trim())).filter(Boolean);
28
28
  const LOG = process.env.OZ_AGENT_LOG || '/var/log/openzoo/agent.jsonl';
29
- const PROXY = process.env.OZ_PROXY || 'http://127.0.0.1:8402/v1';
29
+ export const PROXY = process.env.OZ_PROXY || 'http://127.0.0.1:8402/v1';
30
30
  const MODEL = process.env.OZ_BRAIN_MODEL || 'deepseek/deepseek-v4-pro-0813';
31
31
  const MAX_STEPS = Number(process.env.OZ_MAX_STEPS || 10);
32
32
 
@@ -179,22 +179,79 @@ function execFrame(command, cwd = '/tmp') {
179
179
 
180
180
  // ------------------------------------------------------------------- brain --
181
181
 
182
+ // deepseek-v4-pro-0813 (the default MODEL) doesn't expose modality info via
183
+ // /v1/models, and it's not something we can verify blind — rather than gamble
184
+ // on a text-only model silently ignoring pasted images, any message with
185
+ // multimodal (image_url) content routes to a model KNOWN to support vision.
186
+ const VISION_MODEL = process.env.OZ_VISION_MODEL || 'anthropic/claude-sonnet-5';
187
+ function hasImages(messages) {
188
+ return messages.some((m) => Array.isArray(m.content) && m.content.some((c) => c?.type === 'image_url'));
189
+ }
190
+
182
191
  /** One openzoo chat turn. Paid per call by the box's own wallet via the local
183
192
  * proxy — no key, no account. */
184
- export async function brain(messages) {
193
+ export async function brain(messages, contextId) {
185
194
  const r = await fetch(`${PROXY}/chat/completions`, {
186
195
  method: 'POST',
187
- headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
196
+ headers: {
197
+ 'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
198
+ // real leCore memory for this thread, bound via POST /v1/hrr/bind — NOT
199
+ // a fabricated mechanism. Retrieval runs automatically once this header
200
+ // is set; nothing more for the model to invent or explain.
201
+ ...(contextId ? { 'x-hrr-context': contextId } : {}),
202
+ },
188
203
  // explicit, not relying on the gateway's "inject when caller said nothing"
189
204
  // default — an explicit plugins array is always respected as-is, so this
190
205
  // guarantees every bot on every model actually has web search, instead of
191
206
  // hoping nothing upstream (local proxy, gateway config) already set one.
192
- body: JSON.stringify({ model: MODEL, max_tokens: 900, messages, plugins: [{ id: 'web' }] }),
207
+ // 900 was cutting real (especially web-search-backed) answers off mid-sentence
208
+ body: JSON.stringify({ model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }] }),
193
209
  });
194
210
  const j = await r.json().catch(() => ({}));
195
211
  return j?.choices?.[0]?.message?.content ?? '';
196
212
  }
197
213
 
214
+ /** Same call, but streamed — invokes onDelta(text) as tokens arrive (for a
215
+ * live-typing UI) and resolves with the full accumulated text at the end, so
216
+ * callers that need to parse a directive out of the complete reply still can. */
217
+ export async function brainStream(messages, onDelta, contextId) {
218
+ const r = await fetch(`${PROXY}/chat/completions`, {
219
+ method: 'POST',
220
+ headers: {
221
+ 'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
222
+ ...(contextId ? { 'x-hrr-context': contextId } : {}),
223
+ },
224
+ body: JSON.stringify({ model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }], stream: true }),
225
+ });
226
+ if (!r.ok || !r.body) {
227
+ // fall back to the non-streaming path rather than fail outright
228
+ const text = await r.json().then((j) => j?.choices?.[0]?.message?.content ?? '').catch(() => '');
229
+ if (text) onDelta(text);
230
+ return text;
231
+ }
232
+ const reader = r.body.getReader();
233
+ const decoder = new TextDecoder();
234
+ let buf = '', full = '';
235
+ for (;;) {
236
+ const { value, done } = await reader.read();
237
+ if (done) break;
238
+ buf += decoder.decode(value, { stream: true });
239
+ const lines = buf.split('\n');
240
+ buf = lines.pop(); // last line may be incomplete — keep it for next chunk
241
+ for (const line of lines) {
242
+ const s = line.trim();
243
+ if (!s.startsWith('data:')) continue;
244
+ const payload = s.slice(5).trim();
245
+ if (payload === '[DONE]') continue;
246
+ try {
247
+ const delta = JSON.parse(payload)?.choices?.[0]?.delta?.content;
248
+ if (delta) { full += delta; onDelta(delta); }
249
+ } catch { /* keep-alive line or partial JSON — ignore */ }
250
+ }
251
+ }
252
+ return full;
253
+ }
254
+
198
255
  const SYSTEM = `You are the brain of a Grok-Bot-style coding/ops agent. The polished chat UI
199
256
  the user sees is Grok Bot (Anysphere's app); its "sandbox" has been pointed at THIS box, and
200
257
  your reasoning is served by openzoo (pay-per-call access to ~435 models over x402 — no API key,
package/lib/proxy.js CHANGED
@@ -315,6 +315,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
315
315
  console.log(line);
316
316
  };
317
317
  let sessionSpent = 0;
318
+ let sessionCogs = 0;
319
+ let sessionDirect = 0;
320
+ const MARKUP = 3; // confirmed constant, see .claude/wiki.md "Margin needs a like-for-like denominator"
318
321
  let tunnelSpent = 0;
319
322
  // Live balance refresh state — the real implementation is assigned in the
320
323
  // banner section below; the handler only ever calls scheduleRefresh().
@@ -348,7 +351,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
348
351
  // it's a number, not a capability.
349
352
  if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/session') {
350
353
  res.writeHead(200, { 'content-type': 'application/json' });
351
- res.end(JSON.stringify({ spentUsd: sessionSpent, paidCalls }));
354
+ res.end(JSON.stringify({ spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls }));
352
355
  return;
353
356
  }
354
357
 
@@ -653,6 +656,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
653
656
  if (paid && receipt) {
654
657
  if (receipt.ok && typeof receipt.billedUsd === 'number') {
655
658
  sessionSpent += receipt.billedUsd;
659
+ // cogs: no per-call field for it, but MARKUP is a known constant
660
+ // (3x — confirmed against the gateway's own margin math), and
661
+ // billedUsd = cogs * markup on a straight-markup call. Close enough
662
+ // on a counterfactual (leCore-discounted) call too since markup is
663
+ // still the ceiling those get capped against.
664
+ sessionCogs += receipt.billedUsd / MARKUP;
665
+ // direct: savesVsDirect = direct / billedUsd is on the receipt when
666
+ // leCore compression engaged (server derives it from real token
667
+ // counts) — exact, not estimated. When absent, nothing was
668
+ // compressed, so direct === what was paid (same reasoning as the
669
+ // like-for-like fix: no compression, no saving, not zero).
670
+ sessionDirect += typeof receipt.savesVsDirect === 'number'
671
+ ? receipt.savesVsDirect * receipt.billedUsd
672
+ : receipt.billedUsd;
656
673
  // The public-URL ceiling meters only public-origin spend — your own
657
674
  // local calls never eat into it.
658
675
  if (viaTunnel) tunnelSpent += receipt.billedUsd;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.43.1",
3
+ "version": "0.43.3",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",