chatccc 0.2.229 → 0.2.231

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 (120) 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/agent-prompts/claude_specific.md +45 -45
  6. package/agent-prompts/codex_specific.md +2 -2
  7. package/agent-prompts/cursor_specific.md +13 -13
  8. package/bin/cccagent.mjs +17 -17
  9. package/im-skills/feishu-skill/receive-send-file.md +63 -63
  10. package/im-skills/feishu-skill/receive-send-image.md +24 -24
  11. package/im-skills/feishu-skill/skill.md +3 -3
  12. package/im-skills/wechat-file-skill/receive-send-file.md +38 -38
  13. package/im-skills/wechat-file-skill/send-file.mjs +83 -83
  14. package/im-skills/wechat-file-skill/skill.md +10 -10
  15. package/im-skills/wechat-image-skill/skill.md +10 -10
  16. package/im-skills/wechat-video-skill/receive-send-video.md +38 -38
  17. package/im-skills/wechat-video-skill/send-video.mjs +79 -79
  18. package/im-skills/wechat-video-skill/skill.md +10 -10
  19. package/package.json +1 -1
  20. package/scripts/postinstall-sharp-check.mjs +58 -58
  21. package/src/__tests__/agent-activity.test.ts +86 -76
  22. package/src/__tests__/agent-delegate-task-rpc.test.ts +165 -165
  23. package/src/__tests__/agent-reload-config-rpc.test.ts +99 -99
  24. package/src/__tests__/builtin-chat-session.test.ts +65 -1
  25. package/src/__tests__/builtin-cli-json.test.ts +39 -39
  26. package/src/__tests__/builtin-config.test.ts +26 -26
  27. package/src/__tests__/builtin-context.test.ts +202 -163
  28. package/src/__tests__/builtin-permissions.test.ts +211 -211
  29. package/src/__tests__/builtin-session-select.test.ts +116 -116
  30. package/src/__tests__/builtin-sigint.test.ts +56 -56
  31. package/src/__tests__/builtin-web-tools.test.ts +220 -220
  32. package/src/__tests__/card-action-routing.test.ts +18 -18
  33. package/src/__tests__/cardkit.test.ts +60 -60
  34. package/src/__tests__/ccc-adapter.test.ts +170 -136
  35. package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
  36. package/src/__tests__/chatgpt-subscription.test.ts +135 -135
  37. package/src/__tests__/chrome-devtools-guard.test.ts +165 -165
  38. package/src/__tests__/claude-adapter.test.ts +614 -614
  39. package/src/__tests__/claude-raw-stream-log.test.ts +87 -87
  40. package/src/__tests__/codex-adapter.test.ts +58 -58
  41. package/src/__tests__/codex-raw-stream-log.test.ts +170 -170
  42. package/src/__tests__/codex-reset-actions.test.ts +146 -146
  43. package/src/__tests__/cursor-adapter.test.ts +268 -268
  44. package/src/__tests__/feishu-api.test.ts +60 -60
  45. package/src/__tests__/feishu-avatar.test.ts +164 -164
  46. package/src/__tests__/feishu-message-ingress.test.ts +138 -138
  47. package/src/__tests__/feishu-platform.test.ts +22 -22
  48. package/src/__tests__/format-message.test.ts +47 -47
  49. package/src/__tests__/jsonl-stream.test.ts +79 -79
  50. package/src/__tests__/package-files.test.ts +24 -24
  51. package/src/__tests__/privacy.test.ts +198 -198
  52. package/src/__tests__/progress-reducer.test.ts +121 -110
  53. package/src/__tests__/raw-stream-log.test.ts +106 -106
  54. package/src/__tests__/response-stall.test.ts +49 -49
  55. package/src/__tests__/session.test.ts +174 -16
  56. package/src/__tests__/shared-prefix.test.ts +36 -36
  57. package/src/__tests__/sim-platform.test.ts +16 -16
  58. package/src/__tests__/startup-lifecycle.test.ts +231 -231
  59. package/src/__tests__/stop-session.test.ts +34 -34
  60. package/src/__tests__/stream-state.test.ts +42 -42
  61. package/src/__tests__/terminal-error.test.ts +54 -0
  62. package/src/__tests__/terminal-renderer.test.ts +247 -247
  63. package/src/__tests__/update-command-guard.test.ts +144 -144
  64. package/src/__tests__/web-ui.test.ts +326 -326
  65. package/src/adapters/adapter-interface.ts +24 -18
  66. package/src/adapters/ccc-adapter.ts +141 -131
  67. package/src/adapters/claude-adapter.ts +620 -620
  68. package/src/adapters/claude-session-meta-store.ts +120 -120
  69. package/src/adapters/codex-adapter.ts +426 -426
  70. package/src/adapters/cursor-adapter.ts +681 -681
  71. package/src/adapters/jsonl-stream.ts +157 -157
  72. package/src/adapters/raw-stream-log.ts +124 -124
  73. package/src/adapters/resource-monitor.ts +140 -140
  74. package/src/agent-activity.ts +175 -170
  75. package/src/agent-delegate-task-rpc.ts +153 -153
  76. package/src/agent-delegate-task.ts +91 -91
  77. package/src/agent-reload-config-rpc.ts +34 -34
  78. package/src/agent-stop-stuck.ts +129 -129
  79. package/src/builtin/cli.ts +9 -2
  80. package/src/builtin/config.ts +84 -84
  81. package/src/builtin/context.ts +62 -4
  82. package/src/builtin/file-log.ts +38 -38
  83. package/src/builtin/index.ts +83 -19
  84. package/src/builtin/proc-tree-kill.ts +61 -61
  85. package/src/builtin/progress/cards-helpers.ts +76 -76
  86. package/src/builtin/progress/reducer.ts +113 -108
  87. package/src/builtin/progress/terminal-renderer.ts +294 -294
  88. package/src/builtin/progress/view.ts +77 -77
  89. package/src/builtin/raw-stream-log.ts +124 -124
  90. package/src/builtin/session-select.ts +48 -48
  91. package/src/builtin/sigint.ts +50 -50
  92. package/src/builtin/web-tools.ts +313 -313
  93. package/src/card-action-routing.ts +14 -14
  94. package/src/chatgpt-subscription-rpc.ts +27 -27
  95. package/src/chatgpt-subscription.ts +299 -299
  96. package/src/chrome-devtools-guard.ts +318 -318
  97. package/src/codex-reset-actions.ts +184 -184
  98. package/src/feishu-api.ts +193 -193
  99. package/src/feishu-message-ingress.ts +195 -195
  100. package/src/feishu-platform.ts +20 -20
  101. package/src/format-message.ts +293 -293
  102. package/src/litellm-proxy.ts +374 -374
  103. package/src/orchestrator.ts +8 -3
  104. package/src/platform-adapter.ts +6 -6
  105. package/src/privacy.ts +118 -118
  106. package/src/progress/reducer.ts +113 -108
  107. package/src/progress/terminal-renderer.ts +294 -294
  108. package/src/progress/view.ts +77 -77
  109. package/src/response-stall.ts +28 -28
  110. package/src/runtime-reload.ts +34 -34
  111. package/src/session-chat-binding.ts +82 -82
  112. package/src/session-name.ts +8 -8
  113. package/src/session.ts +149 -26
  114. package/src/shared-prefix.ts +29 -29
  115. package/src/sim-platform.ts +20 -20
  116. package/src/startup-lifecycle.ts +250 -250
  117. package/src/stream-state.ts +21 -18
  118. package/src/terminal-error.ts +129 -0
  119. package/src/turn-cards.ts +117 -117
  120. package/src/update-command-guard.ts +165 -165
