@raolin2025/claude-code-node 2.8.9 → 2.8.11

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
@@ -142,6 +142,9 @@ cc-node 会自动感知当前所用模型的**上下文窗口长度**,并在
142
142
  估算**判断是否超窗;超窗时把早期对话压缩为摘要(保留最近 4 轮 + 摘要),目标压到窗口
143
143
  的 60%。摘要会**显式标注最早的 `Main goal`(核心任务主线)**,并保留最早的关键发现与
144
144
  最新结果,避免例行内容淹没核心任务(防止 AI 压缩后失忆)。
145
+ > **保守触发(v2.8.11)**:由于启发式估算可能比模型真实 token 偏少,压缩**提前到
146
+ > 可用窗口的 85%** 即触发(`compressSafetyFactor`,默认 0.85),给 tokenization 差异
147
+ > 留余量,避免实际请求超过模型窗口(如 `exceed_context_size_error` 400 错误)。
145
148
  2. **滑动窗口精确裁剪**(兜底,保证永不超窗):摘要压缩后仍超窗时,从**最早的消息**逐条
146
149
  挤出,**最新信息始终保留在末尾**,直到总 token ≤ 窗口上限。system 提示永不裁剪;
147
150
  **被裁剪的早期历史会压缩成一条摘要 system 保留**(避免 AI 丢失上下文主线);极端情况
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raolin2025/claude-code-node",
3
- "version": "2.8.9",
3
+ "version": "2.8.11",
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",
@@ -103,6 +103,19 @@ test('滑动窗口:keepSummary=false 时不保留摘要', () => {
103
103
  assert.ok(!r.messages.some(m => m.role === 'system' && m.content.includes('Context Summary')))
104
104
  })
105
105
 
