toolroll 0.9.16 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/assignment-brief.js +11 -4
  3. package/dist/browser/workspace.js +14 -14
  4. package/dist/browser-workspace.d.ts +2 -0
  5. package/dist/chat-actions.d.ts +7 -0
  6. package/dist/chat-actions.js +40 -4
  7. package/dist/chat-ask.js +29 -15
  8. package/dist/chat-channel.js +3 -1
  9. package/dist/chat-decide.d.ts +203 -0
  10. package/dist/chat-decide.js +554 -0
  11. package/dist/chat-delivery-state.d.ts +7 -0
  12. package/dist/chat-delivery.d.ts +3 -0
  13. package/dist/chat-delivery.js +63 -17
  14. package/dist/chat-quiet.d.ts +4 -1
  15. package/dist/chat-quiet.js +6 -2
  16. package/dist/chat-warmth.d.ts +21 -0
  17. package/dist/chat-warmth.js +36 -0
  18. package/dist/desktop-update.d.ts +1 -1
  19. package/dist/discord-api.d.ts +3 -1
  20. package/dist/discord-api.js +4 -2
  21. package/dist/discord-chat.d.ts +3 -2
  22. package/dist/discord-chat.js +35 -6
  23. package/dist/lead-about.d.ts +35 -0
  24. package/dist/lead-about.js +62 -0
  25. package/dist/lead-capabilities.d.ts +64 -0
  26. package/dist/lead-capabilities.js +199 -0
  27. package/dist/lead-commitments.js +13 -7
  28. package/dist/lead-context.d.ts +5 -3
  29. package/dist/lead-context.js +29 -7
  30. package/dist/lead-follow.d.ts +1 -1
  31. package/dist/lead-follow.js +3 -2
  32. package/dist/lead-identity.js +1 -1
  33. package/dist/lead-people.d.ts +55 -0
  34. package/dist/lead-people.js +128 -0
  35. package/dist/lead-voice.d.ts +3 -2
  36. package/dist/lead-voice.js +4 -3
  37. package/dist/mate-cli.d.ts +3 -0
  38. package/dist/mate-cli.js +15 -14
  39. package/dist/mate-contract.d.ts +1 -1
  40. package/dist/mate-contract.js +6 -4
  41. package/dist/mate-progress.js +2 -0
  42. package/dist/mate-tools.d.ts +8 -0
  43. package/dist/mate-tools.js +112 -6
  44. package/dist/mate.d.ts +26 -1
  45. package/dist/mate.js +89 -25
  46. package/dist/modes.d.ts +6 -0
  47. package/dist/modes.js +6 -0
  48. package/dist/operate.js +20 -8
  49. package/dist/phase-routing.d.ts +1 -1
  50. package/dist/project-delete.js +3 -0
  51. package/dist/provider-auth.d.ts +4 -0
  52. package/dist/provider-auth.js +1 -1
  53. package/dist/reply-shape.d.ts +96 -0
  54. package/dist/reply-shape.js +452 -0
  55. package/dist/result-shots.d.ts +51 -0
  56. package/dist/result-shots.js +119 -0
  57. package/dist/serve.js +76 -49
  58. package/dist/slack-chat.d.ts +2 -0
  59. package/dist/slack-chat.js +38 -7
  60. package/dist/store.d.ts +36 -3
  61. package/dist/store.js +163 -16
  62. package/dist/team-runtime.js +1 -1
  63. package/dist/teams-chat.js +17 -1
  64. package/dist/telegram-mate.d.ts +12 -0
  65. package/dist/telegram-mate.js +27 -11
  66. package/dist/telegram-team.js +6 -3
  67. package/dist/telegram.d.ts +5 -3
  68. package/dist/telegram.js +227 -21
  69. package/dist/test-temp.js +1 -1
  70. package/package.json +1 -1
package/dist/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";
@@ -92,6 +93,7 @@ import { DISCLOSURE_CSS } from "./disclosure.js";
92
93
  import { styleAsset } from "./style-asset.js";
93
94
  import { MOBILE_VIEWPORT_SCRIPT } from "./mobile-viewport.js";
94
95
  import { authorizePlanUnderMode, applyModeToNewFiling, planAutoPending } from "./plan-auto.js";
96
+ import { permissionPlainWords } from "./chat-decide.js";
95
97
  import { LEDGER_CSV_HEADER, ledgerCsvRows } from "./ledger-csv.js";
96
98
  import { Readable } from "node:stream";
97
99
  import { pipeline } from "node:stream/promises";
@@ -187,7 +189,8 @@ import { dirname } from "node:path";
187
189
  import { AsyncLocalStorage } from "node:async_hooks";
188
190
  import { loadOrCreateVapidKeys, validatePushEndpoint } from "./push.js";
189
191
  import { parseGithubRepo, previewGithubRepo, cloneGithubRepo, listGithubRepos, isLargeRepo } from "./onboard.js";
190
- import { verifiedAuthor, LEAD_THREAD, isDigestTime, MATE_ASK_OTHER } from "./store.js";
192
+ import { verifiedAuthor, LEAD_THREAD, isDigestTime, RESULT_SCREENSHOTS, MATE_ASK_OTHER } from "./store.js";
193
+ import { RESULT_SHOT_CHOICES } from "./result-shots.js";
191
194
  import { digestTimes } from "./digest-times.js";
