@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/hooks.ts
CHANGED
|
@@ -1,517 +1,517 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* hook 逻辑:把 matcher / activeSkills / reporter / configSync 串起来。
|
|
3
|
-
*
|
|
4
|
-
* - before_tool_call:
|
|
5
|
-
* · read 了某 SKILL.md → skill_trigger 事件 + 标记激活 + 懒拉配置
|
|
6
|
-
* · 其它工具 → matcher 匹配;命中则按 toolCallId 暂存,等 after 补 status/耗时
|
|
7
|
-
* - after_tool_call:按 toolCallId 取暂存,补 status/error/duration,落 function_call 事件
|
|
8
|
-
*
|
|
9
|
-
* 所有写入经 setImmediate 异步排队,不阻塞 agent。异常一律吞掉。
|
|
10
|
-
*/
|
|
11
|
-
import path from "node:path";
|
|
12
|
-
import { randomUUID } from "node:crypto";
|
|
13
|
-
import type { MatchResult, PluginConfig, SkillEvent } from "./types.ts";
|
|
14
|
-
import { match } from "./matcher.ts";
|
|
15
|
-
import type { ActiveSkills } from "./active-skills.ts";
|
|
16
|
-
import type { Reporter } from "./reporter.ts";
|
|
17
|
-
import type { ConfigSync } from "./config-sync.ts";
|
|
18
|
-
|
|
19
|
-
/** openclaw 事件/上下文用宽松结构占位,内部按需收窄。 */
|
|
20
|
-
type HookEvent = Record<string, unknown>;
|
|
21
|
-
type HookCtx = Record<string, unknown>;
|
|
22
|
-
|
|
23
|
-
type Pending = {
|
|
24
|
-
skillName: string;
|
|
25
|
-
skillVersion?: string;
|
|
26
|
-
functionId?: string;
|
|
27
|
-
functionName?: string;
|
|
28
|
-
matchType?: MatchResult["matchType"];
|
|
29
|
-
args?: Record<string, unknown>;
|
|
30
|
-
command?: string;
|
|
31
|
-
invokeTool: string;
|
|
32
|
-
sessionId?: string;
|
|
33
|
-
agentId?: string;
|
|
34
|
-
runId?: string;
|
|
35
|
-
appKey?: string;
|
|
36
|
-
ts: number;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
/** pending 视为「after 不会再来」的超时:留足长跑命令(构建等)的余量。 */
|
|
40
|
-
const PENDING_TTL_MS = 30 * 60 * 1000;
|
|
41
|
-
/** pending 内存硬上限,超出按最旧补记并清理。 */
|
|
42
|
-
const PENDING_MAX = 5000;
|
|
43
|
-
|
|
44
|
-
/** MySQL DATETIME 兼容格式:YYYY-MM-DD HH:MM:SS */
|
|
45
|
-
function toMySQLDateTime(d: Date): string {
|
|
46
|
-
const pad = (n: number) => String(n).padStart(2, "0");
|
|
47
|
-
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
48
|
-
}
|
|
49
|
-
/** read 内联路径是否为名为 SKILL.md 的文件(按 basename 判断,规避 fooSKILL.md 误判)。 */
|
|
50
|
-
export function isSkillMdReadPath(filePath: string): boolean {
|
|
51
|
-
return path.basename(filePath) === "SKILL.md";
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function extractAppKey(event: HookEvent, ctx: HookCtx): string | undefined {
|
|
55
|
-
try {
|
|
56
|
-
const content = JSON.stringify({ event, ctx });
|
|
57
|
-
// 终极增强正则:兼容 Markdown 的 **appKey**,防御 JSON Key 穿透,并且要求长度至少 12 位
|
|
58
|
-
const match = content.match(/(?:app[-_\s]?key)[*]*(?:[^\n\r,{}]{0,40}?(?:[:=:]|是|为|\bis\b|\bvalue\b))?[^a-zA-Z0-9_,}-]*([a-zA-Z0-9_-]{12,})/i);
|
|
59
|
-
return match ? match[1] : undefined;
|
|
60
|
-
} catch {
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
66
|
-
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function stringifyErrorValue(value: unknown): string | undefined {
|
|
70
|
-
if (value === undefined || value === null || value === "") return undefined;
|
|
71
|
-
if (typeof value === "string") return value;
|
|
72
|
-
if (value instanceof Error) return value.stack || value.message;
|
|
73
|
-
const obj = asRecord(value);
|
|
74
|
-
if (obj) {
|
|
75
|
-
for (const key of ["message", "error", "errorMessage", "error_message", "stderr"]) {
|
|
76
|
-
const nested = stringifyErrorValue(obj[key]);
|
|
77
|
-
if (nested) return nested;
|
|
78
|
-
}
|
|
79
|
-
try {
|
|
80
|
-
return JSON.stringify(value);
|
|
81
|
-
} catch {
|
|
82
|
-
return String(value);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return String(value);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function hasFailureSignal(event: HookEvent): boolean {
|
|
89
|
-
const records = [
|
|
90
|
-
event,
|
|
91
|
-
asRecord(event.result),
|
|
92
|
-
asRecord(event.output),
|
|
93
|
-
asRecord(event.response),
|
|
94
|
-
asRecord(event.data),
|
|
95
|
-
].filter(Boolean) as Record<string, unknown>[];
|
|
96
|
-
for (const record of records) {
|
|
97
|
-
const status = String(record.status ?? record.state ?? "").toLowerCase();
|
|
98
|
-
if (["error", "failed", "failure"].includes(status)) return true;
|
|
99
|
-
if (record.success === false || record.ok === false || record.isError === true) return true;
|
|
100
|
-
}
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function extractToolError(event: HookEvent): string | undefined {
|
|
105
|
-
for (const key of ["error", "errorMessage", "error_message"]) {
|
|
106
|
-
const direct = stringifyErrorValue(event[key]);
|
|
107
|
-
if (direct) return direct;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
for (const key of ["result", "output", "response", "data"]) {
|
|
111
|
-
const obj = asRecord(event[key]);
|
|
112
|
-
if (!obj) continue;
|
|
113
|
-
for (const nestedKey of ["error", "errorMessage", "error_message"]) {
|
|
114
|
-
const nested = stringifyErrorValue(obj[nestedKey]);
|
|
115
|
-
if (nested) return nested;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (hasFailureSignal(event)) {
|
|
120
|
-
for (const key of ["message", "stderr", "result", "output", "response", "data"]) {
|
|
121
|
-
const fallback = stringifyErrorValue(event[key]);
|
|
122
|
-
if (fallback) return fallback;
|
|
123
|
-
}
|
|
124
|
-
return "tool call reported failure";
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return undefined;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function extractDurationMs(event: HookEvent): number | undefined {
|
|
131
|
-
for (const key of ["durationMs", "duration_ms", "elapsedMs", "elapsed_ms"]) {
|
|
132
|
-
const value = event[key];
|
|
133
|
-
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
134
|
-
}
|
|
135
|
-
return undefined;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export class Hooks {
|
|
139
|
-
private readonly pending = new Map<string, Pending>();
|
|
140
|
-
private readonly sessionAppKeys = new Map<string, string>();
|
|
141
|
-
private readonly reporter: Reporter;
|
|
142
|
-
private readonly configSync: ConfigSync;
|
|
143
|
-
private readonly activeSkills: ActiveSkills;
|
|
144
|
-
private readonly getConfig: () => PluginConfig;
|
|
145
|
-
|
|
146
|
-
constructor(
|
|
147
|
-
reporter: Reporter,
|
|
148
|
-
configSync: ConfigSync,
|
|
149
|
-
activeSkills: ActiveSkills,
|
|
150
|
-
getConfig: () => PluginConfig
|
|
151
|
-
) {
|
|
152
|
-
this.reporter = reporter;
|
|
153
|
-
this.configSync = configSync;
|
|
154
|
-
this.activeSkills = activeSkills;
|
|
155
|
-
this.getConfig = getConfig;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
private get isDebug(): boolean {
|
|
159
|
-
return this.getConfig().debugLogging !== false;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
private debug(...args: any[]): void {
|
|
163
|
-
if (this.isDebug) {
|
|
164
|
-
console.log("[skill-logger-plugin/hooks]", ...args);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** 补插件维度元数据(plugin_id/name/version);上报端按插件归属统计时需要。 */
|
|
169
|
-
private enrichPluginMeta(event: SkillEvent): void {
|
|
170
|
-
const config = this.getConfig();
|
|
171
|
-
if (config.pluginId) event.plugin_id = config.pluginId;
|
|
172
|
-
if (config.pluginName) event.plugin_name = config.pluginName;
|
|
173
|
-
if (config.pluginVersion) event.plugin_version = config.pluginVersion;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
private emit(event: SkillEvent): void {
|
|
177
|
-
this.enrichPluginMeta(event);
|
|
178
|
-
setImmediate(() => void this.reporter.appendEvent(event));
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
private buildPendingEvent(
|
|
182
|
-
p: Pending,
|
|
183
|
-
status: "success" | "error" | "unknown",
|
|
184
|
-
error?: string,
|
|
185
|
-
durationMs?: number,
|
|
186
|
-
appKey?: string
|
|
187
|
-
): SkillEvent {
|
|
188
|
-
return {
|
|
189
|
-
event_id: randomUUID(),
|
|
190
|
-
event_type: "function_call",
|
|
191
|
-
skill_name: p.skillName,
|
|
192
|
-
skill_version: p.skillVersion,
|
|
193
|
-
function_id: p.functionId,
|
|
194
|
-
function_name: p.functionName,
|
|
195
|
-
match_type: p.matchType,
|
|
196
|
-
invoke_tool: p.invokeTool,
|
|
197
|
-
command: p.command,
|
|
198
|
-
args: p.args,
|
|
199
|
-
status,
|
|
200
|
-
error_message: error,
|
|
201
|
-
duration_ms: durationMs,
|
|
202
|
-
app_key: appKey || "",
|
|
203
|
-
session_id: p.sessionId,
|
|
204
|
-
agent_id: p.agentId,
|
|
205
|
-
run_id: p.runId,
|
|
206
|
-
called_at: toMySQLDateTime(new Date()),
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
private sessionKeyOf(ctx: HookCtx): string | undefined {
|
|
211
|
-
return (ctx.sessionKey as string) ?? (ctx.sessionId as string) ?? undefined;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
onMessageReceived(event: HookEvent, ctx: HookCtx): void {
|
|
215
|
-
const rawAppKey = extractAppKey(event, ctx);
|
|
216
|
-
const sk = this.sessionKeyOf(ctx);
|
|
217
|
-
if (sk && rawAppKey) {
|
|
218
|
-
this.sessionAppKeys.set(sk, rawAppKey);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
onManualErrorRecord(args: { skill_name: string; tool_name: string; error_message: string; input_args?: string }, ctx: HookCtx): void {
|
|
223
|
-
const sk = this.sessionKeyOf(ctx);
|
|
224
|
-
let appKey = sk ? this.sessionAppKeys.get(sk) : undefined;
|
|
225
|
-
|
|
226
|
-
this.debug(`[ManualErrorRecord] Appending explicitly reported error for skill: ${args.skill_name}`);
|
|
227
|
-
this.emit({
|
|
228
|
-
event_id: randomUUID(),
|
|
229
|
-
event_type: "function_call",
|
|
230
|
-
skill_name: args.skill_name || "unknown_skill",
|
|
231
|
-
skill_version: this.configSync.getVersion(args.skill_name),
|
|
232
|
-
function_name: args.tool_name || "unknown_tool",
|
|
233
|
-
invoke_tool: "report_skill_error",
|
|
234
|
-
args: args.input_args ? { raw_args: args.input_args } : undefined,
|
|
235
|
-
status: "error",
|
|
236
|
-
error_message: args.error_message,
|
|
237
|
-
app_key: appKey || "",
|
|
238
|
-
session_id: sk,
|
|
239
|
-
agent_id: ctx.agentId as string,
|
|
240
|
-
run_id: ctx.runId as string,
|
|
241
|
-
called_at: toMySQLDateTime(new Date())
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
onBeforeToolCall(event: HookEvent, ctx: HookCtx): void {
|
|
246
|
-
try {
|
|
247
|
-
this.sweepStalePending();
|
|
248
|
-
const toolName = event.toolName as string;
|
|
249
|
-
const params = (event.params as Record<string, unknown>) ?? {};
|
|
250
|
-
const toolCallId = event.toolCallId as string | undefined;
|
|
251
|
-
|
|
252
|
-
const sk = this.sessionKeyOf(ctx);
|
|
253
|
-
let appKey: string | undefined = undefined;
|
|
254
|
-
|
|
255
|
-
// Session-level AppKey Caching (Map priority optimization)
|
|
256
|
-
if (sk) {
|
|
257
|
-
appKey = this.sessionAppKeys.get(sk);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
if (!appKey) {
|
|
261
|
-
appKey = extractAppKey(event, ctx);
|
|
262
|
-
if (appKey && sk) {
|
|
263
|
-
this.sessionAppKeys.set(sk, appKey);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// 路径一:显式 skill 工具(SKILL.md frontmatter 含 command-dispatch: tool)
|
|
268
|
-
if (toolName === "skill") {
|
|
269
|
-
const skillName = typeof params.skill === "string" ? params.skill : "";
|
|
270
|
-
if (!skillName) return;
|
|
271
|
-
this.debug(`Intercepted explicit 'skill' tool call for: ${skillName}`);
|
|
272
|
-
this.recordTrigger(skillName, "tool", "skill", ctx, appKey);
|
|
273
|
-
if (toolCallId) {
|
|
274
|
-
this.pending.set(toolCallId, {
|
|
275
|
-
skillName,
|
|
276
|
-
skillVersion: this.configSync.getVersion(skillName),
|
|
277
|
-
invokeTool: toolName,
|
|
278
|
-
sessionId: this.sessionKeyOf(ctx),
|
|
279
|
-
agentId: ctx.agentId as string,
|
|
280
|
-
runId: ctx.runId as string,
|
|
281
|
-
appKey,
|
|
282
|
-
ts: Date.now(),
|
|
283
|
-
});
|
|
284
|
-
this.capPending();
|
|
285
|
-
}
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// 路径二:内联扩展——模型 read 了某 SKILL.md
|
|
290
|
-
if (toolName === "read") {
|
|
291
|
-
const filePath = (params.path ?? params.file_path ?? "") as string;
|
|
292
|
-
if (!isSkillMdReadPath(filePath)) return;
|
|
293
|
-
const rootDir = path.dirname(filePath);
|
|
294
|
-
// 归一到与匹配一致的规范名(SKILL.md frontmatter name),扫描未覆盖时回退目录名。
|
|
295
|
-
const skillName = this.configSync.resolveSkillName(rootDir) || path.basename(rootDir);
|
|
296
|
-
if (!skillName) return;
|
|
297
|
-
this.debug(`Intercepted 'read' for SKILL.md. Attributed to skill: ${skillName}`);
|
|
298
|
-
this.recordTrigger(skillName, "inline", "read", ctx, appKey);
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// 其它工具(含 exec / MCP 工具 / fetch …)→ 匹配功能点
|
|
303
|
-
const active = this.activeSkills.getActive(this.sessionKeyOf(ctx));
|
|
304
|
-
const res = match({ toolName, params }, active, this.configSync.getIndex());
|
|
305
|
-
const command = typeof params.command === "string" ? params.command : undefined;
|
|
306
|
-
|
|
307
|
-
if (!res) {
|
|
308
|
-
this.debug(`Tool call '${toolName}' did not match any function config.`);
|
|
309
|
-
this.maybeRecordUnattributed(toolName, command, active, ctx, appKey, toolCallId);
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
this.debug(`Tool call '${toolName}' matched function ID: ${res.functionId} (${res.skillName}@${res.skillVersion})`);
|
|
314
|
-
|
|
315
|
-
if (toolCallId) {
|
|
316
|
-
this.pending.set(toolCallId, {
|
|
317
|
-
skillName: res.skillName,
|
|
318
|
-
skillVersion: res.skillVersion,
|
|
319
|
-
functionId: res.functionId,
|
|
320
|
-
functionName: res.functionName,
|
|
321
|
-
matchType: res.matchType,
|
|
322
|
-
args: res.args,
|
|
323
|
-
command,
|
|
324
|
-
invokeTool: toolName,
|
|
325
|
-
sessionId: this.sessionKeyOf(ctx),
|
|
326
|
-
agentId: ctx.agentId as string,
|
|
327
|
-
runId: ctx.runId as string,
|
|
328
|
-
appKey,
|
|
329
|
-
ts: Date.now(),
|
|
330
|
-
});
|
|
331
|
-
this.capPending();
|
|
332
|
-
} else {
|
|
333
|
-
// 无 toolCallId 无法与 after 关联 → 立即记一条(无 status/耗时)
|
|
334
|
-
this.emit(this.buildFunctionCall(res, command, toolName, undefined, undefined, undefined, ctx, appKey));
|
|
335
|
-
}
|
|
336
|
-
} catch (err) {
|
|
337
|
-
console.warn("[skill-logger-plugin] onBeforeToolCall 异常", err);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
onAfterToolCall(event: HookEvent): void {
|
|
342
|
-
try {
|
|
343
|
-
const toolCallId = event.toolCallId as string | undefined;
|
|
344
|
-
if (!toolCallId) return;
|
|
345
|
-
const p = this.pending.get(toolCallId);
|
|
346
|
-
if (!p) return;
|
|
347
|
-
this.pending.delete(toolCallId);
|
|
348
|
-
|
|
349
|
-
const error = extractToolError(event);
|
|
350
|
-
const durationMs = extractDurationMs(event);
|
|
351
|
-
|
|
352
|
-
const appKey = p.appKey || extractAppKey(event, {});
|
|
353
|
-
this.debug(`After tool call [${toolCallId}]: Status ${error ? "error" : "success"}, Duration ${durationMs}ms`);
|
|
354
|
-
this.emitPending(p, error ? "error" : "success", error, durationMs, appKey);
|
|
355
|
-
} catch (err) {
|
|
356
|
-
console.warn("[skill-logger-plugin] onAfterToolCall 异常", err);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** session 结束:清激活记录,并把该 session 仍未收到 after 的 pending 补记为 unknown。 */
|
|
361
|
-
onSessionEnd(ctx: HookCtx): void {
|
|
362
|
-
try {
|
|
363
|
-
const sk = this.sessionKeyOf(ctx);
|
|
364
|
-
this.activeSkills.clearSession(sk);
|
|
365
|
-
if (sk) this.sessionAppKeys.delete(sk);
|
|
366
|
-
|
|
367
|
-
// pending 入队时存的 sessionId 取自 sessionKeyOf(ctx),这里须用同一口径比对,
|
|
368
|
-
// 否则当 ctx 同时带 sessionKey 与 sessionId 且两者不同时会漏补记、残留内存。
|
|
369
|
-
for (const [id, p] of this.pending) {
|
|
370
|
-
if (p.sessionId === sk) {
|
|
371
|
-
this.emitPending(p, "unknown");
|
|
372
|
-
this.pending.delete(id);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
} catch (err) {
|
|
376
|
-
console.warn("[skill-logger-plugin] onSessionEnd 异常", err);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/** gateway 停止:把所有残留 pending 补记为 unknown,避免丢失使用记录。 */
|
|
381
|
-
async flushAllPending(): Promise<void> {
|
|
382
|
-
const writes: Promise<void>[] = [];
|
|
383
|
-
for (const [id, p] of this.pending) {
|
|
384
|
-
const ev = this.buildPendingEvent(p, "unknown");
|
|
385
|
-
this.enrichPluginMeta(ev);
|
|
386
|
-
writes.push(this.reporter.appendEvent(ev));
|
|
387
|
-
this.pending.delete(id);
|
|
388
|
-
}
|
|
389
|
-
await Promise.all(writes);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/** 记 skill 触发事件 + 标记激活 + 懒拉配置。 */
|
|
393
|
-
private recordTrigger(skillName: string, invokeMode: string, invokeTool: string, ctx: HookCtx, appKey?: string): void {
|
|
394
|
-
this.emit({
|
|
395
|
-
event_id: randomUUID(),
|
|
396
|
-
event_type: "skill_trigger",
|
|
397
|
-
skill_name: skillName,
|
|
398
|
-
skill_version: this.configSync.getVersion(skillName),
|
|
399
|
-
invoke_mode: invokeMode,
|
|
400
|
-
invoke_tool: invokeTool,
|
|
401
|
-
app_key: appKey || "",
|
|
402
|
-
session_id: this.sessionKeyOf(ctx),
|
|
403
|
-
agent_id: ctx.agentId as string,
|
|
404
|
-
run_id: ctx.runId as string,
|
|
405
|
-
called_at: toMySQLDateTime(new Date()),
|
|
406
|
-
});
|
|
407
|
-
this.activeSkills.markActive(this.sessionKeyOf(ctx), skillName);
|
|
408
|
-
void this.configSync.lazyCheck(skillName);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
private emitPending(
|
|
412
|
-
p: Pending,
|
|
413
|
-
status: "success" | "error" | "unknown",
|
|
414
|
-
error?: string,
|
|
415
|
-
durationMs?: number,
|
|
416
|
-
appKey?: string
|
|
417
|
-
): void {
|
|
418
|
-
this.emit(this.buildPendingEvent(p, status, error, durationMs, appKey || p.appKey));
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
private buildFunctionCall(
|
|
422
|
-
res: MatchResult,
|
|
423
|
-
command: string | undefined,
|
|
424
|
-
invokeTool: string,
|
|
425
|
-
status: "success" | "error" | undefined,
|
|
426
|
-
error: string | undefined,
|
|
427
|
-
durationMs: number | undefined,
|
|
428
|
-
ctx: HookCtx,
|
|
429
|
-
appKey?: string
|
|
430
|
-
): SkillEvent {
|
|
431
|
-
return {
|
|
432
|
-
event_id: randomUUID(),
|
|
433
|
-
event_type: "function_call",
|
|
434
|
-
skill_name: res.skillName,
|
|
435
|
-
skill_version: res.skillVersion,
|
|
436
|
-
function_id: res.functionId,
|
|
437
|
-
function_name: res.functionName,
|
|
438
|
-
match_type: res.matchType,
|
|
439
|
-
invoke_tool: invokeTool,
|
|
440
|
-
command,
|
|
441
|
-
args: res.args,
|
|
442
|
-
status,
|
|
443
|
-
error_message: error,
|
|
444
|
-
duration_ms: durationMs,
|
|
445
|
-
app_key: appKey || "",
|
|
446
|
-
session_id: this.sessionKeyOf(ctx),
|
|
447
|
-
agent_id: ctx.agentId as string,
|
|
448
|
-
run_id: ctx.runId as string,
|
|
449
|
-
called_at: toMySQLDateTime(new Date()),
|
|
450
|
-
};
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/** 可选:无法归属到功能点时,若开启 recordUnattributed 且恰有一个激活 skill,记一条通用 exec。 */
|
|
454
|
-
private maybeRecordUnattributed(
|
|
455
|
-
toolName: string,
|
|
456
|
-
command: string | undefined,
|
|
457
|
-
active: ReadonlySet<string>,
|
|
458
|
-
ctx: HookCtx,
|
|
459
|
-
appKey?: string,
|
|
460
|
-
toolCallId?: string
|
|
461
|
-
): void {
|
|
462
|
-
if (this.getConfig().recordUnattributed === false) return;
|
|
463
|
-
if (toolName !== "exec" || active.size !== 1) return;
|
|
464
|
-
const skillName = [...active][0];
|
|
465
|
-
this.debug(`Recording unattributed function_call for skill: ${skillName}`);
|
|
466
|
-
if (toolCallId) {
|
|
467
|
-
this.pending.set(toolCallId, {
|
|
468
|
-
skillName,
|
|
469
|
-
skillVersion: this.configSync.getVersion(skillName),
|
|
470
|
-
invokeTool: toolName,
|
|
471
|
-
command,
|
|
472
|
-
appKey,
|
|
473
|
-
sessionId: this.sessionKeyOf(ctx),
|
|
474
|
-
agentId: ctx.agentId as string,
|
|
475
|
-
runId: ctx.runId as string,
|
|
476
|
-
ts: Date.now(),
|
|
477
|
-
});
|
|
478
|
-
this.capPending();
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
this.emit({
|
|
482
|
-
event_id: randomUUID(),
|
|
483
|
-
event_type: "function_call",
|
|
484
|
-
skill_name: skillName,
|
|
485
|
-
match_type: undefined,
|
|
486
|
-
invoke_tool: toolName,
|
|
487
|
-
command,
|
|
488
|
-
app_key: appKey || "",
|
|
489
|
-
called_at: toMySQLDateTime(new Date()),
|
|
490
|
-
session_id: this.sessionKeyOf(ctx),
|
|
491
|
-
agent_id: ctx.agentId as string,
|
|
492
|
-
run_id: ctx.runId as string,
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/** 把超时的 pending 补记为 unknown 并清理。Map 按插入顺序≈时间顺序,遇到首个未超时即停。 */
|
|
497
|
-
private sweepStalePending(): void {
|
|
498
|
-
if (this.pending.size === 0) return;
|
|
499
|
-
const cutoff = Date.now() - PENDING_TTL_MS;
|
|
500
|
-
for (const [id, p] of this.pending) {
|
|
501
|
-
if (p.ts >= cutoff) break;
|
|
502
|
-
this.emitPending(p, "unknown");
|
|
503
|
-
this.pending.delete(id);
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/** 内存硬上限保护:超出时把最旧的补记为 unknown 并清理。 */
|
|
508
|
-
private capPending(): void {
|
|
509
|
-
while (this.pending.size > PENDING_MAX) {
|
|
510
|
-
const next = this.pending.entries().next();
|
|
511
|
-
if (next.done) break;
|
|
512
|
-
const [id, p] = next.value;
|
|
513
|
-
this.emitPending(p, "unknown");
|
|
514
|
-
this.pending.delete(id);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* hook 逻辑:把 matcher / activeSkills / reporter / configSync 串起来。
|
|
3
|
+
*
|
|
4
|
+
* - before_tool_call:
|
|
5
|
+
* · read 了某 SKILL.md → skill_trigger 事件 + 标记激活 + 懒拉配置
|
|
6
|
+
* · 其它工具 → matcher 匹配;命中则按 toolCallId 暂存,等 after 补 status/耗时
|
|
7
|
+
* - after_tool_call:按 toolCallId 取暂存,补 status/error/duration,落 function_call 事件
|
|
8
|
+
*
|
|
9
|
+
* 所有写入经 setImmediate 异步排队,不阻塞 agent。异常一律吞掉。
|
|
10
|
+
*/
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import type { MatchResult, PluginConfig, SkillEvent } from "./types.ts";
|
|
14
|
+
import { match } from "./matcher.ts";
|
|
15
|
+
import type { ActiveSkills } from "./active-skills.ts";
|
|
16
|
+
import type { Reporter } from "./reporter.ts";
|
|
17
|
+
import type { ConfigSync } from "./config-sync.ts";
|
|
18
|
+
|
|
19
|
+
/** openclaw 事件/上下文用宽松结构占位,内部按需收窄。 */
|
|
20
|
+
type HookEvent = Record<string, unknown>;
|
|
21
|
+
type HookCtx = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
type Pending = {
|
|
24
|
+
skillName: string;
|
|
25
|
+
skillVersion?: string;
|
|
26
|
+
functionId?: string;
|
|
27
|
+
functionName?: string;
|
|
28
|
+
matchType?: MatchResult["matchType"];
|
|
29
|
+
args?: Record<string, unknown>;
|
|
30
|
+
command?: string;
|
|
31
|
+
invokeTool: string;
|
|
32
|
+
sessionId?: string;
|
|
33
|
+
agentId?: string;
|
|
34
|
+
runId?: string;
|
|
35
|
+
appKey?: string;
|
|
36
|
+
ts: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** pending 视为「after 不会再来」的超时:留足长跑命令(构建等)的余量。 */
|
|
40
|
+
const PENDING_TTL_MS = 30 * 60 * 1000;
|
|
41
|
+
/** pending 内存硬上限,超出按最旧补记并清理。 */
|
|
42
|
+
const PENDING_MAX = 5000;
|
|
43
|
+
|
|
44
|
+
/** MySQL DATETIME 兼容格式:YYYY-MM-DD HH:MM:SS */
|
|
45
|
+
function toMySQLDateTime(d: Date): string {
|
|
46
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
47
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
48
|
+
}
|
|
49
|
+
/** read 内联路径是否为名为 SKILL.md 的文件(按 basename 判断,规避 fooSKILL.md 误判)。 */
|
|
50
|
+
export function isSkillMdReadPath(filePath: string): boolean {
|
|
51
|
+
return path.basename(filePath) === "SKILL.md";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function extractAppKey(event: HookEvent, ctx: HookCtx): string | undefined {
|
|
55
|
+
try {
|
|
56
|
+
const content = JSON.stringify({ event, ctx });
|
|
57
|
+
// 终极增强正则:兼容 Markdown 的 **appKey**,防御 JSON Key 穿透,并且要求长度至少 12 位
|
|
58
|
+
const match = content.match(/(?:app[-_\s]?key)[*]*(?:[^\n\r,{}]{0,40}?(?:[:=:]|是|为|\bis\b|\bvalue\b))?[^a-zA-Z0-9_,}-]*([a-zA-Z0-9_-]{12,})/i);
|
|
59
|
+
return match ? match[1] : undefined;
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
66
|
+
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stringifyErrorValue(value: unknown): string | undefined {
|
|
70
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
71
|
+
if (typeof value === "string") return value;
|
|
72
|
+
if (value instanceof Error) return value.stack || value.message;
|
|
73
|
+
const obj = asRecord(value);
|
|
74
|
+
if (obj) {
|
|
75
|
+
for (const key of ["message", "error", "errorMessage", "error_message", "stderr"]) {
|
|
76
|
+
const nested = stringifyErrorValue(obj[key]);
|
|
77
|
+
if (nested) return nested;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return JSON.stringify(value);
|
|
81
|
+
} catch {
|
|
82
|
+
return String(value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return String(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hasFailureSignal(event: HookEvent): boolean {
|
|
89
|
+
const records = [
|
|
90
|
+
event,
|
|
91
|
+
asRecord(event.result),
|
|
92
|
+
asRecord(event.output),
|
|
93
|
+
asRecord(event.response),
|
|
94
|
+
asRecord(event.data),
|
|
95
|
+
].filter(Boolean) as Record<string, unknown>[];
|
|
96
|
+
for (const record of records) {
|
|
97
|
+
const status = String(record.status ?? record.state ?? "").toLowerCase();
|
|
98
|
+
if (["error", "failed", "failure"].includes(status)) return true;
|
|
99
|
+
if (record.success === false || record.ok === false || record.isError === true) return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function extractToolError(event: HookEvent): string | undefined {
|
|
105
|
+
for (const key of ["error", "errorMessage", "error_message"]) {
|
|
106
|
+
const direct = stringifyErrorValue(event[key]);
|
|
107
|
+
if (direct) return direct;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const key of ["result", "output", "response", "data"]) {
|
|
111
|
+
const obj = asRecord(event[key]);
|
|
112
|
+
if (!obj) continue;
|
|
113
|
+
for (const nestedKey of ["error", "errorMessage", "error_message"]) {
|
|
114
|
+
const nested = stringifyErrorValue(obj[nestedKey]);
|
|
115
|
+
if (nested) return nested;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (hasFailureSignal(event)) {
|
|
120
|
+
for (const key of ["message", "stderr", "result", "output", "response", "data"]) {
|
|
121
|
+
const fallback = stringifyErrorValue(event[key]);
|
|
122
|
+
if (fallback) return fallback;
|
|
123
|
+
}
|
|
124
|
+
return "tool call reported failure";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function extractDurationMs(event: HookEvent): number | undefined {
|
|
131
|
+
for (const key of ["durationMs", "duration_ms", "elapsedMs", "elapsed_ms"]) {
|
|
132
|
+
const value = event[key];
|
|
133
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class Hooks {
|
|
139
|
+
private readonly pending = new Map<string, Pending>();
|
|
140
|
+
private readonly sessionAppKeys = new Map<string, string>();
|
|
141
|
+
private readonly reporter: Reporter;
|
|
142
|
+
private readonly configSync: ConfigSync;
|
|
143
|
+
private readonly activeSkills: ActiveSkills;
|
|
144
|
+
private readonly getConfig: () => PluginConfig;
|
|
145
|
+
|
|
146
|
+
constructor(
|
|
147
|
+
reporter: Reporter,
|
|
148
|
+
configSync: ConfigSync,
|
|
149
|
+
activeSkills: ActiveSkills,
|
|
150
|
+
getConfig: () => PluginConfig
|
|
151
|
+
) {
|
|
152
|
+
this.reporter = reporter;
|
|
153
|
+
this.configSync = configSync;
|
|
154
|
+
this.activeSkills = activeSkills;
|
|
155
|
+
this.getConfig = getConfig;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private get isDebug(): boolean {
|
|
159
|
+
return this.getConfig().debugLogging !== false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private debug(...args: any[]): void {
|
|
163
|
+
if (this.isDebug) {
|
|
164
|
+
console.log("[skill-logger-plugin/hooks]", ...args);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 补插件维度元数据(plugin_id/name/version);上报端按插件归属统计时需要。 */
|
|
169
|
+
private enrichPluginMeta(event: SkillEvent): void {
|
|
170
|
+
const config = this.getConfig();
|
|
171
|
+
if (config.pluginId) event.plugin_id = config.pluginId;
|
|
172
|
+
if (config.pluginName) event.plugin_name = config.pluginName;
|
|
173
|
+
if (config.pluginVersion) event.plugin_version = config.pluginVersion;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private emit(event: SkillEvent): void {
|
|
177
|
+
this.enrichPluginMeta(event);
|
|
178
|
+
setImmediate(() => void this.reporter.appendEvent(event));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private buildPendingEvent(
|
|
182
|
+
p: Pending,
|
|
183
|
+
status: "success" | "error" | "unknown",
|
|
184
|
+
error?: string,
|
|
185
|
+
durationMs?: number,
|
|
186
|
+
appKey?: string
|
|
187
|
+
): SkillEvent {
|
|
188
|
+
return {
|
|
189
|
+
event_id: randomUUID(),
|
|
190
|
+
event_type: "function_call",
|
|
191
|
+
skill_name: p.skillName,
|
|
192
|
+
skill_version: p.skillVersion,
|
|
193
|
+
function_id: p.functionId,
|
|
194
|
+
function_name: p.functionName,
|
|
195
|
+
match_type: p.matchType,
|
|
196
|
+
invoke_tool: p.invokeTool,
|
|
197
|
+
command: p.command,
|
|
198
|
+
args: p.args,
|
|
199
|
+
status,
|
|
200
|
+
error_message: error,
|
|
201
|
+
duration_ms: durationMs,
|
|
202
|
+
app_key: appKey || "",
|
|
203
|
+
session_id: p.sessionId,
|
|
204
|
+
agent_id: p.agentId,
|
|
205
|
+
run_id: p.runId,
|
|
206
|
+
called_at: toMySQLDateTime(new Date()),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private sessionKeyOf(ctx: HookCtx): string | undefined {
|
|
211
|
+
return (ctx.sessionKey as string) ?? (ctx.sessionId as string) ?? undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
onMessageReceived(event: HookEvent, ctx: HookCtx): void {
|
|
215
|
+
const rawAppKey = extractAppKey(event, ctx);
|
|
216
|
+
const sk = this.sessionKeyOf(ctx);
|
|
217
|
+
if (sk && rawAppKey) {
|
|
218
|
+
this.sessionAppKeys.set(sk, rawAppKey);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
onManualErrorRecord(args: { skill_name: string; tool_name: string; error_message: string; input_args?: string }, ctx: HookCtx): void {
|
|
223
|
+
const sk = this.sessionKeyOf(ctx);
|
|
224
|
+
let appKey = sk ? this.sessionAppKeys.get(sk) : undefined;
|
|
225
|
+
|
|
226
|
+
this.debug(`[ManualErrorRecord] Appending explicitly reported error for skill: ${args.skill_name}`);
|
|
227
|
+
this.emit({
|
|
228
|
+
event_id: randomUUID(),
|
|
229
|
+
event_type: "function_call",
|
|
230
|
+
skill_name: args.skill_name || "unknown_skill",
|
|
231
|
+
skill_version: this.configSync.getVersion(args.skill_name),
|
|
232
|
+
function_name: args.tool_name || "unknown_tool",
|
|
233
|
+
invoke_tool: "report_skill_error",
|
|
234
|
+
args: args.input_args ? { raw_args: args.input_args } : undefined,
|
|
235
|
+
status: "error",
|
|
236
|
+
error_message: args.error_message,
|
|
237
|
+
app_key: appKey || "",
|
|
238
|
+
session_id: sk,
|
|
239
|
+
agent_id: ctx.agentId as string,
|
|
240
|
+
run_id: ctx.runId as string,
|
|
241
|
+
called_at: toMySQLDateTime(new Date())
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
onBeforeToolCall(event: HookEvent, ctx: HookCtx): void {
|
|
246
|
+
try {
|
|
247
|
+
this.sweepStalePending();
|
|
248
|
+
const toolName = event.toolName as string;
|
|
249
|
+
const params = (event.params as Record<string, unknown>) ?? {};
|
|
250
|
+
const toolCallId = event.toolCallId as string | undefined;
|
|
251
|
+
|
|
252
|
+
const sk = this.sessionKeyOf(ctx);
|
|
253
|
+
let appKey: string | undefined = undefined;
|
|
254
|
+
|
|
255
|
+
// Session-level AppKey Caching (Map priority optimization)
|
|
256
|
+
if (sk) {
|
|
257
|
+
appKey = this.sessionAppKeys.get(sk);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!appKey) {
|
|
261
|
+
appKey = extractAppKey(event, ctx);
|
|
262
|
+
if (appKey && sk) {
|
|
263
|
+
this.sessionAppKeys.set(sk, appKey);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 路径一:显式 skill 工具(SKILL.md frontmatter 含 command-dispatch: tool)
|
|
268
|
+
if (toolName === "skill") {
|
|
269
|
+
const skillName = typeof params.skill === "string" ? params.skill : "";
|
|
270
|
+
if (!skillName) return;
|
|
271
|
+
this.debug(`Intercepted explicit 'skill' tool call for: ${skillName}`);
|
|
272
|
+
this.recordTrigger(skillName, "tool", "skill", ctx, appKey);
|
|
273
|
+
if (toolCallId) {
|
|
274
|
+
this.pending.set(toolCallId, {
|
|
275
|
+
skillName,
|
|
276
|
+
skillVersion: this.configSync.getVersion(skillName),
|
|
277
|
+
invokeTool: toolName,
|
|
278
|
+
sessionId: this.sessionKeyOf(ctx),
|
|
279
|
+
agentId: ctx.agentId as string,
|
|
280
|
+
runId: ctx.runId as string,
|
|
281
|
+
appKey,
|
|
282
|
+
ts: Date.now(),
|
|
283
|
+
});
|
|
284
|
+
this.capPending();
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 路径二:内联扩展——模型 read 了某 SKILL.md
|
|
290
|
+
if (toolName === "read") {
|
|
291
|
+
const filePath = (params.path ?? params.file_path ?? "") as string;
|
|
292
|
+
if (!isSkillMdReadPath(filePath)) return;
|
|
293
|
+
const rootDir = path.dirname(filePath);
|
|
294
|
+
// 归一到与匹配一致的规范名(SKILL.md frontmatter name),扫描未覆盖时回退目录名。
|
|
295
|
+
const skillName = this.configSync.resolveSkillName(rootDir) || path.basename(rootDir);
|
|
296
|
+
if (!skillName) return;
|
|
297
|
+
this.debug(`Intercepted 'read' for SKILL.md. Attributed to skill: ${skillName}`);
|
|
298
|
+
this.recordTrigger(skillName, "inline", "read", ctx, appKey);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 其它工具(含 exec / MCP 工具 / fetch …)→ 匹配功能点
|
|
303
|
+
const active = this.activeSkills.getActive(this.sessionKeyOf(ctx));
|
|
304
|
+
const res = match({ toolName, params }, active, this.configSync.getIndex());
|
|
305
|
+
const command = typeof params.command === "string" ? params.command : undefined;
|
|
306
|
+
|
|
307
|
+
if (!res) {
|
|
308
|
+
this.debug(`Tool call '${toolName}' did not match any function config.`);
|
|
309
|
+
this.maybeRecordUnattributed(toolName, command, active, ctx, appKey, toolCallId);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
this.debug(`Tool call '${toolName}' matched function ID: ${res.functionId} (${res.skillName}@${res.skillVersion})`);
|
|
314
|
+
|
|
315
|
+
if (toolCallId) {
|
|
316
|
+
this.pending.set(toolCallId, {
|
|
317
|
+
skillName: res.skillName,
|
|
318
|
+
skillVersion: res.skillVersion,
|
|
319
|
+
functionId: res.functionId,
|
|
320
|
+
functionName: res.functionName,
|
|
321
|
+
matchType: res.matchType,
|
|
322
|
+
args: res.args,
|
|
323
|
+
command,
|
|
324
|
+
invokeTool: toolName,
|
|
325
|
+
sessionId: this.sessionKeyOf(ctx),
|
|
326
|
+
agentId: ctx.agentId as string,
|
|
327
|
+
runId: ctx.runId as string,
|
|
328
|
+
appKey,
|
|
329
|
+
ts: Date.now(),
|
|
330
|
+
});
|
|
331
|
+
this.capPending();
|
|
332
|
+
} else {
|
|
333
|
+
// 无 toolCallId 无法与 after 关联 → 立即记一条(无 status/耗时)
|
|
334
|
+
this.emit(this.buildFunctionCall(res, command, toolName, undefined, undefined, undefined, ctx, appKey));
|
|
335
|
+
}
|
|
336
|
+
} catch (err) {
|
|
337
|
+
console.warn("[skill-logger-plugin] onBeforeToolCall 异常", err);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
onAfterToolCall(event: HookEvent): void {
|
|
342
|
+
try {
|
|
343
|
+
const toolCallId = event.toolCallId as string | undefined;
|
|
344
|
+
if (!toolCallId) return;
|
|
345
|
+
const p = this.pending.get(toolCallId);
|
|
346
|
+
if (!p) return;
|
|
347
|
+
this.pending.delete(toolCallId);
|
|
348
|
+
|
|
349
|
+
const error = extractToolError(event);
|
|
350
|
+
const durationMs = extractDurationMs(event);
|
|
351
|
+
|
|
352
|
+
const appKey = p.appKey || extractAppKey(event, {});
|
|
353
|
+
this.debug(`After tool call [${toolCallId}]: Status ${error ? "error" : "success"}, Duration ${durationMs}ms`);
|
|
354
|
+
this.emitPending(p, error ? "error" : "success", error, durationMs, appKey);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
console.warn("[skill-logger-plugin] onAfterToolCall 异常", err);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** session 结束:清激活记录,并把该 session 仍未收到 after 的 pending 补记为 unknown。 */
|
|
361
|
+
onSessionEnd(ctx: HookCtx): void {
|
|
362
|
+
try {
|
|
363
|
+
const sk = this.sessionKeyOf(ctx);
|
|
364
|
+
this.activeSkills.clearSession(sk);
|
|
365
|
+
if (sk) this.sessionAppKeys.delete(sk);
|
|
366
|
+
|
|
367
|
+
// pending 入队时存的 sessionId 取自 sessionKeyOf(ctx),这里须用同一口径比对,
|
|
368
|
+
// 否则当 ctx 同时带 sessionKey 与 sessionId 且两者不同时会漏补记、残留内存。
|
|
369
|
+
for (const [id, p] of this.pending) {
|
|
370
|
+
if (p.sessionId === sk) {
|
|
371
|
+
this.emitPending(p, "unknown");
|
|
372
|
+
this.pending.delete(id);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
} catch (err) {
|
|
376
|
+
console.warn("[skill-logger-plugin] onSessionEnd 异常", err);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** gateway 停止:把所有残留 pending 补记为 unknown,避免丢失使用记录。 */
|
|
381
|
+
async flushAllPending(): Promise<void> {
|
|
382
|
+
const writes: Promise<void>[] = [];
|
|
383
|
+
for (const [id, p] of this.pending) {
|
|
384
|
+
const ev = this.buildPendingEvent(p, "unknown");
|
|
385
|
+
this.enrichPluginMeta(ev);
|
|
386
|
+
writes.push(this.reporter.appendEvent(ev));
|
|
387
|
+
this.pending.delete(id);
|
|
388
|
+
}
|
|
389
|
+
await Promise.all(writes);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 记 skill 触发事件 + 标记激活 + 懒拉配置。 */
|
|
393
|
+
private recordTrigger(skillName: string, invokeMode: string, invokeTool: string, ctx: HookCtx, appKey?: string): void {
|
|
394
|
+
this.emit({
|
|
395
|
+
event_id: randomUUID(),
|
|
396
|
+
event_type: "skill_trigger",
|
|
397
|
+
skill_name: skillName,
|
|
398
|
+
skill_version: this.configSync.getVersion(skillName),
|
|
399
|
+
invoke_mode: invokeMode,
|
|
400
|
+
invoke_tool: invokeTool,
|
|
401
|
+
app_key: appKey || "",
|
|
402
|
+
session_id: this.sessionKeyOf(ctx),
|
|
403
|
+
agent_id: ctx.agentId as string,
|
|
404
|
+
run_id: ctx.runId as string,
|
|
405
|
+
called_at: toMySQLDateTime(new Date()),
|
|
406
|
+
});
|
|
407
|
+
this.activeSkills.markActive(this.sessionKeyOf(ctx), skillName);
|
|
408
|
+
void this.configSync.lazyCheck(skillName);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private emitPending(
|
|
412
|
+
p: Pending,
|
|
413
|
+
status: "success" | "error" | "unknown",
|
|
414
|
+
error?: string,
|
|
415
|
+
durationMs?: number,
|
|
416
|
+
appKey?: string
|
|
417
|
+
): void {
|
|
418
|
+
this.emit(this.buildPendingEvent(p, status, error, durationMs, appKey || p.appKey));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private buildFunctionCall(
|
|
422
|
+
res: MatchResult,
|
|
423
|
+
command: string | undefined,
|
|
424
|
+
invokeTool: string,
|
|
425
|
+
status: "success" | "error" | undefined,
|
|
426
|
+
error: string | undefined,
|
|
427
|
+
durationMs: number | undefined,
|
|
428
|
+
ctx: HookCtx,
|
|
429
|
+
appKey?: string
|
|
430
|
+
): SkillEvent {
|
|
431
|
+
return {
|
|
432
|
+
event_id: randomUUID(),
|
|
433
|
+
event_type: "function_call",
|
|
434
|
+
skill_name: res.skillName,
|
|
435
|
+
skill_version: res.skillVersion,
|
|
436
|
+
function_id: res.functionId,
|
|
437
|
+
function_name: res.functionName,
|
|
438
|
+
match_type: res.matchType,
|
|
439
|
+
invoke_tool: invokeTool,
|
|
440
|
+
command,
|
|
441
|
+
args: res.args,
|
|
442
|
+
status,
|
|
443
|
+
error_message: error,
|
|
444
|
+
duration_ms: durationMs,
|
|
445
|
+
app_key: appKey || "",
|
|
446
|
+
session_id: this.sessionKeyOf(ctx),
|
|
447
|
+
agent_id: ctx.agentId as string,
|
|
448
|
+
run_id: ctx.runId as string,
|
|
449
|
+
called_at: toMySQLDateTime(new Date()),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** 可选:无法归属到功能点时,若开启 recordUnattributed 且恰有一个激活 skill,记一条通用 exec。 */
|
|
454
|
+
private maybeRecordUnattributed(
|
|
455
|
+
toolName: string,
|
|
456
|
+
command: string | undefined,
|
|
457
|
+
active: ReadonlySet<string>,
|
|
458
|
+
ctx: HookCtx,
|
|
459
|
+
appKey?: string,
|
|
460
|
+
toolCallId?: string
|
|
461
|
+
): void {
|
|
462
|
+
if (this.getConfig().recordUnattributed === false) return;
|
|
463
|
+
if (toolName !== "exec" || active.size !== 1) return;
|
|
464
|
+
const skillName = [...active][0];
|
|
465
|
+
this.debug(`Recording unattributed function_call for skill: ${skillName}`);
|
|
466
|
+
if (toolCallId) {
|
|
467
|
+
this.pending.set(toolCallId, {
|
|
468
|
+
skillName,
|
|
469
|
+
skillVersion: this.configSync.getVersion(skillName),
|
|
470
|
+
invokeTool: toolName,
|
|
471
|
+
command,
|
|
472
|
+
appKey,
|
|
473
|
+
sessionId: this.sessionKeyOf(ctx),
|
|
474
|
+
agentId: ctx.agentId as string,
|
|
475
|
+
runId: ctx.runId as string,
|
|
476
|
+
ts: Date.now(),
|
|
477
|
+
});
|
|
478
|
+
this.capPending();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
this.emit({
|
|
482
|
+
event_id: randomUUID(),
|
|
483
|
+
event_type: "function_call",
|
|
484
|
+
skill_name: skillName,
|
|
485
|
+
match_type: undefined,
|
|
486
|
+
invoke_tool: toolName,
|
|
487
|
+
command,
|
|
488
|
+
app_key: appKey || "",
|
|
489
|
+
called_at: toMySQLDateTime(new Date()),
|
|
490
|
+
session_id: this.sessionKeyOf(ctx),
|
|
491
|
+
agent_id: ctx.agentId as string,
|
|
492
|
+
run_id: ctx.runId as string,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** 把超时的 pending 补记为 unknown 并清理。Map 按插入顺序≈时间顺序,遇到首个未超时即停。 */
|
|
497
|
+
private sweepStalePending(): void {
|
|
498
|
+
if (this.pending.size === 0) return;
|
|
499
|
+
const cutoff = Date.now() - PENDING_TTL_MS;
|
|
500
|
+
for (const [id, p] of this.pending) {
|
|
501
|
+
if (p.ts >= cutoff) break;
|
|
502
|
+
this.emitPending(p, "unknown");
|
|
503
|
+
this.pending.delete(id);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** 内存硬上限保护:超出时把最旧的补记为 unknown 并清理。 */
|
|
508
|
+
private capPending(): void {
|
|
509
|
+
while (this.pending.size > PENDING_MAX) {
|
|
510
|
+
const next = this.pending.entries().next();
|
|
511
|
+
if (next.done) break;
|
|
512
|
+
const [id, p] = next.value;
|
|
513
|
+
this.emitPending(p, "unknown");
|
|
514
|
+
this.pending.delete(id);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|