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.
Files changed (2) hide show
  1. package/lib/grokui.mjs +299 -1
  2. 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(/&nbsp;/g, ' ').replace(/&amp;/g, '&')
537
720
  .replace(/&lt;/g, '<').replace(/&gt;/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.4",
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",