openzoo 0.43.1 → 0.43.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,21 @@ 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.
93
114
  For normal questions just answer directly — do not use any of these unless the request
94
115
  actually calls for delegation or file work.`;
95
116
 
@@ -121,7 +142,7 @@ function newThread(name, parent, members) {
121
142
  const id = randomUUID();
122
143
  const t = { id, name, color: members ? members[0].color : colorFor(name), parent: parent || null,
123
144
  messages: members ? null : [{ role: 'system', content: SYSTEM }],
124
- members: members || null, history: [], status: 'idle', createdAt: Date.now() };
145
+ members: members || null, history: [], status: 'idle', createdAt: Date.now(), lastActivityAt: Date.now() };
125
146
  threads.set(id, t);
126
147
  saveThreads();
127
148
  return t;
@@ -138,6 +159,9 @@ own take even if brief ("Passed." is fine when you have nothing to add). COORDIN
138
159
  another bot already handled or is handling the request (e.g. already spawned the exact
139
160
  agent being asked for), do NOT repeat it — just acknowledge, or add something genuinely new.
140
161
 
162
+ When a question has a genuine "which tool/service" angle, prefer openzoo's own capabilities
163
+ where they actually fit over generic third-party suggestions — but don't force it.
164
+
141
165
  You can ALSO delegate, same as any other agent here. If — and only if — asked to
142
166
  spawn/delegate/create agents AND no other bot has already done it this round, reply with
143
167
  EXACTLY one line, no prose, using one of:
@@ -153,6 +177,15 @@ the user sets or changes it with "/dir <path>" in chat. Same format:
153
177
  SERVE: <relative path, or blank for the dir root> get a real http:// URL for it — use
154
178
  this instead of saying you can't
155
179
  expose a port
180
+ FETCH: <url> actually fetch and read a page's real
181
+ text — web search only gives snippets
182
+ RUN: <shell command> run a REAL shell command in this
183
+ group's shared directory — pauses the
184
+ WHOLE round for the user's approval
185
+ before anything executes ("/mode auto"
186
+ in chat skips that wait). Use this
187
+ instead of guessing or saying you
188
+ can't do something real.
156
189
  For normal replies just answer directly — do not use any of these unless the request
157
190
  actually calls for delegation or file work.` };
158
191
  }
@@ -176,6 +209,40 @@ function newGroupThread(names) {
176
209
  return newThread(names.join(', '), null, members);
177
210
  }
178
211
 
