toolroll 0.9.16 → 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 (57) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/assignment-brief.js +11 -4
  3. package/dist/chat-actions.d.ts +7 -0
  4. package/dist/chat-actions.js +40 -4
  5. package/dist/chat-ask.js +29 -15
  6. package/dist/chat-channel.js +3 -1
  7. package/dist/chat-delivery-state.d.ts +2 -0
  8. package/dist/chat-delivery.d.ts +3 -0
  9. package/dist/chat-delivery.js +31 -16
  10. package/dist/chat-warmth.d.ts +21 -0
  11. package/dist/chat-warmth.js +36 -0
  12. package/dist/discord-api.d.ts +1 -1
  13. package/dist/discord-api.js +1 -1
  14. package/dist/discord-chat.d.ts +3 -2
  15. package/dist/discord-chat.js +18 -3
  16. package/dist/lead-about.d.ts +35 -0
  17. package/dist/lead-about.js +62 -0
  18. package/dist/lead-capabilities.d.ts +64 -0
  19. package/dist/lead-capabilities.js +199 -0
  20. package/dist/lead-commitments.js +13 -7
  21. package/dist/lead-context.d.ts +5 -3
  22. package/dist/lead-context.js +29 -7
  23. package/dist/lead-follow.d.ts +1 -1
  24. package/dist/lead-follow.js +3 -2
  25. package/dist/lead-identity.js +1 -1
  26. package/dist/lead-people.d.ts +55 -0
  27. package/dist/lead-people.js +128 -0
  28. package/dist/lead-voice.d.ts +3 -2
  29. package/dist/lead-voice.js +4 -3
  30. package/dist/mate-cli.d.ts +3 -0
  31. package/dist/mate-cli.js +15 -14
  32. package/dist/mate-contract.d.ts +1 -1
  33. package/dist/mate-contract.js +6 -4
  34. package/dist/mate-progress.js +2 -0
  35. package/dist/mate-tools.d.ts +8 -0
  36. package/dist/mate-tools.js +112 -6
  37. package/dist/mate.d.ts +26 -1
  38. package/dist/mate.js +89 -25
  39. package/dist/operate.js +2 -0
  40. package/dist/phase-routing.d.ts +1 -1
  41. package/dist/provider-auth.d.ts +4 -0
  42. package/dist/provider-auth.js +1 -1
  43. package/dist/reply-shape.d.ts +96 -0
  44. package/dist/reply-shape.js +452 -0
  45. package/dist/serve.js +53 -28
  46. package/dist/slack-chat.d.ts +2 -0
  47. package/dist/slack-chat.js +20 -3
  48. package/dist/store.d.ts +12 -3
  49. package/dist/store.js +44 -9
  50. package/dist/team-runtime.js +1 -1
  51. package/dist/teams-chat.js +12 -1
  52. package/dist/telegram-mate.d.ts +2 -0
  53. package/dist/telegram-mate.js +27 -11
  54. package/dist/telegram-team.js +6 -3
  55. package/dist/telegram.js +1 -1
  56. package/dist/test-temp.js +1 -1
  57. package/package.json +1 -1
package/dist/serve.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { checkLeadIdentity, leadIdentityOf, leadNameOf, LEAD_NAME_MAX, LEAD_PERSONA_MAX } from "./lead-identity.js";
2
+ import { aboutYouOf, checkAboutYou, saveAboutYou, ABOUT_YOU_LINE_MAX, ABOUT_YOU_MAX_LINES } from "./lead-about.js";
2
3
  import { withActor } from "./actor.js";
3
4
  import { repositoryContext, repositoryContextRead } from './repository-context.js';
4
5
  import { ADD_TESTS_ACTION, followUpChecksOf, requestFollowUpChecks, fileAddTestsTask, resultCheckLevel } from './result-follow-ups.js';
@@ -20,7 +21,7 @@ import { ChatState } from "./chat-delivery-state.js";
20
21
  import { slackSettingsHtml } from "./slack-settings.js";
21
22
  import { checkSlackCredentials, loadSlackCredentials, saveSlackCredentials, clearSlackCredentials, SLACK_MANIFEST, SlackError } from "./slack-api.js";
22
23
  import { SlackState } from "./slack-state.js";
23
- import { CHAT_ACTIONS, sharedActionPayload, sharedActionNeedsReview, sharedActionReviewPath, mintSharedActionReview } from './chat-actions.js';
24
+ import { CHAT_ACTIONS, OWNER_ACTIONS, sharedActionPayload, sharedActionNeedsReview, sharedActionReviewPath, mintSharedActionReview } from './chat-actions.js';
24
25
  import { changeSkills, githubSkill, importSkill, readSkillsSnapshot, reviseSkillTest, skillTestResult, skillsView, testSkill } from "./project-skills.js";
25
26
  import { skillsHtml, skillsScript, skillsSnapshotHtml, skillTestFeedbackHtml, SKILLS_CSS } from "./skills-ui.js";
26
27
  import { toolsHtml, TOOLS_CSS } from "./tools-ui.js";
