dsh-tool-vision 0.5.0 → 0.6.1

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,2481 @@
1
+ // dsh-tool-vision — pixel-level vision tools (ported from dsh-vision-router).
2
+ //
3
+ // The 14 vision_* tools share ONE configured OpenAI-compatible endpoint
4
+ // (the plugin's own baseURL/apiKey/model settings — the same GLM endpoint
5
+ // inspect_image uses). No provider chain, no local models, no fallbacks:
6
+ // the router's tool *semantics* (prompts, parameter contracts, pixel
7
+ // post-processing) are kept; its routing/resilience machinery is not.
8
+ //
9
+ // Everything here is unit-testable without a harness: the only injected
10
+ // dependencies are `ctx` (attachments/fs services), `getConfig` (live
11
+ // settings) and `exec` (tool execution context).
12
+
13
+ import { createHash } from 'node:crypto'
14
+ import { join, dirname, isAbsolute, relative as relativePath, sep, win32 as winPath } from 'node:path'
15
+ import { existsSync } from 'node:fs'
16
+ import { mkdir as mkdirP, writeFile as writeFileP, readFile as readFileP, rm as rmP, stat as statP } from 'node:fs/promises'
17
+ import { Worker } from 'node:worker_threads'
18
+ import { execFile } from 'node:child_process'
19
+ import { promisify } from 'node:util'
20
+ import { pathToFileURL } from 'node:url'
21
+ import { createRequire } from 'node:module'
22
+
23
+ // ── image formats ──────────────────────────────────────────────────────────
24
+
25
+ export const IMAGE_EXTENSIONS = {
26
+ png: 'image/png',
27
+ jpg: 'image/jpeg',
28
+ jpeg: 'image/jpeg',
29
+ webp: 'image/webp',
30
+ gif: 'image/gif',
31
+ bmp: 'image/bmp',
32
+ avif: 'image/avif',
33
+ svg: 'image/svg+xml',
34
+ ico: 'image/x-icon',
35
+ }
36
+
37
+ const EXT_BY_MEDIA = {
38
+ 'image/png': '.png',
39
+ 'image/jpeg': '.jpg',
40
+ 'image/webp': '.webp',
41
+ 'image/gif': '.gif',
42
+ 'image/bmp': '.bmp',
43
+ 'image/avif': '.avif',
44
+ 'image/svg+xml': '.svg',
45
+ 'image/x-icon': '.ico',
46
+ }
47
+
48
+ export function mediaTypeOf(path) {
49
+ const match = String(path).toLowerCase().match(/\.([a-z0-9]+)$/)
50
+ return match ? IMAGE_EXTENSIONS[match[1]] : undefined
51
+ }
52
+
53
+ /**
54
+ * Detect the image format from magic bytes instead of the file extension.
55
+ * Attachments are stored as content-addressed files WITHOUT an extension,
56
+ * so extension-based detection rejects them; the pixel tools must sniff.
57
+ */
58
+ export function sniffMediaType(bytes) {
59
+ if (!bytes || bytes.length < 12) return undefined
60
+ const head = (offset, count) => {
61
+ const parts = []
62
+ for (let i = offset; i < offset + count; i++) parts.push(bytes[i].toString(16).padStart(2, '0'))
63
+ return parts.join('')
64
+ }
65
+ if (head(0, 8) === '89504e470d0a1a0a') return 'image/png'
66
+ if (head(0, 3) === 'ffd8ff') return 'image/jpeg'
67
+ const riff = head(0, 4)
68
+ const webp = head(8, 4)
69
+ if (riff === '52494646' && webp === '57454250') return 'image/webp'
70
+ if (riff === '47494638') return 'image/gif' // GIF87a / GIF89a
71
+ return undefined
72
+ }
73
+
74
+ // ── input resolution ───────────────────────────────────────────────────────
75
+
76
+ export function basenameOf(path) {
77
+ const parts = String(path).split('/')
78
+ return parts[parts.length - 1] || undefined
79
+ }
80
+
81
+ /**
82
+ * True when the string is a durable attachment id such as "sha256:<hex>" —
83
+ * the form the harness uses for uploaded images.
84
+ */
85
+ export function isAttachmentIdInput(input) {
86
+ return typeof input === 'string' && /^[a-z0-9]+:[0-9a-f]{32,}$/i.test(input.trim())
87
+ }
88
+
89
+ /** Cross-platform absolute-path test (posix drive-relative forms included). */
90
+ export function isAbsolutePath(input) {
91
+ const value = String(input ?? '')
92
+ return isAbsolute(value) || winPath.isAbsolute(value)
93
+ }
94
+
95
+ /** Largest local image accepted by the pixel tools, in bytes. */
96
+ export const MAX_IMAGE_BYTES = 20 * 1024 * 1024
97
+
98
+ /** Resolve one tool argument (local path or attachment id) to image bytes. */
99
+ export async function readImageBytes(ctx, exec, imagePath) {
100
+ const input = String(imagePath ?? '')
101
+ let bytes
102
+ let storedMediaType
103
+ if (isAttachmentIdInput(input)) {
104
+ const attachments = ctx.get('attachments')
105
+ if (attachments === undefined) {
106
+ throw new Error('tool-vision: the attachment service is not available in this deployment')
107
+ }
108
+ const session = exec && exec.agent && exec.agent.session
109
+ const ref = lookupAttachment(session, input.trim())
110
+ if (ref === undefined) {
111
+ throw new Error(
112
+ `tool-vision: unknown attachment id "${input}" (it must come from an image uploaded in this conversation)`,
113
+ )
114
+ }
115
+ let stored
116
+ try {
117
+ stored = await attachments.readImage(ref)
118
+ } catch (error) {
119
+ throw new Error(
120
+ `tool-vision: failed to read attachment ${input} (${error && error.message ? error.message : String(error)})`,
121
+ )
122
+ }
123
+ bytes = stored.data
124
+ if (bytes && bytes.length > MAX_IMAGE_BYTES) {
125
+ throw new Error(
126
+ `tool-vision: image too large: ${input} (${bytes.length} bytes, limit ${MAX_IMAGE_BYTES})`,
127
+ )
128
+ }
129
+ if (stored.ref && typeof stored.ref.mediaType === 'string') {
130
+ storedMediaType = stored.ref.mediaType
131
+ }
132
+ } else {
133
+ const fsService = ctx.get('fs')
134
+ if (fsService === undefined) throw new Error('tool-vision: the fs service is not available')
135
+ const resolved = await fsService.resolve(input)
136
+ const target = toRealPath(fsService, resolved)
137
+ // Second line of defense beyond the fs sandbox: a relative input must
138
+ // resolve inside the session workspace (refuse ../ escapes).
139
+ if (!isAbsolutePath(input)) {
140
+ const root = workspaceOf(exec)
141
+ const relative = relativePath(root, target)
142
+ if (relative === '..' || relative.startsWith('..' + sep) || isAbsolutePath(relative)) {
143
+ throw new Error(`tool-vision: path escapes the workspace: ${input}`)
144
+ }
145
+ }
146
+ // Pre-check size BEFORE reading: a huge file must not be buffered whole
147
+ // (readFileP has no limit and would OOM the process).
148
+ const info = await statP(target).catch(() => null)
149
+ if (info !== null && info.size > MAX_IMAGE_BYTES) {
150
+ throw new Error(
151
+ `tool-vision: image too large: ${input} (${info.size} bytes, limit ${MAX_IMAGE_BYTES})`,
152
+ )
153
+ }
154
+ bytes = await readFileP(target)
155
+ if (bytes.length > MAX_IMAGE_BYTES) {
156
+ throw new Error(`tool-vision: image too large: ${input} (${bytes.length} bytes, limit ${MAX_IMAGE_BYTES})`)
157
+ }
158
+ }
159
+ const mediaType = sniffMediaType(bytes) ?? storedMediaType ?? mediaTypeOf(input)
160
+ if (mediaType === undefined) {
161
+ throw new Error(`unsupported image format ${input} (png/jpeg/webp/gif only)`)
162
+ }
163
+ return { bytes, mediaType }
164
+ }
165
+
166
+ /** Find an uploaded attachment ref in the session's recorded upload index. */
167
+ export function lookupAttachment(session, attachmentId) {
168
+ if (!session) return undefined
169
+ const recorded = session.recordedUploads
170
+ if (recorded) {
171
+ const ref = recorded.get ? recorded.get(attachmentId) : undefined
172
+ if (ref) return ref
173
+ }
174
+ const events = session.events ?? []
175
+ for (const event of events) {
176
+ const data = event && event.data
177
+ if (!data) continue
178
+ for (const block of Array.isArray(data.content) ? data.content : []) {
179
+ if (block && block.type === 'image' && block.attachment && block.attachment.attachmentId === attachmentId) {
180
+ return block.attachment
181
+ }
182
+ }
183
+ const message = data.message
184
+ if (message && Array.isArray(message.content)) {
185
+ for (const block of message.content) {
186
+ if (block && block.type === 'image' && block.attachment && block.attachment.attachmentId === attachmentId) {
187
+ return block.attachment
188
+ }
189
+ }
190
+ }
191
+ }
192
+ return undefined
193
+ }
194
+
195
+ /** Normalize an fs.resolve() result (string or { targetKey, displayPath }) to a real path. */
196
+ export function toRealPath(fsService, resolved) {
197
+ if (typeof resolved === 'string') return resolved
198
+ if (typeof fsService?.processPath === 'function') {
199
+ const p = fsService.processPath(resolved)
200
+ if (typeof p === 'string' && p !== '') return p
201
+ }
202
+ const key = resolved?.targetKey
203
+ return typeof key === 'string' && key !== '' ? key : String(resolved ?? '')
204
+ }
205
+
206
+ // ── sharp (lazy) ───────────────────────────────────────────────────────────
207
+
208
+ let sharpPromise = null
209
+
210
+ /** Lazily load sharp; resolves null when the host cannot provide it. */
211
+ export function loadSharp() {
212
+ if (!sharpPromise) {
213
+ sharpPromise = import('sharp')
214
+ .then((mod) => mod.default ?? mod)
215
+ .catch(() => null)
216
+ }
217
+ return sharpPromise
218
+ }
219
+
220
+ /**
221
+ * Downscale an image so its pixel count stays within maxPixels (router
222
+ * default 4MP). Returns the original buffer when the image is already small
223
+ * enough or when sharp is unavailable — pixel tools degrade gracefully
224
+ * instead of failing.
225
+ */
226
+ export async function downscaleImage(bytes, maxPixels) {
227
+ if (!bytes || bytes.length === 0) return bytes
228
+ const sharp = await loadSharp()
229
+ if (!sharp) return bytes
230
+ try {
231
+ const image = sharp(bytes, { failOn: 'none' })
232
+ const meta = await image.metadata()
233
+ const width = Number(meta.width) || 0
234
+ const height = Number(meta.height) || 0
235
+ if (width <= 0 || height <= 0 || width * height <= maxPixels) return bytes
236
+ const scale = Math.sqrt(maxPixels / (width * height))
237
+ const resized = await image
238
+ .resize({
239
+ width: Math.max(1, Math.floor(width * scale)),
240
+ height: Math.max(1, Math.floor(height * scale)),
241
+ fit: 'inside',
242
+ })
243
+ .toBuffer()
244
+ return resized && resized.length > 0 ? resized : bytes
245
+ } catch {
246
+ return bytes
247
+ }
248
+ }
249
+
250
+ /** Image dimensions via sharp (0/0 when unavailable). */
251
+ export async function imageDims(bytes) {
252
+ const sharp = await loadSharp()
253
+ if (!sharp) return { width: 0, height: 0 }
254
+ try {
255
+ const meta = await sharp(bytes, { failOn: 'none' }).metadata()
256
+ return { width: meta.width ?? 0, height: meta.height ?? 0 }
257
+ } catch {
258
+ return { width: 0, height: 0 }
259
+ }
260
+ }
261
+
262
+ // ── content-hash cache ─────────────────────────────────────────────────────
263
+
264
+ export function sha256Of(bytes) {
265
+ return createHash('sha256').update(bytes).digest('hex')
266
+ }
267
+
268
+ /** Small LRU+TTL cache for repeated image+question calls (router semantics). */
269
+ export function createCache(maxEntries, ttlMs) {
270
+ const map = new Map()
271
+ return {
272
+ get(key) {
273
+ const entry = map.get(key)
274
+ if (!entry) return undefined
275
+ if (entry.expires <= Date.now()) {
276
+ map.delete(key)
277
+ return undefined
278
+ }
279
+ map.delete(key)
280
+ map.set(key, entry)
281
+ return entry.value
282
+ },
283
+ set(key, value) {
284
+ if (map.size >= maxEntries) {
285
+ const oldest = map.keys().next().value
286
+ if (oldest !== undefined) map.delete(oldest)
287
+ }
288
+ map.set(key, { value, expires: Date.now() + ttlMs })
289
+ },
290
+ size() {
291
+ return map.size
292
+ },
293
+ clear() {
294
+ map.clear()
295
+ },
296
+ }
297
+ }
298
+
299
+ // ── artifacts (tool outputs land in the session workspace) ─────────────────
300
+
301
+ export function workspaceOf(exec) {
302
+ const session = exec && exec.agent && exec.agent.session
303
+ const cwd = session && session.header && session.header.cwd
304
+ return typeof cwd === 'string' && cwd !== '' ? cwd : process.cwd()
305
+ }
306
+
307
+ /** Write a tool artifact under <workspace>/.dsh-tool-vision/ and return its path.
308
+ * Recursive: long-OCR writes stem/chunk-*.png subdirectories. */
309
+ export async function writeArtifact(exec, relPath, data) {
310
+ const root = workspaceOf(exec)
311
+ const target = join(root, '.dsh-tool-vision', relPath)
312
+ await mkdirP(dirname(target), { recursive: true })
313
+ await writeFileP(target, data)
314
+ return target
315
+ }
316
+
317
+ /** Build an artifact filename stem from an input image reference. */
318
+ export function artifactStemOf(imagePath, suffix) {
319
+ const base = String(basenameOf(imagePath) ?? 'image')
320
+ .replace(/\.(png|jpe?g|webp|gif)$/i, '')
321
+ .replace(/[^a-zA-Z0-9._-]/g, '-')
322
+ .slice(0, 32)
323
+ const fingerprint = createHash('sha256').update(String(imagePath)).digest('hex').slice(0, 8)
324
+ return `${base || 'image'}-${fingerprint}-${suffix}`
325
+ }
326
+
327
+ // ── JSON & prompt helpers ──────────────────────────────────────────────────
328
+
329
+ const MAX_EXTRACT_JSON_CHARS = 200_000
330
+
331
+ /** Extract the first balanced JSON value ({...} or [...]) from model output. */
332
+ export function extractJson(text) {
333
+ const source = String(text ?? '')
334
+ const bounded = source.length > MAX_EXTRACT_JSON_CHARS
335
+ ? source.slice(0, MAX_EXTRACT_JSON_CHARS)
336
+ : source
337
+ const fenced = bounded.match(/```(?:json)?\s*([\s\S]*?)```/i)
338
+ const candidate = fenced ? fenced[1] : bounded
339
+ const start = candidate.search(/[[{]/)
340
+ if (start === -1) return undefined
341
+ const stack = []
342
+ let inString = false
343
+ let escaped = false
344
+ for (let index = start; index < candidate.length; index++) {
345
+ const char = candidate[index]
346
+ if (inString) {
347
+ if (escaped) escaped = false
348
+ else if (char === '\\') escaped = true
349
+ else if (char === '"') inString = false
350
+ continue
351
+ }
352
+ if (char === '"') {
353
+ inString = true
354
+ continue
355
+ }
356
+ if (char === '{') stack.push('}')
357
+ else if (char === '[') stack.push(']')
358
+ else if (char === '}' || char === ']') {
359
+ if (stack.length === 0 || stack.pop() !== char) return undefined
360
+ if (stack.length === 0) {
361
+ try {
362
+ const value = JSON.parse(candidate.slice(start, index + 1))
363
+ return typeof value === 'object' && value !== null ? value : undefined
364
+ } catch {
365
+ return undefined
366
+ }
367
+ }
368
+ }
369
+ }
370
+ return undefined
371
+ }
372
+
373
+ /** Legacy alias for callers of the earlier draft API. */
374
+ export function parseModelJson(text) {
375
+ return extractJson(text)
376
+ }
377
+
378
+ /** Parse "x1,y1,x2,y2" (string) or {x1,y1,x2,y2} (object) into a valid pixel box. */
379
+ export function parseBox(value) {
380
+ let box
381
+ if (typeof value === 'string') {
382
+ const parts = value.split(',').map((part) => Number(part.trim()))
383
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return undefined
384
+ box = { x1: parts[0], y1: parts[1], x2: parts[2], y2: parts[3] }
385
+ } else if (value && typeof value === 'object') {
386
+ box = { x1: value.x1, y1: value.y1, x2: value.x2, y2: value.y2 }
387
+ } else {
388
+ return undefined
389
+ }
390
+ const { x1, y1, x2, y2 } = box
391
+ if (![x1, y1, x2, y2].every((n) => Number.isInteger(n))) return undefined
392
+ if (x1 < 0 || y1 < 0 || x2 <= x1 || y2 <= y1) return undefined
393
+ return { x1, y1, x2, y2 }
394
+ }
395
+
396
+ /** Fixed JSON contract for vision_detect. */
397
+ export function visionDetectInstruction(target, width, height) {
398
+ return (
399
+ `The image is ${width}x${height} pixels. Find every "${String(target).slice(0, 300)}" in it. ` +
400
+ 'Return ONE JSON object and nothing else, shaped EXACTLY as:\n' +
401
+ '{"elements":[{"label":"<short element name>","box":{"x1":0,"y1":0,"x2":0,"y2":0}},...]}\n' +
402
+ '- "elements" is a numbered list (array order = element number) of every match, from top-left to bottom-right in reading order;\n' +
403
+ '- every box is the tight bounding box in ORIGINAL image pixels, integers, 0 <= x1 < x2 <= ' +
404
+ `${width}, 0 <= y1 < y2 <= ${height}` +
405
+ ';\n- if nothing matches, return {"elements":[]}.'
406
+ )
407
+ }
408
+
409
+ /** Structured JSON contract for vision_describe's json:true mode. */
410
+ export function describeStructuredInstruction(question) {
411
+ return (
412
+ `Look at the image and answer the question: 「${String(question).slice(0, 1500)}」. ` +
413
+ 'Return ONE JSON object and nothing else, shaped EXACTLY as:\n' +
414
+ '{"summary":"<1-2 sentence answer to the question>",' +
415
+ '"layout":[{"region":"<e.g. top-left / header / center>","content":"<what is there>"}],' +
416
+ '"entities":[{"type":"<button|input|text|image|link|icon|other>","label":"<name or text>"}],' +
417
+ '"text":"<the full text visible in the image, transcribed in reading order, as faithful as possible>"}\n' +
418
+ '- "layout" lists the main regions in reading order (top-to-bottom, left-to-right);\n' +
419
+ '- "entities" lists notable elements; use only the listed type values;\n' +
420
+ '- "text" is the verbatim transcription; write "" when the image contains no text.'
421
+ )
422
+ }
423
+
424
+ /** Shared vision_describe prompt. */
425
+ export function visionDescribePrompt(question, wantJson = false) {
426
+ const raw = String(question ?? '').trim()
427
+ const text = raw === ''
428
+ ? 'Describe the image accurately and answer based only on visible content.'
429
+ : raw
430
+ return wantJson ? text + '\n\n' + describeStructuredInstruction(text) : text
431
+ }
432
+
433
+ /** Normalize a vision_detect answer into the canonical shape, clamping boxes. */
434
+ export function normalizeDetectResult(parsed, width, height) {
435
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.elements)) return undefined
436
+ const clamp = (value, min, max) => Math.max(min, Math.min(value, max))
437
+ const elements = []
438
+ for (const item of parsed.elements) {
439
+ if (!item || typeof item !== 'object' || !item.box || typeof item.box !== 'object') continue
440
+ const x1 = Math.round(Number(item.box.x1))
441
+ const y1 = Math.round(Number(item.box.y1))
442
+ const x2 = Math.round(Number(item.box.x2))
443
+ const y2 = Math.round(Number(item.box.y2))
444
+ if (![x1, y1, x2, y2].every(Number.isFinite)) continue
445
+ const box = {
446
+ x1: clamp(x1, 0, width - 1),
447
+ y1: clamp(y1, 0, height - 1),
448
+ x2: clamp(x2, 1, width),
449
+ y2: clamp(y2, 1, height),
450
+ }
451
+ if (box.x2 <= box.x1 || box.y2 <= box.y1) continue
452
+ elements.push({
453
+ number: elements.length + 1,
454
+ label: typeof item.label === 'string' && item.label.trim() !== '' ? item.label.trim() : `element ${elements.length + 1}`,
455
+ box,
456
+ })
457
+ }
458
+ return { width, height, elements }
459
+ }
460
+
461
+ /** Normalize a structured vision_describe answer with defaults for missing fields. */
462
+ export function normalizeDescribeResult(parsed) {
463
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
464
+ const layout = Array.isArray(parsed.layout) ? parsed.layout.filter((r) => r && typeof r === 'object' && typeof r.region === 'string' && typeof r.content === 'string') : []
465
+ const entities = Array.isArray(parsed.entities)
466
+ ? parsed.entities
467
+ .filter((e) => e && typeof e === 'object' && typeof e.type === 'string' && typeof e.label === 'string')
468
+ .map((e) => ({ type: e.type, label: e.label }))
469
+ : []
470
+ return {
471
+ summary: typeof parsed.summary === 'string' ? parsed.summary : '',
472
+ layout,
473
+ entities,
474
+ text: typeof parsed.text === 'string' ? parsed.text : '',
475
+ }
476
+ }
477
+
478
+ // ── pixel operations ───────────────────────────────────────────────────────
479
+
480
+ /** Per-pixel RGBA comparison; returns ratio, mask and worst 8x8 cells. */
481
+ export function computePixelDiff(bufferA, bufferB, threshold = 16, width = 0, height = 0) {
482
+ const length = Math.min(bufferA.length, bufferB.length)
483
+ const pixels = Math.floor(length / 4)
484
+ let differing = 0
485
+ const mask = new Uint8Array(pixels)
486
+ for (let i = 0; i < pixels; i++) {
487
+ const o = i * 4
488
+ const d =
489
+ Math.max(
490
+ Math.abs(bufferA[o] - bufferB[o]),
491
+ Math.abs(bufferA[o + 1] - bufferB[o + 1]),
492
+ Math.abs(bufferA[o + 2] - bufferB[o + 2]),
493
+ ) - threshold
494
+ if (d > 0) {
495
+ differing += 1
496
+ mask[i] = 1
497
+ }
498
+ }
499
+ const ratio = pixels === 0 ? 0 : differing / pixels
500
+ const cells = []
501
+ if (width > 0 && height > 0) {
502
+ const cols = 8
503
+ const rows = 8
504
+ const cw = Math.ceil(width / cols)
505
+ const ch = Math.ceil(height / rows)
506
+ for (let cy = 0; cy < rows; cy++) {
507
+ for (let cx = 0; cx < cols; cx++) {
508
+ let hit = 0
509
+ let total = 0
510
+ for (let y = cy * ch; y < Math.min((cy + 1) * ch, height); y++) {
511
+ for (let x = cx * cw; x < Math.min((cx + 1) * cw, width); x++) {
512
+ total += 1
513
+ if (mask[y * width + x]) hit += 1
514
+ }
515
+ }
516
+ if (total > 0 && hit > 0) {
517
+ cells.push({
518
+ x1: cx * cw,
519
+ y1: cy * ch,
520
+ x2: Math.min((cx + 1) * cw, width),
521
+ y2: Math.min((cy + 1) * ch, height),
522
+ ratio: hit / total,
523
+ differing: hit,
524
+ total,
525
+ })
526
+ }
527
+ }
528
+ }
529
+ cells.sort((a, b) => b.ratio - a.ratio)
530
+ }
531
+ return { differing, total: pixels, ratio, mask, cells }
532
+ }
533
+
534
+ function asBuffer(chunk) {
535
+ if (Buffer.isBuffer(chunk)) return chunk
536
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
537
+ return Buffer.from(chunk ?? [])
538
+ }
539
+
540
+ class AsyncByteReader {
541
+ constructor(iterable) {
542
+ if (!iterable || typeof iterable[Symbol.asyncIterator] !== 'function') {
543
+ throw new TypeError('pixel diff stream must be async iterable')
544
+ }
545
+ this.iterator = iterable[Symbol.asyncIterator]()
546
+ this.buffer = Buffer.alloc(0)
547
+ this.done = false
548
+ }
549
+
550
+ async fill(minBytes) {
551
+ while (!this.done && this.buffer.length < minBytes) {
552
+ const next = await this.iterator.next()
553
+ if (next.done) {
554
+ this.done = true
555
+ break
556
+ }
557
+ const chunk = asBuffer(next.value)
558
+ if (chunk.length === 0) continue
559
+ this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk])
560
+ }
561
+ }
562
+
563
+ consume(bytes) {
564
+ const out = this.buffer.subarray(0, bytes)
565
+ this.buffer = this.buffer.subarray(bytes)
566
+ return out
567
+ }
568
+ }
569
+
570
+ function cellBounds(index, width, height, cols, rows) {
571
+ const cx = index % cols
572
+ const cy = Math.floor(index / cols)
573
+ const cw = Math.ceil(width / cols)
574
+ const ch = Math.ceil(height / rows)
575
+ const x1 = cx * cw
576
+ const y1 = cy * ch
577
+ const x2 = Math.min((cx + 1) * cw, width)
578
+ const y2 = Math.min((cy + 1) * ch, height)
579
+ return { x1, y1, x2, y2, total: Math.max(0, x2 - x1) * Math.max(0, y2 - y1) }
580
+ }
581
+
582
+ /**
583
+ * Exact RGB pixel comparison over two RGBA async streams. The function never
584
+ * materializes either full frame; it holds only the current upstream chunks
585
+ * plus a bounded region counter grid.
586
+ */
587
+ export async function compareRgbaStreams(
588
+ streamA,
589
+ streamB,
590
+ { width, height, threshold = 16, cols = 8, rows = 8 } = {},
591
+ ) {
592
+ const w = Math.max(1, Math.floor(Number(width) || 1))
593
+ const h = Math.max(1, Math.floor(Number(height) || 1))
594
+ const limit = w * h
595
+ const gate = Number.isFinite(Number(threshold)) && Number(threshold) >= 0
596
+ ? Number(threshold)
597
+ : 16
598
+ const gridCols = Math.min(w, Math.max(1, Math.floor(Number(cols) || 8)))
599
+ const gridRows = Math.min(h, Math.max(1, Math.floor(Number(rows) || 8)))
600
+ const cellDiff = new Uint32Array(gridCols * gridRows)
601
+ const readerA = new AsyncByteReader(streamA)
602
+ const readerB = new AsyncByteReader(streamB)
603
+ let pixels = 0
604
+ let differing = 0
605
+
606
+ while (pixels < limit) {
607
+ await Promise.all([readerA.fill(4), readerB.fill(4)])
608
+ if (readerA.buffer.length < 4 || readerB.buffer.length < 4) break
609
+ const remainingBytes = (limit - pixels) * 4
610
+ const usable = Math.min(
611
+ remainingBytes,
612
+ Math.floor(Math.min(readerA.buffer.length, readerB.buffer.length) / 4) * 4,
613
+ )
614
+ if (usable <= 0) break
615
+ const a = readerA.consume(usable)
616
+ const b = readerB.consume(usable)
617
+ const chunkPixels = usable / 4
618
+ for (let i = 0; i < chunkPixels; i++) {
619
+ const o = i * 4
620
+ const different =
621
+ Math.max(
622
+ Math.abs(a[o] - b[o]),
623
+ Math.abs(a[o + 1] - b[o + 1]),
624
+ Math.abs(a[o + 2] - b[o + 2]),
625
+ ) > gate
626
+ if (!different) continue
627
+ differing += 1
628
+ const absolute = pixels + i
629
+ const x = absolute % w
630
+ const y = Math.floor(absolute / w)
631
+ const cx = Math.min(gridCols - 1, Math.floor((x * gridCols) / w))
632
+ const cy = Math.min(gridRows - 1, Math.floor((y * gridRows) / h))
633
+ cellDiff[cy * gridCols + cx] += 1
634
+ }
635
+ pixels += chunkPixels
636
+ }
637
+
638
+ if (pixels !== limit) {
639
+ throw new Error(`pixel diff stream ended early (${pixels}/${limit} pixels)`)
640
+ }
641
+ await Promise.all([readerA.fill(1), readerB.fill(1)])
642
+ if (readerA.buffer.length > 0 || readerB.buffer.length > 0) {
643
+ throw new Error('pixel diff stream produced more RGBA bytes than the declared dimensions')
644
+ }
645
+
646
+ const cells = []
647
+ for (let index = 0; index < cellDiff.length; index++) {
648
+ const hit = cellDiff[index]
649
+ if (hit === 0) continue
650
+ const bounds = cellBounds(index, w, h, gridCols, gridRows)
651
+ if (bounds.total <= 0) continue
652
+ cells.push({
653
+ x1: bounds.x1,
654
+ y1: bounds.y1,
655
+ x2: bounds.x2,
656
+ y2: bounds.y2,
657
+ ratio: hit / bounds.total,
658
+ differing: hit,
659
+ total: bounds.total,
660
+ })
661
+ }
662
+ cells.sort((a, b) => b.ratio - a.ratio)
663
+ return { differing, total: limit, ratio: limit === 0 ? 0 : differing / limit, mask: null, cells }
664
+ }
665
+
666
+ /** Render a diff heatmap: grayscale base, red where the mask marks a differing pixel. */
667
+ export function renderDiffHeatmap(originalRaw, mask, width, height) { const out = Buffer.alloc(width * height * 4)
668
+ for (let i = 0; i < width * height; i++) {
669
+ const o = i * 4
670
+ const gray = Math.round(
671
+ 0.299 * originalRaw[o] + 0.587 * originalRaw[o + 1] + 0.114 * originalRaw[o + 2],
672
+ )
673
+ if (mask[i]) {
674
+ out[o] = 255
675
+ out[o + 1] = 0
676
+ out[o + 2] = 0
677
+ out[o + 3] = 255
678
+ } else {
679
+ out[o] = gray
680
+ out[o + 1] = gray
681
+ out[o + 2] = gray
682
+ out[o + 3] = 255
683
+ }
684
+ }
685
+ return out
686
+ }
687
+
688
+ /** Dominant colors via bin quantization of an RGBA raw buffer. */
689
+ export function quantizeColors(raw, topN = 8, bins = 32) {
690
+ const step = 256 / bins
691
+ const counts = new Map()
692
+ const pixels = Math.floor(raw.length / 4)
693
+ for (let i = 0; i < pixels; i++) {
694
+ const o = i * 4
695
+ if (raw[o + 3] < 128) continue
696
+ const r = Math.floor(raw[o] / step) * step
697
+ const g = Math.floor(raw[o + 1] / step) * step
698
+ const b = Math.floor(raw[o + 2] / step) * step
699
+ const key = `${r},${g},${b}`
700
+ counts.set(key, (counts.get(key) ?? 0) + 1)
701
+ }
702
+ return [...counts.entries()]
703
+ .sort((a, b) => b[1] - a[1])
704
+ .slice(0, topN)
705
+ .map(([key, count]) => {
706
+ const [r, g, b] = key.split(',').map(Number)
707
+ const hex = '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')
708
+ return { hex, count, share: pixels === 0 ? 0 : count / pixels }
709
+ })
710
+ }
711
+
712
+ /** SVG overlay string drawing one red pixel box on a width x height canvas. */
713
+ export function boxToSvg(box, width, height) {
714
+ return Buffer.from(
715
+ `<svg width="${width}" height="${height}">` +
716
+ `<rect x="${box.x1}" y="${box.y1}" width="${box.x2 - box.x1}" height="${box.y2 - box.y1}" ` +
717
+ `fill="none" stroke="#ff2d55" stroke-width="${Math.max(2, Math.round(Math.max(width, height) / 400))}"/></svg>`,
718
+ )
719
+ }
720
+
721
+ /** SVG overlay drawing numbered red boxes (detect inventory). */
722
+ export function boxesToSvg(boxes, width, height) {
723
+ const stroke = Math.max(2, Math.round(Math.max(width, height) / 400))
724
+ const labelR = Math.max(10, stroke * 4)
725
+ const parts = [`<svg width="${width}" height="${height}">`]
726
+ for (let i = 0; i < boxes.length; i++) {
727
+ const box = boxes[i]
728
+ parts.push(
729
+ `<rect x="${box.x1}" y="${box.y1}" width="${box.x2 - box.x1}" height="${box.y2 - box.y1}" ` +
730
+ `fill="none" stroke="#ff2d55" stroke-width="${stroke}"/>`,
731
+ )
732
+ const cx = Math.max(labelR, Math.min(box.x1, width - labelR))
733
+ const cy = Math.max(labelR, Math.min(box.y1, height - labelR))
734
+ parts.push(
735
+ `<circle cx="${cx}" cy="${cy}" r="${labelR}" fill="#ff2d55"/>` +
736
+ `<text x="${cx}" y="${cy + labelR * 0.36}" text-anchor="middle" ` +
737
+ `font-family="sans-serif" font-size="${Math.round(labelR * 1.2)}" fill="#ffffff" ` +
738
+ `font-weight="bold">${i + 1}</text>`,
739
+ )
740
+ }
741
+ parts.push('</svg>')
742
+ return Buffer.from(parts.join(''))
743
+ }
744
+
745
+ export function scaledDimensions(width, height, maxPixels) {
746
+ const w = Math.max(0, Math.floor(Number(width) || 0))
747
+ const h = Math.max(0, Math.floor(Number(height) || 0))
748
+ const limit = Math.max(1, Math.floor(Number(maxPixels) || 1))
749
+ if (w <= 0 || h <= 0) return { width: w, height: h, scale: 1 }
750
+ const pixels = w * h
751
+ if (pixels <= limit) return { width: w, height: h, scale: 1 }
752
+ const scale = Math.sqrt(limit / pixels)
753
+ return {
754
+ width: Math.max(1, Math.floor(w * scale)),
755
+ height: Math.max(1, Math.floor(h * scale)),
756
+ scale,
757
+ }
758
+ }
759
+
760
+ export function scaleBox(box, fromWidth, fromHeight, toWidth, toHeight) {
761
+ const sx = fromWidth > 0 ? toWidth / fromWidth : 1
762
+ const sy = fromHeight > 0 ? toHeight / fromHeight : 1
763
+ return {
764
+ x1: Math.max(0, Math.round(box.x1 * sx)),
765
+ y1: Math.max(0, Math.round(box.y1 * sy)),
766
+ x2: Math.min(toWidth, Math.max(1, Math.round(box.x2 * sx))),
767
+ y2: Math.min(toHeight, Math.max(1, Math.round(box.y2 * sy))),
768
+ }
769
+ }
770
+
771
+ /** Draw one red pixel box onto an image buffer via sharp. */
772
+ export async function annotateBoxBuffer(bytes, box) {
773
+ const sharp = await loadSharp()
774
+ if (!sharp) return bytes
775
+ const meta = await sharp(bytes, { failOn: 'none' }).metadata()
776
+ const width = meta.width ?? box.x2
777
+ const height = meta.height ?? box.y2
778
+ const preview = scaledDimensions(width, height, 4_000_000)
779
+ const displayBox = preview.scale === 1
780
+ ? box
781
+ : scaleBox(box, width, height, preview.width, preview.height)
782
+ let image = sharp(bytes, { failOn: 'none' })
783
+ if (preview.scale !== 1) image = image.resize(preview.width, preview.height, { fit: 'fill' })
784
+ return image
785
+ .composite([{ input: boxToSvg(displayBox, preview.width, preview.height), top: 0, left: 0 }])
786
+ .png()
787
+ .toBuffer()
788
+ }
789
+
790
+ /** Draw numbered boxes for a detected-element inventory onto an image buffer. */
791
+ export async function annotateBoxesBuffer(bytes, boxes) {
792
+ const sharp = await loadSharp()
793
+ if (!sharp) return bytes
794
+ const meta = await sharp(bytes, { failOn: 'none' }).metadata()
795
+ const width = meta.width ?? 0
796
+ const height = meta.height ?? 0
797
+ if (width <= 0 || height <= 0 || boxes.length === 0) return bytes
798
+ const preview = scaledDimensions(width, height, 4_000_000)
799
+ const displayBoxes = preview.scale === 1
800
+ ? boxes
801
+ : boxes.map((box) => scaleBox(box, width, height, preview.width, preview.height))
802
+ let image = sharp(bytes, { failOn: 'none' })
803
+ if (preview.scale !== 1) image = image.resize(preview.width, preview.height, { fit: 'fill' })
804
+ return image
805
+ .composite([{ input: boxesToSvg(displayBoxes, preview.width, preview.height), top: 0, left: 0 }])
806
+ .png()
807
+ .toBuffer()
808
+ }
809
+
810
+ /** Remove a solid-ish background via border flood fill; background becomes transparent. */
811
+ export function floodFillBackground(raw, width, height, tolerance = 40) {
812
+ const total = width * height
813
+ const out = Buffer.from(raw)
814
+ const marked = new Uint8Array(total)
815
+ let r = 0
816
+ let g = 0
817
+ let b = 0
818
+ const corners = [0, width - 1, (height - 1) * width, total - 1]
819
+ for (const c of corners) {
820
+ const o = c * 4
821
+ r += raw[o]
822
+ g += raw[o + 1]
823
+ b += raw[o + 2]
824
+ }
825
+ r /= 4
826
+ g /= 4
827
+ b /= 4
828
+ const queue = []
829
+ let head = 0
830
+ const push = (x, y) => {
831
+ const i = y * width + x
832
+ if (marked[i]) return
833
+ const o = i * 4
834
+ const d = Math.max(Math.abs(raw[o] - r), Math.abs(raw[o + 1] - g), Math.abs(raw[o + 2] - b))
835
+ if (d > tolerance) return
836
+ marked[i] = 1
837
+ queue.push(i)
838
+ }
839
+ for (let x = 0; x < width; x++) {
840
+ push(x, 0)
841
+ push(x, height - 1)
842
+ }
843
+ for (let y = 0; y < height; y++) {
844
+ push(0, y)
845
+ push(width - 1, y)
846
+ }
847
+ while (head < queue.length) {
848
+ const i = queue[head++]
849
+ const x = i % width
850
+ const y = (i - x) / width
851
+ if (x > 0) push(x - 1, y)
852
+ if (x < width - 1) push(x + 1, y)
853
+ if (y > 0) push(x, y - 1)
854
+ if (y < height - 1) push(x, y + 1)
855
+ }
856
+ for (let i = 0; i < total; i++) {
857
+ if (marked[i]) out[i * 4 + 3] = 0
858
+ }
859
+ return out
860
+ }
861
+
862
+ /** Cover a long/large image with bounded tiles in reading order. */
863
+ export function boundedOcrTiles(
864
+ width,
865
+ height,
866
+ { chunkHeight = 1200, overlap = 120, maxTilePixels = 4_000_000 } = {},
867
+ ) {
868
+ const w = Math.max(1, Math.floor(Number(width) || 1))
869
+ const h = Math.max(1, Math.floor(Number(height) || 1))
870
+ const requestedHeight = Math.max(1, Math.min(h, Math.floor(Number(chunkHeight) || 1200)))
871
+ const pixelLimit = Math.max(1, Math.floor(Number(maxTilePixels) || 4_000_000))
872
+ const tileWidth = Math.min(w, Math.max(1, Math.floor(pixelLimit / requestedHeight)))
873
+ const tileHeight = Math.min(requestedHeight, Math.max(1, Math.floor(pixelLimit / tileWidth)))
874
+ const verticalOverlap = Math.min(
875
+ Math.max(0, Math.floor(Number(overlap) || 0)),
876
+ Math.max(0, Math.floor(tileHeight / 2)),
877
+ )
878
+ const verticalStep = Math.max(1, tileHeight - verticalOverlap)
879
+ const tiles = []
880
+ for (let top = 0; top < h; top += verticalStep) {
881
+ const bottom = Math.min(h, top + tileHeight)
882
+ for (let left = 0; left < w; left += tileWidth) {
883
+ const right = Math.min(w, left + tileWidth)
884
+ tiles.push({ left, right, top, bottom })
885
+ }
886
+ if (bottom >= h) break
887
+ }
888
+ return tiles
889
+ }
890
+
891
+ // ── potrace (worker-thread, hard timeout) ──────────────────────────────────
892
+
893
+ function runPotraceWorker(workerData, source, timeoutMs) {
894
+ return new Promise((resolve, reject) => {
895
+ let settled = false
896
+ let worker
897
+ const finish = (error, svg) => {
898
+ if (settled) return
899
+ settled = true
900
+ clearTimeout(timer)
901
+ void worker?.terminate()
902
+ if (error) reject(error)
903
+ else resolve(svg)
904
+ }
905
+ const timer = setTimeout(() => {
906
+ if (settled) return
907
+ settled = true
908
+ void worker?.terminate()
909
+ reject(
910
+ new Error(
911
+ 'potrace timed out — the image is too large or too complex; crop it to the target region first',
912
+ ),
913
+ )
914
+ }, timeoutMs)
915
+ try {
916
+ worker = new Worker(source, { eval: true, workerData })
917
+ worker.once('message', (message) => {
918
+ if (message && message.error) finish(new Error(message.error))
919
+ else finish(undefined, message && message.svg)
920
+ })
921
+ worker.once('error', (error) => finish(error))
922
+ worker.once('exit', (code) => {
923
+ if (code !== 0 && !settled) finish(new Error(`potrace worker exited with code ${code}`))
924
+ })
925
+ } catch (error) {
926
+ finish(error)
927
+ }
928
+ })
929
+ }
930
+
931
+ /** Vectorize an image buffer into an SVG string via potrace posterization. */
932
+ export function posterizeSvg(bytes, steps = 4, fillStrategy = 'dominant', timeoutMs = 60000) {
933
+ const potraceUrl = pathToFileURL(createRequire(import.meta.url).resolve('potrace')).href
934
+ const source = `
935
+ import('node:worker_threads').then(({ parentPort, workerData }) => {
936
+ import(workerData.potraceUrl).then((mod) => {
937
+ const potrace = mod.default ?? mod
938
+ potrace.posterize(Buffer.from(workerData.bytes), {
939
+ steps: workerData.steps,
940
+ fillStrategy: workerData.fillStrategy,
941
+ }, (error, svg) => {
942
+ parentPort.postMessage(error ? { error: String((error && error.message) || error) } : { svg })
943
+ })
944
+ }).catch((error) => {
945
+ parentPort.postMessage({ error: String((error && error.message) || error) })
946
+ })
947
+ })
948
+ `
949
+ return runPotraceWorker({ potraceUrl, bytes, steps, fillStrategy }, source, timeoutMs)
950
+ }
951
+
952
+ /**
953
+ * Color-preserving vectorization: quantize into the top colors, trace one
954
+ * 1-bit mask per color with potrace, emit a colored SVG (one path per color).
955
+ */
956
+ export function posterizeSvgColor(data, info, palette, timeoutMs = 60000) {
957
+ const sharpUrl = pathToFileURL(createRequire(import.meta.url).resolve('sharp')).href
958
+ const potraceUrl = pathToFileURL(createRequire(import.meta.url).resolve('potrace')).href
959
+ const source = `
960
+ import('node:worker_threads').then(({ parentPort, workerData }) => {
961
+ Promise.all([import(workerData.sharpUrl), import(workerData.potraceUrl)]).then(([sharpMod, potraceMod]) => {
962
+ const sharp = sharpMod.default ?? sharpMod
963
+ const potrace = potraceMod.default ?? potraceMod
964
+ const { width, height, palette } = workerData
965
+ const raw = Buffer.from(workerData.raw)
966
+ const hexRgb = (hex) => {
967
+ const n = parseInt(hex.slice(1), 16)
968
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
969
+ }
970
+ const paletteRgb = palette.map((p) => hexRgb(p.hex))
971
+ const pixels = width * height
972
+ const masks = palette.map(() => Buffer.alloc(pixels))
973
+ for (let p = 0; p < pixels; p++) {
974
+ const o = p * 4
975
+ if (raw[o + 3] < 128) continue
976
+ let best = 0
977
+ let bestD = Infinity
978
+ for (let c = 0; c < paletteRgb.length; c++) {
979
+ const dr = raw[o] - paletteRgb[c][0]
980
+ const dg = raw[o + 1] - paletteRgb[c][1]
981
+ const db = raw[o + 2] - paletteRgb[c][2]
982
+ const d = dr * dr + dg * dg + db * db
983
+ if (d < bestD) { bestD = d; best = c }
984
+ }
985
+ masks[best][p] = 1
986
+ }
987
+ const paths = []
988
+ let pending = palette.length
989
+ const maybeDone = () => {
990
+ if (pending > 0) return
991
+ const pathSvg = paths.map((p) => '<path fill="' + p.hex + '" d="' + p.d + '"/>').join('')
992
+ parentPort.postMessage({
993
+ ok: true,
994
+ svg: '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height +
995
+ '" viewBox="0 0 ' + width + ' ' + height + '"><rect width="' + width + '" height="' + height +
996
+ '" fill="#ffffff"/>' + pathSvg + '</svg>',
997
+ })
998
+ }
999
+ if (pending === 0) { maybeDone(); return }
1000
+ palette.forEach((entry, index) => {
1001
+ const gray = Buffer.alloc(pixels)
1002
+ const mask = masks[index]
1003
+ for (let p = 0; p < pixels; p++) gray[p] = mask[p] ? 0 : 255
1004
+ sharp(gray, { raw: { width, height, channels: 1 } })
1005
+ .png()
1006
+ .toBuffer()
1007
+ .then((pngBuf) => {
1008
+ potrace.trace(pngBuf, (err, svg) => {
1009
+ pending -= 1
1010
+ if (!err && svg) {
1011
+ const found = [...svg.matchAll(/d="([^"]+)"/g)].map((m) => m[1])
1012
+ for (const d of found) paths.push({ hex: entry.hex, d })
1013
+ }
1014
+ maybeDone()
1015
+ })
1016
+ })
1017
+ .catch(() => {
1018
+ pending -= 1
1019
+ maybeDone()
1020
+ })
1021
+ })
1022
+ }).catch((error) => {
1023
+ parentPort.postMessage({ error: String((error && error.message) || error) })
1024
+ })
1025
+ })
1026
+ `
1027
+ return runPotraceWorker(
1028
+ { sharpUrl, potraceUrl, width: info.width, height: info.height, palette, raw: data },
1029
+ source,
1030
+ timeoutMs,
1031
+ )
1032
+ }
1033
+
1034
+ // ── browser (HTML screenshot) ──────────────────────────────────────────────
1035
+
1036
+ /** Cross-platform Chrome/Chromium/Edge discovery. */
1037
+ export function chromiumCandidates(env = {}, platform = typeof process !== 'undefined' ? process.platform : '') {
1038
+ const out = []
1039
+ const add = (value) => {
1040
+ if (typeof value === 'string' && value !== '' && !out.includes(value)) out.push(value)
1041
+ }
1042
+ add(env.CHROME_PATH)
1043
+ add(env.PUPPETEER_EXECUTABLE_PATH)
1044
+
1045
+ if (platform === 'win32') {
1046
+ const pf = env.PROGRAMFILES
1047
+ const pfx86 = env['PROGRAMFILES(X86)']
1048
+ const local = env.LOCALAPPDATA
1049
+ if (pf) {
1050
+ add(winPath.join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'))
1051
+ add(winPath.join(pf, 'Microsoft', 'Edge', 'Application', 'msedge.exe'))
1052
+ }
1053
+ if (pfx86) {
1054
+ add(winPath.join(pfx86, 'Google', 'Chrome', 'Application', 'chrome.exe'))
1055
+ add(winPath.join(pfx86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'))
1056
+ }
1057
+ if (local) {
1058
+ add(winPath.join(local, 'Google', 'Chrome', 'Application', 'chrome.exe'))
1059
+ add(winPath.join(local, 'Microsoft', 'Edge', 'Application', 'msedge.exe'))
1060
+ add(winPath.join(local, 'Chromium', 'Application', 'chrome.exe'))
1061
+ }
1062
+ } else if (platform === 'darwin') {
1063
+ add('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome')
1064
+ add('/Applications/Chromium.app/Contents/MacOS/Chromium')
1065
+ add('/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge')
1066
+ } else {
1067
+ add('/usr/bin/google-chrome')
1068
+ add('/usr/bin/google-chrome-stable')
1069
+ add('/usr/bin/chromium')
1070
+ add('/usr/bin/chromium-browser')
1071
+ add('/usr/bin/microsoft-edge')
1072
+ add('/usr/bin/microsoft-edge-stable')
1073
+ }
1074
+ return out
1075
+ }
1076
+
1077
+ /** Wake lazy/revealed content before a full-page capture. */
1078
+ export async function wakePageForFullCapture(page, viewportHeight) {
1079
+ const step = Number.isInteger(viewportHeight) && viewportHeight > 0 ? viewportHeight : 720
1080
+ await page.evaluate(() => {
1081
+ document.documentElement.style.scrollBehavior = 'auto'
1082
+ })
1083
+ const total = await page.evaluate(() =>
1084
+ Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0),
1085
+ )
1086
+ for (let y = 0; y < total; y += step) {
1087
+ await page.evaluate((yy) => window.scrollTo(0, yy), y)
1088
+ await new Promise((resolve) => setTimeout(resolve, 60))
1089
+ }
1090
+ await page.evaluate(() => window.scrollTo(0, 0))
1091
+ await new Promise((resolve) => setTimeout(resolve, 800))
1092
+ }
1093
+
1094
+ /** Full scrollable page height (CSS px). */
1095
+ export async function fullPageHeightOf(page) {
1096
+ return await page.evaluate(() =>
1097
+ Math.max(
1098
+ document.documentElement.scrollHeight,
1099
+ document.body ? document.body.scrollHeight : 0,
1100
+ window.innerHeight,
1101
+ ),
1102
+ )
1103
+ }
1104
+
1105
+ // ── model call (single configured endpoint) ────────────────────────────────
1106
+
1107
+ /** Endpoint content-safety rejection markers (GLM: "系统检测到输入或生成内容
1108
+ * 可能包含不安全或敏感内容…"; OpenAI-style: content_filter/policy/safety…). */
1109
+ export const CONTENT_FILTER_RE =
1110
+ /敏感|不安全|内容审核|内容安全|政策|content.?filter|safety|inappropriate|flagged|拒绝.?内容|违反.*(规范|政策|安全)|violat/i
1111
+
1112
+ export function resolveApiKey(config) {
1113
+ if (config.apiKey) return config.apiKey
1114
+ if (config.apiKeyEnv) {
1115
+ const fromEnv = process.env[config.apiKeyEnv]
1116
+ if (fromEnv) return fromEnv
1117
+ }
1118
+ return process.env.OPENAI_API_KEY ?? ''
1119
+ }
1120
+
1121
+ /** Turn image bytes into a data URL for the vision call.
1122
+ * Defensive: attachment-service reads return Uint8Array (whose
1123
+ * toString('base64') is a comma-joined number list, not base64), so always
1124
+ * wrap in a real Buffer first. */
1125
+ export function bytesToDataUrl(bytes, mediaType) {
1126
+ const mime = mediaType ?? 'image/png'
1127
+ const buf = Buffer.isBuffer(bytes)
1128
+ ? bytes
1129
+ : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
1130
+ return `data:${mime};base64,${buf.toString('base64')}`
1131
+ }
1132
+
1133
+ /**
1134
+ * One OpenAI-compatible chat/completions call with image content parts.
1135
+ * This is the ONLY model-call path the ported tools use: the plugin's own
1136
+ * baseURL/apiKey/model settings drive every vision_* tool, exactly like
1137
+ * inspect_image.
1138
+ */
1139
+ export async function callVisionModel(getConfig, prompt, imageUrls, options = {}) {
1140
+ const cfg = getConfig()
1141
+ const key = resolveApiKey(cfg)
1142
+ if (!key) {
1143
+ throw new Error(
1144
+ 'tool-vision: vision API key missing: set the plugin config (apiKey / apiKeyEnv) or the OPENAI_API_KEY environment variable',
1145
+ )
1146
+ }
1147
+ const base = cfg.baseURL.endsWith('/') ? cfg.baseURL : `${cfg.baseURL}/`
1148
+ const endpoint = new URL('chat/completions', base)
1149
+ const controller = new AbortController()
1150
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : cfg.timeoutMs
1151
+ const timer = setTimeout(
1152
+ () => controller.abort(new Error(`tool-vision: vision request timed out after ${timeoutMs}ms`)),
1153
+ timeoutMs,
1154
+ )
1155
+ const signal = options.signal
1156
+ const onSignalAbort = () => controller.abort(signal?.reason)
1157
+ if (signal) {
1158
+ if (signal.aborted) controller.abort()
1159
+ else signal.addEventListener('abort', onSignalAbort, { once: true })
1160
+ }
1161
+ const content = [
1162
+ { type: 'text', text: prompt },
1163
+ ...imageUrls.map((url) => ({ type: 'image_url', image_url: { url } })),
1164
+ ]
1165
+ const bodyOf = () => JSON.stringify({
1166
+ model: cfg.model,
1167
+ messages: [{ role: 'user', content }],
1168
+ max_tokens: Number.isFinite(options.maxTokens) ? options.maxTokens : cfg.maxTokens,
1169
+ })
1170
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
1171
+ const retryAfterOf = (response) => {
1172
+ const header = response?.headers?.get?.('retry-after')
1173
+ if (header) {
1174
+ const seconds = Number(header)
1175
+ if (Number.isFinite(seconds) && seconds > 0) return Math.min(seconds * 1000, 15000)
1176
+ }
1177
+ return 0
1178
+ }
1179
+ const MAX_TRANSIENT_RETRIES = 3
1180
+ try {
1181
+ let lastResponse = null
1182
+ let lastBody = null
1183
+ for (let attempt = 0; ; attempt++) {
1184
+ const response = await fetch(endpoint, {
1185
+ method: 'POST',
1186
+ headers: {
1187
+ 'Content-Type': 'application/json',
1188
+ Authorization: `Bearer ${key}`,
1189
+ },
1190
+ body: bodyOf(),
1191
+ signal: controller.signal,
1192
+ })
1193
+ lastResponse = response
1194
+ lastBody = await response.json().catch(() => null)
1195
+ // Transient failures (rate limit / 5xx) retry with backoff; everything
1196
+ // else (4xx auth/format/content-filter) is terminal.
1197
+ const retriable =
1198
+ response.status === 429 ||
1199
+ response.status === 408 ||
1200
+ response.status === 500 ||
1201
+ response.status === 502 ||
1202
+ response.status === 503 ||
1203
+ response.status === 504
1204
+ if (!response.ok && retriable && attempt < MAX_TRANSIENT_RETRIES) {
1205
+ const waitMs = retryAfterOf(response) || 800 * (attempt + 1)
1206
+ await sleep(waitMs)
1207
+ continue
1208
+ }
1209
+ break
1210
+ }
1211
+ const response = lastResponse
1212
+ const body = lastBody
1213
+ if (!response.ok) {
1214
+ const detailText = body?.error?.message ?? response.statusText
1215
+ throw new Error(
1216
+ `tool-vision: vision endpoint returned ${response.status}: ${detailText} (endpoint ${endpoint})`,
1217
+ )
1218
+ }
1219
+ const message = body?.choices?.[0]?.message
1220
+ let answer = message?.content ?? ''
1221
+ if (!answer.trim()) answer = message?.reasoning_content ?? ''
1222
+ if (typeof answer !== 'string' || !answer.trim()) {
1223
+ throw new Error('tool-vision: vision endpoint returned an empty response')
1224
+ }
1225
+ const trimmed = answer.trim()
1226
+ // max_tokens truncation: glm-4v-flash caps at 1024; a truncated JSON or
1227
+ // transcript must not masquerade as a complete answer.
1228
+ const finishReason = body?.choices?.[0]?.finish_reason
1229
+ if (finishReason === 'length') {
1230
+ return `${trimmed}\n\n[注意:模型输出因 max_tokens 上限被截断(finish_reason=length);以上内容不完整。]`
1231
+ }
1232
+ return trimmed
1233
+ } finally {
1234
+ clearTimeout(timer)
1235
+ signal?.removeEventListener('abort', onSignalAbort)
1236
+ }
1237
+ }
1238
+
1239
+ // ── shared tool plumbing ───────────────────────────────────────────────────
1240
+
1241
+ export const stringOutput = {
1242
+ schema: { type: 'string' },
1243
+ render: (_args, value) => [{ type: 'text', text: value }],
1244
+ }
1245
+
1246
+ /**
1247
+ * Build the 14 vision tools bound to this plugin instance.
1248
+ * @param {object} ctx - plugin context (attachments/fs services via ctx.get)
1249
+ * @param {() => object} getConfig - live settings getter
1250
+ */
1251
+ export function registerVisionTools(ctx, getConfig) {
1252
+ const logger = ctx.logger
1253
+
1254
+ /** Single-endpoint model call wrapper with the router's ok:false contract.
1255
+ * `images` is a single Buffer, or an array of Buffers / data-URL strings. */
1256
+ const answerVisionForTool = async (images, mediaType, instruction, options = {}) => {
1257
+ try {
1258
+ // Uniform pixel budget for EVERY model call: ground/detect/ocr/etc.
1259
+ // must not ship raw 20MB images (base64 ~27MB -> endpoint 413/timeout).
1260
+ // Downscale only Buffer inputs; pre-made data URLs pass through.
1261
+ const bounded = Array.isArray(images)
1262
+ ? await Promise.all(images.map(async (item) => (typeof item === 'string' ? item : bytesToDataUrl(await downscaleImage(item, 4_000_000), mediaType ?? 'image/png'))))
1263
+ : [bytesToDataUrl(await downscaleImage(images, 4_000_000), mediaType)]
1264
+ const text = await callVisionModel(getConfig, instruction, bounded, {
1265
+ signal: options.signal ?? (options.exec && options.exec.signal),
1266
+ })
1267
+ return { ok: true, text }
1268
+ } catch (error) {
1269
+ const raw = error && error.message ? String(error.message) : String(error)
1270
+ // Endpoint content-safety rejections are NOT backend outages: the image
1271
+ // itself was refused. Surface them distinctly so the model tells the
1272
+ // user to pick a different image instead of reporting "vision down".
1273
+ if (CONTENT_FILTER_RE.test(raw)) {
1274
+ return {
1275
+ ok: false,
1276
+ code: 'VISION_CONTENT_FILTERED',
1277
+ retryable: false,
1278
+ contentFiltered: true,
1279
+ message:
1280
+ '图片被视觉端点的内容安全策略拒绝(检测到敏感或不安全内容)。' +
1281
+ '这不是网络或配置问题,请换一张图片或调整图片内容后再试。',
1282
+ }
1283
+ }
1284
+ return {
1285
+ ok: false,
1286
+ code: 'VISION_BACKEND_UNAVAILABLE',
1287
+ retryable: false,
1288
+ message: raw,
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ const saveArtifact = (exec, relPath, data) => writeArtifact(exec, relPath, data)
1294
+ const artifactStem = (imagePath, suffix) => artifactStemOf(imagePath, suffix)
1295
+
1296
+ const tools = []
1297
+
1298
+ // ── vision_describe ──────────────────────────────────────────────────────
1299
+ tools.push({
1300
+ name: 'vision_describe',
1301
+ description:
1302
+ 'Look at images with the configured vision model and answer a focused question about them. ' +
1303
+ 'For text-only sessions this is the bridge that provides image understanding. ' +
1304
+ 'Supports comparing multiple images (e.g. a design mock vs an implementation screenshot). Provide ' +
1305
+ '`paths` (absolute local image file paths, png/jpeg/webp/gif) and/or ' +
1306
+ '`attachmentIds` (ids of images the user uploaded in this conversation), 1-4 images in ' +
1307
+ 'total. `question` is the question to answer; be specific. Set `json: true` to require a ' +
1308
+ 'single valid JSON object as the answer. ' +
1309
+ 'FAILURE SEMANTICS: if the result is JSON with ok:false, the vision backend is unavailable. ' +
1310
+ 'Do NOT call vision_describe again with a reworded question — rephrasing cannot fix an auth, ' +
1311
+ 'rate-limit or outage problem. Answer from the information you already have and continue the ' +
1312
+ 'text task, telling the user vision is temporarily unavailable.',
1313
+ parameters: {
1314
+ type: 'object',
1315
+ properties: {
1316
+ paths: {
1317
+ type: 'array',
1318
+ items: { type: 'string' },
1319
+ description:
1320
+ 'Absolute local image file paths and/or attachment ids (e.g. "sha256:...") of uploaded images, 1-4 images',
1321
+ },
1322
+ attachmentIds: {
1323
+ type: 'array',
1324
+ items: { type: 'string' },
1325
+ description: 'Attachment ids of images uploaded earlier in this conversation',
1326
+ },
1327
+ question: {
1328
+ type: 'string',
1329
+ description:
1330
+ 'The question for the vision model, e.g. "compare the two images and list the differences"',
1331
+ },
1332
+ json: {
1333
+ type: 'boolean',
1334
+ description: 'Require the answer to be a single valid JSON object',
1335
+ },
1336
+ },
1337
+ required: ['question'],
1338
+ additionalProperties: false,
1339
+ },
1340
+ output: stringOutput,
1341
+ async execute(args, exec) {
1342
+ const attachments = ctx.get('attachments')
1343
+ if (attachments === undefined) {
1344
+ throw new Error('vision_describe: the durable attachment service is not available in this deployment')
1345
+ }
1346
+ const blocks = []
1347
+ const contentIds = []
1348
+ const paths = Array.isArray(args.paths) ? args.paths : []
1349
+ const attachmentIds = Array.isArray(args.attachmentIds) ? args.attachmentIds : []
1350
+ if (paths.length + attachmentIds.length === 0 || paths.length + attachmentIds.length > 4) {
1351
+ throw new Error('vision_describe: provide 1-4 images via paths and/or attachmentIds')
1352
+ }
1353
+
1354
+ for (const path of paths) {
1355
+ let bytes
1356
+ let mediaType
1357
+ try {
1358
+ ;({ bytes, mediaType } = await readImageBytes(ctx, exec, path))
1359
+ } catch (error) {
1360
+ throw new Error(
1361
+ `vision_describe: failed to read ${path} (${error && error.message ? error.message : String(error)})`,
1362
+ )
1363
+ }
1364
+ bytes = await downscaleImage(bytes, 4_000_000)
1365
+ let ref
1366
+ try {
1367
+ ref = await attachments.saveImage({ data: bytes, mediaType })
1368
+ } catch (error) {
1369
+ throw new Error(
1370
+ `vision_describe: image ${path} was rejected (${error && error.message ? error.message : String(error)})`,
1371
+ )
1372
+ }
1373
+ contentIds.push(String(ref.attachmentId))
1374
+ blocks.push({ type: 'image', attachment: ref })
1375
+ }
1376
+
1377
+ for (const id of attachmentIds) {
1378
+ const session = exec && exec.agent && exec.agent.session
1379
+ const ref = lookupAttachment(session, String(id))
1380
+ if (ref === undefined) {
1381
+ throw new Error(
1382
+ `vision_describe: unknown attachment id "${id}" (it must come from an image uploaded in this conversation)`,
1383
+ )
1384
+ }
1385
+ let stored
1386
+ try {
1387
+ stored = await attachments.readImage(ref)
1388
+ } catch (error) {
1389
+ throw new Error(
1390
+ `vision_describe: failed to read attachment ${id} (${error && error.message ? error.message : String(error)})`,
1391
+ )
1392
+ }
1393
+ if (stored.data && stored.data.length > 0) {
1394
+ const resized = await downscaleImage(stored.data, 4_000_000)
1395
+ if (resized !== stored.data) {
1396
+ try {
1397
+ const resizedRef = await attachments.saveImage({
1398
+ data: resized,
1399
+ mediaType: stored.ref && stored.ref.mediaType ? stored.ref.mediaType : 'image/png',
1400
+ ...(stored.ref && stored.ref.name ? { name: stored.ref.name } : {}),
1401
+ })
1402
+ stored = { ref: resizedRef, data: resized }
1403
+ } catch {
1404
+ stored = { ...stored, data: resized }
1405
+ }
1406
+ }
1407
+ }
1408
+ contentIds.push(String(ref.attachmentId))
1409
+ blocks.push({ type: 'image', attachment: stored.ref })
1410
+ }
1411
+
1412
+ const question = String(args.question ?? '')
1413
+ const wantJson = args.json === true
1414
+ const promptText = visionDescribePrompt(question, wantJson)
1415
+ // Cache key carries the endpoint/model identity: switching the vision
1416
+ // model must never serve stale answers from the previous model.
1417
+ const cfg = getConfig()
1418
+ const key = `${cfg.baseURL ?? ''}|${cfg.model ?? ''}|${contentIds.join('|')}|${wantJson ? 'json' : 'text'}|${question}`
1419
+ const cache = visionCache
1420
+ if (cacheEnabled()) {
1421
+ const hit = cache.get(key)
1422
+ if (hit !== undefined) return hit
1423
+ }
1424
+
1425
+ // Call with all images as data URLs.
1426
+ const imageUrls = []
1427
+ for (const block of blocks) {
1428
+ const stored = await attachments.readImage(block.attachment)
1429
+ imageUrls.push(bytesToDataUrl(stored.data, stored.ref && stored.ref.mediaType ? stored.ref.mediaType : 'image/png'))
1430
+ }
1431
+ const vision = await answerVisionForTool(imageUrls, 'image/png', promptText, { exec })
1432
+ if (vision.ok === false) return JSON.stringify(vision)
1433
+
1434
+ let text = vision.text
1435
+ if (wantJson) {
1436
+ for (let attempt = 0; attempt < 2; attempt++) {
1437
+ const parsed = extractJson(text)
1438
+ if (parsed !== undefined) {
1439
+ const compact = JSON.stringify(normalizeDescribeResult(parsed) ?? parsed)
1440
+ if (cacheEnabled()) cache.set(key, compact)
1441
+ return compact
1442
+ }
1443
+ if (attempt === 0) {
1444
+ const retry = await answerVisionForTool(imageUrls, 'image/png', promptText + '\n\nThat output was not valid JSON. Respond with ONLY a valid JSON object now.', { exec })
1445
+ if (retry.ok === false) return JSON.stringify(retry)
1446
+ text = retry.text
1447
+ }
1448
+ }
1449
+ // Failure/fallback output must NOT be cached: it would poison the
1450
+ // cache for the whole TTL with an error string masquerading as an
1451
+ // answer.
1452
+ return `vision_describe: the model did not produce valid JSON. Raw output:\n${text.slice(0, 2000)}`
1453
+ }
1454
+ if (text !== '') {
1455
+ if (cacheEnabled()) cache.set(key, text)
1456
+ return text
1457
+ }
1458
+ // Empty output is a failure signal, not a cacheable answer.
1459
+ return '(the vision model returned empty content)'
1460
+ },
1461
+ })
1462
+
1463
+ // ── vision_materialize ───────────────────────────────────────────────────
1464
+ tools.push({
1465
+ name: 'vision_materialize',
1466
+ description:
1467
+ 'Copy an uploaded image attachment (sha256:...) or readable local image into the session workspace and return a real filesystem path. ' +
1468
+ 'This tool performs NO vision model/network call. Use it after vision_describe returns ok:false when a local OCR/parser accepts only file_path. ' +
1469
+ 'Never guess the attachment store path or search for a same-named file.',
1470
+ parameters: {
1471
+ type: 'object',
1472
+ properties: {
1473
+ image: { type: 'string', description: 'Uploaded image attachment id (recommended, e.g. sha256:...) or a readable local image path' },
1474
+ },
1475
+ required: ['image'],
1476
+ additionalProperties: false,
1477
+ },
1478
+ output: stringOutput,
1479
+ async execute(args, exec) {
1480
+ const source = String(args.image ?? '')
1481
+ const { bytes, mediaType } = await readImageBytes(ctx, exec, source)
1482
+ const extension = mediaType === 'image/jpeg' ? '.jpg' : (EXT_BY_MEDIA[mediaType] ?? '.png')
1483
+ const target = await saveArtifact(exec, `${artifactStem(source, 'materialized')}${extension}`, bytes)
1484
+ return JSON.stringify({ path: target, bytes: bytes.length, mediaType })
1485
+ },
1486
+ })
1487
+
1488
+ // ── vision_ground ────────────────────────────────────────────────────────
1489
+ tools.push({
1490
+ name: 'vision_ground',
1491
+ description:
1492
+ 'Locate a target in an image and return its ORIGINAL-pixel bounding box (x1/y1/x2/y2), ' +
1493
+ 'optionally producing an annotated PNG artifact. Pair with vision_crop and vision_pixel_diff ' +
1494
+ 'for a verify-able pixel loop (reference -> implementation -> screenshot -> metrics). ' +
1495
+ 'If the result is JSON with ok:false, the vision backend is unavailable — do not retry with ' +
1496
+ 'reworded instructions this turn.',
1497
+ parameters: {
1498
+ type: 'object',
1499
+ properties: {
1500
+ image: { type: 'string', description: 'Local image path (png/jpeg/webp/gif), workspace-relative or absolute; or the attachment id (e.g. "sha256:...") of an image uploaded in this conversation' },
1501
+ target: { type: 'string', description: 'What to locate, e.g. "the send button"' },
1502
+ annotate: { type: 'boolean', description: 'Also write an annotated PNG with the box drawn (default true)' },
1503
+ },
1504
+ required: ['image', 'target'],
1505
+ additionalProperties: false,
1506
+ },
1507
+ output: stringOutput,
1508
+ async execute(args, exec) {
1509
+ const { bytes, mediaType } = await readImageBytes(ctx, exec, args.image)
1510
+ const { width, height } = await imageDims(bytes)
1511
+ if (width <= 0 || height <= 0) throw new Error('vision_ground: could not read image dimensions')
1512
+ const instruction =
1513
+ `Target to locate: "${String(args.target).slice(0, 500)}". ` +
1514
+ `The image is ${width}x${height} pixels. Return ONE JSON object with integer fields ` +
1515
+ `{"x1":...,"y1":...,"x2":...,"y2":...} — the tight bounding box of that target in ` +
1516
+ `ORIGINAL image pixels (0 <= x1 < x2 <= ${width}, 0 <= y1 < y2 <= ${height}). ` +
1517
+ `Output only the JSON object.`
1518
+ const vision = await answerVisionForTool(bytes, mediaType, instruction, { exec })
1519
+ if (vision.ok === false) return JSON.stringify(vision)
1520
+ const parsed = extractJson(vision.text)
1521
+ const box = parsed !== undefined ? parseBox(parsed) : undefined
1522
+ if (box === undefined) {
1523
+ throw new Error(`vision_ground: the vision model did not return a valid box. Raw output: ${vision.text.slice(0, 500)}`)
1524
+ }
1525
+ let clamped = {
1526
+ x1: Math.max(0, Math.min(box.x1, width - 1)),
1527
+ y1: Math.max(0, Math.min(box.y1, height - 1)),
1528
+ x2: Math.max(1, Math.min(box.x2, width)),
1529
+ y2: Math.max(1, Math.min(box.y2, height)),
1530
+ }
1531
+ if (clamped.x2 - clamped.x1 < 2 || clamped.y2 - clamped.y1 < 2) {
1532
+ const retry = await answerVisionForTool(
1533
+ bytes,
1534
+ mediaType,
1535
+ `Your previous box ${JSON.stringify(clamped)} was a degenerate sliver, not the target. ` +
1536
+ `Return ONE JSON object with the FULL tight bounding box of the target in ORIGINAL ` +
1537
+ `image pixels (0 <= x1 < x2 <= ${width}, 0 <= y1 < y2 <= ${height}). Output only the JSON object.`,
1538
+ { exec },
1539
+ )
1540
+ if (retry.ok === false) return JSON.stringify(retry)
1541
+ const retryParsed = extractJson(retry.text)
1542
+ const retryBox = retryParsed !== undefined ? parseBox(retryParsed) : undefined
1543
+ if (retryBox === undefined) {
1544
+ throw new Error(
1545
+ `vision_ground: the vision model returned a degenerate box (${clamped.x1},${clamped.y1},${clamped.x2},${clamped.y2}) ` +
1546
+ `and the retry returned no valid box. Raw output: ${retry.text.slice(0, 500)}`,
1547
+ )
1548
+ }
1549
+ clamped = {
1550
+ x1: Math.max(0, Math.min(retryBox.x1, width - 1)),
1551
+ y1: Math.max(0, Math.min(retryBox.y1, height - 1)),
1552
+ x2: Math.max(1, Math.min(retryBox.x2, width)),
1553
+ y2: Math.max(1, Math.min(retryBox.y2, height)),
1554
+ }
1555
+ if (clamped.x2 - clamped.x1 < 2 || clamped.y2 - clamped.y1 < 2) {
1556
+ throw new Error(
1557
+ `vision_ground: the vision model returned only degenerate boxes for a ${width}x${height} image. ` +
1558
+ `Last raw output: ${retry.text.slice(0, 500)}`,
1559
+ )
1560
+ }
1561
+ }
1562
+ const result = { ...clamped, width, height }
1563
+ if (args.annotate !== false) {
1564
+ const annotated = await annotateBoxBuffer(bytes, clamped)
1565
+ result.annotatedPath = await saveArtifact(exec, `${artifactStem(args.image, 'ground')}.png`, annotated)
1566
+ }
1567
+ return JSON.stringify(result)
1568
+ },
1569
+ })
1570
+
1571
+ // ── vision_detect ────────────────────────────────────────────────────────
1572
+ tools.push({
1573
+ name: 'vision_detect',
1574
+ description:
1575
+ 'Find every element of a kind in an image (buttons, inputs, links, icons…) and return a ' +
1576
+ 'numbered inventory with ORIGINAL-pixel boxes, optionally annotated on the image. The model ' +
1577
+ 'can then reference "element #3" in follow-up vision_crop / vision_describe calls. ' +
1578
+ 'If the result is JSON with ok:false, the vision backend is unavailable — do not retry with ' +
1579
+ 'reworded instructions this turn.',
1580
+ parameters: {
1581
+ type: 'object',
1582
+ properties: {
1583
+ image: { type: 'string', description: 'Local image path (png/jpeg/webp/gif), workspace-relative or absolute; or the attachment id (e.g. "sha256:...") of an image uploaded in this conversation' },
1584
+ target: {
1585
+ type: 'string',
1586
+ description: 'What kind of elements to list, e.g. "buttons", "input fields", "navigation links" (default: interactive elements)',
1587
+ },
1588
+ annotate: {
1589
+ type: 'boolean',
1590
+ description: 'Also write an annotated PNG with numbered boxes (default true)',
1591
+ },
1592
+ },
1593
+ required: ['image'],
1594
+ additionalProperties: false,
1595
+ },
1596
+ output: stringOutput,
1597
+ async execute(args, exec) {
1598
+ const { bytes, mediaType } = await readImageBytes(ctx, exec, args.image)
1599
+ const { width, height } = await imageDims(bytes)
1600
+ if (width <= 0 || height <= 0) throw new Error('vision_detect: could not read image dimensions')
1601
+ const target = typeof args.target === 'string' && args.target.trim() !== '' ? args.target : 'interactive elements'
1602
+ const vision = await answerVisionForTool(bytes, mediaType, visionDetectInstruction(target, width, height), { exec })
1603
+ if (vision.ok === false) return JSON.stringify(vision)
1604
+ let text = vision.text
1605
+ let parsed = extractJson(text)
1606
+ if (parsed === undefined) {
1607
+ const retry = await answerVisionForTool(
1608
+ bytes,
1609
+ mediaType,
1610
+ visionDetectInstruction(target, width, height) +
1611
+ '\nYour previous answer was not valid JSON. Respond with ONLY the JSON object, no prose, no fences.',
1612
+ { exec },
1613
+ )
1614
+ if (retry.ok === false) return JSON.stringify(retry)
1615
+ parsed = extractJson(retry.text)
1616
+ text = retry.text
1617
+ }
1618
+ const result = normalizeDetectResult(parsed, width, height)
1619
+ if (result === undefined) {
1620
+ throw new Error(`vision_detect: the vision model did not return a valid inventory. Raw output: ${text.slice(0, 500)}`)
1621
+ }
1622
+ if (args.annotate !== false && result.elements.length > 0) {
1623
+ const annotated = await annotateBoxesBuffer(bytes, result.elements.map((e) => e.box))
1624
+ result.annotatedPath = await saveArtifact(exec, `${artifactStem(args.image, 'detect')}.png`, annotated)
1625
+ }
1626
+ return JSON.stringify(result)
1627
+ },
1628
+ })
1629
+
1630
+ // ── vision_crop ──────────────────────────────────────────────────────────
1631
+ tools.push({
1632
+ name: 'vision_crop',
1633
+ description:
1634
+ 'Crop a pixel region (x1,y1,x2,y2 in ORIGINAL pixels) out of an image and write the ' +
1635
+ 'result as a PNG artifact for a closer look. Very large regions are rendered as a bounded ' +
1636
+ 'preview; crop a smaller ORIGINAL-pixel region when tiny details must be preserved.',
1637
+ parameters: {
1638
+ type: 'object',
1639
+ properties: {
1640
+ image: { type: 'string', description: 'Local image path (png/jpeg/webp/gif), workspace-relative or absolute; or the attachment id (e.g. "sha256:...") of an image uploaded in this conversation' },
1641
+ region: {
1642
+ type: 'string',
1643
+ description: 'Pixel box "x1,y1,x2,y2" in original image coordinates',
1644
+ },
1645
+ },
1646
+ required: ['image', 'region'],
1647
+ additionalProperties: false,
1648
+ },
1649
+ output: stringOutput,
1650
+ async execute(args, exec) {
1651
+ const { bytes } = await readImageBytes(ctx, exec, args.image)
1652
+ const { width, height } = await imageDims(bytes)
1653
+ const box = parseBox(args.region)
1654
+ if (box === undefined) {
1655
+ throw new Error(`vision_crop: invalid region "${args.region}" (expect "x1,y1,x2,y2" integers)`)
1656
+ }
1657
+ if (box.x2 > width || box.y2 > height) {
1658
+ throw new Error(`vision_crop: region exceeds image bounds (${width}x${height})`)
1659
+ }
1660
+ const sharp = await loadSharp()
1661
+ if (!sharp) throw new Error('vision_crop: the sharp image library is unavailable')
1662
+ const sourceWidth = box.x2 - box.x1
1663
+ const sourceHeight = box.y2 - box.y1
1664
+ const preview = scaledDimensions(sourceWidth, sourceHeight, 4_000_000)
1665
+ let pipeline = sharp(bytes, { failOn: 'none' }).extract({
1666
+ left: box.x1,
1667
+ top: box.y1,
1668
+ width: sourceWidth,
1669
+ height: sourceHeight,
1670
+ })
1671
+ if (preview.scale !== 1) {
1672
+ pipeline = pipeline.resize(preview.width, preview.height, { fit: 'fill' })
1673
+ }
1674
+ const cropped = await pipeline.png().toBuffer()
1675
+ const target = await saveArtifact(
1676
+ exec,
1677
+ `${artifactStem(args.image, `crop-${box.x1}-${box.y1}-${box.x2}-${box.y2}`)}.png`,
1678
+ cropped,
1679
+ )
1680
+ const meta = await sharp(cropped).metadata()
1681
+ return JSON.stringify({
1682
+ path: target,
1683
+ width: meta.width ?? preview.width,
1684
+ height: meta.height ?? preview.height,
1685
+ bytes: cropped.length,
1686
+ ...(preview.scale !== 1
1687
+ ? {
1688
+ preview: true,
1689
+ sourceRegion: box,
1690
+ sourceWidth,
1691
+ sourceHeight,
1692
+ scale: preview.scale,
1693
+ advice: 'This was a bounded preview of a large crop. Use vision_crop again with a smaller ORIGINAL-pixel region for tiny details.',
1694
+ }
1695
+ : {}),
1696
+ })
1697
+ },
1698
+ })
1699
+
1700
+ // ── vision_present ───────────────────────────────────────────────────────
1701
+ const visionPresentOutput = {
1702
+ schema: {
1703
+ type: 'object',
1704
+ properties: {
1705
+ path: { type: 'string' },
1706
+ label: { type: 'string' },
1707
+ width: { type: 'number' },
1708
+ height: { type: 'number' },
1709
+ bytes: { type: 'number' },
1710
+ safePresentation: { type: 'boolean' },
1711
+ attachment: {
1712
+ type: 'object',
1713
+ properties: {
1714
+ attachmentId: { type: 'string' },
1715
+ mediaType: { type: 'string' },
1716
+ bytes: { type: 'number' },
1717
+ width: { type: 'number' },
1718
+ height: { type: 'number' },
1719
+ name: { type: 'string' },
1720
+ },
1721
+ required: ['attachmentId', 'mediaType', 'bytes', 'width', 'height'],
1722
+ additionalProperties: false,
1723
+ },
1724
+ },
1725
+ required: ['path', 'label', 'width', 'height', 'bytes', 'safePresentation', 'attachment'],
1726
+ additionalProperties: false,
1727
+ },
1728
+ render: (_args, value) => {
1729
+ const attachment = value.attachment
1730
+ return [
1731
+ {
1732
+ type: 'text',
1733
+ text: JSON.stringify({
1734
+ path: value.path,
1735
+ label: value.label,
1736
+ width: value.width,
1737
+ height: value.height,
1738
+ bytes: value.bytes,
1739
+ safePresentation: true,
1740
+ attachmentId: String(attachment.attachmentId),
1741
+ }),
1742
+ },
1743
+ { type: 'image', attachment },
1744
+ ]
1745
+ },
1746
+ }
1747
+ tools.push({
1748
+ name: 'vision_present',
1749
+ description:
1750
+ 'Present a generated local image directly to the user with the host-native image preview. ' +
1751
+ 'MANDATORY PRESENTATION RULE: when you generate, edit, screenshot, or export an image and want the user to see it, ' +
1752
+ 'you MUST call vision_present. read_image is only for model-side inspection; NEVER use read_image to present or send ' +
1753
+ 'an image to the user. The image is retained in the session UI but sanitized out of later text-only model requests.',
1754
+ parameters: {
1755
+ type: 'object',
1756
+ properties: {
1757
+ image: { type: 'string', description: 'Local image path (png/jpeg/webp/gif), workspace-relative or absolute; or the attachment id (e.g. "sha256:...") of an image uploaded in this conversation' },
1758
+ label: { type: 'string', description: 'Optional short user-facing label for the image' },
1759
+ },
1760
+ required: ['image'],
1761
+ additionalProperties: false,
1762
+ },
1763
+ output: visionPresentOutput,
1764
+ async execute(args, exec) {
1765
+ const attachments = ctx.get('attachments')
1766
+ if (attachments === undefined) {
1767
+ throw new Error('vision_present: the durable attachment service is not available in this deployment')
1768
+ }
1769
+ const { bytes, mediaType } = await readImageBytes(ctx, exec, args.image)
1770
+ const label =
1771
+ typeof args.label === 'string' && args.label.trim() !== '' ? args.label.trim().slice(0, 200) : 'image'
1772
+ const extension =
1773
+ mediaType === 'image/jpeg' ? 'jpg' :
1774
+ mediaType === 'image/webp' ? 'webp' :
1775
+ mediaType === 'image/gif' ? 'gif' : 'png'
1776
+ const target = await saveArtifact(exec, `${artifactStem(args.image, 'present')}.${extension}`, bytes)
1777
+ let attachment
1778
+ try {
1779
+ attachment = await attachments.saveImage({
1780
+ data: bytes,
1781
+ mediaType,
1782
+ name: label,
1783
+ })
1784
+ } catch (error) {
1785
+ throw new Error(
1786
+ `vision_present: failed to publish the image attachment (${error && error.message ? error.message : String(error)})`,
1787
+ )
1788
+ }
1789
+ return {
1790
+ path: target,
1791
+ label,
1792
+ width: attachment.width,
1793
+ height: attachment.height,
1794
+ bytes: attachment.bytes,
1795
+ safePresentation: true,
1796
+ attachment,
1797
+ }
1798
+ },
1799
+ })
1800
+
1801
+ // ── vision_pixel_diff ────────────────────────────────────────────────────
1802
+ tools.push({
1803
+ name: 'vision_pixel_diff',
1804
+ description:
1805
+ 'Compare two images pixel by pixel (sharp-based, no Python): returns the differing-pixel ' +
1806
+ 'ratio, the worst 8x8-grid regions as original-pixel boxes, and writes a red heatmap PNG ' +
1807
+ 'plus a JSON report as artifacts. Use it to verify an implementation against a reference.',
1808
+ parameters: {
1809
+ type: 'object',
1810
+ properties: {
1811
+ original: { type: 'string', description: 'Reference image path or attachment id (e.g. "sha256:...")' },
1812
+ rebuilt: { type: 'string', description: 'Candidate image path or attachment id (e.g. "sha256:..."); resized to the original size before comparing' },
1813
+ threshold: { type: 'number', description: 'Per-channel difference threshold, default 16' },
1814
+ },
1815
+ required: ['original', 'rebuilt'],
1816
+ additionalProperties: false,
1817
+ },
1818
+ output: stringOutput,
1819
+ async execute(args, exec) {
1820
+ const { bytes: originalBytes } = await readImageBytes(ctx, exec, args.original)
1821
+ const { bytes: rebuiltBytes } = await readImageBytes(ctx, exec, args.rebuilt)
1822
+ const sharp = await loadSharp()
1823
+ if (!sharp) throw new Error('vision_pixel_diff: the sharp image library is unavailable')
1824
+ const meta = await sharp(originalBytes, { failOn: 'none' }).metadata()
1825
+ const width = meta.width ?? 0
1826
+ const height = meta.height ?? 0
1827
+ if (width <= 0 || height <= 0) throw new Error('vision_pixel_diff: could not read original dimensions')
1828
+ const threshold = Number.isFinite(args.threshold) && args.threshold >= 0 ? Math.round(args.threshold) : 16
1829
+ const pixels = width * height
1830
+ let diff
1831
+ let heatmapPng
1832
+ let heatmapPreview = false
1833
+ let heatmapWidth = width
1834
+ let heatmapHeight = height
1835
+ if (pixels <= 4_000_000) {
1836
+ const originalRaw = await sharp(originalBytes, { failOn: 'none' })
1837
+ .ensureAlpha()
1838
+ .raw()
1839
+ .toBuffer({ resolveWithObject: true })
1840
+ const rebuiltRaw = await sharp(rebuiltBytes, { failOn: 'none' })
1841
+ .resize(width, height, { fit: 'fill' })
1842
+ .ensureAlpha()
1843
+ .raw()
1844
+ .toBuffer({ resolveWithObject: true })
1845
+ diff = computePixelDiff(originalRaw.data, rebuiltRaw.data, threshold, width, height)
1846
+ const heatmap = renderDiffHeatmap(originalRaw.data, diff.mask, width, height)
1847
+ heatmapPng = await sharp(heatmap, { raw: { width, height, channels: 4 } })
1848
+ .png()
1849
+ .toBuffer()
1850
+ } else {
1851
+ // Exact large-image metrics from streaming RGBA; heatmap bounded to 4MP.
1852
+ const originalStream = sharp(originalBytes, { failOn: 'none' }).ensureAlpha().raw()
1853
+ const rebuiltStream = sharp(rebuiltBytes, { failOn: 'none' })
1854
+ .resize(width, height, { fit: 'fill' })
1855
+ .ensureAlpha()
1856
+ .raw()
1857
+ diff = await compareRgbaStreams(originalStream, rebuiltStream, { width, height, threshold })
1858
+ const preview = scaledDimensions(width, height, 4_000_000)
1859
+ heatmapWidth = preview.width
1860
+ heatmapHeight = preview.height
1861
+ heatmapPreview = true
1862
+ const originalPreview = await sharp(originalBytes, { failOn: 'none' })
1863
+ .resize(preview.width, preview.height, { fit: 'fill' })
1864
+ .ensureAlpha()
1865
+ .raw()
1866
+ .toBuffer({ resolveWithObject: true })
1867
+ const rebuiltPreview = await sharp(rebuiltBytes, { failOn: 'none' })
1868
+ .resize(preview.width, preview.height, { fit: 'fill' })
1869
+ .ensureAlpha()
1870
+ .raw()
1871
+ .toBuffer({ resolveWithObject: true })
1872
+ const previewDiff = computePixelDiff(
1873
+ originalPreview.data,
1874
+ rebuiltPreview.data,
1875
+ threshold,
1876
+ preview.width,
1877
+ preview.height,
1878
+ )
1879
+ const heatmap = renderDiffHeatmap(originalPreview.data, previewDiff.mask, preview.width, preview.height)
1880
+ heatmapPng = await sharp(heatmap, {
1881
+ raw: { width: preview.width, height: preview.height, channels: 4 },
1882
+ }).png().toBuffer()
1883
+ }
1884
+ const worst = diff.cells.slice(0, 5).map((cell) => ({
1885
+ x1: cell.x1,
1886
+ y1: cell.y1,
1887
+ x2: cell.x2,
1888
+ y2: cell.y2,
1889
+ ratio: Number(cell.ratio.toFixed(4)),
1890
+ differing: cell.differing,
1891
+ }))
1892
+ const heatmapPath = await saveArtifact(exec, `${artifactStem(args.original, 'diff-heatmap')}.png`, heatmapPng)
1893
+ const report = {
1894
+ width,
1895
+ height,
1896
+ differingPixels: diff.differing,
1897
+ totalPixels: diff.total,
1898
+ differingRatio: Number(diff.ratio.toFixed(6)),
1899
+ threshold,
1900
+ worstRegions: worst,
1901
+ heatmapPath,
1902
+ heatmapWidth,
1903
+ heatmapHeight,
1904
+ heatmapPreview,
1905
+ }
1906
+ const reportPath = await saveArtifact(exec, `${artifactStem(args.original, 'diff-report')}.json`, Buffer.from(JSON.stringify(report, null, 2)))
1907
+ return JSON.stringify({ ...report, reportPath })
1908
+ },
1909
+ })
1910
+
1911
+ // ── vision_colors ────────────────────────────────────────────────────────
1912
+ tools.push({
1913
+ name: 'vision_colors',
1914
+ description:
1915
+ 'Extract the dominant colors of an image (sharp-based quantization) with their share of ' +
1916
+ 'pixels, e.g. to match a palette when rebuilding a UI.',
1917
+ parameters: {
1918
+ type: 'object',
1919
+ properties: {
1920
+ image: { type: 'string', description: 'Local image path (png/jpeg/webp/gif), workspace-relative or absolute; or the attachment id (e.g. "sha256:...") of an image uploaded in this conversation' },
1921
+ top: { type: 'number', description: 'How many colors to return, default 8' },
1922
+ },
1923
+ required: ['image'],
1924
+ additionalProperties: false,
1925
+ },
1926
+ output: stringOutput,
1927
+ async execute(args, exec) {
1928
+ const { bytes } = await readImageBytes(ctx, exec, args.image)
1929
+ const top = Number.isInteger(args.top) && args.top > 0 ? args.top : 8
1930
+ const sharp = await loadSharp()
1931
+ if (!sharp) throw new Error('vision_colors: the sharp image library is unavailable')
1932
+ const raw = await sharp(bytes, { failOn: 'none' })
1933
+ .resize(64, 64, { fit: 'inside' })
1934
+ .ensureAlpha()
1935
+ .raw()
1936
+ .toBuffer({ resolveWithObject: true })
1937
+ const colors = quantizeColors(raw.data, Math.min(top, 32))
1938
+ return JSON.stringify(colors)
1939
+ },
1940
+ })
1941
+
1942
+ // ── vision_ocr ───────────────────────────────────────────────────────────
1943
+ tools.push({
1944
+ name: 'vision_ocr',
1945
+ description:
1946
+ 'Transcribe TEXT from an image with the vision model. ' +
1947
+ 'Returns the text and which engine produced it. ' +
1948
+ 'SCOPE: vision_ocr reads letters, it does NOT recognize people, objects or scenes. Never use it ' +
1949
+ 'as a fallback when vision_describe fails to identify who/what is in a picture ("这是谁" / ' +
1950
+ '"这是什么东西" questions are answered by vision_describe, not OCR). If vision_describe returns ' +
1951
+ 'ok:false with a backend-unavailable code, calling vision_ocr instead will fail the same way — ' +
1952
+ 'do not chain these tools as retries of each other. ' +
1953
+ 'ACCURACY: OCR transcribes characters verbatim and is systematically unreliable for confusable ' +
1954
+ 'glyphs (1/l, 0/O), spacing and line breaks; prefer vision_describe / vision_detect for semantic ' +
1955
+ 'understanding and use OCR only when exact verbatim text is required (executable code, exact ' +
1956
+ 'quotation, forms/contracts, table digits, CAPTCHAs). Treat OCR output as evidence to verify, ' +
1957
+ 'never as ground truth.',
1958
+ parameters: {
1959
+ type: 'object',
1960
+ properties: {
1961
+ image: { type: 'string', description: 'Local image path (png/jpeg/webp/gif), workspace-relative or absolute; or the attachment id (e.g. "sha256:...") of an image uploaded in this conversation' },
1962
+ engine: {
1963
+ type: 'string',
1964
+ description: '"auto" (default) or "vision": transcribe with the configured vision model. "tesseract" is not available in this build.',
1965
+ },
1966
+ },
1967
+ required: ['image'],
1968
+ additionalProperties: false,
1969
+ },
1970
+ output: stringOutput,
1971
+ async execute(args, exec) {
1972
+ const { bytes, mediaType } = await readImageBytes(ctx, exec, args.image)
1973
+ const engine = args.engine === 'vision' ? 'vision' : 'auto'
1974
+ if (args.engine === 'tesseract') {
1975
+ throw new Error('vision_ocr: the local tesseract engine is not available in this build; use engine "auto" or "vision"')
1976
+ }
1977
+ const instruction =
1978
+ '请原样转述图中的所有文字,保持阅读顺序(从上到下、从左到右)与段落结构,不要添加解释。只输出文字本身。'
1979
+ const vision = await answerVisionForTool(bytes, mediaType, instruction, { exec })
1980
+ if (vision.ok === false) {
1981
+ return JSON.stringify({ engine: 'none', ...vision, text: '' })
1982
+ }
1983
+ return JSON.stringify({ engine, text: vision.text })
1984
+ },
1985
+ })
1986
+
1987
+ // ── vision_long_screenshot_ocr ───────────────────────────────────────────
1988
+ tools.push({
1989
+ name: 'vision_long_screenshot_ocr',
1990
+ description:
1991
+ 'Transcribe a LONG screenshot (chat logs, long documents) into ordered Markdown. ' +
1992
+ 'Splits the image into overlapping horizontal chunks, OCRs each chunk with the vision ' +
1993
+ 'model, and stitches the text in reading order. Writes chunk PNGs, the Markdown, and a ' +
1994
+ 'manifest into the workspace artifacts directory.',
1995
+ parameters: {
1996
+ type: 'object',
1997
+ properties: {
1998
+ image: { type: 'string', description: 'Local image path (png/jpeg/webp/gif), workspace-relative or absolute; or the attachment id (e.g. "sha256:...") of an image uploaded in this conversation' },
1999
+ chunkHeight: { type: 'number', description: 'Chunk height in pixels, default 1200' },
2000
+ overlap: { type: 'number', description: 'Overlap between adjacent chunks in pixels, default 120' },
2001
+ engine: { type: 'string', description: '"auto" (default) or "vision": transcribe with the configured vision model.' },
2002
+ },
2003
+ required: ['image'],
2004
+ additionalProperties: false,
2005
+ },
2006
+ output: stringOutput,
2007
+ async execute(args, exec) {
2008
+ const { bytes, mediaType } = await readImageBytes(ctx, exec, args.image)
2009
+ const sharp = await loadSharp()
2010
+ if (!sharp) throw new Error('vision_long_screenshot_ocr: the sharp image library is unavailable')
2011
+ const meta = await sharp(bytes, { failOn: 'none' }).metadata()
2012
+ const width = meta.width ?? 0
2013
+ const height = meta.height ?? 0
2014
+ if (width <= 0 || height <= 0) {
2015
+ throw new Error('vision_long_screenshot_ocr: could not read image dimensions')
2016
+ }
2017
+ const chunkHeight =
2018
+ Number.isInteger(args.chunkHeight) && args.chunkHeight >= 400
2019
+ ? Math.min(args.chunkHeight, 2000)
2020
+ : 1200
2021
+ const overlap =
2022
+ Number.isInteger(args.overlap) && args.overlap >= 0
2023
+ ? Math.min(args.overlap, Math.floor(chunkHeight / 2))
2024
+ : 120
2025
+ if (args.engine === 'tesseract') {
2026
+ throw new Error('vision_long_screenshot_ocr: the local tesseract engine is not available in this build; use engine "auto" or "vision"')
2027
+ }
2028
+ const windows = boundedOcrTiles(width, height, {
2029
+ chunkHeight,
2030
+ overlap,
2031
+ maxTilePixels: 4_000_000,
2032
+ })
2033
+ // Hard bounds: a tall screenshot must never turn into an unbounded,
2034
+ // uncancellable model-call loop (router's 30s budget was dropped here,
2035
+ // so re-introduce explicit limits).
2036
+ const MAX_OCR_CHUNKS = 40
2037
+ const OCR_TOTAL_BUDGET_MS = 120_000
2038
+ const ocrStartedAt = Date.now()
2039
+ const signal = exec && exec.signal
2040
+ const stem = artifactStem(args.image, 'ocr')
2041
+ const results = []
2042
+ let visionFailed = false
2043
+ for (let i = 0; i < windows.length; i++) {
2044
+ if (signal?.aborted) break
2045
+ if (i >= MAX_OCR_CHUNKS) {
2046
+ results.push({
2047
+ chunk: i + 1,
2048
+ left: windows[i].left,
2049
+ right: windows[i].right,
2050
+ top: windows[i].top,
2051
+ bottom: windows[i].bottom,
2052
+ engine: 'skipped',
2053
+ chars: 0,
2054
+ text: '',
2055
+ error: 'chunk limit reached',
2056
+ })
2057
+ continue
2058
+ }
2059
+ if (Date.now() - ocrStartedAt > OCR_TOTAL_BUDGET_MS) {
2060
+ results.push({
2061
+ chunk: i + 1,
2062
+ left: windows[i].left,
2063
+ right: windows[i].right,
2064
+ top: windows[i].top,
2065
+ bottom: windows[i].bottom,
2066
+ engine: 'skipped',
2067
+ chars: 0,
2068
+ text: '',
2069
+ error: 'OCR total budget exhausted',
2070
+ })
2071
+ continue
2072
+ }
2073
+ const { left, right, top, bottom } = windows[i]
2074
+ const tileWidth = right - left
2075
+ const tileHeight = bottom - top
2076
+ const chunk = await sharp(bytes, { failOn: 'none' })
2077
+ .extract({ left, top, width: tileWidth, height: tileHeight })
2078
+ .png()
2079
+ .toBuffer()
2080
+ const chunkRel = `chunk-${String(i + 1).padStart(2, '0')}.png`
2081
+ await writeArtifact(exec, join(stem, chunkRel), chunk)
2082
+ let text = ''
2083
+ let used = 'none'
2084
+ // Upload JPEG without an alpha channel: some vision backends degrade
2085
+ // on RGBA PNGs and hallucinate token-fragment text.
2086
+ const visionBytes = await sharp(chunk, { failOn: 'none' })
2087
+ .removeAlpha()
2088
+ .jpeg({ quality: 92 })
2089
+ .toBuffer()
2090
+ const instruction =
2091
+ '请原样转述这张长截图分片中的所有文字,保持阅读顺序(从上到下、从左到右),' +
2092
+ '不要添加解释,只输出文字本身。如果画面中没有可见文字,只输出 EMPTY,不要编造内容。'
2093
+ if (!visionFailed) {
2094
+ const visionResult = await answerVisionForTool(visionBytes, 'image/jpeg', instruction, { exec })
2095
+ if (visionResult.ok === false) {
2096
+ // Backend failure: stop burning calls on the remaining chunks.
2097
+ visionFailed = true
2098
+ used = 'failed'
2099
+ } else {
2100
+ text = visionResult.text.trim()
2101
+ if (text.length > 12000) {
2102
+ const retry = await answerVisionForTool(
2103
+ visionBytes,
2104
+ 'image/jpeg',
2105
+ '重新转写这张图片中的真实文字,保持阅读顺序。只输出图中肉眼可见的文字,' +
2106
+ '禁止编造、禁止重复;总输出不超过 3000 字。没有任何文字就只输出 EMPTY。',
2107
+ { exec },
2108
+ )
2109
+ if (retry.ok === false) {
2110
+ visionFailed = true
2111
+ used = 'failed'
2112
+ text = ''
2113
+ } else {
2114
+ const retryText = retry.text.trim()
2115
+ if (retryText !== '') text = retryText
2116
+ used = 'vision'
2117
+ }
2118
+ } else {
2119
+ used = 'vision'
2120
+ }
2121
+ if (text === 'EMPTY') text = ''
2122
+ }
2123
+ }
2124
+ results.push({ chunk: i + 1, left, right, top, bottom, engine: used, chars: text.length, text })
2125
+ }
2126
+ const joined = results.map((r) => r.text).filter((t) => t !== '').join('\n\n')
2127
+ const engines = {}
2128
+ for (const r of results) engines[r.engine] = (engines[r.engine] ?? 0) + 1
2129
+ const manifest = {
2130
+ source: args.image,
2131
+ width,
2132
+ height,
2133
+ chunkHeight,
2134
+ overlap,
2135
+ chunks: results.length,
2136
+ engines,
2137
+ perChunk: results.map(({ text, ...rest }) => rest),
2138
+ }
2139
+ const manifestPath = await writeArtifact(exec, join(stem, 'manifest.json'), JSON.stringify(manifest, null, 2))
2140
+ const mdPath = await writeArtifact(exec, join(stem, 'ocr.md'), joined)
2141
+ return JSON.stringify({
2142
+ text: joined,
2143
+ chunks: results.length,
2144
+ engines,
2145
+ markdownPath: mdPath,
2146
+ manifestPath,
2147
+ artifactsDir: dirname(mdPath),
2148
+ })
2149
+ },
2150
+ })
2151
+
2152
+ // ── vision_trace ─────────────────────────────────────────────────────────
2153
+ tools.push({
2154
+ name: 'vision_trace',
2155
+ description:
2156
+ 'Vectorize an image (icon/logo) into an SVG via a local potrace pipeline (no Python). ' +
2157
+ 'Default: COLOR-preserving vectorization — one path per dominant color with fill="#rrggbb". ' +
2158
+ 'Set color=false for the layered grayscale posterization, where `steps` (1-16, default 4) ' +
2159
+ 'controls levels. Writes the SVG as an artifact.',
2160
+ parameters: {
2161
+ type: 'object',
2162
+ properties: {
2163
+ image: { type: 'string', description: 'Local image path (png/jpeg/webp/gif), workspace-relative or absolute; or the attachment id (e.g. "sha256:...") of an image uploaded in this conversation' },
2164
+ steps: { type: 'number', description: 'Posterization steps, 1-16, default 4 (only when color=false)' },
2165
+ color: { type: 'boolean', description: 'Preserve original colors (default true)' },
2166
+ colors: { type: 'number', description: 'Number of dominant colors in color mode, 1-16, default 8' },
2167
+ },
2168
+ required: ['image'],
2169
+ additionalProperties: false,
2170
+ },
2171
+ output: stringOutput,
2172
+ async execute(args, exec) {
2173
+ const { bytes } = await readImageBytes(ctx, exec, args.image)
2174
+ const steps = Number.isInteger(args.steps) && args.steps > 0 ? Math.min(args.steps, 16) : 4
2175
+ const colorMode = args.color !== false
2176
+ // Trace-specific pixel budget: vectorization gains nothing beyond ~1MP.
2177
+ let traceBytes = bytes
2178
+ if (bytes && bytes.length > 0) {
2179
+ traceBytes = await downscaleImage(bytes, 1_000_000)
2180
+ }
2181
+ let svg
2182
+ let colorCount = 0
2183
+ try {
2184
+ if (colorMode) {
2185
+ const sharp = await loadSharp()
2186
+ if (!sharp) throw new Error('vision_trace: the sharp image library is unavailable')
2187
+ const colors = Number.isInteger(args.colors) && args.colors > 0 ? Math.min(args.colors, 16) : 8
2188
+ const raw = await sharp(traceBytes, { failOn: 'none' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
2189
+ const palette = quantizeColors(raw.data, colors)
2190
+ colorCount = palette.length
2191
+ svg = await posterizeSvgColor(raw.data, raw.info, palette, getConfig().timeoutMs || 60000)
2192
+ } else {
2193
+ svg = await posterizeSvg(traceBytes, steps, 'dominant', getConfig().timeoutMs || 60000)
2194
+ }
2195
+ } catch (error) {
2196
+ throw new Error(
2197
+ `vision_trace: potrace failed (${error && error.message ? error.message : String(error)}). ` +
2198
+ 'If potrace is missing, install the plugin dependencies in the profile (pnpm add potrace).',
2199
+ )
2200
+ }
2201
+ const target = await saveArtifact(
2202
+ exec,
2203
+ `${artifactStem(args.image, colorMode ? 'trace-color' : `trace-${steps}`)}.svg`,
2204
+ Buffer.from(svg),
2205
+ )
2206
+ return JSON.stringify({ path: target, bytes: Buffer.byteLength(svg), ...(colorMode ? { colors: colorCount } : {}) })
2207
+ },
2208
+ })
2209
+
2210
+ // ── vision_extract_foreground ────────────────────────────────────────────
2211
+ tools.push({
2212
+ name: 'vision_extract_foreground',
2213
+ description:
2214
+ 'Remove a solid-ish background (border flood fill with color tolerance, no Python) and ' +
2215
+ 'write the cutout as a transparent PNG artifact. Best for logos on uniform backgrounds.',
2216
+ parameters: {
2217
+ type: 'object',
2218
+ properties: {
2219
+ image: { type: 'string', description: 'Local image path (png/jpeg/webp/gif), workspace-relative or absolute; or the attachment id (e.g. "sha256:...") of an image uploaded in this conversation' },
2220
+ tolerance: { type: 'number', description: 'Max per-channel color distance from the background, default 40' },
2221
+ },
2222
+ required: ['image'],
2223
+ additionalProperties: false,
2224
+ },
2225
+ output: stringOutput,
2226
+ async execute(args, exec) {
2227
+ const { bytes } = await readImageBytes(ctx, exec, args.image)
2228
+ let fgBytes = bytes
2229
+ if (bytes && bytes.length > 0) {
2230
+ fgBytes = await downscaleImage(bytes, 4_000_000)
2231
+ }
2232
+ const tolerance = Number.isFinite(args.tolerance) && args.tolerance >= 0 ? Math.round(args.tolerance) : 40
2233
+ const sharp = await loadSharp()
2234
+ if (!sharp) throw new Error('vision_extract_foreground: the sharp image library is unavailable')
2235
+ const { data, info } = await sharp(fgBytes, { failOn: 'none' })
2236
+ .ensureAlpha()
2237
+ .raw()
2238
+ .toBuffer({ resolveWithObject: true })
2239
+ const cutout = floodFillBackground(data, info.width, info.height, tolerance)
2240
+ const png = await sharp(cutout, {
2241
+ raw: { width: info.width, height: info.height, channels: 4 },
2242
+ })
2243
+ .png()
2244
+ .toBuffer()
2245
+ const target = await saveArtifact(exec, `${artifactStem(args.image, 'fg')}.png`, png)
2246
+ return JSON.stringify({ path: target, width: info.width, height: info.height, bytes: png.length })
2247
+ },
2248
+ })
2249
+
2250
+ // ── vision_html_screenshot ───────────────────────────────────────────────
2251
+ tools.push({
2252
+ name: 'vision_html_screenshot',
2253
+ description:
2254
+ 'Render a local .html/.htm file in the system Chrome (headless, network disabled by ' +
2255
+ 'default) and save a PNG screenshot as an artifact — the verify step of the ' +
2256
+ 'reference -> implementation -> screenshot -> pixel-diff loop. With fullPage: true the ' +
2257
+ 'page keeps the requested viewport but the whole scrollable height is captured and the ' +
2258
+ 'result JSON reports pageHeight (CSS px).',
2259
+ parameters: {
2260
+ type: 'object',
2261
+ properties: {
2262
+ source: { type: 'string', description: 'Local .html or .htm file path' },
2263
+ width: { type: 'number', description: 'Viewport width, default 1200' },
2264
+ height: { type: 'number', description: 'Viewport height, default 720' },
2265
+ fullPage: {
2266
+ type: 'boolean',
2267
+ description:
2268
+ 'Capture the complete scrollable page height at the requested viewport width ' +
2269
+ 'instead of just the viewport (default false). Lazy-loaded images and ' +
2270
+ 'scroll-triggered reveals are woken first; the result JSON then includes ' +
2271
+ 'pageHeight (CSS px).',
2272
+ },
2273
+ },
2274
+ required: ['source'],
2275
+ additionalProperties: false,
2276
+ },
2277
+ output: stringOutput,
2278
+ async execute(args, exec) {
2279
+ const source = String(args.source ?? '')
2280
+ if (!/\.(html?|htm)$/i.test(source)) {
2281
+ throw new Error('vision_html_screenshot: source must be a local .html/.htm file')
2282
+ }
2283
+ const fsService = ctx.get('fs')
2284
+ if (fsService === undefined) {
2285
+ throw new Error('vision_html_screenshot: the fs service is not available')
2286
+ }
2287
+ const resolved = await fsService.resolve(source)
2288
+ const targetPath = toRealPath(fsService, resolved)
2289
+ if (!existsSync(targetPath)) {
2290
+ throw new Error(`vision_html_screenshot: file not found: ${source}`)
2291
+ }
2292
+ let puppeteer
2293
+ try {
2294
+ puppeteer = await import('puppeteer-core')
2295
+ } catch {
2296
+ throw new Error(
2297
+ 'vision_html_screenshot: puppeteer-core is not installed. Install the plugin dependencies in the profile (pnpm add puppeteer-core).',
2298
+ )
2299
+ }
2300
+ const candidates = chromiumCandidates(
2301
+ typeof process !== 'undefined' && process.env ? process.env : {},
2302
+ typeof process !== 'undefined' ? process.platform : '',
2303
+ )
2304
+ const executablePath = candidates.find((p) => existsSync(p))
2305
+ if (executablePath === undefined) {
2306
+ throw new Error(
2307
+ 'vision_html_screenshot: no Chrome/Chromium/Edge found; install one or set CHROME_PATH / PUPPETEER_EXECUTABLE_PATH',
2308
+ )
2309
+ }
2310
+ const width = Number.isInteger(args.width) && args.width > 0 ? args.width : 1200
2311
+ const height = Number.isInteger(args.height) && args.height > 0 ? args.height : 720
2312
+ const fullPage = args.fullPage === true
2313
+ const launchArgs = ['--no-sandbox', '--disable-gpu', '--hide-scrollbars', '--incognito']
2314
+ if (fullPage) {
2315
+ launchArgs.push('--blink-settings=imagesLazyLoadingEnabled=false')
2316
+ }
2317
+ const browser = await puppeteer.default.launch({
2318
+ executablePath,
2319
+ headless: true,
2320
+ args: launchArgs,
2321
+ })
2322
+ try {
2323
+ const page = await browser.newPage()
2324
+ await page.setViewport({ width, height })
2325
+ // Honor the "network disabled by default" contract: the page may only
2326
+ // load file:/data:/blob: subresources; everything else is aborted so
2327
+ // a malicious local HTML cannot exfiltrate data or pull remote assets.
2328
+ await page.setRequestInterception(true)
2329
+ page.on('request', (request) => {
2330
+ const url = request.url() ?? ''
2331
+ if (/^(file|data|blob):/i.test(url)) request.continue()
2332
+ else request.abort()
2333
+ })
2334
+ await page.goto(pathToFileURL(targetPath).href, { waitUntil: 'networkidle0', timeout: 30000 })
2335
+ let pageHeight
2336
+ if (fullPage) {
2337
+ await wakePageForFullCapture(page, height)
2338
+ pageHeight = await fullPageHeightOf(page)
2339
+ }
2340
+ const png = fullPage
2341
+ ? await page.screenshot({ type: 'png', fullPage: true })
2342
+ : await page.screenshot({ type: 'png' })
2343
+ const stem = fullPage ? `shot-${width}x${height}-fullpage` : `shot-${width}x${height}`
2344
+ const target = await saveArtifact(exec, `${artifactStem(source, stem)}.png`, png)
2345
+ const result = { path: target, width, height, bytes: png.length }
2346
+ if (fullPage) {
2347
+ result.pageHeight = pageHeight
2348
+ }
2349
+ return JSON.stringify(result)
2350
+ } finally {
2351
+ await browser.close()
2352
+ }
2353
+ },
2354
+ })
2355
+
2356
+ // ── vision_screenshot (privacy-gated: only registered when desktopScreenshot is enabled) ──
2357
+ if (getConfig().desktopScreenshot === true) {
2358
+ tools.push({
2359
+ name: 'vision_screenshot',
2360
+ description:
2361
+ 'Capture the user\'s desktop screen as a PNG artifact (the virtual screen on Windows; the main display on macOS; the root display on Linux). ' +
2362
+ 'Windows: PowerShell CopyFromScreen; macOS: screencapture; Linux: ImageMagick import (falls back to scrot; either command must be installed). ' +
2363
+ 'Use it when you need to see what is on the user\'s screen right now — e.g. their current GUI, an app, or a page outside this browser. ' +
2364
+ 'Optional identify=true also runs recognition on the capture with the configured vision model and returns the description alongside the path.',
2365
+ parameters: {
2366
+ type: 'object',
2367
+ properties: {
2368
+ identify: {
2369
+ type: 'boolean',
2370
+ description:
2371
+ 'Also recognize the captured screen with the configured vision model and return the description text with the path. Default false.',
2372
+ },
2373
+ },
2374
+ additionalProperties: false,
2375
+ },
2376
+ output: stringOutput,
2377
+ async execute(args, exec) {
2378
+ const tmp = join(
2379
+ (typeof process !== 'undefined' && process.env && process.env.TMPDIR) || process.env.TEMP || '/tmp',
2380
+ `vision-screenshot-${Date.now()}-${Math.floor(Math.random() * 1e9)}.png`,
2381
+ )
2382
+ const platform = process.platform
2383
+ try {
2384
+ if (platform === 'win32') {
2385
+ const script = [
2386
+ 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
2387
+ '$b=[System.Windows.Forms.SystemInformation]::VirtualScreen',
2388
+ '$bmp=New-Object System.Drawing.Bitmap($b.Width,$b.Height)',
2389
+ '$g=[System.Drawing.Graphics]::FromImage($bmp)',
2390
+ '$g.CopyFromScreen($b.X,$b.Y,0,0,$bmp.Size)',
2391
+ `$bmp.Save('${tmp.replace(/'/g, "''")}')`,
2392
+ '$g.Dispose();$bmp.Dispose()',
2393
+ ].join('; ')
2394
+ await promisify(execFile)('powershell.exe', ['-NoProfile', '-STA', '-Command', script], {
2395
+ timeout: 60000,
2396
+ windowsHide: true,
2397
+ })
2398
+ } else if (platform === 'darwin') {
2399
+ await promisify(execFile)('screencapture', ['-x', '-m', tmp], {
2400
+ timeout: 60000,
2401
+ windowsHide: true,
2402
+ })
2403
+ } else {
2404
+ try {
2405
+ await promisify(execFile)('import', ['-window', 'root', tmp], { timeout: 60000 })
2406
+ } catch {
2407
+ await promisify(execFile)('scrot', [tmp], { timeout: 60000 })
2408
+ }
2409
+ }
2410
+ if (!existsSync(tmp)) {
2411
+ throw new Error(
2412
+ `vision_screenshot: no output produced on ${platform} (is a screen available?)`,
2413
+ )
2414
+ }
2415
+ const data = await readFileP(tmp)
2416
+ const target = await saveArtifact(exec, `screenshot-${Date.now()}.png`, data)
2417
+ const result = { path: target, bytes: data.length }
2418
+ if (args.identify === true) {
2419
+ let identifyBytes = data
2420
+ try {
2421
+ const sharp = await loadSharp()
2422
+ if (sharp) {
2423
+ const downscaled = await sharp(data, { failOn: 'none' })
2424
+ .resize({ width: 1280, height: 1280, fit: 'inside', withoutEnlargement: true })
2425
+ .png()
2426
+ .toBuffer()
2427
+ if (downscaled.length > 0 && downscaled.length < data.length) {
2428
+ identifyBytes = downscaled
2429
+ }
2430
+ }
2431
+ } catch {
2432
+ /* keep the original capture */
2433
+ }
2434
+ const vision = await answerVisionForTool(
2435
+ identifyBytes,
2436
+ 'image/png',
2437
+ 'Describe what is currently visible on the user\'s screen, in detail and in reading order. Mention the app/window names you can see.',
2438
+ { exec },
2439
+ )
2440
+ if (vision.ok === false) {
2441
+ result.identify = { ok: false, ...vision, text: '' }
2442
+ } else {
2443
+ result.identify = { ok: true, text: vision.text }
2444
+ }
2445
+ }
2446
+ try {
2447
+ await rmP(tmp, { force: true })
2448
+ } catch {
2449
+ /* best-effort cleanup */
2450
+ }
2451
+ return JSON.stringify(result)
2452
+ } catch (error) {
2453
+ throw new Error(
2454
+ `vision_screenshot: failed (${error && error.message ? error.message : String(error)})`,
2455
+ )
2456
+ }
2457
+ },
2458
+ })
2459
+ }
2460
+
2461
+ for (const def of tools) {
2462
+ ctx.tools.register(def)
2463
+ }
2464
+ logger?.info?.('[tool-vision] registered %d pixel-level vision tools', tools.length)
2465
+ return tools.length
2466
+ }
2467
+
2468
+ // ── cache instance shared across tools ─────────────────────────────────────
2469
+
2470
+ const visionCache = createCache(200, 3600 * 1000)
2471
+ let cacheDisabled = false
2472
+
2473
+ function cacheEnabled() {
2474
+ return !cacheDisabled
2475
+ }
2476
+
2477
+ /** Test hook: reset the shared cache. */
2478
+ export function resetVisionCacheForTests() {
2479
+ visionCache.clear()
2480
+ cacheDisabled = false
2481
+ }