toolroll 0.9.95 → 0.9.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/browser/workspace.css +2 -2
  3. package/dist/browser/workspace.js +32 -32
  4. package/dist/browser-workspace.d.ts +2 -0
  5. package/dist/card-render.d.ts +18 -1
  6. package/dist/card-render.js +40 -3
  7. package/dist/chat-delivery.d.ts +5 -1
  8. package/dist/chat-delivery.js +74 -25
  9. package/dist/chat-follow.d.ts +6 -3
  10. package/dist/chat-follow.js +23 -7
  11. package/dist/chat-live-schema.d.ts +17 -0
  12. package/dist/chat-live-schema.js +32 -0
  13. package/dist/chat-live.d.ts +161 -0
  14. package/dist/chat-live.js +491 -0
  15. package/dist/contracts/console-api.d.ts +1 -0
  16. package/dist/contracts/console-api.js +1 -0
  17. package/dist/desktop-update.d.ts +1 -1
  18. package/dist/discord-api.d.ts +3 -1
  19. package/dist/discord-api.js +9 -3
  20. package/dist/discord-chat.d.ts +13 -3
  21. package/dist/discord-chat.js +77 -21
  22. package/dist/discord.js +8 -1
  23. package/dist/lead-answer-eval.d.ts +86 -0
  24. package/dist/lead-answer-eval.js +255 -0
  25. package/dist/lead-cli-live.d.ts +56 -0
  26. package/dist/lead-cli-live.js +200 -0
  27. package/dist/lead-cli.d.ts +3 -0
  28. package/dist/lead-cli.js +34 -4
  29. package/dist/lead-latency-schema.d.ts +24 -0
  30. package/dist/lead-latency-schema.js +36 -0
  31. package/dist/lead-progress.d.ts +10 -1
  32. package/dist/lead-progress.js +1 -1
  33. package/dist/lead-queue-runtime.d.ts +1 -1
  34. package/dist/lead-queue-runtime.js +1 -1
  35. package/dist/lead-route.d.ts +56 -0
  36. package/dist/lead-route.js +104 -0
  37. package/dist/lead.d.ts +4 -2
  38. package/dist/lead.js +79 -26
  39. package/dist/live-reply.d.ts +83 -0
  40. package/dist/live-reply.js +302 -0
  41. package/dist/operate.js +39 -1
  42. package/dist/project-knowledge.d.ts +3 -3
  43. package/dist/reply-shape.d.ts +9 -0
  44. package/dist/reply-shape.js +13 -0
  45. package/dist/serve.js +11 -4
  46. package/dist/server/chat.d.ts +2 -1
  47. package/dist/server/chat.js +18 -6
  48. package/dist/server/live.js +18 -21
  49. package/dist/server/render-chat.d.ts +5 -0
  50. package/dist/server/render-chat.js +4 -2
  51. package/dist/server/render-settings.d.ts +8 -0
  52. package/dist/server/render-settings.js +13 -2
  53. package/dist/server/route-table.js +2 -0
  54. package/dist/server/settings.js +14 -1
  55. package/dist/slack-api.js +11 -0
  56. package/dist/slack-chat.d.ts +11 -1
  57. package/dist/slack-chat.js +50 -5
  58. package/dist/slack-settings.js +8 -1
  59. package/dist/slack.js +10 -3
  60. package/dist/store.d.ts +31 -2
  61. package/dist/store.js +59 -6
  62. package/dist/subscription-chat.d.ts +2 -0
  63. package/dist/subscription-chat.js +3 -1
  64. package/dist/teams-chat.d.ts +9 -2
  65. package/dist/teams-chat.js +50 -2
  66. package/dist/teams.js +5 -2
  67. package/dist/teardown-git.js +33 -3
  68. package/dist/telegram-lead.d.ts +3 -0
  69. package/dist/telegram-lead.js +63 -9
  70. package/dist/telegram-live.d.ts +116 -0
  71. package/dist/telegram-live.js +224 -0
  72. package/dist/telegram.d.ts +2 -0
  73. package/dist/telegram.js +50 -2
  74. package/dist/toolroll-update.js +4 -1
  75. package/package.json +1 -1
package/dist/lead.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type LeadProgress } from "./lead-progress.js";
2
2
  import type { ChatConfig, LeadSession, LeadThread, Store } from "./store.js";
