@weibaohui/experts-management 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/client/bundle.js +815 -0
- package/client/index.js +801 -0
- package/cordis.patch.yml +13 -0
- package/package.json +63 -0
- package/src/index.js +949 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* dsh-plugin-experts-management — Host half
|
|
5
|
+
*
|
|
6
|
+
* Manages ntd-format experts (WorkBuddy plugin.json + Agent MD + skills)
|
|
7
|
+
* WITHOUT touching the ntd application's own directories:
|
|
8
|
+
* - Market: ntd-resource's experts/ subtree via git sparse checkout into the
|
|
9
|
+
* plugin's own dir ($DSH_HOME/experts-management/market). Read-only shelf;
|
|
10
|
+
* install copies into the user library.
|
|
11
|
+
* - User library: $DSH_HOME/experts (writable, the only built-in source).
|
|
12
|
+
* Additional directories are opt-in via config.extraSources.
|
|
13
|
+
* - Model integration: every expert registers on the host skills registry as
|
|
14
|
+
* a USER-INVOCABLE, MODEL-INVISIBLE skill (`disable-model-invocation`
|
|
15
|
+
* semantics, name `expert-<name>`). Typing `/expert-<name>` in a message
|
|
16
|
+
* (or picking it from the composer menu) makes the host's user-explicit
|
|
17
|
+
* gesture boundary deterministically inject the expert's role prompt —
|
|
18
|
+
* ntd's three-section injection, zero model-catalog tokens, zero host code.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { createReadStream } = require('node:fs')
|
|
22
|
+
const { execFile } = require('node:child_process')
|
|
23
|
+
const { randomUUID } = require('node:crypto')
|
|
24
|
+
const fsP = require('node:fs/promises')
|
|
25
|
+
const { basename, join, relative, resolve, sep } = require('node:path')
|
|
26
|
+
const { homedir } = require('node:os')
|
|
27
|
+
const YAML = require('yaml')
|
|
28
|
+
// settings 服务要求 schemastery schema(可调用 + toJSON;zod 不兼容,register 会抛错被吞)。
|
|
29
|
+
// 宿主沙箱内解析打包依赖可能抛 ERR_INTERNAL_ASSERTION(.pnpm 软链),因此优先沿
|
|
30
|
+
// dsh 全局安装取 settings 服务自用的那份副本,本地开发/测试再退回标准 require。
|
|
31
|
+
function loadSchemastery() {
|
|
32
|
+
const errors = []
|
|
33
|
+
const { createRequire } = require('node:module')
|
|
34
|
+
for (const prefix of [process.env.DSH_GLOBAL_PREFIX, join(homedir(), '.local')].filter(Boolean)) {
|
|
35
|
+
const hostCopy = join(prefix, 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules', '@deepseek-ai', 'schemastery', 'lib', 'index.cjs')
|
|
36
|
+
try { return createRequire(hostCopy)(hostCopy) } catch (e) { errors.push(String(e && e.code || e)) }
|
|
37
|
+
}
|
|
38
|
+
try { return require('@deepseek-ai/schemastery') } catch (e) { errors.push(String(e && e.code || e)) }
|
|
39
|
+
if (process.env.EXPERTS_SETTINGS_DEBUG) console.warn(`[experts-management] schemastery unavailable: ${errors.join(' | ')}`)
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
const Schema = loadSchemastery()
|
|
43
|
+
|
|
44
|
+
/** dsh 数据根(与宿主一致:$DSH_HOME,缺省 ~/.dsh)。 */
|
|
45
|
+
function dshHome() {
|
|
46
|
+
return process.env.DSH_HOME ? resolve(process.env.DSH_HOME) : join(homedir(), '.dsh')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MARKET_SCAN_SKIP = new Set(['.git', 'node_modules'])
|
|
50
|
+
const RANK_INSTALLED = 100
|
|
51
|
+
const RANK_MARKET = 500
|
|
52
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
53
|
+
const DESCRIPTION_LIMIT = 140
|
|
54
|
+
// 同款正则见 skill/skill/src/index.ts SKILL_NAME —— 不合规的候选会让 registry 抛错
|
|
55
|
+
const KEBAB_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
56
|
+
// 专家在宿主技能注册表里的名字前缀:expert-<plugin.name>,防与真实技能撞名
|
|
57
|
+
const EXPERT_NAME_PREFIX = 'expert-'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Built-in sources: ONLY the dsh user library. ntd 应用自身的目录
|
|
61
|
+
* (~/.ntd/*)一律不扫描不读取;需要纳管其他目录时经 config.extraSources
|
|
62
|
+
* 显式加入(可标 readOnly)。
|
|
63
|
+
*/
|
|
64
|
+
const SOURCE_DEFS = [
|
|
65
|
+
{ key: 'dsh', label: 'DSH' },
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
/** plugin.json 的固定入口目录(WorkBuddy/CodeBuddy 兼容格式)。 */
|
|
69
|
+
const PLUGIN_JSON_REL = '.codebuddy-plugin/plugin.json'
|
|
70
|
+
|
|
71
|
+
/** Absolute path with the $HOME prefix folded to `~` (no username leaks in UI). */
|
|
72
|
+
function displayPath(p) {
|
|
73
|
+
const home = homedir()
|
|
74
|
+
if (p === home) return '~'
|
|
75
|
+
if (p.startsWith(home + sep)) return '~' + p.slice(home.length)
|
|
76
|
+
return p
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Parsing: frontmatter + plugin.json ───────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function extractFrontmatter(content) {
|
|
82
|
+
const lines = content.split(/\r?\n/)
|
|
83
|
+
if (lines[0] === undefined || lines[0].trim() !== '---') return undefined
|
|
84
|
+
const yamlLines = []
|
|
85
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
86
|
+
const line = lines[index]
|
|
87
|
+
if (line.trim() === '---') return yamlLines.join('\n')
|
|
88
|
+
yamlLines.push(line)
|
|
89
|
+
}
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseFrontmatter(content) {
|
|
94
|
+
const yamlText = extractFrontmatter(content)
|
|
95
|
+
if (yamlText === undefined) return { meta: {}, body: content }
|
|
96
|
+
let meta = {}
|
|
97
|
+
try {
|
|
98
|
+
const parsed = YAML.parse(yamlText)
|
|
99
|
+
if (parsed !== null && typeof parsed === 'object') meta = parsed
|
|
100
|
+
} catch {}
|
|
101
|
+
const lines = content.split(/\r?\n/)
|
|
102
|
+
let closer = -1
|
|
103
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
104
|
+
if (lines[index].trim() === '---') { closer = index; break }
|
|
105
|
+
}
|
|
106
|
+
// body 切掉 frontmatter 块(含紧随的空行):注入 prompt 只携带正文
|
|
107
|
+
const body = closer >= 0 ? lines.slice(closer + 1).join('\n').replace(/^\r?\n/, '') : content
|
|
108
|
+
return { meta, body }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 取 LocalizedText({zh,en} 或纯字符串)某一语言,带回退。 */
|
|
112
|
+
function localized(value, lang, fallbackLang) {
|
|
113
|
+
if (typeof value === 'string') return value
|
|
114
|
+
if (value !== null && typeof value === 'object') {
|
|
115
|
+
const primary = typeof value[lang] === 'string' ? value[lang] : undefined
|
|
116
|
+
const secondary = fallbackLang !== undefined && typeof value[fallbackLang] === 'string' ? value[fallbackLang] : undefined
|
|
117
|
+
return primary ?? secondary
|
|
118
|
+
}
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function truncateDescription(text) {
|
|
123
|
+
if (typeof text !== 'string') return ''
|
|
124
|
+
const single = text.split(/\r?\n/)[0].trim()
|
|
125
|
+
return single.length > DESCRIPTION_LIMIT ? single.slice(0, DESCRIPTION_LIMIT) + '…' : single
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 解析 agents/<name>.md 的 YAML frontmatter(name/description/color/emoji/vibe)。 */
|
|
129
|
+
function parseAgentMd(content, fallbackName) {
|
|
130
|
+
const { meta } = parseFrontmatter(content)
|
|
131
|
+
return {
|
|
132
|
+
name: typeof meta.name === 'string' && meta.name !== '' ? meta.name : fallbackName,
|
|
133
|
+
description: typeof meta.description === 'string' ? meta.description : undefined,
|
|
134
|
+
color: typeof meta.color === 'string' ? meta.color : undefined,
|
|
135
|
+
emoji: typeof meta.emoji === 'string' ? meta.emoji : undefined,
|
|
136
|
+
vibe: typeof meta.vibe === 'string' ? meta.vibe : undefined,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 解析 skills/<name>/SKILL.md 的 frontmatter 摘要。 */
|
|
141
|
+
function parseSkillMd(content) {
|
|
142
|
+
const { meta } = parseFrontmatter(content)
|
|
143
|
+
return {
|
|
144
|
+
name: typeof meta.name === 'string' && meta.name !== '' ? meta.name : undefined,
|
|
145
|
+
description: typeof meta.description === 'string' ? meta.description : undefined,
|
|
146
|
+
descriptionZh: typeof meta.description_zh === 'string' ? meta.description_zh : undefined,
|
|
147
|
+
descriptionEn: typeof meta.description_en === 'string' ? meta.description_en : undefined,
|
|
148
|
+
version: typeof meta.version === 'string' ? meta.version : undefined,
|
|
149
|
+
emoji: typeof meta.emoji === 'string' ? meta.emoji : undefined,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 解析 plugin.json → 专家记录的“头部”字段(不含 agent/skill 明细)。
|
|
155
|
+
* 展示字段按 ntd 语义回退:displayName.zh → .en → name。
|
|
156
|
+
*/
|
|
157
|
+
function parsePluginJson(raw) {
|
|
158
|
+
const plugin = typeof raw === 'string' ? JSON.parse(raw) : raw
|
|
159
|
+
const name = String(plugin.name || '')
|
|
160
|
+
const expertType = plugin.expertType === 'team' ? 'team' : 'agent'
|
|
161
|
+
const members = Array.isArray(plugin.members) ? plugin.members.map((m) => ({
|
|
162
|
+
id: String(m.id || ''),
|
|
163
|
+
nameZh: localized(m.name, 'zh', 'en'),
|
|
164
|
+
nameEn: localized(m.name, 'en'),
|
|
165
|
+
professionZh: localized(m.profession, 'zh', 'en'),
|
|
166
|
+
professionEn: localized(m.profession, 'en'),
|
|
167
|
+
avatar: typeof m.avatar === 'string' ? m.avatar : undefined,
|
|
168
|
+
role: m.role === 'lead' ? 'lead' : 'member',
|
|
169
|
+
})) : []
|
|
170
|
+
const teamInfo = plugin.teamInfo !== null && typeof plugin.teamInfo === 'object' ? plugin.teamInfo : {}
|
|
171
|
+
return {
|
|
172
|
+
name,
|
|
173
|
+
expertType,
|
|
174
|
+
version: typeof plugin.version === 'string' ? plugin.version : undefined,
|
|
175
|
+
displayNameZh: localized(plugin.displayName, 'zh', 'en') ?? (name || undefined),
|
|
176
|
+
displayNameEn: localized(plugin.displayName, 'en') ?? (name || undefined),
|
|
177
|
+
professionZh: localized(plugin.profession, 'zh', 'en'),
|
|
178
|
+
professionEn: localized(plugin.profession, 'en'),
|
|
179
|
+
descZh: localized(plugin.displayDescription, 'zh', 'en') ?? (typeof plugin.description_zh === 'string' ? plugin.description_zh : undefined) ?? (typeof plugin.description === 'string' ? plugin.description : undefined),
|
|
180
|
+
descEn: localized(plugin.displayDescription, 'en') ?? (typeof plugin.description === 'string' ? plugin.description : undefined),
|
|
181
|
+
avatar: typeof plugin.avatar === 'string' && plugin.avatar !== '' ? plugin.avatar : undefined,
|
|
182
|
+
categoryId: typeof plugin.categoryId === 'string' ? plugin.categoryId : undefined,
|
|
183
|
+
agents: Array.isArray(plugin.agents) ? plugin.agents.map(String) : undefined,
|
|
184
|
+
agentName: typeof plugin.agentName === 'string' && plugin.agentName !== '' ? plugin.agentName : undefined,
|
|
185
|
+
leadAgent: typeof teamInfo.leadAgent === 'string' && teamInfo.leadAgent !== '' ? teamInfo.leadAgent : undefined,
|
|
186
|
+
memberAgents: Array.isArray(teamInfo.memberAgents) ? teamInfo.memberAgents.map(String) : [],
|
|
187
|
+
members,
|
|
188
|
+
skills: Array.isArray(plugin.skills) ? plugin.skills.map(String) : [],
|
|
189
|
+
defaultInitPromptZh: localized(plugin.defaultInitPrompt, 'zh', 'en'),
|
|
190
|
+
defaultInitPromptEn: localized(plugin.defaultInitPrompt, 'en'),
|
|
191
|
+
quickPromptsZh: Array.isArray(plugin.quickPrompts) ? plugin.quickPrompts.map((q) => localized(q, 'zh', 'en')).filter(Boolean) : [],
|
|
192
|
+
tags: Array.isArray(plugin.tags) ? plugin.tags.map((t) => ({ zh: localized(t, 'zh', 'en') || '', en: localized(t, 'en') || '' })) : [],
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Path safety(ntd resolve_within 语义)────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/** 解析相对路径并校验仍位于 base 内,防 plugin.json 里的 .. / 绝对路径越界读文件。 */
|
|
199
|
+
function resolveWithin(base, rel) {
|
|
200
|
+
if (typeof rel !== 'string' || rel === '') return undefined
|
|
201
|
+
const target = resolve(base, rel)
|
|
202
|
+
const baseResolved = resolve(base)
|
|
203
|
+
if (target === baseResolved || target.startsWith(baseResolved + sep)) return target
|
|
204
|
+
return undefined
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** ntd is_safe_expert_name:目录名只拒绝路径分隔符、父级引用与控制字符(中文名合法)。 */
|
|
208
|
+
function isSafeExpertName(name) {
|
|
209
|
+
if (typeof name !== 'string' || name === '') return false
|
|
210
|
+
if (name.includes('/') || name.includes('\\') || name.includes('..')) return false
|
|
211
|
+
if ([...name].some((ch) => { const c = ch.codePointAt(0); return c < 32 || c === 127 })) return false
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Scanning ─────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 扫描一个专家来源根目录(ntd 语义:只看一层子目录,每个含
|
|
219
|
+
* .codebuddy-plugin/plugin.json 的目录是一个专家)。
|
|
220
|
+
* 失败的专家跳过并返回 errors,单个坏专家不拖垮整个市场。
|
|
221
|
+
*/
|
|
222
|
+
async function scanExpertsRoot(root, sourceKey) {
|
|
223
|
+
const experts = []
|
|
224
|
+
const errors = []
|
|
225
|
+
let entries
|
|
226
|
+
try { entries = await fsP.readdir(root, { withFileTypes: true }) } catch { return { experts, errors } }
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (!entry.isDirectory()) continue
|
|
229
|
+
if (MARKET_SCAN_SKIP.has(entry.name)) continue
|
|
230
|
+
const dir = join(root, entry.name)
|
|
231
|
+
try {
|
|
232
|
+
experts.push(await readExpertDir(root, dir, sourceKey))
|
|
233
|
+
} catch (e) {
|
|
234
|
+
errors.push(`${entry.name}: ${e && e.message}`)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
experts.sort((a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1)
|
|
238
|
+
return { experts, errors }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 读取单个专家目录 → 完整记录(plugin.json 头部 + agent MD 明细 + skills 明细)。 */
|
|
242
|
+
async function readExpertDir(root, dir, sourceKey) {
|
|
243
|
+
const pluginJsonPath = join(dir, PLUGIN_JSON_REL)
|
|
244
|
+
const raw = await fsP.readFile(pluginJsonPath, 'utf8')
|
|
245
|
+
const head = parsePluginJson(raw)
|
|
246
|
+
if (head.name === '') throw new Error('plugin.json missing name')
|
|
247
|
+
let stat
|
|
248
|
+
try { stat = await fsP.stat(pluginJsonPath) } catch { stat = undefined }
|
|
249
|
+
|
|
250
|
+
// agents 列表:plugin.agents 优先;缺失时扫描 agents/*.md 兜底(旧版 team 格式)
|
|
251
|
+
let agentRels = head.agents
|
|
252
|
+
if (agentRels === undefined) {
|
|
253
|
+
agentRels = []
|
|
254
|
+
try {
|
|
255
|
+
const found = []
|
|
256
|
+
for (const ent of await fsP.readdir(join(dir, 'agents'), { withFileTypes: true })) {
|
|
257
|
+
if (ent.isFile() && ent.name.endsWith('.md')) found.push(`./agents/${ent.name}`)
|
|
258
|
+
}
|
|
259
|
+
agentRels = found.sort()
|
|
260
|
+
} catch { agentRels = [] }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const agentFiles = []
|
|
264
|
+
for (const rel of agentRels) {
|
|
265
|
+
const mdPath = resolveWithin(dir, rel)
|
|
266
|
+
if (mdPath === undefined) continue // 路径逃逸:拒绝
|
|
267
|
+
try {
|
|
268
|
+
const content = await fsP.readFile(mdPath, 'utf8')
|
|
269
|
+
const meta = parseAgentMd(content, basename(mdPath).replace(/\.md$/, ''))
|
|
270
|
+
agentFiles.push({ ...meta, relPath: rel, mdPath })
|
|
271
|
+
} catch { /* 单个 agent 文件坏了不影响其余 */ }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const skillMeta = []
|
|
275
|
+
for (const rel of head.skills) {
|
|
276
|
+
const skillDir = resolveWithin(dir, rel)
|
|
277
|
+
if (skillDir === undefined) continue
|
|
278
|
+
const skillMdPath = join(skillDir, 'SKILL.md')
|
|
279
|
+
let content
|
|
280
|
+
try { content = await fsP.readFile(skillMdPath, 'utf8') } catch { continue }
|
|
281
|
+
const parsed = parseSkillMd(content)
|
|
282
|
+
skillMeta.push({
|
|
283
|
+
...parsed,
|
|
284
|
+
skillName: parsed.name ?? basename(skillDir),
|
|
285
|
+
skillDir,
|
|
286
|
+
skillMdPath,
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
...head,
|
|
292
|
+
source: sourceKey,
|
|
293
|
+
dir,
|
|
294
|
+
root,
|
|
295
|
+
relPath: relative(root, dir).split(sep).join('/'),
|
|
296
|
+
pluginJsonPath,
|
|
297
|
+
mtime: stat !== undefined ? stat.mtime.toISOString() : undefined,
|
|
298
|
+
agentFiles,
|
|
299
|
+
skillMeta,
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** ntd resolve_agent_name:team 用 leadAgent,agent 用 agentName,最后兜底第一个 agent 文件。 */
|
|
304
|
+
function resolveLeadAgentFile(expert) {
|
|
305
|
+
const wanted = expert.leadAgent ?? expert.agentName
|
|
306
|
+
if (expert.agentFiles.length === 0) return undefined
|
|
307
|
+
if (wanted !== undefined) {
|
|
308
|
+
const hit = expert.agentFiles.find((a) => a.name === wanted || a.relPath.endsWith(`/${wanted}.md`) || basename(a.mdPath).replace(/\.md$/, '') === wanted)
|
|
309
|
+
if (hit !== undefined) return hit
|
|
310
|
+
}
|
|
311
|
+
return expert.agentFiles[0]
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Prompt assembly(ntd 三段式注入的 skill-content 适配)────────────────
|
|
315
|
+
|
|
316
|
+
/** 技能清单段:名称渲染为指向 SKILL.md 的链接,模型按需读完整定义(ntd build_skills_context)。 */
|
|
317
|
+
function buildSkillsContext(skillMeta) {
|
|
318
|
+
if (!Array.isArray(skillMeta) || skillMeta.length === 0) return ''
|
|
319
|
+
const parts = ['## 可用技能', '你可以使用以下技能来辅助完成任务。技能名称是 markdown 链接,指向技能定义文件,如需了解技能详细用法可查看该文件:', '']
|
|
320
|
+
for (const skill of skillMeta) {
|
|
321
|
+
const desc = skill.descriptionZh ?? skill.descriptionEn ?? skill.description ?? '(无描述)'
|
|
322
|
+
parts.push(`- **[${skill.skillName}](${skill.skillMdPath})**: ${desc}`)
|
|
323
|
+
}
|
|
324
|
+
parts.push('', '请根据需要自行调用上述技能。')
|
|
325
|
+
return parts.join('\n')
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 拼专家 prompt:角色定义 → 可用技能(有才出现)→ 身份说明。
|
|
330
|
+
* ntd 在 todo 执行前拼接“# 任务 + 原消息”;这里内容经宿主手势边界作为
|
|
331
|
+
* <skill_content> 注入,用户消息随草稿单独送达,故以一句身份说明收尾。
|
|
332
|
+
*/
|
|
333
|
+
function buildExpertPrompt(agentMdBody, skillsText, expert) {
|
|
334
|
+
const displayName = (expert && (expert.displayNameZh ?? expert.displayNameEn)) || ''
|
|
335
|
+
const profession = (expert && (expert.professionZh ?? expert.professionEn)) || ''
|
|
336
|
+
const closing = `\n\n# 身份说明\n你现在是${profession ? `「${profession}」` : ''}专家${displayName ? `「${displayName}」` : ''}。用户的消息中包含具体任务,请严格以上述角色定义的身份、标准与技能完成它。`
|
|
337
|
+
if (skillsText === '') {
|
|
338
|
+
return `# 专家角色定义\n${agentMdBody}${closing}`
|
|
339
|
+
}
|
|
340
|
+
return `# 专家角色定义\n${agentMdBody}\n\n${skillsText}${closing}`
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── Shared route helpers ─────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
function readJsonBody(req) {
|
|
346
|
+
return new Promise((fulfil, reject) => {
|
|
347
|
+
let size = 0, chunks = []
|
|
348
|
+
req.on('data', (chunk) => {
|
|
349
|
+
size += chunk.length
|
|
350
|
+
if (size > MAX_BODY_BYTES) { reject(new Error('request body too large')); req.destroy(); return }
|
|
351
|
+
chunks.push(chunk)
|
|
352
|
+
})
|
|
353
|
+
req.on('end', () => {
|
|
354
|
+
try { fulfil(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8'))) }
|
|
355
|
+
catch (error) { reject(new Error(`invalid JSON body: ${error && error.message}`)) }
|
|
356
|
+
})
|
|
357
|
+
req.on('error', reject)
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function sendJson(res, status, payload) {
|
|
362
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
363
|
+
res.end(JSON.stringify(payload))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function contentTypeFor(p) {
|
|
367
|
+
const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase()
|
|
368
|
+
const map = { md: 'text/markdown; charset=utf-8', txt: 'text/plain; charset=utf-8', json: 'application/json; charset=utf-8', js: 'text/javascript', mjs: 'text/javascript', ts: 'text/typescript', tsx: 'text/typescript', css: 'text/css', html: 'text/html', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', yaml: 'text/yaml', yml: 'text/yaml' }
|
|
369
|
+
return map[ext]
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function sendFile(res, filePath) {
|
|
373
|
+
const stat = await fsP.stat(filePath)
|
|
374
|
+
if (!stat.isFile()) throw new Error('file not found')
|
|
375
|
+
res.writeHead(200, { 'content-type': contentTypeFor(filePath) ?? 'application/octet-stream', 'content-length': stat.size })
|
|
376
|
+
const stream = createReadStream(filePath)
|
|
377
|
+
stream.pipe(res)
|
|
378
|
+
await new Promise((fulfil, reject) => {
|
|
379
|
+
stream.on('error', reject)
|
|
380
|
+
res.on('close', () => fulfil())
|
|
381
|
+
stream.on('end', () => fulfil())
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function countFilesAndSize(dir) {
|
|
386
|
+
let fileCount = 0, totalSize = 0
|
|
387
|
+
const walk = async (current) => {
|
|
388
|
+
const entries = await fsP.readdir(current, { withFileTypes: true })
|
|
389
|
+
for (const entry of entries) {
|
|
390
|
+
const entryPath = join(current, entry.name)
|
|
391
|
+
let stat = await fsP.stat(entryPath).catch(() => undefined)
|
|
392
|
+
if (stat === undefined) continue
|
|
393
|
+
if (stat.isDirectory()) { await walk(entryPath) }
|
|
394
|
+
else if (stat.isFile()) { fileCount += 1; totalSize += stat.size }
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
await walk(dir)
|
|
398
|
+
return { fileCount, totalSize }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function copyDir(from, to) {
|
|
402
|
+
await fsP.mkdir(to, { recursive: true })
|
|
403
|
+
const entries = await fsP.readdir(from, { withFileTypes: true })
|
|
404
|
+
for (const entry of entries) {
|
|
405
|
+
if (entry.name === '.git') continue
|
|
406
|
+
const source = join(from, entry.name), target = join(to, entry.name)
|
|
407
|
+
let stat
|
|
408
|
+
try { stat = await fsP.stat(source) } catch { continue }
|
|
409
|
+
if (stat.isDirectory()) { await copyDir(source, target) }
|
|
410
|
+
else if (stat.isFile()) { await fsP.copyFile(source, target) }
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function atomicWriteJs(file, content) {
|
|
415
|
+
await fsP.mkdir(join(file, '..'), { recursive: true })
|
|
416
|
+
const temp = join(join(file, '..'), `.${randomUUID()}.tmp`)
|
|
417
|
+
await fsP.writeFile(temp, content, 'utf8')
|
|
418
|
+
await fsP.rename(temp, file)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── Market git sync(与 skills-management 同款;稀疏检出 experts/ 子树)──
|
|
422
|
+
|
|
423
|
+
function gitExec(binary, args, cwd) {
|
|
424
|
+
return new Promise((fulfil, reject) => {
|
|
425
|
+
execFile(binary, args, { cwd, timeout: 10 * 60 * 1000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
426
|
+
if (error) {
|
|
427
|
+
const tail = String(stderr || error.message || '').split(/\r?\n/).filter(Boolean).slice(-3).join(' ')
|
|
428
|
+
reject(new Error(`git ${args[0]}: ${tail || error.message}`))
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
fulfil(String(stdout).trim())
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function gitAvailable(binary) {
|
|
437
|
+
try { await gitExec(binary, ['--version']); return true } catch { return false }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function gitCurrentCommit(binary, repo) {
|
|
441
|
+
try { return await gitExec(binary, ['rev-parse', 'HEAD'], repo) } catch { return undefined }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function gitRemoteCommit(binary, repo, remote, branch) {
|
|
445
|
+
try {
|
|
446
|
+
const out = await gitExec(binary, ['ls-remote', '--heads', remote, branch], repo)
|
|
447
|
+
return out.split(/\s+/)[0] || undefined
|
|
448
|
+
} catch { return undefined }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function authedUrl(url, token) {
|
|
452
|
+
if (!token) return url
|
|
453
|
+
return String(url).replace(/^(https?:\/\/)([^@/]+@)?/, `$1oauth2:${encodeURIComponent(token)}@`)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Clone (first time) or fetch+reset (update); sparse checkout只落地指定子树。 */
|
|
457
|
+
async function gitSyncRepo(binary, url, branch, repoDir, token, sparsePaths) {
|
|
458
|
+
const remote = authedUrl(url, token)
|
|
459
|
+
const sparse = Array.isArray(sparsePaths) && sparsePaths.length > 0 ? sparsePaths : undefined
|
|
460
|
+
let repoExists = false
|
|
461
|
+
try { await fsP.access(join(repoDir, '.git')); repoExists = true } catch { repoExists = false }
|
|
462
|
+
if (!repoExists) {
|
|
463
|
+
await fsP.rm(repoDir, { recursive: true, force: true })
|
|
464
|
+
await fsP.mkdir(join(repoDir, '..'), { recursive: true })
|
|
465
|
+
if (sparse) {
|
|
466
|
+
await gitExec(binary, ['clone', '-b', branch, '--depth', '1', '--filter=blob:none', '--sparse', remote, repoDir])
|
|
467
|
+
await gitExec(binary, ['sparse-checkout', 'set', '--cone', ...sparse], repoDir)
|
|
468
|
+
} else {
|
|
469
|
+
await gitExec(binary, ['clone', '-b', branch, '--depth', '1', remote, repoDir])
|
|
470
|
+
}
|
|
471
|
+
return { isFirstClone: true, hasUpdates: true, before: undefined, after: await gitCurrentCommit(binary, repoDir) }
|
|
472
|
+
}
|
|
473
|
+
if (sparse) {
|
|
474
|
+
try { await gitExec(binary, ['sparse-checkout', 'set', '--cone', ...sparse], repoDir) }
|
|
475
|
+
catch (e) { console.warn(`experts-management: sparse-checkout conversion failed, continuing full: ${e && e.message}`) }
|
|
476
|
+
}
|
|
477
|
+
const before = await gitCurrentCommit(binary, repoDir)
|
|
478
|
+
await gitExec(binary, ['fetch', remote, branch], repoDir)
|
|
479
|
+
await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir)
|
|
480
|
+
const after = await gitCurrentCommit(binary, repoDir)
|
|
481
|
+
return { isFirstClone: false, hasUpdates: before !== after, before, after }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const DEFAULT_MARKET_SYNC = {
|
|
485
|
+
url: 'https://gitcode.com/weibaohui/ntd-resource.git',
|
|
486
|
+
branch: 'main',
|
|
487
|
+
gitBinary: 'git',
|
|
488
|
+
autoSync: true, // periodic: sync when lastSyncAt is older than a day
|
|
489
|
+
syncOnStartup: true,
|
|
490
|
+
}
|
|
491
|
+
// ntd-resource 同时携带 skills(~400MB),专家市场只稀疏检出 experts/ 子树
|
|
492
|
+
const DEFAULT_MARKET_SPARSE_PATHS = ['experts']
|
|
493
|
+
|
|
494
|
+
const MARKET_SETTINGS_NS = 'experts-management-market'
|
|
495
|
+
|
|
496
|
+
function marketSettingsSchema() {
|
|
497
|
+
if (!Schema) return null
|
|
498
|
+
return Schema.object({
|
|
499
|
+
url: Schema.string(),
|
|
500
|
+
branch: Schema.string(),
|
|
501
|
+
gitBinary: Schema.string(),
|
|
502
|
+
repoDir: Schema.string(),
|
|
503
|
+
autoSync: Schema.boolean(),
|
|
504
|
+
syncOnStartup: Schema.boolean(),
|
|
505
|
+
token: Schema.string(),
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function baseSettings(config) {
|
|
510
|
+
const cfg = (config.marketSync && typeof config.marketSync === 'object') ? config.marketSync : {}
|
|
511
|
+
const base = { ...DEFAULT_MARKET_SYNC }
|
|
512
|
+
for (const key of ['url', 'branch', 'gitBinary', 'autoSync', 'syncOnStartup']) {
|
|
513
|
+
if (cfg[key] !== undefined) base[key] = cfg[key]
|
|
514
|
+
}
|
|
515
|
+
if (config.marketRepoDir !== undefined) base.repoDir = resolve(String(config.marketRepoDir))
|
|
516
|
+
return base
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ── Module export ────────────────────────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
module.exports = {
|
|
522
|
+
name: 'experts-management',
|
|
523
|
+
inject: ['skills', 'webServer', 'settings'],
|
|
524
|
+
__internals: {
|
|
525
|
+
extractFrontmatter, parseFrontmatter, parseAgentMd, parseSkillMd, parsePluginJson,
|
|
526
|
+
localized, truncateDescription, resolveWithin, isSafeExpertName,
|
|
527
|
+
buildSkillsContext, buildExpertPrompt, resolveLeadAgentFile,
|
|
528
|
+
candidateName: (name) => EXPERT_NAME_PREFIX + name,
|
|
529
|
+
SOURCE_DEFS, EXPERT_NAME_PREFIX, KEBAB_NAME_RE,
|
|
530
|
+
},
|
|
531
|
+
|
|
532
|
+
apply(ctx, config = {}) {
|
|
533
|
+
const installedDir = resolve(String(config.installedDir !== undefined ? config.installedDir : join(dshHome(), 'experts')))
|
|
534
|
+
const providerName = config.providerName !== undefined ? config.providerName : 'ntd-experts'
|
|
535
|
+
|
|
536
|
+
// ── Source rows:内置只有 dsh 用户库;其他目录一律经 extraSources 显式加入 ──
|
|
537
|
+
const disabledSources = new Set(Array.isArray(config.disabledSources) ? config.disabledSources : [])
|
|
538
|
+
const seenKeys = new Set()
|
|
539
|
+
const sourceRows = []
|
|
540
|
+
for (const def of SOURCE_DEFS) {
|
|
541
|
+
if (disabledSources.has(def.key)) continue
|
|
542
|
+
const root = def.key === 'dsh' ? installedDir : def.dir !== undefined ? resolve(String(def.dir)) : undefined
|
|
543
|
+
if (seenKeys.has(def.key)) continue
|
|
544
|
+
seenKeys.add(def.key)
|
|
545
|
+
sourceRows.push({ key: def.key, label: def.label, root, readOnly: def.readOnly === true })
|
|
546
|
+
}
|
|
547
|
+
for (const extra of Array.isArray(config.extraSources) ? config.extraSources : []) {
|
|
548
|
+
if (extra === null || typeof extra !== 'object') continue
|
|
549
|
+
if (typeof extra.key !== 'string' || extra.key === '') continue
|
|
550
|
+
if (typeof extra.dir !== 'string' || extra.dir === '') continue
|
|
551
|
+
if (seenKeys.has(extra.key)) continue
|
|
552
|
+
seenKeys.add(extra.key)
|
|
553
|
+
sourceRows.push({
|
|
554
|
+
key: extra.key,
|
|
555
|
+
label: typeof extra.label === 'string' && extra.label !== '' ? extra.label : extra.key,
|
|
556
|
+
root: resolve(String(extra.dir)),
|
|
557
|
+
readOnly: extra.readOnly === true,
|
|
558
|
+
})
|
|
559
|
+
}
|
|
560
|
+
const findSourceRow = (key) => allSourceRows().find((row) => row.key === key)
|
|
561
|
+
// git 市场检出(稀疏 experts/ 子树)也是一路来源:root 运行期可变,按调用时解析
|
|
562
|
+
const allSourceRows = () => [
|
|
563
|
+
...sourceRows,
|
|
564
|
+
{ key: 'market', label: '专家市场', root: join(marketRootDir(), 'experts'), readOnly: true },
|
|
565
|
+
]
|
|
566
|
+
|
|
567
|
+
// ── Market sync state / settings(skills-management 同款管线)────────
|
|
568
|
+
const marketRootDir = () => {
|
|
569
|
+
const eff = marketSettings()
|
|
570
|
+
return resolve(typeof eff.repoDir === 'string' && eff.repoDir !== '' ? eff.repoDir
|
|
571
|
+
: config.marketRepoDir !== undefined ? resolve(String(config.marketRepoDir))
|
|
572
|
+
: join(dshHome(), 'experts-management', 'market'))
|
|
573
|
+
}
|
|
574
|
+
const marketSparsePaths = () => config.marketSparsePaths === null
|
|
575
|
+
? undefined
|
|
576
|
+
: (Array.isArray(config.marketSparsePaths) && config.marketSparsePaths.length > 0 ? config.marketSparsePaths.map(String) : DEFAULT_MARKET_SPARSE_PATHS)
|
|
577
|
+
|
|
578
|
+
const marketStateFile = join(dshHome(), 'experts-management', 'market-sync.json')
|
|
579
|
+
let marketState = { lastSyncAt: undefined, lastResult: undefined }
|
|
580
|
+
let settingsScope = null
|
|
581
|
+
const settingsOverrides = {} // fallback sheet when the settings service is absent
|
|
582
|
+
const marketStateLoaded = fsP.readFile(marketStateFile, 'utf8')
|
|
583
|
+
.then(raw => {
|
|
584
|
+
const parsed = JSON.parse(raw)
|
|
585
|
+
marketState = { lastSyncAt: parsed.lastSyncAt, lastResult: parsed.lastResult }
|
|
586
|
+
})
|
|
587
|
+
.catch(() => {})
|
|
588
|
+
if (Schema && ctx.settings && typeof ctx.settings.register === 'function') {
|
|
589
|
+
try {
|
|
590
|
+
settingsScope = ctx.settings.register(MARKET_SETTINGS_NS, marketSettingsSchema(), { base: baseSettings(config) })
|
|
591
|
+
} catch (e) { ctx.logger.warn(`experts-management: settings register: ${e && e.message}`) }
|
|
592
|
+
}
|
|
593
|
+
const saveMarketState = async () => {
|
|
594
|
+
try {
|
|
595
|
+
await fsP.mkdir(join(marketStateFile, '..'), { recursive: true })
|
|
596
|
+
await atomicWriteJs(marketStateFile, JSON.stringify(marketState, null, 2))
|
|
597
|
+
await fsP.chmod(marketStateFile, 0o600)
|
|
598
|
+
} catch {}
|
|
599
|
+
}
|
|
600
|
+
const marketSettings = () => {
|
|
601
|
+
if (settingsScope && typeof settingsScope.get === 'function') {
|
|
602
|
+
const v = settingsScope.get()
|
|
603
|
+
if (v && typeof v === 'object') return { ...baseSettings(config), ...v }
|
|
604
|
+
}
|
|
605
|
+
return { ...baseSettings(config), ...settingsOverrides }
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
let marketSyncRun = null
|
|
609
|
+
const runMarketSync = async () => {
|
|
610
|
+
if (marketSyncRun !== null) return marketSyncRun
|
|
611
|
+
marketSyncRun = (async () => {
|
|
612
|
+
await marketStateLoaded
|
|
613
|
+
const eff = marketSettings()
|
|
614
|
+
const ok = await gitAvailable(eff.gitBinary)
|
|
615
|
+
if (!ok) throw new Error('git is not available on PATH')
|
|
616
|
+
const started = Date.now()
|
|
617
|
+
const repoDir = marketRootDir()
|
|
618
|
+
const result = await gitSyncRepo(eff.gitBinary, eff.url, eff.branch, repoDir, eff.token, marketSparsePaths())
|
|
619
|
+
marketState.lastSyncAt = new Date().toISOString()
|
|
620
|
+
marketState.lastResult = { ...result, at: marketState.lastSyncAt, durationMs: Date.now() - started }
|
|
621
|
+
await saveMarketState()
|
|
622
|
+
invalidate()
|
|
623
|
+
return { ...marketState.lastResult, url: eff.url, branch: eff.branch, dir: repoDir }
|
|
624
|
+
})().finally(() => { marketSyncRun = null })
|
|
625
|
+
return marketSyncRun
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Startup + periodic auto-sync (fire-and-forget; failures only warn)
|
|
629
|
+
ctx.effect(() => {
|
|
630
|
+
const eff = marketSettings()
|
|
631
|
+
if (eff.syncOnStartup) {
|
|
632
|
+
marketStateLoaded.then(() => runMarketSync()).catch(e => ctx.logger.warn(`experts-management: startup market sync: ${e && e.message}`))
|
|
633
|
+
}
|
|
634
|
+
const timer = setInterval(() => {
|
|
635
|
+
const eff2 = marketSettings()
|
|
636
|
+
if (!eff2.autoSync) return
|
|
637
|
+
const last = marketState.lastSyncAt ? Date.parse(marketState.lastSyncAt) : 0
|
|
638
|
+
if (Date.now() - last > 24 * 3600 * 1000) {
|
|
639
|
+
runMarketSync().catch(e => ctx.logger.warn(`experts-management: auto market sync: ${e && e.message}`))
|
|
640
|
+
}
|
|
641
|
+
}, 6 * 3600 * 1000)
|
|
642
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
643
|
+
return () => clearInterval(timer)
|
|
644
|
+
}, 'experts-management: market auto-sync')
|
|
645
|
+
|
|
646
|
+
// ── Discovery ────────────────────────────────────────────────────────
|
|
647
|
+
async function discoverAll() {
|
|
648
|
+
const installed = [], market = []
|
|
649
|
+
const errors = []
|
|
650
|
+
for (const row of allSourceRows()) {
|
|
651
|
+
const { experts, errors: errs } = await scanExpertsRoot(row.root, row.key)
|
|
652
|
+
errors.push(...errs)
|
|
653
|
+
if (row.key === 'dsh') installed.push(...experts)
|
|
654
|
+
else market.push(...experts)
|
|
655
|
+
}
|
|
656
|
+
return { installed, market, errors }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function locateExpert(name, sourceKey) {
|
|
660
|
+
const rows = sourceKey !== undefined && sourceKey !== null && sourceKey !== '' && sourceKey !== 'auto'
|
|
661
|
+
? [findSourceRow(sourceKey)].filter(Boolean)
|
|
662
|
+
: allSourceRows()
|
|
663
|
+
if (sourceKey !== undefined && sourceKey !== null && sourceKey !== '' && sourceKey !== 'auto' && findSourceRow(sourceKey) === undefined) {
|
|
664
|
+
throw new Error(`unknown source '${sourceKey}'`)
|
|
665
|
+
}
|
|
666
|
+
for (const row of rows) {
|
|
667
|
+
const { experts } = await scanExpertsRoot(row.root, row.key)
|
|
668
|
+
const hit = experts.find((e) => e.name === name)
|
|
669
|
+
if (hit !== undefined) return { expert: hit, row }
|
|
670
|
+
}
|
|
671
|
+
throw new Error(`expert '${name}' not found${sourceKey ? ` in ${sourceKey}` : ''}`)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ── Skill provider:每个专家 = 仅用户可调用的技能 ────────────────────
|
|
675
|
+
let providerControl
|
|
676
|
+
const invalidate = () => { if (providerControl !== undefined) providerControl.invalidate() }
|
|
677
|
+
|
|
678
|
+
ctx.skills.registerProvider((control) => {
|
|
679
|
+
providerControl = control
|
|
680
|
+
control.signal.addEventListener('abort', () => { if (providerControl === control) providerControl = undefined }, { once: true })
|
|
681
|
+
return {
|
|
682
|
+
name: providerName,
|
|
683
|
+
async list() {
|
|
684
|
+
const { installed, market } = await discoverAll()
|
|
685
|
+
const candidates = []
|
|
686
|
+
const seen = new Set()
|
|
687
|
+
// 专家一律 modelInvocable:false:不进模型目录(零 token 污染),
|
|
688
|
+
// 仅保留 /expert-名称 用户手势(宿主 pre-step 确定性注入 <skill_content>)。
|
|
689
|
+
const invocation = { modelInvocable: false, userInvocable: true }
|
|
690
|
+
const describe = (e) => truncateDescription([e.professionZh ?? e.professionEn, e.descZh ?? e.descEn].filter(Boolean).join(' · '))
|
|
691
|
+
const push = (e, source, rank) => {
|
|
692
|
+
const candName = EXPERT_NAME_PREFIX + e.name
|
|
693
|
+
if (!KEBAB_NAME_RE.test(candName)) {
|
|
694
|
+
ctx.logger.warn(`experts-management: skipping expert '${e.name}' (${e.dir}): invalid candidate name '${candName}'`)
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
if (describe(e) === '') {
|
|
698
|
+
ctx.logger.warn(`experts-management: skipping expert '${e.name}' (${e.dir}): empty description`)
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
if (seen.has(candName)) return
|
|
702
|
+
seen.add(candName)
|
|
703
|
+
candidates.push({
|
|
704
|
+
name: candName,
|
|
705
|
+
description: describe(e),
|
|
706
|
+
invocation,
|
|
707
|
+
source,
|
|
708
|
+
provider: providerName,
|
|
709
|
+
rank,
|
|
710
|
+
locator: { name: e.name, dir: e.dir, source: e.source, agent: (resolveLeadAgentFile(e) || {}).mdPath },
|
|
711
|
+
path: (resolveLeadAgentFile(e) || {}).mdPath,
|
|
712
|
+
resourceBase: { kind: 'directory', path: e.dir },
|
|
713
|
+
metadata: { expertType: e.expertType, profession: e.professionZh ?? e.professionEn, version: e.version },
|
|
714
|
+
})
|
|
715
|
+
}
|
|
716
|
+
for (const e of installed) push(e, 'user-installed', RANK_INSTALLED)
|
|
717
|
+
const installedNames = new Set(installed.map((e) => e.name))
|
|
718
|
+
for (const e of market) {
|
|
719
|
+
if (installedNames.has(e.name)) continue // 用户库覆盖市场同名专家
|
|
720
|
+
push(e, 'market', RANK_MARKET)
|
|
721
|
+
}
|
|
722
|
+
return candidates
|
|
723
|
+
},
|
|
724
|
+
async get(candidate) {
|
|
725
|
+
try {
|
|
726
|
+
// list→get 之间文件可能变化:按 locator 重新读取角色定义
|
|
727
|
+
const pluginRaw = await fsP.readFile(join(candidate.locator.dir, PLUGIN_JSON_REL), 'utf8')
|
|
728
|
+
const expert = { ...parsePluginJson(pluginRaw), dir: candidate.locator.dir }
|
|
729
|
+
const agentPath = candidate.locator.agent
|
|
730
|
+
if (agentPath === undefined) return undefined
|
|
731
|
+
const mdRaw = await fsP.readFile(agentPath, 'utf8')
|
|
732
|
+
const { body } = parseFrontmatter(mdRaw)
|
|
733
|
+
// 技能清单现场重建:链接指向当前磁盘上的 SKILL.md
|
|
734
|
+
const head = expert
|
|
735
|
+
const skillMeta = []
|
|
736
|
+
for (const rel of head.skills) {
|
|
737
|
+
const skillDir = resolveWithin(candidate.locator.dir, rel)
|
|
738
|
+
if (skillDir === undefined) continue
|
|
739
|
+
try {
|
|
740
|
+
const parsed = parseSkillMd(await fsP.readFile(join(skillDir, 'SKILL.md'), 'utf8'))
|
|
741
|
+
skillMeta.push({ ...parsed, skillName: parsed.name ?? basename(skillDir), skillMdPath: join(skillDir, 'SKILL.md') })
|
|
742
|
+
} catch { /* skip */ }
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
name: candidate.name,
|
|
746
|
+
description: candidate.description,
|
|
747
|
+
invocation: { modelInvocable: false, userInvocable: true },
|
|
748
|
+
source: candidate.source,
|
|
749
|
+
provider: providerName,
|
|
750
|
+
resourceBase: { kind: 'directory', path: candidate.locator.dir },
|
|
751
|
+
content: buildExpertPrompt(body, buildSkillsContext(skillMeta), expert),
|
|
752
|
+
path: agentPath,
|
|
753
|
+
metadata: candidate.metadata,
|
|
754
|
+
}
|
|
755
|
+
} catch { return undefined }
|
|
756
|
+
},
|
|
757
|
+
}
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
// ── HTTP API ─────────────────────────────────────────────────────────
|
|
761
|
+
ctx.effect(() => ctx.webServer.register({
|
|
762
|
+
kind: 'prefix',
|
|
763
|
+
path: '/experts-management/api',
|
|
764
|
+
handler: async (req, res) => {
|
|
765
|
+
try {
|
|
766
|
+
const url = new URL(req.url || '/', 'http://dsh.local')
|
|
767
|
+
const apiPath = url.pathname.replace(/\/+$/, '')
|
|
768
|
+
const query = url.searchParams
|
|
769
|
+
|
|
770
|
+
// GET /experts-management/api → { sources, installed, market }
|
|
771
|
+
if (req.method === 'GET' && apiPath === '/experts-management/api') {
|
|
772
|
+
const { installed, market, errors } = await discoverAll()
|
|
773
|
+
const installedNames = new Set(installed.map((e) => e.name))
|
|
774
|
+
const summarize = (e) => ({
|
|
775
|
+
name: e.name,
|
|
776
|
+
displayName: e.displayNameZh ?? e.displayNameEn ?? e.name,
|
|
777
|
+
profession: e.professionZh ?? e.professionEn ?? '',
|
|
778
|
+
description: truncateDescription(e.descZh ?? e.descEn ?? ''),
|
|
779
|
+
expertType: e.expertType,
|
|
780
|
+
tags: e.tags.filter((t) => t.zh || t.en).map((t) => t.zh || t.en),
|
|
781
|
+
hasAvatar: e.avatar !== undefined,
|
|
782
|
+
source: e.source,
|
|
783
|
+
installed: installedNames.has(e.name),
|
|
784
|
+
mtime: e.mtime,
|
|
785
|
+
})
|
|
786
|
+
sendJson(res, 200, {
|
|
787
|
+
sources: allSourceRows().map((row) => ({ key: row.key, label: row.label, dir: displayPath(row.root), readOnly: row.readOnly })),
|
|
788
|
+
installed: installed.map(summarize),
|
|
789
|
+
market: market.map(summarize),
|
|
790
|
+
errors,
|
|
791
|
+
})
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// GET /experts-management/api/detail?name=&source=
|
|
796
|
+
if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/detail')) {
|
|
797
|
+
const name = query.get('name') || ''
|
|
798
|
+
const { expert, row } = await locateExpert(name, query.get('source') || undefined)
|
|
799
|
+
const { fileCount, totalSize } = await countFilesAndSize(expert.dir)
|
|
800
|
+
sendJson(res, 200, {
|
|
801
|
+
...expert,
|
|
802
|
+
plugin: parsePluginJson(await fsP.readFile(expert.pluginJsonPath, 'utf8')),
|
|
803
|
+
leadAgentFile: resolveLeadAgentFile(expert)?.name,
|
|
804
|
+
dir: displayPath(expert.dir),
|
|
805
|
+
sourceLabel: row.label,
|
|
806
|
+
readOnly: row.readOnly,
|
|
807
|
+
fileCount, totalSize,
|
|
808
|
+
})
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// GET /experts-management/api/agent-md?name=&source=&agent=
|
|
813
|
+
if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/agent-md')) {
|
|
814
|
+
const { expert } = await locateExpert(query.get('name') || '', query.get('source') || undefined)
|
|
815
|
+
const wanted = query.get('agent') || undefined
|
|
816
|
+
const normRel = (p) => String(p || '').replace(/^\.\//, '')
|
|
817
|
+
const agentFile = wanted !== undefined
|
|
818
|
+
? expert.agentFiles.find((a) => a.name === wanted || normRel(a.relPath) === normRel(wanted) || basename(a.mdPath) === wanted)
|
|
819
|
+
: resolveLeadAgentFile(expert)
|
|
820
|
+
if (agentFile === undefined) throw new Error(`agent not found in expert '${expert.name}'`)
|
|
821
|
+
sendJson(res, 200, { expert: expert.name, agent: agentFile.name, content: await fsP.readFile(agentFile.mdPath, 'utf8') })
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// GET /experts-management/api/avatar?name=&source=&member=
|
|
826
|
+
if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/avatar')) {
|
|
827
|
+
const { expert } = await locateExpert(query.get('name') || '', query.get('source') || undefined)
|
|
828
|
+
const memberId = query.get('member')
|
|
829
|
+
let rel
|
|
830
|
+
if (memberId !== null && memberId !== '') {
|
|
831
|
+
const member = expert.members.find((m) => m.id === memberId)
|
|
832
|
+
rel = member !== undefined ? member.avatar : undefined
|
|
833
|
+
} else {
|
|
834
|
+
rel = expert.avatar
|
|
835
|
+
}
|
|
836
|
+
if (rel === undefined) { res.writeHead(404); res.end(); return }
|
|
837
|
+
const full = resolveWithin(expert.dir, rel)
|
|
838
|
+
if (full === undefined) { res.writeHead(404); res.end(); return }
|
|
839
|
+
try { await sendFile(res, full) } catch { res.writeHead(404); res.end() }
|
|
840
|
+
return
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// GET /experts-management/api/file?name=&source=&path= (预览 references 等)
|
|
844
|
+
if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/file')) {
|
|
845
|
+
const { expert } = await locateExpert(query.get('name') || '', query.get('source') || undefined)
|
|
846
|
+
const rel = query.get('path') || ''
|
|
847
|
+
const full = resolveWithin(expert.dir, rel)
|
|
848
|
+
if (full === undefined) throw new Error('invalid file path')
|
|
849
|
+
await sendFile(res, full)
|
|
850
|
+
return
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// POST /experts-management/api/install {name, from?, overwrite?}
|
|
854
|
+
if (req.method === 'POST' && apiPath.endsWith('/experts-management/api/install')) {
|
|
855
|
+
const body = await readJsonBody(req)
|
|
856
|
+
if (typeof body.name !== 'string' || body.name === '') { sendJson(res, 400, { error: 'body must provide name' }); return }
|
|
857
|
+
const { expert } = await locateExpert(body.name, typeof body.from === 'string' && body.from !== '' && body.from !== 'market' ? body.from : undefined)
|
|
858
|
+
if (!isSafeExpertName(expert.name)) throw new Error(`invalid expert name: ${expert.name}`)
|
|
859
|
+
const target = join(installedDir, expert.name)
|
|
860
|
+
if (body.overwrite !== true) {
|
|
861
|
+
try { await fsP.access(target); throw new Error(`expert '${expert.name}' already installed`) }
|
|
862
|
+
catch (e) { if (e.code !== 'ENOENT') throw e }
|
|
863
|
+
} else {
|
|
864
|
+
await fsP.rm(target, { recursive: true, force: true })
|
|
865
|
+
}
|
|
866
|
+
await copyDir(expert.dir, target)
|
|
867
|
+
invalidate()
|
|
868
|
+
sendJson(res, 201, { installed: { name: expert.name, dir: target, from: expert.source } })
|
|
869
|
+
return
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// DELETE /experts-management/api {name} → 仅允许删除 dsh 用户库
|
|
873
|
+
if (req.method === 'DELETE' && apiPath.endsWith('/experts-management/api')) {
|
|
874
|
+
const body = await readJsonBody(req)
|
|
875
|
+
if (typeof body.name !== 'string' || body.name === '') { sendJson(res, 400, { error: 'body must provide name' }); return }
|
|
876
|
+
if (!isSafeExpertName(body.name)) throw new Error('invalid expert name')
|
|
877
|
+
const target = join(installedDir, body.name)
|
|
878
|
+
const stat = await fsP.stat(target).catch(() => undefined)
|
|
879
|
+
if (stat === undefined || !stat.isDirectory()) throw new Error(`expert '${body.name}' not found in the dsh library`)
|
|
880
|
+
await fsP.rm(target, { recursive: true })
|
|
881
|
+
invalidate()
|
|
882
|
+
sendJson(res, 200, { removed: body.name, source: 'dsh' })
|
|
883
|
+
return
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// GET /experts-management/api/market/status
|
|
887
|
+
if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/market/status')) {
|
|
888
|
+
await marketStateLoaded
|
|
889
|
+
const eff = marketSettings()
|
|
890
|
+
const repoDir = marketRootDir()
|
|
891
|
+
const repoExists = await fsP.access(join(repoDir, '.git')).then(() => true).catch(() => false)
|
|
892
|
+
const ok = await gitAvailable(eff.gitBinary)
|
|
893
|
+
const [localCommit, remoteCommit] = repoExists && ok
|
|
894
|
+
? [await gitCurrentCommit(eff.gitBinary, repoDir), await gitRemoteCommit(eff.gitBinary, repoDir, 'origin', eff.branch)]
|
|
895
|
+
: [undefined, undefined]
|
|
896
|
+
sendJson(res, 200, {
|
|
897
|
+
url: eff.url, branch: eff.branch, dir: displayPath(repoDir),
|
|
898
|
+
gitAvailable: ok, repoExists,
|
|
899
|
+
localCommit, remoteCommit,
|
|
900
|
+
needsUpdate: localCommit !== undefined && remoteCommit !== undefined ? localCommit !== remoteCommit : undefined,
|
|
901
|
+
lastSyncAt: marketState.lastSyncAt, lastResult: marketState.lastResult,
|
|
902
|
+
autoSync: eff.autoSync, syncOnStartup: eff.syncOnStartup,
|
|
903
|
+
hasToken: typeof eff.token === 'string' && eff.token !== '',
|
|
904
|
+
syncing: marketSyncRun !== null,
|
|
905
|
+
sparsePaths: marketSparsePaths() ?? null,
|
|
906
|
+
})
|
|
907
|
+
return
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// POST /experts-management/api/market/sync
|
|
911
|
+
if (req.method === 'POST' && apiPath.endsWith('/experts-management/api/market/sync')) {
|
|
912
|
+
try {
|
|
913
|
+
const result = await runMarketSync()
|
|
914
|
+
sendJson(res, 200, result)
|
|
915
|
+
} catch (e) { sendJson(res, 400, { error: String(e && e.message || e) }) }
|
|
916
|
+
return
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// PUT /experts-management/api/market/settings {url?, branch?, repoDir?, token?, autoSync?, syncOnStartup?}
|
|
920
|
+
if (req.method === 'PUT' && apiPath.endsWith('/experts-management/api/market/settings')) {
|
|
921
|
+
const body = await readJsonBody(req)
|
|
922
|
+
await marketStateLoaded
|
|
923
|
+
const patch = {}
|
|
924
|
+
for (const key of ['url', 'branch', 'gitBinary']) {
|
|
925
|
+
if (typeof body[key] === 'string' && body[key] !== '') patch[key] = body[key]
|
|
926
|
+
}
|
|
927
|
+
if (typeof body.token === 'string' && body.token !== '') patch.token = body.token
|
|
928
|
+
if (body.token === null || body.token === '') patch.token = undefined
|
|
929
|
+
if (typeof body.repoDir === 'string' && body.repoDir !== '') patch.repoDir = resolve(body.repoDir)
|
|
930
|
+
for (const key of ['autoSync', 'syncOnStartup']) {
|
|
931
|
+
if (typeof body[key] === 'boolean') patch[key] = body[key]
|
|
932
|
+
}
|
|
933
|
+
if (settingsScope && typeof settingsScope.update === 'function') {
|
|
934
|
+
await settingsScope.update(patch)
|
|
935
|
+
} else {
|
|
936
|
+
Object.assign(settingsOverrides, patch)
|
|
937
|
+
}
|
|
938
|
+
const eff = marketSettings()
|
|
939
|
+
const { token, ...safe } = eff // token 只写不回读
|
|
940
|
+
sendJson(res, 200, { settings: safe, hasToken: typeof token === 'string' && token !== '' })
|
|
941
|
+
return
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
sendJson(res, 404, { error: 'not found' })
|
|
945
|
+
} catch (error) { sendJson(res, 400, { error: String(error && error.message || error) }) }
|
|
946
|
+
},
|
|
947
|
+
}), 'experts-management: api route')
|
|
948
|
+
},
|
|
949
|
+
}
|