@modesto_ma/dsh-channels 0.0.0-stage → 0.1.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 +302 -2
- package/cordis.patch.yml +3 -0
- package/docs/access-control-design.md +550 -0
- package/docs/assets/web-settings-panel.png +0 -0
- package/docs/assets/web-settings-restart-pending.png +0 -0
- package/docs/feasibility-and-design.md +213 -0
- package/docs/feishu-onboarding.md +268 -0
- package/docs/persistence.md +199 -0
- package/docs/refactor-plan.md +56 -0
- package/docs/review-dsh-0.2.0.md +389 -0
- package/docs/slash-commands-implementation-plan.md +410 -0
- package/docs/web-display-only-surface-roadmap.md +363 -0
- package/lib/client.js +2226 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +10217 -0
- package/lib/index.js.map +1 -0
- package/package.json +98 -4
- package/src/access/cards.ts +188 -0
- package/src/access/controller.ts +201 -0
- package/src/access/index.ts +13 -0
- package/src/access/policy.ts +103 -0
- package/src/access/requests.ts +512 -0
- package/src/access/store.ts +220 -0
- package/src/approvals.ts +8 -0
- package/src/cards/approval.ts +114 -0
- package/src/cards/index.ts +11 -0
- package/src/cards/primitives.ts +82 -0
- package/src/cards/question.ts +141 -0
- package/src/cards/streaming.ts +563 -0
- package/src/cards.ts +8 -0
- package/src/cleanup.ts +398 -0
- package/src/client/api.ts +262 -0
- package/src/client/catalog.ts +92 -0
- package/src/client/index.ts +61 -0
- package/src/client/panel.tsx +1475 -0
- package/src/client/styles.ts +98 -0
- package/src/commands/registry.ts +155 -0
- package/src/commands.ts +335 -0
- package/src/config.ts +206 -0
- package/src/domain.ts +428 -0
- package/src/dsh-types.ts +11 -0
- package/src/feishu/agent-context.ts +62 -0
- package/src/feishu/delivery.ts +188 -0
- package/src/feishu/inbound-attachments.ts +436 -0
- package/src/feishu-channel.ts +339 -0
- package/src/health.ts +233 -0
- package/src/host/agent.ts +181 -0
- package/src/host/commands.ts +63 -0
- package/src/host/core.ts +345 -0
- package/src/host/index.ts +12 -0
- package/src/host/interaction.ts +101 -0
- package/src/host/services.ts +320 -0
- package/src/index.ts +122 -0
- package/src/interactions/approvals.ts +154 -0
- package/src/interactions/pending.ts +162 -0
- package/src/interactions/questions.ts +274 -0
- package/src/questions.ts +8 -0
- package/src/routing/inbound.ts +524 -0
- package/src/service.ts +2138 -0
- package/src/session/manager.ts +352 -0
- package/src/session/repair.ts +157 -0
- package/src/session/request-policy.ts +192 -0
- package/src/session/routes.ts +165 -0
- package/src/session-commands.ts +310 -0
- package/src/session-export.ts +112 -0
- package/src/settings/catalog.ts +309 -0
- package/src/settings/contract.ts +508 -0
- package/src/settings/overlay.ts +164 -0
- package/src/settings/routes.ts +409 -0
- package/src/settings/surface.ts +773 -0
- package/src/stats.ts +485 -0
- package/src/streamer.ts +1108 -0
- package/src/workspaces.ts +199 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host bridge, service surface: workspaces, storage domains, credentials,
|
|
3
|
+
* token meter, LLM catalog, session projections, Cordis diagnostics, and the
|
|
4
|
+
* `HostContext` slice this plugin consumes.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-channels/host/services
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AgentRegistry } from './agent.ts'
|
|
10
|
+
import type { CommandRuntime } from './commands.ts'
|
|
11
|
+
import type { AssistantStreamPayload, Session, SessionEvent, SessionId } from './core.ts'
|
|
12
|
+
import type { ApprovalAnswererListener, UserQuestionAnswererListener } from './interaction.ts'
|
|
13
|
+
|
|
14
|
+
// ---- Workspace surface --------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export interface Workspace {
|
|
17
|
+
readonly id: string
|
|
18
|
+
readonly path: string
|
|
19
|
+
readonly title: string
|
|
20
|
+
attachSession(id: SessionId): Promise<void> | void
|
|
21
|
+
detachSession(id: SessionId): Promise<void> | void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WorkspaceRegistry {
|
|
25
|
+
create(path: string, title?: string): Promise<Workspace>
|
|
26
|
+
/** Every workspace currently registered with the host. */
|
|
27
|
+
list(): Workspace[]
|
|
28
|
+
/** Unregister one workspace record (the directory itself is the caller's concern). */
|
|
29
|
+
delete(id: string): Promise<boolean>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---- Storage / token-meter / llm / credentials surface ------------------
|
|
33
|
+
|
|
34
|
+
/** The host's storage-domain facility (narrowly typed at the call site). */
|
|
35
|
+
export interface StorageDomainFacility {
|
|
36
|
+
open(spec: unknown): Promise<unknown>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One resolved credential value and the source layer that supplied it. */
|
|
40
|
+
export interface ResolvedCredential {
|
|
41
|
+
value: string
|
|
42
|
+
source: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Presence/writability facts for one credential reference (never the value). */
|
|
46
|
+
export interface CredentialInfo {
|
|
47
|
+
configured: boolean
|
|
48
|
+
writable?: boolean
|
|
49
|
+
source?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The host credentials seam (`@deepseek-ai/dsh-credentials`): resolves a
|
|
54
|
+
* reference per call with the provider's layer precedence
|
|
55
|
+
* (env > $DSH_HOME/.credentials.yaml > .env fallbacks), and can store a value
|
|
56
|
+
* for a reference through the same seam (the Web panel's credential editor).
|
|
57
|
+
* Every member beyond the read path is optional so embedded/test hosts can
|
|
58
|
+
* supply only what they support.
|
|
59
|
+
*/
|
|
60
|
+
export interface CredentialProvider {
|
|
61
|
+
resolve(ref: string): Promise<ResolvedCredential | undefined>
|
|
62
|
+
/** Presence check without revealing the value (the host's `credentials.describe`). */
|
|
63
|
+
describe?(ref: string): Promise<CredentialInfo | undefined>
|
|
64
|
+
/** Store a value for the reference; stored values outrank environment fallbacks. */
|
|
65
|
+
set?(ref: string, value: string): Promise<void>
|
|
66
|
+
/** Remove the stored value (environment fallbacks then apply again). */
|
|
67
|
+
unset?(ref: string): Promise<void>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TokenMeasurement {
|
|
71
|
+
readonly totalTokens: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TokenMeter {
|
|
75
|
+
measure(session: Session, requestHeader?: unknown): TokenMeasurement
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One registered provider route, as advertised by the LLM service. */
|
|
79
|
+
export interface LlmProviderInfo {
|
|
80
|
+
id: string
|
|
81
|
+
name: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One adapter-owned reasoning effort advertised for an exact model route. */
|
|
85
|
+
export interface LlmReasoningEffortInfo {
|
|
86
|
+
/** Opaque stable id accepted by model requests. */
|
|
87
|
+
id: string
|
|
88
|
+
/** Human-readable effort name for selectors. */
|
|
89
|
+
name: string
|
|
90
|
+
description?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Selectable reasoning efforts for one exact provider/model route. */
|
|
94
|
+
export interface LlmModelReasoningInfo {
|
|
95
|
+
/** Supported efforts from least to greatest. */
|
|
96
|
+
efforts: readonly LlmReasoningEffortInfo[]
|
|
97
|
+
/** Adapter-configured default effort; absent means the provider default. */
|
|
98
|
+
defaultEffort?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface LlmModelInfo {
|
|
102
|
+
/** Provider route that owns this entry. */
|
|
103
|
+
provider?: string
|
|
104
|
+
/** Model id passed to request generation. */
|
|
105
|
+
id?: string
|
|
106
|
+
/** Human-readable model name for selectors. */
|
|
107
|
+
name?: string
|
|
108
|
+
description?: string
|
|
109
|
+
context?: { contextWindow: number }
|
|
110
|
+
/** Adapter-owned reasoning levels, when `resolveModelInfo` reports them. */
|
|
111
|
+
reasoning?: LlmModelReasoningInfo
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface LlmService {
|
|
115
|
+
resolveModelInfo(provider: string, model: string): Promise<LlmModelInfo>
|
|
116
|
+
/** Registered provider routes, when the deployment exposes them. */
|
|
117
|
+
listProviders?(): readonly LlmProviderInfo[]
|
|
118
|
+
/** Models advertised for one provider route. */
|
|
119
|
+
listModels?(provider: string): Promise<readonly LlmModelInfo[]>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Deployment default model selection (`@deepseek-ai/dsh-agent-default-model`).
|
|
124
|
+
* Web UI and headless entry points read it before creating an Agent; the
|
|
125
|
+
* settings panel only reads it to label the "inherit" choice.
|
|
126
|
+
*/
|
|
127
|
+
export interface AgentDefaultModel {
|
|
128
|
+
currentSelection(): { provider: string; model: string; reasoningEffort?: string }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- Session projections -------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whole-log figures maintained by the host's `sessionStats` projection
|
|
135
|
+
* (`@deepseek-ai/dsh-session-stats`, mounted by the web-app bundle). Reading
|
|
136
|
+
* these replaces re-folding the event log for wall times and TTFT.
|
|
137
|
+
*/
|
|
138
|
+
export interface SessionStatsView {
|
|
139
|
+
turns: number
|
|
140
|
+
steps: number
|
|
141
|
+
llmMs: number
|
|
142
|
+
toolMs: number
|
|
143
|
+
ttftMs: number
|
|
144
|
+
ttftSteps: number
|
|
145
|
+
decodeMs: number
|
|
146
|
+
decodeTokens: number
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Provider-reported token buckets maintained by the `tokenUsage` projection. */
|
|
150
|
+
export interface TokenUsageBuckets {
|
|
151
|
+
uncachedInputTokens: number
|
|
152
|
+
outputTokens: number
|
|
153
|
+
cacheReadTokens: number
|
|
154
|
+
cacheWriteTokens: number
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface TokenUsageView {
|
|
158
|
+
totals: TokenUsageBuckets
|
|
159
|
+
last: { turn: number; step: number; buckets: TokenUsageBuckets } | null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Route capacity and prompt pressure maintained by the `contextPressure` projection. */
|
|
163
|
+
export interface ContextPressureView {
|
|
164
|
+
pressureTokens?: number
|
|
165
|
+
projectedTokens?: number
|
|
166
|
+
contextWindow?: number
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The host projection registry (`ctx.sessionProjections`). Read through
|
|
171
|
+
* `ctx.get` so deployments without the registry keep working: `stateOf`
|
|
172
|
+
* returns `undefined` for an unregistered key.
|
|
173
|
+
*/
|
|
174
|
+
export interface SessionProjections {
|
|
175
|
+
stateOf(session: Session, key: string): unknown
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
// ---- Session query -------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/** Latest log-backed title of one session. */
|
|
182
|
+
export interface SessionTitleSnapshot {
|
|
183
|
+
title: string
|
|
184
|
+
updatedAt: number
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface SessionTitleObservation {
|
|
188
|
+
/** Absent when the observed log has no title event. */
|
|
189
|
+
title?: SessionTitleSnapshot
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** One ordered result from a batch title observation. */
|
|
193
|
+
export type SessionTitleObservationResult =
|
|
194
|
+
| { sessionId: SessionId; status: 'fulfilled'; value: SessionTitleObservation }
|
|
195
|
+
| { sessionId: SessionId; status: 'rejected'; reason: unknown }
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The narrow read side of `ctx.sessionQuery` this plugin consumes: live-first
|
|
199
|
+
* title folding for the sessions a Feishu topic has bound.
|
|
200
|
+
*/
|
|
201
|
+
export interface SessionQuery {
|
|
202
|
+
readTitleSnapshots(sessionIds: readonly SessionId[]): Promise<readonly SessionTitleObservationResult[]>
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---- Permission presets --------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/** One selectable sandbox+approval preset offered to clients. */
|
|
208
|
+
export interface PermissionPresetOption {
|
|
209
|
+
value: string
|
|
210
|
+
name: string
|
|
211
|
+
description?: string
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The process-level preset catalog exposed to current-session controls. */
|
|
215
|
+
export interface PermissionCatalog {
|
|
216
|
+
options: PermissionPresetOption[]
|
|
217
|
+
defaultOptions: PermissionPresetOption[]
|
|
218
|
+
defaultPreset: string
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The host permission-preset service (`ctx.permissionPresets`): reads the
|
|
223
|
+
* current Session's effective preset and records a switched one.
|
|
224
|
+
*/
|
|
225
|
+
export interface PermissionPresets {
|
|
226
|
+
catalog(): PermissionCatalog
|
|
227
|
+
current(session: Session): string
|
|
228
|
+
optionOf(name: string): PermissionPresetOption
|
|
229
|
+
set(session: Session, name: string): void
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---- Cordis diagnostic surface -----------------------------------------
|
|
233
|
+
|
|
234
|
+
/** Numeric values mirror Cordis 4's public `FiberState` enum. */
|
|
235
|
+
export const CordisFiberState = {
|
|
236
|
+
PENDING: 0,
|
|
237
|
+
LOADING: 1,
|
|
238
|
+
ACTIVE: 2,
|
|
239
|
+
FAILED: 3,
|
|
240
|
+
DISPOSED: 4,
|
|
241
|
+
UNLOADING: 5,
|
|
242
|
+
} as const
|
|
243
|
+
|
|
244
|
+
export type CordisFiberState = typeof CordisFiberState[keyof typeof CordisFiberState]
|
|
245
|
+
|
|
246
|
+
export interface CordisFiber {
|
|
247
|
+
readonly state: CordisFiberState
|
|
248
|
+
/** Best-effort plugin display name (`fiber.name`), used only for health diagnostics. */
|
|
249
|
+
readonly name?: string
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface CordisPluginRuntime {
|
|
253
|
+
readonly fibers: Iterable<CordisFiber>
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface CordisRegistry {
|
|
257
|
+
readonly size: number
|
|
258
|
+
values(): IterableIterator<CordisPluginRuntime>
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- Host context surface ----------------------------------------------
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The host (Cordis) context slice the plugin consumes. Declared locally and
|
|
265
|
+
* structurally: the host injects every capability through `ctx`, and the
|
|
266
|
+
* plugin never imports cordis at runtime, so there is no dependency on the
|
|
267
|
+
* npm-published cordis typings (whose `.ts`-specifier augmentations do not
|
|
268
|
+
* resolve under bundler module resolution).
|
|
269
|
+
*/
|
|
270
|
+
export interface HostLogger {
|
|
271
|
+
debug?(...args: unknown[]): void
|
|
272
|
+
info?(...args: unknown[]): void
|
|
273
|
+
warn?(...args: unknown[]): void
|
|
274
|
+
error?(...args: unknown[]): void
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface HostContext {
|
|
278
|
+
agents: AgentRegistry
|
|
279
|
+
commands: CommandRuntime
|
|
280
|
+
workspaceRegistry: WorkspaceRegistry
|
|
281
|
+
storageDomain: StorageDomainFacility
|
|
282
|
+
/** Resolved through the injected `credentials` seam (env > .credentials.yaml > .env). */
|
|
283
|
+
credentials: CredentialProvider
|
|
284
|
+
tokenMeter: TokenMeter
|
|
285
|
+
llm: LlmService
|
|
286
|
+
/** Built-in Cordis plugin registry used for aggregate health diagnostics. */
|
|
287
|
+
registry: CordisRegistry
|
|
288
|
+
logger?: HostLogger
|
|
289
|
+
/** Safe service lookup (returns undefined for absent services). */
|
|
290
|
+
get?(name: string): unknown
|
|
291
|
+
/** Host events this plugin subscribes to (overloads per event). */
|
|
292
|
+
on(name: 'session/event', listener: (session: Session, event: SessionEvent) => void): () => boolean
|
|
293
|
+
/** Live (transient) model stream for every agent; replaces the removed `assistant/chunk` events. */
|
|
294
|
+
on(name: 'agent/assistant-stream', listener: (payload: AssistantStreamPayload) => void): () => boolean
|
|
295
|
+
/**
|
|
296
|
+
* The host's structured-question waterfall (`ask_user_question`). Same
|
|
297
|
+
* shape as `approval/request`: return an answer to claim, or delegate with
|
|
298
|
+
* `next()`. Untagged plugin contexts receive requests for every agent, so
|
|
299
|
+
* ownership filtering is mandatory.
|
|
300
|
+
*/
|
|
301
|
+
on(
|
|
302
|
+
name: 'user-questions/request',
|
|
303
|
+
listener: UserQuestionAnswererListener,
|
|
304
|
+
options?: { prepend?: boolean },
|
|
305
|
+
): () => boolean
|
|
306
|
+
/**
|
|
307
|
+
* The host's HITL answerer waterfall. Registered with `prepend: true` so
|
|
308
|
+
* this channel claims its own sessions before generic answerers (the web
|
|
309
|
+
* gateway); non-owned requests delegate through `next()`. Untagged plugin
|
|
310
|
+
* contexts receive requests for EVERY agent, so ownership filtering is
|
|
311
|
+
* mandatory. Safe to register without the approval service mounted — the
|
|
312
|
+
* event simply never fires.
|
|
313
|
+
*/
|
|
314
|
+
on(
|
|
315
|
+
name: 'approval/request',
|
|
316
|
+
listener: ApprovalAnswererListener,
|
|
317
|
+
options?: { prepend?: boolean },
|
|
318
|
+
): () => boolean
|
|
319
|
+
effect(callback: () => (() => void | Promise<void>) | void, label?: string): void
|
|
320
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-channels — DeepSeek Harness 飞书渠道插件。
|
|
3
|
+
*
|
|
4
|
+
* 一个 dsh 实例对应一个飞书机器人;用户通过私聊、群聊 @、话题群 @ 三种方式
|
|
5
|
+
* 发起会话。每条触发消息:立即加 “Typing” 表情 → 以话题形式回复(创建话题)
|
|
6
|
+
* → 绑定 DSH session → 卡片 2.0 流式回复(推理折叠面板 + 富文本正文 + 两行
|
|
7
|
+
* session 信息,默认 500ms 刷新)→ 流式结束移除 Typing,成功更新为
|
|
8
|
+
* “DONE”,失败显示结构化错误并更新为 “No”。
|
|
9
|
+
*
|
|
10
|
+
* 工作区归属:私聊一人一工作区;群聊中每个用户在该群拥有独立工作区
|
|
11
|
+
* (目录即工作区,会话以 cwd 落盘)。
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-channels
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { HostContext } from './dsh-types.ts'
|
|
17
|
+
import { FeishuService } from './service.ts'
|
|
18
|
+
import { configSchema, resolveConfig } from './config.ts'
|
|
19
|
+
import type { FeishuConfig } from './config.ts'
|
|
20
|
+
import { defaultChannelFactory } from './feishu-channel.ts'
|
|
21
|
+
import type { ChannelFactory } from './feishu-channel.ts'
|
|
22
|
+
import { OverlayStore, defaultOverlayPath } from './settings/overlay.ts'
|
|
23
|
+
import { mountSettingsRoutes } from './settings/routes.ts'
|
|
24
|
+
import { SettingsSurface } from './settings/surface.ts'
|
|
25
|
+
import { createHostCatalog } from './settings/catalog.ts'
|
|
26
|
+
|
|
27
|
+
export type * from './config.ts'
|
|
28
|
+
export type * from './cards.ts'
|
|
29
|
+
export type * from './domain.ts'
|
|
30
|
+
export type * from './feishu-channel.ts'
|
|
31
|
+
export type * from './approvals.ts'
|
|
32
|
+
export type * from './commands.ts'
|
|
33
|
+
export type * from './health.ts'
|
|
34
|
+
export type * from './session-commands.ts'
|
|
35
|
+
export type * from './session-export.ts'
|
|
36
|
+
export type * from './stats.ts'
|
|
37
|
+
export type * from './questions.ts'
|
|
38
|
+
export type * from './settings/contract.ts'
|
|
39
|
+
export * from './access/index.ts'
|
|
40
|
+
export type { AccessGroupRecord, AccessRequestRecord, AccessUserRecord } from './domain.ts'
|
|
41
|
+
export { CATALOG_ROUTE, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, STATUS_ROUTE } from './settings/contract.ts'
|
|
42
|
+
export { defaultOverlayPath, mergeOverlay, normalizeOverlay, OverlayStore, parseOverlay, pickEditable, resolveDshHome } from './settings/overlay.ts'
|
|
43
|
+
export { applyCatalog, emptyCatalog, FIELD_DESCRIPTORS, normalizeCredentialValue, readOnlyFields, SettingsSurface } from './settings/surface.ts'
|
|
44
|
+
export type { SettingsTarget } from './settings/surface.ts'
|
|
45
|
+
export { createHostCatalog } from './settings/catalog.ts'
|
|
46
|
+
export type { CatalogWarn, SettingsCatalog } from './settings/catalog.ts'
|
|
47
|
+
export { isSameOriginRequest, mountSettingsRoutes, registerSettingsRoutes } from './settings/routes.ts'
|
|
48
|
+
export type { HostWebServer } from './settings/routes.ts'
|
|
49
|
+
export type { ApprovalOutcome, ApprovalRequest, ApprovalAnswererListener, AssistantStreamFrame, AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionRequestEvent, UserQuestionAnswererListener, AgentCancelCause, SessionStatsView, TokenUsageView, ContextPressureView, SessionProjections } from './dsh-types.ts'
|
|
50
|
+
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'
|
|
51
|
+
export { ApprovalAnswerer } from './approvals.ts'
|
|
52
|
+
export { QuestionAnswerer } from './questions.ts'
|
|
53
|
+
export { armModelCommand, buildCommandResultCard, CHANNEL_COMMAND_NAMES, commandResultCardKind, isSupportedCommand, parseSlashCommand, registerChannelCommands, registerModelCommand, RouteOperationQueue, SUPPORTED_COMMAND_NAMES, supportedCommandHelp, supportedCommandList } from './commands.ts'
|
|
54
|
+
export { buildHealthCard, countFiberStates, foldHealthSnapshot, HEALTH_DEPENDENCY_NAMES, maskOpenIdTail } from './health.ts'
|
|
55
|
+
export { buildCompactComparisonCard, buildRenewCard, buildSessionHistoryCard, buildSessionStatusCard, buildWorkDirCard, compactFailureContext, querySessionHistory, resolveWorkDir, SESSION_HISTORY_DISPLAY_LIMIT } from './session-commands.ts'
|
|
56
|
+
export { createLogicalSessionExport, DSH_CHANNELS_VERSION, LOGICAL_EXPORT_FORMAT, LOGICAL_EXPORT_FORMAT_VERSION, serializeLogicalSession } from './session-export.ts'
|
|
57
|
+
export { addUsage, applyProjectionViews, averageDecodeTokensPerSecond, averageTtftMs, cacheHitPct, EMPTY_TOTALS, foldAll, foldEvent, foldSessionStats, measureSessionContext, readProjectionViews, usageFromBuckets } from './stats.ts'
|
|
58
|
+
export { FeishuService } from './service.ts'
|
|
59
|
+
export { TurnStreamer } from './streamer.ts'
|
|
60
|
+
export { ensureWorkspace, stagingPathFor, topicTokenFor, workspacePathFor, workspaceTitleFor } from './workspaces.ts'
|
|
61
|
+
export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
|
|
62
|
+
|
|
63
|
+
/** Cordis function-plugin name. */
|
|
64
|
+
export const name = 'channels'
|
|
65
|
+
|
|
66
|
+
/** Host services this plugin consumes (all resolved through ctx).
|
|
67
|
+
*
|
|
68
|
+
* `credentials` is listed even though the plugin could fall back to the
|
|
69
|
+
* process environment: at boot the credentials provider may not be visible
|
|
70
|
+
* yet (a race observed in real profiles), and inject makes the loader wait
|
|
71
|
+
* for it. `llm` / `tokenMeter` feed the card's context line and ship with
|
|
72
|
+
* every dsh-base profile.
|
|
73
|
+
*/
|
|
74
|
+
export const inject = ['agents', 'commands', 'workspaceRegistry', 'storageDomain', 'credentials', 'llm', 'tokenMeter'] as const
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Cordis Standard Schema declaration for the plugin config.
|
|
78
|
+
*
|
|
79
|
+
* The loader validates (and default-fills) the raw composition config through
|
|
80
|
+
* `Config['~standard'].validate()` *before* `apply()` runs, so a malformed
|
|
81
|
+
* `cordis.patch.yml` entry fails at load time with a cordis `ValidationError`
|
|
82
|
+
* instead of surfacing from inside the async service start. `apply()` keeps
|
|
83
|
+
* its `resolveConfig()` fallback for direct invocation (unit tests, embedded
|
|
84
|
+
* hosts) where config has not been pre-validated.
|
|
85
|
+
*/
|
|
86
|
+
export const Config = configSchema
|
|
87
|
+
|
|
88
|
+
export interface FeishuPluginOptions extends FeishuConfig {
|
|
89
|
+
/** Test seam: substitute a channel implementation. */
|
|
90
|
+
channelFactory?: ChannelFactory
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Install the bridge. Credentials stay references here — the actual secrets
|
|
95
|
+
* resolve through `ctx.credentials` (env > `$DSH_HOME/.credentials.yaml`
|
|
96
|
+
* > `.env`) when `FeishuService.start()` builds the channel.
|
|
97
|
+
*/
|
|
98
|
+
export function apply(ctx: HostContext, config?: unknown): void {
|
|
99
|
+
const resolved = resolveConfig(config)
|
|
100
|
+
const factory: ChannelFactory = (config as FeishuPluginOptions | undefined)?.channelFactory
|
|
101
|
+
?? defaultChannelFactory
|
|
102
|
+
// Web settings overlay (optional file; degrades to "no overrides").
|
|
103
|
+
const overlay = new OverlayStore(
|
|
104
|
+
resolved.settingsFile ?? defaultOverlayPath(),
|
|
105
|
+
{ warn: (message, ...args) => ctx.logger?.warn?.(message, ...args) },
|
|
106
|
+
)
|
|
107
|
+
const service = new FeishuService(ctx, resolved, factory, undefined, overlay)
|
|
108
|
+
// Settings routes ride the host's web server when this deployment has one.
|
|
109
|
+
// The catalog resolves the panel's model selectors from live host services.
|
|
110
|
+
const warn = (message: string, ...args: unknown[]): void => { ctx.logger?.warn?.(message, ...args) }
|
|
111
|
+
mountSettingsRoutes(ctx, new SettingsSurface(service, overlay, { warn }, createHostCatalog(ctx, warn)))
|
|
112
|
+
ctx.effect(() => {
|
|
113
|
+
void service.boot().catch((error: unknown) => {
|
|
114
|
+
ctx.logger?.error?.('dsh-channels failed to start: %s', String(error))
|
|
115
|
+
// Critical lifecycle line duplicated to the console: cordis logger
|
|
116
|
+
// messages emitted before a web exporter attaches are buffered and may
|
|
117
|
+
// never reach stdout, which makes real-device debugging guesswork.
|
|
118
|
+
console.error('[dsh-channels] failed to start:', error)
|
|
119
|
+
})
|
|
120
|
+
return () => service.shutdown()
|
|
121
|
+
}, 'dsh-channels')
|
|
122
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HITL approval answerer: turns the host's `approval/request` waterfall
|
|
3
|
+
* events into standalone Feishu decision cards.
|
|
4
|
+
*
|
|
5
|
+
* The answerer is registered with `prepend: true` on the plugin context so it
|
|
6
|
+
* runs BEFORE generic answerers: a request for a session this channel routes
|
|
7
|
+
* is claimed here, and the decision card becomes the only way to answer.
|
|
8
|
+
* Requests for sessions the channel does NOT route delegate through `next()`.
|
|
9
|
+
*
|
|
10
|
+
* Rendering lives in `cards/approval.ts`; the shared pending-card lifecycle
|
|
11
|
+
* (delivery, owner gating, timeout, settle-once, withdrawal) lives in
|
|
12
|
+
* `interactions/pending.ts`.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-channels/interactions/approvals
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomUUID } from 'node:crypto'
|
|
18
|
+
import { buildApprovalCard, buildApprovalSettledCard } from '../cards/approval.ts'
|
|
19
|
+
import type { ApprovalCardState, ApprovalQuestion } from '../cards/approval.ts'
|
|
20
|
+
import type { ApprovalOutcome, ApprovalRequest } from '../dsh-types.ts'
|
|
21
|
+
import type { CardActionEvent, FeishuChannel } from '../feishu-channel.ts'
|
|
22
|
+
import { PendingCardTable } from './pending.ts'
|
|
23
|
+
import type { InteractionRoute, PendingCardEntry } from './pending.ts'
|
|
24
|
+
|
|
25
|
+
/** Feishu placement of one session's approval cards. */
|
|
26
|
+
export type ApprovalRoute = InteractionRoute
|
|
27
|
+
|
|
28
|
+
export interface ApprovalHooks {
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the Feishu placement for a session. `undefined` means the session
|
|
31
|
+
* is not owned by this channel — the answerer delegates through `next()`.
|
|
32
|
+
*/
|
|
33
|
+
routeFor(sessionId: string): ApprovalRoute | undefined
|
|
34
|
+
/** Log a non-fatal error (delivery/update failures). */
|
|
35
|
+
onError(sessionId: string, error: unknown): void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One in-flight approval question: its card content and its resolver. */
|
|
39
|
+
interface Pending extends PendingCardEntry {
|
|
40
|
+
question: ApprovalQuestion
|
|
41
|
+
resolve(outcome: ApprovalOutcome): void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class ApprovalAnswerer {
|
|
45
|
+
private readonly table: PendingCardTable<Pending>
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
channel: FeishuChannel,
|
|
49
|
+
private readonly hooks: ApprovalHooks,
|
|
50
|
+
private readonly timeoutMs: number,
|
|
51
|
+
) {
|
|
52
|
+
this.table = new PendingCardTable(channel, hooks.onError)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** In-flight question count (diagnostics/tests). */
|
|
56
|
+
pendingCount(): number {
|
|
57
|
+
return this.table.count()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The `approval/request` waterfall listener. Never rejects: the host service
|
|
62
|
+
* already contains answerer failures to the fail-closed `'unavailable'`.
|
|
63
|
+
*/
|
|
64
|
+
onRequest(req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome> {
|
|
65
|
+
const route = this.hooks.routeFor(req.agent.session.id)
|
|
66
|
+
if (route === undefined) return next()
|
|
67
|
+
// The abort window between the service's own signal check and this
|
|
68
|
+
// listener is closed by settling synchronously: an aborted ask never
|
|
69
|
+
// opens a question.
|
|
70
|
+
if (req.signal?.aborted === true) return Promise.resolve('cancelled')
|
|
71
|
+
|
|
72
|
+
const token = randomUUID()
|
|
73
|
+
// `displayReason` (DSH 0.2.0) is presentation-only and locale-aware;
|
|
74
|
+
// `reason` remains the audit-trail fallback.
|
|
75
|
+
const displayReason = req.displayReason?.zh ?? req.displayReason?.en ?? req.reason
|
|
76
|
+
const question: ApprovalQuestion = {
|
|
77
|
+
token,
|
|
78
|
+
toolName: req.toolName,
|
|
79
|
+
...(displayReason === undefined ? {} : { reason: displayReason }),
|
|
80
|
+
timeoutMs: this.timeoutMs,
|
|
81
|
+
sessionId: req.agent.session.id,
|
|
82
|
+
}
|
|
83
|
+
return new Promise<ApprovalOutcome>((resolve) => {
|
|
84
|
+
const entry: Pending = {
|
|
85
|
+
token,
|
|
86
|
+
sessionId: req.agent.session.id,
|
|
87
|
+
route,
|
|
88
|
+
messageId: undefined,
|
|
89
|
+
settled: false,
|
|
90
|
+
timer: undefined,
|
|
91
|
+
signal: req.signal,
|
|
92
|
+
onAbort: undefined,
|
|
93
|
+
unauthorizedNoted: false,
|
|
94
|
+
question,
|
|
95
|
+
resolve,
|
|
96
|
+
}
|
|
97
|
+
const onAbort = (): void => {
|
|
98
|
+
this.settle(entry, 'cancelled', 'cancelled')
|
|
99
|
+
}
|
|
100
|
+
entry.onAbort = onAbort
|
|
101
|
+
// Registered synchronously at promise creation so no abort can slip
|
|
102
|
+
// between the ownership check and the card delivery; a residual abort
|
|
103
|
+
// in the tiny window before addEventListener is still covered because
|
|
104
|
+
// the host's decide() races the signal against this promise and
|
|
105
|
+
// discards a late resolution (worst case: the card times out).
|
|
106
|
+
req.signal?.addEventListener('abort', onAbort, { once: true })
|
|
107
|
+
this.table.add(entry)
|
|
108
|
+
void this.table.deliver(entry, {
|
|
109
|
+
card: buildApprovalCard(question),
|
|
110
|
+
onWithdrawn: e => this.table.patch(e, buildApprovalSettledCard(e.question, 'cancelled')),
|
|
111
|
+
onDelivered: e => this.table.armTimeout(e, this.timeoutMs, () => {
|
|
112
|
+
this.settle(e, 'rejected', 'expired')
|
|
113
|
+
}),
|
|
114
|
+
// No card → no human answerer → fail closed.
|
|
115
|
+
onFailure: e => {
|
|
116
|
+
this.table.close(e, () => {})
|
|
117
|
+
e.resolve('unavailable')
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Route one card button-callback event to its pending question. Unknown
|
|
125
|
+
* cards and non-owner operators are ignored (a non-owner click adds the
|
|
126
|
+
* owner-only notice to the card once); owner decisions settle the question.
|
|
127
|
+
*/
|
|
128
|
+
onCardAction(event: CardActionEvent): void {
|
|
129
|
+
const entry = this.table.resolveAction(event, candidate => {
|
|
130
|
+
if (candidate.unauthorizedNoted) return
|
|
131
|
+
candidate.unauthorizedNoted = true
|
|
132
|
+
this.table.patch(candidate, buildApprovalCard(candidate.question, { unauthorizedNote: true }))
|
|
133
|
+
})
|
|
134
|
+
if (entry === undefined) return
|
|
135
|
+
const decision = (event.action.value as { decision?: unknown } | undefined)?.decision
|
|
136
|
+
if (decision === 'allow') this.settle(entry, 'allowed-once', 'allowed')
|
|
137
|
+
else if (decision === 'deny') this.settle(entry, 'rejected', 'denied')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Withdraw every in-flight question (plugin shutdown). */
|
|
141
|
+
dispose(): void {
|
|
142
|
+
this.table.withdrawAll(entry => this.settle(entry, 'cancelled'))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Settle once, then patch the card to its terminal state. */
|
|
146
|
+
private settle(entry: Pending, outcome: ApprovalOutcome, cardState?: ApprovalCardState): void {
|
|
147
|
+
const closed = this.table.close(entry, () => {
|
|
148
|
+
if (cardState !== undefined) {
|
|
149
|
+
this.table.patch(entry, buildApprovalSettledCard(entry.question, cardState))
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
if (closed) entry.resolve(outcome)
|
|
153
|
+
}
|
|
154
|
+
}
|