dsh-acp-enhanced 0.3.6 → 0.4.0

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/README-zh.md CHANGED
@@ -16,6 +16,11 @@ ACP 线上。
16
16
  `agent_thought_chunk`),取消/重试不留半截输出
17
17
  - **完整遥测**:上下文用量环 + 缓存命中率 / TPS / 输入-输出-推理 token / 工具耗时 /
18
18
  轮次计数(`usage_update._meta` 携带全量明细)
19
+ - **图片支持(多模态)**:当 dsh 组合挂载了附件存储(dsh 0.1.1-rc.2+,`dsh-base`
20
+ 默认装配 `dsh-attachment-local`)时,会声明 `promptCapabilities.image` 并把粘贴/
21
+ 上传的图片持久化进 harness 附件存储——支持视觉的模型(如 `deepseek-v4-flash-vision-exp`)
22
+ 可按线序原生读取,图文交替不乱序。旧版栈(无附件存储)自动降级:不声明 image、
23
+ 收到图片 prompt 明确报错。
19
24
 
20
25
  ### 模型与权限
21
26
 
@@ -211,11 +216,13 @@ node scripts/acp-client-tools.mjs # 客户端工具测试(模拟 Zed 的 f
211
216
  node scripts/acp-mcp-test.mjs # MCP 挂载测试(无模型调用)
212
217
  node scripts/acp-smoke-keyless.mjs # keyless 冒烟(CI 用)
213
218
  node scripts/acp-resume-test.mjs # 会话恢复测试
219
+ node scripts/codec-image-test.mjs # 图片编解码单元测试(无网络,假 store)
220
+ node scripts/acp-image-e2e.mjs # 图片能力端到端(vision 模型段需 API key)
214
221
  ```
215
222
 
216
223
  ## 已知限制
217
224
 
218
- 仅 baseline prompt(无图片/音频附件)、文本按块粒度流式、每会话同时一个 in-flight
225
+ 不支持音频附件(不声明 audio 能力)、文本按块粒度流式、每会话同时一个 in-flight
219
226
  prompt。MCP 支持 stdio 与 streamable HTTP(不声明 legacy SSE / `acp` 传输)。
220
227
  `session/close` / `session/fork` / `session/resume` 未实现(不声明能力,合规客户端
221
228
  不会调用);`session/delete` 因 dsh 持久化无官方删除 API,采用直接删除后端目录的方式。
package/README.md CHANGED
@@ -18,6 +18,12 @@ over the ACP wire.
18
18
  torn output
19
19
  - **Full telemetry**: context usage ring plus cache hit rate / TPS / input-output-reasoning
20
20
  tokens / tool timing / turn counts (`usage_update._meta` carries the full breakdown)
21
+ - **Image support (multimodal)**: when the dsh composition mounts an attachment store
22
+ (dsh 0.1.1-rc.2+ with `dsh-attachment-local`, the default in dsh-base), `promptCapabilities.image`
23
+ is advertised and pasted/uploaded images are ingested into the harness's durable attachment
24
+ store — a vision-capable model (e.g. `deepseek-v4-flash-vision-exp`) reads them natively,
25
+ in wire order with surrounding text. Older stacks (no attachment store) automatically
26
+ downgrade: image is not advertised and an image prompt is refused with a clear error.
21
27
 
22
28
  ### Model & permissions
23
29
 
@@ -229,13 +235,15 @@ node scripts/acp-client-tools.mjs # client-tool tests (mocks Zed fs/terminal
229
235
  node scripts/acp-mcp-test.mjs # MCP mount test (no model calls)
230
236
  node scripts/acp-smoke-keyless.mjs # keyless boot smoke (CI)
231
237
  node scripts/acp-resume-test.mjs # session resume test
238
+ node scripts/codec-image-test.mjs # image-codec unit tests (no network, fake store)
239
+ node scripts/acp-image-e2e.mjs # image capability e2e (vision-model leg needs an API key)
232
240
  ```
233
241
 
234
242
  ## Known limitations
235
243
 
