lcode-cli 0.33.1__py3-none-any.whl → 0.35.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.33.1"
6
+ __version__ = "0.35.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/agent.py CHANGED
@@ -29,6 +29,7 @@ from lcode import (
29
29
  catalog,
30
30
  checks,
31
31
  codesearch,
32
+ escalate,
32
33
  extensions,
33
34
  limits,
34
35
  notify,
@@ -48,7 +49,7 @@ from lcode.config import format_tokens
48
49
  from lcode.jobs import Jobs
49
50
  from lcode.mcp import McpManager
50
51
  from lcode.ollama import Ollama, OllamaError
51
- from lcode.permissions import Permissions
52
+ from lcode.permissions import LEADING_CD, Permissions
52
53
  from lcode.render import MarkdownStreamer
53
54
  from lcode.repeats import Repeats
54
55
  from lcode.sandbox import Sandbox, SandboxError, project_root
@@ -122,7 +123,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
122
123
  # Verifying your work
123
124
  - 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.
124
125
  - 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.
125
- - 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.
126
+ - For what you can't see, like a GUI, a web page or a layout, use the screenshot tool: it runs the program (or opens the page) without a screen and returns what it shows, with the sizes of windows, widgets or the page measured{look}. Measure instead of assuming.
126
127
  - 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}
127
128
 
128
129
  # Communication
@@ -131,7 +132,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
131
132
 
132
133
  # Environment
133
134
  - Working directory: {cwd}
134
- - OS: {os}; shell: bash; Python command: `{python}`
135
+ - OS: {os}; shell: bash; Python command: {python}
135
136
  - Date: {date}
136
137
  - Git: {git}
137
138
 
@@ -218,6 +219,30 @@ def call_arguments(call: dict) -> dict:
218
219
  return args if isinstance(args, dict) else {}
219
220
 
220
221
 
222
+ VENV_DIRS = (".venv", "venv", "env")
223
+
224
+
225
+ def project_python(cwd: Path) -> Path | None:
226
+ """The project's virtual environment's Python (.venv, venv or env, here or up to the repository's root)."""
227
+ for folder in [cwd, *cwd.parents][:4]:
228
+ for name in VENV_DIRS:
229
+ path = folder / name / "bin" / "python"
230
+ if path.exists() and (folder / name / "pyvenv.cfg").exists():
231
+ return path
232
+ if (folder / ".git").exists():
233
+ break # not beyond the repository
234
+ return None
235
+
236
+
237
+ def python_line(cwd: Path) -> str:
238
+ """How the system prompt names the Python command."""
239
+ venv = project_python(cwd)
240
+ if venv is None:
241
+ return f"`{'python' if shutil.which('python') else 'python3'}`"
242
+ shown = venv.relative_to(cwd) if cwd in venv.parents else venv
243
+ return f"`{shown}` (the project's virtual environment: plain python lacks its packages)"
244
+
245
+
221
246
  class Progress:
222
247
  """The status line under a running request.
223
248
 
@@ -283,6 +308,9 @@ class Settings:
283
308
  repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
284
309
  checks: str = "off" # the project's checks after a change (lcode.checks): auto, off or a command
285
310
  review: str = "off" # check a change against the request before the answer (lcode.review): auto or off
311
+ escalate_model: str | None = None # a stronger model to ask when stuck (lcode.escalate): provider/model
312
+ escalate_url: str | None = None # its server, when not the provider's usual one
313
+ escalate_price: str | None = None # dollars per million tokens in/out, e.g. "3/15", for the cost estimate
286
314
 
287
315
 
288
316
  class Agent:
@@ -345,6 +373,8 @@ class Agent:
345
373
  self._check: tuple[Path, checks.Check | None] | None = None
346
374
  self.check_results: list[dict] = [] # the checks of the last request: command, exit code, who ran them
347
375
  self.review_results: list[str] | None = None # what the last request's review found (None: no review)
376
+ self.escalations: list[dict] = [] # the stronger model asked in the last request (lcode.escalate)
377
+ self.escalate_approved: bool | None = None # decided in advance (lcode -p --escalate, the benchmark)
348
378
  self.sent_chars = 0 # characters of the conversation the model had already read
349
379
  self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
350
380
  self.turn_status = "" # how the last request ended: success, max_steps, loop
@@ -372,7 +402,7 @@ class Agent:
372
402
  model=self.settings.model,
373
403
  cwd=self.cwd,
374
404
  os=f"{platform.system()} {platform.release()} ({platform.machine()})",
375
- python="python" if shutil.which("python") else "python3",
405
+ python=python_line(self.cwd),
376
406
  date=dt.date.today().isoformat(),
377
407
  git=git_info(self.cwd),
378
408
  tree=tree(self.cwd, depth=1, limit=120),
@@ -383,7 +413,7 @@ class Agent:
383
413
  skills=extensions.skills_prompt(self.extensions().skills, self.settings.context),
384
414
  code=self.code_prompt(),
385
415
  map=self.map_prompt(),
386
- look=" you look at with view_image" if self.vision_model() else "",
416
+ look="; look at other images with view_image" if self.vision_model() else "",
387
417
  checks=self.checks_prompt(),
388
418
  )
389
419
 
@@ -928,9 +958,8 @@ class Agent:
928
958
  )
929
959
  return None
930
960
  if round_ >= checks.MAX_ROUNDS:
931
- self.say(
932
- f"Checks still fail ({check.command}, exit code {code}): the model will say what's left.", warning=True
933
- )
961
+ then = "" if self.can_escalate() else ": the model will say what's left"
962
+ self.say(f"Checks still fail ({check.command}, exit code {code}){then}.", warning=True)
934
963
  else:
935
964
  self.say(
936
965
  f"Checks fail ({check.command}, exit code {code}): the model gets the output to fix it.", warning=True
@@ -1000,6 +1029,9 @@ class Agent:
1000
1029
  return f"agent({args.get('type', '')}: {args.get('description') or str(args.get('task', ''))[:50]})"
1001
1030
  if name == "bash":
1002
1031
  return f"$ {str(args.get('command', ''))[:60]}"
1032
+ if name == "screenshot":
1033
+ what = args.get("url") or LEADING_CD.sub("", str(args.get("command") or ""))
1034
+ return f"screenshot({what[:60]})"
1003
1035
  if name in ("web_search", "web_fetch", "view_image"):
1004
1036
  return f"{name}({args.get('query') or args.get('url') or args.get('path') or ''})"
1005
1037
  if self.mcp and self.mcp.owns(name):
@@ -1076,6 +1108,8 @@ class Agent:
1076
1108
  self.repeats.reset()
1077
1109
  self.check_results = []
1078
1110
  self.review_results = None
1111
+ self.escalations = []
1112
+ escalated = False # the stronger model is asked at most once per request
1079
1113
  self.tools.before = {}
1080
1114
  reviewed = False
1081
1115
  changed = verified = False # files changed in this request; the checks passed since
@@ -1139,6 +1173,15 @@ class Agent:
1139
1173
  check_rounds += 1
1140
1174
  feedback = self.run_check(check, check_rounds)
1141
1175
  if feedback:
1176
+ if check_rounds >= checks.MAX_ROUNDS and not escalated and self.can_escalate():
1177
+ escalated = True
1178
+ why = f"the project's checks (`{check.command}`) failed {check_rounds} times in a row"
1179
+ advice = escalate.run(self, why, failure=feedback)
1180
+ if advice:
1181
+ self.messages.append({"role": "user", "content": advice})
1182
+ check_rounds -= 1 # the checks run once more after the advice
1183
+ changed = False
1184
+ continue
1142
1185
  self.messages.append({"role": "user", "content": feedback})
1143
1186
  changed = False # run them again only if the model changes something more
1144
1187
  continue
@@ -1232,6 +1275,14 @@ class Agent:
1232
1275
  skipped["tool_call_id"] = rest["id"]
1233
1276
  self.messages.append(skipped)
1234
1277
  break
1278
+ if looping and not escalated and self.can_escalate():
1279
+ escalated = True
1280
+ advice = escalate.run(self, f"it kept making the same call with the same result: {looping}")
1281
+ if advice:
1282
+ self.messages.append({"role": "user", "content": advice})
1283
+ self.repeats.reset()
1284
+ looping = ""
1285
+ continue
1235
1286
  if looping:
1236
1287
  self.turn_status = "loop"
1237
1288
  self.console.print(
@@ -1249,6 +1300,11 @@ class Agent:
1249
1300
  self.turn_status = "success"
1250
1301
  self.print_stats()
1251
1302
 
1303
+ def can_escalate(self) -> bool:
1304
+ """Whether a stronger model may be asked here: one is set, and this isn't a subagent or plan mode."""
1305
+ model = (self.settings.escalate_model or "").strip().lower()
1306
+ return bool(model) and model != "off" and self.allowed_tools is None and not self.planning()
1307
+
1252
1308
  def steer(self) -> None:
