pi-advisor-flow 0.8.3 → 0.9.1
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/README.md +6 -5
- package/dist/index.js +685 -96
- package/package.json +14 -14
- package/src/attachments.ts +21 -5
- package/src/commands/activation.ts +6 -1
- package/src/commands/lifecycle.ts +6 -0
- package/src/commands/manual-command.ts +44 -12
- package/src/commands/manual-consultation.ts +7 -3
- package/src/commands/model-commands.ts +1 -0
- package/src/commands/runtime.ts +36 -10
- package/src/commands/settings-commands.ts +2 -0
- package/src/commands/settings-persistence.ts +2 -0
- package/src/commands/types.ts +5 -0
- package/src/config/defaults.ts +7 -0
- package/src/config/schema.ts +7 -0
- package/src/config/state.ts +5 -0
- package/src/config/types.ts +1 -0
- package/src/conversation.ts +29 -7
- package/src/image-attachments.ts +118 -0
- package/src/image-validation.ts +288 -0
- package/src/images.ts +151 -0
- package/src/scout-context.ts +1 -0
- package/src/scout-curation.ts +11 -4
- package/src/scout-groups.ts +13 -3
- package/src/scout-types.ts +2 -0
- package/src/tools/consult-context.ts +78 -2
- package/src/tools/consultation.ts +36 -4
- package/src/tools/jev-turn-gate.ts +4 -1
- package/src/tools/model-access.ts +21 -2
- package/src/tools/prompts.ts +14 -2
- package/src/tools/register-ask-advisor.ts +28 -1
- package/src/tools/register-lifecycle.ts +8 -2
- package/src/tools/render-advisor-result.ts +33 -9
- package/src/tools/scout-status.ts +11 -8
- package/src/tools/types.ts +9 -0
- package/src/ui/settings-items.ts +17 -1
- package/src/ui/settings-mutations.ts +1 -0
- package/src/ui/types.ts +1 -0
- package/src/ui-guard.ts +31 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open, realpath } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { ImageContent } from "@earendil-works/pi-ai/compat";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
imageFileCandidate,
|
|
9
|
+
imageMime,
|
|
10
|
+
isPermitted,
|
|
11
|
+
normalizeRequestedPath,
|
|
12
|
+
repositoryRoot,
|
|
13
|
+
within,
|
|
14
|
+
} from "./attachments.ts";
|
|
15
|
+
import { isString } from "./content-utils.ts";
|
|
16
|
+
import { ADVISOR_IMAGE_MAX_BYTES, imageFromBytes } from "./images.ts";
|
|
17
|
+
|
|
18
|
+
export interface ImageAttachment {
|
|
19
|
+
bytes: number;
|
|
20
|
+
image: ImageContent;
|
|
21
|
+
path: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const readImageAttachment = async (
|
|
25
|
+
root: string,
|
|
26
|
+
name: string,
|
|
27
|
+
mimeType: string,
|
|
28
|
+
remaining: number
|
|
29
|
+
): Promise<ImageAttachment | undefined> => {
|
|
30
|
+
const absolute = resolve(root, name);
|
|
31
|
+
if (!within(root, absolute)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const stats = await lstat(absolute);
|
|
35
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const resolved = await realpath(absolute);
|
|
39
|
+
if (!within(root, resolved)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const flags = constants.O_NOFOLLOW
|
|
43
|
+
? constants.O_RDONLY + constants.O_NOFOLLOW
|
|
44
|
+
: constants.O_RDONLY;
|
|
45
|
+
const file = await open(resolved, flags);
|
|
46
|
+
try {
|
|
47
|
+
const opened = await file.stat();
|
|
48
|
+
if (
|
|
49
|
+
!opened.isFile() ||
|
|
50
|
+
opened.size > ADVISOR_IMAGE_MAX_BYTES ||
|
|
51
|
+
opened.size > remaining
|
|
52
|
+
) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const buffer = Buffer.alloc(opened.size + 1);
|
|
56
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
57
|
+
if (bytesRead !== opened.size) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const image = imageFromBytes(buffer.subarray(0, bytesRead), mimeType);
|
|
61
|
+
return image ? { bytes: bytesRead, image, path: name } : undefined;
|
|
62
|
+
} finally {
|
|
63
|
+
await file.close();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const readImageFiles = async (
|
|
68
|
+
cwd: string,
|
|
69
|
+
requested: string[],
|
|
70
|
+
enabled: boolean,
|
|
71
|
+
kind: "tracked" | "untracked",
|
|
72
|
+
remainingBytes: number,
|
|
73
|
+
remainingCount: number
|
|
74
|
+
): Promise<{ images: ImageAttachment[]; omitted: number }> => {
|
|
75
|
+
const names = Array.isArray(requested)
|
|
76
|
+
? requested.filter((path) => isString(path) && imageFileCandidate(path))
|
|
77
|
+
: [];
|
|
78
|
+
if (!enabled || !names.length) {
|
|
79
|
+
return { images: [], omitted: names.length };
|
|
80
|
+
}
|
|
81
|
+
const root = await repositoryRoot(cwd);
|
|
82
|
+
if (!root) {
|
|
83
|
+
return { images: [], omitted: names.length };
|
|
84
|
+
}
|
|
85
|
+
const seen = new Set<string>();
|
|
86
|
+
const images: ImageAttachment[] = [];
|
|
87
|
+
let omitted = 0;
|
|
88
|
+
let bytes = 0;
|
|
89
|
+
for (const path of names) {
|
|
90
|
+
const name = normalizeRequestedPath(root, path);
|
|
91
|
+
if (name && seen.has(name)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (name) {
|
|
95
|
+
seen.add(name);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const mime = name && imageMime(name);
|
|
99
|
+
const image =
|
|
100
|
+
name &&
|
|
101
|
+
mime &&
|
|
102
|
+
images.length < remainingCount &&
|
|
103
|
+
remainingBytes - bytes > 0 &&
|
|
104
|
+
isPermitted(root, name, kind)
|
|
105
|
+
? await readImageAttachment(root, name, mime, remainingBytes - bytes)
|
|
106
|
+
: undefined;
|
|
107
|
+
if (image) {
|
|
108
|
+
images.push(image);
|
|
109
|
+
bytes += image.bytes;
|
|
110
|
+
} else {
|
|
111
|
+
omitted += 1;
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
omitted += 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { images, omitted };
|
|
118
|
+
};
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { inflateSync } from "node:zlib";
|
|
2
|
+
|
|
3
|
+
const PNG_SIGNATURE = Buffer.from("89504e470d0a1a0a", "hex");
|
|
4
|
+
|
|
5
|
+
const isPng = (bytes: Buffer): boolean => {
|
|
6
|
+
if (bytes.length < 57 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
let offset = 8;
|
|
10
|
+
let hasHeader = false;
|
|
11
|
+
const compressed: Buffer[] = [];
|
|
12
|
+
while (offset + 12 <= bytes.length) {
|
|
13
|
+
const length = bytes.readUInt32BE(offset);
|
|
14
|
+
if (length > bytes.length - offset - 12) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const type = bytes.toString("ascii", offset + 4, offset + 8);
|
|
18
|
+
const data = offset + 8;
|
|
19
|
+
offset += length + 12;
|
|
20
|
+
if (!hasHeader) {
|
|
21
|
+
if (
|
|
22
|
+
type !== "IHDR" ||
|
|
23
|
+
length !== 13 ||
|
|
24
|
+
bytes.readUInt32BE(data) === 0 ||
|
|
25
|
+
bytes.readUInt32BE(data + 4) === 0
|
|
26
|
+
) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
hasHeader = true;
|
|
30
|
+
} else if (type === "IDAT") {
|
|
31
|
+
compressed.push(bytes.subarray(data, data + length));
|
|
32
|
+
} else if (type === "IEND") {
|
|
33
|
+
if (length !== 0 || offset !== bytes.length || !compressed.length) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return (
|
|
38
|
+
inflateSync(Buffer.concat(compressed), {
|
|
39
|
+
maxOutputLength: 32 * 1024 * 1024,
|
|
40
|
+
}).length > 0
|
|
41
|
+
);
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
} else if (type === "IHDR") {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const isFrameMarker = (marker: number) =>
|
|
53
|
+
[
|
|
54
|
+
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce,
|
|
55
|
+
0xcf,
|
|
56
|
+
].includes(marker);
|
|
57
|
+
|
|
58
|
+
const nextScanMarker = (bytes: Buffer, from: number): number => {
|
|
59
|
+
let offset = from;
|
|
60
|
+
while (offset < bytes.length - 1) {
|
|
61
|
+
if (bytes[offset] !== 0xff) {
|
|
62
|
+
offset += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
let next = offset + 1;
|
|
66
|
+
while (bytes[next] === 0xff) {
|
|
67
|
+
next += 1;
|
|
68
|
+
}
|
|
69
|
+
if (bytes[next] !== 0x00 && (bytes[next] < 0xd0 || bytes[next] > 0xd7)) {
|
|
70
|
+
return offset;
|
|
71
|
+
}
|
|
72
|
+
offset = next + 1;
|
|
73
|
+
}
|
|
74
|
+
return bytes.length;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
interface JpegSegment {
|
|
78
|
+
end: number;
|
|
79
|
+
frame: boolean;
|
|
80
|
+
marker: number;
|
|
81
|
+
size: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const readJpegSegment = (
|
|
85
|
+
bytes: Buffer,
|
|
86
|
+
at: number
|
|
87
|
+
): JpegSegment | undefined => {
|
|
88
|
+
if (bytes[at] !== 0xff) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
let offset = at;
|
|
92
|
+
while (bytes[offset] === 0xff) {
|
|
93
|
+
offset += 1;
|
|
94
|
+
}
|
|
95
|
+
const marker = bytes[offset];
|
|
96
|
+
offset += 1;
|
|
97
|
+
if (marker === 0xd9) {
|
|
98
|
+
return { end: offset, frame: false, marker, size: 0 };
|
|
99
|
+
}
|
|
100
|
+
if (offset + 2 > bytes.length || marker === 0x00) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const size = bytes.readUInt16BE(offset);
|
|
104
|
+
if (size < 2 || offset + size > bytes.length) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const frame =
|
|
108
|
+
isFrameMarker(marker) &&
|
|
109
|
+
size >= 11 &&
|
|
110
|
+
bytes.readUInt16BE(offset + 3) > 0 &&
|
|
111
|
+
bytes.readUInt16BE(offset + 5) > 0;
|
|
112
|
+
return { end: offset + size, frame, marker, size };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const hasJpegEnvelope = (bytes: Buffer) =>
|
|
116
|
+
bytes.length >= 24 &&
|
|
117
|
+
bytes[0] === 0xff &&
|
|
118
|
+
bytes[1] === 0xd8 &&
|
|
119
|
+
bytes.at(-2) === 0xff &&
|
|
120
|
+
bytes.at(-1) === 0xd9;
|
|
121
|
+
|
|
122
|
+
const isJpeg = (bytes: Buffer): boolean => {
|
|
123
|
+
if (!hasJpegEnvelope(bytes)) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
let offset = 2;
|
|
127
|
+
let frame = false;
|
|
128
|
+
let scan = false;
|
|
129
|
+
let quantization = false;
|
|
130
|
+
let entropyTable = false;
|
|
131
|
+
while (offset < bytes.length) {
|
|
132
|
+
const segment = readJpegSegment(bytes, offset);
|
|
133
|
+
if (!segment) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
const { end, marker, size } = segment;
|
|
137
|
+
if (marker === 0xd9) {
|
|
138
|
+
return frame && scan && end === bytes.length;
|
|
139
|
+
}
|
|
140
|
+
frame ||= segment.frame;
|
|
141
|
+
quantization ||= marker === 0xdb;
|
|
142
|
+
entropyTable ||= marker === 0xc4 || marker === 0xcc;
|
|
143
|
+
offset = end;
|
|
144
|
+
if (marker === 0xda) {
|
|
145
|
+
if (!(frame && quantization && entropyTable && size >= 6)) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const next = nextScanMarker(bytes, offset);
|
|
149
|
+
if (next <= offset) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
scan = true;
|
|
153
|
+
offset = next;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const skipGifBlocks = (bytes: Buffer, at: number): number | undefined => {
|
|
160
|
+
let offset = at;
|
|
161
|
+
while (offset < bytes.length) {
|
|
162
|
+
const length = bytes[offset];
|
|
163
|
+
offset += 1;
|
|
164
|
+
if (length === 0) {
|
|
165
|
+
return offset;
|
|
166
|
+
}
|
|
167
|
+
if (offset + length > bytes.length) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
offset += length;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const gifColorTableEnd = (bytes: Buffer, at: number, packed: number) =>
|
|
175
|
+
at + ((packed & 0x80) === 0 ? 0 : 3 * 2 ** ((packed & 7) + 1));
|
|
176
|
+
|
|
177
|
+
const gifFrameEnd = (bytes: Buffer, at: number): number | undefined => {
|
|
178
|
+
if (
|
|
179
|
+
at + 9 > bytes.length ||
|
|
180
|
+
bytes.readUInt16LE(at + 4) === 0 ||
|
|
181
|
+
bytes.readUInt16LE(at + 6) === 0
|
|
182
|
+
) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const data = gifColorTableEnd(bytes, at + 9, bytes[at + 8]);
|
|
186
|
+
if (
|
|
187
|
+
data + 2 >= bytes.length ||
|
|
188
|
+
bytes[data] < 2 ||
|
|
189
|
+
bytes[data] > 12 ||
|
|
190
|
+
bytes[data + 1] === 0
|
|
191
|
+
) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
return skipGifBlocks(bytes, data + 1);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const isGif = (bytes: Buffer): boolean => {
|
|
198
|
+
if (
|
|
199
|
+
bytes.length < 20 ||
|
|
200
|
+
!["GIF87a", "GIF89a"].includes(bytes.toString("ascii", 0, 6)) ||
|
|
201
|
+
bytes.readUInt16LE(6) === 0 ||
|
|
202
|
+
bytes.readUInt16LE(8) === 0
|
|
203
|
+
) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
let offset = gifColorTableEnd(bytes, 13, bytes[10]);
|
|
207
|
+
let frame = false;
|
|
208
|
+
while (offset < bytes.length) {
|
|
209
|
+
const block = bytes[offset];
|
|
210
|
+
offset += 1;
|
|
211
|
+
if (block === 0x3b) {
|
|
212
|
+
return frame && offset === bytes.length;
|
|
213
|
+
}
|
|
214
|
+
if (block === 0x21 && offset < bytes.length) {
|
|
215
|
+
offset = skipGifBlocks(bytes, offset + 1) ?? bytes.length;
|
|
216
|
+
} else if (block === 0x2c) {
|
|
217
|
+
const end = gifFrameEnd(bytes, offset);
|
|
218
|
+
if (end === undefined) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
frame = true;
|
|
222
|
+
offset = end;
|
|
223
|
+
} else {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const validWebpFrame = (
|
|
231
|
+
bytes: Buffer,
|
|
232
|
+
type: string,
|
|
233
|
+
data: number,
|
|
234
|
+
length: number
|
|
235
|
+
) => {
|
|
236
|
+
if (type === "VP8 ") {
|
|
237
|
+
return (
|
|
238
|
+
length >= 10 &&
|
|
239
|
+
bytes.toString("hex", data + 3, data + 6) === "9d012a" &&
|
|
240
|
+
(bytes.readUInt16LE(data + 6) & 0x3fff) > 0 &&
|
|
241
|
+
(bytes.readUInt16LE(data + 8) & 0x3fff) > 0
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
if (type === "VP8L") {
|
|
245
|
+
return length >= 5 && bytes[data] === 0x2f;
|
|
246
|
+
}
|
|
247
|
+
return type === "ANMF" && length >= 16;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const isWebp = (bytes: Buffer): boolean => {
|
|
251
|
+
if (
|
|
252
|
+
bytes.length < 30 ||
|
|
253
|
+
bytes.toString("ascii", 0, 4) !== "RIFF" ||
|
|
254
|
+
bytes.toString("ascii", 8, 12) !== "WEBP" ||
|
|
255
|
+
bytes.readUInt32LE(4) !== bytes.length - 8
|
|
256
|
+
) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
let offset = 12;
|
|
260
|
+
let frame = false;
|
|
261
|
+
while (offset + 8 <= bytes.length) {
|
|
262
|
+
const type = bytes.toString("ascii", offset, offset + 4);
|
|
263
|
+
const length = bytes.readUInt32LE(offset + 4);
|
|
264
|
+
const data = offset + 8;
|
|
265
|
+
const end = data + length + (length % 2);
|
|
266
|
+
if (end > bytes.length) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
frame ||= validWebpFrame(bytes, type, data, length);
|
|
270
|
+
offset = end;
|
|
271
|
+
}
|
|
272
|
+
return frame && offset === bytes.length;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export const detectImageFormat = (bytes: Buffer): string | undefined => {
|
|
276
|
+
if (isPng(bytes)) {
|
|
277
|
+
return "image/png";
|
|
278
|
+
}
|
|
279
|
+
if (isJpeg(bytes)) {
|
|
280
|
+
return "image/jpeg";
|
|
281
|
+
}
|
|
282
|
+
if (isGif(bytes)) {
|
|
283
|
+
return "image/gif";
|
|
284
|
+
}
|
|
285
|
+
if (isWebp(bytes)) {
|
|
286
|
+
return "image/webp";
|
|
287
|
+
}
|
|
288
|
+
};
|
package/src/images.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { ImageContent, TextContent } from "@earendil-works/pi-ai/compat";
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import type { AdvisorToolPolicies } from "./config/types.ts";
|
|
7
|
+
import { contentParts, isRecordOf, isString } from "./content-utils.ts";
|
|
8
|
+
import type { RecordValue } from "./content-utils.ts";
|
|
9
|
+
import { detectImageFormat } from "./image-validation.ts";
|
|
10
|
+
|
|
11
|
+
export const ADVISOR_IMAGE_MAX_BYTES = 4 * 1024 * 1024;
|
|
12
|
+
export const ADVISOR_IMAGES_TOTAL_MAX_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
export const ADVISOR_IMAGES_MAX_COUNT = 4;
|
|
14
|
+
|
|
15
|
+
export const imageFromBytes = (
|
|
16
|
+
bytes: Buffer,
|
|
17
|
+
mimeType?: string
|
|
18
|
+
): ImageContent | undefined => {
|
|
19
|
+
if (!bytes.length || bytes.length > ADVISOR_IMAGE_MAX_BYTES) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const actual = detectImageFormat(bytes);
|
|
23
|
+
if (!actual || (mimeType && mimeType !== actual)) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
return { data: bytes.toString("base64"), mimeType: actual, type: "image" };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const imageFromPart = (part: RecordValue): ImageContent | undefined => {
|
|
30
|
+
if (
|
|
31
|
+
part.type !== "image" ||
|
|
32
|
+
!isString(part.data) ||
|
|
33
|
+
!isString(part.mimeType) ||
|
|
34
|
+
part.data.length > Math.ceil((ADVISOR_IMAGE_MAX_BYTES * 4) / 3) + 4 ||
|
|
35
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
|
|
36
|
+
part.data
|
|
37
|
+
)
|
|
38
|
+
) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const bytes = Buffer.from(part.data, "base64");
|
|
42
|
+
if (bytes.toString("base64") !== part.data) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
return imageFromBytes(bytes, part.mimeType);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const imageMarker = (
|
|
49
|
+
part: RecordValue,
|
|
50
|
+
nonce = ""
|
|
51
|
+
): string | undefined => {
|
|
52
|
+
if (part.type !== "image") {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const image = imageFromPart(part);
|
|
56
|
+
if (!image) {
|
|
57
|
+
return "[Image omitted: unsupported format, invalid data, or over 4 MiB; pixels not reviewed]";
|
|
58
|
+
}
|
|
59
|
+
const id = createHash("sha256")
|
|
60
|
+
.update(nonce)
|
|
61
|
+
.update(image.data)
|
|
62
|
+
.digest("hex")
|
|
63
|
+
.slice(0, 24);
|
|
64
|
+
return `[Image ref=img_${id}; pixels reviewed only if attached below]`;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export interface SelectedImage {
|
|
68
|
+
image: ImageContent;
|
|
69
|
+
marker: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ConversationImageCensus {
|
|
73
|
+
imagePartsSeen: number;
|
|
74
|
+
selected: SelectedImage[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const eligibleImagePart = (part: RecordValue) =>
|
|
78
|
+
part.type === "image" && isString(part.data) && isString(part.mimeType);
|
|
79
|
+
|
|
80
|
+
interface ImagePartCensus {
|
|
81
|
+
parts: number;
|
|
82
|
+
selected: SelectedImage[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const entryImageCensus = (
|
|
86
|
+
content: string | (ImageContent | TextContent)[],
|
|
87
|
+
conversation: string,
|
|
88
|
+
nonce: string,
|
|
89
|
+
seen: Set<string>
|
|
90
|
+
): ImagePartCensus => {
|
|
91
|
+
let parts = 0;
|
|
92
|
+
const selected: SelectedImage[] = [];
|
|
93
|
+
for (const part of contentParts(content)) {
|
|
94
|
+
if (!isRecordOf(part)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (eligibleImagePart(part)) {
|
|
98
|
+
parts += 1;
|
|
99
|
+
}
|
|
100
|
+
const marker = imageMarker(part, nonce);
|
|
101
|
+
if (!marker || seen.has(marker) || !conversation.includes(marker)) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const image = imageFromPart(part);
|
|
105
|
+
if (image) {
|
|
106
|
+
seen.add(marker);
|
|
107
|
+
selected.push({ image, marker });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { parts, selected };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const selectedConversationImages = (
|
|
114
|
+
ctx: ExtensionContext,
|
|
115
|
+
conversation: string,
|
|
116
|
+
policies: AdvisorToolPolicies,
|
|
117
|
+
selectedEntryIds: ReadonlySet<string> | undefined,
|
|
118
|
+
nonce: string
|
|
119
|
+
): ConversationImageCensus => {
|
|
120
|
+
if (!conversation) {
|
|
121
|
+
return { imagePartsSeen: 0, selected: [] };
|
|
122
|
+
}
|
|
123
|
+
const entries = selectedEntryIds
|
|
124
|
+
? ctx.sessionManager.buildContextEntries()
|
|
125
|
+
: ctx.sessionManager.getBranch();
|
|
126
|
+
const images: SelectedImage[] = [];
|
|
127
|
+
const seen = new Set<string>();
|
|
128
|
+
let imagePartsSeen = 0;
|
|
129
|
+
for (const [index, entry] of entries.entries()) {
|
|
130
|
+
if (
|
|
131
|
+
entry.type !== "message" ||
|
|
132
|
+
(selectedEntryIds && !selectedEntryIds.has(entry.id ?? String(index)))
|
|
133
|
+
) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const { message } = entry;
|
|
137
|
+
if (message.role !== "user" && message.role !== "toolResult") {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (
|
|
141
|
+
message.role === "toolResult" &&
|
|
142
|
+
(policies[message.toolName] ?? "full") !== "full"
|
|
143
|
+
) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const census = entryImageCensus(message.content, conversation, nonce, seen);
|
|
147
|
+
imagePartsSeen += census.parts;
|
|
148
|
+
images.push(...census.selected);
|
|
149
|
+
}
|
|
150
|
+
return { imagePartsSeen, selected: images };
|
|
151
|
+
};
|
package/src/scout-context.ts
CHANGED
|
@@ -39,6 +39,7 @@ const resolveCaps = (
|
|
|
39
39
|
maxManifestBytes: number;
|
|
40
40
|
} => ({
|
|
41
41
|
currentInvocationId: options.currentInvocationId,
|
|
42
|
+
imageNonce: options.imageNonce ?? "",
|
|
42
43
|
maxConversationChars: options.maxConversationChars,
|
|
43
44
|
maxGroupBytes: options.maxGroupBytes ?? SCOUT_GROUP_MAX_BYTES,
|
|
44
45
|
maxGroups: options.maxGroups ?? SCOUT_MANIFEST_MAX_GROUPS,
|
package/src/scout-curation.ts
CHANGED
|
@@ -20,10 +20,12 @@ export const curateAdvisorConversation = async (
|
|
|
20
20
|
enabled = advisorScoutEnabledRef,
|
|
21
21
|
runScout: typeof runAdvisorScout = runAdvisorScout,
|
|
22
22
|
currentInvocationId?: string,
|
|
23
|
-
maxChars?: number
|
|
23
|
+
maxChars?: number,
|
|
24
|
+
imageNonce = ""
|
|
24
25
|
): Promise<{
|
|
25
26
|
conversation: string;
|
|
26
27
|
scout?: Exclude<ScoutOutcome, { cancelled: true }>;
|
|
28
|
+
selectedEntryIds?: string[];
|
|
27
29
|
}> => {
|
|
28
30
|
if (!enabled) {
|
|
29
31
|
return { conversation: legacyConversation };
|
|
@@ -33,6 +35,7 @@ export const curateAdvisorConversation = async (
|
|
|
33
35
|
}
|
|
34
36
|
const built = buildScoutManifest(ctx, {
|
|
35
37
|
currentInvocationId,
|
|
38
|
+
imageNonce,
|
|
36
39
|
maxConversationChars: maxChars,
|
|
37
40
|
maxManifestBytes: SCOUT_MANIFEST_MAX_BYTES,
|
|
38
41
|
});
|
|
@@ -59,9 +62,12 @@ export const curateAdvisorConversation = async (
|
|
|
59
62
|
? signal.reason
|
|
60
63
|
: new Error("Advisor operation cancelled during Scout.");
|
|
61
64
|
}
|
|
62
|
-
let conversation = legacyConversation;
|
|
63
65
|
if (outcome.ok) {
|
|
64
|
-
|
|
66
|
+
const selected = new Set(outcome.selection.selectedIds);
|
|
67
|
+
const selectedEntryIds = built.manifest.groups
|
|
68
|
+
.filter((group) => group.required || selected.has(group.id))
|
|
69
|
+
.flatMap((group) => group.entryIds ?? []);
|
|
70
|
+
const conversation =
|
|
65
71
|
maxChars === undefined
|
|
66
72
|
? outcome.conversation
|
|
67
73
|
: reconstructScoutConversation(
|
|
@@ -70,6 +76,7 @@ export const curateAdvisorConversation = async (
|
|
|
70
76
|
outcome.selection.synthesis,
|
|
71
77
|
maxChars
|
|
72
78
|
);
|
|
79
|
+
return { conversation, scout: outcome, selectedEntryIds };
|
|
73
80
|
}
|
|
74
|
-
return { conversation, scout: outcome };
|
|
81
|
+
return { conversation: legacyConversation, scout: outcome };
|
|
75
82
|
};
|
package/src/scout-groups.ts
CHANGED
|
@@ -76,6 +76,7 @@ const createGroup = (
|
|
|
76
76
|
): ScoutContextGroup => ({
|
|
77
77
|
bytes: byteLength(content),
|
|
78
78
|
content,
|
|
79
|
+
entryIds,
|
|
79
80
|
id: stableId(originalIndex, entryIds, kind, content),
|
|
80
81
|
kind,
|
|
81
82
|
label: labelFor(kind, content),
|
|
@@ -101,6 +102,7 @@ const pendingInvocationDisclosure = (
|
|
|
101
102
|
toolResultMaxBytes: number,
|
|
102
103
|
policies: AdvisorToolPolicies,
|
|
103
104
|
redact: boolean,
|
|
105
|
+
imageNonce: string,
|
|
104
106
|
disclosed: string
|
|
105
107
|
) => {
|
|
106
108
|
if (!isRecord(entry.message)) {
|
|
@@ -123,12 +125,15 @@ const pendingInvocationDisclosure = (
|
|
|
123
125
|
toolResultMaxLines,
|
|
124
126
|
toolResultMaxBytes,
|
|
125
127
|
policies,
|
|
126
|
-
redact
|
|
128
|
+
redact,
|
|
129
|
+
true,
|
|
130
|
+
imageNonce
|
|
127
131
|
);
|
|
128
132
|
};
|
|
129
133
|
|
|
130
134
|
export interface DisclosureCaps {
|
|
131
135
|
currentInvocationId?: string;
|
|
136
|
+
imageNonce: string;
|
|
132
137
|
policies: AdvisorToolPolicies;
|
|
133
138
|
redact: boolean;
|
|
134
139
|
toolResultMaxBytes: number;
|
|
@@ -191,7 +196,9 @@ const collectResults = (
|
|
|
191
196
|
caps.toolResultMaxLines,
|
|
192
197
|
caps.toolResultMaxBytes,
|
|
193
198
|
caps.policies,
|
|
194
|
-
caps.redact
|
|
199
|
+
caps.redact,
|
|
200
|
+
true,
|
|
201
|
+
caps.imageNonce
|
|
195
202
|
);
|
|
196
203
|
if (resultText) {
|
|
197
204
|
resultParts.push(resultText);
|
|
@@ -235,6 +242,7 @@ const missingOutcome = (
|
|
|
235
242
|
caps.toolResultMaxBytes,
|
|
236
243
|
caps.policies,
|
|
237
244
|
caps.redact,
|
|
245
|
+
caps.imageNonce,
|
|
238
246
|
disclosed
|
|
239
247
|
);
|
|
240
248
|
return {
|
|
@@ -450,7 +458,9 @@ export const buildGroups = (
|
|
|
450
458
|
caps.toolResultMaxLines,
|
|
451
459
|
caps.toolResultMaxBytes,
|
|
452
460
|
caps.policies,
|
|
453
|
-
caps.redact
|
|
461
|
+
caps.redact,
|
|
462
|
+
true,
|
|
463
|
+
caps.imageNonce
|
|
454
464
|
);
|
|
455
465
|
if (!disclosed) {
|
|
456
466
|
continue;
|
package/src/scout-types.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type ScoutGroupKind =
|
|
|
19
19
|
export interface ScoutContextGroup {
|
|
20
20
|
bytes: number;
|
|
21
21
|
content: string;
|
|
22
|
+
entryIds?: string[];
|
|
22
23
|
id: string;
|
|
23
24
|
kind: ScoutGroupKind;
|
|
24
25
|
label: string;
|
|
@@ -51,6 +52,7 @@ export interface InvalidProtocolResult {
|
|
|
51
52
|
|
|
52
53
|
export interface BuildScoutManifestOptions {
|
|
53
54
|
currentInvocationId?: string;
|
|
55
|
+
imageNonce?: string;
|
|
54
56
|
/** @deprecated Use maxManifestBytes for the Scout transport budget. */
|
|
55
57
|
maxBytes?: number;
|
|
56
58
|
/** Maximum reconstructed Advisor conversation characters. */
|