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/index.js
CHANGED
|
@@ -18,10 +18,12 @@ import { createSkillsService, pluginLog } from './skills/service.js';
|
|
|
18
18
|
import { createAgentsMdService } from './agents-md/service.js';
|
|
19
19
|
import { createRulesService } from './rules/service.js';
|
|
20
20
|
import { createArchiveEngine } from './rules/archive-engine.js';
|
|
21
|
-
import { createSubagentService } from './subagents/service.js';
|
|
21
|
+
import { createSubagentService, decideToolFilter } from './subagents/service.js';
|
|
22
22
|
import { isApprovalNever } from './approval-policy.js';
|
|
23
23
|
import { EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, summarize } from './compat/probe.js';
|
|
24
|
-
import {
|
|
24
|
+
import { assessPresetReach, presetRosterOf, reachNoticeForAgent } from './compat/preset-reach.js';
|
|
25
|
+
import { fenceRejection, secretOpRejection } from './http-fence.js';
|
|
26
|
+
import { planOverrideCompaction } from './mcp/override-blocks.js';
|
|
25
27
|
import { hubPath, hubRoot, relocateEntries } from './hub.js';
|
|
26
28
|
import { createScenePromptSync } from './scene-prompt-sync.js';
|
|
27
29
|
import { defineSubagentListTool, defineSubagentRunTool } from './subagents/tools.js';
|
|
@@ -73,6 +75,9 @@ export default {
|
|
|
73
75
|
const agents = ctx.get('agents');
|
|
74
76
|
// pluginInventory is optional: probe at use time, degrade to no live info.
|
|
75
77
|
const pluginInventory = ctx.get('pluginInventory');
|
|
78
|
+
// Agent 预设名单(@deepseek-ai/dsh-agent-presets):同样是可选服务,按需取用。
|
|
79
|
+
// 读它只为了回答"当前预设下本插件注入的东西到不到得了模型"——只读,不挂载任何预设。
|
|
80
|
+
const presetRoster = () => presetRosterOf(ctx);
|
|
76
81
|
// Package version, surfaced in the Settings pages and the HTTP API. Read
|
|
77
82
|
// from the installed package.json so it always matches the release tag.
|
|
78
83
|
let PKG_VERSION = 'unknown';
|
|
@@ -254,6 +259,44 @@ export default {
|
|
|
254
259
|
// 用生效集去校验会把合法勾选判成 stale 丢掉。
|
|
255
260
|
return (await skillScan()).all;
|
|
256
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* 场景档案「技能集」的技能行 —— 技能名与来源名**分开**给出。
|
|
264
|
+
*
|
|
265
|
+
* 界面早先直接渲染 `key`(形如 `<来源 key>/<技能名>`),而自定义目录的来源 key 是
|
|
266
|
+
* `custom-<sha256-16>`,于是导入的技能在场景档案里显示成一串哈希(用户 2026-09-15 报的)。
|
|
267
|
+
* 这里把来源的 label / localeKey 一并带上,界面就能显示「技能名 · 来源名」。
|
|
268
|
+
*
|
|
269
|
+
* 含被覆盖的副本(`shadowed`):场景允许勾选任意一条技能,用生效集校验会把合法勾选判成 stale。
|
|
270
|
+
*/
|
|
271
|
+
async function skillRows() {
|
|
272
|
+
const rows = [];
|
|
273
|
+
try {
|
|
274
|
+
const r = await skillsService.ops['skill-state']({});
|
|
275
|
+
for (const root of ((r && r.data && r.data.roots) || [])) {
|
|
276
|
+
const rootKey = String((root && root.key) || '');
|
|
277
|
+
if (!rootKey)
|
|
278
|
+
continue;
|
|
279
|
+
for (const sk of ((root && root.skills) || [])) {
|
|
280
|
+
const name = String((sk && (sk.declaredName || sk.name)) || '');
|
|
281
|
+
if (!name)
|
|
282
|
+
continue;
|
|
283
|
+
rows.push({
|
|
284
|
+
key: rootKey + '/' + name,
|
|
285
|
+
name,
|
|
286
|
+
enabled: sk.enabled === true && !sk.shadowedBy,
|
|
287
|
+
shadowed: !!sk.shadowedBy,
|
|
288
|
+
rootKey,
|
|
289
|
+
rootLabel: String((root && root.label) || rootKey),
|
|
290
|
+
...(root && root.localeKey ? { rootLocaleKey: String(root.localeKey) } : {}),
|
|
291
|
+
...(root && root.kind ? { rootKind: String(root.kind) } : {}),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch { /* 技能状态读不到 → 空列表(界面如实显示"没有可选项") */ }
|
|
297
|
+
// 平铺列表按技能名排(同名再按来源),比按来源聚在一起更好找。
|
|
298
|
+
return rows.sort((a, b) => a.name.localeCompare(b.name) || a.rootKey.localeCompare(b.rootKey));
|
|
299
|
+
}
|
|
257
300
|
const archiveService = createArchiveEngine({
|
|
258
301
|
loadSlice: async () => ({ ...(await rulesService.readArchiveSlice()) }),
|
|
259
302
|
saveSlice: (slice) => rulesService.patchIndex(slice),
|
|
@@ -357,6 +400,7 @@ export default {
|
|
|
357
400
|
...archiveService.writeOps,
|
|
358
401
|
...subagentService.writeOps,
|
|
359
402
|
'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-restart',
|
|
403
|
+
'mcpm-compact',
|
|
360
404
|
'mcpm-export', 'mcpm-import', 'mcpm-note', 'mcpm-settings', 'mcpm-tool-enabled',
|
|
361
405
|
// mcpm-reveal returns UNMASKED secrets; even though it is a read, it is
|
|
362
406
|
// token-gated like a write — on a LAN-exposed port the token must be the
|
|
@@ -365,13 +409,16 @@ export default {
|
|
|
365
409
|
'skill-open',
|
|
366
410
|
// agents-md 写操作(create/update/remove 改预设库;apply 写全局 AGENTS.md;import 从外部内容建预设)
|
|
367
411
|
'agentsmd-create', 'agentsmd-update', 'agentsmd-apply', 'agentsmd-remove', 'agentsmd-import',
|
|
412
|
+
// 提示词预设的回收站(恢复 / 永久删除都是写)
|
|
413
|
+
'agentsmd-trash-restore', 'agentsmd-trash-delete',
|
|
368
414
|
// history 写操作(archive/unarchive 改归档集合;delete 永久删除;retention-set 写保留期;
|
|
369
415
|
// workspace-register 会新增一条宿主工作区登记,同样是写)
|
|
370
416
|
'history-archive', 'history-unarchive', 'history-delete', 'history-retention-set',
|
|
371
417
|
'history-unarchive-batch', 'history-delete-batch', 'history-import', 'history-export',
|
|
372
418
|
'history-archive-batch', 'history-workspace-register',
|
|
373
419
|
]);
|
|
374
|
-
// 会泄露明文凭据 / 完整配置的 op
|
|
420
|
+
// 会泄露明文凭据 / 完整配置的 op:**必须**带对的访问令牌,没配令牌就一律拒绝
|
|
421
|
+
// (判定在 http-fence.ts 的 secretOpRejection,含两种情况的区分与理由)。
|
|
375
422
|
const SENSITIVE_OPS = new Set(['mcpm-reveal', 'mcpm-export']);
|
|
376
423
|
// 兼容体检的日志去重:同一宿主版本只写一条 compat/probe,避免轮询刷屏。
|
|
377
424
|
let compatLoggedFor;
|
|
@@ -1941,6 +1988,55 @@ export default {
|
|
|
1941
1988
|
return { ok: true };
|
|
1942
1989
|
});
|
|
1943
1990
|
}
|
|
1991
|
+
/**
|
|
1992
|
+
* 收敛补丁文件里的启停覆盖块(用户显式点「整理补丁」才走这里)。
|
|
1993
|
+
*
|
|
1994
|
+
* 只删"删掉也不改变生效状态"的块(判定见 planOverrideCompaction):insert 行、
|
|
1995
|
+
* 带 config 的覆盖块、以及决定当前生效值的那一条都原样保留。删除前 writePatch
|
|
1996
|
+
* 会自动备份上一版(保留最近 5 份),所以这一步是可回退的。
|
|
1997
|
+
*
|
|
1998
|
+
* 基准状态由 planOverrideCompaction 自己从 insert 行读(另一份文件的内容只用来补
|
|
1999
|
+
* "insert 行不在本文件里"的情况)。这里**不要**喂 parseRows() 的 rows —— 那是合并
|
|
2000
|
+
* 覆盖块之后的生效值,会让每个 decider 都被判成多余:第一版就是这么把 6 台 MCP
|
|
2001
|
+
* 全部启用的。同理,界面不显示任何"多余块数"(那个数字也来自同一条错误口径)。
|
|
2002
|
+
*/
|
|
2003
|
+
async function mcpmCompact() {
|
|
2004
|
+
const p = await ensurePaths();
|
|
2005
|
+
return withWriteLock(async () => {
|
|
2006
|
+
const levels = ['project', 'global'];
|
|
2007
|
+
const absOf = (level) => (level === 'project' ? p.projectPatch : p.globalPatch);
|
|
2008
|
+
const contents = {};
|
|
2009
|
+
for (const level of levels) {
|
|
2010
|
+
try {
|
|
2011
|
+
contents[level] = await readPatch(absOf(level));
|
|
2012
|
+
}
|
|
2013
|
+
catch (e) {
|
|
2014
|
+
contents[level] = '';
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
const removed = {};
|
|
2018
|
+
const files = [];
|
|
2019
|
+
let total = 0;
|
|
2020
|
+
for (const level of levels) {
|
|
2021
|
+
const content = contents[level];
|
|
2022
|
+
if (!content)
|
|
2023
|
+
continue;
|
|
2024
|
+
const others = levels.filter((other) => other !== level).map((other) => contents[other]).filter(Boolean);
|
|
2025
|
+
const plan = planOverrideCompaction(content, others);
|
|
2026
|
+
if (!plan.ranges.length)
|
|
2027
|
+
continue;
|
|
2028
|
+
await writePatch(absOf(level), spliceRanges(splitLines(content), plan.ranges));
|
|
2029
|
+
files.push(absOf(level));
|
|
2030
|
+
for (const [id, stat] of Object.entries(plan.ids)) {
|
|
2031
|
+
if (!stat.dropped)
|
|
2032
|
+
continue;
|
|
2033
|
+
removed[id] = (removed[id] || 0) + stat.dropped;
|
|
2034
|
+
total += stat.dropped;
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
return { ok: true, removed: total, ids: removed, files };
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
1944
2040
|
async function mcpmExport() {
|
|
1945
2041
|
const p = await ensurePaths();
|
|
1946
2042
|
const list = await mcpmList();
|
|
@@ -2289,32 +2385,24 @@ export default {
|
|
|
2289
2385
|
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
2290
2386
|
if (agentPresets && typeof agentPresets.list === 'function') {
|
|
2291
2387
|
try {
|
|
2388
|
+
// roster 顺序 = 官方的展示顺序(preset.yml 的 order:标准 1 / PTC 2 / 极简 3 / 创造 4,
|
|
2389
|
+
// 自建预设排在其后)。人设编辑器的四行照抄这个顺序,**不要**再按 id 排序。
|
|
2292
2390
|
const roster = await agentPresets.list();
|
|
2293
2391
|
for (const p of roster || []) {
|
|
2294
2392
|
const id = String((p && p.id) || '');
|
|
2295
2393
|
if (!id)
|
|
2296
2394
|
continue;
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
}
|
|
2307
|
-
let names = [];
|
|
2308
|
-
try {
|
|
2309
|
-
names = (await tools.schemas(scopeKey) || []).map((s) => String(s.name)).filter(Boolean);
|
|
2310
|
-
}
|
|
2311
|
-
catch {
|
|
2312
|
-
names = [];
|
|
2313
|
-
}
|
|
2395
|
+
// MCP 工具名形如 `mcp__<server>__<tool>`:面向上百个条目,噪声大于价值 → 不进候选。
|
|
2396
|
+
// 子代理照样能用当前在跑的 MCP —— 那份名单在 decideToolFilter 里运行时并进白名单。
|
|
2397
|
+
const names = (await presetToolNames(id)).filter((name) => !name.startsWith('mcp__'));
|
|
2398
|
+
presets.push({
|
|
2399
|
+
id,
|
|
2400
|
+
name: String((p && (p.name || p.id)) || id),
|
|
2401
|
+
trust: String((p && p.trust) || 'user'),
|
|
2402
|
+
broken: typeof (p && p.broken) === 'string',
|
|
2403
|
+
tools: names,
|
|
2404
|
+
});
|
|
2314
2405
|
for (const name of names) {
|
|
2315
|
-
// MCP 工具名形如 `mcp__<server>__<tool>`:面向上百个条目,噪声大于价值 → 不进候选。
|
|
2316
|
-
if (name.startsWith('mcp__'))
|
|
2317
|
-
continue;
|
|
2318
2406
|
const rec = byName.get(name) || { name, presets: new Set(), current: false };
|
|
2319
2407
|
rec.presets.add(id);
|
|
2320
2408
|
byName.set(name, rec);
|
|
@@ -2336,11 +2424,94 @@ export default {
|
|
|
2336
2424
|
tools: [...byName.values()]
|
|
2337
2425
|
.map((r) => ({ name: r.name, presets: [...r.presets].sort(), current: r.current }))
|
|
2338
2426
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
2339
|
-
|
|
2427
|
+
// 保持 roster 顺序(官方的展示顺序),前端四行直接照用。
|
|
2428
|
+
presets,
|
|
2340
2429
|
};
|
|
2341
2430
|
presetToolsCache = { at: Date.now(), value };
|
|
2342
2431
|
return value;
|
|
2343
2432
|
}
|
|
2433
|
+
/**
|
|
2434
|
+
* 单个预设的工具名(列表里含 MCP 与宿主平面的工具,因为子代理的可见集合是
|
|
2435
|
+
* "宿主平面 ∪ 该预设"的并集)。枚举会为该预设建立 standing mount —— 官方语义:
|
|
2436
|
+
* 一个预设每进程只挂一次,正常创建会话时同样会挂,所以这里按 id 缓存 60 秒;
|
|
2437
|
+
* `subagent_run` 每次委派都要用它校验名单,不能每次都重新枚举。
|
|
2438
|
+
*/
|
|
2439
|
+
const presetNamesCache = new Map();
|
|
2440
|
+
async function presetToolNames(id) {
|
|
2441
|
+
const hit = presetNamesCache.get(id);
|
|
2442
|
+
if (hit && Date.now() - hit.at < 60000)
|
|
2443
|
+
return hit.names;
|
|
2444
|
+
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
2445
|
+
let scopeKey;
|
|
2446
|
+
try {
|
|
2447
|
+
scopeKey = agentPresets && typeof agentPresets.standingKeyFor === 'function'
|
|
2448
|
+
? await agentPresets.standingKeyFor(id)
|
|
2449
|
+
: undefined;
|
|
2450
|
+
}
|
|
2451
|
+
catch {
|
|
2452
|
+
scopeKey = undefined;
|
|
2453
|
+
}
|
|
2454
|
+
let names = [];
|
|
2455
|
+
try {
|
|
2456
|
+
names = ((await tools.schemas(scopeKey)) || []).map((s) => String(s.name)).filter(Boolean);
|
|
2457
|
+
}
|
|
2458
|
+
catch {
|
|
2459
|
+
names = [];
|
|
2460
|
+
}
|
|
2461
|
+
presetNamesCache.set(id, { at: Date.now(), names });
|
|
2462
|
+
return names;
|
|
2463
|
+
}
|
|
2464
|
+
/** 预设名单的名字投影(只读 roster,不挂载任何预设):场景页显示模式名用。 */
|
|
2465
|
+
async function presetNames() {
|
|
2466
|
+
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
2467
|
+
if (!agentPresets || typeof agentPresets.list !== 'function')
|
|
2468
|
+
return [];
|
|
2469
|
+
try {
|
|
2470
|
+
const roster = await agentPresets.list();
|
|
2471
|
+
return (roster || [])
|
|
2472
|
+
.map((p) => ({ id: String((p && p.id) || ''), name: String((p && (p.name || p.id)) || ''), trust: String((p && p.trust) || 'user') }))
|
|
2473
|
+
.filter((p) => p.id !== '');
|
|
2474
|
+
}
|
|
2475
|
+
catch {
|
|
2476
|
+
return [];
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
/** 调用方 agent 跑在哪个 Agent 预设上;判断不了返回 null(绝不猜)。 */
|
|
2480
|
+
function currentPresetId(agentCtx) {
|
|
2481
|
+
if (agentCtx === undefined || agentCtx === null)
|
|
2482
|
+
return null;
|
|
2483
|
+
try {
|
|
2484
|
+
const roster = presetRoster();
|
|
2485
|
+
if (roster === undefined || typeof roster.composedPreset !== 'function')
|
|
2486
|
+
return null;
|
|
2487
|
+
const id = String(roster.composedPreset(agentCtx) ?? '');
|
|
2488
|
+
return id === '' ? null : id;
|
|
2489
|
+
}
|
|
2490
|
+
catch {
|
|
2491
|
+
return null;
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
/**
|
|
2495
|
+
* 人设的工具限制:按**当前会话的预设**决定这次委派下发什么(规则见 decideToolFilter)。
|
|
2496
|
+
* 已知工具名 = 宿主平面(含 MCP)∪ 当前预设的工具;枚举失败时退化成宿主平面,
|
|
2497
|
+
* 于是模式名单里的预设侧工具会被如实记为"当前不存在",而不是造成官方 restrict 抛错。
|
|
2498
|
+
*/
|
|
2499
|
+
async function subagentToolFilterFor(persona, agentCtx) {
|
|
2500
|
+
let hostNames = [];
|
|
2501
|
+
try {
|
|
2502
|
+
hostNames = ((await tools.schemas()) || []).map((s) => String(s.name)).filter(Boolean);
|
|
2503
|
+
}
|
|
2504
|
+
catch {
|
|
2505
|
+
hostNames = [];
|
|
2506
|
+
}
|
|
2507
|
+
const mcp = hostNames.filter((name) => name.startsWith('mcp__'));
|
|
2508
|
+
const known = new Set(hostNames);
|
|
2509
|
+
const presetId = currentPresetId(agentCtx);
|
|
2510
|
+
if (presetId !== null)
|
|
2511
|
+
for (const name of await presetToolNames(presetId))
|
|
2512
|
+
known.add(name);
|
|
2513
|
+
return decideToolFilter(persona, presetId, { names: known, mcp });
|
|
2514
|
+
}
|
|
2344
2515
|
/**
|
|
2345
2516
|
* 模型候选(人设的「模型」下拉):宿主已注册的 provider + 各 provider 能宣告的模型。
|
|
2346
2517
|
* 只读宿主 LLM 目录,**不发起网络请求**(`discoverModels` 会打端点,这里不用);
|
|
@@ -2400,6 +2571,7 @@ export default {
|
|
|
2400
2571
|
'mcpm-set-all': mcpmSetAll,
|
|
2401
2572
|
'mcpm-restart': mcpmRestart,
|
|
2402
2573
|
'mcpm-remove': mcpmRemove,
|
|
2574
|
+
'mcpm-compact': mcpmCompact,
|
|
2403
2575
|
'mcpm-export': mcpmExport,
|
|
2404
2576
|
'mcpm-import': mcpmImport,
|
|
2405
2577
|
// 技能管理 ops(由 ./skills/service.js 提供):skill-state / skill-detail /
|
|
@@ -2438,7 +2610,7 @@ export default {
|
|
|
2438
2610
|
'model-candidates': async () => ({ ok: true, ...(await modelCandidates()) }),
|
|
2439
2611
|
// 场景档案勾选器数据源 v2:全部 MCP 服务器(含未运行)+ 技能全集 + 人设清单。
|
|
2440
2612
|
'scene-inventory': async () => {
|
|
2441
|
-
const [rowsR, tools, skills, subs] = await Promise.all([mcpmListView(), toolStates(),
|
|
2613
|
+
const [rowsR, tools, skills, subs] = await Promise.all([mcpmListView(), toolStates(), skillRows(), subagentService.list()]);
|
|
2442
2614
|
const disabledRaw = await readDisabledTools();
|
|
2443
2615
|
const rows = (rowsR && rowsR.rows) || [];
|
|
2444
2616
|
const mcpServers = [];
|
|
@@ -2462,8 +2634,13 @@ export default {
|
|
|
2462
2634
|
ok: true,
|
|
2463
2635
|
mcpServers,
|
|
2464
2636
|
tools: Object.entries(tools).map(([key, enabled]) => ({ key, enabled })),
|
|
2465
|
-
|
|
2466
|
-
|
|
2637
|
+
// 技能行:技能名与来源名分开给(早先只给 `<来源 key>/<技能名>`,自定义目录的
|
|
2638
|
+
// 来源 key 是 `custom-<hash>`,于是导入的技能在场景里显示成一串哈希)。
|
|
2639
|
+
skills,
|
|
2640
|
+
// 人设行要显示"按模式配了什么限制",所以把按模式名单一起带上;
|
|
2641
|
+
// presets 只给名字与来源(不枚举工具,避免打开档案弹窗就挂载每个预设)。
|
|
2642
|
+
subagents: subs.map((p) => ({ name: p.name, description: p.description, toolsByPreset: p.toolsByPreset ?? null })),
|
|
2643
|
+
presets: await presetNames(),
|
|
2467
2644
|
// 档案编辑器第 4 段「记忆」的候选:场景 + 每个场景里的记忆条数。
|
|
2468
2645
|
// 记忆正文不在这里返回(勾选集只存 id,渲染时才读盘)。
|
|
2469
2646
|
scenes: await memorySceneCandidates(),
|
|
@@ -2540,6 +2717,11 @@ export default {
|
|
|
2540
2717
|
return agentsMdService.remove(id);
|
|
2541
2718
|
},
|
|
2542
2719
|
'agentsmd-import': (args) => agentsMdService.importPreset(String((args && args.id) || ''), String((args && args.content) ?? '')),
|
|
2720
|
+
// 提示词预设的回收站:删预设 = 把整个预设目录移入 `hub/trash/agents-md-trash/<id>/`,
|
|
2721
|
+
// 误删可从「提示词」页的回收站里恢复(同 id 已存在时拒绝恢复,绝不覆盖)。
|
|
2722
|
+
'agentsmd-trash-list': () => agentsMdService.trashList(),
|
|
2723
|
+
'agentsmd-trash-restore': (args) => agentsMdService.trashRestore(String((args && args.id) || '')),
|
|
2724
|
+
'agentsmd-trash-delete': (args) => agentsMdService.trashDelete(String((args && args.id) || '')),
|
|
2543
2725
|
// History(归档会话管理)ops。只读:history-list / history-retention-get;
|
|
2544
2726
|
// 写:history-archive / history-unarchive / history-delete / history-retention-set。
|
|
2545
2727
|
// workspaceRegistry 缺失(补丁未生效)时返回明确错误,不崩页面。
|
|
@@ -2750,6 +2932,30 @@ export default {
|
|
|
2750
2932
|
return { ok: false, error: message(e) };
|
|
2751
2933
|
}
|
|
2752
2934
|
},
|
|
2935
|
+
// 预设可达性矩阵:每个 Agent 预设下,本插件的注入类能力到不到得了模型。
|
|
2936
|
+
// 只读预设组合文本,不挂载任何预设、不改任何数据。回答的是"面板上说注入了,
|
|
2937
|
+
// 模型真的看得到吗" —— minimal 这类 persona complete 的预设会压制全部提示词段。
|
|
2938
|
+
'preset-reach': async () => {
|
|
2939
|
+
try {
|
|
2940
|
+
// 宿主平面的 MCP 工具数:四个官方预设都不在组合里挂 MCP,MCP 由
|
|
2941
|
+
// $DSH_HOME/cordis.patch.yml 这一层挂载,所以它对所有预设一视同仁;
|
|
2942
|
+
// 这一列要回答的只是"现在有没有 MCP 工具可用"。探测失败 → 不传该字段,
|
|
2943
|
+
// 矩阵那一格显示"判断不了"而不是猜。
|
|
2944
|
+
let mcpTools;
|
|
2945
|
+
try {
|
|
2946
|
+
const schemas = await tools.schemas();
|
|
2947
|
+
mcpTools = schemas.filter((s) => String(s && s.name || '').startsWith('mcp__')).length;
|
|
2948
|
+
}
|
|
2949
|
+
catch {
|
|
2950
|
+
mcpTools = undefined;
|
|
2951
|
+
}
|
|
2952
|
+
const report = await assessPresetReach(presetRoster(), { mcpTools });
|
|
2953
|
+
return { ok: true, ...report };
|
|
2954
|
+
}
|
|
2955
|
+
catch (e) {
|
|
2956
|
+
return { ok: false, error: message(e) };
|
|
2957
|
+
}
|
|
2958
|
+
},
|
|
2753
2959
|
'history-unarchive': async (args) => {
|
|
2754
2960
|
const registry = getHistoryRegistry();
|
|
2755
2961
|
if (!registry)
|
|
@@ -3120,12 +3326,15 @@ export default {
|
|
|
3120
3326
|
description: 'List AGENTS.md presets in the plugin preset library (id, active state).',
|
|
3121
3327
|
parameters: {},
|
|
3122
3328
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3123
|
-
async execute() {
|
|
3329
|
+
async execute(_args, exec) {
|
|
3124
3330
|
const r = await agentsMdService.list();
|
|
3125
3331
|
if (!r.ok)
|
|
3126
3332
|
throw new Error(r.error);
|
|
3127
3333
|
const summary = r.presets.map((p) => p.id + (p.active ? ' [active]' : ''));
|
|
3128
|
-
|
|
3334
|
+
// 同上:AGENTS.md 由 dsh-agent-instructions 行承载,预设没挂这一行(或 persona
|
|
3335
|
+
// 是 complete)时文件内容不会进提示词。
|
|
3336
|
+
const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx);
|
|
3337
|
+
return 'AGENTS.md presets:\n' + (summary.join('\n') || '(none)') + '\n(Applying takes effect on the next session created; the current session is unchanged.)' + notice;
|
|
3129
3338
|
},
|
|
3130
3339
|
}));
|
|
3131
3340
|
tools.register(defineTool({
|
|
@@ -3153,15 +3362,18 @@ export default {
|
|
|
3153
3362
|
group: { type: 'string', description: 'Optional scene filter.' },
|
|
3154
3363
|
},
|
|
3155
3364
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3156
|
-
async execute(args) {
|
|
3365
|
+
async execute(args, exec) {
|
|
3157
3366
|
const r = await rulesService.ops['rules-list'](args);
|
|
3158
3367
|
if (!r || r.ok === false)
|
|
3159
3368
|
throw new Error((r && r.error) || '读取规则失败');
|
|
3160
3369
|
const lines = (r.rules || []).map((x) => ('- ' + x.id + ' [' + (x.group || '未归属场景') + '] ' + (x.enabled ? '已启用' : '已停用') +
|
|
3161
3370
|
(x.description ? ' — ' + x.description : '')));
|
|
3162
3371
|
const scenes = (r.scenes || []).map((s) => (s.label || s.name) + (s.active ? '(启用)' : '(未启用)')).join('、');
|
|
3372
|
+
// 注入边界:预设可能压制提示词段(complete persona,或未挂 agent-instructions),
|
|
3373
|
+
// 此时列出的记忆**不在**模型上下文里。必须说出来,否则模型会假设自己已经看到正文。
|
|
3374
|
+
const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx);
|
|
3163
3375
|
return '记忆(' + (r.rules || []).length + '):\n' + (lines.join('\n') || '(无记忆)') +
|
|
3164
|
-
'\n场景:' + (scenes || '(无)') + (r.activeMode === 'all' ? '(默认全部启用)' : '(已收窄)');
|
|
3376
|
+
'\n场景:' + (scenes || '(无)') + (r.activeMode === 'all' ? '(默认全部启用)' : '(已收窄)') + notice;
|
|
3165
3377
|
},
|
|
3166
3378
|
}));
|
|
3167
3379
|
tools.register(defineTool({
|
|
@@ -3201,7 +3413,9 @@ export default {
|
|
|
3201
3413
|
// 与其余 12 个工具同一条注册通道:defineTool 负责编译 parameters(object root + required),
|
|
3202
3414
|
// 裸 register 会把未编译的参数声明直接发给模型 API。
|
|
3203
3415
|
tools.register(defineTool(defineSubagentListTool({ list: () => subagentService.list(), sceneLists: subagentSceneLists })));
|
|
3204
|
-
|
|
3416
|
+
// 工具限制按**当前会话的 Agent 预设**下发:父会话跑在哪个预设,就用那个预设那一行的
|
|
3417
|
+
// 白/黑名单(`decideToolFilter`),名单里已消失的工具名会被丢掉并在结果里如实说明。
|
|
3418
|
+
tools.register(defineTool(defineSubagentRunTool({ ...subagentService, sceneLists: subagentSceneLists, toolFilterFor: subagentToolFilterFor })));
|
|
3205
3419
|
}
|
|
3206
3420
|
catch (e) {
|
|
3207
3421
|
console.error('[dsh-plugin-tool-management] subagent tool registration failed:', message(e));
|
|
@@ -3377,7 +3591,6 @@ export default {
|
|
|
3377
3591
|
res.end(JSON.stringify({ ok: false, error: 'missing plugin gate header' }));
|
|
3378
3592
|
return;
|
|
3379
3593
|
}
|
|
3380
|
-
const origin = hdr('origin');
|
|
3381
3594
|
const tokenAccepted = TOKEN !== '' && hdr('x-dsh-token') === TOKEN;
|
|
3382
3595
|
if (!tokenAccepted) {
|
|
3383
3596
|
const fence = fenceRejection(req, connection);
|
|
@@ -3401,12 +3614,16 @@ export default {
|
|
|
3401
3614
|
/* otherwise fall through with {} */
|
|
3402
3615
|
}
|
|
3403
3616
|
const op = String(payload.op || '');
|
|
3404
|
-
// 敏感 op
|
|
3405
|
-
//
|
|
3406
|
-
//
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3617
|
+
// 敏感 op(会吐明文密钥与完整配置):**必须**带对的访问令牌,没配令牌
|
|
3618
|
+
// 就没有明文(判定与理由见 http-fence.ts 的 secretOpRejection)。
|
|
3619
|
+
// 之前这里只查"有没有 Origin 头",而 token 为空时它形同虚设 —— 任何能
|
|
3620
|
+
// 打开 GUI 的浏览器都拿得到全部密钥原文。
|
|
3621
|
+
if (SENSITIVE_OPS.has(op)) {
|
|
3622
|
+
const gate = secretOpRejection({ tokenConfigured: TOKEN !== '', tokenAccepted });
|
|
3623
|
+
if (gate) {
|
|
3624
|
+
res.end(JSON.stringify({ ok: false, error: gate.error, code: gate.code }));
|
|
3625
|
+
return;
|
|
3626
|
+
}
|
|
3410
3627
|
}
|
|
3411
3628
|
if (TOKEN && WRITE_OPS.has(op) && hdr('x-dsh-token') !== TOKEN) {
|
|
3412
3629
|
res.end(JSON.stringify({ ok: false, error: '缺少或错误的访问令牌(x-dsh-token)' }));
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// 补丁文件(cordis.patch.yml)里的「启停覆盖块」识别与收敛计划。
|
|
2
|
+
//
|
|
3
|
+
// 同一个 loader id 的启停状态有两种写法:insert 行自带的 `disabled:`,以及顶层
|
|
4
|
+
// `- id: xxx` + `disabled:` 的独立覆盖块。补丁按顺序应用、**last-wins**
|
|
5
|
+
// (见 @deepseek-ai/dsh-app-boot 的 applyEntryPatches:`target[key] = value`),
|
|
6
|
+
// 所以覆盖块可以无限追加而每次都不报错 —— 代价是文件越用越长,生效值只能靠
|
|
7
|
+
// "最后一条声明了 disabled 的块"推断。
|
|
8
|
+
//
|
|
9
|
+
// 这里只回答一个问题:要收敛的话,删哪些行区间**且生效值保证不变**。
|
|
10
|
+
//
|
|
11
|
+
// ⚠ 这个模块第一版出过一次事故:调用方把 `parseRows()` 合并覆盖块之后的**生效值**
|
|
12
|
+
// 当基准传进来,而生效值恒等于 decider 的值 —— 于是"decider 的值 == 基准"永远成立,
|
|
13
|
+
// 每个 decider 都被当成 no-op 删掉,6 台 MCP 全部被启用。为了从结构上杜绝这种传错,
|
|
14
|
+
// 现在的接口是:`planOverrideCompaction(content, otherFileContents)` —— 基准只能由
|
|
15
|
+
// 本模块自己从 **insert 行**读(`insertBaseRows`),调用方只能再补"另一份补丁文件的
|
|
16
|
+
// 内容",不能传任何现成的状态值进来。
|
|
17
|
+
/**
|
|
18
|
+
* 顶层块扫描:每个 `- ` 开头的行都是新块的开始,块体延伸到下一个 `- ` 行之前。
|
|
19
|
+
*
|
|
20
|
+
* `end` 剔除块尾的注释/空行 —— 那些行属于"块与块之间",其中就包括下一个块自己的
|
|
21
|
+
* `# dsh-…:disable:<id>` 标记注释。带上它们的话,删一个块会把下一个块的注释一起带走。
|
|
22
|
+
*/
|
|
23
|
+
export function scanBlockRanges(lines) {
|
|
24
|
+
const out = [];
|
|
25
|
+
let current = null;
|
|
26
|
+
const flush = () => {
|
|
27
|
+
if (!current)
|
|
28
|
+
return;
|
|
29
|
+
let end = current.start + current.lines.length;
|
|
30
|
+
while (end > current.start + 1 && (lines[end - 1].trim() === '' || /^\s*#/.test(lines[end - 1])))
|
|
31
|
+
end--;
|
|
32
|
+
out.push({ start: current.start, end, text: current.lines.slice(0, end - current.start).join('\n') });
|
|
33
|
+
current = null;
|
|
34
|
+
};
|
|
35
|
+
for (let i = 0; i < lines.length; i++) {
|
|
36
|
+
if (/^- /.test(lines[i])) {
|
|
37
|
+
flush();
|
|
38
|
+
current = { start: i, lines: [lines[i]] };
|
|
39
|
+
}
|
|
40
|
+
else if (current)
|
|
41
|
+
current.lines.push(lines[i]);
|
|
42
|
+
}
|
|
43
|
+
flush();
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 读一个覆盖块三件事:id、disabled、是不是"纯块"。
|
|
48
|
+
*
|
|
49
|
+
* 只看**顶层/一层缩进**的锚定写法(`- id:`、`name:`、`disabled:`):生成器写出来的
|
|
50
|
+
* 就是这几行,而 config 里的同名键缩进更深 —— 万一手工把 `disabled` 藏进 config,
|
|
51
|
+
* 这里会判成"非纯块"(保留不删),保守方向是对的。
|
|
52
|
+
*
|
|
53
|
+
* 不筛 `name:` —— 补丁是按 **loader id** 生效的,别的模块名写同一个 id 时改的仍是
|
|
54
|
+
* 那个条目;真正决定"能不能删"的是 pure 与 decider 两条规则,不是名字。
|
|
55
|
+
*/
|
|
56
|
+
export function readToggleEntry(lines) {
|
|
57
|
+
let id;
|
|
58
|
+
let disabled;
|
|
59
|
+
let pure = true;
|
|
60
|
+
for (const raw of lines) {
|
|
61
|
+
const line = raw.replace(/\s+$/, '');
|
|
62
|
+
if (!line.trim() || line.trimStart().startsWith('#'))
|
|
63
|
+
continue;
|
|
64
|
+
const head = line.match(/^- id:\s*(\S+)\s*$/);
|
|
65
|
+
if (head) {
|
|
66
|
+
id = head[1];
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (/^\s{2,}name:\s*\S+\s*$/.test(line))
|
|
70
|
+
continue;
|
|
71
|
+
const flag = line.match(/^\s{2,}disabled:\s*(true|false)\s*$/);
|
|
72
|
+
if (flag) {
|
|
73
|
+
disabled = flag[1] === 'true';
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
pure = false;
|
|
77
|
+
}
|
|
78
|
+
return { id, disabled, pure };
|
|
79
|
+
}
|
|
80
|
+
/** 覆盖块自己的标记注释(紧邻上方的 `# …:disable|enable:<id>`),删块时一起删。 */
|
|
81
|
+
export function markerLinesAbove(lines, start) {
|
|
82
|
+
let i = start;
|
|
83
|
+
while (i > 0 && /^# (?:dsh-plugin-tool-management|dsh-mcp-manager):(?:disable|enable):/.test(lines[i - 1]))
|
|
84
|
+
i--;
|
|
85
|
+
return i;
|
|
86
|
+
}
|
|
87
|
+
/** 文件里所有顶层启停覆盖块(按出现顺序)。 */
|
|
88
|
+
export function scanOverrideBlocks(content) {
|
|
89
|
+
const lines = content.split(/\r?\n/);
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const block of scanBlockRanges(lines)) {
|
|
92
|
+
if (/^- insert:/.test(lines[block.start]))
|
|
93
|
+
continue;
|
|
94
|
+
const entry = readToggleEntry(lines.slice(block.start, block.end));
|
|
95
|
+
if (!entry.id)
|
|
96
|
+
continue;
|
|
97
|
+
out.push({ ...block, id: entry.id, disabled: entry.disabled, pure: entry.pure });
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* **insert 行自带的** 启停基准(`- insert:` 里每个子条目的 `disabled`,缺失 = 启用)。
|
|
103
|
+
*
|
|
104
|
+
* 这是收敛判定的唯一合法基准来源:绝不能用 `parseRows()` 那种"合并覆盖块之后的生效值",
|
|
105
|
+
* 因为生效值就等于最后一条覆盖块的值,"覆盖块的值 == 基准"会恒成立,decider 会被全删。
|
|
106
|
+
*/
|
|
107
|
+
export function insertBaseRows(content) {
|
|
108
|
+
const lines = content.split(/\r?\n/);
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const block of scanBlockRanges(lines)) {
|
|
111
|
+
if (!/^- insert:/.test(lines[block.start]))
|
|
112
|
+
continue;
|
|
113
|
+
let id;
|
|
114
|
+
let disabled;
|
|
115
|
+
const flush = () => { if (id)
|
|
116
|
+
out.push({ id, disabled }); };
|
|
117
|
+
for (const line of block.text.split('\n')) {
|
|
118
|
+
const child = line.match(/^ {4}- id:\s*(\S+)\s*$/);
|
|
119
|
+
if (child) {
|
|
120
|
+
flush();
|
|
121
|
+
id = child[1];
|
|
122
|
+
disabled = undefined;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!id)
|
|
126
|
+
continue;
|
|
127
|
+
const flag = line.match(/^\s+disabled:\s*(true|false)\s*$/);
|
|
128
|
+
if (flag)
|
|
129
|
+
disabled = flag[1] === 'true';
|
|
130
|
+
}
|
|
131
|
+
flush();
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* 收敛计划:算出"删掉也不改变生效状态"的覆盖块行区间。
|
|
137
|
+
*
|
|
138
|
+
* 为什么除最后一条外都能删:补丁按顺序应用,同一 id 的多个覆盖块 last-wins,真正
|
|
139
|
+
* 决定生效值的只有**最后一个声明了 disabled 的块**(decider)。于是:
|
|
140
|
+
* - decider 之前的纯启停块 → 删(被 decider 盖住);
|
|
141
|
+
* - decider 本身 → 仅当它与 insert 行的 disabled 相同(纯 no-op)**且基准已知**时删;
|
|
142
|
+
* - decider 之后的块 → 留着(它们不声明 disabled,可能改的是别的东西);
|
|
143
|
+
* - 带 config 的非纯块 → 一律留着(可能承载手工覆盖内容)。
|
|
144
|
+
*
|
|
145
|
+
* "基准未知"为什么要单列:覆盖块可以打在**补丁文件之外**定义的条目上(profile 的
|
|
146
|
+
* cordis.yml 原生行,或另一份补丁文件的 insert 行)。此时"删掉这条 disabled: false"
|
|
147
|
+
* 完全可能把它翻回原生状态的 disabled: true —— 所以基准未知时只删被 decider 盖住的
|
|
148
|
+
* 那些块,绝不删 decider 自己。
|
|
149
|
+
*
|
|
150
|
+
* @param content - 补丁文件全文。
|
|
151
|
+
* @param otherFileContents - 同一 id 的 insert 行**可能坐在里面**的其它补丁文件全文
|
|
152
|
+
* (项目级与全局两份互查)。只用于补基准,绝不用来推断本文件要删什么。
|
|
153
|
+
* @returns `ranges` 为要删除的行区间;`ids` 为逐 id 统计。
|
|
154
|
+
*/
|
|
155
|
+
export function planOverrideCompaction(content, otherFileContents = []) {
|
|
156
|
+
const lines = content.split(/\r?\n/);
|
|
157
|
+
const byId = new Map();
|
|
158
|
+
for (const block of scanOverrideBlocks(content)) {
|
|
159
|
+
const list = byId.get(block.id);
|
|
160
|
+
if (list)
|
|
161
|
+
list.push(block);
|
|
162
|
+
else
|
|
163
|
+
byId.set(block.id, [block]);
|
|
164
|
+
}
|
|
165
|
+
const baseOf = new Map();
|
|
166
|
+
for (const row of insertBaseRows(content))
|
|
167
|
+
baseOf.set(row.id, row.disabled === true);
|
|
168
|
+
for (const other of otherFileContents) {
|
|
169
|
+
for (const row of insertBaseRows(other))
|
|
170
|
+
if (!baseOf.has(row.id))
|
|
171
|
+
baseOf.set(row.id, row.disabled === true);
|
|
172
|
+
}
|
|
173
|
+
const ranges = [];
|
|
174
|
+
const ids = {};
|
|
175
|
+
for (const [id, list] of byId) {
|
|
176
|
+
const baseKnown = baseOf.has(id);
|
|
177
|
+
const base = baseOf.get(id) === true;
|
|
178
|
+
let decider = -1;
|
|
179
|
+
for (let i = 0; i < list.length; i++)
|
|
180
|
+
if (list[i].disabled !== undefined)
|
|
181
|
+
decider = i;
|
|
182
|
+
const doomed = [];
|
|
183
|
+
if (decider >= 0) {
|
|
184
|
+
for (let i = 0; i < decider; i++)
|
|
185
|
+
if (list[i].pure)
|
|
186
|
+
doomed.push(list[i]);
|
|
187
|
+
if (baseKnown && list[decider].pure && list[decider].disabled === base)
|
|
188
|
+
doomed.push(list[decider]);
|
|
189
|
+
}
|
|
190
|
+
ids[id] = { total: list.length, dropped: doomed.length };
|
|
191
|
+
for (const block of doomed)
|
|
192
|
+
ranges.push([markerLinesAbove(lines, block.start), block.end]);
|
|
193
|
+
}
|
|
194
|
+
return { ranges, ids };
|
|
195
|
+
}
|