@raolin2025/claude-code-node 2.8.6 → 2.8.8
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 +3 -2
- package/package.json +1 -1
- package/src/__tests__/compact-window.test.js +42 -6
- package/src/core/cli.js +17 -2
- package/src/core/compact.js +89 -14
package/README.md
CHANGED
|
@@ -139,8 +139,9 @@ cc-node 会自动感知当前所用模型的**上下文窗口长度**,并在
|
|
|
139
139
|
上下文**永不超出窗口**,采用"摘要优先 + 滑动窗口裁剪兜底"的双层策略:
|
|
140
140
|
|
|
141
141
|
1. **摘要式压缩**(信息量更高):每次新消息加入 / 工具结果返回 / 发送前,用**实时 token
|
|
142
|
-
估算**判断是否超窗;超窗时把早期对话压缩为摘要(保留最近 4 轮 +
|
|
143
|
-
|
|
142
|
+
估算**判断是否超窗;超窗时把早期对话压缩为摘要(保留最近 4 轮 + 摘要),目标压到窗口
|
|
143
|
+
的 60%。摘要会**显式标注最早的 `Main goal`(核心任务主线)**,并保留最早的关键发现与
|
|
144
|
+
最新结果,避免例行内容淹没核心任务(防止 AI 压缩后失忆)。
|
|
144
145
|
2. **滑动窗口精确裁剪**(兜底,保证永不超窗):摘要压缩后仍超窗时,从**最早的消息**逐条
|
|
145
146
|
挤出,**最新信息始终保留在末尾**,直到总 token ≤ 窗口上限。system 提示永不裁剪;
|
|
146
147
|
**被裁剪的早期历史会压缩成一条摘要 system 保留**(避免 AI 丢失上下文主线);极端情况
|
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.8",
|
|
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",
|
|
@@ -40,7 +40,7 @@ test('滑动窗口:未超窗时消息原样保留', () => {
|
|
|
40
40
|
})
|
|
41
41
|
|
|
42
42
|
test('滑动窗口:超窗时从最早消息精确裁剪,且总 token ≤ 窗口', () => {
|
|
43
|
-
const tb = makeBudget(
|
|
43
|
+
const tb = makeBudget(500)
|
|
44
44
|
const msgs = makeMessages(20) // 41 条,超窗
|
|
45
45
|
const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
|
|
46
46
|
assert.equal(r.trimmed, true)
|
|
@@ -53,7 +53,7 @@ test('滑动窗口:超窗时从最早消息精确裁剪,且总 token ≤ 窗
|
|
|
53
53
|
})
|
|
54
54
|
|
|
55
55
|
test('滑动窗口:最新消息始终保留在末尾', () => {
|
|
56
|
-
const tb = makeBudget(
|
|
56
|
+
const tb = makeBudget(500)
|
|
57
57
|
const msgs = makeMessages(20)
|
|
58
58
|
const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
|
|
59
59
|
const last = r.messages[r.messages.length - 1]
|
|
@@ -62,7 +62,7 @@ test('滑动窗口:最新消息始终保留在末尾', () => {
|
|
|
62
62
|
})
|
|
63
63
|
|
|
64
64
|
test('滑动窗口:system 提示永不裁剪', () => {
|
|
65
|
-
const tb = makeBudget(
|
|
65
|
+
const tb = makeBudget(500)
|
|
66
66
|
const msgs = makeMessages(20)
|
|
67
67
|
const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
|
|
68
68
|
assert.equal(r.messages[0].role, 'system')
|
|
@@ -70,7 +70,7 @@ test('滑动窗口:system 提示永不裁剪', () => {
|
|
|
70
70
|
})
|
|
71
71
|
|
|
72
72
|
test('滑动窗口:裁剪后保留最近连续对话(无空洞)', () => {
|
|
73
|
-
const tb = makeBudget(
|
|
73
|
+
const tb = makeBudget(500)
|
|
74
74
|
const msgs = makeMessages(20)
|
|
75
75
|
const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
|
|
76
76
|
// 裁剪后 = 原始 system + (摘要 system) + 连续的 user/assistant 对
|
|
@@ -82,7 +82,7 @@ test('滑动窗口:裁剪后保留最近连续对话(无空洞)', () => {
|
|
|
82
82
|
})
|
|
83
83
|
|
|
84
84
|
test('滑动窗口:被裁剪历史压缩成摘要保留(不丢失上下文)', () => {
|
|
85
|
-
const tb = makeBudget(
|
|
85
|
+
const tb = makeBudget(500)
|
|
86
86
|
const msgs = makeMessages(20) // 41 条,超窗
|
|
87
87
|
const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens })
|
|
88
88
|
assert.equal(r.trimmed, true)
|
|
@@ -95,7 +95,7 @@ test('滑动窗口:被裁剪历史压缩成摘要保留(不丢失上下文
|
|
|
95
95
|
})
|
|
96
96
|
|
|
97
97
|
test('滑动窗口:keepSummary=false 时不保留摘要', () => {
|
|
98
|
-
const tb = makeBudget(
|
|
98
|
+
const tb = makeBudget(500)
|
|
99
99
|
const msgs = makeMessages(20)
|
|
100
100
|
const r = trimToWindow(msgs, { tokenBudget: tb, maxTokens: tb.maxTokens, keepSummary: false })
|
|
101
101
|
assert.equal(r.trimmed, true)
|
|
@@ -138,3 +138,39 @@ test('摘要式压缩 compactMessages 仍可用(信息量更高路径)', ()
|
|
|
138
138
|
// 摘要作为 system 上下文插入
|
|
139
139
|
assert.equal(r[0].role, 'system')
|
|
140
140
|
})
|
|
141
|
+
|
|
142
|
+
test('摘要改进:Main goal 保留最早的核心任务(不丢早期主线)', () => {
|
|
143
|
+
const tb = makeBudget(128000, 8192)
|
|
144
|
+
// 触发摘要分支:纯对话量大(无超长工具结果可截断),必须靠摘要压缩
|
|
145
|
+
const msgs = [
|
|
146
|
+
{ role: 'user', content: '【核心任务】修复撮合引擎 bug' },
|
|
147
|
+
{ role: 'assistant', content: '定位中,使用工具查看', toolCalls: [{ id: 'a', name: 'Grep', input: { pattern: 'x' } }] },
|
|
148
|
+
{ role: 'tool', tool_call_id: 'a', content: 'result' },
|
|
149
|
+
{ role: 'assistant', content: '关键发现:价格优先排序错误' },
|
|
150
|
+
]
|
|
151
|
+
for (let i = 0; i < 400; i++) {
|
|
152
|
+
msgs.push({ role: 'user', content: `例行检查 ${i} 确认行情 ` + 'x'.repeat(600) })
|
|
153
|
+
msgs.push({ role: 'assistant', content: `完成 ${i} ` + 'y'.repeat(500) })
|
|
154
|
+
}
|
|
155
|
+
const r = compactMessages(msgs, { maxTokens: Math.floor(tb.maxTokens * 0.6) })
|
|
156
|
+
const all = r.map(m => (typeof m.content === 'string' ? m.content : '')).join('\n')
|
|
157
|
+
assert.ok(r.some(m => m.role === 'system' && m.content.includes('Context Summary')), '应生成摘要')
|
|
158
|
+
assert.ok(all.includes('Main goal'), '摘要应包含 Main goal')
|
|
159
|
+
assert.ok(all.includes('撮合引擎'), '核心任务应在 Main goal 中保留')
|
|
160
|
+
assert.ok(all.includes('价格优先'), '早期关键发现应在 Key results 中保留')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('摘要改进:数字归一化去重,避免例行序号占满摘要', () => {
|
|
164
|
+
const tb = makeBudget(128000, 8192)
|
|
165
|
+
const msgs = []
|
|
166
|
+
for (let i = 0; i < 400; i++) {
|
|
167
|
+
msgs.push({ role: 'user', content: `例行检查 ${i} 确认数据 ` + 'x'.repeat(600) })
|
|
168
|
+
msgs.push({ role: 'assistant', content: `检查 ${i} 完成 ` + 'y'.repeat(500) })
|
|
169
|
+
}
|
|
170
|
+
const r = compactMessages(msgs, { maxTokens: Math.floor(tb.maxTokens * 0.6) })
|
|
171
|
+
const sm = r.find(m => m.role === 'system' && m.content.includes('Context Summary'))
|
|
172
|
+
assert.ok(sm, '应有摘要')
|
|
173
|
+
// 数字归一化后,"例行检查 0/1/2..." 应被归并为一条,而不是每个序号都出现
|
|
174
|
+
const intentCount = (sm.content.match(/例行检查/g) || []).length
|
|
175
|
+
assert.ok(intentCount <= 2, `例行意图应被去重(实际 ${intentCount} 条)`)
|
|
176
|
+
})
|
package/src/core/cli.js
CHANGED
|
@@ -732,9 +732,19 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
732
732
|
case 'window': {
|
|
733
733
|
const arg = rest[0] ? rest.join(' ').trim() : ''
|
|
734
734
|
if (!arg) {
|
|
735
|
-
// 无参数:显示当前窗口
|
|
735
|
+
// 无参数:显示当前窗口 + 真实 context 用量
|
|
736
|
+
// 用实时估算(estimateMessages)反映"当前 context 填充了多少窗口",
|
|
737
|
+
// 而非依赖 usage 上报的 inputTokens——本地网关不返回 usage 时
|
|
738
|
+
// inputTokens 恒为 0,无法反映真实填充情况。
|
|
739
|
+
const estTokens = engine.tokenBudget.estimateMessages(engine.state.messages)
|
|
740
|
+
const windowTokens = engine.tokenBudget.maxTokens
|
|
741
|
+
const reserved = engine.tokenBudget.reservedForOutput || 0
|
|
742
|
+
const pct = windowTokens > 0
|
|
743
|
+
? Math.min(100, Math.round((estTokens / (windowTokens - reserved)) * 100))
|
|
744
|
+
: 0
|
|
736
745
|
console.log(`Context window: ${formatTokens(tokenBudget.maxTokens)} (${windowSourceLabel(windowSource)})`)
|
|
737
|
-
console.log(`
|
|
746
|
+
console.log(` Context used: ${estTokens.toLocaleString()} / ${windowTokens.toLocaleString()} (${pct}% of usable window)`)
|
|
747
|
+
console.log(` (real-time estimate; API-reported input: ${tokenBudget.inputTokens.toLocaleString()} tok)`)
|
|
738
748
|
const manual = config.get('maxBudgetTokens') || 0
|
|
739
749
|
if (manual > 0) console.log(` Manual override: ${formatTokens(manual)} (persisted in config)`)
|
|
740
750
|
break
|
|
@@ -893,6 +903,11 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
893
903
|
break
|
|
894
904
|
case 'budget': {
|
|
895
905
|
console.log(tokenBudget.format())
|
|
906
|
+
// 追加实时估算的 context 用量(本地网关不返回 usage 时 inputTokens 为 0,无法反映真实填充)
|
|
907
|
+
const estTokens = engine.tokenBudget.estimateMessages(engine.state.messages)
|
|
908
|
+
const winTk = engine.tokenBudget.maxTokens
|
|
909
|
+
const pct = winTk > 0 ? Math.min(100, Math.round((estTokens / winTk) * 100)) : 0
|
|
910
|
+
console.log(` Context (est): ${estTokens.toLocaleString()} / ${winTk.toLocaleString()} (${pct}% of window)`)
|
|
896
911
|
console.log(`Context window: ${formatTokens(tokenBudget.maxTokens)} (${windowSourceLabel(windowSource)}) | trigger: ${Math.round((tokenBudget.maxTokens * 0.8)).toLocaleString()} (80%)`)
|
|
897
912
|
break
|
|
898
913
|
}
|
package/src/core/compact.js
CHANGED
|
@@ -91,12 +91,74 @@ export function compactMessages(messages, options = {}) {
|
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
93
|
* 从消息列表生成摘要
|
|
94
|
+
*
|
|
95
|
+
* 摘要策略(v2.8.7 改进):
|
|
96
|
+
* - **Main goal**:显式标注对话【最早】的明确用户意图(通常就是核心任务/主线),
|
|
97
|
+
* 避免早期核心指令被后续例行内容挤掉;
|
|
98
|
+
* - 其余用户意图按出现顺序去重保留(前 10 条),不再用 slice(-5) 丢弃早期;
|
|
99
|
+
* - **Key results**:保留【最早】的关键发现 + 【最新】的关键结果(首尾各一),
|
|
100
|
+
* 兼顾"任务进展"与"最终结论"。
|
|
101
|
+
*/
|
|
102
|
+
/**
|
|
103
|
+
* 压缩连续重复字符(去掉无意义的填充,如 "xxx...xxx" → "x")。
|
|
104
|
+
* 用于意图/结果去重归一化,避免"例行检查 10 xxx..." 与 "例行检查 100 xxx..." 因
|
|
105
|
+
* 填充长度不同而被误判为不同意图。
|
|
106
|
+
*/
|
|
107
|
+
function squashRepeats(str) {
|
|
108
|
+
let out = ''
|
|
109
|
+
let last = ''
|
|
110
|
+
for (const ch of str) {
|
|
111
|
+
if (ch !== last) {
|
|
112
|
+
out += ch
|
|
113
|
+
last = ch
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 判断一条文本是否"有实质内容"(去掉填充后仍有意义)。
|
|
121
|
+
*/
|
|
122
|
+
function hasSubstance(content) {
|
|
123
|
+
if (!content) return false
|
|
124
|
+
const squashed = squashRepeats(String(content))
|
|
125
|
+
// 去掉空白后仍有足够的信息量(至少 6 个非重复字符,或含中文/冒号等结构化标记)
|
|
126
|
+
const meaningful = squashed.replace(/\s+/g, '')
|
|
127
|
+
return meaningful.length >= 6
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 结论性关键词:用于挑选 Key results 里"有价值"的结论,而非过渡性话术
|
|
131
|
+
// (注意避开"定位中/正在/好的/让我"等过渡词)
|
|
132
|
+
const CONCLUSION_HINTS = [
|
|
133
|
+
'关键', '发现', '错误', '原因', '结论', '问题', '修复',
|
|
134
|
+
'成功', '失败', '是因为', '在于', '导致', '需要', '找到', '定位到',
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 判断一条文本是否像"结论"(含结论性关键词)。
|
|
139
|
+
*/
|
|
140
|
+
function isConclusionLike(text) {
|
|
141
|
+
const s = String(text)
|
|
142
|
+
return CONCLUSION_HINTS.some(k => s.includes(k))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 从消息列表生成摘要
|
|
147
|
+
*
|
|
148
|
+
* 摘要策略(v2.8.7 改进):
|
|
149
|
+
* - **Main goal**:显式标注对话【最早】的明确用户意图(通常就是核心任务/主线),
|
|
150
|
+
* 避免早期核心指令被后续例行内容挤掉;
|
|
151
|
+
* - 其余用户意图按出现顺序去重保留(前 10 条),数字归一化 + 去填充,
|
|
152
|
+
* 让"例行检查 0/1/2"归并为一条,不占满摘要;
|
|
153
|
+
* - **Key results**:优先保留【最早的一条结论性内容】+【最新的一条结果】,
|
|
154
|
+
* 过滤纯填充/过渡性话术,避免例行内容淹没核心结论。
|
|
94
155
|
*/
|
|
95
156
|
function generateSummary(messages) {
|
|
96
|
-
const topics =
|
|
157
|
+
const topics = [] // 保留出现顺序,不丢早期
|
|
158
|
+
const seenIntents = new Set() // 归一化去重
|
|
97
159
|
const toolsUsed = new Set()
|
|
98
|
-
const keyResults = []
|
|
99
|
-
let
|
|
160
|
+
const keyResults = [] // 有实质内容的结果(保留顺序)
|
|
161
|
+
let firstIntent = ''
|
|
100
162
|
|
|
101
163
|
for (const msg of messages) {
|
|
102
164
|
if (msg.role === 'user' && msg.content) {
|
|
@@ -104,8 +166,15 @@ function generateSummary(messages) {
|
|
|
104
166
|
const intent = typeof msg.content === 'string'
|
|
105
167
|
? msg.content.split('\n')[0].slice(0, 80)
|
|
106
168
|
: ''
|
|
107
|
-
if (intent)
|
|
108
|
-
|
|
169
|
+
if (intent) {
|
|
170
|
+
if (!firstIntent) firstIntent = intent
|
|
171
|
+
// 数字归一化 + 去填充去重:"例行检查 10 xxx..." 与 "例行检查 100 xxx..." → 同一条
|
|
172
|
+
const norm = squashRepeats(intent.replace(/\d+/g, 'N'))
|
|
173
|
+
if (!seenIntents.has(norm)) {
|
|
174
|
+
seenIntents.add(norm)
|
|
175
|
+
topics.push(intent)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
109
178
|
}
|
|
110
179
|
|
|
111
180
|
if (msg.role === 'assistant') {
|
|
@@ -115,16 +184,16 @@ function generateSummary(messages) {
|
|
|
115
184
|
toolsUsed.add(tc.name)
|
|
116
185
|
}
|
|
117
186
|
}
|
|
118
|
-
//
|
|
119
|
-
if (
|
|
187
|
+
// 收集有实质内容的关键文本结果
|
|
188
|
+
if (typeof msg.content === 'string' && hasSubstance(msg.content)) {
|
|
120
189
|
keyResults.push(msg.content.slice(0, 300))
|
|
121
190
|
}
|
|
122
191
|
}
|
|
123
192
|
|
|
124
193
|
if (msg.role === 'tool' && msg.content) {
|
|
125
|
-
//
|
|
194
|
+
// 记录工具结果摘要(去填充后判断)
|
|
126
195
|
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
|
|
127
|
-
if (content
|
|
196
|
+
if (hasSubstance(content)) {
|
|
128
197
|
keyResults.push(`[tool result]: ${content.slice(0, 150)}...`)
|
|
129
198
|
}
|
|
130
199
|
}
|
|
@@ -132,16 +201,22 @@ function generateSummary(messages) {
|
|
|
132
201
|
|
|
133
202
|
// 组装摘要
|
|
134
203
|
const parts = []
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
204
|
+
if (firstIntent) {
|
|
205
|
+
parts.push(`Main goal: ${firstIntent}`)
|
|
206
|
+
}
|
|
207
|
+
const restTopics = topics.filter(t => t !== firstIntent).slice(0, 10)
|
|
208
|
+
if (restTopics.length > 0) {
|
|
209
|
+
parts.push(`User intents:\n${restTopics.map(t => `- ${t}`).join('\n')}`)
|
|
138
210
|
}
|
|
139
211
|
if (toolsUsed.size > 0) {
|
|
140
212
|
parts.push(`Tools used: ${[...toolsUsed].join(', ')}`)
|
|
141
213
|
}
|
|
142
214
|
if (keyResults.length > 0) {
|
|
143
|
-
|
|
144
|
-
|
|
215
|
+
// 优先取【最早的结论性内容】;若无结论性内容,退回最早一条
|
|
216
|
+
const firstConclusion = keyResults.find(k => isConclusionLike(k)) || keyResults[0]
|
|
217
|
+
const last = keyResults[keyResults.length - 1]
|
|
218
|
+
const kr = firstConclusion === last ? [firstConclusion] : [firstConclusion, last]
|
|
219
|
+
parts.push(`Key results:\n${kr.map(k => `- ${k}`).join('\n')}`)
|
|
145
220
|
}
|
|
146
221
|
|
|
147
222
|
return parts.join('\n\n') || 'Previous conversation context was compacted.'
|