toolroll 0.9.16 → 0.9.18

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 (70) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/assignment-brief.js +11 -4
  3. package/dist/browser/workspace.js +14 -14
  4. package/dist/browser-workspace.d.ts +2 -0
  5. package/dist/chat-actions.d.ts +7 -0
  6. package/dist/chat-actions.js +40 -4
  7. package/dist/chat-ask.js +29 -15
  8. package/dist/chat-channel.js +3 -1
  9. package/dist/chat-decide.d.ts +203 -0
  10. package/dist/chat-decide.js +554 -0
  11. package/dist/chat-delivery-state.d.ts +7 -0
  12. package/dist/chat-delivery.d.ts +3 -0
  13. package/dist/chat-delivery.js +63 -17
  14. package/dist/chat-quiet.d.ts +4 -1
  15. package/dist/chat-quiet.js +6 -2
  16. package/dist/chat-warmth.d.ts +21 -0
  17. package/dist/chat-warmth.js +36 -0
  18. package/dist/desktop-update.d.ts +1 -1
  19. package/dist/discord-api.d.ts +3 -1
  20. package/dist/discord-api.js +4 -2
  21. package/dist/discord-chat.d.ts +3 -2
  22. package/dist/discord-chat.js +35 -6
  23. package/dist/lead-about.d.ts +35 -0
  24. package/dist/lead-about.js +62 -0
  25. package/dist/lead-capabilities.d.ts +64 -0
  26. package/dist/lead-capabilities.js +199 -0
  27. package/dist/lead-commitments.js +13 -7
  28. package/dist/lead-context.d.ts +5 -3
  29. package/dist/lead-context.js +29 -7
  30. package/dist/lead-follow.d.ts +1 -1
  31. package/dist/lead-follow.js +3 -2
  32. package/dist/lead-identity.js +1 -1
  33. package/dist/lead-people.d.ts +55 -0
  34. package/dist/lead-people.js +128 -0
  35. package/dist/lead-voice.d.ts +3 -2
  36. package/dist/lead-voice.js +4 -3
  37. package/dist/mate-cli.d.ts +3 -0
  38. package/dist/mate-cli.js +15 -14
  39. package/dist/mate-contract.d.ts +1 -1
  40. package/dist/mate-contract.js +6 -4
  41. package/dist/mate-progress.js +2 -0
  42. package/dist/mate-tools.d.ts +8 -0
  43. package/dist/mate-tools.js +112 -6
  44. package/dist/mate.d.ts +26 -1
  45. package/dist/mate.js +89 -25
  46. package/dist/modes.d.ts +6 -0
  47. package/dist/modes.js +6 -0
  48. package/dist/operate.js +20 -8
  49. package/dist/phase-routing.d.ts +1 -1
  50. package/dist/project-delete.js +3 -0
  51. package/dist/provider-auth.d.ts +4 -0
  52. package/dist/provider-auth.js +1 -1
  53. package/dist/reply-shape.d.ts +96 -0
  54. package/dist/reply-shape.js +452 -0
  55. package/dist/result-shots.d.ts +51 -0
  56. package/dist/result-shots.js +119 -0
  57. package/dist/serve.js +76 -49
  58. package/dist/slack-chat.d.ts +2 -0
  59. package/dist/slack-chat.js +38 -7
  60. package/dist/store.d.ts +36 -3
  61. package/dist/store.js +163 -16
  62. package/dist/team-runtime.js +1 -1
  63. package/dist/teams-chat.js +17 -1
  64. package/dist/telegram-mate.d.ts +12 -0
  65. package/dist/telegram-mate.js +27 -11
  66. package/dist/telegram-team.js +6 -3
  67. package/dist/telegram.d.ts +5 -3
  68. package/dist/telegram.js +227 -21
  69. package/dist/test-temp.js +1 -1
  70. package/package.json +1 -1
package/dist/store.js CHANGED
@@ -148,7 +148,8 @@ CREATE TABLE IF NOT EXISTS notification_preference (
148
148
  digest_at TEXT CHECK (digest_at IS NULL OR (length(digest_at) = 5 AND digest_at GLOB '[0-2][0-9]:[0-5][0-9]')),
149
149
  digest_on TEXT,
150
150
  updated_at TEXT NOT NULL,
151
- updated_by TEXT NOT NULL
151
+ updated_by TEXT NOT NULL,
152
+ screenshots TEXT NOT NULL DEFAULT 'off' CHECK (screenshots IN ('off', 'first', 'all'))
152
153
  );
