nanocode-cli 0.2.5__tar.gz → 0.2.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.2.5
3
+ Version: 0.2.7
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
@@ -41,7 +41,7 @@ from prompt_toolkit.patch_stdout import patch_stdout
41
41
 
42
42
  JsonValue: TypeAlias = Any
43
43
  Json: TypeAlias = dict[str, JsonValue]
44
- __version__ = "0.2.5"
44
+ __version__ = "0.2.7"
45
45
 
46
46
 
47
47
  class Error(Exception): ...
@@ -355,6 +355,9 @@ class RangeFingerprintStore:
355
355
  f"fingerprint mismatch for range {start}:{end}: expected {fingerprint}, current {current_fingerprint}; "
356
356
  f"call Read(filepath, {start}, {end}) and reuse that range fingerprint"
357
357
  )
358
+ other_ranges = self._ranges_for_fingerprint(filepath=filepath, fingerprint=fingerprint)
359
+ if other_ranges:
360
+ message += "; this fingerprint was cached for exact range(s): " + ", ".join(f"{range_start}:{range_end}" for range_start, range_end in other_ranges)
358
361
  if not matches:
359
362
  raise ToolCallError(message)
360
363
  if len(matches) > 1:
@@ -389,6 +392,17 @@ class RangeFingerprintStore:
389
392
  return matches
390
393
  return matches
391
394
 
395
+ def _ranges_for_fingerprint(self, *, filepath: str, fingerprint: str) -> list[tuple[int, int]]:
396
+ filepath = os.path.realpath(filepath)
397
+ ranges = []
398
+ for entry in self._entries:
399
+ if entry.fingerprint != fingerprint or entry.filepath != filepath:
400
+ continue
401
+ item = (entry.start, entry.end)
402
+ if item not in ranges:
403
+ ranges.append(item)
404
+ return ranges
405
+
392
406
 
393
407
  @final
394
408
  @dataclass
@@ -436,6 +450,7 @@ class Session:
436
450
  current: Current = field(default_factory=Current)
437
451
  conversation: list[ConversationItem] = field(default_factory=list)
438
452
  range_fingerprints: RangeFingerprintStore = field(default_factory=RangeFingerprintStore)
453
+ blackboard: list[str] = field(default_factory=list)
439
454
 
440
455
  def resolve_path(self, path: str) -> str:
441
456
  path = os.path.expanduser(path)
@@ -554,6 +569,13 @@ def _parse_line_range(start_arg: str, end_arg: str) -> tuple[int, int]:
554
569
  return start, end
555
570
 
556
571
 
572
+ def _replacement_lines(content: str, *, has_following_line: bool) -> list[str]:
573
+ lines = content.splitlines(keepends=True)
574
+ if content and has_following_line and not content.endswith("\n"):
575
+ lines[-1] += "\n"
576
+ return lines
577
+
578
+
557
579
  def _range_fingerprint(content: str) -> str:
558
580
  return hashlib.blake2s(content.encode("utf-8"), digest_size=3).hexdigest()
559
581
 
@@ -585,7 +607,7 @@ class ReadTool(Tool):
585
607
  "Optional range is 0-based [start,end); end=0 means EOF.",
586
608
  "Returns at most 1000 lines; truncated results include total lines and next-step guidance.",
587
609
  "Prefer Search before Read for large or unknown files; use bounded reads when exact context is needed.",
588
- "For ReplaceRange, call Read with the exact same filepath/start/end and reuse that range fingerprint.",
610
+ "For ReplaceRange, the fingerprint is valid only for this exact filepath/start/end; read the exact edit range immediately before replacing.",
589
611
  ]
590
612
 
591
613
  @classmethod
@@ -733,7 +755,7 @@ class ListDirTool(Tool):
733
755
 
734
756
  @classmethod
735
757
  def example(cls) -> list[str]:
736
- return ['Example args: []', 'Example args: ["."]', 'Example args: ["src", "*.py"]']
758
+ return ["Example args: []", 'Example args: ["."]', 'Example args: ["src", "*.py"]']
737
759
 
738
760
  @classmethod
739
761
  def make(cls, session: Session, args: list[str]) -> Self:
@@ -793,7 +815,7 @@ class SearchTool(Tool):
793
815
  MAX_FILE_BYTES: ClassVar[int] = 2_000_000
794
816
  RG_MAX_FILESIZE: ClassVar[str] = "2M"
795
817
  CONTEXT_LINES: ClassVar[int] = 4
796
- MAX_CONTEXT_LINES: ClassVar[int] = 20
818
+ MAX_CONTEXT_LINES: ClassVar[int] = 30
797
819
 
