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.
Files changed (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +6 -5
  3. package/dist/index.js +685 -96
  4. package/package.json +14 -14
  5. package/src/attachments.ts +21 -5
  6. package/src/commands/activation.ts +6 -1
  7. package/src/commands/lifecycle.ts +6 -0
  8. package/src/commands/manual-command.ts +44 -12
  9. package/src/commands/manual-consultation.ts +7 -3
  10. package/src/commands/model-commands.ts +1 -0
  11. package/src/commands/runtime.ts +36 -10
  12. package/src/commands/settings-commands.ts +2 -0
  13. package/src/commands/settings-persistence.ts +2 -0
  14. package/src/commands/types.ts +5 -0
  15. package/src/config/defaults.ts +7 -0
  16. package/src/config/schema.ts +7 -0
  17. package/src/config/state.ts +5 -0
  18. package/src/config/types.ts +1 -0
  19. package/src/conversation.ts +29 -7
  20. package/src/image-attachments.ts +118 -0
  21. package/src/image-validation.ts +288 -0
  22. package/src/images.ts +151 -0
  23. package/src/scout-context.ts +1 -0
  24. package/src/scout-curation.ts +11 -4
  25. package/src/scout-groups.ts +13 -3
  26. package/src/scout-types.ts +2 -0
  27. package/src/tools/consult-context.ts +78 -2
  28. package/src/tools/consultation.ts +36 -4
  29. package/src/tools/jev-turn-gate.ts +4 -1
  30. package/src/tools/model-access.ts +21 -2
  31. package/src/tools/prompts.ts +14 -2
  32. package/src/tools/register-ask-advisor.ts +28 -1
  33. package/src/tools/register-lifecycle.ts +8 -2
  34. package/src/tools/render-advisor-result.ts +33 -9
  35. package/src/tools/scout-status.ts +11 -8
  36. package/src/tools/types.ts +9 -0
  37. package/src/ui/settings-items.ts +17 -1
  38. package/src/ui/settings-mutations.ts +1 -0
  39. package/src/ui/types.ts +1 -0
  40. 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
+ };
@@ -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,
@@ -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
- conversation =
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
  };
@@ -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;
@@ -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. */