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.
Files changed (49) hide show
  1. {hexcli-2.8.1 → hexcli-2.9.0}/CHANGELOG.md +89 -0
  2. {hexcli-2.8.1 → hexcli-2.9.0}/PKG-INFO +1 -1
  3. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/__init__.py +1 -1
  4. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/launcher.py +17 -1
  5. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/loop_v2.py +4 -1
  6. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/protocol_v2.py +333 -19
  7. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/tools.py +13 -2
  8. {hexcli-2.8.1 → hexcli-2.9.0}/.gitignore +0 -0
  9. {hexcli-2.8.1 → hexcli-2.9.0}/Hex CLI.cmd +0 -0
  10. {hexcli-2.8.1 → hexcli-2.9.0}/LICENSE +0 -0
  11. {hexcli-2.8.1 → hexcli-2.9.0}/README.md +0 -0
  12. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/agent.py +0 -0
  13. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/assets/hexcli.ico +0 -0
  14. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/assets/hexcli.png +0 -0
  15. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/cancel.py +0 -0
  16. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/chatlog.py +0 -0
  17. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/commands.py +0 -0
  18. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/compaction.py +0 -0
  19. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/config.py +0 -0
  20. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/diffview.py +0 -0
  21. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/distribution.py +0 -0
  22. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/doctor.py +0 -0
  23. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/escalate.py +0 -0
  24. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/http_client.py +0 -0
  25. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/lineedit.py +0 -0
  26. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/llm.py +0 -0
  27. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/local_escalation.py +0 -0
  28. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/lockfile.py +0 -0
  29. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/markdown_stream.py +0 -0
  30. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/memory.py +0 -0
  31. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/network.py +0 -0
  32. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/parsing.py +0 -0
  33. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/paths.py +0 -0
  34. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/prompts.py +0 -0
  35. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/repl.py +0 -0
  36. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/safety.py +0 -0
  37. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/sessions.py +0 -0
  38. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/setup_wizard.py +0 -0
  39. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/shell_session.py +0 -0
  40. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/statusbar.py +0 -0
  41. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/stream_render.py +0 -0
  42. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/telemetry.py +0 -0
  43. {hexcli-2.8.1 → hexcli-2.9.0}/hexcli/ui.py +0 -0
  44. {hexcli-2.8.1 → hexcli-2.9.0}/install.ps1 +0 -0
  45. {hexcli-2.8.1 → hexcli-2.9.0}/launcher.py +0 -0
  46. {hexcli-2.8.1 → hexcli-2.9.0}/pyproject.toml +0 -0
  47. {hexcli-2.8.1 → hexcli-2.9.0}/shellai.cmd +0 -0
  48. {hexcli-2.8.1 → hexcli-2.9.0}/shellai.example.json +0 -0
  49. {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.8.1
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
@@ -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.8.1"
6
+ __version__ = "2.9.0"
@@ -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=_npurun_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
- ui.tool_event("edit", f"{path} ({len(payload)} block(s))")
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 — high-confidence closest match. Live traces (uc1-t4, 2026-07-30)
395
- # show the 4B model reconstructing lines from memory instead of copying
396
- # them: its old_string lands ~97% similar to exactly one region, and it
397
- # repeats the SAME wrong string on every retry even when the error shows
398
- # the correct text. When the best window is ≥95% similar AND the runner-up
399
- # is clearly worse (<90%), applying to the best window is what the model
400
- # meant; the mandatory verify step catches any residual mismatch. Ties and
401
- # weaker matches still error with the closest-region report.
402
- ratios: list[tuple[float, int]] = []
403
- search_text = "\n".join(s_lines)
404
- for i in range(max(1, len(c_lines) - win + 1)):
405
- cand = "\n".join(c_lines[i:i + win])
406
- r = difflib.SequenceMatcher(None, search_text, cand, autojunk=False).ratio()
407
- ratios.append((r, i))
408
- ratios.sort(reverse=True)
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
- ui.tool_event("edit", f"{path} ({delta:+d} lines)")
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