dsh-plugin-tool-management 0.9.0 → 0.9.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.
@@ -39,15 +39,26 @@ export function createArchiveEngine(deps) {
39
39
  if (notes.length) {
40
40
  await deps.applyMcpNotes(notes.map((x) => ({ id: x.id, note: x.note })));
41
41
  }
42
- // 子智能体开关:先停「进入时被这次关掉的」那些(档案没勾 = 本场景不开),
43
- // 再开回「进入时被这次启用过的」那批 —— 与进入时的顺序相反,两个方向都要还原。
44
- const personasOff = snapshot.subagents ?? [];
45
- if (personasOff.length) {
46
- await deps.applySubagentSwitches(personasOff.map((n) => ({ name: n, enabled: false })));
42
+ // 子智能体开关(v0.9.1):**全量映射优先**——退出即精确还原「进场景前」的开与关。
43
+ // 为什么不能只靠下面那两个部分名单:场景内页面开关已开放(未锁定即可改、改动同步进档案),
44
+ // 「现在开着」不再只来自档案勾选,部分名单答不出「这个人是场景开的还是用户开的」。
45
+ // 表里**记过的名字**逐个还原;场景中新建的人设不在表里 → 不动它(与技能域同口径)。
46
+ // 老 snapshot 没有这一栏 → 退回两个方向的部分名单(旧行为:先停被开的,再开回被关的)。
47
+ const personaStates = snapshot.subagentsAll;
48
+ if (personaStates && typeof personaStates === 'object') {
49
+ const switches = Object.entries(personaStates).map(([name, on]) => ({ name, enabled: on === true }));
50
+ if (switches.length)
51
+ await deps.applySubagentSwitches(switches);
47
52
  }
48
- const personasOn = snapshot.subagentsOn ?? [];
49
- if (personasOn.length) {
50
- await deps.applySubagentSwitches(personasOn.map((n) => ({ name: n, enabled: true })));
53
+ else {
54
+ const personasOff = snapshot.subagents ?? [];
55
+ if (personasOff.length) {
56
+ await deps.applySubagentSwitches(personasOff.map((n) => ({ name: n, enabled: false })));
57
+ }
58
+ const personasOn = snapshot.subagentsOn ?? [];
59
+ if (personasOn.length) {
60
+ await deps.applySubagentSwitches(personasOn.map((n) => ({ name: n, enabled: true })));
61
+ }
51
62
  }
52
63
  }
53
64
  /**
@@ -156,7 +167,11 @@ export function createArchiveEngine(deps) {
156
167
  delete archive.mcpNotes;
157
168
  }
158
169
  if (archive.skills) {
159
- const known = await deps.knownSkillKeys();
170
+ // 校验用**可勾选**集合(不是「已知全集」):被同名技能覆盖的副本与结构不完整的技能
171
+ // 永远不可能生效,勾了也是假的 —— 留着就是「档案说开着、技能页说没启动」(用户实测:
172
+ // 启动一个目录后档案里多出被覆盖的那个技能)。这里按同一口径丢弃并如实报告,
173
+ // 顺带把历史残留清掉。
174
+ const known = await deps.selectableSkillKeys();
160
175
  const hadKeys = archive.skills.length > 0;
161
176
  stale.push(...archive.skills.filter((k) => !known.has(k)).map((k) => 'skills/' + k));
162
177
  archive.skills = archive.skills.filter((k) => known.has(k));
@@ -254,11 +269,16 @@ export function createArchiveEngine(deps) {
254
269
  let unboundPersonas = [];
255
270
  let personaRestore = [];
256
271
  let personaRestoreOn = [];
272
+ // 全量开关映射(v0.9.1):退出即精确还原「进场景前」。半个名单做不到这件事 ——
273
+ // 场景里手动开过的人设,「改动前是关还是开」只在这张全量表里。
274
+ let personaStates = {};
257
275
  try {
258
276
  const known = await deps.knownPersonas();
259
277
  unboundPersonas = [...known].filter((n) => boundPersonas.indexOf(n) < 0).sort();
260
278
  personaRestore = await deps.disabledPersonas(boundPersonas);
261
279
  personaRestoreOn = await deps.enabledPersonas(unboundPersonas);
280
+ const on = new Set(await deps.enabledPersonas([...known]));
281
+ personaStates = Object.fromEntries([...known].map((n) => [n, on.has(n)]));
262
282
  }
263
283
  catch (e) {
264
284
  return { ok: false, error: `读取人设开关状态失败(未改动任何东西):${msg(e)}` };
@@ -320,7 +340,7 @@ export function createArchiveEngine(deps) {
320
340
  // 进入都可能改动运行时(哪怕只是停掉几台服务器 / 几个技能),退出都得能精确还原。
321
341
  const snapshot = snapshotRuntime(await deps.currentMcpRaw(), await deps.currentSkills(),
322
342
  // 直接用计划带出的 `*Before`(改动前的状态),不在这里反推 —— 反推容易搞反方向。
323
- mcpPlan.serverSwitches.map((s) => ({ id: s.id, level: s.level, disabled: s.disabledBefore })), skillsPlan.sourceSwitches.map((s) => ({ root: s.root, enabled: s.enabledBefore })), personaRestore, personaRestoreOn, noteBefore);
343
+ mcpPlan.serverSwitches.map((s) => ({ id: s.id, level: s.level, disabled: s.disabledBefore })), skillsPlan.sourceSwitches.map((s) => ({ root: s.root, enabled: s.enabledBefore })), personaRestore, personaRestoreOn, noteBefore, personaStates);
324
344
  const entered = { ...slice, mode: { scene: target, snapshot }, active: [target] };
325
345
  try {
326
346
  await deps.saveSlice({ ...slice, mode: entered.mode });
@@ -198,7 +198,12 @@ subagents = [],
198
198
  /** **将被档案停用**的人设名(改动前启用的子集;退出时按此重新打开)。 */
199
199
  subagentsOn = [],
200
200
  /** **将被档案改动**的备注行,带改动前的备注(null = 原本没有)。 */
201
- mcpNotes = []) {
201
+ mcpNotes = [],
202
+ /**
203
+ * 进入模式时**全部**人设的开关状态(名字 → 是否开着)。给了就写进 `subagentsAll`:
204
+ * 退出时按它精确还原(场景里手动开过的人设也会被还原回进场景前的状态)。
205
+ */
206
+ subagentStates = null) {
202
207
  return {
203
208
  mcp: Object.fromEntries(Object.entries(mcpRaw).map(([k, v]) => [k, v.slice()])),
204
209
  skills: { ...skills },
@@ -206,6 +211,7 @@ mcpNotes = []) {
206
211
  skillSources: skillSources.map((x) => ({ root: x.root, enabled: x.enabled })),
207
212
  ...(subagents.length ? { subagents: subagents.slice() } : {}),
208
213
  ...(subagentsOn.length ? { subagentsOn: subagentsOn.slice() } : {}),
214
+ ...(subagentStates ? { subagentsAll: { ...subagentStates } } : {}),
209
215
  ...(mcpNotes.length ? { mcpNotes: mcpNotes.map((x) => ({ id: x.id, note: x.note })) } : {}),
210
216
  };
211
217
  }
@@ -486,9 +486,9 @@ export function parseModeState(raw) {
486
486
  list.push(tool);
487
487
  }
488
488
  }
