@modesto_ma/dsh-channels 0.3.0 → 0.5.0-beta.1

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 (64) hide show
  1. package/README.md +123 -53
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +310 -0
  4. package/docs/feasibility-and-design.md +1 -1
  5. package/docs/feishu-onboarding.md +127 -21
  6. package/docs/persistence.md +6 -4
  7. package/docs/refactor-plan.md +2 -2
  8. package/docs/workspace-isolation-design.md +13 -11
  9. package/lib/client.js +179 -288
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +12125 -6382
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/access/cards.ts +9 -9
  15. package/src/access/controller.ts +10 -10
  16. package/src/access/index.ts +1 -0
  17. package/src/access/policy.ts +10 -10
  18. package/src/access/requests.ts +32 -31
  19. package/src/access/store.ts +12 -12
  20. package/src/access/targets.ts +56 -0
  21. package/src/cards/approval.ts +96 -26
  22. package/src/cards/index.ts +1 -0
  23. package/src/cards/onboarding.ts +1 -0
  24. package/src/cards/picker.ts +115 -0
  25. package/src/cards/streaming.ts +56 -7
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +0 -23
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/styles.ts +2 -0
  34. package/src/commands/registry.ts +2 -2
  35. package/src/config.ts +190 -32
  36. package/src/domain.ts +28 -18
  37. package/src/feishu/agent-context.ts +65 -19
  38. package/src/feishu/inbound-attachments.ts +440 -3
  39. package/src/feishu/manifest.ts +247 -0
  40. package/src/feishu/message-read.ts +1330 -0
  41. package/src/feishu/native-attachments.ts +998 -0
  42. package/src/feishu/scopes.ts +171 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +2305 -0
  46. package/src/feishu-channel.ts +322 -6
  47. package/src/host/agent.ts +55 -0
  48. package/src/host/attachments.ts +210 -0
  49. package/src/host/index.ts +1 -0
  50. package/src/index.ts +5 -4
  51. package/src/interactions/approvals.ts +187 -7
  52. package/src/interactions/pending.ts +3 -3
  53. package/src/routing/inbound.ts +620 -49
  54. package/src/service.ts +808 -375
  55. package/src/session/manager.ts +67 -13
  56. package/src/session/routes.ts +2 -3
  57. package/src/settings/contract.ts +90 -89
  58. package/src/settings/overlay.ts +124 -13
  59. package/src/settings/routes.ts +3 -17
  60. package/src/settings/surface.ts +156 -82
  61. package/src/streamer.ts +94 -5
  62. package/src/workspaces.ts +39 -26
  63. package/src/cleanup.ts +0 -535
  64. package/src/client/panel/cleanup-card.tsx +0 -106
