dsh-plugin-tool-management 0.19.0 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +868 -798
  2. package/README.md +71 -59
  3. package/README_EN.md +91 -80
  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/images/logo.png +0 -0
  21. package/docs/images/promo-cover-EN.png +0 -0
  22. package/docs/images/promo-cover.png +0 -0
  23. package/docs/update.md +31 -520
  24. package/lib/client.js +8308 -6307
  25. package/lib/hub.js +96 -6
  26. package/lib/index.js +54 -2
  27. package/lib/mcp/manager.js +24 -13
  28. package/lib/mcp/secret-guard.js +2 -2
  29. package/lib/memories/constants.js +9 -2
  30. package/lib/memories/service.js +6 -1
  31. package/lib/op-registry.js +22 -7
  32. package/lib/ops/prompts.js +64 -7
  33. package/lib/ops/sessions.js +62 -7
  34. package/lib/ops/trash-retention.js +55 -0
  35. package/lib/ops/trash.js +62 -3
  36. package/lib/prompts/service.js +191 -25
  37. package/lib/scene-prompt-sync.js +1 -1
  38. package/lib/sessions/history.js +32 -22
  39. package/lib/skills/core.js +68 -2
  40. package/lib/skills/manager-state.js +32 -1
  41. package/lib/skills/service.js +7 -1
  42. package/lib/tools/mcp.js +6 -0
  43. package/lib/tools/prompt.js +14 -2
  44. package/lib/tools/scene.js +146 -25
  45. package/lib/trash-retention.js +256 -0
  46. package/package.json +1 -1
  47. package/lib/mcp/inspect-probe.js +0 -199
package/lib/hub.js CHANGED
@@ -24,13 +24,14 @@
24
24
  // ├─ mcp-disabled-tools.json | mcp-known-tools.json | mcp-notes.json | mcp-settings.json | mcp-export.json
25
25
  // ├─ tool-management.log 插件日志(滚动 .1)
26
26
  // ├─ backups/cordis.patch.yml.bak-<时间戳> 改宿主 patch 前的备份
27
- // ├─ trash/{skills,subagents,prompts,scenes}-trash/<id>/ 回收站(除记忆外的四类)
28
- // └─ memories-trash/<id>/ 记忆回收站(**hub 根下独立目录**)
27
+ // ├─ trash/{skills,subagents,prompts,scenes,memories}-trash/<id>/ 回收站(六类)
29
28
  //
30
- // 记忆回收站**不在 `trash/` 下**(本机实测 `trash/` 只有 4 个 `-trash` 目录):它走
31
- // `memories/service.ts` 自己的路径(`join(stateDir, 'memories-trash', id)`,stateDir = hub 根),
32
- // 不经本模块的 `moveToTrash`。此前这行把它画进 `trash/{…}` 里,按它写备份/迁移脚本会既漏搬
33
- // 又误判(那是一类真实存在、条目数最多的用户数据)。
29
+ // 六类回收站的落点已统一在 `trash/` 下(2026-10-10 起;记忆此前在 hub 根的 `memories-trash/`,
30
+ // 由启动迁移并入 —— 见 `HUB_RENAMES`)。
31
+ // 但**记忆那一类仍不经本模块的 `moveToTrash`**:它走 `memories/service.ts` 自己的路径
32
+ // (`join(stateDir, 'trash/memories-trash', id)`,stateDir = hub 根),manifest 形状也由记忆域
33
+ // 自己定。本模块只保证**目录位置**与其余几类一致;把它的读写抄到这里来只会得到第二份会漂移的
34
+ // 真相(本条注释的前身就是这么写错的:它把记忆画进 `trash/{…}` 里,而当时那里根本没有它)。
34
35
  //
35
36
  // 留在 `$DSH_HOME` 根下的两个文件**不是**插件的:`AGENTS.md`(宿主每轮读取的全局基线,
36
37
  // 插件只是按场景/预设写它)与 `cordis.patch.yml`(宿主加载插件的配置入口)。
