toolroll 0.9.20 → 0.9.22

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 (57) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/browser/workspace.css +1 -1
  3. package/dist/browser/workspace.js +22 -21
  4. package/dist/browser-workspace.d.ts +41 -1
  5. package/dist/chat-actions.d.ts +5 -0
  6. package/dist/chat-actions.js +57 -6
  7. package/dist/chat-delivery-state.d.ts +15 -0
  8. package/dist/chat-delivery-state.js +27 -2
  9. package/dist/chat-delivery.js +24 -4
  10. package/dist/chat-flow.d.ts +26 -3
  11. package/dist/chat-flow.js +217 -5
  12. package/dist/desktop-update.d.ts +1 -1
  13. package/dist/discord-chat.js +2 -2
  14. package/dist/evidence.d.ts +13 -1
  15. package/dist/evidence.js +10 -1
  16. package/dist/flow-engine.d.ts +7 -0
  17. package/dist/flow-engine.js +71 -27
  18. package/dist/flow-gallery-ui.d.ts +1 -1
  19. package/dist/flow-gallery-ui.js +8 -7
  20. package/dist/flow-gallery.d.ts +8 -0
  21. package/dist/flow-gallery.js +37 -3
  22. package/dist/flow-send.d.ts +80 -0
  23. package/dist/flow-send.js +249 -0
  24. package/dist/flow-share.js +7 -3
  25. package/dist/flow-steps.js +1 -1
  26. package/dist/flows-cli.js +11 -3
  27. package/dist/flows-ui.js +20 -4
  28. package/dist/flows.d.ts +27 -1
  29. package/dist/flows.js +104 -18
  30. package/dist/mate-tools.js +33 -10
  31. package/dist/operate.js +41 -0
  32. package/dist/project-delete.js +4 -0
  33. package/dist/project-demo.d.ts +12 -0
  34. package/dist/project-demo.js +71 -0
  35. package/dist/provider.d.ts +6 -0
  36. package/dist/provider.js +4 -1
  37. package/dist/report-summary.d.ts +9 -0
  38. package/dist/report-summary.js +9 -0
  39. package/dist/result-shots.d.ts +2 -0
  40. package/dist/result-shots.js +24 -5
  41. package/dist/scout-browser.d.ts +48 -0
  42. package/dist/scout-browser.js +228 -0
  43. package/dist/scout-net.d.ts +22 -0
  44. package/dist/scout-net.js +214 -0
  45. package/dist/scout-report.d.ts +67 -0
  46. package/dist/scout-report.js +108 -1
  47. package/dist/scout.d.ts +48 -0
  48. package/dist/scout.js +264 -17
  49. package/dist/serve.js +43 -5
  50. package/dist/slack-chat.js +3 -3
  51. package/dist/store.d.ts +53 -0
  52. package/dist/store.js +95 -4
  53. package/dist/teams-chat.js +1 -1
  54. package/dist/telegram-flow.d.ts +16 -2
  55. package/dist/telegram-flow.js +58 -0
  56. package/dist/telegram.js +46 -10
  57. package/package.json +1 -1
package/dist/store.js CHANGED
@@ -310,6 +310,35 @@ CREATE INDEX IF NOT EXISTS mate_ask_thread ON mate_ask (thread, turn);
310
310
  `;
311
311
  /** Decisions in the chat app (chat-decide.ts; no version bump: additive only). A token names one act on one card (the chat and message it rides); `digest` is
312
312
  * what the card showed — a receipt, a revision source, a task stamp, a plan or a commit. */
313
+ /** A flow's "Send to me" and "Person chooses" zones (no version bump: additive only). flow_send keeps what one visit sent its
314
+ * person (shown on the card, and read again by every chat app that delivers it); telegram_flow_choice is one opaque button
315
+ * per option for one visit of one card, placed on the message it rides. A reply to that message is the note
316
+ * (telegram_flow_prompt, mode send-back). The chat apps keep their buttons beside their messages (chat-delivery-state.ts). */
317
+ export const FLOW_SEND_SCHEMA = `
318
+ CREATE TABLE IF NOT EXISTS flow_send (
319
+ card INTEGER NOT NULL REFERENCES flow_card(id),
320
+ entry INTEGER NOT NULL,
321
+ stage TEXT NOT NULL,
322
+ person TEXT NOT NULL,
323
+ content_json TEXT NOT NULL,
324
+ created_at TEXT NOT NULL,
325
+ PRIMARY KEY (card, entry)
326
+ );
327
+ CREATE TABLE IF NOT EXISTS telegram_flow_choice (
328
+ token TEXT PRIMARY KEY,
329
+ binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
330
+ card INTEGER NOT NULL REFERENCES flow_card(id),
331
+ entry INTEGER NOT NULL,
332
+ choice INTEGER NOT NULL,
333
+ label TEXT NOT NULL,
334
+ chat_id TEXT NOT NULL,
335
+ message_id TEXT,
336
+ created_at TEXT NOT NULL,
337
+ expires_at TEXT NOT NULL,
338
+ consumed_at TEXT
339
+ );
340
+ CREATE INDEX IF NOT EXISTS telegram_flow_choice_visit ON telegram_flow_choice (card, entry);
341
+ `;
313
342
  export const CHAT_DECIDE_SCHEMA = `
