toolroll 0.9.25 → 0.9.27

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.
@@ -2,6 +2,7 @@
2
2
  * produced by the existing trusted server renderers, never by a model-supplied
3
3
  * fragment. These projections do not authenticate, mutate or grant authority. */
4
4
  import type { TeamSnapshot } from './team-contract.js';
5
+ import type { BrandIconId } from './brand-icons.js';
5
6
  import type { AssignmentSnapshot } from './assignment.js';
6
7
  export { browserCrewFromIndex, browserWorkActionHref } from './browser-crew.js';
7
8
  import type { Store } from './store.js';
@@ -281,11 +282,13 @@ export type BrowserSettingsView = {
281
282
  /** Settings → Updates: this version, the latest known one and its notes, how to update, the daily-check switch, and each worker's version. */
282
283
  updates?: BrowserUpdates | null;
283
284
  };
285
+ /** A tile's `brand` is a chat app's logo (brand-mark.ts), drawn in place of its icon. */
284
286
  export type BrowserSettingsGroup = {
285
287
  title: string;
286
288
  tiles: {
287
289
  href: string;
288
290
  label: string;
291
+ brand?: BrandIconId;
289
292
  status?: {
290
293
  tone: 'ok' | 'warn' | 'off';
291
294
  words: string;
@@ -822,6 +825,18 @@ export type BrowserFlowStage = {
822
825
  onFail: string | null;
823
826
  };
824
827
  /** One card: a piece of work, where it is, what it waits on, what zones said. */
828
+ /** After research, one of the report's items as the message numbered it, with its screenshot when it has one. */
829
+ export type BrowserFlowSentItem = {
830
+ number: number;
831
+ title: string;
832
+ lines: string[];
833
+ source: string;
834
+ url: string;
835
+ image: {
836
+ src: string;
837
+ caption: string;
838
+ } | null;
839
+ };
825
840
  export type BrowserFlowCard = {
826
841
  id: number;
827
842
  title: string;
@@ -912,6 +927,7 @@ export type BrowserFlowCard = {
912
927
  label: string;
913
928
  href: string;
914
929
  }[];
930
+ items?: BrowserFlowSentItem[];
915
931
  person: string;
916
932
  mine: boolean;
917
933
  options: {
@@ -928,6 +944,7 @@ export type BrowserFlowCard = {
928
944
  label: string;
929
945
  href: string;
930
946
  }[];
947
+ items?: BrowserFlowSentItem[];
931
948
  person: string;
932
949
  at: string;
933
950
  } | null;
@@ -840,18 +840,18 @@ export async function planChatNotifications(options) {
840
840
  }
841
841
  else if (notification.kind === "flow-decision") {
842
842
  // The draft as written, and Approve / Edit / Send back on its last part; a card that moved on is not news.
843
- const parts = flowDecisionParts(store, notification);
843
+ const parts = flowDecisionParts(store, notification, state.channel);
844
844
  if (parts !== null) {
845
845
  state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
846
846
  ts: "", thread: "", payload: "{}", created: now.toISOString() });
847
847
  state.plan(id, parts, now);
848
848
  }
849
849
  }
850
- else if (personal && flowSendParts(store, notification) !== null) {
850
+ else if (personal && flowSendParts(store, notification, state.channel) !== null) {
851
851
  // A flow's "Send to me" (flow-send.ts): what was done, with its links as buttons.
852
852
  state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
853
853
  ts: "", thread: "", payload: "{}", created: now.toISOString() });
854
- state.plan(id, flowSendParts(store, notification), now);
854
+ state.plan(id, flowSendParts(store, notification, state.channel), now);
855
855
  }
856
856
  else if (notification.kind === "flow-card" && questionParts(store, notification, binding) !== null) {
857
857
  // A teammate's question (v93): its options and "Answer in words" on the notice, for the person it asks.
@@ -4,12 +4,13 @@ export type ChatFlowAction = "approve" | "edit" | "send-back";
4
4
  export declare const CHAT_FLOW_LABELS: Record<ChatFlowAction, string>;
5
5
  /** Words kept as written, line breaks and all; only control characters go. */
6
6
  export declare function chatFlowText(text: string): string;
7
+ type ChatApp = "slack" | "discord" | "teams";
7
8
  /** A "Send to me" notice as chat parts: what was done, then its links; null when it isn't one. */
8
9
  export declare function flowSendParts(store: Store, notification: {
9
10
  dedupeKey: string;
10
11
  subject: string;
11
12
  body: string;
12
- }): ChatContent[] | null;
13
+ }, app?: ChatApp): ChatContent[] | null;
13
14
  /**
14
15
  * A "flow-decision" notice as chat parts, or null when it isn't one (or the
15
16
  * card has already moved on, and there is nothing left to decide).
@@ -19,7 +20,7 @@ export declare function flowDecisionParts(store: Store, notification: {
19
20
  subject: string;
20
21
  body: string;
21
22
  link: string | null;
22
- }): ChatContent[] | null;
23
+ }, app?: ChatApp): ChatContent[] | null;
23
24
  /** The live buttons on one part, in the order they were minted: a decision's, a choice's options in their order, or
24
25
  * "Use this as your note?" Yes / No. `key` is unique on the part (Slack names each button by it). */
