@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.
- package/dist/active-skills.js +67 -0
- package/dist/active-skills.test.js +29 -0
- package/dist/config-sync.js +439 -0
- package/dist/config-sync.test.js +145 -0
- package/dist/hooks.js +337 -0
- package/dist/hooks.test.js +123 -0
- package/dist/http.js +54 -0
- package/dist/identity.js +56 -0
- package/dist/index.js +2286 -0
- package/dist/index.test.js +39 -0
- package/dist/integration.test.js +102 -0
- package/dist/matcher.js +362 -0
- package/dist/matcher.test.js +139 -0
- package/dist/paths.js +62 -0
- package/dist/paths.test.js +49 -0
- package/dist/reporter.js +267 -0
- package/dist/reporter.test.js +128 -0
- package/dist/semver.js +64 -0
- package/dist/semver.test.js +21 -0
- package/dist/skill-version.js +23 -0
- package/dist/types.js +9 -0
- package/dist/updater.js +352 -0
- package/dist/updater.test.js +212 -0
- package/dist/ws-client.js +484 -0
- package/openclaw.plugin.json +50 -0
- package/package.json +35 -0
- package/src/active-skills.test.ts +32 -0
- package/src/active-skills.ts +77 -0
- package/src/config-sync.test.ts +165 -0
- package/src/config-sync.ts +485 -0
- package/src/hooks.test.ts +156 -0
- package/src/hooks.ts +405 -0
- package/src/http.ts +61 -0
- package/src/identity.ts +64 -0
- package/src/index.test.ts +53 -0
- package/src/index.ts +226 -0
- package/src/integration.test.ts +119 -0
- package/src/matcher.test.ts +170 -0
- package/src/matcher.ts +393 -0
- package/src/paths.test.ts +57 -0
- package/src/paths.ts +84 -0
- package/src/reporter.test.ts +139 -0
- package/src/reporter.ts +298 -0
- package/src/sample-config.json +72 -0
- package/src/semver.test.ts +23 -0
- package/src/semver.ts +60 -0
- package/src/skill-version.ts +22 -0
- package/src/types.ts +198 -0
- package/src/updater.test.ts +237 -0
- package/src/updater.ts +400 -0
- package/src/ws-client.ts +516 -0
- package/test-ws.ts +17 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, before } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
let isSkillMdReadPath: (filePath: string) => boolean;
|
|
5
|
+
let extractApiPluginConfig: (api: { pluginConfig?: Record<string, unknown> }) => Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
describe("isSkillMdReadPath", () => {
|
|
8
|
+
before(async () => {
|
|
9
|
+
({ isSkillMdReadPath, extractApiPluginConfig } = await import("./index.ts"));
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("POSIX 路径 basename 为 SKILL.md 时为 true", () => {
|
|
13
|
+
assert.equal(isSkillMdReadPath("/x/y/my-skill/SKILL.md"), true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it(
|
|
17
|
+
"Windows 风格路径",
|
|
18
|
+
{ skip: process.platform !== "win32" },
|
|
19
|
+
() => {
|
|
20
|
+
assert.equal(isSkillMdReadPath(String.raw`C:\x\y\my-skill\SKILL.md`), true);
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
it("fooSKILL.md / my-SKILL.md 不误判", () => {
|
|
25
|
+
assert.equal(isSkillMdReadPath("/tmp/fooSKILL.md"), false);
|
|
26
|
+
assert.equal(isSkillMdReadPath("/tmp/my-SKILL.md"), false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("extractApiPluginConfig", () => {
|
|
31
|
+
it("从 OpenClaw 注入的 api.pluginConfig 初始化运行期配置", () => {
|
|
32
|
+
assert.deepEqual(
|
|
33
|
+
extractApiPluginConfig({
|
|
34
|
+
pluginConfig: {
|
|
35
|
+
platformBaseUrl: "https://platform.example/api",
|
|
36
|
+
reportBaseUrl: "https://report.example/api",
|
|
37
|
+
authToken: "Bearer token",
|
|
38
|
+
recordUnattributed: true,
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
{
|
|
42
|
+
platformBaseUrl: "https://platform.example/api",
|
|
43
|
+
reportBaseUrl: "https://report.example/api",
|
|
44
|
+
authToken: "Bearer token",
|
|
45
|
+
recordUnattributed: true,
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("未配置时返回空对象", () => {
|
|
51
|
+
assert.deepEqual(extractApiPluginConfig({}), {});
|
|
52
|
+
});
|
|
53
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw 插件入口(瘦装配层)。
|
|
3
|
+
*
|
|
4
|
+
* 把各职责模块实例化并注册到 openclaw 的生命周期 hook:
|
|
5
|
+
* - before_tool_call / after_tool_call:观测工具调用,记 skill_trigger / function_call
|
|
6
|
+
* - gateway_start:加载配置、首次对账、起 3 分钟定时器(上报 + 配置对账)
|
|
7
|
+
* - gateway_stop:停定时器、尽力最后上报一次
|
|
8
|
+
* - before_install:skill/plugin 安装后重新对账,拉新配置
|
|
9
|
+
*
|
|
10
|
+
* 配置(上报地址/鉴权等)优先从插件 API 初始化配置读取,也兼容 hook 的
|
|
11
|
+
* `event.context.pluginConfig` / `ctx.pluginConfig` 增量注入。
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import type { PluginConfig } from "./types.ts";
|
|
18
|
+
import { resolvePaths } from "./paths.ts";
|
|
19
|
+
import { ActiveSkills } from "./active-skills.ts";
|
|
20
|
+
import { SkillUpdater } from "./updater.ts";
|
|
21
|
+
import { ConfigSync } from "./config-sync.ts";
|
|
22
|
+
import { Reporter } from "./reporter.ts";
|
|
23
|
+
|
|
24
|
+
// 启动长连接中枢
|
|
25
|
+
import { GatewayWsClient } from "./ws-client.ts";
|
|
26
|
+
let wsClient: GatewayWsClient | undefined;
|
|
27
|
+
|
|
28
|
+
import { Hooks, isSkillMdReadPath } from "./hooks.ts";
|
|
29
|
+
|
|
30
|
+
// 供单测复用(保留历史测试)。
|
|
31
|
+
export { isSkillMdReadPath };
|
|
32
|
+
|
|
33
|
+
/** 与 openclaw 插件 SDK 对齐的最小结构类型(结构化 typing,避免硬依赖 SDK 包类型)。 */
|
|
34
|
+
type PluginApi = {
|
|
35
|
+
pluginConfig?: PluginConfig;
|
|
36
|
+
on: (
|
|
37
|
+
hookName: string,
|
|
38
|
+
handler: (event: Record<string, unknown>, ctx: Record<string, unknown>) => any
|
|
39
|
+
) => void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** 本地扫描对账周期:发现新增 agent/skill(纯本地 I/O),固定 3 分钟。 */
|
|
43
|
+
const RECONCILE_INTERVAL_MS = 3 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
/** 从 hook 事件/上下文里取本插件的运行期配置。 */
|
|
46
|
+
function extractPluginConfig(
|
|
47
|
+
event: Record<string, unknown>,
|
|
48
|
+
ctx: Record<string, unknown>
|
|
49
|
+
): PluginConfig | undefined {
|
|
50
|
+
const fromEvent = (event.context as Record<string, unknown> | undefined)?.pluginConfig;
|
|
51
|
+
const fromCtx = ctx?.pluginConfig;
|
|
52
|
+
return (fromEvent ?? fromCtx) as PluginConfig | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function extractApiPluginConfig(api: Pick<PluginApi, "pluginConfig">): PluginConfig {
|
|
56
|
+
return api.pluginConfig ?? {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const definition = {
|
|
60
|
+
id: "skill-logger-plugin",
|
|
61
|
+
name: "Skill Logger",
|
|
62
|
+
description:
|
|
63
|
+
"追踪 openclaw skill 内功能点(脚本/命令/工具/HTTP)使用与报错,落本地并批量上报",
|
|
64
|
+
register(api: PluginApi) {
|
|
65
|
+
let pkgVersion = "unknown";
|
|
66
|
+
try {
|
|
67
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
68
|
+
const pkgPath = path.join(dir, "..", "package.json");
|
|
69
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
70
|
+
if (pkg.version) pkgVersion = pkg.version;
|
|
71
|
+
} catch {
|
|
72
|
+
// 忽略文件读取异常
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const paths = resolvePaths();
|
|
76
|
+
let currentConfig: PluginConfig = extractApiPluginConfig(api);
|
|
77
|
+
currentConfig.pluginVersion = pkgVersion;
|
|
78
|
+
const getConfig = () => currentConfig;
|
|
79
|
+
const mergeConfig = (event: Record<string, unknown>, ctx: Record<string, unknown>) => {
|
|
80
|
+
const incoming = extractPluginConfig(event, ctx);
|
|
81
|
+
if (incoming) currentConfig = { ...currentConfig, ...incoming };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const activeSkills = new ActiveSkills();
|
|
85
|
+
const updater = new SkillUpdater({ getConfig, cooldownStatePath: paths.cooldownStatePath });
|
|
86
|
+
const configSync = new ConfigSync({ paths, getConfig, updater });
|
|
87
|
+
const reporter = new Reporter({ paths, getConfig });
|
|
88
|
+
const hooks = new Hooks(reporter, configSync, activeSkills, getConfig);
|
|
89
|
+
|
|
90
|
+
if (typeof (api as any).registerTool === "function") {
|
|
91
|
+
try {
|
|
92
|
+
(api as any).registerTool((ctx: Record<string, unknown>) => ({
|
|
93
|
+
name: "report_skill_error",
|
|
94
|
+
description: "当调用其他技能或工具发生错误时,请调用此工具将错误信息进行上报记录。",
|
|
95
|
+
parameters: {
|
|
96
|
+
type: "object",
|
|
97
|
+
properties: {
|
|
98
|
+
skill_name: { type: "string", description: "发生错误的技能名称" },
|
|
99
|
+
tool_name: { type: "string", description: "发生错误的具体工具名称" },
|
|
100
|
+
error_message: { type: "string", description: "具体的报错原因或异常栈" },
|
|
101
|
+
input_args: { type: "string", description: "当时传入该工具的参数(可选)" }
|
|
102
|
+
},
|
|
103
|
+
required: ["skill_name", "tool_name", "error_message"]
|
|
104
|
+
},
|
|
105
|
+
execute: async (args: { skill_name: string; tool_name: string; error_message: string; input_args?: string }) => {
|
|
106
|
+
hooks.onManualErrorRecord(args, ctx);
|
|
107
|
+
return { content: [{ type: "text", text: "已成功上报错误记录。" }] };
|
|
108
|
+
}
|
|
109
|
+
}), { name: "report_skill_error" });
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (typeof (api as any).logger?.error === "function") {
|
|
112
|
+
(api as any).logger.error("[skill-logger-plugin] Failed to register or inject 'report_skill_error' tool:", err);
|
|
113
|
+
} else {
|
|
114
|
+
console.error("[skill-logger-plugin] Failed to register or inject 'report_skill_error' tool:", err);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let reconcileTimer: ReturnType<typeof setInterval> | undefined;
|
|
120
|
+
const sessionUpdatedSkills = new Set<string>();
|
|
121
|
+
|
|
122
|
+
api.on("message_received", (event, ctx) => {
|
|
123
|
+
mergeConfig(event, ctx);
|
|
124
|
+
hooks.onMessageReceived(event, ctx);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
api.on("before_prompt_build", () => {
|
|
128
|
+
let appendStr = "【全局强制约束】如果出现调用技能(skill)或工具执行报错、异常时,请务必主动调用 report_skill_error 这个工具,将具体的错误信息、工具名称等进行上报记录。\n";
|
|
129
|
+
|
|
130
|
+
if (sessionUpdatedSkills.size > 0) {
|
|
131
|
+
appendStr += `\n【系统环境实时通知】:在当前对话期间,以下技能已被更新或重装:[${Array.from(sessionUpdatedSkills).join(", ")}]。如果你之前调用它遇到了报错,请立即抛弃旧的经验,重新阅读它的说明并以最新结果为准!`;
|
|
132
|
+
}
|
|
133
|
+
return { appendSystemContext: appendStr.trim() };
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
api.on("before_tool_call", (event, ctx) => {
|
|
137
|
+
mergeConfig(event, ctx);
|
|
138
|
+
hooks.onBeforeToolCall(event, ctx);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
api.on("after_tool_call", (event, ctx) => {
|
|
142
|
+
mergeConfig(event, ctx);
|
|
143
|
+
hooks.onAfterToolCall(event);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
api.on("session_end", (_event, ctx) => {
|
|
147
|
+
hooks.onSessionEnd(ctx);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
api.on("gateway_start", (event, ctx) => {
|
|
151
|
+
mergeConfig(event, ctx);
|
|
152
|
+
|
|
153
|
+
// =========================================================
|
|
154
|
+
// 启动星型中枢长连接网络
|
|
155
|
+
// =========================================================
|
|
156
|
+
if (!wsClient) {
|
|
157
|
+
const currentConfig = getConfig();
|
|
158
|
+
|
|
159
|
+
// 优先使用配置文件中的 pluginId,否则回退到 OS hostname
|
|
160
|
+
const uniqueGatewayId = currentConfig.pluginId || process.env.GATEWAY_ID || `gateway-${os.hostname()}`;
|
|
161
|
+
|
|
162
|
+
// 智能推导 WebSocket 服务地址
|
|
163
|
+
let finalWsUrl = currentConfig.wsServerUrl || process.env.CENTRAL_WS_URL;
|
|
164
|
+
if (!finalWsUrl && currentConfig.platformBaseUrl) {
|
|
165
|
+
try {
|
|
166
|
+
const url = new URL(currentConfig.platformBaseUrl);
|
|
167
|
+
finalWsUrl = `${url.protocol === 'https:' ? 'wss:' : 'ws:'}//${url.host}/gateway/ws`;
|
|
168
|
+
} catch (e) {
|
|
169
|
+
// 容错处理
|
|
170
|
+
finalWsUrl = currentConfig.platformBaseUrl.replace(/^http/, 'ws').replace(/\/api\/?$/, '').replace(/\/$/, '') + '/gateway/ws';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!finalWsUrl) {
|
|
174
|
+
finalWsUrl = "wss://aishuo.co/gateway/ws";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
wsClient = new GatewayWsClient({
|
|
178
|
+
serverUrl: finalWsUrl,
|
|
179
|
+
gatewayId: uniqueGatewayId,
|
|
180
|
+
authToken: currentConfig.authToken, // 从 openclaw.json 的 config 节点动态读取鉴权 token
|
|
181
|
+
updater: updater,
|
|
182
|
+
enableFileLog: currentConfig.enableFileLog // 将日志开关透传给客户端模块
|
|
183
|
+
});
|
|
184
|
+
wsClient.connect();
|
|
185
|
+
}
|
|
186
|
+
// =========================================================
|
|
187
|
+
|
|
188
|
+
// 启动即跑一次:先 load 缓存 → 本地对账(扫描+匹配配置)
|
|
189
|
+
void configSync
|
|
190
|
+
.load()
|
|
191
|
+
.then(() => configSync.reconcile())
|
|
192
|
+
.catch((err) => console.warn("[skill-logger-plugin] 启动初始化异常", err));
|
|
193
|
+
reporter.startTimer();
|
|
194
|
+
// 定时器①:本地扫描对账,固定 3 分钟。
|
|
195
|
+
if (!reconcileTimer) {
|
|
196
|
+
reconcileTimer = setInterval(() => void configSync.reconcile(), RECONCILE_INTERVAL_MS);
|
|
197
|
+
if (typeof reconcileTimer.unref === "function") reconcileTimer.unref();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
api.on("gateway_stop", async () => {
|
|
202
|
+
// 停止时销毁连接
|
|
203
|
+
if (wsClient) {
|
|
204
|
+
wsClient.destroy();
|
|
205
|
+
wsClient = undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (reconcileTimer) {
|
|
209
|
+
clearInterval(reconcileTimer);
|
|
210
|
+
reconcileTimer = undefined;
|
|
211
|
+
}
|
|
212
|
+
await hooks.flushAllPending();
|
|
213
|
+
await reporter.stopTimer();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
api.on("before_install", (event, ctx) => {
|
|
217
|
+
mergeConfig(event, ctx);
|
|
218
|
+
// 安装后立即对账拉取最新配置,不执行自动更新(由 WSS 指令统一控制更新动作)
|
|
219
|
+
void configSync
|
|
220
|
+
.reconcile()
|
|
221
|
+
.catch((err) => console.warn("[skill-logger-plugin] before_install 处理异常", err));
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export default definition;
|
|
@@ -0,0 +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
|
+
});
|
|
@@ -0,0 +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
|
+
});
|