@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
@@ -23,8 +23,9 @@ import { mkdir, open, readdir, rename, rm, stat, writeFile } from 'node:fs/promi
23
23
  import path from 'node:path'
24
24
  import { Readable } from 'node:stream'
25
25
  import { pipeline } from 'node:stream/promises'
26
- import type { FeishuChannel, NormalizedMessage } from '../feishu-channel.ts'
26
+ import type { FeishuChannel, NormalizedMessage, ResourceChunkStream } from '../feishu-channel.ts'
27
27
  import { sniffImageMediaType } from '../feishu-channel.ts'
28
+ import type { ImageBlock } from '../host/attachments.ts'
28
29
 
29
30
  /** Workspace subdirectory that holds every archived attachment. */
30
31
  export const ATTACHMENTS_DIR = 'attachments'
@@ -69,8 +70,12 @@ export async function adoptStagedAttachments(stagingDir: string, workspacePath:
69
70
  return adopted
70
71
  }
71
72
 
72
- /** The SDK placeholder for a rich-text message that carried no visible text. */
73
- const EMPTY_POST_PLACEHOLDER = '[rich text message]'
73
+ /**
74
+ * The SDK placeholder for a rich-text message that carried no visible text.
75
+ * Exported so the native storage layer classifies attachment-only messages
76
+ * with exactly the same rule as the workspace archive.
77
+ */
78
+ export const EMPTY_POST_PLACEHOLDER = '[rich text message]'
74
79
 
75
80
  /** Placeholders the SDK leaves in normalized content for attachment payloads. */
76
81
  const IMAGE_PLACEHOLDER = /!\[image\]\([^)]*\)/gu
@@ -153,6 +158,47 @@ export function hasAttachmentPayload(msg: NormalizedMessage): boolean {
153
158
  return attachmentResources(msg).length > 0
154
159
  }
155
160
 
