openzoo 0.48.94 → 0.48.97

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
@@ -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,
@@ -672,7 +672,10 @@ function setRunTurnForTest(fn) {
672
672
  runTurnOverride = typeof fn === 'function' ? fn : null;
673
673
  }
674
674
  function kickTurn(threadId, userText, onEvent, images) {
675
- return (runTurnOverride || runTurn)(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);
676
679
  }
677
680
  function pingWakeText(extra) {
678
681
  const msg = String(extra || '').trim();
@@ -1235,7 +1238,7 @@ async function handleSlash(task, t) {
1235
1238
  }
1236
1239
  const crew = subtreeOf(t.id);
1237
1240
  if (!crew.length) return 'You have no subagents to send to.';
1238
- for (const x of crew) runTurn(x.id, arg).catch(() => {});
1241
+ for (const x of crew) kickTurn(x.id, arg).catch(() => {});
1239
1242
  return `Sent down your branch to ${crew.length} bot(s): ${crew.map((x) => x.name).join(', ')}`;
1240
1243
  }
1241
1244
 
@@ -1309,7 +1312,7 @@ setInterval(() => {
1309
1312
  if (c.nextAt > now) continue;
1310
1313
  c.nextAt = now + c.everyMin * 60000;
1311
1314
  saveThreads();
1312
- runTurn(t.id, c.text).catch(() => {});
1315
+ kickTurn(t.id, c.text).catch(() => {});
1313
1316
  }
1314
1317
  }
1315
1318
  }, 15000).unref();
@@ -1810,7 +1813,7 @@ async function tryDirective(reply, originId, onEvent) {
1810
1813
  }
1811
1814
  // Every thread now exists, so spawnPosition sees the COMPLETE cohort.
1812
1815
  for (const { t: sub, task, fresh } of made) {
1813
- if (fresh) runTurn(sub.id, childKickoff(parent, sub.name, task, { fresh })).catch(() => {});
1816
+ if (fresh) kickTurn(sub.id, childKickoff(parent, sub.name, task, { fresh })).catch(() => {});
1814
1817
  else wakeOnPing(sub);
1815
1818
  }
1816
1819
  const fresh = made.filter((m) => m.fresh).map((m) => m.t.name);
@@ -1846,7 +1849,7 @@ async function tryDirective(reply, originId, onEvent) {
1846
1849
  }
1847
1850
  const sub = newThread(name, originId);
1848
1851
  // The child gets the ORIGINATING brief plus its own job — see spawnBrief.
1849
- runTurn(sub.id, childKickoff(threads.get(originId), name, task)).catch(() => {}); // fire and forget
1852
+ kickTurn(sub.id, childKickoff(threads.get(originId), name, task)).catch(() => {}); // fire and forget
1850
1853
  return `Spawned ${name} — working on it.`;
1851
1854
  }
1852
1855
  // SEND TO A NAME THAT DOES NOT EXIST YET *SPAWNS* IT.
@@ -1875,7 +1878,7 @@ async function tryDirective(reply, originId, onEvent) {
1875
1878
  const msg = sendM[2].trim();
1876
1879
  const target = findByName(name);
1877
1880
  if (target) {
1878
- runTurn(target.id, childKickoff(threads.get(originId), target.name, msg, { fresh: false })).catch(() => {});
1881
+ kickTurn(target.id, childKickoff(threads.get(originId), target.name, msg, { fresh: false })).catch(() => {});
1879
1882
  return `Messaged ${name}.`;
1880
1883
  }
1881
1884
  // The SAME storm guard SPAWN uses — promoting a SEND must not be a way
@@ -1887,7 +1890,7 @@ async function tryDirective(reply, originId, onEvent) {
1887
1890
  + `(limit ${SPAWN_MAX_CHILDREN}). Reuse one with SEND: <existing name> | <task>.`;
1888
1891
  }
1889
1892
  const sub = newThread(name, originId);
1890
- runTurn(sub.id, childKickoff(threads.get(originId), name, msg)).catch(() => {});
1893
+ kickTurn(sub.id, childKickoff(threads.get(originId), name, msg)).catch(() => {});
1891
1894
  return `${name} did not exist — spawned it with that message as its task.`;
1892
1895
  }
