u-foo 3.0.2 → 3.0.4

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,367 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+ const { execFileSync } = require("child_process");
7
+ const { mediaTypeFromPath, sniffMediaType, MAX_IMAGE_BYTES } = require("./tools/readImage");
8
+
9
+ const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp)$/i;
10
+ const FILE_URL_RE = /^file:\/\//i;
11
+
12
+ function uploadsDir(workspaceRoot = "", sessionId = "") {
13
+ const root = path.resolve(String(workspaceRoot || process.cwd()));
14
+ const sid = String(sessionId || "session").trim().replace(/[^a-zA-Z0-9._-]+/g, "_") || "session";
15
+ return path.join(root, ".ufoo", "agent", "ucode", "uploads", sid);
16
+ }
17
+
18
+ function safeBaseName(filePath = "") {
19
+ const base = path.basename(String(filePath || "image.png"));
20
+ const cleaned = base.replace(/[^\w.\-()+ ]+/g, "_").replace(/\s+/g, " ").trim();
21
+ if (!cleaned) return "image.png";
22
+ if (!IMAGE_EXT_RE.test(cleaned)) return `${cleaned}.png`;
23
+ return cleaned.slice(0, 120);
24
+ }
25
+
26
+ function decodeFileUrl(value = "") {
27
+ const text = String(value || "").trim();
28
+ if (!FILE_URL_RE.test(text)) return text;
29
+ try {
30
+ const parsed = new URL(text);
31
+ if (parsed.protocol !== "file:") return text;
32
+ return decodeURIComponent(parsed.pathname || "");
33
+ } catch {
34
+ return text.replace(FILE_URL_RE, "");
35
+ }
36
+ }
37
+
38
+ function looksLikeImagePath(candidate = "") {
39
+ const text = decodeFileUrl(String(candidate || "").trim().replace(/^['"]|['"]$/g, ""));
40
+ if (!text || !IMAGE_EXT_RE.test(text)) return false;
41
+ if (text.startsWith("/") || /^[A-Za-z]:[\\/]/.test(text) || text.startsWith("~")) return true;
42
+ // Relative paths ending in image ext (drag from cwd listings)
43
+ if (!/\s/.test(text) && IMAGE_EXT_RE.test(text)) return true;
44
+ return false;
45
+ }
46
+
47
+ function expandHome(filePath = "") {
48
+ const text = String(filePath || "");
49
+ if (text.startsWith("~/")) return path.join(os.homedir(), text.slice(2));
50
+ return text;
51
+ }
52
+
53
+ /**
54
+ * Extract image file paths from terminal paste / drag-drop text.
55
+ * Supports file://, quoted paths with spaces, and bare absolute paths.
56
+ */
57
+ function extractImagePathsFromPaste(text = "") {
58
+ const raw = String(text || "");
59
+ if (!raw.trim()) return [];
60
+ const found = [];
61
+ const seen = new Set();
62
+
63
+ function pushPath(candidate) {
64
+ let next = decodeFileUrl(String(candidate || "").trim());
65
+ next = next.replace(/^['"]|['"]$/g, "");
66
+ if (!looksLikeImagePath(next)) return;
67
+ next = expandHome(next);
68
+ const key = path.resolve(next);
69
+ if (seen.has(key)) return;
70
+ seen.add(key);
71
+ found.push(next);
72
+ }
73
+
74
+ // Quoted paths (possibly with spaces)
75
+ const quoted = /["']([^"']+\.(?:png|jpe?g|gif|webp))["']/gi;
76
+ let match;
77
+ while ((match = quoted.exec(raw))) {
78
+ pushPath(match[1]);
79
+ }
80
+
81
+ // file:// URLs
82
+ const fileUrls = /file:\/\/[^\s"'<>]+/gi;
83
+ while ((match = fileUrls.exec(raw))) {
84
+ pushPath(match[0]);
85
+ }
86
+
87
+ // Bare tokens / lines
88
+ for (const line of raw.split(/\r?\n/)) {
89
+ const trimmed = line.trim();
90
+ if (!trimmed) continue;
91
+ if (looksLikeImagePath(trimmed)) {
92
+ pushPath(trimmed);
93
+ continue;
94
+ }
95
+ for (const token of trimmed.split(/\s+/)) {
96
+ if (looksLikeImagePath(token)) pushPath(token);
97
+ }
98
+ }
99
+
100
+ return found;
101
+ }
102
+
103
+ function stripExtractedPathsFromText(text = "", paths = []) {
104
+ let out = String(text || "");
105
+ for (const p of paths) {
106
+ const variants = [
107
+ `"${p}"`,
108
+ `'${p}'`,
109
+ p,
110
+ p.startsWith("/") ? `file://${p}` : "",
111
+ p.startsWith("/") ? `file://${encodeURI(p)}` : "",
112
+ ].filter(Boolean);
113
+ for (const v of variants) {
114
+ out = out.split(v).join(" ");
115
+ }
116
+ }
117
+ return out
118
+ .replace(/[ \t]+\n/g, "\n")
119
+ .replace(/\n{3,}/g, "\n\n")
120
+ .replace(/[ \t]{2,}/g, " ")
121
+ .replace(/\s+"/g, " ")
122
+ .replace(/"\s+/g, " ")
123
+ .replace(/\s+'/g, " ")
124
+ .replace(/'\s+/g, " ")
125
+ .trim();
126
+ }
127
+
128
+ function formatImageLogLabel({ relPath = "", fileName = "", path: pathText = "" } = {}) {
129
+ const name = String(fileName || "").trim()
130
+ || path.basename(String(relPath || pathText || "").trim())
131
+ || "image";
132
+ return `[image: ${name}]`;
133
+ }
134
+
135
+ function formatUserLogWithAttachments(userText = "", attachments = []) {
136
+ const labels = (Array.isArray(attachments) ? attachments : [])
137
+ .map((item) => formatImageLogLabel(item))
138
+ .filter(Boolean);
139
+ const body = String(userText || "").trim();
140
+ if (labels.length === 0) return body;
141
+ if (!body) return labels.join(" ");
142
+ return `${labels.join(" ")} ${body}`;
143
+ }
144
+
145
+ function buildAttachedImagesPromptPrefix(attachments = []) {
146
+ const list = Array.isArray(attachments) ? attachments : [];
147
+ if (list.length === 0) return "";
148
+ const lines = [
149
+ "[Attached images — call read_image on each path]",
150
+ ...list.map((item) => `- ${item.relPath || item.path || ""}`).filter((line) => line !== "- "),
151
+ "",
152
+ ];
153
+ return lines.join("\n");
154
+ }
155
+
156
+ function ingestImageFile({
157
+ sourcePath = "",
158
+ workspaceRoot = process.cwd(),
159
+ sessionId = "",
160
+ buffer = null,
161
+ preferredName = "",
162
+ } = {}) {
163
+ const root = path.resolve(String(workspaceRoot || process.cwd()));
164
+ let data = buffer;
165
+ let fromPath = String(sourcePath || "").trim();
166
+
167
+ if (!data) {
168
+ if (!fromPath) {
169
+ return { ok: false, error: "sourcePath or buffer required" };
170
+ }
171
+ fromPath = expandHome(decodeFileUrl(fromPath));
172
+ try {
173
+ const stat = fs.statSync(fromPath);
174
+ if (!stat.isFile()) return { ok: false, error: `not a file: ${fromPath}` };
175
+ if (stat.size > MAX_IMAGE_BYTES) {
176
+ return {
177
+ ok: false,
178
+ error: `image too large (${stat.size} bytes); max ${MAX_IMAGE_BYTES}`,
179
+ };
180
+ }
181
+ data = fs.readFileSync(fromPath);
182
+ } catch (err) {
183
+ return { ok: false, error: err && err.message ? err.message : "read failed" };
184
+ }
185
+ }
186
+
187
+ if (!Buffer.isBuffer(data)) {
188
+ return { ok: false, error: "image buffer required" };
189
+ }
190
+ if (data.length > MAX_IMAGE_BYTES) {
191
+ return {
192
+ ok: false,
193
+ error: `image too large (${data.length} bytes); max ${MAX_IMAGE_BYTES}`,
194
+ };
195
+ }
196
+
197
+ const sniffed = sniffMediaType(data);
198
+ const fromName = mediaTypeFromPath(preferredName || fromPath);
199
+ const mediaType = sniffed || fromName;
200
+ if (!mediaType) {
201
+ return { ok: false, error: "unsupported image type (use png, jpeg, gif, or webp)" };
202
+ }
203
+
204
+ const ext = mediaType === "image/jpeg"
205
+ ? ".jpg"
206
+ : mediaType === "image/gif"
207
+ ? ".gif"
208
+ : mediaType === "image/webp"
209
+ ? ".webp"
210
+ : ".png";
211
+
212
+ let base = safeBaseName(preferredName || fromPath || `clipboard${ext}`);
213
+ if (!IMAGE_EXT_RE.test(base)) base = `${base}${ext}`;
214
+ // Normalize extension to sniffed type
215
+ base = `${path.basename(base, path.extname(base))}${ext}`;
216
+
217
+ const dir = uploadsDir(root, sessionId);
218
+ fs.mkdirSync(dir, { recursive: true });
219
+ const stamp = Date.now().toString(36);
220
+ const destName = `${stamp}-${base}`;
221
+ const absPath = path.join(dir, destName);
222
+ fs.writeFileSync(absPath, data);
223
+
224
+ const relPath = path.relative(root, absPath).split(path.sep).join("/");
225
+ return {
226
+ ok: true,
227
+ relPath,
228
+ absPath,
229
+ fileName: base,
230
+ mediaType,
231
+ bytes: data.length,
232
+ };
233
+ }
234
+
235
+ function tryIngestClipboardImage({
236
+ workspaceRoot = process.cwd(),
237
+ sessionId = "",
238
+ platform = process.platform,
239
+ execFile = execFileSync,
240
+ } = {}) {
241
+ if (platform !== "darwin") {
242
+ return { ok: false, error: "clipboard image ingest is only supported on macOS" };
243
+ }
244
+
245
+ const tmpPath = path.join(
246
+ os.tmpdir(),
247
+ `ufoo-clipboard-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.png`,
248
+ );
249
+ // AppleScript: write clipboard PNGf to a temp file.
250
+ const script = [
251
+ `set outPath to POSIX file ${JSON.stringify(tmpPath)}`,
252
+ "try",
253
+ " set pngData to the clipboard as «class PNGf»",
254
+ " set fileRef to open for access outPath with write permission",
255
+ " set eof of fileRef to 0",
256
+ " write pngData to fileRef",
257
+ " close access fileRef",
258
+ ' return "ok"',
259
+ "on error errMsg number errNum",
260
+ " try",
261
+ " close access outPath",
262
+ " end try",
263
+ ' return "err:" & errMsg',
264
+ "end try",
265
+ ].join("\n");
266
+
267
+ let resultText = "";
268
+ try {
269
+ resultText = String(execFile("osascript", ["-e", script], {
270
+ encoding: "utf8",
271
+ timeout: 5000,
272
+ maxBuffer: 1024 * 1024,
273
+ }) || "").trim();
274
+ } catch (err) {
275
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
276
+ return {
277
+ ok: false,
278
+ error: err && err.message ? err.message : "clipboard read failed",
279
+ };
280
+ }
281
+
282
+ if (!resultText.startsWith("ok")) {
283
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
284
+ return {
285
+ ok: false,
286
+ error: resultText.replace(/^err:/, "").trim() || "no PNG image on clipboard",
287
+ };
288
+ }
289
+
290
+ try {
291
+ const ingested = ingestImageFile({
292
+ sourcePath: tmpPath,
293
+ workspaceRoot,
294
+ sessionId,
295
+ preferredName: `clipboard-${Date.now().toString(36)}.png`,
296
+ });
297
+ return ingested;
298
+ } finally {
299
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Handle a paste chunk: ingest image paths and/or macOS clipboard bitmap.
305
+ * Returns text to insert into the editor (paths removed) plus attachments.
306
+ */
307
+ function handleImagePaste(text = "", {
308
+ workspaceRoot = process.cwd(),
309
+ sessionId = "",
310
+ tryClipboard = true,
311
+ platform = process.platform,
312
+ execFile = execFileSync,
313
+ } = {}) {
314
+ const raw = String(text || "");
315
+ const paths = extractImagePathsFromPaste(raw);
316
+ const attachments = [];
317
+ const errors = [];
318
+
319
+ for (const sourcePath of paths) {
320
+ const ingested = ingestImageFile({ sourcePath, workspaceRoot, sessionId });
321
+ if (ingested.ok) attachments.push(ingested);
322
+ else errors.push(ingested.error || "ingest failed");
323
+ }
324
+
325
+ let remaining = stripExtractedPathsFromText(raw, paths);
326
+
327
+ // If paste had no usable text/paths, try clipboard PNG (Cmd+V of a screenshot).
328
+ const trimmedRemaining = remaining.trim();
329
+ const looksEmptyOrBinary = !trimmedRemaining
330
+ || /[\x00-\x08\x0e-\x1f]/.test(raw)
331
+ || (Buffer.byteLength(raw, "utf8") > 200 && paths.length === 0 && !/\s/.test(raw.slice(0, 40)));
332
+
333
+ if (tryClipboard && attachments.length === 0 && looksEmptyOrBinary) {
334
+ const clip = tryIngestClipboardImage({
335
+ workspaceRoot,
336
+ sessionId,
337
+ platform,
338
+ execFile,
339
+ });
340
+ if (clip.ok) {
341
+ attachments.push(clip);
342
+ remaining = "";
343
+ } else if (paths.length === 0 && !trimmedRemaining) {
344
+ errors.push(clip.error || "clipboard ingest failed");
345
+ }
346
+ }
347
+
348
+ return {
349
+ text: remaining,
350
+ attachments,
351
+ errors,
352
+ };
353
+ }
354
+
355
+ module.exports = {
356
+ IMAGE_EXT_RE,
357
+ uploadsDir,
358
+ safeBaseName,
359
+ extractImagePathsFromPaste,
360
+ stripExtractedPathsFromText,
361
+ formatImageLogLabel,
362
+ formatUserLogWithAttachments,
363
+ buildAttachedImagesPromptPrefix,
364
+ ingestImageFile,
365
+ tryIngestClipboardImage,
366
+ handleImagePaste,
367
+ };
@@ -27,6 +27,8 @@ const {
27
27
  clearUserPrompts,
28
28
  formatUserReminderMessage,
29
29
  ensurePendingUserPrompts,
30
+ shouldAutoContinuePlan,
31
+ buildPlanAutoContinueReminder,
30
32
  } = require("./context/userNudge");
31
33
  const {
32
34
  runAskUserTool,
@@ -50,12 +52,14 @@ const {
50
52
  } = require("./protocol");
51
53
  const { stableStringify } = require("./context/stableJson");
52
54
  const { getReadToolDescription } = require("../agents/prompts/native/toolDescriptions/read");
55
+ const { getReadImageToolDescription } = require("../agents/prompts/native/toolDescriptions/readImage");
53
56
  const { getWriteToolDescription } = require("../agents/prompts/native/toolDescriptions/write");
54
57
  const { getEditToolDescription } = require("../agents/prompts/native/toolDescriptions/edit");
55
58
  const { getBashToolDescription } = require("../agents/prompts/native/toolDescriptions/bash");
56
59
 
57
60
  const CORE_TOOL_NAMES = new Set([
58
61
  "read",
62
+ "read_image",
59
63
  "write",
60
64
  "edit",
61
65
  "bash",
@@ -64,7 +68,14 @@ const CORE_TOOL_NAMES = new Set([
64
68
  "task_run",
65
69
  "ask_user",
66
70
  ]);
67
- const EXECUTABLE_GRAPH_TOOLS = new Set(["read", "write", "edit", "bash", "artifact_read"]);
71
+ const EXECUTABLE_GRAPH_TOOLS = new Set([
72
+ "read",
73
+ "read_image",
74
+ "write",
75
+ "edit",
76
+ "bash",
77
+ "artifact_read",
78
+ ]);
68
79
  const CONTROL_PLANE_TOOLS = new Set(["plan_graph", "task_run"]);
69
80
  const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
70
81
  const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
@@ -76,6 +87,8 @@ const DEFAULT_KIMI_MODEL = "k3";
76
87
  const DEFAULT_MAX_NATIVE_TOOL_CALLS = 100;
77
88
  const DEFAULT_MAX_NATIVE_TOOL_ERRORS = 20;
78
89
  const DEFAULT_NATIVE_TIMEOUT_MS = 43200000; // 12 hours
90
+ /** Max text-only auto-continues while a plan is waiting on a task (per user submit). */
91
+ const DEFAULT_MAX_PLAN_AUTO_CONTINUES = 24;
79
92
  // Anthropic Messages rejects max_tokens above the model's real cap (64K on
80
93
  // current models), so the transports use different defaults. Override either
81
94
  // via UFOO_UCODE_MAX_TOKENS (positive integer).
@@ -392,6 +405,23 @@ function buildCoreToolSpecs() {
392
405
  },
393
406
  },
394
407
  },
408
+ {
409
+ type: "function",
410
+ function: {
411
+ name: "read_image",
412
+ description: getReadImageToolDescription(),
413
+ parameters: {
414
+ type: "object",
415
+ properties: {
416
+ path: {
417
+ type: "string",
418
+ description: "Workspace-relative path to a png, jpeg, gif, or webp image.",
419
+ },
420
+ },
421
+ required: ["path"],
422
+ },
423
+ },
424
+ },
395
425
  {
396
426
  type: "function",
397
427
  function: {
@@ -1733,6 +1763,9 @@ async function runNativeLoop({
1733
1763
  // materialize Provider messages yet. STRICT via UFOO_UCODE_PROTOCOL_STRICT=1.
1734
1764
  let activeLedger = null;
1735
1765
  let lastProtocolLedger = null;
1766
+ let planAutoContinues = 0;
1767
+ let lastAutoContinueWaitingId = "";
1768
+ let consecutiveEmptyAutoContinues = 0;
1736
1769
 
1737
1770
  if (resume) {
1738
1771
  await withFaultPoint("before_provider_resume", () => {});
@@ -1749,6 +1782,29 @@ async function runNativeLoop({
1749
1782
  messages.push({ role: "user", content });
1750
1783
  }
1751
1784
 
1785
+ function tryInjectPlanAutoContinue() {
1786
+ if (!shouldAutoContinuePlan(executionState)) return false;
1787
+ if (planAutoContinues >= DEFAULT_MAX_PLAN_AUTO_CONTINUES) return false;
1788
+ const waitingId = String(
1789
+ (executionState.planGraph && executionState.planGraph.waitingFor
1790
+ && executionState.planGraph.waitingFor.id) || ""
1791
+ ).trim();
1792
+ if (
1793
+ consecutiveEmptyAutoContinues >= 2
1794
+ && waitingId
1795
+ && waitingId === lastAutoContinueWaitingId
1796
+ ) {
1797
+ return false;
1798
+ }
1799
+ const reminder = buildPlanAutoContinueReminder(executionState);
1800
+ if (!reminder) return false;
1801
+ messages.push({ role: "user", content: reminder });
1802
+ planAutoContinues += 1;
1803
+ lastAutoContinueWaitingId = waitingId;
1804
+ consecutiveEmptyAutoContinues += 1;
1805
+ return true;
1806
+ }
1807
+
1752
1808
  while (true) {
1753
1809
  guards.ensureActive();
1754
1810
 
@@ -1820,6 +1876,9 @@ async function runNativeLoop({
1820
1876
  if (!aggregated.trim() && text) {
1821
1877
  aggregated = text;
1822
1878
  }
1879
+ if (tryInjectPlanAutoContinue()) {
1880
+ continue;
1881
+ }
1823
1882
  return {
1824
1883
  text: aggregated,
1825
1884
  streamed,
@@ -1831,6 +1890,9 @@ async function runNativeLoop({
1831
1890
  };
1832
1891
  }
1833
1892
 
1893
+ // A tool-using turn resets the empty auto-continue streak (progress possible).
1894
+ consecutiveEmptyAutoContinues = 0;
1895
+
1834
1896
  const pendingCalls = transport.prepareToolCalls({ messages, turnResult, toolCalls });
1835
1897
  if (!pendingCalls) {
1836
1898
  return {
@@ -1,6 +1,12 @@
1
1
  "use strict";
2
2
 
3
3
  const { assertTransport } = require("./transportContract");
4
+ const {
5
+ extractVisionPayload,
6
+ stripVisionBase64,
7
+ visionSummaryText,
8
+ toAnthropicImageBlock,
9
+ } = require("./visionBlocks");
4
10
 
5
11
  /**
6
12
  * Anthropic Messages API transport adapter.
@@ -70,11 +76,32 @@ function createAnthropicMessagesTransport(deps = {}) {
70
76
  }));
71
77
  },
72
78
  appendToolResult({ collected, call, toolResult }) {
79
+ const vision = extractVisionPayload(toolResult);
80
+ const isError = Boolean(!toolResult || toolResult.ok === false);
81
+ if (vision) {
82
+ const textPayload = stripVisionBase64(toolResult);
83
+ collected.push({
84
+ type: "tool_result",
85
+ tool_use_id: String(call.source.id || ""),
86
+ content: [
87
+ {
88
+ type: "text",
89
+ text: clipText(
90
+ `${visionSummaryText(vision, toolResult)}\n${toJsonString(textPayload)}`,
91
+ 12000,
92
+ ),
93
+ },
94
+ toAnthropicImageBlock(vision),
95
+ ],
96
+ is_error: isError,
97
+ });
98
+ return;
99
+ }
73
100
  collected.push({
74
101
  type: "tool_result",
75
102
  tool_use_id: String(call.source.id || ""),
76
103
  content: clipText(toJsonString(toolResult), 12000),
77
- is_error: Boolean(!toolResult || toolResult.ok === false),
104
+ is_error: isError,
78
105
  });
79
106
  },
80
107
  flushToolResults({ messages, collected }) {
@@ -5,4 +5,5 @@ module.exports = {
5
5
  ...require("./openaiChatTransport"),
6
6
  ...require("./anthropicMessagesTransport"),
7
7
  ...require("./modelsCatalog"),
8
+ ...require("./visionBlocks"),
8
9
  };
@@ -2,6 +2,12 @@
2
2
 
3
3
  const { randomUUID } = require("crypto");
4
4
  const { assertTransport } = require("./transportContract");
5
+ const {
6
+ extractVisionPayload,
7
+ stripVisionBase64,
8
+ visionSummaryText,
9
+ toOpenAiImagePart,
10
+ } = require("./visionBlocks");
5
11
 
6
12
  /**
7
13
  * OpenAI-compatible chat-completions transport adapter.
@@ -82,11 +88,23 @@ function createOpenAiChatTransport(deps = {}) {
82
88
  }));
83
89
  },
84
90
  appendToolResult({ messages, call, toolResult }) {
91
+ const vision = extractVisionPayload(toolResult);
92
+ const payload = vision ? stripVisionBase64(toolResult) : toolResult;
85
93
  messages.push({
86
94
  role: "tool",
87
95
  tool_call_id: call.source.id,
88
- content: clipText(toJsonString(toolResult), 12000),
96
+ content: clipText(toJsonString(payload), 12000),
89
97
  });
98
+ if (vision) {
99
+ const imagePart = toOpenAiImagePart(vision);
100
+ messages.push({
101
+ role: "user",
102
+ content: [
103
+ { type: "text", text: visionSummaryText(vision, toolResult) },
104
+ imagePart,
105
+ ],
106
+ });
107
+ }
90
108
  },
91
109
  };
92
110
 
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Helpers for expanding read_image tool results into provider vision blocks.
5
+ */
6
+
7
+ function extractVisionPayload(toolResult = null) {
8
+ if (!toolResult || typeof toolResult !== "object") return null;
9
+ const base64 = String(toolResult.base64 || "").trim();
10
+ const mediaType = String(toolResult.mediaType || "").trim().toLowerCase();
11
+ if (!base64 || !mediaType.startsWith("image/")) return null;
12
+ if (toolResult.ok === false) return null;
13
+ return {
14
+ path: String(toolResult.path || "").trim(),
15
+ mediaType,
16
+ bytes: Number.isFinite(toolResult.bytes) ? toolResult.bytes : null,
17
+ base64,
18
+ artifactId: String(toolResult.artifactId || "").trim(),
19
+ };
20
+ }
21
+
22
+ function isVisionToolResult(toolResult = null) {
23
+ return Boolean(extractVisionPayload(toolResult));
24
+ }
25
+
26
+ function stripVisionBase64(value) {
27
+ if (Array.isArray(value)) {
28
+ return value.map((item) => stripVisionBase64(item));
29
+ }
30
+ if (!value || typeof value !== "object") return value;
31
+ const out = {};
32
+ for (const [key, entry] of Object.entries(value)) {
33
+ if (key === "base64") continue;
34
+ out[key] = stripVisionBase64(entry);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ function visionSummaryText(vision = null, toolResult = null) {
40
+ const pathText = (vision && vision.path) || (toolResult && toolResult.path) || "image";
41
+ const mediaType = (vision && vision.mediaType) || (toolResult && toolResult.mediaType) || "image/*";
42
+ const bytes = (vision && vision.bytes) != null
43
+ ? vision.bytes
44
+ : (toolResult && toolResult.bytes);
45
+ const parts = [
46
+ `Image loaded for vision: ${pathText}`,
47
+ `mediaType=${mediaType}`,
48
+ ];
49
+ if (Number.isFinite(bytes)) parts.push(`bytes=${bytes}`);
50
+ if (vision && vision.artifactId) parts.push(`artifactId=${vision.artifactId}`);
51
+ parts.push("Visual content is attached for this model call only; call read_image again later if needed.");
52
+ return parts.join(" | ");
53
+ }
54
+
55
+ function toAnthropicImageBlock(vision = null) {
56
+ if (!vision) return null;
57
+ return {
58
+ type: "image",
59
+ source: {
60
+ type: "base64",
61
+ media_type: vision.mediaType,
62
+ data: vision.base64,
63
+ },
64
+ };
65
+ }
66
+
67
+ function toOpenAiImagePart(vision = null) {
68
+ if (!vision) return null;
69
+ return {
70
+ type: "image_url",
71
+ image_url: {
72
+ url: `data:${vision.mediaType};base64,${vision.base64}`,
73
+ },
74
+ };
75
+ }
76
+
77
+ function degradeVisionContent(content) {
78
+ if (typeof content === "string") return content;
79
+ if (!Array.isArray(content)) return content;
80
+ const texts = [];
81
+ for (const block of content) {
82
+ if (!block || typeof block !== "object") continue;
83
+ const type = String(block.type || "").trim().toLowerCase();
84
+ if (type === "text" && block.text) {
85
+ texts.push(String(block.text));
86
+ continue;
87
+ }
88
+ if (type === "image" || type === "image_url") {
89
+ const pathHint = block.path
90
+ || (block.source && block.source.path)
91
+ || "";
92
+ texts.push(pathHint ? `[image: ${pathHint}]` : "[image]");
93
+ continue;
94
+ }
95
+ if (type === "tool_result" && Array.isArray(block.content)) {
96
+ texts.push(degradeVisionContent(block.content));
97
+ }
98
+ }
99
+ return texts.filter(Boolean).join("\n") || "[multimodal content]";
100
+ }
101
+
102
+ module.exports = {
103
+ extractVisionPayload,
104
+ isVisionToolResult,
105
+ stripVisionBase64,
106
+ visionSummaryText,
107
+ toAnthropicImageBlock,
108
+ toOpenAiImagePart,
109
+ degradeVisionContent,
110
+ };