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
@@ -158,6 +158,8 @@ export type BrowserConversation = {
158
158
  previous?: {
159
159
  messages: BrowserMessage[];
160
160
  } | null;
161
+ /** How the last reply failed, from its saved turn (docs/plans/responsive-chat.md §3.4); absent or null otherwise. */
162
+ failure?: string | null;
161
163
  };
162
164
  /** A page rebuilt as React components (shadcn/ui). The server still renders
163
165
  * its HTML as the no-JavaScript fallback; forms post to the same routes. */
@@ -3,8 +3,11 @@
3
3
  * made inert for each app, so a value can't become a link, a mention or formatting. Whatever an app can't show comes out
4
4
  * as readable text, and every renderer has the same plain version to fall back on.
5
5
  */
6
- import type { ChatCard } from "./contracts/chat-card.js";
6
+ import type { ChatCard, ChatCardBlock } from "./contracts/chat-card.js";
7
7
  import { type Html } from "./html.js";
8
+ type Table = Extract<ChatCardBlock, {
9
+ kind: "table";
10
+ }>;
8
11
  /** A Text part is the lead's (a model's) words: always labelled as such, on every app, never as a record. */
9
12
  export declare const LEAD_NOTE_WORDS = "The lead's note, not Toolroll's records";
10
13
  /** The whole card as plain text: the version every app can show. */
@@ -24,3 +27,17 @@ export declare function telegramCardMessage(card: ChatCard): {
24
27
  };
25
28
  /** The card as the web chat's own markup; every value is escaped by `html`. */
26
29
  export declare function webCardHtml(card: ChatCard): Html;
