@raolin2025/claude-code-node 2.8.5 → 2.8.6
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 +8 -7
- package/package.json +1 -1
- package/src/__tests__/compact-window.test.js +41 -16
- package/src/core/cli.js +12 -5
- package/src/core/compact.js +32 -15
- package/src/core/query-engine.js +21 -16
package/README.md
CHANGED
|
@@ -138,15 +138,16 @@ cc-node 会自动感知当前所用模型的**上下文窗口长度**,并在
|
|
|
138
138
|
|
|
139
139
|
上下文**永不超出窗口**,采用"摘要优先 + 滑动窗口裁剪兜底"的双层策略:
|
|
140
140
|
|
|
141
|
-
1.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
≤ 窗口上限。system
|
|
146
|
-
|
|
141
|
+
1. **摘要式压缩**(信息量更高):每次新消息加入 / 工具结果返回 / 发送前,用**实时 token
|
|
142
|
+
估算**判断是否超窗;超窗时把早期对话压缩为摘要(保留最近 4 轮 + 用户意图/工具/关键
|
|
143
|
+
结果),目标压到窗口的 60%。
|
|
144
|
+
2. **滑动窗口精确裁剪**(兜底,保证永不超窗):摘要压缩后仍超窗时,从**最早的消息**逐条
|
|
145
|
+
挤出,**最新信息始终保留在末尾**,直到总 token ≤ 窗口上限。system 提示永不裁剪;
|
|
146
|
+
**被裁剪的早期历史会压缩成一条摘要 system 保留**(避免 AI 丢失上下文主线);极端情况
|
|
147
|
+
(单条消息超窗)仍保留 system + 最近一条,保证至少能发出请求。
|
|
147
148
|
|
|
148
149
|
> 正是这个滑动窗口机制解决了"上下文满了之后新信息无法输入"的问题——窗口满时自动
|
|
149
|
-
>
|
|
150
|
+
> 挤出最早的对话(并保留摘要),让最新消息总能拼接进去、AI 也不会失忆。
|
|
150
151
|
|
|
151
152
|
### `/window` 命令用法
|
|
152
153
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raolin2025/claude-code-node",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.6",
|
|
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",
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* - 未超窗不动;
|
|
6
6
|
* - 超窗时从【最早】消息精确裁剪(最新信息保留在末尾);
|
|
7
7
|
* - system 提示永不裁剪;
|
|
8
|
-
* -
|
|
9
|
-
* - 裁剪后总 token 一定 ≤
|
|
8
|
+
* - 被裁剪历史压缩成摘要保留(不丢上下文,keepSummary 默认开启);
|
|
9
|
+
* - 裁剪后总 token 一定 ≤ 窗口上限;
|
|
10
|
+
* - 极端情况(单条消息超窗)仍保留 system + 最近一条。
|
|
10
11
|
*/
|
|
11
12
|
import { test } from 'node:test'
|
|
12
13
|
import assert from 'node:assert/strict'
|
|
@@ -34,50 +35,74 @@ test('滑动窗口:未超窗时消息原样保留', () => {
|
|
|
34
35
|
const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
|
|
35
36
|
assert.equal(r.trimmed, false)
|
|
36
37
|
assert.equal(r.removed, 0)
|
|
38
|
+
assert.equal(r.summary, null)
|
|
37
39
|
assert.equal(r.messages.length, msgs.length)
|
|
38
40
|
})
|
|
39
41
|
|
|
40
|
-
test('
|
|
41
|
-
const tb = makeBudget(
|
|
42
|
-
const msgs = makeMessages(
|
|
42
|
+
test('滑动窗口:超窗时从最早消息精确裁剪,且总 token ≤ 窗口', () => {
|
|
43
|
+
const tb = makeBudget(300)
|
|
44
|
+
const msgs = makeMessages(20) // 41 条,超窗
|
|
43
45
|
const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
|
|
44
46
|
assert.equal(r.trimmed, true)
|
|
45
47
|
assert.ok(r.removed > 0)
|
|
46
48
|
// 裁剪后总 token ≤ 窗口上限(减去输出预留)
|
|
47
|
-
assert.ok(tb.estimateMessages(r.messages) <= tb.maxTokens - tb.reservedForOutput
|
|
49
|
+
assert.ok(tb.estimateMessages(r.messages) <= tb.maxTokens - tb.reservedForOutput,
|
|
50
|
+
`裁剪后 ${tb.estimateMessages(r.messages)} 应 ≤ ${tb.maxTokens - tb.reservedForOutput}`)
|
|
48
51
|
// 消息数变少
|
|
49
52
|
assert.ok(r.messages.length < msgs.length)
|
|
50
53
|
})
|
|
51
54
|
|
|
52
55
|
test('滑动窗口:最新消息始终保留在末尾', () => {
|
|
53
|
-
const tb = makeBudget(
|
|
54
|
-
const msgs = makeMessages(
|
|
56
|
+
const tb = makeBudget(300)
|
|
57
|
+
const msgs = makeMessages(20)
|
|
55
58
|
const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
|
|
56
59
|
const last = r.messages[r.messages.length - 1]
|
|
57
60
|
assert.equal(last.role, 'assistant')
|
|
58
|
-
assert.ok(last.content.includes('assistant reply
|
|
61
|
+
assert.ok(last.content.includes('assistant reply 19'), '最后一条应为最新回复')
|
|
59
62
|
})
|
|
60
63
|
|
|
61
64
|
test('滑动窗口:system 提示永不裁剪', () => {
|
|
62
|
-
const tb = makeBudget(
|
|
63
|
-
const msgs = makeMessages(
|
|
65
|
+
const tb = makeBudget(300)
|
|
66
|
+
const msgs = makeMessages(20)
|
|
64
67
|
const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
|
|
65
68
|
assert.equal(r.messages[0].role, 'system')
|
|
66
69
|
assert.equal(r.messages[0].content, 'SYS')
|
|
67
70
|
})
|
|
68
71
|
|
|
69
72
|
test('滑动窗口:裁剪后保留最近连续对话(无空洞)', () => {
|
|
70
|
-
const tb = makeBudget(
|
|
71
|
-
const msgs = makeMessages(
|
|
73
|
+
const tb = makeBudget(300)
|
|
74
|
+
const msgs = makeMessages(20)
|
|
72
75
|
const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
|
|
73
|
-
//
|
|
74
|
-
const body = r.messages.
|
|
76
|
+
// 裁剪后 = 原始 system + (摘要 system) + 连续的 user/assistant 对
|
|
77
|
+
const body = r.messages.filter(m => m.role !== 'system') // 去掉 system(含摘要 system)
|
|
75
78
|
for (let i = 0; i < body.length; i++) {
|
|
76
79
|
if (i % 2 === 0) assert.equal(body[i].role, 'user', `第 ${i} 条应为 user`)
|
|
77
80
|
else assert.equal(body[i].role, 'assistant', `第 ${i} 条应为 assistant`)
|
|
78
81
|
}
|
|
79
82
|
})
|
|
80
83
|
|
|
84
|
+
test('滑动窗口:被裁剪历史压缩成摘要保留(不丢失上下文)', () => {
|
|
85
|
+
const tb = makeBudget(300)
|
|
86
|
+
const msgs = makeMessages(20) // 41 条,超窗
|
|
87
|
+
const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
|
|
88
|
+
assert.equal(r.trimmed, true)
|
|
89
|
+
assert.ok(r.summary, '应生成被裁剪历史的摘要')
|
|
90
|
+
// 摘要作为 system 消息保留在结果中
|
|
91
|
+
assert.ok(r.messages.some(m => m.role === 'system' && m.content.includes('Context Summary')),
|
|
92
|
+
'结果中应包含摘要 system')
|
|
93
|
+
// 摘要里应包含被裁掉的早期用户意图
|
|
94
|
+
assert.ok(r.summary.includes('user msg'), '摘要应包含早期用户内容')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('滑动窗口:keepSummary=false 时不保留摘要', () => {
|
|
98
|
+
const tb = makeBudget(300)
|
|
99
|
+
const msgs = makeMessages(20)
|
|
100
|
+
const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens, keepSummary: false })
|
|
101
|
+
assert.equal(r.trimmed, true)
|
|
102
|
+
assert.equal(r.summary, null)
|
|
103
|
+
assert.ok(!r.messages.some(m => m.role === 'system' && m.content.includes('Context Summary')))
|
|
104
|
+
})
|
|
105
|
+
|
|
81
106
|
test('滑动窗口:极端情况(单条消息超窗)仍保留 system + 最近一条', () => {
|
|
82
107
|
const tb = makeBudget(50) // 窗口很小
|
|
83
108
|
// 构造:每条消息都较大,导致任何单条都可能超窗
|
|
@@ -95,7 +120,7 @@ test('滑动窗口:极端情况(单条消息超窗)仍保留 system + 最
|
|
|
95
120
|
|
|
96
121
|
test('滑动窗口:自动估算(无 tokenBudget 时用 estimateTokens)', () => {
|
|
97
122
|
const msgs = makeMessages(10, { pad: 50 })
|
|
98
|
-
const r = trimToWindow(msgs, { maxTokens:
|
|
123
|
+
const r = trimToWindow(msgs, { maxTokens: 200 })
|
|
99
124
|
// 没有 tokenBudget,用内置 estimateTokens,仍能裁剪
|
|
100
125
|
assert.equal(r.trimmed, true)
|
|
101
126
|
assert.ok(r.messages.length < msgs.length)
|
package/src/core/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ import { Config } from './config.js'
|
|
|
16
16
|
import { TokenBudget } from './token-budget.js'
|
|
17
17
|
import { ChannelManager } from '../channel/index.js'
|
|
18
18
|
import { CostTracker } from './cost-tracker.js'
|
|
19
|
-
import {
|
|
19
|
+
import { compactMessages } from './compact.js'
|
|
20
20
|
import {
|
|
21
21
|
detectContextWindow,
|
|
22
22
|
parseWindowArg,
|
|
@@ -957,12 +957,19 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
957
957
|
break
|
|
958
958
|
case 'compact': {
|
|
959
959
|
if (engine.tokenBudget) {
|
|
960
|
-
|
|
961
|
-
|
|
960
|
+
// 手动压缩:用实时 token 估算判断是否需要压缩(而非滞后的 usagePercent),
|
|
961
|
+
// 用户主动触发时总能真正压缩。
|
|
962
|
+
const limit = engine.tokenBudget.maxTokens - engine.tokenBudget.reservedForOutput
|
|
963
|
+
const est = engine.tokenBudget.estimateMessages(engine.state.messages)
|
|
964
|
+
if (est > limit) {
|
|
965
|
+
const messages = compactMessages(engine.state.messages, {
|
|
966
|
+
maxTokens: Math.floor(engine.tokenBudget.maxTokens * 0.6),
|
|
967
|
+
keepRecentTurns: 4,
|
|
968
|
+
})
|
|
962
969
|
engine.state.messages = messages
|
|
963
|
-
console.log(
|
|
970
|
+
console.log(`✅ Context compressed (${est} → ${engine.tokenBudget.estimateMessages(messages)} tokens)`)
|
|
964
971
|
} else {
|
|
965
|
-
console.log(
|
|
972
|
+
console.log(`ℹ️ Context within window (${est}/${limit} tokens), no compression needed`)
|
|
966
973
|
}
|
|
967
974
|
} else {
|
|
968
975
|
console.log('Token budget not configured')
|
package/src/core/compact.js
CHANGED
|
@@ -175,12 +175,17 @@ export function autoCompact(messages, tokenBudget, options = {}) {
|
|
|
175
175
|
*
|
|
176
176
|
* 与摘要式压缩(compactMessages)不同,本函数采用"精确裁剪":
|
|
177
177
|
* 1. 计算当前消息总 token;
|
|
178
|
-
* 2.
|
|
178
|
+
* 2. 若超出窗口上限,从【最早】的完整 user 回合逐轮裁剪
|
|
179
|
+
* (最新信息始终保留在末尾),保证不会从一条孤立的
|
|
180
|
+
* assistant/tool 消息中间断开;
|
|
179
181
|
* 3. 直到总 token ≤ 窗口,保证新信息能拼接到末尾。
|
|
180
182
|
*
|
|
183
|
+
* 为防止裁剪导致上下文(用户任务指令/早期工具结果)彻底丢失,
|
|
184
|
+
* 被裁掉的早期消息会被压缩成一条 summary system 保留(keepSummary)。
|
|
185
|
+
*
|
|
181
186
|
* 约束:
|
|
182
187
|
* - system 提示(首条 system 消息)永不裁剪,作为稳定上下文保留;
|
|
183
|
-
* -
|
|
188
|
+
* - 极端情况(单条消息就超窗):仍保留 system + 最近的一条,
|
|
184
189
|
* 其余裁剪,保证至少能发出请求(宁可截断信息也不报错/无法输入)。
|
|
185
190
|
*
|
|
186
191
|
* @param {Array} messages — 完整消息列表
|
|
@@ -188,12 +193,14 @@ export function autoCompact(messages, tokenBudget, options = {}) {
|
|
|
188
193
|
* @param {number} options.maxTokens — 窗口上限(token)
|
|
189
194
|
* @param {number} options.reservedForOutput — 为输出预留的 token(默认 8192)
|
|
190
195
|
* @param {object} options.tokenBudget — 可选的 TokenBudget 实例(用其 estimateMessages)
|
|
191
|
-
* @
|
|
196
|
+
* @param {boolean} options.keepSummary — 是否把被裁剪历史压缩成摘要保留(默认 true)
|
|
197
|
+
* @returns {{ trimmed: boolean, messages: Array, removed: number, summary: string|null }}
|
|
192
198
|
*/
|
|
193
199
|
export function trimToWindow(messages, options = {}) {
|
|
194
200
|
const budget = options.tokenBudget
|
|
195
201
|
const maxTokens = options.maxTokens || 160_000
|
|
196
202
|
const reservedForOutput = options.reservedForOutput || (budget ? budget.reservedForOutput : 8192)
|
|
203
|
+
const keepSummary = options.keepSummary !== false
|
|
197
204
|
const limit = maxTokens - reservedForOutput
|
|
198
205
|
|
|
199
206
|
const estimate = (msgs) => budget
|
|
@@ -203,7 +210,7 @@ export function trimToWindow(messages, options = {}) {
|
|
|
203
210
|
// 先计算总 token
|
|
204
211
|
let total = estimate(messages)
|
|
205
212
|
if (total <= limit) {
|
|
206
|
-
return { trimmed: false, messages, removed: 0 }
|
|
213
|
+
return { trimmed: false, messages, removed: 0, summary: null }
|
|
207
214
|
}
|
|
208
215
|
|
|
209
216
|
// 分离 system 提示(首条 system 永不裁剪)与普通消息
|
|
@@ -217,23 +224,33 @@ export function trimToWindow(messages, options = {}) {
|
|
|
217
224
|
}
|
|
218
225
|
}
|
|
219
226
|
|
|
220
|
-
// 从头部逐条裁剪(最新信息保留在末尾),直到 ≤
|
|
227
|
+
// 从头部逐条裁剪(最新信息保留在末尾),直到 ≤ 上限。
|
|
228
|
+
// 若 keepSummary,被裁掉的历史会压缩成摘要保留,因此裁剪时把
|
|
229
|
+
// 摘要的 token 也算进预算,确保"裁剪 + 摘要"后仍 ≤ 上限。
|
|
221
230
|
let removed = 0
|
|
231
|
+
let dropped = []
|
|
232
|
+
let summary = null
|
|
222
233
|
while (body.length > 0) {
|
|
223
234
|
// 极端保护:至少保留最后一条非 system 消息(system + 最近 1 条总能发出去)
|
|
224
235
|
if (body.length === 1) break
|
|
225
|
-
body.shift() // 挤掉最早的消息
|
|
236
|
+
dropped.push(body.shift()) // 挤掉最早的消息
|
|
226
237
|
removed++
|
|
227
|
-
|
|
228
|
-
|
|
238
|
+
// 计算"裁剪 + 摘要"后的总 token(摘要会随裁剪动态变化)
|
|
239
|
+
const summaryCandidate = keepSummary && dropped.length > 0 ? generateSummary(dropped) : null
|
|
240
|
+
const candidate = summaryCandidate
|
|
241
|
+
? [...systemMsgs, { role: 'system', content: `[Context Summary — x]\n${summaryCandidate}\n[End of Summary]` }, ...body]
|
|
242
|
+
: [...systemMsgs, ...body]
|
|
243
|
+
total = estimate(candidate)
|
|
244
|
+
if (total <= limit) {
|
|
245
|
+
summary = summaryCandidate
|
|
246
|
+
break
|
|
247
|
+
}
|
|
229
248
|
}
|
|
230
249
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return { trimmed: removed > 0, messages: result, removed }
|
|
236
|
-
}
|
|
250
|
+
// 构建最终结果(用真实时间戳)
|
|
251
|
+
const result = summary
|
|
252
|
+
? [...systemMsgs, { role: 'system', content: `[Context Summary — ${new Date().toISOString()}]\n${summary}\n[End of Summary]` }, ...body]
|
|
253
|
+
: [...systemMsgs, ...body]
|
|
237
254
|
|
|
238
|
-
return { trimmed: removed > 0, messages: result, removed }
|
|
255
|
+
return { trimmed: removed > 0, messages: result, removed, summary }
|
|
239
256
|
}
|
package/src/core/query-engine.js
CHANGED
|
@@ -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 {
|
|
17
|
+
import { compactMessages, 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'
|
|
@@ -101,10 +101,13 @@ export class QueryEngine {
|
|
|
101
101
|
* 确保上下文 ≤ 窗口(滑动窗口语义)
|
|
102
102
|
*
|
|
103
103
|
* 处理顺序:
|
|
104
|
-
* 1. 估算当前消息总 token
|
|
104
|
+
* 1. 估算当前消息总 token(实时估算,反映 state.messages 真实大小);
|
|
105
105
|
* 2. 若未超窗 → 不做任何事;
|
|
106
|
-
* 3. 若超窗 →
|
|
107
|
-
*
|
|
106
|
+
* 3. 若超窗 → 先做摘要式压缩(保留最近 N 轮 + 早期摘要,信息量更高)。
|
|
107
|
+
* 注意:这里直接用实时 token 估算判断是否压缩,而非依赖滞后的
|
|
108
|
+
* usagePercent(那会导致"判定超窗但压缩永不触发")。
|
|
109
|
+
* 4. 摘要后仍超窗 → 滑动窗口精确裁剪兜底:从最早完整 user 回合挤出,
|
|
110
|
+
* 并把被裁掉的历史压缩成摘要 system 保留,避免 AI 失忆。
|
|
108
111
|
* 保证最新信息(含刚加入的用户消息)保留在末尾,上下文永不超出窗口。
|
|
109
112
|
*/
|
|
110
113
|
_ensureFitWindow() {
|
|
@@ -113,27 +116,29 @@ export class QueryEngine {
|
|
|
113
116
|
const est = this.tokenBudget.estimateMessages(this.state.messages)
|
|
114
117
|
if (est <= limit) return
|
|
115
118
|
|
|
116
|
-
// 1)
|
|
117
|
-
const
|
|
119
|
+
// 1) 摘要式压缩优先:用实时估算直接决定是否压缩(不再依赖滞后的 usagePercent)
|
|
120
|
+
const summarized = compactMessages(this.state.messages, {
|
|
118
121
|
maxTokens: Math.floor(this.tokenBudget.maxTokens * 0.6),
|
|
119
122
|
})
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return
|
|
126
|
-
}
|
|
123
|
+
const reEst = this.tokenBudget.estimateMessages(summarized)
|
|
124
|
+
if (reEst <= limit) {
|
|
125
|
+
this.state.messages = summarized
|
|
126
|
+
if (this.config.verbose) console.error('[compact] Context summarized to fit token budget')
|
|
127
|
+
return
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
// 2)
|
|
130
|
-
const { trimmed, messages: trimmedMsgs, removed } = trimToWindow(this.state.messages, {
|
|
130
|
+
// 2) 滑动窗口精确裁剪兜底(摘要仍超窗):裁剪时保留被裁剪历史的摘要
|
|
131
|
+
const { trimmed, messages: trimmedMsgs, removed, summary } = trimToWindow(this.state.messages, {
|
|
131
132
|
tokenBudget: this.tokenBudget,
|
|
132
133
|
maxTokens: this.tokenBudget.maxTokens,
|
|
134
|
+
keepSummary: true,
|
|
133
135
|
})
|
|
134
136
|
if (trimmed) {
|
|
135
137
|
this.state.messages = trimmedMsgs
|
|
136
|
-
if (this.config.verbose)
|
|
138
|
+
if (this.config.verbose) {
|
|
139
|
+
console.error(`[compact] Sliding-window trimmed ${removed} oldest messages (summary kept) to fit window`)
|
|
140
|
+
if (summary) console.error('[compact] 被裁剪历史已压缩为摘要保留:\n' + summary)
|
|
141
|
+
}
|
|
137
142
|
}
|
|
138
143
|
}
|
|
139
144
|
|