@modesto_ma/dsh-channels 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +115 -39
  2. package/docs/access-control-design.md +1 -1
  3. package/docs/attachment-pipeline-v2.md +318 -0
  4. package/docs/feasibility-and-design.md +54 -26
  5. package/docs/feishu-onboarding.md +123 -15
  6. package/docs/persistence.md +4 -4
  7. package/docs/refactor-plan.md +7 -6
  8. package/docs/workspace-isolation-design.md +81 -0
  9. package/lib/client.js +30 -15
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +11008 -5132
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/onboarding.ts +23 -0
  16. package/src/cards/primitives.ts +20 -0
  17. package/src/cards/streaming.ts +310 -291
  18. package/src/cleanup.ts +267 -119
  19. package/src/client/panel/cleanup-card.tsx +4 -2
  20. package/src/client/panel/workspace-tab.tsx +1 -1
  21. package/src/config.ts +221 -13
  22. package/src/domain.ts +141 -3
  23. package/src/feishu/agent-context.ts +66 -16
  24. package/src/feishu/inbound-attachments.ts +440 -3
  25. package/src/feishu/manifest.ts +270 -0
  26. package/src/feishu/message-read.ts +1333 -0
  27. package/src/feishu/native-attachments.ts +998 -0
  28. package/src/feishu/pending-attachments.ts +562 -0
  29. package/src/feishu/tools.ts +2345 -0
  30. package/src/feishu-channel.ts +55 -0
  31. package/src/host/agent.ts +55 -0
  32. package/src/host/attachments.ts +210 -0
  33. package/src/host/index.ts +1 -0
  34. package/src/index.ts +8 -6
  35. package/src/routing/inbound.ts +730 -81
  36. package/src/service.ts +536 -50
  37. package/src/session/isolation.ts +277 -0
  38. package/src/session/manager.ts +120 -23
  39. package/src/session/repair.ts +2 -3
  40. package/src/session/routes.ts +2 -0
  41. package/src/session-commands.ts +41 -14
  42. package/src/settings/contract.ts +43 -21
  43. package/src/settings/overlay.ts +124 -13
  44. package/src/settings/surface.ts +147 -14
  45. package/src/streamer.ts +70 -30
  46. package/src/workspaces.ts +74 -31
@@ -88,6 +88,26 @@ export interface FeishuChannel {
88
88
  fileKey: string,
89
89
  type: 'image' | 'file',
90
90
  ): Promise<ResourceChunkStream>
91
+ /** P0 probe surface: raw `im.v1.message.get` (quoted / merged-forward container). */
92
+ messageGet?(messageId: string): Promise<unknown>
93
+ /**
94
+ * Batch read surface: raw `im.v1.message.mget` (≤50 message ids). The SDK
95
+ * 1.76 client does not expose this endpoint as a typed method, so the
96
+ * adapter calls it through `Client.request`; every caller must tolerate the
97
+ * method being absent (older channels / stubs) and fall back to `message.get`.
98
+ */
99
+ messageMget?(messageIds: readonly string[]): Promise<unknown>
100
+ /** P0 probe surface: raw `im.v1.message.list` (thread / chat history). */
101
+ messageList?(params: RawMessageListParams): Promise<unknown>
102
+ }
103
+
104
+ /** Query for the raw `im.v1.message.list` endpoint. */
105
+ export interface RawMessageListParams {
106
+ container_id_type: 'chat' | 'thread'
107
+ container_id: string
108
+ page_size?: number
109
+ page_token?: string
110
+ sort_type?: 'ByCreateTimeAsc' | 'ByCreateTimeDesc'
91
111
  }
92
112
 
93
113
  /** Ordered byte chunks of one inbound resource. */
@@ -256,6 +276,41 @@ class LarkChannelAdapter implements FeishuChannel {
256
276
  return res?.data?.items?.[0]?.thread_id
257
277
  }
258
278
 