314
343
  CREATE TABLE IF NOT EXISTS chat_decide_action (
315
344
  token TEXT PRIMARY KEY,
@@ -821,6 +850,9 @@ const LIFECYCLE_HOLD_WORDS = {
821
850
  export const RESULT_SCREENSHOTS = ["off", "first", "all"];
822
851
  /** One person's saved screenshots following a result message: its own outbox row, addressed to them (result-shots.ts). */
823
852
  export const RESULT_SHOTS_KIND = "result-screenshots";
853
+ /** The screenshots a flow's "Send to me" or "Person chooses" visit sends its person (flow-send.ts): up to four, whatever
854
+ * their result-screenshots choice, since the flow asked for them. */
855
+ export const FLOW_SHOTS_KIND = "flow-screenshots";
824
856
  /** Tasks filed this close together share one chat message. */
825
857
  export const QUIET_GROUP_MS = 60_000;
826
858
  export function isDigestTime(value) {
@@ -3938,6 +3970,8 @@ function initializeStore(db, file) {
3938
3970
  db.exec(MATE_ASK_SCHEMA);
3939
3971
  // Decisions in the chat app (no version bump: additive only): result, plan and merge buttons, and Request changes.
3940
3972
  db.exec(CHAT_DECIDE_SCHEMA);
3973
+ // A flow's "Send to me" and "Person chooses" zones (no version bump: additive only).
3974
+ db.exec(FLOW_SEND_SCHEMA);
3941
3975
  db.exec(SPEND_SCHEMA);
3942
3976
  // Sign-in pauses: one row per incident of a provider's sign-in no longer working.
3943
3977
  db.exec(PROVIDER_AUTH_SCHEMA);
@@ -18328,6 +18362,56 @@ export class Store {
18328
18362
  return null;
18329
18363
  return { chatId: String(row["chat_id"]), messageId: String(row["message_id"]), binding: Number(row["binding"]), card: Number(row["card"]), entry: Number(row["entry"]), mode: String(row["mode"]) };
18330
18364
  }
18365
+ // ---- "Send to me" and "Person chooses" (flow-send.ts) ------------------------------------------
18366
+ /** What one visit sent: recorded once, the first time (a later pass finds it kept). */
18367
+ recordFlowSend(row, now) {
18368
+ return Number(this.db.prepare("INSERT OR IGNORE INTO flow_send (card, entry, stage, person, content_json, created_at) VALUES (?, ?, ?, ?, ?, ?)")
18369
+ .run(row.card, row.entry, row.stage, row.person, row.contentJson, now.toISOString()).changes) === 1;
18370
+ }
18371
+ flowSend(card, entry) {
18372
+ const row = this.db.prepare("SELECT * FROM flow_send WHERE card = ? AND entry = ?").get(card, entry);
18373
+ return row === undefined ? null : { card: Number(row["card"]), entry: Number(row["entry"]), stage: String(row["stage"]), person: String(row["person"]), contentJson: String(row["content_json"]), createdAt: String(row["created_at"]) };
18374
+ }
18375
+ /** Every visit's send for a card, newest first: what the card shows it sent. */
18376
+ flowSends(card) {
18377
+ return this.db.prepare("SELECT * FROM flow_send WHERE card = ? ORDER BY entry DESC").all(card).map(row => ({ card: Number(row["card"]), entry: Number(row["entry"]), stage: String(row["stage"]),
18378
+ person: String(row["person"]), contentJson: String(row["content_json"]), createdAt: String(row["created_at"]) }));
18379
+ }
18380
+ createTelegramFlowChoices(at, choices, now, days = 7) {
18381
+ const stamp = now.toISOString(), expires = new Date(now.getTime() + days * 86_400_000).toISOString();
18382
+ const insert = this.db.prepare("INSERT INTO telegram_flow_choice (token, binding, card, entry, choice, label, chat_id, message_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)");
18383
+ for (const one of choices)
18384
+ insert.run(one.token, at.binding, at.card, at.entry, one.choice, one.label, at.chatId, stamp, expires);
18385
+ }
18386
+ getTelegramFlowChoice(token) {
18387
+ if (!/^[0-9a-f]{32}$/.test(token))
18388
+ return null;
18389
+ const row = this.db.prepare("SELECT * FROM telegram_flow_choice WHERE token = ?").get(token);
18390
+ if (row === undefined)
18391
+ return null;
18392
+ return { token: String(row["token"]), binding: Number(row["binding"]), card: Number(row["card"]), entry: Number(row["entry"]), choice: Number(row["choice"]), label: String(row["label"]),
18393
+ chatId: String(row["chat_id"]), messageId: row["message_id"] === null ? null : String(row["message_id"]), expiresAt: String(row["expires_at"]), consumedAt: row["consumed_at"] === null ? null : String(row["consumed_at"]) };
18394
+ }
18395
+ placeTelegramFlowChoices(tokens, messageId) {
18396
+ const place = this.db.prepare("UPDATE telegram_flow_choice SET message_id = ? WHERE token = ?");
18397
+ for (const token of tokens)
18398
+ place.run(messageId, token);
18399
+ }
18400
+ /** Retire every choice button for one visit of a card (and its reply prompt): it was chosen, or the card moved on. */
18401
+ /** A choice was made (or the card moved on): every option button, reply prompt and "Use this as your note?" for that
18402
+ * visit is spent, in Telegram and in every other chat app, whichever place it was made in. */
18403
+ retireFlowChoices(card, entry, now) {
18404
+ const stamp = now.toISOString();
18405
+ this.db.prepare("UPDATE telegram_flow_choice SET consumed_at = ? WHERE card = ? AND entry = ? AND consumed_at IS NULL").run(stamp, card, entry);
18406
+ this.retireTelegramFlowVisit(card, entry, now);
18407
+ // A chat app's flow tables exist only once that app's chat state created them; an install without it has none.
18408
+ const names = ["slack", "discord", "teams"].flatMap(app => ["flow_choice", "flow_note", "flow_prompt", "flow_action"].map(table => `${app}_${table}`));
18409
+ const present = new Set(this.db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${names.map(() => "?").join(", ")})`).all(...names).map(row => String(row["name"])));
18410
+ for (const app of ["slack", "discord", "teams"])
18411
+ for (const table of ["flow_choice", "flow_note", "flow_prompt", "flow_action"])
18412
+ if (present.has(`${app}_${table}`))
18413
+ this.db.prepare(`UPDATE ${app}_${table} SET consumed = ?${table === "flow_note" ? ", words = NULL" : ""} WHERE card = ? AND entry = ? AND consumed IS NULL`).run(stamp, card, entry);
18414
+ }
18331
18415
  // ---- v93: a teammate's question on Telegram ------------------------------------------
18332
18416
  createTelegramQuestionActions(at, choices, now, days = 7) {
18333
18417
  const stamp = now.toISOString(), expires = new Date(now.getTime() + days * 86_400_000).toISOString();
@@ -21730,6 +21814,10 @@ export class Store {
21730
21814
  recordProjectConcurrency(by, repo, before, after, now) {
21731
21815
  this.recordPolicy(by, repo, "builds at once", String(before), String(after), now);
21732
21816
  }
21817
+ /** Review 827: a project's demo URL changed (`off` when it has none). */
21818
+ recordProjectDemo(by, repo, before, after, now) {
21819
+ this.recordPolicy(by, repo, "demo URL", before ?? "off", after ?? "off", now);
21820
+ }
21733
21821
  /** Forward only, and only under the live generation. A stale poller moves nothing. */
21734
21822
  /** v98: an update Telegram pushed, kept until the bridge applies it; false when it's already kept or applied. */
21735
21823
  queueTelegramUpdate(botId, updateId, payload, now) {
@@ -22335,11 +22423,14 @@ export class Store {
22335
22423
  enqueueResultShots(source, now) {
22336
22424
  if (source.run === null || source.taskRef === null || source.project === null || source.recipient !== null)
22337
22425
  return;
22338
- const ready = isLifecycleNotification(source) && source.kind === "run-finished";
22339
- if (!ready && !/fail/.test(source.kind))
22340
- return;
22341
22426
  const run = this.getRun(source.run);
22342
- if (run === null || !["builder", "repair"].includes(run.role) || run.contestant !== null)
22427
+ if (run === null || run.contestant !== null)
22428
+ return;
22429
+ // One path per role (review 826): a scout's report is its result, so only its "report ready" carries the
22430
+ // screenshots it saved; a build's are carried by its finished or failed message.
22431
+ const carries = run.role === "scout" ? source.kind === "report-ready"
22432
+ : ["builder", "repair"].includes(run.role) && ((isLifecycleNotification(source) && source.kind === "run-finished") || /fail/.test(source.kind));
22433
+ if (!carries)
22343
22434
  return;
22344
22435
  if (!this.artifactsFor(run.id).some(one => one.kind === "screenshot"))
22345
22436
  return;
@@ -207,7 +207,7 @@ export async function deliverTeamsPart(options) {
207
207
  }
208
208
  else if (!content.image) {
209
209
  // A flow decision (v88): Approve / Edit / Send back, then the link.
210
- const flow = content.flow ? chatFlowButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label, data: { so: one.token }, ...(one.action === "approve" ? { style: "positive" } : {}) })) : [];
210
+ const flow = content.flow || content.choose || content.note ? chatFlowButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label, data: { so: one.token }, ...(one.action === "approve" ? { style: "positive" } : {}) })) : [];
211
211
  // A teammate's question (v93): its options, then "Answer in words".
212
212
  const asked = content.question ? chatQuestionButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
213
213
  // The lead's question to its owner: its options, then "Something else".
@@ -1,6 +1,7 @@
1
+ import { type FlowChoiceVisit, type FlowSendContent } from "./flow-send.js";
1
2
  import { type FlowStage } from "./flows.js";
2
- import type { InlineButton } from "./telegram-mate.js";
3
- import type { FlowCardRow, FlowRow, Store, TelegramBinding, TelegramFlowAction, TelegramFlowPrompt } from "./store.js";
3
+ import { type InlineButton } from "./telegram-mate.js";
4
+ import type { FlowCardRow, FlowRow, Store, TelegramBinding, TelegramFlowAction, TelegramFlowChoice, TelegramFlowPrompt } from "./store.js";
4
5
  export declare const FLOW_DECIDE_KEY: RegExp;
5
6
  type Waiting = {
6
7
  card: FlowCardRow;
@@ -15,6 +16,19 @@ export declare function flowButtons(store: Store, binding: TelegramBinding, wait
15
16
  keyboard: InlineButton[][];
16
17
  tokens: string[];
17
18
  };
19
+ /** What a "Send to me" or "Person chooses" visit sent, read back for its message. */
20
+ export declare function flowSentContent(store: Store, card: number, entry: number): FlowSendContent | null;
21
+ /** A sent visit's links as one row of buttons: Toolroll's pages under the trusted origin, and its pull request. */
22
+ export declare function flowSendKeyboardRow(origin: string | null, content: FlowSendContent): InlineButton[];
23
+ /** A choice's buttons for one visit, one option to a row, minted before the send; `place` stamps the message they landed on. */
24
+ export declare function flowChoiceButtons(store: Store, binding: TelegramBinding, visit: FlowChoiceVisit, content: FlowSendContent, now: Date): {
25
+ keyboard: InlineButton[][];
26
+ tokens: string[];
27
+ };
28
+ /** A tapped option, applied inside the update's transaction. */
29
+ export declare function applyFlowChoiceTap(store: Store, binding: TelegramBinding, choice: TelegramFlowChoice, message: {
30
+ text: string;
31
+ }, repos: readonly string[] | null, now: Date): FlowTapEffect[];
18
32
  export type FlowTapEffect = {
19
33
  kind: "ack";
20
34
  text: string;
@@ -14,11 +14,17 @@
14
14
  * Every button is one opaque token for one visit of one card, placed on the
15
15
  * message it rides. A card that moved on answers so; a tap from anyone but
16
16
  * the paired person and chat never reaches here (telegram.ts checks it).
17
+ *
18
+ * A "Person chooses" visit (flow-send.ts) has one button per option the flow
19
+ * names; a reply to that message instead of a tap is the note for where
20
+ * replies go. Both go through chooseFlowCard, for exactly that visit.
17
21
  */
18
22
  import { randomBytes } from "node:crypto";
19
23
  import { keptDraft } from "./flow-draft.js";
20
24
  import { decideFlowCard, draftFor, flowDefinitionOf } from "./flow-engine.js";
25
+ import { chooseFlowCard, flowChoiceAt, flowSendPaths, readFlowSend } from "./flow-send.js";
21
26
  import { deciderOf } from "./flows.js";
27
+ import { phoneLinkButton } from "./telegram-mate.js";
22
28
  export const FLOW_DECIDE_KEY = /^flow-decide:([1-9][0-9]{0,14}):([1-9][0-9]{0,9})$/;
23
29
  const DRAFT_LIMIT = 4000;
24
30
  /** The card, if it is still at that visit, waiting at a decision. */
@@ -45,6 +51,43 @@ export function flowButtons(store, binding, waiting, now) {
45
51
  .filter((one) => one !== null);
46
52
  return { keyboard: [[{ text: "✅ Approve", callback_data: approve.token }], ...(second.length === 0 ? [] : [second])], tokens: all.map(one => one.token) };
47
53
  }
54
+ /** What a "Send to me" or "Person chooses" visit sent, read back for its message. */
55
+ export function flowSentContent(store, card, entry) {
56
+ const kept = store.flowSend(card, entry);
57
+ return kept === null ? null : readFlowSend(kept.contentJson);
58
+ }
59
+ /** A sent visit's links as one row of buttons: Toolroll's pages under the trusted origin, and its pull request. */
60
+ export function flowSendKeyboardRow(origin, content) {
61
+ const pages = origin === null ? [] : flowSendPaths(content).flatMap(one => phoneLinkButton(origin, one) ?? []);
62
+ const pull = content.links.flatMap(one => "url" in one ? [{ text: one.label, url: one.url }] : []);
63
+ return [...pages.slice(0, 1), ...pull, ...pages.slice(1)].slice(0, 3);
64
+ }
65
+ /** A choice's buttons for one visit, one option to a row, minted before the send; `place` stamps the message they landed on. */
66
+ export function flowChoiceButtons(store, binding, visit, content, now) {
67
+ // Only the options the zone still offers as they were sent: a flow changed since sends no stale button.
68
+ const options = (content.options ?? []).filter(one => visit.stage.options?.[one.choice]?.label === one.label).map(one => ({ ...one, token: randomBytes(16).toString("hex") }));
69
+ store.createTelegramFlowChoices({ binding: binding.id, chatId: binding.chatId, card: visit.card.id, entry: visit.card.entry }, options, now);
70
+ return { keyboard: options.map(one => [{ text: one.label, callback_data: one.token }]), tokens: options.map(one => one.token) };
71
+ }
72
+ /** A tapped option, applied inside the update's transaction. */
73
+ export function applyFlowChoiceTap(store, binding, choice, message, repos, now) {
74
+ if (choice.consumedAt !== null || choice.expiresAt <= now.toISOString())
75
+ return [{ kind: "ack", text: "That was already chosen, or these buttons are too old." }];
76
+ const visit = flowChoiceAt(store, choice.card, choice.entry);
77
+ if (visit === null) {
78
+ store.retireFlowChoices(choice.card, choice.entry, now);
79
+ return [{ kind: "ack", text: "That card has moved on since; nothing was changed." }, { kind: "edit", text: `${message.text}\n\nThis card has moved on since; nothing was changed.`.slice(0, 4000) }];
80
+ }
81
+ if (visit.person !== binding.approver)
82
+ return [{ kind: "ack", text: `Only ${visit.person} chooses here.` }];
83
+ if (repos === null)
84
+ return [{ kind: "ack", text: "Couldn't check your projects just now. Try again in a moment." }];
85
+ const chosen = chooseFlowCard(store, { card: choice.card, entry: choice.entry, choice: choice.choice, label: choice.label, note: null, actor: binding.approver, where: "Telegram", repos }, now);
86
+ if (!chosen.ok)
87
+ return [{ kind: "ack", text: chosen.message.slice(0, 190) }];
88
+ store.retireFlowChoices(choice.card, choice.entry, now);
89
+ return [{ kind: "ack", text: choice.label.slice(0, 190) }, { kind: "edit", text: `${message.text}\n\n✅ You chose “${choice.label}”. ${chosen.said}`.slice(0, 4000) }];
90
+ }
48
91
  /** A tapped flow button, applied inside the update's transaction. */
49
92
  export function applyFlowTap(store, binding, action, message, repos, now) {
50
93
  if (action.consumedAt !== null || action.expiresAt <= now.toISOString())
@@ -78,6 +121,21 @@ const titleOf = (waiting, id) => flowDefinitionOf(waiting.flow)?.stages.find(one
78
121
  export function applyFlowReply(store, binding, prompt, text, repos, now) {
79
122
  if (prompt.binding !== binding.id)
80
123
  return [];
124
+ // A reply to a choice's message (flow-send.ts): the note for where replies go.
125
+ const choosing = flowChoiceAt(store, prompt.card, prompt.entry);
126
+ if (choosing !== null) {
127
+ if (choosing.person !== binding.approver)
128
+ return [{ kind: "say", text: `Only ${choosing.person} chooses here.` }];
129
+ if (text.trim() === "")
130
+ return [{ kind: "say", text: "Say what you'd change." }];
131
+ if (repos === null)
132
+ return [{ kind: "say", text: "Couldn't check your projects just now. Reply again in a moment." }];
133
+ const chosen = chooseFlowCard(store, { card: prompt.card, entry: prompt.entry, choice: null, note: text.trim(), actor: binding.approver, where: "Telegram", repos }, now);
134
+ if (!chosen.ok)
135
+ return [{ kind: "say", text: chosen.message }];
136
+ store.retireFlowChoices(prompt.card, prompt.entry, now);
137
+ return [{ kind: "say", text: `↩️ ${chosen.said}` }];
138
+ }
81
139
  const waiting = flowDecisionAt(store, prompt.card, prompt.entry);
82
140
  if (waiting === null) {
83
141
  store.retireTelegramFlowVisit(prompt.card, prompt.entry, now);
package/dist/telegram.js CHANGED
@@ -27,14 +27,15 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
27
27
  import { chmodSync, readFileSync, rmSync, writeFileSync } from "node:fs";
28
28
  import { join } from "node:path";
29
29
  import { validateNote } from "./decision.js";
30
- import { resultShotsFor } from "./result-shots.js";
30
+ import { isShotsKind, resultShotsFor } from "./result-shots.js";
31
31
  import { isLifecycleNotification, isTelegramProgressNotification, RESULT_SHOTS_KIND, TELEGRAM_HOLD_REASONS } from "./store.js";
32
32
  import { telegramProgressCard } from "./telegram-progress.js";
33
33
  import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
34
34
  import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
35
35
  import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText, leadSubjectOf } from "./lead-voice.js";
36
36
  import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
37
- import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
37
+ import { applyFlowChoiceTap, applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowChoiceButtons, flowDecisionAt, flowSendKeyboardRow, flowSentContent } from "./telegram-flow.js";
38
+ import { flowChoiceAt, FLOW_CHOOSE_KEY, FLOW_SEND_KEY } from "./flow-send.js";
38
39
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
39
40
  import { focusContextFor, taskInCeiling } from "./chat-channel.js";
40
41
  import { answersPrompt, applyDecideFeedback, applyDecideTap, decideFallbackLink, dropDecideTokens, hasLiveDecideTokens, linksFor, placeDecidePrompt, decideOffer, decideTargetOf, isDecideToken, mergedText, mintDecideButtons, offerFingerprint, openPromptFor, placeDecideTokens, recordChatMerge, retireDecideTokens } from "./chat-decide.js";
@@ -658,7 +659,7 @@ async function deliverOutbox(store, botId, transport, owner, clock, report, read
658
659
  // Routine progress facts are not a problem to fix: a first pairing
659
660
  // starts from now and settles them as history. Anything else pending
660
661
  // is named, once per pass, as before.
661
- if (store.listNotifications("pending").some(row => !isLifecycleNotification(row) && row.kind !== RESULT_SHOTS_KIND))
662
+ if (store.listNotifications("pending").some(row => !isLifecycleNotification(row) && !isShotsKind(row.kind)))
662
663
  report.problems.push("outbox rows are pending but no chat is paired — `toolroll bridge telegram pair`");
663
664
  return;
664
665
  }
@@ -1058,7 +1059,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
1058
1059
  return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
1059
1060
  };
1060
1061
  let outcome;
1061
- if (rows.length === 1 && row.kind === RESULT_SHOTS_KIND)
1062
+ if (rows.length === 1 && isShotsKind(row.kind))
1062
1063
  outcome = await shotsSender(row);
1063
1064
  else if (rows.length === 1 && row.kind === LEAD_SAY_KIND)
1064
1065
  outcome = await leadSayOutcome(row);
@@ -1107,7 +1108,7 @@ function isUrgent(notification) {
1107
1108
  /** A routine fact the digest may carry. Screenshots are never urgent (they follow their result message, which the
1108
1109
  * task's order fence holds them behind) and never a digest line: they go as their own photos once it has gone. */
1109
1110
  function joinsDigest(notification) {
1110
- return !isUrgent(notification) && notification.kind !== RESULT_SHOTS_KIND;
1111
+ return !isUrgent(notification) && !isShotsKind(notification.kind);
1111
1112
  }
1112
1113
  /** The digest text: a header with the count and the window, then one
1113
1114
  * fact per entry — its subject, then its body's first line, indented.
@@ -1193,7 +1194,7 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
1193
1194
  const decided = offer === null ? null : mintDecideButtons(store, decideSeat(binding), target, offer, clock());
1194
1195
  const parts = split(offer?.text ?? `${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
1195
1196
  const fallback = offer === null ? decideFallbackLink(target) : null;
1196
- const button = fallback !== null ? (() => { try {
1197
+ let button = fallback !== null ? (() => { try {
1197
1198
  return phoneLinkButton(phoneOrigin?.() ?? null, fallback);
1198
1199
  }
1199
1200
  catch {
@@ -1204,10 +1205,23 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
1204
1205
  const visit = FLOW_DECIDE_KEY.exec(notification.dedupeKey);
1205
1206
  const waiting = visit === null ? null : flowDecisionAt(store, Number(visit[1]), Number(visit[2]));
1206
1207
  const flowKeys = waiting === null ? null : flowButtons(store, binding, waiting, clock());
1208
+ // A flow's "Send to me" or "Person chooses" (flow-send.ts): its links as buttons, and a choice's options above them.
1209
+ const sentVisit = FLOW_SEND_KEY.exec(notification.dedupeKey) ?? FLOW_CHOOSE_KEY.exec(notification.dedupeKey);
1210
+ const sent = sentVisit === null ? null : flowSentContent(store, Number(sentVisit[1]), Number(sentVisit[2]));
1211
+ const choosing = sent === null || !FLOW_CHOOSE_KEY.test(notification.dedupeKey) || notification.recipient !== binding.approver ? null : flowChoiceAt(store, Number(sentVisit[1]), Number(sentVisit[2]));
1212
+ const choiceKeys = choosing === null || sent === null ? null : flowChoiceButtons(store, binding, choosing, sent, clock());
1213
+ const sentRow = sent === null ? null : (() => { try {
1214
+ return flowSendKeyboardRow(phoneOrigin?.() ?? null, sent);
1215
+ }
1216
+ catch {
1217
+ return [];
1218
+ } })();
1219
+ if (sentRow !== null)
1220
+ button = sentRow.length === 0 ? null : sentRow;
1207
1221
  // A teammate's question (v93): its options and "Answer in words", for the person it asks.
1208
- const asked = flowKeys === null ? openQuestionOf(store, notification.dedupeKey) : null;
1222
+ const asked = flowKeys === null && choiceKeys === null ? openQuestionOf(store, notification.dedupeKey) : null;
1209
1223
  const questionKeys = asked === null ? null : telegramQuestionButtons(store, binding, asked, clock());
1210
- const keys = flowKeys ?? questionKeys;
1224
+ const keys = flowKeys ?? choiceKeys ?? questionKeys;
1211
1225
  let last = null;
1212
1226
  for (const [index, part] of parts.entries()) {
1213
1227
  const final = index === parts.length - 1;
@@ -1223,6 +1237,12 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
1223
1237
  }
1224
1238
  if (flowKeys !== null && last !== null)
1225
1239
  store.placeTelegramFlowActions(flowKeys.tokens, last);
1240
+ if (choiceKeys !== null && last !== null) {
1241
+ store.placeTelegramFlowChoices(choiceKeys.tokens, last);
1242
+ // A reply to this message, instead of a tap, is the person's note (applyFlowReply).
1243
+ if (sent?.reply === true)
1244
+ store.recordTelegramFlowPrompt({ chatId: binding.chatId, messageId: last, binding: binding.id, card: choosing.card.id, entry: choosing.card.entry, mode: "send-back" }, clock(), 7 * 24);
1245
+ }
1226
1246
  if (questionKeys !== null && last !== null)
1227
1247
  store.placeTelegramQuestionActions(questionKeys.tokens, last);
1228
1248
  if (decided !== null && last !== null)
@@ -1472,7 +1492,7 @@ async function enrolledForMessage(context, update) {
1472
1492
  async function projectsForTap(context, update) {
1473
1493
  const callback = update.callback_query;
1474
1494
  // Flow decision buttons (v86) work with or without the lead's conversation on this phone.
1475
- const flowTap = callback !== undefined && context.store.getTelegramFlowAction(callback.data ?? "") !== null;
1495
+ const flowTap = callback !== undefined && (context.store.getTelegramFlowAction(callback.data ?? "") !== null || context.store.getTelegramFlowChoice(callback.data ?? "") !== null);
1476
1496
  // So do a result's, plan's or pull request's own buttons (chat-decide.ts).
1477
1497
  const decideTap = callback !== undefined && isDecideToken(context.store, callback.data ?? "");
1478
1498
  if (callback === undefined || context.readProjects === undefined || (context.conversation === undefined && !flowTap && !decideTap))
@@ -1481,7 +1501,7 @@ async function projectsForTap(context, update) {
1481
1501
  if (binding === null || callback.from === undefined ||
1482
1502
  callback.message?.chat === undefined ||
1483
1503
  // Proposal cards and task picks read the chat's project ceiling.
1484
- (context.store.getTelegramProposalAction(callback.data ?? "") === null && context.store.getTelegramFlowAction(callback.data ?? "") === null && !decideTap && !(callback.data ?? "").startsWith("pick:")))
1504
+ (context.store.getTelegramProposalAction(callback.data ?? "") === null && !flowTap && !decideTap && !(callback.data ?? "").startsWith("pick:")))
1485
1505
  return null;
1486
1506
  const chat = callback.message.chat;
1487
1507
  const chatId = String(chat.id);
@@ -2138,6 +2158,22 @@ function applyCallback(context, update, effects) {
2138
2158
  }
2139
2159
  return;
2140
2160
  }
2161
+ // A "Person chooses" option (flow-send.ts): the card goes where it leads, or is ignored.
2162
+ const choiceAction = store.getTelegramFlowChoice(token);
2163
+ if (choiceAction !== null) {
2164
+ if (choiceAction.binding !== binding.id || choiceAction.chatId !== tapChat || (choiceAction.messageId !== null && choiceAction.messageId !== String(message.message_id))) {
2165
+ report.ignored++;
2166
+ return;
2167
+ }
2168
+ const repos = context.projects === null ? null : telegramConversationRepos(store, binding.approver, context.projects);
2169
+ for (const effect of applyFlowChoiceTap(store, binding, choiceAction, { text: message.text ?? "" }, repos, clock())) {
2170
+ if (effect.kind === "ack")
2171
+ ack(effect.text);
2172
+ else if (effect.kind === "edit")
2173
+ editText(effect.text);
2174
+ }
2175
+ return;
2176
+ }
2141
2177
  // The lead's question to its owner: an option is sent as their next message; "Something else" asks them to type it.
2142
2178
  const askTap = /^ask:([1-9][0-9]{0,14}):([0-3]|x)$/.exec(token);
2143
2179
  if (askTap !== null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.20",
3
+ "version": "0.9.22",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"