picturereader-zcode 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * picturereader — pixel-to-text image reading for text-only DeepSeek Harness
3
+ * models. One plugin row registers the `image_scan` tool: decode the image,
4
+ * downscale it into a coarse cell grid, quantize colors against a small named
5
+ * palette, and feed the rendered grids back into the conversation so DeepSeek
6
+ * can describe layout, colors and rough shapes without a vision model.
7
+ *
8
+ * Mount with one row:
9
+ *
10
+ * ```yaml
11
+ * - id: picturereader
12
+ * name: 'picturereader'
13
+ * ```
14
+ * @module picturereader
15
+ */
16
+
17
+ import { createImageScanTool, createImageOcrTool, createImageSampleTool } from './tool.js';
18
+
19
+ export const name = 'picturereader';
20
+
21
+ /** Services required at runtime: the tool registry and the filesystem seam. */
22
+ export const inject = ['tools', 'fs'];
23
+
24
+ export function apply(ctx) {
25
+ ctx.effect(() => {
26
+ ctx.tools.register(createImageScanTool(ctx));
27
+ ctx.tools.register(createImageOcrTool(ctx));
28
+ ctx.tools.register(createImageSampleTool(ctx));
29
+ });
30
+ }
package/src/tool.js ADDED
@@ -0,0 +1,548 @@
1
+ /**
2
+ * The model-facing `image_scan` tool: read a local image as a coarse pixel
3
+ * grid (downscaled + color-quantized) so a text-only model can "see" layout,
4
+ * colors and rough shapes.
5
+ *
6
+ * HOT RELOAD: the entire business logic lives in `core.js`, which is loaded
7
+ * dynamically with a cache-busting query keyed on the file's mtime. Editing
8
+ * `core.js` (rendering, palette, decode, precision/color-depth algorithms)
9
+ * therefore takes effect on the NEXT tool call without a process restart.
10
+ * The tool definition itself (schema/description) is fixed at boot and only
11
+ * changes after a restart.
12
+ * @module picturereader/tool
13
+ */
14
+
15
+ import { extname } from 'node:path';
16
+ import { stat } from 'node:fs/promises';
17
+
18
+ /** Hard cap on file bytes we are willing to read for a scan. */
19
+ export const BYTE_CAP = 50 * 1024 * 1024;
20
+ /** Hard cap on decoded pixel count (pure-JS decoders are slow on huge images). */
21
+ export const MAX_PIXELS = 24_000_000;
22
+
23
+ const CORE_URL = new URL('./core.js', import.meta.url).href;
24
+
25
+ let coreCache = { url: null, mtime: -1, module: null };
26
+
27
+ /**
28
+ * Load the latest `core.js`, refreshing the module whenever the file changes.
29
+ * Exported for tests; the optional `target` overrides the module URL.
30
+ * @param target - module URL to load (defaults to this package's core.js).
31
+ * @returns the core module namespace.
32
+ */
33
+ export async function importCore(target = CORE_URL) {
34
+ const url = new URL(target);
35
+ const info = await stat(url);
36
+ if (coreCache.module !== null && coreCache.url === target && info.mtimeMs === coreCache.mtime) {
37
+ return coreCache.module;
38
+ }
39
+ const module = await import(`${url.href}?t=${info.mtimeMs}`);
40
+ coreCache = { url: target, mtime: info.mtimeMs, module };
41
+ return module;
42
+ }
43
+
44
+ /** The most recent core module, for synchronous tool-result rendering. */
45
+ let latestCore = null;
46
+
47
+ /** Coerce the size argument into a bounded integer. */
48
+ function parseSize(raw) {
49
+ const size = Number(raw ?? 32);
50
+ if (!Number.isInteger(size) || size < 8 || size > 64) {
51
+ throw new Error('image_scan: size must be an integer between 8 and 64');
52
+ }
53
+ return size;
54
+ }
55
+
56
+ function parseMode(raw) {
57
+ const mode = String(raw ?? 'auto');
58
+ if (mode !== 'auto' && mode !== 'ascii' && mode !== 'color') {
59
+ throw new Error("image_scan: mode must be one of 'auto', 'ascii', 'color'");
60
+ }
61
+ return mode;
62
+ }
63
+
64
+ /**
65
+ * Build the tool over one plugin context.
66
+ * @param ctx - the Cordis context providing `ctx.fs` (resolve/stat/readBytes)
67
+ * and observation events.
68
+ */
69
+ export function createImageScanTool(ctx) {
70
+ return {
71
+ name: 'image_scan',
72
+ description: [
73
+ 'Read a local image file as a coarse pixel grid (downscaled + color-quantized) so a text-only model can see layout, colors and rough shapes.',
74
+ 'Use it to inspect charts, screenshots, diagrams, UI mockups or photos: report dominant colors with percentages, relative positions of regions, coarse structure and luminance patterns.',
75
+ 'The result includes a luminance grid (rows top->bottom, columns left->right; " "=transparent, "." darkest, "@" brightest), a color grid for colorful images (one letter per cell, see legend), a "grid coords" line giving the row/col range, and a regions list: connected color blobs with position (grid rows/cols), size, aspect and texture density.',
76
+ 'Semantic reading: use the regions list plus your world knowledge to infer WHAT the image contains, not just raw colors — e.g. a large rough round green blob above a thin brown stem reads as a tree; a dense cluster of small bright blobs near the center with a dark smooth frame reads as a screen with content. Combine regions with the grids and zoom (focus/region) to verify.',
77
+ 'Realism judgment: the "shade diversity" line and each region\'s "N shade(s)" mix tell you how many hue+brightness variations an area has — 1-2 shades means flat/synthetic artwork (a sticker or diagram), many shades means photo-like content with lighting and gradients. Use this to say whether something looks drawn vs photographed.',
78
+ 'Structural hints are listed too: parallel stripes (alternating color bands) suggest panels/grilles/blades (e.g. solar panels, louvres, ribs); left-right symmetry suggests manufactured/constructed objects; smooth bright-to-dark gradients across a blob suggest curved surfaces (cylinders, spheres — e.g. a round module). Use these shape cues to identify objects, then verify with px_per_cell or image_sample on the area.',
79
+ 'To inspect details, work iteratively: first scan the full image (any size, default 32), identify the region you care about, then call image_scan again with focus: [row0, col0, row1, col1] — rows/cols are read from the "grid coords" line of that full scan, and you MUST keep size the SAME as that scan (focus itself provides the zoom: the same grid then covers only the focused area, so each cell shows finer detail). If you want even more detail, zoom again into a smaller focus inside the previous focused result, still with the same size. Alternatively pass region: [x0, y0, x1, y1] (0..1 fractions) which works with any size.',
80
+ 'For fine detail on a specific subject (a person, an object, a face): request a pixel density with px_per_cell — the number of source pixels each cell represents (e.g. px_per_cell: 4 makes every cell show a 4x4 pixel area). The tool clamps to 64 cells per side and reports the actual density in the header ("~XxYpx per cell"); if the region is too large for your requested density, shrink the region (zoom the focus) and retry. Use px_per_cell with region/focus, never for a whole huge image (too many cells).',
81
+ 'palette sets the color depth: auto (default, picks by content), full (14 colors), basic (8 colors) or gray (black/gray/white only).',
82
+ 'Note: "colors by area" reports TRUE pixel-level color shares (small colored details are never diluted away), and the "hue families" line breaks colors down by hue regardless of darkness — use it to spot pink/cyan/green content that a dark palette would otherwise hide (e.g. blossoms, water, vegetation).',
83
+ 'Limitation: no OCR/text recognition and no fine detail — thin lines and small glyphs may disappear at coarse sizes; zoom into a region to inspect details.',
84
+ 'size = target cells on the longer side (8..64, default 32). mode auto picks the color grid when the image is colorful.'
85
+ ].join(' '),
86
+ parameters: {
87
+ type: 'object',
88
+ additionalProperties: true,
89
+ properties: {
90
+ file_path: {
91
+ type: 'string',
92
+ description: 'Path to the image file (PNG/JPEG/GIF/BMP), resolved by the filesystem backend.'
93
+ },
94
+ size: {
95
+ type: 'integer',
96
+ description: 'Target cell count on the longer side (8..64, default 32). Mutually exclusive with px_per_cell.'
97
+ },
98
+ px_per_cell: {
99
+ type: 'integer',
100
+ description: 'Requested source pixels per cell for fine detail (e.g. 2-16); clamped to 64 cells per side, actual density reported in the header. Use with region/focus on a small area, mutually exclusive with size.'
101
+ },
102
+ mode: {
103
+ type: 'string',
104
+ enum: ['auto', 'ascii', 'color'],
105
+ description: "auto = color grid when colorful, else luminance grid (default); ascii = luminance only; color = include color grid."
106
+ },
107
+ palette: {
108
+ type: 'string',
109
+ enum: ['auto', 'full', 'basic', 'gray'],
110
+ description: 'Color depth: auto (default) = pick by content, full = 14 colors, basic = 8 colors, gray = black/gray/white only.'
111
+ },
112
+ region: {
113
+ type: 'array',
114
+ description: 'Optional [x0, y0, x1, y1] fractions in 0..1 to zoom into part of the image. Mutually exclusive with focus.',
115
+ items: { type: 'number' }
116
+ },
117
+ focus: {
118
+ type: 'array',
119
+ description: 'Zoom target as grid coordinates [row0, col0, row1, col1] (inclusive, based on the full-image grid the current size produces — read rows/cols from the "grid coords" line of a previous image_scan output). Mutually exclusive with region.',
120
+ items: { type: 'integer' }
121
+ }
122
+ },
123
+ required: ['file_path']
124
+ },
125
+ output: {
126
+ schema: {
127
+ type: 'object',
128
+ additionalProperties: true,
129
+ properties: {
130
+ path: { type: 'string' },
131
+ width: { type: 'integer' },
132
+ height: { type: 'integer' },
133
+ gridWidth: { type: 'integer' },
134
+ gridHeight: { type: 'integer' },
135
+ region: { type: 'string' },
136
+ palette: { type: 'string', enum: ['full', 'basic', 'gray'] },
137
+ mode: { type: 'string', enum: ['auto', 'ascii', 'color'] },
138
+ distinctShades: { type: 'integer' },
139
+ colors: {
140
+ type: 'array',
141
+ items: {
142
+ type: 'object',
143
+ additionalProperties: true,
144
+ properties: {
145
+ name: { type: 'string' },
146
+ hex: { type: 'string' },
147
+ count: { type: 'integer' },
148
+ pct: { type: 'number' }
149
+ },
150
+ required: ['name', 'hex', 'count', 'pct']
151
+ }
152
+ },
153
+ ascii: { type: 'string' },
154
+ colorGrid: { type: 'string' },
155
+ colorLegend: { type: 'string' }
156
+ },
157
+ required: ['path', 'width', 'height', 'gridWidth', 'gridHeight', 'region', 'palette', 'mode', 'colors', 'ascii']
158
+ },
159
+ render: (_args, value) => {
160
+ const renderer = latestCore?.renderImageScan;
161
+ const text = renderer !== undefined && renderer !== null ? renderer(value) : `image_scan result for ${value.path}: ${JSON.stringify(value)}`;
162
+ return [{ type: 'text', text }];
163
+ }
164
+ },
165
+ isConcurrencySafe: () => true,
166
+ async execute(args, exec) {
167
+ if (exec.signal?.aborted) throw new Error('image_scan: cancelled');
168
+ const filePath = String(args.file_path ?? '').trim();
169
+ if (filePath.length === 0) throw new Error('image_scan: file_path must be a non-empty string');
170
+
171
+ const ext = extname(filePath).toLowerCase();
172
+ const core = await importCore();
173
+ latestCore = core;
174
+
175
+ if (core.UNSUPPORTED_EXTENSIONS.has(ext)) {
176
+ throw new Error('image_scan: WebP is not supported yet — convert the file to PNG or JPEG first');
177
+ }
178
+ if (!core.IMAGE_EXTENSIONS.has(ext)) {
179
+ throw new Error(`image_scan: unsupported image type "${ext}" (supported: PNG, JPEG, GIF, BMP)`);
180
+ }
181
+
182
+ const mode = parseMode(args.mode);
183
+ const palette = core.resolvePaletteArgument(args.palette);
184
+ if (args.region !== undefined && args.focus !== undefined) {
185
+ throw new Error('image_scan: region and focus are mutually exclusive — pass only one');
186
+ }
187
+ let pxPerCell;
188
+ if (args.px_per_cell !== undefined) {
189
+ pxPerCell = Number(args.px_per_cell);
190
+ if (!Number.isInteger(pxPerCell) || pxPerCell < 1 || pxPerCell > 512) {
191
+ throw new Error('image_scan: px_per_cell must be an integer between 1 and 512');
192
+ }
193
+ if (args.size !== undefined) {
194
+ throw new Error('image_scan: size and px_per_cell are mutually exclusive — pass only one');
195
+ }
196
+ }
197
+ const size = pxPerCell !== undefined ? 32 : parseSize(args.size);
198
+
199
+ const cwd = exec.agent?.session?.header?.cwd;
200
+ const target = await ctx.fs.resolve(filePath, {
201
+ ...(cwd !== undefined ? { cwd } : {}),
202
+ signal: exec.signal
203
+ });
204
+ const info = await ctx.fs.stat(target, exec.signal);
205
+ if (!info) {
206
+ throw new Error(`image_scan: cannot read "${target.displayPath}": file not found`);
207
+ }
208
+ if (info.type !== 'file') {
209
+ throw new Error(`image_scan: cannot read "${target.displayPath}": not a regular file`);
210
+ }
211
+ const data = await ctx.fs.readBytes(target, exec.signal, BYTE_CAP);
212
+
213
+ const image = core.decodeImage(data, ext);
214
+ if (image.width * image.height > MAX_PIXELS) {
215
+ throw new Error(
216
+ `image_scan: ${image.width}x${image.height} exceeds the ${MAX_PIXELS}-pixel decode limit — downscale or crop the file first`
217
+ );
218
+ }
219
+
220
+ // Resolve the scan window. focus uses grid coordinates against the
221
+ // full-image grid this size produces, so it must be resolved after decode.
222
+ let regionArray;
223
+ let regionDisplay;
224
+ if (args.focus !== undefined) {
225
+ const fullGridHeight = Math.max(1, Math.round(size * (image.height / image.width)));
226
+ regionArray = core.resolveFocus(args.focus, size, fullGridHeight);
227
+ regionDisplay = `focus [${args.focus.map(String).join(',')}]`;
228
+ } else if (args.region !== undefined) {
229
+ regionArray = core.normalizeRegion(args.region);
230
+ regionDisplay = regionArray.map((v) => Math.round(v * 1000) / 1000).join(',');
231
+ } else {
232
+ regionDisplay = 'full';
233
+ }
234
+
235
+ const analysis = core.analyzeImage(image.data, image.width, image.height, { size, mode, region: regionArray, palette, pxPerCell });
236
+ ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec);
237
+ return {
238
+ path: target.displayPath,
239
+ width: image.width,
240
+ height: image.height,
241
+ region: regionDisplay,
242
+ ...analysis
243
+ };
244
+ }
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Build the model-facing `image_ocr` tool over one plugin context.
250
+ * Recognizes text in an image (optionally within a region/focus) using the
251
+ * Windows built-in OCR engine — fully local, no install.
252
+ * @param ctx - the Cordis context providing `ctx.fs`.
253
+ */
254
+ export function createImageOcrTool(ctx) {
255
+ return {
256
+ name: 'image_ocr',
257
+ description: [
258
+ 'Recognize text in a local image. Two engines: engine="windows" (default) uses the Windows built-in OCR (no install, good for printed/UI text); engine="paddle" uses PaddleOCR via the local paddle_venv (much better for glowing, curved, stylized or game-rendered text and complex backgrounds, Chinese-friendly; ~2s model load per call).',
259
+ 'Use it together with image_scan: when the pixel grid shows a dense, regular, high-contrast structure that looks like text (e.g. titles, labels, buttons, dialogs, glowing banners), call image_ocr on that region and read the actual characters. If the Windows engine returns nothing but text is expected, retry with engine="paddle".',
260
+ 'Parameters: file_path (required), region: [x0, y0, x1, y1] (0..1 fractions) or focus: [row0, col0, row1, col1] (grid coordinates) to restrict recognition to an area, language (optional BCP-47 tag like "zh-Hans" or "en-US", Windows engine only), engine ("windows" default, "paddle").',
261
+ 'The result lists each recognized line with its pixel bounding box and confidence score (paddle).'
262
+ ].join(' '),
263
+ parameters: {
264
+ type: 'object',
265
+ additionalProperties: true,
266
+ properties: {
267
+ file_path: {
268
+ type: 'string',
269
+ description: 'Path to the image file (PNG/JPEG/GIF/BMP), resolved by the filesystem backend.'
270
+ },
271
+ region: {
272
+ type: 'array',
273
+ description: 'Optional [x0, y0, x1, y1] fractions in 0..1 to restrict recognition to part of the image. Mutually exclusive with focus.',
274
+ items: { type: 'number' }
275
+ },
276
+ focus: {
277
+ type: 'array',
278
+ description: 'Optional [row0, col0, row1, col1] grid coordinates (inclusive) to restrict recognition to part of the image. Mutually exclusive with region.',
279
+ items: { type: 'integer' }
280
+ },
281
+ language: {
282
+ type: 'string',
283
+ description: 'Optional BCP-47 language tag (e.g. "zh-Hans", "en-US"); defaults to the user languages. Windows engine only.'
284
+ },
285
+ engine: {
286
+ type: 'string',
287
+ enum: ['windows', 'paddle'],
288
+ description: '"windows" (default) = Windows built-in OCR; "paddle" = PaddleOCR via local paddle_venv (better for glowing/curved/game text).'
289
+ }
290
+ },
291
+ required: ['file_path']
292
+ },
293
+ output: {
294
+ schema: {
295
+ type: 'object',
296
+ additionalProperties: true,
297
+ properties: {
298
+ path: { type: 'string' },
299
+ width: { type: 'integer' },
300
+ height: { type: 'integer' },
301
+ region: { type: 'string' },
302
+ engine: { type: 'string', enum: ['windows', 'paddle'] },
303
+ note: { type: 'string' },
304
+ lines: {
305
+ type: 'array',
306
+ items: {
307
+ type: 'object',
308
+ additionalProperties: true,
309
+ properties: {
310
+ text: { type: 'string' },
311
+ x: { type: 'integer' },
312
+ y: { type: 'integer' },
313
+ width: { type: 'integer' },
314
+ height: { type: 'integer' },
315
+ score: { type: 'number' }
316
+ },
317
+ required: ['text', 'x', 'y', 'width', 'height']
318
+ }
319
+ }
320
+ },
321
+ required: ['path', 'width', 'height', 'region', 'lines']
322
+ },
323
+ render: (_args, value) => {
324
+ const renderer = latestCore?.renderOcr;
325
+ const text = renderer !== undefined && renderer !== null ? renderer(value) : `ocr result for ${value.path}: ${JSON.stringify(value)}`;
326
+ return [{ type: 'text', text }];
327
+ }
328
+ },
329
+ isConcurrencySafe: () => true,
330
+ async execute(args, exec) {
331
+ if (exec.signal?.aborted) throw new Error('image_ocr: cancelled');
332
+ const filePath = String(args.file_path ?? '').trim();
333
+ if (filePath.length === 0) throw new Error('image_ocr: file_path must be a non-empty string');
334
+
335
+ const ext = extname(filePath).toLowerCase();
336
+ const core = await importCore();
337
+ latestCore = core;
338
+
339
+ if (core.UNSUPPORTED_EXTENSIONS.has(ext)) {
340
+ throw new Error('image_ocr: WebP is not supported yet — convert the file to PNG or JPEG first');
341
+ }
342
+ if (!core.IMAGE_EXTENSIONS.has(ext)) {
343
+ throw new Error(`image_ocr: unsupported image type "${ext}" (supported: PNG, JPEG, GIF, BMP)`);
344
+ }
345
+ if (args.region !== undefined && args.focus !== undefined) {
346
+ throw new Error('image_ocr: region and focus are mutually exclusive — pass only one');
347
+ }
348
+ if (args.language !== undefined && String(args.language).trim().length === 0) {
349
+ throw new Error('image_ocr: language must be a non-empty BCP-47 tag');
350
+ }
351
+ const engine = args.engine === undefined ? 'windows' : String(args.engine);
352
+ if (engine !== 'windows' && engine !== 'paddle') {
353
+ throw new Error("image_ocr: engine must be 'windows' (default) or 'paddle'");
354
+ }
355
+
356
+ const cwd = exec.agent?.session?.header?.cwd;
357
+ const target = await ctx.fs.resolve(filePath, {
358
+ ...(cwd !== undefined ? { cwd } : {}),
359
+ signal: exec.signal
360
+ });
361
+ const info = await ctx.fs.stat(target, exec.signal);
362
+ if (!info) {
363
+ throw new Error(`image_ocr: cannot read "${target.displayPath}": file not found`);
364
+ }
365
+ if (info.type !== 'file') {
366
+ throw new Error(`image_ocr: cannot read "${target.displayPath}": not a regular file`);
367
+ }
368
+ const data = await ctx.fs.readBytes(target, exec.signal, BYTE_CAP);
369
+
370
+ const image = core.decodeImage(data, ext);
371
+ if (image.width * image.height > MAX_PIXELS) {
372
+ throw new Error(
373
+ `image_ocr: ${image.width}x${image.height} exceeds the ${MAX_PIXELS}-pixel decode limit — downscale or crop the file first`
374
+ );
375
+ }
376
+
377
+ let regionArray;
378
+ let regionDisplay;
379
+ if (args.focus !== undefined) {
380
+ const fullGridHeight = Math.max(1, Math.round(32 * (image.height / image.width)));
381
+ regionArray = core.resolveFocus(args.focus, 32, fullGridHeight);
382
+ regionDisplay = `focus [${args.focus.map(String).join(',')}]`;
383
+ } else if (args.region !== undefined) {
384
+ regionArray = core.normalizeRegion(args.region);
385
+ regionDisplay = regionArray.map((v) => Math.round(v * 1000) / 1000).join(',');
386
+ } else {
387
+ regionDisplay = 'full';
388
+ }
389
+
390
+ // PaddleOCR is an optional engine: degrade gracefully to the Windows
391
+ // engine (with a note) when it is missing or fails — never crash.
392
+ let effectiveEngine = engine;
393
+ let note;
394
+ if (engine === 'paddle' && !(await core.paddleAvailable())) {
395
+ effectiveEngine = 'windows';
396
+ note = 'PaddleOCR is not installed (engine="paddle" requested) — fell back to Windows OCR. To install it, run: node scripts/setup-ocr.mjs (see README).';
397
+ }
398
+ let result;
399
+ try {
400
+ result = await core.ocrImage(data, ext, {
401
+ region: regionArray,
402
+ language: args.language === undefined ? undefined : String(args.language).trim(),
403
+ engine: effectiveEngine
404
+ });
405
+ } catch (error) {
406
+ if (engine === 'paddle' && effectiveEngine === 'paddle') {
407
+ effectiveEngine = 'windows';
408
+ note = `PaddleOCR failed (${error.message.slice(0, 140)}) — fell back to Windows OCR.`;
409
+ result = await core.ocrImage(data, ext, {
410
+ region: regionArray,
411
+ language: args.language === undefined ? undefined : String(args.language).trim(),
412
+ engine: 'windows'
413
+ });
414
+ } else {
415
+ throw error;
416
+ }
417
+ }
418
+ ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec);
419
+ return {
420
+ path: target.displayPath,
421
+ width: result.width,
422
+ height: result.height,
423
+ region: regionDisplay,
424
+ engine: effectiveEngine,
425
+ ...(note !== undefined ? { note } : {}),
426
+ lines: result.lines
427
+ };
428
+ }
429
+ };
430
+ }
431
+
432
+ /**
433
+ * Build the model-facing `image_sample` tool over one plugin context.
434
+ * Samples a small region as an NxN grid of exact pixels so the model can
435
+ * judge local material (texture pattern, smoothness, color variation).
436
+ * @param ctx - the Cordis context providing `ctx.fs`.
437
+ */
438
+ export function createImageSampleTool(ctx) {
439
+ return {
440
+ name: 'image_sample',
441
+ description: [
442
+ 'Sample a small region of a local image as an NxN grid of EXACT pixels (one real pixel per cell, not an average) plus a local-contrast statistic.',
443
+ 'Use it to judge MATERIAL or TEXTURE where a coarse grid is not enough: smooth color gradients (skin, sky, water), high-contrast stripes (metal, wood grain, brushed surfaces), periodic repeats (fabric, brick), high-frequency noise (foliage, gravel), sharp edges (screen content, UI).',
444
+ 'Workflow: first use image_scan to locate the area, then call image_sample with a SMALL region (e.g. [x0, y0, x1, y1] fractions covering roughly 30-400 px per side) and an optional size (2..16, default 8). The region must be at least `size` pixels in each direction.',
445
+ 'Interpret the returned RGB grid: row 0 is the top, left to right. High contrast with stripes suggests metal/wood/rough material; smooth low-contrast transitions suggest skin/sky/uniform surfaces; repetitive patterns suggest fabric/texture.'
446
+ ].join(' '),
447
+ parameters: {
448
+ type: 'object',
449
+ additionalProperties: true,
450
+ properties: {
451
+ file_path: {
452
+ type: 'string',
453
+ description: 'Path to the image file (PNG/JPEG/GIF/BMP), resolved by the filesystem backend.'
454
+ },
455
+ region: {
456
+ type: 'array',
457
+ description: 'Required [x0, y0, x1, y1] fractions in 0..1: the small area to sample. Must cover at least `size` pixels in each direction.',
458
+ items: { type: 'number' }
459
+ },
460
+ size: {
461
+ type: 'integer',
462
+ description: 'Sample grid side length (2..16, default 8); the output is size x size exact pixels.'
463
+ }
464
+ },
465
+ required: ['file_path', 'region']
466
+ },
467
+ output: {
468
+ schema: {
469
+ type: 'object',
470
+ additionalProperties: true,
471
+ properties: {
472
+ path: { type: 'string' },
473
+ width: { type: 'integer' },
474
+ height: { type: 'integer' },
475
+ region: { type: 'string' },
476
+ contrast: { type: 'number' },
477
+ distinct: { type: 'integer' },
478
+ stepX: { type: 'number' },
479
+ stepY: { type: 'number' },
480
+ points: { type: 'array' }
481
+ },
482
+ required: ['path', 'width', 'height', 'region', 'contrast', 'distinct', 'points']
483
+ },
484
+ render: (_args, value) => {
485
+ const renderer = latestCore?.renderSample;
486
+ const text = renderer !== undefined && renderer !== null ? renderer(value) : `texture sample for ${value.path}: ${JSON.stringify(value)}`;
487
+ return [{ type: 'text', text }];
488
+ }
489
+ },
490
+ isConcurrencySafe: () => true,
491
+ async execute(args, exec) {
492
+ if (exec.signal?.aborted) throw new Error('image_sample: cancelled');
493
+ const filePath = String(args.file_path ?? '').trim();
494
+ if (filePath.length === 0) throw new Error('image_sample: file_path must be a non-empty string');
495
+ if (args.region === undefined) throw new Error('image_sample: region is required ([x0, y0, x1, y1] fractions)');
496
+
497
+ const ext = extname(filePath).toLowerCase();
498
+ const core = await importCore();
499
+ latestCore = core;
500
+
501
+ if (core.UNSUPPORTED_EXTENSIONS.has(ext)) {
502
+ throw new Error('image_sample: WebP is not supported yet — convert the file to PNG or JPEG first');
503
+ }
504
+ if (!core.IMAGE_EXTENSIONS.has(ext)) {
505
+ throw new Error(`image_sample: unsupported image type "${ext}" (supported: PNG, JPEG, GIF, BMP)`);
506
+ }
507
+ const size = args.size === undefined ? 8 : Number(args.size);
508
+ if (!Number.isInteger(size) || size < 2 || size > 16) {
509
+ throw new Error('image_sample: size must be an integer between 2 and 16');
510
+ }
511
+ const regionArray = core.normalizeRegion(args.region);
512
+
513
+ const cwd = exec.agent?.session?.header?.cwd;
514
+ const target = await ctx.fs.resolve(filePath, {
515
+ ...(cwd !== undefined ? { cwd } : {}),
516
+ signal: exec.signal
517
+ });
518
+ const info = await ctx.fs.stat(target, exec.signal);
519
+ if (!info) {
520
+ throw new Error(`image_sample: cannot read "${target.displayPath}": file not found`);
521
+ }
522
+ if (info.type !== 'file') {
523
+ throw new Error(`image_sample: cannot read "${target.displayPath}": not a regular file`);
524
+ }
525
+ const data = await ctx.fs.readBytes(target, exec.signal, BYTE_CAP);
526
+ const image = core.decodeImage(data, ext);
527
+ if (image.width * image.height > MAX_PIXELS) {
528
+ throw new Error(
529
+ `image_sample: ${image.width}x${image.height} exceeds the ${MAX_PIXELS}-pixel decode limit — downscale or crop the file first`
530
+ );
531
+ }
532
+
533
+ const sample = core.samplePixels(image.data, image.width, image.height, regionArray, size);
534
+ ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec);
535
+ return {
536
+ path: target.displayPath,
537
+ width: sample.width,
538
+ height: sample.height,
539
+ region: regionArray.map((v) => Math.round(v * 1000) / 1000).join(','),
540
+ contrast: sample.contrast,
541
+ distinct: sample.distinct,
542
+ stepX: sample.stepX,
543
+ stepY: sample.stepY,
544
+ points: sample.points
545
+ };
546
+ }
547
+ };
548
+ }