@spzhongwin/skill-logger-plugin 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/active-skills.js +67 -0
- package/dist/active-skills.test.js +29 -0
- package/dist/config-sync.js +439 -0
- package/dist/config-sync.test.js +145 -0
- package/dist/hooks.js +337 -0
- package/dist/hooks.test.js +123 -0
- package/dist/http.js +54 -0
- package/dist/identity.js +56 -0
- package/dist/index.js +2286 -0
- package/dist/index.test.js +39 -0
- package/dist/integration.test.js +102 -0
- package/dist/matcher.js +362 -0
- package/dist/matcher.test.js +139 -0
- package/dist/paths.js +62 -0
- package/dist/paths.test.js +49 -0
- package/dist/reporter.js +267 -0
- package/dist/reporter.test.js +128 -0
- package/dist/semver.js +64 -0
- package/dist/semver.test.js +21 -0
- package/dist/skill-version.js +23 -0
- package/dist/types.js +9 -0
- package/dist/updater.js +352 -0
- package/dist/updater.test.js +212 -0
- package/dist/ws-client.js +484 -0
- package/openclaw.plugin.json +50 -0
- package/package.json +35 -0
- package/src/active-skills.test.ts +32 -0
- package/src/active-skills.ts +77 -0
- package/src/config-sync.test.ts +165 -0
- package/src/config-sync.ts +485 -0
- package/src/hooks.test.ts +156 -0
- package/src/hooks.ts +405 -0
- package/src/http.ts +61 -0
- package/src/identity.ts +64 -0
- package/src/index.test.ts +53 -0
- package/src/index.ts +226 -0
- package/src/integration.test.ts +119 -0
- package/src/matcher.test.ts +170 -0
- package/src/matcher.ts +393 -0
- package/src/paths.test.ts +57 -0
- package/src/paths.ts +84 -0
- package/src/reporter.test.ts +139 -0
- package/src/reporter.ts +298 -0
- package/src/sample-config.json +72 -0
- package/src/semver.test.ts +23 -0
- package/src/semver.ts +60 -0
- package/src/skill-version.ts +22 -0
- package/src/types.ts +198 -0
- package/src/updater.test.ts +237 -0
- package/src/updater.ts +400 -0
- package/src/ws-client.ts +516 -0
- package/test-ws.ts +17 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import { openclawHome } from "./paths.ts";
|
|
5
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
6
|
+
const HEARTBEAT_ACK_TIMEOUT_MS = 75_000;
|
|
7
|
+
const AGENT_SCAN_INTERVAL_MS = 3 * 60 * 1000;
|
|
8
|
+
export class GatewayWsClient {
|
|
9
|
+
ws = null;
|
|
10
|
+
options;
|
|
11
|
+
reconnectTimer = null;
|
|
12
|
+
agentScanTimer = null;
|
|
13
|
+
pingTimer = null;
|
|
14
|
+
connectTimeoutTimer = null;
|
|
15
|
+
lastServerAckAt = 0;
|
|
16
|
+
reconnectAttempts = 0;
|
|
17
|
+
isDestroyed = false;
|
|
18
|
+
// 本地缓存的 agent ID 列表
|
|
19
|
+
currentAgentIds = new Set();
|
|
20
|
+
appendLogToFile(level, category, message, payload) {
|
|
21
|
+
if (!this.options.enableFileLog)
|
|
22
|
+
return;
|
|
23
|
+
try {
|
|
24
|
+
const ts = new Date().toISOString();
|
|
25
|
+
let logLine = `[${ts}] [${level}] [${category}] ${message}`;
|
|
26
|
+
if (payload !== undefined && payload !== null) {
|
|
27
|
+
// 如果是 Error 对象,主动提取 stack
|
|
28
|
+
if (payload instanceof Error) {
|
|
29
|
+
logLine += `\n Stack: ${payload.stack || payload.message}`;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
logLine += ` | Data: ${typeof payload === 'object' ? JSON.stringify(payload) : payload}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
logLine += '\n';
|
|
36
|
+
const logsDir = path.join(openclawHome(), "logs");
|
|
37
|
+
fs.mkdir(logsDir, { recursive: true }).then(() => {
|
|
38
|
+
const logPath = path.join(logsDir, "skill-logger.err");
|
|
39
|
+
fs.appendFile(logPath, logLine).catch(() => { });
|
|
40
|
+
}).catch(() => { });
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
// ignore
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
constructor(options) {
|
|
47
|
+
this.options = options;
|
|
48
|
+
}
|
|
49
|
+
connect() {
|
|
50
|
+
if (this.isDestroyed)
|
|
51
|
+
return;
|
|
52
|
+
if (this.ws && (this.ws.readyState === WebSocket.OPEN ||
|
|
53
|
+
this.ws.readyState === WebSocket.CONNECTING)) {
|
|
54
|
+
this.appendLogToFile("INFO", "Connection", "Connect skipped: websocket already active", { readyState: this.ws.readyState });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
this.clearConnectTimeout();
|
|
58
|
+
const msgConnect = `Connecting to Central Service: ${this.options.serverUrl}`;
|
|
59
|
+
console.log(`[skill-logger-plugin][WS] ${msgConnect}`);
|
|
60
|
+
this.appendLogToFile("INFO", "Connection", msgConnect);
|
|
61
|
+
const headers = {
|
|
62
|
+
"X-Gateway-Id": this.options.gatewayId,
|
|
63
|
+
};
|
|
64
|
+
if (this.options.authToken) {
|
|
65
|
+
headers["Authorization"] = this.options.authToken;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const ws = new WebSocket(this.options.serverUrl, { headers });
|
|
69
|
+
this.ws = ws;
|
|
70
|
+
this.connectTimeoutTimer = setTimeout(() => {
|
|
71
|
+
if (this.ws === ws && ws.readyState === WebSocket.CONNECTING) {
|
|
72
|
+
this.appendLogToFile("WARN", "Connection", "Connection timed out before open; terminating socket.");
|
|
73
|
+
ws.terminate();
|
|
74
|
+
}
|
|
75
|
+
}, 15000);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
console.error(`[skill-logger-plugin][WS] Sync init error:`, err);
|
|
79
|
+
this.appendLogToFile("ERROR", "Connection", "Sync initialization error", err);
|
|
80
|
+
this.scheduleReconnect();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const ws = this.ws;
|
|
84
|
+
ws.on("open", async () => {
|
|
85
|
+
if (this.ws !== ws)
|
|
86
|
+
return;
|
|
87
|
+
console.log(`[skill-logger-plugin][WS] Connected successfully!`);
|
|
88
|
+
this.appendLogToFile("INFO", "Connection", "Connected successfully!");
|
|
89
|
+
this.clearConnectTimeout();
|
|
90
|
+
this.reconnectAttempts = 0;
|
|
91
|
+
this.lastServerAckAt = Date.now();
|
|
92
|
+
this.clearReconnectTimer();
|
|
93
|
+
// 首次连接,全量扫描并上报,同时进行应用层握手,确保服务端能把 DB 在线态刷新成真实状态。
|
|
94
|
+
await this.scanAndReportAgents(true);
|
|
95
|
+
this.sendGatewayHello();
|
|
96
|
+
this.startHeartbeat();
|
|
97
|
+
// 开启 3 分钟定期的自动扫码增量同步
|
|
98
|
+
if (!this.agentScanTimer) {
|
|
99
|
+
this.agentScanTimer = setInterval(() => {
|
|
100
|
+
this.scanAndReportAgents(true);
|
|
101
|
+
}, AGENT_SCAN_INTERVAL_MS);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
ws.on("pong", () => {
|
|
105
|
+
this.lastServerAckAt = Date.now();
|
|
106
|
+
});
|
|
107
|
+
ws.on("message", async (data) => {
|
|
108
|
+
if (this.ws !== ws)
|
|
109
|
+
return;
|
|
110
|
+
try {
|
|
111
|
+
const msg = JSON.parse(data.toString());
|
|
112
|
+
if (msg?.type === "GATEWAY_HELLO_ACK" || msg?.type === "HEARTBEAT_ACK") {
|
|
113
|
+
this.lastServerAckAt = Date.now();
|
|
114
|
+
this.appendLogToFile("INFO", "Heartbeat", "Server heartbeat acknowledged", { type: msg.type, connectionId: msg.connectionId });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await this.handleMessage(msg);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
console.error(`[skill-logger-plugin][WS] Failed to parse/handle message:`, err);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
ws.on("close", () => {
|
|
124
|
+
console.warn(`[skill-logger-plugin][WS] Connection closed.`);
|
|
125
|
+
this.appendLogToFile("WARN", "Connection", "Connection closed. Starting reconnect timer.");
|
|
126
|
+
if (this.ws === ws) {
|
|
127
|
+
this.ws = null;
|
|
128
|
+
this.clearConnectTimeout();
|
|
129
|
+
this.clearAgentScanTimer();
|
|
130
|
+
this.clearHeartbeat();
|
|
131
|
+
this.scheduleReconnect();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
ws.on("error", (err) => {
|
|
135
|
+
console.error(`[skill-logger-plugin][WS] Connection error:`, err);
|
|
136
|
+
this.appendLogToFile("ERROR", "Connection", "Connection error", err);
|
|
137
|
+
if (this.ws === ws) {
|
|
138
|
+
ws.close(); // 触发 close 事件进行重连
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 扫描 OpenClaw 根目录下的 workspace-assistant-{userId} 目录
|
|
144
|
+
*/
|
|
145
|
+
async scanAndReportAgents(isInitialReport) {
|
|
146
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
147
|
+
return;
|
|
148
|
+
try {
|
|
149
|
+
const rootPath = openclawHome();
|
|
150
|
+
let entries = [];
|
|
151
|
+
try {
|
|
152
|
+
entries = await fs.readdir(rootPath);
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
this.currentAgentIds = new Set();
|
|
156
|
+
this.appendLogToFile("WARN", "AgentScan", "OpenClaw home is not readable; reporting empty agent list", e);
|
|
157
|
+
this.sendAgentListReport("AGENT_LIST_REPORT");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const newAgentIds = new Set();
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
if (entry.startsWith("workspace-assistant-")) {
|
|
163
|
+
const suffix = entry.replace("workspace-assistant-", "").trim();
|
|
164
|
+
if (suffix && suffix === path.basename(suffix) && !suffix.startsWith(".")) {
|
|
165
|
+
newAgentIds.add(`assistant-${suffix}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
let changed = false;
|
|
170
|
+
if (newAgentIds.size !== this.currentAgentIds.size) {
|
|
171
|
+
changed = true;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
for (const id of newAgentIds) {
|
|
175
|
+
if (!this.currentAgentIds.has(id)) {
|
|
176
|
+
changed = true;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.currentAgentIds = newAgentIds;
|
|
182
|
+
if (isInitialReport) {
|
|
183
|
+
this.appendLogToFile("INFO", "AgentScan", `Reporting full agent list`, { count: this.currentAgentIds.size, agents: Array.from(this.currentAgentIds) });
|
|
184
|
+
this.sendAgentListReport("AGENT_LIST_REPORT");
|
|
185
|
+
}
|
|
186
|
+
else if (changed) {
|
|
187
|
+
this.appendLogToFile("INFO", "AgentScan", `Syncing changed agent list`, { count: this.currentAgentIds.size, agents: Array.from(this.currentAgentIds) });
|
|
188
|
+
this.sendAgentListReport("AGENT_LIST_SYNC");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
console.error(`[skill-logger-plugin][WS] Failed to scan agents:`, err);
|
|
193
|
+
this.appendLogToFile("ERROR", "AgentScan", `Failed to scan agents`, err);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
startHeartbeat() {
|
|
197
|
+
this.clearHeartbeat();
|
|
198
|
+
this.pingTimer = setInterval(() => {
|
|
199
|
+
const ws = this.ws;
|
|
200
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
201
|
+
return;
|
|
202
|
+
const ackAge = Date.now() - this.lastServerAckAt;
|
|
203
|
+
if (ackAge > HEARTBEAT_ACK_TIMEOUT_MS) {
|
|
204
|
+
this.appendLogToFile("WARN", "Heartbeat", "Server heartbeat ACK timed out; terminating socket for reconnect", { ackAge });
|
|
205
|
+
ws.terminate();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
ws.ping();
|
|
209
|
+
this.sendClientHeartbeat();
|
|
210
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
211
|
+
this.sendClientHeartbeat();
|
|
212
|
+
}
|
|
213
|
+
clearHeartbeat() {
|
|
214
|
+
if (this.pingTimer) {
|
|
215
|
+
clearInterval(this.pingTimer);
|
|
216
|
+
this.pingTimer = null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
scheduleReconnect() {
|
|
220
|
+
if (this.isDestroyed || this.reconnectTimer)
|
|
221
|
+
return;
|
|
222
|
+
// 闭环完善:引入随机 Jitter 抖动,打散服务端重启时可能引发的瞬间重连风暴
|
|
223
|
+
const jitter = Math.floor(Math.random() * 5000);
|
|
224
|
+
const baseDelay = Math.min(30000, 2000 * Math.max(1, 2 ** this.reconnectAttempts));
|
|
225
|
+
const delay = baseDelay + jitter;
|
|
226
|
+
this.reconnectAttempts += 1;
|
|
227
|
+
console.log(`[skill-logger-plugin][WS] Reconnecting in ${delay}ms...`);
|
|
228
|
+
this.appendLogToFile("INFO", "Connection", "Reconnect scheduled", { delay, attempt: this.reconnectAttempts });
|
|
229
|
+
this.reconnectTimer = setTimeout(() => {
|
|
230
|
+
this.reconnectTimer = null;
|
|
231
|
+
this.connect();
|
|
232
|
+
}, delay);
|
|
233
|
+
}
|
|
234
|
+
clearReconnectTimer() {
|
|
235
|
+
if (this.reconnectTimer) {
|
|
236
|
+
clearTimeout(this.reconnectTimer);
|
|
237
|
+
this.reconnectTimer = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
clearConnectTimeout() {
|
|
241
|
+
if (this.connectTimeoutTimer) {
|
|
242
|
+
clearTimeout(this.connectTimeoutTimer);
|
|
243
|
+
this.connectTimeoutTimer = null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
terminateCurrentSocket(reason, payload) {
|
|
247
|
+
const ws = this.ws;
|
|
248
|
+
if (!ws)
|
|
249
|
+
return;
|
|
250
|
+
this.appendLogToFile("WARN", "Connection", `Terminating websocket: ${reason}`, payload);
|
|
251
|
+
try {
|
|
252
|
+
ws.terminate();
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
this.appendLogToFile("WARN", "Connection", "Terminate websocket failed", err);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
sendJson(payload, category) {
|
|
259
|
+
const ws = this.ws;
|
|
260
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
261
|
+
return false;
|
|
262
|
+
try {
|
|
263
|
+
ws.send(JSON.stringify(payload), (err) => {
|
|
264
|
+
if (!err)
|
|
265
|
+
return;
|
|
266
|
+
this.appendLogToFile("WARN", category, "WebSocket send failed", err);
|
|
267
|
+
if (this.ws === ws) {
|
|
268
|
+
this.terminateCurrentSocket("send_failed", { category, message: err.message });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
this.appendLogToFile("WARN", category, "WebSocket send threw", err);
|
|
275
|
+
if (this.ws === ws) {
|
|
276
|
+
this.terminateCurrentSocket("send_threw", err);
|
|
277
|
+
}
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
sendGatewayHello() {
|
|
282
|
+
this.sendJson({
|
|
283
|
+
type: "GATEWAY_HELLO",
|
|
284
|
+
gatewayId: this.options.gatewayId,
|
|
285
|
+
agentIds: Array.from(this.currentAgentIds),
|
|
286
|
+
clientTime: Date.now(),
|
|
287
|
+
}, "Heartbeat");
|
|
288
|
+
}
|
|
289
|
+
sendClientHeartbeat() {
|
|
290
|
+
this.sendJson({
|
|
291
|
+
type: "CLIENT_HEARTBEAT",
|
|
292
|
+
gatewayId: this.options.gatewayId,
|
|
293
|
+
agentIds: Array.from(this.currentAgentIds),
|
|
294
|
+
clientTime: Date.now(),
|
|
295
|
+
}, "Heartbeat");
|
|
296
|
+
}
|
|
297
|
+
sendAgentListReport(type) {
|
|
298
|
+
this.sendJson({
|
|
299
|
+
type,
|
|
300
|
+
gatewayId: this.options.gatewayId,
|
|
301
|
+
agentIds: Array.from(this.currentAgentIds),
|
|
302
|
+
clientTime: Date.now(),
|
|
303
|
+
}, "AgentScan");
|
|
304
|
+
}
|
|
305
|
+
clearAgentScanTimer() {
|
|
306
|
+
if (this.agentScanTimer) {
|
|
307
|
+
clearInterval(this.agentScanTimer);
|
|
308
|
+
this.agentScanTimer = null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* 核心指令分发中心:完全跳过沙盒,基于 userId 直接进行底层物理文件操作
|
|
313
|
+
*/
|
|
314
|
+
async handleMessage(msg) {
|
|
315
|
+
const { action, userId, code, url, force, version, replyId } = msg;
|
|
316
|
+
this.appendLogToFile("INFO", "Command", `Received WS message`, { action, userId, code, version, replyId });
|
|
317
|
+
if (!action || !userId) {
|
|
318
|
+
this.appendLogToFile("WARN", "Command", `Message dropped: missing action or userId`, msg);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// 闭环完善:强制清理 userId 和 code 字符串,防止恶意指令通过 '../' 引发宿主机目录穿越攻击
|
|
322
|
+
const safeUserId = path.basename(userId);
|
|
323
|
+
const safeCode = code ? path.basename(code) : undefined;
|
|
324
|
+
// 100% 确定性安全寻址 (去掉 userId 中可能带有的 assistant- 前缀,统一用 workspace-assistant- 拼接)
|
|
325
|
+
const pureId = safeUserId.replace(/^assistant-/, "");
|
|
326
|
+
const targetDir = path.join(openclawHome(), `workspace-assistant-${pureId}`, "skills");
|
|
327
|
+
try {
|
|
328
|
+
if (action === "INSTALL_SKILL") {
|
|
329
|
+
if (!safeCode)
|
|
330
|
+
throw new Error("Missing code parameter");
|
|
331
|
+
console.log(`[skill-logger-plugin][WS] Executing INSTALL for user ${userId}, code: ${safeCode}`);
|
|
332
|
+
this.appendLogToFile("INFO", "Command", `INSTALL_SKILL received`, { userId, code: safeCode, version });
|
|
333
|
+
const result = await this.options.updater.manualInstall({
|
|
334
|
+
code: safeCode, url, version, force: force !== false, targetDir
|
|
335
|
+
});
|
|
336
|
+
this.reply(replyId, { success: result.success, message: result.message, action });
|
|
337
|
+
}
|
|
338
|
+
else if (action === "UNINSTALL_SKILL") {
|
|
339
|
+
if (!safeCode)
|
|
340
|
+
throw new Error("Missing code parameter");
|
|
341
|
+
console.log(`[skill-logger-plugin][WS] Executing UNINSTALL for user ${userId}, code: ${safeCode}`);
|
|
342
|
+
this.appendLogToFile("INFO", "Command", `UNINSTALL_SKILL received`, { userId, code: safeCode });
|
|
343
|
+
const skillPath = path.join(targetDir, safeCode);
|
|
344
|
+
await fs.rm(skillPath, { recursive: true, force: true });
|
|
345
|
+
this.reply(replyId, { success: true, message: `Skill ${safeCode} removed`, action });
|
|
346
|
+
}
|
|
347
|
+
else if (action === "LIST_SKILLS") {
|
|
348
|
+
let list = [];
|
|
349
|
+
let targetDirExists = false;
|
|
350
|
+
try {
|
|
351
|
+
const targetStat = await fs.stat(targetDir);
|
|
352
|
+
targetDirExists = targetStat.isDirectory();
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
if (err?.code !== "ENOENT")
|
|
356
|
+
throw err;
|
|
357
|
+
}
|
|
358
|
+
if (!targetDirExists) {
|
|
359
|
+
throw new Error(`Target skills directory does not exist: ${targetDir}`);
|
|
360
|
+
}
|
|
361
|
+
const entries = await fs.readdir(targetDir, { withFileTypes: true });
|
|
362
|
+
const dirs = entries.filter(e => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith("."));
|
|
363
|
+
for (const e of dirs) {
|
|
364
|
+
const skillDir = path.join(targetDir, e.name);
|
|
365
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
|
366
|
+
try {
|
|
367
|
+
const stat = await fs.stat(skillMdPath);
|
|
368
|
+
if (!stat.isFile())
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const metaPath = path.join(skillDir, '.meta.json');
|
|
375
|
+
let isPlatform = false;
|
|
376
|
+
let isBuiltIn = e.isSymbolicLink();
|
|
377
|
+
let metaData = null;
|
|
378
|
+
let name = e.name;
|
|
379
|
+
let description = "";
|
|
380
|
+
let skillVersion = "";
|
|
381
|
+
try {
|
|
382
|
+
const mdContent = await fs.readFile(skillMdPath, 'utf8');
|
|
383
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(mdContent)?.[1] ?? "";
|
|
384
|
+
const parsedName = /(^|\n)name:\s*(.+)/.exec(fm)?.[2]?.trim();
|
|
385
|
+
if (parsedName)
|
|
386
|
+
name = parsedName;
|
|
387
|
+
const parsedVersion = /(^|\n)version:\s*(.+)/i.exec(fm)?.[2]?.trim();
|
|
388
|
+
if (parsedVersion)
|
|
389
|
+
skillVersion = parsedVersion.replace(/^['"]|['"]$/g, '').replace(/\s+#.*$/, '');
|
|
390
|
+
const descMatch = /(^|\n)description:\s*(?:>\s*\n\s*)?(.*?)(?=\n[a-z]+:|\n---|$)/is.exec(fm);
|
|
391
|
+
if (descMatch && descMatch[2]) {
|
|
392
|
+
description = descMatch[2].replace(/\n\s+/g, ' ').trim();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch (err) { }
|
|
396
|
+
try {
|
|
397
|
+
const metaContent = await fs.readFile(metaPath, 'utf8');
|
|
398
|
+
const parsed = JSON.parse(metaContent);
|
|
399
|
+
if (parsed) {
|
|
400
|
+
if (parsed.ownerId === 'CMS' || parsed.ownerId === 'CMS_COMPAT')
|
|
401
|
+
isPlatform = true;
|
|
402
|
+
if (parsed.isBuiltIn === true || parsed.ownerId === 'built-in')
|
|
403
|
+
isBuiltIn = true;
|
|
404
|
+
metaData = parsed;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
catch (err) { }
|
|
408
|
+
if (isPlatform) {
|
|
409
|
+
list.push({
|
|
410
|
+
code: e.name,
|
|
411
|
+
isPlatform: true,
|
|
412
|
+
isBuiltIn: isBuiltIn,
|
|
413
|
+
version: metaData?.version || skillVersion,
|
|
414
|
+
name,
|
|
415
|
+
description,
|
|
416
|
+
publishedAt: metaData?.publishedAt
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
list.push({
|
|
421
|
+
code: e.name,
|
|
422
|
+
isPlatform: false,
|
|
423
|
+
isBuiltIn: isBuiltIn,
|
|
424
|
+
version: skillVersion,
|
|
425
|
+
name: name,
|
|
426
|
+
description: description
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
this.reply(replyId, { success: true, data: list, action });
|
|
431
|
+
}
|
|
432
|
+
else if (action === "UPDATE_SKILL") {
|
|
433
|
+
if (!safeCode)
|
|
434
|
+
throw new Error("Missing code parameter");
|
|
435
|
+
// 更新可能是大批量广播,增加防阻塞的随机延时 (0~5秒)
|
|
436
|
+
const delayMs = Math.random() * 5000;
|
|
437
|
+
console.log(`[skill-logger-plugin][WS] Scheduled UPDATE for user ${userId}, code: ${safeCode} in ${Math.round(delayMs)}ms`);
|
|
438
|
+
this.appendLogToFile("INFO", "Command", `Scheduled UPDATE_SKILL`, { userId, code: safeCode, version, delayMs: Math.round(delayMs) });
|
|
439
|
+
setTimeout(async () => {
|
|
440
|
+
try {
|
|
441
|
+
await this.options.updater.manualInstall({
|
|
442
|
+
code: safeCode, url, version, force: true, targetDir
|
|
443
|
+
});
|
|
444
|
+
// 广播更新一般无需强阻塞等待 replyId,若有需要也可以回传
|
|
445
|
+
if (replyId) {
|
|
446
|
+
this.reply(replyId, { success: true, message: `Skill ${safeCode} updated successfully`, action });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch (e) {
|
|
450
|
+
if (replyId)
|
|
451
|
+
this.reply(replyId, { success: false, message: e.message, action });
|
|
452
|
+
}
|
|
453
|
+
}, delayMs);
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
console.warn(`[skill-logger-plugin][WS] Unknown action: ${action}`);
|
|
457
|
+
this.appendLogToFile("WARN", "Command", `Unknown action: ${action}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
this.appendLogToFile("ERROR", "Command", `Error executing action ${action}`, err);
|
|
462
|
+
this.reply(replyId, { success: false, message: err.message, action });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
reply(replyId, payload) {
|
|
466
|
+
this.appendLogToFile("INFO", "Command", `Replying to command`, { replyId, payload });
|
|
467
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !replyId) {
|
|
468
|
+
this.appendLogToFile("WARN", "Command", `Cannot reply: WS closed or missing replyId`, { replyId });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
this.ws.send(JSON.stringify({ type: "REPLY", replyId, ...payload }));
|
|
472
|
+
}
|
|
473
|
+
destroy() {
|
|
474
|
+
this.isDestroyed = true;
|
|
475
|
+
this.clearReconnectTimer();
|
|
476
|
+
this.clearAgentScanTimer();
|
|
477
|
+
this.clearHeartbeat();
|
|
478
|
+
this.clearConnectTimeout();
|
|
479
|
+
if (this.ws) {
|
|
480
|
+
this.ws.terminate(); // 强行销毁,斩断半开连接残留
|
|
481
|
+
this.ws = null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "skill-logger-plugin",
|
|
3
|
+
"name": "Skill Logger",
|
|
4
|
+
"description": "追踪 openclaw skill 内功能点(脚本/命令/工具/HTTP)使用与报错,落本地并批量上报",
|
|
5
|
+
"activation": {
|
|
6
|
+
"onStartup": true
|
|
7
|
+
},
|
|
8
|
+
"configSchema": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"additionalProperties": true,
|
|
11
|
+
"properties": {
|
|
12
|
+
"pluginId": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"description": "自定义插件 ID"
|
|
15
|
+
},
|
|
16
|
+
"pluginName": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "自定义插件名称"
|
|
19
|
+
},
|
|
20
|
+
"platformBaseUrl": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "拉取 skill 标准配置的服务地址;缺省时使用内置静态桩"
|
|
23
|
+
},
|
|
24
|
+
"reportBaseUrl": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "批量上报的服务地址;缺省时只落本地、不上报"
|
|
27
|
+
},
|
|
28
|
+
"authToken": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "拉取/上报接口的 Authorization 头值(如 'Bearer xxx')"
|
|
31
|
+
},
|
|
32
|
+
"recordUnattributed": {
|
|
33
|
+
"type": "boolean",
|
|
34
|
+
"description": "是否记录无法归属到具体功能点的 exec 调用(默认 true)"
|
|
35
|
+
},
|
|
36
|
+
"enableFileLog": {
|
|
37
|
+
"type": "boolean",
|
|
38
|
+
"description": "是否开启插件专属文件日志(落盘为 skill-logger.err),用于排查 WS 连接和指令运行情况"
|
|
39
|
+
},
|
|
40
|
+
"autoUpdateSkills": {
|
|
41
|
+
"type": "boolean",
|
|
42
|
+
"description": "是否开启 skill 版本自动更新(检测到本地落后于平台最新版本时下载并覆盖各 workspace 下的副本);默认开启,仅显式设为 false 才关闭"
|
|
43
|
+
},
|
|
44
|
+
"versionCheckIntervalMinutes": {
|
|
45
|
+
"type": "number",
|
|
46
|
+
"description": "版本检查+自动更新的周期(分钟)。每隔该周期按本地所有已装 skill 向服务端拉最新版本并按需更新;默认 30。本地扫描发现固定 3 分钟,与此独立"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spzhongwin/skill-logger-plugin",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": "./dist/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --experimental-strip-types --test src/*.test.ts",
|
|
8
|
+
"typecheck": "tsc --noEmit",
|
|
9
|
+
"build": "npx esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:ws"
|
|
10
|
+
},
|
|
11
|
+
"openclaw": {
|
|
12
|
+
"extensions": [
|
|
13
|
+
"./dist/index.js"
|
|
14
|
+
],
|
|
15
|
+
"contracts": {
|
|
16
|
+
"tools": ["report_skill_error"]
|
|
17
|
+
},
|
|
18
|
+
"compat": {
|
|
19
|
+
"pluginApi": ">=2026.3.28",
|
|
20
|
+
"minGatewayVersion": "2026.3.28"
|
|
21
|
+
},
|
|
22
|
+
"build": {
|
|
23
|
+
"openclawVersion": "2026.3.28",
|
|
24
|
+
"pluginSdkVersion": "2026.3.28"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.19.20",
|
|
29
|
+
"@types/ws": "^8.18.1",
|
|
30
|
+
"typescript": "^5.9.3"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"ws": "^8.21.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +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
|
+
});
|
|
@@ -0,0 +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
|
+
}
|