chatccc 0.2.231 → 0.2.233

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 (130) 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 -86
  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 +449 -393
  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 +319 -202
  28. package/src/__tests__/builtin-file-tools.test.ts +240 -240
  29. package/src/__tests__/builtin-permissions.test.ts +211 -211
  30. package/src/__tests__/builtin-session-search.test.ts +228 -0
  31. package/src/__tests__/builtin-session-select.test.ts +116 -116
  32. package/src/__tests__/builtin-sigint.test.ts +56 -56
  33. package/src/__tests__/builtin-skills.test.ts +284 -284
  34. package/src/__tests__/builtin-web-tools.test.ts +220 -220
  35. package/src/__tests__/card-action-routing.test.ts +18 -18
  36. package/src/__tests__/cardkit.test.ts +60 -60
  37. package/src/__tests__/ccc-adapter.test.ts +170 -170
  38. package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
  39. package/src/__tests__/chatgpt-subscription.test.ts +135 -135
  40. package/src/__tests__/chrome-devtools-guard.test.ts +165 -165
  41. package/src/__tests__/claude-adapter.test.ts +614 -614
  42. package/src/__tests__/claude-raw-stream-log.test.ts +87 -87
  43. package/src/__tests__/codex-adapter.test.ts +58 -58
  44. package/src/__tests__/codex-raw-stream-log.test.ts +170 -170
  45. package/src/__tests__/codex-reset-actions.test.ts +146 -146
  46. package/src/__tests__/crash-logging.test.ts +95 -1
  47. package/src/__tests__/cursor-adapter.test.ts +268 -268
  48. package/src/__tests__/feishu-api.test.ts +60 -60
  49. package/src/__tests__/feishu-avatar.test.ts +164 -164
  50. package/src/__tests__/feishu-message-ingress.test.ts +138 -138
  51. package/src/__tests__/feishu-platform.test.ts +22 -22
  52. package/src/__tests__/format-message.test.ts +47 -47
  53. package/src/__tests__/jsonl-stream.test.ts +79 -79
  54. package/src/__tests__/package-files.test.ts +24 -24
  55. package/src/__tests__/privacy.test.ts +198 -198
  56. package/src/__tests__/progress-reducer.test.ts +121 -121
  57. package/src/__tests__/raw-stream-log.test.ts +106 -106
  58. package/src/__tests__/response-stall.test.ts +49 -49
  59. package/src/__tests__/restart.test.ts +55 -10
  60. package/src/__tests__/session.test.ts +199 -199
  61. package/src/__tests__/shared-prefix.test.ts +36 -36
  62. package/src/__tests__/sim-platform.test.ts +16 -16
  63. package/src/__tests__/startup-lifecycle.test.ts +231 -231
  64. package/src/__tests__/stop-session.test.ts +34 -34
  65. package/src/__tests__/stream-state.test.ts +42 -42
  66. package/src/__tests__/terminal-error.test.ts +54 -54
  67. package/src/__tests__/terminal-renderer.test.ts +247 -247
  68. package/src/__tests__/update-command-guard.test.ts +144 -144
  69. package/src/__tests__/web-ui.test.ts +326 -326
  70. package/src/adapters/adapter-interface.ts +33 -33
  71. package/src/adapters/ccc-adapter.ts +141 -141
  72. package/src/adapters/claude-adapter.ts +620 -620
  73. package/src/adapters/claude-session-meta-store.ts +120 -120
  74. package/src/adapters/codex-adapter.ts +426 -426
  75. package/src/adapters/cursor-adapter.ts +681 -681
  76. package/src/adapters/jsonl-stream.ts +157 -157
  77. package/src/adapters/raw-stream-log.ts +124 -124
  78. package/src/adapters/resource-monitor.ts +140 -140
  79. package/src/agent-activity.ts +175 -175
  80. package/src/agent-delegate-task-rpc.ts +153 -153
  81. package/src/agent-delegate-task.ts +91 -91
  82. package/src/agent-reload-config-rpc.ts +34 -34
  83. package/src/agent-stop-stuck.ts +129 -129
  84. package/src/builtin/cli.ts +6 -0
  85. package/src/builtin/config.ts +84 -84
  86. package/src/builtin/context.ts +465 -391
  87. package/src/builtin/file-log.ts +38 -38
  88. package/src/builtin/file-tools.ts +34 -0
  89. package/src/builtin/index.ts +24 -23
  90. package/src/builtin/proc-tree-kill.ts +61 -61
  91. package/src/builtin/progress/cards-helpers.ts +76 -76
  92. package/src/builtin/progress/reducer.ts +113 -113
  93. package/src/builtin/progress/terminal-renderer.ts +294 -294
  94. package/src/builtin/progress/view.ts +77 -77
  95. package/src/builtin/raw-stream-log.ts +124 -124
  96. package/src/builtin/session-search.ts +363 -0
  97. package/src/builtin/session-select.ts +48 -48
  98. package/src/builtin/sigint.ts +50 -50
  99. package/src/builtin/skills.ts +205 -205
  100. package/src/builtin/web-tools.ts +313 -313
  101. package/src/card-action-routing.ts +14 -14
  102. package/src/chatgpt-subscription-rpc.ts +27 -27
  103. package/src/chatgpt-subscription.ts +299 -299
  104. package/src/chrome-devtools-guard.ts +318 -318
  105. package/src/codex-reset-actions.ts +184 -184
  106. package/src/feishu-api.ts +193 -193
  107. package/src/feishu-message-ingress.ts +195 -195
  108. package/src/feishu-platform.ts +20 -20
  109. package/src/format-message.ts +293 -293
  110. package/src/index.ts +6 -1
  111. package/src/litellm-proxy.ts +374 -374
  112. package/src/orchestrator.ts +36 -13
  113. package/src/platform-adapter.ts +6 -6
  114. package/src/privacy.ts +118 -118
  115. package/src/progress/reducer.ts +113 -113
  116. package/src/progress/terminal-renderer.ts +294 -294
  117. package/src/progress/view.ts +77 -77
  118. package/src/response-stall.ts +28 -28
  119. package/src/runtime-reload.ts +34 -34
  120. package/src/session-chat-binding.ts +82 -82
  121. package/src/session-name.ts +8 -8
  122. package/src/session.ts +201 -201
  123. package/src/shared-prefix.ts +29 -29
  124. package/src/shared.ts +44 -0
  125. package/src/sim-platform.ts +20 -20
  126. package/src/startup-lifecycle.ts +250 -250
  127. package/src/stream-state.ts +21 -21
  128. package/src/terminal-error.ts +129 -129
  129. package/src/turn-cards.ts +117 -117
  130. package/src/update-command-guard.ts +165 -165
