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