@raolin2025/claude-code-node 2.8.19 → 2.8.21

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raolin2025/claude-code-node",
3
- "version": "2.8.19",
3
+ "version": "2.8.21",
4
4
  "description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications, Telegram & QQ Bot remote programming, rich media upload, multi-account management",
5
5
  "type": "module",
6
6
  "main": "src/core/index.js",
@@ -320,3 +320,63 @@ test('trimToWindow:所有 system 均在开头,body 中无 system', () => {
320
320
  const afterBody = roles.slice(firstBody + 1)
321
321
  assert.ok(!afterBody.includes('system'), `trim 后 body 中不应有 system(实际 roles=${roles.join(',')})`)
322
322
  })
323
+
324
+ test('条数折叠:保留当前进行中的任务链,只折叠已完成的早期历史', () => {
325
+ // 场景:正在工具循环中(最近 user 之后有完整工具链),早期有已完成任务
326
+ const msgs = [
327
+ { role: 'system', content: 'SYS' },
328
+ // 早期已完成历史
329
+ { role: 'user', content: '任务1' },
330
+ { role: 'assistant', content: 'a1', toolCalls: [{ id: 't1', name: 'Bash', input: {} }] },
331
+ { role: 'tool', tool_call_id: 't1', content: 'r1' },
332
+ { role: 'assistant', content: '完成1' },
333
+ // 当前进行中的任务(工具链未完成)
334
+ { role: 'user', content: '按计划实现第一阶段' },
335
+ { role: 'assistant', content: '', toolCalls: [{ id: 'c1', name: 'Read', input: {} }] },
336
+ { role: 'tool', tool_call_id: 'c1', content: '计划内容' },
337
+ { role: 'assistant', content: '', toolCalls: [{ id: 'c2', name: 'Write', input: {} }] },
338
+ { role: 'tool', tool_call_id: 'c2', content: '写入成功' },
339
+ ]
340
+ const r = foldHistoryByCount(msgs, { maxMessages: 8, keepRecentTurns: 2 })
341
+ assert.equal(r.folded, true)
342
+ const all = JSON.stringify(r.messages)
343
+ // 当前任务(最近的 user 及其后的工具链)必须完整保留
344
+ assert.ok(all.includes('按计划实现第一阶段'), '当前任务 user 应保留')
345
+ assert.ok(all.includes('计划内容'), '当前任务的 Read 结果应保留')
346
+ assert.ok(all.includes('写入成功'), '当前任务的 Write 结果应保留')
347
+ // 早期已完成历史应被折叠
348
+ assert.ok(!all.includes('完成1'), '早期已完成历史应被折叠')
349
+ })
350
+
351
+ test('条数折叠:当前任务很大时按完整 user 轮次折叠,不截断工具链', () => {
352
+ const msgs = [{ role: 'system', content: 'SYS' }]
353
+ // 早期历史
354
+ for (let i = 0; i < 5; i++) {
355
+ msgs.push({ role: 'user', content: '旧任务' + i })
356
+ msgs.push({ role: 'assistant', content: '旧完成' + i })
357
+ }
358
+ // 当前大任务(多轮工具循环)
359
+ msgs.push({ role: 'user', content: '实现第一阶段' })
360
+ for (let i = 0; i < 15; i++) {
361
+ msgs.push({ role: 'assistant', content: '', toolCalls: [{ id: 'c' + i, name: 'Write', input: {} }] })
362
+ msgs.push({ role: 'tool', tool_call_id: 'c' + i, content: '写模块' + i })
363
+ }
364
+ const r = foldHistoryByCount(msgs, { maxMessages: 20, keepRecentTurns: 3 })
365
+ assert.equal(r.folded, true)
366
+ const all = JSON.stringify(r.messages)
367
+ // 当前任务 user 保留
368
+ assert.ok(all.includes('实现第一阶段'), '当前任务 user 应保留')
369
+ // 折叠后工具链连续(无中间截断)
370
+ const roles = r.messages.map(m => m.role).filter(x => x !== 'system')
371
+ let expectUser = true
372
+ for (const role of roles) {
373
+ if (role === 'user') { expectUser = false }
374
+ }
375
+ // 验证角色序列是 user,assistant,tool,assistant,tool... 的连续模式(无孤立 tool)
376
+ for (let i = 0; i < roles.length; i++) {
377
+ if (roles[i] === 'tool') {
378
+ // tool 之前必须是 assistant
379
+ assert.ok(i > 0 && roles[i - 1] === 'assistant', `第 ${i} 个 tool 前应为 assistant(实际 ${roles.join(',')})`)
380
+ }
381
+ }
382
+ })
@@ -20,6 +20,7 @@ import {
20
20
  isSmallModelEnabled,
21
21
  buildMultiStepPlan,
22
22
  buildCombinedGuidance,
23
+ extractFrameworkAction,
23
24
  } from '../core/small-model.js'
