@anionex/dsh-vision-toolkit 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +383 -0
  4. package/README.zh.md +383 -0
  5. package/assets/dsh-conversation-artifact.png +0 -0
  6. package/assets/dsh-conversation-image-qa-top.png +0 -0
  7. package/assets/dsh-conversation-image-qa.png +0 -0
  8. package/assets/dsh-conversation-pixel-diff.png +0 -0
  9. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  11. package/assets/dsh-conversation-tool-call.png +0 -0
  12. package/assets/dsh-conversation-vision-trace.png +0 -0
  13. package/assets/hero.png +0 -0
  14. package/assets/social-preview.png +0 -0
  15. package/assets/upstream/README.md +16 -0
  16. package/assets/upstream/image-qa.webp +0 -0
  17. package/assets/upstream/infographic-reference.webp +0 -0
  18. package/assets/upstream/infographic-result.webp +0 -0
  19. package/assets/upstream/screenshot-debugging.webp +0 -0
  20. package/assets/upstream/ui-result.webp +0 -0
  21. package/assets/upstream/ui-sketch.webp +0 -0
  22. package/assets/vision-settings.png +0 -0
  23. package/cordis.patch.yml +6 -0
  24. package/docs/assets/vision-settings.png +0 -0
  25. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  26. package/docs/requirements-traceability/README.md +75 -0
  27. package/docs/requirements-traceability/README.zh.md +75 -0
  28. package/examples/ui-restoration/README.i18n.yaml +6 -0
  29. package/examples/ui-restoration/README.md +70 -0
  30. package/examples/ui-restoration/README.zh.md +70 -0
  31. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  32. package/examples/ui-restoration/assets/final-report.json +83 -0
  33. package/examples/ui-restoration/assets/implementation.png +0 -0
  34. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  35. package/examples/ui-restoration/assets/initial-report.json +83 -0
  36. package/examples/ui-restoration/assets/initial.png +0 -0
  37. package/examples/ui-restoration/assets/metrics.json +12 -0
  38. package/examples/ui-restoration/assets/reference.png +0 -0
  39. package/examples/ui-restoration/implementation.html +94 -0
  40. package/examples/ui-restoration/initial.html +57 -0
  41. package/lib/artifact-access.js +369 -0
  42. package/lib/artifact-access.js.map +1 -0
  43. package/lib/artifacts.js +56 -0
  44. package/lib/artifacts.js.map +1 -0
  45. package/lib/client.js +952 -0
  46. package/lib/client.js.map +1 -0
  47. package/lib/config.js +117 -0
  48. package/lib/config.js.map +1 -0
  49. package/lib/errors.js +56 -0
  50. package/lib/errors.js.map +1 -0
  51. package/lib/exposure.js +213 -0
  52. package/lib/exposure.js.map +1 -0
  53. package/lib/index.js +97 -0
  54. package/lib/index.js.map +1 -0
  55. package/lib/paste-images.js +199 -0
  56. package/lib/paste-images.js.map +1 -0
  57. package/lib/paths.js +325 -0
  58. package/lib/paths.js.map +1 -0
  59. package/lib/runtime-install.js +601 -0
  60. package/lib/runtime-install.js.map +1 -0
  61. package/lib/runtime-manager.js +126 -0
  62. package/lib/runtime-manager.js.map +1 -0
  63. package/lib/runtime.js +1344 -0
  64. package/lib/runtime.js.map +1 -0
  65. package/lib/skill.js +139 -0
  66. package/lib/skill.js.map +1 -0
  67. package/lib/tools.js +528 -0
  68. package/lib/tools.js.map +1 -0
  69. package/lib/types/artifact-access.d.ts +61 -0
  70. package/lib/types/artifact-access.d.ts.map +1 -0
  71. package/lib/types/artifacts.d.ts +42 -0
  72. package/lib/types/artifacts.d.ts.map +1 -0
  73. package/lib/types/client/index.d.ts +179 -0
  74. package/lib/types/client/index.d.ts.map +1 -0
  75. package/lib/types/client/paste-images.d.ts +57 -0
  76. package/lib/types/client/paste-images.d.ts.map +1 -0
  77. package/lib/types/config.d.ts +73 -0
  78. package/lib/types/config.d.ts.map +1 -0
  79. package/lib/types/errors.d.ts +35 -0
  80. package/lib/types/errors.d.ts.map +1 -0
  81. package/lib/types/exposure.d.ts +40 -0
  82. package/lib/types/exposure.d.ts.map +1 -0
  83. package/lib/types/index.d.ts +18 -0
  84. package/lib/types/index.d.ts.map +1 -0
  85. package/lib/types/paste-images.d.ts +21 -0
  86. package/lib/types/paste-images.d.ts.map +1 -0
  87. package/lib/types/paths.d.ts +107 -0
  88. package/lib/types/paths.d.ts.map +1 -0
  89. package/lib/types/runtime-install.d.ts +49 -0
  90. package/lib/types/runtime-install.d.ts.map +1 -0
  91. package/lib/types/runtime-manager.d.ts +60 -0
  92. package/lib/types/runtime-manager.d.ts.map +1 -0
  93. package/lib/types/runtime.d.ts +389 -0
  94. package/lib/types/runtime.d.ts.map +1 -0
  95. package/lib/types/skill.d.ts +15 -0
  96. package/lib/types/skill.d.ts.map +1 -0
  97. package/lib/types/tools.d.ts +22 -0
  98. package/lib/types/tools.d.ts.map +1 -0
  99. package/lib/types/upstream.d.ts +207 -0
  100. package/lib/types/upstream.d.ts.map +1 -0
  101. package/lib/types/version.d.ts +15 -0
  102. package/lib/types/version.d.ts.map +1 -0
  103. package/lib/types/web-request.d.ts +4 -0
  104. package/lib/types/web-request.d.ts.map +1 -0
  105. package/lib/types/web.d.ts +74 -0
  106. package/lib/types/web.d.ts.map +1 -0
  107. package/lib/upstream.js +675 -0
  108. package/lib/upstream.js.map +1 -0
  109. package/lib/version.js +18 -0
  110. package/lib/version.js.map +1 -0
  111. package/lib/web-request.js +20 -0
  112. package/lib/web-request.js.map +1 -0
  113. package/lib/web.js +244 -0
  114. package/lib/web.js.map +1 -0
  115. package/package.json +139 -0
  116. package/runtime/requirements.lock +3 -0
  117. package/src/artifact-access.ts +386 -0
  118. package/src/artifacts.ts +85 -0
  119. package/src/client/index.tsx +866 -0
  120. package/src/client/paste-images.tsx +426 -0
  121. package/src/config.ts +177 -0
  122. package/src/errors.ts +62 -0
  123. package/src/exposure.ts +227 -0
  124. package/src/index.ts +122 -0
  125. package/src/paste-images.ts +234 -0
  126. package/src/paths.ts +348 -0
  127. package/src/runtime-install.ts +723 -0
  128. package/src/runtime-manager.ts +166 -0
  129. package/src/runtime.ts +1783 -0
  130. package/src/skill.ts +143 -0
  131. package/src/tools.ts +668 -0
  132. package/src/upstream.ts +861 -0
  133. package/src/version.ts +37 -0
  134. package/src/web-request.ts +17 -0
  135. package/src/web.ts +329 -0
  136. package/vendor/agent-vision-toolkit/CHANGELOG.md +16 -0
  137. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  138. package/vendor/agent-vision-toolkit/README.md +399 -0
  139. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +89 -0
  140. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  141. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  142. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  143. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  144. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  145. package/vendor/agent-vision-toolkit/detect.py +56 -0
  146. package/vendor/agent-vision-toolkit/ground.py +216 -0
  147. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  148. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  149. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +108 -0
  150. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  151. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  152. package/vendor/agent-vision-toolkit/vision_client.py +156 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Agent-scoped progressive exposure for the model-facing visual tools.
