openzoo 0.48.92 → 0.48.96
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 +126 -68
- package/lib/livestatus.js +128 -0
- package/lib/podagent.mjs +105 -43
- package/package.json +1 -1
package/lib/grokui.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSyn
|
|
|
12
12
|
import { cpus, homedir } from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES } from './podagent.mjs';
|
|
15
|
-
import { peekDirectiveStatus, STALE_THINKING_MS } from './livestatus.js';
|
|
15
|
+
import { peekDirectiveStatus, formatRaceStatus, STALE_THINKING_MS } from './livestatus.js';
|
|
16
16
|
import { creditBalance } from './info.js';
|
|
17
17
|
import {
|
|
18
18
|
SUBSCRIPTIONS_PAGE,
|
|
@@ -221,10 +221,12 @@ subtasks), you may instead reply with EXACTLY one line, no prose, using one of:
|
|
|
221
221
|
independent agent and give it a task
|
|
222
222
|
SEND: <name> | <message> message an agent thread that already
|
|
223
223
|
exists (yours or one you spawned)
|
|
224
|
-
PING: <name>
|
|
225
|
-
|
|
224
|
+
PING: <name> wake that agent to take a turn now
|
|
225
|
+
(* / all / everyone = the whole project).
|
|
226
|
+
You get back "pinged, working" — that is
|
|
227
|
+
an ack, not the child's result
|
|
226
228
|
PEEK: <name> a fuller look — its last few messages,
|
|
227
|
-
not just the latest one
|
|
229
|
+
not just the latest one. Read-only.
|
|
228
230
|
You are given the result before your next line, so none of these block you — check back
|
|
229
231
|
later if it's still working.
|
|
230
232
|
|
|
@@ -500,7 +502,7 @@ spawn/delegate/create agents AND no other bot has already done it this round, re
|
|
|
500
502
|
EXACTLY one line, no prose, using one of:
|
|
501
503
|
SPAWN: <short name> | <task for the new agent> create a new thread with its own agent
|
|
502
504
|
SEND: <name> | <message> message an existing agent thread
|
|
503
|
-
PING: <name>
|
|
505
|
+
PING: <name> wake that agent (* / all = the project)
|
|
504
506
|
PEEK: <name> a fuller look at its last few messages
|
|
505
507
|
|
|
506
508
|
You ALSO have real (sandboxed) filesystem access, scoped to THIS group's own directory —
|
|
@@ -661,6 +663,35 @@ const NUDGE = 'That reply announced work instead of doing it — no directive li
|
|
|
661
663
|
const AUTO_CONTINUE = 'AUTO is still on — do not stop and do not ask the user to type continue. '
|
|
662
664
|
+ 'Emit the next directive now (RUN:/SPAWN:/SEND:/READ:/WRITE:/GLOB:/FETCH:/MCP:/SERVE:), '
|
|
663
665
|
+ 'or DONE: if the job is actually finished.';
|
|
666
|
+
// PING used to be a read: last-line status, no turn. Idle children stayed idle
|
|
667
|
+
// while the parent treated the dump as evidence they had acted. A ping is a
|
|
668
|
+
// wake — the same harness continue AUTO already uses, unless a custom message
|
|
669
|
+
// was given. Thinking threads are left alone; pendingRun stays on the human.
|
|
670
|
+
let runTurnOverride = null;
|
|
671
|
+
function setRunTurnForTest(fn) {
|
|
672
|
+
runTurnOverride = typeof fn === 'function' ? fn : null;
|
|
673
|
+
}
|
|
674
|
+
function kickTurn(threadId, userText, onEvent, images) {
|
|
675
|
+
// Default to emitToThread so a spawned/pinged kid streams when someone has
|
|
676
|
+
// that thread open. emitToThread is a no-op if nobody is watching.
|
|
677
|
+
const emit = onEvent === undefined ? (ev) => emitToThread(threadId, ev) : onEvent;
|
|
678
|
+
return (runTurnOverride || runTurn)(threadId, userText, emit, images);
|
|
679
|
+
}
|
|
680
|
+
function pingWakeText(extra) {
|
|
681
|
+
const msg = String(extra || '').trim();
|
|
682
|
+
// A restated spawn brief is not a nudge. MEASURED live: existing-SPAWN
|
|
683
|
+
// wrapped children in childKickoff({fresh:false}) → "CONTEXT REFRESH —
|
|
684
|
+
// you already exist" and they thought, then refused to redo the job.
|
|
685
|
+
if (!msg || /CONTEXT REFRESH|--- your specific job ---|ROOT ASK —/.test(msg)) return AUTO_CONTINUE;
|
|
686
|
+
return msg;
|
|
687
|
+
}
|
|
688
|
+
function pingCanWake(x) {
|
|
689
|
+
return Boolean(x) && !x.pendingRun && x.status !== 'thinking';
|
|
690
|
+
}
|
|
691
|
+
function wakeOnPing(x, extra) {
|
|
692
|
+
// Never childKickoff. Ping is a short continue, not a first-day re-brief.
|
|
693
|
+
kickTurn(x.id, pingWakeText(extra)).catch(() => {});
|
|
694
|
+
}
|
|
664
695
|
// Ceiling on subagents per thread. Spawning is fire-and-forget and each child
|
|
665
696
|
// can spawn too, so without a count it is unbounded — MEASURED as 15+ threads
|
|
666
697
|
// all named tetris-contract, every one of them a live agent making paid calls.
|
|
@@ -927,7 +958,7 @@ const SLASH_COMMANDS = [
|
|
|
927
958
|
{ name: '/memory', args: '[text|clear]', help: 'facts injected into every turn' },
|
|
928
959
|
{ name: '/sessions', args: '', help: 'list all threads' },
|
|
929
960
|
{ name: '/all', args: '<message>', help: 'send a message to every bot in this project' },
|
|
930
|
-
{ name: '/ping', args: '', help: '
|
|
961
|
+
{ name: '/ping', args: '', help: 'wake idle bots below you to take a turn now' },
|
|
931
962
|
{ name: '/cron', args: '<mins> | <message>', help: 'repeat a message on a timer' },
|
|
932
963
|
{ name: '/crons', args: '', help: 'list timers (/cron del <id> removes one)' },
|
|
933
964
|
{ name: '/dir', args: '<path>', help: 'set this thread’s working directory' },
|
|
@@ -1037,7 +1068,8 @@ async function handleSlash(task, t) {
|
|
|
1037
1068
|
+ ' SPAWN: <name> | <task> a NEW subagent (names are unique —\n'
|
|
1038
1069
|
+ ' spawning an existing one sends to it)\n'
|
|
1039
1070
|
+ ' SEND: <name> | <msg> more work for an EXISTING subagent\n'
|
|
1040
|
-
+ ' PING
|
|
1071
|
+
+ ' PING: <name> wake that bot (* wakes the project)\n'
|
|
1072
|
+
+ ' PEEK: <name> read-only look at another bot\n\n'
|
|
1041
1073
|
+ 'READ, LS, GLOB, GREP, FETCH, PEEK and MCP run CONCURRENTLY when several\n'
|
|
1042
1074
|
+ 'appear in one reply — four files cost one round trip, not four.';
|
|
1043
1075
|
}
|
|
@@ -1206,23 +1238,30 @@ async function handleSlash(task, t) {
|
|
|
1206
1238
|
}
|
|
1207
1239
|
const crew = subtreeOf(t.id);
|
|
1208
1240
|
if (!crew.length) return 'You have no subagents to send to.';
|
|
1209
|
-
for (const x of crew)
|
|
1241
|
+
for (const x of crew) kickTurn(x.id, arg).catch(() => {});
|
|
1210
1242
|
return `Sent down your branch to ${crew.length} bot(s): ${crew.map((x) => x.name).join(', ')}`;
|
|
1211
1243
|
}
|
|
1212
1244
|
|
|
1213
|
-
//
|
|
1214
|
-
//
|
|
1245
|
+
// Wake the room. Used to be a free last-line dump — idle children stayed
|
|
1246
|
+
// idle, and a parent reading "kid: <old reply>" thought they had acted.
|
|
1247
|
+
// Empty extra is a nudge (AUTO_CONTINUE), not a cancel. Thinking stays
|
|
1248
|
+
// thinking; pendingRun stays on the human. Same branch scope as /all.
|
|
1215
1249
|
if (cmd === 'ping') {
|
|
1216
|
-
// Same scoping as /all: your branch, not the whole project.
|
|
1217
1250
|
const crew = subtreeOf(t.id, true);
|
|
1218
1251
|
if (crew.length < 2) return 'You have no subagents yet.';
|
|
1219
1252
|
return crew.map((x) => {
|
|
1220
1253
|
const mark = x.id === t.id ? ' (here)' : '';
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1254
|
+
if (x.id === t.id) {
|
|
1255
|
+
const last = x.history[x.history.length - 1];
|
|
1256
|
+
return x.pendingRun ? ` ${x.name}${mark}: BLOCKED — waiting for your approval`
|
|
1257
|
+
: x.status === 'thinking' ? ` ${x.name}${mark}: working`
|
|
1258
|
+
: last ? ` ${x.name}${mark}: ${String(last.text).replace(/\s+/g, ' ').slice(0, 90)}`
|
|
1259
|
+
: ` ${x.name}${mark}: nothing yet`;
|
|
1260
|
+
}
|
|
1261
|
+
if (x.pendingRun) return ` ${x.name}${mark}: BLOCKED — waiting for your approval`;
|
|
1262
|
+
if (x.status === 'thinking') return ` ${x.name}${mark}: working`;
|
|
1263
|
+
wakeOnPing(x, arg);
|
|
1264
|
+
return ` ${x.name}${mark}: pinged, working`;
|
|
1226
1265
|
}).join('\n');
|
|
1227
1266
|
}
|
|
1228
1267
|
|
|
@@ -1273,7 +1312,7 @@ setInterval(() => {
|
|
|
1273
1312
|
if (c.nextAt > now) continue;
|
|
1274
1313
|
c.nextAt = now + c.everyMin * 60000;
|
|
1275
1314
|
saveThreads();
|
|
1276
|
-
|
|
1315
|
+
kickTurn(t.id, c.text).catch(() => {});
|
|
1277
1316
|
}
|
|
1278
1317
|
}
|
|
1279
1318
|
}, 15000).unref();
|
|
@@ -1767,14 +1806,15 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1767
1806
|
const notes = [];
|
|
1768
1807
|
for (const { name, task } of parsed) {
|
|
1769
1808
|
const existing = findByName(name);
|
|
1770
|
-
if (existing) { notes.push(`${name} already exists —
|
|
1809
|
+
if (existing) { notes.push(`${name} already exists — woke it to keep working.`); made.push({ t: existing, task, fresh: false }); continue; }
|
|
1771
1810
|
const siblings = [...threads.values()].filter((x) => x.parent === originId).length;
|
|
1772
1811
|
if (siblings >= SPAWN_MAX_CHILDREN) { notes.push(`Not spawning "${name}": already at ${SPAWN_MAX_CHILDREN} subagents.`); continue; }
|
|
1773
1812
|
made.push({ t: newThread(name, originId), task, fresh: true });
|
|
1774
1813
|
}
|
|
1775
1814
|
// Every thread now exists, so spawnPosition sees the COMPLETE cohort.
|
|
1776
1815
|
for (const { t: sub, task, fresh } of made) {
|
|
1777
|
-
|
|
1816
|
+
if (fresh) kickTurn(sub.id, childKickoff(parent, sub.name, task, { fresh })).catch(() => {});
|
|
1817
|
+
else wakeOnPing(sub);
|
|
1778
1818
|
}
|
|
1779
1819
|
const fresh = made.filter((m) => m.fresh).map((m) => m.t.name);
|
|
1780
1820
|
return [fresh.length ? `Spawned ${fresh.length} together (they can each see the full crew): ${fresh.join(', ')}` : '', ...notes]
|
|
@@ -1793,8 +1833,11 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1793
1833
|
// is what SEND already does.
|
|
1794
1834
|
const existing = findByName(name);
|
|
1795
1835
|
if (existing) {
|
|
1796
|
-
|
|
1797
|
-
|
|
1836
|
+
// Repeat SPAWN is a wake, not a CONTEXT REFRESH. childKickoff({fresh:false})
|
|
1837
|
+
// restates the original job and tells the child it already exists — MEASURED,
|
|
1838
|
+
// the crew flipped to that preview, thought once, and sat.
|
|
1839
|
+
wakeOnPing(existing);
|
|
1840
|
+
return `${name} already exists — woke it to keep working.`;
|
|
1798
1841
|
}
|
|
1799
1842
|
// Storm guard. Fire-and-forget spawning is unbounded by construction: each
|
|
1800
1843
|
// child can spawn, and nothing above it is counting.
|
|
@@ -1806,7 +1849,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1806
1849
|
}
|
|
1807
1850
|
const sub = newThread(name, originId);
|
|
1808
1851
|
// The child gets the ORIGINATING brief plus its own job — see spawnBrief.
|
|
1809
|
-
|
|
1852
|
+
kickTurn(sub.id, childKickoff(threads.get(originId), name, task)).catch(() => {}); // fire and forget
|
|
1810
1853
|
return `Spawned ${name} — working on it.`;
|
|
1811
1854
|
}
|
|
1812
1855
|
// SEND TO A NAME THAT DOES NOT EXIST YET *SPAWNS* IT.
|
|
@@ -1835,7 +1878,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1835
1878
|
const msg = sendM[2].trim();
|
|
1836
1879
|
const target = findByName(name);
|
|
1837
1880
|
if (target) {
|
|
1838
|
-
|
|
1881
|
+
kickTurn(target.id, childKickoff(threads.get(originId), target.name, msg, { fresh: false })).catch(() => {});
|
|
1839
1882
|
return `Messaged ${name}.`;
|
|
1840
1883
|
}
|
|
1841
1884
|
// The SAME storm guard SPAWN uses — promoting a SEND must not be a way
|
|
@@ -1847,7 +1890,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1847
1890
|
+ `(limit ${SPAWN_MAX_CHILDREN}). Reuse one with SEND: <existing name> | <task>.`;
|
|
1848
1891
|
}
|
|
1849
1892
|
const sub = newThread(name, originId);
|
|
1850
|
-
|
|
1893
|
+
kickTurn(sub.id, childKickoff(threads.get(originId), name, msg)).catch(() => {});
|
|
1851
1894
|
return `${name} did not exist — spawned it with that message as its task.`;
|
|
1852
1895
|
}
|
|
1853
1896
|
// PING had the same anchor bug — no `m`, so a PING after any preamble (or
|
|
@@ -1861,27 +1904,29 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1861
1904
|
const ping = pingAll.length === 1 ? [null, pingAll[0]] : null;
|
|
1862
1905
|
if (ping) {
|
|
1863
1906
|
const name = ping[1].trim();
|
|
1864
|
-
// PING: * (or 'all' / 'project')
|
|
1907
|
+
// PING: * (or 'all' / 'project') WAKES every other bot in this project.
|
|
1865
1908
|
// Coordinating a spawn tree by naming siblings one at a time is a chore
|
|
1866
1909
|
// the parent should not have to do, and it cannot know who else exists.
|
|
1910
|
+
// The return is an ack ("pinged, working"), not a last-line dump that
|
|
1911
|
+
// lets the parent think the child already acted.
|
|
1867
1912
|
if (/^(\*|all|project|everyone)$/i.test(name)) {
|
|
1868
1913
|
const me = threads.get(originId);
|
|
1869
1914
|
const root = me ? rootOf(me).rootId : null;
|
|
1870
1915
|
const crew = [...threads.values()].filter((x) => x.id !== originId && rootOf(x).rootId === root);
|
|
1871
1916
|
if (!crew.length) return 'No other bots in this project yet.';
|
|
1872
1917
|
return crew.map((x) => {
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
: x.name + ': no reply yet';
|
|
1918
|
+
if (x.pendingRun) return x.name + ': BLOCKED — waiting for approval';
|
|
1919
|
+
if (x.status === 'thinking') return x.name + ': still working';
|
|
1920
|
+
wakeOnPing(x);
|
|
1921
|
+
return x.name + ': pinged, working';
|
|
1878
1922
|
}).join('\n');
|
|
1879
1923
|
}
|
|
1880
1924
|
const target = findByName(name);
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1925
|
+
if (!target) return `No thread named "${name}".`;
|
|
1926
|
+
if (target.pendingRun) return `${name}: BLOCKED — waiting for approval`;
|
|
1927
|
+
if (target.status === 'thinking') return `${name} is still working.`;
|
|
1928
|
+
wakeOnPing(target);
|
|
1929
|
+
return `${name}: pinged, working`;
|
|
1885
1930
|
}
|
|
1886
1931
|
const peek = /^[ \t>*-]*PEEK:\s*(.+)/m.exec(reply);
|
|
1887
1932
|
if (peek) {
|
|
@@ -2089,7 +2134,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
2089
2134
|
const root = rootOf(t).rootId;
|
|
2090
2135
|
const crew = [...threads.values()].filter((x) => x.id !== t.id && rootOf(x).rootId === root);
|
|
2091
2136
|
for (const x of crew) {
|
|
2092
|
-
|
|
2137
|
+
kickTurn(x.id, `[${t.name} finished] ${peek}\n`
|
|
2093
2138
|
+ `(${t.todos.length - left}/${t.todos.length} of its goals done. `
|
|
2094
2139
|
+ `This is a status peek — do NOT redo this work, and do not reply unless it changes yours.)`)
|
|
2095
2140
|
.catch(() => {});
|
|
@@ -2257,11 +2302,11 @@ async function mcpDirective(url, tool, args) {
|
|
|
2257
2302
|
|
|
2258
2303
|
// onEvent (optional) gets live progress for whoever's actually watching this
|
|
2259
2304
|
// call: {type:'start',name,color} when a bot begins its turn, {type:'status',
|
|
2260
|
-
// detail} while paying / waiting / walking tools, {type:'delta',name,
|
|
2261
|
-
// delta} per streamed token
|
|
2262
|
-
// reply (or directive ack) is
|
|
2263
|
-
//
|
|
2264
|
-
//
|
|
2305
|
+
// detail} while paying / waiting / racing / walking tools, {type:'delta',name,
|
|
2306
|
+
// color,delta} per streamed token (replace:true swaps the bubble once),
|
|
2307
|
+
// {type:'final',name,color,text} once its full reply (or directive ack) is
|
|
2308
|
+
// settled. Background turns go through kickTurn → emitToThread, which is a
|
|
2309
|
+
// no-op if nobody has the thread open.
|
|
2265
2310
|
async function runTurn(threadId, userText, onEvent, images) {
|
|
2266
2311
|
const t = threads.get(threadId);
|
|
2267
2312
|
if (!t) return;
|
|
@@ -2282,7 +2327,9 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2282
2327
|
t.status = 'thinking';
|
|
2283
2328
|
t.thinkingAt = Date.now();
|
|
2284
2329
|
t.lastDeltaAt = Date.now();
|
|
2285
|
-
t.
|
|
2330
|
+
const raceN = Math.min(Number(t.race) || 0, 4);
|
|
2331
|
+
const raceNeed = Math.min(Math.max(Number(t.raceNeed) || 1, 1), raceN || 1);
|
|
2332
|
+
t.liveStatus = (!t.model && raceN >= 2) ? formatRaceStatus(0, raceNeed) : 'waiting on model…';
|
|
2286
2333
|
let chained = false;
|
|
2287
2334
|
let parked = false;
|
|
2288
2335
|
try {
|
|
@@ -2343,7 +2390,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2343
2390
|
}
|
|
2344
2391
|
t.messages.push({ role: 'user', content: contentFor(userText, images) });
|
|
2345
2392
|
let reply = '';
|
|
2346
|
-
paint({ type: 'start', name: t.name, color: t.color, detail: 'waiting on model…' });
|
|
2393
|
+
paint({ type: 'start', name: t.name, color: t.color, detail: t.liveStatus || 'waiting on model…' });
|
|
2347
2394
|
// Transient: the nudge is appended for THIS call only and never pushed into
|
|
2348
2395
|
// t.messages, so it can't accumulate across a chained auto run or get bound
|
|
2349
2396
|
// into the thread's context.
|
|
@@ -2368,7 +2415,11 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2368
2415
|
// `attempt` exists because a retry must be allowed to land somewhere else:
|
|
2369
2416
|
// see the empty-completion loop below.
|
|
2370
2417
|
const ask = async (attempt = 0) => {
|
|
2371
|
-
const emit = (delta) => paint({
|
|
2418
|
+
const emit = (delta, meta) => paint({
|
|
2419
|
+
type: 'delta', name: t.name, color: t.color, delta,
|
|
2420
|
+
...(meta?.replace ? { replace: true } : {}),
|
|
2421
|
+
...(meta?.model ? { model: meta.model } : {}),
|
|
2422
|
+
});
|
|
2372
2423
|
const emitStatus = (detail) => paint({ type: 'status', name: t.name, color: t.color, detail });
|
|
2373
2424
|
// Retrieval breadth scales with the PROJECT's corpus, not this thread's —
|
|
2374
2425
|
// the holobrain is shared at the root, so that is the pool being searched.
|
|
@@ -2379,10 +2430,10 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2379
2430
|
// need = how many must come BACK before judging. need 1 is a plain
|
|
2380
2431
|
// first-past-the-post race; need N waits for all of them. The point of
|
|
2381
2432
|
// the middle (2 of 3) is a judged answer without the slowest entrant
|
|
2382
|
-
// setting the latency.
|
|
2433
|
+
// setting the latency. Collection is first-X-back (non-empty);
|
|
2434
|
+
// classify runs only on those X.
|
|
2383
2435
|
const need = Math.min(Math.max(Number(t.raceNeed) || 1, 1), race);
|
|
2384
|
-
|
|
2385
|
-
return (await brainRace(callMsgs, emit, t.contextId, models, need)).trim();
|
|
2436
|
+
return (await brainRace(callMsgs, emit, t.contextId, models, need, undefined, emitStatus)).trim();
|
|
2386
2437
|
}
|
|
2387
2438
|
// A retry draws a DIFFERENT model from the tier rather than the same one.
|
|
2388
2439
|
const model = t.model || (await tierModels(t.tier || 'medium', attempt + 1, attempt > 0))[attempt] || undefined;
|
|
@@ -3121,12 +3172,9 @@ const APP_HTML = `<!doctype html>
|
|
|
3121
3172
|
</div>
|
|
3122
3173
|
<div id="walletOverlay" data-component="wallet-modal">
|
|
3123
3174
|
<div id="walletBox">
|
|
3124
|
-
<h3>
|
|
3125
|
-
<div class="wsub">
|
|
3126
|
-
<div
|
|
3127
|
-
<div class="wsub">This is <b>your</b> local burner on this machine (or this box). Keys stay in ~/.openzoo/wallet.json. It is not openzoo’s wallet, not a shared zoo account, not the model’s. You fund these deposit addresses; the app pays x402 per call from this wallet. Public addresses only — the UI never shows the key.</div>
|
|
3128
|
-
<div id="walletBody">loading…</div>
|
|
3129
|
-
<div class="wlane" id="subLane" data-component="subscribe-lane">
|
|
3175
|
+
<h3>Pay with a card</h3>
|
|
3176
|
+
<div class="wsub">Pay with a card. Basic, Pro, and Ultra are first. Wallet/x402 is the other option below — it stays; it is not the lead.</div>
|
|
3177
|
+
<div id="subLane" data-component="subscribe-lane">
|
|
3130
3178
|
<div class="wlanetitle">Subscribe with a card</div>
|
|
3131
3179
|
<div class="wtag">Subscription key · no x402</div>
|
|
3132
3180
|
<div class="wsub">Same plans as the public page. Checkout opens in the system browser — never an in-app Stripe window. After Stripe, this app polls the site’s key endpoint with the checkout session, or you paste the key from the success page.</div>
|
|
@@ -3142,6 +3190,11 @@ const APP_HTML = `<!doctype html>
|
|
|
3142
3190
|
<div class="wnote" id="subNote"></div>
|
|
3143
3191
|
<div class="wquiet"><a id="subPageLink" href="${SUBSCRIPTIONS_PAGE}" target="_blank" rel="noopener">Full subscriptions page</a></div>
|
|
3144
3192
|
</div>
|
|
3193
|
+
<div class="wlane" id="x402Lane" data-component="x402-lane">
|
|
3194
|
+
<div class="wlanetitle">Wallet / x402</div>
|
|
3195
|
+
<div class="wsub">This is <b>your</b> local burner on this machine (or this box). Keys stay in ~/.openzoo/wallet.json. It is not openzoo’s wallet, not a shared zoo account, not the model’s. You fund these deposit addresses; the app pays x402 per call from this wallet. Public addresses only — the UI never shows the key.</div>
|
|
3196
|
+
<div id="walletBody">loading…</div>
|
|
3197
|
+
</div>
|
|
3145
3198
|
</div>
|
|
3146
3199
|
</div>
|
|
3147
3200
|
<div id="main">
|
|
@@ -3176,7 +3229,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3176
3229
|
</optgroup>
|
|
3177
3230
|
</select>
|
|
3178
3231
|
<button class="dial" id="walletBtn" data-component="wallet-open"
|
|
3179
|
-
title="
|
|
3232
|
+
title="Pay with a card, or use wallet/x402">pay</button>
|
|
3180
3233
|
<button class="icon-btn" id="reloadBtn" title="Restart grokui on this box">↻</button>
|
|
3181
3234
|
<button class="icon-btn" id="hudBtn">◎</button>
|
|
3182
3235
|
</div>
|
|
@@ -3486,7 +3539,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3486
3539
|
// subagents without retyping — and pressing it addressed the WHOLE
|
|
3487
3540
|
// project, cousins included. Now every thread with descendants gets
|
|
3488
3541
|
// one, scoped to its own branch.
|
|
3489
|
-
(t.kids ? '<button class="pingall trow-ping" data-testid="ping-all" title="
|
|
3542
|
+
(t.kids ? '<button class="pingall trow-ping" data-testid="ping-all" title="Wake all '
|
|
3490
3543
|
+ t.kids + ' bot(s) below ' + escapeHtml(t.name) + '">\u21f2 ' + t.kids + '</button>' : '') +
|
|
3491
3544
|
'<button class="tclose" title="Remove">✕</button>';
|
|
3492
3545
|
row.addEventListener('click', () => {
|
|
@@ -3501,18 +3554,17 @@ const APP_HTML = `<!doctype html>
|
|
|
3501
3554
|
if (pingBtn) {
|
|
3502
3555
|
pingBtn.addEventListener('click', async (e) => {
|
|
3503
3556
|
e.stopPropagation();
|
|
3504
|
-
|
|
3505
|
-
//
|
|
3506
|
-
//
|
|
3507
|
-
|
|
3557
|
+
// Default click = wake with the harness continue. window.prompt is
|
|
3558
|
+
// missing or blocked in Electron, so a modal here silently no-op'd
|
|
3559
|
+
// the only UI path that tried to reach the crew. /all still sends
|
|
3560
|
+
// exact text; ping is "poke them to work".
|
|
3508
3561
|
pingBtn.disabled = true;
|
|
3509
3562
|
const was = pingBtn.textContent;
|
|
3510
3563
|
pingBtn.textContent = '…';
|
|
3511
3564
|
try {
|
|
3512
|
-
// Routed through THIS thread, so /all scopes to its own subtree.
|
|
3513
3565
|
await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
3514
|
-
body: JSON.stringify({ threadId: t.id, task: '/
|
|
3515
|
-
pingBtn.textContent = '
|
|
3566
|
+
body: JSON.stringify({ threadId: t.id, task: '/ping' }) });
|
|
3567
|
+
pingBtn.textContent = 'pinged';
|
|
3516
3568
|
} catch (err) { pingBtn.textContent = 'failed'; }
|
|
3517
3569
|
setTimeout(() => { pingBtn.disabled = false; pingBtn.textContent = was; }, 1500);
|
|
3518
3570
|
await loadThreads();
|
|
@@ -3623,7 +3675,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3623
3675
|
if (!w || (!w.solana && !w.evm && w.creditUsd == null)) {
|
|
3624
3676
|
const p = document.createElement('div');
|
|
3625
3677
|
p.className = 'wnote wempty';
|
|
3626
|
-
p.textContent = 'Could not reach the local openzoo proxy on :8402. It may still be starting — try again in a few seconds. You can still subscribe with a card
|
|
3678
|
+
p.textContent = 'Could not reach the local openzoo proxy on :8402. It may still be starting — try again in a few seconds. You can still subscribe with a card above.';
|
|
3627
3679
|
walletBody.appendChild(p);
|
|
3628
3680
|
renderSubLane(w && w.subscription ? w.subscription : null);
|
|
3629
3681
|
return;
|
|
@@ -3665,7 +3717,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3665
3717
|
note.textContent = subOn
|
|
3666
3718
|
? ('Wallet is optional while a subscription is active. ' + (w.funding || ''))
|
|
3667
3719
|
: (w.funded === false
|
|
3668
|
-
? 'This wallet is EMPTY — wallet/x402 calls will fail with HTTP 402 until you fund the addresses above, or subscribe with a card
|
|
3720
|
+
? 'This wallet is EMPTY — wallet/x402 calls will fail with HTTP 402 until you fund the addresses above, or subscribe with a card at the top. ' + (w.funding || '')
|
|
3669
3721
|
: (w.funding || ''));
|
|
3670
3722
|
if (w.funded === false && !subOn) note.classList.add('wempty');
|
|
3671
3723
|
if (note.textContent.trim()) walletBody.appendChild(note);
|
|
@@ -3832,8 +3884,9 @@ const APP_HTML = `<!doctype html>
|
|
|
3832
3884
|
if (e.key === 'Enter') { e.preventDefault(); savePastedSub(); }
|
|
3833
3885
|
});
|
|
3834
3886
|
// First launch: if the burner is empty AND there is no subscription, open
|
|
3835
|
-
// the
|
|
3836
|
-
// so a funded session, a saved key, or a dismiss does
|
|
3887
|
+
// the pay modal once so they see card plans first — and burner addresses
|
|
3888
|
+
// below. localStorage so a funded session, a saved key, or a dismiss does
|
|
3889
|
+
// not keep popping it.
|
|
3837
3890
|
(async function maybeOpenWalletOnce() {
|
|
3838
3891
|
if (localStorage.getItem('openzoo.wallet.seen')) return;
|
|
3839
3892
|
for (let i = 0; i < 8; i++) {
|
|
@@ -4449,7 +4502,10 @@ const APP_HTML = `<!doctype html>
|
|
|
4449
4502
|
try { ev = JSON.parse(e.data); } catch { return; }
|
|
4450
4503
|
if (ev.type === 'start') { streamBuf = ''; streamStatus = ev.detail || 'waiting on model…'; paintStream(); }
|
|
4451
4504
|
else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
|
|
4452
|
-
else if (ev.type === 'delta') {
|
|
4505
|
+
else if (ev.type === 'delta') {
|
|
4506
|
+
streamBuf = ev.replace ? (ev.delta || '') : streamBuf + (ev.delta || '');
|
|
4507
|
+
paintStream();
|
|
4508
|
+
}
|
|
4453
4509
|
else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; streamStatus = ''; render(); }
|
|
4454
4510
|
};
|
|
4455
4511
|
es.onerror = () => { /* EventSource retries; the 1.2s poll is the backstop */ };
|
|
@@ -5128,7 +5184,7 @@ const server = http.createServer((req, res) => {
|
|
|
5128
5184
|
saveThreads();
|
|
5129
5185
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
5130
5186
|
res.end('{"ok":true}');
|
|
5131
|
-
|
|
5187
|
+
kickTurn(t.id, '(you denied running that command)').catch(() => {});
|
|
5132
5188
|
return;
|
|
5133
5189
|
}
|
|
5134
5190
|
entry.runStatus = 'running';
|
|
@@ -5139,7 +5195,7 @@ const server = http.createServer((req, res) => {
|
|
|
5139
5195
|
entry.runStatus = 'done';
|
|
5140
5196
|
entry.runOutput = output;
|
|
5141
5197
|
saveThreads();
|
|
5142
|
-
|
|
5198
|
+
kickTurn(t.id, `(command output)\n${output}`).catch(() => {});
|
|
5143
5199
|
});
|
|
5144
5200
|
return;
|
|
5145
5201
|
}
|
|
@@ -5237,4 +5293,6 @@ server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0
|
|
|
5237
5293
|
export {
|
|
5238
5294
|
tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
|
|
5239
5295
|
parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
|
|
5296
|
+
handleSlash, newThread, setRunTurnForTest, AUTO_CONTINUE, pingWakeText, pingCanWake,
|
|
5297
|
+
childKickoff,
|
|
5240
5298
|
};
|
package/lib/livestatus.js
CHANGED
|
@@ -28,6 +28,134 @@ export function formatPayStatus(attempt = 0) {
|
|
|
28
28
|
return Number(attempt) > 0 ? 'waiting on x402…' : 'paying…';
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** First-X-back race: how many of the K we asked for have actually landed. */
|
|
32
|
+
export function formatRaceStatus(back, need) {
|
|
33
|
+
const b = Math.max(0, Number(back) || 0);
|
|
34
|
+
const n = Math.max(1, Number(need) || 1);
|
|
35
|
+
return `racing ${b}/${n} back…`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Real answers count toward X. Empty and HTTP/pay/timeout notes do not. */
|
|
39
|
+
export function isRaceCountable(text) {
|
|
40
|
+
const s = String(text || '').trim();
|
|
41
|
+
if (!s) return false;
|
|
42
|
+
return !/^\((?:upstream error|request failed|payment failed|rate limited|stream timed out|stream stalled)/i.test(s);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function shortModel(id) {
|
|
46
|
+
const s = String(id || '');
|
|
47
|
+
if (!s) return '';
|
|
48
|
+
return s.includes('/') ? s.split('/').pop() : s;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Last completion that arrived, even if empty-ish or error-y.
|
|
53
|
+
* Never blank: synthesize a visible error when nobody produced text.
|
|
54
|
+
*/
|
|
55
|
+
export function raceLastShip(arrivals) {
|
|
56
|
+
const list = Array.isArray(arrivals) ? arrivals : [];
|
|
57
|
+
const last = list[list.length - 1];
|
|
58
|
+
if (!last) return { model: '', text: '(race: every model failed — no reply)', error: true };
|
|
59
|
+
const raw = String(last.text || '');
|
|
60
|
+
if (raw.trim()) return { ...last, text: raw };
|
|
61
|
+
const who = shortModel(last.model) || 'model';
|
|
62
|
+
if (last.error) return { ...last, text: `(${who} failed: ${last.error})`, error: true };
|
|
63
|
+
return { ...last, text: `(${who} returned nothing)`, error: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Default bar a classified race answer must clear (0–10). Overridable. */
|
|
67
|
+
export const RACE_MIN_SCORE = Number(process.env.OZ_RACE_MIN_SCORE || 6);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse a cheap classify reply into a 0–10 score.
|
|
71
|
+
* Prefers `SCORE 7` / `SCORE: 7`; falls back to a lone 0–10.
|
|
72
|
+
* Unparseable → 0 (does not clear the bar).
|
|
73
|
+
*/
|
|
74
|
+
export function parseClassifyScore(text) {
|
|
75
|
+
const s = String(text || '');
|
|
76
|
+
const tagged = /SCORE\s*[:=]?\s*(-?\d+(?:\.\d+)?)/i.exec(s);
|
|
77
|
+
const lone = tagged || /\b(10|[0-9])(?:\s*\/\s*10)?\b/.exec(s);
|
|
78
|
+
if (!lone) return 0;
|
|
79
|
+
const n = Number(lone[1]);
|
|
80
|
+
if (!Number.isFinite(n)) return 0;
|
|
81
|
+
return Math.max(0, Math.min(10, n));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Pick a winner among the first-X-back candidates after they have been scored.
|
|
86
|
+
* Passing = score >= minScore. Highest score wins; a tie is returned as
|
|
87
|
+
* `reason: 'tie'` so the caller can pairwise-break it. If nobody clears the
|
|
88
|
+
* bar, the last of the X is accepted — never blank.
|
|
89
|
+
*/
|
|
90
|
+
export function pickRaceWinner(cands, minScore = RACE_MIN_SCORE) {
|
|
91
|
+
const list = Array.isArray(cands) ? cands.filter(Boolean) : [];
|
|
92
|
+
if (!list.length) return { winner: null, reason: 'empty', tied: [] };
|
|
93
|
+
const passing = list.filter((c) => (Number(c.score) || 0) >= minScore);
|
|
94
|
+
if (!passing.length) {
|
|
95
|
+
return { winner: list[list.length - 1], reason: 'fallback-last', tied: [] };
|
|
96
|
+
}
|
|
97
|
+
let max = -Infinity;
|
|
98
|
+
for (const c of passing) {
|
|
99
|
+
const sc = Number(c.score) || 0;
|
|
100
|
+
if (sc > max) max = sc;
|
|
101
|
+
}
|
|
102
|
+
const tied = passing.filter((c) => (Number(c.score) || 0) === max);
|
|
103
|
+
if (tied.length === 1) return { winner: tied[0], reason: 'score', tied };
|
|
104
|
+
return { winner: null, reason: 'tie', tied };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Live race bubble: stream the fastest still-alive entrant, swap once if the
|
|
109
|
+
* winner is someone else. `onDelta(text, { replace, model })`.
|
|
110
|
+
*/
|
|
111
|
+
export function createRaceFeed(onDelta, onStatus, need) {
|
|
112
|
+
let live = null;
|
|
113
|
+
let settled = false;
|
|
114
|
+
let back = 0;
|
|
115
|
+
const buf = new Map();
|
|
116
|
+
const dead = new Set();
|
|
117
|
+
const paintStatus = () => { onStatus?.(formatRaceStatus(back, need)); };
|
|
118
|
+
return {
|
|
119
|
+
start() { paintStatus(); },
|
|
120
|
+
liveModel() { return live; },
|
|
121
|
+
onToken(model, chunk) {
|
|
122
|
+
if (settled || chunk == null || chunk === '') return;
|
|
123
|
+
buf.set(model, (buf.get(model) || '') + chunk);
|
|
124
|
+
if (!live) {
|
|
125
|
+
live = model;
|
|
126
|
+
onDelta?.(chunk, { model });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (live === model) onDelta?.(chunk, { model });
|
|
130
|
+
},
|
|
131
|
+
onFail(model) {
|
|
132
|
+
dead.add(model);
|
|
133
|
+
if (settled || live !== model) return;
|
|
134
|
+
const next = [...buf.entries()].find(([m, t]) => m !== model && t && !dead.has(m));
|
|
135
|
+
if (next) {
|
|
136
|
+
live = next[0];
|
|
137
|
+
onDelta?.(next[1], { replace: true, model: live });
|
|
138
|
+
} else {
|
|
139
|
+
live = null;
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
onBack() {
|
|
143
|
+
back += 1;
|
|
144
|
+
paintStatus();
|
|
145
|
+
},
|
|
146
|
+
settle(winner) {
|
|
147
|
+
settled = true;
|
|
148
|
+
const text = String(winner?.text || '').trim()
|
|
149
|
+
? winner.text
|
|
150
|
+
: '(race: every model failed — no reply)';
|
|
151
|
+
// Live stream already showing this answer — keep going, do not re-dump.
|
|
152
|
+
if (winner?.model && live === winner.model && !winner.error) return;
|
|
153
|
+
live = winner?.model || live;
|
|
154
|
+
onDelta?.(text, { replace: true, model: winner?.model });
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
31
159
|
export function peekDirectiveStatus(reply, runCmd) {
|
|
32
160
|
if (runCmd) return `RUN: ${clipStatusArg(runCmd)}`;
|
|
33
161
|
const raw = String(reply || '');
|
package/lib/podagent.mjs
CHANGED
|
@@ -24,6 +24,8 @@ import { appendFileSync } from 'node:fs';
|
|
|
24
24
|
import { randomUUID } from 'node:crypto';
|
|
25
25
|
import {
|
|
26
26
|
formatPayStatus, startModelWait, readWithIdleTimeout, STREAM_IDLE_MS,
|
|
27
|
+
createRaceFeed, pickRaceWinner, parseClassifyScore, RACE_MIN_SCORE,
|
|
28
|
+
isRaceCountable, raceLastShip,
|
|
27
29
|
} from './livestatus.js';
|
|
28
30
|
|
|
29
31
|
const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
|
|
@@ -630,26 +632,65 @@ export async function tierModels(tier, n = 1, random = false) {
|
|
|
630
632
|
* count toward K — otherwise the fastest model to FAIL would decide the race,
|
|
631
633
|
* the exact bug this exists to fix.
|
|
632
634
|
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
635
|
+
* Live tokens: the fastest still-alive entrant is forwarded into onDelta as
|
|
636
|
+
* they arrive (not swallowed). When a winner is picked, if it is that live
|
|
637
|
+
* stream the bubble keeps going; if it is someone else the bubble is replaced
|
|
638
|
+
* once (`onDelta(text, { replace: true })`), not left on mute dots.
|
|
639
|
+
*
|
|
640
|
+
* After the first X land, a cheap classify call scores each of those X
|
|
641
|
+
* (correctness, completeness, actually did the asked thing — a RUN:/DONE:
|
|
642
|
+
* directive is success, not a flaw). Highest score that clears the bar wins;
|
|
643
|
+
* a tie is pairwise-broken. If nobody clears, the last of the X is shipped
|
|
644
|
+
* anyway. If X never fills (every entrant empty/5xx), ship the last
|
|
645
|
+
* completion that arrived — even if empty-ish or error-y. If nobody produced
|
|
646
|
+
* any text, surface a real error in the bubble. Never blank, never hang.
|
|
636
647
|
*
|
|
637
648
|
* Every entrant is paid for, including the abandoned one — this trades money
|
|
638
649
|
* for latency and quality, which is why it is opt-in and capped.
|
|
650
|
+
*
|
|
651
|
+
* `hooks` is for tests: `{ stream, classify, pairwise, minScore }`.
|
|
639
652
|
*/
|
|
640
|
-
export async function brainRace(messages, onDelta, contextId, models, need = 1, maxTokens) {
|
|
653
|
+
export async function brainRace(messages, onDelta, contextId, models, need = 1, maxTokens, onStatus, hooks = {}) {
|
|
654
|
+
const stream = hooks.stream || brainStream;
|
|
655
|
+
const classify = hooks.classify || classifyRaceAnswer;
|
|
656
|
+
const pairwise = hooks.pairwise || pairwiseTied;
|
|
657
|
+
const minScore = hooks.minScore != null ? Number(hooks.minScore) : RACE_MIN_SCORE;
|
|
641
658
|
const list = (models || []).filter(Boolean).slice(0, RACE_MAX);
|
|
642
|
-
if (list.length < 2) return
|
|
659
|
+
if (list.length < 2) return stream(messages, onDelta, contextId, list[0], maxTokens, 0, 0, onStatus);
|
|
643
660
|
const want = Math.max(1, Math.min(Number(need) || 1, list.length));
|
|
644
661
|
|
|
662
|
+
const feed = createRaceFeed(onDelta, onStatus, want);
|
|
663
|
+
feed.start();
|
|
664
|
+
|
|
645
665
|
const done = [];
|
|
666
|
+
const arrivals = [];
|
|
646
667
|
let finished = 0;
|
|
647
668
|
let release;
|
|
648
669
|
const enough = new Promise((r) => { release = r; });
|
|
649
670
|
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
.
|
|
671
|
+
const ship = (cand) => {
|
|
672
|
+
const out = cand && String(cand.text || '').trim() ? cand : raceLastShip(arrivals);
|
|
673
|
+
feed.settle(out);
|
|
674
|
+
return out.text;
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// Do not pass onStatus into each entrant — their "waiting on model…" would
|
|
678
|
+
// clobber the race line. Race owns the status until a winner ships.
|
|
679
|
+
const attempts = list.map((m) => stream(messages, (chunk) => feed.onToken(m, chunk), contextId, m, maxTokens)
|
|
680
|
+
.then((text) => {
|
|
681
|
+
const raw = text == null ? '' : String(text);
|
|
682
|
+
arrivals.push({ model: m, text: raw });
|
|
683
|
+
if (isRaceCountable(raw)) {
|
|
684
|
+
done.push({ model: m, text: raw });
|
|
685
|
+
feed.onBack();
|
|
686
|
+
} else {
|
|
687
|
+
feed.onFail(m);
|
|
688
|
+
}
|
|
689
|
+
})
|
|
690
|
+
.catch((e) => {
|
|
691
|
+
arrivals.push({ model: m, text: '', error: e?.message || 'error' });
|
|
692
|
+
feed.onFail(m);
|
|
693
|
+
})
|
|
653
694
|
.finally(() => {
|
|
654
695
|
finished += 1;
|
|
655
696
|
// Either we have what we asked for, or everyone is done and no more is
|
|
@@ -662,58 +703,79 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
|
|
|
662
703
|
for (const p of attempts) p.catch(() => {});
|
|
663
704
|
|
|
664
705
|
await enough;
|
|
665
|
-
// Completion order, so this really is the first
|
|
666
|
-
// launched.
|
|
706
|
+
// Completion order, so this really is the first X back — not the first X
|
|
707
|
+
// launched. A slow 3rd never enters this set. Empty/5xx stay in arrivals
|
|
708
|
+
// so we can still ship the last one if X never fills.
|
|
667
709
|
const cands = done.slice(0, want);
|
|
668
|
-
if (!cands.length) return
|
|
669
|
-
//
|
|
670
|
-
|
|
710
|
+
if (!cands.length) return ship(raceLastShip(arrivals));
|
|
711
|
+
// One real answer — nothing to compare. Ship it; do not spend a classify
|
|
712
|
+
// call to rubber-stamp the only candidate.
|
|
713
|
+
if (cands.length === 1) return ship(cands[0]);
|
|
714
|
+
|
|
715
|
+
onStatus?.('judging…');
|
|
716
|
+
const scored = await Promise.all(cands.map(async (c) => {
|
|
717
|
+
let score = 0;
|
|
718
|
+
try { score = Number(await classify(messages, c)) || 0; } catch { score = 0; }
|
|
719
|
+
return { ...c, score };
|
|
720
|
+
}));
|
|
721
|
+
|
|
722
|
+
let picked = pickRaceWinner(scored, minScore);
|
|
723
|
+
if (picked.reason === 'tie' && picked.tied.length > 1) {
|
|
724
|
+
let broken = null;
|
|
725
|
+
try { broken = await pairwise(messages, picked.tied); } catch { /* last of the tie */ }
|
|
726
|
+
const usable = broken && String(broken.text || '').trim();
|
|
727
|
+
// Malformed verdict / all equally bad → last finished of the tie, not empty.
|
|
728
|
+
picked = { winner: usable ? broken : picked.tied[picked.tied.length - 1], reason: 'tiebreak', tied: picked.tied };
|
|
729
|
+
}
|
|
730
|
+
return ship(picked.winner || scored[scored.length - 1] || raceLastShip(arrivals));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function raceQuestion(messages) {
|
|
734
|
+
const asked = [...messages].reverse().find((m) => m.role === 'user')?.content;
|
|
735
|
+
return typeof asked === 'string' ? asked : '(see candidates)';
|
|
736
|
+
}
|
|
671
737
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
738
|
+
/**
|
|
739
|
+
* Cheap structured score of ONE finished answer vs the question.
|
|
740
|
+
* This is grokui's own classify call — not OpenRouter's async log-tag
|
|
741
|
+
* Classifiers beta, which does not pick winners.
|
|
742
|
+
*/
|
|
743
|
+
async function classifyRaceAnswer(messages, cand) {
|
|
744
|
+
const prompt = 'Score this answer to one question from 0 to 10.\n\n'
|
|
745
|
+
+ 'QUESTION:\n' + String(raceQuestion(messages)).slice(0, 4000) + '\n\n'
|
|
746
|
+
+ 'ANSWER:\n' + String(cand?.text || '').slice(0, 6000) + '\n\n'
|
|
747
|
+
+ 'Judge on: correctness first, then completeness, then whether it actually did what was asked '
|
|
748
|
+
+ '(a directive like RUN: or DONE: on one line is the correct format here, not a flaw). '
|
|
749
|
+
+ 'Ignore length and confidence of tone.\n'
|
|
750
|
+
+ 'Reply with exactly: SCORE <n>';
|
|
751
|
+
const verdict = await brainStream(
|
|
752
|
+
[{ role: 'user', content: prompt }], () => {}, undefined, JUDGE_MODEL, 24,
|
|
753
|
+
);
|
|
754
|
+
return parseClassifyScore(verdict);
|
|
675
755
|
}
|
|
676
756
|
|
|
677
757
|
/**
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
* BLIND, as A/B/C/D. A judge told "this one is Claude and this one is a 4B
|
|
681
|
-
* llama" is being handed the answer and will take it, which would turn the
|
|
682
|
-
* whole thing into an expensive way to re-pick the tier's first entry.
|
|
683
|
-
*
|
|
684
|
-
* Cheap on purpose: reading finished replies and comparing them against a
|
|
685
|
-
* question is a far easier task than answering it, and paying frontier prices
|
|
686
|
-
* to referee frontier models would roughly double the cost of the expensive
|
|
687
|
-
* tier for no measured gain.
|
|
758
|
+
* Pairwise break among same-score passers. Blind A/B/C so the model names
|
|
759
|
+
* cannot leak the answer. Last of the tied set if the call dies.
|
|
688
760
|
*/
|
|
689
|
-
async function
|
|
690
|
-
const letters =
|
|
691
|
-
// The question, not the transcript: the judge needs to know what was ASKED,
|
|
692
|
-
// and a full history would cost more to judge than the turn cost to answer.
|
|
693
|
-
const asked = [...messages].reverse().find((m) => m.role === 'user')?.content;
|
|
694
|
-
const question = typeof asked === 'string' ? asked : '(see candidates)';
|
|
761
|
+
async function pairwiseTied(messages, tied) {
|
|
762
|
+
const letters = tied.map((_, i) => String.fromCharCode(65 + i));
|
|
695
763
|
const prompt = 'You are judging answers to one question. Pick the single best one.\n\n'
|
|
696
|
-
+ 'QUESTION:\n' + String(
|
|
697
|
-
+
|
|
764
|
+
+ 'QUESTION:\n' + String(raceQuestion(messages)).slice(0, 4000) + '\n\n'
|
|
765
|
+
+ tied.map((c, i) => 'ANSWER ' + letters[i] + ':\n' + String(c.text || '').slice(0, 6000)).join('\n\n')
|
|
698
766
|
+ '\n\nJudge on: correctness first, then completeness, then whether it actually did what was asked '
|
|
699
767
|
+ '(a directive like RUN: or DONE: on one line is the correct format here, not a flaw). '
|
|
700
768
|
+ 'Ignore length and confidence of tone.\n'
|
|
701
769
|
+ 'Reply with ONE letter and nothing else: ' + letters.join(' or ') + '.';
|
|
702
770
|
try {
|
|
703
771
|
const verdict = await brainStream([{ role: 'user', content: prompt }], () => {}, undefined, JUDGE_MODEL, 8);
|
|
704
|
-
// First in-range letter anywhere in the reply. A judge that ignores "one
|
|
705
|
-
// letter and nothing else" and writes "The best is B." still counts, which
|
|
706
|
-
// is most of them.
|
|
707
772
|
const hit = String(verdict).toUpperCase().split('').find((ch) => {
|
|
708
773
|
const n = ch.charCodeAt(0) - 65;
|
|
709
|
-
return n >= 0 && n <
|
|
774
|
+
return n >= 0 && n < tied.length;
|
|
710
775
|
});
|
|
711
|
-
if (hit) return
|
|
776
|
+
if (hit) return tied[hit.charCodeAt(0) - 65];
|
|
712
777
|
} catch { /* fall through */ }
|
|
713
|
-
|
|
714
|
-
// first finisher degrades this to "fastest wins" — worse than judged, far
|
|
715
|
-
// better than empty.
|
|
716
|
-
return cands[0];
|
|
778
|
+
return tied[tied.length - 1];
|
|
717
779
|
}
|
|
718
780
|
|
|
719
781
|
const RACE_MAX = Number(process.env.OZ_RACE_MAX || 4);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.96",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|