u-foo 3.0.2 → 3.0.3

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.
@@ -50,12 +50,14 @@ const {
50
50
  } = require("./protocol");
51
51
  const { stableStringify } = require("./context/stableJson");
52
52
  const { getReadToolDescription } = require("../agents/prompts/native/toolDescriptions/read");
53
+ const { getReadImageToolDescription } = require("../agents/prompts/native/toolDescriptions/readImage");
53
54
  const { getWriteToolDescription } = require("../agents/prompts/native/toolDescriptions/write");
54
55
  const { getEditToolDescription } = require("../agents/prompts/native/toolDescriptions/edit");
55
56
  const { getBashToolDescription } = require("../agents/prompts/native/toolDescriptions/bash");
56
57
 
57
58
  const CORE_TOOL_NAMES = new Set([
58
59
  "read",
60
+ "read_image",
59
61
  "write",
60
62
  "edit",
61
63
  "bash",
@@ -64,7 +66,14 @@ const CORE_TOOL_NAMES = new Set([
64
66
  "task_run",
65
67
  "ask_user",
66
68
  ]);
67
- const EXECUTABLE_GRAPH_TOOLS = new Set(["read", "write", "edit", "bash", "artifact_read"]);
69
+ const EXECUTABLE_GRAPH_TOOLS = new Set([
70
+ "read",
71
+ "read_image",
72
+ "write",
73
+ "edit",
74
+ "bash",
75
+ "artifact_read",
76
+ ]);
68
77
  const CONTROL_PLANE_TOOLS = new Set(["plan_graph", "task_run"]);
69
78
  const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
70
79
  const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
@@ -392,6 +401,23 @@ function buildCoreToolSpecs() {
392
401
  },
393
402
  },
394
403
  },
