@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,298 @@
1
+ /**
2
+ * 本地落盘 + 日志轮转 (Log Rotation) + 每 3 分钟批量上报。
3
+ *
4
+ * 设计原则:
5
+ * - events.jsonl 是活跃的写入日志,通过 appendFile 追加。
6
+ * - 上报时,将其 rename 轮转为带有时间戳的文件,隔离写和读,根绝并发读写丢失数据的竞态条件。
7
+ * - 后台处理所有的轮转文件并上报,上报成功后通过 fs.unlink 清理文件。
8
+ * - 失败则保留待下次重试(因为底层 DB 依赖 INSERT IGNORE 处理重复 event_id,所以即使重试时存在部分重复上报也是安全的)。
9
+ * - 所有异常吞掉,绝不阻塞 agent / gateway。
10
+ */
11
+ import fs from "node:fs/promises";
12
+ import path from "node:path";
13
+ import type { PluginConfig, SkillEvent, UserInfoSnapshot } from "./types.ts";
14
+ import type { PluginPaths } from "./paths.ts";
15
+ import { GitIdentityProvider, type IdentityProvider } from "./identity.ts";
16
+ import { defaultFetch } from "./http.ts";
17
+
18
+ /** 注入 fetch 便于测试;默认用全局 fetch。 */
19
+ type FetchLike = (url: string, init: RequestInit) => Promise<{ ok: boolean; status: number; json?: () => Promise<unknown> }>;
20
+
21
+ const FLUSH_INTERVAL_MS = 3 * 60 * 1000;
22
+ const BATCH_SIZE = 500;
23
+
24
+ export type ReporterOptions = {
25
+ paths: Pick<PluginPaths, "eventsLogPath">;
26
+ /** 读取最新插件配置(上报地址/鉴权可能在 gateway_start 后才注入)。 */
27
+ getConfig: () => PluginConfig;
28
+ identityProvider?: IdentityProvider;
29
+ fetchImpl?: FetchLike;
30
+ now?: () => number;
31
+ };
32
+
33
+ export class Reporter {
34
+ private readonly paths: Pick<PluginPaths, "eventsLogPath">;
35
+ private readonly getConfig: () => PluginConfig;
36
+ private readonly identityProvider: IdentityProvider;
37
+ private readonly fetchImpl: FetchLike;
38
+ private timer: ReturnType<typeof setInterval> | undefined;
39
+ /** 防止多次 flush 重入。 */
40
+ private flushing = false;
41
+
42
+ constructor(opts: ReporterOptions) {
43
+ this.paths = opts.paths;
44
+ this.getConfig = opts.getConfig;
45
+ this.identityProvider = opts.identityProvider ?? new GitIdentityProvider();
46
+ this.fetchImpl = opts.fetchImpl ?? defaultFetch();
47
+ }
48
+
49
+ private get isDebug(): boolean {
50
+ return this.getConfig().debugLogging !== false;
51
+ }
52
+
53
+ private async writeFallbackLog(level: "INFO" | "WARN" | "ERROR", ...args: any[]): Promise<void> {
54
+ try {
55
+ const msg = args.map(a => (a instanceof Error) ? (a.stack || a.toString()) : (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ");
56
+ const ts = new Date().toISOString();
57
+ const logLine = `[${ts}] [${level}] [skill-logger-plugin] ${msg}\n`;
58
+
59
+ const logDir = path.dirname(this.paths.eventsLogPath);
60
+ await fs.mkdir(logDir, { recursive: true });
61
+ await fs.appendFile(path.join(logDir, "skill-logger.err.log"), logLine);
62
+
63
+ if (level === "INFO" && this.isDebug) {
64
+ console.log("[skill-logger-plugin/reporter]", ...args);
65
+ } else if (level === "WARN" || level === "ERROR") {
66
+ console.warn("[skill-logger-plugin]", ...args);
67
+ }
68
+ } catch {
69
+ // ignore
70
+ }
71
+ }
72
+
73
+ private debug(...args: any[]): void {
74
+ if (this.isDebug) {
75
+ void this.writeFallbackLog("INFO", ...args);
76
+ }
77
+ }
78
+
79
+ /** 追加一行事件到 events.jsonl(目录不存在自动建)。失败不抛。 */
80
+ async appendEvent(event: SkillEvent): Promise<void> {
81
+ try {
82
+ // 防御:截断会导致 MySQL Data Truncation 宕机的超长字段
83
+ if (typeof event.error_message === "string" && event.error_message.length > 15000) {
84
+ event.error_message = event.error_message.substring(0, 15000) + "...(truncated)";
85
+ }
86
+ if (typeof event.command === "string" && event.command.length > 15000) {
87
+ event.command = event.command.substring(0, 15000) + "...(truncated)";
88
+ }
89
+
90
+ let line = "";
91
+ try {
92
+ line = JSON.stringify(event);
93
+ // 防御:防止整个 JSON 过大(如带有 base64 图片的 args)导致 Express 413 Payload Too Large
94
+ if (line.length > 100000) {
95
+ const safeEvent = { ...event, args: { _warning: "args omitted due to excessive size" } };
96
+ line = JSON.stringify(safeEvent);
97
+ }
98
+ } catch {
99
+ return; // JSON 序列化失败直接丢弃
100
+ }
101
+
102
+ await fs.mkdir(path.dirname(this.paths.eventsLogPath), { recursive: true });
103
+ await fs.appendFile(this.paths.eventsLogPath, line + "\n");
104
+ } catch (err) {
105
+ void this.writeFallbackLog("ERROR", "写事件日志失败", this.paths.eventsLogPath, err);
106
+ }
107
+ }
108
+
109
+ /** 启动 3 分钟定时上报。 */
110
+ startTimer(): void {
111
+ if (this.timer) return;
112
+ this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
113
+ // 不阻止进程退出
114
+ if (typeof this.timer.unref === "function") this.timer.unref();
115
+ }
116
+
117
+ /** 停止定时器,并尽力做最后一次 flush。 */
118
+ async stopTimer(): Promise<void> {
119
+ if (this.timer) {
120
+ clearInterval(this.timer);
121
+ this.timer = undefined;
122
+ }
123
+ await this.flush();
124
+ }
125
+
126
+ private linesOf(content: string): string[] {
127
+ return content.split("\n").filter((l) => l.trim().length > 0);
128
+ }
129
+
130
+ private async resolveUserInfo(appKey: string, config: PluginConfig): Promise<UserInfoSnapshot> {
131
+ if (!config.reportBaseUrl) return {};
132
+
133
+ try {
134
+ const url = config.reportBaseUrl.replace(/\/$/, "") + "/skill_user/resolve";
135
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
136
+ if (config.authToken) headers.Authorization = config.authToken;
137
+
138
+ const res = await this.fetchImpl(url, {
139
+ method: "POST",
140
+ headers,
141
+ body: JSON.stringify({ appKey }),
142
+ });
143
+
144
+ if (!res.ok) {
145
+ return { error_message: `resolve user info failed: HTTP ${res.status}` };
146
+ }
147
+
148
+ const data = typeof (res as any).json === "function" ? await (res as any).json() : {};
149
+ return (data.user_info || data.userInfo || data.data || {}) as UserInfoSnapshot;
150
+ } catch (err) {
151
+ return { error_message: `resolve user info failed: ${err instanceof Error ? err.message : String(err)}` };
152
+ }
153
+ }
154
+
155
+ private async attachUserInfo(events: SkillEvent[], config: PluginConfig): Promise<SkillEvent[]> {
156
+ const appKeys = [...new Set(events.map((event) => event.app_key || "").filter(Boolean))];
157
+ if (appKeys.length === 0) return events;
158
+
159
+ const byAppKey = new Map<string, UserInfoSnapshot>();
160
+ await Promise.all(appKeys.map(async (appKey) => {
161
+ byAppKey.set(appKey, await this.resolveUserInfo(appKey, config));
162
+ }));
163
+
164
+ return events.map((event) => {
165
+ if (!event.app_key) return event;
166
+ const userInfo = byAppKey.get(event.app_key);
167
+ if (!userInfo || Object.keys(userInfo).length === 0) return event;
168
+ return { ...event, user_info: userInfo };
169
+ });
170
+ }
171
+
172
+ /**
173
+ * 读本地队列并分批 POST;每批成功后清理对应本地文件。
174
+ * 采用日志轮转(Rename)规避读写竞态条件。
175
+ * 未配置 reportBaseUrl → 直接返回(只落本地,不上报)。
176
+ */
177
+ async flush(): Promise<void> {
178
+ if (this.flushing) return;
179
+ this.flushing = true;
180
+ try {
181
+ const config = this.getConfig();
182
+ if (!config.reportBaseUrl) {
183
+ void this.writeFallbackLog("WARN", "flush skipped: reportBaseUrl 没有配置!请检查 openclaw.json 插件配置是否正确加载。");
184
+ return;
185
+ }
186
+
187
+ const logDir = path.dirname(this.paths.eventsLogPath);
188
+
189
+ // 1. 重命名当前的活跃日志文件为时间戳格式(原子操作,解决读写冲突)
190
+ try {
191
+ await fs.access(this.paths.eventsLogPath);
192
+ const timestamp = Date.now();
193
+ const rotatedPath = path.join(logDir, `events.${timestamp}.jsonl`);
194
+ await fs.rename(this.paths.eventsLogPath, rotatedPath);
195
+ this.debug(`Rotated active log to ${path.basename(rotatedPath)}`);
196
+ } catch {
197
+ // 文件不存在,跳过重命名
198
+ }
199
+
200
+ // 2. 扫描所有轮转的日志文件
201
+ let files: string[] = [];
202
+ try {
203
+ const dirEntries = await fs.readdir(logDir);
204
+ files = dirEntries
205
+ .filter(f => f.startsWith("events.") && f.endsWith(".jsonl") && f !== "events.jsonl")
206
+ .map(f => path.join(logDir, f));
207
+ } catch {
208
+ return; // 目录不存在直接返回
209
+ }
210
+
211
+ if (files.length === 0) {
212
+ this.debug("No rotated log files found. flush finished.");
213
+ return;
214
+ }
215
+
216
+ this.debug(`Found ${files.length} rotated log files to process.`);
217
+ const identity = await this.identityProvider.getIdentity();
218
+ const url = config.reportBaseUrl.replace(/\/$/, "") + "/skill_report/batch";
219
+
220
+ // 3. 逐个处理文件上报
221
+ for (const filePath of files) {
222
+ try {
223
+ const content = await fs.readFile(filePath, "utf-8");
224
+ const lines = this.linesOf(content);
225
+ if (lines.length === 0) {
226
+ await fs.unlink(filePath); // 空文件直接删除
227
+ continue;
228
+ }
229
+
230
+ let cursor = 0;
231
+ let allSuccess = true;
232
+
233
+ while (cursor < lines.length) {
234
+ const slice = lines.slice(cursor, cursor + BATCH_SIZE);
235
+ const events: SkillEvent[] = [];
236
+ for (const line of slice) {
237
+ try {
238
+ events.push(JSON.parse(line));
239
+ } catch {
240
+ // 跳过坏行
241
+ }
242
+ }
243
+
244
+ if (events.length === 0) {
245
+ cursor += slice.length;
246
+ continue;
247
+ }
248
+
249
+ const eventsWithUserInfo = await this.attachUserInfo(events, config);
250
+ const body = JSON.stringify({
251
+ identity,
252
+ ide: "openclaw",
253
+ marketplace: "openclaw",
254
+ events: eventsWithUserInfo,
255
+ });
256
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
257
+ if (config.authToken) headers.Authorization = config.authToken;
258
+
259
+ const res = await this.fetchImpl(url, { method: "POST", headers, body });
260
+ if (!res.ok) {
261
+ const errBody = await res.text().catch(() => "无法读取响应体");
262
+ await this.writeFallbackLog("ERROR", `批量上报失败 (文件 ${path.basename(filePath)}), HTTP`, res.status, "服务端返回信息:", errBody);
263
+ // 防御死循环:如果是由于报文过大(413)或格式错误(400)等业务级拒绝,放弃该批次,不要死锁整个本地队列
264
+ if (res.status === 400 || res.status === 413 || res.status === 422) {
265
+ await this.writeFallbackLog("WARN", `报文被服务器永久拒绝,丢弃该批次以释放队列`);
266
+ cursor += slice.length;
267
+ continue;
268
+ }
269
+ allSuccess = false;
270
+ break; // 其他网络或 500 错误:本文件终止处理,留待下轮重试
271
+ }
272
+
273
+ this.debug(`Successfully reported batch of ${events.length} events from ${path.basename(filePath)}`);
274
+ cursor += slice.length;
275
+ }
276
+
277
+ // 如果该文件所有的 batch 都上报成功了,将其物理删除
278
+ if (allSuccess) {
279
+ await fs.unlink(filePath);
280
+ this.debug(`Deleted fully processed file: ${path.basename(filePath)}`);
281
+ } else {
282
+ // 如果某一批次失败,文件保留。下轮 flush 会把前面成功批次的事件再报一次。
283
+ // 但因为 DB 的 skill_logger_events 表 event_id 是唯一键且使用了 INSERT IGNORE,所以数据去重是绝对安全的。
284
+ this.debug(`File ${path.basename(filePath)} partially failed. Keeping it for next flush.`);
285
+ }
286
+
287
+ } catch (err) {
288
+ await this.writeFallbackLog("ERROR", `处理文件 ${path.basename(filePath)} 异常:`, err);
289
+ }
290
+ }
291
+
292
+ } catch (err) {
293
+ await this.writeFallbackLog("ERROR", "flush 整体异常", err);
294
+ } finally {
295
+ this.flushing = false;
296
+ }
297
+ }
298
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "configs": [
3
+ {
4
+ "skillName": "model-usage",
5
+ "version": "0.0.0-stub",
6
+ "description": "查看各 provider 模型用量(本地静态桩)",
7
+ "functions": [
8
+ {
9
+ "id": "usage_current",
10
+ "name": "当前用量",
11
+ "description": "查看当前模型用量",
12
+ "match": {
13
+ "type": "script",
14
+ "script": "scripts/model_usage.py",
15
+ "argRules": [{ "flag": "--mode", "value": "current" }]
16
+ }
17
+ },
18
+ {
19
+ "id": "usage_all",
20
+ "name": "全部用量",
21
+ "description": "查看全部模型用量",
22
+ "match": {
23
+ "type": "script",
24
+ "script": "scripts/model_usage.py",
25
+ "argRules": [{ "flag": "--mode", "value": "all" }]
26
+ }
27
+ }
28
+ ]
29
+ },
30
+ {
31
+ "skillName": "openai-whisper-api",
32
+ "version": "0.0.0-stub",
33
+ "description": "Whisper 音频转写(本地静态桩)",
34
+ "functions": [
35
+ {
36
+ "id": "transcribe",
37
+ "name": "音频转写",
38
+ "match": { "type": "script", "script": "scripts/transcribe.sh" }
39
+ }
40
+ ]
41
+ },
42
+ {
43
+ "skillName": "mcporter",
44
+ "version": "0.0.0-stub",
45
+ "description": "MCP CLI 包装示例(本地静态桩)",
46
+ "functions": [
47
+ {
48
+ "id": "linear_list_issues",
49
+ "name": "列 Linear issue",
50
+ "match": { "type": "command", "command": "mcporter", "targetPattern": "call linear.list_issues" }
51
+ }
52
+ ]
53
+ },
54
+ {
55
+ "skillName": "linear-mcp",
56
+ "version": "0.0.0-stub",
57
+ "description": "MCP/SSE 工具直调 + HTTP 端点示例(本地静态桩)",
58
+ "functions": [
59
+ {
60
+ "id": "create_issue",
61
+ "name": "创建 issue(MCP工具直调)",
62
+ "match": { "type": "tool", "toolName": "linear_create_issue" }
63
+ },
64
+ {
65
+ "id": "transcribe_http",
66
+ "name": "转写 HTTP 端点",
67
+ "match": { "type": "http", "urlContains": "/v1/audio/transcriptions" }
68
+ }
69
+ ]
70
+ }
71
+ ]
72
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { compareVersions, isOutdated } from "./semver.ts";
4
+
5
+ describe("compareVersions", () => {
6
+ it("数值逐段比较:1.10 > 1.9", () => assert.equal(compareVersions("1.10.0", "1.9.0"), 1));
7
+ it("缺省段按 0 补齐:1.2 == 1.2.0", () => assert.equal(compareVersions("1.2", "1.2.0"), 0));
8
+ it("容忍 v 前缀", () => assert.equal(compareVersions("v2.0.0", "2.0.0"), 0));
9
+ it("a < b", () => assert.equal(compareVersions("1.0.0", "1.0.1"), -1));
10
+ it("忽略预发布后缀(核心相等)", () => assert.equal(compareVersions("1.0.0-beta", "1.0.0"), 0));
11
+ it("非数字版本退化为字符串比较", () => assert.equal(compareVersions("abc", "abc"), 0));
12
+ });
13
+
14
+ describe("isOutdated", () => {
15
+ it("本地落后 → true", () => assert.equal(isOutdated("1.0.0", "1.1.0"), true));
16
+ it("本地更新 → false", () => assert.equal(isOutdated("2.0.0", "1.9.0"), false));
17
+ it("相等 → false", () => assert.equal(isOutdated("1.2.3", "1.2.3"), false));
18
+ it("1.9 vs 1.10:应判过期(数值语义)", () => assert.equal(isOutdated("1.9.0", "1.10.0"), true));
19
+ it("缺本地版本 → 不判过期", () => assert.equal(isOutdated(undefined, "1.0.0"), false));
20
+ it("缺最新版本 → 不判过期", () => assert.equal(isOutdated("1.0.0", undefined), false));
21
+ it("非 semver 且不同 → 过期(保守)", () => assert.equal(isOutdated("alpha", "beta"), true));
22
+ it("带预发布后缀核心相同 → 不过期", () => assert.equal(isOutdated("2026-01", "2026-02"), false));
23
+ });
package/src/semver.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 轻量语义化版本比较(纯函数,无依赖)。
3
+ *
4
+ * 仅用于「本地 skill 版本 vs 平台最新版本」的过期判断,不追求完整 semver 规范:
5
+ * - 容忍前缀 `v`/`V`(如 `v1.2.3`);
6
+ * - 比较 `主.次.修订…` 的数值段,缺省段按 0 补齐(`1.2` == `1.2.0`);
7
+ * - 预发布/构建元数据(`-beta`、`+build`)只取核心版本比较,忽略其后缀;
8
+ * - 任一侧无法解析为「点分数字」时,退化为字符串相等判断。
9
+ */
10
+
11
+ /** 解析核心数字段;非「点分数字」返回 null(触发字符串回退)。 */
12
+ function parseCore(v: string): number[] | null {
13
+ const core = v.trim().replace(/^[vV]/, "").split(/[-+]/, 1)[0];
14
+ if (!core) return null;
15
+ const parts = core.split(".");
16
+ const nums: number[] = [];
17
+ for (const p of parts) {
18
+ if (!/^\d+$/.test(p)) return null;
19
+ nums.push(Number(p));
20
+ }
21
+ return nums.length > 0 ? nums : null;
22
+ }
23
+
24
+ /**
25
+ * 比较 a 与 b:a<b 返回 -1,a>b 返回 1,相等返回 0。
26
+ * 两侧都能解析为点分数字时按数值逐段比较;否则退化为字符串比较。
27
+ */
28
+ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
29
+ const na = parseCore(a);
30
+ const nb = parseCore(b);
31
+ if (na && nb) {
32
+ const len = Math.max(na.length, nb.length);
33
+ for (let i = 0; i < len; i++) {
34
+ const x = na[i] ?? 0;
35
+ const y = nb[i] ?? 0;
36
+ if (x < y) return -1;
37
+ if (x > y) return 1;
38
+ }
39
+ return 0;
40
+ }
41
+ // 字符串回退:仅区分相等/不等(不臆测大小关系)。
42
+ const sa = a.trim();
43
+ const sb = b.trim();
44
+ if (sa === sb) return 0;
45
+ return sa < sb ? -1 : 1;
46
+ }
47
+
48
+ /**
49
+ * 本地版本是否「落后于」最新版本(即需要更新)。
50
+ * - 两侧都是合法 semver:latest 数值更大才算过期;
51
+ * - 无法数值比较时:字符串不相等即视为过期(保守,宁可多更新一次)。
52
+ * 任一侧为空 → 不判过期(缺版本号则不参与更新)。
53
+ */
54
+ export function isOutdated(local: string | undefined, latest: string | undefined): boolean {
55
+ if (!local || !latest) return false;
56
+ const na = parseCore(local);
57
+ const nb = parseCore(latest);
58
+ if (na && nb) return compareVersions(local, latest) < 0;
59
+ return local.trim() !== latest.trim();
60
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 从 SKILL.md 文本中提取版本号(纯正则、不借助任何模型)。
3
+ *
4
+ * 兼容点:
5
+ * - 键名:英文 `version` / `Version`(大小写不敏感)、中文 `版本号` / `版本`;
6
+ * - 冒号:半角 `:` 或全角 `:`;
7
+ * - 取值:去除包裹引号、行尾 ` # 注释`、首尾空白。
8
+ *
9
+ * 范围:在整篇 SKILL.md 内按「单独一行」匹配(frontmatter 在最前,故天然优先命中)。
10
+ * 因要求行首即键名(前面只允许空白),Markdown 标题 `## Version: x`(带 `#` 前缀)不会被误命中。
11
+ * 取不到返回 undefined(该 skill 不参与版本更新)。
12
+ */
13
+ export function parseSkillVersion(content: string): string | undefined {
14
+ const m = /(?:^|\r?\n)[ \t]*(?:version|版本号|版本)[ \t]*[::][ \t]*(.+)/i.exec(content);
15
+ if (!m) return undefined;
16
+ const v = m[1]
17
+ .replace(/\s+#.*$/, "") // 行尾注释
18
+ .trim()
19
+ .replace(/^["']|["']$/g, "") // 包裹引号
20
+ .trim();
21
+ return v || undefined;
22
+ }
package/src/types.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * 全插件共享的数据类型。
3
+ *
4
+ * 设计要点:
5
+ * - `MatchRule` 是「判别联合」(discriminated union):用 `type` 字段区分四种匹配策略,
6
+ * TypeScript 能据此收窄到具体形状,matcher 里按 `rule.type` 分派。
7
+ * - 这些类型同时是「标准配置」的契约:用户平台返回的 JSON 必须符合 `SkillStandardConfig`。
8
+ */
9
+
10
+ /** 脚本参数规则:用于在同一脚本下按参数细分功能点,如 `--mode current`。 */
11
+ export type ArgRule = {
12
+ /** 参数名,含前缀,如 `--mode`。 */
13
+ flag: string;
14
+ /** 期望值;省略表示「只要出现该 flag 即命中」。 */
15
+ value?: string;
16
+ };
17
+
18
+ /**
19
+ * 脚本调用:模型通过 `exec` 跑某个 skill 自带脚本(.py/.sh/.js 等)。
20
+ * 识别时忽略解释器前缀(python/node/bash/uv run/./),用脚本路径后缀匹配。
21
+ */
22
+ export type ScriptMatchRule = {
23
+ type: "script";
24
+ /** 相对 skill 根目录的脚本路径,如 `scripts/model_usage.py`。 */
25
+ script: string;
26
+ /** 可选:按参数细分功能点;全部命中才算命中。 */
27
+ argRules?: ArgRule[];
28
+ };
29
+
30
+ /**
31
+ * CLI 包装调用:模型通过 `exec` 调一个包装 CLI,如 `mcporter call linear.list_issues`。
32
+ * `command` 是命令头,`targetPattern` 是其后用于定位功能点的子串。
33
+ */
34
+ export type CommandMatchRule = {
35
+ type: "command";
36
+ /** 命令名,如 `mcporter`。 */
37
+ command: string;
38
+ /** 命令后用于识别功能点的子串,如 `call linear.list_issues`。 */
39
+ targetPattern: string;
40
+ };
41
+
42
+ /** 参数谓词:判断某个工具参数是否等于期望值(支持点路径取嵌套字段)。 */
43
+ export type WherePredicate = {
44
+ /** 参数键,支持 `a.b.c` 点路径。 */
45
+ param: string;
46
+ /** 期望值(严格相等比较,做 String 归一)。 */
47
+ equals: string;
48
+ };
49
+
50
+ /**
51
+ * 工具直调:模型直接调用一个「非 exec」工具(典型为注册进 openclaw 的 MCP/SSE 工具)。
52
+ * 按 `toolName` 匹配(精确 / 前缀 / 正则三选一),可加参数谓词进一步限定。
53
+ */
54
+ export type ToolMatchRule = {
55
+ type: "tool";
56
+ /** 精确工具名。 */
57
+ toolName?: string;
58
+ /** 工具名前缀(如某 MCP server 的统一前缀)。 */
59
+ toolNamePrefix?: string;
60
+ /** 工具名正则(字符串形式,matcher 内编译)。 */
61
+ toolNameRegex?: string;
62
+ /** 可选参数谓词,全部满足才命中。 */
63
+ where?: WherePredicate[];
64
+ };
65
+
66
+ /**
67
+ * HTTP/SSE 端点调用:模型 `exec` 里 curl 某端点,或 fetch 类工具带 url 参数。
68
+ */
69
+ export type HttpMatchRule = {
70
+ type: "http";
71
+ /** URL 子串匹配,如 `/v1/audio/transcriptions`。 */
72
+ urlContains?: string;
73
+ /** host 子串匹配,如 `api.openai.com`。 */
74
+ hostContains?: string;
75
+ };
76
+
77
+ export type MatchRule =
78
+ | ScriptMatchRule
79
+ | CommandMatchRule
80
+ | ToolMatchRule
81
+ | HttpMatchRule;
82
+
83
+ /** 一个 skill 内的功能点定义。 */
84
+ export type SkillFunction = {
85
+ /** 功能点唯一标识(skill 内唯一)。 */
86
+ id: string;
87
+ /** 人类可读名称。 */
88
+ name: string;
89
+ /** 功能点说明(可选)。 */
90
+ description?: string;
91
+ /** 匹配规则。 */
92
+ match: MatchRule;
93
+ };
94
+
95
+ /** skill 的标准配置:由用户平台定义、插件拉取并缓存。 */
96
+ export type SkillStandardConfig = {
97
+ skillName: string;
98
+ /** 本次返回的配置对应的版本(按请求里的本地版本精确匹配)。 */
99
+ version: string;
100
+ /** 平台上该 skill 的最新版本(服务端附加,用于过期检测);缺省时回退用 version。 */
101
+ latestVersion?: string;
102
+ description?: string;
103
+ functions: SkillFunction[];
104
+ };
105
+
106
+ /** matcher 命中后返回的结构。 */
107
+ export type MatchResult = {
108
+ skillName: string;
109
+ skillVersion: string;
110
+ functionId: string;
111
+ functionName: string;
112
+ matchType: MatchRule["type"];
113
+ /** 从调用里解析出的参数(脚本 flag / mcporter k=v / http query 等)。 */
114
+ args: Record<string, unknown>;
115
+ };
116
+
117
+ /** 观测到的一次工具调用的最小描述(matcher 输入)。 */
118
+ export type ToolCall = {
119
+ toolName: string;
120
+ params: Record<string, unknown>;
121
+ };
122
+
123
+ /** 上报身份;做成独立类型,便于将来替换来源(user_id 等)。 */
124
+ export type Identity = {
125
+ user_id: string;
126
+ git_name: string;
127
+ git_email: string;
128
+ machine_id: string;
129
+ };
130
+
131
+ export type UserInfoSnapshot = {
132
+ user_id?: string;
133
+ user_name?: string;
134
+ emp_id?: string;
135
+ person_id?: string;
136
+ corp_id?: string;
137
+ avatar?: string;
138
+ deptList?: unknown[];
139
+ title?: string;
140
+ error_message?: string;
141
+ };
142
+
143
+ /** 落盘 / 上报的事件。两类:skill 触发、功能点调用。 */
144
+ export type SkillEvent = {
145
+ event_id: string;
146
+ event_type: "skill_trigger" | "function_call";
147
+ skill_name: string;
148
+ skill_version?: string;
149
+ function_id?: string;
150
+ function_name?: string;
151
+ /** 命中的匹配策略类型(function_call 时有)。 */
152
+ match_type?: MatchRule["type"];
153
+ /** 触发方式:skill_trigger 为 inline/tool;function_call 为观测到的工具名(exec/mcp 工具…)。 */
154
+ invoke_mode?: string;
155
+ /** 观测到的工具名。 */
156
+ invoke_tool?: string;
157
+ /** exec 时的原始命令行。 */
158
+ command?: string;
159
+ args?: Record<string, unknown>;
160
+ /** success/error 来自 after_tool_call;unknown 表示 after 未到达(被中断/阻断)而由兜底补记。 */
161
+ status?: "success" | "error" | "unknown";
162
+ error_message?: string;
163
+ duration_ms?: number;
164
+ app_key?: string;
165
+ user_info?: UserInfoSnapshot;
166
+ session_id?: string;
167
+ agent_id?: string;
168
+ run_id?: string;
169
+ plugin_id?: string;
170
+ plugin_name?: string;
171
+ plugin_version?: string;
172
+ /** ISO 8601。 */
173
+ called_at: string;
174
+ };
175
+
176
+ /** 插件运行期配置(来自 manifest configSchema / pluginConfig)。 */
177
+ export type PluginConfig = {
178
+ /** 插件唯一ID,用于区分不同网关或业务线的插件实例。 */
179
+ pluginId?: string;
180
+ /** 插件中文描述名称。 */
181
+ pluginName?: string;
182
+ /** 插件版本号(从 package.json 中读取)。 */
183
+ pluginVersion?: string;
184
+ /** 拉取标准配置的服务地址。缺失则用本地静态桩。 */
185
+ platformBaseUrl?: string;
186
+ /** 网关长连接中心服务地址(如 wss://api.aishuo.co/gateway/ws)。若缺失,尝试从 platformBaseUrl 推导。 */
187
+ wsServerUrl?: string;
188
+ /** 批量上报的服务地址。缺失则只落本地、不上报。 */
189
+ reportBaseUrl?: string;
190
+ /** 上报鉴权头的值(如 Bearer xxx)。 */
191
+ authToken?: string;
192
+ /** 是否记录「无法自信归属到功能点」的调用(默认 true)。 */
193
+ recordUnattributed?: boolean;
194
+ /** 是否开启插件内部的调试日志输出(默认 true,方便实战联调观察过程)。 */
195
+ debugLogging?: boolean;
196
+ /** 是否开启插件专属文件日志(落盘为 skill-logger.err),用于排查 WS 连接和指令运行情况 */
197
+ enableFileLog?: boolean;
198
+ };