@raolin2025/claude-code-node 2.8.4 → 2.8.5

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
@@ -134,12 +134,19 @@ cc-node 会自动感知当前所用模型的**上下文窗口长度**,并在
134
134
  > 探测结果**不落盘**,每次启动重新探测;只有 `/window N` 手动指定才会持久化(方案 A)。
135
135
  > 探测不精确时,用 `/window N` 手动纠正即可。
136
136
 
137
- ### 自动压缩机制
137
+ ### 自动压缩机制(滑动窗口)
138
138
 
139
- - 当已用 token 达到窗口的 **80%** 时自动触发压缩(`autoCompact`)。
140
- - 压缩策略:保留最近 4 轮完整对话,早期历史压缩为摘要(用户意图、用到的工具、关键结果),
141
- 并将过长工具结果截断,目标压到窗口的 60%。
142
- - **发送前硬校验**:每次发请求前估算消息量,超窗先压缩再发,作为最终兜底保险。
139
+ 上下文**永不超出窗口**,采用"摘要优先 + 滑动窗口裁剪兜底"的双层策略:
140
+
141
+ 1. **摘要式压缩**(信息量更高):当已用 token 达到窗口 **80%** 时,把早期对话压缩为摘要
142
+ (保留最近 4 轮 + 用户意图/工具/关键结果),目标压到窗口的 60%。
143
+ 2. **滑动窗口精确裁剪**(兜底,保证永不超窗):每次新消息加入 / 工具结果返回 / 发送前,
144
+ 若上下文仍超窗,则从**最早的消息**逐条挤出,**最新信息始终保留在末尾**,直到总 token
145
+ ≤ 窗口上限。system 提示永不裁剪;极端情况(单条消息超窗)仍保留 system + 最近一条,
146
+ 保证至少能发出请求。
147
+
148
+ > 正是这个滑动窗口机制解决了"上下文满了之后新信息无法输入"的问题——窗口满时自动
149
+ > 挤出最早的对话,让最新消息总能拼接进去。
143
150
 
144
151
  ### `/window` 命令用法
