dsh-plugin-tool-management 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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';
@@ -206,6 +206,10 @@ export default {
206
206
  memoriesRoot: String(config?.memoriesRoot || ''),
207
207
  stateDir: String(config?.memoriesStateDir || ''),
208
208
  scenesDir: String(config?.memoriesScenesDir || ''),
209
+ // 技能回收站根目录:只给回收站「详情」(`trash-preview`)读技能条目用。路径的唯一定义
210
+ // 在 skills/skill-roots.ts(`trashRootPath()`),这里原样转手,不在记忆域重算一遍 ——
211
+ // 重算就是第二个真相源,改一处忘一处时详情会指向一个空目录。
212
+ skillsTrashDir: trashRootPath(),
209
213
  // 场景记忆段预算(字节),默认 131072(= DEFAULT_MAX_BYTES,128 KiB);仅用于测试与特殊部署调优。
210
214
  ...(Number.isFinite(Number(config?.rulesMaxBytes))
211
215
  ? { maxBytes: Number(config.rulesMaxBytes) }
@@ -882,8 +886,8 @@ export default {
882
886
  // 启动那次清扫读完侧车就返回了,一次磁盘遍历都不做。
883
887
  //
884
888
  // 记忆回收站那两条**走 memories 域的 op**,不在这里重抄路径与 manifest 形状:它在
885
- // `hub/memories-trash/`(hub 根下的独立目录,不在 `trash/` 里),形状由 memories 域
886
- // 自己定 —— 重抄一遍只会得到第二份会漂移的真相(hub.ts 顶部那张目录图专门写过这件事)。
889
+ // `hub/trash/memories-trash/`(2026-10-10 起与其余五类同处一处,之前是 hub 根下的独立
890
+ // 目录),形状由 memories 域自己定 —— 重抄一遍只会得到第二份会漂移的真相。
887
891
  const trashRetention = createTrashRetentionDomain({
888
892
  ctx,
889
893
  config,
@@ -2208,7 +2212,15 @@ export default {
2208
2212
  const denied = await frozenGate('agentsmd-apply', { id });
2209
2213
  return denied ? { ok: false, error: denied } : applyPresetGuarded(id);
2210
2214
  };
2211
- 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 });
2212
2224
  buildMemoryTools({ ...toolDeps, rulesOps: auditOps(memoriesService.ops) });
2213
2225
  // 场景族(tools/scene.ts):列场景与档案(`_list`)+ 建场景 / 写档案 / 绑提示词(`_save`)
2214
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 },
@@ -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 };
package/lib/ops/trash.js CHANGED
@@ -4,9 +4,10 @@
4
4
  // 但共用 copyIntoMemoriesTrash 等入口,经 MemoriesOpsCtx 显式传入。
5
5
  import { copyFile, cp, lstat, mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
7
- import { isValidTrashId } from '../hub.js';
7
+ import { isValidTrashId, readTrashPayloadFiles, trashEntryPath } from '../hub.js';
8
8
  import { LEGACY_BUNDLE_DOC, bundleDocName, isValidGroupSegment, isValidGroupPath, fail, MEMORIES_TRASH_DIR } from '../memories/constants.js';
9
9
  import { readIndex, writeIndex } from '../memories/index-io.js';
10
+ import { isValidSegment } from '../paths.js';
10
11
  /** 组装本域 op。rc 由 createMemoriesService 组装,契约见 ./ctx.ts。 */
11
12
  export function buildTrashOps(rc) {
12
13
  const { buildProjected, copyIntoMemoriesTrash, invalidateSnapshot, locateRule, parseId, refuseOutsideRoot, memoriesRoot, stateDir } = rc;
@@ -104,7 +105,7 @@ export function buildTrashOps(rc) {
104
105
  return { ok: true, rule: await buildProjected(manifest.group ? `${manifest.group}/${manifest.name}` : manifest.name) };
105
106
  }
106
107
  /**
107
- * 记忆回收站列表(只读):`<stateDir>/memories-trash/<trashId>/`(`rulesRemove` 移入,
108
+ * 记忆回收站列表(只读):`<stateDir>/trash/memories-trash/<trashId>/`(`rulesRemove` 移入,
108
109
  * `rulesRestore` 恢复)。损坏或内容缺失的条目跳过,不让一个坏条目挡住整份列表。
109
110
  */
110
111
  async function rulesTrashList() {
@@ -172,5 +173,63 @@ export function buildTrashOps(rc) {
172
173
  await rm(dir, { recursive: true, force: true });
173
174
  return { ok: true };
174
175
  }
175
- return { rulesRemove, rulesRestore, rulesTrashList, rulesTrashRemove };
176
+ /**
177
+ * 回收站「详情」(只读):把一条条目的内容读出来给界面看一眼"删掉的到底是什么"。
178
+ *
179
+ * 用户 2026-10-12:「回收站的每条信息应该增加一个详情按键,点开就能看这个信息是什么样子,
180
+ * 不能更改内容,没有保存按键」。所以这里**只有读**:不写盘、不改名、不动回收站条目。
181
+ *
182
+ * 为什么是一条跨域的 op 而不是五个域各加一条:五个回收站落在**三套不同的目录布局**里 ——
183
+ * hub 四类:`<hub>/trash/<kind>-trash/<id>/`(`trashEntryPath`)
184
+ * 记忆: `<stateDir>/trash/memories-trash/<trashId>/`
185
+ * 技能: `<hub>/trash/skills-trash/<id>/`(skills 域的 `trashRootPath()`,由 index 注入)
186
+ * 但"读出来给人看"这件事完全一样,交给 `readTrashPayloadFiles` 一个函数做完,
187
+ * 上限 / 二进制判据 / 哪些名字算清单只写一遍。
188
+ *
189
+ * 条目目录不存在 / 不是目录 → 明确报"条目不存在"(与"目录在、但里面没有可显示的文件"
190
+ * 分开:前者是条死链,后者是一条正常但没内容的记录)。读不动的单个文件由
191
+ * `readTrashPayloadFiles` 自己跳过,不抛 —— 详情打不开不该影响用户对这条目做恢复 / 永久删除。
192
+ */
193
+ async function trashPreview(args) {
194
+ const domain = String((args && args.domain) || '');
195
+ const id = String((args && args.id) || '').trim();
196
+ const hubKinds = { presets: 'prompts', quick: 'quick-prompts', agents: 'subagents', scenes: 'scenes' };
197
+ let dir = null;
198
+ if (hubKinds[domain] || domain === 'memories') {
199
+ // hub 四类与记忆回收站的条目名都由 `newTrashId()` 生成(时间戳 base36 + uuid 前 8 位),
200
+ // 走那一套严格白名单 —— 它同时也是 `rulesRestore` / 硬删除用的同一道闸。
201
+ if (!isValidTrashId(id))
202
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
203
+ dir = hubKinds[domain]
204
+ ? trashEntryPath(hubKinds[domain], id)
205
+ : join(stateDir, MEMORIES_TRASH_DIR, id);
206
+ }
207
+ else if (domain === 'skills') {
208
+ // ⚠️ 技能回收站的条目名是 `${Date.now()}-${randomUUID()}`(见 core.ts 的移入回收站那段),
209
+ // **不满足** `isValidTrashId` 那条白名单(多段连字符 + 大写十六进制)—— 拿它去判会把
210
+ // 每一条技能条目都判成"不存在",界面上就是"详情点了没反应"。
211
+ // 这里换成与 `skill-trash.ts` 的 `entryPath` 同一个口径:能安全当**一个路径段**即可
212
+ //(不含分隔符 / 不是 `.` `..` / 首尾无空白与点)。以点开头的一律拒 —— `listTrash()`
213
+ // 本来就把 `.stage-*` 那类临时目录排除在外,界面拿不到它们,这里也不该读得到。
214
+ if (!isValidSegment(id, 128) || id.startsWith('.'))
215
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
216
+ dir = rc.skillsTrashDir ? join(rc.skillsTrashDir, id) : null;
217
+ }
218
+ else
219
+ return fail('error.rules.notFound', `这个回收站不支持查看详情:${domain || '(空)'}`);
220
+ if (!dir)
221
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
222
+ // 条目目录本身必须真实存在(`readTrashPayloadFiles` 对不存在的目录返回空数组,
223
+ // 那种"空"与"条目不存在"要分开:前者能恢复、后者是条死链)。
224
+ try {
225
+ const st = await lstat(dir);
226
+ if (!st.isDirectory() || st.isSymbolicLink())
227
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
228
+ }
229
+ catch {
230
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
231
+ }
232
+ return { ok: true, id, domain, files: await readTrashPayloadFiles(dir) };
233
+ }
234
+ return { rulesRemove, rulesRestore, rulesTrashList, rulesTrashRemove, trashPreview };
176
235
  }
@@ -205,6 +205,24 @@ export function createHistoryDomain(deps) {
205
205
  const liveByPath = new Map();
206
206
  const owned = new Map();
207
207
  const liveRecords = [];
208
+ // 「目录真实存在」检查缓存:活登记与重建组共用同一份探测,同一目录只 stat 一次;
209
+ // 结果按归一化键缓存,避免每次刷新重复探盘。
210
+ const dirExistsCache = new Map();
211
+ async function directoryExists(p) {
212
+ const k = workspacePathKey(p);
213
+ const hit = dirExistsCache.get(k);
214
+ if (hit !== undefined)
215
+ return hit;
216
+ let ok = false;
217
+ try {
218
+ ok = (await stat(p)).isDirectory();
219
+ }
220
+ catch {
221
+ ok = false;
222
+ }
223
+ dirExistsCache.set(k, ok);
224
+ return ok;
225
+ }
208
226
  const table = typeof registry.requireTable === 'function' ? registry.requireTable() : undefined;
209
227
  const state = typeof registry.requireState === 'function' ? registry.requireState() : undefined;
210
228
  if (table) {
@@ -224,7 +242,14 @@ export function createHistoryDomain(deps) {
224
242
  liveByPath.set(workspacePathKey(path), wid);
225
243
  liveRecords.push({ id: String(wid), path, title });
226
244
  }
227
- const view = { id: 'ws:' + wid, title, kind: 'live', registered: true, ...(path ? { path } : {}) };
245
+ // 活登记也要探一次盘:登记还在、但磁盘上的工作区文件夹被删掉时,界面必须能及时
246
+ // 标出来(用户 2026-10-12:「要怎么及时知道这个工作区实际文件夹已经被删除了」)。
247
+ const liveDirMissing = path !== undefined && !(await directoryExists(path));
248
+ const view = {
249
+ id: 'ws:' + wid, title, kind: 'live', registered: true,
250
+ ...(path ? { path } : {}),
251
+ ...(liveDirMissing ? { dirMissing: true } : {}),
252
+ };
228
253
  groupsById.set(view.id, view);
229
254
  groups.push(view);
230
255
  workspaces[wid] = { title, ...(path ? { path } : {}) };
@@ -240,24 +265,6 @@ export function createHistoryDomain(deps) {
240
265
  }
241
266
  const sessionPaths = registry.sessionPaths instanceof Map ? registry.sessionPaths : undefined;
242
267
  const headers = registry.headers instanceof Map ? registry.headers : undefined;
243
- // 「目录真实存在」检查缓存:canRegister 从「宿主 sessionPaths 命中」放宽为
244
- // 「目录存在」时,同一目录只 stat 一次;结果按归一化键缓存,避免每次刷新重复探盘。
245
- const dirExistsCache = new Map();
246
- async function directoryExists(p) {
247
- const k = workspacePathKey(p);
248
- const hit = dirExistsCache.get(k);
249
- if (hit !== undefined)
250
- return hit;
251
- let ok = false;
252
- try {
253
- ok = (await stat(p)).isDirectory();
254
- }
255
- catch {
256
- ok = false;
257
- }
258
- dirExistsCache.set(k, ok);
259
- return ok;
260
- }
261
268
  for (const it of items) {
262
269
  const sid = it.sessionId;
263
270
  let wid = owned.get(sid);
@@ -287,14 +294,17 @@ export function createHistoryDomain(deps) {
287
294
  const known = snapshot ? snapshot.get(workspacePathKey(path)) : undefined;
288
295
  const existing = groupsById.get(key);
289
296
  if (existing) {
290
- // 同目录的多个会话:宿主索引命中,或磁盘目录真实存在,即可重新登记。
291
- if (canonical !== undefined || (await directoryExists(path))) {
297
+ // 同目录的多个会话:只要磁盘目录真实存在,即可重新登记。
298
+ // 注意:不能只看宿主 sessionPaths 命中——索引里可能残留已被删除目录的 sessionId。
299
+ if (await directoryExists(path)) {
292
300
  existing.canRegister = true;
293
301
  existing.dirMissing = false;
294
302
  }
295
303
  continue;
296
304
  }
297
- const canRegister = canonical !== undefined || (await directoryExists(path));
305
+ // canRegister 必须落到「目录真实存在」:仅靠 canonical/sessionPaths 命中会把
306
+ // 已被删除的工作区目录误判为可登记,界面继续显示「重新登记」。
307
+ const canRegister = await directoryExists(path);
298
308
  const view = {
299
309
  id: key,
300
310
  title: known?.title || workspaceBaseName(path) || path,