@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.
Files changed (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. package/src/workspaces.ts +199 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Logical Session export format and in-memory gzip packaging.
3
+ *
4
+ * This is deliberately a projection of the live logical Session surface. It
5
+ * does not walk descendant Sessions, fetch attachments, or copy persistence
6
+ * backend artifacts.
7
+ *
8
+ * @module dsh-channels/session-export
9
+ */
10
+
11
+ import { readFileSync } from 'node:fs'
12
+ import { promisify } from 'node:util'
13
+ import { gzip } from 'node:zlib'
14
+ import type { Session } from './dsh-types.ts'
15
+
16
+ interface PackageMetadata {
17
+ version?: unknown
18
+ }
19
+
20
+ function packageVersion(): string {
21
+ const metadata = JSON.parse(
22
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
23
+ ) as PackageMetadata
24
+ if (typeof metadata.version !== 'string' || metadata.version === '') {
25
+ throw new Error('dsh-channels: package version is unavailable')
26
+ }
27
+ return metadata.version
28
+ }
29
+
30
+ export const LOGICAL_EXPORT_FORMAT = 'dsh-logical-session-jsonl'
31
+ export const LOGICAL_EXPORT_FORMAT_VERSION = 1
32
+ export const DSH_CHANNELS_VERSION = packageVersion()
33
+
34
+ export interface LogicalExportManifest {
35
+ format: typeof LOGICAL_EXPORT_FORMAT
36
+ formatVersion: typeof LOGICAL_EXPORT_FORMAT_VERSION
37
+ logical: true
38
+ exportedAt: string
39
+ plugin: {
40
+ name: 'dsh-channels'
41
+ version: string
42
+ }
43
+ includes: {
44
+ descendants: false
45
+ attachments: false
46
+ rawArtifacts: false
47
+ }
48
+ session: {
49
+ id: string
50
+ header: Session['header']
51
+ }
52
+ }
53
+
54
+ export interface LogicalSessionExportFile {
55
+ fileName: string
56
+ source: Buffer
57
+ }
58
+
59
+ export type SessionExportBuilder = (session: Session) => Promise<LogicalSessionExportFile>
60
+
61
+ export interface LogicalSessionExportOptions {
62
+ /** Deterministic clock seam for format tests. */
63
+ exportedAt?: Date
64
+ /** Explicit package-version seam for format tests. */
65
+ pluginVersion?: string
66
+ /** Compression seam used to verify gzip failures without filesystem I/O. */
67
+ compress?: (source: Buffer) => Promise<Buffer>
68
+ }
69
+
70
+ const gzipBuffer = promisify(gzip)
71
+
72
+ /** Build one immutable UTF-8 JSONL snapshot before any asynchronous work. */
73
+ export function serializeLogicalSession(
74
+ session: Session,
75
+ options: Pick<LogicalSessionExportOptions, 'exportedAt' | 'pluginVersion'> = {},
76
+ ): Buffer {
77
+ const manifest: LogicalExportManifest = {
78
+ format: LOGICAL_EXPORT_FORMAT,
79
+ formatVersion: LOGICAL_EXPORT_FORMAT_VERSION,
80
+ logical: true,
81
+ exportedAt: (options.exportedAt ?? new Date()).toISOString(),
82
+ plugin: {
83
+ name: 'dsh-channels',
84
+ version: options.pluginVersion ?? DSH_CHANNELS_VERSION,
85
+ },
86
+ includes: {
87
+ descendants: false,
88
+ attachments: false,
89
+ rawArtifacts: false,
90
+ },
91
+ session: {
92
+ id: session.id,
93
+ header: session.header,
94
+ },
95
+ }
96
+ const lines = [JSON.stringify(manifest)]
97
+ for (const event of session.snapshotEvents()) lines.push(JSON.stringify(event))
98
+ return Buffer.from(`${lines.join('\n')}\n`, 'utf8')
99
+ }
100
+
101
+ /** Serialize the logical snapshot, then gzip it entirely in memory. */
102
+ export async function createLogicalSessionExport(
103
+ session: Session,
104
+ options: LogicalSessionExportOptions = {},
105
+ ): Promise<LogicalSessionExportFile> {
106
+ const snapshot = serializeLogicalSession(session, options)
107
+ const source = await (options.compress ?? gzipBuffer)(snapshot)
108
+ return {
109
+ fileName: `dsh-session-${session.id}.logical.jsonl.gz`,
110
+ source,
111
+ }
112
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Web 设置面板的 Host 侧选择器目录:把宿主 LLM 服务注册的 provider / model
3
+ * / 推理档位,以及 agent-preset roster 折叠成面板可渲染的选项列表。
4
+ *
5
+ * 目录只用于「选择」:任何查询失败(服务未挂载、方法缺失、超时、抛错)都
6
+ * 降级为 `selectable: false`,客户端据此退回原有自由文本输入框,绝不让
7
+ * 面板加载依赖宿主的网络状态。成功结果按短 TTL 缓存,避免用户来回切换
8
+ * provider/model 时反复查询适配器。
9
+ *
10
+ * @module dsh-channels/settings/catalog
11
+ */
12
+
13
+ import type {
14
+ AgentDefaultModel,
15
+ AgentPreset,
16
+ AgentPresets,
17
+ HostContext,
18
+ LlmModelInfo,
19
+ LlmProviderInfo,
20
+ } from '../dsh-types.ts'
21
+ import type { CatalogWire, FieldOptionWire, SelectCatalogWire } from './contract.ts'
22
+
23
+ /** 目录查询的硬上限:面板加载不能被宿主的模型发现/连接拖住。 */
24
+ const LOOKUP_TIMEOUT_MS = 4_000
25
+
26
+ /** 成功查询的缓存时长(毫秒)。 */
27
+ const CACHE_TTL_MS = 30_000
28
+
29
+ export type CatalogWarn = (message: string, ...args: unknown[]) => void
30
+
31
+ /** Host capability used by the settings panel to populate its selectors. */
32
+ export interface SettingsCatalog {
33
+ /**
34
+ * Resolve the selector catalog for one configured route.
35
+ * @param provider - configured provider route (absent = inherit deployment default).
36
+ * @param model - configured model id (absent = inherit deployment default).
37
+ * @returns selector options; groups the host cannot enumerate are `selectable: false`.
38
+ */
39
+ catalog(provider?: string, model?: string): Promise<CatalogWire>
40
+ }
41
+
42
+ function messageOf(error: unknown): string {
43
+ return error instanceof Error ? error.message : String(error)
44
+ }
45
+
46
+ /** Resolve `work` or `undefined` after `ms`, so a hanging adapter cannot stall the panel. */
47
+ async function within<T>(work: Promise<T>, ms: number): Promise<T | undefined> {
48
+ let timer: ReturnType<typeof setTimeout> | undefined
49
+ const timeout = new Promise<undefined>((resolve) => {
50
+ timer = setTimeout(() => { resolve(undefined) }, ms)
51
+ timer.unref?.()
52
+ })
53
+ try {
54
+ return await Promise.race([work, timeout])
55
+ } finally {
56
+ if (timer !== undefined) clearTimeout(timer)
57
+ }
58
+ }
59
+
60
+ /** Leading "inherit / clear" choice every selector gets. */
61
+ function inheritOption(label: string): FieldOptionWire {
62
+ return { value: '', label }
63
+ }
64
+
65
+ /** `Display name(id)` when the host publishes both, else the id alone. */
66
+ function labelledOption(id: string, name: string | undefined): FieldOptionWire {
67
+ const display = name === undefined ? '' : name.trim()
68
+ return { value: id, label: display === '' || display === id ? id : `${display}(${id})` }
69
+ }
70
+
71
+ /** Non-empty display text, otherwise undefined. */
72
+ function textOf(value: unknown): string | undefined {
73
+ if (typeof value !== 'string') return undefined
74
+ const trimmed = value.trim()
75
+ return trimmed === '' ? undefined : trimmed
76
+ }
77
+
78
+ /**
79
+ * Build the catalog over one live host context.
80
+ * @param ctx - host context carrying `llm` and the optional preset/default-model services.
81
+ * @param warn - optional diagnostic sink for failed lookups.
82
+ * @returns the catalog facade.
83
+ */
84
+ export function createHostCatalog(ctx: HostContext, warn?: CatalogWarn): SettingsCatalog {
85
+ const llm = ctx.llm
86
+ const presets = ctx.get?.('agentPresets') as AgentPresets | undefined
87
+ const defaultModel = ctx.get?.('agentDefaultModel') as AgentDefaultModel | undefined
88
+ const cache = new Map<string, { at: number; value: unknown }>()
89
+
90
+ /** Cache a successful lookup; failures are re-tried on the next panel refresh. */
91
+ async function cached<T>(key: string, load: () => Promise<T> | T): Promise<T | undefined> {
92
+ const hit = cache.get(key)
93
+ if (hit !== undefined && Date.now() - hit.at < CACHE_TTL_MS) return hit.value as T
94
+ try {
95
+ const value = await within(Promise.resolve(load()), LOOKUP_TIMEOUT_MS)
96
+ if (value === undefined) return undefined
97
+ cache.set(key, { at: Date.now(), value })
98
+ return value
99
+ } catch (error) {
100
+ warn?.('dsh-channels: 读取 %s 选择目录失败:%s', key, messageOf(error))
101
+ return undefined
102
+ }
103
+ }
104
+
105
+ /** Registered provider routes, `undefined` when the deployment exposes none. */
106
+ async function providerOptions(): Promise<FieldOptionWire[] | undefined> {
107
+ if (typeof llm.listProviders !== 'function') return undefined
108
+ const rows = await cached<readonly LlmProviderInfo[]>('providers', () => llm.listProviders!())
109
+ if (rows === undefined) return undefined
110
+ const options: FieldOptionWire[] = []
111
+ const seen = new Set<string>()
112
+ for (const row of rows) {
113
+ const id = textOf((row as { id?: unknown } | undefined)?.id)
114
+ if (id === undefined || seen.has(id)) continue
115
+ seen.add(id)
116
+ options.push(labelledOption(id, textOf((row as { name?: unknown }).name)))
117
+ }
118
+ return options.length === 0 ? undefined : options
119
+ }
120
+
121
+ /** Models advertised for one provider route, `undefined` when it has no catalog. */
122
+ async function modelOptions(provider: string): Promise<FieldOptionWire[] | undefined> {
123
+ if (provider === '' || typeof llm.listModels !== 'function') return undefined
124
+ const rows = await cached<readonly LlmModelInfo[]>(`models:${provider}`, () => llm.listModels!(provider))
125
+ if (rows === undefined) return undefined
126
+ const options: FieldOptionWire[] = []
127
+ const seen = new Set<string>()
128
+ for (const row of rows) {
129
+ const id = textOf((row as { id?: unknown } | undefined)?.id)
130
+ if (id === undefined || seen.has(id)) continue
131
+ seen.add(id)
132
+ options.push(labelledOption(id, textOf((row as { name?: unknown }).name)))
133
+ }
134
+ return options.length === 0 ? undefined : options
135
+ }
136
+
137
+ /** Adapter metadata for one exact route; `undefined` when it cannot be resolved. */
138
+ async function modelInfo(provider: string, model: string): Promise<LlmModelInfo | undefined> {
139
+ if (provider === '' || model === '') return undefined
140
+ return await cached<LlmModelInfo>(`model-info:${provider}/${model}`, () => llm.resolveModelInfo(provider, model))
141
+ }
142
+
143
+ /** Declared preset rows, `undefined` when the roster has no `list()` or no rows. */
144
+ async function presetRows(): Promise<readonly AgentPreset[] | undefined> {
145
+ if (typeof presets?.list !== 'function') return undefined
146
+ const rows = await cached<readonly AgentPreset[]>('presets', () => presets!.list!())
147
+ if (rows === undefined || rows.length === 0) return undefined
148
+ const filtered = rows.filter(row => textOf((row as { id?: unknown } | undefined)?.id) !== undefined)
149
+ return filtered.length === 0 ? undefined : filtered
150
+ }
151
+
152
+ /** Roster default preset id, when the implementation publishes it. */
153
+ function defaultPresetId(): string | undefined {
154
+ try {
155
+ return textOf(presets?.defaultId)
156
+ } catch {
157
+ return undefined
158
+ }
159
+ }
160
+
161
+ /** Deployment default model selection, when the host exposes `agentDefaultModel`. */
162
+ function deploymentDefault(): CatalogWire['deploymentDefault'] | undefined {
163
+ try {
164
+ const selection = defaultModel?.currentSelection?.()
165
+ const provider = textOf(selection?.provider)
166
+ const model = textOf(selection?.model)
167
+ if (provider === undefined || model === undefined) return undefined
168
+ const reasoningEffort = textOf(selection?.reasoningEffort)
169
+ return { provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }) }
170
+ } catch {
171
+ return undefined
172
+ }
173
+ }
174
+
175
+ /** Selector for the configured provider route. */
176
+ function providerWire(
177
+ providers: readonly FieldOptionWire[] | undefined,
178
+ deployment: CatalogWire['deploymentDefault'],
179
+ ): SelectCatalogWire {
180
+ if (providers === undefined) return { selectable: false, options: [] }
181
+ const label = deployment?.provider === undefined
182
+ ? '留空 = 部署默认'
183
+ : `留空 = 部署默认(${deployment.provider})`
184
+ return { selectable: true, options: [inheritOption(label), ...providers] }
185
+ }
186
+
187
+ /** Selector for the configured model id. */
188
+ function modelWire(
189
+ models: readonly FieldOptionWire[] | undefined,
190
+ provider: string,
191
+ deployment: CatalogWire['deploymentDefault'],
192
+ providers: readonly FieldOptionWire[] | undefined,
193
+ ): SelectCatalogWire {
194
+ const label = deployment?.model === undefined
195
+ ? '留空 = 部署默认'
196
+ : `留空 = 部署默认(${deployment.model})`
197
+ if (models === undefined) {
198
+ // Provider selectable but none configured yet: keep the model selector
199
+ // visible and disabled so the dependency is discoverable. A provider the
200
+ // adapter cannot enumerate instead falls back to free text.
201
+ if (providers !== undefined && provider === '') {
202
+ return {
203
+ selectable: true,
204
+ options: [inheritOption(label)],
205
+ disabled: true,
206
+ help: '先选择模型服务商,再选择模型 id。留空表示沿用部署默认。',
207
+ }
208
+ }
209
+ return { selectable: false, options: [] }
210
+ }
211
+ const select: SelectCatalogWire = {
212
+ selectable: true,
213
+ options: [inheritOption(label), ...models],
214
+ }
215
+ if (provider === '') {
216
+ select.disabled = true
217
+ select.help = '先选择模型服务商,再选择模型 id。留空表示沿用部署默认。'
218
+ }
219
+ return select
220
+ }
221
+
222
+ /** Selector for the configured reasoning effort. */
223
+ function effortWire(
224
+ info: LlmModelInfo | undefined,
225
+ provider: string,
226
+ model: string,
227
+ providers: readonly FieldOptionWire[] | undefined,
228
+ ): SelectCatalogWire {
229
+ // 无法枚举 provider 时,整条选择链都退回自由文本输入。
230
+ if (providers === undefined) return { selectable: false, options: [] }
231
+ if (provider === '' || model === '') {
232
+ return {
233
+ selectable: true,
234
+ options: [inheritOption('留空 = 适配器默认')],
235
+ disabled: true,
236
+ help: '先选择模型服务商与模型 id,再选择推理档位。',
237
+ }
238
+ }
239
+ // 路由无法解析(适配器报错/超时):退回自由文本,保留手填能力。
240
+ if (info === undefined) return { selectable: false, options: [] }
241
+ const efforts = (info.reasoning?.efforts ?? [])
242
+ .filter(effort => textOf(effort.id) !== undefined)
243
+ .map(effort => labelledOption(effort.id, textOf(effort.name)))
244
+ if (efforts.length === 0) {
245
+ return {
246
+ selectable: true,
247
+ options: [inheritOption('留空 = 适配器默认')],
248
+ disabled: true,
249
+ help: '当前模型没有声明可选推理档位,只能使用适配器默认。',
250
+ }
251
+ }
252
+ const defaultEffort = textOf(info.reasoning?.defaultEffort)
253
+ return {
254
+ selectable: true,
255
+ options: [
256
+ inheritOption(defaultEffort === undefined
257
+ ? '留空 = 适配器默认'
258
+ : `留空 = 适配器默认(${defaultEffort})`),
259
+ ...efforts,
260
+ ],
261
+ }
262
+ }
263
+
264
+ /** Selector for the agent preset id. */
265
+ function presetWire(
266
+ rows: readonly AgentPreset[] | undefined,
267
+ defaultId: string | undefined,
268
+ ): SelectCatalogWire {
269
+ if (rows === undefined) return { selectable: false, options: [] }
270
+ const options = rows.map((row) => {
271
+ const id = textOf(row.id)!
272
+ const option = labelledOption(id, textOf(row.name))
273
+ if (row.broken === undefined) return option
274
+ return { ...option, label: `${option.label}(不可用)` }
275
+ })
276
+ return {
277
+ selectable: true,
278
+ options: [
279
+ inheritOption(defaultId === undefined ? '留空 = roster 默认' : `留空 = roster 默认(${defaultId})`),
280
+ ...options,
281
+ ],
282
+ }
283
+ }
284
+
285
+ return {
286
+ async catalog(provider?: string, model?: string): Promise<CatalogWire> {
287
+ const wantedProvider = textOf(provider) ?? ''
288
+ const wantedModel = textOf(model) ?? ''
289
+ const deployment = deploymentDefault()
290
+ const defaultPreset = defaultPresetId()
291
+
292
+ const [providers, models, info, rows] = await Promise.all([
293
+ providerOptions(),
294
+ modelOptions(wantedProvider),
295
+ modelInfo(wantedProvider, wantedModel),
296
+ presetRows(),
297
+ ])
298
+
299
+ return {
300
+ provider: providerWire(providers, deployment),
301
+ model: modelWire(models, wantedProvider, deployment, providers),
302
+ reasoningEffort: effortWire(info, wantedProvider, wantedModel, providers),
303
+ agentPreset: presetWire(rows, defaultPreset),
304
+ ...(deployment === undefined ? {} : { deploymentDefault: deployment }),
305
+ ...(defaultPreset === undefined ? {} : { defaultPresetId: defaultPreset }),
306
+ }
307
+ },
308
+ }
309
+ }