@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/README.md +71 -19
- package/docs/attachment-pipeline-v2.md +318 -0
- package/docs/feasibility-and-design.md +1 -1
- package/docs/feishu-onboarding.md +108 -2
- package/docs/refactor-plan.md +2 -2
- package/docs/workspace-isolation-design.md +9 -6
- package/lib/client.js +10 -5
- package/lib/client.js.map +1 -1
- package/lib/index.js +10193 -4824
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cleanup.ts +13 -2
- package/src/config.ts +194 -5
- package/src/domain.ts +107 -0
- package/src/feishu/agent-context.ts +65 -19
- 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 +1 -1
- package/src/routing/inbound.ts +652 -48
- package/src/service.ts +417 -9
- package/src/session/manager.ts +66 -12
- package/src/settings/contract.ts +28 -10
- package/src/settings/overlay.ts +124 -13
- package/src/settings/surface.ts +137 -12
- package/src/workspaces.ts +25 -4
package/package.json
CHANGED
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
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* (see `SessionManager.mountGuidance`), so
|
|
14
|
-
* when that directory is a Git worktree created after Agent
|
|
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
|
-
/**
|
|
32
|
-
export
|
|
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.
|
|
43
|
-
*
|
|
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(
|
|
47
|
-
|
|
48
|
-
|
|
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
|
}
|