@modesto_ma/dsh-channels 0.3.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.
@@ -10,19 +10,30 @@
10
10
  */
11
11
 
12
12
  import { randomUUID } from 'node:crypto'
13
- import type { AgentHandle, HostContext, SessionId, UserMessage, Workspace } from '../dsh-types.ts'
14
- import type { FeishuConfig } from '../config.ts'
13
+ import type {
14
+ AgentHandle, AttachmentStore, ContentBlock, HostContext, SessionId, UserMessage, Workspace,
15
+ } from '../dsh-types.ts'
16
+ import type { AttachmentStoreMode, FeishuConfig } from '../config.ts'
15
17
  import { buildCard, buildOnboardingCard, initialStreamingCard } from '../cards.ts'
16
18
  import { RouteOperationQueue } from '../commands.ts'
17
19
  import { chatRouteKey, sessionDirOf } from '../domain.ts'
18
- import type { FeishuDomainHandle, RouteRecord } from '../domain.ts'
20
+ import type { FeishuDomainHandle, PendingAttachmentRecord, RouteRecord } from '../domain.ts'
19
21
  import type { CardDelivery } from '../feishu/delivery.ts'
20
22
  import {
21
- adoptStagedAttachments, attachmentsDir, hasAttachmentPayload,
23
+ adoptStagedAttachments, attachmentsDir, attachmentResources, hasAttachmentPayload,
24
+ inboundAttachmentState, prefetchImageAttachments,
22
25
  } from '../feishu/inbound-attachments.ts'
23
- import { AttachmentIntake } from '../feishu/inbound-attachments.ts'
24
- import type { AttachmentArchiveResult, AttachmentFailure } from '../feishu/inbound-attachments.ts'
25
- import type { NormalizedMessage } from '../feishu-channel.ts'
26
+ import type {
27
+ AttachmentFailure, InboundAttachmentState,
28
+ } from '../feishu/inbound-attachments.ts'
29
+ import { buildFeishuContextBlock, manifestEntriesOfKnownMessage, triggerManifestAttachments } from '../feishu/manifest.ts'
30
+ import {
31
+ InboundAttachmentStore, NativeAttachmentStore, effectiveAttachmentMode,
32
+ } from '../feishu/native-attachments.ts'
33
+ import type { InboundAttachmentStoreResult } from '../feishu/native-attachments.ts'
34
+ import type { PendingAttachmentScope, PendingAttachmentStore } from '../feishu/pending-attachments.ts'
35
+ import type { KnownAttachmentMessageInput, KnownAttachmentSource } from '../feishu/tools.ts'
36
+ import type { FeishuChannel, NormalizedMessage } from '../feishu-channel.ts'
26
37
  import type { QuestionAnswerer } from '../interactions/questions.ts'
27
38
  import type { InflightRepair } from '../session/repair.ts'
28
39
  import type { SessionManager } from '../session/manager.ts'
@@ -55,6 +66,45 @@ export interface InboundAccessPort {
55
66
  ownsRoute(msg: NormalizedMessage, record: RouteRecord): boolean
56
67
  }
57
68
 
