@modesto_ma/dsh-channels 0.2.0 → 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.
Files changed (46) hide show
  1. package/README.md +115 -39
  2. package/docs/access-control-design.md +1 -1
  3. package/docs/attachment-pipeline-v2.md +318 -0
  4. package/docs/feasibility-and-design.md +54 -26
  5. package/docs/feishu-onboarding.md +123 -15
  6. package/docs/persistence.md +4 -4
  7. package/docs/refactor-plan.md +7 -6
  8. package/docs/workspace-isolation-design.md +81 -0
  9. package/lib/client.js +30 -15
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +11008 -5132
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/onboarding.ts +23 -0
  16. package/src/cards/primitives.ts +20 -0
  17. package/src/cards/streaming.ts +310 -291
  18. package/src/cleanup.ts +267 -119
  19. package/src/client/panel/cleanup-card.tsx +4 -2
  20. package/src/client/panel/workspace-tab.tsx +1 -1
  21. package/src/config.ts +221 -13
  22. package/src/domain.ts +141 -3
  23. package/src/feishu/agent-context.ts +66 -16
  24. package/src/feishu/inbound-attachments.ts +440 -3
  25. package/src/feishu/manifest.ts +270 -0
  26. package/src/feishu/message-read.ts +1333 -0
  27. package/src/feishu/native-attachments.ts +998 -0
  28. package/src/feishu/pending-attachments.ts +562 -0
  29. package/src/feishu/tools.ts +2345 -0
  30. package/src/feishu-channel.ts +55 -0
  31. package/src/host/agent.ts +55 -0
  32. package/src/host/attachments.ts +210 -0
  33. package/src/host/index.ts +1 -0
  34. package/src/index.ts +8 -6
  35. package/src/routing/inbound.ts +730 -81
  36. package/src/service.ts +536 -50
  37. package/src/session/isolation.ts +277 -0
  38. package/src/session/manager.ts +120 -23
  39. package/src/session/repair.ts +2 -3
  40. package/src/session/routes.ts +2 -0
  41. package/src/session-commands.ts +41 -14
  42. package/src/settings/contract.ts +43 -21
  43. package/src/settings/overlay.ts +124 -13
  44. package/src/settings/surface.ts +147 -14
  45. package/src/streamer.ts +70 -30
  46. package/src/workspaces.ts +74 -31
