@modesto_ma/dsh-channels 0.3.0 → 0.5.0-beta.1

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 (64) hide show
  1. package/README.md +123 -53
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +310 -0
  4. package/docs/feasibility-and-design.md +1 -1
  5. package/docs/feishu-onboarding.md +127 -21
  6. package/docs/persistence.md +6 -4
  7. package/docs/refactor-plan.md +2 -2
  8. package/docs/workspace-isolation-design.md +13 -11
  9. package/lib/client.js +179 -288
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +12125 -6382
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/access/cards.ts +9 -9
  15. package/src/access/controller.ts +10 -10
  16. package/src/access/index.ts +1 -0
  17. package/src/access/policy.ts +10 -10
  18. package/src/access/requests.ts +32 -31
  19. package/src/access/store.ts +12 -12
  20. package/src/access/targets.ts +56 -0
  21. package/src/cards/approval.ts +96 -26
  22. package/src/cards/index.ts +1 -0
  23. package/src/cards/onboarding.ts +1 -0
  24. package/src/cards/picker.ts +115 -0
  25. package/src/cards/streaming.ts +56 -7
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +0 -23
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/styles.ts +2 -0
  34. package/src/commands/registry.ts +2 -2
  35. package/src/config.ts +190 -32
  36. package/src/domain.ts +28 -18
  37. package/src/feishu/agent-context.ts +65 -19
  38. package/src/feishu/inbound-attachments.ts +440 -3
  39. package/src/feishu/manifest.ts +247 -0
  40. package/src/feishu/message-read.ts +1330 -0
  41. package/src/feishu/native-attachments.ts +998 -0
  42. package/src/feishu/scopes.ts +171 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +2305 -0
  46. package/src/feishu-channel.ts +322 -6
  47. package/src/host/agent.ts +55 -0
  48. package/src/host/attachments.ts +210 -0
  49. package/src/host/index.ts +1 -0
  50. package/src/index.ts +5 -4
  51. package/src/interactions/approvals.ts +187 -7
  52. package/src/interactions/pending.ts +3 -3
  53. package/src/routing/inbound.ts +620 -49
  54. package/src/service.ts +808 -375
  55. package/src/session/manager.ts +67 -13
  56. package/src/session/routes.ts +2 -3
  57. package/src/settings/contract.ts +90 -89
  58. package/src/settings/overlay.ts +124 -13
  59. package/src/settings/routes.ts +3 -17
  60. package/src/settings/surface.ts +156 -82
  61. package/src/streamer.ts +94 -5
  62. package/src/workspaces.ts +39 -26
  63. package/src/cleanup.ts +0 -535
  64. package/src/client/panel/cleanup-card.tsx +0 -106
@@ -10,20 +10,31 @@
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
20
  import type { FeishuDomainHandle, 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 { KnownAttachmentMessageInput, KnownAttachmentSource } from '../feishu/tools.ts'
35
+ import type { FeishuChannel, NormalizedMessage } from '../feishu-channel.ts'
26
36
  import type { QuestionAnswerer } from '../interactions/questions.ts'
37
+ import type { ApprovalAnswerer, ApprovalTextResult } from '../interactions/approvals.ts'
27
38
  import type { InflightRepair } from '../session/repair.ts'
28
39
  import type { SessionManager } from '../session/manager.ts'
29
40
  import type { RouteStore } from '../session/routes.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,16 +114,47 @@ 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
+ /**
124
+ * Pre-authorize a message's attachments for `feishu_attachment_fetch`:
125
+ * `trigger` (the triggering message) by default, `quote`/`history`/`recent`
126
+ * for the messages this turn's excerpt, history and manifest blocks name.
127
+ */
128
+ noteKnownAttachments(
129
+ record: RouteRecord,
130
+ message: KnownAttachmentMessageInput,
131
+ source?: KnownAttachmentSource,
132
+ ): void
133
+ /**
134
+ * Read-only message access: quoted-message excerpt, first-turn topic
135
+ * history, and recent attachment messages. Failures degrade silently to an
136
+ * absent value (never a throw into the turn).
137
+ */
138
+ messageRead: InboundMessageReadPort
139
+ /** Live image-prefetch policy for triggering messages. */
140
+ prefetchImages(): AttachmentPrefetchPolicy
68
141
  /** Channel-plane access control (administrator + whitelist gates). */
69
142
  access: InboundAccessPort
70
143
  /** Shared per-route FIFO admission (commands and turns must not interleave). */
71
144
  queue: RouteOperationQueue
72
145
  streamer(): TurnStreamer | undefined
73
146
  questionAnswerer(): QuestionAnswerer | undefined
147
+ approvalAnswerer(): ApprovalAnswerer | undefined
74
148
  commands: CommandPlanePort
75
149
  }
76
150
 
