dsh-plugin-tool-management 0.12.1 → 0.14.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 +84 -0
- package/README.md +90 -114
- package/README_EN.md +98 -119
- 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 +63 -0
- package/lib/client.js +1086 -95
- package/lib/compat/preset-reach.js +6 -3
- package/lib/context-inject.js +183 -59
- package/lib/index.js +501 -49
- package/lib/mcp/manager.js +118 -71
- package/lib/mcp/secret-guard.js +21 -0
- package/lib/mcp/state-section.js +19 -14
- package/lib/memories/archive-engine.js +199 -0
- package/lib/memories/constants.js +72 -8
- package/lib/memories/index-io.js +1 -1
- package/lib/memories/projection.js +67 -31
- package/lib/memories/service.js +17 -2
- package/lib/memories/snapshot.js +123 -22
- package/lib/op-registry.js +279 -0
- package/lib/ops/candidates.js +11 -0
- package/lib/ops/compat.js +23 -2
- package/lib/ops/sessions.js +1 -1
- package/lib/ops/state-doctor.js +115 -0
- package/lib/prompts/service.js +25 -2
- package/lib/request-gate.js +21 -78
- package/lib/scene-settings.js +17 -0
- package/lib/scenes/candidates.js +56 -0
- package/lib/skills/catalog.js +10 -5
- package/lib/skills/core.js +102 -0
- package/lib/skills/service.js +34 -2
- package/lib/subagents/catalog.js +9 -5
- package/lib/subagents/service.js +37 -4
- package/lib/subagents/tools.js +19 -5
- package/lib/tools/deps.js +15 -0
- package/lib/tools/mcp.js +291 -50
- package/lib/tools/memory.js +170 -19
- package/lib/tools/prompt.js +28 -14
- package/lib/tools/scene.js +351 -0
- package/lib/tools/skills.js +168 -14
- package/lib/tools/subagent.js +129 -3
- package/lib/tools/table.js +290 -0
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -22,16 +22,22 @@ 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 { addPreset, buildToolTableReport, DEFAULT_HIDDEN_TOOLS, dropPreset, migrateLegacyToolNames, normalizeToolTableSettings, PRESET_MAX_COUNT, PRESET_NAME_MAX_LENGTH, toolTableSettingsFrom, } 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';
|
|
33
37
|
import { TOKEN_CODE_BAD, TOKEN_MSG, fenceRejection, secretOpRejection } from './http-fence.js';
|
|
34
38
|
import { createMcpManager } from './mcp/manager.js';
|
|
39
|
+
// 确认卡回显"新 URL"时要打码(查询串里的凭据不该明文进卡片),口径与列表视图同一份实现。
|
|
40
|
+
import { maskUrlQuery } from './mcp/secret-guard.js';
|
|
35
41
|
import { createCandidates } from './scenes/candidates.js';
|
|
36
42
|
// 「已知工具」归一化与「当前可用工具数」原本定义在本文件(导出仅为测试接缝),
|
|
37
43
|
// 2026-09-19 随 MCP 域搬进 ./mcp/manager.ts。这里保持原路径可导出,契约不变。
|
|
@@ -44,6 +50,7 @@ import { buildPromptOps } from './ops/prompts.js';
|
|
|
44
50
|
import { buildSessionOps } from './ops/sessions.js';
|
|
45
51
|
// HTTP 请求准入与 handlers 后处理(2026-09-19 从本文件 apply 闭包抽出,依赖显式传参)。
|
|
46
52
|
import { createAccessToken, createOpWhitelist, createSceneLock, installHandlerGuards } from './request-gate.js';
|
|
53
|
+
import { auditOpRegistry, auditProblems } from './op-registry.js';
|
|
47
54
|
import { buildSceneSyncOps } from './ops/scene-sync.js';
|
|
48
55
|
import { buildCandidateOps } from './ops/candidates.js';
|
|
49
56
|
// model 工具(ctx.tools.register)按域分文件;共享的依赖形状见 tools/deps.ts。
|
|
@@ -51,6 +58,7 @@ import { buildMcpTools } from './tools/mcp.js';
|
|
|
51
58
|
import { buildSkillTools } from './tools/skills.js';
|
|
52
59
|
import { buildPromptTools } from './tools/prompt.js';
|
|
53
60
|
import { buildMemoryTools } from './tools/memory.js';
|
|
61
|
+
import { buildSceneTools } from './tools/scene.js';
|
|
54
62
|
import { buildSubagentTools } from './tools/subagent.js';
|
|
55
63
|
import { createScenePromptSync } from './scene-prompt-sync.js';
|
|
56
64
|
import { extractText } from './imports/parsers.js';
|
|
@@ -101,6 +109,8 @@ export const INJECT_SERVICES = [
|
|
|
101
109
|
'timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills', 'sessions',
|
|
102
110
|
'agents', 'workspaceRegistry', 'sessionProjectionCache', 'sessionPersistence',
|
|
103
111
|
];
|
|
112
|
+
/** 工具表设置预热完成前的答案:一个都不关(见 apply 里 toolTableHidden 的注释)。 */
|
|
113
|
+
const EMPTY_TOOL_SET = new Set();
|
|
104
114
|
export default {
|
|
105
115
|
name: 'dsh-plugin-tool-management-host',
|
|
106
116
|
inject: [...INJECT_SERVICES],
|
|
@@ -203,7 +213,7 @@ export default {
|
|
|
203
213
|
memoriesRoot: String(config?.memoriesRoot || ''),
|
|
204
214
|
stateDir: String(config?.memoriesStateDir || ''),
|
|
205
215
|
scenesDir: String(config?.memoriesScenesDir || ''),
|
|
206
|
-
// 场景记忆段预算(字节),默认
|
|
216
|
+
// 场景记忆段预算(字节),默认 131072(= DEFAULT_MAX_BYTES,128 KiB);仅用于测试与特殊部署调优。
|
|
207
217
|
...(Number.isFinite(Number(config?.rulesMaxBytes))
|
|
208
218
|
? { maxBytes: Number(config.rulesMaxBytes) }
|
|
209
219
|
: {}),
|
|
@@ -502,7 +512,189 @@ export default {
|
|
|
502
512
|
.filter((s) => s.active)
|
|
503
513
|
.map((s) => slice.archives[s.name]?.subagents ?? []);
|
|
504
514
|
};
|
|
505
|
-
// ----------
|
|
515
|
+
// ---------- 模型工具表开关(侧车 `tool-table.json`,界面在「兼容」页)----------
|
|
516
|
+
// 工具表按**每个请求**付钱:20 个工具的整份定义合计 ≈3,769 tok 每轮都在。关掉某几个,
|
|
517
|
+
// 它们整份不进请求(实测口径与取舍见 src/tools/table.ts 的文件头)。**出厂就已经关了十五条**
|
|
518
|
+
// (20 条里实发只剩 5 条)—— 名单、判据与代价在 `DEFAULT_HIDDEN_TOOLS`,只在这个人从没记过
|
|
519
|
+
// 选择时铺(见下面的 `toolTableSettingsFrom`)。
|
|
520
|
+
//
|
|
521
|
+
// 为什么放在目录之前:两个目录的「用 `X` 查」提示要跟着这份设置变(工具关掉后那句话
|
|
522
|
+
// 就是假的),所以它们的构造依赖在这块之后 —— 不做前向引用,顺序就是依赖顺序。
|
|
523
|
+
const TOOL_TABLE_FILE = 'tool-table.json';
|
|
524
|
+
const TOOL_TABLE_TTL_MS = 3000;
|
|
525
|
+
/** 注册时量到的工具体积(只记真进了表的那些;量的是 register 收到的整份定义)。 */
|
|
526
|
+
const toolTableSizes = new Map();
|
|
527
|
+
function recordToolSize(def) {
|
|
528
|
+
try {
|
|
529
|
+
const name = String(def.name ?? '');
|
|
530
|
+
if (name === '')
|
|
531
|
+
return;
|
|
532
|
+
// 与宿主同一口径(dsh-token-meter 的 estimateToolsTokens 就是把 tools 整份
|
|
533
|
+
// JSON.stringify 后除以 4),所以这里量整份定义,不只量 description。
|
|
534
|
+
toolTableSizes.set(name, JSON.stringify(def).length);
|
|
535
|
+
}
|
|
536
|
+
catch { /* 量不出来就不记:它只影响界面上的估算数字 */ }
|
|
537
|
+
}
|
|
538
|
+
let toolTableCache = null;
|
|
539
|
+
async function readToolTableSettings(force = false) {
|
|
540
|
+
if (toolTableCache && !force && Date.now() - toolTableCache.at < TOOL_TABLE_TTL_MS)
|
|
541
|
+
return toolTableCache.value;
|
|
542
|
+
await ensurePaths();
|
|
543
|
+
const raw = await readJsonFile(hubPath(TOOL_TABLE_FILE));
|
|
544
|
+
// 0.14.0 旧工具名迁移:读侧翻译一次并**回写盘**(幂等)。不迁移的话,用户"关掉了某条"
|
|
545
|
+
// 的意图会在新名字上静默失效 —— 那条工具照旧每轮发出去,而界面上看不出来。
|
|
546
|
+
// 写失败不回滚本次读取:内存里已经是迁移后的值,下次读会再试一次。
|
|
547
|
+
// 侧车不存在(`readJsonFile` 给 null)= 用户从没记过选择 ⇒ 用**出厂默认**(十五条不发,
|
|
548
|
+
// 实发 5 条;判据与代价见 tools/table.ts 的 DEFAULT_HIDDEN_TOOLS)。存过盘的
|
|
549
|
+
// 一律照盘上那份,包括显式的 `hidden: []` —— 那是"我全都要",不能被默认值盖掉。
|
|
550
|
+
const migrated = migrateLegacyToolNames(toolTableSettingsFrom(raw));
|
|
551
|
+
const value = migrated.settings;
|
|
552
|
+
if (migrated.changed) {
|
|
553
|
+
try {
|
|
554
|
+
await writeJsonFile(hubPath(TOOL_TABLE_FILE), value);
|
|
555
|
+
}
|
|
556
|
+
catch { /* 回写失败:本次仍按迁移后的值生效 */ }
|
|
557
|
+
}
|
|
558
|
+
const changed = toolTableCache === null || toolTableCache.value.hidden.join('\u0000') !== value.hidden.join('\u0000');
|
|
559
|
+
toolTableCache = { at: Date.now(), value, off: new Set(value.hidden) };
|
|
560
|
+
// 首读 / 文件被外部改过 ⇒ 可见性要跟着重排。同步快照的调用方(门禁、注入通道)不会
|
|
561
|
+
// 自己重排,它们只是"下次问的时候读到新值";把限制摘掉/装上这一步必须有人做。
|
|
562
|
+
// (`mcp` 在 apply 里是同步赋值、本函数只可能在自己的 await 之后回到这里,所以到得了。)
|
|
563
|
+
if (changed) {
|
|
564
|
+
try {
|
|
565
|
+
mcp.scheduleToolRestrictions();
|
|
566
|
+
}
|
|
567
|
+
catch { /* 兜底:拿不到 manager 就等下一次 tools/change */ }
|
|
568
|
+
}
|
|
569
|
+
return value;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* 同步快照(热路径用:工具门禁每次调用、注入通道每个 step 都要问一句)。还没加载时先给
|
|
573
|
+
* **空集**并异步预热 —— 空集不是出厂默认(出厂关着五条),是刻意的:预热前的这一瞬与
|
|
574
|
+
* 用户的选择可能不一致,但方向必须是安全的那一侧(不隐藏任何东西,绝不因为读盘慢而让
|
|
575
|
+
* 工具凭空消失)。
|
|
576
|
+
*/
|
|
577
|
+
function toolTableHidden() {
|
|
578
|
+
if (toolTableCache === null) {
|
|
579
|
+
void readToolTableSettings().catch(() => { });
|
|
580
|
+
return EMPTY_TOOL_SET;
|
|
581
|
+
}
|
|
582
|
+
return toolTableCache.off;
|
|
583
|
+
}
|
|
584
|
+
const toolTableRows = () => [...toolTableSizes].map(([name, bytes]) => ({ name, bytes }));
|
|
585
|
+
const toolTableReport = (hidden = [...toolTableHidden()]) => buildToolTableReport(toolTableRows(), hidden);
|
|
586
|
+
/** 落盘 + 同步内存缓存(三条写路径共用:改勾选 / 存方案 / 删方案)。 */
|
|
587
|
+
async function writeToolTableSettings(next) {
|
|
588
|
+
await ensurePaths();
|
|
589
|
+
// 落盘在写锁里,落完**出了锁**再刷新(`withWriteLock` 是一条不可重入的链:锁里再调
|
|
590
|
+
// 一次会等自己,直接卡死)。刷新是两处目录读,本来也不必占着写锁。
|
|
591
|
+
return withWriteLock(async () => {
|
|
592
|
+
try {
|
|
593
|
+
await writeJsonFile(hubPath(TOOL_TABLE_FILE), next);
|
|
594
|
+
}
|
|
595
|
+
catch (e) {
|
|
596
|
+
return { ok: false, error: '设置保存失败: ' + message(e) };
|
|
597
|
+
}
|
|
598
|
+
toolTableCache = { at: Date.now(), value: next, off: new Set(next.hidden) };
|
|
599
|
+
return { ok: true };
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* 回给界面的同一份形状:当前名单 + 已存方案 + 出厂默认名单 + 体积报告。
|
|
604
|
+
*
|
|
605
|
+
* `defaultHidden` 单独回一份而不是塞进 `presets`:那条**不存在文件里**(它是代码里的
|
|
606
|
+
* `DEFAULT_HIDDEN_TOOLS`),写进文件就会出现"用户删不掉的一条数据"。界面把它排在最前、
|
|
607
|
+
* 不给删除键,词典出它的名字。
|
|
608
|
+
*/
|
|
609
|
+
const toolTablePayload = (value) => ({
|
|
610
|
+
ok: true, hidden: value.hidden, presets: value.presets,
|
|
611
|
+
defaultHidden: [...DEFAULT_HIDDEN_TOOLS], report: toolTableReport(value.hidden),
|
|
612
|
+
});
|
|
613
|
+
/** 方案名:折叠空白 + 截到上限。空名由调用方报错(这里不猜"用户想叫什么")。 */
|
|
614
|
+
const presetNameOf = (raw) => String(raw ?? '').replaceAll(/\s+/g, ' ').trim().slice(0, PRESET_NAME_MAX_LENGTH);
|
|
615
|
+
async function toolTableOp(args) {
|
|
616
|
+
const current = await readToolTableSettings();
|
|
617
|
+
if (!args || args.set !== true) {
|
|
618
|
+
// 存 / 删方案都不动 `hidden`,所以这两条路**不**重排可见性、也不刷目录(那是 `set`
|
|
619
|
+
// 那一条才有的三处联动)。失败原因回 `code` 不回中文串 —— 英文界面会原样露出中文。
|
|
620
|
+
if (args && args.presetSave !== undefined) {
|
|
621
|
+
const name = presetNameOf(args.presetSave);
|
|
622
|
+
if (name === '')
|
|
623
|
+
return { ok: false, code: 'nameEmpty' };
|
|
624
|
+
const added = addPreset(current, name, current.hidden);
|
|
625
|
+
// 同名不覆盖(用户裁定):撞名要用户换个名字,而不是悄悄改掉已有那份。
|
|
626
|
+
if (added.exists)
|
|
627
|
+
return { ok: false, code: 'nameTaken', name };
|
|
628
|
+
if (current.presets.length >= PRESET_MAX_COUNT) {
|
|
629
|
+
return { ok: false, code: 'limit', limit: PRESET_MAX_COUNT };
|
|
630
|
+
}
|
|
631
|
+
const saved = await writeToolTableSettings(added.settings);
|
|
632
|
+
if (!saved.ok)
|
|
633
|
+
return saved;
|
|
634
|
+
return toolTablePayload(added.settings);
|
|
635
|
+
}
|
|
636
|
+
if (args && args.presetDelete !== undefined) {
|
|
637
|
+
const name = presetNameOf(args.presetDelete);
|
|
638
|
+
const dropped = dropPreset(current, name);
|
|
639
|
+
if (!dropped.changed)
|
|
640
|
+
return { ok: false, code: 'notFound', name };
|
|
641
|
+
const saved = await writeToolTableSettings(dropped.settings);
|
|
642
|
+
if (!saved.ok)
|
|
643
|
+
return saved;
|
|
644
|
+
return toolTablePayload(dropped.settings);
|
|
645
|
+
}
|
|
646
|
+
return toolTablePayload(current);
|
|
647
|
+
}
|
|
648
|
+
// 只收**已注册**的名字:写进来的陌生名字在下一次 restrict 时会让官方抛错
|
|
649
|
+
// ("names unknown global tool")。注册失败的工具本来也不在表里。
|
|
650
|
+
const hidden = normalizeToolTableSettings({ hidden: args.hidden }).hidden
|
|
651
|
+
.filter((name) => toolTableSizes.has(name));
|
|
652
|
+
const next = { hidden, presets: current.presets };
|
|
653
|
+
const saved = await writeToolTableSettings(next);
|
|
654
|
+
if (!saved.ok)
|
|
655
|
+
return saved;
|
|
656
|
+
// 三处跟着变:可见性(restrict 名单重排)、两个目录(截断提示里点名的工具可能没了)。
|
|
657
|
+
// 目录的 refresh 会把新的一句话注入出去 —— 内容变了才重发,这是正确行为。
|
|
658
|
+
mcp.scheduleToolRestrictions();
|
|
659
|
+
await Promise.all([
|
|
660
|
+
skillCatalog.refresh().catch(() => { }),
|
|
661
|
+
subagentCatalog.refresh().catch(() => { }),
|
|
662
|
+
]);
|
|
663
|
+
return toolTablePayload(next);
|
|
664
|
+
}
|
|
665
|
+
// ---------- 「有官方等价物 ⇒ 我们让位」----------
|
|
666
|
+
// 官方 `skill` 工具(`@deepseek-ai/dsh-tool-skill`)与我们的 `skill_manager_read` 是同一
|
|
667
|
+
// 件事:按名字给正文。标准类预设下它在场,第二份就是白付的 174 tok/轮。
|
|
668
|
+
//
|
|
669
|
+
// 判据抄官方自己的写法(`dsh-tool-skill/lib/index.js:207`:`ctx.tools.get(skillTool.name,
|
|
670
|
+
// agent) === skillTool`)—— 拿 agent 当 scope 问"这个 agent 解析得到 `skill` 吗"。比读
|
|
671
|
+
// 预设组合文件更准:它观察的是**这个 agent 实际能看见什么**(限定到该 scope 的同名 shadow
|
|
672
|
+
// 也算数),而组合文件只能说"配了"。判不出来时返回空 = 不让位:少一条拿正文的路,比多花
|
|
673
|
+
// 174 tok 糟。
|
|
674
|
+
//
|
|
675
|
+
// 与注入侧的关系:那边(`CARRIER_FACT_OF`)按组合事实决定"官方目录在场就不注入我们的
|
|
676
|
+
// 目录",这边按解析结果决定"官方加载器在场就不下发我们的加载器"。两者只在一种情形下
|
|
677
|
+
// 不一致 —— 组合里挂了、但这个 agent 的 scope 里解析不到(被别的限制摘掉):那时注入侧
|
|
678
|
+
// 跳过我们的目录,这里仍然保留我们的工具,方向是对的(模型手里还有 list + read)。
|
|
679
|
+
/** `[官方工具名, 我们该让位的工具]` —— 只有技能这一对(官方没有 MCP 管理 / 记忆 / AGENTS 编辑工具)。 */
|
|
680
|
+
const CARRIER_DUPLICATES = [['skill', 'skill_manager_read']];
|
|
681
|
+
const carrierHiddenToolsFor = async (agent) => {
|
|
682
|
+
// 拿不到 agent 就什么都不让位:`get(name, undefined)` 问的是**全局视图**,而官方那些
|
|
683
|
+
// 工具是按预设挂进 agent 那一层的 —— 问错对象会得出相反的结论。
|
|
684
|
+
if (agent === null || (typeof agent !== 'object' && typeof agent !== 'function'))
|
|
685
|
+
return [];
|
|
686
|
+
const scope = agent;
|
|
687
|
+
const out = [];
|
|
688
|
+
for (const [carrier, ours] of CARRIER_DUPLICATES) {
|
|
689
|
+
try {
|
|
690
|
+
if (tools.get(carrier, scope) !== undefined)
|
|
691
|
+
out.push(ours);
|
|
692
|
+
}
|
|
693
|
+
catch { /* 读不到 = 不让位 */ }
|
|
694
|
+
}
|
|
695
|
+
return out;
|
|
696
|
+
};
|
|
697
|
+
// ---------- 注入通道(场景 / 记忆 / MCP / 技能 / 子智能体 / 提示词,各一条消息)----------
|
|
506
698
|
// 这些文本以前是 systemPrompt 段(persona complete 会整段压掉)。现在改走官方的
|
|
507
699
|
// 「每步注入一条合成消息」通道(skill-catalog / AGENTS.md / 时间上下文同款):
|
|
508
700
|
// 任何预设都到得了,内容没变不重发。**每个域一条自己的消息**(来源 kind 各不同,
|
|
@@ -516,6 +708,8 @@ export default {
|
|
|
516
708
|
const subagentCatalog = createSubagentCatalog({
|
|
517
709
|
list: () => subagentService.list(),
|
|
518
710
|
sceneLists: subagentSceneLists,
|
|
711
|
+
// 人在「兼容」页把 `subagent_manager_list` 关掉后,截断提示里那句「用 X 查」就是假的。
|
|
712
|
+
listToolVisible: () => !toolTableHidden().has('subagent_manager_list'),
|
|
519
713
|
});
|
|
520
714
|
// ⚠️ 取数必须用 mcpmRowsWithNotes(false):true 会带上**未打码**的 url/headers/env(含明文密钥)。
|
|
521
715
|
// 技能目录与提示词两个域是**官方载体的兜底**:预设挂得到官方
|
|
@@ -523,6 +717,7 @@ export default {
|
|
|
523
717
|
// CARRIER_FACT_OF),挂不到(极简)时才由这里送 —— 所以极简下也能按开关决定要不要。
|
|
524
718
|
const skillCatalog = createSkillCatalog({
|
|
525
719
|
state: () => skillsService.ops['skill-state']({}),
|
|
720
|
+
listToolVisible: () => !toolTableHidden().has('skill_manager_list'),
|
|
526
721
|
});
|
|
527
722
|
// 注入设置(侧车 `inject-settings.json`,界面在「兼容」页):
|
|
528
723
|
// - underSuppressingPresets:压制型预设(persona complete / 关运行时上下文,如极简)
|
|
@@ -574,6 +769,40 @@ export default {
|
|
|
574
769
|
return { ok: true, settings: next };
|
|
575
770
|
});
|
|
576
771
|
}
|
|
772
|
+
// 场景页的界面设置(侧车 `scene-settings.json`):目前只有一项 —— 进入场景前要不要先弹
|
|
773
|
+
// 那张「会改什么」的卡。它纯粹是界面提示,所以**不进场景冻结**(锁着场景的人在场景页
|
|
774
|
+
// 照样能关掉提醒,那与五个管理域的只读无关)。
|
|
775
|
+
const SCENE_SETTINGS_FILE = 'scene-settings.json';
|
|
776
|
+
const SCENE_SETTINGS_TTL_MS = 3000;
|
|
777
|
+
let sceneSettingsCache = null;
|
|
778
|
+
async function readSceneSettings(force = false) {
|
|
779
|
+
if (sceneSettingsCache && !force && Date.now() - sceneSettingsCache.at < SCENE_SETTINGS_TTL_MS)
|
|
780
|
+
return sceneSettingsCache.value;
|
|
781
|
+
await ensurePaths();
|
|
782
|
+
const raw = await readJsonFile(hubPath(SCENE_SETTINGS_FILE));
|
|
783
|
+
const value = normalizeSceneSettings(raw);
|
|
784
|
+
sceneSettingsCache = { at: Date.now(), value };
|
|
785
|
+
return value;
|
|
786
|
+
}
|
|
787
|
+
async function sceneSettingsOp(args) {
|
|
788
|
+
const current = await readSceneSettings();
|
|
789
|
+
if (!args || args.set !== true)
|
|
790
|
+
return { ok: true, settings: current };
|
|
791
|
+
const next = normalizeSceneSettings({
|
|
792
|
+
enterPreview: typeof args.enterPreview === 'boolean' ? args.enterPreview : current.enterPreview,
|
|
793
|
+
});
|
|
794
|
+
await ensurePaths();
|
|
795
|
+
return withWriteLock(async () => {
|
|
796
|
+
try {
|
|
797
|
+
await writeJsonFile(hubPath(SCENE_SETTINGS_FILE), next);
|
|
798
|
+
}
|
|
799
|
+
catch (e) {
|
|
800
|
+
return { ok: false, error: '设置保存失败: ' + message(e) };
|
|
801
|
+
}
|
|
802
|
+
sceneSettingsCache = { at: Date.now(), value: next };
|
|
803
|
+
return { ok: true, settings: next };
|
|
804
|
+
});
|
|
805
|
+
}
|
|
577
806
|
// 注入实况(只读诊断):注入器的 live() 读回"最近活跃会话"可见表面上的五域文本。
|
|
578
807
|
// 挂在 apply 作用域:`injection-live` op 与注入器不在同一层(effect 内部)。
|
|
579
808
|
let contextInjectorLive = null;
|
|
@@ -596,16 +825,21 @@ export default {
|
|
|
596
825
|
// `catalogDepth` 字段的注释。目录正文也按同一个判据过滤(`text(agent)`),两处同源
|
|
597
826
|
// 所以不会分叉。
|
|
598
827
|
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
828
|
+
// 场景与记忆(2026-09-17 用户裁定;2026-09-23 拆成两个域):**都只在顶层注入**。
|
|
829
|
+
// 记忆是"父会话的现场",不是子代理完成任务所需的事实 —— 塞进一次性子会话只会与
|
|
830
|
+
// 角色定义争注意力(实测:子代理跑审查时,上下文里同时躺着人设与整份记忆)。
|
|
831
|
+
// 场景段同理:它说的是"父会话现在处在哪个模式",而子代理根本没有"模式"可言。
|
|
832
|
+
// 子代理手里有 `memory_manager_list/read` 与 `scene_manager_save`,需要什么自己取;
|
|
833
|
+
// 父代理上下文里也有,相关事实应当由它写进 `task`(子代理的上下文 = 角色 + 任务)。
|
|
604
834
|
// 其余三域对任何深度都成立:提示词是用户规则(本插件的立身之本就是"覆盖到子代理")、
|
|
605
835
|
// 技能目录与 MCP 状态是"操作这台机器所需的事实"(子代理手里就有 `skill` / `mcp__*`
|
|
606
836
|
// 工具,不知道清单就只能瞎调)。
|
|
607
837
|
domains: () => [
|
|
608
|
-
|
|
838
|
+
// 场景(**框架**):启用的场景 + 场景说明(用户的约定)。排第一 —— 先让模型知道
|
|
839
|
+
// "现在在哪个场景、这个场景的约定是什么",再读下面的记忆条目。
|
|
840
|
+
{ key: 'scene', name: 'tool-management:scene', label: '场景', form: 'snapshot', text: () => memoriesService.sceneCatalogText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
|
|
841
|
+
// 记忆(**内容**):各场景下的条目。与场景段同源、同一条 `applicableTo`。
|
|
842
|
+
{ key: 'memory', name: 'tool-management:memory', label: '记忆', form: 'snapshot', text: () => memoriesService.memoryText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
|
|
609
843
|
{ key: 'mcp', name: 'tool-management:mcp-state', label: 'MCP 服务器', form: 'catalog', text: () => mcp.stateCatalog.text() },
|
|
610
844
|
{ key: 'skills', name: 'tool-management:skill-catalog', label: '技能目录', form: 'catalog', text: () => skillCatalog.text() },
|
|
611
845
|
{
|
|
@@ -619,6 +853,8 @@ export default {
|
|
|
619
853
|
{ key: 'prompt', name: 'tool-management:prompt', label: '提示词', form: 'instructions', text: () => memoriesService.promptText(), files: () => memoriesService.promptFiles() },
|
|
620
854
|
],
|
|
621
855
|
settings: () => injectSettingsSync(),
|
|
856
|
+
// 工具表开关:`how` 行里点名工具的那几句要跟着它换话术(关掉的不点名)。
|
|
857
|
+
hiddenTools: toolTableHidden,
|
|
622
858
|
factsFor: (agent) => candidates.presetFactsForAgent(agent),
|
|
623
859
|
// 令牌门禁(2026-09-19,用户裁定「宿主侧硬拦截」):令牌**在生效**(`TOKEN !== ''`,
|
|
624
860
|
// 关掉或没配都是空串)而本次启动还没有人验过 ⇒ 这一步不放行,宿主把 turn 收成
|
|
@@ -1417,6 +1653,12 @@ export default {
|
|
|
1417
1653
|
writePatch,
|
|
1418
1654
|
memoriesService,
|
|
1419
1655
|
wait,
|
|
1656
|
+
// 兼容页「模型工具表」关掉的工具与 MCP 停用工具**合并成一次 restrict**:官方
|
|
1657
|
+
// `tools.restrict()` 是"每个 scope 一层限制",两边各调一次会互相覆盖(后一层赢)。
|
|
1658
|
+
// 名单的合法性(只含已注册工具)由 manager 那边与 schemas() 求交后保证。
|
|
1659
|
+
pluginHiddenTools: () => [...toolTableHidden()],
|
|
1660
|
+
// 有官方等价物时逐 agent 让位(见上面 CARRIER_DUPLICATES 的注释)。
|
|
1661
|
+
carrierHiddenTools: carrierHiddenToolsFor,
|
|
1420
1662
|
});
|
|
1421
1663
|
// ---------- ops ----------
|
|
1422
1664
|
async function pluginVersion() {
|
|
@@ -1511,6 +1753,25 @@ export default {
|
|
|
1511
1753
|
memoriesService,
|
|
1512
1754
|
toolKeyParts,
|
|
1513
1755
|
});
|
|
1756
|
+
// 提示词预设域的 op 表。单独提出来是为了让**模型工具**能复用同一份判定:
|
|
1757
|
+
// `prompt_manager_list` 以前直调 `promptsService.list()`,于是它报的「生效中」是
|
|
1758
|
+
// **文件比对**口径(预设正文 == ~/.dsh/AGENTS.md),而界面报的是 `agentsmd-list` 的口径
|
|
1759
|
+
// (启用的场景绑了预设时,**只有那份**算生效中)—— 同一件事两个答案,场景驱动时工具就在
|
|
1760
|
+
// 说假话。工具改走 op 之后两边只有一份判定。
|
|
1761
|
+
const promptOps = buildPromptOps({
|
|
1762
|
+
promptsService,
|
|
1763
|
+
scenePromptSync,
|
|
1764
|
+
// 显式转发而不是整表传入:这两个 op 名字写死在这里,rules 域哪天改名会立刻红,
|
|
1765
|
+
// 不会静默变成 undefined 调用。
|
|
1766
|
+
rulesOps: {
|
|
1767
|
+
'rules-list': (args) => memoriesService.ops['rules-list'](args),
|
|
1768
|
+
'rules-rebind-prompt': (args) => memoriesService.ops['rules-rebind-prompt'](args),
|
|
1769
|
+
},
|
|
1770
|
+
applyPresetGuarded,
|
|
1771
|
+
withAgentsMdSync,
|
|
1772
|
+
promptRefReason,
|
|
1773
|
+
warn: (m) => { ctx.logger?.warn?.(m); },
|
|
1774
|
+
});
|
|
1514
1775
|
const handlers = {
|
|
1515
1776
|
'plugin-version': pluginVersion,
|
|
1516
1777
|
'skill-open': skillOpen,
|
|
@@ -1544,6 +1805,8 @@ export default {
|
|
|
1544
1805
|
tools,
|
|
1545
1806
|
readInjectSettings,
|
|
1546
1807
|
injectSettingsOp,
|
|
1808
|
+
toolTableOp,
|
|
1809
|
+
toolTableReport: () => toolTableReport(),
|
|
1547
1810
|
presetRoster: candidates.presetRoster,
|
|
1548
1811
|
listPatchBackups,
|
|
1549
1812
|
cleanPatchBackups,
|
|
@@ -1556,23 +1819,8 @@ export default {
|
|
|
1556
1819
|
message,
|
|
1557
1820
|
compatLog,
|
|
1558
1821
|
}),
|
|
1559
|
-
// AGENTS.md / 提示词预设域(ops/prompts.ts
|
|
1560
|
-
|
|
1561
|
-
// trash-delete。
|
|
1562
|
-
...buildPromptOps({
|
|
1563
|
-
promptsService,
|
|
1564
|
-
scenePromptSync,
|
|
1565
|
-
// 显式转发而不是整表传入:这两个 op 名字写死在这里,rules 域哪天改名会立刻红,
|
|
1566
|
-
// 不会静默变成 undefined 调用。
|
|
1567
|
-
rulesOps: {
|
|
1568
|
-
'rules-list': (args) => memoriesService.ops['rules-list'](args),
|
|
1569
|
-
'rules-rebind-prompt': (args) => memoriesService.ops['rules-rebind-prompt'](args),
|
|
1570
|
-
},
|
|
1571
|
-
applyPresetGuarded,
|
|
1572
|
-
withAgentsMdSync,
|
|
1573
|
-
promptRefReason,
|
|
1574
|
-
warn: (m) => { ctx.logger?.warn?.(m); },
|
|
1575
|
-
}),
|
|
1822
|
+
// AGENTS.md / 提示词预设域(ops/prompts.ts 提供,见上方 promptOps)。
|
|
1823
|
+
...promptOps,
|
|
1576
1824
|
// 归档会话域(ops/history.ts 提供):history-list / history-sessions /
|
|
1577
1825
|
// history-export-defaults / dir-list / history-archive / history-archive-batch /
|
|
1578
1826
|
// history-unarchive / history-delete / history-unarchive-batch / history-delete-batch /
|
|
@@ -1612,6 +1860,7 @@ export default {
|
|
|
1612
1860
|
...buildCandidateOps({
|
|
1613
1861
|
presetToolCandidates: candidates.presetToolCandidates,
|
|
1614
1862
|
modelCandidates: candidates.modelCandidates,
|
|
1863
|
+
modelReasoning: candidates.modelReasoning,
|
|
1615
1864
|
getToolFailures: () => subagentToolFailures,
|
|
1616
1865
|
mcpmListView: mcp.mcpmListView,
|
|
1617
1866
|
toolStates: candidates.toolStates,
|
|
@@ -1622,6 +1871,51 @@ export default {
|
|
|
1622
1871
|
memorySceneCandidates: candidates.memorySceneCandidates,
|
|
1623
1872
|
memoryCandidates: candidates.memoryCandidates,
|
|
1624
1873
|
}),
|
|
1874
|
+
// 场景页的界面设置(读 / 写):进入场景前要不要弹预览卡。按写操作门禁(它写侧车),
|
|
1875
|
+
// 但**不进场景冻结** —— 它是界面提示,与五个管理域的只读无关(见 scene-settings.ts)。
|
|
1876
|
+
'scene-settings': (args) => sceneSettingsOp(args),
|
|
1877
|
+
// state-doctor:跨域**悬空引用**体检(只读,实现与理由见 src/ops/state-doctor.ts)。
|
|
1878
|
+
// 这里只做"把权威集合取来"这一件事 —— 某一域读失败就传 null,体检会把它记进
|
|
1879
|
+
// `skipped` 而不是当成"该域没有悬空项"(读不到 ≠ 不存在,报成后者就是骗人)。
|
|
1880
|
+
'state-doctor': async () => {
|
|
1881
|
+
const stateDir = String(config?.memoriesStateDir || '');
|
|
1882
|
+
const safe = async (read) => {
|
|
1883
|
+
try {
|
|
1884
|
+
return await read();
|
|
1885
|
+
}
|
|
1886
|
+
catch {
|
|
1887
|
+
return null;
|
|
1888
|
+
}
|
|
1889
|
+
};
|
|
1890
|
+
const report = await runStateDoctor({
|
|
1891
|
+
index: () => readIndexSync(stateDir),
|
|
1892
|
+
presetIds: async () => {
|
|
1893
|
+
const r = await safe(async () => promptsService.list());
|
|
1894
|
+
return r && r.ok ? r.presets.map((p) => p.id) : null;
|
|
1895
|
+
},
|
|
1896
|
+
serverNames: async () => {
|
|
1897
|
+
const r = await safe(mcp.mcpmListView);
|
|
1898
|
+
if (!r)
|
|
1899
|
+
return null;
|
|
1900
|
+
const out = [];
|
|
1901
|
+
for (const row of (r.rows || [])) {
|
|
1902
|
+
const n = String((row && row.serverName) || '');
|
|
1903
|
+
if (n && out.indexOf(n) < 0)
|
|
1904
|
+
out.push(n);
|
|
1905
|
+
}
|
|
1906
|
+
return out;
|
|
1907
|
+
},
|
|
1908
|
+
skillKeys: async () => {
|
|
1909
|
+
const r = await safe(skillStates);
|
|
1910
|
+
return r === null ? null : new Set(Object.keys(r));
|
|
1911
|
+
},
|
|
1912
|
+
personaNames: async () => {
|
|
1913
|
+
const r = await safe(() => subagentService.list());
|
|
1914
|
+
return r === null ? null : new Set(r.map((p) => p.name));
|
|
1915
|
+
},
|
|
1916
|
+
});
|
|
1917
|
+
return { ok: true, ...report };
|
|
1918
|
+
},
|
|
1625
1919
|
};
|
|
1626
1920
|
// MCP 写操作成功后立即重算状态段(SWR:text() 同步返回缓存值,写后主动 refresh)。
|
|
1627
1921
|
// 覆盖所有会改变「哪些 server 可用 / 有哪些备注」的写入口 —— 集中在这一处包,
|
|
@@ -1965,6 +2259,33 @@ export default {
|
|
|
1965
2259
|
syncSwitchToScene,
|
|
1966
2260
|
subagentToolFailures,
|
|
1967
2261
|
});
|
|
2262
|
+
// A1 的"机器那一边":拿**真实** op 表与 `./op-registry.ts` 对账,四个方向都查(未归类 /
|
|
2263
|
+
// 幽灵条目 / serviceWrite 与 service 自报分叉 / 只读与写自相矛盾)。
|
|
2264
|
+
// 为什么值得在启动期花这一次遍历:门禁失守的症状从来不是报错,而是"没配令牌也能写"或
|
|
2265
|
+
// "锁定的场景能被改"(0.6.0 / 0.7.0 / 0.10.0 已失守四次)。少一个 op 没登记,这里当场
|
|
2266
|
+
// 在兼容页挂一条降级,而不是等到出事再倒查。
|
|
2267
|
+
{
|
|
2268
|
+
const problems = auditProblems(auditOpRegistry(Object.keys(handlers), [
|
|
2269
|
+
...skillsService.writeOps,
|
|
2270
|
+
...memoriesService.writeOps,
|
|
2271
|
+
...archiveService.writeOps,
|
|
2272
|
+
...subagentService.writeOps,
|
|
2273
|
+
]));
|
|
2274
|
+
if (problems.length) {
|
|
2275
|
+
ctx.logger?.warn?.(`op 登记表与实际 op 表对不上(${problems.length} 处):${problems.join(';')}`);
|
|
2276
|
+
noteRuntime({
|
|
2277
|
+
id: 'op-registry',
|
|
2278
|
+
label: 'op 门禁登记表对账',
|
|
2279
|
+
kind: 'read',
|
|
2280
|
+
fallback: 'inform-only',
|
|
2281
|
+
detail: `${problems.length} 处对不上:${problems.slice(0, 6).join(';')}${problems.length > 6 ? ' 等' : ''}`
|
|
2282
|
+
+ ' —— 未归类的 op 不受写门禁与场景冻结约束。修法:在 src/op-registry.ts 补登记那一条。',
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
else {
|
|
2286
|
+
clearRuntimeNote('op-registry');
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
1968
2289
|
// ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
|
|
1969
2290
|
const text = (value) => [{ type: 'text', text: value }];
|
|
1970
2291
|
/**
|
|
@@ -2012,9 +2333,13 @@ export default {
|
|
|
2012
2333
|
// 各域必须用它,直接 import 宿主的那个会丢掉采纳统计。
|
|
2013
2334
|
const toolDeps = {
|
|
2014
2335
|
defineTool,
|
|
2015
|
-
|
|
2336
|
+
// 量体积只认**注册成功**的那些(注册失败的域工具本来就不在模型工具表里)——
|
|
2337
|
+
// 兼容页「模型工具表」块的分组与 ≈token 都取自这里,那是这份设置唯一的数字来源。
|
|
2338
|
+
register: (def) => { recordToolSize(def); tools.register(def); },
|
|
2016
2339
|
lockedSceneGuard,
|
|
2017
2340
|
syncSwitchToScene,
|
|
2341
|
+
// 错误句里点名工具前要先问一句(判据与两个目录的 `listToolVisible` 同一份缓存)。
|
|
2342
|
+
toolVisible: (name) => !toolTableHidden().has(name),
|
|
2018
2343
|
reachNoticeForAgent,
|
|
2019
2344
|
presetRoster: candidates.presetRoster,
|
|
2020
2345
|
injectNoticeOptions,
|
|
@@ -2023,13 +2348,38 @@ export default {
|
|
|
2023
2348
|
buildMcpTools({
|
|
2024
2349
|
...toolDeps,
|
|
2025
2350
|
mcpmListView: mcp.mcpmListView,
|
|
2351
|
+
mcpmTools: mcp.ops['mcpm-tools'],
|
|
2026
2352
|
mcpmSetEnabled: mcp.ops['mcpm-set-enabled'],
|
|
2027
2353
|
mcpmRestart: mcp.ops['mcpm-restart'],
|
|
2354
|
+
mcpmToolEnabled: mcp.ops['mcpm-tool-enabled'],
|
|
2028
2355
|
mcpmAdd: mcp.ops['mcpm-add'],
|
|
2356
|
+
mcpmEdit: mcp.ops['mcpm-edit'],
|
|
2357
|
+
mcpmNote: mcp.ops['mcpm-note'],
|
|
2358
|
+
// **故意不接 `mcpm-reveal`**:`mcp_manager_save` 的改分支用 `mcpmListView()` 的
|
|
2359
|
+
// 打码视图填回省略字段,由 `mcpm-edit` 的 `resolveMaskedKv` / `resolveMaskedUrl`
|
|
2360
|
+
// 还原真值 —— 那条路本来就是给"表单里出现打码值"设计的(界面编辑框预填的就是它)。
|
|
2361
|
+
// 让模型驱动的工具在进程内读明文凭据,是另一条没人设计过、也没有测试覆盖的路径。
|
|
2029
2362
|
});
|
|
2030
2363
|
buildSkillTools({ ...toolDeps, skillsOps: skillsService.ops });
|
|
2031
|
-
|
|
2364
|
+
// 传 op 表而不是 promptsService:「生效中」的判定只有 `agentsmd-list` 里有(场景绑定
|
|
2365
|
+
// 的那份才算),直调服务会得到文件比对口径 —— 场景驱动时工具会报一个与界面不同的答案。
|
|
2366
|
+
buildPromptTools({ ...toolDeps, promptOps, applyPresetGuarded, promptsDir });
|
|
2032
2367
|
buildMemoryTools({ ...toolDeps, rulesOps: memoriesService.ops });
|
|
2368
|
+
// 场景族(tools/scene.ts):列场景与档案(`_list`)+ 建场景 / 写档案 / 绑提示词(`_save`)
|
|
2369
|
+
// + 进入与退出(`_switch`)。
|
|
2370
|
+
// 传**包装后**的 `archiveService.ops`:`scene-archive-save` 与 `scene-mode-set` 都在本文件
|
|
2371
|
+
// 被包了一层(前者联动人设开关、后者带锁定守卫与目录重算),工具走同一条路才有同一套行为。
|
|
2372
|
+
//
|
|
2373
|
+
// `sceneActivate` 传的是 **handlers 表里那份**(`buildSceneSyncOps` 覆盖过的):AGENTS.md
|
|
2374
|
+
// 同步在包装里,直调 `memoriesService.ops['rules-set-active']` 会静默漏掉"把场景绑定的
|
|
2375
|
+
// 预设正文写进 `~/.dsh/AGENTS.md`、退出时恢复基线"这一步。传 handlers 里那份而不是在这里
|
|
2376
|
+
// 再包一次,是为了让工具与 HTTP API 共用同一个实现(不会各自漂移)。
|
|
2377
|
+
buildSceneTools({
|
|
2378
|
+
...toolDeps,
|
|
2379
|
+
rulesOps: memoriesService.ops,
|
|
2380
|
+
archiveOps: archiveService.ops,
|
|
2381
|
+
sceneActivate: (args) => handlers['rules-set-active'](args),
|
|
2382
|
+
});
|
|
2033
2383
|
buildSubagentTools({
|
|
2034
2384
|
...toolDeps,
|
|
2035
2385
|
subagentService,
|
|
@@ -2045,12 +2395,28 @@ export default {
|
|
|
2045
2395
|
// 在完全权限下的行为一致;探测实现与回归测试见 approval-policy.ts(必须 ctx.get('approval'),
|
|
2046
2396
|
// 不能用 ctx.approval——inject 未声明该服务时 cordis 代理会抛 "cannot get property without inject")。
|
|
2047
2397
|
const CONFIRM_LABELS = {
|
|
2048
|
-
|
|
2049
|
-
|
|
2398
|
+
// 0.14.0 起 create 与 update 并成 save(标签取中性的「保存」:改一份已有技能时
|
|
2399
|
+
// "新建"是句假话)。危险度不变 —— 两者都往 hub 落文件。
|
|
2400
|
+
skill_manager_save: '「保存技能」',
|
|
2401
|
+
// 0.14.0 起 write/update 并成一条 upsert,标签取中性的「保存记忆」:「写入」在改一条
|
|
2402
|
+
// 已有记忆时是句假话(与同一轮修 `prompt_manager_list` 的「生效中」同一个口径)。
|
|
2403
|
+
memory_manager_save: '「保存记忆」',
|
|
2404
|
+
// 场景族的唯一写工具。无条件问(不设开关)—— 它改的是"进入这个场景时会切换哪些
|
|
2405
|
+
// mcp / 技能 / 人设",改错了影响的是用户切场景之后的**整个运行时环境**,而不是一次
|
|
2406
|
+
// 输出。与 skill / subagent 的 save 同一档(都是"以后每次都按它来"的长期资产)。
|
|
2407
|
+
scene_manager_save: '「保存场景」',
|
|
2408
|
+
// 0.14.0 的边界是"启用与进入留在界面",0.14.x 补上这条工具后它成为**改运行时**的那一条:
|
|
2409
|
+
// 六处开关一起动 + 收窄注入 + 改写 AGENTS.md。危险度高于 `_save`(那个只写剧本),
|
|
2410
|
+
// 所以门禁同档、不降。
|
|
2411
|
+
scene_manager_switch: '「切换场景」',
|
|
2050
2412
|
subagent_manager_run: '「运行子代理」',
|
|
2051
|
-
//
|
|
2052
|
-
//
|
|
2053
|
-
|
|
2413
|
+
// 0.14.0 起 create 与 update 并成 save(标签取中性的「保存人设」:改一份已有文件时
|
|
2414
|
+
// "新建"是句假话)。危险度不变 —— 两者都往 hub 里落/整份重写一份文件。
|
|
2415
|
+
subagent_manager_save: '「保存人设」',
|
|
2416
|
+
// 0.14.0 起 add 与 edit 并成 save,模型侧能塞进任意 command/args 的仍只有这一个工具
|
|
2417
|
+
// —— 门禁挂在它上面即覆盖整个模型可达面。标签取中性的「保存」:改一台已有服务器
|
|
2418
|
+
// (含改 URL / 命令)与新增一台是同一档危险度,但"新增"在改分支上是句假话。
|
|
2419
|
+
mcp_manager_save: '「保存 MCP 服务器」',
|
|
2054
2420
|
};
|
|
2055
2421
|
const bypassedByFullAccess = (exec) => {
|
|
2056
2422
|
if (!isApprovalNever(ctx, exec))
|
|
@@ -2059,10 +2425,10 @@ export default {
|
|
|
2059
2425
|
pluginLog()('confirm-bypass', `完全权限(approval=never):跳过${CONFIRM_LABELS[String(exec && exec.name)]}的确认,直接放行`).catch(() => { });
|
|
2060
2426
|
return true;
|
|
2061
2427
|
};
|
|
2062
|
-
//
|
|
2063
|
-
//
|
|
2064
|
-
//
|
|
2065
|
-
//
|
|
2428
|
+
// 确认门是「问用户要不要做」,如果这个请求本来就做不成,弹卡 / 写 bypass 日志只会产生
|
|
2429
|
+
// 一次无效审批:用户批准之后模型收到的是「人设不可用」/「人设不存在」/「人设已存在」。
|
|
2430
|
+
// 所以先校验,再决定要不要问。校验本身不可用时返回 true(保持原行为,
|
|
2431
|
+
// 绝不因为探测失败而少问一次)。
|
|
2066
2432
|
const subagentManagerRunTargetExists = async (exec) => {
|
|
2067
2433
|
try {
|
|
2068
2434
|
const name = String((exec && exec.arguments && exec.arguments.agent) || '').trim();
|
|
@@ -2082,37 +2448,117 @@ export default {
|
|
|
2082
2448
|
return next();
|
|
2083
2449
|
if (bypassedByFullAccess(exec))
|
|
2084
2450
|
return next();
|
|
2085
|
-
if (exec.name === '
|
|
2086
|
-
|
|
2451
|
+
if (exec.name === 'skill_manager_save') {
|
|
2452
|
+
// 卡是**执行前**弹的:此时工具还没读 `skill-state`,走建还是改还没判出来,
|
|
2453
|
+
// 所以 reason 只能取并集。0.14.0 起 create 与 update 并成一条 upsert。
|
|
2454
|
+
return Promise.resolve({ kind: 'ask', reason: 'Create or overwrite a skill under ~/.dsh/tool-management/skills' });
|
|
2087
2455
|
}
|
|
2088
|
-
if (exec.name === '
|
|
2456
|
+
if (exec.name === 'mcp_manager_save') {
|
|
2089
2457
|
// 为什么必须问:stdio 服务器是宿主按你给的 command/args **spawn** 出来的进程
|
|
2090
2458
|
// (见 http-fence.ts 对 mcpm-add 的说明),且这条目会写进配置长期生效。
|
|
2091
2459
|
// 写一个技能文件都要问,注册一条能起进程的配置却直接放行,是门禁倒挂 ——
|
|
2092
2460
|
// 危险度与门禁强度必须同向。
|
|
2093
2461
|
// 卡里回显将执行的命令行:用户批准的是「跑这条命令」,不是「加一个服务器」。
|
|
2462
|
+
// 0.14.0 起 save 也覆盖"改一台已存在的服务器",此时把**旧命令**一并摆出来:
|
|
2463
|
+
// 「把 `npx A` 改成 `npx B`」与「新增一台跑 `npx B` 的」是两件不同的事,
|
|
2464
|
+
// 只报新那句会让一次改命令看起来像新建。
|
|
2094
2465
|
const a = (exec && exec.arguments) || {};
|
|
2466
|
+
const server = String(a.server || '');
|
|
2095
2467
|
const transport = String(a.transport || '');
|
|
2096
2468
|
const cmd = [a.command, a.args]
|
|
2097
2469
|
.map((x) => String(x ?? '').trim())
|
|
2098
2470
|
.filter((x) => x !== '')
|
|
2099
2471
|
.join(' ');
|
|
2100
|
-
|
|
2472
|
+
// 新 URL 走打码形态:查询串里的凭据不该明文进确认卡(与列表视图同一份实现)。
|
|
2473
|
+
// 旧 URL 已经是打码的 —— 它来自列表视图。
|
|
2474
|
+
const nextLine = transport === 'stdio'
|
|
2101
2475
|
? (cmd ? `stdio command: ${cmd}` : 'stdio command: (empty)')
|
|
2102
|
-
: `${transport || 'streamable-http'} url: ${String(a.url || '(empty)')}`;
|
|
2476
|
+
: `${transport || 'streamable-http'} url: ${String(maskUrlQuery(String(a.url || '')) || '(empty)')}`;
|
|
2477
|
+
let cur = null;
|
|
2478
|
+
try {
|
|
2479
|
+
const r = await mcp.mcpmListView();
|
|
2480
|
+
cur = ((r && r.rows) || []).find((x) => String(x.serverName) === server) || null;
|
|
2481
|
+
}
|
|
2482
|
+
catch { /* 拿不到现状就按"新增"的口径报:宁可少说一句,也不因此拦住操作 */ }
|
|
2483
|
+
let detail = nextLine;
|
|
2484
|
+
if (cur) {
|
|
2485
|
+
const curCmd = [cur.command, Array.isArray(cur.args) ? cur.args.join(' ') : cur.args]
|
|
2486
|
+
.map((x) => String(x ?? '').trim())
|
|
2487
|
+
.filter((x) => x !== '')
|
|
2488
|
+
.join(' ');
|
|
2489
|
+
const curLine = String(cur.transport) === 'stdio'
|
|
2490
|
+
? `stdio command: ${curCmd || '(empty)'}`
|
|
2491
|
+
: `${String(cur.transport || 'streamable-http')} url: ${String(cur.url || '(empty)')}`;
|
|
2492
|
+
detail = `current — ${curLine}; new — ${nextLine}`;
|
|
2493
|
+
}
|
|
2103
2494
|
return Promise.resolve({
|
|
2104
2495
|
kind: 'ask',
|
|
2105
|
-
reason:
|
|
2496
|
+
reason: `${cur ? 'Update' : 'Add'} MCP server「${server}」— ${detail}. A stdio server is spawned by the host and the entry persists in the config.`,
|
|
2106
2497
|
});
|
|
2107
2498
|
}
|
|
2108
|
-
if (exec.name === '
|
|
2499
|
+
if (exec.name === 'memory_manager_save') {
|
|
2109
2500
|
// D2:模型写规则默认需确认;设置关闭后直接放行。ask 无应答者时降级为拒绝(fail-closed),
|
|
2110
2501
|
// 不在此处做任何兜底放行。
|
|
2502
|
+
// 0.14.0 起 write/update 并成一条 upsert,reason 必须同时覆盖两种可能:卡是**执行前**
|
|
2503
|
+
// 弹的,此时工具还没跑,走"建"还是"改"要读一次 `rules-list` 才知道。旧实现按工具名
|
|
2504
|
+
// 分叉出两句,合并后只能取并集 —— 不能只留 "write",那会让一次覆盖已有内容的操作
|
|
2505
|
+
// 在卡上看起来像新建(危险度与门禁强度必须同向)。
|
|
2506
|
+
const what = 'Create or overwrite a memory under ~/.dsh/tool-management/memories';
|
|
2111
2507
|
return mcp.readPluginSettings()
|
|
2112
|
-
.then((s) => (s.requireConfirmForModelRuleWrite
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2508
|
+
.then((s) => (s.requireConfirmForModelRuleWrite ? { kind: 'ask', reason: what } : next()))
|
|
2509
|
+
.catch(() => ({ kind: 'ask', reason: what }));
|
|
2510
|
+
}
|
|
2511
|
+
if (exec.name === 'scene_manager_save') {
|
|
2512
|
+
// 与记忆 save 的区别:**不设开关**。记忆写入有 `requireConfirmForModelRuleWrite`
|
|
2513
|
+
// 可以放行,场景写入没有 —— 它改的是"用户切进这个场景之后,哪些 mcp / 技能 / 人设
|
|
2514
|
+
// 会开、其余全部关掉",那是一次环境切换的剧本,不是一份可以事后改的文档。
|
|
2515
|
+
// 卡里回显场景名与这次要写的档案段(用户批准的是"这个场景进入时开这些")。
|
|
2516
|
+
const a = (exec && exec.arguments) || {};
|
|
2517
|
+
const scene = String(a.scene || '');
|
|
2518
|
+
const secs = ['mcp', 'skills', 'subagents'].filter((k) => a[k] !== undefined);
|
|
2519
|
+
const detail = secs.length
|
|
2520
|
+
? `archive sections: ${secs.join(', ')}`
|
|
2521
|
+
: 'record only (label / description / prompt)';
|
|
2522
|
+
return Promise.resolve({
|
|
2523
|
+
kind: 'ask',
|
|
2524
|
+
reason: `Save scene「${scene}」— ${detail}. Entering it switches on exactly what this scene lists and turns everything else off.`,
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
if (exec.name === 'scene_manager_switch') {
|
|
2528
|
+
// 与 `scene_manager_save` 同一档(都无条件问),但**问的是另一件事**:
|
|
2529
|
+
// save 改的是"以后进这个场景会怎样"(一份剧本),switch 改的是**现在** ——
|
|
2530
|
+
// 六处开关一起动(MCP 服务器级/工具级、技能来源级/技能级、人设、备注覆盖),
|
|
2531
|
+
// 注入范围随之收窄,并且**改写 `~/.dsh/AGENTS.md`**(本插件唯一会动那个文件的
|
|
2532
|
+
// 动作,覆盖前 5 代备份)。危险度只高不低,所以门禁强度不降。
|
|
2533
|
+
// 卡里回显动作与目标场景:用户批准的是"现在就切到 X",不是"改一下 X 的配置"。
|
|
2534
|
+
const a = (exec && exec.arguments) || {};
|
|
2535
|
+
const action = String(a.action || '');
|
|
2536
|
+
const scene = String(a.scene || '');
|
|
2537
|
+
const what = action === 'exit'
|
|
2538
|
+
? 'Leave the current scene — restore the runtime switches and the AGENTS.md baseline saved on entry'
|
|
2539
|
+
: `Enter scene「${scene}」— apply its archive (everything it does not list goes off), narrow injection to its memories, and apply its bound prompt preset to AGENTS.md`;
|
|
2540
|
+
return Promise.resolve({ kind: 'ask', reason: what });
|
|
2541
|
+
}
|
|
2542
|
+
if (exec.name === 'subagent_manager_save') {
|
|
2543
|
+
// 与 `skill_manager_save` 完全对称:往 hub 里落一份新文件 / 整份重写一份现有文件。
|
|
2544
|
+
// 无条件问(不设开关)—— 人设是"以后每次委派都按它来"的长期资产,改错了影响的是
|
|
2545
|
+
// 后续所有子代理的行为,而不是一次输出。
|
|
2546
|
+
// 改分支尤其:`subagent-update` 走 serializePersona 整份重写,改名还会连带改
|
|
2547
|
+
// 场景档案里的绑定 —— 那是一次会影响环境配置的操作,不是一句文案修改。
|
|
2548
|
+
// 建还是改要读一次现状才知道(工具侧也是执行时才读),所以这里查一下名单:
|
|
2549
|
+
// 只报"新建"会让一次整份重写看起来像加了个文件,反过来也一样。
|
|
2550
|
+
const a = (exec && exec.arguments) || {};
|
|
2551
|
+
const name = String(a.name || '');
|
|
2552
|
+
let exists = false;
|
|
2553
|
+
try {
|
|
2554
|
+
const docs = await subagentService.list();
|
|
2555
|
+
exists = docs.some((p) => String(p && p.name) === name);
|
|
2556
|
+
}
|
|
2557
|
+
catch { /* 读不到现状就按"新建"的口径报:宁可少说一句,也不因此拦住操作 */ }
|
|
2558
|
+
const what = exists
|
|
2559
|
+
? `Rewrite persona「${name}」${a.nextName ? ` and rename it to「${String(a.nextName)}」(scene-profile bindings follow the rename)` : ''}`
|
|
2560
|
+
: `Create persona「${name}」under ~/.dsh/tool-management/subagents`;
|
|
2561
|
+
return Promise.resolve({ kind: 'ask', reason: what });
|
|
2116
2562
|
}
|
|
2117
2563
|
// subagent_manager_run:子代理运行花真 token:默认确认(requireConfirmForModelSubagentRun !== false),可关。
|
|
2118
2564
|
// `inherit: true` 时子代理会读到本次会话已完成的对话 —— 卡里如实说明(用户批准的是
|
|
@@ -2161,6 +2607,11 @@ export default {
|
|
|
2161
2607
|
if (mcp.isToolDisabled(String((exec && exec.name) || ''))) {
|
|
2162
2608
|
return '该工具已在 MCP 管理页停用';
|
|
2163
2609
|
}
|
|
2610
|
+
// 兼容页关掉的工具:可见性半边(restrict)正常时模型根本看不到它,走到这里只有两种
|
|
2611
|
+
// 可能 —— 名字是猜的,或者可见性半边没生效(那条降级会上报)。执行侧一律拦住。
|
|
2612
|
+
if (toolTableHidden().has(String((exec && exec.name) || ''))) {
|
|
2613
|
+
return '该工具已在「兼容」页的模型工具表里关掉(要它先在那里打开)';
|
|
2614
|
+
}
|
|
2164
2615
|
}
|
|
2165
2616
|
catch (e) { /* fallthrough to allow */ }
|
|
2166
2617
|
return undefined;
|
|
@@ -2318,11 +2769,12 @@ export default {
|
|
|
2318
2769
|
}
|
|
2319
2770
|
}
|
|
2320
2771
|
// 「读改写」两态 op:不带 `set:true` 时是**纯读**,与写门禁无关。
|
|
2321
|
-
// `inject-settings
|
|
2772
|
+
// `inject-settings`(兼容页的「注入」块)、`tool-table`(同页的「模型工具表」)、
|
|
2773
|
+
// `scene-settings`(场景页的提醒开关)与 `mcpm-settings`(MCP 页的轮询设置)
|
|
2322
2774
|
// 都用同一个 op 承担读与写,于是整条 op 被列进 WRITE_OPS —— 读侧也一起被拦,
|
|
2323
2775
|
// 界面表现是"没填令牌时整块设置消失、填了还要刷新才出现"(2026-09-19 用户报的)。
|
|
2324
2776
|
// 判据是**这次调用要不要写**,不是 op 名在不在名单里。
|
|
2325
|
-
const readOnlyCall = (op === 'inject-settings' || op === 'mcpm-settings')
|
|
2777
|
+
const readOnlyCall = (op === 'inject-settings' || op === 'tool-table' || op === 'scene-settings' || op === 'mcpm-settings')
|
|
2326
2778
|
&& !(payload.args && payload.args.set === true);
|
|
2327
2779
|
if (TOKEN && WRITE_OPS.has(op) && !readOnlyCall && !tokenMatches(hdr('x-dsh-token'))) {
|
|
2328
2780
|
// 文案与明文门禁同源(http-fence.ts),code 也统一 —— 界面据此在最右侧挂
|