dsh-plugin-tool-management 0.6.0 → 0.7.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/CHANGELOG.md +131 -0
- package/README.md +106 -221
- package/README_EN.md +105 -220
- package/docs/images/1/345/234/272/346/231/257.png +0 -0
- package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
- package/docs/images/2MCP.png +0 -0
- package/docs/images/2MCP_en.png +0 -0
- package/docs/images/3/346/212/200/350/203/275.png +0 -0
- package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
- package/docs/images/6/350/256/260/345/277/206.png +0 -0
- package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
- package/docs/images/7/344/274/232/350/257/235.png +0 -0
- package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
- package/docs/images/8/345/205/274/345/256/271.png +0 -0
- package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
- package/docs/update.md +39 -38
- package/lib/agents-md/service.js +43 -2
- package/lib/client.js +708 -140
- package/lib/compat/preset-reach.js +425 -0
- package/lib/http-fence.js +21 -0
- package/lib/hub.js +160 -2
- package/lib/index.js +256 -39
- package/lib/mcp/override-blocks.js +195 -0
- package/lib/rules/service.js +78 -1
- package/lib/skills/core.js +11 -0
- package/lib/skills/service.js +3 -0
- package/lib/subagents/service.js +197 -23
- package/lib/subagents/tools.js +8 -2
- package/package.json +3 -2
- package/screenshots.json +10 -10
- package/docs/Changelog.md +0 -144
package/lib/rules/service.js
CHANGED
|
@@ -34,6 +34,7 @@ import { expandUploads, planMemoryImport } from '../imports/upload.js';
|
|
|
34
34
|
import { createRuleProviderRegistrar } from './provider.js';
|
|
35
35
|
import { normalizePresetId } from '../agents-md/preset-id.js';
|
|
36
36
|
import { normalizeArchive, memoryAllowed } from './archive.js';
|
|
37
|
+
import { listTrashEntries, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
|
|
37
38
|
// ── 常量 ───────────────────────────────────────────────────────────────────
|
|
38
39
|
const MAX_SOURCE_DEPTH = 64; // 与 core.js 一致
|
|
39
40
|
const MAX_DIRECTORIES = 2000; // 目录预算
|
|
@@ -2047,9 +2048,16 @@ export function createRulesService(ctx, deps) {
|
|
|
2047
2048
|
const parts = parseId(id);
|
|
2048
2049
|
if (!parts)
|
|
2049
2050
|
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
2051
|
+
// `enabled` 必须显式给。缺省时旧实现回落到"沿用当前值",却照样刷新
|
|
2052
|
+
// updatedAt 并返回 ok:true —— 调用方按 toggle(翻转)理解时会以为自己改了
|
|
2053
|
+
// 状态,实际什么都没改(界面不受影响:它一直显式传值)。与 rulesSetActive
|
|
2054
|
+
// 同一条口径:参数缺失就明确拒绝,不猜。
|
|
2055
|
+
if (typeof (args && args.enabled) !== 'boolean') {
|
|
2056
|
+
return fail('error.rules.invalidArgs', '缺少参数:enabled 必须是布尔值(toggle 只按传入值写入,不做"翻转"推断)');
|
|
2057
|
+
}
|
|
2050
2058
|
const index = await readIndex(stateDir);
|
|
2051
2059
|
const idxEntry = index.rules[id] || {};
|
|
2052
|
-
const enabled = args
|
|
2060
|
+
const enabled = args.enabled === true;
|
|
2053
2061
|
index.rules[id] = { ...idxEntry, enabled, updatedAt: new Date().toISOString() };
|
|
2054
2062
|
await writeIndex(stateDir, index);
|
|
2055
2063
|
invalidateSnapshot();
|
|
@@ -2252,6 +2260,15 @@ export function createRulesService(ctx, deps) {
|
|
|
2252
2260
|
if (index.mode?.scene === name) {
|
|
2253
2261
|
return fail('error.rules.sceneInMode', `场景「${name}」正处在当前模式,请先退出模式再删除`);
|
|
2254
2262
|
}
|
|
2263
|
+
// 场景本体(记录 + 档案)**先移入回收站**,再从索引里摘掉——这样「删除场景」
|
|
2264
|
+
// 是可恢复的。记忆目录必须是空的(上面已拦),所以没有正文需要搬。
|
|
2265
|
+
const trashed = await moveToTrash('scenes', name, [], {
|
|
2266
|
+
record: (index.scenes && index.scenes[name]) || null,
|
|
2267
|
+
archive: (index.archives && index.archives[name]) || null,
|
|
2268
|
+
});
|
|
2269
|
+
if (trashed.ok === false) {
|
|
2270
|
+
return fail('error.rules.ioFailed', `移入回收站失败:${trashed.error}`);
|
|
2271
|
+
}
|
|
2255
2272
|
try {
|
|
2256
2273
|
// fs.rm 删目录必须 recursive(即使已确认它是空的),否则报 EISDIR。
|
|
2257
2274
|
await rm(join(rulesRoot, name), { recursive: true, force: true });
|
|
@@ -2277,8 +2294,63 @@ export function createRulesService(ctx, deps) {
|
|
|
2277
2294
|
await writeIndex(stateDir, index);
|
|
2278
2295
|
invalidateSnapshot();
|
|
2279
2296
|
invalidateProviders();
|
|
2297
|
+
return { ok: true, name, trashId: trashed.id };
|
|
2298
|
+
}
|
|
2299
|
+
/** 场景回收站列表(只读):`<hub>/trash/scenes-trash/<id>/manifest.json`。 */
|
|
2300
|
+
async function sceneTrashList() {
|
|
2301
|
+
return { ok: true, trash: await listTrashEntries('scenes') };
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* 从回收站恢复一个场景:重建记忆目录 + 写回记录与档案。
|
|
2305
|
+
* 同名场景已存在时**拒绝**(绝不覆盖既有场景)。
|
|
2306
|
+
*/
|
|
2307
|
+
async function sceneTrashRestore(args) {
|
|
2308
|
+
const id = String((args && args.id) || '').trim();
|
|
2309
|
+
const entry = await readTrashEntry('scenes', id);
|
|
2310
|
+
if (!entry)
|
|
2311
|
+
return fail('error.rules.notFound', `回收站条目不存在:${id}`);
|
|
2312
|
+
const name = String(entry.name || '').trim();
|
|
2313
|
+
if (!isValidGroupSegment(name))
|
|
2314
|
+
return fail('error.rules.invalidGroup', `回收站里的场景名非法:${name || '(空)'}`);
|
|
2315
|
+
const index = await readIndex(stateDir);
|
|
2316
|
+
if (index.scenes?.[name])
|
|
2317
|
+
return fail('error.rules.invalidGroup', `无法恢复,同名场景已存在:${name}`);
|
|
2318
|
+
const dir = join(rulesRoot, name);
|
|
2319
|
+
let entries = [];
|
|
2320
|
+
try {
|
|
2321
|
+
entries = await readdir(dir);
|
|
2322
|
+
}
|
|
2323
|
+
catch {
|
|
2324
|
+
entries = [];
|
|
2325
|
+
}
|
|
2326
|
+
if (entries.filter((n) => !n.startsWith('.')).length > 0) {
|
|
2327
|
+
return fail('error.rules.sceneNotEmpty', `无法恢复,记忆目录「${name}」里已有内容,请先处理`);
|
|
2328
|
+
}
|
|
2329
|
+
const data = (entry.data || {});
|
|
2330
|
+
try {
|
|
2331
|
+
await mkdir(dir, { recursive: true });
|
|
2332
|
+
}
|
|
2333
|
+
catch (e) {
|
|
2334
|
+
return fail('error.rules.ioFailed', `重建场景目录失败:${message(e)}`);
|
|
2335
|
+
}
|
|
2336
|
+
if (data.record && typeof data.record === 'object')
|
|
2337
|
+
index.scenes = { ...(index.scenes || {}), [name]: data.record };
|
|
2338
|
+
if (data.archive && typeof data.archive === 'object')
|
|
2339
|
+
index.archives = { ...(index.archives || {}), [name]: data.archive };
|
|
2340
|
+
await writeIndex(stateDir, index);
|
|
2341
|
+
await purgeTrashEntry('scenes', id);
|
|
2342
|
+
invalidateSnapshot();
|
|
2343
|
+
invalidateProviders();
|
|
2280
2344
|
return { ok: true, name };
|
|
2281
2345
|
}
|
|
2346
|
+
/** 永久删除一条场景回收站条目。 */
|
|
2347
|
+
async function sceneTrashDelete(args) {
|
|
2348
|
+
const id = String((args && args.id) || '').trim();
|
|
2349
|
+
const gone = await purgeTrashEntry('scenes', id);
|
|
2350
|
+
if (!gone)
|
|
2351
|
+
return fail('error.rules.notFound', `回收站条目不存在:${id}`);
|
|
2352
|
+
return { ok: true, id };
|
|
2353
|
+
}
|
|
2282
2354
|
/**
|
|
2283
2355
|
* 提示词预设改名后**同步场景绑定**:把所有 `scenes[].prompt === from` 改成 `to`。
|
|
2284
2356
|
* 由 AGENTS.md 预设库的改名路径调用——绑定存在 `rules-index.json` 里,预设库
|
|
@@ -2365,6 +2437,8 @@ export function createRulesService(ctx, deps) {
|
|
|
2365
2437
|
'rules-create', 'rules-update', 'rules-remove', 'rules-restore', 'rules-toggle', 'rules-import',
|
|
2366
2438
|
'rules-set-index', 'rules-set-active', 'rules-create-scene', 'rules-update-scene', 'rules-remove-scene', 'rules-rebind-prompt',
|
|
2367
2439
|
'rules-attach', 'rules-detach', 'rules-trash-remove',
|
|
2440
|
+
// 场景回收站(与记忆回收站 rules-trash-* 分开:那套管记忆正文,这套管场景记录与档案)
|
|
2441
|
+
'scene-trash-restore', 'scene-trash-delete',
|
|
2368
2442
|
]);
|
|
2369
2443
|
const ops = {
|
|
2370
2444
|
'rules-list': (args) => rulesList(args || {}),
|
|
@@ -2386,6 +2460,9 @@ export function createRulesService(ctx, deps) {
|
|
|
2386
2460
|
'rules-create-scene': (args) => runWrite(() => rulesCreateScene(args || {})),
|
|
2387
2461
|
'rules-update-scene': (args) => runWrite(() => rulesUpdateScene(args || {})),
|
|
2388
2462
|
'rules-remove-scene': (args) => runWrite(() => rulesRemoveScene(args || {})),
|
|
2463
|
+
'scene-trash-list': () => sceneTrashList(),
|
|
2464
|
+
'scene-trash-restore': (args) => runWrite(() => sceneTrashRestore(args || {})),
|
|
2465
|
+
'scene-trash-delete': (args) => runWrite(() => sceneTrashDelete(args || {})),
|
|
2389
2466
|
'rules-rebind-prompt': (args) => runWrite(() => rulesRebindPrompt(args || {})),
|
|
2390
2467
|
};
|
|
2391
2468
|
const service = {
|
package/lib/skills/core.js
CHANGED
|
@@ -178,6 +178,9 @@ export function customRootsFromState(stateValue) {
|
|
|
178
178
|
label: typeof item.label === "string" && item.label.trim()
|
|
179
179
|
? item.label.trim().slice(0, 64)
|
|
180
180
|
: "自定义目录",
|
|
181
|
+
// 用户加进来的来源(相对「按约定发现」的 dsh / hub / agents / codex / claude):
|
|
182
|
+
// 界面据此决定要不要给「永久删除」——只有自定义来源能真正从插件里删掉记录。
|
|
183
|
+
custom: true,
|
|
181
184
|
mutable: false,
|
|
182
185
|
toggleable: true,
|
|
183
186
|
native: false,
|
|
@@ -280,6 +283,10 @@ export async function removeCustomRoot(key, log) {
|
|
|
280
283
|
delete current.state.sources[clean];
|
|
281
284
|
delete current.state.disabledSkills[clean];
|
|
282
285
|
delete current.state.enabledSkills[clean];
|
|
286
|
+
// 已移除记录一起清掉:否则「永久删除」之后状态文件里还留着一条指向不存在来源的脏键
|
|
287
|
+
// (读取时的 normalize 会丢弃它,但没理由写进去)。
|
|
288
|
+
if (Array.isArray(current.state.removedSources))
|
|
289
|
+
current.state.removedSources = current.state.removedSources.filter((key) => key !== clean);
|
|
283
290
|
await writeManagerState(current.state);
|
|
284
291
|
if (log)
|
|
285
292
|
log("custom-remove", `移除自定义技能目录 ${target.path} (${clean})`);
|
|
@@ -2904,6 +2911,8 @@ export async function state(options = {}) {
|
|
|
2904
2911
|
mutable: root.mutable,
|
|
2905
2912
|
deletable: root.deletable === true,
|
|
2906
2913
|
removable: !isDefaultSkillSource(root) && root.scope !== "project",
|
|
2914
|
+
// 自定义来源(用户手加的目录):只有它能「永久删除」——按约定发现的来源删不掉。
|
|
2915
|
+
custom: root.custom === true,
|
|
2907
2916
|
// 默认来源(dsh / hub):界面据此隐藏来源开关、只显示「可管理」标记。
|
|
2908
2917
|
defaultSource: isDefaultSkillSource(root),
|
|
2909
2918
|
toggleable: root.toggleable,
|
|
@@ -2983,6 +2992,8 @@ export async function state(options = {}) {
|
|
|
2983
2992
|
truncated: truncated === true,
|
|
2984
2993
|
// 界面用:能否从管理器「移除」(不再读取)。dsh / hub / 项目级不可移除。
|
|
2985
2994
|
removable: !isDefaultSkillSource(root) && root.scope !== "project",
|
|
2995
|
+
// 自定义来源(用户手加的目录):只有它能「永久删除」——按约定发现的来源删不掉。
|
|
2996
|
+
custom: root.custom === true,
|
|
2986
2997
|
// 默认来源(dsh / hub):界面据此隐藏来源开关(它们不能停用),换成「可管理」标记。
|
|
2987
2998
|
defaultSource: isDefaultSkillSource(root),
|
|
2988
2999
|
removed: false,
|
package/lib/skills/service.js
CHANGED
|
@@ -397,6 +397,9 @@ export function createSkillsService(ctx) {
|
|
|
397
397
|
// 写操作清单:与下方 ops 表同文件同源维护;HTTP 端门禁由 index.ts 从本集合派生,勿在宿主端另抄一份。
|
|
398
398
|
const writeOps = new Set([
|
|
399
399
|
'skill-enable', 'skill-disable', 'skill-source-enable', 'skill-source-disable',
|
|
400
|
+
// 移除/恢复来源会改写本地来源状态(listProviderCandidates 随之变化,模型侧的技能目录
|
|
401
|
+
// 也变),是写操作;漏在这份清单里 = 配了访问令牌也不会被要求带令牌。
|
|
402
|
+
'skill-source-remove', 'skill-source-restore',
|
|
400
403
|
'skill-prefer', 'skill-unprefer',
|
|
401
404
|
'skill-create', 'skill-import', 'skill-upload', 'skill-delete',
|
|
402
405
|
'skill-trash-restore', 'skill-trash-delete', 'skill-custom-add', 'skill-custom-remove',
|
package/lib/subagents/service.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// v0.4 目录变更:人设由 `$DSH_HOME/subagents/` 搬到 `$DSH_HOME/tool-management/agents/`
|
|
6
6
|
// (插件产生的文件统一收在 tool-management/ 下)。旧目录在首次扫描时搬入,见 relocateLegacyPersonas。
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
|
-
import { mkdir, readdir, readFile, rename,
|
|
8
|
+
import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { resolveDshHome } from '../skills/core.js';
|
|
11
|
+
import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
|
|
11
12
|
import { expandUploads, planPersonaImport } from '../imports/upload.js';
|
|
12
13
|
const message = (e) => String((e && e.message) || e);
|
|
13
14
|
/** 人设默认目录:`$DSH_HOME/tool-management/agents/`。 */
|
|
@@ -50,25 +51,66 @@ async function relocateLegacyPersonas(dir) {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
|
-
/**
|
|
54
|
+
/** 逗号/顿号分隔的名字清单(空串 → undefined)。 */
|
|
55
|
+
function listOf(value) {
|
|
56
|
+
const text = String(value ?? '').trim();
|
|
57
|
+
if (!text)
|
|
58
|
+
return undefined;
|
|
59
|
+
const list = text.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
|
60
|
+
return list.length ? list : undefined;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 解析 `toolsByPreset:` 块 —— 每个 Agent 预设一行:
|
|
64
|
+
*
|
|
65
|
+
* ```yaml
|
|
66
|
+
* toolsByPreset:
|
|
67
|
+
* standard: allow: read, write
|
|
68
|
+
* ptc: deny: workflow
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* 缩进即"在块内",回到顶格即块结束(frontmatter 只有这一处嵌套,所以不需要完整
|
|
72
|
+
* YAML 解析器)。模式 id 只做形状校验,认不认得由运行时按当前预设名单判断。
|
|
73
|
+
*/
|
|
74
|
+
export function parsePresetToolRules(frontmatter) {
|
|
75
|
+
const rules = {};
|
|
76
|
+
let inBlock = false;
|
|
77
|
+
for (const raw of frontmatter.split(/\r?\n/)) {
|
|
78
|
+
const line = raw.replace(/\s+#.*$/, '');
|
|
79
|
+
if (!inBlock) {
|
|
80
|
+
if (/^toolsbypreset\s*:\s*$/i.test(line.trim()))
|
|
81
|
+
inBlock = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (line.trim() === '')
|
|
85
|
+
continue;
|
|
86
|
+
if (!/^\s/.test(line))
|
|
87
|
+
break;
|
|
88
|
+
const m = /^\s*([A-Za-z0-9._-]+)\s*:\s*(allow|deny)\s*:\s*(.*)$/i.exec(line);
|
|
89
|
+
if (!m)
|
|
90
|
+
continue;
|
|
91
|
+
const names = listOf(m[3]);
|
|
92
|
+
// 空名单 = 不限制 = 不落盘,读到时也直接忽略,免得把"没开"读成"开了但空"。
|
|
93
|
+
if (!names)
|
|
94
|
+
continue;
|
|
95
|
+
rules[m[1]] = { mode: m[2].toLowerCase() === 'deny' ? 'deny' : 'allow', names };
|
|
96
|
+
}
|
|
97
|
+
return Object.keys(rules).length ? rules : undefined;
|
|
98
|
+
}
|
|
99
|
+
/** 行式 frontmatter 解析:只认 description / provider / model / tools / toolsDeny / toolsByPreset。 */
|
|
54
100
|
export function parsePersona(raw, fallbackName) {
|
|
55
101
|
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
|
|
56
102
|
const data = {};
|
|
57
103
|
let body = raw;
|
|
104
|
+
let toolsByPreset;
|
|
58
105
|
if (m) {
|
|
59
106
|
for (const line of m[1].split(/\r?\n/)) {
|
|
60
107
|
const kv = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(line.trim());
|
|
61
108
|
if (kv)
|
|
62
109
|
data[kv[1].toLowerCase()] = kv[2].trim();
|
|
63
110
|
}
|
|
111
|
+
toolsByPreset = parsePresetToolRules(m[1]);
|
|
64
112
|
body = raw.slice(m[0].length);
|
|
65
113
|
}
|
|
66
|
-
const listOf = (value) => {
|
|
67
|
-
if (!value)
|
|
68
|
-
return undefined;
|
|
69
|
-
const list = value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
|
70
|
-
return list.length ? list : undefined;
|
|
71
|
-
};
|
|
72
114
|
const tools = listOf(data.tools);
|
|
73
115
|
// 兼容两种写法:`toolsdeny: a, b`(线上格式)与 `toolsDeny: a, b`(键名统一小写后同形)。
|
|
74
116
|
const toolsDeny = listOf(data.toolsdeny);
|
|
@@ -80,6 +122,7 @@ export function parsePersona(raw, fallbackName) {
|
|
|
80
122
|
model: data.model || undefined,
|
|
81
123
|
tools,
|
|
82
124
|
toolsDeny,
|
|
125
|
+
...(toolsByPreset === undefined ? {} : { toolsByPreset }),
|
|
83
126
|
body: body.trim(),
|
|
84
127
|
path: '',
|
|
85
128
|
};
|
|
@@ -155,8 +198,14 @@ export function createSubagentService(ctx, opts) {
|
|
|
155
198
|
throw new Error('spawn provider 挂载后仍未出现在 ctx.subagents.list()(宿主版本不匹配?)');
|
|
156
199
|
return runtime;
|
|
157
200
|
}
|
|
158
|
-
async function runOnce(parentAgent, p, task, signal) {
|
|
201
|
+
async function runOnce(parentAgent, p, task, signal, toolFilter) {
|
|
159
202
|
const runtime = await ensureSpawnProvider();
|
|
203
|
+
// 工具限制三态:对象 = 调用方已按当前预设决定;null = 调用方明确决定不加限制;
|
|
204
|
+
// undefined = 调用方没决定(如别的入口直接调 runSerial)→ 回落人设里的旧格式全局名单。
|
|
205
|
+
const legacyFilter = (p.tools?.length || p.toolsDeny?.length)
|
|
206
|
+
? { ...(p.tools?.length ? { allow: p.tools } : {}), ...(p.toolsDeny?.length ? { deny: p.toolsDeny } : {}) }
|
|
207
|
+
: null;
|
|
208
|
+
const resolved = toolFilter === undefined ? legacyFilter : toolFilter;
|
|
160
209
|
// 官方签名:start(name, request) —— name = ctx.subagents 上的 provider 注册名。
|
|
161
210
|
const run = await runtime.start('spawn', {
|
|
162
211
|
label: p.name,
|
|
@@ -166,10 +215,8 @@ export function createSubagentService(ctx, opts) {
|
|
|
166
215
|
prompt: [{ type: 'text', text: task }],
|
|
167
216
|
persona: p.body,
|
|
168
217
|
// 工具白/黑名单 → 官方 ToolRestriction(`deny` 优先级高于 `allow`;未知名官方会直接拒绝启动,
|
|
169
|
-
//
|
|
170
|
-
...(
|
|
171
|
-
? { toolFilter: { ...(p.tools?.length ? { allow: p.tools } : {}), ...(p.toolsDeny?.length ? { deny: p.toolsDeny } : {}) } }
|
|
172
|
-
: {}),
|
|
218
|
+
// 所以名单由 index.ts 按当前预设 + 当前真实存在的工具名算好再传进来,见 decideToolFilter)。
|
|
219
|
+
...(resolved === null ? {} : { toolFilter: resolved }),
|
|
173
220
|
maxDepth: 1,
|
|
174
221
|
// provider 与 model 是模型路由的两半:DSH 的 resolveModel(provider, model) 不做
|
|
175
222
|
// `provider/model` 字符串拆分,只改 model 会落在**主会话的 provider** 上——跨来源
|
|
@@ -197,8 +244,8 @@ export function createSubagentService(ctx, opts) {
|
|
|
197
244
|
}
|
|
198
245
|
// v1 串行:同一时刻至多一个子代理运行(设计 §3.2)。
|
|
199
246
|
let chain = Promise.resolve();
|
|
200
|
-
const runSerial = (parentAgent, p, task, signal) => {
|
|
201
|
-
const queued = chain.then(() => runOnce(parentAgent, p, task, signal), () => runOnce(parentAgent, p, task, signal));
|
|
247
|
+
const runSerial = (parentAgent, p, task, signal, toolFilter) => {
|
|
248
|
+
const queued = chain.then(() => runOnce(parentAgent, p, task, signal, toolFilter), () => runOnce(parentAgent, p, task, signal, toolFilter));
|
|
202
249
|
chain = queued.catch(() => undefined);
|
|
203
250
|
return queued;
|
|
204
251
|
};
|
|
@@ -207,7 +254,7 @@ export function createSubagentService(ctx, opts) {
|
|
|
207
254
|
const docs = await list();
|
|
208
255
|
return {
|
|
209
256
|
ok: true,
|
|
210
|
-
subagents: docs.map((p) => ({ name: p.name, description: p.description, provider: p.provider ?? null, model: p.model ?? null, tools: p.tools ?? null, toolsDeny: p.toolsDeny ?? null })),
|
|
257
|
+
subagents: docs.map((p) => ({ name: p.name, description: p.description, provider: p.provider ?? null, model: p.model ?? null, tools: p.tools ?? null, toolsDeny: p.toolsDeny ?? null, toolsByPreset: p.toolsByPreset ?? null })),
|
|
211
258
|
};
|
|
212
259
|
},
|
|
213
260
|
'subagent-get': async (args) => {
|
|
@@ -216,7 +263,7 @@ export function createSubagentService(ctx, opts) {
|
|
|
216
263
|
const p = docs.find((d) => d.name === name);
|
|
217
264
|
if (!p)
|
|
218
265
|
return { ok: false, error: `人设不存在: ${name}` };
|
|
219
|
-
return { ok: true, persona: { name: p.name, description: p.description, provider: p.provider ?? '', model: p.model ?? '', tools: p.tools ?? [], toolsDeny: p.toolsDeny ?? [], body: p.body } };
|
|
266
|
+
return { ok: true, persona: { name: p.name, description: p.description, provider: p.provider ?? '', model: p.model ?? '', tools: p.tools ?? [], toolsDeny: p.toolsDeny ?? [], toolsByPreset: p.toolsByPreset ?? {}, body: p.body } };
|
|
220
267
|
},
|
|
221
268
|
'subagent-create': async (args) => {
|
|
222
269
|
const name = String((args && args.name) || '').trim();
|
|
@@ -250,13 +297,55 @@ export function createSubagentService(ctx, opts) {
|
|
|
250
297
|
cache = null;
|
|
251
298
|
return { ok: true, name };
|
|
252
299
|
},
|
|
300
|
+
/**
|
|
301
|
+
* 删除人设 = **移入回收站**(`hub/trash/agents-trash/<id>/persona.md`)。
|
|
302
|
+
* 早先这里是 `rm`,而界面按钮写着「移入回收站」——文案与行为不一致;现在行为追上文案,
|
|
303
|
+
* 误删可以从子智能体页的回收站里恢复。
|
|
304
|
+
*/
|
|
253
305
|
'subagent-delete': async (args) => {
|
|
254
306
|
const name = String((args && args.name) || '').trim();
|
|
255
307
|
if (!validPersonaName(name))
|
|
256
308
|
return { ok: false, error: `人设名不合法: ${name || '(空)'}` };
|
|
257
|
-
|
|
309
|
+
const target = join(dir, name + '.md');
|
|
310
|
+
const exists = await readFile(target, 'utf8').then(() => true).catch(() => false);
|
|
311
|
+
if (!exists)
|
|
312
|
+
return { ok: false, error: `人设不存在: ${name}` };
|
|
313
|
+
const moved = await moveToTrash('agents', name, [{ from: target, dest: 'persona.md' }]);
|
|
314
|
+
if (moved.ok === false)
|
|
315
|
+
return { ok: false, error: `移入回收站失败: ${moved.error}` };
|
|
258
316
|
cache = null;
|
|
259
|
-
return { ok: true, name };
|
|
317
|
+
return { ok: true, name, trashId: moved.id };
|
|
318
|
+
},
|
|
319
|
+
'subagent-trash-list': async () => ({ ok: true, trash: await listTrashEntries('agents') }),
|
|
320
|
+
'subagent-trash-restore': async (args) => {
|
|
321
|
+
const id = String((args && args.id) || '').trim();
|
|
322
|
+
const entry = await readTrashEntry('agents', id);
|
|
323
|
+
if (!entry)
|
|
324
|
+
return { ok: false, error: `回收站条目不存在: ${id}` };
|
|
325
|
+
if (!validPersonaName(entry.name))
|
|
326
|
+
return { ok: false, error: `回收站里的人设名不合法: ${entry.name}` };
|
|
327
|
+
const target = join(dir, entry.name + '.md');
|
|
328
|
+
const exists = await readFile(target, 'utf8').then(() => true).catch(() => false);
|
|
329
|
+
// 绝不覆盖:同名人设已经存在时如实拒绝,让人自己决定怎么办。
|
|
330
|
+
if (exists)
|
|
331
|
+
return { ok: false, error: `无法恢复,同名人设已存在: ${entry.name}` };
|
|
332
|
+
try {
|
|
333
|
+
await mkdir(dir, { recursive: true });
|
|
334
|
+
await moveOutOfTrash('agents', id, 'persona.md', target);
|
|
335
|
+
}
|
|
336
|
+
catch (e) {
|
|
337
|
+
return { ok: false, error: `恢复失败: ${message(e)}` };
|
|
338
|
+
}
|
|
339
|
+
await purgeTrashEntry('agents', id);
|
|
340
|
+
cache = null;
|
|
341
|
+
return { ok: true, name: entry.name };
|
|
342
|
+
},
|
|
343
|
+
'subagent-trash-delete': async (args) => {
|
|
344
|
+
const id = String((args && args.id) || '').trim();
|
|
345
|
+
const gone = await purgeTrashEntry('agents', id);
|
|
346
|
+
if (!gone)
|
|
347
|
+
return { ok: false, error: `回收站条目不存在: ${id}` };
|
|
348
|
+
return { ok: true, id };
|
|
260
349
|
},
|
|
261
350
|
/**
|
|
262
351
|
* 导入人设(.md / .zip):一个 .md 一个人设,文件名(去扩展名)= 人设名;
|
|
@@ -302,20 +391,46 @@ export function createSubagentService(ctx, opts) {
|
|
|
302
391
|
return { ok: true, imported, skipped };
|
|
303
392
|
},
|
|
304
393
|
};
|
|
305
|
-
return { list, runSerial, ops, writeOps: new Set(['subagent-create', 'subagent-update', 'subagent-delete', 'subagent-import']) };
|
|
394
|
+
return { list, runSerial, ops, writeOps: new Set(['subagent-create', 'subagent-update', 'subagent-delete', 'subagent-import', 'subagent-trash-restore', 'subagent-trash-delete']) };
|
|
306
395
|
}
|
|
307
396
|
function validPersonaName(name) {
|
|
308
397
|
return name.length > 0 && name.length <= 64 && !name.startsWith('.') && !/[\\/<>:"|?*]/.test(name);
|
|
309
398
|
}
|
|
399
|
+
/** 字符串数组规范化(非数组/空项都丢掉)。 */
|
|
400
|
+
function toStringList(value) {
|
|
401
|
+
return Array.isArray(value) ? value.map((x) => String(x).trim()).filter(Boolean) : [];
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* 规范化前端传来的 `toolsByPreset`:丢掉空名单(空 = 不限制 = 不落盘),
|
|
405
|
+
* 模式 id 只收形状合法的(预设 id 允许字母数字点横线)。
|
|
406
|
+
*/
|
|
407
|
+
export function presetRulesOf(value) {
|
|
408
|
+
if (value === null || typeof value !== 'object')
|
|
409
|
+
return [];
|
|
410
|
+
const out = [];
|
|
411
|
+
for (const [rawId, rawRule] of Object.entries(value)) {
|
|
412
|
+
const id = String(rawId).trim();
|
|
413
|
+
if (!id || !/^[A-Za-z0-9._-]+$/.test(id))
|
|
414
|
+
continue;
|
|
415
|
+
if (rawRule === null || typeof rawRule !== 'object')
|
|
416
|
+
continue;
|
|
417
|
+
const rec = rawRule;
|
|
418
|
+
const names = toStringList(rec.names);
|
|
419
|
+
if (!names.length)
|
|
420
|
+
continue;
|
|
421
|
+
out.push([id, { mode: String(rec.mode) === 'deny' ? 'deny' : 'allow', names }]);
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
310
425
|
/** 人设文件序列化:frontmatter 只写用户填过的键;正文 = 人设提示词。 */
|
|
311
426
|
export function serializePersona(args) {
|
|
312
427
|
const description = String((args && args.description) || '').replace(/\r?\n/g, ' ').trim();
|
|
313
428
|
const provider = String((args && args.provider) || '').trim();
|
|
314
429
|
const model = String((args && args.model) || '').trim();
|
|
315
|
-
const
|
|
316
|
-
const tools = toList(args?.tools);
|
|
430
|
+
const tools = toStringList(args?.tools);
|
|
317
431
|
// 黑名单字段兼容两种入参名:toolsDeny(UI/camel)与 tools_deny(snake)。
|
|
318
|
-
const toolsDeny =
|
|
432
|
+
const toolsDeny = toStringList(args?.toolsDeny ?? args?.tools_deny);
|
|
433
|
+
const byPreset = presetRulesOf(args?.toolsByPreset);
|
|
319
434
|
const body = String((args && args.body) ?? '').trim();
|
|
320
435
|
const lines = ['---'];
|
|
321
436
|
if (description)
|
|
@@ -328,6 +443,65 @@ export function serializePersona(args) {
|
|
|
328
443
|
lines.push('tools: ' + tools.join(', '));
|
|
329
444
|
if (toolsDeny.length)
|
|
330
445
|
lines.push('toolsDeny: ' + toolsDeny.join(', '));
|
|
446
|
+
if (byPreset.length) {
|
|
447
|
+
lines.push('toolsByPreset:');
|
|
448
|
+
for (const [id, rule] of byPreset)
|
|
449
|
+
lines.push(' ' + id + ': ' + rule.mode + ': ' + rule.names.join(', '));
|
|
450
|
+
}
|
|
331
451
|
lines.push('---', '', body, '');
|
|
332
452
|
return lines.join('\n');
|
|
333
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* 按**当前会话的 Agent 预设**决定这次委派下发什么工具限制。
|
|
456
|
+
*
|
|
457
|
+
* 为什么必须按预设分:子代理跑在父会话的预设里(官方 `composeFrom(childCtx, parent.ctx)`),
|
|
458
|
+
* 而各预设的工具集合差别极大(极简模式只有持久 shell),官方 `tools.restrict()` 遇到
|
|
459
|
+
* 名单里不存在的工具名会直接抛错、子代理根本起不来。所以:
|
|
460
|
+
*
|
|
461
|
+
* - 当前预设配了名单(且名单非空)→ 用它;白名单额外并入**当时真实在跑的 MCP 工具**
|
|
462
|
+
* (官方 allow 是"清单之外全砍",不并进来会把 MCP 一起砍掉;用户裁定:子代理要能
|
|
463
|
+
* 用当前启动的 MCP);
|
|
464
|
+
* - 当前预设没配 → 回落旧的全局 `tools` / `toolsDeny`(老文件行为不变);
|
|
465
|
+
* - 名单里有已经消失的工具名 → **丢掉并在 note 里如实说明**(不接受静默失效);
|
|
466
|
+
* - 判断不了当前预设(老宿主 / 异常)→ 不按模式施加,并在 note 里说明。
|
|
467
|
+
*
|
|
468
|
+
* 三态返回:`filter: null` = 明确不加限制;`filter: {...}` = 下发该限制。
|
|
469
|
+
*/
|
|
470
|
+
export function decideToolFilter(persona, presetId, known) {
|
|
471
|
+
const rule = presetId === null ? undefined : persona.toolsByPreset?.[presetId];
|
|
472
|
+
if (rule && rule.names.length) {
|
|
473
|
+
const usable = rule.names.filter((name) => known.names.has(name));
|
|
474
|
+
const dropped = rule.names.filter((name) => !known.names.has(name));
|
|
475
|
+
const droppedNote = dropped.length ? `名单里这些工具当前不存在,已忽略:${dropped.join('、')}` : undefined;
|
|
476
|
+
if (!usable.length) {
|
|
477
|
+
return { filter: null, note: `「${presetId}」的${rule.mode === 'allow' ? '白' : '黑'}名单里没有当前存在的工具,本次不施加工具限制` };
|
|
478
|
+
}
|
|
479
|
+
if (rule.mode === 'allow')
|
|
480
|
+
return { filter: { allow: [...new Set([...usable, ...known.mcp])] }, ...(droppedNote === undefined ? {} : { note: droppedNote }) };
|
|
481
|
+
return { filter: { deny: usable }, ...(droppedNote === undefined ? {} : { note: droppedNote }) };
|
|
482
|
+
}
|
|
483
|
+
// 旧格式(全局名单):保持老行为,白名单同样并入 MCP。
|
|
484
|
+
const legacyAllow = (persona.tools || []).filter((name) => known.names.has(name));
|
|
485
|
+
const legacyDeny = (persona.toolsDeny || []).filter((name) => known.names.has(name));
|
|
486
|
+
const legacyDropped = [...(persona.tools || []), ...(persona.toolsDeny || [])].filter((name) => !known.names.has(name));
|
|
487
|
+
const filter = {
|
|
488
|
+
...(legacyAllow.length ? { allow: [...new Set([...legacyAllow, ...known.mcp])] } : {}),
|
|
489
|
+
...(legacyDeny.length ? { deny: legacyDeny } : {}),
|
|
490
|
+
};
|
|
491
|
+
const notes = [];
|
|
492
|
+
const hasLegacy = Boolean((persona.tools || []).length || (persona.toolsDeny || []).length);
|
|
493
|
+
if (presetId === null) {
|
|
494
|
+
// 说清这一刻到底靠什么在跑:有旧格式就照旧格式,没有就明说"按模式配的限制这次不生效"。
|
|
495
|
+
notes.push(hasLegacy
|
|
496
|
+
? '没能判断当前会话的 Agent 预设,按旧格式的全局名单执行'
|
|
497
|
+
: '没能判断当前会话的 Agent 预设,按模式配的限制这次不生效');
|
|
498
|
+
}
|
|
499
|
+
if (legacyDropped.length)
|
|
500
|
+
notes.push(`名单里这些工具当前不存在,已忽略:${legacyDropped.join('、')}`);
|
|
501
|
+
if (!Object.keys(filter).length) {
|
|
502
|
+
if (presetId === null && !notes.length)
|
|
503
|
+
notes.push('没能判断当前会话的 Agent 预设,本次不施加工具限制');
|
|
504
|
+
return { filter: null, ...(notes.length ? { note: notes.join(';') } : {}) };
|
|
505
|
+
}
|
|
506
|
+
return { filter, ...(notes.length ? { note: notes.join(';') } : {}) };
|
|
507
|
+
}
|
package/lib/subagents/tools.js
CHANGED
|
@@ -54,9 +54,15 @@ export function defineSubagentRunTool(subagents) {
|
|
|
54
54
|
const available = allowed.map((p) => p.name).join('、') || '(无)';
|
|
55
55
|
throw new Error('人设不可用: ' + name + (reason ? '(' + reason + ')' : '(可用: ' + available + ')'));
|
|
56
56
|
}
|
|
57
|
-
|
|
57
|
+
// 按当前会话的 Agent 预设算工具限制:判断不了当前预设、或名单里的工具已经不存在时,
|
|
58
|
+
// 结论里会带一句实话,跟着结果一起返回——不静默改变限制的强度。
|
|
59
|
+
const decision = typeof subagents.toolFilterFor === 'function'
|
|
60
|
+
? await subagents.toolFilterFor(persona, exec.agent && exec.agent.ctx)
|
|
61
|
+
: undefined;
|
|
62
|
+
const r = await subagents.runSerial(exec.agent, persona, task, exec.signal, decision === undefined ? undefined : decision.filter);
|
|
58
63
|
const prefix = r.stopReason && r.stopReason !== 'completed' ? `[stopReason: ${r.stopReason}]\n` : '';
|
|
59
|
-
|
|
64
|
+
const note = decision && decision.note ? `⚠ ${decision.note}\n\n` : '';
|
|
65
|
+
return note + prefix + r.text;
|
|
60
66
|
},
|
|
61
67
|
};
|
|
62
68
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-tool-management",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "DSH plugin: one settings panel for MCP servers, Skills, AGENTS.md presets, archived sessions and scene memories — plus scene memory injected into the system prompt and one-shot persona subagents. Settings UI + HTTP API + 14 model tools (skill_mcp_manager_*, skill_manager_*, agentsmd_*, rule_manager_*, subagent_*). Install with one command: dsh plugin --profile web add dsh-plugin-tool-management@latest",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"cordis.patch.yml",
|
|
21
21
|
"README.md",
|
|
22
22
|
"README_EN.md",
|
|
23
|
+
"CHANGELOG.md",
|
|
23
24
|
"LICENSE",
|
|
24
25
|
"screenshots.json",
|
|
25
26
|
"docs"
|
|
@@ -40,7 +41,7 @@
|
|
|
40
41
|
"check:host": "node scripts/host-deps.mjs && node scripts/doctor.mjs",
|
|
41
42
|
"host-deps": "node scripts/host-deps.mjs --fix",
|
|
42
43
|
"doctor": "node scripts/doctor.mjs",
|
|
43
|
-
"test": "npm run build && npm run check:i18n && node --test test/archive.test.mjs test/import.test.mjs test/approval-policy.test.mjs test/subagent-scene.test.mjs test/subagent-persona.test.mjs test/hub-layout.test.mjs test/skills-state.test.mjs test/skills-delete.test.mjs test/skills-source-remove.test.mjs test/client-exports.test.mjs test/client-render.test.mjs test/compat-probe.test.mjs test/compat-fallback.test.mjs"
|
|
44
|
+
"test": "npm run build && npm run check:i18n && node --test test/archive.test.mjs test/import.test.mjs test/approval-policy.test.mjs test/subagent-scene.test.mjs test/subagent-persona.test.mjs test/hub-layout.test.mjs test/skills-state.test.mjs test/skills-delete.test.mjs test/skills-source-remove.test.mjs test/client-exports.test.mjs test/client-render.test.mjs test/compat-probe.test.mjs test/compat-fallback.test.mjs test/preset-reach.test.mjs test/rules-toggle-args.test.mjs"
|
|
44
45
|
},
|
|
45
46
|
"keywords": [
|
|
46
47
|
"dsh",
|
package/screenshots.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
[
|
|
2
|
-
"docs/images/1场景.png",
|
|
3
|
-
"docs/images/2MCP.png",
|
|
4
|
-
"docs/images/3技能.png",
|
|
5
|
-
"docs/images/4子智能体.png",
|
|
6
|
-
"docs/images/5提示词.png",
|
|
7
|
-
"docs/images/6记忆.png",
|
|
8
|
-
"docs/images/7会话.png",
|
|
9
|
-
"docs/images/8兼容.png"
|
|
10
|
-
]
|
|
1
|
+
[
|
|
2
|
+
"docs/images/1场景.png",
|
|
3
|
+
"docs/images/2MCP.png",
|
|
4
|
+
"docs/images/3技能.png",
|
|
5
|
+
"docs/images/4子智能体.png",
|
|
6
|
+
"docs/images/5提示词.png",
|
|
7
|
+
"docs/images/6记忆.png",
|
|
8
|
+
"docs/images/7会话.png",
|
|
9
|
+
"docs/images/8兼容.png"
|
|
10
|
+
]
|