hexcli 2.20.0__tar.gz → 2.21.0__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.
Files changed (44) hide show
  1. {hexcli-2.20.0 → hexcli-2.21.0}/CHANGELOG.md +47 -0
  2. {hexcli-2.20.0 → hexcli-2.21.0}/PKG-INFO +1 -1
  3. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/__init__.py +1 -1
  4. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/agent.py +57 -9
  5. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/tools.py +22 -0
  6. {hexcli-2.20.0 → hexcli-2.21.0}/.gitignore +0 -0
  7. {hexcli-2.20.0 → hexcli-2.21.0}/Hex CLI.cmd +0 -0
  8. {hexcli-2.20.0 → hexcli-2.21.0}/LICENSE +0 -0
  9. {hexcli-2.20.0 → hexcli-2.21.0}/README.md +0 -0
  10. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/assets/hexcli.ico +0 -0
  11. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/assets/hexcli.png +0 -0
  12. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/cancel.py +0 -0
  13. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/chatlog.py +0 -0
  14. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/commands.py +0 -0
  15. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/compaction.py +0 -0
  16. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/config.py +0 -0
  17. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/diffview.py +0 -0
  18. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/distribution.py +0 -0
  19. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/doctor.py +0 -0
  20. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/editing.py +0 -0
  21. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/http_client.py +0 -0
  22. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/launcher.py +0 -0
  23. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/lineedit.py +0 -0
  24. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/llm.py +0 -0
  25. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/lockfile.py +0 -0
  26. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/markdown_stream.py +0 -0
  27. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/memory.py +0 -0
  28. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/network.py +0 -0
  29. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/parsing.py +0 -0
  30. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/paths.py +0 -0
  31. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/prompts.py +0 -0
  32. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/psparse.py +0 -0
  33. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/repl.py +0 -0
  34. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/safety.py +0 -0
  35. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/sessions.py +0 -0
  36. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/setup_wizard.py +0 -0
  37. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/statusbar.py +0 -0
  38. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/stream_render.py +0 -0
  39. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/telemetry.py +0 -0
  40. {hexcli-2.20.0 → hexcli-2.21.0}/hexcli/ui.py +0 -0
  41. {hexcli-2.20.0 → hexcli-2.21.0}/install.ps1 +0 -0
  42. {hexcli-2.20.0 → hexcli-2.21.0}/launcher.py +0 -0
  43. {hexcli-2.20.0 → hexcli-2.21.0}/pyproject.toml +0 -0
  44. {hexcli-2.20.0 → hexcli-2.21.0}/shellai.example.json +0 -0
@@ -4,6 +4,53 @@ Full evidence for every claim below — including the experiments that failed
4
4
  lives in `docs/V2_PLAN.md` §14. Numbers are pass^k over repeated live runs on
5
5
  the Hexagon NPU, not single-run anecdotes.
6
6
 
