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
@@ -0,0 +1,841 @@
1
+ /**
2
+ * The chat core's messages, buttons and deliveries, for any chat app: what an app received (updates, and messages the
3
+ * lead answers, queued, claimed and finished), the reply parts planned before any send and settled on the app's own
4
+ * message id, the one-tap buttons behind decision, proposal, flow and question cards, the sent messages a reply can be
5
+ * about, and each paired person's notification receipts and away-mode digest. Every query names its provider
6
+ * (ChatCore.prepare), so one app's rows never reach another's. Telegram runs on it today through the store's
7
+ * Telegram-named methods (one line each); the adapters keep only their transport.
8
+ *
9
+ * Imports the store's types only (no runtime cycle): the store builds its chats on it.
10
+ */
11
+ import { join } from "node:path";
12
+ import { ChatCore } from "./chat-core.js";
13
+ import { readChatMessageBody, readChatPart, savedChatEventBody, savedChatPart } from "./contracts/chat-content.js";
14
+ import { isTelegramProgressNotification, proposalTaskOf, readNotification, readNotificationReceipt } from "./notification-rows.js";
15
+ /** v83: a notification for another person, settled for this destination without sending. */
16
+ export const TELEGRAM_SKIPPED_ELSEWHERE = "skipped:for-another-person";
17
+ /** A met promise the lead made on another chat: reported there, settled here without sending. */
18
+ export const TELEGRAM_SKIPPED_OTHER_CHAT = "skipped:for-another-chat";
19
+ /** A fact this person is not messaged about: the lead's work, their own act, or a project they muted. */
20
+ export const TELEGRAM_SKIPPED_QUIET = "skipped:quiet";
21
+ /** A destination receipt (`d` = notification_delivery) that still owes a
22
+ * send: not delivered, and not explicitly skipped as pre-pairing history.
23
+ * Every reader of Telegram receipts that means "undelivered" uses this, so
24
+ * a skip settles the row without ever pretending to be a delivery. */
25
+ export const TELEGRAM_UNSETTLED = "d.delivered_at IS NULL AND (d.receipt IS NULL OR d.receipt NOT LIKE 'skipped:%')";
26
+ /**
27
+ * Why the store itself holds a Telegram delivery back: authority,
28
+ * provenance, ordering, a switched-off channel, or a wait it imposed. A
29
+ * row held for one of these is waiting on policy, not failing on the wire.
30
+ * Every other error recorded on a receipt came from an actual send, and
31
+ * THAT is delivery trouble (`pendingForAttention`). One table, so the
32
+ * fences and the tally can never disagree about the words.
33
+ */
34
+ export const TELEGRAM_HOLD_REASONS = Object.freeze({
35
+ authority: "Telegram pairing or actor authorization changed",
36
+ destination: "Telegram destination changed",
37
+ claim: "Telegram delivery claim expired or changed",
38
+ rateLimit: "Telegram rate limit is still active",
39
+ resolved: "Notification no longer needs delivery",
40
+ provenance: "Notification has no trusted project provenance",
41
+ installation: "Installation notification requires instance access",
42
+ project: "Notification project is not currently authorized and enrolled",
43
+ task: "Notification task provenance changed",
44
+ order: "Earlier task notification is still undelivered",
45
+ disabled: "Telegram delivery is disabled",
46
+ });
47
+ /** A Telegram message the lead answers is its event m<update id>, read with the pairing it came from. */
48
+ const TELEGRAM_CONVERSATION_SELECT = `SELECT e.*, b.approver AS paired_approver, b.generation AS paired_generation
49
+ FROM chat_event e JOIN chat_binding b ON b.provider = e.provider AND b.id = e.binding
50
+ WHERE e.provider = :provider AND e.kind = 'message'`;
51
+ /** In update order. */
52
+ const TELEGRAM_CONVERSATION_ORDER = "CAST(substr(e.id, 2) AS INTEGER)";
53
+ function readTelegramConversation(row) {
54
+ const text = (key) => (row[key] === null || row[key] === undefined ? null : String(row[key]));
55
+ const int = (key) => (row[key] === null || row[key] === undefined ? null : Number(row[key]));
56
+ // A message dropped when its chat was unpaired keeps no words.
57
+ const read = readChatMessageBody(String(row["payload"]));
58
+ const body = read.ok ? read.value : {};
59
+ const update = Number(String(row["id"]).slice(1));
60
+ const state = String(row["state"]);
61
+ return {
62
+ id: update,
63
+ binding: Number(row["binding"]),
64
+ botId: String(row["installation"]),
65
+ chatId: String(row["channel"]),
66
+ userId: String(row["member"]),
67
+ approver: String(row["paired_approver"]),
68
+ approverGeneration: Number(row["paired_generation"]),
69
+ updateId: update,
70
+ messageId: String(row["ts"]),
71
+ replyTo: row["thread"] === "" ? null : text("thread"),
72
+ request: body.request ?? "",
73
+ text: body.text ?? "",
74
+ context: body.context ?? null,
75
+ taskId: body.about?.task ?? null,
76
+ sourceRun: body.about?.run ?? null,
77
+ state: (state === "dropped" ? "failed" : state),
78
+ claimOwner: text("claim_owner"),
79
+ claimExpiresAt: text("claim_until"),
80
+ attempts: Number(row["attempts"]),
81
+ nextAttemptAt: text("next_at"),
82
+ session: int("session"),
83
+ turn: int("turn"),
84
+ outcome: text("result"),
85
+ replyMessageId: text("reply"),
86
+ createdAt: String(row["created"]),
87
+ startedAt: text("started"),
88
+ finishedAt: text("finished"),
89
+ };
90
+ }
91
+ /** One planned part of a Telegram reply (chat_part, its content by contracts/chat-content.ts). */
92
+ function readTelegramConversationPart(row, conversation) {
93
+ const text = (key) => (row[key] === null || row[key] === undefined ? null : String(row[key]));
94
+ const read = readChatPart(String(row["payload"]));
95
+ if (!read.ok)
96
+ throw new Error(`Telegram reply part ${conversation}:${String(row["ordinal"])} can't be read: ${read.issues.map(issue => issue.line).join("; ")}`);
97
+ const content = read.value;
98
+ const kind = content.image !== undefined ? "image" : content.card === true || content.proposal !== undefined ? "card" : "reply";
99
+ return {
100
+ conversation,
101
+ ordinal: Number(row["ordinal"]),
102
+ kind,
103
+ text: content.text,
104
+ replyTo: content.replyTo ?? null,
105
+ // A card's proposal, while it exists (a deleted one reads as gone).
106
+ proposal: content.proposal !== undefined && Number(row["proposal_live"] ?? 1) === 1 ? content.proposal : null,
107
+ keyboard: content.keyboard === undefined ? null : content.keyboard.map(line => line.map(button => ({ ...button, text: button.text, callback_data: button.callback_data }))),
108
+ state: String(row["state"]),
109
+ messageId: text("message"),
110
+ taskId: content.image?.taskId ?? null,
111
+ run: content.image?.run ?? null,
112
+ artifact: content.image?.artifact ?? null,
113
+ sha256: content.image?.sha256 ?? null,
114
+ attempts: Number(row["attempts"]),
115
+ uncertain: Number(row["uncertain"]),
116
+ nextAttemptAt: text("next_at"),
117
+ lastError: text("problem"),
118
+ createdAt: String(row["created"]),
119
+ sentAt: text("sent"),
120
+ };
121
+ }
122
+ function readTelegramProposalAction(row) {
123
+ return {
124
+ token: String(row["token"]),
125
+ binding: Number(row["binding"]),
126
+ proposal: Number(row["proposal"]),
127
+ phase: String(row["phase"]),
128
+ chatId: String(row["chat"]),
129
+ messageId: row["message"] === null ? null : String(row["message"]),
130
+ createdAt: String(row["created"]),
131
+ expiresAt: row["expires"] === null ? null : String(row["expires"]),
132
+ consumedAt: row["consumed"] === null ? null : String(row["consumed"]),
133
+ };
134
+ }
135
+ function readTelegramAction(row) {
136
+ return {
137
+ token: String(row["token"]),
138
+ binding: Number(row["binding"]),
139
+ decision: Number(row["decision"]),
140
+ optionId: String(row["option_id"]),
141
+ phase: String(row["phase"]),
142
+ chatId: String(row["chat"]),
143
+ messageId: row["message"] === null ? null : String(row["message"]),
144
+ createdAt: String(row["created"]),
145
+ expiresAt: row["expires"] === null ? null : String(row["expires"]),
146
+ consumedAt: row["consumed"] === null ? null : String(row["consumed"]),
147
+ noteDigest: row["note_digest"] === null || row["note_digest"] === undefined ? null : String(row["note_digest"]),
148
+ };
149
+ }
150
+ export class ChatMessages extends ChatCore {
151
+ createDecisionAction(action, now) {
152
+ this
153
+ .prepare(`INSERT INTO chat_action (provider, token, binding, chat, message, decision, option_id, phase, created, expires, note_digest)
154
+ VALUES (:provider, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
155
+ .run(action.token, action.binding, action.chatId, action.messageId ?? null, action.decision, action.optionId, action.phase, now.toISOString(), action.ttlMs === undefined ? null : new Date(now.getTime() + action.ttlMs).toISOString(), action.noteDigest ?? null);
156
+ }
157
+ /** Which decision an outbound Telegram message carried. Recorded after the
158
+ * send returns its id; a send whose record was lost routes nothing. */
159
+ recordDecisionMessage(binding, chatId, messageId, decision, now) {
160
+ this
161
+ .prepare(`INSERT OR IGNORE INTO chat_message_ref (provider, binding, chat, message, kind, decision, created)
162
+ VALUES (:provider, ?, ?, ?, 'decision', ?, ?)`)
163
+ .run(binding, chatId, messageId, decision, now.toISOString());
164
+ }
165
+ /** The decision behind an exact replied-to message, or nothing. Never "the latest". */
166
+ decisionForMessage(binding, chatId, messageId) {
167
+ const row = this
168
+ .prepare("SELECT decision FROM chat_message_ref WHERE provider = :provider AND kind = 'decision' AND binding = ? AND chat = ? AND message = ?")
169
+ .get(binding, chatId, messageId);
170
+ return row === undefined ? null : Number(row["decision"]);
171
+ }
172
+ /** The one live, unexpired draft for a decision, if any. */
173
+ liveNoteDraft(binding, decision, now) {
174
+ const row = this
175
+ .prepare(`SELECT id, note, event, state FROM chat_note_draft
176
+ WHERE provider = :provider AND binding = ? AND decision = ? AND state IN ('pending','armed') AND expires > ?`)
177
+ .get(binding, decision, now.toISOString());
178
+ return row === undefined
179
+ ? null
180
+ : { id: Number(row["id"]), note: String(row["note"]), updateId: Number(row["event"]), state: String(row["state"]) };
181
+ }
182
+ /**
183
+ * Persist a validated note as the live draft. A newer update SUPERSEDES
184
+ * the old draft — drafts are immutable, and only a GREATER update_id may
185
+ * replace one, so out-of-order delivery cannot resurrect an older note
186
+ * (Codex free-text review, state machine). Returns false when an older
187
+ * or equal update tried.
188
+ */
189
+ saveNoteDraft(draft, now, ttlMs = 10 * 60_000) {
190
+ return this.store.transact(() => {
191
+ const chat = this;
192
+ const live = chat
193
+ .prepare("SELECT id, event FROM chat_note_draft WHERE provider = :provider AND binding = ? AND decision = ? AND state IN ('pending','armed')")
194
+ .get(draft.binding, draft.decision);
195
+ if (live !== undefined) {
196
+ if (Number(live["event"]) >= draft.updateId)
197
+ return false;
198
+ chat.prepare("UPDATE chat_note_draft SET state = 'superseded' WHERE provider = :provider AND id = ?").run(live["id"]);
199
+ }
200
+ chat
201
+ .prepare(`INSERT INTO chat_note_draft (provider, id, binding, decision, event, message, reply_to, note, state, created, expires)
202
+ VALUES (:provider, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`)
203
+ .run(chat.nextId("chat_note_draft"), draft.binding, draft.decision, draft.updateId, draft.messageId, draft.replyTo, draft.note, now.toISOString(), new Date(now.getTime() + ttlMs).toISOString());
204
+ return true;
205
+ });
206
+ }
207
+ /** Move a draft between states; the caller owns the transaction story. */
208
+ setNoteDraftState(id, state) {
209
+ this.prepare("UPDATE chat_note_draft SET state = ? WHERE provider = :provider AND id = ?").run(state, id);
210
+ }
211
+ createFlowActions(at, actions, now, days = 7) {
212
+ const stamp = now.toISOString(), expires = new Date(now.getTime() + days * 86_400_000).toISOString();
213
+ const insert = this.prepare("INSERT INTO chat_flow_action (provider, token, binding, card, entry, action, chat, message, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?, NULL, ?, ?)");
214
+ for (const one of actions)
215
+ insert.run(one.token, at.binding, at.card, at.entry, one.action, at.chatId, stamp, expires);
216
+ }
217
+ flowAction(token) {
218
+ if (!/^[0-9a-f]{32}$/.test(token))
219
+ return null;
220
+ const row = this.prepare("SELECT * FROM chat_flow_action WHERE provider = :provider AND token = ? AND action IN ('approve','edit','send-back')").get(token);
221
+ if (row === undefined)
222
+ return null;
223
+ return { token: String(row["token"]), binding: Number(row["binding"]), card: Number(row["card"]), entry: Number(row["entry"]), action: String(row["action"]),
224
+ chatId: String(row["chat"]), messageId: row["message"] === null ? null : String(row["message"]), expiresAt: String(row["expires"]), consumedAt: row["consumed"] === null ? null : String(row["consumed"]) };
225
+ }
226
+ placeFlowActions(tokens, messageId) {
227
+ const place = this.prepare("UPDATE chat_flow_action SET message = ? WHERE provider = :provider AND token = ?");
228
+ for (const token of tokens)
229
+ place.run(messageId, token);
230
+ }
231
+ /** Retire every button (and prompt) for one visit of a card, in every chat app: it was decided, or its draft replaced. */
232
+ retireFlowVisit(card, entry, now) {
233
+ const stamp = now.toISOString();
234
+ this.db.prepare("UPDATE chat_flow_action SET consumed = ? WHERE card = ? AND entry = ? AND consumed IS NULL").run(stamp, card, entry);
235
+ this.db.prepare("UPDATE chat_flow_prompt SET consumed = ? WHERE card = ? AND entry = ? AND consumed IS NULL").run(stamp, card, entry);
236
+ }
237
+ recordFlowPrompt(prompt, now, hours = 24) {
238
+ const chat = this;
239
+ chat.prepare("DELETE FROM chat_flow_prompt WHERE provider = :provider AND chat = ? AND message = ?").run(prompt.chatId, prompt.messageId);
240
+ chat.prepare("INSERT INTO chat_flow_prompt (provider, id, chat, message, binding, card, entry, mode, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
241
+ .run(chat.nextId("chat_flow_prompt"), prompt.chatId, prompt.messageId, prompt.binding, prompt.card, prompt.entry, prompt.mode, now.toISOString(), new Date(now.getTime() + hours * 3_600_000).toISOString());
242
+ }
243
+ /** The live prompt a message replies to, if any. */
244
+ flowPrompt(chatId, messageId, now) {
245
+ const row = this.prepare("SELECT * FROM chat_flow_prompt WHERE provider = :provider AND chat = ? AND message = ? AND consumed IS NULL AND expires > ?").get(chatId, messageId, now.toISOString());
246
+ if (row === undefined)
247
+ return null;
248
+ return { chatId: String(row["chat"]), messageId: String(row["message"]), binding: Number(row["binding"]), card: Number(row["card"]), entry: Number(row["entry"]), mode: String(row["mode"]) };
249
+ }
250
+ createFlowChoices(at, choices, now, days = 7) {
251
+ const stamp = now.toISOString(), expires = new Date(now.getTime() + days * 86_400_000).toISOString();
252
+ const insert = this.prepare("INSERT INTO chat_flow_choice (provider, token, binding, card, entry, choice, label, chat, message, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)");
253
+ for (const one of choices)
254
+ insert.run(one.token, at.binding, at.card, at.entry, one.choice, one.label, at.chatId, stamp, expires);
255
+ }
256
+ flowChoice(token) {
257
+ if (!/^[0-9a-f]{32}$/.test(token))
258
+ return null;
259
+ const row = this.prepare("SELECT * FROM chat_flow_choice WHERE provider = :provider AND token = ?").get(token);
260
+ if (row === undefined)
261
+ return null;
262
+ 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"]),
263
+ chatId: String(row["chat"]), messageId: row["message"] === null ? null : String(row["message"]), expiresAt: String(row["expires"]), consumedAt: row["consumed"] === null ? null : String(row["consumed"]) };
264
+ }
265
+ placeFlowChoices(tokens, messageId) {
266
+ const place = this.prepare("UPDATE chat_flow_choice SET message = ? WHERE provider = :provider AND token = ?");
267
+ for (const token of tokens)
268
+ place.run(messageId, token);
269
+ }
270
+ createQuestionActions(at, choices, now, days = 7) {
271
+ const stamp = now.toISOString(), expires = new Date(now.getTime() + days * 86_400_000).toISOString();
272
+ const insert = this.prepare("INSERT INTO chat_question_action (provider, token, binding, question, choice, chat, message, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, NULL, ?, ?)");
273
+ for (const one of choices)
274
+ insert.run(one.token, at.binding, at.question, one.choice, at.chatId, stamp, expires);
275
+ }
276
+ questionAction(token) {
277
+ if (!/^[0-9a-f]{32}$/.test(token))
278
+ return null;
279
+ const row = this.prepare("SELECT * FROM chat_question_action WHERE provider = :provider AND token = ?").get(token);
280
+ if (row === undefined)
281
+ return null;
282
+ return { token: String(row["token"]), binding: Number(row["binding"]), question: Number(row["question"]), choice: row["choice"] === null ? null : String(row["choice"]),
283
+ chatId: String(row["chat"]), messageId: row["message"] === null ? null : String(row["message"]), expiresAt: String(row["expires"]), consumedAt: row["consumed"] === null ? null : String(row["consumed"]) };
284
+ }
285
+ placeQuestionActions(tokens, messageId) {
286
+ const place = this.prepare("UPDATE chat_question_action SET message = ? WHERE provider = :provider AND token = ?");
287
+ for (const token of tokens)
288
+ place.run(messageId, token);
289
+ }
290
+ /** Retire every button and prompt for a question, in every chat app: it was answered (anywhere). */
291
+ retireQuestion(question, now) {
292
+ const stamp = now.toISOString();
293
+ this.db.prepare("UPDATE chat_question_action SET consumed = ? WHERE question = ? AND consumed IS NULL").run(stamp, question);
294
+ this.db.prepare("UPDATE chat_question_prompt SET consumed = ? WHERE question = ? AND consumed IS NULL").run(stamp, question);
295
+ }
296
+ recordQuestionPrompt(prompt, now, hours = 24) {
297
+ const chat = this;
298
+ chat.prepare("DELETE FROM chat_question_prompt WHERE provider = :provider AND chat = ? AND message = ?").run(prompt.chatId, prompt.messageId);
299
+ chat.prepare("INSERT INTO chat_question_prompt (provider, id, chat, message, binding, question, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?, ?)")
300
+ .run(chat.nextId("chat_question_prompt"), prompt.chatId, prompt.messageId, prompt.binding, prompt.question, now.toISOString(), new Date(now.getTime() + hours * 3_600_000).toISOString());
301
+ }
302
+ questionPrompt(chatId, messageId, now) {
303
+ const row = this.prepare("SELECT * FROM chat_question_prompt WHERE provider = :provider AND chat = ? AND message = ? AND consumed IS NULL AND expires > ?").get(chatId, messageId, now.toISOString());
304
+ return row === undefined ? null : { chatId: String(row["chat"]), messageId: String(row["message"]), binding: Number(row["binding"]), question: Number(row["question"]) };
305
+ }
306
+ decisionAction(token) {
307
+ const row = this.prepare("SELECT * FROM chat_action WHERE provider = :provider AND token = ? AND decision IS NOT NULL").get(token);
308
+ return row === undefined ? null : readTelegramAction(row);
309
+ }
310
+ /**
311
+ * Kill every live confirm/cancel challenge on a decision. Cancel means
312
+ * cancelled: a confirm that survived its own cancellation would be an
313
+ * irreversible choice still armed after the person said stop.
314
+ */
315
+ consumeChallenges(decision, now) {
316
+ this
317
+ .prepare("UPDATE chat_action SET consumed = ? WHERE provider = :provider AND decision = ? AND phase IN ('confirm','cancel') AND consumed IS NULL")
318
+ .run(now.toISOString(), decision);
319
+ }
320
+ /** Consume once. False means somebody already did, or it expired — either way, no. */
321
+ consumeDecisionAction(token, now) {
322
+ const stamp = now.toISOString();
323
+ const { changes } = this
324
+ .prepare("UPDATE chat_action SET consumed = ? WHERE provider = :provider AND token = ? AND decision IS NOT NULL AND consumed IS NULL AND (expires IS NULL OR expires > ?)")
325
+ .run(stamp, token, stamp);
326
+ return Number(changes) > 0;
327
+ }
328
+ /** Stamp the message a choose-action's keyboard actually landed on. */
329
+ placeDecisionActions(tokens, messageId) {
330
+ const place = this.prepare("UPDATE chat_action SET message = ? WHERE provider = :provider AND token = ?");
331
+ for (const token of tokens)
332
+ place.run(messageId, token);
333
+ }
334
+ /** A Telegram message this bot sent that showed one task (v79): a reply to
335
+ * it is about that task (and that result, when it showed one). A message
336
+ * edited to show something else forgets it. */
337
+ recordTaskMessage(binding, messageId, taskId, run, now) {
338
+ const chat = this;
339
+ if (taskId === null) {
340
+ chat.prepare("DELETE FROM chat_message_ref WHERE provider = :provider AND kind = 'task' AND binding = ? AND chat = ? AND message = ?").run(binding.id, binding.chatId, messageId);
341
+ return;
342
+ }
343
+ chat.prepare(`INSERT INTO chat_message_ref (provider, binding, chat, message, kind, task_id, run, created) VALUES (:provider, ?, ?, ?, 'task', ?, ?, ?)
344
+ ON CONFLICT (provider, binding, chat, message, kind) WHERE kind <> 'notification' DO UPDATE SET task_id = excluded.task_id, run = excluded.run, created = excluded.created`)
345
+ .run(binding.id, binding.chatId, messageId, taskId, run, now.toISOString());
346
+ }
347
+ /** v98: an update Telegram pushed, kept (queued, with its body) until the bridge applies it; false when it's already kept or applied. */
348
+ queueUpdate(botId, updateId, payload, now) {
349
+ return Number(this.prepare(`INSERT OR IGNORE INTO chat_event (provider, id, installation, kind, channel, member, ts, thread, payload, created, state)
350
+ VALUES (:provider, ?, ?, 'update', '', '', '', '', ?, ?, 'queued')`).run(String(updateId), botId, payload, now.toISOString()).changes) === 1;
351
+ }
352
+ /** v98: pushed updates waiting for the bridge, oldest first. */
353
+ inbox(botId, limit) {
354
+ return this.prepare(`SELECT id, payload FROM chat_event WHERE provider = :provider AND kind = 'update' AND state = 'queued' AND installation = ?
355
+ ORDER BY CAST(id AS INTEGER) LIMIT ?`).all(botId, limit)
356
+ .map(row => ({ updateId: Number(row["id"]), payload: String(row["payload"]) }));
357
+ }
358
+ /** A pushed update the bridge is done with: applied (then its receipt stays), or unreadable (then it is gone). */
359
+ dropInbox(updateId) {
360
+ this.prepare("DELETE FROM chat_event WHERE provider = :provider AND id = ? AND kind = 'update' AND state = 'queued'").run(String(updateId));
361
+ }
362
+ /** True if this update has not been applied before. The (provider, id) key is the idempotency; a pushed update waiting
363
+ * in the inbox is applied in place. A receipt names only its update, as it always has. */
364
+ markUpdateApplied(updateId, result, now) {
365
+ const { changes } = this
366
+ .prepare(`INSERT INTO chat_event (provider, id, installation, kind, channel, member, ts, thread, payload, created, state, result)
367
+ VALUES (:provider, ?, '', 'update', '', '', '', '', '{}', ?, 'done', ?)
368
+ ON CONFLICT (provider, id) DO UPDATE SET state = 'done', installation = '', payload = '{}', result = excluded.result
369
+ WHERE chat_event.kind = 'update' AND chat_event.state = 'queued'`)
370
+ .run(String(updateId), now.toISOString(), result);
371
+ return Number(changes) > 0;
372
+ }
373
+ /** Persist one ordinary message before its update is acknowledged (event m<update id>). The caller's transaction also marks the update applied. */
374
+ enqueueMessage(row, now) {
375
+ const body = { text: row.text, request: row.request, ...(row.context === null ? {} : { context: row.context }),
376
+ ...(row.taskId === null ? {} : { about: { task: row.taskId, run: row.sourceRun } }) };
377
+ this
378
+ .prepare(`INSERT INTO chat_event (provider, id, installation, binding, kind, channel, member, ts, thread, payload, created, state)
379
+ VALUES (:provider, ?, ?, ?, 'message', ?, ?, ?, ?, ?, ?, 'queued')`)
380
+ .run(`m${row.updateId}`, row.binding.botId, row.binding.id, row.binding.chatId, row.binding.userId, row.messageId, row.replyTo ?? "", savedChatEventBody(body), now.toISOString());
381
+ return row.updateId;
382
+ }
383
+ /** Whether a message from this chat is already waiting on, or being answered from, this bot message (a tapped question). */
384
+ messageWaitingOn(binding, messageId) {
385
+ return this.prepare("SELECT 1 AS hit FROM chat_event WHERE provider = :provider AND kind = 'message' AND binding = ? AND ts = ? AND state IN ('queued', 'running') LIMIT 1").get(binding, messageId) !== undefined;
386
+ }
387
+ getMessage(id) {
388
+ const row = this.prepare(`${TELEGRAM_CONVERSATION_SELECT} AND e.id = ?`).get(`m${id}`);
389
+ return row === undefined ? null : readTelegramConversation(row);
390
+ }
391
+ listMessages(botId) {
392
+ return this.prepare(`${TELEGRAM_CONVERSATION_SELECT} AND e.installation = ? ORDER BY ${TELEGRAM_CONVERSATION_ORDER}`).all(botId).map(readTelegramConversation);
393
+ }
394
+ /**
395
+ * Claim the oldest message that still needs a turn: queued, or running
396
+ * under a claim that lapsed (a crash mid-turn), and not deferred past now.
397
+ * One at a time per bot — the mate runs one turn per approver anyway.
398
+ */
399
+ claimMessage(botId, owner, ttlMs, now, only) {
400
+ return this.store.transact(() => {
401
+ const chat = this, stamp = now.toISOString();
402
+ // "turns": still needs the assistant; "replies": the reply is planned and only sending remains.
403
+ const planned = "EXISTS (SELECT 1 FROM chat_part p WHERE p.provider = e.provider AND p.event = e.id)";
404
+ const which = only === "turns" ? `AND NOT ${planned}` : only === "replies" ? `AND ${planned}` : "";
405
+ const row = chat
406
+ .prepare(`SELECT e.id FROM chat_event e
407
+ WHERE e.provider = :provider AND e.kind = 'message' AND e.installation = ? AND e.state IN ('queued','running')
408
+ AND (e.claim_until IS NULL OR e.claim_until <= ?)
409
+ AND (e.next_at IS NULL OR e.next_at <= ?)
410
+ ${which}
411
+ ORDER BY ${TELEGRAM_CONVERSATION_ORDER} LIMIT 1`)
412
+ .get(botId, stamp, stamp);
413
+ if (row === undefined)
414
+ return null;
415
+ chat
416
+ .prepare(`UPDATE chat_event SET state = 'running', claim_owner = ?, claim_until = ?, attempts = attempts + 1,
417
+ started = COALESCE(started, ?), next_at = NULL WHERE provider = :provider AND id = ?`)
418
+ .run(owner, new Date(now.getTime() + ttlMs).toISOString(), stamp, String(row["id"]));
419
+ return this.getMessage(Number(String(row["id"]).slice(1)));
420
+ });
421
+ }
422
+ /** Extend a held claim; false means it lapsed and somebody else may hold the row. */
423
+ renewMessage(id, owner, ttlMs, now) {
424
+ const { changes } = this
425
+ .prepare("UPDATE chat_event SET claim_until = ? WHERE provider = :provider AND id = ? AND claim_owner = ? AND claim_until > ? AND state = 'running'")
426
+ .run(new Date(now.getTime() + ttlMs).toISOString(), `m${id}`, owner, now.toISOString());
427
+ return Number(changes) === 1;
428
+ }
429
+ /**
430
+ * Bind the session a turn is about to be dispatched under, BEFORE the
431
+ * dispatch: the engine receipts the request inside that session, so a
432
+ * later attempt — after a crash, even after the session was ended and
433
+ * replaced from the console — reads the receipt where it was written
434
+ * instead of resolving today's session and finding nothing there.
435
+ */
436
+ bindMessageSession(id, owner, session) {
437
+ const { changes } = this
438
+ .prepare("UPDATE chat_event SET session = ? WHERE provider = :provider AND id = ? AND claim_owner = ? AND state = 'running'")
439
+ .run(session, `m${id}`, owner);
440
+ return Number(changes) === 1;
441
+ }
442
+ /** Bind the admitted turn to its row as soon as the engine's receipt names it. */
443
+ bindMessageTurn(id, owner, session, turn) {
444
+ const { changes } = this
445
+ .prepare("UPDATE chat_event SET session = ?, turn = ? WHERE provider = :provider AND id = ? AND claim_owner = ?")
446
+ .run(session, turn, `m${id}`, owner);
447
+ return Number(changes) === 1;
448
+ }
449
+ /**
450
+ * Settle a claimed row: done or failed with its outcome word, or back to
451
+ * queued for a later attempt (a busy engine). Only the claim holder may;
452
+ * a lapsed claim settles nothing, so a reclaimer's outcome stands.
453
+ */
454
+ finishMessage(id, owner, result, now) {
455
+ const stamp = now.toISOString(), chat = this;
456
+ const { changes } = result.state === "queued"
457
+ ? chat
458
+ .prepare(`UPDATE chat_event SET state = 'queued', result = ?, next_at = ?, claim_owner = NULL, claim_until = NULL
459
+ WHERE provider = :provider AND id = ? AND claim_owner = ? AND claim_until > ? AND state = 'running'`)
460
+ .run(result.outcome, result.nextAttemptAt, `m${id}`, owner, stamp)
461
+ : chat
462
+ .prepare(`UPDATE chat_event SET state = ?, result = ?, reply = COALESCE(?, reply), finished = ?,
463
+ claim_owner = NULL, claim_until = NULL
464
+ WHERE provider = :provider AND id = ? AND claim_owner = ? AND claim_until > ? AND state = 'running'`)
465
+ .run(result.state, result.outcome, result.replyMessageId ?? null, stamp, `m${id}`, owner, stamp);
466
+ return Number(changes) === 1;
467
+ }
468
+ /**
469
+ * Replies the assistant wrote that still have not reached the person
470
+ * `since` ago or longer (status and the console show them with a Retry):
471
+ * the message's row, whose it is, when the oldest unsent part was
472
+ * written, and the last send error.
473
+ */
474
+ unsentReplies(botId, approver, since) {
475
+ return this
476
+ .prepare(`SELECT c.id, b.approver, MIN(p.created) AS since,
477
+ (SELECT q.problem FROM chat_part q WHERE q.provider = c.provider AND q.event = c.id AND q.state = 'pending' ORDER BY q.ordinal LIMIT 1) AS error
478
+ FROM chat_event c JOIN chat_binding b ON b.provider = c.provider AND b.id = c.binding
479
+ JOIN chat_part p ON p.provider = c.provider AND p.event = c.id AND p.state = 'pending'
480
+ WHERE c.provider = :provider AND c.kind = 'message' AND c.state IN ('queued','running') AND (? IS NULL OR c.installation = ?) AND (? IS NULL OR b.approver = ?)
481
+ GROUP BY c.id HAVING MIN(p.created) <= ? ORDER BY ${TELEGRAM_CONVERSATION_ORDER.replaceAll("e.", "c.")}`)
482
+ .all(botId, botId, approver, approver, since.toISOString())
483
+ .map(row => ({ conversation: Number(String(row["id"]).slice(1)), approver: String(row["approver"]), since: String(row["since"]), error: row["error"] === null ? null : String(row["error"]) }));
484
+ }
485
+ /** Send unsent replies on the next bridge pass instead of waiting out their backoff; returns how many. */
486
+ retryReplies(approver, now) {
487
+ const { changes } = this
488
+ .prepare(`UPDATE chat_event SET next_at = ?
489
+ WHERE provider = :provider AND kind = 'message' AND state = 'queued'
490
+ AND (? IS NULL OR binding IN (SELECT id FROM chat_binding WHERE provider = :provider AND approver = ?))
491
+ AND EXISTS (SELECT 1 FROM chat_part p WHERE p.provider = chat_event.provider AND p.event = chat_event.id AND p.state = 'pending')`)
492
+ .run(now.toISOString(), approver, approver);
493
+ return Number(changes);
494
+ }
495
+ /**
496
+ * Persist the whole outbound half of a turn — every reply part and every
497
+ * card, with the cards' tokens already minted — in one transaction, BEFORE
498
+ * any send. Only the claim holder may, and only once: a row that already
499
+ * has parts keeps them (a retry resumes, never re-plans). The turn's
500
+ * identity rides the row too, so the parts answer to exactly that turn.
501
+ */
502
+ planReplyParts(id, owner, turn, parts, now) {
503
+ return this.store.transact(() => {
504
+ const chat = this, stamp = now.toISOString(), event = `m${id}`;
505
+ const held = chat
506
+ .prepare("SELECT 1 AS hit FROM chat_event WHERE provider = :provider AND id = ? AND claim_owner = ? AND claim_until > ? AND state = 'running'")
507
+ .get(event, owner, stamp);
508
+ if (held === undefined)
509
+ return false;
510
+ const existing = chat.prepare("SELECT COUNT(*) AS n FROM chat_part WHERE provider = :provider AND event = ?").get(event);
511
+ if (Number(existing?.["n"] ?? 0) > 0)
512
+ return false;
513
+ chat.prepare("UPDATE chat_event SET session = ?, turn = ? WHERE provider = :provider AND id = ?").run(turn.session, turn.turn, event);
514
+ const insert = chat.prepare("INSERT INTO chat_part (provider, id, event, ordinal, payload, state, created) VALUES (:provider, ?, ?, ?, ?, 'pending', ?)");
515
+ parts.forEach((part, ordinal) => {
516
+ const content = part.kind === "image"
517
+ ? { text: part.text, image: { taskId: part.taskId, run: part.run, artifact: part.artifact, sha256: part.sha256 } }
518
+ : part.kind === "card"
519
+ ? { text: part.text, proposal: part.proposal, ...(part.keyboard == null ? {} : { keyboard: part.keyboard }), card: true }
520
+ : { text: part.text, ...(part.replyTo == null ? {} : { replyTo: part.replyTo }), ...(part.keyboard == null ? {} : { keyboard: part.keyboard }) };
521
+ insert.run(chat.nextId("chat_part"), event, ordinal, savedChatPart(content), stamp);
522
+ });
523
+ return true;
524
+ });
525
+ }
526
+ replyParts(conversation) {
527
+ return this
528
+ .prepare(`SELECT p.*, EXISTS (SELECT 1 FROM mate_proposal mp WHERE mp.id = json_extract(p.payload, '$.proposal')) AS proposal_live
529
+ FROM chat_part p WHERE p.provider = :provider AND p.event = ? ORDER BY p.ordinal`)
530
+ .all(`m${conversation}`).map(row => readTelegramConversationPart(row, conversation));
531
+ }
532
+ /**
533
+ * Settle one send attempt under the row's claim. A confirmed message id
534
+ * is the only success: the part is sent, and a reply part's id becomes
535
+ * the row's reply id. Anything else leaves the part pending with its
536
+ * attempt counted, its error named, its next attempt scheduled, and —
537
+ * when the answer was lost rather than refused — its uncertainty counted.
538
+ * A lapsed claim settles nothing, so a reclaimer's outcome stands.
539
+ */
540
+ settleReplyPart(conversation, ordinal, owner, outcome, now) {
541
+ return this.store.transact(() => {
542
+ const chat = this, stamp = now.toISOString(), event = `m${conversation}`;
543
+ const held = chat
544
+ .prepare("SELECT 1 AS hit FROM chat_event WHERE provider = :provider AND id = ? AND claim_owner = ? AND claim_until > ? AND state = 'running'")
545
+ .get(event, owner, stamp);
546
+ if (held === undefined)
547
+ return false;
548
+ const { changes } = outcome.ok
549
+ ? chat
550
+ .prepare(`UPDATE chat_part SET state = 'sent', message = ?, sent = ?, attempts = attempts + 1, next_at = NULL, problem = NULL
551
+ WHERE provider = :provider AND event = ? AND ordinal = ? AND state = 'pending'`)
552
+ .run(outcome.messageId, stamp, event, ordinal)
553
+ : chat
554
+ .prepare(`UPDATE chat_part SET attempts = attempts + 1, uncertain = uncertain + ?, next_at = ?, problem = ?
555
+ WHERE provider = :provider AND event = ? AND ordinal = ? AND state = 'pending'`)
556
+ .run(outcome.uncertain ? 1 : 0, outcome.retryAt, outcome.error, event, ordinal);
557
+ if (Number(changes) !== 1)
558
+ return false;
559
+ if (outcome.ok) {
560
+ const part = this.replyParts(conversation).find(one => one.ordinal === ordinal);
561
+ if (part?.kind === "reply")
562
+ chat.prepare("UPDATE chat_event SET reply = ? WHERE provider = :provider AND id = ?").run(outcome.messageId, event);
563
+ }
564
+ return true;
565
+ });
566
+ }
567
+ /** A card whose proposal no longer waits (confirmed or dismissed from another surface, or gone) is moot: dropped with the reason, never sent. */
568
+ dropReplyPart(conversation, ordinal, owner, reason, now) {
569
+ const chat = this, stamp = now.toISOString(), event = `m${conversation}`;
570
+ const held = chat
571
+ .prepare("SELECT 1 AS hit FROM chat_event WHERE provider = :provider AND id = ? AND claim_owner = ? AND claim_until > ? AND state = 'running'")
572
+ .get(event, owner, stamp);
573
+ if (held === undefined)
574
+ return false;
575
+ const { changes } = chat
576
+ .prepare("UPDATE chat_part SET state = 'dropped', problem = ?, next_at = NULL WHERE provider = :provider AND event = ? AND ordinal = ? AND state = 'pending'")
577
+ .run(reason, event, ordinal);
578
+ return Number(changes) === 1;
579
+ }
580
+ /** The exact task/run bindings of one outbound message this bot sent: a
581
+ * plain fact names one, a digest part may name several, (v64) a confirmed
582
+ * result image names its exact task and run, the lead's reply names the
583
+ * task its turn was about, a card names its task, and (v79) a status
584
+ * message names the task it showed. */
585
+ messageBindings(binding, messageId) {
586
+ const chat = this;
587
+ const facts = chat
588
+ .prepare(`SELECT DISTINCT task_id, task_ref, run, project FROM chat_message_ref
589
+ WHERE provider = :provider AND kind = 'notification' AND binding = ? AND chat = ? AND message = ? ORDER BY notification`)
590
+ .all(binding.id, binding.chatId, messageId)
591
+ .map(row => ({
592
+ taskId: row["task_id"] === null ? null : String(row["task_id"]),
593
+ taskRef: row["task_ref"] === null ? null : Number(row["task_ref"]),
594
+ run: row["run"] === null ? null : (this.progressRun({ taskRef: row["task_ref"] === null ? null : Number(row["task_ref"]), run: Number(row["run"]) })?.id ?? Number(row["run"])),
595
+ project: row["project"] === null ? null : String(row["project"]),
596
+ }));
597
+ // Every sent part of this bot's replies on that message, oldest first: images, the lead's replies and its cards.
598
+ const parts = chat
599
+ .prepare(`SELECT p.*, c.payload AS asked, EXISTS (SELECT 1 FROM mate_proposal mp WHERE mp.id = json_extract(p.payload, '$.proposal')) AS proposal_live
600
+ FROM chat_part p JOIN chat_event c ON c.provider = p.provider AND c.id = p.event
601
+ WHERE p.provider = :provider AND c.kind = 'message' AND c.binding = ? AND c.channel = ? AND p.message = ? AND p.state = 'sent'
602
+ ORDER BY CAST(substr(p.event, 2) AS INTEGER), p.ordinal`)
603
+ .all(binding.id, binding.chatId, messageId)
604
+ .map(row => ({ part: readTelegramConversationPart(row, Number(String(row["event"]).slice(1))), asked: readChatMessageBody(String(row["asked"])) }));
605
+ const images = parts.filter(one => one.part.kind === "image").map(({ part }) => {
606
+ const taskId = part.taskId;
607
+ const ref = this.store.lookupRef(taskId);
608
+ return { taskId, taskRef: ref?.id ?? null, run: part.run, project: ref?.repo ?? null };
609
+ });
610
+ // The lead's own messages: a reply from a turn that was about one task,
611
+ // and a card, which names its task (or, once it filed one, the new task).
612
+ const lead = parts.filter(one => one.part.kind !== "image").map(({ part, asked }) => part.kind === "card"
613
+ ? proposalTaskOf(part.proposal === null ? null : this.store.getMateProposal(part.proposal))
614
+ : asked.ok && asked.value.about !== undefined ? { task: asked.value.about.task, run: asked.value.about.run } : null);
615
+ const shown = chat
616
+ .prepare("SELECT task_id, run FROM chat_message_ref WHERE provider = :provider AND kind = 'task' AND binding = ? AND chat = ? AND message = ?")
617
+ .all(binding.id, binding.chatId, messageId)
618
+ .map(row => ({ task: String(row["task_id"]), run: row["run"] === null ? null : Number(row["run"]) }));
619
+ const pointed = [...lead, ...shown].flatMap(one => {
620
+ if (one === null)
621
+ return [];
622
+ const ref = this.store.lookupRef(one.task);
623
+ return [{ taskId: one.task, taskRef: ref?.id ?? null, run: one.run, project: ref?.repo ?? null }];
624
+ });
625
+ return [...facts, ...images, ...pointed].filter((one, index, all) => all.findIndex(fact => fact.taskId === one.taskId && fact.taskRef === one.taskRef && fact.run === one.run && fact.project === one.project) === index);
626
+ }
627
+ createProposalAction(action, now) {
628
+ this
629
+ .prepare(`INSERT INTO chat_action (provider, token, binding, proposal, phase, chat, message, created, expires)
630
+ VALUES (:provider, ?, ?, ?, ?, ?, ?, ?, ?)`)
631
+ .run(action.token, action.binding, action.proposal, action.phase, action.chatId, action.messageId ?? null, now.toISOString(), action.ttlMs === undefined ? null : new Date(now.getTime() + action.ttlMs).toISOString());
632
+ }
633
+ proposalAction(token) {
634
+ const row = this.prepare("SELECT * FROM chat_action WHERE provider = :provider AND token = ? AND proposal IS NOT NULL").get(token);
635
+ return row === undefined ? null : readTelegramProposalAction(row);
636
+ }
637
+ /** Consume once. False means somebody already did, or it expired. */
638
+ consumeProposalAction(token, now) {
639
+ const stamp = now.toISOString();
640
+ const { changes } = this
641
+ .prepare("UPDATE chat_action SET consumed = ? WHERE provider = :provider AND token = ? AND proposal IS NOT NULL AND consumed IS NULL AND (expires IS NULL OR expires > ?)")
642
+ .run(stamp, token, stamp);
643
+ return Number(changes) > 0;
644
+ }
645
+ /** Kill every live token on a proposal — after it resolves, or when a challenge is cancelled. */
646
+ consumeProposalActions(proposal, now, phases) {
647
+ const filter = phases === undefined ? "" : ` AND phase IN (${phases.map(() => "?").join(",")})`;
648
+ this
649
+ .prepare(`UPDATE chat_action SET consumed = ? WHERE provider = :provider AND proposal = ? AND consumed IS NULL${filter}`)
650
+ .run(now.toISOString(), proposal, ...(phases ?? []));
651
+ }
652
+ /** Stamp the message a card's keyboard actually landed on. */
653
+ placeProposalActions(tokens, messageId) {
654
+ const place = this.prepare("UPDATE chat_action SET message = ? WHERE provider = :provider AND token = ?");
655
+ for (const token of tokens)
656
+ place.run(messageId, token);
657
+ }
658
+ destination(binding) {
659
+ return `telegram:${binding.botId}:${binding.chatId}:${binding.id}:${binding.approverGeneration}`;
660
+ }
661
+ /** A progress card belongs to one builder result, including its root review.
662
+ * Do not combine planners, correction children, or separate build attempts. */
663
+ progressRun(row) {
664
+ const source = row.run === null ? null : this.store.getRun(row.run);
665
+ const result = source?.role === "reviewer" && source.reviewAttempt != null && source.parentRun !== null
666
+ ? this.store.getRun(source.parentRun) : source;
667
+ return result?.role === "builder" && result.taskRef === row.taskRef && source?.taskRef === row.taskRef ? result : null;
668
+ }
669
+ /** Reuse a confirmed message in this exact destination. A mixed digest,
670
+ * decision, image, or message naming another attempt is never editable. The
671
+ * outbound history survives a restart even if the final receipt was lost. */
672
+ progressMessage(binding, result) {
673
+ const chat = this, destination = this.destination(binding);
674
+ const messages = chat.prepare(`SELECT DISTINCT message FROM chat_message_ref
675
+ WHERE provider = :provider AND kind = 'notification' AND destination = ? AND task_ref = ? ORDER BY CAST(message AS INTEGER) DESC`)
676
+ .all(destination, result.taskRef);
677
+ for (const message of messages) {
678
+ const rows = chat.prepare(`SELECT n.* FROM chat_message_ref m JOIN notification n ON n.id = m.notification
679
+ WHERE m.provider = :provider AND m.kind = 'notification' AND m.destination = ? AND m.message = ?`).all(destination, String(message["message"]));
680
+ if (rows.length > 0 && rows.every(raw => {
681
+ const row = readNotification(raw);
682
+ return isTelegramProgressNotification(row) && this.progressRun(row)?.id === result.id;
683
+ }))
684
+ return String(message["message"]);
685
+ }
686
+ return null;
687
+ }
688
+ /** The existing outbox, with a separate receipt for this exact pairing. */
689
+ claimDeliveries(binding, owner, ttlMs, now, only = "all") {
690
+ return this.store.transact(() => {
691
+ const destination = this.destination(binding);
692
+ this.db.prepare(`INSERT OR IGNORE INTO notification_delivery (notification, destination)
693
+ SELECT id, ? FROM notification WHERE resolved_at IS NULL`).run(destination);
694
+ // Someone else's notification is settled here as skipped, never sent —
695
+ // and never left unsettled, where it would fence that task's later rows.
696
+ this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_ELSEWHERE}'
697
+ WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
698
+ AND notification IN (SELECT id FROM notification WHERE recipient IS NOT NULL AND recipient <> ?)`).run(destination, binding.approver);
699
+ // A promise the lead made on another chat is reported there (lead-commitments.ts), not here.
700
+ this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_OTHER_CHAT}'
701
+ WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
702
+ AND notification IN (SELECT id FROM notification WHERE dedupe_key LIKE 'lead-promise:%' AND dedupe_key NOT LIKE 'lead-promise:telegram:%')`).run(destination);
703
+ // Pings follow responsibility (the lead's work, this person's own act, a muted project): settled here, unsent.
704
+ for (const raw of this.db.prepare(`SELECT n.* FROM notification n JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
705
+ WHERE n.resolved_at IS NULL AND d.delivered_at IS NULL AND d.receipt IS NULL AND (d.claim_owner IS NULL OR d.claim_expires_at <= ?)`).all(destination, now.toISOString())) {
706
+ const row = readNotification(raw);
707
+ if (!this.store.pingAllowed(row, binding.approver)) {
708
+ this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_QUIET}' WHERE notification = ? AND destination = ?`).run(row.id, destination);
709
+ }
710
+ }
711
+ if (this.store.telegramRetryAt(binding.botId) > now.toISOString())
712
+ return [];
713
+ const rows = this.db.prepare(`SELECT n.*, d.claim_generation FROM notification n
714
+ JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
715
+ WHERE n.resolved_at IS NULL AND ${TELEGRAM_UNSETTLED}
716
+ AND (d.claim_owner IS NULL OR d.claim_expires_at <= ?)
717
+ AND (d.next_attempt_at IS NULL OR d.next_attempt_at <= ?)
718
+ AND (? = 'all' OR d.attempts > 0 OR n.dedupe_key LIKE 'decision:%' OR n.push_class = 'attention' OR n.kind IN ('acceptance-evidence', 'acceptance-ready')
719
+ OR EXISTS (SELECT 1 FROM notification urgent
720
+ WHERE urgent.task_ref = n.task_ref AND urgent.id > n.id AND urgent.resolved_at IS NULL
721
+ AND (urgent.dedupe_key LIKE 'decision:%' OR urgent.push_class = 'attention' OR urgent.kind IN ('acceptance-evidence', 'acceptance-ready'))))
722
+ ORDER BY n.id`).all(destination, now.toISOString(), now.toISOString(), only);
723
+ return rows.map(row => {
724
+ this.db.prepare(`UPDATE notification_delivery SET claim_owner = ?, claim_expires_at = ?,
725
+ claim_generation = claim_generation + 1 WHERE notification = ? AND destination = ?`)
726
+ .run(owner, new Date(now.getTime() + ttlMs).toISOString(), Number(row["id"]), destination);
727
+ return { ...readNotification(row), destination, claimGeneration: Number(row["claim_generation"]) + 1 };
728
+ });
729
+ });
730
+ }
731
+ /** Synchronous final fence, called AFTER each asynchronous enrollment read. */
732
+ deliveryProblem(row, binding, owner, projects, now, batch = [row.id]) {
733
+ const live = this.store.liveTelegramBindingById(binding.id);
734
+ if (live === null || live.approverGeneration !== binding.approverGeneration ||
735
+ this.store.accountOf(binding.approver)?.role !== "approver")
736
+ return TELEGRAM_HOLD_REASONS.authority;
737
+ if (row.destination !== this.destination(live))
738
+ return TELEGRAM_HOLD_REASONS.destination;
739
+ if (!this.claimHeld(row, owner, now))
740
+ return TELEGRAM_HOLD_REASONS.claim;
741
+ if (this.store.telegramRetryAt(binding.botId) > now.toISOString())
742
+ return TELEGRAM_HOLD_REASONS.rateLimit;
743
+ const current = this.db.prepare("SELECT * FROM notification WHERE id = ?").get(row.id);
744
+ if (current === undefined || current["resolved_at"] !== null)
745
+ return TELEGRAM_HOLD_REASONS.resolved;
746
+ if (row.scope === "unknown")
747
+ return TELEGRAM_HOLD_REASONS.provenance;
748
+ if (row.scope === "installation") {
749
+ // One addressed to this person (their own budget, v105) reaches them; the rest of the installation's are an operator's.
750
+ if (!this.store.isInstanceOperator(binding.approver) && current["recipient"] !== binding.approver)
751
+ return TELEGRAM_HOLD_REASONS.installation;
752
+ }
753
+ else {
754
+ if (row.project === null || !projects.includes(row.project) || !this.store.accountCanAccess(binding.approver, row.project))
755
+ return TELEGRAM_HOLD_REASONS.project;
756
+ if (row.scope === "task") {
757
+ const ref = this.db.prepare("SELECT repo, external_id FROM task_ref WHERE id = ?").get(row.taskRef);
758
+ if (ref === undefined || ref["repo"] !== row.project || ref["external_id"] !== row.taskId ||
759
+ (row.run !== null && this.store.getRun(row.run)?.taskRef !== row.taskRef))
760
+ return TELEGRAM_HOLD_REASONS.task;
761
+ }
762
+ }
763
+ if (row.taskRef !== null) {
764
+ // Skipped history never fences what follows it: it is settled.
765
+ const earlier = this.db.prepare(`SELECT n.id FROM notification n
766
+ LEFT JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
767
+ WHERE n.task_ref = ? AND n.id < ? AND n.resolved_at IS NULL AND ${TELEGRAM_UNSETTLED}`)
768
+ .all(row.destination, row.taskRef, row.id);
769
+ if (earlier.some(one => !batch.includes(Number(one["id"]))))
770
+ return TELEGRAM_HOLD_REASONS.order;
771
+ }
772
+ return null;
773
+ }
774
+ claimHeld(row, owner, now) {
775
+ return this.db.prepare(`SELECT 1 FROM notification_delivery WHERE notification = ? AND destination = ?
776
+ AND claim_owner = ? AND claim_generation = ? AND claim_expires_at > ? AND delivered_at IS NULL`)
777
+ .get(row.id, row.destination, owner, row.claimGeneration, now.toISOString()) !== undefined;
778
+ }
779
+ /** A confirmed network message remains history even if authority changed
780
+ * while it was in flight. It only binds the OLD destination, never new work. */
781
+ recordMessage(row, binding, messageId, now) {
782
+ if (row.destination !== this.destination(binding))
783
+ throw new Error("Telegram message destination mismatch");
784
+ this.prepare(`INSERT OR IGNORE INTO chat_message_ref
785
+ (provider, binding, chat, message, kind, notification, destination, project, task_ref, task_id, run, created)
786
+ VALUES (:provider, ?, ?, ?, 'notification', ?, ?, ?, ?, ?, ?, ?)`)
787
+ .run(binding.id, binding.chatId, messageId, row.id, row.destination, row.project, row.taskRef, row.taskId, row.run, now.toISOString());
788
+ }
789
+ /** The newest confirmed message this notification became at one destination, if any. */
790
+ messageOf(notification, destination) {
791
+ const row = this.prepare("SELECT message FROM chat_message_ref WHERE provider = :provider AND kind = 'notification' AND notification = ? AND destination = ? ORDER BY CAST(message AS INTEGER) DESC LIMIT 1").get(notification, destination);
792
+ return row === undefined ? null : String(row["message"]);
793
+ }
794
+ finalizeDelivery(row, binding, owner, outcome, now) {
795
+ return this.store.transact(() => {
796
+ if (!this.claimHeld(row, owner, now) || row.destination !== this.destination(binding))
797
+ return false;
798
+ if (outcome.ok && (this.store.liveTelegramBindingById(binding.id) === null || this.store.accountOf(binding.approver)?.role !== "approver"))
799
+ return false;
800
+ // A `skipped:` receipt settles the row without a delivery: nothing
801
+ // reached the phone, so delivered_at stays empty exactly as it does
802
+ // for the pairing skip (TELEGRAM_UNSETTLED treats both as settled).
803
+ const skipped = outcome.ok && outcome.receipt !== null && outcome.receipt.startsWith("skipped:");
804
+ this.db.prepare(`UPDATE notification_delivery SET attempts = attempts + 1, last_attempt_at = ?,
805
+ delivered_at = ?, receipt = ?, last_error = ?, next_attempt_at = ?, claim_owner = NULL, claim_expires_at = NULL
806
+ WHERE notification = ? AND destination = ? AND claim_owner = ? AND claim_generation = ?`)
807
+ .run(now.toISOString(), outcome.ok && !skipped ? now.toISOString() : null, outcome.ok ? outcome.receipt : null, outcome.ok ? null : outcome.error, outcome.ok ? null : outcome.retryAt ?? new Date(now.getTime() + 1_000).toISOString(), row.id, row.destination, owner, row.claimGeneration);
808
+ return true;
809
+ });
810
+ }
811
+ /** The notifications this pairing holds, each with its delivery state there. */
812
+ deliveries(binding) {
813
+ return this.db.prepare(`SELECT n.*, d.attempts, d.last_attempt_at, d.last_error, d.delivered_at, d.receipt
814
+ FROM notification n JOIN notification_delivery d ON d.notification = n.id WHERE d.destination = ? ORDER BY n.id`)
815
+ .all(this.destination(binding)).map(readNotificationReceipt);
816
+ }
817
+ /** The digest cadence: null everyMs = off. */
818
+ digest() {
819
+ const row = this.prepare("SELECT every_ms, set_by, set_at, last_sent_at FROM chat_digest WHERE provider = :provider").get();
820
+ return {
821
+ everyMs: row?.["every_ms"] === null || row?.["every_ms"] === undefined ? null : Number(row["every_ms"]),
822
+ setBy: row?.["set_by"] === null || row?.["set_by"] === undefined ? null : String(row["set_by"]),
823
+ setAt: row?.["set_at"] === null || row?.["set_at"] === undefined ? null : String(row["set_at"]),
824
+ lastSentAt: row?.["last_sent_at"] === null || row?.["last_sent_at"] === undefined ? null : String(row["last_sent_at"]),
825
+ };
826
+ }
827
+ /** Set (or clear, with null) the cadence. Turning it on starts the
828
+ * window NOW — nothing held before the choice is dumped at once. */
829
+ setDigest(everyMs, by, now) {
830
+ const chat = this;
831
+ chat.prepare("INSERT OR IGNORE INTO chat_digest (provider) VALUES (:provider)").run();
832
+ chat
833
+ .prepare("UPDATE chat_digest SET every_ms = ?, set_by = ?, set_at = ?, last_sent_at = CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(last_sent_at, ?) END WHERE provider = :provider")
834
+ .run(everyMs, by, now.toISOString(), everyMs, now.toISOString());
835
+ }
836
+ markDigestSent(now) {
837
+ const chat = this;
838
+ chat.prepare("INSERT OR IGNORE INTO chat_digest (provider) VALUES (:provider)").run();
839
+ chat.prepare("UPDATE chat_digest SET last_sent_at = ? WHERE provider = :provider").run(now.toISOString());
840
+ }
841
+ }