@anionex/dsh-vision-toolkit 0.1.28 → 0.1.29

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.
@@ -8,6 +8,8 @@
8
8
 
9
9
  import { createHash, randomUUID } from 'node:crypto'
10
10
  import {
11
+ access,
12
+ chmod,
11
13
  lstat,
12
14
  mkdir,
13
15
  mkdtemp,
@@ -20,10 +22,16 @@ import {
20
22
  utimes,
21
23
  writeFile,
22
24
  } from 'node:fs/promises'
25
+ import { createWriteStream } from 'node:fs'
23
26
  import { homedir } from 'node:os'
24
27
  import { dirname, join, resolve } from 'node:path'
28
+ import { Transform } from 'node:stream'
29
+ import { pipeline } from 'node:stream/promises'
25
30
  import { fileURLToPath } from 'node:url'
26
31
  import type { Context } from '@deepseek-ai/cordis'
32
+ import { x as extractTar } from 'tar'
33
+ import { EnvHttpProxyAgent } from 'undici'
34
+ import { request as undiciRequest } from 'undici'
27
35
  import type { ResolvedVisionToolkitConfig } from './config.ts'
28
36
  import { VisionToolkitError } from './errors.ts'
29
37
  import { UPSTREAM_COMMIT, UPSTREAM_REPOSITORY, UPSTREAM_VERSION } from './version.ts'
@@ -252,10 +260,317 @@ async function pythonMetadata(
252
260
  }
253
261
  }
254
262
 
