toolroll 0.9.53 → 0.9.54

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 (62) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/chat-ask.js +8 -8
  3. package/dist/chat-core.d.ts +171 -0
  4. package/dist/chat-core.js +288 -0
  5. package/dist/chat-decide.d.ts +10 -8
  6. package/dist/chat-decide.js +26 -21
  7. package/dist/chat-delivery-state.d.ts +51 -75
  8. package/dist/chat-delivery-state.js +140 -170
  9. package/dist/chat-delivery.d.ts +21 -2
  10. package/dist/chat-delivery.js +313 -194
  11. package/dist/chat-flow.d.ts +29 -1
  12. package/dist/chat-flow.js +24 -50
  13. package/dist/chat-messages.d.ts +319 -0
  14. package/dist/chat-messages.js +841 -0
  15. package/dist/chat-migration.d.ts +30 -0
  16. package/dist/chat-migration.js +802 -0
  17. package/dist/chat-quiet.d.ts +3 -0
  18. package/dist/chat-quiet.js +5 -0
  19. package/dist/contracts/chat-content.d.ts +71 -2
  20. package/dist/contracts/chat-content.js +22 -3
  21. package/dist/contracts/chat-tables.d.ts +11 -4
  22. package/dist/contracts/chat-tables.js +165 -87
  23. package/dist/contracts/discord-callback.js +2 -1
  24. package/dist/contracts/slack-callback.js +3 -2
  25. package/dist/contracts/store-json.d.ts +1 -5
  26. package/dist/contracts/store-json.js +1 -4
  27. package/dist/contracts/teams-callback.js +2 -1
  28. package/dist/discord-chat.js +19 -38
  29. package/dist/discord-settings.js +4 -13
  30. package/dist/discord.js +4 -14
  31. package/dist/export.js +4 -4
  32. package/dist/mate-tools.js +3 -2
  33. package/dist/modes.d.ts +13 -1
  34. package/dist/modes.js +17 -1
  35. package/dist/notification-rows.d.ts +30 -0
  36. package/dist/notification-rows.js +102 -0
  37. package/dist/operate.js +5 -2
  38. package/dist/plane-review.js +5 -11
  39. package/dist/project-delete.js +21 -26
  40. package/dist/result-shots.js +2 -2
  41. package/dist/retention.js +1 -1
  42. package/dist/server/settings.js +5 -4
  43. package/dist/slack-api.d.ts +5 -1
  44. package/dist/slack-api.js +2 -2
  45. package/dist/slack-chat.d.ts +9 -4
  46. package/dist/slack-chat.js +29 -52
  47. package/dist/slack-settings.js +6 -15
  48. package/dist/slack.js +6 -16
  49. package/dist/store.d.ts +97 -364
  50. package/dist/store.js +207 -1531
  51. package/dist/teammate-question.js +12 -12
  52. package/dist/teams-chat.js +12 -9
  53. package/dist/teams-settings.js +2 -2
  54. package/dist/teams.js +4 -5
  55. package/dist/telegram-flow.js +5 -5
  56. package/dist/telegram.js +4 -4
  57. package/dist/toolroll-update.d.ts +10 -0
  58. package/dist/toolroll-update.js +50 -3
  59. package/dist/workspace-revision.js +3 -1
  60. package/package.json +1 -1
  61. package/dist/slack-state.d.ts +0 -13
  62. package/dist/slack-state.js +0 -10
@@ -14,7 +14,7 @@ import { answerChatFlowPrompt, answerChoiceMessage, applyChatFlowTap, flowDecisi
14
14
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
15
15
  import { triggerConfigOf } from "./flow-triggers.js";
16
16
  import { telegramProgressCard } from "./telegram-progress.js";
17
- import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
17
+ import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView, wantedBeforePairing } from "./chat-quiet.js";
18
18
  import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
19
19
  import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText, leadSubjectOf } from "./lead-voice.js";
20
20
  import { leadChannelOf } from "./lead-context.js";
@@ -25,6 +25,8 @@ import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
25
25
  import { isShotsKind, resultShotsFor } from "./result-shots.js";
26
26
  import { messageTeammate } from "./teammate-desk.js";
27
27
  import { CHAT_APP_NAMES } from "./flow-triggers.js";
28
+ import { answersPrompt, applyDecideFeedback, applyDecideTap, decideFallbackLink, decideOffer, decideTargetOf, hasLiveDecideTokens, linksFor, mergedText, mintDecideButtons, offerFingerprint, openPromptFor, recordChatMerge } from "./chat-decide.js";
29
+ import { mergePullRequest } from "./pull-request-flow.js";
28
30
  export const chatObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
29
31
  ? v
30
32
  : {};
@@ -69,6 +71,72 @@ export async function channelAccess(options, binding, ceiling) {
69
71
  throw new ChatDeliveryError("Chat access changed");
70
72
  return latest;
71
73
  }
