@mengruo/dsh-vision-toolkit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +356 -0
  4. package/README.zh.md +354 -0
  5. package/assets/community-group-qr.png +0 -0
  6. package/assets/dsh-conversation-artifact.png +0 -0
  7. package/assets/dsh-conversation-image-qa-top.png +0 -0
  8. package/assets/dsh-conversation-image-qa.png +0 -0
  9. package/assets/dsh-conversation-pixel-diff.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  11. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  12. package/assets/dsh-conversation-tool-call.png +0 -0
  13. package/assets/dsh-conversation-vision-trace.png +0 -0
  14. package/assets/dsh-view-example.png +0 -0
  15. package/assets/hero-v2.png +0 -0
  16. package/assets/logo_aihubmix.png +0 -0
  17. package/assets/python-bootstrap.json +48 -0
  18. package/assets/skill/SKILL.md +329 -0
  19. package/assets/skill/UPSTREAM.json +71 -0
  20. package/assets/skill/references/gui.md +88 -0
  21. package/assets/skill/references/long-screenshot-ocr.md +77 -0
  22. package/assets/skill/references/restore-graphic.md +84 -0
  23. package/assets/skill/references/restore-structure.md +45 -0
  24. package/assets/skill/references/restore-ui.md +202 -0
  25. package/assets/social-preview.png +0 -0
  26. package/assets/upstream/README.md +18 -0
  27. package/assets/upstream/focus-hint-comparison-1.webp +0 -0
  28. package/assets/upstream/focus-hint-comparison-2.webp +0 -0
  29. package/assets/upstream/infographic-reference.webp +0 -0
  30. package/assets/upstream/infographic-result.webp +0 -0
  31. package/assets/upstream/ui-fast-restore-reference.webp +0 -0
  32. package/assets/upstream/ui-fast-restore-result.webp +0 -0
  33. package/assets/upstream/ui-result.webp +0 -0
  34. package/assets/upstream/ui-sketch.webp +0 -0
  35. package/assets/vision-model-test.png +0 -0
  36. package/assets/vision-settings.png +0 -0
  37. package/assets/wechat-reward.png +0 -0
  38. package/cordis.patch.yml +6 -0
  39. package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
  40. package/docs/aihubmix-gemini-vision.md +138 -0
  41. package/docs/aihubmix-gemini-vision.zh.md +138 -0
  42. package/docs/assets/aihubmix-api-keys.png +0 -0
  43. package/docs/assets/aihubmix-create-key.png +0 -0
  44. package/docs/assets/aihubmix-free-vision-model.png +0 -0
  45. package/docs/assets/aihubmix-home.png +0 -0
  46. package/docs/assets/aihubmix-sign-up.png +0 -0
  47. package/docs/assets/groq-console-home.png +0 -0
  48. package/docs/assets/groq-console-keys-login.png +0 -0
  49. package/docs/assets/groq-docs-quickstart.png +0 -0
  50. package/docs/assets/groq-docs-qwen3.6.png +0 -0
  51. package/docs/assets/groq-docs-vision.png +0 -0
  52. package/docs/assets/vision-settings.png +0 -0
  53. package/docs/dsh-desktop-install.md +67 -0
  54. package/docs/dsh-desktop-install.zh.md +67 -0
  55. package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
  56. package/docs/groq-qwen3.6-vision.md +236 -0
  57. package/docs/groq-qwen3.6-vision.zh.md +236 -0
  58. package/docs/python-runtime.i18n.yaml +6 -0
  59. package/docs/python-runtime.md +89 -0
  60. package/docs/python-runtime.zh.md +89 -0
  61. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  62. package/docs/requirements-traceability/README.md +75 -0
  63. package/docs/requirements-traceability/README.zh.md +75 -0
  64. package/examples/ui-restoration/README.i18n.yaml +6 -0
  65. package/examples/ui-restoration/README.md +70 -0
  66. package/examples/ui-restoration/README.zh.md +70 -0
  67. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  68. package/examples/ui-restoration/assets/final-report.json +83 -0
  69. package/examples/ui-restoration/assets/implementation.png +0 -0
  70. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  71. package/examples/ui-restoration/assets/initial-report.json +83 -0
  72. package/examples/ui-restoration/assets/initial.png +0 -0
  73. package/examples/ui-restoration/assets/metrics.json +12 -0
  74. package/examples/ui-restoration/assets/reference.png +0 -0
  75. package/examples/ui-restoration/implementation.html +94 -0
  76. package/examples/ui-restoration/initial.html +57 -0
  77. package/lib/artifact-access.js +369 -0
  78. package/lib/artifact-access.js.map +1 -0
  79. package/lib/artifacts.js +56 -0
  80. package/lib/artifacts.js.map +1 -0
  81. package/lib/client.js +2099 -0
  82. package/lib/client.js.map +1 -0
  83. package/lib/config.js +172 -0
  84. package/lib/config.js.map +1 -0
  85. package/lib/defaults.js +6 -0
  86. package/lib/defaults.js.map +1 -0
  87. package/lib/errors.js +56 -0
  88. package/lib/errors.js.map +1 -0
  89. package/lib/evidence-cache.js +350 -0
  90. package/lib/evidence-cache.js.map +1 -0
  91. package/lib/exposure.js +247 -0
  92. package/lib/exposure.js.map +1 -0
  93. package/lib/image-input-variants.js +882 -0
  94. package/lib/image-input-variants.js.map +1 -0
  95. package/lib/index.js +105 -0
  96. package/lib/index.js.map +1 -0
  97. package/lib/paste-images.js +217 -0
  98. package/lib/paste-images.js.map +1 -0
  99. package/lib/paths.js +354 -0
  100. package/lib/paths.js.map +1 -0
  101. package/lib/plugin-update.js +1003 -0
  102. package/lib/plugin-update.js.map +1 -0
  103. package/lib/runtime-install.js +964 -0
  104. package/lib/runtime-install.js.map +1 -0
  105. package/lib/runtime-manager.js +133 -0
  106. package/lib/runtime-manager.js.map +1 -0
  107. package/lib/runtime.js +1678 -0
  108. package/lib/runtime.js.map +1 -0
  109. package/lib/skill.js +25 -0
  110. package/lib/skill.js.map +1 -0
  111. package/lib/tools.js +549 -0
  112. package/lib/tools.js.map +1 -0
  113. package/lib/types/artifact-access.d.ts +61 -0
  114. package/lib/types/artifact-access.d.ts.map +1 -0
  115. package/lib/types/artifacts.d.ts +42 -0
  116. package/lib/types/artifacts.d.ts.map +1 -0
  117. package/lib/types/client/display-config.d.ts +24 -0
  118. package/lib/types/client/display-config.d.ts.map +1 -0
  119. package/lib/types/client/index.d.ts +350 -0
  120. package/lib/types/client/index.d.ts.map +1 -0
  121. package/lib/types/client/model-variants-hider.d.ts +40 -0
  122. package/lib/types/client/model-variants-hider.d.ts.map +1 -0
  123. package/lib/types/client/paste-images.d.ts +126 -0
  124. package/lib/types/client/paste-images.d.ts.map +1 -0
  125. package/lib/types/config.d.ts +123 -0
  126. package/lib/types/config.d.ts.map +1 -0
  127. package/lib/types/defaults.d.ts +6 -0
  128. package/lib/types/defaults.d.ts.map +1 -0
  129. package/lib/types/errors.d.ts +35 -0
  130. package/lib/types/errors.d.ts.map +1 -0
  131. package/lib/types/evidence-cache.d.ts +91 -0
  132. package/lib/types/evidence-cache.d.ts.map +1 -0
  133. package/lib/types/exposure.d.ts +50 -0
  134. package/lib/types/exposure.d.ts.map +1 -0
  135. package/lib/types/image-input-variants.d.ts +144 -0
  136. package/lib/types/image-input-variants.d.ts.map +1 -0
  137. package/lib/types/index.d.ts +19 -0
  138. package/lib/types/index.d.ts.map +1 -0
  139. package/lib/types/paste-images.d.ts +73 -0
  140. package/lib/types/paste-images.d.ts.map +1 -0
  141. package/lib/types/paths.d.ts +117 -0
  142. package/lib/types/paths.d.ts.map +1 -0
  143. package/lib/types/plugin-update.d.ts +119 -0
  144. package/lib/types/plugin-update.d.ts.map +1 -0
  145. package/lib/types/runtime-install.d.ts +92 -0
  146. package/lib/types/runtime-install.d.ts.map +1 -0
  147. package/lib/types/runtime-manager.d.ts +60 -0
  148. package/lib/types/runtime-manager.d.ts.map +1 -0
  149. package/lib/types/runtime.d.ts +412 -0
  150. package/lib/types/runtime.d.ts.map +1 -0
  151. package/lib/types/skill.d.ts +14 -0
  152. package/lib/types/skill.d.ts.map +1 -0
  153. package/lib/types/tools.d.ts +35 -0
  154. package/lib/types/tools.d.ts.map +1 -0
  155. package/lib/types/upstream.d.ts +233 -0
  156. package/lib/types/upstream.d.ts.map +1 -0
  157. package/lib/types/version.d.ts +15 -0
  158. package/lib/types/version.d.ts.map +1 -0
  159. package/lib/types/web-request.d.ts +11 -0
  160. package/lib/types/web-request.d.ts.map +1 -0
  161. package/lib/types/web.d.ts +118 -0
  162. package/lib/types/web.d.ts.map +1 -0
  163. package/lib/upstream.js +860 -0
  164. package/lib/upstream.js.map +1 -0
  165. package/lib/version.js +18 -0
  166. package/lib/version.js.map +1 -0
  167. package/lib/web-request.js +29 -0
  168. package/lib/web-request.js.map +1 -0
  169. package/lib/web.js +462 -0
  170. package/lib/web.js.map +1 -0
  171. package/package.json +149 -0
  172. package/patches/vision-tools-dsh.patch +970 -0
  173. package/runtime/requirements.lock +3 -0
  174. package/src/artifact-access.ts +386 -0
  175. package/src/artifacts.ts +85 -0
  176. package/src/client/display-config.ts +62 -0
  177. package/src/client/index.tsx +1580 -0
  178. package/src/client/model-variants-hider.ts +159 -0
  179. package/src/client/paste-images.tsx +747 -0
  180. package/src/config.ts +290 -0
  181. package/src/defaults.ts +5 -0
  182. package/src/errors.ts +62 -0
  183. package/src/evidence-cache.ts +418 -0
  184. package/src/exposure.ts +265 -0
  185. package/src/image-input-variants.ts +996 -0
  186. package/src/index.ts +141 -0
  187. package/src/paste-images.ts +286 -0
  188. package/src/paths.ts +383 -0
  189. package/src/plugin-update.ts +1143 -0
  190. package/src/runtime-install.ts +1161 -0
  191. package/src/runtime-manager.ts +174 -0
  192. package/src/runtime.ts +2189 -0
  193. package/src/skill.ts +35 -0
  194. package/src/tools.ts +691 -0
  195. package/src/upstream.ts +1078 -0
  196. package/src/version.ts +37 -0
  197. package/src/web-request.ts +27 -0
  198. package/src/web.ts +594 -0
  199. package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
  200. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  201. package/vendor/agent-vision-toolkit/README.md +405 -0
  202. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
  203. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  204. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  205. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  206. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  207. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  208. package/vendor/agent-vision-toolkit/detect.py +60 -0
  209. package/vendor/agent-vision-toolkit/ground.py +267 -0
  210. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  211. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  212. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
  213. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  214. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  215. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
  216. package/vendor/agent-vision-toolkit/vision_client.py +310 -0
