nanocode-cli 0.2.6__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.6
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.6"
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:
@@ -822,13 +844,13 @@ class SearchTool(Tool):
822
844
  "Prefix pattern with re: for regex search.",
823
845
  "Search is line-oriented; regex patterns must not contain newlines.",
824
846
  "Use A|B|C for literal OR search in fixed mode.",
825
- "Optional context=N or N sets nearby context lines.",
847
+ "Optional context=N or N sets nearby context lines, from 0 to 30.",
826
848
  "Optional glob matches file basename or path relative to cwd.",
827
849
  ]
828
850
 
829
851
  @classmethod
830
852
  def signature(cls) -> str:
831
- return "Search(pattern, path[, option...]) -> SearchToolResult<matches>; option is context=N|N or glob_pattern"
853
+ return "Search(pattern, path[, option...]) -> SearchToolResult<matches>; option is context=N|N (0..30) or glob_pattern"
832
854
 
833
855
  @classmethod
834
856
  def example(cls) -> list[str]:
@@ -863,6 +885,10 @@ class SearchTool(Tool):
863
885
  except ValueError:
864
886
  raise ToolCallError("context must be an integer between 0 and " + str(cls.MAX_CONTEXT_LINES))
865
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")
866
892
  if glob_pattern:
867
893
  raise ToolCallError("unexpected search option: " + option)
868
894
  glob_pattern = option
@@ -1083,6 +1109,8 @@ class SearchTool(Tool):
1083
1109
 
1084
1110
  def call(self) -> str:
1085
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')
1086
1114
  raise ToolCallError("not a file or directory")
1087
1115
  if os.path.isfile(self.target_path) and not self._matches_glob(self.target_path):
1088
1116
  return self._format_result("python", [], False)
@@ -1177,7 +1205,8 @@ class ReplaceRangeTool(Tool):
1177
1205
  @classmethod
1178
1206
  def description(cls) -> list[str]:
1179
1207
  return [
1180
- "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.",
1181
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.",
1182
1211
  ]
1183
1212
 
@@ -1218,6 +1247,13 @@ class ReplaceRangeTool(Tool):
1218
1247
  return label + "\n# preview unavailable: " + str(error)
1219
1248
  return _make_unified_diff(original, new_content, self.filepath) or label
1220
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
+
1221
1257
  def call(self) -> str:
1222
1258
  original, new_content, resolved, _ = self._preview()
1223
1259
  if new_content == original:
@@ -1248,7 +1284,7 @@ class ReplaceRangeTool(Tool):
1248
1284
  end=self.end,
1249
1285
  fingerprint=self.fingerprint,
1250
1286
  )
1251
- replacement = self.content.splitlines(keepends=True)
1287
+ replacement = _replacement_lines(self.content, has_following_line=resolved.end < len(lines))
1252
1288
  new_lines = lines[: resolved.start] + replacement + lines[resolved.end :]
1253
1289
  return original, "".join(new_lines), resolved, replacement
1254
1290
 
@@ -1278,7 +1314,8 @@ class BatchReplaceRangesTool(Tool):
1278
1314
  return [
1279
1315
  "Replace multiple 0-based line ranges in one file against one snapshot.",
1280
1316
  "Use this for multiple edits in the same file; earlier edits in this call do not shift later ranges.",
1281
- "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.",
1282
1319
  ]
1283
1320
 
1284
1321
  @classmethod
@@ -1331,6 +1368,13 @@ class BatchReplaceRangesTool(Tool):
1331
1368
  return label + "\n# preview unavailable: " + str(error)
1332
1369
  return _make_unified_diff(original, new_content, self.filepath) or label
1333
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
+
1334
1378
  def call(self) -> str:
1335
1379
  original, new_content, resolved_edits = self._preview()
1336
1380
  if new_content == original:
@@ -1365,7 +1409,7 @@ class BatchReplaceRangesTool(Tool):
1365
1409
  end=edit.end,
1366
1410
  fingerprint=edit.fingerprint,
1367
1411
  )
1368
- 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))))
1369
1413
  self._ensure_non_overlapping(resolved_edits)
1370
1414
 
1371
1415
  new_lines = list(lines)
@@ -1677,6 +1721,67 @@ class GitTool(Tool):
1677
1721
  return _format_process_result("GitToolResult", -1, error.stdout or "", (error.stderr or "") + "timeout")
1678
1722
 
