@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.
- package/README.md +115 -39
- package/docs/access-control-design.md +1 -1
- package/docs/attachment-pipeline-v2.md +318 -0
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +123 -15
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +7 -6
- package/docs/workspace-isolation-design.md +81 -0
- package/lib/client.js +30 -15
- package/lib/client.js.map +1 -1
- package/lib/index.js +11008 -5132
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +23 -0
- package/src/cards/primitives.ts +20 -0
- package/src/cards/streaming.ts +310 -291
- package/src/cleanup.ts +267 -119
- package/src/client/panel/cleanup-card.tsx +4 -2
- package/src/client/panel/workspace-tab.tsx +1 -1
- package/src/config.ts +221 -13
- package/src/domain.ts +141 -3
- package/src/feishu/agent-context.ts +66 -16
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +270 -0
- package/src/feishu/message-read.ts +1333 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/pending-attachments.ts +562 -0
- package/src/feishu/tools.ts +2345 -0
- package/src/feishu-channel.ts +55 -0
- package/src/host/agent.ts +55 -0
- package/src/host/attachments.ts +210 -0
- package/src/host/index.ts +1 -0
- package/src/index.ts +8 -6
- package/src/routing/inbound.ts +730 -81
- package/src/service.ts +536 -50
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +120 -23
- package/src/session/repair.ts +2 -3
- package/src/session/routes.ts +2 -0
- package/src/session-commands.ts +41 -14
- package/src/settings/contract.ts +43 -21
- package/src/settings/overlay.ts +124 -13
- package/src/settings/surface.ts +147 -14
- package/src/streamer.ts +70 -30
- 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
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
-
/**
|
|
14
|
-
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
28
|
-
export
|
|
29
|
-
|
|
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
|
|
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.
|
|
39
|
-
*
|
|
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(
|
|
43
|
-
|
|
44
|
-
|
|
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
|
}
|