lcode-cli 0.28.1__py3-none-any.whl → 0.30.0__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.
- lcode/__init__.py +1 -1
- lcode/acp.py +1 -1
- lcode/agent.py +89 -2
- lcode/api.py +3 -0
- lcode/bench.py +15 -7
- lcode/checks.py +160 -0
- lcode/cli.py +6 -0
- lcode/config.py +1 -0
- lcode/matching.py +287 -0
- lcode/repeats.py +2 -2
- lcode/subagents.py +1 -1
- lcode/tools.py +127 -39
- {lcode_cli-0.28.1.dist-info → lcode_cli-0.30.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.28.1.dist-info → lcode_cli-0.30.0.dist-info}/RECORD +17 -15
- {lcode_cli-0.28.1.dist-info → lcode_cli-0.30.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.28.1.dist-info → lcode_cli-0.30.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.28.1.dist-info → lcode_cli-0.30.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/acp.py
CHANGED
|
@@ -44,7 +44,7 @@ MODES = [
|
|
|
44
44
|
]
|
|
45
45
|
KINDS = {
|
|
46
46
|
"read_file": "read", "view_image": "read", "list_dir": "search", "glob": "search", "grep": "search",
|
|
47
|
-
"repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit",
|
|
47
|
+
"repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit", "apply_patch": "edit",
|
|
48
48
|
"bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think",
|
|
49
49
|
"present_plan": "switch_mode", "bash_output": "read", "bash_stop": "execute",
|
|
50
50
|
} # fmt: skip
|
lcode/agent.py
CHANGED
|
@@ -27,6 +27,7 @@ from rich.text import Text
|
|
|
27
27
|
|
|
28
28
|
from lcode import (
|
|
29
29
|
catalog,
|
|
30
|
+
checks,
|
|
30
31
|
codesearch,
|
|
31
32
|
extensions,
|
|
32
33
|
limits,
|
|
@@ -81,7 +82,13 @@ EVENT_OUTPUT = 4000 # characters of a tool's output in an event
|
|
|
81
82
|
REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
|
|
82
83
|
MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
|
|
83
84
|
# How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
|
|
84
|
-
MALFORMED_CALL = (
|
|
85
|
+
MALFORMED_CALL = (
|
|
86
|
+
"error parsing tool call",
|
|
87
|
+
# Go's XML decoder, for models that write calls as XML (Qwen3.6): a cut-off or misordered call
|
|
88
|
+
"XML syntax error",
|
|
89
|
+
"expected element type",
|
|
90
|
+
"closed by </",
|
|
91
|
+
)
|
|
85
92
|
RESULT_SHARE = 0.25 # of the context window, at most, for one tool result
|
|
86
93
|
MIN_RESULT_CHARS = 8000
|
|
87
94
|
READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
|
|
@@ -114,7 +121,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
|
|
|
114
121
|
- A syntax check or an import only shows that code loads. Run what you changed the way it will be used (the script, the tests, the app) and fix what fails.
|
|
115
122
|
- To fix a bug, first reproduce each problem the user reports with a test, a command or a short script, then run the same checks to show they're gone.
|
|
116
123
|
- For what you can't see, like a GUI, a web page or a layout, check it without a screen where you can: `QT_QPA_PLATFORM=offscreen` for Qt, a headless browser, or a script that prints sizes and positions or renders to an image{look}. Measure instead of assuming.
|
|
117
|
-
- Never claim something works, mark a todo done, or call it tested unless you ran it. If you couldn't check something, say so and tell the user exactly what to look for. If something fails, say so and show the relevant error.
|
|
124
|
+
- Never claim something works, mark a todo done, or call it tested unless you ran it. If you couldn't check something, say so and tell the user exactly what to look for. If something fails, say so and show the relevant error.{checks}
|
|
118
125
|
|
|
119
126
|
# Communication
|
|
120
127
|
- Be concise and direct; no filler. Use GitHub-flavored markdown.
|
|
@@ -268,6 +275,7 @@ class Settings:
|
|
|
268
275
|
secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan)
|
|
269
276
|
compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%)
|
|
270
277
|
repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
|
|
278
|
+
checks: str = "off" # the project's checks after a change (lcode.checks): auto, off or a command
|
|
271
279
|
|
|
272
280
|
|
|
273
281
|
class Agent:
|
|
@@ -324,6 +332,8 @@ class Agent:
|
|
|
324
332
|
self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
|
|
325
333
|
self.load_state = "" # "loading" while the model loads in the background at startup (lcode.repl)
|
|
326
334
|
self.autosave = False # save the session after every step (sessions people resume; not the bench's)
|
|
335
|
+
self._check: tuple[Path, checks.Check | None] | None = None
|
|
336
|
+
self.check_results: list[dict] = [] # the checks of the last request: command, exit code, who ran them
|
|
327
337
|
self.sent_chars = 0 # characters of the conversation the model had already read
|
|
328
338
|
self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
|
|
329
339
|
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
@@ -363,6 +373,7 @@ class Agent:
|
|
|
363
373
|
code=self.code_prompt(),
|
|
364
374
|
map=self.map_prompt(),
|
|
365
375
|
look=" you look at with view_image" if self.vision_model() else "",
|
|
376
|
+
checks=self.checks_prompt(),
|
|
366
377
|
)
|
|
367
378
|
|
|
368
379
|
def repo_map(self) -> repomap.RepoMap | None:
|
|
@@ -812,6 +823,64 @@ class Agent:
|
|
|
812
823
|
self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
|
|
813
824
|
return message
|
|
814
825
|
|
|
826
|
+
def checks_prompt(self) -> str:
|
|
827
|
+
check = self.project_check()
|
|
828
|
+
if check is None:
|
|
829
|
+
return ""
|
|
830
|
+
return (
|
|
831
|
+
f"\n- When you've changed files, lcode runs the project's checks (`{check.command}`) before your answer "
|
|
832
|
+
"is final, and gives you the failures to fix. Run them yourself while you work, too."
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
def project_check(self) -> checks.Check | None:
|
|
836
|
+
"""The project's check command, for the main agent outside plan mode and reviews (None otherwise)."""
|
|
837
|
+
if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
|
|
838
|
+
return None
|
|
839
|
+
root = project_root_or_cwd(self.cwd)
|
|
840
|
+
if self._check is None or self._check[0] != root or self._check[1] is None: # tests may appear meanwhile
|
|
841
|
+
self._check = (root, checks.find(root, self.settings.checks, self.settings.trust_project))
|
|
842
|
+
return self._check[1]
|
|
843
|
+
|
|
844
|
+
def model_ran_checks(self, command: str, result: str) -> bool:
|
|
845
|
+
"""Whether a command the model ran was the project's checks, and they passed."""
|
|
846
|
+
check = self.project_check()
|
|
847
|
+
if check is None or not checks.covers(check, command) or checks.exit_code(result) != 0:
|
|
848
|
+
return False
|
|
849
|
+
self.check_results.append({"command": check.command, "exit_code": 0, "by": "model"})
|
|
850
|
+
return True
|
|
851
|
+
|
|
852
|
+
def run_check(self, check: checks.Check, round_: int) -> str | None:
|
|
853
|
+
"""Run the checks after a change. Returns what to tell the model when they fail, else None."""
|
|
854
|
+
self.say(f"Checking the work: {check.command} (from {check.source})")
|
|
855
|
+
result = self.tools.run("bash", {"command": check.command, "timeout": checks.TIMEOUT})
|
|
856
|
+
code = checks.exit_code(result)
|
|
857
|
+
self.check_results.append({"command": check.command, "exit_code": code, "by": "lcode"})
|
|
858
|
+
if code is None:
|
|
859
|
+
self.say(f"The checks didn't run: {result.splitlines()[0][:200]}", warning=True)
|
|
860
|
+
return None
|
|
861
|
+
if code == 0:
|
|
862
|
+
self.say(f"Checks pass: {check.command}")
|
|
863
|
+
return None
|
|
864
|
+
if checks.no_tests(check, result):
|
|
865
|
+
self.say(f"The checks found no tests to run ({check.command}).")
|
|
866
|
+
return None
|
|
867
|
+
if checks.missing(check, result):
|
|
868
|
+
self.say(
|
|
869
|
+
f"The checks couldn't run ({check.command}): {result.splitlines()[0][:160]}. Install what they need, "
|
|
870
|
+
'or set the command: lcode config set checks "…" (or checks off).',
|
|
871
|
+
warning=True,
|
|
872
|
+
)
|
|
873
|
+
return None
|
|
874
|
+
if round_ >= checks.MAX_ROUNDS:
|
|
875
|
+
self.say(
|
|
876
|
+
f"Checks still fail ({check.command}, exit code {code}): the model will say what's left.", warning=True
|
|
877
|
+
)
|
|
878
|
+
else:
|
|
879
|
+
self.say(
|
|
880
|
+
f"Checks fail ({check.command}, exit code {code}): the model gets the output to fix it.", warning=True
|
|
881
|
+
)
|
|
882
|
+
return checks.failure_note(check, result, round_)
|
|
883
|
+
|
|
815
884
|
def before_request(self, progress: Progress) -> residency.LoadWatch | None:
|
|
816
885
|
"""Set the status line for what comes before the first token: loading the model if it isn't in memory
|
|
817
886
|
(watched until it is), then reading the prompt. Returns the watch while the model loads."""
|
|
@@ -948,6 +1017,9 @@ class Agent:
|
|
|
948
1017
|
note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
|
|
949
1018
|
self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
|
|
950
1019
|
self.repeats.reset()
|
|
1020
|
+
self.check_results = []
|
|
1021
|
+
changed = verified = False # files changed in this request; the checks passed since
|
|
1022
|
+
check_rounds = 0
|
|
951
1023
|
malformed = 0
|
|
952
1024
|
looping = "" # the call the model kept repeating, when lcode stopped the request for it
|
|
953
1025
|
for step in range(self.max_steps):
|
|
@@ -983,6 +1055,8 @@ class Agent:
|
|
|
983
1055
|
reason = str(e).rsplit("err=", 1)[-1].strip()
|
|
984
1056
|
elif "XML syntax error" in str(e):
|
|
985
1057
|
reason = str(e).split("XML syntax error", 1)[1].strip(" :") or "invalid XML"
|
|
1058
|
+
elif "expected element type" in str(e) or "closed by </" in str(e):
|
|
1059
|
+
reason = str(e).split("Ollama error:", 1)[-1].strip() or "invalid XML"
|
|
986
1060
|
else:
|
|
987
1061
|
reason = "invalid JSON"
|
|
988
1062
|
self.console.print("[yellow]The model wrote a malformed tool call; asking it to try again.[/]")
|
|
@@ -996,6 +1070,15 @@ class Agent:
|
|
|
996
1070
|
)
|
|
997
1071
|
continue
|
|
998
1072
|
if not calls:
|
|
1073
|
+
check = self.project_check() if changed and not verified else None
|
|
1074
|
+
if check is not None and check_rounds < checks.MAX_ROUNDS:
|
|
1075
|
+
check_rounds += 1
|
|
1076
|
+
feedback = self.run_check(check, check_rounds)
|
|
1077
|
+
if feedback:
|
|
1078
|
+
self.messages.append({"role": "user", "content": feedback})
|
|
1079
|
+
changed = False # run them again only if the model changes something more
|
|
1080
|
+
continue
|
|
1081
|
+
verified = True
|
|
999
1082
|
break
|
|
1000
1083
|
done: dict[int, str] = {} # results of subagents that ran together
|
|
1001
1084
|
together = [
|
|
@@ -1051,6 +1134,10 @@ class Agent:
|
|
|
1051
1134
|
)
|
|
1052
1135
|
if verdict.stop:
|
|
1053
1136
|
looping = self.describe_call(name, args)
|
|
1137
|
+
if name in ("edit_file", "write_file", "apply_patch") and not result.startswith(REFUSED):
|
|
1138
|
+
changed, verified = True, False
|
|
1139
|
+
elif name == "bash" and changed and self.model_ran_checks(str(args.get("command", "")), result):
|
|
1140
|
+
verified = True # the model ran the checks itself, after its last change
|
|
1054
1141
|
tool_msg = {"role": "tool", "tool_name": name, "content": result}
|
|
1055
1142
|
if call.get("id"):
|
|
1056
1143
|
tool_msg["tool_call_id"] = call["id"]
|
lcode/api.py
CHANGED
|
@@ -76,6 +76,7 @@ class Result:
|
|
|
76
76
|
tool_calls: list[dict] = field(default_factory=list) # {"id", "name", "arguments", "error", "output"}
|
|
77
77
|
files_changed: list[dict] = field(default_factory=list) # {"status": "A" | "M" | "D", "path"}
|
|
78
78
|
usage: dict = field(default_factory=dict) # requests, prompt_tokens, output_tokens
|
|
79
|
+
checks: list[dict] = field(default_factory=list) # {"command", "exit_code", "by": "lcode" | "model"}
|
|
79
80
|
seconds: float = 0.0
|
|
80
81
|
error: str = ""
|
|
81
82
|
|
|
@@ -196,6 +197,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
196
197
|
notify_after=cfg["notify_after"],
|
|
197
198
|
secret_check=cfg["secret_check"],
|
|
198
199
|
repeat_limit=cfg["repeat_limit"],
|
|
200
|
+
checks=cfg["checks"],
|
|
199
201
|
compact_at=cfg["compact_at"],
|
|
200
202
|
)
|
|
201
203
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
@@ -303,6 +305,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
|
|
|
303
305
|
tool_calls=list(calls.values()),
|
|
304
306
|
files_changed=files,
|
|
305
307
|
usage=usage,
|
|
308
|
+
checks=list(agent.check_results),
|
|
306
309
|
seconds=round(time.monotonic() - started, 2),
|
|
307
310
|
error=error,
|
|
308
311
|
)
|
lcode/bench.py
CHANGED
|
@@ -789,6 +789,7 @@ class TaskResult:
|
|
|
789
789
|
steps: int = 0 # model responses
|
|
790
790
|
tool_errors: int = 0 # failed tool calls, including malformed ones
|
|
791
791
|
error: str | None = None # why the run stopped early: a timeout or an Ollama error
|
|
792
|
+
errors_by_tool: dict[str, int] = field(default_factory=dict) # which tools failed, "malformed" for unreadable calls
|
|
792
793
|
|
|
793
794
|
|
|
794
795
|
@dataclass
|
|
@@ -861,14 +862,20 @@ class Elapsed:
|
|
|
861
862
|
return Text(f"{self.label} {time.monotonic() - self.start:.0f}s", style="dim")
|
|
862
863
|
|
|
863
864
|
|
|
865
|
+
def errors_by_tool(messages: list[dict]) -> dict[str, int]:
|
|
866
|
+
"""Failed tool calls by tool, and "malformed" for calls the model wrote unreadably."""
|
|
867
|
+
counts: dict[str, int] = {}
|
|
868
|
+
for m in messages:
|
|
869
|
+
if m.get("role") == "tool" and m.get("content", "").startswith("Error:"):
|
|
870
|
+
name = m.get("tool_name") or "?"
|
|
871
|
+
counts[name] = counts.get(name, 0) + 1
|
|
872
|
+
elif m.get("role") == "user" and m.get("content", "").startswith("[lcode] Your last tool call"):
|
|
873
|
+
counts["malformed"] = counts.get("malformed", 0) + 1
|
|
874
|
+
return counts
|
|
875
|
+
|
|
876
|
+
|
|
864
877
|
def count_tool_errors(messages: list[dict]) -> int:
|
|
865
|
-
|
|
866
|
-
malformed = sum(
|
|
867
|
-
1
|
|
868
|
-
for m in messages
|
|
869
|
-
if m.get("role") == "user" and m.get("content", "").startswith("[lcode] Your last tool call")
|
|
870
|
-
)
|
|
871
|
-
return failed + malformed
|
|
878
|
+
return sum(errors_by_tool(messages).values())
|
|
872
879
|
|
|
873
880
|
|
|
874
881
|
def run_task(
|
|
@@ -933,6 +940,7 @@ def run_task(
|
|
|
933
940
|
detail=error or check.detail,
|
|
934
941
|
steps=agent.usage["requests"] - before.get("requests", 0),
|
|
935
942
|
tool_errors=count_tool_errors(agent.messages[first:] if len(agent.messages) > first else agent.messages),
|
|
943
|
+
errors_by_tool=errors_by_tool(agent.messages[first:] if len(agent.messages) > first else agent.messages),
|
|
936
944
|
error=error,
|
|
937
945
|
)
|
|
938
946
|
|
lcode/checks.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Run the project's checks before calling work done.
|
|
2
|
+
|
|
3
|
+
Small models call work done after a syntax check, and telling them to verify isn't enough. So after a request
|
|
4
|
+
that changed files, lcode runs the project's check command itself (its tests, or a linter) before the answer
|
|
5
|
+
counts as final. When it fails, the output goes back to the model, which keeps working, at most `MAX_ROUNDS`
|
|
6
|
+
times; then the answer says plainly that the checks still fail.
|
|
7
|
+
|
|
8
|
+
The command is, in order: the repository's `.lcode/settings.toml` (`[checks] command = "…"`, used once the
|
|
9
|
+
repository is trusted, like its hooks), the user's `checks` setting, or what the project looks like: pytest,
|
|
10
|
+
unittest, npm test, cargo test, go test, make test. `checks = "off"` turns it off.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
MAX_ROUNDS = 2 # times the model gets failing checks back before lcode stops asking
|
|
22
|
+
TIMEOUT = 600 # seconds a check may run
|
|
23
|
+
OUTPUT_TAIL = 6000 # characters of a failing check's output the model gets back
|
|
24
|
+
EXIT = re.compile(r"\[exit code: (-?\d+)\]\s*$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Check:
|
|
29
|
+
command: str
|
|
30
|
+
source: str # where it comes from, for the user: "pyproject.toml", "your configuration"…
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def find(root: Path, setting: str, trusted: bool) -> Check | None:
|
|
34
|
+
"""The project's check command, or None when there's none or checks are off."""
|
|
35
|
+
setting = (setting or "auto").strip()
|
|
36
|
+
if setting == "off":
|
|
37
|
+
return None
|
|
38
|
+
if trusted:
|
|
39
|
+
from lcode.hooks import HookError, read_toml
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
data = read_toml(root / ".lcode" / "settings.toml")
|
|
43
|
+
except HookError:
|
|
44
|
+
data = {}
|
|
45
|
+
command = str((data.get("checks") or {}).get("command") or "").strip()
|
|
46
|
+
if command:
|
|
47
|
+
return Check(command, ".lcode/settings.toml")
|
|
48
|
+
if setting != "auto":
|
|
49
|
+
return Check(setting, "your configuration (checks)")
|
|
50
|
+
return detect(root)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def python() -> str:
|
|
54
|
+
return "python" if shutil.which("python") else "python3"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def text(path: Path) -> str:
|
|
58
|
+
try:
|
|
59
|
+
return path.read_text(errors="replace") if path.is_file() else ""
|
|
60
|
+
except OSError:
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def python_tests(root: Path) -> bool:
|
|
65
|
+
"""Whether the project has Python test files (a few levels deep, outside environments and caches)."""
|
|
66
|
+
from lcode.tools import IGNORE_DIRS
|
|
67
|
+
|
|
68
|
+
def walk(folder: Path, depth: int) -> bool:
|
|
69
|
+
try:
|
|
70
|
+
entries = list(folder.iterdir())
|
|
71
|
+
except OSError:
|
|
72
|
+
return False
|
|
73
|
+
if any(e.is_file() and re.fullmatch(r"test_.*\.py|.*_test\.py", e.name) for e in entries):
|
|
74
|
+
return True
|
|
75
|
+
return depth > 0 and any(
|
|
76
|
+
walk(e, depth - 1)
|
|
77
|
+
for e in entries
|
|
78
|
+
if e.is_dir() and e.name not in IGNORE_DIRS and not e.name.startswith(".")
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return walk(root, 3)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def detect(root: Path) -> Check | None:
|
|
85
|
+
"""A check command from what the project looks like. Python checks need test files: pytest configured in a
|
|
86
|
+
project without tests "fails" with nothing to run."""
|
|
87
|
+
if python_tests(root):
|
|
88
|
+
configs = text(root / "pyproject.toml") + text(root / "setup.cfg") + text(root / "tox.ini")
|
|
89
|
+
if "pytest" in configs or any((root / name).is_file() for name in ("pytest.ini", "conftest.py")):
|
|
90
|
+
runner = "uv run pytest -q" if (root / "uv.lock").is_file() else f"{python()} -m pytest -q"
|
|
91
|
+
return Check(runner, "pytest in the project's configuration")
|
|
92
|
+
if list(root.glob("test_*.py")) or list(root.glob("*_test.py")):
|
|
93
|
+
return Check(f"{python()} -m unittest", "test files")
|
|
94
|
+
if (root / "tests").is_dir():
|
|
95
|
+
return Check(f"{python()} -m unittest discover -s tests -t .", "tests/")
|
|
96
|
+
package = text(root / "package.json")
|
|
97
|
+
if package:
|
|
98
|
+
try:
|
|
99
|
+
test = (json.loads(package).get("scripts") or {}).get("test") or ""
|
|
100
|
+
except ValueError:
|
|
101
|
+
test = ""
|
|
102
|
+
if test and "no test specified" not in test:
|
|
103
|
+
for lock, tool in (("pnpm-lock.yaml", "pnpm"), ("yarn.lock", "yarn"), ("bun.lockb", "bun")):
|
|
104
|
+
if (root / lock).is_file():
|
|
105
|
+
return Check(f"{tool} test", "package.json")
|
|
106
|
+
return Check("npm test", "package.json")
|
|
107
|
+
if (root / "Cargo.toml").is_file():
|
|
108
|
+
return Check("cargo test", "Cargo.toml")
|
|
109
|
+
if (root / "go.mod").is_file():
|
|
110
|
+
return Check("go test ./...", "go.mod")
|
|
111
|
+
if re.search(r"^test:", text(root / "Makefile"), re.M):
|
|
112
|
+
return Check("make test", "Makefile")
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
NO_TESTS = 5 # pytest's and unittest's exit code when no test ran: not a failure
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def no_tests(check: Check, result: str) -> bool:
|
|
120
|
+
return exit_code(result) == NO_TESTS and bool(re.search(r"pytest|unittest", check.command))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def exit_code(result: str) -> int | None:
|
|
124
|
+
"""The exit code at the end of a bash tool result (None: it didn't run, e.g. not allowed)."""
|
|
125
|
+
found = EXIT.search(result)
|
|
126
|
+
return int(found.group(1)) if found else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def covers(check: Check, command: str) -> bool:
|
|
130
|
+
"""Whether a command the model ran is this check (`cd x && uv run pytest -q 2>&1 | tail` counts)."""
|
|
131
|
+
return " ".join(check.command.split()) in " ".join(command.split())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def failure_note(check: Check, result: str, round_: int) -> str:
|
|
135
|
+
output = result if len(result) <= OUTPUT_TAIL else "…" + result[-OUTPUT_TAIL:]
|
|
136
|
+
last = round_ >= MAX_ROUNDS
|
|
137
|
+
return (
|
|
138
|
+
f"[lcode] Your changes are done, but the project's checks fail: `{check.command}` "
|
|
139
|
+
f"(exit code {exit_code(result)}).\n\n{output}\n\n"
|
|
140
|
+
+ (
|
|
141
|
+
"This was the last check lcode runs for this request. Don't change anything more: tell the user what "
|
|
142
|
+
"still fails and why."
|
|
143
|
+
if last
|
|
144
|
+
else "Fix what your changes broke, then finish. If a failure isn't caused by your changes (it failed "
|
|
145
|
+
"before), don't chase it: say so in your answer."
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# The check command itself is missing (not the code under test): nothing the model can fix.
|
|
151
|
+
MISSING = re.compile(r"No module named '?pytest'?\s*$|command not found|^\S+: not found$", re.I | re.M)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def missing(check: Check, result: str) -> bool:
|
|
155
|
+
"""Whether the check couldn't run at all: its program isn't installed."""
|
|
156
|
+
code = exit_code(result)
|
|
157
|
+
if code == 127:
|
|
158
|
+
return True
|
|
159
|
+
head = result.splitlines()[:5] # a missing program says so first; test failures look different
|
|
160
|
+
return code not in (0, None) and any(MISSING.search(line) for line in head)
|
lcode/cli.py
CHANGED
|
@@ -580,6 +580,7 @@ def cmd_bench(args) -> None:
|
|
|
580
580
|
checkpoints=False,
|
|
581
581
|
prune=not args.no_prune,
|
|
582
582
|
compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at,
|
|
583
|
+
checks=cfg["checks"] if args.checks is None else args.checks,
|
|
583
584
|
repo_map=args.repo_map,
|
|
584
585
|
embed_model=cfg["embed_model"] if args.code_search else "off",
|
|
585
586
|
)
|
|
@@ -774,6 +775,11 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
|
|
|
774
775
|
p.add_argument("--repo-map", action="store_true", help="give the model the repository map (off by default)")
|
|
775
776
|
p.add_argument("--code-search", action="store_true", help="index each task's folder and offer semantic code search")
|
|
776
777
|
p.add_argument("--no-prune", action="store_true", help="don't remove old tool output (to compare, with --session)")
|
|
778
|
+
p.add_argument(
|
|
779
|
+
"--checks",
|
|
780
|
+
metavar="auto|off|COMMAND",
|
|
781
|
+
help="run each task's checks before it counts as done (default: the checks setting)",
|
|
782
|
+
)
|
|
777
783
|
p.add_argument(
|
|
778
784
|
"--compact-at",
|
|
779
785
|
type=compact_tokens,
|
lcode/config.py
CHANGED
|
@@ -59,6 +59,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
59
59
|
"subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
|
|
60
60
|
"notify": (True, bool, "desktop notification when a long request is done or waits for you"),
|
|
61
61
|
"notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
|
|
62
|
+
"checks": ("auto", str, "run the project's checks before calling work done: auto | off | a command"),
|
|
62
63
|
"repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
|
|
63
64
|
"secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
|
|
64
65
|
"compact_at": (65536, int, "summarize at this many tokens even when the window is larger (0: only at 85%)"),
|
lcode/matching.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Find where an edit goes when the model's text doesn't match the file exactly.
|
|
2
|
+
|
|
3
|
+
Small models copy text imperfectly: with the line numbers `read_file` shows, with spaces where the file has
|
|
4
|
+
tabs, a block at the wrong indentation, quotes still escaped, a blank line dropped, a letter wrong. An exact
|
|
5
|
+
match fails and the model often repeats the same failing edit. So when the exact text isn't there, lcode tries,
|
|
6
|
+
in order: removing line-number prefixes and stray escapes; comparing lines without trailing whitespace, without
|
|
7
|
+
indentation (and re-indenting the new text to fit), without blank lines; and, last, a very close match. It
|
|
8
|
+
only ever applies a match that is unique; anything ambiguous or weak is refused, with the closest lines and
|
|
9
|
+
their numbers, so the model can copy them exactly.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from difflib import SequenceMatcher
|
|
17
|
+
|
|
18
|
+
LINE_NUMBER = re.compile(r"^\s*\d+(?:\t|→| \| |: )") # how read_file and other tools number lines
|
|
19
|
+
CLOSE_ENOUGH = 0.9 # similarity a loose match needs
|
|
20
|
+
CLEAR_LEAD = 0.05 # how much better than the next one it must be
|
|
21
|
+
MAX_SIMILARITY_LINES = 20_000 # bigger files: no similarity search
|
|
22
|
+
HINT_FLOOR = 0.5 # weaker candidates aren't worth showing
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Found:
|
|
27
|
+
text: str # the file with the edit applied
|
|
28
|
+
how: str # how the match was found, for the model ("ignoring indentation"); "" for an exact match
|
|
29
|
+
start_line: int # first matched line, 1-based
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Miss:
|
|
34
|
+
reason: str # "not found" or "ambiguous"
|
|
35
|
+
hint: str # the closest lines, numbered, or ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def numbered(lines: list[str], start: int) -> str:
|
|
39
|
+
return "\n".join(f"{start + i:6}\t{line.rstrip(chr(10))}" for i, line in enumerate(lines))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def strip_line_numbers(text: str) -> str | None:
|
|
43
|
+
lines = text.split("\n")
|
|
44
|
+
content = [line for line in lines if line.strip()]
|
|
45
|
+
if not content or not all(LINE_NUMBER.match(line) for line in content):
|
|
46
|
+
return None
|
|
47
|
+
return "\n".join(LINE_NUMBER.sub("", line, count=1) if line.strip() else line for line in lines)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def unescape(text: str, file_text: str) -> str | None:
|
|
51
|
+
"""`\\"`, `\\n` and `\\t` written literally, when the file doesn't contain them literally."""
|
|
52
|
+
changed = text
|
|
53
|
+
for literal, real in (('\\"', '"'), ("\\'", "'"), ("\\t", "\t")):
|
|
54
|
+
if literal in changed and literal not in file_text:
|
|
55
|
+
changed = changed.replace(literal, real)
|
|
56
|
+
if "\\n" in changed and "\n" not in changed and "\\n" not in file_text:
|
|
57
|
+
changed = changed.replace("\\n", "\n")
|
|
58
|
+
return changed if changed != text else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def indent_of(line: str) -> str:
|
|
62
|
+
return line[: len(line) - len(line.lstrip(" \t"))]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def width(indent: str) -> int:
|
|
66
|
+
return len(indent.expandtabs(4))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def reindent(new: str, old_lines: list[str], file_lines: list[str]) -> str:
|
|
70
|
+
"""Move `new` from the old text's indentation to the file's, keeping its relative structure."""
|
|
71
|
+
old_first = next((line for line in old_lines if line.strip()), "")
|
|
72
|
+
file_first = next((line for line in file_lines if line.strip()), "")
|
|
73
|
+
new_first = next((line for line in new.split("\n") if line.strip()), "")
|
|
74
|
+
base_old, base_file = width(indent_of(old_first)), indent_of(file_first)
|
|
75
|
+
same_kind = ("\t" in base_file) == ("\t" in indent_of(new_first))
|
|
76
|
+
if base_old != width(base_file) and width(indent_of(new_first)) == width(base_file) and same_kind:
|
|
77
|
+
return new # the model got the old text's indentation wrong but wrote the new text in the file's
|
|
78
|
+
old_steps = sorted({width(indent_of(line)) - base_old for line in old_lines if line.strip()} - {0})
|
|
79
|
+
step_old = next((s for s in old_steps if s > 0), 4)
|
|
80
|
+
tabs = "\t" in base_file or any(indent_of(line).startswith("\t") for line in file_lines if line.strip())
|
|
81
|
+
file_steps = sorted({width(indent_of(line)) - width(base_file) for line in file_lines if line.strip()} - {0})
|
|
82
|
+
unit = "\t" if tabs else " " * next((s for s in file_steps if s > 0), step_old)
|
|
83
|
+
out = []
|
|
84
|
+
for line in new.split("\n"):
|
|
85
|
+
if not line.strip():
|
|
86
|
+
out.append(line)
|
|
87
|
+
continue
|
|
88
|
+
levels = round((width(indent_of(line)) - base_old) / step_old)
|
|
89
|
+
lead = base_file + unit * levels if levels >= 0 else base_file[: max(0, len(base_file) + levels * len(unit))]
|
|
90
|
+
out.append(lead + line.lstrip(" \t"))
|
|
91
|
+
return "\n".join(out)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def splice(lines: list[str], start: int, end: int, new: str) -> str:
|
|
95
|
+
"""Replace lines[start:end] with `new`, keeping the line break the region ended with."""
|
|
96
|
+
ending = "\n" if lines[end - 1].endswith("\n") else ""
|
|
97
|
+
body = new.rstrip("\n")
|
|
98
|
+
return "".join(lines[:start]) + (body + ending if body else "") + "".join(lines[end:])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def line_matches(file_lines: list[str], old_lines: list[str], key) -> list[int]:
|
|
102
|
+
n = len(old_lines)
|
|
103
|
+
wanted = [key(line) for line in old_lines]
|
|
104
|
+
return [i for i in range(len(file_lines) - n + 1) if [key(line) for line in file_lines[i : i + n]] == wanted]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def locate(text: str, old: str, new: str) -> Found | Miss:
|
|
108
|
+
"""Where `old` goes in `text`, when it isn't there exactly; with `new` in its place."""
|
|
109
|
+
# 1. Copying mistakes that leave the text itself right.
|
|
110
|
+
for fix, how in ((strip_line_numbers, "without the line numbers"), (unescape, "with the escapes undone")):
|
|
111
|
+
fixed = fix(old) if fix is strip_line_numbers else unescape(old, text)
|
|
112
|
+
if fixed is None:
|
|
113
|
+
continue
|
|
114
|
+
fixed_new = (fix(new) if fix is strip_line_numbers else unescape(new, text)) or new
|
|
115
|
+
if text.count(fixed) == 1:
|
|
116
|
+
start = text.index(fixed)
|
|
117
|
+
return Found(text.replace(fixed, fixed_new, 1), how, text.count("\n", 0, start) + 1)
|
|
118
|
+
old, new = fixed, fixed_new # the looser steps below work on the fixed text
|
|
119
|
+
file_lines = text.splitlines(keepends=True)
|
|
120
|
+
old_lines = old.strip("\n").split("\n")
|
|
121
|
+
if not old_lines or not any(line.strip() for line in old_lines):
|
|
122
|
+
return Miss("not found", "")
|
|
123
|
+
plain = [line.rstrip("\r\n") for line in file_lines]
|
|
124
|
+
# 2. Line by line, looser each time.
|
|
125
|
+
for key, how, indent in (
|
|
126
|
+
(lambda s: s.rstrip(), "ignoring trailing spaces", False),
|
|
127
|
+
(lambda s: s.strip(), "ignoring indentation", True),
|
|
128
|
+
):
|
|
129
|
+
hits = line_matches(plain, old_lines, key)
|
|
130
|
+
if len(hits) == 1:
|
|
131
|
+
i = hits[0]
|
|
132
|
+
region = plain[i : i + len(old_lines)]
|
|
133
|
+
body = reindent(new.strip("\n"), old_lines, region) if indent else new.strip("\n")
|
|
134
|
+
return Found(splice(file_lines, i, i + len(old_lines), body), how, i + 1)
|
|
135
|
+
if len(hits) > 1:
|
|
136
|
+
return Miss("ambiguous", "\n\n".join(numbered(plain[i : i + len(old_lines)], i + 1) for i in hits[:3]))
|
|
137
|
+
# Without blank lines: map the non-blank lines back to the file.
|
|
138
|
+
solid = [line for line in old_lines if line.strip()]
|
|
139
|
+
content = [(i, line) for i, line in enumerate(plain) if line.strip()]
|
|
140
|
+
keys = [line.strip() for _, line in content]
|
|
141
|
+
wanted = [line.strip() for line in solid]
|
|
142
|
+
hits = [j for j in range(len(keys) - len(wanted) + 1) if keys[j : j + len(wanted)] == wanted]
|
|
143
|
+
first, last = (content[hits[0]][0], content[hits[0] + len(wanted) - 1][0]) if len(hits) == 1 else (0, -1)
|
|
144
|
+
if len(hits) == 1 and last - first + 1 != len(old_lines):
|
|
145
|
+
body = reindent(new.strip("\n"), old_lines, plain[first : last + 1])
|
|
146
|
+
return Found(splice(file_lines, first, last + 1, body), "ignoring blank lines", first + 1)
|
|
147
|
+
# 3. A very close match of the same number of lines, clearly better than any other. Only the lines the model
|
|
148
|
+
# changed change: the rest stays as the file has it now, so an earlier edit the model's copy doesn't show
|
|
149
|
+
# survives. Where a changed line differs in the file by more than a typo, the edit is refused.
|
|
150
|
+
if len(plain) > MAX_SIMILARITY_LINES:
|
|
151
|
+
return Miss("not found", "")
|
|
152
|
+
n = len(old_lines)
|
|
153
|
+
target = "\n".join(line.strip() for line in old_lines)
|
|
154
|
+
scores = []
|
|
155
|
+
for j in range(len(plain) - n + 1):
|
|
156
|
+
if (old_lines[0].strip() and not plain[j].strip()) or (old_lines[-1].strip() and not plain[j + n - 1].strip()):
|
|
157
|
+
continue # a window that starts or ends on a blank line where the model's text doesn't
|
|
158
|
+
window = "\n".join(line.strip() for line in plain[j : j + n])
|
|
159
|
+
matcher = SequenceMatcher(None, target, window, autojunk=False)
|
|
160
|
+
if matcher.real_quick_ratio() < HINT_FLOOR or matcher.quick_ratio() < HINT_FLOOR:
|
|
161
|
+
continue
|
|
162
|
+
scores.append((matcher.ratio(), j))
|
|
163
|
+
scores.sort(reverse=True)
|
|
164
|
+
if not scores or scores[0][0] < HINT_FLOOR:
|
|
165
|
+
return Miss("not found", "")
|
|
166
|
+
best, j = scores[0]
|
|
167
|
+
runner_up = scores[1][0] if len(scores) > 1 else 0.0
|
|
168
|
+
if best >= CLOSE_ENOUGH and best - runner_up >= CLEAR_LEAD:
|
|
169
|
+
merged = merge(old_lines, new.strip("\n").split("\n"), plain[j : j + n])
|
|
170
|
+
if merged is not None:
|
|
171
|
+
body = "\n".join(merged)
|
|
172
|
+
return Found(splice(file_lines, j, j + n, body), f"at a close match ({best:.0%} alike)", j + 1)
|
|
173
|
+
# To show the model where to look, compare line by line: one long line doesn't outweigh the rest.
|
|
174
|
+
hint = max(scores[:20], key=lambda score: line_by_line(old_lines, plain[score[1] : score[1] + n]))[1]
|
|
175
|
+
return Miss("not found", numbered(plain[hint : hint + n], hint + 1))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def line_by_line(old: list[str], region: list[str]) -> float:
|
|
179
|
+
pairs = list(zip(old, region, strict=False)) # the region may end early, at the end of the file
|
|
180
|
+
return sum(SequenceMatcher(None, a.strip(), b.strip(), autojunk=False).ratio() for a, b in pairs) / len(pairs)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def merge(old: list[str], new: list[str], region: list[str]) -> list[str] | None:
|
|
184
|
+
"""Apply the model's change (old -> new) to the file's lines (`region`, aligned with `old`). None when a line
|
|
185
|
+
it changes differs in the file by more than a typo: the model's copy is out of date there."""
|
|
186
|
+
new = reindent("\n".join(new), old, region).split("\n")
|
|
187
|
+
out: list[str] = []
|
|
188
|
+
ops = SequenceMatcher(None, [line.strip() for line in old], [line.strip() for line in new], autojunk=False)
|
|
189
|
+
for tag, i1, i2, j1, j2 in ops.get_opcodes():
|
|
190
|
+
if tag == "equal":
|
|
191
|
+
out += region[i1:i2] # as the file has them
|
|
192
|
+
continue
|
|
193
|
+
for k in range(i1, i2):
|
|
194
|
+
alike = SequenceMatcher(None, old[k].strip(), region[k].strip(), autojunk=False).ratio()
|
|
195
|
+
if old[k].strip() != region[k].strip() and alike < CLOSE_ENOUGH:
|
|
196
|
+
return None
|
|
197
|
+
out += new[j1:j2]
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ----------------------------------------------------------------------------- unified diffs
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class Hunk:
|
|
206
|
+
header: str # the @@ line, for messages
|
|
207
|
+
old: str # its context and removed lines
|
|
208
|
+
new: str # its context and added lines
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class FilePatch:
|
|
213
|
+
path: str # without a/ and b/
|
|
214
|
+
hunks: list[Hunk]
|
|
215
|
+
new_file: bool = False
|
|
216
|
+
deleted: bool = False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def strip_prefix(path: str) -> str:
|
|
220
|
+
path = path.strip().split("\t")[0]
|
|
221
|
+
return path[2:] if path[:2] in ("a/", "b/") else path
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def parse_patch(patch: str) -> list[FilePatch]:
|
|
225
|
+
"""A unified diff (as git diff writes it) as files and hunks. The @@ line numbers aren't trusted: hunks are
|
|
226
|
+
found by their text. Raises ValueError when it isn't a diff."""
|
|
227
|
+
files: list[FilePatch] = []
|
|
228
|
+
hunk: list[str] | None = None
|
|
229
|
+
current: FilePatch | None = None
|
|
230
|
+
|
|
231
|
+
def close() -> None:
|
|
232
|
+
nonlocal hunk
|
|
233
|
+
if hunk is not None and current is not None:
|
|
234
|
+
old = [line[1:] for line in hunk[1:] if line[:1] in (" ", "-")]
|
|
235
|
+
new = [line[1:] for line in hunk[1:] if line[:1] in (" ", "+")]
|
|
236
|
+
current.hunks.append(Hunk(hunk[0], "\n".join(old), "\n".join(new)))
|
|
237
|
+
hunk = None
|
|
238
|
+
|
|
239
|
+
lines = patch.replace("\r\n", "\n").split("\n")
|
|
240
|
+
i = 0
|
|
241
|
+
while i < len(lines):
|
|
242
|
+
line = lines[i]
|
|
243
|
+
if line.startswith("--- ") and i + 1 < len(lines) and lines[i + 1].startswith("+++ "):
|
|
244
|
+
close()
|
|
245
|
+
old_path, new_path = line[4:], lines[i + 1][4:]
|
|
246
|
+
gone, born = old_path.strip() == "/dev/null", new_path.strip() == "/dev/null"
|
|
247
|
+
current = FilePatch(strip_prefix(old_path if born else new_path), [], new_file=gone, deleted=born)
|
|
248
|
+
files.append(current)
|
|
249
|
+
i += 2
|
|
250
|
+
continue
|
|
251
|
+
if line.startswith("@@") and current is not None:
|
|
252
|
+
close()
|
|
253
|
+
hunk = [line]
|
|
254
|
+
elif hunk is not None:
|
|
255
|
+
if line.startswith(("\\ No newline", "diff --git", "index ")):
|
|
256
|
+
pass
|
|
257
|
+
elif line == "" and i == len(lines) - 1:
|
|
258
|
+
pass # the patch's own final newline
|
|
259
|
+
else:
|
|
260
|
+
hunk.append(line if line[:1] in (" ", "-", "+") else " " + line) # a context line that lost its space
|
|
261
|
+
i += 1
|
|
262
|
+
close()
|
|
263
|
+
if not files or not any(f.hunks for f in files):
|
|
264
|
+
raise ValueError("no ---/+++ file headers with @@ hunks: write a unified diff, as git diff does")
|
|
265
|
+
return files
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def apply_hunks(text: str, hunks: list[Hunk]) -> tuple[str, list[str]]:
|
|
269
|
+
"""Apply hunks one after another; returns the text and how each loose match was made. Raises ValueError
|
|
270
|
+
naming the hunk that can't be placed."""
|
|
271
|
+
notes = []
|
|
272
|
+
for n, hunk in enumerate(hunks, 1):
|
|
273
|
+
if not hunk.old.strip():
|
|
274
|
+
raise ValueError(f"hunk {n} ({hunk.header}) has no context lines to place it by")
|
|
275
|
+
count = text.count(hunk.old)
|
|
276
|
+
if count == 1:
|
|
277
|
+
text = text.replace(hunk.old, hunk.new, 1)
|
|
278
|
+
continue
|
|
279
|
+
if count > 1:
|
|
280
|
+
raise ValueError(f"hunk {n} ({hunk.header}) matches {count} places: add more context lines")
|
|
281
|
+
found = locate(text, hunk.old, hunk.new)
|
|
282
|
+
if isinstance(found, Miss):
|
|
283
|
+
where = f" The closest text is:\n{found.hint}" if found.hint else ""
|
|
284
|
+
raise ValueError(f"hunk {n} ({hunk.header}) doesn't match the file.{where}")
|
|
285
|
+
text = found.text
|
|
286
|
+
notes.append(f"hunk {n} {found.how}")
|
|
287
|
+
return text, notes
|
lcode/repeats.py
CHANGED
|
@@ -82,7 +82,7 @@ class Repeats:
|
|
|
82
82
|
)
|
|
83
83
|
hint = (
|
|
84
84
|
"If it's an edit, read the file again and copy the text exactly. "
|
|
85
|
-
if failed and name in ("edit_file", "write_file")
|
|
85
|
+
if failed and name in ("edit_file", "write_file", "apply_patch")
|
|
86
86
|
else ""
|
|
87
87
|
)
|
|
88
88
|
return Verdict(
|
|
@@ -97,7 +97,7 @@ def changes_things(name: str, args: dict, result: str) -> bool:
|
|
|
97
97
|
"""Whether the call may have changed files (or anything else a later call could see)."""
|
|
98
98
|
if name in UNCHANGING:
|
|
99
99
|
return False
|
|
100
|
-
if name in ("edit_file", "write_file"):
|
|
100
|
+
if name in ("edit_file", "write_file", "apply_patch"):
|
|
101
101
|
return not result.startswith(FAILED)
|
|
102
102
|
if name == "bash":
|
|
103
103
|
return not is_read_only(str(args.get("command", "")))
|
lcode/subagents.py
CHANGED
|
@@ -69,7 +69,7 @@ READ_ONLY_TOOLS = frozenset(
|
|
|
69
69
|
"search_code",
|
|
70
70
|
}
|
|
71
71
|
)
|
|
72
|
-
EDIT_TOOLS = frozenset({"write_file", "edit_file"})
|
|
72
|
+
EDIT_TOOLS = frozenset({"write_file", "edit_file", "apply_patch"})
|
|
73
73
|
NOT_FOR_SUBAGENTS = frozenset({"agent", "memory"}) # no agents inside agents; only the main session remembers
|
|
74
74
|
MAX_STEPS = 200
|
|
75
75
|
MAX_UNTRACKED_BYTES = 10 * 1024 * 1024 # larger untracked files aren't copied into a worker's worktree
|
lcode/tools.py
CHANGED
|
@@ -28,7 +28,7 @@ from rich.panel import Panel
|
|
|
28
28
|
from rich.syntax import Syntax
|
|
29
29
|
from rich.text import Text
|
|
30
30
|
|
|
31
|
-
from lcode import secretscan, web
|
|
31
|
+
from lcode import matching, secretscan, web
|
|
32
32
|
from lcode.context import OutputSpool, shorten
|
|
33
33
|
from lcode.jobs import JobError, describe, stop_leftovers
|
|
34
34
|
from lcode.permissions import bash_key, is_read_only
|
|
@@ -96,6 +96,15 @@ SCHEMAS = [
|
|
|
96
96
|
},
|
|
97
97
|
["path", "old_string", "new_string"],
|
|
98
98
|
),
|
|
99
|
+
_fn(
|
|
100
|
+
"apply_patch",
|
|
101
|
+
"Apply a unified diff to one or more files, as `git diff` writes it: `--- a/path` and `+++ b/path` "
|
|
102
|
+
"headers, then `@@` hunks of context lines (starting with a space), removed lines (-) and added lines (+). "
|
|
103
|
+
"Hunks are found by their text, so the @@ line numbers may be off; include 2-3 context lines around each "
|
|
104
|
+
"change. Use `--- /dev/null` for a new file. For a single change, edit_file is simpler.",
|
|
105
|
+
{"patch": {"type": "string", "description": "The unified diff"}},
|
|
106
|
+
["patch"],
|
|
107
|
+
),
|
|
99
108
|
_fn(
|
|
100
109
|
"list_dir",
|
|
101
110
|
"Show a directory tree (skips .git, node_modules, virtualenvs and caches).",
|
|
@@ -375,22 +384,6 @@ def tree(base: Path, depth: int = 2, limit: int = 400) -> str:
|
|
|
375
384
|
return "\n".join(lines)
|
|
376
385
|
|
|
377
386
|
|
|
378
|
-
def fuzzy_replace(text: str, old: str, new: str) -> str | None:
|
|
379
|
-
"""Replace `old` with `new` matching lines while ignoring trailing whitespace. None unless exactly one match."""
|
|
380
|
-
t_lines = text.splitlines(keepends=True)
|
|
381
|
-
o_lines = [line.rstrip() for line in old.strip("\n").splitlines()]
|
|
382
|
-
if not o_lines:
|
|
383
|
-
return None
|
|
384
|
-
n = len(o_lines)
|
|
385
|
-
matches = [i for i in range(len(t_lines) - n + 1) if [ln.rstrip() for ln in t_lines[i : i + n]] == o_lines]
|
|
386
|
-
if len(matches) != 1:
|
|
387
|
-
return None
|
|
388
|
-
i = matches[0]
|
|
389
|
-
tail = "\n" if t_lines[i + n - 1].endswith("\n") else ""
|
|
390
|
-
replacement = new.strip("\n") + tail if new.strip("\n") else ""
|
|
391
|
-
return "".join(t_lines[:i]) + replacement + "".join(t_lines[i + n :])
|
|
392
|
-
|
|
393
|
-
|
|
394
387
|
def parse_text_tool_calls(content: str) -> list[dict]:
|
|
395
388
|
"""Recover tool calls that a model printed as text instead of emitting structured calls."""
|
|
396
389
|
calls = []
|
|
@@ -703,9 +696,10 @@ class Toolbox:
|
|
|
703
696
|
def t_read_file(self, path: str, offset: int = 1, limit: int = 2000) -> str:
|
|
704
697
|
p = self.path(path, read=True)
|
|
705
698
|
if not p.exists():
|
|
706
|
-
raise ToolError(f"{path} does not exist")
|
|
699
|
+
raise ToolError(f"{path} does not exist.{self._did_you_mean(path)}")
|
|
707
700
|
if p.is_dir():
|
|
708
|
-
|
|
701
|
+
listing = tree(p, depth=1)
|
|
702
|
+
return f"{self.rel(p)} is a directory, so here's what's in it (read a file with read_file):\n{listing}"
|
|
709
703
|
if is_image(p):
|
|
710
704
|
raise ToolError(f"{path} is an image; look at it with view_image")
|
|
711
705
|
if is_binary(p):
|
|
@@ -736,8 +730,10 @@ class Toolbox:
|
|
|
736
730
|
|
|
737
731
|
def t_list_dir(self, path: str = ".", depth: int = 2) -> str:
|
|
738
732
|
p = self.path(path, read=True)
|
|
733
|
+
if p.is_file():
|
|
734
|
+
return f"{self.rel(p)} is a file, not a directory: read it with read_file."
|
|
739
735
|
if not p.is_dir():
|
|
740
|
-
raise ToolError(f"{path}
|
|
736
|
+
raise ToolError(f"{path} does not exist.{self._did_you_mean(path)}")
|
|
741
737
|
return tree(p, depth=max(1, min(int(depth or 2), 6)))
|
|
742
738
|
|
|
743
739
|
def t_glob(self, pattern: str, path: str = ".") -> str:
|
|
@@ -749,6 +745,8 @@ class Toolbox:
|
|
|
749
745
|
]
|
|
750
746
|
hits.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
751
747
|
if not hits:
|
|
748
|
+
if not base.is_dir():
|
|
749
|
+
return f"No files found: {path} does not exist.{self._did_you_mean(path)}"
|
|
752
750
|
return "No files found."
|
|
753
751
|
more = f"\n... and {len(hits) - 200} more" if len(hits) > 200 else ""
|
|
754
752
|
return "\n".join(self.rel(p) for p in hits[:200]) + more
|
|
@@ -757,6 +755,7 @@ class Toolbox:
|
|
|
757
755
|
self, pattern: str, path: str = ".", glob: str | None = None, ignore_case: bool = False, context: int = 0
|
|
758
756
|
) -> str:
|
|
759
757
|
p = self.path(path)
|
|
758
|
+
literal = False # the pattern wasn't a valid regex, so it was searched as plain text
|
|
760
759
|
if shutil.which("rg"):
|
|
761
760
|
cmd = ["rg", "--line-number", "--no-heading", "--with-filename", "--hidden", "--color", "never"]
|
|
762
761
|
cmd += ["--max-columns", "400"]
|
|
@@ -768,16 +767,24 @@ class Toolbox:
|
|
|
768
767
|
cmd += ["-g", glob]
|
|
769
768
|
for d in IGNORE_DIRS:
|
|
770
769
|
cmd += ["-g", f"!{d}/"]
|
|
771
|
-
|
|
772
|
-
|
|
770
|
+
if not p.exists():
|
|
771
|
+
raise ToolError(f"{path} does not exist.{self._did_you_mean(path)}")
|
|
772
|
+
r = subprocess.run([*cmd, "-e", pattern, str(p)], capture_output=True, text=True, timeout=60)
|
|
773
|
+
if r.returncode == 2 and "regex parse error" in r.stderr:
|
|
774
|
+
# "def add(" is meant literally: search for the text instead of failing
|
|
775
|
+
r = subprocess.run([*cmd, "-F", "-e", pattern, str(p)], capture_output=True, text=True, timeout=60)
|
|
776
|
+
literal = True
|
|
773
777
|
if r.returncode == 2:
|
|
774
778
|
raise ToolError(r.stderr.strip())
|
|
775
779
|
out = r.stdout
|
|
776
780
|
else:
|
|
781
|
+
if not p.exists():
|
|
782
|
+
raise ToolError(f"{path} does not exist.{self._did_you_mean(path)}")
|
|
777
783
|
try:
|
|
778
784
|
rx = re.compile(pattern, re.I if ignore_case else 0)
|
|
779
|
-
except re.error
|
|
780
|
-
|
|
785
|
+
except re.error:
|
|
786
|
+
rx = re.compile(re.escape(pattern), re.I if ignore_case else 0) # meant literally
|
|
787
|
+
literal = True
|
|
781
788
|
results = []
|
|
782
789
|
for f in [p] if p.is_file() else walk_files(p):
|
|
783
790
|
if (glob and not fnmatch.fnmatch(f.name, glob)) or is_binary(f):
|
|
@@ -791,13 +798,38 @@ class Toolbox:
|
|
|
791
798
|
out = "\n".join(results)
|
|
792
799
|
out = out.replace(str(self.agent.cwd) + os.sep, "")
|
|
793
800
|
lines = out.splitlines()
|
|
801
|
+
note = "[The pattern isn't a valid regular expression, so it was searched as plain text.]\n" if literal else ""
|
|
794
802
|
if not lines:
|
|
795
|
-
return "No matches."
|
|
803
|
+
return note + "No matches."
|
|
796
804
|
if len(lines) > 400:
|
|
797
|
-
return "\n".join(lines[:400]) + f"\n... [{len(lines) - 400} more lines; narrow the search]"
|
|
798
|
-
return out
|
|
805
|
+
return note + "\n".join(lines[:400]) + f"\n... [{len(lines) - 400} more lines; narrow the search]"
|
|
806
|
+
return note + out
|
|
799
807
|
|
|
800
808
|
# -- file changes
|
|
809
|
+
def _did_you_mean(self, path: str) -> str:
|
|
810
|
+
"""Existing paths close to one that doesn't exist: the same name elsewhere in the project, or a near name
|
|
811
|
+
in the nearest folder that exists. Saves the model a search, or a guess."""
|
|
812
|
+
wanted = self.resolve(path)
|
|
813
|
+
found: list[Path] = []
|
|
814
|
+
from lcode.checkpoints import work_tree_for
|
|
815
|
+
|
|
816
|
+
for i, candidate in enumerate(walk_files(work_tree_for(self.agent.cwd))):
|
|
817
|
+
if i > 20_000 or len(found) >= 3:
|
|
818
|
+
break
|
|
819
|
+
if candidate.name == wanted.name:
|
|
820
|
+
found.append(candidate)
|
|
821
|
+
folder = wanted.parent
|
|
822
|
+
while not folder.is_dir() and folder != folder.parent:
|
|
823
|
+
folder = folder.parent
|
|
824
|
+
try:
|
|
825
|
+
names = [e.name + ("/" if e.is_dir() else "") for e in folder.iterdir() if e.name not in IGNORE_DIRS]
|
|
826
|
+
except OSError:
|
|
827
|
+
names = []
|
|
828
|
+
for name in difflib.get_close_matches(wanted.name, names, n=3, cutoff=0.6):
|
|
829
|
+
found.append(folder / name.rstrip("/"))
|
|
830
|
+
shown = list(dict.fromkeys(self.rel(p) + ("/" if p.is_dir() else "") for p in found))[:3]
|
|
831
|
+
return f" Did you mean: {', '.join(shown)}?" if shown else ""
|
|
832
|
+
|
|
801
833
|
def _check_fresh(self, p: Path) -> None:
|
|
802
834
|
key = str(p)
|
|
803
835
|
if key not in self.read_mtimes:
|
|
@@ -873,6 +905,63 @@ class Toolbox:
|
|
|
873
905
|
"on its current content."
|
|
874
906
|
)
|
|
875
907
|
|
|
908
|
+
def t_apply_patch(self, patch: str) -> str:
|
|
909
|
+
try:
|
|
910
|
+
files = matching.parse_patch(patch)
|
|
911
|
+
except ValueError as e:
|
|
912
|
+
raise ToolError(str(e)) from e
|
|
913
|
+
planned: list[tuple[Path, str | None, str, list[str]]] = [] # path, before, after, notes
|
|
914
|
+
for fp in files:
|
|
915
|
+
p = self.path(fp.path)
|
|
916
|
+
if fp.deleted:
|
|
917
|
+
raise ToolError(f"{fp.path}: deleting files isn't something apply_patch does; use bash (rm)")
|
|
918
|
+
if fp.new_file:
|
|
919
|
+
if p.exists():
|
|
920
|
+
raise ToolError(f"{fp.path} already exists; the patch creates it from /dev/null")
|
|
921
|
+
planned.append((p, None, "\n".join(h.new for h in fp.hunks) + "\n", []))
|
|
922
|
+
continue
|
|
923
|
+
if not p.is_file():
|
|
924
|
+
raise ToolError(f"{fp.path} does not exist (use --- /dev/null to create it)")
|
|
925
|
+
self._check_fresh(p)
|
|
926
|
+
before = self.read_text(p)
|
|
927
|
+
try:
|
|
928
|
+
after, notes = matching.apply_hunks(before, fp.hunks)
|
|
929
|
+
except ValueError as e:
|
|
930
|
+
raise ToolError(f"{fp.path}: {e}") from e
|
|
931
|
+
planned.append((p, before, after, notes))
|
|
932
|
+
done = []
|
|
933
|
+
for p, before, after, notes in planned:
|
|
934
|
+
body = self._diff(p, before or "", after)
|
|
935
|
+
verb = "Create" if before is None else "Patch"
|
|
936
|
+
ok, feedback = self.agent.perms.request("edit", "edit", f"{verb} {self.rel(p)}", body, self.project_rel(p))
|
|
937
|
+
if not ok:
|
|
938
|
+
return feedback + (f" (already patched: {', '.join(done)})" if done else "")
|
|
939
|
+
if (self.read_text(p) if p.exists() else None) != before:
|
|
940
|
+
return self._changed_meanwhile(p)
|
|
941
|
+
self.agent.checkpoint()
|
|
942
|
+
self._write(p, after)
|
|
943
|
+
self.console.print(f" [green]✓[/] {'Created' if before is None else 'Patched'} {self.rel(p)}")
|
|
944
|
+
done.append(self.rel(p) + (f" ({'; '.join(notes)})" if notes else ""))
|
|
945
|
+
problems = self._check(p, before, after)
|
|
946
|
+
if problems:
|
|
947
|
+
done[-1] += problems
|
|
948
|
+
return "Patched " + ", ".join(done) + "."
|
|
949
|
+
|
|
950
|
+
def _edit_miss(self, p: Path, miss: matching.Miss) -> str:
|
|
951
|
+
"""Why an edit can't be placed, with the closest lines so the model can copy them exactly."""
|
|
952
|
+
if miss.reason == "ambiguous":
|
|
953
|
+
return (
|
|
954
|
+
f"old_string isn't in {self.rel(p)} exactly, and it's close to more than one place:\n{miss.hint}\n"
|
|
955
|
+
"Add surrounding lines to old_string so it matches only one, copied exactly."
|
|
956
|
+
)
|
|
957
|
+
message = f"old_string not found in {self.rel(p)}. It must match the file exactly, including indentation."
|
|
958
|
+
if miss.hint:
|
|
959
|
+
return (
|
|
960
|
+
message
|
|
961
|
+
+ f" The closest text is:\n{miss.hint}\nCopy what you mean from it exactly (without the line numbers)."
|
|
962
|
+
)
|
|
963
|
+
return message + " Re-read the file and copy the text precisely (without line-number prefixes)."
|
|
964
|
+
|
|
876
965
|
def t_edit_file(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
|
|
877
966
|
p = self.path(path)
|
|
878
967
|
if not p.exists():
|
|
@@ -884,14 +973,12 @@ class Toolbox:
|
|
|
884
973
|
raise ToolError("old_string is empty; use write_file to create or overwrite files")
|
|
885
974
|
text = self.read_text(p)
|
|
886
975
|
count = text.count(old_string)
|
|
976
|
+
how, start_line = "", 0
|
|
887
977
|
if count == 0:
|
|
888
|
-
|
|
889
|
-
if
|
|
890
|
-
raise ToolError(
|
|
891
|
-
|
|
892
|
-
"Re-read the file and copy the text precisely (without line-number prefixes)."
|
|
893
|
-
)
|
|
894
|
-
count = 1
|
|
978
|
+
found = matching.locate(text, old_string, new_string)
|
|
979
|
+
if isinstance(found, matching.Miss):
|
|
980
|
+
raise ToolError(self._edit_miss(p, found))
|
|
981
|
+
new_text, how, start_line, count = found.text, found.how, found.start_line, 1
|
|
895
982
|
elif count > 1 and not replace_all:
|
|
896
983
|
raise ToolError(
|
|
897
984
|
f"old_string occurs {count} times in {self.rel(p)}. Add surrounding lines to make it unique, "
|
|
@@ -909,15 +996,16 @@ class Toolbox:
|
|
|
909
996
|
self._write(p, new_text)
|
|
910
997
|
self.console.print(f" [green]✓[/] Edited {self.rel(p)}")
|
|
911
998
|
problems = self._check(p, text, new_text)
|
|
912
|
-
|
|
913
|
-
if
|
|
914
|
-
|
|
999
|
+
done = f"Edited {self.rel(p)} ({count} replacement(s)" + (f", {how}" if how else "") + ")."
|
|
1000
|
+
idx = new_text.find(new_string) if new_string and not how else -1
|
|
1001
|
+
if idx < 0 and not start_line:
|
|
1002
|
+
return done + problems
|
|
915
1003
|
# Show the model the edited region so it can verify the result.
|
|
916
|
-
start = new_text.count("\n", 0, idx) + 1
|
|
1004
|
+
start = new_text.count("\n", 0, idx) + 1 if idx >= 0 else start_line
|
|
917
1005
|
lines = new_text.splitlines()
|
|
918
1006
|
lo, hi = max(1, start - 3), min(len(lines), start + new_string.count("\n") + 3)
|
|
919
1007
|
snippet = "\n".join(f"{i:6}\t{lines[i - 1]}" for i in range(lo, hi + 1))
|
|
920
|
-
return f"
|
|
1008
|
+
return f"{done} Result:\n{snippet}" + problems
|
|
921
1009
|
|
|
922
1010
|
# -- shell
|
|
923
1011
|
def t_bash(self, command: str, timeout: int = 180, background: bool = False) -> str:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.30.0
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=_ncPdkojpmAnL6Af19KuRAd4o_TixpnzWrgz3usKy0A,473
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
|
-
lcode/acp.py,sha256=
|
|
3
|
+
lcode/acp.py,sha256=d97VV3sbqXdWKhPQQNbYRaDTm8WrUk0mGbDLiOyX7jE,29058
|
|
4
4
|
lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
|
|
5
|
-
lcode/agent.py,sha256=
|
|
6
|
-
lcode/api.py,sha256=
|
|
5
|
+
lcode/agent.py,sha256=CVOyaV_KJQor1zlW2zou3iQXzf9UPV-3emTmuqJ5JVE,69562
|
|
6
|
+
lcode/api.py,sha256=PhG8PZReM7DXSxG6wf7mEjAKx_TAsgj3xHApacrXGCM,15684
|
|
7
7
|
lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
|
|
8
|
-
lcode/bench.py,sha256=
|
|
8
|
+
lcode/bench.py,sha256=KDgPnkceZOS3gDDEl5j6-k8Ser2es4-IuV_S8p_46Zo,41517
|
|
9
9
|
lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
|
|
10
10
|
lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
|
|
11
11
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
12
12
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
13
|
-
lcode/
|
|
13
|
+
lcode/checks.py,sha256=FOD8DOxPWXY1RMtrL9ONJsOxsV10BLgzPvzIJMK5NrU,6582
|
|
14
|
+
lcode/cli.py,sha256=svnW6pfLtqiMXbA3wMlYESKGgGe6Xb9wlWkqg6Cz0O0,41081
|
|
14
15
|
lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
|
|
15
16
|
lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
|
|
16
|
-
lcode/config.py,sha256=
|
|
17
|
+
lcode/config.py,sha256=KSjSBvxA9nmd4h4QYk3xp8BQ7C04pjTULsSn0jcQwBc,11301
|
|
17
18
|
lcode/context.py,sha256=WSMiitscDh2Juy4Hx0x9EPNWrn804IbOWRZly1-DU38,17022
|
|
18
19
|
lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
|
|
19
20
|
lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
|
|
@@ -23,6 +24,7 @@ lcode/hooks.py,sha256=K0itOiAuH4rxndJ9gne_uCrqby6oWAzFPRrfB3tCq-E,9324
|
|
|
23
24
|
lcode/jobs.py,sha256=P1P3FqI6EqL4LgdMX9YGZwCW0Bh5BRXFA_FnQxpHNjE,5618
|
|
24
25
|
lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
|
|
25
26
|
lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
|
|
27
|
+
lcode/matching.py,sha256=2N3_qwKJ5gd8el2m_y_egKkFM9rsF1eUhYJcRCVIp98,14015
|
|
26
28
|
lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
|
|
27
29
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
28
30
|
lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
|
|
@@ -30,15 +32,15 @@ lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
|
|
|
30
32
|
lcode/permissions.py,sha256=3OPVVth6uTE5kcAWMZ8X_vUnFRbf6STEHAJimZX0Yu4,12118
|
|
31
33
|
lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
|
|
32
34
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
33
|
-
lcode/repeats.py,sha256
|
|
35
|
+
lcode/repeats.py,sha256=-vcbj363nFcIyso7yFJgsbHz9C6VOu2EOg3Svs4eFyE,4091
|
|
34
36
|
lcode/repl.py,sha256=ayrKySMbbRbax_WMjqYBN4fkcDpzuHniefJC3DZ4-2Y,50687
|
|
35
37
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
36
38
|
lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
|
|
37
39
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
38
40
|
lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
|
|
39
41
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
40
|
-
lcode/subagents.py,sha256=
|
|
41
|
-
lcode/tools.py,sha256=
|
|
42
|
+
lcode/subagents.py,sha256=GGn2E7QUwEsShJEHXaMhG53leyGFUZwJku_n33gJguU,29157
|
|
43
|
+
lcode/tools.py,sha256=1zH4hkEyHQz4byV348J4zfe0VzwvjmlH1MOqiobznbA,59446
|
|
42
44
|
lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
|
|
43
45
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
44
46
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
@@ -58,8 +60,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
|
|
|
58
60
|
lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
59
61
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
60
62
|
lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
|
|
61
|
-
lcode_cli-0.
|
|
62
|
-
lcode_cli-0.
|
|
63
|
-
lcode_cli-0.
|
|
64
|
-
lcode_cli-0.
|
|
65
|
-
lcode_cli-0.
|
|
63
|
+
lcode_cli-0.30.0.dist-info/METADATA,sha256=s6dAfe3HCeh9LIPNLLoYY3VGXfGT6b8ZW0pZbERa6X0,12620
|
|
64
|
+
lcode_cli-0.30.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
65
|
+
lcode_cli-0.30.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
|
|
66
|
+
lcode_cli-0.30.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
67
|
+
lcode_cli-0.30.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|