dsh-vscode-mode 0.3.1 → 0.3.3

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/src/skills.ts ADDED
@@ -0,0 +1,534 @@
1
+ /**
2
+ * dsh-vscode-mode host — 插件自带技能组(随包 skills/ 分发的 SKILL.md)。
3
+ * - 存储:<包根>/skills/<技能名>/SKILL.md(目录式)或 <包根>/skills/<技能名>.md(扁平式),随包发布。
4
+ * - 命名:技能名必须 kebab-case(DSH 硬约束,下划线会被 registry 拒绝)且以 dsh-vscodemode- 开头。
5
+ * - 生效:注册自研 skill provider 到 ctx.skills(ctx.inject 惰性获取,服务缺失时插件仍完整可用);
6
+ * 文件变更经 fs.watch → control.invalidate() 即时可见。
7
+ * 为什么自研 provider 而不用 @deepseek-ai/dsh-skill-filesystem:见 README「插件技能组」小节。
8
+ * --region 划分:常量 / 类型 / frontmatter 解析(纯)/ 目录扫描(只读)/ provider / 挂载与状态
9
+ * 作者 ddj 2026年09月11号
10
+ */
11
+ import { existsSync, watch } from 'node:fs'
12
+ import { readFile, readdir, stat } from 'node:fs/promises'
13
+ import { dirname, join } from 'node:path'
14
+ import { skillsDirOf } from './paths.js'
15
+ import { log } from './log.js'
16
+ import type { Ctx } from './store.js'
17
+
18
+ // --region 常量
19
+ /** provider 名(注册进 ctx.skills;不得与 'filesystem' / 'openviking' 及保留名 'runtime' 冲突)。 */
20
+ export const SKILL_PROVIDER_NAME = 'dsh-vscodemode'
21
+ /** 技能组前缀白名单:名字不在其中的技能文件被跳过并告警。 */
22
+ export const SKILL_PREFIXES = ['dsh-vscodemode-'] as const
23
+ /** 候选 rank:对齐 dsh-skill-filesystem 的 CUSTOM_RANK(rank 仅在同一层内决定同名胜负)。 */
24
+ export const SKILL_RANK = 300
25
+ /** 单目录最多扫描的条目数(护栏,镜像 rules.ts 的 RULE_DIR_CAP)。 */
26
+ export const SKILL_DIR_CAP = 200
27
+ /** 技能发现来源标签(skill-explorer 归入 "System bundled" 组)。 */
28
+ const SKILL_SOURCE = 'bundled'
29
+ /** 目录式技能的文件名。 */
30
+ const SKILL_FILE = 'SKILL.md'
31
+ /** DSH 技能名语法(与 @deepseek-ai/dsh-skill 的 SKILL_NAME 一致:下划线非法)。 */
32
+ const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
33
+ /** 不受支持的遗留字段 → 规范字段(官方同样拒绝,避免写错键位却静默无效果)。 */
34
+ const LEGACY_SKILL_KEYS: ReadonlyArray<readonly [string, string]> = [
35
+ ['disableModelInvocation', 'disable-model-invocation'],
36
+ ['modelInvocable', 'disable-model-invocation'],
37
+ ['userInvocable', 'user-invocable'],
38
+ ]
39
+ // --endregion
40
+
41
+ // --region 类型(镜像 @deepseek-ai/dsh-skill 的 provider 契约;本地无 dsh 类型声明)
42
+ /** 技能可见性策略:模型侧目录/加载器与人工命令各自独立。 */
43
+ export interface SkillInvocationPolicy {
44
+ /** 是否进入模型可见目录并可由 skill 工具加载。 */
45
+ readonly modelInvocable: boolean
46
+ /** 是否可由用户的显式技能调用加载。 */
47
+ readonly userInvocable: boolean
48
+ }
49
+
50
+ /** 一条技能解析成功的结果。 */
51
+ export interface ParsedSkill {
52
+ name: string
53
+ description: string
54
+ whenToUse?: string
55
+ invocation: SkillInvocationPolicy
56
+ /** frontmatter 之后的正文(已 trim)。 */
57
+ body: string
58
+ }
59
+
60
+ /** 一条技能被拒绝的原因(frontmatter 缺失/非法、名字不合规等)。 */
61
+ export interface SkillParseFailure {
62
+ error: string
63
+ }
64
+
65
+ /** 解析结果:成功或失败(用 isParsedSkill 区分)。 */
66
+ export type SkillParse = ParsedSkill | SkillParseFailure
67
+
68
+ /** provider 返回的候选(registry 据此排序与加载)。 */
69
+ export interface SkillCandidate {
70
+ name: string
71
+ description: string
72
+ whenToUse?: string
73
+ invocation: SkillInvocationPolicy
74
+ source: string
75
+ provider: string
76
+ rank: number
77
+ /** provider 私有句柄,原样回传给 get()。 */
78
+ locator: unknown
79
+ path?: string
80
+ resourceBase?: { kind: 'directory'; path: string }
81
+ }
82
+
83
+ /** 完整技能定义(含正文)。 */
84
+ export interface SkillDefinition extends SkillCandidate {
85
+ content: string
86
+ }
87
+
88
+ /** 注册生命周期与失效通知(registry 借给 provider 的控制面)。 */
89
+ export interface SkillControl {
90
+ /** 精确注册被释放时 abort。 */
91
+ readonly signal: AbortSignal
92
+ /** 通知 registry 重收集目录(编辑 SKILL.md 后即时可见)。 */
93
+ readonly invalidate: () => void
94
+ }
95
+
96
+ /** 本模块实现的 skill provider 面。 */
97
+ export interface SkillProvider {
98
+ readonly name: string
99
+ readonly list: () => Promise<SkillCandidate[]>
100
+ readonly get: (candidate: SkillCandidate) => Promise<SkillDefinition | undefined>
101
+ }
102
+
103
+ /** 技能组装配状态(供兼容性页与日志读取)。 */
104
+ export interface SkillGroupState {
105
+ /** 是否已调度 ctx.inject(services 就绪回调可能尚未执行)。 */
106
+ dispatched: boolean
107
+ /** provider 是否已注册成功。 */
108
+ mounted: boolean
109
+ /** 已发现的技能数(挂载后异步回填)。 */
110
+ count: number
111
+ /** 技能组根目录。 */
112
+ dir: string
113
+ /** 人类可读的状态说明(含降级原因)。 */
114
+ note: string
115
+ }
116
+ // --endregion
117
+
118
+ // --region frontmatter 解析(纯函数)
119
+ /**
120
+ * 去除标量值两侧成对引号。
121
+ * 与 rules.ts 的同名私有工具语义一致;两处解析器面向不同格式(.mdc 规则 / SKILL.md),
122
+ * 各自保持模块自治,避免为一处 4 行字符串处理引入跨模块耦合。
123
+ * @author ddj 2026年09月11号
124
+ * @param raw 原始标量文本
125
+ * @returns 去引号后的文本
126
+ */
127
+ function stripQuotes(raw: string): string {
128
+ const value = raw.trim()
129
+ const quoted =
130
+ value.length >= 2 &&
131
+ ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
132
+ return quoted ? value.slice(1, -1) : value
133
+ }
134
+
135
+ /**
136
+ * 解析 YAML 布尔标量(true/false/yes/no/on/off/1/0,大小写不敏感)。
137
+ * @author ddj 2026年09月11号
138
+ * @param raw 原始标量文本
139
+ * @returns 布尔值;非布尔字面量返回 undefined
140
+ */
141
+ function parseBool(raw: string): boolean | undefined {
142
+ const value = raw.trim().toLowerCase()
143
+ if (value === 'true' || value === 'yes' || value === 'on' || value === '1') return true
144
+ if (value === 'false' || value === 'no' || value === 'off' || value === '0') return false
145
+ return undefined
146
+ }
147
+
148
+ /**
149
+ * 切出 frontmatter 与正文:首行必须是独立的 `---`,其后需有独立的闭合 `---`。
150
+ * 容忍 BOM 与 CRLF;不满足即视为无 frontmatter(调用方按"忽略该文件"处理)。
151
+ * @author ddj 2026年09月11号
152
+ * @param text SKILL.md 全文
153
+ * @returns frontmatter 行与正文;无合法 frontmatter 返回 null
154
+ */
155
+ function splitFrontmatter(text: string): { fields: string[]; body: string } | null {
156
+ const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/)
157
+ if (lines[0] !== '---') return null
158
+ let close = -1
159
+ for (let i = 1; i < lines.length; i++) {
160
+ if (lines[i] === '---') {
161
+ close = i
162
+ break
163
+ }
164
+ }
165
+ if (close < 0) return null
166
+ return { fields: lines.slice(1, close), body: lines.slice(close + 1).join('\n').trim() }
167
+ }
168
+
169
+ /**
170
+ * 读取块标量(`|` / `>` 及其 chomping/缩进指示符后接的缩进行),返回文本与下一个待扫描位置。
171
+ * 折叠式(`>`)行间以空格连接,字面式(`|`)以换行连接;两者末尾均 trim。
172
+ * @author ddj 2026年09月11号
173
+ * @param fields frontmatter 行
174
+ * @param start 起始下标
175
+ * @param folded 是否为折叠式(`>`)
176
+ * @returns 块文本与下一扫描位置
177
+ */
178
+ function readBlock(fields: string[], start: number, folded: boolean): { value: string; next: number } {
179
+ const parts: string[] = []
180
+ let i = start
181
+ while (i < fields.length && (fields[i].trim() === '' || /^[ \t]/.test(fields[i]))) {
182
+ const line = fields[i].trim()
183
+ if (line !== '') parts.push(line)
184
+ i += 1
185
+ }
186
+ return { value: parts.join(folded ? ' ' : '\n').trim(), next: i }
187
+ }
188
+
189
+ /**
190
+ * 把 frontmatter 行解析为键值映射(仅支持本插件用到的标量形式:内联标量与 `|`/`>` 块标量)。
191
+ * @author ddj 2026年09月11号
192
+ * @param fields frontmatter 行
193
+ * @returns 键值映射(无法识别的行跳过)
194
+ */
195
+ function collectFields(fields: string[]): Record<string, string> {
196
+ const out: Record<string, string> = {}
197
+ for (let i = 0; i < fields.length; i++) {
198
+ const match = /^([A-Za-z0-9_-]+)[ \t]*:[ \t]?(.*)$/.exec(fields[i])
199
+ if (!match) continue
200
+ const key = match[1]
201
+ const inline = match[2]
202
+ // YAML 块标量指示符:| 或 >,可带 chomping(+/-)与显式缩进数字
203
+ const block = /^([|>])[+-]?\d*$/.exec(inline.trim())
204
+ if (block === null) {
205
+ out[key] = stripQuotes(inline)
206
+ continue
207
+ }
208
+ const read = readBlock(fields, i + 1, block[1] === '>')
209
+ out[key] = read.value
210
+ i = read.next - 1
211
+ }
212
+ return out
213
+ }
214
+
215
+ /**
216
+ * 由 frontmatter 推导可见性策略(缺省两者皆 true;值为非布尔字面量时报错)。
217
+ * @author ddj 2026年09月11号
218
+ * @param data frontmatter 键值映射
219
+ * @returns 策略与可选错误文案
220
+ */
221
+ function toInvocation(data: Record<string, string>): { policy: SkillInvocationPolicy; error?: string } {
222
+ const defaults: SkillInvocationPolicy = { modelInvocable: true, userInvocable: true }
223
+ const disabled = parseBool(data['disable-model-invocation'] ?? '')
224
+ if (disabled === undefined && data['disable-model-invocation'] !== undefined) {
225
+ return { policy: defaults, error: 'frontmatter 字段 "disable-model-invocation" 必须是布尔值' }
226
+ }
227
+ const user = parseBool(data['user-invocable'] ?? '')
228
+ if (user === undefined && data['user-invocable'] !== undefined) {
229
+ return { policy: defaults, error: 'frontmatter 字段 "user-invocable" 必须是布尔值' }
230
+ }
231
+ return { policy: { modelInvocable: disabled !== true, userInvocable: user !== false } }
232
+ }
233
+
234
+ /**
235
+ * 判断解析结果是否为成功形态。
236
+ * @author ddj 2026年09月11号
237
+ * @param value 解析结果
238
+ * @returns 是否为 ParsedSkill
239
+ */
240
+ export function isParsedSkill(value: SkillParse): value is ParsedSkill {
241
+ return !('error' in value)
242
+ }
243
+
244
+ /**
245
+ * 解析一条 SKILL.md(纯函数,永不抛错)。
246
+ * @author ddj 2026年09月11号
247
+ * @param text SKILL.md 全文
248
+ * @returns 解析结果(成功含正文与元数据;失败含拒绝原因)
249
+ */
250
+ export function parseSkillMd(text: string): SkillParse {
251
+ const parts = splitFrontmatter(text)
252
+ if (parts === null) return { error: '缺少合法 frontmatter(首行须为 --- 且存在闭合 ---)' }
253
+ const data = collectFields(parts.fields)
254
+ const legacy = LEGACY_SKILL_KEYS.find(([key]) => data[key] !== undefined)
255
+ if (legacy !== undefined) {
256
+ return { error: 'frontmatter 字段 "' + legacy[0] + '" 不受支持,请改用 "' + legacy[1] + '"' }
257
+ }
258
+ const name = (data.name ?? '').trim()
259
+ if (!SKILL_NAME_RE.test(name)) {
260
+ return { error: '非法技能名 "' + name + '"(须 kebab-case:小写字母/数字,段间连字符;下划线非法)' }
261
+ }
262
+ const description = (data.description ?? '').trim()
263
+ if (!description) return { error: '技能 "' + name + '" 缺少 description' }
264
+ const invocation = toInvocation(data)
265
+ if (invocation.error !== undefined) return { error: invocation.error }
266
+ const whenToUse = (data.whenToUse ?? '').trim()
267
+ return {
268
+ name,
269
+ description,
270
+ ...(whenToUse ? { whenToUse } : {}),
271
+ invocation: invocation.policy,
272
+ body: parts.body,
273
+ }
274
+ }
275
+ // --endregion
276
+
277
+ // --region 目录扫描(只读,失败不抛)
278
+ /**
279
+ * 判断技能名是否落在技能组前缀白名单内。
280
+ * @author ddj 2026年09月11号
281
+ * @param name 技能名
282
+ * @returns 是否命中前缀
283
+ */
284
+ export function hasGroupPrefix(name: string): boolean {
285
+ return SKILL_PREFIXES.some((prefix) => name.startsWith(prefix))
286
+ }
287
+
288
+ /**
289
+ * 目录条目 → 候选文件路径(目录式取 <名>/SKILL.md;扁平式取 <名>.md;其余跳过)。
290
+ * @author ddj 2026年09月11号
291
+ * @param dir 技能组根目录
292
+ * @param entry 目录条目
293
+ * @returns 候选文件绝对路径;不构成技能时返回 undefined
294
+ */
295
+ function skillFileOf(dir: string, entry: { name: string; isDirectory(): boolean; isFile(): boolean }): string | undefined {
296
+ if (entry.isDirectory()) return join(dir, entry.name, SKILL_FILE)
297
+ if (entry.isFile() && entry.name.endsWith('.md')) return join(dir, entry.name)
298
+ return undefined
299
+ }
300
+
301
+ /**
302
+ * 由已解析的技能构造候选(provider 名/rank/source/resourceBase 按 registry 契约填充)。
303
+ * @author ddj 2026年09月11号
304
+ * @param parsed 解析成功的技能
305
+ * @param file SKILL.md 绝对路径
306
+ * @returns registry 候选
307
+ */
308
+ function candidateFrom(parsed: ParsedSkill, file: string): SkillCandidate {
309
+ return {
310
+ name: parsed.name,
311
+ description: parsed.description,
312
+ ...(parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}),
313
+ invocation: parsed.invocation,
314
+ source: SKILL_SOURCE,
315
+ provider: SKILL_PROVIDER_NAME,
316
+ rank: SKILL_RANK,
317
+ locator: { path: file },
318
+ path: file,
319
+ resourceBase: { kind: 'directory', path: dirname(file) },
320
+ }
321
+ }
322
+
323
+ /**
324
+ * 读盘并校验单个技能文件(缺失/读失败/解析失败/非本组前缀 → undefined + 告警)。
325
+ * @author ddj 2026年09月11号
326
+ * @param file SKILL.md 绝对路径
327
+ * @returns registry 候选;不可用时 undefined
328
+ */
329
+ async function candidateOf(file: string): Promise<SkillCandidate | undefined> {
330
+ const info = await stat(file).catch(() => undefined)
331
+ if (info === undefined || !info.isFile()) return undefined
332
+ const text = await readFile(file, 'utf8').catch(() => undefined)
333
+ if (text === undefined) return undefined
334
+ const parsed = parseSkillMd(text)
335
+ if (!isParsedSkill(parsed)) {
336
+ log.warn('技能已忽略(' + file + '):' + parsed.error)
337
+ return undefined
338
+ }
339
+ if (!hasGroupPrefix(parsed.name)) {
340
+ log.warn('技能已忽略(' + file + '):名字 "' + parsed.name + '" 不在技能组前缀 ' + SKILL_PREFIXES.join('/') + ' 内')
341
+ return undefined
342
+ }
343
+ return candidateFrom(parsed, file)
344
+ }
345
+
346
+ /**
347
+ * 扫描技能组目录(目录缺失/读取失败 → 空数组,不抛)。
348
+ * @author ddj 2026年09月11号
349
+ * @param dir 技能组根目录
350
+ * @returns 通过校验的候选(按名字排序,受 SKILL_DIR_CAP 约束)
351
+ */
352
+ export async function listSkills(dir: string): Promise<SkillCandidate[]> {
353
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
354
+ const ordered = [...entries].sort((a, b) => a.name.localeCompare(b.name)).slice(0, SKILL_DIR_CAP)
355
+ const found: SkillCandidate[] = []
356
+ for (const entry of ordered) {
357
+ const file = skillFileOf(dir, entry)
358
+ if (file === undefined) continue
359
+ const candidate = await candidateOf(file)
360
+ if (candidate !== undefined) found.push(candidate)
361
+ }
362
+ return found
363
+ }
364
+
365
+ /**
366
+ * 解析候选的落盘路径(locator 优先,回退 candidate.path)。
367
+ * @author ddj 2026年09月11号
368
+ * @param candidate registry 候选
369
+ * @returns 绝对路径;不可解析时 undefined
370
+ */
371
+ function locatorPath(candidate: SkillCandidate): string | undefined {
372
+ const locator = candidate.locator as { path?: unknown } | undefined
373
+ if (locator !== undefined && typeof locator.path === 'string') return locator.path
374
+ return typeof candidate.path === 'string' ? candidate.path : undefined
375
+ }
376
+
377
+ /**
378
+ * 加载候选的完整技能定义(重读盘;文件消失或名字变化 → undefined,让 registry 自行失效缓存)。
379
+ * @author ddj 2026年09月11号
380
+ * @param candidate registry 候选
381
+ * @returns 完整定义;不可用时 undefined
382
+ */
383
+ async function loadSkill(candidate: SkillCandidate): Promise<SkillDefinition | undefined> {
384
+ const file = locatorPath(candidate)
385
+ if (file === undefined) return undefined
386
+ const text = await readFile(file, 'utf8').catch(() => undefined)
387
+ if (text === undefined) return undefined
388
+ const parsed = parseSkillMd(text)
389
+ if (!isParsedSkill(parsed) || parsed.name !== candidate.name) return undefined
390
+ return { ...candidateFrom(parsed, file), content: parsed.body }
391
+ }
392
+ // --endregion
393
+
394
+ // --region provider 与监听
395
+ /**
396
+ * 关闭 watcher(注册被释放时的收尾;失败不影响装配)。
397
+ * @author ddj 2026年09月11号
398
+ * @param watcher 文件监听器
399
+ */
400
+ function closeWatcher(watcher: { close: () => unknown }): void {
401
+ try {
402
+ watcher.close()
403
+ } catch (error) {
404
+ /* 关闭失败不影响装配 */
405
+ }
406
+ }
407
+
408
+ /**
409
+ * 监听技能目录:变更 → control.invalidate()(registry 有收集缓存,必须失效才即时可见)。
410
+ * 注册被释放时经 control.signal 关闭;监听不可用仅降级为"改动需重载插件",不影响正确性。
411
+ * @author ddj 2026年09月11号
412
+ * @param dir 技能组根目录
413
+ * @param control registry 借出的控制面
414
+ */
415
+ function watchSkillDir(dir: string, control: SkillControl): void {
416
+ if (!existsSync(dir)) return
417
+ try {
418
+ const watcher = watch(dir, { recursive: true, persistent: false }, () => control.invalidate())
419
+ // EventEmitter 的 'error' 无监听者时会抛出未捕获异常,必须挂处理器
420
+ watcher.on('error', (error) => log.warn('技能目录监听中断(改动需重载插件生效):' + String(error)))
421
+ control.signal.addEventListener('abort', () => closeWatcher(watcher), { once: true })
422
+ } catch (error) {
423
+ log.warn('技能目录监听不可用(改动需重载插件生效):' + String(error))
424
+ }
425
+ }
426
+
427
+ /**
428
+ * 创建技能组 provider(注册进 ctx.skills)。
429
+ * @author ddj 2026年09月11号
430
+ * @param dir 技能组根目录
431
+ * @param control registry 借出的控制面
432
+ * @returns provider 实例
433
+ */
434
+ export function newSkillProvider(dir: string, control: SkillControl): SkillProvider {
435
+ watchSkillDir(dir, control)
436
+ return {
437
+ name: SKILL_PROVIDER_NAME,
438
+ list: () => listSkills(dir),
439
+ get: (candidate) => loadSkill(candidate),
440
+ }
441
+ }
442
+ // --endregion
443
+
444
+ // --region 挂载与状态
445
+ /** 最近一次装配状态(兼容性页与启动日志读取;模块级单例,热重载后由新装配覆写)。 */
446
+ let group: SkillGroupState = { dispatched: false, mounted: false, count: 0, dir: '', note: '未装配' }
447
+
448
+ /**
449
+ * 读取技能组装配状态(副本,调用方不可改写内部状态)。
450
+ * @author ddj 2026年09月11号
451
+ * @returns 状态快照
452
+ */
453
+ export function skillGroupState(): SkillGroupState {
454
+ return { ...group }
455
+ }
456
+
457
+ /**
458
+ * 复位装配状态(测试隔离用)。
459
+ * @author ddj 2026年09月11号
460
+ */
461
+ export function resetSkillGroup(): void {
462
+ group = { dispatched: false, mounted: false, count: 0, dir: '', note: '未装配' }
463
+ }
464
+
465
+ /**
466
+ * 记录状态片段。
467
+ * @author ddj 2026年09月11号
468
+ * @param patch 待覆写字段
469
+ */
470
+ function recordGroup(patch: Partial<SkillGroupState>): void {
471
+ group = { ...group, ...patch }
472
+ }
473
+
474
+ /**
475
+ * 在 skills 就绪回调里注册 provider,并异步回填技能数。
476
+ * @author ddj 2026年09月11号
477
+ * @param sctx inject 回调给出的服务上下文
478
+ * @param dir 技能组根目录
479
+ */
480
+ function mountGroup(sctx: unknown, dir: string): void {
481
+ const sc = sctx as { get?: (name: string) => unknown; skills?: unknown } | undefined
482
+ const skills = (typeof sc?.get === 'function' ? sc.get('skills') : undefined) ?? sc?.skills
483
+ const register = (skills as { registerProvider?: unknown } | undefined)?.registerProvider
484
+ if (typeof register !== 'function') {
485
+ recordGroup({ mounted: false, note: 'skills 服务不可用或版本不含 registerProvider' })
486
+ log.warn('技能组未挂载:skills 服务不可用,插件其余功能不受影响')
487
+ return
488
+ }
489
+ try {
490
+ ;(register as (create: (control: SkillControl) => SkillProvider) => unknown).call(skills, (control) =>
491
+ newSkillProvider(dir, control),
492
+ )
493
+ recordGroup({ mounted: true, dir, note: '已挂载(等待技能扫描)' })
494
+ } catch (error) {
495
+ recordGroup({ mounted: false, note: 'provider 注册失败:' + String(error) })
496
+ log.warn('技能组未挂载:provider 注册失败(' + String(error) + ')')
497
+ return
498
+ }
499
+ void listSkills(dir)
500
+ .then((found) => {
501
+ recordGroup({ count: found.length, note: '已挂载 ' + found.length + ' 个技能' })
502
+ log.info('插件技能组已挂载:' + SKILL_PREFIXES[0] + '* 共 ' + found.length + ' 个技能(' + dir + ')')
503
+ })
504
+ .catch((error) => log.warn('技能组扫描失败:' + String(error)))
505
+ }
506
+
507
+ /**
508
+ * 装配插件技能组(惰性获取 skills 服务;服务缺失/版本过旧时降级记录,不抛错)。
509
+ * 返回值只表示"是否已调度"——inject 回调异步执行,实际结果见 skillGroupState()。
510
+ * @author ddj 2026年09月11号
511
+ * @param ctx DSH host 上下文
512
+ * @param dir 技能组根目录(缺省 import.meta.url 派生;测试注入)
513
+ * @returns 是否已调度挂载
514
+ */
515
+ export function installSkillGroup(ctx: Ctx, dir: string = skillsDirOf(import.meta.url)): boolean {
516
+ const inject = (ctx as { inject?: unknown } | undefined)?.inject
517
+ if (typeof inject !== 'function') {
518
+ recordGroup({ dispatched: false, mounted: false, dir, note: 'ctx.inject 不可用(DSH 版本过旧),技能组未挂载' })
519
+ log.warn('技能组未挂载:DSH 未提供 ctx.inject')
520
+ return false
521
+ }
522
+ recordGroup({ dispatched: true, mounted: false, dir, note: '已调度 skills 服务装配(等待 skills 就绪)' })
523
+ try {
524
+ ;(inject as (services: string[], callback: (sctx: unknown) => void) => unknown).call(ctx, ['skills'], (sctx) =>
525
+ mountGroup(sctx, dir),
526
+ )
527
+ } catch (error) {
528
+ recordGroup({ dispatched: false, mounted: false, note: '挂载调度失败:' + String(error) })
529
+ log.warn('技能组挂载调度失败:' + String(error))
530
+ return false
531
+ }
532
+ return true
533
+ }
534
+ // --endregion