hexcli 2.21.0__tar.gz → 2.22.1__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.21.0 → hexcli-2.22.1}/CHANGELOG.md +38 -0
  2. {hexcli-2.21.0 → hexcli-2.22.1}/PKG-INFO +1 -1
  3. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/__init__.py +1 -1
  4. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/editing.py +40 -0
  5. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/tools.py +24 -7
  6. {hexcli-2.21.0 → hexcli-2.22.1}/.gitignore +0 -0
  7. {hexcli-2.21.0 → hexcli-2.22.1}/Hex CLI.cmd +0 -0
  8. {hexcli-2.21.0 → hexcli-2.22.1}/LICENSE +0 -0
  9. {hexcli-2.21.0 → hexcli-2.22.1}/README.md +0 -0
  10. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/agent.py +0 -0
  11. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/assets/hexcli.ico +0 -0
  12. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/assets/hexcli.png +0 -0
  13. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/cancel.py +0 -0
  14. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/chatlog.py +0 -0
  15. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/commands.py +0 -0
  16. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/compaction.py +0 -0
  17. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/config.py +0 -0
  18. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/diffview.py +0 -0
  19. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/distribution.py +0 -0
  20. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/doctor.py +0 -0
  21. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/http_client.py +0 -0
  22. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/launcher.py +0 -0
  23. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/lineedit.py +0 -0
  24. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/llm.py +0 -0
  25. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/lockfile.py +0 -0
  26. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/markdown_stream.py +0 -0
  27. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/memory.py +0 -0
  28. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/network.py +0 -0
  29. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/parsing.py +0 -0
  30. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/paths.py +0 -0
  31. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/prompts.py +0 -0
  32. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/psparse.py +0 -0
  33. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/repl.py +0 -0
  34. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/safety.py +0 -0
  35. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/sessions.py +0 -0
  36. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/setup_wizard.py +0 -0
  37. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/statusbar.py +0 -0
  38. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/stream_render.py +0 -0
  39. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/telemetry.py +0 -0
  40. {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/ui.py +0 -0
  41. {hexcli-2.21.0 → hexcli-2.22.1}/install.ps1 +0 -0
  42. {hexcli-2.21.0 → hexcli-2.22.1}/launcher.py +0 -0
  43. {hexcli-2.21.0 → hexcli-2.22.1}/pyproject.toml +0 -0
  44. {hexcli-2.21.0 → hexcli-2.22.1}/shellai.example.json +0 -0
@@ -4,6 +4,44 @@ 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.1 — 2026-10-10
8
+
9
+ A patch release: only the eval harness changes.
10
+
11
+ - `python evals/cases_smoke.py` runs ten cases from the pinned gate set
12
+ (`SMOKE_SET`) instead of its own ten. The old set held `factual-1` (19/24),
13
+ `agentic-2` (18/24), `agentic-3` (18/20) and `lint-1` (20/21), so
14
+ unchanged code scored 10/10 about half the time and most releases
15
+ re-measured one of them. Each of the new ten passed every run in the two
16
+ arms that pinned the gate set and in the 16 arms since (44–51 runs each).
17
+ The new set went 10/10 on its first live run. The extended suite is
18
+ unchanged.
19
+
20
+ ## 2.22.0 — 2026-10-10
21
+
22
+ A minor release: a tool result the model reads changes (`edit_file` when
23
+ `new_string` carries a literal `\n` and the file would not parse).
24
+
25
+ > **Gate: PASS** on the pinned 24-case set after one recheck: `agentic-1`
26
+ > had all five runs invalid ("backend unreachable", the server stalled when
27
+ > free memory hit 1.4 GB), and re-run on a fresh server it was 6/6. Own arm,
28
+ > seed 20261011, against 2.21.0's arm: run-level 184/234 vs 186/233,
29
+ > −1.2 %, Fisher p = 0.82; pass^k 34 → 32 of 49, McNemar p = 0.73; 11
30
+ > invalid runs of 245. The decode never fired in the arm: none of its 48
31
+ > `edit_file` calls had a literal `\n` in `new_string`, so every edit result
32
+ > was byte-identical to 2.21.0 and the arm-level movement is run-to-run
33
+ > noise. The gate shows nothing broke; the benefit is not yet measured live.
34
+
35
+ - When an edit to a `.py` or `.json` file would leave it unparseable and
36
+ `new_string` has a literal `\n` but no real line break, `edit_file` tries
37
+ the decoded `new_string` (the full decode, then for Python one that keeps a
38
+ quote-adjacent escape) and keeps it only if the file then parses. 139 of
39
+ 1,746 recorded `edit_file` calls put a literal `\n` in `new_string`, 135
40
+ with no real line break, mostly in `self-correct-1`, `agentic-3` and
41
+ `regression-anchor-1`, and the 2.21.0 arm lost a run of each of the latter
42
+ two to it. An escape that parses as written is never touched, and other
43
+ file types are left alone.
44
+
7
45
  ## 2.21.0 — 2026-10-10
8
46
 
9
47
  A minor release: a tool result the model reads changes (`edit_file` when
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hexcli
3
- Version: 2.21.0
3
+ Version: 2.22.1
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.21.0"
6
+ __version__ = "2.22.1"
@@ -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,22 +435,39 @@ 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
444
  if err.startswith("SEARCH block 1 was not found") and len(content) <= _WHOLE_FILE_ON_MISS:
445
445
  raise RuntimeError(_not_found_with_content(path, content))
446
446
  raise RuntimeError(err.replace("SEARCH block 1", "old_string"))
447
- 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
448
464
  tmp = path.parent / (path.name + ".tmp")
449
465
  tmp.write_text(new_content, encoding="utf-8")
450
466
  tmp.replace(path)
451
467
  delta = new_string.count("\n") - old_string.count("\n")
452
468
  # The tier is UI-only (arm logs read it); the model sees "Edited <path>".
453
- 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")
454
471
  ui.tool_event("edit", f"{path} ({delta:+d} lines{suffix})")
455
472
  return f"Edited {path}"
456
473
 
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