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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.7.2
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.7.2"
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 = [tool.schema(provider.resolved_strict_tools()) for tool in TOOL_REGISTRY.values()]
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
- "/memory",
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", "/memory", "/mcp", "/yolo"})
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, "`/memory` shows the agent's current goal, plan, and known facts."),
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
- /memory Show durable agent memory.
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
- "/memory": self.memory,
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
- (self.ui.emit_answer if name in {"/status", "/mcp"} else self.emit)(output)
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 memory(self, args: str) -> str:
7656
- state = self.session.state
7657
- known = ["- " + item for item in state.known] or ["- (empty)"]
7658
- return "\n".join(
7659
- [
7660
- "goal: " + (state.goal or "(empty)"),
7661
- "summary:",
7662
- state.summary or "(empty)",
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.7.2
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:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nanocode-cli"
7
- version = "0.7.2"
7
+ version = "0.8.0"
8
8
  description = "A small terminal coding agent written in Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
File without changes
File without changes
File without changes