1253
1309
  """Between two steps: pause if the user pressed Esc, then hand the model the notes typed meanwhile."""
1254
1310
  steering = self.steering
lcode/api.py CHANGED
@@ -78,6 +78,7 @@ class Result:
78
78
  usage: dict = field(default_factory=dict) # requests, prompt_tokens, output_tokens
79
79
  checks: list[dict] = field(default_factory=list) # {"command", "exit_code", "by": "lcode" | "model"}
80
80
  review: list[str] | None = None # problems the review sent back ([] when it found none; None: no review)
81
+ escalations: list[dict] = field(default_factory=list) # a stronger model asked: model, why, tokens, seconds
81
82
  seconds: float = 0.0
82
83
  error: str = ""
83
84
 
@@ -200,6 +201,9 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
200
201
  repeat_limit=cfg["repeat_limit"],
201
202
  checks=cfg["checks"],
202
203
  review=cfg["review"],
204
+ escalate_model=cfg["escalate_model"],
205
+ escalate_url=cfg["escalate_url"],
206
+ escalate_price=cfg["escalate_price"],
203
207
  compact_at=cfg["compact_at"],
204
208
  )
205
209
  agent = Agent(ollama, settings, cwd, console=console)
@@ -309,6 +313,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
309
313
  usage=usage,
310
314
  checks=list(agent.check_results),
311
315
  review=agent.review_results,
316
+ escalations=list(agent.escalations),
312
317
  seconds=round(time.monotonic() - started, 2),
313
318
  error=error,
314
319
  )
lcode/backends.py CHANGED
@@ -265,11 +265,16 @@ class OpenAICompatible:
265
265
  return self.chat({k: v for k, v in payload.items() if k not in ("format", "tools")}, timeout)
266
266
  if r.status_code != 200:
267
267
  raise self._failed(r.status_code, r.text)
268
- choice = (r.json().get("choices") or [{}])[0].get("message") or {}
268
+ data = r.json()
269
+ choice = (data.get("choices") or [{}])[0].get("message") or {}
269
270
  message: dict = {"role": "assistant", "content": strip_thinking(choice.get("content") or "")}
270
271
  if choice.get("tool_calls"):
271
272
  message["tool_calls"] = [self._ollama_call(c) for c in choice["tool_calls"]]
272
- return {"message": message}
273
+ reply: dict = {"message": message}
274
+ usage = data.get("usage") or {}
275
+ if usage.get("prompt_tokens") is not None: # as Ollama counts them
276
+ reply["prompt_eval_count"], reply["eval_count"] = usage["prompt_tokens"], usage.get("completion_tokens")
277
+ return reply
273
278
 
274
279
  @staticmethod
275
280
  def _ollama_call(call: dict) -> dict:
lcode/bench.py CHANGED
@@ -29,7 +29,7 @@ from rich.markup import escape
29
29
  from rich.table import Table
30
30
  from rich.text import Text
31
31
 
32
- from lcode import __version__
32
+ from lcode import __version__, escalate
33
33
  from lcode.agent import Agent, Settings
