@raolin2025/claude-code-node 1.1.0 → 2.0.0

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.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * 费用追踪 (Cost Tracking) — API 调用费用计算和报告
3
+ *
4
+ * 支持主流模型的价格表,自动根据 model 名称匹配价格。
5
+ * 对应原版: src/utils/costTracker.ts
6
+ */
7
+
8
+ // ============================================================
9
+ // 模型价格表(美元 / 1M tokens)
10
+ // ============================================================
11
+
12
+ const PRICING = {
13
+ // DeepSeek
14
+ 'deepseek-chat': { input: 0.27, output: 1.10, cache_read: 0.07 },
15
+ 'deepseek-reasoner': { input: 0.55, output: 2.19, cache_read: 0.14 },
16
+
17
+ // OpenAI
18
+ 'gpt-4o': { input: 2.50, output: 10.00 },
19
+ 'gpt-4o-mini': { input: 0.15, output: 0.60 },
20
+ 'gpt-4.1': { input: 2.00, output: 8.00 },
21
+ 'gpt-4.1-mini': { input: 0.40, output: 1.60 },
22
+ 'gpt-4.1-nano': { input: 0.10, output: 0.40 },
23
+ 'o3': { input: 2.00, output: 8.00 },
24
+ 'o3-mini': { input: 1.10, output: 4.40 },
25
+ 'o4-mini': { input: 1.10, output: 4.40 },
26
+
27
+ // 通义千问 (Qwen)
28
+ 'qwen-plus': { input: 0.50, output: 2.00 },
29
+ 'qwen-turbo': { input: 0.05, output: 0.20 },
30
+ 'qwen-max': { input: 2.00, output: 6.00 },
31
+ 'qwen-long': { input: 0.07, output: 0.28 },
32
+
33
+ // 智谱 GLM
34
+ 'glm-4-flash': { input: 0.10, output: 0.10 },
35
+ 'glm-4-plus': { input: 0.50, output: 0.50 },
36
+ 'glm-4': { input: 1.00, output: 1.00 },
37
+ 'glm-4-long': { input: 0.10, output: 0.10 },
38
+
39
+ // Moonshot Kimi
40
+ 'moonshot-v1-8k': { input: 0.50, output: 2.00 },
41
+ 'moonshot-v1-32k': { input: 1.00, output: 4.00 },
42
+ 'moonshot-v1-128k': { input: 2.00, output: 8.00 },
43
+
44
+ // Ollama (本地免费)
45
+ 'ollama': { input: 0, output: 0 },
46
+ }
47
+
48
+ // 默认价格(未匹配到的模型)
49
+ const DEFAULT_PRICING = { input: 0.50, output: 2.00 }
50
+
51
+ /**
52
+ * 根据模型名查找价格
53
+ */
54
+ function findPricing(model) {
55
+ if (!model) return DEFAULT_PRICING
56
+
57
+ // 精确匹配
58
+ if (PRICING[model]) return PRICING[model]
59
+
60
+ // 前缀匹配 (e.g. "deepseek-chat-v3" → "deepseek-chat")
61
+ for (const [key, price] of Object.entries(PRICING)) {
62
+ if (model.startsWith(key) || model.includes(key)) return price
63
+ }
64
+
65
+ // Ollama 系列全部免费
66
+ if (model.includes('ollama') || model.includes('local') || model.includes('localhost')) {
67
+ return PRICING.ollama
68
+ }
69
+
70
+ return DEFAULT_PRICING
71
+ }
72
+
73
+ /**
74
+ * 费用追踪器
75
+ */
76
+ export class CostTracker {
77
+ constructor(options = {}) {
78
+ this.model = options.model || ''
79
+ this.pricing = findPricing(this.model)
80
+ this.totalInputTokens = 0
81
+ this.totalOutputTokens = 0
82
+ this.totalCacheReadTokens = 0
83
+ this.totalApiCalls = 0
84
+ this.history = [] // 每次 API 调用的记录
85
+ }
86
+
87
+ /** 切换模型(更新价格表) */
88
+ setModel(model) {
89
+ this.model = model
90
+ this.pricing = findPricing(model)
91
+ }
92
+
93
+ /** 记录一次 API 调用 */
94
+ recordUsage(usage) {
95
+ const inputTokens = usage.input_tokens || usage.prompt_tokens || 0
96
+ const outputTokens = usage.output_tokens || usage.completion_tokens || 0
97
+ const cacheReadTokens = usage.cache_read_input_tokens || 0
98
+
99
+ const cost = this.calculateCost(inputTokens, outputTokens, cacheReadTokens)
100
+
101
+ this.totalInputTokens += inputTokens
102
+ this.totalOutputTokens += outputTokens
103
+ this.totalCacheReadTokens += cacheReadTokens
104
+ this.totalApiCalls++
105
+
106
+ this.history.push({
107
+ timestamp: Date.now(),
108
+ inputTokens,
109
+ outputTokens,
110
+ cacheReadTokens,
111
+ cost,
112
+ })
113
+
114
+ return cost
115
+ }
116
+
117
+ /** 计算单次费用 */
118
+ calculateCost(inputTokens, outputTokens, cacheReadTokens = 0) {
119
+ const cost = {
120
+ input: (inputTokens / 1_000_000) * this.pricing.input,
121
+ output: (outputTokens / 1_000_000) * this.pricing.output,
122
+ cacheRead: (cacheReadTokens / 1_000_000) * (this.pricing.cache_read || 0),
123
+ }
124
+ cost.total = cost.input + cost.output + cost.cacheRead
125
+ return cost
126
+ }
127
+
128
+ /** 获取总费用 */
129
+ getTotalCost() {
130
+ const cost = this.calculateCost(
131
+ this.totalInputTokens,
132
+ this.totalOutputTokens,
133
+ this.totalCacheReadTokens
134
+ )
135
+ return cost
136
+ }
137
+
138
+ /** 格式化费用报告 */
139
+ formatReport() {
140
+ const cost = this.getTotalCost()
141
+ const lines = [
142
+ `💰 Cost Report — ${this.model}`,
143
+ ` API Calls: ${this.totalApiCalls}`,
144
+ ` Input Tokens: ${this.totalInputTokens.toLocaleString()} → $${cost.input.toFixed(4)}`,
145
+ ` Output Tokens: ${this.totalOutputTokens.toLocaleString()} → $${cost.output.toFixed(4)}`,
146
+ ]
147
+ if (this.totalCacheReadTokens > 0) {
148
+ lines.push(` Cache Read: ${this.totalCacheReadTokens.toLocaleString()} → $${cost.cacheRead.toFixed(4)}`)
149
+ }
150
+ lines.push(` ───────────────────────────────`)
151
+ lines.push(` Total: $${cost.total.toFixed(4)}`)
152
+ return lines.join('\n')
153
+ }
154
+
155
+ /** 简短格式(单行) */
156
+ formatShort() {
157
+ const cost = this.getTotalCost()
158
+ return `$${cost.total.toFixed(4)} (${this.totalApiCalls} calls, ${this.totalInputTokens.toLocaleString()}+${this.totalOutputTokens.toLocaleString()} tok)`
159
+ }
160
+
161
+ /** 重置 */
162
+ reset() {
163
+ this.totalInputTokens = 0
164
+ this.totalOutputTokens = 0
165
+ this.totalCacheReadTokens = 0
166
+ this.totalApiCalls = 0
167
+ this.history = []
168
+ }
169
+ }
170
+
171
+ export { PRICING, findPricing }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 共享路径常量 — cc-node 和 cc-notify 共用
3
+ */
4
+ import { join } from 'path'
5
+ import { homedir } from 'os'
6
+
7
+ export const SOCK_DIR = join(homedir(), '.cc-node')
8
+ export const SOCK_PATH = join(SOCK_DIR, 'repl.sock')
9
+ export const CC_NODE_PID = join(SOCK_DIR, 'cc-node.pid')
10
+ export const CC_NOTIFY_PID = join(SOCK_DIR, 'cc-notify.pid')
11
+ export const CC_NOTIFY_LOG = join(SOCK_DIR, 'cc-notify.log')
12
+ export const DEFAULT_HTTP_PORT = 3456
@@ -12,8 +12,12 @@
12
12
  * 适用于: OpenAI / DeepSeek / Qwen / GLM / Kimi / Ollama / vLLM / LM Studio / 任何兼容接口
