@waterplus-ai/waterbuddy 0.1.78 → 0.2.5

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 (29) hide show
  1. package/README.md +13 -3
  2. package/assets/experts/teams/smart-water-delivery-team/.codebuddy-plugin/plugin.json +19 -0
  3. package/assets/experts/teams/smart-water-delivery-team/agents/lead.md +10 -0
  4. package/assets/experts/teams/software-development-team/.codebuddy-plugin/plugin.json +57 -0
  5. package/assets/experts/teams/software-development-team/agents/gstack-designer.md +209 -0
  6. package/assets/experts/teams/software-development-team/agents/gstack-investigator.md +208 -0
  7. package/assets/experts/teams/software-development-team/agents/gstack-lead.md +264 -0
  8. package/assets/experts/teams/software-development-team/agents/gstack-product-reviewer.md +516 -0
  9. package/assets/experts/teams/software-development-team/agents/gstack-qa-lead.md +187 -0
  10. package/assets/experts/teams/software-development-team/agents/gstack-security-officer.md +408 -0
  11. package/assets/experts/teams/software-development-team/avatars/gstack-designer.svg +1 -0
  12. package/assets/experts/teams/software-development-team/avatars/gstack-investigator.svg +1 -0
  13. package/assets/experts/teams/software-development-team/avatars/gstack-lead.svg +1 -0
  14. package/assets/experts/teams/software-development-team/avatars/gstack-product-reviewer.svg +1 -0
  15. package/assets/experts/teams/software-development-team/avatars/gstack-qa-lead.svg +1 -0
  16. package/assets/experts/teams/software-development-team/avatars/gstack-security-officer.svg +1 -0
  17. package/assets/experts/teams/software-development-team/avatars/team.svg +1 -0
  18. package/assets/experts/teams/software-development-team/skills/design-html/SKILL.md +35 -0
  19. package/assets/experts/teams/software-development-team/skills/qa/SKILL.md +44 -0
  20. package/assets/experts/teams/software-development-team/skills/review/SKILL.md +43 -0
  21. package/assets/experts/teams/water-operations-team/.codebuddy-plugin/plugin.json +19 -0
  22. package/assets/experts/teams/water-operations-team/agents/lead.md +9 -0
  23. package/lib/client/index.js +296 -120
  24. package/lib/host/expert-store.js +1 -1
  25. package/lib/host/expert-tools.js +156 -25
  26. package/lib/host/experts.js +511 -26
  27. package/lib/host/index.js +51 -22
  28. package/lib/host/llm-gateway.js +1 -1
  29. package/package.json +3 -3
@@ -1,10 +1,11 @@
1
1
  /**
2
- * 专家名册:把 `assets/experts/<分区>/<slug>.md` 读成可召唤的专家目录。
2
+ * 专家名册:把旧版 Markdown persona 与 ntd 目录包统一读成可召唤的专家目录。
3
3
  *
4
- * 每份 persona 的正文是**完整的系统提示词**(十几 KB 量级),所以启动时只读
5
- * frontmatter 做目录,正文留到真正召唤时再读(见 `readExpertPersona`)。
4
+ * 旧版 persona 仍支持 `assets/experts/<分区>/<slug>.md`;新目录包使用
5
+ * `.codebuddy-plugin/plugin.json` 与 `agents/*.md`。每份 persona 的正文是**完整的系统提示词**,
6
+ * 所以启动时只读 frontmatter/元数据做目录,正文留到真正召唤时再读。
6
7
  *
7
- * 文件格式(frontmatter + 正文,正文即系统提示词):
8
+ * 旧版文件格式(frontmatter + 正文,正文即系统提示词):
8
9
  *
9
10
  * ---
10
11
  * name: 供水管网水力建模专家
@@ -18,6 +19,7 @@
18
19
  * 这样解析器不需要引入 YAML 依赖——本插件是零依赖的纯 JS 包。
19
20
  */
20
21
  import fs from 'node:fs/promises'
22
+ import os from 'node:os'
21
23
  import path from 'node:path'
22
24
  import { fileURLToPath } from 'node:url'
23
25
 
@@ -124,6 +126,42 @@ export function parseExpertFrontmatter(text) {
124
126
  return {}
125
127
  }
126
128
 
