openzoo 0.48.5 → 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 +174 -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';
@@ -122,6 +122,24 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
122
122
  own client. Use this directive; it
123
123
  does the initialize handshake, holds
124
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.
125
143
  RUN: <shell command> run a REAL shell command in this
126
144
  thread's directory — by default this
127
145
  pauses and waits for the user to
@@ -473,7 +491,59 @@ if (!loadThreads()) newThread('openzoo', null);
473
491
  // Parses a SPAWN/SEND/PING directive out of a reply, performs its side effect
474
492
  // (creating or messaging another thread), and returns the ack text to show in
475
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
+
476
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
+
477
547
  const spawn = /^SPAWN:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
478
548
  if (spawn) {
479
549
  const name = spawn[1].trim();
@@ -527,6 +597,109 @@ async function tryDirective(reply, originId) {
527
597
  return `${rel}:\n${data.slice(0, 4000)}${data.length > 4000 ? '\n…(truncated)' : ''}`;
528
598
  } catch (e) { return `Couldn't read ${rel}: ${e.message}`; }
529
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
+
530
703
  const serve = /^SERVE:\s*(.*)$/.exec(reply);
531
704
  if (serve) {
532
705
  const rel = serve[1].trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.5",
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",