404
+ {
405
+ type: "function",
406
+ function: {
407
+ name: "read_image",
408
+ description: getReadImageToolDescription(),
409
+ parameters: {
410
+ type: "object",
411
+ properties: {
412
+ path: {
413
+ type: "string",
414
+ description: "Workspace-relative path to a png, jpeg, gif, or webp image.",
415
+ },
416
+ },
417
+ required: ["path"],
418
+ },
419
+ },
420
+ },
395
421
  {
396
422
  type: "function",
397
423
  function: {
@@ -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
+ };
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { resolveWorkspacePath } = require("./common");
6
+
7
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
8
+
9
+ const EXT_MEDIA = Object.freeze({
10
+ ".png": "image/png",
11
+ ".jpg": "image/jpeg",
12
+ ".jpeg": "image/jpeg",
13
+ ".gif": "image/gif",
14
+ ".webp": "image/webp",
15
+ });
16
+
17
+ function sniffMediaType(buffer = Buffer.alloc(0)) {
18
+ if (!Buffer.isBuffer(buffer) || buffer.length < 12) return "";
19
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
20
+ return "image/png";
21
+ }
22
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
23
+ return "image/jpeg";
24
+ }
25
+ if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
26
+ return "image/gif";
27
+ }
28
+ if (
29
+ buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46
30
+ && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50
31
+ ) {
32
+ return "image/webp";
33
+ }
34
+ return "";
35
+ }
36
+
37
+ function mediaTypeFromPath(filePath = "") {
38
+ const ext = path.extname(String(filePath || "")).toLowerCase();
39
+ return EXT_MEDIA[ext] || "";
40
+ }
41
+
42
+ function runReadImageTool(input = {}, options = {}) {
43
+ try {
44
+ const filePath = String(input.path || input.file || "").trim();
45
+ if (!filePath) {
46
+ return { ok: false, error: "path is required" };
47
+ }
48
+ const { workspaceRoot, resolved } = resolveWorkspacePath(
49
+ options.workspaceRoot,
50
+ filePath,
51
+ options.cwd,
52
+ );
53
+ const stat = fs.statSync(resolved);
54
+ if (!stat.isFile()) {
55
+ return { ok: false, error: `not a file: ${resolved}` };
56
+ }
57
+ if (stat.size > MAX_IMAGE_BYTES) {
58
+ return {
59
+ ok: false,
60
+ error: `image too large (${stat.size} bytes); max ${MAX_IMAGE_BYTES} bytes — compress or resize first`,
61
+ path: resolved,
62
+ bytes: stat.size,
63
+ };
64
+ }
65
+
66
+ const buffer = fs.readFileSync(resolved);
67
+ const sniffed = sniffMediaType(buffer);
68
+ const fromExt = mediaTypeFromPath(resolved);
69
+ const mediaType = sniffed || fromExt;
70
+ if (!mediaType) {
71
+ return {
72
+ ok: false,
73
+ error: "unsupported image type (use png, jpeg, gif, or webp)",
74
+ path: resolved,
75
+ bytes: buffer.length,
76
+ };
77
+ }
78
+ if (fromExt && sniffed && fromExt !== sniffed) {
79
+ return {
80
+ ok: false,
81
+ error: `image type mismatch: extension suggests ${fromExt}, bytes are ${sniffed}`,
82
+ path: resolved,
83
+ bytes: buffer.length,
84
+ };
85
+ }
86
+
87
+ return {
88
+ ok: true,
89
+ kind: "image",
90
+ workspaceRoot,
91
+ path: resolved,
92
+ mediaType,
93
+ bytes: buffer.length,
94
+ base64: buffer.toString("base64"),
95
+ };
96
+ } catch (err) {
97
+ return {
98
+ ok: false,
99
+ error: err && err.message ? err.message : "read_image failed",
100
+ };
101
+ }
102
+ }
103
+
104
+ module.exports = {
105
+ MAX_IMAGE_BYTES,
106
+ EXT_MEDIA,
107
+ sniffMediaType,
108
+ mediaTypeFromPath,
109
+ runReadImageTool,
110
+ };
@@ -38,10 +38,14 @@ const STATUS_INDICATORS = {
38
38
  // Keep this list in sync with the keys handled by buildMergedToolSummaryText.
39
39
  const TOOL_LABELS = {
40
40
  read: "Reading file",
41
+ read_image: "Reading image",
41
42
  write: "Writing file",
42
43
  edit: "Editing file",
43
44
  bash: "Running command",
44
45
  artifact_read: "Reading artifact",
46
+ plan_graph: "Updating plan",
47
+ task_run: "Managing task",
48
+ ask_user: "Asking user",
45
49
  };
46
50
 
47
51
  const ANSI_PATTERN = /\x1B\[[0-9;?]*[ -/]*[@-~]/g;
@@ -283,7 +287,15 @@ function messageContentText(message = {}) {
283
287
  if (Array.isArray(content)) {
284
288
  return content.map((part) => {
285
289
  if (typeof part === "string") return part;
286
- if (part && typeof part === "object" && part.text != null) return String(part.text);
290
+ if (!part || typeof part !== "object") return "";
291
+ const type = String(part.type || "").trim().toLowerCase();
292
+ if (type === "image" || type === "image_url") {
293
+ const name = part.fileName
294
+ || (part.path ? require("path").basename(String(part.path)) : "")
295
+ || "image";
296
+ return `[image: ${name}]`;
297
+ }
298
+ if (part.text != null) return String(part.text);
287
299
  return "";
288
300
  }).join("");
289
301
  }
@@ -296,15 +308,47 @@ function toolMessagePreview(message = {}) {
296
308
  try {
297
309
  const parsed = JSON.parse(raw);
298
310
  if (parsed && typeof parsed === "object") {
311
+ if (parsed.kind === "image" || parsed.base64 || parsed.mediaType) {
312
+ const name = require("path").basename(String(parsed.path || parsed.fileName || "image"));
313
+ return parsed.preview || `[image: ${name}]`;
314
+ }
299
315
  if (parsed.preview) return String(parsed.preview);
300
316
  if (parsed.artifactId) return `artifact:${parsed.artifactId}`;
317
+ if (parsed.base64) {
318
+ const clone = { ...parsed };
319
+ delete clone.base64;
320
+ return JSON.stringify(clone);
321
+ }
301
322
  }
302
323
  } catch {
303
324
  // plain tool text
304
325
  }
326
+ if (/data:image\/[a-zA-Z+]+;base64,/.test(raw) || /"base64"\s*:/.test(raw)) {
327
+ return "[image]";
328
+ }
305
329
  return raw;
306
330
  }
307
331
 
332
+ /**
333
+ * Collapse attached-image prompt prefixes / base64 blobs for TUI log display.
334
+ */
335
+ function redactUserMessageForLog(text = "") {
336
+ let out = String(text || "");
337
+ out = out.replace(
338
+ /\[Attached images[^\]]*\]\s*(?:-\s*.+\n?)*/gi,
339
+ (block) => {
340
+ const paths = [...block.matchAll(/^\s*-\s*(.+)$/gm)].map((m) => String(m[1] || "").trim());
341
+ if (paths.length === 0) return "[image]";
342
+ return paths
343
+ .map((p) => `[image: ${require("path").basename(p)}]`)
344
+ .join(" ");
345
+ },
346
+ );
347
+ out = out.replace(/data:image\/[a-zA-Z+]+;base64,[A-Za-z0-9+/=\s]+/g, "[image]");
348
+ out = out.replace(/"base64"\s*:\s*"[^"]*"/g, '"base64":"[redacted]"');
349
+ return out.replace(/\n{3,}/g, "\n\n").trim();
350
+ }
351
+
308
352
  /**
309
353
  * Convert persisted nlMessages into ucode TUI log rows for resume/history.
310
354
  * Applies the shared ANSI markdown renderer so restored assistant text matches
@@ -349,7 +393,7 @@ function buildUcodeSessionLogEntries(messages = [], options = {}) {
349
393
  if (!message || typeof message !== "object") continue;
350
394
  const role = String(message.role || "").trim().toLowerCase();
351
395
  if (role === "user") {
352
- const text = messageContentText(message);
396
+ const text = redactUserMessageForLog(messageContentText(message));
353
397
  if (!text.trim()) continue;
354
398
  const lines = text.split(/\r?\n/);
355
399
  lines.forEach((line, index) => {
@@ -671,6 +715,12 @@ function normalizeToolLogDetail(tool = "", args = {}, payload = {}) {
671
715
  return shortenPathDetail(pathText);
672
716
  }
673
717
 
718
+ if (name === "read_image") {
719
+ const pathText = String(argObj.path || resObj.path || resObj.fileName || "").trim();
720
+ const base = pathText ? require("path").basename(pathText) : "image";
721
+ return `[image: ${base}]`;
722
+ }
723
+
674
724
  if (name === "artifact_read") {
675
725
  const artifactId = String(argObj.artifactId || argObj.id || resObj.artifactId || "").trim();
676
726
  const rangeBits = [];
@@ -1295,6 +1345,9 @@ module.exports = {
1295
1345
  normalizeModelLabel,
1296
1346
  normalizeToolLogDetail,
1297
1347
  normalizeToolMergeEntry,
1348
+ messageContentText,
1349
+ toolMessagePreview,
1350
+ redactUserMessageForLog,
1298
1351
  parseActiveAgentsFromBusStatus,
1299
1352
  planAgentsFooter,
1300
1353
  planProjectsRail,
@@ -25,6 +25,11 @@
25
25
  * component (e.g. completion popup) can
26
26
  * handle them. Plain editing keys still work.
27
27
  * placeholder (string) rendered in gray when value is empty
28
+ * onPasteText(filtered) optional. Called for non-empty paste/insert
29
+ * chunks before insertText. May return:
30
+ * string — insert that text instead
31
+ * { text } — insert text (may be "")
32
+ * null/undefined — fall back to filtered text
28
33
  *
29
34
  * Newlines: Enter submits. Use Alt+Enter (delivered as meta+Return) or end the
30
35
  * line with `\` (the legacy continuation trick) to insert a literal newline.
@@ -32,7 +37,8 @@
32
37
  * plain Enter, so it would silently submit.
33
38
  *
34
39
  * Bracketed paste arrives as a multi-byte `input` chunk in useInput; we route
35
- * it through insertText, so multi-line paste already works without extra code.
40
+ * it through insertText (or onPasteText), so multi-line paste already works
41
+ * without extra code.
36
42
  */
37
43
 
38
44
  const fmt = require("../format");
@@ -167,6 +173,7 @@ function createMultilineInput({ React, ink }) {
167
173
  // the IME composition window pops up at the visible (inverse) cursor
168
174
  // instead of at the bottom of the screen.
169
175
  linesBelowInput = 0,
176
+ onPasteText = null,
170
177
  }) {
171
178
  // Cursor is owned by this component. preferredCol tracks the visual
172
179
  // column we want to keep when bouncing across lines of different widths
@@ -392,7 +399,36 @@ function createMultilineInput({ React, ink }) {
392
399
  // Plain character / paste. Filter control bytes.
393
400
  if (input && !key.ctrl && !key.meta) {
394
401
  const filtered = input.replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]/g, "");
395
- if (filtered) insertText(filtered);
402
+ // Binary clipboard paste can strip to empty — still notify parent so
403
+ // it can try macOS PNGf clipboard ingest.
404
+ if (!filtered) {
405
+ if (typeof onPasteText === "function") {
406
+ try { onPasteText(""); } catch { /* ignore */ }
407
+ }
408
+ return;
409
+ }
410
+ if (typeof onPasteText === "function" && (filtered.length > 1 || filtered.includes("\n"))) {
411
+ let rewritten;
412
+ try {
413
+ rewritten = onPasteText(filtered);
414
+ } catch {
415
+ rewritten = filtered;
416
+ }
417
+ if (rewritten == null) {
418
+ insertText(filtered);
419
+ return;
420
+ }
421
+ if (typeof rewritten === "string") {
422
+ if (rewritten) insertText(rewritten);
423
+ return;
424
+ }
425
+ if (rewritten && typeof rewritten === "object") {
426
+ const nextText = rewritten.text == null ? filtered : String(rewritten.text);
427
+ if (nextText) insertText(nextText);
428
+ return;
429
+ }
430
+ }
431
+ insertText(filtered);
396
432
  }
397
433
  }, { isActive: interactive });
398
434