@raolin2025/claude-code-node 2.8.0 → 2.8.2
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 +45 -1
- package/package.json +1 -1
- package/src/core/cli.js +78 -2
- package/src/core/context-window.js +262 -0
- package/src/core/headpiece.js +46 -34
- package/src/core/index.js +8 -0
- package/src/core/query-engine.js +14 -0
- package/src/core/token-budget.js +14 -0
package/README.md
CHANGED
|
@@ -111,11 +111,51 @@ printf '{"jsonrpc":"2.0","id":1,"method":"initialize"}\n' | cc-node --stdio
|
|
|
111
111
|
| `/clear` | 清空当前对话 |
|
|
112
112
|
| `/config KEY` | 查看配置(支持点号路径如 `tools.bash.timeout`) |
|
|
113
113
|
| `/budget` | 查看 Token 预算使用情况 |
|
|
114
|
+
| `/window [N]` | 查看/设置上下文窗口(如 `/window 128k`、`/window auto`) |
|
|
114
115
|
| `/exit` `/quit` | 退出(Ctrl+C 也可以) |
|
|
115
116
|
|
|
116
117
|
[](https://www.npmjs.com/package/@raolin2025/claude-code-node) [](https://github.com/bg1avd/claude-code-node) [](https://opensource.org/licenses/MIT)
|
|
117
118
|
---
|
|
118
119
|
|
|
120
|
+
## 🧠 上下文窗口感知(自动压缩)
|
|
121
|
+
|
|
122
|
+
cc-node 会自动感知当前所用模型的**上下文窗口长度**,并在接近上限时**自动压缩**对话历史,
|
|
123
|
+
确保上下文**永不超出**模型窗口。
|
|
124
|
+
|
|
125
|
+
### 窗口来源优先级
|
|
126
|
+
|
|
127
|
+
| 优先级 | 来源 | 说明 |
|
|
128
|
+
|--------|------|------|
|
|
129
|
+
| 1 | **手动指定**(`/window N`) | 持久化到 config,每次启动优先采用 |
|
|
130
|
+
| 2 | **API 探测** | 从 `GET /models` 响应提取(vLLM `context_length`、Ollama `model_info` 等) |
|
|
131
|
+
| 3 | **内置模型表** | 内置 40+ 常见模型上下文表(DeepSeek/OpenAI/Qwen/GLM/Kimi 等) |
|
|
132
|
+
| 4 | **安全兜底** | 64K(探测不到且表里没有时) |
|
|
133
|
+
|
|
134
|
+
> 探测结果**不落盘**,每次启动重新探测;只有 `/window N` 手动指定才会持久化(方案 A)。
|
|
135
|
+
> 探测不精确时,用 `/window N` 手动纠正即可。
|
|
136
|
+
|
|
137
|
+
### 自动压缩机制
|
|
138
|
+
|
|
139
|
+
- 当已用 token 达到窗口的 **80%** 时自动触发压缩(`autoCompact`)。
|
|
140
|
+
- 压缩策略:保留最近 4 轮完整对话,早期历史压缩为摘要(用户意图、用到的工具、关键结果),
|
|
141
|
+
并将过长工具结果截断,目标压到窗口的 60%。
|
|
142
|
+
- **发送前硬校验**:每次发请求前估算消息量,超窗先压缩再发,作为最终兜底保险。
|
|
143
|
+
|
|
144
|
+
### `/window` 命令用法
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
/window → 查看当前窗口 + 来源 + 用量
|
|
148
|
+
/window 128k → 手动指定 128K(持久化到 config)
|
|
149
|
+
/window 64k → 手动指定 64K
|
|
150
|
+
/window 1m → 手动指定 100 万
|
|
151
|
+
/window auto → 清除手动指定,回到自动探测
|
|
152
|
+
/window reset → 清除手动指定并立即重探测
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
> 切换模型(`/model`)后会自动重新探测窗口。`/budget` 也会显示当前窗口与 80% 触发阈值。
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
119
159
|
## 🛠️ 内置工具(10 个)
|
|
120
160
|
|
|
121
161
|
| 工具 | 说明 | 权限级别 | 安全检查 |
|
|
@@ -259,7 +299,7 @@ cc-node --api-base http://localhost:11434/v1
|
|
|
259
299
|
{
|
|
260
300
|
"model": "deepseek-chat",
|
|
261
301
|
"maxTurns": 100,
|
|
262
|
-
"maxBudgetTokens":
|
|
302
|
+
"maxBudgetTokens": 128000,
|
|
263
303
|
"permissionMode": "ask",
|
|
264
304
|
"tools": {
|
|
265
305
|
"bash": { "timeout": 120 },
|
|
@@ -272,6 +312,10 @@ cc-node --api-base http://localhost:11434/v1
|
|
|
272
312
|
}
|
|
273
313
|
```
|
|
274
314
|
|
|
315
|
+
> **`maxBudgetTokens`**:手动指定的上下文窗口上限(token 数)。
|
|
316
|
+
> 为 `0` 或未设置时,自动探测模型真实窗口;手动指定后优先于自动探测,
|
|
317
|
+
> 等价于 `/window N` 的效果,并持久化保存。
|
|
318
|
+
|
|
275
319
|
[](https://www.npmjs.com/package/@raolin2025/claude-code-node) [](https://github.com/bg1avd/claude-code-node) [](https://opensource.org/licenses/MIT)
|
|
276
320
|
---
|
|
277
321
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raolin2025/claude-code-node",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.2",
|
|
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",
|
package/src/core/cli.js
CHANGED
|
@@ -17,6 +17,13 @@ import { TokenBudget } from './token-budget.js'
|
|
|
17
17
|
import { ChannelManager } from '../channel/index.js'
|
|
18
18
|
import { CostTracker } from './cost-tracker.js'
|
|
19
19
|
import { autoCompact } from './compact.js'
|
|
20
|
+
import {
|
|
21
|
+
detectContextWindow,
|
|
22
|
+
parseWindowArg,
|
|
23
|
+
formatTokens,
|
|
24
|
+
windowSourceLabel,
|
|
25
|
+
WINDOW_SOURCE,
|
|
26
|
+
} from './context-window.js'
|
|
20
27
|
import { isLocalLlmServer } from '../utils/index.js'
|
|
21
28
|
import { SOCK_DIR, SOCK_PATH, CC_NODE_PID } from './paths.js'
|
|
22
29
|
import { renderHeadpiece } from './headpiece.js'
|
|
@@ -254,6 +261,7 @@ Commands:
|
|
|
254
261
|
/stop — Stop current AI work (when stuck/long)
|
|
255
262
|
/config KEY — Show config value
|
|
256
263
|
/budget — Show token budget
|
|
264
|
+
/window [N] — Show/set context window (e.g. /window 128k, /window auto)
|
|
257
265
|
/channel CMD — Manage notification channels (list|send|test)
|
|
258
266
|
/cost — Show API cost report
|
|
259
267
|
/compact — Manually compact conversation context
|
|
@@ -292,6 +300,8 @@ const DETAILED_HELP = {
|
|
|
292
300
|
|
|
293
301
|
budget: "/budget\n Show token budget usage for the current session.\n Displays how many tokens have been used vs the limit.",
|
|
294
302
|
|
|
303
|
+
window: "/window [N|auto|reset]\n Show or set the LLM context window (max tokens allowed in conversation).\n This is the hard ceiling for auto-compression — context will never exceed it.\n\n Subcommands:\n (no arg) — Show current window + its source (manual/probe/table/fallback) + usage\n /window 128k — Manually set window (persisted to config). Accepts K/M suffix:\n 64k, 128k, 200k, 1m, or a raw number like 131072.\n /window auto — Clear manual override, return to auto-detection (next message re-detects).\n /window reset — Clear manual override AND re-detect now.\n\n Manual setting takes priority over auto-detection and survives restart.\n Auto-detection itself is NOT persisted (re-runs at each startup).\n\n Examples:\n /window\n /window 128k\n /window 64k\n /window auto\n /window reset",
|
|
304
|
+
|
|
295
305
|
channel: "/channel <list|send|test>\n Manage notification channels.\n\n Subcommands:\n list — List all configured notification channels\n send <msg> — Send a message via all channels\n test — Send a test message to verify channels\n\n Requires channel environment variables to be set at startup.",
|
|
296
306
|
|
|
297
307
|
cost: "/cost\n Show API cost report.\n Displays total tokens used and estimated cost in USD.\n Supports pricing for: DeepSeek, OpenAI, Qwen, GLM, Kimi.",
|
|
@@ -481,7 +491,31 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
481
491
|
}
|
|
482
492
|
|
|
483
493
|
// M1 fix: tokenBudget 必须在 engineConfig 之前定义,否则 TDZ ReferenceError
|
|
484
|
-
|
|
494
|
+
// 上下文窗口来源:手动指定 > API 探测 > 内置表 > 安全兜底
|
|
495
|
+
// - 手动指定(config.maxBudgetTokens > 0)优先,且持久化
|
|
496
|
+
// - 自动探测结果不落盘,每次启动重新探测(方案 A)
|
|
497
|
+
let windowSource = WINDOW_SOURCE.FALLBACK
|
|
498
|
+
const tokenBudget = new TokenBudget({
|
|
499
|
+
maxTokens: config.get('maxBudgetTokens') || 0,
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
/** 重新探测并应用上下文窗口(供启动 & /window reset & /model 切换后调用) */
|
|
503
|
+
async function reapplyWindow() {
|
|
504
|
+
const manual = config.get('maxBudgetTokens') || 0
|
|
505
|
+
const { window: win, source } = await detectContextWindow({
|
|
506
|
+
model,
|
|
507
|
+
apiBase,
|
|
508
|
+
apiKey,
|
|
509
|
+
manualWindow: manual,
|
|
510
|
+
})
|
|
511
|
+
tokenBudget.setWindow(win, source)
|
|
512
|
+
windowSource = source
|
|
513
|
+
return { window: win, source }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// 启动即探测一次(若已手动指定,则直接应用手动值)
|
|
517
|
+
await reapplyWindow()
|
|
518
|
+
|
|
485
519
|
const costTracker = new CostTracker({ model })
|
|
486
520
|
|
|
487
521
|
const engineConfig = new QueryEngineConfig({
|
|
@@ -684,10 +718,48 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
684
718
|
} else {
|
|
685
719
|
engine.config.model = rest.join(' ')
|
|
686
720
|
}
|
|
721
|
+
model = engine.config.model
|
|
687
722
|
console.log(`Model → ${engine.config.model}`)
|
|
723
|
+
// 切换模型后重新探测上下文窗口(仅当非手动指定时自动更新)
|
|
724
|
+
if ((config.get('maxBudgetTokens') || 0) <= 0) {
|
|
725
|
+
const { window: win, source } = await reapplyWindow()
|
|
726
|
+
console.log(` ↳ Context window → ${formatTokens(win)} (${windowSourceLabel(source)})`)
|
|
727
|
+
}
|
|
688
728
|
}
|
|
689
729
|
else console.log(`Model: ${engine.config.model}`)
|
|
690
730
|
break
|
|
731
|
+
case 'window': {
|
|
732
|
+
const arg = rest[0] ? rest.join(' ').trim() : ''
|
|
733
|
+
if (!arg) {
|
|
734
|
+
// 无参数:显示当前窗口
|
|
735
|
+
console.log(`Context window: ${formatTokens(tokenBudget.maxTokens)} (${windowSourceLabel(windowSource)})`)
|
|
736
|
+
console.log(` Input used: ${tokenBudget.inputTokens.toLocaleString()} / ${tokenBudget.maxTokens.toLocaleString()} (${tokenBudget.usagePercent}%)`)
|
|
737
|
+
const manual = config.get('maxBudgetTokens') || 0
|
|
738
|
+
if (manual > 0) console.log(` Manual override: ${formatTokens(manual)} (persisted in config)`)
|
|
739
|
+
break
|
|
740
|
+
}
|
|
741
|
+
const lower = arg.toLowerCase()
|
|
742
|
+
if (lower === 'auto' || lower === 'reset') {
|
|
743
|
+
// 清除手动指定并重新探测
|
|
744
|
+
config.set('maxBudgetTokens', 0)
|
|
745
|
+
try { await config.saveToUser() } catch {}
|
|
746
|
+
const { window: win, source } = await reapplyWindow()
|
|
747
|
+
console.log(`Cleared manual override. Context window → ${formatTokens(win)} (${windowSourceLabel(source)})`)
|
|
748
|
+
break
|
|
749
|
+
}
|
|
750
|
+
const win = parseWindowArg(arg)
|
|
751
|
+
if (!win) {
|
|
752
|
+
console.log(`❌ Invalid window: "${arg}". Use a number or K/M suffix, e.g. /window 128k, /window 64k, /window 1m`)
|
|
753
|
+
break
|
|
754
|
+
}
|
|
755
|
+
// 手动指定:立即生效 + 持久化
|
|
756
|
+
tokenBudget.setWindow(win, WINDOW_SOURCE.MANUAL)
|
|
757
|
+
windowSource = WINDOW_SOURCE.MANUAL
|
|
758
|
+
config.set('maxBudgetTokens', win)
|
|
759
|
+
try { await config.saveToUser() } catch (e) { console.log(`⚠️ Failed to persist to config: ${e.message}`) }
|
|
760
|
+
console.log(`Context window → ${formatTokens(win)} (${windowSourceLabel(WINDOW_SOURCE.MANUAL)}, persisted)`)
|
|
761
|
+
break
|
|
762
|
+
}
|
|
691
763
|
case 'models': {
|
|
692
764
|
const apiBase = engine.config.apiBase
|
|
693
765
|
const apiKey = engine.config.apiKey
|
|
@@ -818,7 +890,11 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
818
890
|
console.log(JSON.stringify(config.toJSON(), null, 2))
|
|
819
891
|
}
|
|
820
892
|
break
|
|
821
|
-
case 'budget':
|
|
893
|
+
case 'budget': {
|
|
894
|
+
console.log(tokenBudget.format())
|
|
895
|
+
console.log(`Context window: ${formatTokens(tokenBudget.maxTokens)} (${windowSourceLabel(windowSource)}) | trigger: ${Math.round((tokenBudget.maxTokens * 0.8)).toLocaleString()} (80%)`)
|
|
896
|
+
break
|
|
897
|
+
}
|
|
822
898
|
case 'channel': {
|
|
823
899
|
const subCmd = rest.join(' ')
|
|
824
900
|
if (subCmd === 'list' || subCmd === '') {
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上下文窗口感知 (Context Window) — 自动探测 + 手动指定
|
|
3
|
+
*
|
|
4
|
+
* 解决的问题:TokenBudget.maxTokens 之前是一个"静态配置"(默认 100 万,
|
|
5
|
+
* CLI 兜底 20 万),并不知道所用模型真实上下文窗口。若预算 > 模型真实窗口,
|
|
6
|
+
* 自动压缩永远不会触发,上下文一路涨到模型报错。
|
|
7
|
+
*
|
|
8
|
+
* 本模块提供三层"窗口来源",优先级:
|
|
9
|
+
* 1. manual — 用户通过 /window 手动指定(持久化到 config)
|
|
10
|
+
* 2. probe — 从 API /models 探测到的窗口(本次进程生效,不落盘)
|
|
11
|
+
* 3. table — 内置常见模型上下文表(无法探测时的静态兜底)
|
|
12
|
+
* 4. fallback— 安全默认值(如 64K)
|
|
13
|
+
*
|
|
14
|
+
* 探测结果默认不落盘,手动指定才落盘(方案 A)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ---- 内置常见模型上下文表(token)----
|
|
18
|
+
// 缺失的厂商/模型会回落到 fallback;不精确属预期,可用 /window 手动纠正。
|
|
19
|
+
export const MODEL_CONTEXT_TABLE = {
|
|
20
|
+
// DeepSeek
|
|
21
|
+
'deepseek-chat': 128_000,
|
|
22
|
+
'deepseek-reasoner': 128_000,
|
|
23
|
+
'deepseek-coder': 128_000,
|
|
24
|
+
'deepseek-v3': 128_000,
|
|
25
|
+
'deepseek-v2': 128_000,
|
|
26
|
+
'deepseek-v2.5': 128_000,
|
|
27
|
+
'deepseek-v4': 128_000,
|
|
28
|
+
// OpenAI
|
|
29
|
+
'gpt-4o': 128_000,
|
|
30
|
+
'gpt-4o-mini': 128_000,
|
|
31
|
+
'gpt-4-turbo': 128_000,
|
|
32
|
+
'gpt-4': 32_768,
|
|
33
|
+
'gpt-3.5-turbo': 16_384,
|
|
34
|
+
'o1': 200_000,
|
|
35
|
+
'o1-mini': 128_000,
|
|
36
|
+
'o3-mini': 200_000,
|
|
37
|
+
// Anthropic (经兼容网关时)
|
|
38
|
+
'claude-3-5-sonnet': 200_000,
|
|
39
|
+
'claude-3-5-haiku': 200_000,
|
|
40
|
+
'claude-sonnet-4': 200_000,
|
|
41
|
+
'claude-haiku-4': 200_000,
|
|
42
|
+
// Qwen (通义千问)
|
|
43
|
+
'qwen-max': 32_768,
|
|
44
|
+
'qwen-plus': 131_072,
|
|
45
|
+
'qwen-turbo': 1_000_000,
|
|
46
|
+
'qwen2.5-72b-instruct': 131_072,
|
|
47
|
+
'qwen2.5-32b-instruct': 131_072,
|
|
48
|
+
'qwen2.5-7b-instruct': 131_072,
|
|
49
|
+
'qwen2.5-14b-instruct': 131_072,
|
|
50
|
+
'qwen2.5-coder-32b-instruct': 131_072,
|
|
51
|
+
// GLM (智谱)
|
|
52
|
+
'glm-4': 128_000,
|
|
53
|
+
'glm-4-plus': 128_000,
|
|
54
|
+
'glm-4-air': 128_000,
|
|
55
|
+
'glm-4-flash': 128_000,
|
|
56
|
+
'glm-4v': 8_192,
|
|
57
|
+
'glm-4.5': 128_000,
|
|
58
|
+
'glm-4.5-air': 128_000,
|
|
59
|
+
// Moonshot (Kimi)
|
|
60
|
+
'moonshot-v1-8k': 8_192,
|
|
61
|
+
'moonshot-v1-32k': 32_768,
|
|
62
|
+
'moonshot-v1-128k': 131_072,
|
|
63
|
+
'moonshot-v1-auto': 131_072,
|
|
64
|
+
'kimi-k2': 128_000,
|
|
65
|
+
// 本地/开源 (Ollama / vLLM 常见)
|
|
66
|
+
'llama3.1': 131_072,
|
|
67
|
+
'llama3': 8_192,
|
|
68
|
+
'llama2': 4_096,
|
|
69
|
+
'mistral': 32_768,
|
|
70
|
+
'mixtral': 32_768,
|
|
71
|
+
'gemma2': 8_192,
|
|
72
|
+
'codellama': 16_384,
|
|
73
|
+
'qwen2.5': 131_072,
|
|
74
|
+
'yi-34b': 4_096,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 安全兜底默认值 — 探测不到、表里也没有时使用
|
|
78
|
+
export const FALLBACK_CONTEXT_WINDOW = 64_000
|
|
79
|
+
|
|
80
|
+
// ---- 窗口来源标签 ----
|
|
81
|
+
export const WINDOW_SOURCE = {
|
|
82
|
+
MANUAL: 'manual',
|
|
83
|
+
PROBE: 'probe',
|
|
84
|
+
TABLE: 'table',
|
|
85
|
+
FALLBACK: 'fallback',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const SOURCE_LABEL = {
|
|
89
|
+
[WINDOW_SOURCE.MANUAL]: '手动指定',
|
|
90
|
+
[WINDOW_SOURCE.PROBE]: 'API 探测',
|
|
91
|
+
[WINDOW_SOURCE.TABLE]: '内置模型表',
|
|
92
|
+
[WINDOW_SOURCE.FALLBACK]: '安全兜底',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 从 /models 响应中尝试提取单个模型的上下文窗口
|
|
97
|
+
*
|
|
98
|
+
* 不同服务字段名不一,尽量覆盖常见命名:
|
|
99
|
+
* - OpenAI 兼容 vLLM: context_length / max_model_len / max_context_length
|
|
100
|
+
* - Ollama: (来自 /api/show 的 model_info.llama.context_length)
|
|
101
|
+
*
|
|
102
|
+
* @param {object} modelObj — /models 返回数组中的单个模型对象
|
|
103
|
+
* @returns {number|null} 窗口 token 数;未知返回 null
|
|
104
|
+
*/
|
|
105
|
+
export function extractContextFromModelObj(modelObj) {
|
|
106
|
+
if (!modelObj || typeof modelObj !== 'object') return null
|
|
107
|
+
const keys = [
|
|
108
|
+
'context_length',
|
|
109
|
+
'context_window',
|
|
110
|
+
'max_context_length',
|
|
111
|
+
'max_model_len',
|
|
112
|
+
'max_sequence_length',
|
|
113
|
+
'model_max_length',
|
|
114
|
+
'n_ctx',
|
|
115
|
+
'contextWindow',
|
|
116
|
+
'contextSize',
|
|
117
|
+
'maxContext',
|
|
118
|
+
'maxTokens',
|
|
119
|
+
'context_tokens',
|
|
120
|
+
]
|
|
121
|
+
for (const k of keys) {
|
|
122
|
+
const v = modelObj[k]
|
|
123
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v
|
|
124
|
+
}
|
|
125
|
+
// 某些服务把窗口放在 meta / details 子对象
|
|
126
|
+
for (const sub of ['meta', 'details', 'model_info', 'capabilities']) {
|
|
127
|
+
const nested = modelObj[sub]
|
|
128
|
+
if (nested && typeof nested === 'object') {
|
|
129
|
+
const found = extractContextFromModelObj(nested)
|
|
130
|
+
if (found) return found
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Ollama model_info 专用字段:llama.context_length
|
|
134
|
+
const modelInfo = modelObj.model_info
|
|
135
|
+
if (modelInfo && typeof modelInfo === 'object') {
|
|
136
|
+
const ctxKey = Object.keys(modelInfo).find(k => k.endsWith('.context_length'))
|
|
137
|
+
if (ctxKey && typeof modelInfo[ctxKey] === 'number' && modelInfo[ctxKey] > 0) {
|
|
138
|
+
return modelInfo[ctxKey]
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 探测当前模型的上下文窗口
|
|
146
|
+
*
|
|
147
|
+
* 顺序:
|
|
148
|
+
* 1. 若已手动指定(manualWindow > 0)→ 直接返回手动值
|
|
149
|
+
* 2. 尝试从 GET {apiBase}/models 的响应中找匹配模型并提取窗口
|
|
150
|
+
* 3. 查内置表
|
|
151
|
+
* 4. 兜底 fallback
|
|
152
|
+
*
|
|
153
|
+
* @param {object} opts
|
|
154
|
+
* @param {string} opts.model — 模型名
|
|
155
|
+
* @param {string} opts.apiBase — API Base URL
|
|
156
|
+
* @param {string} [opts.apiKey]
|
|
157
|
+
* @param {number} [opts.manualWindow] — 用户手动指定的窗口(0 表示未指定)
|
|
158
|
+
* @param {number} [opts.fetchTimeoutMs] — 探测超时(默认 3000ms)
|
|
159
|
+
* @returns {Promise<{window:number, source:string}>}
|
|
160
|
+
*/
|
|
161
|
+
export async function detectContextWindow({ model, apiBase, apiKey, manualWindow = 0, fetchTimeoutMs = 3000 }) {
|
|
162
|
+
// 1. 手动指定最高优先级
|
|
163
|
+
if (manualWindow > 0) {
|
|
164
|
+
return { window: manualWindow, source: WINDOW_SOURCE.MANUAL }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 2. 探测 API
|
|
168
|
+
if (apiBase) {
|
|
169
|
+
try {
|
|
170
|
+
const controller = new AbortController()
|
|
171
|
+
const timer = setTimeout(() => controller.abort(), fetchTimeoutMs)
|
|
172
|
+
const url = apiBase.replace(/\/+$/, '') + '/models'
|
|
173
|
+
const res = await fetch(url, {
|
|
174
|
+
headers: {
|
|
175
|
+
'Content-Type': 'application/json',
|
|
176
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
177
|
+
},
|
|
178
|
+
signal: controller.signal,
|
|
179
|
+
})
|
|
180
|
+
clearTimeout(timer)
|
|
181
|
+
if (res.ok) {
|
|
182
|
+
const data = await res.json()
|
|
183
|
+
const models = data.data || []
|
|
184
|
+
// 优先精确匹配当前 model
|
|
185
|
+
let target = models.find(m => (m.id || '') === model)
|
|
186
|
+
// 其次尝试包含匹配(如 model 是 'qwen2.5' 而列表是 'qwen2.5:14b')
|
|
187
|
+
if (!target) {
|
|
188
|
+
target = models.find(m => (m.id || '').startsWith(model) || model.startsWith(m.id || ''))
|
|
189
|
+
}
|
|
190
|
+
if (target) {
|
|
191
|
+
const win = extractContextFromModelObj(target)
|
|
192
|
+
if (win) return { window: win, source: WINDOW_SOURCE.PROBE }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// 探测失败(超时/网络/无权限)→ 继续走表/兜底
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 3. 内置表
|
|
201
|
+
const normalized = (model || '').toLowerCase()
|
|
202
|
+
if (normalized && MODEL_CONTEXT_TABLE[normalized]) {
|
|
203
|
+
return { window: MODEL_CONTEXT_TABLE[normalized], source: WINDOW_SOURCE.TABLE }
|
|
204
|
+
}
|
|
205
|
+
// 表内做前缀匹配(如 'deepseek-chat:latest'、'qwen2.5-coder:7b')
|
|
206
|
+
for (const key of Object.keys(MODEL_CONTEXT_TABLE)) {
|
|
207
|
+
if (normalized.startsWith(key) || key.startsWith(normalized)) {
|
|
208
|
+
return { window: MODEL_CONTEXT_TABLE[key], source: WINDOW_SOURCE.TABLE }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 4. 兜底
|
|
213
|
+
return { window: FALLBACK_CONTEXT_WINDOW, source: WINDOW_SOURCE.FALLBACK }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 解析 /window 命令的参数为窗口 token 数
|
|
218
|
+
*
|
|
219
|
+
* 支持:
|
|
220
|
+
* "131072" → 131072
|
|
221
|
+
* "64k" → 65536
|
|
222
|
+
* "128k" → 131072
|
|
223
|
+
* "200k" → 204800
|
|
224
|
+
* "1m" → 1_000_000
|
|
225
|
+
*
|
|
226
|
+
* @param {string} str
|
|
227
|
+
* @returns {number|null} token 数;非法返回 null
|
|
228
|
+
*/
|
|
229
|
+
export function parseWindowArg(str) {
|
|
230
|
+
if (typeof str !== 'string') return null
|
|
231
|
+
const s = str.trim().toLowerCase().replace(/,/g, '')
|
|
232
|
+
if (!s) return null
|
|
233
|
+
const m = s.match(/^(\d+)\s*(k|m|kb|mb)?$/)
|
|
234
|
+
if (!m) return null
|
|
235
|
+
const num = parseInt(m[1], 10)
|
|
236
|
+
const unit = m[2]
|
|
237
|
+
if (!Number.isFinite(num) || num <= 0) return null
|
|
238
|
+
if (unit === 'k' || unit === 'kb') return num * 1000
|
|
239
|
+
if (unit === 'm' || unit === 'mb') return num * 1_000_000
|
|
240
|
+
return num
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 将 token 数格式化为人类可读(十进制,与 token 常见记法一致:128K = 128,000)
|
|
245
|
+
* @param {number} tokens
|
|
246
|
+
* @returns {string} 如 "128.0K"、"1.00M"
|
|
247
|
+
*/
|
|
248
|
+
export function formatTokens(tokens) {
|
|
249
|
+
if (!Number.isFinite(tokens) || tokens <= 0) return '?'
|
|
250
|
+
if (tokens >= 1_000_000) return (tokens / 1_000_000).toFixed(2) + 'M'
|
|
251
|
+
if (tokens >= 1000) return (tokens / 1000).toFixed(1) + 'K'
|
|
252
|
+
return String(tokens)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 获取窗口来源的中文标签
|
|
257
|
+
* @param {string} source
|
|
258
|
+
* @returns {string}
|
|
259
|
+
*/
|
|
260
|
+
export function windowSourceLabel(source) {
|
|
261
|
+
return SOURCE_LABEL[source] || source || '未知'
|
|
262
|
+
}
|
package/src/core/headpiece.js
CHANGED
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
// 只负责「头标」的绘制与配色,与 CLI 框架解耦。
|
|
5
5
|
// 以后要替换/升级头标,只需修改本文件并保持接口不变即可。
|
|
6
6
|
//
|
|
7
|
-
// 头标内容: CC-NODE
|
|
8
|
-
// CC
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// 头标内容: CC-NODE 机械臂机器人艺术字(v2,取自 log/headpiece.js)
|
|
8
|
+
// CC = 一对机械眼睛(带 ◉◉ 瞳孔,蓝渐变 5 行)
|
|
9
|
+
// N = 左臂,向左侧伸展 ──┼
|
|
10
|
+
// O = 圆形身体/躯干(白色)
|
|
11
|
+
// D = 右臂,向右侧伸展 ┼──
|
|
12
|
+
// E = 手持工具箱/锤子(黄色 ╔═══╗)
|
|
13
|
+
// 上下总宽 20 字符,总高 10 行,严格对齐
|
|
11
14
|
//
|
|
12
15
|
// 接口:
|
|
13
16
|
// renderHeadpiece({ colWidth = 24 } = {})
|
|
@@ -17,39 +20,38 @@
|
|
|
17
20
|
// width 头标原始可见宽度
|
|
18
21
|
// ============================================================
|
|
19
22
|
|
|
20
|
-
// ---- 头标原始数据(20列 x
|
|
21
|
-
// CC
|
|
23
|
+
// ---- 头标原始数据(20列 x 10行)----
|
|
24
|
+
// CC 部分(机械眼睛,蓝渐变,5 行;每行统一右填充至 20 列保证对齐)
|
|
22
25
|
const CC = [
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
26
|
+
'\x1b[38;5;81m ╭────╮╭────╮\x1b[0m',
|
|
27
|
+
'\x1b[38;5;75m ╱ ◉◉ ╲╱ ◉◉ ╲\x1b[0m',
|
|
28
|
+
'\x1b[38;5;69m │ ◉◉ ││ ◉◉ │\x1b[0m',
|
|
29
|
+
'\x1b[38;5;63m │ ││ │\x1b[0m',
|
|
30
|
+
'\x1b[38;5;57m ╰────╯╰────╯\x1b[0m',
|
|
27
31
|
]
|
|
28
32
|
|
|
29
|
-
// NODE
|
|
33
|
+
// NODE 部分(左臂绿 + 身体白 + 右臂绿 + 工具箱黄,5 行)
|
|
30
34
|
const NODE = [
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
+
'\x1b[38;5;82m╱───┐\x1b[0m\x1b[38;5;15m┌───┐\x1b[0m\x1b[38;5;82m┌───╲\x1b[0m\x1b[38;5;220m╔═══╗\x1b[0m',
|
|
36
|
+
'\x1b[38;5;118m│N │\x1b[0m\x1b[38;5;15m│ │\x1b[0m\x1b[38;5;118m│D │\x1b[0m\x1b[38;5;220m║ ║\x1b[0m',
|
|
37
|
+
'\x1b[38;5;154m│ │\x1b[0m\x1b[38;5;15m│ │\x1b[0m\x1b[38;5;154m│ │\x1b[0m\x1b[38;5;220m╠═══╣\x1b[0m',
|
|
38
|
+
'\x1b[38;5;190m──┼ │\x1b[0m\x1b[38;5;15m│ │\x1b[0m\x1b[38;5;190m│ ┼─\x1b[0m\x1b[38;5;220m║ ║\x1b[0m',
|
|
39
|
+
'\x1b[38;5;226m╲───┘\x1b[0m\x1b[38;5;15m└───┘\x1b[0m\x1b[38;5;226m└───╱\x1b[0m\x1b[38;5;220m╚═══╝\x1b[0m',
|
|
35
40
|
]
|
|
36
41
|
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
const BLUE = [81, 75, 69, 63]
|
|
40
|
-
// NODE 绿色渐变(深 → 浅)
|
|
41
|
-
const GREEN = [82, 118, 154, 190]
|
|
42
|
+
// 合并全部行
|
|
43
|
+
const RAW_LINES = [...CC, ...NODE]
|
|
42
44
|
|
|
43
|
-
//
|
|
44
|
-
function colorize(lines, colors) {
|
|
45
|
-
return lines.map((line, i) => `\x1b[38;5;${colors[i]}m${line}\x1b[0m`)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// 去掉 ANSI 转义码,返回真实可见长度
|
|
45
|
+
// ---- 配色工具 ----
|
|
49
46
|
function stripAnsi(s) {
|
|
50
47
|
return s.replace(/\x1b\[[0-9;]*m/g, '')
|
|
51
48
|
}
|
|
52
49
|
|
|
50
|
+
/** 去掉尾部 ANSI reset 码,供拼接填充空格时保持颜色连续 */
|
|
51
|
+
function trimTrailingReset(s) {
|
|
52
|
+
return s.replace(/\x1b\[0m\s*$/, '')
|
|
53
|
+
}
|
|
54
|
+
|
|
53
55
|
/**
|
|
54
56
|
* 渲染头标。
|
|
55
57
|
* @param {Object} [opts]
|
|
@@ -57,23 +59,33 @@ function stripAnsi(s) {
|
|
|
57
59
|
* @returns {{ lines: string[], height: number, width: number }}
|
|
58
60
|
*/
|
|
59
61
|
export function renderHeadpiece({ colWidth = 24 } = {}) {
|
|
60
|
-
// 1.
|
|
61
|
-
const width = stripAnsi
|
|
62
|
+
// 1. 原始可见宽度(取最宽一行)
|
|
63
|
+
const width = Math.max(...RAW_LINES.map(stripAnsi).map(s => s.length))
|
|
64
|
+
const height = RAW_LINES.length // 10
|
|
62
65
|
|
|
63
|
-
// 2.
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
+
// 2. 逐行补齐到统一宽度 width(右填充空格,保证右边缘对齐;空格放 ANSI reset 之前)
|
|
67
|
+
const padded = RAW_LINES.map((line) => {
|
|
68
|
+
const plain = stripAnsi(line)
|
|
69
|
+
const missing = width - plain.length
|
|
70
|
+
if (missing <= 0) return line
|
|
71
|
+
// 保留前导 ANSI 与尾部 reset:在 reset 前补空格
|
|
72
|
+
const leadingAnsi = line.match(/^(\x1b\[[0-9;]*m)+/)?.[0] || ''
|
|
73
|
+
const body = line.slice(leadingAnsi.length)
|
|
74
|
+
const trailingAnsi = body.match(/(\x1b\[0m)+$/)?.[0] || ''
|
|
75
|
+
const inner = body.slice(0, body.length - trailingAnsi.length)
|
|
76
|
+
return leadingAnsi + inner + ' '.repeat(missing) + trailingAnsi
|
|
77
|
+
})
|
|
66
78
|
|
|
67
|
-
// 3. 在 colWidth 内居中填充(若 colWidth
|
|
79
|
+
// 3. 在 colWidth 内居中填充(若 colWidth <= width 则不做填充,原样返回)
|
|
68
80
|
if (colWidth <= width) {
|
|
69
|
-
return { lines:
|
|
81
|
+
return { lines: padded, height, width }
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
const totalPad = colWidth - width
|
|
73
85
|
const leftPad = Math.floor(totalPad / 2)
|
|
74
86
|
const rightPad = totalPad - leftPad
|
|
75
87
|
|
|
76
|
-
const lines =
|
|
88
|
+
const lines = padded.map((line) => {
|
|
77
89
|
const leadingAnsi = line.match(/^(\x1b\[[0-9;]*m)+/)?.[0] || ''
|
|
78
90
|
const trailingAnsi = line.match(/(\x1b\[0m)+$/)?.[0] || '\x1b[0m'
|
|
79
91
|
const plain = stripAnsi(line)
|
package/src/core/index.js
CHANGED
|
@@ -5,6 +5,14 @@ export { UserMessage, AssistantMessage, ToolCall } from "../types/index.js"
|
|
|
5
5
|
*/
|
|
6
6
|
export { QueryEngine, QueryEngineConfig } from './query-engine.js'
|
|
7
7
|
export { TokenBudget, estimateTokens } from './token-budget.js'
|
|
8
|
+
export {
|
|
9
|
+
detectContextWindow,
|
|
10
|
+
parseWindowArg,
|
|
11
|
+
formatTokens,
|
|
12
|
+
windowSourceLabel,
|
|
13
|
+
WINDOW_SOURCE,
|
|
14
|
+
MODEL_CONTEXT_TABLE,
|
|
15
|
+
} from './context-window.js'
|
|
8
16
|
export { SessionManager } from './session.js'
|
|
9
17
|
export { Config } from './config.js'
|
|
10
18
|
export { parseStream, parseNonStreamResponse } from './streaming.js'
|
package/src/core/query-engine.js
CHANGED
|
@@ -111,6 +111,20 @@ export class QueryEngine {
|
|
|
111
111
|
let finalResponse = ''
|
|
112
112
|
|
|
113
113
|
for (let turn = 0; turn < this.config.maxTurns; turn++) {
|
|
114
|
+
// 发送前硬校验:估算即将发送的消息是否超出窗口,超限则先压缩(最终兜底,防止溢出)
|
|
115
|
+
if (this.tokenBudget) {
|
|
116
|
+
const est = this.tokenBudget.estimateMessages(this.state.messages)
|
|
117
|
+
if (est > this.tokenBudget.maxTokens - this.tokenBudget.reservedForOutput) {
|
|
118
|
+
const { compacted, messages } = autoCompact(this.state.messages, this.tokenBudget, {
|
|
119
|
+
maxTokens: Math.floor(this.tokenBudget.maxTokens * 0.6),
|
|
120
|
+
})
|
|
121
|
+
if (compacted) {
|
|
122
|
+
this.state.messages = messages
|
|
123
|
+
if (this.config.verbose) console.error('[compact] Pre-send hard check: compressed to stay within window')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
114
128
|
const requestMessages = this._buildRequest(this.state.messages)
|
|
115
129
|
const response = await this._callLLM(requestMessages, this.state.messages)
|
|
116
130
|
|
package/src/core/token-budget.js
CHANGED
|
@@ -24,6 +24,20 @@ export class TokenBudget {
|
|
|
24
24
|
this.used = 0
|
|
25
25
|
this.inputTokens = 0
|
|
26
26
|
this.outputTokens = 0
|
|
27
|
+
// 当前窗口来源:'manual' | 'probe' | 'table' | 'fallback'(见 context-window.js)
|
|
28
|
+
this.windowSource = options.windowSource || 'fallback'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 更新上下文窗口上限(运行时生效,立即反映到 usagePercent)
|
|
33
|
+
* @param {number} maxTokens
|
|
34
|
+
* @param {string} [source] — 窗口来源标签
|
|
35
|
+
*/
|
|
36
|
+
setWindow(maxTokens, source) {
|
|
37
|
+
if (Number.isFinite(maxTokens) && maxTokens > 0) {
|
|
38
|
+
this.maxTokens = maxTokens
|
|
39
|
+
}
|
|
40
|
+
if (source) this.windowSource = source
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
/** 可用于上下文的最大 token 数 */
|