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
@@ -47,6 +47,7 @@ export function browserCrewFromIndex(page, conversationId) {
47
47
  id: summary.rootId, title: summary.title, project: summary.repo,
48
48
  state: summary.assignmentState, label: status.label, tone: status.tone, href: link(href), resultHref: resultHref === null ? null : link(resultHref),
49
49
  action: action === null || actionHref === null ? null : { label: action.label, href: link(actionHref) },
50
+ ...(summary.lead === undefined ? {} : { lead: summary.lead }),
50
51
  };
51
52
  return { item, rank: status.rank };
52
53
  });
@@ -46,6 +46,8 @@ export type BrowserCrewItem = {
46
46
  label: string;
47
47
  href: string;
48
48
  } | null;
49
+ /** "<name> is on it.": the person's own lead took it on, by the name they gave it. */
50
+ lead?: string;
49
51
  };
50
52
  export type BrowserMessage = {
51
53
  id: number;
@@ -1024,6 +1026,8 @@ export type BrowserWorkspace = {
1024
1026
  user: string;
1025
1027
  csrf: string;
1026
1028
  sensitive: boolean;
1029
+ /** What this person named their lead (Settings → Lead): the chat header and every lead message say it. */
1030
+ leadName?: string;
1027
1031
  refreshUrl: string;
1028
1032
  receipt: {
1029
1033
  request: string;
@@ -1118,6 +1122,7 @@ export type BrowserHome = {
1118
1122
  allHref: string;
1119
1123
  /** What this person's lead is doing now and when it last acted (lead-voice.ts); its task when it named one. */
1120
1124
  lead?: {
1125
+ name?: string;
1121
1126
  doing: string;
1122
1127
  at: string;
1123
1128
  href: string | null;
@@ -196,6 +196,11 @@ export declare const CHAT_ACTIONS: {
196
196
  readonly protected: false;
197
197
  readonly password: false;
198
198
  };
199
+ readonly lead_about_you: {
200
+ readonly label: "Remember";
201
+ readonly protected: false;
202
+ readonly password: false;
203
+ };
199
204
  readonly scope_approve: {
200
205
  readonly label: "Approve work";
201
206
  readonly protected: true;
@@ -222,6 +227,8 @@ export type ChatAction = keyof typeof CHAT_ACTIONS;
222
227
  * yes/cancel challenge instead of the console's secure screen. Password
223
228
  * actions and long or redacted terms never qualify. */
224
229
  export declare const CHALLENGE_ACTIONS: ReadonlySet<ChatAction>;
230
+ /** Actions about the person themselves, in no project: their card carries no project (repo ""). */
231
+ export declare const OWNER_ACTIONS: ReadonlySet<ChatAction>;
225
232
  export declare function isChatAction(value: unknown): value is ChatAction;
226
233
  export type SharedAction = {
227
234
  operation: ChatAction;
@@ -32,6 +32,7 @@ import { kitInstalled, kitOf, KITS, setUpKitNow } from "./kits.js";
32
32
  import { starterFlowOf, starterOf, startersFor, starterTerms, STARTER_FLOWS, switchOnStarter } from "./flow-starters.js";
33
33
  import { answerTeammateQuestion } from "./teammate-work.js";
34
34
  import { callWords, checkRule, defaultRule, grantListed, revokeTool, ruleWords, setToolRules } from "./teammate-tools.js";
35
+ import { aboutYouOf, checkAboutYouLine, saveAboutYou, withAboutYouLine } from "./lead-about.js";
35
36
  /** A tool from the lead's card: a common tool by its id, or the operator's own program or address. */
36
37
  function toolSpecFromRequest(input) {
37
38
  if (input["catalog"] !== undefined) {
@@ -107,6 +108,8 @@ export const CHAT_ACTIONS = {
107
108
  kit_setup: { label: "Set up kit", protected: false, password: false },
108
109
  decision_record: { label: "Record decision", protected: false, password: false },
109
110
  decision_retire: { label: "Retire decision", protected: false, password: false },
111
+ // What the owner's own lead knows about them: theirs alone, in no project (lead-about.ts).
112
+ lead_about_you: { label: "Remember", protected: false, password: false },
110
113
  scope_approve: { label: "Approve work", protected: true, password: true },
111
114
  result_accept: { label: "Accept and finish", protected: true, password: false },
112
115
  task_cancel: { label: "Cancel task", protected: true, password: false },
@@ -116,6 +119,8 @@ export const CHAT_ACTIONS = {
116
119
  * yes/cancel challenge instead of the console's secure screen. Password
117
120
  * actions and long or redacted terms never qualify. */
118
121
  export const CHALLENGE_ACTIONS = new Set(["result_accept"]);
122
+ /** Actions about the person themselves, in no project: their card carries no project (repo ""). */
123
+ export const OWNER_ACTIONS = new Set(["lead_about_you"]);
119
124
  export function isChatAction(value) {
120
125
  return typeof value === "string" && Object.hasOwn(CHAT_ACTIONS, value);
121
126
  }
@@ -160,19 +165,21 @@ export const CHAT_ACTION_FIELDS = {
160
165
  kit_setup: ["repo", "kit"],
161
166
  decision_record: ["repo", "claim", "why", "supersedes", "source"],
162
167
  decision_retire: ["repo", "decision", "reason"],
168
+ lead_about_you: ["line", "replaces"],
163
169
  scope_approve: ["task"],
164
170
  result_accept: ["task", "run"],
165
171
  task_cancel: ["task"],
166
172
  task_resume: ["task", "run"],
167
173
  };
168
174
  const nonceHash = (value) => createHash("sha256").update(value).digest("hex");
169
- function requireActor(store, who, repo) {
175
+ function requireActor(store, who, repo, operation) {
170
176
  if (!store.schemaCurrent())
171
177
  throw Error("The running build changed. Reload before proposing an action.");
178
+ // An action about the person themselves names no project; every other one names one they can reach.
179
+ const owned = repo === "" && operation !== undefined && OWNER_ACTIONS.has(operation);
172
180
  if (!isVerifiedApprover(who) ||
173
181
  !reproveApprover(store, who).ok ||
174
- !who.repos.includes(repo) ||
175
- !store.accountCanAccess(who.name, repo))
182
+ (!owned && (!who.repos.includes(repo) || !store.accountCanAccess(who.name, repo))))
176
183
  throw Error("This project is outside your current access.");
177
184
  }
178
185
  function text(input, key, cap, optional = false) {
@@ -267,6 +274,28 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
267
274
  const allowed = CHAT_ACTION_FIELDS[operation];
268
275
  if (Object.keys(input).some((key) => !allowed.includes(key)))
269
276
  throw Error("This action contains an unsupported field.");
277
+ if (operation === "lead_about_you") {
278
+ // The owner's own note: the card shows the line, and the line it replaces beside it.
279
+ const checked = checkAboutYouLine(input["line"]);
280
+ if (!checked.ok)
281
+ throw Error(checked.message);
282
+ const lines = aboutYouOf(store, who.name);
283
+ const replaces = input["replaces"] === undefined || input["replaces"] === 0 ? 0 : integer(input, "replaces");
284
+ const next = withAboutYouLine(lines, checked.line, replaces);
285
+ if (!next.ok)
286
+ throw Error(next.message);
287
+ requireActor(store, who, "", operation);
288
+ const request = { line: checked.line, ...(replaces === 0 ? {} : { replaces }) };
289
+ const terms = replaces === 0
290
+ ? [checked.line, "Your lead keeps this about you in every project. Change it in Settings → Lead."]
291
+ : [`Was: ${lines[replaces - 1]}`, `Now: ${checked.line}`, "Replaces that line in what your lead knows about you. Change it in Settings → Lead."];
292
+ // An added line goes on the end, so it holds whatever else changed; a replacing card holds only while the line
293
+ // it replaces is still that line.
294
+ const state = replaces === 0 ? {} : { was: lines[replaces - 1] };
295
+ const title = replaces === 0 ? "Remember about you" : "Update what your lead knows about you";
296
+ const stamp = hash({ operation, request, repo: "", state, terms, actor: who.name, generation: who.generation, ceiling: who.ceilingDigest });
297
+ return { operation, request, repo: "", title, terms, stamp, state };
298
+ }
270
299
  const task = operation.startsWith("skill_") || operation.startsWith("knowledge_") || operation.startsWith("decision_") || operation.startsWith("tool_") || operation.startsWith("flow_") || operation.startsWith("teammate_") || operation.startsWith("kit_")
271
300
  ? null
272
301
  : text(input, "task", 64);
@@ -954,7 +983,7 @@ function savedActionContext(store, who, id, state) {
954
983
  (!shared && turn?.session !== session.id) ||
955
984
  turn?.state !== "answered")
956
985
  throw Error("This conversation ended or its project access changed. Ask for a fresh proposal.");
957
- requireActor(store, who, payload.repo);
986
+ requireActor(store, who, payload.repo, payload.operation);
958
987
  return payload;
959
988
  }
960
989
  export function mintSharedActionReview(store, who, id, root, now) {
@@ -1013,6 +1042,13 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1013
1042
  return store.savepoint(() => {
1014
1043
  const task = typeof req["task"] === "string" ? req["task"] : null, repo = payload.repo, actor = who.name;
1015
1044
  let taskId = task;
1045
+ if (payload.operation === "lead_about_you") {
1046
+ const next = withAboutYouLine(aboutYouOf(store, actor), String(req["line"]), Number(req["replaces"] ?? 0));
1047
+ if (!next.ok)
1048
+ throw Error(next.message);
1049
+ saveAboutYou(store, actor, next.lines, now);
1050
+ return { ok: true, taskId: null, said: req["replaces"] === undefined ? "Your lead will remember this." : "Updated what your lead knows about you.", href: "/settings/lead" };
1051
+ }
1016
1052
  if (payload.operation === "skill_import")
1017
1053
  importSkill(store, repo, actor, req["files"], "Chat import", now);
1018
1054
  else if (payload.operation === "skill_test")
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The lead's question to its owner (ask_owner) in Slack, Discord and Teams.
3
+ *
4
+ * The reply's last part carries the question with one button per option and
5
+ * one for "Something else", minted when the part is planned (chat_ask_action).
6
+ * A tapped option is sent as the owner's next message in their own chat with
7
+ * Toolroll, so the lead reads it like anything they typed; "Something else"
8
+ * asks them to type it. Buttons are drawn only in that own chat (any thread of
9
+ * it): in a room the question goes out as text, since a tap there is not the
10
+ * owner's own message. A button works once, only for the person the lead
11
+ * asked, and only while nobody has written in that thread since.
12
+ */
13
+ import type { ChatBinding, ChatEvent, ChatState } from "./chat-delivery-state.js";
14
+ import { type MateAsk, type Store } from "./store.js";
15
+ /** The question as text with its options, where no buttons are drawn. */
16
+ export declare function chatAskText(ask: Pick<MateAsk, "question" | "options">): string;
17
+ /** The live buttons on one part, in the order they were minted: the options, then "Something else". None when the part
18
+ * is not in its owner's own chat with Toolroll (a room), where a tap could not be their message. */
19
+ export declare function chatAskButtons(state: ChatState, part: number, now: Date): Array<{
20
+ token: string;
21
+ label: string;
22
+ words: boolean;
23
+ }>;
24
+ /** A tapped ask button, inside the action's transaction; false when the token isn't one. */
25
+ export declare function applyChatAskTap(options: {
26
+ store: Store;
27
+ state: ChatState;
28
+ }, event: ChatEvent, binding: ChatBinding, token: string, now: Date): boolean;
@@ -0,0 +1,91 @@
1
+ import { chatHash } from "./chat-delivery-state.js";
2
+ import { MATE_ASK_OTHER } from "./store.js";
3
+ /** The question as text with its options, where no buttons are drawn. */
4
+ export function chatAskText(ask) {
5
+ return [ask.question, ...ask.options.map(one => `• ${one}`), `• ${MATE_ASK_OTHER}: say it in your own words`].join("\n");
6
+ }
7
+ /** The live buttons on one part, in the order they were minted: the options, then "Something else". None when the part
8
+ * is not in its owner's own chat with Toolroll (a room), where a tap could not be their message. */
9
+ export function chatAskButtons(state, part, now) {
10
+ const row = state.prepare("SELECT p.payload,e.channel,b.channel AS own FROM chat_part p JOIN chat_event e ON e.id=p.event JOIN chat_binding b ON b.id=e.binding WHERE p.id=?").get(part);
11
+ if (row === undefined)
12
+ return [];
13
+ const content = JSON.parse(String(row["payload"]));
14
+ if ((content.channel ?? row["channel"]) !== row["own"])
15
+ return [];
16
+ const options = content.ask?.options ?? [];
17
+ return state.prepare("SELECT token,choice FROM chat_ask_action WHERE part=? AND consumed IS NULL AND expires>? ORDER BY rowid").all(part, now.toISOString())
18
+ .map(one => {
19
+ const label = one.choice === null ? MATE_ASK_OTHER : options[Number(one.choice)] ?? null;
20
+ return label === null ? null : { token: String(one.token), label: label.slice(0, 75), words: one.choice === null };
21
+ })
22
+ .filter((one) => one !== null);
23
+ }
24
+ /** How old a part may be and still be sent: older ones are dropped undelivered (slack-chat.ts, discord-chat.ts,
25
+ * teams-chat.ts), so an edit to one would never land. */
26
+ const EDIT_WINDOW_MS = 86_400_000;
27
+ /** Show the question again with what happened, and without its buttons: one edit, under the part's own time. A question
28
+ * too old to edit gets the line once as a new message instead. */
29
+ function repaint(state, part, event, line, now) {
30
+ const row = state.prepare("SELECT payload,created FROM chat_part WHERE id=?").get(part);
31
+ if (row === undefined || new Date(String(row["created"])).getTime() + EDIT_WINDOW_MS <= now.getTime()) {
32
+ state.plan(event.id, [{ text: line }], now);
33
+ return;
34
+ }
35
+ const before = JSON.parse(String(row["payload"]));
36
+ const content = { text: `${before.text}\n\n${line}`.slice(0, 3400), edit: event.ts, ...(before.channel === undefined ? {} : { channel: before.channel }) };
37
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE id=?").run(JSON.stringify(content), part);
38
+ state.finish(event.id);
39
+ }
40
+ /** A tapped ask button, inside the action's transaction; false when the token isn't one. */
41
+ export function applyChatAskTap(options, event, binding, token, now) {
42
+ const { store, state } = options;
43
+ const action = state.prepare("SELECT a.*,p.message,e.binding AS owner,e.channel FROM chat_ask_action a JOIN chat_part p ON p.id=a.part JOIN chat_event e ON e.id=p.event WHERE a.token=?").get(token);
44
+ if (action === undefined)
45
+ return false;
46
+ const say = (text) => state.plan(event.id, [{ text }], now);
47
+ // Bound to the person, their own chat and the exact message the button rode.
48
+ if (Number(action["owner"]) !== binding.id || action["channel"] !== event.channel || event.channel !== binding.channel || action["message"] !== event.ts) {
49
+ say("That button expired or was already used.");
50
+ return true;
51
+ }
52
+ const turn = Number(action["turn"]), part = Number(action["part"]);
53
+ // Only the person the lead asked answers it: anyone else's tap changes nothing and says nothing.
54
+ const asker = store.getMateTurn(turn)?.approver;
55
+ if (asker !== undefined && asker !== binding.approver) {
56
+ state.finish(event.id);
57
+ return true;
58
+ }
59
+ const found = asker === undefined ? { state: "expired" } : store.mateAskState(turn, now);
60
+ const expired = found.state === "expired" || String(action["expires"]) <= now.toISOString();
61
+ // A question already settled was shown so once (or is being): a later tap says nothing more.
62
+ if (action["consumed"] !== null) {
63
+ state.finish(event.id);
64
+ return true;
65
+ }
66
+ if (found.state !== "open" || expired) {
67
+ state.prepare("UPDATE chat_ask_action SET consumed=? WHERE part=? AND consumed IS NULL").run(now.toISOString(), part);
68
+ // The question is shown again without its buttons, so a later tap has nothing to press.
69
+ repaint(state, part, event, found.state === "answered" ? "This question was already answered." : "This question expired. If it still matters, send your answer as a message.", now);
70
+ return true;
71
+ }
72
+ const ask = found.ask;
73
+ if (action["choice"] === null) {
74
+ say("Type your answer here as your next message.");
75
+ return true;
76
+ }
77
+ const option = ask.options[Number(action["choice"])];
78
+ if (option === undefined) {
79
+ say("That option is no longer there.");
80
+ return true;
81
+ }
82
+ state.prepare("UPDATE chat_ask_action SET consumed=? WHERE part=? AND consumed IS NULL").run(now.toISOString(), part);
83
+ // The option is the owner's next message, answered like one they typed.
84
+ state.enqueue({
85
+ id: chatHash(`${state.channel}:ask:${event.id}`).slice(0, 32), installation: event.installation, binding: binding.id, kind: "message",
86
+ channel: event.channel, member: event.member, ts: event.ts, thread: event.thread,
87
+ payload: JSON.stringify({ text: option, originalLength: option.length }), created: now.toISOString(),
88
+ });
89
+ repaint(state, part, event, `You chose: ${option}`, now);
90
+ return true;
91
+ }
@@ -117,7 +117,7 @@ export function mirrorToTaskChat(store, who, taskId, surface, message, reply, no
117
117
  }
118
118
  /** The one short message a person gets when their question could not be answered. */
119
119
  export function couldNotAnswerText(reason) {
120
- return `I couldn't answer that just now: ${reason}. Ask again, or open the console.`;
120
+ return `I couldn't answer that just now: ${reason.replace(/\.$/, "")}. Ask again, or open the console.`;
121
121
  }
122
122
  export function tooLongText(length) {
123
123
  return `That message is ${length.toLocaleString("en-US")} characters; chat takes up to ${MATE_MESSAGE_MAX_CHARS.toLocaleString("en-US")}. Nothing was sent to the assistant. Send it in shorter parts, or say which part matters most.`;
@@ -511,6 +511,13 @@ export const CHAT_ACTION_PARITY = {
511
511
  propose_teammate: { support: "direct", how: "Adds a teammate from a template or a soul file, changes one section of its soul file (or the whole short file), pauses, resumes or removes it, passes it a note, or answers its question for the person asked, through the shared confirm door.", gap: "Soul files longer than one message are edited on the console's teammate page; the phone changes one section at a time." },
512
512
  get_flow_insights: { support: "direct", how: "Read during a turn: where each flow's cards pass, fail or are sent back, how long they wait, how its scripts did, and a run's log.", gap: null },
513
513
  propose_flow: { support: "direct", how: "Creates or changes a flow, adds, moves, approves, sends back or cancels its cards, comments on them (@name pings that person), sets their owner, follows them, saves the project's scripts, and adds, pauses or removes its triggers, through the shared confirm door; a long drawing opens the secure review. Work a card files is an ordinary task under the usual approvals.", gap: "The flow canvas itself is on the console; the phone confirms cards but draws nothing. Webhook addresses and the Linear key are set on the console's Triggers panel." },
514
+ commit_to: { support: "direct", how: "Records what the lead promised to follow up on (a task, attempt, check or time; 7 days at most). The follow pass says one line when it is met, in the shared conversation and on the chat the promise was made on.", gap: null },
515
+ release_commitment: { support: "direct", how: "Stops following up on one of the person's open promises, with the reason.", gap: null },
516
+ remember: { support: "direct", how: "Proposes a correction or lasting preference as a decision, project instruction or about-you card, through the shared confirm door; an about-you card shows the line it replaces.", gap: null },
517
+ get_person: { support: "direct", how: "Read during a turn: one person, AI teammate or team chat in full, with their open tasks.", gap: null },
518
+ get_integrations: { support: "direct", how: "Read during a turn: which chat apps, email, GitHub, project tools and monitoring are Connected, Not set up or Broken, as Settings → Integrations shows them.", gap: null },
519
+ get_capabilities: { support: "direct", how: "Read during a turn: which agents are signed in, which workers are online, which tools and skills work, which integrations are connected and what checks the project runs, each with its next step and settings link.", gap: null },
520
+ ask_owner: { support: "direct", how: "One question with 2-4 options and Something else, drawn as buttons under the reply; the tapped option is sent as the person's next message.", gap: null },
514
521
  search_project_memory: { support: "direct", how: "Read during a turn: one search over decisions, references, lessons and the conversations the person may read.", gap: null },
515
522
  list_tasks: { support: "direct", how: "Read during a turn.", gap: null },
516
523
  get_task: {
@@ -66,6 +66,14 @@ export declare const CHAT_CONTROLS: {
66
66
  readonly label: "Manage Discord";
67
67
  readonly href: "/settings/discord";
68
68
  };
69
+ readonly lead: {
70
+ readonly label: "Name your lead";
71
+ readonly href: "/settings/lead";
72
+ };
73
+ readonly integrations: {
74
+ readonly label: "Set up integrations";
75
+ readonly href: "/settings/integrations";
76
+ };
69
77
  readonly settings: {
70
78
  readonly label: "Open settings";
71
79
  readonly href: "/settings";
@@ -18,6 +18,9 @@ export const CHAT_CONTROLS = {
18
18
  workers: { label: "Manage workers", href: "/fleet" },
19
19
  slack: { label: "Manage Slack", href: "/settings/slack" },
20
20
  discord: { label: "Manage Discord", href: "/settings/discord" },
21
+ // Settings → Lead: the lead's name and persona, changed there by its person.
22
+ lead: { label: "Name your lead", href: "/settings/lead" },
23
+ integrations: { label: "Set up integrations", href: "/settings/integrations" },
21
24
  settings: { label: "Open settings", href: "/settings" },
22
25
  permissions: { label: "Review permissions", href: "/settings" },
23
26
  providers: { label: "Connect an agent", href: "/settings#providers" },
@@ -1,4 +1,4 @@
1
- import type { Store } from "./store.js";
1
+ import { type Store } from "./store.js";
2
2
  export type ChatIdentity = {
3
3
  installation: string;
4
4
  team?: string;
@@ -95,6 +95,13 @@ export type ChatContent = {
95
95
  label: string;
96
96
  }>;
97
97
  };
98
+ /** The lead's question to its owner rides this part: its options, then "Something else". */
99
+ ask?: {
100
+ turn: number;
101
+ options: string[];
102
+ };
103
+ /** The lead's own reply, already shaped (reply-shape.ts): the channel renders its bold anchors and labelled links in its own format. */
104
+ voice?: true;
98
105
  };
99
106
  export declare const chatHash: (text: string) => string;
100
107
  export declare class ChatState {
@@ -1,5 +1,6 @@
1
1
  /** Durable private-chat receipts. Each transport has its own tables and lease. */
2
2
  import { createHash, randomBytes } from "node:crypto";
3
+ import { MATE_ASK_TTL_MS } from "./store.js";
3
4
  const CHAT_SCHEMA = `
4
5
  CREATE TABLE IF NOT EXISTS chat_binding (
5
6
  id INTEGER PRIMARY KEY AUTOINCREMENT, installation TEXT NOT NULL,
@@ -93,6 +94,15 @@ CREATE TABLE IF NOT EXISTS chat_question_prompt (
93
94
  );
94
95
  CREATE INDEX IF NOT EXISTS chat_question_prompt_open ON chat_question_prompt(binding, consumed);
95
96
  `;
97
+ /** The lead's question to its owner (ask_owner) in the chat app: one button per option (choice is its index) and one for
98
+ * "Something else" (choice NULL). A tap sends the option as the owner's next message. Never in CHAT_TABLES. */
99
+ const CHAT_ASK_SCHEMA = `
100
+ CREATE TABLE IF NOT EXISTS chat_ask_action (
101
+ token TEXT PRIMARY KEY, part INTEGER NOT NULL REFERENCES chat_part(id) ON DELETE CASCADE,
102
+ turn INTEGER NOT NULL, choice INTEGER, expires TEXT NOT NULL, consumed TEXT
103
+ );
104
+ CREATE INDEX IF NOT EXISTS chat_ask_action_turn ON chat_ask_action(turn);
105
+ `;
96
106
  const CHAT_TABLES = [
97
107
  "chat_binding",
98
108
  "chat_pair",
@@ -111,7 +121,7 @@ export class ChatState {
111
121
  this.channel = channel;
112
122
  }
113
123
  prepare(sql) {
114
- return this.db.prepare(sql.replace(/\bchat_(binding|pair|event|part|action|progress|runtime|room|meta|flow_action|flow_prompt|question_action|question_prompt)\b/g, `${this.channel}_$1`));
124
+ return this.db.prepare(sql.replace(/\bchat_(binding|pair|event|part|action|progress|runtime|room|meta|flow_action|flow_prompt|question_action|question_prompt|ask_action)\b/g, `${this.channel}_$1`));
115
125
  }
116
126
  /** Small transport facts a channel must remember per chat (a Teams service URL). Never credentials. */
117
127
  meta(installation, key) {
@@ -273,6 +283,9 @@ export class ChatState {
273
283
  if (Number(inserted.changes) && part.question)
274
284
  for (const one of part.question.choices)
275
285
  this.prepare("INSERT INTO chat_question_action(token,part,question,choice,expires) VALUES(?,?,?,?,?)").run(randomBytes(16).toString("hex"), Number(inserted.lastInsertRowid), part.question.id, one.choice, new Date(now.getTime() + 7 * 86_400_000).toISOString());
286
+ if (Number(inserted.changes) && part.ask)
287
+ for (const choice of [...part.ask.options.map((_, index) => index), null])
288
+ this.prepare("INSERT INTO chat_ask_action(token,part,turn,choice,expires) VALUES(?,?,?,?,?)").run(randomBytes(16).toString("hex"), Number(inserted.lastInsertRowid), part.ask.turn, choice, new Date(now.getTime() + MATE_ASK_TTL_MS).toISOString());
276
289
  if (Number(inserted.changes) && part.flow)
277
290
  for (const action of part.flow.actions)
278
291
  this.prepare("INSERT INTO chat_flow_action(token,part,card,entry,action,expires) VALUES(?,?,?,?,?,?)").run(randomBytes(16).toString("hex"), Number(inserted.lastInsertRowid), part.flow.card, part.flow.entry, action, new Date(now.getTime() + 7 * 86_400_000).toISOString());
@@ -294,7 +307,7 @@ export class ChatState {
294
307
  return !!this.prepare("SELECT 1 FROM chat_runtime WHERE installation=? AND owner=? AND lease_until>?").get(installation, owner, now.toISOString());
295
308
  }
296
309
  }
297
- export const chatSchema = (channel) => (CHAT_SCHEMA + CHAT_ROOM_SCHEMA + CHAT_FLOW_SCHEMA + CHAT_QUESTION_SCHEMA).replaceAll("chat_", `${channel}_`);
310
+ export const chatSchema = (channel) => (CHAT_SCHEMA + CHAT_ROOM_SCHEMA + CHAT_FLOW_SCHEMA + CHAT_QUESTION_SCHEMA + CHAT_ASK_SCHEMA).replaceAll("chat_", `${channel}_`);
298
311
  export const chatTables = (channel) => CHAT_TABLES.map((name) => name.replace("chat_", `${channel}_`));
299
312
  export class ChatDeliveryError extends Error {
300
313
  code;
@@ -1,3 +1,4 @@
1
+ import { type WarmHooks } from "./chat-warmth.js";
1
2
  import { type DoorOptions } from "./mate-doors.js";
2
3
  import { ChatState, type ChatBinding, type ChatEvent, type ChatIdentity } from "./chat-delivery-state.js";
3
4
  import { type Store } from "./store.js";
@@ -20,6 +21,8 @@ export type ChatDeliveryOptions = {
20
21
  clock?: () => Date;
21
22
  partSize?: number;
22
23
  maxProposal?: number;
24
+ /** The app's warm touches for one owner message (a 👍, typing), where the app and the bot's permissions allow them. */
25
+ warm?: (event: ChatEvent, binding: ChatBinding) => WarmHooks;
23
26
  };
24
27
  export declare const splitChatText: (text: string, size?: number) => string[];
25
28
  export declare function channelAccess(options: ChatDeliveryOptions, binding: ChatBinding, ceiling?: string): Promise<string[]>;
@@ -1,7 +1,10 @@
1
1
  /** Shared saved replies, explicit confirmations and progress for private chat transports. */
2
2
  import { answerChatQuestionPrompt, applyChatQuestionTap, questionParts } from "./teammate-question.js";
3
+ import { applyChatAskTap, chatAskText } from "./chat-ask.js";
3
4
  import { channelRepos, resolveChannelMate, proposalPreview, proposalOutcomeText, confirmedLink, confirmedCardText, replyContextFor, mirrorToTaskChat, focusContextFor, taskInCeiling, tooLongText, } from "./chat-channel.js";
4
- 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";
5
8
  import { confirmMateProposal, dismissMateProposal, } from "./mate-doors.js";
6
9
  import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
7
10
  import { ChatState, ChatDeliveryError, chatHash, } from "./chat-delivery-state.js";
@@ -11,7 +14,9 @@ import { triggerConfigOf } from "./flow-triggers.js";
11
14
  import { telegramProgressCard } from "./telegram-progress.js";
12
15
  import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
13
16
  import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
14
- import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText } from "./lead-voice.js";
17
+ import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText, leadSubjectOf } from "./lead-voice.js";
18
+ import { leadChannelOf } from "./lead-context.js";
19
+ import { promiseChannelOf } from "./lead-commitments.js";
15
20
  import { phoneText, PHONE_HELP, phoneCommand, phoneStatus, phoneTaskView, phoneTaskChoices, phoneTaskListText, resolvePhoneTask, phoneFocusText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
16
21
  import { applyRoomInbound, conversationRow, roomCardApprover, roomCommand, roomGrantAllowed, roomMessagesAfter, roomMessageText, teamDomain } from "./chat-rooms.js";
17
22
  import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
@@ -22,7 +27,8 @@ export const chatObject = (v) => v !== null && typeof v === "object" && !Array.i
22
27
  : {};
23
28
  const object = chatObject;
24
29
  const nowOf = (options) => options.clock?.() ?? new Date();
25
- export const splitChatText = (text, size = 2800) => {
30
+ const CHAT_PART_SIZE = 2800;
31
+ export const splitChatText = (text, size = CHAT_PART_SIZE) => {
26
32
  const parts = [];
27
33
  for (let at = 0; at < text.length;) {
28
34
  let end = Math.min(at + size, text.length);
@@ -218,7 +224,7 @@ export async function processChatEvent(options) {
218
224
  return true;
219
225
  }
220
226
  if (event.session !== null && event.session !== resolved.session.id) {
221
- state.plan(event.id, [{ text: "Your chat session changed. Send your message again." }], now);
227
+ state.plan(event.id, [{ text: "This conversation was restarted after you sent that, so it wasn't answered. Send your message again." }], now);
222
228
  return true;
223
229
  }
224
230
  state
@@ -258,6 +264,11 @@ export async function processChatEvent(options) {
258
264
  .prepare("UPDATE chat_event SET payload=? WHERE id=? AND state='queued'")
259
265
  .run(JSON.stringify({ ...input, about }), event.id);
260
266
  }
267
+ // An unknown surface names no channel rather than a guess.
268
+ const channel = leadChannelOf(options.label);
269
+ // Only ever the owner's own message in their own chat: never the bot's, never a room.
270
+ const warm = warmTurn(options.warm !== undefined && event.kind === "message" && event.member !== identity.bot && event.member === binding.member && event.channel === binding.channel
271
+ ? options.warm(event, binding) : {});
261
272
  const outcome = await runMateTurn({
262
273
  store,
263
274
  who: resolved.who,
@@ -276,19 +287,21 @@ export async function processChatEvent(options) {
276
287
  clock: () => nowOf(options),
277
288
  evidenceRoot: options.evidenceRoot,
278
289
  mediaDelivery: "documents",
290
+ ...(channel === undefined ? {} : { channel }),
291
+ onProgress: warm.onProgress,
279
292
  revalidate: async () => {
280
293
  try {
281
294
  await channelAccess(options, binding, resolved.who.ceilingDigest);
282
295
  return { ok: true };
283
296
  }
284
- catch {
285
- return {
286
- ok: false,
287
- reason: `${options.label} access could not be verified`,
288
- };
297
+ catch (error) {
298
+ const code = error instanceof ChatDeliveryError ? error.code : "";
299
+ const reason = code === "Connected projects changed" ? "projects-changed"
300
+ : code.endsWith("account access changed") ? "member-changed" : "access-changed";
301
+ return { ok: false, reason };
289
302
  }
290
303
  },
291
- });
304
+ }).finally(warm.stop);
292
305
  if (outcome.ok && !outcome.replayed && context?.task)
293
306
  mirrorToTaskChat(store, resolved.who, context.task, options.label, text, outcome.reply, nowOf(options));
294
307
  if (!state.owns(identity.installation, options.owner, nowOf(options)) ||
@@ -321,7 +334,7 @@ export async function processChatEvent(options) {
321
334
  if (turn?.state !== "answered") {
322
335
  state.plan(event.id, [
323
336
  {
324
- text: "The assistant’s reply did not finish. No proposed changes were kept. Send your message again to retry.",
337
+ text: mateFailureText(turn?.failureReason ?? null),
325
338
  },
326
339
  ], nowOf(options));
327
340
  return true;
@@ -336,7 +349,15 @@ export async function processChatEvent(options) {
336
349
  .listMateMessages(turn.thread, 200)
337
350
  .find((message) => message.turn === turn.id && message.role === "assistant")?.text ?? "The reply is no longer in the saved thread.";
338
351
  // The lead's answer about one task names it: a reply to it stays on that task.
339
- const parts = splitChatText(reply, options.partSize).map((text) => (about === null ? { text } : { text, task: about.task, ...(about.run === null ? {} : { run: about.run }) }));
352
+ // Split as written, then shape each part: no cut ever lands inside a link, code or a bold anchor.
353
+ const shapedParts = shapeReplyParts(reply, options.partSize ?? CHAT_PART_SIZE, { asked: text, appOrigin: options.origin() });
354
+ 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 }) }));
355
+ // The lead's question to its owner: its options as buttons, then "Something else", in their own chat (any thread);
356
+ // in a room a tap is not their message, so the question and its options go out as text.
357
+ const ask = store.mateAsk(turn.id);
358
+ if (ask !== null)
359
+ parts.push({ ...(event.channel === binding.channel ? { text: phoneText(ask.question, 1000), ask: { turn: ask.turn, options: ask.options } } : { text: phoneText(chatAskText(ask), 1000) }),
360
+ ...(about === null ? {} : { task: about.task, ...(about.run === null ? {} : { run: about.run }) }) });
340
361
  for (const image of store.listMateTurnEvidence(turn.id))
341
362
  parts.push({
342
363
  text: image.caption,
@@ -432,6 +453,9 @@ export function applyChatAction(options, event, binding, repos) {
432
453
  // A teammate's question's button (v93) is answered by the question's own door.
433
454
  if (applyChatQuestionTap({ store, state, label: options.label }, event, binding, token, now))
434
455
  return;
456
+ // The lead's question to its owner: the tapped option becomes their next message.
457
+ if (applyChatAskTap({ store, state }, event, binding, token, now))
458
+ return;
435
459
  const action = state
436
460
  .prepare("SELECT a.*,p.message,e.binding,e.channel,e.thread FROM chat_action a JOIN chat_part p ON p.id=a.part JOIN chat_event e ON e.id=p.event WHERE token=?")
437
461
  .get(token);
@@ -566,8 +590,13 @@ export async function planRoomMessages(options) {
566
590
  if (carrier === null)
567
591
  break;
568
592
  const parts = [];
569
- if (text !== null)
570
- parts.push({ text, channel: room.chat });
593
+ if (text !== null) {
594
+ if (message.role === "assistant")
595
+ for (const part of shapeReplyParts(text, options.partSize ?? CHAT_PART_SIZE, { appOrigin: options.origin() }))
596
+ parts.push({ text: part, voice: true, channel: room.chat });
597
+ else
598
+ parts.push({ text, channel: room.chat });
599
+ }
571
600
  if (message.role === "assistant" && message.turn !== null)
572
601
  for (const proposal of store.listMateProposals(row.thread, ["pending"]).filter(one => one.turn === message.turn))
573
602
  parts.push({ text: "", proposal: proposal.id, channel: room.chat });
@@ -665,8 +694,11 @@ export async function planChatNotifications(options) {
665
694
  const personal = notification.recipient !== null;
666
695
  if (notification.createdAt >= binding.created &&
667
696
  notification.resolvedAt === null &&
697
+ // A promise the lead made on another chat is reported there (lead-commitments.ts); one its owner asked for on
698
+ // this chat is always said here, however quiet the lead's own work is kept.
699
+ (promiseChannelOf(notification) ?? state.channel) === state.channel &&
668
700
  // Pings follow responsibility: the lead's work, this person's own act and a muted project stay in the console.
669
- store.pingAllowed(notification, binding.approver) &&
701
+ (promiseChannelOf(notification) === state.channel || store.pingAllowed(notification, binding.approver)) &&
670
702
  // A flow decision for "anyone who approves" reaches every approver who can see the project.
671
703
  // A notification addressed to this person reaches them whatever project
672
704
  // their channel follows (a sign-in pause is the installation's, v108).
@@ -680,7 +712,7 @@ export async function planChatNotifications(options) {
680
712
  const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
681
713
  if (notification.kind === LEAD_SAY_KIND) {
682
714
  // The lead's words (lead-voice.ts): one message, repainted in place when a later say joins it.
683
- const content = { text: leadSayText(notification),
715
+ const content = { text: leadSayText(store, notification, binding.approver),
684
716
  ...(notification.link ? { link: { label: "Open", path: notification.link } } : {}) };
685
717
  const earlier = leadSayEarlier(store, notification).map(one => state.prepare("SELECT id FROM chat_part WHERE event=?")
686
718
  .get(chatHash(`${options.state.channel}:notice:${binding.id}:${one}`))).find(one => one !== undefined);
@@ -784,7 +816,7 @@ export async function planChatNotifications(options) {
784
816
  state.plan(id, [
785
817
  {
786
818
  // Never the task's id or a "— revision" suffix (chat-voice.ts).
787
- text: chatText(phoneText(notification.body === "" ? notification.subject : `${notification.subject}\n\n${notification.body}`, 2500), notification.taskId === null ? [] : [{ id: notification.taskId, title: chatTitle(store, notification.taskId) }]),
819
+ text: chatText(phoneText(notification.body === "" ? leadSubjectOf(store, notification, binding.approver) : `${leadSubjectOf(store, notification, binding.approver)}\n\n${notification.body}`, 2500), notification.taskId === null ? [] : [{ id: notification.taskId, title: chatTitle(store, notification.taskId) }]),
788
820
  ...(notification.taskId ? { task: notification.taskId } : {}),
789
821
  ...(run ? { run: run.id } : {}),
790
822
  ...(notification.link
@@ -11,6 +11,7 @@ import { assignmentStatusFacts, headlineEmoji, taskStatusOf } from "./task-statu
11
11
  import { asksToFinish, batchLine, chatText, chatTitle, factLinkLabel, mentions, READY_ACTIONS } from "./chat-voice.js";
12
12
  import { phoneText, projectLabel } from "./telegram-status.js";
13
13
  import { isLifecycleNotification } from "./store.js";
14
+ import { leadSubjectOf } from "./lead-voice.js";
14
15
  /** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
15
16
  * attention fact, a result Ready for review, a security alert or release, and anything addressed to one person
16
17
  * (a sign-in or plan-limit pause, an evening digest). */
@@ -128,7 +129,8 @@ function lineOf(store, item, now, root, viewer) {
128
129
  const row = item.notification === null ? null : store.notificationById(item.notification);
129
130
  if (row !== null && row.kind !== "run-finished") {
130
131
  // Never the task's id or a "— revision" suffix; the title once, in front unless the words already name it.
131
- const words = chatText(phoneText(row.body === "" ? row.subject : `${row.subject}\n\n${row.body}`, 2500), [{ id: ref.externalId, title: summary }]);
132
+ const subject = leadSubjectOf(store, row, viewer);
133
+ const words = chatText(phoneText(row.body === "" ? subject : `${subject}\n\n${row.body}`, 2500), [{ id: ref.externalId, title: summary }]);
132
134
  const { phrase, headline } = updatePhrase(row);
133
135
  return { also: [], link: row.link === null ? { label: "Open task", path: chatControlHref("task", ref.externalId) } : { label: factLinkLabel(row.link), path: row.link },
134
136
  fact: { summary, headline, checks: null, report: false, completedBy: null, update: { words: mentions(words, summary) ? words : `${summary} · ${words}`, phrase } } };
@@ -149,7 +151,7 @@ function lineOf(store, item, now, root, viewer) {
149
151
  pullRequest,
150
152
  // The lead speaks as "I" for what it did: the work it filed or approved, or the completion it recorded.
151
153
  lead: card.status.headline === "Complete" ? card.completedByLead : store.leadWorkOf(item.taskRef) !== null,
152
- ...(card.facts.lead === "on-it" ? { leadOnIt: true } : {}),
154
+ ...(card.facts.lead === "on-it" ? { leadOnIt: true, ...(card.facts.leadName === undefined ? {} : { leadName: card.facts.leadName }) } : {}),
153
155
  };
154
156
  // A result ready for a person offers its real next step, then a look first.
155
157
  if (asksToFinish(fact))