@raolin2025/claude-code-node 2.8.15 → 2.8.17

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/README.md CHANGED
@@ -69,6 +69,7 @@ cc-node --resume session-1747000000000-abc123
69
69
  | `--no-stream` | | 禁用流式响应 | `false` |
70
70
  | `--max-messages` | | 消息条数上限,超过则折叠早期历史为摘要(解决本地小模型"条数过多变傻") | `0`(关闭) |
71
71
  | `--small-model` | | 小模型适配模式(强制工具调用 + 敷衍重试 + 意图引导 + 工具精简) | `false` |
72
+ | `--max-output-tokens` | | 覆盖单次响应输出上限(默认根据上下文窗口动态计算) | 窗口×1/16 |
72
73
  | `--stdio` | | **JSON-RPC 服务器模式**(供桥接层/外部客户端接入,见下) | |
73
74
  | `--help` | `-h` | 显示帮助 | |
74
75
 
@@ -177,6 +178,24 @@ cc-node 会自动感知当前所用模型的**上下文窗口长度**,并在
177
178
 
178
179
  > 切换模型(`/model`)后会自动重新探测窗口。`/budget` 也会显示当前窗口与 80% 触发阈值。
179
180
 
181
+ ### 📤 单次输出上限(max_tokens)动态计算
182
+
183
+ cc-node 的**单次响应输出上限**(`max_tokens`)不再写死 4096,而是**根据上下文窗口大小动态计算**:
184
+
185
+ ```
186
+ max_tokens = max(4096, 窗口大小 × 1/16),且不超过窗口的一半
187
+ ```
188
+
189
+ | 窗口 | 动态输出上限 |
190
+ |------|-------------|
191
+ | 65536 (64K) | 4096 |
192
+ | 131072 (128K) | **8192** |
193
+ | 200000 | 12500 |
194
+
195
+ 这样窗口越大,单次输出空间越大,避免小模型 Write 大文件时被截断;同时输出**绝不超过窗口一半**,保证输入有足够空间、永不超窗。
196
+
197
+ > 可用 `--max-output-tokens N` 或 `config.maxOutputTokens` 覆盖(作为输出下限)。
198
+
180
199
  ### 🤖 小模型适配模式(--small-model)
181
200
 
182
201
  > 专为 **本地小模型**(如 27B Q3 量化)设计,让编程工具在弱模型下也能可靠工作。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raolin2025/claude-code-node",
3
- "version": "2.8.15",
3
+ "version": "2.8.17",
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",
@@ -189,3 +189,66 @@ test('多步计划:普通任务走简单意图引导', () => {
189
189
  assert.ok(g, '应生成引导')
190
190
  assert.ok(g.includes('任务引导'), '应走简单意图引导')
191
191
  })
