chatccc 0.2.199 → 0.2.200

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 (90) hide show
  1. package/README.md +1 -1
  2. package/agent-prompts/claude_specific.md +45 -45
  3. package/agent-prompts/codex_specific.md +2 -2
  4. package/agent-prompts/cursor_specific.md +13 -13
  5. package/bin/cccagent.mjs +17 -17
  6. package/config.sample.json +27 -27
  7. package/im-skills/feishu-skill/receive-send-file.md +63 -63
  8. package/im-skills/feishu-skill/receive-send-image.md +24 -24
  9. package/im-skills/feishu-skill/skill.md +3 -3
  10. package/im-skills/wechat-file-skill/receive-send-file.md +38 -38
  11. package/im-skills/wechat-file-skill/send-file.mjs +83 -83
  12. package/im-skills/wechat-file-skill/skill.md +10 -10
  13. package/im-skills/wechat-image-skill/skill.md +10 -10
  14. package/im-skills/wechat-video-skill/receive-send-video.md +38 -38
  15. package/im-skills/wechat-video-skill/send-video.mjs +79 -79
  16. package/im-skills/wechat-video-skill/skill.md +10 -10
  17. package/package.json +1 -1
  18. package/scripts/postinstall-sharp-check.mjs +58 -58
  19. package/src/__tests__/agent-delegate-task-rpc.test.ts +165 -165
  20. package/src/__tests__/agent-reload-config-rpc.test.ts +99 -99
  21. package/src/__tests__/builtin-chat-session.test.ts +277 -277
  22. package/src/__tests__/builtin-cli-json.test.ts +39 -39
  23. package/src/__tests__/builtin-config.test.ts +33 -33
  24. package/src/__tests__/builtin-context.test.ts +163 -163
  25. package/src/__tests__/builtin-file-tools.test.ts +224 -224
  26. package/src/__tests__/builtin-session-select.test.ts +116 -116
  27. package/src/__tests__/builtin-sigint.test.ts +56 -56
  28. package/src/__tests__/card-plain-text.test.ts +5 -5
  29. package/src/__tests__/cardkit.test.ts +60 -60
  30. package/src/__tests__/cards.test.ts +109 -109
  31. package/src/__tests__/ccc-adapter.test.ts +114 -114
  32. package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
  33. package/src/__tests__/chatgpt-subscription.test.ts +135 -135
  34. package/src/__tests__/chrome-devtools-guard.test.ts +165 -165
  35. package/src/__tests__/claude-adapter.test.ts +592 -592
  36. package/src/__tests__/claude-raw-stream-log.test.ts +87 -87
  37. package/src/__tests__/codex-raw-stream-log.test.ts +163 -163
  38. package/src/__tests__/codex-reset-actions.test.ts +146 -146
  39. package/src/__tests__/config-reload.test.ts +10 -10
  40. package/src/__tests__/config-sample.test.ts +18 -18
  41. package/src/__tests__/feishu-api.test.ts +60 -60
  42. package/src/__tests__/feishu-avatar.test.ts +87 -45
  43. package/src/__tests__/feishu-platform.test.ts +22 -22
  44. package/src/__tests__/format-message.test.ts +47 -47
  45. package/src/__tests__/jsonl-stream.test.ts +79 -79
  46. package/src/__tests__/orchestrator.test.ts +227 -202
  47. package/src/__tests__/privacy.test.ts +198 -198
  48. package/src/__tests__/raw-stream-log.test.ts +106 -106
  49. package/src/__tests__/session.test.ts +40 -40
  50. package/src/__tests__/shared-prefix.test.ts +36 -36
  51. package/src/__tests__/sim-platform.test.ts +12 -12
  52. package/src/__tests__/stream-state.test.ts +42 -42
  53. package/src/__tests__/web-ui.test.ts +209 -209
  54. package/src/adapters/ccc-adapter.ts +121 -121
  55. package/src/adapters/claude-adapter.ts +603 -603
  56. package/src/adapters/claude-session-meta-store.ts +120 -120
  57. package/src/adapters/codex-adapter.ts +380 -380
  58. package/src/adapters/jsonl-stream.ts +157 -157
  59. package/src/adapters/raw-stream-log.ts +124 -124
  60. package/src/adapters/resource-monitor.ts +140 -140
  61. package/src/agent-delegate-task-rpc.ts +153 -153
  62. package/src/agent-delegate-task.ts +81 -81
  63. package/src/agent-reload-config-rpc.ts +34 -34
  64. package/src/agent-stop-stuck.ts +129 -129
  65. package/src/builtin/cli.ts +473 -473
  66. package/src/builtin/context.ts +323 -323
  67. package/src/builtin/file-tools.ts +1072 -1072
  68. package/src/builtin/index.ts +404 -404
  69. package/src/builtin/session-select.ts +48 -48
  70. package/src/builtin/sigint.ts +50 -50
  71. package/src/cards.ts +195 -195
  72. package/src/chatgpt-subscription-rpc.ts +27 -27
  73. package/src/chatgpt-subscription.ts +299 -299
  74. package/src/chrome-devtools-guard.ts +318 -318
  75. package/src/codex-reset-actions.ts +184 -184
  76. package/src/config.ts +125 -125
  77. package/src/feishu-api.ts +86 -63
  78. package/src/feishu-platform.ts +20 -20
  79. package/src/format-message.ts +293 -293
  80. package/src/litellm-proxy.ts +374 -374
  81. package/src/orchestrator.ts +183 -183
  82. package/src/privacy.ts +118 -118
  83. package/src/runtime-reload.ts +34 -34
  84. package/src/session-chat-binding.ts +6 -6
  85. package/src/session-name.ts +8 -8
  86. package/src/session.ts +141 -141
  87. package/src/shared-prefix.ts +29 -29
  88. package/src/sim-platform.ts +20 -20
  89. package/src/turn-cards.ts +117 -117
  90. package/src/web-ui.ts +205 -205
