hexcli 2.8.1__tar.gz → 2.9.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.8.1 → hexcli-2.9.0}/CHANGELOG.md +89 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/PKG-INFO +1 -1
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/__init__.py +1 -1
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/launcher.py +17 -1
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/loop_v2.py +4 -1
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/protocol_v2.py +333 -19
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/tools.py +13 -2
- {hexcli-2.8.1 → hexcli-2.9.0}/.gitignore +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/Hex CLI.cmd +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/LICENSE +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/README.md +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/agent.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/assets/hexcli.ico +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/assets/hexcli.png +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/cancel.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/chatlog.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/commands.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/compaction.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/config.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/diffview.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/distribution.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/doctor.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/escalate.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/http_client.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/lineedit.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/llm.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/local_escalation.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/lockfile.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/markdown_stream.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/memory.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/network.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/parsing.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/paths.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/prompts.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/repl.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/safety.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/sessions.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/setup_wizard.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/shell_session.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/statusbar.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/stream_render.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/telemetry.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/ui.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/install.ps1 +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/launcher.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/pyproject.toml +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/shellai.cmd +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/shellai.example.json +0 -0
- {hexcli-2.8.1 → hexcli-2.9.0}/shellai.py +0 -0
|
@@ -6,6 +6,95 @@ the Hexagon NPU, not single-run anecdotes.
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 2.9.0 — 2026-09-13
|
|
10
|
+
|
|
11
|
+
A minor release: `edit_file` and `write_file` behave differently for the
|
|
12
|
+
model, and the launcher hands the agent its package path. Gate: extended
|
|
13
|
+
suite at 5 runs PASS after recheck, uc1 at 6 runs, smoke 10/10, CI green.
|
|
14
|
+
|
|
15
|
+
- `edit_file` merges the model's change into the file instead of pasting
|
|
16
|
+
its memory of the file. Multi-turn traces (uc1-t3/t4, 2026-09-13) show
|
|
17
|
+
the 4B rebuilding lines from the traceback in its history rather than
|
|
18
|
+
copying them: `for item in data` where the file says `items`, `"average":
|
|
19
|
+
average` where it says `avg`, a dropped trailing comment. Its
|
|
20
|
+
`old_string` then sits at 40–95 % similarity while the change it wants is
|
|
21
|
+
small and clear (`appned` → `append`; add a key to a dict). The old
|
|
22
|
+
fallback pasted `new_string` over the closest region when similarity was
|
|
23
|
+
≥ 95 %, which installed the misremembered names: five of six live uc1-t4
|
|
24
|
+
runs ended in `NameError: name 'average' is not defined`, put there by the
|
|
25
|
+
harness, and the model then retried the same line four times. The paste
|
|
26
|
+
is gone. `protocol_v2._apply_one` now runs a token-level three-way merge
|
|
27
|
+
between the file's closest region, `old_string` and `new_string`: hunks
|
|
28
|
+
the model changed are applied where the file agrees with what the model
|
|
29
|
+
saw, tokens the model misremembered keep the file's text, and a hunk that
|
|
30
|
+
touches a misremembered token refuses (unless the "change" merely restates
|
|
31
|
+
what the file already says, which is skipped). Inserted lines are
|
|
32
|
+
re-indented by the delta between the model's line and the file's; a
|
|
33
|
+
whitespace run holding a newline never matches one that does not; a
|
|
34
|
+
replacement that would restate the tokens beside its anchor, grow into a
|
|
35
|
+
side the model did not see correctly, or carry JSON escapes into a file
|
|
36
|
+
that has no backslashes, refuses. Every refusal still reports the closest
|
|
37
|
+
region as before. Replaying the saved traces against the fixtures: of 20
|
|
38
|
+
distinct failed uc1-t3 edits 5 now apply correctly, 15 still error, 0
|
|
39
|
+
apply wrongly; of 23 non-exact uc1-t4 attempts 8 apply, 12 refuse, 0
|
|
40
|
+
produce a harmful file (the first draft produced two syntax errors, from
|
|
41
|
+
un-shifted indentation and literal `\"` sequences, both now refused or
|
|
42
|
+
fixed).
|
|
43
|
+
- `edit_file` decodes a double-escaped `old_string`. 58 of 667 saved
|
|
44
|
+
edit_file calls (38 of them errors) carried `\"` or literal `\n` because
|
|
45
|
+
the model escaped its JSON arguments twice (agentic-3: `\"name\":
|
|
46
|
+
\"demo\"` for a file holding `"name": "demo"`). When the raw string is
|
|
47
|
+
absent from the file and the decoded one is present, both strings are
|
|
48
|
+
decoded once and the tiers run again. `write_file` does the same for a
|
|
49
|
+
body that is one line of literal `\n` sequences with no real line break
|
|
50
|
+
(1 of 389 saved calls), and leaves any body with a real newline alone.
|
|
51
|
+
- Audit of the same bug classes elsewhere: protocol v2's `edit` action
|
|
52
|
+
shares the applier and gains all of the above; the JSON action parser
|
|
53
|
+
only tracks escapes to find brace boundaries and never re-escapes; tier 3
|
|
54
|
+
(indent shift) replaces whole lines and has no trailing-segment case;
|
|
55
|
+
`run_command` carried one escaped argument in 303 calls and it was a
|
|
56
|
+
legitimate regex.
|
|
57
|
+
- The edit and write event lines name what landed a non-exact edit
|
|
58
|
+
(`, transfer match`, `, unescaped match`, `, indent match`; `, unescaped`
|
|
59
|
+
on a write) so an arm log shows which calls the fallbacks rescued; the
|
|
60
|
+
model still reads `Edited <path>` / `Wrote <path>`.
|
|
61
|
+
- A checkout's launcher runs the checkout's code. `python -m hexcli.agent`
|
|
62
|
+
resolves the package from the working directory, then site-packages; the
|
|
63
|
+
Terminal profile starts in the home directory, so since the 2.8.0
|
|
64
|
+
packaging the launcher started from `Hex CLI.cmd` was silently running
|
|
65
|
+
the pip-installed copy (2.7.1 on the owner's machine on 2026-09-13,
|
|
66
|
+
found when a new input-line feature did not appear) while the checkout
|
|
67
|
+
sat unused. The launcher now prepends its own package's parent to the
|
|
68
|
+
child's `PYTHONPATH` (`_agent_env`), a no-op for an installed copy.
|
|
69
|
+
- `run_recheck.cmd` documents that the case list must be quoted: cmd splits
|
|
70
|
+
an unquoted comma list into arguments, so the first 2026-09-13 recheck ran
|
|
71
|
+
one case and handed the second case's name to the gate as a baseline path.
|
|
72
|
+
- Live numbers (fresh server per arm, 2026-09-13, a day with under 1 GB
|
|
73
|
+
free on the 16 GB machine). Smoke 10/10. uc1 at 6 runs: t1 6/6, t2 6/6,
|
|
74
|
+
t3 5/6 (3/3 on 09-12, 15/24 pooled over earlier arms; the miss called no
|
|
75
|
+
tool), t4 1/6 (0/3 in every baseline: the first pass ever, on a merged
|
|
76
|
+
insertion beside the file's own `avg`), t5 0/6, t6 0/6 as before; the
|
|
77
|
+
merge landed 13 edits across uc1/uc2 that would have errored. uc2 at 3
|
|
78
|
+
runs: t2 2/3 (the model edited on an explain-only turn; both stray edits
|
|
79
|
+
refused), the rest 3/3. uc3 at 3 runs, twice: every run invalid on both
|
|
80
|
+
attempts, the client's 300 s timeout expiring while the server answered
|
|
81
|
+
429 busy at ~3,200 tokens of context (the 09-12 baseline lost one of
|
|
82
|
+
three the same way; no HTP hang in the server log), so uc3 has no valid
|
|
83
|
+
measurement today. Extended suite at 5 runs, seed 20260913: 31/44 pass^5
|
|
84
|
+
(32/44 on the 2.7.x baseline, 31/41 on 09-05), run-level 162/205 vs
|
|
85
|
+
165/208, Fisher p=1.0; not one fallback tier fired in those 220 runs (the
|
|
86
|
+
event-line markers show none), so the suite is a regression check only.
|
|
87
|
+
Gate RECHECK on agentic-3 (4/5) and self-correct-1 (4/5); recheck at 6
|
|
88
|
+
runs: self-correct-1 5/6, agentic-3 4/6 (two double-escaped runs, one
|
|
89
|
+
rescued by the decode, one landing the model's own comma-less JSON, and
|
|
90
|
+
one old_string hallucinated three times) → gate FAIL; a second agentic-3
|
|
91
|
+
recheck on a fresh server 6/6 → gate PASS. Ceiling panel: +lint-1
|
|
92
|
+
3/5→5/5, +trap-4 0/5→1/5, −regression-anchor-1 5/5→4/5 (the model wrote
|
|
93
|
+
invalid JSON through an exact-match edit). Results:
|
|
94
|
+
`merge_transfer_r5.json`, `multiturn_uc{1,2,3}_merge_*_20260913.json`,
|
|
95
|
+
log `merge_transfer.log`; the earlier anchor-only tier's arm is
|
|
96
|
+
`delta_transfer_r5.json` (gate PASS after recheck, 27/44, superseded).
|
|
97
|
+
|
|
9
98
|
## 2.8.1 — 2026-09-12
|
|
10
99
|
|
|
11
100
|
A patch release: nothing model-facing and nothing the launcher hands the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hexcli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.9.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
|
|
@@ -446,10 +446,26 @@ def run_npurun_path() -> int:
|
|
|
446
446
|
# up the same SDK and Rewind settings, not whatever the shell had.
|
|
447
447
|
return subprocess.run(
|
|
448
448
|
[sys.executable, "-m", "hexcli.agent", "--config", str(NPURUN_CONFIG), *sys.argv[1:]],
|
|
449
|
-
env=
|
|
449
|
+
env=_agent_env(),
|
|
450
450
|
).returncode
|
|
451
451
|
|
|
452
452
|
|
|
453
|
+
def _agent_env() -> dict[str, str]:
|
|
454
|
+
"""The server environment plus the path of THIS package, so the child
|
|
455
|
+
runs the same code as the launcher. `python -m hexcli.agent` resolves
|
|
456
|
+
the package from the working directory first and site-packages next;
|
|
457
|
+
the Terminal profile starts in the home directory, so a checkout's
|
|
458
|
+
launcher was starting the pip-installed copy (2.7.1 on the owner's
|
|
459
|
+
machine on 2026-09-13, with none of the 2.8.x or later changes) while
|
|
460
|
+
the checkout sat unused. Prepending the package's parent to PYTHONPATH
|
|
461
|
+
is a no-op for an installed copy and the fix for a checkout."""
|
|
462
|
+
env = dict(_npurun_env())
|
|
463
|
+
here = str(Path(__file__).resolve().parent.parent)
|
|
464
|
+
prior = env.get("PYTHONPATH", "")
|
|
465
|
+
env["PYTHONPATH"] = here if not prior else f"{here}{os.pathsep}{prior}"
|
|
466
|
+
return env
|
|
467
|
+
|
|
468
|
+
|
|
453
469
|
# Flags the REPL answers on its own; no server needed, so `hex --version`
|
|
454
470
|
# must not start one (or write the runtime config) first.
|
|
455
471
|
_NO_SERVER_FLAGS = frozenset({"-h", "--help", "--version", "--doctor", "--update",
|
|
@@ -157,7 +157,10 @@ def _tool_edit(agent: Any, args: dict[str, Any], payload: list[tuple[str, str]]
|
|
|
157
157
|
tmp = path.parent / (path.name + ".tmp")
|
|
158
158
|
tmp.write_text(new_content, encoding="utf-8")
|
|
159
159
|
tmp.replace(path)
|
|
160
|
-
|
|
160
|
+
from . import protocol_v2 as p2
|
|
161
|
+
tier = p2.LAST_APPLY_TIER
|
|
162
|
+
suffix = "" if tier == "exact" else f", {tier} match"
|
|
163
|
+
ui.tool_event("edit", f"{path} ({len(payload)} block(s){suffix})")
|
|
161
164
|
return f"Edited {path}: {len(payload)} block(s) applied."
|
|
162
165
|
|
|
163
166
|
|
|
@@ -312,6 +312,12 @@ def _parse_fence(rest: str) -> tuple[str | None, str]:
|
|
|
312
312
|
# SEARCH/REPLACE application (fuzzy, precise errors)
|
|
313
313
|
# ---------------------------------------------------------------------------
|
|
314
314
|
|
|
315
|
+
# Tier that landed the most recent successful block: "exact", "whitespace",
|
|
316
|
+
# "indent", "transfer" or "closest". Read by tools.edit_file_tool for its
|
|
317
|
+
# event line so arm logs show which edits the fuzzy tiers rescued.
|
|
318
|
+
LAST_APPLY_TIER = "exact"
|
|
319
|
+
|
|
320
|
+
|
|
315
321
|
def apply_search_replace(content: str, blocks: list[tuple[str, str]]) -> tuple[str | None, str]:
|
|
316
322
|
"""Apply blocks in order. Returns (new_content, "") or (None, error).
|
|
317
323
|
|
|
@@ -332,9 +338,22 @@ def apply_search_replace(content: str, blocks: list[tuple[str, str]]) -> tuple[s
|
|
|
332
338
|
|
|
333
339
|
def _apply_one(content: str, search: str, replace: str, block_no: int) -> tuple[str, str]:
|
|
334
340
|
# Tier 1 — exact.
|
|
341
|
+
global LAST_APPLY_TIER
|
|
335
342
|
count = content.count(search)
|
|
336
343
|
if count == 1:
|
|
344
|
+
LAST_APPLY_TIER = "exact"
|
|
337
345
|
return content.replace(search, replace, 1), ""
|
|
346
|
+
if count == 0 and _ESCAPED_RE.search(search):
|
|
347
|
+
# The model escaped its JSON arguments twice (agentic-3, 2026-09-13:
|
|
348
|
+
# old_string '\"name\": \"demo\"' for a file holding "name": "demo").
|
|
349
|
+
# Decode the JSON escapes once, in both strings, and retry from the top
|
|
350
|
+
# when that form actually exists in the file.
|
|
351
|
+
decoded = _unescape_json(search)
|
|
352
|
+
if decoded != search and decoded in content:
|
|
353
|
+
out, err = _apply_one(content, decoded, _unescape_json(replace), block_no)
|
|
354
|
+
if not err and LAST_APPLY_TIER == "exact":
|
|
355
|
+
LAST_APPLY_TIER = "unescaped"
|
|
356
|
+
return out, err
|
|
338
357
|
if count > 1:
|
|
339
358
|
return "", (f"SEARCH block {block_no} matches {count} locations in the file — "
|
|
340
359
|
"add surrounding lines to make it unique.")
|
|
@@ -356,6 +375,7 @@ def _apply_one(content: str, search: str, replace: str, block_no: int) -> tuple[
|
|
|
356
375
|
if len(hits) == 1:
|
|
357
376
|
i = hits[0]
|
|
358
377
|
new_lines = c_lines[:i] + replace.split("\n") + c_lines[i + win:]
|
|
378
|
+
LAST_APPLY_TIER = "whitespace"
|
|
359
379
|
return "\n".join(new_lines), ""
|
|
360
380
|
if len(hits) > 1:
|
|
361
381
|
return "", (f"SEARCH block {block_no} matches {len(hits)} locations "
|
|
@@ -386,35 +406,329 @@ def _apply_one(content: str, search: str, replace: str, block_no: int) -> tuple[
|
|
|
386
406
|
else:
|
|
387
407
|
adjusted.append(ln[len(delta):] if ln.startswith(delta) else ln)
|
|
388
408
|
new_lines = c_lines[:i] + adjusted + c_lines[i + win:]
|
|
409
|
+
LAST_APPLY_TIER = "indent"
|
|
389
410
|
return "\n".join(new_lines), ""
|
|
390
411
|
if len(hits) > 1:
|
|
391
412
|
return "", (f"SEARCH block {block_no} matches {len(hits)} locations "
|
|
392
413
|
"(ignoring indentation) — add more context lines.")
|
|
393
414
|
|
|
394
|
-
# Tier 4 —
|
|
395
|
-
# show the 4B
|
|
396
|
-
# them:
|
|
397
|
-
#
|
|
398
|
-
#
|
|
399
|
-
#
|
|
400
|
-
#
|
|
401
|
-
#
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
if ratios and ratios[0][0] >= 0.95 and (len(ratios) == 1 or ratios[1][0] < 0.90):
|
|
410
|
-
i = ratios[0][1]
|
|
411
|
-
new_lines = c_lines[:i] + replace.split("\n") + c_lines[i + win:]
|
|
412
|
-
return "\n".join(new_lines), ""
|
|
415
|
+
# Tier 4 — three-way merge against the closest region. Multi-turn traces
|
|
416
|
+
# (uc1-t3/t4, 2026-09-13) show the 4B rebuilding the lines from memory
|
|
417
|
+
# instead of copying them: "for item in data" where the file says "items",
|
|
418
|
+
# "average" where the file says "avg", a dropped trailing comment. Its
|
|
419
|
+
# old_string then lands at 40-95% similarity while the CHANGE it wants
|
|
420
|
+
# (old_string -> new_string) is small and clear. Pasting new_string over
|
|
421
|
+
# the region installs the misremembered names (five of six uc1-t4 runs
|
|
422
|
+
# ended in NameError: 'average' that way), so the change is merged
|
|
423
|
+
# instead: token hunks the model changed are applied where the file
|
|
424
|
+
# agrees with what the model saw, tokens the model misremembered keep the
|
|
425
|
+
# file's text, and a hunk that touches a misremembered token refuses.
|
|
426
|
+
merged = _delta_transfer(content, search, replace)
|
|
427
|
+
if merged is not None:
|
|
428
|
+
LAST_APPLY_TIER = "transfer"
|
|
429
|
+
return merged, ""
|
|
413
430
|
|
|
414
431
|
# No match at any tier — report the closest region.
|
|
415
432
|
return "", _no_match_error(c_lines, s_lines, block_no)
|
|
416
433
|
|
|
417
434
|
|
|
435
|
+
def unescape_json(text: str) -> str:
|
|
436
|
+
"""Decode one level of JSON escapes (\\" \\n \\t \\\\)."""
|
|
437
|
+
return _unescape_json(text)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
_DELTA_TOKEN_RE = re.compile(r"\w+|\s+|[^\w\s]")
|
|
441
|
+
_ESCAPED_RE = re.compile(r'\\["nt\\]')
|
|
442
|
+
_DELTA_MAX_HUNKS = 4
|
|
443
|
+
_DELTA_MAX_REPLACED_CHARS = 40
|
|
444
|
+
_DELTA_MAX_INSERTED_CHARS = 400
|
|
445
|
+
_DELTA_MIN_MATCHED = 0.6 # fraction of old_string's tokens found in the window
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def looks_double_escaped(text: str) -> bool:
|
|
449
|
+
"""A file body with two or more literal backslash-n sequences and not a
|
|
450
|
+
single real line break is JSON escaped a second time (write_file,
|
|
451
|
+
persona_guard 2026-09-12: 'import re\\n\\n\\n# Regex …' written as one
|
|
452
|
+
line). A real one-line file that spells "\\n" on purpose is rarer than the
|
|
453
|
+
model's habit; anything with a genuine newline is left alone."""
|
|
454
|
+
return "\n" not in text and text.count("\\n") >= 2
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _unescape_json(text: str) -> str:
|
|
458
|
+
return _ESCAPED_RE.sub(lambda m: {'"': '"', "n": "\n", "t": "\t", "\\": "\\"}[m.group(0)[1]], text)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _keys(toks: list[str]) -> list[str]:
|
|
462
|
+
"""Token keys for matching: whitespace runs compare equal to each other,
|
|
463
|
+
but a run holding a newline never equals one that does not (a line break
|
|
464
|
+
the model wrote must not be absorbed by the file's indentation)."""
|
|
465
|
+
return [("\n" if "\n" in t else " ") if t.isspace() else t for t in toks]
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _delta_transfer(content: str, search: str, replace: str) -> str | None:
|
|
469
|
+
"""Merge the search->replace change into content's closest region, or None.
|
|
470
|
+
|
|
471
|
+
Three sequences: old (what the model believes the region says), new (what
|
|
472
|
+
it wants), window (what the file says). Hunks of old->new are applied to
|
|
473
|
+
the window where the hunk's tokens are equal between old and window; the
|
|
474
|
+
rest of the window is kept verbatim, so nothing the model misremembered
|
|
475
|
+
reaches the file. Refusals, each a documented failure shape: more than
|
|
476
|
+
four hunks; a replaced fragment over 40 chars or an insertion over 400;
|
|
477
|
+
no window at 60% token similarity or a tie between windows; a replaced
|
|
478
|
+
token the file does not have at that spot (unless the "change" merely
|
|
479
|
+
restates the file, which is skipped); a hunk with no stable neighbour on
|
|
480
|
+
either side; a hunk that grows on a side the model did not see correctly;
|
|
481
|
+
an insertion whose anchor sits next to tokens the model never saw; a
|
|
482
|
+
replacement that restates the tokens already beside it.
|
|
483
|
+
"""
|
|
484
|
+
while search.endswith("\n") and replace.endswith("\n"):
|
|
485
|
+
search, replace = search[:-1], replace[:-1]
|
|
486
|
+
s_tok = _DELTA_TOKEN_RE.findall(search)
|
|
487
|
+
r_tok = _DELTA_TOKEN_RE.findall(replace)
|
|
488
|
+
if not s_tok or "".join(s_tok) != search or "".join(r_tok) != replace:
|
|
489
|
+
return None
|
|
490
|
+
s_keys = _keys(s_tok)
|
|
491
|
+
b_ops = [op for op in difflib.SequenceMatcher(None, s_keys, _keys(r_tok), autojunk=False).get_opcodes()
|
|
492
|
+
if op[0] != "equal"]
|
|
493
|
+
if not b_ops or len(b_ops) > _DELTA_MAX_HUNKS:
|
|
494
|
+
return None
|
|
495
|
+
for _, i1, i2, j1, j2 in b_ops:
|
|
496
|
+
if (len("".join(s_tok[i1:i2])) > _DELTA_MAX_REPLACED_CHARS
|
|
497
|
+
or len("".join(r_tok[j1:j2])) > _DELTA_MAX_INSERTED_CHARS):
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
window = _delta_window(content, s_keys, search.count("\n") + 1)
|
|
501
|
+
if window is None:
|
|
502
|
+
return None
|
|
503
|
+
start, end, w_tok = window
|
|
504
|
+
eq: dict[int, int] = {}
|
|
505
|
+
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, s_keys, _keys(w_tok), autojunk=False).get_opcodes():
|
|
506
|
+
if tag == "equal":
|
|
507
|
+
for k in range(i2 - i1):
|
|
508
|
+
eq[i1 + k] = j1 + k
|
|
509
|
+
|
|
510
|
+
edits: list[tuple[int, int, str]] = []
|
|
511
|
+
for tag, i1, i2, j1, j2 in b_ops:
|
|
512
|
+
after = "".join(r_tok[j1:j2])
|
|
513
|
+
if tag == "insert":
|
|
514
|
+
span = _delta_insert_span(s_tok, w_tok, eq, i1)
|
|
515
|
+
else:
|
|
516
|
+
i1, i2, after = _delta_trim(s_tok, i1, i2, after)
|
|
517
|
+
if i1 >= i2:
|
|
518
|
+
return None
|
|
519
|
+
span = _delta_replace_span(s_tok, w_tok, eq, i1, i2, after)
|
|
520
|
+
if span is None:
|
|
521
|
+
return None
|
|
522
|
+
if span == ():
|
|
523
|
+
continue # the "change" restates the file; nothing to do
|
|
524
|
+
ws, we = span
|
|
525
|
+
if _delta_duplicates_neighbour(w_tok, ws, we, after):
|
|
526
|
+
return None
|
|
527
|
+
if _ESCAPED_RE.search(after) and "\\" not in "".join(w_tok):
|
|
528
|
+
# The model escaped part of its arguments a second time; the file
|
|
529
|
+
# holds no backslashes, so those would land literally (a uc1-t4
|
|
530
|
+
# attempt wrote '\\"median\\": median' into Python).
|
|
531
|
+
return None
|
|
532
|
+
if "\n" in after:
|
|
533
|
+
after = _delta_reindent(after, s_tok, i1, w_tok, ws)
|
|
534
|
+
if ws == we and ws > 0 and "\n" in w_tok[ws - 1]:
|
|
535
|
+
lead = _DELTA_TOKEN_RE.match(after)
|
|
536
|
+
if lead and lead.group(0).isspace() and "\n" in lead.group(0) and not after.endswith(("\n", w_tok[ws - 1])):
|
|
537
|
+
# Inserted before a line-leading anchor with the file's
|
|
538
|
+
# own line break already in place: the insert's leading
|
|
539
|
+
# break belongs after its text, not before it.
|
|
540
|
+
after = after[lead.end():] + w_tok[ws - 1]
|
|
541
|
+
edits.append((ws, we, after))
|
|
542
|
+
if not edits:
|
|
543
|
+
return None
|
|
544
|
+
edits.sort()
|
|
545
|
+
for (_, a_end, _), (b_start, _, _) in zip(edits, edits[1:]):
|
|
546
|
+
if a_end > b_start:
|
|
547
|
+
return None
|
|
548
|
+
out = list(w_tok)
|
|
549
|
+
for ws, we, after in reversed(edits):
|
|
550
|
+
out[ws:we] = [after]
|
|
551
|
+
return content[:start] + "".join(out) + content[end:]
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _delta_window(content: str, s_keys: list[str], height: int) -> tuple[int, int, list[str]] | None:
|
|
555
|
+
"""The unique best line window (height-1..height+1) by token similarity.
|
|
556
|
+
|
|
557
|
+
Ranked by the symmetric ratio (so the tightest window wins over a taller
|
|
558
|
+
one holding the same matches); a tie on that ratio refuses. The floor is
|
|
559
|
+
the fraction of old_string's own tokens found in the window, so a short
|
|
560
|
+
fragment (" appned(item)") against a long line still qualifies while
|
|
561
|
+
unrelated text does not.
|
|
562
|
+
"""
|
|
563
|
+
c_lines = content.split("\n")
|
|
564
|
+
offsets = [0]
|
|
565
|
+
for ln in c_lines:
|
|
566
|
+
offsets.append(offsets[-1] + len(ln) + 1)
|
|
567
|
+
best: tuple[float, float, int, int, list[str]] | None = None
|
|
568
|
+
tie = False
|
|
569
|
+
for h in sorted({max(1, height - 1), height, height + 1}):
|
|
570
|
+
for i in range(0, max(1, len(c_lines) - h + 1)):
|
|
571
|
+
w_tok = _DELTA_TOKEN_RE.findall("\n".join(c_lines[i:i + h]))
|
|
572
|
+
if not w_tok:
|
|
573
|
+
continue
|
|
574
|
+
sm = difflib.SequenceMatcher(None, s_keys, _keys(w_tok), autojunk=False)
|
|
575
|
+
r = sm.ratio()
|
|
576
|
+
if best is None or r > best[0] + 1e-9:
|
|
577
|
+
matched = sum(b.size for b in sm.get_matching_blocks()) / len(s_keys)
|
|
578
|
+
best, tie = (r, matched, i, h, w_tok), False
|
|
579
|
+
elif abs(r - best[0]) <= 1e-9:
|
|
580
|
+
tie = True
|
|
581
|
+
if best is None or tie or best[1] < _DELTA_MIN_MATCHED:
|
|
582
|
+
return None
|
|
583
|
+
r, matched, i, h, w_tok = best
|
|
584
|
+
return offsets[i], offsets[i + h] - 1, w_tok
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _line_indent_at(toks: list[str], i: int) -> str:
|
|
588
|
+
"""Leading whitespace of the line holding token i."""
|
|
589
|
+
for k in range(min(i, len(toks) - 1), -1, -1):
|
|
590
|
+
t = toks[k]
|
|
591
|
+
if "\n" in t:
|
|
592
|
+
return t[t.rfind("\n") + 1:]
|
|
593
|
+
return toks[0] if toks and toks[0].isspace() and "\n" not in toks[0] else ""
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _delta_reindent(after: str, s_tok: list[str], i1: int, w_tok: list[str], ws: int) -> str:
|
|
597
|
+
"""Shift the lines after the first newline of `after` by the indent delta
|
|
598
|
+
between the model's line and the file's line (the model wrote the whole
|
|
599
|
+
function four spaces deeper in one uc1-t4 attempt; its inserted lines
|
|
600
|
+
must follow the file, not its memory)."""
|
|
601
|
+
old_ind = _line_indent_at(s_tok, i1)
|
|
602
|
+
win_ind = _line_indent_at(w_tok, ws)
|
|
603
|
+
if old_ind == win_ind:
|
|
604
|
+
return after
|
|
605
|
+
first, rest = after.split("\n", 1)
|
|
606
|
+
lines = rest.split("\n")
|
|
607
|
+
out = []
|
|
608
|
+
for n, ln in enumerate(lines):
|
|
609
|
+
last = n == len(lines) - 1
|
|
610
|
+
if ln.startswith(old_ind) and (ln.strip() or last):
|
|
611
|
+
# A blank middle line stays blank; the trailing segment is the
|
|
612
|
+
# indent of the line that follows and shifts like the others.
|
|
613
|
+
out.append(win_ind + ln[len(old_ind):])
|
|
614
|
+
else:
|
|
615
|
+
out.append(ln)
|
|
616
|
+
return first + "\n" + "\n".join(out)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _delta_trim(s_tok: list[str], i1: int, i2: int, after: str) -> tuple[int, int, str]:
|
|
620
|
+
"""Drop whitespace tokens from the edges of a hunk; a space is no anchor."""
|
|
621
|
+
while i1 < i2 and s_tok[i1].isspace():
|
|
622
|
+
i1 += 1
|
|
623
|
+
after = after.lstrip() if after[:1].isspace() else after
|
|
624
|
+
while i2 > i1 and s_tok[i2 - 1].isspace():
|
|
625
|
+
i2 -= 1
|
|
626
|
+
after = after.rstrip() if after[-1:].isspace() else after
|
|
627
|
+
return i1, i2, after
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _nonws_left(toks: list[str], i: int) -> int | None:
|
|
631
|
+
for k in range(i - 1, -1, -1):
|
|
632
|
+
if not toks[k].isspace():
|
|
633
|
+
return k
|
|
634
|
+
return None
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _nonws_right(toks: list[str], i: int) -> int | None:
|
|
638
|
+
for k in range(i, len(toks)):
|
|
639
|
+
if not toks[k].isspace():
|
|
640
|
+
return k
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _gap_is_ws(w_tok: list[str], a: int, b: int) -> bool:
|
|
645
|
+
"""True when only whitespace sits strictly between window indices a and b."""
|
|
646
|
+
return all(w_tok[k].isspace() for k in range(a + 1, b))
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _delta_replace_span(s_tok: list[str], w_tok: list[str], eq: dict[int, int],
|
|
650
|
+
i1: int, i2: int, after: str) -> tuple[int, int] | tuple[()] | None:
|
|
651
|
+
idx = [k for k in range(i1, i2) if not s_tok[k].isspace()]
|
|
652
|
+
if not idx:
|
|
653
|
+
return None
|
|
654
|
+
lft = _nonws_left(s_tok, i1)
|
|
655
|
+
rgt = _nonws_right(s_tok, i2)
|
|
656
|
+
after_n = sum(1 for t in _DELTA_TOKEN_RE.findall(after) if not t.isspace())
|
|
657
|
+
if any(k not in eq for k in idx):
|
|
658
|
+
# The model is changing a token the file does not have there. Allowed
|
|
659
|
+
# only when its "change" reproduces what the file already says
|
|
660
|
+
# (uc1-t4 fixture: old "cnt", new "count", file "count") — then the
|
|
661
|
+
# hunk is a no-op. Anything else is a conflict.
|
|
662
|
+
if lft is None or rgt is None or lft not in eq or rgt not in eq:
|
|
663
|
+
return None
|
|
664
|
+
if any(not s_tok[k].isspace() for k in range(lft + 1, i1)) or \
|
|
665
|
+
any(not s_tok[k].isspace() for k in range(i2, rgt)):
|
|
666
|
+
return None
|
|
667
|
+
between = [t for t in w_tok[eq[lft] + 1:eq[rgt]] if not t.isspace()]
|
|
668
|
+
wanted = [t for t in _DELTA_TOKEN_RE.findall(after) if not t.isspace()]
|
|
669
|
+
return () if between == wanted else None
|
|
670
|
+
pos = [eq[k] for k in idx]
|
|
671
|
+
for a, b in zip(pos, pos[1:]):
|
|
672
|
+
if b <= a or not _gap_is_ws(w_tok, a, b):
|
|
673
|
+
return None
|
|
674
|
+
left_stable = lft is not None and lft in eq and _gap_is_ws(w_tok, eq[lft], pos[0])
|
|
675
|
+
right_stable = rgt is not None and rgt in eq and _gap_is_ws(w_tok, pos[-1], eq[rgt])
|
|
676
|
+
if not (left_stable or right_stable):
|
|
677
|
+
return None
|
|
678
|
+
if not (left_stable and right_stable) and after_n > len(idx):
|
|
679
|
+
# One side is text the model did not see correctly: a token swap is
|
|
680
|
+
# safe, growing into that side is not ("appned(" -> "result.append("
|
|
681
|
+
# beside a "result." the file already has).
|
|
682
|
+
return None
|
|
683
|
+
return pos[0], pos[-1] + 1
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _delta_insert_span(s_tok: list[str], w_tok: list[str], eq: dict[int, int], b: int) -> tuple[int, int] | None:
|
|
687
|
+
lft = _nonws_left(s_tok, b)
|
|
688
|
+
rgt = _nonws_right(s_tok, b)
|
|
689
|
+
left_ok = lft is not None and lft in eq
|
|
690
|
+
right_ok = rgt is not None and rgt in eq
|
|
691
|
+
if left_ok and right_ok:
|
|
692
|
+
if not _gap_is_ws(w_tok, eq[lft], eq[rgt]):
|
|
693
|
+
return None # the file has tokens between them the model never saw
|
|
694
|
+
return eq[rgt], eq[rgt]
|
|
695
|
+
if right_ok:
|
|
696
|
+
w = eq[rgt]
|
|
697
|
+
if lft is None and not all(t.isspace() for t in w_tok[:w]):
|
|
698
|
+
return None # the region starts with tokens the model never saw
|
|
699
|
+
return w, w
|
|
700
|
+
if left_ok:
|
|
701
|
+
w = eq[lft] + 1
|
|
702
|
+
if rgt is None and not all(t.isspace() for t in w_tok[w:]):
|
|
703
|
+
return None
|
|
704
|
+
return w, w
|
|
705
|
+
return None
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _delta_duplicates_neighbour(w_tok: list[str], ws: int, we: int, after: str) -> bool:
|
|
709
|
+
"""True when `after` restates what already sits beside the span on its line."""
|
|
710
|
+
a_tok = [t for t in _DELTA_TOKEN_RE.findall(after) if not t.isspace()]
|
|
711
|
+
if len(a_tok) < 2:
|
|
712
|
+
return False
|
|
713
|
+
pre: list[str] = []
|
|
714
|
+
for k in range(ws - 1, -1, -1):
|
|
715
|
+
if "\n" in w_tok[k]:
|
|
716
|
+
break
|
|
717
|
+
if not w_tok[k].isspace():
|
|
718
|
+
pre.append(w_tok[k])
|
|
719
|
+
pre.reverse()
|
|
720
|
+
post: list[str] = []
|
|
721
|
+
for k in range(we, len(w_tok)):
|
|
722
|
+
if "\n" in w_tok[k]:
|
|
723
|
+
break
|
|
724
|
+
if not w_tok[k].isspace():
|
|
725
|
+
post.append(w_tok[k])
|
|
726
|
+
for n in range(1, len(a_tok)):
|
|
727
|
+
if (pre and pre[-n:] == a_tok[:n]) or (post and post[:n] == a_tok[-n:]):
|
|
728
|
+
return True
|
|
729
|
+
return False
|
|
730
|
+
|
|
731
|
+
|
|
418
732
|
def _no_match_error(c_lines: list[str], s_lines: list[str], block_no: int) -> str:
|
|
419
733
|
win = len(s_lines)
|
|
420
734
|
best_ratio, best_i = 0.0, 0
|
|
@@ -350,6 +350,7 @@ def edit_file_tool(path_text: str, old_string: str, new_string: str) -> str:
|
|
|
350
350
|
is still an error, never a guess, and a genuine no-match now reports the
|
|
351
351
|
closest region with line numbers so the retry has something to work with.
|
|
352
352
|
"""
|
|
353
|
+
from . import protocol_v2 as p2
|
|
353
354
|
from .protocol_v2 import apply_search_replace
|
|
354
355
|
|
|
355
356
|
path = resolve_path(path_text)
|
|
@@ -359,28 +360,38 @@ def edit_file_tool(path_text: str, old_string: str, new_string: str) -> str:
|
|
|
359
360
|
if not path.exists():
|
|
360
361
|
raise RuntimeError(f"File not found: {path}")
|
|
361
362
|
content = path.read_text(encoding="utf-8")
|
|
363
|
+
tier = "exact"
|
|
362
364
|
if content.count(old_string) == 1:
|
|
363
365
|
new_content = content.replace(old_string, new_string, 1)
|
|
364
366
|
else:
|
|
365
367
|
new_content, err = apply_search_replace(content, [(old_string, new_string)])
|
|
366
368
|
if err:
|
|
367
369
|
raise RuntimeError(err.replace("SEARCH block 1", "old_string"))
|
|
370
|
+
tier = p2.LAST_APPLY_TIER
|
|
368
371
|
tmp = path.parent / (path.name + ".tmp")
|
|
369
372
|
tmp.write_text(new_content, encoding="utf-8")
|
|
370
373
|
tmp.replace(path)
|
|
371
374
|
delta = new_string.count("\n") - old_string.count("\n")
|
|
372
|
-
|
|
375
|
+
# The tier is UI-only (arm logs read it); the model sees "Edited <path>".
|
|
376
|
+
suffix = "" if tier == "exact" else f", {tier} match"
|
|
377
|
+
ui.tool_event("edit", f"{path} ({delta:+d} lines{suffix})")
|
|
373
378
|
return f"Edited {path}"
|
|
374
379
|
|
|
375
380
|
|
|
376
381
|
def write_file_tool(path_text: str, content: str) -> str:
|
|
382
|
+
from . import protocol_v2 as p2
|
|
383
|
+
|
|
377
384
|
path = resolve_path(path_text)
|
|
378
385
|
guard_mutation(path, "write_file", _active_config())
|
|
379
386
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
387
|
+
note = ""
|
|
388
|
+
if p2.looks_double_escaped(content):
|
|
389
|
+
content = p2.unescape_json(content)
|
|
390
|
+
note = ", unescaped"
|
|
380
391
|
tmp = path.parent / (path.name + ".tmp")
|
|
381
392
|
tmp.write_text(content, encoding="utf-8")
|
|
382
393
|
tmp.replace(path)
|
|
383
|
-
ui.tool_event("write", f"{path} ({len(content)} chars)")
|
|
394
|
+
ui.tool_event("write", f"{path} ({len(content)} chars{note})")
|
|
384
395
|
return f"Wrote {path}"
|
|
385
396
|
|
|
386
397
|
|
|
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
|