192
195
  import { updateRepos, addRepos, removeRepos } from "./repos.js";
193
196
  import { run as execRun } from "./exec.js";
@@ -212,7 +215,7 @@ import { authorizedProject, canonicalProject, isGitRepo, projectName, sameRepo,
212
215
  import { tally, spendLine, runCostWords } from "./summary.js";
213
216
  import { classify, holdOwnerWords, attentionCardForUnverifiedDone } from "./board.js";
214
217
  import { approveRoutine, describeSchedule, fireRoutine, parseSchedule, refreshRoutineAgents, routineAgentsState, routineDigestOf, validateRoutineTerms, ROUTINE_NAME } from "./routine.js";
215
- import { effectivePrimary, isMessagingChannel, savePrimary } from "./webhooks.js";
218
+ import { effectivePrimary, isMessagingChannel, loadConsoleUrl, savePrimary } from "./webhooks.js";
216
219
  import { resolvePhaseAgent, resolveRoutineAuthority, INSTALLATION_SCOPE, routeOfTask, agentChoicesFor } from "./agentconfig.js";
217
220
  import { isRiskLevel, projectRoute, riskTitle, riskConsequence, chosenWords, agentsSummary, postureWords, RISK_CHOICES, RISK_LEVELS, PHASES as ROUTE_PHASES } from "./phase-routing.js";
218
221
  import { ALL_CREDENTIAL_ENV, isProviderId, reportsCost, PROVIDER_IDS, validModelId, validateSpec } from "./provider.js";
@@ -260,6 +263,7 @@ import { handleTeamsHttp } from "./teams.js";
260
263
  import { checkTeamsCredentials, clearTeamsCredentials, loadTeamsCredentials, saveTeamsCredentials, TeamsError } from "./teams-api.js";
261
264
  import { verifyApproverByPassword, verifyApproverStanding } from "./principal.js";
262
265
  import { runMateTurn, MATE_MESSAGE_MAX_CHARS } from "./mate.js";
266
+ import { replyHtmlInline, shapeReply } from "./reply-shape.js";
263
267
  import { confirmCoordinatorProposal, confirmMateProposal, dismissCoordinatorProposal, dismissMateProposal } from "./mate-doors.js";
264
268
  import { envValue } from "./names.js";
265
269
  import { cachedRelease, isNewer, runnerVersions, setUpdateChecks, updateChecksOff } from "./releases.js";
@@ -1529,6 +1533,8 @@ export function createDecisionServer(options) {
1529
1533
  actor: who.name,
1530
1534
  csrf: who.via === "cookie" ? who.session.csrf : "",
1531
1535
  returnTo: safeReturn(url.pathname + url.search),
1536
+ // This console's own addresses: a lead reply's links there read as "the task", "the result", "Settings → Lead".
1537
+ appOrigins: [consoleOrigin(request.headers.host), publicOrigin?.origin ?? null, options.configDir === undefined ? null : loadConsoleUrl(process.env, options.configDir)],
1532
1538
  // Every signed-in page shares the workspace shell; pages showing a one-time secret opt out (forceSensitive).
1533
1539
  browser: who.via === 'cookie',
1534
1540
  // v100: signed in with the identity provider: its label, and whether it checked them recently enough to stand in for a password.
@@ -1634,7 +1640,7 @@ export function createDecisionServer(options) {
1634
1640
  }
1635
1641
  if (admitted && proposal) {
1636
1642
  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);
1643
+ const repo = (action?.repo || null) ?? (taskId ? store.lookupRef(taskId)?.repo : null) ?? (typeof proposal.payload['repo'] === 'string' ? proposal.payload['repo'] : null);
1638
1644
  return { repo, taskId, runId: typeof action?.request['run'] === 'number' ? action.request['run'] : null, action: action?.operation ?? `chat ${proposal.kind}` };
1639
1645
  }
1640
1646
  }
@@ -1699,6 +1705,9 @@ export function createDecisionServer(options) {
1699
1705
  }
1700
1706
  if (action && proposal && store.getMateThread(proposal.thread)?.approver === who.name && visible(action.repo) && store.accountCanAccess(who.name, action.repo))
1701
1707
  return true;
1708
+ // A card about the person themselves (what their lead knows about them) is in no project.
1709
+ if (action && proposal && action.repo === '' && OWNER_ACTIONS.has(action.operation) && store.getMateThread(proposal.thread)?.approver === who.name)
1710
+ return true;
1702
1711
  refuse(response, who, 404, 'No such action in your projects.', '/projects');
1703
1712
  return false;
1704
1713
  }