255
- async function resolveBootstrapPython(
263
+ interface PythonBootstrapArtifact {
264
+ url: string
265
+ sha256: string
266
+ size: number
267
+ }
268
+
269
+ interface PythonBootstrapManifest {
270
+ schemaVersion: 1
271
+ pythonVersion: string
272
+ buildTag: string
273
+ artifacts: Record<string, PythonBootstrapArtifact>
274
+ }
275
+
276
+ const PYTHON_BOOTSTRAP_MANIFEST_PATH = join(PACKAGE_ROOT, 'assets', 'python-bootstrap.json')
277
+ const PYTHON_DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000
278
+ const PYTHON_DOWNLOAD_ATTEMPTS = 3
279
+
280
+ async function readPythonBootstrapManifest(): Promise<PythonBootstrapManifest> {
281
+ let parsed: unknown
282
+ try {
283
+ parsed = JSON.parse(await readFile(PYTHON_BOOTSTRAP_MANIFEST_PATH, 'utf8'))
284
+ } catch (error) {
285
+ throw new VisionToolkitError('runtime', `python bootstrap manifest is unreadable: ${PYTHON_BOOTSTRAP_MANIFEST_PATH}`, { cause: error })
286
+ }
287
+ if (typeof parsed !== 'object' || parsed === null) {
288
+ throw new VisionToolkitError('runtime', 'python bootstrap manifest is not an object')
289
+ }
290
+ const manifest = parsed as Partial<PythonBootstrapManifest>
291
+ if (
292
+ manifest.schemaVersion !== 1
293
+ || typeof manifest.pythonVersion !== 'string'
294
+ || !/^3\.\d+\.\d+$/u.test(manifest.pythonVersion)
295
+ || typeof manifest.buildTag !== 'string'
296
+ || !/^\d{8}$/u.test(manifest.buildTag)
297
+ || typeof manifest.artifacts !== 'object'
298
+ || manifest.artifacts === null
299
+ ) {
300
+ throw new VisionToolkitError('runtime', 'python bootstrap manifest is invalid')
301
+ }
302
+ for (const [target, artifact] of Object.entries(manifest.artifacts)) {
303
+ if (
304
+ typeof artifact !== 'object'
305
+ || artifact === null
306
+ || typeof artifact.url !== 'string'
307
+ || !artifact.url.startsWith('https://github.com/astral-sh/python-build-standalone/releases/download/')
308
+ || typeof artifact.sha256 !== 'string'
309
+ || !/^[a-f0-9]{64}$/u.test(artifact.sha256)
310
+ || !Number.isInteger(artifact.size)
311
+ || artifact.size <= 0
312
+ ) {
313
+ throw new VisionToolkitError('runtime', `python bootstrap manifest has an invalid artifact: ${target}`)
314
+ }
315
+ }
316
+ return manifest as PythonBootstrapManifest
317
+ }
318
+
319
+ /** Map Node platform/arch to the pinned artifact name, including musl Linux. */
320
+ export function pythonBootstrapTarget(platform: string, arch: string, musl: boolean): string {
321
+ return platform === 'linux' && musl ? `${platform}-${arch}-musl` : `${platform}-${arch}`
322
+ }
323
+
324
+ async function runningMusl(): Promise<boolean> {
325
+ if (process.platform !== 'linux') return false
326
+ try {
327
+ await access(join('/', 'etc', 'alpine-release'))
328
+ return true
329
+ } catch {
330
+ // Fall through to the loader-name probe.
331
+ }
332
+ const loader = process.arch === 'arm64' ? 'aarch64' : 'x86_64'
333
+ try {
334
+ await access(join('/', 'lib', `ld-musl-${loader}.so.1`))
335
+ return true
336
+ } catch {
337
+ return false
338
+ }
339
+ }
340
+
341
+ interface DownloadResponse {
342
+ statusCode: number
343
+ headers: Record<string, string | string[] | undefined>
344
+ body: NodeJS.ReadableStream
345
+ close: () => Promise<void>
346
+ }
347
+
348
+ type DownloadRequest = (url: string, signal: AbortSignal) => Promise<DownloadResponse>
349
+
350
+ const DOWNLOAD_HOSTS = new Set([
351
+ 'github.com',
352
+ 'objects.githubusercontent.com',
353
+ 'release-assets.githubusercontent.com',
354
+ ])
355
+
356
+ async function defaultDownloadRequest(url: string, signal: AbortSignal): Promise<DownloadResponse> {
357
+ const dispatcher = new EnvHttpProxyAgent()
358
+ let current = url
359
+ try {
360
+ for (let redirects = 0; ; redirects++) {
361
+ const host = new URL(current).hostname
362
+ if (!DOWNLOAD_HOSTS.has(host)) throw new Error(`download redirected outside GitHub: ${host}`)
363
+ const response = await undiciRequest(current, {
364
+ dispatcher,
365
+ signal,
366
+ headers: { 'user-agent': 'dsh-vision-toolkit' },
367
+ })
368
+ if (response.statusCode >= 300 && response.statusCode < 400) {
369
+ const location = response.headers.location
370
+ await response.body.dump().catch(() => {})
371
+ if (typeof location !== 'string' || location.length === 0 || redirects >= 5) {
372
+ throw new Error('download redirected too many times or without a Location header')
373
+ }
374
+ current = new URL(location, current).toString()
375
+ continue
376
+ }
377
+ return {
378
+ statusCode: response.statusCode,
379
+ headers: response.headers,
380
+ body: response.body,
381
+ close: () => dispatcher.close().catch(() => {}),
382
+ }
383
+ }
384
+ } catch (error) {
385
+ await dispatcher.close().catch(() => {})
386
+ throw error
387
+ }
388
+ }
389
+
390
+ async function downloadBundledPythonOnce(
391
+ artifact: PythonBootstrapArtifact,
392
+ destination: string,
393
+ requestImpl: DownloadRequest = defaultDownloadRequest,
394
+ ): Promise<void> {
395
+ const signal = AbortSignal.timeout(PYTHON_DOWNLOAD_TIMEOUT_MS)
396
+ let response: DownloadResponse
397
+ try {
398
+ response = await requestImpl(artifact.url, signal)
399
+ } catch (error) {
400
+ throw new Error(`download request failed: ${error instanceof Error ? error.message : String(error)}`)
401
+ }
402
+ try {
403
+ if (response.statusCode !== 200) throw new Error(`download returned HTTP ${response.statusCode}`)
404
+ const hash = createHash('sha256')
405
+ let bytes = 0
406
+ const hasher = new Transform({
407
+ transform(chunk, _encoding, callback) {
408
+ hash.update(chunk as Buffer)
409
+ bytes += (chunk as Buffer).length
410
+ callback(null, chunk)
411
+ },
412
+ })
413
+ await pipeline(response.body, hasher, createWriteStream(destination))
414
+ if (bytes !== artifact.size) throw new Error(`size mismatch: expected ${artifact.size}, received ${bytes}`)
415
+ if (hash.digest('hex') !== artifact.sha256) throw new Error('sha256 mismatch')
416
+ } finally {
417
+ await response.close()
418
+ }
419
+ }
420
+
421
+ async function downloadBundledPython(
422
+ artifact: PythonBootstrapArtifact,
423
+ destination: string,
424
+ requestImpl: DownloadRequest,
425
+ ): Promise<void> {
426
+ let lastError: unknown
427
+ for (let attempt = 0; attempt < PYTHON_DOWNLOAD_ATTEMPTS; attempt++) {
428
+ try {
429
+ await downloadBundledPythonOnce(artifact, destination, requestImpl)
430
+ return
431
+ } catch (error) {
432
+ lastError = error
433
+ if (attempt + 1 < PYTHON_DOWNLOAD_ATTEMPTS) {
434
+ await new Promise(resolveWait => setTimeout(resolveWait, 500 * 2 ** attempt))
435
+ }
436
+ }
437
+ }
438
+ throw lastError
439
+ }
440
+
441
+ /**
442
+ * Cross-process directory lock with a stale-lock timeout and heartbeat,
443
+ * matching the managed-runtime lock semantics.
444
+ */
445
+ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Promise<T> {
446
+ const owner = randomUUID()
447
+ let acquired = false
448
+ await mkdir(dirname(lockPath), { recursive: true })
449
+ try {
450
+ await mkdir(lockPath, { recursive: false })
451
+ acquired = true
452
+ await writeFile(join(lockPath, 'owner'), `${owner}\n`, { flag: 'wx' })
453
+ } catch (error) {
454
+ if (acquired) {
455
+ await rm(lockPath, { recursive: true, force: true }).catch(() => {})
456
+ throw error
457
+ }
458
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
459
+ const started = Date.now()
460
+ while (Date.now() - started < PREPARE_TIMEOUT_MS) {
461
+ try {
462
+ const info = await stat(lockPath)
463
+ if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
464
+ await rm(lockPath, { recursive: true, force: true })
465
+ return withDirectoryLock(lockPath, fn)
466
+ }
467
+ } catch {
468
+ return withDirectoryLock(lockPath, fn)
469
+ }
470
+ await new Promise(resolveWait => setTimeout(resolveWait, 250))
471
+ }
472
+ throw new VisionToolkitError('runtime', 'timed out waiting for another process to prepare the bundled Python')
473
+ }
474
+ const heartbeat = setInterval(() => {
475
+ const now = new Date()
476
+ void utimes(lockPath, now, now).catch(() => {})
477
+ }, LOCK_HEARTBEAT_MS)
478
+ heartbeat.unref()
479
+ try {
480
+ return await fn()
481
+ } finally {
482
+ clearInterval(heartbeat)
483
+ try {
484
+ if ((await readFile(join(lockPath, 'owner'), 'utf8')).trim() === owner) {
485
+ await rm(lockPath, { recursive: true, force: true })
486
+ }
487
+ } catch {
488
+ // The lock was already removed or replaced.
489
+ }
490
+ }
491
+ }
492
+
493
+ export async function acquireBundledPython(
494
+ ctx: Context,
495
+ stateRoot: string,
496
+ cwd: string,
497
+ manifestOverride?: PythonBootstrapManifest,
498
+ requestImpl?: DownloadRequest,
499
+ ): Promise<{ command: RuntimeCommand; version: string }> {
500
+ const manifest = manifestOverride ?? await readPythonBootstrapManifest()
501
+ const target = pythonBootstrapTarget(process.platform, process.arch, await runningMusl())
502
+ const artifact = manifest.artifacts[target]
503
+ if (artifact === undefined) {
504
+ throw new VisionToolkitError(
505
+ 'runtime',
506
+ `no bundled Python ${manifest.pythonVersion} artifact for ${target}; install Python 3.11+ or configure runtime.python`,
507
+ )
508
+ }
509
+ const root = join(stateRoot, 'python-bootstrap', `${manifest.pythonVersion}-${target}`)
510
+ const interpreter = process.platform === 'win32' ? join(root, 'python.exe') : join(root, 'bin', 'python3')
511
+ const command: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
512
+ const cached = await pythonMetadata(ctx, command, cwd)
513
+ if (cached !== undefined) return { command, version: cached.version }
514
+ await withDirectoryLock(`${root}.lock`, async () => {
515
+ const ready = await pythonMetadata(ctx, command, cwd)
516
+ if (ready !== undefined) return
517
+ const parent = dirname(root)
518
+ await mkdir(parent, { recursive: true })
519
+ await rm(root, { recursive: true, force: true })
520
+ const work = await mkdtemp(join(parent, '.python-bootstrap-'))
521
+ try {
522
+ const archive = join(work, 'python.tar.gz')
523
+ const extractDir = join(work, 'extract')
524
+ await mkdir(extractDir, { recursive: true })
525
+ try {
526
+ await downloadBundledPython(artifact, archive, requestImpl ?? defaultDownloadRequest)
527
+ } catch (error) {
528
+ throw new VisionToolkitError(
529
+ 'runtime',
530
+ `bundled Python ${manifest.pythonVersion} could not be downloaded for ${target} (${error instanceof Error ? error.message : String(error)}); install Python 3.11+ or configure runtime.python`,
531
+ { cause: error },
532
+ )
533
+ }
534
+ try {
535
+ await extractTar({ file: archive, cwd: extractDir, strip: 1 })
536
+ } catch (error) {
537
+ throw new VisionToolkitError(
538
+ 'runtime',
539
+ `bundled Python ${manifest.pythonVersion} could not be extracted for ${target}`,
540
+ { cause: error },
541
+ )
542
+ }
543
+ const extractedInterpreter = process.platform === 'win32'
544
+ ? join(extractDir, 'python.exe')
545
+ : join(extractDir, 'bin', 'python3')
546
+ try {
547
+ await access(extractedInterpreter)
548
+ } catch (error) {
549
+ throw new VisionToolkitError(
550
+ 'runtime',
551
+ `bundled Python ${manifest.pythonVersion} for ${target} is missing its interpreter`,
552
+ { cause: error },
553
+ )
554
+ }
555
+ if (process.platform !== 'win32') await chmod(extractedInterpreter, 0o755)
556
+ await rename(extractDir, root)
557
+ } finally {
558
+ await rm(work, { recursive: true, force: true })
559
+ }
560
+ })
561
+ const metadata = await pythonMetadata(ctx, command, cwd)
562
+ if (metadata === undefined) {
563
+ throw new VisionToolkitError('runtime', 'bundled Python did not start after extraction')
564
+ }
565
+ return { command, version: metadata.version }
566
+ }
567
+
568
+ export async function resolveBootstrapPython(
256
569
  ctx: Context,
257
570
  configured: string | undefined,
258
571
  cwd: string,
572
+ manifestOverride?: PythonBootstrapManifest,
573
+ requestImpl?: DownloadRequest,
259
574
  ): Promise<{ command: RuntimeCommand; version: string; major: number; minor: number }> {
260
575
  const candidates: RuntimeCommand[] = configured === undefined
261
576
  ? process.platform === 'win32'
@@ -275,12 +590,27 @@ async function resolveBootstrapPython(
275
590
  return { command, ...metadata }
276
591
  }
277
592
  }
