@modesto_ma/dsh-channels 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +115 -39
  2. package/docs/access-control-design.md +1 -1
  3. package/docs/attachment-pipeline-v2.md +318 -0
  4. package/docs/feasibility-and-design.md +54 -26
  5. package/docs/feishu-onboarding.md +123 -15
  6. package/docs/persistence.md +4 -4
  7. package/docs/refactor-plan.md +7 -6
  8. package/docs/workspace-isolation-design.md +81 -0
  9. package/lib/client.js +30 -15
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +11008 -5132
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/onboarding.ts +23 -0
  16. package/src/cards/primitives.ts +20 -0
  17. package/src/cards/streaming.ts +310 -291
  18. package/src/cleanup.ts +267 -119
  19. package/src/client/panel/cleanup-card.tsx +4 -2
  20. package/src/client/panel/workspace-tab.tsx +1 -1
  21. package/src/config.ts +221 -13
  22. package/src/domain.ts +141 -3
  23. package/src/feishu/agent-context.ts +66 -16
  24. package/src/feishu/inbound-attachments.ts +440 -3
  25. package/src/feishu/manifest.ts +270 -0
  26. package/src/feishu/message-read.ts +1333 -0
  27. package/src/feishu/native-attachments.ts +998 -0
  28. package/src/feishu/pending-attachments.ts +562 -0
  29. package/src/feishu/tools.ts +2345 -0
  30. package/src/feishu-channel.ts +55 -0
  31. package/src/host/agent.ts +55 -0
  32. package/src/host/attachments.ts +210 -0
  33. package/src/host/index.ts +1 -0
  34. package/src/index.ts +8 -6
  35. package/src/routing/inbound.ts +730 -81
  36. package/src/service.ts +536 -50
  37. package/src/session/isolation.ts +277 -0
  38. package/src/session/manager.ts +120 -23
  39. package/src/session/repair.ts +2 -3
  40. package/src/session/routes.ts +2 -0
  41. package/src/session-commands.ts +41 -14
  42. package/src/settings/contract.ts +43 -21
  43. package/src/settings/overlay.ts +124 -13
  44. package/src/settings/surface.ts +147 -14
  45. package/src/streamer.ts +70 -30
  46. package/src/workspaces.ts +74 -31
package/src/service.ts CHANGED
@@ -6,6 +6,10 @@
6
6
  * - p2p: one workspace per (bot, user), all of that user's sessions inside;
7
7
  * - group: one workspace per (chat, user), sessions grouped per user.
8
8
  *
9
+ * Every topic's Session works in its own directory inside that workspace
10
+ * (`topics/<token>` or a Git worktree), so topics never mix files while the
11
+ * workspace list grows with users instead of conversations.
12
+ *
9
13
  * Routing model:
10
14
  * - `thread_id` → bound session (no @ needed inside an owned topic);
11
15
  * - degraded chat-scoped keys when Feishu refuses topic replies;
@@ -45,15 +49,15 @@ import type { EffortCommandAction, ParsedSlashCommand } from './commands.ts'
45
49
  import { commandSpec } from './commands/registry.ts'
46
50
  import type { ChannelCommandSpec } from './commands/registry.ts'
47
51
  import type { PermissionPresets, SessionQuery } from './dsh-types.ts'
48
- import { feishuDomain } from './domain.ts'
52
+ import { feishuDomain, sessionDirOf } from './domain.ts'
49
53
  import {
50
54
  applyWorkspaceCleanup, isPathInside, planWorkspaceCleanup, retentionMs,
51
55
  } from './cleanup.ts'
52
- import type { CleanupDeps, WorkspaceStats } from './cleanup.ts'
56
+ import type { CleanupDeps, CleanupRoute, CleanupTombstone, ScannedSessionDir, WorkspaceStats } from './cleanup.ts'
53
57
  import type {
54
58
  AccessGroupRecord, AccessRequestRecord, AccessUserRecord,
55
- ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot, ProcessedRecord, RawDomainHandle,
56
- RouteRecord,
59
+ ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot, PendingAttachmentRecord,
60
+ PendingAttachmentsTable, ProcessedRecord, RawDomainHandle, RouteRecord,
57
61
  } from './domain.ts'
58
62
  import { RouteStore } from './session/routes.ts'
59
63
  import { InflightRepair } from './session/repair.ts'
@@ -61,6 +65,17 @@ import { SessionManager } from './session/manager.ts'
61
65
  import type { LiveAgent, RenewCommit } from './session/manager.ts'
62
66
  import { CardDelivery } from './feishu/delivery.ts'
63
67
  import { AttachmentIntake } from './feishu/inbound-attachments.ts'
68
+ import { InboundAttachmentStore, effectiveAttachmentMode, resolveAttachmentService } from './feishu/native-attachments.ts'
69
+ import { PendingAttachmentStore } from './feishu/pending-attachments.ts'
70
+ import { AttachmentFetchToolRuntime, TopicMessagesToolRuntime } from './feishu/tools.ts'
71
+ import type { KnownAttachmentMessageInput, KnownAttachmentSource } from './feishu/tools.ts'
72
+ import {
73
+ MessageReader, TOPIC_MESSAGES_MAX_LIMIT, buildHistoryContextBlock,
74
+ buildQuoteContextBlock, clampInt, knownMessageOfItems, knownMessagesOfHistory, quoteItemsOf,
75
+ readQuoteItems,
76
+ } from './feishu/message-read.ts'
77
+ import type { MessageReadRoute, RawMessageItem } from './feishu/message-read.ts'
78
+ import type { AttachmentStore } from './host/attachments.ts'
64
79
  import { InboundRouter } from './routing/inbound.ts'
65
80
  import type { RequestPolicyController } from './session/request-policy.ts'
66
81
  import type { ChannelFactory, FeishuChannel, NormalizedMessage } from './feishu-channel.ts'