34
34
  from lcode.config import format_tokens
35
35
  from lcode.hardware import Hardware
@@ -790,6 +790,8 @@ class TaskResult:
790
790
  tool_errors: int = 0 # failed tool calls, including malformed ones
791
791
  error: str | None = None # why the run stopped early: a timeout or an Ollama error
792
792
  errors_by_tool: dict[str, int] = field(default_factory=dict) # which tools failed, "malformed" for unreadable calls
793
+ first_passed: bool | None = None # with --retry: whether the first try passed (None: no retry was needed)
794
+ escalations: int = 0 # times the stronger model was asked (--escalate)
793
795
 
794
796
 
795
797
  @dataclass
@@ -887,8 +889,13 @@ def run_task(
887
889
  keep: bool,
888
890
  usage: dict,
889
891
  agent: Agent | None = None,
892
+ retry: bool = False,
890
893
  ) -> TaskResult:
891
- """Run one task in a new folder: in a new conversation, or continuing `agent`'s (--session)."""
894
+ """Run one task in a new folder: in a new conversation, or continuing `agent`'s (--session).
895
+
896
+ With `retry`, a failed task gets one more try, as a user would ask: "That didn't fix it. Look again." With a
897
+ stronger model set (--escalate), its advice goes along, as with /escalate.
898
+ """
892
899
  folder = Path(tempfile.mkdtemp(prefix=f"lcode-bench-{task.id}-"))
893
900
  try:
894
901
  for rel, content in task.files.items():
