toolroll 0.9.15 → 0.9.17

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 (89) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/assignment-brief.d.ts +10 -0
  3. package/dist/assignment-brief.js +56 -26
  4. package/dist/assignment.d.ts +8 -7
  5. package/dist/assignment.js +13 -8
  6. package/dist/browser/workspace.css +1 -1
  7. package/dist/browser/workspace.js +22 -22
  8. package/dist/browser-crew.js +1 -0
  9. package/dist/browser-workspace.d.ts +5 -0
  10. package/dist/chat-actions.d.ts +7 -0
  11. package/dist/chat-actions.js +40 -4
  12. package/dist/chat-ask.d.ts +28 -0
  13. package/dist/chat-ask.js +91 -0
  14. package/dist/chat-channel.js +8 -1
  15. package/dist/chat-controls.d.ts +8 -0
  16. package/dist/chat-controls.js +3 -0
  17. package/dist/chat-delivery-state.d.ts +8 -1
  18. package/dist/chat-delivery-state.js +15 -2
  19. package/dist/chat-delivery.d.ts +3 -0
  20. package/dist/chat-delivery.js +49 -17
  21. package/dist/chat-quiet.js +4 -2
  22. package/dist/chat-voice.d.ts +3 -6
  23. package/dist/chat-voice.js +8 -2
  24. package/dist/chat-warmth.d.ts +21 -0
  25. package/dist/chat-warmth.js +36 -0
  26. package/dist/discord-api.d.ts +1 -1
  27. package/dist/discord-api.js +1 -1
  28. package/dist/discord-chat.d.ts +3 -2
  29. package/dist/discord-chat.js +28 -3
  30. package/dist/lead-about.d.ts +35 -0
  31. package/dist/lead-about.js +62 -0
  32. package/dist/lead-capabilities.d.ts +64 -0
  33. package/dist/lead-capabilities.js +199 -0
  34. package/dist/lead-commitments.d.ts +84 -0
  35. package/dist/lead-commitments.js +214 -0
  36. package/dist/lead-context.d.ts +34 -3
  37. package/dist/lead-context.js +155 -19
  38. package/dist/lead-follow.d.ts +1 -1
  39. package/dist/lead-follow.js +6 -2
  40. package/dist/lead-identity.d.ts +23 -0
  41. package/dist/lead-identity.js +33 -0
  42. package/dist/lead-people.d.ts +55 -0
  43. package/dist/lead-people.js +128 -0
  44. package/dist/lead-voice.d.ts +13 -9
  45. package/dist/lead-voice.js +26 -16
  46. package/dist/ledger-view.js +3 -2
  47. package/dist/mate-cli.d.ts +4 -1
  48. package/dist/mate-cli.js +23 -16
  49. package/dist/mate-contract.d.ts +1 -1
  50. package/dist/mate-contract.js +13 -4
  51. package/dist/mate-progress.js +7 -0
  52. package/dist/mate-tools.d.ts +26 -1
  53. package/dist/mate-tools.js +357 -7
  54. package/dist/mate.d.ts +35 -1
  55. package/dist/mate.js +121 -61
  56. package/dist/operate.js +4 -1
  57. package/dist/phase-routing.d.ts +1 -1
  58. package/dist/project-delete.js +3 -1
  59. package/dist/provider-auth.d.ts +4 -0
  60. package/dist/provider-auth.js +1 -1
  61. package/dist/reply-shape.d.ts +96 -0
  62. package/dist/reply-shape.js +452 -0
  63. package/dist/result-acts.d.ts +14 -0
  64. package/dist/result-acts.js +19 -0
  65. package/dist/retention.js +6 -0
  66. package/dist/serve.js +162 -55
  67. package/dist/slack-chat.d.ts +2 -0
  68. package/dist/slack-chat.js +30 -3
  69. package/dist/store.d.ts +60 -0
  70. package/dist/store.js +152 -3
  71. package/dist/surface.js +2 -2
  72. package/dist/task-status.d.ts +5 -15
  73. package/dist/task-status.js +18 -5
  74. package/dist/team-runtime.js +3 -2
  75. package/dist/teams-chat.js +16 -2
  76. package/dist/telegram-mate.d.ts +6 -1
  77. package/dist/telegram-mate.js +43 -11
  78. package/dist/telegram-progress.js +3 -2
  79. package/dist/telegram-team.js +6 -3
  80. package/dist/telegram.js +43 -3
  81. package/dist/test-temp.js +1 -1
  82. package/dist/when-html.d.ts +8 -3
  83. package/dist/when-html.js +23 -3
  84. package/dist/work-index.d.ts +2 -0
  85. package/dist/work-index.js +9 -6
  86. package/dist/work-summary.d.ts +1 -1
  87. package/dist/workspace-ui.d.ts +2 -14
  88. package/dist/workspace-ui.js +4 -22
  89. package/package.json +1 -1
