dsh-plugin-tool-management 0.16.6 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +20 -4
  3. package/README_EN.md +20 -4
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +59 -0
  21. package/lib/audit-log.js +6 -2
  22. package/lib/client.js +1615 -148
  23. package/lib/compat/capability-specs.js +321 -0
  24. package/lib/compat/host-probe.js +261 -0
  25. package/lib/compat/host-roots.js +168 -0
  26. package/lib/compat/probe-types.js +6 -0
  27. package/lib/compat/probe.js +54 -714
  28. package/lib/context-inject-contract.js +114 -0
  29. package/lib/context-inject-frame.js +147 -0
  30. package/lib/context-inject-select.js +127 -0
  31. package/lib/context-inject-surface.js +97 -0
  32. package/lib/context-inject.js +57 -459
  33. package/lib/host-names.js +150 -0
  34. package/lib/http-api.js +229 -0
  35. package/lib/hub.js +84 -11
  36. package/lib/imports/parsers.js +40 -15
  37. package/lib/index.js +235 -843
  38. package/lib/mcp/inspect-probe.js +199 -0
  39. package/lib/mcp/loader-token.js +26 -0
  40. package/lib/mcp/manager.js +267 -558
  41. package/lib/mcp/patch-yaml.js +207 -40
  42. package/lib/mcp/secret-guard.js +122 -16
  43. package/lib/mcp/tool-visibility.js +388 -0
  44. package/lib/memories/archive-engine.js +163 -16
  45. package/lib/memories/archive.js +10 -1
  46. package/lib/memories/discovery.js +261 -0
  47. package/lib/memories/export-plan.js +42 -0
  48. package/lib/memories/index-io.js +125 -12
  49. package/lib/memories/memories-types.js +1 -0
  50. package/lib/memories/service.js +167 -78
  51. package/lib/memories/snapshot.js +41 -260
  52. package/lib/op-registry.js +72 -8
  53. package/lib/ops/compat.js +17 -2
  54. package/lib/ops/prompts.js +18 -5
  55. package/lib/ops/quick-prompts.js +26 -0
  56. package/lib/ops/scene-records.js +157 -10
  57. package/lib/ops/scene-sync.js +71 -1
  58. package/lib/ops/sessions.js +63 -13
  59. package/lib/ops/snapshot.js +123 -24
  60. package/lib/ops/trash.js +45 -9
  61. package/lib/prompts/quick-service.js +211 -0
  62. package/lib/prompts/service.js +7 -2
  63. package/lib/request-gate.js +115 -56
  64. package/lib/scene-linkage.js +184 -0
  65. package/lib/scene-prompt-sync.js +46 -7
  66. package/lib/scenes/candidates.js +21 -3
  67. package/lib/sessions/archive-schemas.js +121 -0
  68. package/lib/sessions/path-key.js +46 -0
  69. package/lib/sessions/workspace.js +45 -168
  70. package/lib/settings-sidecars.js +143 -0
  71. package/lib/skills/core-types.js +8 -0
  72. package/lib/skills/core-util.js +81 -0
  73. package/lib/skills/core.js +97 -2297
  74. package/lib/skills/dir-watch.js +71 -0
  75. package/lib/skills/frontmatter.js +96 -0
  76. package/lib/skills/homes.js +14 -0
  77. package/lib/skills/import-pipeline.js +231 -0
  78. package/lib/skills/manager-state.js +800 -0
  79. package/lib/skills/official.js +269 -0
  80. package/lib/skills/provider-registration.js +89 -0
  81. package/lib/skills/service.js +4 -234
  82. package/lib/skills/skill-roots.js +287 -0
  83. package/lib/skills/skill-trash.js +265 -0
  84. package/lib/skills/system-trash.js +97 -0
  85. package/lib/skills/upload-pipeline.js +374 -0
  86. package/lib/slash-settings.js +25 -0
  87. package/lib/subagents/persona-parse.js +312 -0
  88. package/lib/subagents/persona-types.js +5 -0
  89. package/lib/subagents/persona-write.js +93 -0
  90. package/lib/subagents/run-provider.js +159 -0
  91. package/lib/subagents/service.js +20 -639
  92. package/lib/subagents/tool-filter.js +98 -0
  93. package/lib/token-config.js +240 -0
  94. package/lib/tool-table-switch.js +163 -0
  95. package/lib/tools/scene.js +40 -9
  96. package/package.json +3 -2
@@ -58,6 +58,24 @@ export function createArchiveEngine(deps) {
58
58
  const byRoot = new Map(current.map((s) => [s.root, s.enabled]));
59
59
  return rows.filter((x) => byRoot.has(x.root) && byRoot.get(x.root) !== x.enabled);
60
60
  }
61
+ /**
62
+ * 快捷提示词段 → 应用(勾了的启用、未勾的停用),只写**状态需要变化**的那几条。
63
+ *
64
+ * 只在档案**定义了**这一段时被调用:未定义 = 这一域完全不碰(与 mcp/skills/subagents 的
65
+ * 「未定义 = 全关」不同,见 `SceneArchive.quickPrompts` 的注释)。
66
+ */
67
+ async function applyQuickPrompts(list) {
68
+ const [known, current] = await Promise.all([deps.knownQuickPrompts(), deps.quickPromptStates()]);
69
+ const switches = [];
70
+ for (const id of known) {
71
+ const want = list.indexOf(id) >= 0;
72
+ if (current[id] === want)
73
+ continue;
74
+ switches.push({ id, enabled: want });
75
+ }
76
+ if (switches.length)
77
+ await deps.applyQuickPromptSwitches(switches);
78
+ }
61
79
  /**
62
80
  * 还原到快照(退出模式与失败回滚共用同一条路径):
63
81
  * MCP 停用表**按快照原文整体回写**——模式自己写进去的键(未勾服务器的 ['*'])必须随之消失,
@@ -67,6 +85,20 @@ export function createArchiveEngine(deps) {
67
85
  * 上层两行(服务器级 / 来源级)按**全量**快照还原 —— 只回写与现状不同的行,见
68
86
  * mcpServerRowsToRestore / skillSourceRowsToRestore。
69
87
  */
