@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.
- 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 +240 -78
- 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 -50
- package/package.json +37 -37
- package/src/active-skills.test.ts +32 -32
- package/src/active-skills.ts +77 -77
- package/src/config-sync.test.ts +165 -165
- package/src/config-sync.ts +544 -544
- package/src/hooks.test.ts +251 -251
- package/src/hooks.ts +517 -517
- package/src/http.ts +61 -61
- package/src/identity.ts +64 -64
- package/src/index.test.ts +53 -53
- package/src/index.ts +226 -226
- package/src/integration.test.ts +119 -119
- package/src/matcher.test.ts +170 -170
- package/src/matcher.ts +393 -393
- package/src/paths.test.ts +57 -57
- package/src/paths.ts +84 -84
- package/src/reporter.test.ts +139 -139
- package/src/reporter.ts +298 -298
- package/src/sample-config.json +72 -72
- package/src/semver.test.ts +23 -23
- package/src/semver.ts +60 -60
- package/src/skill-version.ts +53 -53
- package/src/types.ts +198 -198
- package/src/updater.test.ts +325 -237
- package/src/updater.ts +549 -433
- package/src/ws-client.test.ts +48 -37
- package/src/ws-client.ts +717 -642
- package/test-ws.ts +17 -17
- package/tsconfig.json +14 -14
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
import { describe, it } from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { ActiveSkills } from "./active-skills.ts";
|
|
4
|
-
|
|
5
|
-
describe("ActiveSkills", () => {
|
|
6
|
-
it("标记并取回激活 skill", () => {
|
|
7
|
-
const a = new ActiveSkills();
|
|
8
|
-
a.markActive("s1", "skA");
|
|
9
|
-
a.markActive("s1", "skB");
|
|
10
|
-
assert.deepEqual([...a.getActive("s1")].sort(), ["skA", "skB"]);
|
|
11
|
-
assert.deepEqual([...a.getActive("s2")], []);
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("TTL 过期后不再返回", () => {
|
|
15
|
-
let t = 1000;
|
|
16
|
-
const a = new ActiveSkills({ ttlMs: 100, now: () => t });
|
|
17
|
-
a.markActive("s", "skA");
|
|
18
|
-
t = 1050;
|
|
19
|
-
assert.deepEqual([...a.getActive("s")], ["skA"]);
|
|
20
|
-
t = 2000;
|
|
21
|
-
assert.deepEqual([...a.getActive("s")], []);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("超出 session 上限按 LRU 淘汰最旧", () => {
|
|
25
|
-
const a = new ActiveSkills({ maxSessions: 2 });
|
|
26
|
-
a.markActive("s1", "x");
|
|
27
|
-
a.markActive("s2", "x");
|
|
28
|
-
a.markActive("s3", "x"); // 淘汰 s1
|
|
29
|
-
assert.deepEqual([...a.getActive("s1")], []);
|
|
30
|
-
assert.deepEqual([...a.getActive("s3")], ["x"]);
|
|
31
|
-
});
|
|
32
|
-
});
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { ActiveSkills } from "./active-skills.ts";
|
|
4
|
+
|
|
5
|
+
describe("ActiveSkills", () => {
|
|
6
|
+
it("标记并取回激活 skill", () => {
|
|
7
|
+
const a = new ActiveSkills();
|
|
8
|
+
a.markActive("s1", "skA");
|
|
9
|
+
a.markActive("s1", "skB");
|
|
10
|
+
assert.deepEqual([...a.getActive("s1")].sort(), ["skA", "skB"]);
|
|
11
|
+
assert.deepEqual([...a.getActive("s2")], []);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("TTL 过期后不再返回", () => {
|
|
15
|
+
let t = 1000;
|
|
16
|
+
const a = new ActiveSkills({ ttlMs: 100, now: () => t });
|
|
17
|
+
a.markActive("s", "skA");
|
|
18
|
+
t = 1050;
|
|
19
|
+
assert.deepEqual([...a.getActive("s")], ["skA"]);
|
|
20
|
+
t = 2000;
|
|
21
|
+
assert.deepEqual([...a.getActive("s")], []);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("超出 session 上限按 LRU 淘汰最旧", () => {
|
|
25
|
+
const a = new ActiveSkills({ maxSessions: 2 });
|
|
26
|
+
a.markActive("s1", "x");
|
|
27
|
+
a.markActive("s2", "x");
|
|
28
|
+
a.markActive("s3", "x"); // 淘汰 s1
|
|
29
|
+
assert.deepEqual([...a.getActive("s1")], []);
|
|
30
|
+
assert.deepEqual([...a.getActive("s3")], ["x"]);
|
|
31
|
+
});
|
|
32
|
+
});
|
package/src/active-skills.ts
CHANGED
|
@@ -1,77 +1,77 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 按 session 记录「已触发(激活)的 skill」,供 matcher 在通用命令(curl、共享 CLI)
|
|
3
|
-
* 出现多候选时消歧——优先归属到本 session 已激活的 skill。
|
|
4
|
-
*
|
|
5
|
-
* 内存态,带 TTL + 容量上限,防止长跑 gateway 进程里无限增长。
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
type SkillEntry = { name: string; ts: number };
|
|
9
|
-
|
|
10
|
-
const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 分钟
|
|
11
|
-
const DEFAULT_MAX_SESSIONS = 500;
|
|
12
|
-
|
|
13
|
-
export type ActiveSkillsOptions = {
|
|
14
|
-
ttlMs?: number;
|
|
15
|
-
maxSessions?: number;
|
|
16
|
-
/** 注入时钟,便于测试。 */
|
|
17
|
-
now?: () => number;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export class ActiveSkills {
|
|
21
|
-
private readonly ttlMs: number;
|
|
22
|
-
private readonly maxSessions: number;
|
|
23
|
-
private readonly now: () => number;
|
|
24
|
-
/** sessionKey -> (skillName -> entry)。用 Map 保留插入顺序以便 LRU 淘汰。 */
|
|
25
|
-
private readonly bySession = new Map<string, Map<string, SkillEntry>>();
|
|
26
|
-
|
|
27
|
-
constructor(opts: ActiveSkillsOptions = {}) {
|
|
28
|
-
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
29
|
-
this.maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
30
|
-
this.now = opts.now ?? Date.now;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** 标记某 session 触发了某 skill。 */
|
|
34
|
-
markActive(sessionKey: string | undefined, skillName: string): void {
|
|
35
|
-
const key = sessionKey || "__nosession__";
|
|
36
|
-
let skills = this.bySession.get(key);
|
|
37
|
-
if (!skills) {
|
|
38
|
-
skills = new Map();
|
|
39
|
-
this.bySession.set(key, skills);
|
|
40
|
-
} else {
|
|
41
|
-
// 触碰即刷新 LRU 顺序
|
|
42
|
-
this.bySession.delete(key);
|
|
43
|
-
this.bySession.set(key, skills);
|
|
44
|
-
}
|
|
45
|
-
skills.set(skillName, { name: skillName, ts: this.now() });
|
|
46
|
-
this.evictIfNeeded();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** 取某 session 当前仍在 TTL 内的已激活 skill 集合。 */
|
|
50
|
-
getActive(sessionKey: string | undefined): Set<string> {
|
|
51
|
-
const key = sessionKey || "__nosession__";
|
|
52
|
-
const skills = this.bySession.get(key);
|
|
53
|
-
const out = new Set<string>();
|
|
54
|
-
if (!skills) return out;
|
|
55
|
-
const cutoff = this.now() - this.ttlMs;
|
|
56
|
-
for (const [name, entry] of skills) {
|
|
57
|
-
if (entry.ts >= cutoff) out.add(name);
|
|
58
|
-
else skills.delete(name);
|
|
59
|
-
}
|
|
60
|
-
if (skills.size === 0) this.bySession.delete(key);
|
|
61
|
-
return out;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** session 结束时清掉其激活记录(释放内存 + 避免跨会话误判)。 */
|
|
65
|
-
clearSession(sessionKey: string | undefined): void {
|
|
66
|
-
this.bySession.delete(sessionKey || "__nosession__");
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** 超出 session 上限时,按 LRU 淘汰最旧的 session。 */
|
|
70
|
-
private evictIfNeeded(): void {
|
|
71
|
-
while (this.bySession.size > this.maxSessions) {
|
|
72
|
-
const oldest = this.bySession.keys().next().value;
|
|
73
|
-
if (oldest === undefined) break;
|
|
74
|
-
this.bySession.delete(oldest);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 按 session 记录「已触发(激活)的 skill」,供 matcher 在通用命令(curl、共享 CLI)
|
|
3
|
+
* 出现多候选时消歧——优先归属到本 session 已激活的 skill。
|
|
4
|
+
*
|
|
5
|
+
* 内存态,带 TTL + 容量上限,防止长跑 gateway 进程里无限增长。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
type SkillEntry = { name: string; ts: number };
|
|
9
|
+
|
|
10
|
+
const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 分钟
|
|
11
|
+
const DEFAULT_MAX_SESSIONS = 500;
|
|
12
|
+
|
|
13
|
+
export type ActiveSkillsOptions = {
|
|
14
|
+
ttlMs?: number;
|
|
15
|
+
maxSessions?: number;
|
|
16
|
+
/** 注入时钟,便于测试。 */
|
|
17
|
+
now?: () => number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export class ActiveSkills {
|
|
21
|
+
private readonly ttlMs: number;
|
|
22
|
+
private readonly maxSessions: number;
|
|
23
|
+
private readonly now: () => number;
|
|
24
|
+
/** sessionKey -> (skillName -> entry)。用 Map 保留插入顺序以便 LRU 淘汰。 */
|
|
25
|
+
private readonly bySession = new Map<string, Map<string, SkillEntry>>();
|
|
26
|
+
|
|
27
|
+
constructor(opts: ActiveSkillsOptions = {}) {
|
|
28
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
29
|
+
this.maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
30
|
+
this.now = opts.now ?? Date.now;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 标记某 session 触发了某 skill。 */
|
|
34
|
+
markActive(sessionKey: string | undefined, skillName: string): void {
|
|
35
|
+
const key = sessionKey || "__nosession__";
|
|
36
|
+
let skills = this.bySession.get(key);
|
|
37
|
+
if (!skills) {
|
|
38
|
+
skills = new Map();
|
|
39
|
+
this.bySession.set(key, skills);
|
|
40
|
+
} else {
|
|
41
|
+
// 触碰即刷新 LRU 顺序
|
|
42
|
+
this.bySession.delete(key);
|
|
43
|
+
this.bySession.set(key, skills);
|
|
44
|
+
}
|
|
45
|
+
skills.set(skillName, { name: skillName, ts: this.now() });
|
|
46
|
+
this.evictIfNeeded();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 取某 session 当前仍在 TTL 内的已激活 skill 集合。 */
|
|
50
|
+
getActive(sessionKey: string | undefined): Set<string> {
|
|
51
|
+
const key = sessionKey || "__nosession__";
|
|
52
|
+
const skills = this.bySession.get(key);
|
|
53
|
+
const out = new Set<string>();
|
|
54
|
+
if (!skills) return out;
|
|
55
|
+
const cutoff = this.now() - this.ttlMs;
|
|
56
|
+
for (const [name, entry] of skills) {
|
|
57
|
+
if (entry.ts >= cutoff) out.add(name);
|
|
58
|
+
else skills.delete(name);
|
|
59
|
+
}
|
|
60
|
+
if (skills.size === 0) this.bySession.delete(key);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** session 结束时清掉其激活记录(释放内存 + 避免跨会话误判)。 */
|
|
65
|
+
clearSession(sessionKey: string | undefined): void {
|
|
66
|
+
this.bySession.delete(sessionKey || "__nosession__");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 超出 session 上限时,按 LRU 淘汰最旧的 session。 */
|
|
70
|
+
private evictIfNeeded(): void {
|
|
71
|
+
while (this.bySession.size > this.maxSessions) {
|
|
72
|
+
const oldest = this.bySession.keys().next().value;
|
|
73
|
+
if (oldest === undefined) break;
|
|
74
|
+
this.bySession.delete(oldest);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/config-sync.test.ts
CHANGED
|
@@ -1,165 +1,165 @@
|
|
|
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
|
-
import type { SkillStandardConfig } from "./types.ts";
|
|
8
|
-
|
|
9
|
-
let dir: string;
|
|
10
|
-
let extensionsDir: string;
|
|
11
|
-
let syncStatePath: string;
|
|
12
|
-
|
|
13
|
-
const sampleConfigs: SkillStandardConfig[] = [
|
|
14
|
-
{ skillName: "foo", version: "1", functions: [{ id: "run", name: "运行", match: { type: "script", script: "scripts/run.py" } }] },
|
|
15
|
-
{ skillName: "bar", version: "1", functions: [{ id: "x", name: "x", match: { type: "tool", toolName: "bar_x" } }] },
|
|
16
|
-
];
|
|
17
|
-
|
|
18
|
-
async function writeSkill(name: string, withScripts = true): Promise<void> {
|
|
19
|
-
const skillDir = path.join(extensionsDir, "myext", "skills", name);
|
|
20
|
-
await fs.mkdir(skillDir, { recursive: true });
|
|
21
|
-
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n# ${name}\n`);
|
|
22
|
-
if (withScripts) {
|
|
23
|
-
await fs.mkdir(path.join(skillDir, "scripts"), { recursive: true });
|
|
24
|
-
await fs.writeFile(path.join(skillDir, "scripts", "run.py"), "print(1)\n");
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function makeSync() {
|
|
29
|
-
return new ConfigSync({
|
|
30
|
-
paths: { extensionsDir, syncStatePath, openclawConfigPath: path.join(dir, "openclaw.json") },
|
|
31
|
-
getConfig: () => ({}), // 无 platformBaseUrl → 用静态桩
|
|
32
|
-
sampleConfigs,
|
|
33
|
-
// 测试注入固定扫描目录,跳过 openclaw.json 解析;skill 实际写在 extensionsDir 下。
|
|
34
|
-
resolveSkillDirs: () => [path.join(os.tmpdir(), "openclaw-test-skills")],
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
beforeEach(async () => {
|
|
39
|
-
dir = path.join(os.tmpdir(), `slp-sync-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
40
|
-
extensionsDir = path.join(dir, "extensions");
|
|
41
|
-
syncStatePath = path.join(dir, "sync.json");
|
|
42
|
-
await fs.mkdir(extensionsDir, { recursive: true });
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
afterEach(async () => {
|
|
46
|
-
await fs.rm(dir, { recursive: true, force: true });
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
describe("parseSkillVersion(中英文兼容、纯正则)", () => {
|
|
50
|
-
const fm = (body: string) => `---\n${body}\n---\n# skill\n`;
|
|
51
|
-
it("英文 version", () => assert.equal(parseSkillVersion(fm("name: x\nversion: 1.2.3")), "1.2.3"));
|
|
52
|
-
it("大写 Version", () => assert.equal(parseSkillVersion(fm("Version: 2.0.0")), "2.0.0"));
|
|
53
|
-
it("中文 版本号 + 全角冒号", () => assert.equal(parseSkillVersion(fm("版本号:3.1")), "3.1"));
|
|
54
|
-
it("中文 版本 + 半角冒号", () => assert.equal(parseSkillVersion(fm("版本: v4")), "v4"));
|
|
55
|
-
it("去除包裹引号", () => assert.equal(parseSkillVersion(fm('version: "1.0.0"')), "1.0.0"));
|
|
56
|
-
it("去除行尾注释", () => assert.equal(parseSkillVersion(fm("version: 1.0.0 # latest")), "1.0.0"));
|
|
57
|
-
it("无版本字段返回 undefined", () => assert.equal(parseSkillVersion(fm("name: x")), undefined));
|
|
58
|
-
it("无 frontmatter 时全文宽松查找", () => assert.equal(parseSkillVersion("版本号: 9.9\n正文"), "9.9"));
|
|
59
|
-
it("版本写在正文单独一行 → 能取到", () =>
|
|
60
|
-
assert.equal(parseSkillVersion(fm("name: x") + "\n版本号:7.7\n"), "7.7"));
|
|
61
|
-
it("Markdown 标题 ## Version 不误取(# 前缀)", () =>
|
|
62
|
-
assert.equal(parseSkillVersion(fm("name: x") + "\n## Version: 5.5\n"), undefined));
|
|
63
|
-
it("frontmatter 与正文都有版本 → frontmatter 优先", () =>
|
|
64
|
-
assert.equal(parseSkillVersion(fm("version: 1.0.0") + "\nversion: 2.0.0\n"), "1.0.0"));
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe("ConfigSync.scanInstalledSkills", () => {
|
|
68
|
-
it("扫描出 SKILL.md 并解析 name", async () => {
|
|
69
|
-
await writeSkill("foo");
|
|
70
|
-
const skills = await makeSync().scanInstalledSkills();
|
|
71
|
-
const foo = skills.find((s) => s.name === "foo");
|
|
72
|
-
assert.ok(foo);
|
|
73
|
-
assert.ok(foo!.signature.length > 0);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("跳过 node_modules", async () => {
|
|
77
|
-
const nm = path.join(extensionsDir, "myext", "node_modules", "pkg", "skills", "ghost");
|
|
78
|
-
await fs.mkdir(nm, { recursive: true });
|
|
79
|
-
await fs.writeFile(path.join(nm, "SKILL.md"), `---\nname: ghost\n---\n`);
|
|
80
|
-
const skills = await makeSync().scanInstalledSkills();
|
|
81
|
-
assert.equal(skills.find((s) => s.name === "ghost"), undefined);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
describe("ConfigSync.diffAgainstState", () => {
|
|
86
|
-
it("初次全部需拉取", async () => {
|
|
87
|
-
await writeSkill("foo");
|
|
88
|
-
const cs = makeSync();
|
|
89
|
-
const installed = await cs.scanInstalledSkills();
|
|
90
|
-
const { toFetch } = cs.diffAgainstState(installed);
|
|
91
|
-
assert.equal(toFetch.length, 1);
|
|
92
|
-
assert.equal(toFetch[0].name, "foo");
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe("ConfigSync.pullConfigs(静态桩)", () => {
|
|
97
|
-
it("仅返回请求到的 skill(ok=true)", async () => {
|
|
98
|
-
const got = await makeSync().pullConfigs([{ name: "foo" }]);
|
|
99
|
-
assert.equal(got.ok, true);
|
|
100
|
-
assert.equal(got.configs.length, 1);
|
|
101
|
-
assert.equal(got.configs[0].skillName, "foo");
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
describe("ConfigSync 多 workspace 映射与过期检测", () => {
|
|
106
|
-
async function writeSkillIn(wsDir: string, name: string, version: string): Promise<string> {
|
|
107
|
-
const skillDir = path.join(wsDir, "skills", name);
|
|
108
|
-
await fs.mkdir(skillDir, { recursive: true });
|
|
109
|
-
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\nversion: ${version}\n---\n`);
|
|
110
|
-
return skillDir;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
it("同名 skill 在多个 workspace → 映射记录全部副本,去重后只返回一份", async () => {
|
|
114
|
-
const wsA = path.join(dir, "workspace-a");
|
|
115
|
-
const wsB = path.join(dir, "workspace-b");
|
|
116
|
-
await writeSkillIn(wsA, "demo", "1.0.0");
|
|
117
|
-
await writeSkillIn(wsB, "demo", "1.0.0");
|
|
118
|
-
const cs = new ConfigSync({
|
|
119
|
-
paths: { extensionsDir, syncStatePath, openclawConfigPath: path.join(dir, "openclaw.json") },
|
|
120
|
-
getConfig: () => ({}),
|
|
121
|
-
sampleConfigs,
|
|
122
|
-
resolveSkillDirs: () => [path.join(wsA, "skills"), path.join(wsB, "skills")],
|
|
123
|
-
});
|
|
124
|
-
const list = await cs.scanInstalledSkills();
|
|
125
|
-
assert.equal(list.filter((s) => s.name === "demo").length, 1); // 去重
|
|
126
|
-
assert.equal(cs.getInstallations().get("demo")?.length, 2); // 映射保留两份
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("detectOutdated:本地版本落后于平台最新版本时被标记", async () => {
|
|
130
|
-
const wsA = path.join(dir, "workspace-a");
|
|
131
|
-
await writeSkillIn(wsA, "demo", "1.0.0");
|
|
132
|
-
const cs = new ConfigSync({
|
|
133
|
-
paths: { extensionsDir, syncStatePath, openclawConfigPath: path.join(dir, "openclaw.json") },
|
|
134
|
-
getConfig: () => ({}),
|
|
135
|
-
// 平台最新版本 2.0.0(桩无 latestVersion 字段 → 回退用 version 当最新)
|
|
136
|
-
sampleConfigs: [{ skillName: "demo", version: "2.0.0", functions: [] }],
|
|
137
|
-
resolveSkillDirs: () => [path.join(wsA, "skills")],
|
|
138
|
-
});
|
|
139
|
-
await cs.checkVersionsAndUpdate(); // 刷新 latestVersions + 检测(无 updater 注入则不覆盖文件)
|
|
140
|
-
const outdated = cs.detectOutdated();
|
|
141
|
-
assert.equal(outdated.length, 1);
|
|
142
|
-
assert.equal(outdated[0].skillName, "demo");
|
|
143
|
-
assert.equal(outdated[0].localVersion, "1.0.0");
|
|
144
|
-
assert.equal(outdated[0].latestVersion, "2.0.0");
|
|
145
|
-
|
|
146
|
-
// agent↔skill 映射应本地持久化到 sync.json
|
|
147
|
-
const state = JSON.parse(await fs.readFile(syncStatePath, "utf-8"));
|
|
148
|
-
assert.ok(state.installations.demo, "sync.json 应含 installations 映射");
|
|
149
|
-
assert.equal(state.installations.demo[0].version, "1.0.0");
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
describe("ConfigSync.reconcile 端到端", () => {
|
|
154
|
-
it("对账后索引含该 skill 功能点,并持久化", async () => {
|
|
155
|
-
await writeSkill("foo");
|
|
156
|
-
const cs = makeSync();
|
|
157
|
-
await cs.reconcile();
|
|
158
|
-
// foo 的 run.py 应进入 script 索引
|
|
159
|
-
assert.ok(cs.getIndex().scriptByBasename.has("run.py"));
|
|
160
|
-
// 持久化文件存在且含 foo
|
|
161
|
-
const state = JSON.parse(await fs.readFile(syncStatePath, "utf-8"));
|
|
162
|
-
assert.ok(state.active.foo);
|
|
163
|
-
assert.ok(state.configPool["foo@unknown"]);
|
|
164
|
-
});
|
|
165
|
-
});
|
|
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
|
+
import type { SkillStandardConfig } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
let dir: string;
|
|
10
|
+
let extensionsDir: string;
|
|
11
|
+
let syncStatePath: string;
|
|
12
|
+
|
|
13
|
+
const sampleConfigs: SkillStandardConfig[] = [
|
|
14
|
+
{ skillName: "foo", version: "1", functions: [{ id: "run", name: "运行", match: { type: "script", script: "scripts/run.py" } }] },
|
|
15
|
+
{ skillName: "bar", version: "1", functions: [{ id: "x", name: "x", match: { type: "tool", toolName: "bar_x" } }] },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
async function writeSkill(name: string, withScripts = true): Promise<void> {
|
|
19
|
+
const skillDir = path.join(extensionsDir, "myext", "skills", name);
|
|
20
|
+
await fs.mkdir(skillDir, { recursive: true });
|
|
21
|
+
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n# ${name}\n`);
|
|
22
|
+
if (withScripts) {
|
|
23
|
+
await fs.mkdir(path.join(skillDir, "scripts"), { recursive: true });
|
|
24
|
+
await fs.writeFile(path.join(skillDir, "scripts", "run.py"), "print(1)\n");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeSync() {
|
|
29
|
+
return new ConfigSync({
|
|
30
|
+
paths: { extensionsDir, syncStatePath, openclawConfigPath: path.join(dir, "openclaw.json") },
|
|
31
|
+
getConfig: () => ({}), // 无 platformBaseUrl → 用静态桩
|
|
32
|
+
sampleConfigs,
|
|
33
|
+
// 测试注入固定扫描目录,跳过 openclaw.json 解析;skill 实际写在 extensionsDir 下。
|
|
34
|
+
resolveSkillDirs: () => [path.join(os.tmpdir(), "openclaw-test-skills")],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
dir = path.join(os.tmpdir(), `slp-sync-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
40
|
+
extensionsDir = path.join(dir, "extensions");
|
|
41
|
+
syncStatePath = path.join(dir, "sync.json");
|
|
42
|
+
await fs.mkdir(extensionsDir, { recursive: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("parseSkillVersion(中英文兼容、纯正则)", () => {
|
|
50
|
+
const fm = (body: string) => `---\n${body}\n---\n# skill\n`;
|
|
51
|
+
it("英文 version", () => assert.equal(parseSkillVersion(fm("name: x\nversion: 1.2.3")), "1.2.3"));
|
|
52
|
+
it("大写 Version", () => assert.equal(parseSkillVersion(fm("Version: 2.0.0")), "2.0.0"));
|
|
53
|
+
it("中文 版本号 + 全角冒号", () => assert.equal(parseSkillVersion(fm("版本号:3.1")), "3.1"));
|
|
54
|
+
it("中文 版本 + 半角冒号", () => assert.equal(parseSkillVersion(fm("版本: v4")), "v4"));
|
|
55
|
+
it("去除包裹引号", () => assert.equal(parseSkillVersion(fm('version: "1.0.0"')), "1.0.0"));
|
|
56
|
+
it("去除行尾注释", () => assert.equal(parseSkillVersion(fm("version: 1.0.0 # latest")), "1.0.0"));
|
|
57
|
+
it("无版本字段返回 undefined", () => assert.equal(parseSkillVersion(fm("name: x")), undefined));
|
|
58
|
+
it("无 frontmatter 时全文宽松查找", () => assert.equal(parseSkillVersion("版本号: 9.9\n正文"), "9.9"));
|
|
59
|
+
it("版本写在正文单独一行 → 能取到", () =>
|
|
60
|
+
assert.equal(parseSkillVersion(fm("name: x") + "\n版本号:7.7\n"), "7.7"));
|
|
61
|
+
it("Markdown 标题 ## Version 不误取(# 前缀)", () =>
|
|
62
|
+
assert.equal(parseSkillVersion(fm("name: x") + "\n## Version: 5.5\n"), undefined));
|
|
63
|
+
it("frontmatter 与正文都有版本 → frontmatter 优先", () =>
|
|
64
|
+
assert.equal(parseSkillVersion(fm("version: 1.0.0") + "\nversion: 2.0.0\n"), "1.0.0"));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("ConfigSync.scanInstalledSkills", () => {
|
|
68
|
+
it("扫描出 SKILL.md 并解析 name", async () => {
|
|
69
|
+
await writeSkill("foo");
|
|
70
|
+
const skills = await makeSync().scanInstalledSkills();
|
|
71
|
+
const foo = skills.find((s) => s.name === "foo");
|
|
72
|
+
assert.ok(foo);
|
|
73
|
+
assert.ok(foo!.signature.length > 0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("跳过 node_modules", async () => {
|
|
77
|
+
const nm = path.join(extensionsDir, "myext", "node_modules", "pkg", "skills", "ghost");
|
|
78
|
+
await fs.mkdir(nm, { recursive: true });
|
|
79
|
+
await fs.writeFile(path.join(nm, "SKILL.md"), `---\nname: ghost\n---\n`);
|
|
80
|
+
const skills = await makeSync().scanInstalledSkills();
|
|
81
|
+
assert.equal(skills.find((s) => s.name === "ghost"), undefined);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("ConfigSync.diffAgainstState", () => {
|
|
86
|
+
it("初次全部需拉取", async () => {
|
|
87
|
+
await writeSkill("foo");
|
|
88
|
+
const cs = makeSync();
|
|
89
|
+
const installed = await cs.scanInstalledSkills();
|
|
90
|
+
const { toFetch } = cs.diffAgainstState(installed);
|
|
91
|
+
assert.equal(toFetch.length, 1);
|
|
92
|
+
assert.equal(toFetch[0].name, "foo");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("ConfigSync.pullConfigs(静态桩)", () => {
|
|
97
|
+
it("仅返回请求到的 skill(ok=true)", async () => {
|
|
98
|
+
const got = await makeSync().pullConfigs([{ name: "foo" }]);
|
|
99
|
+
assert.equal(got.ok, true);
|
|
100
|
+
assert.equal(got.configs.length, 1);
|
|
101
|
+
assert.equal(got.configs[0].skillName, "foo");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("ConfigSync 多 workspace 映射与过期检测", () => {
|
|
106
|
+
async function writeSkillIn(wsDir: string, name: string, version: string): Promise<string> {
|
|
107
|
+
const skillDir = path.join(wsDir, "skills", name);
|
|
108
|
+
await fs.mkdir(skillDir, { recursive: true });
|
|
109
|
+
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\nversion: ${version}\n---\n`);
|
|
110
|
+
return skillDir;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
it("同名 skill 在多个 workspace → 映射记录全部副本,去重后只返回一份", async () => {
|
|
114
|
+
const wsA = path.join(dir, "workspace-a");
|
|
115
|
+
const wsB = path.join(dir, "workspace-b");
|
|
116
|
+
await writeSkillIn(wsA, "demo", "1.0.0");
|
|
117
|
+
await writeSkillIn(wsB, "demo", "1.0.0");
|
|
118
|
+
const cs = new ConfigSync({
|
|
119
|
+
paths: { extensionsDir, syncStatePath, openclawConfigPath: path.join(dir, "openclaw.json") },
|
|
120
|
+
getConfig: () => ({}),
|
|
121
|
+
sampleConfigs,
|
|
122
|
+
resolveSkillDirs: () => [path.join(wsA, "skills"), path.join(wsB, "skills")],
|
|
123
|
+
});
|
|
124
|
+
const list = await cs.scanInstalledSkills();
|
|
125
|
+
assert.equal(list.filter((s) => s.name === "demo").length, 1); // 去重
|
|
126
|
+
assert.equal(cs.getInstallations().get("demo")?.length, 2); // 映射保留两份
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("detectOutdated:本地版本落后于平台最新版本时被标记", async () => {
|
|
130
|
+
const wsA = path.join(dir, "workspace-a");
|
|
131
|
+
await writeSkillIn(wsA, "demo", "1.0.0");
|
|
132
|
+
const cs = new ConfigSync({
|
|
133
|
+
paths: { extensionsDir, syncStatePath, openclawConfigPath: path.join(dir, "openclaw.json") },
|
|
134
|
+
getConfig: () => ({}),
|
|
135
|
+
// 平台最新版本 2.0.0(桩无 latestVersion 字段 → 回退用 version 当最新)
|
|
136
|
+
sampleConfigs: [{ skillName: "demo", version: "2.0.0", functions: [] }],
|
|
137
|
+
resolveSkillDirs: () => [path.join(wsA, "skills")],
|
|
138
|
+
});
|
|
139
|
+
await cs.checkVersionsAndUpdate(); // 刷新 latestVersions + 检测(无 updater 注入则不覆盖文件)
|
|
140
|
+
const outdated = cs.detectOutdated();
|
|
141
|
+
assert.equal(outdated.length, 1);
|
|
142
|
+
assert.equal(outdated[0].skillName, "demo");
|
|
143
|
+
assert.equal(outdated[0].localVersion, "1.0.0");
|
|
144
|
+
assert.equal(outdated[0].latestVersion, "2.0.0");
|
|
145
|
+
|
|
146
|
+
// agent↔skill 映射应本地持久化到 sync.json
|
|
147
|
+
const state = JSON.parse(await fs.readFile(syncStatePath, "utf-8"));
|
|
148
|
+
assert.ok(state.installations.demo, "sync.json 应含 installations 映射");
|
|
149
|
+
assert.equal(state.installations.demo[0].version, "1.0.0");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("ConfigSync.reconcile 端到端", () => {
|
|
154
|
+
it("对账后索引含该 skill 功能点,并持久化", async () => {
|
|
155
|
+
await writeSkill("foo");
|
|
156
|
+
const cs = makeSync();
|
|
157
|
+
await cs.reconcile();
|
|
158
|
+
// foo 的 run.py 应进入 script 索引
|
|
159
|
+
assert.ok(cs.getIndex().scriptByBasename.has("run.py"));
|
|
160
|
+
// 持久化文件存在且含 foo
|
|
161
|
+
const state = JSON.parse(await fs.readFile(syncStatePath, "utf-8"));
|
|
162
|
+
assert.ok(state.active.foo);
|
|
163
|
+
assert.ok(state.configPool["foo@unknown"]);
|
|
164
|
+
});
|
|
165
|
+
});
|