@raolin2025/claude-code-node 2.8.1 → 2.8.3
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 +65 -3
- package/package.json +1 -1
- package/src/__tests__/web-fetch-guard.test.js +193 -0
- package/src/core/cli.js +79 -2
- package/src/core/config.js +10 -0
- package/src/core/context-window.js +262 -0
- package/src/core/index.js +8 -0
- package/src/core/query-engine.js +15 -0
- package/src/core/token-budget.js +14 -0
- package/src/security/fetch-guard.js +311 -0
- package/src/security/redact.js +76 -0
- package/src/tools/web-fetch-providers.js +140 -0
- package/src/tools/web-fetch.js +114 -60
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
|
| 工具 | 说明 | 权限级别 | 安全检查 |
|
|
@@ -126,7 +166,7 @@ printf '{"jsonrpc":"2.0","id":1,"method":"initialize"}\n' | cc-node --stdio
|
|
|
126
166
|
| **Write** | 创建/覆盖文件 | `ask` | ✅ 写入路径安全 |
|
|
127
167
|
| **Glob** | 文件模式搜索 | `always-allow` | — |
|
|
128
168
|
| **Grep** | 内容搜索(rg/grep) | `always-allow` | — |
|
|
129
|
-
| **WebFetch** |
|
|
169
|
+
| **WebFetch** | 抓取网页内容(安全管道 + Jina 兜底) | `ask` | ✅ SSRF + 重定向 + 脱敏 |
|
|
130
170
|
| **WebSearch** | 网页搜索 | `ask` | 需要 API Key |
|
|
131
171
|
| **GitTool** | GitHub PR 自动化(审查/合并/评论) | `high` | ✅ 预检查 |
|
|
132
172
|
| **NpmPublish** | npm 发布一键工具(版本/打包/发布) | `ask` | ✅ token 校验 |
|
|
@@ -154,9 +194,27 @@ printf '{"jsonrpc":"2.0","id":1,"method":"initialize"}\n' | cc-node --stdio
|
|
|
154
194
|
✅ ::1 — IPv6 回环
|
|
155
195
|
```
|
|
156
196
|
|
|
197
|
+
### 🕸️ WebFetch 安全抓取(安全管道 + Jina 兜底)
|
|
198
|
+
|
|
199
|
+
`WebFetch` 内置完整安全管道(移植自 safe-jina-fetch 设计,`src/security/fetch-guard.js`):
|
|
200
|
+
|
|
201
|
+
- **协议白名单**:仅 `http/https`,拒绝 `file://`、`ftp://`、`data:` 等
|
|
202
|
+
- **连接级 SSRF**:TCP 连接建立时对全部解析地址逐一校验(防 DNS rebinding),
|
|
203
|
+
并对 IP 字面量前置校验(Node 对 IP 不走 DNS lookup,必须显式拦截)
|
|
204
|
+
- **重定向逐跳校验**:默认最多 5 跳,每跳重新校验协议 + SSRF(防 302 → 内网绕过)
|
|
205
|
+
- **响应大小上限**:10MB;**超时**:30s;**强制 SSL**(证书错误直接拒绝)
|
|
206
|
+
- **敏感数据自动脱敏**:API Key / Bearer / AWS Key / 私钥 / OpenAI `sk-` / Slack token 等
|
|
207
|
+
命中即替换为 `[REDACTED:类型]` 并告警(`src/security/redact.js`)
|
|
208
|
+
|
|
209
|
+
**Jina Reader 兜底**(`src/tools/web-fetch-providers.js`):
|
|
210
|
+
|
|
211
|
+
- 直连失败(403 / 反爬 / 网络错误 / 超时)时,自动经 `r.jina.ai` 清洗后返回 Markdown
|
|
212
|
+
- 直连返回 200 但正文 < 200 字符(疑似 JS 挑战页,如豆瓣)也触发兜底
|
|
213
|
+
- `extractMode` 参数:`auto`(默认,直连优先 + 兜底)/ `direct`(强制直连)/ `jina`(强制 Jina)
|
|
214
|
+
- Jina 凭据三选一(可选,匿名约 20 RPM):环境变量 `JINA_API_KEY` > 配置 `web.fetch.jinaApiKey` > 匿名
|
|
215
|
+
|
|
157
216
|
### 2️⃣ Bash 命令安全(279 行)
|
|
158
217
|
阻止 LLM 执行危险 shell 命令:
|
|
159
|
-
|
|
160
218
|
| 类别 | 示例 | 严重性 |
|
|
161
219
|
|------|------|--------|
|
|
162
220
|
| 破坏性操作 | `rm -rf /`, `dd of=/dev/sda`, `mkfs` | 🚫 CRITICAL |
|
|
@@ -259,7 +317,7 @@ cc-node --api-base http://localhost:11434/v1
|
|
|
259
317
|
{
|
|
260
318
|
"model": "deepseek-chat",
|
|
261
319
|
"maxTurns": 100,
|
|
262
|
-
"maxBudgetTokens":
|
|
320
|
+
"maxBudgetTokens": 128000,
|
|
263
321
|
"permissionMode": "ask",
|
|
264
322
|
"tools": {
|
|
265
323
|
"bash": { "timeout": 120 },
|
|
@@ -272,6 +330,10 @@ cc-node --api-base http://localhost:11434/v1
|
|
|
272
330
|
}
|
|
273
331
|
```
|
|
274
332
|
|
|
333
|
+
> **`maxBudgetTokens`**:手动指定的上下文窗口上限(token 数)。
|
|
334
|
+
> 为 `0` 或未设置时,自动探测模型真实窗口;手动指定后优先于自动探测,
|
|
335
|
+
> 等价于 `/window N` 的效果,并持久化保存。
|
|
336
|
+
|
|
275
337
|
[](https://www.npmjs.com/package/@raolin2025/claude-code-node) [](https://github.com/bg1avd/claude-code-node) [](https://opensource.org/licenses/MIT)
|
|
276
338
|
---
|
|
277
339
|
|
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.3",
|
|
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",
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebFetch 安全管道 + Jina 兜底测试
|
|
3
|
+
* 覆盖:协议白名单、SSRF、重定向逐跳校验、脱敏、大小上限、Jina provider、WebFetch 兜底
|
|
4
|
+
*/
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import http from 'http'
|
|
8
|
+
import {
|
|
9
|
+
parseAndValidateUrl,
|
|
10
|
+
checkAddressBlocked,
|
|
11
|
+
safeFetchWithRedirects,
|
|
12
|
+
readBodyLimited,
|
|
13
|
+
} from '../security/fetch-guard.js'
|
|
14
|
+
import { redactSensitiveData } from '../security/redact.js'
|
|
15
|
+
import {
|
|
16
|
+
safeJinaFetch,
|
|
17
|
+
cleanJinaMarkdown,
|
|
18
|
+
extractTitleFromMarkdown,
|
|
19
|
+
resolveJinaApiKey,
|
|
20
|
+
} from '../tools/web-fetch-providers.js'
|
|
21
|
+
|
|
22
|
+
// ============ 协议白名单 ============
|
|
23
|
+
test('协议白名单:拒绝 file/ftp/data', () => {
|
|
24
|
+
assert.equal(parseAndValidateUrl('file:///etc/passwd').ok, false)
|
|
25
|
+
assert.equal(parseAndValidateUrl('ftp://example.com/x').ok, false)
|
|
26
|
+
assert.equal(parseAndValidateUrl('data:text/plain,hello').ok, false)
|
|
27
|
+
assert.equal(parseAndValidateUrl('javascript:alert(1)').ok, false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('协议白名单:允许 http/https', () => {
|
|
31
|
+
assert.equal(parseAndValidateUrl('https://example.com').ok, true)
|
|
32
|
+
assert.equal(parseAndValidateUrl('http://example.com').ok, true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('无效 URL 拒绝', () => {
|
|
36
|
+
assert.equal(parseAndValidateUrl('not a url').ok, false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// ============ SSRF 地址校验 ============
|
|
40
|
+
test('SSRF:私有/保留地址全部阻止', () => {
|
|
41
|
+
assert.equal(checkAddressBlocked('127.0.0.1').blocked, true)
|
|
42
|
+
assert.equal(checkAddressBlocked('10.0.0.1').blocked, true)
|
|
43
|
+
assert.equal(checkAddressBlocked('192.168.1.1').blocked, true)
|
|
44
|
+
assert.equal(checkAddressBlocked('172.16.0.1').blocked, true)
|
|
45
|
+
assert.equal(checkAddressBlocked('100.64.0.1').blocked, true)
|
|
46
|
+
assert.equal(checkAddressBlocked('169.254.169.254').blocked, true)
|
|
47
|
+
assert.equal(checkAddressBlocked('0.0.0.0').blocked, true)
|
|
48
|
+
assert.equal(checkAddressBlocked('::1').blocked, true)
|
|
49
|
+
assert.equal(checkAddressBlocked('fe80::1').blocked, true)
|
|
50
|
+
assert.equal(checkAddressBlocked('fd00::1').blocked, true)
|
|
51
|
+
assert.equal(checkAddressBlocked('::ffff:192.168.1.1').blocked, true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('SSRF:公网地址放行', () => {
|
|
55
|
+
assert.equal(checkAddressBlocked('8.8.8.8').blocked, false)
|
|
56
|
+
assert.equal(checkAddressBlocked('1.1.1.1').blocked, false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('SSRF:内网域名后缀阻止', () => {
|
|
60
|
+
assert.equal(parseAndValidateUrl('http://router.local/x').ok, false)
|
|
61
|
+
assert.equal(parseAndValidateUrl('http://svc.internal/x').ok, false)
|
|
62
|
+
assert.equal(parseAndValidateUrl('http://localhost:8080/').ok, false)
|
|
63
|
+
assert.equal(parseAndValidateUrl('http://db.home/x').ok, false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('SSRF:IP 字面量私网地址被 parseAndValidateUrl 拒绝', () => {
|
|
67
|
+
assert.equal(parseAndValidateUrl('http://127.0.0.1:8080/').ok, false)
|
|
68
|
+
assert.equal(parseAndValidateUrl('http://192.168.1.1/').ok, false)
|
|
69
|
+
assert.equal(parseAndValidateUrl('http://10.0.0.5/').ok, false)
|
|
70
|
+
assert.equal(parseAndValidateUrl('https://8.8.8.8/').ok, true)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// ============ 重定向逐跳校验 ============
|
|
74
|
+
test('重定向:起始 URL 为内网地址被 SSRF 阻止', async () => {
|
|
75
|
+
// 本地服务器跑在 127.0.0.1(回环,SSRF 目标),safeFetchWithRedirects 应在连接前拒绝
|
|
76
|
+
const server = http.createServer((req, res) => {
|
|
77
|
+
res.writeHead(200)
|
|
78
|
+
res.end('ok')
|
|
79
|
+
})
|
|
80
|
+
await new Promise(r => server.listen(0, r))
|
|
81
|
+
const port = server.address().port
|
|
82
|
+
const result = await safeFetchWithRedirects(`http://127.0.0.1:${port}/`, { timeoutMs: 2000 })
|
|
83
|
+
server.close()
|
|
84
|
+
assert.equal(result.ok, false)
|
|
85
|
+
assert.ok(result.error && result.error.includes('SSRF'), `应报 SSRF 错误,实际: ${result.error}`)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('重定向:目标跳转到内网被拦截(校验 next URL)', async () => {
|
|
89
|
+
// 用公网可访问的域名作为起始(绕过起始 SSRF),但通过 verifySafeRedirectTarget 验证目标校验。
|
|
90
|
+
// 直接验证"相对重定向 → 内网"的目标解析 + 校验逻辑:
|
|
91
|
+
// 起始 https://example.com/a → 302 Location: http://192.168.1.1/(绝对内网)
|
|
92
|
+
const v = parseAndValidateUrl('http://192.168.1.1/')
|
|
93
|
+
assert.equal(v.ok, false)
|
|
94
|
+
assert.ok(v.reason.includes('192.168.1.1'))
|
|
95
|
+
|
|
96
|
+
// 相对重定向目标解析:https://example.com/x + Location: //192.168.1.1 也是内网
|
|
97
|
+
const parsed = new URL('//192.168.1.1/', 'https://example.com')
|
|
98
|
+
assert.equal(parsed.hostname, '192.168.1.1')
|
|
99
|
+
const v2 = parseAndValidateUrl(parsed.href)
|
|
100
|
+
assert.equal(v2.ok, false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('重定向:相对路径解析到公网放行', () => {
|
|
104
|
+
const parsed = new URL('/final', 'https://example.com/start')
|
|
105
|
+
assert.equal(parsed.href, 'https://example.com/final')
|
|
106
|
+
assert.equal(parseAndValidateUrl(parsed.href).ok, true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// ============ 响应大小上限 ============
|
|
110
|
+
test('响应大小上限:超限截断', async () => {
|
|
111
|
+
const server = http.createServer((req, res) => {
|
|
112
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
113
|
+
res.end('x'.repeat(1000))
|
|
114
|
+
})
|
|
115
|
+
await new Promise(r => server.listen(0, r))
|
|
116
|
+
const port = server.address().port
|
|
117
|
+
// 无法用 127.0.0.1(SSRF 阻止),改用测试 readBodyLimited 直接逻辑
|
|
118
|
+
server.close()
|
|
119
|
+
|
|
120
|
+
// 直接测试 readBodyLimited
|
|
121
|
+
const chunks = { async *[Symbol.asyncIterator]() { yield Buffer.from('x'.repeat(100)); yield Buffer.from('y'.repeat(100)) } }
|
|
122
|
+
const fakeRes = { [Symbol.asyncIterator]: chunks[Symbol.asyncIterator].bind(chunks) }
|
|
123
|
+
const { body, truncated } = await readBodyLimited(fakeRes, 150)
|
|
124
|
+
assert.equal(truncated, true)
|
|
125
|
+
assert.equal(body.length, 150)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// ============ 敏感数据脱敏 ============
|
|
129
|
+
test('脱敏:OpenAI key 替换', () => {
|
|
130
|
+
const { text, redacted } = redactSensitiveData('key is sk-abcdefghijklmnopqrstuvwxyz1234567890')
|
|
131
|
+
assert.equal(text.includes('sk-abcdefghijklmnopqrstuvwxyz'), false)
|
|
132
|
+
assert.ok(text.includes('[REDACTED:OpenAI-Key]'))
|
|
133
|
+
assert.ok(redacted.includes('OpenAI-Key'))
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('脱敏:AWS key 替换', () => {
|
|
137
|
+
const { text, redacted } = redactSensitiveData('AKIAIOSFODNN7EXAMPLE')
|
|
138
|
+
assert.ok(text.includes('[REDACTED:AWS-Key]'))
|
|
139
|
+
assert.ok(redacted.includes('AWS-Key'))
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('脱敏:私钥整体替换', () => {
|
|
143
|
+
const { text } = redactSensitiveData('-----BEGIN RSA PRIVATE KEY-----\nabc123\n-----END RSA PRIVATE KEY-----')
|
|
144
|
+
assert.ok(!text.includes('abc123'))
|
|
145
|
+
assert.ok(text.includes('[REDACTED:Private-Key]'))
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('脱敏:Bearer token 替换', () => {
|
|
149
|
+
const { text } = redactSensitiveData('Authorization: Bearer 0123456789abcdef0123456789abcdef')
|
|
150
|
+
assert.ok(text.includes('[REDACTED:Bearer]'))
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('脱敏:无敏感内容不动', () => {
|
|
154
|
+
const { text, redacted } = redactSensitiveData('hello world, nothing sensitive here')
|
|
155
|
+
assert.equal(text, 'hello world, nothing sensitive here')
|
|
156
|
+
assert.equal(redacted.length, 0)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// ============ Jina provider ============
|
|
160
|
+
test('Jina markdown 清洗', () => {
|
|
161
|
+
const md = 'Title: Test Page\nURL Source: https://example.com\nMarkdown Content:\n\nHello **world**'
|
|
162
|
+
const cleaned = cleanJinaMarkdown(md)
|
|
163
|
+
assert.equal(cleaned, 'Hello **world**')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('Jina 标题提取', () => {
|
|
167
|
+
assert.equal(extractTitleFromMarkdown('Title: My Page\ncontent'), 'My Page')
|
|
168
|
+
assert.equal(extractTitleFromMarkdown('no title'), '')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('Jina key 解析优先级:环境变量 > 配置', () => {
|
|
172
|
+
process.env.JINA_API_KEY = 'env-key'
|
|
173
|
+
const fromEnv = resolveJinaApiKey({ get: () => 'config-key' })
|
|
174
|
+
assert.equal(fromEnv, 'env-key')
|
|
175
|
+
delete process.env.JINA_API_KEY
|
|
176
|
+
const fromCfg = resolveJinaApiKey({ get: () => 'config-key' })
|
|
177
|
+
assert.equal(fromCfg, 'config-key')
|
|
178
|
+
const anonymous = resolveJinaApiKey(null)
|
|
179
|
+
assert.equal(anonymous, '')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('safeJinaFetch:拒绝 file 协议', async () => {
|
|
183
|
+
const r = await safeJinaFetch('file:///etc/passwd')
|
|
184
|
+
assert.equal(r.ok, false)
|
|
185
|
+
assert.ok(r.error.includes('协议'))
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('safeJinaFetch:本地 mock 服务器(重定向到 Jina 不可行,验证网络错误处理)', async () => {
|
|
189
|
+
// 指向一个不存在的端口,验证错误路径
|
|
190
|
+
const r = await safeJinaFetch('http://127.0.0.1:1/', { timeoutMs: 500 })
|
|
191
|
+
// Jina 服务本身会 4xx(因为 r.jina.ai/127.0.0.1... 无法访问),或返回错误
|
|
192
|
+
assert.equal(typeof r.ok, 'boolean')
|
|
193
|
+
})
|
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({
|
|
@@ -490,6 +524,7 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
490
524
|
noStream: cliArgs.noStream,
|
|
491
525
|
costTracker,
|
|
492
526
|
tokenBudget,
|
|
527
|
+
configStore: config,
|
|
493
528
|
})
|
|
494
529
|
const engine = new QueryEngine(engineConfig)
|
|
495
530
|
|
|
@@ -684,10 +719,48 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
684
719
|
} else {
|
|
685
720
|
engine.config.model = rest.join(' ')
|
|
686
721
|
}
|
|
722
|
+
model = engine.config.model
|
|
687
723
|
console.log(`Model → ${engine.config.model}`)
|
|
724
|
+
// 切换模型后重新探测上下文窗口(仅当非手动指定时自动更新)
|
|
725
|
+
if ((config.get('maxBudgetTokens') || 0) <= 0) {
|
|
726
|
+
const { window: win, source } = await reapplyWindow()
|
|
727
|
+
console.log(` ↳ Context window → ${formatTokens(win)} (${windowSourceLabel(source)})`)
|
|
728
|
+
}
|
|
688
729
|
}
|
|
689
730
|
else console.log(`Model: ${engine.config.model}`)
|
|
690
731
|
break
|
|
732
|
+
case 'window': {
|
|
733
|
+
const arg = rest[0] ? rest.join(' ').trim() : ''
|
|
734
|
+
if (!arg) {
|
|
735
|
+
// 无参数:显示当前窗口
|
|
736
|
+
console.log(`Context window: ${formatTokens(tokenBudget.maxTokens)} (${windowSourceLabel(windowSource)})`)
|
|
737
|
+
console.log(` Input used: ${tokenBudget.inputTokens.toLocaleString()} / ${tokenBudget.maxTokens.toLocaleString()} (${tokenBudget.usagePercent}%)`)
|
|
738
|
+
const manual = config.get('maxBudgetTokens') || 0
|
|
739
|
+
if (manual > 0) console.log(` Manual override: ${formatTokens(manual)} (persisted in config)`)
|
|
740
|
+
break
|
|
741
|
+
}
|
|
742
|
+
const lower = arg.toLowerCase()
|
|
743
|
+
if (lower === 'auto' || lower === 'reset') {
|
|
744
|
+
// 清除手动指定并重新探测
|
|
745
|
+
config.set('maxBudgetTokens', 0)
|
|
746
|
+
try { await config.saveToUser() } catch {}
|
|
747
|
+
const { window: win, source } = await reapplyWindow()
|
|
748
|
+
console.log(`Cleared manual override. Context window → ${formatTokens(win)} (${windowSourceLabel(source)})`)
|
|
749
|
+
break
|
|
750
|
+
}
|
|
751
|
+
const win = parseWindowArg(arg)
|
|
752
|
+
if (!win) {
|
|
753
|
+
console.log(`❌ Invalid window: "${arg}". Use a number or K/M suffix, e.g. /window 128k, /window 64k, /window 1m`)
|
|
754
|
+
break
|
|
755
|
+
}
|
|
756
|
+
// 手动指定:立即生效 + 持久化
|
|
757
|
+
tokenBudget.setWindow(win, WINDOW_SOURCE.MANUAL)
|
|
758
|
+
windowSource = WINDOW_SOURCE.MANUAL
|
|
759
|
+
config.set('maxBudgetTokens', win)
|
|
760
|
+
try { await config.saveToUser() } catch (e) { console.log(`⚠️ Failed to persist to config: ${e.message}`) }
|
|
761
|
+
console.log(`Context window → ${formatTokens(win)} (${windowSourceLabel(WINDOW_SOURCE.MANUAL)}, persisted)`)
|
|
762
|
+
break
|
|
763
|
+
}
|
|
691
764
|
case 'models': {
|
|
692
765
|
const apiBase = engine.config.apiBase
|
|
693
766
|
const apiKey = engine.config.apiKey
|
|
@@ -818,7 +891,11 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
818
891
|
console.log(JSON.stringify(config.toJSON(), null, 2))
|
|
819
892
|
}
|
|
820
893
|
break
|
|
821
|
-
case 'budget':
|
|
894
|
+
case 'budget': {
|
|
895
|
+
console.log(tokenBudget.format())
|
|
896
|
+
console.log(`Context window: ${formatTokens(tokenBudget.maxTokens)} (${windowSourceLabel(windowSource)}) | trigger: ${Math.round((tokenBudget.maxTokens * 0.8)).toLocaleString()} (80%)`)
|
|
897
|
+
break
|
|
898
|
+
}
|
|
822
899
|
case 'channel': {
|
|
823
900
|
const subCmd = rest.join(' ')
|
|
824
901
|
if (subCmd === 'list' || subCmd === '') {
|
package/src/core/config.js
CHANGED
|
@@ -26,6 +26,16 @@ const DEFAULTS = {
|
|
|
26
26
|
fileRead: { maxLines: 2000, maxSizeKB: 256 },
|
|
27
27
|
webFetch: { timeout: 30, maxChars: 100000 },
|
|
28
28
|
},
|
|
29
|
+
web: {
|
|
30
|
+
fetch: {
|
|
31
|
+
maxChars: 100000,
|
|
32
|
+
maxBytes: 10485760, // 10MB
|
|
33
|
+
timeoutMs: 30000,
|
|
34
|
+
maxRedirects: 5,
|
|
35
|
+
fallbackProvider: 'safe-jina', // 直连失败时的兜底 provider
|
|
36
|
+
jinaApiKey: '', // 可选(匿名约 20 RPM)
|
|
37
|
+
},
|
|
38
|
+
},
|
|
29
39
|
channels: {},
|
|
30
40
|
defaultChannel: null,
|
|
31
41
|
qqbot: {
|