nanocode-cli 0.3.27__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.27/nanocode_cli.egg-info → nanocode_cli-0.3.32}/PKG-INFO +2 -2
- {nanocode_cli-0.3.27 → nanocode_cli-0.3.32}/README.md +1 -1
- {nanocode_cli-0.3.27 → nanocode_cli-0.3.32}/nanocode.py +237 -66
- {nanocode_cli-0.3.27 → nanocode_cli-0.3.32/nanocode_cli.egg-info}/PKG-INFO +2 -2
- {nanocode_cli-0.3.27 → nanocode_cli-0.3.32}/pyproject.toml +1 -1
- {nanocode_cli-0.3.27 → nanocode_cli-0.3.32}/LICENSE +0 -0
- {nanocode_cli-0.3.27 → nanocode_cli-0.3.32}/MANIFEST.in +0 -0
- {nanocode_cli-0.3.27 → nanocode_cli-0.3.32}/nanocode_cli.egg-info/SOURCES.txt +0 -0
- {nanocode_cli-0.3.27 → nanocode_cli-0.3.32}/nanocode_cli.egg-info/dependency_links.txt +0 -0
- {nanocode_cli-0.3.27 → nanocode_cli-0.3.32}/nanocode_cli.egg-info/entry_points.txt +0 -0
- {nanocode_cli-0.3.27 → nanocode_cli-0.3.32}/nanocode_cli.egg-info/requires.txt +0 -0
- {nanocode_cli-0.3.27 → nanocode_cli-0.3.32}/nanocode_cli.egg-info/top_level.txt +0 -0
- {nanocode_cli-0.3.27 → 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
|
|
|
@@ -821,6 +821,12 @@ class RuntimeState:
|
|
|
821
821
|
current_model_call_started_at: float = 0.0
|
|
822
822
|
current_model_call_label: str = ""
|
|
823
823
|
current_model_call_reasoning_label: str = ""
|
|
824
|
+
current_model_call_activity: str = ""
|
|
825
|
+
current_model_call_has_content: bool = False
|
|
826
|
+
current_model_call_streaming_chars: int = 0
|
|
827
|
+
last_model_call_rate: float = 0.0
|
|
828
|
+
status_notice: str = ""
|
|
829
|
+
status_notice_until: float = 0.0
|
|
824
830
|
conversation: list[ConversationItem] = field(default_factory=list)
|
|
825
831
|
user_rules: UserRules = field(default_factory=UserRules)
|
|
826
832
|
range_fingerprints: RangeFingerprintStore = field(default_factory=RangeFingerprintStore)
|
|
@@ -1666,7 +1672,7 @@ class SearchTool(Tool):
|
|
|
1666
1672
|
MAX_CONTEXT_LINES: ClassVar[int] = 30
|
|
1667
1673
|
EFFECT: ClassVar[ToolEffect] = ToolEffect.READONLY
|
|
1668
1674
|
DESCRIPTION: ClassVar[tuple[str, ...]] = (
|
|
1669
|
-
"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.",
|
|
1670
1676
|
"For exact text, escape regex metacharacters like braces, parens, dots, stars, and brackets.",
|
|
1671
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.",
|
|
1672
1678
|
"Second positional arg is always path, third positional arg is always glob; with path=, extra leading positional args are joined as regex alternatives.",
|
|
@@ -1709,8 +1715,7 @@ class SearchTool(Tool):
|
|
|
1709
1715
|
pattern = raw_pattern[3:] if raw_pattern.startswith("re:") else raw_pattern
|
|
1710
1716
|
if not pattern:
|
|
1711
1717
|
raise ToolCallArgError("pattern cannot be empty")
|
|
1712
|
-
|
|
1713
|
-
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")
|
|
1714
1719
|
target_path_arg = "."
|
|
1715
1720
|
glob_pattern = ""
|
|
1716
1721
|
context_lines = cls.CONTEXT_LINES
|
|
@@ -1859,7 +1864,9 @@ class SearchTool(Tool):
|
|
|
1859
1864
|
yield path
|
|
1860
1865
|
|
|
1861
1866
|
def _make_match(self, path: str, line_number: int, text: str) -> Match:
|
|
1862
|
-
|
|
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))
|
|
1863
1870
|
|
|
1864
1871
|
def _read_match_context(self, path: str, line_number: int) -> list[tuple[int, str]]:
|
|
1865
1872
|
if line_number <= 0:
|
|
@@ -1898,6 +1905,8 @@ class SearchTool(Tool):
|
|
|
1898
1905
|
|
|
1899
1906
|
def _rg_command(self, rg: str, *, pcre2: bool = False) -> list[str]:
|
|
1900
1907
|
cmd = [rg, "--json", "--line-number", "--max-filesize", self.RG_MAX_FILESIZE]
|
|
1908
|
+
if self._is_multiline():
|
|
1909
|
+
cmd.extend(["-U", "--multiline-dotall"])
|
|
1901
1910
|
if pcre2:
|
|
1902
1911
|
cmd.append("--pcre2")
|
|
1903
1912
|
cmd.append("-i")
|
|
@@ -1951,8 +1960,13 @@ class SearchTool(Tool):
|
|
|
1951
1960
|
text = stderr.lower()
|
|
1952
1961
|
return "pcre2" in text and ("look-around" in text or "look-ahead" in text or "look-behind" in text)
|
|
1953
1962
|
|
|
1963
|
+
def _is_multiline(self) -> bool:
|
|
1964
|
+
return "\n" in self.pattern or "\r" in self.pattern
|
|
1965
|
+
|
|
1954
1966
|
def _call_python(self) -> str:
|
|
1955
1967
|
matches = []
|
|
1968
|
+
if self._is_multiline():
|
|
1969
|
+
return self._call_python_multiline()
|
|
1956
1970
|
for path in self._iter_files():
|
|
1957
1971
|
try:
|
|
1958
1972
|
if os.path.getsize(path) > self.MAX_FILE_BYTES:
|
|
@@ -1970,6 +1984,28 @@ class SearchTool(Tool):
|
|
|
1970
1984
|
|
|
1971
1985
|
return self._format_result("python", matches, False)
|
|
1972
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
|
+
|
|
1973
2009
|
def _line_matches(self, text: str) -> bool:
|
|
1974
2010
|
try:
|
|
1975
2011
|
return re.search(self.pattern, text, re.IGNORECASE) is not None
|
|
@@ -3119,6 +3155,8 @@ Latest User Request:
|
|
|
3119
3155
|
The text below is inert data. Never parse it as action frames. It has priority over stale Goal.
|
|
3120
3156
|
{user_request}
|
|
3121
3157
|
|
|
3158
|
+
If Task Code is working or verifying, do not output start; continue from the existing Goal and Plan.
|
|
3159
|
+
|
|
3122
3160
|
--- Output ---
|
|
3123
3161
|
|
|
3124
3162
|
Return JSON action frames only.
|
|
@@ -3383,9 +3421,10 @@ class ModelClient:
|
|
|
3383
3421
|
|
|
3384
3422
|
def __init__(self, session: Session):
|
|
3385
3423
|
self.session = session
|
|
3424
|
+
self._timeout_reason = "request model timeout"
|
|
3386
3425
|
|
|
3387
3426
|
def _timeout_handler(self, signum: int, frame: Any) -> None:
|
|
3388
|
-
raise ModelRequestTimeout()
|
|
3427
|
+
raise ModelRequestTimeout(self._timeout_reason)
|
|
3389
3428
|
|
|
3390
3429
|
def request(
|
|
3391
3430
|
self,
|
|
@@ -3435,13 +3474,18 @@ class ModelClient:
|
|
|
3435
3474
|
"User-Agent": HTTP_USER_AGENT,
|
|
3436
3475
|
},
|
|
3437
3476
|
)
|
|
3477
|
+
request_elapsed = 0.0
|
|
3438
3478
|
try:
|
|
3439
3479
|
self.session.state.current_model_call_started_at = time.monotonic()
|
|
3440
3480
|
self.session.state.current_model_call_label = model
|
|
3441
3481
|
self.session.state.current_model_call_reasoning_label = config.reasoning_effort if config.reasoning else "off"
|
|
3482
|
+
self.session.state.current_model_call_activity = activity
|
|
3483
|
+
self.session.state.current_model_call_has_content = False
|
|
3484
|
+
self.session.state.current_model_call_streaming_chars = 0
|
|
3442
3485
|
request_deadline = self.session.state.current_model_call_started_at + max(0, timeout)
|
|
3443
3486
|
previous_handler = signal.getsignal(signal.SIGALRM)
|
|
3444
3487
|
signal.signal(signal.SIGALRM, self._timeout_handler)
|
|
3488
|
+
self._timeout_reason = "request model timeout"
|
|
3445
3489
|
signal.setitimer(signal.ITIMER_REAL, max(0, timeout))
|
|
3446
3490
|
try:
|
|
3447
3491
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
@@ -3457,11 +3501,18 @@ class ModelClient:
|
|
|
3457
3501
|
finally:
|
|
3458
3502
|
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
3459
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)
|
|
3460
3508
|
self.session.state.current_model_call_started_at = 0.0
|
|
3461
3509
|
self.session.state.current_model_call_label = ""
|
|
3462
3510
|
self.session.state.current_model_call_reasoning_label = ""
|
|
3463
|
-
|
|
3464
|
-
|
|
3511
|
+
self.session.state.current_model_call_activity = ""
|
|
3512
|
+
self.session.state.current_model_call_has_content = False
|
|
3513
|
+
self.session.state.current_model_call_streaming_chars = 0
|
|
3514
|
+
except ModelRequestTimeout as error:
|
|
3515
|
+
raise LLMError(str(error) or "request model timeout")
|
|
3465
3516
|
except (socket.timeout, TimeoutError):
|
|
3466
3517
|
raise LLMError("request model timeout")
|
|
3467
3518
|
except urllib.error.HTTPError as error:
|
|
@@ -3480,7 +3531,7 @@ class ModelClient:
|
|
|
3480
3531
|
except json.JSONDecodeError:
|
|
3481
3532
|
raise LLMError("API response is not JSON: " + _shorten(body))
|
|
3482
3533
|
|
|
3483
|
-
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)
|
|
3484
3535
|
if not stream:
|
|
3485
3536
|
content = self._message_content(result)
|
|
3486
3537
|
if content is None:
|
|
@@ -3525,16 +3576,24 @@ class ModelClient:
|
|
|
3525
3576
|
continue
|
|
3526
3577
|
if not first_content_seen:
|
|
3527
3578
|
first_content_seen = True
|
|
3579
|
+
self.session.state.current_model_call_has_content = True
|
|
3528
3580
|
self._arm_stream_timeout(request_deadline=request_deadline, first_content_seen=True, first_token_timeout=first_token_timeout)
|
|
3529
3581
|
parts.append(content)
|
|
3582
|
+
self.session.state.current_model_call_streaming_chars += len(content)
|
|
3530
3583
|
return "".join(parts), usage
|
|
3531
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
|
+
|
|
3532
3588
|
def _arm_stream_timeout(self, *, request_deadline: float, first_content_seen: bool, first_token_timeout: int | None) -> None:
|
|
3533
3589
|
remaining = request_deadline - time.monotonic()
|
|
3534
3590
|
if remaining <= 0:
|
|
3535
|
-
raise ModelRequestTimeout()
|
|
3591
|
+
raise ModelRequestTimeout("request model timeout")
|
|
3592
|
+
self._timeout_reason = "request model timeout"
|
|
3536
3593
|
if not first_content_seen and first_token_timeout is not None and first_token_timeout > 0:
|
|
3537
|
-
|
|
3594
|
+
if first_token_timeout < remaining:
|
|
3595
|
+
remaining = first_token_timeout
|
|
3596
|
+
self._timeout_reason = "request first token timeout"
|
|
3538
3597
|
signal.setitimer(signal.ITIMER_REAL, remaining)
|
|
3539
3598
|
|
|
3540
3599
|
def _write_debug_prompt(self, *, activity: str, messages: list[Json]) -> str:
|
|
@@ -3565,8 +3624,11 @@ class ModelClient:
|
|
|
3565
3624
|
def _parse_model_content(self, content: str) -> Json:
|
|
3566
3625
|
text = content.strip()
|
|
3567
3626
|
text = self._strip_leaked_think_tags(text)
|
|
3627
|
+
text = self._strip_leaked_tool_code(text)
|
|
3568
3628
|
text = self._strip_json_fence(text)
|
|
3629
|
+
text = self._strip_fence_marker_lines(text)
|
|
3569
3630
|
text = self._strip_leaked_think_tags(text)
|
|
3631
|
+
text = self._strip_leaked_tool_code(text)
|
|
3570
3632
|
if not self._has_action_frame_end(text):
|
|
3571
3633
|
actions, error = self._parse_unmarked_actions(text)
|
|
3572
3634
|
if actions:
|
|
@@ -3644,6 +3706,7 @@ class ModelClient:
|
|
|
3644
3706
|
if isinstance(value, dict):
|
|
3645
3707
|
if "actions" in value:
|
|
3646
3708
|
return self._actions_from_json_value(value.get("actions"))
|
|
3709
|
+
self._normalize_tool_type(value)
|
|
3647
3710
|
if not _json_str(value.get("type")):
|
|
3648
3711
|
return [], "action missing type"
|
|
3649
3712
|
return [value], ""
|
|
@@ -3653,12 +3716,20 @@ class ModelClient:
|
|
|
3653
3716
|
action = _json_dict(raw)
|
|
3654
3717
|
if not action:
|
|
3655
3718
|
return [], "array item " + str(index) + ": expected JSON object action"
|
|
3719
|
+
self._normalize_tool_type(action)
|
|
3656
3720
|
if not _json_str(action.get("type")):
|
|
3657
3721
|
return [], "array item " + str(index) + ": action missing type"
|
|
3658
3722
|
actions.append(action)
|
|
3659
3723
|
return actions, ""
|
|
3660
3724
|
return [], "expected JSON object action"
|
|
3661
3725
|
|
|
3726
|
+
def _normalize_tool_type(self, action: Json) -> None:
|
|
3727
|
+
action_type = _json_str(action.get("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:
|
|
3730
|
+
action["type"] = "tool"
|
|
3731
|
+
action.setdefault("name", tool_name)
|
|
3732
|
+
|
|
3662
3733
|
def _parse_unmarked_actions(self, text: str) -> tuple[list[Json], str]:
|
|
3663
3734
|
actions: list[Json] = []
|
|
3664
3735
|
decoder = json.JSONDecoder()
|
|
@@ -3669,8 +3740,8 @@ class ModelClient:
|
|
|
3669
3740
|
if index < len(text) and text[index] != "{":
|
|
3670
3741
|
if text[index] == "[":
|
|
3671
3742
|
try:
|
|
3672
|
-
value, index =
|
|
3673
|
-
except json.JSONDecodeError as error:
|
|
3743
|
+
value, index = self._decode_json_array_text(text, index)
|
|
3744
|
+
except (json.JSONDecodeError, ValueError) as error:
|
|
3674
3745
|
return [], str(error)
|
|
3675
3746
|
parsed, error = self._actions_from_json_value(value)
|
|
3676
3747
|
if error:
|
|
@@ -3682,12 +3753,15 @@ class ModelClient:
|
|
|
3682
3753
|
return parsed, ""
|
|
3683
3754
|
action_start = text.find("{", index)
|
|
3684
3755
|
if action_start < 0:
|
|
3756
|
+
progress = self._plain_progress_text(text[index:])
|
|
3757
|
+
if progress:
|
|
3758
|
+
return [{"type": "progress", "text": progress}], ""
|
|
3685
3759
|
try:
|
|
3686
3760
|
decoder.raw_decode(text, index)
|
|
3687
3761
|
except json.JSONDecodeError as error:
|
|
3688
3762
|
return [], str(error)
|
|
3689
3763
|
return [], "expected JSON object action"
|
|
3690
|
-
prefix =
|
|
3764
|
+
prefix = self._progress_text(text[:action_start])
|
|
3691
3765
|
index = action_start
|
|
3692
3766
|
while True:
|
|
3693
3767
|
while index < len(text) and text[index].isspace():
|
|
@@ -3699,6 +3773,14 @@ class ModelClient:
|
|
|
3699
3773
|
try:
|
|
3700
3774
|
value, index = decoder.raw_decode(text, index)
|
|
3701
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, ""
|
|
3702
3784
|
return [], str(error)
|
|
3703
3785
|
parsed, error = self._actions_from_json_value(value)
|
|
3704
3786
|
if error:
|
|
@@ -3708,6 +3790,71 @@ class ModelClient:
|
|
|
3708
3790
|
index += 1
|
|
3709
3791
|
if index < len(text) and text[index] == ",":
|
|
3710
3792
|
index += 1
|
|
3793
|
+
continue
|
|
3794
|
+
if index < len(text) and text[index] != "{":
|
|
3795
|
+
next_action = text.find("{", index)
|
|
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, ""
|
|
3801
|
+
return [], "unexpected text after JSON action"
|
|
3802
|
+
progress = self._progress_text(text[index:next_action])
|
|
3803
|
+
if progress:
|
|
3804
|
+
actions.append({"type": "progress", "text": progress})
|
|
3805
|
+
index = next_action
|
|
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
|
+
|
|
3831
|
+
def _decode_json_array_text(self, text: str, index: int) -> tuple[JsonValue, int]:
|
|
3832
|
+
decoder = json.JSONDecoder()
|
|
3833
|
+
value, end = decoder.raw_decode(text, index)
|
|
3834
|
+
cursor = end
|
|
3835
|
+
while cursor < len(text) and text[cursor].isspace():
|
|
3836
|
+
cursor += 1
|
|
3837
|
+
if cursor >= len(text):
|
|
3838
|
+
return value, cursor
|
|
3839
|
+
value = json_repair.loads(text[index:])
|
|
3840
|
+
if not isinstance(value, list):
|
|
3841
|
+
raise ValueError("expected JSON action array")
|
|
3842
|
+
return value, len(text)
|
|
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
|
|
3711
3858
|
|
|
3712
3859
|
def _has_action_frame_end(self, line: str) -> bool:
|
|
3713
3860
|
return self.ACTION_FRAME_END_SPLIT_PATTERN.search(line) is not None
|
|
@@ -3722,6 +3869,9 @@ class ModelClient:
|
|
|
3722
3869
|
lines = lines[:-1]
|
|
3723
3870
|
return "\n".join(lines).strip()
|
|
3724
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
|
+
|
|
3725
3875
|
def _strip_leaked_think_tags(self, text: str) -> str:
|
|
3726
3876
|
text = text.strip()
|
|
3727
3877
|
while text.startswith("</think>"):
|
|
@@ -3735,6 +3885,9 @@ class ModelClient:
|
|
|
3735
3885
|
text = text[len("</think>") :].lstrip()
|
|
3736
3886
|
return text
|
|
3737
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
|
+
|
|
3738
3891
|
def _invalid_model_response(self, content: str, reason: str = "expected one JSON object matching the Output JSON schema") -> Json:
|
|
3739
3892
|
guidance = ""
|
|
3740
3893
|
if self._strip_leaked_think_tags(content.strip()).startswith("<tool_call>"):
|
|
@@ -3768,10 +3921,12 @@ class ModelClient:
|
|
|
3768
3921
|
}
|
|
3769
3922
|
return "API response missing message content: " + json.dumps(details, ensure_ascii=False)
|
|
3770
3923
|
|
|
3771
|
-
def _record_usage(self, usage: Json, config: ProviderConfig) -> None:
|
|
3924
|
+
def _record_usage(self, usage: Json, config: ProviderConfig, *, elapsed: float = 0.0) -> None:
|
|
3772
3925
|
prompt_tokens = self._json_int(usage.get("prompt_tokens"))
|
|
3773
3926
|
completion_tokens = self._json_int(usage.get("completion_tokens"))
|
|
3774
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
|
|
3775
3930
|
self.session.state.last_prompt_tokens = prompt_tokens
|
|
3776
3931
|
self.session.state.last_completion_tokens = completion_tokens
|
|
3777
3932
|
self.session.state.last_total_tokens = total_tokens
|
|
@@ -4743,7 +4898,6 @@ class Agent:
|
|
|
4743
4898
|
KEPT_TOOL_RESULT_CHARS: ClassVar[int] = 96_000
|
|
4744
4899
|
RECENT_TOOL_CALL_SUMMARIES: ClassVar[int] = 40
|
|
4745
4900
|
PENDING_OBSERVE_RESULTS: ClassVar[int] = 8
|
|
4746
|
-
PENDING_OBSERVE_CHAR_RATIO: ClassVar[float] = 0.4
|
|
4747
4901
|
PENDING_OBSERVE_TOOL_TURNS: ClassVar[int] = 2
|
|
4748
4902
|
PLAN_MODE_GIT_READONLY: ClassVar[frozenset[str]] = GIT_READONLY_COMMANDS
|
|
4749
4903
|
|
|
@@ -4766,6 +4920,7 @@ class Agent:
|
|
|
4766
4920
|
self.failed_tool_call_count = 0
|
|
4767
4921
|
self.agent_feedback_errors: list[str] = []
|
|
4768
4922
|
self.observe_feedback_errors: list[str] = []
|
|
4923
|
+
self.task_alignment_required = False
|
|
4769
4924
|
self.mode = AgentMode.ACT
|
|
4770
4925
|
|
|
4771
4926
|
def build_user_prompt(self) -> str:
|
|
@@ -4793,12 +4948,14 @@ class Agent:
|
|
|
4793
4948
|
self.session.state.turn_model_calls += 1
|
|
4794
4949
|
return self.model_client.request(system_prompt, user_prompt, activity=activity)
|
|
4795
4950
|
except LLMError as error:
|
|
4796
|
-
|
|
4951
|
+
timeout_reason = str(error)
|
|
4952
|
+
if timeout_reason not in ("request model timeout", "request first token timeout") or attempt >= len(self.MODEL_TIMEOUT_RETRY_DELAYS):
|
|
4797
4953
|
raise
|
|
4798
4954
|
delay = self.MODEL_TIMEOUT_RETRY_DELAYS[attempt]
|
|
4955
|
+
self._set_status_notice("err:first_token" if timeout_reason == "request first token timeout" else "err:timeout")
|
|
4799
4956
|
if on_message is not None and self.session.settings.debug:
|
|
4800
4957
|
on_message(
|
|
4801
|
-
"Retrying:
|
|
4958
|
+
"Retrying: " + timeout_reason + "; retry "
|
|
4802
4959
|
+ str(attempt + 1)
|
|
4803
4960
|
+ "/"
|
|
4804
4961
|
+ str(len(self.MODEL_TIMEOUT_RETRY_DELAYS))
|
|
@@ -4809,6 +4966,10 @@ class Agent:
|
|
|
4809
4966
|
time.sleep(delay)
|
|
4810
4967
|
raise LLMError("request model timeout")
|
|
4811
4968
|
|
|
4969
|
+
def _set_status_notice(self, text: str, ttl: float = 5.0) -> None:
|
|
4970
|
+
self.session.state.status_notice = text
|
|
4971
|
+
self.session.state.status_notice_until = time.monotonic() + ttl
|
|
4972
|
+
|
|
4812
4973
|
def compact_history(self) -> int:
|
|
4813
4974
|
return self.compactor.compact()
|
|
4814
4975
|
|
|
@@ -4834,6 +4995,7 @@ class Agent:
|
|
|
4834
4995
|
format_error = _json_str(response.get("_format_error"))
|
|
4835
4996
|
if format_error:
|
|
4836
4997
|
consecutive_format_errors += 1
|
|
4998
|
+
self._set_status_notice("err:format")
|
|
4837
4999
|
remember_error = self._remember_observe_error if self.mode == AgentMode.OBSERVE else self._remember_agent_error
|
|
4838
5000
|
remember_error(
|
|
4839
5001
|
self._format_gate_user_message("Error: model returned invalid output", format_error) + " Rule: return valid JSON action frames only."
|
|
@@ -4919,6 +5081,8 @@ class Agent:
|
|
|
4919
5081
|
def _report_gate(self, on_message: MessageCallback | None, message: str, debug_message: str) -> None:
|
|
4920
5082
|
if on_message is None:
|
|
4921
5083
|
return
|
|
5084
|
+
if message.startswith(("Retrying:", "Continuing:")) and self.session.state.status_notice_until <= time.monotonic():
|
|
5085
|
+
self._set_status_notice("err:gate")
|
|
4922
5086
|
if self.session.settings.debug:
|
|
4923
5087
|
on_message(debug_message)
|
|
4924
5088
|
return
|
|
@@ -4945,13 +5109,15 @@ class Agent:
|
|
|
4945
5109
|
if self.mode == AgentMode.OBSERVE:
|
|
4946
5110
|
system_prompt = self.prompt_builder.system_prompt(AGENT_OBSERVE_SYSTEM_PROMPT, tools=())
|
|
4947
5111
|
user_prompt = self.build_observe_prompt()
|
|
5112
|
+
activity = "observe"
|
|
4948
5113
|
else:
|
|
4949
5114
|
system_prompt = self.prompt_builder.system_prompt(
|
|
4950
5115
|
AGENT_PLAN_SYSTEM_PROMPT if self.session.settings.plan_mode else None,
|
|
4951
5116
|
tools=PLAN_MODE_TOOLS if self.session.settings.plan_mode else None,
|
|
4952
5117
|
)
|
|
4953
5118
|
user_prompt = self.build_user_prompt()
|
|
4954
|
-
|
|
5119
|
+
activity = "agent"
|
|
5120
|
+
response = self.request(system_prompt, user_prompt, activity=activity, on_message=on_message)
|
|
4955
5121
|
if _json_str(response.get("_format_error")):
|
|
4956
5122
|
return response
|
|
4957
5123
|
invalid_response = self._validate_action_response(response)
|
|
@@ -5039,8 +5205,6 @@ class Agent:
|
|
|
5039
5205
|
return True
|
|
5040
5206
|
if len(pending) >= self.PENDING_OBSERVE_RESULTS:
|
|
5041
5207
|
return True
|
|
5042
|
-
if len("\n\n".join(pending)) >= int(self.RECENT_TOOL_CALL_CHARS * self.PENDING_OBSERVE_CHAR_RATIO):
|
|
5043
|
-
return True
|
|
5044
5208
|
return self.runtime.consecutive_tool_turns >= self.PENDING_OBSERVE_TOOL_TURNS
|
|
5045
5209
|
|
|
5046
5210
|
def _tool_failure_needs_observe(self, execution: ToolCallExecution) -> bool:
|
|
@@ -5297,29 +5461,6 @@ class Agent:
|
|
|
5297
5461
|
conflict = sorted((forgotten & protected) - released)
|
|
5298
5462
|
return "active hypothesis source: " + ", ".join(conflict) if conflict else ""
|
|
5299
5463
|
|
|
5300
|
-
def _plan_shape_error(self, actions: list[Json]) -> str:
|
|
5301
|
-
plan = [PlanItem(text=item.text, status=item.status, id=item.id, context=item.context) for item in self.blackboard.plan]
|
|
5302
|
-
changed = False
|
|
5303
|
-
for action in actions:
|
|
5304
|
-
action_type = _json_str(action.get("type"))
|
|
5305
|
-
if action_type == "start":
|
|
5306
|
-
items = self._plan_items_from_json(action.get("plan"))
|
|
5307
|
-
if items:
|
|
5308
|
-
plan = items
|
|
5309
|
-
changed = True
|
|
5310
|
-
elif action_type == "plan":
|
|
5311
|
-
items = self._plan_items_from_json(action.get("items"))
|
|
5312
|
-
if action.get("mode") != "patch":
|
|
5313
|
-
if items:
|
|
5314
|
-
plan = items
|
|
5315
|
-
changed = True
|
|
5316
|
-
continue
|
|
5317
|
-
changed = self.state_updater._apply_plan_patches(plan, action.get("items")) or changed
|
|
5318
|
-
doing = [item for item in plan if item.status == PlanStatus.DOING]
|
|
5319
|
-
if changed and len(doing) > 1:
|
|
5320
|
-
return "multiple doing plan items: " + self._format_plan_gate_items(doing)
|
|
5321
|
-
return ""
|
|
5322
|
-
|
|
5323
5464
|
def _plan_items_from_json(self, value: JsonValue) -> list[PlanItem]:
|
|
5324
5465
|
return [item for item in (self.state_updater._plan_item_from_json(raw) for raw in _json_list(value)) if item]
|
|
5325
5466
|
|
|
@@ -5427,15 +5568,6 @@ class Agent:
|
|
|
5427
5568
|
"ToolResult_Gate: " + forget_hypothesis_error + ".",
|
|
5428
5569
|
)
|
|
5429
5570
|
return True
|
|
5430
|
-
plan_shape_error = self._plan_shape_error(ctx.actions)
|
|
5431
|
-
if plan_shape_error:
|
|
5432
|
-
self._remember_agent_error("Error: Plan is invalid: " + plan_shape_error + ". Rule: at most one Plan item may be doing.")
|
|
5433
|
-
self._report_gate(
|
|
5434
|
-
on_message,
|
|
5435
|
-
"Retrying: keep only one plan item doing.",
|
|
5436
|
-
"Plan_Gate: " + plan_shape_error + ".",
|
|
5437
|
-
)
|
|
5438
|
-
return True
|
|
5439
5571
|
repeated_tool_retry_error = self._repeated_tool_retry_error(ctx.tool_calls)
|
|
5440
5572
|
if repeated_tool_retry_error:
|
|
5441
5573
|
self._remember_agent_error(
|
|
@@ -5458,6 +5590,24 @@ class Agent:
|
|
|
5458
5590
|
"PlanMode_Gate: " + plan_mode_tool_error + ".",
|
|
5459
5591
|
)
|
|
5460
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
|
|
5461
5611
|
if self.blackboard.task_code != TaskCode.NEW and any(_json_str(action.get("type")) == "start" for action in ctx.actions):
|
|
5462
5612
|
self._remember_agent_error(
|
|
5463
5613
|
"Error: repeated start is invalid after the current task is active. Rule: follow Current Task Code and continue with plan/tool/verify/goal."
|
|
@@ -5732,15 +5882,6 @@ class Agent:
|
|
|
5732
5882
|
"ToolResult_Gate: " + forget_hypothesis_error + ".",
|
|
5733
5883
|
)
|
|
5734
5884
|
return AgentRunResult()
|
|
5735
|
-
plan_shape_error = self._plan_shape_error(ctx.actions)
|
|
5736
|
-
if plan_shape_error:
|
|
5737
|
-
self._remember_observe_error("Error: Plan is invalid: " + plan_shape_error + ". Rule: at most one Plan item may be doing.")
|
|
5738
|
-
self._report_gate(
|
|
5739
|
-
on_message,
|
|
5740
|
-
"Retrying: keep only one plan item doing.",
|
|
5741
|
-
"Plan_Gate: " + plan_shape_error + ".",
|
|
5742
|
-
)
|
|
5743
|
-
return AgentRunResult()
|
|
5744
5885
|
if any(_json_str(action.get("type")) == "verify" and _json_str(action.get("status")) == "pending" for action in ctx.actions):
|
|
5745
5886
|
self._remember_observe_error("Error: cannot request new verification before observing latest results. Rule: keep or forget latest results first.")
|
|
5746
5887
|
self._report_gate(
|
|
@@ -5948,10 +6089,16 @@ class Agent:
|
|
|
5948
6089
|
self.mode = AgentMode.ACT
|
|
5949
6090
|
self.session.state.turn_tool_calls = 0
|
|
5950
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)
|
|
5951
6094
|
self.blackboard.user_input = user_input
|
|
5952
6095
|
previous_task_done = self.blackboard.task_code == TaskCode.DONE
|
|
5953
6096
|
if previous_task_done:
|
|
5954
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)
|
|
5955
6102
|
self.blackboard.task_code = TaskCode.NEW
|
|
5956
6103
|
self.blackboard.goal_reached = False
|
|
5957
6104
|
self.blackboard.verification_required = False
|
|
@@ -5974,6 +6121,9 @@ class Agent:
|
|
|
5974
6121
|
on_step_limit=lambda: (_ for _ in ()).throw(LLMError("agent step limit reached")),
|
|
5975
6122
|
)
|
|
5976
6123
|
|
|
6124
|
+
def _task_text_key(self, text: str) -> str:
|
|
6125
|
+
return re.sub(r"\s+", " ", text).strip(" \t\r\n。.;;").lower()
|
|
6126
|
+
|
|
5977
6127
|
def handle_response(
|
|
5978
6128
|
self,
|
|
5979
6129
|
response: Json,
|
|
@@ -6642,14 +6792,35 @@ class StatusBar:
|
|
|
6642
6792
|
context = str(len(session.state.conversation)) + "/" + str(session.settings.compact_at)
|
|
6643
6793
|
last_tokens = _format_count(session.state.last_total_tokens)
|
|
6644
6794
|
session_tokens = _format_count(session.state.session_total_tokens)
|
|
6645
|
-
|
|
6646
|
-
|
|
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]
|
|
6647
6798
|
if show_elapsed:
|
|
6648
|
-
parts.append(f"{turn_elapsed:.1f}s")
|
|
6799
|
+
parts.append(f"turn:{turn_elapsed:.1f}s")
|
|
6649
6800
|
if session.state.current_model_call_started_at > 0:
|
|
6650
|
-
|
|
6801
|
+
activity = self._activity_label(session.state.current_model_call_activity)
|
|
6802
|
+
if session.state.current_model_call_has_content:
|
|
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
|
|
6807
|
+
parts.append(
|
|
6808
|
+
activity
|
|
6809
|
+
+ "("
|
|
6810
|
+
+ str(session.state.turn_model_calls)
|
|
6811
|
+
+ "):"
|
|
6812
|
+
+ f"{elapsed:.1f}s"
|
|
6813
|
+
)
|
|
6814
|
+
if rate > 0:
|
|
6815
|
+
parts[3] += " " + _format_count(int(rate)) + "t/s"
|
|
6816
|
+
if session.state.status_notice and session.state.status_notice_until > now:
|
|
6817
|
+
parts.append(session.state.status_notice)
|
|
6651
6818
|
return " | ".join(parts)
|
|
6652
6819
|
|
|
6820
|
+
@staticmethod
|
|
6821
|
+
def _activity_label(activity: str) -> str:
|
|
6822
|
+
return {"compact": "compacting", "observe": "observing"}.get(activity, "working")
|
|
6823
|
+
|
|
6653
6824
|
def _sweep_fragments(self, text: str, now: float) -> list[tuple[str, str]]:
|
|
6654
6825
|
if not text:
|
|
6655
6826
|
return [("", "")]
|
|
@@ -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
|