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
@@ -2,7 +2,9 @@
2
2
  import { answerChatQuestionPrompt, applyChatQuestionTap, questionParts } from "./teammate-question.js";
3
3
  import { applyChatAskTap, chatAskText } from "./chat-ask.js";
4
4
  import { channelRepos, resolveChannelMate, proposalPreview, proposalOutcomeText, confirmedLink, confirmedCardText, replyContextFor, mirrorToTaskChat, focusContextFor, taskInCeiling, tooLongText, } from "./chat-channel.js";
5
- import { MATE_MESSAGE_MAX_CHARS, runMateTurn } from "./mate.js";
5
+ import { MATE_MESSAGE_MAX_CHARS, mateFailureText, runMateTurn } from "./mate.js";
6
+ import { shapeReplyParts } from "./reply-shape.js";
7
+ import { warmTurn } from "./chat-warmth.js";
6
8
  import { confirmMateProposal, dismissMateProposal, } from "./mate-doors.js";
7
9
  import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
8
10
  import { ChatState, ChatDeliveryError, chatHash, } from "./chat-delivery-state.js";
@@ -17,7 +19,8 @@ import { leadChannelOf } from "./lead-context.js";
17
19
  import { promiseChannelOf } from "./lead-commitments.js";
18
20
  import { phoneText, PHONE_HELP, phoneCommand, phoneStatus, phoneTaskView, phoneTaskChoices, phoneTaskListText, resolvePhoneTask, phoneFocusText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
19
21
  import { applyRoomInbound, conversationRow, roomCardApprover, roomCommand, roomGrantAllowed, roomMessagesAfter, roomMessageText, teamDomain } from "./chat-rooms.js";
20
- import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
22
+ import { isTelegramProgressNotification, proposalTaskOf, RESULT_SHOTS_KIND } from "./store.js";
23
+ import { resultShotsFor } from "./result-shots.js";
21
24
  import { messageTeammate } from "./teammate-desk.js";
22
25
  import { CHAT_APP_NAMES } from "./flow-triggers.js";
23
26
  export const chatObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
@@ -25,7 +28,8 @@ export const chatObject = (v) => v !== null && typeof v === "object" && !Array.i
25
28
  : {};
26
29
  const object = chatObject;
27
30
  const nowOf = (options) => options.clock?.() ?? new Date();
28
- export const splitChatText = (text, size = 2800) => {
31
+ const CHAT_PART_SIZE = 2800;
32
+ export const splitChatText = (text, size = CHAT_PART_SIZE) => {
29
33
  const parts = [];
30
34
  for (let at = 0; at < text.length;) {
31
35
  let end = Math.min(at + size, text.length);
@@ -221,7 +225,7 @@ export async function processChatEvent(options) {
221
225
  return true;
222
226
  }
223
227
  if (event.session !== null && event.session !== resolved.session.id) {
224
- state.plan(event.id, [{ text: "Your chat session changed. Send your message again." }], now);
228
+ state.plan(event.id, [{ text: "This conversation was restarted after you sent that, so it wasn't answered. Send your message again." }], now);
225
229
  return true;
226
230
  }
227
231
  state
@@ -263,6 +267,9 @@ export async function processChatEvent(options) {
263
267
  }
264
268
  // An unknown surface names no channel rather than a guess.
265
269
  const channel = leadChannelOf(options.label);
270
+ // Only ever the owner's own message in their own chat: never the bot's, never a room.
271
+ const warm = warmTurn(options.warm !== undefined && event.kind === "message" && event.member !== identity.bot && event.member === binding.member && event.channel === binding.channel
272
+ ? options.warm(event, binding) : {});
266
273
  const outcome = await runMateTurn({
267
274
  store,
268
275
  who: resolved.who,
@@ -282,19 +289,20 @@ export async function processChatEvent(options) {
282
289
  evidenceRoot: options.evidenceRoot,
283
290
  mediaDelivery: "documents",
284
291
  ...(channel === undefined ? {} : { channel }),
292
+ onProgress: warm.onProgress,
285
293
  revalidate: async () => {
286
294
  try {
287
295
  await channelAccess(options, binding, resolved.who.ceilingDigest);
288
296
  return { ok: true };
289
297
  }
290
- catch {
291
- return {
292
- ok: false,
293
- reason: `${options.label} access could not be verified`,
294
- };
298
+ catch (error) {
299
+ const code = error instanceof ChatDeliveryError ? error.code : "";
300
+ const reason = code === "Connected projects changed" ? "projects-changed"
301
+ : code.endsWith("account access changed") ? "member-changed" : "access-changed";
302
+ return { ok: false, reason };
295
303
  }
296
304
  },
297
- });
305
+ }).finally(warm.stop);
298
306
  if (outcome.ok && !outcome.replayed && context?.task)
299
307
  mirrorToTaskChat(store, resolved.who, context.task, options.label, text, outcome.reply, nowOf(options));
300
308
  if (!state.owns(identity.installation, options.owner, nowOf(options)) ||
@@ -327,7 +335,7 @@ export async function processChatEvent(options) {
327
335
  if (turn?.state !== "answered") {
328
336
  state.plan(event.id, [
329
337
  {
330
- text: "The assistant’s reply did not finish. No proposed changes were kept. Send your message again to retry.",
338
+ text: mateFailureText(turn?.failureReason ?? null),
331
339
  },
332
340
  ], nowOf(options));
333
341
  return true;
@@ -342,7 +350,9 @@ export async function processChatEvent(options) {
342
350
  .listMateMessages(turn.thread, 200)
343
351
  .find((message) => message.turn === turn.id && message.role === "assistant")?.text ?? "The reply is no longer in the saved thread.";
344
352
  // The lead's answer about one task names it: a reply to it stays on that task.
345
- const parts = splitChatText(reply, options.partSize).map((text) => (about === null ? { text } : { text, task: about.task, ...(about.run === null ? {} : { run: about.run }) }));
353
+ // Split as written, then shape each part: no cut ever lands inside a link, code or a bold anchor.
354
+ const shapedParts = shapeReplyParts(reply, options.partSize ?? CHAT_PART_SIZE, { asked: text, appOrigin: options.origin() });
355
+ const parts = (shapedParts.length > 0 ? shapedParts : splitChatText("")).map((text) => (about === null ? { text, voice: true } : { text, voice: true, task: about.task, ...(about.run === null ? {} : { run: about.run }) }));
346
356
  // The lead's question to its owner: its options as buttons, then "Something else", in their own chat (any thread);
347
357
  // in a room a tap is not their message, so the question and its options go out as text.
348
358
  const ask = store.mateAsk(turn.id);
@@ -581,8 +591,13 @@ export async function planRoomMessages(options) {
581
591
  if (carrier === null)
582
592
  break;
583
593
  const parts = [];
584
- if (text !== null)
585
- parts.push({ text, channel: room.chat });
594
+ if (text !== null) {
595
+ if (message.role === "assistant")
596
+ for (const part of shapeReplyParts(text, options.partSize ?? CHAT_PART_SIZE, { appOrigin: options.origin() }))
597
+ parts.push({ text: part, voice: true, channel: room.chat });
598
+ else
599
+ parts.push({ text, channel: room.chat });
600
+ }
586
601
  if (message.role === "assistant" && message.turn !== null)
587
602
  for (const proposal of store.listMateProposals(row.thread, ["pending"]).filter(one => one.turn === message.turn))
588
603
  parts.push({ text: "", proposal: proposal.id, channel: room.chat });
@@ -654,6 +669,32 @@ function planFinished(options, binding, taskRef, run, now, notification = null)
654
669
  store.setChatBatchMessage(batch.id, String(part.id), shown);
655
670
  return true;
656
671
  }
672
+ /** Screenshots with a result (result-shots.ts), planned after the result's own message: each an upload that follows
673
+ * it in its thread (Slack, Discord), or one link to them (Teams). A failed check is one plain line instead. */
674
+ function planResultShots(options, binding, notification, repos, id, now) {
675
+ const { state, store, identity } = options;
676
+ const plan = resultShotsFor(store, options.evidenceRoot, repos, notification);
677
+ if (plan.kind === "none" || notification.taskId === null || notification.run === null)
678
+ return;
679
+ const where = { task: notification.taskId, run: notification.run };
680
+ // The result's message here: its finished-work batch (quiet), or its progress card (every step).
681
+ const follows = (() => {
682
+ const batch = notification.taskRef === null ? null : store.chatBatchMessageFor(`${state.channel}:${binding.id}`, notification.taskRef, notification.run);
683
+ if (batch !== null)
684
+ return Number(batch);
685
+ const card = state.prepare("SELECT part FROM chat_progress WHERE binding=? AND run=?").get(binding.id, notification.run);
686
+ return card === undefined ? null : Number(card.part);
687
+ })();
688
+ const image = (artifact, sha256) => ({ taskId: where.task, run: where.run, artifact, sha256 });
689
+ const parts = plan.kind === "line" ? [{ text: plan.text, ...where }]
690
+ // Teams has no file upload here: one message that links to the saved result.
691
+ : state.channel === "teams" ? [{ text: plan.shots.length === 1 ? plan.shots[0].caption : `${plan.shots[0].caption} · ${plan.shots.length} screenshots`,
692
+ image: image(plan.shots[0].artifact, plan.shots[0].sha256), shot: { follows: null }, ...where }]
693
+ : plan.shots.map(one => ({ text: one.caption, image: image(one.artifact, one.sha256), shot: { follows }, ...where }));
694
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
695
+ ts: "", thread: "", payload: "{}", created: now.toISOString() });
696
+ state.plan(id, parts, now);
697
+ }
657
698
  /** One progress card per exact result; separate urgent facts retain their own review link. */
658
699
  export async function planChatNotifications(options) {
659
700
  const state = options.state, { store, identity } = options, bindings = state.bindings(identity.installation).filter(one => state.live(one));
@@ -680,10 +721,11 @@ export async function planChatNotifications(options) {
680
721
  const personal = notification.recipient !== null;
681
722
  if (notification.createdAt >= binding.created &&
682
723
  notification.resolvedAt === null &&
683
- // A promise the lead made on another chat is reported there (lead-commitments.ts).
724
+ // A promise the lead made on another chat is reported there (lead-commitments.ts); one its owner asked for on
725
+ // this chat is always said here, however quiet the lead's own work is kept.
684
726
  (promiseChannelOf(notification) ?? state.channel) === state.channel &&
685
727
  // Pings follow responsibility: the lead's work, this person's own act and a muted project stay in the console.
686
- store.pingAllowed(notification, binding.approver) &&
728
+ (promiseChannelOf(notification) === state.channel || store.pingAllowed(notification, binding.approver)) &&
687
729
  // A flow decision for "anyone who approves" reaches every approver who can see the project.
688
730
  // A notification addressed to this person reaches them whatever project
689
731
  // their channel follows (a sign-in pause is the installation's, v108).
@@ -695,9 +737,13 @@ export async function planChatNotifications(options) {
695
737
  // Only when I'm needed (the default): the task's one card is edited in place, and a new message
696
738
  // follows only when this person is needed. Every step keeps the branches below unchanged.
697
739
  const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
740
+ if (notification.kind === RESULT_SHOTS_KIND) {
741
+ planResultShots(options, binding, notification, repos, id, now);
742
+ return;
743
+ }
698
744
  if (notification.kind === LEAD_SAY_KIND) {
699
745
  // The lead's words (lead-voice.ts): one message, repainted in place when a later say joins it.
700
- const content = { text: leadSayText(store, notification),
746
+ const content = { text: leadSayText(store, notification, binding.approver),
701
747
  ...(notification.link ? { link: { label: "Open", path: notification.link } } : {}) };
702
748
  const earlier = leadSayEarlier(store, notification).map(one => state.prepare("SELECT id FROM chat_part WHERE event=?")
703
749
  .get(chatHash(`${options.state.channel}:notice:${binding.id}:${one}`))).find(one => one !== undefined);
@@ -1,12 +1,15 @@
1
1
  import { type ProgressEntity } from "./telegram-progress.js";
2
2
  import { type PhoneTaskLink } from "./telegram-status.js";
3
3
  import { type ChatBatch, type Notification, type Store } from "./store.js";
4
- /** `also`: more buttons after `link`, such as [Look first] beside [Merge]. */
4
+ import { type DecideTarget } from "./chat-decide.js";
5
+ /** `also`: more buttons after `link`, such as [Look first] beside [Merge]. `target`: the one card a chat may act on in
6
+ * place (chat-decide.ts), when the message is about one result, plan, failure or pull request. */
5
7
  export type QuietView = {
6
8
  text: string;
7
9
  entities: ProgressEntity[];
8
10
  link: PhoneTaskLink;
9
11
  also?: PhoneTaskLink[];
12
+ target?: DecideTarget;
10
13
  };
11
14
  export declare function needsPerson(row: Pick<Notification, "dedupeKey" | "kind" | "pushClass" | "recipient">): boolean;
12
15
  /** A fact about one placed task: in quiet mode it lands on that task's card. */
@@ -12,6 +12,7 @@ import { asksToFinish, batchLine, chatText, chatTitle, factLinkLabel, mentions,
12
12
  import { phoneText, projectLabel } from "./telegram-status.js";
13
13
  import { isLifecycleNotification } from "./store.js";
14
14
  import { leadSubjectOf } from "./lead-voice.js";
15
+ import { decideFallbackLink, decideTargetOf } from "./chat-decide.js";
15
16
  /** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
16
17
  * attention fact, a result Ready for review, a security alert or release, and anything addressed to one person
17
18
  * (a sign-in or plan-limit pause, an evening digest). */
@@ -132,7 +133,8 @@ function lineOf(store, item, now, root, viewer) {
132
133
  const subject = leadSubjectOf(store, row, viewer);
133
134
  const words = chatText(phoneText(row.body === "" ? subject : `${subject}\n\n${row.body}`, 2500), [{ id: ref.externalId, title: summary }]);
134
135
  const { phrase, headline } = updatePhrase(row);
135
- return { also: [], link: row.link === null ? { label: "Open task", path: chatControlHref("task", ref.externalId) } : { label: factLinkLabel(row.link), path: row.link },
136
+ const target = decideTargetOf(row);
137
+ return { also: [], target, link: decideFallbackLink(target) ?? (row.link === null ? { label: "Open task", path: chatControlHref("task", ref.externalId) } : { label: factLinkLabel(row.link), path: row.link }),
136
138
  fact: { summary, headline, checks: null, report: false, completedBy: null, update: { words: mentions(words, summary) ? words : `${summary} · ${words}`, phrase } } };
137
139
  }
138
140
  const run = item.run === null ? null : store.getRun(item.run);
@@ -156,6 +158,7 @@ function lineOf(store, item, now, root, viewer) {
156
158
  // A result ready for a person offers its real next step, then a look first.
157
159
  if (asksToFinish(fact))
158
160
  return { fact, also: [{ label: READY_ACTIONS.look, path: chatResultHref(ref.externalId, run.id, "changes") }],
161
+ target: pullRequest ? { kind: "merge", taskId: ref.externalId, run: run.id } : { kind: "result", taskId: ref.externalId, run: run.id },
159
162
  link: pullRequest ? { label: READY_ACTIONS.merge, path: `/t/${encodeURIComponent(ref.externalId)}#merge` } : { label: READY_ACTIONS.complete, path: chatResultHref(ref.externalId, run.id) } };
160
163
  return { fact, link: card.link, also: [] };
161
164
  }
@@ -168,7 +171,8 @@ export function finishedView(store, batch, now, root, viewer) {
168
171
  const text = batchLine(lines.map(one => one.fact));
169
172
  if (lines.length > 1)
170
173
  return { text, entities: [], link: { label: "Open", path: "/tasks" } };
171
- return { text, entities: [], link: lines[0].link, ...(lines[0].also.length === 0 ? {} : { also: lines[0].also }) };
174
+ const target = lines[0].target ?? null;
175
+ return { text, entities: [], link: lines[0].link, ...(lines[0].also.length === 0 ? {} : { also: lines[0].also }), ...(target === null ? {} : { target }) };
172
176
  }
173
177
  // ---- the evening digest ----------------------------------------------------------
174
178
  const FAILURE = /fail|exhausted|fenced/;
@@ -0,0 +1,21 @@
1
+ /** Small warm touches while the lead works on a chat app: once a turn will take more than a few seconds (its first tool
2
+ * step starts), a 👍 on the owner's message and the app's typing indicator until the turn ends. Best effort only: an
3
+ * app or permission that does not allow it is skipped silently, and nothing here can fail, delay or change the turn. */
4
+ import type { MateProgress } from "./mate-progress.js";
5
+ export declare const WARM_EMOJI = "\uD83D\uDC4D";
6
+ /** Telegram and Teams show typing for about five seconds, Discord for ten: refreshed inside the shorter window. */
7
+ export declare const TYPING_REFRESH_MS = 4000;
8
+ export type WarmHooks = {
9
+ /** React to the owner's own message. Absent: the app (or this bot's permission) has no reactions. */
10
+ react?: () => Promise<unknown>;
11
+ /** Show the app's typing indicator once. Absent: the app has none for bots. */
12
+ typing?: () => Promise<unknown>;
13
+ };
14
+ export type WarmTurn = {
15
+ onProgress: (event: MateProgress) => void;
16
+ stop: () => void;
17
+ };
18
+ /** The turn's progress listener: on the first tool step, react once and keep typing shown until `stop`. */
19
+ export declare function warmTurn(hooks: WarmHooks, options?: {
20
+ refreshMs?: number;
21
+ }): WarmTurn;
@@ -0,0 +1,36 @@
1
+ export const WARM_EMOJI = "👍";
2
+ /** Telegram and Teams show typing for about five seconds, Discord for ten: refreshed inside the shorter window. */
3
+ export const TYPING_REFRESH_MS = 4_000;
4
+ /** The turn's progress listener: on the first tool step, react once and keep typing shown until `stop`. */
5
+ export function warmTurn(hooks, options = {}) {
6
+ let started = false;
7
+ let stopped = false;
8
+ let timer = null;
9
+ const quietly = (call) => {
10
+ if (call === undefined || stopped)
11
+ return;
12
+ try {
13
+ void call().catch(() => undefined);
14
+ }
15
+ catch { /* skipped silently */ }
16
+ };
17
+ return {
18
+ onProgress: event => {
19
+ if (stopped || event.kind !== "tool" || started)
20
+ return;
21
+ started = true;
22
+ quietly(hooks.react);
23
+ quietly(hooks.typing);
24
+ if (hooks.typing !== undefined) {
25
+ timer = setInterval(() => quietly(hooks.typing), options.refreshMs ?? TYPING_REFRESH_MS);
26
+ timer.unref?.();
27
+ }
28
+ },
29
+ stop: () => {
30
+ stopped = true;
31
+ if (timer !== null)
32
+ clearInterval(timer);
33
+ timer = null;
34
+ },
35
+ };
36
+ }
@@ -125,6 +125,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
125
125
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
126
126
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
127
127
  export declare function desktopUpdateStatus(stateDir: string): {
128
+ version?: never;
128
129
  active: boolean;
129
130
  phase: string;
130
131
  detail: string;
@@ -138,7 +139,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
138
139
  wasRunning?: never;
139
140
  backupPath?: never;
140
141
  updateId?: never;
141
- version?: never;
142
142
  buildId?: never;
143
143
  workDir?: never;
144
144
  } | {
@@ -5,12 +5,14 @@ export declare const discordId: (v: unknown) => v is string;
5
5
  export type DiscordCredentials = ChatIdentity & {
6
6
  botToken: string;
7
7
  };
8
- export type DiscordApi = (method: "GET" | "POST" | "PATCH", path: string, body?: Record<string, unknown>, file?: {
8
+ export type DiscordApi = (method: "GET" | "POST" | "PATCH" | "PUT", path: string, body?: Record<string, unknown>, file?: {
9
9
  bytes: Uint8Array;
10
10
  name: string;
11
11
  }) => Promise<Record<string, unknown>>;
12
12
  export declare class DiscordError extends ChatDeliveryError {
13
13
  }
14
+ /** Discord's 403: the bot may not do this here (for a file, no Attach Files permission). */
15
+ export declare const DISCORD_REFUSED = "Discord refused access. Check the app and direct-message permissions.";
14
16
  export declare function discordApi(token: string, fetcher?: typeof fetch): DiscordApi;
15
17
  export declare const discordCredentialFile: (dir: string) => string;
16
18
  export declare function loadDiscordCredentials(dir: string): DiscordCredentials | null;
@@ -8,10 +8,12 @@ export { object };
8
8
  export const discordId = (v) => typeof v === "string" && /^[1-9][0-9]{16,19}$/.test(v);
9
9
  export class DiscordError extends ChatDeliveryError {
10
10
  }
11
+ /** Discord's 403: the bot may not do this here (for a file, no Attach Files permission). */
12
+ export const DISCORD_REFUSED = "Discord refused access. Check the app and direct-message permissions.";
11
13
  export function discordApi(token, fetcher = fetch) {
12
14
  return async (method, path, body, file) => {
13
15
  // Only relative API routes; never send the bot credential to supplied URLs.
14
- if (!/^\/(?:users\/@me|users\/[0-9]+|oauth2\/applications\/@me|channels\/[0-9]+(?:\/messages(?:\/[0-9]+)?(?:\?limit=100)?)?|interactions\/[0-9]+\/[A-Za-z0-9._-]+\/callback)$/.test(path))
16
+ if (!/^\/(?:users\/@me|users\/[0-9]+|oauth2\/applications\/@me|channels\/[0-9]+(?:\/typing|\/messages\/[0-9]+\/reactions\/%F0%9F%91%8D\/@me|\/messages(?:\/[0-9]+)?(?:\?limit=100)?)?|interactions\/[0-9]+\/[A-Za-z0-9._-]+\/callback)$/.test(path))
15
17
  throw new DiscordError("Invalid Discord request");
16
18
  let response;
17
19
  const headers = { Authorization: `Bot ${token}` };
@@ -56,7 +58,7 @@ export function discordApi(token, fetcher = fetch) {
56
58
  const message = response.status === 401
57
59
  ? "Discord access expired. Disconnect and reconnect with the current bot token."
58
60
  : response.status === 403
59
- ? "Discord refused access. Check the app and direct-message permissions."
61
+ ? DISCORD_REFUSED
60
62
  : response.status === 404
61
63
  ? "The Discord message or conversation is unavailable. Open the saved chat."
62
64
  : response.status === 413
@@ -1,5 +1,6 @@
1
1
  import { ChatState, type ChatIdentity } from "./chat-delivery-state.js";
2
2
  import { type ChatDeliveryOptions } from "./chat-delivery.js";
3
+ import { discordPlain } from "./reply-shape.js";
3
4
  import { type DiscordApi } from "./discord-api.js";
4
5
  export type DiscordChatOptions = Omit<ChatDeliveryOptions, "state" | "label" | "member" | "partSize" | "maxProposal"> & {
5
6
  api: DiscordApi;
@@ -11,6 +12,6 @@ export declare const planDiscordNotifications: (options: DiscordChatOptions) =>
11
12
  /** Save only normalized input. Interaction credentials and pairing codes never enter SQLite. */
12
13
  export declare function receiveDiscord(state: ChatState, identity: ChatIdentity, type: string, raw: unknown, now: Date): boolean;
13
14
  /** Escape Discord's presentation syntax; allowed_mentions also prevents actual pings. */
14
- export declare const discordPlain: (text: string) => string;
15
- export declare function discordCard(text: string, buttons?: Record<string, unknown>[]): Record<string, unknown>;
15
+ export { discordPlain };
16
+ export declare function discordCard(text: string, buttons?: Record<string, unknown>[], voice?: boolean): Record<string, unknown>;
16
17
  export declare function deliverDiscordPart(options: DiscordChatOptions): Promise<boolean>;
@@ -2,20 +2,28 @@
2
2
  import { chatQuestionButtons } from "./teammate-question.js";
3
3
  import { chatAskButtons } from "./chat-ask.js";
4
4
  import { chatFlowButtons } from "./chat-flow.js";
5
+ import { refuseResultShots, resultShotsPruned } from "./result-shots.js";
5
6
  import { channelInbox } from "./chat-inbox.js";
6
7
  import { roomCommand } from "./chat-rooms.js";
7
8
  import { ChatState, ChatDeliveryError, chatHash, } from "./chat-delivery-state.js";
8
9
  import { processChatEvent, planChatNotifications, channelAccess, chatObject as object, planRoomMessages, } from "./chat-delivery.js";
9
10
  import { chatResultHref } from "./chat-controls.js";
10
11
  import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
12
+ import { discordPlain, renderReply } from "./reply-shape.js";
13
+ import { WARM_EMOJI } from "./chat-warmth.js";
11
14
  import { proposalPreview, proposalLink, proposalOutcomeText, armedCardText, armedYesLabel, } from "./chat-channel.js";
12
15
  import { resultImageFileName, safeResultImageCaption, verifyResultImage, } from "./chat-evidence.js";
13
- import { discordId, discordMember, DiscordError, } from "./discord-api.js";
16
+ import { discordId, discordMember, DiscordError, DISCORD_REFUSED, } from "./discord-api.js";
14
17
  export const discordDelivery = (options) => ({
15
18
  ...options,
16
19
  state: new ChatState(options.store, "discord"),
17
20
  label: "Discord",
18
21
  member: (member, channel) => discordMember(options.api, member, channel),
22
+ // A 👍 on the owner's message and "typing…" while the lead works; a missing permission is skipped silently.
23
+ warm: (event) => ({
24
+ react: () => options.api("PUT", `/channels/${event.channel}/messages/${event.ts}/reactions/${encodeURIComponent(WARM_EMOJI)}/@me`),
25
+ typing: () => options.api("POST", `/channels/${event.channel}/typing`),
26
+ }),
19
27
  partSize: 1800,
20
28
  maxProposal: 3400,
21
29
  });
@@ -140,8 +148,16 @@ function link(origin, target) {
140
148
  }
141
149
  }
142
150
  /** Escape Discord's presentation syntax; allowed_mentions also prevents actual pings. */
143
- export const discordPlain = (text) => text.replace(/([\\`*_~|>#\[\]])/g, "\\$1").replace(/@/g, "@\u200b");
144
- export function discordCard(text, buttons = []) {
151
+ export { discordPlain };
152
+ export function discordCard(text, buttons = [], voice = false) {
153
+ // The lead's own reply: one description in Discord's Markdown (bold anchors, labelled links), no title line promoted to a header.
154
+ if (voice)
155
+ return {
156
+ content: "",
157
+ embeds: [{ description: renderReply(text, "discord").slice(0, 4096), color: 0x297b70 }],
158
+ components: Array.from({ length: Math.min(5, Math.ceil(buttons.length / 5)) }, (_, row) => ({ type: 1, components: buttons.slice(row * 5, row * 5 + 5) })),
159
+ allowed_mentions: { parse: [], replied_user: false },
160
+ };
145
161
  const lines = text.split("\n"), first = lines[0] ?? "Toolroll", title = discordPlain(first).length <= 256 &&
146
162
  first.length <= 150 &&
147
163
  lines.length > 1
@@ -190,6 +206,13 @@ export async function deliverDiscordPart(options) {
190
206
  !repos.includes(store.lookupRef(content.task)?.repo ?? ""))
191
207
  throw new DiscordError("Connected projects changed");
192
208
  let text = content.text, buttons = [], file;
209
+ if (content.image && content.shot && resultShotsPruned(options.evidenceRoot, content.image.run)) {
210
+ state.prepare("UPDATE chat_part SET state='dropped',problem='Removed by retention' WHERE id=?").run(row.id);
211
+ return true;
212
+ }
213
+ // A screenshot sent with a result replies to that result's message once it is placed.
214
+ const follows = content.shot?.follows == null ? undefined : state.prepare("SELECT message FROM chat_part WHERE id=? AND state='sent'").get(content.shot.follows)?.message;
215
+ const thread = discordId(follows) ? String(follows) : event.thread;
193
216
  if (content.image) {
194
217
  const image = verifyResultImage(store, options.evidenceRoot, repos, content.image);
195
218
  if (!image.ok)
@@ -336,7 +359,7 @@ export async function deliverDiscordPart(options) {
336
359
  throw new DiscordError("Screenshot evidence changed before upload");
337
360
  file.bytes = fresh.bytes;
338
361
  }
339
- const args = discordCard(text, buttons);
362
+ const args = discordCard(text, buttons, content.voice === true && !content.proposal && !content.image);
340
363
  if (file)
341
364
  args.attachments = [
342
365
  { id: 0, filename: file.name, description: text.slice(0, 1024) },
@@ -344,9 +367,9 @@ export async function deliverDiscordPart(options) {
344
367
  if (!target) {
345
368
  args.nonce = nonce;
346
369
  args.enforce_nonce = true;
347
- if (discordId(event.thread))
370
+ if (discordId(thread))
348
371
  args.message_reference = {
349
- message_id: event.thread,
372
+ message_id: thread,
350
373
  channel_id: destination,
351
374
  fail_if_not_exists: false,
352
375
  };
@@ -370,6 +393,12 @@ export async function deliverDiscordPart(options) {
370
393
  return true;
371
394
  }
372
395
  catch (error) {
396
+ // No permission to attach files here: one plain line instead of the result's screenshots.
397
+ const content = JSON.parse(row.payload);
398
+ if (content.image && content.shot && error instanceof DiscordError && error.code === DISCORD_REFUSED) {
399
+ refuseResultShots(state, store, row, content, "Discord", options.clock?.() ?? new Date());
400
+ return true;
401
+ }
373
402
  const problem = error instanceof ChatDeliveryError
374
403
  ? error
375
404
  : new DiscordError("Discord delivery is waiting to retry", 15_000, true);
@@ -0,0 +1,35 @@
1
+ /** What your lead knows about you (Settings → Lead): a short list of the owner's own lasting preferences, the same
2
+ * across projects ("keep copy terse", "I test myself"). Stored beside the lead's name and persona; only lines the
3
+ * owner confirmed (a remember about-you card) or wrote themselves are kept, never a guess. */
4
+ import type { Store } from "./store.js";
5
+ export declare const ABOUT_YOU_MAX_LINES = 20;
6
+ /** Each line is under 200 characters. */
7
+ export declare const ABOUT_YOU_LINE_MAX = 199;
8
+ /** This person's confirmed lines; an older store reads as none. */
9
+ export declare function aboutYouOf(store: Store, account: string | null | undefined): string[];
10
+ /** One line as it is saved, or the problem with it. */
11
+ export declare function checkAboutYouLine(value: unknown): {
12
+ ok: true;
13
+ line: string;
14
+ } | {
15
+ ok: false;
16
+ message: string;
17
+ };
18
+ /** The Settings form's text, one line each: the saved list, or the problem with it. Blank lines and repeats go. */
19
+ export declare function checkAboutYou(text: string): {
20
+ ok: true;
21
+ lines: string[];
22
+ } | {
23
+ ok: false;
24
+ message: string;
25
+ };
26
+ /** Save the whole list for this person (already checked). Their lead's name and persona are left as they are. */
27
+ export declare function saveAboutYou(store: Store, account: string, lines: readonly string[], now: Date): void;
28
+ /** The list once a confirmed line is added, or once it replaces line `replaces` (1-based). */
29
+ export declare function withAboutYouLine(lines: readonly string[], line: string, replaces: number): {
30
+ ok: true;
31
+ lines: string[];
32
+ } | {
33
+ ok: false;
34
+ message: string;
35
+ };
@@ -0,0 +1,62 @@
1
+ import { scanForSecrets } from "./evidence.js";
2
+ export const ABOUT_YOU_MAX_LINES = 20;
3
+ /** Each line is under 200 characters. */
4
+ export const ABOUT_YOU_LINE_MAX = 199;
5
+ /** This person's confirmed lines; an older store reads as none. */
6
+ export function aboutYouOf(store, account) {
7
+ if (account == null || account === "")
8
+ return [];
9
+ try {
10
+ return store.leadAbout(account);
11
+ }
12
+ catch {
13
+ return [];
14
+ }
15
+ }
16
+ /** One line as it is saved, or the problem with it. */
17
+ export function checkAboutYouLine(value) {
18
+ const line = typeof value === "string" ? value.replace(/^\s*[-*•]\s*/, "").replace(/\s+/g, " ").trim() : "";
19
+ if (line.length < 2)
20
+ return { ok: false, message: "Say it in a few words." };
21
+ if (line.length > ABOUT_YOU_LINE_MAX)
22
+ return { ok: false, message: `Keep each line under ${ABOUT_YOU_LINE_MAX + 1} characters.` };
23
+ if (/[\u0000-\u001f\u007f​-‏‪-‮⁦-⁩�]/.test(line) || scanForSecrets(line).length > 0)
24
+ return { ok: false, message: "Use plain words, with no passwords or keys." };
25
+ return { ok: true, line };
26
+ }
27
+ /** The Settings form's text, one line each: the saved list, or the problem with it. Blank lines and repeats go. */
28
+ export function checkAboutYou(text) {
29
+ const lines = [];
30
+ // A problem names the line as the person sees it in the box, blank lines and repeats counted.
31
+ for (const [index, raw] of text.replace(/\r\n?/g, "\n").split("\n").entries()) {
32
+ if (raw.trim() === "")
33
+ continue;
34
+ const checked = checkAboutYouLine(raw);
35
+ if (!checked.ok)
36
+ return { ok: false, message: `Line ${index + 1}: ${checked.message}` };
37
+ if (!lines.some(one => one.toLowerCase() === checked.line.toLowerCase()))
38
+ lines.push(checked.line);
39
+ }
40
+ if (lines.length > ABOUT_YOU_MAX_LINES)
41
+ return { ok: false, message: `Keep it to ${ABOUT_YOU_MAX_LINES} lines.` };
42
+ return { ok: true, lines };
43
+ }
44
+ /** Save the whole list for this person (already checked). Their lead's name and persona are left as they are. */
45
+ export function saveAboutYou(store, account, lines, now) {
46
+ store.setLeadAbout(account, lines, now);
47
+ }
48
+ /** The list once a confirmed line is added, or once it replaces line `replaces` (1-based). */
49
+ export function withAboutYouLine(lines, line, replaces) {
50
+ if (replaces > 0) {
51
+ if (replaces > lines.length)
52
+ return { ok: false, message: "That line is no longer in what your lead knows about you." };
53
+ const next = [...lines];
54
+ next[replaces - 1] = line;
55
+ return { ok: true, lines: next.filter((one, index) => index === replaces - 1 || one.toLowerCase() !== line.toLowerCase()) };
56
+ }
57
+ if (lines.some(one => one.toLowerCase() === line.toLowerCase()))
58
+ return { ok: false, message: "Your lead already knows this." };
59
+ if (lines.length >= ABOUT_YOU_MAX_LINES)
60
+ return { ok: false, message: `What your lead knows about you is full (${ABOUT_YOU_MAX_LINES} lines). Replace a line, or tidy it in Settings → Lead.` };
61
+ return { ok: true, lines: [...lines, line] };
62
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * What the lead can rely on right now, in one read (get_capabilities): the agents a project's route would run, the
3
+ * workers that would run them, the project's tools and skills, the integrations, and the project's checks. Every entry
4
+ * says what it lets the lead do and, when it can't, the one next step and the settings control (show_control) that
5
+ * takes the owner there. Everything here reads what is already recorded — runner readiness reports, sign-in pauses,
6
+ * plan-limit readings, tool tests, skill tests, integration checks — and never probes anything that spends.
7
+ */
8
+ import type { Store } from "./store.js";
9
+ import type { ChatControl } from "./chat-controls.js";
10
+ import type { ProviderId } from "./provider.js";
11
+ import { type AuthMode } from "./keys.js";
12
+ import { type Integration } from "./integrations.js";
13
+ /** One thing the lead may depend on: its state in a few words, what it lets the lead do, and when it can't, the next step. */
14
+ export type Capability = {
15
+ name: string;
16
+ state: string;
17
+ ok: boolean;
18
+ lets: string;
19
+ next: string | null;
20
+ link: ChatControl | null;
21
+ };
22
+ export type ProjectCapabilities = {
23
+ repo: string;
24
+ agents: (Capability & {
25
+ provider: string;
26
+ model: string;
27
+ roles: string[];
28
+ })[];
29
+ agentsProblem: Capability | null;
30
+ tools: Capability[];
31
+ skills: Capability[];
32
+ checks: Capability & {
33
+ level: string;
34
+ quickCheck: boolean;
35
+ releaseCheck: boolean;
36
+ };
37
+ };
38
+ export type Capabilities = {
39
+ projects: ProjectCapabilities[];
40
+ workers: (Capability & {
41
+ online: boolean;
42
+ capacity: number;
43
+ running: string[];
44
+ })[];
45
+ integrations: (Capability & {
46
+ kind: string;
47
+ account: string | null;
48
+ usedBy: string[];
49
+ lastError: string | null;
50
+ })[];
51
+ };
52
+ /** The integrations exactly as get_integrations reads them, with what each lets the lead do and the next step. */
53
+ export declare function integrationCapabilities(list: readonly Integration[]): Capabilities["integrations"];
54
+ /** Every capability, for one project or every admitted one. Read-only and spends nothing. */
55
+ export declare function capabilitiesOf(store: Store, input: {
56
+ repos: readonly string[];
57
+ admitted: readonly string[];
58
+ actor: string;
59
+ integrations: readonly Integration[];
60
+ authMode?: (provider: ProviderId) => AuthMode;
61
+ now: Date;
62
+ }): Capabilities;
63
+ export declare function needsCapabilities(name: string, args: Record<string, unknown>, channel: string | undefined): boolean;
64
+ export declare const CAPABILITIES_UNREAD = "This depends on an agent, a worker or an integration. Read get_capabilities first, then propose or promise in a later step; if something it needs can't work, say so plainly with its next step and show_control link instead.";