atris 3.74.13 → 3.74.14

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/gm-bet.js CHANGED
@@ -372,7 +372,11 @@ const SAY_READ_MS = 15000;
372
372
  const SAY_KINDS = new Set(['question', 'answer', 'instruction', 'unclear']);
373
373
  // on the weekly number card, what an instruction wants done with the number
374
374
  const NUMBER_INTENTS = new Set(['stop', 'change', 'later', 'other']);
375
+ // only for a model that answered and could not make sense of the words
375
376
  const SAY_FALLBACK = 'say that another way, or tap a button.';
377
+ // no model answered the words: never put on them
378
+ const SAY_NO_MODEL = 'the orb needs a model signed in on this computer to read that, so nothing was saved.';
379
+ const SAY_NOT_HEARD = 'the model did not answer this time, so nothing was saved. try again in a moment.';
376
380
  // the last turns of a card's conversation the reader sees
377
381
  const THREAD_TURNS = 6;
378
382
 
@@ -391,7 +395,10 @@ function readerModel(workspaceRoot) {
391
395
  // One quick call: what kind of message the owner typed on the card on
392
396
  // screen, and what to say back. The card, the conversation on it so far, and
393
397
  // a short view of the project go in.
394
- async function readSaid({ text, card, thread = [], hint = null, workspaceRoot, bundle = null, deps = {} } = {}) {
398
+ // A read no model answered says so (no_model, retry from think); a model
399
+ // that answered with a read of no use says answered, so only that one asks
400
+ // the owner to say it another way.
401
+ async function readSaid({ text, card, thread = [], hint = null, workspaceRoot, bundle = null, deps = {}, timeoutMs = SAY_READ_MS } = {}) {
395
402
  const context = clip((deps.readContext || readProjectContext)(workspaceRoot), 1500);
396
403
  const goal = bundle && bundle.sources && bundle.sources.goal;
397
404
  const prompt = [
@@ -424,11 +431,12 @@ async function readSaid({ text, card, thread = [], hint = null, workspaceRoot, b
424
431
  context || '(no readme or goals found)',
425
432
  ].filter(l => l !== '').join('\n');
426
433
  const model = (deps.readerModel || readerModel)(workspaceRoot);
427
- const said = await (deps.runModel || runModel)(prompt, { cwd: workspaceRoot, timeoutMs: SAY_READ_MS, command: { bin: 'claude', args: p => claudeArgs(p, model) }, onProgress: () => {} });
428
- if (!said.ok) return { ok: false, reason: said.reason };
434
+ const run = deps.runModel || ((p, o) => think(p, { ...o, root: workspaceRoot }));
435
+ const said = await run(prompt, { cwd: workspaceRoot, timeoutMs, command: { bin: 'claude', args: p => claudeArgs(p, model) }, onProgress: () => {} });
436
+ if (!said.ok) return { ok: false, reason: said.reason, ...(said.no_model ? { no_model: said.no_model, retry: Boolean(said.retry) } : {}) };
429
437
  const parsed = parseBet(said.text);
430
438
  const v = parsed.bet;
431
- if (!v || !SAY_KINDS.has(v.kind) || typeof v.reply !== 'string') return { ok: false, reason: 'the read was not the shape asked for' };
439
+ if (!v || !SAY_KINDS.has(v.kind) || typeof v.reply !== 'string') return { ok: false, reason: 'the read was not the shape asked for', answered: true };
432
440
  const suggestions = Array.isArray(v.suggestions) ? v.suggestions.filter(x => typeof x === 'string' && x.trim()).map(x => x.trim()) : [];
433
441
  const intent = card.kind === 'number' && NUMBER_INTENTS.has(v.intent) ? v.intent : null;
434
442
  return { ok: true, kind: v.kind, reply: clip(v.reply, 280), ...(intent ? { intent } : {}), ...(suggestions.length ? { suggestions } : {}) };
@@ -452,7 +460,8 @@ async function generateScoreboardChoices({ workspaceRoot, bundle = null, deps =
452
460
  '',
453
461
  bundleText(b),
454
462
  ].join('\n');
455
- const said = await (deps.runModel || runModel)(prompt, { cwd: workspaceRoot, onProgress: () => {} });
463
+ const run = deps.runModel || ((p, o) => think(p, { ...o, root: workspaceRoot }));
464
+ const said = await run(prompt, { cwd: workspaceRoot, onProgress: () => {} });
456
465
  if (!said.ok) return { ok: false, reason: said.reason };
457
466
  const parsed = parseBet(said.text);
458
467
  const list = parsed.bet && Array.isArray(parsed.bet.choices) ? parsed.bet.choices : [];
@@ -470,7 +479,7 @@ async function generateQuestion({ workspaceRoot, answers = [], avoid = [], bundl
470
479
  const b = bundle || await readBundle(workspaceRoot, { deps, ledgerFile, today });
471
480
  const context = (deps.readContext || readProjectContext)(workspaceRoot);
472
481
  const source = `${context}\n${bundleText(b)}`;
473
- const run = deps.runModel || runModel;
482
+ const run = deps.runModel || ((p, o) => think(p, { ...o, root: workspaceRoot }));
474
483
  const ask = async (prompt) => {
475
484
  const said = await run(prompt, { cwd: workspaceRoot, onProgress: () => {} });
476
485
  if (!said.ok) return { failed: said.reason };
@@ -1523,10 +1532,14 @@ function quietDir() {
1523
1532
  // Writers running now, by process group, so the server can end them when it
1524
1533
  // stops; otherwise a stopped server leaves them running on their own.
1525
1534
  const LIVE_WRITERS = new Map();
1535
+ // how many times the server stopped its writers: a call under way when it
1536
+ // did starts no other model after
1537
+ let WRITER_STOPS = 0;
1526
1538
 
1527
1539
  // SIGTERM now, then SIGKILL for the same groups after the grace, so a child
1528
1540
  // that ignores SIGTERM does not outlive the server either.
1529
1541
  function stopWriters() {
1542
+ WRITER_STOPS += 1;
1530
1543
  let n = 0;
1531
1544
  const groups = [...LIVE_WRITERS];
1532
1545
  for (const [pid, kill] of groups) {
@@ -1544,6 +1557,9 @@ function stopWriters() {
1544
1557
  return n;
1545
1558
  }
1546
1559
 
1560
+ // A start that failed because the program is not on this computer.
1561
+ const notHere = err => (err && err.code === 'ENOENT' ? { missing: true } : {});
1562
+
1547
1563
  // A signal (an AbortSignal) stops the call early: the whole process group
1548
1564
  // gets SIGTERM, then SIGKILL if it is still there after STOP_GRACE_MS, and
1549
1565
  // the call resolves once the process is gone, so nothing it started is left.
@@ -1561,7 +1577,7 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
1561
1577
  try {
1562
1578
  child = spawnFn(command.bin, args, { cwd, env: modelEnv(env), detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
1563
1579
  } catch (err) {
1564
- resolve({ ok: false, reason: `could not start the model: ${err.message}` });
1580
+ resolve({ ok: false, reason: `could not start the model: ${err.message}`, ...notHere(err) });
1565
1581
  return;
1566
1582
  }
1567
1583
  if (child && child.pid) LIVE_WRITERS.set(child.pid, kill);
@@ -1571,6 +1587,8 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
1571
1587
  let text = '';
1572
1588
  let result = null;
1573
1589
  let thinking = 0;
1590
+ // the model began to answer: the api sent its first event back
1591
+ let heard = false;
1574
1592
  let timedOut = false;
1575
1593
  let stopped = false;
1576
1594
  let hardKill = null;
@@ -1610,6 +1628,7 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
1610
1628
  } catch {
1611
1629
  return;
1612
1630
  }
1631
+ if (ev.type === 'stream_event' || ev.type === 'assistant' || ev.type === 'result') heard = true;
1613
1632
  if (ev.type === 'system' && ev.subtype === 'thinking_tokens' && Number.isFinite(ev.estimated_tokens)) {
1614
1633
  thinking = ev.estimated_tokens;
1615
1634
  onProgress({ thinking, chars: text.length, text });
@@ -1641,11 +1660,12 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
1641
1660
  if (!stopped) LIVE_WRITERS.delete(child.pid);
1642
1661
  resolve(out);
1643
1662
  };
1644
- child.on('error', err => finish({ ok: false, reason: `could not start the model: ${err.message}` }));
1663
+ child.on('error', err => finish({ ok: false, reason: `could not start the model: ${err.message}`, ...notHere(err) }));
1645
1664
  child.on('close', (code) => {
1646
1665
  if (buf) line(buf);
1647
- if (stopped) return finish({ ok: false, reason: STOPPED_REASON, stopped: true });
1648
- if (timedOut) return finish({ ok: false, reason: `the model took longer than ${Math.round(timeoutMs / 1000)} seconds and was stopped`, timed_out: true });
1666
+ const began = heard ? { heard: true } : {};
1667
+ if (stopped) return finish({ ok: false, reason: STOPPED_REASON, stopped: true, ...began });
1668
+ if (timedOut) return finish({ ok: false, reason: `the model took longer than ${Math.round(timeoutMs / 1000)} seconds and was stopped`, timed_out: true, ...began });
1649
1669
  if (result && result.is_error) return finish({ ok: false, reason: `the model said: ${clip(result.result || result.subtype, 160)}` });
1650
1670
  const answer = result && typeof result.result === 'string' ? result.result : text;
1651
1671
  if (code !== 0 && !answer) return finish({ ok: false, reason: `the model exited with code ${code}${stderr.trim() ? `: ${clip(stderr.trim().split('\n').pop(), 160)}` : ''}` });
@@ -1655,6 +1675,196 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
1655
1675
  });
1656
1676
  }
1657
1677
 
1678
+ // ---- who thinks: claude first, then the next model signed in here ---------------------
1679
+
1680
+ // The orb thinks on claude, the way it always has. When claude is not on
1681
+ // this computer, fails, or never starts to answer, the next model the
1682
+ // engine registry says is here answers instead, in the registry's own
1683
+ // fallback order, each on its own default model and launched the way
1684
+ // `atris engine ask` launches it (read-only). Hosted engines are left out:
1685
+ // they spend the owner's atris tokens, and the orb drafts in the background.
1686
+ //
1687
+ // What is known lives in memory, one entry per workspace: the registry's
1688
+ // list, read the way every router reads it (the first read saves it, later
1689
+ // reads take about a millisecond; an unsaved one probes every tool, about
1690
+ // half a second on a busy Mac) and kept for ten minutes (THINKER_LIST_MS),
1691
+ // and the models that just failed, which sit out for five minutes
1692
+ // (THINKER_BENCH_MS). A tap on the card that says no model answered forgets
1693
+ // both (forgetThinkers), so a model signed in a moment ago works on the very
1694
+ // next tap.
1695
+ const THINKER_LIST_MS = 10 * 60 * 1000;
1696
+ const THINKER_BENCH_MS = 5 * 60 * 1000;
1697
+ // claude that sent nothing back from the api for this long is not answering
1698
+ const UNHEARD_MS = 15000;
1699
+ const THINKERS = new Map();
1700
+
1701
+ function thinkerState(root) {
1702
+ const key = path.resolve(String(root || '.'));
1703
+ if (!THINKERS.has(key)) THINKERS.set(key, { list: null, listAt: 0, bench: new Map(), answered: new Map(), benches: 0, fresh: false });
1704
+ return THINKERS.get(key);
1705
+ }
1706
+
1707
+ // The next list read looks for every tool itself. The count of benches
1708
+ // only grows, so a drafting under way still knows whether one sat out.
1709
+ function forgetThinkers(root) {
1710
+ const st = thinkerState(root);
1711
+ st.list = null;
1712
+ st.fresh = true;
1713
+ st.bench.clear();
1714
+ st.answered.clear();
1715
+ }
1716
+
1717
+ // The models that can think here, in order: the one on the claude program
1718
+ // first (it runs the orb's own command), then every other installed,
1719
+ // non-hosted engine by the registry's fallback order, one per program.
1720
+ function thinkerLineup(root) {
1721
+ const st = thinkerState(root);
1722
+ if (st.list && Date.now() - st.listAt < THINKER_LIST_MS) return st.list;
1723
+ let engines = [];
1724
+ try {
1725
+ const reg = require('./engine-registry');
1726
+ const known = reg.readEngineRegistry(root).engines.filter(e => !reg.engineHosted(e));
1727
+ engines = known.filter(e => e.installed === true);
1728
+ // A saved "not here" can be older than the tool on this computer now (a
1729
+ // saved credit wall is never looked at again, a missing tool for a
1730
+ // minute), so the orb looks for itself: for its own model on every read,
1731
+ // and for every tool after a tap on the card that says no model answered.
1732
+ const bins = new Set(engines.map(e => e.bin));
1733
+ for (const e of known.filter(x => st.fresh || x.id === 'claude')) {
1734
+ if (bins.has(e.bin)) continue;
1735
+ bins.add(e.bin);
1736
+ if (reg.binInstalled(e.bin)) engines.push(e);
1737
+ }
1738
+ } catch {
1739
+ engines = [];
1740
+ }
1741
+ const lead = e => (e.bin === 'claude' ? 0 : 1);
1742
+ engines.sort((a, b) => lead(a) - lead(b) || Number(a.fallback_order) - Number(b.fallback_order) || String(a.id).localeCompare(String(b.id)));
1743
+ const once = new Set();
1744
+ st.list = engines.filter(e => !once.has(e.bin) && once.add(e.bin)).map(e => ({ id: e.id, lead: e.bin === 'claude' }));
1745
+ st.listAt = Date.now();
1746
+ st.fresh = false;
1747
+ return st.list;
1748
+ }
1749
+
1750
+ function benched(st, id) {
1751
+ const b = st.bench.get(id);
1752
+ if (!b) return false;
1753
+ if (Date.now() < b.until) return true;
1754
+ st.bench.delete(id);
1755
+ return false;
1756
+ }
1757
+
1758
+ function benchThinker(st, id, reason, missing = false) {
1759
+ st.bench.set(id, { until: Date.now() + THINKER_BENCH_MS, reason: clip(String(reason || ''), 160), missing });
1760
+ st.benches += 1;
1761
+ }
1762
+
1763
+ // 'ready': a model here can take the next call. 'none': no model is on this
1764
+ // computer. 'failed': every one here failed or never answered just now.
1765
+ function thinkerVerdict(root) {
1766
+ const st = thinkerState(root);
1767
+ const list = thinkerLineup(root);
1768
+ if (list.some(t => !benched(st, t.id))) return 'ready';
1769
+ return list.every(t => st.bench.get(t.id) && st.bench.get(t.id).missing) ? 'none' : 'failed';
1770
+ }
1771
+
1772
+ // A fallback model's child joins the writers the server ends when it stops.
1773
+ function trackedSpawn(bin, args, opts) {
1774
+ const child = require('child_process').spawn(bin, args, opts);
1775
+ if (child && child.pid) {
1776
+ const kill = process.kill.bind(process);
1777
+ LIVE_WRITERS.set(child.pid, kill);
1778
+ child.once('close', () => { if (LIVE_WRITERS.get(child.pid) === kill) LIVE_WRITERS.delete(child.pid); });
1779
+ }
1780
+ return child;
1781
+ }
1782
+
1783
+ // One answer from a model other than claude, through the read-only command
1784
+ // `atris engine ask` builds for it, in the same shape runModel answers.
1785
+ function askThinker(id, prompt, { timeoutMs = MODEL_TIMEOUT_MS, onProgress = () => {}, signal = null } = {}) {
1786
+ const ask = require('./engine-ask');
1787
+ let invocation;
1788
+ try {
1789
+ invocation = ask.buildReadOnlyEngineInvocation(id, prompt, '', { timeoutMs, effort: IDEA_EFFORT });
1790
+ } catch (err) {
1791
+ return Promise.resolve({ ok: false, reason: `could not start the model: ${err.message}` });
1792
+ }
1793
+ let text = '';
1794
+ // any word on stdout: the model began to answer
1795
+ let heard = false;
1796
+ return ask.runAskProcess(invocation, {
1797
+ cwd: invocation.cwd || quietDir(),
1798
+ timeoutMs,
1799
+ signal,
1800
+ env: modelEnv(process.env),
1801
+ spawnProcess: trackedSpawn,
1802
+ onOutputChunk: (chunk, stream) => {
1803
+ if (stream !== 'stdout' || text.length > OUTPUT_MAX) return;
1804
+ heard = true;
1805
+ text += chunk.toString('utf8');
1806
+ onProgress({ thinking: 0, chars: text.length, text });
1807
+ },
1808
+ }).then((r) => {
1809
+ const began = heard ? { heard: true } : {};
1810
+ if (r.cancelled) return { ok: false, reason: STOPPED_REASON, stopped: true, ...began };
1811
+ if (r.timed_out) return { ok: false, reason: `the model took longer than ${Math.round(timeoutMs / 1000)} seconds and was stopped`, timed_out: true, ...began };
1812
+ const tail = String(r.stderr || r.stdout || '').trim().split('\n').pop();
1813
+ if (r.reason === 'spawn_error') return { ok: false, reason: `could not start the model: ${clip(tail, 160)}`, ...(/ENOENT/.test(r.stderr || '') ? { missing: true } : {}) };
1814
+ // a failed exit is never an answer, whatever it printed (a login error on stdout)
1815
+ if (r.exit_code !== 0) return { ok: false, reason: `the model exited with code ${r.exit_code}${tail ? `: ${clip(tail, 160)}` : ''}` };
1816
+ const answer = String(r.stdout || '');
1817
+ if (!answer.trim()) return { ok: false, reason: 'the model gave an empty answer' };
1818
+ return { ok: true, text: answer };
1819
+ });
1820
+ }
1821
+
1822
+ // One call through the lineup: each model in turn until one answers, each
1823
+ // with what is left of timeoutMs. command is the claude command the call
1824
+ // wants (its model and effort); the others ride their own defaults. A stop,
1825
+ // or the server stopping its writers, ends it at once. A model that fails
1826
+ // sits out; so does one that said nothing until its time ran out, or for
1827
+ // unheardMs before a stop, unless it answered another call meanwhile. A
1828
+ // model that began to answer and is only slow keeps its place, and one
1829
+ // that answers is back in at once. With no answer, no_model says why:
1830
+ // 'none' when no model is on this computer, else 'failed'; retry says
1831
+ // another one here can still take the next call.
1832
+ async function think(prompt, { root, command = { bin: 'claude', args: claudeArgs }, cwd, timeoutMs = MODEL_TIMEOUT_MS, onProgress = () => {}, signal = null, unheardMs = UNHEARD_MS } = {}) {
1833
+ const st = thinkerState(root);
1834
+ const deadline = Date.now() + timeoutMs;
1835
+ const stops = WRITER_STOPS;
1836
+ const shutDown = () => ({ ok: false, reason: STOPPED_REASON, stopped: true });
1837
+ let last = null;
1838
+ for (const t of thinkerLineup(root)) {
1839
+ if (WRITER_STOPS !== stops) return shutDown();
1840
+ if (benched(st, t.id)) continue;
1841
+ const left = deadline - Date.now();
1842
+ if (left <= 0) break;
1843
+ const started = Date.now();
1844
+ const out = t.lead
1845
+ ? await runModel(prompt, { command, cwd, timeoutMs: left, onProgress, signal })
1846
+ : await askThinker(t.id, prompt, { timeoutMs: left, onProgress, signal });
1847
+ if (out.ok) {
1848
+ st.answered.set(t.id, Date.now());
1849
+ st.bench.delete(t.id);
1850
+ return { ...out, engine: t.id };
1851
+ }
1852
+ // the server stopped while this one ran: not the model's failure
1853
+ if (WRITER_STOPS !== stops) return shutDown();
1854
+ // heard: claude's api sent an event back, or another model printed a word
1855
+ const silent = !out.heard && !(st.answered.get(t.id) >= started);
1856
+ if (out.stopped) {
1857
+ if (silent && Date.now() - started >= unheardMs) benchThinker(st, t.id, 'it never started to answer');
1858
+ return { ...out, engine: t.id, ...(silent ? { unheard: true } : {}) };
1859
+ }
1860
+ last = out;
1861
+ if (out.timed_out && !silent) break;
1862
+ benchThinker(st, t.id, out.reason, Boolean(out.missing));
1863
+ }
1864
+ const verdict = thinkerVerdict(root);
1865
+ return { ok: false, reason: last ? last.reason : 'no model here can think right now', no_model: verdict === 'none' ? 'none' : 'failed', retry: verdict === 'ready' };
1866
+ }
1867
+
1658
1868
  // ---- checking a bet -------------------------------------------------------------------
1659
1869
 
1660
1870
  function parseBet(text) {
@@ -2152,6 +2362,14 @@ function ownerConcern(c) {
2152
2362
  // Every model attempt, writer and skeptic (the kind field), with its lane, is
2153
2363
  // kept in the attempts log, the stopped ones too.
2154
2364
  async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTitles: avoidIn = [], reworkOf = null, ownerNote = null, concern = null, emit = () => {}, onBundle = () => {}, deps = {}, ledgerFile, attemptsFile, allowTeamMetrics = false, writers = deps.writers || SWARM_WRITERS, budgetMs = deps.budgetMs || SWARM_BUDGET_MS, signal = null } = {}) {
2365
+ // the orb's lineup of models thinks, unless a test stubs the model
2366
+ const lineup = !deps.runModel;
2367
+ // no model here can think: said at once, before a fact is read
2368
+ if (lineup) {
2369
+ const verdict = thinkerVerdict(workspaceRoot);
2370
+ if (verdict !== 'ready') return { ok: false, reason: verdict === 'none' ? 'no model is on this computer' : 'every model here failed just now', no_model: verdict, retry: false };
2371
+ }
2372
+ const benchesBefore = lineup ? thinkerState(workspaceRoot).benches : 0;
2155
2373
  // the budget runs on the real clock, the one the stop timer uses
2156
2374
  const deadline = Date.now() + budgetMs;
2157
2375
  const left = () => deadline - Date.now();
@@ -2159,10 +2377,12 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
2159
2377
  const judgeMin = Math.min(JUDGE_MIN_LEFT_MS, budgetMs * (JUDGE_MIN_LEFT_MS / SWARM_BUDGET_MS));
2160
2378
  // a rework may stay close to the idea it reworks
2161
2379
  const avoidTitles = reworkOf ? avoidIn.filter(t => t !== reworkOf) : avoidIn;
2162
- const run = deps.runModel || ((prompt, opts) => runModel(prompt, { ...opts, command: writerCommand() }));
2380
+ // claude silent for half the budget is not answering this drafting
2381
+ const unheardMs = Math.min(UNHEARD_MS, budgetMs / 2);
2382
+ const run = deps.runModel || ((prompt, opts) => think(prompt, { ...opts, root: workspaceRoot, command: writerCommand(), unheardMs }));
2163
2383
  // tests pass runSkeptic; a stubbed runModel without one is also used for the
2164
2384
  // skeptic, so a test can never reach the real model by accident
2165
- const judge = deps.runSkeptic || deps.runModel || ((prompt, opts) => runModel(prompt, { ...opts, command: skepticCommand() }));
2385
+ const judge = deps.runSkeptic || deps.runModel || ((prompt, opts) => think(prompt, { ...opts, root: workspaceRoot, command: skepticCommand(), unheardMs }));
2166
2386
  const clock = deps.now || Date.now;
2167
2387
  const cwd = deps.runModel ? workspaceRoot : quietDir();
2168
2388
  const n = Math.max(1, Math.floor(Number(writers)) || 1);
@@ -2176,6 +2396,9 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
2176
2396
  const live = new Set();
2177
2397
  let turnedDown = 0;
2178
2398
  let checking = 0;
2399
+ // writer answers that came back, and writers stopped before the api said a word
2400
+ let answers = 0;
2401
+ let unheard = 0;
2179
2402
  let winner = null;
2180
2403
  let over = null;
2181
2404
  let release;
@@ -2261,7 +2484,9 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
2261
2484
  emit({ kind: 'thinking', line: w ? `thinking... ${commas(w)} words written` : 'thinking...', n: w, lane: laneNo });
2262
2485
  },
2263
2486
  });
2264
- const base = { at: new Date(clock()).toISOString(), workspace: workspaceRoot, today, attempt, strategy: how, ...tag, ...(how === 'blocker' ? { blocker: blocker.missing_first } : {}), previous_title: previousTitle, avoid_titles: avoid.length, ms: clock() - started };
2487
+ const base = { at: new Date(clock()).toISOString(), workspace: workspaceRoot, today, attempt, strategy: how, ...tag, ...(how === 'blocker' ? { blocker: blocker.missing_first } : {}), previous_title: previousTitle, avoid_titles: avoid.length, ms: clock() - started, ...(answer.engine ? { engine: answer.engine } : {}) };
2488
+ if (answer.ok) answers += 1;
2489
+ else if (answer.unheard) unheard += 1;
2265
2490
  if (!answer.ok) {
2266
2491
  saveAttempt(attemptsFile, { ...base, kind: 'writer', model_ok: false, ...(answer.stopped ? { stopped: true } : {}), raw: null, parse: null, passed: false, reasons: [answer.reason] });
2267
2492
  return { failed: answer.reason, stopped: Boolean(answer.stopped), base };
@@ -2373,6 +2598,13 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
2373
2598
  Promise.all(lanes).then(() => stop('done'));
2374
2599
  await ended;
2375
2600
  finish();
2601
+ // out of time with no answer back: the stopped calls end within the stop's
2602
+ // grace, and only then is it known whether claude ever started to answer
2603
+ if (lineup && over === 'budget' && !answers) {
2604
+ let grace;
2605
+ await Promise.race([Promise.all(lanes), new Promise((resolve) => { grace = setTimeout(resolve, STOP_GRACE_MS + 500); })]);
2606
+ clearTimeout(grace);
2607
+ }
2376
2608
 
2377
2609
  if (over === 'stopped') return { ok: false, stopped: true, reason: 'stopped before an idea was picked', bundle };
2378
2610
  if (winner) return { ok: true, bet: { ...winner.bet, skeptic: winner.verdict }, bundle, attempts: winner.attempt, lane: winner.lane, checked: true };
@@ -2386,7 +2618,19 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
2386
2618
  emit({ kind: 'none', line: 'no draft met the plain rules', say: SWARM_SAY.nothing });
2387
2619
  const failedAll = ends.length > 0 && ends.every(e => e.model_failed);
2388
2620
  const last = [...ends].reverse().find(e => e.reason);
2389
- if (failedAll) return { ok: false, reason: ends[0].reason, bundle };
2621
+ // No model answered: every writer's call failed, or the time ran out with
2622
+ // claude never starting to answer. That is the model's trouble, never the
2623
+ // owner's: no_model says so, and retry says another model here can take
2624
+ // the next drafting (one sat out during this one, so it is not the same try).
2625
+ const silent = over === 'budget' && !answers && unheard > 0;
2626
+ if (failedAll || silent) {
2627
+ const reason = failedAll ? ends[0].reason : `no model answered in ${Math.round(budgetMs / 1000)} seconds`;
2628
+ if (!lineup && failedAll) return { ok: false, reason, bundle, no_model: 'failed', retry: false };
2629
+ const verdict = lineup ? thinkerVerdict(workspaceRoot) : 'ready';
2630
+ if (verdict !== 'ready') return { ok: false, reason, bundle, no_model: verdict, retry: false };
2631
+ if (lineup && thinkerState(workspaceRoot).benches > benchesBefore) return { ok: false, reason, bundle, no_model: 'failed', retry: true };
2632
+ if (failedAll) return { ok: false, reason, bundle };
2633
+ }
2390
2634
  if (ends.length > 0 && ends.every(e => e.out_of_time) && !turnedDown) return { ok: false, reason: `no time was left to draft inside ${Math.round(budgetMs / 1000)} seconds once the facts were read`, bundle };
2391
2635
  if (over === 'budget') return { ok: false, reason: `nothing usable came back in ${Math.round(budgetMs / 1000)} seconds${last ? ` (${last.reason})` : ''}`, bundle };
2392
2636
  return { ok: false, reason: `the model's answer was turned down ${plural(turnedDown, 'time')} (${last ? last.reason : 'no answer met the rules'})`, bundle };
@@ -2854,6 +3098,7 @@ const BACKOFF_MS = 10 * 60 * 1000;
2854
3098
  // sayTimeoutMs how long a read may take before the plain fallback (15 s)
2855
3099
  // loadSeason(root) -> season, the board's 30 days of plays for each ovr (tests stub)
2856
3100
  // resolveEngine(name) -> { engine, model }, the member's roster pick (tests stub)
3101
+ // wakeThinkers() forget which models were missing or failing, on a tap on the card that says so
2857
3102
  function createBetDesk(deps) {
2858
3103
  const now = deps.now || Date.now;
2859
3104
  const setTimer = deps.setTimer || ((fn, ms) => { const t = setTimeout(fn, ms); if (t.unref) t.unref(); return t; });
@@ -2867,7 +3112,7 @@ function createBetDesk(deps) {
2867
3112
  // a desk with a stubbed idea writer never reaches the real reader either
2868
3113
  const readFn = deps.readSaid || (deps.generate
2869
3114
  ? (async () => ({ ok: false, reason: 'no reader here' }))
2870
- : (opts => readSaid({ ...opts, workspaceRoot: deps.workspaceRoot, bundle: state.bundle, deps: deps.generatorDeps || {} })));
3115
+ : (opts => readSaid({ ...opts, workspaceRoot: deps.workspaceRoot, bundle: state.bundle, deps: deps.generatorDeps || {}, ...(deps.sayTimeoutMs ? { timeoutMs: deps.sayTimeoutMs } : {}) })));
2871
3116
  const suggestNumbers = deps.suggestScoreboard || (deps.generate
2872
3117
  ? (() => new Promise(() => {}))
2873
3118
  : (opts => generateScoreboardChoices({ ...opts, deps: deps.generatorDeps || {}, ledgerFile: deps.ledgerFile, today: dayKey(now()) })));
@@ -2878,6 +3123,7 @@ function createBetDesk(deps) {
2878
3123
  // the real writer starts on the next turn, so its first reads never hold up a tap
2879
3124
  const generate = deps.generate || (opts => new Promise(resolve => setImmediate(resolve)).then(() => generateBet({ ...opts, deps: deps.generatorDeps || {}, ledgerFile: deps.ledgerFile, attemptsFile: deps.attemptsFile })));
2880
3125
  const log = deps.log || (() => {});
3126
+ const wakeThinkers = deps.wakeThinkers || (() => forgetThinkers(deps.workspaceRoot));
2881
3127
  const state = {
2882
3128
  status: 'idle',
2883
3129
  current: null,
@@ -3408,6 +3654,10 @@ function createBetDesk(deps) {
3408
3654
  log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'wait' });
3409
3655
  }
3410
3656
  const onWait = () => state.status === 'ready' && Boolean(state.current) && state.current.kind === 'wait';
3657
+ // the card on screen says no model answered
3658
+ const onNoModel = () => state.status === 'ready' && Boolean(state.current) && state.current.kind === 'missed' && Boolean(state.current.no_model);
3659
+ // the line the calm line shows when one model did not answer and another takes over
3660
+ const NEXT_MODEL_LINE = 'the model here did not answer, so another one is trying.';
3411
3661
 
3412
3662
  // Words typed on the calm line are a note for the idea being written: no
3413
3663
  // read, no new card. The next drafting starts with them (one under way
@@ -3480,19 +3730,22 @@ function createBetDesk(deps) {
3480
3730
  return { progress: lines, started_at: startedAt, waited_ms: Math.max(0, now() - startedAt) };
3481
3731
  }
3482
3732
 
3483
- // The wait ended with no draft that met the plain rules (the model is down,
3484
- // or every draft broke a rule): a card the owner can act on, never an error.
3733
+ // The wait ended with no draft that met the plain rules: a card the owner
3734
+ // can act on, never an error. When no model answered at all (noModel:
3735
+ // 'none', no model on this computer, or 'failed', the one here did not
3736
+ // answer) the card says that instead, and what to do.
3485
3737
  const MISSED_CARD = { what: 'no idea came together this time.' };
3738
+ const NO_MODEL_CARD = { what: 'the orb needs a model signed in on this computer.' };
3486
3739
  // It remembers the wait it ended (after, and the id that wait was served
3487
3740
  // under), so words the owner was typing there still count.
3488
- function showMissed() {
3741
+ function showMissed(noModel = null) {
3489
3742
  const was = state.current && state.current.kind === 'wait' ? state.current : null;
3490
3743
  const servedWait = was && state.served && state.served.kind === 'wait' ? state.served.id : null;
3491
- state.current = { kind: 'missed', id: `missed:${now()}`, ...(was ? { after: was.id, afterServed: servedWait, ...(was.note ? { note: was.note, notes: was.notes } : {}) } : {}) };
3744
+ state.current = { kind: 'missed', id: `missed:${now()}`, ...(noModel ? { no_model: noModel } : {}), ...(was ? { after: was.id, afterServed: servedWait, ...(was.note ? { note: was.note, notes: was.notes } : {}) } : {}) };
3492
3745
  state.status = 'ready';
3493
3746
  state.mission = null;
3494
3747
  state.error = null;
3495
- log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'missed' });
3748
+ log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'missed', ...(noModel ? { no_model: noModel } : {}) });
3496
3749
  }
3497
3750
 
3498
3751
  // The owner asked for something (a sharper idea, words on a card): a
@@ -3601,12 +3854,24 @@ function createBetDesk(deps) {
3601
3854
  }
3602
3855
  if (!out.ok) {
3603
3856
  // a failure stays in the log; the owner never sees the reason
3604
- log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 1, error: clip(out.reason, 200) });
3857
+ log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 1, error: clip(out.reason, 200), ...(out.no_model ? { no_model: out.no_model } : {}) });
3605
3858
  emit({ kind: 'failed', line: out.reason });
3859
+ // one model did not answer and another one here can: the next
3860
+ // drafting starts on it now, with what this one was asked, and the
3861
+ // calm line says so instead of ending
3862
+ if (out.no_model && out.retry) {
3863
+ if (gen.asked && !state.pendingSharper) state.pendingSharper = asks;
3864
+ if (onWait()) {
3865
+ state.current.lines = [...(state.current.lines || []), { at: now(), line: NEXT_MODEL_LINE }];
3866
+ state.current.waitsOn = null;
3867
+ }
3868
+ return pump();
3869
+ }
3606
3870
  state.failures += 1;
3607
3871
  if (state.failures >= MAX_BACKGROUND_FAILURES) backOff();
3608
- // the owner is waiting on the calm line: the wait ends in a card he can act on
3609
- if (onWait()) showMissed();
3872
+ // the owner is waiting on the calm line: the wait ends in a card he
3873
+ // can act on, the one that says no model answered when none did
3874
+ if (onWait()) showMissed(out.no_model || null);
3610
3875
  return pump();
3611
3876
  }
3612
3877
  const item = { id: newId(), bet: out.bet, created_at: now(), state_key: key, ...(gen.asked ? { asked: true } : {}), ...(ownerNote && reworkOf ? { rework_of: reworkOf } : {}), ...(ownerNote ? { owner_note: ownerNote } : {}) };
@@ -3677,6 +3942,8 @@ function createBetDesk(deps) {
3677
3942
  // later: not now on a yes card.
3678
3943
  function next({ sharper = false, later = false } = {}) {
3679
3944
  if (state.status === 'starting') return { ok: true, busy: true, ...view() };
3945
+ // a tap on the card that says no model answered looks again, so one signed in since then is found
3946
+ if (onNoModel()) wakeThinkers();
3680
3947
  const onScreen = state.status === 'ready' && state.current ? state.current : null;
3681
3948
  // try again after a wait that ended with a note on it: the note goes into the next
3682
3949
  // drafting, or the calm line joins the one already writing it, never a second
@@ -4020,7 +4287,7 @@ function createBetDesk(deps) {
4020
4287
  id: cur ? cur.id : null,
4021
4288
  kind,
4022
4289
  bet: kind === 'bet' ? cur.bet : null,
4023
- job: kind === 'yes' || kind === 'done' ? publicJob(cur) : (kind === 'wait' ? { ...WAIT_CARD } : (kind === 'missed' ? { ...MISSED_CARD } : null)),
4290
+ job: kind === 'yes' || kind === 'done' ? publicJob(cur) : (kind === 'wait' ? { ...WAIT_CARD } : (kind === 'missed' ? { ...(cur.no_model ? NO_MODEL_CARD : MISSED_CARD) } : null)),
4024
4291
  question: kind === 'question' ? { ask: cur.question.ask, line: cur.question.line, choices: cur.question.choices } : null,
4025
4292
  report: kind === 'report' ? {
4026
4293
  what: `${cur.board.name}: what is it this week?`,
@@ -4083,7 +4350,9 @@ function createBetDesk(deps) {
4083
4350
 
4084
4351
  // Read first. A question back or an unclear message is answered on the
4085
4352
  // same card and saves nothing; an answer or an instruction is acted on.
4086
- // A failed or slow read falls back to the plain line and saves nothing.
4353
+ // A model that answered with a read of no use asks him to say it another
4354
+ // way. A read no model answered (it failed, was slow, or none is here) is
4355
+ // never put on his words: the reply says what happened, and nothing is saved.
4087
4356
  const QUESTION_HINT = 'his message ends with a question mark: it is a question back, not an answer';
4088
4357
  async function readTurn(t, text, card) {
4089
4358
  const thread = t.turns.slice(-THREAD_TURNS);
@@ -4092,16 +4361,23 @@ function createBetDesk(deps) {
4092
4361
  // more with that said, and if it still reads as an answer, fall back
4093
4362
  if (read && read.ok && read.kind === 'answer' && /\?\s*$/.test(text)) {
4094
4363
  read = await readWithin({ text, card, thread, hint: QUESTION_HINT });
4095
- if (read && read.ok && read.kind === 'answer') read = { ok: false, reason: 'a question read as an answer' };
4364
+ if (read && read.ok && read.kind === 'answer') read = { ok: false, reason: 'a question read as an answer', answered: true };
4096
4365
  }
4097
4366
  if (read && read.ok && (read.kind === 'answer' || read.kind === 'instruction')) return { act: read };
4098
- const kind = read && read.ok ? read.kind : 'unclear';
4099
- const reply = (read && read.ok && plainReply(read.reply)) || SAY_FALLBACK;
4367
+ let reply;
4368
+ let kind;
4369
+ if (read && (read.ok || read.answered)) {
4370
+ reply = (read.ok && plainReply(read.reply)) || SAY_FALLBACK;
4371
+ kind = reply === SAY_FALLBACK ? 'unclear' : read.kind;
4372
+ } else {
4373
+ reply = read && read.no_model && !read.retry ? SAY_NO_MODEL : SAY_NOT_HEARD;
4374
+ kind = 'unheard';
4375
+ }
4100
4376
  const suggestions = read && read.ok && reply !== SAY_FALLBACK
4101
4377
  ? plainSuggestions((Array.isArray(read.suggestions) ? read.suggestions : []).map(you).filter(sug => !(card.buttons || []).some(b => sameLabel(b, sug))))
4102
4378
  : [];
4103
4379
  addTurns(t, { who: 'owner', text }, { who: 'orb', text: reply });
4104
- return { stay: { ok: true, stay: true, kind: reply === SAY_FALLBACK ? 'unclear' : kind, reply, ...(suggestions.length ? { suggestions } : {}), thread: t.turns.slice() } };
4380
+ return { stay: { ok: true, stay: true, kind, reply, ...(suggestions.length ? { suggestions } : {}), thread: t.turns.slice() } };
4105
4381
  }
4106
4382
 
4107
4383
  // On a question it is the answer; on an idea, a note the writer reworks it
@@ -4117,6 +4393,8 @@ function createBetDesk(deps) {
4117
4393
  if (card !== undefined && card !== cur.id) return { ok: false, reason: 'gone', message: 'that card changed, so nothing was done. here is the one on screen now.' };
4118
4394
  // on the calm line there is nothing to answer: the words are a note for the idea being written, read by no model
4119
4395
  if (cur.kind === 'wait') return noteOnWait(cur, said);
4396
+ // words on the card that says no model answered look again first, so one signed in since then reads them
4397
+ if (onNoModel()) wakeThinkers();
4120
4398
  let kind = cur.kind || 'bet';
4121
4399
  log({ card: 'bet', choice: 'say', command: null, ran: 'in process', exit_code: 0, kind, chars: said.length });
4122
4400
  const t = threadFor(cur.id);
@@ -4541,7 +4819,7 @@ function createBetDesk(deps) {
4541
4819
  if (kind === 'report') return gmCard.report({ id: cur.id, title: v.report.what, body: v.report.why });
4542
4820
  if (kind === 'choice') return gmCard.choice({ id: cur.id, items: v.choice.options });
4543
4821
  if (kind === 'wait') return gmCard.wait({ id: cur.id, ...waitProgress(cur), thread: waitThread(cur) });
4544
- if (kind === 'missed') return gmCard.missed({ id: cur.id });
4822
+ if (kind === 'missed') return gmCard.missed({ id: cur.id, noModel: cur.no_model || null });
4545
4823
  if (kind === 'yes') {
4546
4824
  const j = cur.job;
4547
4825
  return gmCard.yes({ id: cur.id, title: v.job.what, body: v.job.why, job: jobFacts(j.owner), request: { job: j.mission_id, key: j.key, hash: j.lock_hash || null } });
package/lib/gm-card.js CHANGED
@@ -166,10 +166,20 @@ function wait({ id, progress = [], started_at = null, waited_ms = null, thread =
166
166
  return { id, kind: 'wait', asks: id, title: 'writing your next idea.', body: null, options: [], input: sayInput({ placeholder: 'add a note for this idea' }), watch: true, progress, started_at, waited_ms, thread };
167
167
  }
168
168
 
169
- // the wait ended with nothing usable: a way to try again, and the box
170
- function missed({ id }) {
169
+ // the wait ended with nothing usable: a way to try again, and the box.
170
+ // noModel: no model answered at all, so the card says what it needs
171
+ // instead: 'none' when there is no model on this computer, 'failed' when
172
+ // the one here did not answer
173
+ const NO_MODEL_TITLE = 'the orb needs a model signed in on this computer.';
174
+ const NO_MODEL_BODY = {
175
+ none: 'there is none here yet. once one is set up, tap try again.',
176
+ failed: 'the model here did not answer. check that it is signed in, then tap try again.',
177
+ };
178
+ function missed({ id, noModel = null }) {
171
179
  return {
172
- id, kind: 'missed', asks: id, title: 'no idea came together this time.', body: 'try again, or say what you want one about.',
180
+ id, kind: 'missed', asks: id,
181
+ title: noModel ? NO_MODEL_TITLE : 'no idea came together this time.',
182
+ body: noModel ? NO_MODEL_BODY[noModel] || NO_MODEL_BODY.failed : 'try again, or say what you want one about.',
173
183
  options: [answer(id, { id: 'again', label: 'try again', action: 'next', style: 'primary' })],
174
184
  input: sayInput(),
175
185
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.13",
3
+ "version": "3.74.14",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",