@raolin2025/claude-code-node 1.2.0 → 2.0.0

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/src/core/cli.js CHANGED
@@ -1,15 +1,120 @@
1
1
  /**
2
2
  * CLI 入口 — 命令行解析和 REPL 循环
3
3
  * 对应原版: src/cli/ + src/entrypoints/
4
+ *
5
+ * v1.2: 增加 Unix socket 服务,让 cc-notify 能发现并转发消息
4
6
  */
5
7
  import { createInterface } from 'readline'
8
+ import { createServer as createNetServer } from 'net'
9
+ import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync, chmodSync } from 'fs'
10
+ import { join } from 'path'
6
11
  import { QueryEngine, QueryEngineConfig } from './query-engine.js'
7
12
  import { createDefaultRegistry } from '../tools/index.js'
8
13
  import { SessionManager } from './session.js'
9
14
  import { Config } from './config.js'
10
15
  import { TokenBudget } from './token-budget.js'
11
- import { PermissionChecker } from '../permission/permission.js'
12
16
  import { ChannelManager } from '../channel/index.js'
17
+ import { CostTracker } from './cost-tracker.js'
18
+ import { autoCompact } from './compact.js'
19
+ import { SOCK_DIR, SOCK_PATH, CC_NODE_PID } from './paths.js'
20
+
21
+ // ============================================================
22
+ // Unix Socket — 让 cc-notify 能发现 cc-node
23
+ // ============================================================
24
+
25
+
26
+
27
+ /**
28
+ * 启动 Unix socket 服务器
29
+ * cc-notify 通过此 socket 转发消息给已运行的 cc-node
30
+ */
31
+ function startSocketServer(engine, session, sessionManager, channelManager, verbose) {
32
+ mkdirSync(SOCK_DIR, { recursive: true })
33
+
34
+ // v1.1 修复: 安全清理残留 socket — 检查 PID 文件确认进程已死
35
+ if (existsSync(SOCK_PATH)) {
36
+ let shouldClean = true
37
+ if (existsSync(CC_NODE_PID)) {
38
+ try {
39
+ const oldPid = parseInt(readFileSync(CC_NODE_PID, 'utf8').trim(), 10)
40
+ // 检查旧进程是否还活着
41
+ process.kill(oldPid, 0) // 如果进程存在且活着,这不会抛出
42
+ shouldClean = false // 旧进程还活着,不要清理
43
+ console.error(`cc-node already running (PID ${oldPid}). Use /exit first or kill ${oldPid}`)
44
+ process.exit(1)
45
+ } catch {
46
+ // 旧进程已死,安全清理
47
+ }
48
+ }
49
+ if (shouldClean) {
50
+ try { unlinkSync(SOCK_PATH) } catch {}
51
+ }
52
+ }
53
+
54
+ const server = createNetServer((client) => {
55
+ // v1.1: socket 连接来源验证 — 只允许同用户连接
56
+ // Unix socket 本身通过文件系统权限保护
57
+ let buffer = ''
58
+
59
+ client.on('data', async (data) => {
60
+ buffer += data.toString()
61
+
62
+ // 按行解析 JSON 消息
63
+ const lines = buffer.split('\n')
64
+ buffer = lines.pop() // 保留不完整的行
65
+
66
+ for (const line of lines) {
67
+ if (!line.trim()) continue
68
+ try {
69
+ const msg = JSON.parse(line)
70
+ if (msg.type === 'user_input' && msg.text) {
71
+ // 转发到引擎处理
72
+ const result = await engine.processMessage(msg.text)
73
+ const reply = JSON.stringify({ type: 'reply', text: result.response }) + '\n'
74
+ client.write(reply)
75
+
76
+ // 保存到会话
77
+ await sessionManager.appendMessage({ role: 'user', content: msg.text })
78
+ await sessionManager.appendMessage({ role: 'assistant', content: result.response })
79
+ // M5: 保存 engine state 到 session
80
+ session.state = session.state || {}
81
+ session.state.turnCount = engine.state.turnCount
82
+ session.state.costHistory = engine.costTracker.history.slice(-50) // 只保留最近50条
83
+ await sessionManager.save(session)
84
+ } else if (msg.type === 'ping') {
85
+ client.write(JSON.stringify({ type: 'pong', pid: process.pid }) + '\n')
86
+ }
87
+ } catch (e) {
88
+ client.write(JSON.stringify({ type: 'error', text: e.message }) + '\n')
89
+ }
90
+ }
91
+ })
92
+
93
+ client.on('error', () => {}) // 忽略连接断开
94
+ })
95
+
96
+ server.listen(SOCK_PATH, () => {
97
+ // v1.1 修复: socket 文件权限 0600(仅所有者可读写),阻止其他用户连接
98
+ try { chmodSync(SOCK_PATH, 0o600) } catch {}
99
+ // 写 PID 文件(权限 0644)
100
+ writeFileSync(CC_NODE_PID, String(process.pid), { mode: 0o644 })
101
+ })
102
+
103
+ // 退出时清理
104
+ const cleanup = () => {
105
+ try { unlinkSync(SOCK_PATH) } catch {}
106
+ try { unlinkSync(CC_NODE_PID) } catch {}
107
+ }
108
+ process.on('SIGTERM', cleanup)
109
+ process.on('SIGINT', cleanup)
110
+ process.on('exit', cleanup)
111
+
112
+ return server
113
+ }
114
+
115
+ // ============================================================
116
+ // Banner & Help
117
+ // ============================================================
13
118
 
