toolroll 0.9.26 → 0.9.28

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 (87) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/THIRD_PARTY_NOTICES.md +41 -0
  3. package/dist/agentconfig.js +71 -3
  4. package/dist/brand-icons.d.ts +168 -0
  5. package/dist/brand-icons.js +32 -0
  6. package/dist/brand-mark.d.ts +27 -0
  7. package/dist/brand-mark.js +49 -0
  8. package/dist/browser/THIRD_PARTY_NOTICES.txt +41 -0
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/browser/workspace.js +21 -23
  11. package/dist/browser-workspace.d.ts +17 -0
  12. package/dist/build-review.js +3 -2
  13. package/dist/builder.js +10 -8
  14. package/dist/chat-actions.js +7 -4
  15. package/dist/chat-delivery.js +3 -3
  16. package/dist/chat-flow.d.ts +4 -2
  17. package/dist/chat-flow.js +36 -10
  18. package/dist/converse.js +3 -2
  19. package/dist/coordinator-proposals.js +2 -2
  20. package/dist/decision.d.ts +3 -3
  21. package/dist/decision.js +4 -3
  22. package/dist/desktop-update.d.ts +2 -2
  23. package/dist/evidence.js +1 -1
  24. package/dist/flow-actions.d.ts +1 -0
  25. package/dist/flow-actions.js +6 -7
  26. package/dist/flow-code.d.ts +3 -2
  27. package/dist/flow-code.js +6 -4
  28. package/dist/flow-draft.d.ts +10 -4
  29. package/dist/flow-draft.js +19 -8
  30. package/dist/flow-engine.d.ts +0 -3
  31. package/dist/flow-engine.js +17 -10
  32. package/dist/flow-items.d.ts +46 -0
  33. package/dist/flow-items.js +152 -0
  34. package/dist/flow-people.d.ts +0 -1
  35. package/dist/flow-people.js +9 -1
  36. package/dist/flow-send.d.ts +5 -0
  37. package/dist/flow-send.js +33 -6
  38. package/dist/flow-steps.js +33 -10
  39. package/dist/flows-ui.js +5 -1
  40. package/dist/flows.d.ts +23 -8
  41. package/dist/flows.js +106 -52
  42. package/dist/guides.js +1 -1
  43. package/dist/integrations-ui.js +10 -3
  44. package/dist/integrations.d.ts +2 -0
  45. package/dist/integrations.js +2 -2
  46. package/dist/lead-context.d.ts +3 -9
  47. package/dist/lead-context.js +21 -5
  48. package/dist/mate-doors.js +8 -2
  49. package/dist/mate-tools.js +27 -10
  50. package/dist/mate.js +13 -2
  51. package/dist/operate.d.ts +3 -0
  52. package/dist/operate.js +122 -8
  53. package/dist/phase-routing.d.ts +86 -5
  54. package/dist/phase-routing.js +228 -16
  55. package/dist/project-skills.d.ts +10 -0
  56. package/dist/proposal.d.ts +10 -2
  57. package/dist/proposal.js +25 -10
  58. package/dist/recipe-ui.js +2 -1
  59. package/dist/result-shots.d.ts +5 -1
  60. package/dist/result-shots.js +33 -3
  61. package/dist/scout-report.d.ts +10 -4
  62. package/dist/scout-report.js +23 -6
  63. package/dist/scout.d.ts +2 -0
  64. package/dist/scout.js +84 -11
  65. package/dist/serve.d.ts +2 -0
  66. package/dist/serve.js +76 -23
  67. package/dist/store.d.ts +64 -5
  68. package/dist/store.js +192 -7
  69. package/dist/task-sizing.d.ts +78 -0
  70. package/dist/task-sizing.js +228 -0
  71. package/dist/task-text.d.ts +4 -4
  72. package/dist/task-text.js +10 -6
  73. package/dist/teammate-work.js +31 -7
  74. package/dist/teammates.d.ts +20 -1
  75. package/dist/teammates.js +18 -5
  76. package/dist/teams-chat.d.ts +4 -1
  77. package/dist/teams-chat.js +17 -6
  78. package/dist/telegram-flow.js +6 -2
  79. package/dist/telegram.js +37 -6
  80. package/dist/test-temp.js +1 -1
  81. package/dist/text-limits.d.ts +67 -0
  82. package/dist/text-limits.js +73 -0
  83. package/dist/tier-report.d.ts +44 -0
  84. package/dist/tier-report.js +71 -0
  85. package/dist/tools-ui.d.ts +1 -3
  86. package/dist/tools-ui.js +5 -4
  87. package/package.json +3 -1
