@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,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings panel styling. DSH semantic tokens (`--dsw-alias-*`) with neutral
|
|
3
|
+
* fallbacks so the page follows the active theme; only pseudo-classes and
|
|
4
|
+
* responsive rules live here (everything else is inline in the component).
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-channels/client/styles
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const STYLE_ATTRIBUTE = 'data-dsh-channels-css'
|
|
10
|
+
|
|
11
|
+
const CSS = `
|
|
12
|
+
.dsh-channels-cfg{container-type:inline-size;display:flex;flex-direction:column;gap:12px;width:100%;max-width:720px;color:var(--dsw-alias-label-primary,#1f2328);font-size:13px;line-height:20px}
|
|
13
|
+
.dsh-channels-cfg__head{display:flex;flex-direction:column;gap:4px}
|
|
14
|
+
.dsh-channels-cfg__title{margin:0;font-size:16px;font-weight:500;line-height:24px}
|
|
15
|
+
.dsh-channels-cfg__desc{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
|
|
16
|
+
.dsh-channels-cfg__card{display:flex;flex-direction:column;gap:10px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:12px}
|
|
17
|
+
.dsh-channels-cfg__cardHead{display:flex;align-items:center;gap:8px;min-height:20px;flex-wrap:wrap}
|
|
18
|
+
.dsh-channels-cfg__cardTitle{font-size:13px;font-weight:500}
|
|
19
|
+
.dsh-channels-cfg__cardMeta{margin-left:auto;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px}
|
|
20
|
+
.dsh-channels-cfg__statusRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
21
|
+
.dsh-channels-cfg__badge{display:inline-flex;align-items:center;gap:5px;padding:1px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));font-size:12px;line-height:18px;white-space:nowrap}
|
|
22
|
+
.dsh-channels-cfg__dot{width:7px;height:7px;border-radius:50%;background:currentColor;flex:none}
|
|
23
|
+
.dsh-channels-cfg__badge--ok{color:var(--dsw-alias-state-business-primary,#0a7d33);border-color:currentColor}
|
|
24
|
+
.dsh-channels-cfg__badge--warn{color:var(--dsw-alias-state-warning-primary,#a15c00);border-color:currentColor}
|
|
25
|
+
.dsh-channels-cfg__badge--bad{color:var(--dsw-alias-state-danger-primary,#c62828);border-color:currentColor}
|
|
26
|
+
.dsh-channels-cfg__badge--muted{color:var(--dsw-alias-label-tertiary,#8a8f98)}
|
|
27
|
+
.dsh-channels-cfg__facts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px 16px}
|
|
28
|
+
.dsh-channels-cfg__fact{display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
29
|
+
.dsh-channels-cfg__factLabel{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:16px}
|
|
30
|
+
.dsh-channels-cfg__factValue{font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
|
|
31
|
+
.dsh-channels-cfg__actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
32
|
+
.dsh-channels-cfg__btn{display:inline-flex;align-items:center;justify-content:center;gap:4px;min-height:28px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:8px;background:transparent;color:inherit;font:inherit;font-size:13px;cursor:pointer;white-space:nowrap}
|
|
33
|
+
.dsh-channels-cfg__btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05))}
|
|
34
|
+
.dsh-channels-cfg__btn:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#0a67d3);outline-offset:2px}
|
|
35
|
+
.dsh-channels-cfg__btn:disabled{color:var(--dsw-alias-label-dimmed,#b0b4ba);cursor:default}
|
|
36
|
+
.dsh-channels-cfg__btn--primary{background:var(--dsw-alias-button-primary-fill,#0a67d3);border-color:transparent;color:var(--dsw-alias-label-primary-foreground,#fff)}
|
|
37
|
+
.dsh-channels-cfg__btn--primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,#0b5cba)}
|
|
38
|
+
.dsh-channels-cfg__btn--danger{color:var(--dsw-alias-state-danger-primary,#c62828);border-color:currentColor}
|
|
39
|
+
.dsh-channels-cfg__grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 20px;align-items:start}
|
|
40
|
+
.dsh-channels-cfg__field{display:flex;flex-direction:column;gap:4px;min-width:0}
|
|
41
|
+
.dsh-channels-cfg__field--wide{grid-column:1/-1}
|
|
42
|
+
.dsh-channels-cfg__labelRow{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}
|
|
43
|
+
.dsh-channels-cfg__label{font-size:12px;color:var(--dsw-alias-label-secondary,#5c6169)}
|
|
44
|
+
.dsh-channels-cfg__tag{display:inline-flex;align-items:center;padding:0 5px;border-radius:4px;border:1px dashed currentColor;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#8a8f98);white-space:nowrap}
|
|
45
|
+
.dsh-channels-cfg__tag--override{color:var(--dsw-alias-state-business-primary,#0a67d3);border-style:solid}
|
|
46
|
+
.dsh-channels-cfg__tag--restart{color:var(--dsw-alias-state-warning-primary,#a15c00)}
|
|
47
|
+
.dsh-channels-cfg__help{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:11px;line-height:16px}
|
|
48
|
+
.dsh-channels-cfg__reasonBad{color:var(--dsw-alias-state-danger-primary,#c62828)}
|
|
49
|
+
.dsh-channels-cfg__input{box-sizing:border-box;width:100%;min-height:28px;padding:3px 10px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:8px;background:var(--dsw-alias-bg-layer-1,transparent);color:inherit;font:inherit;font-size:13px;outline:none}
|
|
50
|
+
.dsh-channels-cfg__input:focus{border-color:var(--dsw-alias-state-business-primary,#0a67d3)}
|
|
51
|
+
.dsh-channels-cfg__input--invalid{border-color:var(--dsw-alias-state-danger-primary,#c62828)}
|
|
52
|
+
select.dsh-channels-cfg__input{appearance:auto;cursor:pointer}
|
|
53
|
+
.dsh-channels-cfg__check{display:inline-flex;align-items:center;gap:6px;min-height:28px;cursor:pointer;font-size:13px}
|
|
54
|
+
.dsh-channels-cfg__checkbox{width:15px;height:15px;margin:0;accent-color:var(--dsw-alias-state-business-primary,#0a67d3);cursor:pointer}
|
|
55
|
+
.dsh-channels-cfg__fieldError{color:var(--dsw-alias-state-danger-primary,#c62828);font-size:11px;line-height:16px}
|
|
56
|
+
.dsh-channels-cfg__notice{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:8px;font-size:12px;line-height:18px}
|
|
57
|
+
.dsh-channels-cfg__notice--ok{color:var(--dsw-alias-state-business-primary,#0a7d33);background:color-mix(in srgb,currentColor 8%,transparent)}
|
|
58
|
+
.dsh-channels-cfg__notice--error{color:var(--dsw-alias-state-danger-primary,#c62828);background:color-mix(in srgb,currentColor 8%,transparent)}
|
|
59
|
+
.dsh-channels-cfg__banner{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border-radius:8px;border:1px dashed var(--dsw-alias-state-warning-primary,#a15c00);color:var(--dsw-alias-state-warning-primary,#a15c00);font-size:12px;line-height:18px}
|
|
60
|
+
.dsh-channels-cfg__footer{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:2px}
|
|
61
|
+
.dsh-channels-cfg__spacer{flex:1}
|
|
62
|
+
.dsh-channels-cfg__readOnly{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 20px}
|
|
63
|
+
.dsh-channels-cfg__kv{display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
64
|
+
.dsh-channels-cfg__kvKey{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:16px}
|
|
65
|
+
.dsh-channels-cfg__kvValue{overflow-wrap:anywhere}
|
|
66
|
+
.dsh-channels-cfg__mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
|
|
67
|
+
.dsh-channels-cfg__creds{display:flex;flex-direction:column;gap:6px}
|
|
68
|
+
.dsh-channels-cfg__cred{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
69
|
+
.dsh-channels-cfg__cred .dsh-channels-cfg__input{flex:1 1 240px;min-width:0;width:auto}
|
|
70
|
+
.dsh-channels-cfg__accessSection{display:flex;flex-direction:column;gap:6px}
|
|
71
|
+
.dsh-channels-cfg__accessList{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
|
|
72
|
+
.dsh-channels-cfg__accessRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:6px 8px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:8px}
|
|
73
|
+
.dsh-channels-cfg__accessMain{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1 1 200px;overflow-wrap:anywhere}
|
|
74
|
+
.dsh-channels-cfg__accessMeta{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:11px;line-height:16px;overflow-wrap:anywhere}
|
|
75
|
+
.dsh-channels-cfg__accessActions{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
76
|
+
.dsh-channels-cfg__accessAdd{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
77
|
+
.dsh-channels-cfg__accessInput{flex:1 1 140px;min-width:0;width:auto}
|
|
78
|
+
.dsh-channels-cfg__error{padding:10px 12px;border:1px solid var(--dsw-alias-state-danger-primary,#c62828);border-radius:10px;color:var(--dsw-alias-state-danger-primary,#c62828);font-size:12px;line-height:18px;display:flex;flex-direction:column;gap:8px}
|
|
79
|
+
.dsh-channels-cfg__loading{padding:16px 0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px}
|
|
80
|
+
.dsh-channels-cfg__issues{margin:0;padding-left:18px}
|
|
81
|
+
@container (max-width:560px){
|
|
82
|
+
.dsh-channels-cfg__facts{grid-template-columns:repeat(2,minmax(0,1fr))}
|
|
83
|
+
.dsh-channels-cfg__grid,.dsh-channels-cfg__readOnly{grid-template-columns:minmax(0,1fr)}
|
|
84
|
+
}
|
|
85
|
+
`
|
|
86
|
+
|
|
87
|
+
/** Append the panel stylesheet once (idempotent across HMR / remounts). */
|
|
88
|
+
export function installStyles(): () => void {
|
|
89
|
+
const existing = document.querySelector(`style[${STYLE_ATTRIBUTE}]`)
|
|
90
|
+
if (existing !== null) return () => {}
|
|
91
|
+
const style = document.createElement('style')
|
|
92
|
+
style.setAttribute(STYLE_ATTRIBUTE, '')
|
|
93
|
+
style.textContent = CSS
|
|
94
|
+
document.head.append(style)
|
|
95
|
+
return () => {
|
|
96
|
+
style.remove()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The channel's slash-command registry: one declarative source for the
|
|
3
|
+
* command vocabulary, admission rules, `/help` rendering, and dispatch.
|
|
4
|
+
*
|
|
5
|
+
* Adding a command means adding one entry here (plus its handler): the
|
|
6
|
+
* supported-name list, the help text, and the admission gate all derive from
|
|
7
|
+
* this table.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-channels/commands/registry
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Help grouping, in render order. */
|
|
13
|
+
export const COMMAND_GROUPS = ['基础', '会话', '模型与上下文', '导出'] as const
|
|
14
|
+
export type CommandGroup = typeof COMMAND_GROUPS[number]
|
|
15
|
+
|
|
16
|
+
export interface ChannelCommandSpec {
|
|
17
|
+
/** Canonical command name (without the leading slash). */
|
|
18
|
+
readonly name: string
|
|
19
|
+
readonly group: CommandGroup
|
|
20
|
+
readonly description: string
|
|
21
|
+
/** Argument hint shown in help, e.g. `[id|reset]`. */
|
|
22
|
+
readonly usage?: string
|
|
23
|
+
/** Answerable without any Session work (and inside a retired topic). */
|
|
24
|
+
readonly routeFree?: boolean
|
|
25
|
+
/** Answerable with or without a bound Session: bound runs on the command plane. */
|
|
26
|
+
readonly sessionOptional?: boolean
|
|
27
|
+
/** Unthreaded group messages require a bot mention before the command answers. */
|
|
28
|
+
readonly mentionGate?: boolean
|
|
29
|
+
/** Only usable in this chat type. */
|
|
30
|
+
readonly chatType?: 'p2p' | 'group'
|
|
31
|
+
/** Answer with a direct reply instead of creating a topic. */
|
|
32
|
+
readonly directReply?: boolean
|
|
33
|
+
/**
|
|
34
|
+
* Answered by the channel itself (`/stop`), never through the agent command
|
|
35
|
+
* plane, and never cold-resuming a Session just to answer.
|
|
36
|
+
*/
|
|
37
|
+
readonly channelPlane?: boolean
|
|
38
|
+
/**
|
|
39
|
+
* Registered inside each Agent scope for the agent command plane. Defaults
|
|
40
|
+
* to true; `false` for route-free commands, channel-plane commands, and the
|
|
41
|
+
* native host `/compact`.
|
|
42
|
+
*/
|
|
43
|
+
readonly agentScoped?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The complete command vocabulary. */
|
|
47
|
+
export const CHANNEL_COMMANDS = [
|
|
48
|
+
{
|
|
49
|
+
name: 'help', group: '基础', description: '显示当前支持的斜杠命令',
|
|
50
|
+
routeFree: true, directReply: false,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'userid', group: '基础', description: '在私聊中显示当前用户 ID',
|
|
54
|
+
routeFree: true, mentionGate: true, chatType: 'p2p', directReply: true, agentScoped: false,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'groupid', group: '基础', description: '在群聊中显示当前群组 ID',
|
|
58
|
+
routeFree: true, mentionGate: true, chatType: 'group', directReply: true, agentScoped: false,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'health', group: '基础', description: '显示 Harness 与飞书通道健康状态',
|
|
62
|
+
sessionOptional: true,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'status', group: '会话', description: '显示当前 Session 状态与性能指标',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'workspace', group: '会话', description: '显示当前 Session 工作目录',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'permissions', group: '会话', description: '查看(或按配置切换)当前 Session 权限预设',
|
|
72
|
+
usage: '[name]', channelPlane: true, agentScoped: false,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'auth', group: '会话', description: '查看/管理访问授权(仅管理员)',
|
|
76
|
+
usage: '[status|allow|revoke|list|user add <open_id>|user remove <open_id>]',
|
|
77
|
+
routeFree: true, channelPlane: true, agentScoped: false,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'renew', group: '会话', description: '在当前话题创建并绑定新 Session',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'stop', group: '会话', description: '停止当前正在运行的回合',
|
|
84
|
+
channelPlane: true, agentScoped: false,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'effort', group: '模型与上下文', description: '查看或切换当前 Session 推理强度',
|
|
88
|
+
usage: '[id|reset]',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'model', group: '模型与上下文', description: '查看或切换当前 Session 模型',
|
|
92
|
+
usage: '[id|provider/model|reset]',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'compact', group: '模型与上下文', description: '压缩当前 Session 上下文', agentScoped: false,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'history', group: '导出', description: '显示当前话题的 Session 历史',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'export', group: '导出', description: '导出当前 Session 的逻辑日志',
|
|
102
|
+
},
|
|
103
|
+
] as const satisfies readonly ChannelCommandSpec[]
|
|
104
|
+
|
|
105
|
+
/** Canonical command name literal union. */
|
|
106
|
+
export type ChannelCommandName = typeof CHANNEL_COMMANDS[number]['name']
|
|
107
|
+
|
|
108
|
+
/** User-facing aliases, resolved to their canonical command. */
|
|
109
|
+
export const COMMAND_ALIASES = { new: 'renew' } as const
|
|
110
|
+
export type ChannelCommandAlias = keyof typeof COMMAND_ALIASES
|
|
111
|
+
|
|
112
|
+
/** Any command name a user may type. */
|
|
113
|
+
export type SupportedCommandName = ChannelCommandName | ChannelCommandAlias
|
|
114
|
+
|
|
115
|
+
const BY_NAME = new Map<string, ChannelCommandSpec>(
|
|
116
|
+
CHANNEL_COMMANDS.map(spec => [spec.name, spec as ChannelCommandSpec]),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
/** Resolve one typed name (canonical or alias) to its command spec. */
|
|
120
|
+
export function commandSpec(name: string): ChannelCommandSpec | undefined {
|
|
121
|
+
const canonical = (COMMAND_ALIASES as Record<string, string>)[name] ?? name
|
|
122
|
+
return BY_NAME.get(canonical)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Canonical command names, in registry order. */
|
|
126
|
+
export function commandNames(): readonly ChannelCommandName[] {
|
|
127
|
+
return CHANNEL_COMMANDS.map(spec => spec.name)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Every typeable name (canonical + aliases), for the supported-name list. */
|
|
131
|
+
export function supportedCommandNames(): readonly SupportedCommandName[] {
|
|
132
|
+
return [...commandNames(), ...Object.keys(COMMAND_ALIASES)] as SupportedCommandName[]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `用法:/effort [id|reset]` line, or undefined without a usage hint. */
|
|
136
|
+
export function commandUsage(spec: ChannelCommandSpec): string | undefined {
|
|
137
|
+
if (spec.usage === undefined) return undefined
|
|
138
|
+
return `/${spec.name} ${spec.usage}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Grouped `/help` markdown, derived from the registry. */
|
|
142
|
+
export function groupedCommandHelp(): string {
|
|
143
|
+
const sections: string[] = []
|
|
144
|
+
const specs: readonly ChannelCommandSpec[] = CHANNEL_COMMANDS
|
|
145
|
+
for (const group of COMMAND_GROUPS) {
|
|
146
|
+
const lines = specs
|
|
147
|
+
.filter(spec => spec.group === group)
|
|
148
|
+
.map(spec => {
|
|
149
|
+
const usage = commandUsage(spec)
|
|
150
|
+
return `**/${spec.name}**${usage === undefined ? '' : ` ${spec.usage}`} — ${spec.description}`
|
|
151
|
+
})
|
|
152
|
+
if (lines.length > 0) sections.push(`**${group}**\n${lines.join('\n')}`)
|
|
153
|
+
}
|
|
154
|
+
return sections.join('\n\n')
|
|
155
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu slash-command admission primitives.
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately contains no channel or Host I/O. It owns the
|
|
5
|
+
* product command vocabulary, exact parsing, agent-scoped registrations,
|
|
6
|
+
* per-route admission serialization, and standalone result-card rendering.
|
|
7
|
+
* Command-specific folds and side effects are added by the later SC tasks.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-channels/commands
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
AgentContext, CommandDefinition, CommandDescriptor, CommandResult, CommandRuntime,
|
|
14
|
+
} from './dsh-types.ts'
|
|
15
|
+
import { card } from './cards/primitives.ts'
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
CHANNEL_COMMANDS, commandNames, groupedCommandHelp, commandSpec, supportedCommandNames,
|
|
19
|
+
} from './commands/registry.ts'
|
|
20
|
+
import type { ChannelCommandSpec, SupportedCommandName } from './commands/registry.ts'
|
|
21
|
+
|
|
22
|
+
/** Canonical channel command names, in registry order (agent-scoped ones included). */
|
|
23
|
+
export const CHANNEL_COMMAND_NAMES = commandNames()
|
|
24
|
+
|
|
25
|
+
/** Every typeable command name (canonical + aliases). */
|
|
26
|
+
export const SUPPORTED_COMMAND_NAMES = supportedCommandNames()
|
|
27
|
+
|
|
28
|
+
export type { SupportedCommandName }
|
|
29
|
+
|
|
30
|
+
export interface ParsedSlashCommand {
|
|
31
|
+
readonly name: string
|
|
32
|
+
/** Exact text following the command name, including separator whitespace. */
|
|
33
|
+
readonly rawInput: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Match Harness' lowercase command grammar without normalizing arguments. */
|
|
37
|
+
export function parseSlashCommand(line: string): ParsedSlashCommand | undefined {
|
|
38
|
+
const match = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u.exec(line)
|
|
39
|
+
if (match === null) return undefined
|
|
40
|
+
const name = match[1]
|
|
41
|
+
if (name === undefined) return undefined
|
|
42
|
+
return { name, rawInput: line.slice(match[0].length) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isSupportedCommand(name: string): name is SupportedCommandName {
|
|
46
|
+
return commandSpec(name) !== undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Stable display used by unknown-command and deployment diagnostics. */
|
|
50
|
+
export function supportedCommandList(): string {
|
|
51
|
+
return SUPPORTED_COMMAND_NAMES.map(name => `/${name}`).join('、')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Markdown help, grouped and generated from the command registry. */
|
|
55
|
+
export function supportedCommandHelp(): string {
|
|
56
|
+
return groupedCommandHelp()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Commands registered inside each Agent scope (route-free ones are excluded). */
|
|
60
|
+
const ALL_COMMANDS: readonly ChannelCommandSpec[] = CHANNEL_COMMANDS
|
|
61
|
+
const AGENT_SCOPED_COMMANDS = ALL_COMMANDS.filter(spec => spec.agentScoped !== false)
|
|
62
|
+
|
|
63
|
+
const DESCRIPTORS: ReadonlyArray<CommandDescriptor> = AGENT_SCOPED_COMMANDS.map(spec => ({
|
|
64
|
+
name: spec.name,
|
|
65
|
+
description: spec.description,
|
|
66
|
+
...(spec.usage === undefined ? {} : { input: { hint: spec.usage } }),
|
|
67
|
+
}))
|
|
68
|
+
|
|
69
|
+
function pendingHandler(name: string): CommandDefinition['handler'] {
|
|
70
|
+
return ({ rawInput }): CommandResult => {
|
|
71
|
+
if (rawInput.trim() !== '') {
|
|
72
|
+
return { kind: 'error', text: `用法错误:/${name} 不接受参数。` }
|
|
73
|
+
}
|
|
74
|
+
return { kind: 'error', text: `命令 /${name} 的功能将在后续实施任务中启用。` }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readyHandler(name: string, text: string): CommandDefinition['handler'] {
|
|
79
|
+
return ({ rawInput }): CommandResult => {
|
|
80
|
+
if (rawInput.trim() !== '') {
|
|
81
|
+
return { kind: 'error', text: `用法错误:/${name} 不接受参数。` }
|
|
82
|
+
}
|
|
83
|
+
return { kind: 'success', text }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const healthHandler: CommandDefinition['handler'] = ({ rawInput }): CommandResult => {
|
|
88
|
+
if (rawInput.trim() !== '') {
|
|
89
|
+
return { kind: 'error', text: '用法错误:/health 不接受参数。' }
|
|
90
|
+
}
|
|
91
|
+
return { kind: 'success', text: '健康检查已完成。' }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type RenewOperation = (invocation: Parameters<CommandDefinition['handler']>[0]) =>
|
|
95
|
+
ReturnType<CommandDefinition['handler']>
|
|
96
|
+
|
|
97
|
+
export type EffortCommandAction =
|
|
98
|
+
| { readonly kind: 'show' }
|
|
99
|
+
| { readonly kind: 'set'; readonly value: string }
|
|
100
|
+
| { readonly kind: 'reset' }
|
|
101
|
+
|
|
102
|
+
type EffortOperation = (
|
|
103
|
+
invocation: Parameters<CommandDefinition['handler']>[0],
|
|
104
|
+
action: EffortCommandAction,
|
|
105
|
+
) => ReturnType<CommandDefinition['handler']>
|
|
106
|
+
|
|
107
|
+
/** One armed `/model` execution: lists, validates, and switches models. */
|
|
108
|
+
export type ModelOperation = (
|
|
109
|
+
invocation: Parameters<CommandDefinition['handler']>[0],
|
|
110
|
+
rawInput: string,
|
|
111
|
+
) => ReturnType<CommandDefinition['handler']>
|
|
112
|
+
|
|
113
|
+
/** Per-execution authority: handlers stay instance-neutral when an Agent is borrowed. */
|
|
114
|
+
const RENEW_OPERATIONS = new WeakMap<AbortSignal, RenewOperation>()
|
|
115
|
+
const EFFORT_OPERATIONS = new WeakMap<AbortSignal, EffortOperation>()
|
|
116
|
+
const MODEL_OPERATIONS = new WeakMap<AbortSignal, ModelOperation>()
|
|
117
|
+
|
|
118
|
+
function parseEffortAction(rawInput: string): EffortCommandAction | undefined {
|
|
119
|
+
const value = rawInput.trim()
|
|
120
|
+
if (value === '') return { kind: 'show' }
|
|
121
|
+
if (/\s/u.test(value)) return undefined
|
|
122
|
+
return value === 'reset' ? { kind: 'reset' } : { kind: 'set', value }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const effortHandler: CommandDefinition['handler'] = invocation => {
|
|
126
|
+
const action = parseEffortAction(invocation.rawInput)
|
|
127
|
+
if (action === undefined) {
|
|
128
|
+
return { kind: 'error', text: '用法错误:/effort [档位-id|reset]。' }
|
|
129
|
+
}
|
|
130
|
+
if (action.kind !== 'show' && (
|
|
131
|
+
invocation.agent.status !== 'idle'
|
|
132
|
+
|| invocation.agent.inbox.nextTurn.length > 0
|
|
133
|
+
|| invocation.agent.inbox.nextStep.length > 0
|
|
134
|
+
)) {
|
|
135
|
+
return { kind: 'error', text: '当前 Session 正忙或仍有排队消息,暂时不能切换推理强度。' }
|
|
136
|
+
}
|
|
137
|
+
const operation = EFFORT_OPERATIONS.get(invocation.signal)
|
|
138
|
+
if (operation === undefined) {
|
|
139
|
+
return { kind: 'error', text: '/effort 只能通过已授权的飞书话题执行。' }
|
|
140
|
+
}
|
|
141
|
+
return operation(invocation, action)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const modelHandler: CommandDefinition['handler'] = invocation => {
|
|
145
|
+
const rawInput = invocation.rawInput.trim()
|
|
146
|
+
if (/\s/u.test(rawInput)) {
|
|
147
|
+
return { kind: 'error', text: '用法错误:/model [模型-id|provider/model|reset]。' }
|
|
148
|
+
}
|
|
149
|
+
if (invocation.agent.status !== 'idle'
|
|
150
|
+
|| invocation.agent.inbox.nextTurn.length > 0
|
|
151
|
+
|| invocation.agent.inbox.nextStep.length > 0) {
|
|
152
|
+
return { kind: 'error', text: '当前 Session 正忙或仍有排队消息,暂时不能切换模型。' }
|
|
153
|
+
}
|
|
154
|
+
const operation = MODEL_OPERATIONS.get(invocation.signal)
|
|
155
|
+
if (operation === undefined) {
|
|
156
|
+
return { kind: 'error', text: '/model 只能通过已授权的飞书话题执行。' }
|
|
157
|
+
}
|
|
158
|
+
return operation(invocation, rawInput)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const renewHandler: CommandDefinition['handler'] = invocation => {
|
|
162
|
+
if (invocation.rawInput.trim() !== '') {
|
|
163
|
+
return { kind: 'error', text: '用法错误:/renew 不接受参数。' }
|
|
164
|
+
}
|
|
165
|
+
if (invocation.agent.status !== 'idle'
|
|
166
|
+
|| invocation.agent.inbox.nextTurn.length > 0
|
|
167
|
+
|| invocation.agent.inbox.nextStep.length > 0) {
|
|
168
|
+
return { kind: 'error', text: '当前 Session 正忙或仍有排队消息,暂时不能续建。' }
|
|
169
|
+
}
|
|
170
|
+
const operation = RENEW_OPERATIONS.get(invocation.signal)
|
|
171
|
+
if (operation === undefined) {
|
|
172
|
+
return { kind: 'error', text: '/renew 只能通过已授权的飞书话题执行。' }
|
|
173
|
+
}
|
|
174
|
+
return operation(invocation)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const READY_HANDLERS: Readonly<Record<string, CommandDefinition['handler']>> = {
|
|
178
|
+
help: readyHandler('help', supportedCommandHelp()),
|
|
179
|
+
health: healthHandler,
|
|
180
|
+
status: readyHandler('status', 'Session 状态已读取。'),
|
|
181
|
+
workspace: readyHandler('workspace', '工作目录已读取。'),
|
|
182
|
+
effort: effortHandler,
|
|
183
|
+
model: modelHandler,
|
|
184
|
+
renew: renewHandler,
|
|
185
|
+
history: readyHandler('history', 'Session 历史已读取。'),
|
|
186
|
+
export: readyHandler('export', '导出已请求。'),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Arm exactly one Feishu-issued `/renew` execution, then return its disarmer. */
|
|
190
|
+
export function armRenewCommand(signal: AbortSignal, operation: RenewOperation): () => void {
|
|
191
|
+
RENEW_OPERATIONS.set(signal, operation)
|
|
192
|
+
return () => { RENEW_OPERATIONS.delete(signal) }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Arm exactly one Feishu-issued `/effort` execution, then return its disarmer. */
|
|
196
|
+
export function armEffortCommand(signal: AbortSignal, operation: EffortOperation): () => void {
|
|
197
|
+
EFFORT_OPERATIONS.set(signal, operation)
|
|
198
|
+
return () => { EFFORT_OPERATIONS.delete(signal) }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Arm exactly one Feishu-issued `/model` execution, then return its disarmer. */
|
|
202
|
+
export function armModelCommand(signal: AbortSignal, operation: ModelOperation): () => void {
|
|
203
|
+
MODEL_OPERATIONS.set(signal, operation)
|
|
204
|
+
return () => { MODEL_OPERATIONS.delete(signal) }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Install only `/model` into a borrowed Agent scope that lacks channel commands. */
|
|
208
|
+
export function registerModelCommand(commands: CommandRuntime): () => void {
|
|
209
|
+
const descriptor = DESCRIPTORS.find(entry => entry.name === 'model')!
|
|
210
|
+
return commands.register({ ...descriptor, handler: modelHandler })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Install only `/renew` into a borrowed Agent scope that lacks channel commands. */
|
|
214
|
+
export function registerRenewCommand(commands: CommandRuntime): () => void {
|
|
215
|
+
const descriptor = DESCRIPTORS.find(entry => entry.name === 'renew')!
|
|
216
|
+
return commands.register({ ...descriptor, handler: renewHandler })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Install only `/effort` into a borrowed Agent scope that lacks channel commands. */
|
|
220
|
+
export function registerEffortCommand(commands: CommandRuntime): () => void {
|
|
221
|
+
const descriptor = DESCRIPTORS.find(entry => entry.name === 'effort')!
|
|
222
|
+
return commands.register({ ...descriptor, handler: effortHandler })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Register this channel's commands inside one agent scope. `/compact` stays
|
|
227
|
+
* global and is intentionally not registered here.
|
|
228
|
+
*/
|
|
229
|
+
export function registerChannelCommands(commands: CommandRuntime): void {
|
|
230
|
+
for (const descriptor of DESCRIPTORS) {
|
|
231
|
+
commands.register({
|
|
232
|
+
...descriptor,
|
|
233
|
+
handler: READY_HANDLERS[descriptor.name] ?? pendingHandler(descriptor.name),
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function registerScopedChannelCommands(ctx: AgentContext): void {
|
|
239
|
+
registerChannelCommands(ctx.commands)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Mount channel commands through an Agent child plugin that explicitly
|
|
244
|
+
* injects the command service. The Agent scope is minted by `agent-loop`, so
|
|
245
|
+
* it does not inherit this package's top-level dependency declaration.
|
|
246
|
+
*/
|
|
247
|
+
export async function mountChannelCommands(agentCtx: AgentContext): Promise<void> {
|
|
248
|
+
await agentCtx.inject(['commands'], registerScopedChannelCommands)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* FIFO admission queue keyed by the durable route key. Failures never poison
|
|
253
|
+
* the tail, and an idle key is retired so the map remains bounded.
|
|
254
|
+
*/
|
|
255
|
+
export class RouteOperationQueue {
|
|
256
|
+
private readonly tails = new Map<string, Promise<void>>()
|
|
257
|
+
|
|
258
|
+
async run<T>(key: string, operation: () => Promise<T> | T): Promise<T> {
|
|
259
|
+
const previous = this.tails.get(key) ?? Promise.resolve()
|
|
260
|
+
const result = previous.catch(() => {}).then(operation)
|
|
261
|
+
const tail = result.then(() => {}, () => {})
|
|
262
|
+
this.tails.set(key, tail)
|
|
263
|
+
try {
|
|
264
|
+
return await result
|
|
265
|
+
} finally {
|
|
266
|
+
if (this.tails.get(key) === tail) this.tails.delete(key)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
clear(): void {
|
|
271
|
+
this.tails.clear()
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export type CommandCardKind =
|
|
276
|
+
| 'success'
|
|
277
|
+
| 'help'
|
|
278
|
+
| 'context-error'
|
|
279
|
+
| 'input-error'
|
|
280
|
+
| 'unknown'
|
|
281
|
+
| 'unbound'
|
|
282
|
+
| 'expired'
|
|
283
|
+
| 'forbidden'
|
|
284
|
+
| 'busy'
|
|
285
|
+
| 'unavailable'
|
|
286
|
+
| 'internal-error'
|
|
287
|
+
|
|
288
|
+
export interface CommandCardState {
|
|
289
|
+
readonly kind: CommandCardKind
|
|
290
|
+
readonly command?: string
|
|
291
|
+
readonly text: string
|
|
292
|
+
readonly supported?: string
|
|
293
|
+
readonly sessionId?: string
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Map stable command outcomes onto the shared result-card vocabulary. */
|
|
297
|
+
export function commandResultCardKind(result: CommandResult): CommandCardKind {
|
|
298
|
+
if (result.kind === 'success') return 'success'
|
|
299
|
+
if (/^(?:用法错误:|Usage:)/u.test(result.text)) return 'input-error'
|
|
300
|
+
if (/\b(?:active compaction|not idle|busy)\b|正忙|正在运行/iu.test(result.text)) return 'busy'
|
|
301
|
+
return 'unavailable'
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const CARD_TITLES: Record<CommandCardKind, string> = {
|
|
305
|
+
success: '✅ 命令执行完成',
|
|
306
|
+
help: '📖 斜杠命令帮助',
|
|
307
|
+
'context-error': '⚠️ 使用场景不匹配',
|
|
308
|
+
'input-error': '⚠️ 命令格式不正确',
|
|
309
|
+
unknown: '❓ 未知命令',
|
|
310
|
+
unbound: '🔗 尚未绑定会话',
|
|
311
|
+
expired: '⌛ 话题已过期',
|
|
312
|
+
forbidden: '🔒 无权操作此会话',
|
|
313
|
+
busy: '⏳ 会话正忙',
|
|
314
|
+
unavailable: '🧩 命令暂不可用',
|
|
315
|
+
'internal-error': '❌ 命令执行失败',
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Render a small, non-streaming Feishu card that never contains raw errors. */
|
|
319
|
+
export function buildCommandResultCard(state: CommandCardState): object {
|
|
320
|
+
const elements: object[] = [
|
|
321
|
+
{ tag: 'markdown', content: `**${CARD_TITLES[state.kind]}**` },
|
|
322
|
+
{ tag: 'markdown', content: state.text },
|
|
323
|
+
]
|
|
324
|
+
if (state.supported !== undefined) {
|
|
325
|
+
elements.push({ tag: 'markdown', content: `可用命令:${state.supported}`, text_size: 'notation' })
|
|
326
|
+
}
|
|
327
|
+
if (state.command !== undefined || state.sessionId !== undefined) {
|
|
328
|
+
const trace = [
|
|
329
|
+
state.command === undefined ? undefined : `/${state.command}`,
|
|
330
|
+
state.sessionId,
|
|
331
|
+
].filter((value): value is string => value !== undefined).join(' · ')
|
|
332
|
+
elements.push({ tag: 'markdown', content: trace, text_size: 'notation' })
|
|
333
|
+
}
|
|
334
|
+
return card(elements)
|
|
335
|
+
}
|