88
+ /**
89
+ * 两份名单是不是同一批名字(顺序无关)。
90
+ *
91
+ * 现在只有工具表那一档用它:档案绑的方案与当前现状是同一份关停名单时,进入这一步其实
92
+ * 什么都没改 —— 回执要不要提「工具表切到方案 X」就取决于这一句。
93
+ */
94
+ function sameNameList(a, b) {
95
+ if (!a || !b)
96
+ return false;
97
+ if (a.length !== b.length)
98
+ return false;
99
+ const set = new Set(a);
100
+ return b.every((x) => set.has(x));
101
+ }
70
102
  async function restoreSnapshot(snapshot) {
71
103
  // 顺序与进入时**相反**:先恢复服务器级 / 来源级,再恢复工具级 / 技能级 ——
72
104
  // 来源还关着的时候写技能级策略会被吞掉(`skills/core.js` 的 sourceEnabled 判定)。
@@ -108,11 +140,48 @@ export function createArchiveEngine(deps) {
108
140
  await deps.applySubagentSwitches(personasOn.map((n) => ({ name: n, enabled: true })));
109
141
  }
110
142
  }
143
+ // 快捷提示词(0.18.0):按快照的全量映射还原,但**只回写与现状不同的那几条** ——
144
+ // 与 mcpServerRowsToRestore 同一条理由:没变过的行不该白写一遍侧车。读不到现状就退回全量
145
+ // 回写(宁可多写几行,不可少还原)。老快照没有这一栏 → 一个字都不动(进场景时也没碰过)。
146
+ // 场景期间被删掉的条目由实现侧静默跳过:没有可还原的状态,硬写会把"退出场景"卡成报错。
147
+ const quickStates = snapshot.quickPromptsAll;
148
+ if (quickStates && typeof quickStates === 'object') {
149
+ const rows = Object.entries(quickStates);
150
+ if (rows.length) {
151
+ let current = null;
152
+ try {
153
+ current = await deps.quickPromptStates();
154
+ }
155
+ catch {
156
+ current = null;
157
+ }
158
+ const switches = rows
159
+ .filter(([id, on]) => current === null || current[id] !== (on === true))
160
+ .map(([id, on]) => ({ id, enabled: on === true }));
161
+ if (switches.length)
162
+ await deps.applyQuickPromptSwitches(switches);
163
+ }
164
+ }
111
165
  // 模型工具表(C1):按快照原值整体回写。老快照没有这一栏 → 一个字都不动(与进场景前一致)。
166
+ // 先与现状比一次:绑的方案与进场景前本来就是同一份名单时,这一步什么都没改 —— 既不该白跑
167
+ // 一遍"落盘 → 重排可见性 → 两个目录重算",回执更不该跟着说「工具表按快照还原」(用户问
168
+ // "刚才改了什么"时凭空多一项)。读不到现状就照旧回写(宁可多写一遍,不可少还原)。
169
+ let toolTableRestored = false;
112
170
  if (snapshot.toolTableHidden) {
113
- await deps.applyToolTableHidden(snapshot.toolTableHidden);
171
+ let current = null;
172
+ try {
173
+ current = await deps.currentToolTableHidden();
174
+ }
175
+ catch {
176
+ current = null;
177
+ }
178
+ if (current === null || !sameNameList(current, snapshot.toolTableHidden)) {
179
+ await deps.applyToolTableHidden(snapshot.toolTableHidden);
180
+ toolTableRestored = true;
181
+ }
114
182
  }
115
183
  deps.audit?.('scene-restore', '');
184
+ return { toolTable: toolTableRestored };
116
185
  }
