openzoo 0.48.5 → 0.48.7

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 CHANGED
@@ -8,10 +8,10 @@
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
- import { brain, brainStream, PROXY } from './podagent.mjs';
14
+ import { brain, brainStream, MODEL, PROXY } from './podagent.mjs';
15
15
 
16
16
  const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
17
17
  // BIND HOST. Default 127.0.0.1 so the desktop app never exposes a shell-capable
@@ -122,6 +122,31 @@ 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
+ MULTIEDIT: <path> | a ||| b ;; c ||| d several edits to ONE file, ALL-OR-
135
+ NOTHING — if any piece doesn't match,
136
+ the file is left untouched. Prefer this
137
+ over a run of EDITs, where the third
138
+ can fail after the first two landed.
139
+ NOTEBOOK: <path> | <cell index> | <new source> replace a Jupyter cell (0-indexed);
140
+ stale outputs are cleared
141
+ TODO: <one item per line> set a visible checklist
142
+ TODO: done <n> tick an item (TODO: alone = show it)
143
+
144
+ WORK IN PARALLEL. READ, LS, GLOB, GREP, FETCH, PEEK and MCP are read-only, and if you emit
145
+ several of them in ONE reply the harness runs them CONCURRENTLY and returns every result
146
+ together. Four files in one turn costs one round trip; four turns costs four, and you pay per
147
+ call. Ask for everything you know you need at once instead of discovering it one file at a
148
+ time. Mutating directives (RUN, WRITE, EDIT, SPAWN, SEND) stay sequential on purpose — racing
149
+ them against each other corrupts the tree.
125
150
  RUN: <shell command> run a REAL shell command in this
126
151
  thread's directory — by default this
127
152
  pauses and waits for the user to
@@ -473,7 +498,268 @@ if (!loadThreads()) newThread('openzoo', null);
473
498
  // Parses a SPAWN/SEND/PING directive out of a reply, performs its side effect
474
499
  // (creating or messaging another thread), and returns the ack text to show in
475
500
  // place of the raw directive line — or null if the reply wasn't a directive.