25
26
  export declare function chatFlowButtons(state: ChatState, part: number, now: Date): Array<{
@@ -65,3 +66,4 @@ export declare function answerChatFlowPrompt(options: {
65
66
  text: string;
66
67
  originalLength?: number;
67
68
  }, repos: readonly string[], now: Date): boolean;
69
+ export {};
package/dist/chat-flow.js CHANGED
@@ -27,7 +27,9 @@
27
27
  */
28
28
  import { keptDraft } from "./flow-draft.js";
29
29
  import { decideFlowCard, flowCardHref, flowDefinitionOf } from "./flow-engine.js";
30
- import { chooseFlowCard, flowChoiceAt, flowSendPaths, FLOW_CHOOSE_KEY, FLOW_SEND_KEY, readFlowSend } from "./flow-send.js";
30
+ import { chooseFlowCard, flowChoiceAt, flowSendPaths, flowSendTail, FLOW_CHOOSE_KEY, FLOW_SEND_KEY, readFlowSend } from "./flow-send.js";
31
+ import { cleanFlowMessage, fitFlowMessage } from "./flow-items.js";
32
+ import { renderReply } from "./reply-shape.js";
31
33
  import { deciderOf, replyTarget } from "./flows.js";
32
34
  import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
33
35
  import { verifyApproverStanding } from "./principal.js";
@@ -71,13 +73,27 @@ function decisionParts(text, visit, link) {
71
73
  function sendLinks(paths) {
72
74
  return { ...(paths[0] === undefined ? {} : { link: paths[0] }), ...(paths.length > 1 ? { also: paths.slice(1) } : {}) };
73
75
  }
76
+ /** The most an item list's part carries here, as the app renders it — a Slack section (2,800), Discord's plain message
77
+ * content (2,000), a Teams message as long as Telegram's — less room for the line a tap adds under it. */
78
+ const ITEMS_LIMIT = { slack: PART_CHARS - 300, discord: 2000 - 300, teams: 4096 - 300 };
79
+ /** After research (flow-items.ts): the message with each item numbered under the summary, its words cleaned as every
80
+ * other flow message's are, in as few parts as the app's limit allows (one, unless head, links and tail alone overflow). */
81
+ function itemsText(content, subject, app) {
82
+ if (app === undefined || content.items === undefined || content.items.length === 0)
83
+ return null;
84
+ const message = cleanFlowMessage({ head: subject, summary: content.summary, items: content.items, tail: flowSendTail(content) }, chatFlowText);
85
+ return fitFlowMessage(message, ITEMS_LIMIT[app], shaped => renderReply(shaped, app).length);
86
+ }
74
87
  /** A "Send to me" notice as chat parts: what was done, then its links; null when it isn't one. */
75
- export function flowSendParts(store, notification) {
88
+ export function flowSendParts(store, notification, app) {
76
89
  const visit = FLOW_SEND_KEY.exec(notification.dedupeKey);
77
90
  const kept = visit === null ? null : store.flowSend(Number(visit[1]), Number(visit[2]));
78
91
  const content = kept === null ? null : readFlowSend(kept.contentJson);
79
92
  if (content === null)
80
93
  return null;
94
+ const voiced = itemsText(content, notification.subject, app);
95
+ if (voiced !== null)
96
+ return voiced.map((text, index) => index < voiced.length - 1 ? { text, voice: true } : { text, voice: true, ...sendLinks(flowSendPaths(content)) });
81
97
  const all = pieces(chatFlowText(`${notification.subject}\n\n${notification.body}`));
82
98
  return all.map((piece, index) => index < all.length - 1 ? { text: piece } : { text: piece, ...sendLinks(flowSendPaths(content)) });
83
99
  }
@@ -85,7 +101,7 @@ export function flowSendParts(store, notification) {
85
101
  * A "flow-decision" notice as chat parts, or null when it isn't one (or the
86
102
  * card has already moved on, and there is nothing left to decide).
87
103
  */
88
- export function flowDecisionParts(store, notification) {
104
+ export function flowDecisionParts(store, notification, app) {
89
105
  const choose = FLOW_CHOOSE_KEY.exec(notification.dedupeKey);
90
106
  if (choose !== null) {
91
107
  const visit = flowChoiceAt(store, Number(choose[1]), Number(choose[2]));
@@ -94,6 +110,10 @@ export function flowDecisionParts(store, notification) {
94
110
  if (visit === null || content === null)
95
111
  return null;
96
112
  const options = (content.options ?? []).filter(one => visit.stage.options?.[one.choice]?.label === one.label);
113
+ const voiced = itemsText(content, notification.subject, app);
114
+ if (voiced !== null)
115
+ return voiced.map((text, index) => index < voiced.length - 1 ? { text, voice: true }
116
+ : { text, voice: true, choose: { card: visit.card.id, entry: visit.card.entry, options }, ...sendLinks(flowSendPaths(content)) });
97
117
  const all = pieces(chatFlowText(`${notification.subject}\n\n${notification.body}`));
98
118
  return all.map((piece, index) => index < all.length - 1 ? { text: piece }
99
119
  : { text: piece, choose: { card: visit.card.id, entry: visit.card.entry, options }, ...sendLinks(flowSendPaths(content)) });
@@ -124,7 +144,9 @@ const retire = (state, card, entry, now) => {
124
144
  function repaint(state, part, event, line) {
125
145
  const row = state.prepare("SELECT payload FROM chat_part WHERE id=?").get(part);
126
146
  const before = row === undefined ? { text: "" } : JSON.parse(String(row["payload"]));
127
- const content = { text: `${before.text}\n\n${line}`.slice(0, PART_CHARS + 400), edit: event.ts, ...(before.link === undefined ? {} : { link: before.link }), ...(before.channel === undefined ? {} : { channel: before.channel }) };
147
+ // An item list (flow-items.ts) stays whole and keeps its bold titles and labelled links; only plain text is capped.
148
+ const content = { text: before.voice === true ? `${before.text}\n\n${line}` : `${before.text}\n\n${line}`.slice(0, PART_CHARS + 400), edit: event.ts, ...(before.voice === true ? { voice: true } : {}),
149
+ ...(before.link === undefined ? {} : { link: before.link }), ...(before.channel === undefined ? {} : { channel: before.channel }) };
128
150
  state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE id=?").run(JSON.stringify(content), part);
129
151
  }
130
152
  /**
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Flow passes that don't wait for builds. The worker's pass used to move
3
+ * cards only between builds, so a card whose research finished sat still
4
+ * while another build ran (Oct 4: five minutes). Now the worker runs these
5
+ * passes on their own cadence beside its builds, and a finished build,
6
+ * research or check moves its card at once.
7
+ *
8
+ * Model-free and cheap: nothing here starts a build. Cards entering a build
9
+ * zone file their task, which waits for a free slot like any other.
10
+ */
11
+ import { type FlowAdvance } from "./flow-engine.js";
12
+ import { type TriggerIo, type TriggerPass } from "./flow-triggers.js";
13
+ import { type ReplyWatch, type ReplyWatchIo } from "./flow-replies.js";
14
+ import { type StepIo, type StepPass } from "./flow-steps.js";
15
+ import type { Store } from "./store.js";
16
+ /** How often the worker moves cards, checks triggers, reads replies and runs steps, builds or not. */
17
+ export declare const FLOW_EVERY_MS = 15000;
18
+ /** One pass of a kind for a project at a time in this process: a second one asked for while it runs is skipped
19
+ * (`idle`), and the next cadence takes up whatever it would have done. Cards are also claimed in the database,
20
+ * so passes in other processes can't act twice either. */
21
+ export declare function alone<T>(kind: string, project: string, idle: T, run: () => Promise<T>): Promise<T>;
22
+ export type FlowIo = {
23
+ triggers: TriggerIo;
24
+ replies: ReplyWatchIo;
25
+ steps: StepIo;
26
+ evidenceRoot?: string;
27
+ };
28
+ export type FlowHousekeeping = {
29
+ triggers: TriggerPass;
30
+ replies: ReplyWatch;
31
+ steps: StepPass;
32
+ flows: FlowAdvance;
33
+ };
34
+ /** One project's flow pass: triggers (which may start cards), replies (which move waiting cards), steps, then moves.
35
+ * `halted` (a stop or paused admission) skips everything that reaches outside; moves still run. */
36
+ export declare function flowHousekeeping(store: Store, repo: string, clock: () => Date, io: FlowIo, halted: () => boolean): Promise<FlowHousekeeping>;
37
+ /** Move one project's cards. Synchronous, so two can't overlap in a process; each move is checked against the
38
+ * card's visit in its own transaction, so a pass in another process can't move a card twice. */
39
+ export declare function moveCards(store: Store, repo: string, now: Date, evidenceRoot: string | undefined): FlowAdvance;
40
+ /** A build, research or check just finished: move the cards in this project, and in the project of any flow whose
41
+ * card that task belongs to (a build zone may build in another project), now rather than on the next pass. */
42
+ export declare function moveCardsAfter(store: Store, repo: string, tasks: readonly string[], now: Date, evidenceRoot: string | undefined): FlowAdvance;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Flow passes that don't wait for builds. The worker's pass used to move
3
+ * cards only between builds, so a card whose research finished sat still
4
+ * while another build ran (Oct 4: five minutes). Now the worker runs these
5
+ * passes on their own cadence beside its builds, and a finished build,
6
+ * research or check moves its card at once.
7
+ *
8
+ * Model-free and cheap: nothing here starts a build. Cards entering a build
9
+ * zone file their task, which waits for a free slot like any other.
10
+ */
11
+ import { advanceFlows } from "./flow-engine.js";
12
+ import { runFlowTriggers } from "./flow-triggers.js";
13
+ import { watchFlowReplies } from "./flow-replies.js";
14
+ import { runFlowSteps } from "./flow-steps.js";
15
+ /** How often the worker moves cards, checks triggers, reads replies and runs steps, builds or not. */
16
+ export const FLOW_EVERY_MS = 15_000;
17
+ const running = new Set();
18
+ /** One pass of a kind for a project at a time in this process: a second one asked for while it runs is skipped
19
+ * (`idle`), and the next cadence takes up whatever it would have done. Cards are also claimed in the database,
20
+ * so passes in other processes can't act twice either. */
21
+ export async function alone(kind, project, idle, run) {
22
+ const key = `${kind}\u0000${project}`;
23
+ if (running.has(key))
24
+ return idle;
25
+ running.add(key);
26
+ try {
27
+ return await run();
28
+ }
29
+ finally {
30
+ running.delete(key);
31
+ }
32
+ }
33
+ /** One project's flow pass: triggers (which may start cards), replies (which move waiting cards), steps, then moves.
34
+ * `halted` (a stop or paused admission) skips everything that reaches outside; moves still run. */
35
+ export async function flowHousekeeping(store, repo, clock, io, halted) {
36
+ const triggers = halted() ? { added: 0, checked: 0, problems: [] }
37
+ : await alone("triggers", repo, { added: 0, checked: 0, problems: [] }, () => runFlowTriggers(store, repo, clock(), io.triggers));
38
+ // One mailbox serves every project, so its reads are one at a time across them all.
39
+ const replies = halted() ? { read: 0, taken: 0, problem: null }
40
+ : await alone("replies", "*", { read: 0, taken: 0, problem: null }, () => watchFlowReplies(store, clock(), io.replies));
41
+ const steps = halted() ? { ran: 0, problems: [] }
42
+ : await alone("steps", repo, { ran: 0, problems: [] }, () => runFlowSteps(store, repo, clock(), io.steps));
43
+ // Last, so a step that just finished, or a card a trigger just added, moves now.
44
+ const flows = moveCards(store, repo, clock(), io.evidenceRoot);
45
+ return { triggers, replies, steps, flows };
46
+ }
47
+ /** Move one project's cards. Synchronous, so two can't overlap in a process; each move is checked against the
48
+ * card's visit in its own transaction, so a pass in another process can't move a card twice. */
49
+ export function moveCards(store, repo, now, evidenceRoot) {
50
+ try {
51
+ return advanceFlows(store, repo, now, evidenceRoot === undefined ? {} : { evidenceRoot });
52
+ }
53
+ catch (error) {
54
+ return { moved: 0, filed: [], problems: [`flows: ${error instanceof Error ? error.message : "could not move cards"}`] };
55
+ }
56
+ }
57
+ /** A build, research or check just finished: move the cards in this project, and in the project of any flow whose
58
+ * card that task belongs to (a build zone may build in another project), now rather than on the next pass. */
59
+ export function moveCardsAfter(store, repo, tasks, now, evidenceRoot) {
60
+ const projects = new Set([repo]);
61
+ for (const task of tasks) {
62
+ try {
63
+ const card = store.flowCardByTask(task) ?? store.flowCardByTask(store.taskFamilyOf(task, null, true)?.root.id ?? task);
64
+ const flow = card === null ? null : store.getFlow(card.flow);
65
+ if (flow !== null)
66
+ projects.add(flow.repo);
67
+ }
68
+ catch { /* not a task with a card */ }
69
+ }
70
+ const outcome = { moved: 0, filed: [], problems: [] };
71
+ for (const project of projects) {
72
+ const moved = moveCards(store, project, now, evidenceRoot);
73
+ outcome.moved += moved.moved;
74
+ outcome.filed.push(...moved.filed);
75
+ outcome.problems.push(...moved.problems);
76
+ }
77
+ return outcome;
78
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A research report's items in a flow's message (flow-send.ts): after a report step, each item is listed under the
3
+ * summary, numbered — its title in bold, its why in plain lines, and its link named by its source ("Mobbin", or the
4
+ * site's name). Each screenshot of an item is captioned with the same number, its title and how it plugs in
5
+ * (result-shots.ts), so "2." in the album is item 2 in the text.
6
+ *
7
+ * The message is written in reply-shape.ts's canonical form (`**bold**`, `[label](url)`), which every channel renders
8
+ * in its own format. Its words go through the channel's own cleaner first. Fitted to a channel's limit, each why is
9
+ * shortened evenly first; then the summary, then the titles; past that it is split into parts. Links are never cut.
10
+ */
11
+ export type FlowSendItem = {
12
+ title: string;
13
+ why: string;
14
+ url: string;
15
+ /** Where the link goes, by name: "Mobbin", "Linear", "Apple". */
16
+ source: string;
17
+ /** One line: how it plugs in (from the why, else the image's own caption). */
18
+ plug: string;
19
+ /** The item's screenshot (its evidence id in the report's run), when it has one. */
20
+ shot: number | null;
21
+ };
22
+ /** A link's source by name: a few known sites as they write themselves, else the site's own name from its address. */
23
+ export declare function itemSource(url: string): string;
24
+ /** A why as plain lines: its own lines, or "the pattern; why it fits us; how it plugs in" one to a line. */
25
+ export declare function whyLines(why: string): string[];
26
+ /** How an item plugs in, in one line: the why's last line when it has several, else the image's caption, else the why. */
27
+ export declare function itemPlug(why: string, imageCaption: string | null): string;
28
+ /** A screenshot's caption: its item's number, title and how it plugs in. */
29
+ export declare function itemCaption(number: number, item: Pick<FlowSendItem, "title" | "plug">): string;
30
+ export type FlowMessage = {
31
+ head: string;
32
+ summary: string;
33
+ items: readonly FlowSendItem[];
34
+ tail: readonly string[];
35
+ };
36
+ /** The message's own words through a channel's cleaner — head, summary, each title and why, and tail — before it is fitted. */
37
+ export declare function cleanFlowMessage(message: FlowMessage, clean: (text: string) => string): FlowMessage;
38
+ /**
39
+ * The message in canonical form, as parts within `limit` as `measure` counts each once the channel renders it: one
40
+ * whole part when it fits; else every why shortened to the same length; then the summary; then the titles. When the
41
+ * head, links and tail alone are over the limit, the whole message goes in several parts instead, split between
42
+ * blocks (else between lines). Every link stays.
43
+ */
44
+ export declare function fitFlowMessage(message: FlowMessage, limit: number, measure: (shaped: string) => number): string[];
45
+ /** Items read back from a kept content; anything malformed is left out. */
46
+ export declare function readFlowItems(raw: unknown): FlowSendItem[];
@@ -0,0 +1,152 @@
1
+ /**
2
+ * A research report's items in a flow's message (flow-send.ts): after a report step, each item is listed under the
3
+ * summary, numbered — its title in bold, its why in plain lines, and its link named by its source ("Mobbin", or the
4
+ * site's name). Each screenshot of an item is captioned with the same number, its title and how it plugs in
5
+ * (result-shots.ts), so "2." in the album is item 2 in the text.
6
+ *
7
+ * The message is written in reply-shape.ts's canonical form (`**bold**`, `[label](url)`), which every channel renders
8
+ * in its own format. Its words go through the channel's own cleaner first. Fitted to a channel's limit, each why is
9
+ * shortened evenly first; then the summary, then the titles; past that it is split into parts. Links are never cut.
10
+ */
11
+ const PLUG_CHARS = 160;
12
+ const NAMES = {
13
+ "mobbin.com": "Mobbin", "github.com": "GitHub", "figma.com": "Figma", "dribbble.com": "Dribbble", "behance.net": "Behance",
14
+ "youtube.com": "YouTube", "linkedin.com": "LinkedIn", "producthunt.com": "Product Hunt", "pageflows.com": "Page Flows",
15
+ };
16
+ const cutTo = (text, cap) => cap <= 0 ? "" : text.length <= cap ? text : `${text.slice(0, Math.max(0, cap - 1)).trimEnd()}…`;
17
+ const oneLine = (text) => text.replace(/\s+/g, " ").trim();
18
+ /** A link's source by name: a few known sites as they write themselves, else the site's own name from its address. */
19
+ export function itemSource(url) {
20
+ let host;
21
+ try {
22
+ host = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
23
+ }
24
+ catch {
25
+ return "Link";
26
+ }
27
+ for (const [site, name] of Object.entries(NAMES))
28
+ if (host === site || host.endsWith(`.${site}`))
29
+ return name;
30
+ const labels = host.split(".");
31
+ if (labels.length < 2 || /^[0-9.]+$/.test(host) || host.includes(":"))
32
+ return host;
33
+ // "bbc.co.uk" is the BBC's; "developer.apple.com" is Apple's.
34
+ const short = labels.length >= 3 && labels[labels.length - 1].length === 2 && labels[labels.length - 2].length <= 3;
35
+ const name = labels[labels.length - (short ? 3 : 2)];
36
+ return name === "" ? host : `${name.charAt(0).toUpperCase()}${name.slice(1)}`;
37
+ }
38
+ /** A why as plain lines: its own lines, or "the pattern; why it fits us; how it plugs in" one to a line. */
39
+ export function whyLines(why) {
40
+ const lines = why.split(/\n+/).map(one => one.trim()).filter(one => one !== "");
41
+ if (lines.length !== 1)
42
+ return lines;
43
+ const parts = lines[0].split(/;\s+/).map(one => one.trim()).filter(one => one !== "");
44
+ return parts.length >= 2 && parts.length <= 4 ? parts.map(one => `${one.charAt(0).toUpperCase()}${one.slice(1)}`) : lines;
45
+ }
46
+ /** How an item plugs in, in one line: the why's last line when it has several, else the image's caption, else the why. */
47
+ export function itemPlug(why, imageCaption) {
48
+ const lines = whyLines(why);
49
+ const words = lines.length >= 2 ? lines[lines.length - 1] : imageCaption !== null && imageCaption.trim() !== "" ? imageCaption : lines.join(" ");
50
+ return cutTo(oneLine(words), PLUG_CHARS);
51
+ }
52
+ /** A screenshot's caption: its item's number, title and how it plugs in. */
53
+ export function itemCaption(number, item) {
54
+ return item.plug === "" ? `${number}. ${item.title}` : `${number}. ${item.title} · ${item.plug}`;
55
+ }
56
+ /** Plain words can't become bold or a link by accident. */
57
+ const inert = (text) => text.replace(/\*\*/g, "*").replace(/\]\(/g, "] (");
58
+ /** A link the canonical form keeps whole: the characters it stops at are written percent-encoded, as browsers read them. */
59
+ const linkable = (url) => url.replace(/[()[\]<>"'`\s]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
60
+ /** The message's own words through a channel's cleaner — head, summary, each title and why, and tail — before it is fitted. */
61
+ export function cleanFlowMessage(message, clean) {
62
+ return { head: clean(message.head), summary: clean(message.summary), items: message.items.map(item => ({ ...item, title: clean(item.title), why: clean(item.why) })),
63
+ tail: message.tail.map(clean) };
64
+ }
65
+ function blocks(message, whyCap, summaryCap, titleCap) {
66
+ const items = message.items.map((item, index) => {
67
+ const title = cutTo(oneLine(item.title).replace(/\*/g, ""), titleCap);
68
+ const why = cutTo(whyLines(item.why).join("\n"), whyCap);
69
+ return [`${index + 1}. ${title === "" ? "" : `**${title}**`}`.trimEnd(), ...(why === "" ? [] : [inert(why)]), `[${item.source.replace(/[[\]]/g, "")}](${linkable(item.url)})`].join("\n");
70
+ });
71
+ return [inert(message.head), inert(cutTo(message.summary, summaryCap)), ...items, ...message.tail.map(inert)].filter(one => one.trim() !== "");
72
+ }
73
+ const compose = (message, whyCap, summaryCap, titleCap) => blocks(message, whyCap, summaryCap, titleCap).join("\n\n");
74
+ /** The largest n in [0, high] that fits, or -1. */
75
+ function largest(high, fits) {
76
+ if (!fits(0))
77
+ return -1;
78
+ let low = 0;
79
+ while (low < high) {
80
+ const mid = Math.ceil((low + high) / 2);
81
+ if (fits(mid))
82
+ low = mid;
83
+ else
84
+ high = mid - 1;
85
+ }
86
+ return low;
87
+ }
88
+ /** Whole pieces packed into as few parts as fit, joined by `glue`; a piece too long alone is broken by `smaller`. */
89
+ function pack(pieces, glue, fits, smaller) {
90
+ const parts = [];
91
+ let open = "";
92
+ for (const piece of pieces.flatMap(one => fits(one) ? [one] : smaller(one))) {
93
+ const joined = open === "" ? piece : `${open}${glue}${piece}`;
94
+ if (open === "" || fits(joined))
95
+ open = joined;
96
+ else {
97
+ parts.push(open);
98
+ open = piece;
99
+ }
100
+ }
101
+ return open === "" ? parts : [...parts, open];
102
+ }
103
+ /** A line longer than a whole part, in the longest prefixes that fit (never splitting a surrogate pair). */
104
+ function chop(line, fits) {
105
+ const out = [];
106
+ for (let rest = line; rest !== "";) {
107
+ let at = Math.max(1, largest(rest.length, n => fits(rest.slice(0, n))));
108
+ if (at < rest.length && /[\uD800-\uDBFF]/.test(rest[at - 1]))
109
+ at = at > 1 ? at - 1 : at + 1;
110
+ out.push(rest.slice(0, at));
111
+ rest = rest.slice(at);
112
+ }
113
+ return out;
114
+ }
115
+ /**
116
+ * The message in canonical form, as parts within `limit` as `measure` counts each once the channel renders it: one
117
+ * whole part when it fits; else every why shortened to the same length; then the summary; then the titles. When the
118
+ * head, links and tail alone are over the limit, the whole message goes in several parts instead, split between
119
+ * blocks (else between lines). Every link stays.
120
+ */
121
+ export function fitFlowMessage(message, limit, measure) {
122
+ const fits = (text) => measure(text) <= limit;
123
+ const full = compose(message, Infinity, Infinity, Infinity);
124
+ if (fits(full))
125
+ return [full];
126
+ const longest = Math.max(0, ...message.items.map(one => whyLines(one.why).join("\n").length));
127
+ const why = largest(longest, n => fits(compose(message, n, Infinity, Infinity)));
128
+ if (why >= 0)
129
+ return [compose(message, why, Infinity, Infinity)];
130
+ const summary = largest(message.summary.length, n => fits(compose(message, 0, n, Infinity)));
131
+ if (summary >= 0)
132
+ return [compose(message, 0, summary, Infinity)];
133
+ const title = largest(Math.max(0, ...message.items.map(one => one.title.length)), n => fits(compose(message, 0, 0, n)));
134
+ if (title >= 0)
135
+ return [compose(message, 0, 0, title)];
136
+ return pack(blocks(message, Infinity, Infinity, Infinity), "\n\n", fits, block => pack(block.split("\n"), "\n", fits, line => chop(line, fits)));
137
+ }
138
+ /** Items read back from a kept content; anything malformed is left out. */
139
+ export function readFlowItems(raw) {
140
+ if (!Array.isArray(raw))
141
+ return [];
142
+ return raw.flatMap((one) => {
143
+ if (typeof one !== "object" || one === null)
144
+ return [];
145
+ const item = one;
146
+ const text = (key) => typeof item[key] === "string" ? item[key] : null;
147
+ const [title, why, url, source, plug] = [text("title"), text("why"), text("url"), text("source"), text("plug")];
148
+ if (title === null || why === null || url === null || source === null || plug === null || !/^https?:\/\//.test(url))
149
+ return [];
150
+ return [{ title, why, url, source, plug, shot: typeof item["shot"] === "number" && Number.isSafeInteger(item["shot"]) ? item["shot"] : null }];
151
+ });
152
+ }
@@ -1,4 +1,5 @@
1
1
  import { type FlowDecision } from "./flow-engine.js";
2
+ import { type FlowSendItem } from "./flow-items.js";
2
3
  import { type FlowDefinition, type FlowStage } from "./flows.js";
3
4
  import { type FlowCardRow, type FlowRow, type Store } from "./store.js";
4
5
  /** The notice a "Send to me" visit sends: `flow-send:<card>:<entry>`. */
@@ -26,6 +27,8 @@ export type FlowSendContent = {
26
27
  taskId: string;
27
28
  run: number;
28
29
  } | null;
30
+ /** After research: the report's items, numbered in this order in every message and screenshot caption (flow-items.ts). */
31
+ items?: FlowSendItem[];
29
32
  /** choose: the options as they were offered, and whether a reply is taken. */
30
33
  options?: {
31
34
  choice: number;
@@ -52,6 +55,8 @@ export declare function sendStep(store: Store, flow: FlowRow, definition: FlowDe
52
55
  export declare function chooseStep(store: Store, flow: FlowRow, definition: FlowDefinition, stage: FlowStage, card: FlowCardRow, now: Date, evidenceRoot?: string): void;
53
56
  /** The words above a choice's buttons: what was done, then what to do. */
54
57
  export declare function flowChooseBody(content: FlowSendContent): string;
58
+ /** What a sent visit's message ends with, after its summary (and items): its pull request, and for a choice what to do. */
59
+ export declare function flowSendTail(content: FlowSendContent): string[];
55
60
  export type FlowChoiceVisit = {
56
61
  card: FlowCardRow;
57
62
  flow: FlowRow;
package/dist/flow-send.js CHANGED
@@ -16,7 +16,9 @@
16
16
  import { assignmentOf } from "./assignment.js";
17
17
  import { withActor } from "./actor.js";
18
18
  import { flowCardHref, flowDefinitionOf, revisionWithNote } from "./flow-engine.js";
19
+ import { itemPlug, itemSource, readFlowItems } from "./flow-items.js";
19
20
  import { notifyPeople } from "./flow-people.js";
21
+ import { readVerifiedReport } from "./evidence.js";
20
22
  import { FLOW_END, replyTarget } from "./flows.js";
21
23
  import { FLOW_SHOTS_KIND } from "./store.js";
22
24
  /** The notice a "Send to me" visit sends: `flow-send:<card>:<entry>`. */
@@ -63,6 +65,18 @@ function reportShotsOf(store, taskId) {
63
65
  const run = store.runsFor(ref.id).find(one => one.finishedAt !== null && one.role === "scout");
64
66
  return run !== undefined && run.outcome === "built" && store.artifactsFor(run.id).some(one => one.kind === "screenshot") ? { taskId, run: run.id } : null;
65
67
  }
68
+ /** A research task's report items, each with its source, how it plugs in, and its screenshot from that run. */
69
+ function reportItemsOf(store, taskId, shots, evidenceRoot) {
70
+ const ref = store.lookupRef(taskId);
71
+ const view = ref === null || evidenceRoot === undefined ? null : readVerifiedReport(store, evidenceRoot, ref.id);
72
+ if (view === null || !view.ok)
73
+ return [];
74
+ return view.report.items.map(item => {
75
+ const image = item.image === null ? undefined : view.report.images.find(one => one.file === item.image);
76
+ const shot = item.image === null || shots?.run !== view.run ? null : view.shots.find(one => one.file === item.image)?.artifactId ?? null;
77
+ return { title: plain(item.title), why: plain(item.why), url: item.url, source: itemSource(item.url), plug: plain(itemPlug(item.why, image?.caption ?? null)), shot };
78
+ });
79
+ }
66
80
  /** What a visit sends its person, worked out from the card as it is now. */
67
81
  export function flowSendContent(store, flow, definition, stage, card, now, evidenceRoot) {
68
82
  const before = stepBefore(store, definition, card);
@@ -96,10 +110,12 @@ export function flowSendContent(store, flow, definition, stage, card, now, evide
96
110
  links.push({ label: "Report", path: `/t/${encodeURIComponent(report)}` });
97
111
  links.push({ label: "Card", path: flowCardHref(flow.id, card.id) });
98
112
  const options = stage.kind === "choose" ? (stage.options ?? []).map((one, choice) => ({ choice, label: one.label })) : undefined;
113
+ // After research, its own screenshots are what that step produced; otherwise the build's.
114
+ const shots = (report === null ? null : reportShotsOf(store, report)) ?? result?.shots ?? null;
115
+ const items = report === null ? [] : reportItemsOf(store, report, shots?.taskId === report ? shots : null, evidenceRoot);
99
116
  return {
100
117
  title: cut(plain(before === null ? card.title : `${card.title} · ${before.title}`), 200), ...(before === null ? {} : { from: before.title }),
101
- // After research, its own screenshots are what that step produced; otherwise the build's.
102
- summary: plain(lines.join("\n\n")), links, shots: (report === null ? null : reportShotsOf(store, report)) ?? result?.shots ?? null,
118
+ summary: plain(lines.join("\n\n")), links, shots, ...(items.length === 0 ? {} : { items }),
103
119
  ...(options === undefined ? {} : { options, reply: replyTarget(stage) !== null }),
104
120
  };
105
121
  }
@@ -107,7 +123,13 @@ export function flowSendContent(store, flow, definition, stage, card, now, evide
107
123
  export function readFlowSend(json) {
108
124
  try {
109
125
  const raw = JSON.parse(json);
110
- return typeof raw.title === "string" && typeof raw.summary === "string" && Array.isArray(raw.links) ? raw : null;
126
+ if (typeof raw.title !== "string" || typeof raw.summary !== "string" || !Array.isArray(raw.links))
127
+ return null;
128
+ if (raw.items === undefined)
129
+ return raw;
130
+ const items = readFlowItems(raw.items);
131
+ const { items: _, ...rest } = raw;
132
+ return items.length === 0 ? rest : { ...rest, items };
111
133
  }
112
134
  catch {
113
135
  return null;
@@ -168,8 +190,12 @@ export function chooseStep(store, flow, definition, stage, card, now, evidenceRo
168
190
  }
169
191
  /** The words above a choice's buttons: what was done, then what to do. */
170
192
  export function flowChooseBody(content) {
193
+ return [content.summary, ...flowSendTail(content)].join("\n\n");
194
+ }
195
+ /** What a sent visit's message ends with, after its summary (and items): its pull request, and for a choice what to do. */
196
+ export function flowSendTail(content) {
171
197
  const pull = content.links.find((one) => "url" in one);
172
- return [content.summary, ...(pull === undefined ? [] : [`${pull.label}: ${pull.url}`]), content.reply === true ? `Choose one. ${REPLY_ASK}` : "Choose one."].join("\n\n");
198
+ return [...(pull === undefined ? [] : [`${pull.label}: ${pull.url}`]), ...(content.options === undefined ? [] : [content.reply === true ? `Choose one. ${REPLY_ASK}` : "Choose one."])];
173
199
  }
174
200
  /** The card, if it is still at that visit, waiting for its person's choice. */
175
201
  export function flowChoiceAt(store, cardId, entry) {
package/dist/flows-ui.js CHANGED
@@ -7,6 +7,7 @@ import { flowSecretNames, readEmailSettings, sendingReady } from "./flow-actions
7
7
  import { projectToolsOf, secretsSetFor, toolStanding } from "./project-tools.js";
8
8
  import { flowFingerprint } from "./flow-live.js";
9
9
  import { flowChoiceAt, readFlowSend } from "./flow-send.js";
10
+ import { itemCaption, whyLines } from "./flow-items.js";
10
11
  import { FLOW_FILE_MAX_BYTES } from "./flow-share.js";
11
12
  import { googleConnected } from "./google-mail.js";
12
13
  import { mailboxReady } from "./mailbox.js";
@@ -130,7 +131,10 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
130
131
  // On the card itself: where it came from (the card's title is right above), and only the links the panel doesn't already have.
131
132
  const own = new Set([`/flows/${flow.id}?card=${card.id}`, ...(task === null ? [] : [`/t/${encodeURIComponent(task)}`])]);
132
133
  const sentView = (content) => ({ title: content.from === undefined ? "What was done" : `From ${content.from}`, summary: content.summary,
133
- links: content.links.map(one => ({ label: one.label, href: "url" in one ? one.url : one.path })).filter(one => !own.has(one.href)) });
134
+ links: content.links.map(one => ({ label: one.label, href: "url" in one ? one.url : one.path })).filter(one => !own.has(one.href)),
135
+ // After research: the same numbered items the message carried, each beside its screenshot.
136
+ ...(content.items === undefined ? {} : { items: content.items.map((item, index) => ({ number: index + 1, title: item.title, lines: whyLines(item.why), source: item.source, url: item.url,
137
+ image: item.shot === null || content.shots === null ? null : { src: `/r/${content.shots.run}/evidence/${item.shot}`, caption: itemCaption(index + 1, item) } })) }) });
134
138
  return {
135
139
  id: card.id, title: card.title, description: card.description, stage: card.stage, state: card.state, waiting: card.waiting,
136
140
  task: task === null ? null : { id: task, href: `/t/${encodeURIComponent(task)}` },