@@ -82,7 +97,9 @@ import type { FeishuConfig } from './config.ts'
82
97
  import type {
83
98
  AccessMutationResponse, AccessRequestWire, AccessWire,
84
99
  } from './settings/contract.ts'
85
- import type { OverlayStore } from './settings/overlay.ts'
100
+ import { mergeOverlay } from './settings/overlay.ts'
101
+ import type { OverlayPatch, OverlayStore } from './settings/overlay.ts'
102
+ import { legacyTopicWorkspaceRoot } from './workspaces.ts'
86
103
 
87
104
  /** Periodic route-GC sweep cadence (a long-running instance also accumulates
88
105
  * dead records; TTL semantics make 6 h granularity more than sufficient). */
@@ -122,6 +139,29 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
122
139
  const accessGroupsRaw = domain.table('access_groups')
123
140
  const accessUsersRaw = domain.table('access_users')
124
141
  const accessRequestsRaw = domain.table('access_requests')
142
+ // Pending attachment metadata is a table ADDED to the domain at the same
143
+ // version (see `pendingAttachmentRecordSchema`); hosts that cannot accept the
144
+ // addition leave this undefined and the registry degrades to its memory TTL
145
+ // fallback without taking the whole storage domain down.
146
+ let pendingAttachmentsRaw: ReturnType<RawDomainHandle['table']> | undefined
147
+ try {
148
+ pendingAttachmentsRaw = domain.table('pending_attachments')
149
+ } catch (error) {
150
+ ctx.logger?.warn?.('dsh-channels: pending attachment table unavailable: %s', String(error))
151
+ }
152
+ const pendingAttachments: PendingAttachmentsTable | undefined = pendingAttachmentsRaw === undefined
153
+ ? undefined
154
+ : {
155
+ get: key => pendingAttachmentsRaw.get(key) as PendingAttachmentRecord | undefined,
156
+ put: (key, value) => pendingAttachmentsRaw.put(key, value),
157
+ update: (key, fn) => pendingAttachmentsRaw.update(
158
+ key,
159
+ current => fn(current as PendingAttachmentRecord),
160
+ ),
161
+ delete: key => pendingAttachmentsRaw.delete(key),
162
+ entries: () =>
163
+ pendingAttachmentsRaw.entries() as IterableIterator<[string, PendingAttachmentRecord]>,
164
+ }
125
165
  return {
126
166
  routes: {
127
167
  get: key => table.get(key) as RouteRecord | undefined,
@@ -165,6 +205,7 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
165
205
  delete: key => accessRequestsRaw.delete(key),
166
206
  entries: () => accessRequestsRaw.entries() as IterableIterator<[string, AccessRequestRecord]>,
167
207
  },
208
+ pendingAttachments,
168
209
  getGlobal: () => (domain.global.get() ?? {}) as FeishuDomainGlobal,
169
210
  setGlobal: value => domain.global.set(value),
170
211
  close: () => domain.close(),
@@ -265,10 +306,36 @@ export class FeishuService {
265
306
  private readonly delivery: CardDelivery
266
307
  /** Inbound attachment archival (Feishu resources → workspace files). */
267
308
  private readonly attachments: AttachmentIntake
309
+ /**
310
+ * Backend-selecting inbound storage facade. The native path stays lazy in
311
+ * admission (prefetch images + manifest); this facade owns the legacy
312
+ * workspace download used by `attachmentStore: 'archive'` and by the
313
+ * fallback when the host does not mount `ctx.attachments`.
314
+ */
315
+ private readonly inboundAttachmentStore: InboundAttachmentStore
316
+ /**
317
+ * Silently registered attachment metadata (`pending_attachments` table):
318
+ * admission-layer registration, prompt manifest reads, fetch authorization
319
+ * whitelist, and the TTL/cap sweep wired into the periodic record GC.
320
+ */
321
+ private readonly pendingAttachments: PendingAttachmentStore
322
+ /**
323
+ * Read-only Feishu message access (P2): the quoted-message excerpt and the
324
+ * first-turn topic history. Constructed before the inbound router so the
325
+ * turn assembly can reach it; it resolves the live channel lazily.
326
+ */
327
+ private readonly messageReader = new MessageReader({ channel: () => this.channel })
268
328
  /** Inbound routing: dedup, admission, decision tree, session establishment. */
269
329
  private readonly inbound: InboundRouter
270
330
  /** Agent/session lifecycle: live registry, resume, presets, renew. */
271
331
  private readonly sessions: SessionManager
332
+ /**
333
+ * Model-facing Feishu tools. `feishu_attachment_fetch` plus the shared
334
+ * per-route known-message whitelist the admission, quote and history layers
335
+ * populate; `feishu_topic_messages` reads the current topic on demand.
336
+ */
337
+ private readonly attachmentFetchTools: AttachmentFetchToolRuntime
338
+ private readonly topicMessagesTools: TopicMessagesToolRuntime
272
339
  /** FIFO admission for ordinary turns and commands targeting one route. */
273
340
  private readonly routeOperations = new RouteOperationQueue()
274
341
  /** Active Host command executions, aborted before shutdown tears peers down. */
@@ -293,6 +360,10 @@ export class FeishuService {
293
360
  private runningConfig: FeishuConfig | undefined
294
361
  /** Guards overlapping panel-triggered reconnects. */
295
362
  private restarting = false
363
+ /** One-shot warning for a native deployment without the host attachment library. */
364
+ private warnedNativeAttachmentsUnmounted = false
365
+ /** One-shot warning for silently degraded quote/history reads (missing scope). */
366
+ private warnedPromptReadDegraded = false
296
367
  private unlisten: (() => void) | undefined
297
368
  /** Disposers for host-scoped listeners registered by `start()`. */
298
369
  private readonly unlistenHost: Array<() => boolean> = []
@@ -350,6 +421,24 @@ export class FeishuService {
350
421
  persistRoute: (key, record) => this.routes.put(key, record),
351
422
  started: () => this.started,
352
423
  detachStream: sessionId => this.streamer?.detach(sessionId),
424
+ mountTools: (agentCtx, sessionId) => {
425
+ // The fetch tool only makes sense for the lazy native backend: in
426
+ // archive deployments (explicit, missing host library, or fetch tool
427
+ // disabled) every attachment is downloaded before the turn and the
428
+ // Agent reads the workspace copy, so mounting the tool would only
429
+ // advertise an operation that can never be authorized.
430
+ //
431
+ // The topic-messages tool is backend-independent: quoted excerpts and
432
+ // topic history are read-only, so it is mounted for both backends and
433
+ // self-gates on `feishuTools.topicMessages`. Both mounts start
434
+ // together and resolve as one promise (no serial blocking).
435
+ const mounts: Array<Promise<void>> = []
436
+ if (this.effectiveAttachmentBackend() === 'native') {
437
+ mounts.push(this.attachmentFetchTools.mount(agentCtx, sessionId))
438
+ }
439
+ mounts.push(this.topicMessagesTools.mount(agentCtx, sessionId))
440
+ return Promise.all(mounts).then(() => undefined)
441
+ },
353
442
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
354
443
  })
355
444
  this.attachments = new AttachmentIntake({
@@ -357,6 +446,65 @@ export class FeishuService {
357
446
  channel: () => this.channel,
358
447
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
359
448
  })
449
+ this.inboundAttachmentStore = new InboundAttachmentStore({
450
+ enabled: () => this.config.acceptAttachments,
451
+ // Effective backend, not the raw config: `store()` is only called after
452
+ // admission already decided `archive`, so a native-configured deployment
453
+ // with the fetch tool disabled must download to the workspace here too.
454
+ mode: () => this.effectiveAttachmentBackend(),
455
+ service: () => this.nativeAttachmentService(),
456
+ channel: () => this.channel,
457
+ archive: this.attachments,
458
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
459
+ })
460
+ this.pendingAttachments = new PendingAttachmentStore({
461
+ // Undefined storage domain/table → the registry's memory TTL fallback.
462
+ table: () => this.domain?.pendingAttachments,
463
+ enabled: () => this.config.acceptAttachments,
464
+ ttlMs: () => this.config.pendingAttachmentTtlMs,
465
+ maxPerScope: () => this.config.pendingAttachmentLimit,
466
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
467
+ })
468
+ this.attachmentFetchTools = new AttachmentFetchToolRuntime({
469
+ config: () => this.config,
470
+ channel: () => this.channel,
471
+ pending: () => this.pendingAttachments,
472
+ routeOfSession: sessionId => {
473
+ const record = this.routes.findBySession(sessionId)
474
+ if (record === undefined) return undefined
475
+ return {
476
+ key: record.key,
477
+ kind: record.kind,
478
+ chatId: record.chatId,
479
+ chatType: record.chatType,
480
+ senderOpenId: record.senderOpenId,
481
+ }
482
+ },
483
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
484
+ })
485
+ this.topicMessagesTools = new TopicMessagesToolRuntime({
486
+ config: () => this.config,
487
+ channel: () => this.channel,
488
+ routeOfSession: sessionId => {
489
+ const record = this.routes.findBySession(sessionId)
490
+ if (record === undefined) return undefined
491
+ return {
492
+ key: record.key,
493
+ kind: record.kind,
494
+ chatId: record.chatId,
495
+ chatType: record.chatType,
496
+ senderOpenId: record.senderOpenId,
497
+ }
498
+ },
499
+ noteKnownMessage: (route, message, source) => {
500
+ this.attachmentFetchTools.noteKnownMessage(route, message, source)
501
+ },
502
+ isKnownMessage: (route, messageId) => this.attachmentFetchTools.isKnownMessage(route, messageId),
503
+ // The tool description must not promise a fetch tool that archive
504
+ // deployments do not mount; the runtime appends a degradation note.
505
+ fetchAvailable: () => this.effectiveAttachmentBackend() === 'native',
506
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
507
+ })
360
508
  this.delivery = new CardDelivery({
361
509
  channel: () => this.channel,
362
510
  started: () => this.started,
@@ -392,7 +540,23 @@ export class FeishuService {
392
540
  repair: this.repair,
393
541
  delivery: this.delivery,
394
542
  sessions: this.sessions,
395
- attachments: this.attachments,
543
+ attachmentMode: () => this.config.attachmentStore,
544
+ nativeAttachments: () => this.nativeAttachmentService(),
545
+ attachmentStore: this.inboundAttachmentStore,
546
+ pendingAttachments: this.pendingAttachments,
547
+ noteKnownAttachments: (record, message, source) => {
548
+ this.noteKnownAttachmentMessage(record, message, source ?? 'trigger')
549
+ },
550
+ messageRead: {
551
+ quote: input => this.readQuoteContext(input),
552
+ history: input => this.readHistoryContext(input),
553
+ },
554
+ prefetchImages: () => ({
555
+ enabled: this.config.prefetchImages,
556
+ maxImageBytes: this.config.prefetchImageMaxBytes,
557
+ maxTotalBytes: this.config.prefetchTotalMaxBytes,
558
+ timeoutMs: this.config.prefetchTimeoutMs,
559
+ }),
396
560
  access: this.accessController,
397
561
  queue: this.routeOperations,
398
562
  streamer: () => this.streamer,
@@ -401,6 +565,222 @@ export class FeishuService {
401
565
  })
402
566
  }
403
567
 
568
+ /** Effective inbound attachment backend (matches the inbound router). */
569
+ private effectiveAttachmentBackend(): 'native' | 'archive' {
570
+ return effectiveAttachmentMode(
571
+ this.config.attachmentStore,
572
+ this.nativeAttachmentService(),
573
+ this.config.feishuTools.attachmentFetch,
574
+ this.config.acceptAttachments,
575
+ )
576
+ }
577
+
578
+ /**
579
+ * Resolve the host's native attachment library from the plugin scope.
580
+ *
581
+ * The service is registered host-wide (`@deepseek-ai/dsh-attachment-local`),
582
+ * so the safe `ctx.get` lookup resolves it even though this plugin does not
583
+ * declare `attachments` in its cordis `inject` list (the per-Agent scopes
584
+ * still inject it for the fetch tool). A missing service reads as
585
+ * `undefined` and switches admission to the legacy archive backend.
586
+ */
587
+ private nativeAttachmentService(): AttachmentStore | undefined {
588
+ const service = resolveAttachmentService(this.ctx)
589
+ if (service === undefined && this.config.attachmentStore === 'native'
590
+ && !this.warnedNativeAttachmentsUnmounted) {
591
+ // The facade now receives the effective backend, so its own downgrade
592
+ // warning cannot fire: report the missing library once from the resolver.
593
+ this.warnedNativeAttachmentsUnmounted = true
594
+ this.ctx.logger?.warn?.(
595
+ 'dsh-channels: attachmentStore=native 但宿主未挂载 ctx.attachments,'
596
+ + '入站附件回退话题 attachments/ 工作区落盘',
597
+ )
598
+ }
599
+ return service
600
+ }
601
+
602
+ /**
603
+ * Pre-authorize one admitted Feishu message for `feishu_attachment_fetch`.
604
+ *
605
+ * The admission and quote layers call this after their access gates for the
606
+ * triggering / quoted message; the tool otherwise only authorizes messages
607
+ * visible in the durable pending-attachment registry. Registering is a pure
608
+ * in-memory operation (one LRU entry per route) and never fails the caller.
609
+ */
610
+ noteKnownAttachmentMessage(
611
+ record: RouteRecord,
612
+ message: KnownAttachmentMessageInput,
613
+ source: KnownAttachmentSource = 'trigger',
614
+ ): void {
615
+ this.attachmentFetchTools.noteKnownMessage({
616
+ key: record.key,
617
+ kind: record.kind,
618
+ chatId: record.chatId,
619
+ chatType: record.chatType,
620
+ senderOpenId: record.senderOpenId,
621
+ }, message, source)
622
+ }
623
+
624
+ // ---- read-only message context (quoted excerpt / topic history) -------
625
+
626
+ /**
627
+ * Channel-plane identity of one route for message reads (structurally the
628
+ * same as the attachment-tool route: key/kind/chat/sender).
629
+ */
630
+ private messageReadRouteOf(record: RouteRecord): MessageReadRoute {
631
+ return {
632
+ key: record.key,
633
+ kind: record.kind,
634
+ chatId: record.chatId,
635
+ chatType: record.chatType,
636
+ senderOpenId: record.senderOpenId,
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Resolve one quoted message into the bounded `<feishu_quote>` block and the
642
+ * whitelist registration of the quoted message (merged-forward containers
643
+ * keep their container id, per the P0 download rule).
644
+ *
645
+ * The quoted message must belong to this route's visible container: another
646
+ * chat is always rejected; on a thread route a quoted message of a
647
+ * *different* topic is rejected as well (a main-timeline message carries no
648
+ * thread id and stays allowed — quotes happen before the bot owns a topic);
649
+ * on a degraded chat route any topic message is rejected (a chat route must
650
+ * not read another topic of the same group).
651
+ *
652
+ * The read budget is the short prompt-enrichment budget, never the model
653
+ * tool timeout. Read failures (missing scope, deleted message, channel down)
654
+ * degrade to `undefined`: the turn runs without an excerpt (AC6).
655
+ */
656
+ private async readQuoteContext(input: {
657
+ messageId: string
658
+ record: RouteRecord
659
+ }): Promise<{ block: string; message?: KnownAttachmentMessageInput } | undefined> {
660
+ try {
661
+ const result = await readQuoteItems(this.messageReader, input.messageId, {
662
+ timeoutMs: this.config.promptContextTimeoutMs,
663
+ })
664
+ if (!result.ok) {
665
+ this.reportDegradedRead('quoted message', input.messageId, result.reason)
666
+ return undefined
667
+ }
668
+ const container: RawMessageItem | undefined = quoteItemsOf(result.value)
669
+ if (container === undefined) return undefined
670
+ if (!this.quotedMessageBelongsToRoute(input.record, container)) {
671
+ this.ctx.logger?.debug?.(
672
+ 'dsh-channels: dropping quoted message %s from another chat/topic', input.messageId,
673
+ )
674
+ return undefined
675
+ }
676
+ const block = buildQuoteContextBlock(result.value, {
677
+ attachmentFetchAvailable: this.attachmentFetchMounted(),
678
+ })
679
+ if (block === '') return undefined
680
+ const message = knownMessageOfItems(result.value)
681
+ return { block, ...(message === undefined ? {} : { message }) }
682
+ } catch (error) {
683
+ // Defensive: the reader reports structured failures; a raw throw is still
684
+ // only ever a missing excerpt, never a failed turn.
685
+ this.ctx.logger?.debug?.(
686
+ 'dsh-channels: reading quoted message %s failed: %s', input.messageId, String(error),
687
+ )
688
+ return undefined
689
+ }
690
+ }
691
+
692
+ /** Whether one quoted message may be shown/authorized for one route. */
693
+ private quotedMessageBelongsToRoute(record: RouteRecord, container: RawMessageItem): boolean {
694
+ // Fail closed on a missing/foreign chat id: every real Feishu item carries
695
+ // one, so an absent id is a malformed response, never a legitimate quote.
696
+ if (container.chatId === undefined || container.chatId !== record.chatId) return false
697
+ // A main-timeline message has no thread id; a message of another topic must
698
+ // never leak into this route's excerpt or whitelist. A degraded chat route
699
+ // (no topic) must reject topic messages entirely, even in the same chat.
700
+ if (record.kind === 'thread') {
701
+ return container.threadId === undefined || container.threadId === record.key
702
+ }
703
+ return container.threadId === undefined
704
+ }
705
+
706
+ /**
707
+ * Resolve the bounded first-turn `<feishu_history>` block plus the whitelist
708
+ * registrations of the history messages (chronological order).
709
+ *
710
+ * `limit` is `historyBootstrapLimit` (0/negative → no read at all). The page
711
+ * is requested newest-first so a long topic yields the most recent messages;
712
+ * excluded ids (the trigger and the streaming card), deleted messages, and
713
+ * messages outside the route container are filtered before the newest
714
+ * `limit` rows are reversed into chronological order for rendering. The read
715
+ * uses the short prompt-enrichment budget and degrades to `undefined` (AC6).
716
+ */
717
+ private async readHistoryContext(input: {
718
+ record: RouteRecord
719
+ limit: number
720
+ excludeMessageIds: readonly string[]
721
+ }): Promise<{ block: string; messages: readonly KnownAttachmentMessageInput[] } | undefined> {
722
+ if (!Number.isFinite(input.limit) || input.limit <= 0) return undefined
723
+ try {
724
+ const route = this.messageReadRouteOf(input.record)
725
+ const pageSize = clampInt(
726
+ input.limit + input.excludeMessageIds.length,
727
+ 1,
728
+ TOPIC_MESSAGES_MAX_LIMIT,
729
+ )
730
+ const result = await this.messageReader.listRoute(route, {
731
+ pageSize,
732
+ order: 'newest-first',
733
+ timeoutMs: this.config.promptContextTimeoutMs,
734
+ })
735
+ if (!result.ok) {
736
+ this.reportDegradedRead('topic history', input.record.key, result.reason)
737
+ return undefined
738
+ }
739
+ const excluded = new Set(input.excludeMessageIds)
740
+ const recent = result.value.items
741
+ .filter(item => !item.deleted && !excluded.has(item.messageId))
742
+ .slice(0, input.limit)
743
+ .reverse()
744
+ const block = buildHistoryContextBlock(recent, {
745
+ maxMessages: input.limit,
746
+ attachmentFetchAvailable: this.attachmentFetchMounted(),
747
+ })
748
+ if (block === '') return undefined
749
+ return { block, messages: knownMessagesOfHistory(recent) }
750
+ } catch (error) {
751
+ this.ctx.logger?.debug?.(
752
+ 'dsh-channels: reading topic history of %s failed: %s', input.record.key, String(error),
753
+ )
754
+ return undefined
755
+ }
756
+ }
757
+
758
+ /** Whether this deployment mounts the lazy attachment fetch tool. */
759
+ private attachmentFetchMounted(): boolean {
760
+ return this.effectiveAttachmentBackend() === 'native'
761
+ }
762
+
763
+ /**
764
+ * Report one silently degraded prompt read: debug per occurrence (expected
765
+ * when a deployment misses a scope) plus ONE warn per service lifetime so an
766
+ * operator can find the cause without log spam.
767
+ */
768
+ private reportDegradedRead(kind: string, target: string, reason: string): void {
769
+ if (this.warnedPromptReadDegraded) {
770
+ this.ctx.logger?.debug?.(
771
+ 'dsh-channels: %s read of %s failed (degraded): %s', kind, target, reason,
772
+ )
773
+ return
774
+ }
775
+ this.warnedPromptReadDegraded = true
776
+ this.ctx.logger?.warn?.(
777
+ 'dsh-channels: %s(%s)读取失败,已静默降级(不带引用/历史上下文继续回合):%s。'
778
+ + '如持续出现请检查飞书应用的消息读取权限(如 im:message:readonly / im:message.group_msg),'
779
+ + '或确认该消息未被撤回、网络正常',
780
+ kind, target, reason,
781
+ )
782
+ }
783
+
404
784
  // ---- lifecycle / Web settings panel ----------------------------------
405
785
 
406
786
  /**
@@ -506,7 +886,7 @@ export class FeishuService {
506
886
  /** Re-read the Web overlay over the profile config (no channel touch). */
507
887
  private async loadOverlay(): Promise<void> {
508
888
  const overlay = this.overlay === undefined ? {} : await this.overlay.load()
509
- this.replaceConfig({ ...this.baseConfig, ...overlay })
889
+ this.replaceConfig(mergeOverlay(this.baseConfig, overlay))
510
890
  }
511
891
 
512
892
  /**
@@ -525,8 +905,8 @@ export class FeishuService {
525
905
  * Apply an already-validated overlay over the profile config. Fields read
526
906
  * per event/turn take effect immediately; `RESTART_KEYS` need `restart()`.
527
907
  */
528
- applyOverlay(overlay: Partial<FeishuConfig>): void {
529
- this.replaceConfig({ ...this.baseConfig, ...overlay })
908
+ applyOverlay(overlay: OverlayPatch): void {
909
+ this.replaceConfig(mergeOverlay(this.baseConfig, overlay))
530
910
  this.refreshRetentionTimer()
531
911
  }
532
912
 
@@ -716,12 +1096,12 @@ export class FeishuService {
716
1096
  return value
717
1097
  }
718
1098
 
719
- // ---- workspace retention cleanup -------------------------------------
1099
+ // ---- retention cleanup ------------------------------------------------
720
1100
 
721
1101
  /**
722
- * Workspace-retention preview for the Web panel: how many plugin-created
723
- * workspaces exist, how many are past the configured window, and their
724
- * measured footprint. Never deletes anything.
1102
+ * Retention preview for the Web panel: how many plugin workspaces exist,
1103
+ * how many directories are past the configured window, and their measured
1104
+ * footprint. Never deletes anything.
725
1105
  */
726
1106
  async cleanupPreview(): Promise<{
727
1107
  retention: string
@@ -741,7 +1121,7 @@ export class FeishuService {
741
1121
  }
742
1122
  if (this.domain === undefined) {
743
1123
  // No connection: routes live in the closed storage domain, so a preview
744
- // would mistake every workspace for a candidate. Report why instead.
1124
+ // would mistake every directory for a candidate. Report why instead.
745
1125
  return {
746
1126
  ...base,
747
1127
  workspaces: 0,
@@ -765,43 +1145,57 @@ export class FeishuService {
765
1145
  }
766
1146
 
767
1147
  /**
768
- * Delete every stale plugin workspace the configured retention allows.
769
- * Routes bound to a removed workspace are retired first (thread routes leave
770
- * a tombstone), so an old topic fails with the explicit expired card instead
771
- * of resuming into a missing directory. DSH Session logs are not touched.
1148
+ * Delete every stale plugin directory the configured retention allows: the
1149
+ * per-topic working directories of current conversations and the legacy
1150
+ * one-workspace-per-topic directories of older versions. User workspaces are
1151
+ * never deleted.
1152
+ *
1153
+ * Active routes bound to a removed directory are retired first (thread
1154
+ * routes leave a tombstone), so an old topic fails with the explicit expired
1155
+ * card instead of resuming into a missing directory. DSH Session logs are not
1156
+ * touched.
772
1157
  */
773
1158
  async runWorkspaceCleanup(): Promise<{
774
1159
  ok: boolean
775
1160
  deletedWorkspaces: number
1161
+ deletedSessionDirs: number
776
1162
  retiredRoutes: number
777
1163
  freedBytes: number
778
1164
  failures: string[]
779
1165
  error?: string
780
1166
  }> {
781
- const empty = { ok: false, deletedWorkspaces: 0, retiredRoutes: 0, freedBytes: 0, failures: [] as string[] }
1167
+ const empty = {
1168
+ ok: false,
1169
+ deletedWorkspaces: 0,
1170
+ deletedSessionDirs: 0,
1171
+ retiredRoutes: 0,
1172
+ freedBytes: 0,
1173
+ failures: [] as string[],
1174
+ }
782
1175
  if (this.domain === undefined) {
783
1176
  // Without the storage domain the route table is unreadable, so a cleanup
784
- // could mistake live workspaces for candidates and fail to retire their
1177
+ // could mistake live directories for candidates and fail to retire their
785
1178
  // routes. Refuse rather than rely on the panel disabling the button.
786
1179
  return { ...empty, error: '飞书连接建立后才能清理工作区' }
787
1180
  }
788
1181
  if (retentionMs(this.config.workspaceRetention) === undefined) {
789
- return { ...empty, error: '未启用工作区清理阈值(workspaceRetention 为 off)' }
1182
+ return { ...empty, error: '未启用目录清理阈值(workspaceRetention 为 off)' }
790
1183
  }
791
- if (this.cleanupRunning) return { ...empty, error: '工作区清理正在进行中,请稍候' }
1184
+ if (this.cleanupRunning) return { ...empty, error: '目录清理正在进行中,请稍候' }
792
1185
  this.cleanupRunning = true
793
1186
  try {
794
1187
  const deps = this.cleanupDeps()
795
1188
  const plan = await planWorkspaceCleanup(deps)
796
- if (plan === undefined) return { ...empty, error: '工作区清理不可用:baseDir 配置无效' }
1189
+ if (plan === undefined) return { ...empty, error: '目录清理不可用:baseDir 配置无效' }
797
1190
  const outcome = await applyWorkspaceCleanup(deps, plan)
798
1191
  this.ctx.logger?.info?.(
799
- 'dsh-channels: workspace cleanup removed %d workspace(s), retired %d route(s), freed %d bytes',
800
- outcome.deletedWorkspaces, outcome.retiredRoutes, outcome.freedBytes,
1192
+ 'dsh-channels: retention cleanup removed %d legacy workspace(s) and %d topic dir(s), retired %d route(s), freed %d bytes',
1193
+ outcome.deletedWorkspaces, outcome.deletedSessionDirs, outcome.retiredRoutes, outcome.freedBytes,
801
1194
  )
802
1195
  return {
803
1196
  ok: true,
804
1197
  deletedWorkspaces: outcome.deletedWorkspaces,
1198
+ deletedSessionDirs: outcome.deletedSessionDirs,
805
1199
  retiredRoutes: outcome.retiredRoutes,
806
1200
  freedBytes: outcome.freedBytes,
807
1201
  failures: outcome.failures.map(failure => `${failure.path}: ${failure.message}`),
@@ -826,39 +1220,27 @@ export class FeishuService {
826
1220
  baseDir,
827
1221
  retention: this.config.workspaceRetention,
828
1222
  listWorkspaces: () => this.ctx.workspaceRegistry.list(),
829
- activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]) => ({
1223
+ listSessionDirs: () => this.listCleanableSessionDirs(baseDir),
1224
+ activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]): CleanupRoute => ({
830
1225
  key,
831
1226
  kind: record.kind,
832
1227
  workspacePath: record.workspacePath,
1228
+ ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
833
1229
  sessionId: record.sessionId,
834
1230
  lastActivityAt: record.lastActivityAt,
835
1231
  })),
836
- tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]) => ({
1232
+ tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]): CleanupTombstone => ({
837
1233
  key,
838
1234
  ...(record.workspacePath === undefined ? {} : { workspacePath: record.workspacePath }),
1235
+ ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
839
1236
  expiredAt: record.expiredAt,
840
1237
  ...(record.lastActivityAt === undefined ? {} : { lastActivityAt: record.lastActivityAt }),
841
1238
  })),
842
1239
  isLive: sessionId => this.sessions.get(sessionId) !== undefined
843
1240
  || this.ctx.agents.get(sessionId as SessionId) !== undefined,
844
- statWorkspace: workspacePath => measureWorkspace(workspacePath),
1241
+ statWorkspace: directory => measureWorkspace(directory),
845
1242
  removeWorkspace: async workspace => {
846
- const target = path.resolve(workspace.path)
847
- if (!isPathInside(baseDir, target)) {
848
- throw new Error(`refusing to delete a path outside baseDir (${target})`)
849
- }
850
- // Re-verify the physical path: a symlinked ancestor inside baseDir
851
- // must not let the recursive delete escape it.
852
- try {
853
- const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(target)])
854
- if (!isPathInside(physicalBase, physicalTarget)) {
855
- throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
856
- }
857
- } catch (error) {
858
- if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
859
- // A missing workspace is already effectively removed (rm is idempotent).
860
- }
861
- await rm(target, { recursive: true, force: true })
1243
+ await this.deleteDirectoryInsideBase(baseDir, workspace.path)
862
1244
  try {
863
1245
  await this.ctx.workspaceRegistry.delete(workspace.id)
864
1246
  } catch (error) {
@@ -867,14 +1249,83 @@ export class FeishuService {
867
1249
  )
868
1250
  }
869
1251
  },
1252
+ removeSessionDir: async directory => {
1253
+ await this.deleteDirectoryInsideBase(baseDir, directory)
1254
+ },
870
1255
  retireRoute: async route => {
871
1256
  if (route.kind === 'thread') await this.routes.retire(route.key)
872
1257
  else await this.routes.remove(route.key)
1258
+ this.attachmentFetchTools.forgetRoute(route.key)
873
1259
  },
874
1260
  warn: message => this.ctx.logger?.warn?.(message),
875
1261
  }
876
1262
  }
877
1263
 
1264
+ /**
1265
+ * Scan the live user workspaces for per-topic working directories that no
1266
+ * route record points at anymore (for example the old route of a degraded
1267
+ * chat conversation was deleted by TTL retirement). Route/tombstone evidence
1268
+ * wins for age; the scan mtime only covers true orphans.
1269
+ */
1270
+ private async listCleanableSessionDirs(baseDir: string): Promise<ScannedSessionDir[]> {
1271
+ const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
1272
+ const roots: string[] = []
1273
+ for (const workspace of this.ctx.workspaceRegistry.list()) {
1274
+ if (!this.isPluginWorkspace(workspace)) continue
1275
+ const resolved = path.resolve(workspace.path)
1276
+ // Legacy per-topic workspaces are cleaned as workspaces, not scanned here.
1277
+ if (isPathInside(legacyRoot, resolved)) continue
1278
+ roots.push(resolved)
1279
+ }
1280
+ const scanned: ScannedSessionDir[] = []
1281
+ for (const root of roots) {
1282
+ for (const relative of ['topics', path.join('.agents', 'worktrees')]) {
1283
+ const directory = path.join(root, relative)
1284
+ let entries
1285
+ try {
1286
+ entries = await readdir(directory, { withFileTypes: true })
1287
+ } catch {
1288
+ continue
1289
+ }
1290
+ for (const entry of entries) {
1291
+ if (!entry.isDirectory()) continue
1292
+ const candidate = path.join(directory, entry.name)
1293
+ try {
1294
+ const info = await lstat(candidate)
1295
+ if (info.isDirectory()) scanned.push({ path: candidate, mtimeMs: info.mtimeMs })
1296
+ } catch {
1297
+ // Removed or unreadable between readdir and lstat; skip it.
1298
+ }
1299
+ }
1300
+ }
1301
+ }
1302
+ return scanned
1303
+ }
1304
+
1305
+ /**
1306
+ * Recursively delete one plugin directory below `baseDir`.
1307
+ *
1308
+ * The lexical check refuses paths outside `baseDir`; the realpath check then
1309
+ * refuses a symlinked ancestor that would let the recursive delete escape
1310
+ * the plugin root. A missing directory is already effectively removed.
1311
+ */
1312
+ private async deleteDirectoryInsideBase(baseDir: string, target: string): Promise<void> {
1313
+ const resolved = path.resolve(target)
1314
+ if (!isPathInside(baseDir, resolved)) {
1315
+ throw new Error(`refusing to delete a path outside baseDir (${resolved})`)
1316
+ }
1317
+ try {
1318
+ const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(resolved)])
1319
+ if (!isPathInside(physicalBase, physicalTarget)) {
1320
+ throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
1321
+ }
1322
+ } catch (error) {
1323
+ if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
1324
+ // A missing directory is already effectively removed (rm is idempotent).
1325
+ }
1326
+ await rm(resolved, { recursive: true, force: true })
1327
+ }
1328
+
878
1329
  /**
879
1330
  * (Re)arm the daily retention timer to match the live config. `runNow`
880
1331
  * performs one pass immediately; it is only used at startup so saving any
@@ -890,7 +1341,7 @@ export class FeishuService {
890
1341
  || retentionMs(this.config.workspaceRetention) === undefined) return
891
1342
  const sweep = (): void => {
892
1343
  void this.runWorkspaceCleanup().then(result => {
893
- if (!result.ok && result.error !== '工作区清理正在进行中,请稍候') {
1344
+ if (!result.ok && result.error !== '目录清理正在进行中,请稍候') {
894
1345
  this.ctx.logger?.warn?.('dsh-channels: automatic workspace cleanup failed: %s', result.error)
895
1346
  }
896
1347
  })
@@ -1183,6 +1634,7 @@ export class FeishuService {
1183
1634
  this.retentionTimer = undefined
1184
1635
  }
1185
1636
  await this.sessions.disposeAll()
1637
+ this.attachmentFetchTools.clear()
1186
1638
  this.routeOperations.clear()
1187
1639
  this.inbound.clear()
1188
1640
  this.routes.clear()
@@ -1724,7 +2176,7 @@ export class FeishuService {
1724
2176
  await this.delivery.standaloneReply(msg, buildRenewCard({
1725
2177
  previousSessionId: renewAdmission.commit.previous.sessionId,
1726
2178
  currentSessionId: renewAdmission.commit.current.sessionId,
1727
- workspacePath: renewAdmission.commit.current.workspacePath,
2179
+ sessionDir: sessionDirOf(renewAdmission.commit.current),
1728
2180
  }))
1729
2181
  return
1730
2182
  }
@@ -1744,7 +2196,7 @@ export class FeishuService {
1744
2196
  context,
1745
2197
  extras: {
1746
2198
  permissionPreset: this.permissionPresetOf(session),
1747
- workspacePath: current.workspacePath,
2199
+ sessionDir: sessionDirOf(current),
1748
2200
  uptimeMs: this.startedAt === undefined ? undefined : Date.now() - this.startedAt,
1749
2201
  liveAgents: this.sessions.liveCount(),
1750
2202
  routeCount: this.routes.size(),
@@ -1753,7 +2205,10 @@ export class FeishuService {
1753
2205
  return
1754
2206
  }
1755
2207
  if (spec.name === 'workspace' && result.kind === 'success') {
1756
- const resolved = resolveWorkDir(live.agent.session.header.cwd, current.workspacePath)
2208
+ const resolved = resolveWorkDir(live.agent.session.header.cwd, {
2209
+ workspacePath: current.workspacePath,
2210
+ ...(current.sessionDir === undefined ? {} : { sessionDir: current.sessionDir }),
2211
+ })
1757
2212
  if (resolved.kind === 'success') {
1758
2213
  await this.delivery.standaloneReply(msg, buildWorkDirCard(current.sessionId, resolved.path))
1759
2214
  return
@@ -1940,13 +2395,18 @@ export class FeishuService {
1940
2395
 
1941
2396
 
1942
2397
  /**
1943
- * GC pass over the routing domain. Two independent halves:
2398
+ * GC pass over the routing domain. Three independent parts:
1944
2399
  *
1945
2400
  * 1. Dedup records (unconditional): `processed` records and in-process
1946
2401
  * guard entries older than `PROCESSED_RETENTION_MS` are dropped —
1947
2402
  * redelivery windows are minutes, so a day of retention is ample and
1948
2403
  * keeps the table bounded regardless of the route TTL setting.
1949
- * 2. Route/inflight GC (`routeTtlMs > 0`): thread routes idle past the TTL
2404
+ * 2. Pending attachment metadata (unconditional): rows past
2405
+ * `pendingAttachmentTtlMs` and rows above `pendingAttachmentLimit` per
2406
+ * `(chat, thread?, sender)` scope are removed. Independent of the route
2407
+ * TTL: the registry keeps its own 7-day default even when route retirement
2408
+ * is disabled.
2409
+ * 3. Route/inflight GC (`routeTtlMs > 0`): thread routes idle past the TTL
1950
2410
  * are retired into minimal persistent tombstones before their active
1951
2411
  * records are deleted. A later message in that visible topic is then
1952
2412
  * rejected instead of silently creating a context-free Session. Degraded
@@ -1979,6 +2439,18 @@ export class FeishuService {
1979
2439
  }
1980
2440
  this.inbound.pruneAccepted(processedCutoff)
1981
2441
 
2442
+ try {
2443
+ const pruned = await this.pendingAttachments.prune()
2444
+ if (pruned.removedExpired > 0 || pruned.removedOverLimit > 0) {
2445
+ this.ctx.logger?.info?.(
2446
+ 'dsh-channels: record GC removed %d expired and %d over-limit pending attachment record(s)',
2447
+ pruned.removedExpired, pruned.removedOverLimit,
2448
+ )
2449
+ }
2450
+ } catch (error) {
2451
+ this.ctx.logger?.warn?.('dsh-channels: pending attachment GC failed: %s', String(error))
2452
+ }
2453
+
1982
2454
  const ttlMs = this.config.routeTtlMs
1983
2455
  if (ttlMs === 0) {
1984
2456
  if (removedProcessed > 0) {
@@ -1999,6 +2471,7 @@ export class FeishuService {
1999
2471
  } else {
2000
2472
  await this.routes.remove(key)
2001
2473
  }
2474
+ this.attachmentFetchTools.forgetRoute(key)
2002
2475
  removedRoutes++
2003
2476
  } catch (error) {
2004
2477
  this.ctx.logger?.warn?.('dsh-channels: GC of route %s failed: %s', key, String(error))
@@ -2006,6 +2479,19 @@ export class FeishuService {
2006
2479
  }
2007
2480
  }
2008
2481
 
2482
+ // Reconcile the attachment-fetch whitelist with the active route set: the
2483
+ // inbound TTL path retires a thread route directly (tombstone), so its
2484
+ // in-memory whitelist bucket would otherwise live until process exit.
2485
+ const droppedToolRoutes = this.attachmentFetchTools.pruneRoutes(
2486
+ routeKey => this.routes.get(routeKey) !== undefined,
2487
+ )
2488
+ if (droppedToolRoutes > 0) {
2489
+ this.ctx.logger?.info?.(
2490
+ 'dsh-channels: record GC dropped %d stale attachment-tool route bucket(s)',
2491
+ droppedToolRoutes,
2492
+ )
2493
+ }
2494
+
2009
2495
  const inflight = this.domain?.inflight
2010
2496
  if (inflight !== undefined) {
2011
2497
  for (const [key, snapshot] of [...inflight.entries()]) {