dsh-plugin-tool-management 0.9.0 → 0.10.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 +323 -245
- package/README.md +212 -201
- package/README_EN.md +206 -199
- 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 +171 -111
- package/lib/client.js +6220 -5451
- package/lib/compat/probe.js +0 -5
- package/lib/context-inject.js +468 -39
- package/lib/history/bridge.js +7 -0
- package/lib/history/tombstone.js +1 -2
- package/lib/hub.js +3 -1
- package/lib/index.js +735 -95
- package/lib/mcp/override-blocks.js +10 -6
- package/lib/mcp/state-section.js +64 -21
- package/lib/rules/archive-engine.js +93 -18
- package/lib/rules/archive.js +12 -7
- package/lib/rules/service.js +195 -44
- package/lib/scene-prompt-sync.js +4 -12
- package/lib/skills/catalog.js +6 -9
- package/lib/skills/core.js +5 -4
- package/lib/skills/service.js +56 -2
- package/lib/subagents/catalog.js +31 -15
- package/lib/subagents/service.js +307 -34
- package/lib/subagents/tools.js +29 -4
- package/package.json +105 -102
- package/lib/history/projcache.js +0 -335
package/lib/index.js
CHANGED
|
@@ -10,18 +10,19 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Build: `tsc -p tsconfig.json` compiles this to lib/index.js (the shipped
|
|
12
12
|
// artifact — same convention as DSH's own packages, which ship compiled JS).
|
|
13
|
-
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
13
|
+
import { defineTool as hostDefineTool } from '@deepseek-ai/dsh-tools';
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
15
|
-
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
16
16
|
import { ArchiveWorkspaceRegistry as HistoryService, workspaceBaseName, workspacePathKey } from './history/workspace.js';
|
|
17
17
|
import { createSkillsService, pluginLog } from './skills/service.js';
|
|
18
|
+
import { renameWithRetry } from './skills/core.js';
|
|
18
19
|
import { createAgentsMdService } from './agents-md/service.js';
|
|
19
20
|
import { createRulesService, planMemoryExport } from './rules/service.js';
|
|
20
21
|
import { createArchiveEngine } from './rules/archive-engine.js';
|
|
21
22
|
import { createSubagentService, decideToolFilter, defaultPersonasDir } from './subagents/service.js';
|
|
22
23
|
import { createSubagentCatalog } from './subagents/catalog.js';
|
|
23
24
|
import { createSkillCatalog } from './skills/catalog.js';
|
|
24
|
-
import { DEFAULT_INJECT_SETTINGS, createContextInjector, normalizeInjectSettings, } from './context-inject.js';
|
|
25
|
+
import { DEFAULT_INJECT_SETTINGS, createContextInjector, normalizeInjectSettings, subagentDepthOf, } from './context-inject.js';
|
|
25
26
|
import { isApprovalNever } from './approval-policy.js';
|
|
26
27
|
import { EXPECTED_MIN_HOST_VERSION, EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, summarize } from './compat/probe.js';
|
|
27
28
|
import { assessPresetReach, injectionFactsOf, presetRosterOf, readCompositionFacts, reachNoticeForAgent } from './compat/preset-reach.js';
|
|
@@ -34,7 +35,7 @@ import { defineSubagentManagerListTool, defineSubagentManagerRunTool } from './s
|
|
|
34
35
|
import { detectFormat, extractText, parseGenericText, parseJsonlTranscript, parseMarkdownTranscript } from './imports/parsers.js';
|
|
35
36
|
import { homedir } from 'node:os';
|
|
36
37
|
import { spawn } from 'node:child_process';
|
|
37
|
-
import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
|
|
38
|
+
import { mkdir, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
38
39
|
import { basename, dirname, join } from 'node:path';
|
|
39
40
|
import { zipSync } from 'fflate';
|
|
40
41
|
/**
|
|
@@ -154,6 +155,10 @@ export default {
|
|
|
154
155
|
// is not an injected service and throws "cannot get property without
|
|
155
156
|
// inject" at boot.
|
|
156
157
|
const TOKEN = String(config?.token || process.env.DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN || '').trim();
|
|
158
|
+
// 令牌比较走「双侧 sha256 → timingSafeEqual」:摘要定长 32 字节,无需长度分支
|
|
159
|
+
// (直接比较不等长 Buffer 会抛),逐字节的耗时不随匹配前缀长度变化 —— JS 的 ===
|
|
160
|
+
// 逐字符短路,理论上可被计时侧信道逐位猜测。
|
|
161
|
+
const tokenMatches = (presented) => timingSafeEqual(createHash('sha256').update(presented).digest(), createHash('sha256').update(TOKEN).digest());
|
|
157
162
|
const wait = (ms) => ctx.timeout(ms);
|
|
158
163
|
const message = (e) => String((e && e.message) || e);
|
|
159
164
|
let writeChain = Promise.resolve();
|
|
@@ -284,12 +289,18 @@ export default {
|
|
|
284
289
|
const states = {};
|
|
285
290
|
const all = {};
|
|
286
291
|
const shadowed = new Set();
|
|
292
|
+
const unloadable = new Set();
|
|
287
293
|
for (const root of ((r && r.data && r.data.roots) || [])) {
|
|
288
294
|
for (const sk of (root.skills || [])) {
|
|
289
295
|
const name = String(sk.declaredName || sk.name || '');
|
|
290
296
|
if (!name)
|
|
291
297
|
continue;
|
|
292
298
|
const key = String(root.key || '') + '/' + name;
|
|
299
|
+
// 结构不完整的技能在 core 里**一律拒绝启停**(error.skill.notLoadable):
|
|
300
|
+
// 档案里若残留这样的键,旧实现会把 `applySkills` 的"应用失败"抛出去,于是
|
|
301
|
+
// **整个场景进不去**。这里单独收集,与「被覆盖」同口径处理(见 applySkills)。
|
|
302
|
+
if (sk.loadable === false)
|
|
303
|
+
unloadable.add(key);
|
|
293
304
|
if (sk.shadowedBy) {
|
|
294
305
|
shadowed.add(key);
|
|
295
306
|
all[key] = false;
|
|
@@ -300,7 +311,24 @@ export default {
|
|
|
300
311
|
all[key] = on;
|
|
301
312
|
}
|
|
302
313
|
}
|
|
303
|
-
return { states, shadowed, all };
|
|
314
|
+
return { states, shadowed, all, unloadable };
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* 档案里**能写、且写了有意义**的技能键:既不被同名技能覆盖,结构也完整。
|
|
318
|
+
*
|
|
319
|
+
* 为什么要单独给一个集合:档案的语义是「勾选集 = 该场景下开着的东西」,而被覆盖的副本
|
|
320
|
+
* 永远不可能生效(`applySkills` 直接跳过),结构不完整的更是启停都被 core 拒绝 ——
|
|
321
|
+
* 它们进档案只会造成「档案说开着、运行时说关着」(用户实测:启动目录后档案里多出被覆盖
|
|
322
|
+
* 的那个技能,而技能页显示它并未启动)。保存档案时按这个集合校验,顺带清理历史残留。
|
|
323
|
+
*/
|
|
324
|
+
async function selectableSkillKeys() {
|
|
325
|
+
const { shadowed, unloadable } = await skillScan();
|
|
326
|
+
const all = new Set(Object.keys(await skillStates()));
|
|
327
|
+
for (const k of shadowed)
|
|
328
|
+
all.delete(k);
|
|
329
|
+
for (const k of unloadable)
|
|
330
|
+
all.delete(k);
|
|
331
|
+
return all;
|
|
304
332
|
}
|
|
305
333
|
async function skillStates() {
|
|
306
334
|
// knownSkillKeys 需要**全量键**(含被覆盖的副本):场景档案里允许勾选任意一条技能,
|
|
@@ -333,6 +361,7 @@ export default {
|
|
|
333
361
|
name,
|
|
334
362
|
enabled: sk.enabled === true && !sk.shadowedBy,
|
|
335
363
|
shadowed: !!sk.shadowedBy,
|
|
364
|
+
loadable: sk.loadable !== false,
|
|
336
365
|
rootKey,
|
|
337
366
|
rootLabel: String((root && root.label) || rootKey),
|
|
338
367
|
...(root && root.localeKey ? { rootLocaleKey: String(root.localeKey) } : {}),
|
|
@@ -392,22 +421,31 @@ export default {
|
|
|
392
421
|
scheduleToolRestrictions();
|
|
393
422
|
},
|
|
394
423
|
knownSkillKeys: async () => new Set(Object.keys(await skillStates())),
|
|
424
|
+
// 档案保存校验用:被同名覆盖的副本与结构不完整的技能都不算「可勾选」(见 selectableSkillKeys)。
|
|
425
|
+
selectableSkillKeys,
|
|
395
426
|
// 快照只取**真实生效**的条目(不含被同名覆盖的副本)——副本不参与模式应用,
|
|
396
427
|
// 退出回放时也不该去动它们,否则会改写用户对副本的显式策略记录。
|
|
397
428
|
currentSkills: async () => (await skillScan()).states,
|
|
398
429
|
applySkills: async (target) => {
|
|
399
|
-
//
|
|
430
|
+
// 三道过滤,缺一不可:
|
|
400
431
|
// 1. 被同名覆盖的副本直接跳过——它们本来就不生效,写策略只会污染用户的记录。
|
|
401
|
-
// 2.
|
|
432
|
+
// 2. 结构不完整的技能也跳过——core 对它们一律拒绝启停(error.skill.notLoadable),
|
|
433
|
+
// 不跳过就会抛成"应用失败",**整个场景进不去**(老档案里的残留键能踩到)。
|
|
434
|
+
// 3. **已经处于目标状态的技能不再写**。`applySkills` 写的是显式策略记录,而目标
|
|
402
435
|
// 状态是生效状态,两者不等价:对已经生效为「停用」的技能再 disable 一次,
|
|
403
436
|
// 只是把「没有记录」固化成「显式停用」,场景模式进出一次就会改写 state.json。
|
|
404
|
-
const { states, shadowed } = await skillScan();
|
|
437
|
+
const { states, shadowed, unloadable } = await skillScan();
|
|
405
438
|
for (const [key, on] of Object.entries(target)) {
|
|
406
439
|
const i = key.indexOf('/');
|
|
407
440
|
if (i <= 0)
|
|
408
441
|
throw new Error(`技能 key 不合法: ${key}`);
|
|
442
|
+
// 被同名覆盖的副本跳过(它本来就不生效,写策略只会污染用户的记录)。
|
|
409
443
|
if (shadowed.has(key))
|
|
410
444
|
continue;
|
|
445
|
+
// 结构不完整的技能跳过:core 对它们**一律拒绝启停**(error.skill.notLoadable),
|
|
446
|
+
// 继续往下走会抛成"应用失败",**整个场景就进不去了**(老档案里的残留键能踩到)。
|
|
447
|
+
if (unloadable.has(key))
|
|
448
|
+
continue;
|
|
411
449
|
if (states[key] === on)
|
|
412
450
|
continue;
|
|
413
451
|
const root = key.slice(0, i);
|
|
@@ -487,12 +525,38 @@ export default {
|
|
|
487
525
|
}
|
|
488
526
|
return out;
|
|
489
527
|
},
|
|
528
|
+
/**
|
|
529
|
+
* 来源级启停(进入 / 退出 / 就地重应用共用)。
|
|
530
|
+
*
|
|
531
|
+
* 容错口径(用户 2026-09-17 报「退出模式失败 → 卡在场景里」之后加的):**结构性**失败
|
|
532
|
+
* (来源解析不到 / 该来源本来就不允许启停)只记警告并跳过 —— 这一行与本次改动无关,
|
|
533
|
+
* 拿它中断整个恢复只会把用户困在场景里;**写盘**类失败仍然抛错,那说明确实没还原成功。
|
|
534
|
+
* 结构性失败的正解在 `skills/service.ts` 的 `requestSwitchRoot`(项目来源按 key 前缀解析)。
|
|
535
|
+
*/
|
|
490
536
|
applySkillSourceSwitches: async (switches) => {
|
|
537
|
+
const skipped = [];
|
|
491
538
|
for (const s of switches) {
|
|
492
539
|
const op = s.enabled ? 'skill-source-enable' : 'skill-source-disable';
|
|
493
|
-
|
|
494
|
-
|
|
540
|
+
let r;
|
|
541
|
+
try {
|
|
542
|
+
r = await skillsService.ops[op]({ root: s.root });
|
|
543
|
+
}
|
|
544
|
+
catch (e) {
|
|
545
|
+
r = { ok: false, error: message(e), code: '' };
|
|
546
|
+
}
|
|
547
|
+
if (r && r.ok === false) {
|
|
548
|
+
const code = String((r && r.code) || '');
|
|
549
|
+
const structural = code === 'error.root.readonly' || code === 'error.root.unknown' ||
|
|
550
|
+
/来源不存在|不允许启用或停用/.test(String(r.error || ''));
|
|
551
|
+
if (structural) {
|
|
552
|
+
skipped.push(`${s.root}(${r.error})`);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
495
555
|
throw new Error(`技能来源 ${s.root} ${s.enabled ? '启用' : '停用'}失败:${r.error}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (skipped.length) {
|
|
559
|
+
ctx.logger?.warn?.(`scene: 跳过无法启停的技能来源 —— ${skipped.join('、')}`);
|
|
496
560
|
}
|
|
497
561
|
},
|
|
498
562
|
sceneExists: async (name) => {
|
|
@@ -513,7 +577,7 @@ export default {
|
|
|
513
577
|
await subagentService.enabledStore.setEnabled(on, true);
|
|
514
578
|
void subagentCatalog.refresh();
|
|
515
579
|
},
|
|
516
|
-
// 记忆 id
|
|
580
|
+
// 记忆 id 全集。引擎已不消费(memories 段 P5 起废弃),实现保留以维持接口形状。
|
|
517
581
|
knownMemoryIds: async () => {
|
|
518
582
|
const r = await rulesService.ops['rules-list']({});
|
|
519
583
|
return new Set(((r && r.rules) || []).filter((x) => !x.shadowed).map((x) => String(x.id)));
|
|
@@ -638,6 +702,12 @@ export default {
|
|
|
638
702
|
return undefined;
|
|
639
703
|
}
|
|
640
704
|
}
|
|
705
|
+
// 注入实况(只读诊断):注入器的 live() 读回"最近活跃会话"可见表面上的五域文本。
|
|
706
|
+
// 挂在 apply 作用域:`injection-live` op 与注入器不在同一层(effect 内部)。
|
|
707
|
+
let contextInjectorLive = null;
|
|
708
|
+
// 采纳遥测的入口(同样是 apply 作用域的中转):工具注册在 effect 之外,
|
|
709
|
+
// 拿不到 effect 内部的 `injector`;经这个 `let` 中转,热更新重建注入器也能跟上。
|
|
710
|
+
let contextInjectorNote = null;
|
|
641
711
|
try {
|
|
642
712
|
ctx.effect(() => {
|
|
643
713
|
const injector = createContextInjector({
|
|
@@ -645,17 +715,43 @@ export default {
|
|
|
645
715
|
// 顺序即界面勾选与消息顺序(用户裁定 2026-09-16):场景和记忆 → MCP → 技能 →
|
|
646
716
|
// 子智能体 → 提示词。form 是宿主语义轴:记忆是「当前状态」(snapshot,后发取代先发),
|
|
647
717
|
// 三个目录是 catalog,提示词是 instructions(与官方 AGENTS.md 那条行同一形态)。
|
|
718
|
+
//
|
|
719
|
+
// `applicableTo`(2026-09-17 方案 A):子智能体域按"**目录该不该注入到这么深的
|
|
720
|
+
// 会话**"(人设的 `catalogDepth`,默认 1 = 只在顶层注入)判断,**不是**"能不能
|
|
721
|
+
// 委派"。委派可行性由官方决定:`dsh-tool-subagent` 默认 `maxDepth: 3`,provider
|
|
722
|
+
// 只在传了该值时才校验,所以子代理本来就能继续嵌套(用户实测确认)。此前判据叫
|
|
723
|
+
// "还有没有委派预算"、语义是"还能不能委派",那是错的 —— 详见 service.ts 里
|
|
724
|
+
// `catalogDepth` 字段的注释。目录正文也按同一个判据过滤(`text(agent)`),两处同源
|
|
725
|
+
// 所以不会分叉。
|
|
726
|
+
//
|
|
727
|
+
// 记忆域(2026-09-17 用户裁定):**只在顶层注入**。记忆是"父会话的现场",不是子代理
|
|
728
|
+
// 完成任务所需的事实 —— 而且它带着「一律照办,覆盖你的默认做法」这种强主张,塞进
|
|
729
|
+
// 一次性子会话只会与角色定义争注意力(实测:子代理跑审查时,上下文里同时躺着人设与
|
|
730
|
+
// 整份场景记忆)。子代理手里有 `memory_manager_list/read`,需要什么自己取;父代理
|
|
731
|
+
// 上下文里也有记忆,相关事实应当由它写进 `task`(子代理的上下文 = 角色 + 任务)。
|
|
732
|
+
// 其余三域对任何深度都成立:提示词是用户规则(本插件的立身之本就是"覆盖到子代理")、
|
|
733
|
+
// 技能目录与 MCP 状态是"操作这台机器所需的事实"(子代理手里就有 `skill` / `mcp__*`
|
|
734
|
+
// 工具,不知道清单就只能瞎调)。
|
|
648
735
|
domains: () => [
|
|
649
|
-
{ key: 'memory', name: 'tool-management:scene-memory', label: '场景和记忆', form: 'snapshot', text: () => rulesService.memoryText() },
|
|
736
|
+
{ key: 'memory', name: 'tool-management:scene-memory', label: '场景和记忆', form: 'snapshot', text: () => rulesService.memoryText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
|
|
650
737
|
{ key: 'mcp', name: 'tool-management:mcp-state', label: 'MCP 服务器', form: 'catalog', text: () => mcpStateCatalog.text() },
|
|
651
738
|
{ key: 'skills', name: 'tool-management:skill-catalog', label: '技能目录', form: 'catalog', text: () => skillCatalog.text() },
|
|
652
|
-
{
|
|
653
|
-
|
|
739
|
+
{
|
|
740
|
+
key: 'subagents',
|
|
741
|
+
name: 'tool-management:subagents',
|
|
742
|
+
label: '子智能体',
|
|
743
|
+
form: 'catalog',
|
|
744
|
+
text: (agent) => subagentCatalog.text(agent),
|
|
745
|
+
applicableTo: (agent) => subagentCatalog.catalogVisibleAt(subagentDepthOf(agent)),
|
|
746
|
+
},
|
|
747
|
+
{ key: 'prompt', name: 'tool-management:prompt', label: '提示词', form: 'instructions', text: () => rulesService.promptText(), files: () => rulesService.promptFiles() },
|
|
654
748
|
],
|
|
655
749
|
settings: () => injectSettingsSync(),
|
|
656
750
|
factsFor: (agent) => presetFactsForAgent(agent),
|
|
657
751
|
});
|
|
658
|
-
|
|
752
|
+
contextInjectorLive = () => injector.live();
|
|
753
|
+
contextInjectorNote = (toolName, agent) => injector.noteToolUse(toolName, agent);
|
|
754
|
+
return () => { contextInjectorLive = null; contextInjectorNote = null; injector.dispose(); };
|
|
659
755
|
}, 'dsh-plugin-tool-management: context injection');
|
|
660
756
|
}
|
|
661
757
|
catch (e) {
|
|
@@ -720,12 +816,51 @@ export default {
|
|
|
720
816
|
return 0;
|
|
721
817
|
}
|
|
722
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* 人设改名后同步运行时快照(F-025):退出还原按 `subagentsAll` 逐名走,快照里
|
|
821
|
+
* 还留着旧名的话,退出会「旧名静默跳过、新名不还原」—— 被改名的人设停留在
|
|
822
|
+
* 场景期间状态。快照在进入场景时拍、改名发生在进入后,只能在改名时跟着改。
|
|
823
|
+
*/
|
|
824
|
+
async function renameSubagentInSnapshot(from, to) {
|
|
825
|
+
try {
|
|
826
|
+
const slice = await rulesService.readArchiveSlice();
|
|
827
|
+
const mode = slice && slice.mode;
|
|
828
|
+
const snapshot = mode && mode.snapshot;
|
|
829
|
+
if (!snapshot)
|
|
830
|
+
return;
|
|
831
|
+
let changed = false;
|
|
832
|
+
const next = { ...snapshot };
|
|
833
|
+
if (snapshot.subagentsAll && Object.prototype.hasOwnProperty.call(snapshot.subagentsAll, from)) {
|
|
834
|
+
const all = {};
|
|
835
|
+
for (const [k, v] of Object.entries(snapshot.subagentsAll)) {
|
|
836
|
+
all[k === from ? to : k] = v;
|
|
837
|
+
if (k === from)
|
|
838
|
+
changed = true;
|
|
839
|
+
}
|
|
840
|
+
next.subagentsAll = all;
|
|
841
|
+
}
|
|
842
|
+
for (const field of ['subagents', 'subagentsOn']) {
|
|
843
|
+
const list = snapshot[field];
|
|
844
|
+
if (Array.isArray(list) && list.includes(from)) {
|
|
845
|
+
next[field] = list.map((n) => (n === from ? to : n));
|
|
846
|
+
changed = true;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (changed)
|
|
850
|
+
await rulesService.patchIndex({ mode: { ...mode, snapshot: next } });
|
|
851
|
+
}
|
|
852
|
+
catch { /* 快照同步失败不阻断改名本身;残留与修复前一致 */ }
|
|
853
|
+
}
|
|
723
854
|
const baseSubagentUpdate = subagentService.ops['subagent-update'];
|
|
724
855
|
if (typeof baseSubagentUpdate === 'function') {
|
|
725
856
|
subagentService.ops['subagent-update'] = async (args) => {
|
|
726
857
|
const res = await baseSubagentUpdate(args);
|
|
727
|
-
if (res && res.ok !== false && res.renamedFrom)
|
|
728
|
-
|
|
858
|
+
if (res && res.ok !== false && res.renamedFrom) {
|
|
859
|
+
const from = String(res.renamedFrom);
|
|
860
|
+
const to = String(res.name);
|
|
861
|
+
void rebindSubagentInArchives(from, to);
|
|
862
|
+
void renameSubagentInSnapshot(from, to);
|
|
863
|
+
}
|
|
729
864
|
return res;
|
|
730
865
|
};
|
|
731
866
|
}
|
|
@@ -788,7 +923,10 @@ export default {
|
|
|
788
923
|
if (toOn.length || toOff.length)
|
|
789
924
|
void subagentCatalog.refresh();
|
|
790
925
|
const snapshot = mode.snapshot;
|
|
791
|
-
|
|
926
|
+
// 新快照带**全量映射**(`subagentsAll`,v0.9.1)→ 退出按映射逐个精确还原,这两个
|
|
927
|
+
// 部分名单不再需要,也不再往新快照里写(老快照没有映射,仍然照旧维护,见
|
|
928
|
+
// archive-engine 的 restoreSnapshot 兼容分支)。
|
|
929
|
+
if (snapshot && !snapshot.subagentsAll && (toOn.length || toOff.length)) {
|
|
792
930
|
const backOn = Array.isArray(snapshot.subagents) ? snapshot.subagents.slice() : [];
|
|
793
931
|
const offList = Array.isArray(snapshot.subagentsOn) ? snapshot.subagentsOn.slice() : [];
|
|
794
932
|
for (const n of toOn)
|
|
@@ -817,6 +955,10 @@ export default {
|
|
|
817
955
|
'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-restart',
|
|
818
956
|
'mcpm-compact',
|
|
819
957
|
'mcpm-export', 'mcpm-import', 'mcpm-note', 'mcpm-settings', 'mcpm-tool-enabled',
|
|
958
|
+
// mcpm-tools-refresh 为了拿实时工具表会临时启用目标服务器、结束后恢复原状(两次
|
|
959
|
+
// writePatch),恢复失败还会停留在启用态 —— 是写不是读,按写门禁(与 mcpm-restart
|
|
960
|
+
// 已在清单同理;0.6.0/0.7.0 已有两次写 op 漏列前科)。
|
|
961
|
+
'mcpm-tools-refresh',
|
|
820
962
|
// mcpm-reveal returns UNMASKED secrets; even though it is a read, it is
|
|
821
963
|
// token-gated like a write — on a LAN-exposed port the token must be the
|
|
822
964
|
// last line of defense for plaintext credentials too, not just writes.
|
|
@@ -912,22 +1054,29 @@ export default {
|
|
|
912
1054
|
/**
|
|
913
1055
|
* **应用提示词预设的唯一入口**(界面 op 与模型工具共用)。
|
|
914
1056
|
*
|
|
915
|
-
* 场景接管期间(启用场景 /
|
|
916
|
-
*
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
1057
|
+
* 场景接管期间(启用场景 / 全局绑定在驱动基线):
|
|
1058
|
+
* - 应用**同一份** = 重新应用(把被手改的基线写回去),走场景同步;
|
|
1059
|
+
* - 应用**别的份** = 换掉这个场景的绑定(用户裁定 2026-09-17:未锁定时切换要可用,
|
|
1060
|
+
* 并且同步写进场景档案)。绑定改完再走一次场景同步,于是「场景页显示什么 / 实际注入
|
|
1061
|
+
* 什么 / 谁是引用方」三处立刻重新对齐(此前的做法是直接拒绝,用户得先退出场景)。
|
|
1062
|
+
* 锁定由 `guardLockedOps` 挡住(agentsmd-apply 在冻结清单里);模型工具
|
|
1063
|
+
* `prompt_manager_apply` 不经过 handlers,execute 里自带 `lockedSceneGuard`(F-001)。
|
|
920
1064
|
*/
|
|
921
1065
|
async function applyPresetGuarded(id) {
|
|
922
|
-
const
|
|
923
|
-
if (
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1066
|
+
const driver = await scenePromptSync.driver();
|
|
1067
|
+
if (driver) {
|
|
1068
|
+
const target = String(id ?? '').trim();
|
|
1069
|
+
if (driver.presetId !== target) {
|
|
1070
|
+
const rebound = await rulesService.ops['rules-update-scene']({ name: driver.scene, prompt: target });
|
|
1071
|
+
if (!rebound || rebound.ok === false) {
|
|
1072
|
+
return {
|
|
1073
|
+
ok: false,
|
|
1074
|
+
code: 'error.agentsMd.sceneRebindFailed',
|
|
1075
|
+
error: `无法把场景「${driver.label}」的提示词绑定改成「${target}」:${(rebound && rebound.error) || '未知原因'}`,
|
|
1076
|
+
params: { scene: driver.label, target, reason: String((rebound && rebound.error) || '未知原因') },
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
931
1080
|
const r = await scenePromptSync.sync();
|
|
932
1081
|
if (r.error)
|
|
933
1082
|
return { ok: false, error: r.error };
|
|
@@ -1142,7 +1291,6 @@ export default {
|
|
|
1142
1291
|
catch (e) { /* pruning is best-effort */ }
|
|
1143
1292
|
}
|
|
1144
1293
|
async function writePatch(abs, content) {
|
|
1145
|
-
const t = await fs.resolve(abs);
|
|
1146
1294
|
const policy = await sandboxPolicy.resolve({ mode: 'danger-full-access' });
|
|
1147
1295
|
try {
|
|
1148
1296
|
const previous = await readPatch(abs);
|
|
@@ -1150,7 +1298,20 @@ export default {
|
|
|
1150
1298
|
await backupPatchFile(abs, previous);
|
|
1151
1299
|
}
|
|
1152
1300
|
catch (e) { /* a failed backup must never block the write */ }
|
|
1153
|
-
|
|
1301
|
+
// 同目录临时文件 + rename 原子替换:进程中断/磁盘满最坏留下一个 .tmp 兄弟,
|
|
1302
|
+
// 不会截断补丁本体。官方 app-boot 写这两个补丁文件用的是同一套模式
|
|
1303
|
+
// (`filename + '.tmp'` → rename + 瞬时错误重试,lib/index.js `_writeFile`),
|
|
1304
|
+
// 所以 HMR 对 rename 的容忍不需要额外验证。内容写仍走宿主 fs 服务
|
|
1305
|
+
// (沙箱策略与审批语义不变),rename 只是元数据动作。
|
|
1306
|
+
const temp = abs + '.' + randomUUID().slice(0, 8) + '.dsh-tmp';
|
|
1307
|
+
try {
|
|
1308
|
+
await fs.writeText(await fs.resolve(temp), content, undefined, undefined, policy);
|
|
1309
|
+
await renameWithRetry(temp, abs);
|
|
1310
|
+
}
|
|
1311
|
+
catch (e) {
|
|
1312
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
1313
|
+
throw e;
|
|
1314
|
+
}
|
|
1154
1315
|
}
|
|
1155
1316
|
// ---------- duplicate loader-id guard ----------
|
|
1156
1317
|
// Two rows with the same loader id make the plugin composition fail to
|
|
@@ -1330,7 +1491,6 @@ export default {
|
|
|
1330
1491
|
managedIds.add(m[1].trim());
|
|
1331
1492
|
}
|
|
1332
1493
|
const rows = [];
|
|
1333
|
-
const overrides = [];
|
|
1334
1494
|
const blocks = [];
|
|
1335
1495
|
let current = null;
|
|
1336
1496
|
for (const line of lines) {
|
|
@@ -1369,16 +1529,18 @@ export default {
|
|
|
1369
1529
|
}
|
|
1370
1530
|
}
|
|
1371
1531
|
else {
|
|
1532
|
+
// 覆盖块按**文件顺序**生效,不是"收集后统一回填":官方
|
|
1533
|
+
// `@deepseek-ai/dsh-app-boot` 的 applyEntryPatches 只给此刻已入索引的 id 打补丁
|
|
1534
|
+
// (insert 是插入时立即入索引),命中不到就 warn + skip。所以写在 insert 之前的
|
|
1535
|
+
// 覆盖块在宿主侧是 no-op —— 这里同样丢弃,界面才和生效值一致。
|
|
1372
1536
|
const entry = parseEntry(block.text.split('\n'));
|
|
1373
|
-
if (entry && entry.name === '@deepseek-ai/dsh-mcp-client')
|
|
1374
|
-
|
|
1537
|
+
if (entry && entry.name === '@deepseek-ai/dsh-mcp-client' && entry.disabled !== undefined) {
|
|
1538
|
+
const row = rows.find((r) => r.id === entry.id);
|
|
1539
|
+
if (row)
|
|
1540
|
+
row.disabled = entry.disabled;
|
|
1541
|
+
}
|
|
1375
1542
|
}
|
|
1376
1543
|
}
|
|
1377
|
-
for (const o of overrides) {
|
|
1378
|
-
const row = rows.find((r) => r.id === o.id);
|
|
1379
|
-
if (row && o.disabled !== undefined)
|
|
1380
|
-
row.disabled = o.disabled;
|
|
1381
|
-
}
|
|
1382
1544
|
return { rows };
|
|
1383
1545
|
}
|
|
1384
1546
|
// ---------- line-based block editing ----------
|
|
@@ -2038,7 +2200,9 @@ export default {
|
|
|
2038
2200
|
toolsList.push({
|
|
2039
2201
|
name: known.name,
|
|
2040
2202
|
description: known.description || '(服务器未运行;这个名字来自上次运行记录)',
|
|
2041
|
-
|
|
2203
|
+
// 与上方 live 行同一口径:整台停用(`*`)时 stale 行同样报停用,
|
|
2204
|
+
// 否则同页「live 行停用、stale 行启用」自相矛盾。
|
|
2205
|
+
enabled: wildcardHere ? false : !disabledHere.has(known.name),
|
|
2042
2206
|
parameters: [],
|
|
2043
2207
|
stale: true,
|
|
2044
2208
|
});
|
|
@@ -2133,6 +2297,24 @@ export default {
|
|
|
2133
2297
|
}
|
|
2134
2298
|
const toolCounts = {};
|
|
2135
2299
|
const enabledToolCounts = {};
|
|
2300
|
+
// 只来自**真实 schema** 的两个数,以及只来自**「已知工具」缓存**的一个数。
|
|
2301
|
+
//
|
|
2302
|
+
// 为什么必须与上面那两个分开(2026-09-17 用户实测发现):`toolCounts` 是三处并集
|
|
2303
|
+
// (live ∪ 停用表 ∪ 缓存),把"这台 server 曾经有什么工具"与"它现在有什么工具"混成了
|
|
2304
|
+
// 一个数。界面状态胶囊与注入段此前读的就是它,于是**一台已经连不上的 server 会被报成
|
|
2305
|
+
// 「已运行」并注入上下文**,模型照着去调 `mcp__X__*`,而那个工具根本没注册 —— 白烧一轮。
|
|
2306
|
+
//
|
|
2307
|
+
// 分开之后三个数各有明确含义:
|
|
2308
|
+
// liveToolCounts 当前真实注册的工具数(0 = 一个都没注册)
|
|
2309
|
+
// liveEnabledToolCounts 上面这个数里未被停用表扣减的(真正能调到的)
|
|
2310
|
+
// knownToolCounts 缓存里的工具数 —— 它是**曾经真的连上过**的证据(缓存只在真实
|
|
2311
|
+
// schema 里见到工具时才写入),0 = 从未连上过
|
|
2312
|
+
//
|
|
2313
|
+
// 前两个给状态胶囊与注入段用;`toolCounts` / `enabledToolCounts` 语义不变,继续给场景
|
|
2314
|
+
// 档案挑工具、详情页清单用(那里"上次见过哪些工具"正是需要的,且已标 `stale`)。
|
|
2315
|
+
const liveToolCounts = {};
|
|
2316
|
+
const liveEnabledToolCounts = {};
|
|
2317
|
+
const knownToolCounts = {};
|
|
2136
2318
|
try {
|
|
2137
2319
|
const schemas = await tools.schemas();
|
|
2138
2320
|
const knownCache = await readKnownMcpTools();
|
|
@@ -2183,6 +2365,17 @@ export default {
|
|
|
2183
2365
|
// 否则收窄后模型看到的数字比它能调的工具多,页面与详情页也会互相矛盾。
|
|
2184
2366
|
enabledToolCounts[server] = countEnabledTools(set, disabledMap[server] || []);
|
|
2185
2367
|
}
|
|
2368
|
+
// 真实来源的两个数:只数 `liveNames`(schema 里真正注册了的),再按停用表扣减。
|
|
2369
|
+
for (const [server, list] of Object.entries(liveNames)) {
|
|
2370
|
+
const set = new Set(list);
|
|
2371
|
+
liveToolCounts[server] = set.size;
|
|
2372
|
+
liveEnabledToolCounts[server] = countEnabledTools(set, disabledMap[server] || []);
|
|
2373
|
+
}
|
|
2374
|
+
// 「曾经连上过」的证据:缓存里这台 server 见过几个工具。注意读的是**回写之前**的
|
|
2375
|
+
// `knownCache` —— 回写会把这次 live 见到的并进去,而这次 live 见到的本来就已经算在
|
|
2376
|
+
// liveToolCounts 里了,不需要它来充当"曾经"。
|
|
2377
|
+
for (const [server, list] of Object.entries(knownCache))
|
|
2378
|
+
knownToolCounts[server] = list.length;
|
|
2186
2379
|
// 回写「已知工具」:live 见到的名字与描述并入缓存(有变化才写盘,读路径上的 best-effort)。
|
|
2187
2380
|
// 名字集合变化,或某个已知工具这次拿到了描述而缓存里没有 → 都算变化。
|
|
2188
2381
|
let cacheChanged = false;
|
|
@@ -2238,7 +2431,21 @@ export default {
|
|
|
2238
2431
|
row.toolCount = toolCounts[row.serverName] || 0;
|
|
2239
2432
|
if (row.enabledToolCount === undefined)
|
|
2240
2433
|
row.enabledToolCount = enabledToolCounts[row.serverName] || 0;
|
|
2241
|
-
|
|
2434
|
+
// 这三个**总是**覆盖:它们描述的是"此刻的真实状态",而缓存合并出来的那两个数
|
|
2435
|
+
// 描述的是"这台 server 有过哪些工具"。两者混淆过一次,代价是注入段谎报可用。
|
|
2436
|
+
row.liveToolCount = liveToolCounts[row.serverName] || 0;
|
|
2437
|
+
row.liveEnabledToolCount = liveEnabledToolCounts[row.serverName] || 0;
|
|
2438
|
+
row.knownToolCount = knownToolCounts[row.serverName] || 0;
|
|
2439
|
+
}
|
|
2440
|
+
// 列表顺序(用户要求 2026-09-17):**全局在前、应用级在后**,各级按服务器名排,
|
|
2441
|
+
// 当前在跑的 loader 行垫底。放在服务端而不是页面里:同一个顺序也是场景档案勾选器
|
|
2442
|
+
// (`scene-inventory`)与模型侧 `mcp_manager_list` 的顺序 —— 三处说同一件事。
|
|
2443
|
+
// 名字比较与技能页同一套(localeCompare),大小写混排时不会把大写全顶到前面。
|
|
2444
|
+
const levelRank = { global: 0, project: 1, loader: 2 };
|
|
2445
|
+
const serverNameOf = (row) => String(row.serverName || row.id || '');
|
|
2446
|
+
rows.sort((a, b) => (levelRank[a.level] ?? 3) - (levelRank[b.level] ?? 3) ||
|
|
2447
|
+
serverNameOf(a).localeCompare(serverNameOf(b)) ||
|
|
2448
|
+
String(a.id).localeCompare(String(b.id)));
|
|
2242
2449
|
// A loader id that appears twice (same id in both patch files, or twice in
|
|
2243
2450
|
// one) makes the composition fail to boot. Surface it instead of hiding it.
|
|
2244
2451
|
const idCounts = new Map();
|
|
@@ -2353,6 +2560,17 @@ export default {
|
|
|
2353
2560
|
}
|
|
2354
2561
|
const oldAbs = cur.level === 'global' ? p.globalPatch : p.projectPatch;
|
|
2355
2562
|
const newAbs = level === 'global' ? p.globalPatch : p.projectPatch;
|
|
2563
|
+
// 编辑表单不建模 config 里的全部键:手写补丁可能带 toolCallTimeoutMs 这类字段,
|
|
2564
|
+
// 重建前从旧条目原样带回,否则一次「编辑」就把它静默删掉。只透传 buildInsertBlock
|
|
2565
|
+
// 已有发射路径的键 —— 其余未知键的保真需要值保持式序列化(裸布尔/数字经
|
|
2566
|
+
// unquote/yq 往返会漂成字符串),不在此处理,宁可如实丢弃也不写错类型。
|
|
2567
|
+
try {
|
|
2568
|
+
const curRaw = parseRows(await readPatch(oldAbs)).rows.find((r) => r.id === id);
|
|
2569
|
+
if (curRaw && curRaw.config && curRaw.config.toolCallTimeoutMs != null) {
|
|
2570
|
+
row.toolCallTimeoutMs = curRaw.config.toolCallTimeoutMs;
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
catch { /* 旧文件读不了时按建模字段重建,与原行为一致 */ }
|
|
2356
2574
|
const block = buildInsertBlock(row);
|
|
2357
2575
|
return withWriteLock(async () => {
|
|
2358
2576
|
// Per-tool disable state is keyed by the serverName namespace: migrate
|
|
@@ -2368,6 +2586,20 @@ export default {
|
|
|
2368
2586
|
catch (e) { /* non-fatal */ }
|
|
2369
2587
|
disabledToolsCache = { at: Date.now(), value: toolMap };
|
|
2370
2588
|
}
|
|
2589
|
+
// F-025:运行时快照的 mcp 停用表同样按 serverName 键 —— 改名不跟着改,
|
|
2590
|
+
// 退出场景整体回写时旧键成死键、新名工具的停用状态丢失(回到全启用)。
|
|
2591
|
+
try {
|
|
2592
|
+
const slice = await rulesService.readArchiveSlice();
|
|
2593
|
+
const mode = slice && slice.mode;
|
|
2594
|
+
const mcpMap = mode && mode.snapshot && mode.snapshot.mcp;
|
|
2595
|
+
if (mcpMap && Object.prototype.hasOwnProperty.call(mcpMap, cur.serverName)) {
|
|
2596
|
+
const next = {};
|
|
2597
|
+
for (const [k, v] of Object.entries(mcpMap))
|
|
2598
|
+
next[k === cur.serverName ? serverName : k] = v;
|
|
2599
|
+
await rulesService.patchIndex({ mode: { ...mode, snapshot: { ...mode.snapshot, mcp: next } } });
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
catch { /* 快照同步失败不阻断改名本身;残留与修复前一致 */ }
|
|
2371
2603
|
}
|
|
2372
2604
|
if (oldAbs !== newAbs) {
|
|
2373
2605
|
// Level migration: remove from the old file, insert into the new one.
|
|
@@ -2550,6 +2782,16 @@ export default {
|
|
|
2550
2782
|
// waits deliberately run OUTSIDE the write lock — holding the global
|
|
2551
2783
|
// write lock for up to ~11s stalled every other write op.
|
|
2552
2784
|
await withWriteLock(async () => {
|
|
2785
|
+
// 并发护栏:阶段 1 的强制写自身必然把 effective 状态置为停用,所以走到这里时
|
|
2786
|
+
// 若读到「已启用」,只可能是等待窗口内别的写操作重新启用了它 —— 以现状为准并
|
|
2787
|
+
// 警告,绝不按重启前快照把用户的显式选择改回去。其余情形按 wasDisabled 原样恢复。
|
|
2788
|
+
// (已知残留:窗口内被「停用」与阶段 1 的写不可区分,仍按快照恢复 —— 重启语义
|
|
2789
|
+
// 本就是「状态与重启前一致」,方向性无害。)
|
|
2790
|
+
const currentDisabled = await isRowDisabled(id, level);
|
|
2791
|
+
if (!currentDisabled && wasDisabled) {
|
|
2792
|
+
warnings.push('重启等待窗口内该服务被重新启用:已保留启用状态,未按重启前状态恢复');
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2553
2795
|
let c = await readPatch(abs);
|
|
2554
2796
|
c = removeMarked(c, id, 'disable');
|
|
2555
2797
|
if (wasDisabled)
|
|
@@ -2693,9 +2935,13 @@ export default {
|
|
|
2693
2935
|
return { skipped: true, reason: 'serverName 已存在' };
|
|
2694
2936
|
if (idTaken && !overwrite)
|
|
2695
2937
|
return { skipped: true, reason: 'id 已存在' };
|
|
2938
|
+
// Overwrite: purge every trace of the id from BOTH patch files first,
|
|
2939
|
+
// then insert the imported row at its own level. The purge is not
|
|
2940
|
+
// atomic with the insert, so keep the pre-purge content of both files
|
|
2941
|
+
// and restore it when the insert fails — same trade-off as mcpmEdit's
|
|
2942
|
+
// level migration: losing the old entries is worse than a transient dup.
|
|
2943
|
+
const origs = [];
|
|
2696
2944
|
if (idTaken && overwrite) {
|
|
2697
|
-
// Purge every trace of the id from BOTH patch files first, then
|
|
2698
|
-
// insert the imported row at its own level.
|
|
2699
2945
|
for (const lvl of ['project', 'global']) {
|
|
2700
2946
|
const lAbs = lvl === 'global' ? p.globalPatch : p.projectPatch;
|
|
2701
2947
|
let lContent = '';
|
|
@@ -2705,24 +2951,50 @@ export default {
|
|
|
2705
2951
|
catch (e) {
|
|
2706
2952
|
continue;
|
|
2707
2953
|
}
|
|
2708
|
-
|
|
2954
|
+
origs.push({ abs: lAbs, content: lContent });
|
|
2709
2955
|
try {
|
|
2710
|
-
await writePatch(lAbs, lContent);
|
|
2956
|
+
await writePatch(lAbs, removeEntryAll(lContent, row.id));
|
|
2711
2957
|
}
|
|
2712
2958
|
catch (e) {
|
|
2713
|
-
|
|
2959
|
+
// 前面已清掉的文件也要恢复:purge 中途失败同样不能留下「旧条目没了」的状态。
|
|
2960
|
+
for (const o of origs) {
|
|
2961
|
+
try {
|
|
2962
|
+
await writePatch(o.abs, o.content);
|
|
2963
|
+
}
|
|
2964
|
+
catch { /* best effort */ }
|
|
2965
|
+
}
|
|
2966
|
+
return { skipped: true, reason: '覆盖旧条目失败(已回滚): ' + message(e) };
|
|
2714
2967
|
}
|
|
2715
2968
|
}
|
|
2716
2969
|
}
|
|
2717
2970
|
const abs = row.level === 'global' ? p.globalPatch : p.projectPatch;
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2971
|
+
try {
|
|
2972
|
+
let c = await readPatch(abs);
|
|
2973
|
+
const before = c;
|
|
2974
|
+
c = appendBlock(c, buildInsertBlock(row));
|
|
2975
|
+
if (row.disabled)
|
|
2976
|
+
c = appendBlock(c, buildDisableBlock(row.id, true));
|
|
2977
|
+
const guard = duplicateGuard(before, c);
|
|
2978
|
+
if (guard) {
|
|
2979
|
+
for (const o of origs) {
|
|
2980
|
+
try {
|
|
2981
|
+
await writePatch(o.abs, o.content);
|
|
2982
|
+
}
|
|
2983
|
+
catch { /* best effort */ }
|
|
2984
|
+
}
|
|
2985
|
+
return { skipped: true, reason: guard.error };
|
|
2986
|
+
}
|
|
2987
|
+
await writePatch(abs, c);
|
|
2988
|
+
}
|
|
2989
|
+
catch (e) {
|
|
2990
|
+
for (const o of origs) {
|
|
2991
|
+
try {
|
|
2992
|
+
await writePatch(o.abs, o.content);
|
|
2993
|
+
}
|
|
2994
|
+
catch { /* best effort */ }
|
|
2995
|
+
}
|
|
2996
|
+
return { skipped: true, reason: '写入失败(已回滚): ' + message(e) };
|
|
2997
|
+
}
|
|
2726
2998
|
return { added: true, wasOverwrite: idTaken && overwrite };
|
|
2727
2999
|
});
|
|
2728
3000
|
if (res.added) {
|
|
@@ -3354,10 +3626,12 @@ export default {
|
|
|
3354
3626
|
ctx.logger?.warn?.('agents-md: reference probe failed; remove allowed');
|
|
3355
3627
|
const why = refs ? refs.get(id) || [] : [];
|
|
3356
3628
|
if (why.length) {
|
|
3629
|
+
const refsText = why.map(promptRefReason).join(';');
|
|
3357
3630
|
return {
|
|
3358
3631
|
ok: false,
|
|
3359
3632
|
code: 'error.agentsMd.referenced',
|
|
3360
|
-
error: `「${id}」仍被引用,不能删除:${
|
|
3633
|
+
error: `「${id}」仍被引用,不能删除:${refsText}。先改掉引用(换绑提示词 / 退出场景 / 应用别的预设)再删除。`,
|
|
3634
|
+
params: { id, refs: refsText },
|
|
3361
3635
|
};
|
|
3362
3636
|
}
|
|
3363
3637
|
return agentsMdService.remove(id);
|
|
@@ -3612,6 +3886,18 @@ export default {
|
|
|
3612
3886
|
},
|
|
3613
3887
|
// 注入设置(读 / 写):压制型预设下是否仍然注入 + 各域开关。界面在「兼容」页。
|
|
3614
3888
|
'inject-settings': (args) => injectSettingsOp(args),
|
|
3889
|
+
// 注入实况(只读):最近活跃会话里模型**真正看到**的五域文本 + 本次运行的投递统计。
|
|
3890
|
+
// 回答"勾了开关到底送没送到"——界面配置与实际注入不一致时,这里一眼可见。
|
|
3891
|
+
'injection-live': async () => {
|
|
3892
|
+
if (contextInjectorLive === null)
|
|
3893
|
+
return { ok: false, error: '注入通道未装配' };
|
|
3894
|
+
try {
|
|
3895
|
+
return { ok: true, ...contextInjectorLive() };
|
|
3896
|
+
}
|
|
3897
|
+
catch (e) {
|
|
3898
|
+
return { ok: false, error: message(e) };
|
|
3899
|
+
}
|
|
3900
|
+
},
|
|
3615
3901
|
'history-unarchive': async (args) => {
|
|
3616
3902
|
const registry = getHistoryRegistry();
|
|
3617
3903
|
if (!registry)
|
|
@@ -4009,33 +4295,327 @@ export default {
|
|
|
4009
4295
|
return null;
|
|
4010
4296
|
}
|
|
4011
4297
|
}
|
|
4012
|
-
/**
|
|
4013
|
-
async function
|
|
4298
|
+
/** 「当前场景已锁定」的拒绝文案(模型工具用;无活动场景 / 未锁定 → null)。 */
|
|
4299
|
+
async function lockedSceneGuard() {
|
|
4014
4300
|
const scene = await activeSceneName();
|
|
4015
4301
|
if (!scene)
|
|
4016
4302
|
return null;
|
|
4017
|
-
|
|
4303
|
+
const locked = await lockedSceneNames();
|
|
4304
|
+
return locked.includes(scene) ? `场景「${scene}」已锁定:先到场景页解锁再改。` : null;
|
|
4305
|
+
}
|
|
4306
|
+
// ---------- 场景内改开关 = 同步改档案(v0.9.2)----------
|
|
4307
|
+
/**
|
|
4308
|
+
* 用户裁定(2026-09-17):场景**未锁定**时,页面上的开关(MCP / 技能 / 子智能体 / 提示词)
|
|
4309
|
+
* 照常可用,改动**同步写进当前场景的档案**;只有「锁定」才冻结这些开关。
|
|
4310
|
+
* 记忆不在此列 —— 它的开关是 `rules[*].enabled` 单一真相源,本来就不经过场景档案。
|
|
4311
|
+
*
|
|
4312
|
+
* 为什么必须同步:档案的语义是「勾选集 = 该场景下开着的东西,段未定义 = 全关」(见
|
|
4313
|
+
* archive-engine 的四域同口径)。只改运行时、不改档案,退出场景会被快照整体回滚 ——
|
|
4314
|
+
* 用户看到的「改了」是假的;写进档案才既当下生效、下次进这个场景又照样生效。
|
|
4315
|
+
* 新增 / 改名 / 删除 / 备注 / 导入导出这些**内容**操作不进档案(它们不是场景维度)。
|
|
4316
|
+
*/
|
|
4317
|
+
/**
|
|
4318
|
+
* 技能档案 key 映射:`<来源 key>\u0000<条目名>` → `{ 档案 key, selectable }`。
|
|
4319
|
+
*
|
|
4320
|
+
* `selectable=false` = 被同名技能覆盖的副本,或结构不完整(core 拒绝启停)——
|
|
4321
|
+
* 这两种都**不可能生效**,勾进档案只会让「档案说开着、技能页说没启动」(用户实测)。
|
|
4322
|
+
* `skill-disable` 不受此限:它只是把键从档案里删掉,任何键都可以删。
|
|
4323
|
+
*/
|
|
4324
|
+
async function skillArchiveKeys() {
|
|
4325
|
+
const map = new Map();
|
|
4326
|
+
try {
|
|
4327
|
+
const r = await skillsService.ops['skill-state']({});
|
|
4328
|
+
for (const root of ((r && r.data && r.data.roots) || [])) {
|
|
4329
|
+
const rootKey = String((root && root.key) || '');
|
|
4330
|
+
if (!rootKey)
|
|
4331
|
+
continue;
|
|
4332
|
+
for (const sk of ((root && root.skills) || [])) {
|
|
4333
|
+
const entry = String((sk && sk.name) || '');
|
|
4334
|
+
if (!entry)
|
|
4335
|
+
continue;
|
|
4336
|
+
// 与 skillRows / skillScan 同口径:档案里存**声明名**(frontmatter name)。
|
|
4337
|
+
// 用条目名拼 key 会被保存时的 stale 校验当成未知键丢掉。
|
|
4338
|
+
map.set(rootKey + '\u0000' + entry, {
|
|
4339
|
+
key: rootKey + '/' + String(sk.declaredName || entry),
|
|
4340
|
+
selectable: !sk.shadowedBy && sk.loadable !== false,
|
|
4341
|
+
});
|
|
4342
|
+
}
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4345
|
+
catch { /* 读不到状态 → 调用方退化为原样拼 key */ }
|
|
4346
|
+
return map;
|
|
4347
|
+
}
|
|
4348
|
+
/**
|
|
4349
|
+
* 某个来源下的技能档案 key(来源级开关用):
|
|
4350
|
+
* - `all`:该来源下**全部**键 —— 停用来源时按它清理(连历史残留一起清掉);
|
|
4351
|
+
* - `selectable`:真正**生效得了**的那些 —— 启用来源时只并入这一批。
|
|
4352
|
+
*/
|
|
4353
|
+
async function skillArchiveKeysOfRoot(rootKey) {
|
|
4354
|
+
const all = [];
|
|
4355
|
+
const selectable = [];
|
|
4356
|
+
try {
|
|
4357
|
+
const r = await skillsService.ops['skill-state']({});
|
|
4358
|
+
for (const root of ((r && r.data && r.data.roots) || [])) {
|
|
4359
|
+
if (String((root && root.key) || '') !== rootKey)
|
|
4360
|
+
continue;
|
|
4361
|
+
for (const sk of ((root && root.skills) || [])) {
|
|
4362
|
+
const name = String((sk && sk.declaredName) || (sk && sk.name) || '');
|
|
4363
|
+
if (!name)
|
|
4364
|
+
continue;
|
|
4365
|
+
const key = rootKey + '/' + name;
|
|
4366
|
+
all.push(key);
|
|
4367
|
+
if (!sk.shadowedBy && sk.loadable !== false)
|
|
4368
|
+
selectable.push(key);
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
}
|
|
4372
|
+
catch { /* 读不到 → 空列表 */ }
|
|
4373
|
+
return { all, selectable };
|
|
4374
|
+
}
|
|
4375
|
+
/** 字符串数组的「勾上 / 取消」(已处于目标状态时原样返回,避免无谓的档案写入)。 */
|
|
4376
|
+
function toggleInList(list, key, on) {
|
|
4377
|
+
if (!key)
|
|
4378
|
+
return list;
|
|
4379
|
+
const has = list.indexOf(key) >= 0;
|
|
4380
|
+
if (on === has)
|
|
4381
|
+
return list;
|
|
4382
|
+
return on ? [...list, key] : list.filter((x) => x !== key);
|
|
4383
|
+
}
|
|
4384
|
+
/** loader 条目 id → 服务器名(档案的 mcp 段按 serverName 存)。 */
|
|
4385
|
+
async function serverNameOfId(id) {
|
|
4386
|
+
try {
|
|
4387
|
+
const r = await mcpmListView();
|
|
4388
|
+
for (const row of ((r && r.rows) || [])) {
|
|
4389
|
+
if (String((row && row.id) || '') === id)
|
|
4390
|
+
return String(row.serverName || row.id);
|
|
4391
|
+
}
|
|
4392
|
+
}
|
|
4393
|
+
catch { /* 读不到 → null(这一笔不同步,如实跳过) */ }
|
|
4394
|
+
return null;
|
|
4018
4395
|
}
|
|
4019
4396
|
/**
|
|
4020
|
-
*
|
|
4397
|
+
* 某台服务器当前**启用**的工具名(把档案里的 `'*'` 具化成具体名单时用)。
|
|
4021
4398
|
*
|
|
4022
|
-
*
|
|
4023
|
-
*
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4399
|
+
* live schema 优先(就是此刻真的存在的工具);服务器没跑起来时退回「最后见过的工具名单 −
|
|
4400
|
+
* 停用表」——与 `computeMcpPlan` 的 `knownTools` 同源,别把「不知道」当成「一个都没有」。
|
|
4401
|
+
*/
|
|
4402
|
+
async function enabledToolNames(server) {
|
|
4403
|
+
const live = await (async () => {
|
|
4404
|
+
try {
|
|
4405
|
+
const r = await mcpmTools({ serverName: server });
|
|
4406
|
+
return ((r && r.tools) || []).filter((t) => t.enabled !== false).map((t) => String(t.name));
|
|
4407
|
+
}
|
|
4408
|
+
catch {
|
|
4409
|
+
return [];
|
|
4410
|
+
}
|
|
4411
|
+
})();
|
|
4412
|
+
if (live.length)
|
|
4413
|
+
return live;
|
|
4414
|
+
try {
|
|
4415
|
+
const known = (await readKnownMcpTools())[server] || [];
|
|
4416
|
+
const off = new Set(await readDisabledTools().then((m) => m[server] || []));
|
|
4417
|
+
if (off.has('*'))
|
|
4418
|
+
return [];
|
|
4419
|
+
return known.map((item) => String(item.name)).filter((n) => !off.has(n));
|
|
4420
|
+
}
|
|
4421
|
+
catch {
|
|
4422
|
+
return [];
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
/**
|
|
4426
|
+
* 把一次开关落到当前场景的档案上。
|
|
4427
|
+
* 返回 null = 无需改动(没有活动场景 / 已锁定 / 这一笔不影响档案);
|
|
4428
|
+
* 返回字符串 = 档案没跟上(运行时已经生效,如实告诉用户,而不是假装成功)。
|
|
4429
|
+
*/
|
|
4430
|
+
async function syncSwitchToScene(opName, args) {
|
|
4431
|
+
const scene = await activeSceneName();
|
|
4432
|
+
if (!scene)
|
|
4433
|
+
return null;
|
|
4434
|
+
if ((await lockedSceneNames()).includes(scene))
|
|
4435
|
+
return null;
|
|
4436
|
+
// 开关方向。**先按 op 名判定「动词即方向」的那几个**,再看 `args.enabled`:
|
|
4437
|
+
// `skill-enable` / `skill-disable` / `skill-source-enable` / `skill-source-disable`
|
|
4438
|
+
// 的参数里**根本没有 `enabled` 字段**(只有 root / name),早先统一读 `args.enabled`
|
|
4439
|
+
// 会把它们一律读成 false,于是「在场景里打开某个技能」被同步成「关闭」——目标集合
|
|
4440
|
+
// 一次变化都没有 → 直接早退不写盘,用户看到的就是「开关了但档案不跟着变」
|
|
4441
|
+
// (2026-09-17 用真实产物复现)。反向更糟:启用一个来源会被同步成「把该来源下的
|
|
4442
|
+
// 技能全部从档案里删掉」,档案与运行时当场对不上。
|
|
4443
|
+
// 其余 op 仍按宿主逐字对齐:`mcpm-tool-enabled` 的语义是「缺省 = 启用」(`enabled !== false`)。
|
|
4444
|
+
const enabled = opName === 'skill-enable' || opName === 'skill-source-enable'
|
|
4445
|
+
? true
|
|
4446
|
+
: opName === 'skill-disable' || opName === 'skill-source-disable'
|
|
4447
|
+
? false
|
|
4448
|
+
: args && typeof args.enabled === 'boolean'
|
|
4449
|
+
? args.enabled === true
|
|
4450
|
+
: opName === 'mcpm-tool-enabled';
|
|
4451
|
+
let next;
|
|
4452
|
+
let cur = {};
|
|
4453
|
+
try {
|
|
4454
|
+
const slice = await rulesService.readArchiveSlice();
|
|
4455
|
+
cur = (slice.archives && slice.archives[scene]) || {};
|
|
4456
|
+
next = { ...cur };
|
|
4457
|
+
switch (opName) {
|
|
4458
|
+
case 'skill-enable':
|
|
4459
|
+
case 'skill-disable': {
|
|
4460
|
+
const root = String((args && args.root) || 'dsh');
|
|
4461
|
+
const entry = String((args && args.name) || '');
|
|
4462
|
+
const hit = (await skillArchiveKeys()).get(root + '\u0000' + entry);
|
|
4463
|
+
const key = (hit && hit.key) || root + '/' + entry;
|
|
4464
|
+
// 勾进来这件事只对**生效得了**的技能做:被同名覆盖的副本、结构不完整的技能
|
|
4465
|
+
// 写进去也永远不生效(运行时那一步本来就跳过了它们),档案就会开始说谎。
|
|
4466
|
+
// 取消勾选(enabled=false)不受限——它只是把键删掉,历史残留正好顺手清掉。
|
|
4467
|
+
if (enabled && hit && !hit.selectable)
|
|
4468
|
+
return null;
|
|
4469
|
+
next.skills = toggleInList(Array.isArray(next.skills) ? next.skills.slice() : [], key, enabled);
|
|
4470
|
+
break;
|
|
4471
|
+
}
|
|
4472
|
+
case 'skill-set-all': {
|
|
4473
|
+
const items = Array.isArray(args && args.items) ? args.items : [];
|
|
4474
|
+
const map = await skillArchiveKeys();
|
|
4475
|
+
let list = Array.isArray(next.skills) ? next.skills.slice() : [];
|
|
4476
|
+
for (const item of items) {
|
|
4477
|
+
const root = String((item && item.root) || '');
|
|
4478
|
+
const entry = String((item && item.name) || '');
|
|
4479
|
+
if (!root || !entry)
|
|
4480
|
+
continue;
|
|
4481
|
+
const hit = map.get(root + '\u0000' + entry);
|
|
4482
|
+
if (enabled && hit && !hit.selectable)
|
|
4483
|
+
continue;
|
|
4484
|
+
list = toggleInList(list, (hit && hit.key) || root + '/' + entry, enabled);
|
|
4485
|
+
}
|
|
4486
|
+
next.skills = list;
|
|
4487
|
+
break;
|
|
4488
|
+
}
|
|
4489
|
+
case 'skill-source-enable':
|
|
4490
|
+
case 'skill-source-disable': {
|
|
4491
|
+
// 来源级没有独立字段:档案靠「来源下有没有被勾的技能」反推(computeSkillsPlan)。
|
|
4492
|
+
// 启用时只并入**生效得了**的技能(被覆盖的副本/结构不完整的整批不进档案);
|
|
4493
|
+
// 停用时按**全部**键清(连历史残留一起清掉)。
|
|
4494
|
+
const keys = await skillArchiveKeysOfRoot(String((args && args.root) || ''));
|
|
4495
|
+
const pick = enabled ? keys.selectable : keys.all;
|
|
4496
|
+
let list = Array.isArray(next.skills) ? next.skills.slice() : [];
|
|
4497
|
+
if (enabled) {
|
|
4498
|
+
for (const k of pick)
|
|
4499
|
+
if (list.indexOf(k) < 0)
|
|
4500
|
+
list.push(k);
|
|
4501
|
+
}
|
|
4502
|
+
else {
|
|
4503
|
+
const drop = new Set(pick);
|
|
4504
|
+
list = list.filter((k) => !drop.has(k));
|
|
4505
|
+
}
|
|
4506
|
+
next.skills = list;
|
|
4507
|
+
break;
|
|
4508
|
+
}
|
|
4509
|
+
case 'mcpm-set-enabled': {
|
|
4510
|
+
const server = await serverNameOfId(String((args && args.id) || ''));
|
|
4511
|
+
if (!server)
|
|
4512
|
+
return null;
|
|
4513
|
+
next.mcp = { ...(next.mcp || {}) };
|
|
4514
|
+
if (enabled)
|
|
4515
|
+
next.mcp[server] = '*';
|
|
4516
|
+
else
|
|
4517
|
+
delete next.mcp[server];
|
|
4518
|
+
break;
|
|
4519
|
+
}
|
|
4520
|
+
case 'mcpm-set-all': {
|
|
4521
|
+
if (!enabled) {
|
|
4522
|
+
next.mcp = {};
|
|
4523
|
+
break;
|
|
4524
|
+
}
|
|
4525
|
+
const all = {};
|
|
4526
|
+
try {
|
|
4527
|
+
const r = await mcpmListView();
|
|
4528
|
+
for (const row of ((r && r.rows) || [])) {
|
|
4529
|
+
const n = String((row && row.serverName) || '');
|
|
4530
|
+
if (n)
|
|
4531
|
+
all[n] = '*';
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
catch {
|
|
4535
|
+
return null;
|
|
4536
|
+
}
|
|
4537
|
+
next.mcp = all;
|
|
4538
|
+
break;
|
|
4539
|
+
}
|
|
4540
|
+
case 'mcpm-tool-enabled': {
|
|
4541
|
+
const server = String((args && args.serverName) || '');
|
|
4542
|
+
const tool = String((args && args.tool) || '');
|
|
4543
|
+
if (!server || !tool)
|
|
4544
|
+
return null;
|
|
4545
|
+
const has = next.mcp && Object.prototype.hasOwnProperty.call(next.mcp, server);
|
|
4546
|
+
// 服务器没勾 = 该场景下这台服务器关着,工具级开关在运行时本来就不生效(工具不存在)。
|
|
4547
|
+
if (!has)
|
|
4548
|
+
return null;
|
|
4549
|
+
const spec = next.mcp[server];
|
|
4550
|
+
if (spec === '*') {
|
|
4551
|
+
// '*' = 全部工具。只有「关掉某个工具」才需要具化;否则 '*' 已经涵盖了启用这一笔。
|
|
4552
|
+
if (enabled)
|
|
4553
|
+
return null;
|
|
4554
|
+
const live = await enabledToolNames(server);
|
|
4555
|
+
next.mcp = { ...next.mcp, [server]: live.filter((t) => t !== tool) };
|
|
4556
|
+
}
|
|
4557
|
+
else {
|
|
4558
|
+
const list = Array.isArray(spec) ? spec.slice() : [];
|
|
4559
|
+
next.mcp = {
|
|
4560
|
+
...next.mcp,
|
|
4561
|
+
[server]: enabled
|
|
4562
|
+
? (list.indexOf(tool) < 0 ? [...list, tool] : list)
|
|
4563
|
+
: list.filter((t) => t !== tool),
|
|
4564
|
+
};
|
|
4565
|
+
}
|
|
4566
|
+
break;
|
|
4567
|
+
}
|
|
4568
|
+
case 'subagent-toggle': {
|
|
4569
|
+
const name = String((args && args.name) || '');
|
|
4570
|
+
next.subagents = toggleInList(Array.isArray(next.subagents) ? next.subagents.slice() : [], name, enabled);
|
|
4571
|
+
break;
|
|
4572
|
+
}
|
|
4573
|
+
default: return null;
|
|
4574
|
+
}
|
|
4575
|
+
}
|
|
4576
|
+
catch (e) {
|
|
4577
|
+
return message(e);
|
|
4578
|
+
}
|
|
4579
|
+
// 空段 = 未定义(「一个都没勾 = 全关」),删掉以免两种语义并存(与 scene-archive-save 同口径)。
|
|
4580
|
+
for (const seg of ['skills', 'subagents']) {
|
|
4581
|
+
const list = next[seg];
|
|
4582
|
+
if (Array.isArray(list) && list.length === 0)
|
|
4583
|
+
delete next[seg];
|
|
4584
|
+
}
|
|
4585
|
+
if (next.mcp && Object.keys(next.mcp).length === 0)
|
|
4586
|
+
delete next.mcp;
|
|
4587
|
+
if (JSON.stringify(next) === JSON.stringify(cur))
|
|
4588
|
+
return null; // 档案本来就是这样 → 不写盘
|
|
4589
|
+
// 与「在档案弹窗里点保存」走同一条路:落盘 + 就地重应用 + 快照并入(退出仍按进场景前还原)。
|
|
4590
|
+
try {
|
|
4591
|
+
const save = archiveService.ops['scene-archive-save'];
|
|
4592
|
+
const res = await save({ scene, archive: next });
|
|
4593
|
+
if (res && res.ok === false)
|
|
4594
|
+
return String(res.error || '场景档案保存失败');
|
|
4595
|
+
if (res && res.applyError)
|
|
4596
|
+
return String(res.applyError);
|
|
4597
|
+
}
|
|
4598
|
+
catch (e) {
|
|
4599
|
+
return message(e);
|
|
4600
|
+
}
|
|
4601
|
+
return null;
|
|
4602
|
+
}
|
|
4603
|
+
/**
|
|
4604
|
+
* 包一层:开关成功后把改动同步进当前场景档案(见 syncSwitchToScene)。
|
|
4605
|
+
* 失败只挂 `sceneSyncError`,不改写原操作的成功结论 —— 运行时确实改了,档案没跟上要说得清。
|
|
4606
|
+
* 锁定仍由 `guardLockedOps` 挡住(更靠内的那层包装先执行)。
|
|
4028
4607
|
*/
|
|
4029
|
-
function
|
|
4608
|
+
function syncSceneArchiveOnSwitch(opNames) {
|
|
4030
4609
|
for (const opName of opNames) {
|
|
4031
4610
|
const original = handlers[opName];
|
|
4032
4611
|
if (typeof original !== 'function')
|
|
4033
4612
|
continue;
|
|
4034
4613
|
handlers[opName] = async (args) => {
|
|
4035
|
-
const
|
|
4036
|
-
if (
|
|
4037
|
-
return
|
|
4038
|
-
|
|
4614
|
+
const res = await original(args);
|
|
4615
|
+
if (!res || res.ok === false)
|
|
4616
|
+
return res;
|
|
4617
|
+
const err = await syncSwitchToScene(opName, args);
|
|
4618
|
+
return err ? { ...res, sceneSyncError: err } : res;
|
|
4039
4619
|
};
|
|
4040
4620
|
}
|
|
4041
4621
|
}
|
|
@@ -4055,9 +4635,9 @@ export default {
|
|
|
4055
4635
|
guardLockedOps([
|
|
4056
4636
|
// MCP:改配置 / 服务器启停 / 工具启停 / 导入导出配置 / 备注 / 设置。restart 只重连不改配置,放行。
|
|
4057
4637
|
'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact', 'mcpm-note', 'mcpm-settings',
|
|
4058
|
-
// 技能:启停 / 来源启停与移除恢复 / 首选 / 删除 / 创建导入 / 自定义目录 /
|
|
4638
|
+
// 技能:启停 / 来源启停与移除恢复 / 首选 / 删除 / 创建导入 / 自定义目录 / 回收站 / 批量启停。
|
|
4059
4639
|
'skill-enable', 'skill-disable', 'skill-source-enable', 'skill-source-disable', 'skill-source-remove', 'skill-source-restore',
|
|
4060
|
-
'skill-prefer', 'skill-unprefer', 'skill-delete', 'skill-create', 'skill-import', 'skill-upload',
|
|
4640
|
+
'skill-prefer', 'skill-unprefer', 'skill-delete', 'skill-create', 'skill-import', 'skill-upload', 'skill-set-all',
|
|
4061
4641
|
'skill-custom-add', 'skill-custom-remove', 'skill-trash-restore', 'skill-trash-delete',
|
|
4062
4642
|
// 子智能体:开关 / 改名保存 / 删除 / 导入 / 回收站。
|
|
4063
4643
|
'subagent-create', 'subagent-update', 'subagent-delete', 'subagent-toggle', 'subagent-import', 'subagent-trash-restore', 'subagent-trash-delete',
|
|
@@ -4066,14 +4646,13 @@ export default {
|
|
|
4066
4646
|
// 提示词:建改删 / 应用(切换生效基线)/ 导入 / 回收站。
|
|
4067
4647
|
'agentsmd-create', 'agentsmd-update', 'agentsmd-remove', 'agentsmd-apply', 'agentsmd-import', 'agentsmd-trash-restore', 'agentsmd-trash-delete',
|
|
4068
4648
|
], '修改');
|
|
4069
|
-
//
|
|
4070
|
-
//
|
|
4071
|
-
|
|
4649
|
+
// 场景内「开关」类操作(用户裁定 2026-09-17):未锁定时**可用**,改动同步进当前场景档案。
|
|
4650
|
+
// 与「锁定」正交:锁定冻结全部写操作,这里只是把页面开关的意图也写进档案。
|
|
4651
|
+
syncSceneArchiveOnSwitch([
|
|
4072
4652
|
'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',
|
|
4653
|
+
'skill-enable', 'skill-disable', 'skill-set-all', 'skill-source-enable', 'skill-source-disable',
|
|
4075
4654
|
'subagent-toggle',
|
|
4076
|
-
]
|
|
4655
|
+
]);
|
|
4077
4656
|
// 被锁场景自身的档案与删除:只挡它自己,别的场景照常。
|
|
4078
4657
|
for (const [opName, pickScene, what] of [
|
|
4079
4658
|
['scene-archive-save', (args) => (args && args.scene) || '', '改档案'],
|
|
@@ -4107,9 +4686,50 @@ export default {
|
|
|
4107
4686
|
}
|
|
4108
4687
|
// ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
|
|
4109
4688
|
const text = (value) => [{ type: 'text', text: value }];
|
|
4689
|
+
/**
|
|
4690
|
+
* 采纳遥测:把每个工具包一层,模型一调就记一笔"哪个域被伸手了"(见 context-inject 的
|
|
4691
|
+
* `noteToolUse`)。计数在宿主工具对象之外,所以**参数校验失败也算**——我们要测的是
|
|
4692
|
+
* "模型知不知道有这个域、会不会去用",不是"调用写得对不对"。
|
|
4693
|
+
*
|
|
4694
|
+
* 只统计本插件自己的工具,不走 `tools/result` 这类宿主事件:事件的作用域语义一旦变化,
|
|
4695
|
+
* 遥测会静默变成 0,而 0 恰好会被读成"模型从来不用"——一个假结论比没有结论更糟。
|
|
4696
|
+
* 包自己的工具是确定性的:工具在,观测就在。
|
|
4697
|
+
*/
|
|
4698
|
+
const trackAdoption = (def) => {
|
|
4699
|
+
const name = String(def.name ?? '');
|
|
4700
|
+
const run = def.execute;
|
|
4701
|
+
if (name === '' || typeof run !== 'function')
|
|
4702
|
+
return def;
|
|
4703
|
+
return {
|
|
4704
|
+
...def,
|
|
4705
|
+
async execute(args, exec) {
|
|
4706
|
+
try {
|
|
4707
|
+
if (contextInjectorNote !== null)
|
|
4708
|
+
contextInjectorNote(name, exec && exec.agent);
|
|
4709
|
+
}
|
|
4710
|
+
catch { /* 遥测绝不能影响工具 */ }
|
|
4711
|
+
return run(args, exec);
|
|
4712
|
+
},
|
|
4713
|
+
};
|
|
4714
|
+
};
|
|
4715
|
+
/**
|
|
4716
|
+
* 与宿主 `defineTool` **同签名的**本地包装。
|
|
4717
|
+
*
|
|
4718
|
+
* 为什么用同名遮蔽而不是逐个改 14 个注册点:采纳遥测必须覆盖**每一个**域工具,
|
|
4719
|
+
* 漏一个就得到"这个域从来没用过"的假结论。遮蔽 import 让"新加工具自动被统计"成为
|
|
4720
|
+
* 默认,而不是靠后来者记得手动加。
|
|
4721
|
+
*
|
|
4722
|
+
* 类型上刻意**不重写签名**,而是把箭头函数断言成 `typeof hostDefineTool`:宿主签名带
|
|
4723
|
+
* `const S/O` 类型参数,照着写一遍会让 TS 在每个调用点上多展开一层 `InferObject<S, ?>`,
|
|
4724
|
+
* 直接撞 "Excessive stack depth comparing types"(实测)。断言成宿主自己的类型之后,
|
|
4725
|
+
* 14 个调用点的参数表推断与改造前**完全一致** —— 宿主改签名时该报错的地方照旧报错;
|
|
4726
|
+
* 包装体本身只是转发(参数收 `unknown`、原样交给宿主),不参与推断,也就没有可失效的类型。
|
|
4727
|
+
*/
|
|
4728
|
+
const hostDefineToolAny = hostDefineTool;
|
|
4729
|
+
const defineTool = ((options) => trackAdoption(hostDefineToolAny(options)));
|
|
4110
4730
|
tools.register(defineTool({
|
|
4111
4731
|
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.',
|
|
4732
|
+
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. The same list is injected into your context each turn (the「本机 MCP 服务器的当前状态」system-reminder); this tool is the raw view — all=true includes disabled servers. Defaults to enabled servers; pass all=true for every configured server.',
|
|
4113
4733
|
parameters: {
|
|
4114
4734
|
all: { type: 'boolean', description: 'Include disabled servers (default false).' },
|
|
4115
4735
|
},
|
|
@@ -4159,13 +4779,16 @@ export default {
|
|
|
4159
4779
|
},
|
|
4160
4780
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
4161
4781
|
async execute(args) {
|
|
4162
|
-
const blocked = await
|
|
4782
|
+
const blocked = await lockedSceneGuard();
|
|
4163
4783
|
if (blocked)
|
|
4164
4784
|
throw new Error(blocked);
|
|
4165
4785
|
const r = await mcpmSetEnabled({ id: args.id, level: args.level, enabled: args.enabled });
|
|
4166
4786
|
if (!r.ok)
|
|
4167
4787
|
throw new Error(r.error);
|
|
4168
|
-
|
|
4788
|
+
// 场景未锁定时,页面 / 模型改的开关都要落进当前场景的档案(与 handlers 层同一套同步)。
|
|
4789
|
+
const syncErr = await syncSwitchToScene('mcpm-set-enabled', args);
|
|
4790
|
+
return 'OK: ' + args.id + ' now ' + (args.enabled ? 'enabled' : 'disabled') +
|
|
4791
|
+
(syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '');
|
|
4169
4792
|
},
|
|
4170
4793
|
}));
|
|
4171
4794
|
tools.register(defineTool({
|
|
@@ -4198,6 +4821,9 @@ export default {
|
|
|
4198
4821
|
},
|
|
4199
4822
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
4200
4823
|
async execute(args) {
|
|
4824
|
+
const blocked = await lockedSceneGuard();
|
|
4825
|
+
if (blocked)
|
|
4826
|
+
throw new Error(blocked);
|
|
4201
4827
|
const r = await mcpmAdd(args);
|
|
4202
4828
|
if (!r.ok)
|
|
4203
4829
|
throw new Error(r.error);
|
|
@@ -4208,7 +4834,7 @@ export default {
|
|
|
4208
4834
|
// tools/pre-execute hook below (the model must ask before writing files).
|
|
4209
4835
|
tools.register(defineTool({
|
|
4210
4836
|
name: 'skill_manager_list',
|
|
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.',
|
|
4837
|
+
description: 'List DSH skills with enabled state, effective/shadowed status and source file path. The injected「本机技能目录」system-reminder carries callable skills and summaries only; use this tool to get a source file path (read the file for the full body) and to see entries that are off. Defaults to enabled skills only; pass all=true for every entry. A copy marked "shadowed by <root>" stays inactive even if enabled.',
|
|
4212
4838
|
parameters: {
|
|
4213
4839
|
all: { type: 'boolean', description: 'Include disabled and shadowed entries (default false).' },
|
|
4214
4840
|
},
|
|
@@ -4278,7 +4904,7 @@ export default {
|
|
|
4278
4904
|
},
|
|
4279
4905
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
4280
4906
|
async execute(args) {
|
|
4281
|
-
const blocked = await
|
|
4907
|
+
const blocked = await lockedSceneGuard();
|
|
4282
4908
|
if (blocked)
|
|
4283
4909
|
throw new Error(blocked);
|
|
4284
4910
|
const root = String(args.root || 'dsh');
|
|
@@ -4286,6 +4912,8 @@ export default {
|
|
|
4286
4912
|
const r = await skillsService.ops[op]({ name: args.name, root });
|
|
4287
4913
|
if (!r || r.ok === false)
|
|
4288
4914
|
throw new Error((r && r.error) || 'skill toggle failed');
|
|
4915
|
+
// 场景未锁定时同步进当前场景档案(与 handlers 层同一套同步)。
|
|
4916
|
+
const syncErr = await syncSwitchToScene(op, { name: args.name, root, enabled: args.enabled === true });
|
|
4289
4917
|
// 静默无效是这个工具最容易骗人的地方:同名胜负只由「首选 > 来源优先级」决定,
|
|
4290
4918
|
// 与启用状态无关。对影子副本操作会返回 OK,但技能依然不可用。如实说一句。
|
|
4291
4919
|
let shadowedBy = '';
|
|
@@ -4308,7 +4936,8 @@ export default {
|
|
|
4308
4936
|
const warn = shadowedBy
|
|
4309
4937
|
? ' — 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
4938
|
: '';
|
|
4311
|
-
|
|
4939
|
+
const sceneWarn = syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '';
|
|
4940
|
+
return 'OK: ' + args.name + ' now ' + (args.enabled ? 'enabled' : 'disabled') + warn + sceneWarn;
|
|
4312
4941
|
},
|
|
4313
4942
|
}));
|
|
4314
4943
|
tools.register(defineTool({
|
|
@@ -4324,6 +4953,9 @@ export default {
|
|
|
4324
4953
|
},
|
|
4325
4954
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
4326
4955
|
async execute(args) {
|
|
4956
|
+
const blocked = await lockedSceneGuard();
|
|
4957
|
+
if (blocked)
|
|
4958
|
+
throw new Error(blocked);
|
|
4327
4959
|
const r = await skillsService.ops['skill-create']({ name: args.name, description: args.description, body: args.body });
|
|
4328
4960
|
if (!r || r.ok === false)
|
|
4329
4961
|
throw new Error((r && r.error) || 'skill create failed');
|
|
@@ -4334,7 +4966,7 @@ export default {
|
|
|
4334
4966
|
// AGENTS.md 预设库:模型可查/切,不能造/删(避免模型乱删用户预设)。
|
|
4335
4967
|
tools.register(defineTool({
|
|
4336
4968
|
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.',
|
|
4969
|
+
description: 'List AGENTS.md presets (id, active state, file path). Read that file to see a preset body. The preset in effect right now is injected into your context each turn (the「本机提示词」system-reminder, whose「来源:」line names the file). Defaults to the preset currently in effect; pass all=true for the whole library.',
|
|
4338
4970
|
parameters: {
|
|
4339
4971
|
all: { type: 'boolean', description: 'Include inactive presets (default false).' },
|
|
4340
4972
|
},
|
|
@@ -4379,6 +5011,9 @@ export default {
|
|
|
4379
5011
|
},
|
|
4380
5012
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
4381
5013
|
async execute(args) {
|
|
5014
|
+
const blocked = await lockedSceneGuard();
|
|
5015
|
+
if (blocked)
|
|
5016
|
+
throw new Error(blocked);
|
|
4382
5017
|
const r = await applyPresetGuarded(args.id);
|
|
4383
5018
|
if (!r.ok)
|
|
4384
5019
|
throw new Error(r.error);
|
|
@@ -4391,7 +5026,7 @@ export default {
|
|
|
4391
5026
|
// 路径锚点:$DSH_HOME/tool-management/memories/<场景>/…(场景 `global` = 界面「全局」)。
|
|
4392
5027
|
tools.register(defineTool({
|
|
4393
5028
|
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.',
|
|
5029
|
+
description: 'List memories under ~/.dsh/tool-management/memories (id, scene, enabled, description). The ones actually injected are carried in your context each turn (the「本机当前的场景和记忆」system-reminder); use this tool to find ids/paths or to see entries that are off. Defaults to the memories that will actually be injected; pass all=true for every entry.',
|
|
4395
5030
|
parameters: {
|
|
4396
5031
|
group: { type: 'string', description: 'Optional scene filter.' },
|
|
4397
5032
|
all: { type: 'boolean', description: 'Include memories that are off, in an inactive scene, or shadowed (default false).' },
|
|
@@ -4457,6 +5092,9 @@ export default {
|
|
|
4457
5092
|
},
|
|
4458
5093
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
4459
5094
|
async execute(args) {
|
|
5095
|
+
const blocked = await lockedSceneGuard();
|
|
5096
|
+
if (blocked)
|
|
5097
|
+
throw new Error(blocked);
|
|
4460
5098
|
const r = await rulesService.ops['rules-create'](args);
|
|
4461
5099
|
if (!r || r.ok === false)
|
|
4462
5100
|
throw new Error((r && r.error) || '创建规则失败');
|
|
@@ -4537,11 +5175,13 @@ export default {
|
|
|
4537
5175
|
.catch(() => ({ kind: 'ask', reason: 'Write a memory under ~/.dsh/tool-management/memories' }));
|
|
4538
5176
|
}
|
|
4539
5177
|
// subagent_manager_run:子代理运行花真 token:默认确认(requireConfirmForModelSubagentRun !== false),可关。
|
|
5178
|
+
// `inherit: true` 时子代理会读到本次会话已完成的对话 —— 卡里如实说明(用户批准的是
|
|
5179
|
+
// "把这段对话交给它",不只是"跑个子代理")。文案只在与 fork 有关的部分分叉。
|
|
5180
|
+
const inherits = !!(exec && exec.arguments && exec.arguments.inherit);
|
|
5181
|
+
const ask = { kind: 'ask', reason: inherits ? 'Run a subagent on this conversation (it inherits the conversation; consumes tokens)' : 'Run a subagent (consumes tokens)' };
|
|
4540
5182
|
return readPluginSettings()
|
|
4541
|
-
.then((s) => (s.requireConfirmForModelSubagentRun !== false
|
|
4542
|
-
|
|
4543
|
-
: next()))
|
|
4544
|
-
.catch(() => ({ kind: 'ask', reason: 'Run a subagent (consumes tokens)' }));
|
|
5183
|
+
.then((s) => (s.requireConfirmForModelSubagentRun !== false ? ask : next()))
|
|
5184
|
+
.catch(() => ask);
|
|
4545
5185
|
});
|
|
4546
5186
|
}
|
|
4547
5187
|
// ---------- per-tool MCP switches: execution guard + visibility ----------
|
|
@@ -4658,7 +5298,7 @@ export default {
|
|
|
4658
5298
|
res.end(JSON.stringify({ ok: false, error: 'missing plugin gate header' }));
|
|
4659
5299
|
return;
|
|
4660
5300
|
}
|
|
4661
|
-
const tokenAccepted = TOKEN !== '' && hdr('x-dsh-token')
|
|
5301
|
+
const tokenAccepted = TOKEN !== '' && tokenMatches(hdr('x-dsh-token'));
|
|
4662
5302
|
if (!tokenAccepted) {
|
|
4663
5303
|
const fence = fenceRejection(req, connection);
|
|
4664
5304
|
if (fence) {
|
|
@@ -4692,7 +5332,7 @@ export default {
|
|
|
4692
5332
|
return;
|
|
4693
5333
|
}
|
|
4694
5334
|
}
|
|
4695
|
-
if (TOKEN && WRITE_OPS.has(op) && hdr('x-dsh-token')
|
|
5335
|
+
if (TOKEN && WRITE_OPS.has(op) && !tokenMatches(hdr('x-dsh-token'))) {
|
|
4696
5336
|
res.end(JSON.stringify({ ok: false, error: '缺少或错误的访问令牌(x-dsh-token)' }));
|
|
4697
5337
|
return;
|
|
4698
5338
|
}
|