@raolin2025/claude-code-node 2.8.5 → 2.8.7
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 +9 -7
- package/package.json +1 -1
- package/src/__tests__/compact-window.test.js +77 -16
- package/src/core/cli.js +12 -5
- package/src/core/compact.js +121 -29
- package/src/core/query-engine.js +21 -16
package/README.md
CHANGED
|
@@ -138,15 +138,17 @@ cc-node 会自动感知当前所用模型的**上下文窗口长度**,并在
|
|
|
138
138
|
|
|
139
139
|
上下文**永不超出窗口**,采用"摘要优先 + 滑动窗口裁剪兜底"的双层策略:
|
|
140
140
|
|
|
141
|
-
1.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
1. **摘要式压缩**(信息量更高):每次新消息加入 / 工具结果返回 / 发送前,用**实时 token
|
|
142
|
+
估算**判断是否超窗;超窗时把早期对话压缩为摘要(保留最近 4 轮 + 摘要),目标压到窗口
|
|
143
|
+
的 60%。摘要会**显式标注最早的 `Main goal`(核心任务主线)**,并保留最早的关键发现与
|
|
144
|
+
最新结果,避免例行内容淹没核心任务(防止 AI 压缩后失忆)。
|
|
145
|
+
2. **滑动窗口精确裁剪**(兜底,保证永不超窗):摘要压缩后仍超窗时,从**最早的消息**逐条
|
|
146
|
+
挤出,**最新信息始终保留在末尾**,直到总 token ≤ 窗口上限。system 提示永不裁剪;
|
|
147
|
+
**被裁剪的早期历史会压缩成一条摘要 system 保留**(避免 AI 丢失上下文主线);极端情况
|
|
148
|
+
(单条消息超窗)仍保留 system + 最近一条,保证至少能发出请求。
|
|
147
149
|
|
|
148
150
|
> 正是这个滑动窗口机制解决了"上下文满了之后新信息无法输入"的问题——窗口满时自动
|
|
149
|
-
>
|
|
151
|
+
> 挤出最早的对话(并保留摘要),让最新消息总能拼接进去、AI 也不会失忆。
|
|
150
152
|
|
|
151
153
|
### `/window` 命令用法
|
|
152
154
|
|
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.7",
|
|
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(500)
|
|
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(500)
|
|
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(500)
|
|
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(500)
|
|
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(500)
|
|
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(500)
|
|
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)
|
|
@@ -113,3 +138,39 @@ test('摘要式压缩 compactMessages 仍可用(信息量更高路径)', ()
|
|
|
113
138
|
// 摘要作为 system 上下文插入
|
|
114
139
|
assert.equal(r[0].role, 'system')
|
|
115
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
|
@@ -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
|
@@ -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.'
|
|
@@ -175,12 +250,17 @@ export function autoCompact(messages, tokenBudget, options = {}) {
|
|
|
175
250
|
*
|
|
176
251
|
* 与摘要式压缩(compactMessages)不同,本函数采用"精确裁剪":
|
|
177
252
|
* 1. 计算当前消息总 token;
|
|
178
|
-
* 2.
|
|
253
|
+
* 2. 若超出窗口上限,从【最早】的完整 user 回合逐轮裁剪
|
|
254
|
+
* (最新信息始终保留在末尾),保证不会从一条孤立的
|
|
255
|
+
* assistant/tool 消息中间断开;
|
|
179
256
|
* 3. 直到总 token ≤ 窗口,保证新信息能拼接到末尾。
|
|
180
257
|
*
|
|
258
|
+
* 为防止裁剪导致上下文(用户任务指令/早期工具结果)彻底丢失,
|
|
259
|
+
* 被裁掉的早期消息会被压缩成一条 summary system 保留(keepSummary)。
|
|
260
|
+
*
|
|
181
261
|
* 约束:
|
|
182
262
|
* - system 提示(首条 system 消息)永不裁剪,作为稳定上下文保留;
|
|
183
|
-
* -
|
|
263
|
+
* - 极端情况(单条消息就超窗):仍保留 system + 最近的一条,
|
|
184
264
|
* 其余裁剪,保证至少能发出请求(宁可截断信息也不报错/无法输入)。
|
|
185
265
|
*
|
|
186
266
|
* @param {Array} messages — 完整消息列表
|
|
@@ -188,12 +268,14 @@ export function autoCompact(messages, tokenBudget, options = {}) {
|
|
|
188
268
|
* @param {number} options.maxTokens — 窗口上限(token)
|
|
189
269
|
* @param {number} options.reservedForOutput — 为输出预留的 token(默认 8192)
|
|
190
270
|
* @param {object} options.tokenBudget — 可选的 TokenBudget 实例(用其 estimateMessages)
|
|
191
|
-
* @
|
|
271
|
+
* @param {boolean} options.keepSummary — 是否把被裁剪历史压缩成摘要保留(默认 true)
|
|
272
|
+
* @returns {{ trimmed: boolean, messages: Array, removed: number, summary: string|null }}
|
|
192
273
|
*/
|
|
193
274
|
export function trimToWindow(messages, options = {}) {
|
|
194
275
|
const budget = options.tokenBudget
|
|
195
276
|
const maxTokens = options.maxTokens || 160_000
|
|
196
277
|
const reservedForOutput = options.reservedForOutput || (budget ? budget.reservedForOutput : 8192)
|
|
278
|
+
const keepSummary = options.keepSummary !== false
|
|
197
279
|
const limit = maxTokens - reservedForOutput
|
|
198
280
|
|
|
199
281
|
const estimate = (msgs) => budget
|
|
@@ -203,7 +285,7 @@ export function trimToWindow(messages, options = {}) {
|
|
|
203
285
|
// 先计算总 token
|
|
204
286
|
let total = estimate(messages)
|
|
205
287
|
if (total <= limit) {
|
|
206
|
-
return { trimmed: false, messages, removed: 0 }
|
|
288
|
+
return { trimmed: false, messages, removed: 0, summary: null }
|
|
207
289
|
}
|
|
208
290
|
|
|
209
291
|
// 分离 system 提示(首条 system 永不裁剪)与普通消息
|
|
@@ -217,23 +299,33 @@ export function trimToWindow(messages, options = {}) {
|
|
|
217
299
|
}
|
|
218
300
|
}
|
|
219
301
|
|
|
220
|
-
// 从头部逐条裁剪(最新信息保留在末尾),直到 ≤
|
|
302
|
+
// 从头部逐条裁剪(最新信息保留在末尾),直到 ≤ 上限。
|
|
303
|
+
// 若 keepSummary,被裁掉的历史会压缩成摘要保留,因此裁剪时把
|
|
304
|
+
// 摘要的 token 也算进预算,确保"裁剪 + 摘要"后仍 ≤ 上限。
|
|
221
305
|
let removed = 0
|
|
306
|
+
let dropped = []
|
|
307
|
+
let summary = null
|
|
222
308
|
while (body.length > 0) {
|
|
223
309
|
// 极端保护:至少保留最后一条非 system 消息(system + 最近 1 条总能发出去)
|
|
224
310
|
if (body.length === 1) break
|
|
225
|
-
body.shift() // 挤掉最早的消息
|
|
311
|
+
dropped.push(body.shift()) // 挤掉最早的消息
|
|
226
312
|
removed++
|
|
227
|
-
|
|
228
|
-
|
|
313
|
+
// 计算"裁剪 + 摘要"后的总 token(摘要会随裁剪动态变化)
|
|
314
|
+
const summaryCandidate = keepSummary && dropped.length > 0 ? generateSummary(dropped) : null
|
|
315
|
+
const candidate = summaryCandidate
|
|
316
|
+
? [...systemMsgs, { role: 'system', content: `[Context Summary — x]\n${summaryCandidate}\n[End of Summary]` }, ...body]
|
|
317
|
+
: [...systemMsgs, ...body]
|
|
318
|
+
total = estimate(candidate)
|
|
319
|
+
if (total <= limit) {
|
|
320
|
+
summary = summaryCandidate
|
|
321
|
+
break
|
|
322
|
+
}
|
|
229
323
|
}
|
|
230
324
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return { trimmed: removed > 0, messages: result, removed }
|
|
236
|
-
}
|
|
325
|
+
// 构建最终结果(用真实时间戳)
|
|
326
|
+
const result = summary
|
|
327
|
+
? [...systemMsgs, { role: 'system', content: `[Context Summary — ${new Date().toISOString()}]\n${summary}\n[End of Summary]` }, ...body]
|
|
328
|
+
: [...systemMsgs, ...body]
|
|
237
329
|
|
|
238
|
-
return { trimmed: removed > 0, messages: result, removed }
|
|
330
|
+
return { trimmed: removed > 0, messages: result, removed, summary }
|
|
239
331
|
}
|
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
|
|