hexcli 2.9.1__tar.gz → 2.10.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 (49) hide show
  1. {hexcli-2.9.1 → hexcli-2.10.0}/.gitignore +4 -0
  2. {hexcli-2.9.1 → hexcli-2.10.0}/CHANGELOG.md +36 -0
  3. {hexcli-2.9.1 → hexcli-2.10.0}/PKG-INFO +1 -1
  4. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/__init__.py +1 -1
  5. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/agent.py +5 -2
  6. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/lineedit.py +7 -3
  7. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/parsing.py +79 -30
  8. {hexcli-2.9.1 → hexcli-2.10.0}/Hex CLI.cmd +0 -0
  9. {hexcli-2.9.1 → hexcli-2.10.0}/LICENSE +0 -0
  10. {hexcli-2.9.1 → hexcli-2.10.0}/README.md +0 -0
  11. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/assets/hexcli.ico +0 -0
  12. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/assets/hexcli.png +0 -0
  13. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/cancel.py +0 -0
  14. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/chatlog.py +0 -0
  15. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/commands.py +0 -0
  16. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/compaction.py +0 -0
  17. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/config.py +0 -0
  18. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/diffview.py +0 -0
  19. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/distribution.py +0 -0
  20. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/doctor.py +0 -0
  21. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/escalate.py +0 -0
  22. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/http_client.py +0 -0
  23. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/launcher.py +0 -0
  24. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/llm.py +0 -0
  25. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/local_escalation.py +0 -0
  26. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/lockfile.py +0 -0
  27. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/loop_v2.py +0 -0
  28. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/markdown_stream.py +0 -0
  29. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/memory.py +0 -0
  30. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/network.py +0 -0
  31. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/paths.py +0 -0
  32. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/prompts.py +0 -0
  33. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/protocol_v2.py +0 -0
  34. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/repl.py +0 -0
  35. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/safety.py +0 -0
  36. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/sessions.py +0 -0
  37. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/setup_wizard.py +0 -0
  38. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/shell_session.py +0 -0
  39. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/statusbar.py +0 -0
  40. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/stream_render.py +0 -0
  41. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/telemetry.py +0 -0
  42. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/tools.py +0 -0
  43. {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/ui.py +0 -0
  44. {hexcli-2.9.1 → hexcli-2.10.0}/install.ps1 +0 -0
  45. {hexcli-2.9.1 → hexcli-2.10.0}/launcher.py +0 -0
  46. {hexcli-2.9.1 → hexcli-2.10.0}/pyproject.toml +0 -0
  47. {hexcli-2.9.1 → hexcli-2.10.0}/shellai.cmd +0 -0
  48. {hexcli-2.9.1 → hexcli-2.10.0}/shellai.example.json +0 -0
  49. {hexcli-2.9.1 → hexcli-2.10.0}/shellai.py +0 -0
@@ -56,3 +56,7 @@ tools/backend_bench/.battery_probe.ps1
56
56
 
57
57
  # python -m build output
58
58
  dist/
59
+
60
+ # Local-only notes: research surveys and working documents that are not user-facing.
61
+ # Only the paper (docs/paper) and user-facing docs are committed (owner rule, 2026-09-14).
62
+ docs/local/
@@ -6,6 +6,42 @@ the Hexagon NPU, not single-run anecdotes.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 2.10.0 — 2026-09-14
10
+
11
+ A minor release: the retry feedback the model reads changed. Gate: extended
12
+ suite at 5 runs, seed 20260914, 32/44 pass^5 (the 2.7.x baseline is 32/44), run-level
13
+ 159/205 vs 165/208 (p=0.72); one gate case missed once and passed its 6-run
14
+ recheck; CI green on main and the tag.
15
+
16
+ - A reply whose first JSON object does not decode is never "finished" by a
17
+ later object in the same reply. The owner's 2026-09-13 session: asked for
18
+ a calculator page, the model answered with a `write_file` holding 1.6K of
19
+ HTML and, behind it, a `finish` saying the file was created. Fifteen
20
+ attribute quotes (`onclick=\"input('7')">`) were unescaped, the string
21
+ closed early, the object failed to decode, the parser moved on to the
22
+ next complete object, accepted the finish, and the turn claimed a file it
23
+ never wrote; the next turn found nothing to open. `parse_json_object` now
24
+ decodes the first object from the first brace with `raw_decode` (batched
25
+ actions still take the first and let the loop drive the rest), repairs a
26
+ stray quote inside a string value up to sixty-four times by escaping the
27
+ last unescaped quote before the decoder's error (a truncated string is
28
+ left alone), accepts raw control characters inside strings, and returns
29
+ nothing when the first object still fails, so the loop's existing retry
30
+ fires. That retry now tells the model the decoder's complaint, the
31
+ character offset, the text around it and the quoting rule instead of
32
+ "not valid JSON". The session's reply, verbatim, is a fixture: it decodes
33
+ to the write with all 1,466 characters of HTML.
34
+ - The `/` command menu sits above the input row, between the top rule and
35
+ the prompt. The box is pinned to the window's last rows, so the rows
36
+ 2.9.1 added below the input pushed the input row and the caret up
37
+ whenever the menu appeared or changed height; rows above the input grow
38
+ the box upward and the caret stays where it is.
39
+ - `run_arm.cmd` stops when the suite exits non-zero. An aborted suite (six
40
+ consecutive backend timeouts on a starved machine, 2026-09-13) left the
41
+ previous arm's results file in place, and the script copied it as the
42
+ candidate and gated it, which read as a RECHECK verdict against stale
43
+ data. It now logs the abort and writes no candidate.
44
+
9
45
  ## 2.9.1 — 2026-09-13