151
+ /** Confirmation line for each keyword approval reply. */
152
+ const APPROVAL_TEXT_REPLIES: Record<Exclude<ApprovalTextResult, 'superseded'>, string> = {
153
+ allowed: '已允许本次操作。',
154
+ remembered: '已允许,本话题内同类操作不再询问。',
155
+ denied: '已拒绝本次操作。',
156
+ }
157
+
77
158
  /**
78
159
  * User-facing text for attachments that could not be archived. Delivered as a
79
160
  * plain reply (never a card) so the user learns why the Agent cannot read a
@@ -98,6 +179,55 @@ export function feishuUserMessage(text: string): UserMessage {
98
179
  } as unknown as UserMessage
99
180
  }
100
181
 
182
+ /**
183
+ * Whitelist registration input for one triggering message.
184
+ *
185
+ * The fetch tool requires explicit per-route authorization; the trigger
186
+ * message is the one exception the admission layer can vouch for. Resources
187
+ * are copied from the same normalization the manifest uses, and merged-forward
188
+ * containers carry no direct resources (the tool expands them through the
189
+ * container id, per the P0 rule).
190
+ */
191
+ export function knownAttachmentMessageOf(msg: NormalizedMessage): KnownAttachmentMessageInput {
192
+ const forwarded = msg.rawContentType === 'merge_forward'
193
+ const resources = attachmentResources(msg).map(resource => ({
194
+ type: resource.type,
195
+ fileKey: resource.fileKey,
196
+ ...(resource.fileName === undefined ? {} : { fileName: resource.fileName }),
197
+ ...(resource.folder === true ? { folder: true } : {}),
198
+ }))
199
+ return {
200
+ messageId: msg.messageId,
201
+ kind: forwarded ? 'merge_forward' : 'message',
202
+ resources,
203
+ senderUserId: msg.senderId,
204
+ }
205
+ }
206
+
207
+ /** Feishu message-id shape accepted for quote resolution (`om_...`). */
208
+ const QUOTE_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,128}$/u
209
+
210
+ /** Read-only prompt context assembled for one turn (quote excerpt + history). */
211
+ interface TurnReadContext {
212
+ /** Ready `<feishu_quote>` text block, when the message quotes something. */
213
+ quoteBlock?: string
214
+ /** Whitelist registration of the quoted message (manifest/discoverability). */
215
+ quoteMessage?: KnownAttachmentMessageInput
216
+ /** Ready `<feishu_history>` text block, first turn only. */
217
+ historyBlock?: string
218
+ }
219
+
220
+ /** Per-turn inputs of the prompt assembly. */
221
+ interface TurnDeliveryOptions {
222
+ /** New Session's first turn: attach the bounded topic history bootstrap. */
223
+ firstTurn?: boolean
224
+ /**
225
+ * Message ids never shown in the history block or listed as recent
226
+ * attachments (trigger + streaming card).
227
+ */
228
+ excludeMessageIds?: readonly string[]
229
+ }
230
+
101
231
  export class InboundRouter {
102
232
  /**
103
233
  * In-process dedup guard: message id → accept timestamp. Set synchronously
@@ -114,6 +244,13 @@ export class InboundRouter {
114
244
  * overwrites, or the "no route, no mention" drop window.
115
245
  */
116
246
  private readonly establishing = new Map<string, Promise<RouteRecord>>()
247
+ /**
248
+ * route key → first-turn delivery gate. A new Session persists its route
249
+ * before the prompt is assembled (image prefetch); a message that observes
250
+ * the route in that window must wait for the first followup instead of
251
+ * racing it, otherwise the topic's turns reach the model out of order.
252
+ */
253
+ private readonly firstDeliveries = new Map<string, { promise: Promise<void>; release(): void }>()
117
254
 
118
255
  constructor(private readonly deps: InboundDeps) {}
119
256
 
@@ -124,10 +261,11 @@ export class InboundRouter {
124
261
  private get repair(): InflightRepair { return this.deps.repair }
125
262
  private get delivery(): CardDelivery { return this.deps.delivery }
126
263
  private get sessions(): SessionManager { return this.deps.sessions }
127
- private get attachments(): AttachmentIntake { return this.deps.attachments }
264
+ private get attachmentStore(): InboundAttachmentStore { return this.deps.attachmentStore }
128
265
  private get routeOperations(): RouteOperationQueue { return this.deps.queue }
129
266
  private get streamer(): TurnStreamer | undefined { return this.deps.streamer() }
130
267
  private get questionAnswerer(): QuestionAnswerer | undefined { return this.deps.questionAnswerer() }
268
+ private get approvalAnswerer(): ApprovalAnswerer | undefined { return this.deps.approvalAnswerer() }
131
269
  private get domain(): FeishuDomainHandle | undefined { return this.deps.domain() }
132
270
  private get access(): InboundAccessPort { return this.deps.access }
133
271
 
@@ -138,6 +276,28 @@ export class InboundRouter {
138
276
  }
139
277
  }
140
278
 
279
+ /**
280
+ * Open the first-turn delivery gate of one route. Registered BEFORE the
281
+ * route record is persisted, so any message that can observe the route also
282
+ * observes its gate. The returned closer must be called exactly once.
283
+ */
284
+ private openDeliveryGate(key: string): () => void {
285
+ let settle!: () => void
286
+ const promise = new Promise<void>((resolve) => { settle = resolve })
287
+ const release = (): void => {
288
+ if (this.firstDeliveries.get(key)?.promise === promise) this.firstDeliveries.delete(key)
289
+ settle()
290
+ }
291
+ this.firstDeliveries.set(key, { promise, release })
292
+ return release
293
+ }
294
+
295
+ /** Wait for the in-flight first followup of a route, when one is pending. */
296
+ private async awaitFirstDelivery(key: string): Promise<void> {
297
+ const pending = this.firstDeliveries.get(key)
298
+ if (pending !== undefined) await pending.promise.catch(() => undefined)
299
+ }
300
+
141
301
  /**
142
302
  * Send the one-time onboarding card for a private chat, then remember the
143
303
  * chat in the domain global. Best-effort: a storage failure only risks a
@@ -163,12 +323,14 @@ export class InboundRouter {
163
323
  clear(): void {
164
324
  this.accepted.clear()
165
325
  this.establishing.clear()
326
+ // Resolve pending gates before dropping them so a message waiting on a
327
+ // first-turn delivery never stays suspended across a teardown.
328
+ for (const entry of [...this.firstDeliveries.values()]) entry.release()
329
+ this.firstDeliveries.clear()
166
330
  }
167
331
 
168
332
  /** Entry point: one normalized inbound message. */
169
333
  async handle(msg: NormalizedMessage): Promise<void> {
170
- const botId = this.delivery.requireChannel().botOpenId()
171
- if (botId !== undefined && msg.senderId === botId) return
172
334
  if (msg.chatType !== 'p2p' && msg.chatType !== 'group') return
173
335
  let content = (msg.content ?? '').trim()
174
336
  if (content === '') return
@@ -223,19 +385,44 @@ export class InboundRouter {
223
385
  return
224
386
  }
225
387
 
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))
388
+ // Attachment admission. The effective backend selects between the legacy
389
+ // synchronous workspace download (`attachmentStore: 'archive'`, or a
390
+ // native deployment whose host does not mount the attachment service) and
391
+ // the v2 lazy pipeline: no attachment bytes are downloaded before the
392
+ // session/card exists, and files are never downloaded by admission at all.
393
+ if (this.attachmentBackend() === 'archive') {
394
+ // Attachments are archived before session work: an attachment-only
395
+ // message produces no model turn, and a caption mixed with attachments
396
+ // keeps only the caption as the prompt. The Agent reads the files on
397
+ // demand later.
398
+ const archived = await this.archiveAttachments(msg)
399
+ if (archived !== undefined) {
400
+ if (archived.failures.length > 0) {
401
+ await this.delivery.noticeText(msg, buildAttachmentFailureNotice(archived.failures))
402
+ }
403
+ if (archived.attachmentOnly) return
404
+ content = archived.text
405
+ if (content === '') return
406
+ }
407
+ } else {
408
+ // Native pipeline: classify without downloading. Attachment-only
409
+ // messages and messages that cannot start a turn stay inert (no reply,
410
+ // no reaction, no Session); a mixed caption becomes the prompt with its
411
+ // placeholders stripped.
412
+ const state = inboundAttachmentState(msg)
413
+ if (state.hasPayload) {
414
+ const resolution = this.resolveNativeAttachmentMessage(msg, state)
415
+ if (resolution.kind === 'silent') return
416
+ content = resolution.text
417
+ if (content === '') return
233
418
  }
234
- if (archived.attachmentOnly) return
235
- content = archived.text
236
- if (content === '') return
237
419
  }
