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.
Files changed (44) hide show
  1. {hexcli-2.17.0 → hexcli-2.19.0}/CHANGELOG.md +75 -0
  2. {hexcli-2.17.0 → hexcli-2.19.0}/PKG-INFO +1 -1
  3. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/__init__.py +1 -1
  4. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/agent.py +38 -6
  5. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/editing.py +40 -0
  6. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/tools.py +1 -1
  7. {hexcli-2.17.0 → hexcli-2.19.0}/.gitignore +0 -0
  8. {hexcli-2.17.0 → hexcli-2.19.0}/Hex CLI.cmd +0 -0
  9. {hexcli-2.17.0 → hexcli-2.19.0}/LICENSE +0 -0
  10. {hexcli-2.17.0 → hexcli-2.19.0}/README.md +0 -0
  11. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/assets/hexcli.ico +0 -0
  12. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/assets/hexcli.png +0 -0
  13. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/cancel.py +0 -0
  14. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/chatlog.py +0 -0
  15. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/commands.py +0 -0
  16. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/compaction.py +0 -0
  17. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/config.py +0 -0
  18. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/diffview.py +0 -0
  19. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/distribution.py +0 -0
  20. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/doctor.py +0 -0
  21. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/http_client.py +0 -0
  22. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/launcher.py +0 -0
  23. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/lineedit.py +0 -0
  24. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/llm.py +0 -0
  25. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/lockfile.py +0 -0
  26. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/markdown_stream.py +0 -0
  27. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/memory.py +0 -0
  28. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/network.py +0 -0
  29. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/parsing.py +0 -0
  30. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/paths.py +0 -0
  31. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/prompts.py +0 -0
  32. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/psparse.py +0 -0
  33. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/repl.py +0 -0
  34. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/safety.py +0 -0
  35. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/sessions.py +0 -0
  36. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/setup_wizard.py +0 -0
  37. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/statusbar.py +0 -0
  38. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/stream_render.py +0 -0
  39. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/telemetry.py +0 -0
  40. {hexcli-2.17.0 → hexcli-2.19.0}/hexcli/ui.py +0 -0
  41. {hexcli-2.17.0 → hexcli-2.19.0}/install.ps1 +0 -0
  42. {hexcli-2.17.0 → hexcli-2.19.0}/launcher.py +0 -0
  43. {hexcli-2.17.0 → hexcli-2.19.0}/pyproject.toml +0 -0
  44. {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.17.0
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
@@ -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.17.0"
6
+ __version__ = "2.19.0"
@@ -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=bool(touched_paths), ran=_ran_anything,
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) (in|within) this environment"
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
- if (_WANTS_RUN_RE.search(query) and mutated and not ran
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.unescape_json(content)
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