10
46
 
11
47
  A patch release: the input line and the status bar; nothing model-facing
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hexcli
3
- Version: 2.9.1
3
+ Version: 2.10.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.9.1"
6
+ __version__ = "2.10.0"
@@ -1289,9 +1289,12 @@ def _run_autopilot_turn(
1289
1289
  "one JSON object. No prose."
1290
1290
  )
1291
1291
  else:
1292
+ detail = parsing.describe_json_error(raw)
1292
1293
  feedback = (
1293
- "Your response was not valid JSON. "
1294
- "Respond with exactly one JSON object as specified. No prose."
1294
+ "Your response was not valid JSON"
1295
+ + (f": {detail}. Inside a string value every double quote must be "
1296
+ "written as \\\". " if detail else ". ")
1297
+ + "Respond with exactly one JSON object as specified. No prose."
1295
1298
  )
1296
1299
  messages.append({"role": "assistant", "content": strip_thinking(raw)})
1297
1300
  messages.append({"role": "user", "content": feedback})
@@ -629,6 +629,12 @@ class LineEditor:
629
629
  logical: list[tuple[str, int]] = [] # (rendered text, visible width)
630
630
  for line in above:
631
631
  logical.append((line, visible_len(line)))
632
+ # The / menu sits between the top rule and the input row. The box is
633
+ # pinned to the window's last rows, so rows added BELOW the input
634
+ # pushed the input row and the caret up when the menu appeared; rows
635
+ # above it grow the box upward and the caret stays put.
636
+ menu = self._menu_rows() if chrome else []
637
+ logical.extend(menu)
632
638
  for line in prompt_lines[:-1]:
633
639
  logical.append((line, visible_len(line)))
634
640
  last_prompt = prompt_lines[-1]
@@ -647,8 +653,6 @@ class LineEditor:
647
653
  text, vis = logical[-1]
648
654
  styled = f"\033[2m{ghost}\033[0m" if self.styled else ghost
649
655
  logical[-1] = (text + styled, vis + len(ghost))
650
- if chrome:
651
- logical.extend(self._menu_rows())
652
656
  for line in below:
653
657
  logical.append((line, visible_len(line)))
654
658
 
@@ -656,7 +660,7 @@ class LineEditor:
656
660
  before = self.buffer[:self.pos]
657
661
  cur_line = before.count("\n")
658
662
  col_in_line = len(before) - (before.rfind("\n") + 1)
659
- cursor_logical = len(above) + len(prompt_lines) - 1 + cur_line
663
+ cursor_logical = len(above) + len(menu) + len(prompt_lines) - 1 + cur_line
660
664
  cursor_vis = visible_len(prefixes[cur_line]) + col_in_line
661
665
 
662
666
  pieces: list[str] = []
@@ -93,43 +93,92 @@ def strip_thinking(text: str) -> str:
93
93
  # Parsing
94
94
  # ---------------------------------------------------------------------------
95
95
 