package/src/domain.ts CHANGED
@@ -41,8 +41,8 @@ export const routeRecordSchema = z.object({
41
41
  chatId: z.string().min(1),
42
42
  /** `p2p` or `group`. */
43
43
  chatType: z.enum(['p2p', 'group']),
44
- /** Workspace owner (sender open_id). */
45
- senderOpenId: z.string().min(1),
44
+ /** Workspace owner (sender user_id). */
45
+ senderUserId: z.string().min(1),
46
46
  /** Workspace root owning this route: the Session header cwd / sandbox root. */
47
47
  workspacePath: z.string().min(1),
48
48
  /**
@@ -81,15 +81,14 @@ export const expiredRouteRecordSchema = z.object({
81
81
  /** Original chat type, retained for diagnostics and future policy. */
82
82
  chatType: z.enum(['p2p', 'group']),
83
83
  /** Original route owner; never used to grant access after expiry. */
84
- senderOpenId: z.string().min(1),
84
+ senderUserId: z.string().min(1),
85
85
  /** Epoch ms when the route was retired. */
86
86
  expiredAt: z.number().int().nonnegative(),
87
87
  /** Workspace the retired route belonged to; absent in tombstones written by
88
88
  * older plugin versions, so the field must stay optional. */
89
89
  workspacePath: z.string().min(1).optional(),
90
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). */
91
+ * legacy tombstones, where `workspacePath` was the directory (pre-v2 routes). */
93
92
  sessionDir: z.string().min(1).optional(),
94
93
  /** Git branch of that directory when it was a worktree checkout. */
95
94
  worktreeBranch: z.string().min(1).optional(),
@@ -202,7 +201,7 @@ export const accessGroupRecordSchema = z.object({
202
201
  name: z.string().optional(),
203
202
  /** Which authorization entry produced this grant. */
204
203
  source: z.enum(['command', 'card', 'panel']),
205
- /** Administrator open_id that authorized it. */
204
+ /** Administrator user_id that authorized it. */
206
205
  addedBy: z.string().min(1),
207
206
  /** Epoch ms when it was authorized. */
208
207
  addedAt: z.number().int().nonnegative(),
@@ -211,18 +210,18 @@ export const accessGroupRecordSchema = z.object({
211
210
  export type AccessGroupRecord = z.infer<typeof accessGroupRecordSchema>
212
211
 
213
212
  /**
214
- * One authorized user (`open_id`), global across every authorized group.
213
+ * One authorized user (`user_id`), global across every authorized group.
215
214
  * Private chats are administrator-only, so this grant only affects group
216
215
  * conversations.
217
216
  */
218
217
  export const accessUserRecordSchema = z.object({
219
- /** Feishu open id (`ou_...`) — also the table key. */
220
- openId: z.string().min(1),
218
+ /** Feishu `user_id` — also the table key. */
219
+ userId: z.string().min(1),
221
220
  /** Display name captured when the user was authorized (optional). */
222
221
  name: z.string().optional(),
223
222
  /** Which authorization entry produced this grant. */
224
223
  source: z.enum(['command', 'card', 'panel']),
225
- /** Administrator open_id that authorized it. */
224
+ /** Administrator user_id that authorized it. */
226
225
  addedBy: z.string().min(1),
227
226
  /** Epoch ms when it was authorized. */
228
227
  addedAt: z.number().int().nonnegative(),
@@ -241,14 +240,14 @@ export const accessRequestRecordSchema = z.object({
241
240
  token: z.string().min(1),
242
241
  /** What is being requested. */
243
242
  kind: z.enum(['group', 'user']),
244
- /** `chat_id` for a group request; `open_id` for a user request. */
243
+ /** `chat_id` for a group request; `user_id` for a user request. */
245
244
  targetId: z.string().min(1),
246
245
  /** Chat the request originated from, when applicable. */
247
246
  chatId: z.string().optional(),
248
247
  /** Group name captured for the card (group requests, or the group the user was seen in). */
249
248
  groupName: z.string().optional(),
250
249
  /** User who triggered a user request. */
251
- requesterOpenId: z.string().optional(),
250
+ requesterUserId: z.string().optional(),
252
251
  /** Display name of that user, when the event carried one. */
253
252
  requesterName: z.string().optional(),
254
253
  /** Lifecycle state; only `pending` requests accept a card action. */
@@ -283,7 +282,7 @@ export const processedRecordSchema = z.object({
283
282
  export type ProcessedRecord = z.infer<typeof processedRecordSchema>
284
283
 
285
284
  export interface FeishuDomainSpecShape {
286
- name: 'channels_routes'
285
+ name: 'channels_routes_v2'
287
286
  version: 1
288
287
  global: {
289
288
  schema: z.ZodType<FeishuDomainGlobal>
@@ -338,7 +337,9 @@ export type FeishuDomainGlobal = z.infer<typeof feishuDomainGlobalSchema>
338
337
  * runtime import of `@deepseek-ai/dsh-storage-domain`.
339
338
  */
340
339
  export const feishuDomain: FeishuDomainSpecShape = {
341
- name: 'channels_routes',
340
+ // v2 keys every user by `user_id`. The open_id-keyed `channels_routes` unit
341
+ // is neither read nor migrated; it stays on disk untouched.
342
+ name: 'channels_routes_v2',
342
343
  version: 1,
343
344
  global: {
344
345
  schema: feishuDomainGlobalSchema,
@@ -402,7 +403,7 @@ export interface AccessGroupsTable {
402
403
  entries(): IterableIterator<[string, AccessGroupRecord]>
403
404
  }
404
405
 
405
- /** Normalized table handle for authorized users (key = `open_id`). */
406
+ /** Normalized table handle for authorized users (key = `user_id`). */
406
407
  export interface AccessUsersTable {
407
408
  get(key: string): AccessUserRecord | undefined
408
409
  put(key: string, value: AccessUserRecord): Promise<unknown> | void
@@ -451,9 +452,18 @@ export interface RawDomainHandle {
451
452
  close(): Promise<void>
452
453
  }
453
454
 
455
+ /**
456
+ * Feishu tenant `user_id` (letters, digits, `_`, `-`). It doubles as a
457
+ * workspace path segment and as a DM target, where the SDK infers the
458
+ * receive id type from the prefix: `ou_`/`on_`/`oc_` would be sent as an
459
+ * open_id/union_id/chat_id, so those prefixes are rejected (a pasted open_id
460
+ * fails validation instead of matching nobody).
461
+ */
462
+ export const FEISHU_USER_ID_PATTERN = /^(?!ou_|on_|oc_)[A-Za-z0-9_-]+$/u
463
+
454
464
  /** Build a chat-scoped fallback key for degraded (topic-less) routing. */
455
- export function chatRouteKey(chatType: 'p2p' | 'group', chatId: string, senderOpenId: string): string {
465
+ export function chatRouteKey(chatType: 'p2p' | 'group', chatId: string, senderUserId: string): string {
456
466
  return chatType === 'p2p'
457
- ? `chat:p2p:${chatId}:${senderOpenId}`
458
- : `chat:group:${chatId}:${senderOpenId}`
467
+ ? `chat:p2p:${chatId}:${senderUserId}`
468
+ : `chat:group:${chatId}:${senderUserId}`
459
469
  }
@@ -1,22 +1,30 @@
1
1
  /**
2
2
  * Agent-scoped system-prompt guidance for Feishu attachments.
3
3
  *
4
- * The channel archives inbound images/files/forwarded records below
5
- * `<session working directory>/attachments/` without any model turn. This
6
- * section is what makes that convention discoverable: it tells the Agent where
7
- * attachments live, how to read them, and that reading is opt-in per user
8
- * request.
4
+ * Which guidance is mounted follows the *effective* attachment backend
5
+ * (`attachmentStore`, decisions D6/D2):
6
+ * - `native` (the default): inbound images/files live in the DSH native
7
+ * attachment library. Trigger-message images are prefetched and already
8
+ * visible; files stay unloaded and the prompt carries a stable
9
+ * `<feishu_context>` manifest (see `manifest.ts`) that the Agent uses with the
10
+ * `feishu_attachment_fetch` tool on demand.
11
+ * - `archive` (legacy / fallback): inbound images/files are archived below
12
+ * `<session working directory>/attachments/` and read with filesystem tools.
13
+ * Kept for explicit `attachmentStore: 'archive'` deployments and for native
14
+ * deployments whose host does not mount `ctx.attachments`.
9
15
  *
10
- * Registered inside the Agent scope (through `inject(['systemPrompt'])`), so
11
- * it is disposed with the Agent and does not leak into other compositions.
12
- * It is mounted only after the Session's effective working directory is known
13
- * (see `SessionManager.mountGuidance`), so the absolute path is accurate even
14
- * when that directory is a Git worktree created after Agent creation.
16
+ * Registered inside the Agent scope (through `inject(['systemPrompt'])`), so it
17
+ * is disposed with the Agent and does not leak into other compositions. The
18
+ * archive flavor is mounted only after the Session's effective working
19
+ * directory is known (see `SessionManager.mountGuidance`), so its absolute path
20
+ * is accurate even when that directory is a Git worktree created after Agent
21
+ * creation.
15
22
  *
16
23
  * @module dsh-channels/feishu/agent-context
17
24
  */
18
25
 
19
26
  import type { AgentContext } from '../host/agent.ts'
27
+ import { FEISHU_CONTEXT_TAG } from './manifest.ts'
20
28
 
21
29
  /** Stable prompt-section name; one registration per Agent scope. */
22
30
  export const FEISHU_ATTACHMENTS_SECTION = 'dsh-channels:feishu-attachments'
@@ -28,24 +36,61 @@ export const FEISHU_ATTACHMENTS_SECTION = 'dsh-channels:feishu-attachments'
28
36
  */
29
37
  const FEISHU_ATTACHMENTS_ORDER = 950
30
38
 
31
- /** Model-facing text. Kept stable so unchanged sessions keep their KV cache. */
32
- export const FEISHU_ATTACHMENTS_GUIDANCE = `## Feishu attachments
39
+ /** Attachment backend whose guidance to mount; mirrors `attachmentStore`. */
40
+ export type AttachmentGuidanceMode = 'native' | 'archive'
41
+
42
+ /**
43
+ * Native-library guidance (new default). Model-facing text kept stable so
44
+ * unchanged sessions keep their KV cache.
45
+ */
46
+ export const FEISHU_ATTACHMENTS_NATIVE_GUIDANCE = `## Feishu attachments
47
+ Inbound Feishu images and files are stored in the DSH native attachment library and are tied to this conversation's session; they are not copied into the working directory automatically.
48
+
49
+ - Images that arrived in the message which triggered this turn are already visible to you as image blocks. Never load them again.
50
+ - Files are not loaded by default: the conversation only carries their metadata, not their bytes.
51
+ - The user message may carry a manifest in a \`<${FEISHU_CONTEXT_TAG}>\` block. It lists attachments that are not loaded yet, each with its message_id, type, and, when known, file_key and file name. A line with type=merge_forward is a forwarded-chat container: use its message_id without file_key. An image that could not be prefetched is listed the same way as a file.
52
+ - The user message may also carry \`<feishu_quote>\` (the message this one replies to) and, on a new session's first turn, \`<feishu_history>\` (bounded recent topic messages). Attachments named there are not loaded; fetch one with \`feishu_attachment_fetch\` using the message_id shown on its line.
53
+ - Use the \`feishu_topic_messages\` tool to read a quoted message in full, or to page through earlier messages of this topic (newest first, limit/cursor). Messages and attachments it returns are authorized for \`feishu_attachment_fetch\`.
54
+
55
+ When the user's request genuinely needs an attachment, fetch it with the \`feishu_attachment_fetch\` tool: pass message_id, and add file_key to fetch one specific file of that message. Fetch nothing when the request can be answered without the bytes, and do not proactively enumerate or fetch attachments the user did not mention.
56
+
57
+ Never read, copy, or print Feishu app credentials (for example files under the DSH home directory) and never call the Feishu OpenAPI directly with them; use only the Feishu tools provided in this conversation.
58
+
59
+ A fetched file is an immutable, read-only copy in the native attachment library. Before changing it, copy it into the working directory and modify the copy. Only access attachments of this conversation; never read or mention another conversation's attachments.`
60
+
61
+ /**
62
+ * Legacy archive guidance: attachments are archived under the conversation
63
+ * workspace's `attachments/` directory and read through filesystem tools.
64
+ * Model-facing text kept stable so unchanged sessions keep their KV cache.
65
+ */
66
+ export const FEISHU_ATTACHMENTS_ARCHIVE_GUIDANCE = `## Feishu attachments
33
67
  Every Feishu conversation (topic) has its own working directory inside its user's workspace, and every working directory has its own \`attachments/\` directory. Images, files, and forwarded chat records sent through Feishu are archived verbatim in this conversation's \`attachments/\` directory. Each file name starts with its receipt time (\`YYYYMMDD-HHmmss-\`), so newer attachments sort later and \`ls -lt <dir>\` lists the newest first.
34
68
 
35
69
  Attachments are never inserted into the conversation automatically. Read them only when the user's request needs them, and do not read attachments the user did not mention:
36
70
  - images: use \`read_image\` with the file path;
37
71
  - text and code: use \`read\` when the session mounts that tool, otherwise \`bash\` (for example \`sed -n '1,200p' <file>\`);
38
72
  - other formats (PDF, Office, archives, ...): inspect or convert them with \`bash\`.
39
- If the user refers to "the file/image I just sent" without a name, list this conversation's attachments directory and pick the most recent matching attachment. Other Feishu conversations work in separate directories: never read outside this conversation's working directory, and never mention files from another conversation.`
73
+ If the user refers to "the file/image I just sent" without a name, list this conversation's attachments directory and pick the most recent matching attachment. Other Feishu conversations work in separate directories: never read outside this conversation's working directory, and never mention files from another conversation.
74
+
75
+ Never read, copy, or print Feishu app credentials (for example files under the DSH home directory) and never call the Feishu OpenAPI directly with them; use only the Feishu tools provided in this conversation.
76
+
77
+ A message may also carry \`<feishu_quote>\` / \`<feishu_history>\` text excerpts. Attachments named there arrive lazily and are not in this directory in this deployment; describe what the excerpt says instead of claiming to have read the file.`
40
78
 
41
79
  /**
42
- * Full guidance for one Session. The absolute per-session directory is
43
- * appended when known so the convention still resolves after the Agent changes
80
+ * Full guidance text for one Session.
81
+ *
82
+ * The native flavor ignores `attachmentsDir` (there is no per-conversation
83
+ * directory to name). The archive flavor appends the absolute per-session
84
+ * directory when known so the convention still resolves after the Agent changes
44
85
  * its working directory with the `working_directory` tool.
45
86
  */
46
- export function attachmentsGuidance(attachmentsDir?: string): string {
47
- if (attachmentsDir === undefined || attachmentsDir === '') return FEISHU_ATTACHMENTS_GUIDANCE
48
- return `${FEISHU_ATTACHMENTS_GUIDANCE}\n\nThis conversation's attachment directory is \`${attachmentsDir.replace(/`/gu, "'")}\` (absolute).`
87
+ export function attachmentsGuidance(
88
+ mode: AttachmentGuidanceMode = 'native',
89
+ attachmentsDir?: string,
90
+ ): string {
91
+ if (mode !== 'archive') return FEISHU_ATTACHMENTS_NATIVE_GUIDANCE
92
+ if (attachmentsDir === undefined || attachmentsDir === '') return FEISHU_ATTACHMENTS_ARCHIVE_GUIDANCE
93
+ return `${FEISHU_ATTACHMENTS_ARCHIVE_GUIDANCE}\n\nThis conversation's attachment directory is \`${attachmentsDir.replace(/`/gu, "'")}\` (absolute).`
49
94
  }
50
95
 
51
96
  /**
@@ -54,13 +99,14 @@ export function attachmentsGuidance(attachmentsDir?: string): string {
54
99
  */
55
100
  export async function mountChannelGuidance(
56
101
  agentCtx: AgentContext,
102
+ mode: AttachmentGuidanceMode = 'native',
57
103
  attachmentsDir?: string,
58
104
  ): Promise<void> {
59
105
  await agentCtx.inject(['systemPrompt'], (ctx) => {
60
106
  ctx.systemPrompt?.section({
61
107
  name: FEISHU_ATTACHMENTS_SECTION,
62
108
  order: FEISHU_ATTACHMENTS_ORDER,
63
- text: attachmentsGuidance(attachmentsDir),
109
+ text: attachmentsGuidance(mode, attachmentsDir),
64
110
  })
65
111
  })
66
112
  }