@@ -212,7 +213,7 @@ import { authorizedProject, canonicalProject, isGitRepo, projectName, sameRepo,
212
213
  import { tally, spendLine, runCostWords } from "./summary.js";
213
214
  import { classify, holdOwnerWords, attentionCardForUnverifiedDone } from "./board.js";
214
215
  import { approveRoutine, describeSchedule, fireRoutine, parseSchedule, refreshRoutineAgents, routineAgentsState, routineDigestOf, validateRoutineTerms, ROUTINE_NAME } from "./routine.js";
215
- import { effectivePrimary, isMessagingChannel, savePrimary } from "./webhooks.js";
216
+ import { effectivePrimary, isMessagingChannel, loadConsoleUrl, savePrimary } from "./webhooks.js";
216
217
  import { resolvePhaseAgent, resolveRoutineAuthority, INSTALLATION_SCOPE, routeOfTask, agentChoicesFor } from "./agentconfig.js";
217
218
  import { isRiskLevel, projectRoute, riskTitle, riskConsequence, chosenWords, agentsSummary, postureWords, RISK_CHOICES, RISK_LEVELS, PHASES as ROUTE_PHASES } from "./phase-routing.js";
218
219
  import { ALL_CREDENTIAL_ENV, isProviderId, reportsCost, PROVIDER_IDS, validModelId, validateSpec } from "./provider.js";
@@ -260,6 +261,7 @@ import { handleTeamsHttp } from "./teams.js";
260
261
  import { checkTeamsCredentials, clearTeamsCredentials, loadTeamsCredentials, saveTeamsCredentials, TeamsError } from "./teams-api.js";
261
262
  import { verifyApproverByPassword, verifyApproverStanding } from "./principal.js";
262
263
  import { runMateTurn, MATE_MESSAGE_MAX_CHARS } from "./mate.js";
264
+ import { replyHtmlInline, shapeReply } from "./reply-shape.js";
263
265
  import { confirmCoordinatorProposal, confirmMateProposal, dismissCoordinatorProposal, dismissMateProposal } from "./mate-doors.js";
264
266
  import { envValue } from "./names.js";
265
267
  import { cachedRelease, isNewer, runnerVersions, setUpdateChecks, updateChecksOff } from "./releases.js";
@@ -1529,6 +1531,8 @@ export function createDecisionServer(options) {
1529
1531
  actor: who.name,
1530
1532
  csrf: who.via === "cookie" ? who.session.csrf : "",
1531
1533
  returnTo: safeReturn(url.pathname + url.search),
1534
+ // This console's own addresses: a lead reply's links there read as "the task", "the result", "Settings → Lead".
1535
+ appOrigins: [consoleOrigin(request.headers.host), publicOrigin?.origin ?? null, options.configDir === undefined ? null : loadConsoleUrl(process.env, options.configDir)],
1532
1536
  // Every signed-in page shares the workspace shell; pages showing a one-time secret opt out (forceSensitive).
1533
1537
  browser: who.via === 'cookie',
1534
1538
  // v100: signed in with the identity provider: its label, and whether it checked them recently enough to stand in for a password.
@@ -1634,7 +1638,7 @@ export function createDecisionServer(options) {
1634
1638
  }
1635
1639
  if (admitted && proposal) {
1636
1640
  const taskId = typeof action?.request['task'] === 'string' ? action.request['task'] : typeof proposal.payload['task'] === 'string' ? proposal.payload['task'] : null;
1637
- const repo = action?.repo ?? (taskId ? store.lookupRef(taskId)?.repo : null) ?? (typeof proposal.payload['repo'] === 'string' ? proposal.payload['repo'] : null);
1641
+ const repo = (action?.repo || null) ?? (taskId ? store.lookupRef(taskId)?.repo : null) ?? (typeof proposal.payload['repo'] === 'string' ? proposal.payload['repo'] : null);
1638
1642
  return { repo, taskId, runId: typeof action?.request['run'] === 'number' ? action.request['run'] : null, action: action?.operation ?? `chat ${proposal.kind}` };
1639
1643
  }
1640
1644
  }
@@ -1699,6 +1703,9 @@ export function createDecisionServer(options) {
1699
1703
  }
1700
1704
  if (action && proposal && store.getMateThread(proposal.thread)?.approver === who.name && visible(action.repo) && store.accountCanAccess(who.name, action.repo))
1701
1705
  return true;
1706
+ // A card about the person themselves (what their lead knows about them) is in no project.
1707
+ if (action && proposal && action.repo === '' && OWNER_ACTIONS.has(action.operation) && store.getMateThread(proposal.thread)?.approver === who.name)
1708
+ return true;
1702
1709
  refuse(response, who, 404, 'No such action in your projects.', '/projects');
1703
1710
  return false;
1704
1711
  }
@@ -4138,7 +4145,8 @@ export function createDecisionServer(options) {
4138
4145
  const signedIn = localSignIn?.states.claude === "connected" ? "Claude Code" : localSignIn?.states.codex === "connected" ? "Codex" : null;
4139
4146
  const promises = openCommitments(store, who.name, 50).map(one => ({ id: one.id, what: one.what, when: conditionWords(store, one.condition), until: one.expiresAt }));
4140
4147
  return sendScreen(response, 200, screen("Lead", leadSettingsHtml({ config, facts, words: leadWords(), signedIn, command: agentSignInCommand(),
4141
- said: url.searchParams.get("said"), saved: url.searchParams.get("saved") === "1", identity: leadIdentityOf(store, who.name), promises }), { chrome: chromeFor(project, "settings") }));
4148
+ said: url.searchParams.get("said"), saved: url.searchParams.get("saved") === "1", identity: leadIdentityOf(store, who.name), promises,
4149
+ about: aboutYouOf(store, who.name), aboutSaved: url.searchParams.get("saved") === "about" }), { chrome: chromeFor(project, "settings") }));
4142
4150
  }
