chatccc 0.2.226 → 0.2.228

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.
Files changed (67) hide show
  1. package/.agents/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  2. package/.claude/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  3. package/.cursor/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  4. package/README.md +90 -90
  5. package/package.json +1 -1
  6. package/src/__tests__/agent-activity.test.ts +76 -76
  7. package/src/__tests__/builtin-chat-session.test.ts +350 -350
  8. package/src/__tests__/builtin-config.test.ts +26 -26
  9. package/src/__tests__/builtin-context.test.ts +163 -163
  10. package/src/__tests__/builtin-file-tools.test.ts +275 -275
  11. package/src/__tests__/builtin-permissions.test.ts +211 -211
  12. package/src/__tests__/builtin-session-select.test.ts +116 -116
  13. package/src/__tests__/builtin-skills.test.ts +252 -141
  14. package/src/__tests__/builtin-web-tools.test.ts +220 -0
  15. package/src/__tests__/card-action-routing.test.ts +18 -18
  16. package/src/__tests__/ccc-adapter.test.ts +136 -136
  17. package/src/__tests__/claude-adapter.test.ts +614 -614
  18. package/src/__tests__/codex-adapter.test.ts +58 -58
  19. package/src/__tests__/codex-raw-stream-log.test.ts +170 -170
  20. package/src/__tests__/cursor-adapter.test.ts +268 -268
  21. package/src/__tests__/feishu-avatar.test.ts +164 -164
  22. package/src/__tests__/feishu-message-ingress.test.ts +138 -138
  23. package/src/__tests__/package-files.test.ts +24 -24
  24. package/src/__tests__/progress-reducer.test.ts +110 -110
  25. package/src/__tests__/response-stall.test.ts +49 -49
  26. package/src/__tests__/sim-platform.test.ts +16 -16
  27. package/src/__tests__/startup-lifecycle.test.ts +231 -231
  28. package/src/__tests__/stop-session.test.ts +34 -34
  29. package/src/__tests__/terminal-renderer.test.ts +247 -247
  30. package/src/__tests__/update-command-guard.test.ts +144 -144
  31. package/src/__tests__/web-ui.test.ts +326 -326
  32. package/src/adapters/adapter-interface.ts +18 -18
  33. package/src/adapters/ccc-adapter.ts +131 -131
  34. package/src/adapters/claude-adapter.ts +620 -620
  35. package/src/adapters/codex-adapter.ts +426 -426
  36. package/src/adapters/cursor-adapter.ts +681 -681
  37. package/src/agent-activity.ts +170 -170
  38. package/src/agent-delegate-task.ts +91 -91
  39. package/src/builtin/cli.ts +61 -2
  40. package/src/builtin/config.ts +84 -84
  41. package/src/builtin/context.ts +323 -323
  42. package/src/builtin/file-log.ts +38 -38
  43. package/src/builtin/file-tools.ts +37 -0
  44. package/src/builtin/index.ts +44 -24
  45. package/src/builtin/proc-tree-kill.ts +61 -61
  46. package/src/builtin/progress/cards-helpers.ts +76 -76
  47. package/src/builtin/progress/reducer.ts +108 -108
  48. package/src/builtin/progress/terminal-renderer.ts +294 -294
  49. package/src/builtin/progress/view.ts +77 -77
  50. package/src/builtin/raw-stream-log.ts +124 -124
  51. package/src/builtin/session-select.ts +48 -48
  52. package/src/builtin/skills.ts +190 -108
  53. package/src/builtin/web-tools.ts +313 -0
  54. package/src/card-action-routing.ts +14 -14
  55. package/src/feishu-api.ts +193 -193
  56. package/src/feishu-message-ingress.ts +195 -195
  57. package/src/index.ts +306 -306
  58. package/src/orchestrator.ts +2388 -2388
  59. package/src/platform-adapter.ts +6 -6
  60. package/src/progress/reducer.ts +108 -108
  61. package/src/progress/terminal-renderer.ts +294 -294
  62. package/src/progress/view.ts +77 -77
  63. package/src/response-stall.ts +28 -28
  64. package/src/session-chat-binding.ts +82 -82
  65. package/src/startup-lifecycle.ts +250 -250
  66. package/src/stream-state.ts +18 -18
  67. package/src/update-command-guard.ts +165 -165