798
820
  @dataclass(frozen=True)
799
821
  class Match:
@@ -820,14 +842,15 @@ class SearchTool(Tool):
820
842
  return [
821
843
  "Search files or directories before Read; default is fixed text.",
822
844
  "Prefix pattern with re: for regex search.",
845
+ "Search is line-oriented; regex patterns must not contain newlines.",
823
846
  "Use A|B|C for literal OR search in fixed mode.",
824
- "Optional context=N or N sets nearby context lines.",
847
+ "Optional context=N or N sets nearby context lines, from 0 to 30.",
825
848
  "Optional glob matches file basename or path relative to cwd.",
826
849
  ]
827
850
 
828
851
  @classmethod
829
852
  def signature(cls) -> str:
830
- return "Search(pattern, path[, glob_pattern][, context=N|N]) -> SearchToolResult<matches>"
853
+ return "Search(pattern, path[, option...]) -> SearchToolResult<matches>; option is context=N|N (0..30) or glob_pattern"
831
854
 
832
855
  @classmethod
833
856
  def example(cls) -> list[str]:
@@ -850,6 +873,8 @@ class SearchTool(Tool):
850
873
  pattern = raw_pattern[3:] if regex else raw_pattern
851
874
  if not pattern:
852
875
  raise ToolCallError("pattern cannot be empty")
876
+ if regex and "\n" in pattern:
877
+ raise ToolCallError("multiline regex is not supported; Search is line-oriented. Search each line separately or Read a nearby range.")
853
878
  glob_pattern = ""
854
879
  context_lines = cls.CONTEXT_LINES
855
880
  for raw_option in args[2:]:
@@ -860,6 +885,10 @@ class SearchTool(Tool):
860
885
  except ValueError:
861
886
  raise ToolCallError("context must be an integer between 0 and " + str(cls.MAX_CONTEXT_LINES))
862
887
  continue
888
+ if option.startswith("glob=") or option.startswith("glob_pattern="):
889
+ option = option.split("=", 1)[1]
890
+ if not option:
891
+ raise ToolCallError("glob option cannot be empty")
863
892
  if glob_pattern:
864
893
  raise ToolCallError("unexpected search option: " + option)
865
894
  glob_pattern = option
@@ -1080,6 +1109,8 @@ class SearchTool(Tool):
1080
1109
 
1081
1110
  def call(self) -> str:
1082
1111
  if not (os.path.isdir(self.target_path) or os.path.isfile(self.target_path)):
1112
+ if os.path.basename(self.target_path) == "path":
1113
+ raise ToolCallError('not a file or directory: "path" is a placeholder; pass a real file or directory')
1083
1114
  raise ToolCallError("not a file or directory")
1084
1115
  if os.path.isfile(self.target_path) and not self._matches_glob(self.target_path):
1085
1116
  return self._format_result("python", [], False)
@@ -1174,7 +1205,8 @@ class ReplaceRangeTool(Tool):
1174
1205
  @classmethod
1175
1206
  def description(cls) -> list[str]:
1176
1207
  return [
1177
- "Replace one 0-based line range when its fingerprint comes from Read(filepath, same start, same end).",
1208
+ "Replace one 0-based line range when its fingerprint comes from Read(filepath, exact same start, exact same end).",
1209
+ "Never use a wider Read fingerprint for a narrower edit; if mismatch happens, Read the exact target range and retry once.",
1178
1210
  "If earlier edits shifted lines, a cached Read fingerprint for the same original range can relocate only when old content still matches exactly once.",
1179
1211
  ]
1180
1212
 
@@ -1215,6 +1247,13 @@ class ReplaceRangeTool(Tool):
1215
1247
  return label + "\n# preview unavailable: " + str(error)
1216
1248
  return _make_unified_diff(original, new_content, self.filepath) or label
1217
1249
 
1250
+ def preview_error(self) -> str:
1251
+ try:
1252
+ self._preview()
1253
+ except (OSError, ToolCallError) as error:
1254
+ return str(error)
1255
+ return ""
1256
+
1218
1257
  def call(self) -> str:
1219
1258
  original, new_content, resolved, _ = self._preview()
1220
1259
  if new_content == original:
@@ -1245,7 +1284,7 @@ class ReplaceRangeTool(Tool):
1245
1284
  end=self.end,
1246
1285
  fingerprint=self.fingerprint,
1247
1286
  )
1248
- replacement = self.content.splitlines(keepends=True)
1287
+ replacement = _replacement_lines(self.content, has_following_line=resolved.end < len(lines))
1249
1288
  new_lines = lines[: resolved.start] + replacement + lines[resolved.end :]