4143
4151
  if (url.pathname === "/settings/telegram") {
4144
4152
  // Any approver pairs their OWN phone here; the bot token stays on /settings.
@@ -4818,20 +4826,20 @@ export function createDecisionServer(options) {
4818
4826
  if (result.problem.startsWith("status-")) {
4819
4827
  // The provider ANSWERED with an error: nothing billed.
4820
4828
  finish({ state: "failed", failureReason: "provider-error", settledMicrousd: 0 });
4821
- tell(null, "the provider refused the request — nothing was billed");
4829
+ tell(null, "The chat provider turned the request down. Nothing was kept or charged. Try again in a minute.");
4822
4830
  }
4823
4831
  else if (result.problem === "timeout") {
4824
4832
  finish({ state: "failed", failureReason: "timeout", settledMicrousd: null, unknownSpend: true });
4825
- tell(null, "the turn timed out; its cost is unknown and chat is blocked until you acknowledge it");
4833
+ tell(null, "The reply took too long and was stopped, and its cost isn't known yet, so chat is paused. Confirm the cost on this page to turn chat back on.");
4826
4834
  }
4827
4835
  else if (result.problem === "network") {
4828
4836
  finish({ state: "failed", failureReason: "provider-error", settledMicrousd: null, unknownSpend: true });
4829
- tell(null, "the provider could not be reached after dispatch; cost unknown — acknowledge to re-enable chat");
4837
+ tell(null, "The chat provider stopped responding partway through, and the cost isn't known, so chat is paused. Confirm the cost on this page to turn chat back on.");
4830
4838
  }
4831
4839
  else {
4832
4840
  // 200 with an unusable wrapper: billed, amount unproven.
4833
4841
  finish({ state: "failed", failureReason: "malformed-reply", settledMicrousd: null, unknownSpend: true });
4834
- tell(null, "the provider's response was malformed and was discarded; cost unknown — acknowledge to re-enable chat");
4842
+ tell(null, "The chat provider sent back an answer that couldn't be read, and its cost isn't known, so chat is paused. Confirm the cost on this page to turn chat back on.");
4835
4843
  }
4836
4844
  return;
4837
4845
  }
@@ -9231,6 +9239,16 @@ export function createDecisionServer(options) {
9231
9239
  store.setLeadConfig(who.name, checked.identity.name, checked.identity.persona, now);
9232
9240
  return redirect(response, "/settings/lead?saved=1");
9233
9241
  }
9242
+ // Settings → Lead: what this person's lead knows about them, one line each.
9243
+ if (url.pathname === "/settings/lead/about") {
9244
+ if (who.via !== "cookie" || who.role !== "approver")
9245
+ return refuse(response, who, 403, "An approver edits what their lead knows about them.", "/settings/lead");
9246
+ const checked = checkAboutYou(body.get("about") ?? "");
9247
+ if (!checked.ok)
9248
+ return redirect(response, chatReturnWithSaid("/settings/lead", checked.message));
9249
+ saveAboutYou(store, who.name, checked.lines, now);
9250
+ return redirect(response, "/settings/lead?saved=about");
9251
+ }
9234
9252
  // Settings → Lead: stop the lead following up on one of your promises.
9235
9253
  if (url.pathname === "/settings/lead/promise/cancel") {
9236
9254
  if (who.via !== "cookie" || who.role !== "approver")
@@ -17477,13 +17495,13 @@ function chatHeading(copy, projectCount, showProjectToggle = true) {
17477
17495
  `${strokeIcon(`<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/>`)}<span>projects</span><span class="badge">${projectCount}</span></button>`
17478
17496
  : "") + `</div></div>`);
17479
17497
  }
