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.
@@ -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. */
@@ -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
  };