openzoo 0.49.4 → 0.49.6
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/README.md +7 -7
- package/lib/demo.js +4 -1
- package/lib/grokui.mjs +449 -26
- package/lib/gui.html +4 -1
- package/lib/livestatus.js +173 -4
- package/lib/mcp.js +3 -1
- package/lib/pay.js +3 -0
- package/lib/podagent.mjs +50 -12
- package/lib/proxy.js +114 -97
- package/lib/racesettle.js +78 -8
- package/lib/spill.js +168 -9
- package/lib/x402.js +13 -4
- package/package.json +3 -3
package/lib/grokui.mjs
CHANGED
|
@@ -24,6 +24,13 @@ import {
|
|
|
24
24
|
prepareChildDir, finishChildDir, lockWorktree, unlockWorktree,
|
|
25
25
|
parsePrRef, fetchSpecsForOrigin, agentSlug,
|
|
26
26
|
} from './worktree.mjs';
|
|
27
|
+
import {
|
|
28
|
+
filesForCorpus as collectFilesForCorpus,
|
|
29
|
+
readFilesForCorpus,
|
|
30
|
+
looksLikeFileView,
|
|
31
|
+
extractBashPaths,
|
|
32
|
+
} from './spill.js';
|
|
33
|
+
import { BIND_MIN_CHARS } from './hrr.js';
|
|
27
34
|
|
|
28
35
|
const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
|
|
29
36
|
// BIND HOST. Default 127.0.0.1 so the desktop app never exposes a shell-capable
|
|
@@ -391,8 +398,9 @@ comes back with content:null and finish_reason:"length" (confirmed live) if you
|
|
|
391
398
|
to append to the same bound context, rather than one giant request that silently fails partway.
|
|
392
399
|
|
|
393
400
|
COST ACCOUNTING — do NOT compute this yourself from token counts. Every response carries an
|
|
394
|
-
"x402" object; read the numbers off it: x402.billedUsd (what the user paid
|
|
395
|
-
(our upstream cost), x402.directUsd (what answering
|
|
401
|
+
"x402" object; read the numbers off it: x402.billedUsd (what the user paid — OpenRouter price, plus 33% of savings vs
|
|
402
|
+
direct when any), x402.cogsUsd (our upstream cost), x402.directUsd (what answering
|
|
403
|
+
WITHOUT the zoo would have cost), x402.savedUsd (dollars saved), and
|
|
396
404
|
x402.savesVsDirect (the multiple). Summing usage.cost or usage.prompt_tokens and comparing
|
|
397
405
|
that to a provider's list price is WRONG and understates the saving enormously: against a
|
|
398
406
|
bound context, prompt_tokens counts only the small slice leCore recalled, NOT the corpus
|
|
@@ -401,7 +409,7 @@ the zoo "cost more". MEASURED: a real 21-question run reported 202,238 prompt to
|
|
|
401
409
|
each attach call stood in for a 5,356,546-token corpus — a 556x understatement, and that
|
|
402
410
|
corpus is ~42x larger than the model's own context window, so the "direct" comparison it
|
|
403
411
|
was measured against was not merely pricier but IMPOSSIBLE. When the user asks what they
|
|
404
|
-
saved, quote x402.directUsd and x402.
|
|
412
|
+
saved, quote x402.billedUsd, x402.directUsd and x402.savedUsd. If savedUsd is 0 / savesVsDirect is below 1x, say so
|
|
405
413
|
plainly — that happens on small inputs, where the corpus is too small to save anything.
|
|
406
414
|
For normal questions just answer directly — do not use any of these unless the request
|
|
407
415
|
actually calls for delegation or file work.`;
|
|
@@ -431,6 +439,7 @@ function loadThreads() {
|
|
|
431
439
|
if (t.status === 'thinking') {
|
|
432
440
|
t.status = 'idle';
|
|
433
441
|
t.liveStatus = '';
|
|
442
|
+
t.liveRace = null;
|
|
434
443
|
}
|
|
435
444
|
if (Array.isArray(t.history)) {
|
|
436
445
|
for (const h of t.history) {
|
|
@@ -617,7 +626,7 @@ this is roleplay, and never fabricate command output or receipts. A real failure
|
|
|
617
626
|
as real output and an exit code, not as silence.
|
|
618
627
|
|
|
619
628
|
COST ACCOUNTING — read it off the response's "x402" object (billedUsd, cogsUsd, directUsd,
|
|
620
|
-
savesVsDirect). Never derive it by summing usage.cost or usage.prompt_tokens against a
|
|
629
|
+
savedUsd, savesVsDirect). Never derive it by summing usage.cost or usage.prompt_tokens against a
|
|
621
630
|
provider's list price: on a bound context prompt_tokens counts only the slice leCore
|
|
622
631
|
recalled, not the corpus it stands in for, so that math prices the discount against itself
|
|
623
632
|
and wrongly concludes the zoo cost more.
|
|
@@ -1058,6 +1067,7 @@ const SLASH_COMMANDS = [
|
|
|
1058
1067
|
{ name: '/models', args: '[filter]', help: 'search the ~435 served models' },
|
|
1059
1068
|
{ name: '/tier', args: 'cheap|medium|expensive|grok 4.6', help: 'how much to spend per turn when no model is pinned' },
|
|
1060
1069
|
{ name: '/race', args: '<n> | <k> <n>', help: 'launch n models; judge the first k back (k=1 = fastest wins)' },
|
|
1070
|
+
{ name: '/sitrep', args: '', help: 'session sitrep (drawer)' },
|
|
1061
1071
|
{ name: '/compact', args: '', help: 'summarise history to shrink context' },
|
|
1062
1072
|
{ name: '/clear', args: '', help: 'wipe this thread’s history' },
|
|
1063
1073
|
{ name: '/undo', args: '', help: 'drop the last exchange' },
|
|
@@ -1121,6 +1131,159 @@ function condense(label, text) {
|
|
|
1121
1131
|
return `${label}\n${head}\n\n…[${s.length - head.length - tail.length} chars elided from THIS message — the full output is bound to this thread's holographic memory. It is not lost: mention what you need in your next message and the relevant part is retrieved automatically. Do not invent a command to fetch it.]…\n\n${tail}`;
|
|
1122
1132
|
}
|
|
1123
1133
|
|
|
1134
|
+
// Files the grokui agent READ/WRITE/EDIT/MULTIEDIT/NOTEBOOK'd (and RUN
|
|
1135
|
+
// cat/head/type) must land in the HRR corpus as path-prefixed file parts —
|
|
1136
|
+
// not as unlabeled chat text via bindThread, and not as the ~3k condense()
|
|
1137
|
+
// stub the next completion sends. Sidecar maybeCacheCorpus only sees that
|
|
1138
|
+
// stub; bindThread never labels them as files. filesForCorpus (spill.js)
|
|
1139
|
+
// already dedups path:mtime and caps at KEEP_MAX (400KB). We reuse it.
|
|
1140
|
+
const boundFiles = new Set();
|
|
1141
|
+
|
|
1142
|
+
function pathToReadMsgs(paths) {
|
|
1143
|
+
return (Array.isArray(paths) ? paths : [paths]).filter(Boolean).map((file_path) => ({
|
|
1144
|
+
role: 'assistant',
|
|
1145
|
+
tool_calls: [{
|
|
1146
|
+
function: { name: 'Read', arguments: JSON.stringify({ file_path }) },
|
|
1147
|
+
}],
|
|
1148
|
+
}));
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function commandToBashMsgs(command) {
|
|
1152
|
+
return [{
|
|
1153
|
+
role: 'assistant',
|
|
1154
|
+
tool_calls: [{
|
|
1155
|
+
function: { name: 'Bash', arguments: JSON.stringify({ command }) },
|
|
1156
|
+
}],
|
|
1157
|
+
}];
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Collect grokui file reads/writes for the HRR corpus.
|
|
1162
|
+
*
|
|
1163
|
+
* Accepts an absolute/relative path, a list of paths, or already-shaped
|
|
1164
|
+
* chat messages (OpenAI tool_calls / Anthropic tool_use). Dedup and the
|
|
1165
|
+
* 400KB cap live in spill.js — this is the grokui entry point so READ
|
|
1166
|
+
* records the same way Claude Code Read does.
|
|
1167
|
+
*/
|
|
1168
|
+
function filesForCorpus(target, opts = {}) {
|
|
1169
|
+
const keys = opts.boundFiles || boundFiles;
|
|
1170
|
+
const cwd = opts.cwd || process.cwd();
|
|
1171
|
+
const cap = opts.cap ?? KEEP_MAX;
|
|
1172
|
+
let msgs = target;
|
|
1173
|
+
if (typeof target === 'string') msgs = pathToReadMsgs(target);
|
|
1174
|
+
else if (Array.isArray(target) && (target.length === 0 || typeof target[0] === 'string')) {
|
|
1175
|
+
msgs = pathToReadMsgs(target);
|
|
1176
|
+
}
|
|
1177
|
+
return collectFilesForCorpus(msgs, { ...opts, boundFiles: keys, cwd, cap });
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function filesForCorpusKeys() {
|
|
1181
|
+
return boundFiles;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function resetFilesForCorpus() {
|
|
1185
|
+
boundFiles.clear();
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function inFlightChars(t) {
|
|
1189
|
+
if (!t) return 0;
|
|
1190
|
+
if (Array.isArray(t.messages)) {
|
|
1191
|
+
let n = 0;
|
|
1192
|
+
for (const m of t.messages) {
|
|
1193
|
+
const c = m?.content;
|
|
1194
|
+
if (typeof c === 'string') n += c.length;
|
|
1195
|
+
else if (c != null) n += JSON.stringify(c).length;
|
|
1196
|
+
}
|
|
1197
|
+
return n;
|
|
1198
|
+
}
|
|
1199
|
+
let n = 0;
|
|
1200
|
+
for (const h of t.history || []) n += String(h?.text || '').length;
|
|
1201
|
+
return n;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* POST file bytes to ${PROXY}/hrr/bind, appending to the project context.
|
|
1206
|
+
* Always fire-and-forget so a READ does not wait on the bind round-trip.
|
|
1207
|
+
* When the in-flight corpus/messages are below BIND_MIN_CHARS the bind
|
|
1208
|
+
* still happens — reuse only "wins" on a big body, but the file must be
|
|
1209
|
+
* recallable next turn. Completions keep omitting x-hrr-context.
|
|
1210
|
+
*/
|
|
1211
|
+
function scheduleFilesForCorpus(t, collected, opts = {}) {
|
|
1212
|
+
if (!collected?.pending?.length) return null;
|
|
1213
|
+
const root = t ? (threads.get(rootOf(t).rootId) || t) : null;
|
|
1214
|
+
const ctx = opts.contextId || root?.contextId || t?.contextId || null;
|
|
1215
|
+
const chars = opts.sentChars ?? inFlightChars(t);
|
|
1216
|
+
const background = chars < BIND_MIN_CHARS;
|
|
1217
|
+
const fetchImpl = opts.fetchImpl || fetch;
|
|
1218
|
+
const run = () => {
|
|
1219
|
+
let read;
|
|
1220
|
+
try { read = readFilesForCorpus(collected, { boundFiles: opts.boundFiles || boundFiles, cap: opts.cap ?? KEEP_MAX }); }
|
|
1221
|
+
catch { return Promise.resolve(null); }
|
|
1222
|
+
if (!read?.text) return Promise.resolve(null);
|
|
1223
|
+
const payload = ctx ? { corpus: read.text, context_id: ctx } : { corpus: read.text };
|
|
1224
|
+
return fetchImpl(`${PROXY}/hrr/bind`, {
|
|
1225
|
+
method: 'POST',
|
|
1226
|
+
headers: { 'content-type': 'application/json' },
|
|
1227
|
+
body: JSON.stringify(payload),
|
|
1228
|
+
}).then(async (r) => {
|
|
1229
|
+
const j = await r.json().catch(() => ({}));
|
|
1230
|
+
if (j?.context_id && t) {
|
|
1231
|
+
const live = threads.get(rootOf(t).rootId) || t;
|
|
1232
|
+
live.contextId = j.context_id;
|
|
1233
|
+
t.contextId = j.context_id;
|
|
1234
|
+
if (Number(j.bound)) live.boundItems = (live.boundItems || 0) + Number(j.bound);
|
|
1235
|
+
}
|
|
1236
|
+
return j;
|
|
1237
|
+
}).catch(() => null);
|
|
1238
|
+
};
|
|
1239
|
+
const job = { pending: collected.pending, background, append: Boolean(ctx), run };
|
|
1240
|
+
const kick = () => { job.promise = run(); };
|
|
1241
|
+
if (opts.defer === false) kick();
|
|
1242
|
+
else setImmediate(kick);
|
|
1243
|
+
return job;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function noteFileForCorpus(originId, relOrAbs, extra = {}) {
|
|
1247
|
+
const t = extra.thread || (originId ? threads.get(originId) : null);
|
|
1248
|
+
const cwd = extra.cwd || (originId ? dirFor(originId) : process.cwd());
|
|
1249
|
+
let abs = relOrAbs;
|
|
1250
|
+
if (typeof abs === 'string' && !path.isAbsolute(abs)) {
|
|
1251
|
+
try { abs = originId ? safeResolveIn(cwd, abs) : path.resolve(cwd, abs); }
|
|
1252
|
+
catch { abs = path.resolve(cwd, abs); }
|
|
1253
|
+
}
|
|
1254
|
+
const collected = filesForCorpus(abs, {
|
|
1255
|
+
cwd,
|
|
1256
|
+
boundFiles: extra.boundFiles || boundFiles,
|
|
1257
|
+
cap: extra.cap ?? KEEP_MAX,
|
|
1258
|
+
...(extra.collectOpts || {}),
|
|
1259
|
+
});
|
|
1260
|
+
return scheduleFilesForCorpus(t, collected, extra);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function noteRunForCorpus(originId, command, extra = {}) {
|
|
1264
|
+
if (!looksLikeFileView(command)) return null;
|
|
1265
|
+
const t = extra.thread || (originId ? threads.get(originId) : null);
|
|
1266
|
+
const cwd = extra.cwd || (originId ? dirFor(originId) : process.cwd());
|
|
1267
|
+
const collected = filesForCorpus(commandToBashMsgs(command), {
|
|
1268
|
+
cwd,
|
|
1269
|
+
boundFiles: extra.boundFiles || boundFiles,
|
|
1270
|
+
cap: extra.cap ?? KEEP_MAX,
|
|
1271
|
+
});
|
|
1272
|
+
if (!collected.pending.length) {
|
|
1273
|
+
const { paths } = extractBashPaths(command, cwd);
|
|
1274
|
+
const abs = paths.map((p) => {
|
|
1275
|
+
const raw = p?.raw;
|
|
1276
|
+
if (!raw) return null;
|
|
1277
|
+
if (path.isAbsolute(raw)) return raw;
|
|
1278
|
+
try { return originId ? safeResolveIn(p.cwd || cwd, raw) : path.resolve(p.cwd || cwd, raw); }
|
|
1279
|
+
catch { return path.resolve(p.cwd || cwd, raw); }
|
|
1280
|
+
}).filter(Boolean);
|
|
1281
|
+
if (!abs.length) return null;
|
|
1282
|
+
return noteFileForCorpus(originId, abs, extra);
|
|
1283
|
+
}
|
|
1284
|
+
return scheduleFilesForCorpus(t, collected, extra);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1124
1287
|
// threadId -> open SSE responses. A Set because the same thread can be open in
|
|
1125
1288
|
// two tabs, and both should see the same tokens.
|
|
1126
1289
|
const streamListeners = new Map();
|
|
@@ -1150,6 +1313,7 @@ async function handleSlash(task, t) {
|
|
|
1150
1313
|
const cmd = m[1].toLowerCase();
|
|
1151
1314
|
const arg = m[2].trim();
|
|
1152
1315
|
|
|
1316
|
+
if (cmd === 'sitrep') return null; // drawer-only — never a transcript line
|
|
1153
1317
|
if (cmd === 'help') {
|
|
1154
1318
|
return 'Commands:\n'
|
|
1155
1319
|
+ SLASH_COMMANDS.map((c) => ` ${(c.name + ' ' + c.args).padEnd(26)} ${c.help}`).join('\n')
|
|
@@ -1448,6 +1612,7 @@ setInterval(() => {
|
|
|
1448
1612
|
} else {
|
|
1449
1613
|
t.status = 'idle';
|
|
1450
1614
|
t.liveStatus = '';
|
|
1615
|
+
t.liveRace = null;
|
|
1451
1616
|
unlockWorktree(t);
|
|
1452
1617
|
}
|
|
1453
1618
|
dirty = true;
|
|
@@ -2091,6 +2256,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
2091
2256
|
const full = safeResolveIn(dirFor(originId), rel);
|
|
2092
2257
|
mkdirSync(path.dirname(full), { recursive: true });
|
|
2093
2258
|
writeFileSync(full, content);
|
|
2259
|
+
noteFileForCorpus(originId, full);
|
|
2094
2260
|
return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}.${await previewAck(originId, rel)}`;
|
|
2095
2261
|
} catch (e) { return `Couldn't write ${rel}: ${e.message}`; }
|
|
2096
2262
|
}
|
|
@@ -2098,7 +2264,9 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
2098
2264
|
if (readD) {
|
|
2099
2265
|
const rel = readD[1].trim();
|
|
2100
2266
|
try {
|
|
2101
|
-
const
|
|
2267
|
+
const full = safeResolveIn(dirFor(originId), rel);
|
|
2268
|
+
const data = readFileSync(full, 'utf8');
|
|
2269
|
+
noteFileForCorpus(originId, full);
|
|
2102
2270
|
return `${rel}:\n${keepWhole(data)}`;
|
|
2103
2271
|
} catch (e) { return `Couldn't read ${rel}: ${e.message}`; }
|
|
2104
2272
|
}
|
|
@@ -2117,6 +2285,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
2117
2285
|
if (hits === 0) return `EDIT ${rel}: that exact text isn't in the file — READ it first, the copy must match byte for byte.`;
|
|
2118
2286
|
if (hits > 1) return `EDIT ${rel}: that text appears ${hits} times — include more surrounding context so it matches exactly once.`;
|
|
2119
2287
|
writeFileSync(full, before.replace(oldStr, newStr));
|
|
2288
|
+
noteFileForCorpus(originId, full);
|
|
2120
2289
|
return `Edited ${rel} (${before.length} -> ${before.replace(oldStr, newStr).length} bytes).${await previewAck(originId, rel)}`;
|
|
2121
2290
|
} catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
|
|
2122
2291
|
}
|
|
@@ -2143,6 +2312,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
2143
2312
|
applied.push(o.slice(0, 40));
|
|
2144
2313
|
}
|
|
2145
2314
|
writeFileSync(full, next);
|
|
2315
|
+
noteFileForCorpus(originId, full);
|
|
2146
2316
|
return `MULTIEDIT ${rel}: ${applied.length} edit(s) applied (${before.length} -> ${next.length} bytes).${await previewAck(originId, rel)}`;
|
|
2147
2317
|
} catch (e) { return `Couldn't multiedit ${rel}: ${e.message}`; }
|
|
2148
2318
|
}
|
|
@@ -2163,6 +2333,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
2163
2333
|
// Stale outputs next to new code are worse than none.
|
|
2164
2334
|
if (doc.cells[idx].cell_type === 'code') { doc.cells[idx].outputs = []; doc.cells[idx].execution_count = null; }
|
|
2165
2335
|
writeFileSync(full, JSON.stringify(doc, null, 1));
|
|
2336
|
+
noteFileForCorpus(originId, full);
|
|
2166
2337
|
return `NOTEBOOK ${rel}: replaced cell ${idx} (${doc.cells[idx].cell_type}); outputs cleared.`;
|
|
2167
2338
|
} catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
|
|
2168
2339
|
}
|
|
@@ -2448,11 +2619,12 @@ async function mcpDirective(url, tool, args) {
|
|
|
2448
2619
|
|
|
2449
2620
|
// onEvent (optional) gets live progress for whoever's actually watching this
|
|
2450
2621
|
// call: {type:'start',name,color} when a bot begins its turn, {type:'status',
|
|
2451
|
-
// detail} while paying / waiting / racing / walking tools, {type:'
|
|
2452
|
-
//
|
|
2453
|
-
// {type:'
|
|
2454
|
-
//
|
|
2455
|
-
//
|
|
2622
|
+
// detail} while paying / waiting / racing / walking tools, {type:'race',race}
|
|
2623
|
+
// for the spectator grid (one cell per launched model + a judging beat),
|
|
2624
|
+
// {type:'delta',name,color,delta} per streamed token (replace:true swaps the
|
|
2625
|
+
// bubble once), {type:'final',name,color,text} once its full reply (or
|
|
2626
|
+
// directive ack) is settled. Background turns go through kickTurn →
|
|
2627
|
+
// emitToThread, which is a no-op if nobody has the thread open.
|
|
2456
2628
|
async function runTurn(threadId, userText, onEvent, images) {
|
|
2457
2629
|
const t = threads.get(threadId);
|
|
2458
2630
|
if (!t) return;
|
|
@@ -2469,7 +2641,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2469
2641
|
const paint = (ev) => {
|
|
2470
2642
|
if (!stillMine()) return;
|
|
2471
2643
|
if (ev.type === 'status' && ev.detail && t.status === 'thinking') t.liveStatus = ev.detail;
|
|
2472
|
-
if (ev.type === '
|
|
2644
|
+
if (ev.type === 'race' && ev.race && t.status === 'thinking') t.liveRace = ev.race;
|
|
2645
|
+
if (ev.type === 'delta' || ev.type === 'status' || ev.type === 'start' || ev.type === 'race') t.lastDeltaAt = Date.now();
|
|
2473
2646
|
onEvent?.(ev);
|
|
2474
2647
|
};
|
|
2475
2648
|
t.history.push(images && images.length ? { who: 'user', text: userText, images } : { who: 'user', text: userText });
|
|
@@ -2483,6 +2656,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2483
2656
|
lockWorktree(t);
|
|
2484
2657
|
const raceN = Math.min(Number(t.race) || 0, 4);
|
|
2485
2658
|
const raceNeed = Math.min(Math.max(Number(t.raceNeed) || 1, 1), raceN || 1);
|
|
2659
|
+
t.liveRace = null;
|
|
2486
2660
|
t.liveStatus = (!t.model && raceN >= 2) ? formatRaceStatus(0, raceNeed) : 'waiting on model…';
|
|
2487
2661
|
let chained = false;
|
|
2488
2662
|
let parked = false;
|
|
@@ -2513,6 +2687,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2513
2687
|
if (t.runMode === 'auto') {
|
|
2514
2688
|
emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
|
|
2515
2689
|
const output = await execCommand(command, dirFor(t.id));
|
|
2690
|
+
noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
|
|
2516
2691
|
const shown = `$ ${command}\n${output}`;
|
|
2517
2692
|
t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
|
|
2518
2693
|
paint({ type: 'final', name: m.name, color: m.color, text: shown });
|
|
@@ -2604,6 +2779,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2604
2779
|
return (await brainRace(callMsgs, emit, t.contextId, models, need, undefined, emitStatus, {
|
|
2605
2780
|
signal: turnAbort.signal,
|
|
2606
2781
|
onArrivals: (arr) => { t.lastRaceFail = summarizeRaceFailures(arr); },
|
|
2782
|
+
onRace: (snap) => paint({ type: 'race', name: t.name, color: t.color, race: snap }),
|
|
2607
2783
|
tier: t.tier || 'medium',
|
|
2608
2784
|
})).trim();
|
|
2609
2785
|
}
|
|
@@ -2655,6 +2831,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2655
2831
|
if (t.runMode === 'auto') {
|
|
2656
2832
|
emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
|
|
2657
2833
|
const output = await execCommand(command, dirFor(t.id));
|
|
2834
|
+
noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
|
|
2658
2835
|
if (!stillMine()) return;
|
|
2659
2836
|
const shown = `$ ${command}\n${output}`;
|
|
2660
2837
|
t.history.push({ who: 'bot', text: shown });
|
|
@@ -2757,6 +2934,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2757
2934
|
} else if (!t.pendingRun) {
|
|
2758
2935
|
t.status = 'idle';
|
|
2759
2936
|
t.liveStatus = '';
|
|
2937
|
+
t.liveRace = null;
|
|
2760
2938
|
unlockWorktree(t);
|
|
2761
2939
|
}
|
|
2762
2940
|
}
|
|
@@ -3031,12 +3209,20 @@ const APP_HTML = `<!doctype html>
|
|
|
3031
3209
|
whose entire premise is that the box pays for itself. Click-to-copy is a
|
|
3032
3210
|
shortcut; the address is selectable so Cmd/Ctrl+C still works if copy
|
|
3033
3211
|
fails. */
|
|
3034
|
-
#walletOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.66); z-index: 1200;
|
|
3212
|
+
#walletOverlay, #sitrepOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.66); z-index: 1200;
|
|
3035
3213
|
display: none; align-items: center; justify-content: center; padding: 24px; }
|
|
3036
|
-
#walletOverlay.show { display: flex; }
|
|
3037
|
-
#walletBox { width: 100%; max-width: 560px; max-height: 82vh; overflow-y: auto; background: #111113;
|
|
3214
|
+
#walletOverlay.show, #sitrepOverlay.show { display: flex; }
|
|
3215
|
+
#walletBox, #sitrepBox { width: 100%; max-width: 560px; max-height: 82vh; overflow-y: auto; background: #111113;
|
|
3038
3216
|
border: 1px solid #2c2c2e; border-radius: 16px; padding: 20px 22px; }
|
|
3039
|
-
#walletBox h3 { margin: 0 0 2px; font-size: 15px; font-weight: 600; }
|
|
3217
|
+
#walletBox h3, #sitrepBox h3 { margin: 0 0 2px; font-size: 15px; font-weight: 600; }
|
|
3218
|
+
#sitrepBox { max-width: 420px; }
|
|
3219
|
+
.srow { display: flex; justify-content: space-between; align-items: baseline; gap: 14px;
|
|
3220
|
+
padding: 8px 0; border-bottom: 1px solid #1c1c1e; }
|
|
3221
|
+
.srow:last-child { border-bottom: 0; }
|
|
3222
|
+
.slab { color: #6f7080; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; flex: 0 0 auto; }
|
|
3223
|
+
.sval { color: #ececec; font-size: 13px; text-align: right; word-break: break-word; min-width: 0; }
|
|
3224
|
+
.sval.hlime { color: #b8f240; }
|
|
3225
|
+
.sval.hember { color: #f28c4d; }
|
|
3040
3226
|
.wsub { color: #8e8e93; font-size: 12px; margin-bottom: 16px; }
|
|
3041
3227
|
.wrow { border: 1px solid #1c1c1e; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px;
|
|
3042
3228
|
display: flex; align-items: center; gap: 10px; cursor: pointer; }
|
|
@@ -3121,7 +3307,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3121
3307
|
color: #f0c9a8; font-size: 10.5px; line-height: 1.45; }
|
|
3122
3308
|
#hud .hhint.show { display: block; }
|
|
3123
3309
|
#hud .hhint b { color: #f28c4d; font-weight: 600; }
|
|
3124
|
-
#sidebar, #main, #walletOverlay, #composeOverlay,
|
|
3310
|
+
#sidebar, #main, #walletOverlay, #sitrepOverlay, #composeOverlay,
|
|
3125
3311
|
#inp, #search, #composeInp, .bubble, .md-pre, .runoutput, .runcmd {
|
|
3126
3312
|
-webkit-app-region: no-drag; }
|
|
3127
3313
|
#log { flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 20px 24px 12px;
|
|
@@ -3223,6 +3409,46 @@ const APP_HTML = `<!doctype html>
|
|
|
3223
3409
|
@keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
|
|
3224
3410
|
.tstatus { color: #8e8e93; font-size: 13px; margin-left: 6px; }
|
|
3225
3411
|
.ttrail { display: block; color: #8e8e93; font-size: 12.5px; margin-top: 8px; }
|
|
3412
|
+
/* Race spectator board. Lives in the transcript, never over the header
|
|
3413
|
+
dials / wallet / cost HUD. 2×2 for four, a row for two, wrap otherwise. */
|
|
3414
|
+
.row.bot:has(.raceboard) { max-width: 92%; }
|
|
3415
|
+
.bubble.raceboard { padding: 10px 11px; background: #1a1a1d; }
|
|
3416
|
+
.racewrap { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
|
3417
|
+
.racecaption { color: #6f7080; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
|
|
3418
|
+
.racegrid { display: grid; gap: 7px; grid-template-columns: 1fr 1fr; }
|
|
3419
|
+
.racegrid.n1 { grid-template-columns: 1fr; }
|
|
3420
|
+
.racegrid.n2 { grid-template-columns: 1fr 1fr; }
|
|
3421
|
+
.racegrid.n3 { grid-template-columns: 1fr 1fr; }
|
|
3422
|
+
@media (min-width: 720px) { .racegrid.n3 { grid-template-columns: 1fr 1fr 1fr; } }
|
|
3423
|
+
.racecell { border: 1px solid #2c2c32; border-radius: 12px; padding: 8px 9px 7px;
|
|
3424
|
+
background: #141416; min-width: 0; min-height: 0; transition: opacity .18s ease, border-color .18s ease; }
|
|
3425
|
+
.racecell.streaming { border-color: #3d3d4a; }
|
|
3426
|
+
.racecell.back { border-color: #2a3a18; }
|
|
3427
|
+
.racecell.failed { border-color: #3a2424; }
|
|
3428
|
+
.racecell.abandoned { opacity: .42; }
|
|
3429
|
+
.racecell.winner { border-color: #b8f240; box-shadow: 0 0 0 1px rgba(184,242,64,.28); }
|
|
3430
|
+
.racehead { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; min-width: 0; }
|
|
3431
|
+
.racename { font-size: 12px; font-weight: 600; color: #ececec; white-space: nowrap;
|
|
3432
|
+
overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
3433
|
+
.racechip { flex: 0 0 auto; font-size: 10px; letter-spacing: .03em; color: #8e8e93;
|
|
3434
|
+
border: 1px solid #2c2c32; border-radius: 999px; padding: 1px 7px; }
|
|
3435
|
+
.racecell.streaming .racechip { color: #b8f240; border-color: #3a4a18; }
|
|
3436
|
+
.racecell.back .racechip { color: #b8f240; border-color: #2a3a18; }
|
|
3437
|
+
.racecell.failed .racechip { color: #f28c4d; border-color: #5a3020; }
|
|
3438
|
+
.racecell.abandoned .racechip { color: #6f7080; }
|
|
3439
|
+
.racecell.winner .racechip { color: #0b0b0d; background: #b8f240; border-color: #b8f240; }
|
|
3440
|
+
.raceprev { font: 11.5px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; color: #9a9aa6;
|
|
3441
|
+
white-space: pre-wrap; word-break: break-word; max-height: 8.2em; overflow: hidden; }
|
|
3442
|
+
.racefail { display: inline-block; margin-top: 2px; font-size: 11px; color: #f28c4d; letter-spacing: .03em; }
|
|
3443
|
+
.racejudge { border: 1px dashed #3a4a18; border-radius: 12px; padding: 9px 11px;
|
|
3444
|
+
background: rgba(184,242,64,.05); color: #c8c8b8; }
|
|
3445
|
+
.racejudge.won { border-style: solid; border-color: #b8f240; }
|
|
3446
|
+
.racejudge-lab { font-size: 10px; letter-spacing: .06em; text-transform: uppercase; color: #b8f240;
|
|
3447
|
+
margin-bottom: 3px; }
|
|
3448
|
+
.racejudge-msg { font-size: 13px; color: #ececec; }
|
|
3449
|
+
@media (prefers-reduced-motion: reduce) {
|
|
3450
|
+
.racecell { transition: none; }
|
|
3451
|
+
}
|
|
3226
3452
|
#bar { padding: 10px 16px 18px; position: relative; }
|
|
3227
3453
|
#row-input { display: flex; align-items: center; gap: 8px; }
|
|
3228
3454
|
#plusMenu { position: absolute; bottom: 62px; left: 16px; background: #1c1c1e; border-radius: 14px;
|
|
@@ -3354,6 +3580,13 @@ const APP_HTML = `<!doctype html>
|
|
|
3354
3580
|
</div>
|
|
3355
3581
|
</div>
|
|
3356
3582
|
</div>
|
|
3583
|
+
<div id="sitrepOverlay" data-component="sitrep-drawer">
|
|
3584
|
+
<div id="sitrepBox">
|
|
3585
|
+
<h3>Sitrep</h3>
|
|
3586
|
+
<div class="wsub">This thread and this session. No keys.</div>
|
|
3587
|
+
<div id="sitrepBody">loading…</div>
|
|
3588
|
+
</div>
|
|
3589
|
+
</div>
|
|
3357
3590
|
<div id="main">
|
|
3358
3591
|
<div id="chatHeader">
|
|
3359
3592
|
<div id="chatHeaderId"></div>
|
|
@@ -3373,17 +3606,17 @@ const APP_HTML = `<!doctype html>
|
|
|
3373
3606
|
</select>
|
|
3374
3607
|
<select class="dial" id="raceSel" data-component="model-race" aria-label="Race models"
|
|
3375
3608
|
title="Ask N models from the tier at once, drawn at random — fastest real answer wins. You pay for every entrant.">
|
|
3376
|
-
<option value="0" selected>1 model
|
|
3609
|
+
<option value="0" selected>1 model 0%</option>
|
|
3377
3610
|
<optgroup label="first back wins">
|
|
3378
|
-
<option value="2">race 2
|
|
3379
|
-
<option value="3">race 3
|
|
3380
|
-
<option value="4">race 4
|
|
3611
|
+
<option value="2">race 2 −50%</option>
|
|
3612
|
+
<option value="3">race 3 −67%</option>
|
|
3613
|
+
<option value="4">race 4 −75%</option>
|
|
3381
3614
|
</optgroup>
|
|
3382
3615
|
<optgroup label="judge the first k back">
|
|
3383
|
-
<option value="2 3">best 2 of 3
|
|
3384
|
-
<option value="2 4">best 2 of 4
|
|
3385
|
-
<option value="3 4">best 3 of 4
|
|
3386
|
-
<option value="4 4">best 4 of 4
|
|
3616
|
+
<option value="2 3">best 2 of 3 −67%</option>
|
|
3617
|
+
<option value="2 4">best 2 of 4 −75%</option>
|
|
3618
|
+
<option value="3 4">best 3 of 4 −75%</option>
|
|
3619
|
+
<option value="4 4">best 4 of 4 −75%</option>
|
|
3387
3620
|
</optgroup>
|
|
3388
3621
|
</select>
|
|
3389
3622
|
<button class="dial" id="walletBtn" data-component="wallet-open"
|
|
@@ -3411,6 +3644,10 @@ const APP_HTML = `<!doctype html>
|
|
|
3411
3644
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.19-9.19a3.5 3.5 0 0 1 4.95 4.95l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
|
3412
3645
|
<span>Attach files</span>
|
|
3413
3646
|
</div>
|
|
3647
|
+
<div class="pop-item" id="sitrepBtn">
|
|
3648
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="3" width="14" height="18" rx="2"/><line x1="8" y1="8" x2="16" y2="8"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="8" y1="16" x2="13" y2="16"/></svg>
|
|
3649
|
+
<span>Sitrep</span>
|
|
3650
|
+
</div>
|
|
3414
3651
|
</div>
|
|
3415
3652
|
<input id="fileInp" type="file" multiple style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;">
|
|
3416
3653
|
<div id="attachChips"></div>
|
|
@@ -4068,8 +4305,73 @@ const APP_HTML = `<!doctype html>
|
|
|
4068
4305
|
walletOverlay.addEventListener('click', (e) => {
|
|
4069
4306
|
if (e.target === walletOverlay) walletOverlay.classList.remove('show');
|
|
4070
4307
|
});
|
|
4308
|
+
const sitrepOverlay = document.getElementById('sitrepOverlay');
|
|
4309
|
+
const sitrepBody = document.getElementById('sitrepBody');
|
|
4310
|
+
function raceCutPct(y) {
|
|
4311
|
+
const n = Math.max(1, Number(y) || 1);
|
|
4312
|
+
return Math.round((1 - 1 / n) * 100);
|
|
4313
|
+
}
|
|
4314
|
+
function raceChoiceLabel(y, need) {
|
|
4315
|
+
const n = Math.max(1, Number(y) || 1);
|
|
4316
|
+
const k = Math.max(1, Math.min(Number(need) || 1, n));
|
|
4317
|
+
const cut = raceCutPct(n);
|
|
4318
|
+
const cutTxt = cut === 0 ? '0%' : ('−' + cut + '%');
|
|
4319
|
+
if (n < 2) return '1 model ' + cutTxt;
|
|
4320
|
+
if (k > 1) return 'best ' + k + ' of ' + n + ' ' + cutTxt;
|
|
4321
|
+
return 'race ' + n + ' ' + cutTxt;
|
|
4322
|
+
}
|
|
4323
|
+
function sitrepRow(lab, val, cls) {
|
|
4324
|
+
return '<div class="srow"><span class="slab">' + escapeHtml(lab) + '</span><span class="sval'
|
|
4325
|
+
+ (cls ? (' ' + cls) : '') + '">' + escapeHtml(val) + '</span></div>';
|
|
4326
|
+
}
|
|
4327
|
+
async function openSitrep() {
|
|
4328
|
+
sitrepOverlay.classList.add('show');
|
|
4329
|
+
sitrepBody.textContent = 'loading…';
|
|
4330
|
+
const t = knownThreads.find((x) => x.id === activeId) || {};
|
|
4331
|
+
let full = null;
|
|
4332
|
+
try { if (activeId) full = await (await fetch(API + '/threads/' + activeId)).json(); } catch (e) { full = null; }
|
|
4333
|
+
let you = {};
|
|
4334
|
+
try { you = await (await fetch(API + '/hud-summary')).json(); } catch (e) { you = {}; }
|
|
4335
|
+
const y = Number(t.race) || 0;
|
|
4336
|
+
const need = Number(t.raceNeed) || 1;
|
|
4337
|
+
const raceY = y >= 2 ? y : 1;
|
|
4338
|
+
const spent = Number(you.spentUsd) || 0;
|
|
4339
|
+
const cogs = Number(you.cogsUsd) || 0;
|
|
4340
|
+
const direct = Number(you.directUsd) || 0;
|
|
4341
|
+
const mult = spent > 0 ? direct / spent : null;
|
|
4342
|
+
const saved = mult == null ? '—'
|
|
4343
|
+
: ((mult >= 100 ? Math.round(mult) : mult.toFixed(mult >= 10 ? 1 : 2)) + 'x');
|
|
4344
|
+
const savedCls = mult == null ? '' : (mult >= 1 ? 'hlime' : 'hember');
|
|
4345
|
+
const thinking = (full && full.status === 'thinking') || t.status === 'thinking';
|
|
4346
|
+
const race = (full && full.liveRace) || null;
|
|
4347
|
+
let flight = 'idle';
|
|
4348
|
+
if (thinking && race && race.phase === 'judging') flight = 'classifier judging';
|
|
4349
|
+
else if (thinking && race && race.phase === 'winner') flight = 'winner';
|
|
4350
|
+
else if (thinking && (full && full.liveStatus)) flight = full.liveStatus;
|
|
4351
|
+
else if (thinking && t.liveStatus) flight = t.liveStatus;
|
|
4352
|
+
else if (thinking) flight = 'in flight';
|
|
4353
|
+
const cwd = (full && full.dir) || t.dir || '—';
|
|
4354
|
+
sitrepBody.innerHTML =
|
|
4355
|
+
sitrepRow('race', raceChoiceLabel(raceY, need))
|
|
4356
|
+
+ sitrepRow('band', t.tier || 'medium')
|
|
4357
|
+
+ sitrepRow('mode', t.runMode || 'ask')
|
|
4358
|
+
+ sitrepRow('cwd', cwd)
|
|
4359
|
+
+ sitrepRow('in flight', flight)
|
|
4360
|
+
+ '<div class="wlanetitle" style="margin-top:16px">this session</div>'
|
|
4361
|
+
+ sitrepRow('paid', '$' + (spent >= 0.01 || spent === 0 ? spent.toFixed(2) : spent.toFixed(5)))
|
|
4362
|
+
+ sitrepRow('cogs', '$' + (cogs >= 0.01 || cogs === 0 ? cogs.toFixed(2) : cogs.toFixed(5)))
|
|
4363
|
+
+ sitrepRow('direct', '$' + (direct >= 0.01 || direct === 0 ? direct.toFixed(2) : direct.toFixed(5)))
|
|
4364
|
+
+ sitrepRow('saved vs naked', saved, savedCls)
|
|
4365
|
+
+ sitrepRow('paid calls', String(you.paidCalls || 0))
|
|
4366
|
+
+ sitrepRow('prepaid', (Number(you.creditUsd) > 0) ? 'yes' : 'no');
|
|
4367
|
+
}
|
|
4368
|
+
function closeSitrep() { sitrepOverlay.classList.remove('show'); }
|
|
4369
|
+
sitrepOverlay.addEventListener('click', (e) => {
|
|
4370
|
+
if (e.target === sitrepOverlay) closeSitrep();
|
|
4371
|
+
});
|
|
4071
4372
|
document.addEventListener('keydown', (e) => {
|
|
4072
4373
|
if (e.key === 'Escape' && walletOverlay.classList.contains('show')) walletOverlay.classList.remove('show');
|
|
4374
|
+
if (e.key === 'Escape' && sitrepOverlay.classList.contains('show')) closeSitrep();
|
|
4073
4375
|
});
|
|
4074
4376
|
|
|
4075
4377
|
document.getElementById('tierSel').addEventListener('change', (e) => setDial('tier', e.target.value));
|
|
@@ -4611,6 +4913,12 @@ const APP_HTML = `<!doctype html>
|
|
|
4611
4913
|
}
|
|
4612
4914
|
if (full.status === 'thinking') {
|
|
4613
4915
|
if (full.liveStatus) streamStatus = full.liveStatus;
|
|
4916
|
+
if (full.liveRace && full.liveRace.racers && full.liveRace.racers.length >= 2) {
|
|
4917
|
+
streamRace = full.liveRace;
|
|
4918
|
+
streamRaceId = full.id;
|
|
4919
|
+
} else if (streamRaceId !== full.id) {
|
|
4920
|
+
streamRace = null;
|
|
4921
|
+
}
|
|
4614
4922
|
addRow('bot', streamBuf || '…', t.color, t.name);
|
|
4615
4923
|
// Tag the live bubble so deltas can repaint just this node instead of
|
|
4616
4924
|
// re-rendering (and re-fetching) the whole thread on every token.
|
|
@@ -4625,8 +4933,60 @@ const APP_HTML = `<!doctype html>
|
|
|
4625
4933
|
// so a turn showed "…" for its whole duration and then arrived in one lump.
|
|
4626
4934
|
let streamBuf = '';
|
|
4627
4935
|
let streamStatus = '';
|
|
4936
|
+
let streamRace = null;
|
|
4937
|
+
let streamRaceId = '';
|
|
4938
|
+
let raceHandoff = 0;
|
|
4628
4939
|
let es = null, esId = null;
|
|
4940
|
+
function raceIsLive(r) {
|
|
4941
|
+
return !!(r && r.racers && r.racers.length >= 2 && streamRaceId === activeId);
|
|
4942
|
+
}
|
|
4943
|
+
function shortRaceName(id) {
|
|
4944
|
+
const s = String(id || '');
|
|
4945
|
+
const i = s.lastIndexOf('/');
|
|
4946
|
+
return (i >= 0 ? s.slice(i + 1) : s) || 'model';
|
|
4947
|
+
}
|
|
4948
|
+
function raceGridHtml(race) {
|
|
4949
|
+
const racers = race.racers || [];
|
|
4950
|
+
const n = racers.length;
|
|
4951
|
+
const need = Math.max(1, Number(race.need) || 1);
|
|
4952
|
+
const caption = (need > 1 ? ('first ' + need + ' of ' + n) : (n + ' launched'))
|
|
4953
|
+
+ (race.recut ? ' · recut' : '');
|
|
4954
|
+
let cells = '';
|
|
4955
|
+
for (let i = 0; i < racers.length; i++) {
|
|
4956
|
+
const r = racers[i];
|
|
4957
|
+
const win = race.phase === 'winner' && race.winner && r.model === race.winner;
|
|
4958
|
+
const cls = 'racecell ' + (r.status || 'waiting') + (win ? ' winner' : '');
|
|
4959
|
+
const chip = win ? 'winner' : (r.status || 'waiting');
|
|
4960
|
+
let body = '';
|
|
4961
|
+
if (r.status === 'failed') {
|
|
4962
|
+
body = '<span class="racefail">' + escapeHtml(r.fail ? ('fail · ' + r.fail) : 'fail') + '</span>';
|
|
4963
|
+
} else if (r.preview) {
|
|
4964
|
+
body = '<div class="raceprev">' + escapeHtml(r.preview) + '</div>';
|
|
4965
|
+
} else if (r.status === 'abandoned') {
|
|
4966
|
+
body = '<div class="raceprev">abandoned</div>';
|
|
4967
|
+
} else {
|
|
4968
|
+
body = '<div class="raceprev"></div>';
|
|
4969
|
+
}
|
|
4970
|
+
cells += '<div class="' + cls + '"><div class="racehead"><span class="racename">'
|
|
4971
|
+
+ escapeHtml(r.short || shortRaceName(r.model)) + '</span><span class="racechip">'
|
|
4972
|
+
+ escapeHtml(chip) + '</span></div>' + body + '</div>';
|
|
4973
|
+
}
|
|
4974
|
+
let judge = '';
|
|
4975
|
+
if (need > 1 && (race.phase === 'judging' || race.phase === 'winner')) {
|
|
4976
|
+
const won = race.phase === 'winner' && race.winner;
|
|
4977
|
+
const msg = won
|
|
4978
|
+
? ('goes to ' + shortRaceName(race.winner))
|
|
4979
|
+
: ('looking at the ' + need + ' that made it back');
|
|
4980
|
+
judge = '<div class="racejudge' + (won ? ' won' : '') + '"><div class="racejudge-lab">classifier</div>'
|
|
4981
|
+
+ '<div class="racejudge-msg">' + escapeHtml(msg)
|
|
4982
|
+
+ (won ? '' : ' <span class="dots"><span></span><span></span><span></span></span>')
|
|
4983
|
+
+ '</div></div>';
|
|
4984
|
+
}
|
|
4985
|
+
return '<div class="racewrap"><div class="racecaption">' + escapeHtml(caption) + '</div>'
|
|
4986
|
+
+ '<div class="racegrid n' + n + '">' + cells + '</div>' + judge + '</div>';
|
|
4987
|
+
}
|
|
4629
4988
|
function liveBubbleHtml() {
|
|
4989
|
+
if (raceIsLive(streamRace)) return raceGridHtml(streamRace);
|
|
4630
4990
|
if (streamBuf) {
|
|
4631
4991
|
const trail = streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus)
|
|
4632
4992
|
? '<span class="ttrail">' + escapeHtml(streamStatus) + '</span>' : '';
|
|
@@ -4639,6 +4999,13 @@ const APP_HTML = `<!doctype html>
|
|
|
4639
4999
|
function paintStream() {
|
|
4640
5000
|
const b = document.getElementById('streamBubble');
|
|
4641
5001
|
if (!b) { render(); return; }
|
|
5002
|
+
if (raceIsLive(streamRace)) {
|
|
5003
|
+
b.classList.add('raceboard');
|
|
5004
|
+
b.innerHTML = liveBubbleHtml();
|
|
5005
|
+
if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
|
|
5006
|
+
return;
|
|
5007
|
+
}
|
|
5008
|
+
b.classList.remove('raceboard');
|
|
4642
5009
|
// Deltas stay as text; a silent wait paints dots + one mutating status
|
|
4643
5010
|
// line so a 20–40s pay/model wait is obviously alive.
|
|
4644
5011
|
if (streamBuf && !(streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus))) {
|
|
@@ -4654,17 +5021,50 @@ const APP_HTML = `<!doctype html>
|
|
|
4654
5021
|
esId = id;
|
|
4655
5022
|
streamBuf = '';
|
|
4656
5023
|
streamStatus = '';
|
|
5024
|
+
streamRace = null;
|
|
5025
|
+
streamRaceId = id;
|
|
5026
|
+
raceHandoff += 1;
|
|
4657
5027
|
es = new EventSource('/stream/' + id); // EventSource reconnects on its own
|
|
4658
5028
|
es.onmessage = (e) => {
|
|
4659
5029
|
let ev;
|
|
4660
5030
|
try { ev = JSON.parse(e.data); } catch { return; }
|
|
4661
|
-
if (ev.type === 'start') {
|
|
5031
|
+
if (ev.type === 'start') {
|
|
5032
|
+
streamBuf = '';
|
|
5033
|
+
streamStatus = ev.detail || 'waiting on model…';
|
|
5034
|
+
streamRace = null;
|
|
5035
|
+
streamRaceId = id;
|
|
5036
|
+
paintStream();
|
|
5037
|
+
}
|
|
4662
5038
|
else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
|
|
5039
|
+
else if (ev.type === 'race') {
|
|
5040
|
+
if (ev.race && ev.race.racers && ev.race.racers.length >= 2) {
|
|
5041
|
+
streamRace = ev.race;
|
|
5042
|
+
streamRaceId = id;
|
|
5043
|
+
}
|
|
5044
|
+
paintStream();
|
|
5045
|
+
}
|
|
4663
5046
|
else if (ev.type === 'delta') {
|
|
4664
5047
|
streamBuf = ev.replace ? (ev.delta || '') : streamBuf + (ev.delta || '');
|
|
4665
5048
|
paintStream();
|
|
4666
5049
|
}
|
|
4667
|
-
else if (ev.type === 'final' || ev.type === 'run-pending') {
|
|
5050
|
+
else if (ev.type === 'final' || ev.type === 'run-pending') {
|
|
5051
|
+
if (ev.type === 'final' && raceIsLive(streamRace)) {
|
|
5052
|
+
paintStream();
|
|
5053
|
+
const token = ++raceHandoff;
|
|
5054
|
+
setTimeout(function () {
|
|
5055
|
+
if (token !== raceHandoff) return;
|
|
5056
|
+
streamBuf = '';
|
|
5057
|
+
streamStatus = '';
|
|
5058
|
+
streamRace = null;
|
|
5059
|
+
render();
|
|
5060
|
+
}, 420);
|
|
5061
|
+
return;
|
|
5062
|
+
}
|
|
5063
|
+
streamBuf = '';
|
|
5064
|
+
streamStatus = '';
|
|
5065
|
+
streamRace = null;
|
|
5066
|
+
render();
|
|
5067
|
+
}
|
|
4668
5068
|
};
|
|
4669
5069
|
es.onerror = () => { /* EventSource retries; the 1.2s poll is the backstop */ };
|
|
4670
5070
|
}
|
|
@@ -4721,6 +5121,12 @@ const APP_HTML = `<!doctype html>
|
|
|
4721
5121
|
|
|
4722
5122
|
async function submit() {
|
|
4723
5123
|
const task = inp.value.trim();
|
|
5124
|
+
if (/^\/sitrep\b/i.test(task)) {
|
|
5125
|
+
inp.value = '';
|
|
5126
|
+
send.classList.remove('show');
|
|
5127
|
+
openSitrep();
|
|
5128
|
+
return;
|
|
5129
|
+
}
|
|
4724
5130
|
if ((!task && !pendingFiles.length && !pendingImages.length) || !activeId) return;
|
|
4725
5131
|
inp.value = '';
|
|
4726
5132
|
send.classList.remove('show');
|
|
@@ -4775,6 +5181,12 @@ const APP_HTML = `<!doctype html>
|
|
|
4775
5181
|
function slashAccept(i) {
|
|
4776
5182
|
const c = slashHits[i];
|
|
4777
5183
|
if (!c) return;
|
|
5184
|
+
if (c.name === '/sitrep') {
|
|
5185
|
+
inp.value = '';
|
|
5186
|
+
slashMenu.classList.remove('show');
|
|
5187
|
+
openSitrep();
|
|
5188
|
+
return;
|
|
5189
|
+
}
|
|
4778
5190
|
// Commands that take arguments keep the caret going; ones that don't are
|
|
4779
5191
|
// ready to send, so don't make the user delete a trailing space.
|
|
4780
5192
|
inp.value = c.name + (c.args ? ' ' : '');
|
|
@@ -4807,6 +5219,11 @@ const APP_HTML = `<!doctype html>
|
|
|
4807
5219
|
plusBtn.addEventListener('click', (e) => { e.stopPropagation(); plusMenu.classList.toggle('show'); });
|
|
4808
5220
|
document.addEventListener('click', () => plusMenu.classList.remove('show'));
|
|
4809
5221
|
document.getElementById('attachBtn').addEventListener('click', (e) => { e.stopPropagation(); plusMenu.classList.remove('show'); fileInp.click(); });
|
|
5222
|
+
document.getElementById('sitrepBtn').addEventListener('click', (e) => {
|
|
5223
|
+
e.stopPropagation();
|
|
5224
|
+
plusMenu.classList.remove('show');
|
|
5225
|
+
openSitrep();
|
|
5226
|
+
});
|
|
4810
5227
|
fileInp.addEventListener('change', async () => {
|
|
4811
5228
|
for (const f of Array.from(fileInp.files)) {
|
|
4812
5229
|
const looksText = /^text\\//.test(f.type) || /\\.(txt|md|js|mjs|ts|tsx|jsx|py|json|css|html|csv|log|ya?ml|sh)$/i.test(f.name);
|
|
@@ -5325,6 +5742,7 @@ const server = http.createServer((req, res) => {
|
|
|
5325
5742
|
res.end(t ? JSON.stringify({
|
|
5326
5743
|
id: t.id, history: t.history, status: t.status,
|
|
5327
5744
|
liveStatus: t.status === 'thinking' ? (t.liveStatus || '') : '',
|
|
5745
|
+
liveRace: t.status === 'thinking' ? (t.liveRace || null) : null,
|
|
5328
5746
|
lastRaceFail: t.lastRaceFail || null,
|
|
5329
5747
|
workspacePort: workspacePort || 0, dir: t.dir || WORKSPACE_DIR,
|
|
5330
5748
|
}) : '{}');
|
|
@@ -5365,6 +5783,7 @@ const server = http.createServer((req, res) => {
|
|
|
5365
5783
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
5366
5784
|
res.end('{"ok":true}');
|
|
5367
5785
|
execCommand(command, cwd).then((output) => {
|
|
5786
|
+
noteRunForCorpus(t.id, command, { cwd });
|
|
5368
5787
|
entry.runStatus = 'done';
|
|
5369
5788
|
entry.runOutput = output;
|
|
5370
5789
|
saveThreads();
|
|
@@ -5417,6 +5836,8 @@ const server = http.createServer((req, res) => {
|
|
|
5417
5836
|
// checking your spend or clearing a thread never costs anything.
|
|
5418
5837
|
// /dir and /mode keep their own handlers below, untouched.
|
|
5419
5838
|
if (t && /^\//.test(task.trim())) {
|
|
5839
|
+
// Drawer-only. Never dump sitrep into the transcript.
|
|
5840
|
+
if (/^\/sitrep\b/i.test(task.trim())) return;
|
|
5420
5841
|
const handled = await handleSlash(task.trim(), t).catch((e) => `error: ${e.message}`);
|
|
5421
5842
|
if (handled !== null && handled !== undefined) {
|
|
5422
5843
|
t.history.push({ who: 'bot', text: handled });
|
|
@@ -5471,4 +5892,6 @@ export {
|
|
|
5471
5892
|
isDoneReply, isTransientModelFail, isEmptyToolResult, enqueueAutoHop, childKickoff, findByName,
|
|
5472
5893
|
attachChildDir, finishChildDir,
|
|
5473
5894
|
lockWorktree, unlockWorktree, parsePrRef, fetchSpecsForOrigin, agentSlug,
|
|
5895
|
+
filesForCorpus, noteFileForCorpus, noteRunForCorpus, resetFilesForCorpus,
|
|
5896
|
+
filesForCorpusKeys, scheduleFilesForCorpus, inFlightChars, BIND_MIN_CHARS, KEEP_MAX,
|
|
5474
5897
|
};
|