17480
- /** A deliberately small rich-text grammar for model copy. Input is escaped
17481
- * before tags are introduced: headings, bullets, numbered steps, bold, and
17482
- * inline code are presentation only—never executable HTML or external links. */
17483
- function renderChatText(text) {
17484
- const inline = (value) => escape(value)
17485
- .replace(/`([^`\n]{1,240})`/g, "<code>$1</code>")
17486
- .replace(/\*\*([^*\n]{1,500})\*\*/g, "<strong>$1</strong>");
17498
+ /** A deliberately small rich-text grammar for model copy. The reply is shaped first (the lead's voice, enforced:
17499
+ * no headers, at most three bold anchors, labelled links, no internal ids unless `asked` wanted them); input is
17500
+ * escaped before tags are introduced: bullets, numbered steps, bold, inline code and labelled http(s) links are
17501
+ * presentation only—never executable HTML. */
17502
+ function renderChatText(raw, asked) {
17503
+ const inline = replyHtmlInline;
17504
+ const text = shapeReply(raw, { appOrigin: requestContext.getStore()?.appOrigins ?? null, ...(asked === undefined ? {} : { asked }) });
17487
17505
  const lines = text.replace(/\r\n?/g, "\n").split("\n");
17488
17506
  const out = [];
17489
17507
  let paragraph = [];
@@ -17501,15 +17519,9 @@ function renderChatText(text) {
17501
17519
  list = null;
17502
17520
  };
17503
17521
  for (const line of lines) {
17504
- const heading = /^(?:#{1,3}\s+)(.+)$/.exec(line);
17505
17522
  const bullet = /^\s*[-*]\s+(.+)$/.exec(line);
17506
17523
  const numbered = /^\s*(\d{1,9})[.)]\s+(.+)$/.exec(line);
17507
- if (heading !== null) {
17508
- flushParagraph();
17509
- closeList();
17510
- out.push(`<h3>${inline(heading[1] ?? "")}</h3>`);
17511
- }
17512
- else if (bullet !== null || numbered !== null) {
17524
+ if (bullet !== null || numbered !== null) {
17513
17525
  flushParagraph();
17514
17526
  const wanted = bullet !== null ? "ul" : "ol";
17515
17527
  if (list !== wanted) {
@@ -17598,6 +17610,12 @@ function leadSettingsHtml(data) {
17598
17610
  `<label>Name your lead<input name="name" value="${escape(data.identity.name)}" maxlength="${LEAD_NAME_MAX}" autocomplete="off" required></label>` +
17599
17611
  `<label>Persona<textarea name="persona" rows="4" maxlength="${LEAD_PERSONA_MAX}">${escape(data.identity.persona)}</textarea></label>` +
17600
17612
  `<button type="submit">Save</button>${data.saved === true ? ` <span class="meta" role="status">Saved.</span>` : ""}</form>`,
17613
+ // What the lead knows about this person: lines they confirmed in chat or wrote here. Every project, every chat.
17614
+ data.about === undefined ? "" : `<form method="post" action="/settings/lead/about" class="card lead-about" data-lead-about>` +
17615
+ `<input type="hidden" name="csrf" value="${escape(facts.csrf)}">` +
17616
+ `<label>What your lead knows about you<textarea name="about" rows="${Math.min(10, Math.max(4, data.about.length + 1))}" maxlength="${ABOUT_YOU_MAX_LINES * (ABOUT_YOU_LINE_MAX + 1)}" placeholder="Keep copy terse.&#10;I test changes myself.">${escape(data.about.join("\n"))}</textarea></label>` +
17617
+ `<p class="meta">One per line, up to ${ABOUT_YOU_MAX_LINES}.</p>` +
17618
+ `<button type="submit">Save</button>${data.aboutSaved === true ? ` <span class="meta" role="status">Saved.</span>` : ""}</form>`,
17601
17619
  // What the lead promised to follow up on; it reports each once, here in chat, and drops it after 7 days.
17602
17620
  (data.promises ?? []).length === 0 ? "" : `<section class="card lead-promises" data-lead-promises><h2>Promises</h2><ul class="lead-promise-list">${data.promises.map(one => `<li data-promise="${one.id}"><p>${escape(one.what)}</p><p class="meta">${escape(one.when.charAt(0).toUpperCase() + one.when.slice(1))} · <span class="nowrap">until ${escape(new Date(one.until).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }))}</span></p>` +
17603
17621
  `<form method="post" action="/settings/lead/promise/cancel" class="inline">${hidden}<input type="hidden" name="promise" value="${one.id}"><button type="submit" class="secondary">Cancel</button></form></li>`).join("")}</ul></section>`,
@@ -17699,8 +17717,8 @@ function chatPage(chrome, data) {
17699
17717
  parts.push(data.coordinatorProposals);
17700
17718
  parts.push(data.focusTask === null && !data.catchUp ? `<details class="chat-fleet-context">${chatOverviewSummaryHtml(data.projects, data.fleetSnapshot?.attentionCount)}${chatFleetOverview(data.fleetSnapshot, data.projects, data.csrf, false)}</details>` : "", chatLimitsHtml({ provider: config.provider, model: config.model, turnsToday: data.turnsToday, dailyTurns: config.dailyTurns, subscription, weekly: subscription ? null : { spent: data.weeklySpent, ceiling: config.weeklyCeilingMicrousd } }));
17701
17719
  for (const turn of data.latched) {
17702
- parts.push(`<div class="problem"><strong>Unknown spend blocks chat.</strong> turn #${turn.id} may have cost up to ${chatMoney(turn.reservedMicrousd)} — ` +
17703
- `<a href="/chat/ack/${turn.id}">read and acknowledge it</a> to re-enable this credential.</div>`);
17720
+ parts.push(`<div class="problem"><strong>Chat is paused.</strong> An earlier reply stopped before its cost was known; it may have cost up to ${chatMoney(turn.reservedMicrousd)}. ` +
17721
+ `<a href="/chat/ack/${turn.id}">Confirm that cost</a> to turn chat back on.</div>`);
17704
17722
  }
