@anionex/dsh-vision-toolkit 0.1.5

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.
Files changed (152) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +383 -0
  4. package/README.zh.md +383 -0
  5. package/assets/dsh-conversation-artifact.png +0 -0
  6. package/assets/dsh-conversation-image-qa-top.png +0 -0
  7. package/assets/dsh-conversation-image-qa.png +0 -0
  8. package/assets/dsh-conversation-pixel-diff.png +0 -0
  9. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  11. package/assets/dsh-conversation-tool-call.png +0 -0
  12. package/assets/dsh-conversation-vision-trace.png +0 -0
  13. package/assets/hero.png +0 -0
  14. package/assets/social-preview.png +0 -0
  15. package/assets/upstream/README.md +16 -0
  16. package/assets/upstream/image-qa.webp +0 -0
  17. package/assets/upstream/infographic-reference.webp +0 -0
  18. package/assets/upstream/infographic-result.webp +0 -0
  19. package/assets/upstream/screenshot-debugging.webp +0 -0
  20. package/assets/upstream/ui-result.webp +0 -0
  21. package/assets/upstream/ui-sketch.webp +0 -0
  22. package/assets/vision-settings.png +0 -0
  23. package/cordis.patch.yml +6 -0
  24. package/docs/assets/vision-settings.png +0 -0
  25. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  26. package/docs/requirements-traceability/README.md +75 -0
  27. package/docs/requirements-traceability/README.zh.md +75 -0
  28. package/examples/ui-restoration/README.i18n.yaml +6 -0
  29. package/examples/ui-restoration/README.md +70 -0
  30. package/examples/ui-restoration/README.zh.md +70 -0
  31. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  32. package/examples/ui-restoration/assets/final-report.json +83 -0
  33. package/examples/ui-restoration/assets/implementation.png +0 -0
  34. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  35. package/examples/ui-restoration/assets/initial-report.json +83 -0
  36. package/examples/ui-restoration/assets/initial.png +0 -0
  37. package/examples/ui-restoration/assets/metrics.json +12 -0
  38. package/examples/ui-restoration/assets/reference.png +0 -0
  39. package/examples/ui-restoration/implementation.html +94 -0
  40. package/examples/ui-restoration/initial.html +57 -0
  41. package/lib/artifact-access.js +369 -0
  42. package/lib/artifact-access.js.map +1 -0
  43. package/lib/artifacts.js +56 -0
  44. package/lib/artifacts.js.map +1 -0
  45. package/lib/client.js +952 -0
  46. package/lib/client.js.map +1 -0
  47. package/lib/config.js +117 -0
  48. package/lib/config.js.map +1 -0
  49. package/lib/errors.js +56 -0
  50. package/lib/errors.js.map +1 -0
  51. package/lib/exposure.js +213 -0
  52. package/lib/exposure.js.map +1 -0
  53. package/lib/index.js +97 -0
  54. package/lib/index.js.map +1 -0
  55. package/lib/paste-images.js +199 -0
  56. package/lib/paste-images.js.map +1 -0
  57. package/lib/paths.js +325 -0
  58. package/lib/paths.js.map +1 -0
  59. package/lib/runtime-install.js +601 -0
  60. package/lib/runtime-install.js.map +1 -0
  61. package/lib/runtime-manager.js +126 -0
  62. package/lib/runtime-manager.js.map +1 -0
  63. package/lib/runtime.js +1344 -0
  64. package/lib/runtime.js.map +1 -0
  65. package/lib/skill.js +139 -0
  66. package/lib/skill.js.map +1 -0
  67. package/lib/tools.js +528 -0
  68. package/lib/tools.js.map +1 -0
  69. package/lib/types/artifact-access.d.ts +61 -0
  70. package/lib/types/artifact-access.d.ts.map +1 -0
  71. package/lib/types/artifacts.d.ts +42 -0
  72. package/lib/types/artifacts.d.ts.map +1 -0
  73. package/lib/types/client/index.d.ts +179 -0
  74. package/lib/types/client/index.d.ts.map +1 -0
  75. package/lib/types/client/paste-images.d.ts +57 -0
  76. package/lib/types/client/paste-images.d.ts.map +1 -0
  77. package/lib/types/config.d.ts +73 -0
  78. package/lib/types/config.d.ts.map +1 -0
  79. package/lib/types/errors.d.ts +35 -0
  80. package/lib/types/errors.d.ts.map +1 -0
  81. package/lib/types/exposure.d.ts +40 -0
  82. package/lib/types/exposure.d.ts.map +1 -0
  83. package/lib/types/index.d.ts +18 -0
  84. package/lib/types/index.d.ts.map +1 -0
  85. package/lib/types/paste-images.d.ts +21 -0
  86. package/lib/types/paste-images.d.ts.map +1 -0
  87. package/lib/types/paths.d.ts +107 -0
  88. package/lib/types/paths.d.ts.map +1 -0
  89. package/lib/types/runtime-install.d.ts +49 -0
  90. package/lib/types/runtime-install.d.ts.map +1 -0
  91. package/lib/types/runtime-manager.d.ts +60 -0
  92. package/lib/types/runtime-manager.d.ts.map +1 -0
  93. package/lib/types/runtime.d.ts +389 -0
  94. package/lib/types/runtime.d.ts.map +1 -0
  95. package/lib/types/skill.d.ts +15 -0
  96. package/lib/types/skill.d.ts.map +1 -0
  97. package/lib/types/tools.d.ts +22 -0
  98. package/lib/types/tools.d.ts.map +1 -0
  99. package/lib/types/upstream.d.ts +207 -0
  100. package/lib/types/upstream.d.ts.map +1 -0
  101. package/lib/types/version.d.ts +15 -0
  102. package/lib/types/version.d.ts.map +1 -0
  103. package/lib/types/web-request.d.ts +4 -0
  104. package/lib/types/web-request.d.ts.map +1 -0
  105. package/lib/types/web.d.ts +74 -0
  106. package/lib/types/web.d.ts.map +1 -0
  107. package/lib/upstream.js +675 -0
  108. package/lib/upstream.js.map +1 -0
  109. package/lib/version.js +18 -0
  110. package/lib/version.js.map +1 -0
  111. package/lib/web-request.js +20 -0
  112. package/lib/web-request.js.map +1 -0
  113. package/lib/web.js +244 -0
  114. package/lib/web.js.map +1 -0
  115. package/package.json +139 -0
  116. package/runtime/requirements.lock +3 -0
  117. package/src/artifact-access.ts +386 -0
  118. package/src/artifacts.ts +85 -0
  119. package/src/client/index.tsx +866 -0
  120. package/src/client/paste-images.tsx +426 -0
  121. package/src/config.ts +177 -0
  122. package/src/errors.ts +62 -0
  123. package/src/exposure.ts +227 -0
  124. package/src/index.ts +122 -0
  125. package/src/paste-images.ts +234 -0
  126. package/src/paths.ts +348 -0
  127. package/src/runtime-install.ts +723 -0
  128. package/src/runtime-manager.ts +166 -0
  129. package/src/runtime.ts +1783 -0
  130. package/src/skill.ts +143 -0
  131. package/src/tools.ts +668 -0
  132. package/src/upstream.ts +861 -0
  133. package/src/version.ts +37 -0
  134. package/src/web-request.ts +17 -0
  135. package/src/web.ts +329 -0
  136. package/vendor/agent-vision-toolkit/CHANGELOG.md +16 -0
  137. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  138. package/vendor/agent-vision-toolkit/README.md +399 -0
  139. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +89 -0
  140. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  141. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  142. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  143. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  144. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  145. package/vendor/agent-vision-toolkit/detect.py +56 -0
  146. package/vendor/agent-vision-toolkit/ground.py +216 -0
  147. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  148. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  149. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +108 -0
  150. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  151. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  152. package/vendor/agent-vision-toolkit/vision_client.py +156 -0
