@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
package/src/config-sync.ts
CHANGED
|
@@ -1,544 +1,544 @@
|
|
|
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 fsSync from "node:fs";
|
|
14
|
-
import path from "node:path";
|
|
15
|
-
import { fileURLToPath } from "node:url";
|
|
16
|
-
import type { PluginConfig, SkillStandardConfig } from "./types.ts";
|
|
17
|
-
import { type PluginPaths, resolveAgentSkillDirs, openclawHome } from "./paths.ts";
|
|
18
|
-
import { buildIndex, emptyIndex, type MatchIndex } from "./matcher.ts";
|
|
19
|
-
import { defaultFetch } from "./http.ts";
|
|
20
|
-
import { isOutdated } from "./semver.ts";
|
|
21
|
-
import { parseSkillVersion, readSkillVersion } from "./skill-version.ts";
|
|
22
|
-
import type { OutdatedCopy } from "./updater.ts";
|
|
23
|
-
|
|
24
|
-
// 保持对外导出位置不变(历史测试从 config-sync 导入)。
|
|
25
|
-
export { parseSkillVersion };
|
|
26
|
-
|
|
27
|
-
type FetchLike = (url: string, init: RequestInit) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>;
|
|
28
|
-
|
|
29
|
-
/** 扫描得到的一个已装 skill。 */
|
|
30
|
-
export type InstalledSkill = {
|
|
31
|
-
name: string;
|
|
32
|
-
version?: string;
|
|
33
|
-
rootDir: string;
|
|
34
|
-
signature: string;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
/** 持久化的同步状态:每个 skill 的签名 + 多版本配置缓存池。 */
|
|
38
|
-
type SyncState = {
|
|
39
|
-
// 旧版本兼容字段
|
|
40
|
-
skills?: Record<string, { signature: string; version?: string; config?: SkillStandardConfig }>;
|
|
41
|
-
// 新版本字段
|
|
42
|
-
active?: Record<string, { signature: string }>;
|
|
43
|
-
configPool?: Record<string, SkillStandardConfig>;
|
|
44
|
-
/** agent↔skill 安装映射:skill 名 → 各 workspace 下的副本(目录 + 本地版本)。本地留存,便于排查与更新定位。 */
|
|
45
|
-
installations?: Record<string, Array<{ rootDir: string; version?: string }>>;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
export type ConfigSyncOptions = {
|
|
49
|
-
paths: Pick<PluginPaths, "extensionsDir" | "syncStatePath" | "openclawConfigPath">;
|
|
50
|
-
getConfig: () => PluginConfig;
|
|
51
|
-
fetchImpl?: FetchLike;
|
|
52
|
-
/** 注入静态桩配置,便于测试;默认从 sample-config.json 读。 */
|
|
53
|
-
sampleConfigs?: SkillStandardConfig[];
|
|
54
|
-
/**
|
|
55
|
-
* 动态解析 skill 扫描目录(每次扫描调用,因此运行期新增 agent workspace 无需重启即可被发现)。
|
|
56
|
-
* 默认按 openclawConfigPath 重读 openclaw.json 解析。测试可注入固定目录。
|
|
57
|
-
*/
|
|
58
|
-
resolveSkillDirs?: () => string[];
|
|
59
|
-
/** 版本更新器(可选)。注入后,reconcile 检测到落后副本时触发自动更新(受 autoUpdateSkills 开关约束)。 */
|
|
60
|
-
updater?: { applyUpdates(outdated: OutdatedCopy[]): Promise<void> };
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const MAX_SCAN_DEPTH = 6;
|
|
64
|
-
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".cache"]);
|
|
65
|
-
/** lazyCheck 扫描缓存有效期:足够吸收同一会话内的连续 skill_trigger,又不至于明显滞后真实安装变化。 */
|
|
66
|
-
const SCAN_CACHE_TTL_MS = 5000;
|
|
67
|
-
|
|
68
|
-
export class ConfigSync {
|
|
69
|
-
private readonly paths: Pick<PluginPaths, "extensionsDir" | "syncStatePath" | "openclawConfigPath">;
|
|
70
|
-
private readonly getConfig: () => PluginConfig;
|
|
71
|
-
private readonly fetchImpl: FetchLike;
|
|
72
|
-
private sampleConfigs?: SkillStandardConfig[];
|
|
73
|
-
private readonly resolveSkillDirs: () => string[];
|
|
74
|
-
private readonly updater?: { applyUpdates(outdated: OutdatedCopy[]): Promise<void> };
|
|
75
|
-
|
|
76
|
-
private signatures = new Map<string, string>();
|
|
77
|
-
private configs = new Map<string, SkillStandardConfig>();
|
|
78
|
-
private configPool = new Map<string, SkillStandardConfig>();
|
|
79
|
-
private index: MatchIndex = emptyIndex();
|
|
80
|
-
/** skill 根目录 → 规范名(SKILL.md frontmatter name)。用于把触发事件归一到与匹配一致的身份。 */
|
|
81
|
-
private skillNameByDir = new Map<string, string>();
|
|
82
|
-
/** 同一时刻只跑一次 reconcile,避免周期/懒触发并发。 */
|
|
83
|
-
private reconciling = false;
|
|
84
|
-
/** 同一时刻只跑一次版本检查,避免周期/启动并发。 */
|
|
85
|
-
private checkingVersions = false;
|
|
86
|
-
/** skill 名 → 平台最新版本(由 30 分钟版本检查刷新;缺省回退用已装配置的 version)。 */
|
|
87
|
-
private latestVersions = new Map<string, string>();
|
|
88
|
-
/** scanInstalledSkills 的短 TTL 缓存,仅服务 lazyCheck 的高频触发;reconcile 始终全新扫描并刷新它。 */
|
|
89
|
-
private scanCache?: { ts: number; skills: InstalledSkill[] };
|
|
90
|
-
/** skill 名 → 所有安装副本(多 agent workspace 各一份)。每次扫描刷新。 */
|
|
91
|
-
private installations = new Map<string, InstalledSkill[]>();
|
|
92
|
-
|
|
93
|
-
constructor(opts: ConfigSyncOptions) {
|
|
94
|
-
this.paths = opts.paths;
|
|
95
|
-
this.getConfig = opts.getConfig;
|
|
96
|
-
this.fetchImpl = opts.fetchImpl ?? defaultFetch();
|
|
97
|
-
this.sampleConfigs = opts.sampleConfigs;
|
|
98
|
-
// 默认:每次扫描重读 openclaw.json,动态发现新增 agent workspace(无需重启网关)。
|
|
99
|
-
this.resolveSkillDirs =
|
|
100
|
-
opts.resolveSkillDirs ?? (() => resolveAgentSkillDirs(openclawHome(), this.paths.openclawConfigPath));
|
|
101
|
-
this.updater = opts.updater;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
private get isDebug(): boolean {
|
|
105
|
-
return this.getConfig().debugLogging !== false;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
private debug(...args: any[]): void {
|
|
109
|
-
if (this.isDebug) {
|
|
110
|
-
console.log("[skill-logger-plugin/config-sync]", ...args);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
getIndex(): MatchIndex {
|
|
115
|
-
return this.index;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
getVersion(skillName: string): string | undefined {
|
|
119
|
-
const cfg = this.configs.get(skillName);
|
|
120
|
-
if (cfg?.version) return cfg.version;
|
|
121
|
-
const sig = this.signatures.get(skillName);
|
|
122
|
-
if (sig) {
|
|
123
|
-
const v = sig.split("|")[0];
|
|
124
|
-
return v ? v : undefined;
|
|
125
|
-
}
|
|
126
|
-
return undefined;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** 把 SKILL.md 所在目录解析为规范 skill 名;未扫描到时返回 undefined(调用方回退目录名)。 */
|
|
130
|
-
resolveSkillName(rootDir: string): string | undefined {
|
|
131
|
-
return this.skillNameByDir.get(rootDir);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** 从磁盘加载已缓存的同步状态并重建索引(gateway 启动时调一次)。 */
|
|
135
|
-
async load(): Promise<void> {
|
|
136
|
-
try {
|
|
137
|
-
const raw = await fs.readFile(this.paths.syncStatePath, "utf-8");
|
|
138
|
-
const state = JSON.parse(raw) as SyncState;
|
|
139
|
-
// 兼容旧版本格式
|
|
140
|
-
if (state.skills) {
|
|
141
|
-
for (const [name, entry] of Object.entries(state.skills)) {
|
|
142
|
-
this.signatures.set(name, entry.signature);
|
|
143
|
-
if (entry.config) {
|
|
144
|
-
this.configs.set(name, entry.config);
|
|
145
|
-
const cacheKey = `${name}@${entry.version || "unknown"}`;
|
|
146
|
-
this.configPool.set(cacheKey, entry.config);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
// 新版本格式
|
|
151
|
-
if (state.configPool) {
|
|
152
|
-
for (const [key, cfg] of Object.entries(state.configPool)) {
|
|
153
|
-
this.configPool.set(key, cfg);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
if (state.active) {
|
|
157
|
-
for (const [name, entry] of Object.entries(state.active)) {
|
|
158
|
-
this.signatures.set(name, entry.signature);
|
|
159
|
-
const v = entry.signature.split("|")[0];
|
|
160
|
-
const cacheKey = `${name}@${v || "unknown"}`;
|
|
161
|
-
const cfg = this.configPool.get(cacheKey);
|
|
162
|
-
if (cfg) this.configs.set(name, cfg);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
this.rebuildIndex();
|
|
166
|
-
} catch {
|
|
167
|
-
// 无状态文件,留空
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* 递归扫描 extensions 及各 agent workspace 下所有 SKILL.md,解析名称/版本/签名。
|
|
173
|
-
* 同名 skill 可能分布在多个 workspace(如 coder/coder2 各持一份副本)。缓存以 skill 名为全局 key,
|
|
174
|
-
* 故这里按名去重并取确定性的一份(按 rootDir 排序后取首个),避免 reconcile 每轮在不同副本的
|
|
175
|
-
* 签名间反复横跳、触发无意义的重复拉取与持久化抖动。skillNameByDir 仍保留全部副本目录的映射。
|
|
176
|
-
*/
|
|
177
|
-
async scanInstalledSkills(): Promise<InstalledSkill[]> {
|
|
178
|
-
const out: InstalledSkill[] = [];
|
|
179
|
-
const walk = async (dir: string, depth: number): Promise<void> => {
|
|
180
|
-
if (depth > MAX_SCAN_DEPTH) return;
|
|
181
|
-
let entries: fsSync.Dirent[];
|
|
182
|
-
try {
|
|
183
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
184
|
-
} catch {
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
for (const e of entries) {
|
|
188
|
-
if (e.isDirectory()) {
|
|
189
|
-
if (SKIP_DIRS.has(e.name)) continue;
|
|
190
|
-
await walk(path.join(dir, e.name), depth + 1);
|
|
191
|
-
} else if (e.name === "SKILL.md") {
|
|
192
|
-
const skillMd = path.join(dir, e.name);
|
|
193
|
-
const skill = await this.readSkill(dir, skillMd);
|
|
194
|
-
if (skill) {
|
|
195
|
-
out.push(skill);
|
|
196
|
-
this.skillNameByDir.set(skill.rootDir, skill.name);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
};
|
|
201
|
-
await walk(this.paths.extensionsDir, 0);
|
|
202
|
-
// 动态解析所有 agent workspace 的 skills 目录(含顶层全局 skills):每次扫描重读 openclaw.json,
|
|
203
|
-
// 运行期新增 agent 无需重启即可被发现。不存在的目录在 walk 内已被吞掉。
|
|
204
|
-
for (const skillsDir of this.resolveSkillDirs()) {
|
|
205
|
-
await walk(skillsDir, 0);
|
|
206
|
-
}
|
|
207
|
-
out.sort((a, b) => a.rootDir.localeCompare(b.rootDir));
|
|
208
|
-
// 记录 skill → 所有安装副本(含各 workspace 下的 rootDir 与本地版本),供版本更新定位覆盖目标。
|
|
209
|
-
const installs = new Map<string, InstalledSkill[]>();
|
|
210
|
-
for (const s of out) {
|
|
211
|
-
const arr = installs.get(s.name);
|
|
212
|
-
if (arr) arr.push(s);
|
|
213
|
-
else installs.set(s.name, [s]);
|
|
214
|
-
}
|
|
215
|
-
this.installations = installs;
|
|
216
|
-
// 按 skill 名去重,取确定性的一份(rootDir 字典序最小)。
|
|
217
|
-
const deduped = new Map<string, InstalledSkill>();
|
|
218
|
-
for (const s of out) {
|
|
219
|
-
if (!deduped.has(s.name)) deduped.set(s.name, s);
|
|
220
|
-
}
|
|
221
|
-
return [...deduped.values()];
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** skill → 所有安装副本(多 agent workspace 各一份)。供版本更新定位需要覆盖的目录。 */
|
|
225
|
-
getInstallations(): ReadonlyMap<string, ReadonlyArray<InstalledSkill>> {
|
|
226
|
-
return this.installations;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** 基于最近一次版本检查结果,找出所有本地版本落后的安装副本。 */
|
|
230
|
-
detectOutdated(): OutdatedCopy[] {
|
|
231
|
-
const out: OutdatedCopy[] = [];
|
|
232
|
-
for (const [skillName, copies] of this.installations) {
|
|
233
|
-
const cfg = this.configs.get(skillName);
|
|
234
|
-
const latestVersion = this.latestVersions.get(skillName) || cfg?.latestVersion || cfg?.version;
|
|
235
|
-
if (!latestVersion) continue;
|
|
236
|
-
for (const copy of copies) {
|
|
237
|
-
if (!isOutdated(copy.version, latestVersion)) continue;
|
|
238
|
-
out.push({
|
|
239
|
-
skillName,
|
|
240
|
-
rootDir: copy.rootDir,
|
|
241
|
-
localVersion: copy.version || "",
|
|
242
|
-
latestVersion,
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
return out;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** 扫描本地安装副本,拉取平台最新版本,检测落后副本并按配置触发自动更新。 */
|
|
250
|
-
async checkVersionsAndUpdate(): Promise<void> {
|
|
251
|
-
if (this.checkingVersions) return;
|
|
252
|
-
this.checkingVersions = true;
|
|
253
|
-
try {
|
|
254
|
-
const installed = await this.scanInstalledSkills();
|
|
255
|
-
this.scanCache = { ts: Date.now(), skills: installed };
|
|
256
|
-
|
|
257
|
-
if (installed.length > 0) {
|
|
258
|
-
const { ok, configs } = await this.pullConfigs(
|
|
259
|
-
installed.map((s) => ({ name: s.name, version: s.version }))
|
|
260
|
-
);
|
|
261
|
-
if (ok) {
|
|
262
|
-
for (const cfg of configs) {
|
|
263
|
-
const latestVersion = cfg.latestVersion || cfg.version;
|
|
264
|
-
if (latestVersion) this.latestVersions.set(cfg.skillName, latestVersion);
|
|
265
|
-
if (cfg.version) {
|
|
266
|
-
this.configPool.set(`${cfg.skillName}@${cfg.version}`, cfg);
|
|
267
|
-
if (!this.configs.has(cfg.skillName)) this.configs.set(cfg.skillName, cfg);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const outdated = this.detectOutdated();
|
|
274
|
-
if (outdated.length > 0 && this.updater) {
|
|
275
|
-
await this.updater.applyUpdates(outdated);
|
|
276
|
-
const refreshed = await this.scanInstalledSkills();
|
|
277
|
-
this.scanCache = { ts: Date.now(), skills: refreshed };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
await this.persist();
|
|
281
|
-
} catch (err) {
|
|
282
|
-
console.warn("[skill-logger-plugin] checkVersionsAndUpdate 异常", err);
|
|
283
|
-
} finally {
|
|
284
|
-
this.checkingVersions = false;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/** lazyCheck 专用:命中短 TTL 缓存则跳过整树遍历;reconcile 不走此路径,始终全新扫描。 */
|
|
289
|
-
private async scanInstalledSkillsCached(): Promise<InstalledSkill[]> {
|
|
290
|
-
const now = Date.now();
|
|
291
|
-
if (this.scanCache && now - this.scanCache.ts < SCAN_CACHE_TTL_MS) {
|
|
292
|
-
return this.scanCache.skills;
|
|
293
|
-
}
|
|
294
|
-
const skills = await this.scanInstalledSkills();
|
|
295
|
-
this.scanCache = { ts: now, skills };
|
|
296
|
-
return skills;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
private async readSkill(rootDir: string, skillMdPath: string): Promise<InstalledSkill | undefined> {
|
|
300
|
-
try {
|
|
301
|
-
const content = await fs.readFile(skillMdPath, "utf-8");
|
|
302
|
-
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)?.[1] ?? "";
|
|
303
|
-
const name = /(^|\n)name:\s*(.+)/.exec(fm)?.[2]?.trim() || path.basename(rootDir);
|
|
304
|
-
const version = await readSkillVersion(rootDir, content);
|
|
305
|
-
const signature = await this.computeSignature(rootDir, skillMdPath, version);
|
|
306
|
-
return { name, version, rootDir, signature };
|
|
307
|
-
} catch {
|
|
308
|
-
return undefined;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/** 签名 = 版本(若有)+ SKILL.md 与 scripts/ 的 mtime/size 摘要。 */
|
|
313
|
-
private async computeSignature(rootDir: string, skillMdPath: string, version?: string): Promise<string> {
|
|
314
|
-
const parts: string[] = [version ?? ""];
|
|
315
|
-
try {
|
|
316
|
-
const st = await fs.stat(skillMdPath);
|
|
317
|
-
parts.push(`md:${st.mtimeMs}:${st.size}`);
|
|
318
|
-
} catch {
|
|
319
|
-
/* ignore */
|
|
320
|
-
}
|
|
321
|
-
try {
|
|
322
|
-
const scriptsDir = path.join(rootDir, "scripts");
|
|
323
|
-
const entries = await fs.readdir(scriptsDir, { withFileTypes: true });
|
|
324
|
-
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
325
|
-
if (!e.isFile()) continue;
|
|
326
|
-
const st = await fs.stat(path.join(scriptsDir, e.name));
|
|
327
|
-
parts.push(`s:${e.name}:${st.mtimeMs}:${st.size}`);
|
|
328
|
-
}
|
|
329
|
-
} catch {
|
|
330
|
-
/* 无 scripts 目录 */
|
|
331
|
-
}
|
|
332
|
-
return parts.join("|");
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** 找出需要拉取(新增或签名变化)与已移除的 skill。优先命中本地 configPool。 */
|
|
336
|
-
diffAgainstState(installed: InstalledSkill[]): { toFetch: InstalledSkill[]; removed: string[]; cached: InstalledSkill[] } {
|
|
337
|
-
const toFetch: InstalledSkill[] = [];
|
|
338
|
-
const cached: InstalledSkill[] = [];
|
|
339
|
-
const removed: string[] = [];
|
|
340
|
-
const seen = new Set<string>();
|
|
341
|
-
for (const s of installed) {
|
|
342
|
-
seen.add(s.name);
|
|
343
|
-
if (this.signatures.get(s.name) !== s.signature) {
|
|
344
|
-
const cacheKey = `${s.name}@${s.version || "unknown"}`;
|
|
345
|
-
if (this.configPool.has(cacheKey)) {
|
|
346
|
-
cached.push(s);
|
|
347
|
-
} else {
|
|
348
|
-
toFetch.push(s);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
for (const name of this.signatures.keys()) {
|
|
353
|
-
if (!seen.has(name)) removed.push(name);
|
|
354
|
-
}
|
|
355
|
-
return { toFetch, removed, cached };
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/** 全量对账:扫描 → diff → 拉取 → 更新缓存 → 重建索引 → 持久化。 */
|
|
359
|
-
async reconcile(): Promise<void> {
|
|
360
|
-
if (this.reconciling) return;
|
|
361
|
-
this.reconciling = true;
|
|
362
|
-
try {
|
|
363
|
-
const installed = await this.scanInstalledSkills();
|
|
364
|
-
this.scanCache = { ts: Date.now(), skills: installed }; // 刷新 lazyCheck 缓存,保证安装后对账的新鲜度
|
|
365
|
-
this.debug(`reconcile: Found ${installed.length} installed skills.`);
|
|
366
|
-
const { toFetch, removed, cached } = this.diffAgainstState(installed);
|
|
367
|
-
if (toFetch.length === 0 && removed.length === 0 && cached.length === 0) return;
|
|
368
|
-
|
|
369
|
-
// 命中本地池的,直接置为活跃
|
|
370
|
-
for (const s of cached) {
|
|
371
|
-
this.signatures.set(s.name, s.signature);
|
|
372
|
-
this.configs.set(s.name, this.configPool.get(`${s.name}@${s.version || "unknown"}`)!);
|
|
373
|
-
this.debug(`reconcile: Skill ${s.name}@${s.version} instantly loaded from local configPool.`);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
if (toFetch.length > 0) {
|
|
377
|
-
this.debug(`reconcile: Fetching configs for ${toFetch.length} skills...`);
|
|
378
|
-
const { ok, configs } = await this.pullConfigs(
|
|
379
|
-
toFetch.map((s) => ({ name: s.name, version: s.version }))
|
|
380
|
-
);
|
|
381
|
-
// 仅在拿到确定性结果时推进签名;拉取失败则不推进,下轮重试。
|
|
382
|
-
if (ok) {
|
|
383
|
-
const byName = new Map(configs.map((c) => [c.skillName, c]));
|
|
384
|
-
this.debug(`reconcile: Fetched ${configs.length} configs successfully.`);
|
|
385
|
-
for (const s of toFetch) {
|
|
386
|
-
const cfg = byName.get(s.name) as SkillStandardConfig & { status?: string };
|
|
387
|
-
|
|
388
|
-
// If the platform says this config is still being processed or reviewed,
|
|
389
|
-
// we skip updating the signature so it will be retried in the next reconcile.
|
|
390
|
-
if (cfg && (cfg.status === 'REVIEW_NEEDED' || cfg.status === 'EXTRACTING')) {
|
|
391
|
-
this.debug(`reconcile: Skill ${s.name} is ${cfg.status}, skipping signature update.`);
|
|
392
|
-
continue;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
this.signatures.set(s.name, s.signature);
|
|
396
|
-
if (cfg) {
|
|
397
|
-
this.configs.set(s.name, cfg);
|
|
398
|
-
this.configPool.set(`${s.name}@${s.version || "unknown"}`, cfg);
|
|
399
|
-
} else {
|
|
400
|
-
this.configs.delete(s.name); // 平台明确无此配置
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
for (const name of removed) {
|
|
406
|
-
this.signatures.delete(name);
|
|
407
|
-
this.configs.delete(name);
|
|
408
|
-
}
|
|
409
|
-
this.rebuildIndex();
|
|
410
|
-
await this.persist();
|
|
411
|
-
} catch (err) {
|
|
412
|
-
console.warn("[skill-logger-plugin] reconcile 异常", err);
|
|
413
|
-
} finally {
|
|
414
|
-
this.reconciling = false;
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/** 懒触发:仅检查某个被触发的 skill,缺配置/签名变才拉。`ident` 可为规范名或目录名。 */
|
|
419
|
-
async lazyCheck(ident: string): Promise<void> {
|
|
420
|
-
try {
|
|
421
|
-
const find = (list: InstalledSkill[]) =>
|
|
422
|
-
list.find((x) => x.name === ident || path.basename(x.rootDir) === ident);
|
|
423
|
-
let s = find(await this.scanInstalledSkillsCached());
|
|
424
|
-
// 缓存里没有该 skill:可能是刚安装的新 skill,强制全新扫描兜底,行为与未加缓存前一致。
|
|
425
|
-
if (!s) {
|
|
426
|
-
const fresh = await this.scanInstalledSkills();
|
|
427
|
-
this.scanCache = { ts: Date.now(), skills: fresh };
|
|
428
|
-
s = find(fresh);
|
|
429
|
-
}
|
|
430
|
-
if (!s) return;
|
|
431
|
-
if (this.signatures.get(s.name) === s.signature && this.configs.has(s.name)) return;
|
|
432
|
-
|
|
433
|
-
const cacheKey = `${s.name}@${s.version || "unknown"}`;
|
|
434
|
-
if (this.configPool.has(cacheKey)) {
|
|
435
|
-
this.debug(`lazyCheck: Skill ${s.name}@${s.version} loaded instantly from local configPool.`);
|
|
436
|
-
this.signatures.set(s.name, s.signature);
|
|
437
|
-
this.configs.set(s.name, this.configPool.get(cacheKey)!);
|
|
438
|
-
this.rebuildIndex();
|
|
439
|
-
await this.persist();
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
this.debug(`lazyCheck: Fetching config for ${s.name}@${s.version}...`);
|
|
444
|
-
const { ok, configs } = await this.pullConfigs([{ name: s.name, version: s.version }]);
|
|
445
|
-
if (!ok) return; // 拉取失败,保留旧状态,下轮重试
|
|
446
|
-
|
|
447
|
-
this.debug(`lazyCheck: Fetched ${configs.length} configs successfully.`);
|
|
448
|
-
const cfg = configs.find((c) => c.skillName === s.name) as SkillStandardConfig & { status?: string };
|
|
449
|
-
|
|
450
|
-
if (cfg && (cfg.status === 'REVIEW_NEEDED' || cfg.status === 'EXTRACTING')) {
|
|
451
|
-
this.debug(`lazyCheck: Skill ${s.name} is ${cfg.status}, skipping signature update.`);
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
this.signatures.set(s.name, s.signature);
|
|
456
|
-
if (cfg) {
|
|
457
|
-
this.configs.set(s.name, cfg);
|
|
458
|
-
this.configPool.set(cacheKey, cfg);
|
|
459
|
-
} else {
|
|
460
|
-
this.configs.delete(s.name);
|
|
461
|
-
}
|
|
462
|
-
this.rebuildIndex();
|
|
463
|
-
await this.persist();
|
|
464
|
-
} catch (err) {
|
|
465
|
-
console.warn("[skill-logger-plugin] lazyCheck 异常", err);
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* 拉取标准配置。配了 platformBaseUrl → POST 平台;否则用本地静态桩。
|
|
471
|
-
*
|
|
472
|
-
* 返回 `ok` 区分「确定性结果」与「拉取失败」:
|
|
473
|
-
* - ok=true :拿到了平台的明确答复(configs 可能为空,表示平台对这些 skill 暂无配置)。
|
|
474
|
-
* - ok=false :网络/服务异常,调用方**不应**推进签名,下轮重试。
|
|
475
|
-
*/
|
|
476
|
-
async pullConfigs(
|
|
477
|
-
skillRefs: { name: string; version?: string }[]
|
|
478
|
-
): Promise<{ ok: boolean; configs: SkillStandardConfig[] }> {
|
|
479
|
-
const config = this.getConfig();
|
|
480
|
-
if (config.platformBaseUrl) {
|
|
481
|
-
try {
|
|
482
|
-
const url = config.platformBaseUrl.replace(/\/$/, "") + "/skill_config/pull";
|
|
483
|
-
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
484
|
-
if (config.authToken) headers.Authorization = config.authToken;
|
|
485
|
-
const res = await this.fetchImpl(url, {
|
|
486
|
-
method: "POST",
|
|
487
|
-
headers,
|
|
488
|
-
body: JSON.stringify({ skills: skillRefs }),
|
|
489
|
-
});
|
|
490
|
-
if (!res.ok) {
|
|
491
|
-
console.warn("[skill-logger-plugin] 拉取标准配置失败,HTTP", res.status);
|
|
492
|
-
return { ok: false, configs: [] };
|
|
493
|
-
}
|
|
494
|
-
const data = (await res.json()) as { configs?: SkillStandardConfig[] };
|
|
495
|
-
return { ok: true, configs: data.configs ?? [] };
|
|
496
|
-
} catch (err) {
|
|
497
|
-
console.warn("[skill-logger-plugin] 拉取标准配置异常", err);
|
|
498
|
-
return { ok: false, configs: [] };
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
// 本地静态桩:仅返回请求到的 skill(视为确定性结果)
|
|
502
|
-
const want = new Set(skillRefs.map((r) => r.name));
|
|
503
|
-
return { ok: true, configs: (await this.loadSampleConfigs()).filter((c) => want.has(c.skillName)) };
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
private async loadSampleConfigs(): Promise<SkillStandardConfig[]> {
|
|
507
|
-
if (this.sampleConfigs) return this.sampleConfigs;
|
|
508
|
-
try {
|
|
509
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
510
|
-
const raw = await fs.readFile(path.join(here, "sample-config.json"), "utf-8");
|
|
511
|
-
this.sampleConfigs = (JSON.parse(raw) as { configs: SkillStandardConfig[] }).configs;
|
|
512
|
-
} catch {
|
|
513
|
-
this.sampleConfigs = [];
|
|
514
|
-
}
|
|
515
|
-
return this.sampleConfigs;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
private rebuildIndex(): void {
|
|
519
|
-
this.index = buildIndex([...this.configs.values()]);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
private async persist(): Promise<void> {
|
|
523
|
-
try {
|
|
524
|
-
const state: SyncState = { active: {}, configPool: {}, installations: {} };
|
|
525
|
-
for (const [name, signature] of this.signatures) {
|
|
526
|
-
state.active![name] = { signature };
|
|
527
|
-
}
|
|
528
|
-
for (const [key, cfg] of this.configPool) {
|
|
529
|
-
state.configPool![key] = cfg;
|
|
530
|
-
}
|
|
531
|
-
// agent↔skill 安装映射:本地留存每个 skill 在各 workspace 的副本与版本。
|
|
532
|
-
for (const [name, copies] of this.installations) {
|
|
533
|
-
state.installations![name] = copies.map((c) => ({ rootDir: c.rootDir, version: c.version }));
|
|
534
|
-
}
|
|
535
|
-
await fs.mkdir(path.dirname(this.paths.syncStatePath), { recursive: true });
|
|
536
|
-
// 原子写:写临时文件再 rename,避免 reconcile 与版本检查并发持久化时相互写坏。
|
|
537
|
-
const tmp = `${this.paths.syncStatePath}.tmp-${process.pid}-${Date.now()}`;
|
|
538
|
-
await fs.writeFile(tmp, JSON.stringify(state));
|
|
539
|
-
await fs.rename(tmp, this.paths.syncStatePath);
|
|
540
|
-
} catch (err) {
|
|
541
|
-
console.warn("[skill-logger-plugin] 持久化同步状态失败", err);
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
}
|
|
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 fsSync from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import type { PluginConfig, SkillStandardConfig } from "./types.ts";
|
|
17
|
+
import { type PluginPaths, resolveAgentSkillDirs, openclawHome } from "./paths.ts";
|
|
18
|
+
import { buildIndex, emptyIndex, type MatchIndex } from "./matcher.ts";
|
|
19
|
+
import { defaultFetch } from "./http.ts";
|
|
20
|
+
import { isOutdated } from "./semver.ts";
|
|
21
|
+
import { parseSkillVersion, readSkillVersion } from "./skill-version.ts";
|
|
22
|
+
import type { OutdatedCopy } from "./updater.ts";
|
|
23
|
+
|
|
24
|
+
// 保持对外导出位置不变(历史测试从 config-sync 导入)。
|
|
25
|
+
export { parseSkillVersion };
|
|
26
|
+
|
|
27
|
+
type FetchLike = (url: string, init: RequestInit) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>;
|
|
28
|
+
|
|
29
|
+
/** 扫描得到的一个已装 skill。 */
|
|
30
|
+
export type InstalledSkill = {
|
|
31
|
+
name: string;
|
|
32
|
+
version?: string;
|
|
33
|
+
rootDir: string;
|
|
34
|
+
signature: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** 持久化的同步状态:每个 skill 的签名 + 多版本配置缓存池。 */
|
|
38
|
+
type SyncState = {
|
|
39
|
+
// 旧版本兼容字段
|
|
40
|
+
skills?: Record<string, { signature: string; version?: string; config?: SkillStandardConfig }>;
|
|
41
|
+
// 新版本字段
|
|
42
|
+
active?: Record<string, { signature: string }>;
|
|
43
|
+
configPool?: Record<string, SkillStandardConfig>;
|
|
44
|
+
/** agent↔skill 安装映射:skill 名 → 各 workspace 下的副本(目录 + 本地版本)。本地留存,便于排查与更新定位。 */
|
|
45
|
+
installations?: Record<string, Array<{ rootDir: string; version?: string }>>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type ConfigSyncOptions = {
|
|
49
|
+
paths: Pick<PluginPaths, "extensionsDir" | "syncStatePath" | "openclawConfigPath">;
|
|
50
|
+
getConfig: () => PluginConfig;
|
|
51
|
+
fetchImpl?: FetchLike;
|
|
52
|
+
/** 注入静态桩配置,便于测试;默认从 sample-config.json 读。 */
|
|
53
|
+
sampleConfigs?: SkillStandardConfig[];
|
|
54
|
+
/**
|
|
55
|
+
* 动态解析 skill 扫描目录(每次扫描调用,因此运行期新增 agent workspace 无需重启即可被发现)。
|
|
56
|
+
* 默认按 openclawConfigPath 重读 openclaw.json 解析。测试可注入固定目录。
|
|
57
|
+
*/
|
|
58
|
+
resolveSkillDirs?: () => string[];
|
|
59
|
+
/** 版本更新器(可选)。注入后,reconcile 检测到落后副本时触发自动更新(受 autoUpdateSkills 开关约束)。 */
|
|
60
|
+
updater?: { applyUpdates(outdated: OutdatedCopy[]): Promise<void> };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const MAX_SCAN_DEPTH = 6;
|
|
64
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".cache"]);
|
|
65
|
+
/** lazyCheck 扫描缓存有效期:足够吸收同一会话内的连续 skill_trigger,又不至于明显滞后真实安装变化。 */
|
|
66
|
+
const SCAN_CACHE_TTL_MS = 5000;
|
|
67
|
+
|
|
68
|
+
export class ConfigSync {
|
|
69
|
+
private readonly paths: Pick<PluginPaths, "extensionsDir" | "syncStatePath" | "openclawConfigPath">;
|
|
70
|
+
private readonly getConfig: () => PluginConfig;
|
|
71
|
+
private readonly fetchImpl: FetchLike;
|
|
72
|
+
private sampleConfigs?: SkillStandardConfig[];
|
|
73
|
+
private readonly resolveSkillDirs: () => string[];
|
|
74
|
+
private readonly updater?: { applyUpdates(outdated: OutdatedCopy[]): Promise<void> };
|
|
75
|
+
|
|
76
|
+
private signatures = new Map<string, string>();
|
|
77
|
+
private configs = new Map<string, SkillStandardConfig>();
|
|
78
|
+
private configPool = new Map<string, SkillStandardConfig>();
|
|
79
|
+
private index: MatchIndex = emptyIndex();
|
|
80
|
+
/** skill 根目录 → 规范名(SKILL.md frontmatter name)。用于把触发事件归一到与匹配一致的身份。 */
|
|
81
|
+
private skillNameByDir = new Map<string, string>();
|
|
82
|
+
/** 同一时刻只跑一次 reconcile,避免周期/懒触发并发。 */
|
|
83
|
+
private reconciling = false;
|
|
84
|
+
/** 同一时刻只跑一次版本检查,避免周期/启动并发。 */
|
|
85
|
+
private checkingVersions = false;
|
|
86
|
+
/** skill 名 → 平台最新版本(由 30 分钟版本检查刷新;缺省回退用已装配置的 version)。 */
|
|
87
|
+
private latestVersions = new Map<string, string>();
|
|
88
|
+
/** scanInstalledSkills 的短 TTL 缓存,仅服务 lazyCheck 的高频触发;reconcile 始终全新扫描并刷新它。 */
|
|
89
|
+
private scanCache?: { ts: number; skills: InstalledSkill[] };
|
|
90
|
+
/** skill 名 → 所有安装副本(多 agent workspace 各一份)。每次扫描刷新。 */
|
|
91
|
+
private installations = new Map<string, InstalledSkill[]>();
|
|
92
|
+
|
|
93
|
+
constructor(opts: ConfigSyncOptions) {
|
|
94
|
+
this.paths = opts.paths;
|
|
95
|
+
this.getConfig = opts.getConfig;
|
|
96
|
+
this.fetchImpl = opts.fetchImpl ?? defaultFetch();
|
|
97
|
+
this.sampleConfigs = opts.sampleConfigs;
|
|
98
|
+
// 默认:每次扫描重读 openclaw.json,动态发现新增 agent workspace(无需重启网关)。
|
|
99
|
+
this.resolveSkillDirs =
|
|
100
|
+
opts.resolveSkillDirs ?? (() => resolveAgentSkillDirs(openclawHome(), this.paths.openclawConfigPath));
|
|
101
|
+
this.updater = opts.updater;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private get isDebug(): boolean {
|
|
105
|
+
return this.getConfig().debugLogging !== false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private debug(...args: any[]): void {
|
|
109
|
+
if (this.isDebug) {
|
|
110
|
+
console.log("[skill-logger-plugin/config-sync]", ...args);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
getIndex(): MatchIndex {
|
|
115
|
+
return this.index;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
getVersion(skillName: string): string | undefined {
|
|
119
|
+
const cfg = this.configs.get(skillName);
|
|
120
|
+
if (cfg?.version) return cfg.version;
|
|
121
|
+
const sig = this.signatures.get(skillName);
|
|
122
|
+
if (sig) {
|
|
123
|
+
const v = sig.split("|")[0];
|
|
124
|
+
return v ? v : undefined;
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 把 SKILL.md 所在目录解析为规范 skill 名;未扫描到时返回 undefined(调用方回退目录名)。 */
|
|
130
|
+
resolveSkillName(rootDir: string): string | undefined {
|
|
131
|
+
return this.skillNameByDir.get(rootDir);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 从磁盘加载已缓存的同步状态并重建索引(gateway 启动时调一次)。 */
|
|
135
|
+
async load(): Promise<void> {
|
|
136
|
+
try {
|
|
137
|
+
const raw = await fs.readFile(this.paths.syncStatePath, "utf-8");
|
|
138
|
+
const state = JSON.parse(raw) as SyncState;
|
|
139
|
+
// 兼容旧版本格式
|
|
140
|
+
if (state.skills) {
|
|
141
|
+
for (const [name, entry] of Object.entries(state.skills)) {
|
|
142
|
+
this.signatures.set(name, entry.signature);
|
|
143
|
+
if (entry.config) {
|
|
144
|
+
this.configs.set(name, entry.config);
|
|
145
|
+
const cacheKey = `${name}@${entry.version || "unknown"}`;
|
|
146
|
+
this.configPool.set(cacheKey, entry.config);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// 新版本格式
|
|
151
|
+
if (state.configPool) {
|
|
152
|
+
for (const [key, cfg] of Object.entries(state.configPool)) {
|
|
153
|
+
this.configPool.set(key, cfg);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (state.active) {
|
|
157
|
+
for (const [name, entry] of Object.entries(state.active)) {
|
|
158
|
+
this.signatures.set(name, entry.signature);
|
|
159
|
+
const v = entry.signature.split("|")[0];
|
|
160
|
+
const cacheKey = `${name}@${v || "unknown"}`;
|
|
161
|
+
const cfg = this.configPool.get(cacheKey);
|
|
162
|
+
if (cfg) this.configs.set(name, cfg);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
this.rebuildIndex();
|
|
166
|
+
} catch {
|
|
167
|
+
// 无状态文件,留空
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 递归扫描 extensions 及各 agent workspace 下所有 SKILL.md,解析名称/版本/签名。
|
|
173
|
+
* 同名 skill 可能分布在多个 workspace(如 coder/coder2 各持一份副本)。缓存以 skill 名为全局 key,
|
|
174
|
+
* 故这里按名去重并取确定性的一份(按 rootDir 排序后取首个),避免 reconcile 每轮在不同副本的
|
|
175
|
+
* 签名间反复横跳、触发无意义的重复拉取与持久化抖动。skillNameByDir 仍保留全部副本目录的映射。
|
|
176
|
+
*/
|
|
177
|
+
async scanInstalledSkills(): Promise<InstalledSkill[]> {
|
|
178
|
+
const out: InstalledSkill[] = [];
|
|
179
|
+
const walk = async (dir: string, depth: number): Promise<void> => {
|
|
180
|
+
if (depth > MAX_SCAN_DEPTH) return;
|
|
181
|
+
let entries: fsSync.Dirent[];
|
|
182
|
+
try {
|
|
183
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
184
|
+
} catch {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
for (const e of entries) {
|
|
188
|
+
if (e.isDirectory()) {
|
|
189
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
190
|
+
await walk(path.join(dir, e.name), depth + 1);
|
|
191
|
+
} else if (e.name === "SKILL.md") {
|
|
192
|
+
const skillMd = path.join(dir, e.name);
|
|
193
|
+
const skill = await this.readSkill(dir, skillMd);
|
|
194
|
+
if (skill) {
|
|
195
|
+
out.push(skill);
|
|
196
|
+
this.skillNameByDir.set(skill.rootDir, skill.name);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
await walk(this.paths.extensionsDir, 0);
|
|
202
|
+
// 动态解析所有 agent workspace 的 skills 目录(含顶层全局 skills):每次扫描重读 openclaw.json,
|
|
203
|
+
// 运行期新增 agent 无需重启即可被发现。不存在的目录在 walk 内已被吞掉。
|
|
204
|
+
for (const skillsDir of this.resolveSkillDirs()) {
|
|
205
|
+
await walk(skillsDir, 0);
|
|
206
|
+
}
|
|
207
|
+
out.sort((a, b) => a.rootDir.localeCompare(b.rootDir));
|
|
208
|
+
// 记录 skill → 所有安装副本(含各 workspace 下的 rootDir 与本地版本),供版本更新定位覆盖目标。
|
|
209
|
+
const installs = new Map<string, InstalledSkill[]>();
|
|
210
|
+
for (const s of out) {
|
|
211
|
+
const arr = installs.get(s.name);
|
|
212
|
+
if (arr) arr.push(s);
|
|
213
|
+
else installs.set(s.name, [s]);
|
|
214
|
+
}
|
|
215
|
+
this.installations = installs;
|
|
216
|
+
// 按 skill 名去重,取确定性的一份(rootDir 字典序最小)。
|
|
217
|
+
const deduped = new Map<string, InstalledSkill>();
|
|
218
|
+
for (const s of out) {
|
|
219
|
+
if (!deduped.has(s.name)) deduped.set(s.name, s);
|
|
220
|
+
}
|
|
221
|
+
return [...deduped.values()];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** skill → 所有安装副本(多 agent workspace 各一份)。供版本更新定位需要覆盖的目录。 */
|
|
225
|
+
getInstallations(): ReadonlyMap<string, ReadonlyArray<InstalledSkill>> {
|
|
226
|
+
return this.installations;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 基于最近一次版本检查结果,找出所有本地版本落后的安装副本。 */
|
|
230
|
+
detectOutdated(): OutdatedCopy[] {
|
|
231
|
+
const out: OutdatedCopy[] = [];
|
|
232
|
+
for (const [skillName, copies] of this.installations) {
|
|
233
|
+
const cfg = this.configs.get(skillName);
|
|
234
|
+
const latestVersion = this.latestVersions.get(skillName) || cfg?.latestVersion || cfg?.version;
|
|
235
|
+
if (!latestVersion) continue;
|
|
236
|
+
for (const copy of copies) {
|
|
237
|
+
if (!isOutdated(copy.version, latestVersion)) continue;
|
|
238
|
+
out.push({
|
|
239
|
+
skillName,
|
|
240
|
+
rootDir: copy.rootDir,
|
|
241
|
+
localVersion: copy.version || "",
|
|
242
|
+
latestVersion,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 扫描本地安装副本,拉取平台最新版本,检测落后副本并按配置触发自动更新。 */
|
|
250
|
+
async checkVersionsAndUpdate(): Promise<void> {
|
|
251
|
+
if (this.checkingVersions) return;
|
|
252
|
+
this.checkingVersions = true;
|
|
253
|
+
try {
|
|
254
|
+
const installed = await this.scanInstalledSkills();
|
|
255
|
+
this.scanCache = { ts: Date.now(), skills: installed };
|
|
256
|
+
|
|
257
|
+
if (installed.length > 0) {
|
|
258
|
+
const { ok, configs } = await this.pullConfigs(
|
|
259
|
+
installed.map((s) => ({ name: s.name, version: s.version }))
|
|
260
|
+
);
|
|
261
|
+
if (ok) {
|
|
262
|
+
for (const cfg of configs) {
|
|
263
|
+
const latestVersion = cfg.latestVersion || cfg.version;
|
|
264
|
+
if (latestVersion) this.latestVersions.set(cfg.skillName, latestVersion);
|
|
265
|
+
if (cfg.version) {
|
|
266
|
+
this.configPool.set(`${cfg.skillName}@${cfg.version}`, cfg);
|
|
267
|
+
if (!this.configs.has(cfg.skillName)) this.configs.set(cfg.skillName, cfg);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const outdated = this.detectOutdated();
|
|
274
|
+
if (outdated.length > 0 && this.updater) {
|
|
275
|
+
await this.updater.applyUpdates(outdated);
|
|
276
|
+
const refreshed = await this.scanInstalledSkills();
|
|
277
|
+
this.scanCache = { ts: Date.now(), skills: refreshed };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
await this.persist();
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.warn("[skill-logger-plugin] checkVersionsAndUpdate 异常", err);
|
|
283
|
+
} finally {
|
|
284
|
+
this.checkingVersions = false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** lazyCheck 专用:命中短 TTL 缓存则跳过整树遍历;reconcile 不走此路径,始终全新扫描。 */
|
|
289
|
+
private async scanInstalledSkillsCached(): Promise<InstalledSkill[]> {
|
|
290
|
+
const now = Date.now();
|
|
291
|
+
if (this.scanCache && now - this.scanCache.ts < SCAN_CACHE_TTL_MS) {
|
|
292
|
+
return this.scanCache.skills;
|
|
293
|
+
}
|
|
294
|
+
const skills = await this.scanInstalledSkills();
|
|
295
|
+
this.scanCache = { ts: now, skills };
|
|
296
|
+
return skills;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private async readSkill(rootDir: string, skillMdPath: string): Promise<InstalledSkill | undefined> {
|
|
300
|
+
try {
|
|
301
|
+
const content = await fs.readFile(skillMdPath, "utf-8");
|
|
302
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)?.[1] ?? "";
|
|
303
|
+
const name = /(^|\n)name:\s*(.+)/.exec(fm)?.[2]?.trim() || path.basename(rootDir);
|
|
304
|
+
const version = await readSkillVersion(rootDir, content);
|
|
305
|
+
const signature = await this.computeSignature(rootDir, skillMdPath, version);
|
|
306
|
+
return { name, version, rootDir, signature };
|
|
307
|
+
} catch {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 签名 = 版本(若有)+ SKILL.md 与 scripts/ 的 mtime/size 摘要。 */
|
|
313
|
+
private async computeSignature(rootDir: string, skillMdPath: string, version?: string): Promise<string> {
|
|
314
|
+
const parts: string[] = [version ?? ""];
|
|
315
|
+
try {
|
|
316
|
+
const st = await fs.stat(skillMdPath);
|
|
317
|
+
parts.push(`md:${st.mtimeMs}:${st.size}`);
|
|
318
|
+
} catch {
|
|
319
|
+
/* ignore */
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
const scriptsDir = path.join(rootDir, "scripts");
|
|
323
|
+
const entries = await fs.readdir(scriptsDir, { withFileTypes: true });
|
|
324
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
325
|
+
if (!e.isFile()) continue;
|
|
326
|
+
const st = await fs.stat(path.join(scriptsDir, e.name));
|
|
327
|
+
parts.push(`s:${e.name}:${st.mtimeMs}:${st.size}`);
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
/* 无 scripts 目录 */
|
|
331
|
+
}
|
|
332
|
+
return parts.join("|");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 找出需要拉取(新增或签名变化)与已移除的 skill。优先命中本地 configPool。 */
|
|
336
|
+
diffAgainstState(installed: InstalledSkill[]): { toFetch: InstalledSkill[]; removed: string[]; cached: InstalledSkill[] } {
|
|
337
|
+
const toFetch: InstalledSkill[] = [];
|
|
338
|
+
const cached: InstalledSkill[] = [];
|
|
339
|
+
const removed: string[] = [];
|
|
340
|
+
const seen = new Set<string>();
|
|
341
|
+
for (const s of installed) {
|
|
342
|
+
seen.add(s.name);
|
|
343
|
+
if (this.signatures.get(s.name) !== s.signature) {
|
|
344
|
+
const cacheKey = `${s.name}@${s.version || "unknown"}`;
|
|
345
|
+
if (this.configPool.has(cacheKey)) {
|
|
346
|
+
cached.push(s);
|
|
347
|
+
} else {
|
|
348
|
+
toFetch.push(s);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const name of this.signatures.keys()) {
|
|
353
|
+
if (!seen.has(name)) removed.push(name);
|
|
354
|
+
}
|
|
355
|
+
return { toFetch, removed, cached };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** 全量对账:扫描 → diff → 拉取 → 更新缓存 → 重建索引 → 持久化。 */
|
|
359
|
+
async reconcile(): Promise<void> {
|
|
360
|
+
if (this.reconciling) return;
|
|
361
|
+
this.reconciling = true;
|
|
362
|
+
try {
|
|
363
|
+
const installed = await this.scanInstalledSkills();
|
|
364
|
+
this.scanCache = { ts: Date.now(), skills: installed }; // 刷新 lazyCheck 缓存,保证安装后对账的新鲜度
|
|
365
|
+
this.debug(`reconcile: Found ${installed.length} installed skills.`);
|
|
366
|
+
const { toFetch, removed, cached } = this.diffAgainstState(installed);
|
|
367
|
+
if (toFetch.length === 0 && removed.length === 0 && cached.length === 0) return;
|
|
368
|
+
|
|
369
|
+
// 命中本地池的,直接置为活跃
|
|
370
|
+
for (const s of cached) {
|
|
371
|
+
this.signatures.set(s.name, s.signature);
|
|
372
|
+
this.configs.set(s.name, this.configPool.get(`${s.name}@${s.version || "unknown"}`)!);
|
|
373
|
+
this.debug(`reconcile: Skill ${s.name}@${s.version} instantly loaded from local configPool.`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (toFetch.length > 0) {
|
|
377
|
+
this.debug(`reconcile: Fetching configs for ${toFetch.length} skills...`);
|
|
378
|
+
const { ok, configs } = await this.pullConfigs(
|
|
379
|
+
toFetch.map((s) => ({ name: s.name, version: s.version }))
|
|
380
|
+
);
|
|
381
|
+
// 仅在拿到确定性结果时推进签名;拉取失败则不推进,下轮重试。
|
|
382
|
+
if (ok) {
|
|
383
|
+
const byName = new Map(configs.map((c) => [c.skillName, c]));
|
|
384
|
+
this.debug(`reconcile: Fetched ${configs.length} configs successfully.`);
|
|
385
|
+
for (const s of toFetch) {
|
|
386
|
+
const cfg = byName.get(s.name) as SkillStandardConfig & { status?: string };
|
|
387
|
+
|
|
388
|
+
// If the platform says this config is still being processed or reviewed,
|
|
389
|
+
// we skip updating the signature so it will be retried in the next reconcile.
|
|
390
|
+
if (cfg && (cfg.status === 'REVIEW_NEEDED' || cfg.status === 'EXTRACTING')) {
|
|
391
|
+
this.debug(`reconcile: Skill ${s.name} is ${cfg.status}, skipping signature update.`);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
this.signatures.set(s.name, s.signature);
|
|
396
|
+
if (cfg) {
|
|
397
|
+
this.configs.set(s.name, cfg);
|
|
398
|
+
this.configPool.set(`${s.name}@${s.version || "unknown"}`, cfg);
|
|
399
|
+
} else {
|
|
400
|
+
this.configs.delete(s.name); // 平台明确无此配置
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
for (const name of removed) {
|
|
406
|
+
this.signatures.delete(name);
|
|
407
|
+
this.configs.delete(name);
|
|
408
|
+
}
|
|
409
|
+
this.rebuildIndex();
|
|
410
|
+
await this.persist();
|
|
411
|
+
} catch (err) {
|
|
412
|
+
console.warn("[skill-logger-plugin] reconcile 异常", err);
|
|
413
|
+
} finally {
|
|
414
|
+
this.reconciling = false;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** 懒触发:仅检查某个被触发的 skill,缺配置/签名变才拉。`ident` 可为规范名或目录名。 */
|
|
419
|
+
async lazyCheck(ident: string): Promise<void> {
|
|
420
|
+
try {
|
|
421
|
+
const find = (list: InstalledSkill[]) =>
|
|
422
|
+
list.find((x) => x.name === ident || path.basename(x.rootDir) === ident);
|
|
423
|
+
let s = find(await this.scanInstalledSkillsCached());
|
|
424
|
+
// 缓存里没有该 skill:可能是刚安装的新 skill,强制全新扫描兜底,行为与未加缓存前一致。
|
|
425
|
+
if (!s) {
|
|
426
|
+
const fresh = await this.scanInstalledSkills();
|
|
427
|
+
this.scanCache = { ts: Date.now(), skills: fresh };
|
|
428
|
+
s = find(fresh);
|
|
429
|
+
}
|
|
430
|
+
if (!s) return;
|
|
431
|
+
if (this.signatures.get(s.name) === s.signature && this.configs.has(s.name)) return;
|
|
432
|
+
|
|
433
|
+
const cacheKey = `${s.name}@${s.version || "unknown"}`;
|
|
434
|
+
if (this.configPool.has(cacheKey)) {
|
|
435
|
+
this.debug(`lazyCheck: Skill ${s.name}@${s.version} loaded instantly from local configPool.`);
|
|
436
|
+
this.signatures.set(s.name, s.signature);
|
|
437
|
+
this.configs.set(s.name, this.configPool.get(cacheKey)!);
|
|
438
|
+
this.rebuildIndex();
|
|
439
|
+
await this.persist();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
this.debug(`lazyCheck: Fetching config for ${s.name}@${s.version}...`);
|
|
444
|
+
const { ok, configs } = await this.pullConfigs([{ name: s.name, version: s.version }]);
|
|
445
|
+
if (!ok) return; // 拉取失败,保留旧状态,下轮重试
|
|
446
|
+
|
|
447
|
+
this.debug(`lazyCheck: Fetched ${configs.length} configs successfully.`);
|
|
448
|
+
const cfg = configs.find((c) => c.skillName === s.name) as SkillStandardConfig & { status?: string };
|
|
449
|
+
|
|
450
|
+
if (cfg && (cfg.status === 'REVIEW_NEEDED' || cfg.status === 'EXTRACTING')) {
|
|
451
|
+
this.debug(`lazyCheck: Skill ${s.name} is ${cfg.status}, skipping signature update.`);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
this.signatures.set(s.name, s.signature);
|
|
456
|
+
if (cfg) {
|
|
457
|
+
this.configs.set(s.name, cfg);
|
|
458
|
+
this.configPool.set(cacheKey, cfg);
|
|
459
|
+
} else {
|
|
460
|
+
this.configs.delete(s.name);
|
|
461
|
+
}
|
|
462
|
+
this.rebuildIndex();
|
|
463
|
+
await this.persist();
|
|
464
|
+
} catch (err) {
|
|
465
|
+
console.warn("[skill-logger-plugin] lazyCheck 异常", err);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* 拉取标准配置。配了 platformBaseUrl → POST 平台;否则用本地静态桩。
|
|
471
|
+
*
|
|
472
|
+
* 返回 `ok` 区分「确定性结果」与「拉取失败」:
|
|
473
|
+
* - ok=true :拿到了平台的明确答复(configs 可能为空,表示平台对这些 skill 暂无配置)。
|
|
474
|
+
* - ok=false :网络/服务异常,调用方**不应**推进签名,下轮重试。
|
|
475
|
+
*/
|
|
476
|
+
async pullConfigs(
|
|
477
|
+
skillRefs: { name: string; version?: string }[]
|
|
478
|
+
): Promise<{ ok: boolean; configs: SkillStandardConfig[] }> {
|
|
479
|
+
const config = this.getConfig();
|
|
480
|
+
if (config.platformBaseUrl) {
|
|
481
|
+
try {
|
|
482
|
+
const url = config.platformBaseUrl.replace(/\/$/, "") + "/skill_config/pull";
|
|
483
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
484
|
+
if (config.authToken) headers.Authorization = config.authToken;
|
|
485
|
+
const res = await this.fetchImpl(url, {
|
|
486
|
+
method: "POST",
|
|
487
|
+
headers,
|
|
488
|
+
body: JSON.stringify({ skills: skillRefs }),
|
|
489
|
+
});
|
|
490
|
+
if (!res.ok) {
|
|
491
|
+
console.warn("[skill-logger-plugin] 拉取标准配置失败,HTTP", res.status);
|
|
492
|
+
return { ok: false, configs: [] };
|
|
493
|
+
}
|
|
494
|
+
const data = (await res.json()) as { configs?: SkillStandardConfig[] };
|
|
495
|
+
return { ok: true, configs: data.configs ?? [] };
|
|
496
|
+
} catch (err) {
|
|
497
|
+
console.warn("[skill-logger-plugin] 拉取标准配置异常", err);
|
|
498
|
+
return { ok: false, configs: [] };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
// 本地静态桩:仅返回请求到的 skill(视为确定性结果)
|
|
502
|
+
const want = new Set(skillRefs.map((r) => r.name));
|
|
503
|
+
return { ok: true, configs: (await this.loadSampleConfigs()).filter((c) => want.has(c.skillName)) };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private async loadSampleConfigs(): Promise<SkillStandardConfig[]> {
|
|
507
|
+
if (this.sampleConfigs) return this.sampleConfigs;
|
|
508
|
+
try {
|
|
509
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
510
|
+
const raw = await fs.readFile(path.join(here, "sample-config.json"), "utf-8");
|
|
511
|
+
this.sampleConfigs = (JSON.parse(raw) as { configs: SkillStandardConfig[] }).configs;
|
|
512
|
+
} catch {
|
|
513
|
+
this.sampleConfigs = [];
|
|
514
|
+
}
|
|
515
|
+
return this.sampleConfigs;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private rebuildIndex(): void {
|
|
519
|
+
this.index = buildIndex([...this.configs.values()]);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private async persist(): Promise<void> {
|
|
523
|
+
try {
|
|
524
|
+
const state: SyncState = { active: {}, configPool: {}, installations: {} };
|
|
525
|
+
for (const [name, signature] of this.signatures) {
|
|
526
|
+
state.active![name] = { signature };
|
|
527
|
+
}
|
|
528
|
+
for (const [key, cfg] of this.configPool) {
|
|
529
|
+
state.configPool![key] = cfg;
|
|
530
|
+
}
|
|
531
|
+
// agent↔skill 安装映射:本地留存每个 skill 在各 workspace 的副本与版本。
|
|
532
|
+
for (const [name, copies] of this.installations) {
|
|
533
|
+
state.installations![name] = copies.map((c) => ({ rootDir: c.rootDir, version: c.version }));
|
|
534
|
+
}
|
|
535
|
+
await fs.mkdir(path.dirname(this.paths.syncStatePath), { recursive: true });
|
|
536
|
+
// 原子写:写临时文件再 rename,避免 reconcile 与版本检查并发持久化时相互写坏。
|
|
537
|
+
const tmp = `${this.paths.syncStatePath}.tmp-${process.pid}-${Date.now()}`;
|
|
538
|
+
await fs.writeFile(tmp, JSON.stringify(state));
|
|
539
|
+
await fs.rename(tmp, this.paths.syncStatePath);
|
|
540
|
+
} catch (err) {
|
|
541
|
+
console.warn("[skill-logger-plugin] 持久化同步状态失败", err);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|