toolroll 0.9.16 → 0.9.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/assignment-brief.js +11 -4
- package/dist/browser/workspace.js +14 -14
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/chat-actions.d.ts +7 -0
- package/dist/chat-actions.js +40 -4
- package/dist/chat-ask.js +29 -15
- package/dist/chat-channel.js +3 -1
- package/dist/chat-decide.d.ts +203 -0
- package/dist/chat-decide.js +554 -0
- package/dist/chat-delivery-state.d.ts +7 -0
- package/dist/chat-delivery.d.ts +3 -0
- package/dist/chat-delivery.js +63 -17
- package/dist/chat-quiet.d.ts +4 -1
- package/dist/chat-quiet.js +6 -2
- package/dist/chat-warmth.d.ts +21 -0
- package/dist/chat-warmth.js +36 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-api.d.ts +3 -1
- package/dist/discord-api.js +4 -2
- package/dist/discord-chat.d.ts +3 -2
- package/dist/discord-chat.js +35 -6
- package/dist/lead-about.d.ts +35 -0
- package/dist/lead-about.js +62 -0
- package/dist/lead-capabilities.d.ts +64 -0
- package/dist/lead-capabilities.js +199 -0
- package/dist/lead-commitments.js +13 -7
- package/dist/lead-context.d.ts +5 -3
- package/dist/lead-context.js +29 -7
- package/dist/lead-follow.d.ts +1 -1
- package/dist/lead-follow.js +3 -2
- package/dist/lead-identity.js +1 -1
- package/dist/lead-people.d.ts +55 -0
- package/dist/lead-people.js +128 -0
- package/dist/lead-voice.d.ts +3 -2
- package/dist/lead-voice.js +4 -3
- package/dist/mate-cli.d.ts +3 -0
- package/dist/mate-cli.js +15 -14
- package/dist/mate-contract.d.ts +1 -1
- package/dist/mate-contract.js +6 -4
- package/dist/mate-progress.js +2 -0
- package/dist/mate-tools.d.ts +8 -0
- package/dist/mate-tools.js +112 -6
- package/dist/mate.d.ts +26 -1
- package/dist/mate.js +89 -25
- package/dist/modes.d.ts +6 -0
- package/dist/modes.js +6 -0
- package/dist/operate.js +20 -8
- package/dist/phase-routing.d.ts +1 -1
- package/dist/project-delete.js +3 -0
- package/dist/provider-auth.d.ts +4 -0
- package/dist/provider-auth.js +1 -1
- package/dist/reply-shape.d.ts +96 -0
- package/dist/reply-shape.js +452 -0
- package/dist/result-shots.d.ts +51 -0
- package/dist/result-shots.js +119 -0
- package/dist/serve.js +76 -49
- package/dist/slack-chat.d.ts +2 -0
- package/dist/slack-chat.js +38 -7
- package/dist/store.d.ts +36 -3
- package/dist/store.js +163 -16
- package/dist/team-runtime.js +1 -1
- package/dist/teams-chat.js +17 -1
- package/dist/telegram-mate.d.ts +12 -0
- package/dist/telegram-mate.js +27 -11
- package/dist/telegram-team.js +6 -3
- package/dist/telegram.d.ts +5 -3
- package/dist/telegram.js +227 -21
- package/dist/test-temp.js +1 -1
- 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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
297
|
+
const slack = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type Notification, type ResultScreenshots, type Store } from "./store.js";
|
|
2
|
+
import type { ChatContent, ChatState } from "./chat-delivery-state.js";
|
|
3
|
+
/** "Up to 4": the most screenshots one result ever sends. */
|
|
4
|
+
export declare const RESULT_SHOTS_MAX = 4;
|
|
5
|
+
/** The choices in the order Settings and the CLI show them. */
|
|
6
|
+
export declare const RESULT_SHOT_CHOICES: readonly (readonly [ResultScreenshots, string])[];
|
|
7
|
+
export declare function resultShotLimit(choice: ResultScreenshots): number;
|
|
8
|
+
export type ResultShot = {
|
|
9
|
+
artifact: number;
|
|
10
|
+
sha256: string;
|
|
11
|
+
bytes: Buffer;
|
|
12
|
+
format: "png" | "jpeg";
|
|
13
|
+
fileName: string;
|
|
14
|
+
/** Plain words: the first screenshot's carry the task ("Is the site current? · the home page on a phone"). */
|
|
15
|
+
caption: string;
|
|
16
|
+
/** Within Telegram's photo limits, so it shows an inline preview. */
|
|
17
|
+
photo: boolean;
|
|
18
|
+
};
|
|
19
|
+
export type ResultShotsPlan =
|
|
20
|
+
/** Nothing to send and nothing to say: off, pruned by retention, none saved, or all already sent. */
|
|
21
|
+
{
|
|
22
|
+
kind: "none";
|
|
23
|
+
why: "off" | "pruned" | "none" | "sent";
|
|
24
|
+
}
|
|
25
|
+
/** One plain line instead of any screenshot. */
|
|
26
|
+
| {
|
|
27
|
+
kind: "line";
|
|
28
|
+
text: string;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "send";
|
|
31
|
+
taskId: string;
|
|
32
|
+
run: number;
|
|
33
|
+
shots: ResultShot[];
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* What one person's screenshots row may send now, to a chat that reaches
|
|
37
|
+
* `repos`. `sent`: screenshots this destination already has, never sent again.
|
|
38
|
+
* Every byte returned was verified this instant; a channel uploads them with
|
|
39
|
+
* no wait in between, or asks again.
|
|
40
|
+
*/
|
|
41
|
+
export declare function resultShotsFor(store: Store, evidenceRoot: string | undefined, repos: readonly string[], row: Pick<Notification, "kind" | "recipient" | "taskId" | "taskRef" | "run">, sent?: ReadonlySet<number>): ResultShotsPlan;
|
|
42
|
+
/** Retention removed this result's files: its screenshots go quietly, never as a complaint. */
|
|
43
|
+
export declare function resultShotsPruned(evidenceRoot: string, run: number): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* The chat app refused the upload (no file permission there): this part becomes one plain line and the result's
|
|
46
|
+
* other screenshots in the same message are dropped, so the person reads one line, not one per screenshot.
|
|
47
|
+
*/
|
|
48
|
+
export declare function refuseResultShots(state: Pick<ChatState, "prepare">, store: Store, part: {
|
|
49
|
+
id: number;
|
|
50
|
+
event: string;
|
|
51
|
+
}, content: ChatContent, app: string, now: Date): void;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshots with results: a person can choose to get a result's saved
|
|
3
|
+
* screenshots in their chat app right after its "ready" (or failed) message —
|
|
4
|
+
* Off (the default), the first one, or up to four.
|
|
5
|
+
*
|
|
6
|
+
* The store records one row per result and person (`RESULT_SHOTS_KIND`) when
|
|
7
|
+
* the result message is made; every transport asks here, at send time, what
|
|
8
|
+
* that row may carry. The checks are the acceptance-evidence sender's: the
|
|
9
|
+
* task is in the projects this chat may reach now, the result is still the
|
|
10
|
+
* task's current one, and each file verifies against its recorded sha256
|
|
11
|
+
* under the evidence root. A failure is one plain line and nothing else; a
|
|
12
|
+
* result whose files retention already removed sends nothing and says
|
|
13
|
+
* nothing. The choice is read again here, so turning it off stops what has
|
|
14
|
+
* not gone yet, and never more than it allows is sent.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { imageDimensions, readVerifiedProofForRun, RETENTION_NOTE } from "./evidence.js";
|
|
19
|
+
import { resultImageFileName, verifyResultImage } from "./chat-evidence.js";
|
|
20
|
+
import { publicChatText } from "./chat-display.js";
|
|
21
|
+
import { chatTitle } from "./chat-voice.js";
|
|
22
|
+
import { RESULT_SHOTS_KIND } from "./store.js";
|
|
23
|
+
/** "Up to 4": the most screenshots one result ever sends. */
|
|
24
|
+
export const RESULT_SHOTS_MAX = 4;
|
|
25
|
+
/** The choices in the order Settings and the CLI show them. */
|
|
26
|
+
export const RESULT_SHOT_CHOICES = [["off", "Off"], ["first", "First one"], ["all", "Up to 4"]];
|
|
27
|
+
export function resultShotLimit(choice) {
|
|
28
|
+
return choice === "first" ? 1 : choice === "all" ? RESULT_SHOTS_MAX : 0;
|
|
29
|
+
}
|
|
30
|
+
/** Telegram's own photo limits (Bot API sendPhoto, checked 2026-10-03): over them, a screenshot goes as a document. */
|
|
31
|
+
const PHOTO_MAX_BYTES = 10 * 1024 * 1024;
|
|
32
|
+
const PHOTO_MAX_SIDES = 10_000;
|
|
33
|
+
const PHOTO_MAX_RATIO = 20;
|
|
34
|
+
const SCREENSHOT_CAPTURE = /^agent-claimed screenshot at (.+) \(validated (?:png|jpeg)\)/;
|
|
35
|
+
/** A screenshot's own caption from the result's proof, in plain words; null when it has none fit to show. */
|
|
36
|
+
function ownCaptions(store, root, run) {
|
|
37
|
+
const proof = readVerifiedProofForRun(store, root, run);
|
|
38
|
+
const captions = new Map();
|
|
39
|
+
if (proof === null || !proof.ok)
|
|
40
|
+
return captions;
|
|
41
|
+
for (const shot of proof.proof.screenshots) {
|
|
42
|
+
const words = publicChatText(shot.caption, 200).trim();
|
|
43
|
+
if (words !== "" && !words.includes("[sensitive text hidden]"))
|
|
44
|
+
captions.set(shot.path, words);
|
|
45
|
+
}
|
|
46
|
+
return captions;
|
|
47
|
+
}
|
|
48
|
+
function isPhoto(bytes, format) {
|
|
49
|
+
const size = imageDimensions(bytes, format);
|
|
50
|
+
if (size === null || size.width === 0 || size.height === 0)
|
|
51
|
+
return false;
|
|
52
|
+
return bytes.length <= PHOTO_MAX_BYTES && size.width + size.height <= PHOTO_MAX_SIDES
|
|
53
|
+
&& Math.max(size.width, size.height) / Math.min(size.width, size.height) <= PHOTO_MAX_RATIO;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* What one person's screenshots row may send now, to a chat that reaches
|
|
57
|
+
* `repos`. `sent`: screenshots this destination already has, never sent again.
|
|
58
|
+
* Every byte returned was verified this instant; a channel uploads them with
|
|
59
|
+
* no wait in between, or asks again.
|
|
60
|
+
*/
|
|
61
|
+
export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()) {
|
|
62
|
+
if (row.kind !== RESULT_SHOTS_KIND || row.recipient === null || row.taskId === null || row.taskRef === null || row.run === null)
|
|
63
|
+
return { kind: "none", why: "none" };
|
|
64
|
+
const limit = resultShotLimit(store.notificationPreference(row.recipient).screenshots);
|
|
65
|
+
if (limit === 0)
|
|
66
|
+
return { kind: "none", why: "off" };
|
|
67
|
+
const title = chatTitle(store, row.taskId);
|
|
68
|
+
const line = (why) => ({ kind: "line", text: `Screenshots for ${title} weren't sent: ${why}.` });
|
|
69
|
+
if (evidenceRoot === undefined)
|
|
70
|
+
return line("saved files can't be read from here");
|
|
71
|
+
// Retention removed this result's files: nothing to show, and nothing worth saying.
|
|
72
|
+
if (existsSync(join(evidenceRoot, String(row.run), RETENTION_NOTE)))
|
|
73
|
+
return { kind: "none", why: "pruned" };
|
|
74
|
+
const ref = store.lookupRef(row.taskId);
|
|
75
|
+
if (ref?.repo == null || !repos.includes(ref.repo))
|
|
76
|
+
return line("the result is outside your connected projects now");
|
|
77
|
+
const family = store.taskFamilyOf(row.taskId, repos, false);
|
|
78
|
+
const latest = store.runsFor(row.taskRef).find(one => one.finishedAt !== null && ["builder", "repair", "scout"].includes(one.role));
|
|
79
|
+
if (family?.current.id !== row.taskId || latest?.id !== row.run)
|
|
80
|
+
return line("a newer result replaced this one");
|
|
81
|
+
const chosen = store.artifactsFor(row.run).filter(one => one.kind === "screenshot").slice(0, limit);
|
|
82
|
+
if (chosen.length === 0)
|
|
83
|
+
return { kind: "none", why: "none" };
|
|
84
|
+
const captions = ownCaptions(store, evidenceRoot, row.run);
|
|
85
|
+
const shots = [];
|
|
86
|
+
for (const [index, artifact] of chosen.entries()) {
|
|
87
|
+
const verified = verifyResultImage(store, evidenceRoot, repos, { taskId: row.taskId, run: row.run, artifact: artifact.id, sha256: artifact.sha256 });
|
|
88
|
+
if (!verified.ok) {
|
|
89
|
+
// Removed by retention between the check above and now: still nothing to say.
|
|
90
|
+
if (existsSync(join(evidenceRoot, String(row.run), RETENTION_NOTE)))
|
|
91
|
+
return { kind: "none", why: "pruned" };
|
|
92
|
+
return line(verified.problem);
|
|
93
|
+
}
|
|
94
|
+
const own = captions.get(SCREENSHOT_CAPTURE.exec(artifact.capture)?.[1] ?? "") ?? null;
|
|
95
|
+
shots.push({
|
|
96
|
+
artifact: artifact.id, sha256: artifact.sha256, bytes: verified.bytes, format: verified.format,
|
|
97
|
+
fileName: resultImageFileName(row.taskId, row.run, artifact.id, verified.format),
|
|
98
|
+
caption: index === 0 ? (own === null ? title : `${title} · ${own}`) : own ?? `${title} · screenshot ${index + 1}`,
|
|
99
|
+
photo: isPhoto(verified.bytes, verified.format),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const remaining = shots.filter(one => !sent.has(one.artifact));
|
|
103
|
+
return remaining.length === 0 ? { kind: "none", why: "sent" } : { kind: "send", taskId: row.taskId, run: row.run, shots: remaining };
|
|
104
|
+
}
|
|
105
|
+
/** Retention removed this result's files: its screenshots go quietly, never as a complaint. */
|
|
106
|
+
export function resultShotsPruned(evidenceRoot, run) {
|
|
107
|
+
return existsSync(join(evidenceRoot, String(run), RETENTION_NOTE));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The chat app refused the upload (no file permission there): this part becomes one plain line and the result's
|
|
111
|
+
* other screenshots in the same message are dropped, so the person reads one line, not one per screenshot.
|
|
112
|
+
*/
|
|
113
|
+
export function refuseResultShots(state, store, part, content, app, now) {
|
|
114
|
+
const title = chatTitle(store, content.image?.taskId ?? content.task ?? "");
|
|
115
|
+
const line = { text: `Screenshots for ${title} weren't sent: the ${app} app isn't allowed to upload files here.`,
|
|
116
|
+
...(content.task === undefined ? {} : { task: content.task }), ...(content.run === undefined ? {} : { run: content.run }) };
|
|
117
|
+
state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL,problem=NULL,file=NULL,uploaded=0,uncertain=0,created=? WHERE id=?").run(JSON.stringify(line), now.toISOString(), part.id);
|
|
118
|
+
state.prepare("UPDATE chat_part SET state='dropped',problem='Uploads are not allowed here' WHERE event=? AND id<>? AND state='pending'").run(part.event, part.id);
|
|
119
|
+
}
|