@@ -906,25 +913,28 @@ def run_task(
906
913
  if model:
907
914
  codesearch.Index(folder, model).update(ollama) # on the CPU: lcode's model stays loaded
908
915
  agent._code_index = None
916
+ agent.escalate_approved = True if settings.escalate_model else None # the --escalate flag is the OK
909
917
  before = dict(agent.usage)
910
918
  first = len(agent.messages)
911
- error = None
912
919
  started = time.monotonic()
913
- try:
914
- with time_limit(timeout):
915
- agent.run_turn(task.prompt)
916
- except TaskTimeout:
917
- error = f"timed out after {timeout:.0f}s"
918
- except OllamaError as e:
919
- error = str(e).splitlines()[0][:200]
920
+ escalations = 0
921
+ error = attempt(agent, task.prompt, timeout)
922
+ escalations += len(agent.escalations)
923
+ check = checked(task, folder, agent)
924
+ first_passed = None
925
+ if retry and not (check.passed and error is None):
926
+ first_passed = False
927
+ text = RETRY
928
+ if error is None and agent.can_escalate():
929
+ advice = escalate.run(agent, "the user says the fix didn't work")
930
+ escalations += 1 if advice else 0
931
+ text = f"{RETRY}\n\n{advice}" if advice else RETRY
932
+ error = attempt(agent, text, timeout)
933
+ escalations += len(agent.escalations)
934
+ check = checked(task, folder, agent)
920
935
  seconds = time.monotonic() - started
921
936
  for key, value in agent.usage.items():
922
937
  usage[key] = usage.get(key, 0) + value - before.get(key, 0)
923
- answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
924
- try:
925
- check = task.check(folder, answer)
926
- except Exception as e: # a check must never crash the benchmark
927
- check = Check(False, f"check failed: {type(e).__name__}: {e}")
928
938
  finally: # also when Ctrl+C stops the task
929
939
  from lcode.codesearch import index_dir
930
940
 
@@ -942,9 +952,34 @@ def run_task(
942
952
  tool_errors=count_tool_errors(agent.messages[first:] if len(agent.messages) > first else agent.messages),
943
953
  errors_by_tool=errors_by_tool(agent.messages[first:] if len(agent.messages) > first else agent.messages),
944
954
  error=error,
955
+ first_passed=first_passed,
956
+ escalations=escalations,
945
957
  )
946
958
 
947
959
 
960
+ RETRY = "That didn't fix it: it still doesn't work right. Look again."
961
+
962
+
963
+ def attempt(agent: Agent, text: str, timeout: float) -> str | None:
964
+ """One request of a task; why it stopped early, if it did."""
965
+ try:
966
+ with time_limit(timeout):
967
+ agent.run_turn(text)
968
+ except TaskTimeout:
969
+ return f"timed out after {timeout:.0f}s"
970
+ except OllamaError as e:
971
+ return str(e).splitlines()[0][:200]
972
+ return None
973
+
974
+
975
+ def checked(task: Task, folder: Path, agent: Agent) -> Check:
976
+ answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
977
+ try:
978
+ return task.check(folder, answer)
979
+ except Exception as e: # a check must never crash the benchmark
980
+ return Check(False, f"check failed: {type(e).__name__}: {e}")
981
+
982
+
948
983
  def prompt_speed(ollama: Ollama, settings: Settings, options: dict) -> float | None:
949
984
  """Prompt tokens per second for prompts the model hasn't seen: the faster of two readings.
950
985
 
@@ -998,6 +1033,7 @@ def run_model(
998
1033
  keep: bool = False,
999
1034
  verbose: bool = False,
1000
1035
  session: bool = False,
1036
+ retry: bool = False,
1001
1037
  ) -> ModelRun:
1002
1038
  run = ModelRun(name, settings.model, settings.context, settings.num_batch, settings.think, session=session)
1003
1039
  shared: Agent | None = None # with --session, one conversation for all tasks
@@ -1038,10 +1074,10 @@ def run_model(
1038
1074
  try:
1039
1075
  if verbose:
1040
1076
  console.rule(f"{task.id}: {task.title}")
1041
- result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared)
1077
+ result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared, retry)
1042
1078
  else:
1043
1079
  with console.status(Elapsed(f" {task.id:<13} {task.title}")):
1044
- result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared)
1080
+ result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared, retry)
1045
1081
  except KeyboardInterrupt:
1046
1082
  console.print(" [yellow]Stopped.[/]")
1047
1083
  run.interrupted = True
lcode/cli.py CHANGED
@@ -582,11 +582,14 @@ def cmd_bench(args) -> None:
582
582
  compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at,
583
583
  checks=cfg["checks"] if args.checks is None else args.checks,
584
584
  review=cfg["review"] if args.review is None else args.review,
585
+ escalate_model=args.escalate_model,
586
+ escalate_url=cfg["escalate_url"],
587
+ escalate_price=cfg["escalate_price"],
585
588
  repo_map=args.repo_map,
586
589
  embed_model=cfg["embed_model"] if args.code_search else "off",
587
590
  )
588
591
  run = bench.run_model(
589
- ollama, name, settings, tasks, console, args.timeout, args.keep, args.verbose, args.session
592
+ ollama, name, settings, tasks, console, args.timeout, args.keep, args.verbose, args.session, args.retry
590
593
  )
591
594
  runs.append(run)
592
595
  if run.interrupted:
@@ -662,6 +665,8 @@ def cmd_chat(args) -> None:
662
665
  sys.exit(1)
663
666
  fail(str(e))
664
667
  exit_code = 0
668
+ if args.escalate and args.prompt:
669
+ agent.escalate_approved = True # the flag is the user's OK for this run
665
670
  try:
666
671
  if quiet:
667
672
  agent.perms.approve = lambda request: False # nobody to ask: refuse what needs permission
@@ -727,6 +732,11 @@ def build_parser() -> argparse.ArgumentParser:
727
732
  parser.add_argument("--plan", action="store_true", help="start in plan mode: agree on a plan before any change")
728
733
  parser.add_argument("--auto-edit", action="store_true", help="apply file edits without asking")
729
734
  parser.add_argument("--yolo", action="store_true", help="never ask for permission (edits and commands)")
735
+ parser.add_argument(
736
+ "--escalate",
737
+ action="store_true",
738
+ help="with -p: let lcode ask escalate_model for help when stuck, without asking you (it costs money)",
739
+ )
730
740
  parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
731
741
  parser.add_argument("--no-web", action="store_true", help="don't let the model search or fetch web pages")
732
742
  parser.add_argument("--no-mcp", action="store_true", help="don't start MCP servers in this session")
@@ -779,6 +789,17 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
779
789
  p.add_argument(
780
790
  "--review", choices=["auto", "off"], help="review each change before it counts (default: the setting)"
781
791
  )
792
+ p.add_argument(
793
+ "--retry",
794
+ action="store_true",
795
+ help='give a failed task one more try, as a user would: "That didn\'t fix it. Look again."',
796
+ )
797
+ p.add_argument(
798
+ "--escalate",
799
+ dest="escalate_model",
800
+ metavar="MODEL",
801
+ help="a stronger model to ask when stuck and on --retry, e.g. ollama/qwen3.8-27b (no questions asked)",
802
+ )
782
803
  p.add_argument(
783
804
  "--checks",
784
805
  metavar="auto|off|COMMAND",
lcode/config.py CHANGED
@@ -61,6 +61,14 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
61
61
  "notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
62
62
  "review": ("off", str, "before the answer, check a change against the request and send problems back: auto | off"),
63
63
  "checks": ("auto", str, "run the project's checks before calling work done: auto | off | a command"),
64
+ "escalate_model": (
65
+ None,
66
+ str,
67
+ "a stronger model to ask, with your OK, when the model is stuck: anthropic/…, openai/…, xai/… (key from "
68
+ "ANTHROPIC_API_KEY, OPENAI_API_KEY, XAI_API_KEY) or ollama/<model> (local or at escalate_url)",
69
+ ),
70
+ "escalate_url": (None, str, "the stronger model's server, if not its provider's (another Ollama, any OpenAI API)"),
71
+ "escalate_price": (None, str, "its price in dollars per million tokens in/out, e.g. 3/15, for the cost shown"),
64
72
  "repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
65
73
  "secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
66
74
  "compact_at": (65536, int, "summarize at this many tokens even when the window is larger (0: only at 85%)"),
@@ -165,6 +173,22 @@ def coerce(key: str, value: object) -> object:
165
173
  if value not in MEMORY_MODES:
166
174
  raise ConfigError(f"memory must be one of {', '.join(MEMORY_MODES)}")
167
175
  return value
176
+ if key in ("escalate_model", "escalate_url", "escalate_price"):
177
+ from lcode import escalate
178
+
179
+ text = str(value).strip()
180
+ if key == "escalate_url":
181
+ return normalize_host(text)
182
+ if key == "escalate_model":
183
+ provider = text.partition("/")[0].lower()
184
+ if "/" in text and provider not in escalate.PROVIDERS:
185
+ raise ConfigError(f"escalate_model's provider must be one of {', '.join(escalate.PROVIDERS)}")
186
+ return text
187
+ try:
188
+ escalate.parse_price(text)
189
+ except escalate.EscalateError as e:
190
+ raise ConfigError(str(e)) from e
191
+ return text
168
192
  if key == "lsp" and str(value).lower() not in ("auto", "off"):
169
193
  raise ConfigError("lsp must be auto or off")
170
194
  if key == "lsp":
lcode/context.py CHANGED
@@ -41,6 +41,8 @@ ERROR_LINE = re.compile(
41
41
  )
42
42
  MAX_ERROR_LINES = 40
43
43
  OUTPUT_DAYS = 7 # saved full outputs are deleted after this
44
+ MAX_SAVED = 20_000_000 # characters of one saved output: a runaway command doesn't fill the disk
45
+ MAX_SAVED_TOTAL = 200_000_000 # bytes of saved outputs in all, the oldest go first
44
46
 
45
47
 
46
48
  # ----------------------------------------------------------------------------- the user's requests
@@ -262,17 +264,38 @@ def save_output(text: str, label: str) -> Path | None:
262
264
  folder = outputs_dir()
263
265
  try:
264
266
  folder.mkdir(parents=True, exist_ok=True)
265
- cutoff = time.time() - OUTPUT_DAYS * 86400
266
- for old in folder.glob("*.txt"):
267
- if old.stat().st_mtime < cutoff:
268
- old.unlink(missing_ok=True)
267
+ clean_outputs(folder, room=min(len(text), MAX_SAVED))
269
268
  path = folder / f"{time.strftime('%Y%m%d-%H%M%S')}-{re.sub(r'[^a-z0-9]+', '-', label.lower())[:30]}.txt"
270
- path.write_text(text)
269
+ path.write_text(text if len(text) <= MAX_SAVED else text[:MAX_SAVED] + CUT_SAVED)
271
270
  return path
272
271
  except OSError:
273
272
  return None
274
273
 
275
274
 
275
+ CUT_SAVED = "\n[lcode stopped saving here: the output went on, but a saved output is kept to 20 MB]\n"
276
+
277
+
278
+ def clean_outputs(folder: Path, room: int = 0) -> None:
279
+ """Delete saved outputs older than OUTPUT_DAYS, then the oldest until they take MAX_SAVED_TOTAL with `room`."""
280
+ cutoff = time.time() - OUTPUT_DAYS * 86400
281
+ files = []
282
+ for old in folder.glob("*.txt"):
283
+ try:
284
+ info = old.stat()
285
+ except OSError:
286
+ continue
287
+ if info.st_mtime < cutoff:
288
+ old.unlink(missing_ok=True)
289
+ else:
290
+ files.append((info.st_mtime, info.st_size, old))
291
+ total = sum(size for _, size, _ in files) + room
292
+ for _, size, old in sorted(files):
293
+ if total <= MAX_SAVED_TOTAL:
294
+ break
295
+ old.unlink(missing_ok=True)
296
+ total -= size
297
+
298
+
276
299
  def shorten(text: str, limit: int, label: str = "output") -> str:
277
300
  """Head, tail and the error-looking lines in between; the full text goes to a file."""
278
301
  if len(text) <= limit:
@@ -315,6 +338,7 @@ class OutputSpool:
315
338
  self.chars = 0
316
339
  self.file = None
317
340
  self.path: Path | None = None
341
+ self.saved = 0 # characters written to the file
318
342
 
319
343
  def add(self, line: str) -> None:
320
344
  self.chars += len(line)
@@ -323,10 +347,12 @@ class OutputSpool:
323
347
  if self.chars > self.limit * 2:
324
348
  self._spool()
325
349
  return
326
- try:
327
- self.file.write(line)
328
- except OSError:
329
- pass
350
+ if self.saved <= MAX_SAVED: # past it, only the end and the error lines are kept (in memory)
351
+ try:
352
+ self.file.write(line if self.saved + len(line) <= MAX_SAVED else CUT_SAVED)
353
+ except OSError:
354
+ pass
355
+ self.saved += len(line)
330
356
  if ERROR_LINE.search(line) and len(self.errors) < MAX_ERROR_LINES:
331
357
  self.errors.append(line.strip()[:300])
332
358
  self.tail.append(line)
@@ -337,6 +363,7 @@ class OutputSpool:
337
363
  def _spool(self) -> None:
338
364
  text = "".join(self.head)
339
365
  self.path = save_output(text, self.label)
366
+ self.saved = len(text)
340
367
  if self.path is not None:
341
368
  try:
342
369
  self.file = self.path.open("a")
@@ -359,12 +386,10 @@ class OutputSpool:
359
386
  note += "\n[...]\n\n"
360
387
  else:
361
388
  note += "] ...\n\n"
362
- where = (
363
- f"\n[The full {self.label} ({self.chars} characters) is in {self.path}: read parts of it with read_file "
364
- "if you need them.]"
365
- if self.path
366
- else ""
367
- )
389
+ whole = f"The full {self.label} ({self.chars} characters)"
390
+ if self.saved > MAX_SAVED:
391
+ whole = f"The first {MAX_SAVED // 1_000_000} MB of the {self.label} ({self.chars} characters in all)"
392
+ where = f"\n[{whole} is in {self.path}: read parts of it with read_file if you need them.]" if self.path else ""
368
393
  return f"{head}{note}{tail}{where}"
369
394
 
370
395