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.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/README_en.md +95 -0
- package/bin/dsh-paoding.mjs +12 -0
- package/cordis.patch.yml +6 -0
- package/docs/architecture.md +292 -0
- package/docs/architecture_en.md +363 -0
- package/docs/configuration.md +551 -0
- package/docs/configuration_en.md +560 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/installation.md +364 -0
- package/docs/installation_en.md +367 -0
- package/docs/orchestration.md +254 -0
- package/docs/orchestration_en.md +255 -0
- package/install.sh +31 -0
- package/package.json +29 -0
- package/plugins/paoding-config-ui/api-core.mjs +100 -0
- package/plugins/paoding-config-ui/index.mjs +327 -0
- package/plugins/paoding-config-ui/lib/base.css +615 -0
- package/plugins/paoding-config-ui/lib/client.js +1154 -0
- package/plugins/paoding-config-ui/package.json +23 -0
- package/plugins/paoding-config-ui/runtime-inventory.mjs +212 -0
- package/presets/orchestrator/agent.cordis.yml +323 -0
- package/presets/orchestrator/preset.yml +6 -0
- package/presets/orchestrator/restrict.mjs +191 -0
- package/tools/install.mjs +24 -0
- package/tools/lib/alloc.mjs +138 -0
- package/tools/lib/cli.mjs +522 -0
- package/tools/lib/compose.mjs +647 -0
- package/tools/lib/config.mjs +297 -0
- package/tools/lib/host.mjs +288 -0
- package/tools/lib/skills.mjs +146 -0
- package/tools/lib/spans.mjs +188 -0
- package/tools/lib/state.mjs +328 -0
- package/tools/lib/util.mjs +26 -0
- package/tools/lib/wizard.mjs +516 -0
- package/tools/lib/yaml.mjs +266 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 技能发现与 SKILL.md 元数据解析:detectSkills 扫描四类根目录($DSH_HOME/
|
|
3
|
+
* $DSH_AGENTS_HOME/项目 .dsh|.agents skills),findSkillMeta 定位并读取一个
|
|
4
|
+
* SKILL.md、解析 frontmatter description(skillDescriptionOf /
|
|
5
|
+
* stripSkillDescription)。依赖 node:fs / node:os / node:path;被 state 引用。
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
|
|
11
|
+
// ── skill detection ─────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Discover installed skills from the same roots dsh-skill-filesystem scans:
|
|
15
|
+
* user roots ($DSH_HOME/skills, $DSH_AGENTS_HOME/skills or ~/.agents/skills)
|
|
16
|
+
* plus project roots (<cwd>/.dsh/skills, <cwd>/.agents/skills). A skill is a
|
|
17
|
+
* directory or a .md file; its name is the basename. Roots that do not exist
|
|
18
|
+
* are skipped.
|
|
19
|
+
*/
|
|
20
|
+
export function detectSkills(dshHome, cwd) {
|
|
21
|
+
const agentsHome = process.env.DSH_AGENTS_HOME || path.join(homedir(), '.agents')
|
|
22
|
+
const roots = [
|
|
23
|
+
path.join(dshHome, 'skills'),
|
|
24
|
+
path.join(agentsHome, 'skills'),
|
|
25
|
+
path.join(cwd, '.dsh', 'skills'),
|
|
26
|
+
path.join(cwd, '.agents', 'skills'),
|
|
27
|
+
]
|
|
28
|
+
const skills = new Set()
|
|
29
|
+
for (const root of roots) {
|
|
30
|
+
let entries
|
|
31
|
+
try {
|
|
32
|
+
entries = readdirSync(root, { withFileTypes: true })
|
|
33
|
+
} catch {
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
if (entry.name.startsWith('.')) continue
|
|
39
|
+
skills.add(entry.name)
|
|
40
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
41
|
+
skills.add(entry.name.slice(0, -'.md'.length))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...skills].sort()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Locate and read one skill's SKILL.md for the main-agent persona, shared by
|
|
50
|
+
* both load channels (main_agent_skills / main_agent_skills_inline): the
|
|
51
|
+
* default soft mode writes a compact skill row (name + description + absolute
|
|
52
|
+
* SKILL.md path) into the persona and the main agent reads the full rules on
|
|
53
|
+
* demand with its own read tool (zero directory injection); the optional hard
|
|
54
|
+
* inline mode inlines the full body instead. Uses the same four roots as
|
|
55
|
+
* detectSkills; within each root a directory skill (<root>/<name>/SKILL.md)
|
|
56
|
+
* wins over a bare <root>/<name>.md file. Strips the leading YAML
|
|
57
|
+
* frontmatter when present, then trims. `description` is parsed from the
|
|
58
|
+
* frontmatter (single-line, quoted, folded `>` or literal `|` block — block
|
|
59
|
+
* values join the indented continuation lines up to the next key line or the
|
|
60
|
+
* end of the frontmatter, then collapse whitespace, strip surrounding quotes
|
|
61
|
+
* and cap at 200 chars; empty string when absent). Returns
|
|
62
|
+
* { name, description, file, body } with `file` the absolute SKILL.md path,
|
|
63
|
+
* or null when not found / empty body.
|
|
64
|
+
*/
|
|
65
|
+
export function findSkillMeta(name, dshHome, cwd) {
|
|
66
|
+
const agentsHome = process.env.DSH_AGENTS_HOME || path.join(homedir(), '.agents')
|
|
67
|
+
const roots = [
|
|
68
|
+
path.join(dshHome, 'skills'),
|
|
69
|
+
path.join(agentsHome, 'skills'),
|
|
70
|
+
path.join(cwd, '.dsh', 'skills'),
|
|
71
|
+
path.join(cwd, '.agents', 'skills'),
|
|
72
|
+
]
|
|
73
|
+
const fmRe = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
|
|
74
|
+
for (const root of roots) {
|
|
75
|
+
for (const candidate of [path.join(root, name, 'SKILL.md'), path.join(root, `${name}.md`)]) {
|
|
76
|
+
let text
|
|
77
|
+
try {
|
|
78
|
+
text = readFileSync(candidate, 'utf8')
|
|
79
|
+
} catch {
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
const body = text.replace(fmRe, '').trim()
|
|
83
|
+
if (body === '') return null
|
|
84
|
+
const fm = text.match(fmRe)
|
|
85
|
+
return { name, description: fm ? skillDescriptionOf(fm[1]) : '', file: candidate, body }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse one skill frontmatter's `description:` value. Handles plain
|
|
93
|
+
* single-line, single/double-quoted, and folded (`>`) / literal (`|`) block
|
|
94
|
+
* scalars (marker on the key line or on its own next line): block content is
|
|
95
|
+
* the following indented lines up to the first key line at or above the
|
|
96
|
+
* description key's indent (or the frontmatter end), whitespace-collapsed.
|
|
97
|
+
* Returns '' when the key is absent or empty.
|
|
98
|
+
*/
|
|
99
|
+
export function skillDescriptionOf(frontmatter) {
|
|
100
|
+
const lines = frontmatter.split(/\r?\n/)
|
|
101
|
+
for (let i = 0; i < lines.length; i++) {
|
|
102
|
+
const key = lines[i].match(/^(\s*)description:\s*(.*)$/)
|
|
103
|
+
if (!key) continue
|
|
104
|
+
const indent = key[1].length
|
|
105
|
+
let value = key[2]
|
|
106
|
+
let contentFrom = i + 1
|
|
107
|
+
if (value !== '') {
|
|
108
|
+
if (!/^[>|][+-]?\d*$/.test(value)) return stripSkillDescription(value)
|
|
109
|
+
// block marker on the key line: content starts on the next line
|
|
110
|
+
} else if (lines[i + 1] && /^\s*[>|][+-]?\d*\s*$/.test(lines[i + 1])) {
|
|
111
|
+
contentFrom = i + 2 // marker on its own line
|
|
112
|
+
} else {
|
|
113
|
+
return '' // empty value, no block scalar
|
|
114
|
+
}
|
|
115
|
+
let stop = lines.length
|
|
116
|
+
let minIndent = Infinity
|
|
117
|
+
for (let j = contentFrom; j < lines.length; j++) {
|
|
118
|
+
const line = lines[j]
|
|
119
|
+
if (line.trim() === '') continue
|
|
120
|
+
const lineIndent = (line.match(/^[ \t]*/) ?? [''])[0].length
|
|
121
|
+
if (lineIndent <= indent) {
|
|
122
|
+
stop = j
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
if (lineIndent < minIndent) minIndent = lineIndent
|
|
126
|
+
}
|
|
127
|
+
if (minIndent === Infinity) return ''
|
|
128
|
+
const parts = []
|
|
129
|
+
for (let j = contentFrom; j < stop; j++) {
|
|
130
|
+
const line = lines[j]
|
|
131
|
+
parts.push(line.trim() === '' ? '' : line.slice(Math.min(minIndent, line.length)))
|
|
132
|
+
}
|
|
133
|
+
return stripSkillDescription(parts.join(' '))
|
|
134
|
+
}
|
|
135
|
+
return ''
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Collapse whitespace, strip one pair of surrounding quotes, cap at 200 chars. */
|
|
139
|
+
export function stripSkillDescription(raw) {
|
|
140
|
+
let value = raw.trim()
|
|
141
|
+
const quoted = value.match(/^(['"])([\s\S]*)\1$/)
|
|
142
|
+
if (quoted) value = quoted[2]
|
|
143
|
+
value = value.replace(/\s+/g, ' ').trim()
|
|
144
|
+
if (value.length > 200) value = value.slice(0, 200)
|
|
145
|
+
return value
|
|
146
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 源文本 span 定位:在 agent.cordis.yml 源文本中定位各内置角色 toolFilter.allow
|
|
3
|
+
* 与 persona 块标量的字节区间(yaml AST 优先、结构化 regex 兜底)——
|
|
4
|
+
* locateAllowBlocks / locateAllowBlocksYaml / locateAllowBlocksRegex /
|
|
5
|
+
* locatePersonaBlocks 及内部 walkMaps / leadingIndent / ALLOW_BLOCK_RE。
|
|
6
|
+
* 依赖 util(ROLES / warn);被 state 引用。
|
|
7
|
+
*/
|
|
8
|
+
import { ROLES, warn } from './util.mjs'
|
|
9
|
+
|
|
10
|
+
// ── allow-list location & rewriting ─────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Locate each role's toolFilter.allow block in the source agent.cordis.yml.
|
|
14
|
+
* Preferred strategy: parseDocument AST node ranges (surgical, byte-exact).
|
|
15
|
+
* Fallback: structured regex. Returns a Map role -> { start, end, itemIndent,
|
|
16
|
+
* names } where [start, end) is the exact span of the allow item lines.
|
|
17
|
+
*/
|
|
18
|
+
export function locateAllowBlocks(srcText, yamlMod) {
|
|
19
|
+
if (yamlMod) {
|
|
20
|
+
try {
|
|
21
|
+
const blocks = locateAllowBlocksYaml(srcText, yamlMod)
|
|
22
|
+
for (const role of ROLES) {
|
|
23
|
+
if (!blocks.has(role)) throw new Error(`role ${role}: allow block not found`)
|
|
24
|
+
}
|
|
25
|
+
return blocks
|
|
26
|
+
} catch (yamlErr) {
|
|
27
|
+
warn(`source allow-list location via yaml failed (${yamlErr.message}) — falling back to regex`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return locateAllowBlocksRegex(srcText)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function locateAllowBlocksYaml(srcText, yamlMod) {
|
|
34
|
+
const doc = yamlMod.parseDocument(srcText)
|
|
35
|
+
const blocks = new Map()
|
|
36
|
+
walkMaps(doc.contents, (map) => {
|
|
37
|
+
const toolNamePair = map.items?.find((p) => p.key?.value === 'toolName')
|
|
38
|
+
if (!toolNamePair || !ROLES.includes(toolNamePair.value?.value)) return
|
|
39
|
+
const role = toolNamePair.value.value
|
|
40
|
+
if (blocks.has(role)) throw new Error(`duplicate role block: ${role}`)
|
|
41
|
+
const tfPair = map.items?.find((p) => p.key?.value === 'toolFilter')
|
|
42
|
+
const allowPair = tfPair?.value?.items?.find((p) => p.key?.value === 'allow')
|
|
43
|
+
const seq = allowPair?.value
|
|
44
|
+
if (!seq?.range || !Array.isArray(seq.items)) {
|
|
45
|
+
throw new Error(`role ${role}: toolFilter.allow list not found`)
|
|
46
|
+
}
|
|
47
|
+
const [seqStart, end] = seq.range
|
|
48
|
+
// seqStart points at the first item's `-`; the item line's leading
|
|
49
|
+
// whitespace lies before the range, so extend the replacement span back
|
|
50
|
+
// to the line start (otherwise the preserved indent would double).
|
|
51
|
+
const start = srcText.lastIndexOf('\n', seqStart - 1) + 1
|
|
52
|
+
if (srcText.slice(start, seqStart).trim() !== '' || srcText.slice(seqStart, seqStart + 1) !== '-') {
|
|
53
|
+
throw new Error(`role ${role}: unexpected allow-list layout`)
|
|
54
|
+
}
|
|
55
|
+
blocks.set(role, {
|
|
56
|
+
start,
|
|
57
|
+
end,
|
|
58
|
+
itemIndent: leadingIndent(srcText, seqStart),
|
|
59
|
+
names: seq.items.map((item) => String(item?.value ?? '')),
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
return blocks
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function walkMaps(node, visit) {
|
|
66
|
+
if (!node) return
|
|
67
|
+
if (node.constructor?.name === 'YAMLSeq') {
|
|
68
|
+
for (const item of node.items ?? []) walkMaps(item, visit)
|
|
69
|
+
} else if (node.constructor?.name === 'YAMLMap') {
|
|
70
|
+
visit(node)
|
|
71
|
+
for (const pair of node.items ?? []) walkMaps(pair.value, visit)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Number of leading spaces/tabs of the line containing `offset`. */
|
|
76
|
+
export function leadingIndent(text, offset) {
|
|
77
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1
|
|
78
|
+
const match = text.slice(lineStart, offset).match(/^[ \t]*/)
|
|
79
|
+
return match ? match[0].length : 0
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const ALLOW_BLOCK_RE = /toolFilter:\n([ \t]+)allow:\n((?:[ \t]+)- [^\n]*\n)+/g
|
|
83
|
+
|
|
84
|
+
export function locateAllowBlocksRegex(srcText) {
|
|
85
|
+
const blocks = new Map()
|
|
86
|
+
for (const role of ROLES) {
|
|
87
|
+
const marker = `toolName: ${role}`
|
|
88
|
+
const roleIdx = srcText.indexOf(marker)
|
|
89
|
+
if (roleIdx === -1) throw new Error(`role ${role}: "${marker}" not found`)
|
|
90
|
+
const nextIdx = srcText.indexOf('toolName: ', roleIdx + marker.length)
|
|
91
|
+
const segment = nextIdx === -1 ? srcText.slice(roleIdx) : srcText.slice(roleIdx, nextIdx)
|
|
92
|
+
ALLOW_BLOCK_RE.lastIndex = 0
|
|
93
|
+
const matches = [...segment.matchAll(ALLOW_BLOCK_RE)]
|
|
94
|
+
if (matches.length !== 1) {
|
|
95
|
+
throw new Error(`role ${role}: expected exactly 1 toolFilter.allow block, found ${matches.length}`)
|
|
96
|
+
}
|
|
97
|
+
const match = matches[0]
|
|
98
|
+
const allowLineEnd = match[0].indexOf('allow:\n') + 'allow:\n'.length
|
|
99
|
+
const itemsStart = roleIdx + match.index + allowLineEnd
|
|
100
|
+
const itemsEnd = roleIdx + match.index + match[0].length
|
|
101
|
+
const itemText = srcText.slice(itemsStart, itemsEnd)
|
|
102
|
+
blocks.set(role, {
|
|
103
|
+
start: itemsStart,
|
|
104
|
+
end: itemsEnd,
|
|
105
|
+
// itemsStart points at the first space of the first item line, so the
|
|
106
|
+
// indent is the leading whitespace of itemText itself (leadingIndent
|
|
107
|
+
// would measure the line before it).
|
|
108
|
+
itemIndent: (itemText.match(/^[ \t]*/) ?? [''])[0].length,
|
|
109
|
+
names: itemText
|
|
110
|
+
.split('\n')
|
|
111
|
+
.filter((l) => l.trim() !== '')
|
|
112
|
+
.map((l) => l.replace(/^[ \t]*- /, '').trim()),
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
return blocks
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── generation extensions (persona, custom roles, restrict injection) ───────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Locate each role's persona block scalar span in the source
|
|
122
|
+
* agent.cordis.yml. Returns Map role -> { start, end, indent, text } where
|
|
123
|
+
* [start, end) covers the whole block scalar including its `|-` marker.
|
|
124
|
+
*/
|
|
125
|
+
export function locatePersonaBlocks(srcText, yamlMod) {
|
|
126
|
+
const blocks = new Map()
|
|
127
|
+
if (yamlMod) {
|
|
128
|
+
try {
|
|
129
|
+
const doc = yamlMod.parseDocument(srcText)
|
|
130
|
+
walkMaps(doc.contents, (map) => {
|
|
131
|
+
const toolNamePair = map.items?.find((p) => p.key?.value === 'toolName')
|
|
132
|
+
if (!toolNamePair || !ROLES.includes(toolNamePair.value?.value)) return
|
|
133
|
+
const role = toolNamePair.value.value
|
|
134
|
+
const personaPair = map.items?.find((p) => p.key?.value === 'persona')
|
|
135
|
+
const node = personaPair?.value
|
|
136
|
+
if (!node?.range) throw new Error(`role ${role}: persona block not found`)
|
|
137
|
+
const [start, end] = node.range
|
|
138
|
+
const inner = srcText.slice(start, end)
|
|
139
|
+
const markerEnd = inner.indexOf('\n')
|
|
140
|
+
let indent = 0
|
|
141
|
+
if (markerEnd !== -1) {
|
|
142
|
+
const contentLineStart = start + markerEnd + 1
|
|
143
|
+
const contentLineEnd = srcText.indexOf('\n', contentLineStart)
|
|
144
|
+
const contentLine = srcText.slice(
|
|
145
|
+
contentLineStart,
|
|
146
|
+
contentLineEnd === -1 ? srcText.length : contentLineEnd,
|
|
147
|
+
)
|
|
148
|
+
indent = (contentLine.match(/^[ \t]*/) ?? [''])[0].length
|
|
149
|
+
}
|
|
150
|
+
blocks.set(role, { start, end, indent, text: inner })
|
|
151
|
+
})
|
|
152
|
+
for (const role of ROLES) if (!blocks.has(role)) throw new Error(`role ${role}: persona block not found`)
|
|
153
|
+
return blocks
|
|
154
|
+
} catch (yamlErr) {
|
|
155
|
+
warn(`source persona location via yaml failed (${yamlErr.message}) — falling back to regex`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const role of ROLES) {
|
|
159
|
+
const marker = `toolName: ${role}`
|
|
160
|
+
const roleIdx = srcText.indexOf(marker)
|
|
161
|
+
if (roleIdx === -1) throw new Error(`role ${role}: "${marker}" not found`)
|
|
162
|
+
const personaStart = srcText.indexOf('persona: |-', roleIdx)
|
|
163
|
+
if (personaStart === -1) throw new Error(`role ${role}: persona block not found (regex)`)
|
|
164
|
+
const dashStart = personaStart + 'persona: '.length // points at the `|` of `|-`
|
|
165
|
+
const contentStart = dashStart + '|-'.length + 1
|
|
166
|
+
const firstLineEnd = srcText.indexOf('\n', contentStart)
|
|
167
|
+
const firstLine = srcText.slice(contentStart, firstLineEnd === -1 ? srcText.length : firstLineEnd)
|
|
168
|
+
const indent = (firstLine.match(/^[ \t]*/) ?? [''])[0].length
|
|
169
|
+
// Block ends at the first non-blank line indented shallower than the
|
|
170
|
+
// content (the next map key, e.g. toolFilter).
|
|
171
|
+
let cursor = contentStart
|
|
172
|
+
let end = contentStart
|
|
173
|
+
while (cursor < srcText.length) {
|
|
174
|
+
const lineEnd = srcText.indexOf('\n', cursor)
|
|
175
|
+
const line = srcText.slice(cursor, lineEnd === -1 ? srcText.length : lineEnd)
|
|
176
|
+
if (line.trim() !== '' && (line.match(/^[ \t]*/) ?? [''])[0].length < indent) break
|
|
177
|
+
end = lineEnd === -1 ? srcText.length : lineEnd + 1
|
|
178
|
+
cursor = end
|
|
179
|
+
}
|
|
180
|
+
blocks.set(role, {
|
|
181
|
+
start: dashStart,
|
|
182
|
+
end,
|
|
183
|
+
indent,
|
|
184
|
+
text: srcText.slice(dashStart, end),
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
return blocks
|
|
188
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 公共状态管线:collectState(patch 分层扫描 / MCP 工具名解析 / 插件与技能检测 /
|
|
3
|
+
* smart defaults,含可选 runtimeFacts 合并)与 generateAndInstall(compose +
|
|
4
|
+
* yaml 校验 + dst 目录落盘 + saveConfig)。install.mjs 的公共导出
|
|
5
|
+
* (collectState / generateAndInstall)即来自本模块。
|
|
6
|
+
* 依赖 util / yaml / config / host / skills / spans / compose / alloc 与
|
|
7
|
+
* node:fs / node:path;被 cli 与 plugins/paoding-config-ui 引用。
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
chmodSync,
|
|
11
|
+
copyFileSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
statSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from 'node:fs'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { DEFAULT_MAIN_AGENT_PERSONA_EXTRA, ROLES, SRC_DIR, warn } from './util.mjs'
|
|
21
|
+
import { flattenEntries, loadYaml, parsePatchFile } from './yaml.mjs'
|
|
22
|
+
import { loadConfig, normalizeMainAgentName, saveConfig, yamlScalar } from './config.mjs'
|
|
23
|
+
import {
|
|
24
|
+
extractMcp,
|
|
25
|
+
isEntryEnabled,
|
|
26
|
+
isMcpEntry,
|
|
27
|
+
KNOWN_HOST_PLUGINS,
|
|
28
|
+
pluginLabel,
|
|
29
|
+
resolveMCP,
|
|
30
|
+
} from './host.mjs'
|
|
31
|
+
import { detectSkills, findSkillMeta } from './skills.mjs'
|
|
32
|
+
import { locateAllowBlocks, locatePersonaBlocks } from './spans.mjs'
|
|
33
|
+
import { composeGenerated, extractMainAgentAllow } from './compose.mjs'
|
|
34
|
+
import { smartDefaults } from './alloc.mjs'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reusable state collection for the CLI and the visual config UI.
|
|
38
|
+
* Runs the whole detection pipeline (patch scan, MCP handshakes, plugin and
|
|
39
|
+
* skill detection, smart defaults) and returns everything the generator needs,
|
|
40
|
+
* without writing anything or prompting. Throws on hard errors.
|
|
41
|
+
*
|
|
42
|
+
* `runtimeFacts`(可选,仅 Web 配置器经 ctx 传入)是运行时优先的补充事实:
|
|
43
|
+
* { ok, error?, pluginEntries, toolNames },来自 plugins/paoding-config-ui/
|
|
44
|
+
* runtime-inventory.mjs。不传时整段合并逻辑跳过,行为与旧版完全一致;传入时
|
|
45
|
+
* 把 preset 未覆盖的运行时工具并进 inventory / mcpReports / pluginReports,
|
|
46
|
+
* 让 bundle 形态加载的插件(@hyzyn/dsh-codegraph、dsh-mnemon 等)也能被识别。
|
|
47
|
+
* 文件扫描路径(KNOWN_HOST_PLUGINS 白名单等)始终保持为兜底,不动。
|
|
48
|
+
*/
|
|
49
|
+
export async function collectState({ dshHome, configFile, profile = null, patches = [], cwd = process.cwd(), runtimeFacts = null }) {
|
|
50
|
+
const dstDir = path.join(dshHome, '.agent-presets', 'orchestrator')
|
|
51
|
+
const dstAgentFile = path.join(dstDir, 'agent.cordis.yml')
|
|
52
|
+
|
|
53
|
+
// Source preset files must exist before anything else.
|
|
54
|
+
const sourceFiles = ['agent.cordis.yml', 'preset.yml', 'restrict.mjs'].map((f) => path.join(SRC_DIR, f))
|
|
55
|
+
for (const file of sourceFiles) {
|
|
56
|
+
if (!existsSync(file)) throw new Error(`missing preset source file: ${file}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const yamlMod = loadYaml(dshHome)
|
|
60
|
+
const srcText = readFileSync(path.join(SRC_DIR, 'agent.cordis.yml'), 'utf8')
|
|
61
|
+
const restrictSrc = readFileSync(path.join(SRC_DIR, 'restrict.mjs'), 'utf8')
|
|
62
|
+
|
|
63
|
+
// Locate role allow + persona blocks; failure is a hard error.
|
|
64
|
+
let blocks
|
|
65
|
+
let personaBlocks
|
|
66
|
+
try {
|
|
67
|
+
blocks = locateAllowBlocks(srcText, yamlMod)
|
|
68
|
+
personaBlocks = locatePersonaBlocks(srcText, yamlMod)
|
|
69
|
+
} catch (err) {
|
|
70
|
+
throw new Error(`cannot locate role blocks in ${path.join(SRC_DIR, 'agent.cordis.yml')}: ${err.message}`)
|
|
71
|
+
}
|
|
72
|
+
let restrictBase
|
|
73
|
+
try {
|
|
74
|
+
restrictBase = extractMainAgentAllow(restrictSrc)
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw new Error(err.message)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Load the existing config (null when absent); its profile wins unless an
|
|
80
|
+
// explicit profile was given.
|
|
81
|
+
let existing = null
|
|
82
|
+
try {
|
|
83
|
+
existing = loadConfig(configFile, yamlMod)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
throw new Error(err.message)
|
|
86
|
+
}
|
|
87
|
+
const effectiveProfile = profile ?? existing?.profile ?? 'web'
|
|
88
|
+
|
|
89
|
+
// Scan the patch layers (home, profile, extra --patch files) in order.
|
|
90
|
+
const patchSources = [
|
|
91
|
+
{ path: path.join(dshHome, 'cordis.patch.yml'), explicit: false },
|
|
92
|
+
{ path: path.join(dshHome, 'profiles', effectiveProfile, 'cordis.patch.yml'), explicit: false },
|
|
93
|
+
...patches.map((p) => ({ path: path.resolve(p), explicit: true })),
|
|
94
|
+
]
|
|
95
|
+
const entries = []
|
|
96
|
+
const scanned = []
|
|
97
|
+
for (const source of patchSources) {
|
|
98
|
+
if (!existsSync(source.path)) {
|
|
99
|
+
if (source.explicit) warn(`${source.path}: not found — skipped`)
|
|
100
|
+
scanned.push({ path: source.path, status: 'missing' })
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
const data = parsePatchFile(source.path, yamlMod)
|
|
104
|
+
if (data === null) {
|
|
105
|
+
scanned.push({ path: source.path, status: 'unparseable' })
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
scanned.push({ path: source.path, status: 'parsed' })
|
|
109
|
+
entries.push(...flattenEntries(data))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Detect enabled MCP servers and local tool plugins.
|
|
113
|
+
const mcps = []
|
|
114
|
+
const pluginReports = []
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (isMcpEntry(entry) && isEntryEnabled(entry)) {
|
|
117
|
+
const mcp = extractMcp(entry)
|
|
118
|
+
if (mcp) mcps.push(mcp)
|
|
119
|
+
else warn(`MCP entry "${entry.name}" has no usable config (missing serverName/map) — skipped, its tools will be absent`)
|
|
120
|
+
}
|
|
121
|
+
if (typeof entry?.name === 'string') {
|
|
122
|
+
for (const plugin of KNOWN_HOST_PLUGINS) {
|
|
123
|
+
if (plugin.match.test(entry.name) && isEntryEnabled(entry) && entry.config?.enabled !== false) {
|
|
124
|
+
pluginReports.push({ label: pluginLabel(plugin), tools: [...plugin.tools] })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Resolve exact MCP tool names into the detection inventory D.
|
|
131
|
+
const inventory = new Set()
|
|
132
|
+
const mcpReports = []
|
|
133
|
+
for (const mcp of mcps) {
|
|
134
|
+
const result = await resolveMCP(mcp)
|
|
135
|
+
for (const tool of result.tools) inventory.add(`mcp__${mcp.serverName}__${tool}`)
|
|
136
|
+
mcpReports.push({
|
|
137
|
+
serverName: mcp.serverName,
|
|
138
|
+
transport: mcp.transport,
|
|
139
|
+
count: result.tools.length,
|
|
140
|
+
source: result.source,
|
|
141
|
+
error: result.error,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
for (const plugin of pluginReports) {
|
|
145
|
+
for (const tool of plugin.tools) inventory.add(tool)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const skills = detectSkills(dshHome, cwd)
|
|
149
|
+
const staticBase = {}
|
|
150
|
+
for (const role of ROLES) staticBase[role] = blocks.get(role).names
|
|
151
|
+
const suggested = smartDefaults(mcpReports, inventory, staticBase)
|
|
152
|
+
|
|
153
|
+
// ── 运行时事实合并(可选;CLI/无运行时不传 runtimeFacts,整段零开销跳过)──
|
|
154
|
+
// preset 自带名(restrictBase + 各角色 staticBase)之外的运行时工具叫 extras,
|
|
155
|
+
// 全部并进 inventory,UI 的角色候选 = blocks + inventory 立即可见;extra 里
|
|
156
|
+
// mcp__<server>__<tool> 形态的按 server 补一份 runtime 来源的 mcpReports
|
|
157
|
+
// (已有文件握手 report 的 server 不动);pluginEntries 里非内置包(避开
|
|
158
|
+
// @deepseek-ai/*、dsh-client*、cordis)的条目补进 pluginReports(工具数不填,
|
|
159
|
+
// 无归属信息;白名单命中的以白名单精确清单优先)。整段 try/catch:任何一步
|
|
160
|
+
// 失败只降级为少识别一部分,绝不影响文件扫描兜底结果。
|
|
161
|
+
if (runtimeFacts !== null && Array.isArray(runtimeFacts.toolNames)) {
|
|
162
|
+
try {
|
|
163
|
+
// 1) preset 自带名称全集(注意 staticBase 的值是数组)
|
|
164
|
+
const presetUniverse = new Set([...restrictBase, ...Object.values(staticBase).flat()])
|
|
165
|
+
// 2) extras = 运行时注册表里 preset 未覆盖的名字(去重、保序)
|
|
166
|
+
const extras = []
|
|
167
|
+
for (const name of runtimeFacts.toolNames) {
|
|
168
|
+
if (typeof name !== 'string' || name === '') continue
|
|
169
|
+
if (!presetUniverse.has(name) && !extras.includes(name)) extras.push(name)
|
|
170
|
+
}
|
|
171
|
+
// 3) extras 全部并入检测库存 D
|
|
172
|
+
for (const name of extras) inventory.add(name)
|
|
173
|
+
|
|
174
|
+
// 4) extras 里 mcp__<server>__<tool> 按 server 分组补 mcpReports
|
|
175
|
+
// (非贪婪正则取第一个 __ 分隔:前缀为 server、后缀为 tool;
|
|
176
|
+
// server 名自带 '_' 时仅影响展示分组,不影响 inventory 本体)
|
|
177
|
+
const runtimeMcp = new Map()
|
|
178
|
+
for (const name of extras) {
|
|
179
|
+
const m = /^mcp__(.+?)__(.+)$/.exec(name)
|
|
180
|
+
if (m) {
|
|
181
|
+
const group = runtimeMcp.get(m[1])
|
|
182
|
+
if (group) group.push(name)
|
|
183
|
+
else runtimeMcp.set(m[1], [name])
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const [serverName, names] of runtimeMcp) {
|
|
187
|
+
if (mcpReports.some((r) => r.serverName === serverName)) continue // 握手结果优先
|
|
188
|
+
mcpReports.push({ serverName, transport: undefined, count: names.length, source: 'runtime', error: undefined })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 5) 运行时插件条目补进 pluginReports(过滤内置包避免刷屏)
|
|
192
|
+
if (Array.isArray(runtimeFacts.pluginEntries) && runtimeFacts.pluginEntries.length > 0) {
|
|
193
|
+
for (const e of runtimeFacts.pluginEntries) {
|
|
194
|
+
const moduleName = typeof e?.moduleName === 'string' ? e.moduleName : ''
|
|
195
|
+
if (moduleName === '') continue
|
|
196
|
+
if (moduleName.startsWith('@deepseek-ai/')) continue
|
|
197
|
+
if (moduleName.includes('dsh-client')) continue
|
|
198
|
+
if (moduleName.includes('cordis')) continue
|
|
199
|
+
if (pluginReports.some((p) => p.label === moduleName)) continue // 白名单已覆盖
|
|
200
|
+
pluginReports.push({ label: moduleName, tools: [], runtime: true })
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
warn(`runtime facts merge failed (skipped, file-scan results kept): ${err?.message ?? err}`)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
dshHome,
|
|
210
|
+
cwd,
|
|
211
|
+
dstDir,
|
|
212
|
+
dstAgentFile,
|
|
213
|
+
configFile,
|
|
214
|
+
yamlMod,
|
|
215
|
+
srcText,
|
|
216
|
+
restrictSrc,
|
|
217
|
+
blocks,
|
|
218
|
+
personaBlocks,
|
|
219
|
+
restrictBase,
|
|
220
|
+
existing,
|
|
221
|
+
effectiveProfile,
|
|
222
|
+
scanned,
|
|
223
|
+
mcpReports,
|
|
224
|
+
pluginReports,
|
|
225
|
+
inventory,
|
|
226
|
+
skills,
|
|
227
|
+
staticBase,
|
|
228
|
+
suggested,
|
|
229
|
+
// 主 persona 尾部追加的默认常量(配置键 main_agent_persona_extra 缺省值):
|
|
230
|
+
// 供 UI state(serializeState → /api/paoding/state)展示与「恢复默认」用。
|
|
231
|
+
mainPersonaExtraDefault: DEFAULT_MAIN_AGENT_PERSONA_EXTRA,
|
|
232
|
+
// 运行时事实透传(原字段一个未动,仅追加;未传 runtimeFacts 时为空值,
|
|
233
|
+
// serializeState 与既有消费者均按缺省处理,零回归)
|
|
234
|
+
runtimePlugins: runtimeFacts?.pluginEntries ?? [],
|
|
235
|
+
runtimeOk: !!runtimeFacts?.ok,
|
|
236
|
+
runtimeError: runtimeFacts?.error ?? null,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Compose, validate and (unless dry-run) install the generated preset from an
|
|
242
|
+
* assignments object. Shared by the CLI and the visual config UI.
|
|
243
|
+
* Returns { text, roleResults, staleNote, wrote }.
|
|
244
|
+
*/
|
|
245
|
+
export function generateAndInstall(state, assignments, { dryRun = false, saveConfigOnWrite = false } = {}) {
|
|
246
|
+
const { srcText, blocks, personaBlocks, inventory, restrictBase, yamlMod, dshHome, cwd, dstDir, dstAgentFile, configFile, scanned, effectiveProfile } = state
|
|
247
|
+
|
|
248
|
+
// Pre-read the SKILL.md metas of the skills selected for the main-agent
|
|
249
|
+
// persona (soft rows via main_agent_skills + optional hard inlining via
|
|
250
|
+
// main_agent_skills_inline; union, deduped). Names whose file is missing
|
|
251
|
+
// simply do not enter the map — composeGenerated warns about them.
|
|
252
|
+
const softNames = (assignments?.main_agent_skills ?? []).filter((n) => typeof n === 'string' && n !== '')
|
|
253
|
+
const hardNames = (assignments?.main_agent_skills_inline ?? []).filter((n) => typeof n === 'string' && n !== '')
|
|
254
|
+
const mainAgentSkillMetas = {}
|
|
255
|
+
for (const name of [...new Set([...softNames, ...hardNames])]) {
|
|
256
|
+
const hit = findSkillMeta(name, dshHome, cwd)
|
|
257
|
+
if (hit) mainAgentSkillMetas[name] = hit
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Compose the generated config; a composition error is a hard error.
|
|
261
|
+
let composed
|
|
262
|
+
try {
|
|
263
|
+
composed = composeGenerated(srcText, blocks, personaBlocks, assignments, inventory, restrictBase, mainAgentSkillMetas)
|
|
264
|
+
} catch (err) {
|
|
265
|
+
throw new Error(err.message)
|
|
266
|
+
}
|
|
267
|
+
const generatedText = composed.text
|
|
268
|
+
const roleResults = composed.roleResults
|
|
269
|
+
|
|
270
|
+
// Validate the generated YAML before writing anything.
|
|
271
|
+
if (yamlMod) {
|
|
272
|
+
// `!!js <expr>` 是 cordis 的 JS 表达式标签(tool-bash / tool-pwsh 的平台开关
|
|
273
|
+
// 用)。本校验只关心结构合法性、不求值表达式:dsh loadYaml 的默认 schema 未
|
|
274
|
+
// 注册该标签,直接解析会对每行打 TAG_RESOLVE_FAILED 噪音警告。这里在「校验
|
|
275
|
+
// 副本」上把 !!js 标量换成占位 true 再解析;生成文本原样写入,表达式求值仍
|
|
276
|
+
// 由 cordis 装载时完成,语义零影响。
|
|
277
|
+
const validateText = generatedText.replace(/!!js\s+[^\r\n]*/g, 'true')
|
|
278
|
+
try {
|
|
279
|
+
yamlMod.parse(validateText)
|
|
280
|
+
} catch (err) {
|
|
281
|
+
throw new Error(`generated agent.cordis.yml fails yaml validation: ${err.message}`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Freshness note: any parsed patch file modified after the last generated
|
|
286
|
+
// agent.cordis.yml means the user should rerun the installer after changes.
|
|
287
|
+
let staleNote = false
|
|
288
|
+
if (existsSync(dstAgentFile)) {
|
|
289
|
+
const targetMtime = statSync(dstAgentFile).mtimeMs
|
|
290
|
+
staleNote = scanned.some(
|
|
291
|
+
(s) => s.status === 'parsed' && existsSync(s.path) && statSync(s.path).mtimeMs > targetMtime,
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let wrote = false
|
|
296
|
+
if (!dryRun) {
|
|
297
|
+
mkdirSync(path.join(dshHome, '.agent-presets'), { recursive: true })
|
|
298
|
+
rmSync(dstDir, { recursive: true, force: true })
|
|
299
|
+
mkdirSync(dstDir, { recursive: true })
|
|
300
|
+
for (const name of ['preset.yml', 'restrict.mjs']) {
|
|
301
|
+
const src = path.join(SRC_DIR, name)
|
|
302
|
+
const dst = path.join(dstDir, name)
|
|
303
|
+
const mainAgentName = normalizeMainAgentName(assignments?.main_agent_name ?? null)
|
|
304
|
+
if (name === 'preset.yml' && mainAgentName !== null) {
|
|
305
|
+
// main_agent_name 非空:preset 显示名改为配置名。读 SRC 文本、把首个
|
|
306
|
+
// (非注释的)顶层 `name:` 行值替换为安全 yaml 标量(含 ':' / '#' 等时
|
|
307
|
+
// 单引号包裹转义),其余字节原样;chmod 与下方一致。
|
|
308
|
+
const presetText = readFileSync(src, 'utf8')
|
|
309
|
+
const next = presetText.replace(/^name:.*$/m, `name: ${yamlScalar(mainAgentName)}`)
|
|
310
|
+
if (next === presetText) {
|
|
311
|
+
warn('preset.yml: 未找到可替换的顶层 name: 行(main_agent_name 未生效),已按原样写入')
|
|
312
|
+
}
|
|
313
|
+
writeFileSync(dst, next)
|
|
314
|
+
} else {
|
|
315
|
+
copyFileSync(src, dst)
|
|
316
|
+
}
|
|
317
|
+
chmodSync(dst, statSync(src).mode & 0o777) // preserve source permissions
|
|
318
|
+
}
|
|
319
|
+
writeFileSync(path.join(dstDir, 'agent.cordis.yml'), generatedText)
|
|
320
|
+
chmodSync(path.join(dstDir, 'agent.cordis.yml'), statSync(path.join(SRC_DIR, 'agent.cordis.yml')).mode & 0o777)
|
|
321
|
+
if (saveConfigOnWrite) {
|
|
322
|
+
saveConfig(configFile, { profile: effectiveProfile, ...assignments })
|
|
323
|
+
}
|
|
324
|
+
wrote = true
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return { text: generatedText, roleResults, staleNote, wrote }
|
|
328
|
+
}
|