14
119
  const BANNER = `
15
120
  ╔═══════════════════════════════════════════════╗
@@ -31,13 +136,16 @@ Commands:
31
136
  /config KEY — Show config value
32
137
  /budget — Show token budget
33
138
  /channel CMD — Manage notification channels (list|send|test)
139
+ /cost — Show API cost report
140
+ /compact — Manually compact conversation context
34
141
  /exit — Exit (also Ctrl+C)
35
142
  /quit — Same as /exit
36
143
  `
37
144
 
38
- /**
39
- * 解析命令行参数
40
- */
145
+ // ============================================================
146
+ // 参数解析
147
+ // ============================================================
148
+
41
149
  function parseArgs(argv) {
42
150
  const args = {
43
151
  model: 'deepseek-chat',
@@ -50,7 +158,7 @@ function parseArgs(argv) {
50
158
  noStream: false,
51
159
  }
52
160
 
53
- let i = 2 // skip node and script name
161
+ let i = 2
54
162
  while (i < argv.length) {
55
163
  const arg = argv[i]
56
164
  switch (arg) {
@@ -64,14 +172,14 @@ function parseArgs(argv) {
64
172
  case '--verbose': case '-v': args.verbose = true; break
65
173
  case '--no-stream': args.noStream = true; break
66
174
  case '--help': case '-h':
67
- console.log(`Usage: cc-node [options]
175
+ console.log(`Usage: cc-node [options] [prompt]
68
176
 
69
177
  Options:
70
- -m, --model NAME Model to use (required, e.g. deepseek-chat, qwen-plus, glm-4-flash)
178
+ -m, --model NAME Model to use
71
179
  -s, --system-prompt TEXT System prompt
72
- -p, --permission-mode Permission mode: ask|always-allow|deny (default: ask)
180
+ -p, --permission-mode Permission mode: ask|always-allow|deny
73
181
  -t, --max-turns N Max tool loop turns (default: 100)
74
- --api-base URL API base URL (default: https://api.deepseek.com/v1)
182
+ --api-base URL API base URL
75
183
  --api-key *** API key (or set LLM_API_KEY env)
76
184
  -r, --resume ID Resume a session
77
185
  -v, --verbose Verbose mode
@@ -79,22 +187,17 @@ Options:
79
187
  -h, --help Show this help
80
188
 
81
189
  Environment variables:
82
- LLM_API_KEY Universal API key (recommended)
83
- DEEPSEEK_API_KEY DeepSeek API key (default)
84
- OPENAI_API_KEY OpenAI API key
85
- QWEN_API_KEY Qwen (DashScope) API key
86
- GLM_API_KEY Zhipu GLM API key
87
- KIMI_API_KEY Moonshot Kimi API key
88
- LLM_API_BASE API base URL (default: https://api.deepseek.com/v1)
190
+ LLM_API_KEY, DEEPSEEK_API_KEY, OPENAI_API_KEY,
191
+ QWEN_API_KEY, GLM_API_KEY, KIMI_API_KEY, LLM_API_BASE
89
192
 
90
193
  Channel environment variables:
91
- CC_NODE_CHANNEL_DEFAULT Default channel name
92
- CC_NODE_CHANNEL_TELEGRAM_TOKEN Telegram bot token
93
- CC_NODE_CHANNEL_TELEGRAM_CHAT_ID Telegram chat ID
94
- CC_NODE_CHANNEL_WECOM_WEBHOOK_URL WeCom webhook URL
95
- CC_NODE_CHANNEL_FEISHU_WEBHOOK_URL Feishu webhook URL
96
- CC_NODE_CHANNEL_DISCORD_WEBHOOK_URL Discord webhook URL
97
- CC_NODE_CHANNEL_SLACK_WEBHOOK_URL Slack webhook URL
194
+ CC_NODE_CHANNEL_DEFAULT, CC_NODE_CHANNEL_TELEGRAM_TOKEN,
195
+ CC_NODE_CHANNEL_TELEGRAM_CHAT_ID, CC_NODE_CHANNEL_WECOM_WEBHOOK_URL,
196
+ CC_NODE_CHANNEL_FEISHU_WEBHOOK_URL, CC_NODE_CHANNEL_DISCORD_WEBHOOK_URL,
197
+ CC_NODE_CHANNEL_SLACK_WEBHOOK_URL
198
+
199
+ Unix Socket (for cc-notify):
200
+ ${SOCK_PATH} — cc-notify 通过此 socket 转发消息
98
201
  `)
99
202
  process.exit(0)
100
203
  default:
@@ -109,17 +212,16 @@ Channel environment variables:
109
212
  return args
110
213
  }
111
214
 
112
- /**
113
- * 主入口
114
- */
215
+ // ============================================================
216
+ // 主入口
217
+ // ============================================================
218
+
115
219
  export async function main() {
116
220
  const cliArgs = parseArgs(process.argv)
117
221
 
118
- // 加载配置
119
222
  const config = new Config()
120
223
  await config.load(process.cwd())
121
224
 
122
- // 合并 CLI 参数 > 项目配置 > 用户配置 > 默认值
123
225
  const model = cliArgs.model || config.get('model')
124
226
  const systemPrompt = cliArgs.systemPrompt || ''
125
227
  const permissionMode = cliArgs.permissionMode || config.get('permissionMode')
@@ -128,55 +230,46 @@ export async function main() {
128
230
  const apiKey = cliArgs.apiKey || config.get('apiKey') || ''
129
231
  const verbose = cliArgs.verbose || config.get('verbose')
130
232
 
131
- // 创建工具注册表
132
233
  const registry = createDefaultRegistry()
234
+ const sessionManager = new SessionManager({ sessionsDir: config.get('sessionsDir') })
133
235
 
134
- // 创建会话管理器
135
- const sessionManager = new SessionManager({
136
- sessionsDir: config.get('sessionsDir'),
137
- })
138
-
139
- // 恢复或创建会话
140
236
  let session
141
237
  if (cliArgs.resume) {
142
238
  session = await sessionManager.load(cliArgs.resume)
143
- if (!session) {
144
- console.error(`Session not found: ${cliArgs.resume}`)
145
- process.exit(1)
146
- }
239
+ if (!session) { console.error(`Session not found: ${cliArgs.resume}`); process.exit(1) }
147
240
  } else {
148
241
  session = await sessionManager.create()
149
242
  }
150
243
 
151
- // 创建查询引擎
244
+ const costTracker = new CostTracker({ model })
245
+
152
246
  const engineConfig = new QueryEngineConfig({
153
- model,
154
- systemPrompt,
155
- permissionMode,
156
- maxTurns,
157
- apiBase,
158
- apiKey,
159
- verbose,
247
+ model, systemPrompt, permissionMode, maxTurns, apiBase, apiKey, verbose,
160
248
  tools: registry.getAll(),
249
+ noStream: cliArgs.noStream,
250
+ costTracker,
251
+ tokenBudget,
161
252
  })
162
253
  const engine = new QueryEngine(engineConfig)
163
254
 
164
- // 恢复会话历史
255
+ // M5: 恢复会话历史和状态
165
256
  if (session?.messages?.length) {
166
257
  for (const msg of session.messages) {
167
- if (msg.role === 'user') {
168
- engine.state.messages.push({ role: 'user', content: msg.content })
169
- } else if (msg.role === 'assistant') {
170
- engine.state.messages.push({ role: 'assistant', content: msg.content })
258
+ if (msg.role === 'user') engine.state.messages.push({ role: 'user', content: msg.content })
259
+ else if (msg.role === 'assistant') engine.state.messages.push({ role: 'assistant', content: msg.content })
260
+ }
261
+ // 恢复 turn count
262
+ if (session.state?.turnCount) engine.state.turnCount = session.state.turnCount
263
+ // 恢复费用记录
264
+ if (session.state?.costHistory) {
265
+ for (const record of session.state.costHistory) {
266
+ engine.costTracker.recordUsage(record)
171
267
  }
172
268
  }
173
269
  }
174
270
 
175
- const tokenBudget = new TokenBudget({
176
- maxTokens: config.get('maxBudgetTokens') || 200_000,
177
- })
271
+ const tokenBudget = new TokenBudget({ maxTokens: config.get('maxBudgetTokens') || 200_000 })
178
272
 
179
- // 初始化通讯通道
180
273
  const channelManager = new ChannelManager({
181
274
  channels: config.get('channels') || {},
182
275
  defaultChannel: config.get('defaultChannel') || null,
@@ -186,25 +279,23 @@ export async function main() {
186
279
  if (cliArgs.oneShot) {
187
280
  const result = await engine.processMessage(cliArgs.oneShot)
188
281
  console.log(result.response)
189
- // 一次性模式结束后发通知
190
282
  if (channelManager.list().length > 0) {
191
283
  await channelManager.sendTemplate('task-done', {
192
284
  task: cliArgs.oneShot.slice(0, 80),
193
285
  result: result.response.slice(0, 200),
194
- })
286
+ }).catch(() => {})
195
287
  }
196
288
  process.exit(0)
197
289
  }
198
290
 
199
- // REPL 模式
200
- const rl = createInterface({
201
- input: process.stdin,
202
- output: process.stdout,
203
- prompt: '> ',
204
- })
291
+ // REPL 模式 — 启动 Unix socket 让 cc-notify 能发现
292
+ startSocketServer(engine, session, sessionManager, channelManager, verbose)
293
+
294
+ const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' })
205
295
 
206
296
  console.log(BANNER)
207
297
  console.log(`Model: ${model} | Permission: ${permissionMode} | Tools: ${registry.getNames().join(', ')}`)
298
+ console.log(`Socket: ${SOCK_PATH} (cc-notify can connect)`)
208
299
  if (channelManager.list().length > 0) {
209
300
  const chList = channelManager.list().join(', ')
210
301
  const def = channelManager.defaultChannel ? ` (default: ${channelManager.defaultChannel})` : ''
@@ -213,24 +304,22 @@ export async function main() {
213
304
  console.log()
214
305
  rl.prompt()
215
306
 
307
+ // 共享的消息处理函数(REPL 和 socket 都用)
308
+ async function processInput(input) {
309
+ return engine.processMessage(input)
310
+ }
311
+
216
312
  rl.on('line', async (line) => {
217
313
  const input = line.trim()
218
314
  if (!input) { rl.prompt(); return }
219
315
 
220
- // 命令处理
221
316
  if (input.startsWith('/')) {
222
317
  const [cmd, ...rest] = input.slice(1).split(' ')
223
318
  switch (cmd) {
224
- case 'help':
225
- console.log(HELP_TEXT)
226
- break
319
+ case 'help': console.log(HELP_TEXT); break
227
320
  case 'model':
228
- if (rest[0]) {
229
- engine.config.model = rest.join(' ')
230
- console.log(`Model switched to: ${engine.config.model}`)
231
- } else {
232
- console.log(`Current model: ${engine.config.model}`)
233
- }
321
+ if (rest[0]) { engine.config.model = rest.join(' '); console.log(`Model → ${engine.config.model}`) }
322
+ else console.log(`Model: ${engine.config.model}`)
234
323
  break
235
324
  case 'tools':
236
325
  console.log('Available tools:')
@@ -247,13 +336,8 @@ export async function main() {
247
336
  break
248
337
  case 'sessions': {
249
338
  const sessions = await sessionManager.list()
250
- if (sessions.length === 0) {
251
- console.log('No sessions found')
252
- } else {
253
- for (const s of sessions) {
254
- console.log(` ${s.id} — ${s.title} (${s.messageCount} msgs, ${s.updated})`)
255
- }
256
- }
339
+ if (sessions.length === 0) console.log('No sessions found')
340
+ else for (const s of sessions) console.log(` ${s.id} ${s.title} (${s.messageCount} msgs, ${s.updated})`)
257
341
  break
258
342
  }
259
343
  case 'clear':
@@ -269,18 +353,14 @@ export async function main() {
269
353
  console.log(JSON.stringify(config.toJSON(), null, 2))
270
354
  }
271
355
  break
272
- case 'budget':
273
- console.log(tokenBudget.format())
274
- break
356
+ case 'budget': console.log(tokenBudget.format()); break
275
357
  case 'channel': {
276
358
  const subCmd = rest.join(' ')
277
359
  if (subCmd === 'list' || subCmd === '') {
278
360
  const channels = channelManager.list()
279
361
  if (channels.length === 0) {
280
362
  console.log('No channels configured')
281
- console.log('Setup options:')
282
- console.log(' 1. Environment: CC_NODE_CHANNEL_TELEGRAM_TOKEN=xxx CC_NODE_CHANNEL_TELEGRAM_CHAT_ID=xxx')
283
- console.log(' 2. Config: .claude-code/config.json -> { "channels": { "telegram": { ... } } }')
363
+ console.log('Setup: CC_NODE_CHANNEL_TELEGRAM_TOKEN=xxx CC_NODE_CHANNEL_TELEGRAM_CHAT_ID=xxx')
284
364
  } else {
285
365
  console.log('Channels:')
286
366
  for (const ch of channels) {
@@ -291,24 +371,33 @@ export async function main() {
291
371
  } else if (subCmd.startsWith('send ')) {
292
372
  const text = subCmd.slice(5)
293
373
  const results = await channelManager.send(text)
294
- for (const r of results) {
295
- console.log(r.ok ? `✅ ${r.channel}: sent` : `❌ ${r.channel}: ${r.error}`)
296
- }
374
+ for (const r of results) console.log(r.ok ? `✅ ${r.channel}: sent` : `❌ ${r.channel}: ${r.error}`)
297
375
  } else if (subCmd.startsWith('test')) {
298
376
  const results = await channelManager.send('📡 cc-node channel test')
299
- for (const r of results) {
300
- console.log(r.ok ? `✅ ${r.channel}: test OK` : `❌ ${r.channel}: ${r.error}`)
377
+ for (const r of results) console.log(r.ok ? `✅ ${r.channel}: test OK` : `❌ ${r.channel}: ${r.error}`)
378
+ } else {
379
+ console.log('Usage: /channel list|send <msg>|test')
380
+ }
381
+ break
382
+ }
383
+ case 'cost':
384
+ console.log(engine.costTracker.formatReport())
385
+ break
386
+ case 'compact': {
387
+ if (engine.tokenBudget) {
388
+ const { compacted, messages } = autoCompact(engine.state.messages, engine.tokenBudget, { keepRecentTurns: 4 })
389
+ if (compacted) {
390
+ engine.state.messages = messages
391
+ console.log('✅ Context compressed')
392
+ } else {
393
+ console.log('ℹ️ No compression needed')
301
394
  }
302
395
  } else {
303
- console.log('Usage:')
304
- console.log(' /channel list — List configured channels')
305
- console.log(' /channel send <msg> — Send message to channels')
306
- console.log(' /channel test — Test channel connectivity')
396
+ console.log('Token budget not configured')
307
397
  }
308
398
  break
309
399
  }
310
- case 'exit':
311
- case 'quit':
400
+ case 'exit': case 'quit':
312
401
  console.log('Goodbye!')
313
402
  process.exit(0)
314
403
  default:
@@ -320,35 +409,27 @@ export async function main() {
320
409
 
321
410
  // 发送到引擎
322
411
  try {
323
- const result = await engine.processMessage(input)
324
-
325
- // 输出助手回复
412
+ const result = await processInput(input)
326
413
  console.log()
327
414
  console.log(result.response)
328
415
  console.log()
329
-
330
- // 保存到会话
331
416
  await sessionManager.appendMessage({ role: 'user', content: input })
332
417
  await sessionManager.appendMessage({ role: 'assistant', content: result.response })
333
-
334
- if (verbose) {
335
- console.log(`[Turns: ${result.turns} | Tools: ${result.toolResults.length}]`)
418
+ if (verbose) console.log(`[Turns: ${result.turns} | Tools: ${result.toolResults.length}]`)
419
+ // 显示费用(即使非 verbose 也显示)
420
+ if (engine.costTracker && engine.costTracker.totalApiCalls > 0) {
421
+ console.log(engine.costTracker.formatShort())
336
422
  }
337
423
  } catch (err) {
338
424
  console.error(`\nError: ${err.message}\n`)
339
- // 错误也通知
340
425
  if (channelManager.list().length > 0) {
341
426
  await channelManager.sendTemplate('error', {
342
- task: input.slice(0, 80),
343
- error: err.message.slice(0, 200),
344
- }).catch(() => {}) // 通知失败不影响主流程
427
+ task: input.slice(0, 80), error: err.message.slice(0, 200),
428
+ }).catch(() => {})
345
429
  }
346
430
  }
347
431
  rl.prompt()
348
432
  })
349
433
 
350
- rl.on('close', () => {
351
- console.log('\nGoodbye!')
352
- process.exit(0)
353
- })
434
+ rl.on('close', () => { console.log('\nGoodbye!'); process.exit(0) })
354
435
  }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * 上下文压缩 (Compact) — 长对话自动摘要
3
+ *
4
+ * 当对话 token 数接近预算上限时,自动将早期对话压缩为摘要,
5
+ * 保留最近 N 轮完整对话 + 工具结果的关键信息。
6
+ *
7
+ * 对应原版: src/query/compact.ts
8
+ */
9
+
10
+ import { estimateTokens } from './token-budget.js'
11
+
12
+ /**
13
+ * 压缩策略:保留最近 N 轮完整对话,早期部分压缩为摘要
14
+ *
15
+ * @param {Array} messages — 完整消息列表
16
+ * @param {object} options — 配置
17
+ * @param {number} options.maxTokens — token 预算上限
18
+ * @param {number} options.keepRecentTurns — 保留最近 N 轮(默认 4)
19
+ * @param {number} options.maxToolResultChars — 工具结果截断长度(默认 2000)
20
+ * @returns {Array} 压缩后的消息列表
21
+ */
22
+ export function compactMessages(messages, options = {}) {
23
+ const maxTokens = options.maxTokens || 160_000
24
+ const keepRecentTurns = options.keepRecentTurns || 4
25
+ const maxToolResultChars = options.maxToolResultChars || 2000
26
+
27
+ // 1. 先截断过长的工具结果
28
+ const trimmed = messages.map(msg => {
29
+ if (msg.role === 'tool' && msg.content && msg.content.length > maxToolResultChars) {
30
+ return {
31
+ ...msg,
32
+ content: msg.content.slice(0, maxToolResultChars) + '\n[...compact: truncated]'
33
+ }
34
+ }
35
+ return msg
36
+ })
37
+
38
+ // 2. 估算总 token 数
39
+ const totalTokens = estimateTokens(
40
+ trimmed.map(m => typeof m.content === 'string' ? m.content : JSON.stringify(m.content)).join('')
41
+ )
42
+
43
+ if (totalTokens <= maxTokens) {
44
+ return trimmed // 不需要压缩
45
+ }
46
+
47
+ // 3. 找到分界点:保留最近 keepRecentTurns 轮
48
+ // 一轮 = user + assistant(+tool_calls) + tool 结果们 + assistant 最终回复
49
+ let turnCount = 0
50
+ let splitIndex = trimmed.length
51
+
52
+ for (let i = trimmed.length - 1; i >= 0; i--) {
53
+ if (trimmed[i].role === 'user') {
54
+ turnCount++
55
+ if (turnCount > keepRecentTurns) {
56
+ splitIndex = i
57
+ break
58
+ }
59
+ }
60
+ }
61
+
62
+ if (splitIndex === 0 || splitIndex >= trimmed.length) {
63
+ return trimmed // 无法压缩,全部保留
64
+ }
65
+
66
+ // 4. 将早期消息压缩为摘要
67
+ const earlyMessages = trimmed.slice(0, splitIndex)
68
+ const recentMessages = trimmed.slice(splitIndex)
69
+
70
+ const summary = generateSummary(earlyMessages)
71
+
72
+ // 5. 构建压缩后的消息列表
73
+ const compacted = []
74
+
75
+ // 如果第一条是 system,保留
76
+ if (recentMessages[0]?.role === 'system') {
77
+ compacted.push(recentMessages.shift())
78
+ }
79
+
80
+ // 插入摘要作为 system 上下文
81
+ compacted.push({
82
+ role: 'system',
83
+ content: `[Context Summary — ${new Date().toISOString()}]\n${summary}\n[End of Summary — recent conversation follows]`
84
+ })
85
+
86
+ // 追加最近对话
87
+ compacted.push(...recentMessages)
88
+
89
+ return compacted
90
+ }
91
+
92
+ /**
93
+ * 从消息列表生成摘要
94
+ */
95
+ function generateSummary(messages) {
96
+ const topics = new Set()
97
+ const toolsUsed = new Set()
98
+ const keyResults = []
99
+ let lastUserIntent = ''
100
+
101
+ for (const msg of messages) {
102
+ if (msg.role === 'user' && msg.content) {
103
+ // 提取用户意图(取第一行或前 80 字符)
104
+ const intent = typeof msg.content === 'string'
105
+ ? msg.content.split('\n')[0].slice(0, 80)
106
+ : ''
107
+ if (intent) lastUserIntent = intent
108
+ topics.add(intent)
109
+ }
110
+
111
+ if (msg.role === 'assistant') {
112
+ // 收集使用的工具
113
+ if (msg.toolCalls) {
114
+ for (const tc of msg.toolCalls) {
115
+ toolsUsed.add(tc.name)
116
+ }
117
+ }
118
+ // 收集关键文本结果(取最后一条重要的 assistant 回复)
119
+ if (msg.content && typeof msg.content === 'string' && msg.content.length > 20) {
120
+ keyResults.push(msg.content.slice(0, 300))
121
+ }
122
+ }
123
+
124
+ if (msg.role === 'tool' && msg.content) {
125
+ // 记录工具结果摘要
126
+ const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
127
+ if (content.length > 100) {
128
+ keyResults.push(`[tool result]: ${content.slice(0, 150)}...`)
129
+ }
130
+ }
131
+ }
132
+
133
+ // 组装摘要
134
+ 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}`)
138
+ }
139
+ if (toolsUsed.size > 0) {
140
+ parts.push(`Tools used: ${[...toolsUsed].join(', ')}`)
141
+ }
142
+ if (keyResults.length > 0) {
143
+ const lastResult = keyResults[keyResults.length - 1]
144
+ parts.push(`Last key result: ${lastResult}`)
145
+ }
146
+
147
+ return parts.join('\n\n') || 'Previous conversation context was compacted.'
148
+ }
149
+
150
+ /**
151
+ * 自动检查是否需要压缩,需要时执行
152
+ *
153
+ * @param {Array} messages — 当前消息列表
154
+ * @param {object} tokenBudget — TokenBudget 实例
155
+ * @param {object} options — 压缩选项
156
+ * @returns {{ compacted: boolean, messages: Array }} 是否压缩了 + 结果消息列表
157
+ */
158
+ export function autoCompact(messages, tokenBudget, options = {}) {
159
+ const threshold = options.threshold || 0.8 // 80% 时触发
160
+ const usagePercent = tokenBudget.usagePercent / 100
161
+
162
+ if (usagePercent >= threshold) {
163
+ const compacted = compactMessages(messages, {
164
+ maxTokens: Math.floor(tokenBudget.maxTokens * 0.6), // 压缩到 60%
165
+ ...options,
166
+ })
167
+ return { compacted: true, messages: compacted }
168
+ }
169
+
170
+ return { compacted: false, messages }
171
+ }
@@ -3,8 +3,7 @@
3
3
  * 对应原版: src/query/config.ts + src/utils/config.ts
4
4
  */
5
5
  import { readFile, writeFile, mkdir } from 'fs/promises'
6
- import { resolve, join } from 'path'
7
- import { existsSync } from 'fs'
6
+ import { join } from 'path'
8
7
  import { homedir } from 'os'
9
8
 
10
9
  const PROJECT_CONFIG_FILE = '.claude-code/config.json'