lcode-cli 0.26.0__py3-none-any.whl → 0.28.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.26.0"
6
+ __version__ = "0.28.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/agent.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import base64
6
+ import contextlib
6
7
  import datetime as dt
7
8
  import json
8
9
  import platform
@@ -23,7 +24,20 @@ from rich.markup import escape
23
24
  from rich.panel import Panel
24
25
  from rich.text import Text
25
26
 
26
- from lcode import catalog, codesearch, extensions, limits, notify, planning, repomap, sessions, subagents, vision, web
27
+ from lcode import (
28
+ catalog,
29
+ codesearch,
30
+ extensions,
31
+ limits,
32
+ notify,
33
+ planning,
34
+ repomap,
35
+ residency,
36
+ sessions,
37
+ subagents,
38
+ vision,
39
+ web,
40
+ )
27
41
  from lcode import context as context_tools
28
42
  from lcode import memory as memory_notes
29
43
  from lcode.checkpoints import Checkpoints
@@ -53,7 +67,12 @@ from lcode.tools import (
53
67
  truncate,
54
68
  )
55
69
 
56
- GPU_MEMORY_ERRORS = ("out of memory", "illegal memory access", "cudamalloc failed")
70
+ GPU_MEMORY_ERRORS = (
71
+ "out of memory",
72
+ "illegal memory access",
73
+ "cudamalloc failed",
74
+ "requires more system memory", # Ollama: the model and its context don't fit in RAM either
75
+ )
57
76
  SAFE_NUM_BATCH = 512 # Ollama's default prompt batch
58
77
  MAX_STEPS_PER_TURN = 150
59
78
  EVENT_OUTPUT = 4000 # characters of a tool's output in an event
@@ -62,6 +81,7 @@ REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's pe
62
81
  MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
63
82
  # How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
64
83
  MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
84
+ READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
65
85
  AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
66
86
  PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
67
87
  # @path, @"path with spaces" or @'path'
@@ -171,6 +191,10 @@ def project_root_or_cwd(cwd: Path) -> Path:
171
191
  return work_tree_for(cwd)
172
192
 
173
193
 
194
+ def conversation_chars(messages: list[dict]) -> int:
195
+ return sum(len(str(m.get("content") or "")) + len(str(m.get("thinking") or "")) for m in messages)
196
+
197
+
174
198
  def call_arguments(call: dict) -> dict:
175
199
  """A tool call's arguments as a dict (some models send them as a JSON string)."""
176
200
  args = call.get("function", {}).get("arguments") or {}
@@ -189,17 +213,22 @@ class Progress:
189
213
  arrives, for example while the model writes a long file into a tool call.