@@ -1,38 +1,38 @@
1
- /**
2
- * file-log.ts — DeepCCC 文件日志(写入 ~/.deepccc/logs/)
3
- *
4
- * 交互渲染模式下渲染器独占终端 stdout:普通 console 日志只写文件、不回显
5
- * 到终端,避免生成过程中任何 console 输出混入过程区块、破坏行数计数
6
- * (导致重绘上移不足、把上方历史内容"吃掉")。
7
- */
8
-
9
- import { appendFileSync, mkdirSync } from "node:fs";
10
- import { homedir } from "node:os";
11
- import { join } from "node:path";
12
-
13
- export function setupFileLogging(logDir: string, prefix: string): { logPath: string } {
14
- mkdirSync(logDir, { recursive: true });
15
- const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
16
- const logPath = join(logDir, `${prefix}-${ts}.log`);
17
- appendFileSync(logPath, "", { flag: "a", encoding: "utf8" });
18
- return { logPath };
19
- }
20
-
21
- /** 默认日志目录:~/.deepccc/logs */
22
- export function defaultLogDir(): string {
23
- return join(homedir(), ".deepccc", "logs");
24
- }
25
-
26
- export function writeLogLine(logPath: string, level: string, args: unknown[]): void {
27
- try {
28
- const text = args
29
- .map((a) =>
30
- typeof a === "string" ? a
31
- : a instanceof Error ? (a.stack ?? a.message)
32
- : JSON.stringify(a))
33
- .join(" ");
34
- appendFileSync(logPath, `[${new Date().toISOString()}] [${level}] ${text}\n`, "utf8");
35
- } catch {
36
- // 日志系统自身失败不影响主流程
37
- }
38
- }
1
+ /**
2
+ * file-log.ts — DeepCCC 文件日志(写入 ~/.deepccc/logs/)
3
+ *
4
+ * 交互渲染模式下渲染器独占终端 stdout:普通 console 日志只写文件、不回显
5
+ * 到终端,避免生成过程中任何 console 输出混入过程区块、破坏行数计数
6
+ * (导致重绘上移不足、把上方历史内容"吃掉")。
7
+ */
8
+
9
+ import { appendFileSync, mkdirSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ export function setupFileLogging(logDir: string, prefix: string): { logPath: string } {
14
+ mkdirSync(logDir, { recursive: true });
15
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
16
+ const logPath = join(logDir, `${prefix}-${ts}.log`);
17
+ appendFileSync(logPath, "", { flag: "a", encoding: "utf8" });
18
+ return { logPath };
19
+ }
20
+
21
+ /** 默认日志目录:~/.deepccc/logs */
22
+ export function defaultLogDir(): string {
23
+ return join(homedir(), ".deepccc", "logs");
24
+ }
25
+
26
+ export function writeLogLine(logPath: string, level: string, args: unknown[]): void {
27
+ try {
28
+ const text = args
29
+ .map((a) =>
30
+ typeof a === "string" ? a
31
+ : a instanceof Error ? (a.stack ?? a.message)
32
+ : JSON.stringify(a))
33
+ .join(" ");
34
+ appendFileSync(logPath, `[${new Date().toISOString()}] [${level}] ${text}\n`, "utf8");
35
+ } catch {
36
+ // 日志系统自身失败不影响主流程
37
+ }
38
+ }
@@ -10,6 +10,14 @@ import { jsonSchema, tool, type ToolSet } from "ai";
10
10
 
11
11
  import { isDangerousCommand, type PermissionGate, type PermissionRequest } from "./permissions.js";
12
12
  import { killProcessTree } from "./proc-tree-kill.js";
13
+ import {
14
+ webFetchForTool,
15
+ webSearchForTool,
16
+ type WebFetchInput,
17
+ type WebFetchOutput,
18
+ type WebSearchInput,
19
+ type WebSearchOutput,
20
+ } from "./web-tools.js";
13
21
 
14
22
  const MAX_READ_BYTES = 1024 * 1024;
15
23
  const MAX_LIST_ENTRIES = 200;
@@ -1403,5 +1411,34 @@ export function createBuiltinFileTools(
1403
1411
  return applyPatchForTool(cwd, input);
1404
1412
  },
1405
1413
  }),
