citrus-code 0.1.0__tar.gz → 0.1.1__tar.gz

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.
Files changed (79) hide show
  1. {citrus_code-0.1.0 → citrus_code-0.1.1}/PKG-INFO +41 -6
  2. {citrus_code-0.1.0 → citrus_code-0.1.1}/README.md +40 -5
  3. {citrus_code-0.1.0 → citrus_code-0.1.1}/pyproject.toml +1 -1
  4. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/ai/events.py +14 -1
  5. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/ai/providers/chat_completions.py +28 -4
  6. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/ai/providers/deepseek.py +8 -10
  7. citrus_code-0.1.1/src/citrus_code/ai/retry.py +42 -0
  8. citrus_code-0.1.1/src/citrus_code/cli/commands.py +59 -0
  9. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/cli/main.py +3 -2
  10. citrus_code-0.1.1/src/citrus_code/cli/output.py +41 -0
  11. citrus_code-0.1.1/src/citrus_code/cli/preview.py +122 -0
  12. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/cli/prompts.py +7 -1
  13. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/cli/render.py +94 -50
  14. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/cli/terminal.py +71 -33
  15. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/cli/theme.py +11 -7
  16. citrus_code-0.1.1/src/citrus_code/cli/welcome.py +57 -0
  17. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/__init__.py +26 -0
  18. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/edit.py +10 -9
  19. citrus_code-0.1.1/src/citrus_code/coding/tools/factory.py +75 -0
  20. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/find.py +9 -27
  21. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/grep.py +18 -60
  22. citrus_code-0.1.1/src/citrus_code/coding/tools/local_files.py +31 -0
  23. citrus_code-0.1.1/src/citrus_code/coding/tools/local_search.py +110 -0
  24. citrus_code-0.1.1/src/citrus_code/coding/tools/local_shell.py +109 -0
  25. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/ls.py +9 -9
  26. citrus_code-0.1.1/src/citrus_code/coding/tools/operations.py +110 -0
  27. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/read.py +8 -6
  28. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/shell.py +23 -92
  29. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/write.py +11 -13
  30. citrus_code-0.1.0/src/citrus_code/cli/preview.py +0 -55
  31. citrus_code-0.1.0/src/citrus_code/coding/tools/factory.py +0 -57
  32. {citrus_code-0.1.0 → citrus_code-0.1.1}/LICENSE +0 -0
  33. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/__init__.py +0 -0
  34. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/__main__.py +0 -0
  35. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/agent/__init__.py +0 -0
  36. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/agent/agent.py +0 -0
  37. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/agent/cancellation.py +0 -0
  38. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/agent/context.py +0 -0
  39. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/agent/events.py +0 -0
  40. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/agent/hooks.py +0 -0
  41. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/agent/loop.py +0 -0
  42. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/agent/message_queue.py +0 -0
  43. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/agent/state.py +0 -0
  44. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/agent/tool_runner.py +0 -0
  45. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/agent/types.py +0 -0
  46. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/ai/__init__.py +0 -0
  47. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/ai/completion.py +0 -0
  48. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/ai/provider.py +0 -0
  49. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/ai/providers/__init__.py +0 -0
  50. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/ai/providers/chat_stream_types.py +0 -0
  51. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/ai/providers/fake.py +0 -0
  52. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/ai/providers/tool_call_buffer.py +0 -0
  53. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/ai/registry.py +0 -0
  54. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/ai/types.py +0 -0
  55. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/cli/__init__.py +0 -0
  56. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/cli/session_files.py +0 -0
  57. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/__init__.py +0 -0
  58. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/coding_session.py +0 -0
  59. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/compaction/__init__.py +0 -0
  60. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/compaction/controller.py +0 -0
  61. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/compaction/summarize.py +0 -0
  62. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/compaction/tokens.py +0 -0
  63. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/context_hooks.py +0 -0
  64. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/history.py +0 -0
  65. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/project_context.py +0 -0
  66. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/session/__init__.py +0 -0
  67. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/session/manager.py +0 -0
  68. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/session/projection.py +0 -0
  69. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/session/recovery.py +0 -0
  70. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/session/storage.py +0 -0
  71. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/session/types.py +0 -0
  72. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/bash.py +0 -0
  73. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/edit_text.py +0 -0
  74. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/mutation.py +0 -0
  75. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/paths.py +0 -0
  76. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/powershell.py +0 -0
  77. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/processes.py +0 -0
  78. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/coding/tools/truncate.py +0 -0
  79. {citrus_code-0.1.0 → citrus_code-0.1.1}/src/citrus_code/config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: citrus-code
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: A small Python coding agent inspired by pi, built as a learning project.
5
5
  Keywords: coding-agent,agent,cli,learning,deepseek
6
6
  License-Expression: MIT
@@ -20,6 +20,10 @@ Project-URL: Issues, https://github.com/big-orange947/citrus-code/issues
20
20
  Project-URL: Documentation, https://github.com/big-orange947/citrus-code#readme
21
21
  Description-Content-Type: text/markdown
