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.
- {hexcli-2.9.1 → hexcli-2.10.0}/.gitignore +4 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/CHANGELOG.md +36 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/PKG-INFO +1 -1
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/__init__.py +1 -1
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/agent.py +5 -2
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/lineedit.py +7 -3
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/parsing.py +79 -30
- {hexcli-2.9.1 → hexcli-2.10.0}/Hex CLI.cmd +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/LICENSE +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/README.md +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/assets/hexcli.ico +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/assets/hexcli.png +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/cancel.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/chatlog.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/commands.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/compaction.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/config.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/diffview.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/distribution.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/doctor.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/escalate.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/http_client.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/launcher.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/llm.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/local_escalation.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/lockfile.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/loop_v2.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/markdown_stream.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/memory.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/network.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/paths.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/prompts.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/protocol_v2.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/repl.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/safety.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/sessions.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/setup_wizard.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/shell_session.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/statusbar.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/stream_render.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/telemetry.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/tools.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/hexcli/ui.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/install.ps1 +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/launcher.py +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/pyproject.toml +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/shellai.cmd +0 -0
- {hexcli-2.9.1 → hexcli-2.10.0}/shellai.example.json +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
"
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
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
|
|
File without changes
|
|
File without changes
|