lcode-cli 0.34.0__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.34.0"
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,
@@ -307,6 +308,9 @@ class Settings:
307
308
  repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
308
309
  checks: str = "off" # the project's checks after a change (lcode.checks): auto, off or a command
309
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
310
314
 
311
315
 
312
316
  class Agent:
@@ -369,6 +373,8 @@ class Agent:
369
373
  self._check: tuple[Path, checks.Check | None] | None = None
370
374
  self.check_results: list[dict] = [] # the checks of the last request: command, exit code, who ran them
371
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)
372
378
  self.sent_chars = 0 # characters of the conversation the model had already read
373
379
  self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
374
380
  self.turn_status = "" # how the last request ended: success, max_steps, loop
@@ -952,9 +958,8 @@ class Agent:
952
958
  )
953
959
  return None
954
960
  if round_ >= checks.MAX_ROUNDS:
955
- self.say(
956
- f"Checks still fail ({check.command}, exit code {code}): the model will say what's left.", warning=True
957
- )
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)
958
963
  else:
959
964
  self.say(
960
965
  f"Checks fail ({check.command}, exit code {code}): the model gets the output to fix it.", warning=True
@@ -1103,6 +1108,8 @@ class Agent:
1103
1108
  self.repeats.reset()
1104
1109
  self.check_results = []
1105
1110
  self.review_results = None
1111
+ self.escalations = []
1112
+ escalated = False # the stronger model is asked at most once per request
1106
1113
  self.tools.before = {}
1107
1114
  reviewed = False
1108
1115
  changed = verified = False # files changed in this request; the checks passed since
@@ -1166,6 +1173,15 @@ class Agent:
1166
1173
  check_rounds += 1
1167
1174
  feedback = self.run_check(check, check_rounds)
1168
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
1169
1185
  self.messages.append({"role": "user", "content": feedback})
1170
1186
  changed = False # run them again only if the model changes something more
1171
1187
  continue
@@ -1259,6 +1275,14 @@ class Agent:
1259
1275
  skipped["tool_call_id"] = rest["id"]
1260
1276
  self.messages.append(skipped)
1261
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
1262
1286
  if looping:
1263
1287
  self.turn_status = "loop"
1264
1288
  self.console.print(
@@ -1276,6 +1300,11 @@ class Agent:
1276
1300
  self.turn_status = "success"
1277
1301
  self.print_stats()
1278
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
+
1279
1308
  def steer(self) -> None:
1280
1309
  """Between two steps: pause if the user pressed Esc, then hand the model the notes typed meanwhile."""
1281
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
 
lcode/escalate.py ADDED
@@ -0,0 +1,315 @@
1
+ """Ask a stronger model when the local one is stuck (opt-in, asked each time, with the cost shown).
2
+
3
+ Some problems are beyond a small local model: it loops, or the same check keeps failing. With `escalate_model`
4
+ set, lcode can then ask a stronger model, in the cloud (OpenAI, Anthropic, xAI, with the user's key) or a
5
+ bigger local one (Ollama, here or on another server), for one piece of advice. The local model gets the advice
6
+ and carries on with its own tools.
7
+
8
+ Nothing is sent without asking: the user sees why, what goes (the requests, the task sheet, the failure, the
9
+ files in question) and its size and price, and can read all of it first. Only that goes, never the whole
10
+ session, and API keys come from environment variables, read when needed and sent only to their own provider.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ import time
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING
20
+
21
+ from rich.markup import escape
22
+ from rich.panel import Panel
23
+ from rich.text import Text
24
+
25
+ from lcode import context as context_tools
26
+ from lcode.ollama import Ollama, OllamaError, redact
27
+
28
+ if TYPE_CHECKING:
29
+ from lcode.agent import Agent
30
+
31
+ # provider -> (API address, the environment variable with its key, its name)
32
+ PROVIDERS = {
33
+ "openai": ("https://api.openai.com/v1", "OPENAI_API_KEY", "OpenAI"),
34
+ "anthropic": ("https://api.anthropic.com/v1", "ANTHROPIC_API_KEY", "Anthropic"),
35
+ "xai": ("https://api.x.ai/v1", "XAI_API_KEY", "xAI"),
36
+ "ollama": ("", "", "Ollama"), # lcode's own Ollama, or another one at escalate_url
37
+ "custom": ("", "LCODE_ESCALATE_API_KEY", "OpenAI-compatible server"), # any OpenAI API at escalate_url
38
+ }
39
+ BUDGET = 60_000 # characters sent at most (about 15K tokens)
40
+ FILE_CHARS = 16_000 # of one file
41
+ MAX_OUTPUT = 4096 # tokens the advice may take (thinking included, for local models)
42
+ RECENT_STEPS = 8 # the last tool calls and results that go along
43
+ TIMEOUT = 1800 # seconds: a big local model is slow
44
+
45
+ ADVISOR = """You are a senior engineer helping a smaller coding model that is stuck on a task in the user's repository. You can't run anything: you get the user's request, the model's notes, why it's stuck, the files in question and its last steps. Find the real cause and say exactly what to change.
46
+
47
+ Answer in this order:
48
+ 1. What's wrong: the root cause in a few sentences, pointing at file:line.
49
+ 2. The fix: unified diffs, or edits precise enough to apply (file, the exact code to replace, the new code). Fix the cause, not the symptom: no special cases for a test.
50
+ 3. How to check it: the command to run and what it should show.
51
+ Be brief. If something you need is missing, say what to look at."""
52
+
53
+ ADVICE = """[lcode] You were stuck ({why}), so lcode asked a stronger model, {model}, for advice. It saw your notes, the failure, the files involved and your last steps, but couldn't run anything:
54
+
55
+ {advice}
56
+
57
+ Check its advice against the code, apply what's right with your tools, run the checks, and carry on."""
58
+
59
+
60
+ class EscalateError(Exception):
61
+ """The stronger model can't be asked; the message says why and what to do."""
62
+
63
+
64
+ @dataclass
65
+ class Target:
66
+ provider: str
67
+ model: str
68
+ url: str # where the request goes ("" for lcode's own Ollama)
69
+ key_env: str # the environment variable with the key ("" when none is needed)
70
+ label: str # e.g. "claude-sonnet-4-5 (Anthropic)"
71
+
72
+ @property
73
+ def local(self) -> bool:
74
+ return not self.url or bool(re.match(r"^https?://(localhost|127\.0\.0\.1|\[::1\])(:|/|$)", self.url))
75
+
76
+ @property
77
+ def cloud(self) -> bool:
78
+ """Paid per token: a cloud provider, or an OpenAI-compatible API elsewhere."""
79
+ return self.provider in ("openai", "anthropic", "xai") or (self.provider == "custom" and not self.local)
80
+
81
+ @property
82
+ def where(self) -> str:
83
+ if self.url:
84
+ return re.sub(r"^https?://", "", redact(self.url)).split("/")[0]
85
+ return "this machine"
86
+
87
+
88
+ def target(setting: str | None, url: str | None = None) -> Target | None:
89
+ """The stronger model from the settings (None: escalation is off). Raises EscalateError for a bad setting."""
90
+ setting = (setting or "").strip()
91
+ if not setting or setting.lower() == "off":
92
+ return None
93
+ provider, sep, model = setting.partition("/")
94
+ if not sep or provider.lower() not in PROVIDERS:
95
+ if url: # a model name with its own server
96
+ provider, model = ("ollama" if ":11434" in url else "custom"), setting
97
+ else:
98
+ raise EscalateError(
99
+ f"escalate_model {setting!r} needs a provider: openai/…, anthropic/…, xai/…, ollama/… or custom/… "
100
+ "(with escalate_url)"
101
+ )
102
+ provider = provider.lower()
103
+ base, key_env, name = PROVIDERS[provider]
104
+ if not model.strip():
105
+ raise EscalateError(f"escalate_model {setting!r} names no model")
106
+ if provider == "custom" and not url:
107
+ raise EscalateError("a custom/… escalate_model needs escalate_url, the server's OpenAI-compatible address")
108
+ return Target(provider, model.strip(), (url or base).rstrip("/"), key_env, f"{model.strip()} ({name})")
109
+
110
+
111
+ def client(t: Target, ollama):
112
+ """The client that talks to the stronger model. The key is read now and only goes to its provider."""
113
+ if t.provider == "ollama":
114
+ if t.url:
115
+ return Ollama(t.url)
116
+ # lcode's own Ollama, unless lcode talks to another kind of server: then the usual address
117
+ return ollama if getattr(ollama, "kind", "ollama") == "ollama" else Ollama("http://localhost:11434")
118
+ from lcode.backends import OpenAICompatible
119
+
120
+ key = os.environ.get(t.key_env, "") if t.key_env else ""
121
+ if t.provider != "custom" and not key:
122
+ raise EscalateError(f"{t.label} needs an API key: set {t.key_env} in your environment (lcode never stores it)")
123
+ return OpenAICompatible(t.url, api_key=key, name=t.label)
124
+
125
+
126
+ def parse_price(text: str | None) -> tuple[float, float] | None:
127
+ """`escalate_price` "3/15": dollars per million input and output tokens."""
128
+ if not text:
129
+ return None
130
+ m = re.fullmatch(r"\s*\$?(\d+(?:\.\d+)?)\s*/\s*\$?(\d+(?:\.\d+)?)\s*", str(text))
131
+ if not m:
132
+ raise EscalateError(f"escalate_price must look like 3/15 (dollars per million tokens in/out), not {text!r}")
133
+ return float(m.group(1)), float(m.group(2))
134
+
135
+
136
+ def tokens(text: str) -> int:
137
+ return max(1, len(text) // 4)
138
+
139
+
140
+ def cost_line(t: Target, sent: int, price: tuple[float, float] | None) -> str:
141
+ if not t.cloud:
142
+ return "free: it runs on " + ("this machine" if not t.url else t.where)
143
+ if price is None:
144
+ return "unknown: set escalate_price to see it (dollars per million tokens in/out, e.g. lcode config set escalate_price 3/15)"
145
+ low = sent * price[0] / 1e6
146
+ high = low + MAX_OUTPUT * price[1] / 1e6
147
+ return f"about ${low:.3f} to ${high:.3f} ({format_k(sent)} tokens in at ${price[0]:g}/M, up to {format_k(MAX_OUTPUT)} out at ${price[1]:g}/M)"
148
+
149
+
150
+ def format_k(n: int) -> str:
151
+ return f"{n / 1000:.1f}K" if n >= 1000 else str(n)
152
+
153
+
154
+ # --------------------------------------------------------------------------------------------- what's sent
155
+
156
+
157
+ def brief(agent: Agent, why: str, failure: str = "") -> tuple[str, list[str]]:
158
+ """What the stronger model gets, and the names of the files in it."""
159
+ parts = []
160
+ requests = context_tools.user_requests(agent.messages)[-3:]
161
+ parts.append("# What the user asked (latest last)\n" + "\n---\n".join(r[:3000] for r in requests))
162
+ sheet = context_tools.task_sheet(agent)
163
+ if sheet:
164
+ parts.append("# The model's task sheet\n" + sheet)
165
+ parts.append(f"# Why it's stuck\n{why}" + (f"\n\n{failure[-6000:]}" if failure else ""))
166
+ steps = recent_steps(agent.messages)
167
+ if steps:
168
+ parts.append("# Its last steps\n" + steps)
169
+ used = sum(len(p) for p in parts)
170
+ files, names = [], []
171
+ for path in relevant_files(agent):
172
+ text = agent.current_text(path)
173
+ if text is None:
174
+ continue
175
+ if len(text) > FILE_CHARS:
176
+ text = text[:FILE_CHARS] + f"\n[… {len(text) - FILE_CHARS} more characters]"
177
+ block = f"## {agent.tools.rel(agent.tools.resolve(path))}\n```\n{text}\n```"
178
+ if used + len(block) > BUDGET:
179
+ break
180
+ used += len(block)
181
+ files.append(block)
182
+ names.append(agent.tools.rel(agent.tools.resolve(path)))
183
+ if files:
184
+ parts.append("# The files involved (as they are now)\n" + "\n\n".join(files))
185
+ return "\n\n".join(parts), names
186
+
187
+
188
+ def relevant_files(agent: Agent) -> list[str]:
189
+ """The files changed in this request, then the ones read most recently."""
190
+ seen: dict[str, None] = dict.fromkeys(agent.tools.before)
191
+ for message in reversed(agent.messages):
192
+ for call in message.get("tool_calls") or []:
193
+ fn = call.get("function") or {}
194
+ args = fn.get("arguments") or {}
195
+ if (
196
+ isinstance(args, dict)
197
+ and fn.get("name") in ("read_file", "edit_file", "write_file")
198
+ and args.get("path")
199
+ ):
200
+ seen.setdefault(str(args["path"]), None)
201
+ if len(seen) >= 12:
202
+ break
203
+ return list(seen)
204
+
205
+
206
+ def recent_steps(messages: list[dict]) -> str:
207
+ lines = []
208
+ for message in messages[-40:]:
209
+ for call in message.get("tool_calls") or []:
210
+ fn = call.get("function") or {}
211
+ args = fn.get("arguments") or {}
212
+ shown = ", ".join(f"{k}={str(v)[:120]!r}" for k, v in args.items()) if isinstance(args, dict) else ""
213
+ lines.append(f"- {fn.get('name', '?')}({shown})")
214
+ if message.get("role") == "tool":
215
+ result = str(message.get("content") or "").strip()
216
+ lines.append(" → " + (result[:400] + " …" if len(result) > 400 else result).replace("\n", "\n "))
217
+ return "\n".join(lines[-RECENT_STEPS * 2 :])
218
+
219
+
220
+ # --------------------------------------------------------------------------------------------- asking
221
+
222
+
223
+ def ask_user(agent: Agent, t: Target, why: str, text: str, names: list[str], price) -> bool:
224
+ """Show what would go where, at what cost, and ask. Always asked, whatever the permission mode."""
225
+ if agent.escalate_approved is not None: # a program decides (lcode -p --escalate, the benchmark)
226
+ return agent.escalate_approved
227
+ if not agent.interactive:
228
+ agent.say("A stronger model could help, but nobody can be asked in this run (lcode -p --escalate allows it).")
229
+ return False
230
+ sent = tokens(ADVISOR) + tokens(text)
231
+ files = ", ".join(names[:6]) + (f" and {len(names) - 6} more" if len(names) > 6 else "") if names else "none"
232
+ body = Text.from_markup(
233
+ f"[bold]Why:[/] {escape(why)}\n"
234
+ f"[bold]What goes:[/] your requests, the task sheet, the failure, the last steps and these files: "
235
+ f"{escape(files)} ({format_k(sent)} tokens)\n"
236
+ f"[bold]Where:[/] {escape(t.label)}, {'on' if t.where == 'this machine' else 'at'} {escape(t.where)}\n"
237
+ f"[bold]Cost:[/] {escape(cost_line(t, sent, price))}"
238
+ )
239
+ agent.console.print(Panel(body, title=f"Ask {t.model} for help?", title_align="left", border_style="magenta"))
240
+ agent.waiting(f"ask {t.model} for help?")
241
+ read = agent.perms.read_line or input
242
+ while True:
243
+ try:
244
+ answer = read(" Send it? [y]es / [n]o / [s]how what's sent: ").strip().lower()
245
+ except EOFError:
246
+ return False
247
+ if answer in ("s", "show"):
248
+ agent.console.print(Panel(Text(text), title="What would be sent", title_align="left", border_style="dim"))
249
+ continue
250
+ return answer in ("y", "yes")
251
+
252
+
253
+ def advise(agent: Agent, t: Target, text: str) -> tuple[str, dict]:
254
+ """The stronger model's advice, and its token counts."""
255
+ talk = client(t, agent.ollama)
256
+ payload: dict = {
257
+ "model": t.model,
258
+ "messages": [{"role": "system", "content": ADVISOR}, {"role": "user", "content": text}],
259
+ "options": {"num_predict": MAX_OUTPUT},
260
+ }
261
+ if t.provider == "ollama":
262
+ from lcode.cli import NotInstalled, resolve_model
263
+
264
+ try:
265
+ payload["model"], _ = resolve_model(talk, t.model) # a catalog key (qwen3.8-27b) or an Ollama tag
266
+ except (NotInstalled, OllamaError) as e:
267
+ raise EscalateError(str(e)) from e
268
+ payload["think"] = agent.settings.think
269
+ payload["keep_alive"] = "5m" # it gives way to lcode's model soon after
270
+ payload["options"]["num_ctx"] = max(16384, min(65536, (tokens(text) + MAX_OUTPUT) * 5 // 4))
271
+ try:
272
+ reply = talk.chat(payload, timeout=TIMEOUT)
273
+ except OllamaError as e:
274
+ raise EscalateError(str(e).splitlines()[0][:300]) from e
275
+ advice = ((reply.get("message") or {}).get("content") or "").strip()
276
+ if not advice:
277
+ raise EscalateError(f"{t.model} gave no answer")
278
+ usage = {"in": reply.get("prompt_eval_count") or tokens(text), "out": reply.get("eval_count") or tokens(advice)}
279
+ return advice, usage
280
+
281
+
282
+ def run(agent: Agent, why: str, failure: str = "") -> str | None:
283
+ """Ask the stronger model, if one is set and the user agrees; the message for the local model, or None."""
284
+ try:
285
+ t = target(agent.settings.escalate_model, agent.settings.escalate_url)
286
+ price = parse_price(agent.settings.escalate_price)
287
+ except EscalateError as e:
288
+ agent.say(str(e), warning=True)
289
+ return None
290
+ if t is None:
291
+ return None
292
+ text, names = brief(agent, why, failure)
293
+ if not ask_user(agent, t, why, text, names, price):
294
+ return None
295
+ started = time.monotonic()
296
+ try:
297
+ with agent.console.status(f"Asking {t.label} for help…", spinner="dots"):
298
+ advice, usage = advise(agent, t, text)
299
+ except EscalateError as e:
300
+ agent.say(f"Asking {t.model} failed: {e}", warning=True)
301
+ return None
302
+ seconds = time.monotonic() - started
303
+ cost = ""
304
+ if t.cloud and price:
305
+ cost = f", ${usage['in'] * price[0] / 1e6 + usage['out'] * price[1] / 1e6:.3f}"
306
+ agent.escalations.append(
307
+ {"model": t.label, "why": why, "tokens_in": usage["in"], "tokens_out": usage["out"], "seconds": round(seconds)}
308
+ )
309
+ agent.say(
310
+ f"{t.model} answered in {seconds:.0f}s ({format_k(usage['in'])} tokens in, {format_k(usage['out'])} out"
311
+ f"{cost}). Its advice goes to the model."
312
+ )
313
+ first = next((line.strip() for line in advice.splitlines() if line.strip()), "")
314
+ agent.console.print(Text(f" ⎿ {first[:200]}", style="dim"))
315
+ return ADVICE.format(why=why, model=t.label, advice=advice)
lcode/repl.py CHANGED
@@ -27,7 +27,19 @@ from rich.rule import Rule
27
27
  from rich.table import Table
28
28
  from rich.text import Text
29
29
 
30
- from lcode import __version__, backends, catalog, clipboard, extensions, gitflow, limits, residency, sessions, web
30
+ from lcode import (
31
+ __version__,
32
+ backends,
33
+ catalog,
34
+ clipboard,
35
+ escalate,
36
+ extensions,
37
+ gitflow,
38
+ limits,
39
+ residency,
40
+ sessions,
41
+ web,
42
+ )
31
43
  from lcode import context as context_tools
32
44
  from lcode import memory as memory_notes
33
45
  from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
@@ -65,6 +77,7 @@ COMMANDS = {
65
77
  "/cd": "Change the working directory",
66
78
  "/todos": "Show the current todo list",
67
79
  "/status": "The task sheet: your requests, the plan and notes, files changed, the last check",
80
+ "/escalate": "Ask the stronger model (escalate_model) for advice, with your OK: /escalate [what's wrong]",
68
81
  "/jobs": "Background commands the model started: /jobs, /jobs stop <id>",
69
82
  "/commit": "Commit the changes with a message in the repository's style, after you approve it",
70
83
  "/review": "Review the uncommitted changes, or the branch against a base: /review [base]",
@@ -910,6 +923,28 @@ def stop_steering(agent: Agent, reader: KeyReader) -> None:
910
923
  agent.console.print(Text("✎ What you typed didn't reach the model; it's in the prompt below.", style="cyan"))
911
924
 
912
925
 
926
+ def escalate_command(agent: Agent, arg: str) -> None:
927
+ """Ask the stronger model about the work so far, then let the model carry on with its advice."""
928
+ if agent.planning():
929
+ agent.console.print("[yellow]Leave plan mode first (Shift+Tab): the advice is for changing the code.[/]")
930
+ return
931
+ if not agent.can_escalate():
932
+ agent.console.print(
933
+ "[yellow]No stronger model is set.[/] Set one, for example:\n"
934
+ " lcode config set escalate_model anthropic/<model> (with ANTHROPIC_API_KEY in your environment)\n"
935
+ " lcode config set escalate_model ollama/qwen3.8-27b (a bigger local model)\n"
936
+ "See https://nasser1941.github.io/lcode/usage/#a-stronger-model-when-stuck"
937
+ )
938
+ return
939
+ if not context_tools.user_requests(agent.messages):
940
+ agent.console.print("[dim]Nothing to ask about yet: make a request first.[/]")
941
+ return
942
+ why = f"the user says: {arg}" if arg else "the user asked for a second opinion on the work so far"
943
+ advice = escalate.run(agent, why)
944
+ if advice:
945
+ run_safely(agent, f"{arg}\n\n{advice}" if arg else advice)
946
+
947
+
913
948
  def session_start(agent: Agent) -> None:
914
949
  """session_start hooks: their output is shown, and with feedback = true also given to the model."""
915
950
  for outcome in agent.hooks.run("session_start", {"cwd": str(agent.cwd), "session": agent.session_id}, agent.cwd):
@@ -1037,6 +1072,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
1037
1072
  jobs_command(agent, arg)
1038
1073
  elif cmd == "/init":
1039
1074
  run_safely(agent, INIT_PROMPT)
1075
+ elif cmd == "/escalate":
1076
+ escalate_command(agent, arg)
1040
1077
  elif cmd in REPLACEABLE and cmd[1:] not in agent.extensions().commands:
1041
1078
  git_command(agent, cmd, arg)
1042
1079
  elif cmd[1:] in agent.extensions().commands:
lcode/screenshots.py CHANGED
@@ -786,23 +786,32 @@ def is_local(url: str) -> bool:
786
786
  )
787
787
 
788
788
 
789
+ def answers(url: str) -> bool:
790
+ """Whether something answers at the URL (an HTTP error is an answer: the page shows it)."""
791
+ if url.startswith("file:"):
792
+ return True
793
+ try:
794
+ with urllib.request.urlopen(url, timeout=2):
795
+ return True
796
+ except urllib.error.HTTPError:
797
+ return True
798
+ except (urllib.error.URLError, OSError, ValueError):
799
+ return False
800
+
801
+
789
802
  def wait_for_server(url: str, proc: subprocess.Popen, timeout: int, log: Path, cancel=None) -> None:
790
803
  deadline = time.time() + timeout
791
804
  while time.time() < deadline:
792
805
  if cancel is not None and cancel():
793
806
  raise KeyboardInterrupt
807
+ if answers(url): # first: a command that hands off to a server and exits is fine too
808
+ return
794
809
  if proc.poll() is not None:
795
810
  raise ShotError(
796
811
  f"the server command exited (exit code {proc.returncode}) before {url} answered"
797
812
  + (f"\nWhat it printed:\n{printed(log)}" if printed(log) else "")
798
813
  )
799
- try:
800
- with urllib.request.urlopen(url, timeout=2):
801
- return
802
- except urllib.error.HTTPError:
803
- return # it answers, even if with an error: the page shows it
804
- except (urllib.error.URLError, OSError, ValueError):
805
- time.sleep(0.5)
814
+ time.sleep(0.5)
806
815
  raise ShotError(
807
816
  f"{url} didn't answer within {timeout}s of starting the server"
808
817
  + (f"\nWhat it printed:\n{printed(log)}" if printed(log) else "")
@@ -1060,14 +1069,17 @@ def take(
1060
1069
  url = normalize_url(url, cwd)
1061
1070
  server = None
1062
1071
  log = tmp / "server.txt"
1072
+ running = bool(command) and answers(url) # a server is up already (one the model started, say)
1063
1073
  try:
1064
- if command:
1074
+ if command and not running:
1065
1075
  server = run_program(command, cwd, dict(os.environ), log)
1066
1076
  wait_for_server(url, server, SERVER_TIMEOUT, log, cancel)
1067
1077
  shot = web(url, screen, wait, full_page, path, tmp)
1068
1078
  finally:
1069
1079
  stop(server)
1070
- if command:
1080
+ if running:
1081
+ shot.report.append(f"({url} already answered, so `{command}` wasn't started: a server is running.)")
1082
+ elif command:
1071
1083
  shot.output = printed(log)
1072
1084
  return shot
1073
1085
  if not command:
lcode/tools.py CHANGED
@@ -191,7 +191,8 @@ SCHEMAS = [
191
191
  "page), saves a screenshot in .lcode/screenshots/ and returns what it shows, with the sizes of windows and "
192
192
  "widgets (Qt) or of the page, and the browser's console errors. Use it to check GUI and layout work instead "
193
193
  "of guessing. Python Qt programs run offscreen; other desktop programs on a virtual display (Linux); web pages "
194
- "in a headless browser. For a web app, give the command that starts its server and the url.",
194
+ "in a headless browser. For a web app, give the command that starts its server and the url (lcode starts and "
195
+ "stops it); if a server is already running, or for an .html file, give only the url.",
195
196
  {
196
197
  "command": {"type": "string", "description": "Command that starts the program, or the web server"},
197
198
  "url": {"type": "string", "description": "Page to open: a URL, localhost:PORT/path or an .html file"},
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.34.0
3
+ Version: 0.35.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,21 +1,22 @@
1
- lcode/__init__.py,sha256=y26TUBtLfL6QX-n4WMFUb_G6KZloeo7oYdUzb2UBFvU,473
1
+ lcode/__init__.py,sha256=gk-TBYXXl_EmM4X0Si03ubfRw1OFZ-QjmkNP_Rj4jfc,473
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
3
  lcode/acp.py,sha256=d97VV3sbqXdWKhPQQNbYRaDTm8WrUk0mGbDLiOyX7jE,29058
4
4
  lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
5
- lcode/agent.py,sha256=Vd0sEVlaa1sjUf_yIWmPYo5lLnnJapf_KG7wN7H_E9w,76155
6
- lcode/api.py,sha256=YTfXVMSYSp1EC3tNI_fW-JcNBgrdFTN7V57HNIzB7Kc,15861
7
- lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
8
- lcode/bench.py,sha256=KDgPnkceZOS3gDDEl5j6-k8Ser2es4-IuV_S8p_46Zo,41517
5
+ lcode/agent.py,sha256=hLnVWTzXjqgXMn5JgkR-VXPZj4GDri1lIU7xYB5YBBs,78221
6
+ lcode/api.py,sha256=FahtmNRjdgrVbImGFZhnKjMyeHHXGAMHv6-7NR9vm9I,16153
7
+ lcode/backends.py,sha256=Tq8B1XzZflb56N-j7qtzgIws4lodWXIyWJkIqB1wSO4,20916
8
+ lcode/bench.py,sha256=cBchBoDIqq5iA8Otevbf9BKF6mi6BzTEmmdxdG6MpCI,43078
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
13
  lcode/checks.py,sha256=FOD8DOxPWXY1RMtrL9ONJsOxsV10BLgzPvzIJMK5NrU,6582
14
- lcode/cli.py,sha256=pB8gnPuVkqanm9amuLKWanH3_0onpn8oS8botxl-DSw,41292
14
+ lcode/cli.py,sha256=nPvGnL0gZiUkbTniCoTAAyItiPEXZ-f6rHLYYUOKSiU,42151
15
15
  lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
16
16
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
17
- lcode/config.py,sha256=CujFZiUPsbZERjPw2j_uTBnZqUfCgYz1hvcvQSSMCu0,11421
18
- lcode/context.py,sha256=0YYLrC7gI164HqDjW_jrgHSnfrAilrRUoTLhvw54VI8,19082
17
+ lcode/config.py,sha256=hAjuHg7plRxwFgdD4gOGnDH8-FwyT7NRV0YI5grd0qE,12604
18
+ lcode/context.py,sha256=wgK16ZcNNnq4ngHx0EmtyKHDFTImciGEmtC5NP8tKJQ,20435
19
+ lcode/escalate.py,sha256=6amspekybujPn0BfbVsNDYkB-I_pu6jJB45YJgFv0yc,14638
19
20
  lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
20
21
  lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
21
22
  lcode/gitflow.py,sha256=6j_nnCBZXkX1qhTI7K4CyZ5D0NAZLpH96weFEo64AkQ,27913
@@ -33,17 +34,17 @@ lcode/permissions.py,sha256=q7CjDsc_Xrblw7t3_BbxA5LUjxFSswGs1RiJmtMi0po,12288
33
34
  lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
34
35
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
35
36
  lcode/repeats.py,sha256=-vcbj363nFcIyso7yFJgsbHz9C6VOu2EOg3Svs4eFyE,4091
36
- lcode/repl.py,sha256=_krcqKqHKsGUKo4JfQS9KQNBxA47qE1k__TAkDTusRc,53242
37
+ lcode/repl.py,sha256=zEXyfmIW-V8NqvfXmoqqeiMiM8Il4jb8bUFAA1SnF94,54628
37
38
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
38
39
  lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
39
40
  lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
40
41
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
41
- lcode/screenshots.py,sha256=t0jhiQdHULpFcFXNF-NVAjric1K_nkroNf8HkdqqQXo,45897
42
+ lcode/screenshots.py,sha256=bcWSXS4xTsBk8HhpjBy5jvu705uH9qV8bXANr--Mf2M,46405
42
43
  lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
43
44
  lcode/sessions.py,sha256=ojkpP453JyIll8_iFeJgW4BG4Zz2FKMRsLiij6spiTo,4185
44
45
  lcode/steer.py,sha256=0vaqLxgRKgC0yGp8xoQfV1f9VEOzH3U4rCIb74eFnZ8,12127
45
46
  lcode/subagents.py,sha256=GGn2E7QUwEsShJEHXaMhG53leyGFUZwJku_n33gJguU,29157
46
- lcode/tools.py,sha256=xHW6EJRJsoBYYK7P5ihS07DbFJybQObFgR-V2b4-ZBs,67474
47
+ lcode/tools.py,sha256=2aZtQ1dot9p6Y6pcympP8nyiCdlba8gMbCqREwPMtPI,67586
47
48
  lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
48
49
  lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
49
50
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
@@ -63,8 +64,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
63
64
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
64
65
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
65
66
  lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
66
- lcode_cli-0.34.0.dist-info/METADATA,sha256=IQC9qmYY9gtrhxkwRUc9M4iq60gxQrwpqLi80GgB6lY,13141
67
- lcode_cli-0.34.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
68
- lcode_cli-0.34.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
69
- lcode_cli-0.34.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
70
- lcode_cli-0.34.0.dist-info/RECORD,,
67
+ lcode_cli-0.35.0.dist-info/METADATA,sha256=FanOqu5FsF_g3lvnsVZ9ciVjq11BOHYFeBYTFkqm4G4,13141
68
+ lcode_cli-0.35.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
69
+ lcode_cli-0.35.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
70
+ lcode_cli-0.35.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
71
+ lcode_cli-0.35.0.dist-info/RECORD,,