dsh-plugin-tool-management 0.20.0 → 0.20.1
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 +47 -0
- package/README.md +3 -1
- package/README_EN.md +1 -1
- package/docs/images/logo.gif +0 -0
- package/docs/update.md +1 -0
- package/lib/client.js +1594 -364
- package/lib/compat/preset-reach.js +5 -2
- package/lib/context-inject-frame.js +13 -8
- package/lib/hub.js +8 -5
- package/lib/index.js +56 -20
- package/lib/mcp/manager.js +21 -0
- package/lib/mcp/state-section.js +1 -1
- package/lib/memories/archive-engine.js +4 -5
- package/lib/memories/constants.js +2 -2
- package/lib/op-registry.js +7 -5
- package/lib/ops/memory.js +42 -4
- package/lib/ops/scene-records.js +43 -9
- package/lib/ops/sessions.js +5 -39
- package/lib/ops/trash.js +45 -5
- package/lib/request-gate.js +9 -2
- package/lib/scene-prompt-sync.js +48 -23
- package/lib/sessions/workspace.js +0 -10
- package/lib/skills/catalog.js +17 -5
- package/lib/skills/core.js +121 -5
- package/lib/skills/frontmatter.js +31 -0
- package/lib/skills/manager-state.js +41 -2
- package/lib/skills/service.js +46 -12
- package/lib/skills/skill-roots.js +54 -4
- package/lib/skills/skill-trash.js +118 -12
- package/lib/skills/upload-pipeline.js +110 -26
- package/lib/subagents/catalog.js +9 -5
- package/lib/subagents/service.js +44 -6
- package/lib/subagents/tools.js +45 -15
- package/lib/tool-table-switch.js +6 -6
- package/lib/tools/deps.js +43 -0
- package/lib/tools/mcp.js +68 -27
- package/lib/tools/memory.js +49 -9
- package/lib/tools/prompt.js +26 -9
- package/lib/tools/remove.js +206 -0
- package/lib/tools/scene.js +18 -13
- package/lib/tools/skills.js +162 -55
- package/lib/tools/subagent.js +65 -31
- package/lib/tools/table.js +39 -12
- package/lib/trash-id.js +16 -0
- package/package.json +115 -114
package/lib/subagents/tools.js
CHANGED
|
@@ -15,11 +15,18 @@ export function text(v) {
|
|
|
15
15
|
* 绑定的人设启用,见 archive-engine 的 applySubagentSwitches)。
|
|
16
16
|
* ② 场景绑定校验(设计 §3.3):启用场景的 subagents 并集;空并集 = 全部可用。
|
|
17
17
|
* 之前只有②:全局默认所有子智能体都进上下文,没有开关可言。
|
|
18
|
+
*
|
|
19
|
+
* `includeUnavailable`(2026-10-14 对抗性审查 P1-7)**只给 `subagent_manager_list` 的
|
|
20
|
+
* `all=true` 用**,两道闸一起放开:停用的人设此前**在任何地方都查不到** —— 注入目录只列可用的,
|
|
21
|
+
* `subagent_manager_switch` 又默认关着,于是模型既发现不了它、也就无从提示用户去启用。
|
|
22
|
+
* 另外三个 `*_list`(MCP / 技能 / 记忆)早就有 `all=true` 这个出口,人设是缺的那一个。
|
|
23
|
+
* 注意放开的只是**可见性**:`subagent_manager_run` 仍然按完整两道闸判"能不能委派",
|
|
24
|
+
* 列出来 ≠ 可委派(回执里那句「人设不可用」照旧成立)。
|
|
18
25
|
*/
|
|
19
|
-
export async function filterBySceneBinding(docs, enabledSceneLists) {
|
|
20
|
-
const live = docs.filter((d) => d.enabled !== false);
|
|
26
|
+
export async function filterBySceneBinding(docs, enabledSceneLists, includeUnavailable = false) {
|
|
27
|
+
const live = includeUnavailable ? docs : docs.filter((d) => d.enabled !== false);
|
|
21
28
|
const bound = new Set(enabledSceneLists.flat());
|
|
22
|
-
if (!bound.size)
|
|
29
|
+
if (includeUnavailable || !bound.size)
|
|
23
30
|
return { allowed: live, reason: null };
|
|
24
31
|
const allowed = live.filter((d) => bound.has(d.name));
|
|
25
32
|
return { allowed, reason: allowed.length ? null : `当前启用场景的子智能体绑定: ${[...bound].join('、')}` };
|
|
@@ -27,19 +34,32 @@ export async function filterBySceneBinding(docs, enabledSceneLists) {
|
|
|
27
34
|
export function defineSubagentManagerListTool(subagents) {
|
|
28
35
|
return {
|
|
29
36
|
name: 'subagent_manager_list',
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
// `depth-filtered` 删掉(2026-10-15 注入文本审查):注入深度是**注入侧**的实现细节,
|
|
38
|
+
// 模型看到的是"目录里有哪些人设",`catalogDepth` 那个词对它没有任何可操作性。
|
|
39
|
+
description: 'List personas (pre-configured subagent profiles) with their descriptions. The「当前子智能体」reminder carries a view of the same catalog; call this for the always-current full list, or pass all=true to include personas that are disabled or outside the active scene.',
|
|
40
|
+
parameters: {
|
|
41
|
+
all: { type: 'boolean', description: 'Include disabled personas and ones outside the active scene (default false). Listed ≠ delegable: `run` still refuses those.' },
|
|
42
|
+
},
|
|
32
43
|
output: {
|
|
33
44
|
schema: { type: 'string' },
|
|
34
45
|
render: (_a, v) => text(String(v)),
|
|
35
46
|
},
|
|
36
|
-
async execute(
|
|
37
|
-
const
|
|
47
|
+
async execute(args, exec) {
|
|
48
|
+
const includeUnavailable = Boolean(args?.all === true);
|
|
49
|
+
const docs = await subagents.list();
|
|
50
|
+
const { allowed, reason } = await filterBySceneBinding(docs, await subagents.sceneLists(), includeUnavailable);
|
|
38
51
|
// 这里**不再按深度过滤**(2026-09-17 用户裁定,方案 A):`catalogDepth` 只决定常驻目录
|
|
39
52
|
// 注入到哪些会话,与"能不能委派"无关 —— 委派由官方决定(`dsh-tool-subagent` 默认能嵌套
|
|
40
53
|
// 到 3 层)。此前按深度过滤等于把可用人设藏起来:目录不注入时模型只能靠这个工具查,
|
|
41
54
|
// 而工具又不列全,结果是"明明能委派却查不到人设"。
|
|
42
|
-
|
|
55
|
+
// `all=true` 时**标出哪些不可用**:只列名字的话,模型照样分不清哪个能直接委派
|
|
56
|
+
// (与技能列表标 `shadowed-by:` 同一条理由 —— 列出来不等于能用)。
|
|
57
|
+
const lines = allowed.map((p) => '- ' + p.name + ' — ' + (p.description || '(无描述)') +
|
|
58
|
+
(p.continuable === true ? CONTINUABLE_MARK : '') +
|
|
59
|
+
(includeUnavailable ? (p.enabled === false ? '(已停用,需先启用)' : '') : ''));
|
|
60
|
+
// 默认口径下有多少个没列出来(停用的 + 不在启用场景里的):不报的话,模型看到一份短名单
|
|
61
|
+
// 会当成"本机只有这些人设"。数量照报,与另外三个 list 的表头同一口径。
|
|
62
|
+
const hiddenCount = includeUnavailable ? 0 : docs.length - allowed.length;
|
|
43
63
|
let notice = '';
|
|
44
64
|
try {
|
|
45
65
|
notice = typeof subagents.noticeFor === 'function' ? await subagents.noticeFor(exec) : '';
|
|
@@ -47,7 +67,9 @@ export function defineSubagentManagerListTool(subagents) {
|
|
|
47
67
|
catch {
|
|
48
68
|
notice = '';
|
|
49
69
|
}
|
|
50
|
-
|
|
70
|
+
const header = '人设子智能体(' + allowed.length + ')' +
|
|
71
|
+
(hiddenCount > 0 ? '(另有 ' + hiddenCount + ' 个停用或不在当前场景,传 all=true 查看)' : '') + ':\n';
|
|
72
|
+
return header + (lines.join('\n') || '(无)') + (reason ? '\n注意:' + reason : '') + notice;
|
|
51
73
|
},
|
|
52
74
|
};
|
|
53
75
|
}
|
|
@@ -69,8 +91,13 @@ export function defineSubagentManagerRunTool(subagents) {
|
|
|
69
91
|
// `how` 行(context-inject.ts 的 `DOMAIN_FRAME.subagents`)已经逐字说过一遍,而两份都在
|
|
70
92
|
// 每轮上下文里 = 同一件事付两次 token。留注入那份(它出现在"正在选工具"的那一刻)。
|
|
71
93
|
// 代价如实记下:子智能体域被关掉、或走压制型预设时上下文里没有那条 how 行,模型只剩本
|
|
72
|
-
// 描述与 `agent`
|
|
73
|
-
//
|
|
94
|
+
// 描述与 `agent` 参数说明 —— 够它认出这条是带人设的委派通道,但"没有人设贴合时才用官方
|
|
95
|
+
// 那两个"这层分界就没人说了。
|
|
96
|
+
//
|
|
97
|
+
// 2026-10-14(对抗性审查 P0-2):`subagent_manager_list` 已移出出厂默认隐藏名单,所以
|
|
98
|
+
// `agent` 参数说明可以点名它(此前只能写"Persona name.",因为那条工具默认不在模型手里)。
|
|
99
|
+
// 注释里那句 "Persona name from subagent_manager_list" 从 2026-09-23 起就与代码逐字不符,
|
|
100
|
+
// 现在代码终于跟上了。
|
|
74
101
|
//
|
|
75
102
|
// 2026-09-23(用户裁定):本工具的 `task` / `inherit` 两个参数说明与上面的 Modes 段
|
|
76
103
|
// **逐字重复**,而工具表每轮都发 —— 同一件事付两次 token。实测 `subagent_manager_run`
|
|
@@ -78,11 +105,14 @@ export function defineSubagentManagerRunTool(subagents) {
|
|
|
78
105
|
// 最贵的单个参数。收完两处重复后目标 ≈325 tok。判据是"删掉的那句在描述里已经有了吗":
|
|
79
106
|
// Modes 段留着(它是模型的入口),参数说明只留"这一段独有的信息"。
|
|
80
107
|
// `inherit` 那句"轮中委派拿不到当前轮"必须留 —— 它是这条参数唯一会让人写错 task 的地方。
|
|
81
|
-
description: 'Run a named persona as a subagent: it gets the persona as its own system prompt, works on `task`, and returns only its final output. A persona marked「允许追问」instead returns an agent id and runs in the background — continue it with `send_message`.\n\nModes: by default a fresh child that cannot see this conversation, so `task` must be self-contained. With `inherit: true` it also gets this conversation\'s **finished** turns (like the host\'s `subagent_fork`) — the current turn is never included, so a mid-turn hand-off still needs a self-contained `task`.\n\n`task` = the goal plus the context it needs; leave method and output format to the persona.\n\nUse it when the work matches a persona in the
|
|
108
|
+
description: 'Run a named persona as a subagent: it gets the persona as its own system prompt, works on `task`, and returns only its final output. A persona marked「允许追问」instead returns an agent id and runs in the background — continue it with `send_message`.\n\nModes: by default a fresh child that cannot see this conversation, so `task` must be self-contained. With `inherit: true` it also gets this conversation\'s **finished** turns (like the host\'s `subagent_fork`) — the current turn is never included, so a mid-turn hand-off still needs a self-contained `task`.\n\n`task` = the goal plus the context it needs; leave method and output format to the persona.\n\nUse it when the work matches a persona in the「当前子智能体」reminder (a review, an investigation, a piece of writing) and the detail should not sit in your own context. Not for reading a file (Read), finding a definition (Grep/Glob), or touching two or three files.',
|
|
82
109
|
parameters: {
|
|
83
|
-
agent: { type: 'string', required: true, description: 'Persona name.' },
|
|
84
|
-
|
|
85
|
-
|
|
110
|
+
agent: { type: 'string', required: true, description: 'Persona name — see the「当前子智能体」reminder, or list personas with subagent_manager_list.' },
|
|
111
|
+
// `task` 的说明只留"这是什么"(2026-10-15 注入文本审查):上面描述里那句
|
|
112
|
+
// "leave method and output format to the persona." 此前在这里**逐字重复**了一遍
|
|
113
|
+
// (57 字符),而模型填这个参数时两处都在手上 —— 同一件事付两次 token。
|
|
114
|
+
task: { type: 'string', required: true, description: 'The task for the subagent.' },
|
|
115
|
+
inherit: { type: 'boolean', description: 'Inherit this conversation\'s finished turns (current turn excluded). Default false.' },
|
|
86
116
|
},
|
|
87
117
|
output: {
|
|
88
118
|
schema: { type: 'string' },
|
package/lib/tool-table-switch.js
CHANGED
|
@@ -3,8 +3,8 @@ import { addPreset, buildToolTableReport, DEFAULT_HIDDEN_TOOLS, dropPreset, migr
|
|
|
3
3
|
export function createToolTableSwitch(deps) {
|
|
4
4
|
// ---------- 模型工具表开关(侧车 `tool-table.json`,界面在「兼容」页)----------
|
|
5
5
|
// 工具表按**每个请求**付钱:20 个工具的整份定义合计 ≈3,769 tok 每轮都在。关掉某几个,
|
|
6
|
-
// 它们整份不进请求(实测口径与取舍见 src/tools/table.ts
|
|
7
|
-
// (20
|
|
6
|
+
// 它们整份不进请求(实测口径与取舍见 src/tools/table.ts 的文件头)。**出厂就已经关了十四条**
|
|
7
|
+
// (20 条里实发 6 条)—— 名单、判据与代价在 `DEFAULT_HIDDEN_TOOLS`,只在这个人从没记过
|
|
8
8
|
// 选择时铺(见下面的 `toolTableSettingsFrom`)。
|
|
9
9
|
//
|
|
10
10
|
// 为什么放在目录之前:两个目录的「用 `X` 查」提示要跟着这份设置变(工具关掉后那句话
|
|
@@ -33,8 +33,8 @@ export function createToolTableSwitch(deps) {
|
|
|
33
33
|
// 0.14.0 旧工具名迁移:读侧翻译一次并**回写盘**(幂等)。不迁移的话,用户"关掉了某条"
|
|
34
34
|
// 的意图会在新名字上静默失效 —— 那条工具照旧每轮发出去,而界面上看不出来。
|
|
35
35
|
// 写失败不回滚本次读取:内存里已经是迁移后的值,下次读会再试一次。
|
|
36
|
-
// 侧车不存在(`readJsonFile` 给 null)= 用户从没记过选择 ⇒
|
|
37
|
-
// 实发
|
|
36
|
+
// 侧车不存在(`readJsonFile` 给 null)= 用户从没记过选择 ⇒ 用**出厂默认**(十四条不发,
|
|
37
|
+
// 实发 6 条;判据与代价见 tools/table.ts 的 DEFAULT_HIDDEN_TOOLS)。存过盘的
|
|
38
38
|
// 一律照盘上那份,包括显式的 `hidden: []` —— 那是"我全都要",不能被默认值盖掉。
|
|
39
39
|
const migrated = migrateLegacyToolNames(toolTableSettingsFrom(raw));
|
|
40
40
|
const value = migrated.settings;
|
|
@@ -59,7 +59,7 @@ export function createToolTableSwitch(deps) {
|
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
61
|
* 同步快照(热路径用:工具门禁每次调用、注入通道每个 step 都要问一句)。还没加载时先给
|
|
62
|
-
* **空集**并异步预热 ——
|
|
62
|
+
* **空集**并异步预热 —— 空集不是出厂默认(出厂关着十四条),是刻意的:预热前的这一瞬与
|
|
63
63
|
* 用户的选择可能不一致,但方向必须是安全的那一侧(不隐藏任何东西,绝不因为读盘慢而让
|
|
64
64
|
* 工具凭空消失)。
|
|
65
65
|
*/
|
|
@@ -71,7 +71,7 @@ export function createToolTableSwitch(deps) {
|
|
|
71
71
|
return toolTableCache.off;
|
|
72
72
|
}
|
|
73
73
|
const toolTableRows = () => [...toolTableSizes].map(([name, bytes]) => ({ name, bytes }));
|
|
74
|
-
const toolTableReport = (hidden = [...toolTableHidden()]) => buildToolTableReport(toolTableRows(), hidden);
|
|
74
|
+
const toolTableReport = (hidden = [...toolTableHidden()]) => buildToolTableReport(toolTableRows(), hidden, deps.carrierHiddenTools ? deps.carrierHiddenTools() : []);
|
|
75
75
|
/** 落盘 + 同步内存缓存(三条写路径共用:改勾选 / 存方案 / 删方案)。 */
|
|
76
76
|
async function writeToolTableSettings(next) {
|
|
77
77
|
await deps.ensurePaths();
|
package/lib/tools/deps.js
CHANGED
|
@@ -21,3 +21,46 @@ export function toolNameList(list) {
|
|
|
21
21
|
const head = list.slice(0, TOOL_NAME_LIST_CAP).join('、');
|
|
22
22
|
return list.length > TOOL_NAME_LIST_CAP ? head + ' 等 ' + list.length + ' 项' : head;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* 按名字找技能落在哪个来源(root)。
|
|
26
|
+
*
|
|
27
|
+
* 为什么需要它(2026-10-15 smoke-test P0-1):`skill-create` 的默认落点是 **hub**
|
|
28
|
+
* (`tool-management/skills/`,与界面「创建技能」一致),而 `skill-enable` / `skill-disable` /
|
|
29
|
+
* `skill-prefer` / `skill-delete` 这些 op 的默认 root 是 **dsh**。于是「建完就启用」这条
|
|
30
|
+
* 最自然的链路必然报「技能不存在」—— 同一个动作在工具侧落 hub、在开关侧查 dsh。
|
|
31
|
+
* 默认值改哪边都不对(面板显式传 root,两边都依赖自己的默认),所以判定放到工具侧:
|
|
32
|
+
* **没给 root 就在全部来源里按名字找胜出副本**,找到哪个就用哪个。
|
|
33
|
+
*
|
|
34
|
+
* 选取口径与 `skill_manager_read` 同一套(`markWinners` 已标出影子副本):先取没被
|
|
35
|
+
* `shadowedBy` 盖住的那一份(同名组里胜出的只有一个),都没有就取第一个。
|
|
36
|
+
*/
|
|
37
|
+
export async function locateSkillRoot(skillsOps, name) {
|
|
38
|
+
const wanted = String(name || '').trim();
|
|
39
|
+
if (wanted === '')
|
|
40
|
+
return { ok: true, root: null, roots: [] };
|
|
41
|
+
let state;
|
|
42
|
+
try {
|
|
43
|
+
state = await skillsOps['skill-state']({});
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return { ok: false, root: null, roots: [] };
|
|
47
|
+
}
|
|
48
|
+
if (!state || state.ok === false)
|
|
49
|
+
return { ok: false, root: null, roots: [] };
|
|
50
|
+
const roots = [];
|
|
51
|
+
const hits = [];
|
|
52
|
+
for (const root of (state.data && state.data.roots) || []) {
|
|
53
|
+
roots.push(String(root.key || ''));
|
|
54
|
+
for (const skill of root.skills || []) {
|
|
55
|
+
// 与 list / read / save 同一套名字口径:`declaredName || name`,再比目录名。
|
|
56
|
+
if (String(skill.declaredName || skill.name || '') !== wanted && String(skill.name || '') !== wanted)
|
|
57
|
+
continue;
|
|
58
|
+
hits.push({
|
|
59
|
+
rootKey: String(root.key || ''),
|
|
60
|
+
shadowedBy: skill.shadowedBy && skill.shadowedBy.root ? String(skill.shadowedBy.root) : '',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const winner = hits.find((hit) => hit.shadowedBy === '') || hits[0];
|
|
65
|
+
return { ok: true, root: winner ? winner.rootKey : null, roots };
|
|
66
|
+
}
|
package/lib/tools/mcp.js
CHANGED
|
@@ -27,13 +27,22 @@
|
|
|
27
27
|
// `resolveMaskedKv({}, 旧值)` 把整份 headers / env 静默清空、连 warning 都不给。
|
|
28
28
|
import { DEFAULT_MCP_NOTE_MAX_LENGTH, normalizeMcpNote } from '../mcp/state-section.js';
|
|
29
29
|
import { text } from './deps.js';
|
|
30
|
-
/** 「去看清单」那句:`mcp_manager_list`
|
|
30
|
+
/** 「去看清单」那句:`mcp_manager_list` 不在模型手里时不能点名它(调用方按 `toolVisibleFor` 决定给不给)。 */
|
|
31
31
|
const MCP_LIST_HINT = '(用 mcp_manager_list 看清单,all=true 连停用的也列)';
|
|
32
|
+
/**
|
|
33
|
+
* 指不到 `mcp_manager_list` 时的**中性出口**:不点名任何工具,两条常驻来源都成立
|
|
34
|
+
* —— 模型自己的 `mcp__<server>__<tool>` 工具名(就在它的 schema 里),以及注入段。
|
|
35
|
+
*
|
|
36
|
+
* 为什么必须有这一句(2026-10-14 对抗性审查 P0-1):`mcp_manager_switch` 是默认**唯一可见**
|
|
37
|
+
* 的 MCP 工具,猜错 serverName 时若报错里只有「没有这台服务器:X」,模型拿不到任何补救线索
|
|
38
|
+
* —— 它只能猜第二次。出口要么指向工具(那条工具得在它手里),要么指向这两条常驻来源。
|
|
39
|
+
*/
|
|
40
|
+
const MCP_NEUTRAL_HINT = '(服务器名照抄你自己 `mcp__<server>__<tool>` 工具名里的那一段,或「当前 MCP 服务器」段里列出的名字)';
|
|
32
41
|
/** 按 serverName 定位条目;同名(project + global 各一台)时要求 level 明确指定。 */
|
|
33
42
|
function locate(rows, server, level, listHint = MCP_LIST_HINT) {
|
|
34
43
|
const hits = (rows || []).filter((r) => String(r.serverName) === server);
|
|
35
44
|
if (!hits.length) {
|
|
36
|
-
throw new Error('没有这台服务器:' + server + listHint);
|
|
45
|
+
throw new Error('没有这台服务器:' + server + (listHint || MCP_NEUTRAL_HINT));
|
|
37
46
|
}
|
|
38
47
|
if (level) {
|
|
39
48
|
const picked = hits.filter((r) => r.level === level);
|
|
@@ -64,15 +73,27 @@ function argsToText(value) {
|
|
|
64
73
|
}
|
|
65
74
|
export function buildMcpTools(deps) {
|
|
66
75
|
const { defineTool, register } = deps;
|
|
67
|
-
/**
|
|
68
|
-
*
|
|
69
|
-
|
|
76
|
+
/**
|
|
77
|
+
* 那句"去看清单"要不要给:`mcp_manager_list` 在出厂默认里就是关着的,点名一条模型没有的工具
|
|
78
|
+
* 只会让它白跑一趟(执行侧拦住它,而它看不出为什么)。
|
|
79
|
+
*
|
|
80
|
+
* 判据按 **agent** 而不是全局开关(2026-10-14 对抗性审查 P0-1):全局关着、但这个人设没砍掉
|
|
81
|
+
* 它时,那句话是对的;反过来,全局开着、而这个人设用 `toolsDeny` 砍掉了它时,只读全局开关
|
|
82
|
+
* 就会让我们点名一条它手里没有的工具。与注入框架 `how` 行同一份判据(`toolVisibleFor`)。
|
|
83
|
+
*/
|
|
84
|
+
const listHint = (agent) => (deps.toolVisibleFor('mcp_manager_list', agent) ? MCP_LIST_HINT : '');
|
|
70
85
|
register(defineTool({
|
|
71
86
|
name: 'mcp_manager_list',
|
|
72
87
|
// 注入段与本工具的关系要**如实说成筛过的视图**(而非"同一份清单"):注入段只列可用 +
|
|
73
88
|
// 曾连上现掉线的服务器(停用的不进),也不带 level / loader / 工具数。把这个盲区写出来
|
|
74
89
|
// 是行动依据 —— 模型要知道"停用的那台去哪查"就得靠这句(all=true)。
|
|
75
|
-
|
|
90
|
+
//
|
|
91
|
+
// 2026-10-15 注入文本审查:删掉两处白付的 token ——
|
|
92
|
+
// ① 括号里那句把「当前 MCP 服务器」reminder 的**内容**逐项列了一遍(可用 / 曾连上 /
|
|
93
|
+
// 备注 / 停用不进),而那段 reminder 每轮就在模型上下文里,它自己看得到;
|
|
94
|
+
// ② `where loader:on:<phase> is the loader's current phase` —— 用自己解释自己
|
|
95
|
+
// (phase 就是 phase),零信息。字段名本身留在输出里,模型照抄即可。
|
|
96
|
+
description: 'List configured MCP servers (level project|global; enabled state; live loader status; tool count excluding switched-off tools; user hint). The「当前 MCP 服务器」reminder is a filtered view; this tool is the raw one — defaults to enabled servers, all=true for every configured one. Pass tools=true to list each server\'s tool names.',
|
|
76
97
|
parameters: {
|
|
77
98
|
all: { type: 'boolean', description: 'Include disabled servers (default false).' },
|
|
78
99
|
tools: { type: 'boolean', description: 'Also list each server\'s tool names, marking the switched-off ones (default false).' },
|
|
@@ -98,7 +119,7 @@ export function buildMcpTools(deps) {
|
|
|
98
119
|
// 工具数 = 可用数(停用表扣减后):段里给模型的是同一个口径,模型据此与
|
|
99
120
|
// 自己 schema 里的 `mcp__*` 工具对得上;被场景收窄 / 手动关掉的工具不算。
|
|
100
121
|
const usableTools = typeof x.enabledToolCount === 'number' ? x.enabledToolCount : x.toolCount;
|
|
101
|
-
lines.push(
|
|
122
|
+
lines.push('server:' + x.serverName + ' | level:' + x.level + ' | ' + (x.disabled ? 'disabled' : 'enabled') +
|
|
102
123
|
(x.live ? ' | loader:' + (x.live.enabled ? 'on' : 'off') + (x.live.phase ? ':' + x.live.phase : '') : '') +
|
|
103
124
|
(typeof usableTools === 'number' ? ' | tools:' + usableTools : '') +
|
|
104
125
|
(note ? ' | user-hint:' + note : ''));
|
|
@@ -132,15 +153,22 @@ export function buildMcpTools(deps) {
|
|
|
132
153
|
name: 'mcp_manager_switch',
|
|
133
154
|
// 为什么 action 是必填枚举而不是两个可选布尔:模型最容易犯的错是"想重启却只传了 server",
|
|
134
155
|
// 让它在三个动词里选一个,比让它猜两个布尔的组合省一次往返。
|
|
135
|
-
|
|
156
|
+
// `restart` 的含义只在 `action` 参数里说一次(2026-10-15 注入文本审查):描述里那句
|
|
157
|
+
// "(reconnects and re-syncs tools)" 与参数说明逐字重复,而模型读参数时两处都在手上。
|
|
158
|
+
description: 'Turn one configured MCP server on or off, or restart it. Pass `tool` to switch just that tool. Blocked while the active scene is locked — except restart, which is the recovery path. Only when the user asks or approves.',
|
|
136
159
|
parameters: {
|
|
137
|
-
|
|
160
|
+
// 出口必须指向**常驻**来源,不能指向 `mcp_manager_list`(2026-10-14 对抗性审查 P0-1):
|
|
161
|
+
// 那条工具在出厂默认里就是关着的,`listHint()` 在它不可见时返回空串,于是猜错名字时的
|
|
162
|
+
// 报错退化成「没有这台服务器:<你猜的>」—— 一个字都不告诉它还能去哪看。
|
|
163
|
+
// 指向模型自己的工具表(`mcp__<server>__<tool>` 就在它的 schema 里)与常驻注入段,
|
|
164
|
+
// 这两条**不需要任何工具可见**就成立。
|
|
165
|
+
server: { type: 'string', required: true, description: 'Server name, as it appears in your own `mcp__<server>__<tool>` tool names (the「当前 MCP 服务器」reminder lists the usable ones).' },
|
|
138
166
|
action: { type: 'string', required: true, enum: ['on', 'off', 'restart'], description: 'on = enable, off = disable, restart = reconnect and re-sync tools.' },
|
|
139
167
|
level: { type: 'string', description: 'project or global — only to disambiguate same-name servers.' },
|
|
140
168
|
tool: { type: 'string', description: 'One tool name on that server (no mcp__ prefix). Only for on/off.' },
|
|
141
169
|
},
|
|
142
170
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
143
|
-
async execute(args) {
|
|
171
|
+
async execute(args, exec) {
|
|
144
172
|
const action = String((args && args.action) || '');
|
|
145
173
|
if (action !== 'on' && action !== 'off' && action !== 'restart') {
|
|
146
174
|
throw new Error('action 只能是 on / off / restart(收到:' + (action || '(空)') + ')');
|
|
@@ -161,7 +189,7 @@ export function buildMcpTools(deps) {
|
|
|
161
189
|
if (!list || list.ok === false)
|
|
162
190
|
throw new Error((list && list.error) || '读取 MCP 配置失败');
|
|
163
191
|
const level = args && args.level !== undefined ? String(args.level) : undefined;
|
|
164
|
-
const row = locate(list.rows || [], server, level, listHint());
|
|
192
|
+
const row = locate(list.rows || [], server, level, listHint(exec && exec.agent));
|
|
165
193
|
const enabled = action === 'on';
|
|
166
194
|
if (tool !== '') {
|
|
167
195
|
const r = await deps.mcpmToolEnabled({ serverName: String(row.serverName), tool, enabled });
|
|
@@ -193,20 +221,24 @@ export function buildMcpTools(deps) {
|
|
|
193
221
|
}));
|
|
194
222
|
register(defineTool({
|
|
195
223
|
name: 'mcp_manager_save',
|
|
196
|
-
description: 'Add a new MCP server, or update the one with this name — the receipt says which. Omitted fields keep their current values, so never restate the whole server. Only on the user\'s
|
|
224
|
+
description: 'Add a new MCP server, or update the one with this name — the receipt says which. Omitted fields keep their current values, so never restate the whole server. Pass `hint` to add a usage hint or fallback note. Blocked while the active scene is locked. Only on the user\'s instruction.',
|
|
197
225
|
parameters: {
|
|
198
226
|
server: { type: 'string', required: true, description: 'Server name (1-32 chars, [A-Za-z0-9_-]) — the name the mcp__<server>__ tools use.' },
|
|
199
227
|
transport: { type: 'string', description: 'streamable-http or stdio. Required when creating; omit = keep.' },
|
|
200
228
|
url: { type: 'string', description: 'Server URL (streamable-http). Omit = keep.' },
|
|
201
229
|
command: { type: 'string', description: 'Executable to spawn (stdio). Omit = keep.' },
|
|
202
|
-
args: { type: 'string', description: 'Arguments, space separated (stdio). Omit = keep.' },
|
|
230
|
+
args: { type: 'string', description: 'Arguments, space or comma separated (stdio). Omit = keep.' },
|
|
203
231
|
headers: { type: 'string', description: 'Extra headers, key=value per line (streamable-http). Omit = keep — omitting is what preserves the real secrets.' },
|
|
204
232
|
env: { type: 'string', description: 'Extra env vars, key=value per line (stdio). Omit = keep.' },
|
|
205
233
|
level: { type: 'string', description: 'project or global (project when creating); only needed for same-name servers.' },
|
|
206
|
-
|
|
234
|
+
// 名字与注入段对齐(2026-10-14 对抗性审查 P1-2):注入段与工具输出都写 `user-hint`
|
|
235
|
+
// (对应中文那句「用户提示:」),参数名跟着叫 `hint`,一份数据三个面一个词。
|
|
236
|
+
// 「写什么」那半句已经由注入框架的 `how` 行逐字说过("选服务器之前先看一眼"),
|
|
237
|
+
// 这里只留参数独有的信息:长度上限与截断行为(2026-10-14 审查 P2-4)。
|
|
238
|
+
hint: { type: 'string', description: "Short hint for later (≤300 chars; longer is stored whole but truncated in listings). '' clears the existing hint; omit = keep." },
|
|
207
239
|
},
|
|
208
240
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
209
|
-
async execute(args) {
|
|
241
|
+
async execute(args, exec) {
|
|
210
242
|
const blocked = await deps.lockedSceneGuard();
|
|
211
243
|
if (blocked)
|
|
212
244
|
throw new Error(blocked);
|
|
@@ -214,8 +246,13 @@ export function buildMcpTools(deps) {
|
|
|
214
246
|
if (!server)
|
|
215
247
|
throw new Error('缺少参数:server(服务器名)');
|
|
216
248
|
const levelArg = args && args.level !== undefined ? String(args.level) : undefined;
|
|
217
|
-
|
|
218
|
-
|
|
249
|
+
// `hint` 的三态(2026-10-15 修 P1-5):省略 = 保持;`''` = **清空**;非空 = 设置。
|
|
250
|
+
// 此前用 `hintText.trim() !== ''` 判"要不要写备注",把"清空"与"省略"混成一件事 ——
|
|
251
|
+
// 模型传 `hint: ""` 想清掉已有备注时收到「没有要改的东西」,而 op 侧 `mcpm-note`
|
|
252
|
+
// 本来就支持空串清空(`manager.ts:431`)。判据改成"参数给没给",不是"值空不空"。
|
|
253
|
+
const hintGiven = Boolean(args && args.hint !== undefined);
|
|
254
|
+
const hintText = hintGiven ? String(args.hint) : '';
|
|
255
|
+
const clearsNote = hintGiven && hintText.trim() === '';
|
|
219
256
|
const list = await deps.mcpmListView();
|
|
220
257
|
if (!list || list.ok === false)
|
|
221
258
|
throw new Error((list && list.error) || '读取 MCP 配置失败');
|
|
@@ -250,15 +287,15 @@ export function buildMcpTools(deps) {
|
|
|
250
287
|
// 备注单独走 `mcpm-note`(`mcpm-add` 不认这个字段)。失败**不回滚**主写入:
|
|
251
288
|
// 服务器已经建好了,"备注没写上"是 warning,不是失败(与 0.13.0 界面同规格)。
|
|
252
289
|
let warn = '';
|
|
253
|
-
if (
|
|
254
|
-
const n = await deps.mcpmNote({ id: String(r.row.id), note:
|
|
290
|
+
if (hintGiven && !clearsNote) {
|
|
291
|
+
const n = await deps.mcpmNote({ id: String(r.row.id), note: hintText });
|
|
255
292
|
if (!n || n.ok === false)
|
|
256
293
|
warn = '\nWARN: 服务器已建好,但备注没写进去(' + ((n && n.error) || '未知原因') + ')';
|
|
257
294
|
}
|
|
258
295
|
return 'OK: created ' + r.row.id + ' at ' + r.row.level + warn;
|
|
259
296
|
}
|
|
260
297
|
// ── 改:省略 = 保持,且必须**显式填回**(见文件头那条 parseKv 的坑)────────
|
|
261
|
-
const cur = locate(hits, server, levelArg, listHint());
|
|
298
|
+
const cur = locate(hits, server, levelArg, listHint(exec && exec.agent));
|
|
262
299
|
const transport = args && args.transport !== undefined ? String(args.transport) : String(cur.transport);
|
|
263
300
|
if (transport !== 'stdio' && transport !== 'streamable-http') {
|
|
264
301
|
throw new Error('transport 只能是 stdio 或 streamable-http(这台现在是:' + String(cur.transport) + ')');
|
|
@@ -327,26 +364,30 @@ export function buildMcpTools(deps) {
|
|
|
327
364
|
// 只给了备注:不必走 `mcpm-edit` 把整条重建一遍(那是一次无意义的写盘 + 刷新 updatedAt),
|
|
328
365
|
// 直接写备注。这与"三个可改字段一个都没给就拒"是同一条理由的两面。
|
|
329
366
|
if (!touched.length && !levelChanged) {
|
|
330
|
-
if (!
|
|
331
|
-
throw new Error('没有要改的东西:transport / url / command / args / headers / env / level /
|
|
332
|
-
const n = await deps.mcpmNote({ id: cur.id, note:
|
|
367
|
+
if (!hintGiven)
|
|
368
|
+
throw new Error('没有要改的东西:transport / url / command / args / headers / env / level / hint 至少给一个');
|
|
369
|
+
const n = await deps.mcpmNote({ id: cur.id, note: hintText });
|
|
333
370
|
if (!n || n.ok === false)
|
|
334
371
|
throw new Error((n && n.error) || '写备注失败');
|
|
335
|
-
return 'OK: updated ' + String(cur.serverName) + '
|
|
372
|
+
return 'OK: updated ' + String(cur.serverName) + '(' + (clearsNote ? 'hint 已清空' : '改了 hint') + ')';
|
|
336
373
|
}
|
|
337
374
|
const r = await deps.mcpmEdit(payload);
|
|
338
375
|
if (!r || r.ok === false)
|
|
339
376
|
throw new Error((r && r.error) || '修改服务器失败');
|
|
340
|
-
|
|
377
|
+
// `hint` 进 `changed` 必须**等写成功之后**(2026-10-15 修 P1-6):此前按意图先算进去,
|
|
378
|
+
// mcpmNote 失败时回执一边说"改了 hint"、一边 WARN"备注没写进去",模型读不出到底改没改。
|
|
379
|
+
const changed = touched.concat(levelChanged ? ['level'] : []);
|
|
341
380
|
let warn = '';
|
|
342
381
|
// `mcpm-edit` 的守卫会把"打码值被顶替回真值"这件事回报出来(只含键名,不含值)——
|
|
343
382
|
// 转给模型,它才知道"我没重述的密钥确实保住了"。
|
|
344
383
|
if (r.warning)
|
|
345
384
|
warn += '\nNOTE: ' + String(r.warning);
|
|
346
|
-
if (
|
|
347
|
-
const n = await deps.mcpmNote({ id: cur.id, note:
|
|
385
|
+
if (hintGiven) {
|
|
386
|
+
const n = await deps.mcpmNote({ id: cur.id, note: hintText });
|
|
348
387
|
if (!n || n.ok === false)
|
|
349
388
|
warn += '\nWARN: 服务器已保存,但备注没写进去(' + ((n && n.error) || '未知原因') + ')';
|
|
389
|
+
else
|
|
390
|
+
changed.push(clearsNote ? 'hint(已清空)' : 'hint');
|
|
350
391
|
}
|
|
351
392
|
if (ignored.length) {
|
|
352
393
|
warn += '\nWARN: ' + ignored.join(' / ') + ' 与当前 transport(' + transport + ')无关,已忽略';
|
package/lib/tools/memory.js
CHANGED
|
@@ -26,15 +26,23 @@ export function buildMemoryTools(deps) {
|
|
|
26
26
|
name: 'memory_manager_list',
|
|
27
27
|
// 末尾**不点名** `scene_manager_list`:场景一族在出厂默认里就是关着的,指向一条模型没有的
|
|
28
28
|
// 工具只会让它去猜名字(用户 2026-09-23 把默认名单扩到十五条时去掉的这句)。
|
|
29
|
-
description: 'List memories (id, scene, enabled, description) plus the scene injection currently follows. What is injected each turn is in the
|
|
29
|
+
description: 'List memories (id, scene, enabled, form flat|bundle, description) plus the scene injection currently follows. What is injected each turn is in the「当前记忆」reminder; use this for ids and for entries that are off. Pass a `scene` to filter by scene; all=true lists everything.',
|
|
30
30
|
parameters: {
|
|
31
|
-
|
|
31
|
+
// 参数名与其余五个域同词(2026-10-14 对抗性审查 P1-3):同一个概念在工具面只该有一个名字,
|
|
32
|
+
// 而 `scene` 是它在 `scene_manager_*`、`<scene>/<name>` 与注入段标题里的叫法。
|
|
33
|
+
// op 的内部契约字段仍是 `group`(`rules-list` 与界面侧调用方都读它),所以 execute 里
|
|
34
|
+
// 显式映射一次 —— 改 op 的字段名要动界面,那不是这一条能顺手做的事。
|
|
35
|
+
scene: { type: 'string', description: 'Optional scene filter.' },
|
|
32
36
|
all: { type: 'boolean', description: 'Include memories that are off, in an inactive scene, or shadowed (default false).' },
|
|
33
37
|
},
|
|
34
38
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
35
39
|
async execute(args, exec) {
|
|
36
40
|
const showAll = Boolean(args && args.all === true);
|
|
37
|
-
|
|
41
|
+
// `scene` → `group`:只把 op 认的那个字段传过去,别把 `scene` 也塞给它(它不认)。
|
|
42
|
+
const opArgs = {};
|
|
43
|
+
if (args && args.scene !== undefined)
|
|
44
|
+
opArgs.group = args.scene;
|
|
45
|
+
const r = await deps.rulesOps['rules-list'](opArgs);
|
|
38
46
|
if (!r || r.ok === false)
|
|
39
47
|
throw new Error((r && r.error) || '读取规则失败');
|
|
40
48
|
// 「会注入」的判定与记忆段同源(renderSceneMemory):global 场景恒常注入,其余场景要
|
|
@@ -54,6 +62,11 @@ export function buildMemoryTools(deps) {
|
|
|
54
62
|
const rows = (r.rules || []).map((x) => ({ x, ...stateOf(x) }));
|
|
55
63
|
const shown = showAll ? rows : rows.filter((row) => row.injects);
|
|
56
64
|
const lines = shown.map(({ x, label }) => ('- ' + x.id + ' [' + (x.group || '未归属场景') + '] ' + label +
|
|
65
|
+
// 形态与附件(2026-10-15):此前不报,模型分不清哪条是 bundle、更不知道它带了几个附件
|
|
66
|
+
// (`rules-list` 早就把 `form` 与 bundle 的附件摘要给了出来,只是这里没渲染)。
|
|
67
|
+
(x.form === 'bundle'
|
|
68
|
+
? ' · bundle' + (x.attach && x.attach.count ? '(' + x.attach.count + ' 个附件)' : '')
|
|
69
|
+
: '') +
|
|
57
70
|
(x.description ? ' — ' + x.description : '')));
|
|
58
71
|
// 注入按哪个场景走 —— 上面那句「场景未启用」只有配上它才可解读:少了这一行,
|
|
59
72
|
// 模型看到一片"未启用"会以为是条目自己的问题。(场景本身的清单在 `scene_manager_list`,
|
|
@@ -74,7 +87,10 @@ export function buildMemoryTools(deps) {
|
|
|
74
87
|
}));
|
|
75
88
|
register(defineTool({
|
|
76
89
|
name: 'memory_manager_read',
|
|
77
|
-
|
|
90
|
+
// "dropped by the byte budget" 改成"left out of it"(2026-10-15 注入文本审查):byte budget
|
|
91
|
+
// 是本插件内部术语,模型没有这个概念 —— 它只知道"这条在不在我上下文里"。说内部机制
|
|
92
|
+
// 等于让它去猜一个它观测不到的量。
|
|
93
|
+
description: 'Read the full body of one memory, plus its form (flat|bundle) and the names of its attachments. Only for ones not already in your context (disabled, in an inactive scene, or left out of it). Attachment contents are not returned — read those with your file tools.',
|
|
78
94
|
parameters: {
|
|
79
95
|
memory: { type: 'string', required: true, description: 'Memory id like <scene>/<name>.' },
|
|
80
96
|
},
|
|
@@ -84,7 +100,16 @@ export function buildMemoryTools(deps) {
|
|
|
84
100
|
const r = await deps.rulesOps['rules-read']({ id: String((args && args.memory) || '') });
|
|
85
101
|
if (!r || r.ok === false)
|
|
86
102
|
throw new Error((r && r.error) || '读取规则失败');
|
|
87
|
-
|
|
103
|
+
const rule = r.rule || {};
|
|
104
|
+
// 形态与附件名(2026-10-15):op 一直把 `attachments` 给了出来,这里此前**整个丢掉**,
|
|
105
|
+
// 于是模型连"这条记忆是 bundle、带了 3 个附件"都看不到。只给名字不给内容 ——
|
|
106
|
+
// 附件可能是图片或大 md,全文塞回来会吃光这一轮的上下文(与注入段附件行同口径)。
|
|
107
|
+
const attach = Array.isArray(rule.attachments) ? rule.attachments : [];
|
|
108
|
+
const form = rule.form === 'bundle' ? 'bundle' : 'flat';
|
|
109
|
+
const attLine = attach.length
|
|
110
|
+
? '\n附件(' + attach.length + ' 个):' + attach.map((a) => String((a && a.name) || a)).join('、')
|
|
111
|
+
: '';
|
|
112
|
+
return '# ' + rule.id + '(' + form + ')' + attLine + '\n\n' + (rule.body || '');
|
|
88
113
|
},
|
|
89
114
|
}));
|
|
90
115
|
register(defineTool({
|
|
@@ -102,7 +127,9 @@ export function buildMemoryTools(deps) {
|
|
|
102
127
|
// 三个写侧工具共用一句 consent(`Only when the user asks or approves.` / `Only on the
|
|
103
128
|
// user's instruction.`):这些动的都是"以后每一轮都会带上"的东西,模型不该顺手做。
|
|
104
129
|
// 描述同时按"省 token"重写了一遍 —— 这几段文字每次请求都要发出去,废话是常驻成本。
|
|
105
|
-
|
|
130
|
+
// 后半句"freeing the byte budget"删掉(2026-10-15 注入文本审查):byte budget 是本插件
|
|
131
|
+
// 内部术语,模型据此做不了任何判断 —— "不再被注入"已经是它需要的全部因果。
|
|
132
|
+
description: 'Enable or disable one memory. Disabled memories stop being injected. Reversible. Only when the user asks or approves.',
|
|
106
133
|
parameters: {
|
|
107
134
|
memory: { type: 'string', required: true, description: 'Memory id like <scene>/<name>.' },
|
|
108
135
|
enabled: { type: 'boolean', required: true, description: 'true = enable, false = disable; omission is refused.' },
|
|
@@ -145,12 +172,15 @@ export function buildMemoryTools(deps) {
|
|
|
145
172
|
// (场景段 128 KiB),而模型很乐意把「这个项目用 pnpm」记一条 —— 那看一眼 `package.json`
|
|
146
173
|
// 就知道,记下来却永久占着每一轮请求。合并 write/update 后这两句更要留:upsert 降低了
|
|
147
174
|
// "再记一条"的摩擦,而摩擦正是这里唯一拦得住它的东西。
|
|
148
|
-
description: 'Create a memory, or update the one that already has this id — the receipt says which happened. Memories cost context on EVERY later request, so record only what cannot be re-derived from code, config or git, and check the list for one that already says this — update that instead of adding a near-duplicate. Only on the user\'s instruction.',
|
|
175
|
+
description: 'Create a memory, or update the one that already has this id — the receipt says which happened. Memories cost context on EVERY later request, so record only what cannot be re-derived from code, config or git, and check the list for one that already says this — update that instead of adding a near-duplicate. Blocked while the active scene is locked. Only on the user\'s instruction.',
|
|
149
176
|
parameters: {
|
|
150
177
|
memory: { type: 'string', required: true, description: 'Memory id like <scene>/<name> — copy it from the list. `global` is the always-on scene.' },
|
|
151
178
|
description: { type: 'string', description: 'One-sentence description (<=500 chars). Required when creating. Empty string = drop it and re-derive from the body; omit = keep.' },
|
|
152
179
|
body: { type: 'string', description: 'Markdown body (<=256 KiB). Required when creating. Omit to keep the current body.' },
|
|
153
|
-
|
|
180
|
+
// 不再承诺「旧文件进回收站」(2026-10-14 对抗性审查 P0-3):bundle 记忆改名走的是
|
|
181
|
+
// 「只重写正文、目录不动」那条路,一个字都不进回收站 —— 对 bundle 说进回收站是假话。
|
|
182
|
+
// 与上一轮 P1-1 同口径:**只说可逆,不说实现细节**。
|
|
183
|
+
nextName: { type: 'string', description: 'New memory name (= file name); omit to keep it.' },
|
|
154
184
|
},
|
|
155
185
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
156
186
|
async execute(args) {
|
|
@@ -232,7 +262,17 @@ export function buildMemoryTools(deps) {
|
|
|
232
262
|
const warn = dup.length
|
|
233
263
|
? `\n注意:已有 ${dup.length} 条名字或描述与它很像(${dup.join('、')})—— 若是同一件事,改那一条而不是留着两条。`
|
|
234
264
|
: '';
|
|
235
|
-
|
|
265
|
+
// 回执必须同时说清**两个**条件(2026-10-15 smoke-test P1-3):新建的记忆在 op 层是
|
|
266
|
+
// `enabled: false`(ops/memory.ts 的 rulesCreate),而原先这句只提场景 —— 读起来像
|
|
267
|
+
// "场景一开它就自动生效",模型据此以为已经生效,实际还差一次开关。与 skill_manager_save
|
|
268
|
+
// 的 enableHint 同一形状:点名的工具在出厂默认名单里是关着的,关着就改说"用户开启后"。
|
|
269
|
+
const sceneActive = group === 'global' ||
|
|
270
|
+
(group !== '' && (all.activeMode === 'all' || String(all.activeScene || '') === group));
|
|
271
|
+
const sceneTail = sceneActive ? '' : ',并启用场景「' + group + '」';
|
|
272
|
+
const enableHint = deps.toolVisible('memory_manager_switch')
|
|
273
|
+
? '(新建默认停用,要生效先 memory_manager_switch 开它' + sceneTail + ')'
|
|
274
|
+
: '(新建默认停用,用户开启后才能生效' + sceneTail + ')';
|
|
275
|
+
return 'OK: created ' + r.rule.id + enableHint + warn;
|
|
236
276
|
},
|
|
237
277
|
}));
|
|
238
278
|
}
|