7
+ ## 2.21.0 — 2026-10-10
8
+
9
+ A minor release: a tool result the model reads changes (`edit_file` when
10
+ `old_string` is not in the file).
11
+
12
+ > **Gate: PASS** on the pinned 24-case set after one recheck:
13
+ > `error-recovery-3` missed one of five runs, and that run never called
14
+ > `edit_file` (it reported a "permission error" after `find_files`); re-run on
15
+ > a fresh server it was 6/6. Own arm, seed 20261010, against 2.20.0's arm:
16
+ > run-level 189/237 vs 192/245, +1.4 %, Fisher p = 0.74; pass^k 32 → 34 of
17
+ > 49, McNemar p = 0.73; 8 invalid runs of 245 (backend timeouts, 2.2 GB
18
+ > free). `claims-2`, one of the three cases behind most of the recorded
19
+ > misses, went 2/5 → 5/5. `self-correct-1` lost one run to the known
20
+ > double-escaped `new_string` (a literal `\n` landed on the first edit,
21
+ > before any miss), and `regression-anchor-1` lost one to the same defect
22
+ > with no miss at all. Smoke 9/9 valid on a fresh server.
23
+
24
+ - A missed `edit_file` on a file of up to 1,500 characters returns the
25
+ file's whole current text instead of the closest region. Of 1,279 recorded
26
+ `edit_file` calls, 786 were made without reading the file that turn and
27
+ failed 45 % of the time against 9 % after a read, and 115 of the 291
28
+ misses came straight after another miss on the same file: the retry was
29
+ rebuilt from memory again. Every fixture behind those misses is under 200
30
+ characters. In the arm the message fired 16 times, in `tests-claim-1`,
31
+ `self-correct-1`, `claims-2` and `agentic-3`, and the next edit landed
32
+ after 7 of them (44 %), against 14 of 39 (36 %) in the three arms before
33
+ it; at that count the recovery rate is not yet a measured effect. A larger
34
+ file keeps the closest-region report.
35
+
36
+ ## 2.20.1 — 2026-10-10
37
+
38
+ A patch release: nothing the model reads changes. The turn's return value
39
+ and the history entry for a stopped turn are byte-identical to 2.20.0.
40
+
41
+ - A turn the loop stops now says which tool failed, on which file, and
42
+ whether any file changed: "Stopped after edit_file failed three times on
43
+ calculator.py; no file was changed." instead of "Stopped: edit_file failed
44
+ three times in a row." 33 recorded turns ended on a loop or step-limit stop
45
+ with only the raw `old_string was not found` error behind them, and none of
46
+ them said whether the file had been touched. The file clause comes from the
47
+ writes that landed this turn, not from the attempts, so a turn that wrote
48
+ `notes.txt` and then got stuck on `calculator.py` names `notes.txt`; a turn
49
+ that never tried to change a file gets no clause.
50
+ - Off a terminal (`hex "..." | clip`, scripts) a stopped turn printed
51
+ nothing at all. It prints the stop sentence. The raw tool output still goes
52
+ to the session history, where the next turn reads it.
53
+
7
54
  ## 2.20.0 — 2026-09-18
8
55
 
