@spzhongwin/skill-logger-plugin 1.0.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.
Files changed (53) hide show
  1. package/dist/active-skills.js +67 -0
  2. package/dist/active-skills.test.js +29 -0
  3. package/dist/config-sync.js +439 -0
  4. package/dist/config-sync.test.js +145 -0
  5. package/dist/hooks.js +337 -0
  6. package/dist/hooks.test.js +123 -0
  7. package/dist/http.js +54 -0
  8. package/dist/identity.js +56 -0
  9. package/dist/index.js +2286 -0
  10. package/dist/index.test.js +39 -0
  11. package/dist/integration.test.js +102 -0
  12. package/dist/matcher.js +362 -0
  13. package/dist/matcher.test.js +139 -0
  14. package/dist/paths.js +62 -0
  15. package/dist/paths.test.js +49 -0
  16. package/dist/reporter.js +267 -0
  17. package/dist/reporter.test.js +128 -0
  18. package/dist/semver.js +64 -0
  19. package/dist/semver.test.js +21 -0
  20. package/dist/skill-version.js +23 -0
  21. package/dist/types.js +9 -0
  22. package/dist/updater.js +352 -0
  23. package/dist/updater.test.js +212 -0
  24. package/dist/ws-client.js +484 -0
  25. package/openclaw.plugin.json +50 -0
  26. package/package.json +35 -0
  27. package/src/active-skills.test.ts +32 -0
  28. package/src/active-skills.ts +77 -0
  29. package/src/config-sync.test.ts +165 -0
  30. package/src/config-sync.ts +485 -0
  31. package/src/hooks.test.ts +156 -0
  32. package/src/hooks.ts +405 -0
  33. package/src/http.ts +61 -0
  34. package/src/identity.ts +64 -0
  35. package/src/index.test.ts +53 -0
  36. package/src/index.ts +226 -0
  37. package/src/integration.test.ts +119 -0
  38. package/src/matcher.test.ts +170 -0
  39. package/src/matcher.ts +393 -0
  40. package/src/paths.test.ts +57 -0
  41. package/src/paths.ts +84 -0
  42. package/src/reporter.test.ts +139 -0
  43. package/src/reporter.ts +298 -0
  44. package/src/sample-config.json +72 -0
  45. package/src/semver.test.ts +23 -0
  46. package/src/semver.ts +60 -0
  47. package/src/skill-version.ts +22 -0
  48. package/src/types.ts +198 -0
  49. package/src/updater.test.ts +237 -0
  50. package/src/updater.ts +400 -0
  51. package/src/ws-client.ts +516 -0
  52. package/test-ws.ts +17 -0
  53. package/tsconfig.json +14 -0
