openzoo 0.43.0 → 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/podagent.mjs CHANGED
@@ -26,15 +26,15 @@ 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';
30
- const MODEL = process.env.OZ_BRAIN_MODEL || 'x-ai/grok-4.6';
29
+ export const PROXY = process.env.OZ_PROXY || 'http://127.0.0.1:8402/v1';
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
 
33
33
  // Matches the Grok Bot chat surface itself (dark canvas, right-aligned grey
34
34
  // user pills, pink-avatar left-aligned replies, pill input bar with + / mic) —
35
35
  // this renders INSIDE Grok Bot's own sandbox panel (its sidebar/titlebar are
36
36
  // the app's own chrome, not ours), so only the message canvas needs to match.
37
- const VNC_CHAT_HTML = `<!doctype html>
37
+ export const VNC_CHAT_HTML = `<!doctype html>
38
38
  <html><head><meta charset="utf-8"><title>openzoo box</title>
39
39
  <meta name="viewport" content="width=device-width,initial-scale=1">
40
40
  <style>
@@ -43,23 +43,28 @@ const VNC_CHAT_HTML = `<!doctype html>
43
43
  html, body { margin: 0; height: 100%; background: #000; }
44
44
  body { color: #ececec; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
45
45
  display: flex; flex-direction: column; }
46
- #log { flex: 1; overflow-y: auto; padding: 28px 24px 12px; display: flex; flex-direction: column; gap: 18px; }
47
- .row { display: flex; align-items: flex-start; gap: 10px; max-width: 78%; }
48
- .row.user { align-self: flex-end; flex-direction: row-reverse; }
46
+ #log { flex: 1; overflow-y: auto; padding: 28px 24px 12px; display: flex; flex-direction: column; gap: 6px; }
47
+ .hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
48
+ color: #8e8e93; font-size: 13px; }
49
+ .hdr .avatar { width: 18px; height: 18px; border-radius: 5px; }
50
+ .hdr .avatar svg { width: 10px; height: 10px; }
51
+ .row { display: flex; max-width: 78%; margin: 2px 0; }
52
+ .row.user { align-self: flex-end; }
49
53
  .row.bot { align-self: flex-start; }
50
54
  .avatar { width: 30px; height: 30px; border-radius: 8px; flex: 0 0 30px; background: #e91e8c;
51
55
  display: flex; align-items: center; justify-content: center; }
52
56
  .avatar svg { width: 16px; height: 16px; }
53
- .bubble { padding: 11px 15px; border-radius: 18px; white-space: pre-wrap; word-break: break-word; }
54
- .row.user .bubble { background: #3a3a3c; border-bottom-right-radius: 5px; }
55
- .row.bot .bubble { background: transparent; padding: 6px 0; color: #ececec; }
57
+ .bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word; }
58
+ .row.user .bubble { background: #57575c; }
59
+ .row.bot .bubble { background: #262626; color: #ececec; }
56
60
  .row.bot.pending .bubble { color: #8e8e93; }
57
61
  .dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
58
62
  background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
59
63
  .dots span:nth-child(2) { animation-delay: .2s; } .dots span:nth-child(3) { animation-delay: .4s; }
60
64
  @keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
61
65
  #bar { padding: 10px 16px 18px; }
62
- #pill { display: flex; align-items: center; gap: 10px; background: #2c2c2e; border-radius: 26px;
66
+ #row-input { display: flex; align-items: center; gap: 8px; }
67
+ #pill { flex: 1; display: flex; align-items: center; gap: 6px; background: #2c2c2e; border-radius: 26px;
63
68
  padding: 8px 10px 8px 14px; }
64
69
  .icon-btn { width: 32px; height: 32px; border-radius: 50%; border: none; background: transparent;
65
70
  color: #ececec; display: flex; align-items: center; justify-content: center; cursor: pointer;
@@ -70,17 +75,26 @@ const VNC_CHAT_HTML = `<!doctype html>
70
75
  padding: 6px 0; min-width: 0; }
71
76
  #inp::placeholder { color: #8e8e93; }
72
77
  #inp:focus { outline: none; }
