@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/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,
@@ -103,6 +151,23 @@ export const configSchema = z.object({
103
151
  * `strict` fails the reply instead.
104
152
  */
105
153
  threadFallback: z.enum(['degrade', 'strict']).default('degrade'),
154
+ /**
155
+ * Per-topic working-directory isolation inside one user workspace.
156
+ *
157
+ * - `auto` (default): use a Git worktree per topic when the user workspace
158
+ * is a Git repository (the workspace directory contains `.git`), otherwise
159
+ * a plain subdirectory.
160
+ * - `subdir`: always a plain subdirectory (no Git requirement, no duplicated
161
+ * checkouts).
162
+ * - `worktree`: always try a Git worktree; when the workspace is not a Git
163
+ * repository, the `worktrees` service is unavailable, or creation is
164
+ * denied, log a warning and fall back to a subdirectory.
165
+ *
166
+ * The workspace itself stays the Session header cwd either way, so Sessions
167
+ * remain grouped under the user's project; only the effective working
168
+ * directory changes. See `docs/workspace-isolation-design.md`.
169
+ */
170
+ topicIsolation: z.enum(['auto', 'subdir', 'worktree']).default('auto'),
106
171
  /**
107
172
  * Route-table GC threshold. Route records (and stale in-flight repair
108
173
  * snapshots) whose last activity is older than this many milliseconds are
@@ -121,25 +186,168 @@ export const configSchema = z.object({
121
186
  */
122
187
  approvalTimeoutMs: z.number().int().min(10_000).max(3_600_000).default(DEFAULT_APPROVAL_TIMEOUT_MS),
123
188
  /**
124
- * Accept inbound Feishu images, files, and merged-forward chat records and
125
- * save them below `<workspace>/attachments/`. Nothing is forwarded to the
126
- * model automatically: the Agent reads whichever attachment the
127
- * conversation needs with its ordinary filesystem tools. Disabled keeps
128
- * 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).
129
197
  */
130
198
  acceptAttachments: z.boolean().default(true),
131
199
  /**
132
- * Delete the workspace directories of stale conversations. `off` (default)
133
- * never deletes; `7d`/`30d`/`90d`/`180d` remove plugin-created workspaces
134
- * whose last activity is older than the threshold. The managed Web panel
135
- * exposes this as a select, previews the candidates, and can run one
136
- * cleanup manually. DSH Session logs are never deleted.
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
+ }),
337
+ /**
338
+ * Delete the topic directories of stale conversations, plus legacy
339
+ * one-workspace-per-topic directories written by older plugin versions.
340
+ * `off` (default) never deletes; `7d`/`30d`/`90d`/`180d` remove those
341
+ * directories when their last activity is older than the threshold. User
342
+ * workspaces themselves are never deleted. The managed Web panel exposes
343
+ * this as a select, previews the candidates, and can run one cleanup
344
+ * manually. DSH Session logs are never deleted.
137
345
  */
138
346
  workspaceRetention: z.enum(WORKSPACE_RETENTION_VALUES).default('off'),
139
347
  /**
140
- * Run the workspace-retention cleanup at startup and once per day. Off by
141
- * default: deletion is irreversible, so it stays an explicit opt-in even
142
- * when a retention threshold is configured.
348
+ * Run the retention cleanup at startup and once per day. Off by default:
349
+ * deletion is irreversible, so it stays an explicit opt-in even when a
350
+ * retention threshold is configured.
143
351
  */
144
352
  workspaceRetentionAuto: z.boolean().default(false),
145
353
  /**
package/src/domain.ts CHANGED
@@ -10,8 +10,16 @@
10
10
 
11
11
  import { z } from 'zod'
12
12
 
13
- /** Route key kinds. `thread` = real Feishu topic; `chat` = degraded chat-scoped fallback. */
14
- export const ROUTE_RECORD_VERSION = 1
13
+ /**
14
+ * Route key kinds. `thread` = real Feishu topic; `chat` = degraded chat-scoped
15
+ * fallback.
16
+ *
17
+ * Version 2 adds the optional `sessionDir` / `worktreeBranch` fields (the
18
+ * Session's effective working directory). Version-1 records stay readable: an
19
+ * absent `sessionDir` means the workspace path itself is the working directory
20
+ * (the pre-v2 model).
21
+ */
22
+ export const ROUTE_RECORD_VERSION = 2
15
23
 
16
24
  export const sessionHistoryEntrySchema = z.object({
17
25
  /** One Session that has been bound to this route. */
@@ -35,8 +43,16 @@ export const routeRecordSchema = z.object({
35
43
  chatType: z.enum(['p2p', 'group']),
36
44
  /** Workspace owner (sender open_id). */
37
45
  senderOpenId: z.string().min(1),
38
- /** Workspace directory for this (chat, user) pair. */
46
+ /** Workspace root owning this route: the Session header cwd / sandbox root. */
39
47
  workspacePath: z.string().min(1),
48
+ /**
49
+ * Effective working directory of the bound Session (the per-topic directory
50
+ * or Git worktree checkout). Absent on records written before per-topic
51
+ * directories existed: `workspacePath` is then the working directory too.
52
+ */
53
+ sessionDir: z.string().min(1).optional(),
54
+ /** Git branch owning `sessionDir` when it is a worktree checkout. */
55
+ worktreeBranch: z.string().min(1).optional(),
40
56
  /** Message id of the latest streaming card (updated per turn). */
41
57
  cardMessageId: z.string().min(1),
42
58
  /** Epoch ms when the record was created. */
@@ -71,6 +87,12 @@ export const expiredRouteRecordSchema = z.object({
71
87
  /** Workspace the retired route belonged to; absent in tombstones written by
72
88
  * older plugin versions, so the field must stay optional. */
73
89
  workspacePath: z.string().min(1).optional(),
90
+ /** Effective working directory the retired route's Session used; absent in
91
+ * legacy tombstones (retention cleanup then treats `workspacePath` as the
92
+ * directory, matching pre-v2 routes). */
93
+ sessionDir: z.string().min(1).optional(),
94
+ /** Git branch of that directory when it was a worktree checkout. */
95
+ worktreeBranch: z.string().min(1).optional(),
74
96
  /** Epoch ms of the last activity through the route before retirement.
75
97
  * Optional for the same legacy-compatibility reason; consumers fall back to
76
98
  * `expiredAt` when it is absent. */
@@ -116,6 +138,15 @@ export function appendSessionHistory(
116
138
  })
117
139
  }
118
140
 
141
+ /**
142
+ * Effective working directory of one route's Session. Records written before
143
+ * per-topic directories have no `sessionDir`; their workspace path is the
144
+ * directory the Session really works in.
145
+ */
146
+ export function sessionDirOf(record: Pick<RouteRecord, 'workspacePath' | 'sessionDir'>): string {
147
+ return record.sessionDir ?? record.workspacePath
148
+ }
149
+
119
150
  /**
120
151
  * In-flight turn snapshot: everything needed to repair a streaming card that a
121
152
  * restart left mid-flight (stuck panel, leftover Typing reaction, never-patched
@@ -251,6 +282,89 @@ export const processedRecordSchema = z.object({
251
282
 
252
283
  export type ProcessedRecord = z.infer<typeof processedRecordSchema>
253
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
+
254
368
  export interface FeishuDomainSpecShape {
255
369
  name: 'channels_routes'
256
370
  version: 1
@@ -287,6 +401,10 @@ export interface FeishuDomainSpecShape {
287
401
  valueSchema: typeof accessRequestRecordSchema
288
402
  __key?: string
289
403
  }
404
+ pending_attachments: {
405
+ valueSchema: typeof pendingAttachmentRecordSchema
406
+ __key?: string
407
+ }
290
408
  }
291
409
  }
292
410
 
@@ -321,6 +439,7 @@ export const feishuDomain: FeishuDomainSpecShape = {
321
439
  access_groups: { valueSchema: accessGroupRecordSchema },
322
440
  access_users: { valueSchema: accessUserRecordSchema },
323
441
  access_requests: { valueSchema: accessRequestRecordSchema },
442
+ pending_attachments: { valueSchema: pendingAttachmentRecordSchema },
324
443
  },
325
444
  }
326
445
 
@@ -387,6 +506,19 @@ export interface AccessRequestsTable {
387
506
  entries(): IterableIterator<[string, AccessRequestRecord]>
388
507
  }
389
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
+
390
522
  /** Normalized domain handle. */
391
523
  export interface FeishuDomainHandle {
392
524
  routes: RoutesTable
@@ -396,6 +528,12 @@ export interface FeishuDomainHandle {
396
528
  accessGroups: AccessGroupsTable
397
529
  accessUsers: AccessUsersTable
398
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
399
537
  getGlobal(): FeishuDomainGlobal
400
538
  setGlobal(value: FeishuDomainGlobal): Promise<unknown> | void
401
539
  close(): Promise<void>
@@ -1,18 +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
- * `<workspace>/attachments/` without any model turn. This section is what
6
- * makes that convention discoverable: it tells the Agent where attachments
7
- * live, how to read them, and that reading is opt-in per user 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`.
8
15
  *
9
- * Registered inside the Agent scope (through `inject(['systemPrompt'])`), so
10
- * it is disposed with the Agent and does not leak into other compositions.
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.
11
22
  *
12
23
  * @module dsh-channels/feishu/agent-context
13
24
  */
14
25
 
15
26
  import type { AgentContext } from '../host/agent.ts'
27
+ import { FEISHU_CONTEXT_TAG } from './manifest.ts'
16
28
 
17
29
  /** Stable prompt-section name; one registration per Agent scope. */
18
30
  export const FEISHU_ATTACHMENTS_SECTION = 'dsh-channels:feishu-attachments'
@@ -24,24 +36,61 @@ export const FEISHU_ATTACHMENTS_SECTION = 'dsh-channels:feishu-attachments'
24
36
  */
25
37
  const FEISHU_ATTACHMENTS_ORDER = 950
26
38
 
27
- /** Model-facing text. Kept stable so unchanged sessions keep their KV cache. */
28
- export const FEISHU_ATTACHMENTS_GUIDANCE = `## Feishu attachments
29
- Every Feishu conversation (topic) has its own workspace and 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.
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
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.
30
68
 
31
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:
32
70
  - images: use \`read_image\` with the file path;
33
71
  - text and code: use \`read\` when the session mounts that tool, otherwise \`bash\` (for example \`sed -n '1,200p' <file>\`);
34
72
  - other formats (PDF, Office, archives, ...): inspect or convert them with \`bash\`.
35
- 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 have separate workspaces: never read outside this workspace, 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.`
36
78
 
37
79
  /**
38
- * Full guidance for one Session. The absolute per-session directory is
39
- * 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
40
85
  * its working directory with the `working_directory` tool.
41
86
  */
42
- export function attachmentsGuidance(attachmentsDir?: string): string {
43
- if (attachmentsDir === undefined || attachmentsDir === '') return FEISHU_ATTACHMENTS_GUIDANCE
44
- 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).`
45
94
  }
46
95
 
47
96
  /**
@@ -50,13 +99,14 @@ export function attachmentsGuidance(attachmentsDir?: string): string {
50
99
  */
51
100
  export async function mountChannelGuidance(
52
101
  agentCtx: AgentContext,
102
+ mode: AttachmentGuidanceMode = 'native',
53
103
  attachmentsDir?: string,
54
104
  ): Promise<void> {
55
105
  await agentCtx.inject(['systemPrompt'], (ctx) => {
56
106
  ctx.systemPrompt?.section({
57
107
  name: FEISHU_ATTACHMENTS_SECTION,
58
108
  order: FEISHU_ATTACHMENTS_ORDER,
59
- text: attachmentsGuidance(attachmentsDir),
109
+ text: attachmentsGuidance(mode, attachmentsDir),
60
110
  })
61
111
  })
62
112
  }