1679
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
+
1680
1785
  TOOL_REGISTRY: dict[str, ToolClass] = {
1681
1786
  ReadTool.name(): ReadTool,
1682
1787
  LineCountTool.name(): LineCountTool,
@@ -1688,13 +1793,14 @@ TOOL_REGISTRY: dict[str, ToolClass] = {
1688
1793
  ApplyPatchTool.name(): ApplyPatchTool,
1689
1794
  BashTool.name(): BashTool,
1690
1795
  GitTool.name(): GitTool,
1796
+ BlackboardTool.name(): BlackboardTool,
1691
1797
  }
1692
1798
 
1693
1799
 
1694
1800
  #######################
1695
1801
  # Prompt
1696
- #######################
1697
1802
 
1803
+ #######################
1698
1804
  MAIN_AGENT_SYSTEM_PROMPT = """You are nanocode, a minimal coding agent.
1699
1805
 
1700
1806
  Core:
@@ -1712,6 +1818,7 @@ Tools:
1712
1818
  - Prefer specific tools first; use Bash only when no provided tool fits.
1713
1819
  - Prefer Search before Read when locating code or facts; Read only known small ranges or exact files needed for editing.
1714
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.
1715
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.
1716
1823
  - Latest tool results are already shown in Latest_Tool_Call_Results; use result_file logs only as a fallback when needed.
1717
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.
@@ -2168,6 +2275,9 @@ class ToolCallRunner:
2168
2275
  try:
2169
2276
  call = self.parse_tool_call(item)
2170
2277
  tool = self._make_tool(call)
2278
+ preview_error = self._preview_error(tool)
2279
+ if preview_error:
2280
+ raise ToolCallError("preview unavailable: " + preview_error)
2171
2281
  if tool.requires_confirmation(self.session):
2172
2282
  if self.session.yolo:
2173
2283
  if on_auto_approve is not None:
@@ -2260,6 +2370,12 @@ class ToolCallRunner:
2260
2370
  raise ToolCallError("tool not found: " + call.name)
2261
2371
  return tool_class.make(self.session, call.args)
2262
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
+
2263
2379
  def _is_tool_result_file_read(self, call: ParsedToolCall) -> bool:
2264
2380
  if call.name != ReadTool.name() or not call.args:
2265
2381
  return False
@@ -2740,19 +2856,14 @@ class Agent:
2740
2856
  if consecutive_format_errors >= self.MAX_CONSECUTIVE_FORMAT_ERRORS:
2741
2857
  self._report_gate(
2742
2858
  on_message,
2743
- "Stopped: model returned invalid output "
2744
- + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS)
2745
- + " times in a row.",
2859
+ "Stopped: model returned invalid output " + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS) + " times in a row.",
2746
2860
  "Format_Gate: stopped after "
2747
2861
  + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS)
2748
2862
  + " consecutive invalid model outputs. "
2749
2863
  + _shorten(format_error, 180),
2750
2864
  )
2751
2865
  raise LLMError(
2752
- "model returned invalid output "
2753
- + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS)
2754
- + " times in a row: "
2755
- + _shorten(format_error, 300)
2866
+ "model returned invalid output " + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS) + " times in a row: " + _shorten(format_error, 300)
2756
2867
  )
2757
2868
  self._report_gate(
2758
2869
  on_message,
@@ -2934,6 +3045,7 @@ COMMANDS: tuple[CommandSpec, ...] = (
2934
3045
  CommandSpec("/yolo", "Show or toggle confirmation bypass", "Config", "/yolo [on|off|status]"),
2935
3046
  CommandSpec("/exit", "Exit nanocode", "Control", "/exit"),
2936
3047
  CommandSpec("/quit", "Exit nanocode", "Control", "/quit"),
3048
+ CommandSpec("/blackboard", "View or clear the blackboard", "Control", "/blackboard [status|clear]"),
2937
3049
  )
2938
3050
 
2939
3051
 
@@ -2950,6 +3062,7 @@ class CommandDispatcher:
2950
3062
  self.agent = agent
2951
3063
  self.run_agent = run_agent
2952
3064
  self.run_with_status = run_with_status
3065
+ self.blackboard = agent.session.blackboard
2953
3066
  self.handlers: dict[str, Callable[[str], str]] = {
2954
3067
  "/help": self._help,
2955
3068
  "/status": self._status,
@@ -2959,6 +3072,7 @@ class CommandDispatcher:
2959
3072
  "/reason": self._reason,
2960
3073
  "/reason_effort": self._reason_effort,
2961
3074
  "/yolo": self._yolo,
3075
+ "/blackboard": self._blackboard,
2962
3076
  }
2963
3077
 
2964
3078
  def dispatch(self, user_input: str) -> CommandResult:
@@ -3023,6 +3137,7 @@ class CommandDispatcher:
3023
3137
  "yolo: " + yolo,
3024
3138
  "conversation: " + str(len(session.conversation)) + "/" + str(session.compact_at),
3025
3139
  "tokens: last=" + _format_count(session.last_total_tokens) + " session=" + _format_count(session.session_total_tokens),
3140
+ "blackboard: " + str(len(session.blackboard)) + " items",
3026
3141
  "goal: " + (session.current.goal or "(empty)"),
3027
3142
  "verification: " + session.current.verification.status,
3028
3143
  ]
@@ -3094,6 +3209,17 @@ class CommandDispatcher:
3094
3209
  return "YOLO is " + ("on" if self.agent.session.yolo else "off")
3095
3210
  return "Usage: /yolo [on|off|status]"
3096
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
+
3097
3223
 
3098
3224
  def _format_count(value: int) -> str:
3099
3225
  if value <= 0:
@@ -3201,7 +3327,8 @@ class StatusBar:
3201
3327
  yolo = " | yolo" if session.yolo else ""
3202
3328
  context = str(len(session.conversation)) + "/" + str(session.compact_at)
3203
3329
  tokens = "last:" + self._format_count(session.last_total_tokens) + " session:" + self._format_count(session.session_total_tokens)
3204
- 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]
3205
3332
  if show_elapsed:
3206
3333
  parts.append(f"{turn_elapsed:.1f}s")
3207
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.6
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.6"
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