489
- // v2 快照的**服务器级 / 来源级**名单与 v0.8 的子智能体名单必须原样透传 ——
490
- // 这里曾把它们剥掉,结果退出模式时两张恢复名单全是空的:服务器级 MCP 与技能来源
491
- // 永远不回滚(用户报的「MCP 不复原」「技能目录不回退」),只有工具级 / 技能级能还原。
489
+ // v2 快照的**服务器级 / 来源级**名单、v0.8 的子智能体名单、v0.9.1 的人设全量映射
490
+ // 必须原样透传 —— 这里曾把它们剥掉,结果退出模式时两张恢复名单全是空的:服务器级 MCP 与
491
+ // 技能来源永远不回滚(用户报的「MCP 不复原」「技能目录不回退」),只有工具级 / 技能级能还原。
492
492
  const mcpServers = (Array.isArray(snapshotRaw.mcpServers) ? snapshotRaw.mcpServers : [])
493
493
  .filter((x) => !!x && typeof x === 'object')
494
494
  .map((x) => ({ id: String(x.id || ''), level: String(x.level || ''), disabled: x.disabled === true }))
@@ -501,6 +501,9 @@ export function parseModeState(raw) {
501
501
  // 同 subagents 一组:进入时被档案**关掉**的人设(退出要重新打开)。漏掉它 = 用户实测的
502
502
  // 「进场景关掉了,退出却没开回来」——快照落盘后读回来就只剩「被启用」那一半名单。
503
503
  const subagentsOn = (Array.isArray(snapshotRaw.subagentsOn) ? snapshotRaw.subagentsOn : []).map((x) => String(x)).filter(Boolean);
504
+ // v0.9.1 的人设**全量**开关映射(名字 → 进场景时是否开着):同样必须原样透传 ——
505
+ // 剥掉它,退出模式就只能退回上面两个部分名单,「场景里手动开过的人设」不再被还原。
506
+ const subagentsAll = toFlagMap(snapshotRaw.subagentsAll);
504
507
  // v0.8.1 的场景备注恢复名单:**必须原样透传**——这里漏掉它,退出模式时备注永不回退
505
508
  //(与历史上 mcpServers / skillSources 被剥掉是同一类 bug)。
506
509
  const mcpNotes = (Array.isArray(snapshotRaw.mcpNotes) ? snapshotRaw.mcpNotes : [])
@@ -517,6 +520,7 @@ export function parseModeState(raw) {
517
520
  ...(skillSources.length ? { skillSources } : {}),
518
521
  ...(subagents.length ? { subagents } : {}),
519
522
  ...(subagentsOn.length ? { subagentsOn } : {}),
523
+ ...(Object.keys(subagentsAll).length ? { subagentsAll } : {}),
520
524
  ...(mcpNotes.length ? { mcpNotes } : {}),
521
525
  }
522
526
  : null,
@@ -1251,6 +1255,37 @@ function attachmentNamesSync(bundleDir, name) {
1251
1255
  return [];
1252
1256
  }
1253
1257
  }