74
+ // ---- decisions that finish in the chat (chat-decide.ts), the same in every app --------------------------------------
75
+ /** Where a paired person taps: their binding, and the chat the card is in. */
76
+ const decideSeat = (state, binding, chat = binding.channel) => ({ channel: state.provider, binding: binding.id, chat, approver: binding.approver, generation: binding.generation });
77
+ /** What a card about one result, plan, failure or pull request may act on in this person's chat now, under their
78
+ * current ceiling; null keeps its link. Reads only. */
79
+ function decideOfferFor(options, binding, target, repos, now) {
80
+ if (target == null)
81
+ return null;
82
+ const principal = verifyApproverStanding(options.store, binding.approver, binding.generation, repos);
83
+ return principal.ok ? decideOffer(options.store, target, principal.who, now, options.state.provider, options.evidenceRoot) : null;
84
+ }
85
+ /** A card's buttons as a part carries them. */
86
+ const decideRows = (rows) => rows.map(row => row.map(one => "token" in one ? { label: one.label, token: one.token } : { label: one.label, link: one.link }));
87
+ /** The offer's buttons, minted now: they ride the part's message once it is sent (ChatState.partSent). */
88
+ function mintDecide(options, binding, target, offer, now) {
89
+ return { rows: decideRows(mintDecideButtons(options.store, decideSeat(options.state, binding), target, offer, now).rows) };
90
+ }
91
+ /**
92
+ * A decide button (chat-decide.ts), inside the tap's transaction: the same door, words and two taps as on Telegram.
93
+ * The card it rode is repainted in place; Request changes asks for the next message; a merge goes out after.
94
+ */
95
+ function applyChatDecideTap(options, event, binding, token, repos, now) {
96
+ const { store, state } = options;
97
+ // The card the button rode: this person's part on that message.
98
+ const row = state.prepare(`SELECT p.id, p.payload FROM chat_part p JOIN chat_event e ON e.provider = p.provider AND e.id = p.event
99
+ WHERE p.provider = :provider AND e.binding = ? AND p.message = ? ORDER BY p.id DESC LIMIT 1`).get(binding.id, event.ts);
100
+ const read = row === undefined ? null : readChatPart(String(row["payload"]));
101
+ const card = read?.ok === true ? read.value : null;
102
+ const tapped = applyDecideTap(store, decideSeat(state, binding, event.channel), { token, message: event.ts, shown: card?.text ?? "", repos, root: options.evidenceRoot, now });
103
+ if (tapped === null) {
104
+ state.plan(event.id, [{ text: "That button doesn't do anything now." }], now);
105
+ return null;
106
+ }
107
+ const replies = [];
108
+ let repainted = null;
109
+ if (tapped.edit !== undefined && row !== undefined && card !== null) {
110
+ const { decide: _decide, link: _link, also: _also, edit: _edit, ...kept } = card;
111
+ repainted = { ...kept, text: tapped.edit.text, ...(tapped.edit.rows.length === 0 ? {} : { decide: { rows: decideRows(tapped.edit.rows) } }) };
112
+ state.repaint(Number(row["id"]), repainted);
113
+ }
114
+ else
115
+ replies.push({ text: tapped.ack });
116
+ if (tapped.prompt !== undefined)
117
+ replies.push({ text: tapped.prompt.text, prompt: tapped.prompt.id });
118
+ state.plan(event.id, replies, now);
119
+ return tapped.merge === undefined || row === undefined || repainted === null ? null
120
+ : { merge: tapped.merge, part: Number(row["id"]), shown: tapped.edit?.text ?? card?.text ?? "", content: repainted };
121
+ }
122
+ /** GitHub is a network call: after the tap's transaction, then the card says how it went, and the ledger too. */
123
+ async function mergeFromChat(options, pending) {
124
+ const { merge } = pending;
125
+ let merged;
126
+ try {
127
+ const result = options.merge !== undefined ? await options.merge({ runId: merge.runId, by: merge.by })
128
+ : await mergePullRequest(options.store, { runId: merge.runId, by: merge.by, clock: () => nowOf(options) });
129
+ merged = result.ok ? { ok: true } : { ok: false, message: result.message };
130
+ }
131
+ catch {
132
+ merged = { ok: false, message: "GitHub couldn't be reached just now. Try again from the task." };
133
+ }
134
+ recordChatMerge(options.store, merge, merged, nowOf(options));
135
+ const { decide: _decide, ...kept } = pending.content;
136
+ // A merge that didn't land keeps a way to the task.
137
+ options.state.repaint(pending.part, { ...kept, text: mergedText(pending.shown, merged),
138
+ ...(merged.ok ? {} : { decide: { rows: decideRows(linksFor({ kind: "merge", taskId: merge.taskId, run: merge.runId })) } }) });
139
+ }
72
140
  export async function processChatEvent(options) {
73
141
  const { store, identity } = options, state = options.state, now = nowOf(options);
74
142
  if (!state.owns(identity.installation, options.owner, now) ||
@@ -88,14 +156,14 @@ export async function processChatEvent(options) {
88
156
  return false;
89
157
  const body = readChatPairBody(event.payload);
90
158
  const paired = body.ok
91
- ? state.pair(identity, body.value.hash, event.member, event.channel, nowOf(options))
159
+ ? state.pair(identity, body.value.hash, event.member, event.channel, nowOf(options), event.id)
92
160
  : null;
93
161
  if (!paired) {
94
162
  state.finish(event.id, true);
95
163
  return true;
96
164
  }
97
165
  state
98
- .prepare("UPDATE chat_event SET binding=? WHERE id=?")
166
+ .prepare("UPDATE chat_event SET binding=? WHERE provider=:provider AND id=?")
99
167
  .run(paired.id, event.id);
100
168
  state.plan(event.id, [
101
169
  {
@@ -114,7 +182,9 @@ export async function processChatEvent(options) {
114
182
  }
115
183
  const repos = await channelAccess(options, binding);
116
184
  if (event.kind === "action") {
117
- applyChatAction(options, event, binding, repos);
185
+ const merging = applyChatAction(options, event, binding, repos);
186
+ if (merging !== null)
187
+ await mergeFromChat(options, merging);
118
188
  return true;
119
189
  }
120
190
  const body = readChatMessageBody(event.payload);
@@ -134,12 +204,22 @@ export async function processChatEvent(options) {
134
204
  // A flow decision asked for this person's next message (Edit, Send back): it is the draft or the note.
135
205
  if (answerChatFlowPrompt({ store, state, label: options.label }, event, binding, { text, ...(typeof input.originalLength === "number" ? { originalLength: input.originalLength } : {}) }, repos, nowOf(options)))
136
206
  return true;
207
+ // After Request changes asked "What should change?": the person's next message in their own chat is the feedback
208
+ // (a reply in another message's thread keeps its own meaning).
209
+ if (event.channel === binding.channel && text.trim() !== "" && phoneCommand(text) === null) {
210
+ const prompt = openPromptFor(store, { channel: state.provider, binding: binding.id, chat: binding.channel }, nowOf(options));
211
+ if (prompt !== null && answersPrompt(prompt, event.thread !== "" && event.thread !== event.ts ? event.thread : null)) {
212
+ const answer = applyDecideFeedback(store, decideSeat(state, binding), prompt, text, repos, options.evidenceRoot, nowOf(options));
213
+ state.plan(event.id, [{ text: answer.said, ...(answer.link === null ? {} : { link: answer.link }) }], nowOf(options));
214
+ return true;
215
+ }
216
+ }
137
217
  // A "Person chooses" notice's thread reply is the note; the first other message after it is asked about (chat-flow.ts).
138
218
  if (answerChoiceMessage({ store, state, label: options.label }, event, binding, { text, ...(typeof input.originalLength === "number" ? { originalLength: input.originalLength } : {}), lead: input.lead === true }, nowOf(options), repos))
139
219
  return true;
140
220
  // A message to a teammate by name (v96), in someone's own chat with Toolroll: a card on its desk.
141
221
  if (event.channel === binding.channel) {
142
- const handed = messageTeammate(store, { who: binding.approver, repos, via: CHAT_APP_NAMES[state.channel] ?? "Chat" }, text, nowOf(options));
222
+ const handed = messageTeammate(store, { who: binding.approver, repos, via: CHAT_APP_NAMES[state.provider] ?? "Chat" }, text, nowOf(options));
143
223
  if (handed !== null) {
144
224
  state.plan(event.id, [{ text: handed.said, ...(handed.link === undefined ? {} : { link: handed.link }) }], nowOf(options));
145
225
  return true;
@@ -190,7 +270,7 @@ export async function processChatEvent(options) {
190
270
  const replies = [];
191
271
  const registry = await options.readProjects();
192
272
  const consumed = applyRoomInbound({
193
- store, channel: state.channel, backend: state.roomBackend(identity.installation, now), chatId: event.channel, isGroup: isRoom,
273
+ store, channel: state.provider, backend: state.roomBackend(identity.installation, now), chatId: event.channel, isGroup: isRoom,
194
274
  sender: { approver: binding.approver, generation: binding.generation, binding: binding.id }, updateKey: event.id, text,
195
275
  projects: registry, origin: options.origin(), now, report: { ignored: 0 },
196
276
  say: (reply, link) => replies.push({ text: reply, ...(link ? { link } : {}), ...(isRoom ? { channel: event.channel } : {}) }),
@@ -208,7 +288,7 @@ export async function processChatEvent(options) {
208
288
  ], nowOf(options));
209
289
  return true;
210
290
  }
211
- const request = chatHash(`${options.state.channel}:${binding.id}:${event.id}`).slice(0, 32);
291
+ const request = chatHash(`${options.state.provider}:${binding.id}:${event.id}`).slice(0, 32);
212
292
  let receipt = event.session === null
213
293
  ? null
214
294
  : store.mateRequestReceipt(event.session, request);
@@ -241,10 +321,10 @@ export async function processChatEvent(options) {
241
321
  return true;
242
322
  }
243
323
  state
244
- .prepare("UPDATE chat_event SET session=? WHERE id=? AND state='queued'")
324
+ .prepare("UPDATE chat_event SET session=? WHERE provider=:provider AND id=? AND state='queued'")
245
325
  .run(resolved.session.id, event.id);
246
326
  const contexts = state
247
- .prepare("SELECT p.payload FROM chat_part p JOIN chat_event e ON e.id=p.event WHERE e.binding=? AND e.channel=? AND (p.message=? OR e.thread=?) AND p.state='sent' ORDER BY p.id DESC LIMIT 100")
327
+ .prepare("SELECT p.payload FROM chat_part p JOIN chat_event e ON e.provider=p.provider AND e.id=p.event WHERE p.provider=:provider AND e.binding=? AND e.channel=? AND (p.message=? OR e.thread=?) AND p.state='sent' ORDER BY p.id DESC LIMIT 100")
248
328
  .all(binding.id, binding.channel, event.thread, event.thread)
249
329
  // Context only: a part that can't be read adds none.
250
330
  .flatMap((row) => { const read = readChatPart(String(row.payload)); return read.ok ? [read.value] : []; })
@@ -275,7 +355,7 @@ export async function processChatEvent(options) {
275
355
  if (context?.task) {
276
356
  about = { task: context.task, run: context.run ?? null };
277
357
  state
278
- .prepare("UPDATE chat_event SET payload=? WHERE id=? AND state='queued'")
358
+ .prepare("UPDATE chat_event SET payload=? WHERE provider=:provider AND id=? AND state='queued'")
279
359
  .run(JSON.stringify({ ...input, about }), event.id);
280
360
  }
281
361
  // An unknown surface names no channel rather than a guess.
@@ -397,16 +477,16 @@ export async function processChatEvent(options) {
397
477
  : new ChatDeliveryError(`${options.label} is waiting to retry`);
398
478
  if (problem.permanent) {
399
479
  state.plan(event.id, [{ text: problem.message }], nowOf(options));
400
- state.prepare("UPDATE chat_event SET next_at=NULL,problem=? WHERE id=?").run(problem.message, event.id);
480
+ state.prepare("UPDATE chat_event SET next_at=NULL,problem=? WHERE provider=:provider AND id=?").run(problem.message, event.id);
401
481
  return true;
402
482
  }
403
483
  state
404
- .prepare("UPDATE chat_runtime SET problem=? WHERE installation=? AND owner=?")
484
+ .prepare("UPDATE chat_runtime SET problem=? WHERE provider=:provider AND installation=? AND owner=?")
405
485
  .run(problem.message, identity.installation, options.owner);
406
486
  state.defer(event.id, problem.message, new Date(nowOf(options).getTime() + problem.retryMs));
407
487
  if (problem.code === "ratelimited")
408
488
  state
409
- .prepare("UPDATE chat_runtime SET retry_at=? WHERE installation=?")
489
+ .prepare("UPDATE chat_runtime SET retry_at=? WHERE provider=:provider AND installation=?")
410
490
  .run(new Date(nowOf(options).getTime() + problem.retryMs).toISOString(), identity.installation);
411
491
  return true;
412
492
  }
@@ -416,7 +496,7 @@ export async function processChatEvent(options) {
416
496
  * or that the channel's flow takes as a card. False: not for an inbox.
417
497
  */
418
498
  async function channelInboxEvent(options, event) {
419
- const { store, identity } = options, state = options.state, app = state.channel;
499
+ const { store, identity } = options, state = options.state, app = state.provider;
420
500
  const binding = event.binding === null ? null : state.bindingById(event.binding);
421
501
  if (binding !== null && event.channel === binding.channel)
422
502
  return false;
@@ -454,13 +534,14 @@ async function channelInboxEvent(options, event) {
454
534
  state.finish(event.id);
455
535
  return true;
456
536
  }
457
- state.prepare("UPDATE chat_event SET binding=? WHERE id=?").run(grant.id, event.id);
537
+ state.prepare("UPDATE chat_event SET binding=? WHERE provider=:provider AND id=?").run(grant.id, event.id);
458
538
  state.plan(event.id, [{ text: taken.said, channel: event.channel, ...(taken.link === undefined ? {} : { link: taken.link }) }], nowOf(options));
459
539
  return true;
460
540
  }
461
541
  /** Bound to the paired member, exact channel/message, pending proposal and current ceiling. */
462
542
  export function applyChatAction(options, event, binding, repos) {
463
543
  const state = options.state, { store } = options, now = nowOf(options), signals = [];
544
+ let merging = null;
464
545
  store.transact(() => {
465
546
  if (!state.owns(options.identity.installation, options.owner, now) ||
466
547
  !options.current() ||
@@ -476,6 +557,11 @@ export function applyChatAction(options, event, binding, repos) {
476
557
  return;
477
558
  }
478
559
  const token = body.value.token;
560
+ // A result's, plan's, failure's or pull request's own button (chat-decide.ts).
561
+ if (token.startsWith("d:")) {
562
+ merging = applyChatDecideTap(options, event, binding, token, repos, now);
563
+ return;
564
+ }
479
565
  // A flow decision's button (v88) is answered by the flow's own door.
480
566
  if (applyChatFlowTap({ store, state, label: options.label }, event, binding, token, repos, now))
481
567
  return;
@@ -486,7 +572,7 @@ export function applyChatAction(options, event, binding, repos) {
486
572
  if (applyChatAskTap({ store, state }, event, binding, token, now))
487
573
  return;
488
574
  const saved = state
489
- .prepare("SELECT a.*,p.message,e.binding,e.channel,e.thread FROM chat_action a JOIN chat_part p ON p.id=a.part JOIN chat_event e ON e.id=p.event WHERE token=?")
575
+ .prepare("SELECT a.token,a.part,a.proposal,a.phase,a.expires,a.consumed,p.message,e.binding,e.channel,e.thread FROM chat_action a JOIN chat_part p ON p.provider=a.provider AND p.id=a.part JOIN chat_event e ON e.provider=p.provider AND e.id=p.event WHERE a.provider=:provider AND a.token=?")
490
576
  .get(token);
491
577
  // The saved button, read by its schema: one that can't be read is a spent button, never a guess.
492
578
  const read = saved === undefined ? null : readProposalActionRow({ ...saved });
@@ -495,7 +581,7 @@ export function applyChatAction(options, event, binding, repos) {
495
581
  action.binding !== binding.id ||
496
582
  action.channel !== event.channel ||
497
583
  action.message !== event.ts ||
498
- (options.state.channel === "slack" && action.thread !== event.thread) ||
584
+ (options.state.provider === "slack" && action.thread !== event.thread) ||
499
585
  action.consumed !== null ||
500
586
  action.expires <= now.toISOString();
501
587
  if (invalid) {
@@ -517,7 +603,7 @@ export function applyChatAction(options, event, binding, repos) {
517
603
  proposal: proposal.id,
518
604
  edit: event.ts,
519
605
  };
520
- const preview = proposalPreview(store, proposal, repos, options.state.channel);
606
+ const preview = proposalPreview(store, proposal, repos, options.state.provider);
521
607
  const phase = action.phase;
522
608
  if (proposal.state !== "pending")
523
609
  content = { text: proposalOutcomeText(proposal), edit: event.ts };
@@ -544,7 +630,7 @@ export function applyChatAction(options, event, binding, repos) {
544
630
  }
545
631
  else {
546
632
  const outcome = confirmMateProposal(store, verified.who, proposal.id, now, {
547
- via: options.state.channel,
633
+ via: options.state.provider,
548
634
  evidenceRoot: options.evidenceRoot,
549
635
  confirm: phase === "yes",
550
636
  deferSignal: (signal) => signals.push(signal),
@@ -566,20 +652,21 @@ export function applyChatAction(options, event, binding, repos) {
566
652
  }
567
653
  // Repaint the original persisted card; retain one placement and fresh tokens.
568
654
  state
569
- .prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE id=?")
655
+ .prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE provider=:provider AND id=?")
570
656
  .run(savedChatPart(content), action.part);
571
657
  if (!content.proposal)
572
658
  state
573
- .prepare("UPDATE chat_action SET consumed=? WHERE proposal=? AND consumed IS NULL")
659
+ .prepare("UPDATE chat_action SET consumed=? WHERE provider=:provider AND proposal=? AND consumed IS NULL")
574
660
  .run(now.toISOString(), proposal.id);
575
661
  else
576
662
  state
577
- .prepare("UPDATE chat_action SET consumed=? WHERE token=?")
663
+ .prepare("UPDATE chat_action SET consumed=? WHERE provider=:provider AND token=?")
578
664
  .run(now.toISOString(), token);
579
665
  state.finish(event.id);
580
666
  });
581
667
  for (const signal of signals)
582
668
  signal();
669
+ return merging;
583
670
  }
584
671
  /**
585
672
  * Carry new conversation messages to every room that follows one: the
@@ -609,7 +696,7 @@ export async function planRoomMessages(options) {
609
696
  if (row === null)
610
697
  continue;
611
698
  for (const message of roomMessagesAfter(store, row.thread, room.cursor)) {
612
- const text = roomMessageText(message, state.channel, room.chat);
699
+ const text = roomMessageText(message, state.provider, room.chat);
613
700
  let carrier = null;
614
701
  if (room.kind === "private")
615
702
  carrier = live.find(one => one.channel === room.chat) ?? null;
@@ -631,7 +718,7 @@ export async function planRoomMessages(options) {
631
718
  if (message.role === "assistant" && message.turn !== null)
632
719
  for (const proposal of store.listMateProposals(row.thread, ["pending"]).filter(one => one.turn === message.turn))
633
720
  parts.push({ text: "", proposal: proposal.id, channel: room.chat });
634
- const id = chatHash(`${state.channel}:room:${room.id}:${message.id}`);
721
+ const id = chatHash(`${state.provider}:room:${room.id}:${message.id}`);
635
722
  const target = carrier;
636
723
  store.transact(() => {
637
724
  if (parts.length > 0 && state.enqueue({ id, installation: identity.installation, binding: target.id, kind: "message", channel: room.chat, member: target.member, ts: "", thread: "", payload: {}, created: now.toISOString() }))
@@ -647,7 +734,7 @@ function planQuietCard(options, binding, notification, now) {
647
734
  const { state, store, identity } = options;
648
735
  if (notification.taskRef === null || notification.taskId === null)
649
736
  return;
650
- const card = store.chatCardFor(`${state.channel}:${binding.id}`, notification.taskRef, store.getTask(notification.taskId)?.createdAt ?? notification.createdAt, now);
737
+ const card = store.chatCardFor(`${state.provider}:${binding.id}`, notification.taskRef, store.getTask(notification.taskId)?.createdAt ?? notification.createdAt, now);
651
738
  const view = quietCardView(store, card.tasks, now, options.evidenceRoot, binding.approver);
652
739
  if (view === null)
653
740
  return;
@@ -656,45 +743,53 @@ function planQuietCard(options, binding, notification, now) {
656
743
  if (card.message !== null) {
657
744
  if (card.digest === shown)
658
745
  return;
659
- state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'").run(savedChatPart(content), now.toISOString(), Number(card.message));
746
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE provider=:provider AND id=? AND state!='dropped'").run(savedChatPart(content), now.toISOString(), Number(card.message));
660
747
  store.setChatCardMessage(card.id, card.message, shown);
661
748
  return;
662
749
  }
663
- const id = chatHash(`${state.channel}:card:${binding.id}:${card.id}`);
750
+ const id = chatHash(`${state.provider}:card:${binding.id}:${card.id}`);
664
751
  state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
665
752
  ts: "", thread: "", payload: {}, created: now.toISOString() });
666
753
  state.plan(id, [content], now);
667
- const part = state.prepare("SELECT id FROM chat_part WHERE event=?").get(id);
754
+ const part = state.prepare("SELECT id FROM chat_part WHERE provider=:provider AND event=?").get(id);
668
755
  if (part !== undefined)
669
756
  store.setChatCardMessage(card.id, String(part.id), shown);
670
757
  }
671
758
  /** Any update for this person joins their open batch (two minutes from its first), and the batch's one message
672
759
  * is planned once, then repainted in place as more updates land. `notification`: an update that is not a finished
673
760
  * result (null for one that is). False when nothing in the batch reads as a line, so the update goes out on its own. */
674
- function planFinished(options, binding, taskRef, run, now, notification = null) {
761
+ function planFinished(options, binding, taskRef, run, now, notification = null, repos = []) {
675
762
  const { state, store, identity } = options;
676
- const batch = store.chatBatchFor(`${state.channel}:${binding.id}`, taskRef, run, now, BATCH_MS, notification);
763
+ const batch = store.chatBatchFor(`${state.provider}:${binding.id}`, taskRef, run, now, BATCH_MS, notification);
677
764
  const view = finishedView(store, batch, now, options.evidenceRoot, binding.approver);
678
765
  if (view === null)
679
766
  return false;
680
767
  // A single update keeps its task and run, so a reply to it names that work.
681
768
  const single = batch.items.length === 1 ? batch.items[0] : null;
682
769
  const task = single === null ? null : store.refById(single.taskRef)?.externalId ?? null;
683
- const content = { text: view.text, link: view.link, ...(view.also === undefined ? {} : { also: view.also }),
684
- ...(task === null ? {} : { task }), ...(single?.run == null ? {} : { run: single.run }) };
685
- const shown = chatHash(JSON.stringify(content));
770
+ const about = { ...(task === null ? {} : { task }), ...(single?.run == null ? {} : { run: single.run }) };
771
+ // A message about one result, plan, failure or pull request acts in place (chat-decide.ts), as on Telegram: its
772
+ // buttons are minted only when what it offers changed, or its own were spent (Not now), so an unchanged repaint keeps them.
773
+ const offer = decideOfferFor(options, binding, view.target, repos, now);
774
+ const content = offer === null
775
+ ? { text: view.text, link: view.link, ...(view.also === undefined ? {} : { also: view.also }), ...about }
776
+ : { text: offer.text ?? view.text, ...about };
777
+ const shown = chatHash(JSON.stringify(offer === null ? content : [content, offerFingerprint(offer)]));
686
778
  if (batch.message !== null) {
687
- if (batch.digest === shown)
779
+ const placed = state.part(Number(batch.message));
780
+ const live = offer === null || placed?.state === "pending" || (placed?.message != null && hasLiveDecideTokens(store, state.provider, binding.channel, placed.message, now));
781
+ if (batch.digest === shown && live)
688
782
  return true;
689
- state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'").run(savedChatPart(content), now.toISOString(), Number(batch.message));
783
+ const painted = offer === null ? content : { ...content, decide: mintDecide(options, binding, view.target, offer, now) };
784
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE provider=:provider AND id=? AND state!='dropped'").run(savedChatPart(painted), now.toISOString(), Number(batch.message));
690
785
  store.setChatBatchMessage(batch.id, batch.message, shown);
691
786
  return true;
692
787
  }
693
- const id = chatHash(`${state.channel}:batch:${binding.id}:${batch.id}`);
788
+ const id = chatHash(`${state.provider}:batch:${binding.id}:${batch.id}`);
694
789
  state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
695
790
  ts: "", thread: "", payload: {}, created: now.toISOString() });
696
- state.plan(id, [content], now);
697
- const part = state.prepare("SELECT id FROM chat_part WHERE event=?").get(id);
791
+ state.plan(id, [offer === null ? content : { ...content, decide: mintDecide(options, binding, view.target, offer, now) }], now);
792
+ const part = state.prepare("SELECT id FROM chat_part WHERE provider=:provider AND event=?").get(id);
698
793
  if (part !== undefined)
699
794
  store.setChatBatchMessage(batch.id, String(part.id), shown);
700
795
  return true;
@@ -715,20 +810,20 @@ function planResultShots(options, binding, notification, repos, id, now) {
715
810
  if (visit !== null) {
716
811
  for (const key of [`flow-send:${visit[1]}:${visit[2]}`, `flow-choose:${visit[1]}:${visit[2]}`]) {
717
812
  const notice = store.handle.prepare("SELECT id FROM notification WHERE dedupe_key=?").get(key);
718
- const part = notice === undefined ? undefined : state.prepare("SELECT id FROM chat_part WHERE event=? ORDER BY id DESC LIMIT 1").get(chatHash(`${state.channel}:notice:${binding.id}:${Number(notice["id"])}`));
813
+ const part = notice === undefined ? undefined : state.prepare("SELECT id FROM chat_part WHERE provider=:provider AND event=? ORDER BY id DESC LIMIT 1").get(chatHash(`${state.provider}:notice:${binding.id}:${Number(notice["id"])}`));
719
814
  if (part !== undefined)
720
815
  return Number(part.id);
721
816
  }
722
817
  return null;
723
818
  }
724
- const batch = notification.taskRef === null ? null : store.chatBatchMessageFor(`${state.channel}:${binding.id}`, notification.taskRef, notification.run);
819
+ const batch = notification.taskRef === null ? null : store.chatBatchMessageFor(`${state.provider}:${binding.id}`, notification.taskRef, notification.run);
725
820
  if (batch !== null)
726
821
  return Number(batch);
727
- const card = state.prepare("SELECT part FROM chat_progress WHERE binding=? AND run=?").get(binding.id, notification.run);
822
+ const card = state.prepare("SELECT part FROM chat_progress WHERE provider=:provider AND binding=? AND run=?").get(binding.id, notification.run);
728
823
  if (card !== undefined)
729
824
  return Number(card.part);
730
825
  for (const one of store.handle.prepare("SELECT id FROM notification WHERE source_run=? AND recipient IS NULL AND id<? ORDER BY id DESC").all(notification.run, notification.id)) {
731
- const part = state.prepare("SELECT id FROM chat_part WHERE event=? ORDER BY id LIMIT 1").get(chatHash(`${state.channel}:notice:${binding.id}:${Number(one["id"])}`));
826
+ const part = state.prepare("SELECT id FROM chat_part WHERE provider=:provider AND event=? ORDER BY id LIMIT 1").get(chatHash(`${state.provider}:notice:${binding.id}:${Number(one["id"])}`));
732
827
  if (part !== undefined)
733
828
  return Number(part.id);
734
829
  }
@@ -737,7 +832,7 @@ function planResultShots(options, binding, notification, repos, id, now) {
737
832
  const image = (artifact, sha256) => ({ taskId: where.task, run: where.run, artifact, sha256 });
738
833
  const parts = plan.kind === "line" ? [{ text: plan.text, ...where }]
739
834
  // Teams has no file upload here: one message that links to the saved result.
740
- : state.channel === "teams" ? [{ text: plan.shots.length === 1 ? plan.shots[0].caption : `${plan.shots[0].caption} · ${plan.shots.length} screenshots`,
835
+ : state.provider === "teams" ? [{ text: plan.shots.length === 1 ? plan.shots[0].caption : `${plan.shots[0].caption} · ${plan.shots.length} screenshots`,
741
836
  image: image(plan.shots[0].artifact, plan.shots[0].sha256), shot: { follows: null }, ...where }]
742
837
  : plan.shots.map(one => ({ text: one.caption, image: image(one.artifact, one.sha256), shot: { follows }, ...where }));
743
838
  state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
@@ -759,163 +854,187 @@ export async function planChatNotifications(options) {
759
854
  catch { /* Retried on the next pass. */ }
760
855
  enqueueLeadLapses(store, nowOf(options));
761
856
  const cursor = Number(state
762
- .prepare("SELECT notification FROM chat_runtime WHERE installation=?")
857
+ .prepare("SELECT notification FROM chat_runtime WHERE provider=:provider AND installation=?")
763
858
  .get(identity.installation)?.notification ?? 0);
859
+ // A person who paired after the cursor passed is still owed what wanted them before: once per pairing, their
860
+ // unresolved attention notices and open decisions from before it, through the same door as every new one.
861
+ for (const binding of bindings) {
862
+ const owed = `owed-before-pairing:${binding.id}`;
863
+ if (state.meta(identity.installation, owed) !== null)
864
+ continue;
865
+ for (const notification of store.notificationsWantedBefore(binding.created, cursor))
866
+ offerNotice(options, notification, binding, registry);
867
+ state.setMeta(identity.installation, owed, "offered", nowOf(options));
868
+ }
764
869
  // Every paired person is a destination of their own, under their own ceiling.
765
870
  for (const notification of store.notificationsAfter(cursor, 100)) {
766
- for (const binding of bindings) {
767
- const repos = channelRepos(store, binding.approver, registry);
768
- store.transact(() => {
769
- // One person's notification (v83) goes to that person alone, task or not.
770
- const personal = notification.recipient !== null;
771
- if (notification.createdAt >= binding.created &&
772
- notification.resolvedAt === null &&
773
- // A promise the lead made on another chat is reported there (lead-commitments.ts); one its owner asked for on
774
- // this chat is always said here, however quiet the lead's own work is kept.
775
- (promiseChannelOf(notification) ?? state.channel) === state.channel &&
776
- // Pings follow responsibility: the lead's work, this person's own act and a muted project stay in the console.
777
- (promiseChannelOf(notification) === state.channel || store.pingAllowed(notification, binding.approver)) &&
778
- // A flow decision for "anyone who approves" reaches every approver who can see the project.
779
- // A notification addressed to this person reaches them whatever project
780
- // their channel follows (a sign-in pause is the installation's, v108).
781
- (personal
782
- ? notification.recipient === binding.approver && (notification.project === null || repos.includes(notification.project))
783
- : (notification.taskId || notification.kind === "flow-decision") && notification.project !== null && repos.includes(notification.project))) {
784
- const run = store.telegramProgressRun(notification);
785
- const id = chatHash(`${options.state.channel}:notice:${binding.id}:${notification.id}`), now = nowOf(options);
786
- // Only when I'm needed (the default): the task's one card is edited in place, and a new message
787
- // follows only when this person is needed. Every step keeps the branches below unchanged.
788
- const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
789
- if (isShotsKind(notification.kind)) {
790
- planResultShots(options, binding, notification, repos, id, now);
791
- return;
792
- }
793
- if (notification.kind === LEAD_SAY_KIND) {
794
- // The lead's words (lead-voice.ts): one message, repainted in place when a later say joins it.
795
- const content = { text: leadSayText(store, notification, binding.approver),
796
- ...(notification.link ? { link: { label: "Open", path: notification.link } } : {}) };
797
- const earlier = leadSayEarlier(store, notification).map(one => state.prepare("SELECT id FROM chat_part WHERE event=?")
798
- .get(chatHash(`${options.state.channel}:notice:${binding.id}:${one}`))).find(one => one !== undefined);
799
- if (earlier !== undefined) {
800
- state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'").run(savedChatPart(content), now.toISOString(), Number(earlier.id));
801
- return;
802
- }
803
- state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
804
- ts: "", thread: "", payload: {}, created: now.toISOString() });
805
- state.plan(id, [content], now);
871
+ for (const binding of bindings)
872
+ offerNotice(options, notification, binding, registry);
873
+ state
874
+ .prepare("UPDATE chat_runtime SET notification=? WHERE provider=:provider AND installation=?")
875
+ .run(notification.id, identity.installation);
876
+ }
877
+ }
878
+ /** One notification for one paired person: planned as their parts, or nothing. Never twice: each plan's event id is
879
+ * the notification's for this binding. */
880
+ function offerNotice(options, notification, binding, registry) {
881
+ const { state, store, identity } = options;
882
+ const repos = channelRepos(store, binding.approver, registry);
883
+ store.transact(() => {
884
+ // One person's notification (v83) goes to that person alone, task or not.
885
+ const personal = notification.recipient !== null;
886
+ if (
887
+ // From before the pairing, only what still wants a person (the rule every chat app shares, wantedBeforePairing).
888
+ (notification.createdAt >= binding.created || wantedBeforePairing(notification)) &&
889
+ notification.resolvedAt === null &&
890
+ // A promise the lead made on another chat is reported there (lead-commitments.ts); one its owner asked for on
891
+ // this chat is always said here, however quiet the lead's own work is kept.
892
+ (promiseChannelOf(notification) ?? state.provider) === state.provider &&
893
+ // Pings follow responsibility: the lead's work, this person's own act and a muted project stay in the console.
894
+ (promiseChannelOf(notification) === state.provider || store.pingAllowed(notification, binding.approver)) &&
895
+ // A flow decision for "anyone who approves" reaches every approver who can see the project.
896
+ // A notification addressed to this person reaches them whatever project
897
+ // their channel follows (a sign-in pause is the installation's, v108).
898
+ (personal
899
+ ? notification.recipient === binding.approver && (notification.project === null || repos.includes(notification.project))
900
+ : (notification.taskId || notification.kind === "flow-decision") && notification.project !== null && repos.includes(notification.project))) {
901
+ const run = store.telegramProgressRun(notification);
902
+ const id = chatHash(`${options.state.provider}:notice:${binding.id}:${notification.id}`), now = nowOf(options);
903
+ // Only when I'm needed (the default): the task's one card is edited in place, and a new message
904
+ // follows only when this person is needed. Every step keeps the branches below unchanged.
905
+ const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
906
+ if (isShotsKind(notification.kind)) {
907
+ planResultShots(options, binding, notification, repos, id, now);
908
+ return;
909
+ }
910
+ if (notification.kind === LEAD_SAY_KIND) {
911
+ // The lead's words (lead-voice.ts): one message, repainted in place when a later say joins it.
912
+ const content = { text: leadSayText(store, notification, binding.approver),
913
+ ...(notification.link ? { link: { label: "Open", path: notification.link } } : {}) };
914
+ const earlier = leadSayEarlier(store, notification).map(one => state.prepare("SELECT id FROM chat_part WHERE provider=:provider AND event=?")
915
+ .get(chatHash(`${options.state.provider}:notice:${binding.id}:${one}`))).find(one => one !== undefined);
916
+ if (earlier !== undefined) {
917
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE provider=:provider AND id=? AND state!='dropped'").run(savedChatPart(content), now.toISOString(), Number(earlier.id));
918
+ return;
919
+ }
920
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
921
+ ts: "", thread: "", payload: {}, created: now.toISOString() });
922
+ state.plan(id, [content], now);
923
+ return;
924
+ }
925
+ if (quiet) {
926
+ planQuietCard(options, binding, notification, now);
927
+ if (!needsPerson(notification))
928
+ return;
929
+ if (notification.kind === "run-finished" && run) {
930
+ // Finished work goes out only as the batch line; one left out on purpose (a release check, a replaced
931
+ // task, the reader's own completion) stays quiet rather than falling through to another message.
932
+ planFinished(options, binding, notification.taskRef, run.id, now, null, repos);
933
+ return;
934
+ }
935
+ // Every other update for this person within two minutes joins the same one message.
936
+ else if (notification.kind !== "run-finished" && joinsBatch(notification)) {
937
+ if (planFinished(options, binding, notification.taskRef, run?.id ?? null, now, notification.id, repos))
806
938
  return;
939
+ }
940
+ }
941
+ if (!quiet && run && notification.taskId && notification.project !== null && isTelegramProgressNotification(notification)) {
942
+ const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot, binding.approver);
943
+ const content = {
944
+ text: card.text,
945
+ task: notification.taskId,
946
+ run: run.id,
947
+ link: card.link,
948
+ };
949
+ const digest = chatHash(JSON.stringify(content));
950
+ const prior = state
951
+ .prepare("SELECT part,digest FROM chat_progress WHERE provider=:provider AND binding=? AND run=?")
952
+ .get(binding.id, run.id);
953
+ if (prior) {
954
+ if (prior.digest !== digest) {
955
+ state
956
+ .prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE provider=:provider AND id=? AND state!='dropped'")
957
+ .run(JSON.stringify(content), now.toISOString(), Number(prior.part));
958
+ state
959
+ .prepare("UPDATE chat_progress SET digest=? WHERE provider=:provider AND binding=? AND run=?")
960
+ .run(digest, binding.id, run.id);
807
961
  }
808
- if (quiet) {
809
- planQuietCard(options, binding, notification, now);
810
- if (!needsPerson(notification))
811
- return;
812
- if (notification.kind === "run-finished" && run) {
813
- // Finished work goes out only as the batch line; one left out on purpose (a release check, a replaced
814
- // task, the reader's own completion) stays quiet rather than falling through to another message.
815
- planFinished(options, binding, notification.taskRef, run.id, now);
816
- return;
817
- }
818
- // Every other update for this person within two minutes joins the same one message.
819
- else if (notification.kind !== "run-finished" && joinsBatch(notification)) {
820
- if (planFinished(options, binding, notification.taskRef, run?.id ?? null, now, notification.id))
821
- return;
822
- }
823
- }
824
- if (!quiet && run && notification.taskId && notification.project !== null && isTelegramProgressNotification(notification)) {
825
- const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot, binding.approver);
826
- const content = {
827
- text: card.text,
828
- task: notification.taskId,
829
- run: run.id,
830
- link: card.link,
831
- };
832
- const digest = chatHash(JSON.stringify(content));
833
- const prior = state
834
- .prepare("SELECT part,digest FROM chat_progress WHERE binding=? AND run=?")
835
- .get(binding.id, run.id);
836
- if (prior) {
837
- if (prior.digest !== digest) {
838
- state
839
- .prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'")
840
- .run(JSON.stringify(content), now.toISOString(), Number(prior.part));
841
- state
842
- .prepare("UPDATE chat_progress SET digest=? WHERE binding=? AND run=?")
843
- .run(digest, binding.id, run.id);
844
- }
845
- }
846
- else {
847
- state.enqueue({
848
- id,
849
- installation: identity.installation,
850
- binding: binding.id,
851
- kind: "notice",
852
- channel: binding.channel,
853
- member: binding.member,
854
- ts: "",
855
- thread: "",
856
- payload: {},
857
- created: now.toISOString(),
858
- });
859
- state.plan(id, [content], now);
860
- const part = state
861
- .prepare("SELECT id FROM chat_part WHERE event=?")
862
- .get(id);
863
- state
864
- .prepare("INSERT INTO chat_progress VALUES(?,?,?,?)")
865
- .run(binding.id, run.id, Number(part.id), digest);
866
- }
867
- }
868
- else if (notification.kind === "flow-decision") {
869
- // The draft as written, and Approve / Edit / Send back on its last part; a card that moved on is not news.
870
- const parts = flowDecisionParts(store, notification, state.channel);
871
- if (parts !== null) {
872
- state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
873
- ts: "", thread: "", payload: {}, created: now.toISOString() });
874
- state.plan(id, parts, now);
875
- }
876
- }
877
- else if (personal && flowSendParts(store, notification, state.channel) !== null) {
878
- // A flow's "Send to me" (flow-send.ts): what was done, with its links as buttons.
879
- state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
880
- ts: "", thread: "", payload: {}, created: now.toISOString() });
881
- state.plan(id, flowSendParts(store, notification, state.channel), now);
882
- }
883
- else if (notification.kind === "flow-card" && questionParts(store, notification, binding) !== null) {
884
- // A teammate's question (v93): its options and "Answer in words" on the notice, for the person it asks.
885
- state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
886
- ts: "", thread: "", payload: {}, created: now.toISOString() });
887
- state.plan(id, questionParts(store, notification, binding), now);
888
- }
889
- else if (notification.pushClass !== null || personal || quiet) {
890
- state.enqueue({
891
- id,
892
- installation: identity.installation,
893
- binding: binding.id,
894
- kind: "notice",
895
- channel: binding.channel,
896
- member: binding.member,
897
- ts: "",
898
- thread: "",
899
- payload: {},
900
- created: now.toISOString(),
901
- });
902
- state.plan(id, [
903
- {
904
- // Never the task's id or a "— revision" suffix (chat-voice.ts).
905
- text: chatText(phoneText(notification.body === "" ? leadSubjectOf(store, notification, binding.approver) : `${leadSubjectOf(store, notification, binding.approver)}\n\n${notification.body}`, 2500), notification.taskId === null ? [] : [{ id: notification.taskId, title: chatTitle(store, notification.taskId) }]),
906
- ...(notification.taskId ? { task: notification.taskId } : {}),
907
- ...(run ? { run: run.id } : {}),
908
- ...(notification.link
962
+ }
963
+ else {
964
+ state.enqueue({
965
+ id,
966
+ installation: identity.installation,
967
+ binding: binding.id,
968
+ kind: "notice",
969
+ channel: binding.channel,
970
+ member: binding.member,
971
+ ts: "",
972
+ thread: "",
973
+ payload: {},
974
+ created: now.toISOString(),
975
+ });
976
+ state.plan(id, [content], now);
977
+ const part = state
978
+ .prepare("SELECT id FROM chat_part WHERE provider=:provider AND event=?")
979
+ .get(id);
980
+ state
981
+ .prepare("INSERT INTO chat_progress (provider,binding,run,part,digest) VALUES(:provider,?,?,?,?)")
982
+ .run(binding.id, run.id, Number(part.id), digest);
983
+ }
984
+ }
985
+ else if (notification.kind === "flow-decision") {
986
+ // The draft as written, and Approve / Edit / Send back on its last part; a card that moved on is not news.
987
+ const parts = flowDecisionParts(store, notification, state.provider);
988
+ if (parts !== null) {
989
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
990
+ ts: "", thread: "", payload: {}, created: now.toISOString() });
991
+ state.plan(id, parts, now);
992
+ }
993
+ }
994
+ else if (personal && flowSendParts(store, notification, state.provider) !== null) {
995
+ // A flow's "Send to me" (flow-send.ts): what was done, with its links as buttons.
996
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
997
+ ts: "", thread: "", payload: {}, created: now.toISOString() });
998
+ state.plan(id, flowSendParts(store, notification, state.provider), now);
999
+ }
1000
+ else if (notification.kind === "flow-card" && questionParts(store, notification, binding) !== null) {
1001
+ // A teammate's question (v93): its options and "Answer in words" on the notice, for the person it asks.
1002
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
1003
+ ts: "", thread: "", payload: {}, created: now.toISOString() });
1004
+ state.plan(id, questionParts(store, notification, binding), now);
1005
+ }
1006
+ else if (notification.pushClass !== null || personal || quiet) {
1007
+ state.enqueue({
1008
+ id,
1009
+ installation: identity.installation,
1010
+ binding: binding.id,
1011
+ kind: "notice",
1012
+ channel: binding.channel,
1013
+ member: binding.member,
1014
+ ts: "",
1015
+ thread: "",
1016
+ payload: {},
1017
+ created: now.toISOString(),
1018
+ });
1019
+ // A result, plan, failure or ready pull request acts in place (chat-decide.ts); otherwise its one link (a plan's
1020
+ // is Review & start, where the password step lives).
1021
+ const target = decideTargetOf(notification);
1022
+ const offer = decideOfferFor(options, binding, target, repos, now);
1023
+ const fallback = offer === null ? decideFallbackLink(target) : null;
1024
+ state.plan(id, [
1025
+ {
1026
+ // Never the task's id or a "— revision" suffix (chat-voice.ts).
1027
+ text: offer?.text ?? chatText(phoneText(notification.body === "" ? leadSubjectOf(store, notification, binding.approver) : `${leadSubjectOf(store, notification, binding.approver)}\n\n${notification.body}`, 2500), notification.taskId === null ? [] : [{ id: notification.taskId, title: chatTitle(store, notification.taskId) }]),
1028
+ ...(notification.taskId ? { task: notification.taskId } : {}),
1029
+ ...(run ? { run: run.id } : {}),
1030
+ ...(offer !== null ? { decide: mintDecide(options, binding, target, offer, now) }
1031
+ : fallback !== null ? { link: fallback }
1032
+ : notification.link
909
1033
  ? { link: { label: notification.kind === "pull-request-ready" ? "Merge" : personal ? "Open" : "Review", path: notification.link } }
910
1034
  : {}),
911
- },
912
- ], now);
913
- }
914
- }
915
- });
1035
+ },
1036
+ ], now);
1037
+ }
916
1038
  }
917
- state
918
- .prepare("UPDATE chat_runtime SET notification=? WHERE installation=?")
919
- .run(notification.id, identity.installation);
920
- }
1039
+ });
921
1040
  }