toolroll 0.9.15 → 0.9.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/assignment-brief.d.ts +10 -0
  3. package/dist/assignment-brief.js +56 -26
  4. package/dist/assignment.d.ts +8 -7
  5. package/dist/assignment.js +13 -8
  6. package/dist/browser/workspace.css +1 -1
  7. package/dist/browser/workspace.js +22 -22
  8. package/dist/browser-crew.js +1 -0
  9. package/dist/browser-workspace.d.ts +5 -0
  10. package/dist/chat-actions.d.ts +7 -0
  11. package/dist/chat-actions.js +40 -4
  12. package/dist/chat-ask.d.ts +28 -0
  13. package/dist/chat-ask.js +91 -0
  14. package/dist/chat-channel.js +8 -1
  15. package/dist/chat-controls.d.ts +8 -0
  16. package/dist/chat-controls.js +3 -0
  17. package/dist/chat-delivery-state.d.ts +8 -1
  18. package/dist/chat-delivery-state.js +15 -2
  19. package/dist/chat-delivery.d.ts +3 -0
  20. package/dist/chat-delivery.js +49 -17
  21. package/dist/chat-quiet.js +4 -2
  22. package/dist/chat-voice.d.ts +3 -6
  23. package/dist/chat-voice.js +8 -2
  24. package/dist/chat-warmth.d.ts +21 -0
  25. package/dist/chat-warmth.js +36 -0
  26. package/dist/discord-api.d.ts +1 -1
  27. package/dist/discord-api.js +1 -1
  28. package/dist/discord-chat.d.ts +3 -2
  29. package/dist/discord-chat.js +28 -3
  30. package/dist/lead-about.d.ts +35 -0
  31. package/dist/lead-about.js +62 -0
  32. package/dist/lead-capabilities.d.ts +64 -0
  33. package/dist/lead-capabilities.js +199 -0
  34. package/dist/lead-commitments.d.ts +84 -0
  35. package/dist/lead-commitments.js +214 -0
  36. package/dist/lead-context.d.ts +34 -3
  37. package/dist/lead-context.js +155 -19
  38. package/dist/lead-follow.d.ts +1 -1
  39. package/dist/lead-follow.js +6 -2
  40. package/dist/lead-identity.d.ts +23 -0
  41. package/dist/lead-identity.js +33 -0
  42. package/dist/lead-people.d.ts +55 -0
  43. package/dist/lead-people.js +128 -0
  44. package/dist/lead-voice.d.ts +13 -9
  45. package/dist/lead-voice.js +26 -16
  46. package/dist/ledger-view.js +3 -2
  47. package/dist/mate-cli.d.ts +4 -1
  48. package/dist/mate-cli.js +23 -16
  49. package/dist/mate-contract.d.ts +1 -1
  50. package/dist/mate-contract.js +13 -4
  51. package/dist/mate-progress.js +7 -0
  52. package/dist/mate-tools.d.ts +26 -1
  53. package/dist/mate-tools.js +357 -7
  54. package/dist/mate.d.ts +35 -1
  55. package/dist/mate.js +121 -61
  56. package/dist/operate.js +4 -1
  57. package/dist/phase-routing.d.ts +1 -1
  58. package/dist/project-delete.js +3 -1
  59. package/dist/provider-auth.d.ts +4 -0
  60. package/dist/provider-auth.js +1 -1
  61. package/dist/reply-shape.d.ts +96 -0
  62. package/dist/reply-shape.js +452 -0
  63. package/dist/result-acts.d.ts +14 -0
  64. package/dist/result-acts.js +19 -0
  65. package/dist/retention.js +6 -0
  66. package/dist/serve.js +162 -55
  67. package/dist/slack-chat.d.ts +2 -0
  68. package/dist/slack-chat.js +30 -3
  69. package/dist/store.d.ts +60 -0
  70. package/dist/store.js +152 -3
  71. package/dist/surface.js +2 -2
  72. package/dist/task-status.d.ts +5 -15
  73. package/dist/task-status.js +18 -5
  74. package/dist/team-runtime.js +3 -2
  75. package/dist/teams-chat.js +16 -2
  76. package/dist/telegram-mate.d.ts +6 -1
  77. package/dist/telegram-mate.js +43 -11
  78. package/dist/telegram-progress.js +3 -2
  79. package/dist/telegram-team.js +6 -3
  80. package/dist/telegram.js +43 -3
  81. package/dist/test-temp.js +1 -1
  82. package/dist/when-html.d.ts +8 -3
  83. package/dist/when-html.js +23 -3
  84. package/dist/work-index.d.ts +2 -0
  85. package/dist/work-index.js +9 -6
  86. package/dist/work-summary.d.ts +1 -1
  87. package/dist/workspace-ui.d.ts +2 -14
  88. package/dist/workspace-ui.js +4 -22
  89. package/package.json +1 -1