3
+ import type { LeadFailureDetail } from "./lead-latency-schema.js";
3
4
  import type { Integration } from "./integrations.js";
4
5
  import type { VerifiedApprover } from "./principal.js";
5
6
  import { type LeadHistoryMessage } from "./converse.js";
@@ -128,8 +129,9 @@ export declare const LEAD_FAILURE_COPY: {
128
129
  readonly empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.";
129
130
  readonly unusable: "The lead's answer couldn't be used, so it was thrown away. Nothing was kept. Send your message again.";
130
131
  };
131
- /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry). */
132
- export declare function leadFailureText(reason: string | null, unknownSpend?: boolean): string;
132
+ /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry).
133
+ * Its saved detail (v158), when known, names the exact cause the person saw live ("not installed", "sign-in expired"). */
134
+ export declare function leadFailureText(reason: string | null, unknownSpend?: boolean, detail?: LeadFailureDetail | null): string;
133
135
  /** How long a provider that ignored its abort at the turn's deadline is waited for before the turn ends without it.
134
136
  * Its process group was already killed at the deadline; this only bounds the wait for the transport to say so. */
135
137
  export declare const LEAD_ABORT_GRACE_MS = 5000;
package/dist/lead.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { leadToolFailureReason, leadToolLabel } from "./lead-progress.js";
3
3
  import { createHash } from "node:crypto";
4
- import { leadTimeoutNotice } from "./store.js";
4
+ import { leadAskAwaitsAnswer, leadQuickAnswersOn, leadTimeoutNotice, setLeadTurnRoute } from "./store.js";
5
+ import { quickAdvertises, quickLeadModel, quickRoute, quickStepEscalates, routeWords } from "./lead-route.js";
5
6
  import { RESULT_IMAGES_PER_TURN_CAP } from "./chat-evidence.js";
6
7
  import { isVerifiedApprover, reproveApprover } from "./principal.js";
7
8
  import { LEAD_MAX_CALLS_PER_STEP, LEAD_MAX_STEPS, LEAD_TOOL_RESULT_CAP_BYTES, TURN_WALL_CLOCK_MS, composeLeadRequest, credentialKeyOf, isDirectChatProvider, leadWorstCaseForPrice, performLeadRequest, priceForConfig, cacheWorstPrice, settleWithCache, subscriptionCredentialKey, } from "./converse.js";
@@ -74,8 +75,9 @@ export const LEAD_FAILURE_COPY = {
74
75
  empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.",
75
76
  unusable: "The lead's answer couldn't be used, so it was thrown away. Nothing was kept. Send your message again.",
76
77
  };