@@ -0,0 +1,998 @@
1
+ /**
2
+ * DSH native attachment storage layer for inbound Feishu resources.
3
+ *
4
+ * Two backends exist and one switch selects them (`attachmentStore`):
5
+ *
6
+ * - `native` (default): images are validated and durably committed through
7
+ * `ctx.attachments.saveImage`, files are streamed into
8
+ * `ctx.attachments.saveFileStream` without ever being collected in memory.
9
+ * Images come back as an {@link ImageBlock}, files as a {@link FileBlock}
10
+ * plus the deterministic handle text and read-only host path that keeps the
11
+ * resource readable when a tool result cannot carry a `FileBlock`.
12
+ * - `archive`: the legacy behavior — resources are written into the topic
13
+ * workspace `attachments/` directory by the existing `AttachmentIntake`.
14
+ *
15
+ * Native is downgraded to `archive` when the host does not mount
16
+ * `ctx.attachments`; the downgrade is reported (plan + warning) instead of
17
+ * failing the turn, so headless and older deployments keep working.
18
+ *
19
+ * Every operation returns a structured result instead of throwing: P0 showed
20
+ * that an exception reaching a tool result becomes an unreadable
21
+ * `[object Object]` in session history, and inbound attachments must degrade
22
+ * per resource without aborting the rest of the message.
23
+ *
24
+ * @module dsh-channels/feishu/native-attachments
25
+ */
26
+
27
+ import type { FeishuChannel, NormalizedMessage, ResourceChunkStream } from '../feishu-channel.ts'
28
+ import { sniffImageMediaType } from '../feishu-channel.ts'
29
+ import type { AttachmentStoreMode } from '../config.ts'
30
+ import type { AgentContext } from '../host/agent.ts'
31
+ import type {
32
+ AdmittedPromptContentPart,
33
+ AttachmentAdmissionPart,
34
+ AttachmentErrorCode,
35
+ AttachmentStore,
36
+ FileAttachmentRef,
37
+ FileBlock,
38
+ ImageAttachmentLimits,
39
+ ImageAttachmentRef,
40
+ ImageBlock,
41
+ ImageMediaType,
42
+ } from '../host/attachments.ts'
43
+ import type { AttachmentFailure, AttachmentIntake, AttachmentResource } from './inbound-attachments.ts'
44
+ import {
45
+ attachmentResources,
46
+ describeFailure,
47
+ EMPTY_POST_PLACEHOLDER,
48
+ hasAttachmentPayload,
49
+ sanitizeFileName,
50
+ stripAttachmentPlaceholders,
51
+ } from './inbound-attachments.ts'
52
+
53
+ /** Cordis service name of the DSH native attachment store. */
54
+ export const ATTACHMENT_SERVICE_NAME = 'attachments'
55
+
56
+ /** Fixed reason reported whenever the host attachment service is absent. */
57
+ export const ATTACHMENT_SERVICE_UNMOUNTED_REASON = '宿主未挂载原生附件库(ctx.attachments 不可用)'
58
+
59
+ /** File name used when a source resource carries no usable display name. */
60
+ export const DEFAULT_FILE_NAME = 'file.bin'
61
+
62
+ /**
63
+ * In-memory image cap used when the host publishes no image limits. Feishu
64
+ * caps inbound images well below this, so the value only guards against a
65
+ * misbehaving host/stream, never against a legitimate upload.
66
+ */
67
+ export const FALLBACK_MAX_IMAGE_BYTES = 20 * 1024 * 1024
68
+
69
+ // ---- Service discovery --------------------------------------------------
70
+
71
+ /**
72
+ * Minimal context surface that can resolve the host attachment service.
73
+ *
74
+ * Both the plugin's host context and an Agent-scoped context satisfy it: the
75
+ * direct property is attempted first (it throws in cordis scopes that did not
76
+ * inject the service) and the safe `get()` lookup resolves host-wide services
77
+ * (including from the plugin's own context, which is a sibling of the
78
+ * attachment plugin in the loader composition).
79
+ */
80
+ export interface AttachmentServiceContext {
81
+ attachments?: unknown
82
+ get?(name: string): unknown
83
+ }
84
+
85
+ /** The host attachment store is mounted and usable. */
86
+ export interface AttachmentServiceMounted {
87
+ readonly mounted: true
88
+ readonly service: AttachmentStore
89
+ /** Deployment-resolved image limits, when the host publishes them. */
90
+ readonly limits?: ImageAttachmentLimits
91
+ }
92
+
93
+ /** The host has no attachment store in this Agent scope (degrade to archive). */
94
+ export interface AttachmentServiceUnmounted {
95
+ readonly mounted: false
96
+ readonly code: 'ATTACHMENT_SERVICE_UNMOUNTED'
97
+ readonly reason: string
98
+ }
99
+
100
+ /** Whether the native attachment service can be used for this Agent scope. */
101
+ export type AttachmentServiceState = AttachmentServiceMounted | AttachmentServiceUnmounted
102
+
103
+ /**
104
+ * Structural guard for the narrow service surface. All operations listed in
105
+ * {@link AttachmentStore} are required: a partially matching object is not a
106
+ * usable attachment store and must degrade to `archive` instead of failing
107
+ * later inside a turn.
108
+ */
109
+ export function isAttachmentStore(value: unknown): value is AttachmentStore {
110
+ if (value === null || typeof value !== 'object') return false
111
+ const candidate = value as Partial<AttachmentStore>
112
+ return typeof candidate.saveImage === 'function'
113
+ && typeof candidate.saveFileStream === 'function'
114
+ && typeof candidate.fileHostPath === 'function'
115
+ && typeof candidate.imageHostPath === 'function'
116
+ && typeof candidate.admitPromptContent === 'function'
117
+ }
118
+
119
+ /** Mount state for one already-resolved service value. */
120
+ export function attachmentServiceStateOf(service: AttachmentStore | undefined): AttachmentServiceState {
121
+ if (service === undefined || !isAttachmentStore(service)) {
122
+ return { mounted: false, code: 'ATTACHMENT_SERVICE_UNMOUNTED', reason: ATTACHMENT_SERVICE_UNMOUNTED_REASON }
123
+ }
124
+ const limits = service.imageLimits
125
+ return { mounted: true, service, ...(limits === undefined ? {} : { limits }) }
126
+ }
127
+
128
+ /**
129
+ * Resolve the native attachment service from one context.
130
+ *
131
+ * Cordis throws when an un-injected service property is read, so both the
132
+ * direct property and the safe `ctx.get` lookup are attempted inside guards:
133
+ * a missing service must read as "not mounted", never as a thrown error.
134
+ */
135
+ export function resolveAttachmentService(
136
+ ctx: AttachmentServiceContext | undefined,
137
+ ): AttachmentStore | undefined {
138
+ if (ctx === undefined || ctx === null) return undefined
139
+ try {
140
+ const direct = ctx.attachments
141
+ if (isAttachmentStore(direct)) return direct
142
+ } catch {
143
+ // Not injected into this scope: fall through to the safe lookup.
144
+ }
145
+ try {
146
+ const viaGet = ctx.get?.(ATTACHMENT_SERVICE_NAME)
147
+ if (isAttachmentStore(viaGet)) return viaGet
148
+ } catch {
149
+ // Exotic host: treat the lookup itself as "not mounted".
150
+ }
151
+ return undefined
152
+ }
153
+
154
+ /** Mount state for one Agent scope (never throws). */
155
+ export function attachmentServiceState(ctx: AgentContext | undefined): AttachmentServiceState {
156
+ return attachmentServiceStateOf(resolveAttachmentService(ctx))
157
+ }
158
+
159
+ // ---- Structured failures ------------------------------------------------
160
+
161
+ /** Stable failure codes surfaced by this storage layer. */
162
+ export type AttachmentFailureCode =
163
+ | AttachmentErrorCode
164
+ | 'ATTACHMENT_SERVICE_UNMOUNTED'
165
+ | 'ABORTED'
166
+ | 'DOWNLOAD_FAILED'
167
+ | 'FOLDER_UNSUPPORTED'
168
+ | 'ATTACHMENT_STORE_ERROR'
169
+
170
+ /** One structured, model-safe failure (no raw bytes, no internal stack). */
171
+ export interface AttachmentFailureResult {
172
+ readonly ok: false
173
+ readonly code: AttachmentFailureCode
174
+ /** User/model-facing reason; stable wording, safe to place in a prompt. */
175
+ readonly reason: string
176
+ }
177
+
178
+ /** Result of one attachment operation; failures are values, never throws. */
179
+ export type AttachmentResult<T> = { readonly ok: true; readonly value: T } | AttachmentFailureResult
180
+
181
+ const FAILURE_REASONS: Record<AttachmentFailureCode, string> = {
182
+ // Host attachment codes, mapped to user-facing Chinese wording.
183
+ TOO_MANY_IMAGES: '图片数量超过单条消息上限',
184
+ IMAGES_TOO_LARGE: '图片总字节超过单条消息上限',
185
+ UNSUPPORTED_IMAGE_TYPE: '不支持的图片格式(仅支持 PNG/JPEG/WebP/GIF)',
186
+ INVALID_IMAGE_BASE64: '图片数据编码无效',
187
+ INVALID_IMAGE: '图片内容损坏或无法解码',
188
+ IMAGE_TYPE_MISMATCH: '图片内容与声明的类型不一致',
189
+ IMAGE_TOO_LARGE: '图片超过大小上限',
190
+ IMAGE_TOO_MANY_PIXELS: '图片像素总数超过上限',
191
+ IMAGE_DIMENSION_TOO_LARGE: '图片宽高超过上限',
192
+ INVALID_FILE_BASE64: '文件数据编码无效',
193
+ INVALID_ATTACHMENT_REF: '附件引用无效',
194
+ ATTACHMENT_CORRUPT: '附件内容校验失败(文件可能损坏或被替换)',
195
+ ATTACHMENT_WRITE_FAILED: '附件写入失败',
196
+ ATTACHMENT_NOT_FOUND: '附件不存在(可能已被删除)',
197
+ ATTACHMENT_READ_FAILED: '附件读取失败',
198
+ ATTACHMENT_PROJECTION_UNSUPPORTED: '当前部署不支持该附件投影',
199
+ ATTACHMENT_FILES_UNSUPPORTED: '当前部署不支持文件附件',
200
+ // Storage-layer codes.
201
+ ATTACHMENT_SERVICE_UNMOUNTED: ATTACHMENT_SERVICE_UNMOUNTED_REASON,
202
+ ABORTED: '操作已取消',
203
+ DOWNLOAD_FAILED: '飞书附件下载失败',
204
+ FOLDER_UNSUPPORTED: '飞书文件夹消息暂不支持下载',
205
+ ATTACHMENT_STORE_ERROR: '原生附件库操作失败',
206
+ }
207
+
208
+ const ATTACHMENT_ERROR_CODES: ReadonlySet<string> = new Set<AttachmentErrorCode>([
209
+ 'TOO_MANY_IMAGES', 'IMAGES_TOO_LARGE', 'UNSUPPORTED_IMAGE_TYPE', 'INVALID_IMAGE_BASE64',
210
+ 'INVALID_IMAGE', 'IMAGE_TYPE_MISMATCH', 'IMAGE_TOO_LARGE', 'IMAGE_TOO_MANY_PIXELS',
211
+ 'IMAGE_DIMENSION_TOO_LARGE', 'INVALID_FILE_BASE64', 'INVALID_ATTACHMENT_REF',
212
+ 'ATTACHMENT_CORRUPT', 'ATTACHMENT_WRITE_FAILED', 'ATTACHMENT_NOT_FOUND',
213
+ 'ATTACHMENT_READ_FAILED', 'ATTACHMENT_PROJECTION_UNSUPPORTED', 'ATTACHMENT_FILES_UNSUPPORTED',
214
+ ])
215
+
216
+ function isAttachmentErrorCode(code: string): code is AttachmentErrorCode {
217
+ return ATTACHMENT_ERROR_CODES.has(code)
218
+ }
219
+
220
+ /** User/model-facing wording for one stable failure code. */
221
+ export function attachmentFailureReason(code: AttachmentFailureCode): string {
222
+ return FAILURE_REASONS[code]
223
+ }
224
+
225
+ /**
226
+ * Extract a host attachment failure code from an arbitrary thrown value.
227
+ * Prefers the service classifier when the host exposes one (it survives
228
+ * duplicate package installations), then falls back to the structural
229
+ * `error.code` contract.
230
+ */
231
+ export function attachmentErrorCodeOf(error: unknown, service?: AttachmentStore): AttachmentErrorCode | undefined {
232
+ if (error === null || typeof error !== 'object') return undefined
233
+ const code = (error as { code?: unknown }).code
234
+ if (typeof code !== 'string' || !isAttachmentErrorCode(code)) return undefined
235
+ if (service?.isAttachmentError !== undefined) {
236
+ // Prefer the host classifier so an unrelated error that happens to carry a
237
+ // colliding `code` field is not misreported as an attachment failure.
238
+ let classified = false
239
+ try {
240
+ classified = service.isAttachmentError(error) === true
241
+ } catch {
242
+ classified = false
243
+ }
244
+ if (!classified) return undefined
245
+ }
246
+ return code
247
+ }
248
+
249
+ function isAbortError(error: unknown): boolean {
250
+ return error !== null && typeof error === 'object'
251
+ && (error as { name?: unknown }).name === 'AbortError'
252
+ }
253
+
254
+ /** Cap for one error description placed into a model-safe reason. */
255
+ const MAX_ERROR_TEXT = 500
256
+
257
+ function errorMessage(error: unknown): string {
258
+ return truncateErrorText(rawErrorMessage(error))
259
+ }
260
+
261
+ function rawErrorMessage(error: unknown): string {
262
+ if (error instanceof Error) return error.message
263
+ if (typeof error === 'string' && error !== '') return error
264
+ if (error === null || error === undefined) return '未知错误'
265
+ if (typeof error === 'object') {
266
+ // Never surface `[object Object]` (the P0 unreadable-failure lesson); a
267
+ // circular or unserializable value falls back to a fixed description.
268
+ try {
269
+ const text = JSON.stringify(error)
270
+ if (text !== undefined && text !== '{}') return text
271
+ } catch {
272
+ // Circular structure: fall through to the fixed description.
273
+ }
274
+ return '未知错误(不可序列化的对象)'
275
+ }
276
+ return String(error)
277
+ }
278
+
279
+ /** Keep one untrusted error description short enough for prompt/tool output. */
280
+ function truncateErrorText(text: string): string {
281
+ return text.length <= MAX_ERROR_TEXT ? text : `${text.slice(0, MAX_ERROR_TEXT)}…(已截断)`
282
+ }
283
+
284
+ /** Build the cancellation error passed through a cancelled byte source. */
285
+ function abortReason(signal: AbortSignal): Error {
286
+ const reason: unknown = signal.reason
287
+ if (reason instanceof Error) return reason
288
+ const error = new Error('The operation was aborted')
289
+ error.name = 'AbortError'
290
+ return error
291
+ }
292
+
293
+ // ---- File handle text ---------------------------------------------------
294
+
295
+ /**
296
+ * Deterministic model-facing handle for one stored file: the name, byte size,
297
+ * digest prefix, and the read-only host path to read on demand.
298
+ *
299
+ * Mirrors the host's `FileBlock` projection (`fileHandleText` in
300
+ * `@deepseek-ai/dsh-llm`) so the fallback used when a tool result cannot
301
+ * carry a `FileBlock` is byte-identical to what the host would have produced.
302
+ */
303
+ export function formatFileHandle(ref: FileAttachmentRef, hostPath?: string): string {
304
+ const identity = `File ${JSON.stringify(ref.name)} (${ref.bytes} bytes, sha256:${digestPrefix(ref)})`
305
+ if (hostPath === undefined || hostPath === '') {
306
+ return `[${identity} was uploaded, but the current execution environment cannot access a readable path. Report that limitation if its contents are needed; do not claim to have read it.]`
307
+ }
308
+ return `[${identity}: verbatim read-only copy saved at ${JSON.stringify(hostPath)}. Read that path with your file tools when its contents are needed; copy it to a writable location before modifying it. When delegating file work, include this saved path in the delegation prompt; only subagents sharing this execution environment can read it.]`
309
+ }
310
+
311
+ /** First 8 hex characters of a content-addressed attachment id. */
312
+ function digestPrefix(ref: FileAttachmentRef): string {
313
+ const raw = String(ref.attachmentId)
314
+ const hex = raw.startsWith('sha256:') ? raw.slice('sha256:'.length) : raw
315
+ return hex.slice(0, 8)
316
+ }
317
+
318
+ // ---- Native attachment store -------------------------------------------
319
+
320
+ /** Input of one image admission (bytes already downloaded). */
321
+ export interface SaveImageInput {
322
+ /** Complete encoded image bytes; callers must bound the download size. */
323
+ bytes: Uint8Array
324
+ /** Caller-declared media type (facade detectors leave it undefined). */
325
+ mediaType?: ImageMediaType
326
+ /** Optional display name; sanitized before reaching the host. */
327
+ name?: string
328
+ }
329
+
330
+ /** One durably stored image and the content block built from it. */
331
+ export interface StoredImage {
332
+ readonly block: ImageBlock
333
+ readonly ref: ImageAttachmentRef
334
+ }
335
+
336
+ /** Input of one file streaming admission. */
337
+ export interface SaveFileInput {
338
+ /** Ordered byte chunks; the source is consumed exactly once, never buffered. */
339
+ source: AsyncIterable<Uint8Array>
340
+ /** Optional display name; sanitized before reaching the host. */
341
+ name?: string
342
+ /** Cooperative cancellation honored between chunks and forwarded to the host. */
343
+ signal?: AbortSignal
344
+ }
345
+
346
+ /** One durably stored file, its content block, and the readable fallback. */
347
+ export interface StoredFile {
348
+ readonly block: FileBlock
349
+ readonly ref: FileAttachmentRef
350
+ /** Deterministic handle text for tool results that cannot carry a `FileBlock`. */
351
+ readonly handleText: string
352
+ /** Read-only host path, when the backend maps one. */
353
+ readonly hostPath?: string
354
+ /** Why the read-only path could not be resolved; absent when `hostPath` exists. */
355
+ readonly readPathError?: AttachmentFailureResult
356
+ }
357
+
358
+ export interface NativeAttachmentStoreDeps {
359
+ /**
360
+ * Live lookup of the host attachment service. Called per operation so a
361
+ * service that unmounts (Agent disposal) reads as unmounted instead of
362
+ * handing out a stale reference.
363
+ */
364
+ service(): AttachmentStore | undefined
365
+ }
366
+
367
+ /**
368
+ * Thin, degradation-aware wrapper over one host `ctx.attachments` service.
369
+ *
370
+ * The wrapper owns no state and never imports the host package; construct it
371
+ * inside an Agent scope that injected `attachments`, or resolve it lazily with
372
+ * {@link nativeAttachmentStoreOf}.
373
+ */
374
+ export class NativeAttachmentStore {
375
+ constructor(private readonly deps: NativeAttachmentStoreDeps) {}
376
+
377
+ /** Current mount state of the underlying service. */
378
+ state(): AttachmentServiceState {
379
+ return attachmentServiceStateOf(this.deps.service())
380
+ }
381
+
382
+ /**
383
+ * Validate and durably store one image.
384
+ *
385
+ * The declared media type is verified against the bytes when both are
386
+ * available; an unknown raster is refused before touching the store. Limits
387
+ * published by the host are enforced here as a fast pre-check, and the store
388
+ * still re-validates decode, dimensions, and pixels.
389
+ */
390
+ async saveImage(input: SaveImageInput): Promise<AttachmentResult<StoredImage>> {
391
+ const mounted = this.state()
392
+ if (!mounted.mounted) return { ok: false, code: mounted.code, reason: mounted.reason }
393
+
394
+ const detected = sniffImageMediaType(input.bytes)
395
+ if (input.mediaType !== undefined && detected !== undefined && detected !== input.mediaType) {
396
+ return {
397
+ ok: false,
398
+ code: 'IMAGE_TYPE_MISMATCH',
399
+ reason: `图片内容与声明的类型不一致(声明 ${input.mediaType},实际 ${detected})`,
400
+ }
401
+ }
402
+ const mediaType = input.mediaType ?? detected
403
+ if (mediaType === undefined) {
404
+ return { ok: false, code: 'UNSUPPORTED_IMAGE_TYPE', reason: FAILURE_REASONS.UNSUPPORTED_IMAGE_TYPE }
405
+ }
406
+ const limits = mounted.limits
407
+ if (limits !== undefined) {
408
+ // The host owns authoritative validation; a malformed limits object must
409
+ // not throw here (every operation in this module returns a value).
410
+ try {
411
+ const maxBytes = limits.maxImageBytes
412
+ if (Number.isFinite(maxBytes) && maxBytes > 0 && input.bytes.byteLength > maxBytes) {
413
+ return {
414
+ ok: false,
415
+ code: 'IMAGE_TOO_LARGE',
416
+ reason: `图片超过大小上限(${input.bytes.byteLength} > ${maxBytes} 字节)`,
417
+ }
418
+ }
419
+ const mediaTypes: readonly string[] | undefined = Array.isArray(limits.mediaTypes)
420
+ ? limits.mediaTypes
421
+ : undefined
422
+ if (mediaTypes !== undefined && mediaTypes.length > 0 && !mediaTypes.includes(mediaType)) {
423
+ return { ok: false, code: 'UNSUPPORTED_IMAGE_TYPE', reason: FAILURE_REASONS.UNSUPPORTED_IMAGE_TYPE }
424
+ }
425
+ } catch {
426
+ // Fall through: the store re-validates the image authoritatively.
427
+ }
428
+ }
429
+
430
+ const name = sanitizeFileName(input.name)
431
+ try {
432
+ const ref = await mounted.service.saveImage({
433
+ data: input.bytes,
434
+ mediaType,
435
+ ...(name === undefined ? {} : { name }),
436
+ })
437
+ return { ok: true, value: { block: { type: 'image', attachment: ref }, ref } }
438
+ } catch (error) {
439
+ return this.failure(error, mounted.service)
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Durably store one streamed file.
445
+ *
446
+ * The source is forwarded chunk by chunk (with a cancellation guard between
447
+ * yields); nothing is collected here or held after the host returns its
448
+ * reference. Cancellation is reported as `ABORTED`, never as a raw error.
449
+ */
450
+ async saveFile(input: SaveFileInput): Promise<AttachmentResult<StoredFile>> {
451
+ const mounted = this.state()
452
+ if (!mounted.mounted) return { ok: false, code: mounted.code, reason: mounted.reason }
453
+ if (input.signal?.aborted === true) {
454
+ return { ok: false, code: 'ABORTED', reason: FAILURE_REASONS.ABORTED }
455
+ }
456
+
457
+ const name = sanitizeFileName(input.name) ?? DEFAULT_FILE_NAME
458
+ try {
459
+ const ref = await mounted.service.saveFileStream({
460
+ data: cancelableBytes(input.source, input.signal),
461
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
462
+ name,
463
+ })
464
+ return { ok: true, value: this.storedFile(mounted.service, ref) }
465
+ } catch (error) {
466
+ return this.failure(error, mounted.service, input.signal)
467
+ }
468
+ }
469
+
470
+ /** Resolve the read-only host path of one stored file (structured failure when not). */
471
+ fileReadPath(ref: FileAttachmentRef): AttachmentResult<string> {
472
+ const mounted = this.state()
473
+ if (!mounted.mounted) return { ok: false, code: mounted.code, reason: mounted.reason }
474
+ return readPathOf(ref, 'file', mounted.service)
475
+ }
476
+
477
+ /** Resolve the read-only host path of one stored image (structured failure when not). */
478
+ imageReadPath(ref: ImageAttachmentRef): AttachmentResult<string> {
479
+ const mounted = this.state()
480
+ if (!mounted.mounted) return { ok: false, code: mounted.code, reason: mounted.reason }
481
+ return readPathOf(ref, 'image', mounted.service)
482
+ }
483
+
484
+ /**
485
+ * Admit one host prompt: uploaded images become durable references, text and
486
+ * resolved file references pass through. Wrapped so a prefetch failure stays
487
+ * a readable value instead of aborting the turn.
488
+ */
489
+ async admitPromptContent(
490
+ content: readonly AttachmentAdmissionPart[],
491
+ ): Promise<AttachmentResult<AdmittedPromptContentPart[]>> {
492
+ const mounted = this.state()
493
+ if (!mounted.mounted) return { ok: false, code: mounted.code, reason: mounted.reason }
494
+ try {
495
+ return { ok: true, value: await mounted.service.admitPromptContent(content) }
496
+ } catch (error) {
497
+ return this.failure(error, mounted.service)
498
+ }
499
+ }
500
+
501
+ /** Build the model-facing value of one stored file (block, handle text, path). */
502
+ private storedFile(service: AttachmentStore, ref: FileAttachmentRef): StoredFile {
503
+ const read = readPathOf(ref, 'file', service)
504
+ const hostPath = read.ok ? read.value : undefined
505
+ return {
506
+ block: { type: 'file', attachment: ref },
507
+ ref,
508
+ handleText: formatFileHandle(ref, hostPath),
509
+ ...(hostPath === undefined ? {} : { hostPath }),
510
+ ...(read.ok ? {} : { readPathError: read }),
511
+ }
512
+ }
513
+
514
+ /** Classify one thrown value into a structured, model-safe failure. */
515
+ private failure(error: unknown, service: AttachmentStore, signal?: AbortSignal): AttachmentFailureResult {
516
+ if (signal?.aborted === true || isAbortError(error)) {
517
+ return { ok: false, code: 'ABORTED', reason: FAILURE_REASONS.ABORTED }
518
+ }
519
+ const code = attachmentErrorCodeOf(error, service)
520
+ if (code !== undefined) return { ok: false, code, reason: FAILURE_REASONS[code] }
521
+ return {
522
+ ok: false,
523
+ code: 'ATTACHMENT_STORE_ERROR',
524
+ reason: `${FAILURE_REASONS.ATTACHMENT_STORE_ERROR}:${errorMessage(error)}`,
525
+ }
526
+ }
527
+ }
528
+
529
+ /** Lazy store bound to one Agent scope; unmounted scopes degrade per call. */
530
+ export function nativeAttachmentStoreOf(ctx: AgentContext | undefined): NativeAttachmentStore {
531
+ return new NativeAttachmentStore({ service: () => resolveAttachmentService(ctx) })
532
+ }
533
+
534
+ function readPathOf(
535
+ ref: FileAttachmentRef | ImageAttachmentRef,
536
+ kind: 'file' | 'image',
537
+ service: AttachmentStore,
538
+ ): AttachmentResult<string> {
539
+ try {
540
+ const path = kind === 'file'
541
+ ? service.fileHostPath(ref as FileAttachmentRef)
542
+ : service.imageHostPath(ref as ImageAttachmentRef)
543
+ if (path === undefined || path === '') {
544
+ return {
545
+ ok: false,
546
+ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED',
547
+ reason: '当前附件后端不提供主机只读路径(无法映射到执行环境路径)',
548
+ }
549
+ }
550
+ return { ok: true, value: path }
551
+ } catch (error) {
552
+ const code = attachmentErrorCodeOf(error, service)
553
+ if (code !== undefined) return { ok: false, code, reason: FAILURE_REASONS[code] }
554
+ return {
555
+ ok: false,
556
+ code: 'ATTACHMENT_READ_FAILED',
557
+ reason: `读取附件只读路径失败:${errorMessage(error)}`,
558
+ }
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Wrap one byte source so an aborted signal stops the pull loop between
564
+ * chunks. The host still receives the signal and owns its own aborts.
565
+ */
566
+ async function* cancelableBytes(
567
+ source: AsyncIterable<Uint8Array>,
568
+ signal?: AbortSignal,
569
+ ): AsyncGenerator<Uint8Array> {
570
+ if (signal === undefined) {
571
+ yield* source
572
+ return
573
+ }
574
+ for await (const chunk of source) {
575
+ if (signal.aborted) throw abortReason(signal)
576
+ yield chunk
577
+ }
578
+ if (signal.aborted) throw abortReason(signal)
579
+ }
580
+
581
+ /**
582
+ * Read one image resource completely (bounded by `cap` bytes). Images are
583
+ * platform-capped far below the file path, so one bounded in-memory read is
584
+ * the correct shape here; files never take this path.
585
+ */
586
+ async function collectImageBytes(
587
+ source: AsyncIterable<Uint8Array>,
588
+ cap: number,
589
+ ): Promise<AttachmentResult<Uint8Array>> {
590
+ const chunks: Uint8Array[] = []
591
+ let total = 0
592
+ for await (const chunk of source) {
593
+ total += chunk.byteLength
594
+ if (total > cap) {
595
+ return {
596
+ ok: false,
597
+ code: 'IMAGE_TOO_LARGE',
598
+ reason: `图片超过大小上限(已读取 ${total} 字节,上限 ${cap} 字节)`,
599
+ }
600
+ }
601
+ chunks.push(chunk)
602
+ }
603
+ const bytes = new Uint8Array(total)
604
+ let offset = 0
605
+ for (const chunk of chunks) {
606
+ bytes.set(chunk, offset)
607
+ offset += chunk.byteLength
608
+ }
609
+ return { ok: true, value: bytes }
610
+ }
611
+
612
+ // ---- Backend selection / archive fallback ------------------------------
613
+
614
+ /** Why an inbound message could not use its configured backend. */
615
+ export type AttachmentStorageFallback = 'attachment-service-unmounted' | 'forwarded-record'
616
+
617
+ /** Resolved backend choice for the current deployment. */
618
+ export interface AttachmentStoragePlan {
619
+ readonly configured: AttachmentStoreMode
620
+ /** Backend actually used for new inbound bytes. */
621
+ readonly effective: AttachmentStoreMode
622
+ /** Present when `effective` differs from `configured` or the message path is limited. */
623
+ readonly fallback?: AttachmentStorageFallback
624
+ /** One-shot operator warning for a degraded deployment. */
625
+ readonly warning?: string
626
+ }
627
+
628
+ /**
629
+ * Choose the storage backend. A configured `archive` is always honored; a
630
+ * configured `native` without a mounted service downgrades to `archive` with
631
+ * an explicit fallback and warning (never a failed turn).
632
+ */
633
+ export function planAttachmentStorage(input: {
634
+ configured: AttachmentStoreMode
635
+ service: AttachmentStore | undefined
636
+ }): AttachmentStoragePlan {
637
+ if (input.configured === 'archive') {
638
+ return { configured: 'archive', effective: 'archive' }
639
+ }
640
+ if (input.service !== undefined) {
641
+ return { configured: 'native', effective: 'native' }
642
+ }
643
+ return {
644
+ configured: 'native',
645
+ effective: 'archive',
646
+ fallback: 'attachment-service-unmounted',
647
+ warning: 'dsh-channels: attachmentStore=native 但宿主未挂载 ctx.attachments,回退话题 attachments/ 工作区落盘',
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Effective backend for new inbound bytes.
653
+ *
654
+ * An explicit `archive` configuration always wins (legacy behavior, including
655
+ * synchronous workspace downloads). A `native` configuration degrades to
656
+ * `archive` when:
657
+ *
658
+ * - the host does not mount the attachment service (headless/older hosts must
659
+ * keep the pre-v2 pipeline instead of losing their attachments); or
660
+ * - the model-facing fetch tool is disabled: files in the native pipeline are
661
+ * only obtainable through `feishu_attachment_fetch`, so a manifest without
662
+ * that tool would promise an operation the model cannot perform. Falling
663
+ * back keeps their bytes readable from the workspace archive instead.
664
+ *
665
+ * With `acceptAttachments: false` the backend is `archive` too: the legacy
666
+ * disabled path is the known-inert behavior (no downloads, no turn for
667
+ * attachment-only messages), and the archive guidance never advertises a tool
668
+ * that is not mounted.
669
+ */
670
+ export function effectiveAttachmentMode(
671
+ configured: AttachmentStoreMode,
672
+ service: AttachmentStore | undefined,
673
+ attachmentFetchToolEnabled = true,
674
+ attachmentsEnabled = true,
675
+ ): AttachmentStoreMode {
676
+ if (configured === 'archive' || service === undefined) return 'archive'
677
+ if (!attachmentsEnabled || !attachmentFetchToolEnabled) return 'archive'
678
+ return 'native'
679
+ }
680
+
681
+ /** One image stored natively for an inbound message. */
682
+ export interface StoredNativeImage {
683
+ readonly kind: 'image'
684
+ readonly resource: AttachmentResource
685
+ readonly ref: ImageAttachmentRef
686
+ readonly block: ImageBlock
687
+ }
688
+
689
+ /** One file stored natively for an inbound message. */
690
+ export interface StoredNativeFile {
691
+ readonly kind: 'file'
692
+ readonly resource: AttachmentResource
693
+ readonly ref: FileAttachmentRef
694
+ readonly block: FileBlock
695
+ readonly handleText: string
696
+ readonly hostPath?: string
697
+ readonly readPathError?: AttachmentFailureResult
698
+ }
699
+
700
+ /** One inbound resource stored by the native backend. */
701
+ export type StoredAttachment = StoredNativeImage | StoredNativeFile
702
+
703
+ /** Result of storing one message's attachment payload. */
704
+ export interface InboundAttachmentStoreResult {
705
+ /** Backend that actually handled this message. */
706
+ readonly mode: AttachmentStoreMode
707
+ /** Present when the configured backend could not be used for this message. */
708
+ readonly fallback?: AttachmentStorageFallback
709
+ /** Resources durably stored (native mode only). */
710
+ readonly saved: number
711
+ /** Resources that failed to download/store. */
712
+ readonly failed: number
713
+ /** Per-resource failure notes (user-facing), mirroring the archive intake. */
714
+ readonly failures: readonly AttachmentFailure[]
715
+ /** Native content blocks, ready for prompt assembly or tool results. */
716
+ readonly stored: readonly StoredAttachment[]
717
+ /** True when the message carried no prompt text after removing resource placeholders. */
718
+ readonly attachmentOnly: boolean
719
+ /** Remaining prompt text after attachment placeholders are removed. */
720
+ readonly text: string
721
+ }
722
+
723
+ export interface InboundAttachmentStoreDeps {
724
+ /** Live `acceptAttachments` gate (disabled keeps attachment messages inert). */
725
+ enabled(): boolean
726
+ /** Live configured backend (`attachmentStore`). */
727
+ mode(): AttachmentStoreMode
728
+ /** Live native service lookup (undefined = unmounted). */
729
+ service(): AttachmentStore | undefined
730
+ /** Live Feishu channel; undefined after shutdown. */
731
+ channel(): FeishuChannel | undefined
732
+ /** Existing workspace archive, used by archive mode and every fallback. */
733
+ archive: AttachmentIntake
734
+ /** Structured warning sink (never throws). */
735
+ warn(message: string, ...args: unknown[]): void
736
+ }
737
+
738
+ /**
739
+ * Inbound attachment storage entry point shared by every backend.
740
+ *
741
+ * This is the layer the admission task wires in place of the direct
742
+ * `AttachmentIntake.archive()` call: it resolves the backend once per message,
743
+ * stores trigger-message resources natively, and preserves the legacy
744
+ * workspace archive whenever native is unavailable or configured off.
745
+ */
746
+ export class InboundAttachmentStore {
747
+ private warnedUnmounted = false
748
+
749
+ constructor(private readonly deps: InboundAttachmentStoreDeps) {}
750
+
751
+ /** Current backend decision for a new inbound message. */
752
+ plan(): AttachmentStoragePlan {
753
+ return planAttachmentStorage({ configured: this.deps.mode(), service: this.deps.service() })
754
+ }
755
+
756
+ /**
757
+ * Store every image/file resource (and, for forwarded records, the legacy
758
+ * text archive) of one inbound message.
759
+ *
760
+ * The v2 native admission pipeline does NOT call this for triggering
761
+ * messages: it prefetches images and leaves files lazy behind the prompt
762
+ * manifest. This facade is used by the `archive` / missing-host fallback
763
+ * (synchronous workspace download) and by the storage-layer tests. Its
764
+ * `forwarded-record` text fallback therefore only runs for archive
765
+ * deployments; reading merged-forward transcripts in native deployments is
766
+ * owned by `quote_history_tools`.
767
+ *
768
+ * @returns `undefined` when the message carries no attachment payload;
769
+ * otherwise a structured result whose `text` is the remaining prompt.
770
+ */
771
+ async store(msg: NormalizedMessage, directory: string): Promise<InboundAttachmentStoreResult | undefined> {
772
+ if (!hasAttachmentPayload(msg)) return undefined
773
+
774
+ // Snapshot the live config/service once per message: the backend decision,
775
+ // the one-shot warning and the storage call all describe the same moment
776
+ // (a later service unmount surfaces as a per-resource structured failure).
777
+ const configured = this.deps.mode()
778
+ const service = this.deps.service()
779
+ const plan = planAttachmentStorage({ configured, service })
780
+ const prompt = remainingPromptText(msg, msg.rawContentType === 'merge_forward')
781
+
782
+ if (!this.deps.enabled()) {
783
+ // Disabled means inert for attachment-only messages too: strip the
784
+ // resource placeholders and keep the same attachment-only classification
785
+ // as the enabled paths, so the caller starts no turn when no prompt text
786
+ // is left (config: "Disabled keeps attachment messages inert").
787
+ return {
788
+ mode: plan.effective,
789
+ saved: 0,
790
+ failed: 0,
791
+ failures: [],
792
+ stored: [],
793
+ attachmentOnly: prompt.attachmentOnly,
794
+ text: prompt.text,
795
+ }
796
+ }
797
+ if (plan.warning !== undefined && !this.warnedUnmounted) {
798
+ this.warnedUnmounted = true
799
+ this.deps.warn(plan.warning)
800
+ }
801
+
802
+ // Forwarded records are plain text, not DSH attachments: keep the legacy
803
+ // text archive for them until the quote/history task owns that resource.
804
+ if (plan.effective === 'native' && service !== undefined && msg.rawContentType !== 'merge_forward') {
805
+ return await this.storeNative(msg, service)
806
+ }
807
+ return await this.storeArchive(msg, directory, plan)
808
+ }
809
+
810
+ /** Legacy backend: workspace `attachments/` through the existing intake. */
811
+ private async storeArchive(
812
+ msg: NormalizedMessage,
813
+ directory: string,
814
+ plan: AttachmentStoragePlan,
815
+ ): Promise<InboundAttachmentStoreResult> {
816
+ const archived = await this.deps.archive.archive(msg, directory)
817
+ const fallback: AttachmentStorageFallback | undefined = msg.rawContentType === 'merge_forward'
818
+ && plan.effective === 'native'
819
+ ? 'forwarded-record'
820
+ : plan.fallback
821
+ return {
822
+ mode: 'archive',
823
+ ...(fallback === undefined ? {} : { fallback }),
824
+ saved: archived.saved,
825
+ failed: archived.failed,
826
+ failures: archived.failures,
827
+ stored: [],
828
+ attachmentOnly: archived.attachmentOnly,
829
+ text: archived.text,
830
+ }
831
+ }
832
+
833
+ /** Native backend: images/files through `ctx.attachments`. */
834
+ private async storeNative(
835
+ msg: NormalizedMessage,
836
+ service: AttachmentStore,
837
+ ): Promise<InboundAttachmentStoreResult> {
838
+ const resources = attachmentResources(msg)
839
+ const { text: remainingText, attachmentOnly } = remainingPromptText(msg, false)
840
+
841
+ const channel = this.deps.channel()
842
+ if (channel === undefined) {
843
+ return {
844
+ mode: 'native',
845
+ saved: 0,
846
+ failed: resources.length,
847
+ failures: resources.map(resource => ({
848
+ name: displayName(resource),
849
+ reason: '飞书连接未运行,附件未保存',
850
+ })),
851
+ stored: [],
852
+ attachmentOnly,
853
+ text: remainingText,
854
+ }
855
+ }
856
+
857
+ // Live lookup inside the store: if the host unmounts mid-message, the
858
+ // remaining resources fail as "unmounted" instead of reusing a stale handle.
859
+ const native = new NativeAttachmentStore({ service: () => this.deps.service() })
860
+ const imageCap = imageByteCap(service)
861
+ const stored: StoredAttachment[] = []
862
+ const failures: AttachmentFailure[] = []
863
+
864
+ for (const resource of resources) {
865
+ const name = displayName(resource)
866
+ if (resource.folder === true) {
867
+ failures.push({ name, reason: FAILURE_REASONS.FOLDER_UNSUPPORTED })
868
+ continue
869
+ }
870
+ try {
871
+ const result = resource.type === 'image'
872
+ ? await storeImageResource(native, channel, msg, resource, imageCap)
873
+ : await storeFileResource(native, channel, msg, resource)
874
+ if (result.ok) {
875
+ stored.push(result.value)
876
+ } else {
877
+ failures.push({ name, reason: result.reason })
878
+ this.deps.warn(
879
+ 'dsh-channels: storing attachment %s of message %s failed (%s): %s',
880
+ resource.fileKey, msg.messageId, result.code, result.reason,
881
+ )
882
+ }
883
+ } catch (error) {
884
+ failures.push({ name, reason: describeFailure(error) })
885
+ this.deps.warn(
886
+ 'dsh-channels: storing attachment %s of message %s failed: %s',
887
+ resource.fileKey, msg.messageId, String(error),
888
+ )
889
+ }
890
+ }
891
+
892
+ return {
893
+ mode: 'native',
894
+ saved: stored.length,
895
+ failed: failures.length,
896
+ failures,
897
+ stored,
898
+ attachmentOnly,
899
+ text: remainingText,
900
+ }
901
+ }
902
+ }
903
+
904
+ /** Largest image the native path will buffer before refusing it. */
905
+ function imageByteCap(service: AttachmentStore): number {
906
+ const max = service.imageLimits?.maxImageBytes
907
+ if (max !== undefined && Number.isFinite(max) && max > 0) return max
908
+ return FALLBACK_MAX_IMAGE_BYTES
909
+ }
910
+
911
+ /**
912
+ * Strip attachment placeholders and classify what prompt text is left. Shared
913
+ * by the enabled native path and the disabled (inert) branch so both agree on
914
+ * the attachment-only decision.
915
+ */
916
+ function remainingPromptText(
917
+ msg: NormalizedMessage,
918
+ forwarded: boolean,
919
+ ): { text: string; attachmentOnly: boolean } {
920
+ const stripped = stripAttachmentPlaceholders(msg.content ?? '', forwarded)
921
+ const text = stripped === EMPTY_POST_PLACEHOLDER ? '' : stripped
922
+ return { text, attachmentOnly: text === '' }
923
+ }
924
+
925
+ /** User-facing name for one attachment (mirrors the archive intake). */
926
+ function displayName(resource: AttachmentResource): string {
927
+ if (resource.fileName !== undefined && resource.fileName !== '') return resource.fileName
928
+ return resource.type === 'image' ? '图片' : '文件'
929
+ }
930
+
931
+ async function storeImageResource(
932
+ native: NativeAttachmentStore,
933
+ channel: FeishuChannel,
934
+ msg: NormalizedMessage,
935
+ resource: AttachmentResource,
936
+ cap: number,
937
+ ): Promise<AttachmentResult<StoredNativeImage>> {
938
+ let stream: ResourceChunkStream
939
+ try {
940
+ stream = await channel.downloadResource(msg.messageId, resource.fileKey, 'image')
941
+ } catch (error) {
942
+ return { ok: false, code: 'DOWNLOAD_FAILED', reason: describeFailure(error) }
943
+ }
944
+ const bytes = await collectImageBytes(stream, cap)
945
+ if (!bytes.ok) return bytes
946
+ const saved = await native.saveImage({
947
+ bytes: bytes.value,
948
+ ...(resource.fileName === undefined ? {} : { name: resource.fileName }),
949
+ })
950
+ if (!saved.ok) return saved
951
+ return {
952
+ ok: true,
953
+ value: { kind: 'image', resource, ref: saved.value.ref, block: saved.value.block },
954
+ }
955
+ }
956
+
957
+ async function storeFileResource(
958
+ native: NativeAttachmentStore,
959
+ channel: FeishuChannel,
960
+ msg: NormalizedMessage,
961
+ resource: AttachmentResource,
962
+ ): Promise<AttachmentResult<StoredNativeFile>> {
963
+ let stream: ResourceChunkStream
964
+ try {
965
+ stream = await channel.downloadResource(msg.messageId, resource.fileKey, 'file')
966
+ } catch (error) {
967
+ return { ok: false, code: 'DOWNLOAD_FAILED', reason: describeFailure(error) }
968
+ }
969
+ const saved = await native.saveFile({
970
+ source: stream,
971
+ ...(resource.fileName === undefined ? {} : { name: resource.fileName }),
972
+ })
973
+ if (!saved.ok) {
974
+ // A stream error surfaces from inside the host write. Only a failure whose
975
+ // text matches the known Feishu download refusals (400/403) is re-labeled
976
+ // as a download failure; a real storage fault (ENOSPC, ...) keeps its
977
+ // ATTACHMENT_STORE_ERROR code and its wording.
978
+ if (saved.code === 'ATTACHMENT_STORE_ERROR') {
979
+ const friendly = describeFailure(new Error(saved.reason))
980
+ if (friendly !== saved.reason) {
981
+ return { ok: false, code: 'DOWNLOAD_FAILED', reason: friendly }
982
+ }
983
+ }
984
+ return saved
985
+ }
986
+ return {
987
+ ok: true,
988
+ value: {
989
+ kind: 'file',
990
+ resource,
991
+ ref: saved.value.ref,
992
+ block: saved.value.block,
993
+ handleText: saved.value.handleText,
994
+ ...(saved.value.hostPath === undefined ? {} : { hostPath: saved.value.hostPath }),
995
+ ...(saved.value.readPathError === undefined ? {} : { readPathError: saved.value.readPathError }),
996
+ },
997
+ }
998
+ }