190
214
  """
191
215
 
192
- def __init__(self) -> None:
216
+ def __init__(self, phase: str = "Thinking") -> None:
193
217
  self.started = self.last_chunk = time.time()
194
- self.phase = "Thinking"
218
+ self.phase = phase
195
219
  self.chars = 0
196
220
 
197
221
  def update(self, phase: str, text: str) -> None:
198
222
  self.phase, self.chars, self.last_chunk = phase, self.chars + len(text), time.time()
199
223
 
224
+ def set_phase(self, phase: str) -> None:
225
+ """What happens before the first token: loading the model, reading the prompt."""
226
+ self.phase, self.last_chunk = phase, time.time()
227
+
200
228
  def __rich__(self) -> Text:
201
229
  now = time.time()
202
- phase = self.phase if now - self.last_chunk < 3 else "Working"
230
+ streaming = self.phase in ("Thinking", "Writing") # loading and reading say what they are throughout
231
+ phase = self.phase if now - self.last_chunk < 3 or not streaming else "Working"
203
232
  return Text.from_markup(
204
233
  f"[cyan]{phase}…[/] [dim]({self.chars // 4} tokens, {now - self.started:.0f}s · Ctrl+C to stop)[/]"
205
234
  )
@@ -234,6 +263,7 @@ class Settings:
234
263
  notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
235
264
  max_parallel_agents: int = 1
236
265
  secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan)
266
+ compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%)
237
267
  repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
238
268
 
239
269
 
@@ -270,6 +300,7 @@ class Agent:
270
300
  self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents
271
301
  self.plan = "" # the plan the user approved; kept through compaction
272
302
  self.pruned = self.compacted = 0 # how often the context was pruned or summarized automatically
303
+ self.summarized_size = 0 # tokens right after the last summary (0: none in this conversation)
273
304
  self._extensions: tuple[Path, extensions.Extensions] | None = None
274
305
  self.skills_loaded: set[str] = set() # skills whose instructions are in the conversation
275
306
  # Set on subagents (see lcode.subagents):
@@ -286,6 +317,10 @@ class Agent:
286
317
  # Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not.
287
318
  self.plan_review: Callable[[str, str], str] | None = None
288
319
  self.max_steps = MAX_STEPS_PER_TURN
320
+ self.hardware = None # an lcode.hardware.Hardware, set by the CLI: for what fits on the GPU
321
+ self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
322
+ self.load_state = "" # "loading" while the model loads in the background at startup (lcode.repl)
323
+ self.sent_chars = 0 # characters of the conversation the model had already read
289
324
  self.turn_status = "" # how the last request ended: success, max_steps, loop
290
325
  self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
291
326
  self.response = None # the streaming response, so another thread can abort it
@@ -463,12 +498,33 @@ class Agent:
463
498
  image = vision.read_image(path)
464
499
  # The session's own model keeps its settings, so Ollama doesn't reload it.
465
500
  options = self.options() if model == self.settings.model else None
466
- loading = "" if model == self.settings.model else " (loads it; the next request reloads the main model)"
467
- with self.console.status(f"Looking at {path.name} with {model}{loading}…", spinner="dots"):
501
+ with self.vision_swap(model, path.name):
468
502
  return vision.describe(self.ollama, model, image, question, options, self.settings.keep_alive)
469
503
  except vision.VisionError as e:
470
504
  raise ToolError(str(e)) from e
471
505
 
506
+ @contextlib.contextmanager
507
+ def vision_swap(self, model: str, what: str):
508
+ """Look at an image with `model`, saying when it has to load and when the main model made room for it."""
509
+ main = self.settings.model
510
+ own = model == main
511
+ main_was_loaded = not own and residency.placement(self.ollama, main) is not None
512
+ if own or residency.placement(self.ollama, model) is not None:
513
+ text = f"Looking at {what} with {model}…"
514
+ else:
515
+ text = f"Loading the vision model {model} to look at {what}" + (
516
+ f"; {main} makes room and reloads afterwards…" if main_was_loaded else "…"
517
+ )
518
+ with self.console.status(text, spinner="dots"):
519
+ yield
520
+ if main_was_loaded and residency.placement(self.ollama, main) is None:
521
+ self.made_room_for = f"the vision model {model}"
522
+ self.say(f"{main} was unloaded to make room for {model}; it reloads for the next step.")
523
+
524
+ def say(self, text: str, warning: bool = False) -> None:
525
+ """One line about what happens under the hood (loading, memory), not about the work itself."""
526
+ self.console.print(Text(f"{'⚠' if warning else '◆'} {text}", style="yellow" if warning else "dim cyan"))
527
+
472
528
  def describe_image_data(self, data: str, mime: str) -> str:
473
529
  """For images that tools return (e.g. an MCP browser's screenshots): a description, or a note."""
474
530
  model = self.vision_model()
@@ -476,7 +532,7 @@ class Agent:
476
532
  return f"[{mime} image not shown: {vision.INSTALL_HINT}]"
477
533
  options = self.options() if model == self.settings.model else None
478
534
  try:
479
- with self.console.status(f"Looking at the {mime} image with {model}…", spinner="dots"):
535
+ with self.vision_swap(model, f"the {mime} image"):
480
536
  text = vision.describe(
481
537
  self.ollama, model, base64.b64decode(data), "", options, self.settings.keep_alive
482
538
  )
@@ -514,6 +570,7 @@ class Agent:
514
570
  return WEB_PROMPT.format(tools=tools, date=dt.date.today().isoformat())
515
571
 
516
572
  def reset(self) -> None:
573
+ self.summarized_size = 0
517
574
  self.messages = [{"role": "system", "content": self.system_prompt()}]
518
575
  self._mcp_prompt = ""
519
576
  self.tools.read_mtimes.clear()
@@ -621,9 +678,11 @@ class Agent:
621
678
  if not started and batch and batch > SAFE_NUM_BATCH:
622
679
  # Larger batches read prompts faster but need extra VRAM that isn't always free.
623
680
  self.settings.num_batch = SAFE_NUM_BATCH
624
- self.console.print(
625
- f"[yellow]The GPU ran out of memory with a prompt batch of {batch}; retrying with "
626
- f"{SAFE_NUM_BATCH}.[/] [dim]To skip this retry: lcode config set num_batch {SAFE_NUM_BATCH}[/]"
681
+ self.say(
682
+ f"The GPU ran out of memory reading the prompt in batches of {batch} tokens. Retrying with "
683
+ f"batches of {SAFE_NUM_BATCH}: a little slower to read, but it needs less memory. To start "
684
+ f"there next time: lcode config set num_batch {SAFE_NUM_BATCH}",
685
+ warning=True,
627
686
  )
628
687
  yield from self.chat(messages, tools, think)
629
688
  return
@@ -633,16 +692,19 @@ class Agent:
633
692
  smaller = max(catalog.MIN_USEFUL_CONTEXT, context // 2)
634
693
  self.settings.context = smaller
635
694
  limits.record(self.settings.model, smaller)
636
- self.console.print(
637
- f"[yellow]{self.settings.model} didn't fit in GPU memory with a {format_tokens(context)} "
638
- f"context; retrying with {format_tokens(smaller)}.[/] [dim]lcode will start there next "
639
- "time on this machine.[/]"
695
+ self.say(
696
+ f"{self.settings.model} didn't fit in memory with a {format_tokens(context)} context: the "
697
+ "context needs its own memory next to the model. Reloading it with "
698
+ f"{format_tokens(smaller)}; lcode will start there next time on this machine.",
699
+ warning=True,
640
700
  )
641
701
  yield from self.chat(messages, tools, think)
642
702
  return
643
703
  raise OllamaError(
644
- f"{e}\nThe model ran out of GPU memory. Try a smaller context (/ctx 128k), close other programs "
645
- "using the GPU, or pick a smaller model (/models)."
704
+ f"{e}\n{self.settings.model} ran out of memory. Close other programs that use the GPU (another "
705
+ "model, a game, an image generator), use a smaller context (/ctx "
706
+ f"{format_tokens(max(catalog.MIN_USEFUL_CONTEXT // 2, context // 2)).lower()}), or pick a smaller "
707
+ "model (/models)."
646
708
  ) from e
647
709
  raise
648
710
 
@@ -656,6 +718,7 @@ class Agent:
656
718
  content, thinking, tool_calls, final = "", "", [], {}
657
719
  md = MarkdownStreamer(self.console)
658
720
  progress = Progress()
721
+ watch = self.before_request(progress)
659
722
  status = None
660
723
  printed_thinking = False
661
724
 
@@ -677,6 +740,9 @@ class Agent:
677
740
  if self.cancel is not None and self.cancel.is_set():
678
741
  raise KeyboardInterrupt
679
742
  msg = chunk.get("message", {})
743
+ if watch is not None:
744
+ self.after_load(watch)
745
+ watch = None
680
746
  if msg.get("thinking"):
681
747
  thinking += msg["thinking"]
682
748
  if self.on_delta:
@@ -711,6 +777,8 @@ class Agent:
711
777
  raise
712
778
  finally:
713
779
  stop_spinner()
780
+ if watch is not None:
781
+ watch.stop()
714
782
  if printed_thinking:
715
783
  self.console.print()
716
784
  md.flush()
@@ -729,10 +797,49 @@ class Agent:
729
797
  self.usage["output_tokens"] += final.get("eval_count", 0)
730
798
  self.usage["output_ns"] += final.get("eval_duration", 0)
731
799
  self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
800
+ self.sent_chars = conversation_chars(self.messages)
732
801
  seconds = final.get("eval_duration", 0) / 1e9
733
802
  self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
734
803
  return message
735
804
 
805
+ def before_request(self, progress: Progress) -> residency.LoadWatch | None:
806
+ """Set the status line for what comes before the first token: loading the model if it isn't in memory
807
+ (watched until it is), then reading the prompt. Returns the watch while the model loads."""
808
+ if getattr(self.ollama, "kind", "ollama") != "ollama":
809
+ return None
810
+ model, context = self.settings.model, self.settings.context
811
+ fresh = max(0, conversation_chars(self.messages) - self.sent_chars) // 3
812
+ where = residency.placement(self.ollama, model)
813
+ if where is not None and (not where.context or where.context == context):
814
+ if fresh >= READING_NOTE_TOKENS:
815
+ progress.set_phase(f"Reading {format_tokens(fresh)} new tokens")
816
+ return None
817
+ total = conversation_chars(self.messages) // 3
818
+ if where is not None:
819
+ phase = f"Reloading {model} with a {format_tokens(context)} context"
820
+ elif self.made_room_for:
821
+ phase = f"Reloading {model} into memory (it made room for {self.made_room_for})"
822
+ else:
823
+ phase = f"Loading {model} into memory"
824
+ progress.set_phase(phase)
825
+ watch = residency.LoadWatch(
826
+ self.ollama, model, context, progress, f"Reading the conversation ({format_tokens(total)} tokens)"
827
+ )
828
+ watch.reload = where is not None or bool(self.made_room_for)
829
+ return watch
830
+
831
+ def after_load(self, watch: residency.LoadWatch) -> None:
832
+ """The first token after a load: one line on how long it took and how the model fits."""
833
+ seconds = watch.stop()
834
+ self.made_room_for = ""
835
+ self.load_state = ""
836
+ where = residency.placement(self.ollama, self.settings.model)
837
+ dense = residency.is_moe(self.ollama, self.settings.model) is False
838
+ report = residency.load_report(
839
+ self.settings.model, seconds, where, self.settings.context, self.hardware, dense, watch.reload
840
+ )
841
+ self.say(report, warning=report.endswith(("closed.", "slow.")))
842
+
736
843
  def describe_call(self, name: str, args: dict) -> str:
737
844
  if name in ("read_file", "write_file", "edit_file"):
738
845
  path = self.tools.rel(self.tools.resolve(args.get("path", "")))
@@ -1046,23 +1153,46 @@ class Agent:
1046
1153
  return text + ("\n\n" + "\n\n".join(attached) if attached else "")
1047
1154
 
1048
1155
  # -- context management
1156
+ def compact_limit(self) -> int:
1157
+ """The tokens in use at which lcode makes room: 85% of the window, or `compact_at` when that's lower.
1158
+ Small models lose track in long contexts well before a large window is full."""
1159
+ limit = int(AUTO_COMPACT_RATIO * self.settings.context)
1160
+ if self.settings.compact_at > 0:
1161
+ limit = min(limit, self.settings.compact_at)
1162
+ return limit
1163
+
1049
1164
  def maybe_compact(self) -> None:
1050
- if self.ctx_used <= AUTO_COMPACT_RATIO * self.settings.context:
1165
+ limit = self.compact_limit()
1166
+ if self.ctx_used <= limit:
1051
1167
  return
1168
+ enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
1052
1169
  if self.settings.prune:
1053
- saved = context_tools.prune(self.messages) # tool output from before the last two requests
1054
- if self.ctx_used - saved // 3 > PRUNED_ENOUGH * self.settings.context:
1055
- saved += context_tools.prune(self.messages, keep=1) # not enough: before the last request
1170
+ removed: list[str] = [] # files and pages whose only copy goes
1171
+ saved = context_tools.prune(self.messages, removed=removed) # tool output from before the last two requests
1172
+ if self.ctx_used - saved // 3 > enough:
1173
+ saved += context_tools.prune(
1174
+ self.messages, keep=1, removed=removed
1175
+ ) # not enough: before the last request
1176
+ context_tools.note_removed(self.messages, removed)
1056
1177
  if saved:
1178
+ self.sent_chars = 0 # the conversation changed: the model reads it again
1057
1179
  self.pruned += 1
1058
1180
  self.ctx_used = max(len(self.messages[0]["content"]) // 3, self.ctx_used - saved // 3)
1059
1181
  self.console.print(
1060
1182
  f"[dim]Removed old tool output from the conversation (~{format_tokens(saved // 3)} tokens) to "
1061
1183
  "make room.[/]"
1062
1184
  )
1063
- if self.ctx_used <= PRUNED_ENOUGH * self.settings.context:
1185
+ if self.ctx_used <= enough:
1064
1186
  return
1065
- self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]")
1187
+ window_full = self.ctx_used > AUTO_COMPACT_RATIO * self.settings.context
1188
+ if not window_full and self.summarized_size and self.ctx_used - self.summarized_size < limit // 4:
1189
+ return # summarized not long ago: summarizing again so soon would only lose more
1190
+ if limit < AUTO_COMPACT_RATIO * self.settings.context:
1191
+ self.console.print(
1192
+ f"[yellow]The conversation reached {format_tokens(limit)} tokens (compact_at) — summarizing it…[/]"
1193
+ )
1194
+ else:
1195
+ self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]")
1066
1196
  self.compacted += 1
1067
1197
  self.compact()
1068
1198
 
@@ -1083,9 +1213,13 @@ class Agent:
1083
1213
  with self.console.status("[cyan]Compacting…[/]"):
1084
1214
  for chunk in self.chat([*self.messages, {"role": "user", "content": instructions}], None, False):
1085
1215
  summary += chunk.get("message", {}).get("content", "")
1216
+ requests = context_tools.user_requests(self.messages)
1086
1217
  self.messages = [
1087
1218
  {"role": "system", "content": self.system_prompt()},
1088
- {"role": "user", "content": f"[Summary of our conversation so far]\n\n{summary}"},
1219
+ {
1220
+ "role": "user",
1221
+ "content": f"{sessions.SUMMARY_PREFIX}\n\n{summary}" + context_tools.requests_block(requests),
1222
+ },
1089
1223
  {"role": "assistant", "content": "Got it — I have the context from the summary and will continue."},
1090
1224
  ]
1091
1225
  if self.plan: # the approved plan stays word for word
@@ -1097,6 +1231,8 @@ class Agent:
1097
1231
  )
1098
1232
  self.skills_loaded.clear()
1099
1233
  self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
1234
+ self.summarized_size = self.ctx_used
1235
+ self.sent_chars = 0
1100
1236
  self.reflected = len(self.messages)
1101
1237
  self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
1102
1238
 
lcode/api.py CHANGED
@@ -196,8 +196,10 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
196
196
  notify_after=cfg["notify_after"],
197
197
  secret_check=cfg["secret_check"],
198
198
  repeat_limit=cfg["repeat_limit"],
199
+ compact_at=cfg["compact_at"],
199
200
  )
200
201
  agent = Agent(ollama, settings, cwd, console=console)
202
+ agent.hardware = hw
201
203
  agent.interactive = options.interactive
202
204
  if options.max_steps:
203
205
  agent.max_steps = options.max_steps
lcode/cli.py CHANGED
@@ -579,6 +579,7 @@ def cmd_bench(args) -> None:
579
579
  web="off",
580
580
  checkpoints=False,
581
581
  prune=not args.no_prune,
582
+ compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at,
582
583
  repo_map=args.repo_map,
583
584
  embed_model=cfg["embed_model"] if args.code_search else "off",
584
585
  )
