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.
- {hexcli-2.21.0 → hexcli-2.22.1}/CHANGELOG.md +38 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/PKG-INFO +1 -1
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/__init__.py +1 -1
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/editing.py +40 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/tools.py +24 -7
- {hexcli-2.21.0 → hexcli-2.22.1}/.gitignore +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/Hex CLI.cmd +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/LICENSE +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/README.md +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/agent.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/assets/hexcli.ico +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/assets/hexcli.png +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/cancel.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/chatlog.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/commands.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/compaction.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/config.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/diffview.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/distribution.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/doctor.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/http_client.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/launcher.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/lineedit.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/llm.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/lockfile.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/markdown_stream.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/memory.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/network.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/parsing.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/paths.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/prompts.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/psparse.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/repl.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/safety.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/sessions.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/setup_wizard.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/statusbar.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/stream_render.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/telemetry.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/hexcli/ui.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/install.ps1 +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/launcher.py +0 -0
- {hexcli-2.21.0 → hexcli-2.22.1}/pyproject.toml +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|