117
186
  /**
118
187
  * 失败回滚:运行时还原 + 切片写回;每步失败都记下来,绝不谎报「已回滚」。
@@ -127,7 +196,9 @@ export function createArchiveEngine(deps) {
127
196
  problems.push('运行时还原: ' + msg(e));
128
197
  }
129
198
  try {
130
- await deps.saveSlice(prev);
199
+ // 回滚只还原引擎自己动过的那两片:mode(模式位 + 快照)与 active(记忆收窄)。
200
+ // 不能整片回写 `prev` —— 那是队外读的,`archives` 等字段在窗口期可能已被别人改过。
201
+ await deps.saveSlice(prev, ['mode', 'active']);
131
202
  }
132
203
  catch (e) {
133
204
  problems.push('模式状态写回: ' + msg(e));
@@ -177,6 +248,10 @@ export function createArchiveEngine(deps) {
177
248
  if (entries.length)
178
249
  await deps.applyMcpNotes(entries);
179
250
  }
251
+ // 快捷提示词:段定义了才碰(未定义 = 保持现状)。快照里的原值**不动** ——
252
+ // 退出场景仍回到「进场景前」那批开关,而不是回到上一次档案的勾选。
253
+ if (archive && Array.isArray(archive.quickPrompts))
254
+ await applyQuickPrompts(archive.quickPrompts);
180
255
  // 工具表方案(C1):改档案 = 立刻生效,这一域也得跟上。快照里的原值**不动** ——
181
256
  // 退出场景仍然回到「进场景前」那份名单,而不是回到上一次档案的方案。
182
257
  const reapplyPreset = (archive && archive.toolTablePreset) || '';
@@ -239,6 +314,7 @@ export function createArchiveEngine(deps) {
239
314
  mcpServers: Object.keys(snap.mcp || {}).length,
240
315
  skills: Object.keys(snap.skills || {}).length,
241
316
  subagents: personaStates ? Object.keys(personaStates).length : (snap.subagents ?? []).length + (snap.subagentsOn ?? []).length,
317
+ quickPrompts: Object.keys(snap.quickPromptsAll || {}).length,
242
318
  mcpNotes: (snap.mcpNotes ?? []).length,
243
319
  },
244
320
  // 记忆启用集**不随退出恢复**(设计 §2.2):退出后仍是这个场景,界面别把它写成"会还原"。
@@ -283,6 +359,27 @@ export function createArchiveEngine(deps) {
283
359
  catch (e) {
284
360
  return { ok: false, error: `读取人设开关状态失败:${msg(e)}` };
285
361
  }
362
+ // 快捷提示词:只有档案**定义了**这一段才报(未定义 = 这一域进都不进,也就没有"会改什么")。
363
+ // 与进入时逐字同口径:勾了的启用、未勾的停用,且只数**现状 ≠ 目标**的那几条。
364
+ let quickPrompts = null;
365
+ if (archive && Array.isArray(archive.quickPrompts)) {
366
+ try {
367
+ const list = archive.quickPrompts;
368
+ const [known, current] = await Promise.all([deps.knownQuickPrompts(), deps.quickPromptStates()]);
369
+ const on = [];
370
+ const off = [];
371
+ for (const id of [...known].sort()) {
372
+ const want = list.indexOf(id) >= 0;
373
+ if (current[id] === want)
374
+ continue;
375
+ (want ? on : off).push(id);
376
+ }
377
+ quickPrompts = { on: clip(on), off: clip(off) };
378
+ }
379
+ catch (e) {
380
+ return { ok: false, error: `读取快捷提示词开关状态失败:${msg(e)}` };
381
+ }
382
+ }
286
383
  // 服务器级:计划里已只含「需要变化」的行,按目标状态分两个方向。
287
384
  const serversOn = [];
288
385
  const serversOff = [];
@@ -404,6 +501,7 @@ export function createArchiveEngine(deps) {
404
501
  sourcesOn: clip(sourcesOn), sourcesOff: clip(sourcesOff),
405
502
  },
406
503
  subagents: { on: clip(personas.on), off: clip(personas.off) },
504
+ quickPrompts,
407
505
  toolPreset,
408
506
  stale: clip(stale),
409
507
  truncated,
@@ -463,6 +561,14 @@ export function createArchiveEngine(deps) {
463
561
  if (hadKeys && archive.subagents.length === 0)
464
562
  delete archive.subagents;
465
563
  }
564
+ if (archive.quickPrompts) {
565
+ // 已删掉 / 改名的条目从勾选集里丢掉并报出来(与 subagents 段同一口径)。
566
+ // 与那三段**不同**的一点:清成空表也**不删这一栏** —— 空表说的是"进入后全部停用",
567
+ // 删掉就变成"这一域不碰",两句是相反的意思(见 SceneArchive.quickPrompts)。
568
+ const known = await deps.knownQuickPrompts();
569
+ stale.push(...archive.quickPrompts.filter((id) => !known.has(id)).map((id) => 'quickPrompts/' + id));
570
+ archive.quickPrompts = archive.quickPrompts.filter((id) => known.has(id));
571
+ }
466
572
  if (archive.toolTablePreset) {
467
573
  // 方案名查不到(被删了 / 档案是从别的机器带来的)→ 丢弃这一栏并报告:
468
574
  // 绑一个不存在的方案不该在进场景时变成一句"方案不存在"的硬失败。
@@ -486,11 +592,21 @@ export function createArchiveEngine(deps) {
486
592
  if (prev && typeof prev.toolTablePreset === 'string' && prev.toolTablePreset)
487
593
  archive.toolTablePreset = prev.toolTablePreset;
488
594
  }
489
- if (Object.keys(archive).length === 0)
595
+ // 快捷提示词同理:勾选入口在「修改场景」表单里,档案编辑器不带这一栏 —— **不带 ≠ 解绑**,
596
+ // 保留现值(解绑走 rules-update-scene 的空值;这里带上来的值仍走上面的 stale 校验)。
597
+ if (archive.quickPrompts === undefined) {
598
+ const prev = slice.archives[scene];
599
+ if (prev && Array.isArray(prev.quickPrompts))
600
+ archive.quickPrompts = prev.quickPrompts.slice();
601
+ }
602
+ // 逐场景增量写(审查 P2-3):这里只该动 `archives[scene]` 这一条,而 `slice` 是队外
603
+ // 读的 —— 整片回写会把窗口期里别人保存的**别的场景**档案一起抹掉。
604
+ const archiveNext = Object.keys(archive).length === 0 ? null : archive;
605
+ if (archiveNext === null)
490
606
  delete slice.archives[scene];
491
607
  else
492
- slice.archives[scene] = archive;
493
- await deps.saveSlice(slice);
608
+ slice.archives[scene] = archiveNext;
609
+ await deps.saveArchive(scene, archiveNext);
494
610
  // 改的正是**当前模式**的档案 → 就地重新应用(场景内只能改档案,改完必须生效)。
495
611
  // 这次新改到的**上层行**(服务器级 / 来源级)并进快照:退出仍按「进场景前」还原,
496
612
  // 进场景时改过的行 + 这次新改的行都要有记录(已记过的行保持原值,见 mergeSnapshotSwitches)。
@@ -502,10 +618,9 @@ export function createArchiveEngine(deps) {
502
618
  const changed = await reapplyActiveArchive(slice.archives[scene]);
503
619
  const snapshot = slice.mode.snapshot;
504
620
  if (snapshot && (changed.mcpServers.length || changed.skillSources.length)) {
505
- await deps.saveSlice({
506
- ...slice,
507
- mode: { ...slice.mode, snapshot: mergeSnapshotSwitches(snapshot, changed.mcpServers, changed.skillSources) },
508
- });
621
+ // 只写 mode:`reapplyActiveArchive` 期间可能有人改了 active 或别的场景的档案,
622
+ // 整片回写会顺手把它们带回旧值(审查 P2-3)。
623
+ await deps.saveSlice({ ...slice, mode: { ...slice.mode, snapshot: mergeSnapshotSwitches(snapshot, changed.mcpServers, changed.skillSources) } }, ['mode']);
509
624
  }
510
625
  }
511
626
  catch (e) {
@@ -528,9 +643,10 @@ export function createArchiveEngine(deps) {
528
643
  return { ok: true, mode: current, applied: null, stale: [] };
529
644
  // ① 退出当前模式(切换 = 先退后进):先恢复运行时,再落盘自由模式。
530
645
  // 恢复失败即中止且**保持原 mode 不变**——宁可留在「可再退出一次」的状态,也不吞错。
646
+ let restoredToolTable = false;
531
647
  if (current.scene && current.snapshot) {
532
648
  try {
533
- await restoreSnapshot(current.snapshot);
649
+ restoredToolTable = (await restoreSnapshot(current.snapshot)).toolTable;
534
650
  }
535
651
  catch (e) {
536
652
  return { ok: false, error: `退出模式「${current.scene}」失败,已保持原模式(运行时可能部分残留):${msg(e)}` };
@@ -539,7 +655,9 @@ export function createArchiveEngine(deps) {
539
655
  if (current.scene) {
540
656
  const exited = { ...slice, mode: { scene: null, snapshot: null } };
541
657
  try {
542
- await deps.saveSlice(exited);
658
+ // 只落 mode(审查 P2-3):退出模式不该顺手改写 active(记忆收窄的语义是"退出不恢复")
659
+ // 或 archives —— 而 `slice` 是队外读的,整片回写会把这些带成陈旧值。
660
+ await deps.saveSlice(exited, ['mode']);
543
661
  }
544
662
  catch (e) {
545
663
  return { ok: false, error: `退出模式「${current.scene}」已恢复运行时,但模式状态落盘失败:${msg(e)}` };
@@ -548,7 +666,7 @@ export function createArchiveEngine(deps) {
548
666
  current = exited.mode;
549
667
  }
550
668
  if (!target)
551
- return { ok: true, mode: current, applied: null, stale: [] };
669
+ return { ok: true, mode: current, applied: null, stale: [], restored: { toolTable: restoredToolTable } };
552
670
  // ② 进入目标模式:校验 → 算计划 → 拍快照 → 先落盘 mode(中途失败也留可退快照)→ 应用 → 记忆收窄。
553
671
  // 三个域(MCP / 技能 / 人设)都按「与档案勾选集完全一致」应用,未定义 = 全关;
554
672
  // 备注段只在定义时覆盖。所以这里没有「纯记忆场景不用动运行时」这条分支 —— 恒拍快照。
@@ -654,14 +772,33 @@ export function createArchiveEngine(deps) {
654
772
  return { ok: false, error: `场景绑定的工具表方案「${toolTablePreset}」已不存在,未进入场景(先在场景档案里换一份或取消绑定)` };
655
773
  }
656
774
  }
775
+ // 这一档"报不报"看的是**名单有没有变**:绑的方案与现状是同一份关停名单时,进入这一步
776
+ // 什么都没改,回执再说「工具表切到方案 X」就是给"刚才改了什么"凭空添一项。
777
+ const toolTableSwitched = !!toolTableTarget && !sameNameList(toolTableBefore, toolTableTarget);
778
+ // 快捷提示词(0.18.0):档案**定义了**这一段才碰这一域(未定义 = 保持现状,与
779
+ // mcp/skills/subagents 的「未定义 = 全关」不同 —— 用户 2026-10-01 裁定这一栏的默认是
780
+ // 「当前启用的快捷提示词」)。读现状同样放在改任何东西之前:读失败就等于什么都没动。
781
+ const quickList = archive && Array.isArray(archive.quickPrompts) ? archive.quickPrompts : null;
782
+ let quickStates = null;
783
+ if (quickList) {
784
+ try {
785
+ const [known, current] = await Promise.all([deps.knownQuickPrompts(), deps.quickPromptStates()]);
786
+ quickStates = Object.fromEntries([...known].map((id) => [id, current[id] === true]));
787
+ }
788
+ catch (e) {
789
+ return { ok: false, error: `读取快捷提示词开关状态失败(未改动任何东西):${msg(e)}` };
790
+ }
791
+ }
657
792
  // 快照**恒拍**:三个域都按「与勾选集完全一致」应用(未定义 = 全关),所以任何场景
658
793
  // 进入都可能改动运行时(哪怕只是停掉几台服务器 / 几个技能),退出都得能精确还原。
659
794
  // 上层两行传**现状全量**(上面的 mcpServerStates / skillSources 就是进场景前的值),
660
795
  // 不是计划里那几行 —— 退出按「现状 ≠ 记录值」回写(见 mcpServerRowsToRestore)。
661
- const snapshot = snapshotRuntime(await deps.currentMcpRaw(), await deps.currentSkills(), mcpServerStates.map((s) => ({ id: s.id, level: s.level, disabled: s.disabled })), skillSources.map((s) => ({ root: s.root, enabled: s.enabled })), personaRestore, personaRestoreOn, noteBefore, personaStates, toolTableBefore);
796
+ const snapshot = snapshotRuntime(await deps.currentMcpRaw(), await deps.currentSkills(), mcpServerStates.map((s) => ({ id: s.id, level: s.level, disabled: s.disabled })), skillSources.map((s) => ({ root: s.root, enabled: s.enabled })), personaRestore, personaRestoreOn, noteBefore, personaStates, toolTableBefore, quickStates);
662
797
  const entered = { ...slice, mode: { scene: target, snapshot }, active: [target] };
663
798
  try {
664
- await deps.saveSlice({ ...slice, mode: entered.mode });
799
+ // 先落 **mode**(留可退快照);active 留到最后一步再落 —— 中途失败要能按原样退回,
800
+ // 而"记忆已经收窄、运行时还没应用"是最难看的中间态。只写 mode 不写别的(审查 P2-3)。
801
+ await deps.saveSlice({ ...slice, mode: entered.mode }, ['mode']);
665
802
  }
666
803
  catch (e) {
667
804
  return { ok: false, error: '进入模式前落盘快照失败(运行时未改动):' + msg(e) };
@@ -697,13 +834,19 @@ export function createArchiveEngine(deps) {
697
834
  // 所以它是"进入场景"这一动作里唯一直接改每请求内容的域)。
698
835
  if (toolTableTarget)
699
836
  await deps.applyToolTableHidden(toolTableTarget);
837
+ // ⑥ 快捷提示词:段定义了才动(勾了的启用、未勾的停用)。它不进上下文、不进注入,
838
+ // 改的只是"这条还在不在对话框的 `/` 菜单里"。
839
+ if (quickList)
840
+ await applyQuickPrompts(quickList);
700
841
  }
701
842
  catch (e) {
702
843
  return await rollback(slice, snapshot, '应用档案', e);
703
844
  }
704
845
  // ③ 记忆启用集收窄为 {S}(`_shared` 由 resolveActiveScenes 恒常加入;退出不恢复)。
705
846
  try {
706
- await deps.saveSlice(entered);
847
+ // 落 mode + active(这两片是本次的意图),**不落 archives**(审查 P2-3):上面那一段
848
+ // 运行时应用可能耗时数秒,期间一次"保存别的场景的档案"会被陈旧整片回写抹掉。
849
+ await deps.saveSlice(entered, ['mode', 'active']);
707
850
  }
708
851
  catch (e) {
709
852
  return await rollback(slice, snapshot, '记忆收窄落盘', e);
@@ -713,12 +856,16 @@ export function createArchiveEngine(deps) {
713
856
  mode: entered.mode,
714
857
  // 三个域都是「按勾选集全量应用」(未定义 = 全关),所以都算已应用;
715
858
  // 备注段未定义 = 不覆盖任何备注,如实报它是否定义。
716
- applied: { mcp: true, skills: true, subagents: true, mcpNotes: !!(archive && archive.mcpNotes), toolTable: !!toolTableTarget },
859
+ applied: { mcp: true, skills: true, subagents: true, mcpNotes: !!(archive && archive.mcpNotes), toolTable: !!toolTableTarget, quickPrompts: !!quickList },
717
860
  // 上层实际切换了几个(0 = 本来就已经是目标状态,界面不必提示"已停用 N 台")。
718
861
  switched: {
719
862
  mcpServers: mcpPlan.serverSwitches.length,
720
863
  skillSources: skillsPlan.sourceSwitches.length,
864
+ // 工具表:这次真的换上去的关停条数(0 = 本来就是那份,或压根没绑方案)。
865
+ toolTableHidden: toolTableSwitched ? toolTableTarget.length : 0,
721
866
  },
867
+ // 换上去的那份方案名,供回执点名(与上面同一个判据:没换就回空串)。
868
+ toolTablePreset: toolTableSwitched ? toolTablePreset : '',
722
869
  stale,
723
870
  narrowedTo: [target],
724
871
  };
@@ -64,6 +64,9 @@ export function normalizeArchive(raw) {
64
64
  out.skills = normalizeStringList(obj.skills);
65
65
  if (obj.subagents != null)
66
66
  out.subagents = normalizeStringList(obj.subagents);
67
+ // 快捷提示词:`!= null` 才算定义(空表 = 定义了但全不勾 = 进入后全部停用,必须留住)。
68
+ if (obj.quickPrompts != null)
69
+ out.quickPrompts = normalizeStringList(obj.quickPrompts);
67
70
  if (obj.memories != null)
68
71
  out.memories = normalizeStringList(obj.memories);
69
72
  // 工具表方案是**标量**不是集合:空串 / 非字符串一律视为未绑定(段不存在)。
@@ -208,7 +211,12 @@ mcpNotes = [],
208
211
  */
