@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.
- package/gonext_agent_chat.py +66 -13
- package/image-paste.mjs +55 -8
- package/package.json +1 -1
- package/scripts/image-paste-check.mjs +1 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -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,
|
|
7130
|
-
out = []
|
|
7170
|
+
"""[(label, path)] → (PIL images ready for Claude, [(label, reason)] for the ones that failed)."""
|
|
7171
|
+
out, failed = [], []
|
|
7131
7172
|
try:
|
|
7132
|
-
|
|
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
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
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
|
-
|
|
12812
|
-
|
|
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, {
|
|
25
|
+
import path, { join } from "node:path";
|
|
26
26
|
|
|
27
27
|
export const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
28
|
-
/**
|
|
29
|
-
|
|
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
|
|
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 —
|
|
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)
|
|
152
|
-
|
|
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.
|
|
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], {
|