hexcli 2.20.1__tar.gz → 2.22.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.1 → hexcli-2.22.0}/CHANGELOG.md +54 -0
  2. {hexcli-2.20.1 → hexcli-2.22.0}/PKG-INFO +1 -1
  3. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/__init__.py +1 -1
  4. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/editing.py +40 -0
  5. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/tools.py +46 -7
  6. {hexcli-2.20.1 → hexcli-2.22.0}/.gitignore +0 -0
  7. {hexcli-2.20.1 → hexcli-2.22.0}/Hex CLI.cmd +0 -0
  8. {hexcli-2.20.1 → hexcli-2.22.0}/LICENSE +0 -0
  9. {hexcli-2.20.1 → hexcli-2.22.0}/README.md +0 -0
  10. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/agent.py +0 -0
  11. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/assets/hexcli.ico +0 -0
  12. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/assets/hexcli.png +0 -0
  13. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/cancel.py +0 -0
  14. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/chatlog.py +0 -0
  15. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/commands.py +0 -0
  16. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/compaction.py +0 -0
  17. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/config.py +0 -0
  18. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/diffview.py +0 -0
  19. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/distribution.py +0 -0
  20. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/doctor.py +0 -0
  21. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/http_client.py +0 -0
  22. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/launcher.py +0 -0
  23. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/lineedit.py +0 -0
  24. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/llm.py +0 -0
  25. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/lockfile.py +0 -0
  26. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/markdown_stream.py +0 -0
  27. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/memory.py +0 -0
  28. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/network.py +0 -0
  29. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/parsing.py +0 -0
  30. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/paths.py +0 -0
  31. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/prompts.py +0 -0
  32. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/psparse.py +0 -0
  33. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/repl.py +0 -0
  34. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/safety.py +0 -0
  35. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/sessions.py +0 -0
  36. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/setup_wizard.py +0 -0
  37. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/statusbar.py +0 -0
  38. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/stream_render.py +0 -0
  39. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/telemetry.py +0 -0
  40. {hexcli-2.20.1 → hexcli-2.22.0}/hexcli/ui.py +0 -0
  41. {hexcli-2.20.1 → hexcli-2.22.0}/install.ps1 +0 -0
  42. {hexcli-2.20.1 → hexcli-2.22.0}/launcher.py +0 -0
  43. {hexcli-2.20.1 → hexcli-2.22.0}/pyproject.toml +0 -0
  44. {hexcli-2.20.1 → hexcli-2.22.0}/shellai.example.json +0 -0
