dsh-plugin-tool-management 0.20.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +3 -1
- package/README_EN.md +1 -1
- package/docs/images/logo.gif +0 -0
- package/docs/update.md +1 -0
- package/lib/client.js +1594 -364
- package/lib/compat/preset-reach.js +5 -2
- package/lib/context-inject-frame.js +13 -8
- package/lib/hub.js +8 -5
- package/lib/index.js +56 -20
- package/lib/mcp/manager.js +21 -0
- package/lib/mcp/state-section.js +1 -1
- package/lib/memories/archive-engine.js +4 -5
- package/lib/memories/constants.js +2 -2
- package/lib/op-registry.js +7 -5
- package/lib/ops/memory.js +42 -4
- package/lib/ops/scene-records.js +43 -9
- package/lib/ops/sessions.js +5 -39
- package/lib/ops/trash.js +45 -5
- package/lib/request-gate.js +9 -2
- package/lib/scene-prompt-sync.js +48 -23
- package/lib/sessions/workspace.js +0 -10
- package/lib/skills/catalog.js +17 -5
- package/lib/skills/core.js +121 -5
- package/lib/skills/frontmatter.js +31 -0
- package/lib/skills/manager-state.js +41 -2
- package/lib/skills/service.js +46 -12
- package/lib/skills/skill-roots.js +54 -4
- package/lib/skills/skill-trash.js +118 -12
- package/lib/skills/upload-pipeline.js +110 -26
- package/lib/subagents/catalog.js +9 -5
- package/lib/subagents/service.js +44 -6
- package/lib/subagents/tools.js +45 -15
- package/lib/tool-table-switch.js +6 -6
- package/lib/tools/deps.js +43 -0
- package/lib/tools/mcp.js +68 -27
- package/lib/tools/memory.js +49 -9
- package/lib/tools/prompt.js +26 -9
- package/lib/tools/remove.js +206 -0
- package/lib/tools/scene.js +18 -13
- package/lib/tools/skills.js +162 -55
- package/lib/tools/subagent.js +65 -31
- package/lib/tools/table.js +39 -12
- package/lib/trash-id.js +16 -0
- package/package.json +115 -114
package/lib/skills/service.js
CHANGED
|
@@ -79,6 +79,21 @@ function notifyChatCatalog(ctx, invalidateSkills) {
|
|
|
79
79
|
}
|
|
80
80
|
catch { /* 斜杠菜单刷新失败不影响已完成的操作 */ }
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* 本插件**可写**的技能来源集合,共两员:
|
|
84
|
+
*
|
|
85
|
+
* - `hub`(`$DSH_HOME/tool-management/skills/`,界面名「导入技能」)—— 导入 / 上传的落点;
|
|
86
|
+
* - `dsh`(`$DSH_HOME/skills/`,界面名「DSH 技能」)—— **新建**技能的落点(用户裁定
|
|
87
|
+
* 2026-10-15:「新增技能默认 DSH 技能;导入技能功能才是导入技能目录」)。
|
|
88
|
+
*
|
|
89
|
+
* 其余根(官方内置 / agents / codex / claude / 自定义 / 项目)一律不碰。
|
|
90
|
+
*
|
|
91
|
+
* 这个集合曾经只有 `hub`:新建默认落点还在 hub 的年代那么判够用。落点改到 dsh 之后,
|
|
92
|
+
* 只认 hub 会把「新建 → 再存一次」整条回路掐断 —— 新建默认落 dsh,改的分支永远找不到
|
|
93
|
+
* hub 里的胜出者,每次都回 `error.update.notOwnRoot`。所以它与新建落点是同一条裁定
|
|
94
|
+
* 的两面,必须一起扩。
|
|
95
|
+
*/
|
|
96
|
+
const PLUGIN_WRITABLE_ROOTS = new Set(['hub', 'dsh']);
|
|
82
97
|
/** 全插件共用的审计日志:单一串行写队列,避免多处写同一文件交叉。 */
|
|
83
98
|
let sharedLog = null;
|
|
84
99
|
export function pluginLog() {
|
|
@@ -312,23 +327,30 @@ export function createSkillsService(ctx) {
|
|
|
312
327
|
return { enabled, changed, failed };
|
|
313
328
|
}), afterWrite),
|
|
314
329
|
// 创建 / 导入 / 删除 / 回收站
|
|
315
|
-
// 创建默认落
|
|
316
|
-
//
|
|
330
|
+
// 创建默认落 **DSH 技能目录**(用户裁定 2026-10-15):v0.4 起这里是 hub,现在改回 dsh ——
|
|
331
|
+
// hub(`tool-management/skills/`)只服务「导入技能」功能。与 UI「创建技能」的
|
|
332
|
+
// `defaultCreateRoot`、core.js 的 createSkill 默认值同口径;core 里的那份是第二道保险。
|
|
333
|
+
// 落点变了但有一件事没变:`skill_manager_switch` / `_remove` 侧跨来源解析照旧兜得住(P0-1)。
|
|
317
334
|
'skill-create': wrap((args) => write(async () => {
|
|
318
335
|
// requestRoot 未命中时回传原始 key,让 core 的 error.root.unknown 带上来源名
|
|
319
336
|
// (否则传了不存在的 root 会收到「技能来源不存在:(空)」这种指错方向的提示)。
|
|
320
|
-
const key = String(args.root || '
|
|
321
|
-
const res = await createSkill({ name: args.name, description: args.description, body: args.body }, log, { root: (await requestRoot(key)) || key });
|
|
337
|
+
const key = String(args.root || 'dsh');
|
|
338
|
+
const res = await createSkill({ name: args.name, description: args.description, body: args.body, files: args.files }, log, { root: (await requestRoot(key)) || key });
|
|
322
339
|
// v0.8.5:新建技能默认不启动(用户裁定)——与子智能体 / MCP 同口径。
|
|
323
340
|
if (res && res.ok !== false && res.name) {
|
|
324
341
|
return disableNewSkills([{ root: String(res.root || 'hub'), name: String(res.name) }], res);
|
|
325
342
|
}
|
|
326
343
|
return res;
|
|
327
344
|
}), afterWrite),
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
345
|
+
// 改写**本插件自己写的那一份**技能。先跨来源找胜出者,再要求它落在本插件可写的两个根
|
|
346
|
+
// (hub / dsh,见 PLUGIN_WRITABLE_ROOTS)里:同名技能在 dsh / agents / claude / 自定义根
|
|
347
|
+
// 各有一份时只有一份生效,改错那一份会返回 OK 而技能毫无变化(用户实测踩过:同一技能
|
|
348
|
+
// 四份并存,启错根等于没启)。胜出者判定要读来源排序与首选设置,属于状态层,所以在这里
|
|
349
|
+
// 做;core 的 `updateSkill` 只保证不越出给定根。
|
|
350
|
+
//
|
|
351
|
+
// 门禁为什么不能只认 hub:新建默认落点 2026-10-15 改到 dsh 后,模型 `skill_manager_save`
|
|
352
|
+
// 建出来的技能全在 dsh;只认 hub 时改的分支永远找不到 hub 胜出者,「新建 → 再存一次」
|
|
353
|
+
// 直接回 error.update.notOwnRoot。可写集合跟着落点一起扩才闭环。
|
|
332
354
|
'skill-update': wrap((args) => write(async () => {
|
|
333
355
|
const wanted = String(args.name || '').trim();
|
|
334
356
|
const s = await readState();
|
|
@@ -347,15 +369,17 @@ export function createSkillsService(ctx) {
|
|
|
347
369
|
return { ok: false, code: 'error.update.notFound', params: { name: wanted }, error: `技能不存在:${wanted}` };
|
|
348
370
|
}
|
|
349
371
|
const winner = hits.find((h) => !h.shadowedBy) || hits[0];
|
|
350
|
-
if (winner.rootKey
|
|
372
|
+
if (!PLUGIN_WRITABLE_ROOTS.has(winner.rootKey)) {
|
|
351
373
|
return {
|
|
352
374
|
ok: false,
|
|
353
375
|
code: 'error.update.notOwnRoot',
|
|
354
376
|
params: { name: wanted, root: winner.rootKey },
|
|
355
|
-
error: `技能 ${wanted} 的生效副本在来源「${winner.rootKey}
|
|
377
|
+
error: `技能 ${wanted} 的生效副本在来源「${winner.rootKey}」,不在本插件可写的目录(hub = 导入落点;dsh = $DSH_HOME/skills):本插件不修改官方根里的技能`,
|
|
356
378
|
};
|
|
357
379
|
}
|
|
358
|
-
|
|
380
|
+
// 写**胜出者所在的那个根**,不能写死 hub:同名两边各有一份时写错那份,
|
|
381
|
+
// 正是这道判定要防的「返回 OK 而技能毫无变化」。
|
|
382
|
+
return updateSkill({ name: args.name, description: args.description, body: args.body }, log, { root: (await requestRoot(winner.rootKey)) || winner.rootKey });
|
|
359
383
|
}), afterWrite),
|
|
360
384
|
'skill-import': wrap((args) => write(async () => {
|
|
361
385
|
const res = await importSkill(String(args.source || ''), log, {
|
|
@@ -369,6 +393,10 @@ export function createSkillsService(ctx) {
|
|
|
369
393
|
'skill-upload': wrap((args) => write(async () => {
|
|
370
394
|
const res = await importUploadedSkill({ name: args.name, entries: args.entries, zip: args.zip }, log, {
|
|
371
395
|
conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
|
|
396
|
+
// 上传导入撞名一律退号新增(用户 2026-10-15):静默跳过会让用户想要的
|
|
397
|
+
// 那份根本没进来,结果里只多一行"已跳过"。目录导入(skill-import)
|
|
398
|
+
// 不带这个开关,仍是 skip / overwrite 二选一。
|
|
399
|
+
dedupe: true,
|
|
372
400
|
});
|
|
373
401
|
// 上传/导入同口径:默认停用。
|
|
374
402
|
const names = res && Array.isArray(res.imported) ? res.imported : (res && res.name ? [res.name] : []);
|
|
@@ -376,8 +404,14 @@ export function createSkillsService(ctx) {
|
|
|
376
404
|
}), afterWrite),
|
|
377
405
|
'skill-delete': wrap((args) => write(async () => deleteSkill(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), log)), afterWrite),
|
|
378
406
|
'skill-trash-restore': wrap((args) => write(async () => {
|
|
379
|
-
|
|
407
|
+
// `dedupe: true` = 界面上"已存在同名技能,是否恢复为 foo-1"确认过之后那一次
|
|
408
|
+
// (用户 2026-10-14);不带时撞名照旧拒绝,并把候选名字放在 `params.next` 里回给弹窗。
|
|
409
|
+
const res = await restoreTrash(String(args.id || ''), log, {
|
|
410
|
+
...projectOptions(),
|
|
411
|
+
dedupe: args.dedupe === true,
|
|
412
|
+
});
|
|
380
413
|
// 恢复的技能默认停用(v0.8.5 用户裁定:新建/导入/恢复一律不启动,手动开启)。
|
|
414
|
+
// `res.name` 是**最终落盘的名字**(退号恢复时不是回收站里的原名),启停状态必须按它写。
|
|
381
415
|
if (res && res.ok !== false && res.name) {
|
|
382
416
|
return disableNewSkills([{ root: String(res.root || 'hub'), name: String(res.name) }], res);
|
|
383
417
|
}
|
|
@@ -34,11 +34,13 @@ export function userRoots() {
|
|
|
34
34
|
// 官方内置紧随 DSH 技能之后(用户裁定):同为一眼要看的来源,排在导入技能之前。
|
|
35
35
|
...(official === null ? [] : [official]),
|
|
36
36
|
{
|
|
37
|
-
// v0.4
|
|
37
|
+
// v0.4:插件**导入/上传**的技能落到 hub 内(用户要求:插件产生的文件收在
|
|
38
38
|
// $DSH_HOME/tool-management/)。rank 高于 DSH 技能,同名时 hub 版本遮蔽官方目录里的;
|
|
39
39
|
// 官方 ~/.dsh/skills/ 仍作为可切换来源列出(不搬走、不删)。
|
|
40
40
|
//
|
|
41
|
-
//
|
|
41
|
+
// 界面名「导入技能」:它是插件**导入/上传**的落点 —— v0.20.0 及以前新建也落这里,
|
|
42
|
+
// 2026-10-15 用户裁定「新增技能默认 DSH 技能,导入技能功能才用导入技能目录」后,
|
|
43
|
+
// 新建改走 DSH 技能目录(见 core.ts 的 createSkill 默认值)。
|
|
42
44
|
// 与 dsh 同为默认来源:路径必须读取(不可移除、不可停用),里面的技能可删。
|
|
43
45
|
key: "hub",
|
|
44
46
|
path: join(resolveDshHome(), "tool-management", "skills"),
|
|
@@ -137,6 +139,46 @@ export async function projectSourceSafe(definition) {
|
|
|
137
139
|
}
|
|
138
140
|
return !(await overlapsUserSkillRoot(definition.path));
|
|
139
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* 项目来源目录底下有没有至少一条技能条目(只看形状,不读正文):
|
|
144
|
+
* · 可写来源(`.dsh/skills`)—— 至少一个子目录里带 `SKILL.md`(与 scanEntries 的
|
|
145
|
+
* mutable 分支同形状:顶层 flat `.md` 只属于只读来源);
|
|
146
|
+
* · 只读来源(`.agents/skills`)—— 顶层有子目录(里面才可能藏 bundle)或 `.md` 文件
|
|
147
|
+
* (含根自身的 SKILL.md),与 discoverReadonlyEntries 同一形状。
|
|
148
|
+
*
|
|
149
|
+
* 目录缺失 / 是链接 / 读不了,以及点号开头的隐藏条目,一律按「没有」处理 —— 注册一个
|
|
150
|
+
* 没有技能的来源只有噪音:占一个来源位,还会在场景切换时产生「停用 project-dsh:<路径
|
|
151
|
+
* 哈希>」这类只有内部键的提示(用户 2026-10-15 报的)。
|
|
152
|
+
*/
|
|
153
|
+
async function hasSkillEntry(path, mutable) {
|
|
154
|
+
const stat = await lstatOrNull(path);
|
|
155
|
+
if (!stat || !stat.isDirectory() || stat.isSymbolicLink())
|
|
156
|
+
return false;
|
|
157
|
+
let items;
|
|
158
|
+
try {
|
|
159
|
+
items = await fs.readdir(path, { withFileTypes: true });
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
for (const item of items) {
|
|
165
|
+
if (item.isSymbolicLink() || item.name.startsWith("."))
|
|
166
|
+
continue;
|
|
167
|
+
if (mutable) {
|
|
168
|
+
if (!item.isDirectory())
|
|
169
|
+
continue;
|
|
170
|
+
const doc = await lstatOrNull(join(path, item.name, "SKILL.md"));
|
|
171
|
+
if (doc && doc.isFile() && !doc.isSymbolicLink())
|
|
172
|
+
return true;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (item.isDirectory())
|
|
176
|
+
return true;
|
|
177
|
+
if (item.isFile() && item.name.toLowerCase().endsWith(".md"))
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
140
182
|
export async function projectRoots(projectCwds = [], diagnostics) {
|
|
141
183
|
const projects = new Map();
|
|
142
184
|
for (const cwd of Array.isArray(projectCwds) ? projectCwds : []) {
|
|
@@ -207,8 +249,16 @@ export async function projectRoots(projectCwds = [], diagnostics) {
|
|
|
207
249
|
},
|
|
208
250
|
];
|
|
209
251
|
for (const candidate of candidates) {
|
|
210
|
-
if (await projectSourceSafe(candidate))
|
|
211
|
-
|
|
252
|
+
if (!(await projectSourceSafe(candidate)))
|
|
253
|
+
continue;
|
|
254
|
+
// 无技能的项目来源不注册(用户 2026-10-15 裁定):目录不存在、是链接或底下没有
|
|
255
|
+
// 一条技能条目时,这个来源对用户不可见也不可用 —— 硬注册出来只会平白占一个来源位、
|
|
256
|
+
// 在场景切换时弹「停用 project-dsh:<路径哈希>」这类只有内部键的提示,而新建技能的
|
|
257
|
+
// 落点也早已不含项目来源(createRoots 只收用户级)。本仓库就没有 .dsh/skills,
|
|
258
|
+
// 用户就是在场景预览卡上看到这一条才裁的。
|
|
259
|
+
if (!(await hasSkillEntry(candidate.path, candidate.mutable === true)))
|
|
260
|
+
continue;
|
|
261
|
+
roots.push(candidate);
|
|
212
262
|
}
|
|
213
263
|
}
|
|
214
264
|
return roots;
|
|
@@ -9,8 +9,11 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
9
9
|
import { promises as fs } from "node:fs";
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
11
|
import { isSameOrDescendant } from "../paths.js";
|
|
12
|
-
import { codedError, entryPath, lstatOrNull, pathIdentity, renameWithRetry, } from "./core-util.js";
|
|
12
|
+
import { codedError, entryPath, lstatOrNull, pathIdentity, renameWithRetry, temporaryPath, } from "./core-util.js";
|
|
13
|
+
import { rewriteDeclaredName } from "./frontmatter.js";
|
|
13
14
|
import { dshRootPath, overlapsUserSkillRoot, projectIdentity, projectRoots, readonlyError, rootByKey, rootDefinition, skillCreateRootPath, trashRootPath, } from "./skill-roots.js";
|
|
15
|
+
// `KEBAB_RE` 的单一真相源在 upload-pipeline(manager-state / core 也都从那里取)。
|
|
16
|
+
import { KEBAB_RE } from "./upload-pipeline.js";
|
|
14
17
|
export function writableRootDefinition(root) {
|
|
15
18
|
const definition = rootDefinition(root);
|
|
16
19
|
if (!definition || definition.mutable !== true)
|
|
@@ -19,7 +22,7 @@ export function writableRootDefinition(root) {
|
|
|
19
22
|
return resolve(definition.path) === resolve(dshRootPath())
|
|
20
23
|
? rootByKey("dsh")
|
|
21
24
|
: null;
|
|
22
|
-
// v0.4:hub
|
|
25
|
+
// v0.4:hub 内技能目录同为用户级可写根(插件导入/上传的落点)。
|
|
23
26
|
if (definition.key === "hub")
|
|
24
27
|
return resolve(definition.path) === resolve(skillCreateRootPath())
|
|
25
28
|
? rootByKey("hub")
|
|
@@ -204,6 +207,75 @@ export async function listTrash() {
|
|
|
204
207
|
}
|
|
205
208
|
return result.sort((a, b) => String(b.deletedAt).localeCompare(String(a.deletedAt)));
|
|
206
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* 目标根里第一个空出来的退号名:`foo` → `foo-1` → `foo-2`…(与界面 `uniqueName` 的
|
|
212
|
+
* `'dash'` 档同一写法)。
|
|
213
|
+
*
|
|
214
|
+
* 用半角连字符而不是全角括号:技能条目名必须过 `KEBAB_RE`,`foo(1)` 会被 `toKebab` 抹成
|
|
215
|
+
* `foo-1` —— 写出来本来就是 `foo-1`,不如一开始就按这个写,省得"看着叫甲、落盘叫乙"。
|
|
216
|
+
*
|
|
217
|
+
* 候选本身不合法(超长 / 空)时直接放弃:再往后试也不会变合法。
|
|
218
|
+
*/
|
|
219
|
+
async function nextFreeSkillName(root, name) {
|
|
220
|
+
for (let i = 1; i <= 999; i += 1) {
|
|
221
|
+
const candidate = `${name}-${i}`;
|
|
222
|
+
if (entryPath(root, candidate) === null)
|
|
223
|
+
return null;
|
|
224
|
+
if ((await safeExistingEntryPaths(root, candidate)).length === 0)
|
|
225
|
+
return candidate;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* 退号恢复时把技能**声明名**改成新的落盘名(用户 2026-10-14)。
|
|
231
|
+
*
|
|
232
|
+
* 为什么必须改:`canonicalSkillName()` 取的是 frontmatter 里的 `name:`,**不是文件夹名** ——
|
|
233
|
+
* 技能页的同名判定、`preferredSkills` 首选表、喂给宿主的 provider 候选名,全都以它为准。
|
|
234
|
+
* 只把目录改成 `foo-1` 而里面仍写着 `name: foo`,等于恢复出一份"看着叫 foo-1、其实还是 foo"
|
|
235
|
+
* 的技能:它会跟原件被判成同名,技能页于是给它一颗「启用这个」——而那颗按钮挑的是**来源**,
|
|
236
|
+
* 两份都在同一个来源目录里,按下去什么都不会变。
|
|
237
|
+
*
|
|
238
|
+
* 只重写 `name:` 那一行的值,其余 frontmatter(块标量、`disable-model-invocation` 等)与正文
|
|
239
|
+
* **逐字节保留**(正则只吃一个行内片段,连 CRLF 都不动):恢复的语义是"把原件放回去",
|
|
240
|
+
* 不是"按当前模板重写一遍"——后者会顺手把用户写在别的键上的东西抹掉。
|
|
241
|
+
*
|
|
242
|
+
* 不动内容的情形一律返回 false(调用方只用来记日志):名字不过 `KEBAB_RE`、形态认不出、
|
|
243
|
+
* 没有 frontmatter、原本就没有 `name:` 行、声明名本来就对。**认不出就一个字节都不写** ——
|
|
244
|
+
* 猜错形态会把附件目录里的 SKILL.md 当成技能文档改掉;替用户补一个缺失的 `name:` 也不行,
|
|
245
|
+
* 那会把一份坏技能伪装成好的。
|
|
246
|
+
*/
|
|
247
|
+
async function retitleRestoredSkill(root, name, metadata) {
|
|
248
|
+
if (!KEBAB_RE.test(name))
|
|
249
|
+
return false;
|
|
250
|
+
// 形态与上面的 `entryNameFor` 同一口径:bundle 收的是 `<原名>`,flat 收的是 `<原名>.md`。
|
|
251
|
+
const isBundle = metadata.entries.includes(metadata.name);
|
|
252
|
+
const isFlat = metadata.entries.includes(`${metadata.name}.md`);
|
|
253
|
+
// 两个都认得出或都认不出 = 形状不明,不猜。
|
|
254
|
+
if (isBundle === isFlat)
|
|
255
|
+
return false;
|
|
256
|
+
const docPath = isBundle
|
|
257
|
+
? join(root, name, "SKILL.md")
|
|
258
|
+
: resolve(root, `${name}.md`);
|
|
259
|
+
const doc = await fs.readFile(docPath, "utf8");
|
|
260
|
+
// 声明名改写走 frontmatter.ts 的共享助手(导入撞名的退号也用它):只换 `name:`
|
|
261
|
+
// 一行的值,其余逐字节保留;认不出形态返回 null —— 一个字节都不写。
|
|
262
|
+
// 不加引号:`KEBAB_RE` 已经保证这个名字只含 `[a-z0-9-]`,没有任何需要转义的字符 ——
|
|
263
|
+
// 与插件自己的写手(`createSkill` / `updateSkill` 的 `name: ${name}`)同一形状,
|
|
264
|
+
// 免得"新建出来叫 demo-1、恢复出来叫 \"demo-1\""这种无谓的差别。
|
|
265
|
+
const content = rewriteDeclaredName(doc, name);
|
|
266
|
+
if (content === null)
|
|
267
|
+
return false;
|
|
268
|
+
const temp = temporaryPath(docPath, "restore");
|
|
269
|
+
try {
|
|
270
|
+
await fs.writeFile(temp, content, "utf8");
|
|
271
|
+
await renameWithRetry(temp, docPath);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
await fs.rm(temp, { force: true }).catch(() => undefined);
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
207
279
|
export async function restoreTrash(id, log, options = {}) {
|
|
208
280
|
const metadata = await readTrashMetadata(id);
|
|
209
281
|
if (!metadata)
|
|
@@ -217,31 +289,65 @@ export async function restoreTrash(id, log, options = {}) {
|
|
|
217
289
|
if (!definition || definition.ok === false)
|
|
218
290
|
return definition || readonlyError("restore");
|
|
219
291
|
const root = definition.path;
|
|
292
|
+
/*
|
|
293
|
+
* 同名先问一句(用户 2026-10-14):撞名时**默认仍拒绝**,但把候选名字一起回(`params.next`),
|
|
294
|
+
* 界面弹窗才能如实写出"确认后会叫 `foo-1`";用户确认后带 `dedupe: true` 重来,这一次真的
|
|
295
|
+
* 按退号的名字落盘。
|
|
296
|
+
*
|
|
297
|
+
* `dedupe` 缺省即旧行为 —— 这条 op 目前只有界面在用,但保留"缺省拒"这层是为了不给未来的
|
|
298
|
+
* 调用方埋一个"撞名就悄悄改名"的默认值。
|
|
299
|
+
*/
|
|
300
|
+
let name = metadata.name;
|
|
220
301
|
const conflicts = await safeExistingEntryPaths(root, metadata.name);
|
|
221
|
-
if (conflicts.length)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
302
|
+
if (conflicts.length) {
|
|
303
|
+
const candidate = await nextFreeSkillName(root, metadata.name);
|
|
304
|
+
if (options.dedupe !== true || candidate === null)
|
|
305
|
+
return {
|
|
306
|
+
ok: false,
|
|
307
|
+
error: `已存在同名技能: ${metadata.name}`,
|
|
308
|
+
// 专用错误码(四个回收站统一):界面按它认出"这是撞名"并弹确认窗,而不是靠比文案。
|
|
309
|
+
code: "error.dup.conflict",
|
|
310
|
+
params: { name: metadata.name, ...(candidate ? { next: candidate } : {}) },
|
|
311
|
+
};
|
|
312
|
+
name = candidate;
|
|
313
|
+
}
|
|
314
|
+
const renamed = name !== metadata.name;
|
|
315
|
+
/**
|
|
316
|
+
* 回收站条目里的**顶层条目名**跟着退号,其余(bundle 目录里的附件 / 脚本)原样 ——
|
|
317
|
+
* `entries` 装的是当初移进回收站时那批顶层名字:bundle 是 `<名>`,flat 是 `<名>.md`
|
|
318
|
+
* (见 core.ts 的 deleteSkill)。只有这两个与名字有关,别的名字改了就是改坏内容。
|
|
319
|
+
*/
|
|
320
|
+
const entryNameFor = (fileName) => {
|
|
321
|
+
if (!renamed)
|
|
322
|
+
return fileName;
|
|
323
|
+
if (fileName === metadata.name)
|
|
324
|
+
return name;
|
|
325
|
+
if (fileName === `${metadata.name}.md`)
|
|
326
|
+
return `${name}.md`;
|
|
327
|
+
return fileName;
|
|
328
|
+
};
|
|
228
329
|
await fs.mkdir(root, { recursive: true });
|
|
229
330
|
const itemRoot = join(trashRootPath(), id);
|
|
230
331
|
const moved = [];
|
|
231
332
|
try {
|
|
232
333
|
for (const fileName of metadata.entries) {
|
|
233
334
|
const source = join(itemRoot, fileName);
|
|
234
|
-
const destination = join(root, fileName);
|
|
335
|
+
const destination = join(root, entryNameFor(fileName));
|
|
235
336
|
if (!isSameOrDescendant(itemRoot, source) ||
|
|
236
337
|
!isSameOrDescendant(root, destination))
|
|
237
338
|
throw codedError("回收站条目路径非法", "error.trash.invalid", { id });
|
|
238
339
|
await movePathWithFallback(source, destination, options.renameOptions);
|
|
239
340
|
moved.push({ source, destination });
|
|
240
341
|
}
|
|
342
|
+
// 顶层名字改完,文档里声明的名字也要跟上 —— 否则"看着叫 foo-1、其实还是 foo",
|
|
343
|
+
// 技能页仍按 foo 判同名。失败即抛:半份的恢复(目录改了、声明没改)正是要修的那个 bug。
|
|
344
|
+
if (renamed && (await retitleRestoredSkill(root, name, metadata)))
|
|
345
|
+
if (log)
|
|
346
|
+
log("restore", `恢复技能时改写声明名 ${metadata.name} -> ${name}`);
|
|
241
347
|
await fs.rm(itemRoot, { recursive: true, force: true });
|
|
242
348
|
if (log)
|
|
243
|
-
log("restore", `从回收站恢复 ${
|
|
244
|
-
return { id, name
|
|
349
|
+
log("restore", `从回收站恢复 ${name} -> ${root}`);
|
|
350
|
+
return { id, name, renamed, root: trashRootMetadata(definition) };
|
|
245
351
|
}
|
|
246
352
|
catch (error) {
|
|
247
353
|
for (const item of moved.reverse())
|
|
@@ -12,8 +12,8 @@ import { promises as fs } from "node:fs";
|
|
|
12
12
|
import { randomUUID } from "node:crypto";
|
|
13
13
|
import { unzipSync } from "fflate";
|
|
14
14
|
import { isSameOrDescendant } from "../paths.js";
|
|
15
|
-
import { parseSkillDoc, toKebab } from "./frontmatter.js";
|
|
16
|
-
import { attachCode, codedError, entryPath, lstatOrNull, MAX_ENTRY_NAME_LENGTH, renameWithRetry, } from "./core-util.js";
|
|
15
|
+
import { parseSkillDoc, rewriteDeclaredName, toKebab } from "./frontmatter.js";
|
|
16
|
+
import { attachCode, codedError, entryPath, lstatOrNull, MAX_ENTRY_NAME_LENGTH, renameWithRetry, temporaryPath, } from "./core-util.js";
|
|
17
17
|
import { analyzeSource, collectCandidates, MAX_SOURCE_DEPTH, preflightCandidates, replaceWithCopy, } from "./import-pipeline.js";
|
|
18
18
|
import { managerHomePath, resolvedPath, skillCreateRootPath } from "./skill-roots.js";
|
|
19
19
|
export const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -28,6 +28,37 @@ export async function pathsOverlap(a, b) {
|
|
|
28
28
|
const right = await resolvedPath(b);
|
|
29
29
|
return isSameOrDescendant(left, right) || isSameOrDescendant(right, left);
|
|
30
30
|
}
|
|
31
|
+
/** 目标根下已存在的条目路径:目录 `<name>` 或文件 `<name>.md`,两者都可能占。 */
|
|
32
|
+
async function existingEntryPaths(root, name) {
|
|
33
|
+
const paths = [join(root, name), join(root, `${name}.md`)];
|
|
34
|
+
const existing = [];
|
|
35
|
+
for (const path of paths) {
|
|
36
|
+
try {
|
|
37
|
+
await fs.stat(path);
|
|
38
|
+
existing.push(path);
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
}
|
|
42
|
+
return existing;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 退号取名:`foo` → `foo-1` → `foo-2`…(与 skill-trash 的 nextFreeSkillName、
|
|
46
|
+
* 界面 `uniqueName` 的 'dash' 档同一口径)。占用表 = 磁盘现有条目 + 本批次已定名
|
|
47
|
+
* (`claimed`:防先退号出来的名字又被同批后续候选占用)。候选本身不合法
|
|
48
|
+
* (entryPath 为 null,如超长)时直接放弃 —— 再往后试也不会变合法。
|
|
49
|
+
*/
|
|
50
|
+
async function nextFreeImportName(root, base, claimed) {
|
|
51
|
+
for (let i = 1; i <= 999; i += 1) {
|
|
52
|
+
const candidate = `${base}-${i}`;
|
|
53
|
+
if (claimed.has(candidate))
|
|
54
|
+
continue;
|
|
55
|
+
if (entryPath(root, candidate) === null)
|
|
56
|
+
return null;
|
|
57
|
+
if ((await existingEntryPaths(root, candidate)).length === 0)
|
|
58
|
+
return candidate;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
31
62
|
export async function importSkill(source, log, options = {}) {
|
|
32
63
|
const targetRoot = skillCreateRootPath();
|
|
33
64
|
const conflict = options.conflict === "overwrite" ? "overwrite" : "skip";
|
|
@@ -88,6 +119,9 @@ export async function importSkill(source, log, options = {}) {
|
|
|
88
119
|
entryPath(targetRoot, candidate.kebab) !== null)
|
|
89
120
|
nameCount.set(candidate.kebab, (nameCount.get(candidate.kebab) || 0) + 1);
|
|
90
121
|
}
|
|
122
|
+
// 本批次已定名的集合(含退号名):批量内同名由上方的 nameCount 先拒掉,这里防的是
|
|
123
|
+
// 退号名与后续候选原名的交叉占用(`foo` 退号成 `foo-1`,同批又来个 `foo-1`)。
|
|
124
|
+
const claimed = new Set();
|
|
91
125
|
function failureResult() {
|
|
92
126
|
return {
|
|
93
127
|
ok: false,
|
|
@@ -122,31 +156,39 @@ export async function importSkill(source, log, options = {}) {
|
|
|
122
156
|
});
|
|
123
157
|
continue;
|
|
124
158
|
}
|
|
159
|
+
const existing = await existingEntryPaths(targetRoot, c.kebab);
|
|
160
|
+
let renamedFrom;
|
|
161
|
+
if (existing.length) {
|
|
162
|
+
// 上传导入撞名一律退号新增(用户 2026-10-15):旧行为是静默跳过,结果里只多
|
|
163
|
+
// 一行"已跳过",用户想要的那份根本没进来。`skill-import`(目录导入)不带
|
|
164
|
+
// dedupe,仍是 skip / overwrite 二选一。
|
|
165
|
+
if (options.dedupe !== true) {
|
|
166
|
+
conflicts.push({
|
|
167
|
+
name: c.kebab,
|
|
168
|
+
source: c.source,
|
|
169
|
+
isDir: c.isDir,
|
|
170
|
+
paths: existing,
|
|
171
|
+
});
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const free = await nextFreeImportName(targetRoot, c.kebab, claimed);
|
|
175
|
+
if (!free) {
|
|
176
|
+
failed.push({
|
|
177
|
+
source: c.source,
|
|
178
|
+
error: `同名条目太多,无法生成退号名称: ${c.kebab}`,
|
|
179
|
+
code: "error.import.renameExhausted",
|
|
180
|
+
params: { name: c.kebab },
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
renamedFrom = c.kebab;
|
|
185
|
+
c.kebab = free;
|
|
186
|
+
}
|
|
125
187
|
const dest = c.isDir
|
|
126
188
|
? join(targetRoot, c.kebab)
|
|
127
189
|
: join(targetRoot, `${c.kebab}.md`);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
join(targetRoot, `${c.kebab}.md`),
|
|
131
|
-
];
|
|
132
|
-
const existing = [];
|
|
133
|
-
for (const path of paths) {
|
|
134
|
-
try {
|
|
135
|
-
await fs.stat(path);
|
|
136
|
-
existing.push(path);
|
|
137
|
-
}
|
|
138
|
-
catch { }
|
|
139
|
-
}
|
|
140
|
-
if (existing.length) {
|
|
141
|
-
conflicts.push({
|
|
142
|
-
name: c.kebab,
|
|
143
|
-
source: c.source,
|
|
144
|
-
isDir: c.isDir,
|
|
145
|
-
paths: existing,
|
|
146
|
-
});
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
pending.push({ name: c.kebab, source: c.source, isDir: c.isDir, dest });
|
|
190
|
+
claimed.add(c.kebab);
|
|
191
|
+
pending.push({ name: c.kebab, source: c.source, isDir: c.isDir, dest, renamedFrom });
|
|
150
192
|
}
|
|
151
193
|
if (pending.length === 0 && conflicts.length === 0)
|
|
152
194
|
return failureResult();
|
|
@@ -164,6 +206,31 @@ export async function importSkill(source, log, options = {}) {
|
|
|
164
206
|
for (const p of pending) {
|
|
165
207
|
try {
|
|
166
208
|
const warnings = await replaceWithCopy(p.source, p.dest, p.isDir);
|
|
209
|
+
// 退号落盘后把声明名改成新名字:`canonicalSkillName` 取的是 frontmatter 的
|
|
210
|
+
// `name:`,只改目录会"看着叫 foo-1、其实还是 foo"—— 两份同名技能在「同名
|
|
211
|
+
// 首选」里被并成一组,而那颗开关挑的是来源,按下去什么都不会变(skill-trash.ts
|
|
212
|
+
// 的 retitleRestoredSkill 同一理由)。返回 null(没有 `name:` 行)是良性的:
|
|
213
|
+
// 声明名为空时 canonicalSkillName 回落到条目名,退号目录名就是它的名字。
|
|
214
|
+
// 写盘失败记一条 warning,不阻断导入 —— 技能已按退号名落盘。
|
|
215
|
+
if (p.renamedFrom) {
|
|
216
|
+
try {
|
|
217
|
+
const docPath = p.isDir ? join(p.dest, "SKILL.md") : p.dest;
|
|
218
|
+
const doc = await fs.readFile(docPath, "utf8");
|
|
219
|
+
const content = rewriteDeclaredName(doc, p.name);
|
|
220
|
+
if (content !== null) {
|
|
221
|
+
const temp = temporaryPath(docPath, "retitle");
|
|
222
|
+
await fs.writeFile(temp, content, "utf8");
|
|
223
|
+
await renameWithRetry(temp, docPath);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
warnings.push({
|
|
228
|
+
code: "warning.retitleFailed",
|
|
229
|
+
params: { path: p.dest },
|
|
230
|
+
error: String(e && e.message ? e.message : e),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
167
234
|
imported.push({ name: p.name, overwritten: false, warnings });
|
|
168
235
|
if (log)
|
|
169
236
|
log("import", `导入 ${p.source} -> ${p.dest}`);
|
|
@@ -337,8 +404,25 @@ export async function prepareUploadedSource(sessionRoot, input) {
|
|
|
337
404
|
await writeUploadedEntries(contentRoot, input && input.entries);
|
|
338
405
|
const rootSkill = join(contentRoot, "SKILL.md");
|
|
339
406
|
const rootSkillStat = await lstatOrNull(rootSkill);
|
|
340
|
-
if (!rootSkillStat || !rootSkillStat.isFile())
|
|
341
|
-
|
|
407
|
+
if (!rootSkillStat || !rootSkillStat.isFile()) {
|
|
408
|
+
// 没有根级 SKILL.md:剩下的合法形态只有两种 —— 根级 .md(单文件技能)或子目录
|
|
409
|
+
// 带 SKILL.md(批量),与 importSkill 的 collectCandidates 收录口径一一对应。
|
|
410
|
+
// 两者都不占,这个包就打不开:此时说清缺的是 SKILL.md、以及是哪个包(用户
|
|
411
|
+
// 2026-10-15:老报错是 importSkill 的空来源错误,带着 uuid 临时目录路径、落在
|
|
412
|
+
// 通用「文件要求」段,认不出是谁、也看不出缺什么)。
|
|
413
|
+
const items = await fs.readdir(contentRoot, { withFileTypes: true });
|
|
414
|
+
for (const it of items) {
|
|
415
|
+
if (it.isFile() && it.name.toLowerCase().endsWith(".md"))
|
|
416
|
+
return contentRoot;
|
|
417
|
+
if (it.isDirectory()) {
|
|
418
|
+
const st = await lstatOrNull(join(contentRoot, it.name, "SKILL.md"));
|
|
419
|
+
if (st && st.isFile())
|
|
420
|
+
return contentRoot;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const label = String((input && input.name) || "").trim() || "上传内容";
|
|
424
|
+
throw codedError(`${label} 中未找到 SKILL.md:需放在根目录,或放在某个子目录下`, "error.upload.noSkillMd", { name: label });
|
|
425
|
+
}
|
|
342
426
|
const doc = parseSkillDoc(await fs.readFile(rootSkill, "utf8"));
|
|
343
427
|
const fallback = String((input && input.name) || "uploaded-skill")
|
|
344
428
|
.replace(/\.zip$/i, "")
|
package/lib/subagents/catalog.js
CHANGED
|
@@ -80,8 +80,10 @@ export function createSubagentCatalog(deps, opts = {}) {
|
|
|
80
80
|
let allowed = [];
|
|
81
81
|
let loadedAt = Number.NEGATIVE_INFINITY;
|
|
82
82
|
let inflight = null;
|
|
83
|
-
//
|
|
84
|
-
//
|
|
83
|
+
// 渲染结果按**深度 + 尾句前提**缓存:过滤只依赖深度这一个整数(实际出现的深度就 0/1/2 三档),
|
|
84
|
+
// 而尾句点不点名 `subagent_manager_list` 取决于**这个 agent 手里有没有它**(2026-10-14
|
|
85
|
+
// 审查 P1-8:此前只按深度缓存,于是尾句用的是第一个来问的 agent 的答案)。
|
|
86
|
+
// 两维都是小枚举,于是"每个 step 同步渲染"退化成一次查表。重算成功时清空。
|
|
85
87
|
const rendered = new Map();
|
|
86
88
|
const recompute = async () => {
|
|
87
89
|
try {
|
|
@@ -102,11 +104,13 @@ export function createSubagentCatalog(deps, opts = {}) {
|
|
|
102
104
|
if (now() - loadedAt > ttlMs)
|
|
103
105
|
void revalidate();
|
|
104
106
|
const depth = subagentDepthOf(agent);
|
|
105
|
-
const
|
|
107
|
+
const visible = deps.listToolVisible ? deps.listToolVisible(agent) : true;
|
|
108
|
+
const key = depth + '|' + (visible ? '1' : '0');
|
|
109
|
+
const cached = rendered.get(key);
|
|
106
110
|
if (cached !== undefined)
|
|
107
111
|
return cached;
|
|
108
|
-
const out = renderSubagentCatalog(allowed.filter((p) => catalogInjectedAt(p, depth)), maxEntries, maxDescription,
|
|
109
|
-
rendered.set(
|
|
112
|
+
const out = renderSubagentCatalog(allowed.filter((p) => catalogInjectedAt(p, depth)), maxEntries, maxDescription, visible);
|
|
113
|
+
rendered.set(key, out);
|
|
110
114
|
return out;
|
|
111
115
|
},
|
|
112
116
|
catalogVisibleAt: (depth) => allowed.some((p) => catalogInjectedAt(p, depth)),
|
package/lib/subagents/service.js
CHANGED
|
@@ -196,6 +196,24 @@ export function createSubagentService(ctx, opts) {
|
|
|
196
196
|
: { ...res, stateSyncError: reason };
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* 目标目录里第一个空出来的退号名:`写作助手` → `写作助手(1)` → `写作助手(2)`…
|
|
201
|
+
* (全角括号,与界面 `uniqueName` 同一写法 —— 人设名就是文件名,不做任何归一化,
|
|
202
|
+
* `validPersonaName` 也放行全角括号)。
|
|
203
|
+
*
|
|
204
|
+
* 候选本身不合法(超长 / 空)时直接放弃:再往后试也不会变合法。
|
|
205
|
+
*/
|
|
206
|
+
async function nextFreePersonaName(name) {
|
|
207
|
+
for (let i = 1; i <= 999; i += 1) {
|
|
208
|
+
const candidate = `${name}(${i})`;
|
|
209
|
+
if (!validPersonaName(candidate))
|
|
210
|
+
return null;
|
|
211
|
+
const taken = await readFile(join(dir, candidate + '.md'), 'utf8').then(() => true).catch(() => false);
|
|
212
|
+
if (!taken)
|
|
213
|
+
return candidate;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
199
217
|
/** list() 输出统一附上 enabled:文件缺失 → 全 true;否则按集合。 */
|
|
200
218
|
function withEnabled(docs, set) {
|
|
201
219
|
if (set === null)
|
|
@@ -361,11 +379,30 @@ export function createSubagentService(ctx, opts) {
|
|
|
361
379
|
return { ok: false, error: `回收站条目不存在: ${id}` };
|
|
362
380
|
if (!validPersonaName(entry.name))
|
|
363
381
|
return { ok: false, error: `回收站里的人设名不合法: ${entry.name}` };
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
382
|
+
/*
|
|
383
|
+
* 同名先问一句(用户 2026-10-14):撞名时**默认仍拒绝**(绝不覆盖),但把候选名字一起
|
|
384
|
+
* 回(`params.next`),界面弹窗才能如实写出"确认后会叫 `写作助手(1)`";用户确认后带
|
|
385
|
+
* `dedupe: true` 重来,这一次真的按退号的名字落盘。
|
|
386
|
+
*
|
|
387
|
+
* `dedupe` 缺省即旧行为:这条 op 目前只有界面在用,但默认值必须是"拒" —— 一个默认
|
|
388
|
+
* 静默改名的恢复口,早晚会有人踩。
|
|
389
|
+
*/
|
|
390
|
+
let name = entry.name;
|
|
391
|
+
const exists = await readFile(join(dir, name + '.md'), 'utf8').then(() => true).catch(() => false);
|
|
392
|
+
if (exists) {
|
|
393
|
+
const next = await nextFreePersonaName(name);
|
|
394
|
+
if (args?.dedupe !== true || !next) {
|
|
395
|
+
// 专用错误码(四个回收站统一):界面按它认出"这是撞名"并弹确认窗。
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
error: `已存在同名人设: ${name}`,
|
|
399
|
+
code: 'error.dup.conflict',
|
|
400
|
+
params: { name, ...(next ? { next } : {}) },
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
name = next;
|
|
404
|
+
}
|
|
405
|
+
const target = join(dir, name + '.md');
|
|
369
406
|
try {
|
|
370
407
|
await mkdir(dir, { recursive: true });
|
|
371
408
|
await moveOutOfTrash('subagents', id, 'persona.md', target);
|
|
@@ -375,8 +412,9 @@ export function createSubagentService(ctx, opts) {
|
|
|
375
412
|
}
|
|
376
413
|
await purgeTrashEntry('subagents', id);
|
|
377
414
|
// v0.8.5:回收站恢复默认不启动(与新建/导入同口径)。
|
|
415
|
+
// 启停名单必须按**最终落盘的名字**写:退号恢复时它不是回收站里的原名。
|
|
378
416
|
cache = null;
|
|
379
|
-
return withEnabledNote({ ok: true, name: entry.name }, () => materializeEnabledExcluding([
|
|
417
|
+
return withEnabledNote({ ok: true, name, renamed: name !== entry.name }, () => materializeEnabledExcluding([name]));
|
|
380
418
|
},
|
|
381
419
|
'subagent-trash-delete': async (args) => {
|
|
382
420
|
const id = String((args && args.id) || '').trim();
|