@spzhongwin/skill-logger-plugin 1.0.14 → 1.0.16
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/index.js +80 -285
- package/openclaw.plugin.json +50 -50
- package/package.json +34 -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/expert-skill-layout.test.ts +196 -0
- package/src/expert-skill-layout.ts +233 -0
- package/src/global.d.ts +4 -0
- package/src/hooks.test.ts +228 -251
- package/src/hooks.ts +494 -517
- package/src/http.ts +61 -61
- package/src/identity.ts +88 -64
- package/src/index.test.ts +53 -53
- package/src/index.ts +218 -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 +303 -298
- package/src/sample-config.json +72 -72
- package/src/semver.test.ts +33 -23
- package/src/semver.ts +65 -60
- package/src/skill-version.ts +53 -53
- package/src/types.ts +202 -198
- package/src/updater.test.ts +431 -325
- package/src/updater.ts +584 -549
- package/src/ws-client.test.ts +158 -128
- package/src/ws-client.ts +805 -769
- package/test-ws.ts +17 -17
- package/tsconfig.json +18 -14
- package/dist/active-skills.js +0 -67
- package/dist/active-skills.test.js +0 -29
- package/dist/config-sync.js +0 -439
- package/dist/config-sync.test.js +0 -145
- package/dist/hooks.js +0 -337
- package/dist/hooks.test.js +0 -123
- package/dist/http.js +0 -54
- package/dist/identity.js +0 -56
- package/dist/index.test.js +0 -39
- package/dist/integration.test.js +0 -102
- package/dist/matcher.js +0 -362
- package/dist/matcher.test.js +0 -139
- package/dist/paths.js +0 -62
- package/dist/paths.test.js +0 -49
- package/dist/reporter.js +0 -267
- package/dist/reporter.test.js +0 -128
- package/dist/semver.js +0 -64
- package/dist/semver.test.js +0 -21
- package/dist/skill-version.js +0 -23
- package/dist/types.js +0 -9
- package/dist/updater.js +0 -352
- package/dist/updater.test.js +0 -212
- package/dist/ws-client.js +0 -484
package/dist/matcher.test.js
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { describe, it } from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { buildIndex, match, parseFlags, parseKeyValues, pathEndsWith, commandHead, tokenize, } from "./matcher.ts";
|
|
4
|
-
const configs = [
|
|
5
|
-
{
|
|
6
|
-
skillName: "model-usage",
|
|
7
|
-
version: "1.0.0",
|
|
8
|
-
functions: [
|
|
9
|
-
{ id: "usage_current", name: "当前用量", match: { type: "script", script: "scripts/model_usage.py", argRules: [{ flag: "--mode", value: "current" }] } },
|
|
10
|
-
{ id: "usage_all", name: "全部用量", match: { type: "script", script: "scripts/model_usage.py", argRules: [{ flag: "--mode", value: "all" }] } },
|
|
11
|
-
],
|
|
12
|
-
},
|
|
13
|
-
{
|
|
14
|
-
skillName: "openai-whisper-api",
|
|
15
|
-
version: "1.0.0",
|
|
16
|
-
functions: [{ id: "transcribe", name: "转写", match: { type: "script", script: "scripts/transcribe.sh" } }],
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
skillName: "mcporter",
|
|
20
|
-
version: "1.0.0",
|
|
21
|
-
functions: [{ id: "list_issues", name: "列issue", match: { type: "command", command: "mcporter", targetPattern: "call linear.list_issues" } }],
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
skillName: "linear-mcp",
|
|
25
|
-
version: "1.0.0",
|
|
26
|
-
functions: [
|
|
27
|
-
{ id: "create_issue", name: "建issue", match: { type: "tool", toolName: "linear_create_issue" } },
|
|
28
|
-
{ id: "transcribe_http", name: "转写端点", match: { type: "http", urlContains: "/v1/audio/transcriptions" } },
|
|
29
|
-
],
|
|
30
|
-
},
|
|
31
|
-
];
|
|
32
|
-
const index = buildIndex(configs);
|
|
33
|
-
const noActive = new Set();
|
|
34
|
-
function exec(command) {
|
|
35
|
-
return { toolName: "exec", params: { command } };
|
|
36
|
-
}
|
|
37
|
-
describe("pathEndsWith / tokenize / commandHead", () => {
|
|
38
|
-
it("脚本路径按段对齐,规避 fooX.py 误判", () => {
|
|
39
|
-
assert.equal(pathEndsWith("/a/b/scripts/model_usage.py", "scripts/model_usage.py"), true);
|
|
40
|
-
assert.equal(pathEndsWith("scripts/model_usage.py", "scripts/model_usage.py"), true);
|
|
41
|
-
assert.equal(pathEndsWith("/a/xscripts/model_usage.py", "scripts/model_usage.py"), false);
|
|
42
|
-
assert.equal(pathEndsWith("/a/foomodel_usage.py", "model_usage.py"), false);
|
|
43
|
-
});
|
|
44
|
-
it("tokenize 处理引号", () => {
|
|
45
|
-
assert.deepEqual(tokenize(`a "b c" 'd e' f`), ["a", "b c", "d e", "f"]);
|
|
46
|
-
});
|
|
47
|
-
it("commandHead 跳过环境前缀与解释器", () => {
|
|
48
|
-
assert.equal(commandHead(tokenize("FOO=1 python /x/scripts/y.py --a")), "y.py");
|
|
49
|
-
assert.equal(commandHead(tokenize("mcporter call linear.list_issues")), "mcporter");
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
describe("parseFlags / parseKeyValues", () => {
|
|
53
|
-
it("--k v / --k=v / 布尔 flag", () => {
|
|
54
|
-
const f = parseFlags(tokenize("x --mode current --pretty --n=3 -v"));
|
|
55
|
-
assert.equal(f.mode, "current");
|
|
56
|
-
assert.equal(f.pretty, true);
|
|
57
|
-
assert.equal(f.n, "3");
|
|
58
|
-
assert.equal(f.v, true);
|
|
59
|
-
});
|
|
60
|
-
it("k=v / k:v,跳过 URL", () => {
|
|
61
|
-
const kv = parseKeyValues(tokenize("call linear.list_issues team=ENG limit:5 url=https://x/y"));
|
|
62
|
-
assert.equal(kv.team, "ENG");
|
|
63
|
-
assert.equal(kv.limit, "5");
|
|
64
|
-
assert.equal(kv.url, undefined); // 含 :// 被跳过
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
describe("match - script", () => {
|
|
68
|
-
it("按 --mode 细分功能点", () => {
|
|
69
|
-
const cur = match(exec("python /e/skills/model-usage/scripts/model_usage.py --mode current"), noActive, index);
|
|
70
|
-
assert.equal(cur?.functionId, "usage_current");
|
|
71
|
-
assert.equal(cur?.matchType, "script");
|
|
72
|
-
assert.equal(cur?.args.mode, "current");
|
|
73
|
-
const all = match(exec("python /e/skills/model-usage/scripts/model_usage.py --mode all --pretty"), noActive, index);
|
|
74
|
-
assert.equal(all?.functionId, "usage_all");
|
|
75
|
-
});
|
|
76
|
-
it("argRules 不满足则不命中该功能点", () => {
|
|
77
|
-
const r = match(exec("python /e/scripts/model_usage.py --mode none"), noActive, index);
|
|
78
|
-
assert.equal(r, null);
|
|
79
|
-
});
|
|
80
|
-
it("解释器无关:bash 跑 .sh 命中", () => {
|
|
81
|
-
const r = match(exec("bash /e/openai-whisper-api/scripts/transcribe.sh a.mp3"), noActive, index);
|
|
82
|
-
assert.equal(r?.functionId, "transcribe");
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
describe("match - command / tool / http", () => {
|
|
86
|
-
it("command: mcporter call", () => {
|
|
87
|
-
const r = match(exec("mcporter call linear.list_issues team=ENG"), noActive, index);
|
|
88
|
-
assert.equal(r?.functionId, "list_issues");
|
|
89
|
-
assert.equal(r?.matchType, "command");
|
|
90
|
-
assert.equal(r?.args.team, "ENG");
|
|
91
|
-
});
|
|
92
|
-
it("tool: 非 exec 工具直调", () => {
|
|
93
|
-
const r = match({ toolName: "linear_create_issue", params: { title: "bug" } }, noActive, index);
|
|
94
|
-
assert.equal(r?.functionId, "create_issue");
|
|
95
|
-
assert.equal(r?.matchType, "tool");
|
|
96
|
-
assert.equal(r?.args.title, "bug");
|
|
97
|
-
});
|
|
98
|
-
it("http: curl 命中端点", () => {
|
|
99
|
-
const r = match(exec("curl -s https://api.openai.com/v1/audio/transcriptions -F file=@a.mp3"), noActive, index);
|
|
100
|
-
assert.equal(r?.functionId, "transcribe_http");
|
|
101
|
-
assert.equal(r?.matchType, "http");
|
|
102
|
-
});
|
|
103
|
-
it("http: fetch 类工具 url 参数命中", () => {
|
|
104
|
-
const r = match({ toolName: "fetch", params: { url: "https://api.openai.com/v1/audio/transcriptions?x=1" } }, noActive, index);
|
|
105
|
-
assert.equal(r?.functionId, "transcribe_http");
|
|
106
|
-
assert.equal(r?.args.x, "1");
|
|
107
|
-
});
|
|
108
|
-
it("无匹配返回 null", () => {
|
|
109
|
-
assert.equal(match(exec("ls -la"), noActive, index), null);
|
|
110
|
-
assert.equal(match({ toolName: "unknown_tool", params: {} }, noActive, index), null);
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
describe("match - 弱匹配(cd 后相对路径)仅在 skill 激活时采纳", () => {
|
|
114
|
-
it("仅 basename 相同:未激活 → null,激活 → 命中", () => {
|
|
115
|
-
// `cd .../openai-whisper-api/scripts && bash transcribe.sh a.mp3`
|
|
116
|
-
const call = exec("bash transcribe.sh a.mp3");
|
|
117
|
-
assert.equal(match(call, noActive, index), null);
|
|
118
|
-
const r = match(call, new Set(["openai-whisper-api"]), index);
|
|
119
|
-
assert.equal(r?.functionId, "transcribe");
|
|
120
|
-
});
|
|
121
|
-
it("强匹配优先于弱匹配", () => {
|
|
122
|
-
// 同时出现强(全路径)与弱(裸名)token 时取强匹配
|
|
123
|
-
const r = match(exec("bash /e/openai-whisper-api/scripts/transcribe.sh"), noActive, index);
|
|
124
|
-
assert.equal(r?.functionId, "transcribe");
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
describe("match - 歧义消解优先 activeSkills", () => {
|
|
128
|
-
const ambConfigs = [
|
|
129
|
-
{ skillName: "A", version: "1", functions: [{ id: "a", name: "a", match: { type: "tool", toolNamePrefix: "x_" } }] },
|
|
130
|
-
{ skillName: "B", version: "1", functions: [{ id: "b", name: "b", match: { type: "tool", toolNamePrefix: "x_" } }] },
|
|
131
|
-
];
|
|
132
|
-
const ambIndex = buildIndex(ambConfigs);
|
|
133
|
-
it("无激活时取第一候选", () => {
|
|
134
|
-
assert.equal(match({ toolName: "x_do", params: {} }, noActive, ambIndex)?.skillName, "A");
|
|
135
|
-
});
|
|
136
|
-
it("激活 B 时归属 B", () => {
|
|
137
|
-
assert.equal(match({ toolName: "x_do", params: {} }, new Set(["B"]), ambIndex)?.skillName, "B");
|
|
138
|
-
});
|
|
139
|
-
});
|
package/dist/paths.js
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 集中管理插件用到的所有文件系统路径。
|
|
3
|
-
*
|
|
4
|
-
* 为什么集中:① 单测可注入临时目录;② openclaw 安全策略下插件不读环境变量,
|
|
5
|
-
* 路径在此固定,运行时不依赖外部配置。
|
|
6
|
-
*/
|
|
7
|
-
import fs from "node:fs";
|
|
8
|
-
import path from "node:path";
|
|
9
|
-
import os from "node:os";
|
|
10
|
-
/** 默认根目录 `~/.openclaw`。 */
|
|
11
|
-
export function openclawHome() {
|
|
12
|
-
return path.join(os.homedir(), ".openclaw");
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* 解析所有 agent workspace 下的 skill 扫描目录。
|
|
16
|
-
*
|
|
17
|
-
* 来源:
|
|
18
|
-
* - 顶层全局 skills:`<home>/skills`
|
|
19
|
-
* - 默认 workspace:`<agents.defaults.workspace>/skills`(缺省为 `<home>/workspace`)
|
|
20
|
-
* - 各 agent:`<agents.list[].workspace>/skills`(未显式配置 workspace 的 agent 落到默认)
|
|
21
|
-
*
|
|
22
|
-
* openclaw.json 不存在 / 解析失败 / 字段缺失时,回退到「顶层 skills + 默认 workspace skills」,
|
|
23
|
-
* 至少不弱于历史行为,且整段被 try/catch 包裹,绝不抛。
|
|
24
|
-
*/
|
|
25
|
-
export function resolveAgentSkillDirs(home, configPath) {
|
|
26
|
-
const dirs = new Set();
|
|
27
|
-
// 永远纳入的兜底目录(即便 openclaw.json 缺失也能工作)。
|
|
28
|
-
dirs.add(path.join(home, "skills"));
|
|
29
|
-
const defaultWorkspaceFallback = path.join(home, "workspace");
|
|
30
|
-
dirs.add(path.join(defaultWorkspaceFallback, "skills"));
|
|
31
|
-
try {
|
|
32
|
-
const raw = fs.readFileSync(configPath, "utf-8");
|
|
33
|
-
const cfg = JSON.parse(raw);
|
|
34
|
-
const agents = cfg?.agents;
|
|
35
|
-
const defaultWs = typeof agents?.defaults?.workspace === "string" ? agents.defaults.workspace : defaultWorkspaceFallback;
|
|
36
|
-
dirs.add(path.join(defaultWs, "skills"));
|
|
37
|
-
const list = Array.isArray(agents?.list) ? agents.list : [];
|
|
38
|
-
for (const a of list) {
|
|
39
|
-
const ws = typeof a?.workspace === "string" ? a.workspace : defaultWs;
|
|
40
|
-
dirs.add(path.join(ws, "skills"));
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
// openclaw.json 不存在 / 非法 JSON / 权限不足 → 用上面的兜底目录,绝不影响插件启动。
|
|
45
|
-
}
|
|
46
|
-
return [...dirs];
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* 生成一组路径。`overrides` 仅供测试注入(如指向临时目录)。
|
|
50
|
-
*/
|
|
51
|
-
export function resolvePaths(overrides) {
|
|
52
|
-
const home = openclawHome();
|
|
53
|
-
const logsDir = path.join(home, "logs");
|
|
54
|
-
return {
|
|
55
|
-
eventsLogPath: path.join(logsDir, "skill-logger-plugin.jsonl"),
|
|
56
|
-
syncStatePath: path.join(logsDir, "skill-logger-plugin.sync.json"),
|
|
57
|
-
cooldownStatePath: path.join(logsDir, "skill-logger-plugin.cooldown.json"),
|
|
58
|
-
extensionsDir: path.join(home, "extensions"),
|
|
59
|
-
openclawConfigPath: path.join(home, "openclaw.json"),
|
|
60
|
-
...overrides,
|
|
61
|
-
};
|
|
62
|
-
}
|
package/dist/paths.test.js
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import fs from "node:fs/promises";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import os from "node:os";
|
|
6
|
-
import { resolveAgentSkillDirs } from "./paths.ts";
|
|
7
|
-
let home;
|
|
8
|
-
beforeEach(async () => {
|
|
9
|
-
home = path.join(os.tmpdir(), `slp-paths-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
10
|
-
await fs.mkdir(home, { recursive: true });
|
|
11
|
-
});
|
|
12
|
-
afterEach(async () => {
|
|
13
|
-
await fs.rm(home, { recursive: true, force: true });
|
|
14
|
-
});
|
|
15
|
-
describe("resolveAgentSkillDirs", () => {
|
|
16
|
-
it("从 openclaw.json 收集各 agent workspace 的 skills 目录(含默认与顶层)", async () => {
|
|
17
|
-
const cfg = {
|
|
18
|
-
agents: {
|
|
19
|
-
defaults: { workspace: path.join(home, "workspace") },
|
|
20
|
-
list: [
|
|
21
|
-
{ id: "main" }, // 无 workspace → 落到默认
|
|
22
|
-
{ id: "coder", workspace: path.join(home, "workspace-coder") },
|
|
23
|
-
{ id: "proj", workspace: path.join(home, "agency-agents", "proj") },
|
|
24
|
-
],
|
|
25
|
-
},
|
|
26
|
-
};
|
|
27
|
-
const configPath = path.join(home, "openclaw.json");
|
|
28
|
-
await fs.writeFile(configPath, JSON.stringify(cfg));
|
|
29
|
-
const dirs = resolveAgentSkillDirs(home, configPath);
|
|
30
|
-
assert.ok(dirs.includes(path.join(home, "skills")), "应含顶层全局 skills");
|
|
31
|
-
assert.ok(dirs.includes(path.join(home, "workspace", "skills")), "应含默认 workspace skills");
|
|
32
|
-
assert.ok(dirs.includes(path.join(home, "workspace-coder", "skills")), "应含 coder workspace skills");
|
|
33
|
-
assert.ok(dirs.includes(path.join(home, "agency-agents", "proj", "skills")), "应含 proj workspace skills");
|
|
34
|
-
// 去重:main 落到默认 workspace,不应产生重复项
|
|
35
|
-
assert.equal(new Set(dirs).size, dirs.length);
|
|
36
|
-
});
|
|
37
|
-
it("openclaw.json 缺失时回退到顶层 skills + 默认 workspace skills,且不抛", async () => {
|
|
38
|
-
const dirs = resolveAgentSkillDirs(home, path.join(home, "does-not-exist.json"));
|
|
39
|
-
assert.ok(dirs.includes(path.join(home, "skills")));
|
|
40
|
-
assert.ok(dirs.includes(path.join(home, "workspace", "skills")));
|
|
41
|
-
});
|
|
42
|
-
it("非法 JSON 不抛,回退到默认目录", async () => {
|
|
43
|
-
const configPath = path.join(home, "openclaw.json");
|
|
44
|
-
await fs.writeFile(configPath, "{ not valid json ");
|
|
45
|
-
const dirs = resolveAgentSkillDirs(home, configPath);
|
|
46
|
-
assert.ok(dirs.includes(path.join(home, "skills")));
|
|
47
|
-
assert.ok(dirs.includes(path.join(home, "workspace", "skills")));
|
|
48
|
-
});
|
|
49
|
-
});
|
package/dist/reporter.js
DELETED
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 本地落盘 + 日志轮转 (Log Rotation) + 每 3 分钟批量上报。
|
|
3
|
-
*
|
|
4
|
-
* 设计原则:
|
|
5
|
-
* - events.jsonl 是活跃的写入日志,通过 appendFile 追加。
|
|
6
|
-
* - 上报时,将其 rename 轮转为带有时间戳的文件,隔离写和读,根绝并发读写丢失数据的竞态条件。
|
|
7
|
-
* - 后台处理所有的轮转文件并上报,上报成功后通过 fs.unlink 清理文件。
|
|
8
|
-
* - 失败则保留待下次重试(因为底层 DB 依赖 INSERT IGNORE 处理重复 event_id,所以即使重试时存在部分重复上报也是安全的)。
|
|
9
|
-
* - 所有异常吞掉,绝不阻塞 agent / gateway。
|
|
10
|
-
*/
|
|
11
|
-
import fs from "node:fs/promises";
|
|
12
|
-
import path from "node:path";
|
|
13
|
-
import { GitIdentityProvider } from "./identity.ts";
|
|
14
|
-
import { defaultFetch } from "./http.ts";
|
|
15
|
-
const FLUSH_INTERVAL_MS = 3 * 60 * 1000;
|
|
16
|
-
const BATCH_SIZE = 500;
|
|
17
|
-
export class Reporter {
|
|
18
|
-
paths;
|
|
19
|
-
getConfig;
|
|
20
|
-
identityProvider;
|
|
21
|
-
fetchImpl;
|
|
22
|
-
timer;
|
|
23
|
-
/** 防止多次 flush 重入。 */
|
|
24
|
-
flushing = false;
|
|
25
|
-
constructor(opts) {
|
|
26
|
-
this.paths = opts.paths;
|
|
27
|
-
this.getConfig = opts.getConfig;
|
|
28
|
-
this.identityProvider = opts.identityProvider ?? new GitIdentityProvider();
|
|
29
|
-
this.fetchImpl = opts.fetchImpl ?? defaultFetch();
|
|
30
|
-
}
|
|
31
|
-
get isDebug() {
|
|
32
|
-
return this.getConfig().debugLogging !== false;
|
|
33
|
-
}
|
|
34
|
-
async writeFallbackLog(level, ...args) {
|
|
35
|
-
try {
|
|
36
|
-
const msg = args.map(a => (a instanceof Error) ? (a.stack || a.toString()) : (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ");
|
|
37
|
-
const ts = new Date().toISOString();
|
|
38
|
-
const logLine = `[${ts}] [${level}] [skill-logger-plugin] ${msg}\n`;
|
|
39
|
-
const logDir = path.dirname(this.paths.eventsLogPath);
|
|
40
|
-
await fs.mkdir(logDir, { recursive: true });
|
|
41
|
-
await fs.appendFile(path.join(logDir, "skill-logger.err.log"), logLine);
|
|
42
|
-
if (level === "INFO" && this.isDebug) {
|
|
43
|
-
console.log("[skill-logger-plugin/reporter]", ...args);
|
|
44
|
-
}
|
|
45
|
-
else if (level === "WARN" || level === "ERROR") {
|
|
46
|
-
console.warn("[skill-logger-plugin]", ...args);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
// ignore
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
debug(...args) {
|
|
54
|
-
if (this.isDebug) {
|
|
55
|
-
void this.writeFallbackLog("INFO", ...args);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
/** 追加一行事件到 events.jsonl(目录不存在自动建)。失败不抛。 */
|
|
59
|
-
async appendEvent(event) {
|
|
60
|
-
try {
|
|
61
|
-
// 防御:截断会导致 MySQL Data Truncation 宕机的超长字段
|
|
62
|
-
if (typeof event.error_message === "string" && event.error_message.length > 15000) {
|
|
63
|
-
event.error_message = event.error_message.substring(0, 15000) + "...(truncated)";
|
|
64
|
-
}
|
|
65
|
-
if (typeof event.command === "string" && event.command.length > 15000) {
|
|
66
|
-
event.command = event.command.substring(0, 15000) + "...(truncated)";
|
|
67
|
-
}
|
|
68
|
-
let line = "";
|
|
69
|
-
try {
|
|
70
|
-
line = JSON.stringify(event);
|
|
71
|
-
// 防御:防止整个 JSON 过大(如带有 base64 图片的 args)导致 Express 413 Payload Too Large
|
|
72
|
-
if (line.length > 100000) {
|
|
73
|
-
const safeEvent = { ...event, args: { _warning: "args omitted due to excessive size" } };
|
|
74
|
-
line = JSON.stringify(safeEvent);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
return; // JSON 序列化失败直接丢弃
|
|
79
|
-
}
|
|
80
|
-
await fs.mkdir(path.dirname(this.paths.eventsLogPath), { recursive: true });
|
|
81
|
-
await fs.appendFile(this.paths.eventsLogPath, line + "\n");
|
|
82
|
-
}
|
|
83
|
-
catch (err) {
|
|
84
|
-
void this.writeFallbackLog("ERROR", "写事件日志失败", this.paths.eventsLogPath, err);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
/** 启动 3 分钟定时上报。 */
|
|
88
|
-
startTimer() {
|
|
89
|
-
if (this.timer)
|
|
90
|
-
return;
|
|
91
|
-
this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
|
|
92
|
-
// 不阻止进程退出
|
|
93
|
-
if (typeof this.timer.unref === "function")
|
|
94
|
-
this.timer.unref();
|
|
95
|
-
}
|
|
96
|
-
/** 停止定时器,并尽力做最后一次 flush。 */
|
|
97
|
-
async stopTimer() {
|
|
98
|
-
if (this.timer) {
|
|
99
|
-
clearInterval(this.timer);
|
|
100
|
-
this.timer = undefined;
|
|
101
|
-
}
|
|
102
|
-
await this.flush();
|
|
103
|
-
}
|
|
104
|
-
linesOf(content) {
|
|
105
|
-
return content.split("\n").filter((l) => l.trim().length > 0);
|
|
106
|
-
}
|
|
107
|
-
async resolveUserInfo(appKey, config) {
|
|
108
|
-
if (!config.reportBaseUrl)
|
|
109
|
-
return {};
|
|
110
|
-
try {
|
|
111
|
-
const url = config.reportBaseUrl.replace(/\/$/, "") + "/skill_user/resolve";
|
|
112
|
-
const headers = { "Content-Type": "application/json" };
|
|
113
|
-
if (config.authToken)
|
|
114
|
-
headers.Authorization = config.authToken;
|
|
115
|
-
const res = await this.fetchImpl(url, {
|
|
116
|
-
method: "POST",
|
|
117
|
-
headers,
|
|
118
|
-
body: JSON.stringify({ appKey }),
|
|
119
|
-
});
|
|
120
|
-
if (!res.ok) {
|
|
121
|
-
return { error_message: `resolve user info failed: HTTP ${res.status}` };
|
|
122
|
-
}
|
|
123
|
-
const data = typeof res.json === "function" ? await res.json() : {};
|
|
124
|
-
return (data.user_info || data.userInfo || data.data || {});
|
|
125
|
-
}
|
|
126
|
-
catch (err) {
|
|
127
|
-
return { error_message: `resolve user info failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
async attachUserInfo(events, config) {
|
|
131
|
-
const appKeys = [...new Set(events.map((event) => event.app_key || "").filter(Boolean))];
|
|
132
|
-
if (appKeys.length === 0)
|
|
133
|
-
return events;
|
|
134
|
-
const byAppKey = new Map();
|
|
135
|
-
await Promise.all(appKeys.map(async (appKey) => {
|
|
136
|
-
byAppKey.set(appKey, await this.resolveUserInfo(appKey, config));
|
|
137
|
-
}));
|
|
138
|
-
return events.map((event) => {
|
|
139
|
-
if (!event.app_key)
|
|
140
|
-
return event;
|
|
141
|
-
const userInfo = byAppKey.get(event.app_key);
|
|
142
|
-
if (!userInfo || Object.keys(userInfo).length === 0)
|
|
143
|
-
return event;
|
|
144
|
-
return { ...event, user_info: userInfo };
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* 读本地队列并分批 POST;每批成功后清理对应本地文件。
|
|
149
|
-
* 采用日志轮转(Rename)规避读写竞态条件。
|
|
150
|
-
* 未配置 reportBaseUrl → 直接返回(只落本地,不上报)。
|
|
151
|
-
*/
|
|
152
|
-
async flush() {
|
|
153
|
-
if (this.flushing)
|
|
154
|
-
return;
|
|
155
|
-
this.flushing = true;
|
|
156
|
-
try {
|
|
157
|
-
const config = this.getConfig();
|
|
158
|
-
if (!config.reportBaseUrl) {
|
|
159
|
-
void this.writeFallbackLog("WARN", "flush skipped: reportBaseUrl 没有配置!请检查 openclaw.json 插件配置是否正确加载。");
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
const logDir = path.dirname(this.paths.eventsLogPath);
|
|
163
|
-
// 1. 重命名当前的活跃日志文件为时间戳格式(原子操作,解决读写冲突)
|
|
164
|
-
try {
|
|
165
|
-
await fs.access(this.paths.eventsLogPath);
|
|
166
|
-
const timestamp = Date.now();
|
|
167
|
-
const rotatedPath = path.join(logDir, `events.${timestamp}.jsonl`);
|
|
168
|
-
await fs.rename(this.paths.eventsLogPath, rotatedPath);
|
|
169
|
-
this.debug(`Rotated active log to ${path.basename(rotatedPath)}`);
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
// 文件不存在,跳过重命名
|
|
173
|
-
}
|
|
174
|
-
// 2. 扫描所有轮转的日志文件
|
|
175
|
-
let files = [];
|
|
176
|
-
try {
|
|
177
|
-
const dirEntries = await fs.readdir(logDir);
|
|
178
|
-
files = dirEntries
|
|
179
|
-
.filter(f => f.startsWith("events.") && f.endsWith(".jsonl") && f !== "events.jsonl")
|
|
180
|
-
.map(f => path.join(logDir, f));
|
|
181
|
-
}
|
|
182
|
-
catch {
|
|
183
|
-
return; // 目录不存在直接返回
|
|
184
|
-
}
|
|
185
|
-
if (files.length === 0) {
|
|
186
|
-
this.debug("No rotated log files found. flush finished.");
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
this.debug(`Found ${files.length} rotated log files to process.`);
|
|
190
|
-
const identity = await this.identityProvider.getIdentity();
|
|
191
|
-
const url = config.reportBaseUrl.replace(/\/$/, "") + "/skill_report/batch";
|
|
192
|
-
// 3. 逐个处理文件上报
|
|
193
|
-
for (const filePath of files) {
|
|
194
|
-
try {
|
|
195
|
-
const content = await fs.readFile(filePath, "utf-8");
|
|
196
|
-
const lines = this.linesOf(content);
|
|
197
|
-
if (lines.length === 0) {
|
|
198
|
-
await fs.unlink(filePath); // 空文件直接删除
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
201
|
-
let cursor = 0;
|
|
202
|
-
let allSuccess = true;
|
|
203
|
-
while (cursor < lines.length) {
|
|
204
|
-
const slice = lines.slice(cursor, cursor + BATCH_SIZE);
|
|
205
|
-
const events = [];
|
|
206
|
-
for (const line of slice) {
|
|
207
|
-
try {
|
|
208
|
-
events.push(JSON.parse(line));
|
|
209
|
-
}
|
|
210
|
-
catch {
|
|
211
|
-
// 跳过坏行
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (events.length === 0) {
|
|
215
|
-
cursor += slice.length;
|
|
216
|
-
continue;
|
|
217
|
-
}
|
|
218
|
-
const eventsWithUserInfo = await this.attachUserInfo(events, config);
|
|
219
|
-
const body = JSON.stringify({
|
|
220
|
-
identity,
|
|
221
|
-
ide: "openclaw",
|
|
222
|
-
marketplace: "openclaw",
|
|
223
|
-
events: eventsWithUserInfo,
|
|
224
|
-
});
|
|
225
|
-
const headers = { "Content-Type": "application/json" };
|
|
226
|
-
if (config.authToken)
|
|
227
|
-
headers.Authorization = config.authToken;
|
|
228
|
-
const res = await this.fetchImpl(url, { method: "POST", headers, body });
|
|
229
|
-
if (!res.ok) {
|
|
230
|
-
const errBody = await res.text().catch(() => "无法读取响应体");
|
|
231
|
-
await this.writeFallbackLog("ERROR", `批量上报失败 (文件 ${path.basename(filePath)}), HTTP`, res.status, "服务端返回信息:", errBody);
|
|
232
|
-
// 防御死循环:如果是由于报文过大(413)或格式错误(400)等业务级拒绝,放弃该批次,不要死锁整个本地队列
|
|
233
|
-
if (res.status === 400 || res.status === 413 || res.status === 422) {
|
|
234
|
-
await this.writeFallbackLog("WARN", `报文被服务器永久拒绝,丢弃该批次以释放队列`);
|
|
235
|
-
cursor += slice.length;
|
|
236
|
-
continue;
|
|
237
|
-
}
|
|
238
|
-
allSuccess = false;
|
|
239
|
-
break; // 其他网络或 500 错误:本文件终止处理,留待下轮重试
|
|
240
|
-
}
|
|
241
|
-
this.debug(`Successfully reported batch of ${events.length} events from ${path.basename(filePath)}`);
|
|
242
|
-
cursor += slice.length;
|
|
243
|
-
}
|
|
244
|
-
// 如果该文件所有的 batch 都上报成功了,将其物理删除
|
|
245
|
-
if (allSuccess) {
|
|
246
|
-
await fs.unlink(filePath);
|
|
247
|
-
this.debug(`Deleted fully processed file: ${path.basename(filePath)}`);
|
|
248
|
-
}
|
|
249
|
-
else {
|
|
250
|
-
// 如果某一批次失败,文件保留。下轮 flush 会把前面成功批次的事件再报一次。
|
|
251
|
-
// 但因为 DB 的 skill_logger_events 表 event_id 是唯一键且使用了 INSERT IGNORE,所以数据去重是绝对安全的。
|
|
252
|
-
this.debug(`File ${path.basename(filePath)} partially failed. Keeping it for next flush.`);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
catch (err) {
|
|
256
|
-
await this.writeFallbackLog("ERROR", `处理文件 ${path.basename(filePath)} 异常:`, err);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
catch (err) {
|
|
261
|
-
await this.writeFallbackLog("ERROR", "flush 整体异常", err);
|
|
262
|
-
}
|
|
263
|
-
finally {
|
|
264
|
-
this.flushing = false;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
package/dist/reporter.test.js
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import fs from "node:fs/promises";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import os from "node:os";
|
|
6
|
-
import { Reporter } from "./reporter.ts";
|
|
7
|
-
const identityProvider = {
|
|
8
|
-
getIdentity: async () => ({ user_id: "", git_name: "n", git_email: "e", machine_id: "m" }),
|
|
9
|
-
};
|
|
10
|
-
function makeEvent(i) {
|
|
11
|
-
return { event_id: `id${i}`, event_type: "function_call", skill_name: "s", called_at: new Date().toISOString() };
|
|
12
|
-
}
|
|
13
|
-
let dir;
|
|
14
|
-
let paths;
|
|
15
|
-
beforeEach(async () => {
|
|
16
|
-
dir = path.join(os.tmpdir(), `slp-reporter-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
17
|
-
await fs.mkdir(dir, { recursive: true });
|
|
18
|
-
paths = {
|
|
19
|
-
eventsLogPath: path.join(dir, "events.jsonl"),
|
|
20
|
-
};
|
|
21
|
-
});
|
|
22
|
-
afterEach(async () => {
|
|
23
|
-
await fs.rm(dir, { recursive: true, force: true });
|
|
24
|
-
});
|
|
25
|
-
/** 缺文件视为空,匹配轮转模型下「上报成功即删除」的形态。 */
|
|
26
|
-
async function readEventsOrEmpty(p) {
|
|
27
|
-
try {
|
|
28
|
-
return await fs.readFile(p, "utf-8");
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
return "";
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
/** 列出已轮转、待上报的日志文件(排除活跃的 events.jsonl)。 */
|
|
35
|
-
async function listRotated(d) {
|
|
36
|
-
try {
|
|
37
|
-
const entries = await fs.readdir(d);
|
|
38
|
-
return entries.filter((f) => f.startsWith("events.") && f.endsWith(".jsonl") && f !== "events.jsonl");
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
return [];
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
describe("Reporter.appendEvent", () => {
|
|
45
|
-
it("目录自动创建并追加写", async () => {
|
|
46
|
-
const r = new Reporter({ paths, getConfig: () => ({}), identityProvider });
|
|
47
|
-
await r.appendEvent(makeEvent(1));
|
|
48
|
-
await r.appendEvent(makeEvent(2));
|
|
49
|
-
const lines = (await fs.readFile(paths.eventsLogPath, "utf-8")).trim().split("\n");
|
|
50
|
-
assert.equal(lines.length, 2);
|
|
51
|
-
assert.equal(JSON.parse(lines[1]).event_id, "id2");
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
describe("Reporter.flush", () => {
|
|
55
|
-
it("未配置 reportBaseUrl 时不上报", async () => {
|
|
56
|
-
let called = 0;
|
|
57
|
-
const r = new Reporter({
|
|
58
|
-
paths,
|
|
59
|
-
getConfig: () => ({}),
|
|
60
|
-
identityProvider,
|
|
61
|
-
fetchImpl: async () => {
|
|
62
|
-
called++;
|
|
63
|
-
return { ok: true, status: 200 };
|
|
64
|
-
},
|
|
65
|
-
});
|
|
66
|
-
await r.appendEvent(makeEvent(1));
|
|
67
|
-
await r.flush();
|
|
68
|
-
assert.equal(called, 0);
|
|
69
|
-
});
|
|
70
|
-
it("成功上报后清理本地日志,再次 flush 不重复发", async () => {
|
|
71
|
-
const sent = [];
|
|
72
|
-
const config = { reportBaseUrl: "https://x" };
|
|
73
|
-
const r = new Reporter({
|
|
74
|
-
paths,
|
|
75
|
-
getConfig: () => config,
|
|
76
|
-
identityProvider,
|
|
77
|
-
fetchImpl: async (_url, init) => {
|
|
78
|
-
const body = JSON.parse(String(init.body));
|
|
79
|
-
sent.push(body.events.length);
|
|
80
|
-
return { ok: true, status: 200 };
|
|
81
|
-
},
|
|
82
|
-
});
|
|
83
|
-
await r.appendEvent(makeEvent(1));
|
|
84
|
-
await r.appendEvent(makeEvent(2));
|
|
85
|
-
await r.flush();
|
|
86
|
-
await r.flush(); // 无新事件
|
|
87
|
-
assert.deepEqual(sent, [2]);
|
|
88
|
-
// 轮转文件已全部上报并删除,活跃日志清空
|
|
89
|
-
assert.equal(await readEventsOrEmpty(paths.eventsLogPath), "");
|
|
90
|
-
assert.equal((await listRotated(dir)).length, 0);
|
|
91
|
-
});
|
|
92
|
-
it("保留 flush 期间新追加的事件(轮转隔离读写)", async () => {
|
|
93
|
-
let appendedDuringFlush = false;
|
|
94
|
-
const r = new Reporter({
|
|
95
|
-
paths,
|
|
96
|
-
getConfig: () => ({ reportBaseUrl: "https://x" }),
|
|
97
|
-
identityProvider,
|
|
98
|
-
fetchImpl: async () => {
|
|
99
|
-
if (!appendedDuringFlush) {
|
|
100
|
-
appendedDuringFlush = true;
|
|
101
|
-
await fs.appendFile(paths.eventsLogPath, JSON.stringify(makeEvent(2)) + "\n");
|
|
102
|
-
}
|
|
103
|
-
return { ok: true, status: 200 };
|
|
104
|
-
},
|
|
105
|
-
});
|
|
106
|
-
await r.appendEvent(makeEvent(1));
|
|
107
|
-
await r.flush();
|
|
108
|
-
const lines = (await readEventsOrEmpty(paths.eventsLogPath)).trim().split("\n").filter(Boolean);
|
|
109
|
-
assert.equal(lines.length, 1);
|
|
110
|
-
assert.equal(JSON.parse(lines[0]).event_id, "id2");
|
|
111
|
-
});
|
|
112
|
-
it("上报失败保留轮转文件,下轮重试成功后清空", async () => {
|
|
113
|
-
let ok = false;
|
|
114
|
-
const r = new Reporter({
|
|
115
|
-
paths,
|
|
116
|
-
getConfig: () => ({ reportBaseUrl: "https://x" }),
|
|
117
|
-
identityProvider,
|
|
118
|
-
fetchImpl: async () => ({ ok, status: ok ? 200 : 500 }),
|
|
119
|
-
});
|
|
120
|
-
await r.appendEvent(makeEvent(1));
|
|
121
|
-
await r.flush(); // 失败:轮转文件保留待重试
|
|
122
|
-
assert.equal((await listRotated(dir)).length, 1);
|
|
123
|
-
ok = true;
|
|
124
|
-
await r.flush(); // 成功:清空
|
|
125
|
-
assert.equal((await listRotated(dir)).length, 0);
|
|
126
|
-
assert.equal(await readEventsOrEmpty(paths.eventsLogPath), "");
|
|
127
|
-
});
|
|
128
|
-
});
|