145
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raolin2025/claude-code-node",
3
- "version": "2.8.4",
3
+ "version": "2.8.5",
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",
@@ -0,0 +1,115 @@
1
+ /**
2
+ * 滑动窗口裁剪 (trimToWindow) 测试
3
+ *
4
+ * 验证核心滑动窗口语义:
5
+ * - 未超窗不动;
6
+ * - 超窗时从【最早】消息精确裁剪(最新信息保留在末尾);
7
+ * - system 提示永不裁剪;
8
+ * - 极端情况(单条消息超窗)仍保留 system + 最近一条;
9
+ * - 裁剪后总 token 一定 ≤ 窗口上限。
10
+ */
11
+ import { test } from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+ import { trimToWindow, compactMessages } from '../core/compact.js'
14
+ import { TokenBudget } from '../core/token-budget.js'
15
+
16
+ /** 构造一个固定窗口的 TokenBudget */
17
+ function makeBudget(maxTokens, reservedForOutput = 10) {
18
+ return new TokenBudget({ maxTokens, reservedForOutput })
19
+ }
20
+
21
+ /** 构造消息:system + n 轮 user/assistant */
22
+ function makeMessages(n, { pad = 10, sysContent = 'SYS' } = {}) {
23
+ const msgs = [{ role: 'system', content: sysContent }]
24
+ for (let i = 0; i < n; i++) {
25
+ msgs.push({ role: 'user', content: `user msg ${i}` + ' '.repeat(pad) })
26
+ msgs.push({ role: 'assistant', content: `assistant reply ${i}` + ' '.repeat(pad) })
27
+ }
28
+ return msgs
29
+ }
30
+
31
+ test('滑动窗口:未超窗时消息原样保留', () => {
32
+ const tb = makeBudget(10000)
33
+ const msgs = makeMessages(2)
34
+ const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
35
+ assert.equal(r.trimmed, false)
36
+ assert.equal(r.removed, 0)
37
+ assert.equal(r.messages.length, msgs.length)
38
+ })
39
+
40
+ test('滑动窗口:超窗时从最早消息精确裁剪', () => {
41
+ const tb = makeBudget(100)
42
+ const msgs = makeMessages(10) // 21 条
43
+ const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
44
+ assert.equal(r.trimmed, true)
45
+ assert.ok(r.removed > 0)
46
+ // 裁剪后总 token ≤ 窗口上限(减去输出预留)
47
+ assert.ok(tb.estimateMessages(r.messages) <= tb.maxTokens - tb.reservedForOutput)
48
+ // 消息数变少
49
+ assert.ok(r.messages.length < msgs.length)
50
+ })
51
+
52
+ test('滑动窗口:最新消息始终保留在末尾', () => {
53
+ const tb = makeBudget(100)
54
+ const msgs = makeMessages(10)
55
+ const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
56
+ const last = r.messages[r.messages.length - 1]
57
+ assert.equal(last.role, 'assistant')
58
+ assert.ok(last.content.includes('assistant reply 9'), '最后一条应为最新回复')
59
+ })
60
+
61
+ test('滑动窗口:system 提示永不裁剪', () => {
62
+ const tb = makeBudget(100)
63
+ const msgs = makeMessages(10)
64
+ const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
65
+ assert.equal(r.messages[0].role, 'system')
66
+ assert.equal(r.messages[0].content, 'SYS')
67
+ })
68
+
69
+ test('滑动窗口:裁剪后保留最近连续对话(无空洞)', () => {
70
+ const tb = makeBudget(100)
71
+ const msgs = makeMessages(10)
72
+ const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
73
+ // 检查裁剪后 body 部分是连续的 user/assistant 对
74
+ const body = r.messages.slice(1) // 去掉 system
75
+ for (let i = 0; i < body.length; i++) {
76
+ if (i % 2 === 0) assert.equal(body[i].role, 'user', `第 ${i} 条应为 user`)
77
+ else assert.equal(body[i].role, 'assistant', `第 ${i} 条应为 assistant`)
78
+ }
79
+ })
80
+
81
+ test('滑动窗口:极端情况(单条消息超窗)仍保留 system + 最近一条', () => {
82
+ const tb = makeBudget(50) // 窗口很小
83
+ // 构造:每条消息都较大,导致任何单条都可能超窗
84
+ const msgs = [
85
+ { role: 'system', content: 'SYS' },
86
+ { role: 'user', content: 'a'.repeat(100) },
87
+ { role: 'assistant', content: 'b'.repeat(100) },
88
+ ]
89
+ const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
90
+ // 至少保留 system + 最后一条
91
+ assert.equal(r.messages[0].role, 'system')
92
+ assert.ok(r.messages.length >= 2)
93
+ assert.equal(r.messages[r.messages.length - 1].content, 'b'.repeat(100))
94
+ })
95
+
96
+ test('滑动窗口:自动估算(无 tokenBudget 时用 estimateTokens)', () => {
97
+ const msgs = makeMessages(10, { pad: 50 })
98
+ const r = trimToWindow(msgs, { maxTokens: 50 })
99
+ // 没有 tokenBudget,用内置 estimateTokens,仍能裁剪
100
+ assert.equal(r.trimmed, true)
101
+ assert.ok(r.messages.length < msgs.length)
102
+ // system 保留
103
+ assert.equal(r.messages[0].role, 'system')
104
+ })
105
+
106
+ test('摘要式压缩 compactMessages 仍可用(信息量更高路径)', () => {
107
+ const tb = makeBudget(200, 0)
108
+ const msgs = makeMessages(8, { pad: 30 })
109
+ const r = compactMessages(msgs, { maxTokens: 120 })
110
+ // 摘要式:保留最近 4 轮 + 早期摘要
111
+ assert.ok(r.length > 0)
112
+ assert.ok(r.length < msgs.length, '摘要应减少消息数')
113
+ // 摘要作为 system 上下文插入
114
+ assert.equal(r[0].role, 'system')
115
+ })
@@ -169,3 +169,71 @@ export function autoCompact(messages, tokenBudget, options = {}) {
169
169
 
170
170
  return { compacted: false, messages }
171
171
  }
