dsh-paoding 0.2.2 → 0.3.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 +26 -22
- package/README_en.md +26 -22
- package/bin/dsh-paoding.mjs +73 -11
- package/docs/architecture.md +36 -31
- package/docs/architecture_en.md +54 -38
- package/docs/configuration.md +151 -57
- package/docs/configuration_en.md +155 -60
- package/docs/installation.md +96 -234
- package/docs/installation_en.md +111 -252
- package/docs/orchestration.md +24 -24
- package/docs/orchestration_en.md +24 -24
- package/package.json +8 -5
- package/plugins/paoding-config-ui/api-core.mjs +215 -18
- package/plugins/paoding-config-ui/index.mjs +413 -154
- package/plugins/paoding-config-ui/lib/base.css +1083 -42
- package/plugins/paoding-config-ui/lib/client.js +2266 -252
- package/plugins/paoding-config-ui/package.json +2 -2
- package/plugins/paoding-config-ui/runtime-inventory.mjs +27 -26
- package/presets/orchestrator/agent.cordis.yml +19 -9
- package/presets/orchestrator/preset.yml +3 -2
- package/presets/orchestrator/restrict.mjs +2 -1
- package/tools/install.mjs +9 -4
- package/tools/lib/alloc.mjs +60 -29
- package/tools/lib/cli.mjs +127 -279
- package/tools/lib/compose.mjs +261 -95
- package/tools/lib/config.mjs +266 -86
- package/tools/lib/host.mjs +55 -24
- package/tools/lib/skills.mjs +72 -45
- package/tools/lib/state.mjs +172 -40
- package/tools/lib/upgrade.mjs +170 -0
- package/tools/lib/util.mjs +7 -0
- package/tools/lib/version.mjs +233 -0
- package/tools/lib/workspaces.mjs +74 -0
- package/tools/lib/yaml.mjs +92 -15
- package/install.sh +0 -31
- package/tools/lib/wizard.mjs +0 -516
package/tools/lib/config.mjs
CHANGED
|
@@ -1,30 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-paoding.config.yml 键规范化与读写:
|
|
2
|
+
* dsh-paoding.config.yml 键规范化与读写:main_agent_display_name /
|
|
3
|
+
* roles.<toolName>.name /
|
|
3
4
|
* roles.<toolName>.model / roles.<toolName>.provider(角色专用模型,生成层映射到
|
|
4
|
-
* dsh-tool-subagent 的 agentOptions)/
|
|
5
|
-
*
|
|
5
|
+
* dsh-tool-subagent 的 agentOptions)/ roles.<toolName>.background_mode(可续模式,
|
|
6
|
+
* 生成层映射到 dsh-tool-subagent 的 backgroundMode,'one-shot' | 'continuable')/
|
|
7
|
+
* roles_remove 解析(normalizeRoleName /
|
|
8
|
+
* normalizeModelRef / normalizeBackgroundMode / parseRolesRemove / yamlScalar)、
|
|
9
|
+
* 目标字段集合的规范化与写出(normalizeTargetFields / writeTargetFields —— 顶层与
|
|
10
|
+
* workspaces.<path> 条目共用同一套)、按工作区条目段(normalizeWorkspaces)、
|
|
6
11
|
* 文件加载与规范化(loadConfig / normalizeConfig)、序列化与落盘(serializeConfig /
|
|
7
12
|
* saveConfig)。
|
|
8
13
|
* 依赖 util(ROLES / warn)与 yaml(preprocessPatchText / parseYamlSubset);
|
|
9
|
-
* 被 compose /
|
|
14
|
+
* 被 compose / state / cli / alloc 引用。
|
|
10
15
|
*/
|
|
11
16
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
12
17
|
import path from 'node:path'
|
|
13
18
|
import { ROLES, warn } from './util.mjs'
|
|
14
19
|
import { parseYamlSubset, preprocessPatchText } from './yaml.mjs'
|
|
15
20
|
|
|
16
|
-
// ── config keys:
|
|
21
|
+
// ── config keys: main_agent_display_name / roles.<toolName>.name / roles_remove ─────
|
|
22
|
+
|
|
23
|
+
// main_agent_name 迁移提示的每进程去重标记:normalizeConfig 在一次进程里会被
|
|
24
|
+
// 调多次(collectState 与 generateAndInstall 各 loadConfig 一遍,测试里更多),
|
|
25
|
+
// 裸 warn 会同文案刷屏;标记置位后同一进程不再重复提醒。
|
|
26
|
+
let mainAgentNameMigrationWarned = false
|
|
17
27
|
|
|
18
28
|
/**
|
|
19
|
-
* 校验并规范化一个显示名(配置键
|
|
20
|
-
* label
|
|
21
|
-
*
|
|
22
|
-
* trim 后值,trim 后 1-60 字符且原值不得含 \r \n(违规直接
|
|
23
|
-
*
|
|
24
|
-
*
|
|
29
|
+
* 校验并规范化一个显示名(配置键 main_agent_display_name 或
|
|
30
|
+
* roles.<toolName>.name,label 形参用于报错文案;现存调用点都显式传 label,
|
|
31
|
+
* 缺省 'name' 仅兜底)。null/undefined/非 string/空串 → null(= 维持默认,
|
|
32
|
+
* 不改名);否则存 trim 后值,trim 后 1-60 字符且原值不得含 \r \n(违规直接
|
|
33
|
+
* throw —— 配置错了宁可报错,也不要静默失效)。
|
|
34
|
+
* main_agent_display_name:仅 preset.yml 的 name: 行(显示名),不动 persona
|
|
35
|
+
* 身份行,报错 label 用键名本身;
|
|
25
36
|
* roles.<toolName>.name:仅作用于该内置角色 persona 走默认时的首行身份句主语。
|
|
26
37
|
*/
|
|
27
|
-
export function normalizeRoleName(value, label = '
|
|
38
|
+
export function normalizeRoleName(value, label = 'name') {
|
|
28
39
|
if (value === null || value === undefined || typeof value !== 'string') return null
|
|
29
40
|
const trimmed = value.trim()
|
|
30
41
|
if (trimmed === '') return null
|
|
@@ -37,11 +48,6 @@ export function normalizeRoleName(value, label = 'main_agent_name') {
|
|
|
37
48
|
return trimmed
|
|
38
49
|
}
|
|
39
50
|
|
|
40
|
-
/** 兼容别名:主 agent 显示名校验(main_agent_name),行为与文案逐字不变。 */
|
|
41
|
-
export function normalizeMainAgentName(value) {
|
|
42
|
-
return normalizeRoleName(value)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
51
|
/**
|
|
46
52
|
* 校验并规范化一个模型/路由引用(配置键 roles.<toolName>.model 或 .provider,
|
|
47
53
|
* label 形参用于报错文案)。行为镜像 normalizeRoleName,仅长度上限不同(120):
|
|
@@ -63,6 +69,23 @@ export function normalizeModelRef(value, label = 'model') {
|
|
|
63
69
|
return trimmed
|
|
64
70
|
}
|
|
65
71
|
|
|
72
|
+
/**
|
|
73
|
+
* 校验并规范化 roles.<toolName>.background_mode(角色可续模式,映射到
|
|
74
|
+
* dsh-tool-subagent 实例配置键 backgroundMode)。合法值仅 'one-shot' | 'continuable':
|
|
75
|
+
* 缺省/null → 缺省值 'one-shot'(静默,与 name/model/provider 的 null 口径一致);
|
|
76
|
+
* 非字符串或字符串但不在合法值集 → warn 后回落 'one-shot'(类型/取值不对降级提醒
|
|
77
|
+
* 而非中断加载,与 normalizeTargetFields 的 name/model/provider 非字符串 warn 同款)。
|
|
78
|
+
* 返回恒为合法值之一,生成层(compose)据此决定是否注入 backgroundMode 行。
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeBackgroundMode(value, label = 'background_mode') {
|
|
81
|
+
if (value === null || value === undefined) return 'one-shot'
|
|
82
|
+
if (typeof value !== 'string' || (value !== 'one-shot' && value !== 'continuable')) {
|
|
83
|
+
warn(`${label}: 非法值已忽略(${JSON.stringify(value)}),回落 'one-shot'(可选: 'one-shot' / 'continuable')`)
|
|
84
|
+
return 'one-shot'
|
|
85
|
+
}
|
|
86
|
+
return value
|
|
87
|
+
}
|
|
88
|
+
|
|
66
89
|
/**
|
|
67
90
|
* 解析配置键 roles_remove:string[],只收 ROLES 内置角色名(search_external /
|
|
68
91
|
* design / implement);含其他名字 warn 并忽略;去重保序。非数组 → []。
|
|
@@ -85,8 +108,9 @@ export function parseRolesRemove(raw) {
|
|
|
85
108
|
return out
|
|
86
109
|
}
|
|
87
110
|
|
|
88
|
-
/** 简单 YAML 标量渲染:含空格/CJK 无需引号;含 ':' / '#'
|
|
89
|
-
*
|
|
111
|
+
/** 简单 YAML 标量渲染:含空格/CJK 无需引号;含 ':' / '#'、首/尾空白、纯数字、
|
|
112
|
+
* bool/null 字面量、或首字符为 YAML 指示符(- ? : , [ ] { } # & * ! | > ' " %
|
|
113
|
+
* @ `)等歧义形态时用单引号包裹并转义内部单引号。 */
|
|
90
114
|
export function yamlScalar(value) {
|
|
91
115
|
const s = String(value)
|
|
92
116
|
const needsQuote =
|
|
@@ -95,27 +119,42 @@ export function yamlScalar(value) {
|
|
|
95
119
|
/:(\s|$)/.test(s) || // ':'(键值分隔风险)
|
|
96
120
|
/(^|\s)#/.test(s) || // '#'(行内注释风险)
|
|
97
121
|
/^[-+]?(\d+\.?\d*|\.\d+)$/.test(s) || // 纯数字形态
|
|
98
|
-
/^(true|false|null|~)$/i.test(s) // bool/null 字面量形态
|
|
122
|
+
/^(true|false|null|~)$/i.test(s) || // bool/null 字面量形态
|
|
123
|
+
/^[-?:,\[\]{}#&*!|>'"%@`]/.test(s) // 首字符 YAML 指示符(块/流集合、锚点、别名、标签、引号、保留符)
|
|
99
124
|
return needsQuote ? `'${s.replace(/'/g, "''")}'` : s
|
|
100
125
|
}
|
|
101
126
|
|
|
127
|
+
/** 流序列(`skills: [a, b]`)条目专用渲染:流上下文里 ',' '[' ']' '{' '}' 是
|
|
128
|
+
* 定界符,裸形态含它们会把序列截断成坏 YAML(块上下文合法 ≠ 流内合法)。
|
|
129
|
+
* yamlScalar 已加引号的原样返回(防双重包裹),裸形态补查流定界符。 */
|
|
130
|
+
function yamlFlowScalar(value) {
|
|
131
|
+
const raw = String(value)
|
|
132
|
+
const s = yamlScalar(raw)
|
|
133
|
+
return s === raw && /[,\[\]{}]/.test(raw) ? `'${raw.replace(/'/g, "''")}'` : s
|
|
134
|
+
}
|
|
135
|
+
|
|
102
136
|
// ── config file (dsh-paoding.config.yml) ────────────────────────────────────
|
|
103
137
|
|
|
104
138
|
/**
|
|
105
139
|
* Load the config file when present. Returns null when the file does not
|
|
106
140
|
* exist; throws when it exists but cannot be parsed. Shape:
|
|
107
|
-
* { profile, roles: { <toolName>: { name?, model?, provider?, persona?, tools[] } },
|
|
141
|
+
* { profile, roles: { <toolName>: { name?, model?, provider?, background_mode?, persona?, tools[] } },
|
|
108
142
|
* roles_remove[] (内置角色删除), main_agent_extra[], main_agent_remove[],
|
|
109
143
|
* main_agent_skills[], main_agent_skills_inline[],
|
|
110
|
-
* main_agent_persona_extra? (string|null),
|
|
111
|
-
*
|
|
144
|
+
* main_agent_persona_extra? (string|null),
|
|
145
|
+
* main_agent_display_name? (string|null),
|
|
146
|
+
* skills: { <skill>: [<toolName>...] },
|
|
147
|
+
* workspaces: { <工作区绝对路径>: <目标字段集合(同顶层、除 profile)> } }
|
|
112
148
|
*/
|
|
113
149
|
export function loadConfig(file, yamlMod) {
|
|
114
150
|
let text
|
|
115
151
|
try {
|
|
116
152
|
text = readFileSync(file, 'utf8')
|
|
117
|
-
} catch {
|
|
118
|
-
|
|
153
|
+
} catch (err) {
|
|
154
|
+
// 仅「文件不存在」视为无配置(返回 null);其余读错误(权限 / EIO / 是目录等)
|
|
155
|
+
// 原样上抛 —— 静默吞掉会让调用方把损坏状态当「fresh」继续破坏性落盘。
|
|
156
|
+
if (err.code === 'ENOENT') return null
|
|
157
|
+
throw err
|
|
119
158
|
}
|
|
120
159
|
const processed = preprocessPatchText(text)
|
|
121
160
|
let data = null
|
|
@@ -140,25 +179,95 @@ export function loadConfig(file, yamlMod) {
|
|
|
140
179
|
}
|
|
141
180
|
|
|
142
181
|
export function normalizeConfig(data) {
|
|
182
|
+
// 迁移提示(每进程至多一次,见下方模块级标记):老配置残留的 main_agent_name
|
|
183
|
+
// 键已随「主 agent 改名」特性整体下线 —— 该键曾一改两处(preset 显示名 +
|
|
184
|
+
// persona 身份行),改名会替换编排主 agent 的身份语义,故不再读取也不再返回;
|
|
185
|
+
// 残留行在下次「保存并应用」落盘时自然消失。
|
|
186
|
+
if (!mainAgentNameMigrationWarned && typeof data?.main_agent_name === 'string' && data.main_agent_name.trim() !== '') {
|
|
187
|
+
mainAgentNameMigrationWarned = true
|
|
188
|
+
warn('main_agent_name 已移除:编排主 agent 身份不再支持改名,该键已忽略')
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
profile: typeof data.profile === 'string' && data.profile !== '' ? data.profile : null,
|
|
192
|
+
...normalizeTargetFields(data),
|
|
193
|
+
workspaces: normalizeWorkspaces(data.workspaces),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 工作区条目段(顶层键 workspaces)规范化:只收 trim 后非空的字符串键(键 =
|
|
199
|
+
* 工作区目录绝对路径),值过 normalizeTargetFields;值不是映射、键为空串则
|
|
200
|
+
* warn 并跳过。没有 workspaces 键 / 不是映射 → 空对象(条目缺失不算配置
|
|
201
|
+
* 错误,老配置零感升级)。
|
|
202
|
+
*/
|
|
203
|
+
function normalizeWorkspaces(raw) {
|
|
204
|
+
const out = {}
|
|
205
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out
|
|
206
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
207
|
+
const wsPath = typeof key === 'string' ? key.trim() : ''
|
|
208
|
+
if (wsPath === '') {
|
|
209
|
+
warn('workspaces: 忽略空路径键')
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
213
|
+
warn(`workspaces: '${wsPath}' 的条目不是映射,已忽略`)
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
out[wsPath] = normalizeTargetFields(value)
|
|
217
|
+
}
|
|
218
|
+
return out
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 目标字段集合规范化:顶层(除 profile 外)与 workspaces.<path> 条目共用的
|
|
223
|
+
* 同一套键 —— roles(条目含 background_mode)/ roles_remove / main_agent_extra(含 has_main_agent_extra
|
|
224
|
+
* 内部标记)/ main_agent_remove / main_agent_skills / main_agent_skills_inline /
|
|
225
|
+
* main_agent_persona_extra / main_agent_display_name / skills。
|
|
226
|
+
* 语义与抽取前逐字一致;
|
|
227
|
+
* 非映射输入按空对象处理(工作区条目已先过映射校验,这里只为生成层把外部
|
|
228
|
+
* 传入的 assignments 再规范一次时兜底)。
|
|
229
|
+
*/
|
|
230
|
+
export function normalizeTargetFields(raw) {
|
|
231
|
+
const data = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
|
|
143
232
|
const roles = {}
|
|
144
233
|
const rawRoles = data.roles && typeof data.roles === 'object' && !Array.isArray(data.roles) ? data.roles : {}
|
|
145
234
|
for (const [toolName, raw] of Object.entries(rawRoles)) {
|
|
146
235
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
|
|
147
236
|
const tools = Array.isArray(raw.tools) ? raw.tools.filter((t) => typeof t === 'string' && t !== '') : null
|
|
148
|
-
//
|
|
149
|
-
//
|
|
237
|
+
// 非字符串(且非缺省/null)的 name/model/provider:warn 后置 null,不 throw
|
|
238
|
+
// —— 静默吞错改为可见提醒,但不中断整体加载(与字符串违规 throw 的口径
|
|
239
|
+
// 并行:格式非法宁报错,类型不对降级提醒)。
|
|
240
|
+
for (const key of ['name', 'model', 'provider']) {
|
|
241
|
+
const v = raw[key]
|
|
242
|
+
if (v !== undefined && v !== null && typeof v !== 'string') {
|
|
243
|
+
warn(`roles.${toolName}.${key}: 非字符串值已忽略(${JSON.stringify(v)})`)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// 角色条目字段顺序 { name?, model?, provider?, background_mode?, persona?, tools? }:
|
|
247
|
+
// name(roles.<toolName>.name,内置角色可选显示名)与主 agent 显示名同约束,
|
|
150
248
|
// 仅字符串收(trim 后空/违规由 normalizeRoleName 处理);缺省/null → null
|
|
151
249
|
// (= 保持该角色默认身份句)。model / provider(roles.<toolName>.model /
|
|
152
250
|
// .provider,角色专用模型)镜像同款收法,违规由 normalizeModelRef 处理;
|
|
153
251
|
// 缺省/null → null(= 不注入 agentOptions,子 agent 继承主 agent 当前模型)。
|
|
252
|
+
// background_mode(roles.<toolName>.background_mode,角色可续模式)仅收
|
|
253
|
+
// 'one-shot' | 'continuable',其余 warn 回落 'one-shot'(normalizeBackgroundMode)。
|
|
154
254
|
roles[toolName] = {
|
|
155
255
|
name: typeof raw.name === 'string' ? normalizeRoleName(raw.name, `roles.${toolName}.name`) : null,
|
|
156
256
|
model: typeof raw.model === 'string' ? normalizeModelRef(raw.model, `roles.${toolName}.model`) : null,
|
|
157
257
|
provider: typeof raw.provider === 'string' ? normalizeModelRef(raw.provider, `roles.${toolName}.provider`) : null,
|
|
258
|
+
background_mode: normalizeBackgroundMode(raw.background_mode, `roles.${toolName}.background_mode`),
|
|
158
259
|
persona: typeof raw.persona === 'string' && raw.persona.trim() !== '' ? raw.persona : null,
|
|
159
260
|
tools,
|
|
160
261
|
}
|
|
161
262
|
}
|
|
263
|
+
// main_agent_extra 键存在性(fix:写盘时「原配置该键是否存在」语义的依据):
|
|
264
|
+
// 输入带 has_main_agent_extra 标记(已归一化对象再归一化,UI/CLI/生成层二次
|
|
265
|
+
// 规范)时标记优先 —— 此时数据里恒为数组,只看数组会把「键缺失」误判成
|
|
266
|
+
// 「显式存在」,保存时把老配置物化出 `main_agent_extra: []`,读写往返丢语义;
|
|
267
|
+
// 裸输入(手编配置 / 临时 assignments)回落 Array.isArray 判原始键。
|
|
268
|
+
const hasExtra = 'has_main_agent_extra' in data
|
|
269
|
+
? data.has_main_agent_extra === true
|
|
270
|
+
: Array.isArray(data.main_agent_extra)
|
|
162
271
|
const extra = Array.isArray(data.main_agent_extra)
|
|
163
272
|
? data.main_agent_extra.filter((t) => typeof t === 'string' && t !== '')
|
|
164
273
|
: []
|
|
@@ -182,112 +291,183 @@ export function normalizeConfig(data) {
|
|
|
182
291
|
const mainPersonaExtra = typeof data.main_agent_persona_extra === 'string' ? data.main_agent_persona_extra : null
|
|
183
292
|
// 内置角色删除(roles_remove):只收 ROLES 内置名,其他 warn 忽略、去重。
|
|
184
293
|
const rolesRemove = parseRolesRemove(data.roles_remove)
|
|
185
|
-
// 主 agent
|
|
186
|
-
//
|
|
187
|
-
|
|
294
|
+
// 主 agent 显示名(main_agent_display_name):仅作用于 preset.yml 显示名,
|
|
295
|
+
// 报错 label 用键名本身;typeof string 且 trim 后非空才收,否则 null(= 默认
|
|
296
|
+
// 显示名「编排模式 (Orchestrator)」,工作区预设用派生名)。persona 身份行
|
|
297
|
+
// 不可配置 —— 编排主 agent 的身份语义不属于可改项。
|
|
298
|
+
const mainAgentDisplayName =
|
|
299
|
+
typeof data.main_agent_display_name === 'string'
|
|
300
|
+
? normalizeRoleName(data.main_agent_display_name, 'main_agent_display_name')
|
|
301
|
+
: null
|
|
188
302
|
return {
|
|
189
|
-
profile: typeof data.profile === 'string' && data.profile !== '' ? data.profile : null,
|
|
190
303
|
roles,
|
|
191
304
|
roles_remove: rolesRemove,
|
|
192
305
|
main_agent_extra: extra,
|
|
193
|
-
// 内部标记:键 main_agent_extra
|
|
194
|
-
// resolveAssignments 据此区分「显式 [] =
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
306
|
+
// 内部标记:键 main_agent_extra 是否显式存在(含显式空列表;再归一化时
|
|
307
|
+
// 保留原值,见上方 hasExtra)。resolveAssignments 据此区分「显式 [] = 尊重
|
|
308
|
+
// 空」与「键缺失 = 老配置,回填智能默认」;writeTargetFields 据此决定是否
|
|
309
|
+
// 落盘该键 —— 键缺失(老配置)保存后仍无此键(而非物化成 `[]`),显式存在
|
|
310
|
+
// 才落盘(显式空写 `main_agent_extra: []`),读写 往返语义才存活。
|
|
311
|
+
has_main_agent_extra: hasExtra,
|
|
198
312
|
main_agent_remove: remove,
|
|
199
313
|
main_agent_skills: mainAgentSkills,
|
|
200
314
|
main_agent_skills_inline: mainAgentSkillsInline,
|
|
201
315
|
main_agent_persona_extra: mainPersonaExtra,
|
|
202
|
-
|
|
316
|
+
main_agent_display_name: mainAgentDisplayName,
|
|
203
317
|
skills,
|
|
204
318
|
}
|
|
205
319
|
}
|
|
206
320
|
|
|
207
|
-
/**
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
321
|
+
/**
|
|
322
|
+
* 剥掉 assignments 里不属于目标字段集合的两个容器键(profile / workspaces),
|
|
323
|
+
* 浅拷贝返回;两键都不在场时原样返回。生成与存盘前一律过这道——UI/CLI 透传
|
|
324
|
+
* 的 assignments 若混入了这两个键(如把整份 existing 配置直接当 assignments),
|
|
325
|
+
* 也不能污染生成的 preset 或覆盖掉配置文件里对应的段。
|
|
326
|
+
*/
|
|
327
|
+
export function stripNonTargetKeys(assignments) {
|
|
328
|
+
if (!assignments || typeof assignments !== 'object') return assignments
|
|
329
|
+
if (!('profile' in assignments) && !('workspaces' in assignments)) return assignments
|
|
330
|
+
const out = { ...assignments }
|
|
331
|
+
delete out.profile
|
|
332
|
+
delete out.workspaces
|
|
333
|
+
return out
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 目标字段集合(roles → skills,除
|
|
338
|
+
* profile / workspaces 外的全部顶层键)写出体:pad 为该层缩进前缀(顶层 ''、workspaces.<path> 条目里
|
|
339
|
+
* ' '),行文本与字段序和拆分前逐字一致,顶层输出零变化
|
|
340
|
+
*(唯一例外见下方 main_agent_extra:键缺失的老配置保存后仍缺省,不再物化 `[]`)。
|
|
341
|
+
*/
|
|
342
|
+
function writeTargetFields(lines, pad, target) {
|
|
343
|
+
// main_agent_display_name:仅非 null 写出(null/缺省 = preset 显示名用默认
|
|
344
|
+
// 「编排模式 (Orchestrator)」,工作区预设用派生名);写出前过一次
|
|
345
|
+
// normalizeRoleName(防手工构造的 config 绕过规范化),字段序固定。
|
|
346
|
+
if (target.main_agent_display_name !== null && target.main_agent_display_name !== undefined) {
|
|
347
|
+
const displayName = normalizeRoleName(target.main_agent_display_name, 'main_agent_display_name')
|
|
348
|
+
if (displayName !== null) lines.push(`${pad}main_agent_display_name: ${yamlScalar(displayName)}`)
|
|
215
349
|
}
|
|
216
|
-
lines.push(
|
|
217
|
-
for (const [toolName, role] of Object.entries(
|
|
218
|
-
lines.push(
|
|
350
|
+
lines.push(`${pad}roles:`)
|
|
351
|
+
for (const [toolName, role] of Object.entries(target.roles)) {
|
|
352
|
+
lines.push(`${pad} ${toolName}:`)
|
|
219
353
|
// roles.<toolName>.name:仅非 null 写出(null/缺省 = 保持该角色默认身份句);
|
|
220
354
|
// 写在该角色 persona / tools 之前,缩进 4。
|
|
221
355
|
if (role.name !== null && role.name !== undefined) {
|
|
222
356
|
const name = normalizeRoleName(role.name, `roles.${toolName}.name`)
|
|
223
|
-
if (name !== null) lines.push(
|
|
357
|
+
if (name !== null) lines.push(`${pad} name: ${yamlScalar(name)}`)
|
|
224
358
|
}
|
|
225
359
|
// roles.<toolName>.model / .provider:仅非 null 写出(null/缺省 = 不注入
|
|
226
360
|
// agentOptions,子 agent 继承主 agent 当前模型);写前过一次 normalizeModelRef
|
|
227
361
|
// (与上方 name 写法一致,防手工构造的 config 绕过规范化),字段序 name →
|
|
228
|
-
// model → provider → persona → tools 固定。
|
|
362
|
+
// model → provider → background_mode → persona → tools 固定。
|
|
229
363
|
if (role.model !== null && role.model !== undefined) {
|
|
230
364
|
const model = normalizeModelRef(role.model, `roles.${toolName}.model`)
|
|
231
|
-
if (model !== null) lines.push(
|
|
365
|
+
if (model !== null) lines.push(`${pad} model: ${yamlScalar(model)}`)
|
|
232
366
|
}
|
|
233
367
|
if (role.provider !== null && role.provider !== undefined) {
|
|
234
368
|
const provider = normalizeModelRef(role.provider, `roles.${toolName}.provider`)
|
|
235
|
-
if (provider !== null) lines.push(
|
|
369
|
+
if (provider !== null) lines.push(`${pad} provider: ${yamlScalar(provider)}`)
|
|
236
370
|
}
|
|
371
|
+
// roles.<toolName>.background_mode:恒写出(含缺省 one-shot)—— 配置文件里
|
|
372
|
+
// 旋钮对用户可见、读写往返幂等(loadConfig 也恒收此键)。写前过一次判定
|
|
373
|
+
// (防手工构造的 config 绕过规范化):非 'continuable' 一律按 'one-shot' 落盘。
|
|
374
|
+
lines.push(`${pad} background_mode: ${role.background_mode === 'continuable' ? 'continuable' : 'one-shot'}`)
|
|
237
375
|
if (role.persona !== null && role.persona !== undefined) {
|
|
238
|
-
lines.push(
|
|
239
|
-
for (const line of String(role.persona).split('\n')) lines.push(
|
|
376
|
+
lines.push(`${pad} persona: |-`)
|
|
377
|
+
for (const line of String(role.persona).split('\n')) lines.push(`${pad} ${line}`)
|
|
240
378
|
}
|
|
241
379
|
if (role.tools !== null && role.tools !== undefined) {
|
|
242
|
-
lines.push(
|
|
243
|
-
|
|
380
|
+
lines.push(`${pad} tools:`)
|
|
381
|
+
// 工具名过 yamlScalar:含 ':' / '#' / 首字符指示符的名字裸写会产出坏 YAML。
|
|
382
|
+
for (const tool of role.tools) lines.push(`${pad} - ${yamlScalar(tool)}`)
|
|
244
383
|
}
|
|
245
384
|
}
|
|
246
|
-
if (
|
|
247
|
-
lines.push(
|
|
248
|
-
for (const role of
|
|
385
|
+
if (target.roles_remove && target.roles_remove.length > 0) {
|
|
386
|
+
lines.push(`${pad}roles_remove:`)
|
|
387
|
+
for (const role of target.roles_remove) lines.push(`${pad} - ${role}`)
|
|
249
388
|
}
|
|
250
|
-
// main_agent_extra
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
389
|
+
// main_agent_extra:「原配置显式写了键才写」(has_main_agent_extra 标记,见
|
|
390
|
+
// normalizeTargetFields 的 hasExtra;未过 normalize 的裸对象回落「数组即写」
|
|
391
|
+
// 旧判,标记显式 false = 键缺失 → 整键省略)。显式存在:length>0 走多行块,
|
|
392
|
+
// 显式空数组写 `main_agent_extra: []` —— 显式空必须落盘成键,否则下次加载
|
|
393
|
+
// 键缺失,resolveAssignments 会把空列表当老配置用智能默认回填,用户「明确
|
|
394
|
+
// 不要 host 工具」的意愿就丢了。老配置无此键 → 保存后仍无此键(而非 [])。
|
|
395
|
+
if (Array.isArray(target.main_agent_extra) && target.has_main_agent_extra !== false) {
|
|
396
|
+
if (target.main_agent_extra.length > 0) {
|
|
397
|
+
lines.push(`${pad}main_agent_extra:`)
|
|
398
|
+
for (const tool of target.main_agent_extra) lines.push(`${pad} - ${yamlScalar(tool)}`)
|
|
258
399
|
} else {
|
|
259
|
-
lines.push(
|
|
400
|
+
lines.push(`${pad}main_agent_extra: []`)
|
|
260
401
|
}
|
|
261
402
|
}
|
|
262
|
-
if (
|
|
263
|
-
lines.push(
|
|
264
|
-
for (const tool of
|
|
403
|
+
if (target.main_agent_remove && target.main_agent_remove.length > 0) {
|
|
404
|
+
lines.push(`${pad}main_agent_remove:`)
|
|
405
|
+
for (const tool of target.main_agent_remove) lines.push(`${pad} - ${tool}`)
|
|
265
406
|
}
|
|
266
|
-
if (
|
|
267
|
-
lines.push(
|
|
268
|
-
for (const skill of
|
|
407
|
+
if (target.main_agent_skills && target.main_agent_skills.length > 0) {
|
|
408
|
+
lines.push(`${pad}main_agent_skills:`)
|
|
409
|
+
for (const skill of target.main_agent_skills) lines.push(`${pad} - ${skill}`)
|
|
269
410
|
}
|
|
270
|
-
if (
|
|
271
|
-
lines.push(
|
|
272
|
-
for (const skill of
|
|
411
|
+
if (target.main_agent_skills_inline && target.main_agent_skills_inline.length > 0) {
|
|
412
|
+
lines.push(`${pad}main_agent_skills_inline:`)
|
|
413
|
+
for (const skill of target.main_agent_skills_inline) lines.push(`${pad} - ${skill}`)
|
|
273
414
|
}
|
|
274
415
|
// main_agent_persona_extra:只在非 null 时写出 —— '' 也写(= 明确清空,不追加任何
|
|
275
416
|
// 内容),区别于缺省(= compose 回落默认常量)。
|
|
276
|
-
if (
|
|
277
|
-
const value = String(
|
|
417
|
+
if (target.main_agent_persona_extra !== null && target.main_agent_persona_extra !== undefined) {
|
|
418
|
+
const value = String(target.main_agent_persona_extra)
|
|
278
419
|
if (value === '') {
|
|
279
|
-
lines.push(
|
|
420
|
+
lines.push(`${pad}main_agent_persona_extra: ''`)
|
|
280
421
|
} else {
|
|
281
|
-
lines.push(
|
|
282
|
-
for (const line of value.split('\n')) lines.push(
|
|
422
|
+
lines.push(`${pad}main_agent_persona_extra: |-`)
|
|
423
|
+
for (const line of value.split('\n')) lines.push(`${pad} ${line}`)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (Object.keys(target.skills).length > 0) {
|
|
427
|
+
lines.push(`${pad}skills:`)
|
|
428
|
+
for (const [skill, roleList] of Object.entries(target.skills)) {
|
|
429
|
+
// 技能名(键,块上下文)过 yamlScalar;角色名是流序列项,须过
|
|
430
|
+
// yamlFlowScalar —— 流上下文里 ',' '[' ']' 等是定界符,裸写会截断序列。
|
|
431
|
+
lines.push(`${pad} ${yamlScalar(skill)}: [${roleList.map((r) => yamlFlowScalar(r)).join(', ')}]`)
|
|
283
432
|
}
|
|
284
433
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* 工作区条目段写出(永远追加在 skills 之后、文件末尾)。路径键一律单引号
|
|
438
|
+
* 包裹:路径可含 ':' / '#' 等会被键值切分或行内注释截断的歧义字符,裸写有
|
|
439
|
+
* 歧义;内部单引号按 YAML 规则转义成 ''。键序按路径字符串排序,输出确定。
|
|
440
|
+
*/
|
|
441
|
+
function writeWorkspaces(lines, workspaces) {
|
|
442
|
+
const wsPaths = Object.keys(workspaces)
|
|
443
|
+
.filter((p) => typeof p === 'string' && p.trim() !== '')
|
|
444
|
+
.sort()
|
|
445
|
+
if (wsPaths.length === 0) return
|
|
446
|
+
lines.push('workspaces:')
|
|
447
|
+
for (const wsPath of wsPaths) {
|
|
448
|
+
lines.push(` '${wsPath.replace(/'/g, "''")}':`)
|
|
449
|
+
const entry = workspaces[wsPath]
|
|
450
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
451
|
+
// 过一层 normalizeTargetFields:手编文件里缺 roles / skills 等容器的条目
|
|
452
|
+
// 也能安全写出(对已归一化的条目是恒等变换,零影响)。
|
|
453
|
+
writeTargetFields(lines, ' ', normalizeTargetFields(entry))
|
|
289
454
|
}
|
|
290
455
|
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Serialize the config back to YAML (fixed structure, hand-rolled writer). */
|
|
459
|
+
export function serializeConfig(config) {
|
|
460
|
+
const lines = ['# dsh-paoding(庖丁)安装配置 —— 可手编;改后在 设置 → 庖丁配置 应用,或重跑 node tools/install.mjs --auto']
|
|
461
|
+
lines.push(`profile: ${config.profile ?? 'web'}`)
|
|
462
|
+
writeTargetFields(lines, '', config)
|
|
463
|
+
// workspaces 段可选:键缺失 / 空对象整段省略(不含 workspaces 的配置输出与
|
|
464
|
+
// 旧版逐字节一致)。
|
|
465
|
+
writeWorkspaces(
|
|
466
|
+
lines,
|
|
467
|
+
config.workspaces && typeof config.workspaces === 'object' && !Array.isArray(config.workspaces)
|
|
468
|
+
? config.workspaces
|
|
469
|
+
: {},
|
|
470
|
+
)
|
|
291
471
|
return lines.join('\n') + '\n'
|
|
292
472
|
}
|
|
293
473
|
|
package/tools/lib/host.mjs
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 主机层(host)工具检测与 MCP 工具名解析:KNOWN_MCP_TOOLS / KNOWN_HOST_PLUGINS
|
|
3
|
-
*
|
|
3
|
+
* 静态表(前者仅作握手失败时的降级回落,返回对象带 warning)、JSON-RPC
|
|
4
|
+
* initialize + tools/list 握手(stdio / streamable-http /
|
|
4
5
|
* postMcp / parseHttpMessage)、host 相关判定与文案(isHostDependent /
|
|
5
6
|
* pluginLabel / removedReason)与错误收尾(sanitizeError / truncate)。
|
|
6
7
|
* 仅依赖 node:child_process(spawn);fetch / AbortSignal 为 Node 全局。
|
|
7
|
-
* 被 state / compose /
|
|
8
|
+
* 被 state / compose / cli / alloc 引用。
|
|
8
9
|
*/
|
|
9
10
|
import { spawn } from 'node:child_process'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Exact tool names of well-known MCP servers (restrict() does not support
|
|
13
|
-
* globs, so the names must be spelled out). Servers listed here
|
|
14
|
-
*
|
|
14
|
+
* globs, so the names must be spelled out). Servers listed here still go
|
|
15
|
+
* through a live handshake first; this table is only the degraded fallback
|
|
16
|
+
* when the handshake fails (unreachable / unsupported transport), so a
|
|
17
|
+
* temporary outage does not silently drop their tools.
|
|
15
18
|
*/
|
|
16
19
|
export const KNOWN_MCP_TOOLS = {
|
|
17
20
|
tavily: [
|
|
@@ -60,7 +63,12 @@ export function isMcpEntry(entry) {
|
|
|
60
63
|
const serverName = typeof cfg.serverName === 'string' ? cfg.serverName : ''
|
|
61
64
|
const transport = typeof cfg.transport === 'string' ? cfg.transport : ''
|
|
62
65
|
const hasCommand = typeof cfg.command === 'string' && cfg.command.length > 0
|
|
63
|
-
|
|
66
|
+
// 精确匹配(包名段相等):endsWith 会把 'not-dsh-mcp-client-foo' 这类恰好
|
|
67
|
+
// 同后缀的名字误判成 MCP 条目。unscoped 取首段;scoped 包(@scope/pkg)的
|
|
68
|
+
// 包名是 @ 后那段;带子路径时同样取包名所在段。
|
|
69
|
+
const segments = name.split('/')
|
|
70
|
+
const pkgName = segments[0].startsWith('@') ? (segments[1] ?? '') : (segments[0] ?? '')
|
|
71
|
+
if (name === 'dsh-mcp-client' || pkgName === 'dsh-mcp-client') return true
|
|
64
72
|
return serverName !== '' && (['stdio', 'streamable-http', 'sse'].includes(transport) || hasCommand)
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -96,12 +104,13 @@ export function truncate(text, max) {
|
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
/**
|
|
99
|
-
* Resolve the exact tool names of one MCP server.
|
|
100
|
-
*
|
|
107
|
+
* Resolve the exact tool names of one MCP server. A live JSON-RPC handshake
|
|
108
|
+
* first (工具面随服务器实际版本变化,静态表可能过时);握手失败才回落静态表
|
|
109
|
+
* (仅 KNOWN_MCP_TOOLS 内的服务器,降级不丢工具),返回对象带 `warning` 字段
|
|
110
|
+
* 供上游报告可见。 Never throws: unreachable unknown servers return
|
|
111
|
+
* { tools: [], error }.
|
|
101
112
|
*/
|
|
102
113
|
export async function resolveMCP(mcp) {
|
|
103
|
-
const known = KNOWN_MCP_TOOLS[mcp.serverName]
|
|
104
|
-
if (known) return { tools: [...known], source: 'static' }
|
|
105
114
|
try {
|
|
106
115
|
if (mcp.transport === 'stdio') {
|
|
107
116
|
if (!mcp.command) throw new Error('stdio transport requires config.command')
|
|
@@ -116,7 +125,12 @@ export async function resolveMCP(mcp) {
|
|
|
116
125
|
}
|
|
117
126
|
throw new Error(`unsupported transport: ${mcp.transport ?? '(none)'}`)
|
|
118
127
|
} catch (err) {
|
|
119
|
-
|
|
128
|
+
const message = sanitizeError(err?.message ?? err)
|
|
129
|
+
// 已知服务器握手失败:回落静态表,降级不丢工具;warning 让上游报告
|
|
130
|
+
// 能看见「这是过时的静态表,不是实时握手结果」。
|
|
131
|
+
const known = KNOWN_MCP_TOOLS[mcp.serverName]
|
|
132
|
+
if (known) return { tools: [...known], source: 'static', warning: `static table used; server unreachable (${message})` }
|
|
133
|
+
return { tools: [], source: 'failed', error: message }
|
|
120
134
|
}
|
|
121
135
|
}
|
|
122
136
|
|
|
@@ -138,22 +152,14 @@ export function handshakeStdio(mcp) {
|
|
|
138
152
|
if (settled) return
|
|
139
153
|
settled = true
|
|
140
154
|
clearTimeout(timer)
|
|
141
|
-
|
|
142
|
-
child.kill('SIGTERM')
|
|
143
|
-
} catch {
|
|
144
|
-
/* already gone */
|
|
145
|
-
}
|
|
155
|
+
killChild()
|
|
146
156
|
reject(new Error(sanitizeError(message)))
|
|
147
157
|
}
|
|
148
158
|
function finish(tools) {
|
|
149
159
|
if (settled) return
|
|
150
160
|
settled = true
|
|
151
161
|
clearTimeout(timer)
|
|
152
|
-
|
|
153
|
-
child.kill('SIGTERM')
|
|
154
|
-
} catch {
|
|
155
|
-
/* already gone */
|
|
156
|
-
}
|
|
162
|
+
killChild()
|
|
157
163
|
resolve(tools)
|
|
158
164
|
}
|
|
159
165
|
function send(payload) {
|
|
@@ -163,6 +169,23 @@ export function handshakeStdio(mcp) {
|
|
|
163
169
|
/* the exit handler will surface the failure */
|
|
164
170
|
}
|
|
165
171
|
}
|
|
172
|
+
// kill 后挂一次性 close 清理:销毁残留 stdio 流(幂等),调用方不等 close
|
|
173
|
+
// 返回也不残留句柄、不把事件循环挂住。
|
|
174
|
+
function killChild() {
|
|
175
|
+
try {
|
|
176
|
+
child.kill('SIGTERM')
|
|
177
|
+
} catch {
|
|
178
|
+
/* already gone */
|
|
179
|
+
}
|
|
180
|
+
child.once('close', () => {
|
|
181
|
+
child.stdin.destroy()
|
|
182
|
+
child.stdout.destroy()
|
|
183
|
+
child.stderr.destroy()
|
|
184
|
+
})
|
|
185
|
+
child.stdin.destroy()
|
|
186
|
+
child.stdout.destroy()
|
|
187
|
+
child.stderr.destroy()
|
|
188
|
+
}
|
|
166
189
|
|
|
167
190
|
child.stdout.on('data', (chunk) => {
|
|
168
191
|
stdoutBuf += chunk.toString()
|
|
@@ -178,6 +201,12 @@ export function handshakeStdio(mcp) {
|
|
|
178
201
|
continue
|
|
179
202
|
}
|
|
180
203
|
if (msg.id === 1) {
|
|
204
|
+
// initialize 失败必须立刻走 fail 路径:继续发 tools/list 只会把错误
|
|
205
|
+
// 归因到 tools/list(或超时),掩盖真实失败原因。
|
|
206
|
+
if (msg.error) {
|
|
207
|
+
fail(`initialize error: ${JSON.stringify(msg.error).slice(0, 300)}`)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
181
210
|
send(LIST_REQUEST)
|
|
182
211
|
} else if (msg.id === 2) {
|
|
183
212
|
if (msg.error) {
|
|
@@ -260,9 +289,11 @@ export function parseHttpMessage(text) {
|
|
|
260
289
|
}
|
|
261
290
|
|
|
262
291
|
/**
|
|
263
|
-
* Whether `name` can only be provided by the host patch layer.
|
|
264
|
-
*
|
|
265
|
-
*
|
|
292
|
+
* Whether `name` can only be provided by the host patch layer. These names
|
|
293
|
+
* survive generation only when actually detected; every other name must at
|
|
294
|
+
* least belong to the preset universe (restrict base ∪ the preset's own role
|
|
295
|
+
* allow lists) or be detected — both halves are enforced by the compose layer
|
|
296
|
+
* (usableWith in compose.mjs).
|
|
266
297
|
*/
|
|
267
298
|
export function isHostDependent(name) {
|
|
268
299
|
if (name.startsWith('mcp__')) return true
|
|
@@ -284,5 +315,5 @@ export function removedReason(name, mcpReports) {
|
|
|
284
315
|
}
|
|
285
316
|
const plugin = KNOWN_HOST_PLUGINS.find((p) => p.tools.includes(name))
|
|
286
317
|
if (plugin) return `${pluginLabel(plugin)} 已停用`
|
|
287
|
-
return '
|
|
318
|
+
return '不在 preset 自带工具面与检测库存中(对应插件 / MCP 未启用,或名字有误),已移除'
|
|
288
319
|
}
|