@@ -1,8 +1,3 @@
1
- /** How chat sounds: a teammate talking. One or two short sentences, outcome
2
- * first, plain words, one action at most. Every chat (Telegram, Slack,
3
- * Discord, Teams) takes its titles and its finished-work lines from here, so a
4
- * task id, a "— revision" suffix or a person's own act never reaches a chat.
5
- * The console keeps every exact name and fact. */
6
1
  import type { TelegramTransport } from "./telegram.js";
7
2
  import type { Store } from "./store.js";
8
3
  /** The bot's display name in every chat app. */
@@ -34,8 +29,10 @@ export type FinishedFact = {
34
29
  words: string;
35
30
  phrase: string;
36
31
  } | null;
37
- /** The person's lead took it on (lead-voice.ts): "Your lead is on it", never "It waits for you". */
32
+ /** The person's lead took it on (lead-voice.ts): "<name> is on it", never "It waits for you". */
38
33
  leadOnIt?: boolean;
34
+ /** What the person calls their lead (Settings → Lead); "Lead" when unnamed. */
35
+ leadName?: string;
39
36
  };
40
37
  /** The two buttons under a result ready for a person: the real next step, then a look first. */
41
38
  export declare const READY_ACTIONS: {
@@ -1,3 +1,9 @@
1
+ /** How chat sounds: a teammate talking. One or two short sentences, outcome
2
+ * first, plain words, one action at most. Every chat (Telegram, Slack,
3
+ * Discord, Teams) takes its titles and its finished-work lines from here, so a
4
+ * task id, a "— revision" suffix or a person's own act never reaches a chat.
5
+ * The console keeps every exact name and fact. */
6
+ import { leadOnIt } from "./task-status.js";
1
7
  import { phoneText } from "./telegram-status.js";
2
8
  /** The bot's display name in every chat app. */
3
9
  export const BOT_NAME = "Toolroll";
@@ -55,7 +61,7 @@ export function finishedLine(fact) {
55
61
  const ready = fact.lead === true ? `I finished ${lower}` : `${name} is ready`;
56
62
  // What waited on the person and the lead has taken on (Needs you reads Waiting then).
57
63
  if (fact.leadOnIt === true && (fact.headline === "Needs you" || fact.headline === "Waiting" || fact.headline === "Stopped"))
58
- return `${name}: your lead is on it.`;
64
+ return `${name}: ${leadOnIt(fact.leadName)}`;
59
65
  switch (fact.headline) {
60
66
  case "Ready for review":
61
67
  if (fact.report)
@@ -67,7 +73,7 @@ export function finishedLine(fact) {
67
73
  return `${ready}, but no tests ran. Look it over first.`;
68
74
  case "Failed":
69
75
  if (fact.leadOnIt === true)
70
- return fact.checks === "failed" ? `${name} is built, but its tests failed. Your lead is on it.` : `${name} stopped before it finished. Your lead is on it.`;
76
+ return fact.checks === "failed" ? `${name} is built, but its tests failed. ${leadOnIt(fact.leadName)}` : `${name} stopped before it finished. ${leadOnIt(fact.leadName)}`;
71
77
  if (fact.checks === "failed")
72
78
  return fact.lead === true
73
79
  ? `I built ${lower}, but its tests failed. It waits for you: retry or ask for changes.`
@@ -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
+ }
@@ -5,7 +5,7 @@ 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>>;
@@ -11,7 +11,7 @@ export class DiscordError extends ChatDeliveryError {
11
11
  export function discordApi(token, fetcher = fetch) {
12
12
  return async (method, path, body, file) => {
13
13
  // 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))
14
+ 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
15
  throw new DiscordError("Invalid Discord request");
16
16
  let response;
17
17
  const headers = { Authorization: `Bot ${token}` };
@@ -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>;
@@ -1,5 +1,6 @@
1
1
  /** Discord messages and buttons transport the shared assistant's saved actions. */
2
2
  import { chatQuestionButtons } from "./teammate-question.js";
3
+ import { chatAskButtons } from "./chat-ask.js";
3
4
  import { chatFlowButtons } from "./chat-flow.js";
4
5
  import { channelInbox } from "./chat-inbox.js";
5
6
  import { roomCommand } from "./chat-rooms.js";
@@ -7,6 +8,8 @@ import { ChatState, ChatDeliveryError, chatHash, } from "./chat-delivery-state.j
7
8
  import { processChatEvent, planChatNotifications, channelAccess, chatObject as object, planRoomMessages, } from "./chat-delivery.js";
8
9
  import { chatResultHref } from "./chat-controls.js";
9
10
  import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
11
+ import { discordPlain, renderReply } from "./reply-shape.js";
12
+ import { WARM_EMOJI } from "./chat-warmth.js";
10
13
  import { proposalPreview, proposalLink, proposalOutcomeText, armedCardText, armedYesLabel, } from "./chat-channel.js";
11
14
  import { resultImageFileName, safeResultImageCaption, verifyResultImage, } from "./chat-evidence.js";
12
15
  import { discordId, discordMember, DiscordError, } from "./discord-api.js";
@@ -15,6 +18,11 @@ export const discordDelivery = (options) => ({
15
18
  state: new ChatState(options.store, "discord"),
16
19
  label: "Discord",
17
20
  member: (member, channel) => discordMember(options.api, member, channel),
21
+ // A 👍 on the owner's message and "typing…" while the lead works; a missing permission is skipped silently.
22
+ warm: (event) => ({
23
+ react: () => options.api("PUT", `/channels/${event.channel}/messages/${event.ts}/reactions/${encodeURIComponent(WARM_EMOJI)}/@me`),
24
+ typing: () => options.api("POST", `/channels/${event.channel}/typing`),
25
+ }),
18
26
  partSize: 1800,
19
27
  maxProposal: 3400,
20
28
  });
@@ -139,8 +147,16 @@ function link(origin, target) {
139
147
  }
140
148
  }
141
149
  /** Escape Discord's presentation syntax; allowed_mentions also prevents actual pings. */
142
- export const discordPlain = (text) => text.replace(/([\\`*_~|>#\[\]])/g, "\\$1").replace(/@/g, "@\u200b");
143
- export function discordCard(text, buttons = []) {
150
+ export { discordPlain };
151
+ export function discordCard(text, buttons = [], voice = false) {
152
+ // The lead's own reply: one description in Discord's Markdown (bold anchors, labelled links), no title line promoted to a header.
153
+ if (voice)
154
+ return {
155
+ content: "",
156
+ embeds: [{ description: renderReply(text, "discord").slice(0, 4096), color: 0x297b70 }],
157
+ components: Array.from({ length: Math.min(5, Math.ceil(buttons.length / 5)) }, (_, row) => ({ type: 1, components: buttons.slice(row * 5, row * 5 + 5) })),
158
+ allowed_mentions: { parse: [], replied_user: false },
159
+ };
144
160
  const lines = text.split("\n"), first = lines[0] ?? "Toolroll", title = discordPlain(first).length <= 256 &&
145
161
  first.length <= 150 &&
146
162
  lines.length > 1
@@ -272,6 +288,15 @@ export async function deliverDiscordPart(options) {
272
288
  custom_id: `so_${one.token}`,
273
289
  }))
274
290
  : []),
291
+ // The lead's question to its owner: its options, then "Something else".
292
+ ...(content.ask
293
+ ? chatAskButtons(state, row.id, now).map((one) => ({
294
+ type: 2,
295
+ style: one.words ? 2 : 1,
296
+ label: one.label.slice(0, 80),
297
+ custom_id: `so_${one.token}`,
298
+ }))
299
+ : []),
275
300
  ...link(options.origin(), content.link),
276
301
  ...(content.also ?? []).flatMap(one => link(options.origin(), one)),
277
302
  ];
@@ -326,7 +351,7 @@ export async function deliverDiscordPart(options) {
326
351
  throw new DiscordError("Screenshot evidence changed before upload");
327
352
  file.bytes = fresh.bytes;
328
353
  }
329
- const args = discordCard(text, buttons);
354
+ const args = discordCard(text, buttons, content.voice === true && !content.proposal && !content.image);
330
355
  if (file)
331
356
  args.attachments = [
332
357
  { id: 0, filename: file.name, description: text.slice(0, 1024) },
@@ -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.";
@@ -0,0 +1,199 @@
1
+ import { resolveRouteCandidates } from "./agentconfig.js";
2
+ import { authPauseOf, messageCommand, providerName } from "./provider-auth.js";
3
+ import { readAuthMode, SUBSCRIPTION_CAPABLE } from "./keys.js";
4
+ import { DEFAULT_LIVENESS_MS } from "./runner.js";
5
+ import { projectToolsOf, secretsSetFor, toolStanding } from "./project-tools.js";
6
+ import { conversationSkills } from "./project-skills.js";
7
+ import { CHECK_LEVEL_WORDS, liveQuickCommand, projectCheckLevel } from "./check-levels.js";
8
+ import { STATE_WORDS } from "./integrations.js";
9
+ const ROLE_WORDS = { plan: "plan", build: "build", repair: "fix failed checks", review: "review" };
10
+ const MAX_LISTED = 12;
11
+ /** The exact provider and model pairs a project's route would use, with the roles each would play. */
12
+ function routePairs(store, repo) {
13
+ const pairs = new Map();
14
+ const resolved = resolveRouteCandidates(store, repo);
15
+ if (!resolved.ok)
16
+ return { pairs, problem: resolved.problem };
17
+ const add = (phase, candidate) => {
18
+ if (candidate === null)
19
+ return;
20
+ const key = `${candidate.provider} ${candidate.model}`;
21
+ const pair = pairs.get(key) ?? { provider: candidate.provider, model: candidate.model, roles: [] };
22
+ if (!pair.roles.includes(ROLE_WORDS[phase]))
23
+ pair.roles.push(ROLE_WORDS[phase]);
24
+ pairs.set(key, pair);
25
+ };
26
+ for (const phase of ["plan", "build", "repair", "review"]) {
27
+ const leg = resolved.candidates[phase];
28
+ add(phase, leg.routine ?? (phase === "repair" ? resolved.candidates.build.routine : null));
29
+ add(phase, leg.strong);
30
+ }
31
+ return { pairs, problem: null };
32
+ }
33
+ /** A provider's plan or quota running out, from the last readings (provider_limit and the runners' quota rows). */
34
+ function outOfBudget(store, provider, now) {
35
+ const stamp = now.toISOString();
36
+ const window = store.providerLimits().find(one => one.provider === provider && one.reached && (one.resetsAt === null || one.resetsAt > stamp));
37
+ if (window !== undefined)
38
+ return { until: window.resetsAt };
39
+ const quota = store.handle.prepare("SELECT reset_at FROM quota WHERE provider = ? AND state = 'exhausted' AND (reset_at IS NULL OR reset_at > ?) ORDER BY reset_at DESC LIMIT 1").get(provider, stamp);
40
+ return quota === undefined ? null : { until: quota["reset_at"] == null ? null : String(quota["reset_at"]) };
41
+ }
42
+ function agentsOf(store, repo, now, authMode) {
43
+ const { pairs, problem } = routePairs(store, repo);
44
+ if (problem !== null) {
45
+ return { agents: [], agentsProblem: { name: "Agents", state: "Not set up", ok: false, lets: "plan and build this project's tasks", next: `Choose an exact model for each role: ${problem}`, link: "providers" } };
46
+ }
47
+ const readiness = store.readinessLookupFor(repo, null, now);
48
+ const agents = [...pairs.values()].map(pair => {
49
+ const name = `${providerName(pair.provider)} · ${pair.model}`;
50
+ const lets = `${pair.roles.join(", ")} this project's tasks`;
51
+ const base = { provider: pair.provider, model: pair.model, roles: pair.roles, name, lets };
52
+ // Signed out only on a real sign-in failure: a sign-in pause from a run, the worker's login check saying not logged
53
+ // in, or the key a key-only provider needs missing. A provider on an API key gets the key step, never the sign-in.
54
+ const pause = authPauseOf(store, pair.provider);
55
+ const mode = pause?.authMode ?? (SUBSCRIPTION_CAPABLE[pair.provider] ? authMode(pair.provider) : "api-key");
56
+ const key = mode === "api-key";
57
+ const outWords = key ? "Needs a working API key" : "Signed out";
58
+ const outNext = (detail) => key ? `Save a working API key: run \`${messageCommand({ provider: pair.provider, authMode: "api-key" })}\` on this computer${detail}.` : `Sign in: run \`${messageCommand({ provider: pair.provider, authMode: "subscription" })}\` on this computer${detail}.`;
59
+ if (pause !== null)
60
+ return { ...base, state: outWords, ok: false, next: outNext("; its tasks start again on their own"), link: "providers" };
61
+ const seen = readiness(pair.provider);
62
+ const authFailed = seen?.state === "unavailable" && (seen.probe === "key" || (seen.probe === "identity" && !key));
63
+ if (authFailed)
64
+ return { ...base, state: outWords, ok: false, next: outNext(""), link: "providers" };
65
+ if (seen?.state === "unavailable" && seen.probe !== "identity") {
66
+ return { ...base, state: `Not available: ${seen.reason}`, ok: false, next: `Fix it on the worker: ${seen.reason}.`, link: "providers" };
67
+ }
68
+ const spent = outOfBudget(store, pair.provider, now);
69
+ if (spent !== null)
70
+ return { ...base, state: "Out of plan budget", ok: false, next: spent.until === null ? "Wait for the plan to reset, or choose another agent." : `Wait until ${spent.until}, when the plan resets, or choose another agent.`, link: "providers" };
71
+ if (seen === null)
72
+ return { ...base, state: "Not checked yet", ok: false, next: "No worker has reported this agent yet: start a worker for this project.", link: "workers" };
73
+ // On an API key, only a key check speaks for it: a CLI login check (passed or not) says nothing about the key.
74
+ if (seen.state !== "ready" || (key && seen.probe !== "key")) {
75
+ return { ...base, state: "Not checked yet", ok: false, next: key ? "Its API key has not been checked without spending; the first task shows whether it works." : "A worker could not check it without spending; the first task shows whether it works.", link: "providers" };
76
+ }
77
+ return { ...base, state: key ? "API key set" : "Signed in", ok: true, next: null, link: null };
78
+ });
79
+ return { agents, agentsProblem: null };
80
+ }
81
+ function toolsOf(store, repo) {
82
+ return projectToolsOf(store, repo).slice(0, MAX_LISTED).map(tool => {
83
+ let set = [];
84
+ try {
85
+ set = secretsSetFor(repo, tool.spec);
86
+ }
87
+ catch {
88
+ set = [];
89
+ }
90
+ const standing = toolStanding(tool, set);
91
+ const state = standing.words;
92
+ const lets = `builds in this project can use ${tool.name}${tool.spec.about ? ` (${tool.spec.about})` : ""}`;
93
+ if (state.startsWith("Needs"))
94
+ return { name: tool.name, state, ok: false, lets, next: `Add ${state.slice("Needs ".length)} on the Tools page; never in chat.`, link: "tools" };
95
+ if (state === "Last test failed")
96
+ return { name: tool.name, state, ok: false, lets, next: `Fix it and test it again on the Tools page${tool.lastTest?.problem ? `: ${tool.lastTest.problem}` : ""}.`, link: "tools" };
97
+ if (state === "Not tested yet")
98
+ return { name: tool.name, state, ok: true, lets, next: "Test it on the Tools page to be sure it works.", link: "tools" };
99
+ return { name: tool.name, state: "Working", ok: true, lets, next: null, link: null };
100
+ });
101
+ }
102
+ /** The newest test of one skill version in a project: passed, failed or still running; null when never tested. */
103
+ function lastSkillTest(store, repo, sha) {
104
+ const row = store.handle.prepare("SELECT skill_test.task_ref AS ref FROM skill_test JOIN task_ref ON task_ref.id = skill_test.task_ref WHERE task_ref.repo = ? AND skill_test.package = ? ORDER BY skill_test.task_ref DESC LIMIT 1").get(repo, sha);
105
+ if (row === undefined)
106
+ return null;
107
+ const run = store.runsFor(Number(row["ref"]))[0];
108
+ if (run === undefined || run.outcome === null)
109
+ return "running";
110
+ return run.outcome === "built" || run.outcome === "no-change" ? "passed" : "failed";
111
+ }
112
+ function skillsOf(store, repo, actor) {
113
+ let skills;
114
+ try {
115
+ skills = conversationSkills(store, repo, actor).skills;
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ return skills.filter(one => one.enabled).slice(0, MAX_LISTED).map(skill => {
121
+ const lets = `builds in this project are given the ${skill.name} skill${skill.description ? ` (${skill.description.slice(0, 120)})` : ""}`;
122
+ const test = lastSkillTest(store, repo, skill.sha);
123
+ if (test === "failed")
124
+ return { name: skill.name, state: "Last test failed", ok: false, lets, next: "Read it with get_skills, then fix and test it again on the Skills page.", link: "skills" };
125
+ const needs = skill.requirements.trim();
126
+ if (needs !== "" && test !== "passed")
127
+ return { name: skill.name, state: `Needs ${needs.slice(0, 120)}`, ok: false, lets, next: "Make sure what it needs is set up, then test it on the Skills page.", link: "skills" };
128
+ return { name: skill.name, state: test === "running" ? "Working · test running" : "Working", ok: true, lets, next: null, link: null };
129
+ });
130
+ }
131
+ function checksOf(store, repo) {
132
+ const level = projectCheckLevel(store, repo).level;
133
+ const releaseCheck = store.liveVerifyCommand(repo) !== null, quickCheck = liveQuickCommand(store, repo) !== null;
134
+ const base = { name: "Checks", level: CHECK_LEVEL_WORDS[level], quickCheck, releaseCheck, lets: "say whether a result's checks passed before you call it done" };
135
+ if (level === "off")
136
+ return { ...base, state: "Off", ok: false, next: "Builds are not checked. Turn checks on for the project if results need them.", link: "projects" };
137
+ if (!releaseCheck)
138
+ return { ...base, state: `${CHECK_LEVEL_WORDS[level]} · no release check`, ok: false, next: "No release check is set up, so no result can show a passing check. Set one for the project.", link: "projects" };
139
+ return { ...base, state: level === "quick" && !quickCheck ? "Quick · runs the release check" : `${CHECK_LEVEL_WORDS[level]} · release check set`, ok: true, next: null, link: null };
140
+ }
141
+ function workersOf(store, repos, now) {
142
+ const live = store.handle.prepare(`SELECT ref.external_id AS task, ref.repo AS repo FROM claim JOIN task_ref AS ref ON ref.id = claim.task_ref
143
+ WHERE claim.runner = ? AND claim.released_at IS NULL AND claim.expires_at > ?
144
+ AND claim.lease_generation = (SELECT MAX(newest.lease_generation) FROM claim AS newest WHERE newest.task_ref = claim.task_ref)`);
145
+ const workers = store.listRunners()
146
+ .filter(one => one.retiredAt === null && one.repos.some(repo => repos.includes(repo)))
147
+ .slice(0, MAX_LISTED)
148
+ .map(runner => {
149
+ const online = now.getTime() - Date.parse(runner.heartbeatAt) <= DEFAULT_LIVENESS_MS;
150
+ const held = live.all(runner.name, now.toISOString());
151
+ const running = held.filter(row => repos.includes(String(row["repo"]))).map(row => String(row["task"]));
152
+ const busy = store.liveClaimCount(runner.name, now);
153
+ const lets = `run tasks for ${runner.repos.filter(repo => repos.includes(repo)).length} of your projects, ${runner.capacity} at a time`;
154
+ if (!online)
155
+ return { name: runner.name, state: `Offline since ${runner.heartbeatAt}`, ok: false, online, capacity: runner.capacity, running, lets,
156
+ next: `Start that computer, or reinstall its background worker there: \`toolroll daemon install --runner ${runner.name}\`.`, link: "workers" };
157
+ const full = busy >= runner.capacity;
158
+ return { name: runner.name, state: full ? `Online · full (${busy} of ${runner.capacity})` : `Online · ${busy} of ${runner.capacity} busy`, ok: true, online, capacity: runner.capacity, running, lets,
159
+ next: full ? "New work waits for a free slot; raise its capacity on the Workers page if it should run more at once." : null, link: full ? "workers" : null };
160
+ });
161
+ if (workers.length === 0) {
162
+ return [{ name: "No worker", state: "None for your projects", ok: false, online: false, capacity: 0, running: [], lets: "run any task",
163
+ next: "Add a worker for the project: run `toolroll daemon install` on the computer that should do the work.", link: "workers" }];
164
+ }
165
+ return workers;
166
+ }
167
+ const INTEGRATION_LINK = { slack: "slack", discord: "discord" };
168
+ /** The integrations exactly as get_integrations reads them, with what each lets the lead do and the next step. */
169
+ export function integrationCapabilities(list) {
170
+ return list.map(one => {
171
+ const link = one.group === "agents" ? "providers" : one.group === "tools" ? "tools" : INTEGRATION_LINK[one.key.replace(/^.*:/, "").toLowerCase()] ?? INTEGRATION_LINK[one.name.toLowerCase()] ?? "integrations";
172
+ const lets = one.usedBy.length > 0 ? `use ${one.name} for ${one.usedBy.join(", ")}` : `use ${one.name}`;
173
+ const next = one.action.kind === "setup" ? `Not set up: set up ${one.name} in Settings` : one.action.kind === "fix" ? `Fix: ${one.action.words}` : null;
174
+ return { name: one.name, kind: one.group, state: STATE_WORDS[one.state], ok: one.state === "connected", account: one.account, usedBy: one.usedBy, lastError: one.lastError, lets, next, link: next === null ? null : link };
175
+ });
176
+ }
177
+ /** Every capability, for one project or every admitted one. Read-only and spends nothing. */
178
+ export function capabilitiesOf(store, input) {
179
+ const authMode = input.authMode ?? (provider => readAuthMode(provider));
180
+ return {
181
+ projects: input.repos.map(repo => ({ repo, ...agentsOf(store, repo, input.now, authMode), tools: toolsOf(store, repo), skills: skillsOf(store, repo, input.actor), checks: checksOf(store, repo) })),
182
+ workers: workersOf(store, input.admitted, input.now),
183
+ integrations: integrationCapabilities(input.integrations),
184
+ };
185
+ }
186
+ /** Whether a call depends on an agent, a worker or an integration, and so needs get_capabilities read first this turn:
187
+ * a task always does (a worker runs it with an agent); a flow change that sets up or starts work does; a promise to
188
+ * report on a task, attempt or check does (crew work must run), and a timed one does when it is reported on a chat app. */
189
+ const FLOW_WORK = new Set(["create", "edit", "kit", "starter", "add_trigger", "resume_trigger", "add_card"]);
190
+ export function needsCapabilities(name, args, channel) {
191
+ if (name === "propose_task")
192
+ return true;
193
+ if (name === "propose_flow")
194
+ return FLOW_WORK.has(String(args["operation"]));
195
+ if (name === "commit_to")
196
+ return args["when"] === "time" ? channel !== undefined && channel !== "chat" : true;
197
+ return false;
198
+ }
199
+ export 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.";