dsh-plugin-tool-management 0.12.0 → 0.13.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 +52 -0
- package/README.md +11 -3
- package/README_EN.md +9 -3
- package/docs/update.md +46 -0
- package/lib/client.js +602 -51
- package/lib/context-inject.js +98 -28
- package/lib/index.js +305 -12
- package/lib/mcp/manager.js +115 -57
- package/lib/mcp/state-section.js +5 -2
- package/lib/memories/archive-engine.js +199 -0
- package/lib/memories/constants.js +1 -1
- package/lib/memories/snapshot.js +10 -0
- package/lib/op-registry.js +273 -0
- package/lib/ops/compat.js +15 -2
- package/lib/ops/state-doctor.js +115 -0
- package/lib/request-gate.js +21 -78
- package/lib/scene-settings.js +17 -0
- package/lib/skills/catalog.js +9 -4
- package/lib/skills/core.js +9 -0
- package/lib/subagents/catalog.js +9 -5
- package/lib/subagents/tools.js +9 -2
- package/lib/tools/mcp.js +3 -3
- package/lib/tools/memory.js +105 -5
- package/lib/tools/prompt.js +2 -2
- package/lib/tools/skills.js +81 -3
- package/lib/tools/subagent.js +117 -2
- package/lib/tools/table.js +101 -0
- package/package.json +108 -108
package/lib/index.js
CHANGED
|
@@ -22,11 +22,15 @@ import { isInsideRootResolved } from './paths.js';
|
|
|
22
22
|
import { DEFAULT_PROFILE_NAME, PROFILE_CANDIDATES } from './host-names.js';
|
|
23
23
|
import { createMemoriesService } from './memories/service.js';
|
|
24
24
|
import { createArchiveEngine } from './memories/archive-engine.js';
|
|
25
|
+
import { readIndexSync } from './memories/index-io.js';
|
|
26
|
+
import { runStateDoctor } from './ops/state-doctor.js';
|
|
25
27
|
import { createSubagentService, decideToolFilter, defaultPersonasDir } from './subagents/service.js';
|
|
26
28
|
import { createSubagentCatalog } from './subagents/catalog.js';
|
|
27
29
|
import { createSkillCatalog } from './skills/catalog.js';
|
|
28
30
|
import { DEFAULT_INJECT_SETTINGS, createContextInjector, normalizeInjectSettings, subagentDepthOf, } from './context-inject.js';
|
|
29
31
|
import { isApprovalNever } from './approval-policy.js';
|
|
32
|
+
import { buildToolTableReport, normalizeToolTableSettings, } from './tools/table.js';
|
|
33
|
+
import { normalizeSceneSettings } from './scene-settings.js';
|
|
30
34
|
import { checkPatchWrite, takePatchGuardWarnings } from './compat/patch-dialect.js';
|
|
31
35
|
import { clearRuntimeNote, noteRuntime } from './compat/runtime-notes.js';
|
|
32
36
|
import { reachNoticeForAgent } from './compat/preset-reach.js';
|
|
@@ -44,6 +48,7 @@ import { buildPromptOps } from './ops/prompts.js';
|
|
|
44
48
|
import { buildSessionOps } from './ops/sessions.js';
|
|
45
49
|
// HTTP 请求准入与 handlers 后处理(2026-09-19 从本文件 apply 闭包抽出,依赖显式传参)。
|
|
46
50
|
import { createAccessToken, createOpWhitelist, createSceneLock, installHandlerGuards } from './request-gate.js';
|
|
51
|
+
import { auditOpRegistry, auditProblems } from './op-registry.js';
|
|
47
52
|
import { buildSceneSyncOps } from './ops/scene-sync.js';
|
|
48
53
|
import { buildCandidateOps } from './ops/candidates.js';
|
|
49
54
|
// model 工具(ctx.tools.register)按域分文件;共享的依赖形状见 tools/deps.ts。
|
|
@@ -101,6 +106,8 @@ export const INJECT_SERVICES = [
|
|
|
101
106
|
'timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills', 'sessions',
|
|
102
107
|
'agents', 'workspaceRegistry', 'sessionProjectionCache', 'sessionPersistence',
|
|
103
108
|
];
|
|
109
|
+
/** 工具表设置预热完成前的答案:一个都不关(见 apply 里 toolTableHidden 的注释)。 */
|
|
110
|
+
const EMPTY_TOOL_SET = new Set();
|
|
104
111
|
export default {
|
|
105
112
|
name: 'dsh-plugin-tool-management-host',
|
|
106
113
|
inject: [...INJECT_SERVICES],
|
|
@@ -502,6 +509,125 @@ export default {
|
|
|
502
509
|
.filter((s) => s.active)
|
|
503
510
|
.map((s) => slice.archives[s.name]?.subagents ?? []);
|
|
504
511
|
};
|
|
512
|
+
// ---------- 模型工具表开关(侧车 `tool-table.json`,界面在「兼容」页)----------
|
|
513
|
+
// 工具表按**每个请求**付钱:20 个工具的整份定义合计 ≈3,500 tok 每轮都在。关掉某几个,
|
|
514
|
+
// 它们整份不进请求(实测口径与取舍见 src/tools/table.ts 的文件头)。
|
|
515
|
+
//
|
|
516
|
+
// 为什么放在目录之前:两个目录的「用 `X` 查」提示要跟着这份设置变(工具关掉后那句话
|
|
517
|
+
// 就是假的),所以它们的构造依赖在这块之后 —— 不做前向引用,顺序就是依赖顺序。
|
|
518
|
+
const TOOL_TABLE_FILE = 'tool-table.json';
|
|
519
|
+
const TOOL_TABLE_TTL_MS = 3000;
|
|
520
|
+
/** 注册时量到的工具体积(只记真进了表的那些;量的是 register 收到的整份定义)。 */
|
|
521
|
+
const toolTableSizes = new Map();
|
|
522
|
+
function recordToolSize(def) {
|
|
523
|
+
try {
|
|
524
|
+
const name = String(def.name ?? '');
|
|
525
|
+
if (name === '')
|
|
526
|
+
return;
|
|
527
|
+
// 与宿主同一口径(dsh-token-meter 的 estimateToolsTokens 就是把 tools 整份
|
|
528
|
+
// JSON.stringify 后除以 4),所以这里量整份定义,不只量 description。
|
|
529
|
+
toolTableSizes.set(name, JSON.stringify(def).length);
|
|
530
|
+
}
|
|
531
|
+
catch { /* 量不出来就不记:它只影响界面上的估算数字 */ }
|
|
532
|
+
}
|
|
533
|
+
let toolTableCache = null;
|
|
534
|
+
async function readToolTableSettings(force = false) {
|
|
535
|
+
if (toolTableCache && !force && Date.now() - toolTableCache.at < TOOL_TABLE_TTL_MS)
|
|
536
|
+
return toolTableCache.value;
|
|
537
|
+
await ensurePaths();
|
|
538
|
+
const raw = await readJsonFile(hubPath(TOOL_TABLE_FILE));
|
|
539
|
+
const value = normalizeToolTableSettings(raw);
|
|
540
|
+
const changed = toolTableCache === null || toolTableCache.value.hidden.join('\u0000') !== value.hidden.join('\u0000');
|
|
541
|
+
toolTableCache = { at: Date.now(), value, off: new Set(value.hidden) };
|
|
542
|
+
// 首读 / 文件被外部改过 ⇒ 可见性要跟着重排。同步快照的调用方(门禁、注入通道)不会
|
|
543
|
+
// 自己重排,它们只是"下次问的时候读到新值";把限制摘掉/装上这一步必须有人做。
|
|
544
|
+
// (`mcp` 在 apply 里是同步赋值、本函数只可能在自己的 await 之后回到这里,所以到得了。)
|
|
545
|
+
if (changed) {
|
|
546
|
+
try {
|
|
547
|
+
mcp.scheduleToolRestrictions();
|
|
548
|
+
}
|
|
549
|
+
catch { /* 兜底:拿不到 manager 就等下一次 tools/change */ }
|
|
550
|
+
}
|
|
551
|
+
return value;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* 同步快照(热路径用:工具门禁每次调用、注入通道每个 step 都要问一句)。还没加载时先给
|
|
555
|
+
* 默认值并异步预热 —— 默认是"什么都没关",预热前的这一瞬与用户的选择可能不一致,但
|
|
556
|
+
* 方向是安全的那一侧(不隐藏任何东西,绝不因为读盘慢而让工具凭空消失)。
|
|
557
|
+
*/
|
|
558
|
+
function toolTableHidden() {
|
|
559
|
+
if (toolTableCache === null) {
|
|
560
|
+
void readToolTableSettings().catch(() => { });
|
|
561
|
+
return EMPTY_TOOL_SET;
|
|
562
|
+
}
|
|
563
|
+
return toolTableCache.off;
|
|
564
|
+
}
|
|
565
|
+
const toolTableRows = () => [...toolTableSizes].map(([name, bytes]) => ({ name, bytes }));
|
|
566
|
+
const toolTableReport = (hidden = [...toolTableHidden()]) => buildToolTableReport(toolTableRows(), hidden);
|
|
567
|
+
async function toolTableOp(args) {
|
|
568
|
+
const current = await readToolTableSettings();
|
|
569
|
+
if (!args || args.set !== true)
|
|
570
|
+
return { ok: true, hidden: current.hidden, report: toolTableReport(current.hidden) };
|
|
571
|
+
const next = normalizeToolTableSettings({ hidden: args.hidden });
|
|
572
|
+
// 只收**已注册**的名字:写进来的陌生名字在下一次 restrict 时会让官方抛错
|
|
573
|
+
// ("names unknown global tool")。注册失败的工具本来也不在表里。
|
|
574
|
+
const hidden = next.hidden.filter((name) => toolTableSizes.has(name));
|
|
575
|
+
await ensurePaths();
|
|
576
|
+
// 落盘在写锁里,落完**出了锁**再刷新(`withWriteLock` 是一条不可重入的链:锁里再调
|
|
577
|
+
// 一次会等自己,直接卡死)。刷新是两处目录读,本来也不必占着写锁。
|
|
578
|
+
const saved = await withWriteLock(async () => {
|
|
579
|
+
try {
|
|
580
|
+
await writeJsonFile(hubPath(TOOL_TABLE_FILE), { hidden });
|
|
581
|
+
}
|
|
582
|
+
catch (e) {
|
|
583
|
+
return { ok: false, error: '设置保存失败: ' + message(e) };
|
|
584
|
+
}
|
|
585
|
+
toolTableCache = { at: Date.now(), value: { hidden }, off: new Set(hidden) };
|
|
586
|
+
return { ok: true };
|
|
587
|
+
});
|
|
588
|
+
if (!saved.ok)
|
|
589
|
+
return saved;
|
|
590
|
+
// 三处跟着变:可见性(restrict 名单重排)、两个目录(截断提示里点名的工具可能没了)。
|
|
591
|
+
// 目录的 refresh 会把新的一句话注入出去 —— 内容变了才重发,这是正确行为。
|
|
592
|
+
mcp.scheduleToolRestrictions();
|
|
593
|
+
await Promise.all([
|
|
594
|
+
skillCatalog.refresh().catch(() => { }),
|
|
595
|
+
subagentCatalog.refresh().catch(() => { }),
|
|
596
|
+
]);
|
|
597
|
+
return { ok: true, hidden, report: toolTableReport(hidden) };
|
|
598
|
+
}
|
|
599
|
+
// ---------- 「有官方等价物 ⇒ 我们让位」----------
|
|
600
|
+
// 官方 `skill` 工具(`@deepseek-ai/dsh-tool-skill`)与我们的 `skill_manager_read` 是同一
|
|
601
|
+
// 件事:按名字给正文。标准类预设下它在场,第二份就是白付的 174 tok/轮。
|
|
602
|
+
//
|
|
603
|
+
// 判据抄官方自己的写法(`dsh-tool-skill/lib/index.js:207`:`ctx.tools.get(skillTool.name,
|
|
604
|
+
// agent) === skillTool`)—— 拿 agent 当 scope 问"这个 agent 解析得到 `skill` 吗"。比读
|
|
605
|
+
// 预设组合文件更准:它观察的是**这个 agent 实际能看见什么**(限定到该 scope 的同名 shadow
|
|
606
|
+
// 也算数),而组合文件只能说"配了"。判不出来时返回空 = 不让位:少一条拿正文的路,比多花
|
|
607
|
+
// 174 tok 糟。
|
|
608
|
+
//
|
|
609
|
+
// 与注入侧的关系:那边(`CARRIER_FACT_OF`)按组合事实决定"官方目录在场就不注入我们的
|
|
610
|
+
// 目录",这边按解析结果决定"官方加载器在场就不下发我们的加载器"。两者只在一种情形下
|
|
611
|
+
// 不一致 —— 组合里挂了、但这个 agent 的 scope 里解析不到(被别的限制摘掉):那时注入侧
|
|
612
|
+
// 跳过我们的目录,这里仍然保留我们的工具,方向是对的(模型手里还有 list + read)。
|
|
613
|
+
/** `[官方工具名, 我们该让位的工具]` —— 只有技能这一对(官方没有 MCP 管理 / 记忆 / AGENTS 编辑工具)。 */
|
|
614
|
+
const CARRIER_DUPLICATES = [['skill', 'skill_manager_read']];
|
|
615
|
+
const carrierHiddenToolsFor = async (agent) => {
|
|
616
|
+
// 拿不到 agent 就什么都不让位:`get(name, undefined)` 问的是**全局视图**,而官方那些
|
|
617
|
+
// 工具是按预设挂进 agent 那一层的 —— 问错对象会得出相反的结论。
|
|
618
|
+
if (agent === null || (typeof agent !== 'object' && typeof agent !== 'function'))
|
|
619
|
+
return [];
|
|
620
|
+
const scope = agent;
|
|
621
|
+
const out = [];
|
|
622
|
+
for (const [carrier, ours] of CARRIER_DUPLICATES) {
|
|
623
|
+
try {
|
|
624
|
+
if (tools.get(carrier, scope) !== undefined)
|
|
625
|
+
out.push(ours);
|
|
626
|
+
}
|
|
627
|
+
catch { /* 读不到 = 不让位 */ }
|
|
628
|
+
}
|
|
629
|
+
return out;
|
|
630
|
+
};
|
|
505
631
|
// ---------- 注入通道(场景和记忆 / MCP / 技能 / 子智能体 / 提示词,各一条消息)----------
|
|
506
632
|
// 这些文本以前是 systemPrompt 段(persona complete 会整段压掉)。现在改走官方的
|
|
507
633
|
// 「每步注入一条合成消息」通道(skill-catalog / AGENTS.md / 时间上下文同款):
|
|
@@ -516,6 +642,8 @@ export default {
|
|
|
516
642
|
const subagentCatalog = createSubagentCatalog({
|
|
517
643
|
list: () => subagentService.list(),
|
|
518
644
|
sceneLists: subagentSceneLists,
|
|
645
|
+
// 人在「兼容」页把 `subagent_manager_list` 关掉后,截断提示里那句「用 X 查」就是假的。
|
|
646
|
+
listToolVisible: () => !toolTableHidden().has('subagent_manager_list'),
|
|
519
647
|
});
|
|
520
648
|
// ⚠️ 取数必须用 mcpmRowsWithNotes(false):true 会带上**未打码**的 url/headers/env(含明文密钥)。
|
|
521
649
|
// 技能目录与提示词两个域是**官方载体的兜底**:预设挂得到官方
|
|
@@ -523,6 +651,7 @@ export default {
|
|
|
523
651
|
// CARRIER_FACT_OF),挂不到(极简)时才由这里送 —— 所以极简下也能按开关决定要不要。
|
|
524
652
|
const skillCatalog = createSkillCatalog({
|
|
525
653
|
state: () => skillsService.ops['skill-state']({}),
|
|
654
|
+
listToolVisible: () => !toolTableHidden().has('skill_manager_list'),
|
|
526
655
|
});
|
|
527
656
|
// 注入设置(侧车 `inject-settings.json`,界面在「兼容」页):
|
|
528
657
|
// - underSuppressingPresets:压制型预设(persona complete / 关运行时上下文,如极简)
|
|
@@ -574,6 +703,40 @@ export default {
|
|
|
574
703
|
return { ok: true, settings: next };
|
|
575
704
|
});
|
|
576
705
|
}
|
|
706
|
+
// 场景页的界面设置(侧车 `scene-settings.json`):目前只有一项 —— 进入场景前要不要先弹
|
|
707
|
+
// 那张「会改什么」的卡。它纯粹是界面提示,所以**不进场景冻结**(锁着场景的人在场景页
|
|
708
|
+
// 照样能关掉提醒,那与五个管理域的只读无关)。
|
|
709
|
+
const SCENE_SETTINGS_FILE = 'scene-settings.json';
|
|
710
|
+
const SCENE_SETTINGS_TTL_MS = 3000;
|
|
711
|
+
let sceneSettingsCache = null;
|
|
712
|
+
async function readSceneSettings(force = false) {
|
|
713
|
+
if (sceneSettingsCache && !force && Date.now() - sceneSettingsCache.at < SCENE_SETTINGS_TTL_MS)
|
|
714
|
+
return sceneSettingsCache.value;
|
|
715
|
+
await ensurePaths();
|
|
716
|
+
const raw = await readJsonFile(hubPath(SCENE_SETTINGS_FILE));
|
|
717
|
+
const value = normalizeSceneSettings(raw);
|
|
718
|
+
sceneSettingsCache = { at: Date.now(), value };
|
|
719
|
+
return value;
|
|
720
|
+
}
|
|
721
|
+
async function sceneSettingsOp(args) {
|
|
722
|
+
const current = await readSceneSettings();
|
|
723
|
+
if (!args || args.set !== true)
|
|
724
|
+
return { ok: true, settings: current };
|
|
725
|
+
const next = normalizeSceneSettings({
|
|
726
|
+
enterPreview: typeof args.enterPreview === 'boolean' ? args.enterPreview : current.enterPreview,
|
|
727
|
+
});
|
|
728
|
+
await ensurePaths();
|
|
729
|
+
return withWriteLock(async () => {
|
|
730
|
+
try {
|
|
731
|
+
await writeJsonFile(hubPath(SCENE_SETTINGS_FILE), next);
|
|
732
|
+
}
|
|
733
|
+
catch (e) {
|
|
734
|
+
return { ok: false, error: '设置保存失败: ' + message(e) };
|
|
735
|
+
}
|
|
736
|
+
sceneSettingsCache = { at: Date.now(), value: next };
|
|
737
|
+
return { ok: true, settings: next };
|
|
738
|
+
});
|
|
739
|
+
}
|
|
577
740
|
// 注入实况(只读诊断):注入器的 live() 读回"最近活跃会话"可见表面上的五域文本。
|
|
578
741
|
// 挂在 apply 作用域:`injection-live` op 与注入器不在同一层(effect 内部)。
|
|
579
742
|
let contextInjectorLive = null;
|
|
@@ -619,6 +782,8 @@ export default {
|
|
|
619
782
|
{ key: 'prompt', name: 'tool-management:prompt', label: '提示词', form: 'instructions', text: () => memoriesService.promptText(), files: () => memoriesService.promptFiles() },
|
|
620
783
|
],
|
|
621
784
|
settings: () => injectSettingsSync(),
|
|
785
|
+
// 工具表开关:`how` 行里点名工具的那几句要跟着它换话术(关掉的不点名)。
|
|
786
|
+
hiddenTools: toolTableHidden,
|
|
622
787
|
factsFor: (agent) => candidates.presetFactsForAgent(agent),
|
|
623
788
|
// 令牌门禁(2026-09-19,用户裁定「宿主侧硬拦截」):令牌**在生效**(`TOKEN !== ''`,
|
|
624
789
|
// 关掉或没配都是空串)而本次启动还没有人验过 ⇒ 这一步不放行,宿主把 turn 收成
|
|
@@ -1417,6 +1582,12 @@ export default {
|
|
|
1417
1582
|
writePatch,
|
|
1418
1583
|
memoriesService,
|
|
1419
1584
|
wait,
|
|
1585
|
+
// 兼容页「模型工具表」关掉的工具与 MCP 停用工具**合并成一次 restrict**:官方
|
|
1586
|
+
// `tools.restrict()` 是"每个 scope 一层限制",两边各调一次会互相覆盖(后一层赢)。
|
|
1587
|
+
// 名单的合法性(只含已注册工具)由 manager 那边与 schemas() 求交后保证。
|
|
1588
|
+
pluginHiddenTools: () => [...toolTableHidden()],
|
|
1589
|
+
// 有官方等价物时逐 agent 让位(见上面 CARRIER_DUPLICATES 的注释)。
|
|
1590
|
+
carrierHiddenTools: carrierHiddenToolsFor,
|
|
1420
1591
|
});
|
|
1421
1592
|
// ---------- ops ----------
|
|
1422
1593
|
async function pluginVersion() {
|
|
@@ -1544,6 +1715,8 @@ export default {
|
|
|
1544
1715
|
tools,
|
|
1545
1716
|
readInjectSettings,
|
|
1546
1717
|
injectSettingsOp,
|
|
1718
|
+
toolTableOp,
|
|
1719
|
+
toolTableReport: () => toolTableReport(),
|
|
1547
1720
|
presetRoster: candidates.presetRoster,
|
|
1548
1721
|
listPatchBackups,
|
|
1549
1722
|
cleanPatchBackups,
|
|
@@ -1622,6 +1795,51 @@ export default {
|
|
|
1622
1795
|
memorySceneCandidates: candidates.memorySceneCandidates,
|
|
1623
1796
|
memoryCandidates: candidates.memoryCandidates,
|
|
1624
1797
|
}),
|
|
1798
|
+
// 场景页的界面设置(读 / 写):进入场景前要不要弹预览卡。按写操作门禁(它写侧车),
|
|
1799
|
+
// 但**不进场景冻结** —— 它是界面提示,与五个管理域的只读无关(见 scene-settings.ts)。
|
|
1800
|
+
'scene-settings': (args) => sceneSettingsOp(args),
|
|
1801
|
+
// state-doctor:跨域**悬空引用**体检(只读,实现与理由见 src/ops/state-doctor.ts)。
|
|
1802
|
+
// 这里只做"把权威集合取来"这一件事 —— 某一域读失败就传 null,体检会把它记进
|
|
1803
|
+
// `skipped` 而不是当成"该域没有悬空项"(读不到 ≠ 不存在,报成后者就是骗人)。
|
|
1804
|
+
'state-doctor': async () => {
|
|
1805
|
+
const stateDir = String(config?.memoriesStateDir || '');
|
|
1806
|
+
const safe = async (read) => {
|
|
1807
|
+
try {
|
|
1808
|
+
return await read();
|
|
1809
|
+
}
|
|
1810
|
+
catch {
|
|
1811
|
+
return null;
|
|
1812
|
+
}
|
|
1813
|
+
};
|
|
1814
|
+
const report = await runStateDoctor({
|
|
1815
|
+
index: () => readIndexSync(stateDir),
|
|
1816
|
+
presetIds: async () => {
|
|
1817
|
+
const r = await safe(async () => promptsService.list());
|
|
1818
|
+
return r && r.ok ? r.presets.map((p) => p.id) : null;
|
|
1819
|
+
},
|
|
1820
|
+
serverNames: async () => {
|
|
1821
|
+
const r = await safe(mcp.mcpmListView);
|
|
1822
|
+
if (!r)
|
|
1823
|
+
return null;
|
|
1824
|
+
const out = [];
|
|
1825
|
+
for (const row of (r.rows || [])) {
|
|
1826
|
+
const n = String((row && row.serverName) || '');
|
|
1827
|
+
if (n && out.indexOf(n) < 0)
|
|
1828
|
+
out.push(n);
|
|
1829
|
+
}
|
|
1830
|
+
return out;
|
|
1831
|
+
},
|
|
1832
|
+
skillKeys: async () => {
|
|
1833
|
+
const r = await safe(skillStates);
|
|
1834
|
+
return r === null ? null : new Set(Object.keys(r));
|
|
1835
|
+
},
|
|
1836
|
+
personaNames: async () => {
|
|
1837
|
+
const r = await safe(() => subagentService.list());
|
|
1838
|
+
return r === null ? null : new Set(r.map((p) => p.name));
|
|
1839
|
+
},
|
|
1840
|
+
});
|
|
1841
|
+
return { ok: true, ...report };
|
|
1842
|
+
},
|
|
1625
1843
|
};
|
|
1626
1844
|
// MCP 写操作成功后立即重算状态段(SWR:text() 同步返回缓存值,写后主动 refresh)。
|
|
1627
1845
|
// 覆盖所有会改变「哪些 server 可用 / 有哪些备注」的写入口 —— 集中在这一处包,
|
|
@@ -1965,6 +2183,33 @@ export default {
|
|
|
1965
2183
|
syncSwitchToScene,
|
|
1966
2184
|
subagentToolFailures,
|
|
1967
2185
|
});
|
|
2186
|
+
// A1 的"机器那一边":拿**真实** op 表与 `./op-registry.ts` 对账,四个方向都查(未归类 /
|
|
2187
|
+
// 幽灵条目 / serviceWrite 与 service 自报分叉 / 只读与写自相矛盾)。
|
|
2188
|
+
// 为什么值得在启动期花这一次遍历:门禁失守的症状从来不是报错,而是"没配令牌也能写"或
|
|
2189
|
+
// "锁定的场景能被改"(0.6.0 / 0.7.0 / 0.10.0 已失守四次)。少一个 op 没登记,这里当场
|
|
2190
|
+
// 在兼容页挂一条降级,而不是等到出事再倒查。
|
|
2191
|
+
{
|
|
2192
|
+
const problems = auditProblems(auditOpRegistry(Object.keys(handlers), [
|
|
2193
|
+
...skillsService.writeOps,
|
|
2194
|
+
...memoriesService.writeOps,
|
|
2195
|
+
...archiveService.writeOps,
|
|
2196
|
+
...subagentService.writeOps,
|
|
2197
|
+
]));
|
|
2198
|
+
if (problems.length) {
|
|
2199
|
+
ctx.logger?.warn?.(`op 登记表与实际 op 表对不上(${problems.length} 处):${problems.join(';')}`);
|
|
2200
|
+
noteRuntime({
|
|
2201
|
+
id: 'op-registry',
|
|
2202
|
+
label: 'op 门禁登记表对账',
|
|
2203
|
+
kind: 'read',
|
|
2204
|
+
fallback: 'inform-only',
|
|
2205
|
+
detail: `${problems.length} 处对不上:${problems.slice(0, 6).join(';')}${problems.length > 6 ? ' 等' : ''}`
|
|
2206
|
+
+ ' —— 未归类的 op 不受写门禁与场景冻结约束。修法:在 src/op-registry.ts 补登记那一条。',
|
|
2207
|
+
});
|
|
2208
|
+
}
|
|
2209
|
+
else {
|
|
2210
|
+
clearRuntimeNote('op-registry');
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
1968
2213
|
// ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
|
|
1969
2214
|
const text = (value) => [{ type: 'text', text: value }];
|
|
1970
2215
|
/**
|
|
@@ -2012,7 +2257,9 @@ export default {
|
|
|
2012
2257
|
// 各域必须用它,直接 import 宿主的那个会丢掉采纳统计。
|
|
2013
2258
|
const toolDeps = {
|
|
2014
2259
|
defineTool,
|
|
2015
|
-
|
|
2260
|
+
// 量体积只认**注册成功**的那些(注册失败的域工具本来就不在模型工具表里)——
|
|
2261
|
+
// 兼容页「模型工具表」块的分组与 ≈token 都取自这里,那是这份设置唯一的数字来源。
|
|
2262
|
+
register: (def) => { recordToolSize(def); tools.register(def); },
|
|
2016
2263
|
lockedSceneGuard,
|
|
2017
2264
|
syncSwitchToScene,
|
|
2018
2265
|
reachNoticeForAgent,
|
|
@@ -2047,7 +2294,10 @@ export default {
|
|
|
2047
2294
|
const CONFIRM_LABELS = {
|
|
2048
2295
|
skill_manager_create: '「新建技能」',
|
|
2049
2296
|
memory_manager_write: '「写入记忆」',
|
|
2297
|
+
memory_manager_update: '「修改记忆」',
|
|
2050
2298
|
subagent_manager_run: '「运行子代理」',
|
|
2299
|
+
subagent_manager_create: '「新建人设」',
|
|
2300
|
+
subagent_manager_update: '「修改人设」',
|
|
2051
2301
|
// 模型侧只有这一个工具能塞进任意 command/args(改命令的 mcpm-edit 不对外注册成工具),
|
|
2052
2302
|
// 所以门禁加在它这里就覆盖了整个模型可达面。
|
|
2053
2303
|
mcp_manager_add: '「新增 MCP 服务器」',
|
|
@@ -2059,10 +2309,10 @@ export default {
|
|
|
2059
2309
|
pluginLog()('confirm-bypass', `完全权限(approval=never):跳过${CONFIRM_LABELS[String(exec && exec.name)]}的确认,直接放行`).catch(() => { });
|
|
2060
2310
|
return true;
|
|
2061
2311
|
};
|
|
2062
|
-
//
|
|
2063
|
-
//
|
|
2064
|
-
//
|
|
2065
|
-
//
|
|
2312
|
+
// 确认门是「问用户要不要做」,如果这个请求本来就做不成,弹卡 / 写 bypass 日志只会产生
|
|
2313
|
+
// 一次无效审批:用户批准之后模型收到的是「人设不可用」/「人设不存在」/「人设已存在」。
|
|
2314
|
+
// 所以先校验,再决定要不要问。校验本身不可用时返回 true(保持原行为,
|
|
2315
|
+
// 绝不因为探测失败而少问一次)。
|
|
2066
2316
|
const subagentManagerRunTargetExists = async (exec) => {
|
|
2067
2317
|
try {
|
|
2068
2318
|
const name = String((exec && exec.arguments && exec.arguments.agent) || '').trim();
|
|
@@ -2075,11 +2325,33 @@ export default {
|
|
|
2075
2325
|
return true;
|
|
2076
2326
|
}
|
|
2077
2327
|
};
|
|
2328
|
+
/** `subagent_manager_update` 要改名已存在、`_create` 要目标未被占用。 */
|
|
2329
|
+
const subagentWriteWouldApply = async (exec) => {
|
|
2330
|
+
try {
|
|
2331
|
+
const a = (exec && exec.arguments) || {};
|
|
2332
|
+
const name = String(a.name || '').trim();
|
|
2333
|
+
if (!name)
|
|
2334
|
+
return false;
|
|
2335
|
+
const list = await subagentService.list();
|
|
2336
|
+
const has = (n) => list.some((p) => p.name === n);
|
|
2337
|
+
if (exec.name === 'subagent_manager_update') {
|
|
2338
|
+
const next = String(a.nextName || '').trim();
|
|
2339
|
+
return has(name) && (next === '' || next === name || !has(next));
|
|
2340
|
+
}
|
|
2341
|
+
return !has(name);
|
|
2342
|
+
}
|
|
2343
|
+
catch (e) {
|
|
2344
|
+
return true;
|
|
2345
|
+
}
|
|
2346
|
+
};
|
|
2078
2347
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
2079
2348
|
if (!exec || !CONFIRM_LABELS[String(exec.name)])
|
|
2080
2349
|
return next();
|
|
2081
2350
|
if (exec.name === 'subagent_manager_run' && !(await subagentManagerRunTargetExists(exec)))
|
|
2082
2351
|
return next();
|
|
2352
|
+
if ((exec.name === 'subagent_manager_create' || exec.name === 'subagent_manager_update')
|
|
2353
|
+
&& !(await subagentWriteWouldApply(exec)))
|
|
2354
|
+
return next();
|
|
2083
2355
|
if (bypassedByFullAccess(exec))
|
|
2084
2356
|
return next();
|
|
2085
2357
|
if (exec.name === 'skill_manager_create') {
|
|
@@ -2105,14 +2377,29 @@ export default {
|
|
|
2105
2377
|
reason: `Add MCP server「${String(a.serverName || '')}」— ${detail}. A stdio server is spawned by the host and the entry persists in the config.`,
|
|
2106
2378
|
});
|
|
2107
2379
|
}
|
|
2108
|
-
if (exec.name === 'memory_manager_write') {
|
|
2380
|
+
if (exec.name === 'memory_manager_write' || exec.name === 'memory_manager_update') {
|
|
2109
2381
|
// D2:模型写规则默认需确认;设置关闭后直接放行。ask 无应答者时降级为拒绝(fail-closed),
|
|
2110
2382
|
// 不在此处做任何兜底放行。
|
|
2383
|
+
// `memory_manager_update` 走**同一个开关**:改一条记忆和建一条记忆动的是同一批文件,
|
|
2384
|
+
// 而改的破坏性更大(覆盖已有内容)—— 给它另设一档只会造出一个"改不用问、建要问"的倒挂。
|
|
2385
|
+
const what = exec.name === 'memory_manager_write'
|
|
2386
|
+
? 'Write a memory under ~/.dsh/tool-management/memories'
|
|
2387
|
+
: 'Update an existing memory under ~/.dsh/tool-management/memories';
|
|
2111
2388
|
return mcp.readPluginSettings()
|
|
2112
|
-
.then((s) => (s.requireConfirmForModelRuleWrite
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2389
|
+
.then((s) => (s.requireConfirmForModelRuleWrite ? { kind: 'ask', reason: what } : next()))
|
|
2390
|
+
.catch(() => ({ kind: 'ask', reason: what }));
|
|
2391
|
+
}
|
|
2392
|
+
if (exec.name === 'subagent_manager_create' || exec.name === 'subagent_manager_update') {
|
|
2393
|
+
// 与 `skill_manager_create` 完全对称:往 hub 里落一份新文件 / 整份重写一份现有文件。
|
|
2394
|
+
// 无条件问(不设开关)—— 人设是"以后每次委派都按它来"的长期资产,改错了影响的是
|
|
2395
|
+
// 后续所有子代理的行为,而不是一次输出。
|
|
2396
|
+
// update 尤其:`subagent-update` 走 serializePersona 整份重写,改名还会连带改
|
|
2397
|
+
// 场景档案里的绑定 —— 那是一次会影响环境配置的操作,不是一句文案修改。
|
|
2398
|
+
const a = (exec && exec.arguments) || {};
|
|
2399
|
+
const what = exec.name === 'subagent_manager_create'
|
|
2400
|
+
? `Create persona「${String(a.name || '')}」under ~/.dsh/tool-management/subagents`
|
|
2401
|
+
: `Rewrite persona「${String(a.name || '')}」${a.nextName ? ` and rename it to「${String(a.nextName)}」(scene-profile bindings follow the rename)` : ''}`;
|
|
2402
|
+
return Promise.resolve({ kind: 'ask', reason: what });
|
|
2116
2403
|
}
|
|
2117
2404
|
// subagent_manager_run:子代理运行花真 token:默认确认(requireConfirmForModelSubagentRun !== false),可关。
|
|
2118
2405
|
// `inherit: true` 时子代理会读到本次会话已完成的对话 —— 卡里如实说明(用户批准的是
|
|
@@ -2161,6 +2448,11 @@ export default {
|
|
|
2161
2448
|
if (mcp.isToolDisabled(String((exec && exec.name) || ''))) {
|
|
2162
2449
|
return '该工具已在 MCP 管理页停用';
|
|
2163
2450
|
}
|
|
2451
|
+
// 兼容页关掉的工具:可见性半边(restrict)正常时模型根本看不到它,走到这里只有两种
|
|
2452
|
+
// 可能 —— 名字是猜的,或者可见性半边没生效(那条降级会上报)。执行侧一律拦住。
|
|
2453
|
+
if (toolTableHidden().has(String((exec && exec.name) || ''))) {
|
|
2454
|
+
return '该工具已在「兼容」页的模型工具表里关掉(要它先在那里打开)';
|
|
2455
|
+
}
|
|
2164
2456
|
}
|
|
2165
2457
|
catch (e) { /* fallthrough to allow */ }
|
|
2166
2458
|
return undefined;
|
|
@@ -2318,11 +2610,12 @@ export default {
|
|
|
2318
2610
|
}
|
|
2319
2611
|
}
|
|
2320
2612
|
// 「读改写」两态 op:不带 `set:true` 时是**纯读**,与写门禁无关。
|
|
2321
|
-
// `inject-settings
|
|
2613
|
+
// `inject-settings`(兼容页的「注入」块)、`tool-table`(同页的「模型工具表」)、
|
|
2614
|
+
// `scene-settings`(场景页的提醒开关)与 `mcpm-settings`(MCP 页的轮询设置)
|
|
2322
2615
|
// 都用同一个 op 承担读与写,于是整条 op 被列进 WRITE_OPS —— 读侧也一起被拦,
|
|
2323
2616
|
// 界面表现是"没填令牌时整块设置消失、填了还要刷新才出现"(2026-09-19 用户报的)。
|
|
2324
2617
|
// 判据是**这次调用要不要写**,不是 op 名在不在名单里。
|
|
2325
|
-
const readOnlyCall = (op === 'inject-settings' || op === 'mcpm-settings')
|
|
2618
|
+
const readOnlyCall = (op === 'inject-settings' || op === 'tool-table' || op === 'scene-settings' || op === 'mcpm-settings')
|
|
2326
2619
|
&& !(payload.args && payload.args.set === true);
|
|
2327
2620
|
if (TOKEN && WRITE_OPS.has(op) && !readOnlyCall && !tokenMatches(hdr('x-dsh-token'))) {
|
|
2328
2621
|
// 文案与明文门禁同源(http-fence.ts),code 也统一 —— 界面据此在最右侧挂
|