@raolin2025/claude-code-node 2.8.11 → 2.8.12

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
@@ -67,6 +67,7 @@ cc-node --resume session-1747000000000-abc123
67
67
  | `--resume` | `-r` | 恢复会话 ID | |
68
68
  | `--verbose` | `-v` | 详细输出 | `false` |
69
69
  | `--no-stream` | | 禁用流式响应 | `false` |
70
+ | `--max-messages` | | 消息条数上限,超过则折叠早期历史为摘要(解决本地小模型"条数过多变傻") | `0`(关闭) |
70
71
  | `--stdio` | | **JSON-RPC 服务器模式**(供桥接层/外部客户端接入,见下) | |
71
72
  | `--help` | `-h` | 显示帮助 | |
72
73
 
@@ -136,16 +137,25 @@ cc-node 会自动感知当前所用模型的**上下文窗口长度**,并在
136
137
 
137
138
  ### 自动压缩机制(滑动窗口)
138
139
 
139
- 上下文**永不超出窗口**,采用"摘要优先 + 滑动窗口裁剪兜底"的双层策略:
140
+ 上下文**永不超出窗口**,采用"条数折叠 + 摘要优先 + 滑动窗口裁剪兜底"的多层策略:
140
141
 
141
- 1. **摘要式压缩**(信息量更高):每次新消息加入 / 工具结果返回 / 发送前,用**实时 token
142
+ **0. 发送前常驻工具结果截断**(v2.8.12):每次发送前(不依赖是否超窗)对**超长工具结果**
143
+ (默认 >6000 字符)做截断,从源头压住"工具结果过程噪音"堆积,避免它们淹没模型对最新
144
+ 指令的注意力。
145
+
146
+ **1. 消息条数折叠**(v2.8.12,`--max-messages N` 开启,默认关闭):当上下文**消息条数**
147
+ 超过 `N`(如 80)时,把早期历史折叠成一条摘要(保留 `Main goal` / 工具使用 / 关键结果),
148
+ 仅保留最近 4 轮完整对话。这解决**本地小模型(如 27B)"token 未超窗但 200+ 条消息却变傻、
149
+ 不干活"**的问题——这类模型对"消息条数"比"token 数"更敏感。
150
+
151
+ **2. 摘要式压缩**(信息量更高):每次新消息加入 / 工具结果返回 / 发送前,用**实时 token
142
152
  估算**判断是否超窗;超窗时把早期对话压缩为摘要(保留最近 4 轮 + 摘要),目标压到窗口
143
153
  的 60%。摘要会**显式标注最早的 `Main goal`(核心任务主线)**,并保留最早的关键发现与
144
154
  最新结果,避免例行内容淹没核心任务(防止 AI 压缩后失忆)。
145
155
  > **保守触发(v2.8.11)**:由于启发式估算可能比模型真实 token 偏少,压缩**提前到
146
156
  > 可用窗口的 85%** 即触发(`compressSafetyFactor`,默认 0.85),给 tokenization 差异
147
157
  > 留余量,避免实际请求超过模型窗口(如 `exceed_context_size_error` 400 错误)。
148
- 2. **滑动窗口精确裁剪**(兜底,保证永不超窗):摘要压缩后仍超窗时,从**最早的消息**逐条
158
+ **3. 滑动窗口精确裁剪**(兜底,保证永不超窗):摘要压缩后仍超窗时,从**最早的消息**逐条
149
159
  挤出,**最新信息始终保留在末尾**,直到总 token ≤ 窗口上限。system 提示永不裁剪;
150
160
  **被裁剪的早期历史会压缩成一条摘要 system 保留**(避免 AI 丢失上下文主线);极端情况
151
161
  (单条消息超窗)仍保留 system + 最近一条,保证至少能发出请求。
@@ -330,6 +340,7 @@ cc-node --api-base http://localhost:11434/v1
330
340
  "model": "deepseek-chat",
331
341
  "maxTurns": 100,
332
342
  "maxBudgetTokens": 128000,
343
+ "maxMessages": 80,
333
344
  "permissionMode": "ask",
