openzoo 0.48.3 → 0.48.5

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 +162 -2
  2. package/package.json +1 -1
package/lib/grokui.mjs CHANGED
@@ -112,6 +112,16 @@ 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.
115
125
  RUN: <shell command> run a REAL shell command in this
116
126
  thread's directory — by default this
117
127
  pauses and waits for the user to
@@ -301,6 +311,35 @@ function newGroupThread(names) {
301
311
  const BIND_CHUNK_BYTES = 512 * 1024;
302
312
  // Chained auto-run commands per user message. Each hop is a paid call.
303
313
  const AUTO_MAX_STEPS = Number(process.env.OZ_AUTO_MAX_STEPS || 8);
314
+
315
+ // Injected fresh on every AUTO turn, never persisted into the thread.
316
+ //
317
+ // The auto loop only continues while directives keep parsing, so a reply that
318
+ // merely OFFERS ends the run — auto silently degrades to ask the moment the
319
+ // model hedges. Observed live: "If you want, I can rewrite the prompt with
320
+ // these fixes folded in", "Spawned mcp-integration — working on it" with
321
+ // nothing spawned, and a user reduced to answering "no, this... impl all".
322
+ // Models are trained to close on a consent question; in auto that instinct is
323
+ // the bug. The system prompt is frozen into a thread at creation, so an
324
+ // existing thread can only be reached by a per-turn message.
325
+ const AUTO_DIRECTIVE = `AUTO MODE IS ON for this thread.
326
+
327
+ Do the work in this turn. Do not ask whether to proceed, do not offer to do it,
328
+ do not say what you are "about to" do and stop. The user has already consented
329
+ by enabling auto — a question back to them is a dropped turn, and they must
330
+ type "yes" to get what they already asked for.
331
+
332
+ Concretely, NEVER end a turn with any of: "If you want, I can…", "Should I…?",
333
+ "Let me know and I'll…", "Ready to proceed?", or a plan with no directive after
334
+ it. If you catch yourself writing one, emit the RUN/WRITE/READ/SERVE/FETCH line
335
+ instead — that IS the answer.
336
+
337
+ Announcing an action does not perform it. "Spawned X", "working on it" and
338
+ "kicked that off" are false unless the directive line is in this same reply.
339
+ If a task needs several commands, emit the FIRST one now; you get its real
340
+ output back and continue from there. Only stop to ask when the next step is
341
+ genuinely destructive and irreversible, or when you truly cannot proceed
342
+ without a fact only the user has.`;
304
343
  async function bindThread(t) {
305
344
  // Only bind what's NEW since the last successful bind, continuing the
306
345
  // existing context_id — previously this rebuilt and re-sent the WHOLE
@@ -507,12 +546,127 @@ async function tryDirective(reply, originId) {
507
546
  .replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&')
508
547
  .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s+/g, ' ').trim();
509
548
  }
549
+ // A 405 on an MCP endpoint is not a broken URL, and a model cannot tell
550
+ // the difference — it retries the same GET, then gives up and hand-rolls
551
+ // a client. Say what actually happened and point at the directive that
552
+ // works. MEASURED: three wasted turns curling /api/mcp before the user
553
+ // had to intervene with "stop hitting /api/mcp as a curl".
554
+ if (r.status === 405 && /\/mcp\/?$/.test(url)) {
555
+ return `${url} (405): that is an MCP endpoint, not a web page — it speaks `
556
+ + `JSON-RPC over POST and rejects the GET that FETCH does. Use the MCP `
557
+ + `directive instead:\n MCP: ${url}\nto list its tools, then\n `
558
+ + `MCP: ${url} | <tool> | {"arg": "value"}\nto call one.`;
559
+ }
510
560
  return `${url} (${r.status}):\n${text.slice(0, 8000)}${text.length > 8000 ? '\n…(truncated)' : ''}`;
511
561
  } catch (e) { return `Couldn't fetch ${url}: ${e.message}`; }
512
562
  }
563
+
564
+ const mcpD = /^MCP:\s*(\S+)\s*(?:\|\s*([^|]+?)\s*(?:\|\s*([\s\S]+))?)?$/m.exec(reply);
565
+ if (mcpD) {
566
+ const [, url, tool, argsRaw] = mcpD;
567
+ let args = {};
568
+ if (argsRaw) {
569
+ const fenced = /```[\w-]*\n([\s\S]*?)```/.exec(argsRaw);
570
+ try { args = JSON.parse((fenced ? fenced[1] : argsRaw).trim()); }
571
+ catch (e) { return `MCP: couldn't parse the arguments as JSON — ${e.message}`; }
572
+ }
573
+ return await mcpDirective(url.trim(), tool?.trim(), args);
574
+ }
513
575
  return null;
514
576
  }
515
577
 
