@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modesto_ma/dsh-channels",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "DeepSeek Harness (DSH) 飞书渠道插件:一个 dsh 实例对应一个飞书机器人,私聊/群聊/话题群 @ 触发会话,话题绑定 session,卡片 2.0 流式回复;Web 设置里提供「飞书渠道」状态与常用配置面板",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cleanup.ts CHANGED
@@ -28,11 +28,17 @@
28
28
  * A directory with no usable activity evidence at all (missing/unreadable) is
29
29
  * never offered for deletion: irreversible removal requires positive evidence.
30
30
  *
31
+ * Scope boundary (attachment_pipeline_v2, decisions D6/D12): cleanup only
32
+ * removes workspace / Agent-artifact directories. Inbound attachments are not
33
+ * workspace files — the native backend keeps them in the DSH attachment
34
+ * library, and the legacy `_staging` adoption area is explicitly excluded so a
35
+ * pending one-time adoption can never be destroyed by a retention sweep.
36
+ *
31
37
  * @module dsh-channels/cleanup
32
38
  */
33
39
 
34
40
  import path from 'node:path'
35
- import { legacyTopicWorkspaceRoot } from './workspaces.ts'
41
+ import { legacyTopicWorkspaceRoot, stagingRoot } from './workspaces.ts'
36
42
 
37
43
  /** Values the `workspaceRetention` config accepts. `off` disables cleanup. */
38
44
  export const WORKSPACE_RETENTION_VALUES = ['off', '7d', '30d', '90d', '180d'] as const
@@ -330,6 +336,7 @@ export async function planWorkspaceCleanup(deps: CleanupDeps): Promise<CleanupPl
330
336
  return undefined
331
337
  }
332
338
  const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
339
+ const stagingDir = stagingRoot(baseDir)
333
340
 
334
341
  const now = deps.now?.() ?? Date.now()
335
342
  const cutoff = now - window
@@ -414,6 +421,8 @@ export async function planWorkspaceCleanup(deps: CleanupDeps): Promise<CleanupPl
414
421
  if (isFilesystemRoot(resolved)) return false
415
422
  if (!isPathInside(baseDir, resolved)) return false
416
423
  if (resolved === legacyRoot || isPathInside(legacyRoot, resolved)) return false
424
+ // Legacy pre-topic staging (one-time adoption area): never a cleanup target.
425
+ if (resolved === stagingDir || isPathInside(stagingDir, resolved)) return false
417
426
  if (userWorkspacePaths.has(resolved)) return false
418
427
  return true
419
428
  }
