openzoo 0.48.18 → 0.48.22
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/ctxalias.js +127 -0
- package/lib/grokui.mjs +1687 -59
- package/lib/namespace.js +34 -8
- package/lib/podagent.mjs +326 -7
- package/lib/x402.js +16 -1
- package/package.json +1 -1
package/lib/grokui.mjs
CHANGED
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
// own independent agent (and that agent can spawn further threads too) —
|
|
6
6
|
// reusing the same SPAWN/SEND pattern podagent.mjs built for shell delegation,
|
|
7
7
|
// adapted here for plain chat.
|
|
8
|
-
import { exec } from 'node:child_process';
|
|
8
|
+
import { exec, execFile } from 'node:child_process';
|
|
9
9
|
import http from 'node:http';
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { cpus, homedir } from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
-
import { brain, brainStream, MODEL, PROXY } from './podagent.mjs';
|
|
14
|
+
import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES } 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,68 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
|
|
|
122
122
|
text — web search only gives you short
|
|
123
123
|
snippets; use FETCH when asked to
|
|
124
124
|
"read" or quote something specific
|
|
125
|
+
|
|
126
|
+
HOW YOUR SITE ACTUALLY GETS A URL — read this before writing web files.
|
|
127
|
+
|
|
128
|
+
The working directory is served as a STATIC FILE SERVER at a public URL. It does not run
|
|
129
|
+
a bundler, a dev server, or a build step. Nothing watches your files and compiles them.
|
|
130
|
+
MEASURED failure: a crew wrote tetris-metagame/index.html containing
|
|
131
|
+
<script type="module" src="/src/main.jsx">
|
|
132
|
+
an unbuilt Vite scaffold. Browsers CANNOT execute JSX, so that page returned 200 and
|
|
133
|
+
rendered a blank screen — for hours, while the crew reported the site as built.
|
|
134
|
+
|
|
135
|
+
1. BUILD BEFORE YOU CLAIM A SITE EXISTS. For a Vite/React app: install, build, and put the
|
|
136
|
+
OUTPUT where it is served. Then RUN a command that prints the built index.html and check
|
|
137
|
+
the script tag points at a real .js file, not a .jsx/.ts/.tsx source.
|
|
138
|
+
2. PREFER PLAIN HTML. One self-contained index.html — inline CSS, vanilla JS — needs no
|
|
139
|
+
build and works the instant you write it. For most things asked of you that is entirely
|
|
140
|
+
sufficient, and it gets a live URL now instead of after a toolchain fight.
|
|
141
|
+
3. PUT index.html AT THE WORKSPACE ROOT unless you have a reason not to. The SHALLOWEST
|
|
142
|
+
index.html is the one the preview shows.
|
|
143
|
+
4. RELATIVE ASSET PATHS: src="assets/app.js", never src="/assets/app.js". The site is
|
|
144
|
+
served under a path prefix, so a leading slash resolves off the site entirely.
|
|
145
|
+
5. CHECK IT YOURSELF before reporting done — RUN: curl -s localhost:8080/site/ | head -20
|
|
146
|
+
and READ the output. A .jsx reference, or <div id="root"></div> with no working script,
|
|
147
|
+
means it is broken and you are not finished.
|
|
148
|
+
|
|
149
|
+
Never report a site as finished until you have curled it and seen real markup.
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
SHIP IT IN ONE PASS. This is the single most important instruction here.
|
|
153
|
+
|
|
154
|
+
You are not a planning assistant. The user wants a WORKING THING at a URL, and they want
|
|
155
|
+
it from the first reply — the way v0 or lovable answers: you describe it, it exists.
|
|
156
|
+
|
|
157
|
+
So on any "build me X" request:
|
|
158
|
+
|
|
159
|
+
WRITE THE WHOLE THING NOW. One self-contained index.html at the workspace root, with
|
|
160
|
+
inline CSS and vanilla JS, complete enough to open and use. Not a skeleton, not a
|
|
161
|
+
scaffold, not "here is the structure and I will fill it in" — a real, playable,
|
|
162
|
+
clickable artifact on the FIRST reply. Ugly and working beats elegant and absent.
|
|
163
|
+
|
|
164
|
+
NO BUILD STEP unless the user asked for one. Do not npm init, do not scaffold Vite or
|
|
165
|
+
React or Next, do not create package.json, do not make a src/ tree. The directory is a
|
|
166
|
+
static file server: a plain index.html works the instant you write it, and a framework
|
|
167
|
+
scaffold is a blank page until someone runs a build nobody asked for.
|
|
168
|
+
|
|
169
|
+
ITERATE ON THE FILE, NOT ON A PLAN. When something needs changing, EDIT the file and
|
|
170
|
+
say what changed in one line. Never restate the roadmap, never re-list the features,
|
|
171
|
+
never ask which part to do first.
|
|
172
|
+
|
|
173
|
+
DO NOT SPLIT ONE ARTIFACT ACROSS AGENTS. If it is one page, one game, one dashboard —
|
|
174
|
+
build it yourself. Spawning a subagent per feature produces five halves of a thing and
|
|
175
|
+
no thing. Spawn only for work that is genuinely separate and parallel, and only when
|
|
176
|
+
told to.
|
|
177
|
+
|
|
178
|
+
DO NOT WRITE DOCUMENTS INSTEAD OF CODE. SPEC.md, ARCHITECTURE.md, a design doc, a
|
|
179
|
+
checklist — none of these are what was asked for and none of them render at a URL.
|
|
180
|
+
Write the artifact. If you truly need to think first, think in the reply, not in a file.
|
|
181
|
+
|
|
182
|
+
A STACK IS NEVER YOURS TO PICK. If the brief names one — a chain, an engine, an SDK, a
|
|
183
|
+
token — use exactly that, even where your training pulls somewhere else. "Smart
|
|
184
|
+
contract" does not mean Solidity. Anything you were told about the stack outranks
|
|
185
|
+
anything you assume.
|
|
186
|
+
|
|
125
187
|
MCP: <url> list the tools an MCP server exposes
|
|
126
188
|
MCP: <url> | <tool> | {"arg": "value"} CALL one of them, for real
|
|
127
189
|
An MCP endpoint speaks JSON-RPC over
|
|
@@ -132,7 +194,12 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
|
|
|
132
194
|
own client. Use this directive; it
|
|
133
195
|
does the initialize handshake, holds
|
|
134
196
|
the session, and calls the tool.
|
|
135
|
-
LS:
|
|
197
|
+
LS: [path] list a directory. The argument is
|
|
198
|
+
OPTIONAL — a bare LS: on its own
|
|
199
|
+
line lists the working directory.
|
|
200
|
+
Never write a placeholder like
|
|
201
|
+
<blank> or <path>; either give a
|
|
202
|
+
real path or give nothing.
|
|
136
203
|
GLOB: <pattern> find files — *.js, **/*.test.ts, src/**
|
|
137
204
|
GREP: <regex> | <optional path or glob> search file CONTENTS, with line numbers
|
|
138
205
|
EDIT: <path> | <exact old text> ||| <new text> change PART of a file. Prefer this over
|
|
@@ -246,13 +313,23 @@ function newThread(name, parent, members) {
|
|
|
246
313
|
//
|
|
247
314
|
// dir matters just as much — a child that defaults elsewhere cannot see the
|
|
248
315
|
// files the parent was sent to work on.
|
|
316
|
+
//
|
|
317
|
+
// tier/race/raceMode for the same reason, and one more: they are the SPEND
|
|
318
|
+
// dial. Setting a project to the expensive tier and then having every
|
|
319
|
+
// subagent it spawns silently drop back to medium means the setting applies
|
|
320
|
+
// to the one bot you happened to be looking at and nothing that does the
|
|
321
|
+
// actual work. The opposite is worse — dropping to cheap should not be
|
|
322
|
+
// quietly undone by a fan-out into four frontier models.
|
|
249
323
|
const p = parent ? threads.get(parent) : null;
|
|
250
324
|
const t = { id, name, color: members ? members[0].color : colorFor(name), parent: parent || null,
|
|
251
325
|
messages: members ? null : [{ role: 'system', content: SYSTEM }],
|
|
252
326
|
members: members || null, history: [], status: 'idle', createdAt: Date.now(), lastActivityAt: Date.now(),
|
|
253
327
|
...(p?.runMode ? { runMode: p.runMode } : {}),
|
|
254
328
|
...(p?.dir ? { dir: p.dir } : {}),
|
|
255
|
-
...(p?.model ? { model: p.model } : {})
|
|
329
|
+
...(p?.model ? { model: p.model } : {}),
|
|
330
|
+
...(p?.tier ? { tier: p.tier } : {}),
|
|
331
|
+
...(p?.race ? { race: p.race } : {}),
|
|
332
|
+
...(p?.raceNeed ? { raceNeed: p.raceNeed } : {}) };
|
|
256
333
|
threads.set(id, t);
|
|
257
334
|
saveThreads();
|
|
258
335
|
return t;
|
|
@@ -296,6 +373,68 @@ the user sets or changes it with "/dir <path>" in chat. Same format:
|
|
|
296
373
|
RUN: <shell command> run a REAL shell command in this
|
|
297
374
|
group's shared directory — pauses the
|
|
298
375
|
WHOLE round for the user's approval
|
|
376
|
+
|
|
377
|
+
SHIP IT IN ONE PASS. This is the single most important instruction here.
|
|
378
|
+
|
|
379
|
+
You are not a planning assistant. The user wants a WORKING THING at a URL, and they want
|
|
380
|
+
it from the first reply — the way v0 or lovable answers: you describe it, it exists.
|
|
381
|
+
|
|
382
|
+
So on any "build me X" request:
|
|
383
|
+
|
|
384
|
+
WRITE THE WHOLE THING NOW. One self-contained index.html at the workspace root, with
|
|
385
|
+
inline CSS and vanilla JS, complete enough to open and use. Not a skeleton, not a
|
|
386
|
+
scaffold, not "here is the structure and I will fill it in" — a real, playable,
|
|
387
|
+
clickable artifact on the FIRST reply. Ugly and working beats elegant and absent.
|
|
388
|
+
|
|
389
|
+
NO BUILD STEP unless the user asked for one. Do not npm init, do not scaffold Vite or
|
|
390
|
+
React or Next, do not create package.json, do not make a src/ tree. The directory is a
|
|
391
|
+
static file server: a plain index.html works the instant you write it, and a framework
|
|
392
|
+
scaffold is a blank page until someone runs a build nobody asked for.
|
|
393
|
+
|
|
394
|
+
ITERATE ON THE FILE, NOT ON A PLAN. When something needs changing, EDIT the file and
|
|
395
|
+
say what changed in one line. Never restate the roadmap, never re-list the features,
|
|
396
|
+
never ask which part to do first.
|
|
397
|
+
|
|
398
|
+
DO NOT SPLIT ONE ARTIFACT ACROSS AGENTS. If it is one page, one game, one dashboard —
|
|
399
|
+
build it yourself. Spawning a subagent per feature produces five halves of a thing and
|
|
400
|
+
no thing. Spawn only for work that is genuinely separate and parallel, and only when
|
|
401
|
+
told to.
|
|
402
|
+
|
|
403
|
+
DO NOT WRITE DOCUMENTS INSTEAD OF CODE. SPEC.md, ARCHITECTURE.md, a design doc, a
|
|
404
|
+
checklist — none of these are what was asked for and none of them render at a URL.
|
|
405
|
+
Write the artifact. If you truly need to think first, think in the reply, not in a file.
|
|
406
|
+
|
|
407
|
+
A STACK IS NEVER YOURS TO PICK. If the brief names one — a chain, an engine, an SDK, a
|
|
408
|
+
token — use exactly that, even where your training pulls somewhere else. "Smart
|
|
409
|
+
contract" does not mean Solidity. Anything you were told about the stack outranks
|
|
410
|
+
anything you assume.
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
HOW YOUR SITE ACTUALLY GETS A URL — read this before writing web files.
|
|
414
|
+
|
|
415
|
+
The working directory is served as a STATIC FILE SERVER at a public URL. It does not run
|
|
416
|
+
a bundler, a dev server, or a build step. Nothing watches your files and compiles them.
|
|
417
|
+
MEASURED failure: a crew wrote tetris-metagame/index.html containing
|
|
418
|
+
<script type="module" src="/src/main.jsx">
|
|
419
|
+
an unbuilt Vite scaffold. Browsers CANNOT execute JSX, so that page returned 200 and
|
|
420
|
+
rendered a blank screen — for hours, while the crew reported the site as built.
|
|
421
|
+
|
|
422
|
+
1. BUILD BEFORE YOU CLAIM A SITE EXISTS. For a Vite/React app: install, build, and put the
|
|
423
|
+
OUTPUT where it is served. Then RUN a command that prints the built index.html and check
|
|
424
|
+
the script tag points at a real .js file, not a .jsx/.ts/.tsx source.
|
|
425
|
+
2. PREFER PLAIN HTML. One self-contained index.html — inline CSS, vanilla JS — needs no
|
|
426
|
+
build and works the instant you write it. For most things asked of you that is entirely
|
|
427
|
+
sufficient, and it gets a live URL now instead of after a toolchain fight.
|
|
428
|
+
3. PUT index.html AT THE WORKSPACE ROOT unless you have a reason not to. The SHALLOWEST
|
|
429
|
+
index.html is the one the preview shows.
|
|
430
|
+
4. RELATIVE ASSET PATHS: src="assets/app.js", never src="/assets/app.js". The site is
|
|
431
|
+
served under a path prefix, so a leading slash resolves off the site entirely.
|
|
432
|
+
5. CHECK IT YOURSELF before reporting done — RUN: curl -s localhost:8080/site/ | head -20
|
|
433
|
+
and READ the output. A .jsx reference, or <div id="root"></div> with no working script,
|
|
434
|
+
means it is broken and you are not finished.
|
|
435
|
+
|
|
436
|
+
Never report a site as finished until you have curled it and seen real markup.
|
|
437
|
+
|
|
299
438
|
before anything executes ("/mode auto"
|
|
300
439
|
in chat skips that wait). Use this
|
|
301
440
|
instead of guessing or saying you
|
|
@@ -418,10 +557,21 @@ async function bindThread(t) {
|
|
|
418
557
|
const from = t.boundHistoryCount || 0;
|
|
419
558
|
const delta = t.history.slice(from);
|
|
420
559
|
if (!delta.length) return;
|
|
421
|
-
|
|
560
|
+
// Stamp every line with WHICH BOT said it. The context is shared across a
|
|
561
|
+
// project now, so an unlabelled line is worse than useless — recall would
|
|
562
|
+
// hand one agent another's words with no way to tell them apart.
|
|
563
|
+
const corpus = delta.map((h) => '[' + t.name + '] ' + (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
|
|
422
564
|
if (!corpus.trim()) { t.boundHistoryCount = t.history.length; return; }
|
|
423
565
|
try {
|
|
424
|
-
|
|
566
|
+
// ONE CONTEXT PER PROJECT, not per thread. Every thread used to bind to
|
|
567
|
+
// its own private context, so sibling agents spawned for the same job were
|
|
568
|
+
// memory-isolated: arc-tetris-engine could not recall a thing
|
|
569
|
+
// arc-token-bets had established, and the user paid to re-explain shared
|
|
570
|
+
// facts to each one. They are a team; the memory should be too.
|
|
571
|
+
// The context lives on the ROOT and every descendant binds into it, while
|
|
572
|
+
// each thread keeps its OWN boundHistoryCount so nothing is re-sent.
|
|
573
|
+
const root = threads.get(rootOf(t).rootId) || t;
|
|
574
|
+
let ctx = root.contextId || t.contextId;
|
|
425
575
|
for (let i = 0; i < corpus.length; i += BIND_CHUNK_BYTES) {
|
|
426
576
|
const part = corpus.slice(i, i + BIND_CHUNK_BYTES);
|
|
427
577
|
const body = ctx ? { corpus: part, context_id: ctx } : { corpus: part };
|
|
@@ -432,9 +582,15 @@ async function bindThread(t) {
|
|
|
432
582
|
});
|
|
433
583
|
const j = await r.json().catch(() => ({}));
|
|
434
584
|
if (j?.context_id) ctx = j.context_id;
|
|
585
|
+
// How many chunks the project's holobrain now holds. This is the number
|
|
586
|
+
// adaptive top_k scales on — without it we would be guessing, which is
|
|
587
|
+
// exactly how top_k ended up pinned at 8 in the first place.
|
|
588
|
+
if (Number(j?.bound)) root.boundItems = (root.boundItems || 0) + Number(j.bound);
|
|
435
589
|
else break; // this chunk failed — stop, keep whatever bound so far rather than lose it all
|
|
436
590
|
}
|
|
437
|
-
|
|
591
|
+
// Write to the ROOT so later siblings inherit it, and to this thread so
|
|
592
|
+
// the per-call header is readable without walking the tree again.
|
|
593
|
+
if (ctx) { root.contextId = ctx; t.contextId = ctx; t.boundHistoryCount = t.history.length; saveThreads(); }
|
|
438
594
|
} catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
|
|
439
595
|
}
|
|
440
596
|
|
|
@@ -499,13 +655,40 @@ function parseRun(reply) {
|
|
|
499
655
|
const dsml = new RegExp(`<${SEP}DSML[^>]*\\bparameter\\b[^>]*\\bname="${NAME}"[^>]*>([\\s\\S]*?)<\\/${SEP}DSML`, 'i').exec(reply);
|
|
500
656
|
if (dsml) return sanitizeRunCommand(dsml[1]);
|
|
501
657
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
658
|
+
// SEVERAL "RUN:" LINES IN ONE REPLY RUN BACK TO BACK.
|
|
659
|
+
//
|
|
660
|
+
// This capture is `[\s\S]+` on purpose — a command can legitimately span
|
|
661
|
+
// lines (heredocs, trailing backslashes, fenced blocks). But it is GREEDY,
|
|
662
|
+
// so when a model emitted three RUN: lines the FIRST match swallowed the
|
|
663
|
+
// other two and handed bash:
|
|
664
|
+
// cd .oz-parts && cat ... > ../full.zip
|
|
665
|
+
// RUN: cd .oz-parts && ls -lh full.zip
|
|
666
|
+
// RUN: cd .oz-parts && unzip -q full.zip
|
|
667
|
+
// which is exactly the MEASURED failure:
|
|
668
|
+
// /bin/bash: line 3: RUN:: command not found (lines 3, 5, 7 — exit 127)
|
|
669
|
+
// The first command ran, the rest died as literal text, and the model then
|
|
670
|
+
// concluded the RUN: prefix itself was the problem and started emitting bare
|
|
671
|
+
// commands — which do nothing at all. One greedy quantifier, and the agent
|
|
672
|
+
// reasons its way out of using the only tool it has.
|
|
673
|
+
//
|
|
674
|
+
// Split on the RUN: lines and join with newlines: sequential, one shell, in
|
|
675
|
+
// order. NOT `&&` — that stops the batch at the first non-zero exit, and a
|
|
676
|
+
// model batching three steps means "do these three", not "abort quietly if
|
|
677
|
+
// the first one greps nothing".
|
|
678
|
+
const heads = [...reply.matchAll(/^[ \t>*-]*RUN:[ \t]*/gm)];
|
|
679
|
+
if (!heads.length) return null;
|
|
680
|
+
const cmds = [];
|
|
681
|
+
for (let i = 0; i < heads.length; i++) {
|
|
682
|
+
const from = heads[i].index + heads[i][0].length;
|
|
683
|
+
const to = i + 1 < heads.length ? heads[i + 1].index : reply.length;
|
|
684
|
+
let cmd = reply.slice(from, to);
|
|
685
|
+
const fenced = /^```[\w-]*\n([\s\S]*?)```/.exec(cmd.trim());
|
|
686
|
+
if (fenced) cmd = fenced[1];
|
|
687
|
+
else cmd = cmd.replace(/\n```[\s\S]*$/, ''); // trailing fence + any posttext
|
|
688
|
+
cmd = sanitizeRunCommand(cmd);
|
|
689
|
+
if (cmd) cmds.push(cmd);
|
|
690
|
+
}
|
|
691
|
+
return cmds.length ? cmds.join('\n') : null;
|
|
509
692
|
}
|
|
510
693
|
|
|
511
694
|
// RUN through BASH, not /bin/sh. node's exec() defaults to /bin/sh, which on
|
|
@@ -556,11 +739,15 @@ const SLASH_COMMANDS = [
|
|
|
556
739
|
{ name: '/tokens', args: '', help: 'tokens and calls this session' },
|
|
557
740
|
{ name: '/model', args: '[id]', help: 'show or switch this thread’s model' },
|
|
558
741
|
{ name: '/models', args: '[filter]', help: 'search the ~435 served models' },
|
|
742
|
+
{ name: '/tier', args: 'cheap|medium|expensive', help: 'how much to spend per turn when no model is pinned' },
|
|
743
|
+
{ name: '/race', args: '<n> | <k> <n>', help: 'launch n models; judge the first k back (k=1 = fastest wins)' },
|
|
559
744
|
{ name: '/compact', args: '', help: 'summarise history to shrink context' },
|
|
560
745
|
{ name: '/clear', args: '', help: 'wipe this thread’s history' },
|
|
561
746
|
{ name: '/undo', args: '', help: 'drop the last exchange' },
|
|
562
747
|
{ name: '/memory', args: '[text|clear]', help: 'facts injected into every turn' },
|
|
563
748
|
{ name: '/sessions', args: '', help: 'list all threads' },
|
|
749
|
+
{ name: '/all', args: '<message>', help: 'send a message to every bot in this project' },
|
|
750
|
+
{ name: '/ping', args: '', help: 'status of every bot in this project' },
|
|
564
751
|
{ name: '/cron', args: '<mins> | <message>', help: 'repeat a message on a timer' },
|
|
565
752
|
{ name: '/crons', args: '', help: 'list timers (/cron del <id> removes one)' },
|
|
566
753
|
{ name: '/dir', args: '<path>', help: 'set this thread’s working directory' },
|
|
@@ -698,6 +885,70 @@ async function handleSlash(task, t) {
|
|
|
698
885
|
saveThreads();
|
|
699
886
|
return `This thread now uses ${arg}.\nNote: while images are in play the vision model still wins, or the call would just fail.`;
|
|
700
887
|
}
|
|
888
|
+
// /tier — the spend dial for "auto". A pinned /model outranks it, because a
|
|
889
|
+
// tier silently overriding an explicit id would make /model a suggestion.
|
|
890
|
+
if (cmd === 'tier') {
|
|
891
|
+
if (!arg) {
|
|
892
|
+
const picks = await tierModels(t.tier || 'medium', 3);
|
|
893
|
+
return `This thread: ${t.tier || 'medium'}${t.tier ? '' : ' (default)'}\n`
|
|
894
|
+
+ `Tiers: ${TIER_NAMES.join(' · ')}\n`
|
|
895
|
+
+ `Top of ${t.tier || 'medium'} right now: ${picks.join(', ')}\n`
|
|
896
|
+
+ (t.model ? `NOTE: /model ${t.model} is pinned on this thread, so the tier is ignored until you /model default.\n` : '')
|
|
897
|
+
+ 'Switch with /tier <name> · /race <n> to ask several at once.';
|
|
898
|
+
}
|
|
899
|
+
const want = arg.trim().toLowerCase();
|
|
900
|
+
if (!TIER_NAMES.includes(want)) return `Unknown tier "${arg}". One of: ${TIER_NAMES.join(', ')}.`;
|
|
901
|
+
t.tier = want; saveThreads();
|
|
902
|
+
const picks = await tierModels(want, 3);
|
|
903
|
+
return `This thread now runs on the ${want} tier — ${picks.join(', ')}…`
|
|
904
|
+
+ (t.model ? `\nBut /model ${t.model} is still pinned and wins. Run /model default to let the tier take over.` : '');
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// /race — spend more to wait less, and to survive one provider having a bad
|
|
908
|
+
// minute. Every entrant is PAID FOR; say so plainly, because the cost is not
|
|
909
|
+
// visible anywhere else until the bill.
|
|
910
|
+
if (cmd === 'race') {
|
|
911
|
+
const showNow = () => {
|
|
912
|
+
const n = Number(t.race) || 0, k = Math.min(Number(t.raceNeed) || 1, n || 1);
|
|
913
|
+
if (n < 2) return 'not racing';
|
|
914
|
+
return k > 1 ? `best of the first ${k} back, out of ${n} launched` : `${n} launched, first one back wins`;
|
|
915
|
+
};
|
|
916
|
+
if (!arg) {
|
|
917
|
+
return `This thread: ${showNow()}\n`
|
|
918
|
+
+ 'Set with /race <n> launch n (2-4), FIRST real answer wins.\n'
|
|
919
|
+
+ ' /race <k> <n> launch n, and the moment k of them are back, judge those k\n'
|
|
920
|
+
+ ' and ship the winner. The stragglers are abandoned mid-flight.\n\n'
|
|
921
|
+
+ '/race 2 3 is the useful one. k=1 optimises latency only — on a hard question it rewards\n'
|
|
922
|
+
+ 'whichever model thought LEAST. k=n buys quality with the slowest entrant\'s latency, so one\n'
|
|
923
|
+
+ 'wedged provider stalls the whole turn. Taking the first k bounds the wait at the k-th\n'
|
|
924
|
+
+ 'fastest and still gives the judge something to compare.\n'
|
|
925
|
+
+ 'Judging is blind (A/B/C/D) and done by a cheap model. Empty replies do not count toward k.\n'
|
|
926
|
+
+ 'You pay for every entrant, including the abandoned ones, so n=4 costs about 4x a turn.';
|
|
927
|
+
}
|
|
928
|
+
const nums = arg.trim().split(/[^0-9]+/).filter(Boolean).map(Number);
|
|
929
|
+
if (!nums.length) return `"${arg}" is not a number. Use /race 0 to turn it off, /race 3, or /race 2 3.`;
|
|
930
|
+
// One number is n (judge nothing). Two is "k of n" — and accept them in
|
|
931
|
+
// either order, because "best 2 of 3" and "3, judge 2" are the same wish
|
|
932
|
+
// and guessing wrong silently changes what the user pays for.
|
|
933
|
+
let n = nums.length === 1 ? nums[0] : Math.max(nums[0], nums[1]);
|
|
934
|
+
let k = nums.length === 1 ? 1 : Math.min(nums[0], nums[1]);
|
|
935
|
+
n = Math.max(0, Math.min(4, Math.round(n)));
|
|
936
|
+
k = Math.max(1, Math.min(k, n || 1));
|
|
937
|
+
if (n < 2) { delete t.race; delete t.raceNeed; saveThreads(); return 'Racing off — one model per turn.'; }
|
|
938
|
+
t.race = n;
|
|
939
|
+
if (k > 1) t.raceNeed = k; else delete t.raceNeed;
|
|
940
|
+
saveThreads();
|
|
941
|
+
const pool = await tierModels(t.tier || 'medium', 99);
|
|
942
|
+
return (k > 1
|
|
943
|
+
? `Launching ${n} models per turn from the ${t.tier || 'medium'} tier (${pool.length} in the pool, drawn at random).\n`
|
|
944
|
+
+ `As soon as ${k} of them are back, a cheap model reads those ${k} blind and picks the best. `
|
|
945
|
+
+ `The other ${n - k} are abandoned.`
|
|
946
|
+
: `Launching ${n} models per turn from the ${t.tier || 'medium'} tier (${pool.length} in the pool, drawn at random).\n`
|
|
947
|
+
+ 'First real answer wins; the rest are discarded.')
|
|
948
|
+
+ `\nCosts about ${n}x a normal turn — you pay for the abandoned ones too.`
|
|
949
|
+
+ (t.model ? `\nNOTE: /model ${t.model} is pinned, which disables racing. /model default to race.` : '');
|
|
950
|
+
}
|
|
951
|
+
|
|
701
952
|
if (cmd === 'models') {
|
|
702
953
|
try {
|
|
703
954
|
const list = await (await fetch(`${PROXY}/models`)).json();
|
|
@@ -762,6 +1013,39 @@ async function handleSlash(task, t) {
|
|
|
762
1013
|
saveThreads();
|
|
763
1014
|
return `Remembered. This is injected into every turn of this thread.\n ${t.memory.length}. ${arg}`;
|
|
764
1015
|
}
|
|
1016
|
+
// Talk to the WHOLE project at once. PING: * exists for bots, but there was
|
|
1017
|
+
// no way for a person to do it — you had to open each thread and retype the
|
|
1018
|
+
// same message, which is exactly the chore that makes a 12-agent project
|
|
1019
|
+
// unusable.
|
|
1020
|
+
if (cmd === 'all') {
|
|
1021
|
+
if (!arg) {
|
|
1022
|
+
return 'Usage: /all <message> — sends it to everyone BELOW you: your subagents, '
|
|
1023
|
+
+ 'their subagents, all the way down.\n'
|
|
1024
|
+
+ 'It is scoped to your own branch, not the whole project — run it from the top '
|
|
1025
|
+
+ 'thread to reach everybody.';
|
|
1026
|
+
}
|
|
1027
|
+
const crew = subtreeOf(t.id);
|
|
1028
|
+
if (!crew.length) return 'You have no subagents to send to.';
|
|
1029
|
+
for (const x of crew) runTurn(x.id, arg).catch(() => {});
|
|
1030
|
+
return `Sent down your branch to ${crew.length} bot(s): ${crew.map((x) => x.name).join(', ')}`;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Read the room without spending anything: who is working, who is blocked on
|
|
1034
|
+
// an approval, what each said last.
|
|
1035
|
+
if (cmd === 'ping') {
|
|
1036
|
+
// Same scoping as /all: your branch, not the whole project.
|
|
1037
|
+
const crew = subtreeOf(t.id, true);
|
|
1038
|
+
if (crew.length < 2) return 'You have no subagents yet.';
|
|
1039
|
+
return crew.map((x) => {
|
|
1040
|
+
const mark = x.id === t.id ? ' (here)' : '';
|
|
1041
|
+
const last = x.history[x.history.length - 1];
|
|
1042
|
+
return x.pendingRun ? ` ${x.name}${mark}: BLOCKED — waiting for your approval`
|
|
1043
|
+
: x.status === 'thinking' ? ` ${x.name}${mark}: working`
|
|
1044
|
+
: last ? ` ${x.name}${mark}: ${String(last.text).replace(/\s+/g, ' ').slice(0, 90)}`
|
|
1045
|
+
: ` ${x.name}${mark}: nothing yet`;
|
|
1046
|
+
}).join('\n');
|
|
1047
|
+
}
|
|
1048
|
+
|
|
765
1049
|
if (cmd === 'sessions') {
|
|
766
1050
|
const all = [...threads.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
767
1051
|
if (!all.length) return 'No threads.';
|
|
@@ -855,7 +1139,302 @@ function globToRe(glob) {
|
|
|
855
1139
|
return new RegExp('^' + re + '$');
|
|
856
1140
|
}
|
|
857
1141
|
|
|
1142
|
+
/**
|
|
1143
|
+
* Translate a FOREIGN tool-call envelope into our directive lines.
|
|
1144
|
+
*
|
|
1145
|
+
* Models carry other harnesses' call syntax out of training and emit it here
|
|
1146
|
+
* even when the system prompt spells ours out. MEASURED live on a fresh box:
|
|
1147
|
+
*
|
|
1148
|
+
* [TOOL_CALL]
|
|
1149
|
+
* {tool => "LS", args => {
|
|
1150
|
+
* --path ""
|
|
1151
|
+
* }}
|
|
1152
|
+
* [/TOOL_CALL]
|
|
1153
|
+
*
|
|
1154
|
+
* Nothing matched, so it rendered as chat text, nothing ran, and the user had
|
|
1155
|
+
* to type "continue?" — three prompts to get one spawn. This is the same class
|
|
1156
|
+
* of bug as the DSML envelope in parseRun and the old /^RUN:/ anchor: a
|
|
1157
|
+
* silently discarded directive reads to the model as a tool that does nothing,
|
|
1158
|
+
* and it narrates work it never did rather than reporting a failure.
|
|
1159
|
+
*
|
|
1160
|
+
* Translating beats correcting: a correction costs another paid turn, this
|
|
1161
|
+
* costs nothing and the model never learns it was wrong — which is fine,
|
|
1162
|
+
* because being right about the envelope was never the job.
|
|
1163
|
+
*/
|
|
1164
|
+
function translateForeignToolCall(reply) {
|
|
1165
|
+
const src = String(reply);
|
|
1166
|
+
// THREE envelope dialects seen in production, all from models carrying some
|
|
1167
|
+
// other harness's format out of training:
|
|
1168
|
+
// [TOOL_CALL]{tool => "LS", args => { --path "" }}[/TOOL_CALL]
|
|
1169
|
+
// <tool_call>RUN<arg_key>command</arg_key><arg_value>ls -la</arg_value></tool_call>
|
|
1170
|
+
// <DSML|invoke name="RUN">… (handled separately, inside parseRun)
|
|
1171
|
+
// Each one shipped as chat text and did nothing until it was taught here, so
|
|
1172
|
+
// the shape of the fix is: normalise ANY of them into {tool, args} and share
|
|
1173
|
+
// one mapping. A fourth dialect should be a few lines, not another outage.
|
|
1174
|
+
const out = [];
|
|
1175
|
+
// DELIMITERS DO NOT HAVE TO MATCH. Models MIX the dialects — measured live:
|
|
1176
|
+
// [TOOL_CALL]
|
|
1177
|
+
// RUN
|
|
1178
|
+
// <arg_key>command</arg_key><arg_value>pwd; ls -la</arg_value>
|
|
1179
|
+
// </tool_call>
|
|
1180
|
+
// opens with the bracket form and closes with the XML one. Matching PAIRS
|
|
1181
|
+
// ([TOOL_CALL]…[/TOOL_CALL] or <tool_call>…</tool_call>) misses that
|
|
1182
|
+
// entirely, so it rendered as chat text and nothing ran. Scan from ANY
|
|
1183
|
+
// opener to the NEXT closer of EITHER kind.
|
|
1184
|
+
const blocks = [...src.matchAll(
|
|
1185
|
+
/(?:\[TOOL_CALL\]|<tool_call>)([\s\S]*?)(?:\[\/TOOL_CALL\]|<\/tool_call>|$)/gi,
|
|
1186
|
+
)].map((m) => ({ body: m[1] }));
|
|
1187
|
+
|
|
1188
|
+
// DEEPSEEK'S NATIVE SPECIAL-TOKEN FORM — a fifth dialect, and the costliest.
|
|
1189
|
+
// <|tool_call_begin|>functions.WRITE:0<|tool_call_argument_begin|>
|
|
1190
|
+
// {"path": "/workspace/x/anti-cheat.js", "content": "…"}
|
|
1191
|
+
// <|tool_call_end|>
|
|
1192
|
+
// MEASURED: a complete anti-cheat implementation, several hundred lines,
|
|
1193
|
+
// rendered as chat text and written nowhere. The name carries a
|
|
1194
|
+
// "functions." prefix and a ":0" call index; the argument is plain JSON, so
|
|
1195
|
+
// this one maps straight onto our directives once the wrapper is peeled.
|
|
1196
|
+
const dsBlocks = [...src.matchAll(
|
|
1197
|
+
/<\|tool_call_begin\|>([\s\S]*?)<\|tool_call_argument_begin\|>([\s\S]*?)(?:<\|tool_call_end\|>|$)/g,
|
|
1198
|
+
)];
|
|
1199
|
+
for (const m of dsBlocks) {
|
|
1200
|
+
const rawName = m[1].trim().replace(/^functions?\./i, '').replace(/[:.]\d+$/, '');
|
|
1201
|
+
let a = {};
|
|
1202
|
+
try { a = JSON.parse(m[2].trim()); } catch { continue; }
|
|
1203
|
+
const t = rawName.toUpperCase();
|
|
1204
|
+
const pick = (...ks) => ks.map((k) => a[k]).find((v) => v !== undefined && v !== '');
|
|
1205
|
+
if (t === 'WRITE' || t === 'WRITE_FILE') out.push('WRITE: ' + (pick('path', 'file') || '') + ' | ' + (pick('content', 'text', 'body') || ''));
|
|
1206
|
+
else if (t === 'RUN' || t === 'BASH' || t === 'SHELL' || t === 'EXEC') out.push('RUN: ' + (pick('command', 'cmd', 'script') || ''));
|
|
1207
|
+
else if (t === 'READ' || t === 'READ_FILE' || t === 'CAT') out.push('READ: ' + (pick('path', 'file') || ''));
|
|
1208
|
+
else if (t === 'LS' || t === 'LIST') out.push('LS: ' + (pick('path', 'dir') || ''));
|
|
1209
|
+
else if (t === 'GLOB' || t === 'FIND') out.push('GLOB: ' + (pick('pattern', 'glob', 'query') || '*'));
|
|
1210
|
+
else if (t === 'FETCH') out.push('FETCH: ' + (pick('url', 'uri') || ''));
|
|
1211
|
+
else if (t === 'SPAWN') out.push('SPAWN: ' + (pick('name', 'agent') || 'helper') + ' | ' + (pick('task', 'prompt', 'goal') || ''));
|
|
1212
|
+
else if (t === 'SEND') out.push('SEND: ' + (pick('name', 'agent', 'to') || '') + ' | ' + (pick('message', 'msg', 'task') || ''));
|
|
1213
|
+
else if (t === 'MCP') {
|
|
1214
|
+
const tool2 = pick('tool', 'name', 'method') || '';
|
|
1215
|
+
const argj = pick('arg', 'args', 'arguments', 'params');
|
|
1216
|
+
out.push('MCP: ' + (pick('url', 'server') || '')
|
|
1217
|
+
+ (tool2 ? ' | ' + tool2 + (argj !== undefined ? ' | ' + (typeof argj === 'string' ? argj : JSON.stringify(argj)) : '') : ''));
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
for (const b of blocks) {
|
|
1221
|
+
const body = b.body;
|
|
1222
|
+
// The name is either quoted ({tool => "LS"}) or bare on its own first line
|
|
1223
|
+
// (<tool_call>RUN). Which delimiter opened the block tells us nothing once
|
|
1224
|
+
// models mix them, so try both spellings every time.
|
|
1225
|
+
const tool = (/tool\s*(?:=>|:)\s*"([^"]+)"/i.exec(body) || [])[1]
|
|
1226
|
+
|| (/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*$/m.exec(body) || [])[1];
|
|
1227
|
+
if (!tool) continue;
|
|
1228
|
+
// Arguments come in three spellings across harnesses: --flag "v",
|
|
1229
|
+
// "key" => "v", and key: "v". Accept all of them rather than betting on one.
|
|
1230
|
+
const args = {};
|
|
1231
|
+
for (const m of body.matchAll(/--([a-z_]+)\s+"([^"]*)"/gi)) args[m[1].toLowerCase()] = m[2];
|
|
1232
|
+
for (const m of body.matchAll(/"?([a-z_]+)"?\s*(?:=>|:)\s*"([^"]*)"/gi)) {
|
|
1233
|
+
if (m[1].toLowerCase() !== 'tool') args[m[1].toLowerCase()] = m[2];
|
|
1234
|
+
}
|
|
1235
|
+
// <arg_key>name</arg_key><arg_value>value</arg_value>, repeated. Values are
|
|
1236
|
+
// NOT quoted in this dialect and may contain anything, so the value regex
|
|
1237
|
+
// has to be lazy to its own closing tag rather than stop at a quote.
|
|
1238
|
+
for (const m of body.matchAll(/<arg_key>\s*([^<]+?)\s*<\/arg_key>\s*<arg_value>([\s\S]*?)<\/arg_value>/gi)) {
|
|
1239
|
+
args[m[1].toLowerCase()] = m[2];
|
|
1240
|
+
}
|
|
1241
|
+
const a = (...names) => names.map((n) => args[n]).find((v) => v !== undefined && v !== '');
|
|
1242
|
+
const t = tool.toUpperCase();
|
|
1243
|
+
if (t === 'LS' || t === 'LIST') out.push('GLOB: ' + ((a('path', 'dir', 'directory') || '.').replace(/\/$/, '') + '/*'));
|
|
1244
|
+
else if (t === 'GLOB' || t === 'FIND' || t === 'SEARCH_FILES') out.push('GLOB: ' + (a('pattern', 'glob', 'query') || '*'));
|
|
1245
|
+
else if (t === 'READ' || t === 'READ_FILE' || t === 'CAT') out.push('READ: ' + (a('path', 'file', 'filename') || ''));
|
|
1246
|
+
else if (t === 'WRITE' || t === 'WRITE_FILE') out.push('WRITE: ' + (a('path', 'file') || '') + ' | ' + (a('content', 'text', 'body') || ''));
|
|
1247
|
+
else if (t === 'FETCH' || t === 'HTTP' || t === 'BROWSE') out.push('FETCH: ' + (a('url', 'uri') || ''));
|
|
1248
|
+
else if (t === 'RUN' || t === 'BASH' || t === 'SHELL' || t === 'EXEC') out.push('RUN: ' + (a('command', 'cmd', 'script') || ''));
|
|
1249
|
+
else if (t === 'SPAWN') out.push('SPAWN: ' + (a('name', 'agent') || 'helper') + ' | ' + (a('task', 'prompt', 'goal') || ''));
|
|
1250
|
+
else if (t === 'MCP') {
|
|
1251
|
+
// MCP takes url [| tool | {json}] — rebuild whichever form was meant.
|
|
1252
|
+
const url = a('url', 'server', 'endpoint') || '';
|
|
1253
|
+
const tool2 = a('tool', 'name', 'method') || '';
|
|
1254
|
+
const argj = a('arg', 'args', 'arguments', 'params') || '';
|
|
1255
|
+
out.push('MCP: ' + url + (tool2 ? ' | ' + tool2 + (argj ? ' | ' + argj : '') : ''));
|
|
1256
|
+
}
|
|
1257
|
+
else if (t === 'SEND') out.push('SEND: ' + (a('name', 'agent', 'to') || '') + ' | ' + (a('message', 'msg', 'task') || ''));
|
|
1258
|
+
else if (t === 'PING') out.push('PING: ' + (a('name', 'agent') || ''));
|
|
1259
|
+
else if (t === 'SERVE') out.push('SERVE: ' + (a('path', 'file') || ''));
|
|
1260
|
+
else if (t === 'GREP') out.push('GREP: ' + (a('pattern', 'query', 'text') || ''));
|
|
1261
|
+
}
|
|
1262
|
+
// Only claim a translation when a directive actually came out of it — an
|
|
1263
|
+
// unrecognised tool must fall through to the no-directive nudge, not vanish
|
|
1264
|
+
// into an empty string that looks like a successful parse.
|
|
1265
|
+
if (!blocks.length && !out.length) return null;
|
|
1266
|
+
const text = out.filter((l) => !/(:|\|)\s*$/.test(l)).join('\n');
|
|
1267
|
+
return text || null;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Cut a directive's argument at the START OF THE NEXT DIRECTIVE.
|
|
1272
|
+
*
|
|
1273
|
+
* Directive bodies are captured with a greedy [\s\S]+ so a task or a command
|
|
1274
|
+
* may span lines. Greedy means the FIRST directive in a reply swallows every
|
|
1275
|
+
* one after it — measured on RUN (three commands became one broken script) and
|
|
1276
|
+
* again on SPAWN, where an orchestrator emitting five SPAWN lines would have
|
|
1277
|
+
* created one agent whose "task" was the other four.
|
|
1278
|
+
*/
|
|
1279
|
+
function sliceToNextDirective(text) {
|
|
1280
|
+
const m = /\n[ \t>*-]*(?:RUN|SPAWN|SEND|PING|PEEK|READ|WRITE|EDIT|GLOB|LS|LIST|DIR|FIND|GREP|SERVE|FETCH|MCP|DONE|TODO):/.exec(text);
|
|
1281
|
+
let out = m ? text.slice(0, m.index) : text;
|
|
1282
|
+
// Models fence their directive lists, so the LAST one in a block otherwise
|
|
1283
|
+
// ends up owning the closing ``` and every word of prose after it — a
|
|
1284
|
+
// subagent whose task was "Design anti-cheat ``` These subagents will work
|
|
1285
|
+
// independently." Cut at the closing fence, same as parseRun does.
|
|
1286
|
+
out = out.replace(/\n[ \t]*```[\s\S]*$/, '');
|
|
1287
|
+
return out.trim();
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** Every head of one directive in a reply, in order, each with its argument
|
|
1291
|
+
* already cut at the next head. Tolerates preamble, list markers and quotes —
|
|
1292
|
+
* models put "1. " and "> " in front of directives constantly. */
|
|
1293
|
+
function directiveLines(reply, keyword) {
|
|
1294
|
+
const heads = [...String(reply).matchAll(new RegExp('^[ \\t>*-]*(?:\\d+[.)]\\s*)?' + keyword + ':[ \\t]*', 'gm'))];
|
|
1295
|
+
return heads.map((h, i) => {
|
|
1296
|
+
const from = h.index + h[0].length;
|
|
1297
|
+
const to = i + 1 < heads.length ? heads[i + 1].index : reply.length;
|
|
1298
|
+
return sliceToNextDirective(reply.slice(from, to));
|
|
1299
|
+
}).filter(Boolean);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* Undo the two things models do to a directive line that make it unrecognisable.
|
|
1304
|
+
*
|
|
1305
|
+
* MEASURED live, repeatedly: <LS: <blank>>
|
|
1306
|
+
*
|
|
1307
|
+
* Both halves come from our own prompt. It documented the argument as
|
|
1308
|
+
* "<path, or blank for the root>", so the model copied the angle brackets AND
|
|
1309
|
+
* the word blank, producing a placeholder where a path goes and wrapping the
|
|
1310
|
+
* whole line for good measure. The prompt no longer teaches that, but models
|
|
1311
|
+
* carry <ARG> notation out of training regardless, and a directive we refuse
|
|
1312
|
+
* over its punctuation costs a paid turn every time.
|
|
1313
|
+
*
|
|
1314
|
+
* Only the directive HEAD is touched — never a body. WRITE content legitimately
|
|
1315
|
+
* contains angle brackets (it is usually HTML), and rewriting that would corrupt
|
|
1316
|
+
* files to fix a cosmetic problem.
|
|
1317
|
+
*/
|
|
1318
|
+
function unwrapDirectiveLine(reply) {
|
|
1319
|
+
const KW = 'RUN|SPAWN|SEND|PING|PEEK|READ|WRITE|EDIT|MULTIEDIT|NOTEBOOK|GLOB|LS|LIST|DIR|FIND|GREP|SERVE|FETCH|MCP|DONE|TODO';
|
|
1320
|
+
return String(reply).split('\n').map((line) => {
|
|
1321
|
+
// <LS: ...> -> LS: ... (a whole directive wrapped in angle brackets)
|
|
1322
|
+
let out = line.replace(new RegExp('^([ \\t>*-]*)<\\s*((?:' + KW + '):[\\s\\S]*?)\\s*>\\s*$'), '$1$2');
|
|
1323
|
+
// LS: <blank> -> LS: (a placeholder standing in for "no argument")
|
|
1324
|
+
out = out.replace(new RegExp('^([ \\t>*-]*(?:' + KW + '):)[ \\t]*<(?:blank|empty|none|nothing|path|dir|directory|optional)>[ \\t]*$', 'i'), '$1');
|
|
1325
|
+
return out;
|
|
1326
|
+
}).join('\n');
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* The brief a subagent is missing.
|
|
1331
|
+
*
|
|
1332
|
+
* A SPAWN task is the ORCHESTRATOR'S PARAPHRASE, and it is the whole world the
|
|
1333
|
+
* child ever sees. MEASURED: the user told the orchestrator "remember always:
|
|
1334
|
+
* proofnetwork can do it all, from rng to rpc/wallet management", and the
|
|
1335
|
+
* orchestrator emitted
|
|
1336
|
+
* SPAWN: meta_game_dev | Develop a meta-game where non-player users can wager
|
|
1337
|
+
* with ProofNetwork nowhere in it. The child heard "smart contract for
|
|
1338
|
+
* wagering", and wrote TetrisMetaGame.SOL — Solidity, on a Solana project —
|
|
1339
|
+
* because that is what "smart contract" means in the training distribution
|
|
1340
|
+
* when nothing says otherwise. The stack WAS specified. It just never reached
|
|
1341
|
+
* the agent doing the work.
|
|
1342
|
+
*
|
|
1343
|
+
* So a child now starts with the message that caused its own existence. Not a
|
|
1344
|
+
* summary of it, not the parent's whole transcript — the actual words the user
|
|
1345
|
+
* last wrote to the parent, which is where constraints like "this is
|
|
1346
|
+
* ProofNetwork" and "use token22 <mint>" always live.
|
|
1347
|
+
*/
|
|
1348
|
+
function spawnBrief(parent) {
|
|
1349
|
+
if (!parent) return '';
|
|
1350
|
+
const lastUser = [...(parent.history || [])].reverse()
|
|
1351
|
+
.find((m) => m.who === 'user' && !/^\((command output|directive result)\)/.test(String(m.text || '')));
|
|
1352
|
+
const text = String(lastUser?.text || '').trim();
|
|
1353
|
+
if (!text) return '';
|
|
1354
|
+
return 'CONTEXT — this is what was asked of the team you were just spawned into. '
|
|
1355
|
+
+ 'Constraints in here (the stack, addresses, what NOT to do) apply to you, and outrank '
|
|
1356
|
+
+ 'any assumption you would otherwise make from your own training:\n\n'
|
|
1357
|
+
+ text.slice(0, 4000)
|
|
1358
|
+
+ '\n\n--- your specific job ---\n';
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* WHERE YOU SIT IN THE TREE — the thing that stops pointless re-fanning.
|
|
1363
|
+
*
|
|
1364
|
+
* There is deliberately NO depth cap on SPAWN. A cap is the wrong tool: real
|
|
1365
|
+
* work is n-tiered and the right depth is whatever the job is. What actually
|
|
1366
|
+
* went wrong is that a child had no idea the split had ALREADY HAPPENED —
|
|
1367
|
+
* MEASURED, tetris-engine-builder was handed "build the Tetris game", could
|
|
1368
|
+
* not see that four siblings already owned the other quarters, and spawned
|
|
1369
|
+
* agent-1..agent-4 to re-split its own slice. Sixteen threads, generic names,
|
|
1370
|
+
* nobody building anything.
|
|
1371
|
+
*
|
|
1372
|
+
* So tell it: who you are, how many of you there are, what each sibling holds,
|
|
1373
|
+
* and how deep you already are. An agent that can see the decomposition does
|
|
1374
|
+
* the work instead of re-performing the decomposition.
|
|
1375
|
+
*/
|
|
1376
|
+
function spawnPosition(parent, childName) {
|
|
1377
|
+
if (!parent) return '';
|
|
1378
|
+
const siblings = [...threads.values()].filter((x) => x.parent === parent.id).map((x) => x.name);
|
|
1379
|
+
const depth = rootOf(parent).depth + 1;
|
|
1380
|
+
const others = siblings.filter((n) => n !== childName);
|
|
1381
|
+
return '\n\n--- your place in the team ---\n'
|
|
1382
|
+
+ `You are "${childName}", spawned by "${parent.name}". You are at tier ${depth} of this project.\n`
|
|
1383
|
+
+ (others.length
|
|
1384
|
+
? `The work was ALREADY SPLIT before you existed. Your siblings under ${parent.name} are: `
|
|
1385
|
+
+ others.join(', ') + '.\nThey hold the other slices. Yours is yours to BUILD.\n'
|
|
1386
|
+
: '')
|
|
1387
|
+
+ 'Do NOT re-split your own slice into more agents just because it has several parts — '
|
|
1388
|
+
+ 'that is how a team becomes sixteen bots and zero artifacts. SPAWN only if your slice '
|
|
1389
|
+
+ 'contains genuinely independent work that NO existing sibling covers, and name any agent '
|
|
1390
|
+
+ 'you do spawn after what it owns, never "agent-1".\n'
|
|
1391
|
+
+ 'Work in your own directory so you cannot collide with a sibling: '
|
|
1392
|
+
+ `type /dir <path> if you need one. Everything else — build it yourself, now.\n`;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* "AGENT-NAME: do the thing" -> "SEND: AGENT-NAME | do the thing"
|
|
1397
|
+
*
|
|
1398
|
+
* Gated on the name resolving to a real thread, so prose like "Note:" or
|
|
1399
|
+
* "Warning:" cannot be mistaken for an address. Names are compared with
|
|
1400
|
+
* separators and case stripped, because a model writes INSTALL-ANALYZE-MCP for
|
|
1401
|
+
* a thread called install-analyze-mcp and means the same bot.
|
|
1402
|
+
*/
|
|
1403
|
+
function nameAddressedToSend(reply, originId) {
|
|
1404
|
+
const norm = (x) => String(x).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
1405
|
+
const byNorm = new Map();
|
|
1406
|
+
for (const x of threads.values()) if (x.id !== originId) byNorm.set(norm(x.name), x.name);
|
|
1407
|
+
if (!byNorm.size) return reply;
|
|
1408
|
+
return String(reply).split('\n').map((line) => {
|
|
1409
|
+
const m = /^([ \t>*-]*)([A-Za-z][A-Za-z0-9 _-]{1,48}):[ \t]+(\S[\s\S]*)$/.exec(line);
|
|
1410
|
+
if (!m) return line;
|
|
1411
|
+
const real = byNorm.get(norm(m[2]));
|
|
1412
|
+
// A directive keyword that happens to match a thread name stays a directive.
|
|
1413
|
+
if (!real || /^(RUN|SPAWN|SEND|PING|PEEK|READ|WRITE|EDIT|GLOB|LS|LIST|DIR|FIND|GREP|SERVE|FETCH|MCP|DONE|TODO)$/i.test(m[2].trim())) return line;
|
|
1414
|
+
return `${m[1]}SEND: ${real} | ${m[3]}`;
|
|
1415
|
+
}).join('\n');
|
|
1416
|
+
}
|
|
1417
|
+
|
|
858
1418
|
async function tryDirective(reply, originId) {
|
|
1419
|
+
// Foreign envelope in, our directives out — before any matching runs, so
|
|
1420
|
+
// every branch below sees the shape it was written for.
|
|
1421
|
+
const translated = translateForeignToolCall(reply);
|
|
1422
|
+
if (translated) reply = translated;
|
|
1423
|
+
// Then strip angle-bracket wrapping and placeholder arguments.
|
|
1424
|
+
reply = unwrapDirectiveLine(reply);
|
|
1425
|
+
// ADDRESSING A BOT BY BARE NAME IS A SEND.
|
|
1426
|
+
//
|
|
1427
|
+
// MEASURED: an orchestrator wrote a block of
|
|
1428
|
+
// INSTALL-ANALYZE-MCP: Please proceed with installing the MCP…
|
|
1429
|
+
// GAME-ENGINE: Please proceed with designing the prototype…
|
|
1430
|
+
// one line per subagent, and the user had to ask "sorry did you forget
|
|
1431
|
+
// 'SEND: '". It is a perfectly clear instruction in every sense except the
|
|
1432
|
+
// one the parser cares about.
|
|
1433
|
+
//
|
|
1434
|
+
// Only rewritten when the prefix MATCHES AN EXISTING THREAD, case- and
|
|
1435
|
+
// separator-insensitive. That is what makes this safe: "Note:" or "Step 2:"
|
|
1436
|
+
// never matches a live agent, so ordinary prose is untouched.
|
|
1437
|
+
reply = nameAddressedToSend(reply, originId);
|
|
859
1438
|
// FAN OUT FIRST. Each line is re-entered on its own, so every branch below
|
|
860
1439
|
// stays single-directive and none of them had to learn about batching.
|
|
861
1440
|
const batch = [...reply.matchAll(PARALLEL_DIRECTIVE)];
|
|
@@ -867,7 +1446,57 @@ async function tryDirective(reply, originId) {
|
|
|
867
1446
|
return results.filter(Boolean).join('\n\n');
|
|
868
1447
|
}
|
|
869
1448
|
|
|
870
|
-
|
|
1449
|
+
// EVERY SPAWN LINE, NOT JUST THE FIRST — AND NOT ONLY AT THE START.
|
|
1450
|
+
//
|
|
1451
|
+
// This was /^SPAWN:...([\s\S]+)/ with NO `m` flag, which is two bugs at once:
|
|
1452
|
+
//
|
|
1453
|
+
// `^` without `m` anchors to the start of the WHOLE REPLY. An orchestrator
|
|
1454
|
+
// that writes "To address your requirements, I will spawn subagents:" and
|
|
1455
|
+
// then lists its directives never matched AT ALL — measured live, five
|
|
1456
|
+
// SPAWN lines, five SEND lines and five PING lines across three turns, and
|
|
1457
|
+
// the sidebar still held exactly one bot. Identical to the /^RUN:/ anchor
|
|
1458
|
+
// bug this file already documents, never applied to SPAWN.
|
|
1459
|
+
//
|
|
1460
|
+
// And [\s\S]+ is greedy, so even once it matched, the FIRST spawn would
|
|
1461
|
+
// swallow the other four into its own task.
|
|
1462
|
+
//
|
|
1463
|
+
// Fan out over every head, cutting each task at the next directive.
|
|
1464
|
+
const spawnAll = directiveLines(reply, 'SPAWN');
|
|
1465
|
+
if (spawnAll.length > 1) {
|
|
1466
|
+
// CREATE THE WHOLE COHORT BEFORE ANY OF THEM STARTS THINKING.
|
|
1467
|
+
//
|
|
1468
|
+
// Re-entering tryDirective per line spawns them one at a time, and
|
|
1469
|
+
// spawnPosition reads the sibling set AT SPAWN TIME — so the first child
|
|
1470
|
+
// was told it had no siblings, the second one, the fifth four. MEASURED:
|
|
1471
|
+
// tetris-game, spawned second of five, was told "Your siblings under
|
|
1472
|
+
// openzoo are: mcp-analyzer" while three more were seconds behind it. The
|
|
1473
|
+
// whole point of telling a child the split already happened is defeated if
|
|
1474
|
+
// it cannot see most of the split.
|
|
1475
|
+
//
|
|
1476
|
+
// The full cohort is knowable up front — it is right there in the reply.
|
|
1477
|
+
// So: parse every line, create every thread, THEN start their turns.
|
|
1478
|
+
const parent = threads.get(originId);
|
|
1479
|
+
const parsed = spawnAll.map((line) => /^([^|]+)\|([\s\S]+)/.exec(line))
|
|
1480
|
+
.filter(Boolean).map((m) => ({ name: m[1].trim(), task: m[2].trim() }));
|
|
1481
|
+
const made = [];
|
|
1482
|
+
const notes = [];
|
|
1483
|
+
for (const { name, task } of parsed) {
|
|
1484
|
+
const existing = findByName(name);
|
|
1485
|
+
if (existing) { notes.push(`${name} already exists — sending it the task.`); made.push({ t: existing, task, fresh: false }); continue; }
|
|
1486
|
+
const siblings = [...threads.values()].filter((x) => x.parent === originId).length;
|
|
1487
|
+
if (siblings >= SPAWN_MAX_CHILDREN) { notes.push(`Not spawning "${name}": already at ${SPAWN_MAX_CHILDREN} subagents.`); continue; }
|
|
1488
|
+
made.push({ t: newThread(name, originId), task, fresh: true });
|
|
1489
|
+
}
|
|
1490
|
+
// Every thread now exists, so spawnPosition sees the COMPLETE cohort.
|
|
1491
|
+
for (const { t: sub, task, fresh } of made) {
|
|
1492
|
+
runTurn(sub.id, (fresh ? spawnBrief(parent) : '') + task
|
|
1493
|
+
+ (fresh ? spawnPosition(parent, sub.name) : '')).catch(() => {});
|
|
1494
|
+
}
|
|
1495
|
+
const fresh = made.filter((m) => m.fresh).map((m) => m.t.name);
|
|
1496
|
+
return [fresh.length ? `Spawned ${fresh.length} together (they can each see the full crew): ${fresh.join(', ')}` : '', ...notes]
|
|
1497
|
+
.filter(Boolean).join('\n');
|
|
1498
|
+
}
|
|
1499
|
+
const spawn = spawnAll.length === 1 ? /^([^|]+)\|([\s\S]+)/.exec(spawnAll[0]) : null;
|
|
871
1500
|
if (spawn) {
|
|
872
1501
|
const name = spawn[1].trim();
|
|
873
1502
|
const task = spawn[2].trim();
|
|
@@ -892,27 +1521,85 @@ async function tryDirective(reply, originId) {
|
|
|
892
1521
|
+ `every live subagent costs paid calls.`;
|
|
893
1522
|
}
|
|
894
1523
|
const sub = newThread(name, originId);
|
|
895
|
-
|
|
1524
|
+
// The child gets the ORIGINATING brief plus its own job — see spawnBrief.
|
|
1525
|
+
runTurn(sub.id, spawnBrief(threads.get(originId)) + task + spawnPosition(threads.get(originId), name)).catch(() => {}); // fire and forget
|
|
896
1526
|
return `Spawned ${name} — working on it.`;
|
|
897
1527
|
}
|
|
898
|
-
|
|
1528
|
+
// SEND TO A NAME THAT DOES NOT EXIST YET *SPAWNS* IT.
|
|
1529
|
+
//
|
|
1530
|
+
// SPAWN already degrades to SEND when the name is taken ("already exists —
|
|
1531
|
+
// sent it the task instead of spawning a duplicate"). This is that rule's
|
|
1532
|
+
// missing half, and without it the pair was asymmetric in the direction that
|
|
1533
|
+
// costs money: an orchestrator planning a crew names all of them up front and
|
|
1534
|
+
// then SENDs, so every not-yet-spawned member returned
|
|
1535
|
+
// No thread named "Solana-Betting-Metagame" to message.
|
|
1536
|
+
// — MEASURED live, twice in one chain. Each one is a dead turn the model then
|
|
1537
|
+
// has to notice, diagnose and recover from, at auto-mode prices.
|
|
1538
|
+
//
|
|
1539
|
+
// Creating it is what was meant. The message IS the task; that is exactly the
|
|
1540
|
+
// argument SPAWN takes, so there is nothing to invent.
|
|
1541
|
+
// Same two bugs as SPAWN above: no `m`, and a greedy body.
|
|
1542
|
+
const sendAll = directiveLines(reply, 'SEND');
|
|
1543
|
+
if (sendAll.length > 1) {
|
|
1544
|
+
const out = [];
|
|
1545
|
+
for (const line of sendAll) out.push(await tryDirective('SEND: ' + line, originId));
|
|
1546
|
+
return out.filter(Boolean).join('\n');
|
|
1547
|
+
}
|
|
1548
|
+
const sendM = sendAll.length === 1 ? /^([^|]+)\|([\s\S]+)/.exec(sendAll[0]) : null;
|
|
899
1549
|
if (sendM) {
|
|
900
1550
|
const name = sendM[1].trim();
|
|
901
1551
|
const msg = sendM[2].trim();
|
|
902
1552
|
const target = findByName(name);
|
|
903
|
-
if (target)
|
|
904
|
-
|
|
1553
|
+
if (target) {
|
|
1554
|
+
runTurn(target.id, msg).catch(() => {});
|
|
1555
|
+
return `Messaged ${name}.`;
|
|
1556
|
+
}
|
|
1557
|
+
// The SAME storm guard SPAWN uses — promoting a SEND must not be a way
|
|
1558
|
+
// around the subagent ceiling, or a chatty orchestrator fans out for free
|
|
1559
|
+
// just by spelling its directive differently.
|
|
1560
|
+
const siblings = [...threads.values()].filter((x) => x.parent === originId).length;
|
|
1561
|
+
if (siblings >= SPAWN_MAX_CHILDREN) {
|
|
1562
|
+
return `Cannot create "${name}": this thread already has ${siblings} subagents `
|
|
1563
|
+
+ `(limit ${SPAWN_MAX_CHILDREN}). Reuse one with SEND: <existing name> | <task>.`;
|
|
1564
|
+
}
|
|
1565
|
+
const sub = newThread(name, originId);
|
|
1566
|
+
runTurn(sub.id, spawnBrief(threads.get(originId)) + msg + spawnPosition(threads.get(originId), name)).catch(() => {});
|
|
1567
|
+
return `${name} did not exist — spawned it with that message as its task.`;
|
|
905
1568
|
}
|
|
906
|
-
|
|
1569
|
+
// PING had the same anchor bug — no `m`, so a PING after any preamble (or
|
|
1570
|
+
// inside a fence, which is where models put lists of them) never matched.
|
|
1571
|
+
const pingAll = directiveLines(reply, 'PING');
|
|
1572
|
+
if (pingAll.length > 1) {
|
|
1573
|
+
const out = [];
|
|
1574
|
+
for (const line of pingAll) out.push(await tryDirective('PING: ' + line, originId));
|
|
1575
|
+
return out.filter(Boolean).join('\n');
|
|
1576
|
+
}
|
|
1577
|
+
const ping = pingAll.length === 1 ? [null, pingAll[0]] : null;
|
|
907
1578
|
if (ping) {
|
|
908
1579
|
const name = ping[1].trim();
|
|
1580
|
+
// PING: * (or 'all' / 'project') reaches EVERY bot in this project.
|
|
1581
|
+
// Coordinating a spawn tree by naming siblings one at a time is a chore
|
|
1582
|
+
// the parent should not have to do, and it cannot know who else exists.
|
|
1583
|
+
if (/^(\*|all|project|everyone)$/i.test(name)) {
|
|
1584
|
+
const me = threads.get(originId);
|
|
1585
|
+
const root = me ? rootOf(me).rootId : null;
|
|
1586
|
+
const crew = [...threads.values()].filter((x) => x.id !== originId && rootOf(x).rootId === root);
|
|
1587
|
+
if (!crew.length) return 'No other bots in this project yet.';
|
|
1588
|
+
return crew.map((x) => {
|
|
1589
|
+
const last = x.history[x.history.length - 1];
|
|
1590
|
+
return x.pendingRun ? x.name + ': BLOCKED — waiting for approval'
|
|
1591
|
+
: x.status === 'thinking' ? x.name + ': still working'
|
|
1592
|
+
: last ? x.name + ': ' + String(last.text).slice(0, 200)
|
|
1593
|
+
: x.name + ': no reply yet';
|
|
1594
|
+
}).join('\n');
|
|
1595
|
+
}
|
|
909
1596
|
const target = findByName(name);
|
|
910
1597
|
const last = target?.history[target.history.length - 1];
|
|
911
1598
|
return !target ? `No thread named "${name}".`
|
|
912
1599
|
: target.status === 'thinking' ? `${name} is still working.`
|
|
913
1600
|
: last ? `${name}: ${last.text}` : `${name} hasn't replied yet.`;
|
|
914
1601
|
}
|
|
915
|
-
const peek = /^PEEK:\s*(.+)
|
|
1602
|
+
const peek = /^[ \t>*-]*PEEK:\s*(.+)/m.exec(reply);
|
|
916
1603
|
if (peek) {
|
|
917
1604
|
const name = peek[1].trim();
|
|
918
1605
|
const target = findByName(name);
|
|
@@ -921,7 +1608,7 @@ async function tryDirective(reply, originId) {
|
|
|
921
1608
|
.map((h) => (h.who === 'user' ? 'you' : (h.name || target.name)) + ': ' + h.text).join('\n');
|
|
922
1609
|
return `${name} (${target.status}):\n${recent || '(nothing yet)'}`;
|
|
923
1610
|
}
|
|
924
|
-
const write = /^WRITE:\s*([^|]+)\|([\s\S]+)
|
|
1611
|
+
const write = /^[ \t>*-]*WRITE:\s*([^|]+)\|([\s\S]+)/m.exec(reply);
|
|
925
1612
|
if (write) {
|
|
926
1613
|
const rel = write[1].trim();
|
|
927
1614
|
const content = write[2].replace(/^\n/, '');
|
|
@@ -932,7 +1619,7 @@ async function tryDirective(reply, originId) {
|
|
|
932
1619
|
return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}.`;
|
|
933
1620
|
} catch (e) { return `Couldn't write ${rel}: ${e.message}`; }
|
|
934
1621
|
}
|
|
935
|
-
const readD = /^READ:\s*(.+)
|
|
1622
|
+
const readD = /^[ \t>*-]*READ:\s*(.+)/m.exec(reply);
|
|
936
1623
|
if (readD) {
|
|
937
1624
|
const rel = readD[1].trim();
|
|
938
1625
|
try {
|
|
@@ -943,7 +1630,7 @@ async function tryDirective(reply, originId) {
|
|
|
943
1630
|
// EDIT beats WRITE for changing part of a file: WRITE overwrites the whole
|
|
944
1631
|
// thing, so a model that wants a one-line change has to reproduce the entire
|
|
945
1632
|
// file from memory and silently drops whatever it forgot.
|
|
946
|
-
const edit = /^EDIT:\s*([^|]+)\|([\s\S]*?)\|\|\|([\s\S]*)
|
|
1633
|
+
const edit = /^[ \t>*-]*EDIT:\s*([^|]+)\|([\s\S]*?)\|\|\|([\s\S]*)$/m.exec(reply);
|
|
947
1634
|
if (edit) {
|
|
948
1635
|
const rel = edit[1].trim();
|
|
949
1636
|
const oldStr = edit[2].replace(/^\n/, '').replace(/\n$/, '');
|
|
@@ -962,7 +1649,7 @@ async function tryDirective(reply, originId) {
|
|
|
962
1649
|
// Several edits to ONE file, applied all-or-nothing. Sequential EDITs are a
|
|
963
1650
|
// trap: the third can fail after the first two already landed, leaving the
|
|
964
1651
|
// file in a state neither the model nor the user expected.
|
|
965
|
-
const multi = /^MULTIEDIT:\s*([^|]+)\|([\s\S]+)
|
|
1652
|
+
const multi = /^[ \t>*-]*MULTIEDIT:\s*([^|]+)\|([\s\S]+)$/m.exec(reply);
|
|
966
1653
|
if (multi) {
|
|
967
1654
|
const rel = multi[1].trim();
|
|
968
1655
|
const pairs = multi[2].split(';;').map((p) => p.split('|||')).filter((p) => p.length === 2);
|
|
@@ -986,7 +1673,7 @@ async function tryDirective(reply, originId) {
|
|
|
986
1673
|
}
|
|
987
1674
|
|
|
988
1675
|
// Jupyter: replace one cell's source by index, keeping the notebook valid.
|
|
989
|
-
const nb = /^NOTEBOOK:\s*([^|]+)\|\s*(\d+)\s*\|([\s\S]+)
|
|
1676
|
+
const nb = /^[ \t>*-]*NOTEBOOK:\s*([^|]+)\|\s*(\d+)\s*\|([\s\S]+)$/m.exec(reply);
|
|
990
1677
|
if (nb) {
|
|
991
1678
|
const rel = nb[1].trim();
|
|
992
1679
|
const idx = Number(nb[2]);
|
|
@@ -1005,7 +1692,10 @@ async function tryDirective(reply, originId) {
|
|
|
1005
1692
|
} catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
|
|
1006
1693
|
}
|
|
1007
1694
|
|
|
1008
|
-
|
|
1695
|
+
// The real directory listing. It has always existed and never once fired:
|
|
1696
|
+
// no `m` flag, so a bare LS: after any preamble was invisible, which is why
|
|
1697
|
+
// it looked like LS had no handler at all.
|
|
1698
|
+
const ls = /^[ \t>*-]*(?:LS|LIST|DIR):[ \t]*(.*)$/m.exec(reply);
|
|
1009
1699
|
if (ls) {
|
|
1010
1700
|
const rel = ls[1].trim() || '.';
|
|
1011
1701
|
try {
|
|
@@ -1022,9 +1712,20 @@ async function tryDirective(reply, originId) {
|
|
|
1022
1712
|
} catch (e) { return `Couldn't list ${rel}: ${e.message}`; }
|
|
1023
1713
|
}
|
|
1024
1714
|
|
|
1025
|
-
|
|
1715
|
+
// LS:/LIST:/DIR:/FIND: are ALIASES for GLOB, and the argument is optional.
|
|
1716
|
+
//
|
|
1717
|
+
// Models reach for "LS:" constantly — it is the obvious name for the thing
|
|
1718
|
+
// and it is all over their training. We only ever documented GLOB, so a bot
|
|
1719
|
+
// emitted a bare `LS:` (MEASURED live, right after announcing "let me first
|
|
1720
|
+
// check the current directory"), nothing matched, the line rendered as chat
|
|
1721
|
+
// text, and the user typed "continue…". Refusing a directive over its
|
|
1722
|
+
// spelling is not a rule, it is a bug that costs a paid turn every time.
|
|
1723
|
+
//
|
|
1724
|
+
// A bare LS: means "what is here" — the empty pattern that GLOB would reject
|
|
1725
|
+
// becomes `*`, which is what was meant.
|
|
1726
|
+
const glob = /^(?:GLOB|LS|LIST|DIR|FIND):[ \t]*(.*)$/m.exec(reply);
|
|
1026
1727
|
if (glob) {
|
|
1027
|
-
const pattern = glob[1].trim();
|
|
1728
|
+
const pattern = glob[1].trim() || '*';
|
|
1028
1729
|
try {
|
|
1029
1730
|
const base = dirFor(originId);
|
|
1030
1731
|
const re = globToRe(pattern.startsWith('./') ? pattern.slice(2) : pattern);
|
|
@@ -1035,7 +1736,7 @@ async function tryDirective(reply, originId) {
|
|
|
1035
1736
|
} catch (e) { return `GLOB ${pattern}: ${e.message}`; }
|
|
1036
1737
|
}
|
|
1037
1738
|
|
|
1038
|
-
const grep = /^GREP:\s*([^|]+?)(?:\s*\|\s*(.+))
|
|
1739
|
+
const grep = /^[ \t>*-]*GREP:\s*([^|]+?)(?:\s*\|\s*(.+))?$/m.exec(reply);
|
|
1039
1740
|
if (grep) {
|
|
1040
1741
|
const pattern = grep[1].trim();
|
|
1041
1742
|
const scope = (grep[2] || '').trim();
|
|
@@ -1063,7 +1764,7 @@ async function tryDirective(reply, originId) {
|
|
|
1063
1764
|
|
|
1064
1765
|
// A real, persisted checklist. Bots were already narrating plans; this makes
|
|
1065
1766
|
// the plan a thing the user can see and the model can be held to.
|
|
1066
|
-
const todo = /^TODO:\s*([\s\S]*)
|
|
1767
|
+
const todo = /^[ \t>*-]*TODO:\s*([\s\S]*)$/m.exec(reply);
|
|
1067
1768
|
if (todo) {
|
|
1068
1769
|
const t = threads.get(originId);
|
|
1069
1770
|
if (!t) return 'TODO: no such thread.';
|
|
@@ -1073,13 +1774,37 @@ async function tryDirective(reply, originId) {
|
|
|
1073
1774
|
if (!t.todos.length) return 'TODO: (empty)';
|
|
1074
1775
|
return 'TODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n');
|
|
1075
1776
|
}
|
|
1076
|
-
|
|
1777
|
+
// TICKING SOMETHING OFF TELLS THE CREW.
|
|
1778
|
+
//
|
|
1779
|
+
// A goal completed in silence is a goal the rest of the project cannot
|
|
1780
|
+
// build on. Every agent was working blind: five bots writing five halves
|
|
1781
|
+
// of the same thing, nobody able to see what already existed, and the
|
|
1782
|
+
// orchestrator reduced to PINGing for status one bot at a time. The
|
|
1783
|
+
// information was there the whole time — it just never left the thread.
|
|
1784
|
+
//
|
|
1785
|
+
// "done <n> | <summary>" broadcasts a short peek to the project. The
|
|
1786
|
+
// summary is OPTIONAL; without one the item's own text is the peek, which
|
|
1787
|
+
// is usually enough and costs nothing to write.
|
|
1788
|
+
const done = /^done\s+(\d+)\s*(?:\|\s*([\s\S]+))?$/i.exec(body);
|
|
1077
1789
|
if (done) {
|
|
1078
1790
|
const idx = Number(done[1]) - 1;
|
|
1079
1791
|
if (!t.todos[idx]) return `TODO: no item ${done[1]}.`;
|
|
1080
1792
|
t.todos[idx].done = true;
|
|
1793
|
+
const peek = (done[2] || '').trim() || t.todos[idx].text;
|
|
1081
1794
|
saveThreads();
|
|
1082
|
-
|
|
1795
|
+
const left = t.todos.filter((x) => !x.done).length;
|
|
1796
|
+
// Everyone in the project EXCEPT the sender, and only when there is a
|
|
1797
|
+
// project — a lone bot broadcasting to nobody is just a wasted turn.
|
|
1798
|
+
const root = rootOf(t).rootId;
|
|
1799
|
+
const crew = [...threads.values()].filter((x) => x.id !== t.id && rootOf(x).rootId === root);
|
|
1800
|
+
for (const x of crew) {
|
|
1801
|
+
runTurn(x.id, `[${t.name} finished] ${peek}\n`
|
|
1802
|
+
+ `(${t.todos.length - left}/${t.todos.length} of its goals done. `
|
|
1803
|
+
+ `This is a status peek — do NOT redo this work, and do not reply unless it changes yours.)`)
|
|
1804
|
+
.catch(() => {});
|
|
1805
|
+
}
|
|
1806
|
+
return 'TODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n')
|
|
1807
|
+
+ (crew.length ? `\n\nTold ${crew.length} bot(s) in this project: ${peek.slice(0, 90)}` : '');
|
|
1083
1808
|
}
|
|
1084
1809
|
if (/^clear$/i.test(body)) { t.todos = []; saveThreads(); return 'TODO: cleared.'; }
|
|
1085
1810
|
// Otherwise: replace the list with the lines given.
|
|
@@ -1089,13 +1814,13 @@ async function tryDirective(reply, originId) {
|
|
|
1089
1814
|
return 'TODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [ ] ${x.text}`).join('\n');
|
|
1090
1815
|
}
|
|
1091
1816
|
|
|
1092
|
-
const serve = /^SERVE:\s*(.*)
|
|
1817
|
+
const serve = /^[ \t>*-]*SERVE:\s*(.*)$/m.exec(reply);
|
|
1093
1818
|
if (serve) {
|
|
1094
1819
|
const rel = serve[1].trim();
|
|
1095
1820
|
if (!workspacePort) return 'Workspace server is still starting — try again in a second.';
|
|
1096
1821
|
return `Serving at http://localhost:${workspacePort}/${originId}/${rel}`;
|
|
1097
1822
|
}
|
|
1098
|
-
const fetchD = /^FETCH:\s*(\S+)
|
|
1823
|
+
const fetchD = /^[ \t>*-]*FETCH:\s*(\S+)/m.exec(reply);
|
|
1099
1824
|
if (fetchD) {
|
|
1100
1825
|
const url = fetchD[1].trim();
|
|
1101
1826
|
try {
|
|
@@ -1286,7 +2011,14 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
1286
2011
|
bindThread(t).catch(() => {});
|
|
1287
2012
|
return;
|
|
1288
2013
|
}
|
|
1289
|
-
|
|
2014
|
+
// A real message from the user resets the auto budget AND the one-shot
|
|
2015
|
+
// announcement nudge — otherwise a thread that got nudged once could never be
|
|
2016
|
+
// nudged again for the rest of its life, which is the opposite of a per-turn
|
|
2017
|
+
// guard. The nudge itself is not a reset: it must not re-arm its own budget.
|
|
2018
|
+
if (!/^\(command output\)/.test(userText) && userText !== NUDGE) {
|
|
2019
|
+
t.autoSteps = 0;
|
|
2020
|
+
delete t.autoNudged;
|
|
2021
|
+
}
|
|
1290
2022
|
t.messages.push({ role: 'user', content: contentFor(userText, images) });
|
|
1291
2023
|
t.status = 'thinking';
|
|
1292
2024
|
let reply = '';
|
|
@@ -1307,9 +2039,34 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
1307
2039
|
}
|
|
1308
2040
|
if (t.runMode === 'auto') extras.push({ role: 'system', content: AUTO_DIRECTIVE });
|
|
1309
2041
|
const callMsgs = extras.length ? [...t.messages, ...extras] : t.messages;
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
2042
|
+
// WHICH MODEL SERVES THIS TURN.
|
|
2043
|
+
// /model <id> pins one explicitly and always wins — an explicit choice is
|
|
2044
|
+
// not something a tier gets to override.
|
|
2045
|
+
// /race <n> fires n models from the tier at once, first real answer wins.
|
|
2046
|
+
// /tier otherwise picks the tier's best model.
|
|
2047
|
+
// `attempt` exists because a retry must be allowed to land somewhere else:
|
|
2048
|
+
// see the empty-completion loop below.
|
|
2049
|
+
const ask = async (attempt = 0) => {
|
|
2050
|
+
const emit = (delta) => onEvent && onEvent({ type: 'delta', name: t.name, color: t.color, delta });
|
|
2051
|
+
// Retrieval breadth scales with the PROJECT's corpus, not this thread's —
|
|
2052
|
+
// the holobrain is shared at the root, so that is the pool being searched.
|
|
2053
|
+
const topK = adaptiveTopK((threads.get(rootOf(t).rootId) || t).boundItems);
|
|
2054
|
+
const race = Math.min(Number(t.race) || 0, 4);
|
|
2055
|
+
if (!t.model && race >= 2) {
|
|
2056
|
+
const models = await tierModels(t.tier || 'medium', race, true);
|
|
2057
|
+
// need = how many must come BACK before judging. need 1 is a plain
|
|
2058
|
+
// first-past-the-post race; need N waits for all of them. The point of
|
|
2059
|
+
// the middle (2 of 3) is a judged answer without the slowest entrant
|
|
2060
|
+
// setting the latency.
|
|
2061
|
+
const need = Math.min(Math.max(Number(t.raceNeed) || 1, 1), race);
|
|
2062
|
+
return (await brainRace(callMsgs, emit, t.contextId, models, need)).trim();
|
|
2063
|
+
}
|
|
2064
|
+
// A retry draws a DIFFERENT model from the tier rather than the same one.
|
|
2065
|
+
const model = t.model || (await tierModels(t.tier || 'medium', attempt + 1, attempt > 0))[attempt] || undefined;
|
|
2066
|
+
return (onEvent
|
|
2067
|
+
? (await brainStream(callMsgs, emit, t.contextId, model)).trim()
|
|
2068
|
+
: (await brain(callMsgs, t.contextId, model, topK)).trim());
|
|
2069
|
+
};
|
|
1313
2070
|
try {
|
|
1314
2071
|
reply = await ask();
|
|
1315
2072
|
// An EMPTY completion is transient far more often than it is meaningful —
|
|
@@ -1320,12 +2077,21 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
1320
2077
|
// Retry in place rather than parking the thread behind a note the user has
|
|
1321
2078
|
// to answer. In auto especially: a transient blip must not become a manual
|
|
1322
2079
|
// step, which is the whole point of auto.
|
|
2080
|
+
// Each retry goes to a DIFFERENT model in the tier. Asking the same model
|
|
2081
|
+
// a fourth time after three empty completions is the definition of doing
|
|
2082
|
+
// the same thing and expecting a different result — and it is what produced
|
|
2083
|
+
// the "(the model returned nothing 4 times)" bubbles: four attempts, one
|
|
2084
|
+
// sick provider. Empties are per-model and uncorrelated, so moving is the
|
|
2085
|
+
// fix. A thread pinned with /model stays pinned; that was an explicit
|
|
2086
|
+
// choice and silently answering as something else would be worse.
|
|
1323
2087
|
for (let i = 0; !reply && i < AUTO_EMPTY_RETRIES; i++) {
|
|
1324
2088
|
await new Promise((r) => setTimeout(r, 400 * (i + 1)));
|
|
1325
|
-
reply = await ask();
|
|
2089
|
+
reply = await ask(t.model ? 0 : i + 1);
|
|
1326
2090
|
}
|
|
1327
2091
|
if (!reply) {
|
|
1328
|
-
reply =
|
|
2092
|
+
reply = t.model
|
|
2093
|
+
? `(${t.model} returned nothing ${AUTO_EMPTY_RETRIES + 1} times. It is pinned on this thread, so nothing else was tried — /model default frees it to fall back, or /model <id> to switch.)`
|
|
2094
|
+
: `(${AUTO_EMPTY_RETRIES + 1} different models in the ${t.tier || 'medium'} tier each returned nothing — that is upstream, not your input. Try /tier expensive, /race 3, or send anything to retry.)`;
|
|
1329
2095
|
}
|
|
1330
2096
|
} catch (e) {
|
|
1331
2097
|
reply = `error: ${e.message}`;
|
|
@@ -1410,15 +2176,180 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
1410
2176
|
onEvent?.({ type: 'final', name: t.name, color: t.color, text: note });
|
|
1411
2177
|
saveThreads();
|
|
1412
2178
|
}
|
|
2179
|
+
|
|
2180
|
+
// ANNOUNCING IS NOT DOING — so do not let it end the turn.
|
|
2181
|
+
//
|
|
2182
|
+
// In auto, a reply with NO directive stopped the chain dead. The common one
|
|
2183
|
+
// is not a finished answer, it is an announcement: "I'll break this down and
|
|
2184
|
+
// spawn subagents immediately. First, let me check the uploaded files." The
|
|
2185
|
+
// harness posted that as the final word and waited, so the user typed
|
|
2186
|
+
// "continue?" — MEASURED live, three prompts to get one SPAWN.
|
|
2187
|
+
//
|
|
2188
|
+
// AUTO_DIRECTIVE already forbids this in the prompt, and models do it anyway.
|
|
2189
|
+
// A prompt rule with no enforcement is a suggestion. Re-ask once, in-band.
|
|
2190
|
+
//
|
|
2191
|
+
// NARROW ON PURPOSE. It fires only on announcement language, never on a plain
|
|
2192
|
+
// answer — auto is also how you hold an ordinary conversation, and nudging a
|
|
2193
|
+
// finished reply would spend a turn arguing with a bot that already
|
|
2194
|
+
// succeeded. Once per turn (autoNudged), so a model that announces twice
|
|
2195
|
+
// still stops instead of looping on the user's wallet.
|
|
2196
|
+
if (t.runMode === 'auto' && (ack === null || ack === undefined) && !t.autoNudged
|
|
2197
|
+
&& (t.autoSteps || 0) < AUTO_MAX_STEPS && ANNOUNCEMENT.test(reply)) {
|
|
2198
|
+
t.autoNudged = true;
|
|
2199
|
+
t.autoSteps = (t.autoSteps || 0) + 1;
|
|
2200
|
+
saveThreads();
|
|
2201
|
+
bindThread(t).catch(() => {});
|
|
2202
|
+
runTurn(threadId, NUDGE, onEvent).catch(() => {});
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
1413
2205
|
bindThread(t).catch(() => {});
|
|
1414
2206
|
}
|
|
1415
2207
|
|
|
2208
|
+
// Said it would, without a directive line. "Spawned X" and "working on it" are
|
|
2209
|
+
// in here because they are FALSE without a SPAWN: in the same reply — the bot
|
|
2210
|
+
// reports success for something the harness never saw.
|
|
2211
|
+
const ANNOUNCEMENT = /\b(?:I(?:'| a)?ll |I will |let me |I'm going to |I am going to |first,? |next,? |now I'll |starting|kicking off|spawn(?:ing|ed)|about to|going to (?:check|run|create|start|install))\b/i;
|
|
2212
|
+
|
|
2213
|
+
const NUDGE = 'That reply announced work instead of doing it — no directive line reached the harness, '
|
|
2214
|
+
+ 'so nothing ran. Emit the directive NOW, as the first line of your reply, with no preamble: '
|
|
2215
|
+
+ 'RUN:, SPAWN:, READ:, WRITE:, GLOB:, FETCH:, MCP: or SERVE:. '
|
|
2216
|
+
+ 'Exactly the syntax from your instructions — not [TOOL_CALL], not JSON, not a function-call envelope. '
|
|
2217
|
+
+ 'If several steps are needed, emit the FIRST one; you get its real output back and continue from there.';
|
|
2218
|
+
|
|
2219
|
+
/**
|
|
2220
|
+
* The PROJECT a thread belongs to = the root of its spawn tree, and how deep
|
|
2221
|
+
* it sits. Every thread already carried the parent id; nothing ever walked it, so
|
|
2222
|
+
* fifteen agents rendered as one flat list with no hint that twelve of them
|
|
2223
|
+
* were spawned by one root.
|
|
2224
|
+
*
|
|
2225
|
+
* Depth is capped and visited-guarded: SPAWN sets parent from whoever emitted
|
|
2226
|
+
* the directive, and a bot messaging its own ancestor could otherwise close a
|
|
2227
|
+
* cycle and hang the render loop.
|
|
2228
|
+
*/
|
|
2229
|
+
function rootOf(t) {
|
|
2230
|
+
const seen = new Set();
|
|
2231
|
+
let cur = t;
|
|
2232
|
+
let depth = 0;
|
|
2233
|
+
while (cur?.parent && depth < 32 && !seen.has(cur.id)) {
|
|
2234
|
+
seen.add(cur.id);
|
|
2235
|
+
const next = threads.get(cur.parent);
|
|
2236
|
+
if (!next) break;
|
|
2237
|
+
cur = next;
|
|
2238
|
+
depth += 1;
|
|
2239
|
+
}
|
|
2240
|
+
return { rootId: cur?.id || t.id, depth };
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/**
|
|
2244
|
+
* Order threads as a TREE, not by recency.
|
|
2245
|
+
*
|
|
2246
|
+
* The sidebar sorted purely by lastActivityAt, which scrambled the hierarchy —
|
|
2247
|
+
* a child that just spoke jumped ABOVE its own parent, so the indentation drew
|
|
2248
|
+
* a structure the order contradicted. A tree you cannot read is worse than a
|
|
2249
|
+
* flat list, because it looks like it means something.
|
|
2250
|
+
*
|
|
2251
|
+
* Projects are ordered by their most recent activity (an active project stays
|
|
2252
|
+
* near the top, which is what recency was for), but WITHIN a project the order
|
|
2253
|
+
* is depth-first from the root, so a child is always directly under its parent
|
|
2254
|
+
* and indentation matches position. Siblings are ordered by recency.
|
|
2255
|
+
*
|
|
2256
|
+
* Cycle-guarded: SPAWN sets parent from whoever emitted the directive, and a
|
|
2257
|
+
* bot spawning toward its own ancestor could otherwise loop forever here.
|
|
2258
|
+
*/
|
|
2259
|
+
function orderedThreads() {
|
|
2260
|
+
const all = [...threads.values()];
|
|
2261
|
+
const byParent = new Map();
|
|
2262
|
+
for (const t of all) {
|
|
2263
|
+
const key = t.parent || '';
|
|
2264
|
+
if (!byParent.has(key)) byParent.set(key, []);
|
|
2265
|
+
byParent.get(key).push(t);
|
|
2266
|
+
}
|
|
2267
|
+
for (const list of byParent.values()) list.sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
|
|
2268
|
+
|
|
2269
|
+
// Newest-active project first.
|
|
2270
|
+
const roots = all.filter((t) => !t.parent || !threads.has(t.parent));
|
|
2271
|
+
const freshest = (t) => {
|
|
2272
|
+
let best = t.lastActivityAt || 0;
|
|
2273
|
+
const stack = [t.id];
|
|
2274
|
+
const seen = new Set();
|
|
2275
|
+
while (stack.length) {
|
|
2276
|
+
const id = stack.pop();
|
|
2277
|
+
if (seen.has(id)) continue;
|
|
2278
|
+
seen.add(id);
|
|
2279
|
+
for (const c of byParent.get(id) || []) {
|
|
2280
|
+
best = Math.max(best, c.lastActivityAt || 0);
|
|
2281
|
+
stack.push(c.id);
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
return best;
|
|
2285
|
+
};
|
|
2286
|
+
roots.sort((a, b) => freshest(b) - freshest(a));
|
|
2287
|
+
|
|
2288
|
+
const out = [];
|
|
2289
|
+
const seen = new Set();
|
|
2290
|
+
const walk = (t) => {
|
|
2291
|
+
if (seen.has(t.id)) return;
|
|
2292
|
+
seen.add(t.id);
|
|
2293
|
+
out.push(t);
|
|
2294
|
+
for (const c of byParent.get(t.id) || []) walk(c);
|
|
2295
|
+
};
|
|
2296
|
+
for (const r of roots) walk(r);
|
|
2297
|
+
// Anything unreachable (orphaned parent id) still has to appear — a thread
|
|
2298
|
+
// you cannot see is a thread you cannot stop, and it bills.
|
|
2299
|
+
for (const t of all) if (!seen.has(t.id)) out.push(t);
|
|
2300
|
+
return out;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
/**
|
|
2304
|
+
* Everyone at or below a thread. Ping-all lives HERE, not on the project root.
|
|
2305
|
+
*
|
|
2306
|
+
* "/all" used to mean "every bot sharing my rootId" — the whole project,
|
|
2307
|
+
* regardless of who you were talking to. That is the wrong unit once a tree has
|
|
2308
|
+
* real tiers: a mid-level owner wants to reach ITS OWN crew, not the eleven
|
|
2309
|
+
* cousins under a sibling. Addressing the whole project from a leaf is how one
|
|
2310
|
+
* message costs twenty paid turns.
|
|
2311
|
+
*
|
|
2312
|
+
* Cycle-guarded, because SPAWN sets parent from whoever emitted the directive
|
|
2313
|
+
* and a bot spawning toward its own ancestor would otherwise loop here.
|
|
2314
|
+
*/
|
|
2315
|
+
function subtreeOf(id, includeSelf = false) {
|
|
2316
|
+
const out = [];
|
|
2317
|
+
const seen = new Set([id]);
|
|
2318
|
+
const stack = [id];
|
|
2319
|
+
while (stack.length) {
|
|
2320
|
+
const cur = stack.pop();
|
|
2321
|
+
for (const x of threads.values()) {
|
|
2322
|
+
if (x.parent === cur && !seen.has(x.id)) {
|
|
2323
|
+
seen.add(x.id);
|
|
2324
|
+
out.push(x);
|
|
2325
|
+
stack.push(x.id);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
const self = threads.get(id);
|
|
2330
|
+
return includeSelf && self ? [self, ...out] : out;
|
|
2331
|
+
}
|
|
2332
|
+
|
|
1416
2333
|
function threadSummary(t) {
|
|
1417
2334
|
const last = t.history[t.history.length - 1];
|
|
1418
2335
|
return { id: t.id, name: t.name, color: t.color, parent: t.parent, status: t.status,
|
|
1419
2336
|
preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
|
|
1420
2337
|
createdAt: t.createdAt, lastActivityAt: t.lastActivityAt || t.createdAt,
|
|
1421
|
-
dir: t.dir || WORKSPACE_DIR, runMode: t.runMode || 'ask'
|
|
2338
|
+
dir: t.dir || WORKSPACE_DIR, runMode: t.runMode || 'ask',
|
|
2339
|
+
// BLOCKED ON YOU. A thread with a pending RUN is stopped dead until
|
|
2340
|
+
// someone approves or denies it, and nothing in the sidebar said so — it
|
|
2341
|
+
// looked identical to an idle thread, so a subagent could sit waiting for
|
|
2342
|
+
// an approval nobody knew it wanted. The blue dot means "working"; this
|
|
2343
|
+
// means "your move".
|
|
2344
|
+
awaitingUser: Boolean(t.pendingRun),
|
|
2345
|
+
rootId: rootOf(t).rootId, depth: rootOf(t).depth,
|
|
2346
|
+
rootName: (threads.get(rootOf(t).rootId) || t).name,
|
|
2347
|
+
// The spend dial, so the header can show it without a round trip per
|
|
2348
|
+
// thread. `model` pinned means tier/race are inert — the UI says so.
|
|
2349
|
+
tier: t.tier || 'medium', race: Number(t.race) || 0, raceNeed: Number(t.raceNeed) || 1, model: t.model || '',
|
|
2350
|
+
// How many bots sit BELOW this one. The ping-all affordance belongs on
|
|
2351
|
+
// anyone with a crew, not only on a project root.
|
|
2352
|
+
kids: subtreeOf(t.id).length };
|
|
1422
2353
|
}
|
|
1423
2354
|
|
|
1424
2355
|
const APP_HTML = `<!doctype html>
|
|
@@ -1442,6 +2373,28 @@ const APP_HTML = `<!doctype html>
|
|
|
1442
2373
|
#threads { flex: 1; overflow-y: auto; }
|
|
1443
2374
|
.trow { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-radius: 10px;
|
|
1444
2375
|
margin: 0 6px 2px; }
|
|
2376
|
+
/* PROJECT HEADER. The tree indentation shows who spawned whom, but there was
|
|
2377
|
+
no handle on a project as a WHOLE — no way to see where one ends and the
|
|
2378
|
+
next begins, and no way to talk to all of it at once without opening each
|
|
2379
|
+
bot and retyping. This row is that handle. It only appears for a root that
|
|
2380
|
+
actually has children; a lone bot is not a project and does not need a
|
|
2381
|
+
label above it. */
|
|
2382
|
+
.prow { display: flex; align-items: center; gap: 8px; padding: 10px 12px 4px; margin: 0 6px;
|
|
2383
|
+
font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: #6f7080; }
|
|
2384
|
+
.prow .pname { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
2385
|
+
.pcount { color: #4c4d5a; letter-spacing: 0; text-transform: none; }
|
|
2386
|
+
.pingall { border: 1px solid #2c2c2e; background: transparent; color: #8e8e93; font: inherit; font-size: 10px;
|
|
2387
|
+
letter-spacing: .04em; text-transform: uppercase; padding: 2px 7px; border-radius: 999px;
|
|
2388
|
+
cursor: pointer; opacity: 0; transition: opacity .12s ease, color .12s ease, border-color .12s ease; }
|
|
2389
|
+
.prow:hover .pingall, .pingall:focus-visible { opacity: 1; }
|
|
2390
|
+
/* On a THREAD row the button sits beside the close X, so it follows the same
|
|
2391
|
+
reveal-on-hover rule — a permanently visible control on every parent row
|
|
2392
|
+
would turn the sidebar into a wall of buttons. */
|
|
2393
|
+
.trow .pingall { flex: 0 0 auto; margin-left: 2px; }
|
|
2394
|
+
.trow:hover .pingall, .trow.active .pingall { opacity: 1; }
|
|
2395
|
+
.pingall:hover { color: #b8f240; border-color: #b8f240; }
|
|
2396
|
+
.pingall:focus-visible { outline: 2px solid #6ab0ff; outline-offset: 2px; }
|
|
2397
|
+
.pingall[disabled] { opacity: 1; color: #4c4d5a; border-color: #1c1c1e; cursor: default; }
|
|
1445
2398
|
.trow:hover { background: #17171a; }
|
|
1446
2399
|
.trow.active { background: #1c1c1e; }
|
|
1447
2400
|
.tclose { flex: 0 0 20px; width: 20px; height: 20px; border-radius: 50%; border: none; background: transparent;
|
|
@@ -1455,6 +2408,17 @@ const APP_HTML = `<!doctype html>
|
|
|
1455
2408
|
.tname { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1456
2409
|
.tprev { font-size: 12px; color: #8e8e93; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1457
2410
|
.tdot { width: 8px; height: 8px; border-radius: 50%; background: #0a84ff; flex: 0 0 8px; }
|
|
2411
|
+
/* "Your move" — a CSS triangle, so it reads as a different SHAPE and not
|
|
2412
|
+
just a different colour. Colour alone would be invisible to a red/green
|
|
2413
|
+
colour-blind user, and this is the one state that needs acting on. */
|
|
2414
|
+
.twarn {
|
|
2415
|
+
width: 0; height: 0; flex: 0 0 auto;
|
|
2416
|
+
border-left: 5px solid transparent; border-right: 5px solid transparent;
|
|
2417
|
+
border-bottom: 9px solid #ffcc00;
|
|
2418
|
+
animation: twarnpulse 1.6s ease-in-out infinite;
|
|
2419
|
+
}
|
|
2420
|
+
@keyframes twarnpulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
|
|
2421
|
+
@media (prefers-reduced-motion: reduce) { .twarn { animation: none; } }
|
|
1458
2422
|
#main { flex: 1; min-width: 0; display: flex; flex-direction: column; height: 100vh; }
|
|
1459
2423
|
#chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center; gap: 10px;
|
|
1460
2424
|
font-weight: 600; }
|
|
@@ -1476,6 +2440,54 @@ const APP_HTML = `<!doctype html>
|
|
|
1476
2440
|
.modebtn.ask.on { background: #b8f240; }
|
|
1477
2441
|
.modebtn.auto.on { background: #f28c4d; }
|
|
1478
2442
|
.modebtn:focus-visible { outline: 2px solid #6ab0ff; outline-offset: 2px; }
|
|
2443
|
+
/* SPEND DIAL. Two selects rather than more pill toggles: ask/auto is a safety
|
|
2444
|
+
switch you flip constantly, these are set once and forgotten, and giving
|
|
2445
|
+
them the same visual weight would say they matter equally. Muted until
|
|
2446
|
+
they are off default, then they colour — an expensive tier or a live race
|
|
2447
|
+
should be visible at a glance, because both are spending your wallet. */
|
|
2448
|
+
.dial { border: 1px solid #2c2c2e; background: #131315; color: #8e8e93; font: inherit; font-size: 11px;
|
|
2449
|
+
border-radius: 999px; padding: 4px 8px; cursor: pointer; -webkit-appearance: none; appearance: none; }
|
|
2450
|
+
.dial:hover { color: #ececec; border-color: #3a3a3c; }
|
|
2451
|
+
.dial:focus-visible { outline: 2px solid #6ab0ff; outline-offset: 2px; }
|
|
2452
|
+
.dial.hot { color: #f28c4d; border-color: #f28c4d; }
|
|
2453
|
+
.dial.pinned { color: #4c4d5a; border-color: #1c1c1e; }
|
|
2454
|
+
/* WALLET. GET /wallet shipped in 1.5.22 with nothing pointing at it, so the
|
|
2455
|
+
box's own deposit addresses were reachable only by curl — on a product
|
|
2456
|
+
whose entire premise is that the box pays for itself. An address you
|
|
2457
|
+
cannot copy is an address you cannot fund, so every one of them is a
|
|
2458
|
+
click-to-copy row, not selectable text. */
|
|
2459
|
+
#walletOverlay, #assetsOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.66); z-index: 1200;
|
|
2460
|
+
display: none; align-items: center; justify-content: center; padding: 24px; }
|
|
2461
|
+
#walletOverlay.show, #assetsOverlay.show { display: flex; }
|
|
2462
|
+
#walletBox { width: 100%; max-width: 560px; max-height: 82vh; overflow-y: auto; background: #111113;
|
|
2463
|
+
border: 1px solid #2c2c2e; border-radius: 16px; padding: 20px 22px; }
|
|
2464
|
+
#walletBox h3 { margin: 0 0 2px; font-size: 15px; font-weight: 600; }
|
|
2465
|
+
.wsub { color: #8e8e93; font-size: 12px; margin-bottom: 16px; }
|
|
2466
|
+
.wrow { border: 1px solid #1c1c1e; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px;
|
|
2467
|
+
display: flex; align-items: center; gap: 10px; cursor: pointer; }
|
|
2468
|
+
.wrow:hover { border-color: #3a3a3c; background: #151517; }
|
|
2469
|
+
.wrow .wlab { flex: 0 0 74px; color: #6f7080; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
|
|
2470
|
+
/* min-width:0 or the address (one unbreakable token) widens the row and
|
|
2471
|
+
pushes the copy affordance out of the box — the same flexbox trap that
|
|
2472
|
+
once pushed the cost button off-screen. */
|
|
2473
|
+
.wrow .waddr { flex: 1; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
2474
|
+
font-size: 12px; word-break: break-all; line-height: 1.45; }
|
|
2475
|
+
.wrow .wcopy { flex: 0 0 auto; color: #6f7080; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
|
|
2476
|
+
.wrow:hover .wcopy { color: #b8f240; }
|
|
2477
|
+
.wbal { border: 1px solid #1c1c1e; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px;
|
|
2478
|
+
font-size: 12px; color: #ececec; line-height: 1.7; word-break: break-word; }
|
|
2479
|
+
.wnote { color: #6f7080; font-size: 11px; line-height: 1.6; margin-top: 12px; }
|
|
2480
|
+
.wempty { color: #f28c4d; }
|
|
2481
|
+
/* The passphrase field. type=password so it never renders in clear, and
|
|
2482
|
+
autocomplete off so a browser is not tempted to remember a key that
|
|
2483
|
+
unlocks a public blob. */
|
|
2484
|
+
.wpass { width: 100%; margin-top: 4px; padding: 9px 12px; background: #0d0d0f; color: #ececec;
|
|
2485
|
+
border: 1px solid #2c2c2e; border-radius: 10px; font: inherit; font-size: 13px;
|
|
2486
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .12em; }
|
|
2487
|
+
.wpass:focus { outline: none; border-color: #6ab0ff; }
|
|
2488
|
+
.wgo { margin-top: 10px; width: 100%; padding: 9px; border: 0; border-radius: 10px;
|
|
2489
|
+
background: #b8f240; color: #000; font: inherit; font-weight: 600; cursor: pointer; }
|
|
2490
|
+
.wgo[disabled] { background: #2c2c2e; color: #6f7080; cursor: default; }
|
|
1479
2491
|
/* Slash autocomplete. Anchored above the composer because the composer sits
|
|
1480
2492
|
at the bottom of the viewport — a dropdown BELOW it would render off
|
|
1481
2493
|
screen. */
|
|
@@ -1549,6 +2561,37 @@ const APP_HTML = `<!doctype html>
|
|
|
1549
2561
|
.md-table th { background: #1c1c1e; font-weight: 600; }
|
|
1550
2562
|
.bubble-images { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
|
1551
2563
|
.bubble-images img { max-width: 160px; max-height: 160px; border-radius: 12px; display: block; }
|
|
2564
|
+
|
|
2565
|
+
/* COPY AFFORDANCES.
|
|
2566
|
+
Hidden until the row is hovered so they never compete with the text, but
|
|
2567
|
+
always in the DOM — a button that only exists on hover is unreachable by
|
|
2568
|
+
keyboard, so :focus-within reveals them too and each one is tabbable. */
|
|
2569
|
+
.row { position: relative; }
|
|
2570
|
+
.copybtn {
|
|
2571
|
+
position: absolute; top: 2px; opacity: 0; pointer-events: none;
|
|
2572
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
2573
|
+
padding: 3px 8px; border: 1px solid #3a3a3d; border-radius: 8px;
|
|
2574
|
+
background: #1c1c1e; color: #b9b9c0;
|
|
2575
|
+
font: 500 10.5px/1.4 ui-sans-serif, -apple-system, system-ui, sans-serif;
|
|
2576
|
+
letter-spacing: .02em; cursor: pointer;
|
|
2577
|
+
transition: opacity .12s ease, background .12s ease, color .12s ease;
|
|
2578
|
+
}
|
|
2579
|
+
.row.bot .copybtn { right: -2px; }
|
|
2580
|
+
.row.user .copybtn { left: -2px; }
|
|
2581
|
+
.row:hover .copybtn, .row:focus-within .copybtn { opacity: 1; pointer-events: auto; }
|
|
2582
|
+
.copybtn:hover { background: #2a2a2d; color: #f0f0eb; }
|
|
2583
|
+
.copybtn:focus-visible { opacity: 1; pointer-events: auto; outline: 2px solid #b8f240; outline-offset: 2px; }
|
|
2584
|
+
.copybtn.ok { background: #b8f240; border-color: #b8f240; color: #0b0b0d; }
|
|
2585
|
+
|
|
2586
|
+
/* Code blocks and RUN output get their own button, pinned inside the block —
|
|
2587
|
+
copying one command out of a long reply is the common case, and selecting
|
|
2588
|
+
it by hand in a scrolling <pre> is exactly what people fail at. */
|
|
2589
|
+
.md-pre, .runoutput, .runcmd { position: relative; }
|
|
2590
|
+
.md-pre .copybtn, .runoutput .copybtn, .runcmd .copybtn { top: 6px; right: 6px; left: auto; }
|
|
2591
|
+
.md-pre:hover .copybtn, .runoutput:hover .copybtn, .runcmd:hover .copybtn,
|
|
2592
|
+
.md-pre:focus-within .copybtn, .runoutput:focus-within .copybtn, .runcmd:focus-within .copybtn {
|
|
2593
|
+
opacity: 1; pointer-events: auto;
|
|
2594
|
+
}
|
|
1552
2595
|
.runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px;
|
|
1553
2596
|
max-width: 100%; min-width: 0; overflow: hidden; }
|
|
1554
2597
|
.runcmd { font-family: Menlo, monospace; font-size: 12.5px; color: #ececec; white-space: pre-wrap;
|
|
@@ -1655,6 +2698,23 @@ const APP_HTML = `<!doctype html>
|
|
|
1655
2698
|
<div id="composeFoot"><span><kbd>Tab</kbd> add</span><span><kbd>Enter</kbd> open</span></div>
|
|
1656
2699
|
</div>
|
|
1657
2700
|
</div>
|
|
2701
|
+
<div id="assetsOverlay" data-component="assets-modal">
|
|
2702
|
+
<div id="walletBox">
|
|
2703
|
+
<h3>Unlock baked assets</h3>
|
|
2704
|
+
<div class="wsub">ProofFront ships encrypted inside this box's image. Your passphrase decrypts it here; it is never stored.</div>
|
|
2705
|
+
<input class="wpass" id="assetsPass" type="password" autocomplete="off" spellcheck="false" placeholder="passphrase">
|
|
2706
|
+
<button class="wgo" id="assetsGo" data-testid="assets-decrypt">decrypt</button>
|
|
2707
|
+
<div class="wnote" id="assetsMsg"></div>
|
|
2708
|
+
<div class="wnote">Once unlocked the files are served like anything else in the workspace, and this box's :8080 has no authentication — the lock protects the public image, not a running box whose id someone knows.</div>
|
|
2709
|
+
</div>
|
|
2710
|
+
</div>
|
|
2711
|
+
<div id="walletOverlay" data-component="wallet-modal">
|
|
2712
|
+
<div id="walletBox">
|
|
2713
|
+
<h3>This box's wallet</h3>
|
|
2714
|
+
<div class="wsub">Every model call is paid from here, per call. No account, no API key.</div>
|
|
2715
|
+
<div id="walletBody">loading…</div>
|
|
2716
|
+
</div>
|
|
2717
|
+
</div>
|
|
1658
2718
|
<div id="main">
|
|
1659
2719
|
<div id="chatHeader">
|
|
1660
2720
|
<div id="chatHeaderId"></div>
|
|
@@ -1664,6 +2724,32 @@ const APP_HTML = `<!doctype html>
|
|
|
1664
2724
|
<button class="modebtn auto" id="modeAuto" data-mode="auto"
|
|
1665
2725
|
title="Shell commands run immediately, with no approval prompt">auto</button>
|
|
1666
2726
|
</div>
|
|
2727
|
+
<select class="dial" id="tierSel" data-component="model-tier" aria-label="Model tier"
|
|
2728
|
+
title="How much to spend per turn when no model is pinned">
|
|
2729
|
+
<option value="cheap">cheap</option>
|
|
2730
|
+
<option value="medium" selected>medium</option>
|
|
2731
|
+
<option value="expensive">expensive</option>
|
|
2732
|
+
</select>
|
|
2733
|
+
<select class="dial" id="raceSel" data-component="model-race" aria-label="Race models"
|
|
2734
|
+
title="Ask N models from the tier at once, drawn at random — fastest real answer wins. You pay for every entrant.">
|
|
2735
|
+
<option value="0" selected>1 model</option>
|
|
2736
|
+
<optgroup label="first back wins">
|
|
2737
|
+
<option value="2">race 2</option>
|
|
2738
|
+
<option value="3">race 3</option>
|
|
2739
|
+
<option value="4">race 4</option>
|
|
2740
|
+
</optgroup>
|
|
2741
|
+
<optgroup label="judge the first k back">
|
|
2742
|
+
<option value="2 3">best 2 of 3</option>
|
|
2743
|
+
<option value="2 4">best 2 of 4</option>
|
|
2744
|
+
<option value="3 4">best 3 of 4</option>
|
|
2745
|
+
<option value="4 4">best 4 of 4</option>
|
|
2746
|
+
</optgroup>
|
|
2747
|
+
</select>
|
|
2748
|
+
<button class="dial" id="assetsBtn" data-component="assets-unlock"
|
|
2749
|
+
title="Unlock the encrypted assets baked into this box's image">assets</button>
|
|
2750
|
+
<button class="dial" id="walletBtn" data-component="wallet-open"
|
|
2751
|
+
title="This box's own wallet — deposit addresses and live balances">wallet</button>
|
|
2752
|
+
<button class="icon-btn" id="reloadBtn" title="Restart grokui on this box">↻</button>
|
|
1667
2753
|
<button class="icon-btn" id="hudBtn">◎</button>
|
|
1668
2754
|
</div>
|
|
1669
2755
|
<div id="hud">
|
|
@@ -1709,28 +2795,153 @@ const APP_HTML = `<!doctype html>
|
|
|
1709
2795
|
const log = document.getElementById('log');
|
|
1710
2796
|
const inp = document.getElementById('inp');
|
|
1711
2797
|
const send = document.getElementById('send');
|
|
2798
|
+
// WHERE OUR OWN API LIVES.
|
|
2799
|
+
//
|
|
2800
|
+
// This UI is served from TWO places: the box root (the RunPod proxy,
|
|
2801
|
+
// https://<pod>-8080.proxy.runpod.net/) and behind a path prefix on the site
|
|
2802
|
+
// (openzoo.fun/api/box/go/<pod>/). Every fetch here was root-absolute —
|
|
2803
|
+
// fetch(API + '/threads') — which is correct at the root and wrong behind a prefix,
|
|
2804
|
+
// where it resolves to openzoo.fun/threads and 404s. MEASURED in a clean
|
|
2805
|
+
// browser: the page loads, and the sidebar is empty forever.
|
|
2806
|
+
//
|
|
2807
|
+
// A <base href> does NOT fix this. base only affects RELATIVE urls; a leading
|
|
2808
|
+
// slash is root-absolute and ignores it entirely. So derive the prefix from
|
|
2809
|
+
// the path we were actually served under and put it in front of every call.
|
|
2810
|
+
// NO REGEX HERE. Backslash escapes inside the APP_HTML template literal are
|
|
2811
|
+
// consumed before the browser ever sees them, so /^\/api\// arrives as
|
|
2812
|
+
// /^/api// — "Invalid regular expression flags", and the whole script dies.
|
|
2813
|
+
// That is the same class of bug that shipped a dead UI in v1.5.22. Plain
|
|
2814
|
+
// string ops cannot be mangled that way.
|
|
2815
|
+
const API = location.pathname.startsWith('/api/box/go/')
|
|
2816
|
+
? location.pathname.split('/').slice(0, 5).join('/')
|
|
2817
|
+
: '';
|
|
1712
2818
|
let activeId = null;
|
|
1713
2819
|
let knownThreads = [];
|
|
1714
2820
|
|
|
1715
2821
|
function initials(name) { return name.slice(0, 2).toUpperCase(); }
|
|
1716
2822
|
|
|
2823
|
+
// SEARCH. The input existed with no handler at all — typing in it did
|
|
2824
|
+
// nothing, which is worse than not shipping it. Debounced because every
|
|
2825
|
+
// keystroke otherwise walks every message of every thread server-side.
|
|
2826
|
+
let searchHits = null; // null = not searching; [] = searched, no hits
|
|
2827
|
+
let searchTimer = null;
|
|
2828
|
+
const searchEl = document.getElementById('search');
|
|
2829
|
+
if (searchEl) {
|
|
2830
|
+
searchEl.addEventListener('input', () => {
|
|
2831
|
+
clearTimeout(searchTimer);
|
|
2832
|
+
const q = searchEl.value.trim();
|
|
2833
|
+
if (!q) { searchHits = null; loadThreads(); return; }
|
|
2834
|
+
searchTimer = setTimeout(async () => {
|
|
2835
|
+
try {
|
|
2836
|
+
searchHits = await (await fetch(API + '/search?q=' + encodeURIComponent(q))).json();
|
|
2837
|
+
} catch (e) { searchHits = []; }
|
|
2838
|
+
loadThreads();
|
|
2839
|
+
}, 180);
|
|
2840
|
+
});
|
|
2841
|
+
searchEl.addEventListener('keydown', (e) => {
|
|
2842
|
+
if (e.key === 'Escape') { searchEl.value = ''; searchHits = null; loadThreads(); }
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
|
|
1717
2846
|
async function loadThreads() {
|
|
1718
|
-
const list = await (await fetch('/threads')).json();
|
|
2847
|
+
const list = await (await fetch(API + '/threads')).json();
|
|
2848
|
+
// When a search is active, show ONLY matches, ordered by hit count, and
|
|
2849
|
+
// replace the preview with the matching line — the point of a search is
|
|
2850
|
+
// seeing WHY something matched, not just that it did.
|
|
2851
|
+
const hitById = searchHits ? new Map(searchHits.map((h) => [h.id, h])) : null;
|
|
1719
2852
|
knownThreads = list;
|
|
1720
2853
|
if (!activeId && list.length) activeId = list[0].id;
|
|
1721
2854
|
threadsEl.innerHTML = '';
|
|
1722
|
-
|
|
2855
|
+
const shown = hitById
|
|
2856
|
+
? list.filter((t) => hitById.has(t.id))
|
|
2857
|
+
.sort((a, b) => (hitById.get(b.id).hits || 0) - (hitById.get(a.id).hits || 0))
|
|
2858
|
+
: list;
|
|
2859
|
+
if (hitById && !shown.length) {
|
|
2860
|
+
const empty = document.createElement('div');
|
|
2861
|
+
empty.className = 'tprev';
|
|
2862
|
+
empty.style.cssText = 'padding:14px 12px;color:#6f7080';
|
|
2863
|
+
empty.textContent = 'no messages match';
|
|
2864
|
+
threadsEl.appendChild(empty);
|
|
2865
|
+
}
|
|
2866
|
+
// How many bots share each root, so a header can be drawn only where there
|
|
2867
|
+
// is actually a project. Counted over the FULL list, not the filtered one —
|
|
2868
|
+
// a search that matches two of a project's nine bots should still say nine.
|
|
2869
|
+
const crewSize = new Map();
|
|
2870
|
+
for (const t of list) crewSize.set(t.rootId, (crewSize.get(t.rootId) || 0) + 1);
|
|
2871
|
+
|
|
2872
|
+
let lastRoot = null;
|
|
2873
|
+
for (const t of shown) {
|
|
2874
|
+
// PROJECT HEADER + PING ALL. /all and /ping existed but only as typed
|
|
2875
|
+
// commands, which meant the feature was invisible: you had to know it was
|
|
2876
|
+
// there. This is the button.
|
|
2877
|
+
if (!hitById && t.depth === 0 && (crewSize.get(t.rootId) || 1) > 1 && t.rootId !== lastRoot) {
|
|
2878
|
+
const n = crewSize.get(t.rootId);
|
|
2879
|
+
const head = document.createElement('div');
|
|
2880
|
+
head.className = 'prow';
|
|
2881
|
+
head.innerHTML = '<span class="pname">' + escapeHtml(t.name) + '</span>' +
|
|
2882
|
+
'<span class="pcount">' + n + '</span>';
|
|
2883
|
+
threadsEl.appendChild(head);
|
|
2884
|
+
lastRoot = t.rootId;
|
|
2885
|
+
}
|
|
1723
2886
|
const row = document.createElement('div');
|
|
1724
2887
|
row.className = 'trow' + (t.id === activeId ? ' active' : '');
|
|
2888
|
+
// SPAWN HIERARCHY. the parent id was always on every thread and nothing ever
|
|
2889
|
+
// rendered it, so fifteen agents looked like fifteen unrelated bots when
|
|
2890
|
+
// twelve of them were one project. Indent by depth; a subagent is visibly
|
|
2891
|
+
// a subagent. Capped at 4 so a deep tree cannot squeeze the name column
|
|
2892
|
+
// to nothing.
|
|
2893
|
+
if (t.depth) row.style.paddingLeft = (10 + Math.min(t.depth, 4) * 12) + 'px';
|
|
2894
|
+
if (t.depth) row.title = 'spawned under ' + (t.rootName || 'a parent');
|
|
1725
2895
|
row.innerHTML = '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
|
|
1726
2896
|
'<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
|
|
1727
|
-
(t.
|
|
1728
|
-
|
|
2897
|
+
(hitById && hitById.get(t.id) && hitById.get(t.id).snippet
|
|
2898
|
+
? hitById.get(t.id).snippet
|
|
2899
|
+
: t.awaitingUser ? 'waiting for you' : t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
|
|
2900
|
+
// awaitingUser WINS over thinking: a thread blocked on an approval is
|
|
2901
|
+
// NOT working, and showing a working indicator there is a lie that
|
|
2902
|
+
// quietly costs you a subagent nobody knows is stuck.
|
|
2903
|
+
(t.awaitingUser ? '<div class="twarn" title="Waiting for your approval"></div>'
|
|
2904
|
+
: t.status === 'thinking' ? '<div class="tdot"></div>' : '') +
|
|
2905
|
+
// PING ALL BELONGS TO WHOEVER HAS A CREW. It used to sit only on the
|
|
2906
|
+
// project header, so a mid-level owner could not reach its own
|
|
2907
|
+
// subagents without retyping — and pressing it addressed the WHOLE
|
|
2908
|
+
// project, cousins included. Now every thread with descendants gets
|
|
2909
|
+
// one, scoped to its own branch.
|
|
2910
|
+
(t.kids ? '<button class="pingall trow-ping" data-testid="ping-all" title="Message all '
|
|
2911
|
+
+ t.kids + ' bot(s) below ' + escapeHtml(t.name) + '">\u21f2 ' + t.kids + '</button>' : '') +
|
|
1729
2912
|
'<button class="tclose" title="Remove">✕</button>';
|
|
1730
|
-
row.addEventListener('click', () => {
|
|
2913
|
+
row.addEventListener('click', () => {
|
|
2914
|
+
activeId = t.id;
|
|
2915
|
+
render();
|
|
2916
|
+
// Selecting a bot means you intend to talk to it. Landing focus in the
|
|
2917
|
+
// composer saves a second click every single time, and on mobile it is
|
|
2918
|
+
// what raises the keyboard at all.
|
|
2919
|
+
requestAnimationFrame(() => { try { inp.focus(); } catch (e) {} });
|
|
2920
|
+
});
|
|
2921
|
+
const pingBtn = row.querySelector('.trow-ping');
|
|
2922
|
+
if (pingBtn) {
|
|
2923
|
+
pingBtn.addEventListener('click', async (e) => {
|
|
2924
|
+
e.stopPropagation();
|
|
2925
|
+
const msg = prompt('Send to all ' + t.kids + ' bot(s) below ' + t.name + ':');
|
|
2926
|
+
// Empty is a cancel — sending "" would spend a paid turn on every
|
|
2927
|
+
// bot in the branch for nothing.
|
|
2928
|
+
if (msg === null || !msg.trim()) return;
|
|
2929
|
+
pingBtn.disabled = true;
|
|
2930
|
+
const was = pingBtn.textContent;
|
|
2931
|
+
pingBtn.textContent = '…';
|
|
2932
|
+
try {
|
|
2933
|
+
// Routed through THIS thread, so /all scopes to its own subtree.
|
|
2934
|
+
await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
2935
|
+
body: JSON.stringify({ threadId: t.id, task: '/all ' + msg.trim() }) });
|
|
2936
|
+
pingBtn.textContent = 'sent';
|
|
2937
|
+
} catch (err) { pingBtn.textContent = 'failed'; }
|
|
2938
|
+
setTimeout(() => { pingBtn.disabled = false; pingBtn.textContent = was; }, 1500);
|
|
2939
|
+
await loadThreads();
|
|
2940
|
+
});
|
|
2941
|
+
}
|
|
1731
2942
|
row.querySelector('.tclose').addEventListener('click', async (e) => {
|
|
1732
2943
|
e.stopPropagation();
|
|
1733
|
-
await fetch('/threads/' + t.id, { method: 'DELETE' });
|
|
2944
|
+
await fetch(API + '/threads/' + t.id, { method: 'DELETE' });
|
|
1734
2945
|
if (activeId === t.id) activeId = null;
|
|
1735
2946
|
await loadThreads();
|
|
1736
2947
|
if (activeId) render();
|
|
@@ -1741,7 +2952,7 @@ const APP_HTML = `<!doctype html>
|
|
|
1741
2952
|
|
|
1742
2953
|
async function loadActiveMessages() {
|
|
1743
2954
|
if (!activeId) return null;
|
|
1744
|
-
return await (await fetch('/threads/' + activeId)).json();
|
|
2955
|
+
return await (await fetch(API + '/threads/' + activeId)).json();
|
|
1745
2956
|
}
|
|
1746
2957
|
|
|
1747
2958
|
function renderHeader(t) {
|
|
@@ -1750,8 +2961,171 @@ const APP_HTML = `<!doctype html>
|
|
|
1750
2961
|
'<div class="hname"><div>' + t.name + '</div><div class="hdir" title="' + escapeHtml(t.dir || '') +
|
|
1751
2962
|
'">' + escapeHtml(t.dir || '') + ' · type /dir <path> to change</div></div>';
|
|
1752
2963
|
setModeButtons(t.runMode || 'ask');
|
|
2964
|
+
setDials(t);
|
|
1753
2965
|
}
|
|
1754
2966
|
|
|
2967
|
+
// Same rule as the mode toggle: reflect the SERVER's value, never track it
|
|
2968
|
+
// client-side. Both dials are also settable by typing /tier and /race, so a
|
|
2969
|
+
// local copy would drift the moment anyone used the chat path.
|
|
2970
|
+
function setDials(t) {
|
|
2971
|
+
const tierSel = document.getElementById('tierSel');
|
|
2972
|
+
const raceSel = document.getElementById('raceSel');
|
|
2973
|
+
if (!tierSel || !raceSel) return;
|
|
2974
|
+
tierSel.value = t.tier || 'medium';
|
|
2975
|
+
raceSel.value = (t.race || 0) < 2 ? '0'
|
|
2976
|
+
: ((t.raceNeed || 1) > 1 ? t.raceNeed + ' ' + t.race : String(t.race));
|
|
2977
|
+
// A pinned /model makes BOTH dials inert. Showing them live while they do
|
|
2978
|
+
// nothing is the kind of lie that costs an hour — grey them out and say why
|
|
2979
|
+
// on hover, rather than letting someone set "expensive" and wonder why the
|
|
2980
|
+
// answers never changed.
|
|
2981
|
+
const pinned = Boolean(t.model);
|
|
2982
|
+
for (const el of [tierSel, raceSel]) {
|
|
2983
|
+
el.disabled = pinned;
|
|
2984
|
+
el.className = 'dial' + (pinned ? ' pinned' : '');
|
|
2985
|
+
el.title = pinned
|
|
2986
|
+
? t.model + ' is pinned on this thread with /model, so the tier and race are ignored. Run /model default to free them.'
|
|
2987
|
+
: el === tierSel
|
|
2988
|
+
? 'How much to spend per turn when no model is pinned'
|
|
2989
|
+
: 'Ask N models from the tier at once, drawn at random — fastest real answer wins. You pay for every entrant.';
|
|
2990
|
+
}
|
|
2991
|
+
if (!pinned && (t.tier === 'expensive' || (t.race || 0) >= 2)) {
|
|
2992
|
+
if (t.tier === 'expensive') tierSel.className = 'dial hot';
|
|
2993
|
+
if ((t.race || 0) >= 2) raceSel.className = 'dial hot';
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
async function setDial(cmd, value) {
|
|
2998
|
+
if (!activeId) return;
|
|
2999
|
+
// Reuses the SAME slash-command path, so there is one implementation of the
|
|
3000
|
+
// rule rather than a second that can disagree with it.
|
|
3001
|
+
await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
3002
|
+
body: JSON.stringify({ threadId: activeId, task: '/' + cmd + ' ' + value }) });
|
|
3003
|
+
await loadThreads();
|
|
3004
|
+
await render();
|
|
3005
|
+
}
|
|
3006
|
+
// WALLET MODAL.
|
|
3007
|
+
//
|
|
3008
|
+
// Public addresses and balances only — /wallet proxies the proxy's own
|
|
3009
|
+
// endpoint, which never exposes a key. Nothing here can move funds, and it
|
|
3010
|
+
// must stay that way: this UI is served on a box reachable from a public
|
|
3011
|
+
// *.proxy.runpod.net URL.
|
|
3012
|
+
const walletOverlay = document.getElementById('walletOverlay');
|
|
3013
|
+
const walletBody = document.getElementById('walletBody');
|
|
3014
|
+
function walletRow(label, addr) {
|
|
3015
|
+
const row = document.createElement('div');
|
|
3016
|
+
row.className = 'wrow';
|
|
3017
|
+
row.title = 'Click to copy';
|
|
3018
|
+
const l = document.createElement('div'); l.className = 'wlab'; l.textContent = label;
|
|
3019
|
+
const a = document.createElement('div'); a.className = 'waddr'; a.textContent = addr;
|
|
3020
|
+
const c = document.createElement('div'); c.className = 'wcopy'; c.textContent = 'copy';
|
|
3021
|
+
row.append(l, a, c);
|
|
3022
|
+
// The whole row, not a 20px target. Copying a deposit address by hand is
|
|
3023
|
+
// how funds go to the wrong chain.
|
|
3024
|
+
row.addEventListener('click', async () => {
|
|
3025
|
+
const ok = await copyText(addr);
|
|
3026
|
+
c.textContent = ok ? 'copied' : 'select it';
|
|
3027
|
+
setTimeout(() => { c.textContent = 'copy'; }, 1400);
|
|
3028
|
+
});
|
|
3029
|
+
return row;
|
|
3030
|
+
}
|
|
3031
|
+
async function openWallet() {
|
|
3032
|
+
walletOverlay.classList.add('show');
|
|
3033
|
+
walletBody.textContent = 'loading…';
|
|
3034
|
+
let w = null;
|
|
3035
|
+
try {
|
|
3036
|
+
const r = await fetch(API + '/wallet');
|
|
3037
|
+
// fetch does NOT reject on 4xx/5xx, and an older proxy returns an error
|
|
3038
|
+
// body that parses fine into undefined fields — check ok AND the fields.
|
|
3039
|
+
w = r.ok ? await r.json() : null;
|
|
3040
|
+
} catch (e) { w = null; }
|
|
3041
|
+
walletBody.innerHTML = '';
|
|
3042
|
+
if (!w || (!w.solana && !w.evm)) {
|
|
3043
|
+
const p = document.createElement('div');
|
|
3044
|
+
p.className = 'wnote wempty';
|
|
3045
|
+
p.textContent = 'Could not reach the local openzoo proxy on :8402. It may still be starting — try again in a few seconds.';
|
|
3046
|
+
walletBody.appendChild(p);
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
if (w.solana) walletBody.appendChild(walletRow('Solana', w.solana));
|
|
3050
|
+
if (w.evm) walletBody.appendChild(walletRow('Base / RH', w.evm));
|
|
3051
|
+
if (w.balances) {
|
|
3052
|
+
const b = document.createElement('div');
|
|
3053
|
+
b.className = 'wbal';
|
|
3054
|
+
b.textContent = w.balances;
|
|
3055
|
+
walletBody.appendChild(b);
|
|
3056
|
+
}
|
|
3057
|
+
const note = document.createElement('div');
|
|
3058
|
+
note.className = 'wnote';
|
|
3059
|
+
// funded === false is the genuinely-empty case. Undefined means the proxy
|
|
3060
|
+
// did not say, and guessing "empty" there would send someone to top up a
|
|
3061
|
+
// wallet that is fine.
|
|
3062
|
+
note.textContent = w.funded === false
|
|
3063
|
+
? 'This wallet is EMPTY — calls will fail with HTTP 402 until it is funded. ' + (w.funding || '')
|
|
3064
|
+
: (w.funding || '');
|
|
3065
|
+
if (w.funded === false) note.classList.add('wempty');
|
|
3066
|
+
if (note.textContent.trim()) walletBody.appendChild(note);
|
|
3067
|
+
}
|
|
3068
|
+
// ASSETS UNLOCK. The passphrase lives in the input and in one fetch body and
|
|
3069
|
+
// nowhere else — not in a variable that outlives the call, not in
|
|
3070
|
+
// localStorage, and the field is cleared the moment it succeeds.
|
|
3071
|
+
const assetsOverlay = document.getElementById('assetsOverlay');
|
|
3072
|
+
const assetsPass = document.getElementById('assetsPass');
|
|
3073
|
+
const assetsGo = document.getElementById('assetsGo');
|
|
3074
|
+
const assetsMsg = document.getElementById('assetsMsg');
|
|
3075
|
+
async function unlockAssets() {
|
|
3076
|
+
const pass = assetsPass.value;
|
|
3077
|
+
if (!pass) { assetsMsg.textContent = 'enter the passphrase'; return; }
|
|
3078
|
+
assetsGo.disabled = true;
|
|
3079
|
+
assetsGo.textContent = 'decrypting…';
|
|
3080
|
+
assetsMsg.textContent = 'this takes a minute — 800MB';
|
|
3081
|
+
try {
|
|
3082
|
+
const r = await fetch(API + '/decrypt-assets', {
|
|
3083
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
3084
|
+
body: JSON.stringify({ pass }),
|
|
3085
|
+
});
|
|
3086
|
+
const j = await r.json().catch(() => ({}));
|
|
3087
|
+
if (j.ok) {
|
|
3088
|
+
assetsPass.value = ''; // do not leave it sitting in the DOM
|
|
3089
|
+
assetsMsg.textContent = j.already ? 'already unlocked' : ('unlocked — ' + j.files + ' files in /workspace/prooffront');
|
|
3090
|
+
assetsMsg.className = 'wnote';
|
|
3091
|
+
} else {
|
|
3092
|
+
assetsMsg.textContent = j.error || ('failed (HTTP ' + r.status + ')');
|
|
3093
|
+
assetsMsg.className = 'wnote wempty';
|
|
3094
|
+
}
|
|
3095
|
+
} catch (e) {
|
|
3096
|
+
assetsMsg.textContent = 'could not reach the box';
|
|
3097
|
+
assetsMsg.className = 'wnote wempty';
|
|
3098
|
+
}
|
|
3099
|
+
assetsGo.disabled = false;
|
|
3100
|
+
assetsGo.textContent = 'decrypt';
|
|
3101
|
+
}
|
|
3102
|
+
assetsGo.addEventListener('click', unlockAssets);
|
|
3103
|
+
assetsPass.addEventListener('keydown', (e) => { if (e.key === 'Enter') unlockAssets(); });
|
|
3104
|
+
document.getElementById('assetsBtn').addEventListener('click', () => {
|
|
3105
|
+
assetsOverlay.classList.add('show');
|
|
3106
|
+
assetsMsg.textContent = '';
|
|
3107
|
+
assetsMsg.className = 'wnote';
|
|
3108
|
+
requestAnimationFrame(() => { try { assetsPass.focus(); } catch (e) {} });
|
|
3109
|
+
});
|
|
3110
|
+
assetsOverlay.addEventListener('click', (e) => {
|
|
3111
|
+
// Clear on dismiss so a passphrase never lingers behind a closed modal.
|
|
3112
|
+
if (e.target === assetsOverlay) { assetsPass.value = ''; assetsOverlay.classList.remove('show'); }
|
|
3113
|
+
});
|
|
3114
|
+
|
|
3115
|
+
document.getElementById('walletBtn').addEventListener('click', openWallet);
|
|
3116
|
+
// Click-outside and Escape both close it — a modal you can only dismiss one
|
|
3117
|
+
// way is a modal people get stuck in.
|
|
3118
|
+
walletOverlay.addEventListener('click', (e) => {
|
|
3119
|
+
if (e.target === walletOverlay) walletOverlay.classList.remove('show');
|
|
3120
|
+
});
|
|
3121
|
+
document.addEventListener('keydown', (e) => {
|
|
3122
|
+
if (e.key === 'Escape' && walletOverlay.classList.contains('show')) walletOverlay.classList.remove('show');
|
|
3123
|
+
if (e.key === 'Escape' && assetsOverlay.classList.contains('show')) { assetsPass.value = ''; assetsOverlay.classList.remove('show'); }
|
|
3124
|
+
});
|
|
3125
|
+
|
|
3126
|
+
document.getElementById('tierSel').addEventListener('change', (e) => setDial('tier', e.target.value));
|
|
3127
|
+
document.getElementById('raceSel').addEventListener('change', (e) => setDial('race', e.target.value));
|
|
3128
|
+
|
|
1755
3129
|
// The toggle reflects the SERVER's value rather than local state — the mode
|
|
1756
3130
|
// is per-thread and also settable by typing "/mode auto", so anything that
|
|
1757
3131
|
// tracked it client-side would drift the moment either path was used.
|
|
@@ -1765,7 +3139,7 @@ const APP_HTML = `<!doctype html>
|
|
|
1765
3139
|
setModeButtons(mode); // optimistic: the click should feel instant
|
|
1766
3140
|
// Reuses the SAME "/mode" path the chat command takes, so there is one
|
|
1767
3141
|
// implementation of the rule rather than a second one that can disagree.
|
|
1768
|
-
await fetch('/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
3142
|
+
await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1769
3143
|
body: JSON.stringify({ threadId: activeId, task: '/mode ' + mode }) });
|
|
1770
3144
|
await loadThreads(); // refresh runMode + the confirmation line /mode appends
|
|
1771
3145
|
await render();
|
|
@@ -1792,6 +3166,63 @@ const APP_HTML = `<!doctype html>
|
|
|
1792
3166
|
// Block-level markdown: fenced code, tables, headings, lists. Models answer
|
|
1793
3167
|
// in markdown by default, and rendering it as literal "## " and "| --- |"
|
|
1794
3168
|
// made every structured answer unreadable.
|
|
3169
|
+
/* Copy the given text, and say so. Returns true on success.
|
|
3170
|
+
|
|
3171
|
+
navigator.clipboard is NOT always available: it requires a secure context,
|
|
3172
|
+
and grokui binds 0.0.0.0 inside a box, so reaching it by LAN IP (rather
|
|
3173
|
+
than localhost or the RunPod https proxy) is plain http — where the API is
|
|
3174
|
+
simply undefined. Falling back to execCommand keeps copy working there
|
|
3175
|
+
instead of silently doing nothing, which is the worst outcome for a button
|
|
3176
|
+
whose entire job is invisible. */
|
|
3177
|
+
async function copyText(text) {
|
|
3178
|
+
try {
|
|
3179
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
3180
|
+
await navigator.clipboard.writeText(text);
|
|
3181
|
+
return true;
|
|
3182
|
+
}
|
|
3183
|
+
} catch (e) { /* fall through to the legacy path */ }
|
|
3184
|
+
try {
|
|
3185
|
+
const ta = document.createElement('textarea');
|
|
3186
|
+
ta.value = text;
|
|
3187
|
+
// Off-screen but focusable: display:none or visibility:hidden make
|
|
3188
|
+
// execCommand('copy') a no-op in several browsers.
|
|
3189
|
+
ta.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0';
|
|
3190
|
+
ta.setAttribute('readonly', '');
|
|
3191
|
+
document.body.appendChild(ta);
|
|
3192
|
+
ta.select();
|
|
3193
|
+
ta.setSelectionRange(0, ta.value.length); // iOS needs the explicit range
|
|
3194
|
+
const ok = document.execCommand('copy');
|
|
3195
|
+
document.body.removeChild(ta);
|
|
3196
|
+
return ok;
|
|
3197
|
+
} catch (e) { return false; }
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
/* A copy button bound to a getter, not a value — RUN output and streaming
|
|
3201
|
+
replies grow after the button is created, and a captured string would copy
|
|
3202
|
+
a stale prefix. */
|
|
3203
|
+
function copyBtn(getText, label) {
|
|
3204
|
+
const b = document.createElement('button');
|
|
3205
|
+
b.className = 'copybtn';
|
|
3206
|
+
b.type = 'button';
|
|
3207
|
+
b.textContent = label || 'copy';
|
|
3208
|
+
b.title = 'Copy to clipboard';
|
|
3209
|
+
b.addEventListener('click', async (e) => {
|
|
3210
|
+
e.stopPropagation();
|
|
3211
|
+
const ok = await copyText(String(getText() ?? ''));
|
|
3212
|
+
b.textContent = ok ? 'copied' : 'press ⌘C';
|
|
3213
|
+
b.classList.toggle('ok', ok);
|
|
3214
|
+
if (!ok) {
|
|
3215
|
+
// Last resort: select it so the keyboard shortcut works.
|
|
3216
|
+
const sel = window.getSelection();
|
|
3217
|
+
const r = document.createRange();
|
|
3218
|
+
r.selectNodeContents(b.closest('.bubble, .runcard, .md-pre') || b);
|
|
3219
|
+
sel.removeAllRanges(); sel.addRange(r);
|
|
3220
|
+
}
|
|
3221
|
+
setTimeout(() => { b.textContent = label || 'copy'; b.classList.remove('ok'); }, 1400);
|
|
3222
|
+
});
|
|
3223
|
+
return b;
|
|
3224
|
+
}
|
|
3225
|
+
|
|
1795
3226
|
function renderMentions(text) {
|
|
1796
3227
|
const fences = [];
|
|
1797
3228
|
const src = String(text).replace(/\`\`\`([\\w-]*)\\n?([\\s\\S]*?)\`\`\`/g, (m, lang, code) => {
|
|
@@ -1866,6 +3297,9 @@ const APP_HTML = `<!doctype html>
|
|
|
1866
3297
|
const cmdEl = document.createElement('div');
|
|
1867
3298
|
cmdEl.className = 'runcmd';
|
|
1868
3299
|
cmdEl.textContent = '$ ' + text;
|
|
3300
|
+
// Copy WITHOUT the '$ ' prompt — pasting that into a shell is a syntax
|
|
3301
|
+
// error, and this is the single most re-run thing in the UI.
|
|
3302
|
+
cmdEl.appendChild(copyBtn(() => text, 'copy'));
|
|
1869
3303
|
card.appendChild(cmdEl);
|
|
1870
3304
|
if (run.status === 'pending') {
|
|
1871
3305
|
const actions = document.createElement('div');
|
|
@@ -1878,12 +3312,12 @@ const APP_HTML = `<!doctype html>
|
|
|
1878
3312
|
deny.textContent = 'Deny';
|
|
1879
3313
|
approve.addEventListener('click', async () => {
|
|
1880
3314
|
approve.disabled = true; deny.disabled = true;
|
|
1881
|
-
await fetch('/threads/' + activeId + '/run/' + run.id + '/approve', { method: 'POST' });
|
|
3315
|
+
await fetch(API + '/threads/' + activeId + '/run/' + run.id + '/approve', { method: 'POST' });
|
|
1882
3316
|
render();
|
|
1883
3317
|
});
|
|
1884
3318
|
deny.addEventListener('click', async () => {
|
|
1885
3319
|
approve.disabled = true; deny.disabled = true;
|
|
1886
|
-
await fetch('/threads/' + activeId + '/run/' + run.id + '/deny', { method: 'POST' });
|
|
3320
|
+
await fetch(API + '/threads/' + activeId + '/run/' + run.id + '/deny', { method: 'POST' });
|
|
1887
3321
|
render();
|
|
1888
3322
|
});
|
|
1889
3323
|
actions.appendChild(approve);
|
|
@@ -1898,6 +3332,7 @@ const APP_HTML = `<!doctype html>
|
|
|
1898
3332
|
const out = document.createElement('pre');
|
|
1899
3333
|
out.className = 'runoutput';
|
|
1900
3334
|
out.textContent = run.output;
|
|
3335
|
+
out.appendChild(copyBtn(() => run.output, 'copy'));
|
|
1901
3336
|
card.appendChild(out);
|
|
1902
3337
|
}
|
|
1903
3338
|
}
|
|
@@ -1919,6 +3354,20 @@ const APP_HTML = `<!doctype html>
|
|
|
1919
3354
|
textEl.innerHTML = renderMentions(text);
|
|
1920
3355
|
bubble.appendChild(textEl);
|
|
1921
3356
|
row.appendChild(bubble);
|
|
3357
|
+
// Copy the message SOURCE, not rendered HTML — markdown, code fences and
|
|
3358
|
+
// directive lines are what people want back; innerText drops fences and
|
|
3359
|
+
// mangles indentation.
|
|
3360
|
+
row.appendChild(copyBtn(() => text, 'copy'));
|
|
3361
|
+
for (const pre of textEl.querySelectorAll('.md-pre, pre')) {
|
|
3362
|
+
pre.appendChild(copyBtn(() => {
|
|
3363
|
+
// No regex here on purpose: this string lives inside a template
|
|
3364
|
+
// literal, so an escape sequence is eaten before the browser sees
|
|
3365
|
+
// it. A literal newline inside /.../ is a SyntaxError that kills
|
|
3366
|
+
// the entire script — the sidebar renders empty and nothing works.
|
|
3367
|
+
const t = pre.innerText;
|
|
3368
|
+
return t.endsWith('copy') ? t.slice(0, -4).replace(/\\s+$/, '') : t;
|
|
3369
|
+
}, 'copy'));
|
|
3370
|
+
}
|
|
1922
3371
|
}
|
|
1923
3372
|
log.appendChild(row);
|
|
1924
3373
|
}
|
|
@@ -2046,7 +3495,7 @@ const APP_HTML = `<!doctype html>
|
|
|
2046
3495
|
pendingFiles = [];
|
|
2047
3496
|
pendingImages = [];
|
|
2048
3497
|
renderAttachChips();
|
|
2049
|
-
await fetch('/drive', {
|
|
3498
|
+
await fetch(API + '/drive', {
|
|
2050
3499
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
2051
3500
|
body: JSON.stringify({ threadId: activeId, task: full, images }),
|
|
2052
3501
|
});
|
|
@@ -2062,7 +3511,7 @@ const APP_HTML = `<!doctype html>
|
|
|
2062
3511
|
let slashCmds = [];
|
|
2063
3512
|
let slashHits = [];
|
|
2064
3513
|
let slashSel = 0;
|
|
2065
|
-
fetch('/slash-commands').then((r) => r.json()).then((c) => { slashCmds = c; }).catch(() => {});
|
|
3514
|
+
fetch(API + '/slash-commands').then((r) => r.json()).then((c) => { slashCmds = c; }).catch(() => {});
|
|
2066
3515
|
|
|
2067
3516
|
function slashOpen() { return slashMenu.classList.contains('show'); }
|
|
2068
3517
|
function renderSlash() {
|
|
@@ -2175,7 +3624,7 @@ const APP_HTML = `<!doctype html>
|
|
|
2175
3624
|
createRow.addEventListener('click', async () => {
|
|
2176
3625
|
const name = composeInp.value.trim() || prompt('Bot name?');
|
|
2177
3626
|
if (!name) return;
|
|
2178
|
-
const t = await (await fetch('/threads', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })).json();
|
|
3627
|
+
const t = await (await fetch(API + '/threads', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })).json();
|
|
2179
3628
|
activeId = t.id;
|
|
2180
3629
|
closeCompose();
|
|
2181
3630
|
await loadThreads(); await render();
|
|
@@ -2197,7 +3646,7 @@ const APP_HTML = `<!doctype html>
|
|
|
2197
3646
|
if (t) { activeId = t.id; closeCompose(); await loadThreads(); await render(); return; }
|
|
2198
3647
|
}
|
|
2199
3648
|
if (composeSel.length > 1) {
|
|
2200
|
-
const t = await (await fetch('/threads/group', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ names: composeSel.map((c) => c.name) }) })).json();
|
|
3649
|
+
const t = await (await fetch(API + '/threads/group', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ names: composeSel.map((c) => c.name) }) })).json();
|
|
2201
3650
|
activeId = t.id;
|
|
2202
3651
|
closeCompose();
|
|
2203
3652
|
await loadThreads(); await render();
|
|
@@ -2225,6 +3674,48 @@ const APP_HTML = `<!doctype html>
|
|
|
2225
3674
|
setInterval(tick, 1200);
|
|
2226
3675
|
|
|
2227
3676
|
// --- cost HUD (ported from the Hammerspoon menu-bar widget, same source) ---
|
|
3677
|
+
// RESTART GROKUI. box-boot supervises this process, so exiting IS the
|
|
3678
|
+
// restart. Useful when a box is wedged or has a newer grokui on disk: it
|
|
3679
|
+
// re-execs in seconds instead of costing a box respawn and a 349MB pull.
|
|
3680
|
+
// It cannot change the version baked into the IMAGE — only the site's spawn
|
|
3681
|
+
// path can — so it says restart, not update. Promising an upgrade it cannot
|
|
3682
|
+
// deliver is how a UI teaches people to distrust it.
|
|
3683
|
+
// Cmd/Ctrl+K -> search, the shortcut every chat app trains you to expect.
|
|
3684
|
+
// Escape returns focus to the composer instead of leaving you stranded in
|
|
3685
|
+
// a box you just cleared. Bound on keydown at the document so it works no
|
|
3686
|
+
// matter which pane has focus.
|
|
3687
|
+
document.addEventListener('keydown', (e) => {
|
|
3688
|
+
const k = (e.key || '').toLowerCase();
|
|
3689
|
+
if ((e.metaKey || e.ctrlKey) && k === 'k') {
|
|
3690
|
+
e.preventDefault();
|
|
3691
|
+
const el = document.getElementById('search');
|
|
3692
|
+
if (el) { el.focus(); el.select(); }
|
|
3693
|
+
return;
|
|
3694
|
+
}
|
|
3695
|
+
// Cmd/Ctrl+Enter sends from anywhere — useful when focus drifted into a
|
|
3696
|
+
// RUN card or a copy button mid-thought.
|
|
3697
|
+
if ((e.metaKey || e.ctrlKey) && k === 'enter') {
|
|
3698
|
+
e.preventDefault();
|
|
3699
|
+
try { submit(); } catch (err) { /* not ready */ }
|
|
3700
|
+
}
|
|
3701
|
+
});
|
|
3702
|
+
|
|
3703
|
+
const reloadBtn = document.getElementById('reloadBtn');
|
|
3704
|
+
if (reloadBtn) {
|
|
3705
|
+
reloadBtn.addEventListener('click', async () => {
|
|
3706
|
+
reloadBtn.disabled = true;
|
|
3707
|
+
reloadBtn.textContent = '\u2026';
|
|
3708
|
+
try { await fetch(API + '/restart', { method: 'POST' }); } catch (e) { /* exit races the reply */ }
|
|
3709
|
+
// Poll until it answers again — a fixed timeout either reloads into a
|
|
3710
|
+
// dead port or waits long after it is already back.
|
|
3711
|
+
for (let i = 0; i < 40; i++) {
|
|
3712
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
3713
|
+
try { const r = await fetch(API + '/threads', { cache: 'no-store' }); if (r.ok) break; } catch (e) { /* still down */ }
|
|
3714
|
+
}
|
|
3715
|
+
location.reload();
|
|
3716
|
+
});
|
|
3717
|
+
}
|
|
3718
|
+
|
|
2228
3719
|
const hudBtn = document.getElementById('hudBtn');
|
|
2229
3720
|
const hud = document.getElementById('hud');
|
|
2230
3721
|
function usd(n) {
|
|
@@ -2238,7 +3729,7 @@ const APP_HTML = `<!doctype html>
|
|
|
2238
3729
|
// fetched server-side by US (see /hud-summary below) — a renderer fetch
|
|
2239
3730
|
// straight to localhost:8402 would work fine, but routing it through
|
|
2240
3731
|
// our own backend keeps one fetch path if that ever needs to change.
|
|
2241
|
-
const you = await (await fetch('/hud-summary')).json();
|
|
3732
|
+
const you = await (await fetch(API + '/hud-summary')).json();
|
|
2242
3733
|
const spent = Number(you.spentUsd) || 0;
|
|
2243
3734
|
const cogs = Number(you.cogsUsd) || 0;
|
|
2244
3735
|
const direct = Number(you.directUsd) || 0;
|
|
@@ -2293,6 +3784,111 @@ const APP_HTML = `<!doctype html>
|
|
|
2293
3784
|
</body></html>`;
|
|
2294
3785
|
|
|
2295
3786
|
const server = http.createServer((req, res) => {
|
|
3787
|
+
// Deposit addresses for the wallet THIS box pays from. Server-side fetch for
|
|
3788
|
+
// the same reason /hud-summary is: the browser cannot reach 127.0.0.1:8402
|
|
3789
|
+
// inside the box. The proxy only ever returns PUBLIC addresses — the key
|
|
3790
|
+
// never leaves /root/.openzoo/wallet.json.
|
|
3791
|
+
if (req.method === 'GET' && req.url === '/wallet') {
|
|
3792
|
+
(async () => {
|
|
3793
|
+
let w = { error: 'proxy unreachable' };
|
|
3794
|
+
try { w = await (await fetch(`${PROXY}/wallet`)).json(); }
|
|
3795
|
+
catch { /* proxy not up yet — say so rather than render an empty modal */ }
|
|
3796
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
3797
|
+
res.end(JSON.stringify(w));
|
|
3798
|
+
})();
|
|
3799
|
+
return;
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
// UNLOCK THE BAKED ASSETS. The image ships ProofFront as ciphertext; this is
|
|
3803
|
+
// where the operator supplies the passphrase that opens it.
|
|
3804
|
+
//
|
|
3805
|
+
// The passphrase is used and dropped: passed to openssl via an ENV VAR (never
|
|
3806
|
+
// argv — a command line is world-readable in /proc), never written to disk,
|
|
3807
|
+
// never logged, never stored on the thread. A wrong one is reported as wrong
|
|
3808
|
+
// and nothing else happens.
|
|
3809
|
+
//
|
|
3810
|
+
// SAY WHAT THIS DOES: once decrypted the files are served by /api/files and
|
|
3811
|
+
// /site like anything else in the workspace, and :8080 has no authentication.
|
|
3812
|
+
// The lock protects the IMAGE, which is public. It does not protect a running
|
|
3813
|
+
// box from anyone who knows its pod id.
|
|
3814
|
+
if (req.method === 'POST' && req.url === '/decrypt-assets') {
|
|
3815
|
+
const chunks = [];
|
|
3816
|
+
req.on('data', (d) => chunks.push(d));
|
|
3817
|
+
req.on('end', () => {
|
|
3818
|
+
let pass = '';
|
|
3819
|
+
try { pass = String(JSON.parse(Buffer.concat(chunks).toString('utf8')).pass || ''); } catch { /* ignore */ }
|
|
3820
|
+
const done = (code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
|
|
3821
|
+
if (!pass) return done(400, { ok: false, error: 'no passphrase' });
|
|
3822
|
+
if (!existsSync('/opt/prooffront.enc')) return done(404, { ok: false, error: 'this image has no encrypted assets baked in' });
|
|
3823
|
+
if (existsSync('/workspace/prooffront')) return done(200, { ok: true, already: true, note: 'already unlocked' });
|
|
3824
|
+
// Same shape as box-boot: decrypt to temp, verify it is really a zip,
|
|
3825
|
+
// then unpack and swap. Never unzip unverified plaintext into /workspace.
|
|
3826
|
+
const script = 'set -e; '
|
|
3827
|
+
+ 'openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -in /opt/prooffront.enc -out /tmp/pf.zip -pass env:OZ_PF_PASS 2>/dev/null; '
|
|
3828
|
+
+ 'head -c 2 /tmp/pf.zip | grep -q PK; '
|
|
3829
|
+
+ 'rm -rf /workspace/.pf.tmp; mkdir -p /workspace/.pf.tmp; '
|
|
3830
|
+
+ 'unzip -q /tmp/pf.zip -d /workspace/.pf.tmp; '
|
|
3831
|
+
+ 'mv /workspace/.pf.tmp /workspace/prooffront; '
|
|
3832
|
+
+ 'rm -f /tmp/pf.zip; '
|
|
3833
|
+
+ 'find /workspace/prooffront -type f | wc -l';
|
|
3834
|
+
execFile('bash', ['-lc', script], { env: { ...process.env, OZ_PF_PASS: pass }, timeout: 600000, maxBuffer: 1 << 20 },
|
|
3835
|
+
(err, stdout) => {
|
|
3836
|
+
// Wipe the copy we were handed as soon as openssl is done with it.
|
|
3837
|
+
pass = '';
|
|
3838
|
+
if (err) return done(200, { ok: false, error: 'wrong passphrase, or the blob is corrupt' });
|
|
3839
|
+
done(200, { ok: true, files: Number(String(stdout).trim()) || 0 });
|
|
3840
|
+
});
|
|
3841
|
+
});
|
|
3842
|
+
return;
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
// Restart grokui in place — and ACTUALLY PICK UP THE NEW BUILD.
|
|
3846
|
+
//
|
|
3847
|
+
// Exiting is the restart: on a production box, box-server's ensureOz() poll
|
|
3848
|
+
// notices :4173 is closed and relaunches. (box-boot.sh does NOT supervise
|
|
3849
|
+
// grokui when OZ_UI_B64 is set — box-server owns it, and two supervisors is
|
|
3850
|
+
// the EADDRINUSE bug this file was already fighting.)
|
|
3851
|
+
//
|
|
3852
|
+
// But relaunching alone upgrades NOTHING, and that was the real bug. It runs
|
|
3853
|
+
// /workspace/.grokui/grokui.mjs, and box-server's seedFromImage() overwrites
|
|
3854
|
+
// that only when it is MISSING, under 1KB, or a saved 429 page — a perfectly
|
|
3855
|
+
// valid OLD copy is never replaced. /workspace is a persistent network
|
|
3856
|
+
// volume, so the file a box seeded on its very first boot survived every
|
|
3857
|
+
// restart and every fresh image pull, forever. Symptom: a new image lands,
|
|
3858
|
+
// the box restarts fine, and the UI is byte-identical to the day it spawned.
|
|
3859
|
+
//
|
|
3860
|
+
// So copy the baked build over the workspace copy HERE, before exiting.
|
|
3861
|
+
// Doing it in grokui rather than only in box-server is deliberate: box-server
|
|
3862
|
+
// is injected at spawn, so a fix there reaches new boxes only. This reaches
|
|
3863
|
+
// any box whose grokui can still serve one request.
|
|
3864
|
+
if (req.method === 'POST' && req.url === '/restart') {
|
|
3865
|
+
let seeded = null;
|
|
3866
|
+
try {
|
|
3867
|
+
// Only when they actually DIFFER — an unconditional copy would rewrite
|
|
3868
|
+
// the file on every restart and make "did it upgrade?" unanswerable.
|
|
3869
|
+
for (const f of ['grokui.mjs', 'podagent.mjs']) {
|
|
3870
|
+
const baked = `/opt/grokui/${f}`;
|
|
3871
|
+
const live = `/workspace/.grokui/${f}`;
|
|
3872
|
+
if (!existsSync(baked)) continue;
|
|
3873
|
+
const a = statSync(baked);
|
|
3874
|
+
const b = existsSync(live) ? statSync(live) : null;
|
|
3875
|
+
if (!b || a.size !== b.size) {
|
|
3876
|
+
mkdirSync('/workspace/.grokui', { recursive: true });
|
|
3877
|
+
copyFileSync(baked, live);
|
|
3878
|
+
seeded = (seeded || 0) + 1;
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
} catch (e) {
|
|
3882
|
+
// A read-only or missing /opt is a plain `docker run`, not a box. Restart
|
|
3883
|
+
// is still worth doing; just do not claim an upgrade that did not happen.
|
|
3884
|
+
seeded = null;
|
|
3885
|
+
}
|
|
3886
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
3887
|
+
res.end(JSON.stringify({ ok: true, restarting: true, upgraded: seeded || 0 }));
|
|
3888
|
+
setTimeout(() => process.exit(0), 150);
|
|
3889
|
+
return;
|
|
3890
|
+
}
|
|
3891
|
+
|
|
2296
3892
|
if (req.method === 'GET' && req.url === '/hud-summary') {
|
|
2297
3893
|
(async () => {
|
|
2298
3894
|
let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0 };
|
|
@@ -2338,9 +3934,41 @@ const server = http.createServer((req, res) => {
|
|
|
2338
3934
|
res.end(JSON.stringify(SLASH_COMMANDS));
|
|
2339
3935
|
return;
|
|
2340
3936
|
}
|
|
3937
|
+
// Search ACROSS MESSAGE BODIES, not just names. The sidebar only carries a
|
|
3938
|
+
// 60-char preview, so a client-side filter can only match what is already on
|
|
3939
|
+
// screen — useless for "which bot did the tetris contract". The server has
|
|
3940
|
+
// every message, so it does the work and returns a hit count plus the
|
|
3941
|
+
// matching line, and the client renders that instead of the preview.
|
|
3942
|
+
if (req.method === 'GET' && req.url.startsWith('/search')) {
|
|
3943
|
+
const q = (new URL(req.url, 'http://x').searchParams.get('q') || '').trim().toLowerCase();
|
|
3944
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
3945
|
+
if (!q) { res.end('[]'); return; }
|
|
3946
|
+
const out = [];
|
|
3947
|
+
for (const t of threads.values()) {
|
|
3948
|
+
let hits = 0;
|
|
3949
|
+
let snippet = '';
|
|
3950
|
+
for (const h of t.history) {
|
|
3951
|
+
const text = String(h.text || '');
|
|
3952
|
+
if (!text.toLowerCase().includes(q)) continue;
|
|
3953
|
+
hits += 1;
|
|
3954
|
+
if (!snippet) {
|
|
3955
|
+
// Centre the window on the match so the term is visible, rather than
|
|
3956
|
+
// returning the first 60 chars of a message that matched at char 900.
|
|
3957
|
+
const at = text.toLowerCase().indexOf(q);
|
|
3958
|
+
const from = Math.max(0, at - 24);
|
|
3959
|
+
snippet = (from ? '…' : '') + text.slice(from, from + 90).replace(/\s+/g, ' ');
|
|
3960
|
+
}
|
|
3961
|
+
}
|
|
3962
|
+
const nameHit = t.name.toLowerCase().includes(q);
|
|
3963
|
+
if (hits || nameHit) out.push({ id: t.id, name: t.name, color: t.color, hits, snippet });
|
|
3964
|
+
}
|
|
3965
|
+
out.sort((a, b) => b.hits - a.hits);
|
|
3966
|
+
res.end(JSON.stringify(out));
|
|
3967
|
+
return;
|
|
3968
|
+
}
|
|
2341
3969
|
if (req.method === 'GET' && req.url === '/threads') {
|
|
2342
3970
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
2343
|
-
res.end(JSON.stringify(
|
|
3971
|
+
res.end(JSON.stringify(orderedThreads().map(threadSummary)));
|
|
2344
3972
|
return;
|
|
2345
3973
|
}
|
|
2346
3974
|
if (req.method === 'GET' && req.url.startsWith('/threads/')) {
|