17705
17723
  if (data.pending !== null) {
17706
17724
  parts.push(chatWorkingHtml({ details: `Turn #${data.pending.id}${subscription ? " · subscription" : ` · up to ${chatMoney(data.pending.reservedMicrousd)} reserved`}` }));
@@ -18115,9 +18133,14 @@ function mateBrowserMessages(rows, csrf, back, target = { task: null, project: n
18115
18133
  const ask = turn === null ? undefined : rows.asks?.get(turn);
18116
18134
  return ask === undefined ? "" : mateAskHtml(ask, csrf, target, target.task === null ? "lead-message" : "task-message", ask.turn === open);
18117
18135
  };
18136
+ // The owner's message each reply answers: when it asked for ids, the reply keeps them.
18137
+ const askedBy = new Map();
18138
+ rows.messages.reduce((last, message) => { if (message.role === 'operator')
18139
+ return message.text; if (last !== undefined)
18140
+ askedBy.set(message.id, last); return last; }, undefined);
18118
18141
  return rows.messages.map(message => ({
18119
18142
  id: message.id, role: message.role, text: message.text,
18120
- html: message.role === 'operator' ? `<p>${escape(message.text)}</p>` : renderChatText(message.text) + asked(message.turn),
18143
+ html: message.role === 'operator' ? `<p>${escape(message.text)}</p>` : renderChatText(message.text, askedBy.get(message.id)) + asked(message.turn),
18121
18144
  activity: message.activity, createdAt: message.createdAt,
18122
18145
  ...(() => {
18123
18146
  const parts = message.turn === null ? [] : rows.proposals.filter(one => one.turn === message.turn)
@@ -18188,14 +18211,16 @@ function mateThreadHtml(data) {
18188
18211
  parts.push(`<div class="chat-empty" data-key="empty"><strong>${data.focusTask === null ? "What do you want to get done?" : "What do you want to understand or change?"}</strong>` +
18189
18212
  `<p class="meta">${data.focusTask === null ? "Describe a task or ask about your projects." : "Ask about progress, review results, or adjust the plan."}</p></div>`);
18190
18213
  }
18214
+ let asked;
18191
18215
  for (const message of data.messages) {
18192
18216
  if (message.role === "operator") {
18217
+ asked = message.text;
18193
18218
  parts.push(`<div class="msg op" data-message-role="operator" data-key="m${message.id}"><p style="white-space:pre-wrap">${escape(message.text)}</p></div>`);
18194
18219
  continue;
18195
18220
  }
18196
18221
  const cards = message.turn === null ? [] : (byTurn.get(message.turn) ?? []);
18197
18222
  parts.push(`<div class="msg mate" data-message-role="assistant" data-key="m${message.id}"${message.id === latestReply ? ' id="latest"' : ""}>` +
18198
- renderChatText(message.text) +
18223
+ renderChatText(message.text, asked) +
18199
18224
  (() => {
18200
18225
  const ask = message.turn === null ? undefined : data.asks?.get(message.turn);
18201
18226
  return ask === undefined ? "" : mateAskHtml(ask, data.csrf, { task: data.focusTask?.id ?? null, project: data.chatProject ?? null }, "chat-message", data.pending === null && data.ask?.turn === ask.turn);
@@ -18255,8 +18280,8 @@ function matePage(chrome, data) {
18255
18280
  if (data.problem !== null)
18256
18281
  conversation.push(`<div class="problem">${escape(data.problem)}</div>`);
18257
18282
  for (const turn of data.latched) {
18258
- conversation.push(`<div class="problem"><strong>Unknown spend blocks chat.</strong> turn #${turn.id} may have cost up to ${chatMoney(turn.reservedMicrousd)} — ` +
18259
- `<a href="/chat/ack/${turn.id}">read and acknowledge it</a> to re-enable this credential.</div>`);
18283
+ conversation.push(`<div class="problem"><strong>Chat is paused.</strong> An earlier reply stopped before its cost was known; it may have cost up to ${chatMoney(turn.reservedMicrousd)}. ` +
18284
+ `<a href="/chat/ack/${turn.id}">Confirm that cost</a> to turn chat back on.</div>`);
18260
18285
  }
18261
18286
  const lastMessage = data.messages.at(-1);
18262
18287
  const latestReply = data.pending === null && lastMessage?.role === "assistant" ? lastMessage.id : null;
@@ -25,4 +25,6 @@ export declare const applySlackAction: (options: SlackChatOptions, event: SlackE
25
25
  export declare const planSlackNotifications: (options: SlackChatOptions) => Promise<void>;
26
26
  export declare const planSlackRooms: (options: SlackChatOptions) => Promise<void>;
27
27
  export declare function slackBlocks(text: string, buttons?: Record<string, unknown>[]): Record<string, unknown>[];
28
+ /** The lead's reply as mrkdwn sections: no header block, so its first line stays an ordinary line. */
29
+ export declare function slackVoiceBlocks(mrkdwn: string, buttons?: Record<string, unknown>[]): Record<string, unknown>[];
28
30
  export declare function deliverSlackPart(options: SlackChatOptions): Promise<boolean>;
@@ -7,6 +7,7 @@ import { processChatEvent, applyChatAction, planChatNotifications, channelAccess
7
7
  /** Slack is a transport for the same saved assistant and confirmation doors. */
8
8
  import { proposalPreview, proposalLink, proposalOutcomeText, armedCardText, armedYesLabel, } from "./chat-channel.js";
9
9
  import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
10
+ import { renderReply } from "./reply-shape.js";
10
11
  import {} from "./mate-doors.js";
11
12
  import { SlackState, slackHash, } from "./slack-state.js";
12
13
  import { object, slackId, slackTs, slackMember, SlackError, uploadSlackBytes, } from "./slack-api.js";
@@ -131,6 +132,10 @@ const delivery = (options) => ({
131
132
  state: new SlackState(options.store),
132
133
  label: "Slack",
133
134
  member: (member, channel) => slackMember(options.api, options.identity, member, channel),
135
+ // A 👍 on the owner's message where the app has reactions:write; Slack shows bots no typing indicator.
136
+ warm: (event) => ({
137
+ react: () => options.api("reactions.add", { channel: event.channel, timestamp: event.ts, name: "thumbsup" }),
138
+ }),
134
139
  });
135
140
  export const processSlackEvent = (options) => processChatEvent(delivery(options));
136
141
  export const applySlackAction = (options, event, binding, repos) => applyChatAction(delivery(options), event, binding, repos);
@@ -156,6 +161,16 @@ export function slackBlocks(text, buttons = []) {
156
161
  blocks.push({ type: "actions", elements: buttons });
157
162
  return blocks;
158
163
  }
164
+ /** The lead's reply as mrkdwn sections: no header block, so its first line stays an ordinary line. */
165
+ export function slackVoiceBlocks(mrkdwn, buttons = []) {
166
+ const blocks = split(mrkdwn).map((part) => ({
167
+ type: "section",
168
+ text: { type: "mrkdwn", text: part },
169
+ }));
170
+ if (buttons.length)
171
+ blocks.push({ type: "actions", elements: buttons });
172
+ return blocks;
173
+ }
159
174
  function linkButton(origin, link) {
160
175
  if (!origin ||
161
176
  !link ||
@@ -374,14 +389,16 @@ export async function deliverSlackPart(options) {
374
389
  ...(content.also ?? []).flatMap((one, index) => linkButton(options.origin(), one).map(button => ({ ...button, action_id: `toolroll_link_${index + 2}` }))),
375
390
  ];
376
391
  const target = content.edit ?? row.message;
392
+ // The lead's own reply goes out in Slack's mrkdwn (bold anchors, labelled links); everything else stays plain.
393
+ const voiced = content.voice === true && !content.proposal && !content.image ? renderReply(text, "slack") : null;
377
394
  const args = {
378
395
  channel: destination,
379
- text: text
396
+ text: voiced ?? text
380
397
  .replaceAll("&", "&amp;")
381
398
  .replaceAll("<", "&lt;")
382
399
  .replaceAll(">", "&gt;"),
383
- blocks: slackBlocks(text, buttons),
384
- mrkdwn: false,
400
+ blocks: voiced === null ? slackBlocks(text, buttons) : slackVoiceBlocks(voiced, buttons),
401
+ mrkdwn: voiced !== null,
385
402
  parse: "none",
386
403
  unfurl_links: false,
387
404
  unfurl_media: false,
package/dist/store.d.ts CHANGED
@@ -57,6 +57,8 @@ export type MateAsk = {
57
57
  export declare const MATE_ASK_OTHER = "Something else";
58
58
  /** How long the question's buttons work, on every channel. */
59
59
  export declare const MATE_ASK_TTL_MS: number;
60
+ /** What a turn the lead starts itself for automatic crew updates says in the owner's place (lead-follow.ts): never their answer. */
61
+ export declare const LEAD_FOLLOW_MESSAGE = "Automatic crew update: inspect the saved results or decisions and tell me what needs attention. Do not rerun work.";
60
62
  /** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
61
63
  * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
62
64
  * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
@@ -6702,8 +6704,9 @@ export declare class Store {
6702
6704
  dropMateAsk(turn: number): void;
6703
6705
  /** The question an ANSWERED turn asked; null when it asked none or did not answer. */
6704
6706
  mateAsk(turn: number): MateAsk | null;
6705
- /** What a tap on the question finds: open; answered (the owner has written in its thread since the reply that asked it);
6706
- * or expired (older than MATE_ASK_TTL_MS, or the question or the reply that asked it is gone, as after a retention purge). */
6707
+ /** What a tap on the question finds: open; answered (its owner has written in its thread since the reply that asked
6708
+ * it, typed or tapped; a turn the lead started itself, or another person's message, is no answer); or expired (older
6709
+ * than MATE_ASK_TTL_MS, or the question or the reply that asked it is gone, as after a retention purge). */
6707
6710
  mateAskState(turn: number, now: Date): {
6708
6711
  state: "open";
6709
6712
  ask: MateAsk;
@@ -7501,13 +7504,19 @@ export declare class Store {
7501
7504
  }[];
7502
7505
  /** Claim one local day's digest for one person: true once, false if it was already considered. */
7503
7506
  claimEveningDigestDay(account: string, day: string): boolean;
7504
- /** What this person named their lead and its persona, or null when they never saved one. */
7507
+ /** What this person named their lead and its persona, or null when they never saved one. An empty name or persona
7508
+ * (a row saved only for what the lead knows about them) reads as the default. */
7505
7509
  leadConfig(account: string): {
7506
7510
  name: string;
7507
7511
  persona: string;
7508
7512
  } | null;
7509
7513
  /** Save this person's lead name and persona (checked by the caller: lead-identity.ts). */
7510
7514
  setLeadConfig(account: string, name: string, persona: string, now: Date): void;
7515
+ /** What this person's lead knows about them: their confirmed lines, in order (none when they never saved any). */
7516
+ leadAbout(account: string): string[];
7517
+ /** Save what this person's lead knows about them (checked by the caller: lead-about.ts). A person who never named
7518
+ * their lead gets an empty name and persona, so they keep following the defaults. */
7519
+ setLeadAbout(account: string, lines: readonly string[], now: Date): void;
7511
7520
  /** A lead token for one person, shown once. A new one ends their earlier ones; the ledger names it "lead for <owner>". */
7512
7521
  mintLeadCredential(owner: string, by: string, now: Date): {
7513
7522
  id: string;
package/dist/store.js CHANGED
@@ -184,13 +184,15 @@ CREATE TABLE IF NOT EXISTS chat_batch_item (
184
184
  );
185
185
  `;
186
186
  /** Settings → Lead (no version bump: additive only): what one person calls their lead and the short persona it
187
- * speaks with. No row reads as the defaults (lead-identity.ts). */
187
+ * speaks with, and what their lead knows about them (`about_json`: the confirmed lines, lead-about.ts). No row reads
188
+ * as the defaults (lead-identity.ts). */
188
189
  const LEAD_CONFIG_SCHEMA = `
189
190
  CREATE TABLE IF NOT EXISTS lead_config (
190
191
  account TEXT PRIMARY KEY,
191
192
  name TEXT NOT NULL,
192
193
  persona TEXT NOT NULL,
193
- updated_at TEXT NOT NULL
194
+ updated_at TEXT NOT NULL,
195
+ about_json TEXT NOT NULL DEFAULT '[]'
194
196
  );
195
197
  `;
196
198
  /** Pings follow responsibility (no version bump: additive only). `lead_credential` is a lead token: an agent acting
@@ -296,6 +298,8 @@ CREATE INDEX IF NOT EXISTS mate_ask_thread ON mate_ask (thread, turn);
296
298
  export const MATE_ASK_OTHER = "Something else";
297
299
  /** How long the question's buttons work, on every channel. */
298
300
  export const MATE_ASK_TTL_MS = 7 * 86_400_000;
301
+ /** What a turn the lead starts itself for automatic crew updates says in the owner's place (lead-follow.ts): never their answer. */
302
+ export const LEAD_FOLLOW_MESSAGE = "Automatic crew update: inspect the saved results or decisions and tell me what needs attention. Do not rerun work.";
299
303
  /** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
300
304
  * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
301
305
  * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
@@ -3901,6 +3905,8 @@ function initializeStore(db, file) {
3901
3905
  addColumn(db, "chat_batch_item", "notification", "INTEGER");
3902
3906
  db.exec(LEAD_QUIET_SCHEMA);
3903
3907
  db.exec(LEAD_CONFIG_SCHEMA);
3908
+ // What a person's lead knows about them, beside its name and persona (an older lead_config gains it).
3909
+ addColumn(db, "lead_config", "about_json", "TEXT NOT NULL DEFAULT '[]'");
3904
3910
  db.exec(LEAD_COMMITMENT_SCHEMA);
3905
3911
  mergeCheckTables(db);
3906
3912
  addColumn(db, "monitoring_status", "target", "TEXT");
@@ -13561,6 +13567,7 @@ export class Store {
13561
13567
  reason: String(row["reason"]),
13562
13568
  runner: String(row["runner"]),
13563
13569
  observedAt: String(row["observed_at"]),
13570
+ probe: String(row["probe"]),
13564
13571
  };
13565
13572
  };
13566
13573
  }
@@ -19709,8 +19716,9 @@ export class Store {
19709
19716
  const options = JSON.parse(String(row["options_json"]));
19710
19717
  return { turn: Number(row["turn"]), thread: Number(row["thread"]), question: String(row["question"]), options: Array.isArray(options) ? options.map(String) : [], createdAt: String(row["created_at"]) };
19711
19718
  }
19712
- /** What a tap on the question finds: open; answered (the owner has written in its thread since the reply that asked it);
19713
- * or expired (older than MATE_ASK_TTL_MS, or the question or the reply that asked it is gone, as after a retention purge). */
19719
+ /** What a tap on the question finds: open; answered (its owner has written in its thread since the reply that asked
19720
+ * it, typed or tapped; a turn the lead started itself, or another person's message, is no answer); or expired (older
19721
+ * than MATE_ASK_TTL_MS, or the question or the reply that asked it is gone, as after a retention purge). */
19714
19722
  mateAskState(turn, now) {
19715
19723
  const ask = this.mateAsk(turn);
19716
19724
  if (ask === null)
@@ -19718,7 +19726,10 @@ export class Store {
19718
19726
  const asking = this.db.prepare("SELECT MAX(id) AS id FROM mate_message WHERE thread = ? AND turn = ? AND role = 'assistant'").get(ask.thread, turn);
19719
19727
  if (asking === undefined || asking["id"] === null)
19720
19728
  return { state: "expired" };
19721
- const later = this.db.prepare("SELECT 1 AS hit FROM mate_message WHERE thread = ? AND role = 'operator' AND id > ? LIMIT 1").get(ask.thread, Number(asking["id"]));
19729
+ const owner = String(this.getMateTurn(turn).approver);
19730
+ const later = this.db.prepare(`SELECT 1 AS hit FROM mate_message m LEFT JOIN mate_turn t ON t.id = m.turn LEFT JOIN team_message tm ON tm.message = m.id
19731
+ WHERE m.thread = ? AND m.role = 'operator' AND m.id > ? AND m.text <> ? AND COALESCE(tm.author, t.approver, ?) = ? LIMIT 1`)
19732
+ .get(ask.thread, Number(asking["id"]), LEAD_FOLLOW_MESSAGE, owner, owner);
19722
19733
  if (later !== undefined)
19723
19734
  return { state: "answered" };
19724
19735
  if (new Date(ask.createdAt).getTime() + MATE_ASK_TTL_MS <= now.getTime())
@@ -22245,9 +22256,10 @@ export class Store {
22245
22256
  .run(day, account, day).changes) === 1;
22246
22257
  }
22247
22258
  // ---- pings follow responsibility: the lead, who acted, replacements and muted projects ----
22248
- /** What this person named their lead and its persona, or null when they never saved one. */
22259
+ /** What this person named their lead and its persona, or null when they never saved one. An empty name or persona
22260
+ * (a row saved only for what the lead knows about them) reads as the default. */
22249
22261
  leadConfig(account) {
22250
- const row = this.db.prepare("SELECT name, persona FROM lead_config WHERE account = ?").get(account);
22262
+ const row = this.db.prepare("SELECT name, persona FROM lead_config WHERE account = ? AND (name <> '' OR persona <> '')").get(account);
22251
22263
  return row === undefined ? null : { name: String(row["name"]), persona: String(row["persona"]) };
22252
22264
  }
22253
22265
  /** Save this person's lead name and persona (checked by the caller: lead-identity.ts). */
@@ -22255,6 +22267,25 @@ export class Store {
22255
22267
  this.db.prepare(`INSERT INTO lead_config (account, name, persona, updated_at) VALUES (?, ?, ?, ?)
22256
22268
  ON CONFLICT(account) DO UPDATE SET name = excluded.name, persona = excluded.persona, updated_at = excluded.updated_at`).run(account, name, persona, now.toISOString());
22257
22269
  }
22270
+ /** What this person's lead knows about them: their confirmed lines, in order (none when they never saved any). */
22271
+ leadAbout(account) {
22272
+ const row = this.db.prepare("SELECT about_json FROM lead_config WHERE account = ?").get(account);
22273
+ if (row === undefined)
22274
+ return [];
22275
+ try {
22276
+ const lines = JSON.parse(String(row["about_json"]));
22277
+ return Array.isArray(lines) ? lines.filter((one) => typeof one === "string") : [];
22278
+ }
22279
+ catch {
22280
+ return [];
22281
+ }
22282
+ }
22283
+ /** Save what this person's lead knows about them (checked by the caller: lead-about.ts). A person who never named
22284
+ * their lead gets an empty name and persona, so they keep following the defaults. */
22285
+ setLeadAbout(account, lines, now) {
22286
+ this.db.prepare(`INSERT INTO lead_config (account, name, persona, updated_at, about_json) VALUES (?, '', '', ?, ?)
22287
+ ON CONFLICT(account) DO UPDATE SET about_json = excluded.about_json, updated_at = excluded.updated_at`).run(account, now.toISOString(), JSON.stringify(lines));
22288
+ }
22258
22289
  /** A lead token for one person, shown once. A new one ends their earlier ones; the ledger names it "lead for <owner>". */
22259
22290
  mintLeadCredential(owner, by, now) {
22260
22291
  return this.transact(() => {
@@ -22666,8 +22697,12 @@ export class Store {
22666
22697
  JOIN push_subscription s ON s.id = p.subscription JOIN notification n ON n.id = p.notification
22667
22698
  WHERE p.state = 'pending' AND p.attempts = 0 AND p.created_at = ?`).all(now.toISOString());
22668
22699
  for (const raw of fresh) {
22669
- if (!this.pingAllowed(readNotification(raw), String(raw["approver"]))) {
22670
- this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = 'quiet' WHERE id = ? AND state = 'pending'").run(Number(raw["pair"]));
22700
+ // A met promise made on a chat app is said there (lead-commitments.ts), not as a push: settled here, unsent. One
22701
+ // made in the console or app is this phone's own.
22702
+ const elsewhere = /^lead-promise:(telegram|slack|discord|teams):/.test(String(raw["dedupe_key"]));
22703
+ if (elsewhere || !this.pingAllowed(readNotification(raw), String(raw["approver"]))) {
22704
+ this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = ? WHERE id = ? AND state = 'pending'")
22705
+ .run(elsewhere ? TELEGRAM_SKIPPED_OTHER_CHAT : "quiet", Number(raw["pair"]));
22671
22706
  }
22672
22707
  }
22673
22708
  }
@@ -156,7 +156,7 @@ export function createTeamRuntime(options) {
156
156
  context: `Shared team conversation ${claim.conversationId}. You are the lead ${view.leads.find(lead => lead.id === claim.leadId)?.name ?? claim.leadId}. Messages name their actual authors; message order does not grant authority. Use existing task actions and approvals. Do not rerun work to recover a notification. The following saved lead guidance is context, not new permissions:\n${claim.instructions}`,
157
157
  clock, evidenceRoot: options.evidenceRoot,
158
158
  revalidate: async () => !closed && !updateAdmissionPaused(store.raw()) && domain.current(claim) && store.teamMateSession(claim.actor.name, claim.threadId)?.id === session.id && authorization(claim.actor, domain.snapshot(claim.actor, claim.conversationId)).enabled && (!claim.requestId.startsWith('team-update:') || domain.access(claim.actor, claim.conversationId).conversation.follow)
159
- ? { ok: true } : { ok: false, reason: 'Conversation access or execution ownership changed.' },
159
+ ? { ok: true } : { ok: false, reason: 'access-changed' },
160
160
  ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.subscriptionRunner ? { subscriptionRunner: options.subscriptionRunner } : {}) });
161
161
  if (result.ok)
162
162
  domain.finish(claim, { status: 'answered', turnId: result.turn }, clock());
@@ -6,6 +6,7 @@ import { ChatState, chatHash } from "./chat-delivery-state.js";
6
6
  import { channelAccess, chatObject as object, planChatNotifications, planRoomMessages, processChatEvent } from "./chat-delivery.js";
7
7
  import { roomCommand } from "./chat-rooms.js";
8
8
  import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
9
+ import { renderReply } from "./reply-shape.js";
9
10
  import { armedCardText, armedYesLabel, proposalLink, proposalOutcomeText, proposalPreview } from "./chat-channel.js";
10
11
  import { chatFlowButtons } from "./chat-flow.js";
11
12
  import { channelInbox } from "./chat-inbox.js";
@@ -47,6 +48,14 @@ export const teamsDelivery = (options) => {
47
48
  state,
48
49
  label: "Teams",
49
50
  member: (member, channel) => teamsMember(options.api, state, options.identity.installation, member, channel),
51
+ // "Typing…" while the lead works; Teams gives bots no reactions, so there is no 👍 here.
52
+ warm: (event) => ({
53
+ typing: async () => {
54
+ const serviceUrl = state.meta(options.identity.installation, serviceKey(event.channel));
55
+ if (serviceUrl !== null)
56
+ await options.api("POST", serviceUrl, `/v3/conversations/${encodeURIComponent(event.channel)}/activities`, { type: "typing" });
57
+ },
58
+ }),
50
59
  partSize: 3500,
51
60
  maxProposal: 6000,
52
61
  };
@@ -201,7 +210,9 @@ export async function deliverTeamsPart(options) {
201
210
  actions = [...flow, ...asked, ...owner, ...openUrlAction(options.origin(), content.link), ...(content.also ?? []).flatMap(one => openUrlAction(options.origin(), one))];
202
211
  }
203
212
  const target = content.edit ?? row.message;
204
- const body = actions.length || content.proposal ? teamsCard(text, actions) : { type: "message", text, textFormat: "plain" };
213
+ // The lead's own reply goes out in Teams' Markdown (bold anchors, labelled links); everything else stays plain.
214
+ const voiced = content.voice === true && !content.proposal && !content.image ? renderReply(text, "teams") : null;
215
+ const body = actions.length || content.proposal ? teamsCard(voiced ?? text, actions) : voiced !== null ? { type: "message", text: voiced, textFormat: "markdown" } : { type: "message", text, textFormat: "plain" };
205
216
  const answer = await options.api(target ? "PUT" : "POST", serviceUrl, `/v3/conversations/${encodeURIComponent(destination)}/activities${target ? `/${encodeURIComponent(target)}` : ""}`, body);
206
217
  const messageId = target ?? (typeof answer.id === "string" ? answer.id : null);
207
218
  if (messageId === null)
@@ -12,6 +12,8 @@ export declare const CONVERSATION_MAX_AGE_MS: number;
12
12
  /** A card's buttons and an armed irreversible challenge live this long. */
13
13
  export declare const CARD_TTL_MS: number;
14
14
  export declare const CHALLENGE_TTL_MS: number;
15
+ /** Telegram's own message ceiling, with room for our part headers (the same bound telegram.ts splits at). */
16
+ export declare const PART_CAP = 3900;
15
17
  export type TelegramConversationOptions = {
16
18
  /** Where evidence lives — the same root the console and CLI read results from. */
17
19
  evidenceRoot: string;