@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/README.md +17 -9
- package/client/bundle.js +643 -118
- package/client/index.js +540 -118
- package/package.json +3 -2
- package/src/index.js +329 -87
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weibaohui/experts-management",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "dsh 插件 ·
|
|
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
|
-
* -
|
|
9
|
-
* plugin's own dir ($DSH_HOME/experts-management/
|
|
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
|
|
50
|
+
const BUILTIN_SCAN_SKIP = new Set(['.git', 'node_modules'])
|
|
50
51
|
const RANK_INSTALLED = 100
|
|
51
|
-
const
|
|
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 (
|
|
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
|
-
// ──
|
|
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
|
|
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
|
|
493
|
+
const DEFAULT_BUILTIN_SPARSE_PATHS = ['experts']
|
|
493
494
|
|
|
494
|
-
const
|
|
495
|
+
const BUILTIN_SETTINGS_NS = 'experts-management-builtin'
|
|
495
496
|
|
|
496
|
-
function
|
|
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.
|
|
511
|
-
const base = { ...
|
|
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.
|
|
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: '
|
|
601
|
+
{ key: 'builtin', label: '内置', root: join(builtinRootDir(), 'experts'), readOnly: true },
|
|
565
602
|
]
|
|
566
603
|
|
|
567
604
|
// ── Market sync state / settings(skills-management 同款管线)────────
|
|
568
|
-
const
|
|
569
|
-
const eff =
|
|
605
|
+
const builtinRootDir = () => {
|
|
606
|
+
const eff = builtinSettings()
|
|
570
607
|
return resolve(typeof eff.repoDir === 'string' && eff.repoDir !== '' ? eff.repoDir
|
|
571
|
-
: config.
|
|
572
|
-
: join(dshHome(), 'experts-management', '
|
|
608
|
+
: config.builtinRepoDir !== undefined ? resolve(String(config.builtinRepoDir))
|
|
609
|
+
: join(dshHome(), 'experts-management', 'builtin'))
|
|
573
610
|
}
|
|
574
|
-
const
|
|
611
|
+
const builtinSparsePaths = () => config.builtinSparsePaths === null
|
|
575
612
|
? undefined
|
|
576
|
-
: (Array.isArray(config.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|
583
|
-
.then(
|
|
584
|
-
|
|
585
|
-
|
|
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(
|
|
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
|
|
646
|
+
const saveBuiltinState = async () => {
|
|
594
647
|
try {
|
|
595
|
-
await fsP.mkdir(join(
|
|
596
|
-
await atomicWriteJs(
|
|
597
|
-
await fsP.chmod(
|
|
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
|
|
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
|
|
609
|
-
const
|
|
610
|
-
if (
|
|
611
|
-
|
|
612
|
-
await
|
|
613
|
-
const eff =
|
|
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 =
|
|
618
|
-
const result = await gitSyncRepo(eff.gitBinary, eff.url, eff.branch, repoDir, eff.token,
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
await
|
|
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 { ...
|
|
624
|
-
})().finally(() => {
|
|
625
|
-
return
|
|
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 =
|
|
683
|
+
const eff = builtinSettings()
|
|
631
684
|
if (eff.syncOnStartup) {
|
|
632
|
-
|
|
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 =
|
|
688
|
+
const eff2 = builtinSettings()
|
|
636
689
|
if (!eff2.autoSync) return
|
|
637
|
-
const last =
|
|
690
|
+
const last = builtinState.lastSyncAt ? Date.parse(builtinState.lastSyncAt) : 0
|
|
638
691
|
if (Date.now() - last > 24 * 3600 * 1000) {
|
|
639
|
-
|
|
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:
|
|
697
|
+
}, 'experts-management: builtin auto-sync')
|
|
645
698
|
|
|
646
699
|
// ── Discovery ────────────────────────────────────────────────────────
|
|
647
700
|
async function discoverAll() {
|
|
648
|
-
const
|
|
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')
|
|
654
|
-
else
|
|
706
|
+
if (row.key === 'dsh') mine.push(...experts)
|
|
707
|
+
else builtin.push(...experts)
|
|
655
708
|
}
|
|
656
|
-
return {
|
|
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 {
|
|
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
|
|
717
|
-
const
|
|
718
|
-
for (const e of
|
|
719
|
-
if (
|
|
720
|
-
push(e, '
|
|
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,
|
|
831
|
+
// GET /experts-management/api → { sources, mine, builtin }
|
|
771
832
|
if (req.method === 'GET' && apiPath === '/experts-management/api') {
|
|
772
|
-
const {
|
|
773
|
-
const
|
|
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:
|
|
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
|
-
|
|
789
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
const
|
|
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:
|
|
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:
|
|
905
|
-
sparsePaths:
|
|
1146
|
+
syncing: builtinSyncRun !== null,
|
|
1147
|
+
sparsePaths: builtinSparsePaths() ?? null,
|
|
906
1148
|
})
|
|
907
1149
|
return
|
|
908
1150
|
}
|
|
909
1151
|
|
|
910
|
-
// POST /experts-management/api/
|
|
911
|
-
if (req.method === 'POST' && apiPath.endsWith('/experts-management/api/
|
|
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
|
|
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/
|
|
920
|
-
if (req.method === 'PUT' && apiPath.endsWith('/experts-management/api/
|
|
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
|
|
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 =
|
|
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
|