@@ -4,6 +4,60 @@ 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.22.0 — 2026-10-10
8
+
9
+ A minor release: a tool result the model reads changes (`edit_file` when
10
+ `new_string` carries a literal `\n` and the file would not parse).
11
+
12
+ > **Gate: PASS** on the pinned 24-case set after one recheck: `agentic-1`
13
+ > had all five runs invalid ("backend unreachable", the server stalled when
14
+ > free memory hit 1.4 GB), and re-run on a fresh server it was 6/6. Own arm,
15
+ > seed 20261011, against 2.21.0's arm: run-level 184/234 vs 186/233,
16
+ > −1.2 %, Fisher p = 0.82; pass^k 34 → 32 of 49, McNemar p = 0.73; 11
17
+ > invalid runs of 245. The decode never fired in the arm: none of its 48
18
+ > `edit_file` calls had a literal `\n` in `new_string`, so every edit result
19
+ > was byte-identical to 2.21.0 and the arm-level movement is run-to-run
20
+ > noise. The gate shows nothing broke; the benefit is not yet measured live.
21
+
22
+ - When an edit to a `.py` or `.json` file would leave it unparseable and
23
+ `new_string` has a literal `\n` but no real line break, `edit_file` tries
24
+ the decoded `new_string` (the full decode, then for Python one that keeps a
25
+ quote-adjacent escape) and keeps it only if the file then parses. 139 of
26
+ 1,746 recorded `edit_file` calls put a literal `\n` in `new_string`, 135
27
+ with no real line break, mostly in `self-correct-1`, `agentic-3` and
28
+ `regression-anchor-1`, and the 2.21.0 arm lost a run of each of the latter
29
+ two to it. An escape that parses as written is never touched, and other
30
+ file types are left alone.
31
+
32
+ ## 2.21.0 — 2026-10-10
33
+
34
+ A minor release: a tool result the model reads changes (`edit_file` when
35
+ `old_string` is not in the file).
36
+
37
+ > **Gate: PASS** on the pinned 24-case set after one recheck:
38
+ > `error-recovery-3` missed one of five runs, and that run never called
39
+ > `edit_file` (it reported a "permission error" after `find_files`); re-run on
40
+ > a fresh server it was 6/6. Own arm, seed 20261010, against 2.20.0's arm:
41
+ > run-level 189/237 vs 192/245, +1.4 %, Fisher p = 0.74; pass^k 32 → 34 of
42
+ > 49, McNemar p = 0.73; 8 invalid runs of 245 (backend timeouts, 2.2 GB
43
+ > free). `claims-2`, one of the three cases behind most of the recorded
44
+ > misses, went 2/5 → 5/5. `self-correct-1` lost one run to the known
45
+ > double-escaped `new_string` (a literal `\n` landed on the first edit,
46
+ > before any miss), and `regression-anchor-1` lost one to the same defect
47
+ > with no miss at all. Smoke 9/9 valid on a fresh server.
48
+
49
+ - A missed `edit_file` on a file of up to 1,500 characters returns the
50
+ file's whole current text instead of the closest region. Of 1,279 recorded
51
+ `edit_file` calls, 786 were made without reading the file that turn and
52
+ failed 45 % of the time against 9 % after a read, and 115 of the 291
53
+ misses came straight after another miss on the same file: the retry was
54
+ rebuilt from memory again. Every fixture behind those misses is under 200
55
+ characters. In the arm the message fired 16 times, in `tests-claim-1`,
56
+ `self-correct-1`, `claims-2` and `agentic-3`, and the next edit landed
57
+ after 7 of them (44 %), against 14 of 39 (36 %) in the three arms before
58
+ it; at that count the recovery rate is not yet a measured effect. A larger
59
+ file keeps the closest-region report.
60
+
7
61
  ## 2.20.1 — 2026-10-10
8
62
 
9
63
  A patch release: nothing the model reads changes. The turn's return value
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hexcli
3
- Version: 2.20.1
3
+ Version: 2.22.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.1"
6
+ __version__ = "2.22.0"
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import ast
16
16
  import difflib
17
+ import json
17
18
  import re
18
19
 
19
20
  # Tier that landed the most recent successful block: "exact", "whitespace",
@@ -180,6 +181,45 @@ def unescape_body(text: str, path: str) -> str:
180
181
  return full
181
182
 
182
183
 
184
+ def escaped_new_string_candidates(new_string: str, path: str) -> list[str]:
185
+ """Decoded forms of an edit's new_string, when it carries its line breaks
186
+ as literal \\n and has no real one -- the shape of a JSON argument the
187
+ model escaped twice. Same order as unescape_body: the full decode, then,
188
+ for a .py path, the one that keeps a quote-adjacent \\n as an escape.
189
+ The caller keeps a candidate only when the whole file then parses and the
190
+ edit as written did not, so `print("a\\nb")` is never touched.
191
+
192
+ 139 of 1,746 recorded edit_file calls (2026-10-10) carry this shape, 135
193
+ of them with no real line break; on .py, 73 of 82 parse only decoded
194
+ (self-correct-1's `def add(a, b):\\n return a + b`)."""
195
+ if "\n" in new_string or "\\n" not in new_string:
196
+ return []
197
+ full = _unescape_json(new_string)
198
+ out = [full]
199
+ if path.lower().endswith(".py"):
200
+ kept = _QUOTE_ADJACENT_NL.sub(
201
+ lambda m: (m.group(1) or "") + "\x00" + (m.group(2) or ""), new_string)
202
+ candidate = _unescape_json(kept).replace("\x00", "\\n")
203
+ if candidate != full:
204
+ out.append(candidate)
205
+ return out
206
+
207
+
208
+ def file_parses(text: str, path: str) -> bool | None:
209
+ """Whether a .py or .json file's text parses; None for other types."""
210
+ suffix = path.lower().rsplit(".", 1)[-1] if "." in path else ""
211
+ try:
212
+ if suffix == "py":
213
+ ast.parse(text)
214
+ elif suffix == "json":
215
+ json.loads(text)
216
+ else:
217
+ return None
218
+ except (SyntaxError, ValueError):
219
+ return False
220
+ return True
221
+
222
+
183
223
  _DELTA_TOKEN_RE = re.compile(r"\w+|\s+|[^\w\s]")
