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 CHANGED
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.28.0"
6
+ __version__ = "0.29.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
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 = ("error parsing tool call", "XML syntax error")
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
- f.write_text(json.dumps(data))
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
- def bash_key(command: str) -> str:
26
- """The key "always allow" remembers: the program (plus subcommand for tools like git), ignoring `cd dir &&`."""
27
- command = re.sub(r"^\s*(cd\s+\S+\s*&&\s*)+", "", command)
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
- words = shlex.split(command)
46
+ tokens = list(lexer)
30
47
  except ValueError:
31
- words = command.split()
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 "bash:"
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 f"bash:{' '.join(words[:3])}"
37
- return f"bash:{words[0]} {words[1]}"
38
- return f"bash:{words[0]}"
68
+ return " ".join(words[:3])
69
+ return f"{words[0]} {words[1]}"
70
+ return words[0]
39
71
 
40
72
 
41
- # `cd <plain path> &&` in front of a command: harmless, and models write it all the time.
42
- NOT_ASKED = (
43
- "This needs the user's permission, and nobody can be asked in this run, so it was not allowed. Do without it "
44
- "if you can; otherwise finish and say what permission you need."
45
- )
46
- LEADING_CD = re.compile(r"""^\s*(?:cd\s+(?:[\w./~@+,=:-]+|'[^']*'|"[^"$`\\]*")\s*&&\s*)+""")
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 is_read_only(command: str) -> bool:
50
- command = LEADING_CD.sub("", command)
51
- if any(tok in command for tok in (";", "&", ">", "`", "$(", "<(")) or "\n" in command:
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 re.search(r"-exec|-delete|-ok\b|-fprint", command):
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
- for segment in command.split("|"):
56
- try:
57
- words = shlex.split(segment)
58
- except ValueError:
59
- return False
60
- if not words:
61
- return False
62
- if words[0] == "git":
63
- if len(words) < 2 or words[1] not in SAFE_GIT:
64
- return False
65
- elif words[0] not in SAFE_COMMANDS:
66
- return False
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
- # Bare JSON objects such as {"name": "grep", "arguments": {...}}, possibly inside ``` fences.
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
- for m in re.finditer(r"\{", content):
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, _ = decoder.raw_decode(content, m.start())
427
+ obj, position = decoder.raw_decode(body, position)
370
428
  except json.JSONDecodeError:
371
- continue
372
- if isinstance(obj, dict) and obj.get("name") in TOOL_NAMES and isinstance(obj.get("arguments", {}), dict):
373
- calls.append({"function": {"name": obj["name"], "arguments": obj.get("arguments", {})}})
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
- out = "\n".join(f"{i:6}\t{line[:2000]}" for i, line in enumerate(chunk, start=offset))
643
- end = offset - 1 + len(chunk)
644
- if end < len(lines) or offset > 1:
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
- return p.read_text(errors="replace")
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.read_text(errors="replace") != content:
742
- p.write_text(content) # the file on disk must match, for checkpoints and the tools
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
- body = self._diff(p, self.read_text(p), content)
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
- lines: queue.Queue[str | None] = queue.Queue()
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
- lines.put(line)
888
- lines.put(None)
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: list[str] = []
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
- out.append(line)
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 out and out[-1] == "\n":
939
- out.pop() # the blank line printed before the marker
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(out[-40:])):
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 shorten("".join(out), MAX_TOOL_OUTPUT, "output") + status + f"\n[exit code: {code}]"
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.28.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=OG44SXHF0F_qxUeC9BH5G84cellTu3wqLwxsfUfYdUU,473
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=HFdmGXK_3oHu57L15z2dmJsQTj_ddjRDq7JnxM1Qq34,63045
6
- lcode/api.py,sha256=kYq7RghkNyf-A7qsZh6AeMzLoZbc9rTobsFRLtAcvD8,15480
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/cli.py,sha256=M6Mjd5BROSGlZj4J6TrSZMJqI-OVfUEWPoC0TQyOQ28,40831
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=q1zGzH3p5U23ai6O9Jai3dS-s30CuQ9gc5cWX0_yxWE,11195
17
- lcode/context.py,sha256=_f0NbXyjA4SyH2RCr49AiWwtAfr_PVDaqyFQ5fZDhkg,14404
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=i04YLtqYCxwApgawomPp4d35uk9HAJtpKY__gfw8j9s,6347
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=Et8Tf4COLJ9_FsXtjcOciah_33lLgUS5sl4QAer1m8Y,50652
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=OFltvnRZlSUMtzNLEUF84RFoyKQkAFpb7_ZBVhVcVp4,48426
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.28.0.dist-info/METADATA,sha256=b-CcZWIZTz_P0M5H9xP1cwtQpICZpHlaGWdBRlSB52U,12620
62
- lcode_cli-0.28.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
63
- lcode_cli-0.28.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
64
- lcode_cli-0.28.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
65
- lcode_cli-0.28.0.dist-info/RECORD,,
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,,