24
25
 
25
26
  // ---- 敷衍输出检测 ----
@@ -227,6 +228,27 @@ test('max_tokens:绝不超过窗口一半(防超窗)', async () => {
227
228
  assert.ok(qe2._computeMaxOutputTokens() <= 5000, `max_tokens=${qe2._computeMaxOutputTokens()} 不应超过窗口一半 5000`)
228
229
  })
229
230
 
231
+ // ---- 框架代执行(extractFrameworkAction)----
232
+ test('框架代执行:识别"阅读+绝对路径"任务为 Read', () => {
233
+ const a = extractFrameworkAction('阅读D:\\workspace\\miniQMT-trader\\COMPLETION_REPORT.md', {
234
+ cwd: 'D:\\workspace\\miniQMT-trader',
235
+ })
236
+ assert.ok(a, '应识别到框架动作')
237
+ assert.equal(a.tool, 'Read')
238
+ assert.equal(a.input.file_path, 'D:\\workspace\\miniQMT-trader\\COMPLETION_REPORT.md')
239
+ })
240
+
241
+ test('框架代执行:相对路径拼接 cwd', () => {
242
+ const a = extractFrameworkAction('查看一下 ./PLAN.md', { cwd: 'D:\\workspace\\miniQMT-trader' })
243
+ assert.ok(a && a.tool === 'Read')
244
+ assert.equal(a.input.file_path, 'D:\\workspace\\miniQMT-trader\\PLAN.md')
245
+ })
246
+
247
+ test('框架代执行:非读取任务或无路径返回 null', () => {
248
+ assert.equal(extractFrameworkAction('你好', { cwd: 'D:\\x' }), null)
249
+ assert.equal(extractFrameworkAction('运行测试', { cwd: 'D:\\x' }), null)
250
+ })
251
+
230
252
  // ---- _buildRequest 把 system 统一前置(防 llama.cpp 500)----