13
13
  */
14
14
  import crypto from 'crypto'
15
- import { Message, UserMessage, AssistantMessage, SystemMessage, ToolCall, ToolResult, SessionState } from '../types/index.js'
15
+ import { UserMessage, AssistantMessage, ToolCall, ToolResult, SessionState } from '../types/index.js'
16
+ import { parseStream, parseNonStreamResponse } from './streaming.js'
17
+ import { autoCompact } from './compact.js'
18
+ import { CostTracker } from './cost-tracker.js'
16
19
  import { EnhancedPermissionChecker } from '../security/enhanced-permission.js'
20
+ import { checkHostSafety } from '../security/ssrf-guard.js'
17
21
 
18
22
  /**
19
23
  * 配置选项
@@ -35,6 +39,9 @@ export class QueryEngineConfig {
35
39
  this.apiKey = options.apiKey || process.env.LLM_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY || process.env.QWEN_API_KEY || process.env.GLM_API_KEY || process.env.KIMI_API_KEY || ''
36
40
  // API Base — DeepSeek 为默认
37
41
  this.apiBase = options.apiBase || process.env.LLM_API_BASE || 'https://api.deepseek.com/v1'
42
+ this.noStream = options.noStream || false
43
+ this.costTracker = options.costTracker || null
44
+ this.tokenBudget = options.tokenBudget || null
38
45
  this.initialMessages = options.initialMessages || []
39
46
  }
40
47
  }
@@ -51,6 +58,8 @@ export class QueryEngine {
51
58
  projectDir: this.config.cwd,
52
59
  })
53
60
  this.abortController = null
61
+ this.costTracker = this.config.costTracker || new CostTracker({ model: this.config.model })
62
+ this.tokenBudget = this.config.tokenBudget || null
54
63
  }
55
64
 
56
65
  /**
@@ -66,6 +75,15 @@ export class QueryEngine {
66
75
  const userMsg = new UserMessage(userInput)
67
76
  this.state.messages.push(userMsg)
68
77
 
78
+ // M3: 自动上下文压缩
79
+ if (this.tokenBudget) {
80
+ const { compacted, messages } = autoCompact(this.state.messages, this.tokenBudget)
81
+ if (compacted) {
82
+ this.state.messages = messages
83
+ if (this.config.verbose) console.error('[compact] Context compressed to fit token budget')
84
+ }
85
+ }
86
+
69
87
  try {
70
88
  const result = await this._runToolLoop(userMsg)
71
89
  return result
@@ -81,12 +99,11 @@ export class QueryEngine {
81
99
  * 最多跑 maxTurns 次
82
100
  */
