skill-auto-loader-hook-paperfly777 0.1.0 → 0.1.2

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 CHANGED
@@ -55,14 +55,7 @@ openclaw plugins install skill-auto-loader-hook-paperfly777
55
55
 
56
56
  ## 发布方式
57
57
 
58
- 发布前先在插件目录执行:
59
-
60
- ```bash
61
- npm install
62
- npm run build
63
- ```
64
-
65
- 然后登录 npm 并发布:
58
+ 发布前在插件目录执行:
66
59
 
67
60
  ```bash
68
61
  npm login
@@ -72,13 +65,13 @@ npm publish --access public
72
65
  发布成功后,就可以用:
73
66
 
74
67
  ```bash
75
- openclaw plugins install @applesay/skill-auto-loader-hook
68
+ openclaw plugins install skill-auto-loader-hook-paperfly777
76
69
  ```
77
70
 
78
71
  说明:
79
72
 
80
73
  1. 当前包名我先给你定成了 `skill-auto-loader-hook-paperfly777`,如果你后面有自己的 npm scope,再改 `package.json` 即可。
81
- 2. `openclaw.plugin.json` 已经指向 `dist/index.js`,所以发布前必须先构建。
74
+ 2. 该插件按 OpenClaw 官方方式,通过 `package.json -> openclaw.extensions -> index.ts` 暴露入口。
82
75
 
83
76
  ## 配置方式
84
77
 
package/index.ts ADDED
@@ -0,0 +1,457 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { definePluginEntry } from "openclaw/plugin-sdk/core";
5
+
6
+ const PLUGIN_ID = "skill-auto-loader-hook";
7
+
8
+ type RoutingRule = {
9
+ id: string;
10
+ enabled?: boolean;
11
+ description?: string;
12
+ keywords?: string[];
13
+ allKeywords?: string[];
14
+ regexes?: string[];
15
+ preferSkills?: string[];
16
+ excludeSkills?: string[];
17
+ scopeNote?: string;
18
+ prompt?: string;
19
+ };
20
+
21
+ type RouterFileConfig = {
22
+ enabled?: boolean;
23
+ skillsScanDirs?: string[];
24
+ includeSkills?: string[];
25
+ excludeSkills?: string[];
26
+ maxSkillsInPrompt?: number;
27
+ defaultScopeNote?: string;
28
+ rules?: RoutingRule[];
29
+ };
30
+
31
+ type PluginConfig = {
32
+ enabled?: boolean;
33
+ injectMode?: "prependContext" | "prependSystemContext" | "appendSystemContext";
34
+ routerConfigPath?: string;
35
+ openclawConfigPath?: string;
36
+ };
37
+
38
+ type SkillMeta = {
39
+ name: string;
40
+ description: string;
41
+ skillPath: string;
42
+ };
43
+
44
+ function safeString(value: unknown): string {
45
+ return typeof value === "string" ? value : "";
46
+ }
47
+
48
+ function expandHome(inputPath: string): string {
49
+ if (!inputPath) {
50
+ return inputPath;
51
+ }
52
+ if (inputPath.startsWith("~/")) {
53
+ return path.join(os.homedir(), inputPath.slice(2));
54
+ }
55
+ return inputPath;
56
+ }
57
+
58
+ function resolvePath(inputPath: string, baseDir: string): string {
59
+ const expanded = expandHome(inputPath);
60
+ if (!expanded) {
61
+ return expanded;
62
+ }
63
+ if (path.isAbsolute(expanded)) {
64
+ return expanded;
65
+ }
66
+ return path.resolve(baseDir, expanded);
67
+ }
68
+
69
+ function readJsonFile(filePath: string): any {
70
+ try {
71
+ const raw = fs.readFileSync(filePath, "utf8");
72
+ return JSON.parse(raw);
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function extractTextFromContent(content: unknown): string {
79
+ if (typeof content === "string") {
80
+ return content;
81
+ }
82
+ if (Array.isArray(content)) {
83
+ return content
84
+ .map((item) => {
85
+ if (typeof item === "string") {
86
+ return item;
87
+ }
88
+ if (item && typeof item === "object") {
89
+ const obj = item as Record<string, unknown>;
90
+ if (typeof obj.text === "string") {
91
+ return obj.text;
92
+ }
93
+ if (typeof obj.input === "string") {
94
+ return obj.input;
95
+ }
96
+ }
97
+ return "";
98
+ })
99
+ .filter(Boolean)
100
+ .join("\n");
101
+ }
102
+ return "";
103
+ }
104
+
105
+ function extractLatestUserText(event: unknown): string {
106
+ if (!event || typeof event !== "object") {
107
+ return "";
108
+ }
109
+
110
+ const eventObj = event as Record<string, unknown>;
111
+ const messages = Array.isArray(eventObj.messages) ? eventObj.messages : [];
112
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
113
+ const msg = messages[i];
114
+ if (!msg || typeof msg !== "object") {
115
+ continue;
116
+ }
117
+ const msgObj = msg as Record<string, unknown>;
118
+ if (safeString(msgObj.role) !== "user") {
119
+ continue;
120
+ }
121
+ const content = extractTextFromContent(msgObj.content);
122
+ if (content.trim()) {
123
+ return content.trim();
124
+ }
125
+ }
126
+
127
+ const fallbackPrompt = safeString(eventObj.userPrompt);
128
+ return fallbackPrompt.trim();
129
+ }
130
+
131
+ function normalizePluginConfig(api: any): PluginConfig {
132
+ const pluginCfg =
133
+ api?.config?.plugins?.entries?.[PLUGIN_ID]?.config ??
134
+ api?.config?.plugins?.entries?.[PLUGIN_ID] ??
135
+ {};
136
+
137
+ return {
138
+ enabled: pluginCfg.enabled !== false,
139
+ injectMode: pluginCfg.injectMode || "prependContext",
140
+ routerConfigPath:
141
+ safeString(pluginCfg.routerConfigPath) ||
142
+ path.join(__dirname, "skill-router.config.json"),
143
+ openclawConfigPath:
144
+ safeString(pluginCfg.openclawConfigPath) ||
145
+ path.join(os.homedir(), ".openclaw", "openclaw.json"),
146
+ };
147
+ }
148
+
149
+ function readRouterConfig(configPath: string, baseDir: string): RouterFileConfig {
150
+ const config = readJsonFile(resolvePath(configPath, baseDir));
151
+ if (!config || typeof config !== "object") {
152
+ return {};
153
+ }
154
+ return config as RouterFileConfig;
155
+ }
156
+
157
+ function getDefaultModelInfo(openclawConfigPath: string): string {
158
+ const cfg = readJsonFile(expandHome(openclawConfigPath));
159
+ if (!cfg || typeof cfg !== "object") {
160
+ return "未读取到 OpenClaw 默认模型配置";
161
+ }
162
+
163
+ const root = cfg as Record<string, unknown>;
164
+ const directModel = safeString(root.model);
165
+ const channelsModel = safeString((root.channels as Record<string, unknown> | undefined)?.model);
166
+
167
+ if (directModel) {
168
+ return directModel;
169
+ }
170
+ if (channelsModel) {
171
+ return channelsModel;
172
+ }
173
+ return "openclaw.json 中未显式配置默认模型";
174
+ }
175
+
176
+ function parseYamlLikeFrontmatter(raw: string): { name: string; description: string } {
177
+ const result = { name: "", description: "" };
178
+ if (!raw.startsWith("---")) {
179
+ return result;
180
+ }
181
+
182
+ const endIndex = raw.indexOf("\n---", 3);
183
+ if (endIndex === -1) {
184
+ return result;
185
+ }
186
+
187
+ const frontmatter = raw.slice(3, endIndex).split(/\r?\n/);
188
+ let inDescription = false;
189
+ const descriptionLines: string[] = [];
190
+
191
+ for (const line of frontmatter) {
192
+ if (line.startsWith("name:")) {
193
+ result.name = line.slice(5).trim();
194
+ inDescription = false;
195
+ continue;
196
+ }
197
+ if (line.startsWith("description:")) {
198
+ const remainder = line.slice("description:".length).trim();
199
+ inDescription = true;
200
+ if (remainder && remainder !== "|") {
201
+ descriptionLines.push(remainder);
202
+ inDescription = false;
203
+ }
204
+ continue;
205
+ }
206
+ if (inDescription) {
207
+ if (/^\S/.test(line)) {
208
+ inDescription = false;
209
+ } else {
210
+ descriptionLines.push(line.trim());
211
+ }
212
+ }
213
+ }
214
+
215
+ result.description = descriptionLines.join(" ").trim();
216
+ return result;
217
+ }
218
+
219
+ function extractHeadingFallback(raw: string): string {
220
+ const lines = raw.split(/\r?\n/);
221
+ for (const line of lines) {
222
+ if (line.startsWith("# ")) {
223
+ return line.slice(2).trim();
224
+ }
225
+ }
226
+ return "";
227
+ }
228
+
229
+ function extractSkillMeta(skillDir: string): SkillMeta | null {
230
+ const skillMdPath = path.join(skillDir, "SKILL.md");
231
+ if (!fs.existsSync(skillMdPath)) {
232
+ return null;
233
+ }
234
+
235
+ const raw = fs.readFileSync(skillMdPath, "utf8");
236
+ const fm = parseYamlLikeFrontmatter(raw);
237
+ const name = fm.name || path.basename(skillDir);
238
+ const description = fm.description || extractHeadingFallback(raw) || "无描述";
239
+
240
+ return {
241
+ name,
242
+ description,
243
+ skillPath: skillDir,
244
+ };
245
+ }
246
+
247
+ function scanInstalledSkills(scanDirs: string[]): SkillMeta[] {
248
+ const results: SkillMeta[] = [];
249
+ const visited = new Set<string>();
250
+
251
+ for (const dir of scanDirs) {
252
+ const resolved = expandHome(dir);
253
+ if (!resolved || !fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
254
+ continue;
255
+ }
256
+
257
+ const entries = fs.readdirSync(resolved, { withFileTypes: true });
258
+ for (const entry of entries) {
259
+ if (!entry.isDirectory()) {
260
+ continue;
261
+ }
262
+ const fullPath = path.join(resolved, entry.name);
263
+ if (visited.has(fullPath)) {
264
+ continue;
265
+ }
266
+ const meta = extractSkillMeta(fullPath);
267
+ if (meta) {
268
+ visited.add(fullPath);
269
+ results.push(meta);
270
+ }
271
+ }
272
+ }
273
+
274
+ return results.sort((a, b) => a.name.localeCompare(b.name));
275
+ }
276
+
277
+ function matchesRule(text: string, rule: RoutingRule): boolean {
278
+ const normalized = text.toLowerCase();
279
+ const keywords = Array.isArray(rule.keywords) ? rule.keywords : [];
280
+ const allKeywords = Array.isArray(rule.allKeywords) ? rule.allKeywords : [];
281
+ const regexes = Array.isArray(rule.regexes) ? rule.regexes : [];
282
+
283
+ const anyKeywordMatched =
284
+ keywords.length === 0 ||
285
+ keywords.some((keyword) => normalized.includes(keyword.toLowerCase()));
286
+
287
+ const allKeywordMatched =
288
+ allKeywords.length === 0 ||
289
+ allKeywords.every((keyword) => normalized.includes(keyword.toLowerCase()));
290
+
291
+ const regexMatched =
292
+ regexes.length === 0 ||
293
+ regexes.some((pattern) => {
294
+ try {
295
+ return new RegExp(pattern, "i").test(text);
296
+ } catch {
297
+ return false;
298
+ }
299
+ });
300
+
301
+ const hasMatcher = keywords.length > 0 || allKeywords.length > 0 || regexes.length > 0;
302
+ return hasMatcher && anyKeywordMatched && allKeywordMatched && regexMatched;
303
+ }
304
+
305
+ function applyRuleFiltering(skills: SkillMeta[], routerCfg: RouterFileConfig, matchedRules: RoutingRule[]): SkillMeta[] {
306
+ let filtered = [...skills];
307
+
308
+ const includeSkills = new Set((routerCfg.includeSkills || []).filter(Boolean));
309
+ const excludeSkills = new Set((routerCfg.excludeSkills || []).filter(Boolean));
310
+
311
+ if (includeSkills.size > 0) {
312
+ filtered = filtered.filter((skill) => includeSkills.has(skill.name));
313
+ }
314
+ if (excludeSkills.size > 0) {
315
+ filtered = filtered.filter((skill) => !excludeSkills.has(skill.name));
316
+ }
317
+
318
+ const preferred = new Set<string>();
319
+ const excludedByRule = new Set<string>();
320
+ for (const rule of matchedRules) {
321
+ (rule.preferSkills || []).forEach((name) => preferred.add(name));
322
+ (rule.excludeSkills || []).forEach((name) => excludedByRule.add(name));
323
+ }
324
+
325
+ filtered = filtered.filter((skill) => !excludedByRule.has(skill.name));
326
+
327
+ if (preferred.size > 0) {
328
+ const preferredSkills = filtered.filter((skill) => preferred.has(skill.name));
329
+ const remainingSkills = filtered.filter((skill) => !preferred.has(skill.name));
330
+ filtered = [...preferredSkills, ...remainingSkills];
331
+ }
332
+
333
+ const maxSkills = routerCfg.maxSkillsInPrompt && routerCfg.maxSkillsInPrompt > 0 ? routerCfg.maxSkillsInPrompt : 20;
334
+ return filtered.slice(0, maxSkills);
335
+ }
336
+
337
+ function renderSkillList(skills: SkillMeta[]): string {
338
+ if (skills.length === 0) {
339
+ return "当前未扫描到可用 skill。";
340
+ }
341
+ return skills
342
+ .map((skill, index) => `${index + 1}. ${skill.name}\n说明:${skill.description}\n路径:${skill.skillPath}`)
343
+ .join("\n\n");
344
+ }
345
+
346
+ function renderRuleHints(matchedRules: RoutingRule[], routerCfg: RouterFileConfig): string {
347
+ if (matchedRules.length === 0) {
348
+ return routerCfg.defaultScopeNote ? `默认范围提示:${routerCfg.defaultScopeNote}` : "未命中任何自定义规则。";
349
+ }
350
+
351
+ return matchedRules
352
+ .map((rule, index) => {
353
+ const lines: string[] = [];
354
+ lines.push(`${index + 1}. 规则ID:${rule.id}`);
355
+ if (rule.description) {
356
+ lines.push(`描述:${rule.description}`);
357
+ }
358
+ if ((rule.preferSkills || []).length > 0) {
359
+ lines.push(`优先技能:${rule.preferSkills!.join("、")}`);
360
+ }
361
+ if ((rule.excludeSkills || []).length > 0) {
362
+ lines.push(`排除技能:${rule.excludeSkills!.join("、")}`);
363
+ }
364
+ if (rule.scopeNote) {
365
+ lines.push(`适用范围:${rule.scopeNote}`);
366
+ }
367
+ if (rule.prompt) {
368
+ lines.push(`提示:${rule.prompt}`);
369
+ }
370
+ return lines.join("\n");
371
+ })
372
+ .join("\n\n");
373
+ }
374
+
375
+ function buildRoutingPrompt(
376
+ userText: string,
377
+ defaultModel: string,
378
+ scannedSkills: SkillMeta[],
379
+ matchedRules: RoutingRule[],
380
+ routerCfg: RouterFileConfig,
381
+ ): string {
382
+ const lines: string[] = [];
383
+ lines.push("【Skill Auto Loader Hook】请在正式回答前,先完成本轮 skill 路由判断。");
384
+ lines.push(`当前 OpenClaw 默认模型:${defaultModel}`);
385
+ lines.push(`本轮用户消息:${userText}`);
386
+ lines.push("");
387
+ lines.push("请结合以下信息判断本轮是否需要优先使用某个 skill。");
388
+ lines.push("");
389
+ lines.push("一、当前可用 skill 列表");
390
+ lines.push(renderSkillList(scannedSkills));
391
+ lines.push("");
392
+ lines.push("二、本轮命中的自定义规则");
393
+ lines.push(renderRuleHints(matchedRules, routerCfg));
394
+ lines.push("");
395
+ lines.push("三、判断要求");
396
+ lines.push("1. 先判断本轮是否明显需要 skill。");
397
+ lines.push("2. 如果需要,只选择最相关的 1-3 个 skill 作为优先候选。");
398
+ lines.push("3. 若没有合适 skill,就按常规对话处理,不要强行套 skill。");
399
+ lines.push("4. 不要使用被规则排除的 skill。");
400
+ lines.push("5. 你的判断依据必须来自:用户消息、skill 描述、自定义 scope。");
401
+ lines.push("");
402
+ lines.push("四、执行方式");
403
+ lines.push("请把路由判断体现在你后续的实际执行中:若需要 skill,则优先按判断结果使用;若不需要,则正常回答。");
404
+ return lines.join("\n");
405
+ }
406
+
407
+ export default definePluginEntry({
408
+ id: PLUGIN_ID,
409
+ name: "Skill Auto Loader Hook",
410
+ description: "Intercept each user turn, discover installed skills, and inject routing guidance before prompt build.",
411
+ register(api: any) {
412
+ api.logger?.info?.("skill-auto-loader-hook: register()");
413
+ api.on(
414
+ "before_prompt_build",
415
+ (event: unknown) => {
416
+ const cfg = normalizePluginConfig(api);
417
+ if (!cfg.enabled) {
418
+ return undefined;
419
+ }
420
+
421
+ const routerCfg = readRouterConfig(cfg.routerConfigPath!, __dirname);
422
+ if (routerCfg.enabled === false) {
423
+ return undefined;
424
+ }
425
+
426
+ const userText = extractLatestUserText(event);
427
+ if (!userText) {
428
+ return undefined;
429
+ }
430
+
431
+ const scanDirs =
432
+ Array.isArray(routerCfg.skillsScanDirs) && routerCfg.skillsScanDirs.length > 0
433
+ ? routerCfg.skillsScanDirs.map((dir) => resolvePath(dir, __dirname))
434
+ : [path.join(process.cwd(), "skills"), path.join(process.cwd(), "openclaw-skills", "skills")];
435
+
436
+ const allSkills = scanInstalledSkills(scanDirs);
437
+ const matchedRules = (routerCfg.rules || []).filter((rule) => rule.enabled !== false && matchesRule(userText, rule));
438
+ const candidateSkills = applyRuleFiltering(allSkills, routerCfg, matchedRules);
439
+ const defaultModel = getDefaultModelInfo(cfg.openclawConfigPath!);
440
+ const payload = buildRoutingPrompt(userText, defaultModel, candidateSkills, matchedRules, routerCfg);
441
+
442
+ api.logger?.debug?.(
443
+ `skill-auto-loader-hook: before_prompt_build matchedRules=${matchedRules.length} candidateSkills=${candidateSkills.length}`,
444
+ );
445
+
446
+ if (cfg.injectMode === "prependSystemContext") {
447
+ return { prependSystemContext: payload };
448
+ }
449
+ if (cfg.injectMode === "appendSystemContext") {
450
+ return { appendSystemContext: payload };
451
+ }
452
+ return { prependContext: payload };
453
+ },
454
+ { priority: 50 },
455
+ );
456
+ },
457
+ });
@@ -2,8 +2,7 @@
2
2
  "id": "skill-auto-loader-hook",
3
3
  "name": "Skill Auto Loader Hook",
4
4
  "description": "Intercept each user turn, match custom routing rules, and inject skill selection guidance into prompt build.",
5
- "version": "0.1.0",
6
- "entry": "./dist/index.js",
5
+ "version": "0.1.2",
7
6
  "configSchema": {
8
7
  "type": "object",
9
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,27 +1,24 @@
1
1
  {
2
2
  "name": "skill-auto-loader-hook-paperfly777",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "OpenClaw hook plugin that routes each user message to the most relevant installed skill.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "main": "dist/index.js",
7
+ "main": "index.ts",
8
+ "openclaw": {
9
+ "extensions": [
10
+ "./index.ts"
11
+ ]
12
+ },
8
13
  "files": [
9
- "dist",
14
+ "index.ts",
10
15
  "openclaw.plugin.json",
11
16
  "skill-router.config.json",
12
17
  "README.md"
13
18
  ],
14
- "scripts": {
15
- "build": "tsc -p tsconfig.json",
16
- "prepublishOnly": "npm run build"
17
- },
18
19
  "engines": {
19
20
  "node": ">=18"
20
21
  },
21
- "devDependencies": {
22
- "@types/node": "^22.15.21",
23
- "typescript": "^5.8.3"
24
- },
25
22
  "keywords": [
26
23
  "openclaw",
27
24
  "plugin",
package/dist/index.js DELETED
@@ -1,346 +0,0 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
- const PLUGIN_ID = "skill-auto-loader-hook";
5
- function safeString(value) {
6
- return typeof value === "string" ? value : "";
7
- }
8
- function expandHome(inputPath) {
9
- if (!inputPath) {
10
- return inputPath;
11
- }
12
- if (inputPath.startsWith("~/")) {
13
- return path.join(os.homedir(), inputPath.slice(2));
14
- }
15
- return inputPath;
16
- }
17
- function resolvePath(inputPath, baseDir) {
18
- const expanded = expandHome(inputPath);
19
- if (!expanded) {
20
- return expanded;
21
- }
22
- if (path.isAbsolute(expanded)) {
23
- return expanded;
24
- }
25
- return path.resolve(baseDir, expanded);
26
- }
27
- function readJsonFile(filePath) {
28
- try {
29
- const raw = fs.readFileSync(filePath, "utf8");
30
- return JSON.parse(raw);
31
- }
32
- catch {
33
- return null;
34
- }
35
- }
36
- function extractTextFromContent(content) {
37
- if (typeof content === "string") {
38
- return content;
39
- }
40
- if (Array.isArray(content)) {
41
- return content
42
- .map((item) => {
43
- if (typeof item === "string") {
44
- return item;
45
- }
46
- if (item && typeof item === "object") {
47
- const obj = item;
48
- if (typeof obj.text === "string") {
49
- return obj.text;
50
- }
51
- if (typeof obj.input === "string") {
52
- return obj.input;
53
- }
54
- }
55
- return "";
56
- })
57
- .filter(Boolean)
58
- .join("\n");
59
- }
60
- return "";
61
- }
62
- function extractLatestUserText(event) {
63
- if (!event || typeof event !== "object") {
64
- return "";
65
- }
66
- const eventObj = event;
67
- const messages = Array.isArray(eventObj.messages) ? eventObj.messages : [];
68
- for (let i = messages.length - 1; i >= 0; i -= 1) {
69
- const msg = messages[i];
70
- if (!msg || typeof msg !== "object") {
71
- continue;
72
- }
73
- const msgObj = msg;
74
- if (safeString(msgObj.role) !== "user") {
75
- continue;
76
- }
77
- const content = extractTextFromContent(msgObj.content);
78
- if (content.trim()) {
79
- return content.trim();
80
- }
81
- }
82
- const fallbackPrompt = safeString(eventObj.userPrompt);
83
- return fallbackPrompt.trim();
84
- }
85
- function normalizePluginConfig(api) {
86
- const pluginCfg = api?.config?.plugins?.entries?.[PLUGIN_ID]?.config ??
87
- api?.config?.plugins?.entries?.[PLUGIN_ID] ??
88
- {};
89
- return {
90
- enabled: pluginCfg.enabled !== false,
91
- injectMode: pluginCfg.injectMode || "prependContext",
92
- routerConfigPath: safeString(pluginCfg.routerConfigPath) ||
93
- path.join(__dirname, "skill-router.config.json"),
94
- openclawConfigPath: safeString(pluginCfg.openclawConfigPath) ||
95
- path.join(os.homedir(), ".openclaw", "openclaw.json"),
96
- };
97
- }
98
- function readRouterConfig(configPath, baseDir) {
99
- const config = readJsonFile(resolvePath(configPath, baseDir));
100
- if (!config || typeof config !== "object") {
101
- return {};
102
- }
103
- return config;
104
- }
105
- function getDefaultModelInfo(openclawConfigPath) {
106
- const cfg = readJsonFile(expandHome(openclawConfigPath));
107
- if (!cfg || typeof cfg !== "object") {
108
- return "未读取到 OpenClaw 默认模型配置";
109
- }
110
- const root = cfg;
111
- const directModel = safeString(root.model);
112
- const channelsModel = safeString(root.channels?.model);
113
- if (directModel) {
114
- return directModel;
115
- }
116
- if (channelsModel) {
117
- return channelsModel;
118
- }
119
- return "openclaw.json 中未显式配置默认模型";
120
- }
121
- function parseYamlLikeFrontmatter(raw) {
122
- const result = { name: "", description: "" };
123
- if (!raw.startsWith("---")) {
124
- return result;
125
- }
126
- const endIndex = raw.indexOf("\n---", 3);
127
- if (endIndex === -1) {
128
- return result;
129
- }
130
- const frontmatter = raw.slice(3, endIndex).split(/\r?\n/);
131
- let inDescription = false;
132
- const descriptionLines = [];
133
- for (const line of frontmatter) {
134
- if (line.startsWith("name:")) {
135
- result.name = line.slice(5).trim();
136
- inDescription = false;
137
- continue;
138
- }
139
- if (line.startsWith("description:")) {
140
- const remainder = line.slice("description:".length).trim();
141
- inDescription = true;
142
- if (remainder && remainder !== "|") {
143
- descriptionLines.push(remainder);
144
- inDescription = false;
145
- }
146
- continue;
147
- }
148
- if (inDescription) {
149
- if (/^\S/.test(line)) {
150
- inDescription = false;
151
- }
152
- else {
153
- descriptionLines.push(line.trim());
154
- }
155
- }
156
- }
157
- result.description = descriptionLines.join(" ").trim();
158
- return result;
159
- }
160
- function extractHeadingFallback(raw) {
161
- const lines = raw.split(/\r?\n/);
162
- for (const line of lines) {
163
- if (line.startsWith("# ")) {
164
- return line.slice(2).trim();
165
- }
166
- }
167
- return "";
168
- }
169
- function extractSkillMeta(skillDir) {
170
- const skillMdPath = path.join(skillDir, "SKILL.md");
171
- if (!fs.existsSync(skillMdPath)) {
172
- return null;
173
- }
174
- const raw = fs.readFileSync(skillMdPath, "utf8");
175
- const fm = parseYamlLikeFrontmatter(raw);
176
- const name = fm.name || path.basename(skillDir);
177
- const description = fm.description || extractHeadingFallback(raw) || "无描述";
178
- return {
179
- name,
180
- description,
181
- skillPath: skillDir,
182
- };
183
- }
184
- function scanInstalledSkills(scanDirs) {
185
- const results = [];
186
- const visited = new Set();
187
- for (const dir of scanDirs) {
188
- const resolved = expandHome(dir);
189
- if (!resolved || !fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
190
- continue;
191
- }
192
- const entries = fs.readdirSync(resolved, { withFileTypes: true });
193
- for (const entry of entries) {
194
- if (!entry.isDirectory()) {
195
- continue;
196
- }
197
- const fullPath = path.join(resolved, entry.name);
198
- if (visited.has(fullPath)) {
199
- continue;
200
- }
201
- const meta = extractSkillMeta(fullPath);
202
- if (meta) {
203
- visited.add(fullPath);
204
- results.push(meta);
205
- }
206
- }
207
- }
208
- return results.sort((a, b) => a.name.localeCompare(b.name));
209
- }
210
- function matchesRule(text, rule) {
211
- const normalized = text.toLowerCase();
212
- const keywords = Array.isArray(rule.keywords) ? rule.keywords : [];
213
- const allKeywords = Array.isArray(rule.allKeywords) ? rule.allKeywords : [];
214
- const regexes = Array.isArray(rule.regexes) ? rule.regexes : [];
215
- const anyKeywordMatched = keywords.length === 0 ||
216
- keywords.some((keyword) => normalized.includes(keyword.toLowerCase()));
217
- const allKeywordMatched = allKeywords.length === 0 ||
218
- allKeywords.every((keyword) => normalized.includes(keyword.toLowerCase()));
219
- const regexMatched = regexes.length === 0 ||
220
- regexes.some((pattern) => {
221
- try {
222
- return new RegExp(pattern, "i").test(text);
223
- }
224
- catch {
225
- return false;
226
- }
227
- });
228
- const hasMatcher = keywords.length > 0 || allKeywords.length > 0 || regexes.length > 0;
229
- return hasMatcher && anyKeywordMatched && allKeywordMatched && regexMatched;
230
- }
231
- function applyRuleFiltering(skills, routerCfg, matchedRules) {
232
- let filtered = [...skills];
233
- const includeSkills = new Set((routerCfg.includeSkills || []).filter(Boolean));
234
- const excludeSkills = new Set((routerCfg.excludeSkills || []).filter(Boolean));
235
- if (includeSkills.size > 0) {
236
- filtered = filtered.filter((skill) => includeSkills.has(skill.name));
237
- }
238
- if (excludeSkills.size > 0) {
239
- filtered = filtered.filter((skill) => !excludeSkills.has(skill.name));
240
- }
241
- const preferred = new Set();
242
- const excludedByRule = new Set();
243
- for (const rule of matchedRules) {
244
- (rule.preferSkills || []).forEach((name) => preferred.add(name));
245
- (rule.excludeSkills || []).forEach((name) => excludedByRule.add(name));
246
- }
247
- filtered = filtered.filter((skill) => !excludedByRule.has(skill.name));
248
- if (preferred.size > 0) {
249
- const preferredSkills = filtered.filter((skill) => preferred.has(skill.name));
250
- const remainingSkills = filtered.filter((skill) => !preferred.has(skill.name));
251
- filtered = [...preferredSkills, ...remainingSkills];
252
- }
253
- const maxSkills = routerCfg.maxSkillsInPrompt && routerCfg.maxSkillsInPrompt > 0 ? routerCfg.maxSkillsInPrompt : 20;
254
- return filtered.slice(0, maxSkills);
255
- }
256
- function renderSkillList(skills) {
257
- if (skills.length === 0) {
258
- return "当前未扫描到可用 skill。";
259
- }
260
- return skills
261
- .map((skill, index) => `${index + 1}. ${skill.name}\n说明:${skill.description}\n路径:${skill.skillPath}`)
262
- .join("\n\n");
263
- }
264
- function renderRuleHints(matchedRules, routerCfg) {
265
- if (matchedRules.length === 0) {
266
- return routerCfg.defaultScopeNote ? `默认范围提示:${routerCfg.defaultScopeNote}` : "未命中任何自定义规则。";
267
- }
268
- return matchedRules
269
- .map((rule, index) => {
270
- const lines = [];
271
- lines.push(`${index + 1}. 规则ID:${rule.id}`);
272
- if (rule.description) {
273
- lines.push(`描述:${rule.description}`);
274
- }
275
- if ((rule.preferSkills || []).length > 0) {
276
- lines.push(`优先技能:${rule.preferSkills.join("、")}`);
277
- }
278
- if ((rule.excludeSkills || []).length > 0) {
279
- lines.push(`排除技能:${rule.excludeSkills.join("、")}`);
280
- }
281
- if (rule.scopeNote) {
282
- lines.push(`适用范围:${rule.scopeNote}`);
283
- }
284
- if (rule.prompt) {
285
- lines.push(`提示:${rule.prompt}`);
286
- }
287
- return lines.join("\n");
288
- })
289
- .join("\n\n");
290
- }
291
- function buildRoutingPrompt(userText, defaultModel, scannedSkills, matchedRules, routerCfg) {
292
- const lines = [];
293
- lines.push("【Skill Auto Loader Hook】请在正式回答前,先完成本轮 skill 路由判断。");
294
- lines.push(`当前 OpenClaw 默认模型:${defaultModel}`);
295
- lines.push(`本轮用户消息:${userText}`);
296
- lines.push("");
297
- lines.push("请结合以下信息判断本轮是否需要优先使用某个 skill。");
298
- lines.push("");
299
- lines.push("一、当前可用 skill 列表");
300
- lines.push(renderSkillList(scannedSkills));
301
- lines.push("");
302
- lines.push("二、本轮命中的自定义规则");
303
- lines.push(renderRuleHints(matchedRules, routerCfg));
304
- lines.push("");
305
- lines.push("三、判断要求");
306
- lines.push("1. 先判断本轮是否明显需要 skill。");
307
- lines.push("2. 如果需要,只选择最相关的 1-3 个 skill 作为优先候选。");
308
- lines.push("3. 若没有合适 skill,就按常规对话处理,不要强行套 skill。");
309
- lines.push("4. 不要使用被规则排除的 skill。");
310
- lines.push("5. 你的判断依据必须来自:用户消息、skill 描述、自定义 scope。");
311
- lines.push("");
312
- lines.push("四、执行方式");
313
- lines.push("请把路由判断体现在你后续的实际执行中:若需要 skill,则优先按判断结果使用;若不需要,则正常回答。");
314
- return lines.join("\n");
315
- }
316
- export default function register(api) {
317
- api.on("before_prompt_build", (event) => {
318
- const cfg = normalizePluginConfig(api);
319
- if (!cfg.enabled) {
320
- return undefined;
321
- }
322
- const routerCfg = readRouterConfig(cfg.routerConfigPath, __dirname);
323
- if (routerCfg.enabled === false) {
324
- return undefined;
325
- }
326
- const userText = extractLatestUserText(event);
327
- if (!userText) {
328
- return undefined;
329
- }
330
- const scanDirs = Array.isArray(routerCfg.skillsScanDirs) && routerCfg.skillsScanDirs.length > 0
331
- ? routerCfg.skillsScanDirs.map((dir) => resolvePath(dir, __dirname))
332
- : [path.join(process.cwd(), "skills"), path.join(process.cwd(), "openclaw-skills", "skills")];
333
- const allSkills = scanInstalledSkills(scanDirs);
334
- const matchedRules = (routerCfg.rules || []).filter((rule) => rule.enabled !== false && matchesRule(userText, rule));
335
- const candidateSkills = applyRuleFiltering(allSkills, routerCfg, matchedRules);
336
- const defaultModel = getDefaultModelInfo(cfg.openclawConfigPath);
337
- const payload = buildRoutingPrompt(userText, defaultModel, candidateSkills, matchedRules, routerCfg);
338
- if (cfg.injectMode === "prependSystemContext") {
339
- return { prependSystemContext: payload };
340
- }
341
- if (cfg.injectMode === "appendSystemContext") {
342
- return { appendSystemContext: payload };
343
- }
344
- return { prependContext: payload };
345
- }, { priority: 50 });
346
- }