loom-code 0.1.1__py3-none-any.whl
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.
- loom_code/__init__.py +22 -0
- loom_code/_post_commit.py +119 -0
- loom_code/agent.py +544 -0
- loom_code/approval.py +616 -0
- loom_code/browse/__init__.py +291 -0
- loom_code/browse/act.py +467 -0
- loom_code/browse/observe.py +249 -0
- loom_code/browse/session.py +96 -0
- loom_code/browse/verify.py +194 -0
- loom_code/checkpoint.py +283 -0
- loom_code/cli.py +495 -0
- loom_code/code_index.py +703 -0
- loom_code/compact.py +143 -0
- loom_code/consent.py +47 -0
- loom_code/credentials.py +527 -0
- loom_code/edit_tool.py +635 -0
- loom_code/extensions.py +522 -0
- loom_code/file_history.py +322 -0
- loom_code/file_tools.py +93 -0
- loom_code/git_hook.py +200 -0
- loom_code/grep_tool.py +430 -0
- loom_code/hooks.py +297 -0
- loom_code/loominit/__init__.py +23 -0
- loom_code/loominit/_ast_walk.py +429 -0
- loom_code/loominit/_files.py +284 -0
- loom_code/loominit/_graph.py +141 -0
- loom_code/loominit/_resolve.py +392 -0
- loom_code/loominit/_tests_map.py +108 -0
- loom_code/loominit/extractor.py +332 -0
- loom_code/loominit/repomap.py +225 -0
- loom_code/loominit/schema.py +242 -0
- loom_code/lsp_tools.py +396 -0
- loom_code/mcp_host.py +79 -0
- loom_code/operator.py +449 -0
- loom_code/paste.py +97 -0
- loom_code/paths.py +52 -0
- loom_code/permissions.py +177 -0
- loom_code/project.py +104 -0
- loom_code/prompts.py +451 -0
- loom_code/render.py +783 -0
- loom_code/repl.py +4080 -0
- loom_code/rules.py +267 -0
- loom_code/sandboxed_bash.py +176 -0
- loom_code/scribe.py +88 -0
- loom_code/skills/__init__.py +16 -0
- loom_code/skills/graphify/SKILL.md +97 -0
- loom_code/skills/graphify/tools.py +570 -0
- loom_code/trust.py +216 -0
- loom_code/turn.py +169 -0
- loom_code/web_fetch.py +370 -0
- loom_code/workers.py +758 -0
- loom_code/worktree.py +134 -0
- loom_code-0.1.1.dist-info/METADATA +224 -0
- loom_code-0.1.1.dist-info/RECORD +58 -0
- loom_code-0.1.1.dist-info/WHEEL +5 -0
- loom_code-0.1.1.dist-info/entry_points.txt +2 -0
- loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
- loom_code-0.1.1.dist-info/top_level.txt +1 -0
loom_code/edit_tool.py
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"""Wrapped ``edit`` tool — surfaces post-edit file state so the
|
|
2
|
+
agent can self-correct when its replacement was malformed.
|
|
3
|
+
|
|
4
|
+
Why this exists: loomflow's stock ``edit_tool`` returns just
|
|
5
|
+
``"edited X bytes (Y → Z)"``. The agent has no visibility into
|
|
6
|
+
what the file ACTUALLY looks like after the edit, so when the
|
|
7
|
+
model writes a malformed ``new_string`` (e.g. accidentally
|
|
8
|
+
preserving the old code AND inserting new code, observed in a
|
|
9
|
+
real REPL session on ``silently_swallow`` → got two ``except``
|
|
10
|
+
blocks coexisting), the agent assumes success and moves on.
|
|
11
|
+
|
|
12
|
+
This wrapper forwards every call to the underlying edit_tool and
|
|
13
|
+
then appends an EDIT PREVIEW window — the edited region plus
|
|
14
|
+
±10 lines of surrounding context — to the tool result. Next-turn
|
|
15
|
+
the agent sees what the file looks like and can issue a
|
|
16
|
+
correcting edit instead of declaring victory.
|
|
17
|
+
|
|
18
|
+
Also runs an ``ast.parse`` check for ``.py`` files and surfaces
|
|
19
|
+
syntax errors as a clear warning (loomflow's edit_tool doesn't
|
|
20
|
+
validate; you can edit a Python file into a syntactically broken
|
|
21
|
+
state silently).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
import difflib
|
|
28
|
+
import json
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from loomflow import tool
|
|
33
|
+
from loomflow.tools import edit_tool as _loomflow_edit_tool
|
|
34
|
+
from loomflow.tools.registry import Tool
|
|
35
|
+
|
|
36
|
+
from .grep_tool import _as_bool
|
|
37
|
+
from .paths import is_within, resolve_path
|
|
38
|
+
|
|
39
|
+
# How many lines of context to show around the edited region in
|
|
40
|
+
# the EDIT PREVIEW. Chosen so the model can usually see the whole
|
|
41
|
+
# function being edited; bigger windows just bloat the tool result.
|
|
42
|
+
_CONTEXT_LINES = 10
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _find_edit_region(
|
|
46
|
+
old_text: str, new_text: str
|
|
47
|
+
) -> tuple[int, int]:
|
|
48
|
+
"""Return ``(start_line, end_line)`` (1-indexed, inclusive)
|
|
49
|
+
of the region in ``new_text`` that differs from ``old_text``.
|
|
50
|
+
|
|
51
|
+
Crude line-diff: walk both line lists in parallel, find the
|
|
52
|
+
first divergence, then walk backward from the ends to find
|
|
53
|
+
the last divergence. Good enough for the typical edit
|
|
54
|
+
(single contiguous change). For multi-region edits this just
|
|
55
|
+
picks the bounding window, which is the right thing to show
|
|
56
|
+
anyway.
|
|
57
|
+
"""
|
|
58
|
+
old_lines = old_text.splitlines()
|
|
59
|
+
new_lines = new_text.splitlines()
|
|
60
|
+
|
|
61
|
+
# Find first divergence.
|
|
62
|
+
start = 0
|
|
63
|
+
while (
|
|
64
|
+
start < len(old_lines)
|
|
65
|
+
and start < len(new_lines)
|
|
66
|
+
and old_lines[start] == new_lines[start]
|
|
67
|
+
):
|
|
68
|
+
start += 1
|
|
69
|
+
|
|
70
|
+
# Find last divergence — walk from the end inward.
|
|
71
|
+
o_end = len(old_lines) - 1
|
|
72
|
+
n_end = len(new_lines) - 1
|
|
73
|
+
while (
|
|
74
|
+
o_end >= start
|
|
75
|
+
and n_end >= start
|
|
76
|
+
and old_lines[o_end] == new_lines[n_end]
|
|
77
|
+
):
|
|
78
|
+
o_end -= 1
|
|
79
|
+
n_end -= 1
|
|
80
|
+
return (start + 1, n_end + 1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _render_preview(
|
|
84
|
+
file_path: Path, new_text: str, start_line: int, end_line: int
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Render the edited region + ±N lines of context with line
|
|
87
|
+
numbers. A ▸ marker on each line in the edit window so the
|
|
88
|
+
agent can quickly see what changed."""
|
|
89
|
+
lines = new_text.splitlines()
|
|
90
|
+
if not lines:
|
|
91
|
+
return ""
|
|
92
|
+
show_start = max(1, start_line - _CONTEXT_LINES)
|
|
93
|
+
show_end = min(len(lines), end_line + _CONTEXT_LINES)
|
|
94
|
+
out: list[str] = [
|
|
95
|
+
f"--- EDIT PREVIEW: {file_path.name} "
|
|
96
|
+
f"(lines {show_start}-{show_end} of {len(lines)}) ---"
|
|
97
|
+
]
|
|
98
|
+
for i in range(show_start, show_end + 1):
|
|
99
|
+
line = lines[i - 1].rstrip()
|
|
100
|
+
marker = "▸ " if start_line <= i <= end_line else " "
|
|
101
|
+
out.append(f"{marker}{i:4d} │ {line}")
|
|
102
|
+
out.append("--- END EDIT PREVIEW ---")
|
|
103
|
+
return "\n".join(out)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _syntax_check_python(path: Path, text: str) -> str | None:
|
|
107
|
+
"""Return a warning string if ``text`` is invalid Python, else
|
|
108
|
+
None. Only fires for ``.py`` files — silent for everything
|
|
109
|
+
else.
|
|
110
|
+
|
|
111
|
+
We don't BLOCK the edit on syntax failure because the agent
|
|
112
|
+
sometimes intentionally edits a file into a transient broken
|
|
113
|
+
state (e.g. mid-refactor), but we surface the error so it
|
|
114
|
+
notices on this turn instead of debugging in 5 turns."""
|
|
115
|
+
if path.suffix != ".py":
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
ast.parse(text)
|
|
119
|
+
return None
|
|
120
|
+
except SyntaxError as exc:
|
|
121
|
+
return (
|
|
122
|
+
f"⚠ WARNING: file is no longer syntactically valid "
|
|
123
|
+
f"Python after this edit. {type(exc).__name__}: "
|
|
124
|
+
f"{exc.msg} (line {exc.lineno or '?'}). The edit was "
|
|
125
|
+
"applied; consider correcting it on the next turn."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def verifying_edit_tool(workdir: Path | str) -> Tool:
|
|
130
|
+
"""Build the loom-code edit tool.
|
|
131
|
+
|
|
132
|
+
Same signature as loomflow's edit_tool:
|
|
133
|
+
``edit(path, old_string, new_string, replace_all=False)``
|
|
134
|
+
|
|
135
|
+
Differences in the tool result:
|
|
136
|
+
- On success: appends an ``EDIT PREVIEW`` block showing the
|
|
137
|
+
edited region + ±10 lines of context with line numbers.
|
|
138
|
+
- On Python syntax-break: appends a ``⚠ WARNING`` line so
|
|
139
|
+
the agent immediately knows the file is broken.
|
|
140
|
+
- On failure (file not found, no match, etc.): returns the
|
|
141
|
+
underlying error verbatim. No preview, no extra noise.
|
|
142
|
+
"""
|
|
143
|
+
root = Path(workdir).resolve()
|
|
144
|
+
anchor = root.anchor or "/"
|
|
145
|
+
# Two delegates: the project-rooted one keeps in-project results
|
|
146
|
+
# clean (``edited sample.py``, not the absolute path); the
|
|
147
|
+
# anchor-rooted one handles genuinely-outside files the user
|
|
148
|
+
# referenced (an absolute path never escapes the "/" root, so its
|
|
149
|
+
# own workdir check passes — the outside decision is made HERE via
|
|
150
|
+
# consent). Which one + which path to pass is chosen per call.
|
|
151
|
+
inner = _loomflow_edit_tool(workdir=root)
|
|
152
|
+
inner_fs = _loomflow_edit_tool(workdir=Path(anchor))
|
|
153
|
+
|
|
154
|
+
def _delegate_for(target: Path) -> tuple[Any, str]:
|
|
155
|
+
"""Pick (inner_tool, path_to_pass) for a resolved target: the
|
|
156
|
+
short project-relative form when inside, else the anchor-
|
|
157
|
+
stripped absolute form via the "/"-rooted delegate."""
|
|
158
|
+
if is_within(target, root):
|
|
159
|
+
return inner, str(target.relative_to(root))
|
|
160
|
+
rel = str(target)
|
|
161
|
+
return inner_fs, (
|
|
162
|
+
rel[len(anchor):] if rel.startswith(anchor) else rel
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
async def edit(
|
|
166
|
+
path: str,
|
|
167
|
+
old_string: str,
|
|
168
|
+
new_string: str,
|
|
169
|
+
replace_all: bool = False,
|
|
170
|
+
) -> str:
|
|
171
|
+
"""Find-and-replace inside an existing file; returns the
|
|
172
|
+
edit summary PLUS a preview of the file after the edit
|
|
173
|
+
so the agent can self-correct malformed replacements.
|
|
174
|
+
See module docstring for the full contract."""
|
|
175
|
+
# Coerce ``replace_all`` — the tool-call layer may send the
|
|
176
|
+
# STRING "true"/"false" instead of a bool, and a non-empty
|
|
177
|
+
# "false" string is truthy, which would silently replace
|
|
178
|
+
# ALL occurrences when the model meant just one.
|
|
179
|
+
replace_all = _as_bool(replace_all, default=False)
|
|
180
|
+
# Resolve ~ / relative / absolute the same way read does.
|
|
181
|
+
target = resolve_path(path, root)
|
|
182
|
+
if not is_within(target, root):
|
|
183
|
+
# Outside the project — allowed ONLY when the user
|
|
184
|
+
# explicitly referenced this file in the session ("the
|
|
185
|
+
# user naming a path IS the permission"); the approval
|
|
186
|
+
# gate still previews the diff either way.
|
|
187
|
+
from .consent import is_granted
|
|
188
|
+
|
|
189
|
+
if not is_granted(target):
|
|
190
|
+
return (
|
|
191
|
+
f"edit: refusing to edit {path} — it is outside "
|
|
192
|
+
"the project and the user has not referenced it. "
|
|
193
|
+
"Only files the user pasted or @-mentioned may be "
|
|
194
|
+
"edited outside the project."
|
|
195
|
+
)
|
|
196
|
+
delegate, inner_path = _delegate_for(target)
|
|
197
|
+
if not target.is_file():
|
|
198
|
+
# Defer to loomflow's error message for consistency.
|
|
199
|
+
return await delegate.fn(
|
|
200
|
+
path=inner_path,
|
|
201
|
+
old_string=old_string,
|
|
202
|
+
new_string=new_string,
|
|
203
|
+
replace_all=replace_all,
|
|
204
|
+
)
|
|
205
|
+
old_text = target.read_text(
|
|
206
|
+
encoding="utf-8", errors="replace"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Delegate the actual replace + write.
|
|
210
|
+
result = await delegate.fn(
|
|
211
|
+
path=inner_path,
|
|
212
|
+
old_string=old_string,
|
|
213
|
+
new_string=new_string,
|
|
214
|
+
replace_all=replace_all,
|
|
215
|
+
)
|
|
216
|
+
# loomflow's edit_tool signals errors via leading "ERROR:".
|
|
217
|
+
# On error, return verbatim — no preview when the edit
|
|
218
|
+
# didn't change anything.
|
|
219
|
+
if str(result).startswith("ERROR"):
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
# Read post-edit content + build preview.
|
|
223
|
+
new_text = target.read_text(
|
|
224
|
+
encoding="utf-8", errors="replace"
|
|
225
|
+
)
|
|
226
|
+
start_line, end_line = _find_edit_region(old_text, new_text)
|
|
227
|
+
preview = _render_preview(
|
|
228
|
+
target, new_text, start_line, end_line
|
|
229
|
+
)
|
|
230
|
+
warn = _syntax_check_python(target, new_text)
|
|
231
|
+
parts: list[str] = [result, "", preview]
|
|
232
|
+
if warn:
|
|
233
|
+
parts.append("")
|
|
234
|
+
parts.append(warn)
|
|
235
|
+
return "\n".join(parts)
|
|
236
|
+
|
|
237
|
+
return tool(
|
|
238
|
+
name="edit",
|
|
239
|
+
description=(
|
|
240
|
+
"Find-and-replace inside an existing file. Same as "
|
|
241
|
+
"loomflow's edit but the tool result includes an "
|
|
242
|
+
"EDIT PREVIEW window showing the edited region + ±10 "
|
|
243
|
+
"lines of context AFTER the edit, so you can verify "
|
|
244
|
+
"the replacement looks right and self-correct on the "
|
|
245
|
+
"next turn if it doesn't. Also warns when an edit "
|
|
246
|
+
"leaves a .py file syntactically invalid. Args: path "
|
|
247
|
+
"(relative), old_string (must match exactly), "
|
|
248
|
+
"new_string, replace_all=False."
|
|
249
|
+
),
|
|
250
|
+
# ``destructive=True`` matches loomflow's edit_tool default
|
|
251
|
+
# so the ``ApprovalGate`` continues to fire before the
|
|
252
|
+
# write. Without this, every edit auto-approves even when
|
|
253
|
+
# the user opted into the gate — silently weakens the
|
|
254
|
+
# destructive-action safety contract (caught by
|
|
255
|
+
# tests/test_approval_integration.py).
|
|
256
|
+
destructive=True,
|
|
257
|
+
)(edit)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _loads_lenient(text: str) -> Any:
|
|
261
|
+
"""Parse a model-serialised string into a Python object, tolerating
|
|
262
|
+
BOTH JSON (double quotes) and Python-repr (single quotes) — weak
|
|
263
|
+
models emit either, and ``json.loads`` rejects the single-quote
|
|
264
|
+
form. Raises ``ValueError`` when neither parses."""
|
|
265
|
+
try:
|
|
266
|
+
return json.loads(text)
|
|
267
|
+
except json.JSONDecodeError:
|
|
268
|
+
pass
|
|
269
|
+
try:
|
|
270
|
+
return ast.literal_eval(text)
|
|
271
|
+
except (ValueError, SyntaxError) as exc:
|
|
272
|
+
raise ValueError(str(exc)) from exc
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _coerce_edits(value: Any) -> list[dict[str, str]] | str:
|
|
276
|
+
"""Coerce the model's serialisation of the ``edits`` list into
|
|
277
|
+
a native list of ``{old_string, new_string}`` dicts. Returns
|
|
278
|
+
the list, or an error string the tool returns verbatim.
|
|
279
|
+
|
|
280
|
+
Weak models serialise list-of-objects args inconsistently — a
|
|
281
|
+
JSON string, a list of JSON strings, a Python-repr (single-quote)
|
|
282
|
+
string, a dict with an ``edits`` key — so we salvage every shape,
|
|
283
|
+
same lenient approach ``plan_write`` uses for its ``steps`` arg.
|
|
284
|
+
"""
|
|
285
|
+
if isinstance(value, str):
|
|
286
|
+
try:
|
|
287
|
+
value = _loads_lenient(value)
|
|
288
|
+
except ValueError:
|
|
289
|
+
return (
|
|
290
|
+
"ERROR: `edits` must be a list of "
|
|
291
|
+
"{old_string, new_string} objects (or a JSON "
|
|
292
|
+
"string of one). Couldn't parse the string given."
|
|
293
|
+
)
|
|
294
|
+
if isinstance(value, dict):
|
|
295
|
+
# ``{"edits": [...]}`` wrapper, or a single edit dict.
|
|
296
|
+
value = value.get("edits", [value])
|
|
297
|
+
if not isinstance(value, list):
|
|
298
|
+
return (
|
|
299
|
+
"ERROR: `edits` must be a list of "
|
|
300
|
+
f"{{old_string, new_string}} objects. Got "
|
|
301
|
+
f"{type(value).__name__}."
|
|
302
|
+
)
|
|
303
|
+
out: list[dict[str, str]] = []
|
|
304
|
+
for i, item in enumerate(value):
|
|
305
|
+
if isinstance(item, str):
|
|
306
|
+
try:
|
|
307
|
+
item = _loads_lenient(item)
|
|
308
|
+
except ValueError:
|
|
309
|
+
return (
|
|
310
|
+
f"ERROR: edit #{i + 1} is a string that isn't "
|
|
311
|
+
"valid JSON. Each edit must be an object with "
|
|
312
|
+
"`old_string` and `new_string`."
|
|
313
|
+
)
|
|
314
|
+
if not isinstance(item, dict):
|
|
315
|
+
return (
|
|
316
|
+
f"ERROR: edit #{i + 1} must be an object with "
|
|
317
|
+
f"`old_string` + `new_string`, got "
|
|
318
|
+
f"{type(item).__name__}."
|
|
319
|
+
)
|
|
320
|
+
if "old_string" not in item or "new_string" not in item:
|
|
321
|
+
return (
|
|
322
|
+
f"ERROR: edit #{i + 1} is missing `old_string` "
|
|
323
|
+
"and/or `new_string`."
|
|
324
|
+
)
|
|
325
|
+
coerced_edit: dict[str, str] = {
|
|
326
|
+
"old_string": str(item["old_string"]),
|
|
327
|
+
"new_string": str(item["new_string"]),
|
|
328
|
+
}
|
|
329
|
+
# Preserve the optional per-edit ``replace_all`` flag (the
|
|
330
|
+
# multi_edit applier coerces it to bool). Without carrying
|
|
331
|
+
# it through, every edit defaults to single-replace.
|
|
332
|
+
if "replace_all" in item:
|
|
333
|
+
coerced_edit["replace_all"] = str(item["replace_all"])
|
|
334
|
+
out.append(coerced_edit)
|
|
335
|
+
if not out:
|
|
336
|
+
return "ERROR: `edits` was empty — nothing to do."
|
|
337
|
+
return out
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _leading_ws(s: str) -> str:
|
|
341
|
+
"""Leading whitespace of the first non-blank line of ``s``."""
|
|
342
|
+
for line in s.split("\n"):
|
|
343
|
+
if line.strip():
|
|
344
|
+
return line[: len(line) - len(line.lstrip())]
|
|
345
|
+
return ""
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _flexible_apply(working: str, old: str, new: str) -> str | None:
|
|
349
|
+
"""Whitespace-tolerant fallback for when ``old`` doesn't match
|
|
350
|
+
``working`` byte-for-byte.
|
|
351
|
+
|
|
352
|
+
The #1 cause of "old_string not found" is a model that copied the
|
|
353
|
+
code with slightly wrong indentation / trailing whitespace. So:
|
|
354
|
+
locate the UNIQUE contiguous block whose lines equal ``old``'s
|
|
355
|
+
lines *ignoring per-line leading/trailing whitespace*. If exactly
|
|
356
|
+
one such block exists, re-indent ``new`` from ``old``'s base
|
|
357
|
+
indent to the file block's actual indent and splice it in.
|
|
358
|
+
|
|
359
|
+
Returns the edited text, or ``None`` when there's no match or it's
|
|
360
|
+
ambiguous (>1) — the caller then errors. Only ever used after an
|
|
361
|
+
exact match fails, and only when the match is unique, so it can't
|
|
362
|
+
silently edit the wrong place.
|
|
363
|
+
"""
|
|
364
|
+
if old == "":
|
|
365
|
+
return None
|
|
366
|
+
h_lines = working.split("\n")
|
|
367
|
+
n_lines = old.split("\n")
|
|
368
|
+
target = [ln.strip() for ln in n_lines]
|
|
369
|
+
hits: list[int] = []
|
|
370
|
+
span = len(n_lines)
|
|
371
|
+
for i in range(0, len(h_lines) - span + 1):
|
|
372
|
+
if [ln.strip() for ln in h_lines[i : i + span]] == target:
|
|
373
|
+
hits.append(i)
|
|
374
|
+
if len(hits) != 1:
|
|
375
|
+
return None
|
|
376
|
+
i = hits[0]
|
|
377
|
+
matched = "\n".join(h_lines[i : i + span])
|
|
378
|
+
# Re-indent ``new`` from old's base indent to the file block's,
|
|
379
|
+
# so a model that under/over-indented its old_string doesn't
|
|
380
|
+
# leave the replacement mis-indented (would break .py).
|
|
381
|
+
old_base = _leading_ws(old)
|
|
382
|
+
file_base = _leading_ws(matched)
|
|
383
|
+
new_lines = new.split("\n")
|
|
384
|
+
if old_base != file_base:
|
|
385
|
+
new_lines = [
|
|
386
|
+
(file_base + ln[len(old_base):])
|
|
387
|
+
if ln.startswith(old_base)
|
|
388
|
+
else ln
|
|
389
|
+
for ln in new_lines
|
|
390
|
+
]
|
|
391
|
+
edited = h_lines[:i] + new_lines + h_lines[i + span:]
|
|
392
|
+
return "\n".join(edited)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# Fuzzy-apply only when a block is at least this similar to ``old`` AND no
|
|
396
|
+
# other block comes within _FUZZY_MARGIN of it. The bar is high on purpose:
|
|
397
|
+
# this runs ONLY after exact + whitespace-flexible both miss, and it must
|
|
398
|
+
# never land an edit in a vaguely-similar block. Refuse-on-ambiguity (the
|
|
399
|
+
# margin) is the safety net — we'd rather error (model re-reads) than risk
|
|
400
|
+
# editing the wrong place.
|
|
401
|
+
_FUZZY_THRESHOLD = 0.90
|
|
402
|
+
_FUZZY_MARGIN = 0.05
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _best_fuzzy_window(
|
|
406
|
+
working: str, old: str
|
|
407
|
+
) -> tuple[int, float, float]:
|
|
408
|
+
"""Find the line-window in ``working`` most similar to ``old``.
|
|
409
|
+
|
|
410
|
+
Returns ``(start_line_index, best_ratio, runner_up_ratio)``. Scans
|
|
411
|
+
every contiguous window of ``len(old)`` lines and scores it against
|
|
412
|
+
``old`` with :class:`difflib.SequenceMatcher` on the whitespace-
|
|
413
|
+
stripped forms (indentation drift is already handled by
|
|
414
|
+
``_flexible_apply``; this catches CONTENT drift — a renamed var, a
|
|
415
|
+
reflowed comment — on top). The runner-up lets the caller refuse a
|
|
416
|
+
near-tie. Returns ``(-1, 0, 0)`` when ``old`` is empty."""
|
|
417
|
+
if old == "":
|
|
418
|
+
return -1, 0.0, 0.0
|
|
419
|
+
h_lines = working.split("\n")
|
|
420
|
+
n = len(old.split("\n"))
|
|
421
|
+
old_norm = "\n".join(ln.strip() for ln in old.split("\n"))
|
|
422
|
+
best_i, best_r, second_r = -1, 0.0, 0.0
|
|
423
|
+
sm = difflib.SequenceMatcher()
|
|
424
|
+
sm.set_seq2(old_norm)
|
|
425
|
+
for i in range(0, len(h_lines) - n + 1):
|
|
426
|
+
window = "\n".join(ln.strip() for ln in h_lines[i : i + n])
|
|
427
|
+
sm.set_seq1(window)
|
|
428
|
+
r = sm.ratio()
|
|
429
|
+
if r > best_r:
|
|
430
|
+
best_i, second_r, best_r = i, best_r, r
|
|
431
|
+
elif r > second_r:
|
|
432
|
+
second_r = r
|
|
433
|
+
return best_i, best_r, second_r
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _fuzzy_apply(working: str, old: str, new: str) -> str | None:
|
|
437
|
+
"""Content-drift fallback: apply ``new`` to the single block in
|
|
438
|
+
``working`` that is >=_FUZZY_THRESHOLD similar to ``old`` — but only
|
|
439
|
+
when that block clears the runner-up by _FUZZY_MARGIN (else it's
|
|
440
|
+
ambiguous and we refuse). Returns the edited text or ``None``.
|
|
441
|
+
|
|
442
|
+
Runs ONLY after exact + ``_flexible_apply`` both miss, so it can't
|
|
443
|
+
override a clean match. Re-indents ``new`` to the matched block's
|
|
444
|
+
base indent (same as ``_flexible_apply``) so the splice stays valid."""
|
|
445
|
+
i, best_r, second_r = _best_fuzzy_window(working, old)
|
|
446
|
+
if i < 0 or best_r < _FUZZY_THRESHOLD:
|
|
447
|
+
return None
|
|
448
|
+
if best_r - second_r < _FUZZY_MARGIN:
|
|
449
|
+
return None # ambiguous — two blocks both plausible; refuse
|
|
450
|
+
h_lines = working.split("\n")
|
|
451
|
+
span = len(old.split("\n"))
|
|
452
|
+
matched = "\n".join(h_lines[i : i + span])
|
|
453
|
+
old_base = _leading_ws(old)
|
|
454
|
+
file_base = _leading_ws(matched)
|
|
455
|
+
new_lines = new.split("\n")
|
|
456
|
+
if old_base != file_base:
|
|
457
|
+
new_lines = [
|
|
458
|
+
(file_base + ln[len(old_base):])
|
|
459
|
+
if ln.startswith(old_base)
|
|
460
|
+
else ln
|
|
461
|
+
for ln in new_lines
|
|
462
|
+
]
|
|
463
|
+
edited = h_lines[:i] + new_lines + h_lines[i + span:]
|
|
464
|
+
return "\n".join(edited)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _closest_block_hint(working: str, old: str) -> str:
|
|
468
|
+
"""A unified diff of ``old`` vs the nearest block in ``working`` —
|
|
469
|
+
appended to the not-found error so the model sees WHAT is actually on
|
|
470
|
+
disk and re-sends exact text, instead of retrying blind."""
|
|
471
|
+
i, ratio, _ = _best_fuzzy_window(working, old)
|
|
472
|
+
if i < 0:
|
|
473
|
+
return ""
|
|
474
|
+
h_lines = working.split("\n")
|
|
475
|
+
span = len(old.split("\n"))
|
|
476
|
+
nearest = h_lines[i : i + span]
|
|
477
|
+
diff = "\n".join(
|
|
478
|
+
difflib.unified_diff(
|
|
479
|
+
old.split("\n"),
|
|
480
|
+
nearest,
|
|
481
|
+
fromfile="what you sent",
|
|
482
|
+
tofile=f"nearest text in file (line {i + 1}, "
|
|
483
|
+
f"{ratio:.0%} similar)",
|
|
484
|
+
lineterm="",
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
if not diff:
|
|
488
|
+
return ""
|
|
489
|
+
capped = diff if len(diff) <= 1200 else diff[:1200] + "\n… (truncated)"
|
|
490
|
+
return "Closest block in the file:\n" + capped
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def multi_edit_tool(workdir: Path | str) -> Tool:
|
|
494
|
+
"""Build the loom-code ``multi_edit`` tool — apply MANY edits to
|
|
495
|
+
ONE file in a single ATOMIC call.
|
|
496
|
+
|
|
497
|
+
Why it exists: making N separate ``edit`` calls to fix N things
|
|
498
|
+
in one file is N round-trips, and a model that produces a
|
|
499
|
+
slightly-off ``old_string`` retries the same edit repeatedly
|
|
500
|
+
(observed: ~8 retries for one fix, burning a whole session's
|
|
501
|
+
tokens). ``multi_edit`` collapses N changes into one call AND
|
|
502
|
+
is region-targeted — it never reproduces unchanged code, so it
|
|
503
|
+
scales to arbitrarily large files without the token blow-up (or
|
|
504
|
+
the "# ... rest unchanged ..." laziness) of a whole-file
|
|
505
|
+
rewrite.
|
|
506
|
+
|
|
507
|
+
**Atomic**: every edit's ``old_string`` must match (exactly
|
|
508
|
+
once, unless that edit sets ``replace_all``). If ANY edit fails
|
|
509
|
+
to match, NOTHING is written and the tool reports which edit
|
|
510
|
+
failed — so the file is never left half-changed / corrupted.
|
|
511
|
+
The model fixes the offending edit and resubmits the batch.
|
|
512
|
+
|
|
513
|
+
Model-facing signature:
|
|
514
|
+
``multi_edit(path, edits=[{old_string, new_string,
|
|
515
|
+
replace_all?}, ...])``
|
|
516
|
+
|
|
517
|
+
On success the result carries the same EDIT PREVIEW + Python
|
|
518
|
+
syntax-break warning as ``edit``, so the model can verify the
|
|
519
|
+
whole batch landed correctly in one look.
|
|
520
|
+
"""
|
|
521
|
+
root = Path(workdir).resolve()
|
|
522
|
+
|
|
523
|
+
async def multi_edit(path: str, edits: Any) -> str:
|
|
524
|
+
"""Apply a batch of find-and-replace edits to one file
|
|
525
|
+
atomically. See the module/tool docstring for the full
|
|
526
|
+
contract."""
|
|
527
|
+
coerced = _coerce_edits(edits)
|
|
528
|
+
if isinstance(coerced, str):
|
|
529
|
+
return coerced # error message, verbatim
|
|
530
|
+
|
|
531
|
+
target = resolve_path(path, root)
|
|
532
|
+
if not is_within(target, root):
|
|
533
|
+
# Same consent rule as ``edit`` — user-named files only.
|
|
534
|
+
from .consent import is_granted
|
|
535
|
+
|
|
536
|
+
if not is_granted(target):
|
|
537
|
+
return (
|
|
538
|
+
f"multi_edit: refusing to edit {path} — it is "
|
|
539
|
+
"outside the project and the user has not "
|
|
540
|
+
"referenced it. Only files the user pasted or "
|
|
541
|
+
"@-mentioned may be edited outside the project."
|
|
542
|
+
)
|
|
543
|
+
if not target.is_file():
|
|
544
|
+
return f"multi_edit: file not found: {path}"
|
|
545
|
+
|
|
546
|
+
original = target.read_text(encoding="utf-8", errors="replace")
|
|
547
|
+
working = original
|
|
548
|
+
# Apply each edit to the in-memory working copy. Validate
|
|
549
|
+
# match BEFORE mutating so a mid-batch failure leaves
|
|
550
|
+
# ``working`` partially applied but we NEVER write it.
|
|
551
|
+
for i, e in enumerate(coerced):
|
|
552
|
+
old = e["old_string"]
|
|
553
|
+
new = e["new_string"]
|
|
554
|
+
replace_all = _as_bool(
|
|
555
|
+
e.get("replace_all"), default=False
|
|
556
|
+
)
|
|
557
|
+
count = working.count(old)
|
|
558
|
+
if count == 0:
|
|
559
|
+
# Exact match failed — try whitespace-flexible match
|
|
560
|
+
# (rescues an old_string that's right except for
|
|
561
|
+
# indentation / trailing space, the common failure).
|
|
562
|
+
flexed = _flexible_apply(working, old, new)
|
|
563
|
+
if flexed is not None:
|
|
564
|
+
working = flexed
|
|
565
|
+
continue
|
|
566
|
+
# Whitespace-flex also missed → CONTENT drift (renamed
|
|
567
|
+
# var, reflowed comment). Last resort: high-bar
|
|
568
|
+
# similarity match (single unambiguous block only).
|
|
569
|
+
fuzzed = _fuzzy_apply(working, old, new)
|
|
570
|
+
if fuzzed is not None:
|
|
571
|
+
working = fuzzed
|
|
572
|
+
continue
|
|
573
|
+
# No safe match — fail with the closest block as a diff
|
|
574
|
+
# so the retry is informed, not blind.
|
|
575
|
+
hint = _closest_block_hint(working, old)
|
|
576
|
+
msg = (
|
|
577
|
+
f"ERROR: edit #{i + 1} old_string not found in "
|
|
578
|
+
f"{path} (after applying edits 1..{i}) — not even "
|
|
579
|
+
"with whitespace-flexible or similarity matching, so "
|
|
580
|
+
"it's either absent or appears in multiple near-"
|
|
581
|
+
"identical spots. Re-`read` the exact lines and copy "
|
|
582
|
+
"them verbatim (with more surrounding context if it's "
|
|
583
|
+
"ambiguous). NOTHING was written."
|
|
584
|
+
)
|
|
585
|
+
if hint:
|
|
586
|
+
msg += "\n\n" + hint
|
|
587
|
+
return msg
|
|
588
|
+
if count > 1 and not replace_all:
|
|
589
|
+
return (
|
|
590
|
+
f"ERROR: edit #{i + 1} old_string appears "
|
|
591
|
+
f"{count} times in {path}; add more surrounding "
|
|
592
|
+
"context to make it unique, or set "
|
|
593
|
+
"replace_all=true on that edit. NOTHING was "
|
|
594
|
+
"written."
|
|
595
|
+
)
|
|
596
|
+
working = (
|
|
597
|
+
working.replace(old, new)
|
|
598
|
+
if replace_all
|
|
599
|
+
else working.replace(old, new, 1)
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
# All edits matched — write once.
|
|
603
|
+
target.write_text(working, encoding="utf-8")
|
|
604
|
+
|
|
605
|
+
start_line, end_line = _find_edit_region(original, working)
|
|
606
|
+
preview = _render_preview(
|
|
607
|
+
target, working, start_line, end_line
|
|
608
|
+
)
|
|
609
|
+
warn = _syntax_check_python(target, working)
|
|
610
|
+
header = (
|
|
611
|
+
f"multi_edit: ✓ applied {len(coerced)} edit"
|
|
612
|
+
f"{'s' if len(coerced) != 1 else ''} to {path} "
|
|
613
|
+
f"({len(original)} → {len(working)} bytes)"
|
|
614
|
+
)
|
|
615
|
+
parts = [header, "", preview]
|
|
616
|
+
if warn:
|
|
617
|
+
parts.append("")
|
|
618
|
+
parts.append(warn)
|
|
619
|
+
return "\n".join(parts)
|
|
620
|
+
|
|
621
|
+
return tool(
|
|
622
|
+
name="multi_edit",
|
|
623
|
+
description=(
|
|
624
|
+
"Apply MULTIPLE find-and-replace edits to ONE file in a "
|
|
625
|
+
"single ATOMIC call. Prefer this over repeated `edit` "
|
|
626
|
+
"calls when changing several things in the same file — "
|
|
627
|
+
"one round-trip, and it scales to large files (only "
|
|
628
|
+
"touches the changed regions, never rewrites the whole "
|
|
629
|
+
"file). All edits must match or NONE apply (no half-"
|
|
630
|
+
"edited file). Args: path, edits=[{old_string, "
|
|
631
|
+
"new_string, replace_all?}, ...]. Result includes an "
|
|
632
|
+
"EDIT PREVIEW + a syntax-break warning for .py files."
|
|
633
|
+
),
|
|
634
|
+
destructive=True,
|
|
635
|
+
)(multi_edit)
|