@raolin2025/claude-code-node 1.1.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/README.md +52 -0
- package/package.json +13 -4
- package/src/channel/index.js +81 -146
- package/src/channel/notify-daemon.js +592 -0
- package/src/core/cli.js +195 -114
- package/src/core/compact.js +171 -0
- package/src/core/config.js +1 -2
- package/src/core/cost-tracker.js +171 -0
- package/src/core/paths.js +12 -0
- package/src/core/query-engine.js +192 -89
- package/src/core/session.js +24 -9
- package/src/mcp/client.js +99 -1
- package/src/mcp/registry.js +1 -2
- package/src/security/bash-guard.js +174 -141
- package/src/security/enhanced-permission.js +72 -34
- package/src/security/path-guard.js +32 -29
- package/src/security/ssrf-guard.js +153 -50
- package/src/tools/glob.js +1 -1
- package/src/types/index.js +2 -1
- package/src/utils/file-ops.js +2 -3
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
83
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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('
|
|
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
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/config.js
CHANGED
|
@@ -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 {
|
|
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'
|