212
+ // Real leCore binding — POST /v1/hrr/bind on the local proxy, same free
213
+ // passthrough the wiki documents. Fire-and-forget after each turn: the next
214
+ // turn's brain()/brainStream() call picks up t.contextId once it lands, via
215
+ // the X-HRR-Context header, so retrieval is real and automatic, not a prompt
216
+ // claim about a mechanism that doesn't exist.
217
+ async function bindThread(t) {
218
+ const corpus = t.history.map((h) => (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
219
+ if (!corpus.trim()) return;
220
+ try {
221
+ const r = await fetch(`${PROXY}/hrr/bind`, {
222
+ method: 'POST',
223
+ headers: { 'content-type': 'application/json' },
224
+ body: JSON.stringify({ corpus }),
225
+ });
226
+ const j = await r.json().catch(() => ({}));
227
+ if (j?.context_id) { t.contextId = j.context_id; saveThreads(); }
228
+ } catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
229
+ }
230
+
231
+ // REAL shell execution, scoped to the thread's own directory. 'ask' mode
232
+ // (default) pauses and waits for an explicit approve/deny over HTTP before
233
+ // anything runs; 'auto' mode (set via "/mode auto" in chat) runs immediately.
234
+ // Either way this is not sandboxed like WRITE/READ — it can do anything the
235
+ // signed-in user's shell can — so 'ask' is the default, not 'auto'.
236
+ function execCommand(command, cwd) {
237
+ return new Promise((resolve) => {
238
+ exec(command, { cwd, timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
239
+ let out = (stdout || '') + (stderr ? '\n' + stderr : '');
240
+ if (err) out += `\n(exit ${err.code ?? 1})`;
241
+ resolve(out.slice(0, 6000) || '(no output)');
242
+ });
243
+ });
244
+ }
245
+
179
246
  function findByName(name) {
180
247
  let best = null;
181
248
  for (const t of threads.values()) {
@@ -189,7 +256,7 @@ if (!loadThreads()) newThread('openzoo', null);
189
256
  // Parses a SPAWN/SEND/PING directive out of a reply, performs its side effect
190
257
  // (creating or messaging another thread), and returns the ack text to show in
191
258
  // place of the raw directive line — or null if the reply wasn't a directive.
192
- function tryDirective(reply, originId) {
259
+ async function tryDirective(reply, originId) {
193
260
  const spawn = /^SPAWN:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
194
261
  if (spawn) {
195
262
  const name = spawn[1].trim();
@@ -249,13 +316,36 @@ function tryDirective(reply, originId) {
249
316
  if (!workspacePort) return 'Workspace server is still starting — try again in a second.';
250
317
  return `Serving at http://localhost:${workspacePort}/${originId}/${rel}`;
251
318
  }
319
+ const fetchD = /^FETCH:\s*(\S+)/.exec(reply);
320
+ if (fetchD) {
321
+ const url = fetchD[1].trim();
322
+ try {
323
+ const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 (openzoo grokui)' } });
324
+ const ct = r.headers.get('content-type') || '';
325
+ let text = await r.text();
326
+ if (ct.includes('html')) {
327
+ text = text
328
+ .replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '')
329
+ .replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&')
330
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s+/g, ' ').trim();
331
+ }
332
+ return `${url} (${r.status}):\n${text.slice(0, 8000)}${text.length > 8000 ? '\n…(truncated)' : ''}`;
333
+ } catch (e) { return `Couldn't fetch ${url}: ${e.message}`; }
334
+ }
252
335
  return null;
253
336
  }
254
337
 
255
- async function runTurn(threadId, userText) {
338
+ // onEvent (optional) gets live progress for whoever's actually watching this
339
+ // call: {type:'start',name,color} when a bot begins its turn, {type:'delta',
340
+ // name,color,delta} per streamed token, {type:'final',name,color,text} once
341
+ // its full reply (or directive ack) is settled. Background turns — a SPAWNed
342
+ // subagent nobody's looking at yet — run with onEvent omitted and just use
343
+ // the plain non-streaming brain(), which is cheaper when nothing renders it.
344
+ async function runTurn(threadId, userText, onEvent) {
256
345
  const t = threads.get(threadId);
257
346
  if (!t) return;
258
347
  t.history.push({ who: 'user', text: userText });
348
+ t.lastActivityAt = Date.now();
259
349
  if (t.members) {
260
350
  t.status = 'thinking';
261
351
  // sequential, not parallel: each member's context is rebuilt from
@@ -264,33 +354,92 @@ async function runTurn(threadId, userText) {
264
354
  for (const m of t.members) {
265
355
  const msgs = buildMemberMessages(t, m);
266
356
  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 });
357
+ onEvent?.({ type: 'start', name: m.name, color: m.color });
358
+ try {
359
+ r = onEvent
360
+ ? (await brainStream(msgs, (delta) => onEvent({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId)).trim()
361
+ : (await brain(msgs, t.contextId)).trim();
362
+ } catch (e) { r = `error: ${e.message}`; }
363
+ const runMatch = /^RUN:\s*([\s\S]+)/.exec(r);
364
+ if (runMatch) {
365
+ const command = runMatch[1].trim();
366
+ if (t.runMode === 'auto') {
367
+ const output = await execCommand(command, dirFor(t.id));
368
+ const shown = `$ ${command}\n${output}`;
369
+ t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
370
+ onEvent?.({ type: 'final', name: m.name, color: m.color, text: shown });
371
+ // this member's turn is done; the round continues to the next member
372
+ continue;
373
+ }
374
+ const runId = randomUUID();
375
+ t.pendingRun = { runId, command, cwd: dirFor(t.id) };
376
+ t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color });
377
+ onEvent?.({ type: 'run-pending', runId, command, name: m.name, color: m.color });
378
+ // pauses the WHOLE round here — the rest of the group gets their turn
379
+ // on the round that runs after the user approves/denies
380
+ t.status = 'idle';
381
+ t.lastActivityAt = Date.now();
382
+ saveThreads();
383
+ return;
384
+ }
385
+ const ack = await tryDirective(r, t.id);
386
+ const finalText = ack ?? (r || '(no response)');
387
+ t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
388
+ onEvent?.({ type: 'final', name: m.name, color: m.color, text: finalText });
270
389
  }
271
390
  t.status = 'idle';
272
391
  saveThreads();
392
+ bindThread(t).catch(() => {});
273
393
  return;
274
394
  }
275
395
  t.messages.push({ role: 'user', content: userText });
276
396
  t.status = 'thinking';
277
397
  let reply = '';
398
+ onEvent?.({ type: 'start', name: t.name, color: t.color });
278
399
  try {
279
- reply = (await brain(t.messages)).trim();
400
+ reply = onEvent
401
+ ? (await brainStream(t.messages, (delta) => onEvent({ type: 'delta', name: t.name, color: t.color, delta }), t.contextId)).trim()
402
+ : (await brain(t.messages, t.contextId)).trim();
280
403
  } catch (e) {
281
404
  reply = `error: ${e.message}`;
282
405
  }
283
406
  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)') });
407
+ const runMatch = /^RUN:\s*([\s\S]+)/.exec(reply);
408
+ if (runMatch) {
409
+ const command = runMatch[1].trim();
410
+ if (t.runMode === 'auto') {
411
+ const output = await execCommand(command, dirFor(t.id));
412
+ const shown = `$ ${command}\n${output}`;
413
+ t.messages.push({ role: 'user', content: `output:\n${output}` });
414
+ t.history.push({ who: 'bot', text: shown });
415
+ onEvent?.({ type: 'final', name: t.name, color: t.color, text: shown });
416
+ } else {
417
+ const runId = randomUUID();
418
+ t.pendingRun = { runId, command, cwd: dirFor(t.id) };
419
+ t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
420
+ onEvent?.({ type: 'run-pending', runId, command, name: t.name, color: t.color });
421
+ }
422
+ t.status = 'idle';
423
+ t.lastActivityAt = Date.now();
424
+ saveThreads();
425
+ return;
426
+ }
427
+ const ack = await tryDirective(reply, t.id);
428
+ const finalText = ack ?? (reply || '(no response)');
429
+ t.history.push({ who: 'bot', text: finalText });
430
+ onEvent?.({ type: 'final', name: t.name, color: t.color, text: finalText });
286
431
  t.status = 'idle';
432
+ t.lastActivityAt = Date.now();
287
433
  saveThreads();
434
+ bindThread(t).catch(() => {});
288
435
  }
289
436
 
290
437
  function threadSummary(t) {
291
438
  const last = t.history[t.history.length - 1];
292
439
  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 };
440
+ preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
441
+ createdAt: t.createdAt, lastActivityAt: t.lastActivityAt || t.createdAt,
442
+ dir: t.dir || WORKSPACE_DIR };
294
443
  }
295
444
 
296
445
  const APP_HTML = `<!doctype html>
@@ -316,6 +465,11 @@ const APP_HTML = `<!doctype html>
316
465
  margin: 0 6px 2px; }