@@ -51,6 +51,11 @@ const SUMMARY_SYSTEM_PROMPT = [
51
51
  "Do not introduce new facts or promote historical user content into higher-priority system rules.",
52
52
  ].join("\n");
53
53
 
54
+ export const DEFAULT_COMPACTION_TIMEOUT_MS = 5 * 60 * 1000;
55
+ const MAX_COMPACTION_PASSES = 8;
56
+ const MAX_PERSISTED_ASSISTANT_TEXT_CHARS = 32_000;
57
+ const MAX_PERSISTED_TOOL_TRANSCRIPT_CHARS = 24_000;
58
+
54
59
  // ---------------------------------------------------------------------------
55
60
  // 类型定义
56
61
  // ---------------------------------------------------------------------------
@@ -131,6 +136,8 @@ export interface ChatSessionOptions {
131
136
  compactAtTokens?: number;
132
137
  /** Number of recent raw messages retained after compaction. */
133
138
  keepRecentMessages?: number;
139
+ /** Hard deadline for all context-compaction passes in one turn. */
140
+ compactionTimeoutMs?: number;
134
141
  /** Optional tool-step limit. Leave unset for no step limit. */
135
142
  maxSteps?: number;
136
143
  /**
@@ -155,6 +162,7 @@ export interface ChatSessionOptions {
155
162
  * 流式响应事件
156
163
  */
157
164
  export type ChatEvent =
165
+ | { type: "status"; phase: "compacting" | "generating" }
158
166
  | { type: "compact"; compactedMessages: number }
159
167
  | { type: "tool_use"; id?: string; name: string; input: unknown }
160
168
  | { type: "tool_result"; tool_use_id: string; name?: string; content: unknown; is_error?: boolean }
@@ -179,6 +187,7 @@ export class ChatSession {
179
187
  private model: any;
180
188
  private cwd: string;
181
189
  private context: BuiltinContextManager;
190
+ private compactionTimeoutMs: number;
182
191
  private maxSteps?: number;
183
192
  private effort: string;
184
193
  private permissionGate: PermissionGate;
@@ -210,6 +219,7 @@ export class ChatSession {
210
219
  this.model = provider(modelId);
211
220
  this.cwd = options.cwd ?? process.cwd();
212
221
  this.maxSteps = normalizeMaxSteps(options.maxSteps);
222
+ this.compactionTimeoutMs = Math.max(1, options.compactionTimeoutMs ?? DEFAULT_COMPACTION_TIMEOUT_MS);
213
223
  this.customSystemPrompt = options.systemPrompt ?? "";
214
224
  // 技能目录在构造时确定;技能内容在每次 chat() 前重新扫描(mtime 热加载),
215
225
  // 因此创建/修改技能后下一次对话自动生效,无需重启。
@@ -265,10 +275,14 @@ export class ChatSession {
265
275
  let completed = false;
266
276
 
267
277
  try {
268
- const compactedMessages = await this.compactIfNeeded(signal);
269
- if (compactedMessages > 0) {
270
- yield { type: "compact", compactedMessages };
278
+ if (this.context.planCompaction()) {
279
+ yield { type: "status", phase: "compacting" };
280
+ const compactedMessages = await this.compactIfNeeded(signal);
281
+ if (compactedMessages > 0) {
282
+ yield { type: "compact", compactedMessages };
283
+ }
271
284
  }
285
+ yield { type: "status", phase: "generating" };
272
286
 
273
287
  const rawLogConfig = appConfig.rawStreamLogs;
274
288
  try {
@@ -349,9 +363,18 @@ export class ChatSession {
349
363
  }
350
364
  completed = true;
351
365
 
366
+ const persistedAssistantText = truncateMiddle(
367
+ fullText,
368
+ MAX_PERSISTED_ASSISTANT_TEXT_CHARS,
369
+ "...[assistant response truncated in context]...",
370
+ );
352
371
  const persistedText = toolContext.length > 0
353
- ? `${fullText}\n\n[Tool transcript]\n${toolContext.join("\n")}`
354
- : fullText;
372
+ ? `${persistedAssistantText}\n\n[Tool transcript]\n${truncateMiddle(
373
+ toolContext.join("\n"),
374
+ MAX_PERSISTED_TOOL_TRANSCRIPT_CHARS,
375
+ "...[tool transcript truncated]...",
376
+ )}`
377
+ : persistedAssistantText;
355
378
  this.context.appendMessage({ role: "assistant", content: persistedText });
356
379
  yield { type: "done", text: safeAccumulated };
357
380
  } catch (err) {
@@ -402,22 +425,49 @@ export class ChatSession {
402
425
  }
403
426
 
404
427
  private async compactIfNeeded(signal?: AbortSignal): Promise<number> {
405
- const plan = this.context.planCompaction();
406
- if (!plan) return 0;
407
-
408
- const result = await generateText({
409
- model: this.model,
410
- system: SUMMARY_SYSTEM_PROMPT,
411
- messages: [{ role: "user", content: buildSummaryPrompt(plan) }],
412
- abortSignal: signal,
413
- // 温度 0:相同输入尽量产出相同摘要,避免压缩后上下文前缀随机漂移破坏缓存
414
- temperature: 0,
415
- });
428
+ if (!this.context.planCompaction()) return 0;
429
+
430
+ const timeoutController = new AbortController();
431
+ const timeout = setTimeout(() => timeoutController.abort(), this.compactionTimeoutMs);
432
+ timeout.unref?.();
433
+ const compactionSignal = signal
434
+ ? AbortSignal.any([signal, timeoutController.signal])
435
+ : timeoutController.signal;
436
+ let compactedMessages = 0;
416
437
 
417
- if (!result.text.trim()) return 0;
438
+ try {
439
+ for (let pass = 0; pass < MAX_COMPACTION_PASSES; pass += 1) {
440
+ const plan = this.context.planCompaction();
441
+ if (!plan) return compactedMessages;
442
+
443
+ const result = await generateText({
444
+ model: this.model,
445
+ system: SUMMARY_SYSTEM_PROMPT,
446
+ messages: [{ role: "user", content: buildSummaryPrompt(plan) }],
447
+ abortSignal: compactionSignal,
448
+ temperature: 0,
449
+ });
418
450
 
419
- this.context.applyCompaction(result.text, plan);
420
- return plan.oldMessages.length;
451
+ if (!result.text.trim()) {
452
+ throw new Error("Context compaction returned an empty summary");
453
+ }
454
+
455
+ this.context.applyCompaction(result.text, plan);
456
+ compactedMessages += plan.oldMessages.length;
457
+ }
458
+
459
+ if (this.context.planCompaction()) {
460
+ throw new Error(`Context remains above its token budget after ${MAX_COMPACTION_PASSES} compaction passes`);
461
+ }
462
+ return compactedMessages;
463
+ } catch (error) {
464
+ if (timeoutController.signal.aborted && !signal?.aborted) {
465
+ throw new Error(`Context compaction timed out after ${formatDuration(this.compactionTimeoutMs)}`);
466
+ }
467
+ throw error;
468
+ } finally {
469
+ clearTimeout(timeout);
470
+ }
421
471
  }
422
472
  }
423
473
 
@@ -459,6 +509,20 @@ function truncateToolContext(value: string): string {
459
509
  return value.length > 8000 ? `${value.slice(0, 8000)}...[truncated]` : value;
460
510
  }
461
511
 
512
+ function truncateMiddle(value: string, maxChars: number, marker: string): string {
513
+ if (value.length <= maxChars) return value;
514
+ const available = Math.max(0, maxChars - marker.length - 2);
515
+ const headChars = Math.ceil(available / 2);
516
+ const tailChars = Math.floor(available / 2);
517
+ return `${value.slice(0, headChars)}\n${marker}\n${value.slice(-tailChars)}`;
518
+ }
519
+
520
+ function formatDuration(ms: number): string {
521
+ if (ms % 60_000 === 0) return `${ms / 60_000} minutes`;
522
+ if (ms % 1_000 === 0) return `${ms / 1_000} seconds`;
523
+ return `${ms} ms`;
524
+ }
525
+
462
526
  function errorMessage(value: unknown): string {
463
527
  return value instanceof Error ? value.message : String(value);
464
528
  }
@@ -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
+ }
@@ -1,108 +1,113 @@
1
- /**
2
- * progress/reducer.ts — ChatEvent 事件流 → ProgressView 的增量合并
3
- *
4
- * 这是飞书与终端共享的唯一一份"事件 → 过程视图"公共逻辑:
5
- * 终端 REPL 直接消费 ChatSession.chat() 的事件流,逐条 reduce;
6
- * 飞书侧 display loop 若未来接入事件流,同样使用本 reducer,保证两端
7
- * 看到的过程状态(正文累积、工具调用、终态判定)完全一致。
8
- */
9
-
10
- import type { ChatEvent } from "../index.js";
11
- import {
12
- withProgressView,
13
- type ProgressToolCall,
14
- type ProgressView,
15
- } from "./view.js";
16
-
17
- /** 工具调用 input 单行摘要(终端折叠行展示用) */
18
- export function summarizeToolInput(input: unknown, maxChars = 120): string {
19
- let raw: string;
20
- if (typeof input === "string") {
21
- raw = input;
22
- } else {
23
- try {
24
- raw = JSON.stringify(input);
25
- } catch {
26
- raw = String(input);
27
- }
28
- }
29
- const oneLine = raw.replace(/\s+/g, " ").trim();
30
- return oneLine.length > maxChars ? oneLine.slice(0, maxChars) + "…" : oneLine;
31
- }
32
-
33
- /** 工具结果单行摘要(成功/失败各取首行) */
34
- export function summarizeToolResult(content: unknown, maxChars = 120): string {
35
- let raw: string;
36
- if (typeof content === "string") {
37
- raw = content;
38
- } else if (content instanceof Error) {
39
- raw = content.message;
40
- } else {
41
- try {
42
- raw = JSON.stringify(content);
43
- } catch {
44
- raw = String(content);
45
- }
46
- }
47
- const firstLine = raw.split("\n")[0] ?? "";
48
- return firstLine.length > maxChars ? firstLine.slice(0, maxChars) + "…" : firstLine;
49
- }
50
-
51
- /**
52
- * 把一条 ChatEvent 合并进 ProgressView,返回新视图(不可变更新)。
53
- * 未识别的/不影响展示的事件(如 compact)返回原视图。
54
- */
55
- export function reduceProgress(prev: ProgressView, event: ChatEvent): ProgressView {
56
- switch (event.type) {
57
- case "text":
58
- // accumulated 是全文累积,直接全量替换,天然幂等
59
- return withProgressView(prev, { text: event.accumulated });
60
-
61
- case "tool_use": {
62
- const tool: ProgressToolCall = {
63
- id: event.id ?? `tool-${prev.tools.length + 1}`,
64
- name: event.name,
65
- status: "running",
66
- detail: summarizeToolInput(event.input),
67
- };
68
- return withProgressView(prev, { tools: [...prev.tools, tool] });
69
- }
70
-
71
- case "tool_result": {
72
- const nextStatus = event.is_error ? ("error" as const) : ("ok" as const);
73
- const summary = summarizeToolResult(event.content);
74
- let matched = false;
75
- const tools = prev.tools.map((t) => {
76
- if (matched || t.id !== event.tool_use_id) return t;
77
- matched = true;
78
- return { ...t, status: nextStatus, summary };
79
- });
80
- if (!matched) {
81
- // id 缺失或失配:兜底更新最后一个 running 工具,避免状态悬挂
82
- const lastRunning = [...prev.tools].reverse().findIndex((t) => t.status === "running");
83
- if (lastRunning >= 0) {
84
- const idx = prev.tools.length - 1 - lastRunning;
85
- const updated = prev.tools.map((t, i) =>
86
- i === idx ? { ...t, status: nextStatus, summary } : t,
87
- );
88
- return withProgressView(prev, { tools: updated });
89
- }
90
- }
91
- return withProgressView(prev, { tools });
92
- }
93
-
94
- case "done":
95
- return withProgressView(prev, {
96
- status: "done",
97
- showStop: false,
98
- text: event.text,
99
- });
100
-
101
- case "error":
102
- return withProgressView(prev, { status: "error", showStop: false });
103
-
104
- case "compact":
105
- // 旧上下文压缩不影响当前过程展示
106
- return prev;
107
- }
108
- }
1
+ /**
2
+ * progress/reducer.ts — ChatEvent 事件流 → ProgressView 的增量合并
3
+ *
4
+ * 这是飞书与终端共享的唯一一份"事件 → 过程视图"公共逻辑:
5
+ * 终端 REPL 直接消费 ChatSession.chat() 的事件流,逐条 reduce;
6
+ * 飞书侧 display loop 若未来接入事件流,同样使用本 reducer,保证两端
7
+ * 看到的过程状态(正文累积、工具调用、终态判定)完全一致。
8
+ */
9
+
10
+ import type { ChatEvent } from "../index.js";
11
+ import {
12
+ withProgressView,
13
+ type ProgressToolCall,
14
+ type ProgressView,
15
+ } from "./view.js";
16
+
17
+ /** 工具调用 input 单行摘要(终端折叠行展示用) */
18
+ export function summarizeToolInput(input: unknown, maxChars = 120): string {
19
+ let raw: string;
20
+ if (typeof input === "string") {
21
+ raw = input;
22
+ } else {
23
+ try {
24
+ raw = JSON.stringify(input);
25
+ } catch {
26
+ raw = String(input);
27
+ }
28
+ }
29
+ const oneLine = raw.replace(/\s+/g, " ").trim();
30
+ return oneLine.length > maxChars ? oneLine.slice(0, maxChars) + "…" : oneLine;
31
+ }
32
+
33
+ /** 工具结果单行摘要(成功/失败各取首行) */
34
+ export function summarizeToolResult(content: unknown, maxChars = 120): string {
35
+ let raw: string;
36
+ if (typeof content === "string") {
37
+ raw = content;
38
+ } else if (content instanceof Error) {
39
+ raw = content.message;
40
+ } else {
41
+ try {
42
+ raw = JSON.stringify(content);
43
+ } catch {
44
+ raw = String(content);
45
+ }
46
+ }
47
+ const firstLine = raw.split("\n")[0] ?? "";
48
+ return firstLine.length > maxChars ? firstLine.slice(0, maxChars) + "…" : firstLine;
49
+ }
50
+
51
+ /**
52
+ * 把一条 ChatEvent 合并进 ProgressView,返回新视图(不可变更新)。
53
+ * 未识别的/不影响展示的事件(如 compact)返回原视图。
54
+ */
55
+ export function reduceProgress(prev: ProgressView, event: ChatEvent): ProgressView {
56
+ switch (event.type) {
57
+ case "status":
58
+ return withProgressView(prev, {
59
+ headerTitle: event.phase === "compacting" ? "压缩上下文中..." : "生成回复中...",
60
+ });
61
+
62
+ case "text":
63
+ // accumulated 是全文累积,直接全量替换,天然幂等
64
+ return withProgressView(prev, { text: event.accumulated });
65
+
66
+ case "tool_use": {
67
+ const tool: ProgressToolCall = {
68
+ id: event.id ?? `tool-${prev.tools.length + 1}`,
69
+ name: event.name,
70
+ status: "running",
71
+ detail: summarizeToolInput(event.input),
72
+ };
73
+ return withProgressView(prev, { tools: [...prev.tools, tool] });
74
+ }
75
+
76
+ case "tool_result": {
77
+ const nextStatus = event.is_error ? ("error" as const) : ("ok" as const);
78
+ const summary = summarizeToolResult(event.content);
79
+ let matched = false;
80
+ const tools = prev.tools.map((t) => {
81
+ if (matched || t.id !== event.tool_use_id) return t;
82
+ matched = true;
83
+ return { ...t, status: nextStatus, summary };
84
+ });
85
+ if (!matched) {
86
+ // id 缺失或失配:兜底更新最后一个 running 工具,避免状态悬挂
87
+ const lastRunning = [...prev.tools].reverse().findIndex((t) => t.status === "running");
88
+ if (lastRunning >= 0) {
89
+ const idx = prev.tools.length - 1 - lastRunning;
90
+ const updated = prev.tools.map((t, i) =>
91
+ i === idx ? { ...t, status: nextStatus, summary } : t,
92
+ );
93
+ return withProgressView(prev, { tools: updated });
94
+ }
95
+ }
96
+ return withProgressView(prev, { tools });
97
+ }
98
+
99
+ case "done":
100
+ return withProgressView(prev, {
101
+ status: "done",
102
+ showStop: false,
103
+ text: event.text,
104
+ });
105
+
106
+ case "error":
107
+ return withProgressView(prev, { status: "error", showStop: false });
108
+
109
+ case "compact":
110
+ // 旧上下文压缩不影响当前过程展示
111
+ return prev;
112
+ }
113
+ }