openzoo 0.48.6 → 0.48.8
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 +451 -13
- 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,227 @@ 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
|
+
// threadId -> open SSE responses. A Set because the same thread can be open in
|
|
528
|
+
// two tabs, and both should see the same tokens.
|
|
529
|
+
const streamListeners = new Map();
|
|
530
|
+
function emitToThread(threadId, ev) {
|
|
531
|
+
const set = streamListeners.get(threadId);
|
|
532
|
+
if (!set?.size) return; // nobody watching — free
|
|
533
|
+
const line = `data: ${JSON.stringify(ev)}\n\n`;
|
|
534
|
+
for (const res of set) {
|
|
535
|
+
try { res.write(line); } catch { set.delete(res); }
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function sessionStats() {
|
|
540
|
+
try { return await (await fetch(`${PROXY}/session`)).json(); }
|
|
541
|
+
catch { return null; }
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function todoBlock(t) {
|
|
545
|
+
return (t.todos || []).length
|
|
546
|
+
? '\n\nTODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n')
|
|
547
|
+
: '';
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function handleSlash(task, t) {
|
|
551
|
+
const m = /^\/(\w+)\s*([\s\S]*)$/.exec(task);
|
|
552
|
+
if (!m) return null;
|
|
553
|
+
const cmd = m[1].toLowerCase();
|
|
554
|
+
const arg = m[2].trim();
|
|
555
|
+
|
|
556
|
+
if (cmd === 'help') {
|
|
557
|
+
return 'Commands:\n'
|
|
558
|
+
+ SLASH_COMMANDS.map((c) => ` ${(c.name + ' ' + c.args).padEnd(26)} ${c.help}`).join('\n')
|
|
559
|
+
+ '\n\nDirectives bots can emit:\n'
|
|
560
|
+
+ ' RUN / WRITE / EDIT / READ / LS / GLOB / GREP / SERVE / FETCH / MCP / TODO / SPAWN / SEND / PING / PEEK\n'
|
|
561
|
+
+ ' (/tools for the full signatures)';
|
|
562
|
+
}
|
|
563
|
+
if (cmd === 'tools') {
|
|
564
|
+
return 'Directives:\n'
|
|
565
|
+
+ ' RUN: <cmd> real shell, in this thread’s dir\n'
|
|
566
|
+
+ ' WRITE: <path> | <content> create/overwrite a file\n'
|
|
567
|
+
+ ' EDIT: <path> | <old> ||| <new> change part of a file\n'
|
|
568
|
+
+ ' MULTIEDIT: <path> | a|||b ;; c|||d several edits, all-or-nothing\n'
|
|
569
|
+
+ ' NOTEBOOK: <path> | <cell> | <src> replace a Jupyter cell\n'
|
|
570
|
+
+ ' READ: <path> read a file\n'
|
|
571
|
+
+ ' LS: <path> list a directory\n'
|
|
572
|
+
+ ' GLOB: <pattern> find files\n'
|
|
573
|
+
+ ' GREP: <regex> | <path> search contents\n'
|
|
574
|
+
+ ' SERVE: <path> real http:// URL for a file\n'
|
|
575
|
+
+ ' FETCH: <url> read a page’s text\n'
|
|
576
|
+
+ ' MCP: <url> [| tool | {json}] list or call MCP tools\n'
|
|
577
|
+
+ ' TODO: <lines> visible checklist\n'
|
|
578
|
+
+ ' SPAWN / SEND / PING / PEEK other bots\n\n'
|
|
579
|
+
+ 'READ, LS, GLOB, GREP, FETCH, PEEK and MCP run CONCURRENTLY when several\n'
|
|
580
|
+
+ 'appear in one reply — four files cost one round trip, not four.';
|
|
581
|
+
}
|
|
582
|
+
if (cmd === 'cost' || cmd === 'tokens') {
|
|
583
|
+
const s = await sessionStats();
|
|
584
|
+
if (!s) return 'The local openzoo proxy isn’t reachable, so there are no real numbers to show. (Not zero — unknown.)';
|
|
585
|
+
const spent = Number(s.spentUsd) || 0, cogs = Number(s.cogsUsd) || 0, direct = Number(s.directUsd) || 0;
|
|
586
|
+
const lines = [
|
|
587
|
+
` paid ${usd(spent)}`,
|
|
588
|
+
` our cost ${usd(cogs)}`,
|
|
589
|
+
` without openzoo ${usd(direct)} (counterfactual, not a bill anyone got)`,
|
|
590
|
+
` paid calls ${s.paidCalls || 0}`,
|
|
591
|
+
];
|
|
592
|
+
if (spent > 0) {
|
|
593
|
+
const mult = direct / spent;
|
|
594
|
+
lines.push(` multiple ${mult >= 100 ? Math.round(mult) : mult.toFixed(2)}x`
|
|
595
|
+
+ (mult < 1 ? ' — under 1x: small inputs cost MORE than sending them directly' : ''));
|
|
596
|
+
}
|
|
597
|
+
return 'This session:\n' + lines.join('\n');
|
|
598
|
+
}
|
|
599
|
+
if (cmd === 'model') {
|
|
600
|
+
if (!arg) return `This thread: ${t.model || MODEL}${t.model ? '' : ' (default)'}\nSwitch with /model <id> · /models to search.`;
|
|
601
|
+
if (/^(default|reset)$/i.test(arg)) { delete t.model; saveThreads(); return `Back to the default, ${MODEL}.`; }
|
|
602
|
+
t.model = arg;
|
|
603
|
+
saveThreads();
|
|
604
|
+
return `This thread now uses ${arg}.\nNote: while images are in play the vision model still wins, or the call would just fail.`;
|
|
605
|
+
}
|
|
606
|
+
if (cmd === 'models') {
|
|
607
|
+
try {
|
|
608
|
+
const list = await (await fetch(`${PROXY}/models`)).json();
|
|
609
|
+
let ids = (list.data || []).map((x) => x.id);
|
|
610
|
+
if (arg) ids = ids.filter((i) => i.toLowerCase().includes(arg.toLowerCase()));
|
|
611
|
+
if (!ids.length) return `No model ids match "${arg}".`;
|
|
612
|
+
return `${ids.length} model(s)${arg ? ` matching "${arg}"` : ''}:\n`
|
|
613
|
+
+ ids.slice(0, 60).map((i) => ' ' + i).join('\n')
|
|
614
|
+
+ (ids.length > 60 ? `\n …${ids.length - 60} more — narrow it with /models <text>` : '');
|
|
615
|
+
} catch (e) { return `Couldn’t reach the proxy: ${e.message}`; }
|
|
616
|
+
}
|
|
617
|
+
if (cmd === 'clear') {
|
|
618
|
+
const sys = t.messages?.[0]?.role === 'system' ? [t.messages[0]] : [];
|
|
619
|
+
t.messages = sys;
|
|
620
|
+
t.history = [];
|
|
621
|
+
delete t.contextId;
|
|
622
|
+
delete t.boundHistoryCount;
|
|
623
|
+
saveThreads();
|
|
624
|
+
return 'Cleared. (The system prompt stays; the bound context is dropped so the next turn re-binds.)';
|
|
625
|
+
}
|
|
626
|
+
if (cmd === 'undo') {
|
|
627
|
+
if (!t.history.length) return 'Nothing to undo.';
|
|
628
|
+
// Drop back through the bot turns to the user message that caused them.
|
|
629
|
+
while (t.history.length && t.history[t.history.length - 1].who !== 'user') t.history.pop();
|
|
630
|
+
t.history.pop();
|
|
631
|
+
if (t.messages) {
|
|
632
|
+
while (t.messages.length > 1 && t.messages[t.messages.length - 1].role !== 'user') t.messages.pop();
|
|
633
|
+
if (t.messages.length > 1) t.messages.pop();
|
|
634
|
+
}
|
|
635
|
+
saveThreads();
|
|
636
|
+
return 'Undone.';
|
|
637
|
+
}
|
|
638
|
+
if (cmd === 'compact') {
|
|
639
|
+
if (!t.messages || t.messages.length < 4) return 'Not enough history to be worth compacting.';
|
|
640
|
+
const before = t.messages.length;
|
|
641
|
+
const transcript = t.messages.slice(1)
|
|
642
|
+
.map((x) => `${x.role}: ${typeof x.content === 'string' ? x.content : '[parts]'}`).join('\n')
|
|
643
|
+
.slice(-60000);
|
|
644
|
+
const sum = await brain([
|
|
645
|
+
{ 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.' },
|
|
646
|
+
{ role: 'user', content: transcript },
|
|
647
|
+
], undefined, t.model).catch((e) => `(compact failed: ${e.message})`);
|
|
648
|
+
if (/^\(compact failed/.test(sum)) return sum;
|
|
649
|
+
t.messages = [t.messages[0], { role: 'user', content: `[summary of the conversation so far]\n${sum}` }];
|
|
650
|
+
delete t.contextId;
|
|
651
|
+
delete t.boundHistoryCount;
|
|
652
|
+
saveThreads();
|
|
653
|
+
return `Compacted ${before} messages into a summary.\n\n${sum.slice(0, 1200)}${sum.length > 1200 ? '\n…' : ''}`;
|
|
654
|
+
}
|
|
655
|
+
if (cmd === 'memory') {
|
|
656
|
+
t.memory = t.memory || [];
|
|
657
|
+
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>';
|
|
658
|
+
if (/^clear$/i.test(arg)) { t.memory = []; saveThreads(); return 'Memory cleared.'; }
|
|
659
|
+
const del = /^(?:del|rm)\s+(\d+)$/i.exec(arg);
|
|
660
|
+
if (del) {
|
|
661
|
+
const i = Number(del[1]) - 1;
|
|
662
|
+
if (!t.memory[i]) return `No memory item ${del[1]}.`;
|
|
663
|
+
t.memory.splice(i, 1); saveThreads();
|
|
664
|
+
return 'Memory:\n' + (t.memory.map((x, n) => ` ${n + 1}. ${x}`).join('\n') || ' (empty)');
|
|
665
|
+
}
|
|
666
|
+
t.memory.push(arg);
|
|
667
|
+
saveThreads();
|
|
668
|
+
return `Remembered. This is injected into every turn of this thread.\n ${t.memory.length}. ${arg}`;
|
|
669
|
+
}
|
|
670
|
+
if (cmd === 'sessions') {
|
|
671
|
+
const all = [...threads.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
672
|
+
if (!all.length) return 'No threads.';
|
|
673
|
+
return `${all.length} thread(s):\n` + all.slice(0, 40).map((x) => {
|
|
674
|
+
const age = Math.round((Date.now() - x.lastActivityAt) / 60000);
|
|
675
|
+
return ` ${x.name}${x.id === t.id ? ' ← here' : ''} · ${x.status} · ${x.history.length} msgs · ${age}m ago`;
|
|
676
|
+
}).join('\n');
|
|
677
|
+
}
|
|
678
|
+
if (cmd === 'cron' || cmd === 'crons') {
|
|
679
|
+
t.crons = t.crons || [];
|
|
680
|
+
const show = () => (t.crons.length
|
|
681
|
+
? 'Timers:\n' + t.crons.map((c) => ` ${c.id} every ${c.everyMin}m → ${c.text.slice(0, 60)}`).join('\n')
|
|
682
|
+
: 'No timers. Add one with /cron <minutes> | <message>');
|
|
683
|
+
if (cmd === 'crons' || !arg || /^(list|show)$/i.test(arg)) return show();
|
|
684
|
+
const del = /^(?:del|rm)\s+(\S+)$/i.exec(arg);
|
|
685
|
+
if (del) {
|
|
686
|
+
const before = t.crons.length;
|
|
687
|
+
t.crons = t.crons.filter((c) => c.id !== del[1]);
|
|
688
|
+
saveThreads();
|
|
689
|
+
return t.crons.length === before ? `No timer ${del[1]}.` : `Removed ${del[1]}.\n${show()}`;
|
|
690
|
+
}
|
|
691
|
+
const mk = /^(\d+)\s*m?\s*\|\s*([\s\S]+)$/.exec(arg);
|
|
692
|
+
if (!mk) return 'Usage: /cron <minutes> | <message> · /cron del <id>';
|
|
693
|
+
const everyMin = Math.max(1, Number(mk[1]));
|
|
694
|
+
const c = { id: randomUUID().slice(0, 6), everyMin, text: mk[2].trim(), nextAt: Date.now() + everyMin * 60000 };
|
|
695
|
+
t.crons.push(c);
|
|
696
|
+
saveThreads();
|
|
697
|
+
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.`;
|
|
698
|
+
}
|
|
699
|
+
return null; // not one of ours — /dir and /mode fall through to their handlers
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// One ticker for every thread's timers, rather than a timer per cron: it
|
|
703
|
+
// survives a restart with no re-arming step, because due-ness is derived from
|
|
704
|
+
// persisted nextAt rather than from a live setInterval.
|
|
705
|
+
//
|
|
706
|
+
// This exists because a bot once told the user it had scheduled "a recurring
|
|
707
|
+
// nudge every 2 minutes" — a capability that did not exist anywhere in the
|
|
708
|
+
// product. Now it does, and the claim can be true.
|
|
709
|
+
setInterval(() => {
|
|
710
|
+
const now = Date.now();
|
|
711
|
+
for (const t of threads.values()) {
|
|
712
|
+
if (!t.crons?.length) continue;
|
|
713
|
+
for (const c of t.crons) {
|
|
714
|
+
if (c.nextAt > now) continue;
|
|
715
|
+
c.nextAt = now + c.everyMin * 60000;
|
|
716
|
+
saveThreads();
|
|
717
|
+
runTurn(t.id, c.text).catch(() => {});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}, 15000).unref();
|
|
721
|
+
|
|
494
722
|
// Directives that only READ. These are safe to run at the same time, so a
|
|
495
723
|
// reply carrying several of them costs one round trip instead of N — a model
|
|
496
724
|
// that wants four files currently spends four full turns (and four payments)
|
|
@@ -616,6 +844,52 @@ async function tryDirective(reply, originId) {
|
|
|
616
844
|
} catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
|
|
617
845
|
}
|
|
618
846
|
|
|
847
|
+
// Several edits to ONE file, applied all-or-nothing. Sequential EDITs are a
|
|
848
|
+
// trap: the third can fail after the first two already landed, leaving the
|
|
849
|
+
// file in a state neither the model nor the user expected.
|
|
850
|
+
const multi = /^MULTIEDIT:\s*([^|]+)\|([\s\S]+)$/.exec(reply);
|
|
851
|
+
if (multi) {
|
|
852
|
+
const rel = multi[1].trim();
|
|
853
|
+
const pairs = multi[2].split(';;').map((p) => p.split('|||')).filter((p) => p.length === 2);
|
|
854
|
+
if (!pairs.length) return 'MULTIEDIT: expected <path> | old ||| new ;; old2 ||| new2';
|
|
855
|
+
try {
|
|
856
|
+
const full = safeResolveIn(dirFor(originId), rel);
|
|
857
|
+
const before = readFileSync(full, 'utf8');
|
|
858
|
+
let next = before;
|
|
859
|
+
const applied = [];
|
|
860
|
+
for (const [oldRaw, newRaw] of pairs) {
|
|
861
|
+
const o = oldRaw.trim(), n = newRaw.trim();
|
|
862
|
+
const hits = next.split(o).length - 1;
|
|
863
|
+
if (hits === 0) return `MULTIEDIT ${rel}: edit ${applied.length + 1} — text not found, NOTHING was written:\n ${o.slice(0, 120)}`;
|
|
864
|
+
if (hits > 1) return `MULTIEDIT ${rel}: edit ${applied.length + 1} matches ${hits} times, NOTHING was written — add context.`;
|
|
865
|
+
next = next.replace(o, n);
|
|
866
|
+
applied.push(o.slice(0, 40));
|
|
867
|
+
}
|
|
868
|
+
writeFileSync(full, next);
|
|
869
|
+
return `MULTIEDIT ${rel}: ${applied.length} edit(s) applied (${before.length} -> ${next.length} bytes).`;
|
|
870
|
+
} catch (e) { return `Couldn't multiedit ${rel}: ${e.message}`; }
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Jupyter: replace one cell's source by index, keeping the notebook valid.
|
|
874
|
+
const nb = /^NOTEBOOK:\s*([^|]+)\|\s*(\d+)\s*\|([\s\S]+)$/.exec(reply);
|
|
875
|
+
if (nb) {
|
|
876
|
+
const rel = nb[1].trim();
|
|
877
|
+
const idx = Number(nb[2]);
|
|
878
|
+
const src = nb[3].replace(/^\n/, '');
|
|
879
|
+
try {
|
|
880
|
+
const full = safeResolveIn(dirFor(originId), rel);
|
|
881
|
+
const doc = JSON.parse(readFileSync(full, 'utf8'));
|
|
882
|
+
if (!Array.isArray(doc.cells)) return `NOTEBOOK ${rel}: no cells array — is this really a .ipynb?`;
|
|
883
|
+
if (!doc.cells[idx]) return `NOTEBOOK ${rel}: no cell ${idx} (it has ${doc.cells.length}, 0-indexed).`;
|
|
884
|
+
// nbformat stores source as a LIST OF LINES WITH the newlines kept.
|
|
885
|
+
doc.cells[idx].source = src.split('\n').map((l, i, a) => (i === a.length - 1 ? l : l + '\n'));
|
|
886
|
+
// Stale outputs next to new code are worse than none.
|
|
887
|
+
if (doc.cells[idx].cell_type === 'code') { doc.cells[idx].outputs = []; doc.cells[idx].execution_count = null; }
|
|
888
|
+
writeFileSync(full, JSON.stringify(doc, null, 1));
|
|
889
|
+
return `NOTEBOOK ${rel}: replaced cell ${idx} (${doc.cells[idx].cell_type}); outputs cleared.`;
|
|
890
|
+
} catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
|
|
891
|
+
}
|
|
892
|
+
|
|
619
893
|
const ls = /^LS:\s*(.*)$/.exec(reply);
|
|
620
894
|
if (ls) {
|
|
621
895
|
const rel = ls[1].trim() || '.';
|
|
@@ -905,13 +1179,23 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
905
1179
|
// Transient: the nudge is appended for THIS call only and never pushed into
|
|
906
1180
|
// t.messages, so it can't accumulate across a chained auto run or get bound
|
|
907
1181
|
// into the thread's context.
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
1182
|
+
const extras = [];
|
|
1183
|
+
// /memory is worthless unless it reaches the model. Injected per-turn for
|
|
1184
|
+
// the same reason AUTO_DIRECTIVE is: the system prompt is frozen into the
|
|
1185
|
+
// thread at creation, so anything added there would never reach a thread
|
|
1186
|
+
// that already exists.
|
|
1187
|
+
if (t.memory?.length) {
|
|
1188
|
+
extras.push({ role: 'system', content: `Remember, for this thread:\n${t.memory.map((x) => `- ${x}`).join('\n')}` });
|
|
1189
|
+
}
|
|
1190
|
+
if (t.todos?.length) {
|
|
1191
|
+
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')}` });
|
|
1192
|
+
}
|
|
1193
|
+
if (t.runMode === 'auto') extras.push({ role: 'system', content: AUTO_DIRECTIVE });
|
|
1194
|
+
const callMsgs = extras.length ? [...t.messages, ...extras] : t.messages;
|
|
911
1195
|
try {
|
|
912
1196
|
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();
|
|
1197
|
+
? (await brainStream(callMsgs, (delta) => onEvent({ type: 'delta', name: t.name, color: t.color, delta }), t.contextId, t.model)).trim()
|
|
1198
|
+
: (await brain(callMsgs, t.contextId, t.model)).trim();
|
|
915
1199
|
} catch (e) {
|
|
916
1200
|
reply = `error: ${e.message}`;
|
|
917
1201
|
}
|
|
@@ -1030,6 +1314,19 @@ const APP_HTML = `<!doctype html>
|
|
|
1030
1314
|
.modebtn.ask.on { background: #b8f240; }
|
|
1031
1315
|
.modebtn.auto.on { background: #f28c4d; }
|
|
1032
1316
|
.modebtn:focus-visible { outline: 2px solid #6ab0ff; outline-offset: 2px; }
|
|
1317
|
+
/* Slash autocomplete. Anchored above the composer because the composer sits
|
|
1318
|
+
at the bottom of the viewport — a dropdown BELOW it would render off
|
|
1319
|
+
screen. */
|
|
1320
|
+
#slashMenu { display: none; position: absolute; bottom: calc(100% + 8px); left: 0; right: 0;
|
|
1321
|
+
max-height: 280px; overflow-y: auto; background: rgba(18,18,22,.98); border: 1px solid #2a2a33;
|
|
1322
|
+
border-radius: 12px; padding: 6px; z-index: 40; box-shadow: 0 10px 34px rgba(0,0,0,.55); }
|
|
1323
|
+
#slashMenu.show { display: block; }
|
|
1324
|
+
.scmd { display: flex; align-items: baseline; gap: 10px; padding: 7px 10px; border-radius: 8px;
|
|
1325
|
+
cursor: pointer; font-size: 12.5px; }
|
|
1326
|
+
.scmd:hover, .scmd.sel { background: #24242c; }
|
|
1327
|
+
.scmd b { color: #b8f240; font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
1328
|
+
.scmd i { color: #6f7080; font-style: normal; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
1329
|
+
.scmd span { color: #999aa8; margin-left: auto; text-align: right; }
|
|
1033
1330
|
#hudBtn { margin-left: 10px; }
|
|
1034
1331
|
#chatHeaderId { display: flex; align-items: center; gap: 10px; }
|
|
1035
1332
|
#hud { position: fixed; top: 40px; right: 14px; width: 250px; background: rgba(14,14,17,.94);
|
|
@@ -1120,8 +1417,11 @@ const APP_HTML = `<!doctype html>
|
|
|
1120
1417
|
.pop-item:hover { background: #2c2c2e; }
|
|
1121
1418
|
.pop-item svg { width: 18px; height: 18px; flex: 0 0 18px; }
|
|
1122
1419
|
.pop-item.record svg { color: #ff3b30; }
|
|
1420
|
+
/* position: relative anchors #slashMenu, which is absolutely positioned
|
|
1421
|
+
ABOVE the composer (the composer is pinned to the bottom, so a dropdown
|
|
1422
|
+
below it would render off screen). */
|
|
1123
1423
|
#pill { flex: 1; display: flex; align-items: center; gap: 6px; background: #2c2c2e; border-radius: 26px;
|
|
1124
|
-
padding: 8px 10px 8px 14px; }
|
|
1424
|
+
padding: 8px 10px 8px 14px; position: relative; }
|
|
1125
1425
|
.icon-btn { width: 32px; height: 32px; border-radius: 50%; border: none; background: transparent;
|
|
1126
1426
|
color: #ececec; display: flex; align-items: center; justify-content: center; cursor: pointer;
|
|
1127
1427
|
flex: 0 0 32px; }
|
|
@@ -1229,7 +1529,8 @@ const APP_HTML = `<!doctype html>
|
|
|
1229
1529
|
<button class="icon-btn" id="plusBtn">
|
|
1230
1530
|
<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
1531
|
</button>
|
|
1232
|
-
<
|
|
1532
|
+
<div id="slashMenu" data-component="slash-autocomplete"></div>
|
|
1533
|
+
<input id="inp" placeholder="Message" autofocus autocomplete="off">
|
|
1233
1534
|
<button class="icon-btn" tabindex="-1">
|
|
1234
1535
|
<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
1536
|
</button>
|
|
@@ -1476,10 +1777,45 @@ const APP_HTML = `<!doctype html>
|
|
|
1476
1777
|
addRow(h.who, h.text, h.color || t.color, h.name || t.name,
|
|
1477
1778
|
h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined, h.images);
|
|
1478
1779
|
}
|
|
1479
|
-
if (full.status === 'thinking')
|
|
1780
|
+
if (full.status === 'thinking') {
|
|
1781
|
+
addRow('bot', streamBuf || '…', t.color, t.name);
|
|
1782
|
+
// Tag the live bubble so deltas can repaint just this node instead of
|
|
1783
|
+
// re-rendering (and re-fetching) the whole thread on every token.
|
|
1784
|
+
const b = log.querySelector('.row:last-child .bubble');
|
|
1785
|
+
if (b) b.id = 'streamBubble';
|
|
1786
|
+
}
|
|
1480
1787
|
if (wasNearBottom) log.scrollTop = log.scrollHeight;
|
|
1481
1788
|
}
|
|
1482
1789
|
|
|
1790
|
+
// --- live token stream ---------------------------------------------------
|
|
1791
|
+
// The server has always been able to stream; /drive just never asked for it,
|
|
1792
|
+
// so a turn showed "…" for its whole duration and then arrived in one lump.
|
|
1793
|
+
let streamBuf = '';
|
|
1794
|
+
let es = null, esId = null;
|
|
1795
|
+
function paintStream() {
|
|
1796
|
+
const b = document.getElementById('streamBubble');
|
|
1797
|
+
if (!b) { render(); return; }
|
|
1798
|
+
// textContent, not markdown: the partial text is frequently mid-fence or
|
|
1799
|
+
// mid-link, and half-parsed markdown flickers. The final render formats it.
|
|
1800
|
+
b.textContent = streamBuf || '…';
|
|
1801
|
+
if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
|
|
1802
|
+
}
|
|
1803
|
+
function connectStream(id) {
|
|
1804
|
+
if (!id || esId === id) return;
|
|
1805
|
+
if (es) es.close();
|
|
1806
|
+
esId = id;
|
|
1807
|
+
streamBuf = '';
|
|
1808
|
+
es = new EventSource('/stream/' + id); // EventSource reconnects on its own
|
|
1809
|
+
es.onmessage = (e) => {
|
|
1810
|
+
let ev;
|
|
1811
|
+
try { ev = JSON.parse(e.data); } catch { return; }
|
|
1812
|
+
if (ev.type === 'start') { streamBuf = ''; paintStream(); }
|
|
1813
|
+
else if (ev.type === 'delta') { streamBuf += ev.delta || ''; paintStream(); }
|
|
1814
|
+
else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; render(); }
|
|
1815
|
+
};
|
|
1816
|
+
es.onerror = () => { /* EventSource retries; the 1.2s poll is the backstop */ };
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1483
1819
|
let pendingFiles = [];
|
|
1484
1820
|
let pendingImages = [];
|
|
1485
1821
|
const attachChips = document.getElementById('attachChips');
|
|
@@ -1557,7 +1893,60 @@ const APP_HTML = `<!doctype html>
|
|
|
1557
1893
|
|
|
1558
1894
|
inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0 || pendingImages.length > 0); });
|
|
1559
1895
|
send.addEventListener('click', submit);
|
|
1560
|
-
|
|
1896
|
+
// --- slash autocomplete --------------------------------------------------
|
|
1897
|
+
// The list comes from the SERVER (/slash-commands), so the menu can never
|
|
1898
|
+
// offer something the server doesn't actually handle.
|
|
1899
|
+
const slashMenu = document.getElementById('slashMenu');
|
|
1900
|
+
let slashCmds = [];
|
|
1901
|
+
let slashHits = [];
|
|
1902
|
+
let slashSel = 0;
|
|
1903
|
+
fetch('/slash-commands').then((r) => r.json()).then((c) => { slashCmds = c; }).catch(() => {});
|
|
1904
|
+
|
|
1905
|
+
function slashOpen() { return slashMenu.classList.contains('show'); }
|
|
1906
|
+
function renderSlash() {
|
|
1907
|
+
const v = inp.value;
|
|
1908
|
+
// Only while typing the command itself: once there's a space, the user is
|
|
1909
|
+
// writing arguments and a menu in the way is just noise.
|
|
1910
|
+
const m = /^\\/(\\w*)$/.exec(v);
|
|
1911
|
+
if (!m || !slashCmds.length) { slashMenu.classList.remove('show'); return; }
|
|
1912
|
+
const q = m[1].toLowerCase();
|
|
1913
|
+
slashHits = slashCmds.filter((c) => c.name.slice(1).toLowerCase().startsWith(q));
|
|
1914
|
+
if (!slashHits.length) { slashMenu.classList.remove('show'); return; }
|
|
1915
|
+
if (slashSel >= slashHits.length) slashSel = 0;
|
|
1916
|
+
slashMenu.innerHTML = slashHits.map((c, i) =>
|
|
1917
|
+
'<div class="scmd' + (i === slashSel ? ' sel' : '') + '" data-i="' + i + '">'
|
|
1918
|
+
+ '<b>' + escapeHtml(c.name) + '</b><i>' + escapeHtml(c.args) + '</i>'
|
|
1919
|
+
+ '<span>' + escapeHtml(c.help) + '</span></div>').join('');
|
|
1920
|
+
slashMenu.classList.add('show');
|
|
1921
|
+
}
|
|
1922
|
+
function slashAccept(i) {
|
|
1923
|
+
const c = slashHits[i];
|
|
1924
|
+
if (!c) return;
|
|
1925
|
+
// Commands that take arguments keep the caret going; ones that don't are
|
|
1926
|
+
// ready to send, so don't make the user delete a trailing space.
|
|
1927
|
+
inp.value = c.name + (c.args ? ' ' : '');
|
|
1928
|
+
slashMenu.classList.remove('show');
|
|
1929
|
+
inp.focus();
|
|
1930
|
+
send.classList.add('show');
|
|
1931
|
+
}
|
|
1932
|
+
slashMenu.addEventListener('mousedown', (e) => {
|
|
1933
|
+
const el = e.target.closest('.scmd');
|
|
1934
|
+
if (!el) return;
|
|
1935
|
+
e.preventDefault(); // keep focus in the input
|
|
1936
|
+
slashAccept(Number(el.dataset.i));
|
|
1937
|
+
});
|
|
1938
|
+
inp.addEventListener('input', renderSlash);
|
|
1939
|
+
inp.addEventListener('blur', () => setTimeout(() => slashMenu.classList.remove('show'), 120));
|
|
1940
|
+
|
|
1941
|
+
inp.addEventListener('keydown', (e) => {
|
|
1942
|
+
if (slashOpen()) {
|
|
1943
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); slashSel = (slashSel + 1) % slashHits.length; renderSlash(); return; }
|
|
1944
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); slashSel = (slashSel - 1 + slashHits.length) % slashHits.length; renderSlash(); return; }
|
|
1945
|
+
if (e.key === 'Tab' || e.key === 'Enter') { e.preventDefault(); slashAccept(slashSel); return; }
|
|
1946
|
+
if (e.key === 'Escape') { slashMenu.classList.remove('show'); return; }
|
|
1947
|
+
}
|
|
1948
|
+
if (e.key === 'Enter') submit();
|
|
1949
|
+
});
|
|
1561
1950
|
|
|
1562
1951
|
const plusBtn = document.getElementById('plusBtn');
|
|
1563
1952
|
const plusMenu = document.getElementById('plusMenu');
|
|
@@ -1669,7 +2058,7 @@ const APP_HTML = `<!doctype html>
|
|
|
1669
2058
|
}
|
|
1670
2059
|
});
|
|
1671
2060
|
|
|
1672
|
-
async function tick() { await loadThreads(); await render(); }
|
|
2061
|
+
async function tick() { connectStream(activeId); await loadThreads(); await render(); }
|
|
1673
2062
|
tick();
|
|
1674
2063
|
setInterval(tick, 1200);
|
|
1675
2064
|
|
|
@@ -1752,6 +2141,41 @@ const server = http.createServer((req, res) => {
|
|
|
1752
2141
|
})();
|
|
1753
2142
|
return;
|
|
1754
2143
|
}
|
|
2144
|
+
// LIVE TOKENS. runTurn has always been able to stream — it takes an onEvent
|
|
2145
|
+
// and calls brainStream — but /drive never passed one, so every turn used
|
|
2146
|
+
// the non-streaming brain() and the UI just polled /threads every 1.2s.
|
|
2147
|
+
// The user watched a "…" bubble for the whole generation and then got the
|
|
2148
|
+
// answer in one lump. Same work, all of the latency, none of the feedback.
|
|
2149
|
+
const sse = /^\/stream\/([^/?]+)$/.exec(req.url || '');
|
|
2150
|
+
if (req.method === 'GET' && sse) {
|
|
2151
|
+
const id = sse[1];
|
|
2152
|
+
res.writeHead(200, {
|
|
2153
|
+
'content-type': 'text/event-stream',
|
|
2154
|
+
'cache-control': 'no-cache',
|
|
2155
|
+
connection: 'keep-alive',
|
|
2156
|
+
// Proxies in front of a box (RunPod, nginx) will happily buffer an
|
|
2157
|
+
// event stream into nothing until it ends, which looks exactly like
|
|
2158
|
+
// streaming being broken.
|
|
2159
|
+
'x-accel-buffering': 'no',
|
|
2160
|
+
});
|
|
2161
|
+
res.write(': open\n\n');
|
|
2162
|
+
if (!streamListeners.has(id)) streamListeners.set(id, new Set());
|
|
2163
|
+
streamListeners.get(id).add(res);
|
|
2164
|
+
const ka = setInterval(() => { try { res.write(': ka\n\n'); } catch { /* gone */ } }, 20000);
|
|
2165
|
+
req.on('close', () => {
|
|
2166
|
+
clearInterval(ka);
|
|
2167
|
+
streamListeners.get(id)?.delete(res);
|
|
2168
|
+
if (!streamListeners.get(id)?.size) streamListeners.delete(id);
|
|
2169
|
+
});
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
// One source of truth for the composer's autocomplete — a hand-kept menu in
|
|
2173
|
+
// the client would drift from what the server actually handles.
|
|
2174
|
+
if (req.method === 'GET' && req.url === '/slash-commands') {
|
|
2175
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
2176
|
+
res.end(JSON.stringify(SLASH_COMMANDS));
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
1755
2179
|
if (req.method === 'GET' && req.url === '/threads') {
|
|
1756
2180
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1757
2181
|
res.end(JSON.stringify([...threads.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt).map(threadSummary)));
|
|
@@ -1843,11 +2267,23 @@ const server = http.createServer((req, res) => {
|
|
|
1843
2267
|
} catch { /* ignore */ }
|
|
1844
2268
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1845
2269
|
res.end(JSON.stringify({ ok: true }));
|
|
2270
|
+
const t = threads.get(threadId);
|
|
2271
|
+
// Every OTHER slash command. Local, free, instant — no model call, so
|
|
2272
|
+
// checking your spend or clearing a thread never costs anything.
|
|
2273
|
+
// /dir and /mode keep their own handlers below, untouched.
|
|
2274
|
+
if (t && /^\//.test(task.trim())) {
|
|
2275
|
+
const handled = await handleSlash(task.trim(), t).catch((e) => `error: ${e.message}`);
|
|
2276
|
+
if (handled !== null && handled !== undefined) {
|
|
2277
|
+
t.history.push({ who: 'bot', text: handled });
|
|
2278
|
+
t.lastActivityAt = Date.now();
|
|
2279
|
+
saveThreads();
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
1846
2283
|
// "/dir <path>" is a LOCAL control command, not sent to the model at
|
|
1847
2284
|
// all — free, instant, sets which folder this thread's WRITE/READ/SERVE
|
|
1848
2285
|
// are scoped to. Respecify any time by sending it again.
|
|
1849
2286
|
const dirCmd = /^\/dir\s+(.+)/.exec(task.trim());
|
|
1850
|
-
const t = threads.get(threadId);
|
|
1851
2287
|
if (dirCmd && t) {
|
|
1852
2288
|
const full = path.resolve(expandHome(dirCmd[1].trim()));
|
|
1853
2289
|
let ok = false;
|
|
@@ -1870,7 +2306,9 @@ const server = http.createServer((req, res) => {
|
|
|
1870
2306
|
saveThreads();
|
|
1871
2307
|
return;
|
|
1872
2308
|
}
|
|
1873
|
-
|
|
2309
|
+
// Stream to whoever is watching this thread. emitToThread is a no-op
|
|
2310
|
+
// when nobody is, so a spawned subagent nobody has open costs nothing.
|
|
2311
|
+
runTurn(threadId, task, (ev) => emitToThread(threadId, ev), images).catch(() => {});
|
|
1874
2312
|
});
|
|
1875
2313
|
return;
|
|
1876
2314
|
}
|
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.8",
|
|
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",
|