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.
- package/CHANGELOG.md +915 -829
- package/README.md +237 -254
- package/README_EN.md +234 -223
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/images/logo.gif +0 -0
- package/docs/update.md +32 -30
- package/lib/client.js +9480 -6970
- package/lib/compat/preset-reach.js +5 -2
- package/lib/context-inject-frame.js +13 -8
- package/lib/hub.js +104 -11
- package/lib/index.js +72 -24
- package/lib/mcp/manager.js +45 -13
- package/lib/mcp/secret-guard.js +2 -2
- package/lib/mcp/state-section.js +1 -1
- package/lib/memories/archive-engine.js +4 -5
- package/lib/memories/constants.js +11 -4
- package/lib/memories/service.js +6 -1
- package/lib/op-registry.js +13 -11
- package/lib/ops/memory.js +42 -4
- package/lib/ops/scene-records.js +43 -9
- package/lib/ops/sessions.js +67 -46
- package/lib/ops/trash.js +107 -8
- package/lib/request-gate.js +9 -2
- package/lib/scene-prompt-sync.js +48 -23
- package/lib/sessions/history.js +32 -22
- package/lib/sessions/workspace.js +0 -10
- package/lib/skills/catalog.js +17 -5
- package/lib/skills/core.js +189 -7
- package/lib/skills/frontmatter.js +31 -0
- package/lib/skills/manager-state.js +73 -3
- package/lib/skills/service.js +53 -13
- 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 +74 -27
- package/lib/tools/memory.js +49 -9
- package/lib/tools/prompt.js +38 -9
- package/lib/tools/remove.js +206 -0
- package/lib/tools/scene.js +155 -32
- 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/lib/trash-retention.js +3 -3
- package/package.json +115 -114
- package/lib/mcp/inspect-probe.js +0 -199
package/lib/scene-prompt-sync.js
CHANGED
|
@@ -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
|
-
|
|
182
|
-
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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 失败') };
|
package/lib/sessions/history.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 的标题行,旧格式记录退
|
package/lib/skills/catalog.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/lib/skills/core.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
910
|
-
//
|
|
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("
|
|
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
|
+
}
|