openzoo 0.50.76 → 0.50.78
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/bin/openzoo.js +17 -0
- package/lib/bindpath.js +9 -6
- package/lib/botlog.js +6 -4
- package/lib/cursorbackend.js +77 -2
- package/lib/mcp.js +4 -0
- package/lib/models.js +28 -0
- package/package.json +1 -1
package/bin/openzoo.js
CHANGED
|
@@ -284,6 +284,9 @@ async function main() {
|
|
|
284
284
|
? process.argv[ei + 1].split(',').map((e) => (e.startsWith('.') ? e : `.${e}`))
|
|
285
285
|
: undefined;
|
|
286
286
|
const mb = (n) => (n / 1048576).toFixed(1);
|
|
287
|
+
// Delta uploads are routinely a few KB against a multi-MB corpus; "0.0MB
|
|
288
|
+
// of 3.2MB" reads as a bug. Pick the unit per number.
|
|
289
|
+
const hb = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)}MB` : n >= 1024 ? `${(n / 1024).toFixed(0)}KB` : `${n}B`);
|
|
287
290
|
const out = await bindPath(target, {
|
|
288
291
|
exts,
|
|
289
292
|
force: process.argv.includes('--force'),
|
|
@@ -291,9 +294,23 @@ async function main() {
|
|
|
291
294
|
if (p.stage === 'reused') console.log(`already bound — ${mb(p.bytes)}MB across ${p.files} file(s) reused, nothing uploaded`);
|
|
292
295
|
if (p.stage === 'start') console.log(`binding ${mb(p.bytes)}MB from ${p.files} file(s) in ${p.parts} part(s)...`);
|
|
293
296
|
if (p.stage === 'part') console.log(` part ${p.index}/${p.of} bound (${mb(p.bytes)}MB)`);
|
|
297
|
+
// DELTA: the server already holds most of a re-bound repo, so say
|
|
298
|
+
// how much is actually crossing the wire — that number is the reason
|
|
299
|
+
// re-binding after an edit is cheap, and hiding it makes the feature
|
|
300
|
+
// look like a no-op.
|
|
301
|
+
if (p.stage === 'delta') {
|
|
302
|
+
console.log(p.missing === 0
|
|
303
|
+
? `delta: server already holds all ${p.chunks} chunk(s) of ${hb(p.bytes)} — nothing to upload`
|
|
304
|
+
: `delta: ${p.missing} of ${p.chunks} chunk(s) missing on the server, uploading only those...`);
|
|
305
|
+
}
|
|
306
|
+
if (p.stage === 'delta-fill') console.log(` shipped ${hb(p.shipped)} of ${hb(p.of)}${p.remaining ? ` (${p.remaining} still missing)` : ''}`);
|
|
294
307
|
},
|
|
295
308
|
});
|
|
296
309
|
console.log('');
|
|
310
|
+
if (out.delta) {
|
|
311
|
+
const pct = out.bytes ? ((100 * out.shipped) / out.bytes).toFixed(1) : '0.0';
|
|
312
|
+
console.log(`bound via delta — uploaded ${hb(out.shipped)} of ${hb(out.bytes)} (${pct}%)`);
|
|
313
|
+
}
|
|
297
314
|
console.log(`context: ${out.contextId}`);
|
|
298
315
|
console.log(`ask it: npx openzoo ask "your question" --context ${out.contextId}`);
|
|
299
316
|
console.log('or send X-HRR-Context: <id> with a small body to /v1/chat/completions');
|
package/lib/bindpath.js
CHANGED
|
@@ -144,14 +144,17 @@ export function splitIntoParts(text, maxBytes = MAX_PART_BYTES) {
|
|
|
144
144
|
*/
|
|
145
145
|
const sha256 = (t) => createHash('sha256').update(t, 'utf8').digest('hex');
|
|
146
146
|
|
|
147
|
+
const ddbg = (...a) => { if (process.env.OPENZOO_BIND_DEBUG) console.error('[delta]', ...a); };
|
|
148
|
+
|
|
147
149
|
async function postDelta(payload) {
|
|
148
150
|
const r = await fetch(`${config.apiBase}/v1/hrr/delta`, {
|
|
149
151
|
method: 'POST',
|
|
150
152
|
headers: withNamespace({ 'content-type': 'application/json' }),
|
|
151
153
|
body: JSON.stringify(payload),
|
|
152
154
|
});
|
|
153
|
-
if (r.status !== 200) return null;
|
|
155
|
+
if (r.status !== 200) { ddbg('HTTP', r.status, (await r.text().catch(() => '')).slice(0, 200)); return null; }
|
|
154
156
|
const j = await r.json().catch(() => null);
|
|
157
|
+
if (!(j && Array.isArray(j.missing))) ddbg('bad shape', JSON.stringify(j).slice(0, 200));
|
|
155
158
|
return j && Array.isArray(j.missing) ? j : null;
|
|
156
159
|
}
|
|
157
160
|
|
|
@@ -175,19 +178,19 @@ async function bindDelta(fileTexts, shardKey, onProgress) {
|
|
|
175
178
|
let size = 0;
|
|
176
179
|
for (const h of missing) {
|
|
177
180
|
const t = byHash.get(h);
|
|
178
|
-
if (t === undefined) return null;
|
|
181
|
+
if (t === undefined) { ddbg('missing hash not in byHash', h); return null; }
|
|
179
182
|
if (size && size + Buffer.byteLength(t) > MAX_PART_BYTES) break;
|
|
180
183
|
batch[h] = t; size += Buffer.byteLength(t);
|
|
181
184
|
}
|
|
182
|
-
if (!Object.keys(batch).length) return null;
|
|
185
|
+
if (!Object.keys(batch).length) { ddbg('empty batch'); return null; }
|
|
183
186
|
last = await postDelta({ chunk_hashes: hashes, chunks: batch, shard_key: shardKey });
|
|
184
187
|
if (!last) return null;
|
|
185
188
|
shipped += size;
|
|
186
189
|
onProgress?.({ stage: 'delta-fill', shipped, of: total, remaining: last.missing.length });
|
|
187
|
-
if (last.missing.length >= missing.length) return null; // no progress: refused chunks
|
|
190
|
+
if (last.missing.length >= missing.length) { ddbg('no progress', last.missing.length, missing.length, JSON.stringify(last.refused)); return null; } // no progress: refused chunks
|
|
188
191
|
missing = last.missing;
|
|
189
192
|
}
|
|
190
|
-
if (!last.complete || !last.context_id) return null;
|
|
193
|
+
if (!last.complete || !last.context_id) { ddbg('incomplete', JSON.stringify(last).slice(0, 200)); return null; }
|
|
191
194
|
return { contextId: last.context_id, chunks: chunks.length, shipped, bytes: total };
|
|
192
195
|
}
|
|
193
196
|
|
|
@@ -259,7 +262,7 @@ export async function bindPath(target, { exts, onProgress, force = false } = {})
|
|
|
259
262
|
rememberContext(config.apiBase, hash, d.contextId);
|
|
260
263
|
return { contextId: d.contextId, files, parts: d.chunks, bytes, reused: false, delta: true, shipped: d.shipped };
|
|
261
264
|
}
|
|
262
|
-
} catch { /* fall through to the whole bind */ }
|
|
265
|
+
} catch (e) { ddbg('threw', e?.message); /* fall through to the whole bind */ }
|
|
263
266
|
}
|
|
264
267
|
|
|
265
268
|
const parts = splitIntoParts(text);
|
package/lib/botlog.js
CHANGED
|
@@ -42,7 +42,7 @@ const MILESTONE = [
|
|
|
42
42
|
/ERROR|FAIL|uncaught|rejection|timed out/,
|
|
43
43
|
/wakeups restored|wakeup fire/,
|
|
44
44
|
/ozRevive|ship_|ship:/,
|
|
45
|
-
/sendPrompt done/,
|
|
45
|
+
/sendPrompt done|>> sendPrompt/,
|
|
46
46
|
/create_agent tool|deleteAgents n=/,
|
|
47
47
|
/x_compose|chrome reattach/,
|
|
48
48
|
];
|
|
@@ -57,7 +57,7 @@ export function isBotMilestone(line) {
|
|
|
57
57
|
/**
|
|
58
58
|
* Terminal gets milestones (or everything with verbose); the FULL stream is
|
|
59
59
|
* always appended to `file` so quiet mode never destroys evidence.
|
|
60
|
-
* Default file: ~/.openzoo/bot.log (
|
|
60
|
+
* Default file: ~/.openzoo/bot.log (appended; one header line per run).
|
|
61
61
|
*/
|
|
62
62
|
export function makeBotLogger({ verbose = false, write = (m) => console.error(m), file = defaultBotLogPath(), fsMod = null } = {}) {
|
|
63
63
|
let fd = null;
|
|
@@ -65,8 +65,10 @@ export function makeBotLogger({ verbose = false, write = (m) => console.error(m)
|
|
|
65
65
|
try {
|
|
66
66
|
const fsx = fsMod || fsSync;
|
|
67
67
|
fsx.mkdirSync(pathMod.dirname(file), { recursive: true });
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
// APPEND, never truncate: the minutely revive cron restarts a dead
|
|
69
|
+
// hijack, and a truncating open erased the very turn being debugged.
|
|
70
|
+
fd = fsx.openSync(file, 'a');
|
|
71
|
+
fsx.writeSync(fd, `# openzoo bot run ${new Date().toISOString()} pid=${process.pid}\n`);
|
|
70
72
|
} catch { fd = null; }
|
|
71
73
|
}
|
|
72
74
|
return (m) => {
|
package/lib/cursorbackend.js
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
DEFAULT_WAKEUP_PROMPT, addDeletedIds, filterDeleted,
|
|
46
46
|
} from './grokbotAccount.js';
|
|
47
47
|
import { formatSpendFooter, mergeTurnProof } from './spendProof.js';
|
|
48
|
-
import { zooModelIds } from './models.js';
|
|
48
|
+
import { zooModelIds, zooModelRow, mediaKindOf } from './models.js';
|
|
49
49
|
import { prefixVisitorRichText } from './grokbotweb.js';
|
|
50
50
|
import {
|
|
51
51
|
ingestUpload, lookupUpload, readUploadChunk, readUploadImage, readUploadText,
|
|
@@ -2393,9 +2393,24 @@ const LOCAL_TOOLS = [
|
|
|
2393
2393
|
|
|
2394
2394
|
export const LOCAL_TOOL_NAMES = LOCAL_TOOLS.map((t) => t.function.name);
|
|
2395
2395
|
|
|
2396
|
+
/**
|
|
2397
|
+
* OpenAI-compatible upstreams cap `tools` at 128. MEASURED 2026-09-01:
|
|
2398
|
+
* surplusintelligence `400 Invalid 'tools': array too long … got 130` — every
|
|
2399
|
+
* bot turn with both browsers attached died at the door. Locals first, then
|
|
2400
|
+
* the browser MCPs (the bots' real work), then whatever fits.
|
|
2401
|
+
*/
|
|
2402
|
+
export const MAX_TOOLS = 128;
|
|
2403
|
+
export function capTools(list, max = MAX_TOOLS) {
|
|
2404
|
+
if (!Array.isArray(list) || list.length <= max) return list;
|
|
2405
|
+
const name = (t) => String(t?.function?.name || '');
|
|
2406
|
+
const local = list.filter((t) => LOCAL_TOOL_NAMES.includes(name(t)));
|
|
2407
|
+
const browser = list.filter((t) => !LOCAL_TOOL_NAMES.includes(name(t)) && /^(chrome|brave)-devtools__/.test(name(t)));
|
|
2408
|
+
const rest = list.filter((t) => !local.includes(t) && !browser.includes(t));
|
|
2409
|
+
return [...local, ...browser, ...rest].slice(0, max);
|
|
2410
|
+
}
|
|
2396
2411
|
function liveTools() {
|
|
2397
2412
|
const extra = hostMcpTools();
|
|
2398
|
-
return extra.length ? [...LOCAL_TOOLS, ...extra] : LOCAL_TOOLS;
|
|
2413
|
+
return capTools(extra.length ? [...LOCAL_TOOLS, ...extra] : LOCAL_TOOLS);
|
|
2399
2414
|
}
|
|
2400
2415
|
|
|
2401
2416
|
async function captureScreenshot(log) {
|
|
@@ -2744,6 +2759,58 @@ function zooTextFromMessage(msg, data) {
|
|
|
2744
2759
|
return '';
|
|
2745
2760
|
}
|
|
2746
2761
|
|
|
2762
|
+
let mediaClient = null;
|
|
2763
|
+
function receiptToX402(r) {
|
|
2764
|
+
if (!r) return {};
|
|
2765
|
+
return { billedUsd: r.billedUsd, directUsd: r.directUsd ?? r.billedUsd, rail: r.rail, tx: r.tx, line: r.line, paid: r.asset };
|
|
2766
|
+
}
|
|
2767
|
+
/** Pay the media endpoint, poll the job, paint the URL. Same PayClient path as zoo_video. */
|
|
2768
|
+
async function mediaTurn({ model, kind, prompt, log, onProgress, signal }) {
|
|
2769
|
+
const { PayClient } = await import('./pay.js');
|
|
2770
|
+
const { config } = await import('./config.js');
|
|
2771
|
+
const { withNamespace } = await import('./namespace.js');
|
|
2772
|
+
mediaClient ||= new PayClient();
|
|
2773
|
+
const say = (m) => { try { onProgress?.(m); } catch { /* */ } };
|
|
2774
|
+
say(`Rendering ${kind} with ${model}…`);
|
|
2775
|
+
log(`cursor-backend: media ${kind} POST model=${model} ${JSON.stringify(String(prompt || '').slice(0, 60))}`);
|
|
2776
|
+
let response; let receipt; let data = {};
|
|
2777
|
+
try {
|
|
2778
|
+
({ response, receipt } = await mediaClient.fetch(`${config.apiBase}/v1/${kind}s/generations`, {
|
|
2779
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model, prompt }),
|
|
2780
|
+
}));
|
|
2781
|
+
data = await response.json().catch(() => ({}));
|
|
2782
|
+
} catch (e) {
|
|
2783
|
+
return { text: `${kind} generation failed: ${e.message}`, data: {} };
|
|
2784
|
+
}
|
|
2785
|
+
if (!response.ok) return { text: `${kind} generation failed: HTTP ${response.status} ${JSON.stringify(data).slice(0, 240)}`, data: { x402: receiptToX402(receipt) } };
|
|
2786
|
+
if (kind === 'image') {
|
|
2787
|
+
const url = data?.data?.[0]?.url || data?.outputs?.image_url || data?.url || null;
|
|
2788
|
+
return { text: url ? `🖼️ ${url}` : JSON.stringify(data).slice(0, 400), data: { x402: receiptToX402(receipt) } };
|
|
2789
|
+
}
|
|
2790
|
+
const id = data?.id;
|
|
2791
|
+
let url = data?.outputs?.video_url || null;
|
|
2792
|
+
let status = data?.status || 'queued';
|
|
2793
|
+
let err = null;
|
|
2794
|
+
const t0 = Date.now();
|
|
2795
|
+
while (!url && id && Date.now() - t0 < 8 * 60_000) {
|
|
2796
|
+
if (signal?.aborted) throw new Error('superseded');
|
|
2797
|
+
await new Promise((ok) => setTimeout(ok, 5000));
|
|
2798
|
+
try {
|
|
2799
|
+
const r = await fetch(`${config.apiBase}/v1/videos/${encodeURIComponent(id)}`, { headers: withNamespace({}), signal: AbortSignal.timeout(15000) });
|
|
2800
|
+
const j = await r.json().catch(() => ({}));
|
|
2801
|
+
status = j?.status || status;
|
|
2802
|
+
url = j?.outputs?.video_url || null;
|
|
2803
|
+
if (status === 'failed') { err = j?.error?.message || JSON.stringify(j?.error || j).slice(0, 200); break; }
|
|
2804
|
+
} catch (e) { log(`cursor-backend: media poll ${e.message}`); }
|
|
2805
|
+
say(`Rendering video… ${status} ${Math.round((Date.now() - t0) / 1000)}s`);
|
|
2806
|
+
}
|
|
2807
|
+
const text = url
|
|
2808
|
+
? `🎬 ${url}`
|
|
2809
|
+
: err ? `video failed: ${err}` : `video job ${id || '?'} still ${status} after 8 min — check later: ${config.apiBase}/v1/videos/${id || ''}`;
|
|
2810
|
+
log(`cursor-backend: media video done id=${id} status=${status} url=${url ? 'yes' : 'no'}`);
|
|
2811
|
+
return { text, data: { x402: receiptToX402(receipt) } };
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2747
2814
|
async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
|
|
2748
2815
|
const model = currentModel(agentId);
|
|
2749
2816
|
const helper = localExecSse.size > 0;
|
|
@@ -2757,6 +2824,14 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
|
|
|
2757
2824
|
};
|
|
2758
2825
|
await ensureTranscriptHydrated(agentId, log);
|
|
2759
2826
|
try { await reattachChrome({ log }); } catch (e) { log(`cursor-backend: chrome reattach ${e.message}`); }
|
|
2827
|
+
// A bot pinned to a video/image model: the prompt IS the render request.
|
|
2828
|
+
// A chat POST to ByteDance/Seedance-2.5 came back 502 with an empty body
|
|
2829
|
+
// and painted "(empty zoo reply)" — measured 2026-09-01.
|
|
2830
|
+
if (!chatOnly) {
|
|
2831
|
+
let mediaKind = null;
|
|
2832
|
+
try { mediaKind = mediaKindOf(await zooModelRow(model)); } catch { mediaKind = null; }
|
|
2833
|
+
if (mediaKind) return await mediaTurn({ model, kind: mediaKind, prompt: spoken, log, onProgress: opts.onProgress, signal: opts.signal });
|
|
2834
|
+
}
|
|
2760
2835
|
log(`cursor-backend: zoo POST :8402 model=${model} helper=${helper ? localExecSse.size : 0} hist=${historyMessages(agentId, spoken).length}${chatOnly ? ` visitor=${visitor.shortname} chat-only` : ''} ${JSON.stringify((spoken || '').slice(0, 60))}`);
|
|
2761
2836
|
if (typeof opts.onProgress === 'function') opts.onProgress('Working on your Mac…');
|
|
2762
2837
|
|
package/lib/mcp.js
CHANGED
|
@@ -413,6 +413,10 @@ export function buildMcpServer() {
|
|
|
413
413
|
files: out.files.length,
|
|
414
414
|
parts: out.parts,
|
|
415
415
|
reused: out.reused,
|
|
416
|
+
// A delta bind uploaded only the chunks the server lacked. `uploaded_bytes`
|
|
417
|
+
// against `bound_bytes` is the saving; an agent re-binding a repo after
|
|
418
|
+
// one edit should see a small number here, not the whole tree.
|
|
419
|
+
...(out.delta ? { delta: true, uploaded_bytes: out.shipped } : {}),
|
|
416
420
|
next: `zoo_ask with context_id="${out.contextId}" and a question answers from this corpus; it is already bound, so pasting it into the prompt would send it twice.`,
|
|
417
421
|
});
|
|
418
422
|
} catch (e) {
|
package/lib/models.js
CHANGED
|
@@ -541,3 +541,31 @@ export function wantsAnthropicModelList(headers = {}) {
|
|
|
541
541
|
export function modelsListForRequest(payload, headers) {
|
|
542
542
|
return wantsAnthropicModelList(headers) ? anthropicModelList(payload) : publishModelList(payload);
|
|
543
543
|
}
|
|
544
|
+
|
|
545
|
+
/* ── media rows (video / image) ─────────────────────────────────────────── */
|
|
546
|
+
let rowsCache = { at: 0, rows: null, base: '' };
|
|
547
|
+
/** Raw catalog rows incl. media (`kind`, `endpoint`), cached like zooModelIds. */
|
|
548
|
+
export async function zooCatalogRows() {
|
|
549
|
+
if (rowsCache.rows && rowsCache.base === config.apiBase && Date.now() - rowsCache.at < CATALOG_TTL_MS) return rowsCache.rows;
|
|
550
|
+
const r = await fetchHeaders(`${config.apiBase}/v1/models`);
|
|
551
|
+
if (!r.ok) throw new Error(`model catalog fetch failed: HTTP ${r.status}`);
|
|
552
|
+
const d = await r.json();
|
|
553
|
+
const rows = Array.isArray(d?.data) ? d.data : [];
|
|
554
|
+
if (rows.length) rowsCache = { at: Date.now(), rows, base: config.apiBase };
|
|
555
|
+
return rows;
|
|
556
|
+
}
|
|
557
|
+
export async function zooModelRow(id) {
|
|
558
|
+
const want = String(id || '');
|
|
559
|
+
if (!want) return null;
|
|
560
|
+
const rows = await zooCatalogRows();
|
|
561
|
+
return rows.find((m) => m?.id === want) || rows.find((m) => String(m?.id || '').toLowerCase() === want.toLowerCase()) || null;
|
|
562
|
+
}
|
|
563
|
+
/** 'video' | 'image' | null — a chat POST to one of these is a guaranteed empty reply. */
|
|
564
|
+
export function mediaKindOf(row) {
|
|
565
|
+
const k = String(row?.kind || '').toLowerCase();
|
|
566
|
+
if (k === 'video' || k === 'image') return k;
|
|
567
|
+
const ep = String(row?.endpoint || '');
|
|
568
|
+
if (/\/videos\/generations/.test(ep)) return 'video';
|
|
569
|
+
if (/\/images\/generations/.test(ep)) return 'image';
|
|
570
|
+
return null;
|
|
571
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.50.
|
|
3
|
+
"version": "0.50.78",
|
|
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",
|