@raolin2025/claude-code-node 1.2.0 → 2.1.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/package.json +1 -1
- package/src/channel/index.js +81 -146
- package/src/channel/notify-daemon.js +454 -350
- package/src/core/cli.js +235 -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 +223 -117
- package/src/core/session.js +27 -10
- package/src/mcp/client.js +112 -4
- 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 +43 -30
- package/src/security/ssrf-guard.js +153 -50
- package/src/tools/glob.js +1 -1
- package/src/tools/web-fetch.js +3 -1
- package/src/types/index.js +2 -1
- package/src/utils/file-ops.js +2 -3
|
@@ -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'
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 费用追踪 (Cost Tracking) — API 调用费用计算和报告
|
|
3
|
+
*
|
|
4
|
+
* 支持主流模型的价格表,自动根据 model 名称匹配价格。
|
|
5
|
+
* 对应原版: src/utils/costTracker.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============================================================
|
|
9
|
+
// 模型价格表(美元 / 1M tokens)
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
const PRICING = {
|
|
13
|
+
// DeepSeek
|
|
14
|
+
'deepseek-chat': { input: 0.27, output: 1.10, cache_read: 0.07 },
|
|
15
|
+
'deepseek-reasoner': { input: 0.55, output: 2.19, cache_read: 0.14 },
|
|
16
|
+
|
|
17
|
+
// OpenAI
|
|
18
|
+
'gpt-4o': { input: 2.50, output: 10.00 },
|
|
19
|
+
'gpt-4o-mini': { input: 0.15, output: 0.60 },
|
|
20
|
+
'gpt-4.1': { input: 2.00, output: 8.00 },
|
|
21
|
+
'gpt-4.1-mini': { input: 0.40, output: 1.60 },
|
|
22
|
+
'gpt-4.1-nano': { input: 0.10, output: 0.40 },
|
|
23
|
+
'o3': { input: 2.00, output: 8.00 },
|
|
24
|
+
'o3-mini': { input: 1.10, output: 4.40 },
|
|
25
|
+
'o4-mini': { input: 1.10, output: 4.40 },
|
|
26
|
+
|
|
27
|
+
// 通义千问 (Qwen)
|
|
28
|
+
'qwen-plus': { input: 0.50, output: 2.00 },
|
|
29
|
+
'qwen-turbo': { input: 0.05, output: 0.20 },
|
|
30
|
+
'qwen-max': { input: 2.00, output: 6.00 },
|
|
31
|
+
'qwen-long': { input: 0.07, output: 0.28 },
|
|
32
|
+
|
|
33
|
+
// 智谱 GLM
|
|
34
|
+
'glm-4-flash': { input: 0.10, output: 0.10 },
|
|
35
|
+
'glm-4-plus': { input: 0.50, output: 0.50 },
|
|
36
|
+
'glm-4': { input: 1.00, output: 1.00 },
|
|
37
|
+
'glm-4-long': { input: 0.10, output: 0.10 },
|
|
38
|
+
|
|
39
|
+
// Moonshot Kimi
|
|
40
|
+
'moonshot-v1-8k': { input: 0.50, output: 2.00 },
|
|
41
|
+
'moonshot-v1-32k': { input: 1.00, output: 4.00 },
|
|
42
|
+
'moonshot-v1-128k': { input: 2.00, output: 8.00 },
|
|
43
|
+
|
|
44
|
+
// Ollama (本地免费)
|
|
45
|
+
'ollama': { input: 0, output: 0 },
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 默认价格(未匹配到的模型)
|
|
49
|
+
const DEFAULT_PRICING = { input: 0.50, output: 2.00 }
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 根据模型名查找价格
|
|
53
|
+
*/
|
|
54
|
+
function findPricing(model) {
|
|
55
|
+
if (!model) return DEFAULT_PRICING
|
|
56
|
+
|
|
57
|
+
// 精确匹配
|
|
58
|
+
if (PRICING[model]) return PRICING[model]
|
|
59
|
+
|
|
60
|
+
// 前缀匹配 (e.g. "deepseek-chat-v3" → "deepseek-chat")
|
|
61
|
+
for (const [key, price] of Object.entries(PRICING)) {
|
|
62
|
+
if (model.startsWith(key) || model.includes(key)) return price
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Ollama 系列全部免费
|
|
66
|
+
if (model.includes('ollama') || model.includes('local') || model.includes('localhost')) {
|
|
67
|
+
return PRICING.ollama
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return DEFAULT_PRICING
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 费用追踪器
|
|
75
|
+
*/
|
|
76
|
+
export class CostTracker {
|
|
77
|
+
constructor(options = {}) {
|
|
78
|
+
this.model = options.model || ''
|
|
79
|
+
this.pricing = findPricing(this.model)
|
|
80
|
+
this.totalInputTokens = 0
|
|
81
|
+
this.totalOutputTokens = 0
|
|
82
|
+
this.totalCacheReadTokens = 0
|
|
83
|
+
this.totalApiCalls = 0
|
|
84
|
+
this.history = [] // 每次 API 调用的记录
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 切换模型(更新价格表) */
|
|
88
|
+
setModel(model) {
|
|
89
|
+
this.model = model
|
|
90
|
+
this.pricing = findPricing(model)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 记录一次 API 调用 */
|
|
94
|
+
recordUsage(usage) {
|
|
95
|
+
const inputTokens = usage.input_tokens || usage.prompt_tokens || 0
|
|
96
|
+
const outputTokens = usage.output_tokens || usage.completion_tokens || 0
|
|
97
|
+
const cacheReadTokens = usage.cache_read_input_tokens || 0
|
|
98
|
+
|
|
99
|
+
const cost = this.calculateCost(inputTokens, outputTokens, cacheReadTokens)
|
|
100
|
+
|
|
101
|
+
this.totalInputTokens += inputTokens
|
|
102
|
+
this.totalOutputTokens += outputTokens
|
|
103
|
+
this.totalCacheReadTokens += cacheReadTokens
|
|
104
|
+
this.totalApiCalls++
|
|
105
|
+
|
|
106
|
+
this.history.push({
|
|
107
|
+
timestamp: Date.now(),
|
|
108
|
+
inputTokens,
|
|
109
|
+
outputTokens,
|
|
110
|
+
cacheReadTokens,
|
|
111
|
+
cost,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return cost
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 计算单次费用 */
|
|
118
|
+
calculateCost(inputTokens, outputTokens, cacheReadTokens = 0) {
|
|
119
|
+
const cost = {
|
|
120
|
+
input: (inputTokens / 1_000_000) * this.pricing.input,
|
|
121
|
+
output: (outputTokens / 1_000_000) * this.pricing.output,
|
|
122
|
+
cacheRead: (cacheReadTokens / 1_000_000) * (this.pricing.cache_read || 0),
|
|
123
|
+
}
|
|
124
|
+
cost.total = cost.input + cost.output + cost.cacheRead
|
|
125
|
+
return cost
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 获取总费用 */
|
|
129
|
+
getTotalCost() {
|
|
130
|
+
const cost = this.calculateCost(
|
|
131
|
+
this.totalInputTokens,
|
|
132
|
+
this.totalOutputTokens,
|
|
133
|
+
this.totalCacheReadTokens
|
|
134
|
+
)
|
|
135
|
+
return cost
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 格式化费用报告 */
|
|
139
|
+
formatReport() {
|
|
140
|
+
const cost = this.getTotalCost()
|
|
141
|
+
const lines = [
|
|
142
|
+
`💰 Cost Report — ${this.model}`,
|
|
143
|
+
` API Calls: ${this.totalApiCalls}`,
|
|
144
|
+
` Input Tokens: ${this.totalInputTokens.toLocaleString()} → $${cost.input.toFixed(4)}`,
|
|
145
|
+
` Output Tokens: ${this.totalOutputTokens.toLocaleString()} → $${cost.output.toFixed(4)}`,
|
|
146
|
+
]
|
|
147
|
+
if (this.totalCacheReadTokens > 0) {
|
|
148
|
+
lines.push(` Cache Read: ${this.totalCacheReadTokens.toLocaleString()} → $${cost.cacheRead.toFixed(4)}`)
|
|
149
|
+
}
|
|
150
|
+
lines.push(` ───────────────────────────────`)
|
|
151
|
+
lines.push(` Total: $${cost.total.toFixed(4)}`)
|
|
152
|
+
return lines.join('\n')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 简短格式(单行) */
|
|
156
|
+
formatShort() {
|
|
157
|
+
const cost = this.getTotalCost()
|
|
158
|
+
return `$${cost.total.toFixed(4)} (${this.totalApiCalls} calls, ${this.totalInputTokens.toLocaleString()}+${this.totalOutputTokens.toLocaleString()} tok)`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 重置 */
|
|
162
|
+
reset() {
|
|
163
|
+
this.totalInputTokens = 0
|
|
164
|
+
this.totalOutputTokens = 0
|
|
165
|
+
this.totalCacheReadTokens = 0
|
|
166
|
+
this.totalApiCalls = 0
|
|
167
|
+
this.history = []
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export { PRICING, findPricing }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享路径常量 — cc-node 和 cc-notify 共用
|
|
3
|
+
*/
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import { homedir } from 'os'
|
|
6
|
+
|
|
7
|
+
export const SOCK_DIR = join(homedir(), '.cc-node')
|
|
8
|
+
export const SOCK_PATH = join(SOCK_DIR, 'repl.sock')
|
|
9
|
+
export const CC_NODE_PID = join(SOCK_DIR, 'cc-node.pid')
|
|
10
|
+
export const CC_NOTIFY_PID = join(SOCK_DIR, 'cc-notify.pid')
|
|
11
|
+
export const CC_NOTIFY_LOG = join(SOCK_DIR, 'cc-notify.log')
|
|
12
|
+
export const DEFAULT_HTTP_PORT = 3456
|