@@ -8,14 +8,15 @@
8
8
  import { assignmentOf } from "./assignment.js";
9
9
  import { filerFor } from "./approval-policy.js";
10
10
  import { diagnoseTaskDispatch } from "./dispatch.js";
11
- import { deciderOf, durationWords, fillFlowText, fitFlowText, flowWorkTemplate, validateFlowDefinition, withinHours } from "./flows.js";
11
+ import { passOn, TEXT_LIMITS } from "./text-limits.js";
12
+ import { deciderOf, durationWords, fillFlowText, flowGoal, flowValueLabel, flowWorkTemplate, validateFlowDefinition, withinHours } from "./flows.js";
12
13
  import { reportFillIns, reportSummaryFor } from "./report-summary.js";
13
14
  import { fileTaskProposal } from "./proposal.js";
14
15
  import { requestResultChanges } from "./result-actions.js";
15
16
  import { revisionSourceOf } from "./result-review.js";
16
17
  import { readVerifiedReport, scanForSecrets } from "./evidence.js";
17
18
  import { cardFollowers, notifyPeople } from "./flow-people.js";
18
- import { keptDraft } from "./flow-draft.js";
19
+ import { DRAFT_CHARS, keptDraft } from "./flow-draft.js";
19
20
  import { chooseStep, flowPersonOf, sendStep } from "./flow-send.js";
20
21
  import { parseSoul, teammateLabel } from "./teammates.js";
21
22
  /** The flow's drawing, or null when it can't be read (the card then says so and waits). */
@@ -207,7 +208,7 @@ function advanceCard(store, flow, definition, card, now, options, outcome) {
207
208
  dedupeKey: `flow-decide:${card.id}:${card.entry}`, kind: "flow-decision", pushClass: "attention", ...(decider === null ? {} : { recipient: decider }),
208
209
  subject: (handoff !== null ? `${handoff.label}: ${card.title} needs ${decider === null ? "a decision" : `${decider}'s decision`}` : `${flow.name}: ${card.title} needs ${decider === null ? "a decision" : `${decider}'s decision`}`).slice(0, 200),
209
210
  body: `${handoff === null ? "" : `${handoff.said}\n\n`}${text === undefined ? `${stage.title}: approve it, or send it back with a note.`
210
- : `${stage.title}: approve this draft to send it as written, edit it, or send it back with a note.\n\n${text}`}`.slice(0, 4000),
211
+ : `${stage.title}: approve this draft to send it as written, edit it, or send it back with a note.\n\n${text}`}`,
211
212
  link: flowCardHref(flow.id, card.id), source: { project: flow.repo },
212
213
  }, now);
213
214
  store.updateFlowCard(card.id, { waiting: `Waiting for ${who} to approve or send it back` }, now);
@@ -247,9 +248,9 @@ function workStage(store, flow, definition, stage, card, now, options, outcome,
247
248
  // Waiting cards try again every pass, so a cause that's fixed (a shorter zone, a freed backlog) files the work then.
248
249
  const wait = (why) => { const waiting = `Couldn't file the work: ${why} It tries again on the next pass.`; if (card.waiting !== waiting)
249
250
  store.updateFlowCard(card.id, { waiting }, now); };
