@weibaohui/experts-management 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@weibaohui/experts-management",
3
- "version": "0.1.4",
4
- "description": "dsh 插件 · 专家市场:管理 ntd 格式的专家与专家团队(plugin.json + Agent MD + 技能集),浏览/安装 50+ 内置专家市场;每个专家注册为「仅用户可调用」的技能,在对话输入框用 /expert-名称 即可以该专家的身份执行任务(宿主确定性注入角色定义与技能清单,不占用模型目录 token)。",
3
+ "version": "0.3.0",
4
+ "description": "dsh 插件 · 专家管理:管理 ntd 格式的专家与专家团队(plugin.json + Agent MD + 技能集),浏览/安装 50+ 内置专家市场;每个专家注册为「仅用户可调用」的技能,在对话输入框用 /expert-名称 即可以该专家的身份执行任务(宿主确定性注入角色定义与技能清单,不占用模型目录 token)。",
5
5
  "license": "MIT",
6
6
  "keywords": [
7
7
  "dsh",
@@ -44,6 +44,7 @@
44
44
  "node": ">=22.5"
45
45
  },
46
46
  "dependencies": {
47
+ "@weibaohui/dsh-plugin-kit": "^0.1.0",
47
48
  "yaml": "^2.9.0"
48
49
  },
49
50
  "devDependencies": {
package/src/index.js CHANGED
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * Manages ntd-format experts (WorkBuddy plugin.json + Agent MD + skills)
7
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;
8
+ * - Builtin: ntd-resource's experts/ subtree via git sparse checkout into the
9
+ * plugin's own dir ($DSH_HOME/experts-management/builtin). Read-only shelf;
10
10
  * install copies into the user library.
11
11
  * - User library: $DSH_HOME/experts (writable, the only built-in source).
12
12
  * Additional directories are opt-in via config.extraSources.
@@ -19,8 +19,9 @@
19
19
  */
20
20
 
21
21
  const { createReadStream } = require('node:fs')
22
- const { execFile } = require('node:child_process')
23
22
  const { randomUUID } = require('node:crypto')
23
+ const { execFile } = require('node:child_process')
24
+ const { createShareRunJob } = require('@weibaohui/dsh-plugin-kit')
24
25
  const fsP = require('node:fs/promises')
25
26
  const { basename, join, relative, resolve, sep } = require('node:path')
26
27
  const { homedir } = require('node:os')
@@ -46,9 +47,9 @@ function dshHome() {
46
47
  return process.env.DSH_HOME ? resolve(process.env.DSH_HOME) : join(homedir(), '.dsh')
47
48
  }
48
49
 
49
- const MARKET_SCAN_SKIP = new Set(['.git', 'node_modules'])
50
+ const BUILTIN_SCAN_SKIP = new Set(['.git', 'node_modules'])
50
51
  const RANK_INSTALLED = 100
51
- const RANK_MARKET = 500
52
+ const RANK_BUILTIN = 500
52
53
  const MAX_BODY_BYTES = 64 * 1024
53
54
  const DESCRIPTION_LIMIT = 140
54
55
  // 同款正则见 skill/skill/src/index.ts SKILL_NAME —— 不合规的候选会让 registry 抛错
@@ -226,7 +227,7 @@ async function scanExpertsRoot(root, sourceKey) {
226
227
  try { entries = await fsP.readdir(root, { withFileTypes: true }) } catch { return { experts, errors } }
227
228
  for (const entry of entries) {
228
229
  if (!entry.isDirectory()) continue
229
- if (MARKET_SCAN_SKIP.has(entry.name)) continue
230
+ if (BUILTIN_SCAN_SKIP.has(entry.name)) continue
230
231
  const dir = join(root, entry.name)
231
232
  try {
232
233
  experts.push(await readExpertDir(root, dir, sourceKey))
@@ -418,7 +419,7 @@ async function atomicWriteJs(file, content) {
418
419
  await fsP.rename(temp, file)
419
420
  }
420
421
 
421
- // ── Market git sync(与 skills-management 同款;稀疏检出 experts/ 子树)──
422
+ // ── Builtin git sync(与 skills-management 同款管线;稀疏检出 experts/ 子树)──
422
423
 
423
424
  function gitExec(binary, args, cwd) {
424
425
  return new Promise((fulfil, reject) => {
@@ -481,7 +482,7 @@ async function gitSyncRepo(binary, url, branch, repoDir, token, sparsePaths) {
481
482
  return { isFirstClone: false, hasUpdates: before !== after, before, after }
482
483
  }
483
484
 
484
- const DEFAULT_MARKET_SYNC = {
485
+ const DEFAULT_BUILTIN_SYNC = {
485
486
  url: 'https://gitcode.com/weibaohui/ntd-resource.git',
486
487
  branch: 'main',
487
488
  gitBinary: 'git',
@@ -489,11 +490,11 @@ const DEFAULT_MARKET_SYNC = {
489
490
  syncOnStartup: true,
490
491
  }
491
492
  // ntd-resource 同时携带 skills(~400MB),专家市场只稀疏检出 experts/ 子树
492
- const DEFAULT_MARKET_SPARSE_PATHS = ['experts']
493
+ const DEFAULT_BUILTIN_SPARSE_PATHS = ['experts']
493
494
 
494
- const MARKET_SETTINGS_NS = 'experts-management-market'
495
+ const BUILTIN_SETTINGS_NS = 'experts-management-builtin'
495
496
 
496
- function marketSettingsSchema() {
497
+ function builtinSettingsSchema() {
497
498
  if (!Schema) return null
498
499
  return Schema.object({
499
500
  url: Schema.string(),
@@ -507,15 +508,51 @@ function marketSettingsSchema() {
507
508
  }
508
509
 
509
510
  function baseSettings(config) {
510
- const cfg = (config.marketSync && typeof config.marketSync === 'object') ? config.marketSync : {}
511
- const base = { ...DEFAULT_MARKET_SYNC }
511
+ const cfg = (config.builtinSync && typeof config.builtinSync === 'object') ? config.builtinSync : {}
512
+ const base = { ...DEFAULT_BUILTIN_SYNC }
512
513
  for (const key of ['url', 'branch', 'gitBinary', 'autoSync', 'syncOnStartup']) {
513
514
  if (cfg[key] !== undefined) base[key] = cfg[key]
514
515
  }
515
- if (config.marketRepoDir !== undefined) base.repoDir = resolve(String(config.marketRepoDir))
516
+ if (config.builtinRepoDir !== undefined) base.repoDir = resolve(String(config.builtinRepoDir))
516
517
  return base
517
518
  }
518
519
 
520
+ // ── 编辑端点(v0.3):只写 dsh 用户库;内置只读 ─────────────────────────
521
+ const EDIT_BODY_MAX_BYTES = 8 * 1024 * 1024
522
+
523
+ /** 魔数嗅探图片类型;非白名单格式返回 null。 */
524
+ function sniffImage(buf) {
525
+ if (!buf || buf.length < 12) return null
526
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return { ext: 'png', type: 'image/png' }
527
+ if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { ext: 'jpg', type: 'image/jpeg' }
528
+ const head = buf.slice(0, 12)
529
+ if (head.toString('latin1').startsWith('GIF8')) return { ext: 'gif', type: 'image/gif' }
530
+ if (head.toString('latin1').startsWith('RIFF') && head.toString('latin1').slice(8) === 'WEBP') return { ext: 'webp', type: 'image/webp' }
531
+ return null
532
+ }
533
+
534
+ const readRawBody = (req, cap) => new Promise((fulfil, reject) => {
535
+ let size = 0
536
+ const chunks = []
537
+ req.on('data', (chunk) => {
538
+ size += chunk.length
539
+ if (size > cap) { reject(new Error(`image exceeds ${cap} bytes`)); if (typeof req.destroy === 'function') req.destroy(); return }
540
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
541
+ })
542
+ req.on('end', () => fulfil(Buffer.concat(chunks)))
543
+ req.on('error', reject)
544
+ })
545
+
546
+ /** 编辑端点公共前置:只解析 dsh 用户库副本(内置只读);名字围栏。 */
547
+ async function locateEditable(locateExpert, name) {
548
+ if (typeof name !== 'string' || name === '') throw new Error('body must provide name')
549
+ const { expert } = await locateExpert(name, 'dsh')
550
+ if (!isSafeExpertName(expert.name)) throw new Error(`invalid expert name: ${expert.name}`)
551
+ return expert
552
+ }
553
+
554
+ const MD_MAX_CHARS = 512 * 1024
555
+
519
556
  // ── Module export ────────────────────────────────────────────────────────
520
557
 
521
558
  module.exports = {
@@ -561,43 +598,59 @@ module.exports = {
561
598
  // git 市场检出(稀疏 experts/ 子树)也是一路来源:root 运行期可变,按调用时解析
562
599
  const allSourceRows = () => [
563
600
  ...sourceRows,
564
- { key: 'market', label: '专家市场', root: join(marketRootDir(), 'experts'), readOnly: true },
601
+ { key: 'builtin', label: '内置', root: join(builtinRootDir(), 'experts'), readOnly: true },
565
602
  ]
566
603
 
567
604
  // ── Market sync state / settings(skills-management 同款管线)────────
568
- const marketRootDir = () => {
569
- const eff = marketSettings()
605
+ const builtinRootDir = () => {
606
+ const eff = builtinSettings()
570
607
  return resolve(typeof eff.repoDir === 'string' && eff.repoDir !== '' ? eff.repoDir
571
- : config.marketRepoDir !== undefined ? resolve(String(config.marketRepoDir))
572
- : join(dshHome(), 'experts-management', 'market'))
608
+ : config.builtinRepoDir !== undefined ? resolve(String(config.builtinRepoDir))
609
+ : join(dshHome(), 'experts-management', 'builtin'))
573
610
  }
574
- const marketSparsePaths = () => config.marketSparsePaths === null
611
+ const builtinSparsePaths = () => config.builtinSparsePaths === null
575
612
  ? 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 }
613
+ : (Array.isArray(config.builtinSparsePaths) && config.builtinSparsePaths.length > 0 ? config.builtinSparsePaths.map(String) : DEFAULT_BUILTIN_SPARSE_PATHS)
614
+
615
+ // v0.1 v0.2 落盘迁移:market 命名 → builtin(检出目录 + 同步状态文件)
616
+ const migrateV1Layout = async () => {
617
+ const base = join(dshHome(), 'experts-management')
618
+ for (const [oldName, newName] of [['market', 'builtin'], ['market-sync.json', 'builtin-sync.json']]) {
619
+ const from = join(base, oldName)
620
+ const to = join(base, newName)
621
+ try {
622
+ await fsP.access(from)
623
+ try { await fsP.access(to); continue } catch {}
624
+ await fsP.rename(from, to)
625
+ ctx.logger.info && ctx.logger.info(`experts-management: migrated ${oldName} → ${newName}`)
626
+ } catch { /* 旧布局不存在:跳过 */ }
627
+ }
628
+ }
629
+ const builtinStateFile = join(dshHome(), 'experts-management', 'builtin-sync.json')
630
+ let builtinState = { lastSyncAt: undefined, lastResult: undefined }
580
631
  let settingsScope = null
581
632
  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 }
633
+ const builtinStateLoaded = migrateV1Layout()
634
+ .then(() => fsP.readFile(builtinStateFile, 'utf8'))
635
+ .then((raw) => {
636
+ let parsed
637
+ try { parsed = JSON.parse(raw) } catch { parsed = null }
638
+ if (parsed) builtinState = { lastSyncAt: parsed.lastSyncAt, lastResult: parsed.lastResult }
586
639
  })
587
640
  .catch(() => {})
588
641
  if (Schema && ctx.settings && typeof ctx.settings.register === 'function') {
589
642
  try {
590
- settingsScope = ctx.settings.register(MARKET_SETTINGS_NS, marketSettingsSchema(), { base: baseSettings(config) })
643
+ settingsScope = ctx.settings.register(BUILTIN_SETTINGS_NS, builtinSettingsSchema(), { base: baseSettings(config) })
591
644
  } catch (e) { ctx.logger.warn(`experts-management: settings register: ${e && e.message}`) }
592
645
  }
593
- const saveMarketState = async () => {
646
+ const saveBuiltinState = async () => {
594
647
  try {
595
- await fsP.mkdir(join(marketStateFile, '..'), { recursive: true })
596
- await atomicWriteJs(marketStateFile, JSON.stringify(marketState, null, 2))
597
- await fsP.chmod(marketStateFile, 0o600)
648
+ await fsP.mkdir(join(builtinStateFile, '..'), { recursive: true })
649
+ await atomicWriteJs(builtinStateFile, JSON.stringify(builtinState, null, 2))
650
+ await fsP.chmod(builtinStateFile, 0o600)
598
651
  } catch {}
599
652
  }
600
- const marketSettings = () => {
653
+ const builtinSettings = () => {
601
654
  if (settingsScope && typeof settingsScope.get === 'function') {
602
655
  const v = settingsScope.get()
603
656
  if (v && typeof v === 'object') return { ...baseSettings(config), ...v }
@@ -605,55 +658,55 @@ module.exports = {
605
658
  return { ...baseSettings(config), ...settingsOverrides }
606
659
  }
607
660
 
608
- let marketSyncRun = null
609
- const runMarketSync = async () => {
610
- if (marketSyncRun !== null) return marketSyncRun
611
- marketSyncRun = (async () => {
612
- await marketStateLoaded
613
- const eff = marketSettings()
661
+ let builtinSyncRun = null
662
+ const runBuiltinSync = async () => {
663
+ if (builtinSyncRun !== null) return builtinSyncRun
664
+ builtinSyncRun = (async () => {
665
+ await builtinStateLoaded
666
+ const eff = builtinSettings()
614
667
  const ok = await gitAvailable(eff.gitBinary)
615
668
  if (!ok) throw new Error('git is not available on PATH')
616
669
  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()
670
+ const repoDir = builtinRootDir()
671
+ const result = await gitSyncRepo(eff.gitBinary, eff.url, eff.branch, repoDir, eff.token, builtinSparsePaths())
672
+ builtinState.lastSyncAt = new Date().toISOString()
673
+ builtinState.lastResult = { ...result, at: builtinState.lastSyncAt, durationMs: Date.now() - started }
674
+ await saveBuiltinState()
622
675
  invalidate()
623
- return { ...marketState.lastResult, url: eff.url, branch: eff.branch, dir: repoDir }
624
- })().finally(() => { marketSyncRun = null })
625
- return marketSyncRun
676
+ return { ...builtinState.lastResult, url: eff.url, branch: eff.branch, dir: repoDir }
677
+ })().finally(() => { builtinSyncRun = null })
678
+ return builtinSyncRun
626
679
  }
627
680
 
628
681
  // Startup + periodic auto-sync (fire-and-forget; failures only warn)
629
682
  ctx.effect(() => {
630
- const eff = marketSettings()
683
+ const eff = builtinSettings()
631
684
  if (eff.syncOnStartup) {
632
- marketStateLoaded.then(() => runMarketSync()).catch(e => ctx.logger.warn(`experts-management: startup market sync: ${e && e.message}`))
685
+ builtinStateLoaded.then(() => runBuiltinSync()).catch(e => ctx.logger.warn(`experts-management: startup builtin sync: ${e && e.message}`))
633
686
  }
634
687
  const timer = setInterval(() => {
635
- const eff2 = marketSettings()
688
+ const eff2 = builtinSettings()
636
689
  if (!eff2.autoSync) return
637
- const last = marketState.lastSyncAt ? Date.parse(marketState.lastSyncAt) : 0
690
+ const last = builtinState.lastSyncAt ? Date.parse(builtinState.lastSyncAt) : 0
638
691
  if (Date.now() - last > 24 * 3600 * 1000) {
639
- runMarketSync().catch(e => ctx.logger.warn(`experts-management: auto market sync: ${e && e.message}`))
692
+ runBuiltinSync().catch(e => ctx.logger.warn(`experts-management: auto builtin sync: ${e && e.message}`))
640
693
  }
641
694
  }, 6 * 3600 * 1000)
642
695
  if (typeof timer.unref === 'function') timer.unref()
643
696
  return () => clearInterval(timer)
644
- }, 'experts-management: market auto-sync')
697
+ }, 'experts-management: builtin auto-sync')
645
698
 
646
699
  // ── Discovery ────────────────────────────────────────────────────────
647
700
  async function discoverAll() {
648
- const installed = [], market = []
701
+ const mine = [], builtin = []
649
702
  const errors = []
650
703
  for (const row of allSourceRows()) {
651
704
  const { experts, errors: errs } = await scanExpertsRoot(row.root, row.key)
652
705
  errors.push(...errs)
653
- if (row.key === 'dsh') installed.push(...experts)
654
- else market.push(...experts)
706
+ if (row.key === 'dsh') mine.push(...experts)
707
+ else builtin.push(...experts)
655
708
  }
656
- return { installed, market, errors }
709
+ return { mine, builtin, errors }
657
710
  }
658
711
 
659
712
  async function locateExpert(name, sourceKey) {
@@ -681,7 +734,7 @@ module.exports = {
681
734
  return {
682
735
  name: providerName,
683
736
  async list() {
684
- const { installed, market } = await discoverAll()
737
+ const { mine, builtin } = await discoverAll()
685
738
  const candidates = []
686
739
  const seen = new Set()
687
740
  // 专家一律 modelInvocable:false:不进模型目录(零 token 污染),
@@ -713,11 +766,11 @@ module.exports = {
713
766
  metadata: { expertType: e.expertType, profession: e.professionZh ?? e.professionEn, version: e.version },
714
767
  })
715
768
  }
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)
769
+ for (const e of mine) push(e, 'user-installed', RANK_INSTALLED)
770
+ const mineNames = new Set(mine.map((e) => e.name))
771
+ for (const e of builtin) {
772
+ if (mineNames.has(e.name)) continue // 用户库覆盖内置同名专家
773
+ push(e, 'builtin', RANK_BUILTIN)
721
774
  }
722
775
  return candidates
723
776
  },
@@ -758,6 +811,14 @@ module.exports = {
758
811
  })
759
812
 
760
813
  // ── HTTP API ─────────────────────────────────────────────────────────
814
+ // 分享执行:进程内 agents 服务(web app 自身)可用则流式,否则 headless spawn
815
+ const shareRunJobs = new Map()
816
+ let shareServices = null
817
+ try {
818
+ if (ctx.inject && typeof ctx.inject === 'function') {
819
+ ctx.inject(['agents', 'agentDefaultModel', 'sessions'], (svcs) => { shareServices = svcs })
820
+ }
821
+ } catch {}
761
822
  ctx.effect(() => ctx.webServer.register({
762
823
  kind: 'prefix',
763
824
  path: '/experts-management/api',
@@ -767,10 +828,10 @@ module.exports = {
767
828
  const apiPath = url.pathname.replace(/\/+$/, '')
768
829
  const query = url.searchParams
769
830
 
770
- // GET /experts-management/api → { sources, installed, market }
831
+ // GET /experts-management/api → { sources, mine, builtin }
771
832
  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))
833
+ const { mine, builtin, errors } = await discoverAll()
834
+ const mineNames = new Set(mine.map((e) => e.name))
774
835
  const summarize = (e) => ({
775
836
  name: e.name,
776
837
  displayName: e.displayNameZh ?? e.displayNameEn ?? e.name,
@@ -780,13 +841,13 @@ module.exports = {
780
841
  tags: e.tags.filter((t) => t.zh || t.en).map((t) => t.zh || t.en),
781
842
  hasAvatar: e.avatar !== undefined,
782
843
  source: e.source,
783
- installed: installedNames.has(e.name),
844
+ installed: mineNames.has(e.name),
784
845
  mtime: e.mtime,
785
846
  })
786
847
  sendJson(res, 200, {
787
848
  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),
849
+ mine: mine.map(summarize),
850
+ builtin: builtin.map(summarize),
790
851
  errors,
791
852
  })
792
853
  return
@@ -797,9 +858,11 @@ module.exports = {
797
858
  const name = query.get('name') || ''
798
859
  const { expert, row } = await locateExpert(name, query.get('source') || undefined)
799
860
  const { fileCount, totalSize } = await countFilesAndSize(expert.dir)
861
+ const rawPluginText = await fsP.readFile(expert.pluginJsonPath, 'utf8')
800
862
  sendJson(res, 200, {
801
863
  ...expert,
802
- plugin: parsePluginJson(await fsP.readFile(expert.pluginJsonPath, 'utf8')),
864
+ plugin: parsePluginJson(rawPluginText),
865
+ pluginJson: JSON.parse(rawPluginText),
803
866
  leadAgentFile: resolveLeadAgentFile(expert)?.name,
804
867
  dir: displayPath(expert.dir),
805
868
  sourceLabel: row.label,
@@ -854,7 +917,12 @@ module.exports = {
854
917
  if (req.method === 'POST' && apiPath.endsWith('/experts-management/api/install')) {
855
918
  const body = await readJsonBody(req)
856
919
  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)
920
+ // from 兼容 client source 字段;'auto'/缺省一律钉死为 builtin——
921
+ // 若解析到 dsh 源,overwrite 会先 rm 自己再空拷(v0.2.0 数据丢失事故),此路彻底封死
922
+ const from = typeof body.from === 'string' && body.from !== '' ? body.from
923
+ : typeof body.source === 'string' && body.source !== '' ? body.source : 'builtin'
924
+ if (from === 'dsh') throw new Error('cannot install from the dsh library (it is the install destination)')
925
+ const { expert } = await locateExpert(body.name, from)
858
926
  if (!isSafeExpertName(expert.name)) throw new Error(`invalid expert name: ${expert.name}`)
859
927
  const target = join(installedDir, expert.name)
860
928
  if (body.overwrite !== true) {
@@ -883,11 +951,185 @@ module.exports = {
883
951
  return
884
952
  }
885
953
 
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()
954
+ // ── 编辑端点(v0.3):仅 dsh 用户库可编辑,内置只读 ──
955
+
956
+ // PUT /experts-management/api/agent-md {name, agent?, content} — 角色定义全文
957
+ if (req.method === 'PUT' && apiPath.endsWith('/experts-management/api/agent-md')) {
958
+ const body = await readJsonBody(req)
959
+ const expert = await locateEditable(locateExpert, body.name)
960
+ if (typeof body.content !== 'string' || body.content.trim() === '') throw new Error('content must be a non-empty string')
961
+ if (body.content.length > MD_MAX_CHARS) throw new Error(`content exceeds ${MD_MAX_CHARS} chars`)
962
+ const normRel = (p0) => String(p0 || '').replace(/^\.\//, '')
963
+ const agentFile = body.agent !== undefined && body.agent !== ''
964
+ ? expert.agentFiles.find((a) => a.name === body.agent || normRel(a.relPath) === normRel(body.agent) || basename(a.mdPath) === body.agent)
965
+ : resolveLeadAgentFile(expert)
966
+ if (agentFile === undefined) throw new Error(`agent not found in expert '${expert.name}'`)
967
+ const full = resolveWithin(expert.dir, agentFile.relPath)
968
+ if (full === undefined || resolve(full) !== resolve(agentFile.mdPath)) throw new Error('agent file path escaped the expert dir')
969
+ await atomicWriteJs(full, body.content)
970
+ invalidate()
971
+ sendJson(res, 200, { ok: true, agent: agentFile.name })
972
+ return
973
+ }
974
+
975
+ // PUT /experts-management/api/metadata {name, metadata} — plugin.json 展示字段(读-改-写保留未知键)
976
+ if (req.method === 'PUT' && apiPath.endsWith('/experts-management/api/metadata')) {
977
+ const body = await readJsonBody(req)
978
+ const meta = body.metadata
979
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) throw new Error('metadata must be an object')
980
+ const expert = await locateEditable(locateExpert, body.name)
981
+ const pluginJson = JSON.parse(await fsP.readFile(expert.pluginJsonPath, 'utf8'))
982
+ const normLocalized = (v) => ({ zh: typeof v.zh === 'string' ? v.zh : '', en: typeof v.en === 'string' ? v.en : '' })
983
+ for (const key of ['displayName', 'profession', 'displayDescription', 'defaultInitPrompt']) {
984
+ if (meta[key] === undefined) continue
985
+ const v = meta[key]
986
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) throw new Error(`${key} must be an object`)
987
+ for (const lang of ['zh', 'en']) {
988
+ if (v[lang] !== undefined && (typeof v[lang] !== 'string' || v[lang].length > 2000)) throw new Error(`${key}.${lang} must be a string (≤2000 chars)`)
989
+ }
990
+ pluginJson[key] = normLocalized(v)
991
+ }
992
+ const listOfLocalized = (v, label) => {
993
+ if (!Array.isArray(v) || v.length > 20) throw new Error(`${label} must be an array (≤20)`)
994
+ return v.map((item) => {
995
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) throw new Error(`${label} items must be objects`)
996
+ return { zh: typeof item.zh === 'string' ? item.zh.slice(0, 2000) : '', en: typeof item.en === 'string' ? item.en.slice(0, 2000) : '' }
997
+ }).filter((item) => item.zh !== '' || item.en !== '')
998
+ }
999
+ if (meta.tags !== undefined) pluginJson.tags = listOfLocalized(meta.tags, 'tags')
1000
+ if (meta.quickPrompts !== undefined) pluginJson.quickPrompts = listOfLocalized(meta.quickPrompts, 'quickPrompts')
1001
+ await atomicWriteJs(expert.pluginJsonPath, JSON.stringify(pluginJson, null, 2))
1002
+ invalidate()
1003
+ sendJson(res, 200, { ok: true, plugin: pluginJson })
1004
+ return
1005
+ }
1006
+
1007
+ // PUT /experts-management/api/expert-skills {name, attach?, detach?} — 技能副本同步
1008
+ if (req.method === 'PUT' && apiPath.endsWith('/experts-management/api/expert-skills')) {
1009
+ const body = await readJsonBody(req)
1010
+ const attach = Array.isArray(body.attach) ? body.attach.map(String) : []
1011
+ const detach = Array.isArray(body.detach) ? body.detach.map(String) : []
1012
+ if (attach.length === 0 && detach.length === 0) throw new Error('attach and detach must not both be empty')
1013
+ const kebab = (n) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(n)
1014
+ for (const n of [...attach, ...detach]) {
1015
+ if (!kebab(n)) throw new Error(`invalid skill name: ${n}`)
1016
+ }
1017
+ const expert = await locateEditable(locateExpert, body.name)
1018
+ const pluginJson = JSON.parse(await fsP.readFile(expert.pluginJsonPath, 'utf8'))
1019
+ // detach 先验后删(任一未附 → 整体拒绝,避免半套变更)
1020
+ const detachDirs = []
1021
+ for (const n of detach) {
1022
+ const dir = resolveWithin(expert.dir, `./skills/${n}`)
1023
+ if (dir === undefined) throw new Error(`skill '${n}' is not attached to expert '${expert.name}'`)
1024
+ const st = await fsP.stat(dir).catch(() => undefined)
1025
+ if (st === undefined || !st.isDirectory()) throw new Error(`skill '${n}' is not attached to expert '${expert.name}'`)
1026
+ detachDirs.push({ n, dir })
1027
+ }
1028
+ // attach 全部先在用户技能库解析源目录(任一缺失整体拒绝)
1029
+ const libRoot = join(dshHome(), 'skills')
1030
+ const attachDirs = []
1031
+ for (const n of attach) {
1032
+ const from = join(libRoot, n)
1033
+ const st = await fsP.stat(join(from, 'SKILL.md')).catch(() => undefined)
1034
+ if (st === undefined || !st.isFile()) throw new Error(`skill '${n}' not found in the user skill library (${libRoot})`)
1035
+ attachDirs.push({ n, from })
1036
+ }
1037
+ for (const d of detachDirs) await fsP.rm(d.dir, { recursive: true, force: true })
1038
+ for (const a of attachDirs) {
1039
+ const target = join(expert.dir, 'skills', a.n)
1040
+ await fsP.rm(target, { recursive: true, force: true }) // 同名覆盖 = 技能库更新同步进专家
1041
+ await copyDir(a.from, target)
1042
+ }
1043
+ // plugin.json.skills = 声明同步:原序保留存活项 + 追加新 attach(以 skills/ 目录实况为准)
1044
+ const skillRoot = join(expert.dir, 'skills')
1045
+ const present = new Set()
1046
+ try {
1047
+ for (const ent of await fsP.readdir(skillRoot, { withFileTypes: true })) if (ent.isDirectory()) present.add(ent.name)
1048
+ } catch { /* 无 skills 目录 */ }
1049
+ const oldNames = (Array.isArray(pluginJson.skills) ? pluginJson.skills : []).map((r) => String(r).replace(/^\.\/skills\//, '').replace(/^\.\//, ''))
1050
+ const finalNames = []
1051
+ for (const n of [...oldNames, ...attach]) {
1052
+ if (present.has(n) && !finalNames.includes(n)) finalNames.push(n)
1053
+ }
1054
+ pluginJson.skills = finalNames.map((n) => `./skills/${n}`)
1055
+ await atomicWriteJs(expert.pluginJsonPath, JSON.stringify(pluginJson, null, 2))
1056
+ invalidate()
1057
+ sendJson(res, 200, { ok: true, skills: pluginJson.skills })
1058
+ return
1059
+ }
1060
+
1061
+ // POST /experts-management/api/avatar?name= — 原始图片体(魔数嗅探)
1062
+ if (req.method === 'POST' && apiPath.endsWith('/experts-management/api/avatar')) {
1063
+ const expert = await locateEditable(locateExpert, query.get('name') || '')
1064
+ const imgBody = await readRawBody(req, EDIT_BODY_MAX_BYTES)
1065
+ const img = sniffImage(imgBody)
1066
+ if (img === null) throw new Error('unsupported image (png/jpg/gif/webp only)')
1067
+ const rel = `avatars/expert.${img.ext}`
1068
+ await atomicWriteJs(join(expert.dir, rel), imgBody)
1069
+ const pluginJson = JSON.parse(await fsP.readFile(expert.pluginJsonPath, 'utf8'))
1070
+ pluginJson.avatar = rel
1071
+ await atomicWriteJs(expert.pluginJsonPath, JSON.stringify(pluginJson, null, 2))
1072
+ invalidate()
1073
+ sendJson(res, 200, { ok: true, avatar: rel })
1074
+ return
1075
+ }
1076
+
1077
+ // GET /experts-management/api/available-skills — 技能关联选择器数据源:
1078
+ // 用户技能库(~/.dsh/skills)目录直读。刻意不走 skills 注册表——那会把
1079
+ // 市场货架库存(5900+ 条)漏进来;也不附 bundled/项目级技能。
1080
+ if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/available-skills')) {
1081
+ const libRoot = join(dshHome(), 'skills')
1082
+ const list = []
1083
+ let libEntries = []
1084
+ try { libEntries = await fsP.readdir(libRoot, { withFileTypes: true }) } catch { /* 无技能库 */ }
1085
+ for (const ent of libEntries) {
1086
+ if (!ent.isDirectory() || !isSafeExpertName(ent.name)) continue
1087
+ let content
1088
+ try { content = await fsP.readFile(join(libRoot, ent.name, 'SKILL.md'), 'utf8') } catch { continue }
1089
+ const parsed = parseSkillMd(content)
1090
+ const description = String(parsed.descriptionZh ?? parsed.descriptionEn ?? parsed.description ?? '').slice(0, 200)
1091
+ list.push({ name: ent.name, description })
1092
+ }
1093
+ list.sort((a, b) => a.name.localeCompare(b.name))
1094
+ sendJson(res, 200, { skills: list })
1095
+ return
1096
+ }
1097
+
1098
+ // GET /experts-management/api/share/status — 分享弹窗数据(settings 真实路径)
1099
+ if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/share/status')) {
1100
+ sendJson(res, 200, { settingsFile: join(dshHome(), 'settings.yaml') })
1101
+ return
1102
+ }
1103
+
1104
+ // POST /experts-management/api/share/run {prompt, dir} → 真实 agent 会话执行
1105
+ if (req.method === 'POST' && apiPath.endsWith('/experts-management/api/share/run')) {
1106
+ const body = await readJsonBody(req)
1107
+ if (typeof body.prompt !== 'string' || body.prompt.trim() === '') { sendJson(res, 400, { error: 'body must provide prompt' }); return }
1108
+ if (typeof body.dir !== 'string' || body.dir === '') { sendJson(res, 400, { error: 'body must provide dir' }); return }
1109
+ // 支持 ~ 前缀(client 传的是 displayPath 折叠过的路径)
1110
+ const dir = resolve(String(body.dir).startsWith('~') ? join(homedir(), String(body.dir).slice(2)) : body.dir)
1111
+ const stat = await fsP.stat(dir).catch(() => undefined)
1112
+ if (stat === undefined || !stat.isDirectory()) { sendJson(res, 400, { error: `dir not found: ${displayPath(dir)}` }); return }
1113
+ const binary = process.env.EXPERTS_DSH_BIN || 'dsh'
1114
+ const job = createShareRunJob({ binary, prompt: body.prompt, dir, jobs: shareRunJobs, logger: ctx.logger, services: shareServices })
1115
+ sendJson(res, 202, { jobId: job.id, status: job.status })
1116
+ return
1117
+ }
1118
+
1119
+ // GET /experts-management/api/share/run?id= → 任务状态/输出
1120
+ if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/share/run')) {
1121
+ const id = query.get('id') || ''
1122
+ const job = shareRunJobs.get(id)
1123
+ if (job === undefined) { sendJson(res, 404, { error: 'job not found' }); return }
1124
+ sendJson(res, 200, { ...job, output: job.output.slice(-32 * 1024) })
1125
+ return
1126
+ }
1127
+
1128
+ // GET /experts-management/api/builtin/status
1129
+ if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/builtin/status')) {
1130
+ await builtinStateLoaded
1131
+ const eff = builtinSettings()
1132
+ const repoDir = builtinRootDir()
891
1133
  const repoExists = await fsP.access(join(repoDir, '.git')).then(() => true).catch(() => false)
892
1134
  const ok = await gitAvailable(eff.gitBinary)
893
1135
  const [localCommit, remoteCommit] = repoExists && ok
@@ -898,28 +1140,28 @@ module.exports = {
898
1140
  gitAvailable: ok, repoExists,
899
1141
  localCommit, remoteCommit,
900
1142
  needsUpdate: localCommit !== undefined && remoteCommit !== undefined ? localCommit !== remoteCommit : undefined,
901
- lastSyncAt: marketState.lastSyncAt, lastResult: marketState.lastResult,
1143
+ lastSyncAt: builtinState.lastSyncAt, lastResult: builtinState.lastResult,
902
1144
  autoSync: eff.autoSync, syncOnStartup: eff.syncOnStartup,
903
1145
  hasToken: typeof eff.token === 'string' && eff.token !== '',
904
- syncing: marketSyncRun !== null,
905
- sparsePaths: marketSparsePaths() ?? null,
1146
+ syncing: builtinSyncRun !== null,
1147
+ sparsePaths: builtinSparsePaths() ?? null,
906
1148
  })
907
1149
  return
908
1150
  }
909
1151
 
910
- // POST /experts-management/api/market/sync
911
- if (req.method === 'POST' && apiPath.endsWith('/experts-management/api/market/sync')) {
1152
+ // POST /experts-management/api/builtin/sync
1153
+ if (req.method === 'POST' && apiPath.endsWith('/experts-management/api/builtin/sync')) {
912
1154
  try {
913
- const result = await runMarketSync()
1155
+ const result = await runBuiltinSync()
914
1156
  sendJson(res, 200, result)
915
1157
  } catch (e) { sendJson(res, 400, { error: String(e && e.message || e) }) }
916
1158
  return
917
1159
  }
918
1160
 
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')) {
1161
+ // PUT /experts-management/api/builtin/settings {url?, branch?, repoDir?, token?, autoSync?, syncOnStartup?}
1162
+ if (req.method === 'PUT' && apiPath.endsWith('/experts-management/api/builtin/settings')) {
921
1163
  const body = await readJsonBody(req)
922
- await marketStateLoaded
1164
+ await builtinStateLoaded
923
1165
  const patch = {}
924
1166
  for (const key of ['url', 'branch', 'gitBinary']) {
925
1167
  if (typeof body[key] === 'string' && body[key] !== '') patch[key] = body[key]
@@ -935,7 +1177,7 @@ module.exports = {
935
1177
  } else {
936
1178
  Object.assign(settingsOverrides, patch)
937
1179
  }
938
- const eff = marketSettings()
1180
+ const eff = builtinSettings()
939
1181
  const { token, ...safe } = eff // token 只写不回读
940
1182
  sendJson(res, 200, { settings: safe, hasToken: typeof token === 'string' && token !== '' })
941
1183
  return