78
+ #send { width: 34px; height: 34px; border-radius: 50%; border: none; background: #fff; color: #000;
79
+ display: none; align-items: center; justify-content: center; cursor: pointer; flex: 0 0 34px; }
80
+ #send.show { display: flex; }
81
+ #send svg { width: 16px; height: 16px; }
73
82
  </style></head>
74
83
  <body>
75
84
  <div id="log"></div>
76
85
  <div id="bar">
77
- <div id="pill">
78
- <button class="icon-btn" tabindex="-1">
79
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
80
- </button>
81
- <input id="inp" placeholder="Message pods" autofocus>
82
- <button class="icon-btn" id="send">
83
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M6 11l6-6 6 6"/></svg>
86
+ <div id="row-input">
87
+ <div id="pill">
88
+ <button class="icon-btn" tabindex="-1">
89
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
90
+ </button>
91
+ <input id="inp" placeholder="Message openzoo" autofocus>
92
+ <button class="icon-btn" tabindex="-1">
93
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10v1a7 7 0 0 1-14 0v-1"/><line x1="12" y1="18" x2="12" y2="22"/></svg>
94
+ </button>
95
+ </div>
96
+ <button id="send">
97
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M6 11l6-6 6 6"/></svg>
84
98
  </button>
85
99
  </div>
86
100
  </div>
@@ -89,15 +103,17 @@ const VNC_CHAT_HTML = `<!doctype html>
89
103
  const inp = document.getElementById('inp');
90
104
  const send = document.getElementById('send');
91
105
  const AVATAR = '<svg viewBox="0 0 24 24" fill="none"><path d="M6 10c0-1 1-2 2-1l2 2 2-2c1-1 2 0 2 1v2c0 2-2 3-4 3s-4-1-4-3v-2z" fill="#fff"/></svg>';
106
+ let lastWho = null;
92
107
  function addRow(who, text) {
108
+ if (who === 'bot' && lastWho !== 'bot') {
109
+ const hdr = document.createElement('div');
110
+ hdr.className = 'hdr';
111
+ hdr.innerHTML = '<span class="avatar">' + AVATAR + '</span><span>openzoo</span>';
112
+ log.appendChild(hdr);
113
+ }
114
+ lastWho = who;
93
115
  const row = document.createElement('div');
94
116
  row.className = 'row ' + who + (text === null ? ' pending' : '');
95
- if (who === 'bot') {
96
- const av = document.createElement('div');
97
- av.className = 'avatar';
98
- av.innerHTML = AVATAR;
99
- row.appendChild(av);
100
- }
101
117
  const bubble = document.createElement('div');
102
118
  bubble.className = 'bubble';
103
119
  bubble.innerHTML = text === null ? '<span class="dots"><span></span><span></span><span></span></span>' : '';
@@ -111,7 +127,8 @@ const VNC_CHAT_HTML = `<!doctype html>
111
127
  const task = inp.value.trim();
112
128
  if (!task) return;
113
129
  inp.value = '';
114
- inp.disabled = true; send.disabled = true;
130
+ send.classList.remove('show');
131
+ inp.disabled = true;
115
132
  addRow('user', task);
116
133
  const pending = addRow('bot', null);
