@raolin2025/claude-code-node 1.2.0 → 2.1.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.
- package/package.json +1 -1
- package/src/channel/index.js +81 -146
- package/src/channel/notify-daemon.js +454 -350
- package/src/core/cli.js +235 -114
- package/src/core/compact.js +171 -0
- package/src/core/config.js +1 -2
- package/src/core/cost-tracker.js +171 -0
- package/src/core/paths.js +12 -0
- package/src/core/query-engine.js +223 -117
- package/src/core/session.js +27 -10
- package/src/mcp/client.js +112 -4
- package/src/mcp/registry.js +1 -2
- package/src/security/bash-guard.js +174 -141
- package/src/security/enhanced-permission.js +72 -34
- package/src/security/path-guard.js +43 -30
- package/src/security/ssrf-guard.js +153 -50
- package/src/tools/glob.js +1 -1
- package/src/tools/web-fetch.js +3 -1
- package/src/types/index.js +2 -1
- package/src/utils/file-ops.js +2 -3
package/src/core/query-engine.js
CHANGED
|
@@ -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 {
|
|
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,7 +39,12 @@ 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 || []
|
|
46
|
+
this.onConfirmTool = options.onConfirmTool || null // ask 模式确认回调
|
|
47
|
+
this.readline = options.readline || null // 用于 AskUserQuestion 工具
|
|
39
48
|
}
|
|
40
49
|
}
|
|
41
50
|
|
|
@@ -51,6 +60,8 @@ export class QueryEngine {
|
|
|
51
60
|
projectDir: this.config.cwd,
|
|
52
61
|
})
|
|
53
62
|
this.abortController = null
|
|
63
|
+
this.costTracker = this.config.costTracker || new CostTracker({ model: this.config.model })
|
|
64
|
+
this.tokenBudget = this.config.tokenBudget || null
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
/**
|
|
@@ -66,6 +77,15 @@ export class QueryEngine {
|
|
|
66
77
|
const userMsg = new UserMessage(userInput)
|
|
67
78
|
this.state.messages.push(userMsg)
|
|
68
79
|
|
|
80
|
+
// M3: 自动上下文压缩
|
|
81
|
+
if (this.tokenBudget) {
|
|
82
|
+
const { compacted, messages } = autoCompact(this.state.messages, this.tokenBudget)
|
|
83
|
+
if (compacted) {
|
|
84
|
+
this.state.messages = messages
|
|
85
|
+
if (this.config.verbose) console.error('[compact] Context compressed to fit token budget')
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
try {
|
|
70
90
|
const result = await this._runToolLoop(userMsg)
|
|
71
91
|
return result
|
|
@@ -81,12 +101,11 @@ export class QueryEngine {
|
|
|
81
101
|
* 最多跑 maxTurns 次
|
|
82
102
|
*/
|
|
83
103
|
async _runToolLoop(userMessage) {
|
|
84
|
-
let currentMessages = [...this.state.messages]
|
|
85
104
|
let finalResponse = ''
|
|
86
105
|
|
|
87
106
|
for (let turn = 0; turn < this.config.maxTurns; turn++) {
|
|
88
|
-
const requestMessages = this._buildRequest(
|
|
89
|
-
const response = await this._callLLM(requestMessages,
|
|
107
|
+
const requestMessages = this._buildRequest(this.state.messages)
|
|
108
|
+
const response = await this._callLLM(requestMessages, this.state.messages)
|
|
90
109
|
|
|
91
110
|
if (this.abortController.signal.aborted) {
|
|
92
111
|
throw new Error('操作已取消')
|
|
@@ -95,40 +114,22 @@ export class QueryEngine {
|
|
|
95
114
|
// 没有工具调用 → 最终回复
|
|
96
115
|
if (!response.toolCalls || response.toolCalls.length === 0) {
|
|
97
116
|
finalResponse = response.content
|
|
98
|
-
|
|
99
|
-
this.state.messages.push(assistantMsg)
|
|
117
|
+
this.state.messages.push(new AssistantMessage(response.content))
|
|
100
118
|
break
|
|
101
119
|
}
|
|
102
120
|
|
|
103
|
-
// 有工具调用 →
|
|
104
|
-
|
|
105
|
-
this.state.messages.push(assistantMsg)
|
|
121
|
+
// 有工具调用 → 记录 assistant 消息(含 tool_calls)
|
|
122
|
+
this.state.messages.push(new AssistantMessage(response.content, response.toolCalls))
|
|
106
123
|
|
|
107
124
|
// 执行工具
|
|
108
125
|
const toolResults = await this._executeToolCalls(response.toolCalls)
|
|
109
126
|
|
|
110
|
-
//
|
|
127
|
+
// 工具结果加入 state.messages(OpenAI 兼容格式)
|
|
111
128
|
for (const result of toolResults) {
|
|
112
|
-
|
|
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({
|
|
129
|
+
this.state.messages.push({
|
|
127
130
|
role: 'tool',
|
|
128
131
|
tool_call_id: result.toolCallId,
|
|
129
|
-
content: result.isError
|
|
130
|
-
? `[ERROR] ${result.content}`
|
|
131
|
-
: result.content,
|
|
132
|
+
content: result.isError ? `[ERROR] ${result.content}` : result.content,
|
|
132
133
|
})
|
|
133
134
|
this.state.toolResults.set(result.toolCallId, result)
|
|
134
135
|
}
|
|
@@ -150,7 +151,7 @@ export class QueryEngine {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
/**
|
|
153
|
-
* 构建 LLM 请求消息列表
|
|
154
|
+
* 构建 LLM 请求消息列表 — 统一 OpenAI 兼容格式
|
|
154
155
|
*/
|
|
155
156
|
_buildRequest(messages) {
|
|
156
157
|
const request = []
|
|
@@ -160,76 +161,101 @@ export class QueryEngine {
|
|
|
160
161
|
request.push({ role: 'system', content: this.config.systemPrompt })
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
// 历史消息
|
|
164
|
+
// 历史消息 — 转换为 OpenAI 兼容格式
|
|
164
165
|
for (const msg of messages) {
|
|
165
166
|
if (msg.role === 'system') {
|
|
166
167
|
request.push({ role: 'system', content: msg.content })
|
|
167
168
|
} else if (msg.role === 'user') {
|
|
168
169
|
request.push({ role: 'user', content: this._formatContent(msg.content) })
|
|
169
170
|
} else if (msg.role === 'assistant') {
|
|
170
|
-
|
|
171
|
-
if (msg.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
content
|
|
175
|
-
|
|
171
|
+
// 带 tool_calls 的 assistant 消息:OpenAI 格式
|
|
172
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
173
|
+
request.push({
|
|
174
|
+
role: 'assistant',
|
|
175
|
+
content: msg.content || null,
|
|
176
|
+
tool_calls: msg.toolCalls.map(tc => ({
|
|
177
|
+
id: tc.id,
|
|
178
|
+
type: 'function',
|
|
179
|
+
function: { name: tc.name, arguments: typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input) },
|
|
180
|
+
})),
|
|
181
|
+
})
|
|
182
|
+
} else {
|
|
183
|
+
// 纯文本 assistant 消息
|
|
184
|
+
request.push({ role: 'assistant', content: this._formatContent(msg.content) })
|
|
176
185
|
}
|
|
177
|
-
|
|
186
|
+
} else if (msg.role === 'tool') {
|
|
187
|
+
// tool 结果消息 — 直接透传
|
|
188
|
+
request.push({
|
|
189
|
+
role: 'tool',
|
|
190
|
+
tool_call_id: msg.tool_call_id,
|
|
191
|
+
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
192
|
+
})
|
|
178
193
|
}
|
|
179
194
|
}
|
|
180
195
|
|
|
181
196
|
return request
|
|
182
197
|
}
|
|
183
198
|
|
|
199
|
+
|
|
184
200
|
/**
|
|
185
|
-
* 执行工具调用
|
|
201
|
+
* 执行工具调用 — 两阶段策略
|
|
202
|
+
* 阶段1(串行):安全检查 + ask 模式确认(需要用户交互,必须串行)
|
|
203
|
+
* 阶段2(并行):批准后的工具并行执行,互不依赖的工具同时跑
|
|
186
204
|
*/
|
|
187
205
|
async _executeToolCalls(toolCalls) {
|
|
188
|
-
|
|
206
|
+
// 阶段1:串行安全检查
|
|
207
|
+
const approved = []
|
|
189
208
|
for (const tc of toolCalls) {
|
|
190
|
-
// 安全检查(一票否决)
|
|
191
209
|
const permResult = await this.permissionChecker.check(tc.name, tc.input)
|
|
192
210
|
if (!permResult.allowed) {
|
|
193
|
-
|
|
194
|
-
|
|
211
|
+
if (permResult.requiresConfirmation && this.config.onConfirmTool) {
|
|
212
|
+
const confirmed = await this.config.onConfirmTool(tc.name, tc.input)
|
|
213
|
+
if (!confirmed) {
|
|
214
|
+
approved.push({ tc, error: '用户未确认' })
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
approved.push({ tc, error: `安全策略拒绝: ${permResult.reason || ""}` })
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
195
221
|
}
|
|
196
222
|
|
|
197
|
-
// 查找工具
|
|
198
223
|
const tool = this.config.tools.find(t => t.name === tc.name)
|
|
199
224
|
if (!tool) {
|
|
200
|
-
|
|
225
|
+
approved.push({ tc, error: `未找到工具: ${tc.name}` })
|
|
201
226
|
continue
|
|
202
227
|
}
|
|
203
228
|
|
|
229
|
+
approved.push({ tc, tool })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 阶段2:并行执行已批准的工具
|
|
233
|
+
const execPromises = approved.map(async (item) => {
|
|
234
|
+
if (item.error) {
|
|
235
|
+
const r = new ToolResult(item.tc.id, item.error, true)
|
|
236
|
+
r.toolName = item.tc.name
|
|
237
|
+
return r
|
|
238
|
+
}
|
|
239
|
+
const { tc, tool } = item
|
|
240
|
+
tc.status = 'running'
|
|
204
241
|
try {
|
|
205
|
-
tc.
|
|
206
|
-
const content = await tool.handler(tc.input, { cwd: this.config.cwd, engine: this })
|
|
242
|
+
const content = await tool.handler(tc.input, { cwd: this.config.cwd, engine: this, readline: this.config.readline })
|
|
207
243
|
tc.status = 'done'
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
244
|
+
const r = new ToolResult(tc.id, typeof content === 'string' ? content : JSON.stringify(content), false)
|
|
245
|
+
r.toolName = tc.name
|
|
246
|
+
return r
|
|
211
247
|
} catch (err) {
|
|
212
248
|
tc.status = 'error'
|
|
213
|
-
|
|
214
|
-
|
|
249
|
+
const r = new ToolResult(tc.id, `工具执行错误: ${err.message}`, true)
|
|
250
|
+
r.toolName = tc.name
|
|
251
|
+
return r
|
|
215
252
|
}
|
|
216
|
-
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
const results = await Promise.all(execPromises)
|
|
217
256
|
return results
|
|
218
257
|
}
|
|
219
258
|
|
|
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
259
|
async _callLLM(messages, contextMessages) {
|
|
234
260
|
const apiKey = this.config.apiKey
|
|
235
261
|
const apiBase = this.config.apiBase
|
|
@@ -237,96 +263,176 @@ export class QueryEngine {
|
|
|
237
263
|
if (!apiKey) {
|
|
238
264
|
throw new Error(
|
|
239
265
|
`未设置 API Key。请设置以下环境变量之一:\n` +
|
|
240
|
-
` LLM_API_KEY=xxx
|
|
241
|
-
` DEEPSEEK_API_KEY=xxx
|
|
242
|
-
` OPENAI_API_KEY=xxx
|
|
243
|
-
` QWEN_API_KEY=xxx
|
|
244
|
-
` GLM_API_KEY=xxx
|
|
245
|
-
` KIMI_API_KEY=xxx
|
|
266
|
+
` LLM_API_KEY=xxx (通用,推荐)\n` +
|
|
267
|
+
` DEEPSEEK_API_KEY=xxx (DeepSeek)\n` +
|
|
268
|
+
` OPENAI_API_KEY=xxx (OpenAI)\n` +
|
|
269
|
+
` QWEN_API_KEY=xxx (通义千问)\n` +
|
|
270
|
+
` GLM_API_KEY=xxx (智谱 GLM)\n` +
|
|
271
|
+
` KIMI_API_KEY=xxx (Moonshot Kimi)\n` +
|
|
246
272
|
`或通过 --api-key 参数传入`
|
|
247
273
|
)
|
|
248
274
|
}
|
|
249
275
|
|
|
250
276
|
if (!apiBase) {
|
|
251
277
|
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)`
|
|
278
|
+
`未设置 API Base URL。默认使用 https://api.deepseek.com/v1 ` +
|
|
279
|
+
`可通过 LLM_API_BASE 或 --api-base 参数切换其他提供商`
|
|
261
280
|
)
|
|
262
281
|
}
|
|
263
282
|
|
|
264
|
-
// 构建工具定义
|
|
283
|
+
// 构建工具定义
|
|
265
284
|
const tools = this.config.tools.map(t => ({
|
|
266
285
|
type: 'function',
|
|
267
|
-
function: {
|
|
268
|
-
name: t.name,
|
|
269
|
-
description: t.description,
|
|
270
|
-
parameters: t.parameters,
|
|
271
|
-
},
|
|
286
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
272
287
|
}))
|
|
273
288
|
|
|
289
|
+
const useStream = !this.config.noStream
|
|
274
290
|
const body = {
|
|
275
291
|
model: this.config.model,
|
|
276
292
|
messages,
|
|
277
293
|
max_tokens: 4096,
|
|
278
294
|
...(tools.length && { tools }),
|
|
295
|
+
...(useStream && { stream: true }),
|
|
279
296
|
}
|
|
280
297
|
|
|
281
298
|
const url = apiBase.replace(/\/+$/, '') + '/chat/completions'
|
|
282
299
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
300
|
+
// H3 修复:SSRF 防护 — 在 fetch 之前检查 API 主机名
|
|
301
|
+
try {
|
|
302
|
+
const parsedUrl = new URL(url)
|
|
303
|
+
const hostResult = await checkHostSafety(parsedUrl.hostname)
|
|
304
|
+
if (!hostResult.allowed) {
|
|
305
|
+
throw new Error(`SSRF blocked: ${hostResult.reason}`)
|
|
306
|
+
}
|
|
307
|
+
} catch (err) {
|
|
308
|
+
if (err.message.startsWith('SSRF blocked:')) {
|
|
309
|
+
throw err
|
|
310
|
+
}
|
|
311
|
+
// URL 解析错误忽略,让 fetch 自己处理
|
|
312
|
+
}
|
|
292
313
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
314
|
+
// 带重试的 fetch
|
|
315
|
+
const maxRetries = 3
|
|
316
|
+
// Jitter 退避 — 指数退避 + 随机 ±50%,防止惊群效应
|
|
317
|
+
const retryDelay = (baseMs, attempt) => {
|
|
318
|
+
const ms = baseMs * Math.pow(2, attempt - 1)
|
|
319
|
+
const jitter = ms * (0.5 + Math.random() * 0.5) // 50%-100% of base
|
|
320
|
+
return Math.round(jitter)
|
|
296
321
|
}
|
|
322
|
+
let lastError = null
|
|
323
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
324
|
+
try {
|
|
325
|
+
if (this.abortController?.signal?.aborted) {
|
|
326
|
+
throw new Error('请求已取消')
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const response = await fetch(url, {
|
|
330
|
+
method: 'POST',
|
|
331
|
+
headers: {
|
|
332
|
+
'Content-Type': 'application/json',
|
|
333
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
334
|
+
},
|
|
335
|
+
body: JSON.stringify(body),
|
|
336
|
+
signal: this.abortController?.signal,
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
if (!response.ok) {
|
|
340
|
+
const errText = await response.text()
|
|
341
|
+
// 429/503 可重试
|
|
342
|
+
if ((response.status === 429 || response.status === 503) && attempt < maxRetries) {
|
|
343
|
+
const waitMs = retryDelay(response.status === 429 ? 2000 : 1000, attempt)
|
|
344
|
+
if (this.config.verbose) {
|
|
345
|
+
console.error(`[retry] API ${response.status}, waiting ${waitMs}ms (attempt ${attempt}/${maxRetries})`)
|
|
346
|
+
}
|
|
347
|
+
await new Promise(r => setTimeout(r, waitMs))
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
throw new Error(`API 错误 ${response.status}: ${errText}`)
|
|
351
|
+
}
|
|
297
352
|
|
|
298
|
-
|
|
299
|
-
|
|
353
|
+
// 流式或非流式处理
|
|
354
|
+
if (useStream && response.body) {
|
|
355
|
+
const result = await this._handleStreamResponse(response)
|
|
356
|
+
if (result.usage && this.costTracker) {
|
|
357
|
+
this.costTracker.recordUsage(result.usage)
|
|
358
|
+
}
|
|
359
|
+
if (this.tokenBudget && result.usage) {
|
|
360
|
+
this.tokenBudget.recordUsage(result.usage)
|
|
361
|
+
}
|
|
362
|
+
return result
|
|
363
|
+
} else {
|
|
364
|
+
const data = await response.json()
|
|
365
|
+
const result = parseNonStreamResponse(data)
|
|
366
|
+
if (result.usage && this.costTracker) {
|
|
367
|
+
this.costTracker.recordUsage(result.usage)
|
|
368
|
+
}
|
|
369
|
+
if (this.tokenBudget && result.usage) {
|
|
370
|
+
this.tokenBudget.recordUsage(result.usage)
|
|
371
|
+
}
|
|
372
|
+
return result
|
|
373
|
+
}
|
|
374
|
+
} catch (err) {
|
|
375
|
+
lastError = err
|
|
376
|
+
// 网络错误重试
|
|
377
|
+
if (err.name !== 'AbortError' && attempt < maxRetries && !err.message.startsWith('API 错误')) {
|
|
378
|
+
const waitMs = retryDelay(1000, attempt)
|
|
379
|
+
if (this.config.verbose) {
|
|
380
|
+
console.error(`[retry] Network error: ${err.message}, waiting ${waitMs}ms (attempt ${attempt}/${maxRetries})`)
|
|
381
|
+
}
|
|
382
|
+
await new Promise(r => setTimeout(r, waitMs))
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
throw err
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
throw lastError
|
|
300
389
|
}
|
|
301
390
|
|
|
302
391
|
/**
|
|
303
|
-
*
|
|
392
|
+
* 处理流式响应 — 逐 token 输出
|
|
304
393
|
*/
|
|
305
|
-
|
|
306
|
-
const result = { content: '', toolCalls: [] }
|
|
307
|
-
|
|
308
|
-
if (!choice) return result
|
|
309
|
-
|
|
310
|
-
const message = choice.message
|
|
311
|
-
if (message.content) {
|
|
312
|
-
result.content = message.content
|
|
313
|
-
}
|
|
394
|
+
async _handleStreamResponse(response) {
|
|
395
|
+
const result = { content: '', toolCalls: [], usage: {} }
|
|
396
|
+
let currentText = ''
|
|
314
397
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
398
|
+
try {
|
|
399
|
+
for await (const event of parseStream(response)) {
|
|
400
|
+
if (event.type === 'text') {
|
|
401
|
+
// 实时输出到终端
|
|
402
|
+
process.stdout.write(event.text)
|
|
403
|
+
currentText += event.text
|
|
404
|
+
} else if (event.type === 'tool_use') {
|
|
405
|
+
// 收集工具调用
|
|
406
|
+
result.toolCalls.push(new ToolCall(
|
|
407
|
+
event.toolCall.id,
|
|
408
|
+
event.toolCall.name,
|
|
409
|
+
event.toolCall.input
|
|
410
|
+
))
|
|
411
|
+
} else if (event.type === 'done') {
|
|
412
|
+
result.content = event.result.content || currentText
|
|
413
|
+
result.toolCalls = event.result.toolCalls?.map(tc =>
|
|
414
|
+
new ToolCall(tc.id, tc.name, tc.input)
|
|
415
|
+
) || result.toolCalls
|
|
416
|
+
result.usage = event.result.usage || {}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} catch (err) {
|
|
420
|
+
// 流中断 — 返回已收到的内容
|
|
421
|
+
result.content = currentText || ''
|
|
422
|
+
if (this.config.verbose) {
|
|
423
|
+
console.error(`[stream] interrupted: ${err.message}`)
|
|
321
424
|
}
|
|
322
|
-
result.toolCalls.push(new ToolCall(tc.id, tc.function.name, input))
|
|
323
425
|
}
|
|
324
426
|
|
|
427
|
+
// 流式输出后换行
|
|
428
|
+
if (currentText) process.stdout.write('\n')
|
|
429
|
+
|
|
325
430
|
return result
|
|
326
431
|
}
|
|
327
432
|
|
|
328
433
|
/** 格式化内容 */
|
|
329
434
|
_formatContent(content) {
|
|
435
|
+
if (content == null) return ''
|
|
330
436
|
if (typeof content === 'string') return content
|
|
331
437
|
if (typeof content === 'object') return JSON.stringify(content)
|
|
332
438
|
return String(content)
|
package/src/core/session.js
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
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 {
|
|
8
|
-
import { SessionState } from '../types/index.js'
|
|
7
|
+
import { randomBytes } from 'crypto'
|
|
9
8
|
|
|
10
9
|
const DEFAULT_SESSIONS_DIR = '.claude-code/sessions'
|
|
11
10
|
|
|
@@ -15,15 +14,16 @@ export class SessionManager {
|
|
|
15
14
|
this.currentSession = null
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
/**
|
|
17
|
+
/** 确保会话目录存在(v1.1: 目录权限 0700) */
|
|
19
18
|
async ensureDir() {
|
|
20
|
-
await mkdir(this.sessionsDir, { recursive: true })
|
|
19
|
+
await mkdir(this.sessionsDir, { recursive: true, mode: 0o700 })
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
/** 创建新会话 */
|
|
24
23
|
async create(title = '') {
|
|
25
24
|
await this.ensureDir()
|
|
26
|
-
|
|
25
|
+
// M4 fix: 使用 crypto.randomBytes 生成不可预测的会话 ID
|
|
26
|
+
const id = `session-${Date.now()}-${randomBytes(8).toString('hex')}`
|
|
27
27
|
const session = {
|
|
28
28
|
id,
|
|
29
29
|
title: title || `Session ${new Date().toISOString().slice(0, 19)}`,
|
|
@@ -37,12 +37,12 @@ export class SessionManager {
|
|
|
37
37
|
return session
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
40
|
+
/** 保存会话(v1.1: 文件权限 0600,防止其他用户读取敏感对话内容) */
|
|
41
41
|
async save(session) {
|
|
42
42
|
await this.ensureDir()
|
|
43
43
|
session.updated = new Date().toISOString()
|
|
44
44
|
const filePath = join(this.sessionsDir, `${session.id}.json`)
|
|
45
|
-
await writeFile(filePath, JSON.stringify(session, null, 2), 'utf-8')
|
|
45
|
+
await writeFile(filePath, JSON.stringify(session, null, 2), { encoding: 'utf-8', mode: 0o600 })
|
|
46
46
|
return session
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -109,11 +109,28 @@ export class SessionManager {
|
|
|
109
109
|
/** 追加消息到当前会话 */
|
|
110
110
|
async appendMessage(message) {
|
|
111
111
|
if (!this.currentSession) await this.getOrCreate()
|
|
112
|
-
|
|
112
|
+
|
|
113
|
+
const entry = {
|
|
113
114
|
role: message.role,
|
|
114
115
|
content: typeof message.content === 'string' ? message.content : JSON.stringify(message.content),
|
|
115
116
|
timestamp: new Date().toISOString(),
|
|
116
|
-
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 保存 tool_calls 信息(assistant 消息可能包含工具调用)
|
|
120
|
+
if (message.toolCalls && message.toolCalls.length > 0) {
|
|
121
|
+
entry.toolCalls = message.toolCalls.map(tc => ({
|
|
122
|
+
id: tc.id,
|
|
123
|
+
name: tc.name,
|
|
124
|
+
input: tc.input,
|
|
125
|
+
}))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 保存 tool_call_id(tool 结果消息)
|
|
129
|
+
if (message.tool_call_id) {
|
|
130
|
+
entry.tool_call_id = message.tool_call_id
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.currentSession.messages.push(entry)
|
|
117
134
|
await this.save(this.currentSession)
|
|
118
135
|
return this.currentSession
|
|
119
136
|
}
|