96
+ _STRAY_QUOTE_MSGS = ("Expecting ',' delimiter", "Expecting ':' delimiter",
97
+ "Expecting property name")
98
+ _MAX_QUOTE_REPAIRS = 64
99
+ _DECODER = json.JSONDecoder(strict=False) # raw newlines inside a written file are fine
100
+
101
+
102
+ def _escaped_at(text: str, i: int) -> bool:
103
+ """Is the character at i preceded by an odd run of backslashes?"""
104
+ n = 0
105
+ while i - 1 - n >= 0 and text[i - 1 - n] == "\\":
106
+ n += 1
107
+ return n % 2 == 1
108
+
109
+
110
+ def _repair_stray_quote(text: str, err: json.JSONDecodeError) -> str | None:
111
+ """Escape the quote that ended a string early. A 4B writing 1.6K of HTML
112
+ inside a JSON string forgets the backslash on a closing attribute quote
113
+ (`onclick=\\"input('7')">`, sixteen times in one reply, 2026-09-13): the
114
+ string closes there and the decoder trips on the next token. The last
115
+ unescaped quote before the error is that closer; escape it and let the
116
+ caller decode again. A truncated string (\"Unterminated string\") is not
117
+ repairable and is left alone."""
118
+ if not any(err.msg.startswith(m) for m in _STRAY_QUOTE_MSGS):
119
+ return None
120
+ q = text.rfind('"', 0, err.pos)
121
+ while q > 0 and _escaped_at(text, q):
122
+ q = text.rfind('"', 0, q)
123
+ if q <= 0:
124
+ return None
125
+ return text[:q] + "\\" + text[q:]
126
+
127
+
128
+ def _loads_object(text: str) -> dict[str, Any] | None:
129
+ """The first JSON object in `text`, decoded from its first brace and
130
+ ignoring whatever follows it (a second batched action, prose). Stray
131
+ quotes inside string values are repaired a bounded number of times."""
132
+ start = text.find("{")
133
+ if start < 0:
134
+ return None
135
+ for _ in range(_MAX_QUOTE_REPAIRS + 1):
136
+ try:
137
+ parsed, _end = _DECODER.raw_decode(text, start)
138
+ return parsed if isinstance(parsed, dict) else None
139
+ except json.JSONDecodeError as err:
140
+ fixed = _repair_stray_quote(text, err)
141
+ if fixed is None:
142
+ return None
143
+ text = fixed
144
+ return None
145
+
146
+
147
+ def describe_json_error(raw_text: str) -> str:
148
+ """What is wrong with the first JSON object in a reply, for the retry
149
+ feedback: the decoder's message, the character offset and the text
150
+ around it. Empty when the object decodes."""
151
+ text = strip_thinking(raw_text).strip()
152
+ start = max(0, text.find("{"))
153
+ try:
154
+ _DECODER.raw_decode(text, start)
155
+ return ""
156
+ except json.JSONDecodeError as err:
157
+ lo, hi = max(0, err.pos - 40), min(len(text), err.pos + 20)
158
+ return f"{err.msg} at character {err.pos - start}, near: {text[lo:hi]!r}"
159
+
160
+
96
161
  def parse_json_object(raw_text: str) -> dict[str, Any] | None:
97
162
  text = strip_thinking(raw_text).strip()
98
163
  if not text:
99
164
  return None
100
165
  # Direct parse
101
- try:
102
- parsed = json.loads(text)
103
- if isinstance(parsed, dict):
104
- return parsed
105
- except json.JSONDecodeError:
106
- pass
166
+ parsed = _loads_object(text)
167
+ if parsed is not None:
168
+ return parsed
107
169
  # Strip markdown fences
108
170
  stripped = re.sub(r"^```[a-zA-Z]*\s*|```\s*$", "", text, flags=re.MULTILINE).strip()
109
- try:
110
- parsed = json.loads(stripped)
111
- if isinstance(parsed, dict):
112
- return parsed
113
- except json.JSONDecodeError:
114
- pass
115
- # Extract the FIRST complete {...} object by brace balancing.
116
- #
117
- # This used to be a greedy re.search(r"\{.*\}"), which spans from the first
118
- # brace to the LAST one. When the model emits several actions in a row —
119
- # {"action":"edit_file",…},{"action":"verify_syntax",…},{"action":"run_code",…}
120
- # — that match is not valid JSON, so the whole response was discarded, the
121
- # identical retry was issued up to 3×, and the turn ended with no tool call
122
- # at all. Measured 2026-07-30: this is what actually killed uc1-t4/t5/t6
123
- # (0/3 each), NOT a context-length cliff. Batching is a natural response to
124
- # rules that prescribe an edit→verify→run sequence, so take the first
125
- # action and let the loop drive the rest.
126
- for candidate in _iter_json_objects(text):
127
- try:
128
- parsed = json.loads(candidate)
129
- if isinstance(parsed, dict):
130
- return parsed
131
- except json.JSONDecodeError:
132
- continue
171
+ parsed = _loads_object(stripped)
172
+ if parsed is not None:
173
+ return parsed
174
+ # Nothing decodable from the first brace. Batched actions ({edit}{verify}
175
+ # {run}) are handled above: raw_decode takes the FIRST object and ignores
176
+ # the rest, and the loop drives the next step (measured 2026-07-30: the
177
+ # old greedy match discarded such replies and killed uc1-t4/t5/t6). A
178
+ # broken first object is a malformed reply, never skipped for a later
179
+ # one: on 2026-09-13 the finish behind an unparseable write_file was
180
+ # accepted and the turn claimed a file it had not written. The loop
181
+ # retries with the decoder's complaint (describe_json_error).
133
182
  return None
134
183
 
135
184
 
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
File without changes