nanocode-cli 0.5.7__tar.gz → 0.5.10__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.5.7 → nanocode_cli-0.5.10}/MANIFEST.in +1 -0
- {nanocode_cli-0.5.7/nanocode_cli.egg-info → nanocode_cli-0.5.10}/PKG-INFO +18 -8
- {nanocode_cli-0.5.7 → nanocode_cli-0.5.10}/README.md +16 -7
- nanocode_cli-0.5.10/README.zh-CN.md +142 -0
- {nanocode_cli-0.5.7 → nanocode_cli-0.5.10}/nanocode.py +247 -67
- {nanocode_cli-0.5.7 → nanocode_cli-0.5.10/nanocode_cli.egg-info}/PKG-INFO +18 -8
- {nanocode_cli-0.5.7 → nanocode_cli-0.5.10}/nanocode_cli.egg-info/SOURCES.txt +1 -0
- {nanocode_cli-0.5.7 → nanocode_cli-0.5.10}/nanocode_cli.egg-info/requires.txt +1 -0
- {nanocode_cli-0.5.7 → nanocode_cli-0.5.10}/pyproject.toml +2 -1
- {nanocode_cli-0.5.7 → nanocode_cli-0.5.10}/LICENSE +0 -0
- {nanocode_cli-0.5.7 → nanocode_cli-0.5.10}/nanocode_cli.egg-info/dependency_links.txt +0 -0
- {nanocode_cli-0.5.7 → nanocode_cli-0.5.10}/nanocode_cli.egg-info/entry_points.txt +0 -0
- {nanocode_cli-0.5.7 → nanocode_cli-0.5.10}/nanocode_cli.egg-info/top_level.txt +0 -0
- {nanocode_cli-0.5.7 → nanocode_cli-0.5.10}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.10
|
|
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
|
|
@@ -23,6 +23,7 @@ Description-Content-Type: text/markdown
|
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: anthropic>=0.64.0
|
|
25
25
|
Requires-Dist: code-symbol-index>=0.1.13
|
|
26
|
+
Requires-Dist: json-repair
|
|
26
27
|
Requires-Dist: openai>=2.37.0
|
|
27
28
|
Requires-Dist: prompt-toolkit>=3.0
|
|
28
29
|
Requires-Dist: rich>=13.0
|
|
@@ -36,19 +37,22 @@ Dynamic: license-file
|
|
|
36
37
|
|
|
37
38
|
A small terminal coding agent written in Python.
|
|
38
39
|
|
|
40
|
+
[简体中文](README.zh-CN.md)
|
|
41
|
+
|
|
39
42
|
nanocode is pre-1.0 software. Commands, configuration, and tool behavior may change before a stable release.
|
|
40
43
|
|
|
41
44
|

