toolroll 0.9.19 → 0.9.21
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 +24 -0
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +22 -21
- package/dist/browser-workspace.d.ts +43 -1
- package/dist/chat-actions.d.ts +5 -0
- package/dist/chat-actions.js +57 -6
- package/dist/chat-delivery-state.d.ts +15 -0
- package/dist/chat-delivery-state.js +27 -2
- package/dist/chat-delivery.js +24 -4
- package/dist/chat-flow.d.ts +26 -3
- package/dist/chat-flow.js +217 -5
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-chat.js +2 -2
- package/dist/evidence.d.ts +13 -1
- package/dist/evidence.js +10 -1
- package/dist/flow-engine.d.ts +7 -0
- package/dist/flow-engine.js +71 -27
- package/dist/flow-gallery-ui.d.ts +1 -1
- package/dist/flow-gallery-ui.js +8 -7
- package/dist/flow-gallery.d.ts +8 -0
- package/dist/flow-gallery.js +37 -3
- package/dist/flow-send.d.ts +80 -0
- package/dist/flow-send.js +249 -0
- package/dist/flow-share.js +7 -3
- package/dist/flow-steps.js +1 -1
- package/dist/flow-triggers.d.ts +7 -0
- package/dist/flow-triggers.js +17 -5
- package/dist/flows-cli.js +11 -3
- package/dist/flows-ui.js +22 -6
- package/dist/flows.d.ts +27 -1
- package/dist/flows.js +104 -18
- package/dist/invoke.d.ts +3 -0
- package/dist/invoke.js +1 -0
- package/dist/mate-tools.js +33 -10
- package/dist/operate.js +41 -0
- package/dist/plane-review.d.ts +15 -0
- package/dist/plane-review.js +62 -20
- package/dist/project-delete.js +4 -0
- package/dist/project-demo.d.ts +12 -0
- package/dist/project-demo.js +71 -0
- package/dist/provider.d.ts +14 -0
- package/dist/provider.js +8 -1
- package/dist/report-summary.d.ts +9 -0
- package/dist/report-summary.js +9 -0
- package/dist/result-shots.d.ts +2 -0
- package/dist/result-shots.js +24 -5
- package/dist/scout-net.d.ts +22 -0
- package/dist/scout-net.js +214 -0
- package/dist/scout-report.d.ts +160 -0
- package/dist/scout-report.js +171 -1
- package/dist/scout.d.ts +42 -0
- package/dist/scout.js +380 -69
- package/dist/serve.js +43 -5
- package/dist/slack-chat.js +3 -3
- package/dist/store.d.ts +53 -0
- package/dist/store.js +95 -4
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-flow.d.ts +16 -2
- package/dist/telegram-flow.js +58 -0
- package/dist/telegram.js +46 -10
- package/package.json +1 -1
package/dist/result-shots.js
CHANGED
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { existsSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
-
import { imageDimensions, readVerifiedProofForRun, RETENTION_NOTE } from "./evidence.js";
|
|
18
|
+
import { imageDimensions, readVerifiedProofForRun, readVerifiedReport, RETENTION_NOTE } from "./evidence.js";
|
|
19
19
|
import { resultImageFileName, verifyResultImage } from "./chat-evidence.js";
|
|
20
20
|
import { publicChatText } from "./chat-display.js";
|
|
21
21
|
import { chatTitle } from "./chat-voice.js";
|
|
22
|
-
import { RESULT_SHOTS_KIND } from "./store.js";
|
|
22
|
+
import { FLOW_SHOTS_KIND, RESULT_SHOTS_KIND } from "./store.js";
|
|
23
23
|
/** "Up to 4": the most screenshots one result ever sends. */
|
|
24
24
|
export const RESULT_SHOTS_MAX = 4;
|
|
25
25
|
/** The choices in the order Settings and the CLI show them. */
|
|
@@ -45,6 +45,19 @@ function ownCaptions(store, root, run) {
|
|
|
45
45
|
}
|
|
46
46
|
return captions;
|
|
47
47
|
}
|
|
48
|
+
/** A scout's screenshots keep the captions its report gave them, by evidence id. */
|
|
49
|
+
function reportCaptions(store, root, taskRef, run) {
|
|
50
|
+
const captions = new Map();
|
|
51
|
+
const view = readVerifiedReport(store, root, taskRef);
|
|
52
|
+
if (view === null || !view.ok || view.run !== run)
|
|
53
|
+
return captions;
|
|
54
|
+
for (const image of view.report.images) {
|
|
55
|
+
const words = publicChatText(image.caption, 200).trim();
|
|
56
|
+
if (image.artifact !== undefined && words !== "" && !words.includes("[sensitive text hidden]"))
|
|
57
|
+
captions.set(image.artifact, words);
|
|
58
|
+
}
|
|
59
|
+
return captions;
|
|
60
|
+
}
|
|
48
61
|
function isPhoto(bytes, format) {
|
|
49
62
|
const size = imageDimensions(bytes, format);
|
|
50
63
|
if (size === null || size.width === 0 || size.height === 0)
|
|
@@ -59,9 +72,10 @@ function isPhoto(bytes, format) {
|
|
|
59
72
|
* no wait in between, or asks again.
|
|
60
73
|
*/
|
|
61
74
|
export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()) {
|
|
62
|
-
if (row.kind
|
|
75
|
+
if (!isShotsKind(row.kind) || row.recipient === null || row.taskId === null || row.taskRef === null || row.run === null)
|
|
63
76
|
return { kind: "none", why: "none" };
|
|
64
|
-
|
|
77
|
+
// A flow's "Send to me" or "Person chooses" asked for them: up to four, whatever the person chose for results.
|
|
78
|
+
const limit = row.kind === FLOW_SHOTS_KIND ? RESULT_SHOTS_MAX : resultShotLimit(store.notificationPreference(row.recipient).screenshots);
|
|
65
79
|
if (limit === 0)
|
|
66
80
|
return { kind: "none", why: "off" };
|
|
67
81
|
const title = chatTitle(store, row.taskId);
|
|
@@ -82,6 +96,7 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
|
|
|
82
96
|
if (chosen.length === 0)
|
|
83
97
|
return { kind: "none", why: "none" };
|
|
84
98
|
const captions = ownCaptions(store, evidenceRoot, row.run);
|
|
99
|
+
const reported = latest.role === "scout" ? reportCaptions(store, evidenceRoot, row.taskRef, row.run) : new Map();
|
|
85
100
|
const shots = [];
|
|
86
101
|
for (const [index, artifact] of chosen.entries()) {
|
|
87
102
|
const verified = verifyResultImage(store, evidenceRoot, repos, { taskId: row.taskId, run: row.run, artifact: artifact.id, sha256: artifact.sha256 });
|
|
@@ -91,7 +106,7 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
|
|
|
91
106
|
return { kind: "none", why: "pruned" };
|
|
92
107
|
return line(verified.problem);
|
|
93
108
|
}
|
|
94
|
-
const own = captions.get(SCREENSHOT_CAPTURE.exec(artifact.capture)?.[1] ?? "") ?? null;
|
|
109
|
+
const own = captions.get(SCREENSHOT_CAPTURE.exec(artifact.capture)?.[1] ?? "") ?? reported.get(artifact.id) ?? null;
|
|
95
110
|
shots.push({
|
|
96
111
|
artifact: artifact.id, sha256: artifact.sha256, bytes: verified.bytes, format: verified.format,
|
|
97
112
|
fileName: resultImageFileName(row.taskId, row.run, artifact.id, verified.format),
|
|
@@ -102,6 +117,10 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
|
|
|
102
117
|
const remaining = shots.filter(one => !sent.has(one.artifact));
|
|
103
118
|
return remaining.length === 0 ? { kind: "none", why: "sent" } : { kind: "send", taskId: row.taskId, run: row.run, shots: remaining };
|
|
104
119
|
}
|
|
120
|
+
/** A row that carries a result's screenshots: one a person asked for with results, or one a flow sends them. */
|
|
121
|
+
export function isShotsKind(kind) {
|
|
122
|
+
return kind === RESULT_SHOTS_KIND || kind === FLOW_SHOTS_KIND;
|
|
123
|
+
}
|
|
105
124
|
/** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
|
|
106
125
|
export function shotWaitsUntil(resultNextAt, now) {
|
|
107
126
|
const soonest = new Date(now.getTime() + 1_000).toISOString();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Socket } from "node:net";
|
|
2
|
+
/** Whether an IP literal is a public internet address. IPv4 inside IPv6 (mapped, compatible or NAT64) is judged as IPv4. */
|
|
3
|
+
export declare function isPublicAddress(address: string): boolean;
|
|
4
|
+
export type Lookup = (host: string) => Promise<readonly string[]>;
|
|
5
|
+
/**
|
|
6
|
+
* A connection to the first of the checked addresses that answers, tried in turn (review 827): a host whose IPv6
|
|
7
|
+
* address is unreachable from here is still reached over IPv4. Null when none answers.
|
|
8
|
+
*/
|
|
9
|
+
export declare function connectFirst(addresses: readonly string[], port: number, timeoutMs?: number): Promise<Socket | null>;
|
|
10
|
+
export type ScoutProxy = {
|
|
11
|
+
url: string;
|
|
12
|
+
close: () => Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Start the scout browser's proxy on loopback. `refused` hears every request it turned away (for the live log and
|
|
16
|
+
* tests). `lookup` resolves a host name to its addresses (the system resolver unless a test supplies one).
|
|
17
|
+
*/
|
|
18
|
+
export declare function startScoutProxy(options?: {
|
|
19
|
+
demoUrl?: string | null;
|
|
20
|
+
lookup?: Lookup;
|
|
21
|
+
refused?: (target: string, why: string) => void;
|
|
22
|
+
}): Promise<ScoutProxy>;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scout reaches only the public web (reviews 826, 827): every request
|
|
3
|
+
* its browser and its web fetch make goes through this local proxy, which resolves the host
|
|
4
|
+
* itself and refuses anything that is not a public internet address —
|
|
5
|
+
* loopback, private, link-local, carrier-grade NAT, multicast and reserved
|
|
6
|
+
* ranges, in IPv4 and IPv6 — then connects to the very addresses it
|
|
7
|
+
* checked, each in turn until one answers, so a name that re-resolves
|
|
8
|
+
* somewhere private gets nowhere. The one
|
|
9
|
+
* exception is the project's own demo URL, when one is set, matched by its
|
|
10
|
+
* exact scheme, host and port. Redirects and a page's own requests pass
|
|
11
|
+
* through the same check; file: never reaches a proxy, and the pinned
|
|
12
|
+
* Playwright refuses to open it.
|
|
13
|
+
*/
|
|
14
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
15
|
+
import { BlockList, connect, isIP } from "node:net";
|
|
16
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
17
|
+
/** Address ranges a scout's browser may never reach. */
|
|
18
|
+
const NOT_PUBLIC = (() => {
|
|
19
|
+
const list = new BlockList();
|
|
20
|
+
for (const [net, prefix] of [
|
|
21
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12],
|
|
22
|
+
["192.0.0.0", 24], ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24],
|
|
23
|
+
["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
24
|
+
])
|
|
25
|
+
list.addSubnet(net, prefix, "ipv4");
|
|
26
|
+
for (const [net, prefix] of [
|
|
27
|
+
["::", 128], ["::1", 128], ["64:ff9b:1::", 48], ["100::", 64], ["2001::", 23], ["2001:db8::", 32], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8],
|
|
28
|
+
])
|
|
29
|
+
list.addSubnet(net, prefix, "ipv6");
|
|
30
|
+
return list;
|
|
31
|
+
})();
|
|
32
|
+
/** Whether an IP literal is a public internet address. IPv4 inside IPv6 (mapped, compatible or NAT64) is judged as IPv4. */
|
|
33
|
+
export function isPublicAddress(address) {
|
|
34
|
+
const bare = address.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
|
|
35
|
+
const family = isIP(bare);
|
|
36
|
+
if (family === 4)
|
|
37
|
+
return !NOT_PUBLIC.check(bare, "ipv4");
|
|
38
|
+
if (family !== 6)
|
|
39
|
+
return false;
|
|
40
|
+
const embedded = /^(?:::ffff:(?:0:)?|::|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/i.exec(bare)?.[1];
|
|
41
|
+
if (embedded !== undefined)
|
|
42
|
+
return isPublicAddress(embedded);
|
|
43
|
+
const mappedHex = /^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(bare);
|
|
44
|
+
if (mappedHex !== null) {
|
|
45
|
+
const high = parseInt(mappedHex[1], 16);
|
|
46
|
+
const low = parseInt(mappedHex[2], 16);
|
|
47
|
+
return isPublicAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
48
|
+
}
|
|
49
|
+
if (/^::ffff:/i.test(bare))
|
|
50
|
+
return false;
|
|
51
|
+
return !NOT_PUBLIC.check(bare, "ipv6");
|
|
52
|
+
}
|
|
53
|
+
const systemLookup = async (host) => (await dnsLookup(host, { all: true, verbatim: true })).map(one => one.address);
|
|
54
|
+
/** The demo URL's own origin, or null when none is set or it is not an http(s) address. */
|
|
55
|
+
function demoTarget(demoUrl) {
|
|
56
|
+
if (demoUrl === undefined || demoUrl === null || demoUrl === "")
|
|
57
|
+
return null;
|
|
58
|
+
try {
|
|
59
|
+
const url = new URL(demoUrl);
|
|
60
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
61
|
+
return null;
|
|
62
|
+
const scheme = url.protocol === "http:" ? "http" : "https";
|
|
63
|
+
return { scheme, host: url.hostname.toLowerCase(), port: Number(url.port || (scheme === "http" ? 80 : 443)) };
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** How long one address may take to answer before the next is tried. */
|
|
70
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
71
|
+
/**
|
|
72
|
+
* A connection to the first of the checked addresses that answers, tried in turn (review 827): a host whose IPv6
|
|
73
|
+
* address is unreachable from here is still reached over IPv4. Null when none answers.
|
|
74
|
+
*/
|
|
75
|
+
export async function connectFirst(addresses, port, timeoutMs = CONNECT_TIMEOUT_MS) {
|
|
76
|
+
for (const address of addresses) {
|
|
77
|
+
const socket = await new Promise(done => {
|
|
78
|
+
const attempt = connect({ host: address, port });
|
|
79
|
+
const give = (value) => {
|
|
80
|
+
attempt.removeAllListeners("connect").removeAllListeners("error").removeAllListeners("timeout");
|
|
81
|
+
attempt.setTimeout(0);
|
|
82
|
+
if (value === null)
|
|
83
|
+
attempt.destroy();
|
|
84
|
+
done(value);
|
|
85
|
+
};
|
|
86
|
+
attempt.setTimeout(timeoutMs);
|
|
87
|
+
attempt.once("connect", () => give(attempt));
|
|
88
|
+
attempt.once("error", () => give(null));
|
|
89
|
+
attempt.once("timeout", () => give(null));
|
|
90
|
+
});
|
|
91
|
+
if (socket !== null)
|
|
92
|
+
return socket;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Start the scout browser's proxy on loopback. `refused` hears every request it turned away (for the live log and
|
|
98
|
+
* tests). `lookup` resolves a host name to its addresses (the system resolver unless a test supplies one).
|
|
99
|
+
*/
|
|
100
|
+
export async function startScoutProxy(options = {}) {
|
|
101
|
+
const lookup = options.lookup ?? systemLookup;
|
|
102
|
+
const demo = demoTarget(options.demoUrl);
|
|
103
|
+
const sockets = new Set();
|
|
104
|
+
/** The addresses this request may reach, in the resolver's order, or why it may not. */
|
|
105
|
+
const resolve = async (target) => {
|
|
106
|
+
const host = target.host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
107
|
+
const isDemo = demo !== null && demo.scheme === target.scheme && demo.host.replace(/^\[|\]$/g, "") === host && demo.port === target.port;
|
|
108
|
+
let addresses;
|
|
109
|
+
try {
|
|
110
|
+
addresses = isIP(host) !== 0 ? [host] : await lookup(host);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { ok: false, why: "the host does not resolve" };
|
|
114
|
+
}
|
|
115
|
+
if (addresses.length === 0)
|
|
116
|
+
return { ok: false, why: "the host does not resolve" };
|
|
117
|
+
if (isDemo)
|
|
118
|
+
return { ok: true, addresses };
|
|
119
|
+
if (!addresses.every(isPublicAddress))
|
|
120
|
+
return { ok: false, why: "not a public internet address" };
|
|
121
|
+
return { ok: true, addresses };
|
|
122
|
+
};
|
|
123
|
+
const refuse = (target, why) => {
|
|
124
|
+
try {
|
|
125
|
+
options.refused?.(target, why);
|
|
126
|
+
}
|
|
127
|
+
catch { /* a listener's trouble is not the browser's */ }
|
|
128
|
+
};
|
|
129
|
+
const server = createServer((incoming, outgoing) => {
|
|
130
|
+
void (async () => {
|
|
131
|
+
let url;
|
|
132
|
+
try {
|
|
133
|
+
url = new URL(incoming.url ?? "");
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
outgoing.writeHead(400).end();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (url.protocol !== "http:") {
|
|
140
|
+
refuse(url.protocol, "only web pages");
|
|
141
|
+
outgoing.writeHead(403, { "content-type": "text/plain" }).end("Toolroll's scout browser opens only public web pages.");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const target = { scheme: "http", host: url.hostname, port: Number(url.port || 80) };
|
|
145
|
+
const reached = await resolve(target);
|
|
146
|
+
if (!reached.ok) {
|
|
147
|
+
refuse(url.origin, reached.why);
|
|
148
|
+
outgoing.writeHead(403, { "content-type": "text/plain" }).end("Toolroll's scout browser opens only public web pages.");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const upstream = await connectFirst(reached.addresses, target.port);
|
|
152
|
+
if (upstream === null) {
|
|
153
|
+
outgoing.writeHead(502).end();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
sockets.add(upstream);
|
|
157
|
+
upstream.on("close", () => sockets.delete(upstream));
|
|
158
|
+
const headers = { ...incoming.headers };
|
|
159
|
+
delete headers["proxy-connection"];
|
|
160
|
+
delete headers["proxy-authorization"];
|
|
161
|
+
const forward = httpRequest({ createConnection: () => upstream, method: incoming.method, path: `${url.pathname}${url.search}`, headers, setHost: false }, answer => {
|
|
162
|
+
outgoing.writeHead(answer.statusCode ?? 502, answer.headers);
|
|
163
|
+
answer.pipe(outgoing);
|
|
164
|
+
});
|
|
165
|
+
forward.on("error", () => { if (!outgoing.headersSent)
|
|
166
|
+
outgoing.writeHead(502); outgoing.end(); });
|
|
167
|
+
incoming.pipe(forward);
|
|
168
|
+
})();
|
|
169
|
+
});
|
|
170
|
+
server.on("connect", (incoming, client, head) => {
|
|
171
|
+
sockets.add(client);
|
|
172
|
+
client.on("close", () => sockets.delete(client));
|
|
173
|
+
client.on("error", () => client.destroy());
|
|
174
|
+
void (async () => {
|
|
175
|
+
const match = /^(\[[0-9a-fA-F:.]+\]|[^:[\]]+):(\d{1,5})$/.exec(incoming.url ?? "");
|
|
176
|
+
const target = match === null ? null : { scheme: "https", host: match[1], port: Number(match[2]) };
|
|
177
|
+
const reached = target === null ? { ok: false, why: "not a host and port" } : await resolve(target);
|
|
178
|
+
if (!reached.ok) {
|
|
179
|
+
refuse(incoming.url ?? "", reached.why);
|
|
180
|
+
client.end("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const upstream = await connectFirst(reached.addresses, target.port);
|
|
184
|
+
if (upstream === null) {
|
|
185
|
+
if (!client.destroyed)
|
|
186
|
+
client.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
sockets.add(upstream);
|
|
190
|
+
upstream.on("close", () => { sockets.delete(upstream); client.destroy(); });
|
|
191
|
+
upstream.on("error", () => client.destroy());
|
|
192
|
+
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
193
|
+
if (head.length > 0)
|
|
194
|
+
upstream.write(head);
|
|
195
|
+
upstream.pipe(client);
|
|
196
|
+
client.pipe(upstream);
|
|
197
|
+
})();
|
|
198
|
+
});
|
|
199
|
+
server.on("connection", socket => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
|
|
200
|
+
await new Promise((done, fail) => {
|
|
201
|
+
server.once("error", fail);
|
|
202
|
+
server.listen(0, "127.0.0.1", () => { server.off("error", fail); done(); });
|
|
203
|
+
});
|
|
204
|
+
const address = server.address();
|
|
205
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
206
|
+
return {
|
|
207
|
+
url: `http://127.0.0.1:${port}`,
|
|
208
|
+
close: () => new Promise(done => {
|
|
209
|
+
for (const socket of sockets)
|
|
210
|
+
socket.destroy();
|
|
211
|
+
server.close(() => done());
|
|
212
|
+
}),
|
|
213
|
+
};
|
|
214
|
+
}
|
package/dist/scout-report.d.ts
CHANGED
|
@@ -20,6 +20,24 @@ export type ParsedReport = {
|
|
|
20
20
|
title: string;
|
|
21
21
|
goal: string;
|
|
22
22
|
}[];
|
|
23
|
+
/** What the scout found, as a short list later steps can read: each cites its URL, and may show one of `images`. */
|
|
24
|
+
items: ReportItem[];
|
|
25
|
+
/** Screenshots the scout saved during the run. From the scout, `file` names a file in its output folder; once
|
|
26
|
+
* stored, the runner adds the bytes' sha256 and the evidence row that holds them. */
|
|
27
|
+
images: ReportImage[];
|
|
28
|
+
};
|
|
29
|
+
export type ReportItem = {
|
|
30
|
+
title: string;
|
|
31
|
+
why: string;
|
|
32
|
+
url: string;
|
|
33
|
+
image: string | null;
|
|
34
|
+
};
|
|
35
|
+
export type ReportImage = {
|
|
36
|
+
file: string;
|
|
37
|
+
caption: string;
|
|
38
|
+
url: string;
|
|
39
|
+
sha256?: string;
|
|
40
|
+
artifact?: number;
|
|
23
41
|
};
|
|
24
42
|
export type ReportParseResult = {
|
|
25
43
|
ok: true;
|
|
@@ -38,5 +56,147 @@ export declare const REPORT_LIMITS: {
|
|
|
38
56
|
readonly followUps: 5;
|
|
39
57
|
readonly followUpTitle: 200;
|
|
40
58
|
readonly followUpGoal: 2000;
|
|
59
|
+
readonly items: 6;
|
|
60
|
+
readonly itemTitle: 200;
|
|
61
|
+
readonly itemWhy: 1000;
|
|
62
|
+
readonly url: 2000;
|
|
63
|
+
readonly images: 8;
|
|
64
|
+
readonly caption: 300;
|
|
65
|
+
};
|
|
66
|
+
/** A screenshot's name in the scout's output folder: one plain file name, PNG or JPEG, never a path. */
|
|
67
|
+
export declare const REPORT_IMAGE_FILE: RegExp;
|
|
68
|
+
/**
|
|
69
|
+
* The scout's handback for Claude's `--json-schema` (run 2334's fix): plan
|
|
70
|
+
* mode only lets a session write its own plan file, so a Claude scout
|
|
71
|
+
* returns a report — or a question for the operator — as structured output
|
|
72
|
+
* on the terminal result event. Shape only, never the validator: byte caps,
|
|
73
|
+
* one-line titles and control characters stay `parseReport`'s and
|
|
74
|
+
* `parseDecision`'s, applied to the re-serialized body.
|
|
75
|
+
*/
|
|
76
|
+
export declare const SCOUT_OUTPUT_JSON_SCHEMA: {
|
|
77
|
+
readonly type: "object";
|
|
78
|
+
readonly properties: {
|
|
79
|
+
readonly kind: {
|
|
80
|
+
readonly type: "string";
|
|
81
|
+
readonly enum: readonly ["report", "question"];
|
|
82
|
+
};
|
|
83
|
+
readonly report: {
|
|
84
|
+
readonly type: "object";
|
|
85
|
+
readonly properties: {
|
|
86
|
+
readonly title: {
|
|
87
|
+
readonly type: "string";
|
|
88
|
+
};
|
|
89
|
+
readonly summary: {
|
|
90
|
+
readonly type: "string";
|
|
91
|
+
};
|
|
92
|
+
readonly report: {
|
|
93
|
+
readonly type: "string";
|
|
94
|
+
};
|
|
95
|
+
readonly followUps: {
|
|
96
|
+
readonly type: "array";
|
|
97
|
+
readonly items: {
|
|
98
|
+
readonly type: "object";
|
|
99
|
+
readonly properties: {
|
|
100
|
+
readonly title: {
|
|
101
|
+
readonly type: "string";
|
|
102
|
+
};
|
|
103
|
+
readonly goal: {
|
|
104
|
+
readonly type: "string";
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
readonly required: readonly ["title", "goal"];
|
|
108
|
+
readonly additionalProperties: false;
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
readonly items: {
|
|
112
|
+
readonly type: "array";
|
|
113
|
+
readonly items: {
|
|
114
|
+
readonly type: "object";
|
|
115
|
+
readonly properties: {
|
|
116
|
+
readonly title: {
|
|
117
|
+
readonly type: "string";
|
|
118
|
+
};
|
|
119
|
+
readonly why: {
|
|
120
|
+
readonly type: "string";
|
|
121
|
+
};
|
|
122
|
+
readonly url: {
|
|
123
|
+
readonly type: "string";
|
|
124
|
+
};
|
|
125
|
+
readonly image: {
|
|
126
|
+
readonly type: "string";
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
readonly required: readonly ["title", "why", "url"];
|
|
130
|
+
readonly additionalProperties: false;
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
readonly images: {
|
|
134
|
+
readonly type: "array";
|
|
135
|
+
readonly items: {
|
|
136
|
+
readonly type: "object";
|
|
137
|
+
readonly properties: {
|
|
138
|
+
readonly file: {
|
|
139
|
+
readonly type: "string";
|
|
140
|
+
};
|
|
141
|
+
readonly caption: {
|
|
142
|
+
readonly type: "string";
|
|
143
|
+
};
|
|
144
|
+
readonly url: {
|
|
145
|
+
readonly type: "string";
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
readonly required: readonly ["file", "caption", "url"];
|
|
149
|
+
readonly additionalProperties: false;
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
readonly required: readonly ["title", "summary", "report"];
|
|
154
|
+
readonly additionalProperties: false;
|
|
155
|
+
};
|
|
156
|
+
readonly decision: {
|
|
157
|
+
readonly type: "object";
|
|
158
|
+
readonly properties: {
|
|
159
|
+
readonly urgency: {
|
|
160
|
+
readonly type: "string";
|
|
161
|
+
readonly enum: readonly ["blocking"];
|
|
162
|
+
};
|
|
163
|
+
readonly recap: {
|
|
164
|
+
readonly type: "string";
|
|
165
|
+
};
|
|
166
|
+
readonly question: {
|
|
167
|
+
readonly type: "string";
|
|
168
|
+
};
|
|
169
|
+
readonly options: {
|
|
170
|
+
readonly type: "array";
|
|
171
|
+
readonly items: {
|
|
172
|
+
readonly type: "object";
|
|
173
|
+
readonly properties: {
|
|
174
|
+
readonly id: {
|
|
175
|
+
readonly type: "string";
|
|
176
|
+
};
|
|
177
|
+
readonly label: {
|
|
178
|
+
readonly type: "string";
|
|
179
|
+
};
|
|
180
|
+
readonly consequence: {
|
|
181
|
+
readonly type: "string";
|
|
182
|
+
};
|
|
183
|
+
readonly reversible: {
|
|
184
|
+
readonly type: "boolean";
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
readonly required: readonly ["id", "label", "consequence", "reversible"];
|
|
188
|
+
readonly additionalProperties: false;
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
readonly recommendation: {
|
|
192
|
+
readonly type: "string";
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
readonly required: readonly ["urgency", "recap", "question", "options", "recommendation"];
|
|
196
|
+
readonly additionalProperties: false;
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
readonly required: readonly ["kind"];
|
|
200
|
+
readonly additionalProperties: false;
|
|
41
201
|
};
|
|
42
202
|
export declare function parseReport(raw: string): ReportParseResult;
|
package/dist/scout-report.js
CHANGED
|
@@ -17,6 +17,95 @@ export const REPORT_LIMITS = {
|
|
|
17
17
|
followUps: 5,
|
|
18
18
|
followUpTitle: 200,
|
|
19
19
|
followUpGoal: 2_000,
|
|
20
|
+
items: 6,
|
|
21
|
+
itemTitle: 200,
|
|
22
|
+
itemWhy: 1_000,
|
|
23
|
+
url: 2_000,
|
|
24
|
+
images: 8,
|
|
25
|
+
caption: 300,
|
|
26
|
+
};
|
|
27
|
+
/** A screenshot's name in the scout's output folder: one plain file name, PNG or JPEG, never a path. */
|
|
28
|
+
export const REPORT_IMAGE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.(?:png|jpe?g)$/i;
|
|
29
|
+
const REPORT_SHAPE = {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
title: { type: "string" },
|
|
33
|
+
summary: { type: "string" },
|
|
34
|
+
report: { type: "string" },
|
|
35
|
+
followUps: {
|
|
36
|
+
type: "array",
|
|
37
|
+
items: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: { title: { type: "string" }, goal: { type: "string" } },
|
|
40
|
+
required: ["title", "goal"],
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
items: {
|
|
45
|
+
type: "array",
|
|
46
|
+
items: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: { title: { type: "string" }, why: { type: "string" }, url: { type: "string" }, image: { type: "string" } },
|
|
49
|
+
required: ["title", "why", "url"],
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
images: {
|
|
54
|
+
type: "array",
|
|
55
|
+
items: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: { file: { type: "string" }, caption: { type: "string" }, url: { type: "string" } },
|
|
58
|
+
required: ["file", "caption", "url"],
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
required: ["title", "summary", "report"],
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
};
|
|
66
|
+
/** The park mailbox's decision, the same fields `parseDecision` reads. */
|
|
67
|
+
const DECISION_SHAPE = {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: {
|
|
70
|
+
urgency: { type: "string", enum: ["blocking"] },
|
|
71
|
+
recap: { type: "string" },
|
|
72
|
+
question: { type: "string" },
|
|
73
|
+
options: {
|
|
74
|
+
type: "array",
|
|
75
|
+
items: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
id: { type: "string" },
|
|
79
|
+
label: { type: "string" },
|
|
80
|
+
consequence: { type: "string" },
|
|
81
|
+
reversible: { type: "boolean" },
|
|
82
|
+
},
|
|
83
|
+
required: ["id", "label", "consequence", "reversible"],
|
|
84
|
+
additionalProperties: false,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
recommendation: { type: "string" },
|
|
88
|
+
},
|
|
89
|
+
required: ["urgency", "recap", "question", "options", "recommendation"],
|
|
90
|
+
additionalProperties: false,
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* The scout's handback for Claude's `--json-schema` (run 2334's fix): plan
|
|
94
|
+
* mode only lets a session write its own plan file, so a Claude scout
|
|
95
|
+
* returns a report — or a question for the operator — as structured output
|
|
96
|
+
* on the terminal result event. Shape only, never the validator: byte caps,
|
|
97
|
+
* one-line titles and control characters stay `parseReport`'s and
|
|
98
|
+
* `parseDecision`'s, applied to the re-serialized body.
|
|
99
|
+
*/
|
|
100
|
+
export const SCOUT_OUTPUT_JSON_SCHEMA = {
|
|
101
|
+
type: "object",
|
|
102
|
+
properties: {
|
|
103
|
+
kind: { type: "string", enum: ["report", "question"] },
|
|
104
|
+
report: REPORT_SHAPE,
|
|
105
|
+
decision: DECISION_SHAPE,
|
|
106
|
+
},
|
|
107
|
+
required: ["kind"],
|
|
108
|
+
additionalProperties: false,
|
|
20
109
|
};
|
|
21
110
|
function refuse(reason, message) {
|
|
22
111
|
return { ok: false, problems: [{ reason, message }] };
|
|
@@ -102,7 +191,88 @@ export function parseReport(raw) {
|
|
|
102
191
|
}
|
|
103
192
|
}
|
|
104
193
|
}
|
|
194
|
+
const images = listOf(body["images"], "images", REPORT_LIMITS.images, problems, (entry, at) => {
|
|
195
|
+
// Any one line here: the runner refuses an image that isn't a plain file name in its output folder, not the report.
|
|
196
|
+
const file = prose(entry["file"], `${at}.file`, REPORT_LIMITS.caption, problems);
|
|
197
|
+
const caption = prose(entry["caption"], `${at}.caption`, REPORT_LIMITS.caption, problems);
|
|
198
|
+
const url = link(entry["url"], `${at}.url`, problems);
|
|
199
|
+
if (file !== null && /[\n\r]/.test(file))
|
|
200
|
+
problems.push({ reason: `${at}-file-multiline`, message: `${at}.file must be one line` });
|
|
201
|
+
if (caption !== null && /[\n\r]/.test(caption))
|
|
202
|
+
problems.push({ reason: `${at}-caption-multiline`, message: `${at}.caption must be one line` });
|
|
203
|
+
const sha256 = entry["sha256"];
|
|
204
|
+
if (sha256 !== undefined && (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/.test(sha256)))
|
|
205
|
+
problems.push({ reason: `${at}-sha256`, message: `${at}.sha256 must be a sha256 hex digest` });
|
|
206
|
+
const artifact = entry["artifact"];
|
|
207
|
+
if (artifact !== undefined && (typeof artifact !== "number" || !Number.isSafeInteger(artifact) || artifact <= 0))
|
|
208
|
+
problems.push({ reason: `${at}-artifact`, message: `${at}.artifact must be an evidence id` });
|
|
209
|
+
if (file === null || caption === null || url === null || /[\n\r]/.test(file) || /[\n\r]/.test(caption))
|
|
210
|
+
return null;
|
|
211
|
+
return { file, caption, url, ...(typeof sha256 === "string" ? { sha256 } : {}), ...(typeof artifact === "number" ? { artifact } : {}) };
|
|
212
|
+
});
|
|
213
|
+
const files = new Set();
|
|
214
|
+
for (const one of images) {
|
|
215
|
+
if (files.has(one.file))
|
|
216
|
+
problems.push({ reason: "images-duplicate", message: `images names ${one.file} twice` });
|
|
217
|
+
files.add(one.file);
|
|
218
|
+
}
|
|
219
|
+
const items = listOf(body["items"], "items", REPORT_LIMITS.items, problems, (entry, at) => {
|
|
220
|
+
const itemTitle = prose(entry["title"], `${at}.title`, REPORT_LIMITS.itemTitle, problems);
|
|
221
|
+
const why = prose(entry["why"], `${at}.why`, REPORT_LIMITS.itemWhy, problems);
|
|
222
|
+
const url = link(entry["url"], `${at}.url`, problems);
|
|
223
|
+
const image = entry["image"] === undefined || entry["image"] === null || entry["image"] === "" ? null : entry["image"];
|
|
224
|
+
if (itemTitle !== null && /[\n\r]/.test(itemTitle))
|
|
225
|
+
problems.push({ reason: `${at}-title-multiline`, message: `${at}.title must be one line` });
|
|
226
|
+
if (image !== null && (typeof image !== "string" || !files.has(image)))
|
|
227
|
+
problems.push({ reason: `${at}-image`, message: `${at}.image must name one of images by its file` });
|
|
228
|
+
if (itemTitle === null || why === null || url === null || /[\n\r]/.test(itemTitle) || (image !== null && (typeof image !== "string" || !files.has(image))))
|
|
229
|
+
return null;
|
|
230
|
+
return { title: itemTitle, why, url, image: image };
|
|
231
|
+
});
|
|
105
232
|
if (problems.length > 0)
|
|
106
233
|
return { ok: false, problems };
|
|
107
|
-
return { ok: true, report: { title: title, summary: summary, report: document, followUps } };
|
|
234
|
+
return { ok: true, report: { title: title, summary: summary, report: document, followUps, items, images } };
|
|
235
|
+
}
|
|
236
|
+
/** An optional capped list of objects, each read by `one`; problems are reported, never thrown. */
|
|
237
|
+
function listOf(value, field, cap, problems, one) {
|
|
238
|
+
if (value === undefined || value === null)
|
|
239
|
+
return [];
|
|
240
|
+
if (!Array.isArray(value)) {
|
|
241
|
+
problems.push({ reason: `bad-${field}`, message: `${field} must be an array (got ${describe(value)})` });
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
if (value.length > cap) {
|
|
245
|
+
problems.push({ reason: `${field}-too-many`, message: `${field} lists ${value.length} — cap is ${cap}` });
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
const kept = [];
|
|
249
|
+
for (const [index, entry] of value.entries()) {
|
|
250
|
+
const at = `${field}[${index}]`;
|
|
251
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
252
|
+
problems.push({ reason: `${at}-shape`, message: `${at} must be an object` });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const read = one(entry, at);
|
|
256
|
+
if (read !== null)
|
|
257
|
+
kept.push(read);
|
|
258
|
+
}
|
|
259
|
+
return kept;
|
|
260
|
+
}
|
|
261
|
+
/** A cited web address: http or https, one line, no credentials. */
|
|
262
|
+
function link(value, field, problems) {
|
|
263
|
+
const text = prose(value, field, REPORT_LIMITS.url, problems);
|
|
264
|
+
if (text === null)
|
|
265
|
+
return null;
|
|
266
|
+
let url = null;
|
|
267
|
+
try {
|
|
268
|
+
url = new URL(text);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
url = null;
|
|
272
|
+
}
|
|
273
|
+
if (url === null || (url.protocol !== "http:" && url.protocol !== "https:") || /\s/.test(text) || url.username !== "" || url.password !== "") {
|
|
274
|
+
problems.push({ reason: `${field}-not-a-link`, message: `${field} must be an http or https address` });
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
return text;
|
|
108
278
|
}
|