1258
+ /**
1259
+ * bundle 记忆的附件**摘要**(列表页用):数量 / 总体积 / 前几个文件名。
1260
+ *
1261
+ * 口径与 `attachmentNamesSync`(也就是注入给模型的那份清单)逐字一致:跳过正文本体,
1262
+ * 只认普通文件 —— 所以页面上的数字与模型实际看到的一致,不会出现「界面说 3 个、模型只见 2 个」。
1263
+ * `names` 截到 ATTACHMENT_LIST_MAX:tooltip 列不下更多,要全看到编辑弹窗里去看。
1264
+ *
1265
+ * 目录读不到(索引残留了已消失的条目)→ 返回 null,让界面**什么都不显示**,
1266
+ * 而不是谎报「0 个附件」。
1267
+ */
1268
+ function attachmentSummarySync(bundleDir, name) {
1269
+ let names;
1270
+ try {
1271
+ const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
1272
+ names = readdirSync(bundleDir, { withFileTypes: true })
1273
+ .filter((e) => e.isFile() && !docNames.has(e.name))
1274
+ .map((e) => e.name)
1275
+ .sort((a, b) => a.localeCompare(b));
1276
+ }
1277
+ catch {
1278
+ return null;
1279
+ }
1280
+ let bytes = 0;
1281
+ for (const entry of names) {
1282
+ try {
1283
+ bytes += statSync(join(bundleDir, entry)).size;
1284
+ }
1285
+ catch { /* 读不到的条目不计体积 */ }
1286
+ }
1287
+ return { count: names.length, bytes, names: names.slice(0, ATTACHMENT_LIST_MAX) };
1288
+ }
1254
1289
  /**
1255
1290
  * 附件行(只有 bundle 记忆才有):**给目录与文件名,不给内容**。
1256
1291
  * 附件可能是图片、二进制、大 md —— 全文注入又贵又会把段预算吃光;给路径,模型需要时自己读。
@@ -1667,7 +1702,16 @@ export function createRulesService(ctx, deps) {
1667
1702
  const index = await readIndex(stateDir);
1668
1703
  const rules = snap.rules
1669
1704
  .filter((r) => !groupFilter || r.group === groupFilter)
1670
- .sort((a, b) => a.group.localeCompare(b.group) || a.order - b.order || a.name.localeCompare(b.name));
1705
+ .sort((a, b) => a.group.localeCompare(b.group) || a.order - b.order || a.name.localeCompare(b.name))
1706
+ // bundle 记忆附带附件摘要(用户裁定 2026-09-17):界面要显示「几个附件」。
1707
+ // 每条 bundle 一次 readdirSync + 每个附件一次 statSync —— bundle 记忆通常只有几条,
1708
+ // 这个开销可以忽略;flat 没有目录,直接原样返回(不加字段 = 界面不显示附件标签)。
1709
+ .map((r) => {
1710
+ if (r.form !== 'bundle')
1711
+ return r;
1712
+ const attach = attachmentSummarySync(dirname(r.path), r.name);
1713
+ return attach ? { ...r, attach } : r;
1714
+ });
1671
1715
  const groups = groupFilter ? snap.groups.filter((g) => g.name === groupFilter) : snap.groups;
1672
1716
  const scenes = sceneRows(snap, index);
1673
1717
  // 场景锁定状态(v0.8):任意一个场景锁定 = 五个管理域整体冻结。界面据此禁用各页的写控件。
@@ -9,7 +9,8 @@
9
9
  // 六个动作会触发同步(调用方在 op 层包一层 `withSync`):
10
10
  // ① 启用/切换场景 ② 关掉场景(恢复进场景前的基线)
11
11
  // ③ 改场景绑定的预设 ④ 编辑"正在驱动基线的那份预设"的正文
12
- // ⑤ 从回收站恢复预设(绑定重新变活) ⑥ 应用提示词(场景接管时只放行绑定的那一份)
12
+ // ⑤ 从回收站恢复预设(绑定重新变活) ⑥ 应用提示词(**应用别的份 = 换掉当前场景的绑定**,
13
+ // 改完绑定再走一次同步;见 index.ts 的 `applyPresetGuarded`)
13
14
  //
14
15
  // 「进场景前的基线」快照存在 hub 的 `scene-baseline.json`:进场景时记下当时的
15
16
  // AGENTS.md 正文(以及当时匹配到的预设 id),关掉场景时**按原文**写回 ——
@@ -20,7 +21,7 @@
20
21
  // 写失败**不改**原操作的成功结论,而是把原因放进 `error` 交给界面显示成警告。
21
22
  //
22
23
  // 这里同时是**三条状态的事实源**(用户实测反馈「显示 A、实际注入 B、A 还能删 B 不能删」后加的):
23
- // `driver()` —— 谁在驱动基线(提示词页「应用」的守卫依据:场景接管期间不许应用别的预设);
24
+ // `driver()` —— 谁在驱动基线(提示词页「应用」据此判断:应用别的份 = 换掉该场景的绑定);
24
25
  // `refs()` —— 每个预设被谁引用(删除保护的唯一依据:场景绑定 / 基线当前内容 / 退出恢复目标);
25
26
  // `baseline()` —— 进场景前的基线快照(退出场景后要恢复的那一份)。
26
27
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
@@ -85,15 +86,6 @@ export function createScenePromptSync(deps) {
85
86
  return null;
86
87
  return { scene: current.scene, label: current.label || current.scene, presetId: current.presetId };
87
88
  }
88
- async function applyGuard(id) {
89
- const current = await driver();
90
- if (!current)
91
- return null;
92
- // 同一份 = 重新应用(把被手改的基线写回场景绑定的内容)→ 放行。
93
- if (current.presetId === String(id ?? '').trim())
94
- return null;
95
- return current;
96
- }
97
89
  async function refs() {
98
90
  const out = new Map();
99
91
  const add = (id, ref) => {
@@ -197,5 +189,5 @@ export function createScenePromptSync(deps) {
197
189
  const agentsMd = await sync();
198
190
  return { ...res, agentsMd };
199
191
  }
200
- return { sync, withSync, state, driver, applyGuard, refs };
192
+ return { sync, withSync, state, driver, refs };
201
193
  }
@@ -308,6 +308,33 @@ export function createSkillsService(ctx) {
308
308
  const list = await projectRoots(projectOptions().projectCwds);
309
309
  return list.find((root) => root.key === key);
310
310
  };
311
+ /**
312
+ * **项目来源**(`project-dsh:<hash>` / `project-agents:<hash>`)能不能被启停,此前取决于
313
+ * 「它的工作区此刻在不在活动会话里」(`requestRoot` 的第三步)——于是场景快照恢复时工作区
314
+ * 早已关闭,解析落空 → `setSourceEnabled(undefined)` → 「该技能来源不允许启用或停用」→
315
+ * 退出模式直接失败、卡在场景里(用户 2026-09-17 报的)。
316
+ *
317
+ * key 本身就是自描述的(`<kind>:<路径哈希>`),而启停只写本地状态文件
318
+ * (`state.sources[key]`),一个字节都不碰源目录 —— 所以按前缀合成一个最小 definition
319
+ * 交给 core 即可:这个工作区下次被打开时,来源按记录的状态生效。
320
+ */
321
+ const PROJECT_SOURCE_KEY_RE = /^project-(dsh|agents):[a-f0-9]{16}$/;
322
+ function projectKeyStub(key) {
323
+ if (!PROJECT_SOURCE_KEY_RE.test(key))
324
+ return undefined;
325
+ return {
326
+ key,
327
+ kind: key.slice(0, key.indexOf(':')),
328
+ scope: 'project',
329
+ toggleable: true,
330
+ mutable: false,
331
+ deletable: false,
332
+ label: key,
333
+ path: '',
334
+ };
335
+ }
336
+ /** 启停来源专用的解析:静态 / 自定义 / 活动工作区都命中不了时,按 key 前缀兜底。 */
337
+ const requestSwitchRoot = async (key) => (await requestRoot(key)) || projectKeyStub(key);
311
338
  const providerInvalidators = new Set();