1893
1896
  // PING had the same anchor bug — no `m`, so a PING after any preamble (or
@@ -2131,7 +2134,7 @@ async function tryDirective(reply, originId, onEvent) {
2131
2134
  const root = rootOf(t).rootId;
2132
2135
  const crew = [...threads.values()].filter((x) => x.id !== t.id && rootOf(x).rootId === root);
2133
2136
  for (const x of crew) {
2134
- runTurn(x.id, `[${t.name} finished] ${peek}\n`
2137
+ kickTurn(x.id, `[${t.name} finished] ${peek}\n`
2135
2138
  + `(${t.todos.length - left}/${t.todos.length} of its goals done. `
2136
2139
  + `This is a status peek — do NOT redo this work, and do not reply unless it changes yours.)`)
2137
2140
  .catch(() => {});
@@ -2299,11 +2302,11 @@ async function mcpDirective(url, tool, args) {
2299
2302
 
2300
2303
  // onEvent (optional) gets live progress for whoever's actually watching this
2301
2304
  // call: {type:'start',name,color} when a bot begins its turn, {type:'status',
2302
- // detail} while paying / waiting / walking tools, {type:'delta',name,color,
2303
- // delta} per streamed token, {type:'final',name,color,text} once its full
2304
- // reply (or directive ack) is settled. Background turns — a SPAWNed
2305
- // subagent nobody's looking at yet run with onEvent omitted and just use
2306
- // the plain non-streaming brain(), which is cheaper when nothing renders it.
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.
2307
2310
  async function runTurn(threadId, userText, onEvent, images) {
2308
2311
  const t = threads.get(threadId);
2309
2312
  if (!t) return;
@@ -2324,7 +2327,9 @@ async function runTurn(threadId, userText, onEvent, images) {
2324
2327
  t.status = 'thinking';
2325
2328
  t.thinkingAt = Date.now();
2326
2329
  t.lastDeltaAt = Date.now();
2327
- t.liveStatus = 'waiting on model…';
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…';
2328
2333
  let chained = false;
2329
2334
  let parked = false;
2330
2335
  try {
@@ -2385,7 +2390,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2385
2390
  }
2386
2391
  t.messages.push({ role: 'user', content: contentFor(userText, images) });
2387
2392
  let reply = '';
2388
- 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…' });
2389
2394
  // Transient: the nudge is appended for THIS call only and never pushed into
2390
2395
  // t.messages, so it can't accumulate across a chained auto run or get bound
2391
2396
  // into the thread's context.
@@ -2410,7 +2415,11 @@ async function runTurn(threadId, userText, onEvent, images) {
2410
2415
  // `attempt` exists because a retry must be allowed to land somewhere else:
2411
2416
  // see the empty-completion loop below.
2412
2417
  const ask = async (attempt = 0) => {
2413
- const emit = (delta) => paint({ type: 'delta', name: t.name, color: t.color, delta });
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
+ });
2414
2423
  const emitStatus = (detail) => paint({ type: 'status', name: t.name, color: t.color, detail });
2415
2424
  // Retrieval breadth scales with the PROJECT's corpus, not this thread's —
2416
2425
  // the holobrain is shared at the root, so that is the pool being searched.
@@ -2421,10 +2430,10 @@ async function runTurn(threadId, userText, onEvent, images) {
2421
2430
  // need = how many must come BACK before judging. need 1 is a plain
2422
2431
  // first-past-the-post race; need N waits for all of them. The point of
2423
2432
  // the middle (2 of 3) is a judged answer without the slowest entrant
2424
- // setting the latency.
2433
+ // setting the latency. Collection is first-X-back (non-empty);
2434
+ // classify runs only on those X.
2425
2435
  const need = Math.min(Math.max(Number(t.raceNeed) || 1, 1), race);
2426
- emitStatus('waiting on model…');
2427
- return (await brainRace(callMsgs, emit, t.contextId, models, need)).trim();
2436
+ return (await brainRace(callMsgs, emit, t.contextId, models, need, undefined, emitStatus)).trim();
2428
2437
  }
2429
2438
  // A retry draws a DIFFERENT model from the tier rather than the same one.
2430
2439
  const model = t.model || (await tierModels(t.tier || 'medium', attempt + 1, attempt > 0))[attempt] || undefined;
@@ -4493,7 +4502,10 @@ const APP_HTML = `<!doctype html>
4493
4502
  try { ev = JSON.parse(e.data); } catch { return; }
4494
4503
  if (ev.type === 'start') { streamBuf = ''; streamStatus = ev.detail || 'waiting on model…'; paintStream(); }
4495
4504
  else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
4496
- else if (ev.type === 'delta') { streamBuf += ev.delta || ''; paintStream(); }
4505
+ else if (ev.type === 'delta') {
4506
+ streamBuf = ev.replace ? (ev.delta || '') : streamBuf + (ev.delta || '');
4507
+ paintStream();
4508
+ }
4497
4509
  else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; streamStatus = ''; render(); }
4498
4510
  };
4499
4511
  es.onerror = () => { /* EventSource retries; the 1.2s poll is the backstop */ };
@@ -5172,7 +5184,7 @@ const server = http.createServer((req, res) => {
5172
5184
  saveThreads();
5173
5185
  res.writeHead(200, { 'content-type': 'application/json' });
5174
5186
  res.end('{"ok":true}');
5175
- runTurn(t.id, '(you denied running that command)').catch(() => {});
5187
+ kickTurn(t.id, '(you denied running that command)').catch(() => {});
5176
5188
  return;
5177
5189
  }
5178
5190
  entry.runStatus = 'running';
@@ -5183,7 +5195,7 @@ const server = http.createServer((req, res) => {
5183
5195
  entry.runStatus = 'done';
5184
5196
  entry.runOutput = output;
5185
5197
  saveThreads();
5186
- runTurn(t.id, `(command output)\n${output}`).catch(() => {});
5198
+ kickTurn(t.id, `(command output)\n${output}`).catch(() => {});
5187
5199
  });
5188
5200
  return;
5189
5201
  }
package/lib/livestatus.js CHANGED
@@ -28,6 +28,143 @@ 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
+ /** Race-level failure when no countable answer exists. Never a single model name. */
39
+ export const RACE_EVERY_FAILED = '(race: every model failed — no reply)';
40
+
41
+ const RACE_HTTP_NOTE = /^\((?:upstream error|request failed|payment failed|rate limited|stream timed out|stream stalled)/i;
42
+ const RACE_MODEL_FAILED = /^\([^)]+ (?:failed:|returned nothing)/i;
43
+ const RACE_FETCH_FAILED = /^(?:typeerror:\s*)?fetch failed$/i;
44
+
45
+ /**
46
+ * Real answers count toward X.
47
+ * Empty, HTTP/pay/timeout notes, TypeError `fetch failed`, `(model failed: …)`,
48
+ * and any arrival with `.error` do not — those racers are abandoned.
49
+ * Accepts a string or `{ text, error }`.
50
+ */
51
+ export function isRaceCountable(textOrArrival) {
52
+ const arrival = textOrArrival && typeof textOrArrival === 'object' && !Array.isArray(textOrArrival)
53
+ ? textOrArrival
54
+ : { text: textOrArrival };
55
+ if (arrival.error) return false;
56
+ const s = String(arrival.text || '').trim();
57
+ if (!s) return false;
58
+ if (RACE_FETCH_FAILED.test(s)) return false;
59
+ if (RACE_HTTP_NOTE.test(s)) return false;
60
+ if (RACE_MODEL_FAILED.test(s)) return false;
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * Fallback when X never fills: one race-level error, not `(one-model failed: …)`.
66
+ * Failed arrivals are abandoned, not shipped as the assistant message.
67
+ */
68
+ export function raceLastShip(arrivals) {
69
+ const list = Array.isArray(arrivals) ? arrivals : [];
70
+ const last = [...list].reverse().find((a) => isRaceCountable(a));
71
+ if (last) return { ...last, text: String(last.text) };
72
+ return { model: '', text: RACE_EVERY_FAILED, error: true };
73
+ }
74
+
75
+ /** Default bar a classified race answer must clear (0–10). Overridable. */
76
+ export const RACE_MIN_SCORE = Number(process.env.OZ_RACE_MIN_SCORE || 6);
77
+
78
+ /**
79
+ * Parse a cheap classify reply into a 0–10 score.
80
+ * Prefers `SCORE 7` / `SCORE: 7`; falls back to a lone 0–10.
81
+ * Unparseable → 0 (does not clear the bar).
82
+ */
83
+ export function parseClassifyScore(text) {
84
+ const s = String(text || '');
85
+ const tagged = /SCORE\s*[:=]?\s*(-?\d+(?:\.\d+)?)/i.exec(s);
86
+ const lone = tagged || /\b(10|[0-9])(?:\s*\/\s*10)?\b/.exec(s);
87
+ if (!lone) return 0;
88
+ const n = Number(lone[1]);
89
+ if (!Number.isFinite(n)) return 0;
90
+ return Math.max(0, Math.min(10, n));
91
+ }
92
+
93
+ /**
94
+ * Pick a winner among the first-X-back candidates after they have been scored.
95
+ * Passing = score >= minScore. Highest score wins; a tie is returned as
96
+ * `reason: 'tie'` so the caller can pairwise-break it. If nobody clears the
97
+ * bar, the last of the X is accepted — never blank.
98
+ */
99
+ export function pickRaceWinner(cands, minScore = RACE_MIN_SCORE) {
100
+ const list = Array.isArray(cands) ? cands.filter(Boolean) : [];
101
+ if (!list.length) return { winner: null, reason: 'empty', tied: [] };
102
+ const passing = list.filter((c) => (Number(c.score) || 0) >= minScore);
103
+ if (!passing.length) {
104
+ return { winner: list[list.length - 1], reason: 'fallback-last', tied: [] };
105
+ }
106
+ let max = -Infinity;
107
+ for (const c of passing) {
108
+ const sc = Number(c.score) || 0;
109
+ if (sc > max) max = sc;
110
+ }
111
+ const tied = passing.filter((c) => (Number(c.score) || 0) === max);
112
+ if (tied.length === 1) return { winner: tied[0], reason: 'score', tied };
113
+ return { winner: null, reason: 'tie', tied };
114
+ }
115
+
116
+ /**
117
+ * Live race bubble: stream the fastest still-alive entrant, swap once if the
118
+ * winner is someone else. `onDelta(text, { replace, model })`.
119
+ */
120
+ export function createRaceFeed(onDelta, onStatus, need) {
121
+ let live = null;
122
+ let settled = false;
123
+ let back = 0;
124
+ const buf = new Map();
125
+ const dead = new Set();
126
+ const paintStatus = () => { onStatus?.(formatRaceStatus(back, need)); };
127
+ return {
128
+ start() { paintStatus(); },
129
+ liveModel() { return live; },
130
+ onToken(model, chunk) {
131
+ if (settled || chunk == null || chunk === '') return;
132
+ buf.set(model, (buf.get(model) || '') + chunk);
133
+ if (!live) {
134
+ live = model;
135
+ onDelta?.(chunk, { model });
136
+ return;
137
+ }
138
+ if (live === model) onDelta?.(chunk, { model });
139
+ },
140
+ onFail(model) {
141
+ dead.add(model);
142
+ if (settled || live !== model) return;
143
+ const next = [...buf.entries()].find(([m, t]) => m !== model && t && !dead.has(m));
144
+ if (next) {
145
+ live = next[0];
146
+ onDelta?.(next[1], { replace: true, model: live });
147
+ } else {
148
+ live = null;
149
+ }
150
+ },
151
+ onBack() {
152
+ back += 1;
153
+ paintStatus();
154
+ },
155
+ settle(winner) {
156
+ settled = true;
157
+ const text = String(winner?.text || '').trim()
158
+ ? winner.text
159
+ : RACE_EVERY_FAILED;
160
+ // Live stream already showing this answer — keep going, do not re-dump.
161
+ if (winner?.model && live === winner.model && !winner.error) return;
162
+ live = winner?.model || live;
163
+ onDelta?.(text, { replace: true, model: winner?.model });
164
+ },
165
+ };
166
+ }
167
+
31
168
  export function peekDirectiveStatus(reply, runCmd) {
32
169
  if (runCmd) return `RUN: ${clipStatusArg(runCmd)}`;
33
170
  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,66 @@ 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
- * Streaming is deliberately not forwarded while the race runs: nobody knows who
634
- * is winning until they finish, and interleaving deltas from three models would
635
- * render as noise. The winner's text is emitted whole.
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/fetch-failed), ship a
645
+ * race-level error — never a single model's `(name failed: fetch failed)` as
646
+ * if it won. 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 brainStream(messages, onDelta, contextId, list[0], maxTokens);
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 attempts = list.map((m) => brainStream(messages, () => {}, contextId, m, maxTokens)
651
- .then((text) => { if (text && text.trim()) done.push({ model: m, text }); })
652
- .catch(() => { /* one entrant dying is not the race dying */ })
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
+ const arrival = { model: m, text: raw };
683
+ arrivals.push(arrival);
684
+ if (isRaceCountable(arrival)) {
685
+ done.push(arrival);
686
+ feed.onBack();
687
+ } else {
688
+ feed.onFail(m);
689
+ }
690
+ })
691
+ .catch((e) => {
692
+ arrivals.push({ model: m, text: '', error: e?.message || 'error' });
693
+ feed.onFail(m);
694
+ })
653
695
  .finally(() => {
654
696
  finished += 1;
655
697
  // Either we have what we asked for, or everyone is done and no more is
@@ -662,58 +704,79 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
662
704
  for (const p of attempts) p.catch(() => {});
663
705
 
664
706
  await enough;
665
- // Completion order, so this really is the first K back — not the first K
666
- // launched.
707
+ // Completion order, so this really is the first X back — not the first X
708
+ // launched. A slow 3rd never enters this set. Empty/5xx/fetch-failed stay
709
+ // in arrivals only so an all-fail race can ship one race-level error.
667
710
  const cands = done.slice(0, want);
668
- if (!cands.length) return '';
669
- // Nothing to compare do not spend a judging call to rubber-stamp one answer.
670
- if (cands.length === 1) { onDelta(cands[0].text); return cands[0].text; }
711
+ if (!cands.length) return ship(raceLastShip(arrivals));
712
+ // One real answer — nothing to compare. Ship it; do not spend a classify
713
+ // call to rubber-stamp the only candidate.
714
+ if (cands.length === 1) return ship(cands[0]);
715
+
716
+ onStatus?.('judging…');
717
+ const scored = await Promise.all(cands.map(async (c) => {
718
+ let score = 0;
719
+ try { score = Number(await classify(messages, c)) || 0; } catch { score = 0; }
720
+ return { ...c, score };
721
+ }));
722
+
723
+ let picked = pickRaceWinner(scored, minScore);
724
+ if (picked.reason === 'tie' && picked.tied.length > 1) {
725
+ let broken = null;
726
+ try { broken = await pairwise(messages, picked.tied); } catch { /* last of the tie */ }
727
+ const usable = broken && String(broken.text || '').trim();
728
+ // Malformed verdict / all equally bad → last finished of the tie, not empty.
729
+ picked = { winner: usable ? broken : picked.tied[picked.tied.length - 1], reason: 'tiebreak', tied: picked.tied };
730
+ }
731
+ return ship(picked.winner || scored[scored.length - 1] || raceLastShip(arrivals));
732
+ }
733
+
734
+ function raceQuestion(messages) {
735
+ const asked = [...messages].reverse().find((m) => m.role === 'user')?.content;
736
+ return typeof asked === 'string' ? asked : '(see candidates)';
737
+ }
671
738
 
672
- const winner = await judge(messages, cands);
673
- onDelta(winner.text);
674
- return winner.text;
739
+ /**
740
+ * Cheap structured score of ONE finished answer vs the question.
741
+ * This is grokui's own classify call — not OpenRouter's async log-tag
742
+ * Classifiers beta, which does not pick winners.
743
+ */
744
+ async function classifyRaceAnswer(messages, cand) {
745
+ const prompt = 'Score this answer to one question from 0 to 10.\n\n'
746
+ + 'QUESTION:\n' + String(raceQuestion(messages)).slice(0, 4000) + '\n\n'
747
+ + 'ANSWER:\n' + String(cand?.text || '').slice(0, 6000) + '\n\n'
748
+ + 'Judge on: correctness first, then completeness, then whether it actually did what was asked '
749
+ + '(a directive like RUN: or DONE: on one line is the correct format here, not a flaw). '
750
+ + 'Ignore length and confidence of tone.\n'
751
+ + 'Reply with exactly: SCORE <n>';
752
+ const verdict = await brainStream(
753
+ [{ role: 'user', content: prompt }], () => {}, undefined, JUDGE_MODEL, 24,
754
+ );
755
+ return parseClassifyScore(verdict);
675
756
  }
676
757
 
677
758
  /**
678
- * Pick the best of several finished answers with a small model.
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.
759
+ * Pairwise break among same-score passers. Blind A/B/C so the model names
760
+ * cannot leak the answer. Last of the tied set if the call dies.
688
761
  */
689
- async function judge(messages, cands) {
690
- const letters = cands.map((_, i) => String.fromCharCode(65 + i));
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)';
762
+ async function pairwiseTied(messages, tied) {
763
+ const letters = tied.map((_, i) => String.fromCharCode(65 + i));
695
764
  const prompt = 'You are judging answers to one question. Pick the single best one.\n\n'
696
- + 'QUESTION:\n' + String(question).slice(0, 4000) + '\n\n'
697
- + cands.map((c, i) => 'ANSWER ' + letters[i] + ':\n' + c.text.slice(0, 6000)).join('\n\n')
765
+ + 'QUESTION:\n' + String(raceQuestion(messages)).slice(0, 4000) + '\n\n'
766
+ + tied.map((c, i) => 'ANSWER ' + letters[i] + ':\n' + String(c.text || '').slice(0, 6000)).join('\n\n')
698
767
  + '\n\nJudge on: correctness first, then completeness, then whether it actually did what was asked '
699
768
  + '(a directive like RUN: or DONE: on one line is the correct format here, not a flaw). '
700
769
  + 'Ignore length and confidence of tone.\n'
701
770
  + 'Reply with ONE letter and nothing else: ' + letters.join(' or ') + '.';
702
771
  try {
703
772
  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
773
  const hit = String(verdict).toUpperCase().split('').find((ch) => {
708
774
  const n = ch.charCodeAt(0) - 65;
709
- return n >= 0 && n < cands.length;
775
+ return n >= 0 && n < tied.length;
710
776
  });
711
- if (hit) return cands[hit.charCodeAt(0) - 65];
777
+ if (hit) return tied[hit.charCodeAt(0) - 65];
712
778
  } catch { /* fall through */ }
713
- // A dead or delisted judge must not lose the answers. Falling back to the
714
- // first finisher degrades this to "fastest wins" — worse than judged, far
715
- // better than empty.
716
- return cands[0];
779
+ return tied[tied.length - 1];
717
780
  }
718
781
 
719
782
  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.94",
3
+ "version": "0.48.97",
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",