83
101
  async _runToolLoop(userMessage) {
84
- let currentMessages = [...this.state.messages]
85
102
  let finalResponse = ''
86
103
 
87
104
  for (let turn = 0; turn < this.config.maxTurns; turn++) {
88
- const requestMessages = this._buildRequest(currentMessages)
89
- const response = await this._callLLM(requestMessages, currentMessages)
105
+ const requestMessages = this._buildRequest(this.state.messages)
106
+ const response = await this._callLLM(requestMessages, this.state.messages)
90
107
 
91
108
  if (this.abortController.signal.aborted) {
92
109
  throw new Error('操作已取消')
@@ -95,40 +112,22 @@ export class QueryEngine {
95
112
  // 没有工具调用 → 最终回复
96
113
  if (!response.toolCalls || response.toolCalls.length === 0) {
97
114
  finalResponse = response.content
98
- const assistantMsg = new AssistantMessage(response.content)
99
- this.state.messages.push(assistantMsg)
115
+ this.state.messages.push(new AssistantMessage(response.content))
100
116
  break
101
117
  }
102
118
 
103
- // 有工具调用 → 记录助手消息
104
- const assistantMsg = new AssistantMessage(response.content, response.toolCalls)
105
- this.state.messages.push(assistantMsg)
119
+ // 有工具调用 → 记录 assistant 消息(含 tool_calls)
120
+ this.state.messages.push(new AssistantMessage(response.content, response.toolCalls))
106
121
 
107
122
  // 执行工具
108
123
  const toolResults = await this._executeToolCalls(response.toolCalls)
109
124
 
110
- // 工具结果加入消息流(OpenAI 兼容格式)
125
+ // 工具结果加入 state.messages(OpenAI 兼容格式)
111
126
  for (const result of toolResults) {
112
- // 先加 assistant 的 tool_calls 消息
113
- currentMessages.push({
114
- role: 'assistant',
115
- content: null,
116
- tool_calls: [{
117
- id: result.toolCallId,
118
- type: 'function',
119
- function: {
120
- name: result.toolName || '',
121
- arguments: '{}',
122
- },
123
- }],
124
- })
125
- // 再加 tool 结果消息
126
- currentMessages.push({
127
+ this.state.messages.push({
127
128
  role: 'tool',
128
129
  tool_call_id: result.toolCallId,
129
- content: result.isError
130
- ? `[ERROR] ${result.content}`
131
- : result.content,
130
+ content: result.isError ? `[ERROR] ${result.content}` : result.content,
132
131
  })
133
132
  this.state.toolResults.set(result.toolCallId, result)
134
133
  }
@@ -150,7 +149,7 @@ export class QueryEngine {
150
149
  }
151
150
 
152
151
  /**
153
- * 构建 LLM 请求消息列表
152
+ * 构建 LLM 请求消息列表 — 统一 OpenAI 兼容格式
154
153
  */
155
154
  _buildRequest(messages) {
156
155
  const request = []
@@ -160,37 +159,53 @@ export class QueryEngine {
160
159
  request.push({ role: 'system', content: this.config.systemPrompt })
161
160
  }
162
161
 
163
- // 历史消息
162
+ // 历史消息 — 转换为 OpenAI 兼容格式
164
163
  for (const msg of messages) {
165
164
  if (msg.role === 'system') {
166
165
  request.push({ role: 'system', content: msg.content })
167
166
  } else if (msg.role === 'user') {
168
167
  request.push({ role: 'user', content: this._formatContent(msg.content) })
169
168
  } else if (msg.role === 'assistant') {
170
- const content = []
171
- if (msg.content) content.push({ type: 'text', text: msg.content })
172
- if (msg.toolCalls) {
173
- for (const tc of msg.toolCalls) {
174
- content.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input })
175
- }
169
+ // tool_calls 的 assistant 消息:OpenAI 格式
170
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
171
+ request.push({
172
+ role: 'assistant',
173
+ content: msg.content || null,
174
+ tool_calls: msg.toolCalls.map(tc => ({
175
+ id: tc.id,
176
+ type: 'function',
177
+ function: { name: tc.name, arguments: typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input) },
178
+ })),
179
+ })
180
+ } else {
181
+ // 纯文本 assistant 消息
182
+ request.push({ role: 'assistant', content: this._formatContent(msg.content) })
176
183
  }
177
- request.push({ role: 'assistant', content })
184
+ } else if (msg.role === 'tool') {
185
+ // tool 结果消息 — 直接透传
186
+ request.push({
187
+ role: 'tool',
188
+ tool_call_id: msg.tool_call_id,
189
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
190
+ })
178
191
  }
179
192
  }
180
193
 
181
194
  return request
182
195
  }
183
196
 
197
+
184
198
  /**
185
199
  * 执行工具调用
186
200
  */
187
201
  async _executeToolCalls(toolCalls) {
188
202
  const results = []
189
203
  for (const tc of toolCalls) {
190
- // 安全检查(一票否决)
204
+ // 安全检查
191
205
  const permResult = await this.permissionChecker.check(tc.name, tc.input)
192
206
  if (!permResult.allowed) {
193
- results.push(new ToolResult(tc.id, `工具调用被安全策略拒绝: ${tc.name} — ${permResult.reason || ''}`, true))
207
+ results.push(new ToolResult(tc.id, `工具调用被安全策略拒绝: ${tc.name} — ${permResult.reason || ""}`, true))
208
+ results[results.length - 1].toolName = tc.name
194
209
  continue
195
210
  }
196
211
 
@@ -198,6 +213,7 @@ export class QueryEngine {
198
213
  const tool = this.config.tools.find(t => t.name === tc.name)
199
214
  if (!tool) {
200
215
  results.push(new ToolResult(tc.id, `未找到工具: ${tc.name}`, true))
216
+ results[results.length - 1].toolName = tc.name
201
217
  continue
202
218
  }
203
219
 
@@ -206,7 +222,6 @@ export class QueryEngine {
206
222
  const content = await tool.handler(tc.input, { cwd: this.config.cwd, engine: this })
207
223
  tc.status = 'done'
208
224
  results.push(new ToolResult(tc.id, typeof content === 'string' ? content : JSON.stringify(content), false))
209
- // 记录工具名用于消息构建
210
225
  results[results.length - 1].toolName = tc.name
211
226
  } catch (err) {
212
227
  tc.status = 'error'
@@ -217,19 +232,6 @@ export class QueryEngine {
217
232
  return results
218
233
  }
219
234
 
220
- /**
221
- * 调用 LLM API — OpenAI 兼容协议(全行业通用)
222
- *
223
- * 支持的提供商(举例):
224
- * - DeepSeek: https://api.deepseek.com/v1
225
- * - 通义千问: https://dashscope.aliyuncs.com/compatible-mode/v1
226
- * - 智谱 GLM: https://open.bigmodel.cn/api/paas/v4
227
- * - Moonshot Kimi: https://api.moonshot.cn/v1
228
- * - Ollama 本地: http://localhost:11434/v1
229
- * - vLLM: http://localhost:8000/v1
230
- * - LM Studio: http://localhost:1234/v1
231
- * - OpenAI: https://api.openai.com/v1
232
- */
233
235
  async _callLLM(messages, contextMessages) {
234
236
  const apiKey = this.config.apiKey
235
237
  const apiBase = this.config.apiBase
@@ -237,69 +239,162 @@ export class QueryEngine {
237
239
  if (!apiKey) {
238
240
  throw new Error(
239
241
  `未设置 API Key。请设置以下环境变量之一:\n` +
240
- ` LLM_API_KEY=xxx (通用,推荐)\n` +
241
- ` DEEPSEEK_API_KEY=xxx (DeepSeek)\n` +
242
- ` OPENAI_API_KEY=xxx (OpenAI)\n` +
243
- ` QWEN_API_KEY=xxx (通义千问)\n` +
244
- ` GLM_API_KEY=xxx (智谱 GLM)\n` +
245
- ` KIMI_API_KEY=xxx (Moonshot Kimi)\n` +
242
+ ` LLM_API_KEY=xxx (通用,推荐)\n` +
243
+ ` DEEPSEEK_API_KEY=xxx (DeepSeek)\n` +
244
+ ` OPENAI_API_KEY=xxx (OpenAI)\n` +
245
+ ` QWEN_API_KEY=xxx (通义千问)\n` +
246
+ ` GLM_API_KEY=xxx (智谱 GLM)\n` +
247
+ ` KIMI_API_KEY=xxx (Moonshot Kimi)\n` +
246
248
  `或通过 --api-key 参数传入`
247
249
  )
248
250
  }
249
251
 
250
252
  if (!apiBase) {
251
253
  throw new Error(
252
- `未设置 API Base URL。默认使用 https://api.deepseek.com/v1
253
- ` +
254
- `可通过 LLM_API_BASE 或 --api-base 参数切换其他提供商,例如:\n` +
255
- ` --api-base https://dashscope.aliyuncs.com/compatible-mode/v1 (通义千问)\n` +
256
- ` --api-base https://open.bigmodel.cn/api/paas/v4 (智谱 GLM)\n` +
257
- ` --api-base https://api.moonshot.cn/v1 (Moonshot Kimi)\n` +
258
- ` --api-base http://localhost:11434/v1 (Ollama 本地)\n` +
259
- ` --api-base http://localhost:8000/v1 (vLLM 本地)\n` +
260
- ` --api-base https://api.openai.com/v1 (OpenAI)`
254
+ `未设置 API Base URL。默认使用 https://api.deepseek.com/v1 ` +
255
+ `可通过 LLM_API_BASE 或 --api-base 参数切换其他提供商`
261
256
  )
262
257
  }
263
258
 
264
- // 构建工具定义 — OpenAI function-calling 格式
259
+ // 构建工具定义
265
260
  const tools = this.config.tools.map(t => ({
266
261
  type: 'function',
267
- function: {
268
- name: t.name,
269
- description: t.description,
270
- parameters: t.parameters,
271
- },
262
+ function: { name: t.name, description: t.description, parameters: t.parameters },
272
263
  }))
273
264
 
265
+ const useStream = !this.config.noStream
274
266
  const body = {
275
267
  model: this.config.model,
276
268
  messages,
277
269
  max_tokens: 4096,
278
270
  ...(tools.length && { tools }),
271
+ ...(useStream && { stream: true }),
279
272
  }
280
273
 
281
274
  const url = apiBase.replace(/\/+$/, '') + '/chat/completions'
282
275
 
283
- const response = await fetch(url, {
284
- method: 'POST',
285
- headers: {
286
- 'Content-Type': 'application/json',
287
- 'Authorization': `Bearer ${apiKey}`,
288
- },
289
- body: JSON.stringify(body),
290
- signal: this.abortController?.signal,
291
- })
292
-
293
- if (!response.ok) {
294
- const errText = await response.text()
295
- throw new Error(`API 错误 ${response.status}: ${errText}`)
276
+ // H3 修复:SSRF 防护 — 在 fetch 之前检查 API 主机名
277
+ try {
278
+ const parsedUrl = new URL(url)
279
+ const hostResult = await checkHostSafety(parsedUrl.hostname)
280
+ if (!hostResult.allowed) {
281
+ throw new Error(`SSRF blocked: ${hostResult.reason}`)
282
+ }
283
+ } catch (err) {
284
+ if (err.message.startsWith('SSRF blocked:')) {
285
+ throw err
286
+ }
287
+ // URL 解析错误忽略,让 fetch 自己处理
296
288
  }
297
289
 
298
- const data = await response.json()
299
- return this._parseResponse(data)
290
+ // 带重试的 fetch
291
+ const maxRetries = 3
292
+ let lastError = null
293
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
294
+ try {
295
+ if (this.abortController?.signal?.aborted) {
296
+ throw new Error('请求已取消')
297
+ }
298
+
299
+ const response = await fetch(url, {
300
+ method: 'POST',
301
+ headers: {
302
+ 'Content-Type': 'application/json',
303
+ 'Authorization': `Bearer ${apiKey}`,
304
+ },
305
+ body: JSON.stringify(body),
306
+ signal: this.abortController?.signal,
307
+ })
308
+
309
+ if (!response.ok) {
310
+ const errText = await response.text()
311
+ // 429/503 可重试
312
+ if ((response.status === 429 || response.status === 503) && attempt < maxRetries) {
313
+ const waitMs = response.status === 429 ? 2000 * attempt : 1000
314
+ if (this.config.verbose) {
315
+ console.error(`[retry] API ${response.status}, waiting ${waitMs}ms (attempt ${attempt}/${maxRetries})`)
316
+ }
317
+ await new Promise(r => setTimeout(r, waitMs))
318
+ continue
319
+ }
320
+ throw new Error(`API 错误 ${response.status}: ${errText}`)
321
+ }
322
+
323
+ // 流式或非流式处理
324
+ if (useStream && response.body) {
325
+ return await this._handleStreamResponse(response)
326
+ } else {
327
+ const data = await response.json()
328
+ const result = parseNonStreamResponse(data)
329
+ // M4: 记录非流式响应费用
330
+ if (result.usage && this.costTracker) {
331
+ this.costTracker.recordUsage(result.usage)
332
+ }
333
+ if (this.tokenBudget && result.usage) {
334
+ this.tokenBudget.recordUsage(result.usage)
335
+ }
336
+ return result
337
+ }
338
+ } catch (err) {
339
+ lastError = err
340
+ // 网络错误重试
341
+ if (err.name !== 'AbortError' && attempt < maxRetries && !err.message.startsWith('API 错误')) {
342
+ const waitMs = 1000 * attempt
343
+ if (this.config.verbose) {
344
+ console.error(`[retry] Network error: ${err.message}, waiting ${waitMs}ms (attempt ${attempt}/${maxRetries})`)
345
+ }
346
+ await new Promise(r => setTimeout(r, waitMs))
347
+ continue
348
+ }
349
+ throw err
350
+ }
351
+ }
352
+ throw lastError
300
353
  }
301
354
 
302
355
  /**
356
+ * 处理流式响应 — 逐 token 输出
357
+ */
358
+ async _handleStreamResponse(response) {
359
+ const result = { content: '', toolCalls: [], usage: {} }
360
+ let currentText = ''
361
+
362
+ try {
363
+ for await (const event of parseStream(response)) {
364
+ if (event.type === 'text') {
365
+ // 实时输出到终端
366
+ process.stdout.write(event.text)
367
+ currentText += event.text
368
+ } else if (event.type === 'tool_use') {
369
+ // 收集工具调用
370
+ result.toolCalls.push(new ToolCall(
371
+ event.toolCall.id,
372
+ event.toolCall.name,
373
+ event.toolCall.input
374
+ ))
375
+ } else if (event.type === 'done') {
376
+ result.content = event.result.content || currentText
377
+ result.toolCalls = event.result.toolCalls?.map(tc =>
378
+ new ToolCall(tc.id, tc.name, tc.input)
379
+ ) || result.toolCalls
380
+ result.usage = event.result.usage || {}
381
+ }
382
+ }
383
+ } catch (err) {
384
+ // 流中断 — 返回已收到的内容
385
+ result.content = currentText || ''
386
+ if (this.config.verbose) {
387
+ console.error(`[stream] interrupted: ${err.message}`)
388
+ }
389
+ }
390
+
391
+ // 流式输出后换行
392
+ if (currentText) process.stdout.write('\n')
393
+
394
+ return result
395
+ }
396
+
397
+ /**
303
398
  * 解析 OpenAI 兼容响应
304
399
  */
305
400
  _parseResponse(data) {
@@ -322,6 +417,14 @@ export class QueryEngine {
322
417
  result.toolCalls.push(new ToolCall(tc.id, tc.function.name, input))
323
418
  }
324
419
 
420
+ // M4: 记录 API 调用费用
421
+ if (result.usage && this.costTracker) {
422
+ this.costTracker.recordUsage(result.usage)
423
+ }
424
+ if (this.tokenBudget && result.usage) {
425
+ this.tokenBudget.recordUsage(result.usage)
426
+ }
427
+
325
428
  return result
326
429
  }
327
430
 
@@ -2,10 +2,8 @@
2
2
  * 会话管理
3
3
  * 对应原版: src/utils/sessionState.ts + src/utils/sessionStorage.ts
4
4
  */
5
- import { readFile, writeFile, mkdir, readdir, rm } from 'fs/promises'
5
+ import { readFile, writeFile, mkdir, readdir, rm, chmod } from 'fs/promises'
6
6
  import { resolve, join } from 'path'
7
- import { existsSync } from 'fs'
8
- import { SessionState } from '../types/index.js'
9
7
 
10
8
  const DEFAULT_SESSIONS_DIR = '.claude-code/sessions'
11
9
 
@@ -15,9 +13,9 @@ export class SessionManager {
15
13
  this.currentSession = null
16
14
  }
17
15
 
18
- /** 确保会话目录存在 */
16
+ /** 确保会话目录存在(v1.1: 目录权限 0700) */
19
17
  async ensureDir() {
20
- await mkdir(this.sessionsDir, { recursive: true })
18
+ await mkdir(this.sessionsDir, { recursive: true, mode: 0o700 })
21
19
  }
22
20
 
23
21
  /** 创建新会话 */
@@ -37,12 +35,12 @@ export class SessionManager {
37
35
  return session
38
36
  }
39
37
 
40
- /** 保存会话 */
38
+ /** 保存会话(v1.1: 文件权限 0600,防止其他用户读取敏感对话内容) */
41
39
  async save(session) {
42
40
  await this.ensureDir()
43
41
  session.updated = new Date().toISOString()
44
42
  const filePath = join(this.sessionsDir, `${session.id}.json`)
45
- await writeFile(filePath, JSON.stringify(session, null, 2), 'utf-8')
43
+ await writeFile(filePath, JSON.stringify(session, null, 2), { encoding: 'utf-8', mode: 0o600 })
46
44
  return session
47
45
  }
48
46
 
@@ -109,11 +107,28 @@ export class SessionManager {
109
107
  /** 追加消息到当前会话 */
110
108
  async appendMessage(message) {
111
109
  if (!this.currentSession) await this.getOrCreate()
112
- this.currentSession.messages.push({
110
+
111
+ const entry = {
113
112
  role: message.role,
114
113
  content: typeof message.content === 'string' ? message.content : JSON.stringify(message.content),
115
114
  timestamp: new Date().toISOString(),
116
- })
115
+ }
116
+
117
+ // 保存 tool_calls 信息(assistant 消息可能包含工具调用)
118
+ if (message.toolCalls && message.toolCalls.length > 0) {
119
+ entry.toolCalls = message.toolCalls.map(tc => ({
120
+ id: tc.id,
121
+ name: tc.name,
122
+ input: tc.input,
123
+ }))
124
+ }
125
+
126
+ // 保存 tool_call_id(tool 结果消息)
127
+ if (message.tool_call_id) {
128
+ entry.tool_call_id = message.tool_call_id
129
+ }
130
+
131
+ this.currentSession.messages.push(entry)
117
132
  await this.save(this.currentSession)
118
133
  return this.currentSession
119
134
  }