@vincentbanana/bananacli 0.0.0-stage → 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BananaCLI contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,317 @@
1
- # Temporary Holding Version
1
+ # 🍌 BananaCLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 终端里的多 provider AI agent:能读写文件、跑命令、搜代码、上网查资料,多轮对话自动保存并随时切换。
4
+
5
+ ```
6
+ ____ _
7
+ | __ ) __ _ _ __ __ _| |__ __ _ _ __
8
+ | _ \ / _` | '_ \ / _` | '_ \ / _` | '_ \
9
+ | |_) | (_| | | | | (_| | |_) | (_| | | | |
10
+ |____/ \__,_|_| |_|\__,_|_.__/ \__,_|_| |_|
11
+
12
+ BananaCLI v0.1.0 · 终端里的多 provider agent
13
+
14
+ 模型 deepseek/deepseek-chat
15
+ 工作区 D:\BananaCLI
16
+ 工具 11 个 · 权限 shell=ask write=ask network=allow
17
+ 会话 修一下配置向导 · 20261017-203512-ab12
18
+
19
+ 输入文字开始对话 · /help 查看命令 · /exit 退出 · Ctrl+C 中断请求
20
+
21
+ 🍌 修一下配置向导 [deepseek/deepseek-chat] ›
22
+ ```
23
+
24
+ - **零运行时依赖**:只用 Node 内置模块;源码可直接 `node src/index.ts` 跑,发布版编译到 `dist/`。
25
+ - **手写 agent 循环**:tool-calling 循环、工具注册、权限控制、上下文压缩都在仓库里,可读可改。
26
+ - **多 provider**:OpenAI 兼容协议(DeepSeek / Moonshot / Qwen / Gemini / OpenRouter / Ollama …)和 Anthropic 原生协议都支持,SSE 流式输出。
27
+ - **会话持久化**:每轮自动落盘到 `~/.banana/sessions/`,支持 `/list` `/use` 切换历史对话。
28
+ - **不脆弱**:429/5xx 自动退避重试、上游报上下文超限时自动压缩后重试、网络重试可见。
29
+ - **推理模型友好**:DeepSeek-R1 的 `reasoning_content` 和 Claude extended thinking 都能流式展示。
30
+ - **任务清单**:内置 `todo_write` / `todo_read`,多步骤任务有进度可查,清单随会话保存。
31
+ - **改前先看 diff、改后能回滚**:写文件前展示具体增删行,`/undo` 还原上一轮所有文件改动。
32
+ - **多模态与扩展**:`/image` 附加截图,`.banana/tools/` 放脚本即可注册自定义工具,成本按 token 估算。
33
+
34
+ ## 平台支持
35
+
36
+ | 平台 | 状态 | 说明 |
37
+ | --- | --- | --- |
38
+ | Windows 10/11 | ✅ | PowerShell / Windows Terminal;`shell` 工具走 `cmd.exe`,超时用 `taskkill /T` 杀进程树 |
39
+ | macOS 12+ | ✅ | `shell` 走 `/bin/sh`,超时按进程组杀 |
40
+ | Linux (glibc/musl) | ✅ | 同上;纯 Node 实现,没有原生依赖 |
41
+ | WSL | ✅ | 装在 WSL 里就是 Linux 行为(推荐,脚本兼容性最好) |
42
+
43
+ 要求:**Node.js >= 20**(推荐 22 LTS 或 24)。`npm install -g @vincentbanana/bananacli` 会自动创建 `banana` 命令(Windows 生成 `.cmd` shim,Unix 生成可执行软链)。
44
+
45
+ 平台差异只在这几处,都已处理:
46
+
47
+ - 路径全部用 `node:path`,工作区沙箱按真实路径校验(软链/junction 也不例外)
48
+ - `shell` 命令在 Windows 用 `cmd.exe`、Unix 用 `/bin/sh`;中断/超时都会清理子进程
49
+ - 配置文件在 `~/.banana/`(Windows 是 `C:\Users\<你>\.banana`)
50
+ - **CRLF 兼容**:Windows 仓库常见的 `\r\n` 文件,模型用 `\n` 写 `old_string` 也能匹配,替换后保持原文件行尾
51
+ - 老版 conhost 如果 emoji 显示成方块,`chcp 65001` 或改用 Windows Terminal;不影响功能
52
+
53
+ CI 在 Ubuntu / macOS / Windows × Node 20 / 22 / 24 上跑类型检查、全量测试、打包、从 tarball 全局安装并执行 `banana --version`(见 `.github/workflows/ci.yml`)。
54
+
55
+ ## 快速开始
56
+
57
+ ```bash
58
+ # 安装(包名是 @vincentbanana/bananacli,命令是 banana)
59
+ npm install -g @vincentbanana/bananacli
60
+
61
+ # 第一次启动会进入配置向导(选服务、贴 key、测连接)
62
+ banana
63
+
64
+ # 也可以提前用环境变量给 key,跳过向导
65
+ $env:DEEPSEEK_API_KEY = "sk-..." # PowerShell
66
+ export ANTHROPIC_API_KEY="sk-ant-..." # macOS / Linux
67
+
68
+ # 交互式 / 单轮执行
69
+ banana
70
+ banana "看下 src/index.ts 是干什么的"
71
+ banana -c # 继续上次对话
72
+ ```
73
+
74
+ 不想全局安装可以用 `npx @vincentbanana/bananacli`。
75
+
76
+ 从源码开发:
77
+
78
+ ```bash
79
+ npm install # 会触发 prepare -> 构建 dist
80
+ npm start # 等价于 node src/index.ts
81
+ npm test
82
+ npm run build
83
+ ```
84
+
85
+ ## 配置向导
86
+
87
+ 第一次启动(无 `~/.banana/config.json` 且检测不到 key)会自动进入向导,也可以用 `banana --setup` 随时重跑:
88
+
89
+ 1. **选服务**:内置 OpenAI / Anthropic / DeepSeek / Moonshot / Qwen / Gemini / OpenRouter / Ollama,或选「自定义」接自建网关、vLLM、LiteLLM。列表里会标出每个服务是「环境变量已就绪」「已保存 key」还是「未配置」。
90
+ 2. **填连接信息**:确认或修改 Base URL、模型名。
91
+ 3. **填 API key**:输入时显示为 `*`,不会留在终端历史里;已有环境变量或已保存的 key 可以直接沿用。
92
+ 4. **选权限**:shell 命令、写文件是否每次都要确认(默认都要)。
93
+ 5. **测连接**:真实发一次最小请求;失败会显示上游错误并允许重试,或仍然保存。
94
+ 6. 配置写入 `~/.banana/config.json`(POSIX 上尽量设为 `0600`),然后直接进入对话。
95
+
96
+ > key 默认以明文存在配置文件里。更稳妥的做法是:向导里选择沿用环境变量(`DEEPSEEK_API_KEY` 等),配置文件里就只留 `apiKeyEnv` 引用。
97
+
98
+ 非交互环境也能配置:把答案按顺序逐行喂给 stdin。
99
+
100
+ ```bash
101
+ printf '3\nn\nsk-xxxx\nn\nn\n' | banana --setup
102
+ ```
103
+
104
+ 顺序是:provider 序号 → 是否改 Base URL/模型 → API key(本地服务会先问要不要设)→ shell 是否确认 → 写文件是否确认。自定义服务的顺序是:`序号(最后一项)` → 名字 → 协议(1/2) → Base URL → 模型名 → 是否再修改 → key → 两个权限问题。
105
+
106
+ ## 命令行
107
+
108
+ ```
109
+ banana 进入交互式多轮对话
110
+ banana "帮我跑一下测试" 单轮任务模式(不进入对话)
111
+
112
+ -p, --provider <名称> openai / anthropic / deepseek / moonshot / qwen / gemini / openrouter / ollama
113
+ -m, --model <模型名> 覆盖模型名
114
+ --base-url <url> 覆盖 base URL(接自建网关、vLLM、LiteLLM 等)
115
+ --api-key <key> 直接给 key(不推荐,优先用环境变量)
116
+ -s, --session <ref> 载入指定对话(序号 / id 前缀 / 标题关键词)
117
+ -c, --continue 继续上一次对话
118
+ --list 列出历史对话后退出
119
+ --cwd <目录> 指定工作区(agent 能访问的目录范围)
120
+ --allow-outside 允许工具访问工作区之外的路径
121
+ --yolo 跳过所有确认
122
+ --system <文本> 覆盖系统提示词
123
+ --no-tools 不给 agent 任何工具(纯对话)
124
+ --no-reasoning 不显示推理模型的思考过程
125
+ --setup 运行/重跑配置向导
126
+ ```
127
+
128
+ ## 交互命令
129
+
130
+ | 命令 | 说明 |
131
+ | --- | --- |
132
+ | `/new [标题]` | 开新对话(当前对话已自动保存) |
133
+ | `/list` | 历史对话列表 |
134
+ | `/use <序号\|id前缀\|标题>` | 切换对话 |
135
+ | `/rename` `/delete` `/clear` | 改名 / 删除 / 清空上下文 |
136
+ | `/undo` | 回滚上一轮的文件修改(恢复/删除) |
137
+ | `/session` `/history` | 当前对话信息 / 消息列表 |
138
+ | `/provider [名]` `/model [名]` | 切换供应商 / 模型 |
139
+ | `/setup` | 会话内重新配置 provider / 模型 / key |
140
+ | `/image <路径\|clear>` | 给下一条消息附加图片(png/jpg/gif/webp) |
141
+ | `/tools [名\|all]` | 查看或启停工具(含插件工具) |
142
+ | `/perm <shell\|write\|network> <ask\|allow\|deny>` | 权限策略 |
143
+ | `/compact` | 手动压缩上下文 |
144
+ | `/cwd [路径]` | 查看/切换工作目录 |
145
+ | `/system [文本]` | 查看/替换系统提示词 |
146
+ | `/cls` `/exit` | 清屏 / 保存退出 |
147
+
148
+ 快捷键:`Ctrl+C` 中断当前请求(连按两次退出),`↑` 翻历史输入,输入末尾加 `\` 可以续行,`@` 后按 `Tab` 补全项目文件。
149
+
150
+ ## 安全网:diff 预览与撤销
151
+
152
+ - 写文件 / 改文件需要确认时,会先显示 `+新增 / -删除` 的具体行(最多 40 行,中文按两列宽度截断)
153
+ - 每次工具写入前,旧内容会快照到 `~/.banana/backups/<sessionId>/`,每轮一条记录(最多保留 30 轮)
154
+ - `/undo` 回滚最近一轮:被改的文件恢复原内容,新建的文件直接删除
155
+ - 对话记录不会回滚;`shell` 命令和插件工具的副作用也不在撤销范围内(无法可靠追踪)
156
+
157
+ ## 图片输入
158
+
159
+ ```text
160
+ /image screenshot.png
161
+ 看看这个报错
162
+ ```
163
+
164
+ 图片会编码成 data URL 随下一条消息发送,OpenAI 兼容协议和 Anthropic 协议都支持;单张上限 4MB,支持 png / jpg / gif / webp。`/image` 不带参数查看待发送列表,`/image clear` 清空。
165
+
166
+ ## 插件工具
167
+
168
+ 在 `~/.banana/tools/` 或 `<项目>/.banana/tools/` 放一个 `.mjs`(也支持 `.js` / `.ts`),默认导出工具定义即可,启动时自动加载:
169
+
170
+ ```js
171
+ // ~/.banana/tools/http_status.mjs
172
+ export default {
173
+ name: 'http_status',
174
+ description: '检查一个 URL 的 HTTP 状态码',
175
+ parameters: {
176
+ type: 'object',
177
+ properties: { url: { type: 'string', description: '目标地址' } },
178
+ required: ['url'],
179
+ additionalProperties: false,
180
+ },
181
+ approval: 'network', // never | write | shell | network
182
+ async run(input) {
183
+ const response = await fetch(String(input.url), { method: 'HEAD' });
184
+ return { ok: response.ok, content: `${input.url} -> ${response.status}` };
185
+ },
186
+ };
187
+ ```
188
+
189
+ 名称冲突、缺少 `run`、字段不合法都会在启动时给出提示并跳过该文件。插件以当前 Node 进程权限运行,等同于你自己写的代码,只放可信的脚本。
190
+
191
+ ## 成本估算
192
+
193
+ 按内置价目表(美元 / 百万 token)估算每轮和整会话花费,未知模型不显示。价格会变,可在 provider 配置里覆盖:
194
+
195
+ ```json
196
+ { "pricing": { "input": 0.27, "output": 1.1 } }
197
+ ```
198
+
199
+ ## 界面细节
200
+
201
+ - 启动头部显示当前模型、工作区、工具数与权限组合、会话 id
202
+ - 等待模型响应时有 `⠋ 等待 … 3.2s` 状态行,首个 token 到达即擦除
203
+ - 工具调用显示为 `⏺ shell command="…"`,成功 `✓ 摘要`、失败 `✗ 错误首行`(最多三行缩进展示)
204
+ - 需要确认的操作标注类别(执行命令 / 写文件 / 访问网络),命令太长时自动换行展示
205
+ - 中文按两列宽计算,`/help`、`/tools`、`/list` 的表格在中文环境下也对齐;命令拼错会提示最接近的命令(`/providr` → `/provider`)
206
+
207
+ ## 工具
208
+
209
+ | 工具 | 权限类别 | 说明 |
210
+ | --- | --- | --- |
211
+ | `read_file` | – | 读文件,带行号,支持 offset/limit 分段 |
212
+ | `write_file` | write | 新建/覆盖文件 |
213
+ | `edit_file` | write | 精确字符串替换,要求唯一匹配,支持 `replace_all` |
214
+ | `list_dir` | – | 列目录,自动忽略 `node_modules` 等 |
215
+ | `glob` | – | `**/*.ts`、`{a,b}` 风格 glob |
216
+ | `grep` | – | 正则搜代码,返回 `file:line` |
217
+ | `shell` | shell | 执行命令,回传 stdout/stderr/退出码,带超时与输出截断 |
218
+ | `web_search` | network | 优先 Tavily / Brave,回退 DuckDuckGo |
219
+ | `web_fetch` | network | 抓 URL 转纯文本(HTML 去标签去脚本) |
220
+ | `todo_write` | – | 写入/更新任务清单(整表覆盖,随会话保存) |
221
+ | `todo_read` | – | 读取当前任务清单 |
222
+ | 插件工具 | 自定义 | `.banana/tools/` 下的脚本自动注册,权限类别由插件声明 |
223
+
224
+ 路径默认被限制在工作区内,越界会拒绝并告诉模型原因;符号链接也会按其真实位置校验,不能借软链逃出工作区。`--allow-outside` 或配置 `allowOutsideWorkspace` 可放开。
225
+
226
+ ## 配置
227
+
228
+ 全局 `~/.banana/config.json`,项目级 `<工作区>/.banana/config.json` 覆盖全局:
229
+
230
+ ```json
231
+ {
232
+ "current": "deepseek",
233
+ "providers": {
234
+ "deepseek": { "protocol": "openai", "baseUrl": "https://api.deepseek.com/v1", "model": "deepseek-chat", "apiKeyEnv": "DEEPSEEK_API_KEY" },
235
+ "my-gateway": { "protocol": "openai", "baseUrl": "https://llm.internal/v1", "model": "qwen2.5-coder", "apiKeyEnv": "INTERNAL_KEY", "pricing": { "input": 0.4, "output": 1.2 } }
236
+ },
237
+ "permissions": { "shell": "ask", "write": "ask", "network": "allow" },
238
+ "maxSteps": 40,
239
+ "maxContextChars": 160000,
240
+ "allowOutsideWorkspace": false,
241
+ "showReasoning": true
242
+ }
243
+ ```
244
+
245
+ Anthropic 协议默认给 `system` 和工具定义打 `cache_control: ephemeral`(prompt caching,长对话能省不少钱);如果你的网关不认这个字段,在对应 provider 里设 `"promptCaching": false`。
246
+
247
+ 环境变量:`ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `DEEPSEEK_API_KEY` / `MOONSHOT_API_KEY` / `DASHSCOPE_API_KEY` / `GEMINI_API_KEY` / `OPENROUTER_API_KEY`;搜索可选 `TAVILY_API_KEY` 或 `BRAVE_API_KEY`;临时覆盖用 `BANANA_PROVIDER` / `BANANA_MODEL` / `BANANA_BASE_URL` / `BANANA_SHOW_REASONING` / `BANANA_HOME` / `BANANA_CONFIG`。
248
+
249
+ `protocol` 只有两个值:`openai`(OpenAI 兼容)和 `anthropic`(Claude 原生)。绝大多数第三方服务都能用 `openai` 协议接进来。
250
+
251
+ ## 代码结构
252
+
253
+ ```
254
+ src/
255
+ index.ts 入口:参数解析、装配
256
+ config/config.ts 配置加载/合并、环境变量覆盖、系统提示词
257
+ providers/
258
+ types.ts 统一消息格式(UMessage / ToolCall / ToolSchema / 图片)
259
+ http.ts fetch 重试退避、上下文超限/stream_options 错误识别
260
+ pricing.ts 内置价目表与成本估算
261
+ openai.ts OpenAI 兼容协议 + SSE 流式 + tool_calls 增量拼接 + reasoning + 图片
262
+ anthropic.ts Anthropic Messages API + tool_use / tool_result 角色归并 + prompt caching + 图片
263
+ agent/
264
+ loop.ts agent 主循环:请求 → 工具 → 回灌 → 再请求;断流补偿与超限自愈
265
+ context.ts token 估算与历史压缩(保留首条用户消息 + 最近若干轮,不切断工具配对)
266
+ tools/
267
+ fs.ts shell.ts web.ts todo.ts 文件 / 命令 / 网络 / 任务清单工具
268
+ plugins.ts .banana/tools/ 下的插件加载与校验
269
+ index.ts 工具注册表、权限判定、执行入口
270
+ util/diff.ts 零依赖行级 diff(LCS + 大文件降级)
271
+ session/
272
+ store.ts 会话落盘、列表、按 id/序号/标题查找
273
+ backups.ts 每轮文件快照与 /undo
274
+ cli/
275
+ repl.ts 交互循环、流式渲染、diff 确认、Ctrl+C 中断、历史输入
276
+ commands.ts slash 命令实现
277
+ completion.ts @文件 与 slash 命令补全
278
+ setup.ts 配置向导(终端 raw mode / 可注入 IO)
279
+ ui.ts 终端输出、颜色、双宽对齐
280
+ test/
281
+ mock-provider.mjs 内置 mock 模型服务(两种协议 + 故障注入)
282
+ prompt.test.mjs 掩码输入/退格/粘贴/Ctrl+C 的按键级单元测试
283
+ ui.test.mjs 双宽字符对齐、banner、启动头部、命令纠错
284
+ p0.test.mjs 压缩配对、符号链接沙箱、失败退出码
285
+ features.test.mjs diff/预览/shell 流式/补全/向导/撤销/成本/插件
286
+ smoke.mjs 冒烟测试:不需要真实 API key
287
+ ```
288
+
289
+ ## 开发
290
+
291
+ ```bash
292
+ npm run typecheck # tsc --noEmit
293
+ npm test # 123 项断言,全部基于内置 mock provider
294
+ npm run dev # node --watch
295
+ npm run build # 编译到 dist/(发布用)
296
+ ```
297
+
298
+ 发布(包名 `@vincentbanana/bananacli`,`prepublishOnly` 会自动 typecheck + 测试 + 构建;scope 包需要 `--access public`):
299
+
300
+ ```bash
301
+ npm login
302
+ npm publish # 2FA 账号加 --otp=123456
303
+ npm version patch # 之后发新版本
304
+ npm publish
305
+ ```
306
+
307
+ 覆盖范围:OpenAI / Anthropic 两条协议的工具调用、一轮多个工具、未知工具、工作区越界与软链拦截、会话落盘与 `-c` 续接、stdin 脚本化输入、`ask`/`deny` 权限、todo 清单落盘、429 退避重试、上下文超限自愈、压缩不切断工具配对、推理内容、Anthropic prompt caching、配置向导(含注入 IO)、掩码输入按键、中文双宽对齐、命令纠错、diff 预览、shell 实时输出与超时、`@` 补全、`/undo`、成本估算、图片输入、插件加载与调用。
308
+
309
+ ## 已知边界
310
+
311
+ - 模型名和 provider baseUrl 是内置默认值,不同服务可能需要 `/model` 或配置改一下。
312
+ - 上下文压缩按字符数估算 token,超长会话建议调小 `maxContextChars`。
313
+ - `web_search` 在没有 Tavily/Brave key 时走 DuckDuckGo HTML 抓取,无外网环境下会失败(会明确报错,不会假装成功)。
314
+ - 图片以 data URL 存进会话 JSON,多张大图会让会话文件变大(模型侧仍受单张 4MB 限制)。
315
+ - `/undo` 只覆盖 `write_file` / `edit_file`;`shell` 命令和插件工具的副作用无法回滚。
316
+ - 插件以当前进程权限运行,等同用户自己的代码,只应放可信脚本。
317
+ - 成本估算用内置价目表,实际账单以服务商为准。
@@ -0,0 +1,99 @@
1
+ const CHARS_PER_TOKEN = 3.2;
2
+ /** 粗略估算 token 数:中文按 ~1.5 字符/token,代码/英文更省,所以用偏保守的系数。 */
3
+ export function estimateTokens(messages) {
4
+ let chars = 0;
5
+ for (const message of messages) {
6
+ chars += message.content.length;
7
+ for (const call of message.toolCalls ?? []) {
8
+ chars += call.name.length + JSON.stringify(call.input ?? {}).length;
9
+ }
10
+ }
11
+ return Math.ceil(chars / CHARS_PER_TOKEN);
12
+ }
13
+ export function estimateChars(messages) {
14
+ let chars = 0;
15
+ for (const message of messages) {
16
+ chars += message.content.length + 8;
17
+ for (const call of message.toolCalls ?? []) {
18
+ chars += call.name.length + JSON.stringify(call.input ?? {}).length + 8;
19
+ }
20
+ }
21
+ return chars;
22
+ }
23
+ const SUMMARY_INSTRUCTION = [
24
+ '请把下面这段 AI 编程助手与用户的对话压缩成一份交接摘要,供对话继续进行时使用。',
25
+ '必须保留:用户的原始目标、已经做出的决定、已修改/新建的文件及其改动要点、关键代码事实(函数名/路径/接口)、工具报错与卡点、下一步待办。',
26
+ '不要保留无用的寒暄和重复内容。只输出摘要正文,用 Markdown,分「目标 / 进展 / 关键事实 / 下一步」四段。',
27
+ ].join('\n');
28
+ /**
29
+ * 上下文超限时把中间的历史压成一段摘要,保留第一条用户消息和最近若干条消息。
30
+ * 失败时返回 null,调用方应继续带着原上下文跑(可能只是这次请求会失败)。
31
+ */
32
+ export async function compactMessages(options) {
33
+ const { provider, messages, keepRecentChars = 60_000, maxTailMessages = 8, signal, } = options;
34
+ if (messages.length < 4)
35
+ return null;
36
+ const firstUserIndex = messages.findIndex((message) => message.role === 'user');
37
+ if (firstUserIndex === -1)
38
+ return null;
39
+ const firstUser = messages[firstUserIndex];
40
+ const head = messages.slice(0, firstUserIndex + 1);
41
+ // 从尾部往前保留,直到用完字符预算或达到条数上限
42
+ let boundary = messages.length;
43
+ let used = 0;
44
+ for (let index = messages.length - 1; index > firstUserIndex; index -= 1) {
45
+ const size = estimateChars([messages[index]]);
46
+ const keptCount = messages.length - index;
47
+ if ((used + size > keepRecentChars && keptCount > 2) || keptCount > maxTailMessages) {
48
+ boundary = index + 1;
49
+ break;
50
+ }
51
+ used += size;
52
+ boundary = index;
53
+ }
54
+ // 关键:切点不能落在 tool 结果上,否则 tail 会以孤儿 tool_result 开头,
55
+ // Anthropic 会直接 400(tool_use_id 找不到),部分 OpenAI 兼容服务也会报错。
56
+ // 往前退到发起调用的 assistant 消息,保证 tool_use / tool_result 成对保留。
57
+ while (boundary > firstUserIndex + 1 && messages[boundary]?.role === 'tool') {
58
+ boundary -= 1;
59
+ }
60
+ if (boundary <= firstUserIndex + 1)
61
+ return null;
62
+ const middle = messages.slice(firstUserIndex + 1, boundary);
63
+ const tail = messages.slice(boundary);
64
+ const transcript = middle
65
+ .map((message) => {
66
+ const label = message.role === 'assistant' ? '助手' : message.role === 'tool' ? `工具(${message.name ?? ''})` : '用户';
67
+ const calls = message.toolCalls?.map((call) => `[调用工具 ${call.name}]`).join(' ') ?? '';
68
+ return `### ${label}\n${message.content}\n${calls}`.trim();
69
+ })
70
+ .join('\n\n');
71
+ try {
72
+ const result = await provider.generate({
73
+ messages: [
74
+ { role: 'user', content: `${SUMMARY_INSTRUCTION}\n\n<对话记录>\n${transcript}\n</对话记录>` },
75
+ ],
76
+ system: '你在压缩对话上下文,输出必须简洁、结构化、只保留事实。',
77
+ tools: [],
78
+ signal,
79
+ });
80
+ const summary = result.message.content.trim();
81
+ if (!summary)
82
+ return null;
83
+ return {
84
+ messages: [
85
+ ...head,
86
+ {
87
+ role: 'user',
88
+ content: `[上下文已自动压缩] 下面是较早对话的摘要,请基于它继续工作:\n\n${summary}`,
89
+ },
90
+ ...tail,
91
+ ],
92
+ summary,
93
+ removed: middle.length,
94
+ };
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
@@ -0,0 +1,161 @@
1
+ import { isContextOverflowError } from "../providers/http.js";
2
+ import { permissionFor, runTool, toolMap, toolSchemas, } from "../tools/index.js";
3
+ import { compactMessages, estimateChars, estimateTokens } from "./context.js";
4
+ function mergeUsage(total, next) {
5
+ return {
6
+ inputTokens: (total.inputTokens ?? 0) + (next.inputTokens ?? 0) || undefined,
7
+ outputTokens: (total.outputTokens ?? 0) + (next.outputTokens ?? 0) || undefined,
8
+ };
9
+ }
10
+ const MAX_TOOL_MESSAGE_CHARS = 6000;
11
+ /**
12
+ * 历史太短压不动、但上下文已经超限时,先把超长的工具输出截断。
13
+ * 这是无损信息里最划算的一刀:大文件读取/命令输出通常才是占地方的元凶。
14
+ */
15
+ export function truncateToolOutputs(messages, maxChars = MAX_TOOL_MESSAGE_CHARS) {
16
+ let trimmed = 0;
17
+ for (const message of messages) {
18
+ if (message.role !== 'tool')
19
+ continue;
20
+ if (message.content.length <= maxChars)
21
+ continue;
22
+ const head = Math.floor(maxChars * 0.75);
23
+ const tail = maxChars - head;
24
+ message.content = `${message.content.slice(0, head)}\n...[历史工具输出已截断,原长 ${message.content.length} 字符]...\n${message.content.slice(-tail)}`;
25
+ trimmed += 1;
26
+ }
27
+ return trimmed;
28
+ }
29
+ /**
30
+ * 跑完一整轮:请求 -> 工具调用 -> 回灌结果 -> 再请求,直到模型不再要工具。
31
+ * messages 不会被原地修改,返回的是新数组,方便会话持久化。
32
+ */
33
+ export async function runTurn(deps, messages, input, images) {
34
+ const tools = toolMap(deps.tools);
35
+ const schemas = toolSchemas(deps.tools);
36
+ const convo = [...messages];
37
+ if (input) {
38
+ convo.push({
39
+ role: 'user',
40
+ content: input,
41
+ ...(images?.length ? { images } : {}),
42
+ });
43
+ }
44
+ const usage = {};
45
+ const todos = deps.todos ?? [];
46
+ let steps = 0;
47
+ let compacted = false;
48
+ let overflowRecovered = false;
49
+ const toolContext = {
50
+ workspace: deps.toolsWorkspace,
51
+ signal: deps.signal,
52
+ approve: deps.approve,
53
+ log: deps.onNotice ?? (() => { }),
54
+ allowOutsideWorkspace: deps.allowOutsideWorkspace,
55
+ permissions: deps.permissions,
56
+ todos,
57
+ onOutput: deps.onToolOutput,
58
+ recordChange: deps.recordChange,
59
+ };
60
+ const tryCompact = async (aggressive) => {
61
+ deps.onNotice?.(aggressive ? '上游报告上下文超限,正在压缩历史对话后重试…' : '上下文接近上限,正在压缩历史对话…');
62
+ const result = await compactMessages({
63
+ provider: deps.provider,
64
+ messages: convo,
65
+ keepRecentChars: Math.max(200, Math.floor(deps.maxContextChars * (aggressive ? 0.25 : 0.6))),
66
+ maxTailMessages: aggressive ? 2 : 8,
67
+ signal: deps.signal,
68
+ });
69
+ if (!result) {
70
+ const trimmed = truncateToolOutputs(convo);
71
+ if (trimmed > 0)
72
+ deps.onNotice?.(`历史太短,已截断 ${trimmed} 条超长工具输出。`);
73
+ return trimmed > 0;
74
+ }
75
+ const before = estimateTokens(convo);
76
+ convo.length = 0;
77
+ convo.push(...result.messages);
78
+ compacted = true;
79
+ deps.onCompact?.({ removed: result.removed, before, after: estimateTokens(convo) });
80
+ return true;
81
+ };
82
+ while (steps < deps.maxSteps) {
83
+ if (deps.signal.aborted)
84
+ return { messages: convo, steps, usage, stopReason: 'aborted', compacted };
85
+ if (estimateChars(convo) > deps.maxContextChars) {
86
+ const done = await tryCompact(false);
87
+ if (!done)
88
+ deps.onNotice?.('暂时压不动(历史太短),继续用当前上下文尝试。');
89
+ }
90
+ let result;
91
+ try {
92
+ deps.onStep?.(steps + 1);
93
+ result = await deps.provider.generate({
94
+ messages: convo,
95
+ system: deps.system,
96
+ tools: schemas,
97
+ signal: deps.signal,
98
+ onText: deps.onText,
99
+ onReasoning: deps.onReasoning,
100
+ onRetry: deps.onRetry,
101
+ });
102
+ }
103
+ catch (error) {
104
+ if (deps.signal.aborted)
105
+ return { messages: convo, steps, usage, stopReason: 'aborted', compacted };
106
+ const message = error instanceof Error ? error.message : String(error);
107
+ // 上下文超限:压缩后原地重试一次,不算作一步
108
+ if (!overflowRecovered && isContextOverflowError(message)) {
109
+ overflowRecovered = true;
110
+ if (await tryCompact(true))
111
+ continue;
112
+ }
113
+ return { messages: convo, steps, usage, stopReason: 'error', error: message, compacted };
114
+ }
115
+ steps += 1;
116
+ Object.assign(usage, mergeUsage(usage, result.usage));
117
+ convo.push(result.message);
118
+ const calls = result.message.toolCalls ?? [];
119
+ if (calls.length === 0) {
120
+ return { messages: convo, steps, usage, stopReason: 'done', compacted };
121
+ }
122
+ for (let index = 0; index < calls.length; index += 1) {
123
+ const call = calls[index];
124
+ if (deps.signal.aborted) {
125
+ // 为所有未执行的 tool_use 补上结果,避免下一轮协议校验失败
126
+ for (const pending of calls.slice(index)) {
127
+ convo.push({
128
+ role: 'tool',
129
+ toolCallId: pending.id,
130
+ name: pending.name,
131
+ content: '操作被用户中断。',
132
+ isError: true,
133
+ });
134
+ }
135
+ return { messages: convo, steps, usage, stopReason: 'aborted', compacted };
136
+ }
137
+ const input = (call.input && typeof call.input === 'object' ? call.input : {});
138
+ const tool = tools.get(call.name);
139
+ if (!tool) {
140
+ const result = {
141
+ ok: false,
142
+ content: `未知工具 "${call.name}"。可用工具: ${[...tools.keys()].join(', ')}。请改用已有工具。`,
143
+ };
144
+ deps.onToolEnd?.(call.name, result);
145
+ convo.push({ role: 'tool', toolCallId: call.id, name: call.name, content: result.content, isError: true });
146
+ continue;
147
+ }
148
+ deps.onToolStart?.(call.name, input);
149
+ const toolResult = await runTool(tool, input, toolContext, permissionFor(deps.permissions, tool));
150
+ deps.onToolEnd?.(call.name, toolResult);
151
+ convo.push({
152
+ role: 'tool',
153
+ toolCallId: call.id,
154
+ name: call.name,
155
+ content: toolResult.content,
156
+ isError: !toolResult.ok,
157
+ });
158
+ }
159
+ }
160
+ return { messages: convo, steps, usage, stopReason: 'max_steps', compacted };
161
+ }