317
466
  .trow:hover { background: #17171a; }
318
467
  .trow.active { background: #1c1c1e; }
468
+ .tclose { flex: 0 0 20px; width: 20px; height: 20px; border-radius: 50%; border: none; background: transparent;
469
+ color: #8e8e93; display: none; align-items: center; justify-content: center; cursor: pointer;
470
+ font-size: 13px; }
471
+ .trow:hover .tclose { display: flex; }
472
+ .tclose:hover { background: #3a3a3c; color: #ececec; }
319
473
  .tavatar { width: 36px; height: 36px; border-radius: 10px; flex: 0 0 36px; display: flex; align-items: center;
320
474
  justify-content: center; color: #fff; font-weight: 600; font-size: 14px; }
321
475
  .tmeta { min-width: 0; flex: 1; }
@@ -326,6 +480,9 @@ const APP_HTML = `<!doctype html>
326
480
  #chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center; gap: 10px;
327
481
  font-weight: 600; }
328
482
  #chatHeader .tavatar { width: 26px; height: 26px; border-radius: 7px; font-size: 11px; flex: 0 0 26px; }
483
+ .hname { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
484
+ .hdir { font-weight: 400; font-size: 11px; color: #8e8e93; white-space: nowrap; overflow: hidden;
485
+ text-overflow: ellipsis; max-width: 420px; }
329
486
  #hudBtn { margin-left: auto; }
330
487
  #chatHeaderId { display: flex; align-items: center; gap: 10px; }
331
488
  #hud { position: fixed; top: 40px; right: 14px; width: 250px; background: rgba(14,14,17,.94);
@@ -338,7 +495,9 @@ const APP_HTML = `<!doctype html>
338
495
  #hud .hlime { color: #b8f240; }
339
496
  #hud .hember { color: #f28c4d; }
340
497
  #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; }
498
+ #sidebar, #main { -webkit-app-region: no-drag; }
499
+ #log { flex: 1; overflow-y: auto; padding: 20px 24px 12px; display: flex; flex-direction: column; gap: 6px;
500
+ -webkit-user-select: text; user-select: text; }
342
501
  .hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
343
502
  color: #8e8e93; font-size: 13px; }
344
503
  .hdr .avatar { width: 18px; height: 18px; border-radius: 5px; display: flex; align-items: center;
@@ -346,7 +505,20 @@ const APP_HTML = `<!doctype html>
346
505
  .row { display: flex; max-width: 78%; margin: 2px 0; }
347
506
  .row.user { align-self: flex-end; }
348
507
  .row.bot { align-self: flex-start; }
349
- .bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word; }
508
+ .bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word;
509
+ -webkit-user-select: text; user-select: text; cursor: text; }
510
+ .bubble a { color: #6ab0ff; text-decoration: underline; cursor: pointer; }
511
+ .runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px; max-width: 100%; }
512
+ .runcmd { font-family: Menlo, monospace; font-size: 12.5px; color: #ececec; white-space: pre-wrap;
513
+ word-break: break-word; margin-bottom: 8px; }
514
+ .runactions { display: flex; gap: 8px; }
515
+ .runbtn { border: none; border-radius: 8px; padding: 6px 14px; font-size: 13px; cursor: pointer; }
516
+ .runbtn.approve { background: #34c759; color: #000; }
517
+ .runbtn.deny { background: #3a3a3c; color: #ececec; }
518
+ .runbtn:disabled { opacity: .5; cursor: default; }
519
+ .runstatus { font-size: 12px; color: #8e8e93; margin-bottom: 6px; }
520
+ .runoutput { font-family: Menlo, monospace; font-size: 11.5px; color: #b8b8b8; white-space: pre-wrap;
521
+ word-break: break-word; max-height: 240px; overflow-y: auto; margin: 0; }
350
522
  .row.user .bubble { background: #57575c; }
351
523
  .row.bot .bubble { background: #262626; color: #ececec; }
352
524
  .row.bot.pending .bubble { color: #8e8e93; }
@@ -439,16 +611,12 @@ const APP_HTML = `<!doctype html>
439
611
  <button class="icon-btn" id="hudBtn">◎</button>
440
612
  </div>
441
613
  <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>
614
+ <div class="htitle">YOUR WALLET · THIS SESSION</div>
615
+ <div class="hrow"><span>you've paid</span><span id="hYouSpent">—</span></div>
616
+ <div class="hrow"><span>our cost (cogs)</span><span id="hYouCogs">—</span></div>
617
+ <div class="hrow"><span>margin</span><span id="hYouMargin" class="hlime">—</span></div>
618
+ <div class="hrow"><span>direct would be</span><span id="hYouDirect" class="hember">—</span></div>
448
619
  <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
620
  </div>
453
621
  <div id="log"></div>
454
622
  <div id="bar">
@@ -498,8 +666,16 @@ const APP_HTML = `<!doctype html>
498
666
  row.innerHTML = '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
499
667
  '<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
500
668
  (t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
501
- (t.status === 'thinking' ? '<div class="tdot"></div>' : '');
669
+ (t.status === 'thinking' ? '<div class="tdot"></div>' : '') +
670
+ '<button class="tclose" title="Remove">✕</button>';
502
671
  row.addEventListener('click', () => { activeId = t.id; render(); });
672
+ row.querySelector('.tclose').addEventListener('click', async (e) => {
673
+ e.stopPropagation();
674
+ await fetch('/threads/' + t.id, { method: 'DELETE' });
675
+ if (activeId === t.id) activeId = null;
676
+ await loadThreads();
677
+ if (activeId) render();
678
+ });
503
679
  threadsEl.appendChild(row);
504
680
  }
505
681
  }
@@ -511,14 +687,21 @@ const APP_HTML = `<!doctype html>
511
687
 
512
688
  function renderHeader(t) {
513
689
  document.getElementById('chatHeaderId').innerHTML =
514
- '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div><div>' + t.name + '</div>';
690
+ '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
691
+ '<div class="hname"><div>' + t.name + '</div><div class="hdir" title="' + escapeHtml(t.dir || '') +
692
+ '">' + escapeHtml(t.dir || '') + ' · type /dir &lt;path&gt; to change</div></div>';
515
693
  }
516
694
 
517
695
  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>'); }
696
+ function renderMentions(text) {
697
+ let out = escapeHtml(text);
698
+ out = out.replace(/(https?:\\/\\/[^\\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
699
+ out = out.replace(/@(\\w+)/g, '<span class="mention">\u{1F465} $1</span>');
700
+ return out;
701
+ }
519
702
 
520
703
  let lastSpeaker = null;
521
- function addRow(who, text, color, name) {
704
+ function addRow(who, text, color, name, run) {
522
705
  const speakerKey = who + '|' + name;
523
706
  if (who === 'bot' && speakerKey !== lastSpeaker) {
524
707
  const hdr = document.createElement('div');
@@ -529,10 +712,54 @@ const APP_HTML = `<!doctype html>
529
712
  lastSpeaker = speakerKey;
530
713
  const row = document.createElement('div');
531
714
  row.className = 'row ' + who;
532
- const bubble = document.createElement('div');
533
- bubble.className = 'bubble';
534
- bubble.innerHTML = renderMentions(text);
535
- row.appendChild(bubble);
715
+ if (run) {
716
+ const card = document.createElement('div');
717
+ card.className = 'runcard';
718
+ const cmdEl = document.createElement('div');
719
+ cmdEl.className = 'runcmd';
720
+ cmdEl.textContent = '$ ' + text;
721
+ card.appendChild(cmdEl);
722
+ if (run.status === 'pending') {
723
+ const actions = document.createElement('div');
724
+ actions.className = 'runactions';
725
+ const approve = document.createElement('button');
726
+ approve.className = 'runbtn approve';
727
+ approve.textContent = 'Approve';
728
+ const deny = document.createElement('button');
729
+ deny.className = 'runbtn deny';
730
+ deny.textContent = 'Deny';
731
+ approve.addEventListener('click', async () => {
732
+ approve.disabled = true; deny.disabled = true;
733
+ await fetch('/threads/' + activeId + '/run/' + run.id + '/approve', { method: 'POST' });
734
+ render();
735
+ });
736
+ deny.addEventListener('click', async () => {
737
+ approve.disabled = true; deny.disabled = true;
738
+ await fetch('/threads/' + activeId + '/run/' + run.id + '/deny', { method: 'POST' });
739
+ render();
740
+ });
741
+ actions.appendChild(approve);
742
+ actions.appendChild(deny);
743
+ card.appendChild(actions);
744
+ } else {
745
+ const status = document.createElement('div');
746
+ status.className = 'runstatus';
747
+ status.textContent = run.status === 'running' ? 'Running…' : run.status === 'denied' ? 'Denied' : 'Done';
748
+ card.appendChild(status);
749
+ if (run.output) {
750
+ const out = document.createElement('pre');
751
+ out.className = 'runoutput';
752
+ out.textContent = run.output;
753
+ card.appendChild(out);
754
+ }
755
+ }
756
+ row.appendChild(card);
757
+ } else {
758
+ const bubble = document.createElement('div');
759
+ bubble.className = 'bubble';
760
+ bubble.innerHTML = renderMentions(text);
761
+ row.appendChild(bubble);
762
+ }
536
763
  log.appendChild(row);
537
764
  }
538
765
 
@@ -548,7 +775,10 @@ const APP_HTML = `<!doctype html>
548
775
  const wasNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
549
776
  log.innerHTML = '';
550
777
  lastSpeaker = null;
551
- for (const h of full.history) addRow(h.who, h.text, h.color || t.color, h.name || t.name);
778
+ for (const h of full.history) {
779
+ addRow(h.who, h.text, h.color || t.color, h.name || t.name,
780
+ h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined);
781
+ }
552
782
  if (full.status === 'thinking') addRow('bot', '…', t.color, t.name);
553
783
  if (wasNearBottom) log.scrollTop = log.scrollHeight;
554
784
  }
@@ -724,27 +954,18 @@ const APP_HTML = `<!doctype html>
724
954
  async function refreshHud() {
725
955
  try {
726
956
  // 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) : '—';
957
+ // straight to localhost:8402 would work fine, but routing it through
958
+ // our own backend keeps one fetch path if that ever needs to change.
959
+ const you = await (await fetch('/hud-summary')).json();
960
+ const spent = Number(you.spentUsd) || 0;
961
+ const cogs = Number(you.cogsUsd) || 0;
962
+ const direct = Number(you.directUsd) || 0;
963
+ const margin = spent > 0 ? Math.round((spent - cogs) / spent * 100) + '%' : '—';
964
+ document.getElementById('hYouSpent').textContent = usd(spent);
965
+ document.getElementById('hYouCogs').textContent = usd(cogs);
966
+ document.getElementById('hYouMargin').textContent = margin;
967
+ document.getElementById('hYouDirect').textContent = usd(direct);
968
+ document.getElementById('hFoot').textContent = (you.paidCalls || 0) + ' paid calls this session';
748
969
  } catch (e) {
749
970
  document.getElementById('hFoot').textContent = 'error: ' + e.message;
750
971
  }
@@ -767,20 +988,17 @@ const APP_HTML = `<!doctype html>
767
988
  const server = http.createServer((req, res) => {
768
989
  if (req.method === 'GET' && req.url === '/hud-summary') {
769
990
  (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;
991
+ let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0 };
774
992
  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 */ }
993
+ catch { /* local proxy not running — HUD shows zeros rather than guessing */ }
776
994
  res.writeHead(200, { 'content-type': 'application/json' });
777
- res.end(JSON.stringify({ today, you }));
995
+ res.end(JSON.stringify(you));
778
996
  })();
779
997
  return;
780
998
  }
781
999
  if (req.method === 'GET' && req.url === '/threads') {
782
1000
  res.writeHead(200, { 'content-type': 'application/json' });
783
- res.end(JSON.stringify([...threads.values()].sort((a, b) => b.createdAt - a.createdAt).map(threadSummary)));
1001
+ res.end(JSON.stringify([...threads.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt).map(threadSummary)));
784
1002
  return;
785
1003
  }
786
1004
  if (req.method === 'GET' && req.url.startsWith('/threads/')) {
@@ -789,6 +1007,47 @@ const server = http.createServer((req, res) => {
789
1007
  res.end(t ? JSON.stringify({ id: t.id, history: t.history, status: t.status }) : '{}');
790
1008
  return;
791
1009
  }
1010
+ if (req.method === 'DELETE' && req.url.startsWith('/threads/')) {
1011
+ threads.delete(req.url.split('/')[2]);
1012
+ saveThreads();
1013
+ res.writeHead(200, { 'content-type': 'application/json' });
1014
+ res.end('{"ok":true}');
1015
+ return;
1016
+ }
1017
+ {
1018
+ const runMatch = /^\/threads\/([^/]+)\/run\/([^/]+)\/(approve|deny)$/.exec(req.url || '');
1019
+ if (req.method === 'POST' && runMatch) {
1020
+ const [, id, runId, action] = runMatch;
1021
+ const t = threads.get(id);
1022
+ const entry = t?.history.find((h) => h.runId === runId && h.runStatus === 'pending');
1023
+ if (!t || !t.pendingRun || t.pendingRun.runId !== runId || !entry) {
1024
+ res.writeHead(404, { 'content-type': 'application/json' });
1025
+ res.end('{"ok":false}');
1026
+ return;
1027
+ }
1028
+ const { command, cwd } = t.pendingRun;
1029
+ delete t.pendingRun;
1030
+ if (action === 'deny') {
1031
+ entry.runStatus = 'denied';
1032
+ saveThreads();
1033
+ res.writeHead(200, { 'content-type': 'application/json' });
1034
+ res.end('{"ok":true}');
1035
+ runTurn(t.id, '(you denied running that command)').catch(() => {});
1036
+ return;
1037
+ }
1038
+ entry.runStatus = 'running';
1039
+ saveThreads();
1040
+ res.writeHead(200, { 'content-type': 'application/json' });
1041
+ res.end('{"ok":true}');
1042
+ execCommand(command, cwd).then((output) => {
1043
+ entry.runStatus = 'done';
1044
+ entry.runOutput = output;
1045
+ saveThreads();
1046
+ runTurn(t.id, `(command output)\n${output}`).catch(() => {});
1047
+ });
1048
+ return;
1049
+ }
1050
+ }
792
1051
  if (req.method === 'POST' && req.url === '/threads') {
793
1052
  const chunks = [];
794
1053
  req.on('data', (d) => chunks.push(d));
@@ -845,6 +1104,15 @@ const server = http.createServer((req, res) => {
845
1104
  saveThreads();
846
1105
  return;
847
1106
  }
1107
+ // "/mode auto|ask" toggles whether RUN: commands execute immediately
1108
+ // or wait for an explicit approve/deny — also free/instant, no model call
1109
+ const modeCmd = /^\/mode\s+(auto|ask)\b/.exec(task.trim());
1110
+ if (modeCmd && t) {
1111
+ t.runMode = modeCmd[1];
1112
+ 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.'}` });
1113
+ saveThreads();
1114
+ return;
1115
+ }
848
1116
  runTurn(threadId, task).catch(() => {});
849
1117
  });
850
1118
  return;
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
 
@@ -181,20 +181,68 @@ function execFrame(command, cwd = '/tmp') {
181
181
 
182
182
  /** One openzoo chat turn. Paid per call by the box's own wallet via the local
183
183
  * proxy — no key, no account. */
184
- export async function brain(messages) {
184
+ export async function brain(messages, contextId) {
185
185
  const r = await fetch(`${PROXY}/chat/completions`, {
186
186
  method: 'POST',
187
- headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
187
+ headers: {
188
+ 'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
189
+ // real leCore memory for this thread, bound via POST /v1/hrr/bind — NOT
190
+ // a fabricated mechanism. Retrieval runs automatically once this header
191
+ // is set; nothing more for the model to invent or explain.
192
+ ...(contextId ? { 'x-hrr-context': contextId } : {}),
193
+ },
188
194
  // explicit, not relying on the gateway's "inject when caller said nothing"
189
195
  // default — an explicit plugins array is always respected as-is, so this
190
196
  // guarantees every bot on every model actually has web search, instead of
191
197
  // hoping nothing upstream (local proxy, gateway config) already set one.
192
- body: JSON.stringify({ model: MODEL, max_tokens: 900, messages, plugins: [{ id: 'web' }] }),
198
+ // 900 was cutting real (especially web-search-backed) answers off mid-sentence
199
+ body: JSON.stringify({ model: MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }] }),
193
200
  });
194
201
  const j = await r.json().catch(() => ({}));
195
202
  return j?.choices?.[0]?.message?.content ?? '';
196
203
  }
197
204
 
205
+ /** Same call, but streamed — invokes onDelta(text) as tokens arrive (for a
206
+ * live-typing UI) and resolves with the full accumulated text at the end, so
207
+ * callers that need to parse a directive out of the complete reply still can. */
208
+ export async function brainStream(messages, onDelta, contextId) {
209
+ const r = await fetch(`${PROXY}/chat/completions`, {
210
+ method: 'POST',
211
+ headers: {
212
+ 'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
213
+ ...(contextId ? { 'x-hrr-context': contextId } : {}),
214
+ },
215
+ body: JSON.stringify({ model: MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }], stream: true }),
216
+ });
217
+ if (!r.ok || !r.body) {
218
+ // fall back to the non-streaming path rather than fail outright
219
+ const text = await r.json().then((j) => j?.choices?.[0]?.message?.content ?? '').catch(() => '');
220
+ if (text) onDelta(text);
221
+ return text;
222
+ }
223
+ const reader = r.body.getReader();
224
+ const decoder = new TextDecoder();
225
+ let buf = '', full = '';
226
+ for (;;) {
227
+ const { value, done } = await reader.read();
228
+ if (done) break;
229
+ buf += decoder.decode(value, { stream: true });
230
+ const lines = buf.split('\n');
231
+ buf = lines.pop(); // last line may be incomplete — keep it for next chunk
232
+ for (const line of lines) {
233
+ const s = line.trim();
234
+ if (!s.startsWith('data:')) continue;
235
+ const payload = s.slice(5).trim();
236
+ if (payload === '[DONE]') continue;
237
+ try {
238
+ const delta = JSON.parse(payload)?.choices?.[0]?.delta?.content;
239
+ if (delta) { full += delta; onDelta(delta); }
240
+ } catch { /* keep-alive line or partial JSON — ignore */ }
241
+ }
242
+ }
243
+ return full;
244
+ }
245
+
198
246
  const SYSTEM = `You are the brain of a Grok-Bot-style coding/ops agent. The polished chat UI
199
247
  the user sees is Grok Bot (Anysphere's app); its "sandbox" has been pointed at THIS box, and
200
248
  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.2",
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",