@spzhongwin/skill-logger-plugin 1.0.11 → 1.0.13

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 (54) 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 +240 -78
  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 -50
  26. package/package.json +37 -37
  27. package/src/active-skills.test.ts +32 -32
  28. package/src/active-skills.ts +77 -77
  29. package/src/config-sync.test.ts +165 -165
  30. package/src/config-sync.ts +544 -544
  31. package/src/hooks.test.ts +251 -251
  32. package/src/hooks.ts +517 -517
  33. package/src/http.ts +61 -61
  34. package/src/identity.ts +64 -64
  35. package/src/index.test.ts +53 -53
  36. package/src/index.ts +226 -226
  37. package/src/integration.test.ts +119 -119
  38. package/src/matcher.test.ts +170 -170
  39. package/src/matcher.ts +393 -393
  40. package/src/paths.test.ts +57 -57
  41. package/src/paths.ts +84 -84
  42. package/src/reporter.test.ts +139 -139
  43. package/src/reporter.ts +298 -298
  44. package/src/sample-config.json +72 -72
  45. package/src/semver.test.ts +23 -23
  46. package/src/semver.ts +60 -60
  47. package/src/skill-version.ts +53 -53
  48. package/src/types.ts +198 -198
  49. package/src/updater.test.ts +325 -237
  50. package/src/updater.ts +549 -433
  51. package/src/ws-client.test.ts +48 -37
  52. package/src/ws-client.ts +717 -642
  53. package/test-ws.ts +17 -17
  54. package/tsconfig.json +14 -14
