@gird/ccli 1.0.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/.env.example +23 -0
- package/CCLI.md +15 -0
- package/README.md +187 -0
- package/dist/agent/client-openai.js +166 -0
- package/dist/agent/client.js +63 -0
- package/dist/agent/factory.js +39 -0
- package/dist/agent/loop.js +163 -0
- package/dist/agent/pricing.js +28 -0
- package/dist/agent/prompt.js +43 -0
- package/dist/agent/provider.js +18 -0
- package/dist/cli/headless.js +50 -0
- package/dist/cli/mentions.js +60 -0
- package/dist/cli/session.js +432 -0
- package/dist/commands/custom.js +46 -0
- package/dist/commands/slash.js +148 -0
- package/dist/config/config.js +128 -0
- package/dist/config/dotenv.js +59 -0
- package/dist/context/project.js +155 -0
- package/dist/hooks/hooks.js +82 -0
- package/dist/index.js +237 -0
- package/dist/permissions/permissions.js +87 -0
- package/dist/session/store.js +81 -0
- package/dist/tools/bash.js +97 -0
- package/dist/tools/filesystem.js +215 -0
- package/dist/tools/index.js +45 -0
- package/dist/tools/search.js +0 -0
- package/dist/tools/task.js +35 -0
- package/dist/tools/todo.js +68 -0
- package/dist/tools/types.js +8 -0
- package/dist/tools/web.js +108 -0
- package/dist/ui/diff.js +105 -0
- package/dist/ui/markdown.js +137 -0
- package/dist/ui/spinner.js +64 -0
- package/dist/ui/theme.js +106 -0
- package/package.json +57 -0
package/.env.example
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# CCLI 本地开发默认配置
|
|
2
|
+
# 用法:复制本文件为 .env,填入 API Key 即可。
|
|
3
|
+
# cp .env.example .env
|
|
4
|
+
# .env 已被 .gitignore 忽略,不会提交。启动时自动加载(真实环境变量优先)。
|
|
5
|
+
|
|
6
|
+
# ───────────────────────────────────────────────
|
|
7
|
+
# 后端二选一(不设 ANTHROPIC_API_KEY 时自动用 OpenAI 兼容后端)
|
|
8
|
+
# ───────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
# 【方式 A】通义千问 Qwen(OpenAI 兼容,推荐用于本地开发)
|
|
11
|
+
QWEN_API_KEY=sk-在这里填入你的千问key
|
|
12
|
+
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
|
13
|
+
QWEN_MODEL=qwen-plus
|
|
14
|
+
|
|
15
|
+
# 【方式 B】Anthropic 官方(若有 Claude key,取消注释即可,会优先生效)
|
|
16
|
+
# ANTHROPIC_API_KEY=sk-ant-...
|
|
17
|
+
# ANTHROPIC_BASE_URL=https://api.anthropic.com
|
|
18
|
+
|
|
19
|
+
# 【方式 C】其他 OpenAI 兼容后端(DeepSeek / GLM / 自建)
|
|
20
|
+
# OPENAI_API_KEY=...
|
|
21
|
+
# OPENAI_BASE_URL=https://your-endpoint/v1
|
|
22
|
+
# CCLI_MODEL=your-model
|
|
23
|
+
# CCLI_PROVIDER=openai
|
package/CCLI.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# CCLI 项目说明(供 AI 助手阅读)
|
|
2
|
+
|
|
3
|
+
这是一个用 TypeScript 实现的、复刻 Claude Code 的代理型终端 AI 编程助手。
|
|
4
|
+
|
|
5
|
+
## 关键约定
|
|
6
|
+
- 纯 ESM(`"type": "module"`),import 语句需带 `.js` 扩展名(指向编译产物)。
|
|
7
|
+
- 运行时依赖仅 `@anthropic-ai/sdk`;其余为 Node 内置模块。
|
|
8
|
+
- UI 不使用 Ink/React,全部用 `src/ui/theme.ts` 的 ANSI 工具手写。
|
|
9
|
+
- 工具新增方式:实现 `src/tools/types.ts` 的 `Tool` 接口,在 `src/tools/index.ts` 注册。
|
|
10
|
+
- 修改后用 `npm run build` 或 `npm run typecheck` 验证。
|
|
11
|
+
|
|
12
|
+
## 开发命令
|
|
13
|
+
- `npm run build` —— 编译到 `dist/`
|
|
14
|
+
- `npm run typecheck` —— 仅类型检查
|
|
15
|
+
- `npm run dev -- <args>` —— 用 tsx 直接运行源码
|
package/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# CCLI —— AI 软件工程师(Claude Code 复刻)
|
|
2
|
+
|
|
3
|
+
一个**完整复刻 Claude Code** 的命令行 AI 编程助手。它不是一个简单的「问答机器人」,而是一个**代理型(Agentic)** 的终端工程师:你给出高层次目标,它自主规划步骤、读取文件、执行命令、修改代码并验证结果,直到任务完成。
|
|
4
|
+
|
|
5
|
+
本项目对照 [`docs/主流AI-CLI工具对比说明.md`](./docs/主流AI-CLI工具对比说明.md) 中描述的 Claude CLI 架构实现,覆盖了文档列出的全部核心能力;并在 [`docs/差距/`](./docs/差距/) 目录中对照**真实 Claude Code** 逐项补齐了更多高级特性(TodoWrite、子代理、Hooks、会话恢复等)。
|
|
6
|
+
|
|
7
|
+
## 特性
|
|
8
|
+
|
|
9
|
+
对照对比文档中 Claude CLI 的技术规格逐项实现:
|
|
10
|
+
|
|
11
|
+
| 文档中的能力 | 本项目实现 |
|
|
12
|
+
|---|---|
|
|
13
|
+
| **代理循环 Agentic Loop**(感知→推理→行动→验证) | `src/agent/loop.ts` —— 多轮工具调用状态机,最多 50 次迭代 |
|
|
14
|
+
| **Anthropic Tool Use**(原生函数调用 + 动态注册) | `src/tools/` —— 7 个工具,`ToolRegistry` 支持动态注册 |
|
|
15
|
+
| **HTTP/2 + SSE 流式传输** | `src/agent/client.ts` —— 基于官方 `@anthropic-ai/sdk` 流式接口 |
|
|
16
|
+
| **本地能力**(child_process / fs / git) | `bash` / `read_file` / `write_file` / `edit_file` / `list_dir` 工具 |
|
|
17
|
+
| **显式授权机制 + 权限白名单 + 二次确认** | `src/permissions/` —— 只读工具放行、写/命令需确认、高危命令告警、白名单持久化 |
|
|
18
|
+
| **智能文件索引 + 分层上下文** | `src/context/project.ts` —— 目录树扫描、`CCLI.md` 注入、git 状态、系统提示缓存 |
|
|
19
|
+
| **自定义终端 UI** | `src/ui/` —— ANSI 配色、Markdown 渲染、思考动画、确认框 |
|
|
20
|
+
| **上下文管理** | `/clear` 清空、`/compact` 压缩、`/cost` 用量统计 |
|
|
21
|
+
|
|
22
|
+
## 安装
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install
|
|
26
|
+
npm run build
|
|
27
|
+
npm link # 可选:全局安装 ccli 命令
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## 配置
|
|
31
|
+
|
|
32
|
+
支持两类后端协议,自动识别:
|
|
33
|
+
|
|
34
|
+
**A. Anthropic(默认)**
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
38
|
+
export ANTHROPIC_BASE_URL=https://... # 可选
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**B. OpenAI 兼容(通义千问 Qwen / DeepSeek / GLM 等)**
|
|
42
|
+
|
|
43
|
+
CLI 内置了 OpenAI Chat Completions 适配层,会在边界处把内部的 Anthropic 工具调用格式与 OpenAI 的 `tool_calls` 互转,因此完整的代理循环(工具调用、多轮)在这些后端上同样可用。
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# 通义千问示例(DashScope 兼容端点)
|
|
47
|
+
export QWEN_API_KEY=sk-...
|
|
48
|
+
export QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
|
49
|
+
export QWEN_MODEL=qwen-plus
|
|
50
|
+
|
|
51
|
+
# 或通用 OpenAI 兼容
|
|
52
|
+
export OPENAI_API_KEY=...
|
|
53
|
+
export OPENAI_BASE_URL=https://your-endpoint/v1
|
|
54
|
+
export CCLI_MODEL=your-model
|
|
55
|
+
export CCLI_PROVIDER=openai # 如需强制指定协议
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
未设置 `ANTHROPIC_API_KEY` 而检测到 `QWEN_API_KEY`/`OPENAI_API_KEY` 时,自动切换为 OpenAI 兼容协议。也可运行 `ccli config` 交互式保存到 `~/.ccli/config.json`。
|
|
59
|
+
|
|
60
|
+
> 已实测:`qwen-plus` 上读文件、写文件、多轮工具调用均端到端可用。
|
|
61
|
+
|
|
62
|
+
### 本地开发(推荐 .env)
|
|
63
|
+
|
|
64
|
+
启动时会自动从当前目录(向上最多 5 层)加载 `.env`,**真实环境变量优先、不被覆盖**。
|
|
65
|
+
所以本地开发不必每次 export,只需:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
cp .env.example .env # 模板已预填千问默认配置
|
|
69
|
+
# 编辑 .env,把 QWEN_API_KEY 换成你的 key
|
|
70
|
+
npm run dev -- "读一下 src/agent/loop.ts 并总结" # tsx 直接跑源码
|
|
71
|
+
# 或构建后:node dist/index.js
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`.env` 已被 `.gitignore` 忽略,不会提交。
|
|
75
|
+
|
|
76
|
+
## 使用
|
|
77
|
+
|
|
78
|
+
> 完整使用指南(运行方式、输入快捷键、斜杠命令、工作流、常见问题)见 [`docs/使用指南.md`](./docs/使用指南.md)。
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# 交互式会话(REPL)
|
|
82
|
+
ccli
|
|
83
|
+
|
|
84
|
+
# 直接给出目标
|
|
85
|
+
ccli "给这个项目加一个命令行参数解析模块"
|
|
86
|
+
|
|
87
|
+
# 一次性模式(适合脚本/管道)
|
|
88
|
+
ccli -p "用一句话总结 src/agent/loop.ts 的作用"
|
|
89
|
+
|
|
90
|
+
# 指定模型 / 自动批准
|
|
91
|
+
ccli --model claude-sonnet-4-6
|
|
92
|
+
ccli -y "运行测试并修复失败"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
未全局安装时用 `npm run dev -- "你的目标"`(开发)或 `node dist/index.js`(构建后)。
|
|
96
|
+
|
|
97
|
+
## 交互命令(Slash Commands)
|
|
98
|
+
|
|
99
|
+
| 命令 | 说明 |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `/help` | 显示所有命令 |
|
|
102
|
+
| `/clear` | 清空对话历史 |
|
|
103
|
+
| `/compact` | 压缩历史以节省上下文 |
|
|
104
|
+
| `/model [id]` | 查看或切换模型 |
|
|
105
|
+
| `/cost` | 本次会话 token 用量与估算成本(USD) |
|
|
106
|
+
| `/init` | 分析项目并生成 CCLI.md |
|
|
107
|
+
| `/commit` | 生成提交信息并 git 提交 |
|
|
108
|
+
| `/resume` | 列出本目录的历史会话 |
|
|
109
|
+
| `/auto` | 切换自动批准模式 |
|
|
110
|
+
| `/permissions` | 查看权限白名单 |
|
|
111
|
+
| `/status` | 会话状态 |
|
|
112
|
+
| `/exit` | 退出 |
|
|
113
|
+
|
|
114
|
+
输入框中:
|
|
115
|
+
- `!<命令>` 直接执行 shell 命令
|
|
116
|
+
- `#<文字>` 把一条规则写入 CCLI.md(长期记忆)
|
|
117
|
+
- `@<路径>` 引用文件内容并入消息
|
|
118
|
+
- `Ctrl+C` 中断当前生成;连按两次退出
|
|
119
|
+
|
|
120
|
+
## 工具集
|
|
121
|
+
|
|
122
|
+
| 工具 | 类型 | 说明 |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `read_file` | 感知 | 读取文件(带行号,支持分段) |
|
|
125
|
+
| `list_dir` | 感知 | 列目录 |
|
|
126
|
+
| `grep` | 感知 | 正则搜索代码内容 |
|
|
127
|
+
| `glob` | 感知 | 按文件名模式查找 |
|
|
128
|
+
| `web_fetch` | 感知 | 抓取网页转文本(需授权) |
|
|
129
|
+
| `write_file` | 行动 | 写入/创建文件(需授权,带 diff) |
|
|
130
|
+
| `edit_file` | 行动 | 精确替换编辑(需授权,带行级 diff) |
|
|
131
|
+
| `bash` | 行动 | 执行 shell 命令(需授权,高危告警) |
|
|
132
|
+
| `todo_write` | 协调 | 维护多步任务清单并实时渲染进度 |
|
|
133
|
+
| `task` | 协调 | 派生只读子代理做聚焦调研 |
|
|
134
|
+
|
|
135
|
+
## 对齐真实 Claude Code 的高级特性
|
|
136
|
+
|
|
137
|
+
在 [`docs/差距/`](./docs/差距/) 目录中,对照真实 Claude Code 逐项补齐(每项一份规格文档 + 代码实现):
|
|
138
|
+
|
|
139
|
+
- **TodoWrite 任务追踪** — 模型把多步任务拆成清单,终端实时显示 `✔/▸/○` 进度
|
|
140
|
+
- **WebFetch** — 抓取在线文档/网页
|
|
141
|
+
- **`@文件` 引用** — 输入 `@src/x.ts` 自动并入文件内容
|
|
142
|
+
- **会话持久化** — `ccli -c` 续聊上次、`ccli -r [id]` 列出/恢复历史会话
|
|
143
|
+
- **自定义斜杠命令** — `.ccli/commands/<名>.md` 即定义 `/<名>`,支持 `$ARGUMENTS`
|
|
144
|
+
- **行级 diff 渲染** — 编辑后展示真正的 +/- diff(LCS 算法)
|
|
145
|
+
- **子代理 / Task 工具** — 派生独立上下文的只读子代理做调研
|
|
146
|
+
- **Hooks 生命周期钩子** — `.ccli/settings.json` 配置 `PreToolUse`(可拦截)/`PostToolUse`
|
|
147
|
+
- **流式 Markdown 实时渲染** — 流式正文按完整行实时套用 markdown(标题/列表/代码块/行内样式)
|
|
148
|
+
- **真实定价 `/cost`** — 按模型单价估算美元成本
|
|
149
|
+
- **`#` 写记忆** — `#<文字>` 追加规则到 CCLI.md
|
|
150
|
+
- **`/init`** — 让 agent 分析项目生成 CCLI.md
|
|
151
|
+
- **`/commit`** — 让 agent 总结改动并 git 提交
|
|
152
|
+
|
|
153
|
+
尚未实施的项(MCP、图片输入、WebSearch、Vim 模式等)及理由见 [`docs/差距/09-未实施.md`](./docs/差距/09-未实施.md)。
|
|
154
|
+
|
|
155
|
+
## 架构
|
|
156
|
+
|
|
157
|
+
> 完整的架构说明(设计理念、分层图、代理循环数据流、各模块职责、扩展指南)见 [`docs/架构设计.md`](./docs/架构设计.md)。
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
src/
|
|
161
|
+
├── index.ts 入口:参数解析、API Key 引导、会话恢复
|
|
162
|
+
├── agent/
|
|
163
|
+
│ ├── loop.ts 代理循环(核心状态机,含 Hooks 集成)
|
|
164
|
+
│ ├── client.ts Anthropic API 流式封装
|
|
165
|
+
│ ├── factory.ts Agent 装配(工具 + Todo + Hooks + 子代理)
|
|
166
|
+
│ └── prompt.ts 系统提示词
|
|
167
|
+
├── tools/ 工具系统:filesystem / search / bash / todo / web / task
|
|
168
|
+
├── permissions/ 权限/授权机制
|
|
169
|
+
├── context/ 项目上下文扫描与索引
|
|
170
|
+
├── hooks/ 生命周期钩子(Pre/PostToolUse)
|
|
171
|
+
├── session/ 会话持久化(continue/resume)
|
|
172
|
+
├── commands/ 斜杠命令 + 自定义命令加载
|
|
173
|
+
├── config/ 配置管理
|
|
174
|
+
├── cli/
|
|
175
|
+
│ ├── session.ts 交互式 REPL
|
|
176
|
+
│ ├── headless.ts 一次性模式
|
|
177
|
+
│ └── mentions.ts @文件引用展开
|
|
178
|
+
└── ui/ 终端 UI(配色 / Markdown / diff / 动画)
|
|
179
|
+
|
|
180
|
+
docs/ 文档
|
|
181
|
+
├── 主流AI-CLI工具对比说明.md
|
|
182
|
+
└── 差距/ 与真实 Claude Code 的差距分析与实施记录
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## 许可
|
|
186
|
+
|
|
187
|
+
MIT
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI 兼容客户端 —— 对接 OpenAI Chat Completions 协议的后端,
|
|
3
|
+
* 例如通义千问(DashScope compatible-mode)、DeepSeek、GLM 等。
|
|
4
|
+
*
|
|
5
|
+
* 它在边界处把代理循环内部使用的 Anthropic 消息格式(含 tool_use / tool_result 块)
|
|
6
|
+
* 与 OpenAI 的 messages / tool_calls 格式互相转换,从而让上层循环逻辑保持不变。
|
|
7
|
+
*/
|
|
8
|
+
import OpenAI from 'openai';
|
|
9
|
+
export class OpenAICompatClient {
|
|
10
|
+
client;
|
|
11
|
+
model;
|
|
12
|
+
maxTokens;
|
|
13
|
+
temperature;
|
|
14
|
+
constructor(opts) {
|
|
15
|
+
this.client = new OpenAI({ apiKey: opts.apiKey, baseURL: opts.baseURL });
|
|
16
|
+
this.model = opts.model;
|
|
17
|
+
this.maxTokens = opts.maxTokens;
|
|
18
|
+
this.temperature = opts.temperature;
|
|
19
|
+
}
|
|
20
|
+
setModel(m) {
|
|
21
|
+
this.model = m;
|
|
22
|
+
}
|
|
23
|
+
getModel() {
|
|
24
|
+
return this.model;
|
|
25
|
+
}
|
|
26
|
+
async stream(system, messages, tools, callbacks, signal) {
|
|
27
|
+
const oaMessages = this.toOpenAIMessages(system, messages);
|
|
28
|
+
const oaTools = tools.map((t) => ({
|
|
29
|
+
type: 'function',
|
|
30
|
+
function: {
|
|
31
|
+
name: t.name,
|
|
32
|
+
description: t.description,
|
|
33
|
+
parameters: t.input_schema ?? { type: 'object', properties: {} },
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
const stream = await this.client.chat.completions.create({
|
|
37
|
+
model: this.model,
|
|
38
|
+
max_tokens: this.maxTokens,
|
|
39
|
+
temperature: this.temperature,
|
|
40
|
+
messages: oaMessages,
|
|
41
|
+
tools: oaTools.length ? oaTools : undefined,
|
|
42
|
+
tool_choice: oaTools.length ? 'auto' : undefined,
|
|
43
|
+
stream: true,
|
|
44
|
+
stream_options: { include_usage: true },
|
|
45
|
+
}, { signal });
|
|
46
|
+
let text = '';
|
|
47
|
+
let finishReason = null;
|
|
48
|
+
const usage = { input: 0, output: 0 };
|
|
49
|
+
// 按 index 累积 tool_calls
|
|
50
|
+
const toolAcc = new Map();
|
|
51
|
+
for await (const chunk of stream) {
|
|
52
|
+
const choice = chunk.choices?.[0];
|
|
53
|
+
if (choice) {
|
|
54
|
+
const delta = choice.delta;
|
|
55
|
+
if (delta?.content) {
|
|
56
|
+
text += delta.content;
|
|
57
|
+
callbacks.onText?.(delta.content);
|
|
58
|
+
}
|
|
59
|
+
if (delta?.tool_calls) {
|
|
60
|
+
for (const tc of delta.tool_calls) {
|
|
61
|
+
const idx = tc.index ?? 0;
|
|
62
|
+
const cur = toolAcc.get(idx) ?? { id: '', name: '', args: '' };
|
|
63
|
+
if (tc.id)
|
|
64
|
+
cur.id = tc.id;
|
|
65
|
+
if (tc.function?.name)
|
|
66
|
+
cur.name = tc.function.name;
|
|
67
|
+
if (tc.function?.arguments)
|
|
68
|
+
cur.args += tc.function.arguments;
|
|
69
|
+
toolAcc.set(idx, cur);
|
|
70
|
+
if (tc.function?.name)
|
|
71
|
+
callbacks.onToolUseStart?.(tc.function.name, cur.id);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (choice.finish_reason)
|
|
75
|
+
finishReason = choice.finish_reason;
|
|
76
|
+
}
|
|
77
|
+
if (chunk.usage) {
|
|
78
|
+
usage.input = chunk.usage.prompt_tokens ?? 0;
|
|
79
|
+
usage.output = chunk.usage.completion_tokens ?? 0;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// 组装 Anthropic 内部格式的 content 块
|
|
83
|
+
const content = [];
|
|
84
|
+
if (text)
|
|
85
|
+
content.push({ type: 'text', text });
|
|
86
|
+
let genId = 0;
|
|
87
|
+
for (const [, tc] of [...toolAcc.entries()].sort((a, b) => a[0] - b[0])) {
|
|
88
|
+
let input = {};
|
|
89
|
+
try {
|
|
90
|
+
input = tc.args ? JSON.parse(tc.args) : {};
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
input = {};
|
|
94
|
+
}
|
|
95
|
+
content.push({
|
|
96
|
+
type: 'tool_use',
|
|
97
|
+
id: tc.id || `call_${Date.now()}_${genId++}`,
|
|
98
|
+
name: tc.name,
|
|
99
|
+
input,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const stopReason = finishReason === 'tool_calls'
|
|
103
|
+
? 'tool_use'
|
|
104
|
+
: finishReason === 'length'
|
|
105
|
+
? 'max_tokens'
|
|
106
|
+
: 'end_turn';
|
|
107
|
+
return { content, stopReason, usage };
|
|
108
|
+
}
|
|
109
|
+
/** Anthropic 内部消息 → OpenAI messages。 */
|
|
110
|
+
toOpenAIMessages(system, messages) {
|
|
111
|
+
const out = [
|
|
112
|
+
{ role: 'system', content: system },
|
|
113
|
+
];
|
|
114
|
+
for (const m of messages) {
|
|
115
|
+
if (typeof m.content === 'string') {
|
|
116
|
+
out.push({ role: m.role, content: m.content });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const blocks = m.content;
|
|
120
|
+
if (m.role === 'assistant') {
|
|
121
|
+
let textContent = '';
|
|
122
|
+
const toolCalls = [];
|
|
123
|
+
for (const b of blocks) {
|
|
124
|
+
if (b.type === 'text')
|
|
125
|
+
textContent += b.text;
|
|
126
|
+
else if (b.type === 'tool_use') {
|
|
127
|
+
toolCalls.push({
|
|
128
|
+
id: b.id,
|
|
129
|
+
type: 'function',
|
|
130
|
+
function: {
|
|
131
|
+
name: b.name,
|
|
132
|
+
arguments: JSON.stringify(b.input ?? {}),
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const msg = { role: 'assistant' };
|
|
138
|
+
msg.content = textContent || null;
|
|
139
|
+
if (toolCalls.length)
|
|
140
|
+
msg.tool_calls = toolCalls;
|
|
141
|
+
out.push(msg);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// user 消息:可能含 tool_result 与 text
|
|
145
|
+
const userTexts = [];
|
|
146
|
+
for (const b of blocks) {
|
|
147
|
+
if (b.type === 'tool_result') {
|
|
148
|
+
out.push({
|
|
149
|
+
role: 'tool',
|
|
150
|
+
tool_call_id: b.tool_use_id,
|
|
151
|
+
content: typeof b.content === 'string'
|
|
152
|
+
? b.content
|
|
153
|
+
: JSON.stringify(b.content),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
else if (b.type === 'text') {
|
|
157
|
+
userTexts.push(b.text);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (userTexts.length) {
|
|
161
|
+
out.push({ role: 'user', content: userTexts.join('\n') });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Messages API 客户端封装 —— HTTP/2 + SSE 流式传输。
|
|
3
|
+
*/
|
|
4
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
5
|
+
export class AnthropicClient {
|
|
6
|
+
client;
|
|
7
|
+
model;
|
|
8
|
+
maxTokens;
|
|
9
|
+
temperature;
|
|
10
|
+
constructor(opts) {
|
|
11
|
+
this.client = new Anthropic({
|
|
12
|
+
apiKey: opts.apiKey,
|
|
13
|
+
baseURL: opts.baseURL,
|
|
14
|
+
});
|
|
15
|
+
this.model = opts.model;
|
|
16
|
+
this.maxTokens = opts.maxTokens;
|
|
17
|
+
this.temperature = opts.temperature;
|
|
18
|
+
}
|
|
19
|
+
setModel(m) {
|
|
20
|
+
this.model = m;
|
|
21
|
+
}
|
|
22
|
+
getModel() {
|
|
23
|
+
return this.model;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 发起一次带流式输出的对话请求。
|
|
27
|
+
* 返回完整的 assistant 回合(含文本与 tool_use 块)。
|
|
28
|
+
*/
|
|
29
|
+
async stream(system, messages, tools, callbacks, signal) {
|
|
30
|
+
const stream = this.client.messages.stream({
|
|
31
|
+
model: this.model,
|
|
32
|
+
max_tokens: this.maxTokens,
|
|
33
|
+
temperature: this.temperature,
|
|
34
|
+
system: [
|
|
35
|
+
{
|
|
36
|
+
type: 'text',
|
|
37
|
+
text: system,
|
|
38
|
+
// 系统提示缓存(对应「分层上下文压缩」中的缓存策略)
|
|
39
|
+
cache_control: { type: 'ephemeral' },
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
messages,
|
|
43
|
+
tools: tools.length ? tools : undefined,
|
|
44
|
+
}, { signal });
|
|
45
|
+
stream.on('text', (delta) => callbacks.onText?.(delta));
|
|
46
|
+
stream.on('contentBlock', (block) => {
|
|
47
|
+
if (block.type === 'tool_use') {
|
|
48
|
+
callbacks.onToolUseStart?.(block.name, block.id);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
// thinking(若启用扩展思考)
|
|
52
|
+
stream.on('thinking', (delta) => callbacks.onThinking?.(delta));
|
|
53
|
+
const final = await stream.finalMessage();
|
|
54
|
+
return {
|
|
55
|
+
content: final.content,
|
|
56
|
+
stopReason: final.stop_reason,
|
|
57
|
+
usage: {
|
|
58
|
+
input: final.usage.input_tokens,
|
|
59
|
+
output: final.usage.output_tokens,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Agent } from './loop.js';
|
|
2
|
+
import { buildSystemPrompt } from './prompt.js';
|
|
3
|
+
import { createDefaultRegistry, createReadOnlyRegistry, } from '../tools/index.js';
|
|
4
|
+
import { TodoStore } from '../tools/todo.js';
|
|
5
|
+
import { createTaskTool, SUBAGENT_SYSTEM, } from '../tools/task.js';
|
|
6
|
+
import { HookRunner } from '../hooks/hooks.js';
|
|
7
|
+
import { gatherContext } from '../context/project.js';
|
|
8
|
+
export function buildAgent(opts) {
|
|
9
|
+
const { client, cwd, permissions } = opts;
|
|
10
|
+
const todos = new TodoStore();
|
|
11
|
+
const hooks = new HookRunner(cwd);
|
|
12
|
+
const registry = createDefaultRegistry();
|
|
13
|
+
// 子代理运行器:用只读工具集跑一个独立 Agent,返回最终文本。
|
|
14
|
+
const runner = async (description, prompt, subCwd) => {
|
|
15
|
+
const subRegistry = createReadOnlyRegistry();
|
|
16
|
+
const subAgent = new Agent(client, subRegistry, permissions, SUBAGENT_SYSTEM, { cwd: subCwd }, hooks);
|
|
17
|
+
let text = '';
|
|
18
|
+
let isError = false;
|
|
19
|
+
try {
|
|
20
|
+
await subAgent.run(prompt, {
|
|
21
|
+
onAssistantText: (d) => {
|
|
22
|
+
text += d;
|
|
23
|
+
},
|
|
24
|
+
onTurnStart: () => { },
|
|
25
|
+
onTurnEnd: () => { },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
isError = true;
|
|
30
|
+
text = `子代理出错:${e?.message ?? e}`;
|
|
31
|
+
}
|
|
32
|
+
return { text: text.trim(), isError };
|
|
33
|
+
};
|
|
34
|
+
registry.register(createTaskTool(runner));
|
|
35
|
+
const ctx = gatherContext(cwd);
|
|
36
|
+
const system = buildSystemPrompt(ctx);
|
|
37
|
+
const agent = new Agent(client, registry, permissions, system, { cwd, todos }, hooks);
|
|
38
|
+
return { agent, todos, hooks, client };
|
|
39
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { toAnthropicTool } from '../tools/index.js';
|
|
2
|
+
const MAX_ITERATIONS = 50;
|
|
3
|
+
export class Agent {
|
|
4
|
+
client;
|
|
5
|
+
registry;
|
|
6
|
+
permissions;
|
|
7
|
+
system;
|
|
8
|
+
toolCtx;
|
|
9
|
+
hooks;
|
|
10
|
+
history = [];
|
|
11
|
+
totalUsage = { input: 0, output: 0 };
|
|
12
|
+
constructor(client, registry, permissions, system, toolCtx, hooks) {
|
|
13
|
+
this.client = client;
|
|
14
|
+
this.registry = registry;
|
|
15
|
+
this.permissions = permissions;
|
|
16
|
+
this.system = system;
|
|
17
|
+
this.toolCtx = toolCtx;
|
|
18
|
+
this.hooks = hooks;
|
|
19
|
+
}
|
|
20
|
+
setSystem(s) {
|
|
21
|
+
this.system = s;
|
|
22
|
+
}
|
|
23
|
+
/** 处理一条用户输入,驱动完整的代理循环直到回合结束。 */
|
|
24
|
+
async run(userInput, events, signal) {
|
|
25
|
+
this.history.push({ role: 'user', content: userInput });
|
|
26
|
+
await this.loop(events, signal);
|
|
27
|
+
}
|
|
28
|
+
/** 注入工具结果后继续循环(用于外部恢复,一般内部调用)。 */
|
|
29
|
+
async loop(events, signal) {
|
|
30
|
+
const tools = this.registry.list().map(toAnthropicTool);
|
|
31
|
+
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
32
|
+
if (signal?.aborted)
|
|
33
|
+
return;
|
|
34
|
+
events.onTurnStart?.();
|
|
35
|
+
let turn;
|
|
36
|
+
try {
|
|
37
|
+
turn = await this.client.stream(this.system, this.history, tools, {
|
|
38
|
+
onText: (d) => events.onAssistantText?.(d),
|
|
39
|
+
onThinking: (d) => events.onThinking?.(d),
|
|
40
|
+
}, signal);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
events.onTurnEnd?.();
|
|
44
|
+
if (signal?.aborted)
|
|
45
|
+
return;
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
48
|
+
events.onTurnEnd?.();
|
|
49
|
+
this.totalUsage.input += turn.usage.input;
|
|
50
|
+
this.totalUsage.output += turn.usage.output;
|
|
51
|
+
events.onUsage?.(turn.usage);
|
|
52
|
+
// 记录 assistant 回合
|
|
53
|
+
this.history.push({ role: 'assistant', content: turn.content });
|
|
54
|
+
// 收集 tool_use 块
|
|
55
|
+
const toolUses = turn.content.filter((b) => b.type === 'tool_use');
|
|
56
|
+
if (toolUses.length === 0) {
|
|
57
|
+
// 没有工具调用 —— 回合结束
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// 依次执行工具,收集 tool_result
|
|
61
|
+
const toolResults = [];
|
|
62
|
+
for (const tu of toolUses) {
|
|
63
|
+
if (signal?.aborted) {
|
|
64
|
+
toolResults.push({
|
|
65
|
+
type: 'tool_result',
|
|
66
|
+
tool_use_id: tu.id,
|
|
67
|
+
content: '用户中断了操作。',
|
|
68
|
+
is_error: true,
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const result = await this.executeTool(tu, events);
|
|
73
|
+
toolResults.push(result);
|
|
74
|
+
}
|
|
75
|
+
// 把工具结果作为新的 user 消息回传
|
|
76
|
+
this.history.push({ role: 'user', content: toolResults });
|
|
77
|
+
// 继续循环
|
|
78
|
+
}
|
|
79
|
+
// 达到迭代上限
|
|
80
|
+
this.history.push({
|
|
81
|
+
role: 'user',
|
|
82
|
+
content: '已达到工具调用上限,请总结当前进展。',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async executeTool(tu, events) {
|
|
86
|
+
const tool = this.registry.get(tu.name);
|
|
87
|
+
if (!tool) {
|
|
88
|
+
return {
|
|
89
|
+
type: 'tool_result',
|
|
90
|
+
tool_use_id: tu.id,
|
|
91
|
+
content: `未知工具:${tu.name}`,
|
|
92
|
+
is_error: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const preview = tool.preview ? tool.preview(tu.input, this.toolCtx) : tu.name;
|
|
96
|
+
events.onToolStart?.(tu.name, tu.input, preview);
|
|
97
|
+
// 权限检查
|
|
98
|
+
if (tool.permission) {
|
|
99
|
+
const req = tool.permission(tu.input, this.toolCtx);
|
|
100
|
+
if (req) {
|
|
101
|
+
const decision = await this.permissions.check(req);
|
|
102
|
+
if (decision === 'deny') {
|
|
103
|
+
events.onToolDenied?.(tu.name, '用户拒绝了该操作');
|
|
104
|
+
return {
|
|
105
|
+
type: 'tool_result',
|
|
106
|
+
tool_use_id: tu.id,
|
|
107
|
+
content: '用户拒绝执行此操作。请不要重试相同操作,可以询问用户希望如何继续。',
|
|
108
|
+
is_error: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// PreToolUse 钩子:可阻止工具
|
|
114
|
+
if (this.hooks) {
|
|
115
|
+
const pre = this.hooks.runPre(tu.name, tu.input);
|
|
116
|
+
if (pre.blocked) {
|
|
117
|
+
events.onToolDenied?.(tu.name, `钩子拦截:${pre.reason ?? ''}`);
|
|
118
|
+
return {
|
|
119
|
+
type: 'tool_result',
|
|
120
|
+
tool_use_id: tu.id,
|
|
121
|
+
content: `操作被 PreToolUse 钩子阻止:${pre.reason ?? '(无理由)'}`,
|
|
122
|
+
is_error: true,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// 执行
|
|
127
|
+
try {
|
|
128
|
+
const res = await tool.run(tu.input, this.toolCtx);
|
|
129
|
+
this.hooks?.runPost(tu.name, tu.input, res.content);
|
|
130
|
+
events.onToolResult?.(tu.name, res.display ?? res.content.slice(0, 200), res.isError ?? false);
|
|
131
|
+
return {
|
|
132
|
+
type: 'tool_result',
|
|
133
|
+
tool_use_id: tu.id,
|
|
134
|
+
content: res.content,
|
|
135
|
+
is_error: res.isError ?? false,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
events.onToolResult?.(tu.name, `执行异常:${e.message}`, true);
|
|
140
|
+
return {
|
|
141
|
+
type: 'tool_result',
|
|
142
|
+
tool_use_id: tu.id,
|
|
143
|
+
content: `工具执行异常:${e.message}`,
|
|
144
|
+
is_error: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** 清空对话历史(/clear)。 */
|
|
149
|
+
clear() {
|
|
150
|
+
this.history = [];
|
|
151
|
+
this.totalUsage = { input: 0, output: 0 };
|
|
152
|
+
}
|
|
153
|
+
/** 压缩历史(/compact)—— 简化实现:保留最近若干轮 + 摘要占位。 */
|
|
154
|
+
compactSummary(summary) {
|
|
155
|
+
this.history = [
|
|
156
|
+
{ role: 'user', content: `[此前对话的摘要]\n${summary}` },
|
|
157
|
+
{
|
|
158
|
+
role: 'assistant',
|
|
159
|
+
content: '好的,我已了解此前的上下文,请继续。',
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const PRICES = [
|
|
2
|
+
{ match: /opus/i, price: { input: 15, output: 75 } },
|
|
3
|
+
{ match: /sonnet/i, price: { input: 3, output: 15 } },
|
|
4
|
+
{ match: /haiku/i, price: { input: 0.8, output: 4 } },
|
|
5
|
+
// OpenAI 兼容后端(近似值,仅供参考)
|
|
6
|
+
{ match: /qwen-max/i, price: { input: 1.6, output: 6.4 } },
|
|
7
|
+
{ match: /qwen-plus/i, price: { input: 0.4, output: 1.2 } },
|
|
8
|
+
{ match: /qwen-turbo|qwen-flash/i, price: { input: 0.05, output: 0.2 } },
|
|
9
|
+
{ match: /qwen/i, price: { input: 0.4, output: 1.2 } },
|
|
10
|
+
{ match: /deepseek/i, price: { input: 0.27, output: 1.1 } },
|
|
11
|
+
{ match: /glm/i, price: { input: 0.5, output: 1.5 } },
|
|
12
|
+
];
|
|
13
|
+
const FALLBACK = { input: 3, output: 15 };
|
|
14
|
+
export function priceFor(model) {
|
|
15
|
+
return PRICES.find((p) => p.match.test(model))?.price ?? FALLBACK;
|
|
16
|
+
}
|
|
17
|
+
/** 计算给定用量的美元成本。 */
|
|
18
|
+
export function estimateCost(model, usage) {
|
|
19
|
+
const p = priceFor(model);
|
|
20
|
+
const input = (usage.input / 1_000_000) * p.input;
|
|
21
|
+
const output = (usage.output / 1_000_000) * p.output;
|
|
22
|
+
return { input, output, total: input + output };
|
|
23
|
+
}
|
|
24
|
+
export function formatUsd(n) {
|
|
25
|
+
if (n < 0.01)
|
|
26
|
+
return `$${n.toFixed(4)}`;
|
|
27
|
+
return `$${n.toFixed(4)}`;
|
|
28
|
+
}
|