dsh-plugin-tool-management 0.19.1 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +915 -829
  2. package/README.md +237 -254
  3. package/README_EN.md +234 -223
  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.gif +0 -0
  21. package/docs/update.md +32 -30
  22. package/lib/client.js +9480 -6970
  23. package/lib/compat/preset-reach.js +5 -2
  24. package/lib/context-inject-frame.js +13 -8
  25. package/lib/hub.js +104 -11
  26. package/lib/index.js +72 -24
  27. package/lib/mcp/manager.js +45 -13
  28. package/lib/mcp/secret-guard.js +2 -2
  29. package/lib/mcp/state-section.js +1 -1
  30. package/lib/memories/archive-engine.js +4 -5
  31. package/lib/memories/constants.js +11 -4
  32. package/lib/memories/service.js +6 -1
  33. package/lib/op-registry.js +13 -11
  34. package/lib/ops/memory.js +42 -4
  35. package/lib/ops/scene-records.js +43 -9
  36. package/lib/ops/sessions.js +67 -46
  37. package/lib/ops/trash.js +107 -8
  38. package/lib/request-gate.js +9 -2
  39. package/lib/scene-prompt-sync.js +48 -23
  40. package/lib/sessions/history.js +32 -22
  41. package/lib/sessions/workspace.js +0 -10
  42. package/lib/skills/catalog.js +17 -5
  43. package/lib/skills/core.js +189 -7
  44. package/lib/skills/frontmatter.js +31 -0
  45. package/lib/skills/manager-state.js +73 -3
  46. package/lib/skills/service.js +53 -13
  47. package/lib/skills/skill-roots.js +54 -4
  48. package/lib/skills/skill-trash.js +118 -12
  49. package/lib/skills/upload-pipeline.js +110 -26
  50. package/lib/subagents/catalog.js +9 -5
  51. package/lib/subagents/service.js +44 -6
  52. package/lib/subagents/tools.js +45 -15
  53. package/lib/tool-table-switch.js +6 -6
  54. package/lib/tools/deps.js +43 -0
  55. package/lib/tools/mcp.js +74 -27
  56. package/lib/tools/memory.js +49 -9
  57. package/lib/tools/prompt.js +38 -9
  58. package/lib/tools/remove.js +206 -0
  59. package/lib/tools/scene.js +155 -32
  60. package/lib/tools/skills.js +162 -55
  61. package/lib/tools/subagent.js +65 -31
  62. package/lib/tools/table.js +39 -12
  63. package/lib/trash-id.js +16 -0
  64. package/lib/trash-retention.js +3 -3
  65. package/package.json +115 -114
  66. package/lib/mcp/inspect-probe.js +0 -199
@@ -173,30 +173,47 @@ export function createScenePromptSync(deps) {
173
173
  }
174
174
  return complete ? out : null;
175
175
  }