22
22
 
23
+ <p align="center">
24
+ <img src="https://raw.githubusercontent.com/big-orange947/citrus-code/main/assets/citrus-banner.svg" alt="Citrus Code:绿叶像素橘子,参考 pi 的 Python coding agent" width="960">
25
+ </p>
26
+
23
27
  # Citrus Code
24
28
 
25
29
  **参考 pi 的核心设计,带初学者用 Python 一步步实现一个精简的 coding agent。**
@@ -49,6 +53,16 @@ Description-Content-Type: text/markdown
49
53
 
50
54
  本项目是学习实现,并非 pi 的官方 Python 移植版,也不承诺与其接口和功能完全一致。
51
55
 
56
+ ## 终端界面与项目标识
57
+
58
+ Citrus Code 使用自己的绿叶像素橘子作为项目标识,README 和终端欢迎页采用同一角色与配色。
59
+ 可复用的透明背景图标位于 [assets/citrus-logo.svg](https://github.com/big-orange947/citrus-code/blob/main/assets/citrus-logo.svg),页首横幅位于
60
+ [assets/citrus-banner.svg](https://github.com/big-orange947/citrus-code/blob/main/assets/citrus-banner.svg)。终端角色直接由字符块绘制,运行时无需加载图片。
61
+
62
+ CLI 提供居中的像素欢迎页、随输入内容增高的输入栏、闪烁块状光标,以及输入 `/` 时展开的命令列表。
63
+ 任务运行中显示旋转指示,助手回复支持 Markdown 和代码高亮,工具执行结果以卡片呈现。
64
+ 欢迎页使用独立终端屏幕,首次发送任务或命令后返回普通终端对话;最近的缩放重绘调整仍待实际交互确认。
65
+
52
66
  ## 当前进度
53
67
 
54
68
  | 模块 | 当前能力 |
@@ -56,7 +70,7 @@ Description-Content-Type: text/markdown
56
70
  | 消息类型 | 系统、用户、助手、工具结果消息;文字块、图片块与工具调用块 |
57
71
  | Provider 接口 | 统一模型信息和异步事件流,提供注册与查找 |
58
72
  | FakeProvider | 使用预设文字模拟流式响应,便于理解异步生成器 |
59
- | DeepSeek 接入 | HTTP 请求、SSE 读取、非思考模式、Flash 图片输入、用量统计和错误事件 |
73
+ | DeepSeek 接入 | HTTP 请求、SSE 读取、非思考模式、Flash 图片输入、临时错误退避重试、用量统计和错误事件 |
60
74
  | 工具调用解析 | 按索引拼接多个工具调用的参数,转换成统一工具事件 |
61
75
  | 生成截断处理 | 默认输出上限 32,768 tokens;回复被截断时不执行该回复中的工具调用 |
62
76
  | 完整回答接口 | `complete()` 消费事件流并返回最终助手消息 |
@@ -66,22 +80,25 @@ Description-Content-Type: text/markdown
66
80
  | coding 工具 | read、write、edit、bash、powershell、grep、find、ls |
67
81
  | 输出控制 | read/命令输出按 2000 行或 50KB 截断;搜索工具提供数量和字节限制 |
68
82
  | 工具运行器 | 按名称查找工具,将执行结果或异常转换成工具结果消息 |
83
+ | 工具执行操作 | 文件、命令和搜索后端可替换;默认本机执行,尚未接入沙箱 |
69
84
  | 会话存储 | JSONL 追加保存、加载校验、消息树与分支选择、当前分支上下文重建 |
70
85
  | CodingSession | 按事件保存消息、安全切换分支、取消与排队消息入口 |
71
86
  | CLI | 交互与单次任务、流式输出、运行中追加消息、会话恢复、末尾缺失工具结果修复 |
72
- | 终端展示 | 原创字符 π 标识、欢迎面板、多行输入、状态栏、Markdown 流式预览、代码高亮和工具结果卡片 |
87
+ | 终端展示 | 原创像素块柑橘标识、简洁欢迎面板、横线输入栏与块状光标、命令提示列表、运行动画、Markdown 流式预览和工具结果卡片 |
73
88
  | 项目指令 | 启动时读取工作目录下的 AGENTS.md,通过模型前 Hook 组装当前请求 |
74
89
  | 上下文压缩 | 估计达到 600,000 tokens 时生成摘要,保留当前要求及近期完整交互,保存压缩记录 |
75
90
 
76
91
  工具名称、主要参数和默认组合参考 pi。实现细节、系统程序依赖、图片能力和取消处理的边界见 [工具说明](https://github.com/big-orange947/citrus-code/blob/main/docs/tools.md)。
77
92
 
93
+ 可替换执行操作的接口与使用方式见 [工具后端说明](https://github.com/big-orange947/citrus-code/blob/main/docs/tool-operations.md),从 `0.1.1` 起提供。
94
+
78
95
  会话存储的接口、文件格式和恢复边界见 [会话说明](https://github.com/big-orange947/citrus-code/blob/main/docs/sessions.md)。带自动保存的任务通过 `CodingSession.prompt()` 运行。命令入口见 [CLI 使用说明](https://github.com/big-orange947/citrus-code/blob/main/docs/cli.md)。
79
96
 
80
97
  自动压缩策略与失败边界见 [上下文压缩说明](https://github.com/big-orange947/citrus-code/blob/main/docs/compaction.md)。当前固定假设模型支持 1,000,000 tokens;估计达到 60% 时压缩。摘要调用会消耗模型额度。
81
98
 
82
99
  ## 快速开始
83
100
 
84
- 当前版本从源码安装,需要 Python 3.12 或更新版本,以及 [uv](https://docs.astral.sh/uv/)。
101
+ 要体验仓库中的最新功能,可以从源码安装,需要 Python 3.12 或更新版本,以及 [uv](https://docs.astral.sh/uv/)。
85
102
  先克隆仓库并安装锁定的依赖:
86
103
 
87
104
  ```powershell
@@ -106,7 +123,7 @@ uv run citrus --cwd "D:\project\your-project"
106
123
 
107
124
  ## 安装发行版
108
125
 
109
- 需要 Python 3.12 或更新版本。以下命令安装已经发布到 PyPI 的版本;首次发布前请使用上方的源码安装方式。安装包名为 `citrus-code`,启动命令为 `citrus`。
126
+ 需要 Python 3.12 或更新版本。以下命令从 PyPI 安装最新发行版。`0.1.1` 加入了新版界面、模型请求自动重试和可替换工具后端。安装包名为 `citrus-code`,启动命令为 `citrus`。
110
127
 
111
128
  使用 uv 安装:
112
129
 
@@ -123,6 +140,16 @@ pipx install citrus-code
123
140
  python -m pip install citrus-code
124
141
  ```
125
142
 
143
+ 已有安装可按自己的安装方式升级:
144
+
145
+ ```powershell
146
+ uv tool upgrade citrus-code
147
+ # 或
148
+ pipx upgrade citrus-code
149
+ # 或
150
+ python -m pip install --upgrade citrus-code
151
+ ```
152
+
126
153
  在 PowerShell 中配置自己的模型密钥并启动:
127
154
 
128
155
  ```powershell
@@ -157,6 +184,8 @@ uv run citrus --resume
157
184
 
158
185
  输入框中 Enter 发送,Alt+Enter 或 Ctrl+J 换行,Tab 补全命令。助手回答支持 Markdown;较长的工具结果默认只展示预览,使用 `--verbose` 启动可查看完整工具返回。升级依赖前先退出正在运行的旧 CLI,避免 Windows 锁住启动程序。
159
186
 
187
+ 输入 `/` 自动展开带说明的命令列表。输入区使用上下横线和闪烁块状光标,运行时持续显示旋转指示。模型临时断流、超时和部分 HTTP 错误最多自动重试 3 次,默认等待 2、4、8 秒;失败尝试的临时回复清除,已完成的工具结果保留,重试等待可用 Ctrl+C 取消。
188
+
160
189
  | 操作 | 命令或按键 |
161
190
  | --- | --- |
162
191
  | 查看帮助 | `/help` |
@@ -219,6 +248,7 @@ citrus --cwd "D:\project\poirot" --resume latest --max-output-tokens 65536
219
248
 
220
249
  ```text
221
250
  citrus-code/
251
+ ├── assets/ # 像素橘子图标与 README 品牌横幅
222
252
  ├── src/citrus_code/
223
253
  │ ├── config.py # 从环境变量加载连接配置
224
254
  │ ├── __main__.py # python -m citrus_code 入口
@@ -263,6 +293,8 @@ citrus-code/
263
293
  │ │ └── recovery.py # 为缺失结果补充执行状态未知记录
264
294
  │ └── tools/ # 具体 coding 工具
265
295
  │ ├── factory.py # 默认、只读和全部工具组合
296
+ │ ├── operations.py # 文件、命令与搜索的接口
297
+ │ ├── local_files.py / local_shell.py / local_search.py
266
298
  │ ├── read.py / write.py
267
299
  │ ├── edit.py / edit_text.py
268
300
  │ ├── bash.py / powershell.py / shell.py
@@ -375,8 +407,9 @@ Windows 上如需使用 PowerShell,可添加 `--shell powershell`。上述示
375
407
  | 10 | `coding/tools/` | `coding_tools.py`、`coding_agent.py` | pi 风格工具、精确修改和输出控制 |
376
408
  | 11 | `coding/session/` | `session_storage.py` | 消息树、追加存储、加载与分支恢复 |
377
409
  | 12 | `coding/coding_session.py`、`coding/history.py`、`cli/` | `uv run citrus --fake` | 按事件保存、取消、消息队列与终端交互 |
378
- | 13 | `coding/project_context.py`、`coding/context_hooks.py`、`cli/prompts.py` | CLI 启动时显示项目指令来源 | 项目约定、系统提示组装与历史消息的区别 |
410
+ | 13 | `coding/project_context.py`、`coding/context_hooks.py`、`cli/prompts.py` | 在工作目录中添加 AGENTS.md 后启动 CLI | 项目约定、系统提示组装与历史消息的区别 |
379
411
  | 14 | `coding/compaction/`、`coding/session/projection.py`、`agent/context.py` | 见上下文压缩说明 | 安全边界、完整工具交互、摘要与原始历史的区别 |
412
+ | 15 | `coding/tools/operations.py`、`local_*.py`、`factory.py` | `tool_operations.py` | 工具定义与执行环境分离、Protocol 和可替换实现 |
380
413
 
381
414
  上表中的源码路径以 `src/citrus_code/` 为基准,示例路径以 `examples/` 为基准。早期原型位于 `examples/prototype/`,仅保留作学习对照,不作为当前版本入口。
382
415
 
@@ -389,6 +422,8 @@ Windows 上如需使用 PowerShell,可添加 `--shell powershell`。上述示
389
422
  - [x] Hooks:模型调用前后、工具执行前后的扩展点。
390
423
  - [x] 写入、精确编辑、搜索和命令执行工具。
391
424
  - [x] 工具输出截断与命令超时处理。
425
+ - [x] 文件、命令和搜索的可替换执行操作接口。
426
+ - [ ] 容器或其他隔离环境的执行适配器。
392
427
  - [x] Agent 主动取消与嵌套事件流关闭。
393
428
  - [ ] 工具增量事件。
394
429
  - [x] 第一版 CLI 交互与终端流式展示代码。
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/big-orange947/citrus-code/main/assets/citrus-banner.svg" alt="Citrus Code:绿叶像素橘子,参考 pi 的 Python coding agent" width="960">
3
+ </p>
4
+
1
5
  # Citrus Code
2
6
 
3
7
  **参考 pi 的核心设计,带初学者用 Python 一步步实现一个精简的 coding agent。**
@@ -27,6 +31,16 @@
27
31
 
28
32
  本项目是学习实现,并非 pi 的官方 Python 移植版,也不承诺与其接口和功能完全一致。
29
33
 
34
+ ## 终端界面与项目标识
35
+
36
+ Citrus Code 使用自己的绿叶像素橘子作为项目标识,README 和终端欢迎页采用同一角色与配色。
37
+ 可复用的透明背景图标位于 [assets/citrus-logo.svg](https://github.com/big-orange947/citrus-code/blob/main/assets/citrus-logo.svg),页首横幅位于
38
+ [assets/citrus-banner.svg](https://github.com/big-orange947/citrus-code/blob/main/assets/citrus-banner.svg)。终端角色直接由字符块绘制,运行时无需加载图片。
39
+
40
+ CLI 提供居中的像素欢迎页、随输入内容增高的输入栏、闪烁块状光标,以及输入 `/` 时展开的命令列表。
41
+ 任务运行中显示旋转指示,助手回复支持 Markdown 和代码高亮,工具执行结果以卡片呈现。
42
+ 欢迎页使用独立终端屏幕,首次发送任务或命令后返回普通终端对话;最近的缩放重绘调整仍待实际交互确认。
43
+
30
44
  ## 当前进度
31
45
 
32
46
  | 模块 | 当前能力 |
@@ -34,7 +48,7 @@
34
48
  | 消息类型 | 系统、用户、助手、工具结果消息;文字块、图片块与工具调用块 |
35
49
  | Provider 接口 | 统一模型信息和异步事件流,提供注册与查找 |
36
50
  | FakeProvider | 使用预设文字模拟流式响应,便于理解异步生成器 |
37
- | DeepSeek 接入 | HTTP 请求、SSE 读取、非思考模式、Flash 图片输入、用量统计和错误事件 |
51
+ | DeepSeek 接入 | HTTP 请求、SSE 读取、非思考模式、Flash 图片输入、临时错误退避重试、用量统计和错误事件 |
38
52
  | 工具调用解析 | 按索引拼接多个工具调用的参数,转换成统一工具事件 |
39
53
  | 生成截断处理 | 默认输出上限 32,768 tokens;回复被截断时不执行该回复中的工具调用 |
40
54
  | 完整回答接口 | `complete()` 消费事件流并返回最终助手消息 |
@@ -44,22 +58,25 @@
44
58
  | coding 工具 | read、write、edit、bash、powershell、grep、find、ls |
45
59
  | 输出控制 | read/命令输出按 2000 行或 50KB 截断;搜索工具提供数量和字节限制 |
46
60
  | 工具运行器 | 按名称查找工具,将执行结果或异常转换成工具结果消息 |
61
+ | 工具执行操作 | 文件、命令和搜索后端可替换;默认本机执行,尚未接入沙箱 |
47
62
  | 会话存储 | JSONL 追加保存、加载校验、消息树与分支选择、当前分支上下文重建 |
48
63
  | CodingSession | 按事件保存消息、安全切换分支、取消与排队消息入口 |
49
64
  | CLI | 交互与单次任务、流式输出、运行中追加消息、会话恢复、末尾缺失工具结果修复 |
50
- | 终端展示 | 原创字符 π 标识、欢迎面板、多行输入、状态栏、Markdown 流式预览、代码高亮和工具结果卡片 |
65
+ | 终端展示 | 原创像素块柑橘标识、简洁欢迎面板、横线输入栏与块状光标、命令提示列表、运行动画、Markdown 流式预览和工具结果卡片 |
51
66
  | 项目指令 | 启动时读取工作目录下的 AGENTS.md,通过模型前 Hook 组装当前请求 |
52
67
  | 上下文压缩 | 估计达到 600,000 tokens 时生成摘要,保留当前要求及近期完整交互,保存压缩记录 |
53
68
 
54
69
  工具名称、主要参数和默认组合参考 pi。实现细节、系统程序依赖、图片能力和取消处理的边界见 [工具说明](https://github.com/big-orange947/citrus-code/blob/main/docs/tools.md)。
55
70
 
71
+ 可替换执行操作的接口与使用方式见 [工具后端说明](https://github.com/big-orange947/citrus-code/blob/main/docs/tool-operations.md),从 `0.1.1` 起提供。
72
+
56
73
  会话存储的接口、文件格式和恢复边界见 [会话说明](https://github.com/big-orange947/citrus-code/blob/main/docs/sessions.md)。带自动保存的任务通过 `CodingSession.prompt()` 运行。命令入口见 [CLI 使用说明](https://github.com/big-orange947/citrus-code/blob/main/docs/cli.md)。
57
74
 
58
75
  自动压缩策略与失败边界见 [上下文压缩说明](https://github.com/big-orange947/citrus-code/blob/main/docs/compaction.md)。当前固定假设模型支持 1,000,000 tokens;估计达到 60% 时压缩。摘要调用会消耗模型额度。
59
76
 
60
77
  ## 快速开始
61
78
 
62
- 当前版本从源码安装,需要 Python 3.12 或更新版本,以及 [uv](https://docs.astral.sh/uv/)。
79
+ 要体验仓库中的最新功能,可以从源码安装,需要 Python 3.12 或更新版本,以及 [uv](https://docs.astral.sh/uv/)。
63
80
  先克隆仓库并安装锁定的依赖:
64
81
 
65
82
  ```powershell
@@ -84,7 +101,7 @@ uv run citrus --cwd "D:\project\your-project"
84
101
 
85
102
  ## 安装发行版
86
103
 
87
- 需要 Python 3.12 或更新版本。以下命令安装已经发布到 PyPI 的版本;首次发布前请使用上方的源码安装方式。安装包名为 `citrus-code`,启动命令为 `citrus`。
104
+ 需要 Python 3.12 或更新版本。以下命令从 PyPI 安装最新发行版。`0.1.1` 加入了新版界面、模型请求自动重试和可替换工具后端。安装包名为 `citrus-code`,启动命令为 `citrus`。
88
105
 
89
106
  使用 uv 安装:
90
107
 
@@ -101,6 +118,16 @@ pipx install citrus-code
101
118
  python -m pip install citrus-code
102
119
  ```
103
120
 
121
+ 已有安装可按自己的安装方式升级:
122
+
123
+ ```powershell
124
+ uv tool upgrade citrus-code
125
+ # 或
126
+ pipx upgrade citrus-code
127
+ # 或
128
+ python -m pip install --upgrade citrus-code
129
+ ```
130
+
104
131
  在 PowerShell 中配置自己的模型密钥并启动:
105
132
 
106
133
  ```powershell
@@ -135,6 +162,8 @@ uv run citrus --resume
135
162
 
136
163
  输入框中 Enter 发送,Alt+Enter 或 Ctrl+J 换行,Tab 补全命令。助手回答支持 Markdown;较长的工具结果默认只展示预览,使用 `--verbose` 启动可查看完整工具返回。升级依赖前先退出正在运行的旧 CLI,避免 Windows 锁住启动程序。
137
164
 
165
+ 输入 `/` 自动展开带说明的命令列表。输入区使用上下横线和闪烁块状光标,运行时持续显示旋转指示。模型临时断流、超时和部分 HTTP 错误最多自动重试 3 次,默认等待 2、4、8 秒;失败尝试的临时回复清除,已完成的工具结果保留,重试等待可用 Ctrl+C 取消。
166
+
138
167
  | 操作 | 命令或按键 |
139
168
  | --- | --- |
140
169
  | 查看帮助 | `/help` |
@@ -197,6 +226,7 @@ citrus --cwd "D:\project\poirot" --resume latest --max-output-tokens 65536
197
226
 
198
227
  ```text
199
228
  citrus-code/
229
+ ├── assets/ # 像素橘子图标与 README 品牌横幅
200
230
  ├── src/citrus_code/
201
231
  │ ├── config.py # 从环境变量加载连接配置
202
232
  │ ├── __main__.py # python -m citrus_code 入口
@@ -241,6 +271,8 @@ citrus-code/
241
271
  │ │ └── recovery.py # 为缺失结果补充执行状态未知记录
242
272
  │ └── tools/ # 具体 coding 工具
243
273
  │ ├── factory.py # 默认、只读和全部工具组合
274
+ │ ├── operations.py # 文件、命令与搜索的接口
275
+ │ ├── local_files.py / local_shell.py / local_search.py
244
276
  │ ├── read.py / write.py
245
277
  │ ├── edit.py / edit_text.py
246
278
  │ ├── bash.py / powershell.py / shell.py
@@ -353,8 +385,9 @@ Windows 上如需使用 PowerShell,可添加 `--shell powershell`。上述示
353
385
  | 10 | `coding/tools/` | `coding_tools.py`、`coding_agent.py` | pi 风格工具、精确修改和输出控制 |
354
386
  | 11 | `coding/session/` | `session_storage.py` | 消息树、追加存储、加载与分支恢复 |
355
387
  | 12 | `coding/coding_session.py`、`coding/history.py`、`cli/` | `uv run citrus --fake` | 按事件保存、取消、消息队列与终端交互 |
356
- | 13 | `coding/project_context.py`、`coding/context_hooks.py`、`cli/prompts.py` | CLI 启动时显示项目指令来源 | 项目约定、系统提示组装与历史消息的区别 |
388
+ | 13 | `coding/project_context.py`、`coding/context_hooks.py`、`cli/prompts.py` | 在工作目录中添加 AGENTS.md 后启动 CLI | 项目约定、系统提示组装与历史消息的区别 |
357
389
  | 14 | `coding/compaction/`、`coding/session/projection.py`、`agent/context.py` | 见上下文压缩说明 | 安全边界、完整工具交互、摘要与原始历史的区别 |
390
+ | 15 | `coding/tools/operations.py`、`local_*.py`、`factory.py` | `tool_operations.py` | 工具定义与执行环境分离、Protocol 和可替换实现 |
358
391
 
359
392
  上表中的源码路径以 `src/citrus_code/` 为基准,示例路径以 `examples/` 为基准。早期原型位于 `examples/prototype/`,仅保留作学习对照,不作为当前版本入口。
360
393
 
@@ -367,6 +400,8 @@ Windows 上如需使用 PowerShell,可添加 `--shell powershell`。上述示
367
400
  - [x] Hooks:模型调用前后、工具执行前后的扩展点。
368
401
  - [x] 写入、精确编辑、搜索和命令执行工具。
369
402
  - [x] 工具输出截断与命令超时处理。
403
+ - [x] 文件、命令和搜索的可替换执行操作接口。
404
+ - [ ] 容器或其他隔离环境的执行适配器。
370
405
  - [x] Agent 主动取消与嵌套事件流关闭。
371
406
  - [ ] 工具增量事件。
372
407
  - [x] 第一版 CLI 交互与终端流式展示代码。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "citrus-code"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  description = "A small Python coding agent inspired by pi, built as a learning project."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -62,6 +62,18 @@ class ErrorEvent(BaseModel):
62
62
  type: Literal["error"] = "error"
63
63
  reason: Literal["error", "aborted"]
64
64
  error: AssistantMessage
65
+ retryable: bool = False
66
+ retry_after_seconds: float | None = Field(default=None, ge=0)
67
+
68
+
69
+ class RetryEvent(BaseModel):
70
+ model_config = ConfigDict(extra="forbid")
71
+
72
+ type: Literal["retry"] = "retry"
73
+ attempt: int = Field(ge=1)
74
+ max_retries: int = Field(ge=1)
75
+ delay_seconds: float = Field(ge=0)
76
+ error_message: str
65
77
 
66
78
 
67
79
  AssistantMessageEvent = Annotated[
@@ -73,6 +85,7 @@ AssistantMessageEvent = Annotated[
73
85
  | ToolCallDeltaEvent
74
86
  | ToolCallEndEvent
75
87
  | DoneEvent
88
+ | RetryEvent
76
89
  | ErrorEvent,
77
90
  Field(discriminator="type"),
78
- ]
91
+ ]
@@ -44,6 +44,10 @@ from .tool_call_buffer import ToolCallBuffer
44
44
  from .chat_stream_types import ChatStreamChunk
45
45
 
46
46
 
47
+ class IncompleteStreamError(RuntimeError):
48
+ """The response ended before its required completion markers arrived."""
49
+
50
+
47
51
  def to_text(content: str | Iterable[TextContent | ImageContent]) -> str:
48
52
  if isinstance(content, str):
49
53
  return content
@@ -271,14 +275,14 @@ async def stream_chat_data(
271
275
 
272
276
  data_lines.append(value)
273
277
 
274
- raise RuntimeError(
278
+ raise IncompleteStreamError(
275
279
  "The chat stream ended without a [DONE] marker"
276
280
  )
277
281
 
278
282
  async def stream_assistant_events(
279
283
  model: ModelSpec,
280
284
  data_stream: AsyncIterator[str],
281
- ) -> AsyncIterator[AssistantMessageEvent]:
285
+ ) -> AsyncGenerator[AssistantMessageEvent, None]:
282
286
  message = AssistantMessage(
283
287
  content=[],
284
288
  api=model.api,
@@ -399,7 +403,7 @@ async def stream_assistant_events(
399
403
  finish_reason = choice.finish_reason
400
404
 
401
405
  if finish_reason is None:
402
- raise RuntimeError(
406
+ raise IncompleteStreamError(
403
407
  "The response ended without a finish reason"
404
408
  )
405
409
 
@@ -478,8 +482,26 @@ async def stream_assistant_events(
478
482
 
479
483
  except Exception as exc:
480
484
  message.stop_reason = "error"
485
+ retryable = isinstance(exc, (
486
+ httpx.TimeoutException, httpx.NetworkError,
487
+ httpx.RemoteProtocolError, IncompleteStreamError,
488
+ ))
489
+ retry_after: float | None = None
490
+ if isinstance(exc, httpx.HTTPStatusError):
491
+ retryable = exc.response.status_code in (408, 429, 500, 502, 503, 504)
492
+ header = exc.response.headers.get("Retry-After", "").strip()
493
+ if header.isascii() and header.isdigit():
494
+ retry_after = min(30.0, float(header))
481
495
  detail = str(exc).strip()
482
- if isinstance(exc, httpx.ReadTimeout):
496
+ if isinstance(exc, (httpx.RemoteProtocolError, IncompleteStreamError)):
497
+ detail = (
498
+ "模型接口的流式响应未完整接收,连接就已中断。"
499
+ "可能与服务端、代理或网络有关,不能仅凭此错误确定具体来源。"
500
+ "此前已完成的工具操作不受影响;本条未完整回复中的工具调用未执行。"
501
+ "可以继续当前会话,先检查实际文件状态,再接着完成任务。"
502
+ f" 原始错误:{detail or '服务端响应传输不完整。'}"
503
+ )
504
+ elif isinstance(exc, httpx.ReadTimeout):
483
505
  detail = (
484
506
  "等待模型响应数据超时(ReadTimeout)。"
485
507
  "这是模型接口读取超时,不代表上一条工具命令失败。"
@@ -496,6 +518,8 @@ async def stream_assistant_events(
496
518
  yield ErrorEvent(
497
519
  reason="error",
498
520
  error=message,
521
+ retryable=retryable,
522
+ retry_after_seconds=retry_after,
499
523
  )
500
524
 
501
525
  finally:
@@ -4,6 +4,7 @@ import httpx
4
4
  from pydantic import SecretStr
5
5
 
6
6
  from ..events import AssistantMessageEvent
7
+ from ..retry import stream_with_retries
7
8
  from ..types import (
8
9
  ImageContent, Message, ModelContext, ModelOptions, ModelSpec,
9
10
  TextContent, ToolResultMessage, UserMessage,
@@ -91,14 +92,11 @@ class DeepSeekProvider:
91
92
 
92
93
  body["thinking"] = {"type": "disabled"}
93
94
 
94
- data_stream = stream_chat_data(
95
- client=self._client,
96
- model=model,
97
- api_key=self._api_key,
98
- body=body,
99
- )
100
-
101
- return stream_assistant_events(
102
- model=model,
103
- data_stream=data_stream,
95
+ return stream_with_retries(
96
+ lambda: stream_assistant_events(
97
+ model=model,
98
+ data_stream=stream_chat_data(
99
+ client=self._client, model=model, api_key=self._api_key, body=body,
100
+ ),
101
+ ),
104
102
  )
@@ -0,0 +1,42 @@
1
+ """Retry an uncommitted model response; tool execution stays outside this layer."""
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncGenerator, Callable
5
+ from contextlib import aclosing
6
+
7
+ from .events import AssistantMessageEvent, DoneEvent, ErrorEvent, RetryEvent
8
+
9
+
10
+ async def stream_with_retries(
11
+ create_stream: Callable[[], AsyncGenerator[AssistantMessageEvent, None]],
12
+ *,
13
+ max_retries: int = 3,
14
+ ) -> AsyncGenerator[AssistantMessageEvent, None]:
15
+ if max_retries < 0:
16
+ raise ValueError("max_retries must not be negative")
17
+ for attempt in range(max_retries + 1):
18
+ failure: ErrorEvent | None = None
19
+ async with aclosing(create_stream()) as stream:
20
+ async for event in stream:
21
+ if isinstance(event, ErrorEvent):
22
+ failure = event
23
+ break
24
+ yield event
25
+ if isinstance(event, DoneEvent):
26
+ return
27
+
28
+ if failure is None:
29
+ raise RuntimeError("The model stream ended without a terminal event")
30
+ if not failure.retryable or failure.reason != "error" or attempt == max_retries:
31
+ yield failure
32
+ return
33
+
34
+ delay = min(30.0, max(2.0 ** (attempt + 1), failure.retry_after_seconds or 0.0))
35
+ yield RetryEvent(
36
+ attempt=attempt + 1,
37
+ max_retries=max_retries,
38
+ delay_seconds=delay,
39
+ error_message=failure.error.error_message or "模型请求暂时失败",
40
+ )
41
+ # Cancellation propagates, including while waiting between attempts.
42
+ await asyncio.sleep(delay)
@@ -0,0 +1,59 @@
1
+ """Slash command descriptions shared by help and input completion."""
2
+
3
+ from collections.abc import Iterable
4
+ from dataclasses import dataclass
5
+
6
+ from prompt_toolkit.completion import Completer, CompleteEvent, Completion
7
+ from prompt_toolkit.document import Document
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Command:
12
+ name: str
13
+ description: str
14
+ argument: str = ""
15
+
16
+ @property
17
+ def usage(self) -> str:
18
+ return f"{self.name} {self.argument}" if self.argument else self.name
19
+
20
+
21
+ COMMANDS = (
22
+ Command("/help", "显示帮助"),
23
+ Command("/status", "查看运行状态、模型和消息队列"),
24
+ Command("/history", "显示当前分支的对话"),
25
+ Command("/tree", "列出会话节点与当前分支"),
26
+ Command("/checkout", "空闲且无排队消息时切换分支,支持唯一 ID 前缀", "<节点ID>"),
27
+ Command("/steer", "向正在运行的 Agent 追加引导消息", "<消息>"),
28
+ Command("/follow", "排队,等待 Agent 正常完成后处理", "<消息>"),
29
+ Command("/clear-queue", "清空尚未接纳的排队消息"),
30
+ Command("/recover", "空闲且无排队消息时补齐缺失的工具结果"),
31
+ Command("/abort", "取消当前任务,等待收尾"),
32
+ Command("/quit", "取消当前任务并退出"),
33
+ Command("/exit", "退出,等同于 /quit"),
34
+ )
35
+
36
+
37
+ def is_command_input(document: Document) -> bool:
38
+ return (
39
+ document.text.startswith("/")
40
+ and document.cursor_position == len(document.text)
41
+ and not any(character.isspace() for character in document.text)
42
+ )
43
+
44
+
45
+ class SlashCommandCompleter(Completer):
46
+ def get_completions(
47
+ self, document: Document, complete_event: CompleteEvent,
48
+ ) -> Iterable[Completion]:
49
+ if not is_command_input(document):
50
+ return
51
+ prefix = document.text
52
+ for command in COMMANDS:
53
+ if command.name.startswith(prefix):
54
+ yield Completion(
55
+ command.name,
56
+ start_position=-len(prefix),
57
+ display=command.usage,
58
+ display_meta=command.description,
59
+ )
@@ -218,7 +218,8 @@ async def run_application(options: Options) -> int:
218
218
  raise ValueError("恢复时必须使用会话记录的模型,当前尚未提供模型切换入口")
219
219
  tools = create_tools(cwd, settings.tools)
220
220
  system_prompt = build_system_prompt(
221
- cwd, settings.tools, context_files, supports_images=model.supports_images,
221
+ cwd, settings.tools, context_files,
222
+ model_id=model.id, supports_images=model.supports_images,
222
223
  )
223
224
  if options.resume is None:
224
225
  manager.save(path)
@@ -233,7 +234,7 @@ async def run_application(options: Options) -> int:
233
234
  max_turns=options.max_turns,
234
235
  )
235
236
  console.welcome(
236
- cwd, model, settings.tools, path, [item.path for item in context_files],
237
+ cwd, model,
237
238
  fake=fake, resumed=options.resume is not None, interactive=options.prompt is None,
238
239
  )
239
240
  if recovered:
@@ -0,0 +1,41 @@
1
+ """Flush Rich transcript output while the prompt renderer is suspended."""
2
+
3
+ import asyncio
4
+ from io import StringIO
5
+ from typing import TextIO
6
+
7
+ from prompt_toolkit import PromptSession
8
+ from prompt_toolkit.application import run_in_terminal
9
+
10
+
11
+ class TerminalOutput(StringIO):
12
+ def __init__(self, original: TextIO, prompt: PromptSession[str]) -> None:
13
+ super().__init__()
14
+ self.original = original
15
+ self.prompt = prompt
16
+ self._drain_lock = asyncio.Lock()
17
+
18
+ @property
19
+ def encoding(self) -> str:
20
+ return self.original.encoding or "utf-8"
21
+
22
+ def isatty(self) -> bool:
23
+ return self.original.isatty()
24
+
25
+ # Keep StringIO.fileno unsupported: Rich must not bypass the buffer via
26
+ # the Windows console handle while prompt_toolkit is drawing the input.
27
+ async def drain(self) -> None:
28
+ async with self._drain_lock:
29
+ text = self.getvalue()
30
+ if not text:
31
+ return
32
+ self.seek(0)
33
+ self.truncate(0)
34
+
35
+ def write() -> None:
36
+ output = self.prompt.app.output
37
+ output.enable_autowrap()
38
+ output.write_raw(text)
39
+ output.flush()
40
+
41
+ await run_in_terminal(write)