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 +371 -10
- package/lib/podagent.mjs +9 -4
- package/package.json +1 -1
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
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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.
|
|
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",
|