192
+
193
+ // ---- 动态 max_tokens 计算 ----
194
+ test('max_tokens:根据窗口大小动态计算', async () => {
195
+ const { QueryEngine } = await import('../core/query-engine.js')
196
+ const { TokenBudget } = await import('../core/token-budget.js')
197
+ // 131072 窗口 → 8192(窗口的 1/16)
198
+ const qe1 = new QueryEngine({ tokenBudget: new TokenBudget({ maxTokens: 131072 }) })
199
+ assert.equal(qe1._computeMaxOutputTokens(), 8192)
200
+ // 65536 → 4096
201
+ const qe2 = new QueryEngine({ tokenBudget: new TokenBudget({ maxTokens: 65536 }) })
202
+ assert.equal(qe2._computeMaxOutputTokens(), 4096)
203
+ // 无窗口 → 兜底 4096
204
+ const qe3 = new QueryEngine({})
205
+ assert.equal(qe3._computeMaxOutputTokens(), 4096)
206
+ })
207
+
208
+ test('max_tokens:可配置输出比例与下限覆盖', async () => {
209
+ const { QueryEngine } = await import('../core/query-engine.js')
210
+ const { TokenBudget } = await import('../core/token-budget.js')
211
+ // outputRatio=0.1 → 131072*0.1=13107
212
+ const qe1 = new QueryEngine({ tokenBudget: new TokenBudget({ maxTokens: 131072 }), outputRatio: 0.1 })
213
+ assert.equal(qe1._computeMaxOutputTokens(), 13107)
214
+ // maxOutputTokens=8192 作为下限
215
+ const qe2 = new QueryEngine({ tokenBudget: new TokenBudget({ maxTokens: 131072 }), maxOutputTokens: 8192 })
216
+ assert.equal(qe2._computeMaxOutputTokens(), 8192)
217
+ })
218
+
219
+ test('max_tokens:绝不超过窗口一半(防超窗)', async () => {
220
+ const { QueryEngine } = await import('../core/query-engine.js')
221
+ const { TokenBudget } = await import('../core/token-budget.js')
222
+ // 小窗口 8192:窗口一半 = 4096,1/16 = 512,取 max(4096,512)=4096 ≤ 4096
223
+ const qe1 = new QueryEngine({ tokenBudget: new TokenBudget({ maxTokens: 8192 }) })
224
+ assert.ok(qe1._computeMaxOutputTokens() <= 4096, `max_tokens=${qe1._computeMaxOutputTokens()} 不应超过窗口一半 4096`)
225
+ // 极端 outputRatio=1.0 也不应超过窗口一半
226
+ const qe2 = new QueryEngine({ tokenBudget: new TokenBudget({ maxTokens: 10000 }), outputRatio: 1.0 })
227
+ assert.ok(qe2._computeMaxOutputTokens() <= 5000, `max_tokens=${qe2._computeMaxOutputTokens()} 不应超过窗口一半 5000`)
228
+ })
229
+
230
+ // ---- _buildRequest 把 system 统一前置(防 llama.cpp 500)----
231
+ test('_buildRequest:把中间 system 统一前置到开头', async () => {
232
+ const { QueryEngine } = await import('../core/query-engine.js')
233
+ const qe = new QueryEngine({ systemPrompt: 'SYS-ROOT', tools: [] })
234
+ // 模拟 state.messages 里 system 摘要被插到中间(折叠/恢复导致)
235
+ const messages = [
236
+ { role: 'user', content: 'u1' },
237
+ { role: 'assistant', content: 'a1' },
238
+ { role: 'system', content: '[Context Summary] 中间摘要' },
239
+ { role: 'user', content: 'u2' },
240
+ ]
241
+ const req = qe._buildRequest(messages)
242
+ // 验证:所有 system 在开头,中间无 system
243
+ let seenBody = false, badSystem = false
244
+ for (const m of req) {
245
+ if (m.role !== 'system') seenBody = true
246
+ else if (seenBody) badSystem = true
247
+ }
248
+ assert.equal(badSystem, false, '中间不应出现 system(否则 llama.cpp 报 500)')
249
+ // 第一条是 system
250
+ assert.equal(req[0].role, 'system')
251
+ // 原始顺序(非 system 部分)应保持
252
+ const nonSys = req.filter(m => m.role !== 'system').map(m => m.role)
253
+ assert.deepEqual(nonSys, ['user', 'assistant', 'user'])
254
+ })
package/src/core/cli.js CHANGED
@@ -332,6 +332,7 @@ function parseArgs(argv) {
332
332
  noStream: false,
333
333
  maxMessages: 0,
334
334
  smallModel: false,
335
+ maxOutputTokens: 0,
335
336
  }
336
337
 
337
338
  let i = 2
@@ -349,6 +350,7 @@ function parseArgs(argv) {
349
350
  case '--no-stream': args.noStream = true; break
350
351
  case '--max-messages': args.maxMessages = parseInt(argv[++i], 10); break
351
352
  case '--small-model': args.smallModel = true; break
353
+ case '--max-output-tokens': args.maxOutputTokens = parseInt(argv[++i], 10); break
352
354
  case '--stdio': args.stdio = true; break
353
355
  case '--with-notify': args.withNotify = true; break
354
356
  case '--version':
@@ -371,6 +373,7 @@ Options:
371
373
  --no-stream Disable streaming
372
374
  --max-messages N Fold history when message count exceeds N (default: 0 = off)
373
375
  --small-model Enable small-model adaptation (tool-call enforcement, filler retry, intent guidance)
376
+ --max-output-tokens N Override max single-response output tokens (default: computed from window size)
374
377
  --with-notify Start built-in channel listener (Telegram)
375
378
  (replaces cc-notify daemon — no external script needed)
376
379
  -h, --help Show this help
@@ -535,6 +538,8 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
535
538
  maxMessages: cliArgs.maxMessages || config.get('maxMessages') || 0,
536
539
  // 小模型适配模式(强制工具调用 + 敷衍重试 + 意图引导 + 工具精简)
537
540
  smallModel: cliArgs.smallModel || config.get('smallModel') || false,
541
+ // 单次输出上限覆盖(默认根据窗口动态计算)
542
+ maxOutputTokens: cliArgs.maxOutputTokens || config.get('maxOutputTokens') || 0,
538
543
  })
