@tiens.nguyen/gu-cli 1.0.752 → 1.0.753

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.
@@ -7125,21 +7125,69 @@ def _take_image_markers(messages, images_dir=None):
7125
7125
  return found
7126
7126
 
7127
7127
 
7128
+ # WHAT CLAUDE WILL TAKE (task #285). smolagents re-encodes every image as PNG, base64, before
7129
+ # sending (smolagents.utils.encode_image_base64) — so a 4 MB JPEG photo can become a PNG over the
7130
+ # 5 MB per-image limit, and then Claude rejects the WHOLE request, not just the image. Claude
7131
+ # also scales anything with a long edge over 1568 px down to that (and rejects > 8000 px), so
7132
+ # shrinking first sends the same picture Claude would see, in fewer bytes.
7133
+ _IMAGE_MAX_EDGE = 1568
7134
+ _IMAGE_MAX_B64 = 5 * 1024 * 1024 # measured on the base64 string — the stricter reading
7135
+
7136
+
7137
+ def _png_b64_len(im) -> int:
7138
+ import io
7139
+ buf = io.BytesIO()
7140
+ im.save(buf, format="PNG")
7141
+ return (len(buf.getvalue()) + 2) // 3 * 4
7142
+
7143
+
7144
+ def _prepare_image(path):
7145
+ """Open `path` and return a PIL image Claude will accept, or raise ValueError(reason)."""
7146
+ from PIL import Image
7147
+ try:
7148
+ with Image.open(path) as src:
7149
+ src.load() # a truncated or non-image file fails here, not later in the request
7150
+ im = src.copy()
7151
+ except Image.DecompressionBombError:
7152
+ raise ValueError("too many pixels to open safely") from None
7153
+ except Exception as e: # noqa: BLE001
7154
+ raise ValueError(f"not a readable image ({type(e).__name__})") from None
7155
+ # Colour modes PNG cannot hold, or holds oddly: CMYK photos, 16-bit, float, palette.
7156
+ if im.mode == "P":
7157
+ im = im.convert("RGBA" if "transparency" in im.info else "RGB")
7158
+ elif im.mode not in ("RGB", "RGBA", "L", "LA"):
7159
+ im = im.convert("RGBA" if "A" in im.mode else "RGB")
7160
+ if max(im.size) > _IMAGE_MAX_EDGE:
7161
+ im.thumbnail((_IMAGE_MAX_EDGE, _IMAGE_MAX_EDGE), Image.LANCZOS)
7162
+ while _png_b64_len(im) > _IMAGE_MAX_B64:
7163
+ if max(im.size) <= 256:
7164
+ raise ValueError("still over 5 MB after shrinking")
7165
+ im = im.resize((max(1, im.width * 3 // 4), max(1, im.height * 3 // 4)), Image.LANCZOS)
7166
+ return im
7167
+
7168
+
7128
7169
  def _load_turn_images(refs):
7129
- """[(label, path)] → PIL images, skipping any that will not open."""
7130
- out = []
7170
+ """[(label, path)] → (PIL images ready for Claude, [(label, reason)] for the ones that failed)."""
7171
+ out, failed = [], []
7131
7172
  try:
7132
- from PIL import Image
7173
+ import PIL # noqa: F401
7133
7174
  except Exception: # noqa: BLE001
7134
- return out
7175
+ return out, [(label, "image support (Pillow) is not installed") for label, _ in refs]
7135
7176
  for label, path in refs:
7136
7177
  try:
7137
- with Image.open(path) as im:
7138
- im.load()
7139
- out.append(im.copy())
7140
- except Exception as e: # noqa: BLE001
7141
- _log(f"image {label}: could not open ({type(e).__name__})")
7142
- return out
7178
+ out.append(_prepare_image(path))
7179
+ except ValueError as e:
7180
+ failed.append((label, str(e)))
7181
+ _log(f"image {label}: not attached — {e}")
7182
+ return out, failed
7183
+
7184
+
7185
+ def _label_failed_images(text, failed):
7186
+ """Say which images did not make it, where the model reads their labels — a bare
7187
+ "[Image #1]" with nothing attached invites the model to guess what it shows."""
7188
+ for label, reason in failed:
7189
+ text = text.replace(label, f"{label} (could not be opened: {reason})")
7190
+ return text
7143
7191
 
7144
7192
 
7145
7193
  def run_agent_chat(cfg):
@@ -12807,9 +12855,14 @@ def run_agent_chat(cfg):
12807
12855
  _turn_images = []
12808
12856
  if _turn_image_refs:
12809
12857
  if _is_anthropic_url(coding_base_url):
12810
- _turn_images = _load_turn_images(_turn_image_refs)
12811
- _log(f"attached {len(_turn_images)} image(s) to the question")
12812
- _emit({"type": "step", "text": f"Sending {len(_turn_images)} image(s) with the question"})
12858
+ _turn_images, _img_failed = _load_turn_images(_turn_image_refs)
12859
+ task_with_hint = _label_failed_images(task_with_hint, _img_failed)
12860
+ _log(f"attached {len(_turn_images)} image(s) to the question"
12861
+ + (f", {len(_img_failed)} could not be opened" if _img_failed else ""))
12862
+ if _turn_images:
12863
+ _emit({"type": "step", "text": f"Sending {len(_turn_images)} image(s) with the question"})
12864
+ for _lbl, _why in _img_failed:
12865
+ _emit({"type": "step", "text": f"⚠ {_lbl} not sent — {_why}"})
12813
12866
  else:
12814
12867
  task_with_hint += ("\n\n(The user pasted " + ", ".join(l for l, _ in _turn_image_refs)
12815
12868
  + " but this coding model is not sent images — say so if the "
package/image-paste.mjs CHANGED
@@ -20,13 +20,47 @@
20
20
  * most reliable way; Windows Terminal quotes a path with spaces.
21
21
  */
22
22
  import { spawnSync } from "node:child_process";
23
- import { chmodSync, copyFileSync, existsSync, mkdirSync, statSync } from "node:fs";
23
+ import { chmodSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readSync, rmSync, statSync } from "node:fs";
24
24
  import { homedir } from "node:os";
25
- import path, { extname, join } from "node:path";
25
+ import path, { join } from "node:path";
26
26
 
27
27
  export const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
28
- /** Anthropic's limit per image. A bigger one would fail the whole request. */
29
- export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
28
+ /**
29
+ * The largest file gu takes at paste time. NOT Claude's 5 MB limit: the agent shrinks every image
30
+ * to what Claude accepts before sending (task #285), so a 12 MB photo is fine. This only stops a
31
+ * file far too big to be a screenshot from being copied around.
32
+ */
33
+ export const MAX_IMAGE_BYTES = 30 * 1024 * 1024;
34
+
35
+ /**
36
+ * What the file's first bytes say it is — "png" | "jpeg" | "gif" | "webp" | "" (task #285). The
37
+ * EXTENSION is only a name: a renamed text file, or a screenshot still being written, has the
38
+ * right one and is not an image.
39
+ */
40
+ export function imageKind(bytes) {
41
+ const b = Buffer.from(bytes ?? []);
42
+ if (b.length >= 8 && b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png";
43
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "jpeg";
44
+ if (b.length >= 6 && /^GIF8[79]a$/.test(b.subarray(0, 6).toString("latin1"))) return "gif";
45
+ if (b.length >= 12 && b.subarray(0, 4).toString("latin1") === "RIFF" && b.subarray(8, 12).toString("latin1") === "WEBP") return "webp";
46
+ return "";
47
+ }
48
+
49
+ function fileImageKind(path) {
50
+ let fd;
51
+ try {
52
+ fd = openSync(path, "r");
53
+ const buf = Buffer.alloc(12);
54
+ const n = readSync(fd, buf, 0, 12, 0);
55
+ return imageKind(buf.subarray(0, n));
56
+ } catch {
57
+ return "";
58
+ } finally {
59
+ if (fd !== undefined) closeSync(fd);
60
+ }
61
+ }
62
+
63
+ const NOT_AN_IMAGE = "that file is not an image (png, jpg, gif or webp)";
30
64
 
31
65
  export const imagesDir = (home = homedir()) => join(home, ".gonext", "images");
32
66
 
@@ -97,13 +131,15 @@ export function storeImageFile(src, { home = homedir() } = {}) {
97
131
  return { error: `cannot read ${src}` };
98
132
  }
99
133
  if (size > MAX_IMAGE_BYTES) return { error: tooBig(size) };
100
- const dest = newImagePath(extname(src).toLowerCase() === ".jpeg" ? ".jpg" : extname(src).toLowerCase(), home);
134
+ const kind = fileImageKind(src);
135
+ if (!kind) return { error: NOT_AN_IMAGE };
136
+ const dest = newImagePath(kind === "jpeg" ? ".jpg" : `.${kind}`, home);
101
137
  copyFileSync(src, dest);
102
138
  chmodSync(dest, 0o600);
103
139
  return { path: dest };
104
140
  }
105
141
 
106
- const tooBig = (n) => `the image is ${(n / 1048576).toFixed(1)} MB — Claude accepts up to 5 MB`;
142
+ const tooBig = (n) => `the image is ${(n / 1048576).toFixed(1)} MB — gu takes up to ${MAX_IMAGE_BYTES / 1048576} MB`;
107
143
 
108
144
  /** The command that writes the clipboard's image to `dest` as PNG, per platform (null = none). */
109
145
  export function clipboardImageCommand(dest, platform = process.platform) {
@@ -148,8 +184,19 @@ export function clipboardImageToFile({ home = homedir(), platform = process.plat
148
184
  } catch {
149
185
  /* nothing written */
150
186
  }
151
- if (r?.status !== 0 || size === 0) return { none: true };
152
- if (size > MAX_IMAGE_BYTES) return { error: tooBig(size) };
187
+ if (r?.status !== 0 || size === 0) {
188
+ rmSync(dest, { force: true });
189
+ return { none: true };
190
+ }
191
+ if (size > MAX_IMAGE_BYTES) {
192
+ rmSync(dest, { force: true });
193
+ return { error: tooBig(size) };
194
+ }
195
+ // A file copied in Explorer is copied as-is, so it can be anything with an image's name.
196
+ if (!fileImageKind(dest)) {
197
+ rmSync(dest, { force: true });
198
+ return { error: "the clipboard's file is not an image (png, jpg, gif or webp)" };
199
+ }
153
200
  chmodSync(dest, 0o600);
154
201
  return { path: dest };
155
202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.752",
3
+ "version": "1.0.753",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -62,7 +62,7 @@ const snippet = [
62
62
  "spec = importlib.util.spec_from_file_location('a', sys.argv[1]); a = importlib.util.module_from_spec(spec); spec.loader.exec_module(a)",
63
63
  "msgs = [{'role': 'user', 'content': 'look [Image #1: ' + sys.argv[2] + '] and [Image #2: ' + sys.argv[3] + ']'}]",
64
64
  "refs = a._take_image_markers(msgs)",
65
- "imgs = a._load_turn_images(refs)",
65
+ "imgs, failed = a._load_turn_images(refs)",
66
66
  "print(json.dumps({'refs': refs, 'text': msgs[0]['content'], 'opened': len(imgs)}))",
67
67
  ].join("\n");
68
68
  const r = spawnSync(py.cmd, [...py.args, "-c", snippet, join(WORKER, "gonext_agent_chat.py"), stored.path ?? "", shot], {