172
+
173
+ /**
174
+ * 滑动窗口裁剪 — 保证上下文永不超出窗口
175
+ *
176
+ * 与摘要式压缩(compactMessages)不同,本函数采用"精确裁剪":
177
+ * 1. 计算当前消息总 token;
178
+ * 2. 若超出窗口上限,从【最早】的消息逐条裁剪(最新信息始终保留在末尾);
179
+ * 3. 直到总 token ≤ 窗口,保证新信息能拼接到末尾。
180
+ *
181
+ * 约束:
182
+ * - system 提示(首条 system 消息)永不裁剪,作为稳定上下文保留;
183
+ * - 极端情况(单条非 system 消息就超窗):仍保留 system + 最近的一条,
184
+ * 其余裁剪,保证至少能发出请求(宁可截断信息也不报错/无法输入)。
185
+ *
186
+ * @param {Array} messages — 完整消息列表
187
+ * @param {object} options
188
+ * @param {number} options.maxTokens — 窗口上限(token)
189
+ * @param {number} options.reservedForOutput — 为输出预留的 token(默认 8192)
190
+ * @param {object} options.tokenBudget — 可选的 TokenBudget 实例(用其 estimateMessages)
191
+ * @returns {{ trimmed: boolean, messages: Array, removed: number }}
192
+ */
193
+ export function trimToWindow(messages, options = {}) {
194
+ const budget = options.tokenBudget
195
+ const maxTokens = options.maxTokens || 160_000
196
+ const reservedForOutput = options.reservedForOutput || (budget ? budget.reservedForOutput : 8192)
197
+ const limit = maxTokens - reservedForOutput
198
+
199
+ const estimate = (msgs) => budget
200
+ ? budget.estimateMessages(msgs)
201
+ : estimateTokens(msgs.map(m => typeof m.content === 'string' ? m.content : JSON.stringify(m.content)).join(''))
202
+
203
+ // 先计算总 token
204
+ let total = estimate(messages)
205
+ if (total <= limit) {
206
+ return { trimmed: false, messages, removed: 0 }
207
+ }
208
+
209
+ // 分离 system 提示(首条 system 永不裁剪)与普通消息
210
+ const systemMsgs = []
211
+ const body = []
212
+ for (const m of messages) {
213
+ if (m.role === 'system' && systemMsgs.length === 0) {
214
+ systemMsgs.push(m)
215
+ } else {
216
+ body.push(m)
217
+ }
218
+ }
219
+
220
+ // 从头部逐条裁剪(最新信息保留在末尾),直到 ≤ 上限
221
+ let removed = 0
222
+ while (body.length > 0) {
223
+ // 极端保护:至少保留最后一条非 system 消息(system + 最近 1 条总能发出去)
224
+ if (body.length === 1) break
225
+ body.shift() // 挤掉最早的消息
226
+ removed++
227
+ total = estimate([...systemMsgs, ...body])
228
+ if (total <= limit) break
229
+ }
230
+
231
+ const result = [...systemMsgs, ...body]
232
+
233
+ // 若裁剪后仍超窗(单条消息本身过大),返回 system + 最近一条(保证可发)
234
+ if (estimate(result) > limit && body.length === 1) {
235
+ return { trimmed: removed > 0, messages: result, removed }
236
+ }
237
+
238
+ return { trimmed: removed > 0, messages: result, removed }
239
+ }
@@ -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 { autoCompact } from './compact.js'
17
+ import { autoCompact, trimToWindow } 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'
@@ -85,14 +85,9 @@ export class QueryEngine {
85
85
  const userMsg = new UserMessage(userInput, images)
86
86
  this.state.messages.push(userMsg)
87
87
 
88
- // M3: 自动上下文压缩
89
- if (this.tokenBudget) {
90
- const { compacted, messages } = autoCompact(this.state.messages, this.tokenBudget)
91
- if (compacted) {
92
- this.state.messages = messages
93
- if (this.config.verbose) console.error('[compact] Context compressed to fit token budget')
94
- }
95
- }
88
+ // M3: 自动上下文压缩 + 滑动窗口兜底
89
+ // 新消息已 push,确保上下文 ≤ 窗口(摘要优先,超窗则从最早消息精确裁剪)
90
+ this._ensureFitWindow()
96
91
 
97
92
  try {
98
93
  const result = await this._runToolLoop(userMsg)
@@ -102,6 +97,46 @@ export class QueryEngine {
102
97
  }
103
98
  }
104
99
 
100
+ /**
101
+ * 确保上下文 ≤ 窗口(滑动窗口语义)
102
+ *
103
+ * 处理顺序:
104
+ * 1. 估算当前消息总 token;
105
+ * 2. 若未超窗 → 不做任何事;
106
+ * 3. 若超窗 → 先尝试摘要式压缩(保留最近 N 轮 + 早期摘要,信息量更高);
107
+ * 4. 摘要后仍超窗(或摘要未触发)→ 滑动窗口精确裁剪:从最早消息挤出,
108
+ * 保证最新信息(含刚加入的用户消息)保留在末尾,上下文永不超出窗口。
109
+ */
110
+ _ensureFitWindow() {
111
+ if (!this.tokenBudget) return
112
+ const limit = this.tokenBudget.maxTokens - this.tokenBudget.reservedForOutput
113
+ const est = this.tokenBudget.estimateMessages(this.state.messages)
114
+ if (est <= limit) return
115
+
116
+ // 1) 摘要式压缩优先
117
+ const { compacted, messages } = autoCompact(this.state.messages, this.tokenBudget, {
118
+ maxTokens: Math.floor(this.tokenBudget.maxTokens * 0.6),
119
+ })
120
+ if (compacted) {
121
+ const reEst = this.tokenBudget.estimateMessages(messages)
122
+ if (reEst <= limit) {
123
+ this.state.messages = messages
124
+ if (this.config.verbose) console.error('[compact] Context summarized to fit token budget')
125
+ return
126
+ }
127
+ }
128
+
129
+ // 2) 滑动窗口精确裁剪兜底(摘要仍超窗 / 未触发)
130
+ const { trimmed, messages: trimmedMsgs, removed } = trimToWindow(this.state.messages, {
131
+ tokenBudget: this.tokenBudget,
132
+ maxTokens: this.tokenBudget.maxTokens,
133
+ })
134
+ if (trimmed) {
135
+ this.state.messages = trimmedMsgs
136
+ if (this.config.verbose) console.error(`[compact] Sliding-window trimmed ${removed} oldest messages to fit window`)
137
+ }
138
+ }
139
+
105
140
  /**
106
141
  * 工具调用循环 — 核心逻辑
107
142
  *
@@ -112,19 +147,8 @@ export class QueryEngine {
112
147
  let finalResponse = ''
113
148
 
114
149
  for (let turn = 0; turn < this.config.maxTurns; turn++) {
115
- // 发送前硬校验:估算即将发送的消息是否超出窗口,超限则先压缩(最终兜底,防止溢出)
116
- if (this.tokenBudget) {
117
- const est = this.tokenBudget.estimateMessages(this.state.messages)
118
- if (est > this.tokenBudget.maxTokens - this.tokenBudget.reservedForOutput) {
119
- const { compacted, messages } = autoCompact(this.state.messages, this.tokenBudget, {
120
- maxTokens: Math.floor(this.tokenBudget.maxTokens * 0.6),
121
- })
122
- if (compacted) {
123
- this.state.messages = messages
124
- if (this.config.verbose) console.error('[compact] Pre-send hard check: compressed to stay within window')
125
- }
126
- }
127
- }
150
+ // 发送前硬校验:工具结果可能已使上下文超窗,确保 ≤ 窗口(摘要优先 + 滑动窗口裁剪兜底)
151
+ this._ensureFitWindow()
128
152
 
129
153
  const requestMessages = this._buildRequest(this.state.messages)
130
154
  const response = await this._callLLM(requestMessages, this.state.messages)