dsh-plugin-tool-management 0.19.1 → 0.20.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 +868 -829
- package/README.md +235 -254
- package/README_EN.md +234 -223
- 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 +31 -30
- package/lib/client.js +8154 -6874
- package/lib/hub.js +96 -6
- package/lib/index.js +16 -4
- package/lib/mcp/manager.js +24 -13
- package/lib/mcp/secret-guard.js +2 -2
- package/lib/memories/constants.js +9 -2
- package/lib/memories/service.js +6 -1
- package/lib/op-registry.js +6 -6
- package/lib/ops/sessions.js +62 -7
- package/lib/ops/trash.js +62 -3
- package/lib/sessions/history.js +32 -22
- package/lib/skills/core.js +68 -2
- package/lib/skills/manager-state.js +32 -1
- package/lib/skills/service.js +7 -1
- package/lib/tools/mcp.js +6 -0
- package/lib/tools/prompt.js +14 -2
- package/lib/tools/scene.js +142 -24
- package/lib/trash-retention.js +3 -3
- package/package.json +1 -1
- package/lib/mcp/inspect-probe.js +0 -199
package/lib/skills/core.js
CHANGED
|
@@ -124,7 +124,15 @@ export async function addCustomRoot(inputPath, label, log) {
|
|
|
124
124
|
if (current.writable === false)
|
|
125
125
|
return invalidManagerStateWrite();
|
|
126
126
|
const existing = customRootsFromState(current.state);
|
|
127
|
+
// 「永久删除」过的来源不再参与重叠判定:用户在回收站里把它彻底删掉之后,要能把同一个
|
|
128
|
+
// 目录**作为自定义来源重新加回来**(`confirm.source.forget.desc` 那句「需要时只能重新
|
|
129
|
+
// 添加这个目录」说的就是这条路)。不放过它的话,那句提示就是一句空话 —— 路径仍被
|
|
130
|
+
// `userRoots()` 认领,添加只会吃一个「目录与已有技能来源重叠」。
|
|
131
|
+
const forgotten = new Set(Array.isArray(current.state.forgottenSources)
|
|
132
|
+
? current.state.forgottenSources
|
|
133
|
+
: []);
|
|
127
134
|
for (const known of userRoots()
|
|
135
|
+
.filter((root) => !forgotten.has(root.key))
|
|
128
136
|
.map((root) => root.path)
|
|
129
137
|
.concat(existing.map((root) => root.path))) {
|
|
130
138
|
if (await pathsOverlap(canonical, known))
|
|
@@ -189,6 +197,59 @@ export async function removeCustomRoot(key, log) {
|
|
|
189
197
|
log("custom-remove", `移除自定义技能目录 ${target.path} (${clean})`);
|
|
190
198
|
return { key: clean, path: target.path };
|
|
191
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* 永久删除一条**按约定发现**的来源记录(回收站 → 目录 里那颗「永久删除」)。
|
|
202
|
+
*
|
|
203
|
+
* 与 `removeCustomRoot` 的分工:那条管的是"用户手加的自定义目录",删掉 `customRoots` 里
|
|
204
|
+
* 的记录就完事;这条管的是 `agents` / `codex` / `claude` 这类**由 `userRoots()` 按约定推导**
|
|
205
|
+
* 出来的来源 —— 它们不在状态文件的 customRoots 里,路径每次启动都会重新算出来,所以只把 key
|
|
206
|
+
* 从 `removedSources` 里摘掉的话,下一轮它又原样回来了。
|
|
207
|
+
*
|
|
208
|
+
* 于是另记一份 `forgottenSources`:进了这份名单的来源既不读取(`activeUserRoots` 摘掉它)、
|
|
209
|
+
* 也不列出(`state()` 的 roots 过滤掉它)—— 等于从插件里彻底消失。用户想反悔有两条路:
|
|
210
|
+
* 清掉状态文件里这个 key,或者把它**作为自定义来源重新添加**(`addCustomRoot` 的重叠判定
|
|
211
|
+
* 已经跳过已忘记的来源,所以那条路真的走得通)。
|
|
212
|
+
*
|
|
213
|
+
* **磁盘上的源文件一个字节都不动** —— 这条函数自始至终只写状态文件。
|
|
214
|
+
*/
|
|
215
|
+
export async function forgetSource(key, log) {
|
|
216
|
+
const clean = String(key == null ? "" : key).trim();
|
|
217
|
+
const root = userRoots().find((item) => item.key === clean);
|
|
218
|
+
if (!root)
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
error: `未知的来源: ${clean}`,
|
|
222
|
+
code: "error.root.unknown",
|
|
223
|
+
params: { root: clean },
|
|
224
|
+
};
|
|
225
|
+
// 默认来源(dsh / hub / 官方)必须读取:它们不在移除候选里,也不该能被永久删除。
|
|
226
|
+
if (isDefaultSkillSource(clean))
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
error: `默认来源不能永久删除: ${clean}`,
|
|
230
|
+
code: "error.root.default",
|
|
231
|
+
params: { root: clean },
|
|
232
|
+
};
|
|
233
|
+
const current = await readManagerState();
|
|
234
|
+
if (current.writable === false)
|
|
235
|
+
return invalidManagerStateWrite();
|
|
236
|
+
const forgotten = new Set(Array.isArray(current.state.forgottenSources)
|
|
237
|
+
? current.state.forgottenSources
|
|
238
|
+
: []);
|
|
239
|
+
forgotten.add(clean);
|
|
240
|
+
current.state.forgottenSources = [...forgotten].sort();
|
|
241
|
+
// 「已移除」那条记录一起摘掉:留着它只会在状态文件里多一条指向"已经不存在"的脏键,
|
|
242
|
+
// 而且归一化那一步本来也会把它去掉 —— 没理由写进去(与 removeCustomRoot 同一口径)。
|
|
243
|
+
if (Array.isArray(current.state.removedSources))
|
|
244
|
+
current.state.removedSources = current.state.removedSources.filter((item) => item !== clean);
|
|
245
|
+
delete current.state.sources[clean];
|
|
246
|
+
delete current.state.disabledSkills[clean];
|
|
247
|
+
delete current.state.enabledSkills[clean];
|
|
248
|
+
await writeManagerState(current.state);
|
|
249
|
+
if (log)
|
|
250
|
+
log("source-forget", `永久删除来源记录 ${clean}: ${root.path}`);
|
|
251
|
+
return { root: clean, path: root.path };
|
|
252
|
+
}
|
|
192
253
|
/**
|
|
193
254
|
* 从活动 Session cwd 推导项目技能根。只接受宿主上可解析的绝对目录,
|
|
194
255
|
* 同一项目的多个 Session 会折叠到同一组稳定 key,避免跨 workspace 合并同名技能。
|
|
@@ -434,17 +495,22 @@ async function visibleEntryForRoot(root, name) {
|
|
|
434
495
|
*/
|
|
435
496
|
/** 状态文件已存在但不可用时一律关闭外部来源,避免损坏配置重新暴露技能。 */
|
|
436
497
|
/**
|
|
437
|
-
* 参与扫描的来源 = 全部来源 −
|
|
498
|
+
* 参与扫描的来源 = 全部来源 − 用户已移除的 − 用户已永久删除的。
|
|
438
499
|
*
|
|
439
500
|
* 「移除来源」与「停用来源」是两件事:
|
|
440
501
|
* - 停用(`sources[key] = false`):仍然读取并列出技能,只是不可调用;
|
|
441
502
|
* - 移除(出现在 `removedSources`):**连目录都不读**,技能不出现在列表里,
|
|
442
503
|
* 也不参与 provider 候选。源文件不动,清掉这个标记即恢复。
|
|
504
|
+
*
|
|
505
|
+
* 「永久删除」(`forgottenSources`)是移除的**终点**:这条来源从插件里彻底消失 ——
|
|
506
|
+
* 不再读取(与移除同)、并且连"已移除"那行记录也不再出现(见 `state()` 的 roots 过滤)。
|
|
507
|
+
* 两者都**不碰磁盘**。
|
|
443
508
|
*/
|
|
444
509
|
export function activeUserRoots(stateValue) {
|
|
445
510
|
const state = stateValue;
|
|
446
511
|
const removed = new Set(state && Array.isArray(state.removedSources) ? state.removedSources : []);
|
|
447
|
-
|
|
512
|
+
const forgotten = new Set(state && Array.isArray(state.forgottenSources) ? state.forgottenSources : []);
|
|
513
|
+
return userRoots().filter((root) => !removed.has(root.key) && !forgotten.has(root.key));
|
|
448
514
|
}
|
|
449
515
|
/** 裁剪自定义来源列表:非法条目丢弃,key/path 绑定校验,label 规整。 */
|
|
450
516
|
/**
|
|
@@ -269,6 +269,8 @@ export function defaultManagerState() {
|
|
|
269
269
|
// 也不参与 provider 候选)。与「停用来源」不同——停用仍列出技能、只是不可调用。
|
|
270
270
|
// 源文件一个字节都不动;清空这个数组即可恢复。
|
|
271
271
|
removedSources: [],
|
|
272
|
+
// 用户从回收站「永久删除」的来源:连"已移除"这条记录都不再出现(见 core-types 的说明)。
|
|
273
|
+
forgottenSources: [],
|
|
272
274
|
};
|
|
273
275
|
}
|
|
274
276
|
export function validStateSkillName(name) {
|
|
@@ -324,6 +326,12 @@ export function validManagerStateDocument(value) {
|
|
|
324
326
|
value.removedSources.some((key) => typeof key !== "string" || key.length === 0 || key.length > 128))
|
|
325
327
|
return false;
|
|
326
328
|
}
|
|
329
|
+
// 永久删除的来源:同上(可选字段、同样的 key 形状)。
|
|
330
|
+
if (value.forgottenSources !== undefined) {
|
|
331
|
+
if (!Array.isArray(value.forgottenSources) ||
|
|
332
|
+
value.forgottenSources.some((key) => typeof key !== "string" || key.length === 0 || key.length > 128))
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
327
335
|
// 同名首选表:可选字段;只校验容器与值类型,键值逐条在 normalize 里裁剪(见 validPreferredSkillName)。
|
|
328
336
|
if (value.preferredSkills !== undefined) {
|
|
329
337
|
if (!value.preferredSkills ||
|
|
@@ -426,6 +434,23 @@ export function normalizeManagerState(value) {
|
|
|
426
434
|
!isDefaultSkillSource(key))),
|
|
427
435
|
].sort();
|
|
428
436
|
}
|
|
437
|
+
// 永久删除的来源:同一套口径(已知 key、非默认来源),另外**从 removedSources 里摘掉**
|
|
438
|
+
// —— 一条来源要么"已移除"、要么"已永久删除",同时挂在两份名单上只会让两处显示打架。
|
|
439
|
+
{
|
|
440
|
+
const raw = Array.isArray(value.forgottenSources) ? value.forgottenSources : [];
|
|
441
|
+
const known = new Set([
|
|
442
|
+
...userRoots().map((root) => root.key),
|
|
443
|
+
...customKeys,
|
|
444
|
+
]);
|
|
445
|
+
const forgotten = [
|
|
446
|
+
...new Set(raw.filter((key) => typeof key === "string" &&
|
|
447
|
+
known.has(key) &&
|
|
448
|
+
!isDefaultSkillSource(key))),
|
|
449
|
+
].sort();
|
|
450
|
+
normalized.forgottenSources = forgotten;
|
|
451
|
+
if (forgotten.length)
|
|
452
|
+
normalized.removedSources = normalized.removedSources.filter((key) => !forgotten.includes(key));
|
|
453
|
+
}
|
|
429
454
|
return normalized;
|
|
430
455
|
}
|
|
431
456
|
export function normalizeCustomRoots(value) {
|
|
@@ -618,11 +643,17 @@ export async function state(options = {}) {
|
|
|
618
643
|
const removedKeys = new Set(Array.isArray(policyResult.state.removedSources)
|
|
619
644
|
? policyResult.state.removedSources
|
|
620
645
|
: []);
|
|
646
|
+
// 「永久删除」过的来源(回收站 → 目录 里那条记录的终点):连行都不留 —— 界面不该再有
|
|
647
|
+
// 它的位置,「恢复读取」也无从谈起(用户 2026-10-12 要求 codex / Claude 也能永久删除)。
|
|
648
|
+
// 注意它同时也在 `activeUserRoots` 里被摘掉,所以下面这层过滤只影响**列表展示**。
|
|
649
|
+
const forgottenKeys = new Set(Array.isArray(policyResult.state.forgottenSources)
|
|
650
|
+
? policyResult.state.forgottenSources
|
|
651
|
+
: []);
|
|
621
652
|
// 已移除的来源仍然出现在 roots 里(界面要能显示「已移除」并允许恢复),但**不扫描**:
|
|
622
653
|
// 技能不入列表、不参与重名决胜 —— 这正是用户要的「不读取这个文件夹」。
|
|
623
654
|
// 注意这里必须用**全部**来源 + 自定义来源,不能只用 activeUserRoots(),
|
|
624
655
|
// 否则被移除的来源会连行都不剩,界面就无从提供「恢复读取」。
|
|
625
|
-
const user = [...userRoots(), ...customRootsFromState(policyResult.state)];
|
|
656
|
+
const user = [...userRoots(), ...customRootsFromState(policyResult.state)].filter((root) => !forgottenKeys.has(root.key));
|
|
626
657
|
const scannable = user.filter((root) => !removedKeys.has(root.key));
|
|
627
658
|
const userScans = await scanDeduplicatedRoots(scannable);
|
|
628
659
|
const projectWarnings = [];
|
package/lib/skills/service.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// 沿用上游机制:全局层接管 + 每个活动 agent scope 内二次注册。
|
|
6
6
|
import { appendFile, rename, rm, stat } from 'node:fs/promises';
|
|
7
7
|
import { isInsideRootResolved } from '../paths.js';
|
|
8
|
-
import { state, setSkillEnabled, setPreferredSkill, markSkillsDisabled, setSourceEnabled, setSourceRemoved, deleteSkill, restoreTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, updateSkill, skillDetail, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, } from './core.js';
|
|
8
|
+
import { state, setSkillEnabled, setPreferredSkill, markSkillsDisabled, setSourceEnabled, setSourceRemoved, deleteSkill, restoreTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, updateSkill, skillDetail, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, forgetSource, logPath, } from './core.js';
|
|
9
9
|
import { watchDirectories } from './dir-watch.js';
|
|
10
10
|
import { registerAgentSkillProviders, externalSkillProvider } from './provider-registration.js';
|
|
11
11
|
import { permanentlyDeleteTrashSafely } from './system-trash.js';
|
|
@@ -233,6 +233,8 @@ export function createSkillsService(ctx) {
|
|
|
233
233
|
// 批量启停与单条启停同权:配了访问令牌的宿主必须同样要求带令牌。
|
|
234
234
|
'skill-set-all',
|
|
235
235
|
'skill-trash-restore', 'skill-trash-delete', 'skill-custom-add', 'skill-custom-remove',
|
|
236
|
+
// 「永久删除来源记录」(回收站 → 目录):与上面两条同权,是写操作。
|
|
237
|
+
'skill-source-forget',
|
|
236
238
|
]);
|
|
237
239
|
/**
|
|
238
240
|
* 新建 / 导入 / 恢复技能后统一「默认停用」。**失败不能吞。**
|
|
@@ -385,6 +387,10 @@ export function createSkillsService(ctx) {
|
|
|
385
387
|
// 自定义技能目录:添加(绝对路径,只读接入)/ 移除(连同策略键)。
|
|
386
388
|
'skill-custom-add': wrap((args) => write(() => addCustomRoot(args && args.path, args && args.label, log)), afterWrite),
|
|
387
389
|
'skill-custom-remove': wrap((args) => write(() => removeCustomRoot(args && args.key, log)), afterWrite),
|
|
390
|
+
// 永久删除一条**按约定发现**的来源记录(agents / codex / claude)。与 `skill-custom-remove`
|
|
391
|
+
// 的分工见 core.ts 的 `forgetSource`:那条管用户手加的自定义目录,这条管推导出来的来源
|
|
392
|
+
// —— 后者不在状态文件的 customRoots 里,必须另记一份"已永久删除"名单才删得掉。
|
|
393
|
+
'skill-source-forget': wrap((args) => write(() => forgetSource(args && args.root, log)), afterWrite),
|
|
388
394
|
};
|
|
389
395
|
/**
|
|
390
396
|
* 该绝对路径是否落在「本插件认识的技能来源」内。
|
package/lib/tools/mcp.js
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
// 所以 switch 里只有 on|off 过 `lockedSceneGuard()`,restart 不过;与面板侧
|
|
14
14
|
// `guardLockedOps(frozenOps('all'))` 的口径逐字一致(两处口径不同才是真的"说假话")。
|
|
15
15
|
//
|
|
16
|
+
// restart 现在是**唯一**的重启入口:面板侧那颗重启键用户此前已删除(2026-10-12 复核,
|
|
17
|
+
// 客户端已无任何 restart 调用方)。这不是要把它也删掉 —— 工具是模型按需调用的面,与面板
|
|
18
|
+
// 按钮是两回事;面板删掉只说明"不想让人手点",而"服务器卡住、让模型重连一下"这条恢复路径
|
|
19
|
+
// 仍然成立,且它正是上面那条"锁定期间唯一不冻结的写"的立足点。删掉它等于把场景锁定时的
|
|
20
|
+
// 唯一恢复手段一并拿掉。
|
|
21
|
+
//
|
|
16
22
|
// **改一台已配置服务器时绝不读明文凭据**:`mcpm-edit` 的守卫(`resolveMaskedKv` /
|
|
17
23
|
// `resolveMaskedUrl`)就是为"接受打码值并还原"写的 —— 界面编辑框预填的本来就是打码值。
|
|
18
24
|
// 所以这里拿 `mcpmListView()` 的**打码视图**把模型没给的字段填回去,让 op 去还原。
|
package/lib/tools/prompt.js
CHANGED
|
@@ -58,15 +58,27 @@ export function buildPromptTools(deps) {
|
|
|
58
58
|
}));
|
|
59
59
|
register(defineTool({
|
|
60
60
|
name: 'prompt_manager_apply',
|
|
61
|
-
description: 'Apply one AGENTS.md preset by id; effective next turn. If a scene drives the baseline this does NOT refuse — it rebinds that scene to the preset and re-syncs it, so tell the user which scene was rebound. A locked scene blocks the call. Only on the user\'s instruction.',
|
|
61
|
+
description: 'Apply one AGENTS.md preset by id; effective next turn. If a scene drives the baseline this does NOT refuse — it rebinds that scene to the preset and re-syncs it, so tell the user which scene was rebound. A locked scene blocks the call. Pass apply=false to cancel instead — AGENTS.md goes back to what it was before the run of applies (the file is removed if there was none to begin with); a scene driving the baseline refuses that, rebind the scene instead. Only on the user\'s instruction.',
|
|
62
62
|
parameters: {
|
|
63
|
-
id: { type: 'string', required: true, description: 'Preset id (lowercase letters, digits, hyphens).' },
|
|
63
|
+
id: { type: 'string', required: true, description: 'Preset id (lowercase letters, digits, hyphens). Ignored when apply=false.' },
|
|
64
|
+
apply: { type: 'boolean', description: 'Omit or true = apply the preset in `id`; false = cancel — undo the run of applies.' },
|
|
64
65
|
},
|
|
65
66
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
66
67
|
async execute(args) {
|
|
67
68
|
const blocked = await deps.lockedSceneGuard();
|
|
68
69
|
if (blocked)
|
|
69
70
|
throw new Error(blocked);
|
|
71
|
+
if (args.apply === false) {
|
|
72
|
+
// 取消方向:`agentsmd-unapply` 自带两条护栏(场景驱动时拒绝、没有链起点时给确定
|
|
73
|
+
// 错误码),原样透传。回执要说清恢复成哪种 —— 起点没有文件时是**删文件**,
|
|
74
|
+
// 不说的话用户以为 AGENTS.md 还在(0.19.1 界面弹窗报的就是这两种)。
|
|
75
|
+
const r = await deps.unapplyPreset();
|
|
76
|
+
if (!r.ok)
|
|
77
|
+
throw new Error(r.error || '取消应用失败');
|
|
78
|
+
return r.removed === true
|
|
79
|
+
? 'OK: application cancelled — ~/.dsh/AGENTS.md did not exist before the run started, so the file was removed (effective next turn).'
|
|
80
|
+
: 'OK: application cancelled — ~/.dsh/AGENTS.md is back to what it was before the run started (effective next turn).';
|
|
81
|
+
}
|
|
70
82
|
const r = await deps.applyPresetGuarded(args.id);
|
|
71
83
|
if (!r.ok)
|
|
72
84
|
throw new Error(r.error);
|
package/lib/tools/scene.js
CHANGED
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
// 不因为多了一条工具就破例(工具描述本来就是写"什么时候该用它"的地方)。
|
|
27
27
|
import { text, toolNameList } from './deps.js';
|
|
28
28
|
import { FACTORY_TOOL_TABLE_PRESET } from './table.js';
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/** 档案里由模型设置的段(`memories` 段在 P5 已废弃,不再是档案的一部分)。
|
|
30
|
+
* `mcpNotes`(0.20.0)与其余三段同族:整段替换、段缺席 = 不碰这一域。 */
|
|
31
|
+
const ARCHIVE_SECTIONS = ['mcp', 'mcpNotes', 'skills', 'subagents'];
|
|
31
32
|
/** 段里的名单:数组段是名字列表,`mcp` 段是 serverName → `'*'` | 工具名数组。段缺席 = 空。 */
|
|
32
33
|
function segmentNames(value) {
|
|
33
34
|
if (Array.isArray(value))
|
|
@@ -36,6 +37,21 @@ function segmentNames(value) {
|
|
|
36
37
|
return Object.keys(value);
|
|
37
38
|
return [];
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* `quickPrompts` 入参 → 档案值:去空、去重、保序(与 op 侧 `quickPromptsArg` 同一口径)。
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ 空数组是**合法值**(= 进入后全部停用),不能在这里被当成"没给"滤掉 —— 它与
|
|
44
|
+
* "这一域不碰"(段缺席)是两句相反的话(见 `SceneArchive.quickPrompts` 的注释)。
|
|
45
|
+
*/
|
|
46
|
+
function quickPromptIds(value) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const item of Array.isArray(value) ? value : []) {
|
|
49
|
+
const id = String(item == null ? '' : item).trim();
|
|
50
|
+
if (id && out.indexOf(id) < 0)
|
|
51
|
+
out.push(id);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
39
55
|
/**
|
|
40
56
|
* 档案里绑的工具表方案 → 给人读的那一句。`factory-default` 是个哨兵值而不是本机存过的方案,
|
|
41
57
|
* 原样打出来模型会去找一份叫这个名字的方案(`_save` 那边也没有这一栏,它改不了)。
|
|
@@ -69,6 +85,11 @@ function archiveText(archive) {
|
|
|
69
85
|
const tt = toolTableLabel(archive.toolTablePreset);
|
|
70
86
|
if (tt)
|
|
71
87
|
parts.push('工具表方案「' + tt + '」');
|
|
88
|
+
// 快捷提示词绑定(0.20.0):进了这个场景会动到 `/` 菜单里哪些条目,不报就只能靠猜。
|
|
89
|
+
// 空表是"进入后全部停用",与"没绑"(这一域不碰)是两句相反的话,不能压成一个 "0 条"。
|
|
90
|
+
const qp = archive.quickPrompts;
|
|
91
|
+
if (Array.isArray(qp))
|
|
92
|
+
parts.push(qp.length ? '快捷提示词 ' + qp.length + ' 条' : '快捷提示词:进入后全部停用');
|
|
72
93
|
return parts.join(' | ');
|
|
73
94
|
}
|
|
74
95
|
/**
|
|
@@ -124,7 +145,7 @@ export function buildSceneTools(deps) {
|
|
|
124
145
|
// 默认的隐藏名单里):静态文本没法按 toolVisible 改写,点名一条模型没有的工具只会让它
|
|
125
146
|
// 白跑一趟。要说"去哪看"就用中性说法(the scene listing / the「本机当前的场景」reminder
|
|
126
147
|
// —— 后者是常驻源,只要场景域开着就在)。点名只留在**运行时回执**里、按可见性分叉。
|
|
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.',
|
|
148
|
+
description: 'List every scene with its archive (what entering it switches on: MCP servers, skills, personas, quick prompts, 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.',
|
|
128
149
|
parameters: {},
|
|
129
150
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
130
151
|
async execute() {
|
|
@@ -183,7 +204,7 @@ export function buildSceneTools(deps) {
|
|
|
183
204
|
// 为什么 `action` 是必填枚举而不是一个布尔:与 `mcp_manager_switch` 同一条理由 ——
|
|
184
205
|
// 让模型在 enter / exit 两个动词里选一个,比让它猜"scene 传 null 是什么意思"省一次往返。
|
|
185
206
|
// 参数里刻意不出现 `null`:那是 op(`scene-mode-set`)的形态,模型照着填只会填错。
|
|
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.',
|
|
207
|
+
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, quick prompts, 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.',
|
|
187
208
|
parameters: {
|
|
188
209
|
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).' },
|
|
189
210
|
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.' },
|
|
@@ -271,7 +292,7 @@ export function buildSceneTools(deps) {
|
|
|
271
292
|
// 「写入指引」保留("写成指令而不是备注"—— 这是场景说明质量的杠杆),但不再声称
|
|
272
293
|
// "注入后模型会被要求照办":2026-09-23 定稿把场景段的授权语删了(注入只陈述现状),
|
|
273
294
|
// 参数说明与注入侧各说各话时,模型信的那份是错的。改为如实说明去向。
|
|
274
|
-
description: 'Save a scene — a named bundle the user enters: entering it switches on exactly what its archive lists and everything else off. Creates it if absent, else updates it. An archive section you give replaces that section whole — include what should stay; the receipt reports what got displaced. Saving does not enter it; entering is a separate action. Only on the user\'s instruction.',
|
|
295
|
+
description: 'Save a scene — a named bundle the user enters: entering it switches on exactly what its archive lists (MCP servers, skills, personas, quick prompts, and the model tool table when a scheme is bound) and everything else off. Creates it if absent, else updates it. An archive section you give replaces that section whole — include what should stay; the receipt reports what got displaced. Saving does not enter it; entering is a separate action. Only on the user\'s instruction.',
|
|
275
296
|
parameters: {
|
|
276
297
|
scene: { type: 'string', required: true, description: 'Scene name (one path segment, no slashes). Created if absent.' },
|
|
277
298
|
label: { type: 'string', description: 'Display name shown in the UI. Omit = keep.' },
|
|
@@ -280,6 +301,18 @@ export function buildSceneTools(deps) {
|
|
|
280
301
|
// 界面 2026-10-10 补了「无」这个选项,工具侧的空串语义要跟着说清,否则模型只知道
|
|
281
302
|
// 「省略 = 保持」,不知道还有一条「解绑」的路。
|
|
282
303
|
prompt: { type: 'string', description: 'Prompt preset id to bind; while this scene is on it is the global baseline. Omit = keep; \'\' = bind none (this scene leaves AGENTS.md alone).' },
|
|
304
|
+
// 模型工具表方案绑定(0.20.0 补上模型侧入口:0.15.0 起界面可绑、档案里一直有这一栏,
|
|
305
|
+
// `_list` / `_switch` 也读它,唯独 `_save` 写不了)。'' = 解绑只能走
|
|
306
|
+
// `rules-update-scene` —— `scene-archive-save` 对载荷里**不带的栏**一律保留现值
|
|
307
|
+
// (archive-engine 的「不带 ≠ 解绑」),所以解绑与设置分成两条路,见 execute 的 ②。
|
|
308
|
+
// 哨兵 `factory-default` 与界面下拉同值(tools/table.ts 的 FACTORY_TOOL_TABLE_PRESET);
|
|
309
|
+
// 不存在的方案名由引擎按 stale 丢弃并在进入时报告,op 不硬拒(与悬空绑定同口径)。
|
|
310
|
+
toolTablePreset: { type: 'string', description: "Model tool-table scheme to bind while this scene is on. Omit = keep; '' = unbind (the scene leaves the tool table alone); 'factory-default' = the factory default; otherwise the name of a scheme saved in the panel. An unknown name is dropped and reported." },
|
|
311
|
+
// 快捷提示词绑定(0.20.0)。段语义与上面三个档案段相反:**空数组是合法值**
|
|
312
|
+
// (进入后全部停用),不是"清除这一段"。本工具不提供解绑(界面可解)—— 与
|
|
313
|
+
// mcp / skills / subagents 三段没有解绑参数同一条口径。id 从场景清单里别的场景的
|
|
314
|
+
// 绑定、或用户口述里来;不存在的 id 被引擎丢弃并在回执报出来。
|
|
315
|
+
quickPrompts: { type: 'array', items: { type: 'string' }, description: 'Quick-prompt ids to switch on when this scene is entered; every other one is switched off, and the pre-entry state is restored on exit. Omit = keep; [] = switch all off on entry. Unknown ids are dropped and reported — copy them from a scene binding in the listing, or ask the user.' },
|
|
283
316
|
mcp: {
|
|
284
317
|
type: 'object',
|
|
285
318
|
// 官方要求对象类型**显式声明开放性**(`additionalProperties` 是必需字段,且只能是
|
|
@@ -288,6 +321,14 @@ export function buildSceneTools(deps) {
|
|
|
288
321
|
additionalProperties: true,
|
|
289
322
|
description: "Archive: serverName -> '*' (whole server) or a list of tool names to leave on. Omit = keep; {} = switch none on.",
|
|
290
323
|
},
|
|
324
|
+
// 场景级 MCP 备注覆盖(0.20.0):进入这个场景时用这份盖掉全局备注,退出时还原。
|
|
325
|
+
// 与 mcp 段同一句式(省略 = 保持;{} = 一个都不覆盖)—— 界面存档编辑器早就能改,
|
|
326
|
+
// `_list` 也显示它,`_save` 此前不接这一段。
|
|
327
|
+
mcpNotes: {
|
|
328
|
+
type: 'object',
|
|
329
|
+
additionalProperties: true,
|
|
330
|
+
description: "Archive: serverName -> note that overrides that server's global note while this scene is on (the global note comes back on exit). Omit = keep; {} = drop every scene-level override.",
|
|
331
|
+
},
|
|
291
332
|
skills: { type: 'array', items: { type: 'string' }, description: 'Archive: skill keys `<root>/<name>` to switch on. Omit = keep; [] = switch none on.' },
|
|
292
333
|
subagents: { type: 'array', items: { type: 'string' }, description: 'Archive: persona names to switch on. Omit = keep; [] = switch none on.' },
|
|
293
334
|
},
|
|
@@ -326,22 +367,72 @@ export function buildSceneTools(deps) {
|
|
|
326
367
|
const created = await deps.rulesOps['rules-create-scene'](payload);
|
|
327
368
|
if (!created || created.ok === false)
|
|
328
369
|
throw new Error((created && created.error) || '保存场景失败');
|
|
329
|
-
// ②
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
370
|
+
// ② 两处「解绑」只能走 `rules-update-scene`:`scene-archive-save` 对载荷里**不带的栏**
|
|
371
|
+
// 一律保留现值(archive-engine 的「不带 ≠ 解绑」),从现值复制出来的合并档案会把
|
|
372
|
+
// 刚解掉的绑定又写回去 —— 所以解绑与设置分成两条路,合并前还要先摘掉刚解掉的键。
|
|
373
|
+
// · `prompt` 的 '' 此前也走不到实处 —— `rules-create-scene` 的 upsert 分支把空串
|
|
374
|
+
// 当"没给"(ops/scene-records.ts),旧绑定原样保留而回执报"已更新"。
|
|
375
|
+
// · `toolTablePreset` 的 '' 同解(那个 op 的更新分支认空串 = 解绑)。
|
|
376
|
+
const clearPrompt = args.prompt !== undefined && String(args.prompt).trim() === '';
|
|
377
|
+
const unbindToolTable = args.toolTablePreset !== undefined && String(args.toolTablePreset).trim() === '';
|
|
378
|
+
const setToolTable = args.toolTablePreset !== undefined && String(args.toolTablePreset).trim() !== '';
|
|
379
|
+
const setQuickPrompts = args.quickPrompts !== undefined;
|
|
380
|
+
if (setQuickPrompts && !Array.isArray(args.quickPrompts)) {
|
|
381
|
+
throw new Error('quickPrompts 必须是 id 数组([] = 进入后全部停用;要解绑请到「场景」页)');
|
|
382
|
+
}
|
|
335
383
|
const touched = ARCHIVE_SECTIONS.filter((k) => args[k] !== undefined);
|
|
336
|
-
|
|
337
|
-
|
|
384
|
+
const mustWriteArchive = touched.length > 0 || setToolTable || setQuickPrompts;
|
|
385
|
+
// 档案现状在读的时候就取一次,且必须在 ② **之前** —— 解绑落盘后这一栏已经没了,
|
|
386
|
+
// 回执的「X → 解绑」要的是解绑前的那个 X。只为看回执的 diff 而读时(解绑且不写档案)
|
|
387
|
+
// 容忍读失败:那一步已经写成功了,别为一句对比话把整次调用判失败。
|
|
388
|
+
let before = {};
|
|
389
|
+
let beforeKnown = false;
|
|
390
|
+
if (mustWriteArchive || unbindToolTable) {
|
|
338
391
|
const cur = await deps.archiveOps['scene-mode-get']({});
|
|
339
|
-
if (!cur || cur.ok === false)
|
|
340
|
-
|
|
341
|
-
|
|
392
|
+
if (!cur || cur.ok === false) {
|
|
393
|
+
if (mustWriteArchive)
|
|
394
|
+
throw new Error((cur && cur.error) || '读取场景档案失败');
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
before = (cur.archives || {})[scene] || {};
|
|
398
|
+
beforeKnown = true;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
let clearWarn = '';
|
|
402
|
+
if (clearPrompt || unbindToolTable) {
|
|
403
|
+
const upd = { name: scene };
|
|
404
|
+
if (clearPrompt)
|
|
405
|
+
upd.prompt = '';
|
|
406
|
+
if (unbindToolTable)
|
|
407
|
+
upd.toolTablePreset = '';
|
|
408
|
+
const u = await deps.rulesOps['rules-update-scene'](upd);
|
|
409
|
+
if (!u || u.ok === false) {
|
|
410
|
+
// 主路径已成功、解绑没写上:如实报,别让回执假装绑定已经没了。
|
|
411
|
+
clearWarn = '\nWARN: 解绑未写入(' + ((u && u.error) || '未知错误') + '),旧绑定仍然有效';
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// ③ 档案 read-modify-write:段(mcp / mcpNotes / skills / subagents)与两个绑定栏的
|
|
415
|
+
// 「设置」方向合并成一份,只经 `scene-archive-save` 一条路落盘。
|
|
416
|
+
// **必须 read-modify-write**:`scene-archive-save` 是**整份替换**,只把模型给的段
|
|
417
|
+
// 传过去,没给的段会被静默清掉 —— 与 0.14.0 里 `subagent_manager_save` 省略字段被
|
|
418
|
+
// 清空同一形状,只是这次清掉的是"这个场景进入时要开哪些东西"。
|
|
419
|
+
// 注意段语义:**段不存在 = 不限制(全部照常)**,段存在但为空 = 全部停用。所以模型给
|
|
420
|
+
// `mcp: {}` 得到的是"全不勾",不是"清除该段" —— 后者只能靠不给。
|
|
421
|
+
// `quickPrompts` 与三段都不同:段缺席 = 这一域不碰,空数组 = 进入后全部停用。
|
|
422
|
+
let archiveNote = '';
|
|
423
|
+
let toolTableNote = '';
|
|
424
|
+
let quickNote = '';
|
|
425
|
+
let warnTail = '';
|
|
426
|
+
if (mustWriteArchive) {
|
|
342
427
|
const archive = { ...before };
|
|
343
428
|
for (const k of touched)
|
|
344
429
|
archive[k] = args[k];
|
|
430
|
+
if (unbindToolTable)
|
|
431
|
+
delete archive.toolTablePreset;
|
|
432
|
+
if (setToolTable)
|
|
433
|
+
archive.toolTablePreset = String(args.toolTablePreset).trim();
|
|
434
|
+
if (setQuickPrompts)
|
|
435
|
+
archive.quickPrompts = quickPromptIds(args.quickPrompts);
|
|
345
436
|
const r = await deps.archiveOps['scene-archive-save']({ scene, archive });
|
|
346
437
|
if (!r || r.ok === false)
|
|
347
438
|
throw new Error((r && r.error) || '保存场景档案失败');
|
|
@@ -349,18 +440,41 @@ export function buildSceneTools(deps) {
|
|
|
349
440
|
// 静默清掉,所以"模型给什么就是什么"),但"给出去的段盖掉了原来的勾选"是**静默**的
|
|
350
441
|
// —— 不写出来,模型只会看到一句"档案段:mcp",以为只是加了一项。
|
|
351
442
|
const diffs = touched.map((k) => segmentDiff(k, before[k], args[k]));
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
443
|
+
if (diffs.length) {
|
|
444
|
+
archiveNote = '档案段:' + diffs.map((d) => d.text).join(';');
|
|
445
|
+
if (diffs.some((d) => d.dropped.length > 0)) {
|
|
446
|
+
warnTail += '\n注意:这些段是整段替换,上面标 - 的项已被挤掉;要保留就把它们一并写进这次调用。';
|
|
447
|
+
}
|
|
355
448
|
}
|
|
356
|
-
// 当前环境里不存在的键会被 op
|
|
357
|
-
//
|
|
449
|
+
// 当前环境里不存在的键会被 op 丢弃并报告(改名或删除留下的,含工具表方案名与
|
|
450
|
+
// 快捷提示词 id):如实说,否则模型以为勾上了而实际没有。
|
|
358
451
|
if (r.stale && r.stale.length)
|
|
359
|
-
|
|
452
|
+
warnTail += '\n(丢弃了当前环境不存在的键:' + r.stale.join('、') + ')';
|
|
360
453
|
// 存盘成功但应用到运行时失败:`scene-archive-save` 对"正是当前模式的那个场景"会就地
|
|
361
454
|
// 重应用,失败不回滚档案。如实报出去,别假装生效了(与界面同口径)。
|
|
362
455
|
if (r.applyError)
|
|
363
|
-
|
|
456
|
+
warnTail += '\n(已存盘,但应用到运行时失败:' + r.applyError + ')';
|
|
457
|
+
}
|
|
458
|
+
// 解绑写失败了就别输出「X → 解绑」—— 上面那句 WARN 才是事实;设置方向不受 ② 影响。
|
|
459
|
+
if (args.toolTablePreset !== undefined && !(unbindToolTable && clearWarn !== '')) {
|
|
460
|
+
toolTableNote = beforeKnown
|
|
461
|
+
? '工具表方案:' + (toolTableLabel(before.toolTablePreset) || '未绑') + ' → ' +
|
|
462
|
+
(unbindToolTable ? '解绑' : toolTableLabel(String(args.toolTablePreset).trim()))
|
|
463
|
+
: '工具表方案:已解绑(档案现状读不到,无法对比)';
|
|
464
|
+
}
|
|
465
|
+
if (setQuickPrompts && beforeKnown) {
|
|
466
|
+
const beforeIds = Array.isArray(before.quickPrompts) ? before.quickPrompts.map((x) => String(x)) : [];
|
|
467
|
+
const afterIds = quickPromptIds(args.quickPrompts);
|
|
468
|
+
const added = afterIds.filter((id) => beforeIds.indexOf(id) < 0);
|
|
469
|
+
const dropped = beforeIds.filter((id) => afterIds.indexOf(id) < 0);
|
|
470
|
+
const marks = [];
|
|
471
|
+
if (added.length)
|
|
472
|
+
marks.push('+' + toolNameList(added));
|
|
473
|
+
if (dropped.length)
|
|
474
|
+
marks.push('-' + toolNameList(dropped));
|
|
475
|
+
quickNote = '快捷提示词:' + beforeIds.length + ' 条 → ' + afterIds.length + ' 条' +
|
|
476
|
+
(afterIds.length === 0 ? '(进入后全部停用)' : '') +
|
|
477
|
+
(marks.length ? '(' + marks.join(' ') + ')' : '');
|
|
364
478
|
}
|
|
365
479
|
// 收敛提示:`rules-create-scene` 在"历史默认(全部启用)"状态下会把启用集合收窄成
|
|
366
480
|
// 单选(`collapseActiveForNewScene`)—— 那是一次**会改变运行时**的动作,必须说出来。
|
|
@@ -369,13 +483,17 @@ export function buildSceneTools(deps) {
|
|
|
369
483
|
const parts = [touchedRecord.length ? '场景记录已更新(' + touchedRecord.join('、') + ')' : '场景已确保存在'];
|
|
370
484
|
if (archiveNote)
|
|
371
485
|
parts.push(archiveNote);
|
|
486
|
+
if (toolTableNote)
|
|
487
|
+
parts.push(toolTableNote);
|
|
488
|
+
if (quickNote)
|
|
489
|
+
parts.push(quickNote);
|
|
372
490
|
// 点名 scene_manager_switch 的那句按可见性分叉(它在出厂默认名单里就是关着的):
|
|
373
491
|
// 部分启用(开 save 不开 switch)时指向一条模型没有的工具,只会让它白跑一趟 ——
|
|
374
492
|
// 与 mcp/skill 的 listHint 同一条纪律。
|
|
375
493
|
const enterHint = deps.toolVisible('scene_manager_switch')
|
|
376
494
|
? '要让它生效,用 scene_manager_switch 进入。'
|
|
377
495
|
: '要让它生效,由用户在「场景」页进入。';
|
|
378
|
-
return 'OK: scene「' + scene + '」' + parts.join(';') +
|
|
496
|
+
return 'OK: scene「' + scene + '」' + parts.join(';') + warnTail + clearWarn +
|
|
379
497
|
(collapsed
|
|
380
498
|
? '\n注意:此前所有场景都处于启用状态(历史「全部启用」默认),本次已收窄为只启用一个,新建的这个不在其中 —— 让用户到「场景」页确认。'
|
|
381
499
|
: '') +
|
package/lib/trash-retention.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
// 这里**刻意同形**:同一个 `{retentionDays, updatedAt}` 侧车形状、同一条到期判据、同一个
|
|
7
7
|
// 「改设置即重置倒计时」语义。两处口径一旦分家,用户在两个页面看到的就是两套话术。
|
|
8
8
|
//
|
|
9
|
-
// 覆盖六类:hub
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// 覆盖六类:hub 五类(`trash/<域>-trash/<id>/manifest.json`,含记忆 —— 它的落点自
|
|
10
|
+
// 2026-10-10 起也在这里,读写仍走 memories 域自己的实现)+ 技能(`trash/skills-trash/`,
|
|
11
|
+
// 元数据文件叫 `metadata.json`,是 core 层自己写的那套)。
|
|
12
12
|
//
|
|
13
13
|
// 三条口径(改这个文件前先读):
|
|
14
14
|
// ① `retentionDays <= 0` = **永久保留**,清扫是纯 no-op。这是默认值,也是本文件存在的
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-tool-management",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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",
|