161
+ /** One inbound attachment payload classified without downloading a byte. */
162
+ export interface InboundAttachmentState {
163
+ /** Whether the message carries an attachment payload (resources or a merged-forward record). */
164
+ readonly hasPayload: boolean
165
+ /** Decoded image/file resources, including `post` `files[]`, in message order. */
166
+ readonly resources: readonly AttachmentResource[]
167
+ /** Whether this is a `merge_forward` message (its record is plain text, not a resource). */
168
+ readonly forwarded: boolean
169
+ /** Prompt text left after removing attachment placeholders. */
170
+ readonly text: string
171
+ /** No prompt text remains once the placeholders are stripped (attachment-only message). */
172
+ readonly attachmentOnly: boolean
173
+ }
174
+
175
+ /**
176
+ * Classify one inbound message's attachment payload without downloading
177
+ * anything or touching state outside the message itself.
178
+ *
179
+ * The rules are exactly the ones the archive intake and the native storage
180
+ * layer already use, so admission can decide "silent register" versus "start a
181
+ * turn" before any Session exists: no resources and no merged-forward record
182
+ * means "no payload" and the raw content is the prompt; otherwise placeholders
183
+ * are stripped and an empty remainder marks an attachment-only message.
184
+ */
185
+ export function inboundAttachmentState(msg: NormalizedMessage): InboundAttachmentState {
186
+ const resources = attachmentResources(msg)
187
+ const forwarded = msg.rawContentType === 'merge_forward'
188
+ if (resources.length === 0 && !forwarded) {
189
+ return {
190
+ hasPayload: false,
191
+ resources: [],
192
+ forwarded: false,
193
+ text: msg.content ?? '',
194
+ attachmentOnly: false,
195
+ }
196
+ }
197
+ const stripped = stripAttachmentPlaceholders(msg.content ?? '', forwarded)
198
+ const text = stripped === EMPTY_POST_PLACEHOLDER ? '' : stripped
199
+ return { hasPayload: true, resources, forwarded, text, attachmentOnly: text === '' }
200
+ }
201
+
156
202
  /**
157
203
  * Recover the files a rich-text message attached.
158
204
  *
@@ -434,3 +480,394 @@ async function sniffExtension(file: string): Promise<string> {
434
480
  await handle?.close().catch(() => undefined)
435
481
  }
436
482
  }
483
+
484
+ // ---- Trigger-message image prefetch -------------------------------------
485
+
486
+ /** Outcome of storing one prefetched image in the DSH native attachment library. */
487
+ export type ImagePrefetchSaveResult =
488
+ | { readonly ok: true; readonly block: ImageBlock }
489
+ | { readonly ok: false; readonly reason: string }
490
+
491
+ /** Dependency-injected input of one trigger-message image prefetch. */
492
+ export interface ImagePrefetchInput {
493
+ /** Triggering message; its id addresses every resource download. */
494
+ readonly msg: NormalizedMessage
495
+ /** Candidate resources in prompt order (non-image and folder entries are skipped). */
496
+ readonly resources: readonly AttachmentResource[]
497
+ /** Live Feishu channel; undefined means the connection is not running. */
498
+ readonly channel: FeishuChannel | undefined
499
+ /**
500
+ * Persist one downloaded image in the native attachment library. Failures
501
+ * are returned as values; a thrown error is contained per image as well.
502
+ */
503
+ saveImage(bytes: Uint8Array, name?: string): Promise<ImagePrefetchSaveResult>
504
+ /** Per-image byte cap. */
505
+ readonly maxImageBytes: number
506
+ /** Total byte budget of this whole prefetch. */
507
+ readonly maxTotalBytes: number
508
+ /** Short whole-prefetch deadline in milliseconds (non-positive = already expired). */
509
+ readonly timeoutMs: number
510
+ /** Structured warning sink (optional; never allowed to break the prefetch). */
511
+ warn?(message: string, ...args: unknown[]): void
512
+ }
513
+
514
+ /** Result of one trigger-message image prefetch; failures are values. */
515
+ export interface PrefetchedImages {
516
+ /** Successfully stored image blocks, in input order (skipped entries excluded). */
517
+ readonly blocks: readonly ImageBlock[]
518
+ /** `file_key`s whose images were stored (the manifest must omit these). */
519
+ readonly loadedFileKeys: ReadonlySet<string>
520
+ /** One readable reason per image that was not stored, in processing order. */
521
+ readonly failures: readonly AttachmentFailure[]
522
+ }
523
+
524
+ /** Fixed reason for prefetch failures caused by a disconnected channel. */
525
+ const PREFETCH_CHANNEL_UNAVAILABLE_REASON = '飞书连接未运行'
526
+ /** Whole-prefetch timeout reason used when an in-flight image was interrupted. */
527
+ const PREFETCH_TIMEOUT_REASON = '图片预取超时'
528
+ /** Timeout reason for images that were never attempted after the deadline. */
529
+ const PREFETCH_TIMEOUT_UNPROCESSED_REASON = '图片预取超时,未处理'
530
+ /** Reason recorded for images after the total byte budget was exhausted. */
531
+ const PREFETCH_TOTAL_LIMIT_SKIP_REASON = '超过预取总字节上限,已跳过预取'
532
+ /** Cap for one readable prefetch failure reason (prompt/tool-safe). */
533
+ const PREFETCH_MAX_REASON_TEXT = 500
534
+
535
+ function prefetchTimeoutReason(timeoutMs: number): string {
536
+ return `${PREFETCH_TIMEOUT_REASON}(超过 ${timeoutMs} 毫秒)`
537
+ }
538
+
539
+ function imageLimitReason(readBytes: number, cap: number): string {
540
+ return `图片超过预取大小上限(已读取 ${readBytes} 字节,上限 ${cap} 字节)`
541
+ }
542
+
543
+ function totalLimitReason(readBytes: number, cap: number): string {
544
+ return `超过预取总字节上限(已读取 ${readBytes} 字节,上限 ${cap} 字节)`
545
+ }
546
+
547
+ /**
548
+ * Prefetch the images of one triggering message into the native attachment
549
+ * library before the first model turn.
550
+ *
551
+ * Images are downloaded strictly in order, so the returned blocks keep the
552
+ * message order. Every failure (channel unavailable, size cap, total budget,
553
+ * timeout, download, storage) is contained per image and reported as a value;
554
+ * this function never throws and never mutates its input. The whole prefetch
555
+ * shares one short deadline: on expiry the already-stored blocks are kept and
556
+ * the current plus all unprocessed images are reported as timeouts, so a slow
557
+ * download can never block the turn for long.
558
+ *
559
+ * Known limitation: the channel byte stream cannot be cancelled, so a stalled
560
+ * download is abandoned rather than aborted at the transport layer. The
561
+ * deadline aborts the collection loop between chunks, which keeps an
562
+ * abandoned download from buffering further bytes in this process. Likewise a
563
+ * `saveImage` call that is already in flight cannot be recalled; the deadline
564
+ * only guarantees that the prefetch returns without waiting for it.
565
+ */
566
+ export async function prefetchImageAttachments(input: ImagePrefetchInput): Promise<PrefetchedImages> {
567
+ const images = input.resources.filter(resource => resource.type === 'image' && resource.folder !== true)
568
+ const blocks: ImageBlock[] = []
569
+ const loadedFileKeys = new Set<string>()
570
+ const failures: AttachmentFailure[] = []
571
+ const result = (): PrefetchedImages => ({ blocks, loadedFileKeys, failures })
572
+ if (images.length === 0) return result()
573
+
574
+ const channel = input.channel
575
+ if (channel === undefined) {
576
+ for (const resource of images) {
577
+ failures.push({ name: resourceName(resource), reason: PREFETCH_CHANNEL_UNAVAILABLE_REASON })
578
+ }
579
+ return result()
580
+ }
581
+
582
+ const deadline = createPrefetchDeadline(input.timeoutMs)
583
+ try {
584
+ await prefetchImagesSequentially(input, channel, images, deadline, blocks, loadedFileKeys, failures)
585
+ } catch (error) {
586
+ // Defensive containment: the loop classifies every expected failure, but
587
+ // this function must never throw into the admission path.
588
+ const detail = thrownValueText(error)
589
+ failures.push({ name: '图片', reason: boundedPrefetchReason(`图片预取异常:${detail}`) })
590
+ safeWarn(input, 'dsh-channels: prefetching images of message %s failed unexpectedly: %s', input.msg.messageId, detail)
591
+ } finally {
592
+ deadline.dispose()
593
+ }
594
+ return result()
595
+ }
596
+
597
+ /** Sequential image loop: order, stop-on-cap, and the shared deadline live here. */
598
+ async function prefetchImagesSequentially(
599
+ input: ImagePrefetchInput,
600
+ channel: FeishuChannel,
601
+ images: readonly AttachmentResource[],
602
+ deadline: PrefetchDeadline,
603
+ blocks: ImageBlock[],
604
+ loadedFileKeys: Set<string>,
605
+ failures: AttachmentFailure[],
606
+ ): Promise<void> {
607
+ let loadedBytes = 0
608
+ let stoppedReason: string | undefined
609
+ for (const resource of images) {
610
+ const name = resourceName(resource)
611
+ if (stoppedReason !== undefined) {
612
+ failures.push({ name, reason: stoppedReason })
613
+ continue
614
+ }
615
+ if (deadline.expired) {
616
+ failures.push({ name, reason: PREFETCH_TIMEOUT_UNPROCESSED_REASON })
617
+ continue
618
+ }
619
+ if (loadedBytes >= input.maxTotalBytes) {
620
+ stoppedReason = PREFETCH_TOTAL_LIMIT_SKIP_REASON
621
+ failures.push({ name, reason: totalLimitReason(loadedBytes, input.maxTotalBytes) })
622
+ continue
623
+ }
624
+
625
+ const outcome = await deadline.race(
626
+ prefetchOneImage(input, channel, resource, deadline.signal, loadedBytes),
627
+ )
628
+ if (outcome === PREFETCH_TIMEOUT) {
629
+ failures.push({ name, reason: prefetchTimeoutReason(input.timeoutMs) })
630
+ continue
631
+ }
632
+ if (outcome.kind === 'stopped') {
633
+ stoppedReason = outcome.skipReason
634
+ failures.push({ name, reason: outcome.reason })
635
+ continue
636
+ }
637
+ // Downloaded bytes count against the budget even when the native store
638
+ // refuses the image: the budget bounds what this prefetch transferred.
639
+ loadedBytes += outcome.bytes
640
+ if (outcome.kind === 'failed') {
641
+ failures.push({ name, reason: outcome.reason })
642
+ continue
643
+ }
644
+ blocks.push(outcome.block)
645
+ loadedFileKeys.add(resource.fileKey)
646
+ }
647
+ }
648
+
649
+ /** One image's download + bounded collection + storage; never rejects. */
650
+ async function prefetchOneImage(
651
+ input: ImagePrefetchInput,
652
+ channel: FeishuChannel,
653
+ resource: AttachmentResource,
654
+ signal: AbortSignal,
655
+ usedBytes: number,
656
+ ): Promise<PrefetchOutcome> {
657
+ let stream: ResourceChunkStream
658
+ try {
659
+ stream = await channel.downloadResource(input.msg.messageId, resource.fileKey, 'image')
660
+ } catch (error) {
661
+ const reason = downloadFailureReason(error)
662
+ safeWarn(
663
+ input, 'dsh-channels: prefetching image %s of message %s failed: %s',
664
+ resource.fileKey, input.msg.messageId, reason,
665
+ )
666
+ return { kind: 'failed', reason, bytes: 0 }
667
+ }
668
+
669
+ const collected = await collectPrefetchBytes(stream, signal, {
670
+ maxImageBytes: input.maxImageBytes,
671
+ maxTotalBytes: input.maxTotalBytes,
672
+ usedBytes,
673
+ })
674
+ if (collected.kind !== 'ok') {
675
+ if (collected.kind === 'failed') {
676
+ safeWarn(
677
+ input, 'dsh-channels: prefetching image %s of message %s failed: %s',
678
+ resource.fileKey, input.msg.messageId, collected.reason,
679
+ )
680
+ }
681
+ return collected
682
+ }
683
+ // The deadline may have expired while the last chunk arrived: do not store
684
+ // an image the caller already reported as timed out.
685
+ if (signal.aborted) {
686
+ return { kind: 'failed', reason: prefetchTimeoutReason(input.timeoutMs), bytes: collected.bytes }
687
+ }
688
+
689
+ let saved: ImagePrefetchSaveResult
690
+ try {
691
+ saved = await input.saveImage(collected.data, resource.fileName)
692
+ } catch (error) {
693
+ const detail = thrownValueText(error)
694
+ const reason = boundedPrefetchReason(`图片入库失败:${detail}`)
695
+ safeWarn(
696
+ input, 'dsh-channels: storing prefetched image %s of message %s failed: %s',
697
+ resource.fileKey, input.msg.messageId, detail,
698
+ )
699
+ return { kind: 'failed', reason, bytes: collected.bytes }
700
+ }
701
+ if (!saved.ok) {
702
+ safeWarn(
703
+ input, 'dsh-channels: storing prefetched image %s of message %s failed: %s',
704
+ resource.fileKey, input.msg.messageId, saved.reason,
705
+ )
706
+ return { kind: 'failed', reason: saved.reason, bytes: collected.bytes }
707
+ }
708
+ return { kind: 'saved', block: saved.block, bytes: collected.bytes }
709
+ }
710
+
711
+ /** Result of reading one image stream under both byte caps. */
712
+ type PrefetchOutcome =
713
+ | { readonly kind: 'saved'; readonly block: ImageBlock; readonly bytes: number }
714
+ | { readonly kind: 'failed'; readonly reason: string; readonly bytes: number }
715
+ | { readonly kind: 'stopped'; readonly reason: string; readonly skipReason: string }
716
+
717
+ /** Byte caps applied while one image is collected. */
718
+ interface PrefetchByteCaps {
719
+ readonly maxImageBytes: number
720
+ readonly maxTotalBytes: number
721
+ /** Bytes already counted against the total budget. */
722
+ readonly usedBytes: number
723
+ }
724
+
725
+ /**
726
+ * Read one image stream completely under the single-image cap and the total
727
+ * budget. A single-image violation only refuses that image (later images may
728
+ * be small enough and are still tried); exhausting the total budget stops the
729
+ * whole prefetch and carries the reason for the remaining images.
730
+ */
731
+ async function collectPrefetchBytes(
732
+ stream: AsyncIterable<Uint8Array>,
733
+ signal: AbortSignal,
734
+ caps: PrefetchByteCaps,
735
+ ): Promise<
736
+ | { readonly kind: 'ok'; readonly data: Uint8Array; readonly bytes: number }
737
+ | { readonly kind: 'failed'; readonly reason: string; readonly bytes: number }
738
+ | { readonly kind: 'stopped'; readonly reason: string; readonly skipReason: string }
739
+ > {
740
+ const chunks: Uint8Array[] = []
741
+ let ownBytes = 0
742
+ try {
743
+ for await (const chunk of stream) {
744
+ if (signal.aborted) {
745
+ return { kind: 'failed', reason: PREFETCH_TIMEOUT_REASON, bytes: ownBytes }
746
+ }
747
+ ownBytes += chunk.byteLength
748
+ if (ownBytes > caps.maxImageBytes) {
749
+ return { kind: 'failed', reason: imageLimitReason(ownBytes, caps.maxImageBytes), bytes: ownBytes }
750
+ }
751
+ if (caps.usedBytes + ownBytes > caps.maxTotalBytes) {
752
+ return {
753
+ kind: 'stopped',
754
+ reason: totalLimitReason(caps.usedBytes + ownBytes, caps.maxTotalBytes),
755
+ skipReason: PREFETCH_TOTAL_LIMIT_SKIP_REASON,
756
+ }
757
+ }
758
+ chunks.push(chunk)
759
+ }
760
+ } catch (error) {
761
+ return { kind: 'failed', reason: downloadFailureReason(error), bytes: ownBytes }
762
+ }
763
+ const data = new Uint8Array(ownBytes)
764
+ let offset = 0
765
+ for (const chunk of chunks) {
766
+ data.set(chunk, offset)
767
+ offset += chunk.byteLength
768
+ }
769
+ return { kind: 'ok', data, bytes: ownBytes }
770
+ }
771
+
772
+ /** Marker resolved by the deadline promise; never surfaces to callers. */
773
+ const PREFETCH_TIMEOUT = Symbol('dsh-channels-prefetch-timeout')
774
+
775
+ /** One shared deadline for a whole prefetch (see {@link createPrefetchDeadline}). */
776
+ interface PrefetchDeadline {
777
+ readonly expired: boolean
778
+ /** Aborted when the deadline expires; the collection loop checks it per chunk. */
779
+ readonly signal: AbortSignal
780
+ /** Resolve with the work value, or the timeout marker once the deadline expires. */
781
+ race<T>(work: Promise<T>): Promise<T | typeof PREFETCH_TIMEOUT>
782
+ /** Clear the underlying timer; safe to call more than once. */
783
+ dispose(): void
784
+ }
785
+
786
+ /**
787
+ * One shared deadline for a whole prefetch. A non-positive (or non-finite)
788
+ * `timeoutMs` means "already expired": no timer is armed and every race
789
+ * resolves immediately. The timer is always cleared by `dispose()`, so a
790
+ * completed prefetch leaves no dangling timer behind.
791
+ */
792
+ function createPrefetchDeadline(timeoutMs: number): PrefetchDeadline {
793
+ const alreadyExpired = !Number.isFinite(timeoutMs) || timeoutMs <= 0
794
+ let expired = alreadyExpired
795
+ const controller = new AbortController()
796
+ let resolveTimeout: ((marker: typeof PREFETCH_TIMEOUT) => void) | undefined
797
+ const timedOut = new Promise<typeof PREFETCH_TIMEOUT>(resolve => { resolveTimeout = resolve })
798
+ let timer: ReturnType<typeof setTimeout> | undefined
799
+ if (alreadyExpired) {
800
+ controller.abort()
801
+ resolveTimeout?.(PREFETCH_TIMEOUT)
802
+ } else {
803
+ timer = setTimeout(() => {
804
+ expired = true
805
+ controller.abort()
806
+ resolveTimeout?.(PREFETCH_TIMEOUT)
807
+ }, timeoutMs)
808
+ }
809
+ return {
810
+ get expired(): boolean { return expired },
811
+ signal: controller.signal,
812
+ race<T>(work: Promise<T>): Promise<T | typeof PREFETCH_TIMEOUT> {
813
+ // A timed-out image may still settle later; observe its rejection so it
814
+ // never becomes an unhandled rejection after the prefetch returned.
815
+ void work.catch(() => undefined)
816
+ if (expired) return Promise.resolve(PREFETCH_TIMEOUT)
817
+ return Promise.race([work, timedOut])
818
+ },
819
+ dispose(): void {
820
+ if (timer !== undefined) {
821
+ clearTimeout(timer)
822
+ timer = undefined
823
+ }
824
+ },
825
+ }
826
+ }
827
+
828
+ /** Best-effort structured warning; a broken sink must never fail the prefetch. */
829
+ function safeWarn(input: ImagePrefetchInput, message: string, ...args: unknown[]): void {
830
+ try {
831
+ input.warn?.(message, ...args)
832
+ } catch {
833
+ // Warnings are diagnostics only.
834
+ }
835
+ }
836
+
837
+ /** Readable text for an arbitrary thrown value (never `[object Object]`). */
838
+ function thrownValueText(error: unknown): string {
839
+ if (error instanceof Error && error.message.length > 0) return error.message
840
+ if (typeof error === 'string' && error.length > 0) return error
841
+ if (error === null || error === undefined) return '未知错误'
842
+ if (typeof error === 'object') {
843
+ const record = error as Record<string, unknown>
844
+ const parts: string[] = []
845
+ for (const key of ['code', 'status', 'statusCode', 'name', 'msg', 'message']) {
846
+ const value = record[key]
847
+ if (typeof value === 'string' && value.length > 0) parts.push(`${key}=${value}`)
848
+ else if (typeof value === 'number' && Number.isFinite(value)) parts.push(`${key}=${value}`)
849
+ }
850
+ if (parts.length > 0) return parts.join(' ')
851
+ try {
852
+ const text = JSON.stringify(error)
853
+ if (text !== undefined && text !== '{}') return text
854
+ } catch {
855
+ // Circular structure: fall through to the fixed description.
856
+ }
857
+ return '未知错误(不可序列化的对象)'
858
+ }
859
+ return String(error)
860
+ }
861
+
862
+ /** Readable, bounded download failure reason (Feishu 400/403 mapped). */
863
+ function downloadFailureReason(error: unknown): string {
864
+ return boundedPrefetchReason(describeFailure(new Error(thrownValueText(error))))
865
+ }
866
+
867
+ /** Strip control characters and bound one prompt-safe failure reason. */
868
+ function boundedPrefetchReason(text: string): string {
869
+ const cleaned = text.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim()
870
+ const value = cleaned === '' ? '图片预取失败' : cleaned
871
+ if (value.length <= PREFETCH_MAX_REASON_TEXT) return value
872
+ return `${value.slice(0, PREFETCH_MAX_REASON_TEXT - 1)}…`
873
+ }