153
154
  CREATE TABLE IF NOT EXISTS chat_card (
154
155
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -183,14 +184,28 @@ CREATE TABLE IF NOT EXISTS chat_batch_item (
183
184
  PRIMARY KEY (batch, task_ref)
184
185
  );
185
186
  `;
187
+ /** Screenshots with results (no version bump: additive only): each saved screenshot a destination was sent with a
188
+ * result, marked before the upload so a retry never sends it twice. No foreign keys: a deleted project's runs take
189
+ * their rows with them (project-delete.ts). */
190
+ const RESULT_SHOT_SCHEMA = `
191
+ CREATE TABLE IF NOT EXISTS result_shot_sent (
192
+ destination TEXT NOT NULL,
193
+ run INTEGER NOT NULL,
194
+ artifact INTEGER NOT NULL,
195
+ sent_at TEXT NOT NULL,
196
+ PRIMARY KEY (destination, run, artifact)
197
+ );
198
+ `;
186
199
  /** Settings → Lead (no version bump: additive only): what one person calls their lead and the short persona it
187
- * speaks with. No row reads as the defaults (lead-identity.ts). */
200
+ * speaks with, and what their lead knows about them (`about_json`: the confirmed lines, lead-about.ts). No row reads
201
+ * as the defaults (lead-identity.ts). */
188
202
  const LEAD_CONFIG_SCHEMA = `
189
203
  CREATE TABLE IF NOT EXISTS lead_config (
190
204
  account TEXT PRIMARY KEY,
191
205
  name TEXT NOT NULL,
192
206
  persona TEXT NOT NULL,
193
- updated_at TEXT NOT NULL
207
+ updated_at TEXT NOT NULL,
208
+ about_json TEXT NOT NULL DEFAULT '[]'
194
209
  );
195
210
  `;
196
211
  /** Pings follow responsibility (no version bump: additive only). `lead_credential` is a lead token: an agent acting
@@ -293,9 +308,45 @@ CREATE TABLE IF NOT EXISTS mate_ask (
293
308
  );
294
309
  CREATE INDEX IF NOT EXISTS mate_ask_thread ON mate_ask (thread, turn);
295
310
  `;
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
+ * what the card showed — a receipt, a revision source, a task stamp, a plan or a commit. */
313
+ export const CHAT_DECIDE_SCHEMA = `
314
+ CREATE TABLE IF NOT EXISTS chat_decide_action (
315
+ token TEXT PRIMARY KEY,
316
+ channel TEXT NOT NULL,
317
+ binding INTEGER NOT NULL,
318
+ chat TEXT NOT NULL,
319
+ message TEXT,
320
+ act TEXT NOT NULL CHECK (act IN ('accept','changes','retry','approve','not-now','merge')),
321
+ phase TEXT NOT NULL CHECK (phase IN ('offer','yes','cancel')),
322
+ task_id TEXT NOT NULL,
323
+ run INTEGER,
324
+ digest TEXT NOT NULL,
325
+ created_at TEXT NOT NULL,
326
+ expires_at TEXT NOT NULL,
327
+ consumed_at TEXT
328
+ );
329
+ CREATE INDEX IF NOT EXISTS chat_decide_action_message ON chat_decide_action (channel, chat, message);
330
+ CREATE TABLE IF NOT EXISTS chat_decide_prompt (
331
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
332
+ channel TEXT NOT NULL,
333
+ binding INTEGER NOT NULL,
334
+ chat TEXT NOT NULL,
335
+ task_id TEXT NOT NULL,
336
+ run INTEGER NOT NULL,
337
+ digest TEXT NOT NULL,
338
+ message TEXT,
339
+ created_at TEXT NOT NULL,
340
+ expires_at TEXT NOT NULL,
341
+ consumed_at TEXT
342
+ );
343
+ CREATE INDEX IF NOT EXISTS chat_decide_prompt_open ON chat_decide_prompt (channel, binding, consumed_at);
344
+ `;
296
345
  export const MATE_ASK_OTHER = "Something else";
297
346
  /** How long the question's buttons work, on every channel. */
298
347
  export const MATE_ASK_TTL_MS = 7 * 86_400_000;
348
+ /** What a turn the lead starts itself for automatic crew updates says in the owner's place (lead-follow.ts): never their answer. */
349
+ export const LEAD_FOLLOW_MESSAGE = "Automatic crew update: inspect the saved results or decisions and tell me what needs attention. Do not rerun work.";
299
350
  /** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
300
351
  * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
301
352
  * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
@@ -767,6 +818,9 @@ const LIFECYCLE_HOLD_WORDS = {
767
818
  revision: "a plan revision awaiting approval",
768
819
  stop: "a stopped attempt that has not been resumed",
769
820
  };
821
+ export const RESULT_SCREENSHOTS = ["off", "first", "all"];
822
+ /** One person's saved screenshots following a result message: its own outbox row, addressed to them (result-shots.ts). */
823
+ export const RESULT_SHOTS_KIND = "result-screenshots";
770
824
  /** Tasks filed this close together share one chat message. */
771
825
  export const QUIET_GROUP_MS = 60_000;
772
826
  export function isDigestTime(value) {
@@ -3882,6 +3936,8 @@ function initializeStore(db, file) {
3882
3936
  db.exec(MONITORING_SCHEMA);
3883
3937
  db.exec(INTEGRATIONS_SCHEMA);
3884
3938
  db.exec(MATE_ASK_SCHEMA);
3939
+ // Decisions in the chat app (no version bump: additive only): result, plan and merge buttons, and Request changes.
3940
+ db.exec(CHAT_DECIDE_SCHEMA);
3885
3941
  db.exec(SPEND_SCHEMA);
3886
3942
  // Sign-in pauses: one row per incident of a provider's sign-in no longer working.
3887
3943
  db.exec(PROVIDER_AUTH_SCHEMA);
@@ -3897,10 +3953,15 @@ function initializeStore(db, file) {
3897
3953
  db.exec(POLICY_SCHEMA);
3898
3954
  db.exec(RUN_CHECK_SCHEMA);
3899
3955
  db.exec(QUIET_CHAT_SCHEMA);
3956
+ // Screenshots with results: each person's choice (an older notification_preference gains it), and what was sent.
3957
+ addColumn(db, "notification_preference", "screenshots", "TEXT NOT NULL DEFAULT 'off' CHECK (screenshots IN ('off', 'first', 'all'))");
3958
+ db.exec(RESULT_SHOT_SCHEMA);
3900
3959
  // The update a batch line speaks for: its newest fact about that task (null: a finished result).
3901
3960
  addColumn(db, "chat_batch_item", "notification", "INTEGER");
3902
3961
  db.exec(LEAD_QUIET_SCHEMA);
3903
3962
  db.exec(LEAD_CONFIG_SCHEMA);
3963
+ // What a person's lead knows about them, beside its name and persona (an older lead_config gains it).
3964
+ addColumn(db, "lead_config", "about_json", "TEXT NOT NULL DEFAULT '[]'");
3904
3965
  db.exec(LEAD_COMMITMENT_SCHEMA);
3905
3966
  mergeCheckTables(db);
3906
3967
  addColumn(db, "monitoring_status", "target", "TEXT");
@@ -13561,6 +13622,7 @@ export class Store {
13561
13622
  reason: String(row["reason"]),
13562
13623
  runner: String(row["runner"]),
13563
13624
  observedAt: String(row["observed_at"]),
13625
+ probe: String(row["probe"]),
13564
13626
  };
13565
13627
  };
13566
13628
  }
@@ -15137,6 +15199,11 @@ export class Store {
15137
15199
  if (Number(changes) > 0 && actor !== null) {
15138
15200
  this.db.prepare("INSERT OR IGNORE INTO notification_actor (notification, account, lead) VALUES (?, ?, ?)").run(Number(lastInsertRowid), actor.account, actor.lead ? 1 : 0);
15139
15201
  }
15202
+ if (Number(changes) > 0 && run !== null && notification.recipient == null) {
15203
+ const made = this.notificationById(Number(lastInsertRowid));
15204
+ if (made !== null)
15205
+ this.enqueueResultShots(made, now);
15206
+ }
15140
15207
  return Number(changes) > 0;
15141
15208
  }
15142
15209
  /**
@@ -19709,8 +19776,9 @@ export class Store {
19709
19776
  const options = JSON.parse(String(row["options_json"]));
19710
19777
  return { turn: Number(row["turn"]), thread: Number(row["thread"]), question: String(row["question"]), options: Array.isArray(options) ? options.map(String) : [], createdAt: String(row["created_at"]) };
19711
19778
  }
19712
- /** What a tap on the question finds: open; answered (the owner has written in its thread since the reply that asked it);
19713
- * or expired (older than MATE_ASK_TTL_MS, or the question or the reply that asked it is gone, as after a retention purge). */
19779
+ /** What a tap on the question finds: open; answered (its owner has written in its thread since the reply that asked
19780
+ * it, typed or tapped; a turn the lead started itself, or another person's message, is no answer); or expired (older
19781
+ * than MATE_ASK_TTL_MS, or the question or the reply that asked it is gone, as after a retention purge). */
19714
19782
  mateAskState(turn, now) {
19715
19783
  const ask = this.mateAsk(turn);
19716
19784
  if (ask === null)
@@ -19718,7 +19786,10 @@ export class Store {
19718
19786
  const asking = this.db.prepare("SELECT MAX(id) AS id FROM mate_message WHERE thread = ? AND turn = ? AND role = 'assistant'").get(ask.thread, turn);
19719
19787
  if (asking === undefined || asking["id"] === null)
19720
19788
  return { state: "expired" };
19721
- const later = this.db.prepare("SELECT 1 AS hit FROM mate_message WHERE thread = ? AND role = 'operator' AND id > ? LIMIT 1").get(ask.thread, Number(asking["id"]));
19789
+ const owner = String(this.getMateTurn(turn).approver);
19790
+ const later = this.db.prepare(`SELECT 1 AS hit FROM mate_message m LEFT JOIN mate_turn t ON t.id = m.turn LEFT JOIN team_message tm ON tm.message = m.id
19791
+ WHERE m.thread = ? AND m.role = 'operator' AND m.id > ? AND m.text <> ? AND COALESCE(tm.author, t.approver, ?) = ? LIMIT 1`)
19792
+ .get(ask.thread, Number(asking["id"]), LEAD_FOLLOW_MESSAGE, owner, owner);
19722
19793
  if (later !== undefined)
19723
19794
  return { state: "answered" };
19724
19795
  if (new Date(ask.createdAt).getTime() + MATE_ASK_TTL_MS <= now.getTime())
@@ -22217,23 +22288,69 @@ export class Store {
22217
22288
  // ---- quiet chat: each person's notification choice and each task's one message ----
22218
22289
  /** How chats reach this person: only when they are needed unless they chose every step. */
22219
22290
  notificationPreference(account) {
22220
- const row = this.db.prepare("SELECT mode, digest_at, digest_on, updated_at FROM notification_preference WHERE account = ?").get(account);
22221
- return row === undefined ? { mode: "quiet", digestAt: null, digestOn: null, updatedAt: null }
22291
+ const row = this.db.prepare("SELECT mode, digest_at, digest_on, updated_at, screenshots FROM notification_preference WHERE account = ?").get(account);
22292
+ return row === undefined ? { mode: "quiet", digestAt: null, digestOn: null, updatedAt: null, screenshots: "off" }
22222
22293
  : { mode: row["mode"] === "all" ? "all" : "quiet", digestAt: row["digest_at"] == null ? null : String(row["digest_at"]),
22223
- digestOn: row["digest_on"] == null ? null : String(row["digest_on"]), updatedAt: String(row["updated_at"]) };
22294
+ digestOn: row["digest_on"] == null ? null : String(row["digest_on"]), updatedAt: String(row["updated_at"]),
22295
+ screenshots: row["screenshots"] === "first" || row["screenshots"] === "all" ? row["screenshots"] : "off" };
22224
22296
  }
22225
22297
  /** Change one person's choice; a digest time is a local HH:MM, or null for no evening digest. */
22226
22298
  setNotificationPreference(account, change, by, now) {
22227
22299
  if (change.digestAt != null && !isDigestTime(change.digestAt))
22228
22300
  throw new Error("digest time must be HH:MM");
22301
+ if (change.screenshots !== undefined && !RESULT_SCREENSHOTS.includes(change.screenshots))
22302
+ throw new Error("screenshots must be off, first or all");
22229
22303
  const current = this.notificationPreference(account);
22230
22304
  const mode = change.mode ?? current.mode;
22231
22305
  const digestAt = change.digestAt === undefined ? current.digestAt : change.digestAt;
22232
- this.db.prepare(`INSERT INTO notification_preference (account, mode, digest_at, digest_on, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?)
22233
- ON CONFLICT (account) DO UPDATE SET mode = excluded.mode, digest_at = excluded.digest_at, updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
22234
- .run(account, mode, digestAt, current.digestOn, now.toISOString(), by);
22306
+ const screenshots = change.screenshots ?? current.screenshots;
22307
+ this.db.prepare(`INSERT INTO notification_preference (account, mode, digest_at, digest_on, updated_at, updated_by, screenshots) VALUES (?, ?, ?, ?, ?, ?, ?)
22308
+ ON CONFLICT (account) DO UPDATE SET mode = excluded.mode, digest_at = excluded.digest_at, updated_at = excluded.updated_at, updated_by = excluded.updated_by,
22309
+ screenshots = excluded.screenshots`)
22310
+ .run(account, mode, digestAt, current.digestOn, now.toISOString(), by, screenshots);
22235
22311
  return this.notificationPreference(account);
22236
22312
  }
22313
+ // ---- screenshots with results (result-shots.ts) ----
22314
+ /** The screenshots of this result already sent (or being sent) to one destination. */
22315
+ resultShotsSent(destination, run) {
22316
+ return new Set(this.db.prepare("SELECT artifact FROM result_shot_sent WHERE destination = ? AND run = ?").all(destination, run).map(row => Number(row["artifact"])));
22317
+ }
22318
+ /** Mark screenshots as sent BEFORE they go: an unconfirmed send is never repeated. */
22319
+ markResultShotsSent(destination, run, artifacts, now) {
22320
+ const insert = this.db.prepare("INSERT OR IGNORE INTO result_shot_sent (destination, run, artifact, sent_at) VALUES (?, ?, ?, ?)");
22321
+ this.transact(() => { for (const artifact of artifacts)
22322
+ insert.run(destination, run, artifact, now.toISOString()); });
22323
+ }
22324
+ /** Telegram refused them outright, so nothing arrived: they may be tried again. */
22325
+ unmarkResultShotsSent(destination, run, artifacts) {
22326
+ const remove = this.db.prepare("DELETE FROM result_shot_sent WHERE destination = ? AND run = ? AND artifact = ?");
22327
+ this.transact(() => { for (const artifact of artifacts)
22328
+ remove.run(destination, run, artifact); });
22329
+ }
22330
+ /**
22331
+ * A result message just became a fact: everyone who asked for screenshots with results, can see the project and
22332
+ * would be pinged by it gets one row of their own that follows it — once per result, whatever produced the fact.
22333
+ * Only a result that saved screenshots qualifies; what is sent, and whether at all, is decided again at send time.
22334
+ */
22335
+ enqueueResultShots(source, now) {
22336
+ if (source.run === null || source.taskRef === null || source.project === null || source.recipient !== null)
22337
+ return;
22338
+ const ready = isLifecycleNotification(source) && source.kind === "run-finished";
22339
+ if (!ready && !/fail/.test(source.kind))
22340
+ return;
22341
+ const run = this.getRun(source.run);
22342
+ if (run === null || !["builder", "repair"].includes(run.role) || run.contestant !== null)
22343
+ return;
22344
+ if (!this.artifactsFor(run.id).some(one => one.kind === "screenshot"))
22345
+ return;
22346
+ const people = this.db.prepare("SELECT account FROM notification_preference WHERE screenshots IN ('first', 'all') ORDER BY account").all().map(row => String(row["account"]));
22347
+ for (const account of people) {
22348
+ if (!this.accountCanAccess(account, source.project) || !this.pingAllowed(source, account))
22349
+ continue;
22350
+ this.enqueueNotification({ dedupeKey: `result-shots:r${run.id}:${account}`, kind: RESULT_SHOTS_KIND, subject: "Screenshots with this result", body: "",
22351
+ ...(source.link === null ? {} : { link: source.link }), source: { run: run.id }, recipient: account }, now);
22352
+ }
22353
+ }
22237
22354
  /** Everyone who asked for an evening digest, with the local day theirs was last considered. */
22238
22355
  eveningDigestPeople() {
22239
22356
  return this.db.prepare("SELECT account, digest_at, digest_on FROM notification_preference WHERE digest_at IS NOT NULL ORDER BY account").all()
@@ -22245,9 +22362,10 @@ export class Store {
22245
22362
  .run(day, account, day).changes) === 1;
22246
22363
  }
22247
22364
  // ---- pings follow responsibility: the lead, who acted, replacements and muted projects ----
22248
- /** What this person named their lead and its persona, or null when they never saved one. */
22365
+ /** What this person named their lead and its persona, or null when they never saved one. An empty name or persona
22366
+ * (a row saved only for what the lead knows about them) reads as the default. */
22249
22367
  leadConfig(account) {
22250
- const row = this.db.prepare("SELECT name, persona FROM lead_config WHERE account = ?").get(account);
22368
+ const row = this.db.prepare("SELECT name, persona FROM lead_config WHERE account = ? AND (name <> '' OR persona <> '')").get(account);
22251
22369
  return row === undefined ? null : { name: String(row["name"]), persona: String(row["persona"]) };
22252
22370
  }
22253
22371
  /** Save this person's lead name and persona (checked by the caller: lead-identity.ts). */
@@ -22255,6 +22373,25 @@ export class Store {
22255
22373
  this.db.prepare(`INSERT INTO lead_config (account, name, persona, updated_at) VALUES (?, ?, ?, ?)
22256
22374
  ON CONFLICT(account) DO UPDATE SET name = excluded.name, persona = excluded.persona, updated_at = excluded.updated_at`).run(account, name, persona, now.toISOString());
22257
22375
  }
22376
+ /** What this person's lead knows about them: their confirmed lines, in order (none when they never saved any). */
22377
+ leadAbout(account) {
22378
+ const row = this.db.prepare("SELECT about_json FROM lead_config WHERE account = ?").get(account);
22379
+ if (row === undefined)
22380
+ return [];
22381
+ try {
22382
+ const lines = JSON.parse(String(row["about_json"]));
22383
+ return Array.isArray(lines) ? lines.filter((one) => typeof one === "string") : [];
22384
+ }
22385
+ catch {
22386
+ return [];
22387
+ }
22388
+ }
22389
+ /** Save what this person's lead knows about them (checked by the caller: lead-about.ts). A person who never named
22390
+ * their lead gets an empty name and persona, so they keep following the defaults. */
22391
+ setLeadAbout(account, lines, now) {
22392
+ this.db.prepare(`INSERT INTO lead_config (account, name, persona, updated_at, about_json) VALUES (?, '', '', ?, ?)
22393
+ ON CONFLICT(account) DO UPDATE SET about_json = excluded.about_json, updated_at = excluded.updated_at`).run(account, now.toISOString(), JSON.stringify(lines));
22394
+ }
22258
22395
  /** A lead token for one person, shown once. A new one ends their earlier ones; the ledger names it "lead for <owner>". */
22259
22396
  mintLeadCredential(owner, by, now) {
22260
22397
  return this.transact(() => {
@@ -22492,6 +22629,12 @@ export class Store {
22492
22629
  setChatBatchMessage(id, message, digest) {
22493
22630
  this.db.prepare("UPDATE chat_batch SET message = ?, digest = ? WHERE id = ?").run(message, digest, id);
22494
22631
  }
22632
+ /** The finished-work message that carries this result at one destination, if one was placed: screenshots follow it. */
22633
+ chatBatchMessageFor(destination, taskRef, run) {
22634
+ const row = this.db.prepare(`SELECT b.message FROM chat_batch b JOIN chat_batch_item i ON i.batch = b.id
22635
+ WHERE b.destination = ? AND i.task_ref = ? AND (i.run = ? OR i.run IS NULL) AND b.message IS NOT NULL ORDER BY b.id DESC LIMIT 1`).get(destination, taskRef, run);
22636
+ return row === undefined ? null : String(row["message"]);
22637
+ }
22495
22638
  /** The message now showing this card, and what it shows. */
22496
22639
  setChatCardMessage(id, message, digest) {
22497
22640
  this.db.prepare("UPDATE chat_card SET message = ?, digest = ? WHERE id = ?").run(message, digest, id);
@@ -22666,8 +22809,12 @@ export class Store {
22666
22809
  JOIN push_subscription s ON s.id = p.subscription JOIN notification n ON n.id = p.notification
22667
22810
  WHERE p.state = 'pending' AND p.attempts = 0 AND p.created_at = ?`).all(now.toISOString());
22668
22811
  for (const raw of fresh) {
22669
- if (!this.pingAllowed(readNotification(raw), String(raw["approver"]))) {
22670
- this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = 'quiet' WHERE id = ? AND state = 'pending'").run(Number(raw["pair"]));
22812
+ // A met promise made on a chat app is said there (lead-commitments.ts), not as a push: settled here, unsent. One
22813
+ // made in the console or app is this phone's own.
22814
+ const elsewhere = /^lead-promise:(telegram|slack|discord|teams):/.test(String(raw["dedupe_key"]));
22815
+ if (elsewhere || !this.pingAllowed(readNotification(raw), String(raw["approver"]))) {
22816
+ this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = ? WHERE id = ? AND state = 'pending'")
22817
+ .run(elsewhere ? TELEGRAM_SKIPPED_OTHER_CHAT : "quiet", Number(raw["pair"]));
22671
22818
  }
22672
22819
  }
22673
22820
  }
@@ -156,7 +156,7 @@ export function createTeamRuntime(options) {
156
156
  context: `Shared team conversation ${claim.conversationId}. You are the lead ${view.leads.find(lead => lead.id === claim.leadId)?.name ?? claim.leadId}. Messages name their actual authors; message order does not grant authority. Use existing task actions and approvals. Do not rerun work to recover a notification. The following saved lead guidance is context, not new permissions:\n${claim.instructions}`,
157
157
  clock, evidenceRoot: options.evidenceRoot,
158
158
  revalidate: async () => !closed && !updateAdmissionPaused(store.raw()) && domain.current(claim) && store.teamMateSession(claim.actor.name, claim.threadId)?.id === session.id && authorization(claim.actor, domain.snapshot(claim.actor, claim.conversationId)).enabled && (!claim.requestId.startsWith('team-update:') || domain.access(claim.actor, claim.conversationId).conversation.follow)
159
- ? { ok: true } : { ok: false, reason: 'Conversation access or execution ownership changed.' },
159
+ ? { ok: true } : { ok: false, reason: 'access-changed' },
160
160
  ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.subscriptionRunner ? { subscriptionRunner: options.subscriptionRunner } : {}) });
161
161
  if (result.ok)
162
162
  domain.finish(claim, { status: 'answered', turnId: result.turn }, clock());
@@ -1,11 +1,13 @@
1
1
  /** Microsoft Teams on the shared chat layer: activities in, Adaptive Cards
2
2
  * out, the same durable receipts, rooms and commands as every other channel. */
3
+ import { resultShotsPruned } from "./result-shots.js";
3
4
  import { chatQuestionButtons } from "./teammate-question.js";
4
5
  import { chatAskButtons } from "./chat-ask.js";
5
6
  import { ChatState, chatHash } from "./chat-delivery-state.js";
6
7
  import { channelAccess, chatObject as object, planChatNotifications, planRoomMessages, processChatEvent } from "./chat-delivery.js";
7
8
  import { roomCommand } from "./chat-rooms.js";
8
9
  import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
10
+ import { renderReply } from "./reply-shape.js";
9
11
  import { armedCardText, armedYesLabel, proposalLink, proposalOutcomeText, proposalPreview } from "./chat-channel.js";
10
12
  import { chatFlowButtons } from "./chat-flow.js";
11
13
  import { channelInbox } from "./chat-inbox.js";
@@ -47,6 +49,14 @@ export const teamsDelivery = (options) => {
47
49
  state,
48
50
  label: "Teams",
49
51
  member: (member, channel) => teamsMember(options.api, state, options.identity.installation, member, channel),
52
+ // "Typing…" while the lead works; Teams gives bots no reactions, so there is no 👍 here.
53
+ warm: (event) => ({
54
+ typing: async () => {
55
+ const serviceUrl = state.meta(options.identity.installation, serviceKey(event.channel));
56
+ if (serviceUrl !== null)
57
+ await options.api("POST", serviceUrl, `/v3/conversations/${encodeURIComponent(event.channel)}/activities`, { type: "typing" });
58
+ },
59
+ }),
50
60
  partSize: 3500,
51
61
  maxProposal: 6000,
52
62
  };
@@ -166,6 +176,10 @@ export async function deliverTeamsPart(options) {
166
176
  if (content.task && !repos.includes(store.lookupRef(content.task)?.repo ?? ""))
167
177
  throw new TeamsError("Connected projects changed");
168
178
  let text = content.text, actions = [];
179
+ if (content.image && content.shot && resultShotsPruned(options.evidenceRoot, content.image.run)) {
180
+ state.prepare("UPDATE chat_part SET state='dropped',problem='Removed by retention' WHERE id=?").run(row.id);
181
+ return true;
182
+ }
169
183
  if (content.image) {
170
184
  text = `${content.text}\n\nScreenshots open from the saved result.`;
171
185
  actions = openUrlAction(options.origin(), { label: "Open result", path: chatResultHref(content.image.taskId, content.image.run, "checks") });
@@ -201,7 +215,9 @@ export async function deliverTeamsPart(options) {
201
215
  actions = [...flow, ...asked, ...owner, ...openUrlAction(options.origin(), content.link), ...(content.also ?? []).flatMap(one => openUrlAction(options.origin(), one))];
202
216
  }
203
217
  const target = content.edit ?? row.message;
204
- const body = actions.length || content.proposal ? teamsCard(text, actions) : { type: "message", text, textFormat: "plain" };
218
+ // The lead's own reply goes out in Teams' Markdown (bold anchors, labelled links); everything else stays plain.
219
+ const voiced = content.voice === true && !content.proposal && !content.image ? renderReply(text, "teams") : null;
220
+ const body = actions.length || content.proposal ? teamsCard(voiced ?? text, actions) : voiced !== null ? { type: "message", text: voiced, textFormat: "markdown" } : { type: "message", text, textFormat: "plain" };
205
221
  const answer = await options.api(target ? "PUT" : "POST", serviceUrl, `/v3/conversations/${encodeURIComponent(destination)}/activities${target ? `/${encodeURIComponent(target)}` : ""}`, body);
206
222
  const messageId = target ?? (typeof answer.id === "string" ? answer.id : null);
207
223
  if (messageId === null)
@@ -12,6 +12,8 @@ export declare const CONVERSATION_MAX_AGE_MS: number;
12
12
  /** A card's buttons and an armed irreversible challenge live this long. */
13
13
  export declare const CARD_TTL_MS: number;
14
14
  export declare const CHALLENGE_TTL_MS: number;
15
+ /** Telegram's own message ceiling, with room for our part headers (the same bound telegram.ts splits at). */
16
+ export declare const PART_CAP = 3900;
15
17
  export type TelegramConversationOptions = {
16
18
  /** Where evidence lives — the same root the console and CLI read results from. */
17
19
  evidenceRoot: string;
@@ -26,6 +28,16 @@ export type TelegramConversationOptions = {
26
28
  * wires `phoneOrigin` from webhooks.ts; absent, no card carries a link.
27
29
  */
28
30
  phoneOrigin?: () => string | null;
31
+ /** Injected by tests: how a Merge tapped in chat reaches GitHub (production: mergePullRequest). */
32
+ merge?: (input: {
33
+ runId: number;
34
+ by: string;
35
+ }) => Promise<{
36
+ ok: true;
37
+ } | {
38
+ ok: false;
39
+ message: string;
40
+ }>;
29
41
  };
30
42
  /** One Bot API call, the shape telegram.ts injects. */
31
43
  export type Transport = TelegramTransport;
@@ -23,11 +23,13 @@ export { channelRepos as telegramConversationRepos, resolveChannelMate as resolv
23
23
  * the console and CLI state), and the door re-proves everything again.
24
24
  */
25
25
  import { createHash, randomBytes } from "node:crypto";
26
- import { MATE_MESSAGE_MAX_CHARS, runMateTurn } from "./mate.js";
26
+ import { MATE_MESSAGE_MAX_CHARS, mateFailureText, runMateTurn } from "./mate.js";
27
27
  import { confirmMateProposal, dismissMateProposal } from "./mate-doors.js";
28
28
  import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
29
29
  import { MATE_ASK_OTHER } from "./store.js";
30
30
  import { phoneText } from "./telegram-status.js";
31
+ import { shapeReplyParts, telegramReply } from "./reply-shape.js";
32
+ import { WARM_EMOJI, warmTurn } from "./chat-warmth.js";
31
33
  import { TeamLeads } from "./team-leads.js";
32
34
  import { chatResultHref } from "./chat-controls.js";
33
35
  import { resultImageFileName, safeResultImageCaption, verifyResultImage } from "./chat-evidence.js";
@@ -40,7 +42,7 @@ export const CONVERSATION_MAX_AGE_MS = 10 * 60_000;
40
42
  export const CARD_TTL_MS = 24 * 3_600_000;
41
43
  export const CHALLENGE_TTL_MS = 10 * 60_000;
42
44
  /** Telegram's own message ceiling, with room for our part headers (the same bound telegram.ts splits at). */
43
- const PART_CAP = 3_900;
45
+ export const PART_CAP = 3_900;
44
46
  // ---- the ceiling and the principal --------------------------------------------
45
47
  /**
46
48
  * The phone's project ceiling: the enrolled registry, canonicalized and
@@ -405,7 +407,7 @@ async function runTelegramConversation(row, args) {
405
407
  const bound = store.getTelegramConversation(row.id)?.session ?? null;
406
408
  const session = bound === null ? null : store.getMateSession(bound);
407
409
  if (session === null) {
408
- await notify(couldNotAnswerText("the chat session for this reply is gone"));
410
+ await notify("This conversation was restarted before my reply went out, so it wasn't sent. Nothing was changed. Send your message again.");
409
411
  finish({ state: "failed", outcome: "unsent:no-session" });
410
412
  report.refused++;
411
413
  return;
@@ -525,11 +527,14 @@ async function runTelegramConversation(row, args) {
525
527
  const origin = proposal === null ? null : options.phoneOrigin?.() ?? null;
526
528
  const wanted = proposal === null ? null : proposalLink(store, proposal, repos);
527
529
  const keyboard = keyboardWith(part.keyboard, phoneLinkButton(origin, wanted));
528
- const text = proposal !== null && part.keyboard === null ? handoffCardText(part.text, linkNote(origin, wanted)) : part.text;
530
+ // The lead's own words carry their bold anchors and labelled links as entities, never as Markdown characters.
531
+ const voiced = part.kind === "reply" ? telegramReply(part.text) : null;
532
+ const text = proposal !== null && part.keyboard === null ? handoffCardText(part.text, linkNote(origin, wanted)) : voiced?.text ?? part.text;
529
533
  method = "sendMessage";
530
534
  send = () => transport("sendMessage", {
531
535
  chat_id: chatId,
532
536
  text,
537
+ ...(voiced === null || voiced.entities.length === 0 ? {} : { entities: voiced.entities }),
533
538
  link_preview_options: { is_disabled: true },
534
539
  ...(part.replyTo === null ? {} : { reply_parameters: { message_id: Number(part.replyTo) } }),
535
540
  ...(keyboard === undefined ? {} : { reply_markup: { inline_keyboard: keyboard } }),
@@ -590,7 +595,7 @@ async function runTelegramConversation(row, args) {
590
595
  try {
591
596
  return store.transact(() => {
592
597
  const now = clock();
593
- const parts = splitParts(reply).map((text, index) => ({ kind: "reply", text, replyTo: index === 0 ? row.messageId : null }));
598
+ const parts = shapeReplyParts(reply, PART_CAP, { asked: row.text, appOrigin: options.phoneOrigin?.() ?? null }, shaped => telegramReply(shaped).text.length).map((text, index) => ({ kind: "reply", text, replyTo: index === 0 ? row.messageId : null }));
594
599
  // The lead's question to its owner: one tap per option, then "Something else".
595
600
  const ask = store.mateAsk(turn);
596
601
  if (ask !== null)
@@ -633,7 +638,7 @@ async function runTelegramConversation(row, args) {
633
638
  return;
634
639
  }
635
640
  if (turn.state !== "answered") {
636
- await notify(couldNotAnswerText(`the assistant's reply did not complete (${phoneText(turn.failureReason ?? "unknown", 40)})`));
641
+ await notify(mateFailureText(turn.failureReason));
637
642
  finish({ state: "failed", outcome: `replayed:${turn.failureReason ?? "failed"}` });
638
643
  report.refused++;
639
644
  return;
@@ -693,7 +698,13 @@ async function runTelegramConversation(row, args) {
693
698
  const { who, session, thread, config } = resolved;
694
699
  const revalidate = async () => {
695
700
  const problem = await telegramChannelProblem(store, { botId, bindingId: binding.id, approverGeneration: binding.approverGeneration, repos: who.repos }, readProjects);
696
- return problem === null ? { ok: true } : { ok: false, reason: problem };
701
+ if (problem === null)
702
+ return { ok: true };
703
+ const reason = problem === UNREADABLE_REGISTRY ? "projects-unreadable"
704
+ : problem === "this chat is no longer paired" ? "unpaired"
705
+ : problem === "the paired account is no longer an approver" ? "not-approver"
706
+ : problem === "the connected projects changed" ? "projects-changed" : "access-changed";
707
+ return { ok: false, reason };
697
708
  };
698
709
  // Bound BEFORE the dispatch, under the claim: a crash from here on finds
699
710
  // the receipt in this session, not in whichever session is live later.
@@ -704,17 +715,23 @@ async function runTelegramConversation(row, args) {
704
715
  // crash gets the original turn back, never a second dispatch.
705
716
  const heartbeat = setInterval(() => { held(); }, 30_000);
706
717
  heartbeat.unref?.();
718
+ // Once the turn reaches a tool step: a 👍 on the owner's message, and "typing…" while the lead works.
719
+ const warm = warmTurn({
720
+ react: () => transport("setMessageReaction", { chat_id: chatId, message_id: Number(row.messageId), reaction: [{ type: "emoji", emoji: WARM_EMOJI }] }),
721
+ typing: () => transport("sendChatAction", { chat_id: chatId, action: "typing" }),
722
+ });
707
723
  let outcome;
708
724
  try {
709
725
  outcome = await runMateTurn({
710
726
  store, who, session, thread, config, key: null, message: row.text, requestId: row.request,
711
727
  ...(row.context === null ? {} : { context: row.context }),
712
728
  ...(options.subscriptionRunner === undefined ? {} : { subscriptionRunner: options.subscriptionRunner }),
713
- clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents", channel: "telegram",
729
+ clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents", channel: "telegram", onProgress: warm.onProgress,
714
730
  });
715
731
  }
716
732
  finally {
717
733
  clearInterval(heartbeat);
734
+ warm.stop();
718
735
  }
719
736
  const receipt = store.mateRequestReceipt(session.id, row.request);
720
737
  if (receipt !== null)
@@ -724,7 +741,7 @@ async function runTelegramConversation(row, args) {
724
741
  await requeue("busy");
725
742
  return;
726
743
  }
727
- await notify(phoneText(couldNotAnswerText(outcome.message), 1_000));
744
+ await notify(phoneText(outcome.message, 1_000));
728
745
  finish({ state: "failed", outcome: `refused:${outcome.refused}` });
729
746
  report.refused++;
730
747
  return;
@@ -732,8 +749,7 @@ async function runTelegramConversation(row, args) {
732
749
  if (!outcome.ok) {
733
750
  // Truthful: what failed, that nothing was kept, and that a new message
734
751
  // is a new turn. A failed turn's drafts are already gone.
735
- const words = outcome.failed === "revoked" ? `${outcome.message.charAt(0).toUpperCase()}${outcome.message.slice(1)}.` : couldNotAnswerText(`the assistant's reply did not complete (${outcome.message})`);
736
- await notify(phoneText(words, 1_000));
752
+ await notify(phoneText(outcome.message, 1_000));
737
753
  finish({ state: "failed", outcome: `failed:${outcome.failed}` });
738
754
  report.refused++;
739
755
  return;
@@ -1,6 +1,7 @@
1
+ import { renderReply, shapeReplyParts } from "./reply-shape.js";
1
2
  import { TeamLeads } from "./team-leads.js";
2
3
  import { proposalPreview, tooLongText } from "./chat-channel.js";
3
- import { mintCardTokens, phoneLinkButton, splitParts } from "./telegram-mate.js";
4
+ import { PART_CAP, mintCardTokens, phoneLinkButton, splitParts } from "./telegram-mate.js";
4
5
  import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
5
6
  import { phoneText } from "./telegram-status.js";
6
7
  /** `/team` lists, `/team <n>` chooses, `/team off` (or `private`) leaves. Any other slash text is not ours. */
@@ -265,7 +266,9 @@ export async function deliverTeamChats(store, botId, transport, clock, report, p
265
266
  const skip = role === "operator" && (ownMessage || status === "cancelled" || String(message["author"] ?? "").length === 0);
266
267
  const prefix = `telegram-team:${chat.id}:`;
267
268
  if (!skip) {
268
- const text = role === "assistant" ? String(message["text"]) : `${phoneText(String(message["author"]), 40)}: ${String(message["text"])}`;
269
+ // The lead's reply is split as written, then each part shaped and measured as Telegram shows it, so no cut lands
270
+ // inside a link and no rendered part passes Telegram's ceiling.
271
+ const text = role === "assistant" ? shapeReplyParts(String(message["text"]), PART_CAP, { appOrigin: phoneOrigin?.() ?? null }, shaped => renderReply(shaped, "telegram").length).map(part => renderReply(part, "telegram")) : `${phoneText(String(message["author"]), 40)}: ${String(message["text"])}`;
269
272
  const sent = await sendParts(store, transport, chat.chatId, text, `${prefix}message:${id}`, clock, access, report);
270
273
  if (sent !== null) {
271
274
  report.problems.push(`team chat ${chat.chatId}: ${sent}`);
@@ -329,7 +332,7 @@ function cardBindingFor(store, botId, chat, turn) {
329
332
  return bindings.find(one => members.has(one.approver)) ?? null;
330
333
  }
331
334
  async function sendParts(store, transport, chatId, text, receipt, clock, access, report, keyboard, tokens = []) {
332
- const parts = splitParts(text);
335
+ const parts = typeof text === "string" ? splitParts(text) : text;
333
336
  const delivered = store.serviceCursor(receipt);
334
337
  for (const [index, part] of parts.entries()) {
335
338
  if (index < delivered)
@@ -75,7 +75,9 @@ export declare function scrub(text: string, token: string): string;
75
75
  */
76
76
  export type TelegramTransport = (method: string, params: Record<string, unknown>, signal?: AbortSignal,
77
77
  /** v64: one verified file to send as multipart — the ONLY way bytes leave. Absent, the call is JSON exactly as before. */
78
- upload?: TelegramUpload) => Promise<{
78
+ upload?: TelegramUpload,
79
+ /** More verified files in the same multipart call: the rest of one album (sendMediaGroup). */
80
+ more?: readonly TelegramUpload[]) => Promise<{
79
81
  ok: boolean;
80
82
  result?: unknown;
81
83
  description?: string;
@@ -92,7 +94,7 @@ upload?: TelegramUpload) => Promise<{
92
94
  * uploaded by name.
93
95
  */
94
96
  export type TelegramUpload = {
95
- field: "document";
97
+ field: "document" | "photo" | `shot${number}`;
96
98
  fileName: string;
97
99
  contentType: "image/png" | "image/jpeg";
98
100
  bytes: Buffer;
@@ -113,7 +115,7 @@ export declare function networkFenceLine(reason: string): string;
113
115
  /** fetch's own message ("fetch failed") with the reason under it, e.g. "fetch failed (EMFILE: too many open files)". */
114
116
  export declare function transportError(error: unknown): string;
115
117
  /** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
116
- export declare function multipartOf(params: Record<string, unknown>, upload: TelegramUpload): FormData;
118
+ export declare function multipartOf(params: Record<string, unknown>, upload: TelegramUpload, ...more: readonly TelegramUpload[]): FormData;
117
119
  export declare function hashPairingCode(code: string): string;
118
120
  /** 128 bits, hex — pasteable, and not guessable inside any code's lifetime. */
119
121
  export declare function mintPairingCode(): string;