@spzhongwin/skill-logger-plugin 1.0.10 → 1.0.12

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 +2772 -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 -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 +314 -237
  50. package/src/updater.ts +518 -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
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 按 session 记录「已触发(激活)的 skill」,供 matcher 在通用命令(curl、共享 CLI)
3
+ * 出现多候选时消歧——优先归属到本 session 已激活的 skill。
4
+ *
5
+ * 内存态,带 TTL + 容量上限,防止长跑 gateway 进程里无限增长。
6
+ */
7
+ const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 分钟
8
+ const DEFAULT_MAX_SESSIONS = 500;
9
+ export class ActiveSkills {
10
+ ttlMs;
11
+ maxSessions;
12
+ now;
13
+ /** sessionKey -> (skillName -> entry)。用 Map 保留插入顺序以便 LRU 淘汰。 */
14
+ bySession = new Map();
15
+ constructor(opts = {}) {
16
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
17
+ this.maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
18
+ this.now = opts.now ?? Date.now;
19
+ }
20
+ /** 标记某 session 触发了某 skill。 */
21
+ markActive(sessionKey, skillName) {
22
+ const key = sessionKey || "__nosession__";
23
+ let skills = this.bySession.get(key);
24
+ if (!skills) {
25
+ skills = new Map();
26
+ this.bySession.set(key, skills);
27
+ }
28
+ else {
29
+ // 触碰即刷新 LRU 顺序
30
+ this.bySession.delete(key);
31
+ this.bySession.set(key, skills);
32
+ }
33
+ skills.set(skillName, { name: skillName, ts: this.now() });
34
+ this.evictIfNeeded();
35
+ }
36
+ /** 取某 session 当前仍在 TTL 内的已激活 skill 集合。 */
37
+ getActive(sessionKey) {
38
+ const key = sessionKey || "__nosession__";
39
+ const skills = this.bySession.get(key);
40
+ const out = new Set();
41
+ if (!skills)
42
+ return out;
43
+ const cutoff = this.now() - this.ttlMs;
44
+ for (const [name, entry] of skills) {
45
+ if (entry.ts >= cutoff)
46
+ out.add(name);
47
+ else
48
+ skills.delete(name);
49
+ }
50
+ if (skills.size === 0)
51
+ this.bySession.delete(key);
52
+ return out;
53
+ }
54
+ /** session 结束时清掉其激活记录(释放内存 + 避免跨会话误判)。 */
55
+ clearSession(sessionKey) {
56
+ this.bySession.delete(sessionKey || "__nosession__");
57
+ }
58
+ /** 超出 session 上限时,按 LRU 淘汰最旧的 session。 */
59
+ evictIfNeeded() {
60
+ while (this.bySession.size > this.maxSessions) {
61
+ const oldest = this.bySession.keys().next().value;
62
+ if (oldest === undefined)
63
+ break;
64
+ this.bySession.delete(oldest);
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ActiveSkills } from "./active-skills.ts";
4
+ describe("ActiveSkills", () => {
5
+ it("标记并取回激活 skill", () => {
6
+ const a = new ActiveSkills();
7
+ a.markActive("s1", "skA");
8
+ a.markActive("s1", "skB");
9
+ assert.deepEqual([...a.getActive("s1")].sort(), ["skA", "skB"]);
10
+ assert.deepEqual([...a.getActive("s2")], []);
11
+ });
12
+ it("TTL 过期后不再返回", () => {
13
+ let t = 1000;
14
+ const a = new ActiveSkills({ ttlMs: 100, now: () => t });
15
+ a.markActive("s", "skA");
16
+ t = 1050;
17
+ assert.deepEqual([...a.getActive("s")], ["skA"]);
18
+ t = 2000;
19
+ assert.deepEqual([...a.getActive("s")], []);
20
+ });
21
+ it("超出 session 上限按 LRU 淘汰最旧", () => {
22
+ const a = new ActiveSkills({ maxSessions: 2 });
23
+ a.markActive("s1", "x");
24
+ a.markActive("s2", "x");
25
+ a.markActive("s3", "x"); // 淘汰 s1
26
+ assert.deepEqual([...a.getActive("s1")], []);
27
+ assert.deepEqual([...a.getActive("s3")], ["x"]);
28
+ });
29
+ });
@@ -0,0 +1,439 @@
1
+ /**
2
+ * 智能动态拉取 skill 标准配置。
3
+ *
4
+ * - 扫描本地已装 skill(extensions 下的 SKILL.md),算签名(版本或文件 mtime/size)。
5
+ * - 与本地缓存的同步状态比对,找出「新增 / 签名变化」的 skill,按需拉取标准配置。
6
+ * - 拉取来源:配了 platformBaseUrl → POST 平台接口;否则用本地静态桩(第一步交付)。
7
+ * - 维护 matcher 索引:任何配置变化后重建。
8
+ *
9
+ * 触发时机:① skill_trigger 懒触发(lazyCheck) ② 3 分钟周期 reconcile ③ before_install 后 reconcile。
10
+ * 全部异常吞掉,绝不阻塞 hook。
11
+ */
12
+ import fs from "node:fs/promises";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { resolveAgentSkillDirs, openclawHome } from "./paths.ts";
16
+ import { buildIndex, emptyIndex } from "./matcher.ts";
17
+ import { defaultFetch } from "./http.ts";
18
+ import { parseSkillVersion } from "./skill-version.ts";
19
+ // 保持对外导出位置不变(历史测试从 config-sync 导入)。
20
+ export { parseSkillVersion };
21
+ const MAX_SCAN_DEPTH = 6;
22
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".cache"]);
23
+ /** lazyCheck 扫描缓存有效期:足够吸收同一会话内的连续 skill_trigger,又不至于明显滞后真实安装变化。 */
24
+ const SCAN_CACHE_TTL_MS = 5000;
25
+ export class ConfigSync {
26
+ paths;
27
+ getConfig;
28
+ fetchImpl;
29
+ sampleConfigs;
30
+ resolveSkillDirs;
31
+ updater;
32
+ signatures = new Map();
33
+ configs = new Map();
34
+ configPool = new Map();
35
+ index = emptyIndex();
36
+ /** skill 根目录 → 规范名(SKILL.md frontmatter name)。用于把触发事件归一到与匹配一致的身份。 */
37
+ skillNameByDir = new Map();
38
+ /** 同一时刻只跑一次 reconcile,避免周期/懒触发并发。 */
39
+ reconciling = false;
40
+ /** 同一时刻只跑一次版本检查,避免周期/启动并发。 */
41
+ checkingVersions = false;
42
+ /** skill 名 → 平台最新版本(由 30 分钟版本检查刷新;缺省回退用已装配置的 version)。 */
43
+ latestVersions = new Map();
44
+ /** scanInstalledSkills 的短 TTL 缓存,仅服务 lazyCheck 的高频触发;reconcile 始终全新扫描并刷新它。 */
45
+ scanCache;
46
+ /** skill 名 → 所有安装副本(多 agent workspace 各一份)。每次扫描刷新。 */
47
+ installations = new Map();
48
+ constructor(opts) {
49
+ this.paths = opts.paths;
50
+ this.getConfig = opts.getConfig;
51
+ this.fetchImpl = opts.fetchImpl ?? defaultFetch();
52
+ this.sampleConfigs = opts.sampleConfigs;
53
+ // 默认:每次扫描重读 openclaw.json,动态发现新增 agent workspace(无需重启网关)。
54
+ this.resolveSkillDirs =
55
+ opts.resolveSkillDirs ?? (() => resolveAgentSkillDirs(openclawHome(), this.paths.openclawConfigPath));
56
+ this.updater = opts.updater;
57
+ }
58
+ get isDebug() {
59
+ return this.getConfig().debugLogging !== false;
60
+ }
61
+ debug(...args) {
62
+ if (this.isDebug) {
63
+ console.log("[skill-logger-plugin/config-sync]", ...args);
64
+ }
65
+ }
66
+ getIndex() {
67
+ return this.index;
68
+ }
69
+ getVersion(skillName) {
70
+ const cfg = this.configs.get(skillName);
71
+ if (cfg?.version)
72
+ return cfg.version;
73
+ const sig = this.signatures.get(skillName);
74
+ if (sig) {
75
+ const v = sig.split("|")[0];
76
+ return v ? v : undefined;
77
+ }
78
+ return undefined;
79
+ }
80
+ /** 把 SKILL.md 所在目录解析为规范 skill 名;未扫描到时返回 undefined(调用方回退目录名)。 */
81
+ resolveSkillName(rootDir) {
82
+ return this.skillNameByDir.get(rootDir);
83
+ }
84
+ /** 从磁盘加载已缓存的同步状态并重建索引(gateway 启动时调一次)。 */
85
+ async load() {
86
+ try {
87
+ const raw = await fs.readFile(this.paths.syncStatePath, "utf-8");
88
+ const state = JSON.parse(raw);
89
+ // 兼容旧版本格式
90
+ if (state.skills) {
91
+ for (const [name, entry] of Object.entries(state.skills)) {
92
+ this.signatures.set(name, entry.signature);
93
+ if (entry.config) {
94
+ this.configs.set(name, entry.config);
95
+ const cacheKey = `${name}@${entry.version || "unknown"}`;
96
+ this.configPool.set(cacheKey, entry.config);
97
+ }
98
+ }
99
+ }
100
+ // 新版本格式
101
+ if (state.configPool) {
102
+ for (const [key, cfg] of Object.entries(state.configPool)) {
103
+ this.configPool.set(key, cfg);
104
+ }
105
+ }
106
+ if (state.active) {
107
+ for (const [name, entry] of Object.entries(state.active)) {
108
+ this.signatures.set(name, entry.signature);
109
+ const v = entry.signature.split("|")[0];
110
+ const cacheKey = `${name}@${v || "unknown"}`;
111
+ const cfg = this.configPool.get(cacheKey);
112
+ if (cfg)
113
+ this.configs.set(name, cfg);
114
+ }
115
+ }
116
+ this.rebuildIndex();
117
+ }
118
+ catch {
119
+ // 无状态文件,留空
120
+ }
121
+ }
122
+ /**
123
+ * 递归扫描 extensions 及各 agent workspace 下所有 SKILL.md,解析名称/版本/签名。
124
+ * 同名 skill 可能分布在多个 workspace(如 coder/coder2 各持一份副本)。缓存以 skill 名为全局 key,
125
+ * 故这里按名去重并取确定性的一份(按 rootDir 排序后取首个),避免 reconcile 每轮在不同副本的
126
+ * 签名间反复横跳、触发无意义的重复拉取与持久化抖动。skillNameByDir 仍保留全部副本目录的映射。
127
+ */
128
+ async scanInstalledSkills() {
129
+ const out = [];
130
+ const walk = async (dir, depth) => {
131
+ if (depth > MAX_SCAN_DEPTH)
132
+ return;
133
+ let entries;
134
+ try {
135
+ entries = await fs.readdir(dir, { withFileTypes: true });
136
+ }
137
+ catch {
138
+ return;
139
+ }
140
+ for (const e of entries) {
141
+ if (e.isDirectory()) {
142
+ if (SKIP_DIRS.has(e.name))
143
+ continue;
144
+ await walk(path.join(dir, e.name), depth + 1);
145
+ }
146
+ else if (e.name === "SKILL.md") {
147
+ const skillMd = path.join(dir, e.name);
148
+ const skill = await this.readSkill(dir, skillMd);
149
+ if (skill) {
150
+ out.push(skill);
151
+ this.skillNameByDir.set(skill.rootDir, skill.name);
152
+ }
153
+ }
154
+ }
155
+ };
156
+ await walk(this.paths.extensionsDir, 0);
157
+ // 动态解析所有 agent workspace 的 skills 目录(含顶层全局 skills):每次扫描重读 openclaw.json,
158
+ // 运行期新增 agent 无需重启即可被发现。不存在的目录在 walk 内已被吞掉。
159
+ for (const skillsDir of this.resolveSkillDirs()) {
160
+ await walk(skillsDir, 0);
161
+ }
162
+ out.sort((a, b) => a.rootDir.localeCompare(b.rootDir));
163
+ // 记录 skill → 所有安装副本(含各 workspace 下的 rootDir 与本地版本),供版本更新定位覆盖目标。
164
+ const installs = new Map();
165
+ for (const s of out) {
166
+ const arr = installs.get(s.name);
167
+ if (arr)
168
+ arr.push(s);
169
+ else
170
+ installs.set(s.name, [s]);
171
+ }
172
+ this.installations = installs;
173
+ // 按 skill 名去重,取确定性的一份(rootDir 字典序最小)。
174
+ const deduped = new Map();
175
+ for (const s of out) {
176
+ if (!deduped.has(s.name))
177
+ deduped.set(s.name, s);
178
+ }
179
+ return [...deduped.values()];
180
+ }
181
+ /** skill → 所有安装副本(多 agent workspace 各一份)。供版本更新定位需要覆盖的目录。 */
182
+ getInstallations() {
183
+ return this.installations;
184
+ }
185
+ /** lazyCheck 专用:命中短 TTL 缓存则跳过整树遍历;reconcile 不走此路径,始终全新扫描。 */
186
+ async scanInstalledSkillsCached() {
187
+ const now = Date.now();
188
+ if (this.scanCache && now - this.scanCache.ts < SCAN_CACHE_TTL_MS) {
189
+ return this.scanCache.skills;
190
+ }
191
+ const skills = await this.scanInstalledSkills();
192
+ this.scanCache = { ts: now, skills };
193
+ return skills;
194
+ }
195
+ async readSkill(rootDir, skillMdPath) {
196
+ try {
197
+ const content = await fs.readFile(skillMdPath, "utf-8");
198
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)?.[1] ?? "";
199
+ const name = /(^|\n)name:\s*(.+)/.exec(fm)?.[2]?.trim() || path.basename(rootDir);
200
+ const version = parseSkillVersion(content);
201
+ const signature = await this.computeSignature(rootDir, skillMdPath, version);
202
+ return { name, version, rootDir, signature };
203
+ }
204
+ catch {
205
+ return undefined;
206
+ }
207
+ }
208
+ /** 签名 = 版本(若有)+ SKILL.md 与 scripts/ 的 mtime/size 摘要。 */
209
+ async computeSignature(rootDir, skillMdPath, version) {
210
+ const parts = [version ?? ""];
211
+ try {
212
+ const st = await fs.stat(skillMdPath);
213
+ parts.push(`md:${st.mtimeMs}:${st.size}`);
214
+ }
215
+ catch {
216
+ /* ignore */
217
+ }
218
+ try {
219
+ const scriptsDir = path.join(rootDir, "scripts");
220
+ const entries = await fs.readdir(scriptsDir, { withFileTypes: true });
221
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
222
+ if (!e.isFile())
223
+ continue;
224
+ const st = await fs.stat(path.join(scriptsDir, e.name));
225
+ parts.push(`s:${e.name}:${st.mtimeMs}:${st.size}`);
226
+ }
227
+ }
228
+ catch {
229
+ /* 无 scripts 目录 */
230
+ }
231
+ return parts.join("|");
232
+ }
233
+ /** 找出需要拉取(新增或签名变化)与已移除的 skill。优先命中本地 configPool。 */
234
+ diffAgainstState(installed) {
235
+ const toFetch = [];
236
+ const cached = [];
237
+ const removed = [];
238
+ const seen = new Set();
239
+ for (const s of installed) {
240
+ seen.add(s.name);
241
+ if (this.signatures.get(s.name) !== s.signature) {
242
+ const cacheKey = `${s.name}@${s.version || "unknown"}`;
243
+ if (this.configPool.has(cacheKey)) {
244
+ cached.push(s);
245
+ }
246
+ else {
247
+ toFetch.push(s);
248
+ }
249
+ }
250
+ }
251
+ for (const name of this.signatures.keys()) {
252
+ if (!seen.has(name))
253
+ removed.push(name);
254
+ }
255
+ return { toFetch, removed, cached };
256
+ }
257
+ /** 全量对账:扫描 → diff → 拉取 → 更新缓存 → 重建索引 → 持久化。 */
258
+ async reconcile() {
259
+ if (this.reconciling)
260
+ return;
261
+ this.reconciling = true;
262
+ try {
263
+ const installed = await this.scanInstalledSkills();
264
+ this.scanCache = { ts: Date.now(), skills: installed }; // 刷新 lazyCheck 缓存,保证安装后对账的新鲜度
265
+ this.debug(`reconcile: Found ${installed.length} installed skills.`);
266
+ const { toFetch, removed, cached } = this.diffAgainstState(installed);
267
+ if (toFetch.length === 0 && removed.length === 0 && cached.length === 0)
268
+ return;
269
+ // 命中本地池的,直接置为活跃
270
+ for (const s of cached) {
271
+ this.signatures.set(s.name, s.signature);
272
+ this.configs.set(s.name, this.configPool.get(`${s.name}@${s.version || "unknown"}`));
273
+ this.debug(`reconcile: Skill ${s.name}@${s.version} instantly loaded from local configPool.`);
274
+ }
275
+ if (toFetch.length > 0) {
276
+ this.debug(`reconcile: Fetching configs for ${toFetch.length} skills...`);
277
+ const { ok, configs } = await this.pullConfigs(toFetch.map((s) => ({ name: s.name, version: s.version })));
278
+ // 仅在拿到确定性结果时推进签名;拉取失败则不推进,下轮重试。
279
+ if (ok) {
280
+ const byName = new Map(configs.map((c) => [c.skillName, c]));
281
+ this.debug(`reconcile: Fetched ${configs.length} configs successfully.`);
282
+ for (const s of toFetch) {
283
+ const cfg = byName.get(s.name);
284
+ // If the platform says this config is still being processed or reviewed,
285
+ // we skip updating the signature so it will be retried in the next reconcile.
286
+ if (cfg && (cfg.status === 'REVIEW_NEEDED' || cfg.status === 'EXTRACTING')) {
287
+ this.debug(`reconcile: Skill ${s.name} is ${cfg.status}, skipping signature update.`);
288
+ continue;
289
+ }
290
+ this.signatures.set(s.name, s.signature);
291
+ if (cfg) {
292
+ this.configs.set(s.name, cfg);
293
+ this.configPool.set(`${s.name}@${s.version || "unknown"}`, cfg);
294
+ }
295
+ else {
296
+ this.configs.delete(s.name); // 平台明确无此配置
297
+ }
298
+ }
299
+ }
300
+ }
301
+ for (const name of removed) {
302
+ this.signatures.delete(name);
303
+ this.configs.delete(name);
304
+ }
305
+ this.rebuildIndex();
306
+ await this.persist();
307
+ }
308
+ catch (err) {
309
+ console.warn("[skill-logger-plugin] reconcile 异常", err);
310
+ }
311
+ finally {
312
+ this.reconciling = false;
313
+ }
314
+ }
315
+ /** 懒触发:仅检查某个被触发的 skill,缺配置/签名变才拉。`ident` 可为规范名或目录名。 */
316
+ async lazyCheck(ident) {
317
+ try {
318
+ const find = (list) => list.find((x) => x.name === ident || path.basename(x.rootDir) === ident);
319
+ let s = find(await this.scanInstalledSkillsCached());
320
+ // 缓存里没有该 skill:可能是刚安装的新 skill,强制全新扫描兜底,行为与未加缓存前一致。
321
+ if (!s) {
322
+ const fresh = await this.scanInstalledSkills();
323
+ this.scanCache = { ts: Date.now(), skills: fresh };
324
+ s = find(fresh);
325
+ }
326
+ if (!s)
327
+ return;
328
+ if (this.signatures.get(s.name) === s.signature && this.configs.has(s.name))
329
+ return;
330
+ const cacheKey = `${s.name}@${s.version || "unknown"}`;
331
+ if (this.configPool.has(cacheKey)) {
332
+ this.debug(`lazyCheck: Skill ${s.name}@${s.version} loaded instantly from local configPool.`);
333
+ this.signatures.set(s.name, s.signature);
334
+ this.configs.set(s.name, this.configPool.get(cacheKey));
335
+ this.rebuildIndex();
336
+ await this.persist();
337
+ return;
338
+ }
339
+ this.debug(`lazyCheck: Fetching config for ${s.name}@${s.version}...`);
340
+ const { ok, configs } = await this.pullConfigs([{ name: s.name, version: s.version }]);
341
+ if (!ok)
342
+ return; // 拉取失败,保留旧状态,下轮重试
343
+ this.debug(`lazyCheck: Fetched ${configs.length} configs successfully.`);
344
+ const cfg = configs.find((c) => c.skillName === s.name);
345
+ if (cfg && (cfg.status === 'REVIEW_NEEDED' || cfg.status === 'EXTRACTING')) {
346
+ this.debug(`lazyCheck: Skill ${s.name} is ${cfg.status}, skipping signature update.`);
347
+ return;
348
+ }
349
+ this.signatures.set(s.name, s.signature);
350
+ if (cfg) {
351
+ this.configs.set(s.name, cfg);
352
+ this.configPool.set(cacheKey, cfg);
353
+ }
354
+ else {
355
+ this.configs.delete(s.name);
356
+ }
357
+ this.rebuildIndex();
358
+ await this.persist();
359
+ }
360
+ catch (err) {
361
+ console.warn("[skill-logger-plugin] lazyCheck 异常", err);
362
+ }
363
+ }
364
+ /**
365
+ * 拉取标准配置。配了 platformBaseUrl → POST 平台;否则用本地静态桩。
366
+ *
367
+ * 返回 `ok` 区分「确定性结果」与「拉取失败」:
368
+ * - ok=true :拿到了平台的明确答复(configs 可能为空,表示平台对这些 skill 暂无配置)。
369
+ * - ok=false :网络/服务异常,调用方**不应**推进签名,下轮重试。
370
+ */
371
+ async pullConfigs(skillRefs) {
372
+ const config = this.getConfig();
373
+ if (config.platformBaseUrl) {
374
+ try {
375
+ const url = config.platformBaseUrl.replace(/\/$/, "") + "/skill_config/pull";
376
+ const headers = { "Content-Type": "application/json" };
377
+ if (config.authToken)
378
+ headers.Authorization = config.authToken;
379
+ const res = await this.fetchImpl(url, {
380
+ method: "POST",
381
+ headers,
382
+ body: JSON.stringify({ skills: skillRefs }),
383
+ });
384
+ if (!res.ok) {
385
+ console.warn("[skill-logger-plugin] 拉取标准配置失败,HTTP", res.status);
386
+ return { ok: false, configs: [] };
387
+ }
388
+ const data = (await res.json());
389
+ return { ok: true, configs: data.configs ?? [] };
390
+ }
391
+ catch (err) {
392
+ console.warn("[skill-logger-plugin] 拉取标准配置异常", err);
393
+ return { ok: false, configs: [] };
394
+ }
395
+ }
396
+ // 本地静态桩:仅返回请求到的 skill(视为确定性结果)
397
+ const want = new Set(skillRefs.map((r) => r.name));
398
+ return { ok: true, configs: (await this.loadSampleConfigs()).filter((c) => want.has(c.skillName)) };
399
+ }
400
+ async loadSampleConfigs() {
401
+ if (this.sampleConfigs)
402
+ return this.sampleConfigs;
403
+ try {
404
+ const here = path.dirname(fileURLToPath(import.meta.url));
405
+ const raw = await fs.readFile(path.join(here, "sample-config.json"), "utf-8");
406
+ this.sampleConfigs = JSON.parse(raw).configs;
407
+ }
408
+ catch {
409
+ this.sampleConfigs = [];
410
+ }
411
+ return this.sampleConfigs;
412
+ }
413
+ rebuildIndex() {
414
+ this.index = buildIndex([...this.configs.values()]);
415
+ }
416
+ async persist() {
417
+ try {
418
+ const state = { active: {}, configPool: {}, installations: {} };
419
+ for (const [name, signature] of this.signatures) {
420
+ state.active[name] = { signature };
421
+ }
422
+ for (const [key, cfg] of this.configPool) {
423
+ state.configPool[key] = cfg;
424
+ }
425
+ // agent↔skill 安装映射:本地留存每个 skill 在各 workspace 的副本与版本。
426
+ for (const [name, copies] of this.installations) {
427
+ state.installations[name] = copies.map((c) => ({ rootDir: c.rootDir, version: c.version }));
428
+ }
429
+ await fs.mkdir(path.dirname(this.paths.syncStatePath), { recursive: true });
430
+ // 原子写:写临时文件再 rename,避免 reconcile 与版本检查并发持久化时相互写坏。
431
+ const tmp = `${this.paths.syncStatePath}.tmp-${process.pid}-${Date.now()}`;
432
+ await fs.writeFile(tmp, JSON.stringify(state));
433
+ await fs.rename(tmp, this.paths.syncStatePath);
434
+ }
435
+ catch (err) {
436
+ console.warn("[skill-logger-plugin] 持久化同步状态失败", err);
437
+ }
438
+ }
439
+ }
@@ -0,0 +1,145 @@
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 { ConfigSync, parseSkillVersion } from "./config-sync.ts";
7
+ let dir;
8
+ let extensionsDir;
9
+ let syncStatePath;
10
+ const sampleConfigs = [
11
+ { skillName: "foo", version: "1", functions: [{ id: "run", name: "运行", match: { type: "script", script: "scripts/run.py" } }] },
12
+ { skillName: "bar", version: "1", functions: [{ id: "x", name: "x", match: { type: "tool", toolName: "bar_x" } }] },
13
+ ];
14
+ async function writeSkill(name, withScripts = true) {
15
+ const skillDir = path.join(extensionsDir, "myext", "skills", name);
16
+ await fs.mkdir(skillDir, { recursive: true });
17
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n# ${name}\n`);
18
+ if (withScripts) {
19
+ await fs.mkdir(path.join(skillDir, "scripts"), { recursive: true });
20
+ await fs.writeFile(path.join(skillDir, "scripts", "run.py"), "print(1)\n");
21
+ }
22
+ }
23
+ function makeSync() {
24
+ return new ConfigSync({
25
+ paths: { extensionsDir, syncStatePath, openclawConfigPath: path.join(dir, "openclaw.json") },
26
+ getConfig: () => ({}), // 无 platformBaseUrl → 用静态桩
27
+ sampleConfigs,
28
+ // 测试注入固定扫描目录,跳过 openclaw.json 解析;skill 实际写在 extensionsDir 下。
29
+ resolveSkillDirs: () => [path.join(os.tmpdir(), "openclaw-test-skills")],
30
+ });
31
+ }
32
+ beforeEach(async () => {
33
+ dir = path.join(os.tmpdir(), `slp-sync-${Date.now()}-${Math.random().toString(36).slice(2)}`);
34
+ extensionsDir = path.join(dir, "extensions");
35
+ syncStatePath = path.join(dir, "sync.json");
36
+ await fs.mkdir(extensionsDir, { recursive: true });
37
+ });
38
+ afterEach(async () => {
39
+ await fs.rm(dir, { recursive: true, force: true });
40
+ });
41
+ describe("parseSkillVersion(中英文兼容、纯正则)", () => {
42
+ const fm = (body) => `---\n${body}\n---\n# skill\n`;
43
+ it("英文 version", () => assert.equal(parseSkillVersion(fm("name: x\nversion: 1.2.3")), "1.2.3"));
44
+ it("大写 Version", () => assert.equal(parseSkillVersion(fm("Version: 2.0.0")), "2.0.0"));
45
+ it("中文 版本号 + 全角冒号", () => assert.equal(parseSkillVersion(fm("版本号:3.1")), "3.1"));
46
+ it("中文 版本 + 半角冒号", () => assert.equal(parseSkillVersion(fm("版本: v4")), "v4"));
47
+ it("去除包裹引号", () => assert.equal(parseSkillVersion(fm('version: "1.0.0"')), "1.0.0"));
48
+ it("去除行尾注释", () => assert.equal(parseSkillVersion(fm("version: 1.0.0 # latest")), "1.0.0"));
49
+ it("无版本字段返回 undefined", () => assert.equal(parseSkillVersion(fm("name: x")), undefined));
50
+ it("无 frontmatter 时全文宽松查找", () => assert.equal(parseSkillVersion("版本号: 9.9\n正文"), "9.9"));
51
+ it("版本写在正文单独一行 → 能取到", () => assert.equal(parseSkillVersion(fm("name: x") + "\n版本号:7.7\n"), "7.7"));
52
+ it("Markdown 标题 ## Version 不误取(# 前缀)", () => assert.equal(parseSkillVersion(fm("name: x") + "\n## Version: 5.5\n"), undefined));
53
+ it("frontmatter 与正文都有版本 → frontmatter 优先", () => assert.equal(parseSkillVersion(fm("version: 1.0.0") + "\nversion: 2.0.0\n"), "1.0.0"));
54
+ });
55
+ describe("ConfigSync.scanInstalledSkills", () => {
56
+ it("扫描出 SKILL.md 并解析 name", async () => {
57
+ await writeSkill("foo");
58
+ const skills = await makeSync().scanInstalledSkills();
59
+ const foo = skills.find((s) => s.name === "foo");
60
+ assert.ok(foo);
61
+ assert.ok(foo.signature.length > 0);
62
+ });
63
+ it("跳过 node_modules", async () => {
64
+ const nm = path.join(extensionsDir, "myext", "node_modules", "pkg", "skills", "ghost");
65
+ await fs.mkdir(nm, { recursive: true });
66
+ await fs.writeFile(path.join(nm, "SKILL.md"), `---\nname: ghost\n---\n`);
67
+ const skills = await makeSync().scanInstalledSkills();
68
+ assert.equal(skills.find((s) => s.name === "ghost"), undefined);
69
+ });
70
+ });
71
+ describe("ConfigSync.diffAgainstState", () => {
72
+ it("初次全部需拉取", async () => {
73
+ await writeSkill("foo");
74
+ const cs = makeSync();
75
+ const installed = await cs.scanInstalledSkills();
76
+ const { toFetch } = cs.diffAgainstState(installed);
77
+ assert.equal(toFetch.length, 1);
78
+ assert.equal(toFetch[0].name, "foo");
79
+ });
80
+ });
81
+ describe("ConfigSync.pullConfigs(静态桩)", () => {
82
+ it("仅返回请求到的 skill(ok=true)", async () => {
83
+ const got = await makeSync().pullConfigs([{ name: "foo" }]);
84
+ assert.equal(got.ok, true);
85
+ assert.equal(got.configs.length, 1);
86
+ assert.equal(got.configs[0].skillName, "foo");
87
+ });
88
+ });
89
+ describe("ConfigSync 多 workspace 映射与过期检测", () => {
90
+ async function writeSkillIn(wsDir, name, version) {
91
+ const skillDir = path.join(wsDir, "skills", name);
92
+ await fs.mkdir(skillDir, { recursive: true });
93
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\nversion: ${version}\n---\n`);
94
+ return skillDir;
95
+ }
96
+ it("同名 skill 在多个 workspace → 映射记录全部副本,去重后只返回一份", async () => {
97
+ const wsA = path.join(dir, "workspace-a");
98
+ const wsB = path.join(dir, "workspace-b");
99
+ await writeSkillIn(wsA, "demo", "1.0.0");
100
+ await writeSkillIn(wsB, "demo", "1.0.0");
101
+ const cs = new ConfigSync({
102
+ paths: { extensionsDir, syncStatePath, openclawConfigPath: path.join(dir, "openclaw.json") },
103
+ getConfig: () => ({}),
104
+ sampleConfigs,
105
+ resolveSkillDirs: () => [path.join(wsA, "skills"), path.join(wsB, "skills")],
106
+ });
107
+ const list = await cs.scanInstalledSkills();
108
+ assert.equal(list.filter((s) => s.name === "demo").length, 1); // 去重
109
+ assert.equal(cs.getInstallations().get("demo")?.length, 2); // 映射保留两份
110
+ });
111
+ it("detectOutdated:本地版本落后于平台最新版本时被标记", async () => {
112
+ const wsA = path.join(dir, "workspace-a");
113
+ await writeSkillIn(wsA, "demo", "1.0.0");
114
+ const cs = new ConfigSync({
115
+ paths: { extensionsDir, syncStatePath, openclawConfigPath: path.join(dir, "openclaw.json") },
116
+ getConfig: () => ({}),
117
+ // 平台最新版本 2.0.0(桩无 latestVersion 字段 → 回退用 version 当最新)
118
+ sampleConfigs: [{ skillName: "demo", version: "2.0.0", functions: [] }],
119
+ resolveSkillDirs: () => [path.join(wsA, "skills")],
120
+ });
121
+ await cs.checkVersionsAndUpdate(); // 刷新 latestVersions + 检测(无 updater 注入则不覆盖文件)
122
+ const outdated = cs.detectOutdated();
123
+ assert.equal(outdated.length, 1);
124
+ assert.equal(outdated[0].skillName, "demo");
125
+ assert.equal(outdated[0].localVersion, "1.0.0");
126
+ assert.equal(outdated[0].latestVersion, "2.0.0");
127
+ // agent↔skill 映射应本地持久化到 sync.json
128
+ const state = JSON.parse(await fs.readFile(syncStatePath, "utf-8"));
129
+ assert.ok(state.installations.demo, "sync.json 应含 installations 映射");
130
+ assert.equal(state.installations.demo[0].version, "1.0.0");
131
+ });
132
+ });
133
+ describe("ConfigSync.reconcile 端到端", () => {
134
+ it("对账后索引含该 skill 功能点,并持久化", async () => {
135
+ await writeSkill("foo");
136
+ const cs = makeSync();
137
+ await cs.reconcile();
138
+ // foo 的 run.py 应进入 script 索引
139
+ assert.ok(cs.getIndex().scriptByBasename.has("run.py"));
140
+ // 持久化文件存在且含 foo
141
+ const state = JSON.parse(await fs.readFile(syncStatePath, "utf-8"));
142
+ assert.ok(state.active.foo);
143
+ assert.ok(state.configPool["foo@unknown"]);
144
+ });
145
+ });