312
339
  const invalidateSkills = () => {
313
340
  for (const invalidate of providerInvalidators) {
@@ -402,6 +429,8 @@ export function createSkillsService(ctx) {
402
429
  'skill-source-remove', 'skill-source-restore',
403
430
  'skill-prefer', 'skill-unprefer',
404
431
  'skill-create', 'skill-import', 'skill-upload', 'skill-delete',
432
+ // 批量启停与单条启停同权:配了访问令牌的宿主必须同样要求带令牌。
433
+ 'skill-set-all',
405
434
  'skill-trash-restore', 'skill-trash-delete', 'skill-custom-add', 'skill-custom-remove',
406
435
  ]);
407
436
  const ops = {
@@ -416,14 +445,39 @@ export function createSkillsService(ctx) {
416
445
  // 启停(写 manager 状态,不改源文件)
417
446
  'skill-enable': wrap((args) => write(async () => setSkillEnabled(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), true, log)), afterWrite),
418
447
  'skill-disable': wrap((args) => write(async () => setSkillEnabled(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), false, log)), afterWrite),
419
- 'skill-source-enable': wrap((args) => write(async () => setSourceEnabled(await requestRoot(String(args.root || '')), true, log)), afterWrite),
420
- 'skill-source-disable': wrap((args) => write(async () => setSourceEnabled(await requestRoot(String(args.root || '')), false, log)), afterWrite),
448
+ 'skill-source-enable': wrap((args) => write(async () => setSourceEnabled(await requestSwitchRoot(String(args.root || '')), true, log)), afterWrite),
449
+ 'skill-source-disable': wrap((args) => write(async () => setSourceEnabled(await requestSwitchRoot(String(args.root || '')), false, log)), afterWrite),
421
450
  // 移除 / 恢复来源(「不再读取这个文件夹」):只改本地状态,源目录一个字节都不动。
422
451
  'skill-source-remove': wrap((args) => write(async () => setSourceRemoved(await requestRoot(String(args.root || '')), true, log)), afterWrite),
423
452
  'skill-source-restore': wrap((args) => write(async () => setSourceRemoved(await requestRoot(String(args.root || '')), false, log)), afterWrite),
424
453
  // 同名技能首选来源(手工指定哪个同名版本生效;只写本地策略)。
425
454
  'skill-prefer': wrap((args) => write(async () => setPreferredSkill(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), true, log)), afterWrite),