578
+ // ---------------------------------------------------------------------------
579
+ // MCP client — streamable-http, written against fetch on purpose.
580
+ //
581
+ // grokui runs STANDALONE from /opt/grokui/grokui.mjs, which has no
582
+ // node_modules beside it, so importing @modelcontextprotocol/sdk would break
583
+ // the copy that boxes actually execute. The wire protocol is small: POST
584
+ // JSON-RPC, accept both JSON and SSE, carry the session id the server hands
585
+ // back on initialize.
586
+ //
587
+ // This exists because bots were told to "install an MCP" and had no way to
588
+ // speak to one. FETCH does a GET; every MCP endpoint answers GET with 405, so
589
+ // the model saw a dead URL, retried, then wrote its own Python client.
590
+ let mcpId = 0;
591
+
592
+ async function mcpRpc(url, method, params, session, notify = false) {
593
+ const id = notify ? undefined : ++mcpId;
594
+ const r = await fetch(url, {
595
+ method: 'POST',
596
+ headers: {
597
+ 'content-type': 'application/json',
598
+ // BOTH are required. Servers negotiate between a plain JSON reply and an
599
+ // SSE stream, and offering only one gets a 406 from spec-strict servers.
600
+ accept: 'application/json, text/event-stream',
601
+ 'mcp-protocol-version': '2025-06-18',
602
+ ...(session ? { 'mcp-session-id': session } : {}),
603
+ },
604
+ body: JSON.stringify(notify ? { jsonrpc: '2.0', method, params } : { jsonrpc: '2.0', id, method, params }),
605
+ });
606
+ const sid = r.headers.get('mcp-session-id') || session;
607
+ const body = await r.text();
608
+ if (notify) return { status: r.status, session: sid, json: null };
609
+
610
+ let json = null;
611
+ if ((r.headers.get('content-type') || '').includes('text/event-stream')) {
612
+ // SSE frames: take the last `data:` payload that carries a result/error.
613
+ for (const line of body.split(/\r?\n/)) {
614
+ if (!line.startsWith('data:')) continue;
615
+ try {
616
+ const j = JSON.parse(line.slice(5).trim());
617
+ if (j && (j.result !== undefined || j.error !== undefined)) json = j;
618
+ } catch { /* keep-alive or partial frame */ }
619
+ }
620
+ } else {
621
+ try { json = JSON.parse(body); } catch { /* non-JSON error page */ }
622
+ }
623
+ return { status: r.status, session: sid, json, raw: body };
624
+ }
625
+
626
+ async function mcpDirective(url, tool, args) {
627
+ try {
628
+ const init = await mcpRpc(url, 'initialize', {
629
+ protocolVersion: '2025-06-18',
630
+ capabilities: {},
631
+ clientInfo: { name: 'openzoo-grokui', version: '1' },
632
+ });
633
+ if (init.json?.error) return `MCP ${url}: initialize failed — ${JSON.stringify(init.json.error)}`;
634
+ if (!init.json) return `MCP ${url}: no JSON-RPC reply (HTTP ${init.status})\n${(init.raw || '').slice(0, 600)}`;
635
+ const session = init.session;
636
+ // Required by spec before any other request; skipping it makes some
637
+ // servers reject everything after initialize.
638
+ await mcpRpc(url, 'notifications/initialized', {}, session, true).catch(() => {});
639
+
640
+ const server = init.json.result?.serverInfo;
641
+ const banner = `MCP ${url}${server ? ` — ${server.name} ${server.version || ''}`.trimEnd() : ''}`;
642
+
643
+ if (!tool) {
644
+ const list = await mcpRpc(url, 'tools/list', {}, session);
645
+ if (list.json?.error) return `${banner}\ntools/list failed — ${JSON.stringify(list.json.error)}`;
646
+ const tools = list.json?.result?.tools || [];
647
+ if (!tools.length) return `${banner}\n(no tools)`;
648
+ const lines = tools.map((t) => {
649
+ const req = t.inputSchema?.required || [];
650
+ const props = Object.keys(t.inputSchema?.properties || {});
651
+ const sig = props.map((p) => (req.includes(p) ? p : `${p}?`)).join(', ');
652
+ return ` ${t.name}(${sig})\n ${(t.description || '').split('\n')[0].slice(0, 160)}`;
653
+ });
654
+ return `${banner}\n${tools.length} tools:\n${lines.join('\n')}\n\n`
655
+ + `Call one with: MCP: ${url} | <tool> | {"arg": "value"}`;
656
+ }
657
+
658
+ const call = await mcpRpc(url, 'tools/call', { name: tool, arguments: args }, session);
659
+ if (call.json?.error) return `${banner}\n${tool} failed — ${JSON.stringify(call.json.error)}`;
660
+ const res = call.json?.result;
661
+ const out = (res?.content || [])
662
+ .map((c) => (c.type === 'text' ? c.text : `[${c.type}]`))
663
+ .join('\n') || JSON.stringify(res ?? call.raw);
664
+ return `${banner}\n$ ${tool}\n${out.slice(0, 6000)}${out.length > 6000 ? '\n…(truncated)' : ''}`;
665
+ } catch (e) {
666
+ return `MCP ${url}: ${e.message}`;
667
+ }
668
+ }
669
+
516
670
  // onEvent (optional) gets live progress for whoever's actually watching this
517
671
  // call: {type:'start',name,color} when a bot begins its turn, {type:'delta',
518
672
  // name,color,delta} per streamed token, {type:'final',name,color,text} once
@@ -575,10 +729,16 @@ async function runTurn(threadId, userText, onEvent, images) {
575
729
  t.status = 'thinking';
576
730
  let reply = '';
577
731
  onEvent?.({ type: 'start', name: t.name, color: t.color });
732
+ // Transient: the nudge is appended for THIS call only and never pushed into
733
+ // t.messages, so it can't accumulate across a chained auto run or get bound
734
+ // into the thread's context.
735
+ const callMsgs = t.runMode === 'auto'
736
+ ? [...t.messages, { role: 'system', content: AUTO_DIRECTIVE }]
737
+ : t.messages;
578
738
  try {
579
739
  reply = onEvent
580
- ? (await brainStream(t.messages, (delta) => onEvent({ type: 'delta', name: t.name, color: t.color, delta }), t.contextId)).trim()
581
- : (await brain(t.messages, t.contextId)).trim();
740
+ ? (await brainStream(callMsgs, (delta) => onEvent({ type: 'delta', name: t.name, color: t.color, delta }), t.contextId)).trim()
741
+ : (await brain(callMsgs, t.contextId)).trim();
582
742
  } catch (e) {
583
743
  reply = `error: ${e.message}`;
584
744
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.3",
3
+ "version": "0.48.5",
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",