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/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), x402.cogsUsd
395
- (our upstream cost), x402.directUsd (what answering WITHOUT the zoo would have cost), and
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.savesVsDirect. If savesVsDirect is below 1x, say so
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 data = readFileSync(safeResolveIn(dirFor(originId), rel), 'utf8');
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:'delta',name,
2452
- // color,delta} per streamed token (replace:true swaps the bubble once),
2453
- // {type:'final',name,color,text} once its full reply (or directive ack) is
2454
- // settled. Background turns go through kickTurn emitToThread, which is a
2455
- // no-op if nobody has the thread open.
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 === 'delta' || ev.type === 'status' || ev.type === 'start') t.lastDeltaAt = Date.now();
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</option>
3609
+ <option value="0" selected>1 model 0%</option>
3377
3610
  <optgroup label="first back wins">
3378
- <option value="2">race 2</option>
3379
- <option value="3">race 3</option>
3380
- <option value="4">race 4</option>
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</option>
3384
- <option value="2 4">best 2 of 4</option>
3385
- <option value="3 4">best 3 of 4</option>
3386
- <option value="4 4">best 4 of 4</option>
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') { streamBuf = ''; streamStatus = ev.detail || 'waiting on model…'; paintStream(); }
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') { streamBuf = ''; streamStatus = ''; render(); }
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
  };