129
+ function parseFrontmatterCollections(text) {
130
+ const lines = stripBom(String(text)).split(/\r?\n/)
131
+ if (lines[0]?.trim() !== '---') return {}
132
+ const collections = {}
133
+ let currentKey = ''
134
+ let currentItem
135
+ for (const line of lines.slice(1)) {
136
+ if (line.trim() === '---') break
137
+ const section = line.match(/^([A-Za-z][\w-]*):\s*$/)
138
+ if (section) {
139
+ currentKey = section[1]
140
+ currentItem = undefined
141
+ collections[currentKey] = []
142
+ continue
143
+ }
144
+ const item = line.match(/^\s+-\s+(.*)$/)
145
+ if (item && currentKey) {
146
+ const value = item[1].trim()
147
+ if (value.includes(':')) {
148
+ const separator = value.indexOf(':')
149
+ currentItem = { [value.slice(0, separator).trim()]: value.slice(separator + 1).trim() }
150
+ collections[currentKey].push(currentItem)
151
+ } else {
152
+ currentItem = value
153
+ collections[currentKey].push(value)
154
+ }
155
+ continue
156
+ }
157
+ const property = line.match(/^\s{4,}([A-Za-z][\w-]*):\s*(.*)$/)
158
+ if (property && currentItem && typeof currentItem === 'object') {
159
+ currentItem[property[1]] = property[2].trim()
160
+ }
161
+ }
162
+ return collections
163
+ }
164
+
127
165
  /** 去掉 frontmatter,返回正文。 */