209
212
  subagentStates = null,
210
213
  /** 进场景前的模型工具表关停名单(null = 这次不进快照,退出也就不动工具表)。 */
211
- toolTableHidden = null) {
214
+ toolTableHidden = null,
215
+ /**
216
+ * 进场景前**每一条**快捷提示词的开关(null = 档案没定义 quickPrompts 段 → 这一域不碰,
217
+ * 也就不进快照,退出同样一个字不动)。
218
+ */
219
+ quickPromptStates = null) {
212
220
  return {
213
221
  mcp: Object.fromEntries(Object.entries(mcpRaw).map(([k, v]) => [k, v.slice()])),
214
222
  skills: { ...skills },
@@ -219,6 +227,7 @@ toolTableHidden = null) {
219
227
  ...(subagentStates ? { subagentsAll: { ...subagentStates } } : {}),
220
228
  ...(mcpNotes.length ? { mcpNotes: mcpNotes.map((x) => ({ id: x.id, note: x.note })) } : {}),
221
229
  ...(toolTableHidden ? { toolTableHidden: toolTableHidden.slice() } : {}),
230
+ ...(quickPromptStates ? { quickPromptsAll: { ...quickPromptStates } } : {}),
222
231
  };
223
232
  }
224
233
  // ── 记忆勾选(v3)──────────────────────────────────────────────────────────