@@ -0,0 +1,452 @@
1
+ /**
2
+ * The lead's voice, enforced: every chat surface (console, terminal, Telegram, Slack, Discord, Teams) runs a reply
3
+ * through here before it is sent. The contract asks for plain text; this makes it so, without changing what the reply
4
+ * says. Shaping is channel-neutral and idempotent — it yields a small canonical form (`**anchor**`, `[label](url)`,
5
+ * `code`) — and each channel renders that form in its own format.
6
+ *
7
+ * - Markdown headers become plain lines.
8
+ * - Bold stays on at most three short anchors; the rest is plain.
9
+ * - Bare URLs become labelled links, named from their target: "the task", "the result", "Settings → Lead", else the host.
10
+ * - Internal ids (run #12, digests, r1-style project ids) are removed unless the owner asked for them. When a reply names
11
+ * several of one kind (two runs, two projects), they stay: two items must never read the same.
12
+ * - Runs of blank lines collapse to one.
13
+ * Code (inline or fenced) is left exactly as written.
14
+ */
15
+ export const BOLD_ANCHORS = 3;
16
+ /** A bold anchor is a few words, not a sentence. */
17
+ export const BOLD_ANCHOR_CHARS = 40;
18
+ const URL_CHARS = String.raw `https?:\/\/[^\s<>()\[\]"'\`]+`;
19
+ const LINK = new RegExp(String.raw `\[([^\]\n]{1,120})\]\((${URL_CHARS})\)`, "g");
20
+ const PROTECTED = new RegExp(String.raw `\x60\x60\x60[\s\S]*?(?:\x60\x60\x60|$)|\x60[^\x60\n]+\x60|\[[^\]\n]{1,120}\]\(${URL_CHARS}\)|<${URL_CHARS}>|${URL_CHARS}`, "g");
21
+ const HOLD = (index) => `\u0000${index}\u0000`;
22
+ /** Did the owner ask for ids (an id, a digest, a hash, a run number or a project's r-number)? */
23
+ export function askedForIds(asked) {
24
+ if (asked === undefined)
25
+ return false;
26
+ return /\b(?:ids?|identifiers?|digests?|sha(?:256)?|hash(?:es)?|commit|turn\s*(?:#|number)|run\s*(?:#|numbers?|ids?)|r\d{1,3})\b|#\d+/i.test(asked);
27
+ }
28
+ /** Is this link on the app's own origin? Without a known origin, nothing is. */
29
+ function ownLink(url, appOrigin) {
30
+ if (appOrigin === null || appOrigin === undefined)
31
+ return false;
32
+ const origins = typeof appOrigin === "string" ? [appOrigin] : appOrigin;
33
+ let target;
34
+ try {
35
+ target = new URL(url).origin;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ return origins.some(origin => {
41
+ if (origin === null || origin === "")
42
+ return false;
43
+ try {
44
+ return new URL(origin).origin === target;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ });
50
+ }
51
+ /** What a link is called, from where it goes: the app's own task, result and settings pages by name (only on `appOrigin`); anything else by host. */
52
+ export function linkLabel(url, appOrigin) {
53
+ let parsed;
54
+ try {
55
+ parsed = new URL(url);
56
+ }
57
+ catch {
58
+ return url;
59
+ }
60
+ if (!ownLink(url, appOrigin))
61
+ return parsed.hostname.replace(/^www\./, "");
62
+ const path = parsed.pathname.replace(/\/+$/, "") || "/";
63
+ const query = parsed.searchParams;
64
+ if (query.has("result") || path === "/review" || /^\/results?(?:\/|$)/.test(path))
65
+ return "the result";
66
+ if (/^\/t\/[^/]+$/.test(path) || (path === "/chat" && query.has("task")) || /^\/tasks?\/[^/]+$/.test(path))
67
+ return "the task";
68
+ const settings = /^\/settings(?:\/([a-z-]+))?$/.exec(path);
69
+ if (settings !== null) {
70
+ const page = settings[1];
71
+ if (page === undefined)
72
+ return "Settings";
73
+ return `Settings → ${page.split("-").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" ")}`;
74
+ }
75
+ return parsed.hostname.replace(/^www\./, "");
76
+ }
77
+ /** Trailing sentence punctuation (and an unmatched closing bracket) belongs to the prose, not the URL. */
78
+ function splitUrl(raw) {
79
+ const match = /[.,;:!?'"]+$/.exec(raw);
80
+ return match === null ? { url: raw, rest: "" } : { url: raw.slice(0, match.index), rest: match[0] };
81
+ }
82
+ /** One sentence-cased "the run" for an id-bearing mention of it. */
83
+ function theRun(match) {
84
+ return /^[A-Z]/.test(match) ? "The run" : "the run";
85
+ }
86
+ const RUN_ID = /\b(run|turn)\s*#\s*(\d+)\b/gi;
87
+ const PROJECT_ID = /(?:^|[\s(])(r\d{1,3})\b(?![-\w/]|\.\w)/g;
88
+ const NAMED_DIGEST = /\b(?:digest|sha(?:256)?|hash|commit|revision|build)\b[ \t:=]*\b((?=[0-9a-f]*\d)[0-9a-f]{7,64})\b/gi;
89
+ const BARE_DIGEST = /\b((?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{16,64})\b/g;
90
+ /** The kinds of id a reply names more than one of: those stay, so two runs (or projects, or digests) never read the same. */
91
+ function severalIds(text) {
92
+ const distinct = (pattern, key) => new Set([...text.matchAll(pattern)].map(key)).size;
93
+ const several = new Set();
94
+ if (distinct(RUN_ID, one => `${one[1].toLowerCase()}${one[2]}`) > 1)
95
+ several.add("run");
96
+ if (distinct(PROJECT_ID, one => one[1].toLowerCase()) > 1)
97
+ several.add("project");
98
+ const digests = new Set([...text.matchAll(NAMED_DIGEST), ...text.matchAll(BARE_DIGEST)].map(one => one[1].toLowerCase()));
99
+ if (digests.size > 1)
100
+ several.add("digest");
101
+ return several;
102
+ }
103
+ function dropIds(text, keep) {
104
+ let body = text;
105
+ // "(run #12)", "(r1)", "(digest 4f2a…)" — an aside that only carries the id goes entirely.
106
+ if (!keep.has("run"))
107
+ body = body
108
+ .replace(/[ \t]*\((?:run|turn)\s*#\s*\d+\)/gi, "")
109
+ // "the run #12", "run #12" → "the run".
110
+ .replace(/\b(?:the\s+)?run\s*#\s*\d+\b/gi, theRun);
111
+ if (!keep.has("digest"))
112
+ body = body
113
+ .replace(/[ \t]*\((?:(?:digest|sha(?:256)?|hash)?[\s:=]*[0-9a-f]{12,64})\)/gi, "")
114
+ // "digest 4f2a…", "commit 4f2a…" — the thing stays named, its value goes: "the digest".
115
+ .replace(/\b(?:the\s+)?(digest|sha(?:256)?|hash|commit|revision|build)\b[ \t:=]*\b(?=[0-9a-f]*\d)[0-9a-f]{7,64}\b/gi, (match, noun) => `${/^[A-Z]/.test(match) ? "The" : "the"} ${noun.toLowerCase()}`)
116
+ // A bare long hex value with digits and letters is a digest.
117
+ .replace(/[ \t]*\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{16,64}\b/g, "");
118
+ if (!keep.has("project"))
119
+ body = body
120
+ .replace(/[ \t]*\(r\d{1,3}\)/gi, "")
121
+ // "project r1", "r1" → "the project".
122
+ .replace(/\b(?:the\s+)?project\s+r\d{1,3}\b(?![-\w/]|\.\w)/gi, match => /^[A-Z]/.test(match) ? "The project" : "the project")
123
+ .replace(/(^|[\s(])r\d{1,3}\b(?![-\w/]|\.\w)/g, (_, before) => `${before}the project`);
124
+ // Tidy what a removal left behind.
125
+ return body
126
+ .replace(/[ \t]+([,.;:!?)])/g, "$1")
127
+ .replace(/[ \t]{2,}/g, " ");
128
+ }
129
+ function limitBold(text, state) {
130
+ const anchor = (inner) => {
131
+ const words = inner.trim();
132
+ if (words !== "" && words.length <= BOLD_ANCHOR_CHARS && state.boldLeft > 0) {
133
+ state.boldLeft--;
134
+ return `**${words}**`;
135
+ }
136
+ return inner;
137
+ };
138
+ return text
139
+ .replace(/\*\*([^*\n]+?)\*\*/g, (_, inner) => anchor(inner))
140
+ // Single-asterisk emphasis reads as bold in some apps: plain here.
141
+ .replace(/(^|[^*\w])\*(?![\s*])([^*\n]*?[^\s*])\*(?![*\w])/g, "$1$2");
142
+ }
143
+ /** Code and links held aside so no rule rewrites inside them; a bare URL is labelled as it is held. */
144
+ function holdAside(text, options) {
145
+ const held = [];
146
+ const body = text.replace(/\r\n?/g, "\n").replace(PROTECTED, (match) => {
147
+ if (match.startsWith("`")) {
148
+ held.push(match);
149
+ return HOLD(held.length - 1);
150
+ }
151
+ if (match.startsWith("[")) {
152
+ LINK.lastIndex = 0;
153
+ const link = LINK.exec(match);
154
+ if (link === null) {
155
+ held.push(match);
156
+ return HOLD(held.length - 1);
157
+ }
158
+ const { url, rest } = splitUrl(link[2]);
159
+ // The model's own words may name only our own pages; a foreign link always shows its real host.
160
+ const label = /^https?:\/\//.test(link[1].trim()) || !ownLink(url, options.appOrigin) ? linkLabel(url, options.appOrigin) : link[1].trim();
161
+ held.push(`[${label}](${url})`);
162
+ return HOLD(held.length - 1) + rest;
163
+ }
164
+ const { url, rest } = splitUrl(match.startsWith("<") ? match.slice(1, -1) : match);
165
+ held.push(`[${linkLabel(url, options.appOrigin)}](${url})`);
166
+ return HOLD(held.length - 1) + rest;
167
+ });
168
+ return { body, held };
169
+ }
170
+ function stateFor(text, options) {
171
+ return { boldLeft: BOLD_ANCHORS, keepIds: askedForIds(options.asked) ? "all" : severalIds(holdAside(text, options).body) };
172
+ }
173
+ function shapeWith(text, options, state) {
174
+ const aside = holdAside(text, options);
175
+ const held = aside.held;
176
+ let body = aside.body;
177
+ // Headers become plain lines: ATX ("## Next steps") and setext underlines.
178
+ body = body
179
+ .replace(/^[ \t]{0,3}#{1,6}[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$/gm, "$1")
180
+ .replace(/^[ \t]{0,3}#{1,6}[ \t]*$/gm, "")
181
+ .replace(/^([^\n]*\S[^\n]*)\n[ \t]{0,3}=+[ \t]*$/gm, "$1")
182
+ // A "* " bullet reads the same as "- ", and never as emphasis.
183
+ .replace(/^([ \t]*)\*[ \t]+/gm, "$1- ");
184
+ body = limitBold(body, state);
185
+ if (state.keepIds !== "all")
186
+ body = dropIds(body, state.keepIds);
187
+ body = body
188
+ .split("\n").map(line => line.replace(/[ \t]+$/, "")).join("\n")
189
+ .replace(/\n{3,}/g, "\n\n")
190
+ .trim();
191
+ return body.replace(/\u0000(\d+)\u0000/g, (_, index) => held[Number(index)] ?? "");
192
+ }
193
+ /**
194
+ * The canonical shaped reply: plain lines, at most three bold anchors, labelled links, no internal ids unless asked
195
+ * (or unless several of a kind would otherwise read the same), single blank lines. Idempotent: shaping a shaped reply
196
+ * changes nothing.
197
+ */
198
+ export function shapeReply(text, options = {}) {
199
+ return shapeWith(text, options, stateFor(text, options));
200
+ }
201
+ /** Where a long reply may be cut: never inside code, a link, a URL or a bold anchor; best at a paragraph, then a line,
202
+ * then a space. Always past `from`, and never between the two halves of an emoji. */
203
+ function cutAt(text, from, size) {
204
+ const limit = from + Math.max(1, size);
205
+ if (limit >= text.length)
206
+ return text.length;
207
+ const flags = PROTECTED.flags.includes("g") ? PROTECTED.flags : `${PROTECTED.flags}g`;
208
+ const tokens = [...text.matchAll(new RegExp(String.raw `${PROTECTED.source}|\*\*[^*\n]+?\*\*`, flags))]
209
+ .map(one => ({ start: one.index ?? 0, end: (one.index ?? 0) + one[0].length, url: !one[0].startsWith("`") && !one[0].startsWith("**") }));
210
+ const inside = (at) => tokens.some(token => token.start < at && at < token.end);
211
+ for (const gap of [/\n\n/g, /\n/g, /[ \t]/g]) {
212
+ let best = -1;
213
+ for (const one of text.slice(from, limit).matchAll(gap)) {
214
+ const at = from + (one.index ?? 0) + one[0].length;
215
+ if (at > from && at <= limit && !inside(at))
216
+ best = at;
217
+ }
218
+ if (best > from)
219
+ return best;
220
+ }
221
+ // No gap: cut before the token that crosses the limit. A link or URL longer than a whole part stays whole (cut, it
222
+ // would no longer go where it says); only code or plain text longer than a part is cut at the limit itself.
223
+ const crossing = tokens.find(token => token.start < limit && limit < token.end);
224
+ if (crossing !== undefined && crossing.start > from)
225
+ return crossing.start;
226
+ if (crossing !== undefined && crossing.url)
227
+ return crossing.end;
228
+ let end = limit;
229
+ if (/[\uD800-\uDBFF]/.test(text[end - 1]))
230
+ end = end - 1 > from ? end - 1 : end + 1;
231
+ return end;
232
+ }
233
+ /**
234
+ * A long reply split into parts of at most `size` characters, then each part shaped: the cut is made on the reply as
235
+ * written, at a paragraph or line where possible and never inside code, a link or a bold anchor, so no part carries
236
+ * half of one. The parts share one bold budget and one rule for ids, as if the reply were shaped whole. `measure` is
237
+ * how long a shaped part is once the channel renders it (its own link and escape format): a part that renders past
238
+ * `size` is cut again, smaller. Only a single link longer than a part can exceed it, and it is never cut.
239
+ */
240
+ export function shapeReplyParts(text, size, options = {}, measure = shaped => shaped.length) {
241
+ const state = stateFor(text, options);
242
+ const normal = text.replace(/\r\n?/g, "\n");
243
+ const pieces = (part, at) => {
244
+ const out = [];
245
+ for (let from = 0; from < part.length;) {
246
+ const end = cutAt(part, from, at);
247
+ out.push(part.slice(from, end));
248
+ from = end;
249
+ }
250
+ return out;
251
+ };
252
+ const raw = pieces(normal, size);
253
+ const parts = [];
254
+ while (raw.length > 0) {
255
+ const part = raw.shift();
256
+ const before = state.boldLeft;
257
+ const shaped = shapeWith(part, options, state);
258
+ const length = measure(shaped);
259
+ // A labelled link or the channel's escapes can lengthen a part: cut that part again, smaller, and shape the pieces.
260
+ if (length > size && part.length > 1) {
261
+ const smaller = pieces(part, Math.max(1, Math.floor(part.length * size / length) - 1));
262
+ if (smaller.length > 1) {
263
+ state.boldLeft = before;
264
+ raw.unshift(...smaller);
265
+ continue;
266
+ }
267
+ }
268
+ if (shaped !== "")
269
+ parts.push(shaped);
270
+ }
271
+ return parts;
272
+ }
273
+ /** A shaped reply as pieces: plain text, bold anchors, inline code and labelled links. Fenced code stays text. */
274
+ export function replyPieces(shaped) {
275
+ const pieces = [];
276
+ const pattern = new RegExp(String.raw `(\x60\x60\x60[\s\S]*?(?:\x60\x60\x60|$))|\x60([^\x60\n]+)\x60|\*\*([^*\n]+?)\*\*|\[([^\]\n]{1,120})\]\((${URL_CHARS})\)`, "g");
277
+ let at = 0;
278
+ for (const match of shaped.matchAll(pattern)) {
279
+ const start = match.index ?? 0;
280
+ if (start > at)
281
+ pieces.push({ kind: "text", text: shaped.slice(at, start) });
282
+ if (match[1] !== undefined)
283
+ pieces.push({ kind: "text", text: match[1] });
284
+ else if (match[2] !== undefined)
285
+ pieces.push({ kind: "code", text: match[2] });
286
+ else if (match[3] !== undefined)
287
+ pieces.push({ kind: "bold", text: match[3] });
288
+ else
289
+ pieces.push({ kind: "link", label: match[4], url: match[5] });
290
+ at = start + match[0].length;
291
+ }
292
+ if (at < shaped.length)
293
+ pieces.push({ kind: "text", text: shaped.slice(at) });
294
+ return pieces;
295
+ }
296
+ const html = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
297
+ const slack = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
298
+ /** Discord's presentation syntax, escaped; mentions are broken so nothing pings. */
299
+ export const discordPlain = (text) => text.replace(/([\\`*_~|>#\[\]])/g, "\\$1").replace(/@/g, "@\u200b");
300
+ const teams = (value) => value.replace(/([\\`*_\[\]<>#])/g, "\\$1");
301
+ /** One line of a shaped reply as console HTML (the console lays out paragraphs and lists itself). */
302
+ export function replyHtmlInline(shapedLine) {
303
+ return replyPieces(shapedLine).map(piece => piece.kind === "text" ? html(piece.text)
304
+ : piece.kind === "bold" ? `<strong>${html(piece.text)}</strong>`
305
+ : piece.kind === "code" ? `<code>${html(piece.text)}</code>`
306
+ : `<a href="${html(piece.url)}" rel="noopener noreferrer" target="_blank">${html(piece.label)}</a>`).join("");
307
+ }
308
+ /** A shaped reply in a channel's own text format (Telegram's formatting rides entities: see `telegramReply`). */
309
+ export function renderReply(shaped, channel) {
310
+ const pieces = replyPieces(shaped);
311
+ switch (channel) {
312
+ case "console":
313
+ return pieces.map(piece => piece.kind === "link" ? `[${piece.label}](${piece.url})` : piece.kind === "bold" ? `**${piece.text}**` : piece.kind === "code" ? `\`${piece.text}\`` : piece.text).join("");
314
+ case "slack":
315
+ return pieces.map(piece => piece.kind === "text" ? slack(piece.text)
316
+ : piece.kind === "bold" ? `*${slack(piece.text)}*`
317
+ : piece.kind === "code" ? `\`${slack(piece.text)}\``
318
+ : `<${piece.url}|${slack(piece.label).replaceAll("|", "/")}>`).join("");
319
+ case "discord":
320
+ return pieces.map(piece => piece.kind === "text" ? discordPlain(piece.text)
321
+ : piece.kind === "bold" ? `**${discordPlain(piece.text)}**`
322
+ : piece.kind === "code" ? `\`${piece.text}\``
323
+ : `[${discordPlain(piece.label)}](${piece.url})`).join("");
324
+ case "teams":
325
+ // Teams' markdown keeps a single newline only as a paragraph break.
326
+ return pieces.map(piece => piece.kind === "text" ? teams(piece.text).replace(/\n+/g, "\n\n")
327
+ : piece.kind === "bold" ? `**${teams(piece.text)}**`
328
+ : piece.kind === "code" ? `\`${piece.text}\``
329
+ : `[${teams(piece.label)}](${piece.url})`).join("");
330
+ case "telegram":
331
+ case "terminal":
332
+ return pieces.map(piece => piece.kind === "link" ? `${piece.label} (${piece.url})` : piece.kind === "code" ? `\`${piece.text}\`` : piece.text).join("");
333
+ }
334
+ }
335
+ /** A shaped reply for Telegram: plain text plus entities (bold anchors, code, labelled links). Offsets are UTF-16, as JS strings are. */
336
+ export function telegramReply(shaped) {
337
+ let text = "";
338
+ const entities = [];
339
+ for (const piece of replyPieces(shaped)) {
340
+ const offset = text.length;
341
+ if (piece.kind === "text") {
342
+ text += piece.text;
343
+ continue;
344
+ }
345
+ const shown = piece.kind === "link" ? piece.label : piece.text;
346
+ text += shown;
347
+ entities.push(piece.kind === "link" ? { type: "text_link", offset, length: shown.length, url: piece.url } : { type: piece.kind, offset, length: shown.length });
348
+ }
349
+ return { text, entities };
350
+ }
351
+ /** Shape and render in one step, for a surface that sends the reply as text. */
352
+ export function voiceReply(text, channel, options = {}) {
353
+ return renderReply(shapeReply(text, options), channel);
354
+ }
355
+ // ---- deliverables: a reply never claims an attachment the turn did not attach ----------------------------------
356
+ const DETERMINER = String.raw `(?:(?:the|a|an|your|my|this|that|these|those|both|two|three|\d+|new|latest|full|updated|requested|fresh|final)\s+){0,2}`;
357
+ /** Things that can only arrive attached: a reply's text can never be them. */
358
+ const ATTACHMENT_ONLY = String.raw `(?:screenshots?|screen\s?shots?|images?|pictures?|photos?|recordings?|videos?|pdfs?|zips?|attachments?|spreadsheets?|csvs?)`;
359
+ /** Things a reply can also list in its text (file names, log lines, a report's points). */
360
+ const LISTABLE = String.raw `(?:files?|logs?|reports?|diffs?|documents?|links?)`;
361
+ const NOUN = String.raw `(?:${ATTACHMENT_ONLY}|${LISTABLE})`;
362
+ /** Presented ("here's the …") these need an attachment or a link: a reply's text can never be one. */
363
+ const PRESENTED = String.raw `(?:${ATTACHMENT_ONLY}|links?|files?)`;
364
+ const SEND = String.raw `(?:\bi(?:'ve|’ve|\s+have)\s+(?:just\s+)?(?:attached|included|shared|uploaded|sent|enclosed)|\bi(?:'m|’m|\s+am)\s+(?:now\s+)?(?:attaching|sending|sharing|uploading|enclosing)|\bi(?:'ll|’ll|\s+will)\s+(?:now\s+)?(?:attach|send|share|upload|enclose)|^\s*(?:attaching|sending|sharing|uploading|enclosing))\s+(?:you\s+)?`;
365
+ /** A claim is an attachment noun plus a send verb, in one sentence. */
366
+ const CLAIMS = [
367
+ // "I've attached the report", "I'm sending you the log", "I'll share the link", "Sending the file now".
368
+ new RegExp(String.raw `${SEND}${DETERMINER}(${NOUN})\b`, "i"),
369
+ // "The screenshot is attached", "Logs attached", "the report is enclosed".
370
+ new RegExp(String.raw `\b(${NOUN})\s+(?:(?:is|are|was|were)\s+)?(?:attached|enclosed|uploaded|included\s+below)\b`, "i"),
371
+ // "Here's the screenshot (of the payout page).", "Here's the link to the result.", "Below is the log file." — presenting
372
+ // something only a link or attachment can carry, with nothing listed after it. A bare "Here's the report." can be the
373
+ // reply's own text, so it is not one.
374
+ new RegExp(String.raw `^\s*(?:here(?:'s|’s|\s+is|\s+are)|(?:attached|below)\s+(?:is|are))\s+${DETERMINER}(?:[\w-]+\s+)?(${PRESENTED})(?:\s+(?:of|for|to|from|showing)\s+[^:]+?)?[.!:]?\s*$`, "i"),
375
+ ];
376
+ const SENTENCE = /(?<=[.!?])\s+/;
377
+ const LIST_LINE = /^\s*(?:[-*•]|\d{1,3}[.)])\s+\S|^\s*```/;
378
+ /** One sentence's claim, if it makes one: the words, and whether its noun is something the text could list. */
379
+ function claimOf(sentence) {
380
+ for (const claim of CLAIMS) {
381
+ const found = claim.exec(sentence);
382
+ if (found === null)
383
+ continue;
384
+ const end = (found.index ?? 0) + found[0].length;
385
+ return { words: found[0].trim(), listable: new RegExp(String.raw `^${LISTABLE}$`, "i").test(found[1]), tail: sentence.slice(end) };
386
+ }
387
+ return null;
388
+ }
389
+ /** The content a claim sentence lists itself: text after its colon ("…the files I changed: a.ts, b.ts"). */
390
+ function listedAfterColon(tail) {
391
+ const colon = /:\s*(\S[\s\S]*)$/.exec(tail);
392
+ return colon === null ? null : colon[1];
393
+ }
394
+ /** Every claim in a reply, with whether the reply lists what it names (after a colon, or as the list that follows). */
395
+ function claimsIn(text) {
396
+ const lines = text.split("\n");
397
+ const out = [];
398
+ lines.forEach((line, lineIndex) => {
399
+ const next = lines.slice(lineIndex + 1).find(one => one.trim() !== "");
400
+ line.split(SENTENCE).forEach((sentence, sentenceIndex, sentences) => {
401
+ const claim = claimOf(sentence);
402
+ if (claim === null)
403
+ return;
404
+ const last = sentenceIndex === sentences.length - 1;
405
+ const listed = claim.listable && (listedAfterColon(claim.tail) !== null || (last && /:\s*$/.test(sentence) && next !== undefined && LIST_LINE.test(next)));
406
+ out.push({ line: lineIndex, sentence: sentenceIndex, words: claim.words, listed, tail: claim.tail });
407
+ });
408
+ });
409
+ return out;
410
+ }
411
+ /**
412
+ * The words in a reply that say something is being sent or attached ("I've attached the log", "here's the
413
+ * screenshot"), or null. A claim needs an attachment noun and a send verb; one whose content the reply lists itself
414
+ * ("I've included the files I changed: a.ts") is not a claim.
415
+ */
416
+ export function deliverableClaim(text) {
417
+ return claimsIn(text).find(one => !one.listed)?.words ?? null;
418
+ }
419
+ /** Does the reply carry the thing itself: a link, or content quoted in a code block? */
420
+ export function replyCarriesDeliverable(text) {
421
+ return new RegExp(URL_CHARS).test(text) || /```[\s\S]+?```/.test(text);
422
+ }
423
+ export const NOTHING_ATTACHED = "I couldn't attach that to this reply.";
424
+ /** The reply without its claims of an attachment, said plainly once; whatever a claim listed stays; never the claim alone. */
425
+ export function dropDeliverableClaims(text) {
426
+ const claims = claimsIn(text).filter(one => !one.listed);
427
+ let said = false;
428
+ const lines = text.split("\n").map((line, lineIndex) => line.split(SENTENCE).map((sentence, sentenceIndex) => {
429
+ const claim = claims.find(one => one.line === lineIndex && one.sentence === sentenceIndex);
430
+ if (claim === undefined)
431
+ return sentence;
432
+ // Listed content is never dropped: it follows the plain words instead of the claim.
433
+ const listed = listedAfterColon(claim.tail);
434
+ if (listed !== null) {
435
+ said = true;
436
+ return `${NOTHING_ATTACHED.slice(0, -1)}: ${listed}`;
437
+ }
438
+ if (/:\s*$/.test(sentence)) {
439
+ said = true;
440
+ return `${NOTHING_ATTACHED.slice(0, -1)}:`;
441
+ }
442
+ if (said)
443
+ return "";
444
+ said = true;
445
+ return NOTHING_ATTACHED;
446
+ }).filter(sentence => sentence !== "").join(" "));
447
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() || NOTHING_ATTACHED;
448
+ }
449
+ /** The one repair step's words to the lead: attach it, or drop the claim. */
450
+ export function deliverableRepair(claim) {
451
+ return `Check before sending: your reply says "${claim.slice(0, 80)}", but nothing is attached or linked this turn. Attach it now with the right tool (get_result_images for screenshots, show_control for a page), or rewrite the reply without saying it is attached. Reply with the corrected message.`;
452
+ }
@@ -21,8 +21,22 @@
21
21
  * Accepting finishes the task in one request (Accept and finish): the person's
22
22
  * acceptance, when one is owed, and the completion together. This only decides
23
23
  * which act is ink; the page recomputes it as the person answers each check. */
24
+ import type { ProofVerdict } from "./proof.js";
24
25
  /** The words over the reason field an Accept of a refuted result requires. */
25
26
  export declare const ACCEPT_NEEDS_REASON = "Accepting needs a reason";
27
+ /** What kind of evidence problem a verdict records: a check the machine
28
+ * actually ran and saw fail, evidence that contradicts the sealed record
29
+ * (a mismatched changed-path claim, an altered criterion, a caveat that
30
+ * contradicts a met verdict), or evidence that is simply absent. A generic
31
+ * refuted verdict is NOT a failed test claim — only the verify-command
32
+ * reasons say a check failed. */
33
+ export type EvidenceProblem = "checks-failed" | "mismatched" | "missing" | "none";
34
+ export declare const FAILED_CHECK: RegExp;
35
+ export declare function evidenceProblemOf(verdict: ProofVerdict | null, reasons: readonly string[]): EvidenceProblem;
36
+ /** Why a result can't be accepted as it stands, in one plain line, or null
37
+ * when nothing refutes it (a person's acceptance settles it). The result page
38
+ * shows it before Request changes, its one ink act. */
39
+ export declare function cantAcceptYetOf(verdict: ProofVerdict | null, reasons: readonly string[], accepted: boolean): string | null;
26
40
  export type ResultActKind = "retry" | "accept" | "accept-anyway" | "next-check" | "checks-running" | "run-checks" | "request-changes" | "rebuild" | "confirm-stopped" | "revise" | "draft-repair";
27
41
  export type ResultActFacts = {
28
42
  /** An Accept this page can post (Accept and finish, or the person's acceptance alone); `ready`: every requirement met and checks passed. */
@@ -23,6 +23,25 @@
23
23
  * which act is ink; the page recomputes it as the person answers each check. */
24
24
  /** The words over the reason field an Accept of a refuted result requires. */
25
25
  export const ACCEPT_NEEDS_REASON = "Accepting needs a reason";
26
+ export const FAILED_CHECK = /^the repository's approved verification command exited (-?[0-9]+)/;
27
+ export function evidenceProblemOf(verdict, reasons) {
28
+ if (verdict === "verified" || verdict === "attested")
29
+ return "none";
30
+ if (verdict === "refuted")
31
+ return reasons.some(reason => FAILED_CHECK.test(reason)) ? "checks-failed" : "mismatched";
32
+ return "missing";
33
+ }
34
+ /** Why a result can't be accepted as it stands, in one plain line, or null
35
+ * when nothing refutes it (a person's acceptance settles it). The result page
36
+ * shows it before Request changes, its one ink act. */
37
+ export function cantAcceptYetOf(verdict, reasons, accepted) {
38
+ if (accepted || verdict !== "refuted")
39
+ return null;
40
+ // A mismatch is the status card's own headline; here only what accepting takes.
41
+ return evidenceProblemOf(verdict, reasons) === "checks-failed"
42
+ ? "Can't accept yet: the project's check failed on these changes."
43
+ : ACCEPT_NEEDS_REASON;
44
+ }
26
45
  export function resultActsOf(facts) {
27
46
  const request = facts.canRequest ? "request-changes" : null;
28
47
  const accept = facts.accept === null ? null : "accept";
package/dist/retention.js CHANGED
@@ -263,11 +263,17 @@ export function sweepRetention(store, evidenceRoot, now, actor = "worker") {
263
263
  if (periods.chat !== null) {
264
264
  const team = db.prepare("DELETE FROM team_message WHERE message = ? AND status NOT IN ('queued', 'running', 'uncertain')");
265
265
  const message = db.prepare("DELETE FROM mate_message WHERE id = ? AND NOT EXISTS (SELECT 1 FROM team_message WHERE message = ?)");
266
+ const asked = db.prepare("SELECT turn FROM mate_message WHERE id = ? AND role = 'assistant' AND turn IS NOT NULL");
267
+ // The lead's question goes with the reply that asked it; its buttons then say it expired.
268
+ const ask = db.prepare("DELETE FROM mate_ask WHERE turn = ? AND NOT EXISTS (SELECT 1 FROM mate_message WHERE turn = ? AND role = 'assistant')");
266
269
  for (const one of plan.items.chat) {
267
270
  team.run(one.id);
271
+ const turn = asked.get(one.id)?.["turn"];
268
272
  if (Number(message.run(one.id, one.id).changes) === 1) {
269
273
  done.chat.count++;
270
274
  done.chat.bytes += one.bytes;
275
+ if (turn !== undefined && turn !== null)
276
+ ask.run(Number(turn), Number(turn));
271
277
  }
272
278
  }
273
279
  }