30
+ /**
31
+ * A table's rows for a phone: each row's first value in full as its name, then its other values on one line ("queued ·
32
+ * standing-orders · 3h ago"; a number keeps its column's label: "Failed attempts 2"). A column with the same value in
33
+ * every shown row is said once above the rows instead ("All: queued · Failed attempts 0").
34
+ */
35
+ export declare function telegramTableRows(block: Table, keep: number): {
36
+ same: string;
37
+ rows: {
38
+ name: string;
39
+ rest: string;
40
+ }[];
41
+ more: number;
42
+ };
43
+ export {};
@@ -139,6 +139,16 @@ export function discordCardEmbed(card, plain) {
139
139
  // ---- Telegram ------------------------------------------------------------------------------------------------------
140
140
  /** The card as one Telegram message: plain words, tables and charts as `pre` entities, so nothing in a value is parsed. */
141
141
  export function telegramCardMessage(card) {
142
+ // A phone can't fit a table's columns side by side, so each row is a short list item. Past Telegram's limit, rows are
143
+ // cut from the end (counted in "and N more") before falling back to the plain version.
144
+ for (let keep = TELEGRAM_ROWS; keep >= 1; keep = Math.floor(keep / 2)) {
145
+ const message = telegramCardWith(card, block => Math.min(block.rows.length, keep));
146
+ if (message.text.length <= 4000)
147
+ return message;
148
+ }
149
+ return { text: cardPlainText(card).slice(0, 4000), entities: [] };
150
+ }
151
+ function telegramCardWith(card, rowsFor) {
142
152
  const entities = [];
143
153
  let text = "";
144
154
  const add = (words, type) => {
@@ -168,7 +178,16 @@ export function telegramCardMessage(card) {
168
178
  add(one, "bold");
169
179
  if (block.origin === "report")
170
180
  add(reportWords);
171
- add(tableLines(block, 40).join("\n"), "pre");
181
+ const listed = telegramTableRows(block, rowsFor(block));
182
+ if (listed.same !== "")
183
+ add(listed.same);
184
+ for (const row of listed.rows) {
185
+ add(row.name, "bold");
186
+ if (row.rest !== "")
187
+ text += `\n${row.rest}`;
188
+ }
189
+ if (listed.more > 0)
190
+ add(moreWords(listed.more));
172
191
  }
173
192
  else if (block.kind === "chart") {
174
193
  for (const one of headOf(card, block.heading))
@@ -180,8 +199,7 @@ export function telegramCardMessage(card) {
180
199
  add(one);
181
200
  if (card.actions.length > 0)
182
201
  add(`Confirm below: ${card.actions.map(one => one.label).join(", ")}`);
183
- // Telegram's own limit: past it, the whole plain version, cut, with no entities that could point past the end.
184
- return text.length <= 4000 ? { text, entities } : { text: cardPlainText(card).slice(0, 4000), entities: [] };
202
+ return { text, entities };
185
203
  }
186
204
  // ---- web chat ------------------------------------------------------------------------------------------------------
187
205
  const CHART_COLORS = 6;
@@ -221,3 +239,22 @@ export function webCardHtml(card) {
221
239
  });
222
240
  return html `<div class="tc-card">${joinHtml(blocks)}${joinHtml(footWords(card).map(one => html `<p class="tc-note">${one}</p>`))}</div>`;
223
241
  }
242
+ /** How many rows of a table a Telegram card shows before "and N more". */
243
+ const TELEGRAM_ROWS = 20;
244
+ /**
245
+ * A table's rows for a phone: each row's first value in full as its name, then its other values on one line ("queued ·
246
+ * standing-orders · 3h ago"; a number keeps its column's label: "Failed attempts 2"). A column with the same value in
247
+ * every shown row is said once above the rows instead ("All: queued · Failed attempts 0").
248
+ */
249
+ export function telegramTableRows(block, keep) {
250
+ const shown = block.rows.slice(0, Math.max(0, keep));
251
+ const more = block.more + block.rows.length - shown.length;
252
+ const cell = (at, value) => block.columns[at].numeric ? `${block.columns[at].label} ${value}` : value;
253
+ const constant = block.columns.map((_, at) => at > 0 && shown.length > 1 && shown.every(row => row[at] === shown[0][at]));
254
+ const same = constant.flatMap((yes, at) => yes && shown[0][at] !== "" ? [cell(at, shown[0][at])] : []);
255
+ const rows = shown.map(row => ({
256
+ name: row[0] === "" ? "(untitled)" : row[0],
257
+ rest: row.flatMap((value, at) => at === 0 || constant[at] || value === "" ? [] : [cell(at, value)]).join(" · "),
258
+ }));
259
+ return { same: same.length === 0 ? "" : `All: ${same.join(" · ")}`, rows, more };
260
+ }
@@ -1,4 +1,5 @@
1
1
  import { type WarmHooks } from "./chat-warmth.js";
2
+ import { type ChatLiveTransport } from "./chat-live.js";
2
3
  import { ChatState, type ChatBinding, type ChatContent, type ChatEvent, type ChatIdentity } from "./chat-delivery-state.js";
3
4
  import { type Store } from "./store.js";
4
5
  import type { SubscriptionLeadRunner } from "./subscription-chat.js";
@@ -29,8 +30,11 @@ export type ChatDeliveryOptions = {
29
30
  clock?: () => Date;
30
31
  partSize?: number;
31
32
  maxProposal?: number;
32
- /** The app's warm touches for one owner message (a 👍, typing), where the app and the bot's permissions allow them. */
33
+ /** The app's warm touch while the lead works on one owner message (typing), where the app and the bot's permissions allow it. */
33
34
  warm?: (event: ChatEvent, binding: ChatBinding) => WarmHooks;
35
+ /** Live replies (docs/plans/responsive-chat.md §3.2): the app's transport for the live message that answers the
36
+ * owner's own direct message; absent or null: the reply goes out whole at the end, as before. */
37
+ live?: (event: ChatEvent, binding: ChatBinding) => ChatLiveTransport | null;
34
38
  /** Merges a pull request approved in chat (tests script it); otherwise the project's own merge. */
35
39
  merge?: (input: {
36
40
  runId: number;
@@ -5,6 +5,7 @@ import { channelRepos, resolveChannelLead, proposalPreview, proposalOutcomeText,
5
5
  import { LEAD_MESSAGE_MAX_CHARS, leadFailureText, runLeadTurn } from "./lead.js";
6
6
  import { shapeReplyParts } from "./reply-shape.js";
7
7
  import { warmTurn } from "./chat-warmth.js";
8
+ import { openChatLive, planWithLive, settleLive } from "./chat-live.js";
8
9
  import { confirmLeadProposal, dismissLeadProposal, } from "./lead-doors.js";
9
10
  import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
10
11
  import { ChatState, ChatDeliveryError, chatHash, savedChatPart, } from "./chat-delivery-state.js";
@@ -173,6 +174,10 @@ export async function processChatEvent(options) {
173
174
  const event = state.next(identity.installation, now);
174
175
  if (!event)
175
176
  return false;
177
+ // The live message for this pass's turn, if any (chat-live.ts). Closed before any reply is planned.
178
+ let live = null;
179
+ // Every reply planned from here lands on the event's live message when it has one (its first part edits it).
180
+ const plan = (parts, at) => planWithLive(state, event.id, parts, at);
176
181
  try {
177
182
  if (event.kind === "pair") {
178
183
  if (!(await options.member(event.member, event.channel))) {
@@ -217,12 +222,12 @@ export async function processChatEvent(options) {
217
222
  }
218
223
  const body = readChatMessageBody(event.payload);
219
224
  if (!body.ok) {
220
- state.plan(event.id, [{ text: `That message couldn't be read (${body.issues.map(issue => issue.line).join("; ")}). Send it again.` }], nowOf(options));
225
+ plan([{ text: `That message couldn't be read (${body.issues.map(issue => issue.line).join("; ")}). Send it again.` }], nowOf(options));
221
226
  return true;
222
227
  }
223
228
  const input = body.value;
224
229
  if (typeof input.unsupported === "string") {
225
- state.plan(event.id, [{ text: input.unsupported }], nowOf(options));
230
+ plan([{ text: input.unsupported }], nowOf(options));
226
231
  return true;
227
232
  }
228
233
  const text = String(input.text ?? "");
@@ -238,7 +243,7 @@ export async function processChatEvent(options) {
238
243
  const prompt = openPromptFor(store, { channel: state.provider, binding: binding.id, chat: binding.channel }, nowOf(options));
239
244
  if (prompt !== null && answersPrompt(prompt, event.thread !== "" && event.thread !== event.ts ? event.thread : null)) {
240
245
  const answer = applyDecideFeedback(store, decideSeat(state, binding), prompt, text, repos, options.evidenceRoot, nowOf(options));
241
- state.plan(event.id, [{ text: answer.said, ...(answer.link === null ? {} : { link: answer.link }) }], nowOf(options));
246
+ plan([{ text: answer.said, ...(answer.link === null ? {} : { link: answer.link }) }], nowOf(options));
242
247
  return true;
243
248
  }
244
249
  }
@@ -259,14 +264,14 @@ export async function processChatEvent(options) {
259
264
  const focusedId = store.chatFocus(surface, binding.id);
260
265
  const focusedTitle = focusedId === null ? null : phoneText(store.getTask(focusedId)?.title ?? focusedId, 64);
261
266
  if (command.kind === "help")
262
- state.plan(event.id, [{ text: PHONE_HELP }], now_);
267
+ plan([{ text: PHONE_HELP }], now_);
263
268
  else if (command.kind === "status")
264
- state.plan(event.id, [{ text: phoneStatus(store, repos, now_, focusedTitle, options.evidenceRoot) }], now_);
269
+ plan([{ text: phoneStatus(store, repos, now_, focusedTitle, options.evidenceRoot) }], now_);
265
270
  else if (command.kind === "tasks")
266
- state.plan(event.id, [{ text: phoneTaskListText(phoneTaskChoices(store, repos, now_, null, 8, options.evidenceRoot), focusedTitle) }], now_);
271
+ plan([{ text: phoneTaskListText(phoneTaskChoices(store, repos, now_, null, 8, options.evidenceRoot), focusedTitle) }], now_);
267
272
  else if (command.kind === "lead") {
268
273
  store.setChatFocus(surface, binding.id, null, now_);
269
- state.plan(event.id, [{ text: PHONE_BACK_TO_LEAD }], now_);
274
+ plan([{ text: PHONE_BACK_TO_LEAD }], now_);
270
275
  }
271
276
  else {
272
277
  const pick = resolvePhoneTask(store, repos, now_, command.id);
@@ -274,13 +279,13 @@ export async function processChatEvent(options) {
274
279
  store.setChatFocus(surface, binding.id, pick.id, now_);
275
280
  const view = phoneTaskView(store, repos, pick.view, now_, options.evidenceRoot);
276
281
  // The status names its task (and result): a reply to it is about that task.
277
- state.plan(event.id, [{ text: phoneFocusText(view.text), ...(view.link === null ? {} : { link: view.link }), task: pick.view, ...(view.run === null ? {} : { run: view.run }) }], now_);
282
+ plan([{ text: phoneFocusText(view.text), ...(view.link === null ? {} : { link: view.link }), task: pick.view, ...(view.run === null ? {} : { run: view.run }) }], now_);
278
283
  }
279
284
  else if (pick.kind === "many") {
280
- state.plan(event.id, [{ text: ["Several tasks match. Add a word from the title, or send one of these:", ...pick.choices.map(one => `• ${one.title} — /task ${one.id}`)].join("\n") }], now_);
285
+ plan([{ text: ["Several tasks match. Add a word from the title, or send one of these:", ...pick.choices.map(one => `• ${one.title} — /task ${one.id}`)].join("\n") }], now_);
281
286
  }
282
287
  else
283
- state.plan(event.id, [{ text: PHONE_NO_MATCH }], now_);
288
+ plan([{ text: PHONE_NO_MATCH }], now_);
284
289
  }
285
290
  return true;
286
291
  }
@@ -298,12 +303,12 @@ export async function processChatEvent(options) {
298
303
  say: (reply, link) => replies.push({ text: reply, ...(link ? { link } : {}), ...(isRoom ? { channel: event.channel } : {}) }),
299
304
  });
300
305
  if (consumed) {
301
- state.plan(event.id, replies, now);
306
+ plan(replies, now);
302
307
  return true;
303
308
  }
304
309
  }
305
310
  if (text.length > LEAD_MESSAGE_MAX_CHARS) {
306
- state.plan(event.id, [
311
+ plan([
307
312
  {
308
313
  text: tooLongText(input.originalLength || text.length),
309
314
  },
@@ -311,12 +316,40 @@ export async function processChatEvent(options) {
311
316
  return true;
312
317
  }
313
318
  const request = chatHash(`${options.state.provider}:${binding.id}:${event.id}`).slice(0, 32);
319
+ // The live message answers only the owner's own message in their own chat: never the bot's, never a room.
320
+ const ownMessage = event.kind === "message" && event.member !== identity.bot && event.member === binding.member && event.channel === binding.channel;
321
+ const transport = ownMessage ? options.live?.(event, binding) ?? null : null;
322
+ // `ceiling`: the turn's; without one (still waiting) only the fixed "Waiting…" words may go, never the turn's.
323
+ const openLive = (ceiling) => {
324
+ if (transport === null)
325
+ return null;
326
+ live ??= openChatLive({
327
+ state, event, installation: identity.installation, transport, shape: { asked: text, appOrigin: options.origin() },
328
+ fenced: () => options.current() && state.owns(identity.installation, options.owner, nowOf(options)) && state.live(binding),
329
+ // False only on a definite refusal; a failed proof (a "slow down", a lost answer) throws: that view is dropped.
330
+ standing: async () => {
331
+ if (ceiling === null)
332
+ return false;
333
+ try {
334
+ await channelAccess(options, binding, ceiling);
335
+ return true;
336
+ }
337
+ catch (error) {
338
+ if (error instanceof ChatDeliveryError && ["Chat access changed", "Connected projects changed", `${options.label} account access changed`].includes(error.code))
339
+ return false;
340
+ throw error;
341
+ }
342
+ },
343
+ now: () => nowOf(options),
344
+ });
345
+ return live;
346
+ };
314
347
  let receipt = event.session === null
315
348
  ? null
316
349
  : store.leadRequestReceipt(event.session, request);
317
350
  if (!receipt) {
318
351
  if (new Date(event.created).getTime() + 600_000 < now.getTime()) {
319
- state.plan(event.id, [
352
+ plan([
320
353
  {
321
354
  text: "This message waited too long to start. Send it again when you’re ready.",
322
355
  },
@@ -326,10 +359,11 @@ export async function processChatEvent(options) {
326
359
  const resolved = resolveChannelLead(store, { approver: binding.approver, approverGeneration: binding.generation }, repos, now);
327
360
  if (!resolved.ok && resolved.reason === "busy") {
328
361
  state.defer(event.id, "Assistant is answering another message", new Date(now.getTime() + 5000));
362
+ await openLive(null)?.queued();
329
363
  return true;
330
364
  }
331
365
  if (!resolved.ok) {
332
- state.plan(event.id, [
366
+ plan([
333
367
  {
334
368
  text: (resolved.said ?? "This chat is no longer paired.")
335
369
  .replaceAll("this phone", options.label)
@@ -339,7 +373,7 @@ export async function processChatEvent(options) {
339
373
  return true;
340
374
  }
341
375
  if (event.session !== null && event.session !== resolved.session.id) {
342
- state.plan(event.id, [{ text: "This conversation was restarted after you sent that, so it wasn't answered. Send your message again." }], now);
376
+ plan([{ text: "This conversation was restarted after you sent that, so it wasn't answered. Send your message again." }], now);
343
377
  return true;
344
378
  }
345
379
  state
@@ -363,7 +397,7 @@ export async function processChatEvent(options) {
363
397
  .map((c) => [`${c.task}:${c.run ?? ""}`, c])).values(),
364
398
  ];
365
399
  if (targets.length > 1) {
366
- state.plan(event.id, [
400
+ plan([
367
401
  {
368
402
  text: "This thread is about more than one task. Reply to the message about the one you mean, or send /tasks to pick one.",
369
403
  },
@@ -383,8 +417,8 @@ export async function processChatEvent(options) {
383
417
  // An unknown surface names no channel rather than a guess.
384
418
  const channel = leadChannelOf(options.label);
385
419
  // Only ever the owner's own message in their own chat: never the bot's, never a room.
386
- const warm = warmTurn(options.warm !== undefined && event.kind === "message" && event.member !== identity.bot && event.member === binding.member && event.channel === binding.channel
387
- ? options.warm(event, binding) : {});
420
+ const warm = warmTurn(options.warm !== undefined && ownMessage ? options.warm(event, binding) : {});
421
+ const turnLive = openLive(resolved.who.ceilingDigest);
388
422
  const outcome = await runLeadTurn({
389
423
  store,
390
424
  who: resolved.who,
@@ -404,7 +438,7 @@ export async function processChatEvent(options) {
404
438
  evidenceRoot: options.evidenceRoot,
405
439
  mediaDelivery: "documents",
406
440
  ...(channel === undefined ? {} : { channel }),
407
- onProgress: warm.onProgress,
441
+ onProgress: (progress) => { warm.onProgress(progress); turnLive?.push(progress); },
408
442
  revalidate: async () => {
409
443
  try {
410
444
  await channelAccess(options, binding, resolved.who.ceilingDigest);
@@ -417,7 +451,11 @@ export async function processChatEvent(options) {
417
451
  return { ok: false, reason };
418
452
  }
419
453
  },
420
- }).finally(warm.stop);
454
+ }).finally(async () => {
455
+ warm.stop();
456
+ // Live edits end, and the one in flight settles, before anything of the reply is planned.
457
+ await turnLive?.close();
458
+ });
421
459
  if (outcome.ok && !outcome.replayed && context?.task)
422
460
  mirrorToTaskChat(store, resolved.who, context.task, options.label, text, outcome.reply, nowOf(options));
423
461
  if (!state.owns(identity.installation, options.owner, nowOf(options)) ||
@@ -428,11 +466,12 @@ export async function processChatEvent(options) {
428
466
  (outcome.refused === "concurrent" || outcome.refused === "backlog")) {
429
467
  // The lead is answering, or messages sent earlier wait in the person's lead queue: this one waits its turn.
430
468
  state.defer(event.id, outcome.refused === "backlog" ? "Waiting behind earlier messages" : "Assistant is answering another message", new Date(nowOf(options).getTime() + 5000));
469
+ await turnLive?.queued();
431
470
  return true;
432
471
  }
433
472
  receipt = store.leadRequestReceipt(resolved.session.id, request);
434
473
  if (!receipt) {
435
- state.plan(event.id, [
474
+ plan([
436
475
  {
437
476
  text: !outcome.ok
438
477
  ? phoneText(outcome.message, 1000)
@@ -442,6 +481,14 @@ export async function processChatEvent(options) {
442
481
  return true;
443
482
  }
444
483
  }
484
+ // A live message whose create never confirmed (this pass, or before a restart) is found first (Discord, by its
485
+ // nonce) or marked uncertain, so the reply edits the right message or none.
486
+ if (!(await settleLive(state, event.id, transport, () => nowOf(options)))) {
487
+ // Whether the live message was posted is still unknown (its lookup failed): the reply waits, so it edits that
488
+ // message rather than leaving it behind.
489
+ state.defer(event.id, "Reply is waiting to send", new Date(Math.max(nowOf(options).getTime() + 5000, Date.parse(state.retryAt(identity.installation)) || 0)));
490
+ return true;
491
+ }
445
492
  store.sweepStaleLeadTurns(nowOf(options));
446
493
  const turn = store.getLeadTurn(receipt.turn);
447
494
  if (turn?.state === "running" || turn?.state === "queued") {
@@ -449,9 +496,9 @@ export async function processChatEvent(options) {
449
496
  return true;
450
497
  }
451
498
  if (turn?.state !== "answered") {
452
- state.plan(event.id, [
499
+ plan([
453
500
  {
454
- text: leadFailureText(turn?.failureReason ?? null, turn != null && store.leadTurnCostUnconfirmed(turn.id)),
501
+ text: leadFailureText(turn?.failureReason ?? null, turn != null && store.leadTurnCostUnconfirmed(turn.id), turn?.failureDetail ?? null),
455
502
  },
456
503
  ], nowOf(options));
457
504
  return true;
@@ -490,15 +537,17 @@ export async function processChatEvent(options) {
490
537
  // Cards first, so what a card leads to sits right under it. A card is an ordinary part: it confirms nothing.
491
538
  for (const proposal of cardsFirst(store.listLeadProposals(turn.thread, ["pending"]).filter((p) => p.turn === turn.id)))
492
539
  parts.push(isCardProposal(proposal) ? cardPart(proposal) : { text: "", proposal: proposal.id });
493
- state.plan(event.id, parts, nowOf(options));
540
+ plan(parts, nowOf(options));
494
541
  return true;
495
542
  }
496
543
  catch (error) {
544
+ // Nothing is planned while a live edit could still land.
545
+ await live?.close();
497
546
  const problem = error instanceof ChatDeliveryError
498
547
  ? error
499
548
  : new ChatDeliveryError(`${options.label} is waiting to retry`);
500
549
  if (problem.permanent) {
501
- state.plan(event.id, [{ text: problem.message }], nowOf(options));
550
+ plan([{ text: problem.message }], nowOf(options));
502
551
  state.prepare("UPDATE chat_event SET next_at=NULL,problem=? WHERE provider=:provider AND id=?").run(problem.message, event.id);
503
552
  return true;
504
553
  }
@@ -1,8 +1,11 @@
1
1
  /** A pause that ends early, and quietly, when `signal` aborts. */
2
2
  export declare const abortablePause: (signal: AbortSignal) => (ms: number) => Promise<void>;
3
+ /** The most parts one pass delivers (docs/plans/responsive-chat.md §3.5): a reply's parts go together, bounded. */
4
+ export declare const DELIVER_PER_PASS = 5;
3
5
  /**
4
- * Works the queue while this connection stays the current, leased one: each pass takes in saved events, plans room
5
- * messages, delivers one part and plans notifications (when they are on), checking it is still current between steps.
6
+ * Works the queue while this connection stays the current, leased one: each pass first delivers parts already due,
7
+ * then, only when none remain, takes in saved events and plans room messages, delivering up to DELIVER_PER_PASS parts
8
+ * in all, and plans notifications (when they are on), checking it is still current between steps.
6
9
  * A pass waits while the connection isn't ready (offline, or the provider asked to retry later).
7
10
  */
8
11
  export declare function workChatQueue(steps: {
@@ -12,6 +15,6 @@ export declare function workChatQueue(steps: {
12
15
  notifications: () => boolean;
13
16
  process: () => Promise<unknown>;
14
17
  rooms: () => Promise<unknown>;
15
- deliver: () => Promise<unknown>;
18
+ deliver: () => Promise<boolean>;
16
19
  notify: () => Promise<unknown>;
17
20
  }): Promise<void>;
@@ -9,9 +9,12 @@ export const abortablePause = (signal) => async (ms) => {
9
9
  /* shutdown */
10
10
  }
11
11
  };
12
+ /** The most parts one pass delivers (docs/plans/responsive-chat.md §3.5): a reply's parts go together, bounded. */
13
+ export const DELIVER_PER_PASS = 5;
12
14
  /**
13
- * Works the queue while this connection stays the current, leased one: each pass takes in saved events, plans room
14
- * messages, delivers one part and plans notifications (when they are on), checking it is still current between steps.
15
+ * Works the queue while this connection stays the current, leased one: each pass first delivers parts already due,
16
+ * then, only when none remain, takes in saved events and plans room messages, delivering up to DELIVER_PER_PASS parts
17
+ * in all, and plans notifications (when they are on), checking it is still current between steps.
15
18
  * A pass waits while the connection isn't ready (offline, or the provider asked to retry later).
16
19
  */
17
20
  export async function workChatQueue(steps) {
@@ -20,11 +23,24 @@ export async function workChatQueue(steps) {
20
23
  await steps.pause(1000);
21
24
  continue;
22
25
  }
23
- await steps.process();
24
- if (steps.same())
25
- await steps.rooms();
26
- if (steps.same())
27
- await steps.deliver();
26
+ // Up to five parts a pass (one event's whole reply, usually), stopping at the first rate signal (`ready`). True
27
+ // when nothing more was due.
28
+ let parts = 0;
29
+ const drain = async () => {
30
+ while (parts < DELIVER_PER_PASS && steps.same() && steps.ready()) {
31
+ if (!(await steps.deliver()))
32
+ return true;
33
+ parts += 1;
34
+ }
35
+ return false;
36
+ };
37
+ // Parts already due go first (§3.5): a new event's turn starts only once nothing earlier waits to be delivered.
38
+ if (await drain() && steps.same()) {
39
+ await steps.process();
40
+ if (steps.same())
41
+ await steps.rooms();
42
+ await drain();
43
+ }
28
44
  if (steps.same() && steps.notifications())
29
45
  await steps.notify();
30
46
  await steps.pause(1000);
@@ -0,0 +1,17 @@
1
+ /**
2
+ * v157: live replies in Slack, Discord and Teams (docs/plans/responsive-chat.md §3.2). New tables only, so a saved row
3
+ * reads as it did. Kept beside nothing else so the update road can read the list without loading the store.
4
+ *
5
+ * chat_live: one "Working on it…" message per received event, so the final answer can replace it. Separate from
6
+ * chat_part: planning the reply still adds its own parts (the first one edits this message), and the event stays
7
+ * queued until then. state: creating (asked the app to post it, no answer yet) → placed (its id is known), or
8
+ * uncertain (a crash or a lost answer left it unknown whether it was posted: never posted again), then finalized
9
+ * (the reply was planned onto it) or abandoned (live edits stopped: access changed or the app refused).
10
+ *
11
+ * chat_ack: the 👀 on a person's message, put on when it arrives and taken off once the answer (or the failure
12
+ * notice) is delivered. state: sent (on, to take off later), done (taken off, or nothing to take off: Teams typing),
13
+ * skipped (not put on: the app refused, or it waited too long).
14
+ */
15
+ export declare const V157_SCHEMA = "\nCREATE TABLE IF NOT EXISTS chat_live (\n provider TEXT NOT NULL CHECK (provider IN ('slack','discord','teams')), event TEXT NOT NULL,\n state TEXT NOT NULL CHECK (state IN ('creating','uncertain','placed','finalized','abandoned')),\n message TEXT, generation INTEGER NOT NULL DEFAULT 0, problem TEXT, created TEXT NOT NULL, updated TEXT NOT NULL,\n PRIMARY KEY (provider, event),\n FOREIGN KEY (provider, event) REFERENCES chat_event(provider, id)\n);\nCREATE TABLE IF NOT EXISTS chat_ack (\n provider TEXT NOT NULL CHECK (provider IN ('slack','discord','teams')), event TEXT NOT NULL,\n state TEXT NOT NULL CHECK (state IN ('sent','done','skipped')), created TEXT NOT NULL, updated TEXT NOT NULL,\n PRIMARY KEY (provider, event),\n FOREIGN KEY (provider, event) REFERENCES chat_event(provider, id)\n);\nCREATE INDEX IF NOT EXISTS chat_ack_sent ON chat_ack(provider, state);\n";
16
+ export declare const V157_TABLES: readonly ["chat_live", "chat_ack"];
17
+ export declare const V157_STEPS: readonly ["chat-live-tables"];
@@ -0,0 +1,32 @@
1
+ /**
2
+ * v157: live replies in Slack, Discord and Teams (docs/plans/responsive-chat.md §3.2). New tables only, so a saved row
3
+ * reads as it did. Kept beside nothing else so the update road can read the list without loading the store.
4
+ *
5
+ * chat_live: one "Working on it…" message per received event, so the final answer can replace it. Separate from
6
+ * chat_part: planning the reply still adds its own parts (the first one edits this message), and the event stays
7
+ * queued until then. state: creating (asked the app to post it, no answer yet) → placed (its id is known), or
8
+ * uncertain (a crash or a lost answer left it unknown whether it was posted: never posted again), then finalized
9
+ * (the reply was planned onto it) or abandoned (live edits stopped: access changed or the app refused).
10
+ *
11
+ * chat_ack: the 👀 on a person's message, put on when it arrives and taken off once the answer (or the failure
12
+ * notice) is delivered. state: sent (on, to take off later), done (taken off, or nothing to take off: Teams typing),
13
+ * skipped (not put on: the app refused, or it waited too long).
14
+ */
15
+ export const V157_SCHEMA = `
16
+ CREATE TABLE IF NOT EXISTS chat_live (
17
+ provider TEXT NOT NULL CHECK (provider IN ('slack','discord','teams')), event TEXT NOT NULL,
18
+ state TEXT NOT NULL CHECK (state IN ('creating','uncertain','placed','finalized','abandoned')),
19
+ message TEXT, generation INTEGER NOT NULL DEFAULT 0, problem TEXT, created TEXT NOT NULL, updated TEXT NOT NULL,
20
+ PRIMARY KEY (provider, event),
21
+ FOREIGN KEY (provider, event) REFERENCES chat_event(provider, id)
22
+ );
23
+ CREATE TABLE IF NOT EXISTS chat_ack (
24
+ provider TEXT NOT NULL CHECK (provider IN ('slack','discord','teams')), event TEXT NOT NULL,
25
+ state TEXT NOT NULL CHECK (state IN ('sent','done','skipped')), created TEXT NOT NULL, updated TEXT NOT NULL,
26
+ PRIMARY KEY (provider, event),
27
+ FOREIGN KEY (provider, event) REFERENCES chat_event(provider, id)
28
+ );
29
+ CREATE INDEX IF NOT EXISTS chat_ack_sent ON chat_ack(provider, state);
30
+ `;
31
+ export const V157_TABLES = Object.freeze(["chat_live", "chat_ack"]);
32
+ export const V157_STEPS = Object.freeze(["chat-live-tables"]);