501
+ // ---------------------------------------------------------------------------
502
+ // Slash commands. Local, free, instant — none of these call a model, so
503
+ // checking your spend or clearing a thread costs nothing.
504
+ //
505
+ // Exposed over /slash-commands so the composer can autocomplete them; keeping
506
+ // one list means the menu can never drift from what actually works.
507
+ const SLASH_COMMANDS = [
508
+ { name: '/help', args: '', help: 'every command and directive' },
509
+ { name: '/tools', args: '', help: 'the directives bots can emit' },
510
+ { name: '/cost', args: '', help: 'what this session has actually cost' },
511
+ { name: '/tokens', args: '', help: 'tokens and calls this session' },
512
+ { name: '/model', args: '[id]', help: 'show or switch this thread’s model' },
513
+ { name: '/models', args: '[filter]', help: 'search the ~435 served models' },
514
+ { name: '/compact', args: '', help: 'summarise history to shrink context' },
515
+ { name: '/clear', args: '', help: 'wipe this thread’s history' },
516
+ { name: '/undo', args: '', help: 'drop the last exchange' },
517
+ { name: '/memory', args: '[text|clear]', help: 'facts injected into every turn' },
518
+ { name: '/sessions', args: '', help: 'list all threads' },
519
+ { name: '/cron', args: '<mins> | <message>', help: 'repeat a message on a timer' },
520
+ { name: '/crons', args: '', help: 'list timers (/cron del <id> removes one)' },
521
+ { name: '/dir', args: '<path>', help: 'set this thread’s working directory' },
522
+ { name: '/mode', args: 'auto|ask', help: 'run commands immediately, or ask first' },
523
+ ];
524
+
525
+ const usd = (n) => (n >= 0.01 || n === 0 ? '$' + n.toFixed(2) : '$' + n.toFixed(5));
526
+
527
+ async function sessionStats() {
528
+ try { return await (await fetch(`${PROXY}/session`)).json(); }
529
+ catch { return null; }
530
+ }
531
+
532
+ function todoBlock(t) {
533
+ return (t.todos || []).length
534
+ ? '\n\nTODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n')
535
+ : '';
536
+ }
537
+
538
+ async function handleSlash(task, t) {
539
+ const m = /^\/(\w+)\s*([\s\S]*)$/.exec(task);
540
+ if (!m) return null;
541
+ const cmd = m[1].toLowerCase();
542
+ const arg = m[2].trim();
543
+
544
+ if (cmd === 'help') {
545
+ return 'Commands:\n'
546
+ + SLASH_COMMANDS.map((c) => ` ${(c.name + ' ' + c.args).padEnd(26)} ${c.help}`).join('\n')
547
+ + '\n\nDirectives bots can emit:\n'
548
+ + ' RUN / WRITE / EDIT / READ / LS / GLOB / GREP / SERVE / FETCH / MCP / TODO / SPAWN / SEND / PING / PEEK\n'
549
+ + ' (/tools for the full signatures)';
550
+ }
551
+ if (cmd === 'tools') {
552
+ return 'Directives:\n'
553
+ + ' RUN: <cmd> real shell, in this thread’s dir\n'
554
+ + ' WRITE: <path> | <content> create/overwrite a file\n'
555
+ + ' EDIT: <path> | <old> ||| <new> change part of a file\n'
556
+ + ' MULTIEDIT: <path> | a|||b ;; c|||d several edits, all-or-nothing\n'
557
+ + ' NOTEBOOK: <path> | <cell> | <src> replace a Jupyter cell\n'
558
+ + ' READ: <path> read a file\n'
559
+ + ' LS: <path> list a directory\n'
560
+ + ' GLOB: <pattern> find files\n'
561
+ + ' GREP: <regex> | <path> search contents\n'
562
+ + ' SERVE: <path> real http:// URL for a file\n'
563
+ + ' FETCH: <url> read a page’s text\n'
564
+ + ' MCP: <url> [| tool | {json}] list or call MCP tools\n'
565
+ + ' TODO: <lines> visible checklist\n'
566
+ + ' SPAWN / SEND / PING / PEEK other bots\n\n'
567
+ + 'READ, LS, GLOB, GREP, FETCH, PEEK and MCP run CONCURRENTLY when several\n'
568
+ + 'appear in one reply — four files cost one round trip, not four.';
569
+ }
570
+ if (cmd === 'cost' || cmd === 'tokens') {
571
+ const s = await sessionStats();
572
+ if (!s) return 'The local openzoo proxy isn’t reachable, so there are no real numbers to show. (Not zero — unknown.)';
573
+ const spent = Number(s.spentUsd) || 0, cogs = Number(s.cogsUsd) || 0, direct = Number(s.directUsd) || 0;
574
+ const lines = [
575
+ ` paid ${usd(spent)}`,
576
+ ` our cost ${usd(cogs)}`,
577
+ ` without openzoo ${usd(direct)} (counterfactual, not a bill anyone got)`,
578
+ ` paid calls ${s.paidCalls || 0}`,
579
+ ];
580
+ if (spent > 0) {
581
+ const mult = direct / spent;
582
+ lines.push(` multiple ${mult >= 100 ? Math.round(mult) : mult.toFixed(2)}x`
583
+ + (mult < 1 ? ' — under 1x: small inputs cost MORE than sending them directly' : ''));
584
+ }
585
+ return 'This session:\n' + lines.join('\n');
586
+ }
587
+ if (cmd === 'model') {
588
+ if (!arg) return `This thread: ${t.model || MODEL}${t.model ? '' : ' (default)'}\nSwitch with /model <id> · /models to search.`;
589
+ if (/^(default|reset)$/i.test(arg)) { delete t.model; saveThreads(); return `Back to the default, ${MODEL}.`; }
590
+ t.model = arg;
591
+ saveThreads();
592
+ return `This thread now uses ${arg}.\nNote: while images are in play the vision model still wins, or the call would just fail.`;
593
+ }
594
+ if (cmd === 'models') {
595
+ try {
596
+ const list = await (await fetch(`${PROXY}/models`)).json();
597
+ let ids = (list.data || []).map((x) => x.id);
598
+ if (arg) ids = ids.filter((i) => i.toLowerCase().includes(arg.toLowerCase()));
599
+ if (!ids.length) return `No model ids match "${arg}".`;
600
+ return `${ids.length} model(s)${arg ? ` matching "${arg}"` : ''}:\n`
601
+ + ids.slice(0, 60).map((i) => ' ' + i).join('\n')
602
+ + (ids.length > 60 ? `\n …${ids.length - 60} more — narrow it with /models <text>` : '');
603
+ } catch (e) { return `Couldn’t reach the proxy: ${e.message}`; }
604
+ }
605
+ if (cmd === 'clear') {
606
+ const sys = t.messages?.[0]?.role === 'system' ? [t.messages[0]] : [];
607
+ t.messages = sys;
608
+ t.history = [];
609
+ delete t.contextId;
610
+ delete t.boundHistoryCount;
611
+ saveThreads();
612
+ return 'Cleared. (The system prompt stays; the bound context is dropped so the next turn re-binds.)';
613
+ }
614
+ if (cmd === 'undo') {
615
+ if (!t.history.length) return 'Nothing to undo.';
616
+ // Drop back through the bot turns to the user message that caused them.
617
+ while (t.history.length && t.history[t.history.length - 1].who !== 'user') t.history.pop();
618
+ t.history.pop();
619
+ if (t.messages) {
620
+ while (t.messages.length > 1 && t.messages[t.messages.length - 1].role !== 'user') t.messages.pop();
621
+ if (t.messages.length > 1) t.messages.pop();
622
+ }
623
+ saveThreads();
624
+ return 'Undone.';
625
+ }
626
+ if (cmd === 'compact') {
627
+ if (!t.messages || t.messages.length < 4) return 'Not enough history to be worth compacting.';
628
+ const before = t.messages.length;
629
+ const transcript = t.messages.slice(1)
630
+ .map((x) => `${x.role}: ${typeof x.content === 'string' ? x.content : '[parts]'}`).join('\n')
631
+ .slice(-60000);
632
+ const sum = await brain([
633
+ { role: 'system', content: 'Summarise this conversation so it can be CONTINUED from the summary alone. Keep decisions, file paths, commands that worked, open problems and anything the user asked for and has not received. Drop pleasantries. No preamble.' },
634
+ { role: 'user', content: transcript },
635
+ ], undefined, t.model).catch((e) => `(compact failed: ${e.message})`);
636
+ if (/^\(compact failed/.test(sum)) return sum;
637
+ t.messages = [t.messages[0], { role: 'user', content: `[summary of the conversation so far]\n${sum}` }];
638
+ delete t.contextId;
639
+ delete t.boundHistoryCount;
640
+ saveThreads();
641
+ return `Compacted ${before} messages into a summary.\n\n${sum.slice(0, 1200)}${sum.length > 1200 ? '\n…' : ''}`;
642
+ }
643
+ if (cmd === 'memory') {
644
+ t.memory = t.memory || [];
645
+ if (!arg) return t.memory.length ? 'Memory:\n' + t.memory.map((x, i) => ` ${i + 1}. ${x}`).join('\n') : 'Memory is empty. Add with /memory <fact>';
646
+ if (/^clear$/i.test(arg)) { t.memory = []; saveThreads(); return 'Memory cleared.'; }
647
+ const del = /^(?:del|rm)\s+(\d+)$/i.exec(arg);
648
+ if (del) {
649
+ const i = Number(del[1]) - 1;
650
+ if (!t.memory[i]) return `No memory item ${del[1]}.`;
651
+ t.memory.splice(i, 1); saveThreads();
652
+ return 'Memory:\n' + (t.memory.map((x, n) => ` ${n + 1}. ${x}`).join('\n') || ' (empty)');
653
+ }
654
+ t.memory.push(arg);
655
+ saveThreads();
656
+ return `Remembered. This is injected into every turn of this thread.\n ${t.memory.length}. ${arg}`;
657
+ }
658
+ if (cmd === 'sessions') {
659
+ const all = [...threads.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt);
660
+ if (!all.length) return 'No threads.';
661
+ return `${all.length} thread(s):\n` + all.slice(0, 40).map((x) => {
662
+ const age = Math.round((Date.now() - x.lastActivityAt) / 60000);
663
+ return ` ${x.name}${x.id === t.id ? ' ← here' : ''} · ${x.status} · ${x.history.length} msgs · ${age}m ago`;
664
+ }).join('\n');
665
+ }
666
+ if (cmd === 'cron' || cmd === 'crons') {
667
+ t.crons = t.crons || [];
668
+ const show = () => (t.crons.length
669
+ ? 'Timers:\n' + t.crons.map((c) => ` ${c.id} every ${c.everyMin}m → ${c.text.slice(0, 60)}`).join('\n')
670
+ : 'No timers. Add one with /cron <minutes> | <message>');
671
+ if (cmd === 'crons' || !arg || /^(list|show)$/i.test(arg)) return show();
672
+ const del = /^(?:del|rm)\s+(\S+)$/i.exec(arg);
673
+ if (del) {
674
+ const before = t.crons.length;
675
+ t.crons = t.crons.filter((c) => c.id !== del[1]);
676
+ saveThreads();
677
+ return t.crons.length === before ? `No timer ${del[1]}.` : `Removed ${del[1]}.\n${show()}`;
678
+ }
679
+ const mk = /^(\d+)\s*m?\s*\|\s*([\s\S]+)$/.exec(arg);
680
+ if (!mk) return 'Usage: /cron <minutes> | <message> · /cron del <id>';
681
+ const everyMin = Math.max(1, Number(mk[1]));
682
+ const c = { id: randomUUID().slice(0, 6), everyMin, text: mk[2].trim(), nextAt: Date.now() + everyMin * 60000 };
683
+ t.crons.push(c);
684
+ saveThreads();
685
+ return `Timer ${c.id} set: every ${everyMin}m I'll send this thread “${c.text.slice(0, 60)}”.\nThis is REAL — it fires whether or not anyone is watching, and each firing costs a model call.`;
686
+ }
687
+ return null; // not one of ours — /dir and /mode fall through to their handlers
688
+ }
689
+
690
+ // One ticker for every thread's timers, rather than a timer per cron: it
691
+ // survives a restart with no re-arming step, because due-ness is derived from
692
+ // persisted nextAt rather than from a live setInterval.
693
+ //
694
+ // This exists because a bot once told the user it had scheduled "a recurring
695
+ // nudge every 2 minutes" — a capability that did not exist anywhere in the
696
+ // product. Now it does, and the claim can be true.
697
+ setInterval(() => {
698
+ const now = Date.now();
699
+ for (const t of threads.values()) {
700
+ if (!t.crons?.length) continue;
701
+ for (const c of t.crons) {
702
+ if (c.nextAt > now) continue;
703
+ c.nextAt = now + c.everyMin * 60000;
704
+ saveThreads();
705
+ runTurn(t.id, c.text).catch(() => {});
706
+ }
707
+ }
708
+ }, 15000).unref();
709
+
710
+ // Directives that only READ. These are safe to run at the same time, so a
711
+ // reply carrying several of them costs one round trip instead of N — a model
712
+ // that wants four files currently spends four full turns (and four payments)
713
+ // fetching them one at a time.
714
+ //
715
+ // Deliberately excludes RUN / WRITE / EDIT / SPAWN / SEND: those mutate, and
716
+ // concurrent mutation of the same tree is a race the model cannot reason
717
+ // about. Reads fan out, writes stay sequential.
718
+ const PARALLEL_DIRECTIVE = /^[ \t>*-]*(READ|LS|GLOB|GREP|FETCH|PEEK|MCP):[ \t]*(.+)$/gm;
719
+
720
+ // Walk a thread dir once, cheaply, skipping the things nobody means to search.
721
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__pycache__', '.venv', 'venv']);
722
+ function walkDir(base, rel = '', out = [], depth = 0) {
723
+ if (depth > 12 || out.length > 5000) return out;
724
+ let entries = [];
725
+ try { entries = readdirSync(path.join(base, rel), { withFileTypes: true }); } catch { return out; }
726
+ for (const e of entries) {
727
+ const r = rel ? path.join(rel, e.name) : e.name;
728
+ if (e.isDirectory()) {
729
+ if (SKIP_DIRS.has(e.name)) continue;
730
+ walkDir(base, r, out, depth + 1);
731
+ } else out.push(r);
732
+ }
733
+ return out;
734
+ }
735
+
736
+ // Glob -> RegExp. `**` crosses separators, `*` and `?` do not.
737
+ function globToRe(glob) {
738
+ let re = '';
739
+ for (let i = 0; i < glob.length; i++) {
740
+ const c = glob[i];
741
+ if (c === '*') {
742
+ if (glob[i + 1] === '*') { re += '.*'; i++; if (glob[i + 1] === '/') i++; }
743
+ else re += '[^/]*';
744
+ } else if (c === '?') re += '[^/]';
745
+ else if ('.+^${}()|[]\\'.includes(c)) re += '\\' + c;
746
+ else re += c;
747
+ }
748
+ return new RegExp('^' + re + '$');
749
+ }
750
+
476
751
  async function tryDirective(reply, originId) {
752
+ // FAN OUT FIRST. Each line is re-entered on its own, so every branch below
753
+ // stays single-directive and none of them had to learn about batching.
754
+ const batch = [...reply.matchAll(PARALLEL_DIRECTIVE)];
755
+ if (batch.length > 1) {
756
+ const results = await Promise.all(
757
+ batch.map((m) => tryDirective(m[0].replace(/^[ \t>*-]*/, ''), originId)
758
+ .catch((e) => `${m[1]}: ${e.message}`)),
759
+ );
760
+ return results.filter(Boolean).join('\n\n');
761
+ }
762
+
477
763
  const spawn = /^SPAWN:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
478
764
  if (spawn) {
479
765
  const name = spawn[1].trim();
@@ -527,6 +813,155 @@ async function tryDirective(reply, originId) {
527
813
  return `${rel}:\n${data.slice(0, 4000)}${data.length > 4000 ? '\n…(truncated)' : ''}`;
528
814
  } catch (e) { return `Couldn't read ${rel}: ${e.message}`; }
529
815
  }
816
+ // EDIT beats WRITE for changing part of a file: WRITE overwrites the whole
817
+ // thing, so a model that wants a one-line change has to reproduce the entire
818
+ // file from memory and silently drops whatever it forgot.
819
+ const edit = /^EDIT:\s*([^|]+)\|([\s\S]*?)\|\|\|([\s\S]*)$/.exec(reply);
820
+ if (edit) {
821
+ const rel = edit[1].trim();
822
+ const oldStr = edit[2].replace(/^\n/, '').replace(/\n$/, '');
823
+ const newStr = edit[3].replace(/^\n/, '').replace(/\n$/, '');
824
+ try {
825
+ const full = safeResolveIn(dirFor(originId), rel);
826
+ const before = readFileSync(full, 'utf8');
827
+ const hits = before.split(oldStr).length - 1;
828
+ if (hits === 0) return `EDIT ${rel}: that exact text isn't in the file — READ it first, the copy must match byte for byte.`;
829
+ if (hits > 1) return `EDIT ${rel}: that text appears ${hits} times — include more surrounding context so it matches exactly once.`;
830
+ writeFileSync(full, before.replace(oldStr, newStr));
831
+ return `Edited ${rel} (${before.length} -> ${before.replace(oldStr, newStr).length} bytes).`;
832
+ } catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
833
+ }
834
+
835
+ // Several edits to ONE file, applied all-or-nothing. Sequential EDITs are a
836
+ // trap: the third can fail after the first two already landed, leaving the
837
+ // file in a state neither the model nor the user expected.
838
+ const multi = /^MULTIEDIT:\s*([^|]+)\|([\s\S]+)$/.exec(reply);
839
+ if (multi) {
840
+ const rel = multi[1].trim();
841
+ const pairs = multi[2].split(';;').map((p) => p.split('|||')).filter((p) => p.length === 2);
842
+ if (!pairs.length) return 'MULTIEDIT: expected <path> | old ||| new ;; old2 ||| new2';
843
+ try {
844
+ const full = safeResolveIn(dirFor(originId), rel);
845
+ const before = readFileSync(full, 'utf8');
846
+ let next = before;
847
+ const applied = [];
848
+ for (const [oldRaw, newRaw] of pairs) {
849
+ const o = oldRaw.trim(), n = newRaw.trim();
850
+ const hits = next.split(o).length - 1;
851
+ if (hits === 0) return `MULTIEDIT ${rel}: edit ${applied.length + 1} — text not found, NOTHING was written:\n ${o.slice(0, 120)}`;
852
+ if (hits > 1) return `MULTIEDIT ${rel}: edit ${applied.length + 1} matches ${hits} times, NOTHING was written — add context.`;
853
+ next = next.replace(o, n);
854
+ applied.push(o.slice(0, 40));
855
+ }
856
+ writeFileSync(full, next);
857
+ return `MULTIEDIT ${rel}: ${applied.length} edit(s) applied (${before.length} -> ${next.length} bytes).`;
858
+ } catch (e) { return `Couldn't multiedit ${rel}: ${e.message}`; }
859
+ }
860
+
861
+ // Jupyter: replace one cell's source by index, keeping the notebook valid.
862
+ const nb = /^NOTEBOOK:\s*([^|]+)\|\s*(\d+)\s*\|([\s\S]+)$/.exec(reply);
863
+ if (nb) {
864
+ const rel = nb[1].trim();
865
+ const idx = Number(nb[2]);
866
+ const src = nb[3].replace(/^\n/, '');
867
+ try {
868
+ const full = safeResolveIn(dirFor(originId), rel);
869
+ const doc = JSON.parse(readFileSync(full, 'utf8'));
870
+ if (!Array.isArray(doc.cells)) return `NOTEBOOK ${rel}: no cells array — is this really a .ipynb?`;
871
+ if (!doc.cells[idx]) return `NOTEBOOK ${rel}: no cell ${idx} (it has ${doc.cells.length}, 0-indexed).`;
872
+ // nbformat stores source as a LIST OF LINES WITH the newlines kept.
873
+ doc.cells[idx].source = src.split('\n').map((l, i, a) => (i === a.length - 1 ? l : l + '\n'));
874
+ // Stale outputs next to new code are worse than none.
875
+ if (doc.cells[idx].cell_type === 'code') { doc.cells[idx].outputs = []; doc.cells[idx].execution_count = null; }
876
+ writeFileSync(full, JSON.stringify(doc, null, 1));
877
+ return `NOTEBOOK ${rel}: replaced cell ${idx} (${doc.cells[idx].cell_type}); outputs cleared.`;
878
+ } catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
879
+ }
880
+
881
+ const ls = /^LS:\s*(.*)$/.exec(reply);
882
+ if (ls) {
883
+ const rel = ls[1].trim() || '.';
884
+ try {
885
+ const full = safeResolveIn(dirFor(originId), rel);
886
+ const entries = readdirSync(full, { withFileTypes: true });
887
+ if (!entries.length) return `${rel}: (empty)`;
888
+ const lines = entries.slice(0, 300).map((e) => {
889
+ if (e.isDirectory()) return ` ${e.name}/`;
890
+ let size = '';
891
+ try { size = ` (${statSync(path.join(full, e.name)).size}b)`; } catch { /* raced */ }
892
+ return ` ${e.name}${size}`;
893
+ });
894
+ return `${rel}:\n${lines.join('\n')}${entries.length > 300 ? `\n …${entries.length - 300} more` : ''}`;
895
+ } catch (e) { return `Couldn't list ${rel}: ${e.message}`; }
896
+ }
897
+
898
+ const glob = /^GLOB:\s*(.+)$/.exec(reply);
899
+ if (glob) {
900
+ const pattern = glob[1].trim();
901
+ try {
902
+ const base = dirFor(originId);
903
+ const re = globToRe(pattern.startsWith('./') ? pattern.slice(2) : pattern);
904
+ const hits = walkDir(base).filter((f) => re.test(f) || re.test(path.basename(f)));
905
+ if (!hits.length) return `GLOB ${pattern}: no matches`;
906
+ return `GLOB ${pattern} — ${hits.length} match(es):\n${hits.slice(0, 200).map((h) => ' ' + h).join('\n')}`
907
+ + (hits.length > 200 ? `\n …${hits.length - 200} more` : '');
908
+ } catch (e) { return `GLOB ${pattern}: ${e.message}`; }
909
+ }
910
+
911
+ const grep = /^GREP:\s*([^|]+?)(?:\s*\|\s*(.+))?$/.exec(reply);
912
+ if (grep) {
913
+ const pattern = grep[1].trim();
914
+ const scope = (grep[2] || '').trim();
915
+ try {
916
+ const base = dirFor(originId);
917
+ let re;
918
+ try { re = new RegExp(pattern, 'i'); }
919
+ catch { return `GREP: ${pattern} isn't a valid regex.`; }
920
+ let files = walkDir(base);
921
+ if (scope) { const sre = globToRe(scope); files = files.filter((f) => sre.test(f) || f.startsWith(scope)); }
922
+ const out = [];
923
+ for (const f of files) {
924
+ if (out.length > 200) break;
925
+ let text;
926
+ try { text = readFileSync(path.join(base, f), 'utf8'); } catch { continue; }
927
+ if (text.indexOf(String.fromCharCode(0)) !== -1) continue; // binary — NUL, not whitespace
928
+ text.split('\n').forEach((line, i) => {
929
+ if (out.length <= 200 && re.test(line)) out.push(` ${f}:${i + 1}: ${line.trim().slice(0, 200)}`);
930
+ });
931
+ }
932
+ if (!out.length) return `GREP ${pattern}: no matches`;
933
+ return `GREP ${pattern} — ${out.length} hit(s):\n${out.join('\n')}`;
934
+ } catch (e) { return `GREP: ${e.message}`; }
935
+ }
936
+
937
+ // A real, persisted checklist. Bots were already narrating plans; this makes
938
+ // the plan a thing the user can see and the model can be held to.
939
+ const todo = /^TODO:\s*([\s\S]*)$/.exec(reply);
940
+ if (todo) {
941
+ const t = threads.get(originId);
942
+ if (!t) return 'TODO: no such thread.';
943
+ t.todos = t.todos || [];
944
+ const body = todo[1].trim();
945
+ if (!body || /^(list|show)$/i.test(body)) {
946
+ if (!t.todos.length) return 'TODO: (empty)';
947
+ return 'TODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n');
948
+ }
949
+ const done = /^done\s+(\d+)/i.exec(body);
950
+ if (done) {
951
+ const idx = Number(done[1]) - 1;
952
+ if (!t.todos[idx]) return `TODO: no item ${done[1]}.`;
953
+ t.todos[idx].done = true;
954
+ saveThreads();
955
+ return 'TODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n');
956
+ }
957
+ if (/^clear$/i.test(body)) { t.todos = []; saveThreads(); return 'TODO: cleared.'; }
958
+ // Otherwise: replace the list with the lines given.
959
+ t.todos = body.split('\n').map((l) => l.replace(/^[-*\d.)\]\s]+/, '').trim())
960
+ .filter(Boolean).map((text) => ({ text, done: false }));
961
+ saveThreads();
962
+ return 'TODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [ ] ${x.text}`).join('\n');
963
+ }
964
+
530
965
  const serve = /^SERVE:\s*(.*)$/.exec(reply);
531
966
  if (serve) {
532
967
  const rel = serve[1].trim();
@@ -732,13 +1167,23 @@ async function runTurn(threadId, userText, onEvent, images) {
732
1167
  // Transient: the nudge is appended for THIS call only and never pushed into
733
1168
  // t.messages, so it can't accumulate across a chained auto run or get bound
734
1169
  // into the thread's context.
735
- const callMsgs = t.runMode === 'auto'
736
- ? [...t.messages, { role: 'system', content: AUTO_DIRECTIVE }]
737
- : t.messages;
1170
+ const extras = [];
1171
+ // /memory is worthless unless it reaches the model. Injected per-turn for
1172
+ // the same reason AUTO_DIRECTIVE is: the system prompt is frozen into the
1173
+ // thread at creation, so anything added there would never reach a thread
1174
+ // that already exists.
1175
+ if (t.memory?.length) {
1176
+ extras.push({ role: 'system', content: `Remember, for this thread:\n${t.memory.map((x) => `- ${x}`).join('\n')}` });
1177
+ }
1178
+ if (t.todos?.length) {
1179
+ extras.push({ role: 'system', content: `Current checklist (TODO: done <n> to tick one off):\n${t.todos.map((x, i) => `${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n')}` });
1180
+ }
1181
+ if (t.runMode === 'auto') extras.push({ role: 'system', content: AUTO_DIRECTIVE });
1182
+ const callMsgs = extras.length ? [...t.messages, ...extras] : t.messages;
738
1183
  try {
739
1184
  reply = onEvent
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();
1185
+ ? (await brainStream(callMsgs, (delta) => onEvent({ type: 'delta', name: t.name, color: t.color, delta }), t.contextId, t.model)).trim()
1186
+ : (await brain(callMsgs, t.contextId, t.model)).trim();
742
1187
  } catch (e) {
743
1188
  reply = `error: ${e.message}`;
744
1189
  }
@@ -857,6 +1302,19 @@ const APP_HTML = `<!doctype html>
857
1302
  .modebtn.ask.on { background: #b8f240; }
858
1303
  .modebtn.auto.on { background: #f28c4d; }
859
1304
  .modebtn:focus-visible { outline: 2px solid #6ab0ff; outline-offset: 2px; }
1305
+ /* Slash autocomplete. Anchored above the composer because the composer sits
1306
+ at the bottom of the viewport — a dropdown BELOW it would render off
1307
+ screen. */
1308
+ #slashMenu { display: none; position: absolute; bottom: calc(100% + 8px); left: 0; right: 0;
1309
+ max-height: 280px; overflow-y: auto; background: rgba(18,18,22,.98); border: 1px solid #2a2a33;
1310
+ border-radius: 12px; padding: 6px; z-index: 40; box-shadow: 0 10px 34px rgba(0,0,0,.55); }
1311
+ #slashMenu.show { display: block; }
1312
+ .scmd { display: flex; align-items: baseline; gap: 10px; padding: 7px 10px; border-radius: 8px;
1313
+ cursor: pointer; font-size: 12.5px; }
1314
+ .scmd:hover, .scmd.sel { background: #24242c; }
1315
+ .scmd b { color: #b8f240; font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
1316
+ .scmd i { color: #6f7080; font-style: normal; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
1317
+ .scmd span { color: #999aa8; margin-left: auto; text-align: right; }
860
1318
  #hudBtn { margin-left: 10px; }
861
1319
  #chatHeaderId { display: flex; align-items: center; gap: 10px; }
862
1320
  #hud { position: fixed; top: 40px; right: 14px; width: 250px; background: rgba(14,14,17,.94);
@@ -947,8 +1405,11 @@ const APP_HTML = `<!doctype html>
947
1405
  .pop-item:hover { background: #2c2c2e; }
948
1406
  .pop-item svg { width: 18px; height: 18px; flex: 0 0 18px; }
949
1407
  .pop-item.record svg { color: #ff3b30; }
1408
+ /* position: relative anchors #slashMenu, which is absolutely positioned
1409
+ ABOVE the composer (the composer is pinned to the bottom, so a dropdown
1410
+ below it would render off screen). */
950
1411
  #pill { flex: 1; display: flex; align-items: center; gap: 6px; background: #2c2c2e; border-radius: 26px;
951
- padding: 8px 10px 8px 14px; }
1412
+ padding: 8px 10px 8px 14px; position: relative; }
952
1413
  .icon-btn { width: 32px; height: 32px; border-radius: 50%; border: none; background: transparent;
953
1414
  color: #ececec; display: flex; align-items: center; justify-content: center; cursor: pointer;
954
1415
  flex: 0 0 32px; }
@@ -1056,7 +1517,8 @@ const APP_HTML = `<!doctype html>
1056
1517
  <button class="icon-btn" id="plusBtn">
1057
1518
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
1058
1519
  </button>
1059
- <input id="inp" placeholder="Message" autofocus>
1520
+ <div id="slashMenu" data-component="slash-autocomplete"></div>
1521
+ <input id="inp" placeholder="Message" autofocus autocomplete="off">
1060
1522
  <button class="icon-btn" tabindex="-1">
1061
1523
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10v1a7 7 0 0 1-14 0v-1"/><line x1="12" y1="18" x2="12" y2="22"/></svg>
1062
1524
  </button>
@@ -1384,7 +1846,60 @@ const APP_HTML = `<!doctype html>
1384
1846
 
1385
1847
  inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0 || pendingImages.length > 0); });
1386
1848
  send.addEventListener('click', submit);
1387
- inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
1849
+ // --- slash autocomplete --------------------------------------------------
1850
+ // The list comes from the SERVER (/slash-commands), so the menu can never
1851
+ // offer something the server doesn't actually handle.
1852
+ const slashMenu = document.getElementById('slashMenu');
1853
+ let slashCmds = [];
1854
+ let slashHits = [];
1855
+ let slashSel = 0;
1856
+ fetch('/slash-commands').then((r) => r.json()).then((c) => { slashCmds = c; }).catch(() => {});
1857
+
1858
+ function slashOpen() { return slashMenu.classList.contains('show'); }
1859
+ function renderSlash() {
1860
+ const v = inp.value;
1861
+ // Only while typing the command itself: once there's a space, the user is
1862
+ // writing arguments and a menu in the way is just noise.
1863
+ const m = /^\\/(\\w*)$/.exec(v);
1864
+ if (!m || !slashCmds.length) { slashMenu.classList.remove('show'); return; }
1865
+ const q = m[1].toLowerCase();
1866
+ slashHits = slashCmds.filter((c) => c.name.slice(1).toLowerCase().startsWith(q));
1867
+ if (!slashHits.length) { slashMenu.classList.remove('show'); return; }
1868
+ if (slashSel >= slashHits.length) slashSel = 0;
1869
+ slashMenu.innerHTML = slashHits.map((c, i) =>
1870
+ '<div class="scmd' + (i === slashSel ? ' sel' : '') + '" data-i="' + i + '">'
1871
+ + '<b>' + escapeHtml(c.name) + '</b><i>' + escapeHtml(c.args) + '</i>'
1872
+ + '<span>' + escapeHtml(c.help) + '</span></div>').join('');
1873
+ slashMenu.classList.add('show');
1874
+ }
1875
+ function slashAccept(i) {
1876
+ const c = slashHits[i];
1877
+ if (!c) return;
1878
+ // Commands that take arguments keep the caret going; ones that don't are
1879
+ // ready to send, so don't make the user delete a trailing space.
1880
+ inp.value = c.name + (c.args ? ' ' : '');
1881
+ slashMenu.classList.remove('show');
1882
+ inp.focus();
1883
+ send.classList.add('show');
1884
+ }
1885
+ slashMenu.addEventListener('mousedown', (e) => {
1886
+ const el = e.target.closest('.scmd');
1887
+ if (!el) return;
1888
+ e.preventDefault(); // keep focus in the input
1889
+ slashAccept(Number(el.dataset.i));
1890
+ });
1891
+ inp.addEventListener('input', renderSlash);
1892
+ inp.addEventListener('blur', () => setTimeout(() => slashMenu.classList.remove('show'), 120));
1893
+
1894
+ inp.addEventListener('keydown', (e) => {
1895
+ if (slashOpen()) {
1896
+ if (e.key === 'ArrowDown') { e.preventDefault(); slashSel = (slashSel + 1) % slashHits.length; renderSlash(); return; }
1897
+ if (e.key === 'ArrowUp') { e.preventDefault(); slashSel = (slashSel - 1 + slashHits.length) % slashHits.length; renderSlash(); return; }
1898
+ if (e.key === 'Tab' || e.key === 'Enter') { e.preventDefault(); slashAccept(slashSel); return; }
1899
+ if (e.key === 'Escape') { slashMenu.classList.remove('show'); return; }
1900
+ }
1901
+ if (e.key === 'Enter') submit();
1902
+ });
1388
1903
 
1389
1904
  const plusBtn = document.getElementById('plusBtn');
1390
1905
  const plusMenu = document.getElementById('plusMenu');
@@ -1579,6 +2094,13 @@ const server = http.createServer((req, res) => {
1579
2094
  })();
1580
2095
  return;
1581
2096
  }
2097
+ // One source of truth for the composer's autocomplete — a hand-kept menu in
2098
+ // the client would drift from what the server actually handles.
2099
+ if (req.method === 'GET' && req.url === '/slash-commands') {
2100
+ res.writeHead(200, { 'content-type': 'application/json' });
2101
+ res.end(JSON.stringify(SLASH_COMMANDS));
2102
+ return;
2103
+ }
1582
2104
  if (req.method === 'GET' && req.url === '/threads') {
1583
2105
  res.writeHead(200, { 'content-type': 'application/json' });
1584
2106
  res.end(JSON.stringify([...threads.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt).map(threadSummary)));
@@ -1670,11 +2192,23 @@ const server = http.createServer((req, res) => {
1670
2192
  } catch { /* ignore */ }
1671
2193
  res.writeHead(200, { 'content-type': 'application/json' });
1672
2194
  res.end(JSON.stringify({ ok: true }));
2195
+ const t = threads.get(threadId);
2196
+ // Every OTHER slash command. Local, free, instant — no model call, so
2197
+ // checking your spend or clearing a thread never costs anything.
2198
+ // /dir and /mode keep their own handlers below, untouched.
2199
+ if (t && /^\//.test(task.trim())) {
2200
+ const handled = await handleSlash(task.trim(), t).catch((e) => `error: ${e.message}`);
2201
+ if (handled !== null && handled !== undefined) {
2202
+ t.history.push({ who: 'bot', text: handled });
2203
+ t.lastActivityAt = Date.now();
2204
+ saveThreads();
2205
+ return;
2206
+ }
2207
+ }
1673
2208
  // "/dir <path>" is a LOCAL control command, not sent to the model at
1674
2209
  // all — free, instant, sets which folder this thread's WRITE/READ/SERVE
1675
2210
  // are scoped to. Respecify any time by sending it again.
1676
2211
  const dirCmd = /^\/dir\s+(.+)/.exec(task.trim());
1677
- const t = threads.get(threadId);
1678
2212
  if (dirCmd && t) {
1679
2213
  const full = path.resolve(expandHome(dirCmd[1].trim()));
1680
2214
  let ok = false;
package/lib/podagent.mjs CHANGED
@@ -280,13 +280,18 @@ function withModelId(messages, model) {
280
280
  : m));
281
281
  }
282
282
 
283
- export async function brain(messages, contextId) {
283
+ export async function brain(messages, contextId, modelOverride) {
284
284
  // explicit plugins, not relying on the gateway's "inject when caller said
285
285
  // nothing" default — an explicit array is always respected as-is, so every
286
286
  // bot on every model actually has web search. max_tokens 900 was cutting
287
287
  // real (especially web-search-backed) answers off mid-sentence.
288
+ //
289
+ // modelOverride is what "/model <id>" sets per thread. VISION still wins
290
+ // when the conversation actually contains images: a text-only model handed
291
+ // image parts just errors, so silently honouring the override there would
292
+ // turn a working thread into a broken one.
288
293
  const vision = hasImages(messages);
289
- const model = vision ? VISION_MODEL : MODEL;
294
+ const model = vision ? VISION_MODEL : (modelOverride || MODEL);
290
295
  messages = vision ? messages : stripImages(messages);
291
296
  const r = await postChat(
292
297
  { model, max_tokens: 4096, messages: withModelId(messages, model), plugins: [{ id: 'web' }] },
@@ -300,9 +305,9 @@ export async function brain(messages, contextId) {
300
305
  /** Same call, but streamed — invokes onDelta(text) as tokens arrive (for a
301
306
  * live-typing UI) and resolves with the full accumulated text at the end, so
302
307
  * callers that need to parse a directive out of the complete reply still can. */
303
- export async function brainStream(messages, onDelta, contextId) {
308
+ export async function brainStream(messages, onDelta, contextId, modelOverride) {
304
309
  const vision = hasImages(messages);
305
- const model = vision ? VISION_MODEL : MODEL;
310
+ const model = vision ? VISION_MODEL : (modelOverride || MODEL);
306
311
  messages = vision ? messages : stripImages(messages);
307
312
  const r = await postChat(
308
313
  { model, max_tokens: 4096, messages: withModelId(messages, model), plugins: [{ id: 'web' }], stream: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.5",
3
+ "version": "0.48.7",
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",