3
+ * Runtime readiness is global, while tool schemas enter only an Agent that has
4
+ * loaded the matching Skill; administrative diagnostics stay on the Web seam.
5
+ * @module dsh-vision-toolkit/exposure
6
+ */
7
+
8
+ import type { Agent } from '@deepseek-ai/dsh-agent'
9
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
10
+ import type { Session } from '@deepseek-ai/dsh-session'
11
+ import { defineTool, type ToolDefinition } from '@deepseek-ai/dsh-tools'
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ import { VISION_TOOLS_SKILL_CONTENT, VISION_TOOLS_SKILL_NAME } from './skill.ts'
14
+
15
+ /** Small bootstrap tool retained only until the current Agent gains visual tools. */
16
+ export const VISION_TOOLKIT_ACTIVATE = 'vision_toolkit_activate'
17
+
18
+ interface AgentExposure {
19
+ active: boolean
20
+ hideActivation?: () => void
21
+ toolDisposers: Array<() => void>
22
+ toolNames: string[]
23
+ }
24
+
25
+ /** Result returned by the one-shot activation transport. */
26
+ export interface VisionToolkitActivationResult {
27
+ activated: boolean
28
+ tools: string[]
29
+ }
30
+
31
+ function renderJson(_args: unknown, value: unknown): ContentBlock[] {
32
+ return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
33
+ }
34
+
35
+ function isRecord(value: unknown): value is Record<string, unknown> {
36
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
37
+ }
38
+
39
+ function isVisionSkillArguments(value: unknown): boolean {
40
+ return isRecord(value) && value.name === VISION_TOOLS_SKILL_NAME
41
+ }
42
+
43
+ function nativeSkillCall(raw: string): boolean {
44
+ try {
45
+ return isVisionSkillArguments(JSON.parse(raw))
46
+ } catch {
47
+ return false
48
+ }
49
+ }
50
+
51
+ function containsBundledSkillContent(blocks: readonly unknown[]): boolean {
52
+ return blocks.some(block => isRecord(block)
53
+ && block.type === 'text'
54
+ && typeof block.text === 'string'
55
+ && block.text.includes(VISION_TOOLS_SKILL_CONTENT))
56
+ }
57
+
58
+ function isBundledSkillResult(value: unknown): boolean {
59
+ return isRecord(value)
60
+ && value.name === VISION_TOOLS_SKILL_NAME
61
+ && value.content === VISION_TOOLS_SKILL_CONTENT
62
+ }
63
+
64
+ /** Whether durable history proves that this Session loaded the bundled Skill. */
65
+ function hasLoadedVisionSkill(session: Session): boolean {
66
+ const nativeCalls = new Set<string>()
67
+ for (const event of session.events) {
68
+ if (event.type === 'user/message') {
69
+ const source = event.data.source
70
+ if (source.kind === 'skill-invocation'
71
+ && source.name === VISION_TOOLS_SKILL_NAME
72
+ && containsBundledSkillContent(event.data.content)) return true
73
+ continue
74
+ }
75
+ if (event.type === 'tool/call') {
76
+ if (event.data.name === 'skill' && nativeSkillCall(event.data.arguments)) {
77
+ nativeCalls.add(String(event.data.callId))
78
+ }
79
+ continue
80
+ }
81
+ if (event.type === 'tool/result') {
82
+ const [block] = event.data.message.content
83
+ if (block?.type === 'tool-result'
84
+ && block.isError !== true
85
+ && nativeCalls.has(String(block.toolCallId))
86
+ && containsBundledSkillContent(block.content)) return true
87
+ continue
88
+ }
89
+ if (event.type === 'tool/code-dispatch'
90
+ && event.data.name === 'skill'
91
+ && event.data.isError === false
92
+ && isVisionSkillArguments(event.data.arguments)
93
+ && containsBundledSkillContent(event.data.content)) return true
94
+ }
95
+ return false
96
+ }
97
+
98
+ /**
99
+ * Owns one progressive-exposure generation for a ready Vision Toolkit runtime.
100
+ * The bootstrap tool is global; visual definitions are created and registered
101
+ * in an Agent scope only after the Skill load is durable or just succeeded.
102
+ */
103
+ export class VisionToolExposure {
104
+ readonly activationTool: ToolDefinition
105
+ private readonly states = new Map<Agent, AgentExposure>()
106
+ private installed = false
107
+
108
+ /**
109
+ * @param ctx - Plugin context with Tool and Agent registries.
110
+ * @param createTools - Fresh definitions bound to the current runtime generation.
111
+ */
112
+ constructor(
113
+ private readonly ctx: Context,
114
+ private readonly createTools: () => ToolDefinition[],
115
+ ) {
116
+ this.activationTool = defineTool({
117
+ name: VISION_TOOLKIT_ACTIVATE,
118
+ description: `Activate the independent Vision Toolkit execution tools for this Agent after loading the ${VISION_TOOLS_SKILL_NAME} Skill. `
119
+ + 'The Skill tool normally activates them automatically; call this once only after a direct Skill invocation when the visual tools are still absent. This activation tool disappears after success.',
120
+ parameters: {},
121
+ output: {
122
+ schema: {
123
+ type: 'object',
124
+ additionalProperties: false,
125
+ properties: {
126
+ activated: { type: 'boolean', required: true },
127
+ tools: { type: 'array', items: { type: 'string' }, required: true },
128
+ },
129
+ },
130
+ render: renderJson,
131
+ },
132
+ execute: (_args, exec): Promise<VisionToolkitActivationResult> => {
133
+ if (exec.agent === undefined) {
134
+ throw new Error(`${VISION_TOOLKIT_ACTIVATE}: an Agent Session is required`)
135
+ }
136
+ if (!hasLoadedVisionSkill(exec.agent.session)) {
137
+ throw new Error(`${VISION_TOOLKIT_ACTIVATE}: load the ${VISION_TOOLS_SKILL_NAME} Skill first`)
138
+ }
139
+ return Promise.resolve(this.activate(exec.agent))
140
+ },
141
+ presentCall: () => ({ card: 'generic', title: 'Activate vision tools', kind: 'execute' }),
142
+ })
143
+ }
144
+
145
+ /** Install lifecycle listeners and adopt Agents that already exist. */
146
+ install(): () => void {
147
+ if (this.installed) throw new Error('dsh-vision-toolkit: progressive exposure is already installed')
148
+ this.installed = true
149
+ const listeners = [
150
+ this.ctx.on('agent/created', ({ agent }) => { this.attach(agent) }),
151
+ this.ctx.on('agent/disposed', ({ agent }) => { this.detach(agent) }),
152
+ this.ctx.on('tools/result', (exec, result) => {
153
+ if (result.isError === false
154
+ && exec.name === 'skill'
155
+ && exec.agent !== undefined
156
+ && isVisionSkillArguments(exec.arguments)
157
+ && isBundledSkillResult(result.value)) {
158
+ this.activate(exec.agent)
159
+ }
160
+ return undefined
161
+ }),
162
+ ]
163
+ try {
164
+ for (const agent of this.ctx.agents.list()) this.attach(agent)
165
+ } catch (error) {
166
+ for (const dispose of listeners.reverse()) dispose()
167
+ this.disposeStates()
168
+ this.installed = false
169
+ throw error
170
+ }
171
+ return () => {
172
+ if (!this.installed) return
173
+ this.installed = false
174
+ for (const dispose of listeners.reverse()) dispose()
175
+ this.disposeStates()
176
+ }
177
+ }
178
+
179
+ private attach(agent: Agent): void {
180
+ if (this.states.has(agent)) return
181
+ this.states.set(agent, { active: false, toolDisposers: [], toolNames: [] })
182
+ if (hasLoadedVisionSkill(agent.session)) this.activate(agent)
183
+ }
184
+
185
+ private activate(agent: Agent): VisionToolkitActivationResult {
186
+ this.attach(agent)
187
+ const state = this.states.get(agent)
188
+ /* v8 ignore next -- attach() synchronously creates this exact entry. */
189
+ if (state === undefined) throw new Error(`dsh-vision-toolkit: Agent ${String(agent.id)} has no exposure state`)
190
+ if (state.active) return { activated: false, tools: [...state.toolNames] }
191
+
192
+ const definitions = this.createTools()
193
+ const toolDisposers: Array<() => void> = []
194
+ let hideActivation: (() => void) | undefined
195
+ try {
196
+ for (const definition of definitions) toolDisposers.push(agent.ctx.tools.register(definition))
197
+ hideActivation = agent.ctx.tools.restrict({ deny: [VISION_TOOLKIT_ACTIVATE] })
198
+ } catch (error) {
199
+ hideActivation?.()
200
+ for (const dispose of toolDisposers.reverse()) dispose()
201
+ throw error
202
+ }
203
+
204
+ state.active = true
205
+ state.hideActivation = hideActivation
206
+ state.toolDisposers = toolDisposers
207
+ state.toolNames = definitions.map(definition => definition.name)
208
+ return { activated: true, tools: [...state.toolNames] }
209
+ }
210
+
211
+ private detach(agent: Agent): void {
212
+ const state = this.states.get(agent)
213
+ if (state === undefined) return
214
+ this.states.delete(agent)
215
+ this.disposeState(state)
216
+ }
217
+
218
+ private disposeStates(): void {
219
+ for (const state of this.states.values()) this.disposeState(state)
220
+ this.states.clear()
221
+ }
222
+
223
+ private disposeState(state: AgentExposure): void {
224
+ state.hideActivation?.()
225
+ for (const dispose of state.toolDisposers.reverse()) dispose()
226
+ }
227
+ }
package/src/index.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @anionex/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-tools Skill and its one-shot bootstrap,
6
+ * then mount the execution tools only in Agents that load that Skill. Any
7
+ * failure leaves no model capability behind, and disposal unregisters every
8
+ * global and Agent-scoped contribution the plugin mounted.
9
+ * @module @anionex/dsh-vision-toolkit
10
+ */
11
+
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ import type {} from '@deepseek-ai/dsh-agent'
14
+ import type {} from '@deepseek-ai/dsh-session'
15
+ import type {} from '@deepseek-ai/dsh-settings'
16
+ import { ArtifactAccessController, prepareArtifactAccessKey } from './artifact-access.ts'
17
+ import {
18
+ Config,
19
+ VISION_TOOLKIT_SETTINGS_NAMESPACE,
20
+ resolveConfig,
21
+ type VisionToolkitConfig,
22
+ } from './config.ts'
23
+ import { VisionToolExposure } from './exposure.ts'
24
+ import { VisionToolkitRuntimeManager } from './runtime-manager.ts'
25
+ import { VISION_TOOLS_SKILL } from './skill.ts'
26
+ import { createVisionTools } from './tools.ts'
27
+ import { PLUGIN_VERSION } from './version.ts'
28
+ import { installVisionToolkitWeb, VisionToolkitWebBackend } from './web.ts'
29
+ import { PastedImageBackend } from './paste-images.ts'
30
+
31
+ export const name = '@anionex/dsh-vision-toolkit'
32
+
33
+ export { Config }
34
+
35
+ export const inject = ['tools', 'credentials', 'skills', 'subprocess', 'settings', 'agents', 'sessions']
36
+
37
+ /** Plugin entry: validate configuration synchronously, then mount asynchronously. */
38
+ export async function apply(ctx: Context, config: VisionToolkitConfig = {}): Promise<() => void> {
39
+ // Registration itself rejects an invalid stored section before any runtime
40
+ // or Tool becomes visible. The custom Web editor preflights runtime changes
41
+ // before persistence; hand-edited settings still fail loud here or retain
42
+ // the last serving generation when changed live.
43
+ const settings = ctx.settings.register(VISION_TOOLKIT_SETTINGS_NAMESPACE, Config, {
44
+ base: config,
45
+ applies: 'live',
46
+ validate: (value) => { resolveConfig(value) },
47
+ })
48
+ const manager = new VisionToolkitRuntimeManager(ctx)
49
+ const artifacts = new ArtifactAccessController(await prepareArtifactAccessKey())
50
+ const lifecycle = new AbortController()
51
+ const disposers: Array<() => void> = []
52
+ let operationalDisposers: { activationTool: () => void; exposure: () => void; skill: () => void } | undefined
53
+
54
+ const ensureOperational = (): void => {
55
+ if (!manager.ready || operationalDisposers !== undefined) return
56
+ const exposure = new VisionToolExposure(ctx, () => createVisionTools(
57
+ () => manager.current(),
58
+ value => artifacts.presentationMeta(value),
59
+ lifecycle.signal,
60
+ ))
61
+ let activationTool: (() => void) | undefined
62
+ let exposureDisposer: (() => void) | undefined
63
+ let skill: (() => void) | undefined
64
+ try {
65
+ activationTool = ctx.tools.register(exposure.activationTool)
66
+ skill = ctx.skills.register(VISION_TOOLS_SKILL)
67
+ exposureDisposer = exposure.install()
68
+ operationalDisposers = { activationTool, exposure: exposureDisposer, skill }
69
+ const info = manager.current().upstreamVersion
70
+ ctx.logger.info(
71
+ 'dsh-vision-toolkit %s ready (upstream %s @ %s, checkout %s)',
72
+ PLUGIN_VERSION,
73
+ info.version,
74
+ info.commit,
75
+ info.path,
76
+ )
77
+ } catch (error) {
78
+ exposureDisposer?.()
79
+ if (skill !== undefined) skill()
80
+ activationTool?.()
81
+ throw error
82
+ }
83
+ }
84
+
85
+ try {
86
+ await manager.initialize(settings.get())
87
+ ensureOperational()
88
+ } catch (error) {
89
+ const message = error instanceof Error ? error.message : String(error)
90
+ ctx.logger.error(
91
+ 'dsh-vision-toolkit %s: runtime not ready; the vision-tools skill, activation bootstrap, and Agent-scoped visual tools are NOT registered. Settings remain available for repair. %s',
92
+ PLUGIN_VERSION,
93
+ message,
94
+ )
95
+ }
96
+
97
+ const backend = new VisionToolkitWebBackend(ctx, manager, artifacts, ensureOperational)
98
+ const pastedImages = new PastedImageBackend(ctx, {
99
+ maxImageBytes: () => manager.status().activeConfig?.maxImageBytes ?? resolveConfig(settings.get()).maxImageBytes,
100
+ })
101
+ installVisionToolkitWeb(ctx, backend, artifacts, pastedImages)
102
+ disposers.push(settings.watch(async (next) => {
103
+ try {
104
+ await manager.reconfigure(next)
105
+ ensureOperational()
106
+ } catch (error) {
107
+ const message = error instanceof Error ? error.message : String(error)
108
+ ctx.logger.error('dsh-vision-toolkit: keeping the previous runtime after a refused Settings generation. %s', message)
109
+ }
110
+ }))
111
+
112
+ return () => {
113
+ lifecycle.abort()
114
+ if (operationalDisposers !== undefined) {
115
+ operationalDisposers.exposure()
116
+ operationalDisposers.activationTool()
117
+ operationalDisposers.skill()
118
+ operationalDisposers = undefined
119
+ }
120
+ for (const dispose of disposers.reverse()) dispose()
121
+ }
122
+ }
@@ -0,0 +1,234 @@
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
+ const MAX_NAME_BYTES = 180
15
+
16
+ interface PasteImageResponse {
17
+ ok: true
18
+ value: { absolutePath: string; filename: string; bytes: number }
19
+ }
20
+
21
+ interface PasteImageFailure {
22
+ ok: false
23
+ error: { code: string; message: string }
24
+ }
25
+
26
+ type PasteImageResult = PasteImageResponse | PasteImageFailure
27
+
28
+ function responseJson(res: ServerResponse, status: number, body: PasteImageResult): void {
29
+ const bytes = Buffer.from(JSON.stringify(body))
30
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
31
+ res.setHeader('Content-Length', String(bytes.length))
32
+ res.setHeader('Cache-Control', 'no-store')
33
+ res.setHeader('X-Content-Type-Options', 'nosniff')
34
+ res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'")
35
+ res.writeHead(status)
36
+ res.end(bytes)
37
+ }
38
+
39
+ function requestError(res: ServerResponse, status: number, code: string, message: string): void {
40
+ responseJson(res, status, { ok: false, error: { code, message } })
41
+ }
42
+
43
+ function message(error: unknown): string {
44
+ return error instanceof Error ? error.message : String(error)
45
+ }
46
+
47
+ function singleQuery(url: URL, key: string): string {
48
+ const values = url.searchParams.getAll(key)
49
+ if (values.length !== 1 || values[0] === undefined || values[0] === '') {
50
+ throw new TypeError(`${key} is required exactly once`)
51
+ }
52
+ return values[0]
53
+ }
54
+
55
+ function declaredSize(url: URL): number {
56
+ const value = Number(singleQuery(url, 'size'))
57
+ if (!Number.isSafeInteger(value) || value < 1) throw new TypeError('size must be a positive safe integer')
58
+ return value
59
+ }
60
+
61
+ function imageMediaType(req: IncomingMessage): string {
62
+ const value = req.headers['content-type']?.split(';', 1)[0]?.trim().toLowerCase()
63
+ if (value === undefined || !value.startsWith('image/')) throw new TypeError('Content-Type must be image/*')
64
+ return value
65
+ }
66
+
67
+ function extensionFor(mediaType: string): string {
68
+ switch (mediaType) {
69
+ case 'image/jpeg': return '.jpg'
70
+ case 'image/png': return '.png'
71
+ case 'image/gif': return '.gif'
72
+ case 'image/webp': return '.webp'
73
+ case 'image/bmp': return '.bmp'
74
+ case 'image/tiff': return '.tiff'
75
+ case 'image/avif': return '.avif'
76
+ case 'image/heic': return '.heic'
77
+ case 'image/heif': return '.heif'
78
+ case 'image/svg+xml': return '.svg'
79
+ default: return '.img'
80
+ }
81
+ }
82
+
83
+ /** Convert an untrusted browser label into one portable leaf filename. */
84
+ export function safePastedImageName(raw: string, mediaType: string): string {
85
+ const leaf = basename(raw.replaceAll('\\', '/')).normalize('NFC')
86
+ let cleaned = leaf
87
+ .replace(/[<>:"|?*\u0000-\u001f/\\]/gu, '_')
88
+ .replace(/\s+/gu, ' ')
89
+ .replace(/^\.+/u, '')
90
+ .trim()
91
+ .replace(/[. ]+$/u, '')
92
+ if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(cleaned)) cleaned = `_${cleaned}`
93
+ const fallback = `clipboard-image${extensionFor(mediaType)}`
94
+ const candidate = cleaned === '' || cleaned === '.' || cleaned === '..' ? fallback : cleaned
95
+ if (Buffer.byteLength(candidate) <= MAX_NAME_BYTES) return candidate
96
+ const extension = extname(candidate).slice(0, 20)
97
+ const budget = Math.max(1, MAX_NAME_BYTES - Buffer.byteLength(extension))
98
+ let stem = candidate.slice(0, Math.max(1, candidate.length - extension.length))
99
+ while (Buffer.byteLength(stem) > budget) stem = stem.slice(0, -1)
100
+ return `${stem}${extension}`
101
+ }
102
+
103
+ /** Reject a resolved path that is not rooted below the expected directory. */
104
+ export function ensurePathInside(root: string, target: string): void {
105
+ const rel = relative(root, target)
106
+ if (rel !== '' && (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))) {
107
+ throw new Error(`resolved pasted-image path escapes its workspace root: ${target}`)
108
+ }
109
+ }
110
+
111
+ interface PasteRoot {
112
+ writeRoot: string
113
+ visibleRoot: string
114
+ }
115
+
116
+ async function ensureManagedDirectory(workspace: string, path: string): Promise<string> {
117
+ try {
118
+ await mkdir(path, { mode: 0o700 })
119
+ } catch (error) {
120
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
121
+ }
122
+ const entry = await lstat(path)
123
+ if (entry.isSymbolicLink()) {
124
+ throw new Error(`resolved pasted-image path escapes its workspace root: symbolic link ${path}`)
125
+ }
126
+ if (!entry.isDirectory()) throw new Error(`pasted-image path is not a directory: ${path}`)
127
+ const canonical = await realpath(path)
128
+ ensurePathInside(workspace, canonical)
129
+ return canonical
130
+ }
131
+
132
+ async function sessionPasteRoot(ctx: Context, sessionId: string): Promise<PasteRoot> {
133
+ const session = ctx.sessions.get(sessionId as never)
134
+ if (session === undefined) throw new Error(`live Session not found: ${sessionId}`)
135
+ const cwd = session.header.cwd
136
+ if (cwd === undefined || !isAbsolute(cwd)) throw new Error(`Session has no absolute workspace: ${sessionId}`)
137
+
138
+ const visibleWorkspace = resolve(cwd)
139
+ const workspace = await realpath(visibleWorkspace)
140
+ const pluginRoot = join(visibleWorkspace, '.dsh-vision-toolkit')
141
+ await ensureManagedDirectory(workspace, pluginRoot)
142
+ const temporaryRoot = join(pluginRoot, 'tmp')
143
+ await ensureManagedDirectory(workspace, temporaryRoot)
144
+ const requestedRoot = join(temporaryRoot, 'pasted-images')
145
+ const root = await ensureManagedDirectory(workspace, requestedRoot)
146
+
147
+ const sessionKey = createHash('sha256').update(sessionId).digest('hex').slice(0, 20)
148
+ const requestedSessionRoot = join(requestedRoot, sessionKey)
149
+ const sessionRoot = await ensureManagedDirectory(root, requestedSessionRoot)
150
+ ensurePathInside(root, sessionRoot)
151
+ return { writeRoot: sessionRoot, visibleRoot: requestedSessionRoot }
152
+ }
153
+
154
+ async function writeImage(
155
+ req: IncomingMessage,
156
+ directory: string,
157
+ filename: string,
158
+ expectedBytes: number,
159
+ maxBytes: number,
160
+ ): Promise<string> {
161
+ if (expectedBytes > maxBytes) throw new RangeError(`image exceeds the ${maxBytes}-byte paste limit`)
162
+ const id = randomUUID()
163
+ const finalPath = join(directory, `${id}-${filename}`)
164
+ const stagingPath = join(directory, `.${id}.partial`)
165
+ ensurePathInside(directory, finalPath)
166
+ ensurePathInside(directory, stagingPath)
167
+
168
+ const handle = await open(stagingPath, 'wx', 0o600)
169
+ let received = 0
170
+ try {
171
+ for await (const chunk of req) {
172
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
173
+ received += bytes.length
174
+ if (received > expectedBytes || received > maxBytes) throw new RangeError('pasted image body exceeds its declared size')
175
+ await handle.write(bytes)
176
+ }
177
+ if (received !== expectedBytes) {
178
+ throw new Error(`pasted image body size mismatch: expected ${expectedBytes}, received ${received}`)
179
+ }
180
+ await handle.sync()
181
+ await handle.close()
182
+ await rename(stagingPath, finalPath)
183
+ return finalPath
184
+ } catch (error) {
185
+ await handle.close().catch(() => {})
186
+ await rm(stagingPath, { force: true }).catch(() => {})
187
+ throw error
188
+ }
189
+ }
190
+
191
+ /** Runtime limit face kept separate for focused backend tests. */
192
+ export interface PasteImageRuntime {
193
+ maxImageBytes(): number
194
+ }
195
+
196
+ /** Same-origin, live-Session-bound image upload endpoint. */
197
+ export class PastedImageBackend {
198
+ constructor(
199
+ private readonly ctx: Context,
200
+ private readonly runtime: PasteImageRuntime,
201
+ ) {}
202
+
203
+ async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
204
+ if (req.method !== 'POST') {
205
+ res.setHeader('Allow', 'POST')
206
+ requestError(res, 405, 'method-not-allowed', 'Use POST')
207
+ return
208
+ }
209
+ if (!sameOriginPost(req)) {
210
+ requestError(res, 403, 'origin-rejected', 'The request must originate from this DSH Web application')
211
+ return
212
+ }
213
+
214
+ try {
215
+ const url = new URL(req.url ?? PASTE_IMAGES_ROUTE, 'http://dsh.internal')
216
+ const sessionId = singleQuery(url, 'sessionId')
217
+ const size = declaredSize(url)
218
+ const mediaType = imageMediaType(req)
219
+ const filename = safePastedImageName(singleQuery(url, 'name'), mediaType)
220
+ const contentLength = req.headers['content-length']
221
+ if (contentLength !== undefined && Number(contentLength) !== size) {
222
+ throw new TypeError('Content-Length does not match the declared size')
223
+ }
224
+ const directory = await sessionPasteRoot(this.ctx, sessionId)
225
+ const writtenPath = await writeImage(req, directory.writeRoot, filename, size, this.runtime.maxImageBytes())
226
+ const absolutePath = join(directory.visibleRoot, basename(writtenPath))
227
+ responseJson(res, 201, { ok: true, value: { absolutePath, filename, bytes: size } })
228
+ } catch (error) {
229
+ const status = error instanceof RangeError ? 413 : 400
230
+ this.ctx.logger.warn('dsh-vision-toolkit pasted image rejected: %s', message(error))
231
+ requestError(res, status, 'paste-image-rejected', message(error))
232
+ }
233
+ }
234
+ }