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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.53",
3
+ "version": "0.2.55",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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("file_path");
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
- expect(s.accumulatedContent).toContain("...");
696
- // Should be truncated to 300 + "..."
697
- const inputPart = s.accumulatedContent.split("`")[1];
698
- expect(inputPart.length).toBeLessThanOrEqual(304); // 300 + "..."
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
  });
@@ -39,6 +39,8 @@ export interface UnifiedTextFinalBlock {
39
39
 
40
40
  export interface UnifiedToolUseBlock {
41
41
  type: "tool_use";
42
+ /** 工具调用 ID,用于与 tool_result 对应(Claude SDK 会提供,Cursor 旧格式可能无) */
43
+ id?: string;
42
44
  name: string;
43
45
  input: unknown;
44
46
  }
@@ -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
  });
@@ -117,6 +117,7 @@ export function normalizeCursorMessage(
117
117
  } else if (block.type === "tool_use") {
118
118
  blocks.push({
119
119
  type: "tool_use",
120
+ id: block.tool_use_id,
120
121
  name: block.name ?? "unknown",
121
122
  input: block.input,
122
123
  });
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: text }],
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: title, tag: "plain_text" } },
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
- const inputStr =
396
- typeof block.input === "object"
397
- ? JSON.stringify(block.input)
398
- : String(block.input ?? "");
399
- const shortInput =
400
- inputStr.length > 300 ? inputStr.slice(0, 300) + "..." : inputStr;
401
- state.accumulatedContent +=
402
- `\n\n${getToolEmoji(block.name)} **${block.name}**\n\`${shortInput}\`\n`;
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
- const icon = isError ? "❌" : "✅"; // :
422
- state.accumulatedContent +=
423
- `${icon} *${toolUseId.slice(-6)}*: ${shortResult}\n`;
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)) {
@@ -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
+ }
@@ -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);