@raolin2025/claude-code-node 2.8.3 → 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
 
@@ -544,8 +551,18 @@ cc-notify 是独立的通知守护进程,**不需要 cc-node 在前台运行**
544
551
  /notify 任务完成! → 通过 Telegram 广播通知
545
552
  /status → 查看服务状态
546
553
  /cancel → 取消当前操作
554
+ /help → 查看完整帮助(含 AI 编程命令)
555
+ /model gpt-4o → 切换模型
556
+ /window 128k → 设置上下文窗口
557
+ /budget → 查看 token 预算
558
+ /compact → 压缩上下文
559
+ /clear → 清空对话
547
560
  ```
548
561
 
562
+ > 除上述系统命令外,所有 `/help` 列出的 AI 编程命令(`/model`、`/window`、`/budget`、
563
+ > `/compact`、`/clear`、`/sessions`、`/config`、`/cost`、`/cd`、`/tools`、`/stop`、`/allow`
564
+ > 等)都会自动转发给 cc-node 处理,结果回发到 Telegram。`/help <命令>` 可查看详细用法。
565
+
549
566
  ### HTTP API(守护模式可用)
550
567
 
551
568
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raolin2025/claude-code-node",
3
- "version": "2.8.3",
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
+ })
@@ -316,15 +316,33 @@ export class TelegramListener {
316
316
  this.running = true
317
317
  log(`[TG] Starting long polling...`)
318
318
 
319
- // 设置命令菜单
319
+ // 设置命令菜单(Telegram 输入框 / 提示,最多 100 个命令)
320
320
  try {
321
321
  await this.bot.setMyCommands([
322
+ // —— 系统命令(本进程处理)——
322
323
  { command: 'ping', description: '🏓 检查服务状态' },
323
324
  { command: 'status', description: '📊 查看 cc-node 状态' },
324
325
  { command: 'run', description: '💻 执行 shell 命令(如 /run ls -la)' },
325
326
  { command: 'notify', description: '📢 广播通知消息' },
326
- { command: 'help', description: '❓ 查看帮助' },
327
327
  { command: 'cancel', description: '🚫 取消当前操作' },
328
+ { command: 'help', description: '❓ 查看帮助' },
329
+ // —— AI 编程命令(转发给 cc-node)——
330
+ { command: 'model', description: '🤖 切换模型(如 /model gpt-4o)' },
331
+ { command: 'models', description: '📋 列出可用模型' },
332
+ { command: 'window', description: '🧠 查看/设置上下文窗口(如 /window 128k)' },
333
+ { command: 'budget', description: '💰 查看 token 预算使用' },
334
+ { command: 'compact', description: '🗜️ 手动压缩上下文' },
335
+ { command: 'clear', description: '🧹 清空当前对话' },
336
+ { command: 'session', description: '🗂️ 查看会话信息' },
337
+ { command: 'sessions', description: '📂 列出所有会话' },
338
+ { command: 'resume', description: '↩️ 恢复会话(/resume <id>)' },
339
+ { command: 'config', description: '⚙️ 查看配置(/config model)' },
340
+ { command: 'cost', description: '💲 查看 API 费用' },
341
+ { command: 'channel', description: '🔔 管理通知通道' },
342
+ { command: 'cd', description: '📁 切换工作目录' },
343
+ { command: 'tools', description: '🛠️ 列出可用工具' },
344
+ { command: 'stop', description: '⏹️ 停止当前 AI 任务' },
345
+ { command: 'allow', description: '🔓 工具权限管理' },
328
346
  ])
329
347
  } catch {}
330
348
 
@@ -536,9 +554,13 @@ export class TelegramListener {
536
554
 
537
555
  switch (cmd) {
538
556
  case '/start':
539
- case '/help':
540
557
  return this._helpText()
541
558
 
559
+ case '/help':
560
+ // 无参数 → 返回完整帮助;带参数(/help <cmd>)→ 转发给 cc-node 输出详细用法
561
+ if (!args) return this._helpText()
562
+ return null // 交由 cli.js processInputLine 处理 /help <cmd> 详细帮助
563
+
542
564
  case '/ping':
543
565
  return '🏓 pong! cc-notify is alive.'
544
566
 
@@ -592,25 +614,45 @@ export class TelegramListener {
592
614
  }
593
615
  }
594
616
 
595
- /** 生成帮助文本 */
617
+ /** 生成帮助文本(含 cc-notify 系统命令 + cc-node AI 编程命令) */
596
618
  _helpText() {
597
619
  return [
598
620
  '🤖 *cc-notify — AI Code Agent*',
599
621
  '',
600
622
  '通过 Telegram 远程操控 AI 编程助手。',
623
+ '直接发消息 → AI 处理;发 / 开头命令 → 执行对应操作。',
601
624
  '',
602
- '*命令*',
625
+ '*🔧 系统命令*',
603
626
  '• `/ping` — 检查服务状态',
604
627
  '• `/status` — 查看详细状态',
605
628
  '• `/run <cmd>` — 直接执行 shell 命令',
606
629
  '• `/notify <msg>` — 广播通知到所有通道',
607
630
  '• `/cancel` — 取消当前操作',
608
- '• `/help` — 显示帮助',
631
+ '',
632
+ '*🤖 AI 编程命令*(转发给 cc-node 处理)',
633
+ '• `/model NAME` — 切换模型(如 /model gpt-4o)',
634
+ '• `/models` — 列出可用模型',
635
+ '• `/window [N]` — 查看/设置上下文窗口(/window 128k、/window auto)',
636
+ '• `/budget` — 查看 token 预算使用',
637
+ '• `/compact` — 手动压缩上下文',
638
+ '• `/clear` — 清空当前对话',
639
+ '• `/session` — 查看会话信息',
640
+ '• `/sessions` — 列出所有会话',
641
+ '• `/resume <id>` — 恢复历史会话',
642
+ '• `/config KEY` — 查看配置(如 /config model)',
643
+ '• `/cost` — 查看 API 费用',
644
+ '• `/channel` — 管理通知通道',
645
+ '• `/cd PATH` — 切换工作目录',
646
+ '• `/tools` — 列出可用工具',
647
+ '• `/stop` — 停止当前 AI 任务',
648
+ '• `/allow` — 工具权限管理',
609
649
  '',
610
650
  '*普通消息*',
611
- '直接发送文字消息 → 自动发给 AI 处理',
651
+ '直接发送文字 → 自动发给 AI 处理',
612
652
  '支持发送图片(AI 无法看图,但会作为附件)',
613
653
  '',
654
+ '💡 任意 `/help <命令>` 查看某个命令的详细用法。',
655
+ '',
614
656
  ].join('\n')
615
657
  }
616
658
 
@@ -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)