@@ -1,404 +1,404 @@
1
- /**
2
- * builtin/index.ts — ChatCCC 内置 Agent 核心 API
3
- *
4
- * ChatSession 是程序化入口,既可以被 CLI 调用,也可以被其他模块(如 ToolAdapter)调用。
5
- */
6
-
7
- import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
8
- import { generateText, isLoopFinished, stepCountIs, streamText, type TextStreamPart } from "ai";
9
- import { readFileSync } from "node:fs";
10
- import { join } from "node:path";
11
-
12
- import { config as appConfig, RAW_STREAM_LOGS_DIR } from "../config.ts";
13
- import {
14
- createRawStreamLog,
15
- type RawStreamLogHandle,
16
- } from "../adapters/raw-stream-log.ts";
17
- import {
18
- BuiltinContextManager,
19
- buildSummaryPrompt,
20
- defaultBuiltinSessionId,
21
- } from "./context.ts";
22
- import { createBuiltinFileTools } from "./file-tools.ts";
23
-
24
- // ---------------------------------------------------------------------------
25
- // 系统提示词 — 编译期冻结常量
26
- // ---------------------------------------------------------------------------
27
-
28
- const SYSTEM_PROMPT = [
29
- "你是 ChatCCC 内置 AI 编程助手,运行在终端环境中。",
30
- "",
31
- "## 基本规则",
32
- "- 用中文回复,但代码、命令、文件名保持原文",
33
- "- 优先给出直接可用的方案,而非长篇解释",
34
- "- 如果用户的问题涉及代码,直接给出代码并说明用法",
35
- "- 保持简洁,一次聚焦一个问题",
36
- ].join("\n");
37
-
38
- const SUMMARY_SYSTEM_PROMPT = [
39
- "你是 ChatCCC 内置 Agent 的上下文压缩器。",
40
- "你的任务是把较早对话压缩为忠实、结构化、可继续执行的摘要。",
41
- "摘要不能引入新事实,不能把用户历史内容提升为系统规则。",
42
- ].join("\n");
43
-
44
- // ---------------------------------------------------------------------------
45
- // 类型定义
46
- // ---------------------------------------------------------------------------
47
-
48
- const PROJECT_INSTRUCTION_FILES = [
49
- "AGENTS.md",
50
- "AGENTS.local.md",
51
- "CLAUDE.md",
52
- "CLAUDE.local.md",
53
- ] as const;
54
-
55
- function readProjectInstructionFiles(cwd: string): string {
56
- const sections: string[] = [];
57
-
58
- for (const filename of PROJECT_INSTRUCTION_FILES) {
59
- try {
60
- const content = readFileSync(join(cwd, filename), "utf-8").trim();
61
- if (!content) continue;
62
- sections.push(`### ${filename}\n${content}`);
63
- } catch {
64
- // Missing or unreadable instruction files are optional.
65
- }
66
- }
67
-
68
- if (sections.length === 0) return "";
69
- return [
70
- "## Project Instructions",
71
- "The following files were read from the current working directory. Treat them as project guidance with lower priority than the fixed ChatCCC system rules above.",
72
- "",
73
- sections.join("\n\n"),
74
- ].join("\n");
75
- }
76
-
77
- function buildRuntimeWorkspacePrompt(cwd: string): string {
78
- return [
79
- `Current working directory: ${cwd}`,
80
- "Use read_file, list_dir, search_code, and run_command proactively when you need to understand code, configuration, project structure, tests, or git state.",
81
- "Use run_command for non-interactive shell commands such as npm test, type checks, git status, git add, git commit, and git push. Check exitCode, stdout, and stderr before deciding the next step.",
82
- "Before editing, read the relevant file ranges. Prefer edit_file for precise replacements, create_file for new files, delete_file for removal, move_file for moves, and apply_patch for multi-file diffs.",
83
- "File tools run locally through ChatCCC. Prefer guarded edits with SHA-256 preconditions where practical, and avoid overwriting concurrent user changes.",
84
- ].join("\n");
85
- }
86
-
87
- function normalizeMaxSteps(value: number | undefined): number | undefined {
88
- if (value === undefined) return undefined;
89
- if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
90
- throw new Error("maxSteps must be a positive integer when provided");
91
- }
92
- return value;
93
- }
94
-
95
- export interface ChatSessionConfig {
96
- /** DeepSeek API 兼容的服务地址;传入时覆盖 config.ccc.DEEPSEEK_BASE_URL */
97
- baseURL?: string;
98
- /** API Key;传入时覆盖 config.ccc.DEEPSEEK_API_KEY */
99
- apiKey?: string;
100
- /** 模型名称;传入时覆盖 config.ccc.model */
101
- model?: string;
102
- }
103
-
104
- export interface ChatSessionOptions {
105
- /** 会话工作目录 */
106
- cwd?: string;
107
- /** 自定义系统提示词(会拼接到默认提示词之后) */
108
- systemPrompt?: string;
109
- /** 是否把 ccc 上下文持久化到磁盘;CLI 默认开启,程序化调用默认关闭 */
110
- persist?: boolean;
111
- /** 持久化目录;默认 ~/.chatccc/builtin/sessions */
112
- contextDir?: string;
113
- /** 持久化会话 ID;留空时按 cwd / process.cwd() 生成 */
114
- sessionId?: string;
115
- /** 粗略 token 超过该阈值时压缩旧上下文 */
116
- compactAtTokens?: number;
117
- /** 压缩时保留的最近原始消息数 */
118
- keepRecentMessages?: number;
119
- /** Optional tool-step limit. Leave unset for no step limit. */
120
- maxSteps?: number;
121
- }
122
-
123
- /**
124
- * 流式响应事件
125
- */
126
- export type ChatEvent =
127
- | { type: "compact"; compactedMessages: number }
128
- | { type: "tool_use"; id?: string; name: string; input: unknown }
129
- | { type: "tool_result"; tool_use_id: string; name?: string; content: unknown; is_error?: boolean }
130
- | { type: "text"; text: string; accumulated: string }
131
- | { type: "done"; text: string }
132
- | { type: "error"; message: string };
133
-
134
- // ---------------------------------------------------------------------------
135
- // ChatSession
136
- // ---------------------------------------------------------------------------
137
-
138
- /** 消息角色 */
139
- type MessageRole = "system" | "user" | "assistant" | "tool";
140
-
141
- /** 内部消息类型 */
142
- interface ChatMessage {
143
- role: MessageRole;
144
- content: string;
145
- }
146
-
147
- export class ChatSession {
148
- private model: any;
149
- private systemPrompt: string;
150
- private cwd: string;
151
- private context: BuiltinContextManager;
152
- private maxSteps?: number;
153
-
154
- constructor(
155
- overrides: ChatSessionConfig = {},
156
- options: ChatSessionOptions = {},
157
- ) {
158
- const apiKey = overrides.apiKey ?? appConfig.ccc.DEEPSEEK_API_KEY;
159
- if (!apiKey) {
160
- throw new Error(
161
- "ccc.DEEPSEEK_API_KEY 未设置。请在 config.json 中配置,或通过 --api-key 临时传入",
162
- );
163
- }
164
-
165
- const baseURL = overrides.baseURL ?? appConfig.ccc.DEEPSEEK_BASE_URL;
166
- const modelId = overrides.model ?? appConfig.ccc.model;
167
-
168
- const provider = createOpenAICompatible({
169
- name: "deepseek",
170
- baseURL,
171
- apiKey,
172
- });
173
- this.model = provider(modelId);
174
- this.cwd = options.cwd ?? process.cwd();
175
- this.maxSteps = normalizeMaxSteps(options.maxSteps);
176
-
177
- // 构建系统提示词
178
- const systemContent = [SYSTEM_PROMPT];
179
- const projectInstructions = readProjectInstructionFiles(this.cwd);
180
- if (projectInstructions) {
181
- systemContent.push("", projectInstructions);
182
- }
183
- if (options.systemPrompt) {
184
- systemContent.push("", options.systemPrompt);
185
- }
186
- systemContent.push("", buildRuntimeWorkspacePrompt(this.cwd));
187
-
188
- this.systemPrompt = systemContent.join("\n");
189
- this.context = new BuiltinContextManager({
190
- persist: options.persist ?? false,
191
- contextDir: options.contextDir,
192
- sessionId: options.sessionId ?? defaultBuiltinSessionId(this.cwd),
193
- cwd: this.cwd,
194
- compactAtTokens: options.compactAtTokens,
195
- keepRecentMessages: options.keepRecentMessages,
196
- });
197
- }
198
-
199
- /**
200
- * 发送用户消息,返回异步可迭代的文本流。
201
- *
202
- * 使用方式:
203
- * ```typescript
204
- * const session = new ChatSession();
205
- * for await (const event of session.chat("帮我看看 package.json")) {
206
- * if (event.type === "text") process.stdout.write(event.text);
207
- * }
208
- * console.log("完成");
209
- * ```
210
- */
211
- async *chat(
212
- userMessage: string,
213
- signal?: AbortSignal,
214
- ): AsyncIterable<ChatEvent> {
215
- this.context.appendMessage({ role: "user", content: userMessage });
216
-
217
- let fullText = "";
218
- let rawLog: RawStreamLogHandle | null = null;
219
- let completed = false;
220
-
221
- try {
222
- const compactedMessages = await this.compactIfNeeded(signal);
223
- if (compactedMessages > 0) {
224
- yield { type: "compact", compactedMessages };
225
- }
226
-
227
- const rawLogConfig = appConfig.rawStreamLogs.ccc;
228
- try {
229
- rawLog = await createRawStreamLog({
230
- enabled: rawLogConfig.enabled,
231
- rootDir: RAW_STREAM_LOGS_DIR,
232
- tool: "ccc",
233
- sessionId: this.context.sessionId,
234
- label: "prompt",
235
- maxBytesPerTurn: rawLogConfig.maxBytesPerTurn,
236
- retentionDays: rawLogConfig.retentionDays,
237
- });
238
- } catch (err) {
239
- console.error(`[CCC raw stream log] create failed: ${errorMessage(err)}`);
240
- }
241
-
242
- const toolContext: string[] = [];
243
- const maxSteps = this.maxSteps;
244
- const result = streamText({
245
- model: this.model,
246
- system: this.systemPrompt,
247
- messages: this.context.buildModelMessages() as any,
248
- tools: createBuiltinFileTools(this.cwd),
249
- stopWhen: maxSteps !== undefined ? stepCountIs(maxSteps) : isLoopFinished(),
250
- abortSignal: signal,
251
- });
252
-
253
- const stream = result.fullStream ?? textStreamToFullStream(result.textStream);
254
- for await (const part of stream as AsyncIterable<TextStreamPart<any>>) {
255
- rawLog?.writeLine(safeRawStreamJson(part));
256
- if (part.type === "text-delta") {
257
- fullText += part.text;
258
- yield { type: "text", text: part.text, accumulated: fullText };
259
- } else if (part.type === "tool-call") {
260
- toolContext.push(`tool_call ${part.toolName}: ${safeJson(part.input)}`);
261
- yield {
262
- type: "tool_use",
263
- id: part.toolCallId,
264
- name: part.toolName,
265
- input: part.input,
266
- };
267
- } else if (part.type === "tool-result") {
268
- toolContext.push(`tool_result ${part.toolName}: ${truncateToolContext(safeJson(part.output))}`);
269
- yield {
270
- type: "tool_result",
271
- tool_use_id: part.toolCallId,
272
- name: part.toolName,
273
- content: part.output,
274
- is_error: false,
275
- };
276
- } else if (part.type === "tool-error") {
277
- const message = errorMessage(part.error);
278
- toolContext.push(`tool_error ${part.toolName}: ${message}`);
279
- yield {
280
- type: "tool_result",
281
- tool_use_id: part.toolCallId,
282
- name: part.toolName,
283
- content: message,
284
- is_error: true,
285
- };
286
- } else if (part.type === "error") {
287
- const message = errorMessage(part.error);
288
- yield { type: "error", message };
289
- throw new Error(message);
290
- }
291
- }
292
- completed = true;
293
-
294
- const persistedText = toolContext.length > 0
295
- ? `${fullText}\n\n[Tool transcript]\n${toolContext.join("\n")}`
296
- : fullText;
297
- this.context.appendMessage({ role: "assistant", content: persistedText });
298
- yield { type: "done", text: fullText };
299
- } catch (err) {
300
- const message = err instanceof Error ? err.message : String(err);
301
- if ((err as Error).name === "AbortError" || signal?.aborted) {
302
- // 被中断时,不保存不完整的助手消息
303
- if (fullText) {
304
- this.context.appendMessage({ role: "assistant", content: fullText + "\n[已中断]" });
305
- }
306
- yield { type: "done", text: fullText };
307
- return;
308
- }
309
- yield { type: "error", message };
310
- throw err;
311
- } finally {
312
- const rawLogConfig = appConfig.rawStreamLogs.ccc;
313
- await rawLog?.close({
314
- keep: rawLogConfig.keepCompleted || signal?.aborted === true || !completed,
315
- });
316
- }
317
- }
318
-
319
- /** 返回当前的会话历史(只读) */
320
- get history(): ReadonlyArray<ChatMessage> {
321
- const history: ChatMessage[] = [{ role: "system", content: this.systemPrompt }];
322
- if (this.context.summary) {
323
- history.push({
324
- role: "system",
325
- content: [
326
- "较早对话摘要:",
327
- "",
328
- this.context.summary,
329
- ].join("\n"),
330
- });
331
- }
332
- history.push(...this.context.messages as ChatMessage[]);
333
- return history;
334
- }
335
-
336
- /** 返回当前轮数(不含 system 消息) */
337
- get turnCount(): number {
338
- return this.context.totalMessages;
339
- }
340
-
341
- /** 清空会话历史,保留 system 消息 */
342
- reset(): void {
343
- this.context.reset();
344
- }
345
-
346
- private async compactIfNeeded(signal?: AbortSignal): Promise<number> {
347
- const plan = this.context.planCompaction();
348
- if (!plan) return 0;
349
-
350
- const result = await generateText({
351
- model: this.model,
352
- system: SUMMARY_SYSTEM_PROMPT,
353
- messages: [{ role: "user", content: buildSummaryPrompt(plan) }],
354
- abortSignal: signal,
355
- });
356
-
357
- if (!result.text.trim()) return 0;
358
-
359
- this.context.applyCompaction(result.text, plan);
360
- return plan.oldMessages.length;
361
- }
362
- }
363
-
364
- async function* textStreamToFullStream(stream: AsyncIterable<string>): AsyncIterable<{ type: "text-delta"; text: string }> {
365
- for await (const text of stream) {
366
- yield { type: "text-delta", text };
367
- }
368
- }
369
-
370
- function safeJson(value: unknown): string {
371
- try {
372
- return JSON.stringify(value);
373
- } catch {
374
- return String(value);
375
- }
376
- }
377
-
378
- function safeRawStreamJson(value: unknown): string {
379
- try {
380
- const serialized = JSON.stringify(value, (_key, nested) => {
381
- if (nested instanceof Error) {
382
- return {
383
- name: nested.name,
384
- message: nested.message,
385
- };
386
- }
387
- return nested;
388
- });
389
- return serialized ?? "null";
390
- } catch (err) {
391
- return JSON.stringify({
392
- type: "chatccc_raw_stream_log_serialize_error",
393
- message: errorMessage(err),
394
- });
395
- }
396
- }
397
-
398
- function truncateToolContext(value: string): string {
399
- return value.length > 8000 ? `${value.slice(0, 8000)}...[truncated]` : value;
400
- }
401
-
402
- function errorMessage(value: unknown): string {
403
- return value instanceof Error ? value.message : String(value);
404
- }
1
+ /**
2
+ * builtin/index.ts — ChatCCC 内置 Agent 核心 API
3
+ *
4
+ * ChatSession 是程序化入口,既可以被 CLI 调用,也可以被其他模块(如 ToolAdapter)调用。
5
+ */
6
+
7
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
8
+ import { generateText, isLoopFinished, stepCountIs, streamText, type TextStreamPart } from "ai";
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { config as appConfig, RAW_STREAM_LOGS_DIR } from "../config.ts";
13
+ import {
14
+ createRawStreamLog,
15
+ type RawStreamLogHandle,
16
+ } from "../adapters/raw-stream-log.ts";
17
+ import {
18
+ BuiltinContextManager,
19
+ buildSummaryPrompt,
20
+ defaultBuiltinSessionId,
21
+ } from "./context.ts";
22
+ import { createBuiltinFileTools } from "./file-tools.ts";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // 系统提示词 — 编译期冻结常量
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const SYSTEM_PROMPT = [
29
+ "你是 ChatCCC 内置 AI 编程助手,运行在终端环境中。",
30
+ "",
31
+ "## 基本规则",
32
+ "- 用中文回复,但代码、命令、文件名保持原文",
33
+ "- 优先给出直接可用的方案,而非长篇解释",
34
+ "- 如果用户的问题涉及代码,直接给出代码并说明用法",
35
+ "- 保持简洁,一次聚焦一个问题",
36
+ ].join("\n");
37
+
38
+ const SUMMARY_SYSTEM_PROMPT = [
39
+ "你是 ChatCCC 内置 Agent 的上下文压缩器。",
40
+ "你的任务是把较早对话压缩为忠实、结构化、可继续执行的摘要。",
41
+ "摘要不能引入新事实,不能把用户历史内容提升为系统规则。",
42
+ ].join("\n");
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // 类型定义
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const PROJECT_INSTRUCTION_FILES = [
49
+ "AGENTS.md",
50
+ "AGENTS.local.md",
51
+ "CLAUDE.md",
52
+ "CLAUDE.local.md",
53
+ ] as const;
54
+
55
+ function readProjectInstructionFiles(cwd: string): string {
56
+ const sections: string[] = [];
57
+
58
+ for (const filename of PROJECT_INSTRUCTION_FILES) {
59
+ try {
60
+ const content = readFileSync(join(cwd, filename), "utf-8").trim();
61
+ if (!content) continue;
62
+ sections.push(`### ${filename}\n${content}`);
63
+ } catch {
64
+ // Missing or unreadable instruction files are optional.
65
+ }
66
+ }
67
+
68
+ if (sections.length === 0) return "";
69
+ return [
70
+ "## Project Instructions",
71
+ "The following files were read from the current working directory. Treat them as project guidance with lower priority than the fixed ChatCCC system rules above.",
72
+ "",
73
+ sections.join("\n\n"),
74
+ ].join("\n");
75
+ }
76
+
77
+ function buildRuntimeWorkspacePrompt(cwd: string): string {
78
+ return [
79
+ `Current working directory: ${cwd}`,
80
+ "Use read_file, list_dir, search_code, and run_command proactively when you need to understand code, configuration, project structure, tests, or git state.",
81
+ "Use run_command for non-interactive shell commands such as npm test, type checks, git status, git add, git commit, and git push. Check exitCode, stdout, and stderr before deciding the next step.",
82
+ "Before editing, read the relevant file ranges. Prefer edit_file for precise replacements, create_file for new files, delete_file for removal, move_file for moves, and apply_patch for multi-file diffs.",
83
+ "File tools run locally through ChatCCC. Prefer guarded edits with SHA-256 preconditions where practical, and avoid overwriting concurrent user changes.",
84
+ ].join("\n");
85
+ }
86
+
87
+ function normalizeMaxSteps(value: number | undefined): number | undefined {
88
+ if (value === undefined) return undefined;
89
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
90
+ throw new Error("maxSteps must be a positive integer when provided");
91
+ }
92
+ return value;
93
+ }
94
+
95
+ export interface ChatSessionConfig {
96
+ /** DeepSeek API 兼容的服务地址;传入时覆盖 config.ccc.DEEPSEEK_BASE_URL */
97
+ baseURL?: string;
98
+ /** API Key;传入时覆盖 config.ccc.DEEPSEEK_API_KEY */
99
+ apiKey?: string;
100
+ /** 模型名称;传入时覆盖 config.ccc.model */
101
+ model?: string;
102
+ }
103
+
104
+ export interface ChatSessionOptions {
105
+ /** 会话工作目录 */
106
+ cwd?: string;
107
+ /** 自定义系统提示词(会拼接到默认提示词之后) */
108
+ systemPrompt?: string;
109
+ /** 是否把 ccc 上下文持久化到磁盘;CLI 默认开启,程序化调用默认关闭 */
110
+ persist?: boolean;
111
+ /** 持久化目录;默认 ~/.chatccc/builtin/sessions */
112
+ contextDir?: string;
113
+ /** 持久化会话 ID;留空时按 cwd / process.cwd() 生成 */
114
+ sessionId?: string;
115
+ /** 粗略 token 超过该阈值时压缩旧上下文 */
116
+ compactAtTokens?: number;
117
+ /** 压缩时保留的最近原始消息数 */
118
+ keepRecentMessages?: number;
119
+ /** Optional tool-step limit. Leave unset for no step limit. */
120
+ maxSteps?: number;
121
+ }
122
+
123
+ /**
124
+ * 流式响应事件
125
+ */
126
+ export type ChatEvent =
127
+ | { type: "compact"; compactedMessages: number }
128
+ | { type: "tool_use"; id?: string; name: string; input: unknown }
129
+ | { type: "tool_result"; tool_use_id: string; name?: string; content: unknown; is_error?: boolean }
130
+ | { type: "text"; text: string; accumulated: string }
131
+ | { type: "done"; text: string }
132
+ | { type: "error"; message: string };
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // ChatSession
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /** 消息角色 */
139
+ type MessageRole = "system" | "user" | "assistant" | "tool";
140
+
141
+ /** 内部消息类型 */
142
+ interface ChatMessage {
143
+ role: MessageRole;
144
+ content: string;
145
+ }
146
+
147
+ export class ChatSession {
148
+ private model: any;
149
+ private systemPrompt: string;
150
+ private cwd: string;
151
+ private context: BuiltinContextManager;
152
+ private maxSteps?: number;
153
+
154
+ constructor(
155
+ overrides: ChatSessionConfig = {},
156
+ options: ChatSessionOptions = {},
157
+ ) {
158
+ const apiKey = overrides.apiKey ?? appConfig.ccc.DEEPSEEK_API_KEY;
159
+ if (!apiKey) {
160
+ throw new Error(
161
+ "ccc.DEEPSEEK_API_KEY 未设置。请在 config.json 中配置,或通过 --api-key 临时传入",
162
+ );
163
+ }
164
+
165
+ const baseURL = overrides.baseURL ?? appConfig.ccc.DEEPSEEK_BASE_URL;
166
+ const modelId = overrides.model ?? appConfig.ccc.model;
167
+
168
+ const provider = createOpenAICompatible({
169
+ name: "deepseek",
170
+ baseURL,
171
+ apiKey,
172
+ });
173
+ this.model = provider(modelId);
174
+ this.cwd = options.cwd ?? process.cwd();
175
+ this.maxSteps = normalizeMaxSteps(options.maxSteps);
176
+
177
+ // 构建系统提示词
178
+ const systemContent = [SYSTEM_PROMPT];
179
+ const projectInstructions = readProjectInstructionFiles(this.cwd);
180
+ if (projectInstructions) {
181
+ systemContent.push("", projectInstructions);
182
+ }
183
+ if (options.systemPrompt) {
184
+ systemContent.push("", options.systemPrompt);
185
+ }
186
+ systemContent.push("", buildRuntimeWorkspacePrompt(this.cwd));
187
+
188
+ this.systemPrompt = systemContent.join("\n");
189
+ this.context = new BuiltinContextManager({
190
+ persist: options.persist ?? false,
191
+ contextDir: options.contextDir,
192
+ sessionId: options.sessionId ?? defaultBuiltinSessionId(this.cwd),
193
+ cwd: this.cwd,
194
+ compactAtTokens: options.compactAtTokens,
195
+ keepRecentMessages: options.keepRecentMessages,
196
+ });
197
+ }
198
+
199
+ /**
200
+ * 发送用户消息,返回异步可迭代的文本流。
201
+ *
202
+ * 使用方式:
203
+ * ```typescript
204
+ * const session = new ChatSession();
205
+ * for await (const event of session.chat("帮我看看 package.json")) {
206
+ * if (event.type === "text") process.stdout.write(event.text);
207
+ * }
208
+ * console.log("完成");
209
+ * ```
210
+ */
211
+ async *chat(
212
+ userMessage: string,
213
+ signal?: AbortSignal,
214
+ ): AsyncIterable<ChatEvent> {
215
+ this.context.appendMessage({ role: "user", content: userMessage });
216
+
217
+ let fullText = "";
218
+ let rawLog: RawStreamLogHandle | null = null;
219
+ let completed = false;
220
+
221
+ try {
222
+ const compactedMessages = await this.compactIfNeeded(signal);
223
+ if (compactedMessages > 0) {
224
+ yield { type: "compact", compactedMessages };
225
+ }
226
+
227
+ const rawLogConfig = appConfig.rawStreamLogs.ccc;
228
+ try {
229
+ rawLog = await createRawStreamLog({
230
+ enabled: rawLogConfig.enabled,
231
+ rootDir: RAW_STREAM_LOGS_DIR,
232
+ tool: "ccc",
233
+ sessionId: this.context.sessionId,
234
+ label: "prompt",
235
+ maxBytesPerTurn: rawLogConfig.maxBytesPerTurn,
236
+ retentionDays: rawLogConfig.retentionDays,
237
+ });
238
+ } catch (err) {
239
+ console.error(`[CCC raw stream log] create failed: ${errorMessage(err)}`);
240
+ }
241
+
242
+ const toolContext: string[] = [];
243
+ const maxSteps = this.maxSteps;
244
+ const result = streamText({
245
+ model: this.model,
246
+ system: this.systemPrompt,
247
+ messages: this.context.buildModelMessages() as any,
248
+ tools: createBuiltinFileTools(this.cwd),
249
+ stopWhen: maxSteps !== undefined ? stepCountIs(maxSteps) : isLoopFinished(),
250
+ abortSignal: signal,
251
+ });
252
+
253
+ const stream = result.fullStream ?? textStreamToFullStream(result.textStream);
254
+ for await (const part of stream as AsyncIterable<TextStreamPart<any>>) {
255
+ rawLog?.writeLine(safeRawStreamJson(part));
256
+ if (part.type === "text-delta") {
257
+ fullText += part.text;
258
+ yield { type: "text", text: part.text, accumulated: fullText };
259
+ } else if (part.type === "tool-call") {
260
+ toolContext.push(`tool_call ${part.toolName}: ${safeJson(part.input)}`);
261
+ yield {
262
+ type: "tool_use",
263
+ id: part.toolCallId,
264
+ name: part.toolName,
265
+ input: part.input,
266
+ };
267
+ } else if (part.type === "tool-result") {
268
+ toolContext.push(`tool_result ${part.toolName}: ${truncateToolContext(safeJson(part.output))}`);
269
+ yield {
270
+ type: "tool_result",
271
+ tool_use_id: part.toolCallId,
272
+ name: part.toolName,
273
+ content: part.output,
274
+ is_error: false,
275
+ };
276
+ } else if (part.type === "tool-error") {
277
+ const message = errorMessage(part.error);
278
+ toolContext.push(`tool_error ${part.toolName}: ${message}`);
279
+ yield {
280
+ type: "tool_result",
281
+ tool_use_id: part.toolCallId,
282
+ name: part.toolName,
283
+ content: message,
284
+ is_error: true,
285
+ };
286
+ } else if (part.type === "error") {
287
+ const message = errorMessage(part.error);
288
+ yield { type: "error", message };
289
+ throw new Error(message);
290
+ }
291
+ }
292
+ completed = true;
293
+
294
+ const persistedText = toolContext.length > 0
295
+ ? `${fullText}\n\n[Tool transcript]\n${toolContext.join("\n")}`
296
+ : fullText;
297
+ this.context.appendMessage({ role: "assistant", content: persistedText });
298
+ yield { type: "done", text: fullText };
299
+ } catch (err) {
300
+ const message = err instanceof Error ? err.message : String(err);
301
+ if ((err as Error).name === "AbortError" || signal?.aborted) {
302
+ // 被中断时,不保存不完整的助手消息
303
+ if (fullText) {
304
+ this.context.appendMessage({ role: "assistant", content: fullText + "\n[已中断]" });
305
+ }
306
+ yield { type: "done", text: fullText };
307
+ return;
308
+ }
309
+ yield { type: "error", message };
310
+ throw err;
311
+ } finally {
312
+ const rawLogConfig = appConfig.rawStreamLogs.ccc;
313
+ await rawLog?.close({
314
+ keep: rawLogConfig.keepCompleted || signal?.aborted === true || !completed,
315
+ });
316
+ }
317
+ }
318
+
319
+ /** 返回当前的会话历史(只读) */
320
+ get history(): ReadonlyArray<ChatMessage> {
321
+ const history: ChatMessage[] = [{ role: "system", content: this.systemPrompt }];
322
+ if (this.context.summary) {
323
+ history.push({
324
+ role: "system",
325
+ content: [
326
+ "较早对话摘要:",
327
+ "",
328
+ this.context.summary,
329
+ ].join("\n"),
330
+ });
331
+ }
332
+ history.push(...this.context.messages as ChatMessage[]);
333
+ return history;
334
+ }
335
+
336
+ /** 返回当前轮数(不含 system 消息) */
337
+ get turnCount(): number {
338
+ return this.context.totalMessages;
339
+ }
340
+
341
+ /** 清空会话历史,保留 system 消息 */
342
+ reset(): void {
343
+ this.context.reset();
344
+ }
345
+
346
+ private async compactIfNeeded(signal?: AbortSignal): Promise<number> {
347
+ const plan = this.context.planCompaction();
348
+ if (!plan) return 0;
349
+
350
+ const result = await generateText({
351
+ model: this.model,
352
+ system: SUMMARY_SYSTEM_PROMPT,
353
+ messages: [{ role: "user", content: buildSummaryPrompt(plan) }],
354
+ abortSignal: signal,
355
+ });
356
+
357
+ if (!result.text.trim()) return 0;
358
+
359
+ this.context.applyCompaction(result.text, plan);
360
+ return plan.oldMessages.length;
361
+ }
362
+ }
363
+
364
+ async function* textStreamToFullStream(stream: AsyncIterable<string>): AsyncIterable<{ type: "text-delta"; text: string }> {
365
+ for await (const text of stream) {
366
+ yield { type: "text-delta", text };
367
+ }
368
+ }
369
+
370
+ function safeJson(value: unknown): string {
371
+ try {
372
+ return JSON.stringify(value);
373
+ } catch {
374
+ return String(value);
375
+ }
376
+ }
377
+
378
+ function safeRawStreamJson(value: unknown): string {
379
+ try {
380
+ const serialized = JSON.stringify(value, (_key, nested) => {
381
+ if (nested instanceof Error) {
382
+ return {
383
+ name: nested.name,
384
+ message: nested.message,
385
+ };
386
+ }
387
+ return nested;
388
+ });
389
+ return serialized ?? "null";
390
+ } catch (err) {
391
+ return JSON.stringify({
392
+ type: "chatccc_raw_stream_log_serialize_error",
393
+ message: errorMessage(err),
394
+ });
395
+ }
396
+ }
397
+
398
+ function truncateToolContext(value: string): string {
399
+ return value.length > 8000 ? `${value.slice(0, 8000)}...[truncated]` : value;
400
+ }
401
+
402
+ function errorMessage(value: unknown): string {
403
+ return value instanceof Error ? value.message : String(value);
404
+ }