@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.
- package/README.md +123 -53
- package/docs/access-control-design.md +7 -5
- package/docs/attachment-pipeline-v2.md +310 -0
- package/docs/feasibility-and-design.md +1 -1
- package/docs/feishu-onboarding.md +127 -21
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +2 -2
- package/docs/workspace-isolation-design.md +13 -11
- package/lib/client.js +179 -288
- package/lib/client.js.map +1 -1
- package/lib/index.js +12125 -6382
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/cards.ts +9 -9
- package/src/access/controller.ts +10 -10
- package/src/access/index.ts +1 -0
- package/src/access/policy.ts +10 -10
- package/src/access/requests.ts +32 -31
- package/src/access/store.ts +12 -12
- package/src/access/targets.ts +56 -0
- package/src/cards/approval.ts +96 -26
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +1 -0
- package/src/cards/picker.ts +115 -0
- package/src/cards/streaming.ts +56 -7
- package/src/client/api.ts +5 -25
- package/src/client/panel/access-card.tsx +24 -15
- package/src/client/panel/access-tab.tsx +3 -3
- package/src/client/panel/connection-tab.tsx +70 -1
- package/src/client/panel/index.tsx +11 -62
- package/src/client/panel/shared.tsx +0 -23
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/styles.ts +2 -0
- package/src/commands/registry.ts +2 -2
- package/src/config.ts +190 -32
- package/src/domain.ts +28 -18
- package/src/feishu/agent-context.ts +65 -19
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +247 -0
- package/src/feishu/message-read.ts +1330 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/scopes.ts +171 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +2305 -0
- package/src/feishu-channel.ts +322 -6
- 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 +5 -4
- package/src/interactions/approvals.ts +187 -7
- package/src/interactions/pending.ts +3 -3
- package/src/routing/inbound.ts +620 -49
- package/src/service.ts +808 -375
- package/src/session/manager.ts +67 -13
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +90 -89
- package/src/settings/overlay.ts +124 -13
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +156 -82
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +39 -26
- package/src/cleanup.ts +0 -535
- package/src/client/panel/cleanup-card.tsx +0 -106
package/src/routing/inbound.ts
CHANGED
|
@@ -10,20 +10,31 @@
|
|
|
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
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 {
|
|
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 { 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
|
-
|
|
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
|
|
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
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
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<
|
|
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 {
|
|
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)
|
|
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.
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
|
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
|
-
})
|
|
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(
|
|
1136
|
+
live.agent.followup(await this.deliverTurnMessage(msg, current, content, {
|
|
1137
|
+
excludeMessageIds: [msg.messageId, sent.messageId],
|
|
1138
|
+
}))
|
|
568
1139
|
}
|
|
569
1140
|
}
|