@@ -486,6 +495,7 @@ export async function applyWorkspaceCleanup(
486
495
  const baseDir = path.resolve(deps.baseDir)
487
496
  const baseDirIsRoot = isFilesystemRoot(baseDir)
488
497
  const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
498
+ const stagingDir = stagingRoot(baseDir)
489
499
  const registered = new Set(
490
500
  (await deps.listWorkspaces())
491
501
  .filter(workspace => pluginWorkspaceTitle(workspace.title))
@@ -503,9 +513,10 @@ export async function applyWorkspaceCleanup(
503
513
  // Defense in depth: never trust a plan object for the destructive step.
504
514
  const insideBase = !baseDirIsRoot && isPathInside(baseDir, targetPath)
505
515
  const inLegacyRoot = isPathInside(legacyRoot, targetPath)
516
+ const inStaging = isPathInside(stagingDir, targetPath)
506
517
  const valid = target.kind === 'workspace'
507
518
  ? insideBase && inLegacyRoot
508
- : insideBase && !inLegacyRoot && !registered.has(path.resolve(targetPath))
519
+ : insideBase && !inLegacyRoot && !inStaging && !registered.has(path.resolve(targetPath))
509
520
  if (!valid) {
510
521
  failures.push({ path: targetPath, message: 'refused: path is not a cleanable plugin directory' })
511
522
  continue
package/src/config.ts CHANGED
@@ -34,6 +34,54 @@ export const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60 * 1000
34
34
  /** Default structured-question window: 5 minutes per `ask_user_question` call. */
35
35
  export const DEFAULT_QUESTION_TIMEOUT_MS = 5 * 60 * 1000
36
36
 
37
+ /** Backends for inbound attachment storage, selected by `attachmentStore`. */
38
+ export const ATTACHMENT_STORE_MODES = ['native', 'archive'] as const
39
+ export type AttachmentStoreMode = (typeof ATTACHMENT_STORE_MODES)[number]
40
+
41
+ /** Default retention of silently registered attachment metadata: 7 days. */
42
+ export const DEFAULT_PENDING_ATTACHMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000
43
+
44
+ /** Default per-`(chat, thread, sender)` cap of pending attachment rows. */
45
+ export const DEFAULT_PENDING_ATTACHMENT_LIMIT = 10
46
+
47
+ /** Default per-call attachment count of `feishu_attachment_fetch`. */
48
+ export const DEFAULT_ATTACHMENT_FETCH_MAX_RESOURCES = 10
49
+
50
+ /** Default per-call total download budget of `feishu_attachment_fetch` (100 MiB). */
51
+ export const DEFAULT_ATTACHMENT_FETCH_MAX_BYTES = 100 * 1024 * 1024
52
+
53
+ /** Default whole-call timeout of `feishu_attachment_fetch` (120 s). */
54
+ export const DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS = 120_000
55
+
56
+ /** Default per-image byte cap for trigger-message prefetch (20 MiB). */
57
+ export const DEFAULT_PREFETCH_IMAGE_MAX_BYTES = 20 * 1024 * 1024
58
+
59
+ /** Default total byte budget for one trigger-message image prefetch (50 MiB). */
60
+ export const DEFAULT_PREFETCH_TOTAL_MAX_BYTES = 50 * 1024 * 1024
61
+
62
+ /** Default short timeout for one trigger-message image prefetch (10 s). */
63
+ export const DEFAULT_PREFETCH_TIMEOUT_MS = 10_000
64
+
65
+ /**
66
+ * Default number of recent topic messages attached to a new Session's first
67
+ * turn (0 disables the history bootstrap). Mirrors
68
+ * `HISTORY_BOOTSTRAP_DEFAULT` in `feishu/message-read.ts`.
69
+ */
70
+ export const DEFAULT_HISTORY_BOOTSTRAP_LIMIT = 10
71
+
72
+ /** Hard cap of the first-turn history bootstrap. */
73
+ export const MAX_HISTORY_BOOTSTRAP_LIMIT = 20
74
+
75
+ /** Default whole-call timeout of `feishu_topic_messages` (20 s). */
76
+ export const DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS = 20_000
77
+
78
+ /**
79
+ * Default budget of one passive prompt-enrichment message read (quoted excerpt
80
+ * / first-turn topic history). Kept short: the turn must not wait long for an
81
+ * optional context block.
82
+ */
83
+ export const DEFAULT_PROMPT_CONTEXT_TIMEOUT_MS = 3_000
84
+
37
85
  /** A credential reference: `FEISHU_APP_ID`-style name resolved through ctx.credentials. */
38
86
  const credentialRefSchema = z.string().regex(
39
87
  CREDENTIAL_REF_PATTERN,
@@ -138,13 +186,154 @@ export const configSchema = z.object({
138
186
  */
139
187
  approvalTimeoutMs: z.number().int().min(10_000).max(3_600_000).default(DEFAULT_APPROVAL_TIMEOUT_MS),
140
188
  /**
141
- * Accept inbound Feishu images, files, and merged-forward chat records and
142
- * save them below `<workspace>/attachments/`. Nothing is forwarded to the
143
- * model automatically: the Agent reads whichever attachment the
144
- * conversation needs with its ordinary filesystem tools. Disabled keeps
145
- * attachment messages inert (no save, no turn).
189
+ * Accept inbound Feishu images, files, and merged-forward chat records.
190
+ *
191
+ * With the native backend (default) images of the triggering message are
192
+ * prefetched into the DSH native attachment library and files stay lazy
193
+ * behind a prompt manifest; with the archive backend the resources are saved
194
+ * below `<workspace>/attachments/` before the turn, exactly like the legacy
195
+ * pipeline. Disabled keeps attachment messages inert (no save, no turn, no
196
+ * pending metadata registration).
146
197
  */
147
198
  acceptAttachments: z.boolean().default(true),
199
+ /**
200
+ * Backend for inbound Feishu images and files.
201
+ *
202
+ * - `native` (default): store into the DSH native attachment library
203
+ * (`ctx.attachments`) as durable `ImageBlock`/`FileBlock` references; if
204
+ * the host does not mount that service, the storage layer falls back to
205
+ * `archive` for that deployment.
206
+ * - `archive`: legacy behavior — write the resources into the topic
207
+ * workspace `attachments/` directory through the existing intake.
208
+ *
209
+ * Admission consumes this switch through the backend-selecting inbound
210
+ * store: an explicit `archive` always uses the legacy download path, and a
211
+ * `native` deployment without the mounted host service degrades to the same
212
+ * legacy path (see `effectiveAttachmentMode`).
213
+ */
214
+ attachmentStore: z.enum(ATTACHMENT_STORE_MODES).default('native'),
215
+ /**
216
+ * Prefetch images attached to the triggering message into the first model
217
+ * turn (native attachment backend only). The session, topic, and streaming
218
+ * card are already visible before the prefetch starts; the turn only waits
219
+ * for the (short, bounded) image download, never for file bytes. Failed,
220
+ * oversized, or timed-out images degrade to a manifest row that the model
221
+ * can fetch with `feishu_attachment_fetch`. Set `false` to keep every
222
+ * attachment lazy (images then need the tool as well).
223
+ */
224
+ prefetchImages: z.boolean().default(true),
225
+ /**
226
+ * Largest single image the trigger-message prefetch buffers in memory before
227
+ * refusing it (default 20 MiB). A refused image stays in the manifest and
228
+ * is fetched on demand.
229
+ */
230
+ prefetchImageMaxBytes: z.number().int().min(1).max(200 * 1024 * 1024)
231
+ .default(DEFAULT_PREFETCH_IMAGE_MAX_BYTES),
232
+ /**
233
+ * Total byte budget of one trigger-message image prefetch (default 50 MiB).
234
+ * Once exhausted, the remaining images degrade to the manifest.
235
+ */
236
+ prefetchTotalMaxBytes: z.number().int().min(1).max(1024 * 1024 * 1024)
237
+ .default(DEFAULT_PREFETCH_TOTAL_MAX_BYTES),
238
+ /**
239
+ * Short whole-prefetch timeout (default 10 s). On expiry the already-loaded
240
+ * images stay in the prompt and the rest degrade to the manifest, so a slow
241
+ * image download never blocks the turn for long.
242
+ */
243
+ prefetchTimeoutMs: z.number().int().min(100).max(120_000).default(DEFAULT_PREFETCH_TIMEOUT_MS),
244
+ /**
245
+ * Number of recent messages of the current topic automatically attached to
246
+ * the FIRST turn of a new Session (decision D1: the model should see the
247
+ * discussion before @ without asking). The `<feishu_history>` block is
248
+ * bounded and truncated with an explicit marker and each line carries the
249
+ * message_id needed to fetch a named attachment; older messages remain
250
+ * reachable through the `feishu_topic_messages` tool. `0` disables the
251
+ * bootstrap entirely. Reads use a short (~3 s) prompt-enrichment budget and a
252
+ * failure (missing scope, p2p history unavailable, channel down) degrades
253
+ * silently: the turn still runs.
254
+ */
255
+ historyBootstrapLimit: z.number().int().min(0).max(MAX_HISTORY_BOOTSTRAP_LIMIT)
256
+ .default(DEFAULT_HISTORY_BOOTSTRAP_LIMIT),
257
+ /**
258
+ * Whole-read budget (milliseconds) of the passive prompt enrichment: the
259
+ * quoted-message excerpt and the first-turn topic history. Deliberately short
260
+ * (default 3 s) and independent from `feishuTools.topicMessagesTimeoutMs`:
261
+ * on expiry the block is omitted and the turn runs. Model-initiated reads
262
+ * through `feishu_topic_messages` use the tool timeout instead.
263
+ */
264
+ promptContextTimeoutMs: z.number().int().min(100).max(10_000)
265
+ .default(DEFAULT_PROMPT_CONTEXT_TIMEOUT_MS),
266
+ /**
267
+ * Retention of silently registered attachment metadata (the
268
+ * `pending_attachments` table). A message that does not trigger a model turn
269
+ * (attachment-only p2p message, un-mentioned group message, attachment-only
270
+ * message inside a bound topic) is recorded as metadata only; a later
271
+ * triggered message in the same `(chat, thread, sender)` scope may list and
272
+ * fetch it. Rows older than this window are removed by the periodic record
273
+ * GC. `0` disables age pruning (the per-scope cap still bounds each scope).
274
+ * Default 7 days.
275
+ */
276
+ pendingAttachmentTtlMs: z.number().int().min(0).default(DEFAULT_PENDING_ATTACHMENT_TTL_MS),
277
+ /**
278
+ * Maximum silently registered attachment rows kept per `(chat, thread,
279
+ * sender)` scope. Registering beyond the cap evicts the oldest row of that
280
+ * scope; the prompt manifest only ever shows the most recent 5 unconsumed
281
+ * rows. Default 10.
282
+ */
283
+ pendingAttachmentLimit: z.number().int().min(1).max(100).default(DEFAULT_PENDING_ATTACHMENT_LIMIT),
284
+ /**
285
+ * Model-facing Feishu tools.
286
+ *
287
+ * - `attachmentFetch` (default on): register `feishu_attachment_fetch` in
288
+ * channel sessions so the model can fetch images/files of a known message
289
+ * on demand. Registration is skipped (with one warning) on deployments
290
+ * without the host `tools` / `attachments` services. Turning it off also
291
+ * keeps inbound attachments on the archive backend: lazy native files
292
+ * would be unreachable without the tool, so admission downloads them to
293
+ * the workspace instead (see `effectiveAttachmentMode`).
294
+ * - `attachmentFetchMaxResources`: maximum attachments one tool call may
295
+ * fetch (1–50). A message with more attachments is refused with a readable
296
+ * reason so the model can retry per `file_key`.
297
+ * - `attachmentFetchMaxBytes`: total download budget of one tool call
298
+ * (default 100 MiB). Files stream to the native attachment library, so the
299
+ * limit is enforced while streaming rather than buffered first.
300
+ * - `attachmentFetchTimeoutMs`: whole-call timeout (default 120 s). On expiry
301
+ * the in-flight download is cancelled and reported per attachment.
302
+ * - `topicMessages` (default on): register `feishu_topic_messages` in
303
+ * channel sessions so the model can read the quoted message / earlier
304
+ * topic messages on demand and authorize their attachments for fetch.
305
+ * Reads require the Feishu message-read scopes; a missing scope degrades to
306
+ * a readable tool failure and never fails the turn. This switch only
307
+ * controls the model-facing tool: the passive `<feishu_quote>` /
308
+ * `<feishu_history>` prompt excerpts stay enabled (they are bounded and
309
+ * degrade silently) and are configured by `historyBootstrapLimit`. On the
310
+ * archive backend the tool is still mounted for history text, while its
311
+ * description states that `feishu_attachment_fetch` is unavailable.
312
+ * - `topicMessagesTimeoutMs`: whole-call timeout of one model-initiated
313
+ * topic read (default 20 s). Passive prompt excerpts use a separate short
314
+ * (~3 s) budget and never wait this long.
315
+ *
316
+ * The Web panel/metrics ship with `panel_docs_release`.
317
+ */
318
+ feishuTools: z.object({
319
+ attachmentFetch: z.boolean().default(true),
320
+ attachmentFetchMaxResources: z.number().int().min(1).max(50)
321
+ .default(DEFAULT_ATTACHMENT_FETCH_MAX_RESOURCES),
322
+ attachmentFetchMaxBytes: z.number().int().min(1).max(1024 * 1024 * 1024)
323
+ .default(DEFAULT_ATTACHMENT_FETCH_MAX_BYTES),
324
+ attachmentFetchTimeoutMs: z.number().int().min(1_000).max(600_000)
325
+ .default(DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS),
326
+ topicMessages: z.boolean().default(true),
327
+ topicMessagesTimeoutMs: z.number().int().min(1_000).max(120_000)
328
+ .default(DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS),
329
+ }).default({
330
+ attachmentFetch: true,
331
+ attachmentFetchMaxResources: DEFAULT_ATTACHMENT_FETCH_MAX_RESOURCES,
332
+ attachmentFetchMaxBytes: DEFAULT_ATTACHMENT_FETCH_MAX_BYTES,
333
+ attachmentFetchTimeoutMs: DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS,
334
+ topicMessages: true,
335
+ topicMessagesTimeoutMs: DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS,
336
+ }),
148
337
  /**
149
338
  * Delete the topic directories of stale conversations, plus legacy
150
339
  * one-workspace-per-topic directories written by older plugin versions.
package/src/domain.ts CHANGED
@@ -282,6 +282,89 @@ export const processedRecordSchema = z.object({
282
282
 
283
283
  export type ProcessedRecord = z.infer<typeof processedRecordSchema>
284
284
 
285
+ /**
286
+ * Silently registered inbound attachment metadata ("pending attachments").
287
+ *
288
+ * A message that does not trigger a model turn — a p2p attachment-only
289
+ * message, an un-mentioned group message, an attachment-only message inside an
290
+ * already-bound topic — is recorded here without downloading bytes, replying,
291
+ * or reacting. A later triggered message in the same `(chat, thread?, sender)`
292
+ * scope can list these entries in its prompt manifest and fetch them on demand
293
+ * through the attachment tool. The table stores metadata only (Feishu resource
294
+ * keys), never bytes.
295
+ *
296
+ * Added without a storage-domain version bump: the host's JSON backend loads a
297
+ * declared table that is missing from the stored document as empty and keeps
298
+ * the stored `unit.version` (1), so existing deployments gain the table with no
299
+ * migration. Downgrading to a plugin build without this table would drop it on
300
+ * the next write — acceptable because plugin builds are replaced in place.
301
+ */
302
+ export const PENDING_ATTACHMENT_KINDS = ['image', 'file', 'post', 'merge_forward'] as const
303
+
304
+ /** Kind of message that carried the attachment payload. */
305
+ export type PendingAttachmentKind = (typeof PENDING_ATTACHMENT_KINDS)[number]
306
+
307
+ /**
308
+ * One attachment resource inside a pending record. Feishu downloads resources
309
+ * by `(message id, file key)`; `containerMessageId` is set when only a
310
+ * top-level container id works (merged-forward children).
311
+ */
312
+ export const pendingAttachmentResourceSchema = z.object({
313
+ /** Resource kind delivered by Feishu. */
314
+ type: z.enum(['image', 'file']),
315
+ /** Feishu resource key (`img_v3_...` / `file_v3_...`). */
316
+ fileKey: z.string().min(1),
317
+ /** Original file name, when Feishu reports one. */
318
+ fileName: z.string().min(1).optional(),
319
+ /** Feishu folder entries cannot be downloaded; recorded so the fetch tool can
320
+ * answer with a structured "folder unsupported" instead of a raw API error. */
321
+ folder: z.boolean().optional(),
322
+ /** Top-level container message id to download through, when different from
323
+ * this record's own `messageId` (merged-forward child resources). */
324
+ containerMessageId: z.string().min(1).optional(),
325
+ })
326
+
327
+ export type PendingAttachmentResource = z.infer<typeof pendingAttachmentResourceSchema>
328
+
329
+ /**
330
+ * One silently registered attachment message. Keyed by its Feishu message id so
331
+ * a redelivered event can never register twice. `scopeKey` is the
332
+ * `(chat, thread?, sender)` isolation unit; `consumed` marks metadata the
333
+ * agent already fetched, which then drops out of the prompt manifest (the
334
+ * record stays as an authorization fact until its TTL).
335
+ */
336
+ export const pendingAttachmentRecordSchema = z.object({
337
+ /** Key the record is stored under (= `messageId`). */
338
+ key: z.string().min(1),
339
+ /** `(chat, thread?, sender)` isolation key; see `pendingAttachmentScopeKey`. */
340
+ scopeKey: z.string().min(1),
341
+ /** Feishu message id of the attachment message (the container id for
342
+ * merged-forward records). */
343
+ messageId: z.string().min(1),
344
+ /** Feishu chat the message arrived in. */
345
+ chatId: z.string().min(1),
346
+ /** `p2p` or `group`. */
347
+ chatType: z.enum(['p2p', 'group']),
348
+ /** Feishu topic the message belongs to, when it has one. */
349
+ threadId: z.string().min(1).optional(),
350
+ /** Normalized sender id (open_id) that the scope is bound to. */
351
+ senderOpenId: z.string().min(1),
352
+ /** Epoch ms when the metadata was registered (clock seam for tests). */
353
+ receivedAt: z.number().int().nonnegative(),
354
+ /** Kind of message that carried the payload. */
355
+ kind: z.enum(PENDING_ATTACHMENT_KINDS),
356
+ /** Attachment payloads; empty for merged-forward containers (children are
357
+ * listed through the container's own message id on fetch). */
358
+ resources: z.array(pendingAttachmentResourceSchema),
359
+ /** Remaining caption text after attachment placeholders, truncated. */
360
+ textExcerpt: z.string().min(1).optional(),
361
+ /** Set once the agent fetched the attachment; consumed rows leave the
362
+ * manifest but stay queryable by message id until the TTL sweep. */
363
+ consumed: z.boolean().optional(),
364
+ })
365
+
366
+ export type PendingAttachmentRecord = z.infer<typeof pendingAttachmentRecordSchema>
367
+
285
368
  export interface FeishuDomainSpecShape {
286
369
  name: 'channels_routes'
287
370
  version: 1
@@ -318,6 +401,10 @@ export interface FeishuDomainSpecShape {
318
401
  valueSchema: typeof accessRequestRecordSchema
319
402
  __key?: string
320
403
  }
404
+ pending_attachments: {
405
+ valueSchema: typeof pendingAttachmentRecordSchema
406
+ __key?: string
407
+ }
321
408
  }
322
409
  }
323
410
 
@@ -352,6 +439,7 @@ export const feishuDomain: FeishuDomainSpecShape = {
352
439
  access_groups: { valueSchema: accessGroupRecordSchema },
353
440
  access_users: { valueSchema: accessUserRecordSchema },
354
441
  access_requests: { valueSchema: accessRequestRecordSchema },
442
+ pending_attachments: { valueSchema: pendingAttachmentRecordSchema },
355
443
  },
356
444
  }
357
445
 
@@ -418,6 +506,19 @@ export interface AccessRequestsTable {
418
506
  entries(): IterableIterator<[string, AccessRequestRecord]>
419
507
  }
420
508
 
509
+ /**
510
+ * Normalized table handle for silently registered attachment metadata
511
+ * (key = Feishu message id).
512
+ */
513
+ export interface PendingAttachmentsTable {
514
+ get(key: string): PendingAttachmentRecord | undefined
515
+ put(key: string, value: PendingAttachmentRecord): Promise<unknown> | void
516
+ /** Atomic read-modify-write on the host's write chain (`missing-key` when gone). */
517
+ update(key: string, fn: (current: PendingAttachmentRecord) => PendingAttachmentRecord): Promise<unknown>
518
+ delete(key: string): Promise<unknown> | void
519
+ entries(): IterableIterator<[string, PendingAttachmentRecord]>
520
+ }
521
+
421
522
  /** Normalized domain handle. */
422
523
  export interface FeishuDomainHandle {
423
524
  routes: RoutesTable
@@ -427,6 +528,12 @@ export interface FeishuDomainHandle {
427
528
  accessGroups: AccessGroupsTable
428
529
  accessUsers: AccessUsersTable
429
530
  accessRequests: AccessRequestsTable
531
+ /**
532
+ * Pending attachment metadata. Optional on purpose: an embedded host whose
533
+ * storage domain refuses the added table leaves this undefined and the
534
+ * registry degrades to a process-memory TTL store (records lost on restart).
535
+ */
536
+ pendingAttachments?: PendingAttachmentsTable
430
537
  getGlobal(): FeishuDomainGlobal
431
538
  setGlobal(value: FeishuDomainGlobal): Promise<unknown> | void
432
539
  close(): Promise<void>
@@ -1,22 +1,30 @@
1
1
  /**
2
2
  * Agent-scoped system-prompt guidance for Feishu attachments.
3
3
  *
4
- * The channel archives inbound images/files/forwarded records below
5
- * `<session working directory>/attachments/` without any model turn. This
6
- * section is what makes that convention discoverable: it tells the Agent where
7
- * attachments live, how to read them, and that reading is opt-in per user
8
- * request.
4
+ * Which guidance is mounted follows the *effective* attachment backend
5
+ * (`attachmentStore`, decisions D6/D2):
6
+ * - `native` (the default): inbound images/files live in the DSH native
7
+ * attachment library. Trigger-message images are prefetched and already
8
+ * visible; files stay unloaded and the prompt carries a stable
9
+ * `<feishu_context>` manifest (see `manifest.ts`) that the Agent uses with the
10
+ * `feishu_attachment_fetch` tool on demand.
11
+ * - `archive` (legacy / fallback): inbound images/files are archived below
12
+ * `<session working directory>/attachments/` and read with filesystem tools.
13
+ * Kept for explicit `attachmentStore: 'archive'` deployments and for native
14
+ * deployments whose host does not mount `ctx.attachments`.
9
15
  *
10
- * Registered inside the Agent scope (through `inject(['systemPrompt'])`), so
11
- * it is disposed with the Agent and does not leak into other compositions.
12
- * It is mounted only after the Session's effective working directory is known
13
- * (see `SessionManager.mountGuidance`), so the absolute path is accurate even
14
- * when that directory is a Git worktree created after Agent creation.
16
+ * Registered inside the Agent scope (through `inject(['systemPrompt'])`), so it
17
+ * is disposed with the Agent and does not leak into other compositions. The
18
+ * archive flavor is mounted only after the Session's effective working
19
+ * directory is known (see `SessionManager.mountGuidance`), so its absolute path
20
+ * is accurate even when that directory is a Git worktree created after Agent
21
+ * creation.
15
22
  *
16
23
  * @module dsh-channels/feishu/agent-context
17
24
  */
18
25
 
19
26
  import type { AgentContext } from '../host/agent.ts'
27
+ import { FEISHU_CONTEXT_TAG } from './manifest.ts'
20
28
 
21
29
  /** Stable prompt-section name; one registration per Agent scope. */
22
30
  export const FEISHU_ATTACHMENTS_SECTION = 'dsh-channels:feishu-attachments'
@@ -28,24 +36,61 @@ export const FEISHU_ATTACHMENTS_SECTION = 'dsh-channels:feishu-attachments'
28
36
  */
29
37
  const FEISHU_ATTACHMENTS_ORDER = 950
30
38
 
31
- /** Model-facing text. Kept stable so unchanged sessions keep their KV cache. */
32
- export const FEISHU_ATTACHMENTS_GUIDANCE = `## Feishu attachments
39
+ /** Attachment backend whose guidance to mount; mirrors `attachmentStore`. */
40
+ export type AttachmentGuidanceMode = 'native' | 'archive'
41
+
42
+ /**
43
+ * Native-library guidance (new default). Model-facing text kept stable so
44
+ * unchanged sessions keep their KV cache.
45
+ */
46
+ export const FEISHU_ATTACHMENTS_NATIVE_GUIDANCE = `## Feishu attachments
47
+ Inbound Feishu images and files are stored in the DSH native attachment library and are tied to this conversation's session; they are not copied into the working directory automatically.
48
+
49
+ - Images that arrived in the message which triggered this turn are already visible to you as image blocks. Never load them again.
50
+ - Files are not loaded by default: the conversation only carries their metadata, not their bytes.
51
+ - The user message may carry a manifest in a \`<${FEISHU_CONTEXT_TAG}>\` block. It lists attachments that are not loaded yet, each with its message_id, type, and, when known, file_key and file name. A line with type=merge_forward is a forwarded-chat container: use its message_id without file_key. An image that could not be prefetched is listed the same way as a file.
52
+ - The user message may also carry \`<feishu_quote>\` (the message this one replies to) and, on a new session's first turn, \`<feishu_history>\` (bounded recent topic messages). Attachments named there are not loaded; fetch one with \`feishu_attachment_fetch\` using the message_id shown on its line.
53
+ - Use the \`feishu_topic_messages\` tool to read a quoted message in full, or to page through earlier messages of this topic (newest first, limit/cursor). Messages and attachments it returns are authorized for \`feishu_attachment_fetch\`.
54
+
55
+ When the user's request genuinely needs an attachment, fetch it with the \`feishu_attachment_fetch\` tool: pass message_id, and add file_key to fetch one specific file of that message. Fetch nothing when the request can be answered without the bytes, and do not proactively enumerate or fetch attachments the user did not mention.
56
+
57
+ Never read, copy, or print Feishu app credentials (for example files under the DSH home directory) and never call the Feishu OpenAPI directly with them; use only the Feishu tools provided in this conversation.
58
+
59
+ A fetched file is an immutable, read-only copy in the native attachment library. Before changing it, copy it into the working directory and modify the copy. Only access attachments of this conversation; never read or mention another conversation's attachments.`
60
+
61
+ /**
62
+ * Legacy archive guidance: attachments are archived under the conversation
63
+ * workspace's `attachments/` directory and read through filesystem tools.
64
+ * Model-facing text kept stable so unchanged sessions keep their KV cache.
65
+ */
66
+ export const FEISHU_ATTACHMENTS_ARCHIVE_GUIDANCE = `## Feishu attachments
33
67
  Every Feishu conversation (topic) has its own working directory inside its user's workspace, and every working directory has its own \`attachments/\` directory. Images, files, and forwarded chat records sent through Feishu are archived verbatim in this conversation's \`attachments/\` directory. Each file name starts with its receipt time (\`YYYYMMDD-HHmmss-\`), so newer attachments sort later and \`ls -lt <dir>\` lists the newest first.
34
68
 
35
69
  Attachments are never inserted into the conversation automatically. Read them only when the user's request needs them, and do not read attachments the user did not mention:
36
70
  - images: use \`read_image\` with the file path;
37
71
  - text and code: use \`read\` when the session mounts that tool, otherwise \`bash\` (for example \`sed -n '1,200p' <file>\`);
38
72
  - other formats (PDF, Office, archives, ...): inspect or convert them with \`bash\`.
39
- If the user refers to "the file/image I just sent" without a name, list this conversation's attachments directory and pick the most recent matching attachment. Other Feishu conversations work in separate directories: never read outside this conversation's working directory, and never mention files from another conversation.`
73
+ If the user refers to "the file/image I just sent" without a name, list this conversation's attachments directory and pick the most recent matching attachment. Other Feishu conversations work in separate directories: never read outside this conversation's working directory, and never mention files from another conversation.
74
+
75
+ Never read, copy, or print Feishu app credentials (for example files under the DSH home directory) and never call the Feishu OpenAPI directly with them; use only the Feishu tools provided in this conversation.
76
+
77
+ A message may also carry \`<feishu_quote>\` / \`<feishu_history>\` text excerpts. Attachments named there arrive lazily and are not in this directory in this deployment; describe what the excerpt says instead of claiming to have read the file.`
40
78
 
41
79
  /**
42
- * Full guidance for one Session. The absolute per-session directory is
43
- * appended when known so the convention still resolves after the Agent changes
80
+ * Full guidance text for one Session.
81
+ *
82
+ * The native flavor ignores `attachmentsDir` (there is no per-conversation
83
+ * directory to name). The archive flavor appends the absolute per-session
84
+ * directory when known so the convention still resolves after the Agent changes
44
85
  * its working directory with the `working_directory` tool.
45
86
  */
46
- export function attachmentsGuidance(attachmentsDir?: string): string {
47
- if (attachmentsDir === undefined || attachmentsDir === '') return FEISHU_ATTACHMENTS_GUIDANCE
48
- return `${FEISHU_ATTACHMENTS_GUIDANCE}\n\nThis conversation's attachment directory is \`${attachmentsDir.replace(/`/gu, "'")}\` (absolute).`
87
+ export function attachmentsGuidance(
88
+ mode: AttachmentGuidanceMode = 'native',
89
+ attachmentsDir?: string,
90
+ ): string {
91
+ if (mode !== 'archive') return FEISHU_ATTACHMENTS_NATIVE_GUIDANCE
92
+ if (attachmentsDir === undefined || attachmentsDir === '') return FEISHU_ATTACHMENTS_ARCHIVE_GUIDANCE
93
+ return `${FEISHU_ATTACHMENTS_ARCHIVE_GUIDANCE}\n\nThis conversation's attachment directory is \`${attachmentsDir.replace(/`/gu, "'")}\` (absolute).`
49
94
  }
50
95
 
51
96
  /**
@@ -54,13 +99,14 @@ export function attachmentsGuidance(attachmentsDir?: string): string {
54
99
  */
55
100
  export async function mountChannelGuidance(
56
101
  agentCtx: AgentContext,
102
+ mode: AttachmentGuidanceMode = 'native',
57
103
  attachmentsDir?: string,
58
104
  ): Promise<void> {
59
105
  await agentCtx.inject(['systemPrompt'], (ctx) => {
60
106
  ctx.systemPrompt?.section({
61
107
  name: FEISHU_ATTACHMENTS_SECTION,
62
108
  order: FEISHU_ATTACHMENTS_ORDER,
63
- text: attachmentsGuidance(attachmentsDir),
109
+ text: attachmentsGuidance(mode, attachmentsDir),
64
110
  })
65
111
  })
66
112
  }