9
56
  A minor release: a tool result the model reads changes (`run_code` on a
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hexcli
3
- Version: 2.20.0
3
+ Version: 2.21.0
4
4
  Summary: Local Hexagon NPU terminal agent for Snapdragon X Elite Windows ARM64
5
5
  Project-URL: Homepage, https://github.com/NathanL15/Hex-CLI
6
6
  Project-URL: Repository, https://github.com/NathanL15/Hex-CLI
@@ -3,4 +3,4 @@
3
3
  # The one place the version is written. pyproject.toml reads it (hatch
4
4
  # dynamic version), agent.VERSION re-exports it, and CI refuses a release
5
5
  # tag that does not match it.
6
- __version__ = "2.20.0"
6
+ __version__ = "2.21.0"
@@ -65,17 +65,48 @@ last_streamed_matches = llm.last_streamed_matches
65
65
  _LAST_TURN_STOP: str | None = None
66
66
 
67
67
 
68
- def _mark_turn_stopped(how: str) -> None:
69
- global _LAST_TURN_STOP
68
+ # The one sentence the user gets for a stopped turn. The turn's return value
69
+ # stays the raw tool output because the history keeps it for the next turn;
70
+ # this is what the console notice and a pipe show instead.
71
+ _LAST_STOP_NOTICE: str = ""
72
+
73
+
74
+ def _mark_turn_stopped(how: str, notice: str = "") -> None:
75
+ global _LAST_TURN_STOP, _LAST_STOP_NOTICE
70
76
  _LAST_TURN_STOP = how
77
+ _LAST_STOP_NOTICE = notice
78
+
79
+
80
+ def last_stop_notice() -> str:
81
+ return _LAST_STOP_NOTICE
82
+
83
+
84
+ def _stop_notice(how: str, tool: str = "", path: str = "", *,
85
+ identical: bool = False, changed: list[str] | None = None,
86
+ tried_to_change: bool = False) -> str:
87
+ """Say why a turn stopped and whether any file changed, in one sentence."""
88
+ if how == "loop":
89
+ what = "returned the same result" if identical else "failed"
90
+ where = f" on {Path(path).name}" if path else ""
91
+ head = f"Stopped after {tool} {what} three times{where}"
92
+ else:
93
+ head = "Stopped at the step limit"
94
+ names = list(dict.fromkeys(Path(p).name for p in (changed or [])))
95
+ if names:
96
+ verb = "was" if len(names) == 1 else "were"
97
+ return f"{head}; {', '.join(names)} {verb} changed."
98
+ if tried_to_change:
99
+ return f"{head}; no file was changed."
100
+ return f"{head}."
71
101
 
72
102
 
73
103
  def clear_turn_stop() -> None:
74
104
  """Called by the REPL before each turn. Also forgets what the previous
75
105
  turn streamed: a turn that never calls the model (help, small talk)
76
106
  must not match the box-skip against stale text."""
77
- global _LAST_TURN_STOP
107
+ global _LAST_TURN_STOP, _LAST_STOP_NOTICE
78
108
  _LAST_TURN_STOP = None
109
+ _LAST_STOP_NOTICE = ""
79
110
  llm._LAST_STREAMED_TEXT = ""
80
111
 
81
112
 
@@ -1166,6 +1197,7 @@ def _run_autopilot_turn(
1166
1197
  # One nudge per turn — it guides, never traps.
1167
1198
  _unverified_mutation = False
1168
1199
  _mutated_ok = False # a write/edit/append that did not error, this turn
1200
+ _changed_paths: list[str] = [] # the paths those landed on, for a stop notice
1169
1201
  _verify_nudge_used = False
1170
1202
  # The user asked for the tests to be run: what the run tools executed
1171
1203
  # this turn, so a finish without a test run can be sent back once.
@@ -1478,6 +1510,8 @@ def _run_autopilot_turn(
1478
1510
  if tool_name in {"edit_file", "write_file", "append_file"} and not _is_error:
1479
1511
  _unverified_mutation = True
1480
1512
  _mutated_ok = True
1513
+ if tool_path:
1514
+ _changed_paths.append(str(tool_path))
1481
1515
  elif tool_name in {"read_file", "run_code", "verify_syntax", "lint_code",
1482
1516
  "run_command"} and not _is_error:
1483
1517
  _unverified_mutation = False
@@ -1497,10 +1531,14 @@ def _run_autopilot_turn(
1497
1531
  and all(err for _t, _tgt, err, _out in _loop_tracker)
1498
1532
  and len({(t, tgt) for t, tgt, _e, _out in _loop_tracker}) == 1)
1499
1533
  if _identical_trip or _failure_trip:
1500
- what = f"{tool_name} returned the same result" if _identical_trip else f"{tool_name} failed"
1501
1534
  if not _in_delegate: # a sub-agent's stop is its own result, not the turn's
1502
- cprint(f"\n ⚠ Stopped: {what} three times in a row.", C.BYELLOW)
1503
- _mark_turn_stopped("loop")
1535
+ notice = _stop_notice(
1536
+ "loop", tool_name, str(tool_path or ""),
1537
+ identical=_identical_trip and not _failure_trip,
1538
+ changed=_changed_paths,
1539
+ tried_to_change=any(t in _MUTATING_TOOLS for t in tools_used))
1540
+ cprint(f"\n ⚠ {notice}", C.BYELLOW)
1541
+ _mark_turn_stopped("loop", notice)
1504
1542
  memory.maybe_index_turn(config, query, tools_used, touched_paths, outcome="error_loop")
1505
1543
  if session:
1506
1544
  _record_undo_snapshots(session, _turn_snapshots)
@@ -1511,8 +1549,11 @@ def _run_autopilot_turn(
1511
1549
 
1512
1550
  memory.maybe_index_turn(config, query, tools_used, touched_paths, outcome="step_limit")
1513
1551
  if not _in_delegate: # a sub-agent's cap is routine; its text comes back as a tool result
1514
- cprint("\n ⚠ Stopped at the step limit.", C.BYELLOW)
1515
- _mark_turn_stopped("step_limit")
1552
+ notice = _stop_notice(
1553
+ "step_limit", changed=_changed_paths,
1554
+ tried_to_change=any(t in _MUTATING_TOOLS for t in tools_used))
1555
+ cprint(f"\n ⚠ {notice}", C.BYELLOW)
1556
+ _mark_turn_stopped("step_limit", notice)
1516
1557
  if session:
1517
1558
  _record_undo_snapshots(session, _turn_snapshots)
1518
1559
  _probe(probe, "on_end", "step_limit", last_tool_output or "Done.")
@@ -2057,7 +2098,14 @@ def one_shot_autopilot(config: dict[str, Any], query: str, shell_exe: str) -> in
2057
2098
  tel.record_turn(turn)
2058
2099
  append_session_message(session, "assistant", message)
2059
2100
  sync_session_store(sessions, session)
2060
- if last_streamed_matches(message) or last_turn_stopped():
2101
+ if last_turn_stopped():
2102
+ if sys.stdout.isatty():
2103
+ print()
2104
+ else:
2105
+ # A pipe used to get nothing for a stopped turn. It gets the stop
2106
+ # sentence; the raw tool output stays in the history only.
2107
+ print(last_stop_notice())
2108
+ elif last_streamed_matches(message):
2061
2109
  if sys.stdout.isatty():
2062
2110
  print()
2063
2111
  elif not sys.stdout.isatty():
@@ -441,6 +441,8 @@ def edit_file_tool(path_text: str, old_string: str, new_string: str) -> str:
441
441
  else:
442
442
  new_content, err = apply_search_replace(content, [(old_string, new_string)])
443
443
  if err:
444
+ if err.startswith("SEARCH block 1 was not found") and len(content) <= _WHOLE_FILE_ON_MISS:
445
+ raise RuntimeError(_not_found_with_content(path, content))
444
446
  raise RuntimeError(err.replace("SEARCH block 1", "old_string"))
445
447
  tier = p2.LAST_APPLY_TIER
446
448
  tmp = path.parent / (path.name + ".tmp")
@@ -453,6 +455,26 @@ def edit_file_tool(path_text: str, old_string: str, new_string: str) -> str:
453
455
  return f"Edited {path}"
454
456
 
455
457
 
458
+ # A missed edit_file on a small file gets the file's whole text back instead of
459
+ # the closest region. Of 1,279 recorded edit_file calls (2026-09-18), 786 were
460
+ # made without reading or writing the file that turn and failed 45 % of the
461
+ # time against 9 % after a read, and 115 of the 291 misses came straight after
462
+ # another miss on the same file: the retry was rebuilt from memory again. With
463
+ # the text in the error the retry can be copied from the file, at no extra
464
+ # step. Every fixture behind those misses is under 200 characters; a file
465
+ # above the cap keeps the closest-region report.
466
+ _WHOLE_FILE_ON_MISS = 1500
467
+
468
+
469
+ def _not_found_with_content(path: Path, content: str) -> str:
470
+ body = content if content.strip() else "(the file is empty)"
471
+ return (
472
+ f"old_string was not found in {path}. This is the file's whole current "
473
+ "content; copy the lines to change from it exactly into old_string:\n"
474
+ f"---\n{body.rstrip(chr(10))}\n---"
475
+ )
476
+
477
+
456
478
  def write_file_tool(path_text: str, content: str) -> str:
457
479
  from . import editing as p2
458
480
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes