@raolin2025/claude-code-node 2.8.18 → 2.8.20

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.18",
3
+ "version": "2.8.20",
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",
@@ -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')
package/src/core/cli.js CHANGED
@@ -333,6 +333,7 @@ function parseArgs(argv) {
333
333
  maxMessages: 0,
334
334
  smallModel: false,
335
335
  maxOutputTokens: 0,
336
+ smallModelMaxTurns: 0,
336
337
  }
337
338
 
338
339
  let i = 2
@@ -351,6 +352,7 @@ function parseArgs(argv) {
351
352
  case '--max-messages': args.maxMessages = parseInt(argv[++i], 10); break
352
353
  case '--small-model': args.smallModel = true; break
353
354
  case '--max-output-tokens': args.maxOutputTokens = parseInt(argv[++i], 10); break
355
+ case '--small-model-max-turns': args.smallModelMaxTurns = parseInt(argv[++i], 10); break
354
356
  case '--stdio': args.stdio = true; break
355
357
  case '--with-notify': args.withNotify = true; break
356
358
  case '--version':
@@ -374,6 +376,7 @@ Options:
374
376
  --max-messages N Fold history when message count exceeds N (default: 0 = off)
375
377
  --small-model Enable small-model adaptation (tool-call enforcement, filler retry, intent guidance)
376
378
  --max-output-tokens N Override max single-response output tokens (default: computed from window size)
379
+ --small-model-max-turns N Small-model tool-loop cap (default: 8, prevents infinite loops)
377
380
  --with-notify Start built-in channel listener (Telegram)
378
381
  (replaces cc-notify daemon — no external script needed)
379
382
  -h, --help Show this help
@@ -540,6 +543,8 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
540
543
  smallModel: cliArgs.smallModel || config.get('smallModel') || false,
541
544
  // 单次输出上限覆盖(默认根据窗口动态计算)
542
545
  maxOutputTokens: cliArgs.maxOutputTokens || config.get('maxOutputTokens') || 0,
546
+ // 小模型工具循环轮数上限(默认 8,防无限循环)
547
+ smallModelMaxTurns: cliArgs.smallModelMaxTurns || config.get('smallModelMaxTurns') || 0,
543
548
  })
544
549
  const engine = new QueryEngine(engineConfig)
545
550
 
@@ -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'
@@ -53,6 +54,9 @@ export class QueryEngineConfig {
53
54
  // - 工具数量精简 + 意图引导
54
55
  // 默认关闭,通过 config.smallModel=true 或 --small-model 开启
55
56
  this.smallModel = options.smallModel || false
57
+ // 小模型模式下的工具循环轮数上限(默认 8):
58
+ // 小模型常陷入"写一个又写一个"的无限工具循环,需更小的上限 + 收尾引导
59
+ this.smallModelMaxTurns = options.smallModelMaxTurns || 8
56
60
  // 单次输出上限动态计算:
57
61
  // outputRatio — 输出占窗口比例(默认 1/16),窗口越大输出越大
58
62
  // maxOutputTokens — 输出下限(默认 4096),也可作为硬性覆盖
@@ -228,8 +232,14 @@ export class QueryEngine {
228
232
  // 敷衍重试次数(层 A):模型没调工具只回空话时,追加强引导重试
229
233
  let fillerRetries = 0
230
234
  const MAX_FILLER_RETRIES = 1
231
-
232
- for (let turn = 0; turn < this.config.maxTurns; turn++) {
235
+ // 工具循环轮数上限:
236
+ // - 小模型模式:用更小的上限(默认 8),避免模型陷入"写一个又写一个"的无限循环
237
+ // - 普通模式:用 config.maxTurns
238
+ const toolLoopLimit = smallModel
239
+ ? (this.config.smallModelMaxTurns || 8)
240
+ : this.config.maxTurns
241
+
242
+ for (let turn = 0; turn < toolLoopLimit; turn++) {
233
243
  // 发送前硬校验:工具结果可能已使上下文超窗,确保 ≤ 窗口(摘要优先 + 滑动窗口裁剪兜底)
234
244
  this._ensureFitWindow()
235
245
 
@@ -259,6 +269,22 @@ export class QueryEngine {
259
269
  intentGuided = true
260
270
  }
261
271
 
272
+ // 小模型模式:接近工具循环上限时,注入"总结收尾"引导,防止模型无限循环
273
+ if (smallModel && turn === toolLoopLimit - 1) {
274
+ const stopGuidance = `[系统提示] 你已经完成了大部分工作。现在请【停止调用新工具】,把已经完成的内容整理成一段总结回复给用户。如果确实还有关键步骤未完成,只再调用一次工具完成它,然后立即总结。不要再继续无休止地调用工具。`
275
+ const sysIdx = this.state.messages.findIndex(m => m.role === 'system')
276
+ if (sysIdx !== -1) {
277
+ const sysMsg = this.state.messages[sysIdx]
278
+ this.state.messages[sysIdx] = {
279
+ ...sysMsg,
280
+ content: (typeof sysMsg.content === 'string' ? sysMsg.content : '') + '\n\n' + stopGuidance,
281
+ }
282
+ } else {
283
+ this.state.messages.push({ role: 'user', content: stopGuidance })
284
+ }
285
+ if (this.config.verbose) console.error(`[small-model] 已到工具循环第 ${turn + 1} 轮(上限 ${toolLoopLimit}),注入收尾引导`)
286
+ }
287
+
262
288
  const requestMessages = this._buildRequest(this.state.messages)
263
289
  const response = await this._callLLM(requestMessages, this.state.messages)
264
290
 
@@ -268,16 +294,25 @@ export class QueryEngine {
268
294
 
269
295
  // 没有工具调用 → 潜在最终回复
270
296
  if (!response.toolCalls || response.toolCalls.length === 0) {
271
- // 小模型模式:检测敷衍输出(空话/太短/命中占位句),追加强引导重试
272
- if (smallModel && fillerRetries < MAX_FILLER_RETRIES && isFillerResponse(response)) {
273
- fillerRetries++
274
- if (this.config.verbose) {
275
- 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
276
315
  }
277
- // 把模型的空话 + 强制工具调用提醒注入上下文,再让模型重新决策
278
- this.state.messages.push(new AssistantMessage(response.content, [], response.reasoningContent))
279
- this.state.messages.push({ role: 'user', content: RETRY_GUIDANCE })
280
- continue // 继续循环,重新请求模型
281
316
  }
282
317
 
283
318
  finalResponse = response.content
@@ -306,8 +341,9 @@ export class QueryEngine {
306
341
  }
307
342
  }
308
343
 
309
- if (!finalResponse && this.state.turnCount >= this.config.maxTurns) {
310
- finalResponse = `[达到最大回合数限制 (${this.config.maxTurns}),停止响应]`
344
+ // 工具循环达到上限但仍未收尾(模型一直调用工具)→ 强制收尾
345
+ if (!finalResponse) {
346
+ finalResponse = `[已达到工具循环上限 (${toolLoopLimit}),已停止进一步调用工具。请查看上方工具执行结果,确认任务完成情况。]`
311
347
  }
312
348
 
313
349
  return {
@@ -473,6 +509,47 @@ export class QueryEngine {
473
509
  return results
474
510
  }
475
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
+
476
553
  async _callLLM(messages, contextMessages) {
477
554
  const apiKey = this.config.apiKey
478
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',