hexcli 2.16.0__tar.gz → 2.18.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.16.0 → hexcli-2.18.0}/CHANGELOG.md +67 -0
  2. {hexcli-2.16.0 → hexcli-2.18.0}/PKG-INFO +1 -1
  3. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/__init__.py +1 -1
  4. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/editing.py +40 -0
  5. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/parsing.py +49 -4
  6. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/tools.py +1 -1
  7. {hexcli-2.16.0 → hexcli-2.18.0}/.gitignore +0 -0
  8. {hexcli-2.16.0 → hexcli-2.18.0}/Hex CLI.cmd +0 -0
  9. {hexcli-2.16.0 → hexcli-2.18.0}/LICENSE +0 -0
  10. {hexcli-2.16.0 → hexcli-2.18.0}/README.md +0 -0
  11. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/agent.py +0 -0
  12. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/assets/hexcli.ico +0 -0
  13. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/assets/hexcli.png +0 -0
  14. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/cancel.py +0 -0
  15. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/chatlog.py +0 -0
  16. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/commands.py +0 -0
  17. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/compaction.py +0 -0
  18. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/config.py +0 -0
  19. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/diffview.py +0 -0
  20. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/distribution.py +0 -0
  21. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/doctor.py +0 -0
  22. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/http_client.py +0 -0
  23. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/launcher.py +0 -0
  24. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/lineedit.py +0 -0
  25. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/llm.py +0 -0
  26. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/lockfile.py +0 -0
  27. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/markdown_stream.py +0 -0
  28. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/memory.py +0 -0
  29. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/network.py +0 -0
  30. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/paths.py +0 -0
  31. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/prompts.py +0 -0
  32. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/psparse.py +0 -0
  33. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/repl.py +0 -0
  34. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/safety.py +0 -0
  35. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/sessions.py +0 -0
  36. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/setup_wizard.py +0 -0
  37. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/statusbar.py +0 -0
  38. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/stream_render.py +0 -0
  39. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/telemetry.py +0 -0
  40. {hexcli-2.16.0 → hexcli-2.18.0}/hexcli/ui.py +0 -0
  41. {hexcli-2.16.0 → hexcli-2.18.0}/install.ps1 +0 -0
  42. {hexcli-2.16.0 → hexcli-2.18.0}/launcher.py +0 -0
  43. {hexcli-2.16.0 → hexcli-2.18.0}/pyproject.toml +0 -0
  44. {hexcli-2.16.0 → hexcli-2.18.0}/shellai.example.json +0 -0