106
+ test('滑动窗口:limitOverride 指定保守裁剪上限(提前触发,防实际超窗)', () => {
107
+ const tb = makeBudget(500)
108
+ const msgs = makeMessages(20) // 41 条
109
+ // 用 limitOverride 指定一个更保守的裁剪上限(模拟估算偏差预留余量)
110
+ const hardLimit = tb.maxTokens - tb.reservedForOutput // 490
111
+ const conservative = Math.floor(hardLimit * 0.85) // 416
112
+ const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens, keepSummary: true, limitOverride: conservative })
113
+ assert.equal(r.trimmed, true)
114
+ // 裁剪后估算 ≤ 保守上限
115
+ assert.ok(tb.estimateMessages(r.messages) <= conservative,
116
+ `裁剪后 ${tb.estimateMessages(r.messages)} 应 ≤ 保守上限 ${conservative}`)
117
+ })
118
+
106
119
  test('滑动窗口:极端情况(单条消息超窗)仍保留 system + 最近一条', () => {
107
120
  const tb = makeBudget(50) // 窗口很小
108
121
  // 构造:每条消息都较大,导致任何单条都可能超窗
@@ -276,7 +276,10 @@ export function trimToWindow(messages, options = {}) {
276
276
  const maxTokens = options.maxTokens || 160_000
277
277
  const reservedForOutput = options.reservedForOutput || (budget ? budget.reservedForOutput : 8192)
278
278
  const keepSummary = options.keepSummary !== false
279
- const limit = maxTokens - reservedForOutput
279
+ // 可直接指定裁剪上限(覆盖 maxTokens - reservedForOutput),用于保守触发场景
280
+ const limit = options.limitOverride != null
281
+ ? options.limitOverride
282
+ : maxTokens - reservedForOutput
280
283
 
281
284
  const estimate = (msgs) => budget
282
285
  ? budget.estimateMessages(msgs)
@@ -32,6 +32,8 @@ export class QueryEngineConfig {
32
32
  this.model = options.model || ''
33
33
  this.maxTurns = options.maxTurns || 100
34
34
  this.maxBudgetTokens = options.maxBudgetTokens || 1_000_000
35
+ // 压缩触发保守系数(0~1):估算到可用窗口的该比例即触发压缩,留出 tokenization 差异余量
36
+ this.compressSafetyFactor = options.compressSafetyFactor ?? 0.85
35
37
  this.permissionMode = options.permissionMode || 'ask'
36
38
  this.verbose = options.verbose || false
37
39
  // API 配置 — 通用 OpenAI 兼容协议
@@ -109,29 +111,42 @@ export class QueryEngine {
109
111
  * 4. 摘要后仍超窗 → 滑动窗口精确裁剪兜底:从最早完整 user 回合挤出,
110
112
  * 并把被裁掉的历史压缩成摘要 system 保留,避免 AI 失忆。
111
113
  * 保证最新信息(含刚加入的用户消息)保留在末尾,上下文永不超出窗口。
114
+ *
115
+ * 保守化(v2.8.11):
116
+ * estimateMessages 是启发式估算,可能比模型真实 token 偏少。若等到估算真正
117
+ * 超过 hardLimit 才压缩,实际 token 可能已超模型窗口(如 400 exceed_context_size)。
118
+ * 因此压缩触发点提前到 hardLimit × SAFETY_FACTOR(默认 85%),给 tokenization
119
+ * 差异留余量,确保实际请求永远 ≤ 模型窗口。
112
120
  */
113
121
  _ensureFitWindow() {
114
122
  if (!this.tokenBudget) return
115
- const limit = this.tokenBudget.maxTokens - this.tokenBudget.reservedForOutput
123
+ const maxTokens = this.tokenBudget.maxTokens
124
+ const hardLimit = maxTokens - (this.tokenBudget.reservedForOutput || 0)
125
+ // 保守触发阈值:估算到可用窗口的 85% 就开始压缩,避免估算偏差导致实际超窗
126
+ const safetyFactor = this.config.compressSafetyFactor ?? 0.85
127
+ const triggerLimit = Math.max(1, Math.floor(hardLimit * safetyFactor))
116
128
  const est = this.tokenBudget.estimateMessages(this.state.messages)
117
- if (est <= limit) return
129
+ if (est <= triggerLimit) return
118
130
 
119
131
  // 1) 摘要式压缩优先:用实时估算直接决定是否压缩(不再依赖滞后的 usagePercent)
132
+ // 压缩目标也用保守阈值(而非 hardLimit),确保压缩后实际 token 远离窗口上限
120
133
  const summarized = compactMessages(this.state.messages, {
121
- maxTokens: Math.floor(this.tokenBudget.maxTokens * 0.6),
134
+ maxTokens: Math.floor(maxTokens * 0.6),
122
135
  })
123
136
  const reEst = this.tokenBudget.estimateMessages(summarized)
124
- if (reEst <= limit) {
137
+ if (reEst <= triggerLimit) {
125
138
  this.state.messages = summarized
126
- if (this.config.verbose) console.error('[compact] Context summarized to fit token budget')
139
+ if (this.config.verbose) console.error(`[compact] Context summarized to fit token budget (${est} → ${reEst}, trigger<${triggerLimit})`)
127
140
  return
128
141
  }
129
142
 
130
143
  // 2) 滑动窗口精确裁剪兜底(摘要仍超窗):裁剪时保留被裁剪历史的摘要
131
144
  const { trimmed, messages: trimmedMsgs, removed, summary } = trimToWindow(this.state.messages, {
132
145
  tokenBudget: this.tokenBudget,
133
- maxTokens: this.tokenBudget.maxTokens,
146
+ maxTokens,
134
147
  keepSummary: true,
148
+ // 裁剪也需满足保守阈值(估算 ≤ triggerLimit),避免估算偏差导致实际超窗
149
+ limitOverride: triggerLimit,
135
150
  })
136
151
  if (trimmed) {
137
152
  this.state.messages = trimmedMsgs
@@ -347,6 +362,7 @@ export class QueryEngine {
347
362
 
348
363
  const useStream = !this.config.noStream
349
364
  const modelLower = this.config.model.toLowerCase()
365
+ const url = apiBase.replace(/\/+$/, '') + '/chat/completions'
350
366
 
351
367
  const body = {
352
368
  model: this.config.model,
@@ -377,8 +393,6 @@ export class QueryEngine {
377
393
  body.thinking = { type: 'disabled' }
378
394
  }
379
395
 
380
- const url = apiBase.replace(/\/+$/, '') + '/chat/completions'
381
-
382
396
  // apiBase 是用户显式指定的配置(--api-base),不是外部输入,跳过 SSRF 检查
383
397
  // SSRF 防护仅适用于 web-fetch/web-search 等工具发起的请求
384
398
  const maxRetries = 3