toolroll 0.9.96 → 0.9.98

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 (131) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/approval-authority.d.ts +10 -1
  3. package/dist/approval-authority.js +18 -2
  4. package/dist/browser/workspace.css +2 -2
  5. package/dist/browser/workspace.js +32 -32
  6. package/dist/browser-workspace.d.ts +6 -3
  7. package/dist/chat-actions.d.ts +40 -5
  8. package/dist/chat-actions.js +124 -13
  9. package/dist/chat-card.js +4 -1
  10. package/dist/chat-channel.d.ts +3 -0
  11. package/dist/chat-channel.js +24 -7
  12. package/dist/chat-decide.js +4 -2
  13. package/dist/chat-delivery.d.ts +5 -1
  14. package/dist/chat-delivery.js +74 -25
  15. package/dist/chat-follow.d.ts +6 -3
  16. package/dist/chat-follow.js +23 -7
  17. package/dist/chat-live-schema.d.ts +17 -0
  18. package/dist/chat-live-schema.js +32 -0
  19. package/dist/chat-live.d.ts +161 -0
  20. package/dist/chat-live.js +491 -0
  21. package/dist/chat-task-actions.js +1 -1
  22. package/dist/chat-terms.d.ts +8 -0
  23. package/dist/chat-terms.js +14 -0
  24. package/dist/chat-voice.js +1 -1
  25. package/dist/contracts/chat-actions.d.ts +6 -0
  26. package/dist/contracts/chat-actions.js +2 -0
  27. package/dist/contracts/cli.d.ts +6 -0
  28. package/dist/contracts/cli.js +3 -0
  29. package/dist/contracts/console-api.d.ts +1 -0
  30. package/dist/contracts/console-api.js +1 -0
  31. package/dist/contracts/lead-tools.d.ts +17 -0
  32. package/dist/contracts/lead-tools.js +4 -1
  33. package/dist/contracts/store-json.d.ts +1 -0
  34. package/dist/discord-api.d.ts +3 -1
  35. package/dist/discord-api.js +9 -3
  36. package/dist/discord-chat.d.ts +13 -3
  37. package/dist/discord-chat.js +77 -21
  38. package/dist/discord.js +8 -1
  39. package/dist/fast-start.d.ts +83 -6
  40. package/dist/fast-start.js +112 -15
  41. package/dist/home-groups.d.ts +39 -0
  42. package/dist/home-groups.js +33 -0
  43. package/dist/lead-answer-eval.d.ts +93 -0
  44. package/dist/lead-answer-eval.js +259 -0
  45. package/dist/lead-capabilities.js +1 -1
  46. package/dist/lead-cli-live.d.ts +56 -0
  47. package/dist/lead-cli-live.js +200 -0
  48. package/dist/lead-cli.d.ts +3 -0
  49. package/dist/lead-cli.js +34 -4
  50. package/dist/lead-context.js +2 -2
  51. package/dist/lead-contract.d.ts +1 -1
  52. package/dist/lead-contract.js +10 -7
  53. package/dist/lead-doors.d.ts +32 -0
  54. package/dist/lead-doors.js +80 -14
  55. package/dist/lead-latency-schema.d.ts +24 -0
  56. package/dist/lead-latency-schema.js +36 -0
  57. package/dist/lead-progress.d.ts +10 -1
  58. package/dist/lead-progress.js +1 -1
  59. package/dist/lead-queue-runtime.d.ts +1 -1
  60. package/dist/lead-queue-runtime.js +1 -1
  61. package/dist/lead-route.d.ts +56 -0
  62. package/dist/lead-route.js +104 -0
  63. package/dist/lead-status.d.ts +3 -0
  64. package/dist/lead-status.js +8 -3
  65. package/dist/lead-tools.d.ts +20 -0
  66. package/dist/lead-tools.js +66 -3
  67. package/dist/lead-voice.js +1 -1
  68. package/dist/lead.d.ts +4 -2
  69. package/dist/lead.js +97 -28
  70. package/dist/live-reply.d.ts +83 -0
  71. package/dist/live-reply.js +302 -0
  72. package/dist/local-owner.js +2 -0
  73. package/dist/one-step-chat-schema.d.ts +22 -0
  74. package/dist/one-step-chat-schema.js +37 -0
  75. package/dist/operate.d.ts +27 -1
  76. package/dist/operate.js +245 -33
  77. package/dist/project-delete.js +9 -0
  78. package/dist/project-knowledge.d.ts +10 -10
  79. package/dist/publish.d.ts +2 -0
  80. package/dist/publish.js +1 -1
  81. package/dist/pull-request-flow.d.ts +4 -0
  82. package/dist/pull-request-flow.js +16 -1
  83. package/dist/reply-shape.d.ts +9 -0
  84. package/dist/reply-shape.js +13 -0
  85. package/dist/reply-voice.d.ts +23 -0
  86. package/dist/reply-voice.js +37 -0
  87. package/dist/scope.d.ts +6 -1
  88. package/dist/scope.js +3 -2
  89. package/dist/serve.js +11 -4
  90. package/dist/server/chat.d.ts +2 -1
  91. package/dist/server/chat.js +22 -10
  92. package/dist/server/chrome.js +2 -2
  93. package/dist/server/home.d.ts +4 -3
  94. package/dist/server/home.js +33 -28
  95. package/dist/server/live.js +18 -21
  96. package/dist/server/render-chat.d.ts +5 -0
  97. package/dist/server/render-chat.js +15 -8
  98. package/dist/server/render-pages.js +2 -2
  99. package/dist/server/render-settings.d.ts +8 -0
  100. package/dist/server/render-settings.js +13 -2
  101. package/dist/server/route-table.js +2 -0
  102. package/dist/server/settings.js +14 -1
  103. package/dist/setup-guide.d.ts +2 -2
  104. package/dist/slack-api.js +11 -0
  105. package/dist/slack-chat.d.ts +11 -1
  106. package/dist/slack-chat.js +50 -5
  107. package/dist/slack-settings.js +8 -1
  108. package/dist/slack.js +10 -3
  109. package/dist/store.d.ts +33 -2
  110. package/dist/store.js +92 -16
  111. package/dist/subscription-chat.d.ts +2 -0
  112. package/dist/subscription-chat.js +3 -1
  113. package/dist/teams-chat.d.ts +9 -2
  114. package/dist/teams-chat.js +50 -2
  115. package/dist/teams.js +5 -2
  116. package/dist/telegram-lead.d.ts +3 -0
  117. package/dist/telegram-lead.js +132 -14
  118. package/dist/telegram-live.d.ts +116 -0
  119. package/dist/telegram-live.js +224 -0
  120. package/dist/telegram-status.js +9 -4
  121. package/dist/telegram.d.ts +12 -0
  122. package/dist/telegram.js +142 -10
  123. package/dist/toolroll-update.js +6 -1
  124. package/dist/tui-contract.d.ts +2 -0
  125. package/dist/tui-source.js +3 -0
  126. package/dist/tui.js +41 -41
  127. package/dist/typed-start.d.ts +51 -0
  128. package/dist/typed-start.js +98 -0
  129. package/dist/wake-hub.d.ts +72 -0
  130. package/dist/wake-hub.js +151 -0
  131. package/package.json +1 -1