package/src/runtime.ts ADDED
@@ -0,0 +1,1783 @@
1
+ /**
2
+ * Vision Toolkit runtime: structured requests in, structured results out.
3
+ * One operation-wide deadline reaches every subprocess; image decoding,
4
+ * byte/pixel limits, session-scoped concurrency, credential resolution, safe
5
+ * output staging, and diagnostic logging stay below the model-facing tools.
6
+ * @module dsh-vision-toolkit/runtime
7
+ */
8
+
9
+ import { createHash, randomUUID } from 'node:crypto'
10
+ import { readFile, rm, stat, writeFile } from 'node:fs/promises'
11
+ import { basename, extname, join } from 'node:path'
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ import type { ResolvedCredential } from '@deepseek-ai/dsh-credentials'
14
+ import { SaxesParser } from 'saxes'
15
+ import { describeArtifact, type ArtifactDescriptor } from './artifacts.ts'
16
+ import type { ResolvedVisionToolkitConfig } from './config.ts'
17
+ import { VisionToolkitError } from './errors.ts'
18
+ import {
19
+ assertDistinctOutput,
20
+ commitStagedDirectory,
21
+ commitStagedOutput,
22
+ createPathPolicy,
23
+ createStagedDirectory,
24
+ createStagedOutput,
25
+ isWithin,
26
+ resolveHtmlFile,
27
+ resolveInputFile,
28
+ resolveOutputDirectory,
29
+ resolveOutputFile,
30
+ seedStagedDirectory,
31
+ type PathPolicy,
32
+ } from './paths.ts'
33
+ import {
34
+ parseCropOutput,
35
+ parseDominantColorsOutput,
36
+ parseExtractForegroundOutput,
37
+ parseHtmlScreenshotOutput,
38
+ parseLocationOutput,
39
+ parsePixelDiffOutput,
40
+ parseTraceOutput,
41
+ UpstreamAdapter,
42
+ type DominantColorsOutput,
43
+ type LocatedElement,
44
+ type UpstreamEnvironment,
45
+ type UpstreamRunResult,
46
+ type UpstreamTool,
47
+ type UpstreamVersionInfo,
48
+ } from './upstream.ts'
49
+ import { PLUGIN_VERSION } from './version.ts'
50
+
51
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
52
+
53
+ function svgDocumentPathCount(svg: string): number | undefined {
54
+ const parser = new SaxesParser({ xmlns: true })
55
+ let depth = 0
56
+ let invalid = false
57
+ let pathCount = 0
58
+ let rootSeen = false
59
+ let rootClosed = false
60
+ parser.on('doctype', () => { invalid = true })
61
+ parser.on('error', () => { invalid = true })
62
+ parser.on('opentag', (tag) => {
63
+ if (depth === 0) {
64
+ if (rootSeen || tag.local !== 'svg' || tag.uri !== SVG_NAMESPACE) invalid = true
65
+ rootSeen = true
66
+ }
67
+ if (tag.local === 'path' && tag.uri === SVG_NAMESPACE) pathCount += 1
68
+ depth += 1
69
+ })
70
+ parser.on('closetag', () => {
71
+ depth -= 1
72
+ if (depth === 0) rootClosed = true
73
+ if (depth < 0) invalid = true
74
+ })
75
+ try {
76
+ parser.write(svg).close()
77
+ } catch {
78
+ return undefined
79
+ }
80
+ return invalid || !rootSeen || !rootClosed || depth !== 0 ? undefined : pathCount
81
+ }
82
+
83
+ /** Per-invocation cancellation and timeout facts. */
84
+ export interface Deadline {
85
+ signal: AbortSignal
86
+ /** True when the deadline timer fired. */
87
+ timedOut: boolean
88
+ /** True when the caller signal fired first. */
89
+ cancelled: boolean
90
+ /** Clear the timer and caller listener. */
91
+ cleanup(): void
92
+ }
93
+
94
+ /** Combine a caller abort signal with one hard operation timeout. */
95
+ export function createDeadline(signal: AbortSignal, timeoutMs: number): Deadline {
96
+ const controller = new AbortController()
97
+ const state = { timedOut: false, cancelled: false }
98
+ const onCallerAbort = (): void => {
99
+ if (controller.signal.aborted) return
100
+ state.cancelled = true
101
+ controller.abort()
102
+ }
103
+ if (signal.aborted) {
104
+ state.cancelled = true
105
+ controller.abort()
106
+ } else {
107
+ signal.addEventListener('abort', onCallerAbort, { once: true })
108
+ }
109
+ const timer = setTimeout(() => {
110
+ if (controller.signal.aborted) return
111
+ state.timedOut = true
112
+ controller.abort()
113
+ }, timeoutMs)
114
+ return {
115
+ signal: controller.signal,
116
+ get timedOut(): boolean { return state.timedOut },
117
+ get cancelled(): boolean { return state.cancelled },
118
+ cleanup(): void {
119
+ clearTimeout(timer)
120
+ signal.removeEventListener('abort', onCallerAbort)
121
+ },
122
+ }
123
+ }
124
+
125
+ /** FIFO bounded concurrency gate whose queued callers remain cancellable. */
126
+ export class Semaphore {
127
+ private active = 0
128
+ private readonly waiters: Array<{
129
+ resolve: () => void
130
+ reject: (error: unknown) => void
131
+ signal: AbortSignal
132
+ permits: number
133
+ onAbort: () => void
134
+ }> = []
135
+
136
+ constructor(private readonly limit: number) {}
137
+
138
+ /** Whether no active or queued caller still owns this gate. */
139
+ get idle(): boolean {
140
+ return this.active === 0 && this.waiters.length === 0
141
+ }
142
+
143
+ /** Acquire one slot, aborting while queued when `signal` fires. */
144
+ async acquire(signal: AbortSignal, permits = 1): Promise<void> {
145
+ if (signal.aborted) throw new VisionToolkitError('cancelled', 'vision-toolkit: cancelled before execution')
146
+ if (!Number.isInteger(permits) || permits < 1 || permits > this.limit) {
147
+ throw new VisionToolkitError('input', `concurrency permits must be between 1 and ${this.limit}`)
148
+ }
149
+ if (this.waiters.length === 0 && this.active + permits <= this.limit) {
150
+ this.active += permits
151
+ return
152
+ }
153
+ return new Promise<void>((resolveAcquire, reject) => {
154
+ const entry = {
155
+ resolve: resolveAcquire,
156
+ reject,
157
+ signal,
158
+ permits,
159
+ onAbort: () => {},
160
+ }
161
+ entry.onAbort = (): void => {
162
+ const index = this.waiters.indexOf(entry)
163
+ if (index >= 0) this.waiters.splice(index, 1)
164
+ reject(new VisionToolkitError('cancelled', 'vision-toolkit: cancelled while waiting for a concurrency slot'))
165
+ }
166
+ this.waiters.push(entry)
167
+ signal.addEventListener('abort', entry.onAbort, { once: true })
168
+ })
169
+ }
170
+
171
+ /** Release owned permits and wake FIFO waiters whose full weight now fits. */
172
+ release(permits = 1): void {
173
+ this.active = Math.max(0, this.active - permits)
174
+ while (this.waiters.length > 0) {
175
+ const next = this.waiters[0]
176
+ if (next === undefined || this.active + next.permits > this.limit) break
177
+ this.waiters.shift()
178
+ next.signal.removeEventListener('abort', next.onAbort)
179
+ this.active += next.permits
180
+ next.resolve()
181
+ }
182
+ }
183
+ }
184
+
185
+ /** Validated image metadata retained in structured results and diagnostics. */
186
+ export interface ImageInfo {
187
+ path: string
188
+ bytes: number
189
+ width: number
190
+ height: number
191
+ format: string
192
+ }
193
+
194
+ /** Structured input for one glance call. */
195
+ export interface GlanceRequest {
196
+ images: string[]
197
+ query?: string
198
+ ocr?: boolean
199
+ region?: string
200
+ }
201
+
202
+ /** Structured glance result. */
203
+ export interface GlanceResult {
204
+ images: ImageInfo[]
205
+ mode: 'describe' | 'qa' | 'ocr'
206
+ answer: string
207
+ truncated: boolean
208
+ }
209
+
210
+ /** Structured input for ground/detect. */
211
+ export interface LocateRequest {
212
+ image: string
213
+ target: string
214
+ region?: string
215
+ }
216
+
217
+ /** One located element with an upstream or caller label. */
218
+ export interface LocateMatch {
219
+ label: string
220
+ box: { x1: number; y1: number; x2: number; y2: number }
221
+ }
222
+
223
+ /** Structured ground result. */
224
+ export interface GroundResult {
225
+ target: string
226
+ image: ImageInfo
227
+ imageWidth: number
228
+ imageHeight: number
229
+ matches: LocateMatch[]
230
+ preview?: ArtifactDescriptor
231
+ }
232
+
233
+ /** Structured detect result. */
234
+ export interface DetectResult {
235
+ category: string
236
+ image: ImageInfo
237
+ imageWidth: number
238
+ imageHeight: number
239
+ elements: Array<{ index: number; label: string; box: { x1: number; y1: number; x2: number; y2: number } }>
240
+ preview?: ArtifactDescriptor
241
+ }
242
+
243
+ /** Structured crop request. */
244
+ export interface CropRequest {
245
+ image: string
246
+ region: string
247
+ scale?: number
248
+ output?: string
249
+ }
250
+
251
+ /** Structured crop result. */
252
+ export interface CropResult {
253
+ imageWidth: number
254
+ imageHeight: number
255
+ region: { x1: number; y1: number; x2: number; y2: number }
256
+ outputPath: string
257
+ mimeType: 'image/png' | 'image/jpeg'
258
+ width: number
259
+ height: number
260
+ clamped: boolean
261
+ artifact: ArtifactDescriptor
262
+ note?: string
263
+ }
264
+
265
+ /** Structured trace request supported by the pinned upstream snapshot. */
266
+ export interface TraceRequest {
267
+ image: string
268
+ region?: string
269
+ scale?: number
270
+ color?: boolean
271
+ polygon?: boolean
272
+ output?: string
273
+ }
274
+
275
+ /** Structured trace result. */
276
+ export interface TraceResult {
277
+ imageWidth: number
278
+ imageHeight: number
279
+ outputPath: string
280
+ mimeType: 'image/svg+xml'
281
+ geometry: {
282
+ status: 'generated' | 'empty'
283
+ pathCount: number
284
+ tracedScale: number
285
+ bytes: number
286
+ }
287
+ artifact: ArtifactDescriptor
288
+ warning?: string
289
+ }
290
+
291
+ /** Structured input for local image comparison. */
292
+ export interface PixelDiffRequest {
293
+ original: string
294
+ rebuilt: string
295
+ grid?: number
296
+ top?: number
297
+ runName?: string
298
+ }
299
+
300
+ /** Structured local pixel comparison plus formally delivered files. */
301
+ export interface PixelDiffResult {
302
+ original: ImageInfo
303
+ rebuilt: ImageInfo
304
+ scaled: boolean
305
+ rebuiltOriginalSize?: { width: number; height: number }
306
+ overallDifferencePct: number
307
+ worstRegions: Array<{ index: number; differencePct: number; box: { x1: number; y1: number; x2: number; y2: number } }>
308
+ heatmap: ArtifactDescriptor
309
+ report: ArtifactDescriptor
310
+ }
311
+
312
+ /** Structured input for the pinned long-screenshot OCR pipeline. */
313
+ export interface LongScreenshotOcrRequest {
314
+ image: string
315
+ mode?: 'general' | 'chat'
316
+ output?: string
317
+ runName?: string
318
+ targetHeight?: number
319
+ minHeight?: number
320
+ maxHeight?: number
321
+ overlap?: number
322
+ prompt?: string
323
+ jobs?: number
324
+ chunkTimeoutSeconds?: number
325
+ splitOnly?: boolean
326
+ resume?: boolean
327
+ }
328
+
329
+ /** One long-OCR chunk and the files retained for audit or reuse. */
330
+ export interface LongScreenshotChunk {
331
+ index: number
332
+ coreTop: number
333
+ coreBottom: number
334
+ cropTop: number
335
+ cropBottom: number
336
+ image: ArtifactDescriptor
337
+ ocr?: ArtifactDescriptor
338
+ reused?: boolean
339
+ }
340
+
341
+ /** Long-screenshot split/OCR result with every durable deliverable. */
342
+ export interface LongScreenshotOcrResult {
343
+ source: ImageInfo
344
+ mode: 'general' | 'chat'
345
+ splitOnly: boolean
346
+ complete: boolean
347
+ chunkCount: number
348
+ runDirectory: string
349
+ output?: ArtifactDescriptor
350
+ manifest: ArtifactDescriptor
351
+ audit?: ArtifactDescriptor
352
+ chunks: LongScreenshotChunk[]
353
+ }
354
+
355
+ /** Structured input for transparent foreground extraction. */
356
+ export interface ExtractForegroundRequest {
357
+ image: string
358
+ region?: string
359
+ boxes?: string
360
+ mode?: 'color' | 'dark'
361
+ discRadius?: number
362
+ saturation?: number
363
+ darkThreshold?: number
364
+ excludeColor?: string
365
+ excludeTolerance?: number
366
+ padding?: number
367
+ keepWhites?: boolean
368
+ output?: string
369
+ }
370
+
371
+ /** Transparent foreground file plus the pinned script's component metrics. */
372
+ export interface ExtractForegroundResult {
373
+ source: ImageInfo
374
+ box: { x1: number; y1: number; x2: number; y2: number }
375
+ foregroundPixels: number
376
+ keptComponents: number
377
+ totalComponents: number
378
+ largestComponentPct: number
379
+ width: number
380
+ height: number
381
+ artifact: ArtifactDescriptor
382
+ autoSummary?: string
383
+ }
384
+
385
+ /** Structured input for palette extraction or candidate scoring. */
386
+ export interface DominantColorsRequest {
387
+ image: string
388
+ region?: string
389
+ candidates?: string[]
390
+ top?: number
391
+ quantize?: number
392
+ maxPixels?: number
393
+ mergeTolerance?: number
394
+ candidateTolerance?: number
395
+ }
396
+
397
+ /** Stable dominant-colour result enriched with source image facts. */
398
+ export interface DominantColorsResult {
399
+ image: ImageInfo
400
+ analysis: DominantColorsOutput
401
+ }
402
+
403
+ /** Structured input for rendering an authorized local HTML document. */
404
+ export interface HtmlScreenshotRequest {
405
+ source: string
406
+ width?: number
407
+ height?: number
408
+ scale?: number
409
+ waitMs?: number
410
+ output?: string
411
+ }
412
+
413
+ /** Browser-rendered PNG plus viewport and source facts. */
414
+ export interface HtmlScreenshotResult {
415
+ sourcePath: string
416
+ sourceBytes: number
417
+ viewport: { width: number; height: number; scale: number }
418
+ width: number
419
+ height: number
420
+ artifact: ArtifactDescriptor
421
+ }
422
+
423
+ /** Optional preview controls shared by ground and detect. */
424
+ export interface LocatePreviewRequest extends LocateRequest {
425
+ preview?: boolean
426
+ previewOutput?: string
427
+ }
428
+
429
+ /** One named health-check state. */
430
+ export interface HealthCheck {
431
+ status: 'ok' | 'warning' | 'error' | 'not_tested'
432
+ detail: string
433
+ }
434
+
435
+ /** Runtime, dependency, storage, credential, and optional service health. */
436
+ export interface VisionToolkitHealthResult {
437
+ pluginVersion: string
438
+ upstream: UpstreamVersionInfo
439
+ checks: {
440
+ python: HealthCheck
441
+ dependencies: HealthCheck
442
+ chrome: HealthCheck
443
+ credential: HealthCheck
444
+ artifactDirectory: HealthCheck
445
+ tempDirectory: HealthCheck
446
+ service: HealthCheck
447
+ }
448
+ healthy: boolean
449
+ connectionTested: boolean
450
+ }
451
+
452
+ /** Shared per-call execution options. */
453
+ export interface ToolCallOptions {
454
+ signal: AbortSignal
455
+ timeoutMs?: number
456
+ workspace: string
457
+ /** Session identity for the per-session concurrency cap. */
458
+ sessionId?: string
459
+ /** Live Session object whose lifetime bounds the one-entry glance cache. */
460
+ sessionScope?: object
461
+ }
462
+
463
+ interface GlanceCacheEntry {
464
+ key: string
465
+ result: GlanceResult
466
+ }
467
+
468
+ interface OperationMetrics {
469
+ startedAt: number
470
+ upstreamMs: number
471
+ imageBytes: number
472
+ imagePixels: number
473
+ imageCount: number
474
+ cacheHits: number
475
+ usedVisionService: boolean
476
+ }
477
+
478
+ interface OperationContext {
479
+ signal: AbortSignal
480
+ metrics: OperationMetrics
481
+ }
482
+
483
+ const REGION_PATTERN = /^\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*$/
484
+ const MAX_TIMEOUT_MS = 600_000
485
+ const FORMAT_BY_EXTENSION = new Map([
486
+ ['.png', 'png'],
487
+ ['.jpg', 'jpeg'],
488
+ ['.jpeg', 'jpeg'],
489
+ ['.gif', 'gif'],
490
+ ['.webp', 'webp'],
491
+ ])
492
+ const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/
493
+
494
+ function integerInRange(value: number | undefined, fallback: number, minimum: number, maximum: number, name: string): number {
495
+ const resolved = value ?? fallback
496
+ if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
497
+ throw new VisionToolkitError('input', `${name} must be an integer between ${minimum} and ${maximum}`)
498
+ }
499
+ return resolved
500
+ }
501
+
502
+ function finiteInRange(value: number | undefined, minimum: number, maximum: number, name: string): number | undefined {
503
+ if (value === undefined) return undefined
504
+ if (!Number.isFinite(value) || value < minimum || value > maximum) {
505
+ throw new VisionToolkitError('input', `${name} must be between ${minimum} and ${maximum}`)
506
+ }
507
+ return value
508
+ }
509
+
510
+ function assertBoxWithin(
511
+ box: { x1: number; y1: number; x2: number; y2: number },
512
+ width: number,
513
+ height: number,
514
+ source: string,
515
+ ): void {
516
+ if (
517
+ ![box.x1, box.y1, box.x2, box.y2].every(Number.isInteger)
518
+ || box.x1 < 0
519
+ || box.y1 < 0
520
+ || box.x2 <= box.x1
521
+ || box.y2 <= box.y1
522
+ || box.x2 > width
523
+ || box.y2 > height
524
+ ) {
525
+ throw new VisionToolkitError('output', `${source} returned an out-of-range box for ${width}x${height}`)
526
+ }
527
+ }
528
+
529
+ function safeGeneratedName(name: unknown, source: string): string {
530
+ if (typeof name !== 'string' || name.length === 0 || basename(name) !== name || name === '.' || name === '..') {
531
+ throw new VisionToolkitError('output', `${source} returned an unsafe generated filename`)
532
+ }
533
+ return name
534
+ }
535
+
536
+ interface ParsedLongOcrChunk {
537
+ index: number
538
+ image: string
539
+ imageSha256: string
540
+ coreTop: number
541
+ coreBottom: number
542
+ cropTop: number
543
+ cropBottom: number
544
+ ocr?: string
545
+ ocrReused?: boolean
546
+ }
547
+
548
+ interface ParsedLongOcrManifest {
549
+ mode: 'general' | 'chat'
550
+ complete: boolean
551
+ chunks: ParsedLongOcrChunk[]
552
+ raw: Record<string, unknown>
553
+ }
554
+
555
+ function objectRecord(value: unknown, source: string): Record<string, unknown> {
556
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
557
+ throw new VisionToolkitError('output', `${source} must be a JSON object`)
558
+ }
559
+ return value as Record<string, unknown>
560
+ }
561
+
562
+ function manifestInteger(record: Record<string, unknown>, key: string, source: string): number {
563
+ const value = record[key]
564
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
565
+ throw new VisionToolkitError('output', `${source}.${key} must be an integer`)
566
+ }
567
+ return value
568
+ }
569
+
570
+ function parseLongOcrManifest(
571
+ text: string,
572
+ expected: { source: string; output: string; width: number; height: number; mode: 'general' | 'chat'; splitOnly: boolean },
573
+ ): ParsedLongOcrManifest {
574
+ let parsed: unknown
575
+ try {
576
+ parsed = JSON.parse(text)
577
+ } catch (error) {
578
+ throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest is not valid JSON', { cause: error })
579
+ }
580
+ const manifest = objectRecord(parsed, 'long_screenshot_ocr manifest')
581
+ if (manifest.schema_version !== 1) throw new VisionToolkitError('output', 'long_screenshot_ocr: unsupported manifest schema')
582
+ if (manifest.input !== expected.source) throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest source does not match the requested image')
583
+ if (manifestInteger(manifest, 'image_width', 'long_screenshot_ocr manifest') !== expected.width
584
+ || manifestInteger(manifest, 'image_height', 'long_screenshot_ocr manifest') !== expected.height) {
585
+ throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest dimensions do not match the source image')
586
+ }
587
+ if (manifest.mode !== expected.mode) throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest mode does not match the request')
588
+ const complete = manifest.complete
589
+ if (typeof complete !== 'boolean' || complete === expected.splitOnly) {
590
+ throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest completion state is inconsistent')
591
+ }
592
+ if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0) {
593
+ throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest contains no chunks')
594
+ }
595
+ if (manifest.output !== (expected.splitOnly ? null : expected.output)) {
596
+ throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest output path is inconsistent')
597
+ }
598
+ const chunks = manifest.chunks.map((value, position): ParsedLongOcrChunk => {
599
+ const record = objectRecord(value, `long_screenshot_ocr manifest.chunks[${position}]`)
600
+ const index = manifestInteger(record, 'index', `long_screenshot_ocr manifest.chunks[${position}]`)
601
+ if (index !== position + 1) throw new VisionToolkitError('output', 'long_screenshot_ocr: chunk indexes are not contiguous')
602
+ const image = safeGeneratedName(record.image, 'long_screenshot_ocr')
603
+ const imageSha256 = record.image_sha256
604
+ if (typeof imageSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(imageSha256)) {
605
+ throw new VisionToolkitError('output', 'long_screenshot_ocr: chunk image hash is invalid')
606
+ }
607
+ const coreTop = manifestInteger(record, 'core_top', `long_screenshot_ocr manifest.chunks[${position}]`)
608
+ const coreBottom = manifestInteger(record, 'core_bottom', `long_screenshot_ocr manifest.chunks[${position}]`)
609
+ const cropTop = manifestInteger(record, 'crop_top', `long_screenshot_ocr manifest.chunks[${position}]`)
610
+ const cropBottom = manifestInteger(record, 'crop_bottom', `long_screenshot_ocr manifest.chunks[${position}]`)
611
+ if (
612
+ coreTop < 0
613
+ || coreBottom <= coreTop
614
+ || cropTop < 0
615
+ || cropBottom <= cropTop
616
+ || cropTop > coreTop
617
+ || cropBottom < coreBottom
618
+ || cropBottom > expected.height
619
+ ) {
620
+ throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest contains an invalid chunk range')
621
+ }
622
+ const ocr = record.ocr === undefined ? undefined : safeGeneratedName(record.ocr, 'long_screenshot_ocr')
623
+ const ocrReused = record.ocr_reused
624
+ if (ocrReused !== undefined && typeof ocrReused !== 'boolean') {
625
+ throw new VisionToolkitError('output', 'long_screenshot_ocr: ocr_reused must be boolean')
626
+ }
627
+ if (complete && ocr === undefined) throw new VisionToolkitError('output', 'long_screenshot_ocr: complete manifest is missing an OCR sidecar')
628
+ return {
629
+ index,
630
+ image,
631
+ imageSha256,
632
+ coreTop,
633
+ coreBottom,
634
+ cropTop,
635
+ cropBottom,
636
+ ...(ocr === undefined ? {} : { ocr }),
637
+ ...(ocrReused === undefined ? {} : { ocrReused }),
638
+ }
639
+ })
640
+ return { mode: expected.mode, complete, chunks, raw: manifest }
641
+ }
642
+
643
+ /** Parse a non-empty four-integer pixel box. */
644
+ export function parseRegion(region: string): { x1: number; y1: number; x2: number; y2: number } {
645
+ const match = REGION_PATTERN.exec(region)
646
+ if (match === null) {
647
+ throw new VisionToolkitError('input', 'region must be four integers: X1,Y1,X2,Y2 (pixels)')
648
+ }
649
+ const box = {
650
+ x1: Number(match[1]),
651
+ y1: Number(match[2]),
652
+ x2: Number(match[3]),
653
+ y2: Number(match[4]),
654
+ }
655
+ if (box.x2 <= box.x1 || box.y2 <= box.y1) {
656
+ throw new VisionToolkitError('input', 'region must have x2 > x1 and y2 > y1')
657
+ }
658
+ return box
659
+ }
660
+
661
+ /** Runtime facade used by every native tool. */
662
+ export class VisionToolkitRuntime {
663
+ private readonly semaphores = new Map<string, Semaphore>()
664
+ private readonly glanceCache = new WeakMap<object, GlanceCacheEntry>()
665
+ private readonly adapter: UpstreamAdapter
666
+
667
+ constructor(
668
+ private readonly ctx: Context,
669
+ private readonly config: ResolvedVisionToolkitConfig,
670
+ adapter?: UpstreamAdapter,
671
+ ) {
672
+ this.adapter = adapter ?? new UpstreamAdapter(ctx, config)
673
+ }
674
+
675
+ /** Pinned and prepared upstream identity. */
676
+ get upstreamVersion(): UpstreamVersionInfo {
677
+ return this.adapter.versionInfo
678
+ }
679
+
680
+ private timeout(options: ToolCallOptions): number {
681
+ const value = options.timeoutMs ?? this.config.timeoutMs
682
+ if (!Number.isInteger(value) || value < 1000 || value > MAX_TIMEOUT_MS) {
683
+ throw new VisionToolkitError('input', `timeoutMs must be an integer between 1000 and ${MAX_TIMEOUT_MS}`)
684
+ }
685
+ return value
686
+ }
687
+
688
+ private operationError(tool: string, error: unknown, deadline: Deadline): VisionToolkitError {
689
+ if (deadline.cancelled) return new VisionToolkitError('cancelled', `${tool}: cancelled`)
690
+ if (deadline.timedOut) return new VisionToolkitError('timeout', `${tool}: timed out`)
691
+ if (error instanceof VisionToolkitError) return error
692
+ return new VisionToolkitError('runtime', `${tool}: execution failed`, { cause: error })
693
+ }
694
+
695
+ private semaphore(options: ToolCallOptions): { key: string; value: Semaphore } {
696
+ const key = options.sessionId ?? `workspace:${options.workspace}`
697
+ const value = this.semaphores.get(key) ?? new Semaphore(this.config.concurrency)
698
+ this.semaphores.set(key, value)
699
+ return { key, value }
700
+ }
701
+
702
+ private async runOperation<T>(
703
+ tool: string,
704
+ options: ToolCallOptions,
705
+ action: (operation: OperationContext) => Promise<T>,
706
+ permits = 1,
707
+ ): Promise<T> {
708
+ const deadline = createDeadline(options.signal, this.timeout(options))
709
+ const semaphore = this.semaphore(options)
710
+ const metrics: OperationMetrics = {
711
+ startedAt: Date.now(),
712
+ upstreamMs: 0,
713
+ imageBytes: 0,
714
+ imagePixels: 0,
715
+ imageCount: 0,
716
+ cacheHits: 0,
717
+ usedVisionService: false,
718
+ }
719
+ let acquired = false
720
+ try {
721
+ await semaphore.value.acquire(deadline.signal, permits)
722
+ acquired = true
723
+ const value = await action({ signal: deadline.signal, metrics })
724
+ if (deadline.signal.aborted) throw this.operationError(tool, undefined, deadline)
725
+ this.ctx.logger.info(
726
+ 'dsh-vision-toolkit tool=%s outcome=ok totalMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d model=%s',
727
+ tool,
728
+ Date.now() - metrics.startedAt,
729
+ metrics.upstreamMs,
730
+ metrics.imageCount,
731
+ metrics.imageBytes,
732
+ metrics.imagePixels,
733
+ metrics.cacheHits,
734
+ metrics.usedVisionService ? this.config.provider.model : 'local',
735
+ )
736
+ return value
737
+ } catch (error) {
738
+ const classified = this.operationError(tool, error, deadline)
739
+ this.ctx.logger.warn(
740
+ 'dsh-vision-toolkit tool=%s outcome=error category=%s totalMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d',
741
+ tool,
742
+ classified.code,
743
+ Date.now() - metrics.startedAt,
744
+ metrics.upstreamMs,
745
+ metrics.imageCount,
746
+ metrics.imageBytes,
747
+ metrics.imagePixels,
748
+ metrics.cacheHits,
749
+ )
750
+ throw classified
751
+ } finally {
752
+ if (acquired) semaphore.value.release(permits)
753
+ deadline.cleanup()
754
+ if (semaphore.value.idle) this.semaphores.delete(semaphore.key)
755
+ }
756
+ }
757
+
758
+ /** Resolve the configured credential at the remote-operation boundary. */
759
+ async resolveVisionEnv(): Promise<UpstreamEnvironment> {
760
+ const resolved: ResolvedCredential | undefined = await this.ctx.credentials.resolve(this.config.provider.credential)
761
+ if (resolved === undefined) {
762
+ throw new VisionToolkitError(
763
+ 'config',
764
+ `credential ${this.config.provider.credential} is not configured; set it through DSH credentials`,
765
+ )
766
+ }
767
+ return {
768
+ VISION_API_KEY: resolved.value,
769
+ VISION_BASE_URL: this.config.provider.baseUrl,
770
+ VISION_MODEL: this.config.provider.model,
771
+ LANG: this.config.language,
772
+ }
773
+ }
774
+
775
+ private pathPolicy(workspace: string): Promise<PathPolicy> {
776
+ return createPathPolicy(workspace, this.config.allowedDirs)
777
+ }
778
+
779
+ private async validateImage(raw: string, policy: PathPolicy, operation: OperationContext): Promise<ImageInfo> {
780
+ const image = await resolveInputFile(raw, policy)
781
+ if (image.bytes > this.config.maxImageBytes) {
782
+ throw new VisionToolkitError('capacity', `image is ${image.bytes} bytes, exceeding maxImageBytes ${this.config.maxImageBytes}`)
783
+ }
784
+ const decoded = await this.adapter.probeImageSize(image.path, { signal: operation.signal })
785
+ const pixels = decoded.width * decoded.height
786
+ if (!Number.isSafeInteger(pixels) || pixels > this.config.maxImagePixels) {
787
+ throw new VisionToolkitError(
788
+ 'capacity',
789
+ `image is ${decoded.width}x${decoded.height} (${pixels} pixels), exceeding maxImagePixels ${this.config.maxImagePixels}`,
790
+ )
791
+ }
792
+ const extension = extname(image.path).toLowerCase()
793
+ const expected = FORMAT_BY_EXTENSION.get(extension)
794
+ if (expected !== decoded.format) {
795
+ throw new VisionToolkitError('input', `image content is ${decoded.format}, but the filename uses ${extension}`)
796
+ }
797
+ return { ...image, width: decoded.width, height: decoded.height, format: decoded.format }
798
+ }
799
+
800
+ private accountImage(image: ImageInfo, operation: OperationContext): void {
801
+ operation.metrics.imageCount += 1
802
+ operation.metrics.imageBytes += image.bytes
803
+ operation.metrics.imagePixels += image.width * image.height
804
+ }
805
+
806
+ private async glanceCacheKey(
807
+ request: GlanceRequest,
808
+ images: readonly ImageInfo[],
809
+ env: UpstreamEnvironment,
810
+ signal: AbortSignal,
811
+ ): Promise<string> {
812
+ const imageFingerprints = await Promise.all(images.map(async (image) => {
813
+ let bytes: Buffer
814
+ try {
815
+ bytes = await readFile(image.path, { signal })
816
+ } catch (error) {
817
+ throw new VisionToolkitError('input', `image changed while preparing the vision request: ${image.path}`, { cause: error })
818
+ }
819
+ if (bytes.length !== image.bytes) {
820
+ throw new VisionToolkitError('input', `image changed while preparing the vision request: ${image.path}`)
821
+ }
822
+ return {
823
+ path: image.path,
824
+ sha256: createHash('sha256').update(bytes).digest('hex'),
825
+ }
826
+ }))
827
+ return JSON.stringify({
828
+ images: imageFingerprints,
829
+ query: request.query ?? null,
830
+ ocr: request.ocr === true,
831
+ region: request.region ?? null,
832
+ provider: {
833
+ baseUrl: env.VISION_BASE_URL,
834
+ model: env.VISION_MODEL,
835
+ language: env.LANG,
836
+ credentialSha256: createHash('sha256').update(env.VISION_API_KEY).digest('hex'),
837
+ },
838
+ })
839
+ }
840
+
841
+ private async runUpstream(
842
+ tool: UpstreamTool,
843
+ args: readonly string[],
844
+ operation: OperationContext,
845
+ env?: UpstreamEnvironment,
846
+ ): Promise<UpstreamRunResult> {
847
+ const started = Date.now()
848
+ if (env !== undefined) operation.metrics.usedVisionService = true
849
+ const result = await this.adapter.run(tool, args, {
850
+ signal: operation.signal,
851
+ ...(env === undefined ? {} : { env }),
852
+ })
853
+ operation.metrics.upstreamMs += Date.now() - started
854
+ if (result.outcome.exitCode !== 0) {
855
+ throw this.adapter.classifyFailure(tool, result, {
856
+ timedOut: false,
857
+ cancelled: operation.signal.aborted,
858
+ ...(env === undefined ? {} : { secrets: [env.VISION_API_KEY] }),
859
+ })
860
+ }
861
+ if (result.stdoutTruncated || result.stderrTruncated) {
862
+ throw new VisionToolkitError('output', `${tool}: upstream output exceeded the capture limit`)
863
+ }
864
+ return result
865
+ }
866
+
867
+ private async probeGeneratedImage(
868
+ path: string,
869
+ operation: OperationContext,
870
+ source: string,
871
+ ): Promise<{ width: number; height: number; format: string; mode: string }> {
872
+ try {
873
+ return await this.adapter.probeImageSize(path, { signal: operation.signal })
874
+ } catch (error) {
875
+ if (operation.signal.aborted) throw error
876
+ throw new VisionToolkitError('output', `${source}: generated image is missing, corrupt, or unsupported`, { cause: error })
877
+ }
878
+ }
879
+
880
+ private async annotateLocations(
881
+ tool: 'vision_ground' | 'vision_detect',
882
+ image: ImageInfo,
883
+ elements: readonly LocatedElement[],
884
+ output: string | undefined,
885
+ policy: PathPolicy,
886
+ operation: OperationContext,
887
+ ): Promise<ArtifactDescriptor> {
888
+ const extension = extname(image.path).toLowerCase()
889
+ const stem = basename(image.path, extension)
890
+ const suffix = tool === 'vision_ground' ? 'ground' : 'detect'
891
+ const finalPath = resolveOutputFile(output, policy, `${stem}.${suffix}.preview.png`, ['.png'])
892
+ assertDistinctOutput(image.path, finalPath)
893
+ const staged = createStagedOutput(policy, '.png')
894
+ try {
895
+ const started = Date.now()
896
+ await this.adapter.renderAnnotatedPreview(image.path, staged, elements, { signal: operation.signal })
897
+ operation.metrics.upstreamMs += Date.now() - started
898
+ const preview = await this.probeGeneratedImage(staged, operation, tool)
899
+ if (preview.format !== 'png' || preview.width !== image.width || preview.height !== image.height) {
900
+ throw new VisionToolkitError('output', `${tool}: annotation preview dimensions or format do not match the source image`)
901
+ }
902
+ await commitStagedOutput(staged, finalPath, policy)
903
+ return describeArtifact(finalPath, policy, {
904
+ mimeType: 'image/png',
905
+ kind: 'image',
906
+ description: tool === 'vision_ground' ? 'Grounding bounding-box preview' : 'Detected-element bounding-box preview',
907
+ sourceTool: tool,
908
+ previewIntent: 'image',
909
+ })
910
+ } finally {
911
+ await rm(staged, { force: true }).catch(() => {})
912
+ }
913
+ }
914
+
915
+ /** glance: describe, targeted QA, OCR, or multi-image comparison. */
916
+ async glance(request: GlanceRequest, options: ToolCallOptions): Promise<GlanceResult> {
917
+ return this.runOperation('vision_glance', options, async (operation) => {
918
+ if (request.images.length === 0) throw new VisionToolkitError('input', 'glance requires at least one image')
919
+ if (request.query !== undefined && request.ocr === true) {
920
+ throw new VisionToolkitError('input', 'glance: query and ocr are mutually exclusive')
921
+ }
922
+ if (request.region !== undefined && request.images.length > 1) {
923
+ throw new VisionToolkitError('input', 'glance: region works with exactly one image')
924
+ }
925
+ if (request.region !== undefined) parseRegion(request.region)
926
+ const policy = await this.pathPolicy(options.workspace)
927
+ const images: ImageInfo[] = []
928
+ const seen = new Set<string>()
929
+ for (const raw of request.images) {
930
+ const image = await this.validateImage(raw, policy, operation)
931
+ if (seen.has(image.path)) {
932
+ operation.metrics.cacheHits += 1
933
+ continue
934
+ }
935
+ seen.add(image.path)
936
+ this.accountImage(image, operation)
937
+ images.push(image)
938
+ }
939
+ const env = await this.resolveVisionEnv()
940
+ const cacheKey = options.sessionScope === undefined
941
+ ? undefined
942
+ : await this.glanceCacheKey(request, images, env, operation.signal)
943
+ if (options.sessionScope !== undefined && cacheKey !== undefined) {
944
+ const cached = this.glanceCache.get(options.sessionScope)
945
+ if (cached?.key === cacheKey) {
946
+ operation.metrics.cacheHits += 1
947
+ return cached.result
948
+ }
949
+ }
950
+ const result = await this.runUpstream('glance', [
951
+ ...images.map(image => image.path),
952
+ ...(request.region !== undefined ? ['--region', request.region] : []),
953
+ ...(request.ocr === true ? ['--ocr'] : []),
954
+ ...(request.query !== undefined ? ['-q', request.query] : []),
955
+ ], operation, env)
956
+ const answer = result.stdout.trim()
957
+ if (answer.length === 0) throw new VisionToolkitError('output', 'glance: vision API returned an empty description')
958
+ const value: GlanceResult = {
959
+ images,
960
+ mode: request.ocr === true ? 'ocr' : request.query !== undefined ? 'qa' : 'describe',
961
+ answer,
962
+ truncated: false,
963
+ }
964
+ if (options.sessionScope !== undefined && cacheKey !== undefined && !operation.signal.aborted) {
965
+ this.glanceCache.set(options.sessionScope, { key: cacheKey, result: value })
966
+ }
967
+ return value
968
+ })
969
+ }
970
+
971
+ private validateLocations(elements: LocatedElement[], width: number, height: number): void {
972
+ for (const element of elements) {
973
+ const { x1, y1, x2, y2 } = element.box
974
+ if (
975
+ ![x1, y1, x2, y2].every(Number.isInteger)
976
+ || x1 < 0
977
+ || y1 < 0
978
+ || x2 <= x1
979
+ || y2 <= y1
980
+ || x2 > width
981
+ || y2 > height
982
+ ) {
983
+ throw new VisionToolkitError('output', `upstream returned an out-of-range box for ${width}x${height}`)
984
+ }
985
+ }
986
+ }
987
+
988
+ private async locate(
989
+ request: LocateRequest,
990
+ options: ToolCallOptions,
991
+ operation: OperationContext,
992
+ tool: 'ground' | 'detect',
993
+ ): Promise<{ image: ImageInfo; elements: LocatedElement[] }> {
994
+ if (request.target.trim().length === 0) throw new VisionToolkitError('input', 'target must not be empty')
995
+ if (request.region !== undefined) parseRegion(request.region)
996
+ const policy = await this.pathPolicy(options.workspace)
997
+ const image = await this.validateImage(request.image, policy, operation)
998
+ this.accountImage(image, operation)
999
+ const env = await this.resolveVisionEnv()
1000
+ const result = await this.runUpstream(tool, [
1001
+ image.path,
1002
+ request.target,
1003
+ ...(request.region !== undefined ? ['--region', request.region] : []),
1004
+ ], operation, env)
1005
+ const elements = parseLocationOutput(result.stdout)
1006
+ this.validateLocations(elements, image.width, image.height)
1007
+ return { image, elements }
1008
+ }
1009
+
1010
+ /** ground: locate one named target and return pixel boxes. */
1011
+ async ground(request: LocatePreviewRequest, options: ToolCallOptions): Promise<GroundResult> {
1012
+ return this.runOperation('vision_ground', options, async (operation) => {
1013
+ const { image, elements } = await this.locate(request, options, operation, 'ground')
1014
+ const labeled = elements.map(element => ({ label: element.label ?? request.target, box: element.box }))
1015
+ const preview = request.preview === true
1016
+ ? await this.annotateLocations('vision_ground', image, labeled, request.previewOutput, await this.pathPolicy(options.workspace), operation)
1017
+ : undefined
1018
+ return {
1019
+ target: request.target,
1020
+ image,
1021
+ imageWidth: image.width,
1022
+ imageHeight: image.height,
1023
+ matches: labeled,
1024
+ ...(preview === undefined ? {} : { preview }),
1025
+ }
1026
+ })
1027
+ }
1028
+
1029
+ /** detect: inventory every instance of a kind. */
1030
+ async detect(request: LocatePreviewRequest, options: ToolCallOptions): Promise<DetectResult> {
1031
+ return this.runOperation('vision_detect', options, async (operation) => {
1032
+ const { image, elements } = await this.locate(request, options, operation, 'detect')
1033
+ const labeled = elements.map(element => ({ label: element.label ?? request.target, box: element.box }))
1034
+ const preview = request.preview === true
1035
+ ? await this.annotateLocations('vision_detect', image, labeled, request.previewOutput, await this.pathPolicy(options.workspace), operation)
1036
+ : undefined
1037
+ return {
1038
+ category: request.target,
1039
+ image,
1040
+ imageWidth: image.width,
1041
+ imageHeight: image.height,
1042
+ elements: labeled.map((element, index) => ({
1043
+ index: index + 1,
1044
+ label: element.label,
1045
+ box: element.box,
1046
+ })),
1047
+ ...(preview === undefined ? {} : { preview }),
1048
+ }
1049
+ })
1050
+ }
1051
+
1052
+ /** crop: cut a pixel box into its own image file without requiring a credential. */
1053
+ async crop(request: CropRequest, options: ToolCallOptions): Promise<CropResult> {
1054
+ return this.runOperation('vision_crop', options, async (operation) => {
1055
+ const region = parseRegion(request.region)
1056
+ if (request.scale !== undefined && (!Number.isInteger(request.scale) || request.scale < 1 || request.scale > 8)) {
1057
+ throw new VisionToolkitError('input', 'crop: scale must be an integer between 1 and 8')
1058
+ }
1059
+ const policy = await this.pathPolicy(options.workspace)
1060
+ const image = await this.validateImage(request.image, policy, operation)
1061
+ this.accountImage(image, operation)
1062
+ const sourceExtension = extname(image.path).toLowerCase()
1063
+ const stem = basename(image.path, sourceExtension)
1064
+ const finalPath = resolveOutputFile(
1065
+ request.output,
1066
+ policy,
1067
+ request.scale !== undefined && request.scale > 1 ? `${stem}.crop@${request.scale}x.png` : `${stem}.crop.png`,
1068
+ ['.png', '.jpg', '.jpeg'],
1069
+ )
1070
+ assertDistinctOutput(image.path, finalPath)
1071
+ const outputExtension = extname(finalPath).toLowerCase()
1072
+ const staged = createStagedOutput(policy, outputExtension)
1073
+ try {
1074
+ const result = await this.runUpstream('crop', [
1075
+ image.path,
1076
+ '--region',
1077
+ request.region,
1078
+ '-o',
1079
+ staged,
1080
+ ...(request.scale !== undefined ? ['--scale', String(request.scale)] : []),
1081
+ ], operation)
1082
+ const parsed = parseCropOutput(result.stdout, result.stderr)
1083
+ const generated = await this.probeGeneratedImage(staged, operation, 'crop')
1084
+ const expectedFormat = outputExtension === '.png' ? 'png' : 'jpeg'
1085
+ if (
1086
+ generated.format !== expectedFormat
1087
+ || generated.width !== parsed.width
1088
+ || generated.height !== parsed.height
1089
+ ) {
1090
+ throw new VisionToolkitError('output', 'crop: generated image does not match the upstream summary')
1091
+ }
1092
+ await commitStagedOutput(staged, finalPath, policy)
1093
+ const mimeType = outputExtension === '.png' ? 'image/png' : 'image/jpeg'
1094
+ const artifact = await describeArtifact(finalPath, policy, {
1095
+ mimeType,
1096
+ kind: 'image',
1097
+ description: 'Cropped image region',
1098
+ sourceTool: 'vision_crop',
1099
+ previewIntent: 'image',
1100
+ })
1101
+ return {
1102
+ imageWidth: image.width,
1103
+ imageHeight: image.height,
1104
+ region,
1105
+ outputPath: finalPath,
1106
+ mimeType,
1107
+ width: parsed.width,
1108
+ height: parsed.height,
1109
+ clamped: parsed.clamped,
1110
+ artifact,
1111
+ ...(parsed.note === undefined ? {} : { note: parsed.note }),
1112
+ }
1113
+ } finally {
1114
+ await rm(staged, { force: true }).catch(() => {})
1115
+ }
1116
+ })
1117
+ }
1118
+
1119
+ /** trace: recover an SVG through the pinned upstream vtracer pipeline. */
1120
+ async trace(request: TraceRequest, options: ToolCallOptions): Promise<TraceResult> {
1121
+ return this.runOperation('vision_trace', options, async (operation) => {
1122
+ if (request.region !== undefined) parseRegion(request.region)
1123
+ if (request.scale !== undefined && (!Number.isInteger(request.scale) || request.scale < 1 || request.scale > 16)) {
1124
+ throw new VisionToolkitError('input', 'trace: scale must be an integer between 1 and 16')
1125
+ }
1126
+ const policy = await this.pathPolicy(options.workspace)
1127
+ const image = await this.validateImage(request.image, policy, operation)
1128
+ this.accountImage(image, operation)
1129
+ const extension = extname(image.path).toLowerCase()
1130
+ const stem = basename(image.path, extension)
1131
+ const finalPath = resolveOutputFile(request.output, policy, `${stem}.svg`, ['.svg'])
1132
+ assertDistinctOutput(image.path, finalPath)
1133
+ const staged = createStagedOutput(policy, '.svg')
1134
+ try {
1135
+ const result = await this.runUpstream('trace', [
1136
+ image.path,
1137
+ ...(request.region !== undefined ? ['--region', request.region] : []),
1138
+ ...(request.scale !== undefined ? ['--scale', String(request.scale)] : []),
1139
+ ...(request.polygon === true ? ['--polygon'] : []),
1140
+ ...(request.color === true ? ['--color'] : []),
1141
+ '-o',
1142
+ staged,
1143
+ ], operation)
1144
+ const parsed = parseTraceOutput(result.stdout)
1145
+ const svg = await readFile(staged, 'utf8').catch(() => '')
1146
+ const actualPathCount = svgDocumentPathCount(svg)
1147
+ if (actualPathCount === undefined) {
1148
+ throw new VisionToolkitError('output', 'trace: output SVG is not a parseable document')
1149
+ }
1150
+ if (actualPathCount !== parsed.pathCount) {
1151
+ throw new VisionToolkitError('output', 'trace: reported path count does not match the generated SVG')
1152
+ }
1153
+ const svgInfo = await stat(staged)
1154
+ if (svgInfo.size !== parsed.bytes) {
1155
+ throw new VisionToolkitError('output', 'trace: reported byte count does not match the generated SVG')
1156
+ }
1157
+ await commitStagedOutput(staged, finalPath, policy)
1158
+ const artifact = await describeArtifact(finalPath, policy, {
1159
+ mimeType: 'image/svg+xml',
1160
+ kind: 'svg',
1161
+ description: 'Traced vector geometry',
1162
+ sourceTool: 'vision_trace',
1163
+ previewIntent: 'svg',
1164
+ })
1165
+ const warning = result.stderr.trim()
1166
+ return {
1167
+ imageWidth: image.width,
1168
+ imageHeight: image.height,
1169
+ outputPath: finalPath,
1170
+ mimeType: 'image/svg+xml',
1171
+ geometry: {
1172
+ status: parsed.pathCount === 0 ? 'empty' : 'generated',
1173
+ pathCount: parsed.pathCount,
1174
+ tracedScale: parsed.tracedScale,
1175
+ bytes: parsed.bytes,
1176
+ },
1177
+ artifact,
1178
+ ...(warning.length === 0 ? {} : { warning: warning.split(/\r?\n/).slice(-1)[0] ?? warning }),
1179
+ }
1180
+ } finally {
1181
+ await rm(staged, { force: true }).catch(() => {})
1182
+ }
1183
+ })
1184
+ }
1185
+
1186
+ /** pixel_diff: compare real pixels, rank error regions, and deliver a heatmap plus JSON report. */
1187
+ async pixelDiff(request: PixelDiffRequest, options: ToolCallOptions): Promise<PixelDiffResult> {
1188
+ return this.runOperation('vision_pixel_diff', options, async (operation) => {
1189
+ const grid = integerInRange(request.grid, 6, 1, 32, 'pixel_diff.grid')
1190
+ const top = integerInRange(request.top, 5, 1, grid * grid, 'pixel_diff.top')
1191
+ const policy = await this.pathPolicy(options.workspace)
1192
+ const original = await this.validateImage(request.original, policy, operation)
1193
+ const rebuilt = await this.validateImage(request.rebuilt, policy, operation)
1194
+ this.accountImage(original, operation)
1195
+ this.accountImage(rebuilt, operation)
1196
+ const originalStem = basename(original.path, extname(original.path))
1197
+ const rebuiltStem = basename(rebuilt.path, extname(rebuilt.path))
1198
+ const finalDirectory = resolveOutputDirectory(
1199
+ request.runName,
1200
+ policy,
1201
+ `${originalStem}-vs-${rebuiltStem}.pixel-diff`,
1202
+ )
1203
+ if (isWithin(finalDirectory, original.path) || isWithin(finalDirectory, rebuilt.path)) {
1204
+ throw new VisionToolkitError('input', 'pixel_diff artifact directory would replace an input image')
1205
+ }
1206
+ const stagedDirectory = await createStagedDirectory(policy)
1207
+ const stagedHeatmap = join(stagedDirectory, 'heatmap.png')
1208
+ const stagedReport = join(stagedDirectory, 'report.json')
1209
+ try {
1210
+ const result = await this.runUpstream('pixel_diff', [
1211
+ original.path,
1212
+ rebuilt.path,
1213
+ '--grid',
1214
+ String(grid),
1215
+ '--top',
1216
+ String(top),
1217
+ '-o',
1218
+ stagedHeatmap,
1219
+ ], operation)
1220
+ const parsed = parsePixelDiffOutput(result.stdout)
1221
+ if (parsed.heatmapPath !== stagedHeatmap) {
1222
+ throw new VisionToolkitError('output', 'pixel_diff: upstream reported an unexpected heatmap path')
1223
+ }
1224
+ if (!Number.isFinite(parsed.overallDifferencePct) || parsed.overallDifferencePct < 0 || parsed.overallDifferencePct > 100) {
1225
+ throw new VisionToolkitError('output', 'pixel_diff: overall difference is outside 0-100%')
1226
+ }
1227
+ if (
1228
+ parsed.scaled !== (original.width !== rebuilt.width || original.height !== rebuilt.height)
1229
+ || (parsed.scaledToSize !== undefined
1230
+ && (parsed.scaledToSize.width !== original.width || parsed.scaledToSize.height !== original.height))
1231
+ || parsed.worstRegions.length > top
1232
+ || parsed.worstRegions.some((region, index) => region.index !== index + 1)
1233
+ ) {
1234
+ throw new VisionToolkitError('output', 'pixel_diff: scaling or ranked-region metadata is inconsistent')
1235
+ }
1236
+ for (const region of parsed.worstRegions) assertBoxWithin(region.box, original.width, original.height, 'pixel_diff')
1237
+ const heatmapInfo = await this.probeGeneratedImage(stagedHeatmap, operation, 'pixel_diff')
1238
+ if (heatmapInfo.format !== 'png' || heatmapInfo.width !== original.width || heatmapInfo.height !== original.height) {
1239
+ throw new VisionToolkitError('output', 'pixel_diff: heatmap dimensions or format do not match the reference image')
1240
+ }
1241
+ const reportPayload = {
1242
+ schemaVersion: 1,
1243
+ sourceTool: 'vision_pixel_diff',
1244
+ original,
1245
+ rebuilt,
1246
+ scaled: parsed.scaled,
1247
+ ...(parsed.rebuiltOriginalSize === undefined ? {} : { rebuiltOriginalSize: parsed.rebuiltOriginalSize }),
1248
+ overallDifferencePct: parsed.overallDifferencePct,
1249
+ grid,
1250
+ worstRegions: parsed.worstRegions,
1251
+ }
1252
+ await writeFile(stagedReport, `${JSON.stringify(reportPayload, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' })
1253
+ await commitStagedDirectory(stagedDirectory, finalDirectory, policy)
1254
+ const heatmapPath = join(finalDirectory, 'heatmap.png')
1255
+ const reportPath = join(finalDirectory, 'report.json')
1256
+ const [heatmap, report] = await Promise.all([
1257
+ describeArtifact(heatmapPath, policy, {
1258
+ mimeType: 'image/png',
1259
+ kind: 'image',
1260
+ description: 'Pixel-difference heatmap',
1261
+ sourceTool: 'vision_pixel_diff',
1262
+ previewIntent: 'image',
1263
+ }),
1264
+ describeArtifact(reportPath, policy, {
1265
+ mimeType: 'application/json',
1266
+ kind: 'json',
1267
+ description: 'Structured pixel-difference report',
1268
+ sourceTool: 'vision_pixel_diff',
1269
+ previewIntent: 'text',
1270
+ }),
1271
+ ])
1272
+ return {
1273
+ original,
1274
+ rebuilt,
1275
+ scaled: parsed.scaled,
1276
+ ...(parsed.rebuiltOriginalSize === undefined ? {} : { rebuiltOriginalSize: parsed.rebuiltOriginalSize }),
1277
+ overallDifferencePct: parsed.overallDifferencePct,
1278
+ worstRegions: parsed.worstRegions,
1279
+ heatmap,
1280
+ report,
1281
+ }
1282
+ } finally {
1283
+ await rm(stagedDirectory, { recursive: true, force: true }).catch(() => {})
1284
+ }
1285
+ })
1286
+ }
1287
+
1288
+ /** long_screenshot_ocr: split safely, optionally OCR, and atomically deliver the complete audit run. */
1289
+ async longScreenshotOcr(request: LongScreenshotOcrRequest, options: ToolCallOptions): Promise<LongScreenshotOcrResult> {
1290
+ const jobs = integerInRange(request.jobs, Math.min(2, this.config.concurrency), 1, this.config.concurrency, 'long_screenshot_ocr.jobs')
1291
+ const splitOnly = request.splitOnly === true
1292
+ const permits = splitOnly ? 1 : jobs
1293
+ return this.runOperation('vision_long_screenshot_ocr', options, async (operation) => {
1294
+ const mode = request.mode ?? 'general'
1295
+ if (mode !== 'general' && mode !== 'chat') throw new VisionToolkitError('input', 'long_screenshot_ocr.mode must be general or chat')
1296
+ const targetHeight = request.targetHeight === undefined
1297
+ ? undefined
1298
+ : integerInRange(request.targetHeight, request.targetHeight, 64, 100000, 'long_screenshot_ocr.targetHeight')
1299
+ const minHeight = request.minHeight === undefined
1300
+ ? undefined
1301
+ : integerInRange(request.minHeight, request.minHeight, 64, 100000, 'long_screenshot_ocr.minHeight')
1302
+ const maxHeight = request.maxHeight === undefined
1303
+ ? undefined
1304
+ : integerInRange(request.maxHeight, request.maxHeight, 64, 100000, 'long_screenshot_ocr.maxHeight')
1305
+ if (minHeight !== undefined && maxHeight !== undefined && minHeight > maxHeight) {
1306
+ throw new VisionToolkitError('input', 'long_screenshot_ocr.minHeight must not exceed maxHeight')
1307
+ }
1308
+ if (targetHeight !== undefined && minHeight !== undefined && targetHeight < minHeight) {
1309
+ throw new VisionToolkitError('input', 'long_screenshot_ocr.targetHeight must not be below minHeight')
1310
+ }
1311
+ if (targetHeight !== undefined && maxHeight !== undefined && targetHeight > maxHeight) {
1312
+ throw new VisionToolkitError('input', 'long_screenshot_ocr.targetHeight must not exceed maxHeight')
1313
+ }
1314
+ const overlap = request.overlap === undefined
1315
+ ? undefined
1316
+ : integerInRange(request.overlap, request.overlap, 0, 10000, 'long_screenshot_ocr.overlap')
1317
+ const chunkTimeoutSeconds = finiteInRange(
1318
+ request.chunkTimeoutSeconds ?? Math.min(180, Math.max(1, Math.ceil(this.timeout(options) / 1000))),
1319
+ 1,
1320
+ 600,
1321
+ 'long_screenshot_ocr.chunkTimeoutSeconds',
1322
+ )
1323
+ if (chunkTimeoutSeconds === undefined) throw new VisionToolkitError('input', 'long_screenshot_ocr chunk timeout is required')
1324
+ if (request.prompt !== undefined && request.prompt.trim().length === 0) {
1325
+ throw new VisionToolkitError('input', 'long_screenshot_ocr.prompt must not be empty when provided')
1326
+ }
1327
+ const policy = await this.pathPolicy(options.workspace)
1328
+ const image = await this.validateImage(request.image, policy, operation)
1329
+ this.accountImage(image, operation)
1330
+ const stem = basename(image.path, extname(image.path))
1331
+ const finalDirectory = resolveOutputDirectory(request.runName, policy, `${stem}.long-ocr`)
1332
+ if (isWithin(finalDirectory, image.path)) {
1333
+ throw new VisionToolkitError('input', 'long_screenshot_ocr artifact directory would replace the input image')
1334
+ }
1335
+ const stagedDirectory = await createStagedDirectory(policy)
1336
+ try {
1337
+ if (request.resume === true) await seedStagedDirectory(finalDirectory, stagedDirectory, policy)
1338
+ const stagedPolicy = { ...policy, outputDir: stagedDirectory }
1339
+ const stagedOutput = resolveOutputFile(request.output, stagedPolicy, `${stem}.ocr.md`, ['.md', '.markdown'])
1340
+ const finalOutput = join(finalDirectory, basename(stagedOutput))
1341
+ const stagedChunks = join(stagedDirectory, 'chunks')
1342
+ const stagedManifest = join(stagedChunks, 'manifest.json')
1343
+ const result = await this.runUpstream('long_screenshot_ocr', [
1344
+ image.path,
1345
+ '--mode',
1346
+ mode,
1347
+ '-o',
1348
+ stagedOutput,
1349
+ '--chunks-dir',
1350
+ stagedChunks,
1351
+ ...(targetHeight === undefined ? [] : ['--target-height', String(targetHeight)]),
1352
+ ...(minHeight === undefined ? [] : ['--min-height', String(minHeight)]),
1353
+ ...(maxHeight === undefined ? [] : ['--max-height', String(maxHeight)]),
1354
+ ...(overlap === undefined ? [] : ['--overlap', String(overlap)]),
1355
+ ...(request.prompt === undefined ? [] : ['--prompt', request.prompt]),
1356
+ '--jobs',
1357
+ String(jobs),
1358
+ '--timeout',
1359
+ String(chunkTimeoutSeconds),
1360
+ ...(splitOnly ? ['--split-only'] : []),
1361
+ ...(request.resume === true ? ['--resume'] : []),
1362
+ ], operation, splitOnly ? undefined : await this.resolveVisionEnv())
1363
+ const reported = result.stdout.trim()
1364
+ const expectedReported = splitOnly ? stagedManifest : stagedOutput
1365
+ if (reported !== expectedReported) {
1366
+ throw new VisionToolkitError('output', 'long_screenshot_ocr: upstream reported an unexpected output path')
1367
+ }
1368
+ const parsedManifest = parseLongOcrManifest(await readFile(stagedManifest, 'utf8'), {
1369
+ source: image.path,
1370
+ output: stagedOutput,
1371
+ width: image.width,
1372
+ height: image.height,
1373
+ mode,
1374
+ splitOnly,
1375
+ })
1376
+ for (const chunk of parsedManifest.chunks) {
1377
+ const chunkBytes = await readFile(join(stagedChunks, chunk.image))
1378
+ if (createHash('sha256').update(chunkBytes).digest('hex') !== chunk.imageSha256) {
1379
+ throw new VisionToolkitError('output', `long_screenshot_ocr: chunk ${chunk.index} hash does not match the manifest`)
1380
+ }
1381
+ }
1382
+ parsedManifest.raw.output = splitOnly ? null : finalOutput
1383
+ await writeFile(stagedManifest, `${JSON.stringify(parsedManifest.raw, null, 2)}\n`, 'utf8')
1384
+ await commitStagedDirectory(stagedDirectory, finalDirectory, policy)
1385
+ const finalChunks = join(finalDirectory, 'chunks')
1386
+ const manifest = await describeArtifact(join(finalChunks, 'manifest.json'), policy, {
1387
+ mimeType: 'application/json',
1388
+ kind: 'json',
1389
+ description: 'Long-screenshot split and merge manifest',
1390
+ sourceTool: 'vision_long_screenshot_ocr',
1391
+ previewIntent: 'text',
1392
+ })
1393
+ const output = splitOnly
1394
+ ? undefined
1395
+ : await describeArtifact(finalOutput, policy, {
1396
+ mimeType: 'text/markdown',
1397
+ kind: 'markdown',
1398
+ description: 'Merged long-screenshot OCR transcript',
1399
+ sourceTool: 'vision_long_screenshot_ocr',
1400
+ previewIntent: 'text',
1401
+ })
1402
+ const audit = splitOnly
1403
+ ? undefined
1404
+ : await describeArtifact(join(finalChunks, 'ocr_audit.md'), policy, {
1405
+ mimeType: 'text/markdown',
1406
+ kind: 'markdown',
1407
+ description: 'Long-screenshot OCR boundary audit',
1408
+ sourceTool: 'vision_long_screenshot_ocr',
1409
+ previewIntent: 'text',
1410
+ })
1411
+ const chunks: LongScreenshotChunk[] = []
1412
+ for (const chunk of parsedManifest.chunks) {
1413
+ const chunkImage = await describeArtifact(join(finalChunks, chunk.image), policy, {
1414
+ mimeType: 'image/png',
1415
+ kind: 'image',
1416
+ description: `Long-screenshot OCR chunk ${chunk.index}`,
1417
+ sourceTool: 'vision_long_screenshot_ocr',
1418
+ previewIntent: 'image',
1419
+ })
1420
+ const ocr = chunk.ocr === undefined
1421
+ ? undefined
1422
+ : await describeArtifact(join(finalChunks, chunk.ocr), policy, {
1423
+ mimeType: mode === 'chat' ? 'application/json' : 'text/markdown',
1424
+ kind: mode === 'chat' ? 'json' : 'markdown',
1425
+ description: `OCR sidecar for chunk ${chunk.index}`,
1426
+ sourceTool: 'vision_long_screenshot_ocr',
1427
+ previewIntent: 'text',
1428
+ })
1429
+ chunks.push({
1430
+ index: chunk.index,
1431
+ coreTop: chunk.coreTop,
1432
+ coreBottom: chunk.coreBottom,
1433
+ cropTop: chunk.cropTop,
1434
+ cropBottom: chunk.cropBottom,
1435
+ image: chunkImage,
1436
+ ...(ocr === undefined ? {} : { ocr }),
1437
+ ...(chunk.ocrReused === undefined ? {} : { reused: chunk.ocrReused }),
1438
+ })
1439
+ }
1440
+ return {
1441
+ source: image,
1442
+ mode,
1443
+ splitOnly,
1444
+ complete: parsedManifest.complete,
1445
+ chunkCount: chunks.length,
1446
+ runDirectory: finalDirectory,
1447
+ ...(output === undefined ? {} : { output }),
1448
+ manifest,
1449
+ ...(audit === undefined ? {} : { audit }),
1450
+ chunks,
1451
+ }
1452
+ } finally {
1453
+ await rm(stagedDirectory, { recursive: true, force: true }).catch(() => {})
1454
+ }
1455
+ }, permits)
1456
+ }
1457
+
1458
+ /** extract_foreground: preserve the pinned component selection and deliver an RGBA PNG. */
1459
+ async extractForeground(request: ExtractForegroundRequest, options: ToolCallOptions): Promise<ExtractForegroundResult> {
1460
+ return this.runOperation('vision_extract_foreground', options, async (operation) => {
1461
+ const policy = await this.pathPolicy(options.workspace)
1462
+ const image = await this.validateImage(request.image, policy, operation)
1463
+ this.accountImage(image, operation)
1464
+ const region = request.region === undefined ? undefined : parseRegion(request.region)
1465
+ if (region !== undefined) assertBoxWithin(region, image.width, image.height, 'extract_foreground.region')
1466
+ const boxes = request.boxes === undefined ? undefined : parseRegion(request.boxes)
1467
+ if (boxes !== undefined) assertBoxWithin(boxes, image.width, image.height, 'extract_foreground.boxes')
1468
+ const discRadius = finiteInRange(request.discRadius, 1, Math.max(image.width, image.height) * 4, 'extract_foreground.discRadius')
1469
+ const saturation = integerInRange(request.saturation, 12, 0, 255, 'extract_foreground.saturation')
1470
+ const darkThreshold = integerInRange(request.darkThreshold, 215, 0, 255, 'extract_foreground.darkThreshold')
1471
+ const excludeTolerance = finiteInRange(request.excludeTolerance ?? 24, 0, 442, 'extract_foreground.excludeTolerance')
1472
+ const padding = integerInRange(request.padding, 3, 0, 4096, 'extract_foreground.padding')
1473
+ const excludeColor = request.excludeColor === undefined
1474
+ ? undefined
1475
+ : `#${request.excludeColor.trim().replace(/^#/, '').toUpperCase()}`
1476
+ if (excludeColor !== undefined && !HEX_COLOR_PATTERN.test(excludeColor)) {
1477
+ throw new VisionToolkitError('input', 'extract_foreground.excludeColor must be #RRGGBB')
1478
+ }
1479
+ const extension = extname(image.path).toLowerCase()
1480
+ const stem = basename(image.path, extension)
1481
+ const finalPath = resolveOutputFile(request.output, policy, `${stem}.foreground.png`, ['.png'])
1482
+ assertDistinctOutput(image.path, finalPath)
1483
+ const staged = createStagedOutput(policy, '.png')
1484
+ try {
1485
+ const result = await this.runUpstream('extract_foreground', [
1486
+ image.path,
1487
+ ...(region === undefined ? [] : ['--region', `${region.x1},${region.y1},${region.x2},${region.y2}`]),
1488
+ ...(boxes === undefined ? [] : ['--boxes', `${boxes.x1},${boxes.y1},${boxes.x2},${boxes.y2}`]),
1489
+ '--mode',
1490
+ request.mode ?? 'color',
1491
+ '--sat',
1492
+ String(saturation),
1493
+ '--dark',
1494
+ String(darkThreshold),
1495
+ '--exclude-tol',
1496
+ String(excludeTolerance),
1497
+ '--pad',
1498
+ String(padding),
1499
+ ...(discRadius === undefined ? [] : ['--disc-radius', String(discRadius)]),
1500
+ ...(excludeColor === undefined ? [] : ['--exclude-color', excludeColor]),
1501
+ ...(request.keepWhites === false ? ['--no-keep-whites'] : []),
1502
+ '-o',
1503
+ staged,
1504
+ ], operation)
1505
+ const parsed = parseExtractForegroundOutput(result.stdout)
1506
+ if (parsed.outputPath !== staged) throw new VisionToolkitError('output', 'extract_foreground: upstream reported an unexpected output path')
1507
+ assertBoxWithin(parsed.box, image.width, image.height, 'extract_foreground')
1508
+ if (
1509
+ parsed.foregroundPixels <= 0
1510
+ || parsed.keptComponents <= 0
1511
+ || parsed.totalComponents < parsed.keptComponents
1512
+ || parsed.largestComponentPct < 0
1513
+ || parsed.largestComponentPct > 100
1514
+ ) {
1515
+ throw new VisionToolkitError('output', 'extract_foreground: component metrics are invalid')
1516
+ }
1517
+ const generated = await this.probeGeneratedImage(staged, operation, 'extract_foreground')
1518
+ if (
1519
+ generated.format !== 'png'
1520
+ || (generated.mode !== 'RGBA' && generated.mode !== 'LA')
1521
+ || generated.width !== parsed.width
1522
+ || generated.height !== parsed.height
1523
+ ) {
1524
+ throw new VisionToolkitError('output', 'extract_foreground: output is not the reported transparent PNG')
1525
+ }
1526
+ await commitStagedOutput(staged, finalPath, policy)
1527
+ const artifact = await describeArtifact(finalPath, policy, {
1528
+ mimeType: 'image/png',
1529
+ kind: 'image',
1530
+ description: 'Extracted transparent foreground',
1531
+ sourceTool: 'vision_extract_foreground',
1532
+ previewIntent: 'image',
1533
+ })
1534
+ return {
1535
+ source: image,
1536
+ box: parsed.box,
1537
+ foregroundPixels: parsed.foregroundPixels,
1538
+ keptComponents: parsed.keptComponents,
1539
+ totalComponents: parsed.totalComponents,
1540
+ largestComponentPct: parsed.largestComponentPct,
1541
+ width: parsed.width,
1542
+ height: parsed.height,
1543
+ artifact,
1544
+ ...(parsed.autoSummary === undefined ? {} : { autoSummary: parsed.autoSummary }),
1545
+ }
1546
+ } finally {
1547
+ await rm(staged, { force: true }).catch(() => {})
1548
+ }
1549
+ })
1550
+ }
1551
+
1552
+ /** dominant_colors: expose palette clusters or candidate scores as structure, never stdout prose. */
1553
+ async dominantColors(request: DominantColorsRequest, options: ToolCallOptions): Promise<DominantColorsResult> {
1554
+ return this.runOperation('vision_dominant_colors', options, async (operation) => {
1555
+ const top = integerInRange(request.top, 5, 1, 64, 'dominant_colors.top')
1556
+ const quantize = integerInRange(request.quantize, 16, 2, 256, 'dominant_colors.quantize')
1557
+ const maxPixels = integerInRange(request.maxPixels, 96, 8, 4096, 'dominant_colors.maxPixels')
1558
+ const mergeTolerance = integerInRange(request.mergeTolerance, 8, 0, 255, 'dominant_colors.mergeTolerance')
1559
+ const candidateTolerance = integerInRange(request.candidateTolerance, 16, 0, 255, 'dominant_colors.candidateTolerance')
1560
+ const policy = await this.pathPolicy(options.workspace)
1561
+ const image = await this.validateImage(request.image, policy, operation)
1562
+ this.accountImage(image, operation)
1563
+ const region = request.region === undefined ? undefined : parseRegion(request.region)
1564
+ if (region !== undefined) assertBoxWithin(region, image.width, image.height, 'dominant_colors.region')
1565
+ const candidates = request.candidates?.map(value => `#${value.trim().replace(/^#/, '').toUpperCase()}`)
1566
+ if (candidates !== undefined) {
1567
+ if (candidates.length === 0 || candidates.length > 32) {
1568
+ throw new VisionToolkitError('input', 'dominant_colors.candidates must contain between 1 and 32 colors')
1569
+ }
1570
+ if (candidates.some(candidate => !HEX_COLOR_PATTERN.test(candidate))) {
1571
+ throw new VisionToolkitError('input', 'dominant_colors.candidates must contain only #RRGGBB colors')
1572
+ }
1573
+ if (new Set(candidates).size !== candidates.length) {
1574
+ throw new VisionToolkitError('input', 'dominant_colors.candidates must not contain duplicates')
1575
+ }
1576
+ }
1577
+ const result = await this.runUpstream('dominant_colors', [
1578
+ image.path,
1579
+ ...(region === undefined ? [] : ['--region', `${region.x1},${region.y1},${region.x2},${region.y2}`]),
1580
+ ...(candidates === undefined ? [] : ['--candidates', candidates.join(',')]),
1581
+ '--top',
1582
+ String(top),
1583
+ '--quantize',
1584
+ String(quantize),
1585
+ '--max-pixels',
1586
+ String(maxPixels),
1587
+ '--merge-tol',
1588
+ String(mergeTolerance),
1589
+ '--tol',
1590
+ String(candidateTolerance),
1591
+ ], operation)
1592
+ const analysis = parseDominantColorsOutput(result.stdout)
1593
+ assertBoxWithin(analysis.region, image.width, image.height, 'dominant_colors')
1594
+ if (analysis.width !== analysis.region.x2 - analysis.region.x1 || analysis.height !== analysis.region.y2 - analysis.region.y1) {
1595
+ throw new VisionToolkitError('output', 'dominant_colors: reported region dimensions are inconsistent')
1596
+ }
1597
+ if (candidates !== undefined) {
1598
+ if (analysis.mode !== 'candidates') throw new VisionToolkitError('output', 'dominant_colors: expected candidate mode output')
1599
+ if (analysis.candidates.map(candidate => candidate.color).join(',') !== candidates.join(',')) {
1600
+ throw new VisionToolkitError('output', 'dominant_colors: candidate rows do not match the request')
1601
+ }
1602
+ } else if (analysis.mode !== 'palette') {
1603
+ throw new VisionToolkitError('output', 'dominant_colors: expected palette mode output')
1604
+ }
1605
+ return { image, analysis }
1606
+ })
1607
+ }
1608
+
1609
+ /** html_screenshot: render only a path-fenced local HTML file in the pinned Chrome adapter. */
1610
+ async htmlScreenshot(request: HtmlScreenshotRequest, options: ToolCallOptions): Promise<HtmlScreenshotResult> {
1611
+ return this.runOperation('vision_html_screenshot', options, async (operation) => {
1612
+ const width = integerInRange(request.width, 1280, 1, 8192, 'html_screenshot.width')
1613
+ const height = integerInRange(request.height, 800, 1, 8192, 'html_screenshot.height')
1614
+ const scale = integerInRange(request.scale, 1, 1, 4, 'html_screenshot.scale')
1615
+ const waitMs = integerInRange(request.waitMs, 0, 0, 120000, 'html_screenshot.waitMs')
1616
+ const outputPixels = width * height * scale * scale
1617
+ if (!Number.isSafeInteger(outputPixels) || outputPixels > this.config.maxImagePixels) {
1618
+ throw new VisionToolkitError('capacity', `HTML screenshot would create ${outputPixels} pixels, exceeding maxImagePixels ${this.config.maxImagePixels}`)
1619
+ }
1620
+ const policy = await this.pathPolicy(options.workspace)
1621
+ const source = await resolveHtmlFile(request.source, policy)
1622
+ if (source.bytes > this.config.maxImageBytes) {
1623
+ throw new VisionToolkitError('capacity', `HTML source is ${source.bytes} bytes, exceeding maxImageBytes ${this.config.maxImageBytes}`)
1624
+ }
1625
+ const stem = basename(source.path, extname(source.path))
1626
+ const finalPath = resolveOutputFile(request.output, policy, `${stem}.screenshot.png`, ['.png'])
1627
+ assertDistinctOutput(source.path, finalPath)
1628
+ const staged = createStagedOutput(policy, '.png')
1629
+ try {
1630
+ const result = await this.runUpstream('html_screenshot', [
1631
+ source.path,
1632
+ '-o',
1633
+ staged,
1634
+ '--width',
1635
+ String(width),
1636
+ '--height',
1637
+ String(height),
1638
+ '--scale',
1639
+ String(scale),
1640
+ '--wait-ms',
1641
+ String(waitMs),
1642
+ ], operation)
1643
+ const parsed = parseHtmlScreenshotOutput(result.stdout)
1644
+ const expectedWidth = width * scale
1645
+ const expectedHeight = height * scale
1646
+ if (parsed.outputPath !== staged || parsed.width !== expectedWidth || parsed.height !== expectedHeight) {
1647
+ throw new VisionToolkitError('output', 'html_screenshot: upstream summary does not match the requested output')
1648
+ }
1649
+ const generated = await this.probeGeneratedImage(staged, operation, 'html_screenshot')
1650
+ if (generated.format !== 'png' || generated.width !== expectedWidth || generated.height !== expectedHeight) {
1651
+ throw new VisionToolkitError('output', 'html_screenshot: generated PNG dimensions do not match the viewport')
1652
+ }
1653
+ await commitStagedOutput(staged, finalPath, policy)
1654
+ const artifact = await describeArtifact(finalPath, policy, {
1655
+ mimeType: 'image/png',
1656
+ kind: 'image',
1657
+ description: 'Headless browser screenshot of local HTML',
1658
+ sourceTool: 'vision_html_screenshot',
1659
+ previewIntent: 'image',
1660
+ })
1661
+ return {
1662
+ sourcePath: source.path,
1663
+ sourceBytes: source.bytes,
1664
+ viewport: { width, height, scale },
1665
+ width: expectedWidth,
1666
+ height: expectedHeight,
1667
+ artifact,
1668
+ }
1669
+ } finally {
1670
+ await rm(staged, { force: true }).catch(() => {})
1671
+ }
1672
+ })
1673
+ }
1674
+
1675
+ private async writableDirectoryCheck(path: string, label: string): Promise<HealthCheck> {
1676
+ const probe = join(path, `.vision-toolkit-health-${randomUUID()}`)
1677
+ try {
1678
+ await writeFile(probe, 'ok\n', { encoding: 'utf8', flag: 'wx' })
1679
+ await rm(probe, { force: true })
1680
+ return { status: 'ok', detail: `${label} is writable: ${path}` }
1681
+ } catch {
1682
+ await rm(probe, { force: true }).catch(() => {})
1683
+ return { status: 'error', detail: `${label} is not writable: ${path}` }
1684
+ }
1685
+ }
1686
+
1687
+ /** health: inspect local readiness and optionally probe the configured `/models` endpoint. */
1688
+ async health(testConnection: boolean, options: ToolCallOptions): Promise<VisionToolkitHealthResult> {
1689
+ return this.runOperation('vision_toolkit_health', options, async (operation) => {
1690
+ const info = this.upstreamVersion
1691
+ const python: HealthCheck = { status: 'ok', detail: `${info.pythonVersion} via ${info.python}` }
1692
+ const dependencies: HealthCheck = {
1693
+ status: 'ok',
1694
+ detail: Object.entries(info.dependencies).map(([name, version]) => `${name}=${version}`).join(', '),
1695
+ }
1696
+ let chrome: HealthCheck
1697
+ try {
1698
+ const started = Date.now()
1699
+ const chromePath = await this.adapter.findChrome({ signal: operation.signal })
1700
+ operation.metrics.upstreamMs += Date.now() - started
1701
+ chrome = chromePath === undefined
1702
+ ? { status: 'warning', detail: 'Chrome/Chromium/Edge was not found; vision_html_screenshot is unavailable' }
1703
+ : { status: 'ok', detail: chromePath }
1704
+ } catch {
1705
+ if (operation.signal.aborted) throw new VisionToolkitError('cancelled', 'vision_toolkit_health: cancelled')
1706
+ chrome = { status: 'error', detail: 'Chrome availability probe failed' }
1707
+ }
1708
+ let resolvedCredential: ResolvedCredential | undefined
1709
+ let credential: HealthCheck
1710
+ try {
1711
+ resolvedCredential = await this.ctx.credentials.resolve(this.config.provider.credential)
1712
+ credential = resolvedCredential === undefined
1713
+ ? { status: 'error', detail: `credential ${this.config.provider.credential} is not configured` }
1714
+ : { status: 'ok', detail: `credential ${this.config.provider.credential} is resolvable` }
1715
+ } catch {
1716
+ credential = { status: 'error', detail: `credential ${this.config.provider.credential} could not be resolved` }
1717
+ }
1718
+ let artifactDirectory: HealthCheck
1719
+ try {
1720
+ const policy = await this.pathPolicy(options.workspace)
1721
+ artifactDirectory = await this.writableDirectoryCheck(policy.outputDir, 'Artifact directory')
1722
+ } catch {
1723
+ artifactDirectory = { status: 'error', detail: 'Artifact directory could not be prepared' }
1724
+ }
1725
+ const tempDirectory = await this.writableDirectoryCheck(info.runtimeHome, 'Runtime temp directory')
1726
+ let service: HealthCheck = {
1727
+ status: 'not_tested',
1728
+ detail: 'Connection was not tested; pass testConnection=true to query the configured /models endpoint',
1729
+ }
1730
+ if (testConnection) {
1731
+ if (resolvedCredential === undefined) {
1732
+ service = { status: 'error', detail: 'Connection test skipped because the configured credential is unavailable' }
1733
+ } else {
1734
+ operation.metrics.usedVisionService = true
1735
+ const endpoint = `${this.config.provider.baseUrl}/models`
1736
+ try {
1737
+ const started = Date.now()
1738
+ const response = await fetch(endpoint, {
1739
+ method: 'GET',
1740
+ headers: { Authorization: `Bearer ${resolvedCredential.value}`, Accept: 'application/json' },
1741
+ signal: operation.signal,
1742
+ })
1743
+ operation.metrics.upstreamMs += Date.now() - started
1744
+ await response.body?.cancel().catch(() => {})
1745
+ if (response.ok) {
1746
+ service = { status: 'ok', detail: `Service responded at ${endpoint} (HTTP ${response.status})` }
1747
+ } else if (response.status === 401 || response.status === 403) {
1748
+ service = { status: 'error', detail: `Service rejected the configured credential (HTTP ${response.status})` }
1749
+ } else if (response.status === 404 || response.status === 405) {
1750
+ service = { status: 'warning', detail: `Service is reachable but does not expose GET /models (HTTP ${response.status})` }
1751
+ } else if (response.status === 429) {
1752
+ service = { status: 'warning', detail: 'Service is reachable but rate-limited the connection test (HTTP 429)' }
1753
+ } else {
1754
+ service = { status: 'error', detail: `Service connection test failed with HTTP ${response.status}` }
1755
+ }
1756
+ } catch {
1757
+ if (operation.signal.aborted) throw new VisionToolkitError('cancelled', 'vision_toolkit_health: connection test cancelled')
1758
+ service = { status: 'error', detail: `Service could not be reached at ${endpoint}` }
1759
+ }
1760
+ }
1761
+ }
1762
+ const checks = { python, dependencies, chrome, credential, artifactDirectory, tempDirectory, service }
1763
+ const healthy = Object.values(checks).every(check => check.status !== 'error')
1764
+ return {
1765
+ pluginVersion: PLUGIN_VERSION,
1766
+ upstream: info,
1767
+ checks,
1768
+ healthy,
1769
+ connectionTested: testConnection,
1770
+ }
1771
+ })
1772
+ }
1773
+
1774
+ /** Report the packaged upstream snapshot version. */
1775
+ checkoutVersion(): Promise<string> {
1776
+ return this.adapter.readCheckoutVersion()
1777
+ }
1778
+
1779
+ /** Prepared Python command. */
1780
+ python(): string {
1781
+ return this.adapter.versionInfo.python
1782
+ }
1783
+ }