lcode-cli 0.39.0__py3-none-any.whl → 0.41.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.39.0"
6
+ __version__ = "0.41.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/agent.py CHANGED
@@ -306,6 +306,9 @@ class Settings:
306
306
  context: int
307
307
  mode: str = "code" # what lcode makes: code, image or video (lcode.modes)
308
308
  comfyui_url: str = "http://127.0.0.1:8188"
309
+ comfyui_path: str | None = None # a ComfyUI install lcode doesn't find by itself (lcode.comfy)
310
+ media_dir: str = ".lcode/media" # where image and video mode save their files (lcode.media)
311
+ image_model: str = "flux2-klein-4b" # the media catalog's model image mode uses (lcode.media_catalog)
309
312
  num_batch: int | None = None
310
313
  keep_alive: str = "30m"
311
314
  think: bool = True
@@ -370,6 +373,8 @@ class Agent:
370
373
  self.session_name = ""
371
374
  self.session_title = ""
372
375
  self.interactive = True # False for `lcode -p`: no end-of-session questions
376
+ self.comfy_process = None # the ComfyUI lcode started for image and video mode, stopped when it exits
377
+ self.no_coding_model = "" # why there's no coding model, when lcode started in image or video mode without
373
378
  self.reflected = 0 # messages before this index were already checked for notes to remember
374
379
  self._memory: memory_notes.Memory | None = None
375
380
  self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
@@ -683,6 +688,7 @@ class Agent:
683
688
  "cwd": str(self.workspace), # where `lcode -c` there finds it, wherever the shell moved
684
689
  "model": self.settings.model,
685
690
  "mode": self.settings.mode,
691
+ "image_model": self.settings.image_model,
686
692
  "name": self.session_name,
687
693
  "title": self.session_title,
688
694
  "messages": self.messages,
@@ -731,6 +737,8 @@ class Agent:
731
737
  self.session_name = data.get("name", "")
732
738
  if data.get("mode") in ("code", "image", "video"):
733
739
  self.settings.mode = data["mode"] # a session belongs to the mode it was made in
740
+ if isinstance(data.get("image_model"), str):
741
+ self.settings.image_model = data["image_model"]
734
742
  self.session_title = data.get("title") or sessions.title_from(self.messages)
735
743
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
736
744
  self.tools.todos = list(data.get("todos") or [])
lcode/api.py CHANGED
@@ -15,6 +15,7 @@ A session is one conversation: each `run` continues it. `stream` yields the same
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import contextlib
18
19
  import io
19
20
  import queue
20
21
  import threading
@@ -130,6 +131,24 @@ def switch(wanted: bool | None, configured: str, default_on: str) -> str:
130
131
  return configured if configured != "off" else default_on
131
132
 
132
133
 
134
+ def coding_model(ollama, options: Options, cfg: dict, requested: int | None, hw: Hardware, console: Console):
135
+ """The coding model to use, its catalog entry and the context window; SetupError when there's none."""
136
+ from lcode import cli
137
+
138
+ check_server(ollama, hw)
139
+ try:
140
+ model, spec = cli.resolve_model(ollama, options.model or cfg["model"])
141
+ except cli.NotInstalled as e:
142
+ first_run = not options.model and cfg["model"] == config.DEFAULT_MODEL and not config.CONFIG_PATH.exists()
143
+ if first_run and not backends.is_openai(ollama):
144
+ raise SetupError("lcode isn't set up yet. Run: lcode setup") from e
145
+ raise SetupError(str(e)) from e
146
+ context, note = cli.choose_context(ollama, model, spec, requested, hw)
147
+ if note:
148
+ console.print(f"[yellow]Context {config.format_tokens(context)}: {note}[/]")
149
+ return model, spec, context
150
+
151
+
133
152
  def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | None = None) -> Agent:
134
153
  """An agent set up the way `lcode` sets up a session: model, context, hooks, language servers, MCP."""
135
154
  from lcode import catalog, cli, extensions, hooks
@@ -153,17 +172,14 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
153
172
  except OllamaError as e:
154
173
  raise SetupError(str(e)) from e
155
174
  hw = hw or detect()
156
- check_server(ollama, hw)
175
+ no_coding_model = "" # why, when image or video mode starts without one
157
176
  try:
