@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.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +383 -0
- package/README.zh.md +383 -0
- package/assets/dsh-conversation-artifact.png +0 -0
- package/assets/dsh-conversation-image-qa-top.png +0 -0
- package/assets/dsh-conversation-image-qa.png +0 -0
- package/assets/dsh-conversation-pixel-diff.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging.png +0 -0
- package/assets/dsh-conversation-tool-call.png +0 -0
- package/assets/dsh-conversation-vision-trace.png +0 -0
- package/assets/hero.png +0 -0
- package/assets/social-preview.png +0 -0
- package/assets/upstream/README.md +16 -0
- package/assets/upstream/image-qa.webp +0 -0
- package/assets/upstream/infographic-reference.webp +0 -0
- package/assets/upstream/infographic-result.webp +0 -0
- package/assets/upstream/screenshot-debugging.webp +0 -0
- package/assets/upstream/ui-result.webp +0 -0
- package/assets/upstream/ui-sketch.webp +0 -0
- package/assets/vision-settings.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/assets/vision-settings.png +0 -0
- package/docs/requirements-traceability/README.i18n.yaml +6 -0
- package/docs/requirements-traceability/README.md +75 -0
- package/docs/requirements-traceability/README.zh.md +75 -0
- package/examples/ui-restoration/README.i18n.yaml +6 -0
- package/examples/ui-restoration/README.md +70 -0
- package/examples/ui-restoration/README.zh.md +70 -0
- package/examples/ui-restoration/assets/final-heatmap.png +0 -0
- package/examples/ui-restoration/assets/final-report.json +83 -0
- package/examples/ui-restoration/assets/implementation.png +0 -0
- package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
- package/examples/ui-restoration/assets/initial-report.json +83 -0
- package/examples/ui-restoration/assets/initial.png +0 -0
- package/examples/ui-restoration/assets/metrics.json +12 -0
- package/examples/ui-restoration/assets/reference.png +0 -0
- package/examples/ui-restoration/implementation.html +94 -0
- package/examples/ui-restoration/initial.html +57 -0
- package/lib/artifact-access.js +369 -0
- package/lib/artifact-access.js.map +1 -0
- package/lib/artifacts.js +56 -0
- package/lib/artifacts.js.map +1 -0
- package/lib/client.js +952 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +117 -0
- package/lib/config.js.map +1 -0
- package/lib/errors.js +56 -0
- package/lib/errors.js.map +1 -0
- package/lib/exposure.js +213 -0
- package/lib/exposure.js.map +1 -0
- package/lib/index.js +97 -0
- package/lib/index.js.map +1 -0
- package/lib/paste-images.js +199 -0
- package/lib/paste-images.js.map +1 -0
- package/lib/paths.js +325 -0
- package/lib/paths.js.map +1 -0
- package/lib/runtime-install.js +601 -0
- package/lib/runtime-install.js.map +1 -0
- package/lib/runtime-manager.js +126 -0
- package/lib/runtime-manager.js.map +1 -0
- package/lib/runtime.js +1344 -0
- package/lib/runtime.js.map +1 -0
- package/lib/skill.js +139 -0
- package/lib/skill.js.map +1 -0
- package/lib/tools.js +528 -0
- package/lib/tools.js.map +1 -0
- package/lib/types/artifact-access.d.ts +61 -0
- package/lib/types/artifact-access.d.ts.map +1 -0
- package/lib/types/artifacts.d.ts +42 -0
- package/lib/types/artifacts.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +179 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/paste-images.d.ts +57 -0
- package/lib/types/client/paste-images.d.ts.map +1 -0
- package/lib/types/config.d.ts +73 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/errors.d.ts +35 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/exposure.d.ts +40 -0
- package/lib/types/exposure.d.ts.map +1 -0
- package/lib/types/index.d.ts +18 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/paste-images.d.ts +21 -0
- package/lib/types/paste-images.d.ts.map +1 -0
- package/lib/types/paths.d.ts +107 -0
- package/lib/types/paths.d.ts.map +1 -0
- package/lib/types/runtime-install.d.ts +49 -0
- package/lib/types/runtime-install.d.ts.map +1 -0
- package/lib/types/runtime-manager.d.ts +60 -0
- package/lib/types/runtime-manager.d.ts.map +1 -0
- package/lib/types/runtime.d.ts +389 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/skill.d.ts +15 -0
- package/lib/types/skill.d.ts.map +1 -0
- package/lib/types/tools.d.ts +22 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/upstream.d.ts +207 -0
- package/lib/types/upstream.d.ts.map +1 -0
- package/lib/types/version.d.ts +15 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/web-request.d.ts +4 -0
- package/lib/types/web-request.d.ts.map +1 -0
- package/lib/types/web.d.ts +74 -0
- package/lib/types/web.d.ts.map +1 -0
- package/lib/upstream.js +675 -0
- package/lib/upstream.js.map +1 -0
- package/lib/version.js +18 -0
- package/lib/version.js.map +1 -0
- package/lib/web-request.js +20 -0
- package/lib/web-request.js.map +1 -0
- package/lib/web.js +244 -0
- package/lib/web.js.map +1 -0
- package/package.json +139 -0
- package/runtime/requirements.lock +3 -0
- package/src/artifact-access.ts +386 -0
- package/src/artifacts.ts +85 -0
- package/src/client/index.tsx +866 -0
- package/src/client/paste-images.tsx +426 -0
- package/src/config.ts +177 -0
- package/src/errors.ts +62 -0
- package/src/exposure.ts +227 -0
- package/src/index.ts +122 -0
- package/src/paste-images.ts +234 -0
- package/src/paths.ts +348 -0
- package/src/runtime-install.ts +723 -0
- package/src/runtime-manager.ts +166 -0
- package/src/runtime.ts +1783 -0
- package/src/skill.ts +143 -0
- package/src/tools.ts +668 -0
- package/src/upstream.ts +861 -0
- package/src/version.ts +37 -0
- package/src/web-request.ts +17 -0
- package/src/web.ts +329 -0
- package/vendor/agent-vision-toolkit/CHANGELOG.md +16 -0
- package/vendor/agent-vision-toolkit/LICENSE +21 -0
- package/vendor/agent-vision-toolkit/README.md +399 -0
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +89 -0
- package/vendor/agent-vision-toolkit/bin/crop +90 -0
- package/vendor/agent-vision-toolkit/bin/detect +13 -0
- package/vendor/agent-vision-toolkit/bin/glance +93 -0
- package/vendor/agent-vision-toolkit/bin/ground +13 -0
- package/vendor/agent-vision-toolkit/bin/trace +129 -0
- package/vendor/agent-vision-toolkit/detect.py +56 -0
- package/vendor/agent-vision-toolkit/ground.py +216 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +108 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
- 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
|
+
}
|