dsh-plugin-tool-management 0.17.0 → 0.18.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 +39 -0
- package/README.md +18 -3
- package/README_EN.md +18 -3
- 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 +16 -0
- package/lib/client.js +1573 -133
- package/lib/compat/capability-specs.js +321 -0
- package/lib/compat/host-probe.js +261 -0
- package/lib/compat/host-roots.js +168 -0
- package/lib/compat/probe-types.js +6 -0
- package/lib/compat/probe.js +9 -720
- package/lib/context-inject-contract.js +114 -0
- package/lib/context-inject-frame.js +147 -0
- package/lib/context-inject-select.js +127 -0
- package/lib/context-inject-surface.js +97 -0
- package/lib/context-inject.js +10 -478
- package/lib/http-api.js +229 -0
- package/lib/hub.js +2 -0
- package/lib/index.js +134 -836
- package/lib/mcp/inspect-probe.js +199 -0
- package/lib/mcp/manager.js +30 -548
- package/lib/mcp/tool-visibility.js +388 -0
- package/lib/memories/archive-engine.js +142 -5
- package/lib/memories/archive.js +10 -1
- package/lib/memories/discovery.js +261 -0
- package/lib/memories/export-plan.js +42 -0
- package/lib/memories/memories-types.js +1 -0
- package/lib/memories/service.js +4 -38
- package/lib/memories/snapshot.js +10 -252
- package/lib/op-registry.js +21 -0
- package/lib/ops/compat.js +11 -0
- package/lib/ops/quick-prompts.js +26 -0
- package/lib/ops/scene-records.js +40 -0
- package/lib/ops/scene-sync.js +71 -1
- package/lib/ops/sessions.js +13 -9
- package/lib/ops/snapshot.js +18 -12
- package/lib/prompts/quick-service.js +211 -0
- package/lib/scene-linkage.js +184 -0
- package/lib/sessions/archive-schemas.js +121 -0
- package/lib/sessions/path-key.js +46 -0
- package/lib/sessions/workspace.js +5 -163
- package/lib/settings-sidecars.js +143 -0
- package/lib/skills/core-types.js +8 -0
- package/lib/skills/core-util.js +81 -0
- package/lib/skills/core.js +56 -2326
- package/lib/skills/dir-watch.js +71 -0
- package/lib/skills/frontmatter.js +96 -0
- package/lib/skills/homes.js +14 -0
- package/lib/skills/import-pipeline.js +231 -0
- package/lib/skills/manager-state.js +800 -0
- package/lib/skills/official.js +269 -0
- package/lib/skills/provider-registration.js +89 -0
- package/lib/skills/service.js +5 -240
- package/lib/skills/skill-roots.js +287 -0
- package/lib/skills/skill-trash.js +265 -0
- package/lib/skills/system-trash.js +97 -0
- package/lib/skills/upload-pipeline.js +374 -0
- package/lib/slash-settings.js +25 -0
- package/lib/subagents/persona-parse.js +312 -0
- package/lib/subagents/persona-types.js +5 -0
- package/lib/subagents/persona-write.js +93 -0
- package/lib/subagents/run-provider.js +159 -0
- package/lib/subagents/service.js +14 -638
- package/lib/subagents/tool-filter.js +98 -0
- package/lib/token-config.js +240 -0
- package/lib/tool-table-switch.js +163 -0
- package/lib/tools/scene.js +40 -9
- package/package.json +3 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 官方 `tools.restrict()` 的**保留名**:名单里出现它时,官方不是"当它不存在",而是**直接抛错**
|
|
3
|
+
* (`dsh-tools/lib/index.js:2800`:cannot name reserved PTC mode presentation transport),
|
|
4
|
+
* 子代理当场起不来。
|
|
5
|
+
*
|
|
6
|
+
* 为什么必须在这里显式剔除:`run_code` 在宿主面上是**合法可见**的工具名(非 native 模式下
|
|
7
|
+
* 由官方补进可见集合),所以"按当前存在的工具名过滤未知项"剔不掉它 —— 过滤留下的正好是
|
|
8
|
+
* 会让官方抛错的那个。用户的直观预期是"写了就生效(或至少被忽略)",实际却是整个委派失败。
|
|
9
|
+
*/
|
|
10
|
+
export const RESERVED_TOOL_NAMES = new Set(['run_code']);
|
|
11
|
+
/** 把保留名从名单里剔掉,并说明剔了什么(不静默)。 */
|
|
12
|
+
export function stripReserved(names) {
|
|
13
|
+
const kept = [];
|
|
14
|
+
const dropped = [];
|
|
15
|
+
for (const name of names)
|
|
16
|
+
(RESERVED_TOOL_NAMES.has(name) ? dropped : kept).push(name);
|
|
17
|
+
return { kept, dropped };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 按**当前会话的 Agent 预设**决定这次委派下发什么工具限制。
|
|
21
|
+
*
|
|
22
|
+
* 为什么必须按预设分:子代理跑在父会话的预设里(官方 `composeFrom(childCtx, parent.ctx)`),
|
|
23
|
+
* 而各预设的工具集合差别极大(极简模式只有持久 shell)。官方 `tools.restrict()` 实有
|
|
24
|
+
* **四个**抛错点(`dsh-tools/lib/index.js:2790-2803`),抛了子代理就起不来:
|
|
25
|
+
*
|
|
26
|
+
* ① 非 scoped context 调用(宿主 `childCtx` 已满足,无风险);
|
|
27
|
+
* ② `allow` 与 `deny` 同时缺省 ⇒ `restrict({})` 抛 —— 本函数用 `filter: null`(不下发)
|
|
28
|
+
* 表达"不加限制",**从不**调用 `restrict({})`;
|
|
29
|
+
* ③ **名单含保留名 `run_code` 即抛** —— 而它在宿主面上是合法可见名,"按现有工具名过滤"
|
|
30
|
+
* 剔不掉它,所以这里**显式剔除**并写进 note(见 `stripReserved`);
|
|
31
|
+
* ④ 未知名 ⇒ 抛(唯一此前被记录的那条)。
|
|
32
|
+
*
|
|
33
|
+
* 所以:
|
|
34
|
+
* - 当前预设配了名单(且名单非空)→ 用它;白名单额外并入**当时真实在跑的 MCP 工具**
|
|
35
|
+
* (官方 allow 是"清单之外全砍",不并进来会把 MCP 一起砍掉;用户裁定:子代理要能
|
|
36
|
+
* 用当前启动的 MCP);
|
|
37
|
+
* - 当前预设没配 → 回落旧的全局 `tools` / `toolsDeny`(老文件行为不变);
|
|
38
|
+
* - 名单里有已经消失的工具名、或写了保留名 → **丢掉并在 note 里如实说明**(不接受静默失效);
|
|
39
|
+
* - 判断不了当前预设(老宿主 / 异常)→ 不按模式施加,并在 note 里说明。
|
|
40
|
+
*
|
|
41
|
+
* ⚠ 两种失败方向都要记账(它们互斥,且都不是"没生效"这么简单):名单全落空时本函数返回
|
|
42
|
+
* `filter: null` = **放宽到不限制**(fail-open);保留名没剔干净时官方抛错 = **收紧到起不来**。
|
|
43
|
+
*
|
|
44
|
+
* 三态返回:`filter: null` = 明确不加限制;`filter: {...}` = 下发该限制。
|
|
45
|
+
*/
|
|
46
|
+
export function decideToolFilter(persona, presetId, known) {
|
|
47
|
+
const rule = presetId === null ? undefined : persona.toolsByPreset?.[presetId];
|
|
48
|
+
if (rule && rule.names.length) {
|
|
49
|
+
const knownNames = rule.names.filter((name) => known.names.has(name));
|
|
50
|
+
const reserved = stripReserved(knownNames);
|
|
51
|
+
const usable = reserved.kept;
|
|
52
|
+
const dropped = rule.names.filter((name) => !known.names.has(name));
|
|
53
|
+
const notes = [];
|
|
54
|
+
if (dropped.length)
|
|
55
|
+
notes.push(`名单里这些工具当前不存在,已忽略:${dropped.join('、')}`);
|
|
56
|
+
if (reserved.dropped.length) {
|
|
57
|
+
notes.push(`名单里的 ${reserved.dropped.join('、')} 是官方保留名(写进工具限制会让子代理直接起不来),已忽略`);
|
|
58
|
+
}
|
|
59
|
+
if (!usable.length) {
|
|
60
|
+
const note = notes.length ? notes.join(';') : undefined;
|
|
61
|
+
return { filter: null, note: note ?? `「${presetId}」的${rule.mode === 'allow' ? '白' : '黑'}名单里没有当前存在的工具,本次不施加工具限制` };
|
|
62
|
+
}
|
|
63
|
+
const droppedNote = notes.length ? notes.join(';') : undefined;
|
|
64
|
+
if (rule.mode === 'allow')
|
|
65
|
+
return { filter: { allow: [...new Set([...usable, ...known.mcp])] }, ...(droppedNote === undefined ? {} : { note: droppedNote }) };
|
|
66
|
+
return { filter: { deny: usable }, ...(droppedNote === undefined ? {} : { note: droppedNote }) };
|
|
67
|
+
}
|
|
68
|
+
// 旧格式(全局名单):保持老行为,白名单同样并入 MCP。
|
|
69
|
+
const legacyKnown = [...(persona.tools || []), ...(persona.toolsDeny || [])].filter((name) => known.names.has(name));
|
|
70
|
+
const legacyReserved = stripReserved(legacyKnown);
|
|
71
|
+
const legacyKept = new Set(legacyReserved.kept);
|
|
72
|
+
const legacyAllow = (persona.tools || []).filter((name) => legacyKept.has(name));
|
|
73
|
+
const legacyDeny = (persona.toolsDeny || []).filter((name) => legacyKept.has(name));
|
|
74
|
+
const legacyDropped = [...(persona.tools || []), ...(persona.toolsDeny || [])].filter((name) => !known.names.has(name));
|
|
75
|
+
const filter = {
|
|
76
|
+
...(legacyAllow.length ? { allow: [...new Set([...legacyAllow, ...known.mcp])] } : {}),
|
|
77
|
+
...(legacyDeny.length ? { deny: legacyDeny } : {}),
|
|
78
|
+
};
|
|
79
|
+
const notes = [];
|
|
80
|
+
const hasLegacy = Boolean((persona.tools || []).length || (persona.toolsDeny || []).length);
|
|
81
|
+
if (presetId === null) {
|
|
82
|
+
// 说清这一刻到底靠什么在跑:有旧格式就照旧格式,没有就明说"按模式配的限制这次不生效"。
|
|
83
|
+
notes.push(hasLegacy
|
|
84
|
+
? '没能判断当前会话的 Agent 预设,按旧格式的全局名单执行'
|
|
85
|
+
: '没能判断当前会话的 Agent 预设,按模式配的限制这次不生效');
|
|
86
|
+
}
|
|
87
|
+
if (legacyDropped.length)
|
|
88
|
+
notes.push(`名单里这些工具当前不存在,已忽略:${legacyDropped.join('、')}`);
|
|
89
|
+
if (legacyReserved.dropped.length) {
|
|
90
|
+
notes.push(`名单里的 ${legacyReserved.dropped.join('、')} 是官方保留名(写进工具限制会让子代理直接起不来),已忽略`);
|
|
91
|
+
}
|
|
92
|
+
if (!Object.keys(filter).length) {
|
|
93
|
+
if (presetId === null && !notes.length)
|
|
94
|
+
notes.push('没能判断当前会话的 Agent 预设,本次不施加工具限制');
|
|
95
|
+
return { filter: null, ...(notes.length ? { note: notes.join(';') } : {}) };
|
|
96
|
+
}
|
|
97
|
+
return { filter, ...(notes.length ? { note: notes.join(';') } : {}) };
|
|
98
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// 访问令牌的宿主侧配置读写:定位本插件 loader 行所在的补丁文件、按形状分类,改
|
|
2
|
+
// `config.token` 与 `tokenDisabled` 那两行(兼容页「设置 / 关闭 / 打开 / 清除」四个动作)。
|
|
3
|
+
//
|
|
4
|
+
// 正文从 index.ts 的 apply 闭包里整段搬来,一字未改(只去一层缩进,并把八个 apply 作用域
|
|
5
|
+
// 名字改成走 deps —— moved-verify 按这三类容差机检,容差之外任何一字不同都会报错)。
|
|
6
|
+
//
|
|
7
|
+
// 为什么这一段单独成层:它是唯一会**改写宿主启动配置**的那只手,凭证口径(哪几个方向必须
|
|
8
|
+
// 当面再输一次当前令牌、哪几个可以认请求头里那份)全在下面 tokenConfigure 的注释里。
|
|
9
|
+
// HTTP 路由是它唯一的调用方,而且刻意不放进 handlers 表 —— 模型不该有能力关掉访问令牌。
|
|
10
|
+
import { applyLoaderToken, applyLoaderTokenDisabled, buildLoaderOverrideEntry, readLoaderToken, scanLoaderRows } from './mcp/loader-token.js';
|
|
11
|
+
export function createTokenConfig(deps) {
|
|
12
|
+
// ---------- 访问令牌:宿主侧配置的读写(兼容页就地开关) ----------
|
|
13
|
+
// 用户裁定 2026-09-18:① 没配令牌时能在面板里"设置";② 已配时,填对令牌就能"关闭";
|
|
14
|
+
// ③ 填过的令牌只在本次进程内有效,重启要重填。③在客户端实现(见 client.js 的 bootId 绑定),
|
|
15
|
+
// ①②在这里 —— 它们要改的是**插件自己那条 loader 行**的 config.token。
|
|
16
|
+
/**
|
|
17
|
+
* 找到本插件 loader 行所在的补丁文件,并**按形状分类**。
|
|
18
|
+
*
|
|
19
|
+
* 冲突只认 **insert** 条目:同 id 的两条 insert 才会重复挂载、让 DSH 起不来(与
|
|
20
|
+
* duplicateGuard 同一口径)。顶层**覆盖条目**(`- id:` 在第 0 列)是官方支持的改法,
|
|
21
|
+
* 与 insert 并存是正常形状 —— 把它也算成"第二份补丁"会让 0.1.7 bundle 挂载下的令牌
|
|
22
|
+
* 操作被全量拒绝(2026-09-30 审查 P2-12)。
|
|
23
|
+
*
|
|
24
|
+
* `files` 的**顺序即写点优先级**:覆盖条目在前。理由:官方 `applyEntryPatches` 在所有
|
|
25
|
+
* bundle 层**之后**应用覆盖条目、且是整体替换那条 loader 的 `config` —— 所以两种形状
|
|
26
|
+
* 并存时,令牌写进 insert 行会被覆盖条目盖掉(改了不生效,两边还都看不出分歧)。
|
|
27
|
+
*/
|
|
28
|
+
async function loaderTokenFiles() {
|
|
29
|
+
const p = await deps.ensurePaths();
|
|
30
|
+
const insertFiles = [];
|
|
31
|
+
const overrideFiles = [];
|
|
32
|
+
const infoOf = new Map();
|
|
33
|
+
for (const abs of [p.projectPatch, p.globalPatch]) {
|
|
34
|
+
let content = '';
|
|
35
|
+
try {
|
|
36
|
+
content = await deps.readPatch(abs);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const rows = scanLoaderRows(content);
|
|
42
|
+
if (!rows.insert.length && !rows.override.length)
|
|
43
|
+
continue;
|
|
44
|
+
const read = readLoaderToken(content);
|
|
45
|
+
if (!read.found)
|
|
46
|
+
continue;
|
|
47
|
+
infoOf.set(abs, { token: read.token, disabled: read.disabled });
|
|
48
|
+
if (rows.insert.length)
|
|
49
|
+
insertFiles.push(abs);
|
|
50
|
+
else
|
|
51
|
+
overrideFiles.push(abs);
|
|
52
|
+
}
|
|
53
|
+
// 0.1.7 bundle 挂载:loader 条目由插件包内的 cordis.patch.yml(bundle 层)提供,两份
|
|
54
|
+
// 补丁里都没有可就地改写的行。bundleBase 只回答「覆盖目标存在吗」—— 写点是 profile
|
|
55
|
+
// 补丁里的覆盖条目(见 tokenConfigure),bundle 文件本身永不落密钥。
|
|
56
|
+
let bundleBase = false;
|
|
57
|
+
try {
|
|
58
|
+
bundleBase = readLoaderToken(await deps.readPatch(p.bundlePatch)).found;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
bundleBase = false;
|
|
62
|
+
}
|
|
63
|
+
return { files: [...overrideFiles, ...insertFiles], insertFiles, infoOf, bundleBase };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 配置**文件里**写了令牌,但当前进程还没生效(或反过来)—— 也就是"改了配置还没重启"。
|
|
67
|
+
* 界面据此把"重启后生效"说成事实而不是猜测。
|
|
68
|
+
*
|
|
69
|
+
* 局限(写下来免得当成 bug):读不到环境变量,所以靠 `DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN`
|
|
70
|
+
* 提供令牌时(文件里没有 token),这里会报"待重启"。那是保守方向 —— 环境变量改动同样要重启。
|
|
71
|
+
*/
|
|
72
|
+
async function tokenConfigState() {
|
|
73
|
+
try {
|
|
74
|
+
const { files, infoOf } = await loaderTokenFiles();
|
|
75
|
+
if (files.length !== 1)
|
|
76
|
+
return { found: false, configHasToken: false, configDisabled: false, pendingRestart: false };
|
|
77
|
+
const info = infoOf.get(files[0]) || { token: '', disabled: false };
|
|
78
|
+
const configHasToken = info.token !== '';
|
|
79
|
+
// 「文件里配的」与「当前进程在用的」是否一致 —— 比的是**生效**状态(有位子但关了 = 没生效)。
|
|
80
|
+
const fileEffective = configHasToken && !info.disabled;
|
|
81
|
+
return { found: true, configHasToken, configDisabled: info.disabled, pendingRestart: fileEffective !== (deps.TOKEN !== '') };
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return { found: false, configHasToken: false, configDisabled: false, pendingRestart: false };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 改宿主侧的令牌配置:`set` 写入 / 换掉令牌、`off` 关掉令牌功能、`on` 重新打开、
|
|
89
|
+
* `clear` 把令牌从配置里删掉(回到"还没有令牌"那一态)。
|
|
90
|
+
*
|
|
91
|
+
* 关键设计(用户裁定 2026-09-19):
|
|
92
|
+
* - **`off` 不再删除 `token`**,只写一行 `tokenDisabled: true` —— 原令牌保留,随时能开回来,
|
|
93
|
+
* 不需要重新输一遍(此前的实现把配置删了,用户想再开就得重新想一遍令牌)。
|
|
94
|
+
* - **`clear` 才是"删掉"**(用户 2026-09-19 问"令牌没有彻底清除按钮"):删 `config.token`
|
|
95
|
+
* 与 `tokenDisabled` 两行,回到从未设置过的样子 —— 写操作不再要凭证、明文密钥随之
|
|
96
|
+
* 不可见。它是不可逆的那一端(配置里没有副本了),所以与 `off` 共用同一条凭证口径。
|
|
97
|
+
* - **`off` / `clear` 只认"当场再输一次"的令牌**(`value` 对得上才算):请求头里那份已验过
|
|
98
|
+
* 的凭证不算数 —— 解锁之后顺手一点就能把防护关掉 / 把令牌删掉,等于没把守(用户裁定
|
|
99
|
+
* 2026-09-19:「就算输入过了令牌,关闭保护也应该再次输入令牌才能关闭」)。界面据此就地在
|
|
100
|
+
* 「保护开关」/「宿主配置」里展开一个确认框。
|
|
101
|
+
* - **`set` 与 `off` / `clear` 同一条口径**:宿主已有令牌时,改令牌也必须当场再输一次当前
|
|
102
|
+
* 令牌(走 `current` 字段 —— `token` 里是新令牌,证明不了知道旧值;凭证是旧值这件事不能
|
|
103
|
+
* 靠请求头里那份"本次启动已解锁"顶替)。界面据此在「宿主配置」表单里多摆一栏「当前令牌」,
|
|
104
|
+
* 并且不再要求用户先解锁再改。
|
|
105
|
+
* - **`on` 仍接受请求头里的凭证**(`presented`),或输入框里那个值本身就是当前令牌。
|
|
106
|
+
* 两个方向都必须验 —— 否则"能打开 GUI 就能关掉保护",令牌等于白配(要求 9)。
|
|
107
|
+
* - `set`:宿主**还没配**令牌时允许(首次设置 —— 否则这个功能永远打不开),那一态没有
|
|
108
|
+
* "当前令牌"可证明,所以 `current` 不作要求。
|
|
109
|
+
*
|
|
110
|
+
* 不放进 handlers:这样它既不在 HTTP 的普通 op 面上,也**不会**被任何模型工具间接调用 ——
|
|
111
|
+
* 模型不该有能力关掉访问令牌。
|
|
112
|
+
*/
|
|
113
|
+
async function tokenConfigure(args, presented) {
|
|
114
|
+
const raw = String((args && args.mode) || '');
|
|
115
|
+
const mode = raw === 'off' ? 'off' : raw === 'on' ? 'on' : raw === 'clear' ? 'clear' : 'set';
|
|
116
|
+
const value = String((args && args.token) || '').trim();
|
|
117
|
+
const currentRaw = String((args && args.current) || '').trim();
|
|
118
|
+
// 关闭 / 重新打开时,用户填进输入框的那个值**就是**当前令牌,可以直接当凭证 ——
|
|
119
|
+
// 省掉"先保存再操作"两步。`set` 不行:那个值是新令牌,不能拿它证明自己知道旧值,
|
|
120
|
+
// 所以它的凭证单独走 `current`。
|
|
121
|
+
const proofByValue = mode !== 'set' && value !== '' && deps.CONFIG_TOKEN !== '' && deps.tokenMatches(value);
|
|
122
|
+
const proofByCurrent = mode === 'set' && currentRaw !== '' && deps.CONFIG_TOKEN !== '' && deps.tokenMatches(currentRaw);
|
|
123
|
+
// 关闭保护 / 删除令牌 / 修改令牌都是"改凭证 / 减防护"的方向:**不认**请求头里已经验过的
|
|
124
|
+
// 那份凭证,必须当面再输一次当前令牌(用户裁定 2026-09-19 —— 先判「关闭」,随后同一口径
|
|
125
|
+
// 推到「修改」,再推到「删除」)。开启保护仍接受请求头里的凭证。
|
|
126
|
+
if (mode === 'off' && deps.CONFIG_TOKEN !== '' && !proofByValue) {
|
|
127
|
+
return { ok: false, code: 'error.secret.badToken', error: '关闭保护要再输一次当前令牌。' };
|
|
128
|
+
}
|
|
129
|
+
if (mode === 'clear' && deps.CONFIG_TOKEN !== '' && !proofByValue) {
|
|
130
|
+
return { ok: false, code: 'error.secret.badToken', error: '删除令牌要再输一次当前令牌。' };
|
|
131
|
+
}
|
|
132
|
+
if (mode === 'set' && deps.CONFIG_TOKEN !== '' && !proofByCurrent) {
|
|
133
|
+
return { ok: false, code: 'error.secret.badToken', error: '修改令牌要先填一次当前令牌。' };
|
|
134
|
+
}
|
|
135
|
+
if (deps.CONFIG_TOKEN !== '' && !presented && !proofByValue && !proofByCurrent) {
|
|
136
|
+
return { ok: false, code: 'error.secret.badToken', error: '要改动访问令牌,请先在「本次启动」里填入当前令牌并解锁。' };
|
|
137
|
+
}
|
|
138
|
+
if ((mode === 'off' || mode === 'clear') && deps.CONFIG_TOKEN === '') {
|
|
139
|
+
return { ok: true, changed: false, note: '宿主侧本来就没有配置令牌。', restartRequired: false };
|
|
140
|
+
}
|
|
141
|
+
if (mode === 'on' && deps.CONFIG_TOKEN === '') {
|
|
142
|
+
return { ok: false, error: '配置里没有令牌:请先用「设置令牌」写入一个,再打开令牌保护。' };
|
|
143
|
+
}
|
|
144
|
+
if (mode === 'set') {
|
|
145
|
+
if (!value)
|
|
146
|
+
return { ok: false, error: '请先填入要设置的令牌' };
|
|
147
|
+
// 换行会写坏 YAML 标量、NUL 会截断路径 —— 这两类直接拒绝比转义更清楚。
|
|
148
|
+
if (/[\r\n\u0000]/.test(value))
|
|
149
|
+
return { ok: false, error: '令牌不能包含换行或控制字符' };
|
|
150
|
+
if (value === deps.CONFIG_TOKEN)
|
|
151
|
+
return { ok: true, changed: false, note: '和当前令牌相同,未改动。', restartRequired: false };
|
|
152
|
+
}
|
|
153
|
+
const p = await deps.ensurePaths();
|
|
154
|
+
const { files, insertFiles, bundleBase } = await loaderTokenFiles();
|
|
155
|
+
if (!files.length && !(mode === 'set' && bundleBase)) {
|
|
156
|
+
return { ok: false, error: '没找到本插件的 loader 行,也无法确认 bundle 层的挂载条目,无法自动改写配置:请在 profile 的 cordis.patch.yml 里手动加一条覆盖条目(- id: dsh-plugin-tool-management → config: → token: "…"),或改用环境变量 DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN。' };
|
|
157
|
+
}
|
|
158
|
+
// 只有**两条 insert 条目**才是"重复挂载、DSH 起不来"(审查 P2-12:覆盖条目与 insert
|
|
159
|
+
// 并存是 0.1.7 的正常形状,把它也算进来会让令牌操作被全量拒绝)。
|
|
160
|
+
if (insertFiles.length > 1) {
|
|
161
|
+
return { ok: false, error: '本插件的 loader 行作为 insert 条目同时出现在两份补丁文件里(重复挂载会导致 DSH 无法启动):请先清掉重复那条,再回来设置令牌。' };
|
|
162
|
+
}
|
|
163
|
+
// 覆盖条目多于一份则写点不唯一:官方按文件顺序 last-wins,我们无法确定该改哪一份 ——
|
|
164
|
+
// 与其赌一份,不如让用户先合并。
|
|
165
|
+
if (files.length > 1) {
|
|
166
|
+
return { ok: false, error: '本插件的 loader 行在多处出现且都不是唯一的 insert 条目(多半是多条覆盖条目):无法确定该改哪一处,请先手工合并成一条再设置令牌。' };
|
|
167
|
+
}
|
|
168
|
+
if (!files.length) {
|
|
169
|
+
// 0.1.7 bundle 挂载:两份补丁里都没有本插件的 loader 行(条目由 bundle 层提供,那份
|
|
170
|
+
// 文件是仓库镜像、不能落密钥)—— 令牌写进 **profile 补丁的覆盖条目**:宿主在所有
|
|
171
|
+
// bundle 层之后应用它,整体替换那条 loader 的 config(bundle 条目没有 config,替换
|
|
172
|
+
// 零丢失);同 id 的 insert 条目才会重复挂载导致启动失败,覆盖条目是官方支持的改法。
|
|
173
|
+
// `off` / `clear` / `on` 走不到这里:有 site 时就地改写,没 site 且有令牌 = 令牌来自
|
|
174
|
+
// 环境变量,那两条路在前面已按各自口径返回。
|
|
175
|
+
return deps.withWriteLock(async () => {
|
|
176
|
+
let content = '';
|
|
177
|
+
try {
|
|
178
|
+
content = await deps.readPatch(p.projectPatch);
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
return { ok: false, error: '读取补丁失败: ' + deps.message(e) };
|
|
182
|
+
}
|
|
183
|
+
const next = content === '' ? buildLoaderOverrideEntry(value) + '\n' : (content.endsWith('\n') ? content : content + '\n') + '\n' + buildLoaderOverrideEntry(value);
|
|
184
|
+
try {
|
|
185
|
+
await deps.writePatch(p.projectPatch, next);
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
return { ok: false, error: '写入补丁失败: ' + deps.message(e) };
|
|
189
|
+
}
|
|
190
|
+
return { ok: true, changed: true, mode, restartRequired: true, path: p.projectPatch };
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const abs = files[0];
|
|
194
|
+
return deps.withWriteLock(async () => {
|
|
195
|
+
let content = '';
|
|
196
|
+
try {
|
|
197
|
+
content = await deps.readPatch(abs);
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
return { ok: false, error: '读取补丁失败: ' + deps.message(e) };
|
|
201
|
+
}
|
|
202
|
+
// 两步改写共用一份内容:先写令牌(set 写值 / clear 删掉),再写开关(set 与 clear 顺带
|
|
203
|
+
// 把"已关闭"那行清掉 —— 换了个新令牌却还留着"已关闭"、或者令牌都没了还写着"已关闭",
|
|
204
|
+
// 都是说不通的)。
|
|
205
|
+
let changed = false;
|
|
206
|
+
// `refused`:loader 行里的 config 形状是插件读不了的(flow style / 重复键)。此时
|
|
207
|
+
// 改写会插入第二个 `config:`,而官方 js-yaml 对重复映射键是 throw —— 后果是 DSH 下次
|
|
208
|
+
// 起不来。所以拒绝并说明,而不是"尽力写一下"。
|
|
209
|
+
if (mode === 'set' || mode === 'clear') {
|
|
210
|
+
const wrote = applyLoaderToken(content, mode === 'clear' ? null : value);
|
|
211
|
+
if (!wrote.found)
|
|
212
|
+
return { ok: false, error: '没找到本插件的 loader 行:' + abs };
|
|
213
|
+
if (wrote.refused)
|
|
214
|
+
return { ok: false, error: wrote.refused };
|
|
215
|
+
content = wrote.content;
|
|
216
|
+
changed = changed || wrote.changed;
|
|
217
|
+
}
|
|
218
|
+
const flag = mode === 'off' ? true : mode === 'clear' ? null : false;
|
|
219
|
+
const toggled = applyLoaderTokenDisabled(content, flag);
|
|
220
|
+
if (!toggled.found)
|
|
221
|
+
return { ok: false, error: '没找到本插件的 loader 行:' + abs };
|
|
222
|
+
if (toggled.refused)
|
|
223
|
+
return { ok: false, error: toggled.refused };
|
|
224
|
+
content = toggled.content;
|
|
225
|
+
changed = changed || toggled.changed;
|
|
226
|
+
if (changed) {
|
|
227
|
+
try {
|
|
228
|
+
await deps.writePatch(abs, content);
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
return { ok: false, error: '写入补丁失败: ' + deps.message(e) };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// 改配置**需要重启 DSH 才生效**:当前进程的 TOKEN 是 apply 时读进来的常量。
|
|
235
|
+
// 所以这里如实回 restartRequired,界面据此说清"现在还没生效",而不是让用户以为失败了。
|
|
236
|
+
return { ok: true, changed, mode, restartRequired: changed, path: abs };
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return { tokenConfigState, tokenConfigure };
|
|
240
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { hubPath } from './hub.js';
|
|
2
|
+
import { addPreset, buildToolTableReport, DEFAULT_HIDDEN_TOOLS, dropPreset, migrateLegacyToolNames, normalizeToolTableSettings, PRESET_MAX_COUNT, PRESET_NAME_MAX_LENGTH, toolTableSettingsFrom, } from './tools/table.js';
|
|
3
|
+
export function createToolTableSwitch(deps) {
|
|
4
|
+
// ---------- 模型工具表开关(侧车 `tool-table.json`,界面在「兼容」页)----------
|
|
5
|
+
// 工具表按**每个请求**付钱:20 个工具的整份定义合计 ≈3,769 tok 每轮都在。关掉某几个,
|
|
6
|
+
// 它们整份不进请求(实测口径与取舍见 src/tools/table.ts 的文件头)。**出厂就已经关了十五条**
|
|
7
|
+
// (20 条里实发只剩 5 条)—— 名单、判据与代价在 `DEFAULT_HIDDEN_TOOLS`,只在这个人从没记过
|
|
8
|
+
// 选择时铺(见下面的 `toolTableSettingsFrom`)。
|
|
9
|
+
//
|
|
10
|
+
// 为什么放在目录之前:两个目录的「用 `X` 查」提示要跟着这份设置变(工具关掉后那句话
|
|
11
|
+
// 就是假的),所以它们的构造依赖在这块之后 —— 不做前向引用,顺序就是依赖顺序。
|
|
12
|
+
const TOOL_TABLE_FILE = 'tool-table.json';
|
|
13
|
+
const TOOL_TABLE_TTL_MS = 3000;
|
|
14
|
+
/** 注册时量到的工具体积(只记真进了表的那些;量的是 register 收到的整份定义)。 */
|
|
15
|
+
const toolTableSizes = new Map();
|
|
16
|
+
function recordToolSize(def) {
|
|
17
|
+
try {
|
|
18
|
+
const name = String(def.name ?? '');
|
|
19
|
+
if (name === '')
|
|
20
|
+
return;
|
|
21
|
+
// 与宿主同一口径(dsh-token-meter 的 estimateToolsTokens 就是把 tools 整份
|
|
22
|
+
// JSON.stringify 后除以 4),所以这里量整份定义,不只量 description。
|
|
23
|
+
toolTableSizes.set(name, JSON.stringify(def).length);
|
|
24
|
+
}
|
|
25
|
+
catch { /* 量不出来就不记:它只影响界面上的估算数字 */ }
|
|
26
|
+
}
|
|
27
|
+
let toolTableCache = null;
|
|
28
|
+
async function readToolTableSettings(force = false) {
|
|
29
|
+
if (toolTableCache && !force && Date.now() - toolTableCache.at < TOOL_TABLE_TTL_MS)
|
|
30
|
+
return toolTableCache.value;
|
|
31
|
+
await deps.ensurePaths();
|
|
32
|
+
const raw = await deps.readJsonFile(hubPath(TOOL_TABLE_FILE));
|
|
33
|
+
// 0.14.0 旧工具名迁移:读侧翻译一次并**回写盘**(幂等)。不迁移的话,用户"关掉了某条"
|
|
34
|
+
// 的意图会在新名字上静默失效 —— 那条工具照旧每轮发出去,而界面上看不出来。
|
|
35
|
+
// 写失败不回滚本次读取:内存里已经是迁移后的值,下次读会再试一次。
|
|
36
|
+
// 侧车不存在(`readJsonFile` 给 null)= 用户从没记过选择 ⇒ 用**出厂默认**(十五条不发,
|
|
37
|
+
// 实发 5 条;判据与代价见 tools/table.ts 的 DEFAULT_HIDDEN_TOOLS)。存过盘的
|
|
38
|
+
// 一律照盘上那份,包括显式的 `hidden: []` —— 那是"我全都要",不能被默认值盖掉。
|
|
39
|
+
const migrated = migrateLegacyToolNames(toolTableSettingsFrom(raw));
|
|
40
|
+
const value = migrated.settings;
|
|
41
|
+
if (migrated.changed) {
|
|
42
|
+
try {
|
|
43
|
+
await deps.writeJsonFile(hubPath(TOOL_TABLE_FILE), value);
|
|
44
|
+
}
|
|
45
|
+
catch { /* 回写失败:本次仍按迁移后的值生效 */ }
|
|
46
|
+
}
|
|
47
|
+
const changed = toolTableCache === null || toolTableCache.value.hidden.join('\u0000') !== value.hidden.join('\u0000');
|
|
48
|
+
toolTableCache = { at: Date.now(), value, off: new Set(value.hidden) };
|
|
49
|
+
// 首读 / 文件被外部改过 ⇒ 可见性要跟着重排。同步快照的调用方(门禁、注入通道)不会
|
|
50
|
+
// 自己重排,它们只是"下次问的时候读到新值";把限制摘掉/装上这一步必须有人做。
|
|
51
|
+
// (`mcp` 在 apply 里是同步赋值、本函数只可能在自己的 await 之后回到这里,所以到得了。)
|
|
52
|
+
if (changed) {
|
|
53
|
+
try {
|
|
54
|
+
deps.mcp.scheduleToolRestrictions();
|
|
55
|
+
}
|
|
56
|
+
catch { /* 兜底:拿不到 manager 就等下一次 tools/change */ }
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 同步快照(热路径用:工具门禁每次调用、注入通道每个 step 都要问一句)。还没加载时先给
|
|
62
|
+
* **空集**并异步预热 —— 空集不是出厂默认(出厂关着五条),是刻意的:预热前的这一瞬与
|
|
63
|
+
* 用户的选择可能不一致,但方向必须是安全的那一侧(不隐藏任何东西,绝不因为读盘慢而让
|
|
64
|
+
* 工具凭空消失)。
|
|
65
|
+
*/
|
|
66
|
+
function toolTableHidden() {
|
|
67
|
+
if (toolTableCache === null) {
|
|
68
|
+
void readToolTableSettings().catch(() => { });
|
|
69
|
+
return deps.EMPTY_TOOL_SET;
|
|
70
|
+
}
|
|
71
|
+
return toolTableCache.off;
|
|
72
|
+
}
|
|
73
|
+
const toolTableRows = () => [...toolTableSizes].map(([name, bytes]) => ({ name, bytes }));
|
|
74
|
+
const toolTableReport = (hidden = [...toolTableHidden()]) => buildToolTableReport(toolTableRows(), hidden);
|
|
75
|
+
/** 落盘 + 同步内存缓存(三条写路径共用:改勾选 / 存方案 / 删方案)。 */
|
|
76
|
+
async function writeToolTableSettings(next) {
|
|
77
|
+
await deps.ensurePaths();
|
|
78
|
+
// 落盘在写锁里,落完**出了锁**再刷新(`withWriteLock` 是一条不可重入的链:锁里再调
|
|
79
|
+
// 一次会等自己,直接卡死)。刷新是两处目录读,本来也不必占着写锁。
|
|
80
|
+
return deps.withWriteLock(async () => {
|
|
81
|
+
try {
|
|
82
|
+
await deps.writeJsonFile(hubPath(TOOL_TABLE_FILE), next);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
return { ok: false, error: '设置保存失败: ' + deps.message(e) };
|
|
86
|
+
}
|
|
87
|
+
toolTableCache = { at: Date.now(), value: next, off: new Set(next.hidden) };
|
|
88
|
+
return { ok: true };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 回给界面的同一份形状:当前名单 + 已存方案 + 出厂默认名单 + 体积报告。
|
|
93
|
+
*
|
|
94
|
+
* `defaultHidden` 单独回一份而不是塞进 `presets`:那条**不存在文件里**(它是代码里的
|
|
95
|
+
* `DEFAULT_HIDDEN_TOOLS`),写进文件就会出现"用户删不掉的一条数据"。界面把它排在最前、
|
|
96
|
+
* 不给删除键,词典出它的名字。
|
|
97
|
+
*/
|
|
98
|
+
const toolTablePayload = (value) => ({
|
|
99
|
+
ok: true, hidden: value.hidden, presets: value.presets,
|
|
100
|
+
defaultHidden: [...DEFAULT_HIDDEN_TOOLS], report: toolTableReport(value.hidden),
|
|
101
|
+
});
|
|
102
|
+
/** 方案名:折叠空白 + 截到上限。空名由调用方报错(这里不猜"用户想叫什么")。 */
|
|
103
|
+
const presetNameOf = (raw) => String(raw ?? '').replaceAll(/\s+/g, ' ').trim().slice(0, PRESET_NAME_MAX_LENGTH);
|
|
104
|
+
async function toolTableOp(args) {
|
|
105
|
+
const current = await readToolTableSettings();
|
|
106
|
+
if (!args || args.set !== true) {
|
|
107
|
+
// 存 / 删方案都不动 `hidden`,所以这两条路**不**重排可见性、也不刷目录(那是 `set`
|
|
108
|
+
// 那一条才有的三处联动)。失败原因回 `code` 不回中文串 —— 英文界面会原样露出中文。
|
|
109
|
+
if (args && args.presetSave !== undefined) {
|
|
110
|
+
const name = presetNameOf(args.presetSave);
|
|
111
|
+
if (name === '')
|
|
112
|
+
return { ok: false, code: 'nameEmpty' };
|
|
113
|
+
const added = addPreset(current, name, current.hidden);
|
|
114
|
+
// 同名不覆盖(用户裁定):撞名要用户换个名字,而不是悄悄改掉已有那份。
|
|
115
|
+
if (added.exists)
|
|
116
|
+
return { ok: false, code: 'nameTaken', name };
|
|
117
|
+
if (current.presets.length >= PRESET_MAX_COUNT) {
|
|
118
|
+
return { ok: false, code: 'limit', limit: PRESET_MAX_COUNT };
|
|
119
|
+
}
|
|
120
|
+
const saved = await writeToolTableSettings(added.settings);
|
|
121
|
+
if (!saved.ok)
|
|
122
|
+
return saved;
|
|
123
|
+
return toolTablePayload(added.settings);
|
|
124
|
+
}
|
|
125
|
+
if (args && args.presetDelete !== undefined) {
|
|
126
|
+
const name = presetNameOf(args.presetDelete);
|
|
127
|
+
const dropped = dropPreset(current, name);
|
|
128
|
+
if (!dropped.changed)
|
|
129
|
+
return { ok: false, code: 'notFound', name };
|
|
130
|
+
const saved = await writeToolTableSettings(dropped.settings);
|
|
131
|
+
if (!saved.ok)
|
|
132
|
+
return saved;
|
|
133
|
+
return toolTablePayload(dropped.settings);
|
|
134
|
+
}
|
|
135
|
+
return toolTablePayload(current);
|
|
136
|
+
}
|
|
137
|
+
// 只收**已注册**的名字:写进来的陌生名字在下一次 restrict 时会让官方抛错
|
|
138
|
+
// ("names unknown global tool")。注册失败的工具本来也不在表里。
|
|
139
|
+
const hidden = normalizeToolTableSettings({ hidden: args.hidden }).hidden
|
|
140
|
+
.filter((name) => toolTableSizes.has(name));
|
|
141
|
+
const next = { hidden, presets: current.presets };
|
|
142
|
+
const saved = await writeToolTableSettings(next);
|
|
143
|
+
if (!saved.ok)
|
|
144
|
+
return saved;
|
|
145
|
+
// 三处跟着变:可见性(restrict 名单重排)、两个目录(截断提示里点名的工具可能没了)。
|
|
146
|
+
// 目录的 refresh 会把新的一句话注入出去 —— 内容变了才重发,这是正确行为。
|
|
147
|
+
deps.mcp.scheduleToolRestrictions();
|
|
148
|
+
await Promise.all([
|
|
149
|
+
deps.skillCatalog.refresh().catch(() => { }),
|
|
150
|
+
deps.subagentCatalog.refresh().catch(() => { }),
|
|
151
|
+
]);
|
|
152
|
+
return toolTablePayload(next);
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
toolTableSizes,
|
|
156
|
+
recordToolSize,
|
|
157
|
+
readToolTableSettings,
|
|
158
|
+
toolTableHidden,
|
|
159
|
+
writeToolTableSettings,
|
|
160
|
+
toolTableReport,
|
|
161
|
+
toolTableOp,
|
|
162
|
+
};
|
|
163
|
+
}
|
package/lib/tools/scene.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// **场景段的动作句仍然不加回来**:「上下文注入只给现在是什么」是同一天定下的原则,
|
|
26
26
|
// 不因为多了一条工具就破例(工具描述本来就是写"什么时候该用它"的地方)。
|
|
27
27
|
import { text, toolNameList } from './deps.js';
|
|
28
|
+
import { FACTORY_TOOL_TABLE_PRESET } from './table.js';
|
|
28
29
|
/** 档案里由模型设置的三个段(`memories` 段在 P5 已废弃,不再是档案的一部分)。 */
|
|
29
30
|
const ARCHIVE_SECTIONS = ['mcp', 'skills', 'subagents'];
|
|
30
31
|
/** 段里的名单:数组段是名字列表,`mcp` 段是 serverName → `'*'` | 工具名数组。段缺席 = 空。 */
|
|
@@ -35,6 +36,16 @@ function segmentNames(value) {
|
|
|
35
36
|
return Object.keys(value);
|
|
36
37
|
return [];
|
|
37
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* 档案里绑的工具表方案 → 给人读的那一句。`factory-default` 是个哨兵值而不是本机存过的方案,
|
|
41
|
+
* 原样打出来模型会去找一份叫这个名字的方案(`_save` 那边也没有这一栏,它改不了)。
|
|
42
|
+
*/
|
|
43
|
+
function toolTableLabel(name) {
|
|
44
|
+
const v = typeof name === 'string' ? name.trim() : '';
|
|
45
|
+
if (!v)
|
|
46
|
+
return '';
|
|
47
|
+
return v === FACTORY_TOOL_TABLE_PRESET ? '出厂默认' : v;
|
|
48
|
+
}
|
|
38
49
|
/**
|
|
39
50
|
* 一个档案的名单渲染(`_list` 用)。**点名而不是只给数量**:模型要判断"这个场景进入时会不会
|
|
40
51
|
* 关掉它正用的那台服务器",数量答不了这一句。`mcpNotes` 是档案里的备注覆盖,单独列一项。
|
|
@@ -52,6 +63,12 @@ function archiveText(archive) {
|
|
|
52
63
|
const notes = keys(archive.mcpNotes);
|
|
53
64
|
if (notes.length)
|
|
54
65
|
parts.push('备注 ' + toolNameList(notes));
|
|
66
|
+
// 模型工具表方案(0.15.0 起档案里有这一栏):进入时换的是**模型自己能用什么**,不报就是
|
|
67
|
+
// 让它事后发现自己少了工具却说不出原因。只给方案名 —— 关停名单不在档案里,硬算要多读一次
|
|
68
|
+
// 方案清单,而真正需要条数的时机是切换之后(那条回执有)。没绑就不冒这一格(与备注同口径)。
|
|
69
|
+
const tt = toolTableLabel(archive.toolTablePreset);
|
|
70
|
+
if (tt)
|
|
71
|
+
parts.push('工具表方案「' + tt + '」');
|
|
55
72
|
return parts.join(' | ');
|
|
56
73
|
}
|
|
57
74
|
/**
|
|
@@ -107,7 +124,7 @@ export function buildSceneTools(deps) {
|
|
|
107
124
|
// 默认的隐藏名单里):静态文本没法按 toolVisible 改写,点名一条模型没有的工具只会让它
|
|
108
125
|
// 白跑一趟。要说"去哪看"就用中性说法(the scene listing / the「本机当前的场景」reminder
|
|
109
126
|
// —— 后者是常驻源,只要场景域开着就在)。点名只留在**运行时回执**里、按可见性分叉。
|
|
110
|
-
description: 'List every scene with its archive (what entering it switches on), bound prompt preset, memory count and enabled / entered / locked state. Read the archive here before saving a scene: a section you give later replaces it whole. Read-only; the scene in effect now is also the「本机当前的场景」reminder.',
|
|
127
|
+
description: 'List every scene with its archive (what entering it switches on: MCP servers, skills, personas, and the bound model tool-table scheme when there is one), bound prompt preset, memory count and enabled / entered / locked state. Read the archive here before saving a scene: a section you give later replaces it whole. Read-only; the scene in effect now is also the「本机当前的场景」reminder.',
|
|
111
128
|
parameters: {},
|
|
112
129
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
113
130
|
async execute() {
|
|
@@ -166,7 +183,7 @@ export function buildSceneTools(deps) {
|
|
|
166
183
|
// 为什么 `action` 是必填枚举而不是一个布尔:与 `mcp_manager_switch` 同一条理由 ——
|
|
167
184
|
// 让模型在 enter / exit 两个动词里选一个,比让它猜"scene 传 null 是什么意思"省一次往返。
|
|
168
185
|
// 参数里刻意不出现 `null`:那是 op(`scene-mode-set`)的形态,模型照着填只会填错。
|
|
169
|
-
description: 'Enter a scene, or leave the one currently entered. Entering switches on exactly what its archive lists and everything else off, narrows injection to its memories, and applies its bound prompt preset to AGENTS.md. Leaving restores the runtime snapshot taken on entry. Check the scene\'s archive in the scene listing before entering. Only when the user asks or approves.',
|
|
186
|
+
description: 'Enter a scene, or leave the one currently entered. Entering switches on exactly what its archive lists and everything else off — MCP servers, skills, personas, and the model tool table when the scene binds a scheme — narrows injection to its memories, and applies its bound prompt preset to AGENTS.md. Leaving restores the runtime snapshot taken on entry. Check the scene\'s archive in the scene listing before entering. Only when the user asks or approves.',
|
|
170
187
|
parameters: {
|
|
171
188
|
action: { type: 'string', required: true, enum: ['enter', 'exit'], description: 'enter = switch to the scene named in `scene`; exit = leave the current one (no scene needed).' },
|
|
172
189
|
scene: { type: 'string', description: 'Scene name (one path segment), required for enter. Use the exact name, not the display label — the scene listing and the「本机当前的场景」reminder both print names.' },
|
|
@@ -213,26 +230,40 @@ export function buildSceneTools(deps) {
|
|
|
213
230
|
',但启用集合未写入(记忆与提示词仍按旧场景注入):' + ((act && act.error) || '未知错误') +
|
|
214
231
|
' —— 重试一次 scene_manager_switch 即可(两个 op 都幂等)');
|
|
215
232
|
}
|
|
216
|
-
// 回执:说清"从哪到哪 + 实际切了几台 / 几个来源 + 有没有键被丢弃 + AGENTS.md 那一步"。
|
|
233
|
+
// 回执:说清"从哪到哪 + 实际切了几台 / 几个来源 + 工具表换到哪一份 + 有没有键被丢弃 + AGENTS.md 那一步"。
|
|
217
234
|
// 都是界面会如实报的同一批事实(`memory.result.modeSet` / `modeSwitched` / `archive.stale`),
|
|
218
235
|
// 少报一项,模型就会以为那次切换"完全干净"。
|
|
219
|
-
|
|
236
|
+
// 「这次到底动没动」两个方向判的不是同一个值:进入看 `applied`(目标就是当前场景时引擎早退,
|
|
237
|
+
// `applied` 为 null),而**成功退出也回 `applied:null`** —— 拿它当判据会把每一次退出报成
|
|
238
|
+
// 「already in scene「」」。引擎只在真的退掉一个场景时回 `restored`,退出按它判。
|
|
239
|
+
const sw = (mode && mode.switched) || {};
|
|
240
|
+
const count = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
241
|
+
const noop = action === 'enter'
|
|
242
|
+
? (mode.applied === null || mode.applied === undefined)
|
|
243
|
+
: !mode.restored;
|
|
220
244
|
const head = noop
|
|
221
|
-
? 'OK: already in scene「' + scene + '」'
|
|
245
|
+
? (action === 'enter' ? 'OK: already in scene「' + scene + '」' : 'OK: not in any scene (nothing to leave)')
|
|
222
246
|
: action === 'enter'
|
|
223
247
|
? 'OK: ' + (before === null ? 'entered' : '「' + before + '」→') + ' scene「' + scene + '」'
|
|
224
|
-
: 'OK: left the scene' + (before
|
|
225
|
-
const sw = (mode && mode.switched) || {};
|
|
226
|
-
const count = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
248
|
+
: 'OK: left the scene「' + (before || '') + '」';
|
|
227
249
|
const parts = [];
|
|
228
250
|
if (count(sw.mcpServers) || count(sw.skillSources)) {
|
|
229
251
|
parts.push('switched ' + count(sw.mcpServers) + ' MCP server(s) / ' + count(sw.skillSources) + ' skill source(s)');
|
|
230
252
|
}
|
|
253
|
+
// 工具表这一档改的就是模型自己能用什么:不报,它只会发现自己少了(或多回)几条工具却说不出
|
|
254
|
+
// 原因,用户问"刚才改了什么"时拿回去的就是半截话。引擎只在**名单真的换了**时才回方案名。
|
|
255
|
+
const tt = toolTableLabel(mode && mode.toolTablePreset);
|
|
256
|
+
if (tt) {
|
|
257
|
+
parts.push('工具表切到方案「' + tt + '」(关 ' + count(sw.toolTableHidden) + ' 条)');
|
|
258
|
+
}
|
|
259
|
+
else if (action === 'exit' && mode.restored && mode.restored.toolTable === true) {
|
|
260
|
+
parts.push('工具表按进场景前的快照还原');
|
|
261
|
+
}
|
|
231
262
|
if (Array.isArray(mode.stale) && mode.stale.length) {
|
|
232
263
|
parts.push('丢弃了本机不存在的键:' + mode.stale.join('、'));
|
|
233
264
|
}
|
|
234
265
|
return head + (parts.length ? ' · ' + parts.join(' · ') : '') + agentsMdNote(act.agentsMd) +
|
|
235
|
-
(noop ? '\n
|
|
266
|
+
(noop ? '\n(' + (action === 'enter' ? '目标就是当前场景' : '本来就不在任何场景') + ',运行时没有改动)' : '');
|
|
236
267
|
},
|
|
237
268
|
}));
|
|
238
269
|
register(defineTool({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-tool-management",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "DSH plugin: one settings panel for MCP servers, skills, scenes, memories, subagent personas and AGENTS.md presets — a scene switches the whole environment, and the model can drive it from chat too.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,7 +60,8 @@
|
|
|
60
60
|
"client": {
|
|
61
61
|
"platform": "web",
|
|
62
62
|
"inject": [
|
|
63
|
-
"@deepseek-ai/dsh-client-locale"
|
|
63
|
+
"@deepseek-ai/dsh-client-locale",
|
|
64
|
+
"@deepseek-ai/dsh-client-ui-input-trigger"
|
|
64
65
|
]
|
|
65
66
|
}
|
|
66
67
|
},
|