openzoo 0.48.7 → 0.48.9
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 +108 -4
- package/package.json +1 -1
package/lib/grokui.mjs
CHANGED
|
@@ -336,6 +336,10 @@ function newGroupThread(names) {
|
|
|
336
336
|
const BIND_CHUNK_BYTES = 512 * 1024;
|
|
337
337
|
// Chained auto-run commands per user message. Each hop is a paid call.
|
|
338
338
|
const AUTO_MAX_STEPS = Number(process.env.OZ_AUTO_MAX_STEPS || 8);
|
|
339
|
+
// Ceiling on subagents per thread. Spawning is fire-and-forget and each child
|
|
340
|
+
// can spawn too, so without a count it is unbounded — MEASURED as 15+ threads
|
|
341
|
+
// all named tetris-contract, every one of them a live agent making paid calls.
|
|
342
|
+
const SPAWN_MAX_CHILDREN = Number(process.env.OZ_SPAWN_MAX_CHILDREN || 12);
|
|
339
343
|
|
|
340
344
|
// Injected fresh on every AUTO turn, never persisted into the thread.
|
|
341
345
|
//
|
|
@@ -524,6 +528,18 @@ const SLASH_COMMANDS = [
|
|
|
524
528
|
|
|
525
529
|
const usd = (n) => (n >= 0.01 || n === 0 ? '$' + n.toFixed(2) : '$' + n.toFixed(5));
|
|
526
530
|
|
|
531
|
+
// threadId -> open SSE responses. A Set because the same thread can be open in
|
|
532
|
+
// two tabs, and both should see the same tokens.
|
|
533
|
+
const streamListeners = new Map();
|
|
534
|
+
function emitToThread(threadId, ev) {
|
|
535
|
+
const set = streamListeners.get(threadId);
|
|
536
|
+
if (!set?.size) return; // nobody watching — free
|
|
537
|
+
const line = `data: ${JSON.stringify(ev)}\n\n`;
|
|
538
|
+
for (const res of set) {
|
|
539
|
+
try { res.write(line); } catch { set.delete(res); }
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
527
543
|
async function sessionStats() {
|
|
528
544
|
try { return await (await fetch(`${PROXY}/session`)).json(); }
|
|
529
545
|
catch { return null; }
|
|
@@ -563,7 +579,10 @@ async function handleSlash(task, t) {
|
|
|
563
579
|
+ ' FETCH: <url> read a page’s text\n'
|
|
564
580
|
+ ' MCP: <url> [| tool | {json}] list or call MCP tools\n'
|
|
565
581
|
+ ' TODO: <lines> visible checklist\n'
|
|
566
|
-
+ ' SPAWN
|
|
582
|
+
+ ' SPAWN: <name> | <task> a NEW subagent (names are unique —\n'
|
|
583
|
+
+ ' spawning an existing one sends to it)\n'
|
|
584
|
+
+ ' SEND: <name> | <msg> more work for an EXISTING subagent\n'
|
|
585
|
+
+ ' PING / PEEK reach or inspect another bot\n\n'
|
|
567
586
|
+ 'READ, LS, GLOB, GREP, FETCH, PEEK and MCP run CONCURRENTLY when several\n'
|
|
568
587
|
+ 'appear in one reply — four files cost one round trip, not four.';
|
|
569
588
|
}
|
|
@@ -764,6 +783,26 @@ async function tryDirective(reply, originId) {
|
|
|
764
783
|
if (spawn) {
|
|
765
784
|
const name = spawn[1].trim();
|
|
766
785
|
const task = spawn[2].trim();
|
|
786
|
+
// NAMES ARE UNIQUE. This used to call newThread() unconditionally, so a
|
|
787
|
+
// model asked to "keep spawning" produced FIFTEEN threads all called
|
|
788
|
+
// tetris-contract, each one a live agent burning paid calls, and the
|
|
789
|
+
// sidebar became an unusable wall of identical rows. A repeat SPAWN is
|
|
790
|
+
// almost always the model re-issuing work for the same worker, not asking
|
|
791
|
+
// for a second identical one — so route it to the existing thread, which
|
|
792
|
+
// is what SEND already does.
|
|
793
|
+
const existing = findByName(name);
|
|
794
|
+
if (existing) {
|
|
795
|
+
runTurn(existing.id, task).catch(() => {});
|
|
796
|
+
return `${name} already exists — sent it the task instead of spawning a duplicate.`;
|
|
797
|
+
}
|
|
798
|
+
// Storm guard. Fire-and-forget spawning is unbounded by construction: each
|
|
799
|
+
// child can spawn, and nothing above it is counting.
|
|
800
|
+
const siblings = [...threads.values()].filter((x) => x.parent === originId).length;
|
|
801
|
+
if (siblings >= SPAWN_MAX_CHILDREN) {
|
|
802
|
+
return `Not spawning "${name}": this thread already has ${siblings} subagents `
|
|
803
|
+
+ `(limit ${SPAWN_MAX_CHILDREN}). Reuse one with SEND: <name> | <task> — `
|
|
804
|
+
+ `every live subagent costs paid calls.`;
|
|
805
|
+
}
|
|
767
806
|
const sub = newThread(name, originId);
|
|
768
807
|
runTurn(sub.id, task).catch(() => {}); // fire and forget — runs independently
|
|
769
808
|
return `Spawned ${name} — working on it.`;
|
|
@@ -1765,10 +1804,45 @@ const APP_HTML = `<!doctype html>
|
|
|
1765
1804
|
addRow(h.who, h.text, h.color || t.color, h.name || t.name,
|
|
1766
1805
|
h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined, h.images);
|
|
1767
1806
|
}
|
|
1768
|
-
if (full.status === 'thinking')
|
|
1807
|
+
if (full.status === 'thinking') {
|
|
1808
|
+
addRow('bot', streamBuf || '…', t.color, t.name);
|
|
1809
|
+
// Tag the live bubble so deltas can repaint just this node instead of
|
|
1810
|
+
// re-rendering (and re-fetching) the whole thread on every token.
|
|
1811
|
+
const b = log.querySelector('.row:last-child .bubble');
|
|
1812
|
+
if (b) b.id = 'streamBubble';
|
|
1813
|
+
}
|
|
1769
1814
|
if (wasNearBottom) log.scrollTop = log.scrollHeight;
|
|
1770
1815
|
}
|
|
1771
1816
|
|
|
1817
|
+
// --- live token stream ---------------------------------------------------
|
|
1818
|
+
// The server has always been able to stream; /drive just never asked for it,
|
|
1819
|
+
// so a turn showed "…" for its whole duration and then arrived in one lump.
|
|
1820
|
+
let streamBuf = '';
|
|
1821
|
+
let es = null, esId = null;
|
|
1822
|
+
function paintStream() {
|
|
1823
|
+
const b = document.getElementById('streamBubble');
|
|
1824
|
+
if (!b) { render(); return; }
|
|
1825
|
+
// textContent, not markdown: the partial text is frequently mid-fence or
|
|
1826
|
+
// mid-link, and half-parsed markdown flickers. The final render formats it.
|
|
1827
|
+
b.textContent = streamBuf || '…';
|
|
1828
|
+
if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
|
|
1829
|
+
}
|
|
1830
|
+
function connectStream(id) {
|
|
1831
|
+
if (!id || esId === id) return;
|
|
1832
|
+
if (es) es.close();
|
|
1833
|
+
esId = id;
|
|
1834
|
+
streamBuf = '';
|
|
1835
|
+
es = new EventSource('/stream/' + id); // EventSource reconnects on its own
|
|
1836
|
+
es.onmessage = (e) => {
|
|
1837
|
+
let ev;
|
|
1838
|
+
try { ev = JSON.parse(e.data); } catch { return; }
|
|
1839
|
+
if (ev.type === 'start') { streamBuf = ''; paintStream(); }
|
|
1840
|
+
else if (ev.type === 'delta') { streamBuf += ev.delta || ''; paintStream(); }
|
|
1841
|
+
else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; render(); }
|
|
1842
|
+
};
|
|
1843
|
+
es.onerror = () => { /* EventSource retries; the 1.2s poll is the backstop */ };
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1772
1846
|
let pendingFiles = [];
|
|
1773
1847
|
let pendingImages = [];
|
|
1774
1848
|
const attachChips = document.getElementById('attachChips');
|
|
@@ -2011,7 +2085,7 @@ const APP_HTML = `<!doctype html>
|
|
|
2011
2085
|
}
|
|
2012
2086
|
});
|
|
2013
2087
|
|
|
2014
|
-
async function tick() { await loadThreads(); await render(); }
|
|
2088
|
+
async function tick() { connectStream(activeId); await loadThreads(); await render(); }
|
|
2015
2089
|
tick();
|
|
2016
2090
|
setInterval(tick, 1200);
|
|
2017
2091
|
|
|
@@ -2094,6 +2168,34 @@ const server = http.createServer((req, res) => {
|
|
|
2094
2168
|
})();
|
|
2095
2169
|
return;
|
|
2096
2170
|
}
|
|
2171
|
+
// LIVE TOKENS. runTurn has always been able to stream — it takes an onEvent
|
|
2172
|
+
// and calls brainStream — but /drive never passed one, so every turn used
|
|
2173
|
+
// the non-streaming brain() and the UI just polled /threads every 1.2s.
|
|
2174
|
+
// The user watched a "…" bubble for the whole generation and then got the
|
|
2175
|
+
// answer in one lump. Same work, all of the latency, none of the feedback.
|
|
2176
|
+
const sse = /^\/stream\/([^/?]+)$/.exec(req.url || '');
|
|
2177
|
+
if (req.method === 'GET' && sse) {
|
|
2178
|
+
const id = sse[1];
|
|
2179
|
+
res.writeHead(200, {
|
|
2180
|
+
'content-type': 'text/event-stream',
|
|
2181
|
+
'cache-control': 'no-cache',
|
|
2182
|
+
connection: 'keep-alive',
|
|
2183
|
+
// Proxies in front of a box (RunPod, nginx) will happily buffer an
|
|
2184
|
+
// event stream into nothing until it ends, which looks exactly like
|
|
2185
|
+
// streaming being broken.
|
|
2186
|
+
'x-accel-buffering': 'no',
|
|
2187
|
+
});
|
|
2188
|
+
res.write(': open\n\n');
|
|
2189
|
+
if (!streamListeners.has(id)) streamListeners.set(id, new Set());
|
|
2190
|
+
streamListeners.get(id).add(res);
|
|
2191
|
+
const ka = setInterval(() => { try { res.write(': ka\n\n'); } catch { /* gone */ } }, 20000);
|
|
2192
|
+
req.on('close', () => {
|
|
2193
|
+
clearInterval(ka);
|
|
2194
|
+
streamListeners.get(id)?.delete(res);
|
|
2195
|
+
if (!streamListeners.get(id)?.size) streamListeners.delete(id);
|
|
2196
|
+
});
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2097
2199
|
// One source of truth for the composer's autocomplete — a hand-kept menu in
|
|
2098
2200
|
// the client would drift from what the server actually handles.
|
|
2099
2201
|
if (req.method === 'GET' && req.url === '/slash-commands') {
|
|
@@ -2231,7 +2333,9 @@ const server = http.createServer((req, res) => {
|
|
|
2231
2333
|
saveThreads();
|
|
2232
2334
|
return;
|
|
2233
2335
|
}
|
|
2234
|
-
|
|
2336
|
+
// Stream to whoever is watching this thread. emitToThread is a no-op
|
|
2337
|
+
// when nobody is, so a spawned subagent nobody has open costs nothing.
|
|
2338
|
+
runTurn(threadId, task, (ev) => emitToThread(threadId, ev), images).catch(() => {});
|
|
2235
2339
|
});
|
|
2236
2340
|
return;
|
|
2237
2341
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.9",
|
|
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",
|