dsh-plugin-tool-management 0.8.0 → 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 +67 -1
- package/README.md +53 -48
- package/README_EN.md +47 -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 +39 -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 +431 -135
- package/lib/compat/preset-reach.js +155 -93
- package/lib/compat/probe.js +6 -4
- package/lib/context-inject.js +304 -0
- package/lib/hub.js +155 -13
- package/lib/imports/upload.js +34 -14
- package/lib/index.js +870 -226
- package/lib/mcp/state-section.js +46 -15
- package/lib/rules/archive-engine.js +182 -57
- package/lib/rules/archive.js +57 -1
- package/lib/rules/service.js +386 -156
- package/lib/scene-prompt-sync.js +101 -12
- package/lib/skills/catalog.js +125 -0
- package/lib/skills/core.js +38 -4
- package/lib/skills/service.js +36 -10
- package/lib/subagents/catalog.js +15 -5
- package/lib/subagents/service.js +39 -28
- package/lib/subagents/tools.js +18 -13
- package/package.json +14 -9
- 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
|
|
@@ -319,11 +363,15 @@ export default {
|
|
|
319
363
|
const raw = await readDisabledTools();
|
|
320
364
|
for (const [server, list] of Object.entries(raw))
|
|
321
365
|
out[server] = list.filter((t) => t !== '*');
|
|
366
|
+
// 「已知工具」缓存:未运行服务器也能按最后见过的名单计算补集。
|
|
367
|
+
for (const [server, list] of Object.entries(await readKnownMcpTools())) {
|
|
368
|
+
out[server] = [...new Set([...(out[server] || []), ...list.map((item) => item.name)])].sort();
|
|
369
|
+
}
|
|
322
370
|
let schemas = [];
|
|
323
371
|
try {
|
|
324
372
|
schemas = await tools.schemas();
|
|
325
373
|
}
|
|
326
|
-
catch { /* 无 live 工具 →
|
|
374
|
+
catch { /* 无 live 工具 → 仅停用表 + 缓存 */ }
|
|
327
375
|
for (const s of schemas) {
|
|
328
376
|
const p = toolKeyParts(String((s && s.name) || ''));
|
|
329
377
|
if (!p)
|
|
@@ -338,7 +386,7 @@ export default {
|
|
|
338
386
|
applyMcpEntries: async (entries) => {
|
|
339
387
|
const p = await ensurePaths();
|
|
340
388
|
await withWriteLock(async () => {
|
|
341
|
-
await writeJsonFile(
|
|
389
|
+
await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), entries);
|
|
342
390
|
});
|
|
343
391
|
disabledToolsCache = { at: Date.now(), value: entries };
|
|
344
392
|
scheduleToolRestrictions();
|
|
@@ -399,6 +447,31 @@ export default {
|
|
|
399
447
|
// 补丁改动由宿主热重载(实测 ≤5s)。这里**不等**,避免进入模式被拖住十几秒;
|
|
400
448
|
// 界面在应用完成后即可用,工具集合会在数秒内补齐。
|
|
401
449
|
},
|
|
450
|
+
// 场景备注(v0.8.1):备注写在 hub 内的 `mcp-notes.json`(按 loader id),
|
|
451
|
+
// 场景档案按 serverName 存 —— 引擎负责映射与读写,与 mcpm-note 同一条写锁。
|
|
452
|
+
currentMcpNotes: async () => ({ ...(await readNotes()) }),
|
|
453
|
+
applyMcpNotes: async (entries) => {
|
|
454
|
+
await withWriteLock(async () => {
|
|
455
|
+
const map = Object.assign({}, await readNotes(true));
|
|
456
|
+
for (const e of entries) {
|
|
457
|
+
if (e.note)
|
|
458
|
+
map[e.id] = e.note;
|
|
459
|
+
else
|
|
460
|
+
delete map[e.id];
|
|
461
|
+
}
|
|
462
|
+
const p = await ensurePaths();
|
|
463
|
+
await writeJsonFile(mcpSidecar(MCP_NOTES_FILE), map);
|
|
464
|
+
notesCache = { at: Date.now(), value: map };
|
|
465
|
+
});
|
|
466
|
+
},
|
|
467
|
+
mcpIdOfServer: async (serverName) => {
|
|
468
|
+
const r = await mcpmListView();
|
|
469
|
+
for (const row of ((r && r.rows) || [])) {
|
|
470
|
+
if (String(row.serverName || row.id) === serverName)
|
|
471
|
+
return String(row.id);
|
|
472
|
+
}
|
|
473
|
+
return undefined;
|
|
474
|
+
},
|
|
402
475
|
skillSourceStates: async () => {
|
|
403
476
|
const r = await skillsService.ops['skill-state']({});
|
|
404
477
|
const roots = (r && r.data && r.data.roots) || [];
|
|
@@ -428,8 +501,9 @@ export default {
|
|
|
428
501
|
},
|
|
429
502
|
// 人设名全集(保存 subagents 段时校验并报 stale,与 mcp/skills 两段同口径)。
|
|
430
503
|
knownPersonas: async () => new Set((await subagentService.list()).map((p) => p.name)),
|
|
431
|
-
//
|
|
504
|
+
// 人设开关读/写(进入模式:勾了的启用、未勾的停用;退出按快照两个方向还原)。
|
|
432
505
|
disabledPersonas: async (names) => subagentService.enabledStore.disabledAmong(names),
|
|
506
|
+
enabledPersonas: async (names) => subagentService.enabledStore.enabledAmong(names),
|
|
433
507
|
applySubagentSwitches: async (switches) => {
|
|
434
508
|
const off = switches.filter((s) => !s.enabled).map((s) => s.name);
|
|
435
509
|
const on = switches.filter((s) => s.enabled).map((s) => s.name);
|
|
@@ -446,9 +520,9 @@ export default {
|
|
|
446
520
|
},
|
|
447
521
|
});
|
|
448
522
|
// ---------- 轻量子智能体(设计 §3)----------
|
|
449
|
-
// 人设 = $DSH_HOME/tool-management/
|
|
523
|
+
// 人设 = $DSH_HOME/tool-management/subagents/<name>.md;运行走官方 ctx.subagents.start(spawn provider)。
|
|
450
524
|
// sceneLists 供场景绑定校验:启用场景(rules-list 的 active 行)档案里的 subagents 并集。
|
|
451
|
-
// stateDir 与 rules 共用同一个覆盖键(测试注入一致);子智能体开关存 <stateDir>/
|
|
525
|
+
// stateDir 与 rules 共用同一个覆盖键(测试注入一致);子智能体开关存 <stateDir>/subagents-index.json。
|
|
452
526
|
const subagentService = createSubagentService(ctx, { stateDir: String(config?.rulesStateDir || '') });
|
|
453
527
|
const subagentSceneLists = async () => {
|
|
454
528
|
const slice = await rulesService.readArchiveSlice();
|
|
@@ -459,13 +533,17 @@ export default {
|
|
|
459
533
|
.filter((s) => s.active)
|
|
460
534
|
.map((s) => slice.archives[s.name]?.subagents ?? []);
|
|
461
535
|
};
|
|
462
|
-
// ----------
|
|
463
|
-
//
|
|
464
|
-
//
|
|
536
|
+
// ---------- 注入通道(场景和记忆 / MCP / 技能 / 子智能体 / 提示词,各一条消息)----------
|
|
537
|
+
// 这些文本以前是 systemPrompt 段(persona complete 会整段压掉)。现在改走官方的
|
|
538
|
+
// 「每步注入一条合成消息」通道(skill-catalog / AGENTS.md / 时间上下文同款):
|
|
539
|
+
// 任何预设都到得了,内容没变不重发。**每个域一条自己的消息**(来源 kind 各不同,
|
|
540
|
+
// 轨迹里各自一行),见 src/context-inject.ts 的文件头。
|
|
541
|
+
// - 场景和记忆:场景名 + 场景说明 + 记忆正文(同一份文本;场景与记忆是绑定的)。
|
|
542
|
+
// - 人设:本插件自造的概念,宿主没有对应物 → 模型不知道有哪些人设,subagent_manager_run 不会被触发。
|
|
465
543
|
// - MCP:启停只改工具 schema,没有任何显式状态陈述 → 模型不知道有哪些 server,
|
|
466
544
|
// 更拿不到用户写的**备注**(那是写给「未来的模型」的决策提示,工具 schema 永远传达不到)。
|
|
467
|
-
//
|
|
468
|
-
// 口径与理由分别见 src/subagents/catalog.ts、src/mcp/state-section.ts
|
|
545
|
+
// 各域只列「名字 + 摘要」,正文/工具清单绝不进上下文。
|
|
546
|
+
// 口径与理由分别见 src/subagents/catalog.ts、src/mcp/state-section.ts。
|
|
469
547
|
const subagentCatalog = createSubagentCatalog({
|
|
470
548
|
list: () => subagentService.list(),
|
|
471
549
|
sceneLists: subagentSceneLists,
|
|
@@ -474,24 +552,136 @@ export default {
|
|
|
474
552
|
const mcpStateCatalog = createMcpStateCatalog({
|
|
475
553
|
rows: () => mcpmRowsWithNotes(false),
|
|
476
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
|
+
}
|
|
477
641
|
try {
|
|
478
642
|
ctx.effect(() => {
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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');
|
|
485
660
|
}
|
|
486
661
|
catch (e) {
|
|
487
|
-
console.error('[dsh-plugin-tool-management]
|
|
662
|
+
console.error('[dsh-plugin-tool-management] context injection setup failed:', message(e));
|
|
488
663
|
}
|
|
664
|
+
void readInjectSettings().catch(() => { });
|
|
489
665
|
// 预热放到下一轮事件循环:此时 apply 的同步初始化(补丁路径、tools 服务…)已全部完成,
|
|
490
666
|
// 避免在初始化中途就去读 MCP 补丁与工具 schema。
|
|
491
667
|
setTimeout(() => {
|
|
492
668
|
void subagentCatalog.warm();
|
|
493
669
|
void mcpStateCatalog.warm();
|
|
670
|
+
void skillCatalog.warm();
|
|
494
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
|
+
}
|
|
495
685
|
// 人设写操作成功后立即重算目录(SWR:text() 同步返回缓存值,写后主动 refresh)。
|
|
496
686
|
// 包装在 service 的 ops 上:写入口只有这一处,不需要在界面层逐个补。
|
|
497
687
|
for (const opName of subagentService.writeOps) {
|
|
@@ -508,7 +698,7 @@ export default {
|
|
|
508
698
|
/**
|
|
509
699
|
* 人设改名后同步场景绑定:场景档案的 `subagents` 名单存的是**人设名**,
|
|
510
700
|
* 不跟着改就会留一个悬空引用(界面把它报成 stale,用户看到「人设不存在」却找不到地方改)。
|
|
511
|
-
* 绑定存在
|
|
701
|
+
* 绑定存在 memories-index.json 里,人设服务看不到它,所以在这一层补一次(同提示词改名的做法)。
|
|
512
702
|
*/
|
|
513
703
|
async function rebindSubagentInArchives(from, to) {
|
|
514
704
|
try {
|
|
@@ -562,15 +752,21 @@ export default {
|
|
|
562
752
|
}
|
|
563
753
|
catch { /* 守卫读状态失败不拦正常流程(引擎自身校验兜底) */ }
|
|
564
754
|
const res = await baseSceneModeSet(args);
|
|
565
|
-
if (res && res.ok !== false)
|
|
755
|
+
if (res && res.ok !== false) {
|
|
756
|
+
// 进/退/切换模式会改 MCP 启停(服务器级)与备注(场景备注覆盖/恢复),
|
|
757
|
+
// 状态段必须立即重算 —— 场景退出后下一次请求就该看到恢复的全局备注与启停。
|
|
566
758
|
void subagentCatalog.refresh();
|
|
759
|
+
void mcpStateCatalog.refresh();
|
|
760
|
+
}
|
|
567
761
|
return res;
|
|
568
762
|
};
|
|
569
763
|
}
|
|
570
764
|
/**
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
765
|
+
* 模式进行中保存当前场景的档案时,联动人设开关:**档案 = 这个场景开着的人设**
|
|
766
|
+
* (与进入场景时同一口径,见 archive-engine)——
|
|
767
|
+
* 新勾进来的立即启用,并追加进快照的「退出时停回」名单;
|
|
768
|
+
* 取消勾选的立即停用;其中「进场景前就开着」的那些追加进快照的「退出时开回」名单
|
|
769
|
+
* (在停回名单里的说明是本次进场景才打开的,退出本来就该关,不进开回名单)。
|
|
574
770
|
*/
|
|
575
771
|
const baseSceneArchiveSave = archiveService.ops['scene-archive-save'];
|
|
576
772
|
if (typeof baseSceneArchiveSave === 'function') {
|
|
@@ -578,23 +774,30 @@ export default {
|
|
|
578
774
|
const res = await baseSceneArchiveSave(args);
|
|
579
775
|
try {
|
|
580
776
|
const bound = res && res.ok !== false && res.archive && Array.isArray(res.archive.subagents) ? res.archive.subagents : [];
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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 } } });
|
|
598
801
|
}
|
|
599
802
|
}
|
|
600
803
|
}
|
|
@@ -619,7 +822,7 @@ export default {
|
|
|
619
822
|
// last line of defense for plaintext credentials too, not just writes.
|
|
620
823
|
'mcpm-reveal',
|
|
621
824
|
'skill-open',
|
|
622
|
-
//
|
|
825
|
+
// 提示词写操作(create/update/remove 改预设库;apply 写全局 AGENTS.md;import 从外部内容建预设)
|
|
623
826
|
'agentsmd-create', 'agentsmd-update', 'agentsmd-apply', 'agentsmd-remove', 'agentsmd-import',
|
|
624
827
|
// 提示词预设的回收站(恢复 / 永久删除都是写)
|
|
625
828
|
'agentsmd-trash-restore', 'agentsmd-trash-delete',
|
|
@@ -630,6 +833,8 @@ export default {
|
|
|
630
833
|
// 通用导出:往用户指定的目录写文件,按写操作门禁(token)。
|
|
631
834
|
'bundle-export',
|
|
632
835
|
'history-archive-batch', 'history-workspace-register',
|
|
836
|
+
// 注入设置(五个域开关 / 压制型预设口径)写侧车,按写操作门禁。
|
|
837
|
+
'inject-settings',
|
|
633
838
|
]);
|
|
634
839
|
// 会泄露明文凭据 / 完整配置的 op:**必须**带对的访问令牌,没配令牌就一律拒绝
|
|
635
840
|
// (判定在 http-fence.ts 的 secretOpRejection,含两种情况的区分与理由)。
|
|
@@ -644,7 +849,7 @@ export default {
|
|
|
644
849
|
// 历史侧车三件套(归档时刻账本 / 保留期设置 / 工作区登记快照)统一放 hub 根
|
|
645
850
|
// (`$DSH_HOME/tool-management/`),**不放插件目录**:npm 安装下插件目录会被
|
|
646
851
|
// `dsh plugin update` 整体替换,放那里等于"升级即丢账本"——账本一丢,保留期基线
|
|
647
|
-
// 就从 archivedAt 退回 createdAt,归档会话会被提前清掉(v0.4
|
|
852
|
+
// 就从 archivedAt 退回 createdAt,归档会话会被提前清掉(v0.4 把提示词预设
|
|
648
853
|
// 搬进 hub 是同一条理由,见上方注释;这三个是当时漏掉的)。
|
|
649
854
|
// 旧位置(插件目录 data/)在启动时一次性搬入:只搬不删、绝不覆盖、失败下次再试。
|
|
650
855
|
const historyArchivedAtFile = String(config?.archivedAtFile || hubPath('history-archived-at.json'));
|
|
@@ -705,31 +910,39 @@ export default {
|
|
|
705
910
|
}
|
|
706
911
|
}
|
|
707
912
|
/**
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
913
|
+
* **应用提示词预设的唯一入口**(界面 op 与模型工具共用)。
|
|
914
|
+
*
|
|
915
|
+
* 场景接管期间(启用场景 / 全局绑定在驱动基线)只允许「重新应用场景绑定的那一份」:
|
|
916
|
+
* 应用别的预设会绕过场景绑定,于是「场景页显示 A、实际注入 B、A 不可删而 B 可删」
|
|
917
|
+
* 四套状态各说各话(用户实测)。同一份 = 修复(把被手改的基线写回去),走场景同步
|
|
918
|
+
* 而不是裸写 —— 同步会在写之前补上「进场景前的基线」快照,退出场景才恢复得回来。
|
|
919
|
+
* 换提示词的唯一正路在场景设置里改绑定(`rules-update-scene`,成功后同样同步基线)。
|
|
712
920
|
*/
|
|
713
|
-
async function
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
ctx.logger?.warn?.(`agents-md: scene prompt probe failed: ${message(e)}`);
|
|
722
|
-
}
|
|
723
|
-
try {
|
|
724
|
-
const cur = await agentsMdService.getCurrent();
|
|
725
|
-
if (cur && cur.ok && cur.presetId)
|
|
726
|
-
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
|
+
};
|
|
727
929
|
}
|
|
728
|
-
|
|
729
|
-
|
|
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 };
|
|
730
935
|
}
|
|
731
|
-
return
|
|
936
|
+
return agentsMdService.apply(id);
|
|
732
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
|
+
};
|
|
733
946
|
async function readHistoryRetention() {
|
|
734
947
|
try {
|
|
735
948
|
await historyStandby;
|
|
@@ -834,7 +1047,6 @@ export default {
|
|
|
834
1047
|
catch (e) { /* ignore */ }
|
|
835
1048
|
if (!home)
|
|
836
1049
|
throw new Error('无法确定 DSH 主目录(settings.prepareDocument 未返回路径)');
|
|
837
|
-
await migrateRenamedData(home);
|
|
838
1050
|
const sep = home.indexOf('\\') >= 0 ? '\\' : '/';
|
|
839
1051
|
let profileDir = null;
|
|
840
1052
|
let profileName = 'web';
|
|
@@ -881,33 +1093,9 @@ export default {
|
|
|
881
1093
|
return false;
|
|
882
1094
|
}
|
|
883
1095
|
}
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
// first boot pull them to the new names so an upgraded install keeps its
|
|
888
|
-
// data. Best effort — any failure just starts the new layout from scratch.
|
|
889
|
-
async function migrateRenamedData(home) {
|
|
890
|
-
const sep = home.indexOf('\\') >= 0 ? '\\' : '/';
|
|
891
|
-
const pairs = [
|
|
892
|
-
// 状态目录经历过两次改名:skill-mcp-manager → dsh-plugin-tool-management → tool-management
|
|
893
|
-
['skill-mcp-manager', 'tool-management'],
|
|
894
|
-
['dsh-plugin-tool-management', 'tool-management'],
|
|
895
|
-
['skill-mcp-manager-notes.json', 'dsh-plugin-tool-management-notes.json'],
|
|
896
|
-
['skill-mcp-manager-settings.json', 'dsh-plugin-tool-management-settings.json'],
|
|
897
|
-
['skill-mcp-manager-disabled-tools.json', 'dsh-plugin-tool-management-disabled-tools.json'],
|
|
898
|
-
['skill-mcp-manager-export.json', 'dsh-plugin-tool-management-export.json'],
|
|
899
|
-
];
|
|
900
|
-
for (const [oldName, newName] of pairs) {
|
|
901
|
-
try {
|
|
902
|
-
const oldAbs = home + sep + oldName;
|
|
903
|
-
const newAbs = home + sep + newName;
|
|
904
|
-
if (!(await exists(oldAbs)) || (await exists(newAbs)))
|
|
905
|
-
continue;
|
|
906
|
-
await rename(oldAbs, newAbs);
|
|
907
|
-
}
|
|
908
|
-
catch (e) { /* best effort */ }
|
|
909
|
-
}
|
|
910
|
-
}
|
|
1096
|
+
// 一次性数据迁移(旧插件名 / 旧侧车名 / 旧 hub 目录名)已统一收进 hub.ts 的
|
|
1097
|
+
// `migrateHubLayoutSync()`(在 apply() 开头、任何读盘之前同步跑一次)——
|
|
1098
|
+
// 这里不再维护第二份迁移表,避免两处口径漂移。
|
|
911
1099
|
async function readPatch(abs) {
|
|
912
1100
|
try {
|
|
913
1101
|
const t = await fs.resolve(abs);
|
|
@@ -928,17 +1116,27 @@ export default {
|
|
|
928
1116
|
const p = (n) => String(n).padStart(2, '0');
|
|
929
1117
|
return '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
|
|
930
1118
|
}
|
|
1119
|
+
// 改 patch 前的自动备份:写在 `hub/backups/`(不再堆在 `$DSH_HOME` 根下)。
|
|
1120
|
+
// 两个 patch 都叫 `cordis.patch.yml`(全局 + `profiles/<名字>/`),所以备份名里带上层级,
|
|
1121
|
+
// 否则两处在同一个目录里互相撞名、互相挤掉。最坏情况(配置写坏导致 DSH 起不来)
|
|
1122
|
+
// 按文档里的路径找回:`~/.dsh/tool-management/backups/`。
|
|
931
1123
|
async function backupPatchFile(abs, previous) {
|
|
932
1124
|
const i = Math.max(abs.lastIndexOf('\\'), abs.lastIndexOf('/'));
|
|
933
1125
|
if (i <= 0)
|
|
934
1126
|
return;
|
|
935
|
-
const dir = abs.slice(0, i);
|
|
936
1127
|
const base = abs.slice(i + 1);
|
|
937
|
-
|
|
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');
|
|
938
1134
|
try {
|
|
939
|
-
|
|
1135
|
+
// 旧位置的备份没有层级后缀(迁移搬进来的),只统计不删错别人的。
|
|
1136
|
+
const mine = (name) => name.startsWith(stem + '.bak-') || (tag === 'global' && name.startsWith(base + '.bak-'));
|
|
1137
|
+
const names = (await readdir(dir)).filter(mine).sort();
|
|
940
1138
|
for (const stale of names.slice(0, Math.max(0, names.length - KEEP_PATCH_BACKUPS))) {
|
|
941
|
-
await unlink(dir
|
|
1139
|
+
await unlink(join(dir, stale));
|
|
942
1140
|
}
|
|
943
1141
|
}
|
|
944
1142
|
catch (e) { /* pruning is best-effort */ }
|
|
@@ -1406,12 +1604,7 @@ export default {
|
|
|
1406
1604
|
// for display. Only the direct `properties` of the parameters object are
|
|
1407
1605
|
// summarized — nested object/array children are omitted (one level is enough
|
|
1408
1606
|
// for a preview, keeps the dialog readable).
|
|
1409
|
-
// ---------- plugin-owned side-car data (notes / settings) ----------
|
|
1410
|
-
// The patch file carries loader config only, so user-facing extras live in
|
|
1411
|
-
// small JSON files under the DSH home, next to the patch files they describe.
|
|
1412
|
-
function sidecarPath(home, name) {
|
|
1413
|
-
return home + (home.indexOf('\\') >= 0 ? '\\' : '/') + name;
|
|
1414
|
-
}
|
|
1607
|
+
// ---------- plugin-owned side-car data (notes / settings / disabled tools) ----------
|
|
1415
1608
|
async function readJsonFile(abs) {
|
|
1416
1609
|
try {
|
|
1417
1610
|
return JSON.parse(await readFile(abs, 'utf8'));
|
|
@@ -1430,13 +1623,24 @@ export default {
|
|
|
1430
1623
|
// hit the cache, but every WRITE re-reads the file (force=true) and merges
|
|
1431
1624
|
// on top of the fresh on-disk state. Without the forced re-read, a manual
|
|
1432
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())。
|
|
1433
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);
|
|
1434
1639
|
let notesCache = null;
|
|
1435
1640
|
async function readNotes(force = false) {
|
|
1436
1641
|
if (notesCache && !force && Date.now() - notesCache.at < SIDECAR_TTL_MS)
|
|
1437
1642
|
return notesCache.value;
|
|
1438
|
-
const
|
|
1439
|
-
const raw = await readJsonFile(sidecarPath(p.home, 'dsh-plugin-tool-management-notes.json'));
|
|
1643
|
+
const raw = await readJsonFile(mcpSidecar(MCP_NOTES_FILE));
|
|
1440
1644
|
const out = {};
|
|
1441
1645
|
if (raw && typeof raw === 'object') {
|
|
1442
1646
|
for (const key of Object.keys(raw)) {
|
|
@@ -1462,7 +1666,7 @@ export default {
|
|
|
1462
1666
|
else
|
|
1463
1667
|
delete map[id];
|
|
1464
1668
|
try {
|
|
1465
|
-
await writeJsonFile(
|
|
1669
|
+
await writeJsonFile(mcpSidecar(MCP_NOTES_FILE), map);
|
|
1466
1670
|
}
|
|
1467
1671
|
catch (e) {
|
|
1468
1672
|
return { ok: false, error: '备注保存失败: ' + message(e) };
|
|
@@ -1486,7 +1690,7 @@ export default {
|
|
|
1486
1690
|
if (pluginSettingsCache && !force && Date.now() - pluginSettingsCache.at < SIDECAR_TTL_MS)
|
|
1487
1691
|
return pluginSettingsCache.value;
|
|
1488
1692
|
const p = await ensurePaths();
|
|
1489
|
-
const raw = await readJsonFile(
|
|
1693
|
+
const raw = await readJsonFile(mcpSidecar(MCP_SETTINGS_FILE));
|
|
1490
1694
|
pluginSettingsCache = {
|
|
1491
1695
|
at: Date.now(),
|
|
1492
1696
|
value: {
|
|
@@ -1522,7 +1726,7 @@ export default {
|
|
|
1522
1726
|
const p = await ensurePaths();
|
|
1523
1727
|
return withWriteLock(async () => {
|
|
1524
1728
|
try {
|
|
1525
|
-
await writeJsonFile(
|
|
1729
|
+
await writeJsonFile(mcpSidecar(MCP_SETTINGS_FILE), next);
|
|
1526
1730
|
}
|
|
1527
1731
|
catch (e) {
|
|
1528
1732
|
return { ok: false, error: '设置保存失败: ' + message(e) };
|
|
@@ -1537,13 +1741,12 @@ export default {
|
|
|
1537
1741
|
// every scope's model-visible schema list, and `tools.guard` denies the call
|
|
1538
1742
|
// before the body runs. Disabled tools therefore become invisible AND
|
|
1539
1743
|
// uncallable — no patch rewrite, no DSH restart.
|
|
1540
|
-
const DISABLED_TOOLS_FILE = 'dsh-plugin-tool-management-disabled-tools.json';
|
|
1541
1744
|
let disabledToolsCache = null;
|
|
1542
1745
|
async function readDisabledTools(force = false) {
|
|
1543
1746
|
if (disabledToolsCache && !force && Date.now() - disabledToolsCache.at < SIDECAR_TTL_MS)
|
|
1544
1747
|
return disabledToolsCache.value;
|
|
1545
1748
|
const p = await ensurePaths();
|
|
1546
|
-
const raw = await readJsonFile(
|
|
1749
|
+
const raw = await readJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE));
|
|
1547
1750
|
const out = {};
|
|
1548
1751
|
if (raw && typeof raw === 'object') {
|
|
1549
1752
|
for (const serverName of Object.keys(raw)) {
|
|
@@ -1569,6 +1772,33 @@ export default {
|
|
|
1569
1772
|
}
|
|
1570
1773
|
return out;
|
|
1571
1774
|
}
|
|
1775
|
+
// ---------- 「已知工具」侧车(v0.8.1)-----------------------------------
|
|
1776
|
+
// MCP 工具名只在服务器**运行**时可见(tools.schemas() 里才有)。未运行的服务器在
|
|
1777
|
+
// 场景档案里既显示 0 工具、工具明细也空空如也 —— 用户没法给未运行服务器挑工具。
|
|
1778
|
+
// 每次枚举到 live 工具时按 serverName 记一份「最后见过的工具名」,未运行时用它兜底:
|
|
1779
|
+
// 场景档案能列出/勾选,进入场景后按实际注册的工具生效(restrict 会过滤掉不存在的名字)。
|
|
1780
|
+
let knownMcpToolsCache = null;
|
|
1781
|
+
async function readKnownMcpTools(force = false) {
|
|
1782
|
+
if (knownMcpToolsCache && !force && Date.now() - knownMcpToolsCache.at < SIDECAR_TTL_MS)
|
|
1783
|
+
return knownMcpToolsCache.value;
|
|
1784
|
+
const p = await ensurePaths();
|
|
1785
|
+
const raw = await readJsonFile(mcpSidecar(MCP_KNOWN_TOOLS_FILE));
|
|
1786
|
+
const out = {};
|
|
1787
|
+
if (raw && typeof raw === 'object') {
|
|
1788
|
+
for (const serverName of Object.keys(raw)) {
|
|
1789
|
+
const list = normalizeKnownTools(raw[serverName]);
|
|
1790
|
+
if (list.length)
|
|
1791
|
+
out[serverName] = list;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
knownMcpToolsCache = { at: Date.now(), value: out };
|
|
1795
|
+
return out;
|
|
1796
|
+
}
|
|
1797
|
+
async function writeKnownMcpTools(value) {
|
|
1798
|
+
const p = await ensurePaths();
|
|
1799
|
+
await writeJsonFile(mcpSidecar(MCP_KNOWN_TOOLS_FILE), value);
|
|
1800
|
+
knownMcpToolsCache = { at: Date.now(), value: value };
|
|
1801
|
+
}
|
|
1572
1802
|
/** 单工具停用判定(含整服务器通配 `*`——场景档案的"MCP 工具集=整台"写的就是它)。 */
|
|
1573
1803
|
function isToolDisabled(map, fullName) {
|
|
1574
1804
|
if (!fullName.startsWith('mcp__'))
|
|
@@ -1606,7 +1836,7 @@ export default {
|
|
|
1606
1836
|
else
|
|
1607
1837
|
delete map[serverName];
|
|
1608
1838
|
try {
|
|
1609
|
-
await writeJsonFile(
|
|
1839
|
+
await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), map);
|
|
1610
1840
|
}
|
|
1611
1841
|
catch (e) {
|
|
1612
1842
|
return { ok: false, error: '保存失败: ' + message(e) };
|
|
@@ -1799,8 +2029,90 @@ export default {
|
|
|
1799
2029
|
toolsList.push({ name, description: '(已停用;描述暂不可用)', enabled: false, parameters: [] });
|
|
1800
2030
|
}
|
|
1801
2031
|
}
|
|
2032
|
+
// 「已知工具」缓存兜底:服务器没运行时 schemas() 里没有它任何工具 —— 场景档案里
|
|
2033
|
+
// 想给未运行服务器挑工具就全靠这份最后见过的名单。描述也一并带上(v0.9.0 起缓存),
|
|
2034
|
+
// 并标 `stale: true`:界面据此说明「上次运行时」,不假装是实时数据。
|
|
2035
|
+
for (const known of (await readKnownMcpTools())[serverName] || []) {
|
|
2036
|
+
if (listed.has(known.name))
|
|
2037
|
+
continue;
|
|
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);
|
|
2046
|
+
}
|
|
1802
2047
|
return { ok: true, tools: toolsList };
|
|
1803
2048
|
}
|
|
2049
|
+
/**
|
|
2050
|
+
* 临时启动服务器以枚举工具(v0.8.1):从未运行过的服务器在 schemas() 与「已知工具」
|
|
2051
|
+
* 缓存里都没有工具名,场景档案勾选器对它一无所知。这里**临时**把它启用(写补丁),
|
|
2052
|
+
* 轮询等它的工具注册(npx 冷启动要下载,上限 30s),拿到名单后就**恢复原启停状态**——
|
|
2053
|
+
* 用户环境的服务器开关不受影响。已有已知工具时直接返回现有清单,不做任何改动。
|
|
2054
|
+
*/
|
|
2055
|
+
async function mcpmToolsRefresh(args) {
|
|
2056
|
+
const serverName = String((args && args.serverName) || '').trim();
|
|
2057
|
+
if (!serverName)
|
|
2058
|
+
return { ok: false, error: 'serverName 不能为空' };
|
|
2059
|
+
const existing = await mcpmTools({ serverName });
|
|
2060
|
+
if (existing && existing.ok === false)
|
|
2061
|
+
return existing;
|
|
2062
|
+
if (existing && (existing.tools || []).length)
|
|
2063
|
+
return existing;
|
|
2064
|
+
const list = await mcpmListView();
|
|
2065
|
+
const row = ((list && list.rows) || []).find((r) => String(r.serverName) === serverName);
|
|
2066
|
+
if (!row)
|
|
2067
|
+
return { ok: false, error: `未找到服务器: ${serverName}` };
|
|
2068
|
+
if (row.level !== 'global' && row.level !== 'project') {
|
|
2069
|
+
return { ok: false, error: `该服务器不驻留在补丁文件(level=${row.level}),无法临时启动;请先在 MCP 页启用它一次以记录工具` };
|
|
2070
|
+
}
|
|
2071
|
+
const wasDisabled = !!row.disabled;
|
|
2072
|
+
try {
|
|
2073
|
+
if (wasDisabled) {
|
|
2074
|
+
const r = await mcpmSetEnabled({ id: row.id, level: row.level, enabled: true });
|
|
2075
|
+
if (r && r.ok === false)
|
|
2076
|
+
return r;
|
|
2077
|
+
}
|
|
2078
|
+
const deadline = Date.now() + 30000;
|
|
2079
|
+
for (;;) {
|
|
2080
|
+
const t = await mcpmTools({ serverName });
|
|
2081
|
+
if (t && t.ok && (t.tools || []).length) {
|
|
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) {
|
|
2087
|
+
try {
|
|
2088
|
+
const cache = await readKnownMcpTools();
|
|
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));
|
|
2095
|
+
await writeKnownMcpTools(cache);
|
|
2096
|
+
}
|
|
2097
|
+
catch { /* 缓存写失败不影响本次返回 */ }
|
|
2098
|
+
}
|
|
2099
|
+
return t;
|
|
2100
|
+
}
|
|
2101
|
+
if (Date.now() >= deadline)
|
|
2102
|
+
break;
|
|
2103
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
2104
|
+
}
|
|
2105
|
+
return { ok: false, error: `等待超时:${serverName} 在 30 秒内没有注册任何工具(服务器可能启动失败或连接过慢)` };
|
|
2106
|
+
}
|
|
2107
|
+
finally {
|
|
2108
|
+
if (wasDisabled) {
|
|
2109
|
+
const r = await mcpmSetEnabled({ id: row.id, level: row.level, enabled: false });
|
|
2110
|
+
if (!r || r.ok === false)
|
|
2111
|
+
console.warn('[dsh-plugin-tool-management] mcpm-tools-refresh: 恢复服务器停用状态失败', serverName);
|
|
2112
|
+
}
|
|
2113
|
+
void mcpStateCatalog.refresh();
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
1804
2116
|
async function mcpmList() {
|
|
1805
2117
|
const p = await ensurePaths();
|
|
1806
2118
|
const rows = [];
|
|
@@ -1820,25 +2132,82 @@ export default {
|
|
|
1820
2132
|
rows.push(normalizeRow(r, level, abs));
|
|
1821
2133
|
}
|
|
1822
2134
|
const toolCounts = {};
|
|
2135
|
+
const enabledToolCounts = {};
|
|
1823
2136
|
try {
|
|
1824
2137
|
const schemas = await tools.schemas();
|
|
2138
|
+
const knownCache = await readKnownMcpTools();
|
|
2139
|
+
const disabledMap = await readDisabledTools();
|
|
2140
|
+
const liveNames = {};
|
|
2141
|
+
const liveTools = {};
|
|
1825
2142
|
const seen = new Set();
|
|
1826
2143
|
for (const s of schemas) {
|
|
1827
2144
|
const fullName = String(s && s.name || '');
|
|
1828
|
-
seen.add(fullName);
|
|
1829
|
-
const m = fullName.match(/^mcp__([A-Za-z0-9_-]+)__/);
|
|
1830
|
-
if (m)
|
|
1831
|
-
toolCounts[m[1]] = (toolCounts[m[1]] || 0) + 1;
|
|
1832
|
-
}
|
|
1833
|
-
// Restricted tools may be absent from schemas(); they still belong to
|
|
1834
|
-
// the per-server count so the UI does not show a phantom drop.
|
|
1835
|
-
for (const fullName of disabledToolNames(await readDisabledTools())) {
|
|
1836
2145
|
if (seen.has(fullName))
|
|
1837
2146
|
continue;
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
2147
|
+
seen.add(fullName);
|
|
2148
|
+
const m = fullName.match(/^mcp__([A-Za-z0-9_-]+)__(.+)$/);
|
|
2149
|
+
if (!m)
|
|
2150
|
+
continue;
|
|
2151
|
+
const list = liveNames[m[1]] || (liveNames[m[1]] = []);
|
|
2152
|
+
if (list.indexOf(m[2]) < 0)
|
|
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 } : {}) });
|
|
2159
|
+
}
|
|
2160
|
+
// 工具数 = live ∪ 停用表里的单个工具名 ∪ 「已知工具」缓存 —— 未运行的服务器
|
|
2161
|
+
// 也能显示最后一次见过的工具数,而不是永远 0。
|
|
2162
|
+
const union = {};
|
|
2163
|
+
const addTool = (server, tool) => {
|
|
2164
|
+
if (!server || !tool)
|
|
2165
|
+
return;
|
|
2166
|
+
const set = union[server] || (union[server] = new Set());
|
|
2167
|
+
set.add(tool);
|
|
2168
|
+
};
|
|
2169
|
+
for (const [server, list] of Object.entries(liveNames))
|
|
2170
|
+
for (const t of list)
|
|
2171
|
+
addTool(server, t);
|
|
2172
|
+
for (const [server, list] of Object.entries(disabledMap))
|
|
2173
|
+
for (const t of list)
|
|
2174
|
+
if (t !== '*')
|
|
2175
|
+
addTool(server, t);
|
|
2176
|
+
for (const [server, list] of Object.entries(knownCache))
|
|
2177
|
+
for (const t of list)
|
|
2178
|
+
addTool(server, t.name);
|
|
2179
|
+
for (const [server, set] of Object.entries(union)) {
|
|
2180
|
+
toolCounts[server] = set.size;
|
|
2181
|
+
// 可用数 = 已知工具里未被停用表扣减的(`*` = 整台停用 → 0)。场景档案收窄与
|
|
2182
|
+
// 手动逐工具开关写的是同一张停用表,段与页面的「N 个工具」都必须按它扣减,
|
|
2183
|
+
// 否则收窄后模型看到的数字比它能调的工具多,页面与详情页也会互相矛盾。
|
|
2184
|
+
enabledToolCounts[server] = countEnabledTools(set, disabledMap[server] || []);
|
|
2185
|
+
}
|
|
2186
|
+
// 回写「已知工具」:live 见到的名字与描述并入缓存(有变化才写盘,读路径上的 best-effort)。
|
|
2187
|
+
// 名字集合变化,或某个已知工具这次拿到了描述而缓存里没有 → 都算变化。
|
|
2188
|
+
let cacheChanged = false;
|
|
2189
|
+
for (const [server, list] of Object.entries(liveTools)) {
|
|
2190
|
+
const prev = knownCache[server] || [];
|
|
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
|
+
}
|
|
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;
|
|
1841
2208
|
}
|
|
2209
|
+
if (cacheChanged)
|
|
2210
|
+
void writeKnownMcpTools(knownCache).catch(() => { });
|
|
1842
2211
|
}
|
|
1843
2212
|
catch (e) { /* ignore */ }
|
|
1844
2213
|
let live = [];
|
|
@@ -1852,14 +2221,23 @@ export default {
|
|
|
1852
2221
|
for (const e of live) {
|
|
1853
2222
|
const bid = bareEntryId(e.entryId);
|
|
1854
2223
|
const found = rows.find((r) => r.id === bid);
|
|
1855
|
-
if (found)
|
|
1856
|
-
|
|
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
|
+
}
|
|
1857
2233
|
else
|
|
1858
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 } });
|
|
1859
2235
|
}
|
|
1860
2236
|
for (const row of rows) {
|
|
1861
2237
|
if (row.toolCount === undefined)
|
|
1862
2238
|
row.toolCount = toolCounts[row.serverName] || 0;
|
|
2239
|
+
if (row.enabledToolCount === undefined)
|
|
2240
|
+
row.enabledToolCount = enabledToolCounts[row.serverName] || 0;
|
|
1863
2241
|
}
|
|
1864
2242
|
// A loader id that appears twice (same id in both patch files, or twice in
|
|
1865
2243
|
// one) makes the composition fail to boot. Surface it instead of hiding it.
|
|
@@ -1924,7 +2302,10 @@ export default {
|
|
|
1924
2302
|
}
|
|
1925
2303
|
const before = content;
|
|
1926
2304
|
content = appendBlock(content, buildInsertBlock(row));
|
|
1927
|
-
|
|
2305
|
+
// v0.8.5:新增服务器默认不启动(用户裁定,与技能 / 子智能体同口径);
|
|
2306
|
+
// 显式传 enabled: true 才创建即启动。
|
|
2307
|
+
const defaultDisabled = args.enabled !== true;
|
|
2308
|
+
if (defaultDisabled)
|
|
1928
2309
|
content = appendBlock(content, buildDisableBlock(id, true));
|
|
1929
2310
|
const guard = duplicateGuard(before, content);
|
|
1930
2311
|
if (guard)
|
|
@@ -1935,7 +2316,7 @@ export default {
|
|
|
1935
2316
|
catch (e) {
|
|
1936
2317
|
return { ok: false, error: '写入补丁失败: ' + message(e) };
|
|
1937
2318
|
}
|
|
1938
|
-
return { ok: true, row: { ...row, level, disabled:
|
|
2319
|
+
return { ok: true, row: { ...row, level, disabled: defaultDisabled } };
|
|
1939
2320
|
});
|
|
1940
2321
|
}
|
|
1941
2322
|
async function mcpmEdit(args) {
|
|
@@ -1982,7 +2363,7 @@ export default {
|
|
|
1982
2363
|
toolMap[serverName] = toolMap[cur.serverName];
|
|
1983
2364
|
delete toolMap[cur.serverName];
|
|
1984
2365
|
try {
|
|
1985
|
-
await writeJsonFile(
|
|
2366
|
+
await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), toolMap);
|
|
1986
2367
|
}
|
|
1987
2368
|
catch (e) { /* non-fatal */ }
|
|
1988
2369
|
disabledToolsCache = { at: Date.now(), value: toolMap };
|
|
@@ -2269,7 +2650,7 @@ export default {
|
|
|
2269
2650
|
const json = JSON.stringify({ exportedAt: new Date().toISOString(), rows }, null, 2);
|
|
2270
2651
|
let savedTo = null;
|
|
2271
2652
|
try {
|
|
2272
|
-
const abs =
|
|
2653
|
+
const abs = mcpSidecar(MCP_EXPORT_FILE);
|
|
2273
2654
|
await writePatch(abs, json);
|
|
2274
2655
|
savedTo = abs;
|
|
2275
2656
|
}
|
|
@@ -2484,6 +2865,24 @@ export default {
|
|
|
2484
2865
|
}
|
|
2485
2866
|
const sessionPaths = registry.sessionPaths instanceof Map ? registry.sessionPaths : undefined;
|
|
2486
2867
|
const headers = registry.headers instanceof Map ? registry.headers : undefined;
|
|
2868
|
+
// 「目录真实存在」检查缓存:canRegister 从「宿主 sessionPaths 命中」放宽为
|
|
2869
|
+
// 「目录存在」时,同一目录只 stat 一次;结果按归一化键缓存,避免每次刷新重复探盘。
|
|
2870
|
+
const dirExistsCache = new Map();
|
|
2871
|
+
async function directoryExists(p) {
|
|
2872
|
+
const k = workspacePathKey(p);
|
|
2873
|
+
const hit = dirExistsCache.get(k);
|
|
2874
|
+
if (hit !== undefined)
|
|
2875
|
+
return hit;
|
|
2876
|
+
let ok = false;
|
|
2877
|
+
try {
|
|
2878
|
+
ok = (await stat(p)).isDirectory();
|
|
2879
|
+
}
|
|
2880
|
+
catch {
|
|
2881
|
+
ok = false;
|
|
2882
|
+
}
|
|
2883
|
+
dirExistsCache.set(k, ok);
|
|
2884
|
+
return ok;
|
|
2885
|
+
}
|
|
2487
2886
|
for (const it of items) {
|
|
2488
2887
|
const sid = it.sessionId;
|
|
2489
2888
|
let wid = owned.get(sid);
|
|
@@ -2513,17 +2912,21 @@ export default {
|
|
|
2513
2912
|
const known = snapshot ? snapshot.get(workspacePathKey(path)) : undefined;
|
|
2514
2913
|
const existing = groupsById.get(key);
|
|
2515
2914
|
if (existing) {
|
|
2516
|
-
//
|
|
2517
|
-
if (canonical !== undefined)
|
|
2915
|
+
// 同目录的多个会话:宿主索引命中,或磁盘目录真实存在,即可重新登记。
|
|
2916
|
+
if (canonical !== undefined || (await directoryExists(path))) {
|
|
2518
2917
|
existing.canRegister = true;
|
|
2918
|
+
existing.dirMissing = false;
|
|
2919
|
+
}
|
|
2519
2920
|
continue;
|
|
2520
2921
|
}
|
|
2922
|
+
const canRegister = canonical !== undefined || (await directoryExists(path));
|
|
2521
2923
|
const view = {
|
|
2522
2924
|
id: key,
|
|
2523
2925
|
title: known?.title || workspaceBaseName(path) || path,
|
|
2524
2926
|
kind: 'detached',
|
|
2525
2927
|
registered: known !== undefined,
|
|
2526
|
-
canRegister
|
|
2928
|
+
canRegister,
|
|
2929
|
+
dirMissing: !canRegister,
|
|
2527
2930
|
path: known?.path || path,
|
|
2528
2931
|
};
|
|
2529
2932
|
groupsById.set(key, view);
|
|
@@ -2648,7 +3051,7 @@ export default {
|
|
|
2648
3051
|
* 单个预设的工具名(列表里含 MCP 与宿主平面的工具,因为子代理的可见集合是
|
|
2649
3052
|
* "宿主平面 ∪ 该预设"的并集)。枚举会为该预设建立 standing mount —— 官方语义:
|
|
2650
3053
|
* 一个预设每进程只挂一次,正常创建会话时同样会挂,所以这里按 id 缓存 60 秒;
|
|
2651
|
-
* `
|
|
3054
|
+
* `subagent_manager_run` 每次委派都要用它校验名单,不能每次都重新枚举。
|
|
2652
3055
|
*/
|
|
2653
3056
|
const presetNamesCache = new Map();
|
|
2654
3057
|
async function presetToolNames(id) {
|
|
@@ -2779,6 +3182,7 @@ export default {
|
|
|
2779
3182
|
'mcpm-tool-enabled': mcpmToolEnabled,
|
|
2780
3183
|
'skill-open': skillOpen,
|
|
2781
3184
|
'mcpm-tools': mcpmTools,
|
|
3185
|
+
'mcpm-tools-refresh': mcpmToolsRefresh,
|
|
2782
3186
|
'mcpm-add': mcpmAdd,
|
|
2783
3187
|
'mcpm-edit': mcpmEdit,
|
|
2784
3188
|
'mcpm-set-enabled': mcpmSetEnabled,
|
|
@@ -2834,14 +3238,19 @@ export default {
|
|
|
2834
3238
|
if (!name || seen.has(name))
|
|
2835
3239
|
continue;
|
|
2836
3240
|
seen.add(name);
|
|
3241
|
+
const live = !!(row.live && row.live.enabled);
|
|
2837
3242
|
mcpServers.push({
|
|
2838
3243
|
name,
|
|
2839
3244
|
level: row.level || null,
|
|
2840
|
-
live
|
|
3245
|
+
live,
|
|
2841
3246
|
serverDisabled: !!row.disabled,
|
|
2842
3247
|
// 停用表里的 ['*'] = 整台工具停用(勾选器据此预勾,避免把"全停"读成"全启用")。
|
|
2843
3248
|
allToolsDisabled: (disabledRaw[name] || []).indexOf('*') >= 0,
|
|
2844
|
-
|
|
3249
|
+
// 未运行且没有任何已知工具 ⇒ null(客户端显示「工具数未知」并引导读取),
|
|
3250
|
+
// 不再给出误导性的 0。
|
|
3251
|
+
toolCount: live || Number(row.toolCount) > 0
|
|
3252
|
+
? (typeof row.toolCount === 'number' ? row.toolCount : null)
|
|
3253
|
+
: null,
|
|
2845
3254
|
});
|
|
2846
3255
|
}
|
|
2847
3256
|
return {
|
|
@@ -2885,11 +3294,20 @@ export default {
|
|
|
2885
3294
|
// 场景驱动时**只有场景绑的那份**算生效中(用户裁定);文件里那份降级为「文件里是它」,
|
|
2886
3295
|
// 因为场景的提示词已经接管基线。没有场景驱动时才按文件比对定「生效中」。
|
|
2887
3296
|
const sceneDrives = sceneId !== null;
|
|
3297
|
+
// 引用清单与删除保护同源(scene-prompt-sync):界面据此禁用删除并说明「谁在用」,
|
|
3298
|
+
// 不必自己再拼一遍判定(那正是这次四处状态各说各话的根源)。探测失败 = 空清单。
|
|
3299
|
+
const refs = await scenePromptSync.refs();
|
|
2888
3300
|
const presets = base.presets.map((p) => {
|
|
2889
3301
|
const fileApplied = p.active === true;
|
|
2890
3302
|
const byScene = sceneDrives && sceneId === p.id;
|
|
2891
3303
|
const active = sceneDrives ? byScene : fileApplied;
|
|
2892
|
-
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
|
+
};
|
|
2893
3311
|
});
|
|
2894
3312
|
return { ...base, presets, ...(scenePrompt ? { scenePrompt } : {}) };
|
|
2895
3313
|
},
|
|
@@ -2901,7 +3319,7 @@ export default {
|
|
|
2901
3319
|
...(args && typeof args.description === 'string' ? { description: String(args.description) } : {}),
|
|
2902
3320
|
}),
|
|
2903
3321
|
// 保存:改正文 + 可选改名(nextId)。改名成功顺带把场景绑定一起改名 ——
|
|
2904
|
-
// 绑定存在
|
|
3322
|
+
// 绑定存在 memories-index.json 里,预设库自己看不到,不叫这一声就会留悬空绑定。
|
|
2905
3323
|
// 保存后同步一次全局基线:若改的正是「正在驱动基线的那份」,正文要跟着写进
|
|
2906
3324
|
// `~/.dsh/AGENTS.md`(否则页面标着「生效中」而文件里还是旧内容)。
|
|
2907
3325
|
'agentsmd-update': async (args) => {
|
|
@@ -2920,24 +3338,37 @@ export default {
|
|
|
2920
3338
|
}
|
|
2921
3339
|
return withAgentsMdSync({ ...res, ...renamed });
|
|
2922
3340
|
},
|
|
2923
|
-
|
|
3341
|
+
// 应用 = 写全局基线文件;场景接管期间只放行「场景绑定的那一份」(见 applyPresetGuarded)。
|
|
3342
|
+
'agentsmd-apply': (args) => applyPresetGuarded(String((args && args.id) || '')),
|
|
2924
3343
|
'agentsmd-get-current': () => agentsMdService.getCurrent(),
|
|
2925
|
-
//
|
|
2926
|
-
//
|
|
2927
|
-
//
|
|
3344
|
+
// 删除:**被引用的那份一律拒绝**(用户裁定,2026-09-16 扩到引用清单)。三类引用:
|
|
3345
|
+
// 场景绑定(含恒常的 `_shared` / `global`)、`~/.dsh/AGENTS.md` 的当前内容、
|
|
3346
|
+
// 进场景前保存的基线(退出场景后要恢复的那一份)。拒绝时逐条说明「谁在用」,
|
|
3347
|
+
// 用户知道该先改哪里。引用清单由 scene-prompt-sync 与显示/注入同源算出。
|
|
3348
|
+
// 探测失败(refs() 返回 null)时放行并记 warn:删预设不动 ~/.dsh/AGENTS.md,
|
|
3349
|
+
// 最坏是少一份副本,可重建 —— 不因一次探测故障把删除堵死。
|
|
2928
3350
|
'agentsmd-remove': async (args) => {
|
|
2929
3351
|
const id = String((args && args.id) || '');
|
|
2930
|
-
const
|
|
2931
|
-
if (
|
|
2932
|
-
|
|
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
|
+
};
|
|
2933
3362
|
}
|
|
2934
3363
|
return agentsMdService.remove(id);
|
|
2935
3364
|
},
|
|
2936
3365
|
'agentsmd-import': (args) => agentsMdService.importPreset(String((args && args.id) || ''), String((args && args.content) ?? ''), args && typeof args.description === 'string' ? String(args.description) : undefined),
|
|
2937
|
-
// 提示词预设的回收站:删预设 = 把整个预设目录移入 `hub/trash/
|
|
3366
|
+
// 提示词预设的回收站:删预设 = 把整个预设目录移入 `hub/trash/prompts-trash/<id>/`,
|
|
2938
3367
|
// 误删可从「提示词」页的回收站里恢复(同 id 已存在时拒绝恢复,绝不覆盖)。
|
|
2939
3368
|
'agentsmd-trash-list': () => agentsMdService.trashList(),
|
|
2940
|
-
|
|
3369
|
+
// 恢复也要同步:把一个「场景绑定着、但曾被删掉」的预设恢复回来 → 绑定重新变活,
|
|
3370
|
+
// 基线要跟着写回它(否则场景页说「生效中:它」而文件里并不是)。
|
|
3371
|
+
'agentsmd-trash-restore': async (args) => withAgentsMdSync(await agentsMdService.trashRestore(String((args && args.id) || ''))),
|
|
2941
3372
|
'agentsmd-trash-delete': (args) => agentsMdService.trashDelete(String((args && args.id) || '')),
|
|
2942
3373
|
// History(归档会话管理)ops。只读:history-list / history-retention-get;
|
|
2943
3374
|
// 写:history-archive / history-unarchive / history-delete / history-retention-set。
|
|
@@ -3152,7 +3583,8 @@ export default {
|
|
|
3152
3583
|
},
|
|
3153
3584
|
// 预设可达性矩阵:每个 Agent 预设下,本插件的注入类能力到不到得了模型。
|
|
3154
3585
|
// 只读预设组合文本,不挂载任何预设、不改任何数据。回答的是"面板上说注入了,
|
|
3155
|
-
// 模型真的看得到吗" ——
|
|
3586
|
+
// 模型真的看得到吗" —— 压制型预设(persona complete / includeRuntimeContext:false,
|
|
3587
|
+
// 如极简)默认不注入,可在「注入」设置里改成强制注入。
|
|
3156
3588
|
'preset-reach': async () => {
|
|
3157
3589
|
try {
|
|
3158
3590
|
// 宿主平面的 MCP 工具数:四个官方预设都不在组合里挂 MCP,MCP 由
|
|
@@ -3167,13 +3599,19 @@ export default {
|
|
|
3167
3599
|
catch {
|
|
3168
3600
|
mcpTools = undefined;
|
|
3169
3601
|
}
|
|
3170
|
-
const
|
|
3602
|
+
const settings = await readInjectSettings();
|
|
3603
|
+
const report = await assessPresetReach(presetRoster(), {
|
|
3604
|
+
mcpTools,
|
|
3605
|
+
inject: { underSuppressingPresets: settings.underSuppressingPresets, domains: settings.domains },
|
|
3606
|
+
});
|
|
3171
3607
|
return { ok: true, ...report };
|
|
3172
3608
|
}
|
|
3173
3609
|
catch (e) {
|
|
3174
3610
|
return { ok: false, error: message(e) };
|
|
3175
3611
|
}
|
|
3176
3612
|
},
|
|
3613
|
+
// 注入设置(读 / 写):压制型预设下是否仍然注入 + 各域开关。界面在「兼容」页。
|
|
3614
|
+
'inject-settings': (args) => injectSettingsOp(args),
|
|
3177
3615
|
'history-unarchive': async (args) => {
|
|
3178
3616
|
const registry = getHistoryRegistry();
|
|
3179
3617
|
if (!registry)
|
|
@@ -3356,7 +3794,7 @@ export default {
|
|
|
3356
3794
|
}
|
|
3357
3795
|
}
|
|
3358
3796
|
else if (kind === 'memories') {
|
|
3359
|
-
// 记忆有 flat(`<场景>/<name>.md`)与 bundle(`<场景>/<name
|
|
3797
|
+
// 记忆有 flat(`<场景>/<name>.md`)与 bundle(`<场景>/<name>/<name>.md`)两种形态,
|
|
3360
3798
|
// 且场景名本身可含 '/' —— 按 id 拼路径会把 bundle 读成 `<场景>/<name>.md`,读不到
|
|
3361
3799
|
// 就静默丢项(详见 planMemoryExport)。所以走索引拿那条规则自己的真实路径。
|
|
3362
3800
|
const r = await rulesService.ops['rules-list']({});
|
|
@@ -3539,7 +3977,7 @@ export default {
|
|
|
3539
3977
|
// 覆盖所有会改变「哪些 server 可用 / 有哪些备注」的写入口 —— 集中在这一处包,
|
|
3540
3978
|
// 不需要在界面层逐个补(漏一个就会出现「改了但模型看不到」的静默不一致)。
|
|
3541
3979
|
for (const opName of ['mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all',
|
|
3542
|
-
'mcpm-restart', 'mcpm-note', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact']) {
|
|
3980
|
+
'mcpm-restart', 'mcpm-note', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact', 'mcpm-tools-refresh']) {
|
|
3543
3981
|
const original = handlers[opName];
|
|
3544
3982
|
if (typeof original !== 'function')
|
|
3545
3983
|
continue;
|
|
@@ -3561,6 +3999,46 @@ export default {
|
|
|
3561
3999
|
const r = await rulesService.ops['rules-list']({});
|
|
3562
4000
|
return ((r && r.scenes) || []).filter((s) => s.locked === true).map((s) => String(s.name));
|
|
3563
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
|
+
}
|
|
3564
4042
|
function guardLockedOps(opNames, what) {
|
|
3565
4043
|
for (const opName of opNames) {
|
|
3566
4044
|
const original = handlers[opName];
|
|
@@ -3588,6 +4066,14 @@ export default {
|
|
|
3588
4066
|
// 提示词:建改删 / 应用(切换生效基线)/ 导入 / 回收站。
|
|
3589
4067
|
'agentsmd-create', 'agentsmd-update', 'agentsmd-remove', 'agentsmd-apply', 'agentsmd-import', 'agentsmd-trash-restore', 'agentsmd-trash-delete',
|
|
3590
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
|
+
], '开关');
|
|
3591
4077
|
// 被锁场景自身的档案与删除:只挡它自己,别的场景照常。
|
|
3592
4078
|
for (const [opName, pickScene, what] of [
|
|
3593
4079
|
['scene-archive-save', (args) => (args && args.scene) || '', '改档案'],
|
|
@@ -3604,10 +4090,13 @@ export default {
|
|
|
3604
4090
|
return original(args);
|
|
3605
4091
|
};
|
|
3606
4092
|
}
|
|
3607
|
-
// 各页列表响应带上 anyLocked
|
|
4093
|
+
// 各页列表响应带上 anyLocked + activeScene:界面据此禁用写控件、并说明
|
|
4094
|
+
// 「当前处于场景 X,开关请到档案里改」(读 op,附加字段不影响既有消费方)。
|
|
3608
4095
|
async function annotateLocked(res) {
|
|
3609
|
-
if (res && res.ok !== false)
|
|
4096
|
+
if (res && res.ok !== false) {
|
|
3610
4097
|
res.anyLocked = (await lockedSceneNames()).length > 0;
|
|
4098
|
+
res.activeScene = await activeSceneName();
|
|
4099
|
+
}
|
|
3611
4100
|
return res;
|
|
3612
4101
|
}
|
|
3613
4102
|
for (const opName of ['mcpm-list', 'mcpm-reveal', 'skill-state', 'subagent-list', 'agentsmd-list']) {
|
|
@@ -3619,22 +4108,49 @@ export default {
|
|
|
3619
4108
|
// ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
|
|
3620
4109
|
const text = (value) => [{ type: 'text', text: value }];
|
|
3621
4110
|
tools.register(defineTool({
|
|
3622
|
-
name: '
|
|
3623
|
-
description: 'List
|
|
3624
|
-
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
|
+
},
|
|
3625
4116
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3626
|
-
async execute() {
|
|
3627
|
-
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();
|
|
3628
4124
|
if (!r.ok)
|
|
3629
4125
|
throw new Error(r.error);
|
|
3630
|
-
const
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
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;
|
|
3634
4150
|
},
|
|
3635
4151
|
}));
|
|
3636
4152
|
tools.register(defineTool({
|
|
3637
|
-
name: '
|
|
4153
|
+
name: 'mcp_manager_set_enabled',
|
|
3638
4154
|
description: 'Enable or disable one configured MCP server (writes the patch file; takes effect via HMR).',
|
|
3639
4155
|
parameters: {
|
|
3640
4156
|
id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
|
|
@@ -3643,6 +4159,9 @@ export default {
|
|
|
3643
4159
|
},
|
|
3644
4160
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3645
4161
|
async execute(args) {
|
|
4162
|
+
const blocked = await sceneSwitchBlock();
|
|
4163
|
+
if (blocked)
|
|
4164
|
+
throw new Error(blocked);
|
|
3646
4165
|
const r = await mcpmSetEnabled({ id: args.id, level: args.level, enabled: args.enabled });
|
|
3647
4166
|
if (!r.ok)
|
|
3648
4167
|
throw new Error(r.error);
|
|
@@ -3650,7 +4169,7 @@ export default {
|
|
|
3650
4169
|
},
|
|
3651
4170
|
}));
|
|
3652
4171
|
tools.register(defineTool({
|
|
3653
|
-
name: '
|
|
4172
|
+
name: 'mcp_manager_restart',
|
|
3654
4173
|
description: 'Restart one configured MCP server (disable + re-enable; reconnect and re-sync tools).',
|
|
3655
4174
|
parameters: {
|
|
3656
4175
|
id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
|
|
@@ -3665,7 +4184,7 @@ export default {
|
|
|
3665
4184
|
},
|
|
3666
4185
|
}));
|
|
3667
4186
|
tools.register(defineTool({
|
|
3668
|
-
name: '
|
|
4187
|
+
name: 'mcp_manager_add',
|
|
3669
4188
|
description: 'Add a new MCP server (streamable-http or stdio) at project or global level.',
|
|
3670
4189
|
parameters: {
|
|
3671
4190
|
serverName: { type: 'string', required: true, description: 'Unique server name (1-32 chars, [A-Za-z0-9_-]).' },
|
|
@@ -3689,26 +4208,69 @@ export default {
|
|
|
3689
4208
|
// tools/pre-execute hook below (the model must ask before writing files).
|
|
3690
4209
|
tools.register(defineTool({
|
|
3691
4210
|
name: 'skill_manager_list',
|
|
3692
|
-
description: 'List DSH skills
|
|
3693
|
-
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
|
+
},
|
|
3694
4215
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3695
|
-
async execute() {
|
|
4216
|
+
async execute(args, exec) {
|
|
4217
|
+
const showAll = Boolean(args && args.all === true);
|
|
3696
4218
|
const r = await skillsService.ops['skill-state']({});
|
|
3697
4219
|
if (!r || r.ok === false)
|
|
3698
4220
|
throw new Error((r && r.error) || 'skill-state failed');
|
|
3699
4221
|
const data = r.data || {};
|
|
3700
|
-
|
|
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();
|
|
3701
4233
|
for (const root of data.roots || []) {
|
|
3702
4234
|
for (const skill of root.skills || []) {
|
|
3703
|
-
|
|
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
|
+
});
|
|
3704
4255
|
}
|
|
3705
4256
|
}
|
|
3706
|
-
|
|
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;
|
|
3707
4269
|
},
|
|
3708
4270
|
}));
|
|
3709
4271
|
tools.register(defineTool({
|
|
3710
4272
|
name: 'skill_manager_set_enabled',
|
|
3711
|
-
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.',
|
|
3712
4274
|
parameters: {
|
|
3713
4275
|
name: { type: 'string', required: true, description: 'Skill name (kebab-case).' },
|
|
3714
4276
|
enabled: { type: 'boolean', required: true, description: 'true to enable, false to disable.' },
|
|
@@ -3716,11 +4278,37 @@ export default {
|
|
|
3716
4278
|
},
|
|
3717
4279
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3718
4280
|
async execute(args) {
|
|
4281
|
+
const blocked = await sceneSwitchBlock();
|
|
4282
|
+
if (blocked)
|
|
4283
|
+
throw new Error(blocked);
|
|
4284
|
+
const root = String(args.root || 'dsh');
|
|
3719
4285
|
const op = args.enabled ? 'skill-enable' : 'skill-disable';
|
|
3720
|
-
const r = await skillsService.ops[op]({ name: args.name, root
|
|
4286
|
+
const r = await skillsService.ops[op]({ name: args.name, root });
|
|
3721
4287
|
if (!r || r.ok === false)
|
|
3722
4288
|
throw new Error((r && r.error) || 'skill toggle failed');
|
|
3723
|
-
|
|
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;
|
|
3724
4312
|
},
|
|
3725
4313
|
}));
|
|
3726
4314
|
tools.register(defineTool({
|
|
@@ -3728,7 +4316,7 @@ export default {
|
|
|
3728
4316
|
// 落点必须和 UI「创建技能」一致:两者都走 core 的默认落点(hub 的
|
|
3729
4317
|
// tool-management/skills/,hub 缺失时退回 DSH_HOME/skills)。以前这里硬编码
|
|
3730
4318
|
// root:'dsh',于是同一个「新建技能」动作,人点界面和模型调用会落到两个不同的根。
|
|
3731
|
-
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.',
|
|
3732
4320
|
parameters: {
|
|
3733
4321
|
name: { type: 'string', required: true, description: 'Skill name; normalized to kebab-case.' },
|
|
3734
4322
|
description: { type: 'string', required: true, description: 'A concise routing description for when to use the skill.' },
|
|
@@ -3745,63 +4333,108 @@ export default {
|
|
|
3745
4333
|
}));
|
|
3746
4334
|
// AGENTS.md 预设库:模型可查/切,不能造/删(避免模型乱删用户预设)。
|
|
3747
4335
|
tools.register(defineTool({
|
|
3748
|
-
name: '
|
|
3749
|
-
description: 'List AGENTS.md presets
|
|
3750
|
-
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
|
+
},
|
|
3751
4341
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3752
|
-
async execute(
|
|
4342
|
+
async execute(args, exec) {
|
|
4343
|
+
const showAll = Boolean(args && args.all === true);
|
|
3753
4344
|
const r = await agentsMdService.list();
|
|
3754
4345
|
if (!r.ok)
|
|
3755
4346
|
throw new Error(r.error);
|
|
3756
|
-
const
|
|
3757
|
-
//
|
|
3758
|
-
//
|
|
3759
|
-
|
|
3760
|
-
|
|
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;
|
|
3761
4372
|
},
|
|
3762
4373
|
}));
|
|
3763
4374
|
tools.register(defineTool({
|
|
3764
|
-
name: '
|
|
3765
|
-
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).',
|
|
3766
4377
|
parameters: {
|
|
3767
4378
|
id: { type: 'string', required: true, description: 'Preset id (lowercase letters, digits, hyphens).' },
|
|
3768
4379
|
},
|
|
3769
4380
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3770
4381
|
async execute(args) {
|
|
3771
|
-
const r = await
|
|
4382
|
+
const r = await applyPresetGuarded(args.id);
|
|
3772
4383
|
if (!r.ok)
|
|
3773
4384
|
throw new Error(r.error);
|
|
3774
4385
|
return 'OK: preset ' + args.id + ' applied to ~/.dsh/AGENTS.md (next session; current session unchanged' + (r.backedUp ? '; previous backed up to __last-applied__' : '') + ')';
|
|
3775
4386
|
},
|
|
3776
4387
|
}));
|
|
3777
4388
|
// ---------- rules model tools(v0.4)----------
|
|
3778
|
-
//
|
|
3779
|
-
// 查询/编辑规则本身。
|
|
4389
|
+
// 活动场景的记忆正文会自动注入上下文(无需调用工具读取);这里的工具用于
|
|
4390
|
+
// 查询/编辑规则本身。memory_manager_write 受 tools/pre-execute 审批门禁(D2)。
|
|
3780
4391
|
// 路径锚点:$DSH_HOME/tool-management/memories/<场景>/…(场景 `global` = 界面「全局」)。
|
|
3781
4392
|
tools.register(defineTool({
|
|
3782
|
-
name: '
|
|
3783
|
-
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.',
|
|
3784
4395
|
parameters: {
|
|
3785
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).' },
|
|
3786
4398
|
},
|
|
3787
4399
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3788
4400
|
async execute(args, exec) {
|
|
4401
|
+
const showAll = Boolean(args && args.all === true);
|
|
3789
4402
|
const r = await rulesService.ops['rules-list'](args);
|
|
3790
4403
|
if (!r || r.ok === false)
|
|
3791
4404
|
throw new Error((r && r.error) || '读取规则失败');
|
|
3792
|
-
|
|
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 +
|
|
3793
4422
|
(x.description ? ' — ' + x.description : '')));
|
|
3794
4423
|
const scenes = (r.scenes || []).map((s) => (s.label || s.name) + (s.active ? '(启用)' : '(未启用)')).join('、');
|
|
3795
|
-
|
|
4424
|
+
const header = '记忆:' + (showAll
|
|
4425
|
+
? rows.length + ' 条'
|
|
4426
|
+
: shown.length + ' 条会注入 / 共 ' + rows.length + ' 条' +
|
|
4427
|
+
(shown.length === rows.length ? '' : '(传 all=true 看全部)')) + '\n';
|
|
4428
|
+
// 注入边界:压制型预设(persona complete / 关闭运行时上下文)下本插件默认不注入,
|
|
3796
4429
|
// 此时列出的记忆**不在**模型上下文里。必须说出来,否则模型会假设自己已经看到正文。
|
|
3797
|
-
const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx);
|
|
3798
|
-
return
|
|
4430
|
+
const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx, injectNoticeOptions());
|
|
4431
|
+
return header + (lines.join('\n') || '(无记忆)') +
|
|
3799
4432
|
'\n场景:' + (scenes || '(无)') + (r.activeMode === 'all' ? '(默认全部启用)' : '(已收窄)') + notice;
|
|
3800
4433
|
},
|
|
3801
4434
|
}));
|
|
3802
4435
|
tools.register(defineTool({
|
|
3803
|
-
name: '
|
|
3804
|
-
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).',
|
|
3805
4438
|
parameters: {
|
|
3806
4439
|
id: { type: 'string', required: true, description: 'Memory id like <scene>/<name>.' },
|
|
3807
4440
|
},
|
|
@@ -3814,11 +4447,11 @@ export default {
|
|
|
3814
4447
|
},
|
|
3815
4448
|
}));
|
|
3816
4449
|
tools.register(defineTool({
|
|
3817
|
-
name: '
|
|
3818
|
-
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).',
|
|
3819
4452
|
parameters: {
|
|
3820
|
-
group: { type: 'string', required: true, description: 'Scene name
|
|
3821
|
-
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.' },
|
|
3822
4455
|
description: { type: 'string', required: true, description: 'One-sentence description (<=500 chars).' },
|
|
3823
4456
|
body: { type: 'string', required: true, description: 'Markdown body (<=256 KiB).' },
|
|
3824
4457
|
},
|
|
@@ -3830,15 +4463,21 @@ export default {
|
|
|
3830
4463
|
return 'OK: memory ' + r.rule.id + '(场景「' + (r.rule.group || '未归属') + '」启用后自动生效)';
|
|
3831
4464
|
},
|
|
3832
4465
|
}));
|
|
3833
|
-
// ---------- 子智能体工具(
|
|
4466
|
+
// ---------- 子智能体工具(subagent_manager_list / subagent_manager_run)----------
|
|
3834
4467
|
// exec.agent / exec.signal 由工具运行时提供(parent 与取消信号的官方通道)。
|
|
3835
4468
|
try {
|
|
3836
4469
|
// 与其余 12 个工具同一条注册通道:defineTool 负责编译 parameters(object root + required),
|
|
3837
4470
|
// 裸 register 会把未编译的参数声明直接发给模型 API。
|
|
3838
|
-
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
|
+
})));
|
|
3839
4478
|
// 工具限制按**当前会话的 Agent 预设**下发:父会话跑在哪个预设,就用那个预设那一行的
|
|
3840
4479
|
// 白/黑名单(`decideToolFilter`),名单里已消失的工具名会被丢掉并在结果里如实说明。
|
|
3841
|
-
tools.register(defineTool(
|
|
4480
|
+
tools.register(defineTool(defineSubagentManagerRunTool({ ...subagentService, sceneLists: subagentSceneLists, toolFilterFor: subagentToolFilterFor })));
|
|
3842
4481
|
}
|
|
3843
4482
|
catch (e) {
|
|
3844
4483
|
console.error('[dsh-plugin-tool-management] subagent tool registration failed:', message(e));
|
|
@@ -3852,8 +4491,8 @@ export default {
|
|
|
3852
4491
|
// 不能用 ctx.approval——inject 未声明该服务时 cordis 代理会抛 "cannot get property without inject")。
|
|
3853
4492
|
const CONFIRM_LABELS = {
|
|
3854
4493
|
skill_manager_create: '「新建技能」',
|
|
3855
|
-
|
|
3856
|
-
|
|
4494
|
+
memory_manager_write: '「写入记忆」',
|
|
4495
|
+
subagent_manager_run: '「运行子代理」',
|
|
3857
4496
|
};
|
|
3858
4497
|
const bypassedByFullAccess = (exec) => {
|
|
3859
4498
|
if (!isApprovalNever(ctx, exec))
|
|
@@ -3862,11 +4501,11 @@ export default {
|
|
|
3862
4501
|
pluginLog()('confirm-bypass', `完全权限(approval=never):跳过${CONFIRM_LABELS[String(exec && exec.name)]}的确认,直接放行`).catch(() => { });
|
|
3863
4502
|
return true;
|
|
3864
4503
|
};
|
|
3865
|
-
//
|
|
4504
|
+
// subagent_manager_run 的目标人设是否真实存在。确认门是「问用户要不要做」,如果这个请求
|
|
3866
4505
|
// 本来就做不成(人设不存在),弹卡 / 写 bypass 日志只会产生一次无效审批:
|
|
3867
4506
|
// 用户批准之后模型收到的是「人设不可用」。所以先校验,再决定要不要问。
|
|
3868
4507
|
// 校验本身不可用时返回 true(保持原行为,绝不因为探测失败而少问一次)。
|
|
3869
|
-
const
|
|
4508
|
+
const subagentManagerRunTargetExists = async (exec) => {
|
|
3870
4509
|
try {
|
|
3871
4510
|
const name = String((exec && exec.arguments && exec.arguments.agent) || '').trim();
|
|
3872
4511
|
if (!name)
|
|
@@ -3881,14 +4520,14 @@ export default {
|
|
|
3881
4520
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
3882
4521
|
if (!exec || !CONFIRM_LABELS[String(exec.name)])
|
|
3883
4522
|
return next();
|
|
3884
|
-
if (exec.name === '
|
|
4523
|
+
if (exec.name === 'subagent_manager_run' && !(await subagentManagerRunTargetExists(exec)))
|
|
3885
4524
|
return next();
|
|
3886
4525
|
if (bypassedByFullAccess(exec))
|
|
3887
4526
|
return next();
|
|
3888
4527
|
if (exec.name === 'skill_manager_create') {
|
|
3889
4528
|
return Promise.resolve({ kind: 'ask', reason: 'Create a new skill under ~/.dsh/tool-management/skills' });
|
|
3890
4529
|
}
|
|
3891
|
-
if (exec.name === '
|
|
4530
|
+
if (exec.name === 'memory_manager_write') {
|
|
3892
4531
|
// D2:模型写规则默认需确认;设置关闭后直接放行。ask 无应答者时降级为拒绝(fail-closed),
|
|
3893
4532
|
// 不在此处做任何兜底放行。
|
|
3894
4533
|
return readPluginSettings()
|
|
@@ -3897,7 +4536,7 @@ export default {
|
|
|
3897
4536
|
: next()))
|
|
3898
4537
|
.catch(() => ({ kind: 'ask', reason: 'Write a memory under ~/.dsh/tool-management/memories' }));
|
|
3899
4538
|
}
|
|
3900
|
-
//
|
|
4539
|
+
// subagent_manager_run:子代理运行花真 token:默认确认(requireConfirmForModelSubagentRun !== false),可关。
|
|
3901
4540
|
return readPluginSettings()
|
|
3902
4541
|
.then((s) => (s.requireConfirmForModelSubagentRun !== false
|
|
3903
4542
|
? { kind: 'ask', reason: 'Run a subagent (consumes tokens)' }
|
|
@@ -3926,9 +4565,14 @@ export default {
|
|
|
3926
4565
|
if (typeof ctx.on === 'function') {
|
|
3927
4566
|
try {
|
|
3928
4567
|
// MCP servers (de)register their tools as instances come and go; the
|
|
3929
|
-
// visible-set restriction must track that
|
|
4568
|
+
// visible-set restriction must track that — and so must the MCP state
|
|
4569
|
+
// section: 工具加载完成后若不重算,注入文本会一直停留在「空/旧集合」的缓存值上
|
|
4570
|
+
//(用户切 MCP 后立即看上下文注入就是空段 —— 2026-09-16 实测)。
|
|
3930
4571
|
ctx.effect(() => {
|
|
3931
|
-
const stop = ctx.on('tools/change', () =>
|
|
4572
|
+
const stop = ctx.on('tools/change', () => {
|
|
4573
|
+
scheduleToolRestrictions();
|
|
4574
|
+
void mcpStateCatalog.refresh();
|
|
4575
|
+
});
|
|
3932
4576
|
return typeof stop === 'function' ? stop : () => { };
|
|
3933
4577
|
}, 'dsh-plugin-tool-management: tools/change listener');
|
|
3934
4578
|
}
|