hexcli 2.17.0__tar.gz → 2.19.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.
- {hexcli-2.17.0 → hexcli-2.19.0}/CHANGELOG.md +75 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/PKG-INFO +1 -1
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/__init__.py +1 -1
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/agent.py +38 -6
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/editing.py +40 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/tools.py +1 -1
- {hexcli-2.17.0 → hexcli-2.19.0}/.gitignore +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/Hex CLI.cmd +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/LICENSE +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/README.md +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/assets/hexcli.ico +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/assets/hexcli.png +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/cancel.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/chatlog.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/commands.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/compaction.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/config.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/diffview.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/distribution.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/doctor.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/http_client.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/launcher.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/lineedit.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/llm.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/lockfile.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/markdown_stream.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/memory.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/network.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/parsing.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/paths.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/prompts.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/psparse.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/repl.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/safety.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/sessions.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/setup_wizard.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/statusbar.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/stream_render.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/telemetry.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/ui.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/install.ps1 +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/launcher.py +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/pyproject.toml +0 -0
- {hexcli-2.17.0 → hexcli-2.19.0}/shellai.example.json +0 -0
|
@@ -4,6 +4,81 @@ 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.19.0 — 2026-09-18
|
|
8
|
+
|
|
9
|
+
A minor release: nudge text the model reads is new (the claimed-fix rule)
|
|
10
|
+
and two existing rules fire where they did not.
|
|
11
|
+
|
|
12
|
+
> **Gate: PASS** on the pinned 24-case set: 21 cases held 5/5 in the arm;
|
|
13
|
+
> `missing-file-1`, `missing-file-2` and `regression-count-1` lost one, three
|
|
14
|
+
> and one runs to backend timeouts and were re-run on a fresh server at six
|
|
15
|
+
> runs, 6/6 each. Own arm, seed 20260918. **Platform: 16 invalid of 245 runs
|
|
16
|
+
> (6.5 %)** -- the model server's working set grew from 3 GB to 6.7 GB over
|
|
17
|
+
> the hour and the machine fell to 1.2 GB free, so the ceiling comparison
|
|
18
|
+
> carries the `[PLATFORM]` flag: run-level 391/502 vs 161/214, −2.6 %, Fisher p = 0.44;
|
|
19
|
+
> pass^k 26 → 30 of 46 shared cases, McNemar p = 0.13; every ceiling drop
|
|
20
|
+
> (`livestate-1` 1/4, `tests-claim-1` 0/2, `claims-1` 1/5) is on a case that
|
|
21
|
+
> lost runs to timeouts. **The new and widened rules did not fire once in
|
|
22
|
+
> the arm**, so the arm is evidence of no regression on the gated set and
|
|
23
|
+
> the effect rests on the replay below. Smoke 10/10 on a fresh server.
|
|
24
|
+
|
|
25
|
+
- The intent nudge counts only a mutation that landed, catches a claimed
|
|
26
|
+
fix with no change behind it, and reads "cannot be created" however the
|
|
27
|
+
sentence goes on. Three gaps, all from the owner's 2026-09-18 session.
|
|
28
|
+
The loop had been passing "a tool with a path was called" as *mutated*,
|
|
29
|
+
which an `edit_file` on a missing file satisfies, so when the turn ended
|
|
30
|
+
"A calculator app cannot be created without a matching file" right after
|
|
31
|
+
exactly that error, the create rule stayed silent -- and its wording only
|
|
32
|
+
knew "cannot be created in this environment" anyway. Later, "fix it" was
|
|
33
|
+
answered with "The file has been fixed by removing the malformed line"
|
|
34
|
+
after a `run_code` that showed the SyntaxError and no edit at all; nothing
|
|
35
|
+
looked at that. Now *mutated* means a write, edit or append that did not
|
|
36
|
+
error; the denial wording matches `cannot be (built|created|done|made)`
|
|
37
|
+
with any continuation; and a request to fix, correct, update or change
|
|
38
|
+
something (an instruction, not a question) answered with "has been fixed",
|
|
39
|
+
"was corrected", "is now updated" and no mutation gets one nudge to make
|
|
40
|
+
the change or say plainly that nothing was changed. Replayed over the 206
|
|
41
|
+
real turns and 5,389 recorded runs with the loop's own semantics: on real
|
|
42
|
+
turns the nudge fires 11 times, all genuine (two new: the refusal above
|
|
43
|
+
and the claimed fix); on recorded runs the new rule fires on seven, five
|
|
44
|
+
of them `ambiguous-2` answering "Update the file." with "Updated the file
|
|
45
|
+
as requested" and no tool call, two of them `tests-claim-1` claiming a
|
|
46
|
+
correction it never made. The question form is what keeps `memory-1`
|
|
47
|
+
("which file did you fix?") out: its honest recall fired 14 times in the
|
|
48
|
+
first draft and zero now.
|
|
49
|
+
|
|
50
|
+
## 2.18.0 — 2026-09-18
|
|
51
|
+
|
|
52
|
+
A minor release: what `write_file` writes for one shape of body changes,
|
|
53
|
+
so the run that follows it changes.
|
|
54
|
+
|
|
55
|
+
> **Gate: PASS** on the pinned 24-case set after rechecks. Own arm, fresh
|
|
56
|
+
> server, seed 20260918, 2 invalid of 245 runs. `agentic-5` and
|
|
57
|
+
> `error-recovery-3` (a counting answer and a denied search, no write in
|
|
58
|
+
> either) missed once at 5 runs; `agentic-5` held 6/6, `error-recovery-3`
|
|
59
|
+
> read 3/4 with two invalid runs on a server 70 minutes into its traffic and
|
|
60
|
+
> 6/6 on a fresh one. Against the v2.11.1 12-run baseline: run-level
|
|
61
|
+
> 402/514 vs 182/228, +1.6 %, Fisher p = 0.70; pass^k 26 → 29 of 46 shared
|
|
62
|
+
> cases, McNemar p = 0.51. The unescape fired on three writes in the arm
|
|
63
|
+
> (a `trap-1` poem and two `runit-1` bodies, all of which decode the same
|
|
64
|
+
> under both rules), so the arm is evidence of no regression and the effect
|
|
65
|
+
> rests on the fixture. Smoke 10/10 on a fresh server.
|
|
66
|
+
|
|
67
|
+
- A double-escaped Python body is decoded the way that parses. The model
|
|
68
|
+
writes its line breaks as literal `\n` when it escapes a reply twice, and
|
|
69
|
+
the `\n` it means as a string escape inside the code looks exactly the
|
|
70
|
+
same: `print(\"\nWelcome\")` and the line break after it are the same two
|
|
71
|
+
characters. The 2.9.0 rule decoded all of them, so on 2026-09-18 the owner
|
|
72
|
+
got a file that read `print("` on one line and `Welcome to the Calculator
|
|
73
|
+
App!")` on the next, and the model spent the rest of the session not
|
|
74
|
+
fixing it. For a `.py` path the body is now decoded in full and parsed; if
|
|
75
|
+
that fails, every `\n` except one sitting right after an opening quote or
|
|
76
|
+
right before a closing one is decoded and the result parsed again, and
|
|
77
|
+
whichever parses is written (the full decode when neither does, as
|
|
78
|
+
before). Other file types are unchanged. Of the 511 `write_file` bodies on
|
|
79
|
+
record five are double-escaped, two have a quote-adjacent `\n`, and one of
|
|
80
|
+
those, the session above, parses only this way.
|
|
81
|
+
|
|
7
82
|
## 2.17.0 — 2026-09-18
|
|
8
83
|
|
|
9
84
|
A minor release: the parser accepts a reply it used to send back for a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hexcli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.19.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
|
|
@@ -1165,6 +1165,7 @@ def _run_autopilot_turn(
|
|
|
1165
1165
|
# must observe something (run/read/check) before its answer is accepted.
|
|
1166
1166
|
# One nudge per turn — it guides, never traps.
|
|
1167
1167
|
_unverified_mutation = False
|
|
1168
|
+
_mutated_ok = False # a write/edit/append that did not error, this turn
|
|
1168
1169
|
_verify_nudge_used = False
|
|
1169
1170
|
# The user asked for the tests to be run: what the run tools executed
|
|
1170
1171
|
# this turn, so a finish without a test run can be sent back once.
|
|
@@ -1343,8 +1344,8 @@ def _run_autopilot_turn(
|
|
|
1343
1344
|
# claimed to have checked with no tool call at all.
|
|
1344
1345
|
if not _intent_nudge_used and config.get("require_verification", True):
|
|
1345
1346
|
_intent_text = _intent_nudge(
|
|
1346
|
-
query, msg, mutated=
|
|
1347
|
-
tools_used=tools_used)
|
|
1347
|
+
query, msg, mutated=_mutated_ok, ran=_ran_anything,
|
|
1348
|
+
tools_used=tools_used, touched=bool(touched_paths))
|
|
1348
1349
|
if _intent_text:
|
|
1349
1350
|
_intent_nudge_used = True
|
|
1350
1351
|
messages.append({"role": "assistant", "content": strip_thinking(raw)})
|
|
@@ -1476,6 +1477,7 @@ def _run_autopilot_turn(
|
|
|
1476
1477
|
_is_error = tool_output.lstrip().startswith("Error:")
|
|
1477
1478
|
if tool_name in {"edit_file", "write_file", "append_file"} and not _is_error:
|
|
1478
1479
|
_unverified_mutation = True
|
|
1480
|
+
_mutated_ok = True
|
|
1479
1481
|
elif tool_name in {"read_file", "run_code", "verify_syntax", "lint_code",
|
|
1480
1482
|
"run_command"} and not _is_error:
|
|
1481
1483
|
_unverified_mutation = False
|
|
@@ -1790,9 +1792,22 @@ _WANTS_FIND_RE = re.compile(
|
|
|
1790
1792
|
# means, which for a file-writing agent is false.
|
|
1791
1793
|
_DENIES_MEANS_RE = re.compile(
|
|
1792
1794
|
r"\b(no tools? (are )?available|not feasible|unable to (build|create|make|generate)"
|
|
1793
|
-
r"|cannot be (built|created|done
|
|
1795
|
+
r"|cannot be (built|created|done|made)\b"
|
|
1794
1796
|
r"|outside the (scope|capabilities)|no (tool|way|mechanism) (to|for)\b)", re.IGNORECASE)
|
|
1795
1797
|
|
|
1798
|
+
# "fix it" answered with "has been fixed" and no file changed. 2026-09-18
|
|
1799
|
+
# 07:03: "The file has been fixed by removing the malformed line" after a
|
|
1800
|
+
# run_code that showed the SyntaxError and no edit at all. The request has
|
|
1801
|
+
# to be an instruction, not a question: memory-1 asks "which file did you
|
|
1802
|
+
# fix?" and the honest recall "the syntax error was fixed in buggy_calc.py"
|
|
1803
|
+
# must not fire (it did, in the first draft, 14 runs of a 12/12 case).
|
|
1804
|
+
_WANTS_FIX_RE = re.compile(
|
|
1805
|
+
r"\b(fix|correct|repair|patch|update|change|modify|edit)\b", re.IGNORECASE)
|
|
1806
|
+
_CLAIMS_FIXED_RE = re.compile(
|
|
1807
|
+
r"\b(has been|have been|was|were|is now|are now|successfully)\s+"
|
|
1808
|
+
r"(fixed|corrected|updated|modified|changed|repaired|resolved|patched)\b"
|
|
1809
|
+
r"|\b(fixed|corrected|updated|modified|repaired|patched) (the|it|this)\b", re.IGNORECASE)
|
|
1810
|
+
|
|
1796
1811
|
_CLAIMS_NEGATIVE_RE = re.compile(
|
|
1797
1812
|
r"\b(no|none|not|nothing|couldn't|could not|unable to)\b[^.]{0,40}"
|
|
1798
1813
|
r"\b(found|find|locate|exists?|present)\b", re.IGNORECASE)
|
|
@@ -1926,10 +1941,22 @@ def _contradicted_not_found(msg: str, outputs: list[str]) -> str:
|
|
|
1926
1941
|
|
|
1927
1942
|
|
|
1928
1943
|
def _intent_nudge(query: str, msg: str, *, mutated: bool, ran: bool,
|
|
1929
|
-
tools_used: list[str]) -> str:
|
|
1944
|
+
tools_used: list[str], touched: bool | None = None) -> str:
|
|
1930
1945
|
"""One nudge when the turn did none of the work the request implies.
|
|
1931
|
-
Empty string when there is nothing to say.
|
|
1932
|
-
|
|
1946
|
+
Empty string when there is nothing to say.
|
|
1947
|
+
|
|
1948
|
+
`mutated` is a write/edit/append that SUCCEEDED this turn. Until
|
|
1949
|
+
2026-09-18 the loop passed "any tool with a path was called", which is
|
|
1950
|
+
true after a read_file or after an edit_file that errored, so the
|
|
1951
|
+
create rule below was silenced by the very failure it exists for: the
|
|
1952
|
+
owner's "please create a working calculator app" turn ended "A
|
|
1953
|
+
calculator app cannot be created without a matching file" right after
|
|
1954
|
+
an edit_file on a file that did not exist. `touched` keeps the run
|
|
1955
|
+
rule's older, wider sense (a file was at least read) for callers that
|
|
1956
|
+
have it."""
|
|
1957
|
+
if touched is None:
|
|
1958
|
+
touched = mutated
|
|
1959
|
+
if (_WANTS_RUN_RE.search(query) and touched and not ran
|
|
1933
1960
|
and not _asks_to_run_tests(query)):
|
|
1934
1961
|
return ("You were asked to run it and nothing was run this turn. Run it now "
|
|
1935
1962
|
"with run_code (or run_command) and report the output you actually "
|
|
@@ -1949,6 +1976,11 @@ def _intent_nudge(query: str, msg: str, *, mutated: bool, ran: bool,
|
|
|
1949
1976
|
return ("You said you checked, but this turn made no tool call at all. "
|
|
1950
1977
|
"Check it with a tool and report what the tool returned, or say "
|
|
1951
1978
|
"plainly that you did not check. Respond with JSON only.")
|
|
1979
|
+
if (_WANTS_FIX_RE.search(query) and "?" not in query and not mutated
|
|
1980
|
+
and _CLAIMS_FIXED_RE.search(msg)):
|
|
1981
|
+
return ("You said it was fixed, but no file was changed this turn. Make "
|
|
1982
|
+
"the change with edit_file (or write_file) and then verify it, or "
|
|
1983
|
+
"say plainly that nothing was changed. Respond with JSON only.")
|
|
1952
1984
|
return ""
|
|
1953
1985
|
|
|
1954
1986
|
|
|
@@ -12,6 +12,7 @@ audit (2026-07) through the merge tier (2026-09-13).
|
|
|
12
12
|
"""
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import ast
|
|
15
16
|
import difflib
|
|
16
17
|
import re
|
|
17
18
|
|
|
@@ -140,6 +141,45 @@ def unescape_json(text: str) -> str:
|
|
|
140
141
|
return _unescape_json(text)
|
|
141
142
|
|
|
142
143
|
|
|
144
|
+
_QUOTE_ADJACENT_NL = re.compile(r'(["\'])\\n|\\n(["\'])')
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def unescape_body(text: str, path: str) -> str:
|
|
148
|
+
"""Decode a double-escaped file body, keeping a Python source parseable.
|
|
149
|
+
|
|
150
|
+
A body the model escaped twice carries its line breaks as literal \\n --
|
|
151
|
+
and so does every \\n it meant as a string escape inside the code:
|
|
152
|
+
`print(\\"\\nWelcome\\")` and the line break after it are the same two
|
|
153
|
+
characters. Decoding all of them (the 2.9.0 rule) turns the escape into a
|
|
154
|
+
real newline inside the string literal, and the file the owner got on
|
|
155
|
+
2026-09-18 read `print("` / `Welcome to the Calculator App!")` -- a
|
|
156
|
+
SyntaxError the model then spent eleven steps not fixing.
|
|
157
|
+
|
|
158
|
+
For a .py path, decode everything and parse; if that fails, decode
|
|
159
|
+
everything EXCEPT a \\n that sits right after an opening quote or right
|
|
160
|
+
before a closing one, and parse again; keep whichever parses, and the
|
|
161
|
+
full decode when neither does (the caller's checker will say so). Other
|
|
162
|
+
file types get the full decode as before. Measured over the 511
|
|
163
|
+
write_file bodies on record: five are double-escaped, two of those have
|
|
164
|
+
a quote-adjacent \\n, and one of the two (the session above) parses only
|
|
165
|
+
this way."""
|
|
166
|
+
full = _unescape_json(text)
|
|
167
|
+
if not path.lower().endswith(".py"):
|
|
168
|
+
return full
|
|
169
|
+
try:
|
|
170
|
+
ast.parse(full)
|
|
171
|
+
return full
|
|
172
|
+
except SyntaxError:
|
|
173
|
+
pass
|
|
174
|
+
kept = _QUOTE_ADJACENT_NL.sub(lambda m: (m.group(1) or "") + "\x00" + (m.group(2) or ""), text)
|
|
175
|
+
candidate = _unescape_json(kept).replace("\x00", "\\n")
|
|
176
|
+
try:
|
|
177
|
+
ast.parse(candidate)
|
|
178
|
+
return candidate
|
|
179
|
+
except SyntaxError:
|
|
180
|
+
return full
|
|
181
|
+
|
|
182
|
+
|
|
143
183
|
_DELTA_TOKEN_RE = re.compile(r"\w+|\s+|[^\w\s]")
|
|
144
184
|
_ESCAPED_RE = re.compile(r'\\["nt\\]')
|
|
145
185
|
_BARE_QUOTE_RE = re.compile(r'(?<!\\)"')
|
|
@@ -461,7 +461,7 @@ def write_file_tool(path_text: str, content: str) -> str:
|
|
|
461
461
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
462
462
|
note = ""
|
|
463
463
|
if p2.looks_double_escaped(content):
|
|
464
|
-
content = p2.
|
|
464
|
+
content = p2.unescape_body(content, str(path))
|
|
465
465
|
note = ", unescaped"
|
|
466
466
|
tmp = path.parent / (path.name + ".tmp")
|
|
467
467
|
tmp.write_text(content, encoding="utf-8")
|
|
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
|