@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.
- package/README.md +71 -19
- package/docs/attachment-pipeline-v2.md +318 -0
- package/docs/feasibility-and-design.md +1 -1
- package/docs/feishu-onboarding.md +108 -2
- package/docs/refactor-plan.md +2 -2
- package/docs/workspace-isolation-design.md +9 -6
- package/lib/client.js +10 -5
- package/lib/client.js.map +1 -1
- package/lib/index.js +10193 -4824
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cleanup.ts +13 -2
- package/src/config.ts +194 -5
- package/src/domain.ts +107 -0
- package/src/feishu/agent-context.ts +65 -19
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +270 -0
- package/src/feishu/message-read.ts +1333 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/pending-attachments.ts +562 -0
- package/src/feishu/tools.ts +2345 -0
- package/src/feishu-channel.ts +55 -0
- package/src/host/agent.ts +55 -0
- package/src/host/attachments.ts +210 -0
- package/src/host/index.ts +1 -0
- package/src/index.ts +1 -1
- package/src/routing/inbound.ts +652 -48
- package/src/service.ts +417 -9
- package/src/session/manager.ts +66 -12
- package/src/settings/contract.ts +28 -10
- package/src/settings/overlay.ts +124 -13
- package/src/settings/surface.ts +137 -12
- package/src/workspaces.ts +25 -4
package/src/routing/inbound.ts
CHANGED
|
@@ -10,19 +10,30 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { randomUUID } from 'node:crypto'
|
|
13
|
-
import type {
|
|
14
|
-
|
|
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 {
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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))
|
|
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
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
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<
|
|
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 {
|
|
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)
|
|
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.
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
|
538
|
-
//
|
|
539
|
-
await
|
|
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(
|
|
1171
|
+
live.agent.followup(await this.deliverTurnMessage(msg, current, content))
|
|
568
1172
|
}
|
|
569
1173
|
}
|