176
+ /**
177
+ * 进场景的第一笔写入之前,把当时的基线记下来(已有待恢复的基线就不覆盖:连续切场景时
178
+ * 基线始终是「进场景之前」那一份)。
179
+ * @returns 需要让用户知道的原因(写不进去 / 记下来是空的);一切正常返回 undefined。
180
+ */
181
+ async function captureBaseline(scene) {
182
+ const saved = await readBaseline();
183
+ if (saved && saved.content !== null)
184
+ return undefined;
185
+ const cur = await deps.prompts.getCurrent();
186
+ const content = cur && cur.ok && cur.exists ? String(cur.content) : null;
187
+ const failed = await writeBaseline({
188
+ v: 1,
189
+ at: Date.now(),
190
+ presetId: cur && cur.ok && cur.presetId ? String(cur.presetId) : null,
191
+ content,
192
+ });
193
+ // 基线写不进去 = 退出场景时恢复不回来。**照旧进场景**(那是用户的要求),
194
+ // 但必须当场说出来,别让用户以为还能退回去(审查 F14)。
195
+ if (failed !== null) {
196
+ return `进场景前的提示词基线没能写入(${failed}):AGENTS.md 已切到场景「${scene}」绑定的预设,但退出场景时恢复不回原来的内容(${deps.baselineFile})。`;
197
+ }
198
+ // 记下来的是**空的**(进场景时 AGENTS.md 就不存在、或读不出来):退出场景时没有正文
199
+ // 可写回,AGENTS.md 会留在场景态。与「写失败」是两件事,后果一样,同样当场说
200
+ // (2026-10-15 smoke-test P0-2:这一种此前要等到退出之后,才被误报成「快照损坏」)。
201
+ if (content === null) {
202
+ return `进场景前的 AGENTS.md 没有内容可记(文件不存在或读不出来):退出场景时恢复不回原来的状态,AGENTS.md 会留在场景「${scene}」绑定的预设上。`;
203
+ }
204
+ return undefined;
205
+ }
176
206
  async function sync() {
177
207
  const current = await state();
178
208
  if (!current)
179
209
  return { unchanged: true };
180
210
  if (drives(current)) {
181
- if (current.duplicate)
182
- return { applied: current.presetId, unchanged: true };
183
- // 进场景的第一笔写入之前,先记下当时的基线(已有快照就不覆盖:连续切场景时
184
- // 基线始终是"进场景之前"那一份)。
185
- let baselineError;
186
- const saved = await readBaseline();
187
- if (!saved || saved.content === null) {
188
- const cur = await deps.prompts.getCurrent();
189
- const failed = await writeBaseline({
190
- v: 1,
191
- at: Date.now(),
192
- presetId: cur && cur.ok && cur.presetId ? String(cur.presetId) : null,
193
- content: cur && cur.ok && cur.exists ? String(cur.content) : null,
194
- });
195
- // 基线写不进去 = 退出场景时恢复不回来。**照旧进场景**(那是用户的要求),
196
- // 但必须当场说出来,别让用户以为还能退回去(审查 F14)。
197
- if (failed !== null) {
198
- baselineError = `进场景前的提示词基线没能写入(${failed}):AGENTS.md 已切到场景「${current.scene}」绑定的预设,但退出场景时恢复不回原来的内容(${deps.baselineFile})。`;
199
- }
211
+ // 基线必须在 `duplicate` 早退**之前**记(2026-10-15 smoke-test P0-2):早退省的只是
212
+ // 「AGENTS.md 不用再写一遍」,不是「不用留退路」。原先这一句在早退之后,于是
213
+ // 「进一个内容已经等于该预设的场景」完全不记基线 —— 退出时恢复不回原内容。
214
+ const baselineError = await captureBaseline(current.scene);
215
+ if (current.duplicate) {
216
+ return { applied: current.presetId, unchanged: true, ...(baselineError ? { baselineError } : {}) };
200
217
  }
201
218
  const res = await deps.prompts.apply(current.presetId);
202
219
  if (res && res.ok === false)
@@ -205,16 +222,24 @@ export function createScenePromptSync(deps) {
205
222
  }
206
223
  // 没有场景驱动 → 如果刚从驱动态退出,把进场景前的基线写回去。
207
224
  const saved = await readBaseline();
208
- if (!saved || saved.content === null) {
209
- // 「没有快照」是常态(没进过场景);「快照文件在、但读不出来」是**故障** ——
210
- // 那意味着恢复目标丢了,AGENTS.md 的提示词基线会悄悄留在场景态。这里曾经一律
211
- // 静默 `unchanged`,用户看到的是"没场景驱动所以什么都没做"(审查 P2-16)。
225
+ // 「快照文件在、但读不出来」是**故障** —— 那意味着恢复目标丢了,AGENTS.md 的提示词基线
226
+ // 会悄悄留在场景态。这里曾经一律静默 `unchanged`,用户看到的是"没场景驱动所以什么都没做"
227
+ // (审查 P2-16)。但两种"没有正文"必须分开(2026-10-15 smoke-test P0-2):
228
+ // · `saved === null` → 文件缺失 / 半截 JSON / 解析失败;
229
+ // · `saved.content === null` → 下面 `writeBaseline({presetId:null, content:null})` 写下的
230
+ // 「没有待恢复基线」哨兵,是**正常终态**(每次正常退出场景之后都会留下它)。
231
+ // 原先两者共用 `!saved || saved.content === null` 一个条件,于是每次正常退出之后,下一次
232
+ // 无场景驱动的同步都把哨兵判成损坏,报一句假 WARN —— 而假 WARN 长得和真故障一样,
233
+ // 反过来把"恢复目标真丢了"这条信号淹掉。
234
+ if (saved === null) {
212
235
  if (await baselineExists()) {
213
236
  warn(`scene-prompt: baseline snapshot exists but is unreadable: ${deps.baselineFile}`);
214
237
  return { error: `基线快照读不出来(文件可能损坏),未能恢复 AGENTS.md:${deps.baselineFile}` };
215
238
  }
216
239
  return { unchanged: true };
217
240
  }
241
+ if (saved.content === null)
242
+ return { unchanged: true };
218
243
  const res = await deps.prompts.restore(saved.content);
219
244
  if (res && res.ok === false)
220
245
  return { error: String(res.error || '恢复 AGENTS.md 失败') };
@@ -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,
@@ -544,16 +544,6 @@ var ArchiveWorkspaceRegistry = class {
544
544
  }
545
545
  return { attached, attachSkipped };
546
546
  }
547
- /**
548
- * 归档委托宿主。官方 UI 不经过本方法,而由 domain/changed 事件
549
- * 同步归档时刻;重复归档不重置保留期。
550
- */
551
- async archiveSession(sessionId) {
552
- // 委托宿主原生入口;宿主没有原生入口时走本门面的适配路径(能力探测决定)。
553
- this.bridge.checkWorkspace("archive");
554
- await this.registry.archiveSession(sessionId);
555
- await this.reconcileArchiveLedger();
556
- }
557
547
  /**
558
548
  * 列出已归档会话的展示元数据:sessionId / createdAt / cwd / title / archivedAt。
559
549
  * title 经投影缓存 best-effort 读取(cachedSnapshot 的标题行,旧格式记录退
@@ -93,17 +93,22 @@ export function renderSkillCatalog(data, maxEntries = SKILL_CATALOG_MAX_ENTRIES,
93
93
  export function createSkillCatalog(deps, opts = {}) {
94
94
  const ttlMs = opts.ttlMs ?? 30_000;
95
95
  const now = opts.now ?? (() => Date.now());
96
- let value = '';
96
+ // 缓存的是**清单数据**而不是渲染结果:清单本体只依赖磁盘(一次读盘),而尾句要不要点名那个
97
+ // 查询工具取决于**这个 agent 手里有没有它**。渲染是纯字符串操作,代价可忽略;这个布尔就
98
+ // 两档,于是"每个 step 同步渲染"退化成一次查表(与人设目录按深度缓存同一手法)。
99
+ let data = null;
97
100
  let loadedAt = Number.NEGATIVE_INFINITY;
98
101
  let inflight = null;
102
+ let rendered = null;
99
103
  const recompute = async () => {
100
104
  try {
101
105
  const result = await deps.state();
102
106
  if (result && result.ok !== false) {
103
- value = renderSkillCatalog(result.data ?? result, opts.maxEntries, opts.maxDescription, deps.listToolVisible ? deps.listToolVisible() : true);
107
+ data = result.data ?? result;
108
+ rendered = null;
104
109
  }
105
110
  }
106
- catch { /* 保留上一次的值;首次失败则维持 '' */ }
111
+ catch { /* 保留上一次的值;首次失败则维持 null */ }
107
112
  loadedAt = now();
108
113
  };
109
114
  const revalidate = () => {
@@ -113,10 +118,17 @@ export function createSkillCatalog(deps, opts = {}) {
113
118
  return inflight;
114
119
  };
115
120
  return {
116
- text: () => {
121
+ text: (agent) => {
117
122
  if (now() - loadedAt > ttlMs)
118
123
  void revalidate();
119
- return value;
124
+ if (data === null)
125
+ return '';
126
+ const visible = deps.listToolVisible ? deps.listToolVisible(agent) : true;
127
+ if (rendered && rendered.visible === visible)
128
+ return rendered.text;
129
+ const out = renderSkillCatalog(data, opts.maxEntries, opts.maxDescription, visible);
130
+ rendered = { visible, text: out };
131
+ return out;
120
132
  },
121
133
  refresh: () => {
122
134
  loadedAt = Number.NEGATIVE_INFINITY;
@@ -17,6 +17,9 @@ import { promises as fs, } from "node:fs";
17
17
  import { randomUUID } from "node:crypto";
18
18
  import { discoverReadonlyEntries, validDiscoveryName, } from "./readonly-discovery.js";
19
19
  import { isSameOrDescendant } from "../paths.js";
20
+ // 回收站条目 id(与 hub / 记忆 / 人设 / 场景 / 提示词共用一个生成器)。不能引 `../hub.js` ——
21
+ // 那边引了本文件的 resolveDshHome,引回去就是环;所以生成器放在 `../trash-id.js` 这一叶上。
22
+ import { newTrashId } from "../trash-id.js";
20
23
  import { OFFICE_SKILL_NAMES, OFFICIAL_SOURCE_KEY, officialOfficeRuntimeSection, } from "./official.js";
21
24
  import { parseSkillDoc, toKebab, unquote } from "./frontmatter.js";
22
25
  // 拆出去的公开函数照旧挂在 core.js 上(tools/skills.ts 引 toKebab,memories/* 与 ops/memory.ts
@@ -49,7 +52,7 @@ import { checkedWritableRootDefinition, movePathWithFallback, safeExistingEntryP
49
52
  // ./core.js 引),照旧再出口一次。
50
53
  export { customRootsFromState, effectiveSkillPolicy, entryOf, groupLoadableSkillsByName, parseBoolValue, readManagerState, scanEntries, state, writeManagerState, } from "./manager-state.js";
51
54
  import { CUSTOM_ROOT_KEY_RE, PROJECT_ROOT_KEY_RE, customRootsFromState, effectiveSkillPolicy, entryOf, groupLoadableSkillsByName, isInsideResolvedRoot, readManagerState, scanDeduplicatedRoots, writeFileAtomically, writeManagerState, } from "./manager-state.js";
52
- import { KEBAB_RE, pathsOverlap, } from "./upload-pipeline.js";
55
+ import { KEBAB_RE, normalizeUploadPath, pathsOverlap, uploadError, writeUploadedEntries, } from "./upload-pipeline.js";
53
56
  import { customRootKey, dshRootPath, isDefaultSkillSource, managerHomePath, managerStatePath, projectIdentity, projectRoots, projectSourceSafe, readonlyError, resolvedPath, rootByKey, rootDefinition, trashRootPath, userRoots, } from "./skill-roots.js";
54
57
  import { codedError, entryPath, lstatOrNull, renameWithRetry, pathIdentity, temporaryPath, TRANSIENT_RENAME_CODES, } from "./core-util.js";
55
58
  const USER_DSH_POLICY_RANK = 399;
@@ -124,7 +127,15 @@ export async function addCustomRoot(inputPath, label, log) {
124
127
  if (current.writable === false)
125
128
  return invalidManagerStateWrite();
126
129
  const existing = customRootsFromState(current.state);
130
+ // 「永久删除」过的来源不再参与重叠判定:用户在回收站里把它彻底删掉之后,要能把同一个
131
+ // 目录**作为自定义来源重新加回来**(`confirm.source.forget.desc` 那句「需要时只能重新
132
+ // 添加这个目录」说的就是这条路)。不放过它的话,那句提示就是一句空话 —— 路径仍被
133
+ // `userRoots()` 认领,添加只会吃一个「目录与已有技能来源重叠」。
134
+ const forgotten = new Set(Array.isArray(current.state.forgottenSources)
135
+ ? current.state.forgottenSources
136
+ : []);
127
137
  for (const known of userRoots()
138
+ .filter((root) => !forgotten.has(root.key))
128
139
  .map((root) => root.path)
129
140
  .concat(existing.map((root) => root.path))) {
130
141
  if (await pathsOverlap(canonical, known))
@@ -189,6 +200,59 @@ export async function removeCustomRoot(key, log) {
189
200
  log("custom-remove", `移除自定义技能目录 ${target.path} (${clean})`);
190
201
  return { key: clean, path: target.path };
191
202
  }
203
+ /**
204
+ * 永久删除一条**按约定发现**的来源记录(回收站 → 目录 里那颗「永久删除」)。
205
+ *
206
+ * 与 `removeCustomRoot` 的分工:那条管的是"用户手加的自定义目录",删掉 `customRoots` 里
207
+ * 的记录就完事;这条管的是 `agents` / `codex` / `claude` 这类**由 `userRoots()` 按约定推导**
208
+ * 出来的来源 —— 它们不在状态文件的 customRoots 里,路径每次启动都会重新算出来,所以只把 key
209
+ * 从 `removedSources` 里摘掉的话,下一轮它又原样回来了。
210
+ *
211
+ * 于是另记一份 `forgottenSources`:进了这份名单的来源既不读取(`activeUserRoots` 摘掉它)、
212
+ * 也不列出(`state()` 的 roots 过滤掉它)—— 等于从插件里彻底消失。用户想反悔有两条路:
213
+ * 清掉状态文件里这个 key,或者把它**作为自定义来源重新添加**(`addCustomRoot` 的重叠判定
214
+ * 已经跳过已忘记的来源,所以那条路真的走得通)。
215
+ *
216
+ * **磁盘上的源文件一个字节都不动** —— 这条函数自始至终只写状态文件。
217
+ */
218
+ export async function forgetSource(key, log) {
219
+ const clean = String(key == null ? "" : key).trim();
220
+ const root = userRoots().find((item) => item.key === clean);
221
+ if (!root)
222
+ return {
223
+ ok: false,
224
+ error: `未知的来源: ${clean}`,
225
+ code: "error.root.unknown",
226
+ params: { root: clean },
227
+ };
228
+ // 默认来源(dsh / hub / 官方)必须读取:它们不在移除候选里,也不该能被永久删除。
229
+ if (isDefaultSkillSource(clean))
230
+ return {
231
+ ok: false,
232
+ error: `默认来源不能永久删除: ${clean}`,
233
+ code: "error.root.default",
234
+ params: { root: clean },
235
+ };
236
+ const current = await readManagerState();
237
+ if (current.writable === false)
238
+ return invalidManagerStateWrite();
239
+ const forgotten = new Set(Array.isArray(current.state.forgottenSources)
240
+ ? current.state.forgottenSources
241
+ : []);
242
+ forgotten.add(clean);
243
+ current.state.forgottenSources = [...forgotten].sort();
244
+ // 「已移除」那条记录一起摘掉:留着它只会在状态文件里多一条指向"已经不存在"的脏键,
245
+ // 而且归一化那一步本来也会把它去掉 —— 没理由写进去(与 removeCustomRoot 同一口径)。
246
+ if (Array.isArray(current.state.removedSources))
247
+ current.state.removedSources = current.state.removedSources.filter((item) => item !== clean);
248
+ delete current.state.sources[clean];
249
+ delete current.state.disabledSkills[clean];
250
+ delete current.state.enabledSkills[clean];
251
+ await writeManagerState(current.state);
252
+ if (log)
253
+ log("source-forget", `永久删除来源记录 ${clean}: ${root.path}`);
254
+ return { root: clean, path: root.path };
255
+ }
192
256
  /**
193
257
  * 从活动 Session cwd 推导项目技能根。只接受宿主上可解析的绝对目录,
194
258
  * 同一项目的多个 Session 会折叠到同一组稳定 key,避免跨 workspace 合并同名技能。
@@ -434,17 +498,22 @@ async function visibleEntryForRoot(root, name) {
434
498
  */
435
499
  /** 状态文件已存在但不可用时一律关闭外部来源,避免损坏配置重新暴露技能。 */
436
500
  /**
437
- * 参与扫描的来源 = 全部来源 − 用户已移除的。
501
+ * 参与扫描的来源 = 全部来源 − 用户已移除的 − 用户已永久删除的。
438
502
  *
439
503
  * 「移除来源」与「停用来源」是两件事:
440
504
  * - 停用(`sources[key] = false`):仍然读取并列出技能,只是不可调用;
441
505
  * - 移除(出现在 `removedSources`):**连目录都不读**,技能不出现在列表里,
442
506
  * 也不参与 provider 候选。源文件不动,清掉这个标记即恢复。
507
+ *
508
+ * 「永久删除」(`forgottenSources`)是移除的**终点**:这条来源从插件里彻底消失 ——
509
+ * 不再读取(与移除同)、并且连"已移除"那行记录也不再出现(见 `state()` 的 roots 过滤)。
510
+ * 两者都**不碰磁盘**。
443
511
  */
444
512
  export function activeUserRoots(stateValue) {
445
513
  const state = stateValue;
446
514
  const removed = new Set(state && Array.isArray(state.removedSources) ? state.removedSources : []);
447
- return userRoots().filter((root) => !removed.has(root.key));
515
+ const forgotten = new Set(state && Array.isArray(state.forgottenSources) ? state.forgottenSources : []);
516
+ return userRoots().filter((root) => !removed.has(root.key) && !forgotten.has(root.key));
448
517
  }
449
518
  /** 裁剪自定义来源列表:非法条目丢弃,key/path 绑定校验,label 规整。 */
450
519
  /**
@@ -647,6 +716,24 @@ export async function markSkillsDisabled(entries, log) {
647
716
  return invalidManagerStateWrite();
648
717
  let dirty = false;
649
718
  for (const e of clean) {
719
+ /*
720
+ * 同一张表里的两个名单**互斥**(`disabledSkills` = 显式关、`enabledSkills` = 显式开,
721
+ * 见 manager-state 的 validManagerStateDocument)。名字进了 disabled 就必须从 enabled 摘掉。
722
+ *
723
+ * 少了这一步,「显式启用过 → 删除 → 从回收站恢复」这条链会把同一个名字同时留在两份名单
724
+ * 上(删除不清策略、恢复又要默认停用),整份状态随即被判非法 —— 插件 fail-closed 停用
725
+ * **所有**技能,而且 `writable` 一并变 false,用户在界面上连一个开关都按不动,等于被锁死在
726
+ * 一份自己造不出来的状态上(用户 2026-10-14 实测踩到)。
727
+ *
728
+ * 摘除要放在下面那个 `continue` **之前**:名字已经在 disabled 里、却还挂在 enabled 上
729
+ * (就是被锁死的那份文件的形状)时,`continue` 会把清理一并跳过去。
730
+ */
731
+ const explicit = current.state.enabledSkills[e.root] || [];
732
+ const keptExplicit = explicit.filter((name) => name !== e.name);
733
+ if (keptExplicit.length !== explicit.length) {
734
+ current.state.enabledSkills[e.root] = keptExplicit;
735
+ dirty = true;
736
+ }
650
737
  const prev = current.state.disabledSkills[e.root] || [];
651
738
  if (prev.indexOf(e.name) >= 0)
652
739
  continue;
@@ -807,7 +894,11 @@ export async function deleteSkill(root, name, log, options = {}) {
807
894
  params: { name },
808
895
  };
809
896
  const targets = await safeExistingEntryPaths(definition.path, name);
810
- const id = `${Date.now()}-${randomUUID()}`;
897
+ // 回收站条目 id 走五个域共用的那个生成器(2026-10-15 smoke-test P1-7):此前这里另写了一套
898
+ // `${Date.now()}-${randomUUID()}`,于是同一次操作里记忆/人设/场景/提示词拿到 `mv8faxqe-…`
899
+ // 这种形状、技能拿到 `1792001296466-62d3d393-…`。模型按 `action=list` 的 id 跨域 restore,
900
+ // 两种长相只会让它多猜一层。读侧只校验单个合法路径段,旧条目照旧能列、能恢复。
901
+ const id = newTrashId();
811
902
  const trashRoot = trashRootPath();
812
903
  const stage = join(trashRoot, `.stage-${randomUUID()}`);
813
904
  const finalPath = join(trashRoot, id);
@@ -906,11 +997,12 @@ function yamlString(value) {
906
997
  return JSON.stringify(String(value));
907
998
  }
908
999
  export async function createSkill(input, log, options = {}) {
909
- // v0.4:默认落点由「DSH 技能目录」改为 hub 内的 `tool-management/skills/`;
910
- // 调用方显式传 options.root(如项目根)时仍以调用方为准。
1000
+ // 默认落点是「DSH 技能目录」(用户裁定 2026-10-15 改回):v0.4-v0.20.0 期间这里是 hub。
1001
+ // hub(`tool-management/skills/`)现在只服务「导入技能」功能 —— 那边走
1002
+ // `skillCreateRootPath()`,不经这里。调用方显式传 options.root(如项目根)时仍以调用方为准。
911
1003
  const requestedRoot = Object.prototype.hasOwnProperty.call(options, "root")
912
1004
  ? options.root
913
- : rootByKey("hub") || rootByKey("dsh");
1005
+ : rootByKey("dsh") || rootByKey("hub");
914
1006
  const definition = await checkedWritableRootDefinition(requestedRoot);
915
1007
  if (definition && definition.ok === false)
916
1008
  return definition;
@@ -954,6 +1046,31 @@ export async function createSkill(input, log, options = {}) {
954
1046
  };
955
1047
  if (description.length > 500 || body.length > 1 << 18)
956
1048
  return { ok: false, error: "技能内容过长", code: "error.create.tooLarge" };
1049
+ // 附件的闸一律复用导入那条链(normalizeUploadPath + writeUploadedEntries 里的 32 MiB /
1050
+ // 64 MiB / 1000 条),不另立规矩:界面上新建的技能与导入的技能带同一份 `references/`
1051
+ // 时,落盘形态必须一致。SKILL.md 是本次要写的正文,不能被附件占用。
1052
+ const files = Array.isArray(input && input.files) ? input.files : [];
1053
+ try {
1054
+ for (const item of files) {
1055
+ const rel = normalizeUploadPath(item?.path).path;
1056
+ if (/(^|\/)skill\.md$/i.test(rel))
1057
+ return {
1058
+ ok: false,
1059
+ error: `附件不能占用 SKILL.md(正文由本次创建写入): ${rel}`,
1060
+ code: "error.create.reservedPath",
1061
+ params: { path: rel },
1062
+ };
1063
+ }
1064
+ }
1065
+ catch (error) {
1066
+ return uploadError(error);
1067
+ }
1068
+ if (files.length && form === "flat")
1069
+ return {
1070
+ ok: false,
1071
+ error: "单文件技能不能带附件:附件需要目录形态",
1072
+ code: "error.create.filesWithFlat",
1073
+ };
957
1074
  if (await safeExistingEntryPaths(root, name).then((items) => items.length > 0))
958
1075
  return {
959
1076
  ok: false,
@@ -977,10 +1094,18 @@ export async function createSkill(input, log, options = {}) {
977
1094
  try {
978
1095
  await fs.mkdir(stage);
979
1096
  await fs.writeFile(join(stage, "SKILL.md"), content, "utf8");
1097
+ // 附件写进**同一个临时目录**再整体改名到位:中途失败时目标路径根本不存在,
1098
+ // 不会留下「有 SKILL.md 没有 references」的半截技能(下方 catch 顺手把 stage 清掉)。
1099
+ if (files.length)
1100
+ await writeUploadedEntries(stage, files);
980
1101
  await renameWithRetry(stage, target);
981
1102
  }
982
1103
  catch (error) {
983
1104
  await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
1105
+ // 带附件时把失败收拢成结果对象:路径非法 / 超限这些都是用户输入问题,抛出去只会
1106
+ // 让界面拿到一句英文堆栈。不带附件时照旧抛(与改动前一字不差)。
1107
+ if (files.length)
1108
+ return uploadError(error);
984
1109
  throw error;
985
1110
  }
986
1111
  if (log)
@@ -1080,6 +1205,59 @@ export async function updateSkill(input, log, options = {}) {
1080
1205
  log("update", `更新 ${existingDoc}`);
1081
1206
  return { name, path: existingDoc, root: definition.key };
1082
1207
  }
1208
+ /** 详情弹窗列出的 bundle 附件上限:再多就不是给人看的清单了(2026-10-16)。 */
1209
+ const MAX_DETAIL_ATTACHMENTS = 200;
1210
+ const MAX_DETAIL_ATTACHMENT_DEPTH = 6;
1211
+ /**
1212
+ * 列出 bundle 目录里除顶层 `SKILL.md` 之外的普通文件(相对路径 + 字节数)。
1213
+ *
1214
+ * 走 lstat 并跳过符号链:这份清单会原样列进详情弹窗、也会打印给模型看,
1215
+ * 里面混一条指向目录树之外的链就是误导。深度与条数都有上限,超了如实标 truncated。
1216
+ */
1217
+ async function listBundleAttachments(dir) {
1218
+ const files = [];
1219
+ let truncated = false;
1220
+ async function walk(base, prefix, depth) {
1221
+ let items;
1222
+ try {
1223
+ items = await fs.readdir(base, { withFileTypes: true });
1224
+ }
1225
+ catch {
1226
+ return;
1227
+ }
1228
+ for (const item of items) {
1229
+ const rel = prefix ? `${prefix}/${item.name}` : item.name;
1230
+ if (depth === 0 && item.name.toLowerCase() === "skill.md")
1231
+ continue;
1232
+ const abs = join(base, item.name);
1233
+ let stat;
1234
+ try {
1235
+ stat = await fs.lstat(abs);
1236
+ }
1237
+ catch {
1238
+ continue;
1239
+ }
1240
+ if (stat.isSymbolicLink())
1241
+ continue;
1242
+ if (stat.isDirectory()) {
1243
+ if (depth + 1 >= MAX_DETAIL_ATTACHMENT_DEPTH)
1244
+ continue;
1245
+ await walk(abs, rel, depth + 1);
1246
+ continue;
1247
+ }
1248
+ if (!stat.isFile())
1249
+ continue;
1250
+ if (files.length >= MAX_DETAIL_ATTACHMENTS) {
1251
+ truncated = true;
1252
+ return;
1253
+ }
1254
+ files.push({ path: rel, size: stat.size });
1255
+ }
1256
+ }
1257
+ await walk(dir, "", 0);
1258
+ files.sort((a, b) => a.path.localeCompare(b.path));
1259
+ return { files, truncated };
1260
+ }
1083
1261
  export async function skillDetail(keyOrRoot, name, options = {}) {
1084
1262
  const scopedRoots = await projectRoots(options.projectCwds);
1085
1263
  // 支持传 key 或 definition(自定义来源由 service 层解析后传入)。
@@ -1117,6 +1295,8 @@ export async function skillDetail(keyOrRoot, name, options = {}) {
1117
1295
  }
1118
1296
  const doc = parseSkillDoc(raw);
1119
1297
  const summary = entryOf(name, entry.kind, entry.docPath, doc);
1298
+ // bundle 才有附件;flat 是单文件,给 null 让调用方分清"没有目录"与"目录是空的"。
1299
+ const attachments = entry.kind === "bundle" ? await listBundleAttachments(dirname(entry.docPath)) : null;
1120
1300
  return {
1121
1301
  root: root.key,
1122
1302
  name,
@@ -1131,6 +1311,8 @@ export async function skillDetail(keyOrRoot, name, options = {}) {
1131
1311
  diagnostics: summary.diagnostics,
1132
1312
  loadable: summary.loadable,
1133
1313
  sourceReadOnly: !root.mutable,
1314
+ attachments: attachments ? attachments.files : null,
1315
+ attachmentsTruncated: attachments ? attachments.truncated : false,
1134
1316
  };
1135
1317
  }
1136
1318
  /**
@@ -94,3 +94,34 @@ export function unquote(v) {
94
94
  }
95
95
  return s;
96
96
  }
97
+ // ── 声明名改写(退号落盘后同步 frontmatter)─────────────────────────────────
98
+ /** frontmatter 整块(开栏 / 字段区 / 闭栏三段各自成组,便于逐字回拼)。 */
99
+ const DECLARED_NAME_BLOCK_RE = /^(?<open>(?:\uFEFF)?[^\S\r\n]*---[^\S\r\n]*(?:\r\n|\n))(?<fields>[\s\S]*?)(?<close>^[^\S\r\n]*---[^\S\r\n]*(?:\r\n|\n|$))/m;
100
+ /** 顶格的 `name:` 行;缩进的嵌套字段(块标量的续行等)不匹配。 */
101
+ const DECLARED_NAME_LINE_RE = /^name[^\S\r\n]*:[^\r\n]*/m;
102
+ /**
103
+ * 把 frontmatter 的 `name:` 行改写成新名字,其余逐字节保留(连 CRLF 都不动);
104
+ * 认不出形态(没有 frontmatter / 没有 `name:` 行 / 改完不变)时返回 null。
105
+ *
106
+ * **调用方保证 `name` 过 KEBAB_RE**(只含 `[a-z0-9-]`,无需转义;本模块不引
107
+ * upload-pipeline 的 KEBAB_RE —— 那边引了本模块,引回来就成了环)。
108
+ *
109
+ * 退号落盘后必须调用(导入撞名、回收站恢复):`canonicalSkillName` 取的是
110
+ * 声明名而不是条目名,只改目录会"看着叫 foo-1、其实还是 foo"—— 两份同名
111
+ * 技能在「同名首选」里被并成一组,而那颗开关挑的是来源,按下去什么都不会变。
112
+ * 没有 `name:` 行的文档返回 null 是**良性**的:声明名为空时 canonicalSkillName
113
+ * 回落到条目名,退号后的目录名就是它的名字。
114
+ */
115
+ export function rewriteDeclaredName(text, name) {
116
+ const match = DECLARED_NAME_BLOCK_RE.exec(text);
117
+ if (!match || !match.groups)
118
+ return null;
119
+ const fields = match.groups.fields;
120
+ // 函数式替换:字段区里可能带 `$&` / `$'` 之类字面量,字符串式替换会把它们当占位符展开。
121
+ const rewritten = fields.replace(DECLARED_NAME_LINE_RE, () => `name: ${name}`);
122
+ if (rewritten === fields)
123
+ return null;
124
+ const open = match.groups.open;
125
+ const close = match.groups.close;
126
+ return text.replace(DECLARED_NAME_BLOCK_RE, () => `${open}${rewritten}${close}`);
127
+ }