package/dist/lead.js CHANGED
@@ -1,14 +1,16 @@
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";
8
9
  import { redactSecretLines, scanForSecrets } from "./evidence.js";
9
10
  import { redactSecretAssignments } from "./builder.js";
10
11
  import { LEAD_CONTRACT } from "./lead-contract.js";
11
- import { LEAD_CORE_TOOLS, LEAD_MAX_PROPOSALS_PER_TURN, LEAD_TOOL_SCHEMAS, executeLeadTool, isLeadTool, leadViewContextFor, projectLabelForLead, redactForLead, toolResultBytes } from "./lead-tools.js";
12
+ import { LEAD_CORE_TOOLS, LEAD_MAX_PROPOSALS_PER_TURN, LEAD_TOOL_SCHEMAS, executeLeadTool, isLeadTool, leadViewContextFor, projectLabelForLead, redactForLead, startInputOf, toolResultBytes } from "./lead-tools.js";
13
+ import { prepareChatStart } from "./fast-start.js";
12
14
  import { composeSubscriptionLeadPrompt, performSubscriptionLeadRequest } from "./subscription-chat.js";
13
15
  import { LEAD_REPLY_LIMITS, leadContext } from './lead-context.js';
14
16
  import { shortenAsk } from './text-limits.js';
@@ -74,8 +76,9 @@ export const LEAD_FAILURE_COPY = {
74
76
  empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.",
75
77
  unusable: "The lead's answer couldn't be used, so it was thrown away. Nothing was kept. Send your message again.",
76
78
  };
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) {
79
+ /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry).
80
+ * Its saved detail (v158), when known, names the exact cause the person saw live ("not installed", "sign-in expired"). */
81
+ export function leadFailureText(reason, unknownSpend = false, detail = null) {
79
82
  // A turn whose cost is unknown paused chat: say what happened (its reason's first sentence), then that the person must
80
83
  // confirm the cost before chat runs again — never "send it again", which would only meet the pause.
81
84
  if (unknownSpend) {
@@ -87,6 +90,10 @@ export function leadFailureText(reason, unknownSpend = false) {
87
90
  const happened = /^[^.]*\./.exec(leadFailureText(reason))?.[0] ?? "The lead's reply didn't finish.";
88
91
  return `${happened} Its cost isn't known, so chat is paused. Confirm the cost on the Chat page to turn chat back on.`;
89
92
  }
93
+ if (reason === "provider-error" && detail === "not-installed")
94
+ return LEAD_FAILURE_COPY.notInstalled;
95
+ if (reason === "provider-error" && detail === "sign-in")
96
+ return LEAD_FAILURE_COPY.signIn;
90
97
  switch (reason) {
91
98
  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
99
  case "timeout":
@@ -212,6 +219,13 @@ export async function runLeadTurn(input) {
212
219
  // The channel's standing, proved before anything is admitted or sent.
213
220
  if (input.revalidate !== undefined && !(await input.revalidate()).ok)
214
221
  return refuse("channel");
222
+ // Which model answers (responsive-chat.md §8), decided once, before the turn: the faster one only for a plain read
223
+ // question with quick answers on; everything else on the configured model, as before.
224
+ const quickOn = leadQuickAnswersOn(store);
225
+ const quickModel = quickOn ? quickLeadModel(store) : config.model;
226
+ let route = quickRoute({ enabled: quickOn, provider: config.provider, mainModel: config.model, message,
227
+ hasContext: input.context !== undefined, hasReplyImage: input.replyImage !== undefined, askOpen: quickOn && leadAskAwaitsAnswer(store, thread.id, clock()),
228
+ quickModel, quickAllowed: quickOn && store.agentPolicyRefusal(config.provider, quickModel) === null }).route;
215
229
  let now = clock();
216
230
  store.sweepStaleLeadTurns(now);
217
231
  const view = leadViewContextFor(store, who);
@@ -282,6 +296,7 @@ export async function runLeadTurn(input) {
282
296
  const started = store.startLeadTurn(turnId, now);
283
297
  if (!started.ok)
284
298
  throw new Error("new lead turn could not start");
299
+ setLeadTurnRoute(store, turnId, started.generation, route);
285
300
  if (input.queuedMessageId === undefined)
286
301
  store.appendLeadMessage({ thread: thread.id, turn: turnId, role: "operator", text: message }, now);
287
302
  else {
@@ -308,6 +323,12 @@ export async function runLeadTurn(input) {
308
323
  }
309
324
  catch { /* a watcher never breaks the turn */ } };
310
325
  progress({ kind: "started", turn: turnId });
326
+ /** Text that looked like a secret was held back: every preview closes, once (responsive-chat.md §2.1). */
327
+ let blockedSaid = false;
328
+ const blockPreview = () => { if (!blockedSaid) {
329
+ blockedSaid = true;
330
+ progress({ kind: "blocked", turn: turnId });
331
+ } };
311
332
  let proposals = 0;
312
333
  let reads = 0;
313
334
  let steps = 0;
@@ -336,18 +357,18 @@ export async function runLeadTurn(input) {
336
357
  return store.listLeadTurnEvidence(turnId).map(one => one.artifact);
337
358
  };
338
359
  /** 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) => {
360
+ const fail = (failed, message, unknownSpend, failureDetail = null) => {
340
361
  now = clock();
341
- store.finalizeLeadTurn(turnId, started.generation, { state: "failed", settledMicrousd: settled, unknownSpend, tokensIn, tokensOut, failureReason: failed }, now);
362
+ store.finalizeLeadTurn(turnId, started.generation, { state: "failed", settledMicrousd: settled, unknownSpend, tokensIn, tokensOut, failureReason: failed, failureDetail }, now);
342
363
  store.dropLeadAsk(turnId);
343
364
  return { ok: false, turn: turnId, failed, message, unknownSpend };
344
365
  };
345
366
  /** The turn stopped at its deadline: the notice is saved in the thread with whatever its completed tool calls proposed, in
346
367
  * the same write that ends the turn, so a refresh, another device or a restart shows the same outcome. */
347
- const timedOut = (unknownSpend) => {
368
+ const timedOut = (unknownSpend, failureDetail = null) => {
348
369
  now = clock();
349
370
  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);
371
+ 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
372
  store.dropLeadAsk(turnId);
352
373
  if (!saved)
353
374
  return { ok: false, turn: turnId, failed: "superseded", message: LEAD_FAILURE_COPY.stopped, unknownSpend: false };
@@ -387,6 +408,8 @@ export async function runLeadTurn(input) {
387
408
  let repaired = false;
388
409
  /** The turn's one shorten step for a reply over its channel's message limit (LEAD_REPLY_LIMITS); still over, it is split. */
389
410
  let shortened = false;
411
+ /** Why the next step runs, when it re-asks about the reply itself (repair or shorten): the live status says so. */
412
+ let nextReason;
390
413
  const replyLimit = input.channel === undefined ? null : LEAD_REPLY_LIMITS[input.channel];
391
414
  const unbacked = (text) => {
392
415
  const claim = deliverableClaim(text);
@@ -403,7 +426,10 @@ export async function runLeadTurn(input) {
403
426
  if (remainingMs <= 0)
404
427
  return timedOut(false);
405
428
  // 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();
429
+ // A quick step (§8) advertises the reads, and find_tools as its way to ask for more, on the faster model.
430
+ const quick = route === "fast";
431
+ const stepModel = quick ? quickModel : config.model;
432
+ const advertised = quick ? LEAD_TOOL_SCHEMAS.filter(one => quickAdvertises(one.name)) : advertisedTools();
407
433
  const advertisedNames = new Set(advertised.map(one => one.name));
408
434
  const request = direct ? composeDirect(input.key, advertised) : composeSubscription(advertised);
409
435
  // Tool results join the outbound body: scanned again before every dispatch.
@@ -411,7 +437,7 @@ export async function runLeadTurn(input) {
411
437
  if (scanForSecrets(outbound).length > 0) {
412
438
  return fail("secret-refused", LEAD_FAILURE_COPY.secretRead, false);
413
439
  }
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);
440
+ const step = store.openLeadStep({ leadTurn: turnId, generation: started.generation, approver: who.name, credentialKey, provider: config.provider, model: stepModel, deadlineMs: remainingMs + 10_000 }, now);
415
441
  if (!step.ok) {
416
442
  if (step.reason === "latched")
417
443
  return fail("latched", LEAD_FAILURE_COPY.paused, false);
@@ -421,7 +447,8 @@ export async function runLeadTurn(input) {
421
447
  if (!stepStarted.ok)
422
448
  return fail("provider-error", LEAD_FAILURE_COPY.notStarted, false);
423
449
  steps++;
424
- progress({ kind: "step", turn: turnId, step: steps });
450
+ progress({ kind: "step", turn: turnId, step: steps, ...(nextReason === undefined ? {} : { reason: nextReason }) });
451
+ nextReason = undefined;
425
452
  const requestBytes = Buffer.byteLength(outbound, "utf8");
426
453
  let result;
427
454
  // The turn owns its deadline (lead-stall, release check 2438): at the deadline the request is aborted, which ends a
@@ -430,7 +457,9 @@ export async function runLeadTurn(input) {
430
457
  const controller = new AbortController();
431
458
  const abortAt = setTimeout(() => controller.abort(), remainingMs);
432
459
  const settle = (work) => settleBy(work, remainingMs + LEAD_ABORT_GRACE_MS)
433
- .then(value => value === LATE || controller.signal.aborted ? { ok: false, problem: "timeout" } : value)
460
+ // A harness that ended at the deadline still says whether it had written anything (recorded, never shown).
461
+ .then(value => value === LATE ? { ok: false, problem: "timeout" } : controller.signal.aborted
462
+ ? { ok: false, problem: "timeout", ...(!value.ok && "quiet" in value && typeof value.quiet === "boolean" ? { quiet: value.quiet } : {}) } : value)
434
463
  .finally(() => clearTimeout(abortAt));
435
464
  if (direct) {
436
465
  result = await settle(performLeadRequest(request, directProvider, controller.signal, fetcher)
@@ -440,7 +469,7 @@ export async function runLeadTurn(input) {
440
469
  const runner = input.subscriptionRunner ?? performSubscriptionLeadRequest;
441
470
  result = await settle((async () => runner({
442
471
  provider: subscriptionProvider,
443
- model: config.model,
472
+ model: stepModel,
444
473
  system: LEAD_CONTRACT,
445
474
  dataDocument: document,
446
475
  history,
@@ -449,15 +478,22 @@ export async function runLeadTurn(input) {
449
478
  signal: controller.signal,
450
479
  // The reply as it is written; text that looks like a secret is
451
480
  // 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 }); } }),
481
+ ...(input.onProgress === undefined ? {} : { onText: (text) => { if (controller.signal.aborted)
482
+ return; if (scanForSecrets(text).length === 0)
483
+ progress({ kind: "text", turn: turnId, step: steps, text });
484
+ else
485
+ blockPreview(); } }),
454
486
  }))().catch(() => ({ ok: false, problem: "provider-error" })));
455
487
  }
456
488
  now = clock();
457
489
  const finishStep = (outcome) => store.finalizeChatTurn(step.id, stepStarted.generation, outcome, now);
458
490
  if (!result.ok) {
459
491
  if (!direct) {
460
- finishStep({ state: "failed", failureReason: result.problem === "timeout" ? "timeout" : result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", settledMicrousd: 0 });
492
+ // The exact cause, saved (v158): what a later reader shows, and what the next timeout diagnosis reads.
493
+ const quiet = "quiet" in result ? result.quiet : undefined;
494
+ const detail = result.problem === "timeout" ? (quiet === true ? "timeout-silent" : quiet === false ? "timeout-output" : null)
495
+ : result.problem === "not-found" ? "not-installed" : result.problem === "malformed-reply" ? null : "sign-in";
496
+ finishStep({ state: "failed", failureReason: result.problem === "timeout" ? "timeout" : result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", settledMicrousd: 0, failureDetail: detail });
461
497
  const message = result.problem === "not-found"
462
498
  ? LEAD_FAILURE_COPY.notInstalled
463
499
  : result.problem === "timeout"
@@ -466,29 +502,32 @@ export async function runLeadTurn(input) {
466
502
  ? LEAD_FAILURE_COPY.unreadable
467
503
  : LEAD_FAILURE_COPY.signIn;
468
504
  if (result.problem === "timeout")
469
- return timedOut(false);
470
- return fail(result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", message, false);
505
+ return timedOut(false, detail);
506
+ return fail(result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", message, false, detail);
471
507
  }
472
508
  if (result.problem.startsWith("status-")) {
473
509
  // 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);
510
+ finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: 0, failureDetail: "refused" });
511
+ return fail("provider-error", LEAD_FAILURE_COPY.refused, false, "refused");
476
512
  }
477
513
  if (result.problem === "timeout") {
478
514
  finishStep({ state: "failed", failureReason: "timeout", settledMicrousd: null, unknownSpend: true });
479
515
  return timedOut(true);
480
516
  }
481
517
  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);
518
+ finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: null, unknownSpend: true, failureDetail: "lost" });
519
+ return fail("provider-error", LEAD_FAILURE_COPY.lostUnknownCost, true, "lost");
484
520
  }
485
521
  finishStep({ state: "failed", failureReason: "malformed-reply", settledMicrousd: null, unknownSpend: true });
486
522
  return fail("malformed-reply", LEAD_FAILURE_COPY.unreadableUnknownCost, true);
487
523
  }
488
524
  const answer = result.answer;
489
525
  // 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 });
526
+ if (!direct && input.onProgress !== undefined && !controller.signal.aborted && answer.text !== "") {
527
+ if (scanForSecrets(answer.text).length === 0)
528
+ progress({ kind: "text", turn: turnId, step: steps, text: answer.text, final: true });
529
+ else
530
+ blockPreview();
492
531
  }
493
532
  // Usage that cannot be true — more input tokens than bytes sent — is a
494
533
  // malformed reply with unknown cost, never a number to settle by.
@@ -512,7 +551,9 @@ export async function runLeadTurn(input) {
512
551
  cacheReadTokens: answer.cacheReadTokens ?? null, cacheWriteTokens: answer.cacheWriteTokens ?? null,
513
552
  // v140: what the provider itself reported for this request (absent: unreported, kept NULL).
514
553
  modelReported: answer.modelReported ?? null, providerTurns: answer.providerTurns ?? null, reportedCostUsd: answer.reportedCostUsd ?? null,
515
- billingReported: answer.billingReported ?? null });
554
+ billingReported: answer.billingReported ?? null,
555
+ // v158: the tools this step asked for, names only (responsive-chat.md §7.2), so the next latency call is data-driven.
556
+ tools: answer.calls.map(one => one.name) });
516
557
  tokensIn += answer.tokensIn;
517
558
  tokensOut += answer.tokensOut;
518
559
  if (answer.cacheReadTokens === undefined)
@@ -527,6 +568,14 @@ export async function runLeadTurn(input) {
527
568
  const changed = guard(await revalidated());
528
569
  if (changed !== null)
529
570
  return changed;
571
+ // A quick step that asked for more than a read (§8): its calls are discarded unrun, and the configured model takes the
572
+ // reply over from the same history, for this step and every later one. Steps and the turn's deadline still bind.
573
+ if (route === "fast" && quickStepEscalates(answer.calls)) {
574
+ route = "escalated";
575
+ setLeadTurnRoute(store, turnId, started.generation, route);
576
+ lastText = "";
577
+ continue;
578
+ }
530
579
  if (answer.calls.length === 0) {
531
580
  if (answer.text.trim() === "")
532
581
  return fail("malformed-reply", LEAD_FAILURE_COPY.empty, false);
@@ -534,6 +583,7 @@ export async function runLeadTurn(input) {
534
583
  const claim = unbacked(answer.text);
535
584
  if (claim !== null && !repaired && steps < maxSteps) {
536
585
  repaired = true;
586
+ nextReason = "repair";
537
587
  history.push({ role: "assistant", text: answer.text, calls: [] });
538
588
  history.push({ role: "operator", text: deliverableRepair(claim) });
539
589
  continue;
@@ -541,6 +591,7 @@ export async function runLeadTurn(input) {
541
591
  const candidate = claim === null ? answer.text : dropDeliverableClaims(answer.text);
542
592
  if (replyLimit !== null && candidate.length > replyLimit && !shortened && steps < maxSteps) {
543
593
  shortened = true;
594
+ nextReason = "shorten";
544
595
  history.push({ role: "assistant", text: answer.text, calls: [] });
545
596
  history.push({ role: "operator", text: shortenAsk([{ field: "Your reply", limit: replyLimit, length: candidate.length }]) });
546
597
  continue;
@@ -566,9 +617,24 @@ export async function runLeadTurn(input) {
566
617
  progress({ kind: "tool", turn: turnId, step: steps, id: progressId, label: leadToolLabel(call.name) });
567
618
  // A registered tool this request did not advertise is refused before it is read or run (the only production
568
619
  // caller of executeLeadTool): the model learns the road (find_tools, then the next step) and the reply goes on.
620
+ const toolContext = () => ({ store, who, now: clock(), draft, selectEvidence, ...(input.replyImage === undefined ? {} : { replyImage: input.replyImage }), step: steps, readDecisions, readResults, searchedMemory, checkedCapabilities, ask, findTools, ...(input.integrations === undefined ? {} : { integrations: input.integrations }), thread: thread.id, turn: turnId, ...(promiseChannelOfTurn(input.channel) === undefined ? {} : { channel: promiseChannelOfTurn(input.channel) }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.mediaDelivery === undefined ? {} : { mediaDelivery: input.mediaDelivery }) });
621
+ // propose_start (docs/plans/one-step-chat.md C3), after the advertisement gate: its input is validated first (so
622
+ // invalid input never reaches the classifier), then Toolroll prepares this call's own preview in this turn (up to
623
+ // the classifier's budget), then the person's standing and the thread are re-proved after that wait, and only then
624
+ // does the tool run, with the preview in this call's own context. The model's arguments never name it.
625
+ let startPrepared;
626
+ if (call.name === "propose_start" && advertisedNames.has(call.name)) {
627
+ const checked = startInputOf(toolContext(), call.args);
628
+ if (checked.ok) {
629
+ startPrepared = await prepareChatStart(store, { actor: who.name, repo: checked.input.repo, title: checked.input.title, turn: turnId }, clock());
630
+ const changed = guard(await revalidated());
631
+ if (changed !== null)
632
+ return changed;
633
+ }
634
+ }
569
635
  const outcome = !advertisedNames.has(call.name)
570
636
  ? { ok: false, message: `${call.name} isn't available in this step. Call find_tools with what you want to do; tools it finds can be called from your next step.` }
571
- : executeLeadTool({ store, who, now: clock(), draft, selectEvidence, ...(input.replyImage === undefined ? {} : { replyImage: input.replyImage }), step: steps, readDecisions, readResults, searchedMemory, checkedCapabilities, ask, findTools, ...(input.integrations === undefined ? {} : { integrations: input.integrations }), thread: thread.id, turn: turnId, ...(promiseChannelOfTurn(input.channel) === undefined ? {} : { channel: promiseChannelOfTurn(input.channel) }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.mediaDelivery === undefined ? {} : { mediaDelivery: input.mediaDelivery }) }, call.name, call.args, view);
637
+ : executeLeadTool({ ...toolContext(), ...(startPrepared === undefined ? {} : { startPrepared }) }, call.name, call.args, view);
572
638
  let displayOutcome = { state: "succeeded" };
573
639
  if (!outcome.ok) {
574
640
  const reason = leadToolFailureReason(call.name, outcome.message);
@@ -598,10 +664,13 @@ export async function runLeadTurn(input) {
598
664
  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
665
  }
600
666
  // Ruling 11: model text is scanned before it becomes durable.
601
- if (scanForSecrets(reply).length > 0)
667
+ if (scanForSecrets(reply).length > 0) {
668
+ blockPreview();
602
669
  return fail("secret-refused", LEAD_FAILURE_COPY.secretReply, false);
670
+ }
603
671
  now = clock();
604
- const activity = activitySummary(reads, proposals, steps, cacheKnown && tokensIn > 0 ? Math.min(1, cacheRead / tokensIn) : null);
672
+ const routed = routeWords(route, quickModel, config.model);
673
+ const activity = `${activitySummary(reads, proposals, steps, cacheKnown && tokensIn > 0 ? Math.min(1, cacheRead / tokensIn) : null)}${routed === null ? "" : ` · ${routed}`}`;
605
674
  // One write (finding 12): settle, debit, promote the drafts, append the assistant text.
606
675
  const finalized = store.finalizeLeadTurn(turnId, started.generation, { state: "answered", settledMicrousd: settled, tokensIn, tokensOut, message: { text: reply, activity } }, now);
607
676
  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>;