@morehao/dsh-skills-console 0.3.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -65
- package/client.js +206 -267
- package/cordis.patch.yml +2 -1
- package/lib/agent-table.json +133 -64
- package/lib/core/agents.js +68 -9
- package/lib/core/exporter.js +137 -0
- package/lib/core/inventory.js +73 -39
- package/lib/core/migrations.js +178 -0
- package/lib/core/paths.js +13 -0
- package/lib/core/policy.js +373 -0
- package/lib/core/remote.js +3 -1
- package/lib/core/roots.js +182 -0
- package/lib/core/service.js +391 -137
- package/lib/core/shadow.js +195 -0
- package/lib/core/skill-md.js +23 -4
- package/lib/core/state.js +293 -42
- package/lib/core/visibility.js +145 -0
- package/lib/index.js +27 -0
- package/lib/routes.js +13 -3
- package/package.json +1 -1
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { findProjectRoot, listRoots, ROOT_RANKS, scanSkillRoot } from './roots.js';
|
|
4
|
+
import { readSkillDocument } from './skill-md.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 本地策略 provider(v0.4.0 起替代「~/.dsh/skills 停用桩」)。
|
|
8
|
+
*
|
|
9
|
+
* 它只做一件事:把 `state.installs[].enabled === false` 的 canonical 技能报告成
|
|
10
|
+
* 「同名、不可调用」的候选。enabled 技能完全不报告,交给宿主 `skill-filesystem`。
|
|
11
|
+
*
|
|
12
|
+
* 两个硬约束(P0b 已在真实宿主上验证,见设计文档 §3.6):
|
|
13
|
+
* 1. **必须注册在每个 live agent 自己的 scope 里**:agent scope 是 preset scope 的
|
|
14
|
+
* 子层,注册表「最近层直接赢」;只注册 global 层会被 preset 层的文件系统候选盖住。
|
|
15
|
+
* 2. **只在 canonical 是 native winner 时才发候选**:agent 层的候选会连项目/user-dsh
|
|
16
|
+
* 的同名副本一起压掉(rank 跨层不参与裁决)。所以项目 100/200、user-dsh 400
|
|
17
|
+
* 有副本时 provider 必须沉默,让副本继续可见。
|
|
18
|
+
*
|
|
19
|
+
* 失败策略是 fail-open:读不到 state / 单个 SKILL.md 解析失败只跳过,`list` 绝不
|
|
20
|
+
* 抛错——provider 抛错会让宿主把整层标记为不完整,可能让所有技能消失。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 同层兜底 rank:只用于「两个 policy provider 都在 agent 层」这类情况。 */
|
|
24
|
+
export const POLICY_RANK = ROOT_RANKS['user-agents'] - 2;
|
|
25
|
+
export const POLICY_PROVIDER = 'dsh-skills-console-policy';
|
|
26
|
+
export const DISABLED_DESCRIPTION = '(已停用)';
|
|
27
|
+
export const SKILL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
28
|
+
|
|
29
|
+
function isDirectory(candidate) {
|
|
30
|
+
try {
|
|
31
|
+
return fs.statSync(candidate).isDirectory();
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function samePath(left, right) {
|
|
38
|
+
if (!left || !right) return false;
|
|
39
|
+
return path.resolve(left) === path.resolve(right);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `cwd` → 项目根;缺省时**不回退** `process.cwd()`(宿主这时也只扫 user 根)。 */
|
|
43
|
+
function projectRootFor(cwd) {
|
|
44
|
+
if (typeof cwd !== 'string' || !cwd.trim()) return null;
|
|
45
|
+
return findProjectRoot(cwd, fs.existsSync);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 每个根的扫描结果按 frontmatter `name` 合并,rank 最小者就是宿主的 native winner。
|
|
50
|
+
* 返回 `Map<name, {rank, path, skillFile, source, modelInvocable}>`。
|
|
51
|
+
*/
|
|
52
|
+
function nativeWinners({ paths: pathSet, projectRoot, logger, purpose }) {
|
|
53
|
+
const winners = new Map();
|
|
54
|
+
for (const root of listRoots({ paths: pathSet, projectRoot, customDirs: [] })) {
|
|
55
|
+
if (!root.exists) continue;
|
|
56
|
+
const scanned = scanSkillRoot(root.path, (error) => {
|
|
57
|
+
logger('policy.scan.failed', { purpose, source: root.source, message: error?.message || String(error) });
|
|
58
|
+
});
|
|
59
|
+
for (const entry of scanned.entries) {
|
|
60
|
+
const previous = winners.get(entry.name);
|
|
61
|
+
if (previous && previous.rank <= root.rank) continue;
|
|
62
|
+
winners.set(entry.name, {
|
|
63
|
+
rank: root.rank,
|
|
64
|
+
path: entry.path,
|
|
65
|
+
skillFile: entry.skillFile,
|
|
66
|
+
source: root.source,
|
|
67
|
+
modelInvocable: entry.modelInvocable !== false,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return winners;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {object} options
|
|
76
|
+
* @param {object} options.paths `createPaths()` 的结果
|
|
77
|
+
* @param {Function} options.getState 返回当前 state(同步)
|
|
78
|
+
* @param {Function} [options.logger]
|
|
79
|
+
*/
|
|
80
|
+
export function createPolicyProvider({ paths, getState, logger = () => {} }) {
|
|
81
|
+
const stateOf = () => {
|
|
82
|
+
try {
|
|
83
|
+
return getState() || null;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
logger('policy.state.failed', { message: error?.message || String(error) });
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 当前 cwd 下,该项目/用户根里有没有比 canonical 更靠前的同名副本。
|
|
92
|
+
* 返回 `{ projectRoot, winners }`;`winners` 在 list/get 内复用,避免重复扫根。
|
|
93
|
+
*/
|
|
94
|
+
function scanNative(cwd, purpose) {
|
|
95
|
+
return nativeWinners({ paths, projectRoot: projectRootFor(cwd), logger, purpose });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function canonicalDirOf(installName) {
|
|
99
|
+
return path.join(paths.canonical, installName);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** disabled 且真的是 canonical 目录包的行(flat `.md` 本期不支持启停)。 */
|
|
103
|
+
function disabledInstalls() {
|
|
104
|
+
const state = stateOf();
|
|
105
|
+
if (!state || !Array.isArray(state.installs)) return [];
|
|
106
|
+
const rows = [];
|
|
107
|
+
for (const install of state.installs) {
|
|
108
|
+
if (!install || install.enabled !== false) continue;
|
|
109
|
+
if (typeof install.name !== 'string' || !SKILL_NAME_PATTERN.test(install.name)) continue;
|
|
110
|
+
if (!isDirectory(canonicalDirOf(install.name))) continue;
|
|
111
|
+
rows.push(install);
|
|
112
|
+
}
|
|
113
|
+
return rows;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const provider = {
|
|
117
|
+
name: POLICY_PROVIDER,
|
|
118
|
+
|
|
119
|
+
async list(options = {}) {
|
|
120
|
+
try {
|
|
121
|
+
// canonical 不在扫描根里时,发候选等于「凭空造出一个本来不可见的技能」。
|
|
122
|
+
if (!paths.canonicalScanned) return [];
|
|
123
|
+
const installs = disabledInstalls();
|
|
124
|
+
if (installs.length === 0) return [];
|
|
125
|
+
const winners = scanNative(options.cwd, 'list');
|
|
126
|
+
const candidates = [];
|
|
127
|
+
for (const install of installs) {
|
|
128
|
+
const dir = canonicalDirOf(install.name);
|
|
129
|
+
const docPath = path.join(dir, 'SKILL.md');
|
|
130
|
+
let doc;
|
|
131
|
+
try {
|
|
132
|
+
doc = readSkillDocument(docPath);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
logger('policy.document.failed', { name: install.name, message: error?.message || String(error) });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const name = doc.fields?.name;
|
|
138
|
+
if (typeof name !== 'string' || !SKILL_NAME_PATTERN.test(name) || !doc.fields.description) {
|
|
139
|
+
// 与宿主 / roots.js 同一口径:缺 name/description 的目录本来就不被当成技能,
|
|
140
|
+
// 发候选等于凭空造一个。
|
|
141
|
+
logger('policy.document.invalid', { name: install.name });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const winner = winners.get(name);
|
|
145
|
+
// 项目 / user-dsh 副本赢:保持沉默,副本继续可见(P0b)。
|
|
146
|
+
if (winner && !samePath(winner.skillFile, docPath)) continue;
|
|
147
|
+
candidates.push({
|
|
148
|
+
name,
|
|
149
|
+
description: doc.fields.description || DISABLED_DESCRIPTION,
|
|
150
|
+
invocation: { modelInvocable: false, userInvocable: false },
|
|
151
|
+
provider: POLICY_PROVIDER,
|
|
152
|
+
source: 'user-agents',
|
|
153
|
+
rank: POLICY_RANK,
|
|
154
|
+
locator: { kind: 'canonical', installName: install.name, name, docPath, dir },
|
|
155
|
+
resourceBase: { kind: 'directory', path: dir },
|
|
156
|
+
path: docPath,
|
|
157
|
+
metadata: { dshSkillsConsole: { disabled: true } },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return candidates;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
logger('policy.list.failed', { message: error?.message || String(error) });
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async get(candidate, options = {}) {
|
|
168
|
+
try {
|
|
169
|
+
const name = candidate?.name;
|
|
170
|
+
const installName = candidate?.locator?.installName;
|
|
171
|
+
if (typeof name !== 'string' || !SKILL_NAME_PATTERN.test(name)) return undefined;
|
|
172
|
+
if (typeof installName !== 'string' || !SKILL_NAME_PATTERN.test(installName)) return undefined;
|
|
173
|
+
const dir = canonicalDirOf(installName);
|
|
174
|
+
const docPath = path.join(dir, 'SKILL.md');
|
|
175
|
+
// 不信任候选里的任意路径:只认 canonical/<installName>/SKILL.md
|
|
176
|
+
if (!samePath(candidate?.locator?.docPath, docPath) || !isDirectory(dir)) return undefined;
|
|
177
|
+
|
|
178
|
+
const state = stateOf();
|
|
179
|
+
const install = state?.installs?.find((item) => item && item.name === installName) || null;
|
|
180
|
+
if (!install || install.enabled !== false) return undefined;
|
|
181
|
+
|
|
182
|
+
// 候选可能已经陈旧:重新算一遍 winner,副本赢了就不再压它。
|
|
183
|
+
const winners = scanNative(options.cwd, 'get');
|
|
184
|
+
const winner = winners.get(name);
|
|
185
|
+
if (winner && !samePath(winner.skillFile, docPath)) return undefined;
|
|
186
|
+
|
|
187
|
+
let doc;
|
|
188
|
+
try {
|
|
189
|
+
doc = readSkillDocument(docPath);
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
if (doc.fields?.name !== name || !doc.fields.description) return undefined;
|
|
194
|
+
return {
|
|
195
|
+
name,
|
|
196
|
+
description: doc.fields.description || DISABLED_DESCRIPTION,
|
|
197
|
+
invocation: { modelInvocable: false, userInvocable: false },
|
|
198
|
+
source: 'user-agents',
|
|
199
|
+
provider: POLICY_PROVIDER,
|
|
200
|
+
resourceBase: { kind: 'directory', path: dir },
|
|
201
|
+
path: docPath,
|
|
202
|
+
content: doc.body,
|
|
203
|
+
};
|
|
204
|
+
} catch (error) {
|
|
205
|
+
logger('policy.get.failed', { message: error?.message || String(error) });
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
return provider;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 把同一个 provider 注册到**每个 live agent 自己的 skill registry**,并跟踪
|
|
216
|
+
* `agent/created` / `agent/disposed`。返回 `{ invalidate, dispose, status }`。
|
|
217
|
+
*
|
|
218
|
+
* `status()`:
|
|
219
|
+
* - `available`:宿主有没有全局 `skills.registerProvider`(没有时启停要 409,
|
|
220
|
+
* 不能写一个「界面说停用、模型照样看见」的状态);
|
|
221
|
+
* - `liveAgents` / `registered`:当前 live agent 数与成功注册数;
|
|
222
|
+
* - `errors[]`:注册失败的摘要(只有 agent id / 错误码,不写技能正文)。
|
|
223
|
+
*/
|
|
224
|
+
export function installPolicyProviders(ctx, { provider, logger = () => {} } = {}) {
|
|
225
|
+
const errors = [];
|
|
226
|
+
const registrations = new Map();
|
|
227
|
+
const invalidators = new Set();
|
|
228
|
+
const liveAgents = new Set();
|
|
229
|
+
const unsubscribes = [];
|
|
230
|
+
let disposed = false;
|
|
231
|
+
|
|
232
|
+
const skillsOf = () => {
|
|
233
|
+
try {
|
|
234
|
+
return ctx?.get?.('skills') || null;
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
const available = () => typeof skillsOf()?.registerProvider === 'function';
|
|
240
|
+
|
|
241
|
+
function recordError(agent, code, error) {
|
|
242
|
+
const row = {
|
|
243
|
+
agent: agent?.id ?? null,
|
|
244
|
+
code,
|
|
245
|
+
message: error?.message || String(error ?? ''),
|
|
246
|
+
};
|
|
247
|
+
errors.push(row);
|
|
248
|
+
if (errors.length > 20) errors.shift();
|
|
249
|
+
logger('policy.register.failed', { agent: row.agent, code, message: row.message });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function unregister(agent) {
|
|
253
|
+
const id = agent?.id;
|
|
254
|
+
if (id === undefined || id === null) return;
|
|
255
|
+
liveAgents.delete(id);
|
|
256
|
+
const entry = registrations.get(id);
|
|
257
|
+
if (!entry) return;
|
|
258
|
+
registrations.delete(id);
|
|
259
|
+
for (const invalidate of entry.invalidators) invalidators.delete(invalidate);
|
|
260
|
+
try {
|
|
261
|
+
entry.dispose?.();
|
|
262
|
+
} catch (error) {
|
|
263
|
+
logger('policy.dispose.failed', { agent: id, message: error?.message || String(error) });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function register(agent) {
|
|
268
|
+
if (disposed || !agent || agent.id === undefined || agent.id === null) return;
|
|
269
|
+
// 初始 list 与 agent/created 可能重复注册同一个 agent:先撤旧的。
|
|
270
|
+
if (registrations.has(agent.id)) unregister(agent);
|
|
271
|
+
liveAgents.add(agent.id);
|
|
272
|
+
let skills;
|
|
273
|
+
try {
|
|
274
|
+
skills = agent.ctx?.get?.('skills') ?? agent.ctx?.skills;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
recordError(agent, 'agent-ctx-failed', error);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (typeof skills?.registerProvider !== 'function') {
|
|
280
|
+
recordError(agent, 'no-register-provider', null);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const entry = { invalidators: new Set(), dispose: null };
|
|
284
|
+
try {
|
|
285
|
+
// factory 在 registerProvider 内同步调用;注册完就能拿到 control.invalidate。
|
|
286
|
+
entry.dispose = skills.registerProvider((control = {}) => {
|
|
287
|
+
const invalidate = control.invalidate;
|
|
288
|
+
if (typeof invalidate === 'function') {
|
|
289
|
+
entry.invalidators.add(invalidate);
|
|
290
|
+
invalidators.add(invalidate);
|
|
291
|
+
// 宿主的 provider 被单独回收时,别让失效集合继续抓着它的闭包。
|
|
292
|
+
if (control.signal && typeof control.signal.addEventListener === 'function') {
|
|
293
|
+
control.signal.addEventListener('abort', () => {
|
|
294
|
+
entry.invalidators.delete(invalidate);
|
|
295
|
+
invalidators.delete(invalidate);
|
|
296
|
+
}, { once: true });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { ...provider };
|
|
300
|
+
});
|
|
301
|
+
} catch (error) {
|
|
302
|
+
recordError(agent, 'register-failed', error);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
registrations.set(agent.id, entry);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 初始已有 agent(宿主先建 session 再装插件时不漏)
|
|
309
|
+
try {
|
|
310
|
+
const agentsService = ctx?.get?.('agents');
|
|
311
|
+
const initial = typeof agentsService?.list === 'function' ? agentsService.list() : [];
|
|
312
|
+
for (const agent of initial || []) register(agent);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
recordError(null, 'agents-list-failed', error);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const [event, handler] of [
|
|
318
|
+
['agent/created', ({ agent } = {}) => register(agent)],
|
|
319
|
+
['agent/disposed', ({ agent } = {}) => unregister(agent)],
|
|
320
|
+
]) {
|
|
321
|
+
try {
|
|
322
|
+
const off = ctx?.on?.(event, handler);
|
|
323
|
+
if (typeof off === 'function') unsubscribes.push(off);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
recordError(null, `${event.replace('/', '-')}-subscribe-failed`, error);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
/** 启停写成功后调用:宿主下次 `list` 会重新收集,模型目录随之刷新。 */
|
|
331
|
+
invalidate() {
|
|
332
|
+
for (const invalidate of [...invalidators]) {
|
|
333
|
+
try {
|
|
334
|
+
invalidate();
|
|
335
|
+
} catch (error) {
|
|
336
|
+
logger('policy.invalidate.failed', { message: error?.message || String(error) });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
/** 退订事件并释放所有 scope 注册;幂等。 */
|
|
341
|
+
dispose() {
|
|
342
|
+
if (disposed) return;
|
|
343
|
+
disposed = true;
|
|
344
|
+
for (const off of unsubscribes.splice(0)) {
|
|
345
|
+
try {
|
|
346
|
+
off();
|
|
347
|
+
} catch {
|
|
348
|
+
/* 已卸载 */
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
for (const id of [...registrations.keys()]) {
|
|
352
|
+
const entry = registrations.get(id);
|
|
353
|
+
registrations.delete(id);
|
|
354
|
+
for (const invalidate of entry?.invalidators || []) invalidators.delete(invalidate);
|
|
355
|
+
try {
|
|
356
|
+
entry?.dispose?.();
|
|
357
|
+
} catch {
|
|
358
|
+
/* 已卸载 */
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
invalidators.clear();
|
|
362
|
+
liveAgents.clear();
|
|
363
|
+
},
|
|
364
|
+
status() {
|
|
365
|
+
return {
|
|
366
|
+
available: available(),
|
|
367
|
+
liveAgents: liveAgents.size,
|
|
368
|
+
registered: registrations.size,
|
|
369
|
+
errors: errors.map((item) => ({ ...item })),
|
|
370
|
+
};
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
package/lib/core/remote.js
CHANGED
|
@@ -16,7 +16,9 @@ import { parseSource } from './updates.js';
|
|
|
16
16
|
* - 默认 60s 超时;超时/中止都抛 RemoteError,不留下半写文件。
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// 上游仓库可能带设计素材(如 tt-a1i/archify 解压后 ~100MB),50MB 会误伤;
|
|
20
|
+
// 解压侧另有 200MB / 10000 文件的上限兜底。
|
|
21
|
+
export const DEFAULT_MAX_BYTES = 100 * 1024 * 1024;
|
|
20
22
|
export const DEFAULT_TIMEOUT_MS = 60_000;
|
|
21
23
|
const MAX_REDIRECTS = 5;
|
|
22
24
|
const ALLOWED_HOSTS = new Set([
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseSkillFrontmatter } from './skill-md.js';
|
|
4
|
+
import { findProjectRoot } from './paths.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 项目根 = 最近含 `.git` 的祖先,无则 null(与宿主 `skill-filesystem` 一致)。
|
|
8
|
+
* 实现在 `paths.js`(那里也做 env/override 归一),这里再导出一次,让
|
|
9
|
+
* `roots.js` 成为「扫描根 + 项目根口径」的唯一入口。
|
|
10
|
+
*/
|
|
11
|
+
export { findProjectRoot };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* DSH 的文件系统扫描根,rank 与宿主 `@deepseek-ai/dsh-skill-filesystem` 一致
|
|
15
|
+
* (**数字越小优先级越高**,与 CLI agent 表的 `rank` 方向相反)。
|
|
16
|
+
*
|
|
17
|
+
* 600 `bundled` 是构建期资源,磁盘上没有对应目录,只能从 `ctx.skills.snapshot()` 拿,
|
|
18
|
+
* 所以在 `visibility.js` 里作为「额外候选」参与合并。
|
|
19
|
+
*/
|
|
20
|
+
export const ROOT_RANKS = Object.freeze({
|
|
21
|
+
'project-dsh': 100,
|
|
22
|
+
'project-agents': 200,
|
|
23
|
+
custom: 300,
|
|
24
|
+
'user-dsh': 400,
|
|
25
|
+
'user-agents': 500,
|
|
26
|
+
bundled: 600,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function isDir(candidate) {
|
|
30
|
+
try {
|
|
31
|
+
return fs.statSync(candidate).isDirectory();
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 当前环境下的扫描根。`projectRoot` 为空时只有两个 user 根。
|
|
39
|
+
* `customDirs` 是 `Config.customSkillDirs`;本插件读不到宿主配置,默认空。
|
|
40
|
+
*/
|
|
41
|
+
export function listRoots({ paths, projectRoot = null, customDirs = [] }) {
|
|
42
|
+
const roots = [];
|
|
43
|
+
if (projectRoot) {
|
|
44
|
+
roots.push({
|
|
45
|
+
source: 'project-dsh',
|
|
46
|
+
rank: ROOT_RANKS['project-dsh'],
|
|
47
|
+
path: path.join(projectRoot, '.dsh', 'skills'),
|
|
48
|
+
label: '本项目 .dsh/skills',
|
|
49
|
+
});
|
|
50
|
+
roots.push({
|
|
51
|
+
source: 'project-agents',
|
|
52
|
+
rank: ROOT_RANKS['project-agents'],
|
|
53
|
+
path: path.join(projectRoot, '.agents', 'skills'),
|
|
54
|
+
label: '本项目 .agents/skills',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
customDirs
|
|
58
|
+
.filter((dir) => typeof dir === 'string' && path.isAbsolute(dir))
|
|
59
|
+
.forEach((dir, index) => {
|
|
60
|
+
roots.push({ source: 'custom', rank: ROOT_RANKS.custom, path: dir, label: `自定义目录 ${index + 1}` });
|
|
61
|
+
});
|
|
62
|
+
roots.push({
|
|
63
|
+
source: 'user-dsh',
|
|
64
|
+
rank: ROOT_RANKS['user-dsh'],
|
|
65
|
+
path: paths.dshGlobalSkills,
|
|
66
|
+
label: '~/.dsh/skills',
|
|
67
|
+
});
|
|
68
|
+
roots.push({
|
|
69
|
+
source: 'user-agents',
|
|
70
|
+
rank: ROOT_RANKS['user-agents'],
|
|
71
|
+
path: paths.userAgentsSkills,
|
|
72
|
+
label: '~/.agents/skills',
|
|
73
|
+
});
|
|
74
|
+
return roots.map((root) => ({ ...root, exists: isDir(root.path) }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* DSH 扫描根的**路径**(只含 project / user 四个本地根)。
|
|
79
|
+
*
|
|
80
|
+
* 用途:判断「这个目录是不是 DSH 自己的扫描根」——往扫描根里建导出链是「用链接伪装
|
|
81
|
+
* 可见性」,而扫描根本身已经决定可见性(见 AGENTS.md §2)。`customDirs` 不在此列:
|
|
82
|
+
* 那是宿主配置,本插件读不到,也不会成为导出目标。
|
|
83
|
+
*
|
|
84
|
+
* 与 `listRoots` 的 project-dsh / project-agents / user-dsh / user-agents 一一对应,
|
|
85
|
+
* `test/agents.test.mjs` 有一条用例交叉校验两者的路径集合,别只改一边。
|
|
86
|
+
*/
|
|
87
|
+
export function dshScanRootPaths(paths, { projectRoot = null } = {}) {
|
|
88
|
+
const roots = [];
|
|
89
|
+
if (projectRoot) {
|
|
90
|
+
roots.push(path.join(projectRoot, '.dsh', 'skills'));
|
|
91
|
+
roots.push(path.join(projectRoot, '.agents', 'skills'));
|
|
92
|
+
}
|
|
93
|
+
roots.push(paths.dshGlobalSkills);
|
|
94
|
+
roots.push(paths.userAgentsSkills);
|
|
95
|
+
return roots.map((root) => path.resolve(root));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 扫一个根:`<name>/SKILL.md` 目录包,或 `<name>.md` 平铺。
|
|
100
|
+
* 与 `inventory.scanCanonical` 同一套口径,但**不跳软链**——扫描根里可能有别人建的链
|
|
101
|
+
* (历史上的可见性链、或用户手工放的),按目录解析才能如实反映「模型看得见什么」。
|
|
102
|
+
*
|
|
103
|
+
* **技能身份取 frontmatter 的 `name`,不是目录名**:宿主就是这么认的
|
|
104
|
+
* (`@deepseek-ai/dsh-skill-filesystem` 的 `discoverRoot` → `parseSkillFile` →
|
|
105
|
+
* `stringField(parsed.data, 'name')`)。所以 `project-insight.bak-20261008-113007/`
|
|
106
|
+
* 这种目录,在宿主眼里就叫 `project-insight`,会和 canonical 的同名技能抢同一个名字。
|
|
107
|
+
*
|
|
108
|
+
* 与宿主一致地忽略:没有 `SKILL.md`、没有 frontmatter、缺 `name`/`description`、
|
|
109
|
+
* 名字不合法。被忽略的条目放进返回值的 `ignored` 里,供诊断用(不计入可见技能)。
|
|
110
|
+
*/
|
|
111
|
+
export function scanSkillRoot(dir, onError = null) {
|
|
112
|
+
let entries;
|
|
113
|
+
try {
|
|
114
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (onError) onError(error);
|
|
117
|
+
return { entries: [], ignored: [] };
|
|
118
|
+
}
|
|
119
|
+
const out = [];
|
|
120
|
+
const ignored = [];
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
if (entry.name.startsWith('.')) continue;
|
|
123
|
+
const full = path.join(dir, entry.name);
|
|
124
|
+
let isDirectory = entry.isDirectory();
|
|
125
|
+
let linked = false;
|
|
126
|
+
if (entry.isSymbolicLink()) {
|
|
127
|
+
try {
|
|
128
|
+
isDirectory = fs.statSync(full).isDirectory();
|
|
129
|
+
linked = true;
|
|
130
|
+
} catch {
|
|
131
|
+
ignored.push({ name: entry.name, path: full, reason: 'broken-link' });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
let skillFile;
|
|
136
|
+
if (isDirectory) skillFile = path.join(full, 'SKILL.md');
|
|
137
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) skillFile = full;
|
|
138
|
+
else continue;
|
|
139
|
+
|
|
140
|
+
const meta = readFrontmatter(skillFile);
|
|
141
|
+
if (!meta.name) {
|
|
142
|
+
ignored.push({ name: entry.name, path: full, reason: meta.reason || 'no-skill-file' });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
out.push({
|
|
146
|
+
// 身份 = frontmatter name;宿主也按它去重
|
|
147
|
+
name: meta.name,
|
|
148
|
+
dirName: entry.name,
|
|
149
|
+
path: full,
|
|
150
|
+
skillFile,
|
|
151
|
+
kind: isDirectory ? 'dir' : 'file',
|
|
152
|
+
hasSkillFile: true,
|
|
153
|
+
linked,
|
|
154
|
+
description: meta.description || '',
|
|
155
|
+
// 宿主按 frontmatter 决定能不能被模型调用;`disable-model-invocation: true`
|
|
156
|
+
// 的技能(含 旧版的停用桩)在模型目录里就是不可见/不可调用。
|
|
157
|
+
modelInvocable: meta.modelInvocable !== false,
|
|
158
|
+
userInvocable: meta.userInvocable !== false,
|
|
159
|
+
nameMismatch: meta.name !== entry.name.replace(/\.md$/, ''),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
163
|
+
return { entries: out, ignored };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readFrontmatter(skillFile) {
|
|
167
|
+
let text;
|
|
168
|
+
try {
|
|
169
|
+
text = fs.readFileSync(skillFile, 'utf8');
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return { reason: error && error.code === 'ENOENT' ? 'no-skill-file' : 'unreadable' };
|
|
172
|
+
}
|
|
173
|
+
const fields = parseSkillFrontmatter(text);
|
|
174
|
+
if (!fields.name || !fields.description) return { reason: 'incomplete-frontmatter' };
|
|
175
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(fields.name)) return { reason: 'invalid-name' };
|
|
176
|
+
return {
|
|
177
|
+
name: fields.name,
|
|
178
|
+
description: fields.description,
|
|
179
|
+
modelInvocable: fields['disable-model-invocation'] !== true,
|
|
180
|
+
userInvocable: fields['user-invocable'] !== false,
|
|
181
|
+
};
|
|
182
|
+
}
|