lcode-cli 0.28.0__py3-none-any.whl → 0.29.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/agent.py +120 -4
- lcode/api.py +4 -0
- lcode/checks.py +160 -0
- lcode/cli.py +6 -0
- lcode/config.py +1 -0
- lcode/context.py +68 -0
- lcode/permissions.py +153 -31
- lcode/repl.py +1 -0
- lcode/tools.py +160 -33
- {lcode_cli-0.28.0.dist-info → lcode_cli-0.29.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.28.0.dist-info → lcode_cli-0.29.0.dist-info}/RECORD +15 -14
- {lcode_cli-0.28.0.dist-info → lcode_cli-0.29.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.28.0.dist-info → lcode_cli-0.29.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.28.0.dist-info → lcode_cli-0.29.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -6,6 +6,7 @@ import base64
|
|
|
6
6
|
import contextlib
|
|
7
7
|
import datetime as dt
|
|
8
8
|
import json
|
|
9
|
+
import os
|
|
9
10
|
import platform
|
|
10
11
|
import re
|
|
11
12
|
import shutil
|
|
@@ -26,6 +27,7 @@ from rich.text import Text
|
|
|
26
27
|
|
|
27
28
|
from lcode import (
|
|
28
29
|
catalog,
|
|
30
|
+
checks,
|
|
29
31
|
codesearch,
|
|
30
32
|
extensions,
|
|
31
33
|
limits,
|
|
@@ -80,7 +82,15 @@ EVENT_OUTPUT = 4000 # characters of a tool's output in an event
|
|
|
80
82
|
REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
|
|
81
83
|
MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
|
|
82
84
|
# How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
|
|
83
|
-
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
|
+
)
|
|
92
|
+
RESULT_SHARE = 0.25 # of the context window, at most, for one tool result
|
|
93
|
+
MIN_RESULT_CHARS = 8000
|
|
84
94
|
READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
|
|
85
95
|
AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
|
|
86
96
|
PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
|
|
@@ -111,7 +121,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
|
|
|
111
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.
|
|
112
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.
|
|
113
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.
|
|
114
|
-
- 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}
|
|
115
125
|
|
|
116
126
|
# Communication
|
|
117
127
|
- Be concise and direct; no filler. Use GitHub-flavored markdown.
|
|
@@ -265,6 +275,7 @@ class Settings:
|
|
|
265
275
|
secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan)
|
|
266
276
|
compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%)
|
|
267
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
|
|
268
279
|
|
|
269
280
|
|
|
270
281
|
class Agent:
|
|
@@ -320,7 +331,11 @@ class Agent:
|
|
|
320
331
|
self.hardware = None # an lcode.hardware.Hardware, set by the CLI: for what fits on the GPU
|
|
321
332
|
self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
|
|
322
333
|
self.load_state = "" # "loading" while the model loads in the background at startup (lcode.repl)
|
|
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
|
|
323
337
|
self.sent_chars = 0 # characters of the conversation the model had already read
|
|
338
|
+
self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
|
|
324
339
|
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
325
340
|
self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
|
|
326
341
|
self.response = None # the streaming response, so another thread can abort it
|
|
@@ -358,6 +373,7 @@ class Agent:
|
|
|
358
373
|
code=self.code_prompt(),
|
|
359
374
|
map=self.map_prompt(),
|
|
360
375
|
look=" you look at with view_image" if self.vision_model() else "",
|
|
376
|
+
checks=self.checks_prompt(),
|
|
361
377
|
)
|
|
362
378
|
|
|
363
379
|
def repo_map(self) -> repomap.RepoMap | None:
|
|
@@ -576,6 +592,7 @@ class Agent:
|
|
|
576
592
|
self.tools.read_mtimes.clear()
|
|
577
593
|
self.reflected = len(self.messages)
|
|
578
594
|
self.ctx_used = len(self.messages[0]["content"]) // 3
|
|
595
|
+
self.counted_chars = None
|
|
579
596
|
|
|
580
597
|
def session_file(self) -> Path:
|
|
581
598
|
return sessions.sessions_dir() / f"{self.session_id}.json"
|
|
@@ -597,7 +614,10 @@ class Agent:
|
|
|
597
614
|
"messages": self.messages,
|
|
598
615
|
"checkpoints": self.checkpoints.to_json(),
|
|
599
616
|
}
|
|
600
|
-
|
|
617
|
+
# Written next to it and then renamed: a crash halfway leaves the previous version, not a broken file.
|
|
618
|
+
partial = f.with_name(f".{f.name}.partial")
|
|
619
|
+
partial.write_text(json.dumps(data))
|
|
620
|
+
os.replace(partial, f)
|
|
601
621
|
|
|
602
622
|
def new_session(self) -> None:
|
|
603
623
|
self.reset()
|
|
@@ -634,6 +654,7 @@ class Agent:
|
|
|
634
654
|
self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
|
|
635
655
|
self.reflected = len(self.messages) # checked for notes to remember when that session ended
|
|
636
656
|
self.ctx_used = sum(len(json.dumps(m)) for m in self.messages) // 3
|
|
657
|
+
self.counted_chars = None
|
|
637
658
|
return note
|
|
638
659
|
|
|
639
660
|
def load_latest(self) -> bool:
|
|
@@ -797,11 +818,69 @@ class Agent:
|
|
|
797
818
|
self.usage["output_tokens"] += final.get("eval_count", 0)
|
|
798
819
|
self.usage["output_ns"] += final.get("eval_duration", 0)
|
|
799
820
|
self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
|
|
800
|
-
self.sent_chars = conversation_chars(self.messages)
|
|
821
|
+
self.sent_chars = self.counted_chars = conversation_chars(self.messages)
|
|
801
822
|
seconds = final.get("eval_duration", 0) / 1e9
|
|
802
823
|
self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
|
|
803
824
|
return message
|
|
804
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
|
+
|
|
805
884
|
def before_request(self, progress: Progress) -> residency.LoadWatch | None:
|
|
806
885
|
"""Set the status line for what comes before the first token: loading the model if it isn't in memory
|
|
807
886
|
(watched until it is), then reading the prompt. Returns the watch while the model loads."""
|
|
@@ -938,11 +1017,16 @@ class Agent:
|
|
|
938
1017
|
note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
|
|
939
1018
|
self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
|
|
940
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
|
|
941
1023
|
malformed = 0
|
|
942
1024
|
looping = "" # the call the model kept repeating, when lcode stopped the request for it
|
|
943
1025
|
for step in range(self.max_steps):
|
|
944
1026
|
if self.cancel is not None and self.cancel.is_set():
|
|
945
1027
|
raise KeyboardInterrupt
|
|
1028
|
+
if step and self.autosave:
|
|
1029
|
+
self.save() # a crash or a closed terminal loses at most this step
|
|
946
1030
|
self.maybe_compact()
|
|
947
1031
|
try:
|
|
948
1032
|
reply = self.assistant_step()
|
|
@@ -971,6 +1055,8 @@ class Agent:
|
|
|
971
1055
|
reason = str(e).rsplit("err=", 1)[-1].strip()
|
|
972
1056
|
elif "XML syntax error" in str(e):
|
|
973
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"
|
|
974
1060
|
else:
|
|
975
1061
|
reason = "invalid JSON"
|
|
976
1062
|
self.console.print("[yellow]The model wrote a malformed tool call; asking it to try again.[/]")
|
|
@@ -984,6 +1070,15 @@ class Agent:
|
|
|
984
1070
|
)
|
|
985
1071
|
continue
|
|
986
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
|
|
987
1082
|
break
|
|
988
1083
|
done: dict[int, str] = {} # results of subagents that ran together
|
|
989
1084
|
together = [
|
|
@@ -1027,6 +1122,9 @@ class Agent:
|
|
|
1027
1122
|
self.console.print(Text(f" {result[:300]}", style="red"))
|
|
1028
1123
|
elif name in ("read_file", "grep", "glob", "list_dir"):
|
|
1029
1124
|
self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s)", style="dim"))
|
|
1125
|
+
budget = self.result_budget()
|
|
1126
|
+
if len(result) > budget and name not in ("agent", "skill"):
|
|
1127
|
+
result = context_tools.shorten(result, budget, f"{name} result")
|
|
1030
1128
|
verdict = self.repeats.check(name, args, result)
|
|
1031
1129
|
if verdict.note:
|
|
1032
1130
|
result += verdict.note
|
|
@@ -1036,6 +1134,10 @@ class Agent:
|
|
|
1036
1134
|
)
|
|
1037
1135
|
if verdict.stop:
|
|
1038
1136
|
looping = self.describe_call(name, args)
|
|
1137
|
+
if name in ("edit_file", "write_file") 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
|
|
1039
1141
|
tool_msg = {"role": "tool", "tool_name": name, "content": result}
|
|
1040
1142
|
if call.get("id"):
|
|
1041
1143
|
tool_msg["tool_call_id"] = call["id"]
|
|
@@ -1161,8 +1263,21 @@ class Agent:
|
|
|
1161
1263
|
limit = min(limit, self.settings.compact_at)
|
|
1162
1264
|
return limit
|
|
1163
1265
|
|
|
1266
|
+
def next_request_tokens(self) -> int:
|
|
1267
|
+
"""The size of the next request: what the model last counted, plus what was added since (tool output,
|
|
1268
|
+
attachments). Deciding on the last count alone let one large tool result overflow the window."""
|
|
1269
|
+
if self.counted_chars is None:
|
|
1270
|
+
return self.ctx_used
|
|
1271
|
+
return self.ctx_used + max(0, conversation_chars(self.messages) - self.counted_chars) // 3
|
|
1272
|
+
|
|
1273
|
+
def result_budget(self) -> int:
|
|
1274
|
+
"""Characters one tool result may take: a share of the window, so one large result can't fill it."""
|
|
1275
|
+
return max(MIN_RESULT_CHARS, int(self.settings.context * 3 * RESULT_SHARE))
|
|
1276
|
+
|
|
1164
1277
|
def maybe_compact(self) -> None:
|
|
1165
1278
|
limit = self.compact_limit()
|
|
1279
|
+
if self.next_request_tokens() > limit:
|
|
1280
|
+
self.ctx_used, self.counted_chars = self.next_request_tokens(), None
|
|
1166
1281
|
if self.ctx_used <= limit:
|
|
1167
1282
|
return
|
|
1168
1283
|
enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
|
|
@@ -1231,6 +1346,7 @@ class Agent:
|
|
|
1231
1346
|
)
|
|
1232
1347
|
self.skills_loaded.clear()
|
|
1233
1348
|
self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
|
|
1349
|
+
self.counted_chars = None
|
|
1234
1350
|
self.summarized_size = self.ctx_used
|
|
1235
1351
|
self.sent_chars = 0
|
|
1236
1352
|
self.reflected = len(self.messages)
|
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,10 +197,12 @@ 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)
|
|
202
204
|
agent.hardware = hw
|
|
205
|
+
agent.autosave = True
|
|
203
206
|
agent.interactive = options.interactive
|
|
204
207
|
if options.max_steps:
|
|
205
208
|
agent.max_steps = options.max_steps
|
|
@@ -302,6 +305,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
|
|
|
302
305
|
tool_calls=list(calls.values()),
|
|
303
306
|
files_changed=files,
|
|
304
307
|
usage=usage,
|
|
308
|
+
checks=list(agent.check_results),
|
|
305
309
|
seconds=round(time.monotonic() - started, 2),
|
|
306
310
|
error=error,
|
|
307
311
|
)
|
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/context.py
CHANGED
|
@@ -14,8 +14,10 @@ full output is saved to a file the model can read if it needs more.
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
|
+
import os
|
|
17
18
|
import re
|
|
18
19
|
import time
|
|
20
|
+
from collections import deque
|
|
19
21
|
from dataclasses import dataclass
|
|
20
22
|
from pathlib import Path
|
|
21
23
|
from typing import TYPE_CHECKING
|
|
@@ -253,6 +255,72 @@ def shorten(text: str, limit: int, label: str = "output") -> str:
|
|
|
253
255
|
return f"{head}{note}{tail}{where}"
|
|
254
256
|
|
|
255
257
|
|
|
258
|
+
class OutputSpool:
|
|
259
|
+
"""Output collected line by line with bounded memory: once it's long, everything goes to a file, and only the
|
|
260
|
+
start, the end and the error-looking lines stay in memory. What `shorten` returns comes out the same."""
|
|
261
|
+
|
|
262
|
+
def __init__(self, limit: int, label: str = "output") -> None:
|
|
263
|
+
self.limit, self.label = limit, label
|
|
264
|
+
self.head: list[str] = [] # everything, until the output gets long; then its start
|
|
265
|
+
self.tail: deque[str] = deque()
|
|
266
|
+
self.tail_chars = 0
|
|
267
|
+
self.errors: list[str] = []
|
|
268
|
+
self.chars = 0
|
|
269
|
+
self.file = None
|
|
270
|
+
self.path: Path | None = None
|
|
271
|
+
|
|
272
|
+
def add(self, line: str) -> None:
|
|
273
|
+
self.chars += len(line)
|
|
274
|
+
if self.file is None:
|
|
275
|
+
self.head.append(line)
|
|
276
|
+
if self.chars > self.limit * 2:
|
|
277
|
+
self._spool()
|
|
278
|
+
return
|
|
279
|
+
try:
|
|
280
|
+
self.file.write(line)
|
|
281
|
+
except OSError:
|
|
282
|
+
pass
|
|
283
|
+
if ERROR_LINE.search(line) and len(self.errors) < MAX_ERROR_LINES:
|
|
284
|
+
self.errors.append(line.strip()[:300])
|
|
285
|
+
self.tail.append(line)
|
|
286
|
+
self.tail_chars += len(line)
|
|
287
|
+
while self.tail_chars > self.limit // 4 and len(self.tail) > 1:
|
|
288
|
+
self.tail_chars -= len(self.tail.popleft())
|
|
289
|
+
|
|
290
|
+
def _spool(self) -> None:
|
|
291
|
+
text = "".join(self.head)
|
|
292
|
+
self.path = save_output(text, self.label)
|
|
293
|
+
if self.path is not None:
|
|
294
|
+
try:
|
|
295
|
+
self.file = self.path.open("a")
|
|
296
|
+
except OSError:
|
|
297
|
+
self.file = None
|
|
298
|
+
if self.file is None: # nowhere to keep it: keep only the start and carry on counting
|
|
299
|
+
self.file = open(os.devnull, "w") # noqa: SIM115
|
|
300
|
+
head = text[: self.limit * 3 // 5]
|
|
301
|
+
self.head = [head]
|
|
302
|
+
|
|
303
|
+
def text(self) -> str:
|
|
304
|
+
if self.file is None:
|
|
305
|
+
return shorten("".join(self.head), self.limit, self.label)
|
|
306
|
+
self.file.close()
|
|
307
|
+
head, tail = "".join(self.head), "".join(self.tail)
|
|
308
|
+
cut = self.chars - len(head) - len(tail)
|
|
309
|
+
note = f"\n\n... [{cut} characters cut"
|
|
310
|
+
if self.errors:
|
|
311
|
+
note += f"; {len(self.errors)} line(s) from them that look like errors:]\n" + "\n".join(self.errors)
|
|
312
|
+
note += "\n[...]\n\n"
|
|
313
|
+
else:
|
|
314
|
+
note += "] ...\n\n"
|
|
315
|
+
where = (
|
|
316
|
+
f"\n[The full {self.label} ({self.chars} characters) is in {self.path}: read parts of it with read_file "
|
|
317
|
+
"if you need them.]"
|
|
318
|
+
if self.path
|
|
319
|
+
else ""
|
|
320
|
+
)
|
|
321
|
+
return f"{head}{note}{tail}{where}"
|
|
322
|
+
|
|
323
|
+
|
|
256
324
|
# ----------------------------------------------------------------------------- what uses the context
|
|
257
325
|
|
|
258
326
|
|
lcode/permissions.py
CHANGED
|
@@ -22,51 +22,173 @@ SAFE_GIT = {"status", "log", "diff", "show", "branch", "ls-files", "rev-parse",
|
|
|
22
22
|
MULTI_WORD = {"git", "npm", "pnpm", "yarn", "pip", "uv", "docker", "cargo", "go", "kubectl", "python", "python3", "brew"} # fmt: skip # noqa: E501
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
# `cd <plain path> &&` in front of a command: harmless, and models write it all the time.
|
|
26
|
+
NOT_ASKED = (
|
|
27
|
+
"This needs the user's permission, and nobody can be asked in this run, so it was not allowed. Do without it "
|
|
28
|
+
"if you can; otherwise finish and say what permission you need."
|
|
29
|
+
)
|
|
30
|
+
LEADING_CD = re.compile(r"""^\s*(?:cd\s+(?:[\w./~@+,=:-]+|'[^']*'|"[^"$`\\]*")\s*&&\s*)+""")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Redirections that write nothing anyone keeps: stderr into stdout, or output thrown away.
|
|
34
|
+
HARMLESS_REDIRECT = re.compile(r"(?<![\w>&])(?:[12]?>&[12]|[12]?>\s*/dev/null)(?=\s|$)")
|
|
35
|
+
OPERATORS = {"|", "||", "&&", ";", "&", ">", ">>", "<", "<<", "<<<", ">&", "<&", ">|", "(", ")", ";;"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def split_command(command: str) -> tuple[list[list[str]], list[str]] | None:
|
|
39
|
+
"""The command's segments (as words) and the operators between them, quotes respected. None when it can't be
|
|
40
|
+
read: command substitution, process substitution or unbalanced quotes."""
|
|
41
|
+
if any(tok in command for tok in ("`", "$(", "<(", ">(")) or "\n" in command.strip():
|
|
42
|
+
return None
|
|
43
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
44
|
+
lexer.whitespace_split = True
|
|
28
45
|
try:
|
|
29
|
-
|
|
46
|
+
tokens = list(lexer)
|
|
30
47
|
except ValueError:
|
|
31
|
-
|
|
48
|
+
return None
|
|
49
|
+
segments: list[list[str]] = [[]]
|
|
50
|
+
operators: list[str] = []
|
|
51
|
+
for token in tokens:
|
|
52
|
+
if token in OPERATORS or (token and set(token) <= set("|&;<>()")):
|
|
53
|
+
operators.append(token)
|
|
54
|
+
segments.append([])
|
|
55
|
+
else:
|
|
56
|
+
segments[-1].append(token)
|
|
57
|
+
return [s for s in segments if s], operators
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def segment_key(words: list[str]) -> str:
|
|
61
|
+
"""The program, plus the subcommand for tools like git (`git commit`, `python -m pytest`)."""
|
|
62
|
+
while words and re.fullmatch(r"\w+=\S*", words[0]):
|
|
63
|
+
words = words[1:] # VAR=value in front
|
|
32
64
|
if not words:
|
|
33
|
-
return "
|
|
65
|
+
return ""
|
|
34
66
|
if words[0] in MULTI_WORD and len(words) > 1:
|
|
35
67
|
if words[1] in ("-m", "-c") and len(words) > 2: # python -m <module>
|
|
36
|
-
return
|
|
37
|
-
return f"
|
|
38
|
-
return
|
|
68
|
+
return " ".join(words[:3])
|
|
69
|
+
return f"{words[0]} {words[1]}"
|
|
70
|
+
return words[0]
|
|
39
71
|
|
|
40
72
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
)
|
|
46
|
-
|
|
73
|
+
def bash_key(command: str) -> str:
|
|
74
|
+
"""What "always allow" remembers. For one program, the program (plus subcommand for git and the like); for a
|
|
75
|
+
chain such as `npm test && rm -rf build`, every program in it, so allowing `npm test` doesn't allow the chain.
|
|
76
|
+
`cd dir &&` in front doesn't count."""
|
|
77
|
+
command = HARMLESS_REDIRECT.sub(" ", LEADING_CD.sub("", command)).strip()
|
|
78
|
+
parsed = split_command(command)
|
|
79
|
+
if parsed is None:
|
|
80
|
+
return "bash:" + " ".join(command.split()) # can't be read: only this exact command
|
|
81
|
+
segments, operators = parsed
|
|
82
|
+
keys, redirected = [], False
|
|
83
|
+
for i, words in enumerate(segments):
|
|
84
|
+
before = operators[i - 1] if i else ""
|
|
85
|
+
if before and before not in ("|", "&&", "||", ";", "&"):
|
|
86
|
+
redirected = True # a file name after > or <, not a program
|
|
87
|
+
continue
|
|
88
|
+
keys.append(segment_key(words))
|
|
89
|
+
key = " + ".join(k for k in keys if k) + (" (redirected)" if redirected else "")
|
|
90
|
+
return f"bash:{key}"
|
|
47
91
|
|
|
48
92
|
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
|
|
93
|
+
def option_values(words: list[str], *options: str) -> list[str]:
|
|
94
|
+
"""The values of options such as `-o FILE`, `-oFILE` or `--output=FILE`."""
|
|
95
|
+
found = []
|
|
96
|
+
for i, word in enumerate(words):
|
|
97
|
+
for option in options:
|
|
98
|
+
if word == option and i + 1 < len(words):
|
|
99
|
+
found.append(words[i + 1])
|
|
100
|
+
elif option.startswith("--") and word.startswith(option + "="):
|
|
101
|
+
found.append(word.split("=", 1)[1])
|
|
102
|
+
elif not option.startswith("--") and word.startswith(option) and len(word) > len(option):
|
|
103
|
+
found.append(word[len(option) :])
|
|
104
|
+
return found
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def flags(words: list[str]) -> list[str]:
|
|
108
|
+
return [w for w in words[1:] if w.startswith("-")]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def positional(words: list[str]) -> list[str]:
|
|
112
|
+
return [w for w in words[1:] if not w.startswith("-")]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Branch and remote listing; anything else creates, deletes, renames or reconfigures.
|
|
116
|
+
BRANCH_LISTING = {
|
|
117
|
+
"-a", "--all", "-r", "--remotes", "-l", "--list", "-v", "-vv", "--verbose", "--show-current", "--color",
|
|
118
|
+
"--no-color", "--column", "--no-column", "--abbrev", "--no-abbrev", "-i", "--ignore-case", "--omit-empty",
|
|
119
|
+
} # fmt: skip
|
|
120
|
+
BRANCH_WITH_VALUE = {"--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--sort", "--format"}
|
|
121
|
+
NVIDIA_QUERY = ("-q", "--query", "-L", "--list-gpus", "--format", "-i", "--id", "-d", "--display", "-x", "--xml-format",
|
|
122
|
+
"-h", "--help", "--query-gpu", "--query-compute-apps") # fmt: skip
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def safe_git(words: list[str]) -> bool:
|
|
126
|
+
if len(words) < 2 or words[1] not in SAFE_GIT:
|
|
127
|
+
return False # also `git -c …` and `git -C …`, which can run anything
|
|
128
|
+
sub, args = words[1], words[2:]
|
|
129
|
+
if sub in ("log", "show", "diff", "shortlog") and option_values(words, "--output"):
|
|
130
|
+
return False # writes the output to a file
|
|
131
|
+
if sub == "branch":
|
|
132
|
+
listing = any(a in ("-l", "--list") for a in args)
|
|
133
|
+
i = 0
|
|
134
|
+
while i < len(args):
|
|
135
|
+
arg = args[i]
|
|
136
|
+
name = arg.split("=", 1)[0]
|
|
137
|
+
if name in BRANCH_WITH_VALUE:
|
|
138
|
+
i += 1 if "=" in arg else 2
|
|
139
|
+
continue
|
|
140
|
+
if arg.startswith("-") and name not in BRANCH_LISTING:
|
|
141
|
+
return False # -d, -D, -m, -c, -f, -u, --set-upstream-to, --edit-description, …
|
|
142
|
+
if not arg.startswith("-") and not listing:
|
|
143
|
+
return False # `git branch name` creates a branch
|
|
144
|
+
i += 1
|
|
145
|
+
if sub == "remote":
|
|
146
|
+
return not args or all(a in ("-v", "--verbose") for a in args) or args[0] in ("show", "get-url")
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def safe_segment(words: list[str]) -> bool:
|
|
151
|
+
program = words[0]
|
|
152
|
+
if program == "git":
|
|
153
|
+
return safe_git(words)
|
|
154
|
+
if program not in SAFE_COMMANDS:
|
|
52
155
|
return False
|
|
53
|
-
if
|
|
156
|
+
if program == "sort" and (
|
|
157
|
+
option_values(words, "-o", "--output") or any(f.startswith("--compress-program") for f in flags(words))
|
|
158
|
+
):
|
|
54
159
|
return False
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
160
|
+
if program == "uniq" and len(positional(words)) > 1:
|
|
161
|
+
return False # `uniq in out` writes out
|
|
162
|
+
if program == "tree" and option_values(words, "-o"):
|
|
163
|
+
return False
|
|
164
|
+
if program == "rg" and any(f.split("=")[0] in ("--pre", "--pre-glob") for f in flags(words)):
|
|
165
|
+
return False # runs a program on each file
|
|
166
|
+
if program == "find" and any(
|
|
167
|
+
w in ("-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprint0", "-fprintf", "-fls") for w in words
|
|
168
|
+
):
|
|
169
|
+
return False
|
|
170
|
+
if program == "date" and (option_values(words, "-s", "--set") or "-s" in words):
|
|
171
|
+
return False
|
|
172
|
+
if program == "file" and any(f in ("-C", "--compile") for f in flags(words)):
|
|
173
|
+
return False
|
|
174
|
+
if program == "nvidia-smi":
|
|
175
|
+
return all(f.split("=")[0] in NVIDIA_QUERY for f in flags(words))
|
|
67
176
|
return True
|
|
68
177
|
|
|
69
178
|
|
|
179
|
+
def is_read_only(command: str) -> bool:
|
|
180
|
+
"""Whether a command only reads: known read-only programs with arguments that don't write, joined only by
|
|
181
|
+
pipes. `cd dir &&` in front, `2>&1` and `>/dev/null` are fine."""
|
|
182
|
+
command = HARMLESS_REDIRECT.sub(" ", LEADING_CD.sub("", command)).strip()
|
|
183
|
+
parsed = split_command(command)
|
|
184
|
+
if parsed is None:
|
|
185
|
+
return False
|
|
186
|
+
segments, operators = parsed
|
|
187
|
+
if not segments or any(op != "|" for op in operators):
|
|
188
|
+
return False
|
|
189
|
+
return all(safe_segment(words) for words in segments)
|
|
190
|
+
|
|
191
|
+
|
|
70
192
|
class Permissions:
|
|
71
193
|
def __init__(self, console: Console, mode: str = "ask"):
|
|
72
194
|
from lcode.hooks import Rules
|
lcode/repl.py
CHANGED
|
@@ -754,6 +754,7 @@ def restore_checkpoint(agent: Agent, target: Checkpoint, rewind: bool) -> None:
|
|
|
754
754
|
agent.messages = agent.messages[:index]
|
|
755
755
|
agent.reflected = min(agent.reflected, index)
|
|
756
756
|
agent.ctx_used = sum(len(json.dumps(m)) for m in agent.messages) // 3
|
|
757
|
+
agent.counted_chars = None
|
|
757
758
|
c.print(f"The conversation is back to before request {target.n} as well.")
|
|
758
759
|
elif plan.restore or plan.remove:
|
|
759
760
|
what = "undid the file changes from" if not rewind else "rolled the files back to before"
|
lcode/tools.py
CHANGED
|
@@ -6,6 +6,7 @@ import difflib
|
|
|
6
6
|
import fnmatch
|
|
7
7
|
import hashlib
|
|
8
8
|
import inspect
|
|
9
|
+
import itertools
|
|
9
10
|
import json
|
|
10
11
|
import os
|
|
11
12
|
import queue
|
|
@@ -16,7 +17,9 @@ import signal
|
|
|
16
17
|
import subprocess
|
|
17
18
|
import threading
|
|
18
19
|
import time
|
|
20
|
+
from collections import deque
|
|
19
21
|
from collections.abc import Callable
|
|
22
|
+
from dataclasses import dataclass
|
|
20
23
|
from pathlib import Path
|
|
21
24
|
from typing import TYPE_CHECKING
|
|
22
25
|
|
|
@@ -26,7 +29,7 @@ from rich.syntax import Syntax
|
|
|
26
29
|
from rich.text import Text
|
|
27
30
|
|
|
28
31
|
from lcode import secretscan, web
|
|
29
|
-
from lcode.context import shorten
|
|
32
|
+
from lcode.context import OutputSpool, shorten
|
|
30
33
|
from lcode.jobs import JobError, describe, stop_leftovers
|
|
31
34
|
from lcode.permissions import bash_key, is_read_only
|
|
32
35
|
from lcode.planning import BLOCKED, PLAN_MODE_TOOLS
|
|
@@ -41,6 +44,7 @@ NO_NETWORK = re.compile(
|
|
|
41
44
|
r"EAI_AGAIN|Name or service not known|network is unreachable|No route to host"
|
|
42
45
|
)
|
|
43
46
|
MAX_TOOL_OUTPUT = 30_000 # characters returned to the model per tool call
|
|
47
|
+
PENDING_LINES = 5000 # output lines waiting to be read, at most
|
|
44
48
|
LEFTOVER_GRACE = 0.5 # seconds to wait for output after a command ends, before stopping what it left running
|
|
45
49
|
IGNORE_DIRS = {
|
|
46
50
|
".git", "node_modules", "__pycache__", ".venv", "venv", "env", ".mypy_cache", ".pytest_cache",
|
|
@@ -289,6 +293,48 @@ def truncate(text: str, limit: int = MAX_TOOL_OUTPUT) -> str:
|
|
|
289
293
|
return f"{head}\n\n... [{len(text) - len(head) - len(tail)} characters truncated] ...\n\n{tail}"
|
|
290
294
|
|
|
291
295
|
|
|
296
|
+
@dataclass
|
|
297
|
+
class TextFormat:
|
|
298
|
+
"""How a file stores its text, so an edit writes it back the same way."""
|
|
299
|
+
|
|
300
|
+
encoding: str = "utf-8"
|
|
301
|
+
crlf: bool = False # Windows line endings
|
|
302
|
+
bom: bool = False # a UTF-8 byte order mark
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def decode(raw: bytes) -> tuple[str, TextFormat]:
|
|
306
|
+
"""A file's text with \n line endings, and its format: UTF-8, else Windows-1252 or Latin-1 (which always
|
|
307
|
+
decode), never with characters replaced. Writing a replaced character back used to corrupt the whole file."""
|
|
308
|
+
fmt = TextFormat(bom=raw.startswith(b"\xef\xbb\xbf"))
|
|
309
|
+
text = ""
|
|
310
|
+
for encoding in ("utf-8-sig" if fmt.bom else "utf-8", "cp1252", "latin-1"):
|
|
311
|
+
try:
|
|
312
|
+
text = raw.decode(encoding)
|
|
313
|
+
fmt.encoding = "utf-8" if encoding == "utf-8-sig" else encoding
|
|
314
|
+
break
|
|
315
|
+
except UnicodeDecodeError:
|
|
316
|
+
continue
|
|
317
|
+
crlf = text.count("\r\n")
|
|
318
|
+
if crlf and crlf == text.count("\n"): # only CRLF: mixed files are left as they are
|
|
319
|
+
fmt.crlf = True
|
|
320
|
+
text = text.replace("\r\n", "\n")
|
|
321
|
+
return text, fmt
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def encode(text: str, fmt: TextFormat) -> bytes:
|
|
325
|
+
if fmt.crlf:
|
|
326
|
+
text = text.replace("\r\n", "\n").replace("\n", "\r\n")
|
|
327
|
+
try:
|
|
328
|
+
data = text.encode(fmt.encoding)
|
|
329
|
+
except UnicodeEncodeError as e:
|
|
330
|
+
bad = text[e.start : e.end]
|
|
331
|
+
raise ToolError(
|
|
332
|
+
f"the file is in {fmt.encoding}, which can't hold {bad!r}: write it without that character, or ask the "
|
|
333
|
+
"user to convert the file to UTF-8"
|
|
334
|
+
) from e
|
|
335
|
+
return (b"\xef\xbb\xbf" if fmt.bom else b"") + data
|
|
336
|
+
|
|
337
|
+
|
|
292
338
|
def is_binary(path: Path) -> bool:
|
|
293
339
|
try:
|
|
294
340
|
with open(path, "rb") as f:
|
|
@@ -362,15 +408,28 @@ def parse_text_tool_calls(content: str) -> list[dict]:
|
|
|
362
408
|
pass
|
|
363
409
|
if calls:
|
|
364
410
|
return calls
|
|
365
|
-
#
|
|
411
|
+
# A message that is nothing but JSON calls such as {"name": "grep", "arguments": {...}}, possibly in a ```
|
|
412
|
+
# fence. JSON inside an answer is an example, not a call: running it once ran a command the model was only
|
|
413
|
+
# explaining.
|
|
414
|
+
body = content.strip()
|
|
415
|
+
lead = re.match(r"([^\n{`]{0,50})\n+(?=```|\{)", body) # a short lead-in such as "Sure:" is fine
|
|
416
|
+
if lead:
|
|
417
|
+
body = body[lead.end() :]
|
|
418
|
+
body = re.sub(r"^```(?:json)?\s*|\s*```$", "", body)
|
|
366
419
|
decoder = json.JSONDecoder()
|
|
367
|
-
|
|
420
|
+
position = 0
|
|
421
|
+
while position < len(body):
|
|
422
|
+
while position < len(body) and body[position].isspace():
|
|
423
|
+
position += 1
|
|
424
|
+
if position == len(body):
|
|
425
|
+
break
|
|
368
426
|
try:
|
|
369
|
-
obj,
|
|
427
|
+
obj, position = decoder.raw_decode(body, position)
|
|
370
428
|
except json.JSONDecodeError:
|
|
371
|
-
|
|
372
|
-
if isinstance(obj, dict) and obj.get("name") in TOOL_NAMES and isinstance(obj.get("arguments", {}), dict):
|
|
373
|
-
|
|
429
|
+
return [] # something else in the message: not a call
|
|
430
|
+
if not (isinstance(obj, dict) and obj.get("name") in TOOL_NAMES and isinstance(obj.get("arguments", {}), dict)):
|
|
431
|
+
return []
|
|
432
|
+
calls.append({"function": {"name": obj["name"], "arguments": obj.get("arguments", {})}})
|
|
374
433
|
return calls
|
|
375
434
|
|
|
376
435
|
|
|
@@ -385,6 +444,7 @@ class Toolbox:
|
|
|
385
444
|
def __init__(self, agent: Agent):
|
|
386
445
|
self.agent = agent
|
|
387
446
|
self.read_mtimes: dict[str, float] = {}
|
|
447
|
+
self.formats: dict[str, TextFormat] = {} # encoding and line endings of the files read
|
|
388
448
|
self.todos: list[dict] = []
|
|
389
449
|
# An editor's buffers (lcode acp): read unsaved changes and write through the editor.
|
|
390
450
|
self.reader: Callable[[Path], str | None] | None = None
|
|
@@ -476,7 +536,7 @@ class Toolbox:
|
|
|
476
536
|
if problem:
|
|
477
537
|
return f"Error: bad arguments for {name}: {problem}"
|
|
478
538
|
try:
|
|
479
|
-
return fn(**args)
|
|
539
|
+
return fn(**self._typed(fn, args))
|
|
480
540
|
except ToolError as e:
|
|
481
541
|
return f"Error: {e}"
|
|
482
542
|
except TypeError as e:
|
|
@@ -484,6 +544,23 @@ class Toolbox:
|
|
|
484
544
|
except Exception as e: # surface every failure to the model instead of crashing the session
|
|
485
545
|
return f"Error: {type(e).__name__}: {e}"
|
|
486
546
|
|
|
547
|
+
@staticmethod
|
|
548
|
+
def _typed(fn, args: dict) -> dict:
|
|
549
|
+
"""Arguments with text turned into the declared type: some servers and the text fallback send every value
|
|
550
|
+
as a string, and "false" is true in Python."""
|
|
551
|
+
params = inspect.signature(fn).parameters
|
|
552
|
+
typed = dict(args)
|
|
553
|
+
for name, value in args.items():
|
|
554
|
+
if not isinstance(value, str) or name not in params:
|
|
555
|
+
continue
|
|
556
|
+
kind = str(params[name].annotation).split("|")[0].strip()
|
|
557
|
+
text = value.strip().lower()
|
|
558
|
+
if kind == "bool" and text in ("true", "false", "1", "0", "yes", "no"):
|
|
559
|
+
typed[name] = text in ("true", "1", "yes")
|
|
560
|
+
elif kind == "int" and re.fullmatch(r"-?\d+", text):
|
|
561
|
+
typed[name] = int(text)
|
|
562
|
+
return typed
|
|
563
|
+
|
|
487
564
|
@staticmethod
|
|
488
565
|
def _argument_problem(fn, args: dict) -> str:
|
|
489
566
|
"""Explain unknown or missing arguments in terms the model can act on."""
|
|
@@ -639,9 +716,21 @@ class Toolbox:
|
|
|
639
716
|
self.read_mtimes[str(p)] = p.stat().st_mtime
|
|
640
717
|
if not lines:
|
|
641
718
|
return "(empty file)"
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
719
|
+
budget, shown, size = self.agent.result_budget() - 300, [], 0
|
|
720
|
+
for i, line in enumerate(chunk, start=offset):
|
|
721
|
+
numbered = f"{i:6}\t{line[:2000]}"
|
|
722
|
+
if shown and size + len(numbered) + 1 > budget:
|
|
723
|
+
break # the rest wouldn't fit in the context at once
|
|
724
|
+
shown.append(numbered)
|
|
725
|
+
size += len(numbered) + 1
|
|
726
|
+
out = "\n".join(shown)
|
|
727
|
+
end = offset - 1 + len(shown)
|
|
728
|
+
if end < offset - 1 + len(chunk):
|
|
729
|
+
out += (
|
|
730
|
+
f"\n\n[Showing lines {offset}-{end} of {len(lines)}: more doesn't fit in the context at once. "
|
|
731
|
+
f"Read on with offset={end + 1}.]"
|
|
732
|
+
)
|
|
733
|
+
elif end < len(lines) or offset > 1:
|
|
645
734
|
out += f"\n\n[Showing lines {offset}-{end} of {len(lines)}. Use offset/limit to read more.]"
|
|
646
735
|
return truncate(out, 120_000)
|
|
647
736
|
|
|
@@ -732,14 +821,17 @@ class Toolbox:
|
|
|
732
821
|
text = self.reader(p)
|
|
733
822
|
if text is not None:
|
|
734
823
|
return text
|
|
735
|
-
|
|
824
|
+
text, self.formats[str(p)] = decode(p.read_bytes())
|
|
825
|
+
return text
|
|
736
826
|
|
|
737
827
|
def _write(self, p: Path, content: str) -> None:
|
|
738
828
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
829
|
+
fmt = self.formats.get(str(p)) or (decode(p.read_bytes())[1] if p.exists() else TextFormat())
|
|
830
|
+
data = encode(content, fmt) # before anything is written: it may not be possible
|
|
739
831
|
if self.writer is not None:
|
|
740
832
|
self.writer(p, content) # the editor updates its buffer (and the file)
|
|
741
|
-
if not p.exists() or p.
|
|
742
|
-
p.
|
|
833
|
+
if not p.exists() or p.read_bytes() != data:
|
|
834
|
+
p.write_bytes(data) # the file on disk must match, for checkpoints and the tools
|
|
743
835
|
self.read_mtimes[str(p)] = p.stat().st_mtime
|
|
744
836
|
|
|
745
837
|
def t_write_file(self, path: str, content: str) -> str:
|
|
@@ -749,8 +841,10 @@ class Toolbox:
|
|
|
749
841
|
if p.is_dir():
|
|
750
842
|
raise ToolError(f"{path} is a directory")
|
|
751
843
|
self._check_fresh(p)
|
|
752
|
-
|
|
844
|
+
shown = self.read_text(p)
|
|
845
|
+
body = self._diff(p, shown, content)
|
|
753
846
|
else:
|
|
847
|
+
shown = None
|
|
754
848
|
lines = content.splitlines()
|
|
755
849
|
preview = "\n".join(lines[:60]) + (f"\n... ({len(lines) - 60} more lines)" if len(lines) > 60 else "")
|
|
756
850
|
body = Syntax(preview, Syntax.guess_lexer(str(p), content), theme="monokai", line_numbers=True)
|
|
@@ -758,13 +852,27 @@ class Toolbox:
|
|
|
758
852
|
ok, feedback = self.agent.perms.request("edit", "edit", f"{verb} {self.rel(p)}", body, self.project_rel(p))
|
|
759
853
|
if not ok:
|
|
760
854
|
return feedback
|
|
761
|
-
before = self.read_text(p) if exists else None
|
|
855
|
+
before = self.read_text(p) if p.exists() else None
|
|
856
|
+
if before != shown:
|
|
857
|
+
return self._changed_meanwhile(p)
|
|
762
858
|
self.agent.checkpoint()
|
|
763
859
|
self._write(p, content)
|
|
764
860
|
n = len(content.splitlines())
|
|
765
861
|
self.console.print(f" [green]✓[/] {'Updated' if exists else 'Created'} {self.rel(p)} ({n} lines)")
|
|
766
862
|
return f"{'Overwrote' if exists else 'Created'} {self.rel(p)} ({n} lines)." + self._check(p, before, content)
|
|
767
863
|
|
|
864
|
+
def _changed_meanwhile(self, p: Path) -> str:
|
|
865
|
+
"""The file changed while the user was asked (they edited it): writing now would undo their change."""
|
|
866
|
+
self.read_mtimes.pop(str(p), None)
|
|
867
|
+
self.console.print(
|
|
868
|
+
Text(f" ⎿ {self.rel(p)} changed while you were asked; the edit wasn't applied", style="yellow")
|
|
869
|
+
)
|
|
870
|
+
return (
|
|
871
|
+
f"Error: {self.rel(p)} changed while the user was being asked (they or another program edited it), so "
|
|
872
|
+
"lcode didn't apply this change: it would have undone theirs. Read the file again and redo the change "
|
|
873
|
+
"on its current content."
|
|
874
|
+
)
|
|
875
|
+
|
|
768
876
|
def t_edit_file(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
|
|
769
877
|
p = self.path(path)
|
|
770
878
|
if not p.exists():
|
|
@@ -795,6 +903,8 @@ class Toolbox:
|
|
|
795
903
|
ok, feedback = self.agent.perms.request("edit", "edit", f"Edit {self.rel(p)}", diff, self.project_rel(p))
|
|
796
904
|
if not ok:
|
|
797
905
|
return feedback
|
|
906
|
+
if not p.exists() or self.read_text(p) != text:
|
|
907
|
+
return self._changed_meanwhile(p)
|
|
798
908
|
self.agent.checkpoint()
|
|
799
909
|
self._write(p, new_text)
|
|
800
910
|
self.console.print(f" [green]✓[/] Edited {self.rel(p)}")
|
|
@@ -879,13 +989,22 @@ class Toolbox:
|
|
|
879
989
|
start_new_session=True,
|
|
880
990
|
bufsize=1,
|
|
881
991
|
)
|
|
882
|
-
|
|
992
|
+
# Bounded, so a command that prints faster than lcode reads waits instead of filling memory.
|
|
993
|
+
lines: queue.Queue[str | None] = queue.Queue(maxsize=PENDING_LINES)
|
|
994
|
+
reading = threading.Event() # cleared when lcode stops reading
|
|
995
|
+
reading.set()
|
|
883
996
|
|
|
884
997
|
def pump() -> None:
|
|
885
998
|
assert proc.stdout is not None
|
|
886
|
-
for line in proc.stdout:
|
|
887
|
-
|
|
888
|
-
|
|
999
|
+
for line in itertools.chain(proc.stdout, [None]): # streamed: never the whole output
|
|
1000
|
+
while reading.is_set():
|
|
1001
|
+
try:
|
|
1002
|
+
lines.put(line, timeout=0.5)
|
|
1003
|
+
break
|
|
1004
|
+
except queue.Full:
|
|
1005
|
+
continue
|
|
1006
|
+
else:
|
|
1007
|
+
return
|
|
889
1008
|
|
|
890
1009
|
def stop() -> None:
|
|
891
1010
|
if sandbox and token:
|
|
@@ -893,7 +1012,9 @@ class Toolbox:
|
|
|
893
1012
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
894
1013
|
|
|
895
1014
|
threading.Thread(target=pump, daemon=True).start()
|
|
896
|
-
out
|
|
1015
|
+
out = OutputSpool(MAX_TOOL_OUTPUT, "output")
|
|
1016
|
+
last = "" # the latest line, for the end-of-output checks below
|
|
1017
|
+
recent: deque[str] = deque(maxlen=40)
|
|
897
1018
|
shown, status, recorded = 0, "", ""
|
|
898
1019
|
deadline = time.time() + int(timeout or 180)
|
|
899
1020
|
finished = 0.0 # when the command itself ended (its marker arrived)
|
|
@@ -902,16 +1023,16 @@ class Toolbox:
|
|
|
902
1023
|
while True:
|
|
903
1024
|
if self.agent.cancel is not None and self.agent.cancel.is_set():
|
|
904
1025
|
raise KeyboardInterrupt
|
|
1026
|
+
if finished and time.time() - finished > LEFTOVER_GRACE:
|
|
1027
|
+
held_open = True # also when what it left running keeps printing
|
|
1028
|
+
break
|
|
1029
|
+
if time.time() > deadline and not finished: # also while it keeps printing
|
|
1030
|
+
stop()
|
|
1031
|
+
status = f"\n[Command timed out after {timeout}s and was killed]"
|
|
1032
|
+
break
|
|
905
1033
|
try:
|
|
906
1034
|
line = lines.get(timeout=0.2)
|
|
907
1035
|
except queue.Empty:
|
|
908
|
-
if finished and time.time() - finished > LEFTOVER_GRACE:
|
|
909
|
-
held_open = True
|
|
910
|
-
break
|
|
911
|
-
if time.time() > deadline:
|
|
912
|
-
stop()
|
|
913
|
-
status = f"\n[Command timed out after {timeout}s and was killed]"
|
|
914
|
-
break
|
|
915
1036
|
continue
|
|
916
1037
|
if line is None:
|
|
917
1038
|
break
|
|
@@ -919,14 +1040,19 @@ class Toolbox:
|
|
|
919
1040
|
recorded = line[len(marker) :].strip()
|
|
920
1041
|
finished = time.time()
|
|
921
1042
|
continue
|
|
922
|
-
|
|
1043
|
+
if last:
|
|
1044
|
+
out.add(last)
|
|
1045
|
+
recent.append(last)
|
|
1046
|
+
last = line
|
|
923
1047
|
if shown <= 40:
|
|
924
1048
|
msg = " ... (more output hidden)" if shown == 40 else " " + line.rstrip("\n")[:300]
|
|
925
1049
|
self.console.print(Text(msg, style="dim"))
|
|
926
1050
|
shown += 1
|
|
927
1051
|
except KeyboardInterrupt:
|
|
1052
|
+
reading.clear()
|
|
928
1053
|
stop()
|
|
929
1054
|
raise
|
|
1055
|
+
reading.clear()
|
|
930
1056
|
code = proc.wait()
|
|
931
1057
|
if held_open and sandbox and token:
|
|
932
1058
|
sandbox.kill(token) # what it left running in the container
|
|
@@ -935,21 +1061,22 @@ class Toolbox:
|
|
|
935
1061
|
"\n[lcode stopped what this command left running (started with &). To keep a process running, "
|
|
936
1062
|
"start it with background=true: then bash_output reads its output and bash_stop stops it]"
|
|
937
1063
|
)
|
|
938
|
-
if
|
|
939
|
-
out.
|
|
1064
|
+
if last and last != "\n": # the blank line printed before the marker isn't output
|
|
1065
|
+
out.add(last)
|
|
1066
|
+
recent.append(last)
|
|
940
1067
|
if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd:
|
|
941
1068
|
if sandbox and not sandbox.contains(Path(recorded).resolve()):
|
|
942
1069
|
status += "\n[That directory is outside the project; the working directory stays the same]"
|
|
943
1070
|
else:
|
|
944
1071
|
self.agent.cwd = Path(recorded).resolve()
|
|
945
1072
|
status += f"\n[Working directory is now {self.agent.cwd}]"
|
|
946
|
-
if sandbox and code != 0 and not sandbox.network and NO_NETWORK.search("".join(
|
|
1073
|
+
if sandbox and code != 0 and not sandbox.network and NO_NETWORK.search("".join(recent)):
|
|
947
1074
|
status += (
|
|
948
1075
|
"\n[The sandbox has no network access. If this needs the network, ask the user to allow it "
|
|
949
1076
|
"with /sandbox network on]"
|
|
950
1077
|
)
|
|
951
1078
|
self.console.print(Text(f" exit code {code}", style="green" if code == 0 else "red"))
|
|
952
|
-
return
|
|
1079
|
+
return out.text() + status + f"\n[exit code: {code}]"
|
|
953
1080
|
|
|
954
1081
|
# -- web
|
|
955
1082
|
def _web_allowed(self, key: str, title: str, detail: str) -> tuple[bool, str]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.29.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,20 +1,21 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=MDtHYmkvKuvQap8m7LmA2apEsX16ubVEWst8qW4l_kQ,473
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
3
|
lcode/acp.py,sha256=Tpc8lsvffZSYbTRI4t7X4E2chahqZIPcKeYIcL5bV3Y,29035
|
|
4
4
|
lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
|
|
5
|
-
lcode/agent.py,sha256=
|
|
6
|
-
lcode/api.py,sha256=
|
|
5
|
+
lcode/agent.py,sha256=mYGtftFoEzgI-KvpDpXTOLmtKPrB3ZVjfP__1B872r8,69547
|
|
6
|
+
lcode/api.py,sha256=PhG8PZReM7DXSxG6wf7mEjAKx_TAsgj3xHApacrXGCM,15684
|
|
7
7
|
lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
|
|
8
8
|
lcode/bench.py,sha256=7ICipu_2GXW6vE519XeZucc2GSWA44votT8JvqcNv1k,40964
|
|
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/context.py,sha256=
|
|
17
|
+
lcode/config.py,sha256=KSjSBvxA9nmd4h4QYk3xp8BQ7C04pjTULsSn0jcQwBc,11301
|
|
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
|
|
20
21
|
lcode/gitflow.py,sha256=6j_nnCBZXkX1qhTI7K4CyZ5D0NAZLpH96weFEo64AkQ,27913
|
|
@@ -27,18 +28,18 @@ lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
|
|
|
27
28
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
28
29
|
lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
|
|
29
30
|
lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
|
|
30
|
-
lcode/permissions.py,sha256=
|
|
31
|
+
lcode/permissions.py,sha256=3OPVVth6uTE5kcAWMZ8X_vUnFRbf6STEHAJimZX0Yu4,12118
|
|
31
32
|
lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
|
|
32
33
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
33
34
|
lcode/repeats.py,sha256=uzyAewX03nJf5dKwYFH4y9ZDnh0Lvy2k0qC2vdntM5Y,4061
|
|
34
|
-
lcode/repl.py,sha256=
|
|
35
|
+
lcode/repl.py,sha256=ayrKySMbbRbax_WMjqYBN4fkcDpzuHniefJC3DZ4-2Y,50687
|
|
35
36
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
36
37
|
lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
|
|
37
38
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
38
39
|
lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
|
|
39
40
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
40
41
|
lcode/subagents.py,sha256=Z2RHp7an16dhOGReucVIEOAnXjxM8jx7sHvWd_Fp2V4,29142
|
|
41
|
-
lcode/tools.py,sha256=
|
|
42
|
+
lcode/tools.py,sha256=h7C6r27_cBoCkW8tZ4BLmxdgRByYa2cUKh8AWfmEucI,54193
|
|
42
43
|
lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
|
|
43
44
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
44
45
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
@@ -58,8 +59,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
|
|
|
58
59
|
lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
59
60
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
60
61
|
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.
|
|
62
|
+
lcode_cli-0.29.0.dist-info/METADATA,sha256=IYMbafynIQ-eNxr7iOJkmc5vXhfry3Q5FIeyMXmeEgE,12620
|
|
63
|
+
lcode_cli-0.29.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
64
|
+
lcode_cli-0.29.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
|
|
65
|
+
lcode_cli-0.29.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
66
|
+
lcode_cli-0.29.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|