dsh-paoding 0.2.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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/README_en.md +95 -0
  4. package/bin/dsh-paoding.mjs +12 -0
  5. package/cordis.patch.yml +6 -0
  6. package/docs/architecture.md +292 -0
  7. package/docs/architecture_en.md +363 -0
  8. package/docs/configuration.md +551 -0
  9. package/docs/configuration_en.md +560 -0
  10. package/docs/images/1.png +0 -0
  11. package/docs/images/2.png +0 -0
  12. package/docs/images/3.png +0 -0
  13. package/docs/images/4.png +0 -0
  14. package/docs/images/5.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/installation.md +364 -0
  17. package/docs/installation_en.md +367 -0
  18. package/docs/orchestration.md +254 -0
  19. package/docs/orchestration_en.md +255 -0
  20. package/install.sh +31 -0
  21. package/package.json +29 -0
  22. package/plugins/paoding-config-ui/api-core.mjs +100 -0
  23. package/plugins/paoding-config-ui/index.mjs +327 -0
  24. package/plugins/paoding-config-ui/lib/base.css +615 -0
  25. package/plugins/paoding-config-ui/lib/client.js +1154 -0
  26. package/plugins/paoding-config-ui/package.json +23 -0
  27. package/plugins/paoding-config-ui/runtime-inventory.mjs +212 -0
  28. package/presets/orchestrator/agent.cordis.yml +323 -0
  29. package/presets/orchestrator/preset.yml +6 -0
  30. package/presets/orchestrator/restrict.mjs +191 -0
  31. package/tools/install.mjs +24 -0
  32. package/tools/lib/alloc.mjs +138 -0
  33. package/tools/lib/cli.mjs +522 -0
  34. package/tools/lib/compose.mjs +647 -0
  35. package/tools/lib/config.mjs +297 -0
  36. package/tools/lib/host.mjs +288 -0
  37. package/tools/lib/skills.mjs +146 -0
  38. package/tools/lib/spans.mjs +188 -0
  39. package/tools/lib/state.mjs +328 -0
  40. package/tools/lib/util.mjs +26 -0
  41. package/tools/lib/wizard.mjs +516 -0
  42. package/tools/lib/yaml.mjs +266 -0