@@ -0,0 +1,39 @@
1
+ import { describe, it, before } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ let isSkillMdReadPath;
4
+ let extractApiPluginConfig;
5
+ describe("isSkillMdReadPath", () => {
6
+ before(async () => {
7
+ ({ isSkillMdReadPath, extractApiPluginConfig } = await import("./index.ts"));
8
+ });
9
+ it("POSIX 路径 basename 为 SKILL.md 时为 true", () => {
10
+ assert.equal(isSkillMdReadPath("/x/y/my-skill/SKILL.md"), true);
11
+ });
12
+ it("Windows 风格路径", { skip: process.platform !== "win32" }, () => {
13
+ assert.equal(isSkillMdReadPath(String.raw `C:\x\y\my-skill\SKILL.md`), true);
14
+ });
15
+ it("fooSKILL.md / my-SKILL.md 不误判", () => {
16
+ assert.equal(isSkillMdReadPath("/tmp/fooSKILL.md"), false);
17
+ assert.equal(isSkillMdReadPath("/tmp/my-SKILL.md"), false);
18
+ });
19
+ });
20
+ describe("extractApiPluginConfig", () => {
21
+ it("从 OpenClaw 注入的 api.pluginConfig 初始化运行期配置", () => {
22
+ assert.deepEqual(extractApiPluginConfig({
23
+ pluginConfig: {
24
+ platformBaseUrl: "https://platform.example/api",
25
+ reportBaseUrl: "https://report.example/api",
26
+ authToken: "Bearer token",
27
+ recordUnattributed: true,
28
+ },
29
+ }), {
30
+ platformBaseUrl: "https://platform.example/api",
31
+ reportBaseUrl: "https://report.example/api",
32
+ authToken: "Bearer token",
33
+ recordUnattributed: true,
34
+ });
35
+ });
36
+ it("未配置时返回空对象", () => {
37
+ assert.deepEqual(extractApiPluginConfig({}), {});
38
+ });
39
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { createHash } from "node:crypto";
7
+ import { ConfigSync } from "./config-sync.ts";
8
+ import { SkillUpdater } from "./updater.ts";
9
+ const idHash = (code, version) => createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
10
+ let dir;
11
+ beforeEach(async () => {
12
+ dir = path.join(os.tmpdir(), `slp-int-${Date.now()}-${Math.random().toString(36).slice(2)}`);
13
+ await fs.mkdir(dir, { recursive: true });
14
+ });
15
+ afterEach(async () => {
16
+ await fs.rm(dir, { recursive: true, force: true });
17
+ });
18
+ describe("版本更新闭环(端到端)", () => {
19
+ it("config-pull 报最新版 → 检测落后 → 下载覆盖 → 再查收敛", async () => {
20
+ // 本地装一个 demo@1.0.0
21
+ const ws = path.join(dir, "workspace");
22
+ const skillDir = path.join(ws, "skills", "demo");
23
+ await fs.mkdir(skillDir, { recursive: true });
24
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
25
+ const config = { platformBaseUrl: "https://api", autoUpdateSkills: true };
26
+ // 服务端最新版本(随测试推进而变化),模拟「更新后平台版本=本地版本」的收敛。
27
+ let platformLatest = "2.0.0";
28
+ // ConfigSync 的 fetch:/skill_config/pull → 带 latestVersion
29
+ const csFetch = async (_url, _init) => ({
30
+ ok: true,
31
+ status: 200,
32
+ json: async () => ({ configs: [{ skillName: "demo", version: "1.0.0", latestVersion: platformLatest, functions: [] }] }),
33
+ });
34
+ // Updater 的 fetch:/skill_package/pull → {url, sha256};GET → zip 字节
35
+ const upFetch = async (_url, init) => {
36
+ if (init?.method === "POST") {
37
+ return {
38
+ ok: true,
39
+ status: 200,
40
+ json: async () => ({ url: "https://pkg/skill.zip", version: platformLatest, sha256: idHash("demo", platformLatest) }),
41
+ arrayBuffer: async () => new ArrayBuffer(0),
42
+ };
43
+ }
44
+ return { ok: true, status: 200, json: async () => ({}), arrayBuffer: async () => new Uint8Array([1]).buffer };
45
+ };
46
+ // 解压:写出新版本的 SKILL.md(版本号与 platformLatest 一致)
47
+ const unzip = async (_zip, destDir) => {
48
+ const root = path.join(destDir, "demo");
49
+ await fs.mkdir(root, { recursive: true });
50
+ await fs.writeFile(path.join(root, "SKILL.md"), `---\nname: demo\nversion: ${platformLatest}\n---\nNEW\n`);
51
+ };
52
+ const updater = new SkillUpdater({ getConfig: () => config, fetchImpl: upFetch, unzip, tmpDir: dir });
53
+ const cs = new ConfigSync({
54
+ paths: { extensionsDir: path.join(dir, "ext"), syncStatePath: path.join(dir, "sync.json"), openclawConfigPath: path.join(dir, "openclaw.json") },
55
+ getConfig: () => config,
56
+ fetchImpl: csFetch,
57
+ resolveSkillDirs: () => [path.join(ws, "skills")],
58
+ updater,
59
+ });
60
+ // 第一轮:检测落后并覆盖到 2.0.0
61
+ await cs.checkVersionsAndUpdate();
62
+ const afterMd = await fs.readFile(path.join(skillDir, "SKILL.md"), "utf-8");
63
+ assert.ok(afterMd.includes("version: 2.0.0"), "应被更新到 2.0.0");
64
+ assert.ok(afterMd.includes("NEW"), "应为新内容");
65
+ // 第二轮:平台版本仍 2.0.0、本地已 2.0.0 → 收敛,无落后副本
66
+ platformLatest = "2.0.0";
67
+ await cs.checkVersionsAndUpdate();
68
+ assert.equal(cs.detectOutdated().length, 0, "更新后应收敛");
69
+ // sync.json 落了 installations 映射,且版本已是 2.0.0
70
+ const state = JSON.parse(await fs.readFile(path.join(dir, "sync.json"), "utf-8"));
71
+ assert.equal(state.installations.demo[0].version, "2.0.0");
72
+ });
73
+ it("autoUpdateSkills=false:检测到落后也不覆盖", async () => {
74
+ const ws = path.join(dir, "workspace");
75
+ const skillDir = path.join(ws, "skills", "demo");
76
+ await fs.mkdir(skillDir, { recursive: true });
77
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
78
+ const config = { platformBaseUrl: "https://api", autoUpdateSkills: false };
79
+ const csFetch = async () => ({
80
+ ok: true,
81
+ status: 200,
82
+ json: async () => ({ configs: [{ skillName: "demo", version: "1.0.0", latestVersion: "2.0.0", functions: [] }] }),
83
+ });
84
+ let upCalled = 0;
85
+ const upFetch = async () => {
86
+ upCalled++;
87
+ return { ok: true, status: 200, json: async () => ({ url: "" }), arrayBuffer: async () => new ArrayBuffer(0) };
88
+ };
89
+ const updater = new SkillUpdater({ getConfig: () => config, fetchImpl: upFetch, unzip: async () => { }, tmpDir: dir });
90
+ const cs = new ConfigSync({
91
+ paths: { extensionsDir: path.join(dir, "ext"), syncStatePath: path.join(dir, "sync.json"), openclawConfigPath: path.join(dir, "openclaw.json") },
92
+ getConfig: () => config,
93
+ fetchImpl: csFetch,
94
+ resolveSkillDirs: () => [path.join(ws, "skills")],
95
+ updater,
96
+ });
97
+ await cs.checkVersionsAndUpdate();
98
+ assert.equal(cs.detectOutdated().length, 1, "仍检测到落后");
99
+ assert.equal(upCalled, 0, "关闭时不应发起下载");
100
+ assert.ok((await fs.readFile(path.join(skillDir, "SKILL.md"), "utf-8")).includes("OLD"), "文件保持原样");
101
+ });
102
+ });
@@ -0,0 +1,362 @@
1
+ /**
2
+ * 多策略匹配引擎(纯函数、无 I/O)。
3
+ *
4
+ * 职责:给定一次观测到的工具调用(toolName + params)和「本 session 已激活的 skill 集合」,
5
+ * 判定它是否对应某 skill 的某功能点,并解析出参数。
6
+ *
7
+ * 四种策略(由标准配置里 `match.type` 判别):
8
+ * - script :exec 跑 skill 自带脚本(.py/.sh/.js),按脚本路径后缀匹配
9
+ * - command :exec 调包装 CLI(如 `mcporter call linear.list_issues`)
10
+ * - tool :直接调非 exec 工具(MCP/SSE 工具),按 toolName(+参数谓词) 匹配
11
+ * - http :curl/fetch 打 HTTP/SSE 端点,按 url/host 子串匹配
12
+ *
13
+ * 为保证 hook 热路径快:先用 buildIndex 预索引(按 basename / 命令头 / toolName / host 建桶),
14
+ * match 时仅在小候选集上做精确判定。
15
+ */
16
+ import path from "node:path";
17
+ /** 已知解释器/包装前缀,用于在找「命令头」时跳过。 */
18
+ const INTERPRETERS = new Set([
19
+ "python", "python3", "py", "bash", "sh", "zsh", "node", "ts-node",
20
+ "tsx", "deno", "ruby", "perl", "uv", "uvx", "npx", "pnpm", "yarn", "env",
21
+ ]);
22
+ /** 空索引。 */
23
+ export function emptyIndex() {
24
+ return {
25
+ scriptByBasename: new Map(),
26
+ commandByHead: new Map(),
27
+ toolByName: new Map(),
28
+ toolFuzzy: [],
29
+ httpRules: [],
30
+ };
31
+ }
32
+ function pushBucket(map, key, fn) {
33
+ const arr = map.get(key);
34
+ if (arr)
35
+ arr.push(fn);
36
+ else
37
+ map.set(key, [fn]);
38
+ }
39
+ /** 把标准配置编译成可快速匹配的索引。 */
40
+ export function buildIndex(configs) {
41
+ const index = emptyIndex();
42
+ for (const cfg of configs) {
43
+ for (const fn of cfg.functions) {
44
+ const indexed = {
45
+ skillName: cfg.skillName,
46
+ skillVersion: cfg.version,
47
+ functionId: fn.id,
48
+ functionName: fn.name,
49
+ rule: fn.match,
50
+ };
51
+ switch (fn.match.type) {
52
+ case "script":
53
+ pushBucket(index.scriptByBasename, path.basename(fn.match.script), indexed);
54
+ break;
55
+ case "command":
56
+ pushBucket(index.commandByHead, fn.match.command, indexed);
57
+ break;
58
+ case "tool":
59
+ if (fn.match.toolName) {
60
+ pushBucket(index.toolByName, fn.match.toolName, indexed);
61
+ }
62
+ else {
63
+ if (fn.match.toolNameRegex) {
64
+ try {
65
+ indexed.compiledRegex = new RegExp(fn.match.toolNameRegex);
66
+ }
67
+ catch {
68
+ indexed.compiledRegex = null; // 非法正则:预编译失败,匹配时直接不命中
69
+ }
70
+ }
71
+ index.toolFuzzy.push(indexed);
72
+ }
73
+ break;
74
+ case "http":
75
+ index.httpRules.push(indexed);
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ return index;
81
+ }
82
+ /**
83
+ * 把 shell 命令切成 token,识别简单的单/双引号;不求完整 shell 语义,够匹配脚本路径与 flag 即可。
84
+ */
85
+ export function tokenize(command) {
86
+ const tokens = [];
87
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
88
+ let m;
89
+ while ((m = re.exec(command)) !== null) {
90
+ tokens.push(m[1] ?? m[2] ?? m[3] ?? "");
91
+ }
92
+ return tokens;
93
+ }
94
+ /** 路径后缀匹配(按路径分隔对齐,避免 `xfoo.py` 误判 `foo.py`)。 */
95
+ export function pathEndsWith(token, scriptRel) {
96
+ const t = token.replace(/\\/g, "/");
97
+ const s = scriptRel.replace(/\\/g, "/").replace(/^\.?\//, "");
98
+ return t === s || t.endsWith("/" + s);
99
+ }
100
+ /** 解析命令行 flag:`--k v` / `--k=v` / `-k v` / 布尔 flag。返回去前缀的键值表。 */
101
+ export function parseFlags(tokens) {
102
+ const out = {};
103
+ for (let i = 0; i < tokens.length; i++) {
104
+ const t = tokens[i];
105
+ if (t.startsWith("--")) {
106
+ const eq = t.indexOf("=");
107
+ if (eq >= 0) {
108
+ out[t.slice(2, eq)] = t.slice(eq + 1);
109
+ }
110
+ else {
111
+ const next = tokens[i + 1];
112
+ if (next !== undefined && !next.startsWith("-")) {
113
+ out[t.slice(2)] = next;
114
+ i++;
115
+ }
116
+ else {
117
+ out[t.slice(2)] = true;
118
+ }
119
+ }
120
+ }
121
+ else if (t.length > 1 && t.startsWith("-") && !/^-\d/.test(t)) {
122
+ const key = t.slice(1);
123
+ const next = tokens[i + 1];
124
+ if (next !== undefined && !next.startsWith("-")) {
125
+ out[key] = next;
126
+ i++;
127
+ }
128
+ else {
129
+ out[key] = true;
130
+ }
131
+ }
132
+ }
133
+ return out;
134
+ }
135
+ /** argRules 全部命中才算命中(flag 去前缀比较;value 省略则只判存在)。 */
136
+ function argRulesMatch(argRules, flags) {
137
+ if (!argRules || argRules.length === 0)
138
+ return true;
139
+ for (const rule of argRules) {
140
+ const key = rule.flag.replace(/^-+/, "");
141
+ if (!(key in flags))
142
+ return false;
143
+ if (rule.value !== undefined && String(flags[key]) !== rule.value)
144
+ return false;
145
+ }
146
+ return true;
147
+ }
148
+ /** 找命令头:跳过 `VAR=val` 环境前缀与已知解释器,返回首个真实命令名。 */
149
+ export function commandHead(tokens) {
150
+ for (let i = 0; i < tokens.length; i++) {
151
+ const t = tokens[i];
152
+ if (/^[A-Za-z_][\w]*=/.test(t))
153
+ continue; // 环境变量前缀
154
+ const base = path.basename(t);
155
+ if (INTERPRETERS.has(base))
156
+ continue; // 解释器
157
+ return base;
158
+ }
159
+ return undefined;
160
+ }
161
+ /** 解析 `k=v` / `k:v`(用于 mcporter 等),避开 URL 的 `://`。 */
162
+ export function parseKeyValues(tokens) {
163
+ const out = {};
164
+ for (const t of tokens) {
165
+ if (t.includes("://"))
166
+ continue;
167
+ const m = /^([A-Za-z_][\w.-]*)[=:](.*)$/.exec(t);
168
+ if (m)
169
+ out[m[1]] = m[2];
170
+ }
171
+ return out;
172
+ }
173
+ /** 从字符串里抽第一个 URL。 */
174
+ function extractUrl(s) {
175
+ const m = /https?:\/\/[^\s"'`]+/.exec(s);
176
+ return m ? m[0] : undefined;
177
+ }
178
+ /** 解析 URL 的 query 参数为对象。 */
179
+ function parseQuery(url) {
180
+ const out = {};
181
+ const qi = url.indexOf("?");
182
+ if (qi < 0)
183
+ return out;
184
+ for (const pair of url.slice(qi + 1).split("&")) {
185
+ if (!pair)
186
+ continue;
187
+ const eq = pair.indexOf("=");
188
+ const k = decodeURIComponent(eq >= 0 ? pair.slice(0, eq) : pair);
189
+ const v = eq >= 0 ? decodeURIComponent(pair.slice(eq + 1)) : true;
190
+ out[k] = v;
191
+ }
192
+ return out;
193
+ }
194
+ function httpMatchesUrl(url, rule) {
195
+ if (rule.urlContains && !url.includes(rule.urlContains))
196
+ return false;
197
+ if (rule.hostContains) {
198
+ let host = "";
199
+ try {
200
+ host = new URL(url).host;
201
+ }
202
+ catch {
203
+ host = url;
204
+ }
205
+ if (!host.includes(rule.hostContains))
206
+ return false;
207
+ }
208
+ return Boolean(rule.urlContains || rule.hostContains);
209
+ }
210
+ /** 点路径取嵌套字段。 */
211
+ function getByPath(obj, dotted) {
212
+ let cur = obj;
213
+ for (const seg of dotted.split(".")) {
214
+ if (cur && typeof cur === "object" && seg in cur) {
215
+ cur = cur[seg];
216
+ }
217
+ else {
218
+ return undefined;
219
+ }
220
+ }
221
+ return cur;
222
+ }
223
+ function whereMatches(where, params) {
224
+ if (!where || where.length === 0)
225
+ return true;
226
+ for (const p of where) {
227
+ if (String(getByPath(params, p.param)) !== p.equals)
228
+ return false;
229
+ }
230
+ return true;
231
+ }
232
+ function toolNameMatches(f, toolName) {
233
+ const rule = f.rule;
234
+ if (rule.toolName)
235
+ return rule.toolName === toolName;
236
+ if (rule.toolNamePrefix)
237
+ return toolName.startsWith(rule.toolNamePrefix);
238
+ if (rule.toolNameRegex) {
239
+ // 预编译命中走缓存;null 表示编译失败不命中;undefined 仅为兜底(理论上 buildIndex 必已编译)。
240
+ if (f.compiledRegex === null)
241
+ return false;
242
+ if (f.compiledRegex)
243
+ return f.compiledRegex.test(toolName);
244
+ try {
245
+ return new RegExp(rule.toolNameRegex).test(toolName);
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ }
251
+ return false;
252
+ }
253
+ /** 从工具参数里挑一个 url 字段(fetch 类工具)。 */
254
+ function pickUrlParam(params) {
255
+ for (const key of ["url", "endpoint", "uri", "href"]) {
256
+ const v = params[key];
257
+ if (typeof v === "string" && /^https?:\/\//.test(v))
258
+ return v;
259
+ }
260
+ return undefined;
261
+ }
262
+ function toResult(f, matchType, args) {
263
+ return {
264
+ skillName: f.skillName,
265
+ skillVersion: f.skillVersion,
266
+ functionId: f.functionId,
267
+ functionName: f.functionName,
268
+ matchType,
269
+ args,
270
+ };
271
+ }
272
+ /**
273
+ * 主匹配。命中返回 MatchResult;无自信命中返回 null。
274
+ * 多候选时优先归属到 `activeSkills`(本 session 已触发的 skill)以消歧。
275
+ */
276
+ export function match(call, activeSkills, index) {
277
+ const candidates = [];
278
+ const add = (res, strong = true) => candidates.push({ res, skillActive: activeSkills.has(res.skillName), strong });
279
+ if (call.toolName === "exec") {
280
+ const command = typeof call.params.command === "string" ? call.params.command : "";
281
+ if (!command)
282
+ return null;
283
+ const tokens = tokenize(command);
284
+ const flags = parseFlags(tokens);
285
+ // script:按 token 的 basename 命中桶。
286
+ // - 强匹配:token 路径以配置脚本相对路径结尾(如完整 baseDir 路径)。
287
+ // - 弱匹配:仅 basename 相同(如 `cd` 进目录后 `python model_usage.py`);
288
+ // 弱匹配只有在「该 skill 已激活」时才会被采纳,避免跨 skill 同名脚本误判。
289
+ for (const tok of tokens) {
290
+ const fns = index.scriptByBasename.get(path.basename(tok));
291
+ if (!fns)
292
+ continue;
293
+ for (const f of fns) {
294
+ const rule = f.rule;
295
+ if (!argRulesMatch(rule.argRules, flags))
296
+ continue;
297
+ add(toResult(f, "script", flags), pathEndsWith(tok, rule.script));
298
+ }
299
+ }
300
+ // command:命令头命中桶,再校验 targetPattern 出现在命令里(空白归一以增强鲁棒性)
301
+ const head = commandHead(tokens);
302
+ if (head) {
303
+ const fns = index.commandByHead.get(head);
304
+ if (fns) {
305
+ const normCmd = command.replace(/\s+/g, " ");
306
+ for (const f of fns) {
307
+ const rule = f.rule;
308
+ if (normCmd.includes(rule.targetPattern.replace(/\s+/g, " "))) {
309
+ add(toResult(f, "command", parseKeyValues(tokens)));
310
+ }
311
+ }
312
+ }
313
+ }
314
+ // http:curl/wget 命令里的 URL
315
+ if (index.httpRules.length > 0) {
316
+ const url = extractUrl(command);
317
+ if (url) {
318
+ for (const f of index.httpRules) {
319
+ if (httpMatchesUrl(url, f.rule)) {
320
+ add(toResult(f, "http", parseQuery(url)));
321
+ }
322
+ }
323
+ }
324
+ }
325
+ }
326
+ else {
327
+ // 非 exec:tool 直调
328
+ const exact = index.toolByName.get(call.toolName) ?? [];
329
+ for (const f of exact) {
330
+ const rule = f.rule;
331
+ if (whereMatches(rule.where, call.params))
332
+ add(toResult(f, "tool", { ...call.params }));
333
+ }
334
+ for (const f of index.toolFuzzy) {
335
+ const rule = f.rule;
336
+ if (toolNameMatches(f, call.toolName) && whereMatches(rule.where, call.params)) {
337
+ add(toResult(f, "tool", { ...call.params }));
338
+ }
339
+ }
340
+ // http:fetch 类工具的 url 参数
341
+ if (index.httpRules.length > 0) {
342
+ const url = pickUrlParam(call.params);
343
+ if (url) {
344
+ for (const f of index.httpRules) {
345
+ if (httpMatchesUrl(url, f.rule)) {
346
+ add(toResult(f, "http", parseQuery(url)));
347
+ }
348
+ }
349
+ }
350
+ }
351
+ }
352
+ if (candidates.length === 0)
353
+ return null;
354
+ // 丢弃「弱匹配且 skill 未激活」的低置信候选。
355
+ const eligible = candidates.filter((c) => c.strong || c.skillActive);
356
+ if (eligible.length === 0)
357
+ return null;
358
+ // 置信度排序:强且激活 > 强 > 弱且激活。sort 在 V8 稳定,平手时保留配置顺序。
359
+ const rank = (c) => (c.strong ? 2 : 0) + (c.skillActive ? 1 : 0);
360
+ eligible.sort((a, b) => rank(b) - rank(a));
361
+ return eligible[0].res;
362
+ }
@@ -0,0 +1,139 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildIndex, match, parseFlags, parseKeyValues, pathEndsWith, commandHead, tokenize, } from "./matcher.ts";
4
+ const configs = [
5
+ {
6
+ skillName: "model-usage",
7
+ version: "1.0.0",
8
+ functions: [
9
+ { id: "usage_current", name: "当前用量", match: { type: "script", script: "scripts/model_usage.py", argRules: [{ flag: "--mode", value: "current" }] } },
10
+ { id: "usage_all", name: "全部用量", match: { type: "script", script: "scripts/model_usage.py", argRules: [{ flag: "--mode", value: "all" }] } },
11
+ ],
12
+ },
13
+ {
14
+ skillName: "openai-whisper-api",
15
+ version: "1.0.0",
16
+ functions: [{ id: "transcribe", name: "转写", match: { type: "script", script: "scripts/transcribe.sh" } }],
17
+ },
18
+ {
19
+ skillName: "mcporter",
20
+ version: "1.0.0",
21
+ functions: [{ id: "list_issues", name: "列issue", match: { type: "command", command: "mcporter", targetPattern: "call linear.list_issues" } }],
22
+ },
23
+ {
24
+ skillName: "linear-mcp",
25
+ version: "1.0.0",
26
+ functions: [
27
+ { id: "create_issue", name: "建issue", match: { type: "tool", toolName: "linear_create_issue" } },
28
+ { id: "transcribe_http", name: "转写端点", match: { type: "http", urlContains: "/v1/audio/transcriptions" } },
29
+ ],
30
+ },
31
+ ];
32
+ const index = buildIndex(configs);
33
+ const noActive = new Set();
34
+ function exec(command) {
35
+ return { toolName: "exec", params: { command } };
36
+ }
37
+ describe("pathEndsWith / tokenize / commandHead", () => {
38
+ it("脚本路径按段对齐,规避 fooX.py 误判", () => {
39
+ assert.equal(pathEndsWith("/a/b/scripts/model_usage.py", "scripts/model_usage.py"), true);
40
+ assert.equal(pathEndsWith("scripts/model_usage.py", "scripts/model_usage.py"), true);
41
+ assert.equal(pathEndsWith("/a/xscripts/model_usage.py", "scripts/model_usage.py"), false);
42
+ assert.equal(pathEndsWith("/a/foomodel_usage.py", "model_usage.py"), false);
43
+ });
44
+ it("tokenize 处理引号", () => {
45
+ assert.deepEqual(tokenize(`a "b c" 'd e' f`), ["a", "b c", "d e", "f"]);
46
+ });
47
+ it("commandHead 跳过环境前缀与解释器", () => {
48
+ assert.equal(commandHead(tokenize("FOO=1 python /x/scripts/y.py --a")), "y.py");
49
+ assert.equal(commandHead(tokenize("mcporter call linear.list_issues")), "mcporter");
50
+ });
51
+ });
52
+ describe("parseFlags / parseKeyValues", () => {
53
+ it("--k v / --k=v / 布尔 flag", () => {
54
+ const f = parseFlags(tokenize("x --mode current --pretty --n=3 -v"));
55
+ assert.equal(f.mode, "current");
56
+ assert.equal(f.pretty, true);
57
+ assert.equal(f.n, "3");
58
+ assert.equal(f.v, true);
59
+ });
60
+ it("k=v / k:v,跳过 URL", () => {
61
+ const kv = parseKeyValues(tokenize("call linear.list_issues team=ENG limit:5 url=https://x/y"));
62
+ assert.equal(kv.team, "ENG");
63
+ assert.equal(kv.limit, "5");
64
+ assert.equal(kv.url, undefined); // 含 :// 被跳过
65
+ });
66
+ });
67
+ describe("match - script", () => {
68
+ it("按 --mode 细分功能点", () => {
69
+ const cur = match(exec("python /e/skills/model-usage/scripts/model_usage.py --mode current"), noActive, index);
70
+ assert.equal(cur?.functionId, "usage_current");
71
+ assert.equal(cur?.matchType, "script");
72
+ assert.equal(cur?.args.mode, "current");
73
+ const all = match(exec("python /e/skills/model-usage/scripts/model_usage.py --mode all --pretty"), noActive, index);
74
+ assert.equal(all?.functionId, "usage_all");
75
+ });
76
+ it("argRules 不满足则不命中该功能点", () => {
77
+ const r = match(exec("python /e/scripts/model_usage.py --mode none"), noActive, index);
78
+ assert.equal(r, null);
79
+ });
80
+ it("解释器无关:bash 跑 .sh 命中", () => {
81
+ const r = match(exec("bash /e/openai-whisper-api/scripts/transcribe.sh a.mp3"), noActive, index);
82
+ assert.equal(r?.functionId, "transcribe");
83
+ });
84
+ });
85
+ describe("match - command / tool / http", () => {
86
+ it("command: mcporter call", () => {
87
+ const r = match(exec("mcporter call linear.list_issues team=ENG"), noActive, index);
88
+ assert.equal(r?.functionId, "list_issues");
89
+ assert.equal(r?.matchType, "command");
90
+ assert.equal(r?.args.team, "ENG");
91
+ });
92
+ it("tool: 非 exec 工具直调", () => {
93
+ const r = match({ toolName: "linear_create_issue", params: { title: "bug" } }, noActive, index);
94
+ assert.equal(r?.functionId, "create_issue");
95
+ assert.equal(r?.matchType, "tool");
96
+ assert.equal(r?.args.title, "bug");
97
+ });
98
+ it("http: curl 命中端点", () => {
99
+ const r = match(exec("curl -s https://api.openai.com/v1/audio/transcriptions -F file=@a.mp3"), noActive, index);
100
+ assert.equal(r?.functionId, "transcribe_http");
101
+ assert.equal(r?.matchType, "http");
102
+ });
103
+ it("http: fetch 类工具 url 参数命中", () => {
104
+ const r = match({ toolName: "fetch", params: { url: "https://api.openai.com/v1/audio/transcriptions?x=1" } }, noActive, index);
105
+ assert.equal(r?.functionId, "transcribe_http");
106
+ assert.equal(r?.args.x, "1");
107
+ });
108
+ it("无匹配返回 null", () => {
109
+ assert.equal(match(exec("ls -la"), noActive, index), null);
110
+ assert.equal(match({ toolName: "unknown_tool", params: {} }, noActive, index), null);
111
+ });
112
+ });
113
+ describe("match - 弱匹配(cd 后相对路径)仅在 skill 激活时采纳", () => {
114
+ it("仅 basename 相同:未激活 → null,激活 → 命中", () => {
115
+ // `cd .../openai-whisper-api/scripts && bash transcribe.sh a.mp3`
116
+ const call = exec("bash transcribe.sh a.mp3");
117
+ assert.equal(match(call, noActive, index), null);
118
+ const r = match(call, new Set(["openai-whisper-api"]), index);
119
+ assert.equal(r?.functionId, "transcribe");
120
+ });
121
+ it("强匹配优先于弱匹配", () => {
122
+ // 同时出现强(全路径)与弱(裸名)token 时取强匹配
123
+ const r = match(exec("bash /e/openai-whisper-api/scripts/transcribe.sh"), noActive, index);
124
+ assert.equal(r?.functionId, "transcribe");
125
+ });
126
+ });
127
+ describe("match - 歧义消解优先 activeSkills", () => {
128
+ const ambConfigs = [
129
+ { skillName: "A", version: "1", functions: [{ id: "a", name: "a", match: { type: "tool", toolNamePrefix: "x_" } }] },
130
+ { skillName: "B", version: "1", functions: [{ id: "b", name: "b", match: { type: "tool", toolNamePrefix: "x_" } }] },
131
+ ];
132
+ const ambIndex = buildIndex(ambConfigs);
133
+ it("无激活时取第一候选", () => {
134
+ assert.equal(match({ toolName: "x_do", params: {} }, noActive, ambIndex)?.skillName, "A");
135
+ });
136
+ it("激活 B 时归属 B", () => {
137
+ assert.equal(match({ toolName: "x_do", params: {} }, new Set(["B"]), ambIndex)?.skillName, "B");
138
+ });
139
+ });