158
- model, spec = cli.resolve_model(ollama, options.model or cfg["model"])
159
- except cli.NotInstalled as e:
160
- first_run = not options.model and cfg["model"] == config.DEFAULT_MODEL and not config.CONFIG_PATH.exists()
161
- if first_run and not backends.is_openai(ollama):
162
- raise SetupError("lcode isn't set up yet. Run: lcode setup") from e
163
- raise SetupError(str(e)) from e
164
- context, note = cli.choose_context(ollama, model, spec, requested, hw)
165
- if note:
166
- console.print(f"[yellow]Context {config.format_tokens(context)}: {note}[/]")
177
+ model, spec, context = coding_model(ollama, options, cfg, requested, hw, console)
178
+ except SetupError as e:
179
+ if (options.mode or cfg["mode"]) == "code":
180
+ raise
181
+ # Image and video mode don't need the coding model: start without it, and say so on /mode code.
182
+ model, spec, context, no_coding_model = options.model or cfg["model"], None, requested or 32768, str(e)
167
183
  num_batch = cfg["num_batch"]
168
184
  if num_batch is None and spec and spec.num_batch:
169
185
  if model == spec.local_name:
@@ -182,6 +198,9 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
182
198
  permission_mode=options.permission_mode or cfg["permission_mode"],
183
199
  mode=options.mode or cfg["mode"],
184
200
  comfyui_url=cfg["comfyui_url"],
201
+ comfyui_path=cfg["comfyui_path"],
202
+ media_dir=cfg["media_dir"],
203
+ image_model=cfg["image_model"],
185
204
  web=switch(options.web, cfg["web"], "on"),
186
205
  search_backend=cfg["search_backend"],
187
206
  searxng_url=cfg["searxng_url"],
@@ -211,6 +230,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
211
230
  )
212
231
  agent = Agent(ollama, settings, cwd, console=console)
213
232
  agent.hardware = hw
233
+ agent.no_coding_model = no_coding_model
214
234
  agent.autosave = True
215
235
  agent.interactive = options.interactive
216
236
  if options.max_steps:
@@ -250,15 +270,25 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
250
270
 
251
271
 
252
272
  def close_agent(agent: Agent) -> None:
273
+ """Stop what lcode started (background jobs, servers, the sandbox, ComfyUI), then say so: the terminal may be
274
+ gone already (closed), and saying it then fails."""
275
+ from lcode import media
276
+
277
+ said = []
253
278
  stopped = agent.jobs.stop_all()
254
279
  if stopped:
255
- agent.console.print(f"[dim]Stopped {stopped} background job(s).[/]")
280
+ said.append(f"Stopped {stopped} background job(s).")
281
+ if media.stop_engine(agent):
282
+ said.append("Stopped ComfyUI.")
256
283
  if agent.lsp is not None:
257
284
  agent.lsp.close()
258
285
  if agent.mcp:
259
286
  agent.mcp.close()
260
287
  if agent.sandbox:
261
288
  agent.sandbox.stop()
289
+ for line in said:
290
+ with contextlib.suppress(OSError):
291
+ agent.console.print(f"[dim]{line}[/]")
262
292
 
263
293
 
264
294
  # ----------------------------------------------------------------------------- running
lcode/cli.py CHANGED
@@ -3,9 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import contextlib
6
7
  import platform
7
8
  import re
8
9
  import shutil
10
+ import signal
9
11
  import sys
10
12
  import tempfile
11
13
  import time
@@ -466,6 +468,7 @@ def cmd_doctor(args) -> None:
466
468
  except ValueError as e:
467
469
  line("MCP", str(e), False)
468
470
  ok = False
471
+ line("ComfyUI", *comfyui_status(cfg))
469
472
  if cfg["sandbox"] == "off":
470
473
  line("Sandbox", "off (lcode config set sandbox docker)", True)
471
474
  else:
@@ -733,17 +736,10 @@ def cmd_chat(args) -> None:
733
736
  print(json.dumps({"type": "result", "status": "error", "error": str(e)}), flush=True)
734
737
  sys.exit(1)
735
738
  fail(str(e))
739
+ exit_on_hangup()
736
740
  exit_code = 0
737
741
  if args.prompt and agent.settings.mode != "code":