@@ -2641,6 +2650,10 @@ export function createDecisionServer(options) {
2641
2650
  `<option value="0">wait for my approval</option>` +
2642
2651
  `<option value="1">auto-approve plans that preserve my filed contract</option>` +
2643
2652
  `</select><span class="meta">Requires automatic filing approval. File the goal, paths, and acceptance criteria upfront. Changed scope and unanswered questions still pause.</span></label>`,
2653
+ `<label>From my chat app<select name="chat-approve">` +
2654
+ `<option value="">approve plans and merges here, with my password (the default)</option>` +
2655
+ `<option value="1">approve plans and merge ready pull requests from my paired chat, two taps each</option>` +
2656
+ `</select><span class="meta">Plans that widen permissions, exceed the attempt cap or touch protected paths still open Toolroll.</span></label>`,
2644
2657
  `<label>If a subscription runs out<select name="allow-paid-fallback">` +
2645
2658
  `<option value="">never switch to a paid API key on its own (the default, every preset)</option>` +
2646
2659
  `<option value="1">allow the approved fallback — spend moves to that account</option>` +
@@ -4138,7 +4151,8 @@ export function createDecisionServer(options) {
4138
4151
  const signedIn = localSignIn?.states.claude === "connected" ? "Claude Code" : localSignIn?.states.codex === "connected" ? "Codex" : null;
4139
4152
  const promises = openCommitments(store, who.name, 50).map(one => ({ id: one.id, what: one.what, when: conditionWords(store, one.condition), until: one.expiresAt }));
4140
4153
  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") }));
4154
+ said: url.searchParams.get("said"), saved: url.searchParams.get("saved") === "1", identity: leadIdentityOf(store, who.name), promises,
4155
+ about: aboutYouOf(store, who.name), aboutSaved: url.searchParams.get("saved") === "about" }), { chrome: chromeFor(project, "settings") }));
4142
4156
  }
4143
4157
  if (url.pathname === "/settings/telegram") {
4144
4158
  // Any approver pairs their OWN phone here; the bot token stays on /settings.
@@ -4223,7 +4237,7 @@ export function createDecisionServer(options) {
4223
4237
  return at === null || since === null ? null : firstResultWords(since, at);
4224
4238
  })(), (() => {
4225
4239
  const chosen = store.notificationPreference(who.name);
4226
- return { mode: chosen.mode, digestAt: chosen.digestAt, projects: notificationProjects(store, who.name) };
4240
+ return { mode: chosen.mode, digestAt: chosen.digestAt, screenshots: chosen.screenshots, projects: notificationProjects(store, who.name) };
4227
4241
  })()));
4228
4242
  }