@@ -0,0 +1,261 @@
1
+ // 规则/记忆的**发现**:扫一个来源目录读成条目、把 frontmatter 投成规则、按老布局搬家。
2
+ //
3
+ // 声明从 memories/snapshot.ts 按行号区间搬来,正文一字未改(唯一的容差是
4
+ // `--export-top-level` 加的 `export ` 前缀,moved-verify 逐字对拍)。
5
+ //
6
+ // 为什么与快照分两层:发现是「读外面进来的东西」(官方技能目录、AGENTS.md、旧布局),
7
+ // 快照是「记下当前场景长什么样并写回索引」。两者各自演化的方向不同 —— 前者跟着**来源形状**
8
+ // 变,后者跟着**索引字段**变。
9
+ import { statSync } from 'node:fs';
10
+ import { cp, lstat, mkdir, readdir, realpath, rename, rm } from 'node:fs/promises';
11
+ import { join, resolve } from 'node:path';
12
+ import { resolveDshHome, unquote } from '../skills/core.js';
13
+ import { MAX_SOURCE_DEPTH, MAX_DIRECTORIES, MAX_ENTRIES, MAX_DESCRIPTION_LENGTH, DEFAULT_ORDER, GLOBAL_SCENE, LEGACY_BUNDLE_DOC, bundleDocName, SEGMENT_RULE_HINT, isValidGroupSegment } from './constants.js';
14
+ import { pathExists } from './index-io.js';
15
+ export const identity = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
16
+ export async function discover(memoriesRoot) {
17
+ const entries = new Map();
18
+ const shadowed = [];
19
+ const groups = new Set();
20
+ const scenes = [];
21
+ const warnings = [];
22
+ const rootPath = resolve(memoriesRoot);
23
+ try {
24
+ const st = await lstat(rootPath);
25
+ if (!st.isDirectory() || st.isSymbolicLink())
26
+ return { entries, shadowed, groups, scenes, warnings, truncated: false };
27
+ }
28
+ catch {
29
+ return { entries, shadowed, groups, scenes, warnings, truncated: false };
30
+ }
31
+ const queue = [
32
+ { path: rootPath, group: '', depth: 0, ancestors: new Set() },
33
+ ];
34
+ let directories = 0;
35
+ let itemCount = 0;
36
+ let truncated = false;
37
+ const addEntry = (entry) => {
38
+ const existing = entries.get(entry.id);
39
+ if (!existing) {
40
+ entries.set(entry.id, entry);
41
+ return;
42
+ }
43
+ if (entry.kind === 'bundle' && existing.kind === 'flat') {
44
+ shadowed.push({ ...existing, shadowed: true });
45
+ entries.set(entry.id, entry);
46
+ return;
47
+ }
48
+ if (entry.kind === 'flat' && existing.kind === 'bundle') {
49
+ shadowed.push({ ...entry, shadowed: true });
50
+ }
51
+ };
52
+ for (let cursor = 0; cursor < queue.length; cursor++) {
53
+ if (directories >= MAX_DIRECTORIES || itemCount >= MAX_ENTRIES) {
54
+ truncated = true;
55
+ break;
56
+ }
57
+ const current = queue[cursor];
58
+ let realDirectory;
59
+ let dirents = [];
60
+ try {
61
+ realDirectory = await realpath(current.path);
62
+ if (current.ancestors.has(identity(realDirectory)))
63
+ continue;
64
+ const dir = await readdir(current.path, { withFileTypes: true });
65
+ directories++;
66
+ for (const item of dir) {
67
+ if (itemCount >= MAX_ENTRIES) {
68
+ truncated = true;
69
+ break;
70
+ }
71
+ itemCount++;
72
+ dirents.push(item);
73
+ }
74
+ }
75
+ catch {
76
+ continue; // 失效链接或不可读目录不阻断其他来源
77
+ }
78
+ // bundle 检查:当前目录含 `<目录名>.md`(或旧版 `SKILL.md`)→ 它是规则叶子,父路径为场景。
79
+ //
80
+ // **根下的一级目录恒为场景**(`parentGroup === ''` 时不做 bundle 判断)——与
81
+ // `probeSceneFilesSync`(注入段)同口径。场景 `1` 里一条叫 `1` 的记忆(`memories/1/1.md`)
82
+ // 与「根层的 bundle `1`」在磁盘上同形,认成后者会把整个场景目录吃成叶子:同场景其余
83
+ // 记忆全从列表消失、多出一条「未归属场景」的同名记忆,新建记忆还会被误报「同名 bundle
84
+ // 遮蔽」(2026-09-16 用户实测)。根层因此没有 bundle,只有 legacy 裸 .md(见 rulesCheck 的 noScene)。
85
+ if (current.group !== '') {
86
+ const sepIdx = current.group.lastIndexOf('/');
87
+ const parentGroup = sepIdx >= 0 ? current.group.slice(0, sepIdx) : '';
88
+ const name = sepIdx >= 0 ? current.group.slice(sepIdx + 1) : current.group;
89
+ let docPath = '';
90
+ if (parentGroup !== '') {
91
+ for (const candidate of [bundleDocName(name), LEGACY_BUNDLE_DOC]) {
92
+ const p = join(current.path, candidate);
93
+ try {
94
+ const st = await lstat(p);
95
+ if (st.isFile() && !st.isSymbolicLink()) {
96
+ docPath = p;
97
+ break;
98
+ }
99
+ }
100
+ catch { /* 该候选名不存在 → 试下一个 */ }
101
+ }
102
+ }
103
+ if (docPath !== '') {
104
+ const id = parentGroup ? `${parentGroup}/${name}` : name;
105
+ addEntry({ id, group: parentGroup, name, kind: 'bundle', docPath, entryPath: current.path });
106
+ continue;
107
+ }
108
+ }
109
+ if (current.group !== '') {
110
+ groups.add(current.group);
111
+ // 一级目录 = 场景名(多段场景名用 '/' 连接,见 isValidGroupPath)。
112
+ if (current.depth === 1)
113
+ scenes.push(current.group);
114
+ }
115
+ dirents.sort((a, b) => a.name.localeCompare(b.name));
116
+ const ancestors = new Set(current.ancestors).add(identity(realDirectory));
117
+ for (const item of dirents) {
118
+ if (item.name.startsWith('.'))
119
+ continue; // 隐藏目录/文件跳过
120
+ const path = join(current.path, item.name);
121
+ try {
122
+ const st = await lstat(path);
123
+ // 符号链接一律不跟随:注入路径(`probeSceneFilesSync`)本来就不跟随,两端必须同口径 ——
124
+ // 否则列表里会出现**永远注入不进去**的记忆(用户打开开关却什么也没发生)。顺带也关掉了
125
+ // 「链接指向根外、写操作顺着链接穿透出去」这条路(写入侧另有 realpath 断言兜底)。
126
+ if (st.isSymbolicLink())
127
+ continue;
128
+ if (st.isDirectory()) {
129
+ if (!isValidGroupSegment(item.name)) {
130
+ warnings.push(`跳过非法目录名(${SEGMENT_RULE_HINT}):${current.group ? current.group + '/' : ''}${item.name}`);
131
+ continue;
132
+ }
133
+ if (current.depth >= MAX_SOURCE_DEPTH || queue.length >= MAX_DIRECTORIES) {
134
+ truncated = true;
135
+ continue;
136
+ }
137
+ queue.push({
138
+ path,
139
+ group: current.group ? `${current.group}/${item.name}` : item.name,
140
+ depth: current.depth + 1,
141
+ ancestors,
142
+ });
143
+ continue;
144
+ }
145
+ if (!st.isFile())
146
+ continue;
147
+ if (!item.name.toLowerCase().endsWith('.md'))
148
+ continue; // 只认 .md
149
+ const name = item.name.slice(0, -3);
150
+ const id = current.group ? `${current.group}/${name}` : name;
151
+ addEntry({ id, group: current.group, name, kind: 'flat', docPath: path, entryPath: path });
152
+ }
153
+ catch {
154
+ /* 来源在扫描期间失效时跳过 */
155
+ }
156
+ }
157
+ }
158
+ return { entries, shadowed, groups, scenes, warnings, truncated };
159
+ }
160
+ export function deriveDescription(body) {
161
+ const text = String(body ?? '').trim();
162
+ if (!text)
163
+ return '';
164
+ const heading = /^#\s+(.+)$/m.exec(text);
165
+ const raw = heading && heading[1].trim() ? heading[1].trim() : text.split(/\r?\n/, 1)[0].trim();
166
+ return raw.slice(0, MAX_DESCRIPTION_LENGTH);
167
+ }
168
+ export function deriveFromDoc(entry, doc) {
169
+ const declaredName = doc.map.name != null && String(doc.map.name).trim() !== '' ? unquote(String(doc.map.name)).trim() : '';
170
+ const name = declaredName || entry.name;
171
+ let description = doc.map.description != null ? unquote(String(doc.map.description)).trim() : '';
172
+ let descriptionDerived = false;
173
+ if (description === '') {
174
+ description = deriveDescription(doc.body);
175
+ descriptionDerived = true;
176
+ }
177
+ const globs = doc.map.globs != null && String(doc.map.globs).trim() !== '' ? String(doc.map.globs).trim() : undefined;
178
+ let whenToUse;
179
+ if (doc.map.whenToUse != null && String(doc.map.whenToUse).trim() !== '') {
180
+ whenToUse = String(doc.map.whenToUse).trim();
181
+ }
182
+ else if (globs) {
183
+ whenToUse = `适用路径:${globs}`;
184
+ }
185
+ const metadata = doc.map.metadata !== undefined ? doc.map.metadata : undefined;
186
+ return { name, description, descriptionDerived, whenToUse, globs, metadata };
187
+ }
188
+ export function projectRule(entry, derived, idxEntry) {
189
+ // 体积按**正文文件**算(bundle 的附件不计:附件不进注入正文)。一次 stat 一条记忆,
190
+ // 结果随快照的 1s TTL 缓存一起复用;读不到就不带这个字段,别把"不知道"报成 0 B。
191
+ let size;
192
+ try {
193
+ size = statSync(entry.docPath).size;
194
+ }
195
+ catch {
196
+ size = undefined;
197
+ }
198
+ const rule = {
199
+ id: entry.id,
200
+ group: entry.group,
201
+ name: derived.name,
202
+ form: entry.kind,
203
+ path: entry.docPath,
204
+ ...(size !== undefined ? { bytes: size } : {}),
205
+ description: derived.description,
206
+ ...(derived.descriptionDerived ? { descriptionDerived: true } : {}),
207
+ ...(derived.whenToUse !== undefined ? { whenToUse: derived.whenToUse } : {}),
208
+ ...(derived.globs !== undefined ? { globs: derived.globs } : {}),
209
+ ...(derived.metadata !== undefined ? { metadata: derived.metadata } : {}),
210
+ enabled: idxEntry?.enabled ?? true,
211
+ order: idxEntry?.order ?? DEFAULT_ORDER,
212
+ tags: Array.isArray(idxEntry?.tags) ? idxEntry.tags : [],
213
+ pinned: idxEntry?.pinned === true,
214
+ note: typeof idxEntry?.note === 'string' ? idxEntry.note : '',
215
+ ...(typeof idxEntry?.updatedAt === 'string' ? { updatedAt: idxEntry.updatedAt } : {}),
216
+ };
217
+ return rule;
218
+ }
219
+ export async function relocateLegacyLayout(memoriesRoot) {
220
+ const home = resolveDshHome();
221
+ const legacyRoots = [join(home, 'scene-memory'), join(home, 'rules')];
222
+ for (const legacyRoot of legacyRoots) {
223
+ if (!(await pathExists(legacyRoot)))
224
+ continue;
225
+ if (identity(resolve(legacyRoot)) === identity(resolve(memoriesRoot)))
226
+ continue; // 自定义 roots 落在旧路径 → 不自我搬移
227
+ let items;
228
+ try {
229
+ items = await readdir(legacyRoot, { withFileTypes: true });
230
+ }
231
+ catch {
232
+ continue;
233
+ }
234
+ for (const item of items) {
235
+ if (item.name.startsWith('.'))
236
+ continue;
237
+ const src = join(legacyRoot, item.name);
238
+ // 根层 .md = 旧的「全局」桶 → 搬进 `global/`,文件名保留(否则多套一层目录)。
239
+ // 其余条目(场景目录)整体搬进 memories/,目录名保留。
240
+ const isRootMd = item.isFile() && item.name.toLowerCase().endsWith('.md');
241
+ const destDir = isRootMd ? join(memoriesRoot, GLOBAL_SCENE) : memoriesRoot;
242
+ const dest = join(destDir, item.name);
243
+ try {
244
+ if (await pathExists(dest))
245
+ continue;
246
+ await mkdir(destDir, { recursive: true });
247
+ try {
248
+ await rename(src, dest);
249
+ }
250
+ catch {
251
+ // 跨设备/被占用 → 复制后删源;复制失败则保留源文件(宁可重复,不可丢失)。
252
+ await cp(src, dest, { recursive: true });
253
+ await rm(src, { recursive: true, force: true });
254
+ }
255
+ }
256
+ catch {
257
+ /* 单项失败不阻断其余项(如文件被外部程序锁住,下次启动再试) */
258
+ }
259
+ }
260
+ }
261
+ }
@@ -0,0 +1,42 @@
1
+ // 记忆导出的落盘映射:把「导出哪几条记忆」算成 zip 里的条目与缺失清单。
2
+ //
3
+ // 从 memories/service.ts 整段搬来,一行未改。它是纯函数(不碰磁盘)——flat / bundle 两种
4
+ // 形态与「场景名本身可含 /」的口径都写在它自己的注释里,那条口径两边必须一致。
5
+ import { dirname } from 'node:path';
6
+ /**
7
+ * 记忆导出的落盘映射(`bundle-export` 的 `kind: 'memories'` 用,见 design-plan D14)。
8
+ *
9
+ * 记忆有两种形态(见文件头):flat = `<场景>/<name>.md`,bundle = `<场景>/<name>/<name>.md`;
10
+ * 而且**场景名本身可含 `/`**(多段场景名,见 `isValidGroupPath`)。所以「按 id 的最后一个
11
+ * `/` 切出场景与名字、再拼 `.md`」是错的:bundle 会被读成 `<场景>/<name>.md` → 读不到 →
12
+ * 该项目静默丢失(只在 `missing` 里留个名,界面按「导出成功」显示)。
13
+ *
14
+ * 这里一律按索引里那条规则自己的 `path` 与 `form` 决定;bundle 只交出目录,由调用方
15
+ * 把目录内的文件全部打包(与技能分支同口径)。索引里没有、或已被同名 bundle 遮蔽的 id
16
+ * 进 `missing`——遮蔽的 flat 不会被加载,导出去只会让人以为它能用。
17
+ *
18
+ * 纯函数(不碰磁盘),可直接断言。
19
+ */
20
+ export function planMemoryExport(names, rules) {
21
+ const list = Array.isArray(names) ? names.map((n) => String(n).trim()).filter(Boolean) : [];
22
+ const byId = new Map();
23
+ for (const row of (Array.isArray(rules) ? rules : [])) {
24
+ const id = row && typeof row.id === 'string' ? row.id : '';
25
+ if (id !== '')
26
+ byId.set(id, row);
27
+ }
28
+ const entries = [];
29
+ const missing = [];
30
+ for (const id of list) {
31
+ const row = byId.get(id);
32
+ const abs = row && typeof row.path === 'string' ? row.path : '';
33
+ if (!row || abs === '' || row.shadowed === true) {
34
+ missing.push(id);
35
+ continue;
36
+ }
37
+ entries.push(row.form === 'bundle'
38
+ ? { id, zip: id, abs: dirname(abs), kind: 'dir' }
39
+ : { id, zip: `${id}.md`, abs, kind: 'file' });
40
+ }
41
+ return { entries, missing };
42
+ }