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.
Files changed (45) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +3 -1
  3. package/README_EN.md +1 -1
  4. package/docs/images/logo.gif +0 -0
  5. package/docs/update.md +1 -0
  6. package/lib/client.js +1594 -364
  7. package/lib/compat/preset-reach.js +5 -2
  8. package/lib/context-inject-frame.js +13 -8
  9. package/lib/hub.js +8 -5
  10. package/lib/index.js +56 -20
  11. package/lib/mcp/manager.js +21 -0
  12. package/lib/mcp/state-section.js +1 -1
  13. package/lib/memories/archive-engine.js +4 -5
  14. package/lib/memories/constants.js +2 -2
  15. package/lib/op-registry.js +7 -5
  16. package/lib/ops/memory.js +42 -4
  17. package/lib/ops/scene-records.js +43 -9
  18. package/lib/ops/sessions.js +5 -39
  19. package/lib/ops/trash.js +45 -5
  20. package/lib/request-gate.js +9 -2
  21. package/lib/scene-prompt-sync.js +48 -23
  22. package/lib/sessions/workspace.js +0 -10
  23. package/lib/skills/catalog.js +17 -5
  24. package/lib/skills/core.js +121 -5
  25. package/lib/skills/frontmatter.js +31 -0
  26. package/lib/skills/manager-state.js +41 -2
  27. package/lib/skills/service.js +46 -12
  28. package/lib/skills/skill-roots.js +54 -4
  29. package/lib/skills/skill-trash.js +118 -12
  30. package/lib/skills/upload-pipeline.js +110 -26
  31. package/lib/subagents/catalog.js +9 -5
  32. package/lib/subagents/service.js +44 -6
  33. package/lib/subagents/tools.js +45 -15
  34. package/lib/tool-table-switch.js +6 -6
  35. package/lib/tools/deps.js +43 -0
  36. package/lib/tools/mcp.js +68 -27
  37. package/lib/tools/memory.js +49 -9
  38. package/lib/tools/prompt.js +26 -9
  39. package/lib/tools/remove.js +206 -0
  40. package/lib/tools/scene.js +18 -13
  41. package/lib/tools/skills.js +162 -55
  42. package/lib/tools/subagent.js +65 -31
  43. package/lib/tools/table.js +39 -12
  44. package/lib/trash-id.js +16 -0
  45. package/package.json +115 -114
@@ -468,10 +468,13 @@ export function reachNoticeFor(presetId, facts, inject) {
468
468
  const parts = [];
469
469
  const domainOff = (key) => inject?.domains?.[key] === false;
470
470
  if (isSuppressingPreset(facts) && inject?.underSuppressingPresets !== true) {
471
+ // 末尾那句「(想让它在这类预设下也注入:插件的「兼容」页 → 注入。)」已删(2026-10-15 用户裁定):
472
+ // 它是**给人看的操作路径**(界面怎么点),而这段文字进的是**工具回执** —— 回执留在 transcript 里,
473
+ // 比工具 schema 还贵(每次调用都会带一份)。判据与上一轮 P1-5 从场景锁定守卫里清掉
474
+ // 「去界面解锁」逐字同一条:守卫/提示只**陈述事实**,"怎么改"由模型自己向用户解释。
471
475
  parts.push(`预设「${presetId}」声明只要它自己的文本(persona complete / 关闭运行时上下文):` +
472
476
  '本插件注入的 —— 场景、记忆、MCP、技能、子智能体、提示词 —— 默认不注入,' +
473
- '需要时用对应的 list / read 工具按需读取;不要假设你已经看到它们。' +
474
- '(想让它在这类预设下也注入:插件的「兼容」页 → 注入。)');
477
+ '需要时用对应的 list / read 工具按需读取;不要假设你已经看到它们。');
475
478
  }