@@ -4,6 +4,73 @@ 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.18.0 — 2026-09-18
8
+
9
+ A minor release: what `write_file` writes for one shape of body changes,
10
+ so the run that follows it changes.
11
+
12
+ > **Gate: PASS** on the pinned 24-case set after rechecks. Own arm, fresh
13
+ > server, seed 20260918, 2 invalid of 245 runs. `agentic-5` and
14
+ > `error-recovery-3` (a counting answer and a denied search, no write in
15
+ > either) missed once at 5 runs; `agentic-5` held 6/6, `error-recovery-3`
16
+ > read 3/4 with two invalid runs on a server 70 minutes into its traffic and
17
+ > 6/6 on a fresh one. Against the v2.11.1 12-run baseline: run-level
18
+ > 402/514 vs 182/228, +1.6 %, Fisher p = 0.70; pass^k 26 → 29 of 46 shared
19
+ > cases, McNemar p = 0.51. The unescape fired on three writes in the arm
20
+ > (a `trap-1` poem and two `runit-1` bodies, all of which decode the same
21
+ > under both rules), so the arm is evidence of no regression and the effect
22
+ > rests on the fixture. Smoke 10/10 on a fresh server.
23
+
24
+ - A double-escaped Python body is decoded the way that parses. The model
25
+ writes its line breaks as literal `\n` when it escapes a reply twice, and
26
+ the `\n` it means as a string escape inside the code looks exactly the
27
+ same: `print(\"\nWelcome\")` and the line break after it are the same two
28
+ characters. The 2.9.0 rule decoded all of them, so on 2026-09-18 the owner
29
+ got a file that read `print("` on one line and `Welcome to the Calculator
30
+ App!")` on the next, and the model spent the rest of the session not
31
+ fixing it. For a `.py` path the body is now decoded in full and parsed; if
32
+ that fails, every `\n` except one sitting right after an opening quote or
33
+ right before a closing one is decoded and the result parsed again, and
34
+ whichever parses is written (the full decode when neither does, as
35
+ before). Other file types are unchanged. Of the 511 `write_file` bodies on
36
+ record five are double-escaped, two have a quote-adjacent `\n`, and one of
37
+ those, the session above, parses only this way.
38
+
39
+ ## 2.17.0 — 2026-09-18
40
+
41
+ A minor release: the parser accepts a reply it used to send back for a
42
+ retry, so what the model sees after such a reply changes.
43
+
44
+ > **Gate: PASS** on the pinned 24-case set, all 24 held at 5 runs with no
45
+ > recheck. Own arm, fresh server, seed 20260918, 6 invalid of 245 runs.
46
+ > Against the v2.11.1 12-run baseline: run-level 402/514 vs 174/224, −0.5 %,
47
+ > Fisher p = 0.92; pass^k 26 → 31 of 46 shared cases, McNemar p = 0.13. The
48
+ > close fired on five replies in the arm (`claims-1`, `claims-2`, two in
49
+ > `tests-claim-1`, one in `trap-3`); `claims-1` 3/5 against 3/11, `trap-3`
50
+ > 0/5 as before. Smoke 9/10 on a fresh server; the miss was `factual-1`,
51
+ > re-measured 7/10 against its recorded 79 % (p = 0.67).
52
+
53
+ - A reply that stops before its last closing brace is closed and decoded.
54
+ In the owner's 2026-09-18 session two `write_file` replies, 2,239 and 782
55
+ characters, ended at `"}` with the outer object never closed. The parser's
56
+ stray-quote repair took the decoder's "Expecting ',' delimiter" at the end
57
+ of the text as a quote that had ended a string early, escaped the
58
+ content's own closing quote, and the decoder then reported an unterminated
59
+ string; the retry feedback told the model to fix its quoting, the model
60
+ escaped everything twice, and the file it wrote had `\n` and `\"` in it as
61
+ text. Every step after that in the session was the model failing to repair
62
+ that file. When the decoder's error sits at the very end of the reply,
63
+ every string is closed and the brace depth is still positive, the missing
64
+ braces are appended and the object decodes. Measured over the 10,772
65
+ replies on record: 185 fail to decode, and 27 of them now decode (all one
66
+ brace short; 23 in `claims-1`, `claims-2` and `tests-claim-1`, four in the
67
+ owner's sessions). The close is only tried on the reply as sent: three
68
+ more replies are a brace short *after* a stray quote, and a reply whose
69
+ content string was cut off and then followed by a second object would
70
+ otherwise be glued into one, so those stay retries. A reply cut off inside
71
+ a string is still reported as cut off, and a stray quote in the middle is
72
+ still repaired as before; the other 158 undecodable replies are unchanged.
73
+
7
74
  ## 2.16.0 — 2026-09-18
8
75
 
9
76
  A minor release: a tool result the model reads changes (`find_files` returns
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hexcli
3
- Version: 2.16.0
3
+ Version: 2.18.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.16.0"
6
+ __version__ = "2.18.0"
@@ -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'(?<!\\)"')
@@ -126,6 +126,49 @@ def _repair_stray_quote(text: str, err: json.JSONDecodeError) -> str | None:
126
126
  return text[:q] + "\\" + text[q:]
127
127
 
128
128
 
129
+ def _close_unfinished_object(text: str, start: int, err: json.JSONDecodeError) -> str | None:
130
+ """The reply ended before its last closing brace(s): `{"action":"write_file",
131
+ "args":{...,"content":"..."}` and then nothing. The decoder reports
132
+ "Expecting ',' delimiter" AT THE END of the text; every string is closed
133
+ and the brace depth is still positive. Appending the missing braces is
134
+ the whole repair. 30 of the 185 undecodable replies on record are this
135
+ shape, all one brace short, four of them in the owner's sessions
136
+ (2026-09-13 and 2026-09-18), and the stray-quote repair below used to
137
+ make them worse: it escaped the content's own closing quote, the decoder
138
+ then said "Unterminated string", and the retry feedback told the model
139
+ to fix its quoting -- which it did by escaping everything twice, and the
140
+ file it then wrote had backslashes in it. None when the error is not at
141
+ the end, a string is still open, or the braces already balance."""
142
+ if err.pos < len(text.rstrip()):
143
+ return None
144
+ depth, in_string, escaped = 0, False, False
145
+ for ch in text[start:]:
146
+ if in_string:
147
+ if escaped:
148
+ escaped = False
149
+ elif ch == "\\":
150
+ escaped = True
151
+ elif ch == '"':
152
+ in_string = False
153
+ elif ch == '"':
154
+ in_string = True
155
+ elif ch == "{":
156
+ depth += 1
157
+ elif ch == "}":
158
+ depth -= 1
159
+ if in_string or depth <= 0:
160
+ return None
161
+ return text.rstrip() + "}" * depth
162
+
163
+
164
+ # Only the reply AS SENT is closed this way. Once a stray-quote repair has
165
+ # changed the text, a positive depth at the end can be the repair's own
166
+ # doing (a write whose content string was cut off, followed by a second
167
+ # object: the repairs walk the quotes back until the text "balances" and
168
+ # the close would then glue the two objects into one), and that reply must
169
+ # stay a retry -- evals/test_backports.py pins it.
170
+
171
+
129
172
  def _loads_object(text: str) -> dict[str, Any] | None:
130
173
  """The first JSON object in `text`, decoded from its first brace and
131
174
  ignoring whatever follows it (a second batched action, prose). Stray
@@ -133,12 +176,13 @@ def _loads_object(text: str) -> dict[str, Any] | None:
133
176
  start = text.find("{")
134
177
  if start < 0:
135
178
  return None
136
- for _ in range(_MAX_QUOTE_REPAIRS + 1):
179
+ for attempt in range(_MAX_QUOTE_REPAIRS + 1):
137
180
  try:
138
181
  parsed, _end = _DECODER.raw_decode(text, start)
139
182
  return parsed if isinstance(parsed, dict) else None
140
183
  except json.JSONDecodeError as err:
141
- fixed = _repair_stray_quote(text, err)
184
+ fixed = (_close_unfinished_object(text, start, err) if attempt == 0 else None) \
185
+ or _repair_stray_quote(text, err)
142
186
  if fixed is None:
143
187
  return None
144
188
  text = fixed
@@ -155,13 +199,14 @@ def _decode_failure(text: str) -> tuple[json.JSONDecodeError | None, str]:
155
199
  if start < 0:
156
200
  return None, text
157
201
  last: json.JSONDecodeError | None = None
158
- for _ in range(_MAX_QUOTE_REPAIRS + 1):
202
+ for attempt in range(_MAX_QUOTE_REPAIRS + 1):
159
203
  try:
160
204
  _DECODER.raw_decode(text, start)
161
205
  return None, text
162
206
  except json.JSONDecodeError as err:
163
207
  last = err
164
- fixed = _repair_stray_quote(text, err)
208
+ fixed = (_close_unfinished_object(text, start, err) if attempt == 0 else None) \
209
+ or _repair_stray_quote(text, err)
165
210
  if fixed is None:
166
211
  return err, text
167
212
  text = fixed
@@ -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