@@ -68,6 +69,11 @@ const HUB_RENAMES = [
68
69
  ['agents-md', 'prompts'],
69
70
  ['rules-index.json', 'memories-index.json'],
70
71
  ['rules-trash', 'memories-trash'],
72
+ // 记忆回收站并入 `trash/`,与其余五类同处一处(2026-10-10)。
73
+ // **必须排在上一行之后**:极老的 `rules-trash/` 要先改名成 `memories-trash/`,这一行才能
74
+ // 把它一并搬进 `trash/`。顺序颠倒的话,老用户那份会留在 hub 根下不动 —— 而它恰恰是条目数
75
+ // 最多的一类数据。
76
+ ['memories-trash', 'trash/memories-trash'],
71
77
  ['agents', 'subagents'],
72
78
  ['agents-index.json', 'subagents-index.json'],
73
79
  ['state.json', 'skills-state.json'],
@@ -266,6 +272,90 @@ export function trashEntryPath(kind, id) {
266
272
  return null;
267
273
  return join(trashKindDir(kind), id);
268
274
  }
275
+ /** 清单类文件名:它们是插件自己的元数据,不是"删掉的那份东西",不进详情。 */
276
+ const TRASH_META_NAMES = new Set(['manifest.json', 'metadata.json']);
277
+ /** 详情上限:单个文件、总字节、文件数、递归深度。超了就如实标 `truncated`,绝不悄悄截。 */
278
+ const PREVIEW_MAX_FILE = 64 * 1024;
279
+ const PREVIEW_MAX_TOTAL = 256 * 1024;
280
+ const PREVIEW_MAX_FILES = 40;
281
+ const PREVIEW_MAX_DEPTH = 4;
282
+ /** 前 8 KiB 里出现 NUL 就按二进制处理 —— 二进制内容转成字符串只会是一屏乱码。 */
283
+ function looksBinary(buf) {
284
+ const n = Math.min(buf.length, 8192);
285
+ for (let i = 0; i < n; i += 1)
286
+ if (buf[i] === 0)
287
+ return true;
288
+ return false;
289
+ }
290
+ /**
291
+ * 把一条回收站条目目录里的负载**只读**地读出来,供界面「详情」显示。
292
+ *
293
+ * 为什么放在这里而不是各域各写一遍:三个域的回收站落在三套不同的目录布局里
294
+ * (hub 的四类 `<kind>-trash`、记忆的 `memories-trash`、技能的 `skills-trash`),
295
+ * 但"把一个目录里的文件读成能看的文本"这件事完全一样。上限、二进制判据、哪些名字算清单
296
+ * 只写一遍 —— 否则迟早出现"某一域的详情能读到别的东西"。
297
+ *
298
+ * 只读、不落盘、不跟随符号链接(条目里出现链接一律跳过,别把根外的文件读出来给界面看)。
299
+ * 目录不存在 / 读不动 → 返回空数组,调用方据此报"内容已不可读",不抛。
300
+ */
301
+ export async function readTrashPayloadFiles(dir, options = {}) {
302
+ const out = [];
303
+ let total = 0;
304
+ const maxDepth = Number.isFinite(Number(options.maxDepth)) ? Number(options.maxDepth) : PREVIEW_MAX_DEPTH;
305
+ const walk = async (current, rel, depth) => {
306
+ if (out.length >= PREVIEW_MAX_FILES || depth > maxDepth)
307
+ return;
308
+ let entries;
309
+ try {
310
+ entries = await readdir(current, { withFileTypes: true });
311
+ }
312
+ catch {
313
+ return;
314
+ }
315
+ // 目录在前、文件在后;同层按名字排序 —— 让详情里的顺序稳定(不随文件系统枚举顺序变)。
316
+ const sorted = [...entries].sort((a, b) => {
317
+ if (a.isDirectory() !== b.isDirectory())
318
+ return a.isDirectory() ? -1 : 1;
319
+ return a.name.localeCompare(b.name);
320
+ });
321
+ for (const entry of sorted) {
322
+ if (out.length >= PREVIEW_MAX_FILES)
323
+ return;
324
+ const abs = join(current, entry.name);
325
+ const nextRel = rel ? `${rel}/${entry.name}` : entry.name;
326
+ if (entry.isSymbolicLink())
327
+ continue;
328
+ if (entry.isDirectory()) {
329
+ await walk(abs, nextRel, depth + 1);
330
+ continue;
331
+ }
332
+ if (!entry.isFile() || TRASH_META_NAMES.has(entry.name))
333
+ continue;
334
+ let buf;
335
+ try {
336
+ buf = await readFile(abs);
337
+ }
338
+ catch {
339
+ continue;
340
+ }
341
+ const file = { path: nextRel, size: buf.length };
342
+ if (looksBinary(buf)) {
343
+ file.binary = true;
344
+ }
345
+ else {
346
+ const room = Math.max(0, Math.min(PREVIEW_MAX_FILE, PREVIEW_MAX_TOTAL - total));
347
+ const slice = buf.subarray(0, room);
348
+ file.text = slice.toString('utf8');
349
+ if (slice.length < buf.length)
350
+ file.truncated = true;
351
+ total += slice.length;
352
+ }
353
+ out.push(file);
354
+ }
355
+ };
356
+ await walk(dir, '', 0);
357
+ return out;
358
+ }
269
359
  async function readManifest(kind, id) {
270
360
  const dir = trashEntryPath(kind, id);
271
361
  if (dir === null)
package/lib/index.js CHANGED
@@ -16,7 +16,7 @@ import { createRequire } from 'node:module';
16
16
  import { randomUUID } from 'node:crypto';
17
17
  import { createHistoryDomain } from './sessions/history.js';
18
18
  import { createSkillsService, pluginLog } from './skills/service.js';
19
- import { renameWithRetry } from './skills/core.js';
19
+ import { renameWithRetry, trashRootPath } from './skills/core.js';
20
20
  import { createPromptsService } from './prompts/service.js';
21
21
  import { createQuickPromptsService } from './prompts/quick-service.js';
22
22
  import { isInsideRootResolved } from './paths.js';
@@ -56,6 +56,9 @@ import { buildCompatOps } from './ops/compat.js';
56
56
  import { buildPromptOps } from './ops/prompts.js';
57
57
  import { buildQuickPromptOps } from './ops/quick-prompts.js';
58
58
  import { buildSessionOps } from './ops/sessions.js';
59
+ import { buildTrashRetentionOps } from './ops/trash-retention.js';
60
+ // 回收站保留期(0.19.1):侧车 `hub/trash-retention.json` + 六类回收站的到期清扫。
61
+ import { createTrashRetentionDomain } from './trash-retention.js';
59
62
  // HTTP 请求准入与 handlers 后处理(2026-09-19 从本文件 apply 闭包抽出,依赖显式传参)。
60
63
  import { createAccessToken, createFrozenGate, createOpWhitelist, createSceneLock, guardModelOp, guardModelOps, installHandlerGuards } from './request-gate.js';
61
64
  // `isReadCall` 随 HTTP 路由一起搬走了(写门禁与审计流水都读同一份登记表判据)。
@@ -203,6 +206,10 @@ export default {
203
206
  memoriesRoot: String(config?.memoriesRoot || ''),
204
207
  stateDir: String(config?.memoriesStateDir || ''),
205
208
  scenesDir: String(config?.memoriesScenesDir || ''),
209
+ // 技能回收站根目录:只给回收站「详情」(`trash-preview`)读技能条目用。路径的唯一定义
210
+ // 在 skills/skill-roots.ts(`trashRootPath()`),这里原样转手,不在记忆域重算一遍 ——
211
+ // 重算就是第二个真相源,改一处忘一处时详情会指向一个空目录。
212
+ skillsTrashDir: trashRootPath(),
206
213
  // 场景记忆段预算(字节),默认 131072(= DEFAULT_MAX_BYTES,128 KiB);仅用于测试与特殊部署调优。
207
214
  ...(Number.isFinite(Number(config?.rulesMaxBytes))
208
215
  ? { maxBytes: Number(config.rulesMaxBytes) }
@@ -874,6 +881,33 @@ export default {
874
881
  // 保留期设置与到期清扫、批量操作目标校验、归档列表分组视图。构造实例、首次清扫、
875
882
  // 挂周期定时器都在工厂内完成 —— 它们原本就写在 apply() 的同一位置。
876
883
  const history = createHistoryDomain({ ctx, config, pluginRoot: PLUGIN_ROOT, message });
884
+ // 回收站保留期域(0.19.1):与上面的 history 同形 —— 构造、首次清扫、周期定时器都在
885
+ // 工厂内完成。默认保留期是 `0`(永久保留),所以对没打开过这个开关的宿主来说,
886
+ // 启动那次清扫读完侧车就返回了,一次磁盘遍历都不做。
887
+ //
888
+ // 记忆回收站那两条**走 memories 域的 op**,不在这里重抄路径与 manifest 形状:它在
889
+ // `hub/trash/memories-trash/`(2026-10-10 起与其余五类同处一处,之前是 hub 根下的独立
890
+ // 目录),形状由 memories 域自己定 —— 重抄一遍只会得到第二份会漂移的真相。
891
+ const trashRetention = createTrashRetentionDomain({
892
+ ctx,
893
+ config,
894
+ memories: {
895
+ list: async () => {
896
+ const res = await memoriesService.ops['rules-trash-list']({});
897
+ const entries = res && Array.isArray(res.entries) ? res.entries : [];
898
+ return entries
899
+ .map((e) => ({
900
+ id: String((e && e.trashId) || ''),
901
+ name: String((e && e.name) || ''),
902
+ deletedAt: String((e && e.deletedAt) || ''),
903
+ }))
904
+ // 没有 id 的条目删不掉,也不能记流水 —— 在这里挡掉,免得把一条坏数据一路带到
905
+ // 「永久删除」那一步才失败。
906
+ .filter((e) => e.id !== '');
907
+ },
908
+ purge: (id) => memoriesService.ops['rules-trash-remove']({ trashId: id }),
909
+ },
910
+ });
877
911
  // ── 场景提示词 → 全局基线(AGENTS.md)同步 ────────────────────────────────
878
912
  //
879
913
  // 用户裁定(2026-09-15):「切换场景,对应的提示词直接把 AGENTS.md 直接修改」。
@@ -933,6 +967,8 @@ export default {
933
967
  return `场景「${ref.label || ''}」${ref.active ? '(已启用)' : ''}绑定了它`;
934
968
  if (ref && ref.kind === 'restore')
935
969
  return '它就是退出场景后要恢复的全局提示词';
970
+ if (ref && ref.applied === true)
971
+ return '它正被「应用」在 ~/.dsh/AGENTS.md 里';
936
972
  return '~/.dsh/AGENTS.md 当前内容就是它';
937
973
  };
938
974
  // ---------- path discovery ----------
@@ -1546,6 +1582,14 @@ export default {
1546
1582
  extractTurnsFromEvents: extractExportTurns,
1547
1583
  serializeTurns: serializeTranscript,
1548
1584
  }),
1585
+ // 回收站保留期(ops/trash-retention.ts):trash-retention-get(只读)/
1586
+ // trash-retention-set(写 + 冻结;成功后立即扫一次,见该文件文件头)。
1587
+ // 清扫本身**不在这里**——它是引擎的后台动作,由 trashRetention 域自己挂启动与周期。
1588
+ ...buildTrashRetentionOps({
1589
+ read: trashRetention.read,
1590
+ write: trashRetention.write,
1591
+ sweep: trashRetention.sweep,
1592
+ }),
1549
1593
  // rules 域里要走「场景 ↔ 全局基线同步」包装的四个写 op(ops/scene-sync.ts 提供)。
1550
1594
  // 必须排在 ...memoriesService.ops 之后 —— 这里是显式覆盖同名 op,不是新增。
1551
1595
  ...buildSceneSyncOps({
@@ -2168,7 +2212,15 @@ export default {
2168
2212
  const denied = await frozenGate('agentsmd-apply', { id });
2169
2213
  return denied ? { ok: false, error: denied } : applyPresetGuarded(id);
2170
2214
  };
2171
- buildPromptTools({ ...toolDeps, promptOps: auditOps(promptOps), applyPresetGuarded: applyPresetForModel, promptsDir });
2215
+ // 取消应用同理:`agentsmd-unapply` 在冻结清单里,模型侧不经 op 表直调就要在这里显式过
2216
+ // 冻结闸(与 apply 同一形状)—— 少了这一层,锁着场景时模型仍能撤掉当前基线。
2217
+ const unapplyPresetForModel = async () => {
2218
+ const denied = await frozenGate('agentsmd-unapply', {});
2219
+ return denied
2220
+ ? { ok: false, error: denied }
2221
+ : promptOps['agentsmd-unapply']();
2222
+ };
2223
+ buildPromptTools({ ...toolDeps, promptOps: auditOps(promptOps), applyPresetGuarded: applyPresetForModel, unapplyPreset: unapplyPresetForModel, promptsDir });
2172
2224
  buildMemoryTools({ ...toolDeps, rulesOps: auditOps(memoriesService.ops) });
2173
2225
  // 场景族(tools/scene.ts):列场景与档案(`_list`)+ 建场景 / 写档案 / 绑提示词(`_save`)
2174
2226
  // + 进入与退出(`_switch`)。
@@ -18,11 +18,11 @@
18
18
  // 其余 14 项(ensurePaths / withWriteLock / readPatch / pluginInventory / memoriesService 等)
19
19
  // 由 deps 显式传入。
20
20
  import { clearRuntimeNote, noteRuntime } from '../compat/runtime-notes.js';
21
+ import { mkdir } from 'node:fs/promises';
22
+ import { join } from 'node:path';
21
23
  import { MCP_CLIENT_MODULE, MCP_TOOL_PREFIX, ambiguousServerNames, looksLikeMcpPrefixDrift, serverNameCandidates, splitMcpToolName } from '../host-names.js';
22
24
  import { hubPath } from '../hub.js';
23
25
  import { planOverrideCompaction } from './override-blocks.js';
24
- // 配置体检的探测(PATH 查询 / 端点连通性 / 检查清单 + mcpm-inspect op),2026-10-01 剥出。
25
- import { createInspectProbe } from './inspect-probe.js';
26
26
  import { createMcpStateCatalog } from './state-section.js';
27
27
  import { createToolVisibility } from './tool-visibility.js';
28
28
  import { appendBlock, buildDisableBlock, buildInsertBlock, insertEntryIds, parseRows, removeEntryAll, removeMarked, spliceRanges, splitLines, } from './patch-yaml.js';
@@ -1519,15 +1519,19 @@ export function createMcpManager(deps) {
1519
1519
  return { ok: true, removed: total, ids: removed, files };
1520
1520
  });
1521
1521
  }
1522
- // ---------- 配置体检的探测(0.15.0 B1;0.16.5 起含端点探测)----------------------
1523
- // 正文 2026-10-01 整段搬到 ./inspect-probe.js(一字未改,只把闭包作用域的几个名字改走
1524
- // deps)。那段「对补丁只读、`where`/`which` 只查命令名绝不执行配置里的命令、复用列表结果
1525
- // 但不回写任何缓存」的口径连同理由一起搬过去了,见那边的文件头。
1526
- const { mcpmInspect } = createInspectProbe({
1527
- ensurePaths, readPatch, message, normalizeRow, mcpmList,
1528
- });
1529
1522
  // ---------- 配置的导出 / 导入 ----------
1530
- async function mcpmExport() {
1523
+ /**
1524
+ * `args.outDir`(可选):给了就写到该目录下的 `mcp-export.json`,不给则照旧写 hub 侧车。
1525
+ *
1526
+ * 判据与 `bundle-export` / `snapshot-export` 完全一致(必须绝对路径):相对路径会落到
1527
+ * 进程 CWD,那是猜。用户 2026-10-12:「mcp 的导出缺少选择导出目录」—— 此前这一档只能
1528
+ * 落到 `~/.dsh/tool-management/mcp-export.json`,界面上没有任何可改的出口。
1529
+ *
1530
+ * `args.dryRun`(可选):只算 JSON、**不落盘**。界面打开弹窗时先用它取预览,真正落盘
1531
+ * 由用户点「导出到目录」触发 —— 与其余四个导出弹窗同一套交互(不开弹窗就写文件是副作用)。
1532
+ * `snapshot-export` 走的是不带 dryRun 的老路径,行为不变。
1533
+ */
1534
+ async function mcpmExport(args) {
1531
1535
  const p = await ensurePaths();
1532
1536
  const list = await mcpmList();
1533
1537
  const rows = (list.rows || []).filter((r) => r.level !== 'loader').map((r) => ({
@@ -1544,16 +1548,24 @@ export function createMcpManager(deps) {
1544
1548
  }));
1545
1549
  const payload = { exportedAt: new Date().toISOString(), rows };
1546
1550
  const json = JSON.stringify(payload, null, 2);
1551
+ if (args && args.dryRun)
1552
+ return { ok: true, json, savedTo: null };
1553
+ const outDir = String((args && args.outDir) || '').trim();
1554
+ if (outDir && !/^([A-Za-z]:[\\/]|\\\\|\/)/.test(outDir))
1555
+ return { ok: false, error: '导出目录需为绝对路径' };
1547
1556
  let savedTo = null;
1548
1557
  try {
1549
- const abs = mcpSidecar(MCP_EXPORT_FILE);
1550
- // 用 `writeJsonFile`(直写 hub 侧车)而**不是** `writePatch`(2026-09-30 审查 P1-3):
1558
+ const abs = outDir ? join(outDir, MCP_EXPORT_FILE) : mcpSidecar(MCP_EXPORT_FILE);
1559
+ // 用 `writeJsonFile`(直写 JSON)而**不是** `writePatch`(2026-09-30 审查 P1-3):
1551
1560
  // `writePatch` 是宿主**补丁文件**的写口,写前会跑 `checkPatchWrite` 的官方方言校验
1552
1561
  // (顶层必须是 YAML 数组),而这里写的是 hub 里一份 JSON **对象** —— 校验必然失败、
1553
1562
  // 异常被下面的 catch 吞掉,于是 `savedTo` 恒为 null,界面显示"没能落盘(hub 目录写入
1554
1563
  // 失败)",**诊断是错的**(真实原因是方言校验器拒收 JSON 对象)。而且 `writePatch`
1555
1564
  // 自己的调用面纪律(`index.ts`:「不写宿主配置的动作也不该调用它」)本就排除了它 ——
1556
1565
  // 本处是唯一一个传 hub 路径的调用点。顺带省掉一次 `danger-full-access` 策略升级。
1566
+ // `writeJsonFile` 不建父目录(hub 路径总是已存在),所以自定义目录要自己 mkdir。
1567
+ if (outDir)
1568
+ await mkdir(outDir, { recursive: true });
1557
1569
  await writeJsonFile(abs, payload);
1558
1570
  savedTo = abs;
1559
1571
  }
@@ -1792,7 +1804,6 @@ export function createMcpManager(deps) {
1792
1804
  'mcpm-compact': mcpmCompact,
1793
1805
  'mcpm-export': mcpmExport,
1794
1806
  'mcpm-import': mcpmImport,
1795
- 'mcpm-inspect': mcpmInspect,
1796
1807
  },
1797
1808
  readDisabledTools,
1798
1809
  readKnownMcpTools,
@@ -15,7 +15,7 @@
15
15
  // 「那几段」= **userinfo / 查询串 / 片段**三处(2026-09-30 审查 F5):`new URL()` 把 userinfo
16
16
  // 与 fragment 存在 `username/password` 与 `hash` 里,`toString()` 原样带出。只换 `search`
17
17
  // 的话,`https://user:pass@host/mcp`(Basic-Auth 形态)与 `https://host/mcp#access_token=…`
18
- // 会整条原样返回 —— 而这两条路正是免令牌出口(`mcpm-list` / `mcpm-inspect` / 模型工具 /
18
+ // 会整条原样返回 —— 而这两条路正是免令牌出口(`mcpm-list` / 模型工具 /
19
19
  // 快照导出)。路径与主机**不动**:它们是该服务的身份,算进来会把真 URL 误判成打码值。
20
20
  //
21
21
  // 判据为什么可以这么硬:打码值只可能表示"用户没改这一项",绝不可能表示"用户想把它设成这个"。
@@ -170,7 +170,7 @@ function maskUrlPartsByString(url) {
170
170
  * fragment 存在 `username/password` 与 `hash` 里,`toString()` 会原样带出 ——
171
171
  * 只替换 `search` 的话,`https://user:pass@host/mcp` 与 `https://host/mcp#access_token=…`
172
172
  * 会**整条原样返回**(后者连 `?` 都没有),而这两条路正是免令牌出口
173
- * (`mcpm-list` / `mcpm-inspect` / 模型工具 / 快照导出)。Basic-Auth 形态的凭据就此明文可见。
173
+ * (`mcpm-list` / 模型工具 / 快照导出)。Basic-Auth 形态的凭据就此明文可见。
174
174
  */
175
175
  export function maskUrlQuery(url) {
176
176
  if (!url)
@@ -188,5 +188,12 @@ export const fail = (code, error, params) => (params ? { ok: false, error, code,
188
188
  * 旧名 `rules-index.json` / `rules-trash/` 由 hub 的启动迁移搬过来(见 hub.ts)。
189
189
  */
190
190
  export const MEMORIES_INDEX_FILE = 'memories-index.json';
191
- /** 记忆回收站目录名(hub 根下)。ops 分域后 trash 域要用,故与索引文件名一起放在这一层。 */
192
- export const MEMORIES_TRASH_DIR = 'memories-trash';
191
+ /**
192
+ * 记忆回收站目录(**hub 内相对路径**,故带 `trash/` 段)。
193
+ *
194
+ * 2026-10-10 起与其余五类同处一处:`hub/trash/memories-trash/`。此前它是 hub 根下的
195
+ * `memories-trash/`,独立于 `trash/` —— 界面上六类早就统一成一个「回收站」了,落点却分两处,
196
+ * 按 `trash/` 写备份 / 迁移脚本会漏掉这一类(条目数最多的那类)。
197
+ * 老位置由 hub 的启动迁移搬过来(见 `hub.ts` 的 `HUB_RENAMES`),只搬不删。
198
+ */
199
+ export const MEMORIES_TRASH_DIR = 'trash/memories-trash';
@@ -65,7 +65,7 @@ import { deriveFromDoc, projectRule, relocateLegacyLayout, buildSnapshot, probeS
65
65
  * 场景/记忆根目录名($DSH_HOME 下),集中在 `tool-management/` 一个目录内:
66
66
  * - `memories/<场景>/…` 记忆正文真源
67
67
  * - `..`(即 tool-management 根)侧车:memories-index.json(内含 `scenes` 切片 = 场景记录
68
- * 的**真源**)/ skills-state.json / trash/ / memories-trash/
68
+ * 的**真源**)/ skills-state.json / trash/(六类回收站,含 `trash/memories-trash/`)
69
69
  *
70
70
  * `scenes/`(`SCENES_DIR`)是 v0.3 遗留的**空壳**:只创建、只回显路径,没有任何读写。
71
71
  * 别把场景记录的位置写成它。
@@ -728,6 +728,7 @@ export function createMemoriesService(ctx, deps) {
728
728
  // 抽出去的 ops/*.ts 只通过它访问闭包,任何漏挂都会在 tsc 阶段报「Cannot find name」。
729
729
  const rc = {
730
730
  stateDir, memoriesRoot, scenesRoot, maxBytes,
731
+ ...(deps.skillsTrashDir ? { skillsTrashDir: deps.skillsTrashDir } : {}),
731
732
  snapshot, invalidateSnapshot, sceneMemory, scenePrompt,
732
733
  parseId, locateRule, refuseOutsideRoot, buildProjected, sceneRows,
733
734
  copyIntoMemoriesTrash, listAttachments, serializeRuleFile, serializeUpdatedFile,
@@ -759,6 +760,10 @@ export function createMemoriesService(ctx, deps) {
759
760
  'rules-remove': (args) => runWrite(() => trashOps.rulesRemove(args || {})),
760
761
  'rules-restore': (args) => runWrite(() => trashOps.rulesRestore(args || {})),
761
762
  'rules-trash-list': () => trashOps.rulesTrashList(),
763
+ // 回收站「详情」:跨域只读预览(记忆 / 技能 / 提示词 / 人设 / 场景五类的条目都能查)。
764
+ // 只 lstat + 递归读文件,不写任何东西 ⇒ 不进写队列、也不 refresh 快照。
765
+ // 登记在 op-registry 的 `readonly` 一档:**锁定期间照样能看**(锁的是写,不是看)。
766
+ 'trash-preview': (args) => trashOps.trashPreview(args || {}),
762
767
  'rules-trash-remove': (args) => runWrite(() => trashOps.rulesTrashRemove(args || {})),
763
768
  'rules-attach': (args) => runWrite(() => memoryOps.rulesAttach(args || {})),
764
769
  'rules-detach': (args) => runWrite(() => memoryOps.rulesDetach(args || {})),
@@ -57,12 +57,6 @@ 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
- // 配置体检:只读补丁文件 + 一次 PATH 查询(`where`/`which`,不执行配置里的命令)。
61
- // 回的是检查项 id + 少量**打码后**的字段值(URL 查询串走 `maskUrlQuery`,与 `mcpm-list`
62
- // 同口径;`command` 明文,理由同 `mcpm-list`:命令名不是凭据,`args` 两边都不回)。
63
- // 与 `mcpm-list` 同档,不带令牌 —— 0.16.6 之前这里回的是 URL 原文,等于让免令牌的请求
64
- // 拿到 `?api_key=…`,绕过了 `mcpm-reveal` 那道防线(审查 P0-5,0.17.0 修)。
65
- 'mcpm-inspect': { readonly: true },
66
60
  // ── 技能域(22,含内联的 skill-open)───────────────────────────────────────
67
61
  'skill-state': { annotatesLock: true },
68
62
  'skill-detail': { readonly: true },
@@ -88,6 +82,9 @@ export const OP_REGISTRY = Object.freeze({
88
82
  'skill-trash-delete': { serviceWrite: true, frozen: true },
89
83
  'skill-custom-add': { serviceWrite: true, frozen: true },
90
84
  'skill-custom-remove': { serviceWrite: true, frozen: true },
85
+ // 永久删除一条**按约定发现**的来源记录(agents / codex / claude):只写本地状态里的
86
+ // `forgottenSources`,磁盘源文件一个字节都不动。与 `skill-custom-remove` 同权。
87
+ 'skill-source-forget': { serviceWrite: true, frozen: true },
91
88
  // 打开系统编辑器改文件:不改宿主状态,但它是"界面按钮"性质的写入口,按写门禁。
92
89
  'skill-open': { write: true },
93
90
  // ── 记忆与场景域(27:memories 24 + archive-engine 3)──────────────────────
@@ -96,6 +93,9 @@ export const OP_REGISTRY = Object.freeze({
96
93
  'rules-budget': { readonly: true },
97
94
  'rules-diagnose': { readonly: true },
98
95
  'rules-trash-list': { readonly: true },
96
+ // 回收站「详情」:跨域只读(五类回收站共用一条,域由 args.domain 区分)。只读 → 不冻,
97
+ // 锁定的场景里照样能看一眼"删掉的是什么"(看不是改,与「编辑页可打开」同一条口径)。
98
+ 'trash-preview': { readonly: true },
99
99
  'rules-create': { serviceWrite: true, frozen: true },
100
100
  'rules-update': { serviceWrite: true, frozen: true },
101
101
  'rules-remove': { serviceWrite: true, frozen: true },
@@ -140,7 +140,7 @@ export const OP_REGISTRY = Object.freeze({
140
140
  'subagent-toggle': { serviceWrite: true, frozen: true, syncsArchive: true },
141
141
  'subagent-trash-restore': { serviceWrite: true, frozen: true },
142
142
  'subagent-trash-delete': { serviceWrite: true, frozen: true },
143
- // ── 提示词域(11)──────────────────────────────────────────────────────────
143
+ // ── 提示词域(12)──────────────────────────────────────────────────────────
144
144
  'agentsmd-list': { annotatesLock: true },
145
145
  'agentsmd-read': { readonly: true },
146
146
  'agentsmd-get-current': { readonly: true },
@@ -149,6 +149,8 @@ export const OP_REGISTRY = Object.freeze({
149
149
  'agentsmd-update': { write: true, frozen: true },
150
150
  // apply 写全局 AGENTS.md = 换掉生效基线。
151
151
  'agentsmd-apply': { write: true, frozen: true },
152
+ // unapply 同样写全局 AGENTS.md(把它恢复成链起点,即开始应用之前的那份内容)→ 与 apply 同档。
153
+ 'agentsmd-unapply': { write: true, frozen: true },
152
154
  'agentsmd-remove': { write: true, frozen: true },
153
155
  'agentsmd-import': { write: true, frozen: true },
154
156
  'agentsmd-trash-restore': { write: true, frozen: true },
@@ -188,6 +190,19 @@ export const OP_REGISTRY = Object.freeze({
188
190
  'bundle-export': { write: true },
189
191
  // 新增一条宿主工作区登记 —— 改的是宿主侧的登记,不是插件自己的侧车。
190
192
  'history-workspace-register': { write: true },
193
+ // ── 回收站保留期(2)──────────────────────────────────────────────────────
194
+ // 与 History 页的保留期(上面那两条)是同一个功能出现在两个页面:同一份侧车形状
195
+ // (`{retentionDays, updatedAt}`)、同一条到期判据、同一个「改设置即重置倒计时」语义。
196
+ // 读侧是纯读(客户端 boot 时必问一次拿当前值),与 `history-retention-get` 同档。
197
+ 'trash-retention-get': { readonly: true },
198
+ // 写侧**比 `history-retention-set` 多一层冻结**,这是一处刻意的不同:
199
+ // · 历史页那条只影响「已归档会话」—— 归档是用户主动收进去的,删了就是删了;
200
+ // · 这一条成功后立即扫一次,**能永久删掉六类回收站条目里的任意一条**,而六条手工
201
+ // 删除 op(`skill-trash-delete` / `agentsmd-trash-delete` / `subagent-trash-delete` /
202
+ // `quickprompt-trash-delete` / `rules-trash-remove`)全都是冻结的。
203
+ // 一个能删掉所有回收站条目的 op 却比单条删除更宽松,等于给冻结开了后门。
204
+ // (`scene-trash-delete` 是唯一没标冻结的那条,属既有不一致,不在本次范围。)
205
+ 'trash-retention-set': { write: true, frozen: true },
191
206
  // ── 兼容 / 注入 / 备份 / 流水 / 迁移快照(11)────────────────────────────────
192
207
  'compat-status': { readonly: true },
193
208
  // 「最近改动」流水的读侧:只读 hub 里的 `audit.jsonl`(内容只有对象名,见 audit-log.ts 第 ① 条)。
@@ -71,10 +71,44 @@ export function buildPromptOps(deps) {
71
71
  },
72
72
  // 应用 = 写全局基线文件;场景接管期间只放行「场景绑定的那一份」(见 applyPresetGuarded)。
73
73
  'agentsmd-apply': (args) => deps.applyPresetGuarded(String((args && args.id) || '')),
74
+ // 取消应用 = **回到链起点**(这一串应用/取消往复开始之前的状态):起点有内容就把
75
+ // `__last-applied__/AGENTS.md` 写回去,起点文件不存在就把文件删掉(`removed: true`)。
76
+ // 连续应用过 A、B 时是**一步回到起点**,不是只退一步 —— 与场景基线同一条口径
77
+ // (见 `service.ts` 的 `backupGlobal`)。
78
+ // 它是「生效中」那颗开关的**关**方向 —— 此前只有「应用」没有反向动作,于是「只有一份预设
79
+ // 且它生效中」时开关关不掉、删除又被引用拦着(2026-10-10 实测反馈)。
80
+ //
81
+ // 用户 2026-10-10 第二次反馈「取消应用,AGENTS.md 还是存在」暴露了另一半:`backupGlobal()`
82
+ // 原先在文件不存在时**什么都不写**,"起点不存在"因此不可恢复,取消应用恒判 noBackup,
83
+ // 而这里给出的出路「删除这份预设」不成立(remove 不碰 AGENTS.md)。现在那个事实由
84
+ // `__last-applied__/absent.json` 记着,删文件这一支才走得通。
85
+ // 第三次反馈(同一天)「新建 a、b 两份提示词,启动 a,再启动 b,不启动 a 也不启动 b,
86
+ // AGENTS.md 没有消失」则是这个标记**被后续写入抹掉**:`backupGlobal` 原先每笔都覆盖链
87
+ // 起点槽位,第 ③ 步「应用 b」就把"起点不存在"刷成了 a 的内容。改成链起点只写一次。
88
+ //
89
+ // 场景驱动时**拒绝**:那时基线归场景管(生效中的是场景绑定的那一份),在这里撤销只会被
90
+ // 下一次场景同步写回去,而且「取消应用」对场景绑定也不成立 —— 要解除得去场景页改绑定。
91
+ 'agentsmd-unapply': async () => {
92
+ const driver = await deps.scenePromptSync.driver();
93
+ if (driver) {
94
+ return {
95
+ ok: false,
96
+ code: 'error.agentsMd.unapplyWhileScene',
97
+ error: `场景「${driver.label}」正在驱动全局基线,不能在这里取消应用:先退出场景,或到场景页改掉它的提示词绑定。`,
98
+ params: { scene: driver.label },
99
+ };
100
+ }
101
+ const res = await deps.promptsService.unapply();
102
+ if (res && res.ok === false) {
103
+ // 「没有链起点」是一种**确定的**状态(不是故障):AGENTS.md 的当前内容不是由
104
+ // 「应用」写进去的(首次打开时播种的 default 就是这样)。单给一个 code,界面才能
105
+ // 把出路说清楚(应用别的预设 / 直接编辑 AGENTS.md),而不是丢一句写盘失败。
106
+ return res.noBackup ? { ok: false, code: 'error.agentsMd.noUnapplyBackup', error: res.error } : res;
107
+ }
108
+ return res;
109
+ },
74
110
  'agentsmd-get-current': () => deps.promptsService.getCurrent(),
75
- // 删除:**被引用的那份一律拒绝**(用户裁定,2026-09-16 扩到引用清单)。三类引用:
76
- // 场景绑定(含恒常的 `_shared` / `global`)、`~/.dsh/AGENTS.md` 的当前内容、
77
- // 进场景前保存的基线(退出场景后要恢复的那一份)。拒绝时逐条说明「谁在用」,
111
+ // 删除:**场景绑定**与**当前应用的那份**拦得住(判据见下方注释)。拒绝时逐条说明「谁在用」,
78
112
  // 用户知道该先改哪里。引用清单由 scene-prompt-sync 与显示/注入同源算出。
79
113
  //
80
114
  // 探测**失败**时改为拒绝(2026-09-30 审查 F13)。原来的取舍是「放行并记 warn:删预设不动
@@ -96,13 +130,36 @@ export function buildPromptOps(deps) {
96
130
  params: { id },
97
131
  };
98
132
  }
99
- const why = refs.get(id) || [];
100
- if (why.length) {
101
- const refsText = why.map(deps.promptRefReason).join(';');
133
+ // 删除保护认两类**真依赖**(判据是「删掉它会不会让别处静默坏掉」或「会不会留下一个
134
+ // 用户无法理解的中间态」):
135
+ // ① 场景绑定 → 会静默坏掉:场景驱动靠 `presetId` 去读预设正文,读不到 `driver()`
136
+ // 就返回 null,场景静默失效(AGENTS.md 停在场景态,用户看到的是"什么都没做")。
137
+ // ② **当前应用的**那份(`file` + `applied`,见下)→ 会留下中间态:删掉之后
138
+ // `~/.dsh/AGENTS.md` 内容一字不变、**照旧生效**,但界面上再没有任何预设对应它。
139
+ // 用户看到的是「我把它删了,怎么全局提示词还在?」(2026-10-10 用户反馈:
140
+ // 「当前应用的提示词如果直接删除,AGENTS.md 还存在,当前应用的提示词应该不能删除」)。
141
+ // 拦住并引导先「取消应用」,这一步会把文件恢复成链起点(开始应用之前的状态),
142
+ // 之后就能删了。
143
+ //
144
+ // **不拦**的两类(各自都有明确的理由,别顺手加回来):
145
+ // - `file` 但没有 `applied`(首次打开提示词页时自动播种出来的 default):它内容与
146
+ // AGENTS.md 相同、界面标「文件里是它」,却从未发生过写入。删掉它完全合理(那只是
147
+ // 一份副本),而**拦它就是死路** —— 那份没有链起点可回,取消应用关不掉。
148
+ // - `restore`(退出场景要恢复的那一份):恢复走的是 `scene-baseline.json` 里存下的
149
+ // **正文**(scene-prompt-sync 的 `restore(saved.content)`),不读预设目录。
150
+ // 2026-09-16 的裁定是三类一律拦,那条规则在「只有一份预设且它生效中」时构成**死锁**:
151
+ // 开关关不掉(基线没有"全部不应用"这一态)、引用又解不开。现在开关能关了(取消应用),
152
+ // 拦 ② 才有出路 —— 取消应用会把 `__applied__.json` 清成 null,`applied` 随之变 false。
153
+ const blocking = (refs.get(id) || []).filter((r) => r.kind === 'scene' || (r.kind === 'file' && r.applied === true));
154
+ if (blocking.length) {
155
+ const refsText = blocking.map(deps.promptRefReason).join(';');
156
+ const hasScene = blocking.some((r) => r.kind === 'scene');
157
+ const hasFile = blocking.some((r) => r.kind === 'file');
158
+ const escape = [hasScene ? '到场景页换绑提示词、或退出场景' : '', hasFile ? '先点左边那颗开关「取消应用」' : ''].filter(Boolean).join(';');
102
159
  return {
103
160
  ok: false,
104
161
  code: 'error.agentsMd.referenced',
105
- error: `「${id}」仍被引用,不能删除:${refsText}。先改掉引用(换绑提示词 / 退出场景 / 应用别的预设)再删除。`,
162
+ error: `「${id}」不能删除:${refsText}。先解除引用再删 —— ${escape}。`,
106
163
  params: { id, refs: refsText },
107
164
  };
108
165
  }
@@ -18,6 +18,31 @@ import { isValidPresetId } from '../prompts/preset-id.js';
18
18
  import { defaultPersonasDir, validPersonaName } from '../subagents/service.js';
19
19
  import { isInsideRoot } from '../paths.js';
20
20
  import { planMemoryExport } from '../memories/service.js';
21
+ /**
22
+ * Linux 的桌面目录:按 XDG 用户目录规范从 `user-dirs.dirs` 里读 `XDG_DESKTOP_DIR`。
23
+ *
24
+ * 为什么需要它:中文 / 日文等本地化环境里,桌面目录的**真名**不是 `Desktop`,而是
25
+ * `桌面` / `デスクトップ` —— 这时 `join(homedir(), 'Desktop')` 探不到,我们会白白退回主目录。
26
+ * 配置文件里的值形如 `"$HOME/桌面"`(含 `$HOME` 变量与引号),这里做最小解析:
27
+ * 去引号、把 `$HOME` / 前导 `~` 换成真实主目录。
28
+ *
29
+ * 读不到 / 没有这一行 / 值不是绝对路径 → 返回 undefined,调用方继续试下一个候选。
30
+ * 纯只读,任何异常都吞掉(这只是"默认值填哪儿"的增强,不该影响导出本身)。
31
+ */
32
+ async function xdgDesktopDir() {
33
+ try {
34
+ const base = String(process.env.XDG_CONFIG_HOME || '').trim() || join(homedir(), '.config');
35
+ const text = await readFile(join(base, 'user-dirs.dirs'), 'utf8');
36
+ const m = /^\s*XDG_DESKTOP_DIR\s*=\s*"([^"]*)"/m.exec(text);
37
+ if (!m)
38
+ return undefined;
39
+ const raw = m[1].replace(/^\$HOME(?=[\\/]|$)/, homedir()).replace(/^~(?=[\\/]|$)/, homedir());
40
+ return raw && (raw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(raw)) ? raw : undefined;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
21
46
  export function buildSessionOps(deps) {
22
47
  return {
23
48
  // History(归档会话管理)ops。只读:history-list / history-retention-get;
@@ -114,16 +139,35 @@ export function buildSessionOps(deps) {
114
139
  return { ok: false, error: deps.message(e) };
115
140
  }
116
141
  },
117
- // 导出默认目录:桌面(存在时)否则用户主目录。只读,不门控。
142
+ // 导入 / 导出的默认目录:桌面(存在时)否则用户主目录。只读,不门控。
143
+ //
144
+ // 名字里带 `history` 是历史原因,但它早就是**全插件共用的**默认 IO 目录:整机迁移导出
145
+ // / 快照位置 / 会话导出 / MCP JSON 导出 / 技能自定义来源目录都取这一份。之所以必须问宿主、
146
+ // 不能让客户端写死:桌面在哪取决于**跑宿主的那台机器 + 那个用户**——
147
+ // Windows C:\Users\<用户名>\Desktop
148
+ // macOS /Users/<用户名>/Desktop
149
+ // Linux /home/<用户名>/Desktop,且桌面目录**未必存在**(也可能是本地化名字,
150
+ // 例如中文环境下的 ~/桌面 —— 那种走 XDG 配置,见下)
151
+ // 全部用 `os.homedir()` 起算,换用户 / 换系统自动跟着走。
118
152
  'history-export-defaults': async () => {
119
153
  let dir = homedir();
120
- try {
121
- const desktop = join(homedir(), 'Desktop');
122
- const st = await stat(desktop);
123
- if (st.isDirectory())
124
- dir = desktop;
154
+ // Linux 的桌面目录名可能被本地化:先按 XDG 用户目录规范问一次,再退回惯用的 Desktop。
155
+ // 两条都探不到就用主目录 —— 主目录一定存在,且导出的只是子目录,不影响可用性。
156
+ const candidates = [join(homedir(), 'Desktop')];
157
+ if (process.platform === 'linux') {
158
+ candidates.unshift(await xdgDesktopDir());
159
+ }
160
+ for (const candidate of candidates) {
161
+ if (!candidate)
162
+ continue;
163
+ try {
164
+ if ((await stat(candidate)).isDirectory()) {
165
+ dir = candidate;
166
+ break;
167
+ }
168
+ }
169
+ catch { /* 这个候选不可用 → 试下一个 */ }
125
170
  }
126
- catch { /* 桌面路径不可用 → 回退主目录 */ }
127
171
  return { ok: true, defaultDir: dir };
128
172
  },
129
173
  // 列出目录的子目录,供客户端「选择文件夹」弹窗逐级浏览。dir 为空时返回根
@@ -321,6 +365,17 @@ export function buildSessionOps(deps) {
321
365
  return { ok: false, error: '当前环境不支持重新登记工作区(registry 未实现 registerWorkspace)' };
322
366
  }
323
367
  const title = args && args.title ? String(args.title) : undefined;
368
+ // 目录已被删除时提前拦下(用户 2026-10-12:工作区文件夹删掉后不该还能「重新登记」)。
369
+ // 宿主 registerWorkspace 内部的 realpath 本来就会拒绝,但报错文本是底层的;这里给
370
+ // 一句能直接读懂的提示。界面在 dirMissing 时已不再渲染该按钮,这条是防御性兜底。
371
+ try {
372
+ const st = await stat(path);
373
+ if (!st.isDirectory())
374
+ return { ok: false, error: '目录已不存在,无法重新登记:' + path };
375
+ }
376
+ catch {
377
+ return { ok: false, error: '目录已不存在,无法重新登记:' + path };
378
+ }
324
379
  try {
325
380
  const workspace = await registry.registerWorkspace(path, title);
326
381
  return { ok: true, workspace };