|
|
42
45
|
|
|
43
46
|
## Features
|
|
44
47
|
|
|
45
|
-
- **
|
|
46
|
-
- **
|
|
47
|
-
- **
|
|
48
|
-
- **
|
|
49
|
-
- **
|
|
50
|
-
- **
|
|
51
|
-
- **
|
|
48
|
+
- **Live turn control**: Add follow-up input while the agent is still working, without losing the current tool flow.
|
|
49
|
+
- **File-state brain**: Reads and edits build a current, line-numbered view of the files that matter now.
|
|
50
|
+
- **Stale-edit protection**: `line:hash` anchors reject edits when the target code has drifted.
|
|
51
|
+
- **Project-aware navigation**: Use the symbol index to jump through outlines, references, and changed files quickly.
|
|
52
|
+
- **Recoverable context**: Tool output stays bounded in the prompt, while raw `tr.N` results remain recallable.
|
|
53
|
+
- **Cache-aware context**: Stable sections stay early and noisy working state stays late to improve prompt-cache reuse.
|
|
54
|
+
- **Focused working memory**: `Note` separates goal, plan, and known facts from noisy execution logs.
|
|
55
|
+
- **Terminal-first workflow**: Model selection, history search, confirmations, live command output, appended input, and status all stay in one CLI.
|
|
52
56
|
|
|
53
57
|
## Install
|
|
54
58
|
|
|
@@ -56,6 +60,12 @@ nanocode is pre-1.0 software. Commands, configuration, and tool behavior may cha
|
|
|
56
60
|
uv tool install nanocode-cli
|
|
57
61
|
```
|
|
58
62
|
|
|
63
|
+
Upgrade:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
uv tool upgrade nanocode-cli
|
|
67
|
+
```
|
|
68
|
+
|
|
59
69
|
For local development:
|
|
60
70
|
|
|
61
71
|
```sh
|
|
@@ -2,19 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
A small terminal coding agent written in Python.
|
|
4
4
|
|
|
5
|
+
[简体中文](README.zh-CN.md)
|
|
6
|
+
|
|
5
7
|
nanocode is pre-1.0 software. Commands, configuration, and tool behavior may change before a stable release.
|
|
6
8
|
|
|
7
9
|

|
|
8
10
|
|
|
9
11
|
## Features
|
|
10
12
|
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
13
|
+
- **Live turn control**: Add follow-up input while the agent is still working, without losing the current tool flow.
|
|
14
|
+
- **File-state brain**: Reads and edits build a current, line-numbered view of the files that matter now.
|
|
15
|
+
- **Stale-edit protection**: `line:hash` anchors reject edits when the target code has drifted.
|
|
16
|
+
- **Project-aware navigation**: Use the symbol index to jump through outlines, references, and changed files quickly.
|
|
17
|
+
- **Recoverable context**: Tool output stays bounded in the prompt, while raw `tr.N` results remain recallable.
|
|
18
|
+
- **Cache-aware context**: Stable sections stay early and noisy working state stays late to improve prompt-cache reuse.
|
|
19
|
+
- **Focused working memory**: `Note` separates goal, plan, and known facts from noisy execution logs.
|
|
20
|
+
- **Terminal-first workflow**: Model selection, history search, confirmations, live command output, appended input, and status all stay in one CLI.
|
|
18
21
|
|
|
19
22
|
## Install
|
|
20
23
|
|
|
@@ -22,6 +25,12 @@ nanocode is pre-1.0 software. Commands, configuration, and tool behavior may cha
|
|
|
22
25
|
uv tool install nanocode-cli
|
|
23
26
|
```
|
|
24
27
|
|
|
28
|
+
Upgrade:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
uv tool upgrade nanocode-cli
|
|
32
|
+
```
|
|
33
|
+
|
|
25
34
|
For local development:
|
|
26
35
|
|
|
27
36
|
```sh
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# nanocode
|
|
2
|
+
|
|
3
|
+
一个用 Python 写的小型终端编程代理。
|
|
4
|
+
|
|
5
|
+
[English](README.md)
|
|
6
|
+
|
|
7
|
+
nanocode 仍是 1.0 前的软件。稳定版发布前,命令、配置和工具行为都可能变化。
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## 特性
|
|
12
|
+
|
|
13
|
+
- **实时回合控制**:代理还在工作时,也可以追加输入,不打断当前工具流程。
|
|
14
|
+
- **文件状态大脑**:`Read` 和 `Edit` 会构建当前重要文件的带行号视图。
|
|
15
|
+
- **过期编辑保护**:`line:hash` 锚点会在目标代码漂移后拒绝错误编辑。
|
|
16
|
+
- **项目级导航**:通过符号索引快速查看 outline、references 和变更文件。
|
|
17
|
+
- **可恢复上下文**:prompt 中的工具输出保持有界,原始 `tr.N` 结果仍可按需召回。
|
|
18
|
+
- **缓存友好上下文**:稳定内容靠前,嘈杂的工作状态靠后,提高 prompt cache 复用率。
|
|
19
|
+
- **聚焦工作记忆**:`Note` 把 goal、plan、known facts 从嘈杂执行日志中拆出来。
|
|
20
|
+
- **终端优先工作流**:模型选择、历史搜索、确认、实时命令输出、追加输入和状态展示都在一个 CLI 内完成。
|
|
21
|
+
|
|
22
|
+
## 安装
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
uv tool install nanocode-cli
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
升级:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
uv tool upgrade nanocode-cli
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
本地开发:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
uv sync --extra dev
|
|
38
|
+
uv run nanocode
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 使用
|
|
42
|
+
|
|
43
|
+
启动 CLI:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
nanocode
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
常用参数:
|
|
50
|
+
|
|
51
|
+
- `--config <path>`:使用指定 TOML 配置文件。
|
|
52
|
+
- `--init-config`:创建默认配置文件。
|
|
53
|
+
- `--yolo`:跳过会修改环境的工具确认。
|
|
54
|
+
- `-v`, `--version`:显示版本。
|
|
55
|
+
|
|
56
|
+
代理运行中,`+>` 提示符可以接收追加输入,并在下一次模型请求中发送。
|
|
57
|
+
|
|
58
|
+
## 命令
|
|
59
|
+
|
|
60
|
+
- `/help`:显示命令和工具。
|
|
61
|
+
- `/status`:显示运行状态。
|
|
62
|
+
- `/config`:显示当前配置。
|
|
63
|
+
- `/api [auto|chat|anthropic]`:显示或设置 provider API 格式。
|
|
64
|
+
- `/debug [on|off]`:切换模型 I/O debug trace。
|
|
65
|
+
- `/compact`:立即压缩上下文。
|
|
66
|
+
- `/index [force]`:同步或重建代码符号索引。
|
|
67
|
+
- `/provider [NAME]`:显示或设置 provider。
|
|
68
|
+
- `/model [MODEL]`:显示或设置模型。
|
|
69
|
+
- `/reason`:选择 reasoning effort。
|
|
70
|
+
- `/set KEY VALUE`:设置 provider/runtime 值。
|
|
71
|
+
- `/yolo`:切换工具确认。
|
|
72
|
+
- `/exit`, `/quit`:退出。
|
|
73
|
+
|
|
74
|
+
交互选择器支持 `j`/`k`、方向键、`/` 搜索、Enter 和 Esc。输入框支持历史、补全和 `Ctrl-R` 历史搜索。
|
|
75
|
+
|
|
76
|
+
## 工具
|
|
77
|
+
|
|
78
|
+
- 文件:`Read`, `LineCount`, `List`, `Find`, `Search`。
|
|
79
|
+
- 代码索引:`InspectCode`。
|
|
80
|
+
- 编辑:`Edit` 创建或修改文件内容。
|
|
81
|
+
- Shell:`Bash`, `Git`。
|
|
82
|
+
- 工具结果:`Recall`。
|
|
83
|
+
- 工作笔记:`Note`。
|
|
84
|
+
|
|
85
|
+
`Read`、`Search` 和 `InspectCode` 会在合适时返回行锚点。`Edit` 使用当前 `line:hash` 锚点拒绝过期编辑。
|
|
86
|
+
|
|
87
|
+
## 配置
|
|
88
|
+
|
|
89
|
+
运行:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
nanocode --init-config
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
默认配置位置是 `~/.nanocode/config.toml`。
|
|
96
|
+
|
|
97
|
+
主要字段:
|
|
98
|
+
|
|
99
|
+
- `[provider] active = "name"`
|
|
100
|
+
- `[provider.<name>]`:`url`, `key`, `model`, `api`, `prompt_cache_key`, `available_models`, `reasoning`, `chat_reasoning`, `temperature`, `timeout`
|
|
101
|
+
- `[paths] data_dir`
|
|
102
|
+
- `[runtime] shell_timeout`, `max_agent_steps`, `max_context_tokens`, `yolo`
|
|
103
|
+
|
|
104
|
+
`api = "auto"` 会根据 provider/model profile 在 Chat Completions 和 Anthropic Messages 之间选择。`prompt_cache_key = "auto"` 会根据 provider、model、workspace 和工具 schema 名称生成稳定 key。
|
|
105
|
+
|
|
106
|
+
## 上下文设计
|
|
107
|
+
|
|
108
|
+
每次模型请求都由 nanocode 手动构建成明确的 messages。稳定上下文在前,会话作为 messages 保留,工作记忆随后,最新文件状态放在末尾。
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
model request
|
|
112
|
+
+--------------------------------------------------+
|
|
113
|
+
| system |
|
|
114
|
+
| concise agent contract and tool rules |
|
|
115
|
+
+--------------------------------------------------+
|
|
116
|
+
| user |
|
|
117
|
+
| Environment |
|
|
118
|
+
+--------------------------------------------------+
|
|
119
|
+
| user/assistant |
|
|
120
|
+
| conversation, compacted summaries, tools |
|
|
121
|
+
+--------------------------------------------------+
|
|
122
|
+
| user |
|
|
123
|
+
| Memory: goal, plan, known, date |
|
|
124
|
+
+--------------------------------------------------+
|
|
125
|
+
| user |
|
|
126
|
+
| FILE STATE: latest Read/Edit file view |
|
|
127
|
+
+--------------------------------------------------+
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
核心规则:
|
|
131
|
+
|
|
132
|
+
- 回合中的 assistant 文本和用户追加输入都会作为 conversation 保留。
|
|
133
|
+
- 上下文过大时,较早 conversation 会压缩成明确的 summary。
|
|
134
|
+
- FILE STATE 由成功的 `Read` 和 `Edit` 更新,展示当前文件范围,最近文件优先。
|
|
135
|
+
- 更新的文件行会覆盖旧行;edit invalidation 会清理过期范围。
|
|
136
|
+
- 文件行展示前会通过当前文件 stat 或行 hash 校验。
|
|
137
|
+
- 成功的 `Read` 和 `Edit` 工具消息只指向 FILE STATE,不重复塞入文件正文。
|
|
138
|
+
- 其他工具输出在 conversation messages 中保持有界,并可通过 `tr.N` 召回。
|
|
139
|
+
|
|
140
|
+
## 安全
|
|
141
|
+
|
|
142
|
+
nanocode 会在启动它的环境中编辑文件和执行 shell 命令。它不提供 sandbox 保护。需要隔离时,请在你自己的 sandbox、容器、虚拟机或其他隔离环境中运行。
|
|
@@ -11,10 +11,12 @@ import os
|
|
|
11
11
|
import platform
|
|
12
12
|
import re
|
|
13
13
|
import selectors
|
|
14
|
+
import shlex
|
|
14
15
|
import shutil
|
|
15
16
|
import signal
|
|
16
17
|
import subprocess
|
|
17
18
|
import sys
|
|
19
|
+
import tempfile
|
|
18
20
|
import threading
|
|
19
21
|
import time
|
|
20
22
|
import tomllib
|
|
@@ -28,6 +30,7 @@ from urllib.request import Request, urlopen
|
|
|
28
30
|
|
|
29
31
|
import code_symbol_index as csi
|
|
30
32
|
from anthropic import Anthropic
|
|
33
|
+
from json_repair import repair_json
|
|
31
34
|
from openai import OpenAI
|
|
32
35
|
from prompt_toolkit import print_formatted_text, search as pt_search
|
|
33
36
|
from prompt_toolkit.application import Application, run_in_terminal
|
|
@@ -52,7 +55,7 @@ from prompt_toolkit.widgets import SearchToolbar
|
|
|
52
55
|
from rich.console import Console
|
|
53
56
|
from rich.markdown import Markdown
|
|
54
57
|
|
|
55
|
-
__version__ = "0.5.
|
|
58
|
+
__version__ = "0.5.10"
|
|
56
59
|
|
|
57
60
|
Json = dict[str, Any]
|
|
58
61
|
HTTP_USER_AGENT = "nanocode/" + __version__
|
|
@@ -407,6 +410,7 @@ class AgentState:
|
|
|
407
410
|
goal: str = ""
|
|
408
411
|
plan: list[str] = field(default_factory=list)
|
|
409
412
|
known: list[str] = field(default_factory=list)
|
|
413
|
+
check: str = ""
|
|
410
414
|
summary: str = ""
|
|
411
415
|
code_index_status: str = ""
|
|
412
416
|
code_index_error: str = ""
|
|
@@ -439,7 +443,7 @@ class AgentState:
|
|
|
439
443
|
return next((text for item in self.plan if (text := self.plan_text(item))), "")
|
|
440
444
|
|
|
441
445
|
def apply(self, data: Json) -> None:
|
|
442
|
-
for attr in ("goal", "summary"):
|
|
446
|
+
for attr in ("goal", "summary", "check"):
|
|
443
447
|
if isinstance(data.get(attr), str):
|
|
444
448
|
setattr(self, attr, str(data[attr]).strip())
|
|
445
449
|
for attr in ("plan", "known"):
|
|
@@ -450,7 +454,7 @@ class AgentState:
|
|
|
450
454
|
|
|
451
455
|
def format(self) -> str:
|
|
452
456
|
known = ["- " + item for item in self.known] or ["- (empty)"]
|
|
453
|
-
return "\n".join(["Goal: " + (self.goal or "(empty)"), "Plan:", *self.plan_rows(), "Known:", *known])
|
|
457
|
+
return "\n".join(["Goal: " + (self.goal or "(empty)"), "Plan:", *self.plan_rows(), "Known:", *known, "Check: " + (self.check or "(empty)")])
|
|
454
458
|
|
|
455
459
|
|
|
456
460
|
@dataclass
|
|
@@ -549,6 +553,7 @@ class Session:
|
|
|
549
553
|
tool_counter: int = 0
|
|
550
554
|
usage: ModelUsage = field(default_factory=ModelUsage)
|
|
551
555
|
update: UpdateStatus = field(default_factory=UpdateStatus)
|
|
556
|
+
_gitignore_cache: dict[str, tuple[int, list[str]]] = field(default_factory=dict)
|
|
552
557
|
|
|
553
558
|
def __post_init__(self) -> None:
|
|
554
559
|
if self.system_info is None:
|
|
@@ -755,12 +760,23 @@ class Tool:
|
|
|
755
760
|
|
|
756
761
|
def gitignore_patterns(self, root: str) -> list[str]:
|
|
757
762
|
patterns = []
|
|
758
|
-
|
|
763
|
+
cache = self.session._gitignore_cache
|
|
764
|
+
paths = [os.path.join(self.session.cwd, ".gitignore")]
|
|
765
|
+
if os.path.isdir(root):
|
|
766
|
+
paths.append(os.path.join(root, ".gitignore"))
|
|
767
|
+
for path in dict.fromkeys(paths):
|
|
759
768
|
try:
|
|
769
|
+
mtime = os.stat(path).st_mtime_ns
|
|
770
|
+
cached = cache.get(path)
|
|
771
|
+
if cached is not None and cached[0] == mtime:
|
|
772
|
+
patterns.extend(cached[1])
|
|
773
|
+
continue
|
|
760
774
|
with open(path, encoding="utf-8") as file:
|
|
761
|
-
|
|
775
|
+
pats = [line.strip() for line in file if line.strip() and not line.lstrip().startswith("#") and not line.startswith("!")]
|
|
776
|
+
cache[path] = (mtime, pats)
|
|
777
|
+
patterns.extend(pats)
|
|
762
778
|
except OSError:
|
|
763
|
-
|
|
779
|
+
cache.pop(path, None)
|
|
764
780
|
return patterns
|
|
765
781
|
|
|
766
782
|
def ignored(self, path: str, patterns: list[str]) -> bool:
|
|
@@ -1742,7 +1758,7 @@ class EditTool(Tool):
|
|
|
1742
1758
|
|
|
1743
1759
|
class BashTool(Tool):
|
|
1744
1760
|
NAME = "Bash"
|
|
1745
|
-
DESCRIPTION = "Run one bash shell invocation in the workspace; returns exit_code/stdout/stderr and shows live output."
|
|
1761
|
+
DESCRIPTION = "Run one bash shell invocation in the workspace; returns exit_code/stdout/stderr and shows live output. Avoid unbounded output; limit noisy commands with head/tail/sed/rg filters or command-specific limits, and inspect large outputs in chunks."
|
|
1746
1762
|
SIGNATURE = "Bash(command)"
|
|
1747
1763
|
EXAMPLE = (
|
|
1748
1764
|
'Check environment. Example: {"command":"python3 --version"}',
|
|
@@ -1869,20 +1885,22 @@ class BashTool(Tool):
|
|
|
1869
1885
|
|
|
1870
1886
|
class GitTool(Tool):
|
|
1871
1887
|
NAME = "Git"
|
|
1872
|
-
DESCRIPTION =
|
|
1873
|
-
SIGNATURE = "Git(argv=[
|
|
1888
|
+
DESCRIPTION = 'Run git with explicit argv (default: cwd from Environment; use cwd= for other directories). For add, pass explicit file paths; broad add is rejected.'
|
|
1889
|
+
SIGNATURE = "Git(argv=[command,...], cwd?)"
|
|
1874
1890
|
EXAMPLE = (
|
|
1875
|
-
'
|
|
1876
|
-
'Diff
|
|
1877
|
-
'
|
|
1891
|
+
'Status. Example: {"argv":["status","--short"]}',
|
|
1892
|
+
'Diff. Example: {"cwd":"src","argv":["diff","--","app.py"]}',
|
|
1893
|
+
'Stage explicit files. Example: {"argv":["add","--","nanocode.py","README.md"]}',
|
|
1894
|
+
'Show commit/file. Example: {"argv":["show","--stat","HEAD"]}',
|
|
1878
1895
|
)
|
|
1879
1896
|
READONLY = {"status", "diff", "log", "show", "rev-parse", "ls-files", "grep", "blame"}
|
|
1897
|
+
BROAD_ADD = {"-A", "--all", ".", "./", ":/", "*"}
|
|
1880
1898
|
|
|
1881
1899
|
@classmethod
|
|
1882
1900
|
def params_schema(cls) -> Json:
|
|
1883
1901
|
return {
|
|
1884
1902
|
"type": "object",
|
|
1885
|
-
"properties": {"cwd": {"type": "string"}, "argv": {"type": "array", "items": {"type": "string"}, "minItems": 1}},
|
|
1903
|
+
"properties": {"cwd": {"type": "string"}, "argv": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1}},
|
|
1886
1904
|
"required": ["argv"],
|
|
1887
1905
|
"additionalProperties": False,
|
|
1888
1906
|
}
|
|
@@ -1923,8 +1941,26 @@ class GitTool(Tool):
|
|
|
1923
1941
|
raise ToolError("git cwd is not a directory")
|
|
1924
1942
|
if not args:
|
|
1925
1943
|
raise ToolError("Git requires arguments")
|
|
1944
|
+
self.validate_add(args, cwd)
|
|
1926
1945
|
return args, cwd
|
|
1927
1946
|
|
|
1947
|
+
def validate_add(self, args: list[str], cwd: str) -> None:
|
|
1948
|
+
if args[0] != "add":
|
|
1949
|
+
return
|
|
1950
|
+
paths, explicit = [], False
|
|
1951
|
+
for arg in args[1:]:
|
|
1952
|
+
if arg == "--":
|
|
1953
|
+
explicit = True
|
|
1954
|
+
elif arg in self.BROAD_ADD or arg.startswith("--pathspec-from-file"):
|
|
1955
|
+
raise ToolError("Git add requires explicit file paths")
|
|
1956
|
+
elif explicit or not arg.startswith("-"):
|
|
1957
|
+
paths.append(arg)
|
|
1958
|
+
if not paths:
|
|
1959
|
+
raise ToolError("Git add requires explicit file paths")
|
|
1960
|
+
for path in paths:
|
|
1961
|
+
if path.startswith(":") or any(char in path for char in "*?[]") or not self.session.in_cwd(os.path.abspath(os.path.join(cwd, path))):
|
|
1962
|
+
raise ToolError("Git add requires explicit file paths inside workspace")
|
|
1963
|
+
|
|
1928
1964
|
|
|
1929
1965
|
class RecallTool(Tool):
|
|
1930
1966
|
NAME = "Recall"
|
|
@@ -2013,15 +2049,15 @@ class RecallTool(Tool):
|
|
|
2013
2049
|
|
|
2014
2050
|
class NoteTool(Tool):
|
|
2015
2051
|
NAME = "Note"
|
|
2016
|
-
DESCRIPTION = "Maintain durable working notes; goal and
|
|
2017
|
-
SIGNATURE = "Note(goal?, plan?, known?)"
|
|
2018
|
-
EXAMPLE = ('Set memory. Example: {"goal":"ship parser fix","plan":["inspect parser","patch bug"],"known":["tests use pytest"]}',)
|
|
2052
|
+
DESCRIPTION = "Maintain durable working notes; goal, plan, and check replace current values, known appends unique facts."
|
|
2053
|
+
SIGNATURE = "Note(goal?, plan?, known?, check?)"
|
|
2054
|
+
EXAMPLE = ('Set memory. Example: {"goal":"ship parser fix","plan":["inspect parser","patch bug"],"known":["tests use pytest"],"check":"pytest passed"}',)
|
|
2019
2055
|
STORES_RESULT = False
|
|
2020
2056
|
|
|
2021
2057
|
@classmethod
|
|
2022
2058
|
def params_schema(cls) -> Json:
|
|
2023
2059
|
strings = {"type": "array", "items": {"type": "string"}, "minItems": 1}
|
|
2024
|
-
return {"type": "object", "properties": {"goal": {"type": "string"}, "plan": strings, "known": strings}, "additionalProperties": False}
|
|
2060
|
+
return {"type": "object", "properties": {"goal": {"type": "string"}, "plan": strings, "known": strings, "check": {"type": "string"}}, "additionalProperties": False}
|
|
2025
2061
|
|
|
2026
2062
|
@classmethod
|
|
2027
2063
|
def payload_args(cls, payload: Json) -> list[Any]:
|
|
@@ -2031,7 +2067,7 @@ class NoteTool(Tool):
|
|
|
2031
2067
|
if len(self.args) != 1 or not isinstance(self.args[0], dict):
|
|
2032
2068
|
raise ToolError("Note requires named fields")
|
|
2033
2069
|
data = self.args[0]
|
|
2034
|
-
if unexpected := sorted(set(data) - {"goal", "plan", "known"}):
|
|
2070
|
+
if unexpected := sorted(set(data) - {"goal", "plan", "known", "check"}):
|
|
2035
2071
|
raise ToolError("Note unexpected field: " + ", ".join(unexpected))
|
|
2036
2072
|
changed = []
|
|
2037
2073
|
if "goal" in data:
|
|
@@ -2047,8 +2083,11 @@ class NoteTool(Tool):
|
|
|
2047
2083
|
raise ToolError("Note known must be an array")
|
|
2048
2084
|
self.session.state.known = list(dict.fromkeys([*self.session.state.known, *(str(item).strip() for item in data["known"] if str(item).strip())]))
|
|
2049
2085
|
changed.append("known")
|
|
2086
|
+
if "check" in data:
|
|
2087
|
+
self.session.state.check = str(data["check"]).strip()
|
|
2088
|
+
changed.append("check")
|
|
2050
2089
|
if not changed:
|
|
2051
|
-
raise ToolError("Note requires goal, plan, or
|
|
2090
|
+
raise ToolError("Note requires goal, plan, known, or check")
|
|
2052
2091
|
return "Updated memory: " + ", ".join(changed)
|
|
2053
2092
|
|
|
2054
2093
|
def short_args(self) -> list[str]:
|
|
@@ -2062,6 +2101,8 @@ class NoteTool(Tool):
|
|
|
2062
2101
|
known = [Tool.compact(item, 120) for item in data["known"] if str(item).strip() and str(item).strip() not in self.session.state.known]
|
|
2063
2102
|
if known:
|
|
2064
2103
|
lines.extend(["known:", *(f" + {item}" for item in known)])
|
|
2104
|
+
if check := str(data.get("check") or "").strip():
|
|
2105
|
+
lines.append("check -> " + Tool.compact(check, 120))
|
|
2065
2106
|
return ["\n".join(lines) or "{}"]
|
|
2066
2107
|
|
|
2067
2108
|
|
|
@@ -2091,6 +2132,11 @@ class ToolCall:
|
|
|
2091
2132
|
class ContextManager:
|
|
2092
2133
|
COMPACT_TITLE: ClassVar[str] = "--- Prior Conversation Summary (compacted) ---"
|
|
2093
2134
|
COMPACT_RECENT_MESSAGES: ClassVar[int] = 8
|
|
2135
|
+
CODE_EXTENSIONS: ClassVar[set[str]] = set(
|
|
2136
|
+
".c .cc .cpp .cxx .css .go .h .hpp .html .java .js .json .jsx .kt .lua .php .py .rb .rs .scss .sh .sql "
|
|
2137
|
+
".swift .toml .ts .tsx .vue .yaml .yml".split()
|
|
2138
|
+
)
|
|
2139
|
+
CODE_FILENAMES: ClassVar[set[str]] = {"CMakeLists.txt", "Dockerfile", "Makefile", "go.mod", "package.json", "pyproject.toml"}
|
|
2094
2140
|
|
|
2095
2141
|
@dataclass
|
|
2096
2142
|
class FileContextItem:
|
|
@@ -2123,28 +2169,35 @@ class ContextManager:
|
|
|
2123
2169
|
|
|
2124
2170
|
def update_percent(self, messages: list[Json]) -> int:
|
|
2125
2171
|
tokens = self.estimated_tokens(messages)
|
|
2126
|
-
self.session.state.context_percent = min(100,
|
|
2172
|
+
self.session.state.context_percent = min(100, tokens * 100 // self.session.settings.max_context_tokens)
|
|
2127
2173
|
return self.session.state.context_percent
|
|
2128
2174
|
|
|
2129
2175
|
def maybe_compact(self, model: "ModelClient", base_system: str, turn_messages: list[Json] | None = None) -> None:
|
|
2130
|
-
if self.
|
|
2176
|
+
if not self.over_budget(base_system, turn_messages):
|
|
2131
2177
|
return
|
|
2132
2178
|
compacted, keep = self.compaction_parts()
|
|
2133
|
-
if
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2179
|
+
if compacted:
|
|
2180
|
+
try:
|
|
2181
|
+
self.apply_compaction(model.compact(self.compaction_input(compacted)), keep, turn_messages)
|
|
2182
|
+
except Exception:
|
|
2183
|
+
self.apply_compaction_fallback(keep, turn_messages)
|
|
2184
|
+
if turn_messages is not None and self.over_budget(base_system, turn_messages):
|
|
2185
|
+
compacted, keep = self.turn_compaction_parts(turn_messages)
|
|
2186
|
+
if compacted:
|
|
2187
|
+
try:
|
|
2188
|
+
self.apply_turn_compaction(model.compact(self.compaction_input(compacted)), keep, turn_messages)
|
|
2189
|
+
except Exception:
|
|
2190
|
+
self.apply_turn_compaction_fallback(keep, turn_messages)
|
|
2191
|
+
|
|
2192
|
+
def over_budget(self, base_system: str, turn_messages: list[Json] | None = None) -> bool:
|
|
2193
|
+
return self.estimated_tokens(self.model_messages(base_system, turn_messages)) >= self.session.settings.max_context_tokens
|
|
2142
2194
|
|
|
2143
2195
|
def memory_context(self, *, with_date: bool = False) -> str:
|
|
2144
2196
|
rows = [
|
|
2145
2197
|
"Goal: " + (self.session.state.goal or "(empty; use Note for multi-step work)"),
|
|
2146
2198
|
"Plan:\n" + "\n".join(self.session.state.plan_rows() or ["- (empty; use Note for a short plan)"]),
|
|
2147
2199
|
"Known:\n" + "\n".join("- " + item for item in self.session.state.known or ["(empty)"]),
|
|
2200
|
+
"Check: " + (self.session.state.check or "(empty)"),
|
|
2148
2201
|
]
|
|
2149
2202
|
if with_date:
|
|
2150
2203
|
rows.append("Date: " + datetime.now().astimezone().strftime("%Y-%m-%d"))
|
|
@@ -2242,8 +2295,10 @@ class ContextManager:
|
|
|
2242
2295
|
)
|
|
2243
2296
|
|
|
2244
2297
|
paths = sorted((path for path in lines_by_path if lines_by_path[path]), key=lambda path: (-recent(path), path))
|
|
2298
|
+
code_edits = self.recent_code_edits()
|
|
2299
|
+
check_status = self.check_status(code_edits)
|
|
2245
2300
|
focus, actions, errors = self.session.state.current_focus(), self.recent_file_actions(), self.recent_tool_errors()
|
|
2246
|
-
if not paths and not omitted and not focus and not actions and not errors:
|
|
2301
|
+
if not paths and not omitted and not focus and not actions and not code_edits and not check_status and not errors:
|
|
2247
2302
|
return ""
|
|
2248
2303
|
chunks = ["Read/Edit outputs update this section. Treat listed ranges as current file state."] if paths else []
|
|
2249
2304
|
if focus:
|
|
@@ -2254,6 +2309,10 @@ class ContextManager:
|
|
|
2254
2309
|
chunks.extend(f"- {path} {start}:{end} current" for start, end in self.coverage(lines_by_path[path]))
|
|
2255
2310
|
if actions:
|
|
2256
2311
|
chunks.extend(["", "Recent file events:", *actions])
|
|
2312
|
+
if code_edits:
|
|
2313
|
+
chunks.extend(["", "Recent code edits:", *code_edits])
|
|
2314
|
+
if check_status:
|
|
2315
|
+
chunks.extend(["", "Check status:", *check_status])
|
|
2257
2316
|
if errors:
|
|
2258
2317
|
chunks.extend(["", "Recent tool errors:", *errors])
|
|
2259
2318
|
if paths:
|
|
@@ -2267,7 +2326,6 @@ class ContextManager:
|
|
|
2267
2326
|
chunks.append("Omitted content:")
|
|
2268
2327
|
for path in sorted(omitted):
|
|
2269
2328
|
chunks.extend(f"- {path} source={source} lines={count}" for source, count in sorted(omitted[path].items()))
|
|
2270
|
-
chunks.extend(["", "OUTPUT IN USER LANGUAGE"])
|
|
2271
2329
|
return "\n".join(chunks).strip() if len(chunks) > 4 else ""
|
|
2272
2330
|
|
|
2273
2331
|
def recent_file_actions(self) -> list[str]:
|
|
@@ -2284,6 +2342,30 @@ class ContextManager:
|
|
|
2284
2342
|
for record in self.session.tool_errors[-5:]
|
|
2285
2343
|
]
|
|
2286
2344
|
|
|
2345
|
+
def recent_code_edits(self) -> list[str]:
|
|
2346
|
+
rows: dict[str, str] = {}
|
|
2347
|
+
for record in self.session.tool_records[-20:]:
|
|
2348
|
+
if record.name == "Edit":
|
|
2349
|
+
for match in re.finditer(r'<Edit\s+path=(".*?")', record.output):
|
|
2350
|
+
try:
|
|
2351
|
+
path = str(json.loads(match.group(1)))
|
|
2352
|
+
except json.JSONDecodeError:
|
|
2353
|
+
continue
|
|
2354
|
+
if self.code_like_path(path):
|
|
2355
|
+
rows[path] = f"- {record.key} Edit {path}"
|
|
2356
|
+
return list(rows.values())[-8:]
|
|
2357
|
+
|
|
2358
|
+
def check_status(self, code_edits: list[str]) -> list[str]:
|
|
2359
|
+
if not code_edits:
|
|
2360
|
+
return []
|
|
2361
|
+
check = self.session.state.check.strip()
|
|
2362
|
+
return ["- " + check] if check else ["- Code changed recently. Use Note(check=...) after checks, or final must say checks not run."]
|
|
2363
|
+
|
|
2364
|
+
@classmethod
|
|
2365
|
+
def code_like_path(cls, path: str) -> bool:
|
|
2366
|
+
name = os.path.basename(path)
|
|
2367
|
+
return name in cls.CODE_FILENAMES or os.path.splitext(name)[1].lower() in cls.CODE_EXTENSIONS
|
|
2368
|
+
|
|
2287
2369
|
def coverage(self, numbered: dict[int, tuple[str, str, str]]) -> list[tuple[int, int]]:
|
|
2288
2370
|
numbers = sorted(numbered)
|
|
2289
2371
|
if not numbers:
|
|
@@ -2320,7 +2402,7 @@ class ContextManager:
|
|
|
2320
2402
|
return segments
|
|
2321
2403
|
|
|
2322
2404
|
def compaction_input(self, messages: list[Json]) -> str:
|
|
2323
|
-
older, recent = self.
|
|
2405
|
+
older, recent = self.compaction_parts_for(messages)
|
|
2324
2406
|
return "\n\n".join(
|
|
2325
2407
|
[
|
|
2326
2408
|
"State:\n" + self.session.state.format(),
|
|
@@ -2334,7 +2416,14 @@ class ContextManager:
|
|
|
2334
2416
|
index = self.latest_user_index(self.session.messages)
|
|
2335
2417
|
return (self.session.messages, []) if index is None else (self.session.messages[:index], self.session.messages[index:])
|
|
2336
2418
|
|
|
2337
|
-
def
|
|
2419
|
+
def turn_compaction_parts(self, messages: list[Json]) -> tuple[list[Json], list[Json]]:
|
|
2420
|
+
index = self.latest_user_index(messages)
|
|
2421
|
+
if index is None:
|
|
2422
|
+
return self.compaction_parts_for(messages)
|
|
2423
|
+
compacted, keep = self.compaction_parts_for(messages[index + 1 :])
|
|
2424
|
+
return compacted, messages[: index + 1] + keep
|
|
2425
|
+
|
|
2426
|
+
def compaction_parts_for(self, messages: list[Json]) -> tuple[list[Json], list[Json]]:
|
|
2338
2427
|
cut = max(0, len(messages) - self.COMPACT_RECENT_MESSAGES)
|
|
2339
2428
|
return messages[:cut], messages[cut:]
|
|
2340
2429
|
|
|
@@ -2347,6 +2436,24 @@ class ContextManager:
|
|
|
2347
2436
|
self.session.messages = ([{"role": "user", "content": self.COMPACT_TITLE + "\n" + summary}] if summary else []) + keep
|
|
2348
2437
|
self.prune_tool_records([*self.session.messages, *(tool_messages or [])])
|
|
2349
2438
|
|
|
2439
|
+
def apply_compaction_fallback(self, keep: list[Json], tool_messages: list[Json] | None = None) -> None:
|
|
2440
|
+
self.session.state.summary = (self.session.state.summary + "\nPrevious context was deterministically trimmed.").strip()
|
|
2441
|
+
summary = self.session.state.summary
|
|
2442
|
+
self.session.messages = ([{"role": "user", "content": self.COMPACT_TITLE + "\n" + summary}] if summary else []) + keep
|
|
2443
|
+
self.prune_tool_records([*keep, *(tool_messages or [])])
|
|
2444
|
+
|
|
2445
|
+
def apply_turn_compaction(self, data: Json, keep: list[Json], turn_messages: list[Json]) -> None:
|
|
2446
|
+
self.session.state.apply(data)
|
|
2447
|
+
summary = self.session.state.summary
|
|
2448
|
+
index = self.latest_user_index(keep)
|
|
2449
|
+
insert = len(keep) if index is None else index + 1
|
|
2450
|
+
turn_messages[:] = keep[:insert] + ([{"role": "user", "content": self.COMPACT_TITLE + "\n" + summary}] if summary else []) + keep[insert:]
|
|
2451
|
+
self.prune_tool_records([*self.session.messages, *turn_messages])
|
|
2452
|
+
|
|
2453
|
+
def apply_turn_compaction_fallback(self, keep: list[Json], turn_messages: list[Json]) -> None:
|
|
2454
|
+
self.session.state.summary = (self.session.state.summary + "\nCurrent turn context was deterministically trimmed.").strip()
|
|
2455
|
+
self.apply_turn_compaction({"summary": self.session.state.summary}, keep, turn_messages)
|
|
2456
|
+
|
|
2350
2457
|
def prune_tool_records(self, keep_messages: list[Json]) -> None:
|
|
2351
2458
|
records = self.session.tool_records
|
|
2352
2459
|
keep = set(re.findall(r"\btr\.\d+\b", self.messages_text(keep_messages)))
|
|
@@ -2629,6 +2736,7 @@ class ToolRunner:
|
|
|
2629
2736
|
self.input_fn = input_fn
|
|
2630
2737
|
self.output_fn = output_fn
|
|
2631
2738
|
self.preview_fn: Callable[[str], bool] | None = None
|
|
2739
|
+
self.preview_full_fn: Callable[[str], None] | None = None
|
|
2632
2740
|
self.live_output: Callable[[str, str], None] | None = None
|
|
2633
2741
|
self.live_start: Callable[[], None] | None = None
|
|
2634
2742
|
|
|
@@ -2783,8 +2891,11 @@ class ToolRunner:
|
|
|
2783
2891
|
CodeIndex(self.session).update(list(dict.fromkeys(paths)))
|
|
2784
2892
|
|
|
2785
2893
|
def confirm(self, call: ToolCall, tool: Tool, batch_suffix: str = "", planned_edit: EditBatchPlan.PlannedEdit | None = None) -> tuple[bool, str]:
|
|
2786
|
-
|
|
2787
|
-
|
|
2894
|
+
display = self.approval_display(call, tool, "confirm", batch_suffix=batch_suffix, planned_edit=planned_edit, preview_lines=40)
|
|
2895
|
+
if self.preview_full_fn and tool.NAME == "Edit":
|
|
2896
|
+
self.preview_full_fn(self.approval_display(call, tool, "confirm", batch_suffix=batch_suffix, planned_edit=planned_edit, preview_lines=None))
|
|
2897
|
+
if not (self.preview_fn and self.preview_fn(display)):
|
|
2898
|
+
self.output_fn(display)
|
|
2788
2899
|
answer = self.input_fn("[Y/n or reason] ").strip()
|
|
2789
2900
|
lower = answer.lower()
|
|
2790
2901
|
if lower in {"", "y", "yes"}:
|
|
@@ -2792,19 +2903,26 @@ class ToolRunner:
|
|
|
2792
2903
|
return False, "" if lower in {"n", "no"} else answer
|
|
2793
2904
|
|
|
2794
2905
|
def approval_display(
|
|
2795
|
-
self,
|
|
2906
|
+
self,
|
|
2907
|
+
call: ToolCall,
|
|
2908
|
+
tool: Tool,
|
|
2909
|
+
status: str,
|
|
2910
|
+
batch_suffix: str = "",
|
|
2911
|
+
planned_edit: EditBatchPlan.PlannedEdit | None = None,
|
|
2912
|
+
preview_lines: int | None = 40,
|
|
2796
2913
|
) -> str:
|
|
2797
2914
|
header = self.with_batch_suffix(("approve " if status == "confirm" else "auto ") + self.short_call(call), batch_suffix)
|
|
2798
2915
|
if tool.NAME != "Edit":
|
|
2799
2916
|
return header
|
|
2800
2917
|
preview = planned_edit.preview(tool) if planned_edit and isinstance(tool, EditTool) else tool.preview()
|
|
2801
|
-
return header + (("\n" + block) if (block := self.preview_block(preview)) else "")
|
|
2918
|
+
return header + (("\n" + block) if (block := self.preview_block(preview, max_lines=preview_lines)) else "")
|
|
2802
2919
|
|
|
2803
|
-
def preview_block(self, preview: str, *, max_lines: int = 40) -> str:
|
|
2920
|
+
def preview_block(self, preview: str, *, max_lines: int | None = 40) -> str:
|
|
2804
2921
|
lines = preview.rstrip().splitlines()
|
|
2805
2922
|
if not lines:
|
|
2806
2923
|
return ""
|
|
2807
|
-
|
|
2924
|
+
if max_lines is not None and len(lines) > max_lines:
|
|
2925
|
+
lines = lines[:max_lines] + [f"... preview truncated: {len(lines) - max_lines} more lines (Ctrl-A: full preview) ..."]
|
|
2808
2926
|
return "\n".join([" preview", *(" " + line for line in lines)])
|
|
2809
2927
|
|
|
2810
2928
|
def finish_display(
|
|
@@ -2981,7 +3099,8 @@ class ModelClient:
|
|
|
2981
3099
|
def compact(self, context: str) -> Json:
|
|
2982
3100
|
prompt = """
|
|
2983
3101
|
Compact the nanocode working context.
|
|
2984
|
-
Return
|
|
3102
|
+
Return one JSON object only. No markdown, prose, code fences, or comments.
|
|
3103
|
+
Use keys: summary, goal, plan, known.
|
|
2985
3104
|
Rewrite recent conversation briefly inside summary.
|
|
2986
3105
|
Keep only durable facts needed to continue; preserve file paths, symbols, constraints, and tr.N keys.
|
|
2987
3106
|
""".strip()
|
|
@@ -2991,13 +3110,29 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
|
|
|
2991
3110
|
if self.session.config.provider.resolved_api() == "anthropic"
|
|
2992
3111
|
else self.chat_request(messages, None, activity="compact")
|
|
2993
3112
|
)
|
|
2994
|
-
|
|
2995
|
-
content = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.IGNORECASE | re.DOTALL).strip()
|
|
2996
|
-
data = json.loads(content)
|
|
3113
|
+
data = self.parse_json_object(content)
|
|
2997
3114
|
if not isinstance(data, dict):
|
|
2998
3115
|
raise ModelError("compactor returned non-object JSON")
|
|
2999
3116
|
return data
|
|
3000
3117
|
|
|
3118
|
+
@classmethod
|
|
3119
|
+
def parse_json_object(cls, text: str) -> Json:
|
|
3120
|
+
text = cls.strip_json_fence(Text.clean(text).strip())
|
|
3121
|
+
if not text:
|
|
3122
|
+
raise ModelError("compactor returned empty output")
|
|
3123
|
+
try:
|
|
3124
|
+
data = json.loads(text)
|
|
3125
|
+
except json.JSONDecodeError:
|
|
3126
|
+
data = repair_json(text, return_objects=True)
|
|
3127
|
+
if isinstance(data, dict):
|
|
3128
|
+
return data
|
|
3129
|
+
raise ModelError("compactor returned invalid JSON: " + Tool.compact(text, 200))
|
|
3130
|
+
|
|
3131
|
+
@staticmethod
|
|
3132
|
+
def strip_json_fence(text: str) -> str:
|
|
3133
|
+
match = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, flags=re.IGNORECASE | re.DOTALL)
|
|
3134
|
+
return (match.group(1) if match else text).strip()
|
|
3135
|
+
|
|
3001
3136
|
def client(self) -> OpenAI:
|
|
3002
3137
|
provider = self.session.config.provider
|
|
3003
3138
|
if missing := self.session.missing_config():
|
|
@@ -3238,13 +3373,13 @@ FLOW:
|
|
|
3238
3373
|
- ACT when clear; keep using tools until done.
|
|
3239
3374
|
- Every turn must call tools or return final; never emit empty content.
|
|
3240
3375
|
- Prefer built-ins over Bash. Batch independent read-only calls.
|
|
3241
|
-
- All user-visible
|
|
3242
|
-
-
|
|
3376
|
+
- All assistant text is user-visible, including interim narration before tool calls and final answers.
|
|
3377
|
+
- Write every assistant text message as markdown in the latest user's language; do not switch languages unless asked.
|
|
3243
3378
|
|
|
3244
3379
|
CONTEXT:
|
|
3245
3380
|
- Trust LATEST FILE STATE from Read/Edit for listed ranges.
|
|
3246
3381
|
- Recall bounded tr.N only when needed; prefer FILE STATE over old outputs.
|
|
3247
|
-
- For multi-step work, call Note early with goal and a short plan; update plan/known when they change.
|
|
3382
|
+
- For multi-step work, call Note early with goal and a short plan; update plan/known/check when they change.
|
|
3248
3383
|
|
|
3249
3384
|
EDITS:
|
|
3250
3385
|
- Inspect/read before edits.
|
|
@@ -3256,7 +3391,8 @@ EDITS:
|
|
|
3256
3391
|
|
|
3257
3392
|
FINAL:
|
|
3258
3393
|
- Concise markdown in the user's language.
|
|
3259
|
-
- Include changed files and checks when relevant
|
|
3394
|
+
- Include changed files and checks when relevant.
|
|
3395
|
+
- Mention checks run, or say checks not run.\
|
|
3260
3396
|
"""
|
|
3261
3397
|
|
|
3262
3398
|
def __init__(self, session: Session, input_fn=input, output_fn=print):
|
|
@@ -3445,7 +3581,7 @@ class UiPrinter:
|
|
|
3445
3581
|
return [("ansicyan", text + "\n")]
|
|
3446
3582
|
if text.startswith("Error:") or text.startswith("ConfigError:") or text.startswith("Unknown command:"):
|
|
3447
3583
|
return [("ansired", text + "\n")]
|
|
3448
|
-
return
|
|
3584
|
+
return [("ansiwhite", line + "\n") for line in text.splitlines() or [""]]
|
|
3449
3585
|
|
|
3450
3586
|
def tool_segments(self, text: str) -> list[tuple[str, str]]:
|
|
3451
3587
|
segments = []
|
|
@@ -3562,10 +3698,6 @@ class UiPrinter:
|
|
|
3562
3698
|
at_start = part.endswith("\n")
|
|
3563
3699
|
return indented
|
|
3564
3700
|
|
|
3565
|
-
def text_segments(self, text: str) -> list[tuple[str, str]]:
|
|
3566
|
-
return [("ansiwhite", line + "\n") for line in text.splitlines() or [""]]
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
3701
|
class BashLivePreview:
|
|
3570
3702
|
HEIGHT: ClassVar[int] = 6
|
|
3571
3703
|
MAX_CHARS: ClassVar[int] = 8000
|
|
@@ -3737,15 +3869,13 @@ class StatusBar:
|
|
|
3737
3869
|
self.output.flush()
|
|
3738
3870
|
self.rendered = False
|
|
3739
3871
|
|
|
3740
|
-
def elapsed(self) -> float:
|
|
3741
|
-
return max(0.0, time.monotonic() - self.started_at) if self.started_at else 0.0
|
|
3742
|
-
|
|
3743
3872
|
def idle_fragments(self) -> list[tuple[str, str]]:
|
|
3744
3873
|
return self.fragments(0.0, sweep=False, show_elapsed=False)
|
|
3745
3874
|
|
|
3746
3875
|
def active_fragments(self) -> list[tuple[str, str]]:
|
|
3747
3876
|
self.refresh_retry_state()
|
|
3748
|
-
|
|
3877
|
+
elapsed = max(0.0, time.monotonic() - self.started_at) if self.started_at else 0.0
|
|
3878
|
+
return self.fragments(elapsed, sweep=True, show_elapsed=True)
|
|
3749
3879
|
|
|
3750
3880
|
def fragments(self, elapsed: float, *, sweep: bool, show_elapsed: bool) -> list[tuple[str, str]]:
|
|
3751
3881
|
text = self.text(elapsed, show_elapsed=show_elapsed)
|
|
@@ -3870,6 +4000,7 @@ Tools:
|
|
|
3870
4000
|
self.live_status_paused = False
|
|
3871
4001
|
self.live_queue_paused = False
|
|
3872
4002
|
self.transient_tool_lines = 0
|
|
4003
|
+
self.approval_full_preview = ""
|
|
3873
4004
|
self.interactive_input = input_fn is input and sys.stdin.isatty()
|
|
3874
4005
|
self.queue_input_paused = threading.Event()
|
|
3875
4006
|
self.queue_input_active = threading.Event()
|
|
@@ -3888,6 +4019,7 @@ Tools:
|
|
|
3888
4019
|
self.agent.tools.output_fn = self.tool_output
|
|
3889
4020
|
self.agent.tools.input_fn = self.tool_input
|
|
3890
4021
|
self.agent.tools.preview_fn = self.tool_preview
|
|
4022
|
+
self.agent.tools.preview_full_fn = lambda text: setattr(self, "approval_full_preview", text)
|
|
3891
4023
|
self.agent.tools.live_start = self.tool_live_start
|
|
3892
4024
|
self.agent.tools.live_output = self.tool_live_output
|
|
3893
4025
|
|
|
@@ -4094,7 +4226,14 @@ Tools:
|
|
|
4094
4226
|
frame = "|/-\\"[int(time.monotonic() / 0.2) % 4]
|
|
4095
4227
|
return [("class:approval", prompt_text), ("class:approval.wait", frame + " ")]
|
|
4096
4228
|
|
|
4097
|
-
def read_input(
|
|
4229
|
+
def read_input(
|
|
4230
|
+
self,
|
|
4231
|
+
prompt_text: str = "nano> ",
|
|
4232
|
+
*,
|
|
4233
|
+
multiline: bool = False,
|
|
4234
|
+
submit_on_enter: bool = False,
|
|
4235
|
+
prompt_style: str = "class:prompt",
|
|
4236
|
+
) -> str:
|
|
4098
4237
|
if self.input_history is None:
|
|
4099
4238
|
return self.input_fn(prompt_text)
|
|
4100
4239
|
|
|
@@ -4150,6 +4289,10 @@ Tools:
|
|
|
4150
4289
|
else:
|
|
4151
4290
|
pt_search.start_search(direction=direction)
|
|
4152
4291
|
|
|
4292
|
+
@bindings.add("c-a", filter=Condition(lambda: bool(self.approval_full_preview)), eager=True)
|
|
4293
|
+
def _ctrl_a(event):
|
|
4294
|
+
run_in_terminal(self.open_approval_preview)
|
|
4295
|
+
|
|
4153
4296
|
@bindings.add("tab")
|
|
4154
4297
|
def _tab(event):
|
|
4155
4298
|
if buffer.complete_state:
|
|
@@ -4209,7 +4352,12 @@ Tools:
|
|
|
4209
4352
|
if text.startswith("approve ") and self.interactive_input and sys.stdout.isatty():
|
|
4210
4353
|
self.with_status_paused(lambda: self.show_transient_tool_output(text))
|
|
4211
4354
|
return
|
|
4212
|
-
|
|
4355
|
+
|
|
4356
|
+
def emit() -> None:
|
|
4357
|
+
self.clear_transient_tool_output()
|
|
4358
|
+
self.emit(text)
|
|
4359
|
+
|
|
4360
|
+
self.with_status_paused(emit)
|
|
4213
4361
|
|
|
4214
4362
|
def agent_output(self, text: str = "") -> None:
|
|
4215
4363
|
self.with_status_paused(lambda: self.emit_agent_output(text))
|
|
@@ -4225,6 +4373,7 @@ Tools:
|
|
|
4225
4373
|
finally:
|
|
4226
4374
|
if self.interactive_input and sys.stdout.isatty():
|
|
4227
4375
|
self.clear_transient_tool_output()
|
|
4376
|
+
self.approval_full_preview = ""
|
|
4228
4377
|
|
|
4229
4378
|
return self.with_status_paused(read)
|
|
4230
4379
|
|
|
@@ -4239,20 +4388,46 @@ Tools:
|
|
|
4239
4388
|
self.with_status_paused(lambda: self.show_transient_tool_preview(text))
|
|
4240
4389
|
return True
|
|
4241
4390
|
|
|
4391
|
+
def open_approval_preview(self) -> None:
|
|
4392
|
+
if not self.approval_full_preview:
|
|
4393
|
+
return
|
|
4394
|
+
fd, path = tempfile.mkstemp(prefix="nanocode-preview-", suffix=".diff")
|
|
4395
|
+
try:
|
|
4396
|
+
pager_env = os.environ.get("PAGER", "")
|
|
4397
|
+
pager = shlex.split(pager_env) if pager_env else ([less, "-R"] if (less := shutil.which("less")) else [])
|
|
4398
|
+
text = self.ansi_diff_preview(self.approval_full_preview) if pager and os.path.basename(pager[0]) == "less" else self.approval_full_preview
|
|
4399
|
+
with os.fdopen(fd, "w", encoding="utf-8") as file:
|
|
4400
|
+
file.write(text.rstrip() + "\n")
|
|
4401
|
+
if pager:
|
|
4402
|
+
subprocess.run([*pager, path])
|
|
4403
|
+
else:
|
|
4404
|
+
print(self.approval_full_preview)
|
|
4405
|
+
input("Press Enter to return...")
|
|
4406
|
+
finally:
|
|
4407
|
+
try:
|
|
4408
|
+
os.unlink(path)
|
|
4409
|
+
except OSError:
|
|
4410
|
+
pass
|
|
4411
|
+
|
|
4412
|
+
@staticmethod
|
|
4413
|
+
def ansi_diff_preview(text: str) -> str:
|
|
4414
|
+
colors = [("---", "\033[90m"), ("+++", "\033[90m"), ("@@", "\033[36m"), ("+", "\033[32m"), ("-", "\033[31m")]
|
|
4415
|
+
lines = []
|
|
4416
|
+
for line in text.splitlines():
|
|
4417
|
+
style = next((color for prefix, color in colors if line.lstrip().startswith(prefix)), "")
|
|
4418
|
+
lines.append(style + line + ("\033[0m" if style else ""))
|
|
4419
|
+
return "\n".join(lines)
|
|
4420
|
+
|
|
4242
4421
|
def show_transient_tool_preview(self, text: str) -> None:
|
|
4243
4422
|
self.clear_transient_tool_output()
|
|
4244
4423
|
lines = text.rstrip().splitlines()
|
|
4245
4424
|
if not lines:
|
|
4246
4425
|
return
|
|
4247
4426
|
height, width = 12, max(20, shutil.get_terminal_size((120, 20)).columns)
|
|
4248
|
-
shown = lines[:height] + (["... preview truncated ..."] if len(lines) > height else [])
|
|
4427
|
+
shown = lines[:height] + ([f"... preview truncated: {len(lines) - height} more lines (Ctrl-A: full preview) ..."] if len(lines) > height else [])
|
|
4249
4428
|
self.emit("\n".join(line[: max(0, width - 1)] for line in shown))
|
|
4250
4429
|
self.transient_tool_lines = len(shown)
|
|
4251
4430
|
|
|
4252
|
-
def emit_tool_output(self, text: str) -> None:
|
|
4253
|
-
self.clear_transient_tool_output()
|
|
4254
|
-
self.emit(text)
|
|
4255
|
-
|
|
4256
4431
|
def emit_agent_output(self, text: str) -> None:
|
|
4257
4432
|
self.clear_transient_tool_output()
|
|
4258
4433
|
if self.ui.color and text.strip():
|
|
@@ -4651,16 +4826,20 @@ Tools:
|
|
|
4651
4826
|
compacted, keep = self.agent.context.compaction_parts()
|
|
4652
4827
|
if not compacted:
|
|
4653
4828
|
return "No prior conversation to compact"
|
|
4829
|
+
fallback = False
|
|
4654
4830
|
try:
|
|
4655
4831
|
self.status_bar.start()
|
|
4656
4832
|
data = self.agent.model.compact(self.agent.context.compaction_input(compacted))
|
|
4657
4833
|
except KeyboardInterrupt:
|
|
4658
4834
|
return "Cancelled"
|
|
4659
|
-
except Exception
|
|
4660
|
-
|
|
4835
|
+
except Exception:
|
|
4836
|
+
self.agent.context.apply_compaction_fallback(keep)
|
|
4837
|
+
fallback = True
|
|
4838
|
+
data = None
|
|
4661
4839
|
finally:
|
|
4662
4840
|
self.status_bar.stop()
|
|
4663
|
-
|
|
4841
|
+
if data is not None:
|
|
4842
|
+
self.agent.context.apply_compaction(data, keep)
|
|
4664
4843
|
self.agent.context.update_percent(self.agent.context.model_messages(self.agent.SYSTEM_PROMPT))
|
|
4665
4844
|
return (
|
|
4666
4845
|
"Compacted context: messages "
|
|
@@ -4670,6 +4849,7 @@ Tools:
|
|
|
4670
4849
|
+ ", prior summary inserted, ctx "
|
|
4671
4850
|
+ str(self.session.state.context_percent)
|
|
4672
4851
|
+ "%"
|
|
4852
|
+
+ (" (fallback)" if fallback else "")
|
|
4673
4853
|
)
|
|
4674
4854
|
|
|
4675
4855
|
def index(self, args: str) -> str:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.10
|
|
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
|
|
@@ -23,6 +23,7 @@ Description-Content-Type: text/markdown
|
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: anthropic>=0.64.0
|
|
25
25
|
Requires-Dist: code-symbol-index>=0.1.13
|
|
26
|
+
Requires-Dist: json-repair
|
|
26
27
|
Requires-Dist: openai>=2.37.0
|
|
27
28
|
Requires-Dist: prompt-toolkit>=3.0
|
|
28
29
|
Requires-Dist: rich>=13.0
|
|
@@ -36,19 +37,22 @@ Dynamic: license-file
|
|
|
36
37
|
|
|
37
38
|
A small terminal coding agent written in Python.
|
|
38
39
|
|
|
40
|
+
[简体中文](README.zh-CN.md)
|
|
41
|
+
|
|
39
42
|
nanocode is pre-1.0 software. Commands, configuration, and tool behavior may change before a stable release.
|
|
40
43
|
|
|
41
44
|

|
|
42
45
|
|
|
43
46
|
## Features
|
|
44
47
|
|
|
45
|
-
- **
|
|
46
|
-
- **
|
|
47
|
-
- **
|
|
48
|
-
- **
|
|
49
|
-
- **
|
|
50
|
-
- **
|
|
51
|
-
- **
|
|
48
|
+
- **Live turn control**: Add follow-up input while the agent is still working, without losing the current tool flow.
|
|
49
|
+
- **File-state brain**: Reads and edits build a current, line-numbered view of the files that matter now.
|
|
50
|
+
- **Stale-edit protection**: `line:hash` anchors reject edits when the target code has drifted.
|
|
51
|
+
- **Project-aware navigation**: Use the symbol index to jump through outlines, references, and changed files quickly.
|
|
52
|
+
- **Recoverable context**: Tool output stays bounded in the prompt, while raw `tr.N` results remain recallable.
|
|
53
|
+
- **Cache-aware context**: Stable sections stay early and noisy working state stays late to improve prompt-cache reuse.
|
|
54
|
+
- **Focused working memory**: `Note` separates goal, plan, and known facts from noisy execution logs.
|
|
55
|
+
- **Terminal-first workflow**: Model selection, history search, confirmations, live command output, appended input, and status all stay in one CLI.
|
|
52
56
|
|
|
53
57
|
## Install
|
|
54
58
|
|
|
@@ -56,6 +60,12 @@ nanocode is pre-1.0 software. Commands, configuration, and tool behavior may cha
|
|
|
56
60
|
uv tool install nanocode-cli
|
|
57
61
|
```
|
|
58
62
|
|
|
63
|
+
Upgrade:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
uv tool upgrade nanocode-cli
|
|
67
|
+
```
|
|
68
|
+
|
|
59
69
|
For local development:
|
|
60
70
|
|
|
61
71
|
```sh
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nanocode-cli"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.10"
|
|
8
8
|
description = "A small terminal coding agent written in Python"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -29,6 +29,7 @@ classifiers = [
|
|
|
29
29
|
dependencies = [
|
|
30
30
|
"anthropic>=0.64.0",
|
|
31
31
|
"code-symbol-index>=0.1.13",
|
|
32
|
+
"json-repair",
|
|
32
33
|
"openai>=2.37.0",
|
|
33
34
|
"prompt-toolkit>=3.0",
|
|
34
35
|
"rich>=13.0",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|