nanocode-cli 0.2.7__tar.gz → 0.2.8__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.2.7/nanocode_cli.egg-info → nanocode_cli-0.2.8}/PKG-INFO +3 -1
- {nanocode_cli-0.2.7 → nanocode_cli-0.2.8}/README.md +1 -0
- {nanocode_cli-0.2.7 → nanocode_cli-0.2.8}/nanocode.py +507 -211
- {nanocode_cli-0.2.7 → nanocode_cli-0.2.8/nanocode_cli.egg-info}/PKG-INFO +3 -1
- {nanocode_cli-0.2.7 → nanocode_cli-0.2.8}/nanocode_cli.egg-info/requires.txt +1 -0
- {nanocode_cli-0.2.7 → nanocode_cli-0.2.8}/pyproject.toml +2 -1
- {nanocode_cli-0.2.7 → nanocode_cli-0.2.8}/LICENSE +0 -0
- {nanocode_cli-0.2.7 → nanocode_cli-0.2.8}/MANIFEST.in +0 -0
- {nanocode_cli-0.2.7 → nanocode_cli-0.2.8}/nanocode_cli.egg-info/SOURCES.txt +0 -0
- {nanocode_cli-0.2.7 → nanocode_cli-0.2.8}/nanocode_cli.egg-info/dependency_links.txt +0 -0
- {nanocode_cli-0.2.7 → nanocode_cli-0.2.8}/nanocode_cli.egg-info/entry_points.txt +0 -0
- {nanocode_cli-0.2.7 → nanocode_cli-0.2.8}/nanocode_cli.egg-info/top_level.txt +0 -0
- {nanocode_cli-0.2.7 → nanocode_cli-0.2.8}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.8
|
|
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
|
|
@@ -21,6 +21,7 @@ Classifier: Topic :: Terminals
|
|
|
21
21
|
Requires-Python: >=3.11
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
|
+
Requires-Dist: json-repair>=0.39
|
|
24
25
|
Requires-Dist: prompt-toolkit>=3.0
|
|
25
26
|
Requires-Dist: typing-extensions>=4.7
|
|
26
27
|
Provides-Extra: dev
|
|
@@ -76,6 +77,7 @@ export NANOCODE_DIR=".nanocode"
|
|
|
76
77
|
export NANOCODE_TEMPERATURE="0.7"
|
|
77
78
|
export NANOCODE_REASONING="on"
|
|
78
79
|
export NANOCODE_REASONING_EFFORT="medium"
|
|
80
|
+
export NANOCODE_STREAM="on"
|
|
79
81
|
export NANOCODE_MODEL_TIMEOUT="60"
|
|
80
82
|
export NANOCODE_SHELL_TIMEOUT="60"
|
|
81
83
|
export NANOCODE_COMPACT_AT="100"
|
|
@@ -46,6 +46,7 @@ export NANOCODE_DIR=".nanocode"
|
|
|
46
46
|
export NANOCODE_TEMPERATURE="0.7"
|
|
47
47
|
export NANOCODE_REASONING="on"
|
|
48
48
|
export NANOCODE_REASONING_EFFORT="medium"
|
|
49
|
+
export NANOCODE_STREAM="on"
|
|
49
50
|
export NANOCODE_MODEL_TIMEOUT="60"
|
|
50
51
|
export NANOCODE_SHELL_TIMEOUT="60"
|
|
51
52
|
export NANOCODE_COMPACT_AT="100"
|
|
@@ -11,6 +11,7 @@ import fnmatch
|
|
|
11
11
|
import hashlib
|
|
12
12
|
import itertools
|
|
13
13
|
import json
|
|
14
|
+
import json_repair
|
|
14
15
|
import os
|
|
15
16
|
import platform
|
|
16
17
|
import re
|
|
@@ -41,7 +42,7 @@ from prompt_toolkit.patch_stdout import patch_stdout
|
|
|
41
42
|
|
|
42
43
|
JsonValue: TypeAlias = Any
|
|
43
44
|
Json: TypeAlias = dict[str, JsonValue]
|
|
44
|
-
__version__ = "0.2.
|
|
45
|
+
__version__ = "0.2.8"
|
|
45
46
|
|
|
46
47
|
|
|
47
48
|
class Error(Exception): ...
|
|
@@ -350,14 +351,23 @@ class RangeFingerprintStore:
|
|
|
350
351
|
if current_fingerprint == fingerprint:
|
|
351
352
|
return self.Resolved(start=resolved_start, end=resolved_end, fingerprint=current_fingerprint)
|
|
352
353
|
|
|
353
|
-
|
|
354
|
+
for content in self._candidate_contents(
|
|
355
|
+
filepath=filepath,
|
|
356
|
+
start=resolved_start,
|
|
357
|
+
end=resolved_end,
|
|
358
|
+
fingerprint=fingerprint,
|
|
359
|
+
):
|
|
360
|
+
if _range_fingerprint(content) == current_fingerprint:
|
|
361
|
+
return self.Resolved(start=resolved_start, end=resolved_end, fingerprint=current_fingerprint)
|
|
362
|
+
|
|
363
|
+
matches = self._find_matches(lines, filepath=filepath, start=resolved_start, end=resolved_end, fingerprint=fingerprint)
|
|
354
364
|
message = (
|
|
355
365
|
f"fingerprint mismatch for range {start}:{end}: expected {fingerprint}, current {current_fingerprint}; "
|
|
356
366
|
f"call Read(filepath, {start}, {end}) and reuse that range fingerprint"
|
|
357
367
|
)
|
|
358
368
|
other_ranges = self._ranges_for_fingerprint(filepath=filepath, fingerprint=fingerprint)
|
|
359
369
|
if other_ranges:
|
|
360
|
-
message += "; this fingerprint was cached for
|
|
370
|
+
message += "; this fingerprint was cached for range(s): " + ", ".join(f"{range_start}:{range_end}" for range_start, range_end in other_ranges)
|
|
361
371
|
if not matches:
|
|
362
372
|
raise ToolCallError(message)
|
|
363
373
|
if len(matches) > 1:
|
|
@@ -366,18 +376,16 @@ class RangeFingerprintStore:
|
|
|
366
376
|
return self.Resolved(
|
|
367
377
|
start=relocated_start,
|
|
368
378
|
end=relocated_end,
|
|
369
|
-
fingerprint=
|
|
379
|
+
fingerprint=_range_fingerprint("".join(lines[relocated_start:relocated_end])),
|
|
370
380
|
relocated_from=(resolved_start, resolved_end),
|
|
371
381
|
)
|
|
372
382
|
|
|
373
383
|
def _find_matches(self, lines: list[str], *, filepath: str, start: int, end: int, fingerprint: str) -> list[tuple[int, int]]:
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if
|
|
378
|
-
|
|
379
|
-
if entry.content not in contents:
|
|
380
|
-
contents.append(entry.content)
|
|
384
|
+
contents = [
|
|
385
|
+
content
|
|
386
|
+
for content in self._candidate_contents(filepath=filepath, start=start, end=end, fingerprint=fingerprint)
|
|
387
|
+
if content
|
|
388
|
+
]
|
|
381
389
|
|
|
382
390
|
matches = []
|
|
383
391
|
for content in contents:
|
|
@@ -392,6 +400,25 @@ class RangeFingerprintStore:
|
|
|
392
400
|
return matches
|
|
393
401
|
return matches
|
|
394
402
|
|
|
403
|
+
def _candidate_contents(self, *, filepath: str, start: int, end: int, fingerprint: str) -> list[str]:
|
|
404
|
+
filepath = os.path.realpath(filepath)
|
|
405
|
+
contents: list[str] = []
|
|
406
|
+
for entry in self._entries:
|
|
407
|
+
if entry.fingerprint != fingerprint or entry.filepath != filepath:
|
|
408
|
+
continue
|
|
409
|
+
if start == end:
|
|
410
|
+
if entry.start == start and entry.end == end and entry.content == "":
|
|
411
|
+
contents.append("")
|
|
412
|
+
continue
|
|
413
|
+
entry_lines = entry.content.splitlines(keepends=True)
|
|
414
|
+
cached_end = entry.start + len(entry_lines)
|
|
415
|
+
if start < entry.start or end > cached_end:
|
|
416
|
+
continue
|
|
417
|
+
candidate = "".join(entry_lines[start - entry.start : end - entry.start])
|
|
418
|
+
if candidate not in contents:
|
|
419
|
+
contents.append(candidate)
|
|
420
|
+
return contents
|
|
421
|
+
|
|
395
422
|
def _ranges_for_fingerprint(self, *, filepath: str, fingerprint: str) -> list[tuple[int, int]]:
|
|
396
423
|
filepath = os.path.realpath(filepath)
|
|
397
424
|
ranges = []
|
|
@@ -427,6 +454,7 @@ class Session:
|
|
|
427
454
|
temperature: float = field(default_factory=lambda: float(os.environ.get("NANOCODE_TEMPERATURE", "0.7")))
|
|
428
455
|
reasoning: bool = field(default_factory=lambda: os.environ.get("NANOCODE_REASONING", "on") == "on")
|
|
429
456
|
reasoning_effort: str = field(default_factory=lambda: os.environ.get("NANOCODE_REASONING_EFFORT", "medium"))
|
|
457
|
+
stream: bool = field(default_factory=lambda: os.environ.get("NANOCODE_STREAM", "on") == "on")
|
|
430
458
|
model_timeout: int = field(default_factory=lambda: int(os.environ.get("NANOCODE_MODEL_TIMEOUT", "60")))
|
|
431
459
|
shell_timeout: int = field(default_factory=lambda: int(os.environ.get("NANOCODE_SHELL_TIMEOUT", "60")))
|
|
432
460
|
compact_at: int = field(default_factory=lambda: int(os.environ.get("NANOCODE_COMPACT_AT", "100")))
|
|
@@ -546,6 +574,7 @@ ConfirmationResult: TypeAlias = bool | str
|
|
|
546
574
|
ConfirmCallback: TypeAlias = Callable[[ParsedToolCall, Tool], ConfirmationResult]
|
|
547
575
|
ToolDisplayCallback: TypeAlias = Callable[[ParsedToolCall, Tool], None]
|
|
548
576
|
MessageCallback: TypeAlias = Callable[[str], None]
|
|
577
|
+
ActionCallback: TypeAlias = Callable[[Json], None]
|
|
549
578
|
StatusAction: TypeAlias = Callable[[], str]
|
|
550
579
|
StatusRunner: TypeAlias = Callable[[StatusAction], str]
|
|
551
580
|
|
|
@@ -607,7 +636,7 @@ class ReadTool(Tool):
|
|
|
607
636
|
"Optional range is 0-based [start,end); end=0 means EOF.",
|
|
608
637
|
"Returns at most 1000 lines; truncated results include total lines and next-step guidance.",
|
|
609
638
|
"Prefer Search before Read for large or unknown files; use bounded reads when exact context is needed.",
|
|
610
|
-
"For ReplaceRange,
|
|
639
|
+
"For ReplaceRange, non-empty subranges may use a wider cached Read fingerprint that covers them; empty insert ranges require an exact empty-range Read.",
|
|
611
640
|
]
|
|
612
641
|
|
|
613
642
|
@classmethod
|
|
@@ -850,11 +879,12 @@ class SearchTool(Tool):
|
|
|
850
879
|
|
|
851
880
|
@classmethod
|
|
852
881
|
def signature(cls) -> str:
|
|
853
|
-
return "Search(pattern, path[, option...]) -> SearchToolResult<matches>; option is context=N|N (0..30) or glob_pattern"
|
|
882
|
+
return "Search(pattern[, path][, option...]) -> SearchToolResult<matches>; option is context=N|N (0..30) or glob_pattern"
|
|
854
883
|
|
|
855
884
|
@classmethod
|
|
856
885
|
def example(cls) -> list[str]:
|
|
857
886
|
return [
|
|
887
|
+
'Example args: ["TODO"]',
|
|
858
888
|
'Example args: ["class Foo", "code.py"]',
|
|
859
889
|
'Example args: ["TODO", ".", "*.py"]',
|
|
860
890
|
'Example args: ["class Bar|def main", "nanocode.py", "context=6"]',
|
|
@@ -864,8 +894,8 @@ class SearchTool(Tool):
|
|
|
864
894
|
|
|
865
895
|
@classmethod
|
|
866
896
|
def make(cls, session: Session, args: list[str]) -> Self:
|
|
867
|
-
if len(args) not in (2, 3, 4):
|
|
868
|
-
raise ToolCallError("requires
|
|
897
|
+
if len(args) not in (1, 2, 3, 4):
|
|
898
|
+
raise ToolCallError("requires 1 to 4 args: pattern[, path][, glob_pattern][, context=N]")
|
|
869
899
|
raw_pattern = str(args[0])
|
|
870
900
|
if not raw_pattern:
|
|
871
901
|
raise ToolCallError("pattern cannot be empty")
|
|
@@ -875,6 +905,9 @@ class SearchTool(Tool):
|
|
|
875
905
|
raise ToolCallError("pattern cannot be empty")
|
|
876
906
|
if regex and "\n" in pattern:
|
|
877
907
|
raise ToolCallError("multiline regex is not supported; Search is line-oriented. Search each line separately or Read a nearby range.")
|
|
908
|
+
target_path_arg = str(args[1]) if len(args) >= 2 else "."
|
|
909
|
+
if not target_path_arg:
|
|
910
|
+
target_path_arg = "."
|
|
878
911
|
glob_pattern = ""
|
|
879
912
|
context_lines = cls.CONTEXT_LINES
|
|
880
913
|
for raw_option in args[2:]:
|
|
@@ -904,7 +937,7 @@ class SearchTool(Tool):
|
|
|
904
937
|
pattern=raw_pattern,
|
|
905
938
|
patterns=patterns,
|
|
906
939
|
regex=regex,
|
|
907
|
-
target_path=session.resolve_path(
|
|
940
|
+
target_path=session.resolve_path(target_path_arg),
|
|
908
941
|
glob_pattern=glob_pattern,
|
|
909
942
|
context_lines=context_lines,
|
|
910
943
|
cwd=session.cwd,
|
|
@@ -1205,8 +1238,8 @@ class ReplaceRangeTool(Tool):
|
|
|
1205
1238
|
@classmethod
|
|
1206
1239
|
def description(cls) -> list[str]:
|
|
1207
1240
|
return [
|
|
1208
|
-
"Replace one 0-based line range when its fingerprint comes from Read(filepath,
|
|
1209
|
-
"
|
|
1241
|
+
"Replace one 0-based line range when its fingerprint comes from Read(filepath, ...).",
|
|
1242
|
+
"If a wider cached Read range covers this target range, the tool may slice and validate the narrower range automatically; otherwise Read the exact target range and retry once.",
|
|
1210
1243
|
"If earlier edits shifted lines, a cached Read fingerprint for the same original range can relocate only when old content still matches exactly once.",
|
|
1211
1244
|
]
|
|
1212
1245
|
|
|
@@ -1314,7 +1347,7 @@ class BatchReplaceRangesTool(Tool):
|
|
|
1314
1347
|
return [
|
|
1315
1348
|
"Replace multiple 0-based line ranges in one file against one snapshot.",
|
|
1316
1349
|
"Use this for multiple edits in the same file; earlier edits in this call do not shift later ranges.",
|
|
1317
|
-
"Each edit fingerprint must come from Read(filepath,
|
|
1350
|
+
"Each edit fingerprint must come from Read(filepath, ...); if a wider cached range covers the target range, it may be sliced and validated automatically.",
|
|
1318
1351
|
"Same-range cached fingerprints can relocate shifted old content.",
|
|
1319
1352
|
]
|
|
1320
1353
|
|
|
@@ -1465,7 +1498,7 @@ class ApplyPatchTool(Tool):
|
|
|
1465
1498
|
try:
|
|
1466
1499
|
with open(self.filepath, "r", encoding="utf-8") as f:
|
|
1467
1500
|
original = f.read()
|
|
1468
|
-
new_content, _ = self._apply_unified_diff(original, self.
|
|
1501
|
+
new_content, _ = self._apply_unified_diff(original, self._normalized_unified_diff())
|
|
1469
1502
|
except (OSError, ToolCallError) as error:
|
|
1470
1503
|
return label + "\n# preview unavailable: " + str(error)
|
|
1471
1504
|
return _make_unified_diff(original, new_content, self.filepath) or label
|
|
@@ -1473,7 +1506,7 @@ class ApplyPatchTool(Tool):
|
|
|
1473
1506
|
def call(self) -> str:
|
|
1474
1507
|
with open(self.filepath, "r", encoding="utf-8") as f:
|
|
1475
1508
|
original = f.read()
|
|
1476
|
-
new_content, hunks = self._apply_unified_diff(original, self.
|
|
1509
|
+
new_content, hunks = self._apply_unified_diff(original, self._normalized_unified_diff())
|
|
1477
1510
|
if new_content == original:
|
|
1478
1511
|
raise ToolCallError("patch produced no changes")
|
|
1479
1512
|
with open(self.filepath, "w", encoding="utf-8") as f:
|
|
@@ -1488,6 +1521,58 @@ class ApplyPatchTool(Tool):
|
|
|
1488
1521
|
]
|
|
1489
1522
|
)
|
|
1490
1523
|
|
|
1524
|
+
def _normalized_unified_diff(self) -> str:
|
|
1525
|
+
lines = self.unified_diff.splitlines(keepends=True)
|
|
1526
|
+
begin_index = next((index for index, line in enumerate(lines) if line.strip()), -1)
|
|
1527
|
+
if begin_index < 0 or lines[begin_index].strip() != "*** Begin Patch":
|
|
1528
|
+
return self.unified_diff
|
|
1529
|
+
return self._codex_update_patch_to_unified_diff(lines, begin_index)
|
|
1530
|
+
|
|
1531
|
+
def _codex_update_patch_to_unified_diff(self, lines: list[str], begin_index: int) -> str:
|
|
1532
|
+
update_seen = False
|
|
1533
|
+
end_seen = False
|
|
1534
|
+
hunk_lines: list[str] = []
|
|
1535
|
+
for line in lines[begin_index + 1 :]:
|
|
1536
|
+
stripped = line.strip()
|
|
1537
|
+
if stripped == "*** End Patch":
|
|
1538
|
+
end_seen = True
|
|
1539
|
+
break
|
|
1540
|
+
if stripped.startswith("*** Update File: "):
|
|
1541
|
+
if update_seen:
|
|
1542
|
+
raise ToolCallError("ApplyPatch supports one Update File per call")
|
|
1543
|
+
self._validate_codex_patch_path(stripped[len("*** Update File: ") :].strip())
|
|
1544
|
+
update_seen = True
|
|
1545
|
+
continue
|
|
1546
|
+
if stripped.startswith(("*** Add File:", "*** Delete File:", "*** Move to:")):
|
|
1547
|
+
raise ToolCallError("ApplyPatch supports only Update File patches")
|
|
1548
|
+
if stripped == "*** End of File":
|
|
1549
|
+
continue
|
|
1550
|
+
if not update_seen:
|
|
1551
|
+
if stripped:
|
|
1552
|
+
raise ToolCallError("invalid ApplyPatch wrapper")
|
|
1553
|
+
continue
|
|
1554
|
+
hunk_lines.append(self._normalize_codex_hunk_header(line))
|
|
1555
|
+
if not update_seen:
|
|
1556
|
+
raise ToolCallError("ApplyPatch wrapper missing Update File")
|
|
1557
|
+
if not end_seen:
|
|
1558
|
+
raise ToolCallError("ApplyPatch wrapper missing End Patch")
|
|
1559
|
+
return "".join(hunk_lines)
|
|
1560
|
+
|
|
1561
|
+
def _validate_codex_patch_path(self, patch_path: str) -> None:
|
|
1562
|
+
if not patch_path:
|
|
1563
|
+
raise ToolCallError("ApplyPatch wrapper missing Update File path")
|
|
1564
|
+
candidate = patch_path if os.path.isabs(patch_path) else os.path.join(self.cwd, patch_path)
|
|
1565
|
+
if os.path.realpath(candidate) != os.path.realpath(self.filepath):
|
|
1566
|
+
raise ToolCallError("patch target does not match filepath: " + patch_path)
|
|
1567
|
+
|
|
1568
|
+
@staticmethod
|
|
1569
|
+
def _normalize_codex_hunk_header(line: str) -> str:
|
|
1570
|
+
body = line.rstrip("\r\n")
|
|
1571
|
+
newline = line[len(body) :]
|
|
1572
|
+
if body.startswith("@@ ") and not body.startswith("@@ -"):
|
|
1573
|
+
return "@@" + newline
|
|
1574
|
+
return line
|
|
1575
|
+
|
|
1491
1576
|
@staticmethod
|
|
1492
1577
|
def _apply_unified_diff(content: str, unified_diff: str) -> tuple[str, int]:
|
|
1493
1578
|
lines = content.splitlines(keepends=True)
|
|
@@ -1813,17 +1898,20 @@ Core:
|
|
|
1813
1898
|
- Follow the plan, but revise it when facts require it.
|
|
1814
1899
|
|
|
1815
1900
|
Tools:
|
|
1816
|
-
- MUST use
|
|
1817
|
-
-
|
|
1901
|
+
- MUST use tool actions. Do not use native <tool_call>Tool(args...) syntax.
|
|
1902
|
+
- Keep each tool batch small: never more than 5 tool actions unless the user explicitly asked for broad parallel work.
|
|
1903
|
+
- Use multiple tool calls in one turn only when they are independent and do not require seeing another tool's result first.
|
|
1904
|
+
- If you need discovery (ListDir/Search/Read/LineCount), do only the necessary discovery batch, then stop and wait for results before editing, patching, testing, or cleanup.
|
|
1905
|
+
- Do not queue speculative follow-up tools whose arguments depend on unread files, unknown search results, or unverified command output.
|
|
1818
1906
|
- Prefer specific tools first; use Bash only when no provided tool fits.
|
|
1819
1907
|
- Prefer Search before Read when locating code or facts; Read only known small ranges or exact files needed for editing.
|
|
1820
1908
|
- Read returns at most 1000 lines; if truncated, use Search or smaller Read ranges in batches.
|
|
1821
|
-
- ReplaceRange/BatchReplaceRanges
|
|
1822
|
-
- Summarize every latest tool result
|
|
1909
|
+
- ReplaceRange/BatchReplaceRanges may use a wider cached Read fingerprint for a non-empty subrange that it covers. Empty insert ranges require an exact empty-range Read. If fingerprint mismatch happens, immediately Read the exact target range and retry once.
|
|
1910
|
+
- Summarize every latest tool result with tool_summary actions; raw results are shown once only, so include key_evidence when paths, lines, errors, or decisions matter later.
|
|
1823
1911
|
- Latest tool results are already shown in Latest_Tool_Call_Results; use result_file logs only as a fallback when needed.
|
|
1824
1912
|
- If an older tool result lacks detail that is needed for the task, prefer re-running a targeted source tool; Read result_file logs only when that is the cheapest accurate source.
|
|
1825
|
-
-
|
|
1826
|
-
-
|
|
1913
|
+
- known actions are stable memory; context actions are task-local memory.
|
|
1914
|
+
- tool action intention must state the question to answer, not just the action.
|
|
1827
1915
|
|
|
1828
1916
|
Verification:
|
|
1829
1917
|
- Verification_State belongs only to its <goal>.
|
|
@@ -1844,77 +1932,40 @@ Input:
|
|
|
1844
1932
|
- Latest_User_Input: latest user message
|
|
1845
1933
|
- Tools: available tool specs
|
|
1846
1934
|
|
|
1847
|
-
Output
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1935
|
+
Output format is mandatory:
|
|
1936
|
+
- Output action frames only.
|
|
1937
|
+
- Each action frame MUST contain exactly one JSON object action.
|
|
1938
|
+
- Each action frame MUST end with a separator line containing only __END_ACTION__.
|
|
1939
|
+
- The separator is required after every action, including the final action.
|
|
1940
|
+
- Pretty-printed multi-line JSON is allowed inside a frame.
|
|
1941
|
+
- Do not wrap actions in {"actions":[...]}.
|
|
1942
|
+
- Do not output a JSON array.
|
|
1943
|
+
- Do not output markdown, prose, code fences, XML tags, native tool calls, or text outside action frames.
|
|
1944
|
+
- Include only actions that are needed; do not emit null/no-op fields.
|
|
1945
|
+
|
|
1946
|
+
Action schemas:
|
|
1947
|
+
{"type": "message", "text": "string"}
|
|
1948
|
+
{"type": "tool", "name": "string", "intention": "string", "args": ["string"]}
|
|
1949
|
+
{"type": "tool_summary", "tool": "string", "intention": "string", "outcome": "success|failure|partial", "summary": "string", "key_evidence": null | ["string"], "result_file": null | "string", "needs_raw_read": true | false}
|
|
1950
|
+
{"type": "goal", "text": "string"}
|
|
1951
|
+
{"type": "plan", "mode": "replace|patch", "items": [{"op": "add|update|remove", "id": "string", "after": null | "string", "text": null | "string", "status": null | "todo|doing|done|blocked", "evidence": null | "string"}]}
|
|
1952
|
+
{"type": "known", "items": [{"fact": "string", "details": null | ["string"]}]}
|
|
1953
|
+
{"type": "context", "mode": "replace|append", "items": [{"note": "string", "details": null | ["string"]}]}
|
|
1954
|
+
{"type": "verify", "method": null | "string", "status": "pending|passed|blocked", "evidence": null | "string"}
|
|
1955
|
+
|
|
1956
|
+
Example:
|
|
1957
|
+
{
|
|
1958
|
+
"type": "tool",
|
|
1959
|
+
"name": "Read",
|
|
1960
|
+
"intention": "Inspect SearchTool.make.",
|
|
1961
|
+
"args": ["nanocode.py", "880", "930"]
|
|
1962
|
+
}
|
|
1963
|
+
__END_ACTION__
|
|
1853
1964
|
{
|
|
1854
|
-
"
|
|
1855
|
-
|
|
1856
|
-
"goal_update": null | "string",
|
|
1857
|
-
"goal_reached": true | false,
|
|
1858
|
-
|
|
1859
|
-
"plan_update": null | {
|
|
1860
|
-
"mode": "replace" | "patch",
|
|
1861
|
-
"items": [
|
|
1862
|
-
{
|
|
1863
|
-
"op": "add|update|remove",
|
|
1864
|
-
"id": "string",
|
|
1865
|
-
"after": null | "string",
|
|
1866
|
-
"text": null | "string",
|
|
1867
|
-
"status": null | "todo|doing|done|blocked",
|
|
1868
|
-
"evidence": null | "string"
|
|
1869
|
-
}
|
|
1870
|
-
]
|
|
1871
|
-
},
|
|
1872
|
-
|
|
1873
|
-
"known_append": null | [
|
|
1874
|
-
{
|
|
1875
|
-
"fact": "string",
|
|
1876
|
-
"details": null | ["string"]
|
|
1877
|
-
}
|
|
1878
|
-
],
|
|
1879
|
-
|
|
1880
|
-
"current_context_update": null | {
|
|
1881
|
-
"mode": "replace" | "append",
|
|
1882
|
-
"items": [
|
|
1883
|
-
{
|
|
1884
|
-
"note": "string",
|
|
1885
|
-
"details": null | ["string"]
|
|
1886
|
-
}
|
|
1887
|
-
]
|
|
1888
|
-
},
|
|
1889
|
-
|
|
1890
|
-
"verification": {
|
|
1891
|
-
"method": null | "string",
|
|
1892
|
-
"status": "pending" | "passed" | "blocked",
|
|
1893
|
-
"evidence": null | "string"
|
|
1894
|
-
},
|
|
1895
|
-
|
|
1896
|
-
"tool_calls": null | [
|
|
1897
|
-
{
|
|
1898
|
-
"name": "string",
|
|
1899
|
-
"intention": "string",
|
|
1900
|
-
"args": ["string"]
|
|
1901
|
-
}
|
|
1902
|
-
],
|
|
1903
|
-
|
|
1904
|
-
"last_tool_calls_summaries": [
|
|
1905
|
-
{
|
|
1906
|
-
"tool": "string",
|
|
1907
|
-
"intention": "string",
|
|
1908
|
-
"outcome": "success" | "failure" | "partial",
|
|
1909
|
-
"summary": "string",
|
|
1910
|
-
"key_evidence": null | ["string"],
|
|
1911
|
-
"result_file": null | "string",
|
|
1912
|
-
"needs_raw_read": true | false
|
|
1913
|
-
}
|
|
1914
|
-
],
|
|
1915
|
-
|
|
1916
|
-
"message_to_user": null | "string"
|
|
1965
|
+
"type": "message",
|
|
1966
|
+
"text": "Done."
|
|
1917
1967
|
}
|
|
1968
|
+
__END_ACTION__
|
|
1918
1969
|
"""
|
|
1919
1970
|
|
|
1920
1971
|
MAIN_AGENT_USER_PROMPT_TEMPLATE = """
|
|
@@ -2059,10 +2110,14 @@ class PromptBuilder:
|
|
|
2059
2110
|
|
|
2060
2111
|
@final
|
|
2061
2112
|
class ModelClient:
|
|
2113
|
+
ACTION_FRAME_END: ClassVar[str] = "__END_ACTION__"
|
|
2114
|
+
ACTION_FRAME_END_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^\s*\**_*\s*END[\s_-]*ACTION\s*_*\**\s*$", re.IGNORECASE)
|
|
2115
|
+
ACTION_FRAME_END_SPLIT_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"\**_*\s*END[\s_-]*ACTION\s*_*\**", re.IGNORECASE)
|
|
2116
|
+
|
|
2062
2117
|
def __init__(self, session: Session):
|
|
2063
2118
|
self.session = session
|
|
2064
2119
|
|
|
2065
|
-
def request(self, system_prompt: str, user_prompt: str, *, activity: str = "main") -> Json:
|
|
2120
|
+
def request(self, system_prompt: str, user_prompt: str, *, activity: str = "main", on_action: ActionCallback | None = None) -> Json:
|
|
2066
2121
|
if not self.session.api_url:
|
|
2067
2122
|
raise LLMError("NANOCODE_API_URL is required")
|
|
2068
2123
|
if not self.session.api_key:
|
|
@@ -2078,8 +2133,10 @@ class ModelClient:
|
|
|
2078
2133
|
"model": self.session.model,
|
|
2079
2134
|
"messages": messages,
|
|
2080
2135
|
"temperature": self.session.temperature,
|
|
2081
|
-
"response_format": {"type": "json_object"},
|
|
2082
2136
|
}
|
|
2137
|
+
if self.session.stream:
|
|
2138
|
+
payload["stream"] = True
|
|
2139
|
+
payload["stream_options"] = {"include_usage": True}
|
|
2083
2140
|
extra_params = self._reasoning_params()
|
|
2084
2141
|
payload.update(extra_params)
|
|
2085
2142
|
self._write_debug_prompt(activity=activity, messages=messages)
|
|
@@ -2096,7 +2153,11 @@ class ModelClient:
|
|
|
2096
2153
|
self.session.current_model_call_started_at = time.monotonic()
|
|
2097
2154
|
try:
|
|
2098
2155
|
with urllib.request.urlopen(request, timeout=self.session.model_timeout) as response:
|
|
2099
|
-
|
|
2156
|
+
if self.session.stream:
|
|
2157
|
+
content, usage = self._read_streaming_content(response, on_action=on_action)
|
|
2158
|
+
result: Json = {"usage": usage}
|
|
2159
|
+
else:
|
|
2160
|
+
body = response.read().decode("utf-8")
|
|
2100
2161
|
finally:
|
|
2101
2162
|
self.session.current_model_call_started_at = 0.0
|
|
2102
2163
|
except socket.timeout:
|
|
@@ -2107,17 +2168,57 @@ class ModelClient:
|
|
|
2107
2168
|
except Exception as error:
|
|
2108
2169
|
raise LLMError(str(error))
|
|
2109
2170
|
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2171
|
+
if not self.session.stream:
|
|
2172
|
+
try:
|
|
2173
|
+
result = json.loads(body)
|
|
2174
|
+
except json.JSONDecodeError:
|
|
2175
|
+
raise LLMError("API response is not JSON: " + _shorten(body))
|
|
2114
2176
|
|
|
2115
2177
|
self._record_usage(_json_dict(result.get("usage") if isinstance(result, dict) else None))
|
|
2116
|
-
|
|
2178
|
+
if not self.session.stream:
|
|
2179
|
+
content = self._message_content(result)
|
|
2117
2180
|
if content is None:
|
|
2118
2181
|
return self._invalid_model_response(self._format_missing_message_content(result))
|
|
2119
2182
|
return self._parse_model_content(content)
|
|
2120
2183
|
|
|
2184
|
+
def _read_streaming_content(self, response: Any, *, on_action: ActionCallback | None = None) -> tuple[str, Json]:
|
|
2185
|
+
parts: list[str] = []
|
|
2186
|
+
usage: Json = {}
|
|
2187
|
+
buffer = ""
|
|
2188
|
+
frame_number = 0
|
|
2189
|
+
for raw_line in response:
|
|
2190
|
+
line = raw_line.decode("utf-8", errors="replace").strip()
|
|
2191
|
+
if not line or line.startswith(":") or not line.startswith("data:"):
|
|
2192
|
+
continue
|
|
2193
|
+
data = line[len("data:") :].strip()
|
|
2194
|
+
if data == "[DONE]":
|
|
2195
|
+
break
|
|
2196
|
+
try:
|
|
2197
|
+
event = json.loads(data)
|
|
2198
|
+
except json.JSONDecodeError:
|
|
2199
|
+
continue
|
|
2200
|
+
event_data = _json_dict(event)
|
|
2201
|
+
event_usage = _json_dict(event_data.get("usage"))
|
|
2202
|
+
if event_usage:
|
|
2203
|
+
usage = event_usage
|
|
2204
|
+
choices = _json_list(event_data.get("choices"))
|
|
2205
|
+
if not choices:
|
|
2206
|
+
continue
|
|
2207
|
+
delta = _json_dict(_json_dict(choices[0]).get("delta"))
|
|
2208
|
+
content = delta.get("content")
|
|
2209
|
+
if not isinstance(content, str):
|
|
2210
|
+
continue
|
|
2211
|
+
parts.append(content)
|
|
2212
|
+
if on_action is not None:
|
|
2213
|
+
buffer += content
|
|
2214
|
+
frames, buffer = self._completed_action_frames(buffer)
|
|
2215
|
+
for frame in frames:
|
|
2216
|
+
frame_number += 1
|
|
2217
|
+
action, _error = self._parse_action_frame(frame, frame_number)
|
|
2218
|
+
if action is not None:
|
|
2219
|
+
on_action(action)
|
|
2220
|
+
return "".join(parts), usage
|
|
2221
|
+
|
|
2121
2222
|
def _write_debug_prompt(self, *, activity: str, messages: list[Json]) -> str:
|
|
2122
2223
|
if not self.session.debug:
|
|
2123
2224
|
return ""
|
|
@@ -2148,13 +2249,79 @@ class ModelClient:
|
|
|
2148
2249
|
text = self._strip_leaked_think_tags(text)
|
|
2149
2250
|
text = self._strip_json_fence(text)
|
|
2150
2251
|
text = self._strip_leaked_think_tags(text)
|
|
2252
|
+
actions: list[Json] = []
|
|
2253
|
+
frame_errors: list[str] = []
|
|
2254
|
+
for frame_number, frame in enumerate(self._action_frames(text), start=1):
|
|
2255
|
+
action, error = self._parse_action_frame(frame, frame_number)
|
|
2256
|
+
if action is not None:
|
|
2257
|
+
actions.append(action)
|
|
2258
|
+
continue
|
|
2259
|
+
if error:
|
|
2260
|
+
frame_errors.append(error)
|
|
2261
|
+
if not actions:
|
|
2262
|
+
reason = "expected at least one valid action frame ending with " + self.ACTION_FRAME_END
|
|
2263
|
+
if frame_errors:
|
|
2264
|
+
reason += "; " + "; ".join(frame_errors[:3])
|
|
2265
|
+
return self._invalid_model_response(content, reason)
|
|
2266
|
+
response: Json = {"actions": actions}
|
|
2267
|
+
if frame_errors:
|
|
2268
|
+
response["_format_frame_errors"] = frame_errors
|
|
2269
|
+
return response
|
|
2270
|
+
|
|
2271
|
+
def _action_frames(self, text: str) -> list[str]:
|
|
2272
|
+
frames: list[str] = []
|
|
2273
|
+
current: list[str] = []
|
|
2274
|
+
for line in text.splitlines():
|
|
2275
|
+
if not self._has_action_frame_end(line):
|
|
2276
|
+
current.append(line)
|
|
2277
|
+
continue
|
|
2278
|
+
parts = self.ACTION_FRAME_END_SPLIT_PATTERN.split(line)
|
|
2279
|
+
for index, part in enumerate(parts):
|
|
2280
|
+
if part:
|
|
2281
|
+
current.append(part)
|
|
2282
|
+
if index < len(parts) - 1:
|
|
2283
|
+
frames.append("\n".join(current).strip())
|
|
2284
|
+
current = []
|
|
2285
|
+
trailing = "\n".join(current).strip()
|
|
2286
|
+
if trailing:
|
|
2287
|
+
frames.append(trailing)
|
|
2288
|
+
return frames
|
|
2289
|
+
|
|
2290
|
+
def _completed_action_frames(self, text: str) -> tuple[list[str], str]:
|
|
2291
|
+
frames: list[str] = []
|
|
2292
|
+
current: list[str] = []
|
|
2293
|
+
for line in text.splitlines(keepends=True):
|
|
2294
|
+
if not self._has_action_frame_end(line):
|
|
2295
|
+
current.append(line)
|
|
2296
|
+
continue
|
|
2297
|
+
parts = self.ACTION_FRAME_END_SPLIT_PATTERN.split(line)
|
|
2298
|
+
for index, part in enumerate(parts):
|
|
2299
|
+
if part:
|
|
2300
|
+
current.append(part)
|
|
2301
|
+
if index < len(parts) - 1:
|
|
2302
|
+
frames.append("".join(current).strip())
|
|
2303
|
+
current = []
|
|
2304
|
+
return frames, "".join(current)
|
|
2305
|
+
|
|
2306
|
+
def _parse_action_frame(self, frame: str, frame_number: int) -> tuple[Json | None, str]:
|
|
2307
|
+
frame = frame.strip()
|
|
2308
|
+
if not frame:
|
|
2309
|
+
return None, ""
|
|
2151
2310
|
try:
|
|
2152
|
-
value =
|
|
2153
|
-
except
|
|
2154
|
-
return
|
|
2311
|
+
value = json_repair.loads(frame)
|
|
2312
|
+
except Exception as error:
|
|
2313
|
+
return None, "frame " + str(frame_number) + ": " + str(error)
|
|
2155
2314
|
if not isinstance(value, dict):
|
|
2156
|
-
return
|
|
2157
|
-
|
|
2315
|
+
return None, "frame " + str(frame_number) + ": expected JSON object action"
|
|
2316
|
+
if not _json_str(value.get("type")):
|
|
2317
|
+
return None, "frame " + str(frame_number) + ": action missing type"
|
|
2318
|
+
return value, ""
|
|
2319
|
+
|
|
2320
|
+
def _has_action_frame_end(self, line: str) -> bool:
|
|
2321
|
+
return self.ACTION_FRAME_END_SPLIT_PATTERN.search(line) is not None
|
|
2322
|
+
|
|
2323
|
+
def _is_action_frame_end(self, line: str) -> bool:
|
|
2324
|
+
return self.ACTION_FRAME_END_PATTERN.match(line) is not None
|
|
2158
2325
|
|
|
2159
2326
|
def _strip_json_fence(self, text: str) -> str:
|
|
2160
2327
|
if not text.startswith("```"):
|
|
@@ -2179,18 +2346,19 @@ class ModelClient:
|
|
|
2179
2346
|
text = text[len("</think>") :].lstrip()
|
|
2180
2347
|
return text
|
|
2181
2348
|
|
|
2182
|
-
def _invalid_model_response(self, content: str) -> Json:
|
|
2349
|
+
def _invalid_model_response(self, content: str, reason: str = "expected one JSON object matching the Output JSON schema") -> Json:
|
|
2183
2350
|
guidance = ""
|
|
2184
2351
|
if self._looks_like_native_tool_call(content):
|
|
2185
2352
|
guidance = (
|
|
2186
|
-
" Native tool_call syntax is not supported; return
|
|
2187
|
-
'{"name":"Read","intention":"...","args":["nanocode.py","0","100"]}.'
|
|
2353
|
+
" Native tool_call syntax is not supported; return an action frame like "
|
|
2354
|
+
'{"type":"tool","name":"Read","intention":"...","args":["nanocode.py","0","100"]}\n__END_ACTION__.'
|
|
2188
2355
|
)
|
|
2189
2356
|
return {
|
|
2190
|
-
"
|
|
2191
|
-
"
|
|
2192
|
-
"
|
|
2193
|
-
|
|
2357
|
+
"actions": [],
|
|
2358
|
+
"_format_bad_output": content,
|
|
2359
|
+
"_format_error": "Invalid model output: "
|
|
2360
|
+
+ reason
|
|
2361
|
+
+ ". Return action frames only. Bad output: "
|
|
2194
2362
|
+ _shorten(content)
|
|
2195
2363
|
+ guidance,
|
|
2196
2364
|
}
|
|
@@ -2471,6 +2639,9 @@ class AgentStateUpdater:
|
|
|
2471
2639
|
before_verification,
|
|
2472
2640
|
)
|
|
2473
2641
|
|
|
2642
|
+
def _actions(self, response: Json) -> list[Json]:
|
|
2643
|
+
return [action for action in (_json_dict(item) for item in _json_list(response.get("actions"))) if action]
|
|
2644
|
+
|
|
2474
2645
|
def apply_tool_call_summaries(self, response: Json) -> None:
|
|
2475
2646
|
self._apply_tool_call_summaries(response)
|
|
2476
2647
|
|
|
@@ -2567,7 +2738,7 @@ class AgentStateUpdater:
|
|
|
2567
2738
|
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
2568
2739
|
|
|
2569
2740
|
def _apply_tool_call_summaries(self, response: Json) -> None:
|
|
2570
|
-
summaries =
|
|
2741
|
+
summaries = [action for action in self._actions(response) if _json_str(action.get("type")) == "tool_summary"]
|
|
2571
2742
|
if not summaries:
|
|
2572
2743
|
return
|
|
2573
2744
|
pending = [event for event in self.tool_runner.latest_events if not event.summary]
|
|
@@ -2615,42 +2786,42 @@ class AgentStateUpdater:
|
|
|
2615
2786
|
return [detail for detail in details if detail]
|
|
2616
2787
|
|
|
2617
2788
|
def _apply_goal(self, response: Json) -> bool:
|
|
2618
|
-
update = _json_str(response.get("goal_update"))
|
|
2619
2789
|
changed = False
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2790
|
+
for action in self._actions(response):
|
|
2791
|
+
action_type = _json_str(action.get("type"))
|
|
2792
|
+
if action_type == "goal":
|
|
2793
|
+
update = _json_str(action.get("text"))
|
|
2794
|
+
if update is not None:
|
|
2795
|
+
changed = changed or update != self.session.current.goal
|
|
2796
|
+
self.session.current.goal = update
|
|
2626
2797
|
return changed
|
|
2627
2798
|
|
|
2628
2799
|
def _apply_plan(self, response: Json) -> bool:
|
|
2629
|
-
|
|
2630
|
-
if
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
return True
|
|
2636
|
-
for raw in items:
|
|
2637
|
-
patch = _json_dict(raw)
|
|
2638
|
-
op = _json_str(patch.get("op")) or "add"
|
|
2639
|
-
item_id = _json_str(patch.get("id")) or ""
|
|
2640
|
-
if op == "remove":
|
|
2641
|
-
self.session.current.plan = [item for item in self.session.current.plan if item.id != item_id]
|
|
2642
|
-
continue
|
|
2643
|
-
plan_item = self._plan_item_from_json(patch)
|
|
2644
|
-
if plan_item is None:
|
|
2800
|
+
replaced = False
|
|
2801
|
+
for update in [action for action in self._actions(response) if _json_str(action.get("type")) == "plan"]:
|
|
2802
|
+
items = _json_list(update.get("items"))
|
|
2803
|
+
if update.get("mode") == "replace":
|
|
2804
|
+
self.session.current.plan = [item for item in (self._plan_item_from_json(raw) for raw in items) if item]
|
|
2805
|
+
replaced = True
|
|
2645
2806
|
continue
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2807
|
+
for raw in items:
|
|
2808
|
+
patch = _json_dict(raw)
|
|
2809
|
+
op = _json_str(patch.get("op")) or "add"
|
|
2810
|
+
item_id = _json_str(patch.get("id")) or ""
|
|
2811
|
+
if op == "remove":
|
|
2812
|
+
self.session.current.plan = [item for item in self.session.current.plan if item.id != item_id]
|
|
2813
|
+
continue
|
|
2814
|
+
plan_item = self._plan_item_from_json(patch)
|
|
2815
|
+
if plan_item is None:
|
|
2816
|
+
continue
|
|
2817
|
+
existing = next((item for item in self.session.current.plan if item.id == plan_item.id and item.id), None)
|
|
2818
|
+
if existing:
|
|
2819
|
+
existing.text = plan_item.text
|
|
2820
|
+
existing.status = plan_item.status
|
|
2821
|
+
existing.evidence = plan_item.evidence
|
|
2822
|
+
else:
|
|
2823
|
+
self.session.current.plan.append(plan_item)
|
|
2824
|
+
return replaced
|
|
2654
2825
|
|
|
2655
2826
|
def _plan_item_from_json(self, value: JsonValue) -> PlanItem | None:
|
|
2656
2827
|
item = _json_dict(value)
|
|
@@ -2668,58 +2839,55 @@ class AgentStateUpdater:
|
|
|
2668
2839
|
)
|
|
2669
2840
|
|
|
2670
2841
|
def _apply_known(self, response: Json) -> None:
|
|
2671
|
-
for
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
self.session.current.known
|
|
2842
|
+
for action in [action for action in self._actions(response) if _json_str(action.get("type")) == "known"]:
|
|
2843
|
+
for raw in _json_list(action.get("items")):
|
|
2844
|
+
item = _json_dict(raw)
|
|
2845
|
+
fact = _json_str(item.get("fact")) if item else _json_str(raw)
|
|
2846
|
+
if not fact:
|
|
2847
|
+
continue
|
|
2848
|
+
details = [_json_str(detail) or "" for detail in _json_list(item.get("details") if item else None)]
|
|
2849
|
+
details = [detail for detail in details if detail]
|
|
2850
|
+
if not any(known.fact == fact for known in self.session.current.known):
|
|
2851
|
+
self.session.current.known.append(KnownItem(fact=fact, details=details))
|
|
2680
2852
|
|
|
2681
2853
|
def _apply_current_context(self, response: Json) -> None:
|
|
2682
|
-
update
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
else:
|
|
2698
|
-
self.session.current.current_context.append(CurrentContextItem(note=note, details=details))
|
|
2854
|
+
for update in [action for action in self._actions(response) if _json_str(action.get("type")) == "context"]:
|
|
2855
|
+
if update.get("mode") == "replace":
|
|
2856
|
+
self.session.current.current_context = []
|
|
2857
|
+
for raw in _json_list(update.get("items")):
|
|
2858
|
+
item = _json_dict(raw)
|
|
2859
|
+
note = _json_str(item.get("note")) if item else _json_str(raw)
|
|
2860
|
+
if not note:
|
|
2861
|
+
continue
|
|
2862
|
+
details = [_json_str(detail) or "" for detail in _json_list(item.get("details") if item else None)]
|
|
2863
|
+
details = [detail for detail in details if detail]
|
|
2864
|
+
existing = next((context for context in self.session.current.current_context if context.note == note), None)
|
|
2865
|
+
if existing:
|
|
2866
|
+
existing.details = details
|
|
2867
|
+
else:
|
|
2868
|
+
self.session.current.current_context.append(CurrentContextItem(note=note, details=details))
|
|
2699
2869
|
if len(self.session.current.current_context) > self.CURRENT_CONTEXT_LIMIT:
|
|
2700
2870
|
self.session.current.current_context = self.session.current.current_context[-self.CURRENT_CONTEXT_LIMIT :]
|
|
2701
2871
|
|
|
2702
2872
|
def _apply_verification(self, response: Json) -> None:
|
|
2703
|
-
data
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
self.session.current.verification.
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
if evidence is not None:
|
|
2722
|
-
self.session.current.verification.evidence = evidence
|
|
2873
|
+
for data in [action for action in self._actions(response) if _json_str(action.get("type")) == "verify"]:
|
|
2874
|
+
method = _json_str(data.get("method"))
|
|
2875
|
+
if method is not None:
|
|
2876
|
+
if method != self.session.current.verification.method:
|
|
2877
|
+
self.session.current.verification.evidence = ""
|
|
2878
|
+
self.session.current.verification.method = method
|
|
2879
|
+
status = _json_str(data.get("status"))
|
|
2880
|
+
if status == "pending":
|
|
2881
|
+
self.session.current.verification.status = VerificationStatus.REQUIRED
|
|
2882
|
+
if "evidence" not in data:
|
|
2883
|
+
self.session.current.verification.evidence = ""
|
|
2884
|
+
elif status == "passed":
|
|
2885
|
+
self.session.current.verification.status = VerificationStatus.DONE
|
|
2886
|
+
elif status == "blocked":
|
|
2887
|
+
self.session.current.verification.status = VerificationStatus.BLOCKED
|
|
2888
|
+
evidence = _json_str(data.get("evidence"))
|
|
2889
|
+
if evidence is not None:
|
|
2890
|
+
self.session.current.verification.evidence = evidence
|
|
2723
2891
|
|
|
2724
2892
|
def _reset_stale_verification(self, response: Json, *, goal_changed: bool, plan_replaced: bool) -> None:
|
|
2725
2893
|
verification = self.session.current.verification
|
|
@@ -2731,7 +2899,7 @@ class AgentStateUpdater:
|
|
|
2731
2899
|
return
|
|
2732
2900
|
if (
|
|
2733
2901
|
plan_replaced
|
|
2734
|
-
and not
|
|
2902
|
+
and not any(_json_str(action.get("type")) == "verify" for action in self._actions(response))
|
|
2735
2903
|
and verification.status
|
|
2736
2904
|
in {
|
|
2737
2905
|
VerificationStatus.REQUIRED,
|
|
@@ -2822,7 +2990,9 @@ class Agent:
|
|
|
2822
2990
|
self.latest_agent_feedback = ""
|
|
2823
2991
|
return prompt
|
|
2824
2992
|
|
|
2825
|
-
def request(self, system_prompt: str, user_prompt: str, *, activity: str = "main") -> Json:
|
|
2993
|
+
def request(self, system_prompt: str, user_prompt: str, *, activity: str = "main", on_action: ActionCallback | None = None) -> Json:
|
|
2994
|
+
if isinstance(self.model_client, ModelClient):
|
|
2995
|
+
return self.model_client.request(system_prompt, user_prompt, activity=activity, on_action=on_action)
|
|
2826
2996
|
return self.model_client.request(system_prompt, user_prompt, activity=activity)
|
|
2827
2997
|
|
|
2828
2998
|
def compact_history(self) -> int:
|
|
@@ -2848,7 +3018,7 @@ class Agent:
|
|
|
2848
3018
|
consecutive_format_errors = 0
|
|
2849
3019
|
|
|
2850
3020
|
for _ in range(self.session.max_agent_steps):
|
|
2851
|
-
response = self.step()
|
|
3021
|
+
response = self.step(on_action=self._stream_action_preview_callback(on_message) if on_message is not None else None)
|
|
2852
3022
|
format_error = _json_str(response.get("_format_error"))
|
|
2853
3023
|
if format_error:
|
|
2854
3024
|
consecutive_format_errors += 1
|
|
@@ -2860,24 +3030,29 @@ class Agent:
|
|
|
2860
3030
|
"Format_Gate: stopped after "
|
|
2861
3031
|
+ str(self.MAX_CONSECUTIVE_FORMAT_ERRORS)
|
|
2862
3032
|
+ " consecutive invalid model outputs. "
|
|
2863
|
-
+
|
|
3033
|
+
+ self._format_gate_debug_details(response, format_error),
|
|
2864
3034
|
)
|
|
2865
3035
|
raise LLMError(
|
|
2866
3036
|
"model returned invalid output " + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS) + " times in a row: " + _shorten(format_error, 300)
|
|
2867
3037
|
)
|
|
2868
3038
|
self._report_gate(
|
|
2869
3039
|
on_message,
|
|
2870
|
-
"Retrying: model returned invalid output
|
|
2871
|
-
"Format_Gate: retrying model response. " +
|
|
3040
|
+
self._format_gate_user_message("Retrying: model returned invalid output", format_error),
|
|
3041
|
+
"Format_Gate: retrying model response. " + self._format_gate_debug_details(response, format_error),
|
|
2872
3042
|
)
|
|
2873
3043
|
continue
|
|
2874
3044
|
consecutive_format_errors = 0
|
|
2875
|
-
|
|
3045
|
+
actions = self._response_actions(response)
|
|
3046
|
+
tool_calls = self._tool_calls_from_actions(actions)
|
|
3047
|
+
messages = self._messages_from_actions(actions)
|
|
3048
|
+
if self.session.debug and on_message is not None:
|
|
3049
|
+
frame_error_report = self._format_frame_error_report(response)
|
|
3050
|
+
if frame_error_report:
|
|
3051
|
+
on_message(frame_error_report)
|
|
2876
3052
|
self.apply_response(response)
|
|
2877
3053
|
if on_message is not None and self.state_updater.latest_report:
|
|
2878
3054
|
on_message(self.state_updater.latest_report)
|
|
2879
|
-
message
|
|
2880
|
-
if message:
|
|
3055
|
+
for message in messages:
|
|
2881
3056
|
self.session.append_conversation(AssistantMessage(content=message))
|
|
2882
3057
|
if on_message is not None:
|
|
2883
3058
|
on_message(message)
|
|
@@ -2889,8 +3064,6 @@ class Agent:
|
|
|
2889
3064
|
on_message(report)
|
|
2890
3065
|
self.maybe_auto_compact()
|
|
2891
3066
|
continue
|
|
2892
|
-
if self.session.current.goal_reached and self.session.current.verification.status != VerificationStatus.REQUIRED:
|
|
2893
|
-
return response
|
|
2894
3067
|
if self.session.current.verification.status == VerificationStatus.REQUIRED:
|
|
2895
3068
|
self.session.current.goal_reached = False
|
|
2896
3069
|
self.latest_agent_feedback = self._format_verification_gate()
|
|
@@ -2900,7 +3073,10 @@ class Agent:
|
|
|
2900
3073
|
"Verification_Gate: retrying until verification is passed or blocked.",
|
|
2901
3074
|
)
|
|
2902
3075
|
continue
|
|
2903
|
-
if
|
|
3076
|
+
if messages:
|
|
3077
|
+
self.session.current.goal_reached = True
|
|
3078
|
+
return response
|
|
3079
|
+
if not messages:
|
|
2904
3080
|
self.latest_agent_feedback = self._format_continuation_hint()
|
|
2905
3081
|
self._report_gate(
|
|
2906
3082
|
on_message,
|
|
@@ -2915,6 +3091,22 @@ class Agent:
|
|
|
2915
3091
|
if on_message is not None:
|
|
2916
3092
|
on_message(debug_message if self.session.debug else message)
|
|
2917
3093
|
|
|
3094
|
+
def _format_gate_user_message(self, prefix: str, format_error: str) -> str:
|
|
3095
|
+
detail = format_error
|
|
3096
|
+
for marker in (". Bad output:", " Bad output:"):
|
|
3097
|
+
if marker in detail:
|
|
3098
|
+
detail = detail.split(marker, 1)[0]
|
|
3099
|
+
break
|
|
3100
|
+
if detail.startswith("Invalid model output: "):
|
|
3101
|
+
detail = detail[len("Invalid model output: ") :]
|
|
3102
|
+
return prefix + ": " + _shorten(detail, 180)
|
|
3103
|
+
|
|
3104
|
+
def _format_gate_debug_details(self, response: Json, format_error: str) -> str:
|
|
3105
|
+
bad_output = _json_str(response.get("_format_bad_output"))
|
|
3106
|
+
if bad_output is None:
|
|
3107
|
+
return _shorten(format_error, 180)
|
|
3108
|
+
return _shorten(format_error, 180) + "\nFull bad output:\n" + bad_output
|
|
3109
|
+
|
|
2918
3110
|
def _compact_gate_report(self, gate: str) -> str:
|
|
2919
3111
|
lines = gate.splitlines()
|
|
2920
3112
|
headline = lines[0] if lines else "Gate"
|
|
@@ -2923,10 +3115,13 @@ class Agent:
|
|
|
2923
3115
|
return headline + ": " + _shorten("; ".join(details[:3]), 220)
|
|
2924
3116
|
return headline
|
|
2925
3117
|
|
|
2926
|
-
def step(self) -> Json:
|
|
2927
|
-
response = self.request(self.build_system_prompt(), self.build_user_prompt(consume_latest_tool_results=False), activity="main")
|
|
3118
|
+
def step(self, *, on_action: ActionCallback | None = None) -> Json:
|
|
3119
|
+
response = self.request(self.build_system_prompt(), self.build_user_prompt(consume_latest_tool_results=False), activity="main", on_action=on_action)
|
|
2928
3120
|
if _json_str(response.get("_format_error")):
|
|
2929
3121
|
return response
|
|
3122
|
+
invalid_response = self._validate_action_response(response)
|
|
3123
|
+
if invalid_response is not None:
|
|
3124
|
+
return invalid_response
|
|
2930
3125
|
self.latest_tool_call_results = ""
|
|
2931
3126
|
self.latest_agent_feedback = ""
|
|
2932
3127
|
self.state_updater.apply_tool_call_summaries(response)
|
|
@@ -2935,6 +3130,47 @@ class Agent:
|
|
|
2935
3130
|
def apply_response(self, response: Json) -> None:
|
|
2936
3131
|
self.state_updater.apply(response)
|
|
2937
3132
|
|
|
3133
|
+
def _stream_action_preview_callback(self, on_message: MessageCallback | None) -> ActionCallback:
|
|
3134
|
+
def preview(action: Json) -> None:
|
|
3135
|
+
if on_message is None:
|
|
3136
|
+
return
|
|
3137
|
+
report = self._format_stream_action_preview(action)
|
|
3138
|
+
if report:
|
|
3139
|
+
on_message(report)
|
|
3140
|
+
|
|
3141
|
+
return preview
|
|
3142
|
+
|
|
3143
|
+
def _format_stream_action_preview(self, action: Json) -> str:
|
|
3144
|
+
if _json_str(action.get("type")) != "tool":
|
|
3145
|
+
return ""
|
|
3146
|
+
try:
|
|
3147
|
+
call = self.tool_runner.parse_tool_call(action)
|
|
3148
|
+
except ToolCallError:
|
|
3149
|
+
return ""
|
|
3150
|
+
label = "Queued: " + self._format_stream_tool_label(call)
|
|
3151
|
+
if call.intention:
|
|
3152
|
+
label += " - " + _shorten(call.intention, 80)
|
|
3153
|
+
return label
|
|
3154
|
+
|
|
3155
|
+
def _format_stream_tool_label(self, call: ParsedToolCall) -> str:
|
|
3156
|
+
args = call.args
|
|
3157
|
+
if call.name == "Bash":
|
|
3158
|
+
return "Bash"
|
|
3159
|
+
if call.name in {"Read", "ReplaceRange"} and args:
|
|
3160
|
+
return self._format_stream_path_range_label(call.name, args)
|
|
3161
|
+
if call.name == "Search":
|
|
3162
|
+
path = args[1] if len(args) >= 2 and args[1] else ""
|
|
3163
|
+
return "Search" + ((" " + _shorten(path, 48)) if path else "")
|
|
3164
|
+
if args:
|
|
3165
|
+
return call.name + " " + _shorten(args[0], 48)
|
|
3166
|
+
return call.name
|
|
3167
|
+
|
|
3168
|
+
def _format_stream_path_range_label(self, name: str, args: list[str]) -> str:
|
|
3169
|
+
label = name + " " + _shorten(args[0], 48)
|
|
3170
|
+
if len(args) >= 3:
|
|
3171
|
+
label += ":" + args[1] + "-" + args[2]
|
|
3172
|
+
return label
|
|
3173
|
+
|
|
2938
3174
|
def execute_tool_calls(
|
|
2939
3175
|
self,
|
|
2940
3176
|
tool_calls: list[JsonValue],
|
|
@@ -2952,12 +3188,12 @@ class Agent:
|
|
|
2952
3188
|
[
|
|
2953
3189
|
"Verification_Gate: required before completion.",
|
|
2954
3190
|
"Method: " + method,
|
|
2955
|
-
'Next_Action: run a relevant
|
|
3191
|
+
'Next_Action: run a relevant tool action, or return a verify action with status="passed" or "blocked" and evidence if verification is already resolved.',
|
|
2956
3192
|
]
|
|
2957
3193
|
)
|
|
2958
3194
|
|
|
2959
3195
|
def _format_continuation_hint(self) -> str:
|
|
2960
|
-
return "No tool
|
|
3196
|
+
return "No tool actions and no message action. Continue with the next useful action."
|
|
2961
3197
|
|
|
2962
3198
|
def _missing_tool_summaries(self) -> list[ToolCallEvent]:
|
|
2963
3199
|
return [event for event in self.tool_runner.latest_events if not event.summary]
|
|
@@ -2986,7 +3222,7 @@ class Agent:
|
|
|
2986
3222
|
for event in needs_read:
|
|
2987
3223
|
lines.append("- Read(" + event.result_file + ") before continuing")
|
|
2988
3224
|
lines.append(
|
|
2989
|
-
"Next_Action: return
|
|
3225
|
+
"Next_Action: return tool_summary actions for missing results, read result_file with a tool action only when it is the cheapest accurate fallback, or continue with source tools such as Search/ListDir."
|
|
2990
3226
|
)
|
|
2991
3227
|
return "\n".join(lines)
|
|
2992
3228
|
|
|
@@ -3003,6 +3239,39 @@ class Agent:
|
|
|
3003
3239
|
return True
|
|
3004
3240
|
return False
|
|
3005
3241
|
|
|
3242
|
+
def _invalid_action_response(self, response: Json, reason: str) -> Json:
|
|
3243
|
+
return {
|
|
3244
|
+
"actions": [],
|
|
3245
|
+
"_format_error": "Invalid model output: "
|
|
3246
|
+
+ reason
|
|
3247
|
+
+ ". Return action frames only. Bad output: "
|
|
3248
|
+
+ _shorten(json.dumps(response, ensure_ascii=False)),
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
def _validate_action_response(self, response: Json) -> Json | None:
|
|
3252
|
+
if not isinstance(response.get("actions"), list):
|
|
3253
|
+
return self._invalid_action_response(response, "expected actions array")
|
|
3254
|
+
extra_keys = sorted(str(key) for key in response.keys() if key != "actions" and not str(key).startswith("_format_"))
|
|
3255
|
+
if extra_keys:
|
|
3256
|
+
return self._invalid_action_response(response, "unexpected top-level keys: " + ", ".join(extra_keys))
|
|
3257
|
+
return None
|
|
3258
|
+
|
|
3259
|
+
def _format_frame_error_report(self, response: Json) -> str:
|
|
3260
|
+
errors = [_json_str(error) or "" for error in _json_list(response.get("_format_frame_errors"))]
|
|
3261
|
+
errors = [error for error in errors if error]
|
|
3262
|
+
if not errors:
|
|
3263
|
+
return ""
|
|
3264
|
+
return "Format_Warning: ignored invalid action frame(s).\n" + "\n".join("- " + _shorten(error, 220) for error in errors)
|
|
3265
|
+
|
|
3266
|
+
def _response_actions(self, response: Json) -> list[Json]:
|
|
3267
|
+
return [action for action in (_json_dict(item) for item in _json_list(response.get("actions"))) if action]
|
|
3268
|
+
|
|
3269
|
+
def _tool_calls_from_actions(self, actions: list[Json]) -> list[JsonValue]:
|
|
3270
|
+
return [action for action in actions if _json_str(action.get("type")) == "tool"]
|
|
3271
|
+
|
|
3272
|
+
def _messages_from_actions(self, actions: list[Json]) -> list[str]:
|
|
3273
|
+
return [message for message in (_json_str(action.get("text")) for action in actions if _json_str(action.get("type")) == "message") if message]
|
|
3274
|
+
|
|
3006
3275
|
|
|
3007
3276
|
############################
|
|
3008
3277
|
# Commands
|
|
@@ -3042,6 +3311,7 @@ COMMANDS: tuple[CommandSpec, ...] = (
|
|
|
3042
3311
|
CommandSpec("/compact-at", "Show or set auto-compact threshold", "Config", "/compact-at [number]"),
|
|
3043
3312
|
CommandSpec("/reason", "Show or toggle reasoning", "Config", "/reason [on|off|status]"),
|
|
3044
3313
|
CommandSpec("/reason_effort", "Show or set reasoning effort", "Config", "/reason_effort [minimal|low|medium|high|xhigh]"),
|
|
3314
|
+
CommandSpec("/stream", "Show or toggle streaming responses", "Config", "/stream [on|off|status]"),
|
|
3045
3315
|
CommandSpec("/yolo", "Show or toggle confirmation bypass", "Config", "/yolo [on|off|status]"),
|
|
3046
3316
|
CommandSpec("/exit", "Exit nanocode", "Control", "/exit"),
|
|
3047
3317
|
CommandSpec("/quit", "Exit nanocode", "Control", "/quit"),
|
|
@@ -3071,6 +3341,7 @@ class CommandDispatcher:
|
|
|
3071
3341
|
"/compact-at": self._compact_at,
|
|
3072
3342
|
"/reason": self._reason,
|
|
3073
3343
|
"/reason_effort": self._reason_effort,
|
|
3344
|
+
"/stream": self._stream,
|
|
3074
3345
|
"/yolo": self._yolo,
|
|
3075
3346
|
"/blackboard": self._blackboard,
|
|
3076
3347
|
}
|
|
@@ -3129,11 +3400,13 @@ class CommandDispatcher:
|
|
|
3129
3400
|
return "Usage: /status"
|
|
3130
3401
|
session = self.agent.session
|
|
3131
3402
|
reasoning = session.reasoning_effort if session.reasoning else "off"
|
|
3403
|
+
stream = "on" if session.stream else "off"
|
|
3132
3404
|
yolo = "on" if session.yolo else "off"
|
|
3133
3405
|
return "\n".join(
|
|
3134
3406
|
[
|
|
3135
3407
|
"model: " + (session.model or "(empty)"),
|
|
3136
3408
|
"reasoning: " + reasoning,
|
|
3409
|
+
"stream: " + stream,
|
|
3137
3410
|
"yolo: " + yolo,
|
|
3138
3411
|
"conversation: " + str(len(session.conversation)) + "/" + str(session.compact_at),
|
|
3139
3412
|
"tokens: last=" + _format_count(session.last_total_tokens) + " session=" + _format_count(session.session_total_tokens),
|
|
@@ -3198,6 +3471,17 @@ class CommandDispatcher:
|
|
|
3198
3471
|
self.agent.session.reasoning_effort = args
|
|
3199
3472
|
return "Reasoning effort set to: " + args
|
|
3200
3473
|
|
|
3474
|
+
def _stream(self, args: str) -> str:
|
|
3475
|
+
if args == "on":
|
|
3476
|
+
self.agent.session.stream = True
|
|
3477
|
+
return "Streaming enabled"
|
|
3478
|
+
if args == "off":
|
|
3479
|
+
self.agent.session.stream = False
|
|
3480
|
+
return "Streaming disabled"
|
|
3481
|
+
if args in {"", "status"}:
|
|
3482
|
+
return "Streaming is " + ("on" if self.agent.session.stream else "off")
|
|
3483
|
+
return "Usage: /stream [on|off|status]"
|
|
3484
|
+
|
|
3201
3485
|
def _yolo(self, args: str) -> str:
|
|
3202
3486
|
if args == "on":
|
|
3203
3487
|
self.agent.session.yolo = True
|
|
@@ -3558,6 +3842,9 @@ class AgentLoop:
|
|
|
3558
3842
|
if message.startswith("Tool Calls"):
|
|
3559
3843
|
self._emit_segments(self._tool_segments(message), message)
|
|
3560
3844
|
return
|
|
3845
|
+
if message.startswith("Queued:"):
|
|
3846
|
+
self._emit_segments(self._queued_segments(message), message)
|
|
3847
|
+
return
|
|
3561
3848
|
if message.startswith("Error:"):
|
|
3562
3849
|
self._emit_segments([("bold ansired", message + "\n")], message)
|
|
3563
3850
|
return
|
|
@@ -3661,6 +3948,15 @@ class AgentLoop:
|
|
|
3661
3948
|
segments.extend([("ansibrightblack", line + "\n")])
|
|
3662
3949
|
return segments
|
|
3663
3950
|
|
|
3951
|
+
def _queued_segments(self, message: str) -> list[tuple[str, str]]:
|
|
3952
|
+
body = message[len("Queued:") :].strip()
|
|
3953
|
+
target, separator, reason = body.partition(" - ")
|
|
3954
|
+
segments: list[tuple[str, str]] = [("ansibrightblack", "Queued: "), ("ansicyan", target)]
|
|
3955
|
+
if separator:
|
|
3956
|
+
segments.extend([("ansibrightblack", " - "), ("ansimagenta", reason)])
|
|
3957
|
+
segments.append(("", "\n"))
|
|
3958
|
+
return segments
|
|
3959
|
+
|
|
3664
3960
|
def _verify_style(self, badge: str) -> str:
|
|
3665
3961
|
if "required" in badge:
|
|
3666
3962
|
return "bold ansimagenta"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.8
|
|
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
|
|
@@ -21,6 +21,7 @@ Classifier: Topic :: Terminals
|
|
|
21
21
|
Requires-Python: >=3.11
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
|
+
Requires-Dist: json-repair>=0.39
|
|
24
25
|
Requires-Dist: prompt-toolkit>=3.0
|
|
25
26
|
Requires-Dist: typing-extensions>=4.7
|
|
26
27
|
Provides-Extra: dev
|
|
@@ -76,6 +77,7 @@ export NANOCODE_DIR=".nanocode"
|
|
|
76
77
|
export NANOCODE_TEMPERATURE="0.7"
|
|
77
78
|
export NANOCODE_REASONING="on"
|
|
78
79
|
export NANOCODE_REASONING_EFFORT="medium"
|
|
80
|
+
export NANOCODE_STREAM="on"
|
|
79
81
|
export NANOCODE_MODEL_TIMEOUT="60"
|
|
80
82
|
export NANOCODE_SHELL_TIMEOUT="60"
|
|
81
83
|
export NANOCODE_COMPACT_AT="100"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nanocode-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.8"
|
|
8
8
|
description = "A lightweight terminal-based AI coding assistant"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -27,6 +27,7 @@ classifiers = [
|
|
|
27
27
|
"Topic :: Terminals",
|
|
28
28
|
]
|
|
29
29
|
dependencies = [
|
|
30
|
+
"json-repair>=0.39",
|
|
30
31
|
"prompt-toolkit>=3.0",
|
|
31
32
|
"typing-extensions>=4.7",
|
|
32
33
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|