231
253
  test('_buildRequest:把中间 system 统一前置到开头', async () => {
232
254
  const { QueryEngine } = await import('../core/query-engine.js')
@@ -198,9 +198,6 @@ export function foldHistoryByCount(messages, options = {}) {
198
198
  }
199
199
 
200
200
  // 分离 system 提示与普通消息
201
- // 注意:把所有 system 都收集到 systemMsgs(不只是第一条)——多次折叠后 body 里
202
- // 可能残留旧的摘要 system,若不全部隔离,折叠结果会把 system 挤到对话中间,
203
- // 触发 llama.cpp "System message must be at the beginning" (500)。
204
201
  const systemMsgs = []
205
202
  const body = []
206
203
  for (const m of messages) {
@@ -211,18 +208,55 @@ export function foldHistoryByCount(messages, options = {}) {
211
208
  }
212
209
  }
213
210
 
214
- // 找到分界点:保留最近 keepRecentTurns 轮
215
- // 一轮 = user + assistant(+tool_calls) + tool 结果们 + assistant 最终回复
216
- // 从末尾倒推,数到第 keepRecentTurns 个 user 即分界(splitIndex 指向该轮起点,
217
- // 使得 recentMessages 恰好包含最近 keepRecentTurns 轮完整对话)
218
- let turnCount = 0
219
- let splitIndex = body.length
211
+ // 找到分界点。
212
+ // 核心原则:**绝不能破坏"当前正在进行的任务链"的完整性**。
213
+ // 小模型"工作到一半变傻"的根因,就是折叠把正在进行的任务(最近的 user 指令
214
+ // 及其后的 tool 调用链)折叠成模糊摘要,模型丢失了"当前任务状态"(在做什么、
215
+ // 做到哪、下一步干什么)而停止调用工具。
216
+ // 因此:
217
+ // 1. 优先只折叠【最近 user 指令之前】的早期历史——当前任务(最近 user 指令
218
+ // 之后所有消息,含进行中的工具调用链)完整保留,永不折叠。
219
+ // 2. 若当前任务本身消息仍过多(单个任务几十轮),再从当前任务内部按
220
+ // 【完整 user 轮次】为单位折叠(保留最近 keepRecentTurns 个 user 任务),
221
+ // 绝不从 tool/assistant 消息中间截断工具链。
222
+ let splitIndex = -1
223
+ // 找最近的 user 指令位置
224
+ let lastUserIdx = -1
220
225
  for (let i = body.length - 1; i >= 0; i--) {
221
226
  if (body[i].role === 'user') {
222
- turnCount++
223
- if (turnCount >= keepRecentTurns) {
224
- splitIndex = i
225
- break
227
+ lastUserIdx = i
228
+ break
229
+ }
230
+ }
231
+ // 最近的 user 之后的消息数(当前任务的大小)
232
+ const currentTaskSize = lastUserIdx === -1 ? body.length : body.length - lastUserIdx
233
+ if (lastUserIdx > 0 && currentTaskSize <= maxMessages) {
234
+ // 当前任务本身未超限 → 折叠当前任务之前的历史(保留最近 user 指令起全部)
235
+ splitIndex = lastUserIdx
236
+ } else if (lastUserIdx >= 0) {
237
+ // 当前任务本身也超限 → 从当前任务内部按完整 user 轮次折叠
238
+ let turnCount = 0
239
+ splitIndex = lastUserIdx
240
+ for (let i = body.length - 1; i >= lastUserIdx; i--) {
241
+ if (body[i].role === 'user') {
242
+ turnCount++
243
+ if (turnCount >= keepRecentTurns) {
244
+ splitIndex = i
245
+ break
246
+ }
247
+ }
248
+ }
249
+ } else {
250
+ // 没有 user 消息(异常)→ 用 keepRecentTurns 兜底
251
+ let turnCount = 0
252
+ splitIndex = body.length
253
+ for (let i = body.length - 1; i >= 0; i--) {
254
+ if (body[i].role === 'user') {
255
+ turnCount++
256
+ if (turnCount >= keepRecentTurns) {
257
+ splitIndex = i
258
+ break
259
+ }
226
260
  }
227
261
  }
228
262
  }
@@ -238,7 +272,7 @@ export function foldHistoryByCount(messages, options = {}) {
238
272
  // 对折叠掉的历史生成摘要(保留 Main goal / 工具 / 关键结果)
239
273
  const summary = generateSummary(earlyMessages)
240
274
 
241
- // 构建折叠后的消息列表:system + (摘要 system) + 最近 N 轮完整对话
275
+ // 构建折叠后的消息列表:system + (摘要 system) + 当前任务完整对话
242
276
  const folded = [...systemMsgs]
243
277
  folded.push({
244
278
  role: 'system',
@@ -22,6 +22,7 @@ import {
22
22
  buildCombinedGuidance,
23
23
  selectRelevantTools,
24
24
  RETRY_GUIDANCE,
25
+ extractFrameworkAction,
25
26
  } from './small-model.js'
26
27
  import { CostTracker } from './cost-tracker.js'
27
28
  import { EnhancedPermissionChecker } from '../security/enhanced-permission.js'
@@ -293,16 +294,25 @@ export class QueryEngine {
293
294
 
294
295
  // 没有工具调用 → 潜在最终回复
295
296
  if (!response.toolCalls || response.toolCalls.length === 0) {
296
- // 小模型模式:检测敷衍输出(空话/太短/命中占位句),追加强引导重试
297
- if (smallModel && fillerRetries < MAX_FILLER_RETRIES && isFillerResponse(response)) {
298
- fillerRetries++
299
- if (this.config.verbose) {
300
- console.error(`[small-model] 检测到敷衍输出(第 ${fillerRetries} 次),追加强引导重试`)
297
+ // 小模型模式:检测敷衍输出(空话/太短/命中占位句)
298
+ if (smallModel && isFillerResponse(response)) {
299
+ // 第一次敷衍 → 追加强引导重试
300
+ if (fillerRetries < MAX_FILLER_RETRIES) {
301
+ fillerRetries++
302
+ if (this.config.verbose) {
303
+ console.error(`[small-model] 检测到敷衍输出(第 ${fillerRetries} 次),追加强引导重试`)
304
+ }
305
+ this.state.messages.push(new AssistantMessage(response.content, [], response.reasoningContent))
306
+ this.state.messages.push({ role: 'user', content: RETRY_GUIDANCE })
307
+ continue
308
+ }
309
+ // 重试后仍敷衍 → 框架代执行(不依赖模型调工具)
310
+ const execResult = await this._frameworkExecute(userMessage.content)
311
+ if (execResult.done) {
312
+ if (this.config.verbose) console.error(`[small-model] 框架代执行:${execResult.summary}`)
313
+ // 框架已执行工具并把结果注入对话,继续循环让模型基于结果总结
314
+ continue
301
315
  }
302
- // 把模型的空话 + 强制工具调用提醒注入上下文,再让模型重新决策
303
- this.state.messages.push(new AssistantMessage(response.content, [], response.reasoningContent))
304
- this.state.messages.push({ role: 'user', content: RETRY_GUIDANCE })
305
- continue // 继续循环,重新请求模型
306
316
  }
307
317
 
308
318
  finalResponse = response.content
@@ -499,6 +509,47 @@ export class QueryEngine {
499
509
  return results
500
510
  }
501
511
 
512
+ /**
513
+ * 框架代执行 — 模型敷衍调不起工具时,框架直接替它执行最合理的工具
514
+ *
515
+ * 背景:27B Q3 量化模型工具调用极弱,即使强引导也常只回"研究一下"而不调工具。
516
+ * 此时框架【绕过模型】,根据指令推断该执行什么工具并直接运行,把真实结果
517
+ * 注入对话,再让模型基于结果总结——"一句话调用小模型工作"才能成立。
518
+ *
519
+ * @param {string} userInput — 用户指令
520
+ * @returns {Promise<{ done: boolean, summary: string }>}
521
+ * done=true 表示框架已执行工具(结果已注入 state.messages)
522
+ */
523
+ async _frameworkExecute(userInput) {
524
+ const action = extractFrameworkAction(userInput, { cwd: this.config.cwd })
525
+ if (!action) return { done: false, summary: '' }
526
+
527
+ // 找到对应的工具
528
+ const tool = this.config.tools.find(t => t.name === action.tool)
529
+ if (!tool) return { done: false, summary: '' }
530
+
531
+ // 执行工具(直接调用 handler)
532
+ try {
533
+ const content = await tool.handler(action.input, { cwd: this.config.cwd, engine: this, readline: this.config.readline })
534
+
535
+ // 注入框架代执行结果(作为 user 消息,避免 tool 消息无对应 assistant tool_call 报错)
536
+ // 说明:框架代执行不是模型发起的工具调用,不能作为 tool 角色(会缺 assistant tool_call),
537
+ // 因此以 user 消息承载真实结果 + 引导模型基于结果总结。
538
+ const resultText = typeof content === 'string' ? content : JSON.stringify(content)
539
+ this.state.messages.push({
540
+ role: 'user',
541
+ content: `[框架代执行结果] 框架已替你执行工具 ${tool.name}(参数 ${JSON.stringify(action.input)}),结果如下:\n\n${resultText.slice(0, 8000)}\n\n请基于以上【真实执行结果】,用中文直接回答用户的问题或总结内容。不要再调用工具,直接总结即可。`,
542
+ })
543
+
544
+ const summary = `框架已代执行 ${tool.name}(${JSON.stringify(action.input)})`
545
+ if (this.config.verbose) console.error(`[small-model] 框架代执行 ${tool.name} 完成`)
546
+ return { done: true, summary }
547
+ } catch (err) {
548
+ if (this.config.verbose) console.error(`[small-model] 框架代执行失败: ${err.message}`)
549
+ return { done: false, summary: '' }
550
+ }
551
+ }
552
+
502
553
  async _callLLM(messages, contextMessages) {
503
554
  const apiKey = this.config.apiKey
504
555
  const apiBase = this.config.apiBase
@@ -233,6 +233,48 @@ export function buildIntentGuidance(userInput, opts = {}) {
233
233
  return `[任务引导] 根据用户指令,建议按此思路用工具推进:${intent.intent}。\n说明:${intent.note}\n可用工具:${intent.toolHint}`
234
234
  }
235
235
 
236
+ /**
237
+ * 框架代执行:从用户指令里推断"框架该替模型执行哪个工具动作"
238
+ *
239
+ * 背景:27B Q3 量化模型工具调用能力极弱,即使有最强引导也常只回"研究一下"等
240
+ * 空话而不调工具。此时框架必须【绕过模型】,根据指令直接替它执行最合理的工具,
241
+ * 把真实结果注入对话,再让模型基于结果总结。
242
+ *
243
+ * 当前支持(按优先级):
244
+ * 1. 读取文件:指令里含文件路径 + 阅读/查看/打开等动词 → Read(path)
245
+ *
246
+ * 后续可扩展:写文件、跑命令、搜索等。
247
+ *
248
+ * @param {string} userInput — 用户指令
249
+ * @param {object} [opts]
250
+ * @param {string} [opts.cwd] — 当前工作目录(用于解析相对路径)
251
+ * @returns {{ tool: string, input: object, toolName: string } | null}
252
+ * tool = 'Read'/'Write' 等工具名;input 为工具参数;toolName 为展示名
253
+ */
254
+ export function extractFrameworkAction(userInput, opts = {}) {
255
+ if (!userInput) return null
256
+ const cwd = opts.cwd || ''
257
+
258
+ // 1. 读取文件任务:含路径 + 读/查看/打开
259
+ // 匹配绝对路径(Windows: D:\... 或 /... 或 ./...)或文件名
260
+ const readIntent = /(读|阅读|查看|打开|展示|显示|看看|浏览|看)/i.test(userInput)
261
+ // 提取路径:优先 Windows 绝对路径 D:\...、Unix /...、相对 ./ 或 ../,或 .md/.txt/.py/.json 文件
262
+ const pathMatch = userInput.match(/([A-Za-z]:\\[^\s,。;]+|(?:\/|\/\/)[^\s,。;]+|\.{1,2}\/[^\s,。;]+|[\w@.-]+\.(?:md|txt|py|json|log|yaml|yml|toml|ini|cfg|csv|xml))+/i)
263
+
264
+ if (readIntent && pathMatch) {
265
+ let filePath = pathMatch[1].trim()
266
+ // 去掉开头的 ./ 或 ../
267
+ filePath = filePath.replace(/^\.\.?[\\/]/, '')
268
+ // 若是相对路径且给了 cwd,拼成绝对路径
269
+ if (!/^[A-Za-z]:\\/.test(filePath) && !filePath.startsWith('/') && cwd) {
270
+ filePath = cwd.replace(/[\\/]+$/, '') + '\\' + filePath
271
+ }
272
+ return { tool: 'Read', toolName: 'Read', input: { file_path: filePath } }
273
+ }
274
+
275
+ return null
276
+ }
277
+
236
278
  // 常见计划文档文件名(供多步拆解时定位计划文件)
237
279
  const PLAN_FILE_CANDIDATES = [
238
280
  'DEVELOPMENT_PLAN.md',