238
420
 
421
+ // A session blocked on an approval card reads the owner's reply as the
422
+ // decision; any other text rejects the card and continues below as the
423
+ // next instruction.
424
+ if (await this.tryAnswerPendingApproval(msg, content)) return
425
+
239
426
  // A session blocked on `ask_user_question` has no other use for an inbound
240
427
  // text message: the next reply becomes the free-text answer.
241
428
  if (await this.tryAnswerPendingQuestion(msg, content)) return
@@ -247,6 +434,9 @@ export class InboundRouter {
247
434
  // Only the topic creator may continue its Session (admission already
248
435
  // passed, but another authorized user could be in the same group).
249
436
  if (!this.access.ownsRoute(msg, record)) return
437
+ // The route may be durable while its first turn is still being
438
+ // prepared (prefetch): stay behind that turn instead of racing it.
439
+ await this.awaitFirstDelivery(record.key)
250
440
  await this.routeOperations.run(record.key, () => {
251
441
  if (!this.started) return
252
442
  return this.continueSession(msg, this.routes.get(record.key) ?? record, content)
@@ -260,6 +450,7 @@ export class InboundRouter {
260
450
  const chatRecord = this.routes.get(chatKey)
261
451
  if (chatRecord !== undefined) {
262
452
  if (!this.access.ownsRoute(msg, chatRecord)) return
453
+ await this.awaitFirstDelivery(chatRecord.key)
263
454
  await this.routeOperations.run(chatRecord.key, () => {
264
455
  if (!this.started) return
265
456
  return this.continueSession(msg, this.routes.get(chatRecord.key) ?? chatRecord, content)
@@ -292,14 +483,17 @@ export class InboundRouter {
292
483
  }
293
484
 
294
485
  /**
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).
486
+ * Legacy archive path: download every resource of one message into the
487
+ * conversation workspace (or its pre-topic staging directory). Returns
488
+ * `undefined` when the message carries no archivable payload; otherwise the
489
+ * storage result whose `text` is the remaining prompt (possibly empty).
298
490
  *
299
491
  * 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.
492
+ * a file is queued behind the file's download instead of racing it. Only the
493
+ * `archive` effective backend calls this; the native pipeline never downloads
494
+ * files during admission.
301
495
  */
302
- private async archiveAttachments(msg: NormalizedMessage): Promise<AttachmentArchiveResult | undefined> {
496
+ private async archiveAttachments(msg: NormalizedMessage): Promise<InboundAttachmentStoreResult | undefined> {
303
497
  if (!hasAttachmentPayload(msg)) return undefined
304
498
 
305
499
  const threadRecord = msg.threadId === undefined ? undefined : this.routes.get(msg.threadId)
@@ -312,20 +506,387 @@ export class InboundRouter {
312
506
  // already owns does not.
313
507
  if (record === undefined && msg.chatType === 'group'
314
508
  && this.config.requireMention && !msg.mentionedBot) {
315
- return { saved: 0, failed: 0, failures: [], attachmentOnly: true, text: '' }
509
+ return {
510
+ mode: 'archive', saved: 0, failed: 0, failures: [], stored: [], attachmentOnly: true, text: '',
511
+ }
316
512
  }
317
513
 
318
514
  return await this.routeOperations.run(record?.key ?? chatKey, async () => {
319
- if (!this.started) return { saved: 0, failed: 0, failures: [], attachmentOnly: true, text: '' }
515
+ if (!this.started) {
516
+ return {
517
+ mode: 'archive', saved: 0, failed: 0, failures: [], stored: [], attachmentOnly: true, text: '',
518
+ } satisfies InboundAttachmentStoreResult
519
+ }
320
520
  // An existing topic already has its own working directory; a
321
521
  // conversation without one stages files below baseDir until its first
322
522
  // Session creates the topic directory and adopts them.
323
523
  const directory = record === undefined
324
524
  ? stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId)
325
525
  : attachmentsDir(sessionDirOf(record))
326
- return await this.attachments.archive(msg, directory)
526
+ return await this.attachmentStore.store(msg, directory)
327
527
  })
328
528
  }
529
+
530
+ /**
531
+ * One-time legacy migration: move files older plugin versions staged below
532
+ * `<baseDir>/_staging/...` into this conversation's topic directory.
533
+ *
534
+ * The v2 pipeline stores inbound attachments in the DSH native library and
535
+ * never writes staging, so this path only adopts files left by a pre-v2
536
+ * deployment. It is kept for one release cycle after attachment_pipeline_v2
537
+ * ships and is then removed together with `stagingPathFor` (decisions D12).
538
+ * A successful adoption logs one warning so operators can retire the old
539
+ * data instead of relying on this compatibility path indefinitely.
540
+ */
541
+ private async adoptLegacyStagedAttachments(
542
+ msg: NormalizedMessage,
543
+ sessionDir: string,
544
+ warn: (message: string, ...args: unknown[]) => void,
545
+ ): Promise<void> {
546
+ const adopted = await adoptStagedAttachments(
547
+ stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
548
+ sessionDir,
549
+ ).catch((error: unknown) => {
550
+ warn('dsh-channels: adopting staged attachments failed: %s', String(error))
551
+ return 0
552
+ })
553
+ if (adopted > 0) {
554
+ warn(
555
+ '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)',
556
+ adopted,
557
+ sessionDir,
558
+ )
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Effective inbound attachment backend for the live deployment.
564
+ *
565
+ * The lazy native pipeline can only deliver files through
566
+ * `feishu_attachment_fetch`; with that tool disabled (or without the host
567
+ * attachment library) the deployment falls back to the legacy archive
568
+ * backend, where the Agent reads the downloaded workspace copies instead of
569
+ * a manifest that would name an unavailable tool.
570
+ */
571
+ private attachmentBackend(): AttachmentStoreMode {
572
+ return effectiveAttachmentMode(
573
+ this.deps.attachmentMode(),
574
+ this.deps.nativeAttachments(),
575
+ this.config.feishuTools.attachmentFetch,
576
+ this.config.acceptAttachments,
577
+ )
578
+ }
579
+
580
+ /**
581
+ * Native-pipeline attachment decision for one message, without downloading.
582
+ *
583
+ * Returns `{ kind: 'silent' }` when the message must stay inert (no reply,
584
+ * no reaction, no Session, no download, no local state; a later turn finds
585
+ * it through the recent-attachment read) and `{ kind: 'prompt', text }` when
586
+ * a model turn starts with this text. The caller keeps the message flowing
587
+ * through the normal route decision tree.
588
+ */
589
+ private resolveNativeAttachmentMessage(
590
+ msg: NormalizedMessage,
591
+ state: InboundAttachmentState,
592
+ ): { kind: 'silent' } | { kind: 'prompt'; text: string } {
593
+ if (state.attachmentOnly) return { kind: 'silent' }
594
+ if (!this.config.acceptAttachments) return { kind: 'prompt', text: state.text }
595
+
596
+ // Group messages without an @-mention (and without an already-bound topic)
597
+ // never start a turn; their attachments stay discoverable from the topic a
598
+ // later @-mentioned message creates (Roadmap AC-4).
599
+ const threadRecord = msg.threadId === undefined ? undefined : this.routes.get(msg.threadId)
600
+ const chatKey = chatRouteKey(msg.chatType, msg.chatId, msg.senderId)
601
+ const chatRecord = threadRecord === undefined ? this.routes.get(chatKey) : undefined
602
+ const requiresMention = msg.chatType === 'group' && this.config.requireMention && !msg.mentionedBot
603
+ const triggers = threadRecord !== undefined || chatRecord !== undefined
604
+ || this.establishing.has(chatKey) || !requiresMention
605
+ return triggers ? { kind: 'prompt', text: state.text } : { kind: 'silent' }
606
+ }
607
+
608
+ /**
609
+ * Build the first-turn user message for the native pipeline: the caption
610
+ * text, the quoted-message excerpt, the prefetched trigger images, the
611
+ * `<feishu_context>` manifest block listing every attachment still unloaded,
612
+ * and (first turn only) the `<feishu_history>` topic bootstrap. Optional
613
+ * blocks are omitted entirely (no token cost) when empty.
614
+ *
615
+ * Runs after the session/card/route exist and before `followup`; only the
616
+ * triggering message's images are prefetched (bounded and short-timeout),
617
+ * files never are. Block order is stable: text, quote, images, manifest,
618
+ * history.
619
+ */
620
+ private async nativeTurnMessage(
621
+ msg: NormalizedMessage,
622
+ record: RouteRecord,
623
+ content: string,
624
+ options: TurnDeliveryOptions,
625
+ ): Promise<UserMessage> {
626
+ const message = feishuUserMessage(content)
627
+ const blocks: ContentBlock[] = content === '' ? [] : [{ type: 'text', text: content }]
628
+ const state = inboundAttachmentState(msg)
629
+ const attachmentPipeline = this.config.acceptAttachments
630
+
631
+ // Authorize the trigger message before any prefetch attempt so the model
632
+ // can fetch whatever the prefetch could not load.
633
+ if (state.hasPayload && attachmentPipeline) {
634
+ try {
635
+ this.deps.noteKnownAttachments(record, knownAttachmentMessageOf(msg))
636
+ } catch (error) {
637
+ this.ctx.logger?.warn?.(
638
+ 'dsh-channels: authorizing trigger attachments of %s failed: %s',
639
+ msg.messageId, String(error),
640
+ )
641
+ }
642
+ }
643
+
644
+ // Quote excerpt + first-turn history: read only after the trigger message
645
+ // is durable and whitelisted (the record exists and the message is known),
646
+ // so a quoted/earlier attachment can reference the trigger's route. The
647
+ // read runs CONCURRENTLY with the bounded image prefetch below: neither
648
+ // waits for the other, and a failed read degrades to its absence (AC6).
649
+ const contextPromise = this.readTurnContext(msg, record, options)
650
+
651
+ const loadedFileKeys = new Set<string>()
652
+ const prefetchedBlocks: ContentBlock[] = []
653
+ const policy = this.deps.prefetchImages()
654
+ const images = state.resources.filter(resource => resource.type === 'image')
655
+ if (state.hasPayload && attachmentPipeline && policy.enabled && images.length > 0) {
656
+ const prefetched = await prefetchImageAttachments({
657
+ msg,
658
+ resources: images,
659
+ channel: this.liveChannel(),
660
+ maxImageBytes: policy.maxImageBytes,
661
+ maxTotalBytes: policy.maxTotalBytes,
662
+ timeoutMs: policy.timeoutMs,
663
+ saveImage: async (bytes, name) => {
664
+ const store = new NativeAttachmentStore({ service: () => this.deps.nativeAttachments() })
665
+ const saved = await store.saveImage({ bytes, ...(name === undefined ? {} : { name }) })
666
+ return saved.ok
667
+ ? { ok: true, block: saved.value.block }
668
+ : { ok: false, reason: saved.reason }
669
+ },
670
+ warn: (warning, ...args) => this.ctx.logger?.warn?.(warning, ...args),
671
+ })
672
+ // Spread each block into a plain object literal: the host `ContentBlock`
673
+ // union carries an index signature that the named `ImageBlock` interface
674
+ // does not declare, and the clone is structurally identical.
675
+ prefetchedBlocks.push(...prefetched.blocks.map(block => ({ ...block })))
676
+ for (const fileKey of prefetched.loadedFileKeys) loadedFileKeys.add(fileKey)
677
+ }
678
+ // Stable block order: text, quote, image blocks, manifest, history.
679
+ const context = await contextPromise
680
+ if (context.quoteBlock !== undefined) blocks.push({ type: 'text', text: context.quoteBlock })
681
+ blocks.push(...prefetchedBlocks)
682
+
683
+ const manifest = attachmentPipeline
684
+ ? buildFeishuContextBlock({
685
+ trigger: triggerManifestAttachments(msg, loadedFileKeys),
686
+ // A quoted file must be DISCOVERABLE in the manifest (AC1): the quote
687
+ // block names it, and this row carries the message_id/file_key the
688
+ // model needs for feishu_attachment_fetch.
689
+ ...(context.quoteMessage === undefined
690
+ ? {}
691
+ : { quote: manifestEntriesOfKnownMessage(context.quoteMessage) }),
692
+ })
693
+ : ''
694
+ if (manifest !== '') blocks.push({ type: 'text', text: manifest })
695
+ if (context.historyBlock !== undefined) blocks.push({ type: 'text', text: context.historyBlock })
696
+ // A trigger always carries caption text, but keep the message well-formed
697
+ // if a future call path ever reaches here with nothing to say.
698
+ if (blocks.length === 0) blocks.push({ type: 'text', text: '' })
699
+ message.content = blocks
700
+ return message
701
+ }
702
+
703
+ /**
704
+ * Deliver the prompt for one turn. The lazy native pipeline assembles the
705
+ * attachment blocks + manifest; every other deployment keeps the plain text
706
+ * prompt. Both backends still carry the quoted-message excerpt and (first
707
+ * turn only) the bounded topic history: those are read-only text context and
708
+ * remain useful without the lazy fetch tool.
709
+ *
710
+ * A failure in prompt assembly degrades to the plain text prompt (the turn
711
+ * must run); a failed quote/history read degrades silently to its absence.
712
+ */
713
+ private async deliverTurnMessage(
714
+ msg: NormalizedMessage,
715
+ record: RouteRecord,
716
+ content: string,
717
+ options: TurnDeliveryOptions = {},
718
+ ): Promise<UserMessage> {
719
+ try {
720
+ if (this.attachmentBackend() !== 'native') {
721
+ return await this.textTurnMessage(msg, record, content, options)
722
+ }
723
+ return await this.nativeTurnMessage(msg, record, content, options)
724
+ } catch (error) {
725
+ this.ctx.logger?.warn?.(
726
+ 'dsh-channels: assembling the attachment manifest for %s failed: %s',
727
+ msg.messageId, String(error),
728
+ )
729
+ return feishuUserMessage(content)
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Text-only turn prompt (archive/fallback deployments): the legacy single
735
+ * text block, extended with the quote excerpt and the first-turn history
736
+ * block when they exist. With nothing extra this is byte-identical to
737
+ * {@link feishuUserMessage}.
738
+ */
739
+ private async textTurnMessage(
740
+ msg: NormalizedMessage,
741
+ record: RouteRecord,
742
+ content: string,
743
+ options: TurnDeliveryOptions,
744
+ ): Promise<UserMessage> {
745
+ const context = await this.readTurnContext(msg, record, options)
746
+ if (context.quoteBlock === undefined && context.historyBlock === undefined) {
747
+ return feishuUserMessage(content)
748
+ }
749
+ const blocks: ContentBlock[] = content === '' ? [] : [{ type: 'text', text: content }]
750
+ if (context.quoteBlock !== undefined) blocks.push({ type: 'text', text: context.quoteBlock })
751
+ if (context.historyBlock !== undefined) blocks.push({ type: 'text', text: context.historyBlock })
752
+ const message = feishuUserMessage(content)
753
+ message.content = blocks
754
+ return message
755
+ }
756
+
757
+ /**
758
+ * Read the read-only prompt context of one turn: the quoted-message excerpt
759
+ * (every triggered message, per D3) and the bounded topic history (new
760
+ * Session's first turn only). Both reads run concurrently and are bounded by
761
+ * a short prompt budget, so a slow/stalled read can delay the turn by at
762
+ * most {@link PROMPT_CONTEXT_TIMEOUT} — never by a model-tool timeout (AC4:
763
+ * the first turn is not blocked by enrichment).
764
+ */
765
+ private async readTurnContext(
766
+ msg: NormalizedMessage,
767
+ record: RouteRecord,
768
+ options: TurnDeliveryOptions,
769
+ ): Promise<TurnReadContext> {
770
+ const [quote, historyBlock] = await Promise.all([
771
+ this.readQuoteBlock(msg, record),
772
+ this.readHistoryBlock(record, options),
773
+ ])
774
+ return {
775
+ ...(quote === undefined
776
+ ? {}
777
+ : {
778
+ quoteBlock: quote.block,
779
+ ...(quote.message === undefined ? {} : { quoteMessage: quote.message }),
780
+ }),
781
+ ...(historyBlock === undefined ? {} : { historyBlock }),
782
+ }
783
+ }
784
+
785
+ /**
786
+ * Resolve the quoted message into a `<feishu_quote>` block and register the
787
+ * quoted message (including a merged-forward container, by container id) in
788
+ * the route's attachment whitelist. Only messages that already triggered a
789
+ * turn reach this point (D3: quoting the bot without @ never triggers).
790
+ */
791
+ private async readQuoteBlock(
792
+ msg: NormalizedMessage,
793
+ record: RouteRecord,
794
+ ): Promise<{ block: string; message?: KnownAttachmentMessageInput } | undefined> {
795
+ const messageId = msg.replyToMessageId
796
+ if (messageId === undefined || !QUOTE_MESSAGE_ID.test(messageId)) return undefined
797
+ try {
798
+ const quote = await this.deps.messageRead.quote({ messageId, record })
799
+ if (quote === undefined) return undefined
800
+ if (quote.message !== undefined) {
801
+ try {
802
+ this.deps.noteKnownAttachments(record, quote.message, 'quote')
803
+ } catch (error) {
804
+ // Best-effort whitelist registration: the excerpt is still useful.
805
+ this.ctx.logger?.debug?.(
806
+ 'dsh-channels: registering quoted attachments of %s failed: %s',
807
+ messageId, String(error),
808
+ )
809
+ }
810
+ }
811
+ return quote.block === '' ? undefined : quote
812
+ } catch (error) {
813
+ // Missing read scope / deleted message / channel down: no excerpt.
814
+ this.ctx.logger?.debug?.(
815
+ 'dsh-channels: reading quoted message %s failed: %s', messageId, String(error),
816
+ )
817
+ return undefined
818
+ }
819
+ }
820
+
821
+ /**
822
+ * Resolve the first-turn topic history into a `<feishu_history>` block and
823
+ * register every message it shows in the route's attachment whitelist.
824
+ * Disabled by `historyBootstrapLimit: 0`; continuations never read history.
825
+ */
826
+ private async readHistoryBlock(
827
+ record: RouteRecord,
828
+ options: TurnDeliveryOptions,
829
+ ): Promise<string | undefined> {
830
+ if (options.firstTurn !== true) return undefined
831
+ const limit = this.config.historyBootstrapLimit
832
+ if (!Number.isFinite(limit) || limit <= 0) return undefined
833
+ try {
834
+ const history = await this.deps.messageRead.history({
835
+ record,
836
+ limit,
837
+ excludeMessageIds: options.excludeMessageIds ?? [],
838
+ })
839
+ if (history === undefined) return undefined
840
+ for (const message of history.messages) {
841
+ try {
842
+ this.deps.noteKnownAttachments(record, message, 'history')
843
+ } catch (error) {
844
+ this.ctx.logger?.debug?.(
845
+ 'dsh-channels: registering history attachments of %s failed: %s',
846
+ message.messageId, String(error),
847
+ )
848
+ }
849
+ }
850
+ return history.block === '' ? undefined : history.block
851
+ } catch (error) {
852
+ this.ctx.logger?.debug?.(
853
+ 'dsh-channels: reading topic history for %s failed: %s', record.key, String(error),
854
+ )
855
+ return undefined
856
+ }
857
+ }
858
+
859
+ /** Live channel accessor for best-effort prefetch; undefined when gone. */
860
+ private liveChannel(): FeishuChannel | undefined {
861
+ try {
862
+ return this.delivery.requireChannel()
863
+ } catch {
864
+ return undefined
865
+ }
866
+ }
867
+
868
+ /**
869
+ * Consume one plain reply as an approval decision. Only the topic owner's
870
+ * replies in a session with an undecided approval card are intercepted.
871
+ * @returns true when the reply was a keyword decision and is fully handled;
872
+ * false when nothing was pending or the reply rejected the pending cards and
873
+ * must still be delivered as the next instruction.
874
+ */
875
+ private async tryAnswerPendingApproval(msg: NormalizedMessage, content: string): Promise<boolean> {
876
+ const answerer = this.approvalAnswerer
877
+ if (answerer === undefined || answerer.pendingCount() === 0) return false
878
+ const route = await this.commandRoute(msg)
879
+ if (route === undefined || !this.access.ownsRoute(msg, route)) return false
880
+ if (!answerer.hasPendingFor(route.sessionId)) return false
881
+ const result = answerer.submitText(route.sessionId, content)
882
+ if (result === undefined || result === 'superseded') return false
883
+ await this.delivery.commandCard(msg, {
884
+ kind: 'success', command: 'approval', sessionId: route.sessionId,
885
+ text: APPROVAL_TEXT_REPLIES[result],
886
+ }, { createThread: false })
887
+ return true
888
+ }
889
+
329
890
  /**
330
891
  * Consume one plain reply as an `ask_user_question` free-text answer.
331
892
  *
@@ -467,13 +1028,9 @@ export class InboundRouter {
467
1028
  warn('dsh-channels: attaching initial session %s to workspace failed: %s', sessionId, String(error))
468
1029
  })
469
1030
  }
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
- })
1031
+ // Files staged before this topic existed by a pre-v2 deployment belong
1032
+ // to this conversation (one-time legacy adoption, see decisions D12).
1033
+ await this.adoptLegacyStagedAttachments(msg, placement.sessionDir, warn)
477
1034
  await this.sessions.mountGuidance(handle.agent, placement.sessionDir)
478
1035
  } catch (error) {
479
1036
  // The card lands in a final failure state — no repair needed later. A
@@ -494,7 +1051,7 @@ export class InboundRouter {
494
1051
  sessionId,
495
1052
  chatId: msg.chatId,
496
1053
  chatType: msg.chatType,
497
- senderOpenId: msg.senderId,
1054
+ senderUserId: msg.senderId,
498
1055
  workspacePath: workspace.path,
499
1056
  sessionDir: placement.sessionDir,
500
1057
  ...(placement.worktreeBranch === undefined ? {} : { worktreeBranch: placement.worktreeBranch }),
@@ -503,11 +1060,28 @@ export class InboundRouter {
503
1060
  lastActivityAt: createdAt,
504
1061
  sessionHistory: [{ sessionId, createdAt }],
505
1062
  }
506
- await this.routes.put(key, record)
1063
+ // The delivery gate is opened BEFORE the route becomes durable: a message
1064
+ // that can see the route must also wait for this Session's first followup
1065
+ // (the image prefetch below keeps the route-to-followup window open).
1066
+ const closeDeliveryGate = this.openDeliveryGate(key)
1067
+ try {
1068
+ await this.routes.put(key, record)
507
1069
 
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))
1070
+ // 6. Stream into the card and hand the message to the driver. The prompt
1071
+ // is assembled only now (after the route is durable and tools are
1072
+ // mounted): images may be prefetched, files stay lazy behind the
1073
+ // manifest, and the (first turn only) bounded topic history plus the
1074
+ // quoted-message excerpt are attached as text blocks. `routes.put` and
1075
+ // `followup` stay synchronous with respect to other turns because
1076
+ // later messages wait on the delivery gate.
1077
+ this.streamer?.attach(handle.agent.session, sent.messageId, msg.messageId, typingId)
1078
+ handle.agent.followup(await this.deliverTurnMessage(msg, record, content, {
1079
+ firstTurn: true,
1080
+ excludeMessageIds: [msg.messageId, sent.messageId],
1081
+ }))
1082
+ } finally {
1083
+ closeDeliveryGate()
1084
+ }
511
1085
  return record
512
1086
  }
513
1087
  private async continueSession(msg: NormalizedMessage, record: RouteRecord, content: string): Promise<void> {
@@ -534,14 +1108,9 @@ export class InboundRouter {
534
1108
  delete current.worktreeBranch
535
1109
  if (placement.worktreeBranch !== undefined) current = { ...current, worktreeBranch: placement.worktreeBranch }
536
1110
  }
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
- })
1111
+ // Files staged before this conversation's topic existed by a pre-v2
1112
+ // deployment follow its next turn (one-time legacy adoption, D12).
1113
+ await this.adoptLegacyStagedAttachments(msg, placement.sessionDir, warn)
545
1114
 
546
1115
  const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(current.sessionId))
547
1116
  const typingId = await typingPromise
@@ -564,6 +1133,8 @@ export class InboundRouter {
564
1133
  lastActivityAt: Date.now(),
565
1134
  })
566
1135
  this.streamer?.attach(live.agent.session, sent.messageId, msg.messageId, typingId)
567
- live.agent.followup(feishuUserMessage(content))
1136
+ live.agent.followup(await this.deliverTurnMessage(msg, current, content, {
1137
+ excludeMessageIds: [msg.messageId, sent.messageId],
1138
+ }))
568
1139
  }
569
1140
  }