1250
1289
  return original, "".join(new_lines), resolved, replacement
1251
1290
 
@@ -1275,7 +1314,8 @@ class BatchReplaceRangesTool(Tool):
1275
1314
  return [
1276
1315
  "Replace multiple 0-based line ranges in one file against one snapshot.",
1277
1316
  "Use this for multiple edits in the same file; earlier edits in this call do not shift later ranges.",
1278
- "Each edit fingerprint must come from Read(filepath, same start, same end); same-range cached fingerprints can relocate shifted old content.",
1317
+ "Each edit fingerprint must come from Read(filepath, exact same start, exact same end); never reuse a wider Read fingerprint for a narrower edit.",
1318
+ "Same-range cached fingerprints can relocate shifted old content.",
1279
1319
  ]
1280
1320
 
1281
1321
  @classmethod
@@ -1328,6 +1368,13 @@ class BatchReplaceRangesTool(Tool):
1328
1368
  return label + "\n# preview unavailable: " + str(error)
1329
1369
  return _make_unified_diff(original, new_content, self.filepath) or label
1330
1370
 
1371
+ def preview_error(self) -> str:
1372
+ try:
1373
+ self._preview()
1374
+ except (OSError, ToolCallError) as error:
1375
+ return str(error)
1376
+ return ""
1377
+
1331
1378
  def call(self) -> str:
1332
1379
  original, new_content, resolved_edits = self._preview()
1333
1380
  if new_content == original:
@@ -1362,7 +1409,7 @@ class BatchReplaceRangesTool(Tool):
1362
1409
  end=edit.end,
1363
1410
  fingerprint=edit.fingerprint,
1364
1411
  )
1365
- resolved_edits.append((resolved, edit.content.splitlines(keepends=True)))
1412
+ resolved_edits.append((resolved, _replacement_lines(edit.content, has_following_line=resolved.end < len(lines))))
1366
1413
  self._ensure_non_overlapping(resolved_edits)
1367
1414
 
1368
1415
  new_lines = list(lines)
@@ -1674,6 +1721,67 @@ class GitTool(Tool):
1674
1721
  return _format_process_result("GitToolResult", -1, error.stdout or "", (error.stderr or "") + "timeout")
1675
1722
 
1676
1723
 
