nanocode-cli 0.7.2__tar.gz → 0.8.0__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.
- {nanocode_cli-0.7.2/nanocode_cli.egg-info → nanocode_cli-0.8.0}/PKG-INFO +40 -3
- {nanocode_cli-0.7.2 → nanocode_cli-0.8.0}/README.md +39 -2
- {nanocode_cli-0.7.2 → nanocode_cli-0.8.0}/README.zh-CN.md +39 -2
- {nanocode_cli-0.7.2 → nanocode_cli-0.8.0}/nanocode.py +579 -26
- {nanocode_cli-0.7.2 → nanocode_cli-0.8.0/nanocode_cli.egg-info}/PKG-INFO +40 -3
- {nanocode_cli-0.7.2 → nanocode_cli-0.8.0}/pyproject.toml +1 -1
- {nanocode_cli-0.7.2 → nanocode_cli-0.8.0}/LICENSE +0 -0
- {nanocode_cli-0.7.2 → nanocode_cli-0.8.0}/MANIFEST.in +0 -0
- {nanocode_cli-0.7.2 → nanocode_cli-0.8.0}/nanocode_cli.egg-info/SOURCES.txt +0 -0
- {nanocode_cli-0.7.2 → nanocode_cli-0.8.0}/nanocode_cli.egg-info/dependency_links.txt +0 -0
- {nanocode_cli-0.7.2 → nanocode_cli-0.8.0}/nanocode_cli.egg-info/entry_points.txt +0 -0
- {nanocode_cli-0.7.2 → nanocode_cli-0.8.0}/nanocode_cli.egg-info/requires.txt +0 -0
- {nanocode_cli-0.7.2 → nanocode_cli-0.8.0}/nanocode_cli.egg-info/top_level.txt +0 -0
- {nanocode_cli-0.7.2 → nanocode_cli-0.8.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: A small terminal coding agent written in Python
|
|
5
5
|
Author-email: hit9 <hit9@icloud.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -131,6 +131,8 @@ Commands:
|
|
|
131
131
|
|
|
132
132
|
- `/help`: show commands and tools.
|
|
133
133
|
- `/status`: show runtime status, including the active session id.
|
|
134
|
+
- `/context [PATH]`: show the model's context frame — environment, memory (goal, plan, known facts, check), and file state; `PATH` shows that file's current in-context lines.
|
|
135
|
+
- `/skills`: list installed skills (load with `Skill(name)` or reference inline with `$name`).
|
|
134
136
|
- `/config`: show active config.
|
|
135
137
|
- `/api [auto|chat|anthropic]`: show or set provider API format.
|
|
136
138
|
- `/debug [on|off]`: toggle model I/O debug traces.
|
|
@@ -140,6 +142,7 @@ Commands:
|
|
|
140
142
|
- `/provider [NAME]`: show or set provider.
|
|
141
143
|
- `/model [MODEL]`: show or set model.
|
|
142
144
|
- `/reason`: choose reasoning effort.
|
|
145
|
+
- `/strict`: toggle strict tool-call schemas (OpenAI / DeepSeek only).
|
|
143
146
|
- `/set KEY VALUE`: set supported provider/runtime values for the current session.
|
|
144
147
|
- `/yolo`: toggle tool confirmations.
|
|
145
148
|
- `/exit`, `/quit`: exit.
|
|
@@ -156,6 +159,7 @@ Tools:
|
|
|
156
159
|
- Working notes: `Note`.
|
|
157
160
|
- Ask the user: `Question`.
|
|
158
161
|
- MCP: `MCP`.
|
|
162
|
+
- Skills: `Skill` loads a skill's full instructions on demand (offered whenever any skill exists — the built-in `nanocode-help` means it is normally always available).
|
|
159
163
|
|
|
160
164
|
`Read`, `Search`, and `InspectCode` return line anchors where useful. `Edit` uses current `line:hash` anchors to reject stale edits.
|
|
161
165
|
|
|
@@ -170,12 +174,14 @@ Default config location:
|
|
|
170
174
|
Main fields:
|
|
171
175
|
|
|
172
176
|
- `[provider] active = "name"`
|
|
173
|
-
- `[provider.<name>]`: `url`, `key`, `model`, `api`, `prompt_cache_key`, `available_models`, `reasoning`, `chat_reasoning`, `temperature`, `timeout`
|
|
177
|
+
- `[provider.<name>]`: `url`, `key`, `model`, `api`, `prompt_cache_key`, `available_models`, `reasoning`, `chat_reasoning`, `temperature`, `max_tokens`, `strict_tools`, `timeout`
|
|
174
178
|
- `[paths] data_dir`
|
|
175
|
-
- `[runtime] shell_timeout`, `max_agent_steps`, `max_context_tokens`, `check_updates`, `update_check_interval_hours`, `session_retention_days`, `yolo`, `debug`
|
|
179
|
+
- `[runtime] shell_timeout`, `max_agent_steps`, `max_context_tokens`, `max_parallel_tools`, `check_updates`, `update_check_interval_hours`, `session_retention_days`, `yolo`, `debug`, `tips`
|
|
176
180
|
|
|
177
181
|
`api = "auto"` chooses between Chat Completions and Anthropic Messages using provider/model profiles. `prompt_cache_key = "auto"` derives a stable key from provider, model, workspace, and tool schema names.
|
|
178
182
|
|
|
183
|
+
`strict_tools = true` (toggle with `/strict`) constrains tool-call arguments to each tool's JSON schema. It only takes effect on hosts that support it (OpenAI and DeepSeek) and on the Chat Completions path; it is a no-op elsewhere. For DeepSeek, enabling it routes requests to the `/beta` endpoint. Tools whose schemas can't be represented under strict function calling fall back to non-strict automatically.
|
|
184
|
+
|
|
179
185
|
Runtime flags such as `--yolo`, `--debug`, and `--mcp` apply to resumed sessions too. Saved sessions do not carry their old runtime config forward.
|
|
180
186
|
|
|
181
187
|
## MCP
|
|
@@ -215,6 +221,37 @@ Manage servers at runtime:
|
|
|
215
221
|
- `/mcp refresh [server]`: rediscover servers.
|
|
216
222
|
- `/mcp login <server>` / `/mcp logout <server>`: OAuth login and logout.
|
|
217
223
|
|
|
224
|
+
## Skills
|
|
225
|
+
|
|
226
|
+
Skills are reusable instruction packs the agent can pull in on demand. Each skill is a folder with a `SKILL.md`:
|
|
227
|
+
|
|
228
|
+
```text
|
|
229
|
+
.nanocode/skills/ # project skills (travel with the repo)
|
|
230
|
+
release-notes/
|
|
231
|
+
SKILL.md
|
|
232
|
+
scripts/
|
|
233
|
+
collect_commits.py
|
|
234
|
+
~/.nanocode/skills/ # personal skills (all projects)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`SKILL.md` has `name`/`description` frontmatter and a Markdown body of instructions:
|
|
238
|
+
|
|
239
|
+
```markdown
|
|
240
|
+
---
|
|
241
|
+
name: release-notes
|
|
242
|
+
description: Draft a CHANGELOG entry from commits since the last release.
|
|
243
|
+
---
|
|
244
|
+
Run `python "{skill_dir}/scripts/collect_commits.py" <last-tag>` to gather commits,
|
|
245
|
+
then group them by type and write entries in the house style.
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
- **Discovery**: `.nanocode/skills/` (project) and `~/.nanocode/skills/` (user). On a name clash the project skill wins.
|
|
249
|
+
- **How the model sees them**: only a compact `SKILLS` index (name + description) sits in context; the full body is loaded on demand when the model calls `Skill(name)`. A repeated load of the same skill collapses to a pointer so the instructions are not re-billed. When no skills are installed, nothing is added to the prompt.
|
|
250
|
+
- **Reference one inline**: type `$name` in your message (Tab-completes) to nudge the model to use that skill; its instructions are injected for that turn.
|
|
251
|
+
- **Bundled scripts**: `{skill_dir}` (or `${SKILL_DIR}`) in the body expands to the skill's absolute folder path, so the model can run bundled scripts via `Bash` (subject to normal confirmation unless `/yolo`).
|
|
252
|
+
- **Inspect**: `/skills` lists installed skills; the status bar and `/status` show the count.
|
|
253
|
+
- **Built-in**: a `nanocode-help` skill ships by default and carries a self-contained manual — authored prose on how to use nanocode, its features, and common problems, plus command/tool/config lists assembled from nanocode's own `/help` text, tool descriptions, and config keys. So "how do I / what does X / why is Y" questions are answered from the manual without searching the source, and the lists can't drift from the running version. Drop a `nanocode-help` skill of your own to override it.
|
|
254
|
+
|
|
218
255
|
## Providers
|
|
219
256
|
|
|
220
257
|
The following providers have been tested with nanocode:
|
|
@@ -94,6 +94,8 @@ Commands:
|
|
|
94
94
|
|
|
95
95
|
- `/help`: show commands and tools.
|
|
96
96
|
- `/status`: show runtime status, including the active session id.
|
|
97
|
+
- `/context [PATH]`: show the model's context frame — environment, memory (goal, plan, known facts, check), and file state; `PATH` shows that file's current in-context lines.
|
|
98
|
+
- `/skills`: list installed skills (load with `Skill(name)` or reference inline with `$name`).
|
|
97
99
|
- `/config`: show active config.
|
|
98
100
|
- `/api [auto|chat|anthropic]`: show or set provider API format.
|
|
99
101
|
- `/debug [on|off]`: toggle model I/O debug traces.
|
|
@@ -103,6 +105,7 @@ Commands:
|
|
|
103
105
|
- `/provider [NAME]`: show or set provider.
|
|
104
106
|
- `/model [MODEL]`: show or set model.
|
|
105
107
|
- `/reason`: choose reasoning effort.
|
|
108
|
+
- `/strict`: toggle strict tool-call schemas (OpenAI / DeepSeek only).
|
|
106
109
|
- `/set KEY VALUE`: set supported provider/runtime values for the current session.
|
|
107
110
|
- `/yolo`: toggle tool confirmations.
|
|
108
111
|
- `/exit`, `/quit`: exit.
|
|
@@ -119,6 +122,7 @@ Tools:
|
|
|
119
122
|
- Working notes: `Note`.
|
|
120
123
|
- Ask the user: `Question`.
|
|
121
124
|
- MCP: `MCP`.
|
|
125
|
+
- Skills: `Skill` loads a skill's full instructions on demand (offered whenever any skill exists — the built-in `nanocode-help` means it is normally always available).
|
|
122
126
|
|
|
123
127
|
`Read`, `Search`, and `InspectCode` return line anchors where useful. `Edit` uses current `line:hash` anchors to reject stale edits.
|
|
124
128
|
|
|
@@ -133,12 +137,14 @@ Default config location:
|
|
|
133
137
|
Main fields:
|
|
134
138
|
|
|
135
139
|
- `[provider] active = "name"`
|
|
136
|
-
- `[provider.<name>]`: `url`, `key`, `model`, `api`, `prompt_cache_key`, `available_models`, `reasoning`, `chat_reasoning`, `temperature`, `timeout`
|
|
140
|
+
- `[provider.<name>]`: `url`, `key`, `model`, `api`, `prompt_cache_key`, `available_models`, `reasoning`, `chat_reasoning`, `temperature`, `max_tokens`, `strict_tools`, `timeout`
|
|
137
141
|
- `[paths] data_dir`
|
|
138
|
-
- `[runtime] shell_timeout`, `max_agent_steps`, `max_context_tokens`, `check_updates`, `update_check_interval_hours`, `session_retention_days`, `yolo`, `debug`
|
|
142
|
+
- `[runtime] shell_timeout`, `max_agent_steps`, `max_context_tokens`, `max_parallel_tools`, `check_updates`, `update_check_interval_hours`, `session_retention_days`, `yolo`, `debug`, `tips`
|
|
139
143
|
|
|
140
144
|
`api = "auto"` chooses between Chat Completions and Anthropic Messages using provider/model profiles. `prompt_cache_key = "auto"` derives a stable key from provider, model, workspace, and tool schema names.
|
|
141
145
|
|
|
146
|
+
`strict_tools = true` (toggle with `/strict`) constrains tool-call arguments to each tool's JSON schema. It only takes effect on hosts that support it (OpenAI and DeepSeek) and on the Chat Completions path; it is a no-op elsewhere. For DeepSeek, enabling it routes requests to the `/beta` endpoint. Tools whose schemas can't be represented under strict function calling fall back to non-strict automatically.
|
|
147
|
+
|
|
142
148
|
Runtime flags such as `--yolo`, `--debug`, and `--mcp` apply to resumed sessions too. Saved sessions do not carry their old runtime config forward.
|
|
143
149
|
|
|
144
150
|
## MCP
|
|
@@ -178,6 +184,37 @@ Manage servers at runtime:
|
|
|
178
184
|
- `/mcp refresh [server]`: rediscover servers.
|
|
179
185
|
- `/mcp login <server>` / `/mcp logout <server>`: OAuth login and logout.
|
|
180
186
|
|
|
187
|
+
## Skills
|
|
188
|
+
|
|
189
|
+
Skills are reusable instruction packs the agent can pull in on demand. Each skill is a folder with a `SKILL.md`:
|
|
190
|
+
|
|
191
|
+
```text
|
|
192
|
+
.nanocode/skills/ # project skills (travel with the repo)
|
|
193
|
+
release-notes/
|
|
194
|
+
SKILL.md
|
|
195
|
+
scripts/
|
|
196
|
+
collect_commits.py
|
|
197
|
+
~/.nanocode/skills/ # personal skills (all projects)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`SKILL.md` has `name`/`description` frontmatter and a Markdown body of instructions:
|
|
201
|
+
|
|
202
|
+
```markdown
|
|
203
|
+
---
|
|
204
|
+
name: release-notes
|
|
205
|
+
description: Draft a CHANGELOG entry from commits since the last release.
|
|
206
|
+
---
|
|
207
|
+
Run `python "{skill_dir}/scripts/collect_commits.py" <last-tag>` to gather commits,
|
|
208
|
+
then group them by type and write entries in the house style.
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- **Discovery**: `.nanocode/skills/` (project) and `~/.nanocode/skills/` (user). On a name clash the project skill wins.
|
|
212
|
+
- **How the model sees them**: only a compact `SKILLS` index (name + description) sits in context; the full body is loaded on demand when the model calls `Skill(name)`. A repeated load of the same skill collapses to a pointer so the instructions are not re-billed. When no skills are installed, nothing is added to the prompt.
|
|
213
|
+
- **Reference one inline**: type `$name` in your message (Tab-completes) to nudge the model to use that skill; its instructions are injected for that turn.
|
|
214
|
+
- **Bundled scripts**: `{skill_dir}` (or `${SKILL_DIR}`) in the body expands to the skill's absolute folder path, so the model can run bundled scripts via `Bash` (subject to normal confirmation unless `/yolo`).
|
|
215
|
+
- **Inspect**: `/skills` lists installed skills; the status bar and `/status` show the count.
|
|
216
|
+
- **Built-in**: a `nanocode-help` skill ships by default and carries a self-contained manual — authored prose on how to use nanocode, its features, and common problems, plus command/tool/config lists assembled from nanocode's own `/help` text, tool descriptions, and config keys. So "how do I / what does X / why is Y" questions are answered from the manual without searching the source, and the lists can't drift from the running version. Drop a `nanocode-help` skill of your own to override it.
|
|
217
|
+
|
|
181
218
|
## Providers
|
|
182
219
|
|
|
183
220
|
The following providers have been tested with nanocode:
|
|
@@ -94,6 +94,8 @@ nanocode --resume last
|
|
|
94
94
|
|
|
95
95
|
- `/help`:显示命令和工具。
|
|
96
96
|
- `/status`:显示运行状态,包括当前 session id。
|
|
97
|
+
- `/context [PATH]`:显示模型的上下文帧——环境、记忆(goal、plan、known facts、check)和文件状态;`PATH` 显示该文件当前在上下文中的行。
|
|
98
|
+
- `/skills`:列出已安装的 skills(用 `Skill(name)` 加载,或在消息中用 `$name` 引用)。
|
|
97
99
|
- `/config`:显示当前配置。
|
|
98
100
|
- `/api [auto|chat|anthropic]`:显示或设置 provider API 格式。
|
|
99
101
|
- `/debug [on|off]`:切换模型 I/O debug trace。
|
|
@@ -103,6 +105,7 @@ nanocode --resume last
|
|
|
103
105
|
- `/provider [NAME]`:显示或设置 provider。
|
|
104
106
|
- `/model [MODEL]`:显示或设置模型。
|
|
105
107
|
- `/reason`:选择 reasoning effort。
|
|
108
|
+
- `/strict`:切换严格工具调用 schema(仅 OpenAI / DeepSeek)。
|
|
106
109
|
- `/set KEY VALUE`:设置当前 session 支持的 provider/runtime 值。
|
|
107
110
|
- `/yolo`:切换工具确认。
|
|
108
111
|
- `/exit`, `/quit`:退出。
|
|
@@ -119,6 +122,7 @@ nanocode --resume last
|
|
|
119
122
|
- 工作笔记:`Note`。
|
|
120
123
|
- 询问用户:`Question`。
|
|
121
124
|
- MCP:`MCP`。
|
|
125
|
+
- Skills:`Skill` 按需加载某个 skill 的完整说明(只要存在任一 skill 即提供——内置的 `nanocode-help` 意味着它通常始终可用)。
|
|
122
126
|
|
|
123
127
|
`Read`、`Search` 和 `InspectCode` 会在合适时返回行锚点。`Edit` 使用当前 `line:hash` 锚点拒绝过期编辑。
|
|
124
128
|
|
|
@@ -133,12 +137,14 @@ nanocode --resume last
|
|
|
133
137
|
主要字段:
|
|
134
138
|
|
|
135
139
|
- `[provider] active = "name"`
|
|
136
|
-
- `[provider.<name>]`:`url`, `key`, `model`, `api`, `prompt_cache_key`, `available_models`, `reasoning`, `chat_reasoning`, `temperature`, `timeout`
|
|
140
|
+
- `[provider.<name>]`:`url`, `key`, `model`, `api`, `prompt_cache_key`, `available_models`, `reasoning`, `chat_reasoning`, `temperature`, `max_tokens`, `strict_tools`, `timeout`
|
|
137
141
|
- `[paths] data_dir`
|
|
138
|
-
- `[runtime] shell_timeout`, `max_agent_steps`, `max_context_tokens`, `check_updates`, `update_check_interval_hours`, `session_retention_days`, `yolo`, `debug`
|
|
142
|
+
- `[runtime] shell_timeout`, `max_agent_steps`, `max_context_tokens`, `max_parallel_tools`, `check_updates`, `update_check_interval_hours`, `session_retention_days`, `yolo`, `debug`, `tips`
|
|
139
143
|
|
|
140
144
|
`api = "auto"` 会根据 provider/model profile 在 Chat Completions 和 Anthropic Messages 之间选择。`prompt_cache_key = "auto"` 会根据 provider、model、workspace 和工具 schema 名称生成稳定 key。
|
|
141
145
|
|
|
146
|
+
`strict_tools = true`(用 `/strict` 切换)会将工具调用参数约束到每个工具的 JSON schema。它仅在支持该特性的 host(OpenAI 和 DeepSeek)以及 Chat Completions 路径上生效,其他情况下为空操作。对 DeepSeek,启用后请求会路由到 `/beta` 端点。无法在严格函数调用下表示的工具 schema 会自动回退到非严格模式。
|
|
147
|
+
|
|
142
148
|
`--yolo`、`--debug` 和 `--mcp` 等 runtime flags 对恢复的 session 同样生效。保存的 session 不会携带旧 runtime config。
|
|
143
149
|
|
|
144
150
|
## MCP
|
|
@@ -178,6 +184,37 @@ HTTP 鉴权选项(`auth`、`bearer_token_env_var`、`env_http_headers`)只
|
|
|
178
184
|
- `/mcp refresh [server]`:重新发现服务器。
|
|
179
185
|
- `/mcp login <server>` / `/mcp logout <server>`:OAuth 登录和登出。
|
|
180
186
|
|
|
187
|
+
## Skills
|
|
188
|
+
|
|
189
|
+
Skills 是可复用的指令包,agent 可按需加载。每个 skill 是一个包含 `SKILL.md` 的文件夹:
|
|
190
|
+
|
|
191
|
+
```text
|
|
192
|
+
.nanocode/skills/ # 项目 skills(随仓库一起提交)
|
|
193
|
+
release-notes/
|
|
194
|
+
SKILL.md
|
|
195
|
+
scripts/
|
|
196
|
+
collect_commits.py
|
|
197
|
+
~/.nanocode/skills/ # 个人 skills(对所有项目生效)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`SKILL.md` 带有 `name`/`description` 前置元数据,正文是 Markdown 指令:
|
|
201
|
+
|
|
202
|
+
```markdown
|
|
203
|
+
---
|
|
204
|
+
name: release-notes
|
|
205
|
+
description: Draft a CHANGELOG entry from commits since the last release.
|
|
206
|
+
---
|
|
207
|
+
运行 `python "{skill_dir}/scripts/collect_commits.py" <last-tag>` 收集提交,
|
|
208
|
+
再按类型分组并以项目既有风格撰写条目。
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- **发现路径**:`.nanocode/skills/`(项目)和 `~/.nanocode/skills/`(用户)。同名时项目 skill 优先。
|
|
212
|
+
- **模型如何看到**:上下文中只放一个精简的 `SKILLS` 索引(name + description);完整正文仅在模型调用 `Skill(name)` 时按需加载。对同一 skill 的重复加载会折叠为一行指针,避免重复计费。未安装任何 skill 时不会向 prompt 添加内容。
|
|
213
|
+
- **内联引用**:在消息中输入 `$name`(支持 Tab 补全)以提示模型使用该 skill;其指令会为该轮注入。
|
|
214
|
+
- **随附脚本**:正文中的 `{skill_dir}`(或 `${SKILL_DIR}`)会展开为该 skill 的绝对目录路径,模型可通过 `Bash` 运行随附脚本(除非 `/yolo`,否则仍需正常确认)。
|
|
215
|
+
- **查看**:`/skills` 列出已安装的 skills;状态栏和 `/status` 会显示数量。
|
|
216
|
+
- **内置**:默认自带 `nanocode-help` skill,其正文是一份自包含手册——包含关于如何使用 nanocode、其功能和常见问题的成文说明,外加由 nanocode 自身的 `/help` 文本、工具描述和配置项在加载时拼装而成的命令/工具/配置清单。因此“怎么用 / X 是什么 / 为什么 Y”这类问题可直接从手册回答,无需检索源码,且清单不会与运行版本脱节。放置同名 `nanocode-help` skill 即可覆盖它。
|
|
217
|
+
|
|
181
218
|
## Providers
|
|
182
219
|
|
|
183
220
|
以下 provider 已在 nanocode 中测试通过:
|
|
@@ -45,7 +45,7 @@ from prompt_toolkit.buffer import Buffer
|
|
|
45
45
|
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
|
46
46
|
from prompt_toolkit.document import Document
|
|
47
47
|
from prompt_toolkit.filters import Condition, has_completions, is_done
|
|
48
|
-
from prompt_toolkit.formatted_text import ANSI, FormattedText
|
|
48
|
+
from prompt_toolkit.formatted_text import ANSI, FormattedText, to_formatted_text
|
|
49
49
|
from prompt_toolkit.history import FileHistory
|
|
50
50
|
from prompt_toolkit.key_binding import KeyBindings
|
|
51
51
|
from prompt_toolkit.keys import Keys
|
|
@@ -63,7 +63,7 @@ from rich.console import Console
|
|
|
63
63
|
from rich.markdown import Markdown
|
|
64
64
|
from rich.rule import Rule
|
|
65
65
|
|
|
66
|
-
__version__ = "0.
|
|
66
|
+
__version__ = "0.8.0"
|
|
67
67
|
|
|
68
68
|
Json = dict[str, Any]
|
|
69
69
|
HTTP_USER_AGENT = "nanocode/" + __version__
|
|
@@ -538,6 +538,9 @@ class AgentState:
|
|
|
538
538
|
current_model_call_started_at: float = 0.0
|
|
539
539
|
manual_model_retry_requested: bool = False
|
|
540
540
|
model_retry_count: int = 0
|
|
541
|
+
compaction_count: int = 0
|
|
542
|
+
prefix_fingerprint: str = ""
|
|
543
|
+
prefix_fingerprints: list[str] = field(default_factory=list)
|
|
541
544
|
|
|
542
545
|
def __post_init__(self) -> None:
|
|
543
546
|
self.plan = self.plan_items(self.plan)
|
|
@@ -854,6 +857,9 @@ class SessionSnapshotCodec:
|
|
|
854
857
|
"known": state.known,
|
|
855
858
|
"check": state.check,
|
|
856
859
|
"summary": state.summary,
|
|
860
|
+
"compaction_count": state.compaction_count,
|
|
861
|
+
"prefix_fingerprint": state.prefix_fingerprint,
|
|
862
|
+
"prefix_fingerprints": state.prefix_fingerprints,
|
|
857
863
|
}
|
|
858
864
|
|
|
859
865
|
@staticmethod
|
|
@@ -1100,6 +1106,200 @@ class SessionSnapshotStore:
|
|
|
1100
1106
|
return os.path.abspath(os.path.join(os.path.expanduser(data_dir), *parts))
|
|
1101
1107
|
|
|
1102
1108
|
|
|
1109
|
+
@dataclass
|
|
1110
|
+
class Skill:
|
|
1111
|
+
name: str
|
|
1112
|
+
description: str
|
|
1113
|
+
body: str
|
|
1114
|
+
dir: str
|
|
1115
|
+
source: str # "project" or "user"
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
class SkillLibrary:
|
|
1119
|
+
"""Skills discovered from `.nanocode/skills/<name>/SKILL.md` (project) and the user data dir.
|
|
1120
|
+
|
|
1121
|
+
Each skill is a Markdown file with `name`/`description` frontmatter; the index (name + description)
|
|
1122
|
+
rides the cache-stable prefix so the model knows what exists, and the full body is pulled into the
|
|
1123
|
+
conversation only when the model calls Skill(name) or the user references it with `$name`."""
|
|
1124
|
+
|
|
1125
|
+
FRONTMATTER = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL)
|
|
1126
|
+
META_LINE = re.compile(r"^([A-Za-z0-9_-]+):[ \t]*(.*)$", re.MULTILINE)
|
|
1127
|
+
MENTION_PATTERN = re.compile(r"(?<![A-Za-z0-9_])\$([A-Za-z0-9_-]+)")
|
|
1128
|
+
|
|
1129
|
+
# Authored manual behind the built-in `nanocode-help` skill. Explains concepts, workflows, and
|
|
1130
|
+
# common problems — the prose a "how do I / why does" question needs, which the auto-generated
|
|
1131
|
+
# command/tool/key lists (appended in builtins) cannot supply. Kept broader than `/help`.
|
|
1132
|
+
MANUAL = """\
|
|
1133
|
+
# nanocode manual
|
|
1134
|
+
|
|
1135
|
+
nanocode is a concise, single-file terminal coding agent. You describe a task; it plans, calls
|
|
1136
|
+
tools in a loop (read files, search, edit, run commands), and returns a short answer. Assistant
|
|
1137
|
+
text is user-visible markdown in your language.
|
|
1138
|
+
|
|
1139
|
+
## Getting started
|
|
1140
|
+
- Config lives at `~/.nanocode/config.toml`. At minimum set `provider.url`, `provider.key`, and
|
|
1141
|
+
`provider.model`. `/status` and startup warn when these are missing.
|
|
1142
|
+
- View config with `/config`; change most values for the session with `/set KEY VALUE`.
|
|
1143
|
+
- Pick provider/model/effort at runtime with `/provider`, `/model`, `/reason`.
|
|
1144
|
+
|
|
1145
|
+
## How the agent works
|
|
1146
|
+
- It acts when the task is clear and keeps using tools until done or blocked, up to
|
|
1147
|
+
`runtime.max_agent_steps`. It does not repeat a failed call unchanged; tool errors come back as
|
|
1148
|
+
results so it can self-correct.
|
|
1149
|
+
- Read-only tools in one batch run concurrently (`runtime.max_parallel_tools`); edits and shell run
|
|
1150
|
+
serially. It keeps working notes (goal/plan/known/check) via the `Note` tool, shown in `/context`.
|
|
1151
|
+
- It answers concisely by default and notes which files changed and which checks it ran (or did not).
|
|
1152
|
+
|
|
1153
|
+
## Context model
|
|
1154
|
+
Each request is a cache-stable prefix (system prompt, environment, the SKILLS index, the MCP tools
|
|
1155
|
+
index, and tool schemas) followed by the conversation, then the `Memory` and `FILE STATE` sections.
|
|
1156
|
+
`Read`/`Edit` refresh `FILE STATE`. Prompt caching depends on that prefix staying byte-identical;
|
|
1157
|
+
`/status` shows context %, cache hit rate, a `prefix churn` warning if the prefix mutated mid-session
|
|
1158
|
+
(inspect with `--debug`, label `cache-prefix-drift`), and a compaction count. Long conversations are
|
|
1159
|
+
compacted automatically; `/compact` forces it. Inspect the whole frame with `/context` (tabbed:
|
|
1160
|
+
Environment / Memory / File State); `/context <path>` shows a file's current in-context lines.
|
|
1161
|
+
|
|
1162
|
+
## Sessions
|
|
1163
|
+
Sessions auto-save. Resume the latest with `--resume` (or `--resume <UID>` for a specific one).
|
|
1164
|
+
|
|
1165
|
+
## Providers, models, reasoning
|
|
1166
|
+
Configure `provider.*` per provider. `/reason` sets reasoning effort; `provider.max_tokens`,
|
|
1167
|
+
`provider.temperature`, and `provider.api` (auto/chat/anthropic) tune requests. `/strict` (or
|
|
1168
|
+
`provider.strict_tools`) constrains tool-call arguments to each tool's schema on hosts that support
|
|
1169
|
+
it (OpenAI, DeepSeek). Native thinking modes (DeepSeek/Qwen) drop `temperature` automatically.
|
|
1170
|
+
|
|
1171
|
+
## MCP
|
|
1172
|
+
Configure external tools under `[mcp.<name>]` (either `url` or `command`). Manage with `/mcp`,
|
|
1173
|
+
`/mcp tools`, `/mcp refresh`, `/mcp login|logout`. Reference a server/tool inline with `@server.tool`
|
|
1174
|
+
to pull its schema into the turn. The `MCP` tool invokes them.
|
|
1175
|
+
|
|
1176
|
+
## Skills
|
|
1177
|
+
Skills are reusable instruction packs under `.nanocode/skills/<name>/SKILL.md` (project) and
|
|
1178
|
+
`~/.nanocode/skills/<name>/SKILL.md` (user; project wins on name clash). The model loads one with
|
|
1179
|
+
`Skill(name)`; you can reference one inline with `$name` to load it for a turn. A skill-directory
|
|
1180
|
+
placeholder in the body expands to the skill's absolute folder so bundled scripts run via `Bash`.
|
|
1181
|
+
`/skills` lists them;
|
|
1182
|
+
the status bar and `/status` show the count. This manual is the built-in `nanocode-help` skill.
|
|
1183
|
+
|
|
1184
|
+
## Safety
|
|
1185
|
+
Mutating actions (`Edit`, `Bash`, writing `Git`) ask for confirmation unless `/yolo` is on. nanocode
|
|
1186
|
+
will not switch, create, or delete git branches unless asked, and checks the branch before committing.
|
|
1187
|
+
|
|
1188
|
+
## Troubleshooting
|
|
1189
|
+
- "missing config": set `provider.url`/`key`/`model` via `/set` or `config.toml`.
|
|
1190
|
+
- Slow or costly turns / low cache hit rate: check `/status`; a `prefix churn` warning means the
|
|
1191
|
+
cached prefix changed mid-session — see the `--debug` cache-prefix-drift diff.
|
|
1192
|
+
- InspectCode unavailable or stale symbols: run `/index` to sync or rebuild the code index.
|
|
1193
|
+
- Context filling up: it compacts automatically; `/compact` forces it now.
|
|
1194
|
+
- A command typed while the agent works is refused unless it is read-only (`/help`, `/status`,
|
|
1195
|
+
`/context`, `/skills`, read-only `/mcp`) or `/yolo`; press Ctrl-C to run others."""
|
|
1196
|
+
|
|
1197
|
+
def __init__(self, skills: dict[str, Skill]):
|
|
1198
|
+
self.skills = skills
|
|
1199
|
+
|
|
1200
|
+
@classmethod
|
|
1201
|
+
def load(cls, session: "Session") -> "SkillLibrary":
|
|
1202
|
+
# Built-ins seed the library first; a user/project skill of the same name overrides them.
|
|
1203
|
+
skills: dict[str, Skill] = {skill.name: skill for skill in cls.builtins()}
|
|
1204
|
+
# User skills load before project skills so a project skill of the same name overrides them.
|
|
1205
|
+
for root, source in ((session.data_path("skills"), "user"), (os.path.join(session.cwd, ".nanocode", "skills"), "project")):
|
|
1206
|
+
if not os.path.isdir(root):
|
|
1207
|
+
continue
|
|
1208
|
+
for entry in sorted(os.listdir(root)):
|
|
1209
|
+
skill = cls.parse(os.path.join(root, entry, "SKILL.md"), entry, source)
|
|
1210
|
+
if skill is not None:
|
|
1211
|
+
skills[skill.name] = skill
|
|
1212
|
+
return cls(skills)
|
|
1213
|
+
|
|
1214
|
+
@classmethod
|
|
1215
|
+
def builtins(cls) -> list[Skill]:
|
|
1216
|
+
"""Skills shipped with nanocode itself. `nanocode-help` carries a self-contained reference so
|
|
1217
|
+
the model answers questions about nanocode instantly, without searching the source. The body is
|
|
1218
|
+
assembled at load time from the same in-code constants the app uses (`/help` text, tool
|
|
1219
|
+
DESCRIPTIONs, settable keys), so it is fast to read yet cannot drift from the running version;
|
|
1220
|
+
the raw source is named only as a fallback for anything the reference does not cover."""
|
|
1221
|
+
source = os.path.abspath(__file__)
|
|
1222
|
+
root = os.path.dirname(source)
|
|
1223
|
+
tool_lines = [f"- {tool.NAME}: {tool.DESCRIPTION}" for tool in TOOLS]
|
|
1224
|
+
sections = [
|
|
1225
|
+
"Self-contained manual for answering questions about nanocode itself — how to use it, its",
|
|
1226
|
+
"features, and common problems. Answer from the sections below; only fall back to reading the",
|
|
1227
|
+
"source for details they do not cover. Cite exact command names, flags, and config keys.",
|
|
1228
|
+
"",
|
|
1229
|
+
cls.MANUAL,
|
|
1230
|
+
"",
|
|
1231
|
+
"## Commands, mentions, CLI, tools (verbatim /help)",
|
|
1232
|
+
CommandLoop.HELP.strip(),
|
|
1233
|
+
"",
|
|
1234
|
+
"## Tool details",
|
|
1235
|
+
*tool_lines,
|
|
1236
|
+
"",
|
|
1237
|
+
"## Settable config keys (/set KEY VALUE)",
|
|
1238
|
+
", ".join(CommandCompleter.SET_KEYS),
|
|
1239
|
+
]
|
|
1240
|
+
if os.path.isfile(source):
|
|
1241
|
+
sections += ["", "## Source (last-resort fallback)", f"For anything the manual does not cover, read `{source}` (README/CHANGELOG in `{root}` if present)."]
|
|
1242
|
+
description = "Answer questions about nanocode itself — how to use it, its features, config, and common problems — from a bundled manual."
|
|
1243
|
+
return [Skill("nanocode-help", description, "\n".join(sections), root, "builtin")]
|
|
1244
|
+
|
|
1245
|
+
@classmethod
|
|
1246
|
+
def parse(cls, path: str, folder: str, source: str) -> "Skill | None":
|
|
1247
|
+
try:
|
|
1248
|
+
with open(path, encoding="utf-8") as handle:
|
|
1249
|
+
text = handle.read()
|
|
1250
|
+
except OSError:
|
|
1251
|
+
return None
|
|
1252
|
+
# Normalize BOM and CRLF/CR so the frontmatter regex (which keys on "\n") matches files
|
|
1253
|
+
# authored on any platform; we only read two simple scalars, so this stays regex-light.
|
|
1254
|
+
text = text.lstrip("").replace("\r\n", "\n").replace("\r", "\n")
|
|
1255
|
+
match = cls.FRONTMATTER.match(text)
|
|
1256
|
+
meta, body = (match.group(1), match.group(2)) if match else ("", text)
|
|
1257
|
+
fields = {key: cls.scalar(value) for key, value in cls.META_LINE.findall(meta)}
|
|
1258
|
+
name = fields.get("name") or folder.strip()
|
|
1259
|
+
if not name:
|
|
1260
|
+
return None
|
|
1261
|
+
return Skill(name, fields.get("description", ""), body.strip(), os.path.dirname(path), source)
|
|
1262
|
+
|
|
1263
|
+
@staticmethod
|
|
1264
|
+
def scalar(value: str) -> str:
|
|
1265
|
+
value = value.strip()
|
|
1266
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
1267
|
+
value = value[1:-1]
|
|
1268
|
+
return value.strip()
|
|
1269
|
+
|
|
1270
|
+
def all(self) -> list[Skill]:
|
|
1271
|
+
return sorted(self.skills.values(), key=lambda skill: skill.name)
|
|
1272
|
+
|
|
1273
|
+
def get(self, name: str) -> "Skill | None":
|
|
1274
|
+
if name in self.skills:
|
|
1275
|
+
return self.skills[name]
|
|
1276
|
+
resolved = {key.lower(): key for key in self.skills}.get(name.lower())
|
|
1277
|
+
return self.skills.get(resolved) if resolved else None
|
|
1278
|
+
|
|
1279
|
+
def expand(self, skill: Skill) -> str:
|
|
1280
|
+
return skill.body.replace("{skill_dir}", skill.dir).replace("${SKILL_DIR}", skill.dir)
|
|
1281
|
+
|
|
1282
|
+
def index(self) -> str:
|
|
1283
|
+
if not self.skills:
|
|
1284
|
+
return ""
|
|
1285
|
+
rows = [f"- {skill.name}: {skill.description or '(no description)'}" for skill in self.all()]
|
|
1286
|
+
return "\n".join(["--- SKILLS ---", "Use Skill(name) to load a skill's full instructions when its description fits the task.", "", *rows])
|
|
1287
|
+
|
|
1288
|
+
def resolve_mentions(self, text: str) -> str:
|
|
1289
|
+
seen: set[str] = set()
|
|
1290
|
+
blocks: list[str] = []
|
|
1291
|
+
for raw in self.MENTION_PATTERN.findall(text):
|
|
1292
|
+
skill = self.get(raw)
|
|
1293
|
+
if skill is None or skill.name in seen:
|
|
1294
|
+
continue
|
|
1295
|
+
seen.add(skill.name)
|
|
1296
|
+
blocks.append(f"[{skill.name}] {skill.description}\n{self.expand(skill)}")
|
|
1297
|
+
if not blocks:
|
|
1298
|
+
return ""
|
|
1299
|
+
header = ["--- SKILL MENTIONS ---", "The user explicitly referenced these skills; follow their instructions unless clearly irrelevant.", ""]
|
|
1300
|
+
return "\n".join(header + blocks).strip()
|
|
1301
|
+
|
|
1302
|
+
|
|
1103
1303
|
@dataclass
|
|
1104
1304
|
class Session:
|
|
1105
1305
|
cwd: str = field(default_factory=os.getcwd)
|
|
@@ -1117,10 +1317,12 @@ class Session:
|
|
|
1117
1317
|
usage: ModelUsage = field(default_factory=ModelUsage)
|
|
1118
1318
|
update: UpdateStatus = field(default_factory=UpdateStatus)
|
|
1119
1319
|
mcp: MCPManager | None = None
|
|
1320
|
+
skills: SkillLibrary | None = None
|
|
1120
1321
|
_gitignore_cache: dict[str, tuple[int, list[str]]] = field(default_factory=dict)
|
|
1121
1322
|
uid: str = ""
|
|
1122
1323
|
resumed: bool = False
|
|
1123
1324
|
_snapshot_saved: dict = field(default_factory=dict)
|
|
1325
|
+
_cache_prefix_text: str | None = None
|
|
1124
1326
|
|
|
1125
1327
|
def __post_init__(self) -> None:
|
|
1126
1328
|
if not self.uid:
|
|
@@ -1131,6 +1333,8 @@ class Session:
|
|
|
1131
1333
|
self.expected_git_branch = self.git_branch(self.cwd)
|
|
1132
1334
|
if self.mcp is None:
|
|
1133
1335
|
self.mcp = MCPManager(self)
|
|
1336
|
+
if self.skills is None:
|
|
1337
|
+
self.skills = SkillLibrary.load(self)
|
|
1134
1338
|
|
|
1135
1339
|
@classmethod
|
|
1136
1340
|
def from_config_file(cls, *, path: str | None = None, yolo: bool = False, debug: bool = False, mcp_selector: str = "") -> "Session":
|
|
@@ -3235,8 +3439,38 @@ class MCPTool(Tool):
|
|
|
3235
3439
|
)
|
|
3236
3440
|
|
|
3237
3441
|
|
|
3442
|
+
class SkillTool(Tool):
|
|
3443
|
+
NAME = "Skill"
|
|
3444
|
+
DESCRIPTION = "Load a skill's full instructions by name (skills are listed in the SKILLS section). Follow the returned steps, running any bundled scripts it references via Bash."
|
|
3445
|
+
SIGNATURE = "Skill(name)"
|
|
3446
|
+
EXAMPLE = ('Load a skill. Example: {"name":"release-notes"}',)
|
|
3447
|
+
|
|
3448
|
+
@classmethod
|
|
3449
|
+
def params_schema(cls) -> Json:
|
|
3450
|
+
return {
|
|
3451
|
+
"type": "object",
|
|
3452
|
+
"properties": {"name": {"type": "string", "description": "Skill name from the SKILLS section"}},
|
|
3453
|
+
"required": ["name"],
|
|
3454
|
+
"additionalProperties": False,
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
@classmethod
|
|
3458
|
+
def payload_args(cls, payload: Json) -> list[Any]:
|
|
3459
|
+
return [payload.get("name", "")]
|
|
3460
|
+
|
|
3461
|
+
def call(self) -> str:
|
|
3462
|
+
(name,) = self.strings(min_count=1, max_count=1)
|
|
3463
|
+
library = self.session.skills
|
|
3464
|
+
skill = library.get(name) if library else None
|
|
3465
|
+
if skill is None:
|
|
3466
|
+
available = ", ".join(item.name for item in library.all()) if library else ""
|
|
3467
|
+
raise ToolError(f"unknown skill {name!r}" + (f"; available: {available}" if available else "; no skills are installed"))
|
|
3468
|
+
return f"<Skill name={json.dumps(skill.name)}>\n{library.expand(skill)}\n</Skill>"
|
|
3469
|
+
|
|
3470
|
+
|
|
3238
3471
|
TOOLS: tuple[type[Tool], ...] = (
|
|
3239
3472
|
MCPTool,
|
|
3473
|
+
SkillTool,
|
|
3240
3474
|
ReadTool,
|
|
3241
3475
|
LineCountTool,
|
|
3242
3476
|
ListTool,
|
|
@@ -3267,6 +3501,7 @@ class ContextManager:
|
|
|
3267
3501
|
COMPACT_TITLE: ClassVar[str] = "--- Prior Conversation Summary (compacted) ---"
|
|
3268
3502
|
COMPACT_RECENT_MESSAGES: ClassVar[int] = 8
|
|
3269
3503
|
MCP_DESCRIBE_BLOCK: ClassVar[re.Pattern] = re.compile(r"<MCPDescribe server=(\".*?\") tool=(\".*?\")>.*?</MCPDescribe>", re.DOTALL)
|
|
3504
|
+
SKILL_BLOCK: ClassVar[re.Pattern] = re.compile(r"<Skill name=(\".*?\")>.*?</Skill>", re.DOTALL)
|
|
3270
3505
|
CODE_EXTENSIONS: ClassVar[set[str]] = set(
|
|
3271
3506
|
".c .cc .cpp .cxx .css .go .h .hpp .html .java .js .json .jsx .kt .lua .php .py .rb .rs .scss .sh .sql .swift .toml .ts .tsx .vue .yaml .yml".split()
|
|
3272
3507
|
)
|
|
@@ -3291,6 +3526,7 @@ class ContextManager:
|
|
|
3291
3526
|
|
|
3292
3527
|
def model_messages(self, base_system: str, turn_messages: list[Json] | None = None) -> list[Json]:
|
|
3293
3528
|
file_context = self.file_context() or "(empty)"
|
|
3529
|
+
skills_index = self.skills_context()
|
|
3294
3530
|
mcp_tools = self.mcp_tools_context()
|
|
3295
3531
|
|
|
3296
3532
|
messages: list[Json] = [
|
|
@@ -3298,10 +3534,12 @@ class ContextManager:
|
|
|
3298
3534
|
{"role": "user", "content": "--- Environment ---\n" + (self.environment() or "(empty)")},
|
|
3299
3535
|
]
|
|
3300
3536
|
|
|
3537
|
+
if skills_index:
|
|
3538
|
+
messages.append({"role": "user", "content": skills_index})
|
|
3301
3539
|
if mcp_tools:
|
|
3302
3540
|
messages.append({"role": "user", "content": mcp_tools})
|
|
3303
3541
|
|
|
3304
|
-
messages.extend(self.dedup_mcp_describes([*self.session.messages, *(turn_messages or [])]))
|
|
3542
|
+
messages.extend(self.dedup_skill_loads(self.dedup_mcp_describes([*self.session.messages, *(turn_messages or [])])))
|
|
3305
3543
|
messages.append({"role": "user", "content": "--- Memory ---\n" + (self.memory_context(with_date=True) or "(empty)")})
|
|
3306
3544
|
messages.append({"role": "user", "content": "--- FILE STATE ---\n" + file_context})
|
|
3307
3545
|
return Text.value(messages)
|
|
@@ -3341,11 +3579,90 @@ class ContextManager:
|
|
|
3341
3579
|
result.append({**message, "content": self.MCP_DESCRIBE_BLOCK.sub(lambda _: marker, content)})
|
|
3342
3580
|
return result
|
|
3343
3581
|
|
|
3582
|
+
def dedup_skill_loads(self, messages: list[Json]) -> list[Json]:
|
|
3583
|
+
"""Collapse repeated Skill(name) loads to a pointer, keeping the first full body per skill.
|
|
3584
|
+
|
|
3585
|
+
Same send-time transform as dedup_mcp_describes: a re-load of an already-shown skill shrinks to
|
|
3586
|
+
a one-line marker so the instructions are not re-billed, while the first (cached) copy is left
|
|
3587
|
+
untouched. If that first copy is later compacted away, the next occurrence stands on its own."""
|
|
3588
|
+
seen: dict[str, str] = {}
|
|
3589
|
+
result: list[Json] = []
|
|
3590
|
+
for message in messages:
|
|
3591
|
+
content = message.get("content")
|
|
3592
|
+
if message.get("role") != "tool" or not isinstance(content, str):
|
|
3593
|
+
result.append(message)
|
|
3594
|
+
continue
|
|
3595
|
+
match = self.SKILL_BLOCK.search(content)
|
|
3596
|
+
if match is None:
|
|
3597
|
+
result.append(message)
|
|
3598
|
+
continue
|
|
3599
|
+
try:
|
|
3600
|
+
name = str(json.loads(match.group(1)))
|
|
3601
|
+
except (json.JSONDecodeError, ValueError):
|
|
3602
|
+
result.append(message)
|
|
3603
|
+
continue
|
|
3604
|
+
first_key = seen.get(name)
|
|
3605
|
+
if first_key is None:
|
|
3606
|
+
key = re.search(r"\btr\.\d+\b", content)
|
|
3607
|
+
seen[name] = key.group(0) if key else "above"
|
|
3608
|
+
result.append(message)
|
|
3609
|
+
continue
|
|
3610
|
+
marker = f"(repeat load of skill {name}; instructions shown earlier at {first_key}, unchanged)"
|
|
3611
|
+
result.append({**message, "content": self.SKILL_BLOCK.sub(lambda _: marker, content)})
|
|
3612
|
+
return result
|
|
3613
|
+
|
|
3344
3614
|
def mcp_tools_context(self) -> str:
|
|
3345
3615
|
if self.session.mcp is None:
|
|
3346
3616
|
return ""
|
|
3347
3617
|
return self.session.mcp.render_tools_index()
|
|
3348
3618
|
|
|
3619
|
+
def skills_context(self) -> str:
|
|
3620
|
+
return self.session.skills.index() if self.session.skills else ""
|
|
3621
|
+
|
|
3622
|
+
def has_skills(self) -> bool:
|
|
3623
|
+
return bool(self.session.skills and self.session.skills.skills)
|
|
3624
|
+
|
|
3625
|
+
def cache_prefix(self, base_system: str, tools: list[Json] | None) -> str:
|
|
3626
|
+
# Canonical text of the bytes a provider can cache: the stable head of every request.
|
|
3627
|
+
# Mirrors the leading blocks model_messages() emits (system + environment + mcp index)
|
|
3628
|
+
# plus the tool schemas. Everything mutable (history, memory, FILE STATE) sits after it.
|
|
3629
|
+
return "\x00".join(
|
|
3630
|
+
[
|
|
3631
|
+
base_system.strip(),
|
|
3632
|
+
"--- Environment ---\n" + (self.environment() or "(empty)"),
|
|
3633
|
+
self.skills_context(),
|
|
3634
|
+
self.mcp_tools_context() or "",
|
|
3635
|
+
json.dumps(tools or [], ensure_ascii=False, sort_keys=True, separators=(",", ":")),
|
|
3636
|
+
]
|
|
3637
|
+
)
|
|
3638
|
+
|
|
3639
|
+
def tool_schemas(self) -> list[Json]:
|
|
3640
|
+
strict = self.session.config.provider.resolved_strict_tools()
|
|
3641
|
+
# The Skill tool only appears when at least one skill is installed, so a skill-free session
|
|
3642
|
+
# keeps a byte-identical prefix to before skills existed.
|
|
3643
|
+
return [tool.schema(strict) for tool in TOOL_REGISTRY.values() if tool is not SkillTool or self.has_skills()]
|
|
3644
|
+
|
|
3645
|
+
def check_cache_prefix(self, base_system: str) -> None:
|
|
3646
|
+
# Tripwire for silent cache breakage: fingerprint the stable prefix and flag drift.
|
|
3647
|
+
# A healthy session keeps one fingerprint start to finish; a second one means the prefix
|
|
3648
|
+
# mutated mid-session and every token from the change onward is a cache miss.
|
|
3649
|
+
text = self.cache_prefix(base_system, self.tool_schemas())
|
|
3650
|
+
fingerprint = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
3651
|
+
state = self.session.state
|
|
3652
|
+
if fingerprint in state.prefix_fingerprints:
|
|
3653
|
+
self.session._cache_prefix_text = text
|
|
3654
|
+
return
|
|
3655
|
+
previous = self.session._cache_prefix_text
|
|
3656
|
+
if state.prefix_fingerprint and previous is not None:
|
|
3657
|
+
diff = "\n".join(
|
|
3658
|
+
list(difflib.unified_diff(previous.splitlines(), text.splitlines(), "cached-prefix", "current-prefix", lineterm=""))[:40]
|
|
3659
|
+
)
|
|
3660
|
+
DebugTrace.cache_drift(self.session, expected=state.prefix_fingerprint, actual=fingerprint, diff=diff)
|
|
3661
|
+
if not state.prefix_fingerprint:
|
|
3662
|
+
state.prefix_fingerprint = fingerprint
|
|
3663
|
+
state.prefix_fingerprints.append(fingerprint)
|
|
3664
|
+
self.session._cache_prefix_text = text
|
|
3665
|
+
|
|
3349
3666
|
def update_percent(self, messages: list[Json]) -> int:
|
|
3350
3667
|
tokens = self.estimated_tokens(messages)
|
|
3351
3668
|
self.session.state.context_percent = min(100, tokens * 100 // self.session.settings.max_context_tokens)
|
|
@@ -3402,6 +3719,106 @@ class ContextManager:
|
|
|
3402
3719
|
lines_by_path, omitted = self.active_file_lines()
|
|
3403
3720
|
return self.render_file_lines(lines_by_path, omitted)
|
|
3404
3721
|
|
|
3722
|
+
def context_overview(self) -> str:
|
|
3723
|
+
"""Markdown view of the synthesized context frame the model receives each turn:
|
|
3724
|
+
the Environment, Memory, and File State sections (the live transcript is excluded)."""
|
|
3725
|
+
lines_by_path, omitted = self.active_file_lines()
|
|
3726
|
+
paths = sorted(path for path in lines_by_path if lines_by_path[path])
|
|
3727
|
+
total_lines = sum(len(lines_by_path[path]) for path in paths)
|
|
3728
|
+
header = f"### Context · ctx `{self.session.state.context_percent}%` · {len(paths)} files · {total_lines} lines"
|
|
3729
|
+
return "\n\n".join([header, self.environment_md(), self.memory_md(), self.files_overview((lines_by_path, omitted))])
|
|
3730
|
+
|
|
3731
|
+
@staticmethod
|
|
3732
|
+
def md_table(headers: list[str], rows: list[tuple]) -> str:
|
|
3733
|
+
def cell(value: object) -> str:
|
|
3734
|
+
return Text.clean(str(value)).replace("\n", " ").replace("|", "\\|")
|
|
3735
|
+
|
|
3736
|
+
return "\n".join(
|
|
3737
|
+
[
|
|
3738
|
+
"| " + " | ".join(headers) + " |",
|
|
3739
|
+
"| " + " | ".join("---" for _ in headers) + " |",
|
|
3740
|
+
*("| " + " | ".join(cell(value) for value in row) + " |" for row in rows),
|
|
3741
|
+
]
|
|
3742
|
+
)
|
|
3743
|
+
|
|
3744
|
+
def environment_md(self) -> str:
|
|
3745
|
+
info = self.session.system_info
|
|
3746
|
+
rows = [
|
|
3747
|
+
("cwd", "`" + info.cwd + "`"),
|
|
3748
|
+
("os", f"{info.os} · {info.arch}"),
|
|
3749
|
+
("shell timeout", f"{self.session.settings.shell_timeout}s"),
|
|
3750
|
+
("commands", ", ".join(info.commands) or "(none)"),
|
|
3751
|
+
]
|
|
3752
|
+
if branch := self.session.git_branch(self.session.cwd):
|
|
3753
|
+
rows.append(("git", "`" + branch + "`"))
|
|
3754
|
+
return "#### Environment\n" + self.md_table(["key", "value"], rows)
|
|
3755
|
+
|
|
3756
|
+
def memory_md(self) -> str:
|
|
3757
|
+
state = self.session.state
|
|
3758
|
+
index_status = state.code_index_status or "missing"
|
|
3759
|
+
index_usable = "yes" if index_status in {"synced", "ready", "stale"} else "no"
|
|
3760
|
+
rows = [
|
|
3761
|
+
("goal", state.goal or "(empty)"),
|
|
3762
|
+
("check", state.check or "(empty)"),
|
|
3763
|
+
("index", f"{index_status} (usable: {index_usable})"),
|
|
3764
|
+
]
|
|
3765
|
+
known = "\n".join("- " + item for item in state.known) or "- (empty)"
|
|
3766
|
+
return "\n\n".join(
|
|
3767
|
+
[
|
|
3768
|
+
"#### Memory\n" + self.md_table(["field", "value"], rows),
|
|
3769
|
+
"**Plan**\n" + "\n".join(AgentState.plan_rows_for(state.plan, status=True, style="symbol")),
|
|
3770
|
+
"**Known**\n" + known,
|
|
3771
|
+
]
|
|
3772
|
+
)
|
|
3773
|
+
|
|
3774
|
+
def files_overview(self, precomputed: tuple[dict, dict] | None = None) -> str:
|
|
3775
|
+
"""Markdown summary of the FILE STATE section: which files/ranges are current, recent
|
|
3776
|
+
events, and omissions, without dumping the full anchored content."""
|
|
3777
|
+
lines_by_path, omitted = precomputed if precomputed is not None else self.active_file_lines()
|
|
3778
|
+
paths = sorted(path for path in lines_by_path if lines_by_path[path])
|
|
3779
|
+
state = self.session.state
|
|
3780
|
+
focus = state.focus_text(state.plan)
|
|
3781
|
+
actions, code_edits, errors = self.recent_file_actions(), self.recent_code_edits(), self.recent_tool_errors()
|
|
3782
|
+
check_status = self.check_status(code_edits)
|
|
3783
|
+
if not (paths or omitted or focus or actions or code_edits or check_status or errors):
|
|
3784
|
+
return "#### File State\n(no files in context)"
|
|
3785
|
+
chunks = ["#### File State" + (f" · focus: {focus}" if focus else "")]
|
|
3786
|
+
if paths:
|
|
3787
|
+
rows = [
|
|
3788
|
+
(f"`{path}`", ", ".join(f"{start}:{end}" for start, end in self.coverage(lines_by_path[path])), len(lines_by_path[path]), self.latest_source(lines_by_path[path]))
|
|
3789
|
+
for path in paths
|
|
3790
|
+
]
|
|
3791
|
+
chunks.append(self.md_table(["file", "ranges", "lines", "source"], rows))
|
|
3792
|
+
for label, items in (("Recent events", actions), ("Recent code edits", code_edits), ("Check status", check_status), ("Recent tool errors", errors)):
|
|
3793
|
+
if items:
|
|
3794
|
+
chunks.append(f"**{label}**\n" + "\n".join(items))
|
|
3795
|
+
if omitted:
|
|
3796
|
+
omit_rows = [f"- `{path}` source={source} lines={count}" for path in sorted(omitted) for source, count in sorted(omitted[path].items())]
|
|
3797
|
+
chunks.append("**Omitted** (stale/superseded)\n" + "\n".join(omit_rows))
|
|
3798
|
+
return "\n\n".join(chunks)
|
|
3799
|
+
|
|
3800
|
+
@staticmethod
|
|
3801
|
+
def latest_source(numbered: dict[int, tuple[str, str, str]]) -> str:
|
|
3802
|
+
source, tool, _line = max(numbered.values(), key=lambda value: int(value[0][3:]) if value[0].startswith("tr.") and value[0][3:].isdigit() else -1)
|
|
3803
|
+
return f"{source} {tool}".strip()
|
|
3804
|
+
|
|
3805
|
+
def file_detail(self, path: str) -> str:
|
|
3806
|
+
"""Full current anchored content for one in-context file, exactly as the model sees it,
|
|
3807
|
+
wrapped in a fenced block so it renders monospace and unwrapped."""
|
|
3808
|
+
lines_by_path, _ = self.active_file_lines()
|
|
3809
|
+
available = sorted(candidate for candidate in lines_by_path if lines_by_path[candidate])
|
|
3810
|
+
matches = [candidate for candidate in available if candidate == path or os.path.basename(candidate) == path or candidate.endswith("/" + path)]
|
|
3811
|
+
match = matches[0] if len(matches) == 1 else (path if path in available else None)
|
|
3812
|
+
if match is None:
|
|
3813
|
+
listing = "\n".join("- `" + candidate + "`" for candidate in available) or "(none)"
|
|
3814
|
+
return f"No in-context content for `{path}`.\n\n**Files in context**\n{listing}"
|
|
3815
|
+
numbered = lines_by_path[match]
|
|
3816
|
+
body: list[str] = []
|
|
3817
|
+
for start, end, source, tool, segment_lines in self.segments(numbered):
|
|
3818
|
+
body.append(f"@@ {start}:{end} {source} {tool}")
|
|
3819
|
+
body.extend(segment_lines)
|
|
3820
|
+
return f"**{match}** — current, {len(numbered)} lines\n\n```\n" + "\n".join(body) + "\n```"
|
|
3821
|
+
|
|
3405
3822
|
def active_file_lines(self) -> tuple[dict[str, dict[int, tuple[str, str, str]]], dict[str, dict[str, int]]]:
|
|
3406
3823
|
lines_by_path: dict[str, dict[int, tuple[str, str, str]]] = {}
|
|
3407
3824
|
omitted: dict[str, dict[str, int]] = {}
|
|
@@ -3616,18 +4033,21 @@ class ContextManager:
|
|
|
3616
4033
|
return "\n\n".join(f"{message.get('role', 'message')}:\n{message.get('content') or ''}" for message in messages) or "(empty)"
|
|
3617
4034
|
|
|
3618
4035
|
def apply_compaction(self, data: Json, keep: list[Json], tool_messages: list[Json] | None = None) -> None:
|
|
4036
|
+
self.session.state.compaction_count += 1
|
|
3619
4037
|
self.session.state.apply(data)
|
|
3620
4038
|
summary = self.session.state.summary
|
|
3621
4039
|
self.session.messages = ([{"role": "user", "content": self.COMPACT_TITLE + "\n" + summary}] if summary else []) + keep
|
|
3622
4040
|
self.prune_tool_records([*self.session.messages, *(tool_messages or [])])
|
|
3623
4041
|
|
|
3624
4042
|
def apply_compaction_fallback(self, keep: list[Json], tool_messages: list[Json] | None = None) -> None:
|
|
4043
|
+
self.session.state.compaction_count += 1
|
|
3625
4044
|
self.session.state.summary = (self.session.state.summary + "\nPrevious context was deterministically trimmed.").strip()
|
|
3626
4045
|
summary = self.session.state.summary
|
|
3627
4046
|
self.session.messages = ([{"role": "user", "content": self.COMPACT_TITLE + "\n" + summary}] if summary else []) + keep
|
|
3628
4047
|
self.prune_tool_records([*keep, *(tool_messages or [])])
|
|
3629
4048
|
|
|
3630
4049
|
def apply_turn_compaction(self, data: Json, keep: list[Json], turn_messages: list[Json]) -> None:
|
|
4050
|
+
self.session.state.compaction_count += 1
|
|
3631
4051
|
self.session.state.apply(data)
|
|
3632
4052
|
summary = self.session.state.summary
|
|
3633
4053
|
index = self.latest_user_index(keep)
|
|
@@ -5390,6 +5810,10 @@ class DebugTrace:
|
|
|
5390
5810
|
payload = {"api": api, "model": model, "error": str(error), "param_keys": sorted(params), "params": cls.filtered_params(params)}
|
|
5391
5811
|
cls.write(session, activity=activity, label="model-error", payload=payload)
|
|
5392
5812
|
|
|
5813
|
+
@classmethod
|
|
5814
|
+
def cache_drift(cls, session: Session, *, expected: str, actual: str, diff: str) -> None:
|
|
5815
|
+
cls.write(session, activity="agent", label="cache-prefix-drift", payload={"expected": expected, "actual": actual, "diff": diff})
|
|
5816
|
+
|
|
5393
5817
|
@staticmethod
|
|
5394
5818
|
def filtered_params(params: Json) -> Json:
|
|
5395
5819
|
return {key: value for key, value in params.items() if key not in {"messages", "tools"}}
|
|
@@ -5406,11 +5830,18 @@ class ModelClient:
|
|
|
5406
5830
|
def __init__(self, session: Session):
|
|
5407
5831
|
self.session = session
|
|
5408
5832
|
|
|
5833
|
+
def tool_schemas(self) -> list[Json]:
|
|
5834
|
+
provider = self.session.config.provider
|
|
5835
|
+
# Keep in lockstep with ContextManager.tool_schemas: the Skill tool is only offered when a
|
|
5836
|
+
# skill is installed, so the sent tools match the cache-prefix fingerprint.
|
|
5837
|
+
has_skills = bool(self.session.skills and self.session.skills.skills)
|
|
5838
|
+
return [tool.schema(provider.resolved_strict_tools()) for tool in TOOL_REGISTRY.values() if tool is not SkillTool or has_skills]
|
|
5839
|
+
|
|
5409
5840
|
def request(self, messages: list[Json]) -> tuple[Json, list[ToolCall], str]:
|
|
5410
5841
|
provider = self.session.config.provider
|
|
5411
5842
|
if missing := self.session.missing_config():
|
|
5412
5843
|
raise ModelError("missing config: " + ", ".join(missing))
|
|
5413
|
-
tools =
|
|
5844
|
+
tools = self.tool_schemas()
|
|
5414
5845
|
for attempt in range(MODEL_REQUEST_RETRIES + 1):
|
|
5415
5846
|
self.session.state.current_model_call_started_at = time.monotonic()
|
|
5416
5847
|
try:
|
|
@@ -5825,6 +6256,10 @@ FINAL:
|
|
|
5825
6256
|
mentions = self.session.mcp.resolve_mentions(user_input)
|
|
5826
6257
|
if mentions:
|
|
5827
6258
|
turn_messages.append({"role": "user", "content": mentions})
|
|
6259
|
+
if self.session.skills is not None:
|
|
6260
|
+
skill_mentions = self.session.skills.resolve_mentions(user_input)
|
|
6261
|
+
if skill_mentions:
|
|
6262
|
+
turn_messages.append({"role": "user", "content": skill_mentions})
|
|
5828
6263
|
for step in range(self.session.settings.max_steps):
|
|
5829
6264
|
self.session.state.turn_step = step + 1
|
|
5830
6265
|
while True:
|
|
@@ -5859,6 +6294,7 @@ FINAL:
|
|
|
5859
6294
|
self.session.state.turn_messages = len(request_turn)
|
|
5860
6295
|
self.context.maybe_compact(self.model, self.SYSTEM_PROMPT, request_turn)
|
|
5861
6296
|
messages = self.context.model_messages(self.SYSTEM_PROMPT, request_turn)
|
|
6297
|
+
self.context.check_cache_prefix(self.SYSTEM_PROMPT)
|
|
5862
6298
|
self.context.update_percent(messages)
|
|
5863
6299
|
return messages, pending
|
|
5864
6300
|
|
|
@@ -5891,7 +6327,8 @@ class CommandCompleter(Completer):
|
|
|
5891
6327
|
COMMANDS = (
|
|
5892
6328
|
"/help",
|
|
5893
6329
|
"/status",
|
|
5894
|
-
"/
|
|
6330
|
+
"/context",
|
|
6331
|
+
"/skills",
|
|
5895
6332
|
"/config",
|
|
5896
6333
|
"/api",
|
|
5897
6334
|
"/debug",
|
|
@@ -5946,12 +6383,14 @@ class CommandCompleter(Completer):
|
|
|
5946
6383
|
mcp_servers: Callable[[], tuple[str, ...]] = tuple,
|
|
5947
6384
|
mcp_oauth_servers: Callable[[], tuple[str, ...]] = tuple,
|
|
5948
6385
|
mcp_tools: Callable[[str], tuple[str, ...]] = lambda _server: (),
|
|
6386
|
+
skills: Callable[[], tuple[str, ...]] = tuple,
|
|
5949
6387
|
):
|
|
5950
6388
|
self.providers = providers
|
|
5951
6389
|
self.models = models
|
|
5952
6390
|
self.mcp_servers = mcp_servers
|
|
5953
6391
|
self.mcp_oauth_servers = mcp_oauth_servers
|
|
5954
6392
|
self.mcp_tools = mcp_tools
|
|
6393
|
+
self.skills = skills
|
|
5955
6394
|
|
|
5956
6395
|
def get_completions(self, document, complete_event):
|
|
5957
6396
|
text = document.text_before_cursor
|
|
@@ -5995,6 +6434,11 @@ class CommandCompleter(Completer):
|
|
|
5995
6434
|
yield from self.matches(self.mcp_servers(), server_part)
|
|
5996
6435
|
return
|
|
5997
6436
|
|
|
6437
|
+
skill_match = re.search(r"(?<![A-Za-z0-9_])\$([A-Za-z0-9_-]*)$", text)
|
|
6438
|
+
if skill_match:
|
|
6439
|
+
yield from self.matches(self.skills(), skill_match.group(1))
|
|
6440
|
+
return
|
|
6441
|
+
|
|
5998
6442
|
if text.startswith("/") and " " not in text:
|
|
5999
6443
|
yield from self.matches(self.COMMANDS, text)
|
|
6000
6444
|
|
|
@@ -6450,6 +6894,9 @@ class StatusBar:
|
|
|
6450
6894
|
mcp_status = self.mcp_status()
|
|
6451
6895
|
if mcp_status:
|
|
6452
6896
|
parts.append((mcp_status, "mcp"))
|
|
6897
|
+
skill_count = len(self.session.skills.skills) if self.session.skills else 0
|
|
6898
|
+
if skill_count:
|
|
6899
|
+
parts.append((f"skills {skill_count}", "mcp"))
|
|
6453
6900
|
parts.append(("ctx " + str(self.session.state.context_percent) + "%", "ctx"))
|
|
6454
6901
|
if self.session.settings.debug and self.session.usage.cached_prompt_tokens:
|
|
6455
6902
|
parts.append(("cache " + str(self.session.usage.cached_prompt_tokens), "debug"))
|
|
@@ -6549,7 +6996,7 @@ class StatusBar:
|
|
|
6549
6996
|
class CommandLoop:
|
|
6550
6997
|
# Commands safe to run from the background queue-input thread while the agent works: read-only
|
|
6551
6998
|
# views plus /yolo, whose single atomic flag flip the agent simply reads at the next approval.
|
|
6552
|
-
QUEUE_RUN_COMMANDS: ClassVar[frozenset[str]] = frozenset({"/help", "/status", "/
|
|
6999
|
+
QUEUE_RUN_COMMANDS: ClassVar[frozenset[str]] = frozenset({"/help", "/status", "/context", "/skills", "/mcp", "/yolo"})
|
|
6553
7000
|
MODEL_CONFIGURED_LABEL = "---- Configured models ----"
|
|
6554
7001
|
MODEL_DISCOVERED_LABEL = "---- Discovered models ----"
|
|
6555
7002
|
MODEL_LABELS = frozenset((MODEL_CONFIGURED_LABEL, MODEL_DISCOVERED_LABEL))
|
|
@@ -6570,7 +7017,7 @@ class CommandLoop:
|
|
|
6570
7017
|
(ALWAYS, "Tab completes commands, file paths, and mentions."),
|
|
6571
7018
|
# Context & memory
|
|
6572
7019
|
(ALWAYS, "`/compact` summarizes a long conversation to reclaim context."),
|
|
6573
|
-
(ALWAYS, "`/
|
|
7020
|
+
(ALWAYS, "`/context` shows the model's context frame: environment, memory (goal/plan/known), and file state."),
|
|
6574
7021
|
(ALWAYS, "`/status` shows token usage, context %, and prompt-cache hit rate."),
|
|
6575
7022
|
(ALWAYS, "Stable context is kept early so the prompt cache is reused — cheaper, faster turns."),
|
|
6576
7023
|
# Model & reasoning
|
|
@@ -6604,7 +7051,8 @@ class CommandLoop:
|
|
|
6604
7051
|
HELP = """Commands:
|
|
6605
7052
|
/help Show this help.
|
|
6606
7053
|
/status Show runtime status.
|
|
6607
|
-
/
|
|
7054
|
+
/context [PATH] Show the model's context frame (environment, memory, file state); PATH shows that file's current lines.
|
|
7055
|
+
/skills List installed skills (load with Skill(name) or reference inline with $name).
|
|
6608
7056
|
/config Show active config.
|
|
6609
7057
|
/api [NAME] Show or set provider API format: auto, chat, anthropic.
|
|
6610
7058
|
/debug [on|off] Toggle model I/O debug traces.
|
|
@@ -6624,11 +7072,13 @@ class CommandLoop:
|
|
|
6624
7072
|
/exit, /quit Exit.
|
|
6625
7073
|
Mentions:
|
|
6626
7074
|
@server[.tool] Point the agent at an MCP server/tool in your message (tab-completes).
|
|
7075
|
+
$skill Reference a skill in your message to load its instructions for that turn (tab-completes).
|
|
6627
7076
|
CLI:
|
|
6628
7077
|
--mcp "orion*,!orionEval" Select MCP servers by name glob; use all or none.
|
|
6629
7078
|
--resume [UID] Resume a saved session; defaults to latest (last also works).
|
|
6630
7079
|
Tools:
|
|
6631
|
-
Read, LineCount, List, Find, InspectCode, Search, Edit, Bash, Git, Recall, Note, Question, MCP.
|
|
7080
|
+
Read, LineCount, List, Find, InspectCode, Search, Edit, Bash, Git, Recall, Note, Question, MCP, Skill.
|
|
7081
|
+
Skill(name) loads a skill's full instructions on demand (see the SKILLS section / $skill).
|
|
6632
7082
|
"""
|
|
6633
7083
|
|
|
6634
7084
|
def __init__(self, agent: Agent, input_fn=input, output_fn=print):
|
|
@@ -6659,6 +7109,7 @@ Tools:
|
|
|
6659
7109
|
mcp_servers=lambda: tuple(config.name for config in self.session.mcp.parse_configs() if config.enabled),
|
|
6660
7110
|
mcp_oauth_servers=lambda: tuple(config.name for config in self.session.mcp.parse_configs() if config.enabled and config.auth == "oauth"),
|
|
6661
7111
|
mcp_tools=lambda server: self.session.mcp.server_tool_names(server),
|
|
7112
|
+
skills=lambda: tuple(skill.name for skill in self.session.skills.all()) if self.session.skills else (),
|
|
6662
7113
|
)
|
|
6663
7114
|
self.agent.output_fn = self.agent_output
|
|
6664
7115
|
self.agent.tools.output_fn = self.tool_output
|
|
@@ -6998,6 +7449,8 @@ Tools:
|
|
|
6998
7449
|
"choice.selected": "reverse",
|
|
6999
7450
|
"choice.disabled": "ansibrightblack",
|
|
7000
7451
|
"choice.preview": "ansigreen italic",
|
|
7452
|
+
"tab.active": "bold reverse ansicyan",
|
|
7453
|
+
"tab.inactive": "ansicyan",
|
|
7001
7454
|
"completion-menu": "noreverse bg:default",
|
|
7002
7455
|
"completion-menu.completion": "noreverse bg:default fg:ansiwhite",
|
|
7003
7456
|
"completion-menu.completion.current": "noreverse bg:default fg:ansicyan bold",
|
|
@@ -7301,7 +7754,8 @@ Tools:
|
|
|
7301
7754
|
handlers = {
|
|
7302
7755
|
"/help": self.help,
|
|
7303
7756
|
"/status": self.status,
|
|
7304
|
-
"/
|
|
7757
|
+
"/context": self.context_view,
|
|
7758
|
+
"/skills": self.skills_command,
|
|
7305
7759
|
"/config": self.config,
|
|
7306
7760
|
"/api": self.api,
|
|
7307
7761
|
"/debug": self.debug,
|
|
@@ -7317,7 +7771,9 @@ Tools:
|
|
|
7317
7771
|
}
|
|
7318
7772
|
handler = handlers.get(name)
|
|
7319
7773
|
output = handler(args.strip()) if handler else f"Unknown command: {name}"
|
|
7320
|
-
|
|
7774
|
+
# A None result means the handler already rendered its own UI (e.g. /context's tab viewer).
|
|
7775
|
+
if output is not None:
|
|
7776
|
+
(self.ui.emit_answer if name in {"/status", "/mcp", "/context", "/skills"} else self.emit)(output)
|
|
7321
7777
|
return True, False
|
|
7322
7778
|
|
|
7323
7779
|
def mcp_command(self, args: str) -> str:
|
|
@@ -7630,12 +8086,13 @@ Tools:
|
|
|
7630
8086
|
),
|
|
7631
8087
|
(
|
|
7632
8088
|
"context",
|
|
7633
|
-
f"ctx `{self.session.state.context_percent}%`; history `{len(self.session.messages)}`; turn `{self.session.state.turn_messages}`; tools `{len(self.session.tool_results)}`; files `{self.agent.context.file_count()}`; known `{len(self.session.state.known)}`",
|
|
8089
|
+
f"ctx `{self.session.state.context_percent}%`; history `{len(self.session.messages)}`; turn `{self.session.state.turn_messages}`; tools `{len(self.session.tool_results)}`; files `{self.agent.context.file_count()}`; skills `{len(self.session.skills.skills) if self.session.skills else 0}`; known `{len(self.session.state.known)}`; compactions `{self.session.state.compaction_count}`",
|
|
7634
8090
|
),
|
|
7635
8091
|
("goal", self.session.state.goal or "(empty)"),
|
|
7636
8092
|
(
|
|
7637
8093
|
"usage",
|
|
7638
|
-
f"calls `{usage.calls}`; total `{usage.total_tokens}`; cached `{usage.cached_prompt_tokens}/{usage.prompt_tokens}` (`{cache_ratio:.1f}%`); last `{usage.last_cached_prompt_tokens}/{usage.last_prompt_tokens}` (`{last_cache_ratio:.1f}%`)"
|
|
8094
|
+
f"calls `{usage.calls}`; total `{usage.total_tokens}`; cached `{usage.cached_prompt_tokens}/{usage.prompt_tokens}` (`{cache_ratio:.1f}%`); last `{usage.last_cached_prompt_tokens}/{usage.last_prompt_tokens}` (`{last_cache_ratio:.1f}%`)"
|
|
8095
|
+
+ (f"; ⚠ prefix churn `{len(set(self.session.state.prefix_fingerprints))}` (cache broken; see debug cache-prefix-drift)" if len(set(self.session.state.prefix_fingerprints)) > 1 else ""),
|
|
7639
8096
|
),
|
|
7640
8097
|
(
|
|
7641
8098
|
"runtime",
|
|
@@ -7652,20 +8109,116 @@ Tools:
|
|
|
7652
8109
|
]
|
|
7653
8110
|
)
|
|
7654
8111
|
|
|
7655
|
-
def
|
|
7656
|
-
|
|
7657
|
-
|
|
7658
|
-
|
|
7659
|
-
|
|
7660
|
-
|
|
7661
|
-
|
|
7662
|
-
|
|
7663
|
-
"plan:",
|
|
7664
|
-
*AgentState.plan_rows_for(state.plan, status=True, style="symbol"),
|
|
7665
|
-
"known:",
|
|
7666
|
-
*known,
|
|
7667
|
-
]
|
|
8112
|
+
def skills_command(self, args: str) -> str:
|
|
8113
|
+
library = self.session.skills
|
|
8114
|
+
skills = library.all() if library else []
|
|
8115
|
+
if not skills:
|
|
8116
|
+
return "No skills installed. Add `<name>/SKILL.md` under `.nanocode/skills/` (project) or `~/.nanocode/skills/` (user)."
|
|
8117
|
+
table = ContextManager.md_table(
|
|
8118
|
+
["skill", "source", "description"],
|
|
8119
|
+
[(f"`{skill.name}`", skill.source, skill.description or "(no description)") for skill in skills],
|
|
7668
8120
|
)
|
|
8121
|
+
return "\n".join([f"### Skills · {len(skills)}", "", "Load with `Skill(name)` or reference inline with `$name`.", "", table])
|
|
8122
|
+
|
|
8123
|
+
def context_view(self, args: str) -> str | None:
|
|
8124
|
+
context = self.agent.context
|
|
8125
|
+
if args:
|
|
8126
|
+
return context.file_detail(args)
|
|
8127
|
+
context.update_percent(context.model_messages(self.agent.SYSTEM_PROMPT))
|
|
8128
|
+
# At the idle prompt on a real terminal, open the interactive tabbed viewer; while the agent
|
|
8129
|
+
# is working (queue path sets capture_ansi) or without a TTY, fall back to the static dump.
|
|
8130
|
+
if self.interactive_input and self.ui.color and not self.ui.capture_ansi:
|
|
8131
|
+
self.context_tabs(context)
|
|
8132
|
+
return None
|
|
8133
|
+
return context.context_overview()
|
|
8134
|
+
|
|
8135
|
+
CONTEXT_TABS: ClassVar[tuple[tuple[str, str], ...]] = (("Environment", "environment_md"), ("Memory", "memory_md"), ("File State", "files_overview"))
|
|
8136
|
+
|
|
8137
|
+
def context_tabs(self, context: "ContextManager") -> None:
|
|
8138
|
+
"""Interactive tabbed viewer for the context frame: ←/→ switch tabs, ↑/↓ scroll, Esc close.
|
|
8139
|
+
Renders a static snapshot; the transcript continues below once closed."""
|
|
8140
|
+
width = max(20, shutil.get_terminal_size().columns - 2)
|
|
8141
|
+
pages = [self.render_markdown_lines(getattr(context, method)(), width) for _, method in self.CONTEXT_TABS]
|
|
8142
|
+
state = self.context_tab_state = {"tab": 0, "scroll": 0}
|
|
8143
|
+
|
|
8144
|
+
def viewport() -> int:
|
|
8145
|
+
return max(3, shutil.get_terminal_size().lines - 5)
|
|
8146
|
+
|
|
8147
|
+
def fragments():
|
|
8148
|
+
# Blank line separates the viewer from the `nano> /context` input line above it.
|
|
8149
|
+
parts: list[tuple[str, str]] = [("", "\n")]
|
|
8150
|
+
for index, (name, _) in enumerate(self.CONTEXT_TABS):
|
|
8151
|
+
active = index == state["tab"]
|
|
8152
|
+
parts.append(("class:tab.active" if active else "class:tab.inactive", f" {name} "))
|
|
8153
|
+
if index < len(self.CONTEXT_TABS) - 1:
|
|
8154
|
+
parts.append(("class:choice.disabled", " │ "))
|
|
8155
|
+
lines = pages[state["tab"]]
|
|
8156
|
+
height = viewport()
|
|
8157
|
+
scrollable = len(lines) > height
|
|
8158
|
+
state["scroll"] = min(max(0, int(state["scroll"])), max(0, len(lines) - height))
|
|
8159
|
+
visible = lines[state["scroll"] : state["scroll"] + height]
|
|
8160
|
+
parts.append(("", "\n"))
|
|
8161
|
+
scroll_hint = "↑/↓ scroll" if scrollable else "↑/↓ scroll (fits)"
|
|
8162
|
+
parts.append(("class:choice.disabled", f" ←/→ switch · {scroll_hint} · Esc close [{state['scroll'] + 1}-{state['scroll'] + len(visible)}/{len(lines)}]\n"))
|
|
8163
|
+
for line in visible:
|
|
8164
|
+
parts.extend(line)
|
|
8165
|
+
parts.append(("", "\n"))
|
|
8166
|
+
return parts
|
|
8167
|
+
|
|
8168
|
+
def scroll(event, delta: int) -> None:
|
|
8169
|
+
state["scroll"] = max(0, int(state["scroll"]) + delta)
|
|
8170
|
+
event.app.invalidate()
|
|
8171
|
+
|
|
8172
|
+
def switch(event, delta: int) -> None:
|
|
8173
|
+
state["tab"] = (int(state["tab"]) + delta) % len(self.CONTEXT_TABS)
|
|
8174
|
+
state["scroll"] = 0
|
|
8175
|
+
event.app.invalidate()
|
|
8176
|
+
|
|
8177
|
+
bindings = KeyBindings()
|
|
8178
|
+
bindings.add("right", eager=True)(lambda event: switch(event, 1))
|
|
8179
|
+
bindings.add("l", eager=True)(lambda event: switch(event, 1))
|
|
8180
|
+
bindings.add("left", eager=True)(lambda event: switch(event, -1))
|
|
8181
|
+
bindings.add("h", eager=True)(lambda event: switch(event, -1))
|
|
8182
|
+
bindings.add("tab", eager=True)(lambda event: switch(event, 1))
|
|
8183
|
+
bindings.add("down", eager=True)(lambda event: scroll(event, 1))
|
|
8184
|
+
bindings.add("j", eager=True)(lambda event: scroll(event, 1))
|
|
8185
|
+
bindings.add("up", eager=True)(lambda event: scroll(event, -1))
|
|
8186
|
+
bindings.add("k", eager=True)(lambda event: scroll(event, -1))
|
|
8187
|
+
bindings.add("pagedown", eager=True)(lambda event: scroll(event, viewport()))
|
|
8188
|
+
bindings.add("pageup", eager=True)(lambda event: scroll(event, -viewport()))
|
|
8189
|
+
|
|
8190
|
+
for number in range(1, len(self.CONTEXT_TABS) + 1):
|
|
8191
|
+
|
|
8192
|
+
@bindings.add(str(number), eager=True)
|
|
8193
|
+
def _jump(event, number=number):
|
|
8194
|
+
state["tab"] = number - 1
|
|
8195
|
+
state["scroll"] = 0
|
|
8196
|
+
event.app.invalidate()
|
|
8197
|
+
|
|
8198
|
+
@bindings.add("escape", eager=True)
|
|
8199
|
+
@bindings.add("q", eager=True)
|
|
8200
|
+
@bindings.add("c-c", eager=True)
|
|
8201
|
+
@bindings.add("<sigint>", eager=True)
|
|
8202
|
+
def _close(event):
|
|
8203
|
+
event.app.exit(result=None)
|
|
8204
|
+
|
|
8205
|
+
content = FormattedTextControl(fragments, focusable=True)
|
|
8206
|
+
window = Window(content, dont_extend_height=True, wrap_lines=False)
|
|
8207
|
+
app = self._make_app(Layout(HSplit([window, self.status_window()]), focused_element=window), bindings)
|
|
8208
|
+
try:
|
|
8209
|
+
self.run_input_app(app)
|
|
8210
|
+
except KeyboardInterrupt:
|
|
8211
|
+
pass
|
|
8212
|
+
|
|
8213
|
+
def render_markdown_lines(self, markdown: str, width: int) -> list[Any]:
|
|
8214
|
+
"""Render Markdown to per-line prompt_toolkit fragments via Rich, so the tab body keeps its
|
|
8215
|
+
table/heading styling inside the interactive viewer."""
|
|
8216
|
+
if not self.ui.color:
|
|
8217
|
+
return [[("", line)] for line in markdown.splitlines()]
|
|
8218
|
+
console = Console(force_terminal=True, width=width)
|
|
8219
|
+
with console.capture() as capture:
|
|
8220
|
+
console.print(Markdown(markdown))
|
|
8221
|
+
return [to_formatted_text(ANSI(line)) for line in capture.get().splitlines()]
|
|
7669
8222
|
|
|
7670
8223
|
def config(self, args: str) -> str:
|
|
7671
8224
|
provider = self.session.config.provider
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: A small terminal coding agent written in Python
|
|
5
5
|
Author-email: hit9 <hit9@icloud.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -131,6 +131,8 @@ Commands:
|
|
|
131
131
|
|
|
132
132
|
- `/help`: show commands and tools.
|
|
133
133
|
- `/status`: show runtime status, including the active session id.
|
|
134
|
+
- `/context [PATH]`: show the model's context frame — environment, memory (goal, plan, known facts, check), and file state; `PATH` shows that file's current in-context lines.
|
|
135
|
+
- `/skills`: list installed skills (load with `Skill(name)` or reference inline with `$name`).
|
|
134
136
|
- `/config`: show active config.
|
|
135
137
|
- `/api [auto|chat|anthropic]`: show or set provider API format.
|
|
136
138
|
- `/debug [on|off]`: toggle model I/O debug traces.
|
|
@@ -140,6 +142,7 @@ Commands:
|
|
|
140
142
|
- `/provider [NAME]`: show or set provider.
|
|
141
143
|
- `/model [MODEL]`: show or set model.
|
|
142
144
|
- `/reason`: choose reasoning effort.
|
|
145
|
+
- `/strict`: toggle strict tool-call schemas (OpenAI / DeepSeek only).
|
|
143
146
|
- `/set KEY VALUE`: set supported provider/runtime values for the current session.
|
|
144
147
|
- `/yolo`: toggle tool confirmations.
|
|
145
148
|
- `/exit`, `/quit`: exit.
|
|
@@ -156,6 +159,7 @@ Tools:
|
|
|
156
159
|
- Working notes: `Note`.
|
|
157
160
|
- Ask the user: `Question`.
|
|
158
161
|
- MCP: `MCP`.
|
|
162
|
+
- Skills: `Skill` loads a skill's full instructions on demand (offered whenever any skill exists — the built-in `nanocode-help` means it is normally always available).
|
|
159
163
|
|
|
160
164
|
`Read`, `Search`, and `InspectCode` return line anchors where useful. `Edit` uses current `line:hash` anchors to reject stale edits.
|
|
161
165
|
|
|
@@ -170,12 +174,14 @@ Default config location:
|
|
|
170
174
|
Main fields:
|
|
171
175
|
|
|
172
176
|
- `[provider] active = "name"`
|
|
173
|
-
- `[provider.<name>]`: `url`, `key`, `model`, `api`, `prompt_cache_key`, `available_models`, `reasoning`, `chat_reasoning`, `temperature`, `timeout`
|
|
177
|
+
- `[provider.<name>]`: `url`, `key`, `model`, `api`, `prompt_cache_key`, `available_models`, `reasoning`, `chat_reasoning`, `temperature`, `max_tokens`, `strict_tools`, `timeout`
|
|
174
178
|
- `[paths] data_dir`
|
|
175
|
-
- `[runtime] shell_timeout`, `max_agent_steps`, `max_context_tokens`, `check_updates`, `update_check_interval_hours`, `session_retention_days`, `yolo`, `debug`
|
|
179
|
+
- `[runtime] shell_timeout`, `max_agent_steps`, `max_context_tokens`, `max_parallel_tools`, `check_updates`, `update_check_interval_hours`, `session_retention_days`, `yolo`, `debug`, `tips`
|
|
176
180
|
|
|
177
181
|
`api = "auto"` chooses between Chat Completions and Anthropic Messages using provider/model profiles. `prompt_cache_key = "auto"` derives a stable key from provider, model, workspace, and tool schema names.
|
|
178
182
|
|
|
183
|
+
`strict_tools = true` (toggle with `/strict`) constrains tool-call arguments to each tool's JSON schema. It only takes effect on hosts that support it (OpenAI and DeepSeek) and on the Chat Completions path; it is a no-op elsewhere. For DeepSeek, enabling it routes requests to the `/beta` endpoint. Tools whose schemas can't be represented under strict function calling fall back to non-strict automatically.
|
|
184
|
+
|
|
179
185
|
Runtime flags such as `--yolo`, `--debug`, and `--mcp` apply to resumed sessions too. Saved sessions do not carry their old runtime config forward.
|
|
180
186
|
|
|
181
187
|
## MCP
|
|
@@ -215,6 +221,37 @@ Manage servers at runtime:
|
|
|
215
221
|
- `/mcp refresh [server]`: rediscover servers.
|
|
216
222
|
- `/mcp login <server>` / `/mcp logout <server>`: OAuth login and logout.
|
|
217
223
|
|
|
224
|
+
## Skills
|
|
225
|
+
|
|
226
|
+
Skills are reusable instruction packs the agent can pull in on demand. Each skill is a folder with a `SKILL.md`:
|
|
227
|
+
|
|
228
|
+
```text
|
|
229
|
+
.nanocode/skills/ # project skills (travel with the repo)
|
|
230
|
+
release-notes/
|
|
231
|
+
SKILL.md
|
|
232
|
+
scripts/
|
|
233
|
+
collect_commits.py
|
|
234
|
+
~/.nanocode/skills/ # personal skills (all projects)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`SKILL.md` has `name`/`description` frontmatter and a Markdown body of instructions:
|
|
238
|
+
|
|
239
|
+
```markdown
|
|
240
|
+
---
|
|
241
|
+
name: release-notes
|
|
242
|
+
description: Draft a CHANGELOG entry from commits since the last release.
|
|
243
|
+
---
|
|
244
|
+
Run `python "{skill_dir}/scripts/collect_commits.py" <last-tag>` to gather commits,
|
|
245
|
+
then group them by type and write entries in the house style.
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
- **Discovery**: `.nanocode/skills/` (project) and `~/.nanocode/skills/` (user). On a name clash the project skill wins.
|
|
249
|
+
- **How the model sees them**: only a compact `SKILLS` index (name + description) sits in context; the full body is loaded on demand when the model calls `Skill(name)`. A repeated load of the same skill collapses to a pointer so the instructions are not re-billed. When no skills are installed, nothing is added to the prompt.
|
|
250
|
+
- **Reference one inline**: type `$name` in your message (Tab-completes) to nudge the model to use that skill; its instructions are injected for that turn.
|
|
251
|
+
- **Bundled scripts**: `{skill_dir}` (or `${SKILL_DIR}`) in the body expands to the skill's absolute folder path, so the model can run bundled scripts via `Bash` (subject to normal confirmation unless `/yolo`).
|
|
252
|
+
- **Inspect**: `/skills` lists installed skills; the status bar and `/status` show the count.
|
|
253
|
+
- **Built-in**: a `nanocode-help` skill ships by default and carries a self-contained manual — authored prose on how to use nanocode, its features, and common problems, plus command/tool/config lists assembled from nanocode's own `/help` text, tool descriptions, and config keys. So "how do I / what does X / why is Y" questions are answered from the manual without searching the source, and the lists can't drift from the running version. Drop a `nanocode-help` skill of your own to override it.
|
|
254
|
+
|
|
218
255
|
## Providers
|
|
219
256
|
|
|
220
257
|
The following providers have been tested with nanocode:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|