nanocode-cli 0.3.28__tar.gz → 0.3.32__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.3.28/nanocode_cli.egg-info → nanocode_cli-0.3.32}/PKG-INFO +2 -2
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.32}/README.md +1 -1
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.32}/nanocode.py +160 -18
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.32/nanocode_cli.egg-info}/PKG-INFO +2 -2
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.32}/pyproject.toml +1 -1
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.32}/LICENSE +0 -0
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.32}/MANIFEST.in +0 -0
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.32}/nanocode_cli.egg-info/SOURCES.txt +0 -0
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.32}/nanocode_cli.egg-info/dependency_links.txt +0 -0
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.32}/nanocode_cli.egg-info/entry_points.txt +0 -0
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.32}/nanocode_cli.egg-info/requires.txt +0 -0
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.32}/nanocode_cli.egg-info/top_level.txt +0 -0
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.32}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.32
|
|
4
4
|
Summary: A lightweight terminal-based AI coding assistant
|
|
5
5
|
Author-email: hit9 <hit9@icloud.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -119,7 +119,7 @@ USE AT YOUR OWN RISK.
|
|
|
119
119
|
- Maintenance: `/clean`.
|
|
120
120
|
- Exit: `/exit`, `/quit`.
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
Selectors support `j`/`k`, arrows, `/keyword`, Enter, and Esc. `/model` lists configured models before discovered ones, then prompts for reasoning; `/model <name>` and `/reason` are direct shortcuts.
|
|
123
123
|
|
|
124
124
|
## Configuration
|
|
125
125
|
|
|
@@ -89,7 +89,7 @@ USE AT YOUR OWN RISK.
|
|
|
89
89
|
- Maintenance: `/clean`.
|
|
90
90
|
- Exit: `/exit`, `/quit`.
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
Selectors support `j`/`k`, arrows, `/keyword`, Enter, and Esc. `/model` lists configured models before discovered ones, then prompts for reasoning; `/model <name>` and `/reason` are direct shortcuts.
|
|
93
93
|
|
|
94
94
|
## Configuration
|
|
95
95
|
|
|
@@ -51,7 +51,7 @@ from prompt_toolkit.output.defaults import create_output
|
|
|
51
51
|
from prompt_toolkit.patch_stdout import patch_stdout
|
|
52
52
|
from prompt_toolkit.styles import Style
|
|
53
53
|
|
|
54
|
-
__version__ = "0.3.
|
|
54
|
+
__version__ = "0.3.32"
|
|
55
55
|
HTTP_USER_AGENT = "nanocode/" + __version__
|
|
56
56
|
|
|
57
57
|
|
|
@@ -823,6 +823,8 @@ class RuntimeState:
|
|
|
823
823
|
current_model_call_reasoning_label: str = ""
|
|
824
824
|
current_model_call_activity: str = ""
|
|
825
825
|
current_model_call_has_content: bool = False
|
|
826
|
+
current_model_call_streaming_chars: int = 0
|
|
827
|
+
last_model_call_rate: float = 0.0
|
|
826
828
|
status_notice: str = ""
|
|
827
829
|
status_notice_until: float = 0.0
|
|
828
830
|
conversation: list[ConversationItem] = field(default_factory=list)
|
|
@@ -1670,7 +1672,7 @@ class SearchTool(Tool):
|
|
|
1670
1672
|
MAX_CONTEXT_LINES: ClassVar[int] = 30
|
|
1671
1673
|
EFFECT: ClassVar[ToolEffect] = ToolEffect.READONLY
|
|
1672
1674
|
DESCRIPTION: ClassVar[tuple[str, ...]] = (
|
|
1673
|
-
"Case-insensitive regex search before Read; use A|B|C for alternatives.",
|
|
1675
|
+
"Case-insensitive regex search before Read; use A|B|C for alternatives and \\n for multiline matches.",
|
|
1674
1676
|
"For exact text, escape regex metacharacters like braces, parens, dots, stars, and brackets.",
|
|
1675
1677
|
"Scope with path=FILE_OR_DIR, optionally filter with one glob=*.py, set context=N for 0..30 lines; omitted path defaults to current directory.",
|
|
1676
1678
|
"Second positional arg is always path, third positional arg is always glob; with path=, extra leading positional args are joined as regex alternatives.",
|
|
@@ -1713,8 +1715,7 @@ class SearchTool(Tool):
|
|
|
1713
1715
|
pattern = raw_pattern[3:] if raw_pattern.startswith("re:") else raw_pattern
|
|
1714
1716
|
if not pattern:
|
|
1715
1717
|
raise ToolCallArgError("pattern cannot be empty")
|
|
1716
|
-
|
|
1717
|
-
raise ToolCallArgError("multiline regex is not supported; Search is line-oriented. Search each line separately or Read a nearby range.")
|
|
1718
|
+
pattern = pattern.replace("\\n", "\n").replace("\\r", "\r")
|
|
1718
1719
|
target_path_arg = "."
|
|
1719
1720
|
glob_pattern = ""
|
|
1720
1721
|
context_lines = cls.CONTEXT_LINES
|
|
@@ -1863,7 +1864,9 @@ class SearchTool(Tool):
|
|
|
1863
1864
|
yield path
|
|
1864
1865
|
|
|
1865
1866
|
def _make_match(self, path: str, line_number: int, text: str) -> Match:
|
|
1866
|
-
|
|
1867
|
+
text = text.rstrip("\n")
|
|
1868
|
+
preview = _shorten(" ".join(text.split()), 300) if "\n" in text or "\r" in text else text[:300]
|
|
1869
|
+
return self.Match(path=path, line_number=line_number, text=preview, context=self._read_match_context(path, line_number))
|
|
1867
1870
|
|
|
1868
1871
|
def _read_match_context(self, path: str, line_number: int) -> list[tuple[int, str]]:
|
|
1869
1872
|
if line_number <= 0:
|
|
@@ -1902,6 +1905,8 @@ class SearchTool(Tool):
|
|
|
1902
1905
|
|
|
1903
1906
|
def _rg_command(self, rg: str, *, pcre2: bool = False) -> list[str]:
|
|
1904
1907
|
cmd = [rg, "--json", "--line-number", "--max-filesize", self.RG_MAX_FILESIZE]
|
|
1908
|
+
if self._is_multiline():
|
|
1909
|
+
cmd.extend(["-U", "--multiline-dotall"])
|
|
1905
1910
|
if pcre2:
|
|
1906
1911
|
cmd.append("--pcre2")
|
|
1907
1912
|
cmd.append("-i")
|
|
@@ -1955,8 +1960,13 @@ class SearchTool(Tool):
|
|
|
1955
1960
|
text = stderr.lower()
|
|
1956
1961
|
return "pcre2" in text and ("look-around" in text or "look-ahead" in text or "look-behind" in text)
|
|
1957
1962
|
|
|
1963
|
+
def _is_multiline(self) -> bool:
|
|
1964
|
+
return "\n" in self.pattern or "\r" in self.pattern
|
|
1965
|
+
|
|
1958
1966
|
def _call_python(self) -> str:
|
|
1959
1967
|
matches = []
|
|
1968
|
+
if self._is_multiline():
|
|
1969
|
+
return self._call_python_multiline()
|
|
1960
1970
|
for path in self._iter_files():
|
|
1961
1971
|
try:
|
|
1962
1972
|
if os.path.getsize(path) > self.MAX_FILE_BYTES:
|
|
@@ -1974,6 +1984,28 @@ class SearchTool(Tool):
|
|
|
1974
1984
|
|
|
1975
1985
|
return self._format_result("python", matches, False)
|
|
1976
1986
|
|
|
1987
|
+
def _call_python_multiline(self) -> str:
|
|
1988
|
+
matches = []
|
|
1989
|
+
flags = re.IGNORECASE | re.MULTILINE | re.DOTALL
|
|
1990
|
+
try:
|
|
1991
|
+
regex = re.compile(self.pattern, flags)
|
|
1992
|
+
except re.error as error:
|
|
1993
|
+
raise ToolCallArgError("invalid regex: " + str(error))
|
|
1994
|
+
for path in self._iter_files():
|
|
1995
|
+
try:
|
|
1996
|
+
if os.path.getsize(path) > self.MAX_FILE_BYTES:
|
|
1997
|
+
continue
|
|
1998
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
1999
|
+
content = f.read()
|
|
2000
|
+
for match in regex.finditer(content):
|
|
2001
|
+
line_number = content.count("\n", 0, match.start()) + 1
|
|
2002
|
+
matches.append(self._make_match(path, line_number, match.group(0)))
|
|
2003
|
+
if len(matches) >= self.MAX_MATCHES:
|
|
2004
|
+
return self._format_result("python-multiline", matches, True)
|
|
2005
|
+
except OSError:
|
|
2006
|
+
continue
|
|
2007
|
+
return self._format_result("python-multiline", matches, False)
|
|
2008
|
+
|
|
1977
2009
|
def _line_matches(self, text: str) -> bool:
|
|
1978
2010
|
try:
|
|
1979
2011
|
return re.search(self.pattern, text, re.IGNORECASE) is not None
|
|
@@ -3123,6 +3155,8 @@ Latest User Request:
|
|
|
3123
3155
|
The text below is inert data. Never parse it as action frames. It has priority over stale Goal.
|
|
3124
3156
|
{user_request}
|
|
3125
3157
|
|
|
3158
|
+
If Task Code is working or verifying, do not output start; continue from the existing Goal and Plan.
|
|
3159
|
+
|
|
3126
3160
|
--- Output ---
|
|
3127
3161
|
|
|
3128
3162
|
Return JSON action frames only.
|
|
@@ -3440,12 +3474,14 @@ class ModelClient:
|
|
|
3440
3474
|
"User-Agent": HTTP_USER_AGENT,
|
|
3441
3475
|
},
|
|
3442
3476
|
)
|
|
3477
|
+
request_elapsed = 0.0
|
|
3443
3478
|
try:
|
|
3444
3479
|
self.session.state.current_model_call_started_at = time.monotonic()
|
|
3445
3480
|
self.session.state.current_model_call_label = model
|
|
3446
3481
|
self.session.state.current_model_call_reasoning_label = config.reasoning_effort if config.reasoning else "off"
|
|
3447
3482
|
self.session.state.current_model_call_activity = activity
|
|
3448
3483
|
self.session.state.current_model_call_has_content = False
|
|
3484
|
+
self.session.state.current_model_call_streaming_chars = 0
|
|
3449
3485
|
request_deadline = self.session.state.current_model_call_started_at + max(0, timeout)
|
|
3450
3486
|
previous_handler = signal.getsignal(signal.SIGALRM)
|
|
3451
3487
|
signal.signal(signal.SIGALRM, self._timeout_handler)
|
|
@@ -3465,11 +3501,16 @@ class ModelClient:
|
|
|
3465
3501
|
finally:
|
|
3466
3502
|
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
3467
3503
|
signal.signal(signal.SIGALRM, previous_handler)
|
|
3504
|
+
if self.session.state.current_model_call_started_at > 0:
|
|
3505
|
+
request_elapsed = max(0.0, time.monotonic() - self.session.state.current_model_call_started_at)
|
|
3506
|
+
if request_elapsed > 0 and self.session.state.current_model_call_streaming_chars > 0:
|
|
3507
|
+
self.session.state.last_model_call_rate = self._estimate_stream_rate(request_elapsed)
|
|
3468
3508
|
self.session.state.current_model_call_started_at = 0.0
|
|
3469
3509
|
self.session.state.current_model_call_label = ""
|
|
3470
3510
|
self.session.state.current_model_call_reasoning_label = ""
|
|
3471
3511
|
self.session.state.current_model_call_activity = ""
|
|
3472
3512
|
self.session.state.current_model_call_has_content = False
|
|
3513
|
+
self.session.state.current_model_call_streaming_chars = 0
|
|
3473
3514
|
except ModelRequestTimeout as error:
|
|
3474
3515
|
raise LLMError(str(error) or "request model timeout")
|
|
3475
3516
|
except (socket.timeout, TimeoutError):
|
|
@@ -3490,7 +3531,7 @@ class ModelClient:
|
|
|
3490
3531
|
except json.JSONDecodeError:
|
|
3491
3532
|
raise LLMError("API response is not JSON: " + _shorten(body))
|
|
3492
3533
|
|
|
3493
|
-
self._record_usage(_json_dict(result.get("usage") if isinstance(result, dict) else None), config)
|
|
3534
|
+
self._record_usage(_json_dict(result.get("usage") if isinstance(result, dict) else None), config, elapsed=request_elapsed)
|
|
3494
3535
|
if not stream:
|
|
3495
3536
|
content = self._message_content(result)
|
|
3496
3537
|
if content is None:
|
|
@@ -3538,8 +3579,12 @@ class ModelClient:
|
|
|
3538
3579
|
self.session.state.current_model_call_has_content = True
|
|
3539
3580
|
self._arm_stream_timeout(request_deadline=request_deadline, first_content_seen=True, first_token_timeout=first_token_timeout)
|
|
3540
3581
|
parts.append(content)
|
|
3582
|
+
self.session.state.current_model_call_streaming_chars += len(content)
|
|
3541
3583
|
return "".join(parts), usage
|
|
3542
3584
|
|
|
3585
|
+
def _estimate_stream_rate(self, elapsed: float) -> float:
|
|
3586
|
+
return self.session.state.current_model_call_streaming_chars / 4 / elapsed if elapsed > 0 else 0.0
|
|
3587
|
+
|
|
3543
3588
|
def _arm_stream_timeout(self, *, request_deadline: float, first_content_seen: bool, first_token_timeout: int | None) -> None:
|
|
3544
3589
|
remaining = request_deadline - time.monotonic()
|
|
3545
3590
|
if remaining <= 0:
|
|
@@ -3579,8 +3624,11 @@ class ModelClient:
|
|
|
3579
3624
|
def _parse_model_content(self, content: str) -> Json:
|
|
3580
3625
|
text = content.strip()
|
|
3581
3626
|
text = self._strip_leaked_think_tags(text)
|
|
3627
|
+
text = self._strip_leaked_tool_code(text)
|
|
3582
3628
|
text = self._strip_json_fence(text)
|
|
3629
|
+
text = self._strip_fence_marker_lines(text)
|
|
3583
3630
|
text = self._strip_leaked_think_tags(text)
|
|
3631
|
+
text = self._strip_leaked_tool_code(text)
|
|
3584
3632
|
if not self._has_action_frame_end(text):
|
|
3585
3633
|
actions, error = self._parse_unmarked_actions(text)
|
|
3586
3634
|
if actions:
|
|
@@ -3677,9 +3725,10 @@ class ModelClient:
|
|
|
3677
3725
|
|
|
3678
3726
|
def _normalize_tool_type(self, action: Json) -> None:
|
|
3679
3727
|
action_type = _json_str(action.get("type"))
|
|
3680
|
-
if action_type
|
|
3728
|
+
tool_name = next((name for name in TOOL_REGISTRY if name.lower() == action_type.lower()), "") if action_type else ""
|
|
3729
|
+
if tool_name:
|
|
3681
3730
|
action["type"] = "tool"
|
|
3682
|
-
action.setdefault("name",
|
|
3731
|
+
action.setdefault("name", tool_name)
|
|
3683
3732
|
|
|
3684
3733
|
def _parse_unmarked_actions(self, text: str) -> tuple[list[Json], str]:
|
|
3685
3734
|
actions: list[Json] = []
|
|
@@ -3704,12 +3753,15 @@ class ModelClient:
|
|
|
3704
3753
|
return parsed, ""
|
|
3705
3754
|
action_start = text.find("{", index)
|
|
3706
3755
|
if action_start < 0:
|
|
3756
|
+
progress = self._plain_progress_text(text[index:])
|
|
3757
|
+
if progress:
|
|
3758
|
+
return [{"type": "progress", "text": progress}], ""
|
|
3707
3759
|
try:
|
|
3708
3760
|
decoder.raw_decode(text, index)
|
|
3709
3761
|
except json.JSONDecodeError as error:
|
|
3710
3762
|
return [], str(error)
|
|
3711
3763
|
return [], "expected JSON object action"
|
|
3712
|
-
prefix =
|
|
3764
|
+
prefix = self._progress_text(text[:action_start])
|
|
3713
3765
|
index = action_start
|
|
3714
3766
|
while True:
|
|
3715
3767
|
while index < len(text) and text[index].isspace():
|
|
@@ -3721,6 +3773,14 @@ class ModelClient:
|
|
|
3721
3773
|
try:
|
|
3722
3774
|
value, index = decoder.raw_decode(text, index)
|
|
3723
3775
|
except json.JSONDecodeError as error:
|
|
3776
|
+
if actions:
|
|
3777
|
+
return [], str(error)
|
|
3778
|
+
if self._should_repair_json_decode_error(str(error), text):
|
|
3779
|
+
repaired, repair_error = self._repair_single_json_action(text)
|
|
3780
|
+
if not repair_error:
|
|
3781
|
+
if prefix:
|
|
3782
|
+
repaired.insert(0, {"type": "progress", "text": prefix})
|
|
3783
|
+
return repaired, ""
|
|
3724
3784
|
return [], str(error)
|
|
3725
3785
|
parsed, error = self._actions_from_json_value(value)
|
|
3726
3786
|
if error:
|
|
@@ -3734,12 +3794,40 @@ class ModelClient:
|
|
|
3734
3794
|
if index < len(text) and text[index] != "{":
|
|
3735
3795
|
next_action = text.find("{", index)
|
|
3736
3796
|
if next_action < 0:
|
|
3797
|
+
if self._should_repair_trailing_json_text(text[index:]):
|
|
3798
|
+
repaired, error = self._repair_single_json_action(text)
|
|
3799
|
+
if not error:
|
|
3800
|
+
return repaired, ""
|
|
3737
3801
|
return [], "unexpected text after JSON action"
|
|
3738
|
-
progress =
|
|
3802
|
+
progress = self._progress_text(text[index:next_action])
|
|
3739
3803
|
if progress:
|
|
3740
3804
|
actions.append({"type": "progress", "text": progress})
|
|
3741
3805
|
index = next_action
|
|
3742
3806
|
|
|
3807
|
+
def _progress_text(self, text: str) -> str:
|
|
3808
|
+
text = re.sub(r"```[a-zA-Z0-9_-]*", "", text)
|
|
3809
|
+
text = text.replace("```", "")
|
|
3810
|
+
return _shorten(" ".join(text.split()), 500)
|
|
3811
|
+
|
|
3812
|
+
def _plain_progress_text(self, text: str) -> str:
|
|
3813
|
+
progress = self._progress_text(text)
|
|
3814
|
+
if not progress or "{" in progress or "}" in progress:
|
|
3815
|
+
return ""
|
|
3816
|
+
starters = (
|
|
3817
|
+
"let me ",
|
|
3818
|
+
"i need ",
|
|
3819
|
+
"i will ",
|
|
3820
|
+
"i'll ",
|
|
3821
|
+
"now ",
|
|
3822
|
+
"next ",
|
|
3823
|
+
"我需要",
|
|
3824
|
+
"让我",
|
|
3825
|
+
"我会",
|
|
3826
|
+
"现在",
|
|
3827
|
+
"接下来",
|
|
3828
|
+
)
|
|
3829
|
+
return progress if progress.lower().startswith(starters) else ""
|
|
3830
|
+
|
|
3743
3831
|
def _decode_json_array_text(self, text: str, index: int) -> tuple[JsonValue, int]:
|
|
3744
3832
|
decoder = json.JSONDecoder()
|
|
3745
3833
|
value, end = decoder.raw_decode(text, index)
|
|
@@ -3753,6 +3841,21 @@ class ModelClient:
|
|
|
3753
3841
|
raise ValueError("expected JSON action array")
|
|
3754
3842
|
return value, len(text)
|
|
3755
3843
|
|
|
3844
|
+
def _repair_single_json_action(self, text: str) -> tuple[list[Json], str]:
|
|
3845
|
+
try:
|
|
3846
|
+
value = json_repair.loads(text)
|
|
3847
|
+
except Exception as error:
|
|
3848
|
+
return [], str(error)
|
|
3849
|
+
if isinstance(value, list):
|
|
3850
|
+
return [], "unexpected text after JSON action"
|
|
3851
|
+
return self._actions_from_json_value(value)
|
|
3852
|
+
|
|
3853
|
+
def _should_repair_json_decode_error(self, error: str, text: str) -> bool:
|
|
3854
|
+
return "Invalid control character" in error or re.fullmatch(r".*[}\]]\s*[}\]]+\s*", text, re.DOTALL) is not None
|
|
3855
|
+
|
|
3856
|
+
def _should_repair_trailing_json_text(self, text: str) -> bool:
|
|
3857
|
+
return re.fullmatch(r"\s*[}\]]+\s*", text) is not None
|
|
3858
|
+
|
|
3756
3859
|
def _has_action_frame_end(self, line: str) -> bool:
|
|
3757
3860
|
return self.ACTION_FRAME_END_SPLIT_PATTERN.search(line) is not None
|
|
3758
3861
|
|
|
@@ -3766,6 +3869,9 @@ class ModelClient:
|
|
|
3766
3869
|
lines = lines[:-1]
|
|
3767
3870
|
return "\n".join(lines).strip()
|
|
3768
3871
|
|
|
3872
|
+
def _strip_fence_marker_lines(self, text: str) -> str:
|
|
3873
|
+
return re.sub(r"(?m)^\s*```[a-zA-Z0-9_-]*\s*$\n?", "", text).strip()
|
|
3874
|
+
|
|
3769
3875
|
def _strip_leaked_think_tags(self, text: str) -> str:
|
|
3770
3876
|
text = text.strip()
|
|
3771
3877
|
while text.startswith("</think>"):
|
|
@@ -3779,6 +3885,9 @@ class ModelClient:
|
|
|
3779
3885
|
text = text[len("</think>") :].lstrip()
|
|
3780
3886
|
return text
|
|
3781
3887
|
|
|
3888
|
+
def _strip_leaked_tool_code(self, text: str) -> str:
|
|
3889
|
+
return re.sub(r"<tool_code>.*?</tool_code>", "", text, flags=re.DOTALL).strip()
|
|
3890
|
+
|
|
3782
3891
|
def _invalid_model_response(self, content: str, reason: str = "expected one JSON object matching the Output JSON schema") -> Json:
|
|
3783
3892
|
guidance = ""
|
|
3784
3893
|
if self._strip_leaked_think_tags(content.strip()).startswith("<tool_call>"):
|
|
@@ -3812,10 +3921,12 @@ class ModelClient:
|
|
|
3812
3921
|
}
|
|
3813
3922
|
return "API response missing message content: " + json.dumps(details, ensure_ascii=False)
|
|
3814
3923
|
|
|
3815
|
-
def _record_usage(self, usage: Json, config: ProviderConfig) -> None:
|
|
3924
|
+
def _record_usage(self, usage: Json, config: ProviderConfig, *, elapsed: float = 0.0) -> None:
|
|
3816
3925
|
prompt_tokens = self._json_int(usage.get("prompt_tokens"))
|
|
3817
3926
|
completion_tokens = self._json_int(usage.get("completion_tokens"))
|
|
3818
3927
|
total_tokens = self._json_int(usage.get("total_tokens"))
|
|
3928
|
+
if completion_tokens > 0 and elapsed > 0:
|
|
3929
|
+
self.session.state.last_model_call_rate = completion_tokens / elapsed
|
|
3819
3930
|
self.session.state.last_prompt_tokens = prompt_tokens
|
|
3820
3931
|
self.session.state.last_completion_tokens = completion_tokens
|
|
3821
3932
|
self.session.state.last_total_tokens = total_tokens
|
|
@@ -4787,7 +4898,6 @@ class Agent:
|
|
|
4787
4898
|
KEPT_TOOL_RESULT_CHARS: ClassVar[int] = 96_000
|
|
4788
4899
|
RECENT_TOOL_CALL_SUMMARIES: ClassVar[int] = 40
|
|
4789
4900
|
PENDING_OBSERVE_RESULTS: ClassVar[int] = 8
|
|
4790
|
-
PENDING_OBSERVE_CHAR_RATIO: ClassVar[float] = 0.4
|
|
4791
4901
|
PENDING_OBSERVE_TOOL_TURNS: ClassVar[int] = 2
|
|
4792
4902
|
PLAN_MODE_GIT_READONLY: ClassVar[frozenset[str]] = GIT_READONLY_COMMANDS
|
|
4793
4903
|
|
|
@@ -4810,6 +4920,7 @@ class Agent:
|
|
|
4810
4920
|
self.failed_tool_call_count = 0
|
|
4811
4921
|
self.agent_feedback_errors: list[str] = []
|
|
4812
4922
|
self.observe_feedback_errors: list[str] = []
|
|
4923
|
+
self.task_alignment_required = False
|
|
4813
4924
|
self.mode = AgentMode.ACT
|
|
4814
4925
|
|
|
4815
4926
|
def build_user_prompt(self) -> str:
|
|
@@ -5094,8 +5205,6 @@ class Agent:
|
|
|
5094
5205
|
return True
|
|
5095
5206
|
if len(pending) >= self.PENDING_OBSERVE_RESULTS:
|
|
5096
5207
|
return True
|
|
5097
|
-
if len("\n\n".join(pending)) >= int(self.RECENT_TOOL_CALL_CHARS * self.PENDING_OBSERVE_CHAR_RATIO):
|
|
5098
|
-
return True
|
|
5099
5208
|
return self.runtime.consecutive_tool_turns >= self.PENDING_OBSERVE_TOOL_TURNS
|
|
5100
5209
|
|
|
5101
5210
|
def _tool_failure_needs_observe(self, execution: ToolCallExecution) -> bool:
|
|
@@ -5481,6 +5590,24 @@ class Agent:
|
|
|
5481
5590
|
"PlanMode_Gate: " + plan_mode_tool_error + ".",
|
|
5482
5591
|
)
|
|
5483
5592
|
return True
|
|
5593
|
+
if (
|
|
5594
|
+
self.blackboard.task_code == TaskCode.NEW
|
|
5595
|
+
and self.task_alignment_required
|
|
5596
|
+
and (ctx.tool_calls or ctx.pending_verify_requested)
|
|
5597
|
+
and not ctx.has_goal_action
|
|
5598
|
+
and not ctx.has_plan_action
|
|
5599
|
+
and not ctx.has_user_rule_action
|
|
5600
|
+
):
|
|
5601
|
+
self._remember_agent_error(
|
|
5602
|
+
"Error: previous task context is still present. Rule: before work, emit start if the latest request changes the task; "
|
|
5603
|
+
"if continuing, update or confirm the plan first."
|
|
5604
|
+
)
|
|
5605
|
+
self._report_gate(
|
|
5606
|
+
on_message,
|
|
5607
|
+
"Retrying: align this request with the task before work.",
|
|
5608
|
+
"GoalPlan_Gate: work before task alignment with previous task context.",
|
|
5609
|
+
)
|
|
5610
|
+
return True
|
|
5484
5611
|
if self.blackboard.task_code != TaskCode.NEW and any(_json_str(action.get("type")) == "start" for action in ctx.actions):
|
|
5485
5612
|
self._remember_agent_error(
|
|
5486
5613
|
"Error: repeated start is invalid after the current task is active. Rule: follow Current Task Code and continue with plan/tool/verify/goal."
|
|
@@ -5962,10 +6089,16 @@ class Agent:
|
|
|
5962
6089
|
self.mode = AgentMode.ACT
|
|
5963
6090
|
self.session.state.turn_tool_calls = 0
|
|
5964
6091
|
self.session.state.turn_model_calls = 0
|
|
6092
|
+
old_goal = self.blackboard.goal
|
|
6093
|
+
old_task_context = bool(self.blackboard.goal or self.blackboard.plan or self.blackboard.hypotheses)
|
|
5965
6094
|
self.blackboard.user_input = user_input
|
|
5966
6095
|
previous_task_done = self.blackboard.task_code == TaskCode.DONE
|
|
5967
6096
|
if previous_task_done:
|
|
5968
6097
|
self.blackboard.work_mode = WorkMode.NORMAL
|
|
6098
|
+
# Keep previous task state at a new user turn so short follow-ups like
|
|
6099
|
+
# "continue" can resume. The first response must align with it before work
|
|
6100
|
+
# when the new request does not match the previous goal.
|
|
6101
|
+
self.task_alignment_required = old_task_context and self._task_text_key(user_input) != self._task_text_key(old_goal)
|
|
5969
6102
|
self.blackboard.task_code = TaskCode.NEW
|
|
5970
6103
|
self.blackboard.goal_reached = False
|
|
5971
6104
|
self.blackboard.verification_required = False
|
|
@@ -5988,6 +6121,9 @@ class Agent:
|
|
|
5988
6121
|
on_step_limit=lambda: (_ for _ in ()).throw(LLMError("agent step limit reached")),
|
|
5989
6122
|
)
|
|
5990
6123
|
|
|
6124
|
+
def _task_text_key(self, text: str) -> str:
|
|
6125
|
+
return re.sub(r"\s+", " ", text).strip(" \t\r\n。.;;").lower()
|
|
6126
|
+
|
|
5991
6127
|
def handle_response(
|
|
5992
6128
|
self,
|
|
5993
6129
|
response: Json,
|
|
@@ -6656,21 +6792,27 @@ class StatusBar:
|
|
|
6656
6792
|
context = str(len(session.state.conversation)) + "/" + str(session.settings.compact_at)
|
|
6657
6793
|
last_tokens = _format_count(session.state.last_total_tokens)
|
|
6658
6794
|
session_tokens = _format_count(session.state.session_total_tokens)
|
|
6659
|
-
|
|
6660
|
-
|
|
6795
|
+
rate = session.state.last_model_call_rate
|
|
6796
|
+
token_summary = "last:" + last_tokens + " sess:" + session_tokens
|
|
6797
|
+
parts = [model + " (" + reasoning + ")" + modes, "ctx:" + context, "tool:" + str(session.state.turn_tool_calls), "tok:" + token_summary]
|
|
6661
6798
|
if show_elapsed:
|
|
6662
|
-
parts.append(f"{turn_elapsed:.1f}s")
|
|
6799
|
+
parts.append(f"turn:{turn_elapsed:.1f}s")
|
|
6663
6800
|
if session.state.current_model_call_started_at > 0:
|
|
6664
6801
|
activity = self._activity_label(session.state.current_model_call_activity)
|
|
6665
6802
|
if session.state.current_model_call_has_content:
|
|
6666
6803
|
activity += "*"
|
|
6804
|
+
elapsed = max(0.0, now - session.state.current_model_call_started_at)
|
|
6805
|
+
if session.state.current_model_call_has_content and elapsed > 0:
|
|
6806
|
+
rate = session.state.current_model_call_streaming_chars / 4 / elapsed
|
|
6667
6807
|
parts.append(
|
|
6668
6808
|
activity
|
|
6669
6809
|
+ "("
|
|
6670
6810
|
+ str(session.state.turn_model_calls)
|
|
6671
6811
|
+ "):"
|
|
6672
|
-
+ f"{
|
|
6812
|
+
+ f"{elapsed:.1f}s"
|
|
6673
6813
|
)
|
|
6814
|
+
if rate > 0:
|
|
6815
|
+
parts[3] += " " + _format_count(int(rate)) + "t/s"
|
|
6674
6816
|
if session.state.status_notice and session.state.status_notice_until > now:
|
|
6675
6817
|
parts.append(session.state.status_notice)
|
|
6676
6818
|
return " | ".join(parts)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.32
|
|
4
4
|
Summary: A lightweight terminal-based AI coding assistant
|
|
5
5
|
Author-email: hit9 <hit9@icloud.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -119,7 +119,7 @@ USE AT YOUR OWN RISK.
|
|
|
119
119
|
- Maintenance: `/clean`.
|
|
120
120
|
- Exit: `/exit`, `/quit`.
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
Selectors support `j`/`k`, arrows, `/keyword`, Enter, and Esc. `/model` lists configured models before discovered ones, then prompts for reasoning; `/model <name>` and `/reason` are direct shortcuts.
|
|
123
123
|
|
|
124
124
|
## Configuration
|
|
125
125
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|