skill-auto-loader-hook-paperfly777 0.1.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 +191 -0
- package/dist/index.js +346 -0
- package/openclaw.plugin.json +47 -0
- package/package.json +32 -0
- package/skill-router.config.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# skill-auto-loader-hook
|
|
2
|
+
|
|
3
|
+
这是一个 OpenClaw 钩子插件,用来在每次用户发送消息时,自动判断是否需要优先使用某个 skill,并把“本轮应优先考虑哪些 skill、适用范围是什么”注入到 prompt 构建阶段。
|
|
4
|
+
|
|
5
|
+
## 这个插件解决什么问题
|
|
6
|
+
|
|
7
|
+
适合下面这类需求:
|
|
8
|
+
|
|
9
|
+
1. 用户一发消息,就自动判断要不要优先走某个 skill。
|
|
10
|
+
2. 不同场景要命中不同 skill。
|
|
11
|
+
3. skill 的“适用范围”需要自定义,而不是全局写死。
|
|
12
|
+
4. 规则应该放在一个单独配置文件里,而不是挤在 `openclaw.json` 里。
|
|
13
|
+
5. 插件要自动读取本地已安装 skill 的 `name` 和 `description`。
|
|
14
|
+
6. 插件要自动读取 `~/.openclaw/openclaw.json` 中的默认模型信息。
|
|
15
|
+
|
|
16
|
+
例如:
|
|
17
|
+
|
|
18
|
+
1. 用户提到“知识库、公司规定、查文档”,优先用 `company-knowledge-base`
|
|
19
|
+
2. 用户提到“日报、工时、工作记录”,优先用 `daily-report`
|
|
20
|
+
3. 用户提到“飞书插件、飞书配置”,优先用 `openclaw-feishu-plugin`
|
|
21
|
+
|
|
22
|
+
## 官方设计依据
|
|
23
|
+
|
|
24
|
+
根据 OpenClaw 官方插件文档:
|
|
25
|
+
|
|
26
|
+
1. 原生插件通过 `openclaw.plugin.json` + 运行时模块工作。
|
|
27
|
+
2. 这类“每次消息到来时做动态判断”的需求,最合适的钩子是 `before_prompt_build`。
|
|
28
|
+
3. 该钩子运行时已经拿得到 `messages`,适合做本轮 skill 路由判断。
|
|
29
|
+
|
|
30
|
+
## 插件目录结构
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
skill-auto-loader-hook/
|
|
34
|
+
├── openclaw.plugin.json
|
|
35
|
+
├── index.ts
|
|
36
|
+
├── skill-router.config.json
|
|
37
|
+
└── README.md
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 安装方式
|
|
41
|
+
|
|
42
|
+
本地开发推荐:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
openclaw plugins install -l D:/project/openclaw-skill-2/openclaw-skills/plugins/skill-auto-loader-hook
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
安装后重启 Gateway。
|
|
49
|
+
|
|
50
|
+
如果你已经发布到 npm,则可以直接安装:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
openclaw plugins install skill-auto-loader-hook-paperfly777
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 发布方式
|
|
57
|
+
|
|
58
|
+
发布前先在插件目录执行:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm install
|
|
62
|
+
npm run build
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
然后登录 npm 并发布:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm login
|
|
69
|
+
npm publish --access public
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
发布成功后,就可以用:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
openclaw plugins install @applesay/skill-auto-loader-hook
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
说明:
|
|
79
|
+
|
|
80
|
+
1. 当前包名我先给你定成了 `skill-auto-loader-hook-paperfly777`,如果你后面有自己的 npm scope,再改 `package.json` 即可。
|
|
81
|
+
2. `openclaw.plugin.json` 已经指向 `dist/index.js`,所以发布前必须先构建。
|
|
82
|
+
|
|
83
|
+
## 配置方式
|
|
84
|
+
|
|
85
|
+
这个插件分两层配置:
|
|
86
|
+
|
|
87
|
+
1. `openclaw.json` 里只放插件基础入口配置
|
|
88
|
+
2. `skill-router.config.json` 里放独立的技能路由规则
|
|
89
|
+
|
|
90
|
+
路径说明:
|
|
91
|
+
|
|
92
|
+
1. `routerConfigPath` 如果写相对路径,则相对于插件目录解析。
|
|
93
|
+
2. `skillsScanDirs` 如果写相对路径,也相对于插件目录解析。
|
|
94
|
+
3. `openclawConfigPath` 建议使用 `~/.openclaw/openclaw.json`。
|
|
95
|
+
|
|
96
|
+
### 1. `openclaw.json` 中的插件入口配置
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"plugins": {
|
|
101
|
+
"entries": {
|
|
102
|
+
"skill-auto-loader-hook": {
|
|
103
|
+
"enabled": true,
|
|
104
|
+
"hooks": {
|
|
105
|
+
"allowPromptInjection": true
|
|
106
|
+
},
|
|
107
|
+
"config": {
|
|
108
|
+
"enabled": true,
|
|
109
|
+
"injectMode": "prependContext",
|
|
110
|
+
"routerConfigPath": "./skill-router.config.json",
|
|
111
|
+
"openclawConfigPath": "~/.openclaw/openclaw.json"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 2. 独立路由配置文件 `skill-router.config.json`
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"enabled": true,
|
|
124
|
+
"skillsScanDirs": [
|
|
125
|
+
"./skills",
|
|
126
|
+
"./openclaw-skills/skills",
|
|
127
|
+
"~/.openclaw/workspace/skills"
|
|
128
|
+
],
|
|
129
|
+
"includeSkills": [],
|
|
130
|
+
"excludeSkills": [],
|
|
131
|
+
"maxSkillsInPrompt": 20,
|
|
132
|
+
"defaultScopeNote": "只在当前业务强相关的已安装 skill 范围内做判断,不要为了使用 skill 而使用 skill。",
|
|
133
|
+
"rules": [
|
|
134
|
+
{
|
|
135
|
+
"id": "company-kb",
|
|
136
|
+
"enabled": true,
|
|
137
|
+
"description": "公司知识库问答、制度查询、内部文档处理",
|
|
138
|
+
"keywords": ["知识库", "查文档", "公司规定", "制度", "技术方案"],
|
|
139
|
+
"preferSkills": ["company-knowledge-base"],
|
|
140
|
+
"excludeSkills": [],
|
|
141
|
+
"scopeNote": "仅适用于公司知识库、内部文档、流程制度相关问题。",
|
|
142
|
+
"prompt": "优先判断是否需要先检索知识库,再决定是否回答或执行文档操作。"
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## 运行逻辑
|
|
149
|
+
|
|
150
|
+
每次用户发消息时:
|
|
151
|
+
|
|
152
|
+
1. `before_prompt_build` 读取本轮用户消息。
|
|
153
|
+
2. 自动读取 `skill-router.config.json`。
|
|
154
|
+
3. 自动扫描本地已安装的 skill,提取 `SKILL.md` 里的 `name` 和 `description`。
|
|
155
|
+
4. 自动读取 `~/.openclaw/openclaw.json`,拿到当前默认模型信息。
|
|
156
|
+
5. 用自定义规则先做范围收缩。
|
|
157
|
+
6. 把“用户消息 + 候选 skills + 规则提示 + 默认模型”一起注入当前轮 prompt。
|
|
158
|
+
7. 再由当前默认大模型判断:本轮是否需要 skill、优先用哪个 skill。
|
|
159
|
+
|
|
160
|
+
## 这个插件和大模型的关系
|
|
161
|
+
|
|
162
|
+
这个插件不是自己在本地写死判断逻辑,而是:
|
|
163
|
+
|
|
164
|
+
1. 插件负责收集上下文
|
|
165
|
+
2. 插件负责收窄可选 skill 范围
|
|
166
|
+
3. 最终由当前默认大模型完成本轮 skill 路由判断
|
|
167
|
+
|
|
168
|
+
这样后面你增加新 skill,不需要改核心判断逻辑,只要:
|
|
169
|
+
|
|
170
|
+
1. skill 已安装
|
|
171
|
+
2. skill 有 `name` 和 `description`
|
|
172
|
+
3. 在独立配置文件里补规则
|
|
173
|
+
|
|
174
|
+
## 重要边界
|
|
175
|
+
|
|
176
|
+
这个插件做的是“动态路由和提示注入”,不是“运行时热安装 skill”。
|
|
177
|
+
|
|
178
|
+
也就是说:
|
|
179
|
+
|
|
180
|
+
1. 它可以引导 OpenClaw 本轮优先考虑哪些已安装 skill。
|
|
181
|
+
2. 它不能替代 skill 本身的安装和发现机制。
|
|
182
|
+
3. 所有要被路由的 skill,仍然需要先安装到 OpenClaw 可发现的位置。
|
|
183
|
+
4. 它当前做的是“把 skill 判断交给默认模型”,不是在插件里直接调用第二个独立模型 API。
|
|
184
|
+
|
|
185
|
+
## 推荐下一步
|
|
186
|
+
|
|
187
|
+
如果你准备把它真正用到你的项目里,下一步最值得做的是:
|
|
188
|
+
|
|
189
|
+
1. 先把 `company-knowledge-base`、`daily-report`、`openclaw-feishu-plugin` 三个规则配起来。
|
|
190
|
+
2. 再补一个“电商虾 skill 路由规则集”。
|
|
191
|
+
3. 最后再加一个后台管理页或配置文件生成器,避免手改 `skill-router.config.json`。
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "skill-auto-loader-hook",
|
|
3
|
+
"name": "Skill Auto Loader Hook",
|
|
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",
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
"properties": {
|
|
11
|
+
"enabled": {
|
|
12
|
+
"type": "boolean",
|
|
13
|
+
"default": true
|
|
14
|
+
},
|
|
15
|
+
"injectMode": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"enum": [
|
|
18
|
+
"prependContext",
|
|
19
|
+
"prependSystemContext",
|
|
20
|
+
"appendSystemContext"
|
|
21
|
+
],
|
|
22
|
+
"default": "prependContext"
|
|
23
|
+
},
|
|
24
|
+
"routerConfigPath": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"default": "./skill-router.config.json"
|
|
27
|
+
},
|
|
28
|
+
"openclawConfigPath": {
|
|
29
|
+
"type": "string"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"uiHints": {
|
|
34
|
+
"enabled": {
|
|
35
|
+
"label": "启用自动 Skill 路由"
|
|
36
|
+
},
|
|
37
|
+
"injectMode": {
|
|
38
|
+
"label": "Prompt 注入位置"
|
|
39
|
+
},
|
|
40
|
+
"routerConfigPath": {
|
|
41
|
+
"label": "独立路由配置文件路径"
|
|
42
|
+
},
|
|
43
|
+
"openclawConfigPath": {
|
|
44
|
+
"label": "OpenClaw 配置文件路径"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skill-auto-loader-hook-paperfly777",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenClaw hook plugin that routes each user message to the most relevant installed skill.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"openclaw.plugin.json",
|
|
11
|
+
"skill-router.config.json",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^22.15.21",
|
|
23
|
+
"typescript": "^5.8.3"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"openclaw",
|
|
27
|
+
"plugin",
|
|
28
|
+
"hook",
|
|
29
|
+
"skill",
|
|
30
|
+
"router"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enabled": true,
|
|
3
|
+
"skillsScanDirs": [
|
|
4
|
+
"./skills",
|
|
5
|
+
"./openclaw-skills/skills",
|
|
6
|
+
"~/.openclaw/workspace/skills"
|
|
7
|
+
],
|
|
8
|
+
"includeSkills": [],
|
|
9
|
+
"excludeSkills": [],
|
|
10
|
+
"maxSkillsInPrompt": 20,
|
|
11
|
+
"defaultScopeNote": "只在当前业务强相关的已安装 skill 范围内做判断,不要为了使用 skill 而使用 skill。",
|
|
12
|
+
"rules": [
|
|
13
|
+
{
|
|
14
|
+
"id": "company-kb",
|
|
15
|
+
"enabled": true,
|
|
16
|
+
"description": "公司知识库问答、制度查询、内部文档处理",
|
|
17
|
+
"keywords": ["知识库", "查文档", "公司规定", "制度", "技术方案"],
|
|
18
|
+
"preferSkills": ["company-knowledge-base"],
|
|
19
|
+
"excludeSkills": [],
|
|
20
|
+
"scopeNote": "仅适用于公司知识库、内部文档、流程制度相关问题。",
|
|
21
|
+
"prompt": "优先判断是否需要先检索知识库,再决定是否回答或执行文档操作。"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "daily-report",
|
|
25
|
+
"enabled": true,
|
|
26
|
+
"description": "日报生成、草稿整理、上传提交",
|
|
27
|
+
"keywords": ["日报", "工时", "工作记录", "提交日报"],
|
|
28
|
+
"preferSkills": ["daily-report"],
|
|
29
|
+
"excludeSkills": ["company-knowledge-base"],
|
|
30
|
+
"scopeNote": "仅适用于日报草稿生成、日报上传、日报修订场景。",
|
|
31
|
+
"prompt": "先判断是需要生成新草稿,还是基于已有文件进行上传。"
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|