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
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The terminal's live reply for `toolroll chat` (docs/plans/responsive-chat.md §3.6): on a TTY only, one status line
3
+ * shown at once and redrawn in place, then the answer as it is written, in a block of at most six rows wrapped to the
4
+ * terminal's width by display width (wide and combining characters). Piped output and `--json` never get here.
5
+ *
6
+ * Only this renderer emits cursor controls. Every piece of model-derived text it draws (the partial reply, and the
7
+ * status line, which can carry a tool's label) passes through `terminalText` first, on the whole accumulated text
8
+ * every time, so an escape sequence split across stream chunks can never get past it.
9
+ *
10
+ * Views come from the shared live-reply core and are drawn through its serialized sender, so `finish()` resolves only
11
+ * after the last draw: the block is erased and the caller prints the final answer (or the failure) strictly after.
12
+ * The erase moves up within the block's known height; a terminal that scrolled meanwhile keeps what it showed.
13
+ * Every draw first re-proves the caller's `standing`: once it fails, the block is erased and nothing more is drawn.
14
+ */
15
+ import type { LeadProgress } from "./lead-progress.js";
16
+ import { type ShapeOptions } from "./reply-shape.js";
17
+ /** Where the live block is drawn: the terminal, or a test's recording. */
18
+ export type TerminalLiveOutput = {
19
+ write(chunk: string): void;
20
+ /** The terminal's width now, in columns; undefined when unknown. */
21
+ columns(): number | undefined;
22
+ /** Call the listener when the terminal is resized; returns the unsubscribe. */
23
+ onResize(listener: () => void): () => void;
24
+ /** Call the listener on Ctrl-C during a turn (the process then exits, as Ctrl-C always did); returns the unsubscribe. */
25
+ onInterrupt(listener: () => void): () => void;
26
+ /** Draw the status line dimmed (off under NO_COLOR). */
27
+ dim?: boolean;
28
+ };
29
+ /** At most this many rows of the reply show under the status line. */
30
+ export declare const LIVE_TERMINAL_ROWS = 6;
31
+ /** The columns one grapheme takes: 0 for a lone combining or format mark, 2 for wide and emoji, else 1. */
32
+ export declare function graphemeWidth(cluster: string): number;
33
+ /** The columns a line of text takes on a terminal. */
34
+ export declare function displayWidth(text: string): number;
35
+ /** Text wrapped to rows of at most `width` columns: at the last space where there is one, else mid-word. */
36
+ export declare function wrapRows(text: string, width: number): string[];
37
+ export type TerminalLive = {
38
+ /** Feed one engine event (the turn's `onProgress`). */
39
+ progress(event: LeadProgress): void;
40
+ /** The turn ended: wait for the last draw, then erase the block. The final answer or failure prints after this. */
41
+ finish(): Promise<void>;
42
+ };
43
+ /**
44
+ * Start one turn's live block: the status line shows at once. `shape` is the final reply's shaping (the owner's
45
+ * message and this app's origins), so a partial reads as the final will.
46
+ */
47
+ export declare function startTerminalLive(out: TerminalLiveOutput, shape?: ShapeOptions, options?: {
48
+ spacingMs?: number;
49
+ /** Re-proved immediately before every draw (the person, the conversation, its projects and the turn, once known,
50
+ * running under the generation it was admitted with). False: the block is erased and nothing more is drawn. */
51
+ standing?: (turn: number | null) => boolean;
52
+ }): TerminalLive;
53
+ /** The real terminal: stdout, its resize events, and Ctrl-C (which still ends the process, after the block is gone). */
54
+ export declare function processTerminal(env?: Record<string, string | undefined>): TerminalLiveOutput;
55
+ /** A live block only on a real terminal that can move its cursor, and never for an agent driving the CLI. */
56
+ export declare function liveTerminalWanted(json: boolean, env: Record<string, string | undefined>, tty: boolean, agent: () => boolean): boolean;
@@ -0,0 +1,200 @@
1
+ import { createLiveReply, createLiveSender } from "./live-reply.js";
2
+ import { renderReply } from "./reply-shape.js";
3
+ import { terminalText } from "./terminal-text.js";
4
+ /** At most this many rows of the reply show under the status line. */
5
+ export const LIVE_TERMINAL_ROWS = 6;
6
+ /** The least time between two redraws. */
7
+ const REDRAW_MS = 50;
8
+ const STOPPED = "Stopped.";
9
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
10
+ /** East Asian wide and fullwidth ranges: these take two columns. */
11
+ const WIDE = [
12
+ [0x1100, 0x115f], [0x231a, 0x231b], [0x2329, 0x232a], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3],
13
+ [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xa960, 0xa97f],
14
+ [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe6f], [0xff00, 0xff60], [0xffe0, 0xffe6],
15
+ [0x1f300, 0x1f64f], [0x1f900, 0x1f9ff], [0x20000, 0x2fffd], [0x30000, 0x3fffd],
16
+ ];
17
+ /** The columns one grapheme takes: 0 for a lone combining or format mark, 2 for wide and emoji, else 1. */
18
+ export function graphemeWidth(cluster) {
19
+ if (cluster === "")
20
+ return 0;
21
+ if (/\p{Regional_Indicator}/u.test(cluster))
22
+ return 2;
23
+ if (/\p{Extended_Pictographic}/u.test(cluster) && (cluster.includes("️") || /\p{Emoji_Presentation}/u.test(cluster)))
24
+ return 2;
25
+ if (/^[\p{Mn}\p{Me}\p{Cf}]/u.test(cluster))
26
+ return 0;
27
+ const point = cluster.codePointAt(0);
28
+ return WIDE.some(([low, high]) => point >= low && point <= high) ? 2 : 1;
29
+ }
30
+ /** The columns a line of text takes on a terminal. */
31
+ export function displayWidth(text) {
32
+ let width = 0;
33
+ for (const { segment } of graphemes.segment(text))
34
+ width += graphemeWidth(segment);
35
+ return width;
36
+ }
37
+ /** One line cut to fit `width` columns, ending in "…" when it was cut. */
38
+ function fitRow(text, width) {
39
+ if (displayWidth(text) <= width)
40
+ return text;
41
+ let shown = "";
42
+ let used = 0;
43
+ for (const { segment } of graphemes.segment(text)) {
44
+ const next = graphemeWidth(segment);
45
+ if (used + next > width - 1)
46
+ break;
47
+ shown += segment;
48
+ used += next;
49
+ }
50
+ return `${shown}…`;
51
+ }
52
+ /** Text wrapped to rows of at most `width` columns: at the last space where there is one, else mid-word. */
53
+ export function wrapRows(text, width) {
54
+ const rows = [];
55
+ for (const line of text.split("\n")) {
56
+ let row = [];
57
+ let used = 0;
58
+ let space = -1;
59
+ for (const { segment } of graphemes.segment(line)) {
60
+ const next = graphemeWidth(segment);
61
+ if (used + next > width && row.length > 0) {
62
+ // A space that doesn't fit ends the row there; it is not carried to the next one.
63
+ if (segment === " ") {
64
+ rows.push(row.join("").trimEnd());
65
+ row = [];
66
+ used = 0;
67
+ space = -1;
68
+ continue;
69
+ }
70
+ while (used + next > width && row.length > 0) {
71
+ if (space > 0) {
72
+ rows.push(row.slice(0, space).join("").trimEnd());
73
+ row = row.slice(space + 1);
74
+ }
75
+ else {
76
+ rows.push(row.join(""));
77
+ row = [];
78
+ }
79
+ used = row.reduce((sum, one) => sum + graphemeWidth(one), 0);
80
+ space = row.lastIndexOf(" ");
81
+ }
82
+ }
83
+ row.push(segment);
84
+ used += next;
85
+ if (segment === " ")
86
+ space = row.length - 1;
87
+ }
88
+ rows.push(row.join("").trimEnd());
89
+ }
90
+ return rows;
91
+ }
92
+ /**
93
+ * Start one turn's live block: the status line shows at once. `shape` is the final reply's shaping (the owner's
94
+ * message and this app's origins), so a partial reads as the final will.
95
+ */
96
+ export function startTerminalLive(out, shape = {}, options = {}) {
97
+ const reply = createLiveReply(shape);
98
+ /** The turn this block follows, once the engine has said it started. */
99
+ let turn = null;
100
+ /** The rows on screen now, as drawn (without the dim styling); empty when nothing is. */
101
+ let drawn = [];
102
+ let ended = false;
103
+ const columns = () => {
104
+ const known = out.columns();
105
+ return known !== undefined && Number.isFinite(known) && known > 0 ? Math.floor(known) : 80;
106
+ };
107
+ /** Back to the block's first row and clear to the end of the screen. Rows are measured at the width now, as a
108
+ * terminal that reflows on resize has rewrapped them. */
109
+ const eraseSequence = () => {
110
+ if (drawn.length === 0)
111
+ return "";
112
+ const now = columns();
113
+ const height = drawn.reduce((sum, row) => sum + Math.max(1, Math.ceil(displayWidth(row) / now)), 0);
114
+ drawn = [];
115
+ return `\r${height > 1 ? `\x1b[${height - 1}A` : ""}\x1b[J`;
116
+ };
117
+ const draw = () => {
118
+ if (ended)
119
+ return;
120
+ if (options.standing !== undefined && !options.standing(turn)) {
121
+ // Standing lost: clear what is shown and close the preview; the turn's own outcome says why.
122
+ ended = true;
123
+ void sender.close();
124
+ stopResize();
125
+ const erase = eraseSequence();
126
+ if (erase !== "")
127
+ out.write(erase);
128
+ return;
129
+ }
130
+ // Three columns short of the edge: a full row never leaves the cursor waiting to wrap, and the "^C" a terminal
131
+ // echoes after the last row on Ctrl-C never wraps it, so the erase's height stays right.
132
+ const width = Math.max(10, columns() - 3);
133
+ const view = reply.view();
134
+ const status = view.phase === "working" || view.phase === "writing" ? view.status : reply.status();
135
+ const text = view.phase === "writing" ? terminalText(renderReply(view.text, "terminal")) : "";
136
+ const rows = [fitRow(terminalText(status), width), ...(text === "" ? [] : wrapRows(text, width).slice(-LIVE_TERMINAL_ROWS))];
137
+ const erase = eraseSequence();
138
+ const styled = rows.map((row, index) => (index === 0 && out.dim !== false ? `\x1b[2m${row}\x1b[22m` : row));
139
+ out.write(`${erase}${styled.join("\n")}`);
140
+ drawn = rows;
141
+ };
142
+ const sender = createLiveSender({ send: draw, spacingMs: options.spacingMs ?? REDRAW_MS });
143
+ const stopResize = out.onResize(() => sender.offer(null));
144
+ const stopInterrupt = out.onInterrupt(() => {
145
+ void sender.close();
146
+ const erase = eraseSequence();
147
+ ended = true;
148
+ stopResize();
149
+ out.write(`${erase}${STOPPED}\n`);
150
+ });
151
+ draw();
152
+ return {
153
+ progress(event) {
154
+ turn ??= event.turn;
155
+ // Model text is made terminal-safe before it is settled and shaped, on the whole text written so far.
156
+ if (reply.push(event.kind === "text" ? { ...event, text: terminalText(event.text) } : event))
157
+ sender.offer(null);
158
+ },
159
+ async finish() {
160
+ await sender.close();
161
+ stopResize();
162
+ stopInterrupt();
163
+ if (ended)
164
+ return;
165
+ ended = true;
166
+ const erase = eraseSequence();
167
+ if (erase !== "")
168
+ out.write(erase);
169
+ },
170
+ };
171
+ }
172
+ /** The real terminal: stdout, its resize events, and Ctrl-C (which still ends the process, after the block is gone). */
173
+ export function processTerminal(env = process.env) {
174
+ return {
175
+ write: chunk => { process.stdout.write(chunk); },
176
+ columns: () => process.stdout.columns,
177
+ onResize(listener) {
178
+ process.stdout.on("resize", listener);
179
+ return () => { process.stdout.off("resize", listener); };
180
+ },
181
+ onInterrupt(listener) {
182
+ const handler = () => {
183
+ process.off("SIGINT", handler);
184
+ try {
185
+ listener();
186
+ }
187
+ finally {
188
+ process.exit(130);
189
+ }
190
+ };
191
+ process.on("SIGINT", handler);
192
+ return () => { process.off("SIGINT", handler); };
193
+ },
194
+ dim: env["NO_COLOR"] === undefined || env["NO_COLOR"] === "",
195
+ };
196
+ }
197
+ /** A live block only on a real terminal that can move its cursor, and never for an agent driving the CLI. */
198
+ export function liveTerminalWanted(json, env, tty, agent) {
199
+ return !json && tty && env["TERM"] !== "dumb" && !agent();
200
+ }
@@ -1,6 +1,7 @@
1
1
  import { type LeadProposal, type Store } from "./store.js";
2
2
  import { type ShapeOptions } from "./reply-shape.js";
3
3
  import type { SubscriptionLeadRunner } from "./subscription-chat.js";
4
+ import { type TerminalLiveOutput } from "./lead-cli-live.js";
4
5
  export type LeadCliSeams = {
5
6
  fetcher?: typeof fetch;
6
7
  env?: Record<string, string | undefined>;
@@ -30,6 +31,8 @@ export type LeadCliInput = {
30
31
  evidenceRoot?: string;
31
32
  /** The console's address(es): a reply's links there are named as its pages ("the task"), not by host. */
32
33
  appOrigin?: ShapeOptions["appOrigin"];
34
+ /** A real terminal (never piped, never `--json`): each turn shows a live status line and the answer as it is written. */
35
+ liveTerminal?: TerminalLiveOutput;
33
36
  };
34
37
  export type LeadCliResult = {
35
38
  code: number;
package/dist/lead-cli.js CHANGED
@@ -16,13 +16,15 @@ import { readFileSync } from "node:fs";
16
16
  import { dirname, join } from "node:path";
17
17
  import { LEAD_ASK_OTHER } from "./store.js";
18
18
  import { CHAT_KEY_ENV, credentialKeyOf, isDirectChatProvider, priceForConfig, subscriptionCredentialKey } from "./converse.js";
19
- import { verifyApproverByPassword } from "./principal.js";
19
+ import { reproveApprover, verifyApproverByPassword } from "./principal.js";
20
20
  import { runLeadTurn } from "./lead.js";
21
21
  import { voiceReply } from "./reply-shape.js";
22
22
  import { confirmLeadProposal, dismissLeadProposal } from "./lead-doors.js";
23
23
  import { projectName } from "./project.js";
24
24
  import { CHAT_CONTROLS, chatControlHref, isChatControl } from "./chat-controls.js";
25
25
  import { CHAT_TASK_ACTIONS, isChatTaskAction } from "./chat-task-actions.js";
26
+ import { terminalText } from "./terminal-text.js";
27
+ import { startTerminalLive } from "./lead-cli-live.js";
26
28
  export const LEAD_CLI_EXIT = { ok: 0, failed: 1, usage: 2, refused: 3 };
27
29
  function money(microusd) {
28
30
  return `$${(microusd / 1_000_000).toFixed(2)}`;
@@ -135,9 +137,11 @@ export async function runLeadCli(input) {
135
137
  const seams = input.seams ?? {};
136
138
  const clock = seams.clock ?? (() => new Date());
137
139
  const env = seams.env ?? process.env;
140
+ // Everything said to a person's terminal passes the terminal filter: replies, cards and asks carry model-written
141
+ // text, and only Toolroll's own renderer may move the cursor. (`--json` escapes control characters itself.)
138
142
  const say = (line) => {
139
143
  if (!json)
140
- write(line);
144
+ write(terminalText(line));
141
145
  };
142
146
  const emit = (envelope) => {
143
147
  if (json)
@@ -255,7 +259,33 @@ export async function runLeadCli(input) {
255
259
  const outcome = { ok: false, refused: "session-ended", message: "This conversation has ended. Run chat again to start a new one." };
256
260
  return outcome;
257
261
  }
258
- return runLeadTurn({ store, who, session: live, thread, config, key, message, channel: "terminal", ...(seams.fetcher === undefined ? {} : { fetcher: seams.fetcher }), ...(seams.subscriptionRunner === undefined ? {} : { subscriptionRunner: seams.subscriptionRunner }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), clock });
262
+ // Before every draw: this approver as verified, this conversation and thread still open under the same projects,
263
+ // and the turn still running under the generation it was admitted with (as the engine's own guard re-proves).
264
+ let admitted = null;
265
+ const standing = (turnId) => {
266
+ if (!reproveApprover(store, who).ok)
267
+ return false;
268
+ const nowSession = store.getLeadSession(live.id), nowThread = store.getLeadThread(thread.id);
269
+ if (nowSession === null || nowSession.endedAt !== null || nowSession.approver !== who.name || nowSession.approverGeneration !== who.generation || nowSession.ceilingDigest !== who.ceilingDigest)
270
+ return false;
271
+ if (nowThread === null || nowThread.closedAt !== null || nowThread.approver !== who.name || nowThread.ceilingDigest !== who.ceilingDigest)
272
+ return false;
273
+ if (turnId === null)
274
+ return true;
275
+ const row = store.getLeadTurn(turnId);
276
+ if (row === null || row.state !== "running" || row.session !== live.id || row.thread !== thread.id)
277
+ return false;
278
+ admitted ??= row.generation;
279
+ return row.generation === admitted;
280
+ };
281
+ // The live block draws only on a real terminal; its last draw is erased before the answer or failure is printed.
282
+ const watch = input.liveTerminal === undefined || json ? null : startTerminalLive(input.liveTerminal, { appOrigin: input.appOrigin ?? null, asked: message }, { standing });
283
+ try {
284
+ return await runLeadTurn({ store, who, session: live, thread, config, key, message, channel: "terminal", ...(watch === null ? {} : { onProgress: watch.progress }), ...(seams.fetcher === undefined ? {} : { fetcher: seams.fetcher }), ...(seams.subscriptionRunner === undefined ? {} : { subscriptionRunner: seams.subscriptionRunner }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), clock });
285
+ }
286
+ finally {
287
+ await watch?.finish();
288
+ }
259
289
  };
260
290
  const report = (outcome, message) => {
261
291
  if (outcome.ok && outcome.replayed) {
@@ -268,7 +298,7 @@ export async function runLeadCli(input) {
268
298
  emit({ ok: true, turn: outcome.turn, reply: outcome.reply, activity: outcome.activity, steps: outcome.steps, settledMicrousd: outcome.settledMicrousd, proposals: pendingProposals().map(one => ({ id: one.id, ordinal: ordinalOf(one), kind: one.kind, payload: one.payload })),
269
299
  ...(ask === null ? {} : { ask: { question: ask.question, options: [...ask.options, LEAD_ASK_OTHER] } }) });
270
300
  say(` ${outcome.activity}`);
271
- say(voiceReply(outcome.reply, "terminal", { appOrigin: input.appOrigin ?? null, ...(message === undefined ? {} : { asked: message }) }));
301
+ say(voiceReply(terminalText(outcome.reply), "terminal", { appOrigin: input.appOrigin ?? null, ...(message === undefined ? {} : { asked: message }) }));
272
302
  // The lead's question: the terminal has no buttons, so the options are listed to type back.
273
303
  if (ask !== null)
274
304
  say(`${ask.question}\n${ask.options.map(one => ` · ${one}`).join("\n")}\n · ${LEAD_ASK_OTHER} (type your answer)`);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * v158: the lead's latency records and its quick-answer switch (docs/plans/responsive-chat.md, slice 5). Adds only:
3
+ * a saved row reads as it did. Kept beside nothing else so the update road can read the lists without the store.
4
+ *
5
+ * - `chat_turn.tools`: the tool names one lead step asked for, as a JSON array of names (never arguments); saved rows
6
+ * read NULL (not recorded). So the next latency decision (a prefetched brief, §7.2) is made from data.
7
+ * - `chat_turn.failure_detail` and `mate_turn.failure_detail`: why a step or reply failed, more exactly than
8
+ * failure_reason (LEAD_FAILURE_DETAILS); saved rows read NULL (unknown), and read the plain reason's words as before.
9
+ * - `mate_turn.route`: which model answered (§8): `default`, `fast` (the quick model answered) or `escalated` (a quick
10
+ * step asked for more than reading, so the main model took the reply over); saved rows read NULL (before routing).
11
+ * - `lead_quick_setting`: Settings → Lead's "Quick answers use a faster model", one row; absent reads off.
12
+ */
13
+ export declare const LEAD_FAILURE_DETAILS: readonly ["not-installed", "sign-in", "refused", "lost", "timeout-silent", "timeout-output"];
14
+ export type LeadFailureDetail = typeof LEAD_FAILURE_DETAILS[number];
15
+ export declare const LEAD_ROUTES: readonly ["default", "fast", "escalated"];
16
+ export type LeadRoute = typeof LEAD_ROUTES[number];
17
+ export declare const V158_SCHEMA = "\nCREATE TABLE IF NOT EXISTS lead_quick_setting (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n";
18
+ export declare const V158_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record<string, {
19
+ definition: string;
20
+ saved: string;
21
+ }>>>>;
22
+ export declare const V158_TABLES: readonly ["lead_quick_setting"];
23
+ /** The v158 migration's steps, in order (a test seam may stop after each). */
24
+ export declare const V158_STEPS: readonly ["lead-quick-setting", "lead-latency-columns"];
@@ -0,0 +1,36 @@
1
+ /**
2
+ * v158: the lead's latency records and its quick-answer switch (docs/plans/responsive-chat.md, slice 5). Adds only:
3
+ * a saved row reads as it did. Kept beside nothing else so the update road can read the lists without the store.
4
+ *
5
+ * - `chat_turn.tools`: the tool names one lead step asked for, as a JSON array of names (never arguments); saved rows
6
+ * read NULL (not recorded). So the next latency decision (a prefetched brief, §7.2) is made from data.
7
+ * - `chat_turn.failure_detail` and `mate_turn.failure_detail`: why a step or reply failed, more exactly than
8
+ * failure_reason (LEAD_FAILURE_DETAILS); saved rows read NULL (unknown), and read the plain reason's words as before.
9
+ * - `mate_turn.route`: which model answered (§8): `default`, `fast` (the quick model answered) or `escalated` (a quick
10
+ * step asked for more than reading, so the main model took the reply over); saved rows read NULL (before routing).
11
+ * - `lead_quick_setting`: Settings → Lead's "Quick answers use a faster model", one row; absent reads off.
12
+ */
13
+ export const LEAD_FAILURE_DETAILS = ["not-installed", "sign-in", "refused", "lost", "timeout-silent", "timeout-output"];
14
+ export const LEAD_ROUTES = ["default", "fast", "escalated"];
15
+ const detailCheck = (column) => `TEXT CHECK (${column} IS NULL OR ${column} IN (${LEAD_FAILURE_DETAILS.map(one => `'${one}'`).join(",")}))`;
16
+ export const V158_SCHEMA = `
17
+ CREATE TABLE IF NOT EXISTS lead_quick_setting (
18
+ id INTEGER PRIMARY KEY CHECK (id = 1),
19
+ enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
20
+ updated_by TEXT NOT NULL,
21
+ updated_at TEXT NOT NULL
22
+ );
23
+ `;
24
+ export const V158_ADDED_COLUMNS = Object.freeze({
25
+ chat_turn: {
26
+ tools: { definition: "TEXT", saved: "NULL" },
27
+ failure_detail: { definition: detailCheck("failure_detail"), saved: "NULL" },
28
+ },
29
+ mate_turn: {
30
+ route: { definition: `TEXT CHECK (route IS NULL OR route IN (${LEAD_ROUTES.map(one => `'${one}'`).join(",")}))`, saved: "NULL" },
31
+ failure_detail: { definition: detailCheck("failure_detail"), saved: "NULL" },
32
+ },
33
+ });
34
+ export const V158_TABLES = Object.freeze(["lead_quick_setting"]);
35
+ /** The v158 migration's steps, in order (a test seam may stop after each). */
36
+ export const V158_STEPS = Object.freeze(["lead-quick-setting", "lead-latency-columns"]);
@@ -4,10 +4,13 @@
4
4
  export type LeadProgress = {
5
5
  kind: "started";
6
6
  turn: number;
7
- } | {
7
+ }
8
+ /** `reason`: the step re-asks the model about its own reply (a repair of a claim it can't back, or a shorten). */
9
+ | {
8
10
  kind: "step";
9
11
  turn: number;
10
12
  step: number;
13
+ reason?: "repair" | "shorten";
11
14
  } | {
12
15
  kind: "tool";
13
16
  turn: number;
@@ -28,6 +31,12 @@ export type LeadProgress = {
28
31
  step: number;
29
32
  text: string;
30
33
  final?: true;
34
+ }
35
+ /** Text was held back because it looked like a password or key (mid-stream, or the finished reply refused): every
36
+ * live preview of this turn closes and clears at once (docs/plans/responsive-chat.md §2.1). */
37
+ | {
38
+ kind: "blocked";
39
+ turn: number;
31
40
  };
32
41
  export type LeadToolOutcome = {
33
42
  state: "succeeded";
@@ -48,7 +48,7 @@ const TOOL_LABELS = {
48
48
  export function leadToolLabel(name) {
49
49
  if (name in TOOL_LABELS)
50
50
  return TOOL_LABELS[name];
51
- return name.startsWith("propose_") ? "Preparing a card for you to confirm" : "Working";
51
+ return name.startsWith("propose_") ? "Preparing a card for you to confirm" : "Working on it";
52
52
  }
53
53
  /** Tool errors instruct the model how to retry. Only known, fixed copy belongs
54
54
  * in the live UI; never echo arguments, redaction placeholders or exceptions. */
@@ -28,7 +28,7 @@ export type PersonalQueueRuntimeOptions = {
28
28
  };
29
29
  /** The console's live reply display: started per turn, ended with the progress handle it returned. */
30
30
  live?: {
31
- begin: (thread: number) => (event: LeadProgress) => void;
31
+ begin: (thread: number, asked: string) => (event: LeadProgress) => void;
32
32
  end: (thread: number, ok: boolean, progress: (event: LeadProgress) => void) => void;
33
33
  };
34
34
  subscriptionRunner?: LeadTurnInput["subscriptionRunner"];
@@ -105,7 +105,7 @@ export function createPersonalQueueRuntime(options) {
105
105
  onAdmitted: turn => {
106
106
  if (!queue.bindTurn(claim, turn))
107
107
  throw new Error("The queued message changed before admission.");
108
- progress = options.live?.begin(claim.thread);
108
+ progress = options.live?.begin(claim.thread, claim.text);
109
109
  },
110
110
  ...(context === undefined ? {} : { context }), channel: "console", clock, evidenceRoot: options.evidenceRoot,
111
111
  ...(options.live === undefined ? {} : { onProgress: (event) => progress?.(event) }),
@@ -0,0 +1,56 @@
1
+ import type { LeadRoute } from "./lead-latency-schema.js";
2
+ import type { Store } from "./store.js";
3
+ /** Words that ask for something to be done (§8's fixed list, plus a few plain synonyms). Any of them keeps a message on
4
+ * the main model. Matched as whole words in their plain form, the form a request takes ("fix it", "can you merge",
5
+ * "file a task"): "what's running?" or "is it merged?" ask about a state and stay quick. A quick step that asks to act
6
+ * anyway is escalated (quickStepEscalates), so this list is the first line, not the only one. */
7
+ export declare const QUICK_ACT_WORDS: readonly string[];
8
+ /** The longest message the quick model takes: longer ones are rarely a plain status question. */
9
+ export declare const QUICK_MESSAGE_MAX_CHARS = 240;
10
+ /** Whether a message reads as a status or read question with no act word (§8's message rule). */
11
+ export declare function quickQuestion(message: string): boolean;
12
+ /**
13
+ * The tools a quick reply's requests advertise: reads only. Every one is a read of saved state as this person sees it;
14
+ * none drafts, records a question, a promise, a memory or a card, or opens a page. `find_tools` is advertised beside them
15
+ * but is not one of them: it is how a quick step says it needs something else ("search before saying something can't
16
+ * be done"), so calling it hands the reply to the main model, unrun (more conservative than §8's "find_tools for
17
+ * something to change": any search escalates).
18
+ */
19
+ export declare const QUICK_READ_TOOLS: ReadonlySet<string>;
20
+ /** Whether a quick step's request advertises this tool: the reads, and find_tools as the way to ask for more. */
21
+ export declare function quickAdvertises(name: string): boolean;
22
+ /** The faster model, as the model catalog names it: the newest Sonnet this computer's Claude has been seen to run, else
23
+ * the compiled current one. Always an exact id, never an alias that could move under a recorded route. */
24
+ export declare function quickLeadModel(store: Store): string;
25
+ export type QuickRouteInput = {
26
+ /** Settings → Lead's switch. */
27
+ enabled: boolean;
28
+ provider: string;
29
+ /** The configured model, which a quick reply would otherwise run on. */
30
+ mainModel: string;
31
+ message: string;
32
+ /** Server-authored context for this turn (a task page, a crew update): present keeps the main model. */
33
+ hasContext: boolean;
34
+ /** The message replies to an image (a result's screenshot). */
35
+ hasReplyImage: boolean;
36
+ /** The thread's newest reply asked the owner a question. */
37
+ askOpen: boolean;
38
+ /** The faster model, and whether the organisation policy allows it. */
39
+ quickModel: string;
40
+ quickAllowed: boolean;
41
+ };
42
+ /** Which model answers: the faster one only when every condition holds. */
43
+ export declare function quickRoute(input: QuickRouteInput): {
44
+ route: Extract<LeadRoute, "fast">;
45
+ model: string;
46
+ } | {
47
+ route: Extract<LeadRoute, "default">;
48
+ };
49
+ /** Whether a quick step asked for anything beyond the quick reads: its calls are then discarded unrun (§8). */
50
+ export declare function quickStepEscalates(calls: readonly {
51
+ name: string;
52
+ }[]): boolean;
53
+ /** "Sonnet 5.5" for claude-sonnet-5-5; the id itself for anything else. */
54
+ export declare function modelName(model: string): string;
55
+ /** The activity line's words for a routed reply: "answered by Sonnet 5.5", "moved to Opus"; nothing for the default. */
56
+ export declare function routeWords(route: LeadRoute, quickModel: string, mainModel: string): string | null;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The quick-answer router (docs/plans/responsive-chat.md §8, owner decision 7): which model answers a reply, decided
3
+ * before the turn, deterministically and conservatively. A reply goes to the faster model only when quick answers are
4
+ * on, the lead runs on the Claude membership, the organisation policy allows that model, the message carries no task
5
+ * or reply context, it doesn't answer the lead's own open question, and it reads as a status or read question with no
6
+ * act word. Everything else stays on the main model, unchanged.
7
+ *
8
+ * A quick reply is read-only by construction: its requests advertise only QUICK_READ_TOOLS (and find_tools, the way to
9
+ * ask for more). A quick step that asks for anything else has its calls discarded unrun, and the main model takes the reply over from that step (lead.ts).
10
+ */
11
+ import { aliasTarget } from "./model-catalog.js";
12
+ import { exactModelId } from "./phase-routing.js";
13
+ import { SONNET } from "./model-choices.js";
14
+ /** Words that ask for something to be done (§8's fixed list, plus a few plain synonyms). Any of them keeps a message on
15
+ * the main model. Matched as whole words in their plain form, the form a request takes ("fix it", "can you merge",
16
+ * "file a task"): "what's running?" or "is it merged?" ask about a state and stay quick. A quick step that asks to act
17
+ * anyway is escalated (quickStepEscalates), so this list is the first line, not the only one. */
18
+ export const QUICK_ACT_WORDS = Object.freeze([
19
+ "file", "make", "create", "add", "fix", "change", "build", "plan", "approve", "confirm", "merge", "release", "ship",
20
+ "deploy", "cancel", "pause", "hold", "resume", "retry", "steer", "remember", "delete", "archive", "send", "run",
21
+ "start", "stop",
22
+ // Plain synonyms of the same acts.
23
+ "rerun", "redo", "undo", "revert", "push", "publish", "kill", "edit", "remove", "rename", "reset", "restart",
24
+ "install", "upgrade", "enable", "disable", "accept", "reject", "schedule", "assign", "write", "draft", "set", "move",
25
+ "close", "reopen", "unhold", "dismiss", "prioritize", "prioritise", "split", "revise",
26
+ ]);
27
+ /** Openings that ask about the state of things. */
28
+ const ASKING_OPENINGS = new Set([
29
+ "what", "what's", "whats", "which", "who", "who's", "whose", "why", "when", "where", "how", "how's", "is", "are", "was",
30
+ "were", "did", "does", "do", "has", "have", "had", "any", "anything", "show", "list", "status", "tell", "give",
31
+ ]);
32
+ /** The longest message the quick model takes: longer ones are rarely a plain status question. */
33
+ export const QUICK_MESSAGE_MAX_CHARS = 240;
34
+ const words = (text) => text.toLowerCase().replace(/[’]/g, "'").split(/[^a-z0-9']+/).filter(one => one !== "");
35
+ /** Whether a message reads as a status or read question with no act word (§8's message rule). */
36
+ export function quickQuestion(message) {
37
+ const text = message.trim();
38
+ if (text === "" || text.length > QUICK_MESSAGE_MAX_CHARS || /[\r\n]/.test(text))
39
+ return false;
40
+ const all = words(text);
41
+ if (all.length === 0)
42
+ return false;
43
+ if (all.some(word => QUICK_ACT_WORDS.includes(word.replace(/'s$/, ""))))
44
+ return false;
45
+ // A negated or conditional request ("don't…", "if…, then…") is a judgement call: the main model takes it.
46
+ if (all.some(word => word === "don't" || word === "dont" || word === "if" || word === "please"))
47
+ return false;
48
+ return text.endsWith("?") || ASKING_OPENINGS.has(all[0]);
49
+ }
50
+ /**
51
+ * The tools a quick reply's requests advertise: reads only. Every one is a read of saved state as this person sees it;
52
+ * none drafts, records a question, a promise, a memory or a card, or opens a page. `find_tools` is advertised beside them
53
+ * but is not one of them: it is how a quick step says it needs something else ("search before saying something can't
54
+ * be done"), so calling it hands the reply to the main model, unrun (more conservative than §8's "find_tools for
55
+ * something to change": any search escalates).
56
+ */
57
+ export const QUICK_READ_TOOLS = new Set([
58
+ "get_brief", "get_project_context", "get_code_map", "get_project_activity", "get_actions", "get_action_status", "get_skills",
59
+ "get_acceptance_evidence", "recap", "list_repos", "list_tasks", "get_task", "get_result", "get_result_images", "get_controls",
60
+ "get_agents", "get_project_knowledge", "get_flaky_tests", "get_task_conversation", "get_diff", "get_check_log",
61
+ "get_project_tools", "get_flows", "get_flow_insights", "list_decisions", "get_decision", "queue",
62
+ "get_findings", "get_subagents", "get_person", "get_integrations", "get_capabilities", "search_project_memory", "get_models", "get_sweep",
63
+ ]);
64
+ /** Whether a quick step's request advertises this tool: the reads, and find_tools as the way to ask for more. */
65
+ export function quickAdvertises(name) {
66
+ return QUICK_READ_TOOLS.has(name) || name === "find_tools";
67
+ }
68
+ /** The faster model, as the model catalog names it: the newest Sonnet this computer's Claude has been seen to run, else
69
+ * the compiled current one. Always an exact id, never an alias that could move under a recorded route. */
70
+ export function quickLeadModel(store) {
71
+ const seen = aliasTarget(store, "sonnet")?.id ?? null;
72
+ return seen !== null && exactModelId(seen) ? seen : SONNET;
73
+ }
74
+ /** Which model answers: the faster one only when every condition holds. */
75
+ export function quickRoute(input) {
76
+ if (!input.enabled || input.provider !== "claude-subscription" || !input.quickAllowed)
77
+ return { route: "default" };
78
+ if (input.quickModel === input.mainModel || input.hasContext || input.hasReplyImage || input.askOpen)
79
+ return { route: "default" };
80
+ return quickQuestion(input.message) ? { route: "fast", model: input.quickModel } : { route: "default" };
81
+ }
82
+ /** Whether a quick step asked for anything beyond the quick reads: its calls are then discarded unrun (§8). */
83
+ export function quickStepEscalates(calls) {
84
+ return calls.some(call => !QUICK_READ_TOOLS.has(call.name));
85
+ }
86
+ /** "Sonnet 5.5" for claude-sonnet-5-5; the id itself for anything else. */
87
+ export function modelName(model) {
88
+ if (model === "default")
89
+ return "the main model";
90
+ const family = /^claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d+))?/.exec(model);
91
+ if (family !== null)
92
+ return `${family[1].charAt(0).toUpperCase()}${family[1].slice(1)} ${family[2]}${family[3] === undefined ? "" : `.${family[3]}`}`;
93
+ if (/^(opus|sonnet|haiku|fable)$/.test(model))
94
+ return `${model.charAt(0).toUpperCase()}${model.slice(1)}`;
95
+ return model;
96
+ }
97
+ /** The activity line's words for a routed reply: "answered by Sonnet 5.5", "moved to Opus"; nothing for the default. */
98
+ export function routeWords(route, quickModel, mainModel) {
99
+ if (route === "fast")
100
+ return `answered by ${modelName(quickModel)}`;
101
+ if (route === "escalated")
102
+ return `moved to ${modelName(mainModel)}`;
103
+ return null;
104
+ }