pi-advisor-flow 0.8.3-dev.3 → 0.9.0
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 +8 -1
- package/README.md +6 -5
- package/dist/index.js +631 -76
- package/package.json +1 -1
- 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 +13 -1
- package/src/commands/model-commands.ts +1 -0
- package/src/commands/runtime.ts +29 -0
- 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 +24 -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
|
@@ -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. */
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { ImageContent } from "@earendil-works/pi-ai/compat";
|
|
1
4
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
5
|
|
|
3
6
|
import { readTrackedFiles, readUntrackedFiles } from "../attachments.ts";
|
|
@@ -7,12 +10,19 @@ import {
|
|
|
7
10
|
advisorGitContextRef,
|
|
8
11
|
advisorRedactSecretsRef,
|
|
9
12
|
advisorScoutEnabledRef,
|
|
13
|
+
advisorToolPoliciesRef,
|
|
10
14
|
advisorTrackedFileContentRef,
|
|
11
15
|
advisorUntrackedContentRef,
|
|
12
16
|
contextMaxCharsRef,
|
|
13
17
|
} from "../config/state.ts";
|
|
14
18
|
import { clampGitContextLevel, collectGitContext } from "../git.ts";
|
|
15
19
|
import type { GitContextLevel } from "../git.ts";
|
|
20
|
+
import { readImageFiles } from "../image-attachments.ts";
|
|
21
|
+
import {
|
|
22
|
+
ADVISOR_IMAGES_MAX_COUNT,
|
|
23
|
+
ADVISOR_IMAGES_TOTAL_MAX_BYTES,
|
|
24
|
+
selectedConversationImages,
|
|
25
|
+
} from "../images.ts";
|
|
16
26
|
import { readProjectPreferences } from "../preferences.ts";
|
|
17
27
|
import { redactAndCapText, redactSecrets } from "../redaction.ts";
|
|
18
28
|
import { curateAdvisorConversation } from "../scout-curation.ts";
|
|
@@ -38,6 +48,7 @@ export interface ConsultationContextOptions {
|
|
|
38
48
|
includeUntracked?: string[];
|
|
39
49
|
onScout?: (event: ScoutLifecycleEvent) => void;
|
|
40
50
|
signal?: AbortSignal;
|
|
51
|
+
supportsImages?: boolean;
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
export interface ConsultationContext {
|
|
@@ -47,6 +58,10 @@ export interface ConsultationContext {
|
|
|
47
58
|
conversation: string;
|
|
48
59
|
/** Redacted draft text, if a draft was supplied. */
|
|
49
60
|
draftText?: string;
|
|
61
|
+
imageOmissions: number;
|
|
62
|
+
imagePartsSeen: number;
|
|
63
|
+
images: { image: ImageContent; label: string }[];
|
|
64
|
+
supportsImages: boolean;
|
|
50
65
|
/** Redacted project preferences, if present. */
|
|
51
66
|
preferences?: { bytes: number; text: string };
|
|
52
67
|
/** Non-cancelling Scout outcome, when Scout ran. */
|
|
@@ -92,9 +107,12 @@ export const assembleConsultationContext = async (
|
|
|
92
107
|
0,
|
|
93
108
|
contextMaxCharsRef - changeText.length
|
|
94
109
|
);
|
|
110
|
+
const imageNonce = randomUUID();
|
|
95
111
|
const legacyConversation = advisorRequestConversation(
|
|
96
112
|
ctx,
|
|
97
|
-
conversationBudget
|
|
113
|
+
conversationBudget,
|
|
114
|
+
true,
|
|
115
|
+
imageNonce
|
|
98
116
|
);
|
|
99
117
|
const curated = await curateAdvisorConversation(
|
|
100
118
|
ctx,
|
|
@@ -104,7 +122,8 @@ export const assembleConsultationContext = async (
|
|
|
104
122
|
advisorScoutEnabledRef,
|
|
105
123
|
runAdvisorScout,
|
|
106
124
|
options.currentInvocationId,
|
|
107
|
-
conversationBudget
|
|
125
|
+
conversationBudget,
|
|
126
|
+
imageNonce
|
|
108
127
|
);
|
|
109
128
|
const preferences = await readProjectPreferences(
|
|
110
129
|
ctx,
|
|
@@ -135,12 +154,69 @@ export const assembleConsultationContext = async (
|
|
|
135
154
|
untracked.reduce((sum, item) => sum + item.bytes, 0)
|
|
136
155
|
)
|
|
137
156
|
);
|
|
157
|
+
const supportsImages = options.supportsImages ?? false;
|
|
158
|
+
const untrackedImages = await readImageFiles(
|
|
159
|
+
ctx.cwd,
|
|
160
|
+
options.includeUntracked ?? [],
|
|
161
|
+
supportsImages && advisorUntrackedContentRef,
|
|
162
|
+
"untracked",
|
|
163
|
+
ADVISOR_IMAGES_TOTAL_MAX_BYTES,
|
|
164
|
+
ADVISOR_IMAGES_MAX_COUNT
|
|
165
|
+
);
|
|
166
|
+
const trackedImages = await readImageFiles(
|
|
167
|
+
ctx.cwd,
|
|
168
|
+
options.includeTracked ?? [],
|
|
169
|
+
supportsImages && advisorTrackedFileContentRef,
|
|
170
|
+
"tracked",
|
|
171
|
+
ADVISOR_IMAGES_TOTAL_MAX_BYTES -
|
|
172
|
+
untrackedImages.images.reduce((sum, item) => sum + item.bytes, 0),
|
|
173
|
+
ADVISOR_IMAGES_MAX_COUNT - untrackedImages.images.length
|
|
174
|
+
);
|
|
175
|
+
const images = [...untrackedImages.images, ...trackedImages.images].map(
|
|
176
|
+
(item) => ({
|
|
177
|
+
image: item.image,
|
|
178
|
+
label: `File ${JSON.stringify(item.path)}`,
|
|
179
|
+
})
|
|
180
|
+
);
|
|
181
|
+
let remainingBytes =
|
|
182
|
+
ADVISOR_IMAGES_TOTAL_MAX_BYTES -
|
|
183
|
+
[...untrackedImages.images, ...trackedImages.images].reduce(
|
|
184
|
+
(sum, item) => sum + item.bytes,
|
|
185
|
+
0
|
|
186
|
+
);
|
|
187
|
+
let imageOmissions = untrackedImages.omitted + trackedImages.omitted;
|
|
188
|
+
const census = selectedConversationImages(
|
|
189
|
+
ctx,
|
|
190
|
+
curated.conversation,
|
|
191
|
+
advisorToolPoliciesRef,
|
|
192
|
+
curated.scout?.ok === true
|
|
193
|
+
? new Set<string>(curated.selectedEntryIds)
|
|
194
|
+
: undefined,
|
|
195
|
+
imageNonce
|
|
196
|
+
);
|
|
197
|
+
for (const item of census.selected) {
|
|
198
|
+
const bytes = Buffer.from(item.image.data, "base64").length;
|
|
199
|
+
if (
|
|
200
|
+
!supportsImages ||
|
|
201
|
+
images.length >= ADVISOR_IMAGES_MAX_COUNT ||
|
|
202
|
+
bytes > remainingBytes
|
|
203
|
+
) {
|
|
204
|
+
imageOmissions += 1;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
remainingBytes -= bytes;
|
|
208
|
+
images.push({ image: item.image, label: item.marker });
|
|
209
|
+
}
|
|
138
210
|
return {
|
|
139
211
|
changeText,
|
|
140
212
|
conversation: curated.conversation,
|
|
141
213
|
draftText,
|
|
214
|
+
imageOmissions,
|
|
215
|
+
imagePartsSeen: census.imagePartsSeen,
|
|
216
|
+
images,
|
|
142
217
|
preferences,
|
|
143
218
|
scout: curated.scout,
|
|
219
|
+
supportsImages,
|
|
144
220
|
tracked,
|
|
145
221
|
untracked,
|
|
146
222
|
};
|