69
+ /**
70
+ * Read-only message access the inbound turn assembly needs: the quoted message
71
+ * of the triggering message and the bounded first-turn topic history.
72
+ *
73
+ * Both methods return `undefined` for "nothing to show" (no quoted message,
74
+ * read failed, missing permission); implementations never throw for expected
75
+ * failures, and a silently degraded read must never block the turn (AC6).
76
+ */
77
+ export interface InboundMessageReadPort {
78
+ /**
79
+ * Resolve the message the triggering message replies to. The returned
80
+ * `block` is the ready-to-inject `<feishu_quote>` prompt block and `message`
81
+ * the quoted message's whitelist registration (merged-forward containers are
82
+ * registered by container id).
83
+ */
84
+ quote(input: {
85
+ messageId: string
86
+ record: RouteRecord
87
+ }): Promise<{ block: string; message?: KnownAttachmentMessageInput } | undefined>
88
+ /**
89
+ * Resolve the most recent topic messages for the first turn of a new
90
+ * Session: the ready-to-inject `<feishu_history>` block plus the per-message
91
+ * whitelist registrations in chronological order.
92
+ */
93
+ history(input: {
94
+ record: RouteRecord
95
+ limit: number
96
+ excludeMessageIds: readonly string[]
97
+ }): Promise<{ block: string; messages: readonly KnownAttachmentMessageInput[] } | undefined>
98
+ }
99
+
100
+ /** Live trigger-image prefetch policy (see `prefetchImages` config). */
101
+ export interface AttachmentPrefetchPolicy {
102
+ readonly enabled: boolean
103
+ readonly maxImageBytes: number
104
+ readonly maxTotalBytes: number
105
+ readonly timeoutMs: number
106
+ }
107
+
58
108
  export interface InboundDeps {
59
109
  ctx: HostContext
60
110
  config: FeishuConfig
@@ -64,7 +114,32 @@ export interface InboundDeps {
64
114
  repair: InflightRepair
65
115
  delivery: CardDelivery
66
116
  sessions: SessionManager
67
- attachments: AttachmentIntake
117
+ /** Configured inbound attachment backend (`attachmentStore`). */
118
+ attachmentMode(): AttachmentStoreMode
119
+ /** Host-level native attachment library; undefined = not mounted. */
120
+ nativeAttachments(): AttachmentStore | undefined
121
+ /** Backend-selecting archive/fallback store (legacy workspace download). */
122
+ attachmentStore: InboundAttachmentStore
123
+ /** Durable metadata registry for silently registered attachments. */
124
+ pendingAttachments: PendingAttachmentStore
125
+ /**
126
+ * Pre-authorize a message's attachments for `feishu_attachment_fetch`:
127
+ * `trigger` (the triggering message) by default, `quote`/`history` for the
128
+ * messages this turn's excerpt/history blocks name.
129
+ */
130
+ noteKnownAttachments(
131
+ record: RouteRecord,
132
+ message: KnownAttachmentMessageInput,
133
+ source?: KnownAttachmentSource,
134
+ ): void
135
+ /**
136
+ * Read-only message access: quoted-message excerpt and first-turn topic
137
+ * history. Failures degrade silently to a `undefined` value (never a throw
138
+ * into the turn).
139
+ */
140
+ messageRead: InboundMessageReadPort
141
+ /** Live image-prefetch policy for triggering messages. */
142
+ prefetchImages(): AttachmentPrefetchPolicy
68
143
  /** Channel-plane access control (administrator + whitelist gates). */
69
144
  access: InboundAccessPort
70
145
  /** Shared per-route FIFO admission (commands and turns must not interleave). */
@@ -98,6 +173,52 @@ export function feishuUserMessage(text: string): UserMessage {
98
173
  } as unknown as UserMessage
99
174
  }
100
175
 
176
+ /**
177
+ * Whitelist registration input for one triggering message.
178
+ *
179
+ * The fetch tool requires explicit per-route authorization; the trigger
180
+ * message is the one exception the admission layer can vouch for. Resources
181
+ * are copied from the same normalization the manifest uses, and merged-forward
182
+ * containers carry no direct resources (the tool expands them through the
183
+ * container id, per the P0 rule).
184
+ */
185
+ export function knownAttachmentMessageOf(msg: NormalizedMessage): KnownAttachmentMessageInput {
186
+ const forwarded = msg.rawContentType === 'merge_forward'
187
+ const resources = attachmentResources(msg).map(resource => ({
188
+ type: resource.type,
189
+ fileKey: resource.fileKey,
190
+ ...(resource.fileName === undefined ? {} : { fileName: resource.fileName }),
191
+ ...(resource.folder === true ? { folder: true } : {}),
192
+ }))
193
+ return {
194
+ messageId: msg.messageId,
195
+ kind: forwarded ? 'merge_forward' : 'message',
196
+ resources,
197
+ senderOpenId: msg.senderId,
198
+ }
199
+ }
200
+
201
+ /** Feishu message-id shape accepted for quote resolution (`om_...`). */
202
+ const QUOTE_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,128}$/u
203
+
204
+ /** Read-only prompt context assembled for one turn (quote excerpt + history). */
205
+ interface TurnReadContext {
206
+ /** Ready `<feishu_quote>` text block, when the message quotes something. */
207
+ quoteBlock?: string
208
+ /** Whitelist registration of the quoted message (manifest/discoverability). */
209
+ quoteMessage?: KnownAttachmentMessageInput
210
+ /** Ready `<feishu_history>` text block, first turn only. */
211
+ historyBlock?: string
212
+ }
213
+
214
+ /** First-turn-only inputs of the prompt assembly. */
215
+ interface TurnDeliveryOptions {
216
+ /** New Session's first turn: attach the bounded topic history bootstrap. */
217
+ firstTurn?: boolean
218
+ /** Message ids never shown in the history block (trigger + streaming card). */
219
+ excludeMessageIds?: readonly string[]
220
+ }
221
+
101
222
  export class InboundRouter {
102
223
  /**
103
224
  * In-process dedup guard: message id → accept timestamp. Set synchronously
@@ -114,6 +235,13 @@ export class InboundRouter {
114
235
  * overwrites, or the "no route, no mention" drop window.
115
236
  */
116
237
  private readonly establishing = new Map<string, Promise<RouteRecord>>()
238
+ /**
239
+ * route key → first-turn delivery gate. A new Session persists its route
240
+ * before the prompt is assembled (image prefetch); a message that observes
241
+ * the route in that window must wait for the first followup instead of
242
+ * racing it, otherwise the topic's turns reach the model out of order.
243
+ */
244
+ private readonly firstDeliveries = new Map<string, { promise: Promise<void>; release(): void }>()
117
245
 
118
246
  constructor(private readonly deps: InboundDeps) {}
119
247
 
@@ -124,7 +252,8 @@ export class InboundRouter {
124
252
  private get repair(): InflightRepair { return this.deps.repair }
125
253
  private get delivery(): CardDelivery { return this.deps.delivery }
126
254
  private get sessions(): SessionManager { return this.deps.sessions }
127
- private get attachments(): AttachmentIntake { return this.deps.attachments }
255
+ private get attachmentStore(): InboundAttachmentStore { return this.deps.attachmentStore }
256
+ private get pendingAttachments(): PendingAttachmentStore { return this.deps.pendingAttachments }
128
257
  private get routeOperations(): RouteOperationQueue { return this.deps.queue }
129
258
  private get streamer(): TurnStreamer | undefined { return this.deps.streamer() }
130
259
  private get questionAnswerer(): QuestionAnswerer | undefined { return this.deps.questionAnswerer() }
@@ -138,6 +267,28 @@ export class InboundRouter {
138
267
  }
139
268
  }
140
269
 
270
+ /**
271
+ * Open the first-turn delivery gate of one route. Registered BEFORE the
272
+ * route record is persisted, so any message that can observe the route also
273
+ * observes its gate. The returned closer must be called exactly once.
274
+ */
275
+ private openDeliveryGate(key: string): () => void {
276
+ let settle!: () => void
277
+ const promise = new Promise<void>((resolve) => { settle = resolve })
278
+ const release = (): void => {
279
+ if (this.firstDeliveries.get(key)?.promise === promise) this.firstDeliveries.delete(key)
280
+ settle()
281
+ }
282
+ this.firstDeliveries.set(key, { promise, release })
283
+ return release
284
+ }
285
+
286
+ /** Wait for the in-flight first followup of a route, when one is pending. */
287
+ private async awaitFirstDelivery(key: string): Promise<void> {
288
+ const pending = this.firstDeliveries.get(key)
289
+ if (pending !== undefined) await pending.promise.catch(() => undefined)
290
+ }
291
+
141
292
  /**
142
293
  * Send the one-time onboarding card for a private chat, then remember the
143
294
  * chat in the domain global. Best-effort: a storage failure only risks a
@@ -163,6 +314,10 @@ export class InboundRouter {
163
314
  clear(): void {
164
315
  this.accepted.clear()
165
316
  this.establishing.clear()
317
+ // Resolve pending gates before dropping them so a message waiting on a
318
+ // first-turn delivery never stays suspended across a teardown.
319
+ for (const entry of [...this.firstDeliveries.values()]) entry.release()
320
+ this.firstDeliveries.clear()
166
321
  }
167
322
 
168
323
  /** Entry point: one normalized inbound message. */
@@ -223,22 +378,45 @@ export class InboundRouter {
223
378
  return
224
379
  }
225
380
 
226
- // Attachments are archived before session work: an attachment-only message
227
- // produces no model turn, and a caption mixed with attachments keeps only
228
- // the caption as the prompt. The Agent reads the files on demand later.
229
- const archived = await this.archiveAttachments(msg)
230
- if (archived !== undefined) {
231
- if (archived.failures.length > 0) {
232
- await this.delivery.noticeText(msg, buildAttachmentFailureNotice(archived.failures))
381
+ // Attachment admission. The effective backend selects between the legacy
382
+ // synchronous workspace download (`attachmentStore: 'archive'`, or a
383
+ // native deployment whose host does not mount the attachment service) and
384
+ // the v2 lazy pipeline: no attachment bytes are downloaded before the
385
+ // session/card exists, and files are never downloaded by admission at all.
386
+ if (this.attachmentBackend() === 'archive') {
387
+ // Attachments are archived before session work: an attachment-only
388
+ // message produces no model turn, and a caption mixed with attachments
389
+ // keeps only the caption as the prompt. The Agent reads the files on
390
+ // demand later.
391
+ const archived = await this.archiveAttachments(msg)
392
+ if (archived !== undefined) {
393
+ if (archived.failures.length > 0) {
394
+ await this.delivery.noticeText(msg, buildAttachmentFailureNotice(archived.failures))
395
+ }
396
+ if (archived.attachmentOnly) return
397
+ content = archived.text
398
+ if (content === '') return
399
+ }
400
+ } else {
401
+ // Native pipeline: classify without downloading. Attachment-only
402
+ // messages and messages that cannot start a turn are silently registered
403
+ // (no reply, no reaction, no Session); a mixed caption becomes the prompt
404
+ // with its placeholders stripped.
405
+ const state = inboundAttachmentState(msg)
406
+ if (state.hasPayload) {
407
+ const resolution = await this.resolveNativeAttachmentMessage(msg, state)
408
+ if (resolution.kind === 'silent') return
409
+ content = resolution.text
410
+ if (content === '') return
233
411
  }
234
- if (archived.attachmentOnly) return
235
- content = archived.text
236
- if (content === '') return
237
412
  }
238
413
 
239
414
  // A session blocked on `ask_user_question` has no other use for an inbound
240
415
  // text message: the next reply becomes the free-text answer.
241
- if (await this.tryAnswerPendingQuestion(msg, content)) return
416
+ if (await this.tryAnswerPendingQuestion(msg, content)) {
417
+ await this.registerConsumedTriggerAttachments(msg)
418
+ return
419
+ }
242
420
 
243
421
  // 1. Real topic route first (works without a mention, per product spec).
244
422
  if (msg.threadId !== undefined) {
@@ -247,6 +425,9 @@ export class InboundRouter {
247
425
  // Only the topic creator may continue its Session (admission already
248
426
  // passed, but another authorized user could be in the same group).
249
427
  if (!this.access.ownsRoute(msg, record)) return
428
+ // The route may be durable while its first turn is still being
429
+ // prepared (prefetch): stay behind that turn instead of racing it.
430
+ await this.awaitFirstDelivery(record.key)
250
431
  await this.routeOperations.run(record.key, () => {
251
432
  if (!this.started) return
252
433
  return this.continueSession(msg, this.routes.get(record.key) ?? record, content)
@@ -260,6 +441,7 @@ export class InboundRouter {
260
441
  const chatRecord = this.routes.get(chatKey)
261
442
  if (chatRecord !== undefined) {
262
443
  if (!this.access.ownsRoute(msg, chatRecord)) return
444
+ await this.awaitFirstDelivery(chatRecord.key)
263
445
  await this.routeOperations.run(chatRecord.key, () => {
264
446
  if (!this.started) return
265
447
  return this.continueSession(msg, this.routes.get(chatRecord.key) ?? chatRecord, content)
@@ -292,14 +474,17 @@ export class InboundRouter {
292
474
  }
293
475
 
294
476
  /**
295
- * Archive the attachment payload of one message, if any. Returns `undefined`
296
- * when the message carries no archivable payload; otherwise the archive
297
- * result whose `text` is the remaining prompt (possibly empty).
477
+ * Legacy archive path: download every resource of one message into the
478
+ * conversation workspace (or its pre-topic staging directory). Returns
479
+ * `undefined` when the message carries no archivable payload; otherwise the
480
+ * storage result whose `text` is the remaining prompt (possibly empty).
298
481
  *
299
482
  * Runs inside the same per-route FIFO as turns so a caption sent right after
300
- * a file is queued behind the file's download instead of racing it.
483
+ * a file is queued behind the file's download instead of racing it. Only the
484
+ * `archive` effective backend calls this; the native pipeline never downloads
485
+ * files during admission.
301
486
  */
302
- private async archiveAttachments(msg: NormalizedMessage): Promise<AttachmentArchiveResult | undefined> {
487
+ private async archiveAttachments(msg: NormalizedMessage): Promise<InboundAttachmentStoreResult | undefined> {
303
488
  if (!hasAttachmentPayload(msg)) return undefined
304
489
 
305
490
  const threadRecord = msg.threadId === undefined ? undefined : this.routes.get(msg.threadId)
@@ -312,20 +497,431 @@ export class InboundRouter {
312
497
  // already owns does not.
313
498
  if (record === undefined && msg.chatType === 'group'
314
499
  && this.config.requireMention && !msg.mentionedBot) {
315
- return { saved: 0, failed: 0, failures: [], attachmentOnly: true, text: '' }
500
+ return {
501
+ mode: 'archive', saved: 0, failed: 0, failures: [], stored: [], attachmentOnly: true, text: '',
502
+ }
316
503
  }
317
504
 
318
505
  return await this.routeOperations.run(record?.key ?? chatKey, async () => {
319
- if (!this.started) return { saved: 0, failed: 0, failures: [], attachmentOnly: true, text: '' }
506
+ if (!this.started) {
507
+ return {
508
+ mode: 'archive', saved: 0, failed: 0, failures: [], stored: [], attachmentOnly: true, text: '',
509
+ } satisfies InboundAttachmentStoreResult
510
+ }
320
511
  // An existing topic already has its own working directory; a
321
512
  // conversation without one stages files below baseDir until its first
322
513
  // Session creates the topic directory and adopts them.
323
514
  const directory = record === undefined
324
515
  ? stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId)
325
516
  : attachmentsDir(sessionDirOf(record))
326
- return await this.attachments.archive(msg, directory)
517
+ return await this.attachmentStore.store(msg, directory)
327
518
  })
328
519
  }
520
+
521
+ /**
522
+ * One-time legacy migration: move files older plugin versions staged below
523
+ * `<baseDir>/_staging/...` into this conversation's topic directory.
524
+ *
525
+ * The v2 pipeline stores inbound attachments in the DSH native library and
526
+ * never writes staging, so this path only adopts files left by a pre-v2
527
+ * deployment. It is kept for one release cycle after attachment_pipeline_v2
528
+ * ships and is then removed together with `stagingPathFor` (decisions D12).
529
+ * A successful adoption logs one warning so operators can retire the old
530
+ * data instead of relying on this compatibility path indefinitely.
531
+ */
532
+ private async adoptLegacyStagedAttachments(
533
+ msg: NormalizedMessage,
534
+ sessionDir: string,
535
+ warn: (message: string, ...args: unknown[]) => void,
536
+ ): Promise<void> {
537
+ const adopted = await adoptStagedAttachments(
538
+ stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
539
+ sessionDir,
540
+ ).catch((error: unknown) => {
541
+ warn('dsh-channels: adopting staged attachments failed: %s', String(error))
542
+ return 0
543
+ })
544
+ if (adopted > 0) {
545
+ warn(
546
+ 'dsh-channels: adopted %d legacy staged attachment(s) into %s; the one-time _staging migration is deprecated and will be removed after one release cycle (decisions D12)',
547
+ adopted,
548
+ sessionDir,
549
+ )
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Effective inbound attachment backend for the live deployment.
555
+ *
556
+ * The lazy native pipeline can only deliver files through
557
+ * `feishu_attachment_fetch`; with that tool disabled (or without the host
558
+ * attachment library) the deployment falls back to the legacy archive
559
+ * backend, where the Agent reads the downloaded workspace copies instead of
560
+ * a manifest that would name an unavailable tool.
561
+ */
562
+ private attachmentBackend(): AttachmentStoreMode {
563
+ return effectiveAttachmentMode(
564
+ this.deps.attachmentMode(),
565
+ this.deps.nativeAttachments(),
566
+ this.config.feishuTools.attachmentFetch,
567
+ this.config.acceptAttachments,
568
+ )
569
+ }
570
+
571
+ /**
572
+ * Native-pipeline attachment decision for one message, without downloading.
573
+ *
574
+ * Returns `{ kind: 'silent' }` when the message must stay inert (metadata
575
+ * registration only: no reply, no reaction, no Session, no download) and
576
+ * `{ kind: 'prompt', text }` when a model turn starts with this text. The
577
+ * caller keeps the message flowing through the normal route decision tree.
578
+ */
579
+ private async resolveNativeAttachmentMessage(
580
+ msg: NormalizedMessage,
581
+ state: InboundAttachmentState,
582
+ ): Promise<{ kind: 'silent' } | { kind: 'prompt'; text: string }> {
583
+ // A foreign sender's attachment never registers (and is dropped later by
584
+ // the route-ownership checks): silent metadata must stay per-sender.
585
+ const threadRecord = msg.threadId === undefined ? undefined : this.routes.get(msg.threadId)
586
+ const chatKey = chatRouteKey(msg.chatType, msg.chatId, msg.senderId)
587
+ const chatRecord = threadRecord === undefined ? this.routes.get(chatKey) : undefined
588
+ const foreign = (threadRecord !== undefined && !this.access.ownsRoute(msg, threadRecord))
589
+ || (chatRecord !== undefined && !this.access.ownsRoute(msg, chatRecord))
590
+ if (foreign) return { kind: 'prompt', text: state.text }
591
+
592
+ if (!this.config.acceptAttachments && state.attachmentOnly) return { kind: 'silent' }
593
+ if (state.attachmentOnly) {
594
+ await this.registerSilentAttachment(msg)
595
+ return { kind: 'silent' }
596
+ }
597
+ if (!this.config.acceptAttachments) return { kind: 'prompt', text: state.text }
598
+
599
+ // Group messages without an @-mention (and without an already-bound topic)
600
+ // never start a turn, but their attachments must remain discoverable from
601
+ // the topic a later @-mentioned message creates (Roadmap AC-4).
602
+ const pendingEstablishment = this.establishing.get(chatKey)
603
+ const requiresMention = msg.chatType === 'group' && this.config.requireMention && !msg.mentionedBot
604
+ const triggers = threadRecord !== undefined || chatRecord !== undefined
605
+ || pendingEstablishment !== undefined || !requiresMention
606
+ if (!triggers) {
607
+ await this.registerSilentAttachment(msg)
608
+ return { kind: 'silent' }
609
+ }
610
+ return { kind: 'prompt', text: state.text }
611
+ }
612
+
613
+ /**
614
+ * Record attachment metadata without any side effect beyond the durable
615
+ * registry write. Never replies, reacts, downloads, or establishes a Session.
616
+ */
617
+ private async registerSilentAttachment(msg: NormalizedMessage): Promise<void> {
618
+ if (!this.config.acceptAttachments) return
619
+ // `admitted: true` is safe because every call site runs after
620
+ // `access.admit` returned true; the registry itself has no admission policy.
621
+ try {
622
+ await this.pendingAttachments.registerMessage(msg, { admitted: true })
623
+ } catch (error) {
624
+ // Metadata only: a registry failure must never fail the inbound pipeline.
625
+ this.ctx.logger?.warn?.(
626
+ 'dsh-channels: registering pending attachment metadata for %s failed: %s',
627
+ msg.messageId, String(error),
628
+ )
629
+ }
630
+ }
631
+
632
+ /**
633
+ * A native-pipeline message whose caption was consumed as a structured
634
+ * question answer starts no model turn, so its attachments would otherwise
635
+ * vanish from the conversation. Register them silently for later turns;
636
+ * the archive backend already downloaded them before question interception.
637
+ */
638
+ private async registerConsumedTriggerAttachments(msg: NormalizedMessage): Promise<void> {
639
+ if (this.attachmentBackend() !== 'native') return
640
+ if (!inboundAttachmentState(msg).hasPayload) return
641
+ await this.registerSilentAttachment(msg)
642
+ }
643
+ /**
644
+ * Build the first-turn user message for the native pipeline: the caption
645
+ * text, the quoted-message excerpt, the prefetched trigger images, the
646
+ * `<feishu_context>` manifest block listing every attachment still unloaded,
647
+ * and (first turn only) the `<feishu_history>` topic bootstrap. Optional
648
+ * blocks are omitted entirely (no token cost) when empty.
649
+ *
650
+ * Runs after the session/card/route exist and before `followup`; only the
651
+ * triggering message's images are prefetched (bounded and short-timeout),
652
+ * files never are. Block order is stable: text, quote, images, manifest,
653
+ * history.
654
+ */
655
+ private async nativeTurnMessage(
656
+ msg: NormalizedMessage,
657
+ record: RouteRecord,
658
+ content: string,
659
+ options: TurnDeliveryOptions,
660
+ ): Promise<UserMessage> {
661
+ const message = feishuUserMessage(content)
662
+ const blocks: ContentBlock[] = content === '' ? [] : [{ type: 'text', text: content }]
663
+ const state = inboundAttachmentState(msg)
664
+ const attachmentPipeline = this.config.acceptAttachments
665
+
666
+ // Authorize the trigger message before any prefetch attempt so the model
667
+ // can fetch whatever the prefetch could not load.
668
+ if (state.hasPayload && attachmentPipeline) {
669
+ try {
670
+ this.deps.noteKnownAttachments(record, knownAttachmentMessageOf(msg))
671
+ } catch (error) {
672
+ this.ctx.logger?.warn?.(
673
+ 'dsh-channels: authorizing trigger attachments of %s failed: %s',
674
+ msg.messageId, String(error),
675
+ )
676
+ }
677
+ }
678
+
679
+ // Quote excerpt + first-turn history: read only after the trigger message
680
+ // is durable and whitelisted (the record exists and the message is known),
681
+ // so a quoted/earlier attachment can reference the trigger's route. The
682
+ // read runs CONCURRENTLY with the bounded image prefetch below: neither
683
+ // waits for the other, and a failed read degrades to its absence (AC6).
684
+ const contextPromise = this.readTurnContext(msg, record, options)
685
+
686
+ const loadedFileKeys = new Set<string>()
687
+ const prefetchedBlocks: ContentBlock[] = []
688
+ const policy = this.deps.prefetchImages()
689
+ const images = state.resources.filter(resource => resource.type === 'image')
690
+ if (state.hasPayload && attachmentPipeline && policy.enabled && images.length > 0) {
691
+ const prefetched = await prefetchImageAttachments({
692
+ msg,
693
+ resources: images,
694
+ channel: this.liveChannel(),
695
+ maxImageBytes: policy.maxImageBytes,
696
+ maxTotalBytes: policy.maxTotalBytes,
697
+ timeoutMs: policy.timeoutMs,
698
+ saveImage: async (bytes, name) => {
699
+ const store = new NativeAttachmentStore({ service: () => this.deps.nativeAttachments() })
700
+ const saved = await store.saveImage({ bytes, ...(name === undefined ? {} : { name }) })
701
+ return saved.ok
702
+ ? { ok: true, block: saved.value.block }
703
+ : { ok: false, reason: saved.reason }
704
+ },
705
+ warn: (warning, ...args) => this.ctx.logger?.warn?.(warning, ...args),
706
+ })
707
+ // Spread each block into a plain object literal: the host `ContentBlock`
708
+ // union carries an index signature that the named `ImageBlock` interface
709
+ // does not declare, and the clone is structurally identical.
710
+ prefetchedBlocks.push(...prefetched.blocks.map(block => ({ ...block })))
711
+ for (const fileKey of prefetched.loadedFileKeys) loadedFileKeys.add(fileKey)
712
+ }
713
+ // Stable block order: text, quote, image blocks, manifest, history.
714
+ const context = await contextPromise
715
+ if (context.quoteBlock !== undefined) blocks.push({ type: 'text', text: context.quoteBlock })
716
+ blocks.push(...prefetchedBlocks)
717
+
718
+ let pending: readonly PendingAttachmentRecord[] = []
719
+ if (attachmentPipeline) {
720
+ try {
721
+ pending = this.pendingAttachments.list(this.pendingScopeOfRecord(record))
722
+ } catch (error) {
723
+ this.ctx.logger?.warn?.(
724
+ 'dsh-channels: listing pending attachments for %s failed: %s',
725
+ record.key, String(error),
726
+ )
727
+ }
728
+ }
729
+ const manifest = attachmentPipeline
730
+ ? buildFeishuContextBlock({
731
+ trigger: triggerManifestAttachments(msg, loadedFileKeys),
732
+ // A quoted file must be DISCOVERABLE in the manifest (AC1): the quote
733
+ // block names it, and this row carries the message_id/file_key the
734
+ // model needs for feishu_attachment_fetch.
735
+ ...(context.quoteMessage === undefined
736
+ ? {}
737
+ : { quote: manifestEntriesOfKnownMessage(context.quoteMessage) }),
738
+ pending,
739
+ })
740
+ : ''
741
+ if (manifest !== '') blocks.push({ type: 'text', text: manifest })
742
+ if (context.historyBlock !== undefined) blocks.push({ type: 'text', text: context.historyBlock })
743
+ // A trigger always carries caption text, but keep the message well-formed
744
+ // if a future call path ever reaches here with nothing to say.
745
+ if (blocks.length === 0) blocks.push({ type: 'text', text: '' })
746
+ message.content = blocks
747
+ return message
748
+ }
749
+
750
+ /**
751
+ * Deliver the prompt for one turn. The lazy native pipeline assembles the
752
+ * attachment blocks + manifest; every other deployment keeps the plain text
753
+ * prompt. Both backends still carry the quoted-message excerpt and (first
754
+ * turn only) the bounded topic history: those are read-only text context and
755
+ * remain useful without the lazy fetch tool.
756
+ *
757
+ * A failure in prompt assembly degrades to the plain text prompt (the turn
758
+ * must run); a failed quote/history read degrades silently to its absence.
759
+ */
760
+ private async deliverTurnMessage(
761
+ msg: NormalizedMessage,
762
+ record: RouteRecord,
763
+ content: string,
764
+ options: TurnDeliveryOptions = {},
765
+ ): Promise<UserMessage> {
766
+ try {
767
+ if (this.attachmentBackend() !== 'native') {
768
+ return await this.textTurnMessage(msg, record, content, options)
769
+ }
770
+ return await this.nativeTurnMessage(msg, record, content, options)
771
+ } catch (error) {
772
+ this.ctx.logger?.warn?.(
773
+ 'dsh-channels: assembling the attachment manifest for %s failed: %s',
774
+ msg.messageId, String(error),
775
+ )
776
+ return feishuUserMessage(content)
777
+ }
778
+ }
779
+
780
+ /**
781
+ * Text-only turn prompt (archive/fallback deployments): the legacy single
782
+ * text block, extended with the quote excerpt and the first-turn history
783
+ * block when they exist. With nothing extra this is byte-identical to
784
+ * {@link feishuUserMessage}.
785
+ */
786
+ private async textTurnMessage(
787
+ msg: NormalizedMessage,
788
+ record: RouteRecord,
789
+ content: string,
790
+ options: TurnDeliveryOptions,
791
+ ): Promise<UserMessage> {
792
+ const context = await this.readTurnContext(msg, record, options)
793
+ if (context.quoteBlock === undefined && context.historyBlock === undefined) {
794
+ return feishuUserMessage(content)
795
+ }
796
+ const blocks: ContentBlock[] = content === '' ? [] : [{ type: 'text', text: content }]
797
+ if (context.quoteBlock !== undefined) blocks.push({ type: 'text', text: context.quoteBlock })
798
+ if (context.historyBlock !== undefined) blocks.push({ type: 'text', text: context.historyBlock })
799
+ const message = feishuUserMessage(content)
800
+ message.content = blocks
801
+ return message
802
+ }
803
+
804
+ /**
805
+ * Read the read-only prompt context of one turn: the quoted-message excerpt
806
+ * (every triggered message, per D3) and the bounded topic history (new
807
+ * Session's first turn only). Both reads run concurrently and are bounded by
808
+ * a short prompt budget, so a slow/stalled read can delay the turn by at
809
+ * most {@link PROMPT_CONTEXT_TIMEOUT} — never by a model-tool timeout (AC4:
810
+ * the first turn is not blocked by enrichment).
811
+ */
812
+ private async readTurnContext(
813
+ msg: NormalizedMessage,
814
+ record: RouteRecord,
815
+ options: TurnDeliveryOptions,
816
+ ): Promise<TurnReadContext> {
817
+ const [quote, historyBlock] = await Promise.all([
818
+ this.readQuoteBlock(msg, record),
819
+ this.readHistoryBlock(record, options),
820
+ ])
821
+ return {
822
+ ...(quote === undefined
823
+ ? {}
824
+ : {
825
+ quoteBlock: quote.block,
826
+ ...(quote.message === undefined ? {} : { quoteMessage: quote.message }),
827
+ }),
828
+ ...(historyBlock === undefined ? {} : { historyBlock }),
829
+ }
830
+ }
831
+
832
+ /**
833
+ * Resolve the quoted message into a `<feishu_quote>` block and register the
834
+ * quoted message (including a merged-forward container, by container id) in
835
+ * the route's attachment whitelist. Only messages that already triggered a
836
+ * turn reach this point (D3: quoting the bot without @ never triggers).
837
+ */
838
+ private async readQuoteBlock(
839
+ msg: NormalizedMessage,
840
+ record: RouteRecord,
841
+ ): Promise<{ block: string; message?: KnownAttachmentMessageInput } | undefined> {
842
+ const messageId = msg.replyToMessageId
843
+ if (messageId === undefined || !QUOTE_MESSAGE_ID.test(messageId)) return undefined
844
+ try {
845
+ const quote = await this.deps.messageRead.quote({ messageId, record })
846
+ if (quote === undefined) return undefined
847
+ if (quote.message !== undefined) {
848
+ try {
849
+ this.deps.noteKnownAttachments(record, quote.message, 'quote')
850
+ } catch (error) {
851
+ // Best-effort whitelist registration: the excerpt is still useful.
852
+ this.ctx.logger?.debug?.(
853
+ 'dsh-channels: registering quoted attachments of %s failed: %s',
854
+ messageId, String(error),
855
+ )
856
+ }
857
+ }
858
+ return quote.block === '' ? undefined : quote
859
+ } catch (error) {
860
+ // Missing read scope / deleted message / channel down: no excerpt.
861
+ this.ctx.logger?.debug?.(
862
+ 'dsh-channels: reading quoted message %s failed: %s', messageId, String(error),
863
+ )
864
+ return undefined
865
+ }
866
+ }
867
+
868
+ /**
869
+ * Resolve the first-turn topic history into a `<feishu_history>` block and
870
+ * register every message it shows in the route's attachment whitelist.
871
+ * Disabled by `historyBootstrapLimit: 0`; continuations never read history.
872
+ */
873
+ private async readHistoryBlock(
874
+ record: RouteRecord,
875
+ options: TurnDeliveryOptions,
876
+ ): Promise<string | undefined> {
877
+ if (options.firstTurn !== true) return undefined
878
+ const limit = this.config.historyBootstrapLimit
879
+ if (!Number.isFinite(limit) || limit <= 0) return undefined
880
+ try {
881
+ const history = await this.deps.messageRead.history({
882
+ record,
883
+ limit,
884
+ excludeMessageIds: options.excludeMessageIds ?? [],
885
+ })
886
+ if (history === undefined) return undefined
887
+ for (const message of history.messages) {
888
+ try {
889
+ this.deps.noteKnownAttachments(record, message, 'history')
890
+ } catch (error) {
891
+ this.ctx.logger?.debug?.(
892
+ 'dsh-channels: registering history attachments of %s failed: %s',
893
+ message.messageId, String(error),
894
+ )
895
+ }
896
+ }
897
+ return history.block === '' ? undefined : history.block
898
+ } catch (error) {
899
+ this.ctx.logger?.debug?.(
900
+ 'dsh-channels: reading topic history for %s failed: %s', record.key, String(error),
901
+ )
902
+ return undefined
903
+ }
904
+ }
905
+
906
+
907
+ /** Pending-registry isolation scope for one persisted route. */
908
+ private pendingScopeOfRecord(record: RouteRecord): PendingAttachmentScope {
909
+ return {
910
+ chatId: record.chatId,
911
+ senderOpenId: record.senderOpenId,
912
+ ...(record.kind === 'thread' ? { threadId: record.key } : {}),
913
+ }
914
+ }
915
+
916
+ /** Live channel accessor for best-effort prefetch; undefined when gone. */
917
+ private liveChannel(): FeishuChannel | undefined {
918
+ try {
919
+ return this.delivery.requireChannel()
920
+ } catch {
921
+ return undefined
922
+ }
923
+ }
924
+
329
925
  /**
330
926
  * Consume one plain reply as an `ask_user_question` free-text answer.
331
927
  *
@@ -467,13 +1063,9 @@ export class InboundRouter {
467
1063
  warn('dsh-channels: attaching initial session %s to workspace failed: %s', sessionId, String(error))
468
1064
  })
469
1065
  }
470
- // Files staged before this topic existed belong to this conversation.
471
- await adoptStagedAttachments(
472
- stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
473
- placement.sessionDir,
474
- ).catch((error: unknown) => {
475
- warn('dsh-channels: adopting staged attachments failed: %s', String(error))
476
- })
1066
+ // Files staged before this topic existed by a pre-v2 deployment belong
1067
+ // to this conversation (one-time legacy adoption, see decisions D12).
1068
+ await this.adoptLegacyStagedAttachments(msg, placement.sessionDir, warn)
477
1069
  await this.sessions.mountGuidance(handle.agent, placement.sessionDir)
478
1070
  } catch (error) {
479
1071
  // The card lands in a final failure state — no repair needed later. A
@@ -503,11 +1095,28 @@ export class InboundRouter {
503
1095
  lastActivityAt: createdAt,
504
1096
  sessionHistory: [{ sessionId, createdAt }],
505
1097
  }
506
- await this.routes.put(key, record)
1098
+ // The delivery gate is opened BEFORE the route becomes durable: a message
1099
+ // that can see the route must also wait for this Session's first followup
1100
+ // (the image prefetch below keeps the route-to-followup window open).
1101
+ const closeDeliveryGate = this.openDeliveryGate(key)
1102
+ try {
1103
+ await this.routes.put(key, record)
507
1104
 
508
- // 6. Stream into the card and hand the message to the driver.
509
- this.streamer?.attach(handle.agent.session, sent.messageId, msg.messageId, typingId)
510
- handle.agent.followup(feishuUserMessage(content))
1105
+ // 6. Stream into the card and hand the message to the driver. The prompt
1106
+ // is assembled only now (after the route is durable and tools are
1107
+ // mounted): images may be prefetched, files stay lazy behind the
1108
+ // manifest, and the (first turn only) bounded topic history plus the
1109
+ // quoted-message excerpt are attached as text blocks. `routes.put` and
1110
+ // `followup` stay synchronous with respect to other turns because
1111
+ // later messages wait on the delivery gate.
1112
+ this.streamer?.attach(handle.agent.session, sent.messageId, msg.messageId, typingId)
1113
+ handle.agent.followup(await this.deliverTurnMessage(msg, record, content, {
1114
+ firstTurn: true,
1115
+ excludeMessageIds: [msg.messageId, sent.messageId],
1116
+ }))
1117
+ } finally {
1118
+ closeDeliveryGate()
1119
+ }
511
1120
  return record
512
1121
  }
513
1122
  private async continueSession(msg: NormalizedMessage, record: RouteRecord, content: string): Promise<void> {
@@ -534,14 +1143,9 @@ export class InboundRouter {
534
1143
  delete current.worktreeBranch
535
1144
  if (placement.worktreeBranch !== undefined) current = { ...current, worktreeBranch: placement.worktreeBranch }
536
1145
  }
537
- // Files staged before this conversation's topic existed follow its next
538
- // turn (for example a file sent in the chat before the first prompt).
539
- await adoptStagedAttachments(
540
- stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
541
- placement.sessionDir,
542
- ).catch((error: unknown) => {
543
- this.ctx.logger?.warn?.('dsh-channels: adopting staged attachments failed: %s', String(error))
544
- })
1146
+ // Files staged before this conversation's topic existed by a pre-v2
1147
+ // deployment follow its next turn (one-time legacy adoption, D12).
1148
+ await this.adoptLegacyStagedAttachments(msg, placement.sessionDir, warn)
545
1149
 
546
1150
  const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(current.sessionId))
547
1151
  const typingId = await typingPromise
@@ -564,6 +1168,6 @@ export class InboundRouter {
564
1168
  lastActivityAt: Date.now(),
565
1169
  })
566
1170
  this.streamer?.attach(live.agent.session, sent.messageId, msg.messageId, typingId)
567
- live.agent.followup(feishuUserMessage(content))
1171
+ live.agent.followup(await this.deliverTurnMessage(msg, current, content))
568
1172
  }
569
1173
  }