476
479
  else {
477
480
  if (facts.agentInstructions !== 'mounted' && domainOff('prompt')) {
@@ -26,22 +26,22 @@ export const DOMAIN_FRAME = {
26
26
  // 现在这一段只回答一个问题:**当前处在哪个场景、它是什么**(`**「代码」—— 写代码**`)。
27
27
  // 它还比别的段少一层:因果句也删了 —— 它解释的是"另外四段是怎么产生的"(机制),
28
28
  // 不是当前情况本身,而且"清单里没有 ≠ 本机没有"这层反推被用户明确判为浪费。
29
- title: '本机当前的场景',
29
+ title: '当前场景',
30
30
  supersede: SUPERSEDE_NOTE,
31
31
  },
32
32
  memory: {
33
- title: '本机当前的记忆',
33
+ title: '当前记忆',
34
34
  supersede: SUPERSEDE_NOTE,
35
35
  },
36
36
  mcp: {
37
- title: '本机 MCP 服务器的当前状态',
37
+ title: '当前 MCP 服务器',
38
38
  // how 只剩"怎么用备注"这半句:前半个分句「工具名是 `mcp__<服务器>__<工具>`」删掉了
39
39
  // —— 模型自己的工具表里就是这个命名(`mcp__context7__xxx`),告诉它格式是零信息量。
40
40
  how: '带「用户提示:」的行是用户写给这台服务器的决策提示,选服务器之前先看一眼。',
41
41
  supersede: SUPERSEDE_NOTE,
42
42
  },
43
43
  skills: {
44
- title: '本机技能目录',
44
+ title: '当前技能目录',
45
45
  // 两句都只在预设没挂官方 `skill` 工具时出现,差别只在点名不点名那个取正文的工具
46
46
  // (工具被用户在兼容页关掉时不点名 —— 点名一个模型手里没有的工具只会让它去猜名字)。
47
47
  how: (toolHidden) => toolHidden('skill_manager_read')
@@ -50,20 +50,25 @@ export const DOMAIN_FRAME = {
50
50
  supersede: `${SUPERSEDE_NOTE.slice(0, -1)};只列当前可调用的技能。`,
51
51
  },
52
52
  subagents: {
53
- title: '可委派的子智能体',
53
+ title: '当前子智能体',
54
54
  // 分界规则(2026-09-17 方案 C,本机实测 session-ee722e23 逼出来的):官方那两个
55
55
  // 委派工具(`subagent` / `subagent_fork`)不带人设,而此前没有任何一句话说明何时该
56
56
  // 用谁 —— 模型在"审查刚读过的 README"时选了 `subagent_fork`(fork 能继承已读内容、
57
57
  // 省一次复述)。现在人设通道也有 `inherit`(同一套 fork 机制),分界只剩"要不要后台跑"。
58
58
  // 带人设的委派工具被关掉时改说"本会话没有这条通道":不说的话,模型看到有人设目录却
59
59
  // 找不到对应的工具,会去拿官方那两个凑(它们不接受人设,等于白跑一趟)。
60
+ //
61
+ // `inherit` 那半句 2026-10-14 删掉了(用户裁定):它在 `subagent_manager_run` 的
62
+ // Modes 段与 `inherit` 参数说明里**各有一份**,三份都在每轮上下文里 = 同一件事付三次
63
+ // token。留这两份(它们就在"要传这个参数"的那一刻),这里只留 how 行独有的那半句 ——
64
+ // 与官方两个委派工具的分界规则。
60
65
  how: (toolHidden) => toolHidden('subagent_manager_run')
61
66
  ? '本会话没有带人设的委派工具;官方 `subagent` / `subagent_fork` 不带人设,只在没有人设贴合、或要后台跑时用。'
62
- : '贴合人设的任务一律用 `subagent_manager_run`(要它看到本次会话就开 `inherit`);官方 `subagent` / `subagent_fork` 不带人设,只在没有人设贴合、或要后台跑时用。',
67
+ : '贴合人设的任务一律用 `subagent_manager_run`;官方 `subagent` / `subagent_fork` 不带人设,只在没有人设贴合、或要后台跑时用。',
63
68
  supersede: SUPERSEDE_NOTE,
64
69
  },
65
70
  prompt: {
66
- title: '本机提示词',
71
+ title: '当前提示词',
67
72
  supersede: SUPERSEDE_NOTE,
68
73
  },
69
74
  };
@@ -82,7 +87,7 @@ export const DOMAIN_FRAME = {
82
87
  export const HOW_NAMED_TOOLS = ['skill_manager_read', 'subagent_manager_run'];
83
88
  /** 域声明里没登记的 key(理论上到不了这里):给一个不出错的通用框架。 */
84
89
  export const fallbackFrame = (label) => ({
85
- title: `本机的${label}`,
90
+ title: `当前的${label}`,
86
91
  supersede: SUPERSEDE_NOTE,
87
92
  });
88
93
  export const domainFrame = (key, label) => DOMAIN_FRAME[key] ?? fallbackFrame(label);
package/lib/hub.js CHANGED
@@ -43,8 +43,8 @@
43
43
  // 3. **每进程一次**——迁移是启动期的一次性动作,失败也不阻断服务(旧目录仍可读)。
44
44
  import { cp, lstat, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
45
45
  import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs';
46
- import { randomUUID } from 'node:crypto';
47
46
  import { basename, dirname, join } from 'node:path';
47
+ import { newTrashId } from './trash-id.js';
48
48
  import { resolveDshHome } from './skills/core.js';
49
49
  import { isSameOrDescendant } from './paths.js';
50
50
  /** hub 根目录名(`$DSH_HOME` 下)。 */
@@ -258,10 +258,13 @@ const LEGACY_TRASH_KINDS = {
258
258
  export function trashKindDir(kind) {
259
259
  return hubPath('trash', `${kind}-trash`);
260
260
  }
261
- /** 新条目 id(时间戳 base36 + uuid 前 8 位,与记忆回收站同形)。 */
262
- export function newTrashId() {
263
- return Date.now().toString(36) + '-' + randomUUID().slice(0, 8);
264
- }
261
+ /**
262
+ * 新条目 id(时间戳 base36 + uuid 前 8 位,与记忆回收站同形)。
263
+ *
264
+ * 实现挪到 `./trash-id.ts`(2026-10-15):本模块引了 `skills/core.js`,技能域要共用这个生成器
265
+ * 就不能再反过来引这里。这里保留同一条导出路径 —— `memories/service.ts` 等既有引方不动。
266
+ */
267
+ export { newTrashId };
265
268
  /** id 只由本模块生成:严格白名单,杜绝路径穿越。 */
266
269
  export function isValidTrashId(id) {
267
270
  return /^[a-z0-9]+-[a-z0-9]{1,32}$/i.test(id);
package/lib/index.js CHANGED
@@ -70,6 +70,7 @@ import { buildMcpTools } from './tools/mcp.js';
70
70
  import { buildSkillTools } from './tools/skills.js';
71
71
  import { buildPromptTools } from './tools/prompt.js';
72
72
  import { buildMemoryTools } from './tools/memory.js';
73
+ import { buildRemoveTools } from './tools/remove.js';
73
74
  import { buildSceneTools } from './tools/scene.js';
74
75
  import { buildSubagentTools } from './tools/subagent.js';
75
76
  import { createScenePromptSync } from './scene-prompt-sync.js';
@@ -632,7 +633,7 @@ export default {
632
633
  return out;
633
634
  };
634
635
  /**
635
- * `how` 行点名过的工具里,**这个 agent 手里没有**的那些(注入器每步同步问一次)。
636
+ * 这条工具**在这个 agent 手里吗** —— 注入侧(`how` 行、两个目录的尾句)与工具回执共用的一份。
636
637
  *
637
638
  * 两条路并起来,缺一条框架文本就会说谎:
638
639
  * ① `toolTableHidden()` —— 兼容页「模型工具表」的全局开关。**必须留着**:它读的是设置
@@ -643,21 +644,33 @@ export default {
643
644
  * scope 上(`dsh-subagent/lib/index.js:522`),全局开关表里看不到它 —— 于是人设砍掉
644
645
  * `skill_manager_read` 之后,技能目录照注、`how` 行照样点名一个模型手里没有的工具。
645
646
  * 拿不到 agent 时跳过:`get(name, undefined)` 问的是**全局视图**,会得出相反结论。
647
+ *
648
+ * 为什么要有这一份(2026-10-14 对抗性审查 P0-1/P1-8):此前只有全局开关一条路,于是
649
+ * `mcp_manager_switch` 的 `listHint`、技能目录与子智能体目录的尾句都会点名一条**这个模型
650
+ * 手里没有的**工具。它是这两处的唯一判据 —— 工具侧经 `toolVisibleFor` 依赖拿,注入侧经
651
+ * `injectHiddenToolsFor` 拿,同一个函数,不定义第二份。
646
652
  */
647
- const howHiddenToolsFor = (agent) => {
648
- const hidden = new Set(toolTableHidden());
653
+ const toolVisibleFor = (name, agent) => {
654
+ if (toolTableHidden().has(name))
655
+ return false;
649
656
  if (agent === null || (typeof agent !== 'object' && typeof agent !== 'function'))
650
- return hidden;
651
- const scope = agent;
652
- for (const name of HOW_NAMED_TOOLS) {
653
- if (hidden.has(name))
654
- continue;
655
- try {
656
- if (tools.get(name, scope) === undefined)
657
- hidden.add(name);
658
- }
659
- catch { /* 读不到 = 按还在手里算 */ }
657
+ return true;
658
+ try {
659
+ return tools.get(name, agent) !== undefined;
660
660
  }
661
+ catch {
662
+ return true; /* 读不到 = 按还在手里算 */
663
+ }
664
+ };
665
+ /**
666
+ * `how` 行点名过的工具里,**这个 agent 手里没有**的那些(注入器每步同步问一次)。
667
+ * 判据就是上面那份 `toolVisibleFor`,这里只把它收成一份集合交给注入框架。
668
+ */
669
+ const injectHiddenToolsFor = (agent) => {
670
+ const hidden = new Set(toolTableHidden());
671
+ for (const name of HOW_NAMED_TOOLS)
672
+ if (!toolVisibleFor(name, agent))
673
+ hidden.add(name);
661
674
  return hidden;
662
675
  };
663
676
  // ---------- 注入通道(场景 / 记忆 / MCP / 技能 / 子智能体 / 提示词,各一条消息)----------
@@ -674,8 +687,10 @@ export default {
674
687
  const subagentCatalog = createSubagentCatalog({
675
688
  list: () => subagentService.list(),
676
689
  sceneLists: subagentSceneLists,
677
- // 人在「兼容」页把 `subagent_manager_list` 关掉后,截断提示里那句「用 X 查」就是假的。
678
- listToolVisible: () => !toolTableHidden().has('subagent_manager_list'),
690
+ // 人在「兼容」页把 `subagent_manager_list` 关掉、或某个人设用 `toolsDeny` 砍掉它之后,
691
+ // 截断提示里那句「用 X 查」就是假的。判据按 **agent**(2026-10-14 对抗性审查 P1-8):
692
+ // 与 `how` 行、MCP 回执同一份 `toolVisibleFor`,不再只看全局开关。
693
+ listToolVisible: (agent) => toolVisibleFor('subagent_manager_list', agent),
679
694
  });
680
695
  // ⚠️ 取数必须用 mcpmRowsWithNotes(false):true 会带上**未打码**的 url/headers/env(含明文密钥)。
681
696
  // 技能目录与提示词两个域是**官方载体的兜底**:预设挂得到官方
@@ -683,7 +698,9 @@ export default {
683
698
  // CARRIER_FACT_OF),挂不到(极简)时才由这里送 —— 所以极简下也能按开关决定要不要。
684
699
  const skillCatalog = createSkillCatalog({
685
700
  state: () => skillsService.ops['skill-state']({}),
686
- listToolVisible: () => !toolTableHidden().has('skill_manager_list'),
701
+ // 与子智能体目录同一份判据(2026-10-14 对抗性审查 P1-8):只看全局开关时,人设用
702
+ // `toolsDeny` 砍掉 `skill_manager_list` 之后,技能目录尾句照样点名一条它手里没有的工具。
703
+ listToolVisible: (agent) => toolVisibleFor('skill_manager_list', agent),
687
704
  });
688
705
  // 三份界面设置侧车(inject / scene / slash)拆到 ./settings-sidecars.ts:读走 3 秒 TTL 缓存,
689
706
  // 写共用上面那条串行写锁。工厂只在这里调用一次,三份缓存仍是「每次 apply 一份」。
@@ -733,7 +750,7 @@ export default {
733
750
  // 记忆(**内容**):各场景下的条目。与场景段同源、同一条 `applicableTo`。
734
751
  { key: 'memory', name: 'tool-management:memory', label: '记忆', form: 'snapshot', text: () => memoriesService.memoryText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
735
752
  { key: 'mcp', name: 'tool-management:mcp-state', label: 'MCP 服务器', form: 'catalog', text: () => mcp.stateCatalog.text() },
736
- { key: 'skills', name: 'tool-management:skill-catalog', label: '技能目录', form: 'catalog', text: () => skillCatalog.text() },
753
+ { key: 'skills', name: 'tool-management:skill-catalog', label: '技能目录', form: 'catalog', text: (agent) => skillCatalog.text(agent) },
737
754
  {
738
755
  key: 'subagents',
739
756
  name: 'tool-management:subagents',
@@ -746,7 +763,7 @@ export default {
746
763
  ],
747
764
  settings: () => injectSettingsSync(),
748
765
  // 工具表开关:`how` 行里点名工具的那几句要跟着它换话术(关掉的不点名)。
749
- hiddenTools: howHiddenToolsFor,
766
+ hiddenTools: injectHiddenToolsFor,
750
767
  factsFor: (agent) => candidates.presetFactsForAgent(agent),
751
768
  // 令牌门禁(2026-09-19,用户裁定「宿主侧硬拦截」):令牌**在生效**(`TOKEN !== ''`,
752
769
  // 关掉或没配都是空串)而本次启动还没有人验过 ⇒ 这一步不放行,宿主把 turn 收成
@@ -1506,7 +1523,7 @@ export default {
1506
1523
  // 清空流水。清完这一条 op 自己会被下面的包装层记进新流水("谁清过"也留痕)。
1507
1524
  'audit-clear': async () => await clearAudit(),
1508
1525
  'skill-open': skillOpen,
1509
- // MCP 域 16 个 op(实现在 ./mcp/manager.ts)
1526
+ // MCP 域 17 个 op(实现在 ./mcp/manager.ts)
1510
1527
  ...mcp.ops,
1511
1528
  // 技能管理 ops(由 ./skills/service.js 提供):skill-state / skill-detail /
1512
1529
  // skill-browse / skill-enable / skill-disable / skill-source-enable /
@@ -1558,7 +1575,7 @@ export default {
1558
1575
  // -toggle / -remove 与回收站三条。它**不**碰 AGENTS.md、不进场景、不进注入。
1559
1576
  ...quickPromptOps,
1560
1577
  // 归档会话域(ops/history.ts 提供):history-list / history-sessions /
1561
- // history-export-defaults / dir-list / history-archive / history-archive-batch /
1578
+ // history-export-defaults / dir-list /
1562
1579
  // history-unarchive / history-delete / history-unarchive-batch / history-delete-batch /
1563
1580
  // history-workspace-register / history-import / bundle-export / history-export /
1564
1581
  // history-retention-get / history-retention-set。
@@ -2179,6 +2196,10 @@ export default {
2179
2196
  syncSwitchToScene,
2180
2197
  // 错误句里点名工具前要先问一句(判据与两个目录的 `listToolVisible` 同一份缓存)。
2181
2198
  toolVisible: (name) => !toolTableHidden().has(name),
2199
+ // 同上一句,但把**该 agent 的工具限制**也算进去(2026-10-14 对抗性审查 P0-1/P1-8):
2200
+ // 人设用 `toolsDeny` 砍掉 `mcp_manager_list` 之后,只看全局开关就会让回执点名一条
2201
+ // 这个模型手里没有的工具。与注入侧 `injectHiddenToolsFor` 同一个函数。
2202
+ toolVisibleFor: (name, agent) => toolVisibleFor(name, agent),
2182
2203
  reachNoticeForAgent,
2183
2204
  presetRoster: candidates.presetRoster,
2184
2205
  injectNoticeOptions,
@@ -2245,6 +2266,21 @@ export default {
2245
2266
  toolFilterFor: subagentToolFilterFor,
2246
2267
  failures: subagentFailures,
2247
2268
  });
2269
+ // 删除 / 回收站族(tools/remove.ts,2026-10-15):五个域各一条 `*_manager_remove`。
2270
+ // 此前模型只能停用、不能删除。删除一律进回收站(可恢复);**不做永久删除**(低频且
2271
+ // 不可恢复,留给面板)。`sceneRemove` 传 handlers 里那份(`withAgentsMdSync` 包过),
2272
+ // 与 `sceneActivate` 同一条纪律:直调 `memoriesService.ops` 会静默漏掉 AGENTS.md 那一步。
2273
+ buildRemoveTools({
2274
+ ...toolDeps,
2275
+ memoryOps: auditOps(memoriesService.ops),
2276
+ skillOps: auditOps(skillsService.ops),
2277
+ subagentOps: auditOps(subagentService.ops),
2278
+ promptOps: auditOps(promptOps),
2279
+ // 场景回收站(`scene-trash-list` / `scene-trash-restore`)在 **memoriesService** 的 op 表里
2280
+ // (`archiveService` 只有 scene-mode-get / -set / scene-archive-save 三条)。
2281
+ sceneOps: auditOps(memoriesService.ops),
2282
+ sceneRemove: auditFn('rules-remove-scene', (args) => handlers['rules-remove-scene'](args)),
2283
+ });
2248
2284
  if (typeof ctx.on === 'function') {
2249
2285
  // 三个确认门的统一前裁决。口径(用户裁定,方案 B):会话审批策略为 never(「完全权限」预设)
2250
2286
  // = 用户已在预设层面预先批准一切确认门,**直接放行**并写审计日志留痕。
@@ -106,6 +106,8 @@ export function isToolDisabledIn(map, fullName) {
106
106
  return true;
107
107
  return list.indexOf(parts.tool) >= 0;
108
108
  }
109
+ /** `mcpm-parse-patch` 的输入上限:粘贴框不是文件上传口,几十 KB 之外基本都是误贴。 */
110
+ const PATCH_PARSE_MAX_CHARS = 200_000;
109
111
  export function createMcpManager(deps) {
110
112
  // 外部能力一次解构成局部名:块内代码是逐字搬来的,保持原样最不容易出错。
111
113
  const { readJsonFile, writeJsonFile, ensurePaths, withWriteLock, message, tools, pluginInventory, settings, readPatch, writePatch, memoriesService, wait, } = deps;
@@ -1734,6 +1736,24 @@ export function createMcpManager(deps) {
1734
1736
  const notes = importNotes.length ? { warning: importNotes.join(';') } : {};
1735
1737
  return overwrite ? { ok: true, added, overwritten, skipped, ...notes } : { ok: true, added, skipped, ...notes };
1736
1738
  }
1739
+ /**
1740
+ * 补丁片段解析(0.20.1 导入弹窗):把用户粘贴的 dsh 补丁文本读成条目 —— 不读盘、不写盘。
1741
+ *
1742
+ * 为什么这件事在宿主侧:客户端那一半跑在浏览器里(`window.__ModuleLoader__`),那里的
1743
+ * `require` 只解析宿主注册过的浏览器模块,`js-yaml` 永远取不到 —— 界面自己解析 YAML 补丁
1744
+ * 是死路。这里走 `parseRows`:纯字符串运算,且正是本插件读真身补丁用的同一份口径(只收
1745
+ * `name === MCP_CLIENT_MODULE` 的 insert 子条目、覆盖块按文件顺序生效)。
1746
+ */
1747
+ async function mcpmParsePatch(args) {
1748
+ const text = String((args && args.text) || '');
1749
+ if (!text.trim())
1750
+ return { ok: false, error: '没有内容' };
1751
+ if (text.length > PATCH_PARSE_MAX_CHARS) {
1752
+ return { ok: false, error: `内容过长(${text.length} 字符,上限 ${PATCH_PARSE_MAX_CHARS}):请只粘贴要导入的那几块` };
1753
+ }
1754
+ const rows = parseRows(text).rows.map((r) => ({ id: r.id, disabled: r.disabled, config: r.config }));
1755
+ return { ok: true, rows };
1756
+ }
1737
1757
  function parseKv(text) {
1738
1758
  const out = {};
1739
1759
  String(text || '').split(/\r?\n/).forEach((line) => {
@@ -1804,6 +1824,7 @@ export function createMcpManager(deps) {
1804
1824
  'mcpm-compact': mcpmCompact,
1805
1825
  'mcpm-export': mcpmExport,
1806
1826
  'mcpm-import': mcpmImport,
1827
+ 'mcpm-parse-patch': mcpmParsePatch,
1807
1828
  },
1808
1829
  readDisabledTools,
1809
1830
  readKnownMcpTools,
@@ -122,7 +122,7 @@ export function renderMcpStateSection(rows, opts = {}) {
122
122
  ...shownUsable.map((r) => lineOf(r, liveEnabledOf(r), false)),
123
123
  ...shownOffline.map((r) => lineOf(r, knownOf(r), true)),
124
124
  ];
125
- // 只有清单:标题(`# 本机 MCP 服务器的当前状态`)由注入通道的框架承担
125
+ // 只有清单:标题(`# 当前 MCP 服务器`)由注入通道的框架承担
126
126
  // (2026-09-18 起,见 context-inject.ts 的 DOMAIN_FRAME)—— 同一件事只有一个出处,
127
127
  // 框架已经写了标题,正文再写一遍就是每步多付一行 token。
128
128
  const out = [...lines];
@@ -449,10 +449,10 @@ export function createArchiveEngine(deps) {
449
449
  catch (e) {
450
450
  return { ok: false, error: `读取技能启停现状失败:${msg(e)}` };
451
451
  }
452
- const sourcesOn = [];
453
- const sourcesOff = [];
454
- for (const s of skillsPlan.sourceSwitches)
455
- (s.enabled ? sourcesOn : sourcesOff).push(s.root);
452
+ // 来源级切换(skillsPlan.sourceSwitches)只做不说:它是技能启停的实现细节 ——
453
+ // 勾了技能的来源才启用、一条都没勾就停用。项目来源的 key 是路径哈希
454
+ // (`project-dsh:47d48f408c5edc5e`),目录对用户可能根本不存在、也不是新建技能的
455
+ // 落点,报上预览卡只会被当成故障(用户 2026-10-15 裁定:提醒不显示技能目录)。
456
456
  const stale = [...mcpPlan.stale, ...skillsPlan.stale];
457
457
  // 工具表方案(C1):绑了就说清「会切成哪一份、关几条」。方案在别处被删掉 / 换机器之后
458
458
  // 查不到时 hiddenCount 给 null,界面那句会改成"方案已不存在,这次不会切换"——
@@ -498,7 +498,6 @@ export function createArchiveEngine(deps) {
498
498
  enter: { servers: clip(enterServers) },
499
499
  skills: {
500
500
  on: clip(skillsOn), off: clip(skillsOff),
501
- sourcesOn: clip(sourcesOn), sourcesOff: clip(sourcesOff),
502
501
  },
503
502
  subagents: { on: clip(personas.on), off: clip(personas.off) },
504
503
  quickPrompts,
@@ -63,7 +63,7 @@ export const ATTACHMENT_LIST_MAX = 10;
63
63
  *
64
64
  * 用词:不用「常驻」「注入」这类内部行话(模型没有先验)。
65
65
  * ⚠️ 「用『信息』而不是『记忆』」那条**已被 2026-09-23 第四次裁定推翻**(见下):用户
66
- * 给定的定稿文案里就是「记忆」,而板块标题本来就是「本机当前的记忆」—— 两者同名反而更好,
66
+ * 给定的定稿文案里就是「记忆」,而板块标题本来就是「当前记忆」—— 两者同名反而更好,
67
67
  * 当时那条理由(「记忆」在系统提示词里指代不明)在板块标题定名之后也不成立了。
68
68
  *
69
69
  * 2026-09-17 重写(用户:「当前模式会不重视这些提示词」)。上一版的三个毛病都在**授权**
@@ -110,7 +110,7 @@ export const ATTACHMENT_LIST_MAX = 10;
110
110
  * 时以实际情况为准、④ 与用户当场说的冲突时以用户为准、⑤ 无关不必提 + 完整性声明(防探测,
111
111
  * 见上,那半句是整句里唯一不能删的)。77 → 48 字符(219 → 144 字节),而它**每个场景块各来
112
112
  * 一次**,所以省的是 N 倍。删掉的只有修饰与重复:「以下是用户为本机写的记录」→「本机记录」
113
- * (板块标题 `# 本机当前的记忆` 已经把"这是本机的记忆"说过了),两句"冲突时以…为准"
113
+ * (板块标题 `# 当前记忆` 已经把"这是本机的记忆"说过了),两句"冲突时以…为准"
114
114
  * 合成一句。
115
115
  *
116
116
  * 2026-09-23 第四次裁定(用户直接给定稿文案,逐字):
@@ -57,6 +57,8 @@ export const OP_REGISTRY = Object.freeze({
57
57
  // 会停在启用态 —— 是写不是读。(0.6.0 / 0.7.0 各有漏列前科,见文件头。)
58
58
  'mcpm-tools-refresh': { write: true },
59
59
  'mcpm-tools': { readonly: true },
60
+ // 把粘贴的补丁片段读成条目:纯文本运算,不读盘也不写盘(客户端在浏览器里解析不了 YAML)。
61
+ 'mcpm-parse-patch': { readonly: true },
60
62
  // ── 技能域(22,含内联的 skill-open)───────────────────────────────────────
61
63
  'skill-state': { annotatesLock: true },
62
64
  'skill-detail': { readonly: true },
@@ -72,8 +74,10 @@ export const OP_REGISTRY = Object.freeze({
72
74
  'skill-prefer': { serviceWrite: true, frozen: true },
73
75
  'skill-unprefer': { serviceWrite: true, frozen: true },
74
76
  'skill-create': { serviceWrite: true, frozen: true },
75
- // 改写 hub 里已存在的那一份技能(`skill_manager_save` 的改分支)。与 create 同规格:
76
- // 都是往 hub 落文件。它**不动**官方根里的技能 —— 那层边界在 service 的胜出者判定里。
77
+ // 改写本插件自己写过的那一份技能(`skill_manager_save` 的改分支)。与 create 同规格:
78
+ // 落点只限本插件可写的两个根 —— hub(导入落点)+ dsh(新建默认落点,2026-10-15 裁定,
79
+ // 见 service.ts 的 PLUGIN_WRITABLE_ROOTS)。它**不动**官方根里的技能 —— 那层边界在
80
+ // service 的胜出者判定里。
77
81
  'skill-update': { serviceWrite: true, frozen: true },
78
82
  'skill-import': { serviceWrite: true, frozen: true },
79
83
  'skill-upload': { serviceWrite: true, frozen: true },
@@ -171,16 +175,14 @@ export const OP_REGISTRY = Object.freeze({
171
175
  'quickprompt-remove': { write: true, frozen: true },
172
176
  'quickprompt-trash-restore': { write: true, frozen: true },
173
177
  'quickprompt-trash-delete': { write: true, frozen: true },
174
- // ── 历史会话域(16)────────────────────────────────────────────────────────
178
+ // ── 历史会话域(14)────────────────────────────────────────────────────────
175
179
  'history-list': { readonly: true },
176
180
  'history-sessions': { readonly: true },
177
181
  'history-export-defaults': { readonly: true },
178
182
  'history-retention-get': { readonly: true },
179
183
  'dir-list': { readonly: true },
180
- 'history-archive': { write: true },
181
184
  'history-unarchive': { write: true },
182
185
  'history-delete': { write: true },
183
- 'history-archive-batch': { write: true },
184
186
  'history-unarchive-batch': { write: true },
185
187
  'history-delete-batch': { write: true },
186
188
  'history-retention-set': { write: true },
package/lib/ops/memory.js CHANGED
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // ops 表里的包装(enqueueMutation / runWrite)**留在 service.ts**:本文件只导出裸 op,
7
7
  // 包装归属集中在一处,改门禁时一眼看全。
8
- import { lstat, mkdir, readFile, rm } from 'node:fs/promises';
8
+ import { lstat, mkdir, readFile, rename, rm } from 'node:fs/promises';
9
9
  import { basename, dirname, join } from 'node:path';
10
10
  import { parseSkillDoc, unquote } from '../skills/core.js';
11
11
  import { expandUploads, planMemoryImport } from '../imports/upload.js';
@@ -535,15 +535,46 @@ export function buildMemoryOps(rc) {
535
535
  await copyIntoMemoriesTrash(located, parts.group, parts.name);
536
536
  await rm(join(memoriesRoot, parts.group, parts.name + '.md'), { force: true });
537
537
  }
538
+ else if (located.kind === 'bundle' && newName !== parts.name) {
539
+ // bundle 改名 = **目录连同附件一起改**(2026-10-14 对抗性审查 P0-4)。
540
+ //
541
+ // 此前这条落到下面的 else:只把 frontmatter/正文写回**旧**路径,而索引照新名迁移 ——
542
+ // 实测(临时目录内复现)结果是索引里多一条指向不存在路径的记录(`rules-read` 报
543
+ // notFound),真实条目反而丢了索引记录,于是它承载的 `enabled` / `order` / `tags`
544
+ // 一起丢失:**一条停用的 bundle 记忆改个名就被静默重新启用**(重新进入每轮注入)。
545
+ // 面板的编辑框走同一个 op(`client/47-memory.js` 的 `submitUpdate`),所以两边同一条路。
546
+ //
547
+ // 形状与 flat 改名对齐:旧形态先整份进回收站(可恢复),再搬目录,最后把正文改名。
548
+ const oldDir = located.entryPath;
549
+ const newDir = join(memoriesRoot, parts.group, newName);
550
+ // 只改大小写时新旧是同一个目录(Windows / macOS 不区分大小写),不能按"已存在"拒。
551
+ const caseOnly = newName.toLowerCase() === parts.name.toLowerCase();
552
+ if (!caseOnly && await pathExists(newDir)) {
553
+ return fail('error.rules.exists', `同名记忆已存在(bundle):${parts.group}/${newName}`);
554
+ }
555
+ // 正文文件名:新建的 bundle 一律 `<名>.md`;旧数据可能是 `SKILL.md`,那份**保持原名不动**
556
+ // (发现侧两个候选都认,改名时顺手动它只会把兼容数据弄坏)。
557
+ const keepLegacyDoc = basename(located.docPath) === LEGACY_BUNDLE_DOC;
558
+ await copyIntoMemoriesTrash(located, parts.group, parts.name);
559
+ await rename(oldDir, newDir);
560
+ const movedDoc = join(newDir, basename(located.docPath));
561
+ const targetDoc = join(newDir, keepLegacyDoc ? LEGACY_BUNDLE_DOC : bundleDocName(newName));
562
+ if (movedDoc !== targetDoc)
563
+ await rename(movedDoc, targetDoc);
564
+ await writeFileAtomically(targetDoc, serialize());
565
+ }
538
566
  else {
539
- // bundle 不 rename 目录:只更新 frontmatter/正文
567
+ // bundle 不改名:只更新 frontmatter/正文
540
568
  await writeFileAtomically(located.docPath, serialize());
541
569
  }
542
570
  // 索引:改名则迁移记录(保留 order/tags/enabled 等),否则原地更新。
571
+ // 判据只看"名字变没变",不再附加 `index.rules[id]` 存在 —— 磁盘上由外部新增、索引里
572
+ // 还没有记录的条目(发现侧会认它)改名时,旧判据会走到 else 把记录写回**旧名**,
573
+ // 于是新名下没有记录、旧名下多一条悬空的(2026-10-14 对抗性审查 P0-4 同族)。
543
574
  const index = await readIndex(stateDir);
544
575
  const newId = `${parts.group}/${newName}`;
545
- if (newId !== id && index.rules[id]) {
546
- index.rules[newId] = { ...index.rules[id], updatedAt: new Date().toISOString() };
576
+ if (newId !== id) {
577
+ index.rules[newId] = { ...(index.rules[id] || {}), updatedAt: new Date().toISOString() };
547
578
  delete index.rules[id];
548
579
  }
549
580
  else {
@@ -565,6 +596,13 @@ export function buildMemoryOps(rc) {
565
596
  if (typeof (args && args.enabled) !== 'boolean') {
566
597
  return fail('error.rules.invalidArgs', '缺少参数:enabled 必须是布尔值(toggle 只按传入值写入,不做"翻转"推断)');
567
598
  }
599
+ // 不存在的 id 必须拒绝(2026-10-15 smoke-test P1-4):`parseId` 只校验 `<场景>/<名字>`
600
+ // 的形状,原先据此直接 `index.rules[id] = {...}` —— 于是一次拼错的 id 会**写进一条幽灵
601
+ // 索引**(磁盘上没有正文、清单里也列不出来,索引里却多一条 enabled),调用方还收到 ok。
602
+ // 判据与 `rulesRead` / `rulesUpdate` 同源:`locateRule` 在磁盘上找得到才算存在。
603
+ const located = await locateRule(parts.group, parts.name);
604
+ if (!located)
605
+ return fail('error.rules.notFound', `规则不存在:${id}`);
568
606
  const index = await readIndex(stateDir);
569
607
  const idxEntry = index.rules[id] || {};
570
608
  const enabled = args.enabled === true;
@@ -514,9 +514,24 @@ export function buildSceneRecordOps(rc) {
514
514
  async function sceneTrashList() {
515
515
  return { ok: true, trash: await listTrashEntries('scenes') };
516
516
  }
517
+ /**
518
+ * 同名的场景里第一个空出来的退号名:`甲` → `甲(1)` → `甲(2)`…(全角括号,与界面
519
+ * `uniqueName` 同一写法)。超过段名长度上限的候选直接跳过 —— 退号名本身也得是合法段名。
520
+ */
521
+ function nextFreeSceneName(index, name) {
522
+ for (let i = 1; i <= 999; i += 1) {
523
+ const candidate = `${name}(${i})`;
524
+ if (!isValidGroupPath(candidate))
525
+ return null;
526
+ if (!index.scenes?.[candidate])
527
+ return candidate;
528
+ }
529
+ return null;
530
+ }
517
531
  /**
518
532
  * 从回收站恢复一个场景:重建记忆目录 + 写回记录与档案。
519
- * 同名场景已存在时**拒绝**(绝不覆盖既有场景)。
533
+ * 同名场景已存在时默认**拒绝**(绝不覆盖既有场景);带 `dedupe: true` 时退号恢复
534
+ * (`甲` → `甲(1)`,用户 2026-10-14 裁定四个域都要能退号)。
520
535
  *
521
536
  * 场景名按**完整路径**寻址(2026-09-30 审查 P2-4,与删除侧同口径):回收站里的名字来自
522
537
  * 当初删掉的那条记录,多段名同样得能恢复回来 —— 否则删得掉、恢复不了,数据就卡在回收站里。
@@ -526,12 +541,26 @@ export function buildSceneRecordOps(rc) {
526
541
  const entry = await readTrashEntry('scenes', id);
527
542
  if (!entry)
528
543
  return fail('error.rules.notFound', `回收站条目不存在:${id}`);
529
- const name = String(entry.name || '').trim();
530
- if (!isValidGroupPath(name))
531
- return fail('error.rules.invalidGroup', `回收站里的场景名非法:${name || '(空)'}`);
544
+ const oldName = String(entry.name || '').trim();
545
+ if (!isValidGroupPath(oldName))
546
+ return fail('error.rules.invalidGroup', `回收站里的场景名非法:${oldName || '(空)'}`);
532
547
  const index = await readIndex(stateDir);
533
- if (index.scenes?.[name])
534
- return fail('error.rules.invalidGroup', `无法恢复,同名场景已存在:${name}`);
548
+ let name = oldName;
549
+ if (index.scenes?.[name]) {
550
+ // 「同名先问一句」(用户 2026-10-14):拒绝时把候选名字一起回(`params.next`),
551
+ // 界面弹窗才能如实写出"确认后会叫 `甲(1)`";用户确认后带 `dedupe: true` 重来。
552
+ const next = nextFreeSceneName(index, name);
553
+ if (args?.dedupe !== true || !next) {
554
+ // 专用错误码(四个回收站统一):界面按它认出"这是撞名"并弹确认窗。原先这里复用
555
+ // `error.rules.invalidGroup`,那一条同时还表示"名字非法",界面分不出来。
556
+ return fail('error.dup.conflict', `已存在同名场景:${name}`, {
557
+ name,
558
+ ...(next ? { next } : {}),
559
+ });
560
+ }
561
+ name = next;
562
+ }
563
+ const renamed = name !== oldName;
535
564
  const dir = join(memoriesRoot, name);
536
565
  // 恢复是**写入**方向:落点若是指向根外的目录链接,`mkdir` 会顺着链接成功、随后
537
566
  // `moveOutOfTrash` 把记忆文件写到**根外**去。与删除侧同一个根因(缺 realpath 根内断言),
@@ -590,17 +619,22 @@ export function buildSceneRecordOps(rc) {
590
619
  // 记忆条目随文件一起回来(删除时存进 `data.rules`,见 `rulesRemoveScene`)——
591
620
  // 否则恢复出来的记忆全部按 `enabled ?? true` 重置,用户停用的会复活(审查 P1-1)。
592
621
  // **只补不覆盖**:同名键若已存在以现有为准(上面已拒绝同名场景,正常情况下不会撞)。
622
+ //
623
+ // 退号恢复时这批记忆的 key 也要**跟着改名**:索引里的 key 是 `<场景>/<名>`,照抄旧 key
624
+ // 会让它们挂在旧场景名上 —— 文件躺在 `甲(1)/` 里、索引却指向 `甲/`,恢复出来的场景
625
+ // 一条记忆都看不见。前缀不匹配的(理论上不会有)原样保留,宁可留着也不猜。
593
626
  if (data.rules && typeof data.rules === 'object' && !Array.isArray(data.rules)) {
594
627
  const rules = index.rules || (index.rules = {});
595
628
  for (const [ruleId, ruleEntry] of Object.entries(data.rules)) {
596
- if (rules[ruleId] === undefined)
597
- rules[ruleId] = ruleEntry;
629
+ const mapped = renamed && ruleId.startsWith(oldName + '/') ? name + ruleId.slice(oldName.length) : ruleId;
630
+ if (rules[mapped] === undefined)
631
+ rules[mapped] = ruleEntry;
598
632
  }
599
633
  }
600
634
  await writeIndex(stateDir, index);
601
635
  await purgeTrashEntry('scenes', id);
602
636
  invalidateSnapshot();
603
- return { ok: true, name, restoredFiles: restored.length };
637
+ return { ok: true, name, renamed, restoredFiles: restored.length };
604
638
  }
605
639
  /** 永久删除一条场景回收站条目。 */
606
640
  async function sceneTrashDelete(args) {
@@ -4,10 +4,13 @@
4
4
  // 整张表上(见 index.ts)。
5
5
  //
6
6
  // 本域的 op 名:history-list / history-sessions / history-export-defaults / dir-list /
7
- // history-archive / history-archive-batch / history-unarchive / history-delete /
7
+ // history-unarchive / history-delete /
8
8
  // history-unarchive-batch / history-delete-batch / history-workspace-register /
9
9
  // history-import / bundle-export / history-export / history-retention-get /
10
10
  // history-retention-set。
11
+ //
12
+ // 归档(archive)不在这里:那是宿主的原生入口(官方侧栏),本插件只经 `domain/changed`
13
+ // 事件同步归档时刻(见 src/sessions/bridge.ts),不替用户发起归档。
11
14
  import { createHash, randomUUID } from 'node:crypto';
12
15
  import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
13
16
  import { basename, dirname, join } from 'node:path';
@@ -46,7 +49,7 @@ async function xdgDesktopDir() {
46
49
  export function buildSessionOps(deps) {
47
50
  return {
48
51
  // History(归档会话管理)ops。只读:history-list / history-retention-get;
49
- // 写:history-archive / history-unarchive / history-delete / history-retention-set。
52
+ // 写:history-unarchive / history-delete / history-retention-set。
50
53
  // workspaceRegistry 缺失(补丁未生效)时返回明确错误,不崩页面。
51
54
  'history-list': async () => {
52
55
  const registry = deps.getSessionsRegistry();
@@ -204,43 +207,6 @@ export function buildSessionOps(deps) {
204
207
  return { ok: false, error: deps.message(e) };
205
208
  }
206
209
  },
207
- 'history-archive': async (args) => {
208
- const registry = deps.getSessionsRegistry();
209
- if (!registry)
210
- return { ok: false, error: '归档服务未挂载' };
211
- const sessionId = String((args && args.sessionId) || '').trim();
212
- if (!sessionId)
213
- return { ok: false, error: '缺少 sessionId' };
214
- try {
215
- await registry.archiveSession(sessionId);
216
- return { ok: true, sessionId };
217
- }
218
- catch (e) {
219
- return { ok: false, error: deps.message(e) };
220
- }
221
- },
222
- // 批量归档(导出弹窗「归档所选」):把选中的会话收进 History,纳入保留期管理。
223
- 'history-archive-batch': async (args) => {
224
- const registry = deps.getSessionsRegistry();
225
- if (!registry)
226
- return { ok: false, error: '归档服务未挂载' };
227
- const rawIds = (args && args.sessionIds) || [];
228
- const ids = Array.isArray(rawIds) ? rawIds.map((s) => String(s).trim()).filter(Boolean) : [];
229
- if (!ids.length)
230
- return { ok: false, error: '请至少选择一个会话' };
231
- const archived = [];
232
- const failed = [];
233
- for (const sessionId of ids) {
234
- try {
235
- await registry.archiveSession(sessionId);
236
- archived.push(sessionId);
237
- }
238
- catch (e) {
239
- failed.push({ sessionId, error: deps.message(e) });
240
- }
241
- }
242
- return { ok: true, archived, failed };
243
- },
244
210
  'history-unarchive': async (args) => {
245
211
  const registry = deps.getSessionsRegistry();
246
212
  if (!registry)