334
345
  "tools": {
335
346
  "bash": { "timeout": 120 },
@@ -345,6 +356,10 @@ cc-node --api-base http://localhost:11434/v1
345
356
  > **`maxBudgetTokens`**:手动指定的上下文窗口上限(token 数)。
346
357
  > 为 `0` 或未设置时,自动探测模型真实窗口;手动指定后优先于自动探测,
347
358
  > 等价于 `/window N` 的效果,并持久化保存。
359
+ >
360
+ > **`maxMessages`**:消息条数上限(可选,默认关闭/`0`)。当上下文消息条数超过该值时,
361
+ > 自动折叠早期历史为摘要(保留 Main goal + 最近 4 轮完整对话),解决本地小模型
362
+ > "条数过多、token 不高却变傻"的问题。等价于 `--max-messages N`。
348
363
 
349
364
  [![npm version](https://img.shields.io/npm/v/@raolin2025/claude-code-node.svg)](https://www.npmjs.com/package/@raolin2025/claude-code-node) [![GitHub](https://img.shields.io/badge/GitHub-bg1avd%2Fclaude--code--node-blue)](https://github.com/bg1avd/claude-code-node) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
350
365
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raolin2025/claude-code-node",
3
- "version": "2.8.11",
3
+ "version": "2.8.12",
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",
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { test } from 'node:test'
13
13
  import assert from 'node:assert/strict'
14
- import { trimToWindow, compactMessages } from '../core/compact.js'
14
+ import { trimToWindow, compactMessages, foldHistoryByCount, trimToolResults } from '../core/compact.js'
15
15
  import { TokenBudget } from '../core/token-budget.js'
16
16
 
17
17
  /** 构造一个固定窗口的 TokenBudget */
@@ -187,3 +187,100 @@ test('摘要改进:数字归一化去重,避免例行序号占满摘要', ()
187
187
  const intentCount = (sm.content.match(/例行检查/g) || []).length
188
188
  assert.ok(intentCount <= 2, `例行意图应被去重(实际 ${intentCount} 条)`)
189
189
  })
190
+
191
+ // ============================================================
192
+ // foldHistoryByCount — 按消息条数折叠(解决"条数过多、token不高却变傻")
193
+ // ============================================================
194
+
195
+ /** 构造带工具结果的对话:system + n 轮(每轮 user + assistant(tool_calls) + tool + assistant) */
196
+ function makeToolMessages(n, { sysContent = 'SYS' } = {}) {
197
+ const msgs = [{ role: 'system', content: sysContent }]
198
+ for (let i = 0; i < n; i++) {
199
+ msgs.push({ role: 'user', content: `用户任务 ${i}:做点事` })
200
+ msgs.push({ role: 'assistant', content: '', toolCalls: [{ id: `c${i}`, name: 'Bash', input: { command: 'ls' } }] })
201
+ msgs.push({ role: 'tool', tool_call_id: `c${i}`, content: `工具结果 ${i} 的内容` })
202
+ msgs.push({ role: 'assistant', content: `完成 ${i} 的汇报` })
203
+ }
204
+ return msgs
205
+ }
206
+
207
+ test('条数折叠:未超过 maxMessages 时原样保留', () => {
208
+ const msgs = makeToolMessages(5) // 1 + 20 = 21 条
209
+ const r = foldHistoryByCount(msgs, { maxMessages: 80 })
210
+ assert.equal(r.folded, false)
211
+ assert.equal(r.removed, 0)
212
+ assert.equal(r.messages.length, msgs.length)
213
+ })
214
+
215
+ test('条数折叠:超过 maxMessages 时把早期历史折叠为摘要', () => {
216
+ const msgs = makeToolMessages(30) // 1 + 120 = 121 条 > 80
217
+ const r = foldHistoryByCount(msgs, { maxMessages: 80, keepRecentTurns: 4 })
218
+ assert.equal(r.folded, true)
219
+ assert.ok(r.removed > 0, '应移除早期消息')
220
+ assert.ok(r.messages.length < msgs.length, '折叠后消息数应减少')
221
+ // 折叠后消息数应显著降低(远低于 121)
222
+ assert.ok(r.messages.length < 60, `折叠后应降到可管理数量(实际 ${r.messages.length})`)
223
+ // 摘要 system 保留
224
+ assert.ok(r.messages.some(m => m.role === 'system' && m.content.includes('Context Summary')),
225
+ '应插入折叠摘要 system')
226
+ // Main goal 保留最早的核心任务
227
+ assert.ok(r.summary.includes('用户任务 0'), '摘要应保留最早的核心任务')
228
+ })
229
+
230
+ test('条数折叠:最新 4 轮完整对话保留在末尾(含工具调用链无空洞)', () => {
231
+ const msgs = makeToolMessages(20) // 1 + 80 = 81 条 > 80
232
+ const r = foldHistoryByCount(msgs, { maxMessages: 80, keepRecentTurns: 4 })
233
+ // 保留最近 4 轮 = 16 条 + system + 摘要 system
234
+ // 检查末尾保留了最新的用户任务
235
+ const lastUser = [...r.messages].reverse().find(m => m.role === 'user')
236
+ assert.ok(lastUser, '应有 user 消息')
237
+ assert.ok(lastUser.content.includes('用户任务 19'), '最新一轮应保留')
238
+ })
239
+
240
+ test('条数折叠:system 提示永不折叠', () => {
241
+ const msgs = makeToolMessages(30)
242
+ const r = foldHistoryByCount(msgs, { maxMessages: 80 })
243
+ assert.equal(r.messages[0].role, 'system')
244
+ assert.equal(r.messages[0].content, 'SYS')
245
+ })
246
+
247
+ test('条数折叠:keepRecentTurns=1 时只保留最新一轮', () => {
248
+ const msgs = makeToolMessages(10) // 1 + 40 = 41 条
249
+ const r = foldHistoryByCount(msgs, { maxMessages: 20, keepRecentTurns: 1 })
250
+ assert.equal(r.folded, true)
251
+ // 1(system) + 1(摘要) + 4(最新一轮) = 6
252
+ assert.equal(r.messages.length, 6, `应保留 system+摘要+最新一轮(实际 ${r.messages.length})`)
253
+ })
254
+
255
+ // ============================================================
256
+ // trimToolResults — 发送前常驻工具结果截断(不依赖超窗)
257
+ // ============================================================
258
+
259
+ test('工具结果截断:超长工具结果被截断到 maxChars', () => {
260
+ const msgs = [
261
+ { role: 'tool', tool_call_id: 'a', content: 'x'.repeat(10000) },
262
+ ]
263
+ const r = trimToolResults(msgs, 6000)
264
+ assert.ok(r[0].content.length <= 6000 + 50, `截断后长度 ${r[0].content.length} 应 ≤ 6000`)
265
+ assert.ok(r[0].content.includes('truncated'), '应有截断标记')
266
+ })
267
+
268
+ test('工具结果截断:未超长的不受影响', () => {
269
+ const msgs = [
270
+ { role: 'tool', tool_call_id: 'a', content: 'short' },
271
+ { role: 'user', content: 'hello' },
272
+ ]
273
+ const r = trimToolResults(msgs, 6000)
274
+ assert.equal(r[0].content, 'short')
275
+ assert.equal(r[1].content, 'hello')
276
+ })
277
+
278
+ test('工具结果截断:非 tool 消息与空内容不受影响', () => {
279
+ const msgs = [
280
+ { role: 'user', content: 'x'.repeat(10000) }, // user 不截断
281
+ { role: 'tool', tool_call_id: 'a', content: '' },
282
+ ]
283
+ const r = trimToolResults(msgs, 100)
284
+ assert.equal(r[0].content.length, 10000, 'user 消息不应被截断')
285
+ assert.equal(r[1].content, '')
286
+ })
package/src/core/cli.js CHANGED
@@ -330,6 +330,7 @@ function parseArgs(argv) {
330
330
  apiBase: 'https://api.deepseek.com/v1',
331
331
  resume: null,
332
332
  noStream: false,
333
+ maxMessages: 0,
333
334
  }
334
335
 
335
336
  let i = 2
@@ -345,6 +346,7 @@ function parseArgs(argv) {
345
346
  case '--resume': case '-r': args.resume = argv[++i]; break
346
347
  case '--verbose': case '-v': args.verbose = true; break
347
348
  case '--no-stream': args.noStream = true; break
349
+ case '--max-messages': args.maxMessages = parseInt(argv[++i], 10); break
348
350
  case '--stdio': args.stdio = true; break
349
351
  case '--with-notify': args.withNotify = true; break
350
352
  case '--version':
@@ -365,6 +367,7 @@ Options:
365
367
  --version Show version
366
368
  -v, --verbose Verbose mode
367
369
  --no-stream Disable streaming
370
+ --max-messages N Fold history when message count exceeds N (default: 0 = off)
368
371
  --with-notify Start built-in channel listener (Telegram)
369
372
  (replaces cc-notify daemon — no external script needed)
370
373
  -h, --help Show this help
@@ -525,6 +528,8 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
525
528
  costTracker,
526
529
  tokenBudget,
527
530
  configStore: config,
531
+ // 消息条数上限(折叠早期历史,解决本地小模型"条数过多变傻");0 = 关闭
532
+ maxMessages: cliArgs.maxMessages || config.get('maxMessages') || 0,
528
533
  })
529
534
  const engine = new QueryEngine(engineConfig)
530
535
 
@@ -142,6 +142,115 @@ function isConclusionLike(text) {
142
142
  return CONCLUSION_HINTS.some(k => s.includes(k))
143
143
  }
144
144
 
145
+ /**
146
+ * 发送前常驻工具结果截断 — 不依赖是否超窗
147
+ *
148
+ * 背景:长对话中工具结果(命令回显、git 输出等)会不断累积,即便单条不长,
149
+ * 条数一多也会让模型(尤其本地小模型)"迷失"在过程噪音里。旧逻辑只在 token
150
+ * 超窗时才截断,导致 token 未超窗(如 37%)但 200+ 条消息让模型变傻。
151
+ *
152
+ * 本函数在【每次发送前】都对工具结果做上限截断(默认 6000 字符),
153
+ * 阈值比压缩时的 maxToolResultChars(2000)宽松,避免频繁误截,
154
+ * 同时把"超长噪音"从源头上压住。
155
+ *
156
+ * @param {Array} messages — 消息列表
157
+ * @param {number} [maxChars=6000] — 工具结果保留的最大字符数
158
+ * @returns {Array} 截断后的消息列表(原列表被浅拷贝修改,不影响调用方原始引用结构)
159
+ */
160
+ export function trimToolResults(messages, maxChars = 6000) {
161
+ return messages.map(msg => {
162
+ if (msg.role === 'tool' && typeof msg.content === 'string' && msg.content.length > maxChars) {
163
+ return {
164
+ ...msg,
165
+ content: msg.content.slice(0, maxChars) + `\n[...tool result truncated: kept ${maxChars} chars]`,
166
+ }
167
+ }
168
+ return msg
169
+ })
170
+ }
171
+
172
+ /**
173
+ * 按消息条数折叠历史 — 解决"条数过多、token 不高却变傻"
174
+ *
175
+ * 背景:27B 等本地小模型对"消息条数"比"token 数"更敏感。一个 session 塞进
176
+ * 200+ 条消息(113 条工具结果 + 84 条 assistant + 多个 user),即便 token 只占
177
+ * 窗口 37%,模型也会因为角色切换频繁、工具结果噪音堆积而"迷失"当前指令。
178
+ *
179
+ * 本函数在消息条数超过 maxMessages 时,把早期历史折叠成一条摘要
180
+ * (保留 Main goal + 工具使用 + 关键结果),仅保留最近 keepRecentTurns 轮的
181
+ * 完整对话,把消息条数压到可管理范围,同时不丢主线。
182
+ *
183
+ * @param {Array} messages — 完整消息列表
184
+ * @param {object} options
185
+ * @param {number} [options.maxMessages=80] — 超过此条数即触发折叠
186
+ * @param {number} [options.keepRecentTurns=4] — 保留最近 N 轮完整对话
187
+ * @param {number} [options.maxToolResultChars=2000] — 折叠时工具结果截断长度
188
+ * @returns {{ folded: boolean, messages: Array, removed: number, summary: string|null }}
189
+ */
190
+ export function foldHistoryByCount(messages, options = {}) {
191
+ const maxMessages = options.maxMessages || 80
192
+ const keepRecentTurns = options.keepRecentTurns ?? 4
193
+ const maxToolResultChars = options.maxToolResultChars || 2000
194
+
195
+ // 未超过条数阈值 → 不折叠
196
+ if (messages.length <= maxMessages) {
197
+ return { folded: false, messages, removed: 0, summary: null }
198
+ }
199
+
200
+ // 分离 system 提示(首条 system 永不折叠)与普通消息
201
+ const systemMsgs = []
202
+ const body = []
203
+ for (const m of messages) {
204
+ if (m.role === 'system' && systemMsgs.length === 0) {
205
+ systemMsgs.push(m)
206
+ } else {
207
+ body.push(m)
208
+ }
209
+ }
210
+
211
+ // 找到分界点:保留最近 keepRecentTurns 轮
212
+ // 一轮 = user + assistant(+tool_calls) + tool 结果们 + assistant 最终回复
213
+ // 从末尾倒推,数到第 keepRecentTurns 个 user 即分界(splitIndex 指向该轮起点,
214
+ // 使得 recentMessages 恰好包含最近 keepRecentTurns 轮完整对话)
215
+ let turnCount = 0
216
+ let splitIndex = body.length
217
+ for (let i = body.length - 1; i >= 0; i--) {
218
+ if (body[i].role === 'user') {
219
+ turnCount++
220
+ if (turnCount >= keepRecentTurns) {
221
+ splitIndex = i
222
+ break
223
+ }
224
+ }
225
+ }
226
+
227
+ // 没有可折叠的早期消息(全都要保留)→ 不折叠
228
+ if (splitIndex <= 0 || splitIndex >= body.length) {
229
+ return { folded: false, messages, removed: 0, summary: null }
230
+ }
231
+
232
+ const earlyMessages = body.slice(0, splitIndex)
233
+ const recentMessages = body.slice(splitIndex)
234
+
235
+ // 对折叠掉的历史生成摘要(保留 Main goal / 工具 / 关键结果)
236
+ const summary = generateSummary(earlyMessages)
237
+
238
+ // 构建折叠后的消息列表:system + (摘要 system) + 最近 N 轮完整对话
239
+ const folded = [...systemMsgs]
240
+ folded.push({
241
+ role: 'system',
242
+ content: `[Context Summary — ${new Date().toISOString()}]\n${summary}\n[End of Summary — recent conversation follows]`,
243
+ })
244
+ folded.push(...recentMessages)
245
+
246
+ return {
247
+ folded: true,
248
+ messages: folded,
249
+ removed: earlyMessages.length,
250
+ summary,
251
+ }
252
+ }
253
+
145
254
  /**
146
255
  * 从消息列表生成摘要
147
256
  *
@@ -14,7 +14,7 @@
14
14
  import crypto from 'crypto'
15
15
  import { UserMessage, AssistantMessage, ToolCall, ToolResult, SessionState } from '../types/index.js'
16
16
  import { parseStream, parseNonStreamResponse } from './streaming.js'
17
- import { compactMessages, trimToWindow } from './compact.js'
17
+ import { compactMessages, trimToWindow, foldHistoryByCount, trimToolResults } from './compact.js'
18
18
  import { CostTracker } from './cost-tracker.js'
19
19
  import { EnhancedPermissionChecker } from '../security/enhanced-permission.js'
20
20
  import { isLocalLlmServer, buildAuthHeaders } from '../utils/index.js'
@@ -34,6 +34,11 @@ export class QueryEngineConfig {
34
34
  this.maxBudgetTokens = options.maxBudgetTokens || 1_000_000
35
35
  // 压缩触发保守系数(0~1):估算到可用窗口的该比例即触发压缩,留出 tokenization 差异余量
36
36
  this.compressSafetyFactor = options.compressSafetyFactor ?? 0.85
37
+ // 消息条数上限:超过则强制折叠早期历史(解决本地小模型"条数过多、token不高却变傻")
38
+ // 0 表示不启用条数折叠(仅按 token)
39
+ this.maxMessages = options.maxMessages || 0
40
+ // 发送前常驻工具结果截断长度(字符),防止超长工具结果堆积(0 表示不截断)
41
+ this.maxToolResultChars = options.maxToolResultChars || 6000
37
42
  this.permissionMode = options.permissionMode || 'ask'
38
43
  this.verbose = options.verbose || false
39
44
  // API 配置 — 通用 OpenAI 兼容协议
@@ -100,14 +105,17 @@ export class QueryEngine {
100
105
  }
101
106
 
102
107
  /**
103
- * 确保上下文 ≤ 窗口(滑动窗口语义)
108
+ * 确保上下文 ≤ 窗口(滑动窗口语义)+ 消息条数可控
104
109
  *
105
- * 处理顺序:
106
- * 1. 估算当前消息总 token(实时估算,反映 state.messages 真实大小);
107
- * 2. 若未超窗 → 不做任何事;
110
+ * 处理顺序(每层都基于上一层的输出,逐层收敛):
111
+ * 0. 发送前常驻工具结果截断(trimToolResults)—— 不依赖是否超窗,从源头压住
112
+ * 超长工具结果噪音,防止"token 未超窗但工具结果堆积"。
113
+ * 1. 消息条数折叠(foldHistoryByCount)—— 当 state.messages 条数超过
114
+ * maxMessages 时,把早期历史折叠成摘要,保留最近 N 轮完整对话。
115
+ * 解决本地小模型"200+ 条消息、token 仅 37% 却变傻不干活"的核心问题。
116
+ * 2. 估算当前消息总 token(实时估算,反映 state.messages 真实大小);
117
+ * 若未超窗 → 不做 token 层处理;
108
118
  * 3. 若超窗 → 先做摘要式压缩(保留最近 N 轮 + 早期摘要,信息量更高)。
109
- * 注意:这里直接用实时 token 估算判断是否压缩,而非依赖滞后的
110
- * usagePercent(那会导致"判定超窗但压缩永不触发")。
111
119
  * 4. 摘要后仍超窗 → 滑动窗口精确裁剪兜底:从最早完整 user 回合挤出,
112
120
  * 并把被裁掉的历史压缩成摘要 system 保留,避免 AI 失忆。
113
121
  * 保证最新信息(含刚加入的用户消息)保留在末尾,上下文永不超出窗口。
@@ -120,6 +128,33 @@ export class QueryEngine {
120
128
  */
121
129
  _ensureFitWindow() {
122
130
  if (!this.tokenBudget) return
131
+
132
+ // ---- 0) 发送前常驻工具结果截断(不依赖超窗)----
133
+ // 避免超长工具结果持续堆积成噪音;阈值宽松(默认 6000),仅在确实过长时截断
134
+ if (this.config.maxToolResultChars > 0) {
135
+ const beforeTrim = this.state.messages.length
136
+ this.state.messages = trimToolResults(this.state.messages, this.config.maxToolResultChars)
137
+ if (this.config.verbose && this.state.messages.length !== beforeTrim) {
138
+ console.error('[compact] 工具结果已按上限截断(常驻)')
139
+ }
140
+ }
141
+
142
+ // ---- 1) 消息条数折叠(解决本地小模型"条数过多变傻")----
143
+ // 不依赖 token 是否超窗:只要消息条数超过 maxMessages,就折叠早期历史为摘要
144
+ if (this.config.maxMessages > 0) {
145
+ const { folded, messages: foldedMsgs, removed, summary } = foldHistoryByCount(this.state.messages, {
146
+ maxMessages: this.config.maxMessages,
147
+ keepRecentTurns: 4,
148
+ })
149
+ if (folded) {
150
+ this.state.messages = foldedMsgs
151
+ if (this.config.verbose) {
152
+ console.error(`[compact] 消息条数 ${removed + foldedMsgs.length} > ${this.config.maxMessages},已折叠早期 ${removed} 条为摘要(保留最近 4 轮)`)
153
+ if (summary) console.error('[compact] 折叠摘要:\n' + summary)
154
+ }
155
+ }
156
+ }
157
+
123
158
  const maxTokens = this.tokenBudget.maxTokens
124
159
  const hardLimit = maxTokens - (this.tokenBudget.reservedForOutput || 0)
125
160
  // 保守触发阈值:估算到可用窗口的 85% 就开始压缩,避免估算偏差导致实际超窗
@@ -128,7 +163,7 @@ export class QueryEngine {
128
163
  const est = this.tokenBudget.estimateMessages(this.state.messages)
129
164
  if (est <= triggerLimit) return
130
165
 
131
- // 1) 摘要式压缩优先:用实时估算直接决定是否压缩(不再依赖滞后的 usagePercent)
166
+ // 2) 摘要式压缩优先:用实时估算直接决定是否压缩(不再依赖滞后的 usagePercent)
132
167
  // 压缩目标也用保守阈值(而非 hardLimit),确保压缩后实际 token 远离窗口上限
133
168
  const summarized = compactMessages(this.state.messages, {
134
169
  maxTokens: Math.floor(maxTokens * 0.6),
@@ -140,7 +175,7 @@ export class QueryEngine {
140
175
  return
141
176
  }
142
177
 
143
- // 2) 滑动窗口精确裁剪兜底(摘要仍超窗):裁剪时保留被裁剪历史的摘要
178
+ // 3) 滑动窗口精确裁剪兜底(摘要仍超窗):裁剪时保留被裁剪历史的摘要
144
179
  const { trimmed, messages: trimmedMsgs, removed, summary } = trimToWindow(this.state.messages, {
145
180
  tokenBudget: this.tokenBudget,
146
181
  maxTokens,