738
- from lcode import modes
739
-
740
- message = modes.COMING[agent.settings.mode]
741
- if quiet:
742
- print(json.dumps({"type": "result", "status": "error", "error": message}), flush=True)
743
- else:
744
- out.print(f"[yellow]{escape(message)}[/]")
745
- api.close_agent(agent)
746
- sys.exit(2)
742
+ media_request(agent, args.prompt, out, quiet) # makes the image or video, then exits
747
743
  if args.escalate and args.prompt:
748
744
  agent.escalate_approved = True # the flag is the user's OK for this run
749
745
  try:
@@ -765,6 +761,83 @@ def cmd_chat(args) -> None:
765
761
  sys.exit(exit_code)
766
762
 
767
763
 
764
+ def exit_on_hangup() -> None:
765
+ """A closed terminal (SIGHUP) or a plain kill (SIGTERM) ends lcode the usual way: it saves the session and stops
766
+ what it started (ComfyUI, MCP servers, background jobs) instead of leaving them running."""
767
+
768
+ def leave(signum, frame):
769
+ raise SystemExit(128 + signum)
770
+
771
+ for name in ("SIGHUP", "SIGTERM"):
772
+ sig = getattr(signal, name, None)
773
+ if sig is not None:
774
+ with contextlib.suppress(ValueError, OSError): # only the main thread can
775
+ signal.signal(sig, leave)
776
+
777
+
778
+ def comfyui_status(cfg: dict) -> tuple[str, bool | None]:
779
+ """lcode doctor's ComfyUI line: running or found, and whether the image model is there."""
780
+ from lcode import comfy
781
+ from lcode import media_catalog as catalog
782
+
783
+ model = catalog.load().find(cfg.get("image_model") or "") or catalog.load().models[catalog.DEFAULT_IMAGE]
784
+ server = comfy.Server(cfg["comfyui_url"])
785
+ stats = server.stats()
786
+ install = None if stats else comfy.find_install(cfg["comfyui_path"])
787
+ if not stats and install is None:
788
+ return "not found: image mode offers to install it (or lcode config set comfyui_path <folder>)", None
789
+ if stats:
790
+ where = f"{(stats.get('system') or {}).get('comfyui_version', '?')} running at {server.url}"
791
+ else:
792
+ where = f"{install.path} (lcode starts it in image mode)"
793
+ _, missing = catalog.chosen_files(model, catalog.lister(server if stats else None, install))
794
+ if missing:
795
+ size = sum(f.gb for f in missing)
796
+ return f"{where} · {model.name} not installed: lcode media install {model.key} ({size:.1f} GB)", None
797
+ return f"{where} · image model: {model.name}", True
798
+
799
+
800
+ def media_request(agent, prompt: str, out: Console, quiet: bool) -> None:
801
+ """`lcode --mode image -p "…"`: make the image, say where it went (or, with --output json, a result object)."""
802
+ import json
803
+
804
+ from lcode import api, media, modes
805
+
806
+ if agent.settings.mode == "video":
807
+ message = modes.COMING["video"]
808
+ if quiet:
809
+ print(json.dumps({"type": "result", "status": "error", "error": message}), flush=True)
810
+ else:
811
+ out.print(f"[yellow]{escape(message)}[/]")
812
+ api.close_agent(agent)
813
+ sys.exit(2)
814
+ try:
815
+ made = media.generate_image(agent, prompt)
816
+ except (media.MediaError, KeyboardInterrupt) as e:
817
+ message = str(e) or "Cancelled."
818
+ if quiet:
819
+ print(json.dumps({"type": "result", "status": "error", "error": message}), flush=True)
820
+ else:
821
+ out.print(f"[red]{escape(message)}[/]")
822
+ api.close_agent(agent)
823
+ sys.exit(1)
824
+ if quiet:
825
+ result = {
826
+ "type": "result",
827
+ "status": "success",
828
+ "mode": "image",
829
+ "files": [str(path) for path in made.files],
830
+ "model": made.model,
831
+ "seed": made.seed,
832
+ "width": made.width,
833
+ "height": made.height,
834
+ "seconds": round(made.seconds, 1),
835
+ }
836
+ print(json.dumps(result, ensure_ascii=False), flush=True)
837
+ api.close_agent(agent)
838
+ sys.exit(0)
839
+
840
+
768
841
  def compact_tokens(value: str) -> int:
769
842
  """`--compact-at`: a token count such as 64k, or 0."""
770
843
  try:
@@ -836,6 +909,13 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
836
909
  subs["setup"] = p
837
910
  subs["models"] = argparse.ArgumentParser(prog="lcode models", description="List models and how they fit here.")
838
911
  subs["doctor"] = argparse.ArgumentParser(prog="lcode doctor", description="Check the installation.")
912
+ p = argparse.ArgumentParser(
913
+ prog="lcode media", description="Image and video models: list them, with how they fit here, or install one."
914
+ )
915
+ p.add_argument("action", nargs="?", choices=["list", "install"], default="list")
916
+ p.add_argument("model", nargs="?", help="with install: a model key from the list, e.g. z-image-turbo")
917
+ p.add_argument("-y", "--yes", action="store_true", help="don't ask before downloading")
918
+ subs["media"] = p
839
919
  p = argparse.ArgumentParser(prog="lcode config", description="Show or change settings.")
840
920
  p.add_argument("action", nargs="?", choices=["show", "set", "unset", "path"], default="show")
841
921
  p.add_argument("key", nargs="?", choices=list(config.SETTINGS))
@@ -992,6 +1072,55 @@ def cmd_index(args) -> None:
992
1072
  )
993
1073
 
994
1074
 
1075
+ def cmd_media(args) -> None:
1076
+ """lcode media [list] | install <model>: the media catalog, what's installed in ComfyUI, downloads."""
1077
+ from rich.prompt import Confirm
1078
+
1079
+ from lcode import comfy, media
1080
+ from lcode import media_catalog as catalog
1081
+
1082
+ try:
1083
+ cfg = config.load()
1084
+ except ConfigError as e:
1085
+ fail(str(e))
1086
+ cat, hw = catalog.load(), detect()
1087
+ server = comfy.Server(cfg["comfyui_url"])
1088
+ install = comfy.find_install(cfg["comfyui_path"])
1089
+ running = server.stats() is not None
1090
+ installed = catalog.lister(server if running else None, install) if running or install else None
1091
+ if args.action == "install":
1092
+ model = cat.find(args.model or "")
1093
+ if model is None:
1094
+ fail(f"no media model {args.model!r}: lcode media lists them")
1095
+ if not running and install is None:
1096
+ fail(f"lcode needs ComfyUI to install models into. {comfy.install_hint()}")
1097
+ ask = None if args.yes else (lambda q: Confirm.ask(q, default=True, console=console))
1098
+ try:
1099
+ saved = catalog.install(model, server if running else None, install, console, ask)
1100
+ except catalog.CatalogError as e:
1101
+ fail(str(e))
1102
+ how = f" ({len(saved)} file(s) downloaded)." if saved else "."
1103
+ console.print(f"[green]{model.name} is installed[/]{how}")
1104
+ return
1105
+ where = (
1106
+ f"ComfyUI at {server.url}" if running else (f"{install.path} (not running)" if install else "no ComfyUI found")
1107
+ )
1108
+ console.print(f"[dim]{hw.describe()} · {where} · image model: {cfg['image_model']}[/]\n")
1109
+ for mode in ("image", "video"):
1110
+ models = cat.for_mode(mode)
1111
+ console.print(f"[bold]{mode.capitalize()} models[/]")
1112
+ if not models:
1113
+ console.print(f" [dim]{media.SOON[mode]}[/]\n")
1114
+ continue
1115
+ for m in models:
1116
+ line = escape(catalog.describe(m, hw, installed))
1117
+ console.print(f" [bold]{m.key:<16}[/] {escape(m.name)} [dim]· {line}[/]")
1118
+ if m.measured:
1119
+ console.print(f" {'':<16} [dim]{escape(m.measured)}[/]")
1120
+ console.print()
1121
+ console.print("[dim]Install one: lcode media install <model> · choose in image mode: /model <model>[/]")
1122
+
1123
+
995
1124
  def cmd_mcp(args) -> None:
996
1125
  from lcode.mcp.commands import run
997
1126
 
@@ -1002,6 +1131,7 @@ COMMANDS = {
1002
1131
  "setup": cmd_setup,
1003
1132
  "models": cmd_models,
1004
1133
  "doctor": cmd_doctor,
1134
+ "media": cmd_media,
1005
1135
  "config": cmd_config,
1006
1136
  "bench": cmd_bench,
1007
1137
  "mcp": cmd_mcp,