1414
+ // 联网工具:只读外部网络操作,不触碰本地文件系统,无需权限询问
1415
+ websearch: tool<WebSearchInput, WebSearchOutput>({
1416
+ description:
1417
+ "Search the web (DuckDuckGo, no API key) and return matching titles, URLs, and snippets. Use when you need current or external information not available locally, e.g. latest docs, news, or package versions.",
1418
+ inputSchema: jsonSchema<WebSearchInput>({
1419
+ type: "object",
1420
+ additionalProperties: false,
1421
+ properties: {
1422
+ query: { type: "string", description: "Search query." },
1423
+ maxResults: { type: "number", description: "Optional result count, default 5, capped at 10." },
1424
+ },
1425
+ required: ["query"],
1426
+ }),
1427
+ execute: (input, options) => webSearchForTool(input, { abortSignal: options.abortSignal }),
1428
+ }),
1429
+ webfetch: tool<WebFetchInput, WebFetchOutput>({
1430
+ description:
1431
+ "Fetch a URL and return its readable text content (HTML stripped, truncated). Use for documentation pages, articles, or API docs. Only http/https URLs are allowed.",
1432
+ inputSchema: jsonSchema<WebFetchInput>({
1433
+ type: "object",
1434
+ additionalProperties: false,
1435
+ properties: {
1436
+ url: { type: "string", description: "http/https URL to fetch." },
1437
+ maxChars: { type: "number", description: "Optional text length cap, default 10000, capped at 100000." },
1438
+ },
1439
+ required: ["url"],
1440
+ }),
1441
+ execute: (input, options) => webFetchForTool(input, { abortSignal: options.abortSignal }),
1442
+ }),
1406
1443
  };
1407
1444
  }
@@ -21,7 +21,13 @@ import {
21
21
  } from "./context.js";
22
22
  import { createBuiltinFileTools } from "./file-tools.js";
23
23
  import { PermissionGate, type PermissionMode, type PermissionResolver } from "./permissions.js";
24
- import { buildDefaultSkillDirs, buildSkillsIndexPrompt, scanSkillsDirs } from "./skills.js";
24
+ import {
25
+ buildDefaultSkillDirs,
26
+ buildSkillsIndexPrompt,
27
+ scanSkillsDirs,
28
+ type BuiltinSkill,
29
+ type SkillDirSpec,
30
+ } from "./skills.js";
25
31
  import { applyPrivacy, applyPrivacyToJson } from "./privacy.js";
26
32
 
27
33
  // ---------------------------------------------------------------------------