117
134
  try {
@@ -126,8 +143,9 @@ const VNC_CHAT_HTML = `<!doctype html>
126
143
  pending.row.classList.remove('pending');
127
144
  pending.bubble.textContent = 'error: ' + e.message;
128
145
  }
129
- inp.disabled = false; send.disabled = false; inp.focus();
146
+ inp.disabled = false; inp.focus();
130
147
  }
148
+ inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0); });
131
149
  send.addEventListener('click', submit);
132
150
  inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
133
151
  </script>
@@ -163,16 +181,68 @@ function execFrame(command, cwd = '/tmp') {
163
181
 
164
182
  /** One openzoo chat turn. Paid per call by the box's own wallet via the local
165
183
  * proxy — no key, no account. */
166
- async function brain(messages) {
184
+ export async function brain(messages, contextId) {
167
185
  const r = await fetch(`${PROXY}/chat/completions`, {
168
186
  method: 'POST',
169
- headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
170
- body: JSON.stringify({ model: MODEL, max_tokens: 900, messages }),
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
+ },
194
+ // explicit, not relying on the gateway's "inject when caller said nothing"
195
+ // default — an explicit plugins array is always respected as-is, so this
196
+ // guarantees every bot on every model actually has web search, instead of
197
+ // hoping nothing upstream (local proxy, gateway config) already set one.
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' }] }),
171
200
  });
172
201
  const j = await r.json().catch(() => ({}));
173
202
  return j?.choices?.[0]?.message?.content ?? '';
174
203
  }
175
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
+
176
246
  const SYSTEM = `You are the brain of a Grok-Bot-style coding/ops agent. The polished chat UI
177
247
  the user sees is Grok Bot (Anysphere's app); its "sandbox" has been pointed at THIS box, and
178
248
  your reasoning is served by openzoo (pay-per-call access to ~435 models over x402 — no API key,
@@ -186,9 +256,29 @@ be careful, never destructive, prefer read-before-write, and explain nothing to
186
256
  PROTOCOL — reply with EXACTLY one line, no prose, no code fences:
187
257
  RUN: <a single shell command> to execute a step
188
258
  DONE: <a short natural-language answer> when the task is complete (this text is shown in the UI)
189
- You are given each command's output before your next line. If the task needs no shell (a question,
190
- an explanation), answer it directly with a single DONE: line. Keep DONE summaries human and useful —
191
- they are the assistant's reply to the user, not a log.`;
259
+ SPAWN: <name> | <task> to delegate an independent subtask to a fresh subagent
260
+ SEND: <name> | <message> to message a subagent you (or the user) already spawned
261
+ PING: <name> to check whether a subagent is still working or has finished
262
+ You are given each command's output before your next line. SPAWN does not block you — the subagent
263
+ runs independently and you continue your own reasoning immediately; check on it later with PING or
264
+ wait for it to SEND you its result. All subagents share the ONE real exec channel (the user's Mac),
265
+ so their shell commands queue safely behind each other automatically — you never need to worry about
266
+ that part. If the task needs no shell (a question, an explanation), answer it directly with a single
267
+ DONE: line. Keep DONE summaries human and useful — they are the assistant's reply to the user, not a
268
+ log.`;
269
+
270
+ // ---------------------------------------------------------------- subagents --
271
+
272
+ // name -> { mailbox: string[], done: boolean, result: string|null }. In-process
273
+ // registry — every agent (root task + SPAWNed subagents) shares the ONE real
274
+ // exec channel (queueDrive), but reason (call the brain) independently and
275
+ // concurrently, so SPAWN genuinely doesn't block the spawning agent.
276
+ const agents = new Map();
277
+
278
+ function ensureAgent(name) {
279
+ if (!agents.has(name)) agents.set(name, { mailbox: [], done: false, result: null });
280
+ return agents.get(name);
281
+ }
192
282
 
193
283
  /** The agent loop for one task, executed through the connected daemon. Each
194
284
  * RUN is pushed as an exec frame; the daemon's result frames (captured in
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().
@@ -342,6 +345,16 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
342
345
  // both surfaces: point base_url at /v1 for transparent context spilling,
343
346
  // or add /mcp for tools (zoo_bind, zoo_ask...). Running two commands to
344
347
  // get both was friction nobody should pay.
348
+ // This wallet's own running total for THIS proxy process — every paid
349
+ // call through this port counts (GUI, MCP, CLI, any harness), not just
350
+ // whichever surface happens to be asking. Local-only, no auth needed:
351
+ // it's a number, not a capability.
352
+ if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/session') {
353
+ res.writeHead(200, { 'content-type': 'application/json' });
354
+ res.end(JSON.stringify({ spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls }));
355
+ return;
356
+ }
357
+
345
358
  if ((req.url || '').split('?')[0] === '/mcp') {
346
359
  try {
347
360
  const { handleMcpRequest } = await import('./mcphttp.js');
@@ -643,6 +656,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
643
656
  if (paid && receipt) {
644
657
  if (receipt.ok && typeof receipt.billedUsd === 'number') {
645
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;
646
673
  // The public-URL ceiling meters only public-origin spend — your own
647
674
  // local calls never eat into it.
648
675
  if (viaTunnel) tunnelSpent += receipt.billedUsd;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.43.0",
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",