@@ -678,6 +679,14 @@ def cmd_chat(args) -> None:
678
679
  sys.exit(exit_code)
679
680
 
680
681
 
682
+ def compact_tokens(value: str) -> int:
683
+ """`--compact-at`: a token count such as 64k, or 0."""
684
+ try:
685
+ return config.parse_compact_at(value)
686
+ except ConfigError as e:
687
+ raise argparse.ArgumentTypeError(str(e)) from e
688
+
689
+
681
690
  def build_parser() -> argparse.ArgumentParser:
682
691
  parser = argparse.ArgumentParser(
683
692
  prog="lcode",
@@ -765,6 +774,12 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
765
774
  p.add_argument("--repo-map", action="store_true", help="give the model the repository map (off by default)")
766
775
  p.add_argument("--code-search", action="store_true", help="index each task's folder and offer semantic code search")
767
776
  p.add_argument("--no-prune", action="store_true", help="don't remove old tool output (to compare, with --session)")
777
+ p.add_argument(
778
+ "--compact-at",
779
+ type=compact_tokens,
780
+ metavar="TOKENS",
781
+ help="summarize at this many tokens, e.g. 64k (default: the compact_at setting; 0: only at 85%%)",
782
+ )
768
783
  subs["bench"] = p
769
784
  p = argparse.ArgumentParser(
770
785
  prog="lcode index",
lcode/config.py CHANGED
@@ -61,6 +61,7 @@ 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
  "repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
63
63
  "secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
64
+ "compact_at": (65536, int, "summarize at this many tokens even when the window is larger (0: only at 85%)"),
64
65
  "max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
65
66
  }
66
67
  ENV_OVERRIDES = {
@@ -81,6 +82,19 @@ class ConfigError(ValueError):
81
82
  pass
82
83
 
83
84
 
85
+ MIN_COMPACT_AT = 16384 # below this, the system prompt and a summary alone come close to the ceiling
86
+
87
+
88
+ def parse_compact_at(value: str | int) -> int:
89
+ """compact_at: a token count like parse_context's, at least 16K, or 0 (off)."""
90
+ if str(value).strip().lower() in ("0", "off", "false", "none"):
91
+ return 0
92
+ tokens = parse_context(value)
93
+ if tokens < MIN_COMPACT_AT:
94
+ raise ConfigError(f"compact_at {tokens} is too small: use at least 16k, or 0 to turn it off")
95
+ return tokens
96
+
97
+
84
98
  def parse_context(value: str | int) -> int:
85
99
  """Parse '131072', '128k', '128K' or '1m' into a token count (k = 1024)."""
86
100
  if isinstance(value, int):
@@ -118,6 +132,8 @@ def coerce(key: str, value: object) -> object:
118
132
  _, typ, _ = SETTINGS[key]
119
133
  if key == "context":
120
134
  return parse_context(value) # type: ignore[arg-type]
135
+ if key == "compact_at":
136
+ return parse_compact_at(value) # type: ignore[arg-type]
121
137
  if key in ("ollama_host", "searxng_url"):
122
138
  return normalize_host(str(value))
123
139
  if key == "base_url":
lcode/context.py CHANGED
@@ -29,6 +29,10 @@ if TYPE_CHECKING:
29
29
  KEEP_RECENT_REQUESTS = 2 # tool results of the last requests stay as they are
30
30
  MIN_PRUNE_CHARS = 400 # shorter results aren't worth replacing
31
31
  PRUNED = "[lcode: " # how stubs start, so they're never pruned twice
32
+ GONE = ( # a removed file read: firm, because models answer from the stub instead of reading it again
33
+ "earlier; its content was removed to save context and isn't in this conversation any more. Read it again "
34
+ "before answering anything about it.]"
35
+ )
32
36
  KEEP_TOOLS = {"agent", "skill", "memory", "todo_write", "present_plan"} # short, or still guiding the work
33
37
  ERROR_LINE = re.compile(
34
38
  r"error|exception|traceback|failed|failure|fatal|panic|assert|denied|not found|cannot|can't|undefined", re.I
@@ -37,6 +41,56 @@ MAX_ERROR_LINES = 40
37
41
  OUTPUT_DAYS = 7 # saved full outputs are deleted after this
38
42
 
39
43
 
44
+ # ----------------------------------------------------------------------------- the user's requests
45
+
46
+ REQUESTS_HEADER = "[The user's requests in this conversation, word for word, oldest first]"
47
+ MAX_REQUESTS = 8 # the most recent ones are kept through a summary
48
+ MAX_REQUEST_CHARS = 1500
49
+ # What lcode appends to the user's words: attached files, images and folders, and its own notes.
50
+ ATTACHED = re.compile(r"\n\n(?:<file path=|<image path=|<directory path=|\[lcode[:\]] |\[The user wants the )")
51
+
52
+
53
+ def request_text(content: str) -> str:
54
+ """The user's own words in a message, without what lcode attached to it."""
55
+ cut = ATTACHED.search(content)
56
+ return (content[: cut.start()] if cut else content).strip()
57
+
58
+
59
+ def user_requests(messages: list[dict]) -> list[str]:
60
+ """What the user asked in the conversation, oldest first, including the requests an earlier summary kept."""
61
+ requests: list[str] = []
62
+ for message in messages:
63
+ content = message.get("content") or ""
64
+ if message.get("role") != "user":
65
+ continue
66
+ if content.startswith(SUMMARY_PREFIX):
67
+ start = content.find(REQUESTS_HEADER)
68
+ if start >= 0:
69
+ rest = content[start + len(REQUESTS_HEADER) :]
70
+ try:
71
+ kept, _ = json.JSONDecoder().raw_decode(rest[rest.index("[") :])
72
+ requests += [str(r) for r in kept]
73
+ except ValueError:
74
+ pass
75
+ continue
76
+ if content.startswith("[lcode]"):
77
+ continue # lcode's own nudges
78
+ text = request_text(content)
79
+ if text:
80
+ requests.append(text)
81
+ return requests
82
+
83
+
84
+ def requests_block(requests: list[str]) -> str:
85
+ """The user's latest requests, word for word, to keep the goal through a summary."""
86
+ if not requests:
87
+ return ""
88
+ kept = [r if len(r) <= MAX_REQUEST_CHARS else f"{r[: MAX_REQUEST_CHARS - 200]} […] {r[-150:]}" for r in requests]
89
+ older = len(kept) - MAX_REQUESTS
90
+ note = f" (and {older} earlier, in the summary)" if older > 0 else ""
91
+ return f"\n\n{REQUESTS_HEADER}{note}\n{json.dumps(kept[-MAX_REQUESTS:], ensure_ascii=False, indent=1)}"
92
+
93
+
40
94
  # ----------------------------------------------------------------------------- pruning
41
95
 
42
96
 
@@ -63,7 +117,7 @@ def stub(name: str, args: dict, content: str) -> str:
63
117
  if args.get("offset") or args.get("limit"):
64
118
  start = int(args.get("offset") or 1)
65
119
  span = f" from line {start}"
66
- return f"{PRUNED}read {args.get('path', 'a file')}{span} earlier; removed to save context. Read it again if you need it.]"
120
+ return f"{PRUNED}read {args.get('path', 'a file')}{span} {GONE}"
67
121
  if name == "bash":
68
122
  command = " ".join(str(args.get("command", "")).split())
69
123
  command = command if len(command) <= 80 else command[:79] + "…"
@@ -92,8 +146,9 @@ def boundary(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
92
146
  return requests[-keep] if len(requests) >= keep else 0
93
147
 
94
148
 
95
- def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
96
- """Replace stale tool results with stubs, in place. Returns the number of characters saved."""
149
+ def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS, removed: list[str] | None = None) -> int:
150
+ """Replace stale tool results with stubs, in place. Returns the number of characters saved; the files and
151
+ pages whose only copy went are added to `removed`."""
97
152
  calls = calls_for_results(messages)
98
153
  edge = boundary(messages, keep)
99
154
  # A read is superseded by a later read of the whole file, or of the same lines: not by a later
@@ -118,12 +173,34 @@ def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
118
173
  continue
119
174
  new = stub(name, args, content)
120
175
  if reread:
121
- new = new.replace("earlier; removed", "earlier (read again later); removed")
176
+ new = new.replace(
177
+ GONE, "earlier (read again later); removed to save context. Read it again if you need it.]"
178
+ )
179
+ elif removed is not None and name in ("read_file", "web_fetch"):
180
+ what = str(args.get("path") or args.get("url") or "")
181
+ if what and what not in removed:
182
+ removed.append(what)
122
183
  messages[i]["content"] = new
123
184
  saved += len(content) - len(new)
124
185
  return saved
125
186
 
126
187
 
188
+ def note_removed(messages: list[dict], removed: list[str]) -> None:
189
+ """Tell the model, next to the request it's working on, which files it no longer has: a stub deep in the
190
+ conversation is easy to overlook, and models then answer from memory instead of reading again."""
191
+ if not removed:
192
+ return
193
+ for message in reversed(messages):
194
+ content = str(message.get("content", ""))
195
+ if message.get("role") == "user" and not content.startswith(("[lcode]", SUMMARY_PREFIX)):
196
+ names = ", ".join(removed[:10]) + (f" and {len(removed) - 10} more" if len(removed) > 10 else "")
197
+ message["content"] = (
198
+ f"{content}\n\n[lcode] To make room, lcode removed what you read earlier from: {names}. It isn't in "
199
+ "this conversation any more: read it again before answering anything about it, don't answer from memory."
200
+ )
201
+ return
202
+
203
+
127
204
  # ----------------------------------------------------------------------------- long output
128
205
 
129
206
 
lcode/repl.py CHANGED
@@ -27,7 +27,7 @@ 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, sessions, web
30
+ from lcode import __version__, backends, catalog, clipboard, extensions, gitflow, limits, residency, sessions, web
31
31
  from lcode import context as context_tools
32
32
  from lcode import memory as memory_notes
33
33
  from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
@@ -153,6 +153,7 @@ def build_session(agent: Agent) -> PromptSession:
153
153
  f" <b>{html.escape(s.model)}</b> · ctx {format_tokens(agent.ctx_used)}/{format_tokens(s.context)} "
154
154
  f"({pct:.0f}%) · mode <{color}>{agent.perms.mode}</{color}> (shift+tab) · "
155
155
  f"think {'on' if s.think else 'off'} · {html.escape(agent.cwd.name)}/"
156
+ + (" · <ansiyellow>loading the model into memory…</ansiyellow>" if agent.load_state == "loading" else "")
156
157
  + (f" · <b>{html.escape(agent.session_name)}</b>" if agent.session_name else "")
157
158
  )
158
159
 
@@ -242,7 +243,8 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
242
243
  c.print(
243
244
  f"In use: {format_tokens(agent.ctx_used)} of {format_tokens(s.context)} tokens "
244
245
  f"({100 * agent.ctx_used / s.context:.0f}%) in {len(agent.messages)} message"
245
- f"{'' if len(agent.messages) == 1 else 's'}; auto-compacts at 85%."
246
+ f"{'' if len(agent.messages) == 1 else 's'}; makes room at {format_tokens(agent.compact_limit())} "
247
+ + ("(compact_at)." if agent.compact_limit() < AUTO_COMPACT_RATIO * s.context else "(85%).")
246
248
  )
247
249
  if agent.mcp and agent.mcp.ready():
248
250
  how = "found on demand" if agent.mcp.searching(s.context) else "sent with every request"
@@ -934,8 +936,12 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
934
936
  s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
935
937
  elif backends.is_openai(agent.ollama):
936
938
  s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
939
+ agent._vision = False # the model that looks at images goes with the model: decide again
937
940
  agent.messages[0]["content"] = agent.system_prompt()
938
941
  c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
942
+ warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
943
+ if warning:
944
+ agent.say(warning, warning=True)
939
945
  elif cmd == "/think":
940
946
  s.think = (arg == "on") if arg in ("on", "off") else not s.think
941
947
  c.print(f"Reasoning {'on' if s.think else 'off'}.")
@@ -1109,9 +1115,12 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
1109
1115
  if prompt:
1110
1116
  run_safely(agent, prompt)
1111
1117
  return
1112
- preload(agent)
1113
1118
  banner(agent)
1119
+ warning = residency.dense_warning(agent.ollama, agent.settings.model, agent.settings.context, hardware)
1120
+ if warning:
1121
+ agent.say(warning, warning=True)
1114
1122
  session = build_session(agent)
1123
+ preload(agent, session)
1115
1124
  while True:
1116
1125
  try:
1117
1126
  agent.console.print()
@@ -1134,13 +1143,25 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
1134
1143
  agent.console.print("[dim]Bye.[/]")
1135
1144
 
1136
1145
 
1137
- def preload(agent: Agent) -> None:
1138
- """Start loading the model in the background so the first answer comes sooner."""
1146
+ def preload(agent: Agent, session: PromptSession | None = None) -> None:
1147
+ """Start loading the model in the background so the first answer comes sooner; the status bar says so."""
1148
+ where = residency.placement(agent.ollama, agent.settings.model)
1149
+ if where is not None and (not where.context or where.context == agent.settings.context):
1150
+ return # already in memory
1139
1151
 
1140
1152
  def load() -> None:
1153
+ agent.load_state = "loading"
1154
+ refresh()
1141
1155
  try:
1142
1156
  agent.ollama.load(agent.settings.model, agent.options(), agent.settings.keep_alive)
1143
1157
  except OllamaError:
1144
1158
  pass
1159
+ finally:
1160
+ agent.load_state = ""
1161
+ refresh()
1162
+
1163
+ def refresh() -> None:
1164
+ if session is not None and session.app.is_running:
1165
+ session.app.invalidate()
1145
1166
 
1146
1167
  threading.Thread(target=load, daemon=True).start()
lcode/residency.py ADDED
@@ -0,0 +1,167 @@
1
+ """Where the model is, in words: loading it, how it fits, making room for another one.
2
+
3
+ A local model has to be in memory before it answers. On a small GPU loading takes a while, how much of
4
+ the model fits on the GPU decides how fast it runs, and a second model (the one that looks at images)
5
+ can push the first one out. lcode says so, in a line, when it happens: nothing is printed while the
6
+ model simply stays loaded.
7
+
8
+ Only Ollama is asked: other servers load and place their models themselves.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ import time
15
+ from dataclasses import dataclass
16
+
17
+ from lcode import catalog
18
+ from lcode.ollama import OllamaError
19
+
20
+ GB = 1e9
21
+ SPLIT_WARNING = 0.95 # a dense model with less than this on the GPU runs much slower
22
+ SHORTFALL = 0.25 # this much less on the GPU than expected: something else is using GPU memory
23
+
24
+
25
+ @dataclass
26
+ class Placement:
27
+ """A loaded model, from Ollama's /api/ps."""
28
+
29
+ name: str
30
+ size: float # bytes in memory
31
+ vram: float # bytes of it on the GPU
32
+ context: int = 0 # the context window it was loaded with
33
+
34
+ @property
35
+ def gpu_share(self) -> float:
36
+ return self.vram / self.size if self.size else 0.0
37
+
38
+ def describe(self) -> str:
39
+ """'22.3 GB, 44% on the GPU and the rest in system RAM'"""
40
+ size = f"{self.size / GB:.1f} GB"
41
+ share = self.gpu_share
42
+ if share >= 0.995:
43
+ return f"{size}, all on the GPU"
44
+ if share <= 0.005:
45
+ return f"{size}, all in system RAM (on the CPU)"
46
+ return f"{size}, {share:.0%} on the GPU and the rest in system RAM"
47
+
48
+
49
+ def same_model(a: str, b: str) -> bool:
50
+ return a.removesuffix(":latest") == b.removesuffix(":latest")
51
+
52
+
53
+ def loaded(ollama, model: str | None = None) -> list[Placement]:
54
+ """The models Ollama has in memory (only `model`, if given). Empty when it can't be asked."""
55
+ if getattr(ollama, "kind", "ollama") != "ollama":
56
+ return []
57
+ try:
58
+ entries = ollama.running()
59
+ except (OllamaError, OSError):
60
+ return []
61
+ found = []
62
+ for entry in entries or []:
63
+ name = entry.get("name") or entry.get("model") or ""
64
+ if model is None or same_model(name, model):
65
+ size = float(entry.get("size") or 0)
66
+ vram = float(entry.get("size_vram") if entry.get("size_vram") is not None else size)
67
+ found.append(Placement(name.removesuffix(":latest"), size, vram, int(entry.get("context_length") or 0)))
68
+ return found
69
+
70
+
71
+ def placement(ollama, model: str) -> Placement | None:
72
+ found = loaded(ollama, model)
73
+ return found[0] if found else None
74
+
75
+
76
+ def is_moe(ollama, model: str) -> bool | None:
77
+ """Whether the model is a Mixture-of-Experts (None: unknown)."""
78
+ spec = catalog.find(model)
79
+ if spec:
80
+ return spec.moe
81
+ try:
82
+ info = (ollama.show(model) or {}).get("model_info") or {}
83
+ except (OllamaError, OSError):
84
+ return None
85
+ if not info:
86
+ return None
87
+ return any(key.endswith(".expert_count") and value for key, value in info.items())
88
+
89
+
90
+ def expected_share(model: str, context: int, hardware) -> float | None:
91
+ """How much of the model should fit on the GPU, from the catalog's estimate."""
92
+ spec = catalog.find(model)
93
+ if not spec or hardware is None or not hardware.gpu or hardware.unified:
94
+ return None
95
+ return min(1.0, hardware.vram_gib / spec.memory_gib(context))
96
+
97
+
98
+ def dense_warning(ollama, model: str, context: int, hardware) -> str:
99
+ """For a dense model that won't fit on the GPU: what that means and what to do. Empty otherwise."""
100
+ if hardware is None or hardware.unified or is_moe(ollama, model) is not False:
101
+ return ""
102
+ spec = catalog.find(model)
103
+ params = f" {spec.params.split(' ')[0]}" if spec and spec.params else ""
104
+ if not hardware.gpu:
105
+ where = "There's no GPU here, so it runs on the CPU"
106
+ else:
107
+ share = expected_share(model, context, hardware)
108
+ if share is None or share >= SPLIT_WARNING:
109
+ return ""
110
+ where = (
111
+ f"Only about {share:.0%} of it fits in the {hardware.vram_gib:.0f} GB of GPU memory and the rest runs "
112
+ "on the CPU"
113
+ )
114
+ return (
115
+ f"{model} is a dense model: all{params} parameters work on every token. It's capable, but slow here. "
116
+ f"{where}, so expect a few tokens a second. For speed, pick a Mixture-of-Experts model such as "
117
+ "qwen3.6-35b (/model qwen3.6-35b), or a smaller context (/ctx 16k) to keep more of it on the GPU."
118
+ )
119
+
120
+
121
+ def load_report(
122
+ model: str, seconds: float, where: Placement | None, context: int, hardware, dense: bool, reload: bool = False
123
+ ) -> str:
124
+ """The line after a load: how long it took, how it fits, and a warning if it fits worse than it should."""
125
+ took = f" in {seconds:.0f}s" if seconds >= 1 else ""
126
+ line = f"{'Reloaded' if reload else 'Loaded'} {model}{took}" + (f": {where.describe()}." if where else ".")
127
+ if where is None:
128
+ return line
129
+ expected = expected_share(model, context, hardware)
130
+ if expected is not None and where.gpu_share < expected - SHORTFALL:
131
+ line += (
132
+ f" Less of it is on the GPU than usual ({expected:.0%}): another program or model is using GPU "
133
+ "memory, so it runs slower until that's closed."
134
+ )
135
+ elif dense and where.gpu_share < SPLIT_WARNING:
136
+ line += " It's a dense model split between GPU and CPU, so expect it to be slow."
137
+ return line
138
+
139
+
140
+ class LoadWatch:
141
+ """While a model loads: asks Ollama once a second, and once the model is in memory moves the status line
142
+ on to reading the prompt, which comes next and can take a while too."""
143
+
144
+ POLL = 1.0
145
+
146
+ def __init__(self, ollama, model: str, context: int, progress, reading: str) -> None:
147
+ self.ollama, self.model, self.context = ollama, model, context
148
+ self.progress, self.reading = progress, reading
149
+ self.started = time.monotonic()
150
+ self.loaded_after: float | None = None
151
+ self.reload = False # it was in memory before: unloaded for another model, or loaded with another context
152
+ self.stopped = threading.Event()
153
+ threading.Thread(target=self.run, daemon=True).start()
154
+
155
+ def run(self) -> None:
156
+ while not self.stopped.wait(self.POLL):
157
+ where = placement(self.ollama, self.model)
158
+ if where and (not where.context or not self.context or where.context == self.context):
159
+ self.loaded_after = time.monotonic() - self.started
160
+ if not self.stopped.is_set():
161
+ self.progress.set_phase(self.reading)
162
+ return
163
+
164
+ def stop(self) -> float:
165
+ """Seconds the load took (until the first token, when the end of the load went unseen)."""
166
+ self.stopped.set()
167
+ return self.loaded_after if self.loaded_after is not None else time.monotonic() - self.started
lcode/tools.py CHANGED
@@ -614,9 +614,8 @@ class Toolbox:
614
614
  arguments = {**arguments, "wait": True}
615
615
  freed = self.agent.free_gpu()
616
616
  if freed:
617
- self.console.print(
618
- Text(f" ⎿ freed the GPU for {state.name} ({', '.join(freed)} reloads afterwards)", style="dim")
619
- )
617
+ self.agent.made_room_for = state.name
618
+ self.agent.say(f"Unloaded {', '.join(freed)} so {state.name} can use the GPU; it reloads afterwards.")
620
619
  result = mcp.call(state, tool, arguments, image_text=self.agent.describe_image_data)
621
620
  if result.startswith("Error:"):
622
621
  return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.26.0
3
+ Version: 0.28.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,20 @@
1
- lcode/__init__.py,sha256=xjCrbnr5jMJNipwGklkGM9ZGwF83DFfVEQPHFTwk3IY,473
1
+ lcode/__init__.py,sha256=OG44SXHF0F_qxUeC9BH5G84cellTu3wqLwxsfUfYdUU,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=kAjvCq2XrXqNVyrwbgLhG0XOtXdY0TVk-ty2Oou6hnk,56297
6
- lcode/api.py,sha256=i6mxPCkD1ObrVQQeJ7LacEtsjuhQFdV21fM9FTj3hrg,15418
5
+ lcode/agent.py,sha256=HFdmGXK_3oHu57L15z2dmJsQTj_ddjRDq7JnxM1Qq34,63045
6
+ lcode/api.py,sha256=kYq7RghkNyf-A7qsZh6AeMzLoZbc9rTobsFRLtAcvD8,15480
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=cH5mcYi54Aofcv0PDNgFmFFQX8OQjhwTGdbWMUM_FG8,40289
13
+ lcode/cli.py,sha256=M6Mjd5BROSGlZj4J6TrSZMJqI-OVfUEWPoC0TQyOQ28,40831
14
14
  lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
15
15
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
16
- lcode/config.py,sha256=g7oT5ElKonmbR7_4itgJllTp81wGEYZy1fRZJnAvzOw,10481
17
- lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
16
+ lcode/config.py,sha256=q1zGzH3p5U23ai6O9Jai3dS-s30CuQ9gc5cWX0_yxWE,11195
17
+ lcode/context.py,sha256=_f0NbXyjA4SyH2RCr49AiWwtAfr_PVDaqyFQ5fZDhkg,14404
18
18
  lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
19
19
  lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
20
20
  lcode/gitflow.py,sha256=6j_nnCBZXkX1qhTI7K4CyZ5D0NAZLpH96weFEo64AkQ,27913
@@ -31,13 +31,14 @@ lcode/permissions.py,sha256=i04YLtqYCxwApgawomPp4d35uk9HAJtpKY__gfw8j9s,6347
31
31
  lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
32
32
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
33
33
  lcode/repeats.py,sha256=uzyAewX03nJf5dKwYFH4y9ZDnh0Lvy2k0qC2vdntM5Y,4061
34
- lcode/repl.py,sha256=ZKd4l4wDGKHVaGz8bxjLGc2gRc2W-RpHPQYG9M2A_Zw,49441
34
+ lcode/repl.py,sha256=Et8Tf4COLJ9_FsXtjcOciah_33lLgUS5sl4QAer1m8Y,50652
35
35
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
36
+ lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
36
37
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
37
38
  lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
38
39
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
39
40
  lcode/subagents.py,sha256=Z2RHp7an16dhOGReucVIEOAnXjxM8jx7sHvWd_Fp2V4,29142
40
- lcode/tools.py,sha256=jqxMcyvvDZs0rV0AROYxpJ4krxbU6LbkOWnxiLEqq1o,48426
41
+ lcode/tools.py,sha256=OFltvnRZlSUMtzNLEUF84RFoyKQkAFpb7_ZBVhVcVp4,48426
41
42
  lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
42
43
  lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
43
44
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
@@ -57,8 +58,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
57
58
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
58
59
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
59
60
  lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
60
- lcode_cli-0.26.0.dist-info/METADATA,sha256=iHy2mJa4Bk0LVY3hiDAkHxhBs2uqk21--IGY27MaePw,12620
61
- lcode_cli-0.26.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
62
- lcode_cli-0.26.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
63
- lcode_cli-0.26.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
64
- lcode_cli-0.26.0.dist-info/RECORD,,
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,,