426
455
  'skill-unprefer': wrap((args) => write(async () => setPreferredSkill(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), false, log)), afterWrite),
456
+ /**
457
+ * 批量启停(技能页 / 记忆页那种「全选 / 取消全选」用)。
458
+ *
459
+ * 为什么要有它:前端逐条打单条 op 时,每次成功都会清状态缓存 + 重扫技能目录 +
460
+ * 广播 agent-preset 事件,几十上百条就是几十上百轮全量重扫。这里一次写锁走完、
461
+ * 只在最后失效一次。逐条仍复用单条写通道(不另写一份策略逻辑)。
462
+ */
463
+ 'skill-set-all': wrap((args) => write(async () => {
464
+ const items = Array.isArray(args && args.items) ? args.items : [];
465
+ const enabled = (args && args.enabled) === true;
466
+ const failed = [];
467
+ let changed = 0;
468
+ for (const item of items) {
469
+ const rootKey = String((item && item.root) || '');
470
+ const name = String((item && item.name) || '');
471
+ if (!rootKey || !name)
472
+ continue;
473
+ const res = await setSkillEnabled(await requestRoot(rootKey), name, enabled, log);
474
+ if (res && res.ok === false)
475
+ failed.push({ root: rootKey, name, error: String(res.error || '') });
476
+ else
477
+ changed++;
478
+ }
479
+ return { enabled, changed, failed };
480
+ }), afterWrite),
427
481
  // 创建 / 导入 / 删除 / 回收站
428
482
  // 创建默认落 hub(`tool-management/skills/`):UI 不传 root 时走这里;
429
483
  // core.js 的 createSkill 默认值是第二道保险(同口径)。官方 `~/.dsh/skills/` 仍作为来源列出。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-tool-management",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "DSH plugin: one settings panel for MCP servers, skills, scenes, memories, subagents, AGENTS.md presets and archived sessions — scene memory injects into the system prompt, scene lock freezes all five domains, deleted workspaces re-register with one click. Settings UI + HTTP API + 14 model tools (mcp_manager_*, skill_manager_*, prompt_manager_*, memory_manager_*, subagent_*). Install with one command: dsh plugin --profile web add dsh-plugin-tool-management@latest",
5
5
  "repository": {
6
6
  "type": "git",