@@ -128,8 +134,9 @@ export interface ChatSessionOptions {
128
134
  /** Optional tool-step limit. Leave unset for no step limit. */
129
135
  maxSteps?: number;
130
136
  /**
131
- * Codex-style skill directories (<dir>/<name>/SKILL.md).
132
- * Defaults to ~/.codex/skills, ~/.agents/skills, <cwd>/.codex/skills.
137
+ * Custom skill directories (<dir>/<name>/SKILL.md). When set, these are
138
+ * scanned with the highest priority (deepccc source). Defaults to the
139
+ * combined Claude/Codex/Cursor/DeepCCC directories (see buildDefaultSkillDirs).
133
140
  */
134
141
  skillsDirs?: string[];
135
142
  /**
@@ -170,12 +177,15 @@ interface ChatMessage {
170
177
 
171
178
  export class ChatSession {
172
179
  private model: any;
173
- private systemPrompt: string;
174
180
  private cwd: string;
175
181
  private context: BuiltinContextManager;
176
182
  private maxSteps?: number;
177
183
  private effort: string;
178
184
  private permissionGate: PermissionGate;
185
+ private skillDirs: SkillDirSpec[];
186
+ private customSystemPrompt: string;
187
+ /** 最近一次 chat() 使用的 system prompt(供 history 等读取) */
188
+ private systemPrompt = "";
179
189
 
180
190
  constructor(
181
191
  overrides: ChatSessionConfig = {},
@@ -200,25 +210,12 @@ export class ChatSession {
200
210
  this.model = provider(modelId);
201
211
  this.cwd = options.cwd ?? process.cwd();
202
212
  this.maxSteps = normalizeMaxSteps(options.maxSteps);
203
-
204
- // 构建系统提示词
205
- const systemContent = [SYSTEM_PROMPT];
206
- const projectInstructions = readProjectInstructionFiles(this.cwd);
207
- if (projectInstructions) {
208
- systemContent.push("", projectInstructions);
209
- }
210
- // Codex-style skills 索引注入(name + description + 路径,模型按需 read_file 全文)
211
- const skills = scanSkillsDirs(options.skillsDirs ?? buildDefaultSkillDirs(this.cwd));
212
- const skillsPrompt = buildSkillsIndexPrompt(skills);
213
- if (skillsPrompt) {
214
- systemContent.push("", skillsPrompt);
215
- }
216
- if (options.systemPrompt) {
217
- systemContent.push("", options.systemPrompt);
218
- }
219
- systemContent.push("", buildRuntimeWorkspacePrompt(this.cwd));
220
-
221
- this.systemPrompt = systemContent.join("\n");
213
+ this.customSystemPrompt = options.systemPrompt ?? "";
214
+ // 技能目录在构造时确定;技能内容在每次 chat() 前重新扫描(mtime 热加载),
215
+ // 因此创建/修改技能后下一次对话自动生效,无需重启。
216
+ this.skillDirs =
217
+ options.skillsDirs?.map((d) => ({ dir: d, source: "deepccc" as const, scope: "project" as const })) ??
218
+ buildDefaultSkillDirs(this.cwd);
222
219
  this.context = new BuiltinContextManager({
223
220
  persist: options.persist ?? false,
224
221
  contextDir: options.contextDir,
@@ -233,6 +230,24 @@ export class ChatSession {
233
230
  );
234
231
  }
235
232
 
233
+ /** 组装系统提示词:固定规则 + 项目指令 + 技能索引 + 用户补充 + 运行时上下文 */
234
+ private buildSystemPrompt(skills: BuiltinSkill[]): string {
235
+ const systemContent = [SYSTEM_PROMPT];
236
+ const projectInstructions = readProjectInstructionFiles(this.cwd);
237
+ if (projectInstructions) {
238
+ systemContent.push("", projectInstructions);
239
+ }
240
+ const skillsPrompt = buildSkillsIndexPrompt(skills);
241
+ if (skillsPrompt) {
242
+ systemContent.push("", skillsPrompt);
243
+ }
244
+ if (this.customSystemPrompt) {
245
+ systemContent.push("", this.customSystemPrompt);
246
+ }
247
+ systemContent.push("", buildRuntimeWorkspacePrompt(this.cwd));
248
+ return systemContent.join("\n");
249
+ }
250
+
236
251
  async *chat(
237
252
  userMessage: string,
238
253
  signal?: AbortSignal,
@@ -267,9 +282,14 @@ export class ChatSession {
267
282
 
268
283
  const toolContext: string[] = [];
269
284
  const maxSteps = this.maxSteps;
285
+ // 每次对话前重新扫描技能索引(并行 + mtime 缓存,开销极小):
286
+ // 新技能/修改的技能在下一次对话自动生效(热加载)。
287
+ const skills = await scanSkillsDirs(this.skillDirs);
288
+ const system = this.buildSystemPrompt(skills);
289
+ this.systemPrompt = system;
270
290
  const result = streamText({
271
291
  model: this.model,
272
- system: this.systemPrompt,
292
+ system,
273
293
  messages: this.context.buildModelMessages() as any,
274
294
  tools: createBuiltinFileTools(this.cwd, { permissionGate: this.permissionGate }),
275
295
  stopWhen: maxSteps !== undefined ? stepCountIs(maxSteps) : isLoopFinished(),
@@ -1,61 +1,61 @@
1
- import { spawn } from "node:child_process";
2
-
3
- /**
4
- * Best-effort process-tree termination.
5
- *
6
- * Commands are spawned through a platform shell, so the pid we get is often
7
- * the outer shell process. Killing only that process can leave the real child
8
- * command running. This helper targets the whole process tree on Windows and
9
- * the process group on POSIX when possible.
10
- */
11
- export async function killProcessTree(pid: number | undefined): Promise<void> {
12
- if (pid == null || !Number.isFinite(pid) || pid <= 0) return;
13
- if (process.platform === "win32") {
14
- await killWindowsTree(pid);
15
- return;
16
- }
17
- await killPosixTree(pid);
18
- }
19
-
20
- function killWindowsTree(pid: number): Promise<void> {
21
- return new Promise<void>((resolve) => {
22
- let resolved = false;
23
- const done = () => {
24
- if (resolved) return;
25
- resolved = true;
26
- resolve();
27
- };
28
-
29
- try {
30
- const proc = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
31
- stdio: "ignore",
32
- windowsHide: true,
33
- });
34
- proc.once("error", (err) => {
35
- console.warn(`[killProcessTree] taskkill spawn error for pid=${pid}: ${(err as Error).message}`);
36
- done();
37
- });
38
- proc.once("close", () => { done(); });
39
- setTimeout(done, 3000).unref();
40
- } catch (err) {
41
- console.warn(`[killProcessTree] taskkill failed for pid=${pid}: ${(err as Error).message}`);
42
- done();
43
- }
44
- });
45
- }
46
-
47
- async function killPosixTree(pid: number): Promise<void> {
48
- trySignal(-pid, "SIGTERM");
49
- trySignal(pid, "SIGTERM");
50
- await new Promise((resolve) => setTimeout(resolve, 1000));
51
- trySignal(-pid, "SIGKILL");
52
- trySignal(pid, "SIGKILL");
53
- }
54
-
55
- function trySignal(target: number, signal: NodeJS.Signals): void {
56
- try {
57
- process.kill(target, signal);
58
- } catch {
59
- // Process is already gone or cannot be signaled.
60
- }
61
- }
1
+ import { spawn } from "node:child_process";
2
+
3
+ /**
4
+ * Best-effort process-tree termination.
5
+ *
6
+ * Commands are spawned through a platform shell, so the pid we get is often
7
+ * the outer shell process. Killing only that process can leave the real child
8
+ * command running. This helper targets the whole process tree on Windows and
9
+ * the process group on POSIX when possible.
10
+ */
11
+ export async function killProcessTree(pid: number | undefined): Promise<void> {
12
+ if (pid == null || !Number.isFinite(pid) || pid <= 0) return;
13
+ if (process.platform === "win32") {
14
+ await killWindowsTree(pid);
15
+ return;
16
+ }
17
+ await killPosixTree(pid);
18
+ }
19
+
20
+ function killWindowsTree(pid: number): Promise<void> {
21
+ return new Promise<void>((resolve) => {
22
+ let resolved = false;
23
+ const done = () => {
24
+ if (resolved) return;
25
+ resolved = true;
26
+ resolve();
27
+ };
28
+
29
+ try {
30
+ const proc = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
31
+ stdio: "ignore",
32
+ windowsHide: true,
33
+ });
34
+ proc.once("error", (err) => {
35
+ console.warn(`[killProcessTree] taskkill spawn error for pid=${pid}: ${(err as Error).message}`);
36
+ done();
37
+ });
38
+ proc.once("close", () => { done(); });
39
+ setTimeout(done, 3000).unref();
40
+ } catch (err) {
41
+ console.warn(`[killProcessTree] taskkill failed for pid=${pid}: ${(err as Error).message}`);
42
+ done();
43
+ }
44
+ });
45
+ }
46
+
47
+ async function killPosixTree(pid: number): Promise<void> {
48
+ trySignal(-pid, "SIGTERM");
49
+ trySignal(pid, "SIGTERM");
50
+ await new Promise((resolve) => setTimeout(resolve, 1000));
51
+ trySignal(-pid, "SIGKILL");
52
+ trySignal(pid, "SIGKILL");
53
+ }
54
+
55
+ function trySignal(target: number, signal: NodeJS.Signals): void {
56
+ try {
57
+ process.kill(target, signal);
58
+ } catch {
59
+ // Process is already gone or cannot be signaled.
60
+ }
61
+ }
@@ -1,76 +1,76 @@
1
- /**
2
- * progress/cards-helpers.ts — 从 ChatCCC cards.ts 提取的纯函数(DeepCCC 独立版)
3
- *
4
- * terminal-renderer 需要 getToolEmoji(工具 emoji 映射)和 truncateContent
5
- * (正文按行/字符截断)。DeepCCC 没有飞书卡片模块,这两个函数在此独立存放,
6
- * 与 ChatCCC 保持一致实现,避免引入整张卡片模块。
7
- */
8
-
9
- // 检测 markdown 代码块是否未闭合(``` 出现奇数次)
10
- export function isCodeBlockOpen(text: string): boolean {
11
- const matches = text.match(/```/g);
12
- return matches ? matches.length % 2 !== 0 : false;
13
- }
14
-
15
- export function truncateContent(text: string, maxLines = 20, maxChars = 8000): string {
16
- const lines = text.split("\n");
17
- // 跳过开头空行
18
- let startIdx = 0;
19
- while (startIdx < lines.length && lines[startIdx].trim() === "") {
20
- startIdx++;
21
- }
22
- const effectiveLines = lines.slice(startIdx);
23
- let displayText: string;
24
- if (effectiveLines.length > maxLines) {
25
- const firstLine = effectiveLines[0];
26
- const lastLines = effectiveLines.slice(-(maxLines - 1)).join("\n");
27
- displayText = firstLine + "\n...\n" + lastLines;
28
- } else {
29
- displayText = text;
30
- }
31
-
32
- // 截断后如果代码块未闭合,补上闭合标记,避免后续追加内容时误入代码块
33
- if (isCodeBlockOpen(displayText)) {
34
- displayText += "\n```";
35
- }
36
-
37
- return displayText;
38
- }
39
-
40
- const TOOL_EMOJI_MAP: Record<string, string> = {
41
- Read: "\u{1F4D6}", // 📖
42
- Write: "\u{270D}\u{FE0F}", // ✍️
43
- Edit: "\u{270F}\u{FE0F}", // ✏️
44
- Grep: "\u{1F50E}", // 🔎
45
- Glob: "\u{1F4C2}", // 📂
46
- Bash: "\u{1F5A5}\u{FE0F}", // 🖥️
47
- WebSearch: "\u{1F310}", // 🌐
48
- WebFetch: "\u{1F4E5}", // 📥
49
- TodoWrite: "\u{2705}", // ✅
50
- Agent: "\u{1F916}", // 🤖
51
- NotebookEdit: "\u{1F4D3}", // 📓
52
- AskUserQuestion: "\u{2753}",// ❓
53
- // CCC 内置 Agent 下划线命名(getToolEmoji 会先把下划线转驼峰再查表,这里保留蛇形条目以便直接命中)
54
- read_file: "\u{1F4D6}", // 📖
55
- list_dir: "\u{1F4C2}", // 📂
56
- search_code: "\u{1F50E}", // 🔎
57
- run_command: "\u{1F5A5}\u{FE0F}", // 🖥️
58
- edit_file: "\u{270F}\u{FE0F}", // ✏️
59
- create_file: "\u{270D}\u{FE0F}", // ✍️
60
- delete_file: "\u{1F5D1}\u{FE0F}", // 🗑️
61
- move_file: "\u{1F4E6}", // 📦
62
- apply_patch: "\u{1F4CB}", // 📋
63
- };
64
-
65
- export function getToolEmoji(name: string): string {
66
- return TOOL_EMOJI_MAP[name] ?? TOOL_EMOJI_MAP[normalizeToolName(name)] ?? "\u{1F527}"; // 🔧
67
- }
68
-
69
- /** 把下划线命名转成驼峰(read_file → ReadFile),用于兼容两种命名风格 */
70
- export function normalizeToolName(name: string): string {
71
- return name
72
- .split("_")
73
- .filter((part) => part.length > 0)
74
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
75
- .join("");
76
- }
1
+ /**
2
+ * progress/cards-helpers.ts — 从 ChatCCC cards.ts 提取的纯函数(DeepCCC 独立版)
3
+ *
4
+ * terminal-renderer 需要 getToolEmoji(工具 emoji 映射)和 truncateContent
5
+ * (正文按行/字符截断)。DeepCCC 没有飞书卡片模块,这两个函数在此独立存放,
6
+ * 与 ChatCCC 保持一致实现,避免引入整张卡片模块。
7
+ */
8
+
9
+ // 检测 markdown 代码块是否未闭合(``` 出现奇数次)
10
+ export function isCodeBlockOpen(text: string): boolean {
11
+ const matches = text.match(/```/g);
12
+ return matches ? matches.length % 2 !== 0 : false;
13
+ }
14
+
15
+ export function truncateContent(text: string, maxLines = 20, maxChars = 8000): string {
16
+ const lines = text.split("\n");
17
+ // 跳过开头空行
18
+ let startIdx = 0;
19
+ while (startIdx < lines.length && lines[startIdx].trim() === "") {
20
+ startIdx++;
21
+ }
22
+ const effectiveLines = lines.slice(startIdx);
23
+ let displayText: string;
24
+ if (effectiveLines.length > maxLines) {
25
+ const firstLine = effectiveLines[0];
26
+ const lastLines = effectiveLines.slice(-(maxLines - 1)).join("\n");
27
+ displayText = firstLine + "\n...\n" + lastLines;
28
+ } else {
29
+ displayText = text;
30
+ }
31
+
32
+ // 截断后如果代码块未闭合,补上闭合标记,避免后续追加内容时误入代码块
33
+ if (isCodeBlockOpen(displayText)) {
34
+ displayText += "\n```";
35
+ }
36
+
37
+ return displayText;
38
+ }
39
+
40
+ const TOOL_EMOJI_MAP: Record<string, string> = {
41
+ Read: "\u{1F4D6}", // 📖
42
+ Write: "\u{270D}\u{FE0F}", // ✍️
43
+ Edit: "\u{270F}\u{FE0F}", // ✏️
44
+ Grep: "\u{1F50E}", // 🔎
45
+ Glob: "\u{1F4C2}", // 📂
46
+ Bash: "\u{1F5A5}\u{FE0F}", // 🖥️
47
+ WebSearch: "\u{1F310}", // 🌐
48
+ WebFetch: "\u{1F4E5}", // 📥
49
+ TodoWrite: "\u{2705}", // ✅
50
+ Agent: "\u{1F916}", // 🤖
51
+ NotebookEdit: "\u{1F4D3}", // 📓
52
+ AskUserQuestion: "\u{2753}",// ❓
53
+ // CCC 内置 Agent 下划线命名(getToolEmoji 会先把下划线转驼峰再查表,这里保留蛇形条目以便直接命中)
54
+ read_file: "\u{1F4D6}", // 📖
55
+ list_dir: "\u{1F4C2}", // 📂
56
+ search_code: "\u{1F50E}", // 🔎
57
+ run_command: "\u{1F5A5}\u{FE0F}", // 🖥️
58
+ edit_file: "\u{270F}\u{FE0F}", // ✏️
59
+ create_file: "\u{270D}\u{FE0F}", // ✍️
60
+ delete_file: "\u{1F5D1}\u{FE0F}", // 🗑️
61
+ move_file: "\u{1F4E6}", // 📦
62
+ apply_patch: "\u{1F4CB}", // 📋
63
+ };
64
+
65
+ export function getToolEmoji(name: string): string {
66
+ return TOOL_EMOJI_MAP[name] ?? TOOL_EMOJI_MAP[normalizeToolName(name)] ?? "\u{1F527}"; // 🔧
67
+ }
68
+
69
+ /** 把下划线命名转成驼峰(read_file → ReadFile),用于兼容两种命名风格 */
70
+ export function normalizeToolName(name: string): string {
71
+ return name
72
+ .split("_")
73
+ .filter((part) => part.length > 0)
74
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
75
+ .join("");
76
+ }