1724
+ @final
1725
+ @dataclass
1726
+ class BlackboardTool(Tool):
1727
+ action: str
1728
+ content: str
1729
+ blackboard: list[str]
1730
+
1731
+ @classmethod
1732
+ def name(cls) -> str:
1733
+ return "Blackboard"
1734
+
1735
+ @classmethod
1736
+ def description(cls) -> list[str]:
1737
+ return [
1738
+ "Scratchpad for hypotheses, intermediate analysis, or task state.",
1739
+ "Stores temporary data outside main context; cleared when the session ends. Actions: read, append, clear.",
1740
+ ]
1741
+
1742
+ @classmethod
1743
+ def signature(cls) -> str:
1744
+ return "Blackboard(action[, content]) -> BlackboardToolResult<content>"
1745
+
1746
+ @classmethod
1747
+ def example(cls) -> list[str]:
1748
+ return [
1749
+ '{"name": "Blackboard", "intention": "Record progress", "args": ["append", "Step 1 done"]}',
1750
+ '{"name": "Blackboard", "intention": "Clear it", "args": ["clear"]}',
1751
+ ]
1752
+
1753
+ @classmethod
1754
+ def make(cls, session: Session, args: list[str]) -> Self:
1755
+ action = args[0] if args else "read"
1756
+ content = args[1] if len(args) > 1 else ""
1757
+ return cls(action=action, content=content, blackboard=session.blackboard)
1758
+
1759
+ def requires_confirmation(self, session: Session) -> bool:
1760
+ return False
1761
+
1762
+ def display(self) -> str:
1763
+ if self.action == "append":
1764
+ preview = self.content.replace("\n", " ")[:80]
1765
+ return f"Blackboard append: {preview}"
1766
+ return f"Blackboard {self.action}"
1767
+
1768
+ def call(self) -> str:
1769
+ if self.action == "read":
1770
+ content = "\n".join(self.blackboard)
1771
+ return f"<BlackboardToolResult>\n{content}\n</BlackboardToolResult>"
1772
+
1773
+ if self.action == "append":
1774
+ if self.content:
1775
+ self.blackboard.append(self.content.rstrip())
1776
+ return "<BlackboardToolResult>appended</BlackboardToolResult>"
1777
+
1778
+ if self.action == "clear":
1779
+ self.blackboard.clear()
1780
+ return "<BlackboardToolResult>cleared</BlackboardToolResult>"
1781
+
1782
+ raise ToolCallError("Blackboard action must be one of: read, append, clear")
1783
+
1784
+
1677
1785
  TOOL_REGISTRY: dict[str, ToolClass] = {
1678
1786
  ReadTool.name(): ReadTool,
1679
1787
  LineCountTool.name(): LineCountTool,
@@ -1685,13 +1793,14 @@ TOOL_REGISTRY: dict[str, ToolClass] = {
1685
1793
  ApplyPatchTool.name(): ApplyPatchTool,
1686
1794
  BashTool.name(): BashTool,
1687
1795
  GitTool.name(): GitTool,
1796
+ BlackboardTool.name(): BlackboardTool,
1688
1797
  }
1689
1798
 
1690
1799
 
1691
1800
  #######################
1692
1801
  # Prompt
1693
- #######################
1694
1802
 
1803
+ #######################
1695
1804
  MAIN_AGENT_SYSTEM_PROMPT = """You are nanocode, a minimal coding agent.
1696
1805
 
1697
1806
  Core:
@@ -1709,6 +1818,7 @@ Tools:
1709
1818
  - Prefer specific tools first; use Bash only when no provided tool fits.
1710
1819
  - Prefer Search before Read when locating code or facts; Read only known small ranges or exact files needed for editing.
1711
1820
  - Read returns at most 1000 lines; if truncated, use Search or smaller Read ranges in batches.
1821
+ - ReplaceRange/BatchReplaceRanges fingerprints are valid only for the exact filepath/start/end returned by Read. Never use a wider Read fingerprint for a narrower edit. If fingerprint mismatch happens, immediately Read the exact target range and retry once.
1712
1822
  - Summarize every latest tool result in last_tool_calls_summaries; raw results are shown once only, so include key_evidence when paths, lines, errors, or decisions matter later.
1713
1823
  - Latest tool results are already shown in Latest_Tool_Call_Results; use result_file logs only as a fallback when needed.
1714
1824
  - 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.
@@ -1968,6 +2078,7 @@ class ModelClient:
1968
2078
  "model": self.session.model,
1969
2079
  "messages": messages,
1970
2080
  "temperature": self.session.temperature,
2081
+ "response_format": {"type": "json_object"},
1971
2082
  }
1972
2083
  extra_params = self._reasoning_params()
1973
2084
  payload.update(extra_params)
@@ -2164,6 +2275,9 @@ class ToolCallRunner:
2164
2275
  try:
2165
2276
  call = self.parse_tool_call(item)
2166
2277
  tool = self._make_tool(call)
2278
+ preview_error = self._preview_error(tool)
2279
+ if preview_error:
2280
+ raise ToolCallError("preview unavailable: " + preview_error)
2167
2281
  if tool.requires_confirmation(self.session):
2168
2282
  if self.session.yolo:
2169
2283
  if on_auto_approve is not None:
@@ -2256,6 +2370,12 @@ class ToolCallRunner:
2256
2370
  raise ToolCallError("tool not found: " + call.name)
2257
2371
  return tool_class.make(self.session, call.args)
2258
2372
 
2373
+ def _preview_error(self, tool: Tool) -> str:
2374
+ preview_error = getattr(tool, "preview_error", None)
2375
+ if not callable(preview_error):
2376
+ return ""
2377
+ return str(preview_error())
2378
+
2259
2379
  def _is_tool_result_file_read(self, call: ParsedToolCall) -> bool:
2260
2380
  if call.name != ReadTool.name() or not call.args:
2261
2381
  return False
@@ -2736,19 +2856,14 @@ class Agent:
2736
2856
  if consecutive_format_errors >= self.MAX_CONSECUTIVE_FORMAT_ERRORS:
2737
2857
  self._report_gate(
2738
2858
  on_message,
2739
- "Stopped: model returned invalid output "
2740
- + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS)
2741
- + " times in a row.",
2859
+ "Stopped: model returned invalid output " + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS) + " times in a row.",
2742
2860
  "Format_Gate: stopped after "
2743
2861
  + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS)
2744
2862
  + " consecutive invalid model outputs. "
2745
2863
  + _shorten(format_error, 180),
2746
2864
  )
2747
2865
  raise LLMError(
2748
- "model returned invalid output "
2749
- + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS)
2750
- + " times in a row: "
2751
- + _shorten(format_error, 300)
2866
+ "model returned invalid output " + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS) + " times in a row: " + _shorten(format_error, 300)
2752
2867
  )
2753
2868
  self._report_gate(
2754
2869
  on_message,
@@ -2809,7 +2924,11 @@ class Agent:
2809
2924
  return headline
2810
2925
 
2811
2926
  def step(self) -> Json:
2812
- response = self.request(self.build_system_prompt(), self.build_user_prompt(), activity="main")
2927
+ response = self.request(self.build_system_prompt(), self.build_user_prompt(consume_latest_tool_results=False), activity="main")
2928
+ if _json_str(response.get("_format_error")):
2929
+ return response
2930
+ self.latest_tool_call_results = ""
2931
+ self.latest_agent_feedback = ""
2813
2932
  self.state_updater.apply_tool_call_summaries(response)
2814
2933
  return response
2815
2934
 
@@ -2926,6 +3045,7 @@ COMMANDS: tuple[CommandSpec, ...] = (
2926
3045
  CommandSpec("/yolo", "Show or toggle confirmation bypass", "Config", "/yolo [on|off|status]"),
2927
3046
  CommandSpec("/exit", "Exit nanocode", "Control", "/exit"),
2928
3047
  CommandSpec("/quit", "Exit nanocode", "Control", "/quit"),
3048
+ CommandSpec("/blackboard", "View or clear the blackboard", "Control", "/blackboard [status|clear]"),
2929
3049
  )
2930
3050
 
2931
3051
 
@@ -2942,6 +3062,7 @@ class CommandDispatcher:
2942
3062
  self.agent = agent
2943
3063
  self.run_agent = run_agent
2944
3064
  self.run_with_status = run_with_status
3065
+ self.blackboard = agent.session.blackboard
2945
3066
  self.handlers: dict[str, Callable[[str], str]] = {
2946
3067
  "/help": self._help,
2947
3068
  "/status": self._status,
@@ -2951,6 +3072,7 @@ class CommandDispatcher:
2951
3072
  "/reason": self._reason,
2952
3073
  "/reason_effort": self._reason_effort,
2953
3074
  "/yolo": self._yolo,
3075
+ "/blackboard": self._blackboard,
2954
3076
  }
2955
3077
 
2956
3078
  def dispatch(self, user_input: str) -> CommandResult:
@@ -3015,6 +3137,7 @@ class CommandDispatcher:
3015
3137
  "yolo: " + yolo,
3016
3138
  "conversation: " + str(len(session.conversation)) + "/" + str(session.compact_at),
3017
3139
  "tokens: last=" + _format_count(session.last_total_tokens) + " session=" + _format_count(session.session_total_tokens),
3140
+ "blackboard: " + str(len(session.blackboard)) + " items",
3018
3141
  "goal: " + (session.current.goal or "(empty)"),
3019
3142
  "verification: " + session.current.verification.status,
3020
3143
  ]
@@ -3086,6 +3209,17 @@ class CommandDispatcher:
3086
3209
  return "YOLO is " + ("on" if self.agent.session.yolo else "off")
3087
3210
  return "Usage: /yolo [on|off|status]"
3088
3211
 
3212
+ def _blackboard(self, args: str) -> str:
3213
+ if args == "clear":
3214
+ self.blackboard.clear()
3215
+ return "Blackboard cleared"
3216
+ if args in {"", "status"}:
3217
+ content = "\n".join(self.blackboard)
3218
+ if content:
3219
+ return "Blackboard:\n" + content
3220
+ return "Blackboard is empty"
3221
+ return "Usage: /blackboard [status|clear]"
3222
+
3089
3223
 
3090
3224
  def _format_count(value: int) -> str:
3091
3225
  if value <= 0:
@@ -3193,7 +3327,8 @@ class StatusBar:
3193
3327
  yolo = " | yolo" if session.yolo else ""
3194
3328
  context = str(len(session.conversation)) + "/" + str(session.compact_at)
3195
3329
  tokens = "last:" + self._format_count(session.last_total_tokens) + " session:" + self._format_count(session.session_total_tokens)
3196
- parts = [model + " (" + reasoning + ")" + yolo, "ctx:" + context, "tok:" + tokens]
3330
+ blackboard = "bb:" + str(len(session.blackboard))
3331
+ parts = [model + " (" + reasoning + ")" + yolo, "ctx:" + context, "tok:" + tokens, blackboard]
3197
3332
  if show_elapsed:
3198
3333
  parts.append(f"{turn_elapsed:.1f}s")
3199
3334
  if session.current_model_call_started_at > 0:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.2.5
3
+ Version: 0.2.7
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nanocode-cli"
7
- version = "0.2.5"
7
+ version = "0.2.7"
8
8
  description = "A lightweight terminal-based AI coding assistant"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
File without changes
File without changes
File without changes
File without changes