184
224
  _ESCAPED_RE = re.compile(r'\\["nt\\]')
185
225
  _BARE_QUOTE_RE = re.compile(r'(?<!\\)"')
@@ -435,24 +435,63 @@ def edit_file_tool(path_text: str, old_string: str, new_string: str) -> str:
435
435
  "Put the changed text in new_string, or read_file to see what is there now."
436
436
  )
437
437
  content = path.read_text(encoding="utf-8")
438
- tier = "exact"
439
- if content.count(old_string) == 1:
440
- new_content = content.replace(old_string, new_string, 1)
441
- else:
442
- new_content, err = apply_search_replace(content, [(old_string, new_string)])
438
+
439
+ def _apply(replacement: str) -> tuple[str, str]:
440
+ if content.count(old_string) == 1:
441
+ return content.replace(old_string, replacement, 1), "exact"
442
+ out, err = apply_search_replace(content, [(old_string, replacement)])
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
- tier = p2.LAST_APPLY_TIER
447
+ return out, p2.LAST_APPLY_TIER
448
+
449
+ new_content, tier = _apply(new_string)
450
+ # A new_string the model escaped twice puts a literal \n into the file
451
+ # (self-correct-1, regression-anchor-1: "unexpected character after line
452
+ # continuation"). Decode it only when the file as written does not parse
453
+ # and the decoded edit does; a genuine escape always parses as written.
454
+ if p2.file_parses(new_content, str(path)) is False:
455
+ for candidate in p2.escaped_new_string_candidates(new_string, str(path)):
456
+ try:
457
+ decoded_content, decoded_tier = _apply(candidate)
458
+ except RuntimeError:
459
+ continue
460
+ if p2.file_parses(decoded_content, str(path)):
461
+ new_content, new_string = decoded_content, candidate
462
+ tier = "unescaped" if decoded_tier == "exact" else f"{decoded_tier} match, unescaped"
463
+ break
446
464
  tmp = path.parent / (path.name + ".tmp")
447
465
  tmp.write_text(new_content, encoding="utf-8")
448
466
  tmp.replace(path)
449
467
  delta = new_string.count("\n") - old_string.count("\n")
450
468
  # The tier is UI-only (arm logs read it); the model sees "Edited <path>".
451
- suffix = "" if tier == "exact" else f", {tier} match"
469
+ suffix = ("" if tier == "exact" else f", {tier}" if "unescaped" in tier
470
+ else f", {tier} match")
452
471
  ui.tool_event("edit", f"{path} ({delta:+d} lines{suffix})")
453
472
  return f"Edited {path}"
454
473
 
455
474
 
475
+ # A missed edit_file on a small file gets the file's whole text back instead of
476
+ # the closest region. Of 1,279 recorded edit_file calls (2026-09-18), 786 were
477
+ # made without reading or writing the file that turn and failed 45 % of the
478
+ # time against 9 % after a read, and 115 of the 291 misses came straight after
479
+ # another miss on the same file: the retry was rebuilt from memory again. With
480
+ # the text in the error the retry can be copied from the file, at no extra
481
+ # step. Every fixture behind those misses is under 200 characters; a file
482
+ # above the cap keeps the closest-region report.
483
+ _WHOLE_FILE_ON_MISS = 1500
484
+
485
+
486
+ def _not_found_with_content(path: Path, content: str) -> str:
487
+ body = content if content.strip() else "(the file is empty)"
488
+ return (
489
+ f"old_string was not found in {path}. This is the file's whole current "
490
+ "content; copy the lines to change from it exactly into old_string:\n"
491
+ f"---\n{body.rstrip(chr(10))}\n---"
492
+ )
493
+
494
+
456
495
  def write_file_tool(path_text: str, content: str) -> str:
457
496
  from . import editing as p2
458
497
 
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