openzoo 0.48.6 → 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
@@ -11,7 +11,7 @@ import { randomUUID } from 'node:crypto';
11
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
@@ -131,6 +131,13 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
131
131
  reproduce from memory is destroyed.
132
132
  The old text must match byte for byte
133
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
134
141
  TODO: <one item per line> set a visible checklist
135
142
  TODO: done <n> tick an item (TODO: alone = show it)
136
143
 
@@ -491,6 +498,215 @@ if (!loadThreads()) newThread('openzoo', null);
491
498
  // Parses a SPAWN/SEND/PING directive out of a reply, performs its side effect
492
499
  // (creating or messaging another thread), and returns the ack text to show in
493
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
+
494
710
  // Directives that only READ. These are safe to run at the same time, so a
495
711
  // reply carrying several of them costs one round trip instead of N — a model
496
712
  // that wants four files currently spends four full turns (and four payments)
@@ -616,6 +832,52 @@ async function tryDirective(reply, originId) {
616
832
  } catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
617
833
  }
618
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
+
619
881
  const ls = /^LS:\s*(.*)$/.exec(reply);
620
882
  if (ls) {
621
883
  const rel = ls[1].trim() || '.';
@@ -905,13 +1167,23 @@ async function runTurn(threadId, userText, onEvent, images) {
905
1167
  // Transient: the nudge is appended for THIS call only and never pushed into
906
1168
  // t.messages, so it can't accumulate across a chained auto run or get bound
907
1169
  // into the thread's context.
908
- const callMsgs = t.runMode === 'auto'
909
- ? [...t.messages, { role: 'system', content: AUTO_DIRECTIVE }]
910
- : 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;
911
1183
  try {
912
1184
  reply = onEvent
913
- ? (await brainStream(callMsgs, (delta) => onEvent({ type: 'delta', name: t.name, color: t.color, delta }), t.contextId)).trim()
914
- : (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();
915
1187
  } catch (e) {
916
1188
  reply = `error: ${e.message}`;
917
1189
  }
@@ -1030,6 +1302,19 @@ const APP_HTML = `<!doctype html>
1030
1302
  .modebtn.ask.on { background: #b8f240; }
1031
1303
  .modebtn.auto.on { background: #f28c4d; }
1032
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; }
1033
1318
  #hudBtn { margin-left: 10px; }
1034
1319
  #chatHeaderId { display: flex; align-items: center; gap: 10px; }
1035
1320
  #hud { position: fixed; top: 40px; right: 14px; width: 250px; background: rgba(14,14,17,.94);
@@ -1120,8 +1405,11 @@ const APP_HTML = `<!doctype html>
1120
1405
  .pop-item:hover { background: #2c2c2e; }
1121
1406
  .pop-item svg { width: 18px; height: 18px; flex: 0 0 18px; }
1122
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). */
1123
1411
  #pill { flex: 1; display: flex; align-items: center; gap: 6px; background: #2c2c2e; border-radius: 26px;
1124
- padding: 8px 10px 8px 14px; }
1412
+ padding: 8px 10px 8px 14px; position: relative; }
1125
1413
  .icon-btn { width: 32px; height: 32px; border-radius: 50%; border: none; background: transparent;
1126
1414
  color: #ececec; display: flex; align-items: center; justify-content: center; cursor: pointer;
1127
1415
  flex: 0 0 32px; }
@@ -1229,7 +1517,8 @@ const APP_HTML = `<!doctype html>
1229
1517
  <button class="icon-btn" id="plusBtn">
1230
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>
1231
1519
  </button>
1232
- <input id="inp" placeholder="Message" autofocus>
1520
+ <div id="slashMenu" data-component="slash-autocomplete"></div>
1521
+ <input id="inp" placeholder="Message" autofocus autocomplete="off">
1233
1522
  <button class="icon-btn" tabindex="-1">
1234
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>
1235
1524
  </button>
@@ -1557,7 +1846,60 @@ const APP_HTML = `<!doctype html>
1557
1846
 
1558
1847
  inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0 || pendingImages.length > 0); });
1559
1848
  send.addEventListener('click', submit);
1560
- 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
+ });
1561
1903
 
1562
1904
  const plusBtn = document.getElementById('plusBtn');
1563
1905
  const plusMenu = document.getElementById('plusMenu');
@@ -1752,6 +2094,13 @@ const server = http.createServer((req, res) => {
1752
2094
  })();
1753
2095
  return;
1754
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
+ }
1755
2104
  if (req.method === 'GET' && req.url === '/threads') {
1756
2105
  res.writeHead(200, { 'content-type': 'application/json' });
1757
2106
  res.end(JSON.stringify([...threads.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt).map(threadSummary)));
@@ -1843,11 +2192,23 @@ const server = http.createServer((req, res) => {
1843
2192
  } catch { /* ignore */ }
1844
2193
  res.writeHead(200, { 'content-type': 'application/json' });
1845
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
+ }
1846
2208
  // "/dir <path>" is a LOCAL control command, not sent to the model at
1847
2209
  // all — free, instant, sets which folder this thread's WRITE/READ/SERVE
1848
2210
  // are scoped to. Respecify any time by sending it again.
1849
2211
  const dirCmd = /^\/dir\s+(.+)/.exec(task.trim());
1850
- const t = threads.get(threadId);
1851
2212
  if (dirCmd && t) {
1852
2213
  const full = path.resolve(expandHome(dirCmd[1].trim()));
1853
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.6",
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",