package/src/index.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @mengruo/dsh-vision-toolkit — DSH Vision Toolkit profile bundle.
3
+ *
4
+ * Plugin lifecycle follows the documented readiness chain: verify the pinned
5
+ * upstream checkout, publish the vision-skills Skill and its one-shot bootstrap,
6
+ * then mount the execution tools only in Agents that load that Skill or invoke
7
+ * the bootstrap. Any
8
+ * failure leaves no model capability behind, and disposal unregisters every
9
+ * global and Agent-scoped contribution the plugin mounted.
10
+ * @module @mengruo/dsh-vision-toolkit
11
+ */
12
+
13
+ import type { Context } from '@deepseek-ai/cordis'
14
+ import type {} from '@deepseek-ai/dsh-agent'
15
+ import type {} from '@deepseek-ai/dsh-session'
16
+ import type {} from '@deepseek-ai/dsh-settings'
17
+ import { ArtifactAccessController, prepareArtifactAccessKey } from './artifact-access.ts'
18
+ import {
19
+ Config,
20
+ VISION_TOOLKIT_SETTINGS_NAMESPACE,
21
+ resolveConfig,
22
+ type VisionToolkitConfig,
23
+ } from './config.ts'
24
+ import { VisionToolExposure } from './exposure.ts'
25
+ import { createPasteTakeoverResolver, installImageInputVariants } from './image-input-variants.ts'
26
+ import { VisionToolkitRuntimeManager } from './runtime-manager.ts'
27
+ import { VISION_SKILLS_SKILL } from './skill.ts'
28
+ import { createVisionTools } from './tools.ts'
29
+ import { PLUGIN_VERSION } from './version.ts'
30
+ import { installVisionToolkitWeb, VisionToolkitWebBackend } from './web.ts'
31
+ import { MAX_PASTE_IMAGE_BYTES, PastedImageBackend } from './paste-images.ts'
32
+
33
+ export const name = '@mengruo/dsh-vision-toolkit'
34
+
35
+ export { Config }
36
+
37
+ export const inject = ['tools', 'credentials', 'skills', 'subprocess', 'settings', 'agents', 'sessions']
38
+
39
+ /** Plugin entry: validate configuration synchronously, then mount asynchronously. */
40
+ export async function apply(ctx: Context, config: VisionToolkitConfig = {}): Promise<() => void> {
41
+ // Registration itself rejects an invalid stored section before any runtime
42
+ // or Tool becomes visible. The custom Web editor preflights runtime changes
43
+ // before persistence; hand-edited settings still fail loud here or retain
44
+ // the last serving generation when changed live.
45
+ const settings = ctx.settings.register(VISION_TOOLKIT_SETTINGS_NAMESPACE, Config, {
46
+ base: config,
47
+ applies: 'live',
48
+ validate: (value) => { resolveConfig(value) },
49
+ })
50
+ const manager = new VisionToolkitRuntimeManager(ctx)
51
+ const artifacts = new ArtifactAccessController(await prepareArtifactAccessKey())
52
+ const lifecycle = new AbortController()
53
+ const disposers: Array<() => void> = []
54
+ let operationalDisposers: { activationTool: () => void; exposure: () => void; skill: () => void } | undefined
55
+
56
+ const ensureOperational = (): void => {
57
+ if (!manager.ready || operationalDisposers !== undefined) return
58
+ const exposure = new VisionToolExposure(ctx, () => createVisionTools(
59
+ () => manager.current(),
60
+ value => artifacts.presentationMeta(value),
61
+ lifecycle.signal,
62
+ ))
63
+ let activationTool: (() => void) | undefined
64
+ let exposureDisposer: (() => void) | undefined
65
+ let skill: (() => void) | undefined
66
+ try {
67
+ activationTool = ctx.tools.register(exposure.activationTool)
68
+ skill = ctx.skills.register(VISION_SKILLS_SKILL)
69
+ exposureDisposer = exposure.install()
70
+ operationalDisposers = { activationTool, exposure: exposureDisposer, skill }
71
+ const info = manager.current().upstreamVersion
72
+ ctx.logger.info(
73
+ 'dsh-vision-toolkit %s ready (upstream %s @ %s, checkout %s)',
74
+ PLUGIN_VERSION,
75
+ info.version,
76
+ info.commit,
77
+ info.path,
78
+ )
79
+ } catch (error) {
80
+ exposureDisposer?.()
81
+ if (skill !== undefined) skill()
82
+ activationTool?.()
83
+ throw error
84
+ }
85
+ }
86
+
87
+ try {
88
+ await manager.initialize(settings.get())
89
+ ensureOperational()
90
+ } catch (error) {
91
+ const message = error instanceof Error ? error.message : String(error)
92
+ ctx.logger.error(
93
+ 'dsh-vision-toolkit %s: runtime not ready; the vision-skills skill, activation bootstrap, and Agent-scoped visual tools are NOT registered. Settings remain available for repair. %s',
94
+ PLUGIN_VERSION,
95
+ message,
96
+ )
97
+ }
98
+
99
+ const backend = new VisionToolkitWebBackend(ctx, manager, artifacts, ensureOperational)
100
+ const pastedImages = new PastedImageBackend(ctx, {
101
+ maxUploadBytes: () => MAX_PASTE_IMAGE_BYTES,
102
+ })
103
+ // Image-input variants register asynchronously once eligible routes exist;
104
+ // the runtime getter stays lazy so variants appear even when the runtime
105
+ // becomes ready after the first sweep.
106
+ const variants = installImageInputVariants(
107
+ ctx,
108
+ () => resolveConfig(settings.get()),
109
+ () => manager.ready ? manager.current() : undefined,
110
+ )
111
+ installVisionToolkitWeb(
112
+ ctx,
113
+ backend,
114
+ artifacts,
115
+ pastedImages,
116
+ createPasteTakeoverResolver(ctx, () => resolveConfig(settings.get())),
117
+ () => ({ hidden: resolveConfig(settings.get()).imageInputVariants.hidden }),
118
+ )
119
+ disposers.push(variants.dispose)
120
+ disposers.push(settings.watch(async (next) => {
121
+ try {
122
+ await manager.reconfigure(next)
123
+ ensureOperational()
124
+ variants.reconcile()
125
+ } catch (error) {
126
+ const message = error instanceof Error ? error.message : String(error)
127
+ ctx.logger.error('dsh-vision-toolkit: keeping the previous runtime after a refused Settings generation. %s', message)
128
+ }
129
+ }))
130
+
131
+ return () => {
132
+ lifecycle.abort()
133
+ if (operationalDisposers !== undefined) {
134
+ operationalDisposers.exposure()
135
+ operationalDisposers.activationTool()
136
+ operationalDisposers.skill()
137
+ operationalDisposers = undefined
138
+ }
139
+ for (const dispose of disposers.reverse()) dispose()
140
+ }
141
+ }
@@ -0,0 +1,286 @@
1
+ /** Workspace-local storage for images pasted into the DSH Web composer. */
2
+
3
+ import { createHash, randomUUID } from 'node:crypto'
4
+ import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'
5
+ import type { IncomingMessage, ServerResponse } from 'node:http'
6
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+ import type {} from '@deepseek-ai/dsh-session'
9
+ import { sameOriginPost } from './web-request.ts'
10
+
11
+ /** Exact route used by the browser paste integration. */
12
+ export const PASTE_IMAGES_ROUTE = '/_dsh/vision-toolkit/paste-images'
13
+
14
+ /**
15
+ * Exact route the browser paste integration asks before taking a paste over:
16
+ * `GET ?sessionId=&model=&provider=&modelId=` answers the verdict from the
17
+ * live Session's model route.
18
+ */
19
+ export const PASTE_POLICY_ROUTE = '/_dsh/vision-toolkit/paste-policy'
20
+
21
+ /**
22
+ * One exact model route the browser should switch the Session to before the
23
+ * native attachment flow: the image-input variant of the current text-only
24
+ * model. The variant declares image input, so the paste keeps the composer
25
+ * thumbnail and the durable session image.
26
+ */
27
+ export interface PasteSwitchRoute {
28
+ /** The variant provider route (`vision-toolkit-` + upstream provider id). */
29
+ provider: string
30
+ /** The model id, identical to the upstream text-only model's id. */
31
+ model: string
32
+ /** The variant's selector display name (upstream name + variant suffix). */
33
+ label: string
34
+ /** The upstream reasoning effort, when the selection carries one. */
35
+ reasoningEffort?: string
36
+ }
37
+
38
+ /** The exact model route the browser read from the live model catalog. */
39
+ export interface PasteSelectionQuery {
40
+ provider: string
41
+ model: string
42
+ reasoningEffort?: string
43
+ }
44
+
45
+ /** The paste-policy answer for one Session and model route. */
46
+ export interface PasteVerdict {
47
+ /** Whether the browser should turn the paste into workspace paths instead of attachments. */
48
+ takeOver: boolean
49
+ /** When present, the browser switches to this route first, then lets the paste flow natively. */
50
+ autoSwitch?: PasteSwitchRoute
51
+ }
52
+
53
+ const MAX_NAME_BYTES = 180
54
+
55
+ /**
56
+ * Hard per-image upload ceiling for pastes. Files between the configured
57
+ * `maxImageBytes` (default 4 MiB) and this ceiling are stored as-is and
58
+ * auto-compressed by the runtime on first use; matching the browser-side
59
+ * safety cap keeps the two bundles in agreement.
60
+ */
61
+ export const MAX_PASTE_IMAGE_BYTES = 20 * 1024 * 1024
62
+
63
+ interface PasteImageResponse {
64
+ ok: true
65
+ value: { absolutePath: string; filename: string; bytes: number }
66
+ }
67
+
68
+ interface PasteImageFailure {
69
+ ok: false
70
+ error: { code: string; message: string }
71
+ }
72
+
73
+ type PasteImageResult = PasteImageResponse | PasteImageFailure
74
+
75
+ function responseJson(res: ServerResponse, status: number, body: PasteImageResult): void {
76
+ const bytes = Buffer.from(JSON.stringify(body))
77
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
78
+ res.setHeader('Content-Length', String(bytes.length))
79
+ res.setHeader('Cache-Control', 'no-store')
80
+ res.setHeader('X-Content-Type-Options', 'nosniff')
81
+ res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'")
82
+ res.writeHead(status)
83
+ res.end(bytes)
84
+ }
85
+
86
+ function requestError(res: ServerResponse, status: number, code: string, message: string): void {
87
+ responseJson(res, status, { ok: false, error: { code, message } })
88
+ }
89
+
90
+ function message(error: unknown): string {
91
+ return error instanceof Error ? error.message : String(error)
92
+ }
93
+
94
+ function singleQuery(url: URL, key: string): string {
95
+ const values = url.searchParams.getAll(key)
96
+ if (values.length !== 1 || values[0] === undefined || values[0] === '') {
97
+ throw new TypeError(`${key} is required exactly once`)
98
+ }
99
+ return values[0]
100
+ }
101
+
102
+ function declaredSize(url: URL): number {
103
+ const value = Number(singleQuery(url, 'size'))
104
+ if (!Number.isSafeInteger(value) || value < 1) throw new TypeError('size must be a positive safe integer')
105
+ return value
106
+ }
107
+
108
+ function imageMediaType(req: IncomingMessage): string {
109
+ const value = req.headers['content-type']?.split(';', 1)[0]?.trim().toLowerCase()
110
+ if (value === undefined || !value.startsWith('image/')) throw new TypeError('Content-Type must be image/*')
111
+ return value
112
+ }
113
+
114
+ function extensionFor(mediaType: string): string {
115
+ switch (mediaType) {
116
+ case 'image/jpeg': return '.jpg'
117
+ case 'image/png': return '.png'
118
+ case 'image/gif': return '.gif'
119
+ case 'image/webp': return '.webp'
120
+ case 'image/bmp': return '.bmp'
121
+ case 'image/tiff': return '.tiff'
122
+ case 'image/avif': return '.avif'
123
+ case 'image/heic': return '.heic'
124
+ case 'image/heif': return '.heif'
125
+ case 'image/svg+xml': return '.svg'
126
+ default: return '.img'
127
+ }
128
+ }
129
+
130
+ /** Convert an untrusted browser label into one portable leaf filename. */
131
+ export function safePastedImageName(raw: string, mediaType: string): string {
132
+ const leaf = basename(raw.replaceAll('\\', '/')).normalize('NFC')
133
+ let cleaned = leaf
134
+ .replace(/[<>:"|?*\u0000-\u001f/\\]/gu, '_')
135
+ .replace(/\s+/gu, ' ')
136
+ .replace(/^\.+/u, '')
137
+ .trim()
138
+ .replace(/[. ]+$/u, '')
139
+ if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(cleaned)) cleaned = `_${cleaned}`
140
+ const fallback = `clipboard-image${extensionFor(mediaType)}`
141
+ const candidate = cleaned === '' || cleaned === '.' || cleaned === '..' ? fallback : cleaned
142
+ if (Buffer.byteLength(candidate) <= MAX_NAME_BYTES) return candidate
143
+ const extension = extname(candidate).slice(0, 20)
144
+ const budget = Math.max(1, MAX_NAME_BYTES - Buffer.byteLength(extension))
145
+ let stem = candidate.slice(0, Math.max(1, candidate.length - extension.length))
146
+ while (Buffer.byteLength(stem) > budget) stem = stem.slice(0, -1)
147
+ return `${stem}${extension}`
148
+ }
149
+
150
+ /** Reject a resolved path that is not rooted below the expected directory. */
151
+ export function ensurePathInside(root: string, target: string): void {
152
+ const rel = relative(root, target)
153
+ if (rel !== '' && (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))) {
154
+ throw new Error(`resolved pasted-image path escapes its workspace root: ${target}`)
155
+ }
156
+ }
157
+
158
+ export interface PasteRoot {
159
+ writeRoot: string
160
+ visibleRoot: string
161
+ }
162
+
163
+ async function ensureManagedDirectory(workspace: string, path: string): Promise<string> {
164
+ try {
165
+ await mkdir(path, { mode: 0o700 })
166
+ } catch (error) {
167
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
168
+ }
169
+ const entry = await lstat(path)
170
+ if (entry.isSymbolicLink()) {
171
+ throw new Error(`resolved pasted-image path escapes its workspace root: symbolic link ${path}`)
172
+ }
173
+ if (!entry.isDirectory()) throw new Error(`pasted-image path is not a directory: ${path}`)
174
+ const canonical = await realpath(path)
175
+ ensurePathInside(workspace, canonical)
176
+ return canonical
177
+ }
178
+
179
+ /**
180
+ * Resolve the managed per-session image directory used by both browser pastes
181
+ * and native attachment bridging. Keeping both flows under the same workspace
182
+ * root makes the resulting absolute path valid for the model's visual tools.
183
+ */
184
+ export async function sessionPasteRoot(ctx: Context, sessionId: string): Promise<PasteRoot> {
185
+ const session = ctx.sessions.get(sessionId as never)
186
+ if (session === undefined) throw new Error(`live Session not found: ${sessionId}`)
187
+ const cwd = session.header.cwd
188
+ if (cwd === undefined || !isAbsolute(cwd)) throw new Error(`Session has no absolute workspace: ${sessionId}`)
189
+
190
+ const visibleWorkspace = resolve(cwd)
191
+ const workspace = await realpath(visibleWorkspace)
192
+ const pluginRoot = join(visibleWorkspace, '.dsh-vision-toolkit')
193
+ await ensureManagedDirectory(workspace, pluginRoot)
194
+ const temporaryRoot = join(pluginRoot, 'tmp')
195
+ await ensureManagedDirectory(workspace, temporaryRoot)
196
+ const requestedRoot = join(temporaryRoot, 'pasted-images')
197
+ const root = await ensureManagedDirectory(workspace, requestedRoot)
198
+
199
+ const sessionKey = createHash('sha256').update(sessionId).digest('hex').slice(0, 20)
200
+ const requestedSessionRoot = join(requestedRoot, sessionKey)
201
+ const sessionRoot = await ensureManagedDirectory(root, requestedSessionRoot)
202
+ ensurePathInside(root, sessionRoot)
203
+ return { writeRoot: sessionRoot, visibleRoot: requestedSessionRoot }
204
+ }
205
+
206
+ async function writeImage(
207
+ req: IncomingMessage,
208
+ directory: string,
209
+ filename: string,
210
+ expectedBytes: number,
211
+ maxBytes: number,
212
+ ): Promise<string> {
213
+ if (expectedBytes > maxBytes) throw new RangeError(`image exceeds the ${maxBytes}-byte paste limit`)
214
+ const id = randomUUID()
215
+ const finalPath = join(directory, `${id}-${filename}`)
216
+ const stagingPath = join(directory, `.${id}.partial`)
217
+ ensurePathInside(directory, finalPath)
218
+ ensurePathInside(directory, stagingPath)
219
+
220
+ const handle = await open(stagingPath, 'wx', 0o600)
221
+ let received = 0
222
+ try {
223
+ for await (const chunk of req) {
224
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
225
+ received += bytes.length
226
+ if (received > expectedBytes || received > maxBytes) throw new RangeError('pasted image body exceeds its declared size')
227
+ await handle.write(bytes)
228
+ }
229
+ if (received !== expectedBytes) {
230
+ throw new Error(`pasted image body size mismatch: expected ${expectedBytes}, received ${received}`)
231
+ }
232
+ await handle.sync()
233
+ await handle.close()
234
+ await rename(stagingPath, finalPath)
235
+ return finalPath
236
+ } catch (error) {
237
+ await handle.close().catch(() => {})
238
+ await rm(stagingPath, { force: true }).catch(() => {})
239
+ throw error
240
+ }
241
+ }
242
+
243
+ /** Runtime limit face kept separate for focused backend tests. */
244
+ export interface PasteImageRuntime {
245
+ maxUploadBytes(): number
246
+ }
247
+
248
+ /** Same-origin, live-Session-bound image upload endpoint. */
249
+ export class PastedImageBackend {
250
+ constructor(
251
+ private readonly ctx: Context,
252
+ private readonly runtime: PasteImageRuntime,
253
+ ) {}
254
+
255
+ async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
256
+ if (req.method !== 'POST') {
257
+ res.setHeader('Allow', 'POST')
258
+ requestError(res, 405, 'method-not-allowed', 'Use POST')
259
+ return
260
+ }
261
+ if (!sameOriginPost(req)) {
262
+ requestError(res, 403, 'origin-rejected', 'The request must originate from this DSH Web application')
263
+ return
264
+ }
265
+
266
+ try {
267
+ const url = new URL(req.url ?? PASTE_IMAGES_ROUTE, 'http://dsh.internal')
268
+ const sessionId = singleQuery(url, 'sessionId')
269
+ const size = declaredSize(url)
270
+ const mediaType = imageMediaType(req)
271
+ const filename = safePastedImageName(singleQuery(url, 'name'), mediaType)
272
+ const contentLength = req.headers['content-length']
273
+ if (contentLength !== undefined && Number(contentLength) !== size) {
274
+ throw new TypeError('Content-Length does not match the declared size')
275
+ }
276
+ const directory = await sessionPasteRoot(this.ctx, sessionId)
277
+ const writtenPath = await writeImage(req, directory.writeRoot, filename, size, this.runtime.maxUploadBytes())
278
+ const absolutePath = join(directory.visibleRoot, basename(writtenPath))
279
+ responseJson(res, 201, { ok: true, value: { absolutePath, filename, bytes: size } })
280
+ } catch (error) {
281
+ const status = error instanceof RangeError ? 413 : 400
282
+ this.ctx.logger.warn('dsh-vision-toolkit pasted image rejected: %s', message(error))
283
+ requestError(res, status, 'paste-image-rejected', message(error))
284
+ }
285
+ }
286
+ }