@@ -0,0 +1,647 @@
1
+ /**
2
+ * 生成期合成与改写:restrict.mjs 主 agent allow 基础提取(extractMainAgentAllow)、
3
+ * persona 渲染 / 尾部追加(renderPersona / personaDefaultText / withSkillGuidance /
4
+ * appendToMainPersona)、角色显示名身份句替换(roleIdentitySubject /
5
+ * replaceRoleIdentity,roles.<toolName>.name 生效路径)、自定义角色委派块
6
+ * (renderCustomRoleBlocks / firstLine / insertCustomRoles)、roles_remove 委派行
7
+ * span 删除(delegationRowSpan / composeMainPersonaEdit)、角色专用模型注入
8
+ * (injectRoleAgentOptions,roles.<toolName>.model / .provider → 内置角色委派块
9
+ * 的 agentOptions 子块)、restrict config.allow 注入(computeRestrictAllow /
10
+ * injectRestrictAllow / filterHostTools)与整文 compose(composeGenerated)。
11
+ * 自定义角色另有两道防护/自动化:tools 为空时拒绝安装零工具角色
12
+ * (renderCustomRoleBlocks 直接 throw,防 `toolFilter.allow:` 空白名单);并把
13
+ * 角色 persona 首行职责句自动追加为主 agent persona 委派行
14
+ * (composeMainPersonaEdit 第 4 参 customBullets → `- <职责>: delegate to
15
+ * <toolName>.`),否则主 agent 永远不会委派给自定义角色。
16
+ * 依赖 util(ROLES / warn / DEFAULT_MAIN_AGENT_PERSONA_EXTRA)、config
17
+ * (normalizeMainAgentName / normalizeModelRef / yamlScalar)与 host
18
+ * (isHostDependent);被 wizard / state 引用。
19
+ */
20
+ import { DEFAULT_MAIN_AGENT_PERSONA_EXTRA, ROLES, warn } from './util.mjs'
21
+ import { normalizeMainAgentName, normalizeModelRef, yamlScalar } from './config.mjs'
22
+ import { isHostDependent } from './host.mjs'
23
+
24
+ // ── restrict.mjs main-agent allow base ──────────────────────────────────────
25
+
26
+ /**
27
+ * Extract the MAIN_AGENT_ALLOW names from restrict.mjs source. This is the
28
+ * base list the generated orchestrator-restrict config.allow starts from
29
+ * (the filter falls back to it when no config.allow is injected).
30
+ */
31
+ export function extractMainAgentAllow(restrictSrc) {
32
+ const match = restrictSrc.match(/const MAIN_AGENT_ALLOW = new Set\(\[([\s\S]*?)\]\)/)
33
+ if (!match) throw new Error('MAIN_AGENT_ALLOW block not found in restrict.mjs')
34
+ const names = [...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1])
35
+ if (names.length === 0) throw new Error('MAIN_AGENT_ALLOW block is empty in restrict.mjs')
36
+ return names
37
+ }
38
+
39
+ /** Render a persona block scalar with a given content indent. */
40
+ export function renderPersona(text, indent) {
41
+ const pad = ' '.repeat(indent)
42
+ const body = String(text).split('\n').map((l) => (l === '' ? '' : `${pad}${l}`)).join('\n')
43
+ return `|-\n${body}\n`
44
+ }
45
+
46
+ /** Append the skill guidance paragraph to a persona when assigned. */
47
+ export function withSkillGuidance(persona, role, skillsMap) {
48
+ const assigned = Object.entries(skillsMap)
49
+ .filter(([, roleList]) => roleList.includes(role))
50
+ .map(([skill]) => skill)
51
+ .sort()
52
+ if (assigned.length === 0) return persona
53
+ const tail = persona.endsWith('\n') ? persona.slice(0, -1) : persona
54
+ return `${tail}\n\nAvailable skills: ${assigned.join(', ')}`
55
+ }
56
+
57
+ /**
58
+ * 取 persona 文本首行身份句的整段匹配("You are the <主语> agent")。基于正则
59
+ * 匹配首行:`^You are the .*? agent` 命中时返回整段匹配,其中主语段 = `You are
60
+ * the ` 与 ` agent` 之间内容;`.` 不跨行,天然只作用于首行。不命中返回 null
61
+ * (调用方应 warn 并跳过,不崩)。
62
+ */
63
+ export function roleIdentitySubject(text) {
64
+ const match = /^You are the .*? agent/.exec(String(text ?? ''))
65
+ return match ? match[0] : null
66
+ }
67
+
68
+ /**
69
+ * 角色 persona 首行身份句主语替换(roles.<toolName>.name 生效路径):匹配到
70
+ * "You are the <主语> agent" 时把主语换成 displayName —— 保持 `You are the `
71
+ * 前缀、` agent` 后缀与该行其余文字、后续段落全部原样 —— 返回
72
+ * { changed: true, text: <替换后全文> };首行不匹配 `^You are the ` 时返回
73
+ * { changed: false, text: <原文本> }。replace 用回调写法,显示名含 $ 等字符也
74
+ * 不会被替换串语法误解。
75
+ */
76
+ export function replaceRoleIdentity(text, displayName) {
77
+ if (roleIdentitySubject(text) === null) return { changed: false, text }
78
+ return {
79
+ changed: true,
80
+ text: String(text).replace(/^You are the .*? agent/, () => `You are the ${displayName} agent`),
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Render the delegation block(s) for custom roles, matching the existing
86
+ * delegation-* indentation (4-space list items).
87
+ */
88
+ export function renderCustomRoleBlocks(roles, skillsMap) {
89
+ const custom = Object.entries(roles).filter(([toolName]) => !ROLES.includes(toolName))
90
+ if (custom.length === 0) return ''
91
+ const chunks = []
92
+ for (const [toolName, role] of custom) {
93
+ // 零工具防护(与 composeGenerated 内置角色循环同款):tools 为空(缺省或
94
+ // `tools:` 空列表)会生成 `toolFilter.allow:`(YAML null),子 agent 创建后
95
+ // 零工具——宁可拒绝安装,也不静默生成一个干不了活的角色。
96
+ if ((role.tools ?? []).length === 0) {
97
+ throw new Error(`role "${toolName}" would end up with an empty toolFilter.allow — refusing to install a zero-tool role.`)
98
+ }
99
+ const persona = withSkillGuidance(
100
+ role.persona || `You are the ${toolName} agent. Handle tasks delegated to this role.`,
101
+ toolName,
102
+ skillsMap,
103
+ )
104
+ // 角色专用模型(roles.<toolName>.model / .provider):自定义角色与内置角色
105
+ // 同形状(provider: spawn / toolName / agentOptions? / persona / toolFilter)。
106
+ // 先归一再判 provider-alone(防 UI / 手工构造的 assignments 绕过规范化);
107
+ // 仅 model 非 null 才插 agentOptions 子块,provider-alone 仅 warn 忽略。
108
+ const model = normalizeModelRef(role.model ?? null, `roles.${toolName}.model`)
109
+ let provider = normalizeModelRef(role.provider ?? null, `roles.${toolName}.provider`)
110
+ if (model === null && provider !== null) {
111
+ warn(`roles.${toolName}.provider: '${provider}' 仅在同时配置 model 时生效,已忽略`)
112
+ provider = null
113
+ }
114
+ // agentOptions 子行(键序固定 provider 在前、model 在后,与 dsh-tool-subagent
115
+ // Config 字段序一致);model 缺省时整块不出现。
116
+ const agentOptionsLines = []
117
+ if (model !== null) {
118
+ agentOptionsLines.push(' agentOptions:')
119
+ if (provider !== null) agentOptionsLines.push(` provider: ${yamlScalar(provider)}`)
120
+ agentOptionsLines.push(` model: ${yamlScalar(model)}`)
121
+ }
122
+ chunks.push(
123
+ [
124
+ ` # ${toolName}:自定义角色(${firstLine(role.persona || '')})`,
125
+ ` - id: delegation-${toolName}`,
126
+ ` name: '@deepseek-ai/dsh-tool-subagent'`,
127
+ ' config:',
128
+ ' provider: spawn',
129
+ ` toolName: ${toolName}`,
130
+ ...agentOptionsLines,
131
+ ' persona: |-',
132
+ ...persona.split('\n').map((l) => ` ${l}`),
133
+ ' toolFilter:',
134
+ ' allow:',
135
+ ...(role.tools ?? []).map((t) => ` - ${t}`),
136
+ ].join('\n'),
137
+ )
138
+ }
139
+ return chunks.join('\n') + '\n'
140
+ }
141
+
142
+ export function firstLine(text) {
143
+ const s = String(text ?? '').split('\n')[0].trim()
144
+ return s === '' ? '自定义角色' : s
145
+ }
146
+
147
+ /**
148
+ * Compute the orchestrator-restrict config.allow: restrict.mjs base, minus
149
+ * host-dependent names not detected, plus main-agent extra tools and custom
150
+ * role toolNames, minus main_agent_remove entries and removed builtin role
151
+ * toolNames (roles_remove). Returns null when the result equals the base (no
152
+ * injection needed) — but only when no builtin role was removed: once
153
+ * roles_remove is non-empty the explicit allow MUST be injected, otherwise the
154
+ * static restrict.mjs MAIN_AGENT_ALLOW would keep delegating to the deleted
155
+ * role (删除不生效).
156
+ */
157
+ export function computeRestrictAllow(base, inventory, mainAgentExtra, mainAgentRemove, customToolNames, removedRoleNames = []) {
158
+ const kept = []
159
+ for (const name of base) {
160
+ if (!isHostDependent(name) || inventory.has(name)) kept.push(name)
161
+ }
162
+ for (const name of mainAgentExtra) {
163
+ if (!kept.includes(name) && (!isHostDependent(name) || inventory.has(name))) kept.push(name)
164
+ }
165
+ for (const name of customToolNames) {
166
+ if (!kept.includes(name)) kept.push(name)
167
+ }
168
+ const removed = new Set([...(mainAgentRemove ?? []), ...(removedRoleNames ?? [])])
169
+ const final = kept.filter((name) => !removed.has(name))
170
+ const sameAsBase =
171
+ (removedRoleNames ?? []).length === 0 && final.length === base.length && final.every((n, i) => n === base[i])
172
+ return sameAsBase ? null : final
173
+ }
174
+
175
+ /** Insert config.allow into the orchestrator-restrict row (after `name:`). */
176
+ export function injectRestrictAllow(srcText, allowList) {
177
+ const marker = `- id: orchestrator-restrict\n name: ./restrict.mjs`
178
+ const idx = srcText.indexOf(marker)
179
+ if (idx === -1) throw new Error('orchestrator-restrict row not found')
180
+ const insertAt = idx + marker.length
181
+ const block = `\n config:\n allow:\n${allowList.map((n) => ` - ${n}`).join('\n')}`
182
+ return srcText.slice(0, insertAt) + block + srcText.slice(insertAt)
183
+ }
184
+
185
+ /**
186
+ * Append text to the main-agent persona (`- id: persona` row) block scalar,
187
+ * used to inline the selected skills' full SKILL.md bodies (composeGenerated,
188
+ * the main-agent skills step) and to append the user-editable persona tail
189
+ * (main_agent_persona_extra). Locates the block structurally (same scan as
190
+ * locatePersonaBlocks' regex branch): marker row → `text: |-` → first content
191
+ * line sets the indent (6 in the source preset) → block ends at the first
192
+ * non-empty line indented shallower than the content. Replaces the span
193
+ * [dashStart, end) with the original content + suffixText re-rendered as a
194
+ * block scalar.
195
+ */
196
+ export function appendToMainPersona(srcText, suffixText) {
197
+ const marker = `- id: persona\n name: '@deepseek-ai/dsh-persona'`
198
+ const idx = srcText.indexOf(marker)
199
+ if (idx === -1) throw new Error('main agent persona block (`- id: persona` / @deepseek-ai/dsh-persona) not found')
200
+ const personaStart = srcText.indexOf('text: |-', idx)
201
+ if (personaStart === -1) throw new Error('main agent persona block: `text: |-` scalar not found')
202
+ const dashStart = personaStart + 'text: '.length // points at the `|` of `|-`
203
+ const contentStart = dashStart + '|-'.length + 1
204
+ const firstLineEnd = srcText.indexOf('\n', contentStart)
205
+ const firstLine = srcText.slice(contentStart, firstLineEnd === -1 ? srcText.length : firstLineEnd)
206
+ const indent = (firstLine.match(/^[ \t]*/) ?? [''])[0].length
207
+ let cursor = contentStart
208
+ let end = contentStart
209
+ while (cursor < srcText.length) {
210
+ const lineEnd = srcText.indexOf('\n', cursor)
211
+ const line = srcText.slice(cursor, lineEnd === -1 ? srcText.length : lineEnd)
212
+ if (line.trim() !== '' && (line.match(/^[ \t]*/) ?? [''])[0].length < indent) break
213
+ end = lineEnd === -1 ? srcText.length : lineEnd + 1
214
+ cursor = end
215
+ }
216
+ if (end === contentStart) throw new Error('main agent persona block: no content lines found')
217
+ const text = personaDefaultText(srcText.slice(dashStart, end))
218
+ return srcText.slice(0, dashStart) + renderPersona(text + suffixText, indent) + srcText.slice(end)
219
+ }
220
+
221
+ /** Insert custom role blocks right before the "remaining model-facing rows" comment. */
222
+ export function insertCustomRoles(srcText, customBlock) {
223
+ const marker = '# ── remaining model-facing rows'
224
+ const idx = srcText.indexOf(marker)
225
+ if (idx === -1) throw new Error('remaining model-facing rows marker not found')
226
+ return srcText.slice(0, idx) + customBlock + srcText.slice(idx)
227
+ }
228
+
229
+ /**
230
+ * 定位某内置角色委派行(` - id: delegation-<rowId>`,rowId = 角色名中 `_` 换成
231
+ * `-`,如 search_external → delegation-search-external)在源文本中的整条 span
232
+ * [start, end)(roles_remove 删除用):
233
+ * - start:item 行首向上吸收紧邻的注释行(连续 `#` 行,遇空行/非注释即停);
234
+ * - end:到「下一条 delegation- 行紧邻其上的注释行首」(下一条块完整保留,二者之间
235
+ * 的分隔空行并入删除,保留下一条块前恰一个空行);无下一条(最后一个内置角色)时
236
+ * 到 '# ── remaining model-facing rows' 注释前的空行(该注释保留)。
237
+ * 返回原坐标;调用方与 allow/persona edits 一起按 start 降序应用。
238
+ */
239
+ export function delegationRowSpan(srcText, role) {
240
+ const rowId = role.replace(/_/g, '-') // 委派行 id 用连字符(toolName 用下划线)
241
+ const marker = `- id: delegation-${rowId}`
242
+ const idx = srcText.indexOf(marker)
243
+ if (idx === -1) throw new Error(`委派行缺失(roles_remove 引用了 '${role}',但源文本里找不到 "${marker}")`)
244
+ const itemStart = srcText.lastIndexOf('\n', idx - 1) + 1
245
+ let start = itemStart
246
+ for (;;) {
247
+ if (start === 0) break
248
+ const prevStart = srcText.lastIndexOf('\n', start - 2) + 1
249
+ const prevLine = srcText.slice(prevStart, start - 1)
250
+ if (prevLine.trim().startsWith('#')) start = prevStart
251
+ else break
252
+ }
253
+ const nextMarker = '\n - id: delegation-'
254
+ const nextIdx = srcText.indexOf(nextMarker, idx + marker.length)
255
+ let end
256
+ if (nextIdx !== -1) {
257
+ // 下一条委派行:end 停在其紧邻上方注释行的行首(该注释属于下一条块,保留)
258
+ end = nextIdx + 1
259
+ for (;;) {
260
+ if (end === 0) break
261
+ const prevStart = srcText.lastIndexOf('\n', end - 2) + 1
262
+ const prevLine = srcText.slice(prevStart, end - 1)
263
+ if (prevLine.trim().startsWith('#')) end = prevStart
264
+ else break
265
+ }
266
+ } else {
267
+ // 无下一条:删除到 '# ── remaining model-facing rows' 注释前的空行行首(注释保留)
268
+ const tailMarker = '# ── remaining model-facing rows'
269
+ const tailIdx = srcText.indexOf(tailMarker)
270
+ if (tailIdx === -1) {
271
+ end = srcText.length
272
+ } else {
273
+ const tailStart = srcText.lastIndexOf('\n', tailIdx - 1) + 1
274
+ const prevStart = srcText.lastIndexOf('\n', tailStart - 2) + 1
275
+ const above = tailStart === 0 ? '' : srcText.slice(prevStart, tailStart - 1)
276
+ end = above.trim() === '' && tailStart > 0 ? prevStart : tailStart
277
+ }
278
+ }
279
+ return { start, end }
280
+ }
281
+
282
+ /**
283
+ * 角色专用模型注入(roles.<toolName>.model / .provider 生效路径):在 role(内置
284
+ * 角色名,下划线)的既有委派块内、`toolName:` 行行尾插入 agentOptions 子块,返回
285
+ * 零宽 span edit { start, end, text }(start = end = toolName 行行尾换行符下标;
286
+ * 原换行符保留在 text 之后,故 text 不带尾换行)。子块键序固定 provider 在前、
287
+ * model 在后(与 dsh-tool-subagent Config 字段序一致),provider 缺省时不出该行。
288
+ * 块定位:`- id: delegation-<rowId>`(rowId = 角色名 `_` 换 `-`,找不到 throw);
289
+ * 块结束 = min(其后首个 `\n - id: delegation-` 下标, '# ── remaining
290
+ * model-facing rows' 注释下标, srcText.length),保证 toolName 搜索不越界命中
291
+ * 别的块。返回坐标为源文本原坐标,与 allow/persona/删除 edits 一起按 start 降序
292
+ * 应用。
293
+ */
294
+ export function injectRoleAgentOptions(srcText, role, model, provider) {
295
+ const rowId = role.replace(/_/g, '-') // 委派行 id 用连字符(toolName 用下划线)
296
+ const marker = `- id: delegation-${rowId}`
297
+ const idx = srcText.indexOf(marker)
298
+ if (idx === -1) throw new Error(`委派行缺失(roles.${role}.model 引用了 '${role}',但源文本里找不到 "${marker}")`)
299
+ const nextIdx = srcText.indexOf('\n - id: delegation-', idx + marker.length)
300
+ const tailIdx = srcText.indexOf('# ── remaining model-facing rows')
301
+ // 块结束取三者最小(无下一条/无尾注释时以 srcText.length 兜底)。
302
+ const blockEnd = Math.min(
303
+ nextIdx === -1 ? srcText.length : nextIdx,
304
+ tailIdx === -1 ? srcText.length : tailIdx,
305
+ srcText.length,
306
+ )
307
+ const toolNameMarker = '\n toolName: ' // 8 空格缩进 = config 键层
308
+ const toolNameIdx = srcText.indexOf(toolNameMarker, idx)
309
+ if (toolNameIdx === -1 || toolNameIdx >= blockEnd) {
310
+ throw new Error(`委派块缺 toolName 行(roles.${role}.model:"${marker}" 块内找不到 8 空格缩进的 "toolName: " 行)`)
311
+ }
312
+ // 插入点 = toolName 行行尾换行符的下标;零宽插入后该换行符仍在 text 之后。
313
+ const lineEnd = srcText.indexOf('\n', toolNameIdx + 1)
314
+ const text =
315
+ '\n' +
316
+ [
317
+ ' agentOptions:',
318
+ ...(provider !== null && provider !== undefined ? [` provider: ${yamlScalar(provider)}`] : []),
319
+ ` model: ${yamlScalar(model)}`,
320
+ ].join('\n')
321
+ return { start: lineEnd, end: lineEnd, text }
322
+ }
323
+
324
+ /**
325
+ * 主 persona 整块改造(改名 + 删委派 bullet + 自定义角色自动委派行),返回一个
326
+ * span edit { start, end, text }(start/end 为源文本原坐标、随其它 edits 降序应用),
327
+ * 无任何改动时返回 null(产物零 diff)。结构扫描与 appendToMainPersona 相同:
328
+ * `- id: persona` 行 → `text: |-` → 首内容行定缩进 → 浅缩进行收尾。
329
+ * - 改名:main_agent_name 非空时,把身份行 label 换成它(正则不中则 warn 不崩);
330
+ * - 删 bullet:对每个 removedRoles 角色,删掉 content 中 trimmed 匹配
331
+ * `^- .*delegate to <role>\.?$` 的行(限定行首 `- `,不误删 SOP 段落);
332
+ * - 自定义角色自动委派行:customBullets(第 4 参,元素 { toolName, duty })逐个
333
+ * 渲染成 `- <duty>: delegate to <toolName>.`(duty 去掉尾部 `。`/`.`/`!`/`!`
334
+ * 与空白),插到 body 中最后一个匹配 `^- .*delegate to \S+\.?$` 的行之后;
335
+ * 一条都没有时退到匹配 `^Decompose the task` 的行之后;仍没有则退到首行之后。
336
+ * 其余文本与空行结构保持不变——没有这行指引,主 agent persona 就不含自定义
337
+ * 角色,主 agent 永远不会委派给它。
338
+ * 返回值经 renderPersona 以块标量整体回填;技能行/extra 追加在 edits 应用后由
339
+ * appendToMainPersona 基于已改造文本追加,顺序天然正确。
340
+ */
341
+ export function composeMainPersonaEdit(srcText, mainAgentName, removedRoles, customBullets = []) {
342
+ if (!mainAgentName && removedRoles.length === 0 && customBullets.length === 0) return null
343
+ const marker = `- id: persona\n name: '@deepseek-ai/dsh-persona'`
344
+ const idx = srcText.indexOf(marker)
345
+ if (idx === -1) throw new Error('main agent persona block (`- id: persona` / @deepseek-ai/dsh-persona) not found')
346
+ const personaStart = srcText.indexOf('text: |-', idx)
347
+ if (personaStart === -1) throw new Error('main agent persona block: `text: |-` scalar not found')
348
+ const dashStart = personaStart + 'text: '.length // points at the `|` of `|-`
349
+ const contentStart = dashStart + '|-'.length + 1
350
+ const firstLineEnd = srcText.indexOf('\n', contentStart)
351
+ const firstLine = srcText.slice(contentStart, firstLineEnd === -1 ? srcText.length : firstLineEnd)
352
+ const indent = (firstLine.match(/^[ \t]*/) ?? [''])[0].length
353
+ let cursor = contentStart
354
+ let end = contentStart
355
+ while (cursor < srcText.length) {
356
+ const lineEnd = srcText.indexOf('\n', cursor)
357
+ const line = srcText.slice(cursor, lineEnd === -1 ? srcText.length : lineEnd)
358
+ if (line.trim() !== '' && (line.match(/^[ \t]*/) ?? [''])[0].length < indent) break
359
+ end = lineEnd === -1 ? srcText.length : lineEnd + 1
360
+ cursor = end
361
+ }
362
+ if (end === contentStart) throw new Error('main agent persona block: no content lines found')
363
+ let body = personaDefaultText(srcText.slice(dashStart, end))
364
+ if (mainAgentName) {
365
+ // 身份行 label 替换:仅匹配行首的 "You are the … agent powered by the {{model}} model."
366
+ const identityRe = /^You are the [^\n]*?agent powered by the \{\{model\}\} model\./
367
+ const match = body.match(identityRe)
368
+ if (!match) {
369
+ warn('main_agent_name: 主 persona 身份行未匹配,agent.cordis.yml 保留默认身份行')
370
+ } else {
371
+ const renamed = `You are the ${mainAgentName} agent powered by the {{model}} model.`
372
+ body = body.slice(0, match.index) + renamed + body.slice(match.index + match[0].length)
373
+ }
374
+ }
375
+ if (removedRoles.length > 0) {
376
+ // 删委派 bullet:行首 `- ` 且含 `delegate to <role>`;SOP 段落等不命中。
377
+ const dropRe = removedRoles.map((role) => new RegExp(`^- .*delegate to ${role}\\.?$`))
378
+ const lines = body.split('\n')
379
+ body = lines
380
+ .filter((line) => !dropRe.some((re) => re.test(line.trim())))
381
+ .join('\n')
382
+ }
383
+ if (customBullets.length > 0) {
384
+ // 自定义角色自动委派行:duty 去尾部句读与空白后渲染成 `- <duty>: delegate to
385
+ // <toolName>.`;插到最后一条既有委派行之后(与编排段落保持相邻),一条都没有
386
+ // 则退到 Decompose the task 行之后、再退到首行之后。
387
+ const lines = body.split('\n')
388
+ const bullets = customBullets.map(({ toolName, duty }) => {
389
+ const cleaned = String(duty ?? '').trim().replace(/[。.!!]+$/u, '').trim()
390
+ return `- ${cleaned}: delegate to ${toolName}.`
391
+ })
392
+ let after = -1
393
+ for (let i = lines.length - 1; i >= 0; i--) {
394
+ if (/^- .*delegate to \S+\.?$/.test(lines[i].trim())) {
395
+ after = i
396
+ break
397
+ }
398
+ }
399
+ if (after === -1) {
400
+ const decIdx = lines.findIndex((l) => /^Decompose the task/.test(l.trim()))
401
+ if (decIdx !== -1) after = decIdx
402
+ }
403
+ if (after === -1) after = 0 // 兜底:插到首行之后
404
+ lines.splice(after + 1, 0, ...bullets)
405
+ body = lines.join('\n')
406
+ }
407
+ return { start: dashStart, end, text: renderPersona(body, indent) }
408
+ }
409
+
410
+ /** Keep only host-dependent names that were actually detected. */
411
+ export function filterHostTools(tools, inventory) {
412
+ return tools.filter((name) => !isHostDependent(name) || inventory.has(name))
413
+ }
414
+
415
+ /**
416
+ * Compose the generated agent.cordis.yml from the final assignments.
417
+ * Returns the new text (with allow/persona replacements and any custom-role
418
+ * block / restrict config.allow injection) plus the per-role diff info for
419
+ * the report.
420
+ */
421
+ export function composeGenerated(srcText, blocks, personaBlocks, assignments, inventory, restrictBase, mainAgentSkillMetas = {}) {
422
+ const skillsMap = assignments.skills ?? {}
423
+ const customToolNames = Object.keys(assignments.roles).filter((n) => !ROLES.includes(n))
424
+ // roles_remove:被删除的内置角色不产 allow/persona edits(委派行整条删除时避免重叠),
425
+ // 主 persona bullet 与 restrict allow 也据此裁剪。main_agent_name 两处共用归一化值。
426
+ const removedRoles = ROLES.filter((role) => (assignments.roles_remove ?? []).includes(role))
427
+ const mainAgentName = normalizeMainAgentName(assignments.main_agent_name)
428
+
429
+ // Role allow results (default roles only; custom roles are rendered fresh).
430
+ const roleResults = []
431
+ for (const role of ROLES) {
432
+ if (removedRoles.includes(role)) continue // 已删除角色:不产 roleResults / allow edits
433
+ const intent = assignments.roles[role]?.tools ?? blocks.get(role).names
434
+ const filtered = filterHostTools(intent, inventory)
435
+ if (filtered.length === 0) {
436
+ throw new Error(`role "${role}" would end up with an empty toolFilter.allow — refusing to install a zero-tool role.`)
437
+ }
438
+ roleResults.push({ role, intent, kept: filtered, removed: intent.filter((n) => !filtered.includes(n)) })
439
+ }
440
+
441
+ // Collect all edits (applied back-to-front so offsets stay valid).
442
+ const edits = []
443
+ for (const rr of roleResults) {
444
+ const block = blocks.get(rr.role)
445
+ edits.push({
446
+ start: block.start,
447
+ end: block.end,
448
+ text: rr.kept.map((name) => `${' '.repeat(block.itemIndent)}- ${name}\n`).join(''),
449
+ })
450
+ }
451
+ // Persona replacements: only when the final persona differs from the source
452
+ // default (config persona set, display name applied, or skill guidance
453
+ // assigned). roles.<toolName>.name(内置角色可选显示名)只在该角色 persona 走
454
+ // 默认时生效:把默认 persona 首行身份句主语换成显示名;persona 已被用户自定义
455
+ // 时不生效(warn 一次提示写进自定义 persona)。toolName / restrict allow / 主
456
+ // persona delegate bullet / skills 分配键一律不受影响(不产对应 edit)。
457
+ for (const role of ROLES) {
458
+ if (removedRoles.includes(role)) continue // 已删除角色:persona 随委派行整条删除
459
+ const configured = assignments.roles[role]?.persona ?? null
460
+ const displayNameRaw = assignments.roles[role]?.name ?? null
461
+ // 空串/null 视为无;字符串取 trim 值(与 config 端 normalizeRoleName 同语义,
462
+ // 防 UI / 手工构造的 assignments 绕过规范化)。
463
+ const displayName =
464
+ typeof displayNameRaw === 'string' && displayNameRaw.trim() !== '' ? displayNameRaw.trim() : null
465
+ const block = personaBlocks.get(role)
466
+ const hasSkills = Object.entries(skillsMap).some(([, roleList]) => roleList.includes(role))
467
+ if (configured !== null) {
468
+ // A: persona 已自定义 → 全文原样生效(仅叠加技能引导);显示名不生效并 warn。
469
+ if (displayName !== null) {
470
+ warn(
471
+ `roles.${role}.name: '${displayName}' 仅作用于默认 persona(该角色 persona 已自定义)——请直接把该名字写进自定义 persona 的首行`,
472
+ )
473
+ }
474
+ edits.push({
475
+ start: block.start,
476
+ end: block.end,
477
+ text: renderPersona(withSkillGuidance(configured, role, skillsMap), block.indent),
478
+ })
479
+ } else if (displayName !== null || hasSkills) {
480
+ // B': 默认 persona 上叠加(可选)显示名主语替换 +(可选)技能引导;只在与
481
+ // 源默认文本确有差异时才产 edit(显示名与默认主语相同 → 替换后文本不变 →
482
+ // 不产 edit,产物与源逐字节一致)。首行不匹配身份句模式 → warn 并跳过。
483
+ const defaultText = personaDefaultText(block.text)
484
+ let finalPersona = defaultText
485
+ if (displayName !== null) {
486
+ const replaced = replaceRoleIdentity(finalPersona, displayName)
487
+ if (!replaced.changed) {
488
+ warn(
489
+ `roles.${role}.name: '${displayName}' 未生效——该角色默认 persona 首行不是 "You are the <主语> agent" 形态(已跳过)`,
490
+ )
491
+ } else {
492
+ finalPersona = replaced.text
493
+ }
494
+ }
495
+ if (hasSkills) finalPersona = withSkillGuidance(finalPersona, role, skillsMap)
496
+ if (finalPersona !== defaultText) {
497
+ edits.push({ start: block.start, end: block.end, text: renderPersona(finalPersona, block.indent) })
498
+ }
499
+ }
500
+ }
501
+ // roles_remove:委派行整条删除(含其上方注释行与一个分隔空行,span 为原坐标,
502
+ // 与 allow/persona edits 一起按 start 降序应用;被删角色不产 allow/persona edits,
503
+ // 故区间不相交)。
504
+ for (const role of removedRoles) {
505
+ const span = delegationRowSpan(srcText, role)
506
+ edits.push({ start: span.start, end: span.end, text: '' })
507
+ }
508
+ // 主 persona 改造(改名 + 删委派 bullet + 自定义角色自动委派行):无任何改动时
509
+ // 不产 edit(产物零 diff)。自定义角色取 persona 首行职责句生成 delegate 行
510
+ // (firstLine 对空 persona 返回占位「自定义角色」,中文占位可直接用)——没有这
511
+ // 行指引,主 agent persona 就不含该角色,永远不会委派给它。
512
+ const customBullets = customToolNames.map((n) => ({
513
+ toolName: n,
514
+ duty: firstLine(assignments.roles[n]?.persona || ''),
515
+ }))
516
+ const personaEdit = composeMainPersonaEdit(srcText, mainAgentName, removedRoles, customBullets)
517
+ if (personaEdit !== null) edits.push(personaEdit)
518
+
519
+ // 角色专用模型注入(roles.<toolName>.model / .provider → 委派块 agentOptions):
520
+ // 与 roles_remove 委派行删除 edits 同段收集。被删角色不注入(委派行已整条删除,
521
+ // 模型配置 warn 忽略);仅 model 非 null 才注入,provider-alone 仅 warn 忽略
522
+ // (先 normalize 再判断,防手工构造的 assignments 绕过规范化)。
523
+ for (const role of ROLES) {
524
+ const rawModel = assignments.roles[role]?.model ?? null
525
+ const rawProvider = assignments.roles[role]?.provider ?? null
526
+ if (removedRoles.includes(role)) {
527
+ const model = normalizeModelRef(rawModel, `roles.${role}.model`)
528
+ const provider = normalizeModelRef(rawProvider, `roles.${role}.provider`)
529
+ if (model !== null || provider !== null) {
530
+ warn(`roles.${role}.model: 该角色已在 roles_remove 中删除,模型配置被忽略`)
531
+ }
532
+ continue
533
+ }
534
+ const model = normalizeModelRef(rawModel, `roles.${role}.model`)
535
+ let provider = normalizeModelRef(rawProvider, `roles.${role}.provider`)
536
+ if (model === null && provider !== null) {
537
+ warn(`roles.${role}.provider: '${provider}' 仅在同时配置 model 时生效,已忽略`)
538
+ provider = null
539
+ }
540
+ if (model !== null) edits.push(injectRoleAgentOptions(srcText, role, model, provider))
541
+ }
542
+ // 注入 edit(零宽,位于 toolName 行行尾)与该块的 allow edit / persona edit
543
+ // 区间天然不相交:委派块内 toolName 行先于 persona / toolFilter 出现,插入点
544
+ // 在其后 8+ 缩进区域的 allow/persona span 之前;按 start 降序应用无需额外处理。
545
+
546
+ // Custom role blocks + restrict config.allow injection.
547
+ let out = srcText
548
+ edits.sort((a, b) => b.start - a.start)
549
+ for (const edit of edits) out = out.slice(0, edit.start) + edit.text + out.slice(edit.end)
550
+
551
+ const customBlock = renderCustomRoleBlocks(assignments.roles, skillsMap)
552
+ if (customBlock !== '') out = insertCustomRoles(out, customBlock)
553
+
554
+ const mainAgentRemove = assignments.main_agent_remove ?? []
555
+ // Warn about removals that are not part of the main agent's current allow
556
+ // (undetected host tools, typos, names never present) and skip them.
557
+ if (mainAgentRemove.length > 0) {
558
+ const removable = new Set(
559
+ [...restrictBase, ...(assignments.main_agent_extra ?? []), ...customToolNames].filter(
560
+ (n) => !isHostDependent(n) || inventory.has(n),
561
+ ),
562
+ )
563
+ for (const name of mainAgentRemove) {
564
+ if (!removable.has(name)) warn(`main_agent_remove: '${name}' 不在主 agent 当前 allow(未检测或不存在),已跳过`)
565
+ }
566
+ }
567
+ const restrictAllow = computeRestrictAllow(
568
+ restrictBase,
569
+ inventory,
570
+ assignments.main_agent_extra ?? [],
571
+ mainAgentRemove,
572
+ customToolNames,
573
+ assignments.roles_remove ?? [],
574
+ )
575
+ if (mainAgentRemove.length > 0 && restrictAllow !== null && restrictAllow.length === 0) {
576
+ throw new Error('main_agent_remove 把主 agent 的工具清空了——至少保留一个工具(或清掉多余的移除项)')
577
+ }
578
+ if (restrictAllow !== null) out = injectRestrictAllow(out, restrictAllow)
579
+
580
+ // Main-agent skills (dual mode, mirrors the roles' skill soft guidance):
581
+ // - main_agent_skills (soft): one compact "# skill: <name> — <description>"
582
+ // row + "Read full rules: <file>" per skill appended to the main-agent
583
+ // persona — the main agent loads the full SKILL.md on demand with read.
584
+ // - main_agent_skills_inline (hard, optional): those skills' full bodies
585
+ // are appended to the persona instead (fixed per-response cost; style
586
+ // skills like caveman that must apply unconditionally). A skill listed
587
+ // here is not repeated as a soft row.
588
+ const softNames = (assignments.main_agent_skills ?? []).filter((n) => typeof n === 'string' && n !== '')
589
+ const hardNames = (assignments.main_agent_skills_inline ?? []).filter((n) => typeof n === 'string' && n !== '')
590
+ const warned = new Set()
591
+ for (const n of softNames) {
592
+ if (mainAgentSkillMetas[n] || warned.has(n)) continue
593
+ warn(`main_agent_skills: '${n}' SKILL.md 未找到(~/.dsh/skills、~/.agents/skills、项目 .dsh|.agents/skills),已跳过`)
594
+ warned.add(n)
595
+ }
596
+ for (const n of hardNames) {
597
+ if (mainAgentSkillMetas[n] || warned.has(n)) continue
598
+ warn(`main_agent_skills_inline: '${n}' SKILL.md 未找到(~/.dsh/skills、~/.agents/skills、项目 .dsh|.agents/skills),已跳过`)
599
+ warned.add(n)
600
+ }
601
+ const softPresent = softNames.filter((n) => mainAgentSkillMetas[n] && !hardNames.includes(n))
602
+ const hardPresent = hardNames.filter((n) => mainAgentSkillMetas[n])
603
+ if (softPresent.length > 0 || hardPresent.length > 0) {
604
+ let suffix = ''
605
+ if (softPresent.length > 0) {
606
+ suffix +=
607
+ '\n\n── Assigned skills (soft guidance: when a task matches, load the full rules with the read tool before acting, then follow them for the rest of the session) ──'
608
+ for (const n of softPresent) {
609
+ const meta = mainAgentSkillMetas[n]
610
+ const head = meta.description !== '' ? `# skill: ${n} — ${meta.description}` : `# skill: ${n}`
611
+ suffix += `\n\n${head}\nRead full rules: ${meta.file}`
612
+ }
613
+ }
614
+ if (hardPresent.length > 0) {
615
+ suffix += '\n\n── Assigned skills (full rules inlined below — active for every response) ──'
616
+ for (const n of hardPresent) {
617
+ const meta = mainAgentSkillMetas[n]
618
+ const head = meta.description !== '' ? `# skill: ${n} — ${meta.description} (inlined)` : `# skill: ${n} (inlined)`
619
+ suffix += `\n\n${head}\n\n${meta.body}`
620
+ }
621
+ }
622
+ out = appendToMainPersona(out, suffix)
623
+ }
624
+
625
+ // main_agent_persona_extra(主 persona 尾部追加,用户可在配置 UI「人设追加」里编辑):
626
+ // null/缺省 → DEFAULT_MAIN_AGENT_PERSONA_EXTRA(默认 codegraph projectPath 规则);
627
+ // '' → 明确清空,不追加任何内容;其他字符串 → 用户覆盖。追加顺序保持
628
+ // SRC 本体 → 技能行/内联(上方)→ extra。空值跳过追加(无内容可加)。
629
+ const personaExtraRaw = assignments.main_agent_persona_extra
630
+ const personaExtra = personaExtraRaw === null || personaExtraRaw === undefined
631
+ ? DEFAULT_MAIN_AGENT_PERSONA_EXTRA
632
+ : String(personaExtraRaw)
633
+ if (personaExtra !== '') out = appendToMainPersona(out, '\n\n' + personaExtra)
634
+
635
+ return { text: out, roleResults }
636
+ }
637
+
638
+ /** Strip the block-scalar markers/indent from a persona block's source text. */
639
+ export function personaDefaultText(blockText) {
640
+ const lines = blockText.split('\n')
641
+ lines.shift() // drop the `|-` marker line
642
+ const indent = (lines[0]?.match(/^[ \t]*/) ?? [''])[0].length
643
+ return lines
644
+ .map((l) => (l.trim() === '' ? '' : l.slice(Math.min(indent, l.length))))
645
+ .join('\n')
646
+ .replace(/\n+$/, '')
647
+ }