@@ -1,119 +1,119 @@
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
- import type { PluginConfig } from "./types.ts";
10
-
11
- const idHash = (code: string, version: string) =>
12
- createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
13
-
14
- let dir: string;
15
-
16
- beforeEach(async () => {
17
- dir = path.join(os.tmpdir(), `slp-int-${Date.now()}-${Math.random().toString(36).slice(2)}`);
18
- await fs.mkdir(dir, { recursive: true });
19
- });
20
- afterEach(async () => {
21
- await fs.rm(dir, { recursive: true, force: true });
22
- });
23
-
24
- describe("版本更新闭环(端到端)", () => {
25
- it("config-pull 报最新版 → 检测落后 → 下载覆盖 → 再查收敛", async () => {
26
- // 本地装一个 demo@1.0.0
27
- const ws = path.join(dir, "workspace");
28
- const skillDir = path.join(ws, "skills", "demo");
29
- await fs.mkdir(skillDir, { recursive: true });
30
- await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
31
-
32
- const config: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
33
-
34
- // 服务端最新版本(随测试推进而变化),模拟「更新后平台版本=本地版本」的收敛。
35
- let platformLatest = "2.0.0";
36
-
37
- // ConfigSync 的 fetch:/skill_config/pull → 带 latestVersion
38
- const csFetch = async (_url: string, _init?: any) => ({
39
- ok: true,
40
- status: 200,
41
- json: async () => ({ configs: [{ skillName: "demo", version: "1.0.0", latestVersion: platformLatest, functions: [] }] }),
42
- });
43
-
44
- // Updater 的 fetch:/skill_package/pull → {url, sha256};GET → zip 字节
45
- const upFetch = async (_url: string, init?: any) => {
46
- if (init?.method === "POST") {
47
- return {
48
- ok: true,
49
- status: 200,
50
- json: async () => ({ url: "https://pkg/skill.zip", version: platformLatest, sha256: idHash("demo", platformLatest) }),
51
- arrayBuffer: async () => new ArrayBuffer(0),
52
- };
53
- }
54
- return { ok: true, status: 200, json: async () => ({}), arrayBuffer: async () => new Uint8Array([1]).buffer };
55
- };
56
- // 解压:写出新版本的 SKILL.md(版本号与 platformLatest 一致)
57
- const unzip = async (_zip: string, destDir: string) => {
58
- const root = path.join(destDir, "demo");
59
- await fs.mkdir(root, { recursive: true });
60
- await fs.writeFile(path.join(root, "SKILL.md"), `---\nname: demo\nversion: ${platformLatest}\n---\nNEW\n`);
61
- };
62
-
63
- const updater = new SkillUpdater({ getConfig: () => config, fetchImpl: upFetch, unzip, tmpDir: dir });
64
- const cs = new ConfigSync({
65
- paths: { extensionsDir: path.join(dir, "ext"), syncStatePath: path.join(dir, "sync.json"), openclawConfigPath: path.join(dir, "openclaw.json") },
66
- getConfig: () => config,
67
- fetchImpl: csFetch as any,
68
- resolveSkillDirs: () => [path.join(ws, "skills")],
69
- updater,
70
- });
71
-
72
- // 第一轮:检测落后并覆盖到 2.0.0
73
- await cs.checkVersionsAndUpdate();
74
- const afterMd = await fs.readFile(path.join(skillDir, "SKILL.md"), "utf-8");
75
- assert.ok(afterMd.includes("version: 2.0.0"), "应被更新到 2.0.0");
76
- assert.ok(afterMd.includes("NEW"), "应为新内容");
77
-
78
- // 第二轮:平台版本仍 2.0.0、本地已 2.0.0 → 收敛,无落后副本
79
- platformLatest = "2.0.0";
80
- await cs.checkVersionsAndUpdate();
81
- assert.equal(cs.detectOutdated().length, 0, "更新后应收敛");
82
-
83
- // sync.json 落了 installations 映射,且版本已是 2.0.0
84
- const state = JSON.parse(await fs.readFile(path.join(dir, "sync.json"), "utf-8"));
85
- assert.equal(state.installations.demo[0].version, "2.0.0");
86
- });
87
-
88
- it("autoUpdateSkills=false:检测到落后也不覆盖", async () => {
89
- const ws = path.join(dir, "workspace");
90
- const skillDir = path.join(ws, "skills", "demo");
91
- await fs.mkdir(skillDir, { recursive: true });
92
- await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
93
-
94
- const config: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: false };
95
- const csFetch = async () => ({
96
- ok: true,
97
- status: 200,
98
- json: async () => ({ configs: [{ skillName: "demo", version: "1.0.0", latestVersion: "2.0.0", functions: [] }] }),
99
- });
100
- let upCalled = 0;
101
- const upFetch = async () => {
102
- upCalled++;
103
- return { ok: true, status: 200, json: async () => ({ url: "" }), arrayBuffer: async () => new ArrayBuffer(0) };
104
- };
105
- const updater = new SkillUpdater({ getConfig: () => config, fetchImpl: upFetch, unzip: async () => {}, tmpDir: dir });
106
- const cs = new ConfigSync({
107
- paths: { extensionsDir: path.join(dir, "ext"), syncStatePath: path.join(dir, "sync.json"), openclawConfigPath: path.join(dir, "openclaw.json") },
108
- getConfig: () => config,
109
- fetchImpl: csFetch as any,
110
- resolveSkillDirs: () => [path.join(ws, "skills")],
111
- updater,
112
- });
113
-
114
- await cs.checkVersionsAndUpdate();
115
- assert.equal(cs.detectOutdated().length, 1, "仍检测到落后");
116
- assert.equal(upCalled, 0, "关闭时不应发起下载");
117
- assert.ok((await fs.readFile(path.join(skillDir, "SKILL.md"), "utf-8")).includes("OLD"), "文件保持原样");
118
- });
119
- });
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
+ import type { PluginConfig } from "./types.ts";
10
+
11
+ const idHash = (code: string, version: string) =>
12
+ createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
13
+
14
+ let dir: string;
15
+
16
+ beforeEach(async () => {
17
+ dir = path.join(os.tmpdir(), `slp-int-${Date.now()}-${Math.random().toString(36).slice(2)}`);
18
+ await fs.mkdir(dir, { recursive: true });
19
+ });
20
+ afterEach(async () => {
21
+ await fs.rm(dir, { recursive: true, force: true });
22
+ });
23
+
24
+ describe("版本更新闭环(端到端)", () => {
25
+ it("config-pull 报最新版 → 检测落后 → 下载覆盖 → 再查收敛", async () => {
26
+ // 本地装一个 demo@1.0.0
27
+ const ws = path.join(dir, "workspace");
28
+ const skillDir = path.join(ws, "skills", "demo");
29
+ await fs.mkdir(skillDir, { recursive: true });
30
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
31
+
32
+ const config: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
33
+
34
+ // 服务端最新版本(随测试推进而变化),模拟「更新后平台版本=本地版本」的收敛。
35
+ let platformLatest = "2.0.0";
36
+
37
+ // ConfigSync 的 fetch:/skill_config/pull → 带 latestVersion
38
+ const csFetch = async (_url: string, _init?: any) => ({
39
+ ok: true,
40
+ status: 200,
41
+ json: async () => ({ configs: [{ skillName: "demo", version: "1.0.0", latestVersion: platformLatest, functions: [] }] }),
42
+ });
43
+
44
+ // Updater 的 fetch:/skill_package/pull → {url, sha256};GET → zip 字节
45
+ const upFetch = async (_url: string, init?: any) => {
46
+ if (init?.method === "POST") {
47
+ return {
48
+ ok: true,
49
+ status: 200,
50
+ json: async () => ({ url: "https://pkg/skill.zip", version: platformLatest, sha256: idHash("demo", platformLatest) }),
51
+ arrayBuffer: async () => new ArrayBuffer(0),
52
+ };
53
+ }
54
+ return { ok: true, status: 200, json: async () => ({}), arrayBuffer: async () => new Uint8Array([1]).buffer };
55
+ };
56
+ // 解压:写出新版本的 SKILL.md(版本号与 platformLatest 一致)
57
+ const unzip = async (_zip: string, destDir: string) => {
58
+ const root = path.join(destDir, "demo");
59
+ await fs.mkdir(root, { recursive: true });
60
+ await fs.writeFile(path.join(root, "SKILL.md"), `---\nname: demo\nversion: ${platformLatest}\n---\nNEW\n`);
61
+ };
62
+
63
+ const updater = new SkillUpdater({ getConfig: () => config, fetchImpl: upFetch, unzip, tmpDir: dir });
64
+ const cs = new ConfigSync({
65
+ paths: { extensionsDir: path.join(dir, "ext"), syncStatePath: path.join(dir, "sync.json"), openclawConfigPath: path.join(dir, "openclaw.json") },
66
+ getConfig: () => config,
67
+ fetchImpl: csFetch as any,
68
+ resolveSkillDirs: () => [path.join(ws, "skills")],
69
+ updater,
70
+ });
71
+
72
+ // 第一轮:检测落后并覆盖到 2.0.0
73
+ await cs.checkVersionsAndUpdate();
74
+ const afterMd = await fs.readFile(path.join(skillDir, "SKILL.md"), "utf-8");
75
+ assert.ok(afterMd.includes("version: 2.0.0"), "应被更新到 2.0.0");
76
+ assert.ok(afterMd.includes("NEW"), "应为新内容");
77
+
78
+ // 第二轮:平台版本仍 2.0.0、本地已 2.0.0 → 收敛,无落后副本
79
+ platformLatest = "2.0.0";
80
+ await cs.checkVersionsAndUpdate();
81
+ assert.equal(cs.detectOutdated().length, 0, "更新后应收敛");
82
+
83
+ // sync.json 落了 installations 映射,且版本已是 2.0.0
84
+ const state = JSON.parse(await fs.readFile(path.join(dir, "sync.json"), "utf-8"));
85
+ assert.equal(state.installations.demo[0].version, "2.0.0");
86
+ });
87
+
88
+ it("autoUpdateSkills=false:检测到落后也不覆盖", async () => {
89
+ const ws = path.join(dir, "workspace");
90
+ const skillDir = path.join(ws, "skills", "demo");
91
+ await fs.mkdir(skillDir, { recursive: true });
92
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
93
+
94
+ const config: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: false };
95
+ const csFetch = async () => ({
96
+ ok: true,
97
+ status: 200,
98
+ json: async () => ({ configs: [{ skillName: "demo", version: "1.0.0", latestVersion: "2.0.0", functions: [] }] }),
99
+ });
100
+ let upCalled = 0;
101
+ const upFetch = async () => {
102
+ upCalled++;
103
+ return { ok: true, status: 200, json: async () => ({ url: "" }), arrayBuffer: async () => new ArrayBuffer(0) };
104
+ };
105
+ const updater = new SkillUpdater({ getConfig: () => config, fetchImpl: upFetch, unzip: async () => {}, tmpDir: dir });
106
+ const cs = new ConfigSync({
107
+ paths: { extensionsDir: path.join(dir, "ext"), syncStatePath: path.join(dir, "sync.json"), openclawConfigPath: path.join(dir, "openclaw.json") },
108
+ getConfig: () => config,
109
+ fetchImpl: csFetch as any,
110
+ resolveSkillDirs: () => [path.join(ws, "skills")],
111
+ updater,
112
+ });
113
+
114
+ await cs.checkVersionsAndUpdate();
115
+ assert.equal(cs.detectOutdated().length, 1, "仍检测到落后");
116
+ assert.equal(upCalled, 0, "关闭时不应发起下载");
117
+ assert.ok((await fs.readFile(path.join(skillDir, "SKILL.md"), "utf-8")).includes("OLD"), "文件保持原样");
118
+ });
119
+ });
@@ -1,170 +1,170 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import {
4
- buildIndex,
5
- match,
6
- parseFlags,
7
- parseKeyValues,
8
- pathEndsWith,
9
- commandHead,
10
- tokenize,
11
- } from "./matcher.ts";
12
- import type { SkillStandardConfig, ToolCall } from "./types.ts";
13
-
14
- const configs: SkillStandardConfig[] = [
15
- {
16
- skillName: "model-usage",
17
- version: "1.0.0",
18
- functions: [
19
- { id: "usage_current", name: "当前用量", match: { type: "script", script: "scripts/model_usage.py", argRules: [{ flag: "--mode", value: "current" }] } },
20
- { id: "usage_all", name: "全部用量", match: { type: "script", script: "scripts/model_usage.py", argRules: [{ flag: "--mode", value: "all" }] } },
21
- ],
22
- },
23
- {
24
- skillName: "openai-whisper-api",
25
- version: "1.0.0",
26
- functions: [{ id: "transcribe", name: "转写", match: { type: "script", script: "scripts/transcribe.sh" } }],
27
- },
28
- {
29
- skillName: "mcporter",
30
- version: "1.0.0",
31
- functions: [{ id: "list_issues", name: "列issue", match: { type: "command", command: "mcporter", targetPattern: "call linear.list_issues" } }],
32
- },
33
- {
34
- skillName: "linear-mcp",
35
- version: "1.0.0",
36
- functions: [
37
- { id: "create_issue", name: "建issue", match: { type: "tool", toolName: "linear_create_issue" } },
38
- { id: "transcribe_http", name: "转写端点", match: { type: "http", urlContains: "/v1/audio/transcriptions" } },
39
- ],
40
- },
41
- ];
42
-
43
- const index = buildIndex(configs);
44
- const noActive = new Set<string>();
45
-
46
- function exec(command: string): ToolCall {
47
- return { toolName: "exec", params: { command } };
48
- }
49
-
50
- describe("pathEndsWith / tokenize / commandHead", () => {
51
- it("脚本路径按段对齐,规避 fooX.py 误判", () => {
52
- assert.equal(pathEndsWith("/a/b/scripts/model_usage.py", "scripts/model_usage.py"), true);
53
- assert.equal(pathEndsWith("scripts/model_usage.py", "scripts/model_usage.py"), true);
54
- assert.equal(pathEndsWith("/a/xscripts/model_usage.py", "scripts/model_usage.py"), false);
55
- assert.equal(pathEndsWith("/a/foomodel_usage.py", "model_usage.py"), false);
56
- });
57
-
58
- it("tokenize 处理引号", () => {
59
- assert.deepEqual(tokenize(`a "b c" 'd e' f`), ["a", "b c", "d e", "f"]);
60
- });
61
-
62
- it("commandHead 跳过环境前缀与解释器", () => {
63
- assert.equal(commandHead(tokenize("FOO=1 python /x/scripts/y.py --a")), "y.py");
64
- assert.equal(commandHead(tokenize("mcporter call linear.list_issues")), "mcporter");
65
- });
66
- });
67
-
68
- describe("parseFlags / parseKeyValues", () => {
69
- it("--k v / --k=v / 布尔 flag", () => {
70
- const f = parseFlags(tokenize("x --mode current --pretty --n=3 -v"));
71
- assert.equal(f.mode, "current");
72
- assert.equal(f.pretty, true);
73
- assert.equal(f.n, "3");
74
- assert.equal(f.v, true);
75
- });
76
-
77
- it("k=v / k:v,跳过 URL", () => {
78
- const kv = parseKeyValues(tokenize("call linear.list_issues team=ENG limit:5 url=https://x/y"));
79
- assert.equal(kv.team, "ENG");
80
- assert.equal(kv.limit, "5");
81
- assert.equal(kv.url, undefined); // 含 :// 被跳过
82
- });
83
- });
84
-
85
- describe("match - script", () => {
86
- it("按 --mode 细分功能点", () => {
87
- const cur = match(exec("python /e/skills/model-usage/scripts/model_usage.py --mode current"), noActive, index);
88
- assert.equal(cur?.functionId, "usage_current");
89
- assert.equal(cur?.matchType, "script");
90
- assert.equal(cur?.args.mode, "current");
91
-
92
- const all = match(exec("python /e/skills/model-usage/scripts/model_usage.py --mode all --pretty"), noActive, index);
93
- assert.equal(all?.functionId, "usage_all");
94
- });
95
-
96
- it("argRules 不满足则不命中该功能点", () => {
97
- const r = match(exec("python /e/scripts/model_usage.py --mode none"), noActive, index);
98
- assert.equal(r, null);
99
- });
100
-
101
- it("解释器无关:bash 跑 .sh 命中", () => {
102
- const r = match(exec("bash /e/openai-whisper-api/scripts/transcribe.sh a.mp3"), noActive, index);
103
- assert.equal(r?.functionId, "transcribe");
104
- });
105
- });
106
-
107
- describe("match - command / tool / http", () => {
108
- it("command: mcporter call", () => {
109
- const r = match(exec("mcporter call linear.list_issues team=ENG"), noActive, index);
110
- assert.equal(r?.functionId, "list_issues");
111
- assert.equal(r?.matchType, "command");
112
- assert.equal(r?.args.team, "ENG");
113
- });
114
-
115
- it("tool: 非 exec 工具直调", () => {
116
- const r = match({ toolName: "linear_create_issue", params: { title: "bug" } }, noActive, index);
117
- assert.equal(r?.functionId, "create_issue");
118
- assert.equal(r?.matchType, "tool");
119
- assert.equal(r?.args.title, "bug");
120
- });
121
-
122
- it("http: curl 命中端点", () => {
123
- const r = match(exec("curl -s https://api.openai.com/v1/audio/transcriptions -F file=@a.mp3"), noActive, index);
124
- assert.equal(r?.functionId, "transcribe_http");
125
- assert.equal(r?.matchType, "http");
126
- });
127
-
128
- it("http: fetch 类工具 url 参数命中", () => {
129
- const r = match({ toolName: "fetch", params: { url: "https://api.openai.com/v1/audio/transcriptions?x=1" } }, noActive, index);
130
- assert.equal(r?.functionId, "transcribe_http");
131
- assert.equal(r?.args.x, "1");
132
- });
133
-
134
- it("无匹配返回 null", () => {
135
- assert.equal(match(exec("ls -la"), noActive, index), null);
136
- assert.equal(match({ toolName: "unknown_tool", params: {} }, noActive, index), null);
137
- });
138
- });
139
-
140
- describe("match - 弱匹配(cd 后相对路径)仅在 skill 激活时采纳", () => {
141
- it("仅 basename 相同:未激活 → null,激活 → 命中", () => {
142
- // `cd .../openai-whisper-api/scripts && bash transcribe.sh a.mp3`
143
- const call = exec("bash transcribe.sh a.mp3");
144
- assert.equal(match(call, noActive, index), null);
145
- const r = match(call, new Set(["openai-whisper-api"]), index);
146
- assert.equal(r?.functionId, "transcribe");
147
- });
148
-
149
- it("强匹配优先于弱匹配", () => {
150
- // 同时出现强(全路径)与弱(裸名)token 时取强匹配
151
- const r = match(exec("bash /e/openai-whisper-api/scripts/transcribe.sh"), noActive, index);
152
- assert.equal(r?.functionId, "transcribe");
153
- });
154
- });
155
-
156
- describe("match - 歧义消解优先 activeSkills", () => {
157
- const ambConfigs: SkillStandardConfig[] = [
158
- { skillName: "A", version: "1", functions: [{ id: "a", name: "a", match: { type: "tool", toolNamePrefix: "x_" } }] },
159
- { skillName: "B", version: "1", functions: [{ id: "b", name: "b", match: { type: "tool", toolNamePrefix: "x_" } }] },
160
- ];
161
- const ambIndex = buildIndex(ambConfigs);
162
-
163
- it("无激活时取第一候选", () => {
164
- assert.equal(match({ toolName: "x_do", params: {} }, noActive, ambIndex)?.skillName, "A");
165
- });
166
-
167
- it("激活 B 时归属 B", () => {
168
- assert.equal(match({ toolName: "x_do", params: {} }, new Set(["B"]), ambIndex)?.skillName, "B");
169
- });
170
- });
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ buildIndex,
5
+ match,
6
+ parseFlags,
7
+ parseKeyValues,
8
+ pathEndsWith,
9
+ commandHead,
10
+ tokenize,
11
+ } from "./matcher.ts";
12
+ import type { SkillStandardConfig, ToolCall } from "./types.ts";
13
+
14
+ const configs: SkillStandardConfig[] = [
15
+ {
16
+ skillName: "model-usage",
17
+ version: "1.0.0",
18
+ functions: [
19
+ { id: "usage_current", name: "当前用量", match: { type: "script", script: "scripts/model_usage.py", argRules: [{ flag: "--mode", value: "current" }] } },
20
+ { id: "usage_all", name: "全部用量", match: { type: "script", script: "scripts/model_usage.py", argRules: [{ flag: "--mode", value: "all" }] } },
21
+ ],
22
+ },
23
+ {
24
+ skillName: "openai-whisper-api",
25
+ version: "1.0.0",
26
+ functions: [{ id: "transcribe", name: "转写", match: { type: "script", script: "scripts/transcribe.sh" } }],
27
+ },
28
+ {
29
+ skillName: "mcporter",
30
+ version: "1.0.0",
31
+ functions: [{ id: "list_issues", name: "列issue", match: { type: "command", command: "mcporter", targetPattern: "call linear.list_issues" } }],
32
+ },
33
+ {
34
+ skillName: "linear-mcp",
35
+ version: "1.0.0",
36
+ functions: [
37
+ { id: "create_issue", name: "建issue", match: { type: "tool", toolName: "linear_create_issue" } },
38
+ { id: "transcribe_http", name: "转写端点", match: { type: "http", urlContains: "/v1/audio/transcriptions" } },
39
+ ],
40
+ },
41
+ ];
42
+
43
+ const index = buildIndex(configs);
44
+ const noActive = new Set<string>();
45
+
46
+ function exec(command: string): ToolCall {
47
+ return { toolName: "exec", params: { command } };
48
+ }
49
+
50
+ describe("pathEndsWith / tokenize / commandHead", () => {
51
+ it("脚本路径按段对齐,规避 fooX.py 误判", () => {
52
+ assert.equal(pathEndsWith("/a/b/scripts/model_usage.py", "scripts/model_usage.py"), true);
53
+ assert.equal(pathEndsWith("scripts/model_usage.py", "scripts/model_usage.py"), true);
54
+ assert.equal(pathEndsWith("/a/xscripts/model_usage.py", "scripts/model_usage.py"), false);
55
+ assert.equal(pathEndsWith("/a/foomodel_usage.py", "model_usage.py"), false);
56
+ });
57
+
58
+ it("tokenize 处理引号", () => {
59
+ assert.deepEqual(tokenize(`a "b c" 'd e' f`), ["a", "b c", "d e", "f"]);
60
+ });
61
+
62
+ it("commandHead 跳过环境前缀与解释器", () => {
63
+ assert.equal(commandHead(tokenize("FOO=1 python /x/scripts/y.py --a")), "y.py");
64
+ assert.equal(commandHead(tokenize("mcporter call linear.list_issues")), "mcporter");
65
+ });
66
+ });
67
+
68
+ describe("parseFlags / parseKeyValues", () => {
69
+ it("--k v / --k=v / 布尔 flag", () => {
70
+ const f = parseFlags(tokenize("x --mode current --pretty --n=3 -v"));
71
+ assert.equal(f.mode, "current");
72
+ assert.equal(f.pretty, true);
73
+ assert.equal(f.n, "3");
74
+ assert.equal(f.v, true);
75
+ });
76
+
77
+ it("k=v / k:v,跳过 URL", () => {
78
+ const kv = parseKeyValues(tokenize("call linear.list_issues team=ENG limit:5 url=https://x/y"));
79
+ assert.equal(kv.team, "ENG");
80
+ assert.equal(kv.limit, "5");
81
+ assert.equal(kv.url, undefined); // 含 :// 被跳过
82
+ });
83
+ });
84
+
85
+ describe("match - script", () => {
86
+ it("按 --mode 细分功能点", () => {
87
+ const cur = match(exec("python /e/skills/model-usage/scripts/model_usage.py --mode current"), noActive, index);
88
+ assert.equal(cur?.functionId, "usage_current");
89
+ assert.equal(cur?.matchType, "script");
90
+ assert.equal(cur?.args.mode, "current");
91
+
92
+ const all = match(exec("python /e/skills/model-usage/scripts/model_usage.py --mode all --pretty"), noActive, index);
93
+ assert.equal(all?.functionId, "usage_all");
94
+ });
95
+
96
+ it("argRules 不满足则不命中该功能点", () => {
97
+ const r = match(exec("python /e/scripts/model_usage.py --mode none"), noActive, index);
98
+ assert.equal(r, null);
99
+ });
100
+
101
+ it("解释器无关:bash 跑 .sh 命中", () => {
102
+ const r = match(exec("bash /e/openai-whisper-api/scripts/transcribe.sh a.mp3"), noActive, index);
103
+ assert.equal(r?.functionId, "transcribe");
104
+ });
105
+ });
106
+
107
+ describe("match - command / tool / http", () => {
108
+ it("command: mcporter call", () => {
109
+ const r = match(exec("mcporter call linear.list_issues team=ENG"), noActive, index);
110
+ assert.equal(r?.functionId, "list_issues");
111
+ assert.equal(r?.matchType, "command");
112
+ assert.equal(r?.args.team, "ENG");
113
+ });
114
+
115
+ it("tool: 非 exec 工具直调", () => {
116
+ const r = match({ toolName: "linear_create_issue", params: { title: "bug" } }, noActive, index);
117
+ assert.equal(r?.functionId, "create_issue");
118
+ assert.equal(r?.matchType, "tool");
119
+ assert.equal(r?.args.title, "bug");
120
+ });
121
+
122
+ it("http: curl 命中端点", () => {
123
+ const r = match(exec("curl -s https://api.openai.com/v1/audio/transcriptions -F file=@a.mp3"), noActive, index);
124
+ assert.equal(r?.functionId, "transcribe_http");
125
+ assert.equal(r?.matchType, "http");
126
+ });
127
+
128
+ it("http: fetch 类工具 url 参数命中", () => {
129
+ const r = match({ toolName: "fetch", params: { url: "https://api.openai.com/v1/audio/transcriptions?x=1" } }, noActive, index);
130
+ assert.equal(r?.functionId, "transcribe_http");
131
+ assert.equal(r?.args.x, "1");
132
+ });
133
+
134
+ it("无匹配返回 null", () => {
135
+ assert.equal(match(exec("ls -la"), noActive, index), null);
136
+ assert.equal(match({ toolName: "unknown_tool", params: {} }, noActive, index), null);
137
+ });
138
+ });
139
+
140
+ describe("match - 弱匹配(cd 后相对路径)仅在 skill 激活时采纳", () => {
141
+ it("仅 basename 相同:未激活 → null,激活 → 命中", () => {
142
+ // `cd .../openai-whisper-api/scripts && bash transcribe.sh a.mp3`
143
+ const call = exec("bash transcribe.sh a.mp3");
144
+ assert.equal(match(call, noActive, index), null);
145
+ const r = match(call, new Set(["openai-whisper-api"]), index);
146
+ assert.equal(r?.functionId, "transcribe");
147
+ });
148
+
149
+ it("强匹配优先于弱匹配", () => {
150
+ // 同时出现强(全路径)与弱(裸名)token 时取强匹配
151
+ const r = match(exec("bash /e/openai-whisper-api/scripts/transcribe.sh"), noActive, index);
152
+ assert.equal(r?.functionId, "transcribe");
153
+ });
154
+ });
155
+
156
+ describe("match - 歧义消解优先 activeSkills", () => {
157
+ const ambConfigs: SkillStandardConfig[] = [
158
+ { skillName: "A", version: "1", functions: [{ id: "a", name: "a", match: { type: "tool", toolNamePrefix: "x_" } }] },
159
+ { skillName: "B", version: "1", functions: [{ id: "b", name: "b", match: { type: "tool", toolNamePrefix: "x_" } }] },
160
+ ];
161
+ const ambIndex = buildIndex(ambConfigs);
162
+
163
+ it("无激活时取第一候选", () => {
164
+ assert.equal(match({ toolName: "x_do", params: {} }, noActive, ambIndex)?.skillName, "A");
165
+ });
166
+
167
+ it("激活 B 时归属 B", () => {
168
+ assert.equal(match({ toolName: "x_do", params: {} }, new Set(["B"]), ambIndex)?.skillName, "B");
169
+ });
170
+ });