@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.
@@ -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'
@@ -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