236
- Baseline prompts only (no image/audio attachments), text streams at block granularity,
237
- one in-flight prompt per session. MCP supports stdio and streamable HTTP (legacy SSE /
238
- `acp` transports are not advertised).
244
+ Audio attachments are not supported (audio capability is not advertised), text streams at
245
+ block granularity, one in-flight prompt per session. MCP supports stdio and streamable HTTP
246
+ (legacy SSE / `acp` transports are not advertised).
239
247
  `session/close` / `session/fork` / `session/resume` are not implemented (capabilities
240
248
  undeclared, compliant clients will not call them); `session/delete` removes the
241
249
  persisted directory directly because dsh persistence has no official delete API.
package/lib/codec.js CHANGED
@@ -57,6 +57,191 @@ export function promptHasUnsupportedContent(prompt) {
57
57
  return prompt.some((block) => block.type !== 'text' && block.type !== 'resource_link')
58
58
  }
59
59
 
60
+ /** Raster media types the harness attachment seam admits (dsh-attachment). */
61
+ const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
62
+
63
+ /**
64
+ * Canonicalize an ACP image MIME type for the harness attachment store.
65
+ * @param mimeType - the client-declared type (may be `image/jpg`, which
66
+ * the raster vocabulary spells `image/jpeg`).
67
+ * @returns the harness media type, or `undefined` when the value is not a
68
+ * raster we ingest.
69
+ */
70
+ export function canonicalImageMediaType(mimeType) {
71
+ const lower = String(mimeType ?? '').trim().toLowerCase()
72
+ const mapped = lower === 'image/jpg' ? 'image/jpeg' : lower
73
+ return IMAGE_MEDIA_TYPES.has(mapped) ? mapped : undefined
74
+ }
75
+
76
+ /** Error for prompt content this adapter does not advertise. */
77
+ export class UnsupportedPromptContentError extends Error {
78
+ constructor(contentType) {
79
+ super(`unsupported prompt content type: ${contentType}`)
80
+ this.name = 'UnsupportedPromptContentError'
81
+ }
82
+ }
83
+
84
+ /** Error when an advertised image cannot be ingested (limits, decode, store). */
85
+ export class PromptImageError extends Error {
86
+ constructor(message, options) {
87
+ super(message, options)
88
+ this.name = 'PromptImageError'
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Narrow an unknown `ctx.attachments` value to the ingest surface used by
94
+ * {@link convertPrompt}. Capability detection instead of version detection:
95
+ * the service exists (with methods) on dsh 0.1.1-rc.2+, while the 0.1.0-rc.x
96
+ * seam is an empty shell without `validateImage`/`saveImage` — and a
97
+ * deployment without the attachment-local row has no service at all. All
98
+ * three fall back to `undefined` here, so the caller simply does not
99
+ * advertise image support.
100
+ * @param value - `ctx.get('attachments')` (or anything shaped like it).
101
+ * @returns the ingest surface, or `undefined` when absent/empty.
102
+ */
103
+ export function attachmentIngestOf(value) {
104
+ if (value === null || typeof value !== 'object') return undefined
105
+ const candidate = value
106
+ if (typeof candidate.validateImage !== 'function' || typeof candidate.saveImage !== 'function') {
107
+ return undefined
108
+ }
109
+ const limits = candidate.imageLimits
110
+ if (limits === undefined
111
+ || typeof limits.maxImagesPerMessage !== 'number'
112
+ || typeof limits.maxMessageImageBytes !== 'number'
113
+ || typeof limits.maxImageBytes !== 'number') {
114
+ return undefined
115
+ }
116
+ return candidate
117
+ }
118
+
119
+ function decodeImageData(data) {
120
+ if (typeof data !== 'string' || data.length === 0) throw new PromptImageError('image data is empty')
121
+ const decoded = Buffer.from(data, 'base64')
122
+ if (decoded.byteLength === 0) throw new PromptImageError('image data is empty')
123
+ return new Uint8Array(decoded)
124
+ }
125
+
126
+ /** Display name from an image URI's leaf, with local path info stripped. */
127
+ function imageName(uri) {
128
+ if (typeof uri !== 'string' || uri.length === 0) return undefined
129
+ let leaf
130
+ try {
131
+ leaf = new URL(uri).pathname.split('/').filter(Boolean).at(-1)
132
+ } catch {
133
+ leaf = uri.split(/[/\\]/).filter(Boolean).at(-1)
134
+ }
135
+ if (leaf === undefined || leaf.length === 0) return undefined
136
+ try {
137
+ return decodeURIComponent(leaf)
138
+ } catch {
139
+ return leaf
140
+ }
141
+ }
142
+
143
+ /** Flush accumulated text into the block list (keeps 图文交替 wire order). */
144
+ function flushText(parts, blocks) {
145
+ const text = parts.join('')
146
+ parts.length = 0
147
+ if (text.length > 0) blocks.push({ type: 'text', text })
148
+ }
149
+
150
+ /**
151
+ * Convert an ACP prompt's content blocks into harness user-message content
152
+ * blocks. Text and resource links concatenate in wire order; when the
153
+ * composition provides an attachment ingest, ACP `image` blocks are decoded,
154
+ * admission-checked against the store limits, and durably committed with
155
+ * `saveImage`, keeping block order with surrounding text. Binary `resource`
156
+ * payloads and audio stay rejected — silently dropping them would be worse
157
+ * than refusing.
158
+ * @param prompt - ACP `session/prompt` content, in wire order.
159
+ * @param attachments - `ctx.attachments` ingest when the composition mounted
160
+ * one; omit (or pass `undefined`) to refuse images.
161
+ * @returns `{ blocks, displayText }` ready for `createUserMessage` plus a
162
+ * human-readable text rendering (used for titles, transcripts, commands).
163
+ * @throws UnsupportedPromptContentError for audio/binary blocks, or images
164
+ * with no ingest; PromptImageError when advertised image bytes fail
165
+ * admission.
166
+ */
167
+ export async function convertPrompt(prompt, attachments) {
168
+ const preparedImages = []
169
+ for (const block of prompt) {
170
+ if (block?.type !== 'image') continue
171
+ if (attachments === undefined) throw new UnsupportedPromptContentError('image')
172
+ const mediaType = canonicalImageMediaType(block.mimeType)
173
+ if (mediaType === undefined) throw new PromptImageError(`unsupported image media type: ${block.mimeType}`)
174
+ const data = decodeImageData(block.data)
175
+ preparedImages.push({
176
+ data,
177
+ mediaType,
178
+ ...imageName(block.uri) === undefined ? {} : { name: imageName(block.uri) },
179
+ })
180
+ }
181
+
182
+ if (preparedImages.length > 0) {
183
+ const { maxImagesPerMessage, maxMessageImageBytes, maxImageBytes } = attachments.imageLimits
184
+ if (preparedImages.length > maxImagesPerMessage) {
185
+ throw new PromptImageError('prompt exceeds the configured image-count limit')
186
+ }
187
+ const totalBytes = preparedImages.reduce((sum, image) => sum + image.data.byteLength, 0)
188
+ if (totalBytes > maxMessageImageBytes) {
189
+ throw new PromptImageError('prompt exceeds the configured aggregate image-byte limit')
190
+ }
191
+ for (const image of preparedImages) {
192
+ if (image.data.byteLength > maxImageBytes) {
193
+ throw new PromptImageError('image exceeds the configured encoded-byte limit')
194
+ }
195
+ try {
196
+ await attachments.validateImage({ data: image.data, mediaType: image.mediaType, ...image.name === undefined ? {} : { name: image.name } })
197
+ } catch (error) {
198
+ const message = error instanceof Error ? error.message : String(error)
199
+ throw new PromptImageError(`image validation failed: ${message}`, { cause: error })
200
+ }
201
+ }
202
+ }
203
+
204
+ const parts = []
205
+ const display = []
206
+ const blocks = []
207
+ let imageIndex = 0
208
+ for (const block of prompt) {
209
+ switch (block?.type) {
210
+ case 'text':
211
+ parts.push(block.text)
212
+ display.push(block.text)
213
+ break
214
+ case 'resource_link':
215
+ // Mirror the baseline bridge's textual reference so plain clients
216
+ // keep file mentions without the bridge dropping them.
217
+ parts.push(`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`)
218
+ display.push(`@${block.name}`)
219
+ break
220
+ case 'image': {
221
+ if (attachments === undefined) throw new UnsupportedPromptContentError('image')
222
+ const prepared = preparedImages[imageIndex]
223
+ imageIndex += 1
224
+ if (prepared === undefined) throw new PromptImageError('image block was not prepared')
225
+ flushText(parts, blocks)
226
+ let attachment
227
+ try {
228
+ attachment = await attachments.saveImage(prepared)
229
+ } catch (error) {
230
+ const message = error instanceof Error ? error.message : String(error)
231
+ throw new PromptImageError(message, { cause: error })
232
+ }
233
+ blocks.push({ type: 'image', attachment })
234
+ display.push(`[image${prepared.name === undefined ? '' : `: ${prepared.name}`}]`)
235
+ break
236
+ }
237
+ default:
238
+ throw new UnsupportedPromptContentError(block?.type ?? 'unknown')
239
+ }
240
+ }
241
+ flushText(parts, blocks)
242
+ return { blocks, displayText: display.join(' ').trim() }
243
+ }
244
+
60
245
  /** Kramdown attribute-style inline markup (SiYuan exports), including
61
246
  * truncation-damaged tails — titles are byte-budgeted upstream, so a cut
62
247
  * can land mid-attribute (unterminated `"` or no closing `]`):
package/lib/index.js CHANGED
@@ -47,7 +47,15 @@ import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/d
47
47
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
48
48
  import { defineTool } from '@deepseek-ai/dsh-tools'
49
49
  import { SessionId } from '@deepseek-ai/dsh-session'
50
- import { acpPromptToText, promptHasUnsupportedContent, sanitizeWireTitle, turnEndToStopReason, usageTelemetry } from './codec.js'
50
+ import {
51
+ attachmentIngestOf,
52
+ convertPrompt,
53
+ PromptImageError,
54
+ sanitizeWireTitle,
55
+ turnEndToStopReason,
56
+ UnsupportedPromptContentError,
57
+ usageTelemetry,
58
+ } from './codec.js'
51
59
 
52
60
  /** Agent version advertised on the ACP wire — read from package.json so the
53
61
  * handshake can never drift from the released package version. */
@@ -560,6 +568,21 @@ export function apply(ctx, config) {
560
568
  // Final accounting when the adapter reported usage on the message
561
569
  // rather than as a stream chunk.
562
570
  if (event.data.usage !== undefined) emitUsage(record, event.data.usage, event)
571
+ // Model-produced image blocks never stream through the text chunk
572
+ // path; surface them as a wire placeholder so the reply is not
573
+ // silently missing a block (ACP clients render the text).
574
+ for (const block of event.data.message?.content ?? []) {
575
+ if (block?.type === 'image' && block.attachment?.attachmentId !== undefined) {
576
+ notify({
577
+ sessionId: session.header.id,
578
+ update: {
579
+ sessionUpdate: 'agent_message_chunk',
580
+ messageId: record.messageId,
581
+ content: { type: 'text', text: `[image attachment ${block.attachment.attachmentId}]` },
582
+ },
583
+ })
584
+ }
585
+ }
563
586
  break
564
587
  case 'turn/start': {
565
588
  record.turnCount += 1
@@ -1585,6 +1608,11 @@ export function apply(ctx, config) {
1585
1608
  const parts = []
1586
1609
  for (const block of content ?? []) {
1587
1610
  if (block?.type === 'text' && typeof block.text === 'string') parts.push(block.text)
1611
+ else if (block?.type === 'image' && block.attachment?.attachmentId !== undefined) {
1612
+ // Model-produced images replay as a textual reference — the wire
1613
+ // surface does not carry attachment bytes.
1614
+ parts.push(`[image attachment ${block.attachment.attachmentId}]`)
1615
+ }
1588
1616
  }
1589
1617
  return parts.join('\n')
1590
1618
  }
@@ -1689,7 +1717,15 @@ export function apply(ctx, config) {
1689
1717
  // workspace root on session/new / session/load instead of showing
1690
1718
  // the "doesn't currently support multi-root workspaces" callout).
1691
1719
  sessionCapabilities: { list: {}, delete: {}, additionalDirectories: {} },
1692
- promptCapabilities: { image: false, audio: false, embeddedContext: false },
1720
+ // Image support is a live capability: the harness advertises
1721
+ // `image: true` only when the composition mounted a working
1722
+ // attachment store (duck-typed, so dsh 0.1.1-rc.2+ with
1723
+ // dsh-attachment-local enables it and older stacks report false).
1724
+ promptCapabilities: {
1725
+ image: attachmentIngestOf(ctx.get('attachments')) !== undefined,
1726
+ audio: false,
1727
+ embeddedContext: false,
1728
+ },
1693
1729
  // Stdio MCP servers always work; streamable HTTP maps onto
1694
1730
  // dsh-mcp-client's second transport. Legacy SSE does not.
1695
1731
  mcpCapabilities: { http: true, sse: false },
@@ -1810,10 +1846,28 @@ export function apply(ctx, config) {
1810
1846
  if (record.inflight !== undefined) {
1811
1847
  throw invalidParams('a prompt is already in flight for this session')
1812
1848
  }
1813
- if (promptHasUnsupportedContent(params.prompt)) {
1814
- throw invalidParams('only text and resource_link prompt content is supported')
1849
+ // Capability-gated content conversion: text/resource_link always work;
1850
+ // image blocks are ingested through the composition's attachment
1851
+ // store when one is mounted (advertised on initialize). A client that
1852
+ // sends unsupported content gets the exact failing kind — never a
1853
+ // silent drop — and an image that fails admission (bad type, over
1854
+ // limits, store rejection) surfaces its precise reason.
1855
+ let blocks
1856
+ let text
1857
+ try {
1858
+ const ingest = attachmentIngestOf(ctx.get('attachments'))
1859
+ const converted = await convertPrompt(params.prompt, ingest)
1860
+ blocks = converted.blocks
1861
+ text = converted.displayText
1862
+ } catch (error) {
1863
+ if (error instanceof UnsupportedPromptContentError) {
1864
+ throw invalidParams(error.message)
1865
+ }
1866
+ if (error instanceof PromptImageError) {
1867
+ throw invalidParams(`image rejected: ${error.message}`)
1868
+ }
1869
+ throw error
1815
1870
  }
1816
- const text = acpPromptToText(params.prompt)
1817
1871
  if (text.trim().length === 0) throw invalidParams('empty prompt')
1818
1872
 
1819
1873
  // Insurance: by the first prompt the client is guaranteed to know the
@@ -1861,7 +1915,7 @@ export function apply(ctx, config) {
1861
1915
  if (ctx.agents.get(record.agent.id) !== record.agent) {
1862
1916
  throw internalError('prompt was not queued: the agent was disposed outside the bridge')
1863
1917
  }
1864
- const message = createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
1918
+ const message = createUserMessage({ content: blocks, source: { kind: 'user' } })
1865
1919
  if (process.env.ACP_DEBUG) process.stderr.write(`[acp-debug] followup queued, agent phase=${record.agent.phase?.kind} inboxPending=${record.agent.inbox?.hasPending}\n`)
1866
1920
  const stopReason = await new Promise((resolve, reject) => {
1867
1921
  const inflight = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-acp-enhanced",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "Enhanced ACP server for DeepSeek Harness: block-level streaming, usage/stat telemetry (cache hit rate, token speed, input/output tokens, context length, turns, tool timing), model & reasoning-effort switching, and permission-preset control over the ACP wire (Zed-friendly)",
5
5
  "keywords": [
6
6
  "dsh",