openzoo 0.48.4 → 0.48.6
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 +299 -1
- package/package.json +1 -1
package/lib/grokui.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { exec } from 'node:child_process';
|
|
9
9
|
import http from 'node:http';
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { brain, brainStream, PROXY } from './podagent.mjs';
|
|
@@ -112,6 +112,34 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
|
|
|
112
112
|
text — web search only gives you short
|
|
113
113
|
snippets; use FETCH when asked to
|
|
114
114
|
"read" or quote something specific
|
|
115
|
+
MCP: <url> list the tools an MCP server exposes
|
|
116
|
+
MCP: <url> | <tool> | {"arg": "value"} CALL one of them, for real
|
|
117
|
+
An MCP endpoint speaks JSON-RPC over
|
|
118
|
+
POST. FETCH does a GET, so it will
|
|
119
|
+
always come back 405 Method Not
|
|
120
|
+
Allowed — that is NOT a broken URL and
|
|
121
|
+
NOT a reason to curl it or write your
|
|
122
|
+
own client. Use this directive; it
|
|
123
|
+
does the initialize handshake, holds
|
|
124
|
+
the session, and calls the tool.
|
|
125
|
+
LS: <path, or blank for the root> list a directory
|
|
126
|
+
GLOB: <pattern> find files — *.js, **/*.test.ts, src/**
|
|
127
|
+
GREP: <regex> | <optional path or glob> search file CONTENTS, with line numbers
|
|
128
|
+
EDIT: <path> | <exact old text> ||| <new text> change PART of a file. Prefer this over
|
|
129
|
+
WRITE for edits — WRITE replaces the
|
|
130
|
+
whole file, so anything you don't
|
|
131
|
+
reproduce from memory is destroyed.
|
|
132
|
+
The old text must match byte for byte
|
|
133
|
+
and appear exactly once; READ first.
|
|
134
|
+
TODO: <one item per line> set a visible checklist
|
|
135
|
+
TODO: done <n> tick an item (TODO: alone = show it)
|
|
136
|
+
|
|
137
|
+
WORK IN PARALLEL. READ, LS, GLOB, GREP, FETCH, PEEK and MCP are read-only, and if you emit
|
|
138
|
+
several of them in ONE reply the harness runs them CONCURRENTLY and returns every result
|
|
139
|
+
together. Four files in one turn costs one round trip; four turns costs four, and you pay per
|
|
140
|
+
call. Ask for everything you know you need at once instead of discovering it one file at a
|
|
141
|
+
time. Mutating directives (RUN, WRITE, EDIT, SPAWN, SEND) stay sequential on purpose — racing
|
|
142
|
+
them against each other corrupts the tree.
|
|
115
143
|
RUN: <shell command> run a REAL shell command in this
|
|
116
144
|
thread's directory — by default this
|
|
117
145
|
pauses and waits for the user to
|
|
@@ -463,7 +491,59 @@ if (!loadThreads()) newThread('openzoo', null);
|
|
|
463
491
|
// Parses a SPAWN/SEND/PING directive out of a reply, performs its side effect
|
|
464
492
|
// (creating or messaging another thread), and returns the ack text to show in
|
|
465
493
|
// place of the raw directive line — or null if the reply wasn't a directive.
|
|
494
|
+
// Directives that only READ. These are safe to run at the same time, so a
|
|
495
|
+
// reply carrying several of them costs one round trip instead of N — a model
|
|
496
|
+
// that wants four files currently spends four full turns (and four payments)
|
|
497
|
+
// fetching them one at a time.
|
|
498
|
+
//
|
|
499
|
+
// Deliberately excludes RUN / WRITE / EDIT / SPAWN / SEND: those mutate, and
|
|
500
|
+
// concurrent mutation of the same tree is a race the model cannot reason
|
|
501
|
+
// about. Reads fan out, writes stay sequential.
|
|
502
|
+
const PARALLEL_DIRECTIVE = /^[ \t>*-]*(READ|LS|GLOB|GREP|FETCH|PEEK|MCP):[ \t]*(.+)$/gm;
|
|
503
|
+
|
|
504
|
+
// Walk a thread dir once, cheaply, skipping the things nobody means to search.
|
|
505
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__pycache__', '.venv', 'venv']);
|
|
506
|
+
function walkDir(base, rel = '', out = [], depth = 0) {
|
|
507
|
+
if (depth > 12 || out.length > 5000) return out;
|
|
508
|
+
let entries = [];
|
|
509
|
+
try { entries = readdirSync(path.join(base, rel), { withFileTypes: true }); } catch { return out; }
|
|
510
|
+
for (const e of entries) {
|
|
511
|
+
const r = rel ? path.join(rel, e.name) : e.name;
|
|
512
|
+
if (e.isDirectory()) {
|
|
513
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
514
|
+
walkDir(base, r, out, depth + 1);
|
|
515
|
+
} else out.push(r);
|
|
516
|
+
}
|
|
517
|
+
return out;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Glob -> RegExp. `**` crosses separators, `*` and `?` do not.
|
|
521
|
+
function globToRe(glob) {
|
|
522
|
+
let re = '';
|
|
523
|
+
for (let i = 0; i < glob.length; i++) {
|
|
524
|
+
const c = glob[i];
|
|
525
|
+
if (c === '*') {
|
|
526
|
+
if (glob[i + 1] === '*') { re += '.*'; i++; if (glob[i + 1] === '/') i++; }
|
|
527
|
+
else re += '[^/]*';
|
|
528
|
+
} else if (c === '?') re += '[^/]';
|
|
529
|
+
else if ('.+^${}()|[]\\'.includes(c)) re += '\\' + c;
|
|
530
|
+
else re += c;
|
|
531
|
+
}
|
|
532
|
+
return new RegExp('^' + re + '$');
|
|
533
|
+
}
|
|
534
|
+
|
|
466
535
|
async function tryDirective(reply, originId) {
|
|
536
|
+
// FAN OUT FIRST. Each line is re-entered on its own, so every branch below
|
|
537
|
+
// stays single-directive and none of them had to learn about batching.
|
|
538
|
+
const batch = [...reply.matchAll(PARALLEL_DIRECTIVE)];
|
|
539
|
+
if (batch.length > 1) {
|
|
540
|
+
const results = await Promise.all(
|
|
541
|
+
batch.map((m) => tryDirective(m[0].replace(/^[ \t>*-]*/, ''), originId)
|
|
542
|
+
.catch((e) => `${m[1]}: ${e.message}`)),
|
|
543
|
+
);
|
|
544
|
+
return results.filter(Boolean).join('\n\n');
|
|
545
|
+
}
|
|
546
|
+
|
|
467
547
|
const spawn = /^SPAWN:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
|
|
468
548
|
if (spawn) {
|
|
469
549
|
const name = spawn[1].trim();
|
|
@@ -517,6 +597,109 @@ async function tryDirective(reply, originId) {
|
|
|
517
597
|
return `${rel}:\n${data.slice(0, 4000)}${data.length > 4000 ? '\n…(truncated)' : ''}`;
|
|
518
598
|
} catch (e) { return `Couldn't read ${rel}: ${e.message}`; }
|
|
519
599
|
}
|
|
600
|
+
// EDIT beats WRITE for changing part of a file: WRITE overwrites the whole
|
|
601
|
+
// thing, so a model that wants a one-line change has to reproduce the entire
|
|
602
|
+
// file from memory and silently drops whatever it forgot.
|
|
603
|
+
const edit = /^EDIT:\s*([^|]+)\|([\s\S]*?)\|\|\|([\s\S]*)$/.exec(reply);
|
|
604
|
+
if (edit) {
|
|
605
|
+
const rel = edit[1].trim();
|
|
606
|
+
const oldStr = edit[2].replace(/^\n/, '').replace(/\n$/, '');
|
|
607
|
+
const newStr = edit[3].replace(/^\n/, '').replace(/\n$/, '');
|
|
608
|
+
try {
|
|
609
|
+
const full = safeResolveIn(dirFor(originId), rel);
|
|
610
|
+
const before = readFileSync(full, 'utf8');
|
|
611
|
+
const hits = before.split(oldStr).length - 1;
|
|
612
|
+
if (hits === 0) return `EDIT ${rel}: that exact text isn't in the file — READ it first, the copy must match byte for byte.`;
|
|
613
|
+
if (hits > 1) return `EDIT ${rel}: that text appears ${hits} times — include more surrounding context so it matches exactly once.`;
|
|
614
|
+
writeFileSync(full, before.replace(oldStr, newStr));
|
|
615
|
+
return `Edited ${rel} (${before.length} -> ${before.replace(oldStr, newStr).length} bytes).`;
|
|
616
|
+
} catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const ls = /^LS:\s*(.*)$/.exec(reply);
|
|
620
|
+
if (ls) {
|
|
621
|
+
const rel = ls[1].trim() || '.';
|
|
622
|
+
try {
|
|
623
|
+
const full = safeResolveIn(dirFor(originId), rel);
|
|
624
|
+
const entries = readdirSync(full, { withFileTypes: true });
|
|
625
|
+
if (!entries.length) return `${rel}: (empty)`;
|
|
626
|
+
const lines = entries.slice(0, 300).map((e) => {
|
|
627
|
+
if (e.isDirectory()) return ` ${e.name}/`;
|
|
628
|
+
let size = '';
|
|
629
|
+
try { size = ` (${statSync(path.join(full, e.name)).size}b)`; } catch { /* raced */ }
|
|
630
|
+
return ` ${e.name}${size}`;
|
|
631
|
+
});
|
|
632
|
+
return `${rel}:\n${lines.join('\n')}${entries.length > 300 ? `\n …${entries.length - 300} more` : ''}`;
|
|
633
|
+
} catch (e) { return `Couldn't list ${rel}: ${e.message}`; }
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const glob = /^GLOB:\s*(.+)$/.exec(reply);
|
|
637
|
+
if (glob) {
|
|
638
|
+
const pattern = glob[1].trim();
|
|
639
|
+
try {
|
|
640
|
+
const base = dirFor(originId);
|
|
641
|
+
const re = globToRe(pattern.startsWith('./') ? pattern.slice(2) : pattern);
|
|
642
|
+
const hits = walkDir(base).filter((f) => re.test(f) || re.test(path.basename(f)));
|
|
643
|
+
if (!hits.length) return `GLOB ${pattern}: no matches`;
|
|
644
|
+
return `GLOB ${pattern} — ${hits.length} match(es):\n${hits.slice(0, 200).map((h) => ' ' + h).join('\n')}`
|
|
645
|
+
+ (hits.length > 200 ? `\n …${hits.length - 200} more` : '');
|
|
646
|
+
} catch (e) { return `GLOB ${pattern}: ${e.message}`; }
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const grep = /^GREP:\s*([^|]+?)(?:\s*\|\s*(.+))?$/.exec(reply);
|
|
650
|
+
if (grep) {
|
|
651
|
+
const pattern = grep[1].trim();
|
|
652
|
+
const scope = (grep[2] || '').trim();
|
|
653
|
+
try {
|
|
654
|
+
const base = dirFor(originId);
|
|
655
|
+
let re;
|
|
656
|
+
try { re = new RegExp(pattern, 'i'); }
|
|
657
|
+
catch { return `GREP: ${pattern} isn't a valid regex.`; }
|
|
658
|
+
let files = walkDir(base);
|
|
659
|
+
if (scope) { const sre = globToRe(scope); files = files.filter((f) => sre.test(f) || f.startsWith(scope)); }
|
|
660
|
+
const out = [];
|
|
661
|
+
for (const f of files) {
|
|
662
|
+
if (out.length > 200) break;
|
|
663
|
+
let text;
|
|
664
|
+
try { text = readFileSync(path.join(base, f), 'utf8'); } catch { continue; }
|
|
665
|
+
if (text.indexOf(String.fromCharCode(0)) !== -1) continue; // binary — NUL, not whitespace
|
|
666
|
+
text.split('\n').forEach((line, i) => {
|
|
667
|
+
if (out.length <= 200 && re.test(line)) out.push(` ${f}:${i + 1}: ${line.trim().slice(0, 200)}`);
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
if (!out.length) return `GREP ${pattern}: no matches`;
|
|
671
|
+
return `GREP ${pattern} — ${out.length} hit(s):\n${out.join('\n')}`;
|
|
672
|
+
} catch (e) { return `GREP: ${e.message}`; }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// A real, persisted checklist. Bots were already narrating plans; this makes
|
|
676
|
+
// the plan a thing the user can see and the model can be held to.
|
|
677
|
+
const todo = /^TODO:\s*([\s\S]*)$/.exec(reply);
|
|
678
|
+
if (todo) {
|
|
679
|
+
const t = threads.get(originId);
|
|
680
|
+
if (!t) return 'TODO: no such thread.';
|
|
681
|
+
t.todos = t.todos || [];
|
|
682
|
+
const body = todo[1].trim();
|
|
683
|
+
if (!body || /^(list|show)$/i.test(body)) {
|
|
684
|
+
if (!t.todos.length) return 'TODO: (empty)';
|
|
685
|
+
return 'TODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n');
|
|
686
|
+
}
|
|
687
|
+
const done = /^done\s+(\d+)/i.exec(body);
|
|
688
|
+
if (done) {
|
|
689
|
+
const idx = Number(done[1]) - 1;
|
|
690
|
+
if (!t.todos[idx]) return `TODO: no item ${done[1]}.`;
|
|
691
|
+
t.todos[idx].done = true;
|
|
692
|
+
saveThreads();
|
|
693
|
+
return 'TODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n');
|
|
694
|
+
}
|
|
695
|
+
if (/^clear$/i.test(body)) { t.todos = []; saveThreads(); return 'TODO: cleared.'; }
|
|
696
|
+
// Otherwise: replace the list with the lines given.
|
|
697
|
+
t.todos = body.split('\n').map((l) => l.replace(/^[-*\d.)\]\s]+/, '').trim())
|
|
698
|
+
.filter(Boolean).map((text) => ({ text, done: false }));
|
|
699
|
+
saveThreads();
|
|
700
|
+
return 'TODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [ ] ${x.text}`).join('\n');
|
|
701
|
+
}
|
|
702
|
+
|
|
520
703
|
const serve = /^SERVE:\s*(.*)$/.exec(reply);
|
|
521
704
|
if (serve) {
|
|
522
705
|
const rel = serve[1].trim();
|
|
@@ -536,12 +719,127 @@ async function tryDirective(reply, originId) {
|
|
|
536
719
|
.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&')
|
|
537
720
|
.replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim();
|
|
538
721
|
}
|
|
722
|
+
// A 405 on an MCP endpoint is not a broken URL, and a model cannot tell
|
|
723
|
+
// the difference — it retries the same GET, then gives up and hand-rolls
|
|
724
|
+
// a client. Say what actually happened and point at the directive that
|
|
725
|
+
// works. MEASURED: three wasted turns curling /api/mcp before the user
|
|
726
|
+
// had to intervene with "stop hitting /api/mcp as a curl".
|
|
727
|
+
if (r.status === 405 && /\/mcp\/?$/.test(url)) {
|
|
728
|
+
return `${url} (405): that is an MCP endpoint, not a web page — it speaks `
|
|
729
|
+
+ `JSON-RPC over POST and rejects the GET that FETCH does. Use the MCP `
|
|
730
|
+
+ `directive instead:\n MCP: ${url}\nto list its tools, then\n `
|
|
731
|
+
+ `MCP: ${url} | <tool> | {"arg": "value"}\nto call one.`;
|
|
732
|
+
}
|
|
539
733
|
return `${url} (${r.status}):\n${text.slice(0, 8000)}${text.length > 8000 ? '\n…(truncated)' : ''}`;
|
|
540
734
|
} catch (e) { return `Couldn't fetch ${url}: ${e.message}`; }
|
|
541
735
|
}
|
|
736
|
+
|
|
737
|
+
const mcpD = /^MCP:\s*(\S+)\s*(?:\|\s*([^|]+?)\s*(?:\|\s*([\s\S]+))?)?$/m.exec(reply);
|
|
738
|
+
if (mcpD) {
|
|
739
|
+
const [, url, tool, argsRaw] = mcpD;
|
|
740
|
+
let args = {};
|
|
741
|
+
if (argsRaw) {
|
|
742
|
+
const fenced = /```[\w-]*\n([\s\S]*?)```/.exec(argsRaw);
|
|
743
|
+
try { args = JSON.parse((fenced ? fenced[1] : argsRaw).trim()); }
|
|
744
|
+
catch (e) { return `MCP: couldn't parse the arguments as JSON — ${e.message}`; }
|
|
745
|
+
}
|
|
746
|
+
return await mcpDirective(url.trim(), tool?.trim(), args);
|
|
747
|
+
}
|
|
542
748
|
return null;
|
|
543
749
|
}
|
|
544
750
|
|
|
751
|
+
// ---------------------------------------------------------------------------
|
|
752
|
+
// MCP client — streamable-http, written against fetch on purpose.
|
|
753
|
+
//
|
|
754
|
+
// grokui runs STANDALONE from /opt/grokui/grokui.mjs, which has no
|
|
755
|
+
// node_modules beside it, so importing @modelcontextprotocol/sdk would break
|
|
756
|
+
// the copy that boxes actually execute. The wire protocol is small: POST
|
|
757
|
+
// JSON-RPC, accept both JSON and SSE, carry the session id the server hands
|
|
758
|
+
// back on initialize.
|
|
759
|
+
//
|
|
760
|
+
// This exists because bots were told to "install an MCP" and had no way to
|
|
761
|
+
// speak to one. FETCH does a GET; every MCP endpoint answers GET with 405, so
|
|
762
|
+
// the model saw a dead URL, retried, then wrote its own Python client.
|
|
763
|
+
let mcpId = 0;
|
|
764
|
+
|
|
765
|
+
async function mcpRpc(url, method, params, session, notify = false) {
|
|
766
|
+
const id = notify ? undefined : ++mcpId;
|
|
767
|
+
const r = await fetch(url, {
|
|
768
|
+
method: 'POST',
|
|
769
|
+
headers: {
|
|
770
|
+
'content-type': 'application/json',
|
|
771
|
+
// BOTH are required. Servers negotiate between a plain JSON reply and an
|
|
772
|
+
// SSE stream, and offering only one gets a 406 from spec-strict servers.
|
|
773
|
+
accept: 'application/json, text/event-stream',
|
|
774
|
+
'mcp-protocol-version': '2025-06-18',
|
|
775
|
+
...(session ? { 'mcp-session-id': session } : {}),
|
|
776
|
+
},
|
|
777
|
+
body: JSON.stringify(notify ? { jsonrpc: '2.0', method, params } : { jsonrpc: '2.0', id, method, params }),
|
|
778
|
+
});
|
|
779
|
+
const sid = r.headers.get('mcp-session-id') || session;
|
|
780
|
+
const body = await r.text();
|
|
781
|
+
if (notify) return { status: r.status, session: sid, json: null };
|
|
782
|
+
|
|
783
|
+
let json = null;
|
|
784
|
+
if ((r.headers.get('content-type') || '').includes('text/event-stream')) {
|
|
785
|
+
// SSE frames: take the last `data:` payload that carries a result/error.
|
|
786
|
+
for (const line of body.split(/\r?\n/)) {
|
|
787
|
+
if (!line.startsWith('data:')) continue;
|
|
788
|
+
try {
|
|
789
|
+
const j = JSON.parse(line.slice(5).trim());
|
|
790
|
+
if (j && (j.result !== undefined || j.error !== undefined)) json = j;
|
|
791
|
+
} catch { /* keep-alive or partial frame */ }
|
|
792
|
+
}
|
|
793
|
+
} else {
|
|
794
|
+
try { json = JSON.parse(body); } catch { /* non-JSON error page */ }
|
|
795
|
+
}
|
|
796
|
+
return { status: r.status, session: sid, json, raw: body };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async function mcpDirective(url, tool, args) {
|
|
800
|
+
try {
|
|
801
|
+
const init = await mcpRpc(url, 'initialize', {
|
|
802
|
+
protocolVersion: '2025-06-18',
|
|
803
|
+
capabilities: {},
|
|
804
|
+
clientInfo: { name: 'openzoo-grokui', version: '1' },
|
|
805
|
+
});
|
|
806
|
+
if (init.json?.error) return `MCP ${url}: initialize failed — ${JSON.stringify(init.json.error)}`;
|
|
807
|
+
if (!init.json) return `MCP ${url}: no JSON-RPC reply (HTTP ${init.status})\n${(init.raw || '').slice(0, 600)}`;
|
|
808
|
+
const session = init.session;
|
|
809
|
+
// Required by spec before any other request; skipping it makes some
|
|
810
|
+
// servers reject everything after initialize.
|
|
811
|
+
await mcpRpc(url, 'notifications/initialized', {}, session, true).catch(() => {});
|
|
812
|
+
|
|
813
|
+
const server = init.json.result?.serverInfo;
|
|
814
|
+
const banner = `MCP ${url}${server ? ` — ${server.name} ${server.version || ''}`.trimEnd() : ''}`;
|
|
815
|
+
|
|
816
|
+
if (!tool) {
|
|
817
|
+
const list = await mcpRpc(url, 'tools/list', {}, session);
|
|
818
|
+
if (list.json?.error) return `${banner}\ntools/list failed — ${JSON.stringify(list.json.error)}`;
|
|
819
|
+
const tools = list.json?.result?.tools || [];
|
|
820
|
+
if (!tools.length) return `${banner}\n(no tools)`;
|
|
821
|
+
const lines = tools.map((t) => {
|
|
822
|
+
const req = t.inputSchema?.required || [];
|
|
823
|
+
const props = Object.keys(t.inputSchema?.properties || {});
|
|
824
|
+
const sig = props.map((p) => (req.includes(p) ? p : `${p}?`)).join(', ');
|
|
825
|
+
return ` ${t.name}(${sig})\n ${(t.description || '').split('\n')[0].slice(0, 160)}`;
|
|
826
|
+
});
|
|
827
|
+
return `${banner}\n${tools.length} tools:\n${lines.join('\n')}\n\n`
|
|
828
|
+
+ `Call one with: MCP: ${url} | <tool> | {"arg": "value"}`;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const call = await mcpRpc(url, 'tools/call', { name: tool, arguments: args }, session);
|
|
832
|
+
if (call.json?.error) return `${banner}\n${tool} failed — ${JSON.stringify(call.json.error)}`;
|
|
833
|
+
const res = call.json?.result;
|
|
834
|
+
const out = (res?.content || [])
|
|
835
|
+
.map((c) => (c.type === 'text' ? c.text : `[${c.type}]`))
|
|
836
|
+
.join('\n') || JSON.stringify(res ?? call.raw);
|
|
837
|
+
return `${banner}\n$ ${tool}\n${out.slice(0, 6000)}${out.length > 6000 ? '\n…(truncated)' : ''}`;
|
|
838
|
+
} catch (e) {
|
|
839
|
+
return `MCP ${url}: ${e.message}`;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
545
843
|
// onEvent (optional) gets live progress for whoever's actually watching this
|
|
546
844
|
// call: {type:'start',name,color} when a bot begins its turn, {type:'delta',
|
|
547
845
|
// name,color,delta} per streamed token, {type:'final',name,color,text} once
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.6",
|
|
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",
|