278
- throw new VisionToolkitError(
279
- 'runtime',
280
- configured === undefined
281
- ? 'vision-toolkit requires Python 3.11 or newer; tried python3, python, and the Windows py launcher'
282
- : `vision-toolkit requires Python 3.11 or newer: ${configured}`,
283
- )
593
+ if (configured !== undefined) {
594
+ throw new VisionToolkitError('runtime', `vision-toolkit requires Python 3.11 or newer: ${configured}`)
595
+ }
596
+ try {
597
+ const stateRoot = visionToolkitStateRoot()
598
+ await mkdir(stateRoot, { recursive: true })
599
+ const bundled = await acquireBundledPython(ctx, stateRoot, cwd, manifestOverride, requestImpl)
600
+ return {
601
+ command: bundled.command,
602
+ version: bundled.version,
603
+ major: Number.parseInt(bundled.version.split('.')[0] ?? '', 10),
604
+ minor: Number.parseInt(bundled.version.split('.')[1] ?? '', 10),
605
+ }
606
+ } catch (error) {
607
+ if (error instanceof VisionToolkitError) throw error
608
+ throw new VisionToolkitError(
609
+ 'runtime',
610
+ 'vision-toolkit requires Python 3.11 or newer; tried python3, python, and the Windows py launcher, and automatic bundled-Python preparation failed',
611
+ { cause: error },
612
+ )
613
+ }
284
614
  }
