chatccc 0.2.53 → 0.2.55
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/package.json +1 -1
- package/src/__tests__/privacy.test.ts +143 -0
- package/src/__tests__/session.test.ts +8 -6
- package/src/__tests__/simplify.test.ts +283 -0
- package/src/__tests__/wechat-platform.test.ts +0 -25
- package/src/adapters/adapter-interface.ts +2 -0
- package/src/adapters/claude-adapter.ts +1 -0
- package/src/adapters/cursor-adapter.ts +1 -0
- package/src/feishu-api.ts +7 -3
- package/src/privacy.ts +68 -0
- package/src/session.ts +48 -25
- package/src/simplify.ts +120 -0
- package/src/wechat-platform.ts +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// 在 import privacy 之前 mock config,让 USER_DATA_DIR 指向临时目录
|
|
7
|
+
const TEST_DATA_DIR = await mkdtemp(join(tmpdir(), "chatccc-privacy-test-"));
|
|
8
|
+
vi.mock("../config.ts", async () => {
|
|
9
|
+
const actual = await vi.importActual<typeof import("../config.ts")>("../config.ts");
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
USER_DATA_DIR: TEST_DATA_DIR,
|
|
13
|
+
ts: () => "test-ts",
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
let applyPrivacy: (text: string) => string;
|
|
18
|
+
let reloadPrivacyRules: () => void;
|
|
19
|
+
let getPrivacyRules: () => Record<string, string>;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
vi.resetModules();
|
|
23
|
+
// 清理临时目录中的 privacy.json
|
|
24
|
+
try {
|
|
25
|
+
await rm(join(TEST_DATA_DIR, "privacy.json"), { force: true });
|
|
26
|
+
} catch {}
|
|
27
|
+
const mod = await import("../privacy.ts");
|
|
28
|
+
applyPrivacy = mod.applyPrivacy;
|
|
29
|
+
reloadPrivacyRules = mod.reloadPrivacyRules;
|
|
30
|
+
getPrivacyRules = mod.getPrivacyRules;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
try {
|
|
35
|
+
await rm(join(TEST_DATA_DIR, "privacy.json"), { force: true });
|
|
36
|
+
} catch {}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
try {
|
|
41
|
+
await rm(TEST_DATA_DIR, { recursive: true, force: true });
|
|
42
|
+
} catch {}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("applyPrivacy", () => {
|
|
46
|
+
it("无 privacy.json 时返回原文", () => {
|
|
47
|
+
reloadPrivacyRules();
|
|
48
|
+
expect(applyPrivacy("hello weizhangjian")).toBe("hello weizhangjian");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("privacy.json 存在时按规则替换", async () => {
|
|
52
|
+
await writeFile(
|
|
53
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
54
|
+
JSON.stringify({ weizhangjian: "wzj", secret: "***" }),
|
|
55
|
+
"utf-8",
|
|
56
|
+
);
|
|
57
|
+
reloadPrivacyRules();
|
|
58
|
+
|
|
59
|
+
expect(applyPrivacy("hello weizhangjian")).toBe("hello wzj");
|
|
60
|
+
expect(applyPrivacy("my secret is safe")).toBe("my *** is safe");
|
|
61
|
+
expect(applyPrivacy("weizhangjian and secret")).toBe("wzj and ***");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("多规则替换多次出现", async () => {
|
|
65
|
+
await writeFile(
|
|
66
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
67
|
+
JSON.stringify({ a: "A", b: "B" }),
|
|
68
|
+
"utf-8",
|
|
69
|
+
);
|
|
70
|
+
reloadPrivacyRules();
|
|
71
|
+
|
|
72
|
+
expect(applyPrivacy("a b a b")).toBe("A B A B");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("空文本直接返回", async () => {
|
|
76
|
+
await writeFile(
|
|
77
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
78
|
+
JSON.stringify({ x: "y" }),
|
|
79
|
+
"utf-8",
|
|
80
|
+
);
|
|
81
|
+
reloadPrivacyRules();
|
|
82
|
+
|
|
83
|
+
expect(applyPrivacy("")).toBe("");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("规则中的特殊字符不会被当作正则", async () => {
|
|
87
|
+
await writeFile(
|
|
88
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
89
|
+
JSON.stringify({ "a.b": "X", "(test)": "Y", "*star": "Z" }),
|
|
90
|
+
"utf-8",
|
|
91
|
+
);
|
|
92
|
+
reloadPrivacyRules();
|
|
93
|
+
|
|
94
|
+
expect(applyPrivacy("hello a.b world")).toBe("hello X world");
|
|
95
|
+
expect(applyPrivacy("text (test) here")).toBe("text Y here");
|
|
96
|
+
expect(applyPrivacy("a *star shines")).toBe("a Z shines");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("reloadPrivacyRules 强制重新加载", async () => {
|
|
100
|
+
await writeFile(
|
|
101
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
102
|
+
JSON.stringify({ old: "OLD" }),
|
|
103
|
+
"utf-8",
|
|
104
|
+
);
|
|
105
|
+
reloadPrivacyRules();
|
|
106
|
+
|
|
107
|
+
expect(applyPrivacy("old")).toBe("OLD");
|
|
108
|
+
expect(getPrivacyRules()).toEqual({ old: "OLD" });
|
|
109
|
+
|
|
110
|
+
// 变更磁盘内容后 reload
|
|
111
|
+
await writeFile(
|
|
112
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
113
|
+
JSON.stringify({ new: "NEW" }),
|
|
114
|
+
"utf-8",
|
|
115
|
+
);
|
|
116
|
+
reloadPrivacyRules();
|
|
117
|
+
|
|
118
|
+
expect(applyPrivacy("new")).toBe("NEW");
|
|
119
|
+
expect(getPrivacyRules()).toEqual({ new: "NEW" });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("格式错误的 JSON 不抛异常,返回原文", async () => {
|
|
123
|
+
await writeFile(
|
|
124
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
125
|
+
"not json",
|
|
126
|
+
"utf-8",
|
|
127
|
+
);
|
|
128
|
+
reloadPrivacyRules();
|
|
129
|
+
|
|
130
|
+
expect(applyPrivacy("hello")).toBe("hello");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("数组格式的 JSON 不抛异常,返回原文", async () => {
|
|
134
|
+
await writeFile(
|
|
135
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
136
|
+
JSON.stringify(["a", "b"]),
|
|
137
|
+
"utf-8",
|
|
138
|
+
);
|
|
139
|
+
reloadPrivacyRules();
|
|
140
|
+
|
|
141
|
+
expect(applyPrivacy("hello")).toBe("hello");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -682,20 +682,22 @@ describe("accumulateBlockContent", () => {
|
|
|
682
682
|
);
|
|
683
683
|
expect(s.accumulatedContent).toContain("📖"); // 📖
|
|
684
684
|
expect(s.accumulatedContent).toContain("**Read**");
|
|
685
|
-
expect(s.accumulatedContent).toContain("
|
|
685
|
+
expect(s.accumulatedContent).toContain("/tmp/test.txt");
|
|
686
686
|
});
|
|
687
687
|
|
|
688
688
|
it("accumulates tool_use block with long input truncated", () => {
|
|
689
689
|
const s = freshState();
|
|
690
690
|
const longInput = "x".repeat(500);
|
|
691
691
|
accumulateBlockContent(
|
|
692
|
-
{ type: "tool_use", name: "Bash", input: longInput },
|
|
692
|
+
{ type: "tool_use", name: "Bash", input: { command: longInput } },
|
|
693
693
|
s,
|
|
694
694
|
);
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
695
|
+
// 简化规则截断到 500 chars
|
|
696
|
+
expect(s.accumulatedContent).toContain("🖥️");
|
|
697
|
+
expect(s.accumulatedContent).toContain("**Bash**");
|
|
698
|
+
// command 超过 maxLength=500 时会被截断
|
|
699
|
+
const body = s.accumulatedContent.split("**Bash** ")[1]?.trim() ?? "";
|
|
700
|
+
expect(body.length).toBeLessThanOrEqual(503); // 500 + "..."
|
|
699
701
|
});
|
|
700
702
|
|
|
701
703
|
it("accumulates tool_result block with success icon (✅)", () => {
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const TEST_DATA_DIR = await mkdtemp(join(tmpdir(), "chatccc-simplify-test-"));
|
|
7
|
+
vi.mock("../config.ts", async () => {
|
|
8
|
+
const actual = await vi.importActual<typeof import("../config.ts")>("../config.ts");
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
PROJECT_ROOT: TEST_DATA_DIR,
|
|
12
|
+
ts: () => "test-ts",
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
let simplifyToolUse: (name: string, input: unknown) => string | null;
|
|
17
|
+
let simplifyToolResult: (name: string, toolUseId: string, isError: boolean, toolCallInput?: unknown) => string | null;
|
|
18
|
+
let reloadSimplifyConfig: () => void;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
vi.resetModules();
|
|
22
|
+
try {
|
|
23
|
+
await rm(join(TEST_DATA_DIR, "simplify.json"), { force: true });
|
|
24
|
+
} catch {}
|
|
25
|
+
const mod = await import("../simplify.ts");
|
|
26
|
+
simplifyToolUse = mod.simplifyToolUse;
|
|
27
|
+
simplifyToolResult = mod.simplifyToolResult;
|
|
28
|
+
reloadSimplifyConfig = mod.reloadSimplifyConfig;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(async () => {
|
|
32
|
+
try {
|
|
33
|
+
await rm(TEST_DATA_DIR, { recursive: true, force: true });
|
|
34
|
+
} catch {}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("simplifyToolUse", () => {
|
|
38
|
+
it("无 simplify.json 时返回 null(回退默认格式化)", () => {
|
|
39
|
+
reloadSimplifyConfig();
|
|
40
|
+
expect(simplifyToolUse("Read", { file_path: "/tmp/x.ts" })).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("有规则时按模板格式化 tool_use", async () => {
|
|
44
|
+
await writeFile(
|
|
45
|
+
join(TEST_DATA_DIR, "simplify.json"),
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
tool_use: {
|
|
48
|
+
Read: { template: "📖 **Read** {file_path}", maxLength: 300 },
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
"utf-8",
|
|
52
|
+
);
|
|
53
|
+
reloadSimplifyConfig();
|
|
54
|
+
|
|
55
|
+
expect(simplifyToolUse("Read", { file_path: "/home/user/project/src/index.ts" }))
|
|
56
|
+
.toBe("📖 **Read** /home/user/project/src/index.ts");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("无对应工具规则时返回 null", async () => {
|
|
60
|
+
await writeFile(
|
|
61
|
+
join(TEST_DATA_DIR, "simplify.json"),
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
tool_use: {
|
|
64
|
+
Read: { template: "📖 **Read** {file_path}", maxLength: 300 },
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
"utf-8",
|
|
68
|
+
);
|
|
69
|
+
reloadSimplifyConfig();
|
|
70
|
+
|
|
71
|
+
expect(simplifyToolUse("Write", { file_path: "/tmp/x.ts" })).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("模板中未匹配的占位符保留原文", async () => {
|
|
75
|
+
await writeFile(
|
|
76
|
+
join(TEST_DATA_DIR, "simplify.json"),
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
tool_use: {
|
|
79
|
+
Read: { template: "📖 **Read** {file_path} {unknown_field}", maxLength: 300 },
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
"utf-8",
|
|
83
|
+
);
|
|
84
|
+
reloadSimplifyConfig();
|
|
85
|
+
|
|
86
|
+
expect(simplifyToolUse("Read", { file_path: "/tmp/x.ts" }))
|
|
87
|
+
.toBe("📖 **Read** /tmp/x.ts {unknown_field}");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("超过 maxLength 时截断", async () => {
|
|
91
|
+
await writeFile(
|
|
92
|
+
join(TEST_DATA_DIR, "simplify.json"),
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
tool_use: {
|
|
95
|
+
Bash: { template: "🖥️ **Bash** {command}", maxLength: 20 },
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
"utf-8",
|
|
99
|
+
);
|
|
100
|
+
reloadSimplifyConfig();
|
|
101
|
+
|
|
102
|
+
const result = simplifyToolUse("Bash", { command: "echo hello world this is a long command" });
|
|
103
|
+
expect(result).toBeTruthy();
|
|
104
|
+
// 被截断了:长度不超过 maxLength + 3("..." 后缀)
|
|
105
|
+
expect(result!.length).toBeLessThanOrEqual(23);
|
|
106
|
+
expect(result!.endsWith("...")).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("多字段模板", async () => {
|
|
110
|
+
await writeFile(
|
|
111
|
+
join(TEST_DATA_DIR, "simplify.json"),
|
|
112
|
+
JSON.stringify({
|
|
113
|
+
tool_use: {
|
|
114
|
+
Grep: { template: "🔎 **Grep** {pattern} in {path}", maxLength: 300 },
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
"utf-8",
|
|
118
|
+
);
|
|
119
|
+
reloadSimplifyConfig();
|
|
120
|
+
|
|
121
|
+
expect(simplifyToolUse("Grep", { pattern: "TODO", path: "src/" }))
|
|
122
|
+
.toBe("🔎 **Grep** TODO in src/");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("input 为 null 时不抛异常", async () => {
|
|
126
|
+
await writeFile(
|
|
127
|
+
join(TEST_DATA_DIR, "simplify.json"),
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
tool_use: {
|
|
130
|
+
TodoWrite: { template: "✅ **TodoWrite**", maxLength: 200 },
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
"utf-8",
|
|
134
|
+
);
|
|
135
|
+
reloadSimplifyConfig();
|
|
136
|
+
|
|
137
|
+
expect(simplifyToolUse("TodoWrite", null)).toBe("✅ **TodoWrite**");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("simplifyToolResult", () => {
|
|
142
|
+
it("无 simplify.json 时返回 null", () => {
|
|
143
|
+
reloadSimplifyConfig();
|
|
144
|
+
expect(simplifyToolResult("Read", "abc123def456", false)).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("有规则时按模板格式化 tool_result", async () => {
|
|
148
|
+
await writeFile(
|
|
149
|
+
join(TEST_DATA_DIR, "simplify.json"),
|
|
150
|
+
JSON.stringify({
|
|
151
|
+
tool_result: {
|
|
152
|
+
Read: { template: "✅ *{id}*: 已读取", maxLength: 200 },
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
"utf-8",
|
|
156
|
+
);
|
|
157
|
+
reloadSimplifyConfig();
|
|
158
|
+
|
|
159
|
+
expect(simplifyToolResult("Read", "abc123def456", false))
|
|
160
|
+
.toBe("✅ *def456*: 已读取");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("isError 时结果前加 ❌", async () => {
|
|
164
|
+
await writeFile(
|
|
165
|
+
join(TEST_DATA_DIR, "simplify.json"),
|
|
166
|
+
JSON.stringify({
|
|
167
|
+
tool_result: {
|
|
168
|
+
Read: { template: "✅ *{id}*: 已读取", maxLength: 200 },
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
"utf-8",
|
|
172
|
+
);
|
|
173
|
+
reloadSimplifyConfig();
|
|
174
|
+
|
|
175
|
+
expect(simplifyToolResult("Read", "abc123def456", true))
|
|
176
|
+
.toBe("❌ ✅ *def456*: 已读取");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("tool_result 模板可使用 tool_use 的输入字段", async () => {
|
|
180
|
+
await writeFile(
|
|
181
|
+
join(TEST_DATA_DIR, "simplify.json"),
|
|
182
|
+
JSON.stringify({
|
|
183
|
+
tool_result: {
|
|
184
|
+
Write: { template: "✅ *{id}*: 已写入 {file_path}", maxLength: 200 },
|
|
185
|
+
},
|
|
186
|
+
}),
|
|
187
|
+
"utf-8",
|
|
188
|
+
);
|
|
189
|
+
reloadSimplifyConfig();
|
|
190
|
+
|
|
191
|
+
expect(simplifyToolResult("Write", "abc123def456", false, { file_path: "/tmp/out.txt" }))
|
|
192
|
+
.toBe("✅ *def456*: 已写入 /tmp/out.txt");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("无对应工具规则时返回 null", async () => {
|
|
196
|
+
await writeFile(
|
|
197
|
+
join(TEST_DATA_DIR, "simplify.json"),
|
|
198
|
+
JSON.stringify({
|
|
199
|
+
tool_result: {
|
|
200
|
+
Read: { template: "✅ *{id}*: 已读取", maxLength: 200 },
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
"utf-8",
|
|
204
|
+
);
|
|
205
|
+
reloadSimplifyConfig();
|
|
206
|
+
|
|
207
|
+
expect(simplifyToolResult("Write", "abc123def456", false)).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("accumulateBlockContent with simplification", () => {
|
|
212
|
+
it("tool_use 使用简化规则,tool_result 回退默认格式", async () => {
|
|
213
|
+
// 动态 import session 以使用当前 mock 的简单化模块
|
|
214
|
+
const { accumulateBlockContent } = await import("../session.ts");
|
|
215
|
+
|
|
216
|
+
await writeFile(
|
|
217
|
+
join(TEST_DATA_DIR, "simplify.json"),
|
|
218
|
+
JSON.stringify({
|
|
219
|
+
tool_use: {
|
|
220
|
+
Read: { template: "📖 **Read** {file_path}", maxLength: 300 },
|
|
221
|
+
},
|
|
222
|
+
}),
|
|
223
|
+
"utf-8",
|
|
224
|
+
);
|
|
225
|
+
reloadSimplifyConfig();
|
|
226
|
+
|
|
227
|
+
const state = { accumulatedContent: "", finalText: "", finalCompleteText: "", chunkCount: 0 };
|
|
228
|
+
const toolCallMap = new Map<string, { name: string; input: unknown }>();
|
|
229
|
+
|
|
230
|
+
accumulateBlockContent(
|
|
231
|
+
{ type: "tool_use", id: "toolu_001", name: "Read", input: { file_path: "/src/app.ts" } },
|
|
232
|
+
state,
|
|
233
|
+
toolCallMap,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
expect(state.accumulatedContent).toBe("\n\n📖 **Read** /src/app.ts\n");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("无简化规则时回退默认 tool_use 格式", async () => {
|
|
240
|
+
const { accumulateBlockContent: acb } = await import("../session.ts");
|
|
241
|
+
|
|
242
|
+
reloadSimplifyConfig(); // 无 simplify.json
|
|
243
|
+
|
|
244
|
+
const state = { accumulatedContent: "", finalText: "", finalCompleteText: "", chunkCount: 0 };
|
|
245
|
+
const toolCallMap = new Map<string, { name: string; input: unknown }>();
|
|
246
|
+
|
|
247
|
+
acb(
|
|
248
|
+
{ type: "tool_use", id: "toolu_001", name: "Read", input: { file_path: "/src/app.ts" } },
|
|
249
|
+
state,
|
|
250
|
+
toolCallMap,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(state.accumulatedContent).toContain("📖 **Read**");
|
|
254
|
+
expect(state.accumulatedContent).toContain('{"file_path":"/src/app.ts"}');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("tool_result 使用简化规则", async () => {
|
|
258
|
+
const { accumulateBlockContent: acb } = await import("../session.ts");
|
|
259
|
+
|
|
260
|
+
await writeFile(
|
|
261
|
+
join(TEST_DATA_DIR, "simplify.json"),
|
|
262
|
+
JSON.stringify({
|
|
263
|
+
tool_result: {
|
|
264
|
+
Read: { template: "✅ *{id}*: 已读取 {file_path}", maxLength: 200 },
|
|
265
|
+
},
|
|
266
|
+
}),
|
|
267
|
+
"utf-8",
|
|
268
|
+
);
|
|
269
|
+
reloadSimplifyConfig();
|
|
270
|
+
|
|
271
|
+
const state = { accumulatedContent: "", finalText: "", finalCompleteText: "", chunkCount: 0 };
|
|
272
|
+
const toolCallMap = new Map<string, { name: string; input: unknown }>();
|
|
273
|
+
toolCallMap.set("toolu_001", { name: "Read", input: { file_path: "/src/app.ts" } });
|
|
274
|
+
|
|
275
|
+
acb(
|
|
276
|
+
{ type: "tool_result", tool_use_id: "toolu_001", content: "file content here..." },
|
|
277
|
+
state,
|
|
278
|
+
toolCallMap,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect(state.accumulatedContent).toBe("✅ *lu_001*: 已读取 /src/app.ts\n");
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -29,29 +29,4 @@ describe("createWechatAdapter", () => {
|
|
|
29
29
|
expect(text).toContain("/new");
|
|
30
30
|
expect(log).toHaveBeenCalledWith("[WECHAT] sendRawCard degraded to text");
|
|
31
31
|
});
|
|
32
|
-
|
|
33
|
-
it("reports unsent non-final messages after the claw limit", async () => {
|
|
34
|
-
const wire = {
|
|
35
|
-
push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
|
|
36
|
-
sendText: vi.fn(
|
|
37
|
-
async (_chatId: string, _text: string, _contextToken?: string) =>
|
|
38
|
-
"msg-id",
|
|
39
|
-
),
|
|
40
|
-
};
|
|
41
|
-
const log = vi.fn();
|
|
42
|
-
const platform = createWechatAdapter({
|
|
43
|
-
getWire: () => wire,
|
|
44
|
-
log,
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
for (let i = 0; i < 10; i++) {
|
|
48
|
-
await expect(platform.sendText("wx-chat-limit", `chunk ${i}`)).resolves.toBe(true);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
await expect(platform.sendText("wx-chat-limit", "chunk 10")).resolves.toBe(false);
|
|
52
|
-
expect(wire.push).toHaveBeenCalledTimes(10);
|
|
53
|
-
expect(log).toHaveBeenCalledWith(
|
|
54
|
-
"[WECHAT] sendText skipped (claw limit): chatId=wx-chat-limit count=11",
|
|
55
|
-
);
|
|
56
|
-
});
|
|
57
32
|
});
|
|
@@ -158,6 +158,7 @@ export function normalizeSdkMessage(msg: SdkMessageLike): UnifiedStreamMessage |
|
|
|
158
158
|
} else if (block.type === "tool_use") {
|
|
159
159
|
blocks.push({
|
|
160
160
|
type: "tool_use",
|
|
161
|
+
id: (block as { id?: string }).id,
|
|
161
162
|
name: block.name ?? "unknown",
|
|
162
163
|
input: block.input,
|
|
163
164
|
});
|
package/src/feishu-api.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
CODEX_SESSION_PREFIX,
|
|
16
16
|
ts,
|
|
17
17
|
} from "./config.ts";
|
|
18
|
+
import { applyPrivacy } from "./privacy.ts";
|
|
18
19
|
import { buildHelpCard } from "./cards.ts";
|
|
19
20
|
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
@@ -536,9 +537,10 @@ export async function sendTextReply(
|
|
|
536
537
|
chatId: string,
|
|
537
538
|
text: string
|
|
538
539
|
): Promise<boolean> {
|
|
540
|
+
const safeText = applyPrivacy(text);
|
|
539
541
|
const card = JSON.stringify({
|
|
540
542
|
config: { wide_screen_mode: true },
|
|
541
|
-
elements: [{ tag: "markdown", content:
|
|
543
|
+
elements: [{ tag: "markdown", content: safeText }],
|
|
542
544
|
});
|
|
543
545
|
try {
|
|
544
546
|
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
@@ -778,10 +780,12 @@ export async function sendCardReply(
|
|
|
778
780
|
content: string,
|
|
779
781
|
template = "green"
|
|
780
782
|
): Promise<boolean> {
|
|
783
|
+
const safeTitle = applyPrivacy(title);
|
|
784
|
+
const safeContent = applyPrivacy(content);
|
|
781
785
|
const card = JSON.stringify({
|
|
782
786
|
config: { wide_screen_mode: true },
|
|
783
|
-
header: { template, title: { content:
|
|
784
|
-
elements: [{ tag: "div", text: { tag: "lark_md", content } }],
|
|
787
|
+
header: { template, title: { content: safeTitle, tag: "plain_text" } },
|
|
788
|
+
elements: [{ tag: "div", text: { tag: "lark_md", content: safeContent } }],
|
|
785
789
|
});
|
|
786
790
|
|
|
787
791
|
try {
|
package/src/privacy.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { USER_DATA_DIR } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// 隐私替换规则
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
interface PrivacyRules {
|
|
10
|
+
[key: string]: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let rules: PrivacyRules | null = null;
|
|
14
|
+
let loaded = false;
|
|
15
|
+
|
|
16
|
+
function loadRules(): PrivacyRules {
|
|
17
|
+
const filePath = join(USER_DATA_DIR, "privacy.json");
|
|
18
|
+
if (!existsSync(filePath)) {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
25
|
+
console.error(`[PRIVACY] privacy.json 格式错误:应为对象`);
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
29
|
+
if (typeof v !== "string") {
|
|
30
|
+
console.error(`[PRIVACY] privacy.json 值必须为字符串,跳过 "${k}"`);
|
|
31
|
+
delete (parsed as Record<string, unknown>)[k];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return parsed as PrivacyRules;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(`[PRIVACY] 读取 privacy.json 失败: ${(err as Error).message}`);
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getPrivacyRules(): PrivacyRules {
|
|
42
|
+
if (!loaded) {
|
|
43
|
+
rules = loadRules();
|
|
44
|
+
loaded = true;
|
|
45
|
+
}
|
|
46
|
+
return rules!;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 重新加载规则(热更新用) */
|
|
50
|
+
export function reloadPrivacyRules(): void {
|
|
51
|
+
loaded = false;
|
|
52
|
+
rules = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 对文本应用隐私替换规则。
|
|
57
|
+
* 若无规则或文本为空,直接返回原文。
|
|
58
|
+
*/
|
|
59
|
+
export function applyPrivacy(text: string): string {
|
|
60
|
+
const r = getPrivacyRules();
|
|
61
|
+
if (Object.keys(r).length === 0 || !text) return text;
|
|
62
|
+
let result = text;
|
|
63
|
+
for (const [from, to] of Object.entries(r)) {
|
|
64
|
+
// 用 split+join 替代 replaceAll,避免正则特殊字符问题
|
|
65
|
+
result = result.split(from).join(to);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
ts,
|
|
21
21
|
} from "./config.ts";
|
|
22
22
|
import { buildProgressCard, getToolEmoji, truncateContent } from "./cards.ts";
|
|
23
|
+
import { simplifyToolUse, simplifyToolResult } from "./simplify.ts";
|
|
23
24
|
import { logTrace } from "./trace.ts";
|
|
24
25
|
import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
|
|
25
26
|
import type { ToolAdapter } from "./adapters/adapter-interface.ts";
|
|
@@ -385,6 +386,7 @@ export function pickFinalReply(state: AccumulatorState): string {
|
|
|
385
386
|
export function accumulateBlockContent(
|
|
386
387
|
block: UnifiedBlock,
|
|
387
388
|
state: AccumulatorState,
|
|
389
|
+
toolCallMap?: Map<string, { name: string; input: unknown }>,
|
|
388
390
|
): void {
|
|
389
391
|
switch (block.type) {
|
|
390
392
|
case "thinking":
|
|
@@ -392,35 +394,55 @@ export function accumulateBlockContent(
|
|
|
392
394
|
state.accumulatedContent += block.thinking;
|
|
393
395
|
break;
|
|
394
396
|
case "tool_use": {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
397
|
+
// 记录 tool_use 信息供后续 tool_result 使用
|
|
398
|
+
if (toolCallMap && block.id) {
|
|
399
|
+
toolCallMap.set(block.id, { name: block.name, input: block.input });
|
|
400
|
+
}
|
|
401
|
+
const simplified = simplifyToolUse(block.name, block.input);
|
|
402
|
+
if (simplified !== null) {
|
|
403
|
+
state.accumulatedContent += `\n\n${simplified}\n`;
|
|
404
|
+
} else {
|
|
405
|
+
const inputStr =
|
|
406
|
+
typeof block.input === "object"
|
|
407
|
+
? JSON.stringify(block.input)
|
|
408
|
+
: String(block.input ?? "");
|
|
409
|
+
const shortInput =
|
|
410
|
+
inputStr.length > 300 ? inputStr.slice(0, 300) + "..." : inputStr;
|
|
411
|
+
state.accumulatedContent +=
|
|
412
|
+
`\n\n${getToolEmoji(block.name)} **${block.name}**\n\`${shortInput}\`\n`;
|
|
413
|
+
}
|
|
403
414
|
break;
|
|
404
415
|
}
|
|
405
416
|
case "tool_result": {
|
|
406
417
|
const toolUseId = block.tool_use_id;
|
|
407
|
-
const resultContent = block.content;
|
|
408
|
-
let resultStr = "";
|
|
409
|
-
if (typeof resultContent === "string") {
|
|
410
|
-
resultStr = resultContent;
|
|
411
|
-
} else if (Array.isArray(resultContent)) {
|
|
412
|
-
resultStr = resultContent
|
|
413
|
-
.map((c: { type?: string; text?: string }) => c.text ?? "")
|
|
414
|
-
.join("");
|
|
415
|
-
} else if (resultContent) {
|
|
416
|
-
resultStr = JSON.stringify(resultContent);
|
|
417
|
-
}
|
|
418
|
-
const shortResult =
|
|
419
|
-
resultStr.length > 200 ? resultStr.slice(0, 200) + "..." : resultStr;
|
|
420
418
|
const isError = block.is_error;
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
419
|
+
// 查找对应的 tool_use 以获取工具名和输入
|
|
420
|
+
const toolCall = toolCallMap?.get(toolUseId);
|
|
421
|
+
const toolName = toolCall?.name;
|
|
422
|
+
const toolInput = toolCall?.input;
|
|
423
|
+
const simplified = toolName
|
|
424
|
+
? simplifyToolResult(toolName, toolUseId, !!isError, toolInput)
|
|
425
|
+
: null;
|
|
426
|
+
if (simplified !== null) {
|
|
427
|
+
state.accumulatedContent += `${simplified}\n`;
|
|
428
|
+
} else {
|
|
429
|
+
const resultContent = block.content;
|
|
430
|
+
let resultStr = "";
|
|
431
|
+
if (typeof resultContent === "string") {
|
|
432
|
+
resultStr = resultContent;
|
|
433
|
+
} else if (Array.isArray(resultContent)) {
|
|
434
|
+
resultStr = resultContent
|
|
435
|
+
.map((c: { type?: string; text?: string }) => c.text ?? "")
|
|
436
|
+
.join("");
|
|
437
|
+
} else if (resultContent) {
|
|
438
|
+
resultStr = JSON.stringify(resultContent);
|
|
439
|
+
}
|
|
440
|
+
const shortResult =
|
|
441
|
+
resultStr.length > 200 ? resultStr.slice(0, 200) + "..." : resultStr;
|
|
442
|
+
const icon = isError ? "❌" : "✅"; // ❌ : ✅
|
|
443
|
+
state.accumulatedContent +=
|
|
444
|
+
`${icon} *${toolUseId.slice(-6)}*: ${shortResult}\n`;
|
|
445
|
+
}
|
|
424
446
|
break;
|
|
425
447
|
}
|
|
426
448
|
case "redacted_thinking":
|
|
@@ -756,11 +778,12 @@ export async function runAgentSession(
|
|
|
756
778
|
|
|
757
779
|
let lastFileWrite = Date.now();
|
|
758
780
|
const FILE_WRITE_INTERVAL_MS = 2000;
|
|
781
|
+
const toolCallMap = new Map<string, { name: string; input: unknown }>();
|
|
759
782
|
|
|
760
783
|
try {
|
|
761
784
|
for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal)) {
|
|
762
785
|
for (const block of unifiedMsg.blocks) {
|
|
763
|
-
accumulateBlockContent(block, state);
|
|
786
|
+
accumulateBlockContent(block, state, toolCallMap);
|
|
764
787
|
|
|
765
788
|
if (block.type === "compact_boundary" && block.post_tokens) {
|
|
766
789
|
for (const cid of getChatsForSession(sessionId)) {
|
package/src/simplify.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve as resolvePath } from "node:path";
|
|
3
|
+
import { PROJECT_ROOT } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// 消息简化规则 —— 数据驱动,在 simplify.json 中配置
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
interface ToolRule {
|
|
10
|
+
template: string;
|
|
11
|
+
maxLength: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface SimplifyConfig {
|
|
15
|
+
tool_use?: Record<string, ToolRule>;
|
|
16
|
+
tool_result?: Record<string, ToolRule>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let config: SimplifyConfig | null = null;
|
|
20
|
+
let loaded = false;
|
|
21
|
+
|
|
22
|
+
function loadConfig(): SimplifyConfig {
|
|
23
|
+
const filePath = resolvePath(PROJECT_ROOT, "simplify.json");
|
|
24
|
+
if (!existsSync(filePath)) return {};
|
|
25
|
+
try {
|
|
26
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
29
|
+
console.error("[SIMPLIFY] simplify.json 格式错误:应为对象");
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
return parsed as SimplifyConfig;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error(`[SIMPLIFY] 读取 simplify.json 失败: ${(err as Error).message}`);
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getConfig(): SimplifyConfig {
|
|
40
|
+
if (!loaded) {
|
|
41
|
+
config = loadConfig();
|
|
42
|
+
loaded = true;
|
|
43
|
+
}
|
|
44
|
+
return config!;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 热重载 */
|
|
48
|
+
export function reloadSimplifyConfig(): void {
|
|
49
|
+
loaded = false;
|
|
50
|
+
config = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 对模板字符串中的 {field} 占位符做替换。
|
|
55
|
+
* fields 为可用字段映射,extra 是额外的上下文(如 tool_use_id 的 id)。
|
|
56
|
+
*/
|
|
57
|
+
function resolveTemplate(template: string, fields: Record<string, unknown>, extra?: Record<string, string>): string {
|
|
58
|
+
let result = template;
|
|
59
|
+
if (extra) {
|
|
60
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
61
|
+
result = result.split(`{${k}}`).join(v);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
65
|
+
const strVal = typeof v === "string" ? v : JSON.stringify(v);
|
|
66
|
+
result = result.split(`{${k}}`).join(strVal);
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 简化 tool_use 展示。
|
|
73
|
+
* 返回 null 表示无规则,调用方应回退到默认格式化。
|
|
74
|
+
*/
|
|
75
|
+
export function simplifyToolUse(name: string, input: unknown): string | null {
|
|
76
|
+
const cfg = getConfig();
|
|
77
|
+
const rules = cfg.tool_use;
|
|
78
|
+
if (!rules) return null;
|
|
79
|
+
const rule = rules[name];
|
|
80
|
+
if (!rule) return null;
|
|
81
|
+
|
|
82
|
+
const fields = typeof input === "object" && input !== null
|
|
83
|
+
? input as Record<string, unknown>
|
|
84
|
+
: {};
|
|
85
|
+
let result = resolveTemplate(rule.template, fields);
|
|
86
|
+
if (result.length > rule.maxLength) {
|
|
87
|
+
result = result.slice(0, rule.maxLength) + "...";
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 简化 tool_result 展示。
|
|
94
|
+
* 返回 null 表示无规则,调用方应回退到默认格式化。
|
|
95
|
+
* toolCallInput 为对应的 tool_use 输入(可选),用于在 result 模板中引用输入字段。
|
|
96
|
+
*/
|
|
97
|
+
export function simplifyToolResult(
|
|
98
|
+
name: string,
|
|
99
|
+
toolUseId: string,
|
|
100
|
+
isError: boolean,
|
|
101
|
+
toolCallInput?: unknown,
|
|
102
|
+
): string | null {
|
|
103
|
+
const cfg = getConfig();
|
|
104
|
+
const rules = cfg.tool_result;
|
|
105
|
+
if (!rules) return null;
|
|
106
|
+
const rule = rules[name];
|
|
107
|
+
if (!rule) return null;
|
|
108
|
+
|
|
109
|
+
const id = toolUseId.slice(-6);
|
|
110
|
+
const extra = { id };
|
|
111
|
+
const fields = toolCallInput && typeof toolCallInput === "object"
|
|
112
|
+
? toolCallInput as Record<string, unknown>
|
|
113
|
+
: {};
|
|
114
|
+
let result = resolveTemplate(rule.template, fields, extra);
|
|
115
|
+
if (isError) result = "❌ " + result;
|
|
116
|
+
if (result.length > rule.maxLength) {
|
|
117
|
+
result = result.slice(0, rule.maxLength) + "...";
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
package/src/wechat-platform.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
|
24
24
|
import { setupFileLogging } from "./shared.ts";
|
|
25
25
|
import { appendChatLog } from "./config.ts";
|
|
26
26
|
import { cardJsonToPlainText } from "./card-plain-text.ts";
|
|
27
|
+
import { applyPrivacy } from "./privacy.ts";
|
|
27
28
|
|
|
28
29
|
interface TerminalQrRenderer {
|
|
29
30
|
generate(
|
|
@@ -146,6 +147,8 @@ export function createWechatAdapter(
|
|
|
146
147
|
const wire = getWire();
|
|
147
148
|
if (!wire) return false;
|
|
148
149
|
|
|
150
|
+
text = applyPrivacy(text);
|
|
151
|
+
|
|
149
152
|
// 微信 claw 连发限制:统计用户未回复时已连发条数
|
|
150
153
|
const count = (consecutiveSendCount.get(chatId) ?? 0) + 1;
|
|
151
154
|
consecutiveSendCount.set(chatId, count);
|