@@ -5,9 +5,9 @@
5
5
  * 所有 IM 平台操作通过 PlatformAdapter 接口注入,不直接依赖 feishu-platform.ts。
6
6
  */
7
7
 
8
- import { execSync, spawn, type ChildProcess } from "node:child_process";
8
+ import { execSync, spawn, type ChildProcess, type StdioOptions } from "node:child_process";
9
9
  import { readdir, stat } from "node:fs/promises";
10
- import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { join, resolve, dirname } from "node:path";
12
12
  import { homedir } from "node:os";
13
13
 
@@ -27,6 +27,7 @@ import {
27
27
  getAllModelsForTool,
28
28
  getDefaultEffortForTool,
29
29
  getDefaultCwd,
30
+ LOG_DIR,
30
31
  setDefaultCwd,
31
32
  getRecentDirs,
32
33
  addRecentDir,
@@ -815,39 +816,61 @@ export interface RestartSpawnDeps {
815
816
  projectRoot?: string;
816
817
  spawnImpl?: typeof spawn;
817
818
  trace?: typeof appendStartupTrace;
819
+ /** restart 子进程 stderr 的落盘目录;默认 ~/.chatccc/logs */
820
+ restartLogDir?: string;
818
821
  }
819
822
 
820
823
  /**
821
- * spawn 自重启子进程。stdio 第三位用 pipe 收集 stderr(原 "ignore" 会吞掉
822
- * 所有错误,导致失败原因不可见),退出时把收集到的 stderr 写入 startup-trace。
824
+ * spawn 自重启子进程。
825
+ *
826
+ * stderr 不用 pipe 收集:pipe 读端随父进程退出关闭后,子进程(尤其经 tsx
827
+ * 包装)再写 stderr(如飞书 SDK 内部 console.warn)会 EPIPE → uncaughtException
828
+ * → 整个服务崩溃。改为把 stderr 重定向到磁盘日志文件:子进程继承文件句柄,
829
+ * 父进程退出不影响写入,启动诊断同样留痕。若日志文件打开失败,退回 pipe
830
+ * 收集(旧行为),并记录 trace。
823
831
  */
824
832
  export function spawnRestartChild(deps: RestartSpawnDeps = {}): ChildProcess {
825
833
  const projectRoot = deps.projectRoot ?? PROJECT_ROOT;
826
834
  const spawnImpl = deps.spawnImpl ?? spawn;
827
835
  const trace = deps.trace ?? appendStartupTrace;
836
+ const restartLogDir = deps.restartLogDir ?? LOG_DIR;
828
837
  const { command, args } = buildRestartSpawnSpec(projectRoot);
838
+
839
+ let stderrFd: number | undefined;
840
+ const stdio: StdioOptions = ["ignore", "ignore", "pipe"];
841
+ try {
842
+ mkdirSync(restartLogDir, { recursive: true });
843
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
844
+ const restartLogPath = join(restartLogDir, `restart-${timestamp}.log`);
845
+ stderrFd = openSync(restartLogPath, "a");
846
+ stdio[2] = stderrFd;
847
+ } catch (err) {
848
+ trace("restart: stderr log open failed, falling back to pipe", {
849
+ error: err instanceof Error ? err.message : String(err),
850
+ });
851
+ }
852
+
829
853
  const child = spawnImpl(command, args, {
830
854
  cwd: projectRoot,
831
855
  detached: true,
832
- stdio: ["ignore", "ignore", "pipe"],
856
+ stdio,
833
857
  shell: false,
834
858
  env: createInternalRestartEnv(),
835
859
  });
836
- let stderrBuf = "";
837
- child.stderr?.on("data", (chunk: Buffer) => {
838
- if (stderrBuf.length < 4096) {
839
- stderrBuf += chunk.toString("utf8");
840
- }
841
- });
860
+ // 子进程已继承 stderr 文件句柄;父进程关闭自己的副本,避免"子进程早退、
861
+ // 父进程留下继续服务" fd 泄漏。
862
+ if (stderrFd !== undefined) {
863
+ try { closeSync(stderrFd); } catch { /* ignore */ }
864
+ }
865
+
842
866
  child.on("error", (err) => {
843
- trace("restart: spawn error", { error: err.message, stderr: stderrBuf.slice(0, 2000) });
867
+ trace("restart: spawn error", { error: err.message });
844
868
  });
845
869
  child.on("exit", (code, signal) => {
846
870
  trace("restart: child exit", {
847
871
  childPid: child.pid,
848
872
  code,
849
873
  signal,
850
- stderr: stderrBuf.slice(0, 2000),
851
874
  });
852
875
  });
853
876
  return child;
@@ -8,11 +8,11 @@
8
8
  import type { CursorUsageSummary } from "./cursor-usage.ts";
9
9
  import type { CodexUsageSummary } from "./feishu-api.ts";
10
10
 
11
- export interface ChatAvatarUsageHints {
12
- codexUsage?: CodexUsageSummary | null;
13
- cursorUsage?: CursorUsageSummary | null;
14
- fastMode?: boolean;
15
- }
11
+ export interface ChatAvatarUsageHints {
12
+ codexUsage?: CodexUsageSummary | null;
13
+ cursorUsage?: CursorUsageSummary | null;
14
+ fastMode?: boolean;
15
+ }
16
16
 
17
17
  export interface PlatformAdapter {
18
18
  /** 平台标识,用于区分不同平台的行为(如 wechat、feishu 等) */
@@ -67,4 +67,4 @@ export interface PlatformAdapter {
67
67
 
68
68
  /** 更新已发送的进度展示,sequence 保证有序 */
69
69
  cardUpdate(cardId: string, cardJson: string, sequence: number): Promise<void>;
70
- }
70
+ }
package/src/privacy.ts CHANGED
@@ -1,118 +1,118 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { USER_DATA_DIR } from "./config.ts";
4
-
5
- interface PrivacyRules {
6
- [key: string]: string;
7
- }
8
-
9
- interface PrivacyConfig {
10
- enabled: boolean;
11
- rules: PrivacyRules;
12
- }
13
-
14
- let config: PrivacyConfig | null = null;
15
- let loadedStamp: string | null | undefined;
16
-
17
- function privacyFilePath(): string {
18
- return join(USER_DATA_DIR, "privacy.json");
19
- }
20
-
21
- function privacyFileStamp(): string | null {
22
- const filePath = privacyFilePath();
23
- if (!existsSync(filePath)) return null;
24
- try {
25
- const s = statSync(filePath);
26
- return `${s.mtimeMs}:${s.size}`;
27
- } catch {
28
- return null;
29
- }
30
- }
31
-
32
- function sanitizeRules(raw: Record<string, unknown>): PrivacyRules {
33
- const result: PrivacyRules = {};
34
- for (const [from, to] of Object.entries(raw)) {
35
- if (!from) {
36
- console.error("[PRIVACY] privacy.json rule key cannot be empty, skipped");
37
- continue;
38
- }
39
- if (typeof to !== "string") {
40
- console.error(`[PRIVACY] privacy.json value must be a string, skipped "${from}"`);
41
- continue;
42
- }
43
- result[from] = to;
44
- }
45
- return result;
46
- }
47
-
48
- function normalizeConfig(parsed: Record<string, unknown>): PrivacyConfig {
49
- const hasNewSchema = Object.prototype.hasOwnProperty.call(parsed, "enabled") ||
50
- Object.prototype.hasOwnProperty.call(parsed, "rules");
51
-
52
- if (!hasNewSchema) {
53
- return { enabled: true, rules: sanitizeRules(parsed) };
54
- }
55
-
56
- const enabledRaw = parsed.enabled;
57
- const enabled = enabledRaw === undefined ? true : enabledRaw !== false;
58
- if (enabledRaw !== undefined && typeof enabledRaw !== "boolean") {
59
- console.error("[PRIVACY] privacy.json enabled must be a boolean, using true");
60
- }
61
-
62
- const rulesRaw = parsed.rules;
63
- if (rulesRaw === undefined) return { enabled, rules: {} };
64
- if (typeof rulesRaw !== "object" || rulesRaw === null || Array.isArray(rulesRaw)) {
65
- console.error("[PRIVACY] privacy.json rules must be an object");
66
- return { enabled, rules: {} };
67
- }
68
- return { enabled, rules: sanitizeRules(rulesRaw as Record<string, unknown>) };
69
- }
70
-
71
- function loadConfig(): PrivacyConfig {
72
- const filePath = privacyFilePath();
73
- if (!existsSync(filePath)) {
74
- return { enabled: true, rules: {} };
75
- }
76
- try {
77
- const raw = readFileSync(filePath, "utf-8").replace(/^\uFEFF/, "");
78
- const parsed = JSON.parse(raw);
79
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
80
- console.error("[PRIVACY] privacy.json must be an object");
81
- return { enabled: true, rules: {} };
82
- }
83
- return normalizeConfig(parsed as Record<string, unknown>);
84
- } catch (err) {
85
- console.error(`[PRIVACY] failed to read privacy.json: ${(err as Error).message}`);
86
- return { enabled: true, rules: {} };
87
- }
88
- }
89
-
90
- export function getPrivacyConfig(): PrivacyConfig {
91
- const stamp = privacyFileStamp();
92
- if (!config || stamp !== loadedStamp) {
93
- config = loadConfig();
94
- loadedStamp = stamp;
95
- }
96
- return config;
97
- }
98
-
99
- export function getPrivacyRules(): PrivacyRules {
100
- return getPrivacyConfig().rules;
101
- }
102
-
103
- export function reloadPrivacyRules(): void {
104
- config = null;
105
- loadedStamp = undefined;
106
- }
107
-
108
- export function applyPrivacy(text: string): string {
109
- const { enabled, rules } = getPrivacyConfig();
110
- if (!enabled || Object.keys(rules).length === 0 || !text) return text;
111
-
112
- let result = text;
113
- for (const [from, to] of Object.entries(rules)) {
114
- // Use split+join instead of regex replacement so rule keys stay literal.
115
- result = result.split(from).join(to);
116
- }
117
- return result;
118
- }
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { USER_DATA_DIR } from "./config.ts";
4
+
5
+ interface PrivacyRules {
6
+ [key: string]: string;
7
+ }
8
+
9
+ interface PrivacyConfig {
10
+ enabled: boolean;
11
+ rules: PrivacyRules;
12
+ }
13
+
14
+ let config: PrivacyConfig | null = null;
15
+ let loadedStamp: string | null | undefined;
16
+
17
+ function privacyFilePath(): string {
18
+ return join(USER_DATA_DIR, "privacy.json");
19
+ }
20
+
21
+ function privacyFileStamp(): string | null {
22
+ const filePath = privacyFilePath();
23
+ if (!existsSync(filePath)) return null;
24
+ try {
25
+ const s = statSync(filePath);
26
+ return `${s.mtimeMs}:${s.size}`;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function sanitizeRules(raw: Record<string, unknown>): PrivacyRules {
33
+ const result: PrivacyRules = {};
34
+ for (const [from, to] of Object.entries(raw)) {
35
+ if (!from) {
36
+ console.error("[PRIVACY] privacy.json rule key cannot be empty, skipped");
37
+ continue;
38
+ }
39
+ if (typeof to !== "string") {
40
+ console.error(`[PRIVACY] privacy.json value must be a string, skipped "${from}"`);
41
+ continue;
42
+ }
43
+ result[from] = to;
44
+ }
45
+ return result;
46
+ }
47
+
48
+ function normalizeConfig(parsed: Record<string, unknown>): PrivacyConfig {
49
+ const hasNewSchema = Object.prototype.hasOwnProperty.call(parsed, "enabled") ||
50
+ Object.prototype.hasOwnProperty.call(parsed, "rules");
51
+
52
+ if (!hasNewSchema) {
53
+ return { enabled: true, rules: sanitizeRules(parsed) };
54
+ }
55
+
56
+ const enabledRaw = parsed.enabled;
57
+ const enabled = enabledRaw === undefined ? true : enabledRaw !== false;
58
+ if (enabledRaw !== undefined && typeof enabledRaw !== "boolean") {
59
+ console.error("[PRIVACY] privacy.json enabled must be a boolean, using true");
60
+ }
61
+
62
+ const rulesRaw = parsed.rules;
63
+ if (rulesRaw === undefined) return { enabled, rules: {} };
64
+ if (typeof rulesRaw !== "object" || rulesRaw === null || Array.isArray(rulesRaw)) {
65
+ console.error("[PRIVACY] privacy.json rules must be an object");
66
+ return { enabled, rules: {} };
67
+ }
68
+ return { enabled, rules: sanitizeRules(rulesRaw as Record<string, unknown>) };
69
+ }
70
+
71
+ function loadConfig(): PrivacyConfig {
72
+ const filePath = privacyFilePath();
73
+ if (!existsSync(filePath)) {
74
+ return { enabled: true, rules: {} };
75
+ }
76
+ try {
77
+ const raw = readFileSync(filePath, "utf-8").replace(/^\uFEFF/, "");
78
+ const parsed = JSON.parse(raw);
79
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
80
+ console.error("[PRIVACY] privacy.json must be an object");
81
+ return { enabled: true, rules: {} };
82
+ }
83
+ return normalizeConfig(parsed as Record<string, unknown>);
84
+ } catch (err) {
85
+ console.error(`[PRIVACY] failed to read privacy.json: ${(err as Error).message}`);
86
+ return { enabled: true, rules: {} };
87
+ }
88
+ }
89
+
90
+ export function getPrivacyConfig(): PrivacyConfig {
91
+ const stamp = privacyFileStamp();
92
+ if (!config || stamp !== loadedStamp) {
93
+ config = loadConfig();
94
+ loadedStamp = stamp;
95
+ }
96
+ return config;
97
+ }
98
+
99
+ export function getPrivacyRules(): PrivacyRules {
100
+ return getPrivacyConfig().rules;
101
+ }
102
+
103
+ export function reloadPrivacyRules(): void {
104
+ config = null;
105
+ loadedStamp = undefined;
106
+ }
107
+
108
+ export function applyPrivacy(text: string): string {
109
+ const { enabled, rules } = getPrivacyConfig();
110
+ if (!enabled || Object.keys(rules).length === 0 || !text) return text;
111
+
112
+ let result = text;
113
+ for (const [from, to] of Object.entries(rules)) {
114
+ // Use split+join instead of regex replacement so rule keys stay literal.
115
+ result = result.split(from).join(to);
116
+ }
117
+ return result;
118
+ }
@@ -1,113 +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 "../builtin/index.ts";
11
- import {
12
- withProgressView,
13
- type ProgressToolCall,
14
- type ProgressView,
15
- } from "./view.ts";
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
- }
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 "../builtin/index.ts";
11
+ import {
12
+ withProgressView,
13
+ type ProgressToolCall,
14
+ type ProgressView,
15
+ } from "./view.ts";
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
+ }