285
615
 
286
616
  /** Persistent per-DSH-home cache root shared by runtime and Web support files. */
package/src/skill.ts CHANGED
@@ -24,8 +24,8 @@ export const VISION_TOOLS_SKILL_CONTENT = readFileSync(
24
24
  /** Runtime skill registration mounted only after every native tool is ready. */
25
25
  export const VISION_TOOLS_SKILL: SkillRegistration = {
26
26
  name: VISION_TOOLS_SKILL_NAME,
27
- description: 'Native DSH visual engineering tools adapted from agent-vision-toolkit: vision_glance, vision_ground, vision_detect, vision_trace, vision_crop, vision_pixel_diff, vision_long_screenshot_ocr, vision_extract_foreground, vision_dominant_colors, vision_html_screenshot, and upstream playbooks.',
28
- whenToUse: 'Use whenever a task depends on image text/content, pixel coordinates, screenshot-to-UI reconstruction, visual regression, reusable image/SVG assets, diagram reconstruction, GUI operation from screenshots, or tall screenshot OCR.',
27
+ description: '把截图或设计图还原为 UI(网页、应用界面或组件),生成 HTML/CSS 或项目现有前端代码;也支持图片问答、长截图/聊天记录 OCR、元素定位与盘点、裁剪、前景提取、像素对比、取色、SVG 描摹和 HTML 转截图。当任务涉及图片理解、UI 还原/重建(rebuild or restore a UI from a screenshot)、视觉回归、像素坐标、基于截图的 GUI 操作、可复用图片/SVG 素材、图表还原或长截图 OCR 时使用。',
28
+ whenToUse: '任务依赖图片文字/内容、像素坐标、截图转 UI 重建、视觉回归、可复用图片/SVG 素材、图表还原、基于截图的 GUI 操作或长截图 OCR 时使用。',
29
29
  source: 'runtime',
30
30
  resourceBase: {
31
31
  kind: 'directory',