279
+ async messageGet(messageId: string): Promise<unknown> {
280
+ const message = this.channel.rawClient.im.v1.message as unknown as {
281
+ get(input: { path: { message_id: string } }): Promise<unknown>
282
+ }
283
+ return await message.get({ path: { message_id: messageId } })
284
+ }
285
+
286
+ async messageMget(messageIds: readonly string[]): Promise<unknown> {
287
+ // SDK 1.76 has no typed `im.v1.message.mget`; the endpoint is
288
+ // `GET /open-apis/im/v1/messages/mget` with *repeated* `message_ids`
289
+ // query params (comma-separated ids are rejected by Feishu with 99992354).
290
+ // Axios would serialize an array with brackets, so the query string is
291
+ // built explicitly inside the URL.
292
+ if (messageIds.length === 0 || messageIds.length > 50) {
293
+ throw new Error(`message.mget accepts 1-50 message ids (got ${messageIds.length})`)
294
+ }
295
+ const query = messageIds
296
+ .map(messageId => `message_ids=${encodeURIComponent(messageId)}`)
297
+ .join('&')
298
+ const client = this.channel.rawClient as unknown as {
299
+ request(input: { method: string; url: string }): Promise<unknown>
300
+ }
301
+ return await client.request({
302
+ method: 'GET',
303
+ url: `/open-apis/im/v1/messages/mget?${query}&card_msg_content_type=raw_card_content`,
304
+ })
305
+ }
306
+
307
+ async messageList(params: RawMessageListParams): Promise<unknown> {
308
+ const message = this.channel.rawClient.im.v1.message as unknown as {
309
+ list(input: { params: RawMessageListParams }): Promise<unknown>
310
+ }
311
+ return await message.list({ params })
312
+ }
313
+
259
314
  async downloadResource(
260
315
  messageId: string,
261
316
  fileKey: string,
package/src/host/agent.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * @module dsh-channels/host/agent
6
6
  */
7
7
 
8
+ import type { AttachmentStore } from './attachments.ts'
8
9
  import type { CommandRuntime } from './commands.ts'
9
10
  import type { Session, SessionId, UserMessage } from './core.ts'
10
11
 
@@ -58,10 +59,64 @@ export interface SystemPromptRuntime {
58
59
  section(section: PromptSection): () => void
59
60
  }
60
61
 
62
+ /** One model-facing content block a channel tool may project (text/image/file). */
63
+ export interface ToolContentBlock {
64
+ readonly type: string
65
+ readonly [key: string]: unknown
66
+ }
67
+
68
+ /** One tool execution's cooperative cancellation signal. */
69
+ export interface ToolExecution {
70
+ readonly signal: AbortSignal
71
+ }
72
+
73
+ /** Canonical output contract of one channel tool registration. */
74
+ export interface ToolOutputContract {
75
+ readonly schema: Record<string, unknown>
76
+ render(args: Record<string, unknown>, value: unknown): readonly ToolContentBlock[]
77
+ presentationMeta?(args: Record<string, unknown>, value: unknown): unknown
78
+ }
79
+
80
+ /**
81
+ * One model-facing tool registration. Kept structural (no runtime import of
82
+ * the host's `@deepseek-ai/dsh-tools` package) so the plugin keeps its
83
+ * zero-host-dependency stance.
84
+ */
85
+ export interface ToolDefinition {
86
+ readonly name: string
87
+ readonly description: string
88
+ readonly parameters: Record<string, unknown>
89
+ readonly output: ToolOutputContract
90
+ readonly timeoutMs?: number
91
+ execute(
92
+ args: Record<string, unknown>,
93
+ exec: ToolExecution,
94
+ ): Promise<unknown> | unknown
95
+ presentCall?(args: Record<string, unknown>): unknown
96
+ presentResult?(args: Record<string, unknown>, value: unknown): unknown
97
+ readonly isConcurrencySafe?: () => boolean
98
+ }
99
+
100
+ /** The Agent-scoped slice of the host `tools` registry. */
101
+ export interface ToolRuntime {
102
+ register(definition: ToolDefinition): unknown
103
+ }
104
+
61
105
  export interface AgentContext {
62
106
  commands: CommandRuntime
63
107
  /** Present inside a setup scope that injected `systemPrompt`. */
64
108
  systemPrompt?: SystemPromptRuntime
109
+ /** Present inside a setup scope that injected `tools`. */
110
+ tools?: ToolRuntime
111
+ /**
112
+ * Present inside a setup scope that injected `attachments` (the DSH native
113
+ * attachment store). Optional: headless or legacy hosts, and test doubles,
114
+ * may not mount it, so consumers resolve it through the degradation-aware
115
+ * helper in `src/feishu/native-attachments.ts` instead of assuming presence.
116
+ */
117
+ attachments?: AttachmentStore
118
+ /** Safe service lookup (returns undefined for absent services). */
119
+ get?(name: string): unknown
65
120
  /** Register an Agent-scoped model-call override. */
66
121
  on(
67
122
  event: 'agent/request',
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Host bridge, attachment surface: the minimal `ctx.attachments` face this
3
+ * plugin consumes (durable image/file storage) plus the content-block and
4
+ * prompt-part shapes it produces.
5
+ *
6
+ * Everything here is declared locally and structurally. The DSH attachment
7
+ * packages are never imported at runtime and are not runtime dependencies of
8
+ * this plugin, so these types mirror their published shapes by convention:
9
+ * only the members this plugin actually reads or calls are modeled, and every
10
+ * service lookup tolerates a deployment without the service. The exact
11
+ * upstream package (`@deepseek-ai/dsh-attachment`, DSH 0.2.1-rc.2) has more
12
+ * operations (batches, request projection, encoded uploads); add them here
13
+ * only when a task needs them.
14
+ *
15
+ * @module dsh-channels/host/attachments
16
+ */
17
+
18
+ // ---- Durable attachment vocabulary --------------------------------------
19
+
20
+ /** Opaque content-addressed attachment identifier (never a filesystem path). */
21
+ export type AttachmentId = string
22
+
23
+ /** Raster image formats accepted by the version-one attachment path. */
24
+ export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
25
+
26
+ /** Durable, serializable reference to one immutable normalized image. */
27
+ export interface ImageAttachmentRef {
28
+ /** Opaque storage identifier; never a filesystem path or bearer URL. */
29
+ attachmentId: AttachmentId
30
+ /** Media type verified from the stored bytes. */
31
+ mediaType: ImageMediaType
32
+ /** Exact encoded byte length. */
33
+ bytes: number
34
+ /** Intrinsic encoded width in pixels. */
35
+ width: number
36
+ /** Intrinsic encoded height in pixels. */
37
+ height: number
38
+ /** Optional display name stripped of local path information. */
39
+ name?: string
40
+ /**
41
+ * Input dimensions after applying EXIF orientation and before normalization
42
+ * scaling. Present only when normalization reduced the image.
43
+ */
44
+ originalDimensions?: {
45
+ width: number
46
+ height: number
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Durable, serializable reference to one verbatim stored file. Files are
52
+ * stored byte-for-byte with no normalization; `attachmentId` is the sha256
53
+ * digest of exactly those bytes.
54
+ */
55
+ export interface FileAttachmentRef {
56
+ /** Opaque content-addressed storage identifier; never a filesystem path. */
57
+ attachmentId: AttachmentId
58
+ /** Sanitized display filename, also the stored object's leaf name. */
59
+ name: string
60
+ /** Exact byte length. */
61
+ bytes: number
62
+ }
63
+
64
+ /** Deployment-resolved image limits used by upload admission. */
65
+ export interface ImageAttachmentLimits {
66
+ maxImageBytes: number
67
+ maxImagesPerMessage: number
68
+ maxMessageImageBytes: number
69
+ maxImagePixels: number
70
+ /** Maximum intrinsic width and maximum intrinsic height in pixels for one image. */
71
+ maxImageDimension: number
72
+ mediaTypes: readonly ImageMediaType[]
73
+ }
74
+
75
+ /** Request to validate and durably commit one image. */
76
+ export interface SaveImageAttachment {
77
+ /** Encoded bytes, checked against the declared media type by the service. */
78
+ data: Uint8Array
79
+ /** Caller-declared media type, checked against fully decoded bytes. */
80
+ mediaType: ImageMediaType
81
+ /** Optional display name; never interpreted as a path. */
82
+ name?: string
83
+ }
84
+
85
+ /** Request to durably commit one file from bounded byte chunks. */
86
+ export interface SaveFileStreamAttachment {
87
+ /** Exact file bytes in order; providers must not retain the whole sequence. */
88
+ data: AsyncIterable<Uint8Array>
89
+ /** Optional cancellation for source reads and storage writes. */
90
+ signal?: AbortSignal
91
+ /** Optional display name; never interpreted as a path. */
92
+ name?: string
93
+ }
94
+
95
+ /** One text/image part of host prompt content whose images still need admission. */
96
+ export type PromptContentPart = {
97
+ readonly type: 'text'
98
+ readonly text: string
99
+ } | {
100
+ readonly type: 'image'
101
+ readonly mediaType: ImageMediaType
102
+ readonly data: string
103
+ readonly name?: string
104
+ }
105
+
106
+ /** Host prompt content whose file receipts are resolved and whose image bytes await admission. */
107
+ export type AttachmentAdmissionPart = PromptContentPart | {
108
+ readonly type: 'file'
109
+ readonly attachment: FileAttachmentRef
110
+ }
111
+
112
+ /** Host-admitted prompt content with every attachment represented by its durable reference. */
113
+ export type AdmittedPromptContentPart = {
114
+ readonly type: 'text'
115
+ readonly text: string
116
+ } | {
117
+ readonly type: 'image'
118
+ readonly attachment: ImageAttachmentRef
119
+ } | {
120
+ readonly type: 'file'
121
+ readonly attachment: FileAttachmentRef
122
+ }
123
+
124
+ // ---- Content blocks -----------------------------------------------------
125
+
126
+ /**
127
+ * A durable raster image reference as it appears in model content
128
+ * (`ImageBlock` in `@deepseek-ai/dsh-llm`). The block is role-neutral; this
129
+ * plugin only produces it for user content.
130
+ */
131
+ export interface ImageBlock {
132
+ type: 'image'
133
+ /** Immutable bytes and intrinsic display metadata owned by the service. */
134
+ attachment: ImageAttachmentRef
135
+ }
136
+
137
+ /**
138
+ * A durable verbatim file reference as it appears in model content
139
+ * (`FileBlock` in `@deepseek-ai/dsh-llm`). Files never reach a provider as
140
+ * bytes: request assembly projects them to handle text, so a channel tool that
141
+ * cannot deliver this block must fall back to {@link formatFileHandle}-style
142
+ * text instead.
143
+ */
144
+ export interface FileBlock {
145
+ type: 'file'
146
+ /** Immutable verbatim bytes and display metadata owned by the service. */
147
+ attachment: FileAttachmentRef
148
+ }
149
+
150
+ // ---- Failure vocabulary -------------------------------------------------
151
+
152
+ /**
153
+ * Stable attachment failure codes the host routes on
154
+ * (`AttachmentErrorCode` in `@deepseek-ai/dsh-attachment`). Kept as a
155
+ * type-only union; the runtime classifier lives in the Feishu storage module.
156
+ */
157
+ export type AttachmentErrorCode =
158
+ | 'TOO_MANY_IMAGES'
159
+ | 'IMAGES_TOO_LARGE'
160
+ | 'UNSUPPORTED_IMAGE_TYPE'
161
+ | 'INVALID_IMAGE_BASE64'
162
+ | 'INVALID_IMAGE'
163
+ | 'IMAGE_TYPE_MISMATCH'
164
+ | 'IMAGE_TOO_LARGE'
165
+ | 'IMAGE_TOO_MANY_PIXELS'
166
+ | 'IMAGE_DIMENSION_TOO_LARGE'
167
+ | 'INVALID_FILE_BASE64'
168
+ | 'INVALID_ATTACHMENT_REF'
169
+ | 'ATTACHMENT_CORRUPT'
170
+ | 'ATTACHMENT_WRITE_FAILED'
171
+ | 'ATTACHMENT_NOT_FOUND'
172
+ | 'ATTACHMENT_READ_FAILED'
173
+ | 'ATTACHMENT_PROJECTION_UNSUPPORTED'
174
+ | 'ATTACHMENT_FILES_UNSUPPORTED'
175
+
176
+ // ---- Service surface ----------------------------------------------------
177
+
178
+ /**
179
+ * The narrow `ctx.attachments` service face. Every method mirrors the
180
+ * upstream contract; callers must go through a module that tolerates absence
181
+ * (for example `src/feishu/native-attachments.ts`) rather than branching on
182
+ * this type themselves, so the degradation path exists in exactly one place.
183
+ */
184
+ export interface AttachmentStore {
185
+ /** Deployment-resolved image policy; absent on minimal embedding hosts. */
186
+ readonly imageLimits?: ImageAttachmentLimits
187
+ /** Validate, normalize, and durably commit one image. */
188
+ saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
189
+ /** Durably commit one verbatim file from bounded chunks (backpressure, no full buffering). */
190
+ saveFileStream(input: SaveFileStreamAttachment): Promise<FileAttachmentRef>
191
+ /**
192
+ * Locate the verbatim stored file object in the harness host filesystem.
193
+ * @returns an absolute host path, or undefined when this backend is not
194
+ * host-file-backed.
195
+ * @throws an attachment error when the durable reference is invalid.
196
+ */
197
+ fileHostPath(ref: FileAttachmentRef): string | undefined
198
+ /**
199
+ * Locate the provider-owned normalized image object in the harness host
200
+ * filesystem.
201
+ * @returns an absolute host path, or undefined when this backend is not
202
+ * host-file-backed.
203
+ * @throws an attachment error when the durable reference is invalid.
204
+ */
205
+ imageHostPath(ref: ImageAttachmentRef): string | undefined
206
+ /** Replace every uploaded image in one host prompt with its durable reference. */
207
+ admitPromptContent(content: readonly AttachmentAdmissionPart[]): Promise<AdmittedPromptContentPart[]>
208
+ /** Optional host-provided classifier for attachment failures (cross-install safe). */
209
+ isAttachmentError?(error: unknown): boolean
210
+ }
package/src/host/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  export * from './core.ts'
9
9
  export * from './agent.ts'
10
+ export * from './attachments.ts'
10
11
  export * from './commands.ts'
11
12
  export * from './interaction.ts'
12
13
  export * from './services.ts'
package/src/index.ts CHANGED
@@ -3,12 +3,14 @@
3
3
  *
4
4
  * 一个 dsh 实例对应一个飞书机器人;用户通过私聊、群聊 @、话题群 @ 三种方式
5
5
  * 发起会话。每条触发消息:立即加 “Typing” 表情 → 以话题形式回复(创建话题)
6
- * → 绑定 DSH session → 卡片 2.0 流式回复(推理折叠面板 + 富文本正文 + 两行
7
- * session 信息,默认 500ms 刷新)→ 流式结束移除 Typing,成功更新为
6
+ * → 绑定 DSH session → 卡片 2.0 流式回复(推理折叠面板 + 临时活动行 +
7
+ * 富文本正文 + 结算页脚,默认 500ms 刷新)→ 流式结束移除 Typing,成功更新为
8
8
  * “DONE”,失败显示结构化错误并更新为 “No”。
9
9
  *
10
10
  * 工作区归属:私聊一人一工作区;群聊中每个用户在该群拥有独立工作区
11
- * (目录即工作区,会话以 cwd 落盘)。
11
+ * (工作区是会话 header cwd,保证 GUI 归属与沙箱边界)。每个话题在该工作区
12
+ * 内拥有独立的工作目录(`topics/<话题>`,Git 仓库工作区可自动改用 worktree),
13
+ * 因此话题之间的文件互不干扰,而工作区数量只随用户增长。
12
14
  *
13
15
  * @module dsh-channels
14
16
  */
@@ -51,7 +53,7 @@ export type { HostDirectoryPickerService, SettingsDirectoryPicker } from './sett
51
53
  export { isSameOriginRequest, mountSettingsRoutes, registerSettingsRoutes } from './settings/routes.ts'
52
54
  export type { HostWebServer } from './settings/routes.ts'
53
55
  export type { ApprovalOutcome, ApprovalRequest, ApprovalAnswererListener, AssistantStreamFrame, AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionRequestEvent, UserQuestionAnswererListener, AgentCancelCause, SessionStatsView, TokenUsageView, ContextPressureView, SessionProjections } from './dsh-types.ts'
54
- export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES, CARD_PANEL_RESERVE_BYTES, REASONING_MAX_BYTES, INTERRUPTION_NOTICE, MAX_TABLES_PER_CARD, PANEL_TITLES, PANEL_CHEVRON_TOKEN, segmentCardContent, splitBodyForCard, stripTables, tableBlocks, TRUNCATION_SUFFIX, buildApprovalCard, buildApprovalSettledCard, buildQuestionCard, buildQuestionSettledCard, MAX_QUESTION_OPTIONS } from './cards.ts'
56
+ export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES, REASONING_MAX_BYTES, INTERRUPTION_NOTICE, MAX_TABLES_PER_CARD, PANEL_TITLES, PANEL_CHEVRON_TOKEN, segmentCardContent, splitBodyForCard, stripTables, tableBlocks, TRUNCATION_SUFFIX, buildApprovalCard, buildApprovalSettledCard, buildQuestionCard, buildQuestionSettledCard, MAX_QUESTION_OPTIONS } from './cards.ts'
55
57
  export { ApprovalAnswerer } from './approvals.ts'
56
58
  export { QuestionAnswerer } from './questions.ts'
57
59
  export { armModelCommand, buildCommandResultCard, CHANNEL_COMMAND_NAMES, commandResultCardKind, isSupportedCommand, parseSlashCommand, registerChannelCommands, registerModelCommand, RouteOperationQueue, SUPPORTED_COMMAND_NAMES, supportedCommandHelp, supportedCommandList } from './commands.ts'
@@ -61,8 +63,8 @@ export { createLogicalSessionExport, DSH_CHANNELS_VERSION, LOGICAL_EXPORT_FORMAT
61
63
  export { addUsage, applyProjectionViews, averageDecodeTokensPerSecond, averageTtftMs, cacheHitPct, EMPTY_TOTALS, foldAll, foldEvent, foldSessionStats, measureSessionContext, readProjectionViews, usageFromBuckets } from './stats.ts'
62
64
  export { FeishuService } from './service.ts'
63
65
  export { TurnStreamer } from './streamer.ts'
64
- export { ensureWorkspace, stagingPathFor, topicTokenFor, workspacePathFor, workspaceTitleFor } from './workspaces.ts'
65
- export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
66
+ export { ensureWorkspace, legacyTopicWorkspaceRoot, stagingPathFor, stagingRoot, topicDirFor, topicTokenFor, workspacePathFor, workspaceTitleFor, worktreeBranchFor } from './workspaces.ts'
67
+ export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionDirOf, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
66
68
 
67
69
  /** Cordis function-plugin name. */
68
70
  export const name = 'channels'