dsh-plugin-tool-management 0.8.5 → 0.9.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 +40 -1
- package/README.md +50 -48
- package/README_EN.md +44 -44
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +25 -0
- package/lib/agents-md/preset-id.js +1 -1
- package/lib/agents-md/service.js +46 -9
- package/lib/approval-policy.js +1 -1
- package/lib/client.js +335 -92
- package/lib/compat/preset-reach.js +155 -93
- package/lib/context-inject.js +304 -0
- package/lib/hub.js +155 -13
- package/lib/imports/upload.js +34 -14
- package/lib/index.js +680 -233
- package/lib/mcp/state-section.js +46 -15
- package/lib/rules/archive-engine.js +137 -63
- package/lib/rules/archive.js +34 -0
- package/lib/rules/service.js +376 -158
- package/lib/scene-prompt-sync.js +101 -12
- package/lib/skills/catalog.js +125 -0
- package/lib/skills/core.js +6 -4
- package/lib/subagents/catalog.js +15 -6
- package/lib/subagents/service.js +25 -12
- package/lib/subagents/tools.js +18 -13
- package/package.json +11 -6
- package/screenshots.json +8 -8
- 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/lib/prompt-sections.js +0 -148
package/lib/index.js
CHANGED
|
@@ -20,22 +20,67 @@ import { createRulesService, planMemoryExport } from './rules/service.js';
|
|
|
20
20
|
import { createArchiveEngine } from './rules/archive-engine.js';
|
|
21
21
|
import { createSubagentService, decideToolFilter, defaultPersonasDir } from './subagents/service.js';
|
|
22
22
|
import { createSubagentCatalog } from './subagents/catalog.js';
|
|
23
|
-
import {
|
|
23
|
+
import { createSkillCatalog } from './skills/catalog.js';
|
|
24
|
+
import { DEFAULT_INJECT_SETTINGS, createContextInjector, normalizeInjectSettings, } from './context-inject.js';
|
|
24
25
|
import { isApprovalNever } from './approval-policy.js';
|
|
25
26
|
import { EXPECTED_MIN_HOST_VERSION, EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, summarize } from './compat/probe.js';
|
|
26
|
-
import { assessPresetReach, presetRosterOf, reachNoticeForAgent } from './compat/preset-reach.js';
|
|
27
|
+
import { assessPresetReach, injectionFactsOf, presetRosterOf, readCompositionFacts, reachNoticeForAgent } from './compat/preset-reach.js';
|
|
27
28
|
import { fenceRejection, secretOpRejection } from './http-fence.js';
|
|
28
29
|
import { planOverrideCompaction } from './mcp/override-blocks.js';
|
|
29
|
-
import { createMcpStateCatalog,
|
|
30
|
-
import { hubPath, hubRoot, relocateEntries } from './hub.js';
|
|
30
|
+
import { DEFAULT_MCP_NOTE_MAX_LENGTH, createMcpStateCatalog, normalizeMcpNote } from './mcp/state-section.js';
|
|
31
|
+
import { hubBackupDir, hubPath, hubRoot, migrateHubLayoutSync, relocateEntries } from './hub.js';
|
|
31
32
|
import { createScenePromptSync } from './scene-prompt-sync.js';
|
|
32
|
-
import {
|
|
33
|
+
import { defineSubagentManagerListTool, defineSubagentManagerRunTool } from './subagents/tools.js';
|
|
33
34
|
import { detectFormat, extractText, parseGenericText, parseJsonlTranscript, parseMarkdownTranscript } from './imports/parsers.js';
|
|
34
35
|
import { homedir } from 'node:os';
|
|
35
36
|
import { spawn } from 'node:child_process';
|
|
36
|
-
import { mkdir, readdir, readFile,
|
|
37
|
+
import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
|
|
37
38
|
import { basename, dirname, join } from 'node:path';
|
|
38
39
|
import { zipSync } from 'fflate';
|
|
40
|
+
/**
|
|
41
|
+
* 归一化「已知工具」侧车的一行值。
|
|
42
|
+
*
|
|
43
|
+
* 兼容两种格式:旧版 `string[]`(只存名字,v0.8.5 及以前)与新版 `Array<{name, description?}>`。
|
|
44
|
+
* 非法名字、重复名字一律丢掉并去重后按名字排序 —— 排序保证写盘内容稳定(不会因为枚举顺序
|
|
45
|
+
* 变化而反复重写侧车)。导出仅为测试接缝(与 client 的 `memDefaultPickIds` 同一用意)。
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeKnownTools(raw) {
|
|
48
|
+
if (!Array.isArray(raw))
|
|
49
|
+
return [];
|
|
50
|
+
const out = [];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
for (const item of raw) {
|
|
53
|
+
const name = typeof item === 'string'
|
|
54
|
+
? item
|
|
55
|
+
: String(item?.name ?? '');
|
|
56
|
+
if (!name || !/^[A-Za-z0-9_-]{1,128}$/.test(name) || seen.has(name))
|
|
57
|
+
continue;
|
|
58
|
+
seen.add(name);
|
|
59
|
+
const description = typeof item === 'object' && item !== null && typeof item.description === 'string'
|
|
60
|
+
? String(item.description)
|
|
61
|
+
: undefined;
|
|
62
|
+
out.push({ name, ...(description ? { description } : {}) });
|
|
63
|
+
}
|
|
64
|
+
return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 「当前可用」工具数:已知工具名里未被停用表扣减的个数;`*` = 整台停用 → 0。
|
|
68
|
+
*
|
|
69
|
+
* 场景档案收窄与手动逐工具开关写的是同一张停用表,所以**段、页面、`mcp_manager_list`
|
|
70
|
+
* 三处的「N 个工具」都必须是这个数** —— 用扣减前的总数会让模型看到自己 schema 里
|
|
71
|
+
* 并不存在的工具(数字与能调的工具对不上),页面也会和详情页的开关自相矛盾。
|
|
72
|
+
* 导出仅为测试接缝(与 `normalizeKnownTools` 同一用意)。
|
|
73
|
+
*/
|
|
74
|
+
export function countEnabledTools(known, disabled) {
|
|
75
|
+
const off = Array.isArray(disabled) ? disabled : [];
|
|
76
|
+
if (off.indexOf('*') >= 0)
|
|
77
|
+
return 0;
|
|
78
|
+
let n = 0;
|
|
79
|
+
for (const tool of known)
|
|
80
|
+
if (off.indexOf(tool) < 0)
|
|
81
|
+
n++;
|
|
82
|
+
return n;
|
|
83
|
+
}
|
|
39
84
|
// ---------------------------------------------------------------------------
|
|
40
85
|
// Transcript export helpers — mirror the import parsers (src/imports/parsers.js)
|
|
41
86
|
// so an exported file can be imported back losslessly. Pure Node, no ctx.
|
|
@@ -71,6 +116,10 @@ export default {
|
|
|
71
116
|
name: 'dsh-plugin-tool-management-host',
|
|
72
117
|
inject: ['timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills', 'sessions', 'agents', 'workspaceRegistry', 'sessionProjectionCache', 'sessionPersistence'],
|
|
73
118
|
apply(ctx, config) {
|
|
119
|
+
// 布局迁移必须**先于任何读盘**:hub 内的旧名(`agents-md/` → `prompts/` 等)与
|
|
120
|
+
// `$DSH_HOME` 根下的插件侧车 / 日志 / patch 备份,都要在服务读它们之前搬到位。
|
|
121
|
+
// 同步执行:一次性的几次 stat/rename,换来「没有并发窗口」;失败只跳过该项,不抛错。
|
|
122
|
+
migrateHubLayoutSync();
|
|
74
123
|
const fs = ctx.fs;
|
|
75
124
|
const settings = ctx.settings;
|
|
76
125
|
const sandboxPolicy = ctx.sandboxPolicy;
|
|
@@ -125,11 +174,11 @@ export default {
|
|
|
125
174
|
catch (e) {
|
|
126
175
|
console.error('[dsh-plugin-tool-management] skills provider setup failed:', message(e));
|
|
127
176
|
}
|
|
128
|
-
// ----------
|
|
177
|
+
// ---------- 提示词预设库(hub/prompts/)+ 切换 ----------
|
|
129
178
|
// DSH 全局指令基线只有 ~/.dsh/AGENTS.md 一个文件,无内置多预设切换;
|
|
130
|
-
// 本服务在 hub 内
|
|
179
|
+
// 本服务在 hub 内 prompts/ 维护预设库,「应用」= 写入 ~/.dsh/AGENTS.md,
|
|
131
180
|
// 下一轮对话生效:宿主 dsh-agent-instructions 每个 agent/pre-step 都会 stat 并重读该文件。
|
|
132
|
-
// presetsDir 可由 config 注入(测试用),否则落到 $DSH_HOME/tool-management/
|
|
181
|
+
// presetsDir 可由 config 注入(测试用),否则落到 $DSH_HOME/tool-management/prompts。
|
|
133
182
|
// v0.4:旧位置(插件目录 data/agents-md-presets/)在启动时搬入 hub——
|
|
134
183
|
// 插件目录在 npm 安装下会被覆盖,放用户数据在那里本身就丢数据风险。
|
|
135
184
|
const PLUGIN_ROOT = (() => {
|
|
@@ -141,7 +190,7 @@ export default {
|
|
|
141
190
|
}
|
|
142
191
|
})();
|
|
143
192
|
const legacyAgentsMdPresetsDir = join(PLUGIN_ROOT, 'data', 'agents-md-presets');
|
|
144
|
-
const agentsMdPresetsDir = String(config?.presetsDir || hubPath('
|
|
193
|
+
const agentsMdPresetsDir = String(config?.presetsDir || hubPath('prompts'));
|
|
145
194
|
void relocateEntries(legacyAgentsMdPresetsDir, agentsMdPresetsDir, (n) => n !== '__last-applied__')
|
|
146
195
|
.catch(() => 0);
|
|
147
196
|
const agentsMdService = createAgentsMdService(ctx, {
|
|
@@ -154,9 +203,9 @@ export default {
|
|
|
154
203
|
});
|
|
155
204
|
// ---------- rules(规则/记忆,v0.4)----------
|
|
156
205
|
// 记忆真源 $DSH_HOME/tool-management/memories/<场景>/<name>.md(仅用户级,D1)。
|
|
157
|
-
// **场景是显式记录**(
|
|
206
|
+
// **场景是显式记录**(memories-index.json 的 scenes 切片):含保留场景 `global`(界面「全局」,
|
|
158
207
|
// 其记忆注入任何对话),空场景也合法存在——不再是"恰好有这个目录名"的隐式约定。
|
|
159
|
-
// 单投影 = 活动场景记忆 →
|
|
208
|
+
// 单投影 = 活动场景记忆 → 上下文注入(自动在场,模型无需调用任何工具;见下方注入通道)。
|
|
160
209
|
// 原"始终层写 ~/.dsh/AGENTS.md"已下线(变更单 01 §4/§10):公共基线由 _shared/ 承担。
|
|
161
210
|
// 旧的 $DSH_HOME/scene-memory 与 $DSH_HOME/rules 由服务在首次读盘前搬入本目录。
|
|
162
211
|
// rulesRoot / rulesStateDir / rulesScenesDir 仅测试注入,生产留空由服务按 DSH_HOME 解析。
|
|
@@ -169,12 +218,7 @@ export default {
|
|
|
169
218
|
? { maxBytes: Number(config.rulesMaxBytes) }
|
|
170
219
|
: {}),
|
|
171
220
|
});
|
|
172
|
-
|
|
173
|
-
ctx.effect(() => rulesService.registerProviders(), 'dsh-plugin-tool-management: rules providers');
|
|
174
|
-
}
|
|
175
|
-
catch (e) {
|
|
176
|
-
console.error('[dsh-plugin-tool-management] rules provider setup failed:', message(e));
|
|
177
|
-
}
|
|
221
|
+
// 场景记忆段的注册/生命周期由下面的注入通道统一管(rulesService.memoryText / promptText)。
|
|
178
222
|
// ---------- 场景档案引擎(设计 §2)----------
|
|
179
223
|
// deps 把既有写通道(MCP 单工具启停 / 技能启停)注入引擎;引擎自身串行,
|
|
180
224
|
// 状态机与纯逻辑在 ./rules/archive*.ts。mcpmToolEnabled / readDisabledTools
|
|
@@ -321,7 +365,7 @@ export default {
|
|
|
321
365
|
out[server] = list.filter((t) => t !== '*');
|
|
322
366
|
// 「已知工具」缓存:未运行服务器也能按最后见过的名单计算补集。
|
|
323
367
|
for (const [server, list] of Object.entries(await readKnownMcpTools())) {
|
|
324
|
-
out[server] = [...new Set([...(out[server] || []), ...list])].sort();
|
|
368
|
+
out[server] = [...new Set([...(out[server] || []), ...list.map((item) => item.name)])].sort();
|
|
325
369
|
}
|
|
326
370
|
let schemas = [];
|
|
327
371
|
try {
|
|
@@ -342,7 +386,7 @@ export default {
|
|
|
342
386
|
applyMcpEntries: async (entries) => {
|
|
343
387
|
const p = await ensurePaths();
|
|
344
388
|
await withWriteLock(async () => {
|
|
345
|
-
await writeJsonFile(
|
|
389
|
+
await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), entries);
|
|
346
390
|
});
|
|
347
391
|
disabledToolsCache = { at: Date.now(), value: entries };
|
|
348
392
|
scheduleToolRestrictions();
|
|
@@ -403,7 +447,7 @@ export default {
|
|
|
403
447
|
// 补丁改动由宿主热重载(实测 ≤5s)。这里**不等**,避免进入模式被拖住十几秒;
|
|
404
448
|
// 界面在应用完成后即可用,工具集合会在数秒内补齐。
|
|
405
449
|
},
|
|
406
|
-
// 场景备注(v0.8.1):备注写在 `
|
|
450
|
+
// 场景备注(v0.8.1):备注写在 hub 内的 `mcp-notes.json`(按 loader id),
|
|
407
451
|
// 场景档案按 serverName 存 —— 引擎负责映射与读写,与 mcpm-note 同一条写锁。
|
|
408
452
|
currentMcpNotes: async () => ({ ...(await readNotes()) }),
|
|
409
453
|
applyMcpNotes: async (entries) => {
|
|
@@ -416,7 +460,7 @@ export default {
|
|
|
416
460
|
delete map[e.id];
|
|
417
461
|
}
|
|
418
462
|
const p = await ensurePaths();
|
|
419
|
-
await writeJsonFile(
|
|
463
|
+
await writeJsonFile(mcpSidecar(MCP_NOTES_FILE), map);
|
|
420
464
|
notesCache = { at: Date.now(), value: map };
|
|
421
465
|
});
|
|
422
466
|
},
|
|
@@ -457,8 +501,9 @@ export default {
|
|
|
457
501
|
},
|
|
458
502
|
// 人设名全集(保存 subagents 段时校验并报 stale,与 mcp/skills 两段同口径)。
|
|
459
503
|
knownPersonas: async () => new Set((await subagentService.list()).map((p) => p.name)),
|
|
460
|
-
//
|
|
504
|
+
// 人设开关读/写(进入模式:勾了的启用、未勾的停用;退出按快照两个方向还原)。
|
|
461
505
|
disabledPersonas: async (names) => subagentService.enabledStore.disabledAmong(names),
|
|
506
|
+
enabledPersonas: async (names) => subagentService.enabledStore.enabledAmong(names),
|
|
462
507
|
applySubagentSwitches: async (switches) => {
|
|
463
508
|
const off = switches.filter((s) => !s.enabled).map((s) => s.name);
|
|
464
509
|
const on = switches.filter((s) => s.enabled).map((s) => s.name);
|
|
@@ -475,9 +520,9 @@ export default {
|
|
|
475
520
|
},
|
|
476
521
|
});
|
|
477
522
|
// ---------- 轻量子智能体(设计 §3)----------
|
|
478
|
-
// 人设 = $DSH_HOME/tool-management/
|
|
523
|
+
// 人设 = $DSH_HOME/tool-management/subagents/<name>.md;运行走官方 ctx.subagents.start(spawn provider)。
|
|
479
524
|
// sceneLists 供场景绑定校验:启用场景(rules-list 的 active 行)档案里的 subagents 并集。
|
|
480
|
-
// stateDir 与 rules 共用同一个覆盖键(测试注入一致);子智能体开关存 <stateDir>/
|
|
525
|
+
// stateDir 与 rules 共用同一个覆盖键(测试注入一致);子智能体开关存 <stateDir>/subagents-index.json。
|
|
481
526
|
const subagentService = createSubagentService(ctx, { stateDir: String(config?.rulesStateDir || '') });
|
|
482
527
|
const subagentSceneLists = async () => {
|
|
483
528
|
const slice = await rulesService.readArchiveSlice();
|
|
@@ -488,13 +533,17 @@ export default {
|
|
|
488
533
|
.filter((s) => s.active)
|
|
489
534
|
.map((s) => slice.archives[s.name]?.subagents ?? []);
|
|
490
535
|
};
|
|
491
|
-
// ----------
|
|
492
|
-
//
|
|
493
|
-
//
|
|
536
|
+
// ---------- 注入通道(场景和记忆 / MCP / 技能 / 子智能体 / 提示词,各一条消息)----------
|
|
537
|
+
// 这些文本以前是 systemPrompt 段(persona complete 会整段压掉)。现在改走官方的
|
|
538
|
+
// 「每步注入一条合成消息」通道(skill-catalog / AGENTS.md / 时间上下文同款):
|
|
539
|
+
// 任何预设都到得了,内容没变不重发。**每个域一条自己的消息**(来源 kind 各不同,
|
|
540
|
+
// 轨迹里各自一行),见 src/context-inject.ts 的文件头。
|
|
541
|
+
// - 场景和记忆:场景名 + 场景说明 + 记忆正文(同一份文本;场景与记忆是绑定的)。
|
|
542
|
+
// - 人设:本插件自造的概念,宿主没有对应物 → 模型不知道有哪些人设,subagent_manager_run 不会被触发。
|
|
494
543
|
// - MCP:启停只改工具 schema,没有任何显式状态陈述 → 模型不知道有哪些 server,
|
|
495
544
|
// 更拿不到用户写的**备注**(那是写给「未来的模型」的决策提示,工具 schema 永远传达不到)。
|
|
496
|
-
//
|
|
497
|
-
// 口径与理由分别见 src/subagents/catalog.ts、src/mcp/state-section.ts
|
|
545
|
+
// 各域只列「名字 + 摘要」,正文/工具清单绝不进上下文。
|
|
546
|
+
// 口径与理由分别见 src/subagents/catalog.ts、src/mcp/state-section.ts。
|
|
498
547
|
const subagentCatalog = createSubagentCatalog({
|
|
499
548
|
list: () => subagentService.list(),
|
|
500
549
|
sceneLists: subagentSceneLists,
|
|
@@ -503,24 +552,136 @@ export default {
|
|
|
503
552
|
const mcpStateCatalog = createMcpStateCatalog({
|
|
504
553
|
rows: () => mcpmRowsWithNotes(false),
|
|
505
554
|
});
|
|
555
|
+
// 技能目录与提示词两个域是**官方载体的兜底**:预设挂得到官方
|
|
556
|
+
// dsh-tool-skill / dsh-agent-instructions 时注入侧会跳过它们(见 context-inject.ts 的
|
|
557
|
+
// CARRIER_FACT_OF),挂不到(极简)时才由这里送 —— 所以极简下也能按开关决定要不要。
|
|
558
|
+
const skillCatalog = createSkillCatalog({
|
|
559
|
+
state: () => skillsService.ops['skill-state']({}),
|
|
560
|
+
});
|
|
561
|
+
// 注入设置(侧车 `inject-settings.json`,界面在「兼容」页):
|
|
562
|
+
// - underSuppressingPresets:压制型预设(persona complete / 关运行时上下文,如极简)
|
|
563
|
+
// 下是否仍然注入。默认 false = 跟随预设。
|
|
564
|
+
// - domains:各域开关(任何预设下都生效;界面五个勾选)。
|
|
565
|
+
// 注入器每个 step 都要同步读一次设置 → 走 TTL 缓存;未加载时先给默认值并异步预热。
|
|
566
|
+
const INJECT_SETTINGS_FILE = 'inject-settings.json';
|
|
567
|
+
const INJECT_SETTINGS_TTL_MS = 3000;
|
|
568
|
+
let injectSettingsCache = null;
|
|
569
|
+
async function readInjectSettings(force = false) {
|
|
570
|
+
if (injectSettingsCache && !force && Date.now() - injectSettingsCache.at < INJECT_SETTINGS_TTL_MS)
|
|
571
|
+
return injectSettingsCache.value;
|
|
572
|
+
await ensurePaths();
|
|
573
|
+
const raw = await readJsonFile(hubPath(INJECT_SETTINGS_FILE));
|
|
574
|
+
injectSettingsCache = { at: Date.now(), value: normalizeInjectSettings(raw) };
|
|
575
|
+
return injectSettingsCache.value;
|
|
576
|
+
}
|
|
577
|
+
/** 同步快照(注入器取用);还没加载时先返回默认值,并顺手预热一次。 */
|
|
578
|
+
function injectSettingsSync() {
|
|
579
|
+
if (injectSettingsCache)
|
|
580
|
+
return injectSettingsCache.value;
|
|
581
|
+
void readInjectSettings().catch(() => { });
|
|
582
|
+
return DEFAULT_INJECT_SETTINGS;
|
|
583
|
+
}
|
|
584
|
+
/** 边界提示要的注入设置快照(总开关 + 各域开关)。 */
|
|
585
|
+
const injectNoticeOptions = () => {
|
|
586
|
+
const s = injectSettingsSync();
|
|
587
|
+
return { underSuppressingPresets: s.underSuppressingPresets, domains: s.domains };
|
|
588
|
+
};
|
|
589
|
+
async function injectSettingsOp(args) {
|
|
590
|
+
const current = await readInjectSettings();
|
|
591
|
+
if (!args || args.set !== true)
|
|
592
|
+
return { ok: true, settings: current };
|
|
593
|
+
const next = normalizeInjectSettings({
|
|
594
|
+
underSuppressingPresets: typeof args.underSuppressingPresets === 'boolean'
|
|
595
|
+
? args.underSuppressingPresets
|
|
596
|
+
: current.underSuppressingPresets,
|
|
597
|
+
domains: { ...current.domains, ...(args.domains && typeof args.domains === 'object' ? args.domains : {}) },
|
|
598
|
+
});
|
|
599
|
+
await ensurePaths();
|
|
600
|
+
return withWriteLock(async () => {
|
|
601
|
+
try {
|
|
602
|
+
await writeJsonFile(hubPath(INJECT_SETTINGS_FILE), next);
|
|
603
|
+
}
|
|
604
|
+
catch (e) {
|
|
605
|
+
return { ok: false, error: '设置保存失败: ' + message(e) };
|
|
606
|
+
}
|
|
607
|
+
injectSettingsCache = { at: Date.now(), value: next };
|
|
608
|
+
return { ok: true, settings: next };
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
// 注入判定要的两条预设事实(压制型?挂得到 AGENTS.md 通道?)。读取走 TTL 缓存:
|
|
612
|
+
// 组合文件可以随时被编辑(patchReload: live),但每个 step 读一次盘没有必要。
|
|
613
|
+
const PRESET_FACTS_TTL_MS = 15_000;
|
|
614
|
+
const presetFactsCache = new Map();
|
|
615
|
+
async function presetFactsForAgent(agent) {
|
|
616
|
+
const roster = presetRoster();
|
|
617
|
+
if (!roster || typeof roster.composedPreset !== 'function' || typeof roster.read !== 'function')
|
|
618
|
+
return undefined;
|
|
619
|
+
let presetId = '';
|
|
620
|
+
try {
|
|
621
|
+
presetId = String(roster.composedPreset(agent?.ctx) ?? '');
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
return undefined;
|
|
625
|
+
}
|
|
626
|
+
if (presetId === '')
|
|
627
|
+
return undefined;
|
|
628
|
+
const hit = presetFactsCache.get(presetId);
|
|
629
|
+
if (hit && Date.now() - hit.at < PRESET_FACTS_TTL_MS)
|
|
630
|
+
return hit.value;
|
|
631
|
+
try {
|
|
632
|
+
const text = String((await roster.read(presetId)) ?? '');
|
|
633
|
+
const value = injectionFactsOf(readCompositionFacts(text));
|
|
634
|
+
presetFactsCache.set(presetId, { at: Date.now(), value });
|
|
635
|
+
return value;
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
return undefined;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
506
641
|
try {
|
|
507
642
|
ctx.effect(() => {
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
643
|
+
const injector = createContextInjector({
|
|
644
|
+
ctx,
|
|
645
|
+
// 顺序即界面勾选与消息顺序(用户裁定 2026-09-16):场景和记忆 → MCP → 技能 →
|
|
646
|
+
// 子智能体 → 提示词。form 是宿主语义轴:记忆是「当前状态」(snapshot,后发取代先发),
|
|
647
|
+
// 三个目录是 catalog,提示词是 instructions(与官方 AGENTS.md 那条行同一形态)。
|
|
648
|
+
domains: () => [
|
|
649
|
+
{ key: 'memory', name: 'tool-management:scene-memory', label: '场景和记忆', form: 'snapshot', text: () => rulesService.memoryText() },
|
|
650
|
+
{ key: 'mcp', name: 'tool-management:mcp-state', label: 'MCP 服务器', form: 'catalog', text: () => mcpStateCatalog.text() },
|
|
651
|
+
{ key: 'skills', name: 'tool-management:skill-catalog', label: '技能目录', form: 'catalog', text: () => skillCatalog.text() },
|
|
652
|
+
{ key: 'subagents', name: 'tool-management:subagents', label: '子智能体', form: 'catalog', text: () => subagentCatalog.text() },
|
|
653
|
+
{ key: 'prompt', name: 'tool-management:prompt', label: '提示词', form: 'instructions', text: () => rulesService.promptText() },
|
|
654
|
+
],
|
|
655
|
+
settings: () => injectSettingsSync(),
|
|
656
|
+
factsFor: (agent) => presetFactsForAgent(agent),
|
|
657
|
+
});
|
|
658
|
+
return () => injector.dispose();
|
|
659
|
+
}, 'dsh-plugin-tool-management: context injection');
|
|
514
660
|
}
|
|
515
661
|
catch (e) {
|
|
516
|
-
console.error('[dsh-plugin-tool-management]
|
|
662
|
+
console.error('[dsh-plugin-tool-management] context injection setup failed:', message(e));
|
|
517
663
|
}
|
|
664
|
+
void readInjectSettings().catch(() => { });
|
|
518
665
|
// 预热放到下一轮事件循环:此时 apply 的同步初始化(补丁路径、tools 服务…)已全部完成,
|
|
519
666
|
// 避免在初始化中途就去读 MCP 补丁与工具 schema。
|
|
520
667
|
setTimeout(() => {
|
|
521
668
|
void subagentCatalog.warm();
|
|
522
669
|
void mcpStateCatalog.warm();
|
|
670
|
+
void skillCatalog.warm();
|
|
523
671
|
}, 0);
|
|
672
|
+
// 技能写操作成功后立即重算目录(SWR:text() 同步返回缓存值,写后主动 refresh)。
|
|
673
|
+
// 与下面人设目录同一手法:写入口只有 service 的 writeOps,不需要在界面层逐个补。
|
|
674
|
+
for (const opName of skillsService.writeOps) {
|
|
675
|
+
const original = skillsService.ops[opName];
|
|
676
|
+
if (typeof original !== 'function')
|
|
677
|
+
continue;
|
|
678
|
+
skillsService.ops[opName] = async (args) => {
|
|
679
|
+
const result = await original(args);
|
|
680
|
+
if (result && result.ok !== false)
|
|
681
|
+
void skillCatalog.refresh();
|
|
682
|
+
return result;
|
|
683
|
+
};
|
|
684
|
+
}
|
|
524
685
|
// 人设写操作成功后立即重算目录(SWR:text() 同步返回缓存值,写后主动 refresh)。
|
|
525
686
|
// 包装在 service 的 ops 上:写入口只有这一处,不需要在界面层逐个补。
|
|
526
687
|
for (const opName of subagentService.writeOps) {
|
|
@@ -537,7 +698,7 @@ export default {
|
|
|
537
698
|
/**
|
|
538
699
|
* 人设改名后同步场景绑定:场景档案的 `subagents` 名单存的是**人设名**,
|
|
539
700
|
* 不跟着改就会留一个悬空引用(界面把它报成 stale,用户看到「人设不存在」却找不到地方改)。
|
|
540
|
-
* 绑定存在
|
|
701
|
+
* 绑定存在 memories-index.json 里,人设服务看不到它,所以在这一层补一次(同提示词改名的做法)。
|
|
541
702
|
*/
|
|
542
703
|
async function rebindSubagentInArchives(from, to) {
|
|
543
704
|
try {
|
|
@@ -601,9 +762,11 @@ export default {
|
|
|
601
762
|
};
|
|
602
763
|
}
|
|
603
764
|
/**
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
765
|
+
* 模式进行中保存当前场景的档案时,联动人设开关:**档案 = 这个场景开着的人设**
|
|
766
|
+
* (与进入场景时同一口径,见 archive-engine)——
|
|
767
|
+
* 新勾进来的立即启用,并追加进快照的「退出时停回」名单;
|
|
768
|
+
* 取消勾选的立即停用;其中「进场景前就开着」的那些追加进快照的「退出时开回」名单
|
|
769
|
+
* (在停回名单里的说明是本次进场景才打开的,退出本来就该关,不进开回名单)。
|
|
607
770
|
*/
|
|
608
771
|
const baseSceneArchiveSave = archiveService.ops['scene-archive-save'];
|
|
609
772
|
if (typeof baseSceneArchiveSave === 'function') {
|
|
@@ -611,23 +774,30 @@ export default {
|
|
|
611
774
|
const res = await baseSceneArchiveSave(args);
|
|
612
775
|
try {
|
|
613
776
|
const bound = res && res.ok !== false && res.archive && Array.isArray(res.archive.subagents) ? res.archive.subagents : [];
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
777
|
+
const slice = await rulesService.readArchiveSlice();
|
|
778
|
+
const mode = slice.mode;
|
|
779
|
+
if (mode && mode.scene === res.scene) {
|
|
780
|
+
const every = (await subagentService.list()).map((p) => p.name);
|
|
781
|
+
const unbound = every.filter((n) => bound.indexOf(n) < 0);
|
|
782
|
+
const toOn = await subagentService.enabledStore.disabledAmong(bound);
|
|
783
|
+
const toOff = await subagentService.enabledStore.enabledAmong(unbound);
|
|
784
|
+
if (toOn.length)
|
|
785
|
+
await subagentService.enabledStore.setEnabled(toOn, true);
|
|
786
|
+
if (toOff.length)
|
|
787
|
+
await subagentService.enabledStore.setEnabled(toOff, false);
|
|
788
|
+
if (toOn.length || toOff.length)
|
|
789
|
+
void subagentCatalog.refresh();
|
|
790
|
+
const snapshot = mode.snapshot;
|
|
791
|
+
if (snapshot && (toOn.length || toOff.length)) {
|
|
792
|
+
const backOn = Array.isArray(snapshot.subagents) ? snapshot.subagents.slice() : [];
|
|
793
|
+
const offList = Array.isArray(snapshot.subagentsOn) ? snapshot.subagentsOn.slice() : [];
|
|
794
|
+
for (const n of toOn)
|
|
795
|
+
if (backOn.indexOf(n) < 0)
|
|
796
|
+
backOn.push(n);
|
|
797
|
+
for (const n of toOff)
|
|
798
|
+
if (backOn.indexOf(n) < 0 && offList.indexOf(n) < 0)
|
|
799
|
+
offList.push(n);
|
|
800
|
+
await rulesService.patchIndex({ mode: { ...mode, snapshot: { ...snapshot, subagents: backOn, subagentsOn: offList } } });
|
|
631
801
|
}
|
|
632
802
|
}
|
|
633
803
|
}
|
|
@@ -652,7 +822,7 @@ export default {
|
|
|
652
822
|
// last line of defense for plaintext credentials too, not just writes.
|
|
653
823
|
'mcpm-reveal',
|
|
654
824
|
'skill-open',
|
|
655
|
-
//
|
|
825
|
+
// 提示词写操作(create/update/remove 改预设库;apply 写全局 AGENTS.md;import 从外部内容建预设)
|
|
656
826
|
'agentsmd-create', 'agentsmd-update', 'agentsmd-apply', 'agentsmd-remove', 'agentsmd-import',
|
|
657
827
|
// 提示词预设的回收站(恢复 / 永久删除都是写)
|
|
658
828
|
'agentsmd-trash-restore', 'agentsmd-trash-delete',
|
|
@@ -663,6 +833,8 @@ export default {
|
|
|
663
833
|
// 通用导出:往用户指定的目录写文件,按写操作门禁(token)。
|
|
664
834
|
'bundle-export',
|
|
665
835
|
'history-archive-batch', 'history-workspace-register',
|
|
836
|
+
// 注入设置(五个域开关 / 压制型预设口径)写侧车,按写操作门禁。
|
|
837
|
+
'inject-settings',
|
|
666
838
|
]);
|
|
667
839
|
// 会泄露明文凭据 / 完整配置的 op:**必须**带对的访问令牌,没配令牌就一律拒绝
|
|
668
840
|
// (判定在 http-fence.ts 的 secretOpRejection,含两种情况的区分与理由)。
|
|
@@ -677,7 +849,7 @@ export default {
|
|
|
677
849
|
// 历史侧车三件套(归档时刻账本 / 保留期设置 / 工作区登记快照)统一放 hub 根
|
|
678
850
|
// (`$DSH_HOME/tool-management/`),**不放插件目录**:npm 安装下插件目录会被
|
|
679
851
|
// `dsh plugin update` 整体替换,放那里等于"升级即丢账本"——账本一丢,保留期基线
|
|
680
|
-
// 就从 archivedAt 退回 createdAt,归档会话会被提前清掉(v0.4
|
|
852
|
+
// 就从 archivedAt 退回 createdAt,归档会话会被提前清掉(v0.4 把提示词预设
|
|
681
853
|
// 搬进 hub 是同一条理由,见上方注释;这三个是当时漏掉的)。
|
|
682
854
|
// 旧位置(插件目录 data/)在启动时一次性搬入:只搬不删、绝不覆盖、失败下次再试。
|
|
683
855
|
const historyArchivedAtFile = String(config?.archivedAtFile || hubPath('history-archived-at.json'));
|
|
@@ -738,31 +910,39 @@ export default {
|
|
|
738
910
|
}
|
|
739
911
|
}
|
|
740
912
|
/**
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
*
|
|
913
|
+
* **应用提示词预设的唯一入口**(界面 op 与模型工具共用)。
|
|
914
|
+
*
|
|
915
|
+
* 场景接管期间(启用场景 / 全局绑定在驱动基线)只允许「重新应用场景绑定的那一份」:
|
|
916
|
+
* 应用别的预设会绕过场景绑定,于是「场景页显示 A、实际注入 B、A 不可删而 B 可删」
|
|
917
|
+
* 四套状态各说各话(用户实测)。同一份 = 修复(把被手改的基线写回去),走场景同步
|
|
918
|
+
* 而不是裸写 —— 同步会在写之前补上「进场景前的基线」快照,退出场景才恢复得回来。
|
|
919
|
+
* 换提示词的唯一正路在场景设置里改绑定(`rules-update-scene`,成功后同样同步基线)。
|
|
745
920
|
*/
|
|
746
|
-
async function
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
ctx.logger?.warn?.(`agents-md: scene prompt probe failed: ${message(e)}`);
|
|
755
|
-
}
|
|
756
|
-
try {
|
|
757
|
-
const cur = await agentsMdService.getCurrent();
|
|
758
|
-
if (cur && cur.ok && cur.presetId)
|
|
759
|
-
return String(cur.presetId);
|
|
921
|
+
async function applyPresetGuarded(id) {
|
|
922
|
+
const blocked = await scenePromptSync.applyGuard(id);
|
|
923
|
+
if (blocked) {
|
|
924
|
+
return {
|
|
925
|
+
ok: false,
|
|
926
|
+
code: 'error.agentsMd.scenePromptLocked',
|
|
927
|
+
error: `提示词基线当前由场景「${blocked.label}」绑定的预设「${blocked.presetId}」接管,不能直接应用其他预设:要换提示词,请到场景页修改该场景的绑定,或先退出场景。`,
|
|
928
|
+
};
|
|
760
929
|
}
|
|
761
|
-
|
|
762
|
-
|
|
930
|
+
if (await scenePromptSync.driver()) {
|
|
931
|
+
const r = await scenePromptSync.sync();
|
|
932
|
+
if (r.error)
|
|
933
|
+
return { ok: false, error: r.error };
|
|
934
|
+
return { ok: true, id, viaScene: true };
|
|
763
935
|
}
|
|
764
|
-
return
|
|
936
|
+
return agentsMdService.apply(id);
|
|
765
937
|
}
|
|
938
|
+
/** 删除拒绝里的引用说明(人话;与界面标签同一套结构化事实)。 */
|
|
939
|
+
const promptRefReason = (ref) => {
|
|
940
|
+
if (ref && ref.kind === 'scene')
|
|
941
|
+
return `场景「${ref.label || ''}」${ref.active ? '(已启用)' : ''}绑定了它`;
|
|
942
|
+
if (ref && ref.kind === 'restore')
|
|
943
|
+
return '它就是退出场景后要恢复的全局提示词';
|
|
944
|
+
return '~/.dsh/AGENTS.md 当前内容就是它';
|
|
945
|
+
};
|
|
766
946
|
async function readHistoryRetention() {
|
|
767
947
|
try {
|
|
768
948
|
await historyStandby;
|
|
@@ -867,7 +1047,6 @@ export default {
|
|
|
867
1047
|
catch (e) { /* ignore */ }
|
|
868
1048
|
if (!home)
|
|
869
1049
|
throw new Error('无法确定 DSH 主目录(settings.prepareDocument 未返回路径)');
|
|
870
|
-
await migrateRenamedData(home);
|
|
871
1050
|
const sep = home.indexOf('\\') >= 0 ? '\\' : '/';
|
|
872
1051
|
let profileDir = null;
|
|
873
1052
|
let profileName = 'web';
|
|
@@ -914,33 +1093,9 @@ export default {
|
|
|
914
1093
|
return false;
|
|
915
1094
|
}
|
|
916
1095
|
}
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
// first boot pull them to the new names so an upgraded install keeps its
|
|
921
|
-
// data. Best effort — any failure just starts the new layout from scratch.
|
|
922
|
-
async function migrateRenamedData(home) {
|
|
923
|
-
const sep = home.indexOf('\\') >= 0 ? '\\' : '/';
|
|
924
|
-
const pairs = [
|
|
925
|
-
// 状态目录经历过两次改名:skill-mcp-manager → dsh-plugin-tool-management → tool-management
|
|
926
|
-
['skill-mcp-manager', 'tool-management'],
|
|
927
|
-
['dsh-plugin-tool-management', 'tool-management'],
|
|
928
|
-
['skill-mcp-manager-notes.json', 'dsh-plugin-tool-management-notes.json'],
|
|
929
|
-
['skill-mcp-manager-settings.json', 'dsh-plugin-tool-management-settings.json'],
|
|
930
|
-
['skill-mcp-manager-disabled-tools.json', 'dsh-plugin-tool-management-disabled-tools.json'],
|
|
931
|
-
['skill-mcp-manager-export.json', 'dsh-plugin-tool-management-export.json'],
|
|
932
|
-
];
|
|
933
|
-
for (const [oldName, newName] of pairs) {
|
|
934
|
-
try {
|
|
935
|
-
const oldAbs = home + sep + oldName;
|
|
936
|
-
const newAbs = home + sep + newName;
|
|
937
|
-
if (!(await exists(oldAbs)) || (await exists(newAbs)))
|
|
938
|
-
continue;
|
|
939
|
-
await rename(oldAbs, newAbs);
|
|
940
|
-
}
|
|
941
|
-
catch (e) { /* best effort */ }
|
|
942
|
-
}
|
|
943
|
-
}
|
|
1096
|
+
// 一次性数据迁移(旧插件名 / 旧侧车名 / 旧 hub 目录名)已统一收进 hub.ts 的
|
|
1097
|
+
// `migrateHubLayoutSync()`(在 apply() 开头、任何读盘之前同步跑一次)——
|
|
1098
|
+
// 这里不再维护第二份迁移表,避免两处口径漂移。
|
|
944
1099
|
async function readPatch(abs) {
|
|
945
1100
|
try {
|
|
946
1101
|
const t = await fs.resolve(abs);
|
|
@@ -961,17 +1116,27 @@ export default {
|
|
|
961
1116
|
const p = (n) => String(n).padStart(2, '0');
|
|
962
1117
|
return '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
|
|
963
1118
|
}
|
|
1119
|
+
// 改 patch 前的自动备份:写在 `hub/backups/`(不再堆在 `$DSH_HOME` 根下)。
|
|
1120
|
+
// 两个 patch 都叫 `cordis.patch.yml`(全局 + `profiles/<名字>/`),所以备份名里带上层级,
|
|
1121
|
+
// 否则两处在同一个目录里互相撞名、互相挤掉。最坏情况(配置写坏导致 DSH 起不来)
|
|
1122
|
+
// 按文档里的路径找回:`~/.dsh/tool-management/backups/`。
|
|
964
1123
|
async function backupPatchFile(abs, previous) {
|
|
965
1124
|
const i = Math.max(abs.lastIndexOf('\\'), abs.lastIndexOf('/'));
|
|
966
1125
|
if (i <= 0)
|
|
967
1126
|
return;
|
|
968
|
-
const dir = abs.slice(0, i);
|
|
969
1127
|
const base = abs.slice(i + 1);
|
|
970
|
-
|
|
1128
|
+
const profile = /(?:^|[\\/])profiles[\\/]([^\\/]+)$/.exec(abs.slice(0, i));
|
|
1129
|
+
const tag = profile ? 'profile-' + profile[1].replace(/[^A-Za-z0-9._-]/g, '_') : 'global';
|
|
1130
|
+
const stem = base + '.' + tag;
|
|
1131
|
+
const dir = hubBackupDir();
|
|
1132
|
+
await mkdir(dir, { recursive: true });
|
|
1133
|
+
await writeFile(join(dir, stem + '.bak-' + backupStamp()), previous, 'utf8');
|
|
971
1134
|
try {
|
|
972
|
-
|
|
1135
|
+
// 旧位置的备份没有层级后缀(迁移搬进来的),只统计不删错别人的。
|
|
1136
|
+
const mine = (name) => name.startsWith(stem + '.bak-') || (tag === 'global' && name.startsWith(base + '.bak-'));
|
|
1137
|
+
const names = (await readdir(dir)).filter(mine).sort();
|
|
973
1138
|
for (const stale of names.slice(0, Math.max(0, names.length - KEEP_PATCH_BACKUPS))) {
|
|
974
|
-
await unlink(dir
|
|
1139
|
+
await unlink(join(dir, stale));
|
|
975
1140
|
}
|
|
976
1141
|
}
|
|
977
1142
|
catch (e) { /* pruning is best-effort */ }
|
|
@@ -1439,12 +1604,7 @@ export default {
|
|
|
1439
1604
|
// for display. Only the direct `properties` of the parameters object are
|
|
1440
1605
|
// summarized — nested object/array children are omitted (one level is enough
|
|
1441
1606
|
// for a preview, keeps the dialog readable).
|
|
1442
|
-
// ---------- plugin-owned side-car data (notes / settings) ----------
|
|
1443
|
-
// The patch file carries loader config only, so user-facing extras live in
|
|
1444
|
-
// small JSON files under the DSH home, next to the patch files they describe.
|
|
1445
|
-
function sidecarPath(home, name) {
|
|
1446
|
-
return home + (home.indexOf('\\') >= 0 ? '\\' : '/') + name;
|
|
1447
|
-
}
|
|
1607
|
+
// ---------- plugin-owned side-car data (notes / settings / disabled tools) ----------
|
|
1448
1608
|
async function readJsonFile(abs) {
|
|
1449
1609
|
try {
|
|
1450
1610
|
return JSON.parse(await readFile(abs, 'utf8'));
|
|
@@ -1463,13 +1623,24 @@ export default {
|
|
|
1463
1623
|
// hit the cache, but every WRITE re-reads the file (force=true) and merges
|
|
1464
1624
|
// on top of the fresh on-disk state. Without the forced re-read, a manual
|
|
1465
1625
|
// edit of the sidecar file would be silently overwritten by a stale cache.
|
|
1626
|
+
//
|
|
1627
|
+
// 落点:**hub 内**(`~/.dsh/tool-management/`),文件名按域取(`mcp-*`)。
|
|
1628
|
+
// 它们曾经躺在 `$DSH_HOME` 根下、还带着插件名前缀(`dsh-plugin-tool-management-notes.json`),
|
|
1629
|
+
// 2026-09-16 用户要求「插件产生的文件全部收进 hub、名字与当前代码一致」——
|
|
1630
|
+
// 旧文件由 hub.ts 的 `migrateHubLayoutSync()` 在启动时搬进来(见 apply())。
|
|
1466
1631
|
const SIDECAR_TTL_MS = 3000;
|
|
1632
|
+
const MCP_NOTES_FILE = 'mcp-notes.json';
|
|
1633
|
+
const MCP_SETTINGS_FILE = 'mcp-settings.json';
|
|
1634
|
+
const MCP_DISABLED_TOOLS_FILE = 'mcp-disabled-tools.json';
|
|
1635
|
+
const MCP_KNOWN_TOOLS_FILE = 'mcp-known-tools.json';
|
|
1636
|
+
const MCP_EXPORT_FILE = 'mcp-export.json';
|
|
1637
|
+
/** MCP 侧车绝对路径(都在 hub 内)。 */
|
|
1638
|
+
const mcpSidecar = (name) => hubPath(name);
|
|
1467
1639
|
let notesCache = null;
|
|
1468
1640
|
async function readNotes(force = false) {
|
|
1469
1641
|
if (notesCache && !force && Date.now() - notesCache.at < SIDECAR_TTL_MS)
|
|
1470
1642
|
return notesCache.value;
|
|
1471
|
-
const
|
|
1472
|
-
const raw = await readJsonFile(sidecarPath(p.home, 'dsh-plugin-tool-management-notes.json'));
|
|
1643
|
+
const raw = await readJsonFile(mcpSidecar(MCP_NOTES_FILE));
|
|
1473
1644
|
const out = {};
|
|
1474
1645
|
if (raw && typeof raw === 'object') {
|
|
1475
1646
|
for (const key of Object.keys(raw)) {
|
|
@@ -1495,7 +1666,7 @@ export default {
|
|
|
1495
1666
|
else
|
|
1496
1667
|
delete map[id];
|
|
1497
1668
|
try {
|
|
1498
|
-
await writeJsonFile(
|
|
1669
|
+
await writeJsonFile(mcpSidecar(MCP_NOTES_FILE), map);
|
|
1499
1670
|
}
|
|
1500
1671
|
catch (e) {
|
|
1501
1672
|
return { ok: false, error: '备注保存失败: ' + message(e) };
|
|
@@ -1519,7 +1690,7 @@ export default {
|
|
|
1519
1690
|
if (pluginSettingsCache && !force && Date.now() - pluginSettingsCache.at < SIDECAR_TTL_MS)
|
|
1520
1691
|
return pluginSettingsCache.value;
|
|
1521
1692
|
const p = await ensurePaths();
|
|
1522
|
-
const raw = await readJsonFile(
|
|
1693
|
+
const raw = await readJsonFile(mcpSidecar(MCP_SETTINGS_FILE));
|
|
1523
1694
|
pluginSettingsCache = {
|
|
1524
1695
|
at: Date.now(),
|
|
1525
1696
|
value: {
|
|
@@ -1555,7 +1726,7 @@ export default {
|
|
|
1555
1726
|
const p = await ensurePaths();
|
|
1556
1727
|
return withWriteLock(async () => {
|
|
1557
1728
|
try {
|
|
1558
|
-
await writeJsonFile(
|
|
1729
|
+
await writeJsonFile(mcpSidecar(MCP_SETTINGS_FILE), next);
|
|
1559
1730
|
}
|
|
1560
1731
|
catch (e) {
|
|
1561
1732
|
return { ok: false, error: '设置保存失败: ' + message(e) };
|
|
@@ -1570,13 +1741,12 @@ export default {
|
|
|
1570
1741
|
// every scope's model-visible schema list, and `tools.guard` denies the call
|
|
1571
1742
|
// before the body runs. Disabled tools therefore become invisible AND
|
|
1572
1743
|
// uncallable — no patch rewrite, no DSH restart.
|
|
1573
|
-
const DISABLED_TOOLS_FILE = 'dsh-plugin-tool-management-disabled-tools.json';
|
|
1574
1744
|
let disabledToolsCache = null;
|
|
1575
1745
|
async function readDisabledTools(force = false) {
|
|
1576
1746
|
if (disabledToolsCache && !force && Date.now() - disabledToolsCache.at < SIDECAR_TTL_MS)
|
|
1577
1747
|
return disabledToolsCache.value;
|
|
1578
1748
|
const p = await ensurePaths();
|
|
1579
|
-
const raw = await readJsonFile(
|
|
1749
|
+
const raw = await readJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE));
|
|
1580
1750
|
const out = {};
|
|
1581
1751
|
if (raw && typeof raw === 'object') {
|
|
1582
1752
|
for (const serverName of Object.keys(raw)) {
|
|
@@ -1607,19 +1777,18 @@ export default {
|
|
|
1607
1777
|
// 场景档案里既显示 0 工具、工具明细也空空如也 —— 用户没法给未运行服务器挑工具。
|
|
1608
1778
|
// 每次枚举到 live 工具时按 serverName 记一份「最后见过的工具名」,未运行时用它兜底:
|
|
1609
1779
|
// 场景档案能列出/勾选,进入场景后按实际注册的工具生效(restrict 会过滤掉不存在的名字)。
|
|
1610
|
-
const KNOWN_MCP_TOOLS_FILE = 'dsh-plugin-tool-management-mcp-tools.json';
|
|
1611
1780
|
let knownMcpToolsCache = null;
|
|
1612
1781
|
async function readKnownMcpTools(force = false) {
|
|
1613
1782
|
if (knownMcpToolsCache && !force && Date.now() - knownMcpToolsCache.at < SIDECAR_TTL_MS)
|
|
1614
1783
|
return knownMcpToolsCache.value;
|
|
1615
1784
|
const p = await ensurePaths();
|
|
1616
|
-
const raw = await readJsonFile(
|
|
1785
|
+
const raw = await readJsonFile(mcpSidecar(MCP_KNOWN_TOOLS_FILE));
|
|
1617
1786
|
const out = {};
|
|
1618
1787
|
if (raw && typeof raw === 'object') {
|
|
1619
1788
|
for (const serverName of Object.keys(raw)) {
|
|
1620
|
-
const list = raw[serverName];
|
|
1621
|
-
if (
|
|
1622
|
-
out[serverName] = list
|
|
1789
|
+
const list = normalizeKnownTools(raw[serverName]);
|
|
1790
|
+
if (list.length)
|
|
1791
|
+
out[serverName] = list;
|
|
1623
1792
|
}
|
|
1624
1793
|
}
|
|
1625
1794
|
knownMcpToolsCache = { at: Date.now(), value: out };
|
|
@@ -1627,7 +1796,7 @@ export default {
|
|
|
1627
1796
|
}
|
|
1628
1797
|
async function writeKnownMcpTools(value) {
|
|
1629
1798
|
const p = await ensurePaths();
|
|
1630
|
-
await writeJsonFile(
|
|
1799
|
+
await writeJsonFile(mcpSidecar(MCP_KNOWN_TOOLS_FILE), value);
|
|
1631
1800
|
knownMcpToolsCache = { at: Date.now(), value: value };
|
|
1632
1801
|
}
|
|
1633
1802
|
/** 单工具停用判定(含整服务器通配 `*`——场景档案的"MCP 工具集=整台"写的就是它)。 */
|
|
@@ -1667,7 +1836,7 @@ export default {
|
|
|
1667
1836
|
else
|
|
1668
1837
|
delete map[serverName];
|
|
1669
1838
|
try {
|
|
1670
|
-
await writeJsonFile(
|
|
1839
|
+
await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), map);
|
|
1671
1840
|
}
|
|
1672
1841
|
catch (e) {
|
|
1673
1842
|
return { ok: false, error: '保存失败: ' + message(e) };
|
|
@@ -1861,12 +2030,19 @@ export default {
|
|
|
1861
2030
|
}
|
|
1862
2031
|
}
|
|
1863
2032
|
// 「已知工具」缓存兜底:服务器没运行时 schemas() 里没有它任何工具 —— 场景档案里
|
|
1864
|
-
//
|
|
1865
|
-
|
|
1866
|
-
|
|
2033
|
+
// 想给未运行服务器挑工具就全靠这份最后见过的名单。描述也一并带上(v0.9.0 起缓存),
|
|
2034
|
+
// 并标 `stale: true`:界面据此说明「上次运行时」,不假装是实时数据。
|
|
2035
|
+
for (const known of (await readKnownMcpTools())[serverName] || []) {
|
|
2036
|
+
if (listed.has(known.name))
|
|
1867
2037
|
continue;
|
|
1868
|
-
toolsList.push({
|
|
1869
|
-
|
|
2038
|
+
toolsList.push({
|
|
2039
|
+
name: known.name,
|
|
2040
|
+
description: known.description || '(服务器未运行;这个名字来自上次运行记录)',
|
|
2041
|
+
enabled: !disabledHere.has(known.name),
|
|
2042
|
+
parameters: [],
|
|
2043
|
+
stale: true,
|
|
2044
|
+
});
|
|
2045
|
+
listed.add(known.name);
|
|
1870
2046
|
}
|
|
1871
2047
|
return { ok: true, tools: toolsList };
|
|
1872
2048
|
}
|
|
@@ -1903,13 +2079,19 @@ export default {
|
|
|
1903
2079
|
for (;;) {
|
|
1904
2080
|
const t = await mcpmTools({ serverName });
|
|
1905
2081
|
if (t && t.ok && (t.tools || []).length) {
|
|
1906
|
-
//
|
|
1907
|
-
const
|
|
1908
|
-
|
|
2082
|
+
// 记进「已知工具」缓存:以后未运行时场景档案也能列出(名字 + 这次的描述)。
|
|
2083
|
+
const known = t.tools
|
|
2084
|
+
.filter((x) => String(x && x.name))
|
|
2085
|
+
.map((x) => ({ name: String(x.name), ...(typeof x.description === 'string' && x.description ? { description: x.description } : {}) }));
|
|
2086
|
+
if (known.length) {
|
|
1909
2087
|
try {
|
|
1910
2088
|
const cache = await readKnownMcpTools();
|
|
1911
|
-
const
|
|
1912
|
-
|
|
2089
|
+
const byName = new Map((cache[serverName] || []).map((item) => [item.name, item]));
|
|
2090
|
+
for (const item of known) {
|
|
2091
|
+
const old = byName.get(item.name);
|
|
2092
|
+
byName.set(item.name, old && old.description && !item.description ? old : { ...old, ...item });
|
|
2093
|
+
}
|
|
2094
|
+
cache[serverName] = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
1913
2095
|
await writeKnownMcpTools(cache);
|
|
1914
2096
|
}
|
|
1915
2097
|
catch { /* 缓存写失败不影响本次返回 */ }
|
|
@@ -1950,10 +2132,13 @@ export default {
|
|
|
1950
2132
|
rows.push(normalizeRow(r, level, abs));
|
|
1951
2133
|
}
|
|
1952
2134
|
const toolCounts = {};
|
|
2135
|
+
const enabledToolCounts = {};
|
|
1953
2136
|
try {
|
|
1954
2137
|
const schemas = await tools.schemas();
|
|
1955
2138
|
const knownCache = await readKnownMcpTools();
|
|
2139
|
+
const disabledMap = await readDisabledTools();
|
|
1956
2140
|
const liveNames = {};
|
|
2141
|
+
const liveTools = {};
|
|
1957
2142
|
const seen = new Set();
|
|
1958
2143
|
for (const s of schemas) {
|
|
1959
2144
|
const fullName = String(s && s.name || '');
|
|
@@ -1966,6 +2151,11 @@ export default {
|
|
|
1966
2151
|
const list = liveNames[m[1]] || (liveNames[m[1]] = []);
|
|
1967
2152
|
if (list.indexOf(m[2]) < 0)
|
|
1968
2153
|
list.push(m[2]);
|
|
2154
|
+
// 顺手把描述记下来:未运行时详情页就有东西可显示,不必再重复「描述暂不可用」。
|
|
2155
|
+
const bucket = liveTools[m[1]] || (liveTools[m[1]] = []);
|
|
2156
|
+
const desc = typeof s.description === 'string' ? s.description : '';
|
|
2157
|
+
if (!bucket.some((t) => t.name === m[2]))
|
|
2158
|
+
bucket.push({ name: m[2], ...(desc ? { description: desc } : {}) });
|
|
1969
2159
|
}
|
|
1970
2160
|
// 工具数 = live ∪ 停用表里的单个工具名 ∪ 「已知工具」缓存 —— 未运行的服务器
|
|
1971
2161
|
// 也能显示最后一次见过的工具数,而不是永远 0。
|
|
@@ -1979,24 +2169,42 @@ export default {
|
|
|
1979
2169
|
for (const [server, list] of Object.entries(liveNames))
|
|
1980
2170
|
for (const t of list)
|
|
1981
2171
|
addTool(server, t);
|
|
1982
|
-
for (const [server, list] of Object.entries(
|
|
2172
|
+
for (const [server, list] of Object.entries(disabledMap))
|
|
1983
2173
|
for (const t of list)
|
|
1984
2174
|
if (t !== '*')
|
|
1985
2175
|
addTool(server, t);
|
|
1986
2176
|
for (const [server, list] of Object.entries(knownCache))
|
|
1987
2177
|
for (const t of list)
|
|
1988
|
-
addTool(server, t);
|
|
1989
|
-
for (const [server, set] of Object.entries(union))
|
|
2178
|
+
addTool(server, t.name);
|
|
2179
|
+
for (const [server, set] of Object.entries(union)) {
|
|
1990
2180
|
toolCounts[server] = set.size;
|
|
1991
|
-
|
|
2181
|
+
// 可用数 = 已知工具里未被停用表扣减的(`*` = 整台停用 → 0)。场景档案收窄与
|
|
2182
|
+
// 手动逐工具开关写的是同一张停用表,段与页面的「N 个工具」都必须按它扣减,
|
|
2183
|
+
// 否则收窄后模型看到的数字比它能调的工具多,页面与详情页也会互相矛盾。
|
|
2184
|
+
enabledToolCounts[server] = countEnabledTools(set, disabledMap[server] || []);
|
|
2185
|
+
}
|
|
2186
|
+
// 回写「已知工具」:live 见到的名字与描述并入缓存(有变化才写盘,读路径上的 best-effort)。
|
|
2187
|
+
// 名字集合变化,或某个已知工具这次拿到了描述而缓存里没有 → 都算变化。
|
|
1992
2188
|
let cacheChanged = false;
|
|
1993
|
-
for (const [server, list] of Object.entries(
|
|
2189
|
+
for (const [server, list] of Object.entries(liveTools)) {
|
|
1994
2190
|
const prev = knownCache[server] || [];
|
|
1995
|
-
const
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
2191
|
+
const byName = new Map(prev.map((t) => [t.name, t]));
|
|
2192
|
+
for (const tool of list) {
|
|
2193
|
+
const old = byName.get(tool.name);
|
|
2194
|
+
if (!old) {
|
|
2195
|
+
byName.set(tool.name, tool);
|
|
2196
|
+
cacheChanged = true;
|
|
2197
|
+
continue;
|
|
2198
|
+
}
|
|
2199
|
+
if (tool.description && old.description !== tool.description) {
|
|
2200
|
+
byName.set(tool.name, { ...old, description: tool.description });
|
|
2201
|
+
cacheChanged = true;
|
|
2202
|
+
}
|
|
1999
2203
|
}
|
|
2204
|
+
const next = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
2205
|
+
if (next.length !== prev.length)
|
|
2206
|
+
cacheChanged = true;
|
|
2207
|
+
knownCache[server] = next;
|
|
2000
2208
|
}
|
|
2001
2209
|
if (cacheChanged)
|
|
2002
2210
|
void writeKnownMcpTools(knownCache).catch(() => { });
|
|
@@ -2013,14 +2221,23 @@ export default {
|
|
|
2013
2221
|
for (const e of live) {
|
|
2014
2222
|
const bid = bareEntryId(e.entryId);
|
|
2015
2223
|
const found = rows.find((r) => r.id === bid);
|
|
2016
|
-
if (found)
|
|
2017
|
-
|
|
2224
|
+
if (found) {
|
|
2225
|
+
// **补丁说停用 → 它不可能在运行。** loader 的清单偶尔停在上一帧(用户实测:退出场景后
|
|
2226
|
+
// 开关已关、进程已停、AI 也调不到,页面却仍显示「已运行」)——同一个响应里两处矛盾时
|
|
2227
|
+
// 以**补丁**为准:开关是真正生效的控制面,显示跟着它走,四者(实际 / 显示 / 开关 / AI)
|
|
2228
|
+
// 才一致。反方向的偏差(刚停用、进程还在收尾的几秒)同样由补丁给出的答案兜住。
|
|
2229
|
+
found.live = found.disabled
|
|
2230
|
+
? { enabled: false, phase: e.fiberPhase }
|
|
2231
|
+
: { enabled: e.enabled, phase: e.fiberPhase };
|
|
2232
|
+
}
|
|
2018
2233
|
else
|
|
2019
2234
|
rows.push({ id: e.entryId, serverName: e.entryId, transport: null, url: null, command: null, args: null, env: null, headers: null, level: 'loader', disabled: !e.enabled, managed: false, live: { enabled: e.enabled, phase: e.fiberPhase } });
|
|
2020
2235
|
}
|
|
2021
2236
|
for (const row of rows) {
|
|
2022
2237
|
if (row.toolCount === undefined)
|
|
2023
2238
|
row.toolCount = toolCounts[row.serverName] || 0;
|
|
2239
|
+
if (row.enabledToolCount === undefined)
|
|
2240
|
+
row.enabledToolCount = enabledToolCounts[row.serverName] || 0;
|
|
2024
2241
|
}
|
|
2025
2242
|
// A loader id that appears twice (same id in both patch files, or twice in
|
|
2026
2243
|
// one) makes the composition fail to boot. Surface it instead of hiding it.
|
|
@@ -2146,7 +2363,7 @@ export default {
|
|
|
2146
2363
|
toolMap[serverName] = toolMap[cur.serverName];
|
|
2147
2364
|
delete toolMap[cur.serverName];
|
|
2148
2365
|
try {
|
|
2149
|
-
await writeJsonFile(
|
|
2366
|
+
await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), toolMap);
|
|
2150
2367
|
}
|
|
2151
2368
|
catch (e) { /* non-fatal */ }
|
|
2152
2369
|
disabledToolsCache = { at: Date.now(), value: toolMap };
|
|
@@ -2433,7 +2650,7 @@ export default {
|
|
|
2433
2650
|
const json = JSON.stringify({ exportedAt: new Date().toISOString(), rows }, null, 2);
|
|
2434
2651
|
let savedTo = null;
|
|
2435
2652
|
try {
|
|
2436
|
-
const abs =
|
|
2653
|
+
const abs = mcpSidecar(MCP_EXPORT_FILE);
|
|
2437
2654
|
await writePatch(abs, json);
|
|
2438
2655
|
savedTo = abs;
|
|
2439
2656
|
}
|
|
@@ -2834,7 +3051,7 @@ export default {
|
|
|
2834
3051
|
* 单个预设的工具名(列表里含 MCP 与宿主平面的工具,因为子代理的可见集合是
|
|
2835
3052
|
* "宿主平面 ∪ 该预设"的并集)。枚举会为该预设建立 standing mount —— 官方语义:
|
|
2836
3053
|
* 一个预设每进程只挂一次,正常创建会话时同样会挂,所以这里按 id 缓存 60 秒;
|
|
2837
|
-
* `
|
|
3054
|
+
* `subagent_manager_run` 每次委派都要用它校验名单,不能每次都重新枚举。
|
|
2838
3055
|
*/
|
|
2839
3056
|
const presetNamesCache = new Map();
|
|
2840
3057
|
async function presetToolNames(id) {
|
|
@@ -3077,11 +3294,20 @@ export default {
|
|
|
3077
3294
|
// 场景驱动时**只有场景绑的那份**算生效中(用户裁定);文件里那份降级为「文件里是它」,
|
|
3078
3295
|
// 因为场景的提示词已经接管基线。没有场景驱动时才按文件比对定「生效中」。
|
|
3079
3296
|
const sceneDrives = sceneId !== null;
|
|
3297
|
+
// 引用清单与删除保护同源(scene-prompt-sync):界面据此禁用删除并说明「谁在用」,
|
|
3298
|
+
// 不必自己再拼一遍判定(那正是这次四处状态各说各话的根源)。探测失败 = 空清单。
|
|
3299
|
+
const refs = await scenePromptSync.refs();
|
|
3080
3300
|
const presets = base.presets.map((p) => {
|
|
3081
3301
|
const fileApplied = p.active === true;
|
|
3082
3302
|
const byScene = sceneDrives && sceneId === p.id;
|
|
3083
3303
|
const active = sceneDrives ? byScene : fileApplied;
|
|
3084
|
-
return {
|
|
3304
|
+
return {
|
|
3305
|
+
...p,
|
|
3306
|
+
active,
|
|
3307
|
+
fileApplied,
|
|
3308
|
+
activeVia: byScene ? 'scene' : (active && fileApplied ? 'file' : null),
|
|
3309
|
+
refs: refs ? refs.get(p.id) || [] : [],
|
|
3310
|
+
};
|
|
3085
3311
|
});
|
|
3086
3312
|
return { ...base, presets, ...(scenePrompt ? { scenePrompt } : {}) };
|
|
3087
3313
|
},
|
|
@@ -3093,7 +3319,7 @@ export default {
|
|
|
3093
3319
|
...(args && typeof args.description === 'string' ? { description: String(args.description) } : {}),
|
|
3094
3320
|
}),
|
|
3095
3321
|
// 保存:改正文 + 可选改名(nextId)。改名成功顺带把场景绑定一起改名 ——
|
|
3096
|
-
// 绑定存在
|
|
3322
|
+
// 绑定存在 memories-index.json 里,预设库自己看不到,不叫这一声就会留悬空绑定。
|
|
3097
3323
|
// 保存后同步一次全局基线:若改的正是「正在驱动基线的那份」,正文要跟着写进
|
|
3098
3324
|
// `~/.dsh/AGENTS.md`(否则页面标着「生效中」而文件里还是旧内容)。
|
|
3099
3325
|
'agentsmd-update': async (args) => {
|
|
@@ -3112,24 +3338,37 @@ export default {
|
|
|
3112
3338
|
}
|
|
3113
3339
|
return withAgentsMdSync({ ...res, ...renamed });
|
|
3114
3340
|
},
|
|
3115
|
-
|
|
3341
|
+
// 应用 = 写全局基线文件;场景接管期间只放行「场景绑定的那一份」(见 applyPresetGuarded)。
|
|
3342
|
+
'agentsmd-apply': (args) => applyPresetGuarded(String((args && args.id) || '')),
|
|
3116
3343
|
'agentsmd-get-current': () => agentsMdService.getCurrent(),
|
|
3117
|
-
//
|
|
3118
|
-
//
|
|
3119
|
-
//
|
|
3344
|
+
// 删除:**被引用的那份一律拒绝**(用户裁定,2026-09-16 扩到引用清单)。三类引用:
|
|
3345
|
+
// 场景绑定(含恒常的 `_shared` / `global`)、`~/.dsh/AGENTS.md` 的当前内容、
|
|
3346
|
+
// 进场景前保存的基线(退出场景后要恢复的那一份)。拒绝时逐条说明「谁在用」,
|
|
3347
|
+
// 用户知道该先改哪里。引用清单由 scene-prompt-sync 与显示/注入同源算出。
|
|
3348
|
+
// 探测失败(refs() 返回 null)时放行并记 warn:删预设不动 ~/.dsh/AGENTS.md,
|
|
3349
|
+
// 最坏是少一份副本,可重建 —— 不因一次探测故障把删除堵死。
|
|
3120
3350
|
'agentsmd-remove': async (args) => {
|
|
3121
3351
|
const id = String((args && args.id) || '');
|
|
3122
|
-
const
|
|
3123
|
-
if (
|
|
3124
|
-
|
|
3352
|
+
const refs = await scenePromptSync.refs();
|
|
3353
|
+
if (refs === null)
|
|
3354
|
+
ctx.logger?.warn?.('agents-md: reference probe failed; remove allowed');
|
|
3355
|
+
const why = refs ? refs.get(id) || [] : [];
|
|
3356
|
+
if (why.length) {
|
|
3357
|
+
return {
|
|
3358
|
+
ok: false,
|
|
3359
|
+
code: 'error.agentsMd.referenced',
|
|
3360
|
+
error: `「${id}」仍被引用,不能删除:${why.map(promptRefReason).join(';')}。先改掉引用(换绑提示词 / 退出场景 / 应用别的预设)再删除。`,
|
|
3361
|
+
};
|
|
3125
3362
|
}
|
|
3126
3363
|
return agentsMdService.remove(id);
|
|
3127
3364
|
},
|
|
3128
3365
|
'agentsmd-import': (args) => agentsMdService.importPreset(String((args && args.id) || ''), String((args && args.content) ?? ''), args && typeof args.description === 'string' ? String(args.description) : undefined),
|
|
3129
|
-
// 提示词预设的回收站:删预设 = 把整个预设目录移入 `hub/trash/
|
|
3366
|
+
// 提示词预设的回收站:删预设 = 把整个预设目录移入 `hub/trash/prompts-trash/<id>/`,
|
|
3130
3367
|
// 误删可从「提示词」页的回收站里恢复(同 id 已存在时拒绝恢复,绝不覆盖)。
|
|
3131
3368
|
'agentsmd-trash-list': () => agentsMdService.trashList(),
|
|
3132
|
-
|
|
3369
|
+
// 恢复也要同步:把一个「场景绑定着、但曾被删掉」的预设恢复回来 → 绑定重新变活,
|
|
3370
|
+
// 基线要跟着写回它(否则场景页说「生效中:它」而文件里并不是)。
|
|
3371
|
+
'agentsmd-trash-restore': async (args) => withAgentsMdSync(await agentsMdService.trashRestore(String((args && args.id) || ''))),
|
|
3133
3372
|
'agentsmd-trash-delete': (args) => agentsMdService.trashDelete(String((args && args.id) || '')),
|
|
3134
3373
|
// History(归档会话管理)ops。只读:history-list / history-retention-get;
|
|
3135
3374
|
// 写:history-archive / history-unarchive / history-delete / history-retention-set。
|
|
@@ -3344,7 +3583,8 @@ export default {
|
|
|
3344
3583
|
},
|
|
3345
3584
|
// 预设可达性矩阵:每个 Agent 预设下,本插件的注入类能力到不到得了模型。
|
|
3346
3585
|
// 只读预设组合文本,不挂载任何预设、不改任何数据。回答的是"面板上说注入了,
|
|
3347
|
-
// 模型真的看得到吗" ——
|
|
3586
|
+
// 模型真的看得到吗" —— 压制型预设(persona complete / includeRuntimeContext:false,
|
|
3587
|
+
// 如极简)默认不注入,可在「注入」设置里改成强制注入。
|
|
3348
3588
|
'preset-reach': async () => {
|
|
3349
3589
|
try {
|
|
3350
3590
|
// 宿主平面的 MCP 工具数:四个官方预设都不在组合里挂 MCP,MCP 由
|
|
@@ -3359,13 +3599,19 @@ export default {
|
|
|
3359
3599
|
catch {
|
|
3360
3600
|
mcpTools = undefined;
|
|
3361
3601
|
}
|
|
3362
|
-
const
|
|
3602
|
+
const settings = await readInjectSettings();
|
|
3603
|
+
const report = await assessPresetReach(presetRoster(), {
|
|
3604
|
+
mcpTools,
|
|
3605
|
+
inject: { underSuppressingPresets: settings.underSuppressingPresets, domains: settings.domains },
|
|
3606
|
+
});
|
|
3363
3607
|
return { ok: true, ...report };
|
|
3364
3608
|
}
|
|
3365
3609
|
catch (e) {
|
|
3366
3610
|
return { ok: false, error: message(e) };
|
|
3367
3611
|
}
|
|
3368
3612
|
},
|
|
3613
|
+
// 注入设置(读 / 写):压制型预设下是否仍然注入 + 各域开关。界面在「兼容」页。
|
|
3614
|
+
'inject-settings': (args) => injectSettingsOp(args),
|
|
3369
3615
|
'history-unarchive': async (args) => {
|
|
3370
3616
|
const registry = getHistoryRegistry();
|
|
3371
3617
|
if (!registry)
|
|
@@ -3548,7 +3794,7 @@ export default {
|
|
|
3548
3794
|
}
|
|
3549
3795
|
}
|
|
3550
3796
|
else if (kind === 'memories') {
|
|
3551
|
-
// 记忆有 flat(`<场景>/<name>.md`)与 bundle(`<场景>/<name
|
|
3797
|
+
// 记忆有 flat(`<场景>/<name>.md`)与 bundle(`<场景>/<name>/<name>.md`)两种形态,
|
|
3552
3798
|
// 且场景名本身可含 '/' —— 按 id 拼路径会把 bundle 读成 `<场景>/<name>.md`,读不到
|
|
3553
3799
|
// 就静默丢项(详见 planMemoryExport)。所以走索引拿那条规则自己的真实路径。
|
|
3554
3800
|
const r = await rulesService.ops['rules-list']({});
|
|
@@ -3753,6 +3999,46 @@ export default {
|
|
|
3753
3999
|
const r = await rulesService.ops['rules-list']({});
|
|
3754
4000
|
return ((r && r.scenes) || []).filter((s) => s.locked === true).map((s) => String(s.name));
|
|
3755
4001
|
}
|
|
4002
|
+
/** 当前启用(= 已进入)的那个场景名;没有则 null。 */
|
|
4003
|
+
async function activeSceneName() {
|
|
4004
|
+
try {
|
|
4005
|
+
const r = await rulesService.ops['rules-list']({});
|
|
4006
|
+
return r && r.ok && r.activeScene ? String(r.activeScene) : null;
|
|
4007
|
+
}
|
|
4008
|
+
catch {
|
|
4009
|
+
return null;
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
/** 「场景内开关一律禁用」的拒绝文案(界面 / 模型工具共用;不在场景里返回 null)。 */
|
|
4013
|
+
async function sceneSwitchBlock() {
|
|
4014
|
+
const scene = await activeSceneName();
|
|
4015
|
+
if (!scene)
|
|
4016
|
+
return null;
|
|
4017
|
+
return `当前处于场景「${scene}」:开关由场景档案定义,不能在页面上直接改。请到场景页的档案编辑器里改(改完立即生效;退出场景仍按进场景前的状态还原),或先退出场景。`;
|
|
4018
|
+
}
|
|
4019
|
+
/**
|
|
4020
|
+
* 场景内「开关」类操作一律禁用(用户裁定 2026-09-16)。
|
|
4021
|
+
*
|
|
4022
|
+
* 场景里的环境由**档案**定义,页面开关在场景里本来就是"白改":退出场景时 MCP 停用表、
|
|
4023
|
+
* 技能表按快照整体写回,那些改动会被静默丢弃,而档案又不会跟着变 —— 于是「页面开关 /
|
|
4024
|
+
* 实际生效 / 档案勾选」三处各说各话(用户实测数次)。要改就改档案:那里改完立即生效、
|
|
4025
|
+
* 且档案持久化(下次进这个场景照旧生效);退出场景时运行时仍按进场景前的快照还原。
|
|
4026
|
+
* 只拦**启停**:新增 / 改名 / 删除 / 备注 / 重启 / 导入导出这些内容操作照旧可用
|
|
4027
|
+
* (要整体冻结用场景「锁定」)。
|
|
4028
|
+
*/
|
|
4029
|
+
function guardSceneSwitches(opNames, what) {
|
|
4030
|
+
for (const opName of opNames) {
|
|
4031
|
+
const original = handlers[opName];
|
|
4032
|
+
if (typeof original !== 'function')
|
|
4033
|
+
continue;
|
|
4034
|
+
handlers[opName] = async (args) => {
|
|
4035
|
+
const blocked = await sceneSwitchBlock();
|
|
4036
|
+
if (blocked)
|
|
4037
|
+
return { ok: false, code: 'error.sceneSwitchesLocked', error: blocked };
|
|
4038
|
+
return original(args);
|
|
4039
|
+
};
|
|
4040
|
+
}
|
|
4041
|
+
}
|
|
3756
4042
|
function guardLockedOps(opNames, what) {
|
|
3757
4043
|
for (const opName of opNames) {
|
|
3758
4044
|
const original = handlers[opName];
|
|
@@ -3780,6 +4066,14 @@ export default {
|
|
|
3780
4066
|
// 提示词:建改删 / 应用(切换生效基线)/ 导入 / 回收站。
|
|
3781
4067
|
'agentsmd-create', 'agentsmd-update', 'agentsmd-remove', 'agentsmd-apply', 'agentsmd-import', 'agentsmd-trash-restore', 'agentsmd-trash-delete',
|
|
3782
4068
|
], '修改');
|
|
4069
|
+
// 场景内「开关」类操作一律禁用(用户裁定):改开关请去场景档案编辑器。
|
|
4070
|
+
// 与「锁定」正交:锁定冻结全部写操作,这里只拦启停,内容编辑照旧。
|
|
4071
|
+
guardSceneSwitches([
|
|
4072
|
+
'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-tool-enabled',
|
|
4073
|
+
'skill-enable', 'skill-disable', 'skill-source-enable', 'skill-source-disable',
|
|
4074
|
+
'skill-prefer', 'skill-unprefer',
|
|
4075
|
+
'subagent-toggle',
|
|
4076
|
+
], '开关');
|
|
3783
4077
|
// 被锁场景自身的档案与删除:只挡它自己,别的场景照常。
|
|
3784
4078
|
for (const [opName, pickScene, what] of [
|
|
3785
4079
|
['scene-archive-save', (args) => (args && args.scene) || '', '改档案'],
|
|
@@ -3796,10 +4090,13 @@ export default {
|
|
|
3796
4090
|
return original(args);
|
|
3797
4091
|
};
|
|
3798
4092
|
}
|
|
3799
|
-
// 各页列表响应带上 anyLocked
|
|
4093
|
+
// 各页列表响应带上 anyLocked + activeScene:界面据此禁用写控件、并说明
|
|
4094
|
+
// 「当前处于场景 X,开关请到档案里改」(读 op,附加字段不影响既有消费方)。
|
|
3800
4095
|
async function annotateLocked(res) {
|
|
3801
|
-
if (res && res.ok !== false)
|
|
4096
|
+
if (res && res.ok !== false) {
|
|
3802
4097
|
res.anyLocked = (await lockedSceneNames()).length > 0;
|
|
4098
|
+
res.activeScene = await activeSceneName();
|
|
4099
|
+
}
|
|
3803
4100
|
return res;
|
|
3804
4101
|
}
|
|
3805
4102
|
for (const opName of ['mcpm-list', 'mcpm-reveal', 'skill-state', 'subagent-list', 'agentsmd-list']) {
|
|
@@ -3811,22 +4108,49 @@ export default {
|
|
|
3811
4108
|
// ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
|
|
3812
4109
|
const text = (value) => [{ type: 'text', text: value }];
|
|
3813
4110
|
tools.register(defineTool({
|
|
3814
|
-
name: '
|
|
3815
|
-
description: 'List
|
|
3816
|
-
parameters: {
|
|
4111
|
+
name: 'mcp_manager_list',
|
|
4112
|
+
description: 'List configured MCP servers (level, enabled state, live loader status, tool count excluding switched-off tools, note). Read a server\'s note before choosing it. Defaults to enabled servers; pass all=true for every configured server.',
|
|
4113
|
+
parameters: {
|
|
4114
|
+
all: { type: 'boolean', description: 'Include disabled servers (default false).' },
|
|
4115
|
+
},
|
|
3817
4116
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3818
|
-
async execute() {
|
|
3819
|
-
const
|
|
4117
|
+
async execute(args, exec) {
|
|
4118
|
+
const showAll = Boolean(args && args.all === true);
|
|
4119
|
+
// 必须走 mcpmListView()(= mcpmRowsWithNotes(false)):备注在这里合入,且未打码的
|
|
4120
|
+
// url/headers/env 已被打码。备注是本插件 MCP 这一项的真正价值(用户写给未来模型的
|
|
4121
|
+
// 决策提示,如「A 挂了改用 B」),而它只走注入通道 —— 压制型预设下不注入时,
|
|
4122
|
+
// 这里就是唯一的读取路径。
|
|
4123
|
+
const r = await mcpmListView();
|
|
3820
4124
|
if (!r.ok)
|
|
3821
4125
|
throw new Error(r.error);
|
|
3822
|
-
const
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
4126
|
+
const all = r.rows || [];
|
|
4127
|
+
// 默认只列「已启用」的(用户裁定 2026-09-16,与技能 / 记忆列表同口径):停用的服务器
|
|
4128
|
+
// 模型调不到,列出来只是噪音。表头给出全量口径,所以不会变成盲区。
|
|
4129
|
+
const shown = showAll ? all : all.filter((x) => !x.disabled);
|
|
4130
|
+
const summary = shown.map((x) => {
|
|
4131
|
+
const note = normalizeMcpNote(x.notes, DEFAULT_MCP_NOTE_MAX_LENGTH);
|
|
4132
|
+
// 工具数 = 可用数(停用表扣减后):段里给模型的是同一个口径,模型据此与
|
|
4133
|
+
// 自己 schema 里的 `mcp__*` 工具对得上;被场景收窄 / 手动关掉的工具不算。
|
|
4134
|
+
const usableTools = typeof x.enabledToolCount === 'number' ? x.enabledToolCount : x.toolCount;
|
|
4135
|
+
return x.id + ' | ' + x.serverName + ' | ' + x.level + ' | ' + (x.disabled ? 'disabled' : 'enabled') +
|
|
4136
|
+
(x.live ? ' | loader:' + (x.live.enabled ? 'on' : 'off') + (x.live.phase ? ':' + x.live.phase : '') : '') +
|
|
4137
|
+
(typeof usableTools === 'number' ? ' | tools:' + usableTools : '') +
|
|
4138
|
+
(note ? ' | user-hint:' + note : '');
|
|
4139
|
+
});
|
|
4140
|
+
const header = 'MCP servers: ' + (showAll
|
|
4141
|
+
? all.length + ' configured'
|
|
4142
|
+
: shown.length + ' enabled of ' + all.length + ' configured' +
|
|
4143
|
+
(shown.length === all.length ? '' : ' (pass all=true for every configured server)')) + '\n';
|
|
4144
|
+
// 注入边界:压制型预设(persona complete / 关闭运行时上下文,如极简)下本插件
|
|
4145
|
+
// 默认不注入 —— 模型只有 mcp__* 的工具名与参数,没有服务级信息。不说明的话,
|
|
4146
|
+
// 模型会把「能调用这些工具」当成「已经知道有哪些 server、用户给它们写了什么备注」。
|
|
4147
|
+
// 提示本身不点名任何工具,所以挂在哪个发现型工具上都不会出现循环指引。
|
|
4148
|
+
const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx, injectNoticeOptions());
|
|
4149
|
+
return header + (summary.join('\n') || '(none)') + notice;
|
|
3826
4150
|
},
|
|
3827
4151
|
}));
|
|
3828
4152
|
tools.register(defineTool({
|
|
3829
|
-
name: '
|
|
4153
|
+
name: 'mcp_manager_set_enabled',
|
|
3830
4154
|
description: 'Enable or disable one configured MCP server (writes the patch file; takes effect via HMR).',
|
|
3831
4155
|
parameters: {
|
|
3832
4156
|
id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
|
|
@@ -3835,6 +4159,9 @@ export default {
|
|
|
3835
4159
|
},
|
|
3836
4160
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3837
4161
|
async execute(args) {
|
|
4162
|
+
const blocked = await sceneSwitchBlock();
|
|
4163
|
+
if (blocked)
|
|
4164
|
+
throw new Error(blocked);
|
|
3838
4165
|
const r = await mcpmSetEnabled({ id: args.id, level: args.level, enabled: args.enabled });
|
|
3839
4166
|
if (!r.ok)
|
|
3840
4167
|
throw new Error(r.error);
|
|
@@ -3842,7 +4169,7 @@ export default {
|
|
|
3842
4169
|
},
|
|
3843
4170
|
}));
|
|
3844
4171
|
tools.register(defineTool({
|
|
3845
|
-
name: '
|
|
4172
|
+
name: 'mcp_manager_restart',
|
|
3846
4173
|
description: 'Restart one configured MCP server (disable + re-enable; reconnect and re-sync tools).',
|
|
3847
4174
|
parameters: {
|
|
3848
4175
|
id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
|
|
@@ -3857,7 +4184,7 @@ export default {
|
|
|
3857
4184
|
},
|
|
3858
4185
|
}));
|
|
3859
4186
|
tools.register(defineTool({
|
|
3860
|
-
name: '
|
|
4187
|
+
name: 'mcp_manager_add',
|
|
3861
4188
|
description: 'Add a new MCP server (streamable-http or stdio) at project or global level.',
|
|
3862
4189
|
parameters: {
|
|
3863
4190
|
serverName: { type: 'string', required: true, description: 'Unique server name (1-32 chars, [A-Za-z0-9_-]).' },
|
|
@@ -3881,26 +4208,69 @@ export default {
|
|
|
3881
4208
|
// tools/pre-execute hook below (the model must ask before writing files).
|
|
3882
4209
|
tools.register(defineTool({
|
|
3883
4210
|
name: 'skill_manager_list',
|
|
3884
|
-
description: 'List DSH skills
|
|
3885
|
-
parameters: {
|
|
4211
|
+
description: 'List DSH skills with enabled state, effective/shadowed status and source file path. Defaults to enabled skills only; pass all=true for every entry. A copy marked "shadowed by <root>" stays inactive even if enabled.',
|
|
4212
|
+
parameters: {
|
|
4213
|
+
all: { type: 'boolean', description: 'Include disabled and shadowed entries (default false).' },
|
|
4214
|
+
},
|
|
3886
4215
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3887
|
-
async execute() {
|
|
4216
|
+
async execute(args, exec) {
|
|
4217
|
+
const showAll = Boolean(args && args.all === true);
|
|
3888
4218
|
const r = await skillsService.ops['skill-state']({});
|
|
3889
4219
|
if (!r || r.ok === false)
|
|
3890
4220
|
throw new Error((r && r.error) || 'skill-state failed');
|
|
3891
4221
|
const data = r.data || {};
|
|
3892
|
-
|
|
4222
|
+
// 同名(canonical = declaredName || name)跨根重复时只有**一份**生效,排序规则是
|
|
4223
|
+
// 「同名首选 > 来源优先级 rank」,**与启用状态无关**(core 的 groupLoadableSkillsByName)。
|
|
4224
|
+
// 于是「启用被覆盖的那份」是静默无效的:工具会回 OK,技能却依然不可用 —— 用户实测
|
|
4225
|
+
// 踩到的正是这个坑(同一技能在 dsh/agents/claude/custom 各有一份,启错根等于没启)。
|
|
4226
|
+
// 这里把生效/被覆盖如实标出来,让模型(和人)不必猜。
|
|
4227
|
+
//
|
|
4228
|
+
// 默认只列启用的(2026-09-16 用户裁定):本机 36 条里只有 1 条启用,全列一次约 4.3k
|
|
4229
|
+
// 字符且永久留在 transcript。表头始终给出全量口径(多少条 / 多少同名组),所以
|
|
4230
|
+
// 「还有没列出来的」不会变成盲区 —— 要看就传 all=true。
|
|
4231
|
+
const rows = [];
|
|
4232
|
+
const counts = new Map();
|
|
3893
4233
|
for (const root of data.roots || []) {
|
|
3894
4234
|
for (const skill of root.skills || []) {
|
|
3895
|
-
|
|
4235
|
+
const key = String(skill.declaredName || skill.name || '');
|
|
4236
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
4237
|
+
// `enabled` 只写在「胜出者」上(core 的 markWinners);影子副本与不可加载的技能
|
|
4238
|
+
// 没有这个字段。原先直接读 skill.enabled,这些技能一律被报成 disabled ——
|
|
4239
|
+
// 这里改用与界面同口径的兜底推导(client 的 isSkillEnabled)。
|
|
4240
|
+
const enabled = skill.enabled !== undefined
|
|
4241
|
+
? skill.enabled === true
|
|
4242
|
+
: (skill.invocationPolicyValid && skill.modelInvocable && skill.userInvocable && skill.managerEnabled !== false);
|
|
4243
|
+
const marks = [];
|
|
4244
|
+
if (skill.shadowedBy && skill.shadowedBy.root)
|
|
4245
|
+
marks.push('shadowed by ' + skill.shadowedBy.root);
|
|
4246
|
+
if (skill.preferred === true)
|
|
4247
|
+
marks.push('preferred');
|
|
4248
|
+
rows.push({
|
|
4249
|
+
root: String(root.key || ''),
|
|
4250
|
+
enabled,
|
|
4251
|
+
text: (skill.name || skill.declaredName || '') + ' | ' + (root.key || '') + ' | ' + (enabled ? 'enabled' : 'disabled') +
|
|
4252
|
+
(marks.length ? ' | ' + marks.join(' ') : '') +
|
|
4253
|
+
(skill.path ? ' | ' + skill.path : ''),
|
|
4254
|
+
});
|
|
3896
4255
|
}
|
|
3897
4256
|
}
|
|
3898
|
-
|
|
4257
|
+
const dupGroups = [...counts.values()].filter((n) => n > 1).length;
|
|
4258
|
+
const shown = showAll ? rows : rows.filter((row) => row.enabled);
|
|
4259
|
+
const header = 'Skills: ' + (showAll
|
|
4260
|
+
? rows.length + ' entries'
|
|
4261
|
+
: shown.length + ' enabled of ' + rows.length + ' entries') +
|
|
4262
|
+
(dupGroups ? ' · ' + dupGroups + ' duplicated names' : '') +
|
|
4263
|
+
(showAll || shown.length === rows.length ? '' : ' (pass all=true to include disabled and shadowed entries)') + '\n';
|
|
4264
|
+
// 注入边界:预设没挂 dsh-tool-skill(如极简)时官方技能目录不在,本插件的
|
|
4265
|
+
// 「技能目录」注入域会在开关打开时兜底(见上面的注入通道);两者都没有时模型看到的
|
|
4266
|
+
// 只是这份名单 —— 说清边界,别让它以为上下文里已经有技能正文(正文用路径读)。
|
|
4267
|
+
const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx, injectNoticeOptions());
|
|
4268
|
+
return header + (shown.map((row) => row.text).join('\n') || '(none)') + notice;
|
|
3899
4269
|
},
|
|
3900
4270
|
}));
|
|
3901
4271
|
tools.register(defineTool({
|
|
3902
4272
|
name: 'skill_manager_set_enabled',
|
|
3903
|
-
description: 'Enable or disable one DSH skill (manager policy only;
|
|
4273
|
+
description: 'Enable or disable one DSH skill (manager policy only; source files are never modified). Enabling a shadowed copy has no effect.',
|
|
3904
4274
|
parameters: {
|
|
3905
4275
|
name: { type: 'string', required: true, description: 'Skill name (kebab-case).' },
|
|
3906
4276
|
enabled: { type: 'boolean', required: true, description: 'true to enable, false to disable.' },
|
|
@@ -3908,11 +4278,37 @@ export default {
|
|
|
3908
4278
|
},
|
|
3909
4279
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3910
4280
|
async execute(args) {
|
|
4281
|
+
const blocked = await sceneSwitchBlock();
|
|
4282
|
+
if (blocked)
|
|
4283
|
+
throw new Error(blocked);
|
|
4284
|
+
const root = String(args.root || 'dsh');
|
|
3911
4285
|
const op = args.enabled ? 'skill-enable' : 'skill-disable';
|
|
3912
|
-
const r = await skillsService.ops[op]({ name: args.name, root
|
|
4286
|
+
const r = await skillsService.ops[op]({ name: args.name, root });
|
|
3913
4287
|
if (!r || r.ok === false)
|
|
3914
4288
|
throw new Error((r && r.error) || 'skill toggle failed');
|
|
3915
|
-
|
|
4289
|
+
// 静默无效是这个工具最容易骗人的地方:同名胜负只由「首选 > 来源优先级」决定,
|
|
4290
|
+
// 与启用状态无关。对影子副本操作会返回 OK,但技能依然不可用。如实说一句。
|
|
4291
|
+
let shadowedBy = '';
|
|
4292
|
+
try {
|
|
4293
|
+
const state = await skillsService.ops['skill-state']({});
|
|
4294
|
+
for (const rt of (state && state.data && state.data.roots) || []) {
|
|
4295
|
+
for (const skill of rt.skills || []) {
|
|
4296
|
+
if (String(skill.declaredName || skill.name || '') !== String(args.name))
|
|
4297
|
+
continue;
|
|
4298
|
+
if (String(rt.key || '') !== root)
|
|
4299
|
+
continue;
|
|
4300
|
+
if (skill.shadowedBy && skill.shadowedBy.root)
|
|
4301
|
+
shadowedBy = String(skill.shadowedBy.root);
|
|
4302
|
+
}
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
catch {
|
|
4306
|
+
shadowedBy = '';
|
|
4307
|
+
}
|
|
4308
|
+
const warn = shadowedBy
|
|
4309
|
+
? ' — BUT this copy is shadowed by the same-name skill in "' + shadowedBy + '", so it stays inactive: enable that copy instead, or make this root the preferred copy in the panel'
|
|
4310
|
+
: '';
|
|
4311
|
+
return 'OK: ' + args.name + ' now ' + (args.enabled ? 'enabled' : 'disabled') + warn;
|
|
3916
4312
|
},
|
|
3917
4313
|
}));
|
|
3918
4314
|
tools.register(defineTool({
|
|
@@ -3920,7 +4316,7 @@ export default {
|
|
|
3920
4316
|
// 落点必须和 UI「创建技能」一致:两者都走 core 的默认落点(hub 的
|
|
3921
4317
|
// tool-management/skills/,hub 缺失时退回 DSH_HOME/skills)。以前这里硬编码
|
|
3922
4318
|
// root:'dsh',于是同一个「新建技能」动作,人点界面和模型调用会落到两个不同的根。
|
|
3923
|
-
description: 'Create a new
|
|
4319
|
+
description: 'Create a new DSH skill under DSH_HOME/tool-management/skills. Use only when the user explicitly asks to create or save a reusable skill.',
|
|
3924
4320
|
parameters: {
|
|
3925
4321
|
name: { type: 'string', required: true, description: 'Skill name; normalized to kebab-case.' },
|
|
3926
4322
|
description: { type: 'string', required: true, description: 'A concise routing description for when to use the skill.' },
|
|
@@ -3937,63 +4333,108 @@ export default {
|
|
|
3937
4333
|
}));
|
|
3938
4334
|
// AGENTS.md 预设库:模型可查/切,不能造/删(避免模型乱删用户预设)。
|
|
3939
4335
|
tools.register(defineTool({
|
|
3940
|
-
name: '
|
|
3941
|
-
description: 'List AGENTS.md presets
|
|
3942
|
-
parameters: {
|
|
4336
|
+
name: 'prompt_manager_list',
|
|
4337
|
+
description: 'List AGENTS.md presets (id, active state, file path). Read that file to see a preset body. Defaults to the preset currently in effect; pass all=true for the whole library.',
|
|
4338
|
+
parameters: {
|
|
4339
|
+
all: { type: 'boolean', description: 'Include inactive presets (default false).' },
|
|
4340
|
+
},
|
|
3943
4341
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3944
|
-
async execute(
|
|
4342
|
+
async execute(args, exec) {
|
|
4343
|
+
const showAll = Boolean(args && args.all === true);
|
|
3945
4344
|
const r = await agentsMdService.list();
|
|
3946
4345
|
if (!r.ok)
|
|
3947
4346
|
throw new Error(r.error);
|
|
3948
|
-
const
|
|
3949
|
-
//
|
|
3950
|
-
//
|
|
3951
|
-
|
|
3952
|
-
|
|
4347
|
+
const all = r.presets || [];
|
|
4348
|
+
// 默认只列「当前这份 ~/.dsh/AGENTS.md 是从哪个预设来的」(用户裁定 2026-09-16)。
|
|
4349
|
+
// 判定分两级:内容逐字节相同 = 生效;否则看服务层记下的「最近一次应用」——
|
|
4350
|
+
// 用户手改过全局文件时,第二级仍能给出答案,不必退化成全列(那会白烧上下文)。
|
|
4351
|
+
const active = all.filter((p) => p.active === true);
|
|
4352
|
+
const fallback = active.length ? active : all.filter((p) => p.lastApplied === true);
|
|
4353
|
+
const shown = showAll ? all : fallback;
|
|
4354
|
+
// 路径这一列是「名单 → 全文」的闭环:预设正文既不进提示词、也没有读取工具,
|
|
4355
|
+
// 给出文件路径让模型用宿主的文件工具读,比再造一个 read 工具省一条常驻 schema。
|
|
4356
|
+
const summary = shown.map((p) => {
|
|
4357
|
+
const mark = p.active ? ' [active]'
|
|
4358
|
+
: p.lastApplied ? ' [last applied — ~/.dsh/AGENTS.md has changed since]'
|
|
4359
|
+
: '';
|
|
4360
|
+
return p.id + mark + ' | ' + join(agentsMdPresetsDir, p.id, 'AGENTS.md');
|
|
4361
|
+
});
|
|
4362
|
+
const header = 'AGENTS.md presets: ' + (showAll
|
|
4363
|
+
? all.length + ' in the library'
|
|
4364
|
+
: fallback.length
|
|
4365
|
+
? fallback.length + (active.length ? ' active' : ' last applied') + ' of ' + all.length +
|
|
4366
|
+
(fallback.length === all.length ? '' : ' (pass all=true for the whole library)')
|
|
4367
|
+
: '0 active of ' + all.length + ' (pass all=true for the whole library)') + '\n';
|
|
4368
|
+
// 同上:AGENTS.md 由官方 dsh-agent-instructions 行承载(极简没挂这一行),
|
|
4369
|
+
// 这一行不在时文件内容不会进上下文。
|
|
4370
|
+
const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx, injectNoticeOptions());
|
|
4371
|
+
return header + (summary.join('\n') || '(none)') + '\n(DSH re-reads ~/.dsh/AGENTS.md every turn, so applying takes effect on the next turn.)' + notice;
|
|
3953
4372
|
},
|
|
3954
4373
|
}));
|
|
3955
4374
|
tools.register(defineTool({
|
|
3956
|
-
name: '
|
|
3957
|
-
description: 'Apply one AGENTS.md preset by writing it to ~/.dsh/AGENTS.md.
|
|
4375
|
+
name: 'prompt_manager_apply',
|
|
4376
|
+
description: 'Apply one AGENTS.md preset by writing it to ~/.dsh/AGENTS.md; effective on the next turn. Refused while a scene drives the baseline, unless it is that scene\'s bound preset (change the scene binding instead).',
|
|
3958
4377
|
parameters: {
|
|
3959
4378
|
id: { type: 'string', required: true, description: 'Preset id (lowercase letters, digits, hyphens).' },
|
|
3960
4379
|
},
|
|
3961
4380
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3962
4381
|
async execute(args) {
|
|
3963
|
-
const r = await
|
|
4382
|
+
const r = await applyPresetGuarded(args.id);
|
|
3964
4383
|
if (!r.ok)
|
|
3965
4384
|
throw new Error(r.error);
|
|
3966
4385
|
return 'OK: preset ' + args.id + ' applied to ~/.dsh/AGENTS.md (next session; current session unchanged' + (r.backedUp ? '; previous backed up to __last-applied__' : '') + ')';
|
|
3967
4386
|
},
|
|
3968
4387
|
}));
|
|
3969
4388
|
// ---------- rules model tools(v0.4)----------
|
|
3970
|
-
//
|
|
3971
|
-
// 查询/编辑规则本身。
|
|
4389
|
+
// 活动场景的记忆正文会自动注入上下文(无需调用工具读取);这里的工具用于
|
|
4390
|
+
// 查询/编辑规则本身。memory_manager_write 受 tools/pre-execute 审批门禁(D2)。
|
|
3972
4391
|
// 路径锚点:$DSH_HOME/tool-management/memories/<场景>/…(场景 `global` = 界面「全局」)。
|
|
3973
4392
|
tools.register(defineTool({
|
|
3974
|
-
name: '
|
|
3975
|
-
description: 'List memories under ~/.dsh/tool-management/memories (id, scene, enabled, description).',
|
|
4393
|
+
name: 'memory_manager_list',
|
|
4394
|
+
description: 'List memories under ~/.dsh/tool-management/memories (id, scene, enabled, description). Defaults to the memories that will actually be injected; pass all=true for every entry.',
|
|
3976
4395
|
parameters: {
|
|
3977
4396
|
group: { type: 'string', description: 'Optional scene filter.' },
|
|
4397
|
+
all: { type: 'boolean', description: 'Include memories that are off, in an inactive scene, or shadowed (default false).' },
|
|
3978
4398
|
},
|
|
3979
4399
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3980
4400
|
async execute(args, exec) {
|
|
4401
|
+
const showAll = Boolean(args && args.all === true);
|
|
3981
4402
|
const r = await rulesService.ops['rules-list'](args);
|
|
3982
4403
|
if (!r || r.ok === false)
|
|
3983
4404
|
throw new Error((r && r.error) || '读取规则失败');
|
|
3984
|
-
|
|
4405
|
+
// 「会注入」的判定与记忆段同源(renderSceneMemory):global 场景恒常注入,其余场景要
|
|
4406
|
+
// 在 index.active 里,再叠加单条 enabled 与同名 bundle 的 shadowed。默认只列这些,
|
|
4407
|
+
// 全列一次会把没启用的条目也算进上下文(用户裁定 2026-09-16,与技能列表同口径)。
|
|
4408
|
+
const sceneOn = (group) => group === 'global' ||
|
|
4409
|
+
(group !== '' && (r.activeMode === 'all' || String(r.activeScene || '') === group));
|
|
4410
|
+
const stateOf = (x) => {
|
|
4411
|
+
if (x.shadowed === true)
|
|
4412
|
+
return { injects: false, label: '被同名覆盖' };
|
|
4413
|
+
if (x.enabled === false)
|
|
4414
|
+
return { injects: false, label: '已停用' };
|
|
4415
|
+
if (!sceneOn(String(x.group || '')))
|
|
4416
|
+
return { injects: false, label: '场景未启用' };
|
|
4417
|
+
return { injects: true, label: '已启用' };
|
|
4418
|
+
};
|
|
4419
|
+
const rows = (r.rules || []).map((x) => ({ x, ...stateOf(x) }));
|
|
4420
|
+
const shown = showAll ? rows : rows.filter((row) => row.injects);
|
|
4421
|
+
const lines = shown.map(({ x, label }) => ('- ' + x.id + ' [' + (x.group || '未归属场景') + '] ' + label +
|
|
3985
4422
|
(x.description ? ' — ' + x.description : '')));
|
|
3986
4423
|
const scenes = (r.scenes || []).map((s) => (s.label || s.name) + (s.active ? '(启用)' : '(未启用)')).join('、');
|
|
3987
|
-
|
|
4424
|
+
const header = '记忆:' + (showAll
|
|
4425
|
+
? rows.length + ' 条'
|
|
4426
|
+
: shown.length + ' 条会注入 / 共 ' + rows.length + ' 条' +
|
|
4427
|
+
(shown.length === rows.length ? '' : '(传 all=true 看全部)')) + '\n';
|
|
4428
|
+
// 注入边界:压制型预设(persona complete / 关闭运行时上下文)下本插件默认不注入,
|
|
3988
4429
|
// 此时列出的记忆**不在**模型上下文里。必须说出来,否则模型会假设自己已经看到正文。
|
|
3989
|
-
const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx);
|
|
3990
|
-
return
|
|
4430
|
+
const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx, injectNoticeOptions());
|
|
4431
|
+
return header + (lines.join('\n') || '(无记忆)') +
|
|
3991
4432
|
'\n场景:' + (scenes || '(无)') + (r.activeMode === 'all' ? '(默认全部启用)' : '(已收窄)') + notice;
|
|
3992
4433
|
},
|
|
3993
4434
|
}));
|
|
3994
4435
|
tools.register(defineTool({
|
|
3995
|
-
name: '
|
|
3996
|
-
description: 'Read the full body of one memory under ~/.dsh/tool-management/memories.',
|
|
4436
|
+
name: 'memory_manager_read',
|
|
4437
|
+
description: 'Read the full body of one memory under ~/.dsh/tool-management/memories. Call it only for memories that are not already in your context (disabled, unassigned to a scene, or dropped by the injection budget).',
|
|
3997
4438
|
parameters: {
|
|
3998
4439
|
id: { type: 'string', required: true, description: 'Memory id like <scene>/<name>.' },
|
|
3999
4440
|
},
|
|
@@ -4006,11 +4447,11 @@ export default {
|
|
|
4006
4447
|
},
|
|
4007
4448
|
}));
|
|
4008
4449
|
tools.register(defineTool({
|
|
4009
|
-
name: '
|
|
4010
|
-
description: 'Create a new memory as ~/.dsh/tool-management/memories/<scene>/<name>.md. The scene must already exist (use global for the always-on scene).
|
|
4450
|
+
name: 'memory_manager_write',
|
|
4451
|
+
description: 'Create a new memory as ~/.dsh/tool-management/memories/<scene>/<name>.md. The scene must already exist (use global for the always-on scene).',
|
|
4011
4452
|
parameters: {
|
|
4012
|
-
group: { type: 'string', required: true, description: 'Scene name
|
|
4013
|
-
name: { type: 'string', required: true, description: 'Memory name =
|
|
4453
|
+
group: { type: 'string', required: true, description: 'Scene name (no path separators or < > : " | ? *); `global` = the always-on scene.' },
|
|
4454
|
+
name: { type: 'string', required: true, description: 'Memory name = .md file name without extension; <=64 chars, no path separators or < > : " | ? *, must not start with a dot.' },
|
|
4014
4455
|
description: { type: 'string', required: true, description: 'One-sentence description (<=500 chars).' },
|
|
4015
4456
|
body: { type: 'string', required: true, description: 'Markdown body (<=256 KiB).' },
|
|
4016
4457
|
},
|
|
@@ -4022,15 +4463,21 @@ export default {
|
|
|
4022
4463
|
return 'OK: memory ' + r.rule.id + '(场景「' + (r.rule.group || '未归属') + '」启用后自动生效)';
|
|
4023
4464
|
},
|
|
4024
4465
|
}));
|
|
4025
|
-
// ---------- 子智能体工具(
|
|
4466
|
+
// ---------- 子智能体工具(subagent_manager_list / subagent_manager_run)----------
|
|
4026
4467
|
// exec.agent / exec.signal 由工具运行时提供(parent 与取消信号的官方通道)。
|
|
4027
4468
|
try {
|
|
4028
4469
|
// 与其余 12 个工具同一条注册通道:defineTool 负责编译 parameters(object root + required),
|
|
4029
4470
|
// 裸 register 会把未编译的参数声明直接发给模型 API。
|
|
4030
|
-
tools.register(defineTool(
|
|
4471
|
+
tools.register(defineTool(defineSubagentManagerListTool({
|
|
4472
|
+
list: () => subagentService.list(),
|
|
4473
|
+
sceneLists: subagentSceneLists,
|
|
4474
|
+
// 与其余发现型工具同口径:压制型预设下不注入时,人设目录不在模型上下文里,
|
|
4475
|
+
// 只有工具可用。挂上边界提示,模型才不会把「看不到人设」当成「没有人设」。
|
|
4476
|
+
noticeFor: (exec) => reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx, injectNoticeOptions()),
|
|
4477
|
+
})));
|
|
4031
4478
|
// 工具限制按**当前会话的 Agent 预设**下发:父会话跑在哪个预设,就用那个预设那一行的
|
|
4032
4479
|
// 白/黑名单(`decideToolFilter`),名单里已消失的工具名会被丢掉并在结果里如实说明。
|
|
4033
|
-
tools.register(defineTool(
|
|
4480
|
+
tools.register(defineTool(defineSubagentManagerRunTool({ ...subagentService, sceneLists: subagentSceneLists, toolFilterFor: subagentToolFilterFor })));
|
|
4034
4481
|
}
|
|
4035
4482
|
catch (e) {
|
|
4036
4483
|
console.error('[dsh-plugin-tool-management] subagent tool registration failed:', message(e));
|
|
@@ -4044,8 +4491,8 @@ export default {
|
|
|
4044
4491
|
// 不能用 ctx.approval——inject 未声明该服务时 cordis 代理会抛 "cannot get property without inject")。
|
|
4045
4492
|
const CONFIRM_LABELS = {
|
|
4046
4493
|
skill_manager_create: '「新建技能」',
|
|
4047
|
-
|
|
4048
|
-
|
|
4494
|
+
memory_manager_write: '「写入记忆」',
|
|
4495
|
+
subagent_manager_run: '「运行子代理」',
|
|
4049
4496
|
};
|
|
4050
4497
|
const bypassedByFullAccess = (exec) => {
|
|
4051
4498
|
if (!isApprovalNever(ctx, exec))
|
|
@@ -4054,11 +4501,11 @@ export default {
|
|
|
4054
4501
|
pluginLog()('confirm-bypass', `完全权限(approval=never):跳过${CONFIRM_LABELS[String(exec && exec.name)]}的确认,直接放行`).catch(() => { });
|
|
4055
4502
|
return true;
|
|
4056
4503
|
};
|
|
4057
|
-
//
|
|
4504
|
+
// subagent_manager_run 的目标人设是否真实存在。确认门是「问用户要不要做」,如果这个请求
|
|
4058
4505
|
// 本来就做不成(人设不存在),弹卡 / 写 bypass 日志只会产生一次无效审批:
|
|
4059
4506
|
// 用户批准之后模型收到的是「人设不可用」。所以先校验,再决定要不要问。
|
|
4060
4507
|
// 校验本身不可用时返回 true(保持原行为,绝不因为探测失败而少问一次)。
|
|
4061
|
-
const
|
|
4508
|
+
const subagentManagerRunTargetExists = async (exec) => {
|
|
4062
4509
|
try {
|
|
4063
4510
|
const name = String((exec && exec.arguments && exec.arguments.agent) || '').trim();
|
|
4064
4511
|
if (!name)
|
|
@@ -4073,14 +4520,14 @@ export default {
|
|
|
4073
4520
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
4074
4521
|
if (!exec || !CONFIRM_LABELS[String(exec.name)])
|
|
4075
4522
|
return next();
|
|
4076
|
-
if (exec.name === '
|
|
4523
|
+
if (exec.name === 'subagent_manager_run' && !(await subagentManagerRunTargetExists(exec)))
|
|
4077
4524
|
return next();
|
|
4078
4525
|
if (bypassedByFullAccess(exec))
|
|
4079
4526
|
return next();
|
|
4080
4527
|
if (exec.name === 'skill_manager_create') {
|
|
4081
4528
|
return Promise.resolve({ kind: 'ask', reason: 'Create a new skill under ~/.dsh/tool-management/skills' });
|
|
4082
4529
|
}
|
|
4083
|
-
if (exec.name === '
|
|
4530
|
+
if (exec.name === 'memory_manager_write') {
|
|
4084
4531
|
// D2:模型写规则默认需确认;设置关闭后直接放行。ask 无应答者时降级为拒绝(fail-closed),
|
|
4085
4532
|
// 不在此处做任何兜底放行。
|
|
4086
4533
|
return readPluginSettings()
|
|
@@ -4089,7 +4536,7 @@ export default {
|
|
|
4089
4536
|
: next()))
|
|
4090
4537
|
.catch(() => ({ kind: 'ask', reason: 'Write a memory under ~/.dsh/tool-management/memories' }));
|
|
4091
4538
|
}
|
|
4092
|
-
//
|
|
4539
|
+
// subagent_manager_run:子代理运行花真 token:默认确认(requireConfirmForModelSubagentRun !== false),可关。
|
|
4093
4540
|
return readPluginSettings()
|
|
4094
4541
|
.then((s) => (s.requireConfirmForModelSubagentRun !== false
|
|
4095
4542
|
? { kind: 'ask', reason: 'Run a subagent (consumes tokens)' }
|
|
@@ -4119,8 +4566,8 @@ export default {
|
|
|
4119
4566
|
try {
|
|
4120
4567
|
// MCP servers (de)register their tools as instances come and go; the
|
|
4121
4568
|
// visible-set restriction must track that — and so must the MCP state
|
|
4122
|
-
// section:
|
|
4123
|
-
//(用户切 MCP
|
|
4569
|
+
// section: 工具加载完成后若不重算,注入文本会一直停留在「空/旧集合」的缓存值上
|
|
4570
|
+
//(用户切 MCP 后立即看上下文注入就是空段 —— 2026-09-16 实测)。
|
|
4124
4571
|
ctx.effect(() => {
|
|
4125
4572
|
const stop = ctx.on('tools/change', () => {
|
|
4126
4573
|
scheduleToolRestrictions();
|