128
166
  export function expertBody(text) {
129
167
  const lines = stripBom(String(text)).split(/\r?\n/)
@@ -134,6 +172,141 @@ export function expertBody(text) {
134
172
  return ''
135
173
  }
136
174
 
175
+ const NTD_PLUGIN_DIR = '.codebuddy-plugin'
176
+ const NTD_PLUGIN_FILE = 'plugin.json'
177
+
178
+ async function readJsonFile(filePath) {
179
+ try {
180
+ return JSON.parse(await fs.readFile(filePath, 'utf8'))
181
+ } catch (error) {
182
+ if (error.code === 'ENOENT') return undefined
183
+ throw new Error(`EXPERT_PLUGIN_INVALID: ${filePath} 无法解析 plugin.json`)
184
+ }
185
+ }
186
+
187
+ function ntdSlug(values) {
188
+ for (const value of values) {
189
+ const slug = String(value || '').trim().toLowerCase()
190
+ if (EXPERT_PATH_SEGMENT_PATTERN.test(slug)) return slug
191
+ }
192
+ return ''
193
+ }
194
+
195
+ function ntdLocalized(value) {
196
+ if (value && typeof value === 'object') {
197
+ return String(value.zh || value.en || Object.values(value)[0] || '').trim()
198
+ }
199
+ return String(value || '').trim()
200
+ }
201
+
202
+ function ntdMember(member) {
203
+ if (typeof member === 'string') return { slug: member, name: member, avatar: '' }
204
+ if (!member || typeof member !== 'object') return undefined
205
+ const slug = String(member.slug || member.id || ntdLocalized(member.name) || '').trim()
206
+ if (!slug) return undefined
207
+ return {
208
+ slug,
209
+ name: ntdLocalized(member.displayName || member.name || slug),
210
+ avatar: String(member.avatar || '').trim(),
211
+ }
212
+ }
213
+
214
+ async function loadNtdEntry(directory, pluginPath) {
215
+ const plugin = await readJsonFile(pluginPath)
216
+ if (!plugin || typeof plugin !== 'object') return undefined
217
+ const isTeam = String(plugin.expertType || '').toLowerCase() === 'team'
218
+ const fallback = path.basename(directory)
219
+ const configuredSlug = plugin.slug || plugin.name
220
+ const slug = ntdSlug([configuredSlug || fallback])
221
+ if (!slug) throw new Error(`EXPERT_SLUG_INVALID: ${directory} 的 plugin.json name 不合法`)
222
+ const agentsDir = path.join(directory, 'agents')
223
+ let agentFiles = []
224
+ try { agentFiles = (await fs.readdir(agentsDir)).filter(file => file.endsWith('.md')).sort() } catch (error) { if (error.code !== 'ENOENT') throw error }
225
+ const teamInfo = plugin.teamInfo && typeof plugin.teamInfo === 'object' ? plugin.teamInfo : {}
226
+ const declaredAgents = Array.isArray(plugin.agents) ? plugin.agents : []
227
+ const leadRef = isTeam
228
+ ? String(teamInfo.leadAgent || teamInfo.leader || teamInfo.lead || plugin.agentName || agentFiles[0] || declaredAgents[0] || '').trim()
229
+ : String(plugin.agent || plugin.agentName || agentFiles[0] || declaredAgents[0] || '').trim()
230
+ const leadPath = leadRef.includes('/') ? leadRef : path.join('agents', leadRef.endsWith('.md') ? leadRef : `${leadRef}.md`)
231
+ const leadFile = leadRef
232
+ ? path.resolve(directory, leadPath)
233
+ : undefined
234
+ if (!leadFile || !leadFile.startsWith(path.resolve(directory) + path.sep)) throw new Error(`EXPERT_AGENT_INVALID: ${slug} 的负责人路径不合法`)
235
+ let leadFields = {}
236
+ try {
237
+ leadFields = parseExpertFrontmatter(await readFrontmatterHead(leadFile))
238
+ } catch (error) {
239
+ if (error.code === 'ENOENT') throw new Error(`EXPERT_AGENT_MISSING: ${slug} 找不到负责人文件 ${leadRef}`)
240
+ throw error
241
+ }
242
+ const displayName = ntdLocalized(plugin.displayName || plugin.title || leadFields.displayName || leadFields.name || (plugin.name && !EXPERT_PATH_SEGMENT_PATTERN.test(String(plugin.name).trim()) ? plugin.name : slug))
243
+ const description = ntdLocalized(plugin.description || plugin.displayDescription || plugin.summary || leadFields.description || '水务行业专家')
244
+ if (!displayName || !description) throw new Error(`EXPERT_FRONTMATTER_INCOMPLETE: ${directory} 缺少 displayName 或 description`)
245
+ const declaredMembers = Array.isArray(teamInfo.members) && teamInfo.members.length > 0
246
+ ? teamInfo.members
247
+ : Array.isArray(plugin.members)
248
+ ? [...plugin.members, ...(Array.isArray(teamInfo.memberAgents)
249
+ ? teamInfo.memberAgents
250
+ .filter(member => !plugin.members.some(item => item && (item.id === member || item.slug === member)))
251
+ .map(member => ({ id: member }))
252
+ : [])]
253
+ : Array.isArray(teamInfo.memberAgents)
254
+ ? teamInfo.memberAgents
255
+ : []
256
+ const members = isTeam ? declaredMembers.map(ntdMember).filter(Boolean) : []
257
+ return {
258
+ slug,
259
+ name: displayName,
260
+ description,
261
+ version: String(plugin.version || '').trim(),
262
+ profession: ntdLocalized(plugin.profession || ''),
263
+ displayName: ntdLocalized(plugin.displayName || ''),
264
+ displayDescription: ntdLocalized(plugin.displayDescription || ''),
265
+ defaultInitPrompt: ntdLocalized(plugin.defaultInitPrompt || ''),
266
+ quickPrompts: Array.isArray(plugin.quickPrompts) ? plugin.quickPrompts.map(item => ntdLocalized(item)).filter(Boolean) : [],
267
+ tags: Array.isArray(plugin.tags)
268
+ ? plugin.tags.map(item => ({
269
+ zh: ntdLocalized(item && typeof item === 'object' ? item.zh : item),
270
+ en: ntdLocalized(item && typeof item === 'object' ? item.en : item),
271
+ })).filter(item => item.zh || item.en)
272
+ : [],
273
+ categoryId: String(plugin.categoryId || '').trim(),
274
+ agentName: String(plugin.agentName || plugin.agent_name || '').trim(),
275
+ leadAgent: String(teamInfo.leadAgent || teamInfo.leader || '').trim(),
276
+ memberAgents: Array.isArray(teamInfo.memberAgents) ? teamInfo.memberAgents.map(String) : [],
277
+ emoji: String(plugin.emoji || leadFields.emoji || (isTeam ? '👥' : '🧠')).trim(),
278
+ avatar: String(plugin.avatar || leadFields.avatar || '').trim(),
279
+ filePath: leadFile,
280
+ custom: false,
281
+ source: 'ntd',
282
+ kind: isTeam ? 'team' : 'expert',
283
+ expertType: isTeam ? 'team' : 'expert',
284
+ members,
285
+ skills: Array.isArray(plugin.skills) ? plugin.skills : [],
286
+ pluginPath,
287
+ assetDir: directory,
288
+ plugin,
289
+ }
290
+ }
291
+
292
+ async function walkNtdEntries(directory, add, division, depth = 0) {
293
+ if (depth > 4) return
294
+ let entries
295
+ try { entries = await fs.readdir(directory, { withFileTypes: true }) } catch (error) { if (error.code === 'ENOENT') return; throw error }
296
+ for (const entry of entries) {
297
+ if (!entry.isDirectory()) continue
298
+ const child = path.join(directory, entry.name)
299
+ const pluginPath = path.join(child, NTD_PLUGIN_DIR, NTD_PLUGIN_FILE)
300
+ const plugin = await loadNtdEntry(child, pluginPath)
301
+ if (plugin) {
302
+ if (plugin.kind !== 'team') plugin.division = division || 'general'
303
+ add(plugin)
304
+ } else {
305
+ await walkNtdEntries(child, add, division || entry.name, depth + 1)
306
+ }
307
+ }
308
+ }
309
+
137
310
  /**
138
311
  * 扫描名册目录,只读 frontmatter 建立目录。
139
312
  *
@@ -141,41 +314,66 @@ export function expertBody(text) {
141
314
  * 专家在分区内按 slug 排序,保证同一次构建的目录顺序稳定。
142
315
  *
143
316
  * @param rootDir - 名册根目录,默认 {@link DEFAULT_EXPERTS_DIR}。
144
- * @returns `{ divisions, bySlug }`;`divisions` 是 `[{ slug, name, experts }]`。
317
+ * @returns `{ divisions, teams, bySlug }`;`divisions` 是 `[{ slug, name, experts }]`。
145
318
  * @throws 当文件名不合法、缺必填字段或 slug 重复时——名册是自己发布的资产,
146
319
  * 坏文件必须显式失败,不能悄悄少一个专家。
147
320
  */
148
321
  export async function loadExpertCatalog(rootDir = DEFAULT_EXPERTS_DIR) {
149
322
  const divisions = []
150
323
  const bySlug = new Map()
324
+ const teams = []
325
+ const seenNtdPlugins = new Set()
326
+ const byDivision = new Map()
327
+ const add = expert => {
328
+ if (bySlug.has(expert.slug)) throw new Error(`EXPERT_SLUG_DUPLICATE: ${expert.slug} 在名册中重复`)
329
+ bySlug.set(expert.slug, expert)
330
+ if (expert.kind === 'team') {
331
+ teams.push(expert)
332
+ return
333
+ }
334
+ const divisionSlug = expert.division || 'general'
335
+ let division = byDivision.get(divisionSlug)
336
+ if (!division) {
337
+ division = { slug: divisionSlug, name: EXPERT_DIVISIONS[divisionSlug] || divisionSlug, experts: [] }
338
+ byDivision.set(divisionSlug, division)
339
+ divisions.push(division)
340
+ }
341
+ division.experts.push(expert)
342
+ }
151
343
  let divisionEntries = []
152
344
  try {
153
345
  divisionEntries = await fs.readdir(rootDir, { withFileTypes: true })
154
346
  } catch (error) {
155
- if (error.code === 'ENOENT') return { divisions, bySlug }
347
+ if (error.code === 'ENOENT') return { divisions, teams, bySlug }
156
348
  throw error
157
349
  }
158
350
 
159
351
  for (const entry of divisionEntries) {
160
352
  if (!entry.isDirectory() || !EXPERT_PATH_SEGMENT_PATTERN.test(entry.name)) continue
161
353
  const divisionSlug = entry.name
162
- const experts = []
163
- const files = (await fs.readdir(path.join(rootDir, divisionSlug))).filter(name => name.endsWith('.md')).sort()
354
+ const divisionDir = path.join(rootDir, divisionSlug)
355
+ const ntdPlugin = await loadNtdEntry(divisionDir, path.join(divisionDir, NTD_PLUGIN_DIR, NTD_PLUGIN_FILE))
356
+ if (ntdPlugin) {
357
+ seenNtdPlugins.add(ntdPlugin.pluginPath)
358
+ if (ntdPlugin.kind !== 'team') ntdPlugin.division = divisionSlug
359
+ add(ntdPlugin)
360
+ continue
361
+ }
362
+ const files = (await fs.readdir(divisionDir)).filter(name => name.endsWith('.md')).sort()
164
363
  for (const file of files) {
165
364
  const slug = file.slice(0, -3)
166
365
  if (!EXPERT_PATH_SEGMENT_PATTERN.test(slug)) {
167
366
  throw new Error(`EXPERT_SLUG_INVALID: ${divisionSlug}/${file} 只允许小写字母数字与连字符`)
168
367
  }
169
368
  const filePath = path.join(rootDir, divisionSlug, file)
170
- const fields = parseExpertFrontmatter(await readFrontmatterHead(filePath))
369
+ const header = await readFrontmatterHead(filePath)
370
+ const fields = parseExpertFrontmatter(header)
371
+ const collections = parseFrontmatterCollections(header)
171
372
  const name = (fields.name || '').trim()
172
373
  const description = (fields.description || '').trim()
173
374
  if (!name || !description) {
174
375
  throw new Error(`EXPERT_FRONTMATTER_INCOMPLETE: ${divisionSlug}/${file} 缺少 name 或 description`)
175
376
  }
176
- if (bySlug.has(slug)) {
177
- throw new Error(`EXPERT_SLUG_DUPLICATE: ${slug} 在名册中重复`)
178
- }
179
377
  if (fields.division && fields.division !== divisionSlug) {
180
378
  throw new Error(
181
379
  `EXPERT_DIVISION_MISMATCH: ${divisionSlug}/${file} 的 division=${fields.division} 与目录不一致`,
@@ -186,26 +384,46 @@ export async function loadExpertCatalog(rootDir = DEFAULT_EXPERTS_DIR) {
186
384
  division: divisionSlug,
187
385
  name,
188
386
  description,
387
+ version: String(fields.version || '').trim(),
388
+ profession: String(fields.profession || '').trim(),
389
+ displayName: String(fields.displayName || '').trim(),
390
+ displayDescription: String(fields.displayDescription || '').trim(),
391
+ defaultInitPrompt: String(fields.defaultInitPrompt || '').trim(),
392
+ quickPrompts: String(fields.quickPrompts || '').split('|').map(item => item.trim()).filter(Boolean),
393
+ tags: String(fields.tags || '').split('|').map(item => item.trim()).filter(Boolean).map(item => ({ zh: item, en: item })),
394
+ categoryId: String(fields.categoryId || '').trim(),
395
+ frontmatter: fields,
396
+ knowledgeBases: Array.isArray(collections.knowledgeBases) ? collections.knowledgeBases : [],
189
397
  emoji: (fields.emoji || '').trim(),
190
398
  // 头像图片:http(s) URL 直接用;否则当作与本 md 同目录的文件名,
191
399
  // 由 `/api/waterbuddy/experts/avatar` 按需吐出来(不塞进名册快照)。
192
400
  avatar: (fields.avatar || '').trim(),
193
401
  filePath,
194
402
  custom: false,
403
+ source: 'legacy',
404
+ kind: 'expert',
405
+ expertType: 'expert',
406
+ members: [],
195
407
  }
196
- experts.push(expert)
197
- bySlug.set(slug, expert)
198
- }
199
- if (experts.length > 0) {
200
- divisions.push({
201
- slug: divisionSlug,
202
- name: EXPERT_DIVISIONS[divisionSlug] || divisionSlug,
203
- experts,
204
- })
408
+ add(expert)
205
409
  }
206
410
  }
207
411
 
208
- return { divisions, bySlug }
412
+ await walkNtdEntries(rootDir, expert => {
413
+ if (seenNtdPlugins.has(expert.pluginPath)) return
414
+ seenNtdPlugins.add(expert.pluginPath)
415
+ add(expert)
416
+ }, undefined)
417
+ // NTD 团队只保存成员引用;等所有旧版与 NTD 专家都入册后再解析,避免目录顺序影响结果。
418
+ for (const team of teams) {
419
+ team.memberExperts = team.members.map(member => bySlug.get(member.slug)).filter(Boolean)
420
+ }
421
+ // 声明顺序优先,未知分区稳定排在最后;避免 readdir 顺序改变设置页和 @ 菜单。
422
+ const divisionOrder = new Map(Object.keys(EXPERT_DIVISIONS).map((slug, index) => [slug, index]))
423
+ divisions.sort((left, right) => (divisionOrder.get(left.slug) ?? Number.MAX_SAFE_INTEGER) - (divisionOrder.get(right.slug) ?? Number.MAX_SAFE_INTEGER) || left.slug.localeCompare(right.slug))
424
+ for (const division of divisions) division.experts.sort((left, right) => left.slug.localeCompare(right.slug))
425
+ teams.sort((left, right) => left.slug.localeCompare(right.slug))
426
+ return { divisions, teams, bySlug }
209
427
  }
210
428
 
211
429
  /**
@@ -223,6 +441,178 @@ export async function readExpertBody(expert) {
223
441
  return expertBody(await fs.readFile(expert.filePath, 'utf8'))
224
442
  }
225
443
 
444
+ async function countExpertFilesAndSize(target) {
445
+ let fileCount = 0
446
+ let totalSize = 0
447
+ const walk = async current => {
448
+ const stat = await fs.stat(current).catch(() => undefined)
449
+ if (!stat) return
450
+ if (stat.isFile()) {
451
+ fileCount += 1
452
+ totalSize += stat.size
453
+ return
454
+ }
455
+ if (!stat.isDirectory()) return
456
+ const entries = await fs.readdir(current, { withFileTypes: true })
457
+ for (const entry of entries) {
458
+ if (entry.name === '.git') continue
459
+ await walk(path.join(current, entry.name))
460
+ }
461
+ }
462
+ await walk(target)
463
+ return { fileCount, totalSize }
464
+ }
465
+
466
+ function displayExpertPath(target) {
467
+ const absolute = path.resolve(target)
468
+ const home = os.homedir()
469
+ if (absolute === home) return '~'
470
+ if (absolute.startsWith(home + path.sep)) return '~' + absolute.slice(home.length)
471
+ const relative = path.relative(process.cwd(), absolute)
472
+ return relative && !relative.startsWith('..') ? relative : absolute
473
+ }
474
+
475
+ function detailAgentName(filePath) {
476
+ return path.basename(filePath).replace(/\.md$/i, '')
477
+ }
478
+
479
+ function detailAgentMatches(agent, wanted) {
480
+ if (!wanted) return false
481
+ const normalized = String(wanted).replace(/^\.\//, '')
482
+ return agent.name === wanted
483
+ || agent.relPath.replace(/^\.\//, '') === normalized
484
+ || detailAgentName(agent.relPath) === detailAgentName(normalized)
485
+ }
486
+
487
+ async function readAgentDetails(expert) {
488
+ if (expert.custom && !expert.filePath) return []
489
+ if (expert.source !== 'ntd') {
490
+ const relPath = expert.filePath ? path.basename(expert.filePath) : `${expert.slug}.md`
491
+ return [{
492
+ name: expert.name || expert.slug,
493
+ description: expert.description || '',
494
+ emoji: expert.emoji || '',
495
+ relPath,
496
+ role: 'lead',
497
+ }]
498
+ }
499
+ const root = expert.assetDir
500
+ if (!root) return []
501
+ let relativePaths = Array.isArray(expert.plugin?.agents) ? expert.plugin.agents.map(String) : []
502
+ if (relativePaths.length === 0) {
503
+ try {
504
+ relativePaths = (await fs.readdir(path.join(root, 'agents'), { withFileTypes: true }))
505
+ .filter(entry => entry.isFile() && entry.name.endsWith('.md'))
506
+ .map(entry => `./agents/${entry.name}`)
507
+ .sort()
508
+ } catch {
509
+ relativePaths = []
510
+ }
511
+ }
512
+ const details = []
513
+ for (const relPath of relativePaths) {
514
+ const filePath = path.resolve(root, relPath)
515
+ if (!(filePath === path.resolve(root) || filePath.startsWith(path.resolve(root) + path.sep))) continue
516
+ const fields = await readFrontmatterHead(filePath).then(parseExpertFrontmatter).catch(() => ({}))
517
+ const fileName = detailAgentName(filePath)
518
+ const member = (expert.members || []).find(item => detailAgentMatches({ name: item.slug, relPath: fileName }, fileName) || item.slug === fileName)
519
+ details.push({
520
+ name: String(fields.name || fields.displayName || fileName).trim(),
521
+ description: String(fields.description || '').trim(),
522
+ emoji: String(fields.emoji || '').trim(),
523
+ relPath,
524
+ role: member?.role || (detailAgentMatches({ name: fileName, relPath }, expert.leadAgent || expert.agentName) ? 'lead' : 'member'),
525
+ })
526
+ }
527
+ return details
528
+ }
529
+
530
+ async function readSkillDetails(expert) {
531
+ const skills = Array.isArray(expert.plugin?.skills) ? expert.plugin.skills : (expert.skills || [])
532
+ if (!Array.isArray(skills) || skills.length === 0) return []
533
+ const root = expert.assetDir
534
+ const details = []
535
+ for (const item of skills) {
536
+ const raw = typeof item === 'string' ? item : item?.name || item?.path || ''
537
+ const relPath = String(raw || '').trim()
538
+ if (!relPath) continue
539
+ if (!root) {
540
+ details.push({ skillName: relPath, description: '', descriptionZh: '', descriptionEn: '' })
541
+ continue
542
+ }
543
+ const skillDir = path.resolve(root, relPath)
544
+ if (!(skillDir === path.resolve(root) || skillDir.startsWith(path.resolve(root) + path.sep))) continue
545
+ const skillPath = path.join(skillDir, 'SKILL.md')
546
+ const fields = await readFrontmatterHead(skillPath).then(parseExpertFrontmatter).catch(() => ({}))
547
+ const skillName = String(fields.name || path.basename(skillDir)).trim()
548
+ details.push({
549
+ skillName,
550
+ description: String(fields.description || '').trim(),
551
+ descriptionZh: String(fields.description_zh || fields.description || '').trim(),
552
+ descriptionEn: String(fields.description_en || '').trim(),
553
+ version: String(fields.version || '').trim(),
554
+ emoji: String(fields.emoji || '').trim(),
555
+ relPath,
556
+ })
557
+ }
558
+ return details
559
+ }
560
+
561
+ /**
562
+ * 读取设置页详情弹窗所需的完整元数据。
563
+ *
564
+ * 名册快照保持轻量;只有用户打开详情时才读取目录统计、agent 摘要、技能摘要和 plugin.json。
565
+ */
566
+ export async function readExpertDetails(expert) {
567
+ const root = expert.assetDir || expert.filePath || ''
568
+ const stats = root ? await countExpertFilesAndSize(root) : { fileCount: 0, totalSize: 0 }
569
+ const agents = await readAgentDetails(expert)
570
+ const pluginMembers = Array.isArray(expert.plugin?.members) ? expert.plugin.members : []
571
+ const members = (expert.members || []).map(member => {
572
+ const metadata = pluginMembers.find(item => item && (item.id === member.slug || item.slug === member.slug))
573
+ return {
574
+ ...member,
575
+ profession: metadata ? ntdLocalized(metadata.profession || metadata.roleName || '') : '',
576
+ role: metadata?.role === 'lead' ? 'lead' : (member.slug === (expert.leadAgent || expert.agentName) ? 'lead' : 'member'),
577
+ }
578
+ })
579
+ const pluginJson = expert.source === 'ntd'
580
+ ? (expert.plugin || {})
581
+ : {
582
+ name: expert.slug,
583
+ version: expert.version || '1.0.0',
584
+ expertType: 'agent',
585
+ displayName: { zh: expert.name },
586
+ profession: { zh: expert.profession || expert.name },
587
+ displayDescription: { zh: expert.description },
588
+ agentName: expert.slug,
589
+ ...(expert.frontmatter || {}),
590
+ }
591
+ const leadAgent = expert.leadAgent || expert.agentName || agents[0]?.name || ''
592
+ const leadAgentFile = agents.find(agent => detailAgentMatches(agent, leadAgent))?.relPath || agents[0]?.relPath || ''
593
+ const quickPrompts = Array.isArray(expert.quickPrompts) ? expert.quickPrompts : []
594
+ return {
595
+ source: expert.custom ? 'user' : 'builtin',
596
+ sourceLabel: expert.custom ? '自定义' : '内置',
597
+ readOnly: expert.custom !== true,
598
+ version: expert.version || pluginJson.version || '1.0.0',
599
+ profession: expert.profession || ntdLocalized(pluginJson.profession || ''),
600
+ defaultInitPrompt: expert.defaultInitPrompt || ntdLocalized(pluginJson.defaultInitPrompt || ''),
601
+ quickPrompts,
602
+ tags: expert.tags || [],
603
+ knowledgeBases: expert.knowledgeBases || (Array.isArray(pluginJson.knowledgeBases) ? pluginJson.knowledgeBases : []),
604
+ categoryId: expert.categoryId || String(pluginJson.categoryId || '').trim(),
605
+ dir: displayExpertPath(root),
606
+ fileCount: stats.fileCount,
607
+ totalSize: stats.totalSize,
608
+ members,
609
+ agentFiles: agents,
610
+ leadAgentFile,
611
+ skillMeta: await readSkillDetails(expert),
612
+ pluginJson,
613
+ }
614
+ }
615
+
226
616
  /**
227
617
  * 把自定义专家并进内置名册,产出一份新名册(不改原对象)。
228
618
  *
@@ -237,6 +627,7 @@ export function mergeCustomExperts(catalog, customExperts) {
237
627
  if (!Array.isArray(customExperts) || customExperts.length === 0) return catalog
238
628
  const bySlug = new Map(catalog.bySlug)
239
629
  const divisions = catalog.divisions.map(division => ({ ...division, experts: [...division.experts] }))
630
+ const teams = [...(catalog.teams || [])]
240
631
  const byDivision = new Map(divisions.map(division => [division.slug, division]))
241
632
  for (const custom of customExperts) {
242
633
  if (!custom || bySlug.has(custom.slug)) continue
@@ -249,6 +640,10 @@ export function mergeCustomExperts(catalog, customExperts) {
249
640
  avatar: custom.avatar || '',
250
641
  body: custom.prompt,
251
642
  custom: true,
643
+ source: 'user',
644
+ kind: 'expert',
645
+ expertType: 'expert',
646
+ members: [],
252
647
  }
253
648
  bySlug.set(expert.slug, expert)
254
649
  const division = byDivision.get(expert.division)
@@ -264,7 +659,7 @@ export function mergeCustomExperts(catalog, customExperts) {
264
659
  divisions.push(created)
265
660
  }
266
661
  }
267
- return { divisions, bySlug }
662
+ return { divisions, teams, bySlug }
268
663
  }
269
664
 
270
665
  /**
@@ -298,7 +693,86 @@ export const EXPERT_OUTPUT_RULES = [
298
693
  */
299
694
  export async function readExpertPersona(expert) {
300
695
  const body = await readExpertBody(expert)
301
- return sanitizePersona(`${EXPERT_OUTPUT_RULES}\n${body}`)
696
+ const teamContext = expert.kind === 'team'
697
+ ? [
698
+ '## 专家团协作上下文',
699
+ '',
700
+ '你是一个专家团的负责人。以下成员是可协同的专业角色;请在最终交付中综合成员视角,',
701
+ '不要虚构未提供的成员结论,也不要把内部协作过程写进给用户的成品。',
702
+ ...(expert.members || []).map(member => `- ${member.name || member.slug}(${member.slug})`),
703
+ '',
704
+ ].join('\n')
705
+ : ''
706
+ return sanitizePersona(`${EXPERT_OUTPUT_RULES}\n${teamContext}${body}`)
707
+ }
708
+
709
+ function normalizeAgentReference(value) {
710
+ return String(value || '')
711
+ .replaceAll('\\', '/')
712
+ .replace(/^\.\//, '')
713
+ .replace(/^agents\//, '')
714
+ .replace(/\.md$/i, '')
715
+ .trim()
716
+ }
717
+
718
+ function teamMemberMetadata(expert, memberId) {
719
+ const wanted = normalizeAgentReference(memberId)
720
+ return (expert.members || []).find(member => normalizeAgentReference(member.slug) === wanted)
721
+ || (expert.members || []).find(member => normalizeAgentReference(member.name) === wanted)
722
+ }
723
+
724
+ async function resolveTeamMemberFile(expert, memberId) {
725
+ const wanted = normalizeAgentReference(memberId)
726
+ if (!wanted || !expert?.assetDir) return undefined
727
+ const root = path.resolve(expert.assetDir)
728
+ const candidates = []
729
+ if (expert.filePath) candidates.push(expert.filePath)
730
+ const configuredAgents = Array.isArray(expert.plugin?.agents) ? expert.plugin.agents : []
731
+ for (const configured of configuredAgents) {
732
+ const relative = String(configured || '').trim()
733
+ if (relative) candidates.push(path.resolve(root, relative))
734
+ }
735
+ for (const candidate of candidates) {
736
+ const absolute = path.resolve(candidate)
737
+ if (!(absolute === root || absolute.startsWith(root + path.sep))) continue
738
+ const relative = path.relative(root, absolute)
739
+ if (normalizeAgentReference(relative) !== wanted && normalizeAgentReference(path.basename(absolute)) !== wanted) continue
740
+ try {
741
+ const stat = await fs.stat(absolute)
742
+ if (stat.isFile()) return absolute
743
+ } catch (error) {
744
+ if (error.code !== 'ENOENT') throw error
745
+ }
746
+ }
747
+ return undefined
748
+ }
749
+
750
+ /**
751
+ * 读取专家团中某一成员的完整 persona。
752
+ *
753
+ * 成员可以是 NTD 包内的 `agents/*.md`,也可以是旧名册中由团队引用的专家。
754
+ * 这个 helper 只给 Host 的 fan-out 使用;详情页仍然读取未消毒的原文。
755
+ */
756
+ export async function readExpertMemberPersona(expert, memberId) {
757
+ if (expert?.kind !== 'team') return readExpertPersona(expert)
758
+ const member = teamMemberMetadata(expert, memberId)
759
+ const localFile = await resolveTeamMemberFile(expert, memberId)
760
+ let body
761
+ if (localFile) {
762
+ body = expertBody(await fs.readFile(localFile, 'utf8'))
763
+ } else {
764
+ const referenced = (expert.memberExperts || []).find(item => normalizeAgentReference(item.slug) === normalizeAgentReference(memberId))
765
+ if (!referenced) throw new Error(`EXPERT_MEMBER_PERSONA_MISSING: ${expert.slug}/${memberId}`)
766
+ body = await readExpertBody(referenced)
767
+ }
768
+ if (!body) throw new Error(`EXPERT_MEMBER_PERSONA_EMPTY: ${expert.slug}/${memberId}`)
769
+ const teamContext = [
770
+ '## 专家团协作上下文',
771
+ '',
772
+ `你是“${expert.name}”中的${member?.name || memberId}。只输出自己的专业判断,供负责人汇总;不要虚构其他成员结论。`,
773
+ '',
774
+ ].join('\n')
775
+ return sanitizePersona(`${EXPERT_OUTPUT_RULES}\n${teamContext}${body}`)
302
776
  }
303
777
 
304
778
  /**
@@ -313,6 +787,17 @@ export function resolveExpertAvatar(expert) {
313
787
  if (!raw) return undefined
314
788
  if (/^https?:\/\//i.test(raw)) return { kind: 'remote', url: raw }
315
789
  if (raw.includes('..') || raw.startsWith('/')) return undefined
316
- if (!expert.filePath) return undefined
317
- return { kind: 'file', path: path.join(path.dirname(expert.filePath), raw) }
790
+ if (!expert.filePath && !expert.assetDir) return undefined
791
+ const baseDir = expert.assetDir || path.dirname(expert.filePath)
792
+ return { kind: 'file', path: path.join(baseDir, raw) }
793
+ }
794
+
795
+ export function resolveExpertMemberAvatar(expert, member) {
796
+ const raw = String(member?.avatar || '').trim()
797
+ if (!raw) return undefined
798
+ if (/^https?:\/\//i.test(raw)) return { kind: 'remote', url: raw }
799
+ if (raw.includes('..') || raw.startsWith('/')) return undefined
800
+ if (!expert?.filePath && !expert?.assetDir) return undefined
801
+ const baseDir = expert.assetDir || path.dirname(expert.filePath)
802
+ return { kind: 'file', path: path.join(baseDir, raw) }
318
803
  }