539
544
  const engine = new QueryEngine(engineConfig)
540
545
 
@@ -53,6 +53,11 @@ export class QueryEngineConfig {
53
53
  // - 工具数量精简 + 意图引导
54
54
  // 默认关闭,通过 config.smallModel=true 或 --small-model 开启
55
55
  this.smallModel = options.smallModel || false
56
+ // 单次输出上限动态计算:
57
+ // outputRatio — 输出占窗口比例(默认 1/16),窗口越大输出越大
58
+ // maxOutputTokens — 输出下限(默认 4096),也可作为硬性覆盖
59
+ this.outputRatio = options.outputRatio != null ? options.outputRatio : 1 / 16
60
+ this.maxOutputTokens = options.maxOutputTokens || 0
56
61
  this.permissionMode = options.permissionMode || 'ask'
57
62
  this.verbose = options.verbose || false
58
63
  // API 配置 — 通用 OpenAI 兼容协议
@@ -312,6 +317,32 @@ export class QueryEngine {
312
317
  }
313
318
  }
314
319
 
320
+ /**
321
+ * 动态计算单次输出上限 max_tokens
322
+ *
323
+ * 不再写死 4096,而是根据当前上下文窗口(/window 设置、tokenBudget.maxTokens)
324
+ * 按比例动态得出——窗口越大,允许的单次输出越大,避免小模型写大文件时被截断。
325
+ *
326
+ * 公式:max_tokens = max(基础下限, 窗口 × 输出比例)
327
+ * - 输出比例默认 1/16(6.25%):131072 窗口 → 8192;65536 → 4096
328
+ * - 下限 4096:窗口很小时也有足够输出空间
329
+ * - 上限不超过窗口的一半(留足输入空间,绝不超窗)
330
+ *
331
+ * @returns {number}
332
+ */
333
+ _computeMaxOutputTokens() {
334
+ const windowSize = this.tokenBudget?.maxTokens
335
+ // 可配置:默认输出比例 1/16,下限 4096
336
+ const ratio = this.config.outputRatio != null ? this.config.outputRatio : 1 / 16
337
+ const min = this.config.maxOutputTokens || 4096
338
+ if (!windowSize || !Number.isFinite(windowSize) || windowSize <= 0) return min
339
+ const byWindow = Math.floor(windowSize * ratio)
340
+ // 上限 = 窗口一半,保证输入有足够空间,绝不超窗
341
+ const cap = Math.floor(windowSize / 2)
342
+ const maxOut = Math.min(cap, Math.max(min, byWindow))
343
+ return maxOut
344
+ }
345
+
315
346
  /**
316
347
  * 构建 LLM 请求消息列表 — 统一 OpenAI 兼容格式
317
348
  */
@@ -327,10 +358,25 @@ export class QueryEngine {
327
358
  }
328
359
 
329
360
  // 历史消息 — 转换为 OpenAI 兼容格式
361
+ // 关键:把所有 system 消息【收集到一起放到最前面】,而不是原样透传。
362
+ // 原因:llama.cpp/Jinja 严格要求 system 消息必须在对话开头,中间出现 system
363
+ // 会报 "System message must be at the beginning" (500)。
364
+ // 折叠/压缩/引导注入可能在 state.messages 中间留下 system 摘要消息,
365
+ // 这里统一前置,彻底规避该错误。
366
+ const systemMsgs = []
367
+ const bodyMsgs = []
330
368
  for (const msg of messages) {
331
369
  if (msg.role === 'system') {
332
- request.push({ role: 'system', content: msg.content })
333
- } else if (msg.role === 'user') {
370
+ systemMsgs.push(msg)
371
+ } else {
372
+ bodyMsgs.push(msg)
373
+ }
374
+ }
375
+ for (const msg of systemMsgs) {
376
+ request.push({ role: 'system', content: msg.content })
377
+ }
378
+ for (const msg of bodyMsgs) {
379
+ if (msg.role === 'user') {
334
380
  request.push({ role: 'user', content: this._buildUserContent(msg) })
335
381
  } else if (msg.role === 'assistant') {
336
382
  // 构建 assistant 消息基础
@@ -475,7 +521,8 @@ export class QueryEngine {
475
521
  const body = {
476
522
  model: this.config.model,
477
523
  messages,
478
- max_tokens: 4096,
524
+ // 根据当前上下文窗口动态计算输出上限(不再写死 4096
525
+ max_tokens: this._computeMaxOutputTokens(),
479
526
  ...(tools.length && { tools }),
480
527
  ...(useStream && { stream: true }),
481
528
  }