@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 CHANGED
@@ -139,8 +139,9 @@ cc-node 会自动感知当前所用模型的**上下文窗口长度**,并在
139
139
  上下文**永不超出窗口**,采用"摘要优先 + 滑动窗口裁剪兜底"的双层策略:
140
140
 
141
141
  1. **摘要式压缩**(信息量更高):每次新消息加入 / 工具结果返回 / 发送前,用**实时 token
142
- 估算**判断是否超窗;超窗时把早期对话压缩为摘要(保留最近 4 轮 + 用户意图/工具/关键
143
- 结果),目标压到窗口的 60%。
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.6",
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(300)
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(300)
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(300)
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(300)
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(300)
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(300)
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(` Input used: ${tokenBudget.inputTokens.toLocaleString()} / ${tokenBudget.maxTokens.toLocaleString()} (${tokenBudget.usagePercent}%)`)
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
  }
@@ -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 = new Set()
157
+ const topics = [] // 保留出现顺序,不丢早期
158
+ const seenIntents = new Set() // 归一化去重
97
159
  const toolsUsed = new Set()
98
- const keyResults = []
99
- let lastUserIntent = ''
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) lastUserIntent = intent
108
- topics.add(intent)
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
- // 收集关键文本结果(取最后一条重要的 assistant 回复)
119
- if (msg.content && typeof msg.content === 'string' && msg.content.length > 20) {
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.length > 100) {
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 (topics.size > 0) {
136
- const topicList = [...topics].slice(-5).map(t => `- ${t}`).join('\n')
137
- parts.push(`User intents:\n${topicList}`)
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
- const lastResult = keyResults[keyResults.length - 1]
144
- parts.push(`Last key result: ${lastResult}`)
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.'