77
- /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry). */
78
- export function leadFailureText(reason, unknownSpend = false) {
78
+ /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry).
79
+ * Its saved detail (v158), when known, names the exact cause the person saw live ("not installed", "sign-in expired"). */
80
+ export function leadFailureText(reason, unknownSpend = false, detail = null) {
79
81
  // A turn whose cost is unknown paused chat: say what happened (its reason's first sentence), then that the person must
80
82
  // confirm the cost before chat runs again — never "send it again", which would only meet the pause.
81
83
  if (unknownSpend) {
@@ -87,6 +89,10 @@ export function leadFailureText(reason, unknownSpend = false) {
87
89
  const happened = /^[^.]*\./.exec(leadFailureText(reason))?.[0] ?? "The lead's reply didn't finish.";
88
90
  return `${happened} Its cost isn't known, so chat is paused. Confirm the cost on the Chat page to turn chat back on.`;
89
91
  }
92
+ if (reason === "provider-error" && detail === "not-installed")
93
+ return LEAD_FAILURE_COPY.notInstalled;
94
+ if (reason === "provider-error" && detail === "sign-in")
95
+ return LEAD_FAILURE_COPY.signIn;
90
96
  switch (reason) {
91
97
  case "over-reservation": return "This reply reported more use than was set aside for it, so it was stopped and its cost counted. Send your message again.";
92
98
  case "timeout":
@@ -212,6 +218,13 @@ export async function runLeadTurn(input) {
212
218
  // The channel's standing, proved before anything is admitted or sent.
213
219
  if (input.revalidate !== undefined && !(await input.revalidate()).ok)
214
220
  return refuse("channel");
221
+ // Which model answers (responsive-chat.md §8), decided once, before the turn: the faster one only for a plain read
222
+ // question with quick answers on; everything else on the configured model, as before.
223
+ const quickOn = leadQuickAnswersOn(store);
224
+ const quickModel = quickOn ? quickLeadModel(store) : config.model;
225
+ let route = quickRoute({ enabled: quickOn, provider: config.provider, mainModel: config.model, message,
226
+ hasContext: input.context !== undefined, hasReplyImage: input.replyImage !== undefined, askOpen: quickOn && leadAskAwaitsAnswer(store, thread.id, clock()),
227
+ quickModel, quickAllowed: quickOn && store.agentPolicyRefusal(config.provider, quickModel) === null }).route;
215
228
  let now = clock();
216
229
  store.sweepStaleLeadTurns(now);
217
230
  const view = leadViewContextFor(store, who);
@@ -282,6 +295,7 @@ export async function runLeadTurn(input) {
282
295
  const started = store.startLeadTurn(turnId, now);
283
296
  if (!started.ok)
284
297
  throw new Error("new lead turn could not start");
298
+ setLeadTurnRoute(store, turnId, started.generation, route);
285
299
  if (input.queuedMessageId === undefined)
286
300
  store.appendLeadMessage({ thread: thread.id, turn: turnId, role: "operator", text: message }, now);
287
301
  else {
@@ -308,6 +322,12 @@ export async function runLeadTurn(input) {
308
322
  }
309
323
  catch { /* a watcher never breaks the turn */ } };
310
324
  progress({ kind: "started", turn: turnId });
325
+ /** Text that looked like a secret was held back: every preview closes, once (responsive-chat.md §2.1). */
326
+ let blockedSaid = false;
327
+ const blockPreview = () => { if (!blockedSaid) {
328
+ blockedSaid = true;
329
+ progress({ kind: "blocked", turn: turnId });
330
+ } };
311
331
  let proposals = 0;
312
332
  let reads = 0;
313
333
  let steps = 0;
@@ -336,18 +356,18 @@ export async function runLeadTurn(input) {
336
356
  return store.listLeadTurnEvidence(turnId).map(one => one.artifact);
337
357
  };
338
358
  /** The turn ends failed: its drafts are deleted, its cost settled — the whole reservation when any of it is unknown. */
339
- const fail = (failed, message, unknownSpend) => {
359
+ const fail = (failed, message, unknownSpend, failureDetail = null) => {
340
360
  now = clock();
341
- store.finalizeLeadTurn(turnId, started.generation, { state: "failed", settledMicrousd: settled, unknownSpend, tokensIn, tokensOut, failureReason: failed }, now);
361
+ store.finalizeLeadTurn(turnId, started.generation, { state: "failed", settledMicrousd: settled, unknownSpend, tokensIn, tokensOut, failureReason: failed, failureDetail }, now);
342
362
  store.dropLeadAsk(turnId);
343
363
  return { ok: false, turn: turnId, failed, message, unknownSpend };
344
364
  };
345
365
  /** The turn stopped at its deadline: the notice is saved in the thread with whatever its completed tool calls proposed, in
346
366
  * the same write that ends the turn, so a refresh, another device or a restart shows the same outcome. */
347
- const timedOut = (unknownSpend) => {
367
+ const timedOut = (unknownSpend, failureDetail = null) => {
348
368
  now = clock();
349
369
  const message = leadTimeoutNotice(proposals > 0, unknownSpend);
350
- const saved = store.finalizeLeadTurn(turnId, started.generation, { state: "failed", settledMicrousd: settled, unknownSpend, tokensIn, tokensOut, failureReason: "timeout", keepProposals: true, message: { text: message, activity: activitySummary(reads, proposals, steps) } }, now);
370
+ const saved = store.finalizeLeadTurn(turnId, started.generation, { state: "failed", settledMicrousd: settled, unknownSpend, tokensIn, tokensOut, failureReason: "timeout", failureDetail, keepProposals: true, message: { text: message, activity: activitySummary(reads, proposals, steps) } }, now);
351
371
  store.dropLeadAsk(turnId);
352
372
  if (!saved)
353
373
  return { ok: false, turn: turnId, failed: "superseded", message: LEAD_FAILURE_COPY.stopped, unknownSpend: false };
@@ -387,6 +407,8 @@ export async function runLeadTurn(input) {
387
407
  let repaired = false;
388
408
  /** The turn's one shorten step for a reply over its channel's message limit (LEAD_REPLY_LIMITS); still over, it is split. */
389
409
  let shortened = false;
410
+ /** Why the next step runs, when it re-asks about the reply itself (repair or shorten): the live status says so. */
411
+ let nextReason;
390
412
  const replyLimit = input.channel === undefined ? null : LEAD_REPLY_LIMITS[input.channel];
391
413
  const unbacked = (text) => {
392
414
  const claim = deliverableClaim(text);
@@ -403,7 +425,10 @@ export async function runLeadTurn(input) {
403
425
  if (remainingMs <= 0)
404
426
  return timedOut(false);
405
427
  // This request's advertised tools: fixed for the request; a tool found in its reply is advertised from the next one.
406
- const advertised = advertisedTools();
428
+ // A quick step (§8) advertises the reads, and find_tools as its way to ask for more, on the faster model.
429
+ const quick = route === "fast";
430
+ const stepModel = quick ? quickModel : config.model;
431
+ const advertised = quick ? LEAD_TOOL_SCHEMAS.filter(one => quickAdvertises(one.name)) : advertisedTools();
407
432
  const advertisedNames = new Set(advertised.map(one => one.name));
408
433
  const request = direct ? composeDirect(input.key, advertised) : composeSubscription(advertised);
409
434
  // Tool results join the outbound body: scanned again before every dispatch.
@@ -411,7 +436,7 @@ export async function runLeadTurn(input) {
411
436
  if (scanForSecrets(outbound).length > 0) {
412
437
  return fail("secret-refused", LEAD_FAILURE_COPY.secretRead, false);
413
438
  }
414
- const step = store.openLeadStep({ leadTurn: turnId, generation: started.generation, approver: who.name, credentialKey, provider: config.provider, model: config.model, deadlineMs: remainingMs + 10_000 }, now);
439
+ const step = store.openLeadStep({ leadTurn: turnId, generation: started.generation, approver: who.name, credentialKey, provider: config.provider, model: stepModel, deadlineMs: remainingMs + 10_000 }, now);
415
440
  if (!step.ok) {
416
441
  if (step.reason === "latched")
417
442
  return fail("latched", LEAD_FAILURE_COPY.paused, false);
@@ -421,7 +446,8 @@ export async function runLeadTurn(input) {
421
446
  if (!stepStarted.ok)
422
447
  return fail("provider-error", LEAD_FAILURE_COPY.notStarted, false);
423
448
  steps++;
424
- progress({ kind: "step", turn: turnId, step: steps });
449
+ progress({ kind: "step", turn: turnId, step: steps, ...(nextReason === undefined ? {} : { reason: nextReason }) });
450
+ nextReason = undefined;
425
451
  const requestBytes = Buffer.byteLength(outbound, "utf8");
426
452
  let result;
427
453
  // The turn owns its deadline (lead-stall, release check 2438): at the deadline the request is aborted, which ends a
@@ -430,7 +456,9 @@ export async function runLeadTurn(input) {
430
456
  const controller = new AbortController();
431
457
  const abortAt = setTimeout(() => controller.abort(), remainingMs);
432
458
  const settle = (work) => settleBy(work, remainingMs + LEAD_ABORT_GRACE_MS)
433
- .then(value => value === LATE || controller.signal.aborted ? { ok: false, problem: "timeout" } : value)
459
+ // A harness that ended at the deadline still says whether it had written anything (recorded, never shown).
460
+ .then(value => value === LATE ? { ok: false, problem: "timeout" } : controller.signal.aborted
461
+ ? { ok: false, problem: "timeout", ...(!value.ok && "quiet" in value && typeof value.quiet === "boolean" ? { quiet: value.quiet } : {}) } : value)
434
462
  .finally(() => clearTimeout(abortAt));
435
463
  if (direct) {
436
464
  result = await settle(performLeadRequest(request, directProvider, controller.signal, fetcher)
@@ -440,7 +468,7 @@ export async function runLeadTurn(input) {
440
468
  const runner = input.subscriptionRunner ?? performSubscriptionLeadRequest;
441
469
  result = await settle((async () => runner({
442
470
  provider: subscriptionProvider,
443
- model: config.model,
471
+ model: stepModel,
444
472
  system: LEAD_CONTRACT,
445
473
  dataDocument: document,
446
474
  history,
@@ -449,15 +477,22 @@ export async function runLeadTurn(input) {
449
477
  signal: controller.signal,
450
478
  // The reply as it is written; text that looks like a secret is
451
479
  // never shown (the finished reply is scanned again before saving).
452
- ...(input.onProgress === undefined ? {} : { onText: (text) => { if (!controller.signal.aborted && scanForSecrets(text).length === 0)
453
- progress({ kind: "text", turn: turnId, step: steps, text }); } }),
480
+ ...(input.onProgress === undefined ? {} : { onText: (text) => { if (controller.signal.aborted)
481
+ return; if (scanForSecrets(text).length === 0)
482
+ progress({ kind: "text", turn: turnId, step: steps, text });
483
+ else
484
+ blockPreview(); } }),
454
485
  }))().catch(() => ({ ok: false, problem: "provider-error" })));
455
486
  }
456
487
  now = clock();
457
488
  const finishStep = (outcome) => store.finalizeChatTurn(step.id, stepStarted.generation, outcome, now);
458
489
  if (!result.ok) {
459
490
  if (!direct) {
460
- finishStep({ state: "failed", failureReason: result.problem === "timeout" ? "timeout" : result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", settledMicrousd: 0 });
491
+ // The exact cause, saved (v158): what a later reader shows, and what the next timeout diagnosis reads.
492
+ const quiet = "quiet" in result ? result.quiet : undefined;
493
+ const detail = result.problem === "timeout" ? (quiet === true ? "timeout-silent" : quiet === false ? "timeout-output" : null)
494
+ : result.problem === "not-found" ? "not-installed" : result.problem === "malformed-reply" ? null : "sign-in";
495
+ finishStep({ state: "failed", failureReason: result.problem === "timeout" ? "timeout" : result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", settledMicrousd: 0, failureDetail: detail });
461
496
  const message = result.problem === "not-found"
462
497
  ? LEAD_FAILURE_COPY.notInstalled
463
498
  : result.problem === "timeout"
@@ -466,29 +501,32 @@ export async function runLeadTurn(input) {
466
501
  ? LEAD_FAILURE_COPY.unreadable
467
502
  : LEAD_FAILURE_COPY.signIn;
468
503
  if (result.problem === "timeout")
469
- return timedOut(false);
470
- return fail(result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", message, false);
504
+ return timedOut(false, detail);
505
+ return fail(result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", message, false, detail);
471
506
  }
472
507
  if (result.problem.startsWith("status-")) {
473
508
  // The provider ANSWERED with an error: nothing billed for this step.
474
- finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: 0 });
475
- return fail("provider-error", LEAD_FAILURE_COPY.refused, false);
509
+ finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: 0, failureDetail: "refused" });
510
+ return fail("provider-error", LEAD_FAILURE_COPY.refused, false, "refused");
476
511
  }
477
512
  if (result.problem === "timeout") {
478
513
  finishStep({ state: "failed", failureReason: "timeout", settledMicrousd: null, unknownSpend: true });
479
514
  return timedOut(true);
480
515
  }
481
516
  if (result.problem === "network") {
482
- finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: null, unknownSpend: true });
483
- return fail("provider-error", LEAD_FAILURE_COPY.lostUnknownCost, true);
517
+ finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: null, unknownSpend: true, failureDetail: "lost" });
518
+ return fail("provider-error", LEAD_FAILURE_COPY.lostUnknownCost, true, "lost");
484
519
  }
485
520
  finishStep({ state: "failed", failureReason: "malformed-reply", settledMicrousd: null, unknownSpend: true });
486
521
  return fail("malformed-reply", LEAD_FAILURE_COPY.unreadableUnknownCost, true);
487
522
  }
488
523
  const answer = result.answer;
489
524
  // The streamed reply's finished text, once, secret-checked whole: only now may its last word show live.
490
- if (!direct && input.onProgress !== undefined && !controller.signal.aborted && answer.text !== "" && scanForSecrets(answer.text).length === 0) {
491
- progress({ kind: "text", turn: turnId, step: steps, text: answer.text, final: true });
525
+ if (!direct && input.onProgress !== undefined && !controller.signal.aborted && answer.text !== "") {
526
+ if (scanForSecrets(answer.text).length === 0)
527
+ progress({ kind: "text", turn: turnId, step: steps, text: answer.text, final: true });
528
+ else
529
+ blockPreview();
492
530
  }
493
531
  // Usage that cannot be true — more input tokens than bytes sent — is a
494
532
  // malformed reply with unknown cost, never a number to settle by.
@@ -512,7 +550,9 @@ export async function runLeadTurn(input) {
512
550
  cacheReadTokens: answer.cacheReadTokens ?? null, cacheWriteTokens: answer.cacheWriteTokens ?? null,
513
551
  // v140: what the provider itself reported for this request (absent: unreported, kept NULL).
514
552
  modelReported: answer.modelReported ?? null, providerTurns: answer.providerTurns ?? null, reportedCostUsd: answer.reportedCostUsd ?? null,
515
- billingReported: answer.billingReported ?? null });
553
+ billingReported: answer.billingReported ?? null,
554
+ // v158: the tools this step asked for, names only (responsive-chat.md §7.2), so the next latency call is data-driven.
555
+ tools: answer.calls.map(one => one.name) });
516
556
  tokensIn += answer.tokensIn;
517
557
  tokensOut += answer.tokensOut;
518
558
  if (answer.cacheReadTokens === undefined)
@@ -527,6 +567,14 @@ export async function runLeadTurn(input) {
527
567
  const changed = guard(await revalidated());
528
568
  if (changed !== null)
529
569
  return changed;
570
+ // A quick step that asked for more than a read (§8): its calls are discarded unrun, and the configured model takes the
571
+ // reply over from the same history, for this step and every later one. Steps and the turn's deadline still bind.
572
+ if (route === "fast" && quickStepEscalates(answer.calls)) {
573
+ route = "escalated";
574
+ setLeadTurnRoute(store, turnId, started.generation, route);
575
+ lastText = "";
576
+ continue;
577
+ }
530
578
  if (answer.calls.length === 0) {
531
579
  if (answer.text.trim() === "")
532
580
  return fail("malformed-reply", LEAD_FAILURE_COPY.empty, false);
@@ -534,6 +582,7 @@ export async function runLeadTurn(input) {
534
582
  const claim = unbacked(answer.text);
535
583
  if (claim !== null && !repaired && steps < maxSteps) {
536
584
  repaired = true;
585
+ nextReason = "repair";
537
586
  history.push({ role: "assistant", text: answer.text, calls: [] });
538
587
  history.push({ role: "operator", text: deliverableRepair(claim) });
539
588
  continue;
@@ -541,6 +590,7 @@ export async function runLeadTurn(input) {
541
590
  const candidate = claim === null ? answer.text : dropDeliverableClaims(answer.text);
542
591
  if (replyLimit !== null && candidate.length > replyLimit && !shortened && steps < maxSteps) {
543
592
  shortened = true;
593
+ nextReason = "shorten";
544
594
  history.push({ role: "assistant", text: answer.text, calls: [] });
545
595
  history.push({ role: "operator", text: shortenAsk([{ field: "Your reply", limit: replyLimit, length: candidate.length }]) });
546
596
  continue;
@@ -598,10 +648,13 @@ export async function runLeadTurn(input) {
598
648
  reply = `${last === "" ? "" : `${last}\n\n`}(I stopped here: this answer needed more steps than ${maxSteps < LEAD_MAX_STEPS ? "this conversation's remaining spending allows" : "one reply allows"}. Ask me to carry on.)`;
599
649
  }
600
650
  // Ruling 11: model text is scanned before it becomes durable.
601
- if (scanForSecrets(reply).length > 0)
651
+ if (scanForSecrets(reply).length > 0) {
652
+ blockPreview();
602
653
  return fail("secret-refused", LEAD_FAILURE_COPY.secretReply, false);
654
+ }
603
655
  now = clock();
604
- const activity = activitySummary(reads, proposals, steps, cacheKnown && tokensIn > 0 ? Math.min(1, cacheRead / tokensIn) : null);
656
+ const routed = routeWords(route, quickModel, config.model);
657
+ const activity = `${activitySummary(reads, proposals, steps, cacheKnown && tokensIn > 0 ? Math.min(1, cacheRead / tokensIn) : null)}${routed === null ? "" : ` · ${routed}`}`;
605
658
  // One write (finding 12): settle, debit, promote the drafts, append the assistant text.
606
659
  const finalized = store.finalizeLeadTurn(turnId, started.generation, { state: "answered", settledMicrousd: settled, tokensIn, tokensOut, message: { text: reply, activity } }, now);
607
660
  if (!finalized)
@@ -0,0 +1,83 @@
1
+ import type { LeadLiveTool, LeadProgress } from "./lead-progress.js";
2
+ import { type ShapeOptions } from "./reply-shape.js";
3
+ export type LiveView = {
4
+ phase: "queued";
5
+ } | {
6
+ phase: "working";
7
+ status: string;
8
+ } | {
9
+ phase: "writing";
10
+ status: string;
11
+ text: string;
12
+ } | {
13
+ phase: "closed";
14
+ why: "answered" | "failed" | "blocked";
15
+ };
16
+ /** The status line's fixed words (§2.3). A running tool reads as its own label plus "…". */
17
+ export declare const LIVE_STATUS: {
18
+ readonly queued: "Waiting for your earlier messages…";
19
+ readonly thinking: "Thinking…";
20
+ readonly writing: "Writing…";
21
+ readonly repair: "Fixing the reply…";
22
+ readonly shorten: "Shortening the reply…";
23
+ /** A preview closed because text looked like a password or key; the turn itself may still answer. */
24
+ readonly held: "Working on it…";
25
+ };
26
+ /** Could the end of this text be the start of a password or key? Then nothing of it shows yet. */
27
+ export declare function secretMayStart(text: string): boolean;
28
+ /**
29
+ * What of a reply still being written is settled: everything before its last whitespace (the unfinished last word
30
+ * stays out), then cut back before any code fence, code span, bold run or link that hasn't closed yet. Trailing
31
+ * whitespace is trimmed.
32
+ */
33
+ export declare function settledText(raw: string): string;
34
+ /**
35
+ * A partial as it may be shown: `blocked` when it holds a password or key (the preview closes), `withheld` while its
36
+ * tail could still become one (show nothing new), else the settled text shaped for display. A step's finished text
37
+ * (`final`, secret-checked whole by the engine) shows whole.
38
+ */
39
+ export declare function partialDisplay(raw: string, final: boolean, shape?: ShapeOptions): {
40
+ kind: "blocked";
41
+ } | {
42
+ kind: "withheld";
43
+ } | {
44
+ kind: "shown";
45
+ text: string;
46
+ };
47
+ export type LiveReply = {
48
+ /** Feed one engine event; true when the view changed. Events after close are ignored. */
49
+ push(event: LeadProgress): boolean;
50
+ /** The turn ended (its outcome is the engine's, never inferred from text). Idempotent; a blocked close stays blocked. */
51
+ close(why: "answered" | "failed"): void;
52
+ view(): LiveView;
53
+ /** The status line now, also once a blocked preview closed while the turn runs on. */
54
+ status(): string;
55
+ /** Each step's tool calls, for a surface that lists them behind a disclosure. Fixed copy only, never model text. */
56
+ tools(): LeadLiveTool[][];
57
+ readonly turn: number | null;
58
+ };
59
+ /** One turn's live reply. `shape`: the owner's message (`asked`) and this app's origins, as the final reply is shaped. */
60
+ export declare function createLiveReply(shape?: ShapeOptions, options?: {
61
+ queued?: boolean;
62
+ }): LiveReply;
63
+ export type LiveSender<V> = {
64
+ /** Offer the newest view: it replaces any view still waiting. Ignored once closed. */
65
+ offer(view: V): void;
66
+ /** Drop the waiting view, wait for the request in flight to settle, then refuse every later view. */
67
+ close(): Promise<void>;
68
+ /** Grows with every offered view; a transport may use it to refuse an older one. */
69
+ readonly generation: number;
70
+ readonly closed: boolean;
71
+ };
72
+ /**
73
+ * The serialized sender for one live surface message (§2.4). `standing` re-proves the viewer's right to see this turn
74
+ * before every send (it may await; the close is re-checked after it). `spacingMs` is the least time between two sends,
75
+ * read before each one (a surface widens it after the platform asks for a pause). A failed send drops that view:
76
+ * previews are best effort.
77
+ */
78
+ export declare function createLiveSender<V>(options: {
79
+ send: (view: V, generation: number) => unknown;
80
+ standing?: () => boolean | Promise<boolean>;
81
+ spacingMs?: number | (() => number);
82
+ now?: () => number;
83
+ }): LiveSender<V>;