4229
4243
  if (url.pathname === "/push/key") {
@@ -4818,20 +4832,20 @@ export function createDecisionServer(options) {
4818
4832
  if (result.problem.startsWith("status-")) {
4819
4833
  // The provider ANSWERED with an error: nothing billed.
4820
4834
  finish({ state: "failed", failureReason: "provider-error", settledMicrousd: 0 });
4821
- tell(null, "the provider refused the request — nothing was billed");
4835
+ tell(null, "The chat provider turned the request down. Nothing was kept or charged. Try again in a minute.");
4822
4836
  }
4823
4837
  else if (result.problem === "timeout") {
4824
4838
  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");
4839
+ 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
4840
  }
4827
4841
  else if (result.problem === "network") {
4828
4842
  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");
4843
+ 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
4844
  }
4831
4845
  else {
4832
4846
  // 200 with an unusable wrapper: billed, amount unproven.
4833
4847
  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");
4848
+ 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
4849
  }
4836
4850
  return;
4837
4851
  }
@@ -8228,16 +8242,21 @@ export function createDecisionServer(options) {
8228
8242
  }
8229
8243
  if (url.pathname === "/settings/notifications") {
8230
8244
  // Each person's own choice: a closed list of modes and a 24-hour HH:MM, or off.
8231
- const mode = body.get("mode"), time = (body.get("digest") ?? "").trim();
8245
+ const mode = body.get("mode"), time = (body.get("digest") ?? "").trim(), shots = body.get("screenshots");
8232
8246
  if (mode !== null && mode !== "quiet" && mode !== "all")
8233
8247
  return refuse(response, who, 400, "choose Only when I'm needed or Every step", "/settings");
8234
8248
  if (time !== "" && time !== "off" && !isDigestTime(time))
8235
8249
  return refuse(response, who, 400, "the evening digest time is HH:MM, or off", "/settings");
8250
+ if (shots !== null && !RESULT_SCREENSHOTS.includes(shots))
8251
+ return refuse(response, who, 400, "choose Off, First one or Up to 4", "/settings");
8236
8252
  const before = store.notificationPreference(who.name);
8237
- const after = store.setNotificationPreference(who.name, { ...(mode === null ? {} : { mode }), ...(time === "" ? {} : { digestAt: time === "off" ? null : time }) }, who.name, now);
8253
+ const after = store.setNotificationPreference(who.name, { ...(mode === null ? {} : { mode }), ...(time === "" ? {} : { digestAt: time === "off" ? null : time }),
8254
+ ...(shots === null ? {} : { screenshots: shots }) }, who.name, now);
8238
8255
  const said = after.mode !== before.mode
8239
8256
  ? after.mode === "quiet" ? "Chats now message you only when you're needed." : "Chats now message you at every step."
8240
- : after.digestAt === null ? "Evening digest off." : `Evening digest at ${after.digestAt}.`;
8257
+ : after.screenshots !== before.screenshots
8258
+ ? after.screenshots === "off" ? "Results arrive without screenshots." : `Results arrive with ${after.screenshots === "first" ? "their first screenshot" : "up to 4 screenshots"}.`
8259
+ : after.digestAt === null ? "Evening digest off." : `Evening digest at ${after.digestAt}.`;
8241
8260
  return redirect(response, `/settings?said=${encodeURIComponent(said)}`);
8242
8261
  }
8243
8262
  if (url.pathname === "/settings/notifications/mute") {
@@ -8704,6 +8723,8 @@ export function createDecisionServer(options) {
8704
8723
  // attempt cap it carries is meaningless without it.
8705
8724
  repairAuto: false,
8706
8725
  repairMaxAttempts: 0,
8726
+ // Approving from the paired chat: only the explicit choice grants it.
8727
+ chatApprove: body.get("chat-approve") === "1",
8707
8728
  publication: body.get("publication") === "automerge" ? "automerge" : "notify",
8708
8729
  };
8709
8730
  if (["review-auto", "review-retry-auto", "repair-auto"].some(field => body.get(field) === "1")) {
@@ -8725,7 +8746,7 @@ export function createDecisionServer(options) {
8725
8746
  `<input type="hidden" name="csrf" value="${escape(who.session.csrf)}">` +
8726
8747
  `<input type="hidden" name="nonce" value="${escape(nonce)}">` +
8727
8748
  `<input type="hidden" name="digest" value="${escape(digest)}">` +
8728
- ["name", "days", "publication", "auto-approve", "plan-auto", "allow-paid-fallback"]
8749
+ ["name", "days", "publication", "auto-approve", "plan-auto", "chat-approve", "allow-paid-fallback"]
8729
8750
  .map(field => `<input type="hidden" name="${field}" value="${escape(body.get(field) ?? "")}">`)
8730
8751
  .join("") +
8731
8752
  `<input type="hidden" name="expiry" value="${escape(expiry)}">` +
@@ -9231,6 +9252,16 @@ export function createDecisionServer(options) {
9231
9252
  store.setLeadConfig(who.name, checked.identity.name, checked.identity.persona, now);
9232
9253
  return redirect(response, "/settings/lead?saved=1");
9233
9254
  }
9255
+ // Settings → Lead: what this person's lead knows about them, one line each.
9256
+ if (url.pathname === "/settings/lead/about") {
9257
+ if (who.via !== "cookie" || who.role !== "approver")
9258
+ return refuse(response, who, 403, "An approver edits what their lead knows about them.", "/settings/lead");
9259
+ const checked = checkAboutYou(body.get("about") ?? "");
9260
+ if (!checked.ok)
9261
+ return redirect(response, chatReturnWithSaid("/settings/lead", checked.message));
9262
+ saveAboutYou(store, who.name, checked.lines, now);
9263
+ return redirect(response, "/settings/lead?saved=about");
9264
+ }
9234
9265
  // Settings → Lead: stop the lead following up on one of your promises.
9235
9266
  if (url.pathname === "/settings/lead/promise/cancel") {
9236
9267
  if (who.via !== "cookie" || who.role !== "approver")
@@ -12525,7 +12556,7 @@ function approvalSheetHtml(input) {
12525
12556
  // What the yes allows, in plain words, right above it: the agent's
12526
12557
  // permissions, the per-attempt limit, and an earlier version still running.
12527
12558
  const allowing = [];
12528
- const allowed = permissionPlainWordsOf(scope.profile);
12559
+ const allowed = permissionPlainWords(scope.profile);
12529
12560
  if (allowed !== null)
12530
12561
  allowing.push(allowed);
12531
12562
  if (raceTerms === null)
@@ -12676,20 +12707,6 @@ class PermissionsWouldChange extends Error {
12676
12707
  }
12677
12708
  /** Evidence kinds in plain words, for the signed criteria in Details. */
12678
12709
  const EVIDENCE_PLAIN = { check: "the project check", screenshot: "screenshots", "changed-path": "changed files", "manual-review": "your own check" };
12679
- /** What an agent may do on its own, in a few plain words. */
12680
- function permissionPlainWordsOf(profile) {
12681
- if (profile === null || profile === undefined)
12682
- return null;
12683
- const full = "full access, nothing asks first";
12684
- if (profile.provider === "claude") {
12685
- return profile.permissionArgv === "bypassPermissions" ? full
12686
- : profile.permissionArgv === "auto" ? "file edits and routine commands; anything risky stops"
12687
- : "file edits only; commands are refused";
12688
- }
12689
- if (profile.provider === "gemini")
12690
- return profile.approvalArgv === "yolo" ? full : "file edits only; other tools are refused";
12691
- return profile.sandboxMode === "danger-full-access" ? full : "file edits and commands inside the project only";
12692
- }
12693
12710
  /** The permission choice a sealed profile carries, as the scope form names it. */
12694
12711
  function permissionModeOfProfile(profile) {
12695
12712
  if (profile === null || profile === undefined)
@@ -17477,13 +17494,13 @@ function chatHeading(copy, projectCount, showProjectToggle = true) {
17477
17494
  `${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
17495
  : "") + `</div></div>`);
17479
17496
  }
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>");
17497
+ /** A deliberately small rich-text grammar for model copy. The reply is shaped first (the lead's voice, enforced:
17498
+ * no headers, at most three bold anchors, labelled links, no internal ids unless `asked` wanted them); input is
17499
+ * escaped before tags are introduced: bullets, numbered steps, bold, inline code and labelled http(s) links are
17500
+ * presentation only—never executable HTML. */
17501
+ function renderChatText(raw, asked) {
17502
+ const inline = replyHtmlInline;
17503
+ const text = shapeReply(raw, { appOrigin: requestContext.getStore()?.appOrigins ?? null, ...(asked === undefined ? {} : { asked }) });
17487
17504
  const lines = text.replace(/\r\n?/g, "\n").split("\n");
17488
17505
  const out = [];
17489
17506
  let paragraph = [];
@@ -17501,15 +17518,9 @@ function renderChatText(text) {
17501
17518
  list = null;
17502
17519
  };
17503
17520
  for (const line of lines) {
17504
- const heading = /^(?:#{1,3}\s+)(.+)$/.exec(line);
17505
17521
  const bullet = /^\s*[-*]\s+(.+)$/.exec(line);
17506
17522
  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) {
17523
+ if (bullet !== null || numbered !== null) {
17513
17524
  flushParagraph();
17514
17525
  const wanted = bullet !== null ? "ul" : "ol";
17515
17526
  if (list !== wanted) {
@@ -17598,6 +17609,12 @@ function leadSettingsHtml(data) {
17598
17609
  `<label>Name your lead<input name="name" value="${escape(data.identity.name)}" maxlength="${LEAD_NAME_MAX}" autocomplete="off" required></label>` +
17599
17610
  `<label>Persona<textarea name="persona" rows="4" maxlength="${LEAD_PERSONA_MAX}">${escape(data.identity.persona)}</textarea></label>` +
17600
17611
  `<button type="submit">Save</button>${data.saved === true ? ` <span class="meta" role="status">Saved.</span>` : ""}</form>`,
17612
+ // What the lead knows about this person: lines they confirmed in chat or wrote here. Every project, every chat.
17613
+ data.about === undefined ? "" : `<form method="post" action="/settings/lead/about" class="card lead-about" data-lead-about>` +
17614
+ `<input type="hidden" name="csrf" value="${escape(facts.csrf)}">` +
17615
+ `<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>` +
17616
+ `<p class="meta">One per line, up to ${ABOUT_YOU_MAX_LINES}.</p>` +
17617
+ `<button type="submit">Save</button>${data.aboutSaved === true ? ` <span class="meta" role="status">Saved.</span>` : ""}</form>`,
17601
17618
  // What the lead promised to follow up on; it reports each once, here in chat, and drops it after 7 days.
17602
17619
  (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
17620
  `<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 +17716,8 @@ function chatPage(chrome, data) {
17699
17716
  parts.push(data.coordinatorProposals);
17700
17717
  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
17718
  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>`);
17719
+ 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)}. ` +
17720
+ `<a href="/chat/ack/${turn.id}">Confirm that cost</a> to turn chat back on.</div>`);
17704
17721
  }
17705
17722
  if (data.pending !== null) {
17706
17723
  parts.push(chatWorkingHtml({ details: `Turn #${data.pending.id}${subscription ? " · subscription" : ` · up to ${chatMoney(data.pending.reservedMicrousd)} reserved`}` }));
@@ -18115,9 +18132,14 @@ function mateBrowserMessages(rows, csrf, back, target = { task: null, project: n
18115
18132
  const ask = turn === null ? undefined : rows.asks?.get(turn);
18116
18133
  return ask === undefined ? "" : mateAskHtml(ask, csrf, target, target.task === null ? "lead-message" : "task-message", ask.turn === open);
18117
18134
  };
18135
+ // The owner's message each reply answers: when it asked for ids, the reply keeps them.
18136
+ const askedBy = new Map();
18137
+ rows.messages.reduce((last, message) => { if (message.role === 'operator')
18138
+ return message.text; if (last !== undefined)
18139
+ askedBy.set(message.id, last); return last; }, undefined);
18118
18140
  return rows.messages.map(message => ({
18119
18141
  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),
18142
+ html: message.role === 'operator' ? `<p>${escape(message.text)}</p>` : renderChatText(message.text, askedBy.get(message.id)) + asked(message.turn),
18121
18143
  activity: message.activity, createdAt: message.createdAt,
18122
18144
  ...(() => {
18123
18145
  const parts = message.turn === null ? [] : rows.proposals.filter(one => one.turn === message.turn)
@@ -18188,14 +18210,16 @@ function mateThreadHtml(data) {
18188
18210
  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
18211
  `<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
18212
  }
18213
+ let asked;
18191
18214
  for (const message of data.messages) {
18192
18215
  if (message.role === "operator") {
18216
+ asked = message.text;
18193
18217
  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
18218
  continue;
18195
18219
  }
18196
18220
  const cards = message.turn === null ? [] : (byTurn.get(message.turn) ?? []);
18197
18221
  parts.push(`<div class="msg mate" data-message-role="assistant" data-key="m${message.id}"${message.id === latestReply ? ' id="latest"' : ""}>` +
18198
- renderChatText(message.text) +
18222
+ renderChatText(message.text, asked) +
18199
18223
  (() => {
18200
18224
  const ask = message.turn === null ? undefined : data.asks?.get(message.turn);
18201
18225
  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 +18279,8 @@ function matePage(chrome, data) {
18255
18279
  if (data.problem !== null)
18256
18280
  conversation.push(`<div class="problem">${escape(data.problem)}</div>`);
18257
18281
  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>`);
18282
+ 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)}. ` +
18283
+ `<a href="/chat/ack/${turn.id}">Confirm that cost</a> to turn chat back on.</div>`);
18260
18284
  }
18261
18285
  const lastMessage = data.messages.at(-1);
18262
18286
  const latestReply = data.pending === null && lastMessage?.role === "assistant" ? lastMessage.id : null;
@@ -23916,6 +23940,9 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
23916
23940
  digestTimes(chatNotices.digestAt).map(([value, label]) => `<option value="${value}"${value === (chatNotices.digestAt ?? "off") ? " selected" : ""}>${label}</option>`).join("") +
23917
23941
  `</select></label>`,
23918
23942
  `<p class="meta">One message: what finished, what waits, what failed.</p>`,
23943
+ `<label>Screenshots with results<select name="screenshots">` +
23944
+ RESULT_SHOT_CHOICES.map(([value, label]) => `<option value="${value}"${value === (chatNotices.screenshots ?? "off") ? " selected" : ""}>${label}</option>`).join("") +
23945
+ `</select></label>`,
23919
23946
  `<button type="submit">Save</button>`,
23920
23947
  `</form>`,
23921
23948
  ...((chatNotices.projects ?? []).length === 0 ? [] : [
@@ -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>;
@@ -1,4 +1,5 @@
1
1
  import { chatFlowButtons } from "./chat-flow.js";
2
+ import { refuseResultShots, resultShotsPruned } from "./result-shots.js";
2
3
  import { chatQuestionButtons } from "./teammate-question.js";
3
4
  import { chatAskButtons } from "./chat-ask.js";
4
5
  import { channelInbox } from "./chat-inbox.js";
@@ -7,6 +8,7 @@ import { processChatEvent, applyChatAction, planChatNotifications, channelAccess
7
8
  /** Slack is a transport for the same saved assistant and confirmation doors. */
8
9
  import { proposalPreview, proposalLink, proposalOutcomeText, armedCardText, armedYesLabel, } from "./chat-channel.js";
9
10
  import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
11
+ import { renderReply } from "./reply-shape.js";
10
12
  import {} from "./mate-doors.js";
11
13
  import { SlackState, slackHash, } from "./slack-state.js";
12
14
  import { object, slackId, slackTs, slackMember, SlackError, uploadSlackBytes, } from "./slack-api.js";
@@ -131,6 +133,10 @@ const delivery = (options) => ({
131
133
  state: new SlackState(options.store),
132
134
  label: "Slack",
133
135
  member: (member, channel) => slackMember(options.api, options.identity, member, channel),
136
+ // A 👍 on the owner's message where the app has reactions:write; Slack shows bots no typing indicator.
137
+ warm: (event) => ({
138
+ react: () => options.api("reactions.add", { channel: event.channel, timestamp: event.ts, name: "thumbsup" }),
139
+ }),
134
140
  });
135
141
  export const processSlackEvent = (options) => processChatEvent(delivery(options));
136
142
  export const applySlackAction = (options, event, binding, repos) => applyChatAction(delivery(options), event, binding, repos);
@@ -156,6 +162,16 @@ export function slackBlocks(text, buttons = []) {
156
162
  blocks.push({ type: "actions", elements: buttons });
157
163
  return blocks;
158
164
  }
165
+ /** The lead's reply as mrkdwn sections: no header block, so its first line stays an ordinary line. */
166
+ export function slackVoiceBlocks(mrkdwn, buttons = []) {
167
+ const blocks = split(mrkdwn).map((part) => ({
168
+ type: "section",
169
+ text: { type: "mrkdwn", text: part },
170
+ }));
171
+ if (buttons.length)
172
+ blocks.push({ type: "actions", elements: buttons });
173
+ return blocks;
174
+ }
159
175
  function linkButton(origin, link) {
160
176
  if (!origin ||
161
177
  !link ||
@@ -217,6 +233,13 @@ export async function deliverSlackPart(options) {
217
233
  if (content.task &&
218
234
  !repos.includes(store.lookupRef(content.task)?.repo ?? ""))
219
235
  throw new SlackError("Connected projects changed");
236
+ // A screenshot sent with a result goes in that result's thread once its message is placed.
237
+ const follows = content.shot?.follows == null ? undefined : state.db.prepare("SELECT message FROM slack_part WHERE id=? AND state='sent'").get(content.shot.follows)?.message;
238
+ const thread = slackTs(follows) ? String(follows) : event.thread;
239
+ if (content.image && content.shot && resultShotsPruned(options.evidenceRoot, content.image.run)) {
240
+ state.db.prepare("UPDATE slack_part SET state='dropped',problem='Removed by retention' WHERE id=?").run(row.id);
241
+ return true;
242
+ }
220
243
  if (content.image) {
221
244
  const verified = verifyResultImage(store, options.evidenceRoot, repos, content.image);
222
245
  if (!verified.ok) {
@@ -257,7 +280,7 @@ export async function deliverSlackPart(options) {
257
280
  : [];
258
281
  const found = receipts
259
282
  .map(object)
260
- .find((receipt) => receipt.thread_ts === event.thread && slackTs(receipt.ts));
283
+ .find((receipt) => (receipt.thread_ts ?? "") === thread && slackTs(receipt.ts));
261
284
  if (found) {
262
285
  state.db
263
286
  .prepare("UPDATE slack_part SET state='sent',message=?,problem=NULL WHERE id=?")
@@ -273,7 +296,7 @@ export async function deliverSlackPart(options) {
273
296
  },
274
297
  ],
275
298
  channel_id: destination,
276
- thread_ts: event.thread,
299
+ thread_ts: thread === "" ? undefined : thread,
277
300
  initial_comment: safeResultImageCaption(content.text, content.image.taskId, content.image.run),
278
301
  });
279
302
  if (!Array.isArray(completed.files) ||
@@ -374,14 +397,16 @@ export async function deliverSlackPart(options) {
374
397
  ...(content.also ?? []).flatMap((one, index) => linkButton(options.origin(), one).map(button => ({ ...button, action_id: `toolroll_link_${index + 2}` }))),
375
398
  ];
376
399
  const target = content.edit ?? row.message;
400
+ // The lead's own reply goes out in Slack's mrkdwn (bold anchors, labelled links); everything else stays plain.
401
+ const voiced = content.voice === true && !content.proposal && !content.image ? renderReply(text, "slack") : null;
377
402
  const args = {
378
403
  channel: destination,
379
- text: text
404
+ text: voiced ?? text
380
405
  .replaceAll("&", "&amp;")
381
406
  .replaceAll("<", "&lt;")
382
407
  .replaceAll(">", "&gt;"),
383
- blocks: slackBlocks(text, buttons),
384
- mrkdwn: false,
408
+ blocks: voiced === null ? slackBlocks(text, buttons) : slackVoiceBlocks(voiced, buttons),
409
+ mrkdwn: voiced !== null,
385
410
  parse: "none",
386
411
  unfurl_links: false,
387
412
  unfurl_media: false,
@@ -390,9 +415,9 @@ export async function deliverSlackPart(options) {
390
415
  ? await options.api("chat.update", { ...args, ts: target })
391
416
  : await options.api("chat.postMessage", {
392
417
  ...args,
393
- thread_ts: event.kind === "notice" && event.thread === ""
418
+ thread_ts: event.kind === "notice" && thread === ""
394
419
  ? undefined
395
- : event.thread,
420
+ : thread,
396
421
  });
397
422
  if (!slackTs(answer.ts) || (target && answer.ts !== target))
398
423
  throw new SlackError("Slack did not confirm the message identity", 15_000, true);
@@ -405,6 +430,12 @@ export async function deliverSlackPart(options) {
405
430
  return true;
406
431
  }
407
432
  catch (error) {
433
+ // No permission to upload files here: one plain line instead of the result's screenshots.
434
+ const content = JSON.parse(row.payload);
435
+ if (content.image && content.shot && error instanceof SlackError && ["missing_scope", "no_permission"].includes(error.code)) {
436
+ refuseResultShots(state, store, row, content, "Slack", nowOf(options));
437
+ return true;
438
+ }
408
439
  const problem = error instanceof SlackError
409
440
  ? error
410
441
  : new SlackError("Slack delivery is waiting to retry", 15_000, true);
package/dist/store.d.ts CHANGED
@@ -47,6 +47,9 @@ export type TaskActRow = {
47
47
  * "Something else" is implied. The tapped option comes back as the owner's next message, so nothing here is an answer:
48
48
  * a question is open while no later message from the owner sits in its thread. */
49
49
  export declare const MATE_ASK_SCHEMA = "\nCREATE TABLE IF NOT EXISTS mate_ask (\n turn INTEGER PRIMARY KEY REFERENCES mate_turn(id) ON DELETE CASCADE,\n thread INTEGER NOT NULL REFERENCES mate_thread(id) ON DELETE CASCADE,\n question TEXT NOT NULL,\n options_json TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS mate_ask_thread ON mate_ask (thread, turn);\n";
50
+ /** Decisions in the chat app (chat-decide.ts; no version bump: additive only). A token names one act on one card (the chat and message it rides); `digest` is
51
+ * what the card showed — a receipt, a revision source, a task stamp, a plan or a commit. */
52
+ export declare const CHAT_DECIDE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS chat_decide_action (\n token TEXT PRIMARY KEY,\n channel TEXT NOT NULL,\n binding INTEGER NOT NULL,\n chat TEXT NOT NULL,\n message TEXT,\n act TEXT NOT NULL CHECK (act IN ('accept','changes','retry','approve','not-now','merge')),\n phase TEXT NOT NULL CHECK (phase IN ('offer','yes','cancel')),\n task_id TEXT NOT NULL,\n run INTEGER,\n digest TEXT NOT NULL,\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n consumed_at TEXT\n);\nCREATE INDEX IF NOT EXISTS chat_decide_action_message ON chat_decide_action (channel, chat, message);\nCREATE TABLE IF NOT EXISTS chat_decide_prompt (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel TEXT NOT NULL,\n binding INTEGER NOT NULL,\n chat TEXT NOT NULL,\n task_id TEXT NOT NULL,\n run INTEGER NOT NULL,\n digest TEXT NOT NULL,\n message TEXT,\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n consumed_at TEXT\n);\nCREATE INDEX IF NOT EXISTS chat_decide_prompt_open ON chat_decide_prompt (channel, binding, consumed_at);\n";
50
53
  export type MateAsk = {
51
54
  turn: number;
52
55
  thread: number;
@@ -57,6 +60,8 @@ export type MateAsk = {
57
60
  export declare const MATE_ASK_OTHER = "Something else";
58
61
  /** How long the question's buttons work, on every channel. */
59
62
  export declare const MATE_ASK_TTL_MS: number;
63
+ /** What a turn the lead starts itself for automatic crew updates says in the owner's place (lead-follow.ts): never their answer. */
64
+ 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
65
  /** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
61
66
  * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
62
67
  * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
@@ -990,12 +995,18 @@ export type TelegramDelivery = Notification & {
990
995
  };
991
996
  /** How chats reach one person (quiet chat): only when they are needed, or every step. */
992
997
  export type NotificationMode = "quiet" | "all";
998
+ /** Screenshots with results: none (the default), the first one, or up to four (result-shots.ts). */
999
+ export type ResultScreenshots = "off" | "first" | "all";
1000
+ export declare const RESULT_SCREENSHOTS: readonly ResultScreenshots[];
993
1001
  export type NotificationPreference = {
994
1002
  mode: NotificationMode;
995
1003
  digestAt: string | null;
996
1004
  digestOn: string | null;
997
1005
  updatedAt: string | null;
1006
+ screenshots: ResultScreenshots;
998
1007
  };
1008
+ /** One person's saved screenshots following a result message: its own outbox row, addressed to them (result-shots.ts). */
1009
+ export declare const RESULT_SHOTS_KIND = "result-screenshots";
999
1010
  /** Tasks filed this close together share one chat message. */
1000
1011
  export declare const QUIET_GROUP_MS = 60000;
1001
1012
  export declare function isDigestTime(value: string): boolean;
@@ -6702,8 +6713,9 @@ export declare class Store {
6702
6713
  dropMateAsk(turn: number): void;
6703
6714
  /** The question an ANSWERED turn asked; null when it asked none or did not answer. */
6704
6715
  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). */
6716
+ /** What a tap on the question finds: open; answered (its owner has written in its thread since the reply that asked
6717
+ * it, typed or tapped; a turn the lead started itself, or another person's message, is no answer); or expired (older
6718
+ * than MATE_ASK_TTL_MS, or the question or the reply that asked it is gone, as after a retention purge). */
6707
6719
  mateAskState(turn: number, now: Date): {
6708
6720
  state: "open";
6709
6721
  ask: MateAsk;
@@ -7492,7 +7504,20 @@ export declare class Store {
7492
7504
  setNotificationPreference(account: string, change: {
7493
7505
  mode?: NotificationMode;
7494
7506
  digestAt?: string | null;
7507
+ screenshots?: ResultScreenshots;
7495
7508
  }, by: string, now: Date): NotificationPreference;
7509
+ /** The screenshots of this result already sent (or being sent) to one destination. */
7510
+ resultShotsSent(destination: string, run: number): Set<number>;
7511
+ /** Mark screenshots as sent BEFORE they go: an unconfirmed send is never repeated. */
7512
+ markResultShotsSent(destination: string, run: number, artifacts: readonly number[], now: Date): void;
7513
+ /** Telegram refused them outright, so nothing arrived: they may be tried again. */
7514
+ unmarkResultShotsSent(destination: string, run: number, artifacts: readonly number[]): void;
7515
+ /**
7516
+ * A result message just became a fact: everyone who asked for screenshots with results, can see the project and
7517
+ * would be pinged by it gets one row of their own that follows it — once per result, whatever produced the fact.
7518
+ * Only a result that saved screenshots qualifies; what is sent, and whether at all, is decided again at send time.
7519
+ */
7520
+ private enqueueResultShots;
7496
7521
  /** Everyone who asked for an evening digest, with the local day theirs was last considered. */
7497
7522
  eveningDigestPeople(): {
7498
7523
  account: string;
@@ -7501,13 +7526,19 @@ export declare class Store {
7501
7526
  }[];
7502
7527
  /** Claim one local day's digest for one person: true once, false if it was already considered. */
7503
7528
  claimEveningDigestDay(account: string, day: string): boolean;
7504
- /** What this person named their lead and its persona, or null when they never saved one. */
7529
+ /** What this person named their lead and its persona, or null when they never saved one. An empty name or persona
7530
+ * (a row saved only for what the lead knows about them) reads as the default. */
7505
7531
  leadConfig(account: string): {
7506
7532
  name: string;
7507
7533
  persona: string;
7508
7534
  } | null;
7509
7535
  /** Save this person's lead name and persona (checked by the caller: lead-identity.ts). */
7510
7536
  setLeadConfig(account: string, name: string, persona: string, now: Date): void;
7537
+ /** What this person's lead knows about them: their confirmed lines, in order (none when they never saved any). */
7538
+ leadAbout(account: string): string[];
7539
+ /** Save what this person's lead knows about them (checked by the caller: lead-about.ts). A person who never named
7540
+ * their lead gets an empty name and persona, so they keep following the defaults. */
7541
+ setLeadAbout(account: string, lines: readonly string[], now: Date): void;
7511
7542
  /** A lead token for one person, shown once. A new one ends their earlier ones; the ledger names it "lead for <owner>". */
7512
7543
  mintLeadCredential(owner: string, by: string, now: Date): {
7513
7544
  id: string;
@@ -7579,6 +7610,8 @@ export declare class Store {
7579
7610
  chatBatchFor(destination: string, taskRef: number, run: number | null, now: Date, windowMs: number, notification?: number | null): ChatBatch;
7580
7611
  chatBatch(id: number): ChatBatch | null;
7581
7612
  setChatBatchMessage(id: number, message: string, digest: string): void;
7613
+ /** The finished-work message that carries this result at one destination, if one was placed: screenshots follow it. */
7614
+ chatBatchMessageFor(destination: string, taskRef: number, run: number): string | null;
7582
7615
  /** The message now showing this card, and what it shows. */
7583
7616
  setChatCardMessage(id: number, message: string, digest: string): void;
7584
7617
  markTelegramDigestSent(now: Date): void;