250
- // The goal fits a task: the zone's words whole, long filled-in values (a script's output) cut in the middle.
251
- // The agent is given them in full (flowGoalCuts).
252
- const goal = fitFlowText(flowWorkTemplate(stage.instructions ?? card.title, card), card);
251
+ // The goal fits a task: the zone's words whole and the card's details whole; a value too long for the goal (a
252
+ // script's output) is attached instead, and the agent is given it in full (flowGoalCuts). Nothing is cut.
253
+ const goal = flowGoal(stage.instructions ?? card.title, card, id => titleIn(definition, id));
253
254
  if (goal === null) {
254
255
  wait(`${stage.title}'s instructions are too long for a task. Open the flow and shorten them.`);
255
256
  return;
@@ -313,7 +314,8 @@ function workStage(store, flow, definition, stage, card, now, options, outcome,
313
314
  if (stage.kind === "report") {
314
315
  const ref = store.lookupRef(current);
315
316
  const summary = ref === null ? null : reportSummaryFor(store, options.evidenceRoot, ref.id);
316
- outputs[stage.id] = summary !== null && "summary" in summary ? summary.summary.slice(0, 6000) : "The report is ready on its task.";
317
+ // The summary whole (a report's summary is within TEXT_LIMITS.reportSummary bytes; an older, longer one is linked).
318
+ outputs[stage.id] = summary !== null && "summary" in summary ? passOn(summary.summary, TEXT_LIMITS.stageOutput, { label: `task ${current}`, href: null }).text : "The report is ready on its task.";
317
319
  // {{stage.<id>.items}} and {{stage.<id>.report}}: what it found and the whole report, for the zones after it.
318
320
  // A visit's report replaces the last visit's, so nothing stale is left behind.
319
321
  const view = ref === null || options.evidenceRoot === undefined ? null : readVerifiedReport(store, options.evidenceRoot, ref.id);
@@ -353,6 +355,9 @@ function workStage(store, flow, definition, stage, card, now, options, outcome,
353
355
  /** What a flow task's goal had to cut, whole, for the agent doing it: each value its zone fills in from the card that the
354
356
  * goal doesn't hold in full. Empty when the task didn't come from a flow card or nothing was cut. Script output and
355
357
  * card text can come from outside, so the brief quotes these as untrusted data. */
358
+ /** The most of one attached value the agent's brief carries: more than any card value holds (a whole report is at most
359
+ * 64 KiB), so in practice an attachment is never shortened. */
360
+ const FLOW_ATTACHED_LIMIT = 128_000;
356
361
  export function flowGoalCuts(store, taskId, goal) {
357
362
  try {
358
363
  return goalCuts(store, taskId, goal);
@@ -369,7 +374,6 @@ function goalCuts(store, taskId, goal) {
369
374
  const stage = definition?.stages.find(one => one.id === card.stage);
370
375
  if (card === null || definition == null || stage === undefined || (stage.kind !== "task" && stage.kind !== "report"))
371
376
  return [];
372
- const labels = { "card.title": "The card's title", "card.description": "The card's description", note: "The note it was sent back with" };
373
377
  const cuts = [];
374
378
  const seen = new Set();
375
379
  for (const match of flowWorkTemplate(stage.instructions ?? card.title, card).matchAll(/\{\{\s*(card\.title|card\.description|note|stage\.([a-z0-9-]+)(?:\.(items|report))?)\s*\}\}/g)) {
@@ -379,8 +383,7 @@ function goalCuts(store, taskId, goal) {
379
383
  const text = fillFlowText(`{{${match[1]}}}`, card);
380
384
  if (text === "" || goal.includes(text))
381
385
  continue;
382
- const part = match[3] === "items" ? "listed" : match[3] === "report" ? "reported in full" : "found";
383
- cuts.push({ label: labels[match[1]] ?? `What ${titleIn(definition, match[2])} ${part}`, text: text.slice(0, 50_000) });
386
+ cuts.push({ label: flowValueLabel(match[1], id => titleIn(definition, id)), text: text.slice(0, FLOW_ATTACHED_LIMIT) });
384
387
  }
385
388
  return cuts;
386
389
  }
@@ -488,6 +491,10 @@ export function decideFlowCard(store, input, now) {
488
491
  if (input.decision === "approve") {
489
492
  // An edited draft replaces the one Claude wrote, so the steps after this send what the person approved.
490
493
  const draft = draftFor(definition, stage);
494
+ // A person's edit over the draft limit is refused with it, never cut. (A teammate's was asked once to shorten and is kept whole.)
495
+ if (draft !== null && input.teammate === undefined && typeof input.draft === "string" && input.draft.trim().length > DRAFT_CHARS) {
496
+ return { ok: false, message: `Keep the draft to ${DRAFT_CHARS.toLocaleString("en-US")} characters; this is ${input.draft.trim().length.toLocaleString("en-US")}.` };
497
+ }
491
498
  if (draft !== null && typeof input.draft === "string" && input.draft.trim() !== "" && input.draft.trim() !== card.outputs[draft.id]?.trim()) {
492
499
  store.updateFlowCard(card.id, { outputs: { ...card.outputs, [draft.id]: keptDraft(input.draft) } }, now);
493
500
  store.addFlowComment({ card: card.id, author: input.actor, body: "Edited the draft before approving it.", mentions: [] }, now);
@@ -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
+ }
@@ -10,7 +10,6 @@ type CardEvent = {
10
10
  body: string;
11
11
  attention?: boolean;
12
12
  };
13
- /** One notification per person, to that person only. The actor never notifies themself. */
14
13
  export declare function notifyPeople(store: Store, card: FlowCardRow, people: Iterable<string>, actor: string | null, event: CardEvent, now: Date): string[];
15
14
  /** Who follows a card: its owner and its watchers. */
16
15
  export declare function cardFollowers(store: Store, card: FlowCardRow): string[];
@@ -22,6 +22,14 @@ export function mentionsIn(body, people) {
22
22
  return found;
23
23
  }
24
24
  /** One notification per person, to that person only. The actor never notifies themself. */
25
+ /** A notice's body: whole up to 2,000 characters; longer, its start and a line saying the rest is on the card it links. */
26
+ const NOTICE_CHARS = 2000;
27
+ function noticeBody(body) {
28
+ if (body.length <= NOTICE_CHARS)
29
+ return body;
30
+ const mark = "\n… (the rest is on the card)";
31
+ return `${body.slice(0, NOTICE_CHARS - mark.length)}${mark}`;
32
+ }
25
33
  export function notifyPeople(store, card, people, actor, event, now) {
26
34
  const flow = store.getFlow(card.flow);
27
35
  if (flow === null)
@@ -33,7 +41,7 @@ export function notifyPeople(store, card, people, actor, event, now) {
33
41
  const queued = store.enqueueNotification({
34
42
  dedupeKey: `flow-card:${card.id}:${event.key}:${person}`, kind: "flow-card", recipient: person,
35
43
  ...(event.attention === true ? { pushClass: "attention" } : {}),
36
- subject: `${flow.name}: ${event.subject}`.slice(0, 200), body: event.body.slice(0, 2000), link: flowCardHref(flow.id, card.id), source: { project: flow.repo },
44
+ subject: `${flow.name}: ${event.subject}`.slice(0, 200), body: noticeBody(event.body), link: flowCardHref(flow.id, card.id), source: { project: flow.repo },
37
45
  }, now);
38
46
  if (queued)
39
47
  sent.push(person);
@@ -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
@@ -14,9 +14,12 @@
14
14
  * Deterministic and model-free, in the worker's flow pass.
15
15
  */
16
16
  import { assignmentOf } from "./assignment.js";
17
+ import { TEXT_LIMITS } from "./text-limits.js";
17
18
  import { withActor } from "./actor.js";
18
19
  import { flowCardHref, flowDefinitionOf, revisionWithNote } from "./flow-engine.js";
20
+ import { itemPlug, itemSource, readFlowItems } from "./flow-items.js";
19
21
  import { notifyPeople } from "./flow-people.js";
22
+ import { readVerifiedReport } from "./evidence.js";
20
23
  import { FLOW_END, replyTarget } from "./flows.js";
21
24
  import { FLOW_SHOTS_KIND } from "./store.js";
22
25
  /** The notice a "Send to me" visit sends: `flow-send:<card>:<entry>`. */
@@ -63,6 +66,18 @@ function reportShotsOf(store, taskId) {
63
66
  const run = store.runsFor(ref.id).find(one => one.finishedAt !== null && one.role === "scout");
64
67
  return run !== undefined && run.outcome === "built" && store.artifactsFor(run.id).some(one => one.kind === "screenshot") ? { taskId, run: run.id } : null;
65
68
  }
69
+ /** A research task's report items, each with its source, how it plugs in, and its screenshot from that run. */
70
+ function reportItemsOf(store, taskId, shots, evidenceRoot) {
71
+ const ref = store.lookupRef(taskId);
72
+ const view = ref === null || evidenceRoot === undefined ? null : readVerifiedReport(store, evidenceRoot, ref.id);
73
+ if (view === null || !view.ok)
74
+ return [];
75
+ return view.report.items.map(item => {
76
+ const image = item.image === null ? undefined : view.report.images.find(one => one.file === item.image);
77
+ const shot = item.image === null || shots?.run !== view.run ? null : view.shots.find(one => one.file === item.image)?.artifactId ?? null;
78
+ 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 };
79
+ });
80
+ }
66
81
  /** What a visit sends its person, worked out from the card as it is now. */
67
82
  export function flowSendContent(store, flow, definition, stage, card, now, evidenceRoot) {
68
83
  const before = stepBefore(store, definition, card);
@@ -96,10 +111,12 @@ export function flowSendContent(store, flow, definition, stage, card, now, evide
96
111
  links.push({ label: "Report", path: `/t/${encodeURIComponent(report)}` });
97
112
  links.push({ label: "Card", path: flowCardHref(flow.id, card.id) });
98
113
  const options = stage.kind === "choose" ? (stage.options ?? []).map((one, choice) => ({ choice, label: one.label })) : undefined;
114
+ // After research, its own screenshots are what that step produced; otherwise the build's.
115
+ const shots = (report === null ? null : reportShotsOf(store, report)) ?? result?.shots ?? null;
116
+ const items = report === null ? [] : reportItemsOf(store, report, shots?.taskId === report ? shots : null, evidenceRoot);
99
117
  return {
100
118
  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,
119
+ summary: plain(lines.join("\n\n")), links, shots, ...(items.length === 0 ? {} : { items }),
103
120
  ...(options === undefined ? {} : { options, reply: replyTarget(stage) !== null }),
104
121
  };
105
122
  }
@@ -107,7 +124,13 @@ export function flowSendContent(store, flow, definition, stage, card, now, evide
107
124
  export function readFlowSend(json) {
108
125
  try {
109
126
  const raw = JSON.parse(json);
110
- return typeof raw.title === "string" && typeof raw.summary === "string" && Array.isArray(raw.links) ? raw : null;
127
+ if (typeof raw.title !== "string" || typeof raw.summary !== "string" || !Array.isArray(raw.links))
128
+ return null;
129
+ if (raw.items === undefined)
130
+ return raw;
131
+ const items = readFlowItems(raw.items);
132
+ const { items: _, ...rest } = raw;
133
+ return items.length === 0 ? rest : { ...rest, items };
111
134
  }
112
135
  catch {
113
136
  return null;
@@ -168,8 +191,12 @@ export function chooseStep(store, flow, definition, stage, card, now, evidenceRo
168
191
  }
169
192
  /** The words above a choice's buttons: what was done, then what to do. */
170
193
  export function flowChooseBody(content) {
194
+ return [content.summary, ...flowSendTail(content)].join("\n\n");
195
+ }
196
+ /** What a sent visit's message ends with, after its summary (and items): its pull request, and for a choice what to do. */
197
+ export function flowSendTail(content) {
171
198
  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");
199
+ return [...(pull === undefined ? [] : [`${pull.label}: ${pull.url}`]), ...(content.options === undefined ? [] : [content.reply === true ? `Choose one. ${REPLY_ASK}` : "Choose one."])];
173
200
  }
174
201
  /** The card, if it is still at that visit, waiting for its person's choice. */
175
202
  export function flowChoiceAt(store, cardId, entry) {
@@ -240,8 +267,8 @@ export function chooseFlowCard(store, input, now) {
240
267
  return { ok: false, message: "Where replies go changed since; nothing was changed." };
241
268
  if (note === "")
242
269
  return { ok: false, message: "Say what you'd change." };
243
- if (note.length > 2000)
244
- return { ok: false, message: "Keep it under 2,000 characters." };
270
+ if (note.length > TEXT_LIMITS.note)
271
+ return { ok: false, message: `Keep it to ${TEXT_LIMITS.note.toLocaleString("en-US")} characters; this is ${note.length.toLocaleString("en-US")}.` };
245
272
  const revision = revisionWithNote(store, card, definition.stages.find(one => one.id === target), note, input.actor, input.repos, input.evidenceRoot, now);
246
273
  const moved = store.transact(() => {
247
274
  if (!store.moveFlowCard(card.id, { to: target, outcome: "sent-back", actor: input.actor, note, ...(revision === null ? {} : { task: revision }), expectEntry: card.entry }, now))
@@ -27,12 +27,13 @@ import { join } from "node:path";
27
27
  import { approvedCommandShell, redactSecretAssignments, SETUP_ENV_ALLOWLIST, SETUP_ENV_DENYLIST } from "./builder.js";
28
28
  import { assignmentOf } from "./assignment.js";
29
29
  import { redactSecretLines, scanForSecrets } from "./evidence.js";
30
- import { flowDefinitionOf } from "./flow-engine.js";
30
+ import { flowCardHref, flowDefinitionOf } from "./flow-engine.js";
31
31
  import { cardFollowers, notifyPeople } from "./flow-people.js";
32
32
  import { LINEAR_URL, readLinearKey } from "./flow-triggers.js";
33
33
  import { cardEmailOf, fillFlowText } from "./flows.js";
34
34
  import { JEV_MODEL, askJev, readJevAnswers, sortLog, sortRequest, sortState, sortWords } from "./flow-sort.js";
35
- import { claudeDraftRunner, DRAFT_TIMEOUT, draftPrompt, keptDraft } from "./flow-draft.js";
35
+ import { claudeDraftRunner, DRAFT_CHARS, DRAFT_TIMEOUT, draftPrompt, wholeDraft } from "./flow-draft.js";
36
+ import { overruns, passOn, TEXT_LIMITS, writeWithin } from "./text-limits.js";
36
37
  import { readFlowSecrets, sendingReady, runRequest, sendEmail, toolWaiting, useTool } from "./flow-actions.js";
37
38
  import { cleanFolder, runCode } from "./flow-code.js";
38
39
  import { agentFence } from "./agent-fence.js";
@@ -246,7 +247,13 @@ function settle(store, definition, stage, card, outcome, now, durationMs) {
246
247
  outcome = { state: "failed", said: `${outcome.said} It didn't work after three tries.` };
247
248
  }
248
249
  store.finishFlowStep(card.id, card.entry, { state: outcome.state === "passed" ? "passed" : "failed", result: outcome.said, ...kept }, now);
249
- store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: outcome.output ?? outcome.said }, waiting: null }, now);
250
+ // What the steps after it read: whole up to TEXT_LIMITS.stageOutput; longer is attached whole to the card's discussion,
251
+ // and they read a link to it. Never cut.
252
+ const whole = outcome.output ?? outcome.said;
253
+ const passed = passOn(whole, TEXT_LIMITS.stageOutput, { label: "the card's discussion", href: flowCardHref(card.flow, card.id) });
254
+ if (passed.kept)
255
+ store.addFlowComment({ card: card.id, author: "flow", body: `What ${stage.title} produced, in full (${whole.length.toLocaleString("en-US")} characters):\n\n${whole}`, mentions: [] }, now);
256
+ store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: passed.text }, waiting: null }, now);
250
257
  const titleOf = (id) => definition.stages.find(one => one.id === id)?.title ?? "another zone";
251
258
  if (outcome.state === "passed") {
252
259
  // A sort names where the card goes; one it isn't sure about is a person's to place.
@@ -289,16 +296,32 @@ function draftModel(store) {
289
296
  /** A draft: Claude writes what the zone asks, from the card, with the lead chat's Claude model; the text stays on the card. */
290
297
  async function draftCard(store, definition, stage, card, io, repo, now) {
291
298
  const model = draftModel(store);
292
- const answer = await (io.draft ?? claudeDraftRunner())({ model, prompt: draftPrompt(stage, card, definition), timeoutMs: DRAFT_TIMEOUT });
299
+ const runner = io.draft ?? claudeDraftRunner();
300
+ // Claude is told the limit before it writes; a draft over it is asked once to shorten, and one still over is kept whole
301
+ // in the step's log and attached to the card's discussion, linked from what the next steps read (settle): never cut.
302
+ let first = null;
303
+ const written = await writeWithin(async (shorten) => {
304
+ const answer = await runner({ model, prompt: draftPrompt(stage, card, definition, shorten === null || first === null ? null : { draft: first, ask: shorten }), timeoutMs: DRAFT_TIMEOUT });
305
+ // v105: what it cost, billed as this computer's Claude does (a plan's is $0).
306
+ if (answer.ok) {
307
+ store.recordDraftSpend({ repo, model: model === "default" ? null : model, costUsd: answer.costUsd ?? null }, now);
308
+ first ??= wholeDraft(answer.text);
309
+ }
310
+ return answer;
311
+ }, answer => answer.ok ? overruns({ draft: wholeDraft(answer.text) }, { draft: DRAFT_CHARS }) : []);
312
+ const answer = written.answer;
293
313
  if (!answer.ok)
294
- return { state: "retry", said: answer.said };
295
- // v105: what it cost, billed as this computer's Claude does (a plan's is $0).
296
- store.recordDraftSpend({ repo, model: model === "default" ? null : model, costUsd: answer.costUsd ?? null }, now);
297
- const text = keptDraft(answer.text);
314
+ return written.repaired && first !== null ? draftOutcome(first, model, null, true) : { state: "retry", said: answer.said };
315
+ const text = wholeDraft(answer.text);
298
316
  if (text === "")
299
- return { state: "retry", said: "Claude's draft came back empty." };
317
+ return written.repaired && first !== null ? draftOutcome(first, model, answer.ms, true) : { state: "retry", said: "Claude's draft came back empty." };
318
+ return draftOutcome(text, model, answer.ms, written.repaired);
319
+ }
320
+ /** A draft step's result: the draft whole, on the card and in its log. */
321
+ function draftOutcome(text, model, ms, shortened) {
300
322
  const words = text.split(/\s+/).filter(Boolean).length;
301
- return { state: "passed", said: `Drafted ${words} word${words === 1 ? "" : "s"}.`, output: text, log: `Claude (${model}) · ${(answer.ms / 1000).toFixed(1)} s\n\n${text}` };
323
+ const over = text.length > DRAFT_CHARS ? ` It is still over the ${DRAFT_CHARS.toLocaleString("en-US")}-character limit after one ask to shorten, so it is kept whole.` : shortened ? " Shortened once to fit." : "";
324
+ return { state: "passed", said: `Drafted ${words} word${words === 1 ? "" : "s"}.${over}`, output: text, log: `Claude (${model})${ms === null ? "" : ` · ${(ms / 1000).toFixed(1)} s`}\n\n${text}` };
302
325
  }
303
326
  /** A sort: Jev picks one of the zone's answers for the card; the answer (or the not-sure path) says where it goes. */
304
327
  async function sortCard(definition, stage, card, key, io) {
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)}` },
package/dist/flows.d.ts CHANGED
@@ -167,6 +167,14 @@ export declare function deciderOf(stage: Pick<FlowStage, "approver" | "toOwner">
167
167
  export declare function validateFlowDefinition(input: unknown, options?: {
168
168
  stored?: boolean;
169
169
  }): FlowDefinition;
170
+ /** The instructions 0.9.26 and earlier read from a saved zone: they re-check this length on every read, and a flow with a
171
+ * longer one won't load. A saved zone keeps its first part under it, and the rest in `instructionsMore`, which they ignore. */
172
+ export declare const FLOW_INSTRUCTIONS_STORED = 4000;
173
+ /** A flow's definition as the store saves it: any zone's instructions over FLOW_INSTRUCTIONS_STORED are split, so a
174
+ * person who goes back a version still opens the flow (with its instructions' first part). Unchanged otherwise. */
175
+ export declare function flowDefinitionForStore(json: string): string;
176
+ /** A saved flow's definition with every zone's instructions whole again (flowDefinitionForStore). */
177
+ export declare function flowDefinitionFromStore(json: string): string;
170
178
  /** The zones a card can reach from `start` without passing through a zone `stop` picks (those zones aren't included). */
171
179
  export declare function reachableWithout(stages: readonly FlowStage[], start: string, stop: (stage: FlowStage) => boolean): Set<string>;
172
180
  /** The zones a choose zone's options lead to (an option that ends the card leads to none). */
@@ -183,20 +191,27 @@ type FlowFillCard = {
183
191
  };
184
192
  /** Fill a zone's text from the card: title, description, the latest note and earlier zones' reports. */
185
193
  export declare function fillFlowText(template: string, card: FlowFillCard, encode?: (value: string) => string): string;
186
- /** The task door's goal limit (task-text.ts). A work zone's goal is kept under it. */
187
- export declare const FLOW_GOAL_LIMIT = 2000;
188
- /** What stands in for the middle of a filled-in value the goal had no room for. */
189
- export declare const FLOW_CUT = "\u2026 (cut; the full text is on the card)";
194
+ /** The task door's goal limit (task-text.ts). A work zone's goal is kept within it. */
195
+ export declare const FLOW_GOAL_LIMIT: 8000;
196
+ /** What stands in a goal for a filled-in value too long to hold whole: the agent is given it whole, attached (flowGoalCuts). */
197
+ export declare const flowAttachedMark: (label: string) => string;
190
198
  /** A work zone's words, plus the card and any send-back note when the words leave them out. "Fix the bug on the card"
191
199
  * says nothing on its own (found in the real e2e: the builder saw only the branch name). */
192
200
  export declare function flowWorkTemplate(instructions: string, card: {
193
201
  description: string | null;
194
202
  note: string | null;
195
203
  }): string;
196
- /** A work zone's goal, filled from the card and kept under FLOW_GOAL_LIMIT: the zone's own words stay whole, and only the
197
- * filled-in values are shortened (the longest first), each keeping its start and end. Null when the words alone leave no
198
- * room, which a saved flow can't have (validateFlowDefinition). */
199
- export declare function fitFlowText(template: string, card: FlowFillCard, limit?: number): string | null;
204
+ /** How a filled-in value is named where it is attached: the same words flowGoalCuts labels it with. */
205
+ export declare function flowValueLabel(key: string, stageTitle?: (id: string) => string): string;
206
+ /** A work zone's goal, filled from the card and kept within `limit`: the zone's own words stay whole, and every filled-in
207
+ * value goes in whole. When they don't all fit, the longest values are attached instead (each named in the goal by
208
+ * flowAttachedMark, and given to the agent whole by flowGoalCuts) until the rest fits: nothing is cut. Words that fill
209
+ * the limit to the last character leave attached values a shorter mark, then none (they are still attached). Null only
210
+ * when the template's own words are over the limit: the caller then files the zone's words alone (flowGoal). */
211
+ export declare function fitFlowText(template: string, card: FlowFillCard, limit?: 8000, stageTitle?: (id: string) => string): string | null;
212
+ /** A work zone's goal for a card: its words, the card and any note, fitted (fitFlowText); when the zone's words leave no
213
+ * room for the card's own lines, its words alone, with the card attached. Null only for words over the limit. */
214
+ export declare function flowGoal(instructions: string, card: FlowFillCard, stageTitle?: (id: string) => string): string | null;
200
215
  /** The first email address a card mentions, or "" — what {{card.email}} holds. */
201
216
  export declare function cardEmailOf(card: {
202
217
  title: string;