lcode-cli 0.40.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.40.0"
6
+ __version__ = "0.41.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/agent.py CHANGED
@@ -308,6 +308,7 @@ class Settings:
308
308
  comfyui_url: str = "http://127.0.0.1:8188"
309
309
  comfyui_path: str | None = None # a ComfyUI install lcode doesn't find by itself (lcode.comfy)
310
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)
311
312
  num_batch: int | None = None
312
313
  keep_alive: str = "30m"
313
314
  think: bool = True
@@ -687,6 +688,7 @@ class Agent:
687
688
  "cwd": str(self.workspace), # where `lcode -c` there finds it, wherever the shell moved
688
689
  "model": self.settings.model,
689
690
  "mode": self.settings.mode,
691
+ "image_model": self.settings.image_model,
690
692
  "name": self.session_name,
691
693
  "title": self.session_title,
692
694
  "messages": self.messages,
@@ -735,6 +737,8 @@ class Agent:
735
737
  self.session_name = data.get("name", "")
736
738
  if data.get("mode") in ("code", "image", "video"):
737
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"]
738
742
  self.session_title = data.get("title") or sessions.title_from(self.messages)
739
743
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
740
744
  self.tools.todos = list(data.get("todos") or [])
lcode/api.py CHANGED
@@ -200,6 +200,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
200
200
  comfyui_url=cfg["comfyui_url"],
201
201
  comfyui_path=cfg["comfyui_path"],
202
202
  media_dir=cfg["media_dir"],
203
+ image_model=cfg["image_model"],
203
204
  web=switch(options.web, cfg["web"], "on"),
204
205
  search_backend=cfg["search_backend"],
205
206
  searxng_url=cfg["searxng_url"],
lcode/cli.py CHANGED
@@ -777,9 +777,10 @@ def exit_on_hangup() -> None:
777
777
 
778
778
  def comfyui_status(cfg: dict) -> tuple[str, bool | None]:
779
779
  """lcode doctor's ComfyUI line: running or found, and whether the image model is there."""
780
- from lcode import comfy, media
780
+ from lcode import comfy
781
+ from lcode import media_catalog as catalog
781
782
 
782
- workflow = comfy.load_workflow(media.IMAGE_WORKFLOW)
783
+ model = catalog.load().find(cfg.get("image_model") or "") or catalog.load().models[catalog.DEFAULT_IMAGE]
783
784
  server = comfy.Server(cfg["comfyui_url"])
784
785
  stats = server.stats()
785
786
  install = None if stats else comfy.find_install(cfg["comfyui_path"])
@@ -787,14 +788,13 @@ def comfyui_status(cfg: dict) -> tuple[str, bool | None]:
787
788
  return "not found: image mode offers to install it (or lcode config set comfyui_path <folder>)", None
788
789
  if stats:
789
790
  where = f"{(stats.get('system') or {}).get('comfyui_version', '?')} running at {server.url}"
790
- installed = {folder: set(server.models(folder)) for folder in workflow.models}
791
791
  else:
792
792
  where = f"{install.path} (lcode starts it in image mode)"
793
- installed = {folder: set(comfy.local_models(install, folder)) for folder in workflow.models}
794
- missing = [f"{f}/{names[0]}" for f, names in workflow.models.items() if not installed[f] & set(names)]
793
+ _, missing = catalog.chosen_files(model, catalog.lister(server if stats else None, install))
795
794
  if missing:
796
- return f"{where} · {workflow.name} not installed (missing {', '.join(missing)})", None
797
- return f"{where} · image model: {workflow.name}", True
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
798
 
799
799
 
800
800
  def media_request(agent, prompt: str, out: Console, quiet: bool) -> None:
@@ -909,6 +909,13 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
909
909
  subs["setup"] = p
910
910
  subs["models"] = argparse.ArgumentParser(prog="lcode models", description="List models and how they fit here.")
911
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
912
919
  p = argparse.ArgumentParser(prog="lcode config", description="Show or change settings.")
913
920
  p.add_argument("action", nargs="?", choices=["show", "set", "unset", "path"], default="show")
914
921
  p.add_argument("key", nargs="?", choices=list(config.SETTINGS))
@@ -1065,6 +1072,55 @@ def cmd_index(args) -> None:
1065
1072
  )
1066
1073
 
1067
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
+
1068
1124
  def cmd_mcp(args) -> None:
1069
1125
  from lcode.mcp.commands import run
1070
1126
 
@@ -1075,6 +1131,7 @@ COMMANDS = {
1075
1131
  "setup": cmd_setup,
1076
1132
  "models": cmd_models,
1077
1133
  "doctor": cmd_doctor,
1134
+ "media": cmd_media,
1078
1135
  "config": cmd_config,
1079
1136
  "bench": cmd_bench,
1080
1137
  "mcp": cmd_mcp,
lcode/comfy.py CHANGED
@@ -254,6 +254,14 @@ class Server:
254
254
  except (requests.RequestException, ValueError):
255
255
  return []
256
256
 
257
+ def folder_paths(self) -> dict[str, list[str]]:
258
+ """Where ComfyUI keeps each kind of model (its own folders and extra_model_paths.yaml's)."""
259
+ try:
260
+ r = requests.get(self.url + "/internal/folder_paths", timeout=10)
261
+ return r.json() if r.status_code == 200 else {}
262
+ except (requests.RequestException, ValueError):
263
+ return {}
264
+
257
265
  def upload(self, path: Path) -> str:
258
266
  """Upload an input image; the name to use in a workflow."""
259
267
  with path.open("rb") as f:
@@ -420,32 +428,18 @@ class Events:
420
428
 
421
429
  @dataclass
422
430
  class Workflow:
431
+ """A graph in ComfyUI's API format with `$name` placeholders: the model's files (by role, from media.toml),
432
+ the prompt, the seed and the settings in `defaults`."""
433
+
423
434
  key: str
424
- name: str
425
- task: str
426
- models: dict[str, list[str]] # ComfyUI folder -> file names it can use, preferred first
427
- defaults: dict
428
435
  graph: dict
436
+ defaults: dict = field(default_factory=dict)
429
437
  labels: dict[str, str] = field(default_factory=dict) # node id -> what it does, for progress
430
438
 
431
439
 
432
440
  def load_workflow(key: str) -> Workflow:
433
441
  data = json.loads(resources.files("lcode.workflows").joinpath(f"{key}.json").read_text())
434
- return Workflow(key, data["name"], data["task"], data["models"], data.get("defaults", {}), data["graph"],
435
- data.get("labels", {})) # fmt: skip
436
-
437
-
438
- def choose_files(server: Server, workflow: Workflow) -> tuple[dict[str, str], list[str]]:
439
- """For each model the workflow needs, the installed file to use; and the folders where none is installed."""
440
- chosen, missing = {}, []
441
- for folder, names in workflow.models.items():
442
- installed = set(server.models(folder))
443
- found = next((n for n in names if n in installed), None)
444
- if found:
445
- chosen[folder] = found
446
- else:
447
- missing.append(f"{folder}/{names[0]}")
448
- return chosen, missing
442
+ return Workflow(key, data["graph"], data.get("defaults", {}), data.get("labels", {}))
449
443
 
450
444
 
451
445
  PLACEHOLDER = re.compile(r"^\$([a-z_]+)$")
lcode/config.py CHANGED
@@ -45,6 +45,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
45
45
  "comfyui_url": ("http://127.0.0.1:8188", str, "ComfyUI, for image and video mode"),
46
46
  "comfyui_path": (None, str, "your ComfyUI folder, if lcode doesn't find it (lcode starts it when needed)"),
47
47
  "media_dir": (".lcode/media", str, "where images and videos go: a folder in the project, or an absolute path"),
48
+ "image_model": ("flux2-klein-4b", str, "the model image mode uses: a key from `lcode media`"),
48
49
  "think": (True, bool, "let the model reason before answering (slower, better)"),
49
50
  "web": ("on", str, "web search and page fetching for the model: on | ask | off"),
50
51
  "search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
@@ -210,6 +211,12 @@ def coerce(key: str, value: object) -> object:
210
211
  raise ConfigError(f"permission_mode must be one of {', '.join(PERMISSION_MODES)}")
211
212
  if key == "mode" and value not in WORK_MODES:
212
213
  raise ConfigError(f"mode must be one of {', '.join(WORK_MODES)}")
214
+ if key == "image_model":
215
+ from lcode import media_catalog
216
+
217
+ keys = [m.key for m in media_catalog.load().for_mode("image")]
218
+ if value not in keys:
219
+ raise ConfigError(f"image_model must be one of {', '.join(keys)} (see lcode media)")
213
220
  if typ is bool and isinstance(value, str):
214
221
  if value.lower() not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
215
222
  raise ConfigError(f"{key} must be true or false")
lcode/media.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Image and video mode: a request becomes media, made by ComfyUI (lcode.comfy) with no language model in between.
2
2
 
3
- For now image mode makes one image per request with FLUX.2 klein 4B, the engine's first workflow; models, sizes,
4
- variations and a gallery come with #150 and #151, video with #153.
3
+ Image mode makes one image per request with the image model chosen from the media catalog (lcode.media_catalog);
4
+ sizes, variations and a gallery come with #151, video with #153.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -16,11 +16,12 @@ from typing import TYPE_CHECKING
16
16
  from rich.text import Text
17
17
 
18
18
  from lcode import comfy
19
+ from lcode import media_catalog as catalog
19
20
 
20
21
  if TYPE_CHECKING:
21
22
  from lcode.agent import Agent
22
23
 
23
- IMAGE_WORKFLOW = "flux2-klein-4b-t2i"
24
+ SOON = {"video": "Video models come with video generation (#153)."}
24
25
  IGNORED = "# lcode's images and videos: not part of the project unless you add them\n*\n"
25
26
 
26
27
 
@@ -28,8 +29,19 @@ class MediaError(Exception):
28
29
  pass
29
30
 
30
31
 
31
- def image_model() -> str:
32
- return comfy.load_workflow(IMAGE_WORKFLOW).name
32
+ def installed_lister(agent: Agent) -> catalog.Installed | None:
33
+ """What ComfyUI has installed: asked from it when it's running, else read from its folders; None: unknown."""
34
+ server = comfy.Server(agent.settings.comfyui_url)
35
+ if server.stats():
36
+ return catalog.lister(server)
37
+ install = comfy.find_install(agent.settings.comfyui_path)
38
+ return catalog.lister(install=install) if install else None
39
+
40
+
41
+ def current(agent: Agent) -> catalog.MediaModel:
42
+ """The image model the agent uses (the image_model setting, or /model in image mode)."""
43
+ cat = catalog.load()
44
+ return cat.find(agent.settings.image_model) or cat.models[catalog.DEFAULT_IMAGE]
33
45
 
34
46
 
35
47
  @dataclass
@@ -183,6 +195,28 @@ class Line:
183
195
  return text
184
196
 
185
197
 
198
+ def ensure_installed(
199
+ agent: Agent, model: catalog.MediaModel, server: comfy.Server, ask: Callable[[str], bool] | None
200
+ ) -> dict[str, str]:
201
+ """The model's files (role -> installed name), downloading the missing ones first when the user agrees."""
202
+ files, missing = catalog.chosen_files(model, catalog.lister(server))
203
+ if not missing:
204
+ return files
205
+ size = sum(f.gb for f in missing)
206
+ if ask is None:
207
+ raise MediaError(f"{model.name} isn't installed ({size:.1f} GB to download): lcode media install {model.key}")
208
+ install = comfy.find_install(agent.settings.comfyui_path)
209
+ try:
210
+ catalog.install(model, server=server, install=install, console=agent.console, ask=ask)
211
+ except catalog.CatalogError as e:
212
+ raise MediaError(str(e)) from e
213
+ files, missing = catalog.chosen_files(model, catalog.lister(server))
214
+ if missing:
215
+ raise MediaError(f"ComfyUI doesn't see {', '.join(f.name for f in missing)} yet: restart it and try again")
216
+ agent.say(f"Installed {model.name}.")
217
+ return files
218
+
219
+
186
220
  def generate_image(
187
221
  agent: Agent, prompt: str, cancelled: Callable[[], bool] = lambda: False, ask: Callable[[str], bool] | None = None
188
222
  ) -> Made:
@@ -193,20 +227,17 @@ def generate_image(
193
227
  server = engine(agent, ask, cancelled)
194
228
  for path in comfy.collect(server):
195
229
  agent.say(f"Saved {shown(agent, path)}: it finished while lcode wasn't watching.")
196
- workflow = comfy.load_workflow(IMAGE_WORKFLOW)
197
- files, missing = comfy.choose_files(server, workflow)
198
- if missing:
199
- raise MediaError(
200
- f"{workflow.name} isn't installed in ComfyUI (missing {', '.join(missing)}). lcode installs image "
201
- "models from #150 on; until then put the files in ComfyUI's models folder."
202
- )
230
+ model = current(agent)
231
+ files = ensure_installed(agent, model, server, ask)
232
+ workflow = comfy.load_workflow(model.workflow)
203
233
  free_gpu(agent)
204
234
  seed = random.randrange(2**32)
205
235
  values = {**workflow.defaults, **files, "prompt": prompt, "seed": seed}
206
236
  graph = comfy.fill(workflow.graph, values)
207
237
  meta = {
208
238
  "prompt": prompt,
209
- "model": workflow.name,
239
+ "model": model.name,
240
+ "model_key": model.key,
210
241
  "workflow": workflow.key,
211
242
  "seed": seed,
212
243
  "width": values["width"],
@@ -217,7 +248,7 @@ def generate_image(
217
248
  }
218
249
  out = folder(agent)
219
250
  stem = f"{time.strftime('%Y%m%d-%H%M%S')}-{comfy.slug(prompt)}"
220
- line = Line(workflow.name, "Esc or Ctrl+C cancels" if agent.interactive else "")
251
+ line = Line(model.name, "Esc or Ctrl+C cancels" if agent.interactive else "")
221
252
  job = {"id": ""}
222
253
 
223
254
  def on_event(event: comfy.Event) -> None:
@@ -252,7 +283,7 @@ def generate_image(
252
283
  comfy.forget(prompt_id)
253
284
  if not saved:
254
285
  raise MediaError("ComfyUI finished but returned no image.")
255
- made = Made(saved, workflow.name, seed, values["width"], values["height"], time.monotonic() - line.started)
286
+ made = Made(saved, model.name, seed, values["width"], values["height"], time.monotonic() - line.started)
256
287
  for path in saved:
257
288
  agent.console.print(
258
289
  Text("◆ ", style="green")
lcode/media.toml ADDED
@@ -0,0 +1,74 @@
1
+ # Image and video models lcode can run through ComfyUI (lcode.media_catalog).
2
+ #
3
+ # [files.<key>] one file in ComfyUI's model folders, listed once: models share text encoders and VAEs.
4
+ # folder ComfyUI's model folder (diffusion_models, text_encoders, vae, loras, …)
5
+ # name the file name there
6
+ # url where lcode downloads it from (Hugging Face; HF_TOKEN is used for gated files)
7
+ # gb its size in GB (10^9 bytes)
8
+ # alternatives other file names that do the same job: an installed one is used instead of downloading
9
+ #
10
+ # [models.<key>] one model.
11
+ # mode image or video
12
+ # tasks text-to-image, edit, inpaint, text-to-video, image-to-video, first-last-frame, reference-to-video, audio
13
+ # workflow lcode's workflow for it (lcode/workflows/<workflow>.json)
14
+ # files role -> file key; the workflow's $role placeholders get the file names
15
+ # gpu_gb GPU memory it needs, when known better than lcode's estimate (the largest file + 1.5 GB); ComfyUI
16
+ # fills whatever is free with cached models, so nvidia-smi's peak doesn't tell
17
+ # measured what it did on real hardware; models without it haven't been run yet
18
+
19
+ [files.flux-2-klein-4b-fp8]
20
+ folder = "diffusion_models"
21
+ name = "flux-2-klein-4b-fp8.safetensors"
22
+ url = "https://huggingface.co/black-forest-labs/FLUX.2-klein-4b-fp8/resolve/main/flux-2-klein-4b-fp8.safetensors"
23
+ gb = 4.07
24
+ alternatives = ["flux-2-klein-4b.safetensors"]
25
+
26
+ [files.qwen-3-4b]
27
+ folder = "text_encoders"
28
+ name = "qwen_3_4b.safetensors"
29
+ url = "https://huggingface.co/Comfy-Org/flux2-klein/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors"
30
+ gb = 8.04
31
+ alternatives = ["qwen_3_4b_fp8_mixed.safetensors"]
32
+
33
+ [files.flux2-vae]
34
+ folder = "vae"
35
+ name = "flux2-vae.safetensors"
36
+ url = "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/vae/flux2-vae.safetensors"
37
+ gb = 0.34
38
+
39
+ [files.z-image-turbo-int8]
40
+ folder = "diffusion_models"
41
+ name = "z_image_turbo_int8_convrot.safetensors"
42
+ url = "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_int8_convrot.safetensors"
43
+ gb = 6.20
44
+ alternatives = ["z_image_turbo_bf16.safetensors"]
45
+
46
+ [files.flux-ae]
47
+ folder = "vae"
48
+ name = "ae.safetensors"
49
+ url = "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors"
50
+ gb = 0.34
51
+
52
+ [models.flux2-klein-4b]
53
+ name = "FLUX.2 klein 4B"
54
+ mode = "image"
55
+ publisher = "Black Forest Labs"
56
+ license = "Apache 2.0"
57
+ tasks = ["text-to-image"]
58
+ workflow = "flux2-klein-4b-t2i"
59
+ files = { model = "flux-2-klein-4b-fp8", text_encoder = "qwen-3-4b", vae = "flux2-vae" }
60
+ gpu_gb = 0.0
61
+ measured = "1024×1024: 3.5 s per image, 14 s for the first with ComfyUI starting (RTX 4080 Laptop, 12 GB)"
62
+ notes = "fast and small: 4 steps"
63
+
64
+ [models.z-image-turbo]
65
+ name = "Z-Image Turbo"
66
+ mode = "image"
67
+ publisher = "Tongyi-MAI (Alibaba)"
68
+ license = "Apache 2.0"
69
+ tasks = ["text-to-image"]
70
+ workflow = "z-image-turbo-t2i"
71
+ files = { model = "z-image-turbo-int8", text_encoder = "qwen-3-4b", vae = "flux-ae" }
72
+ gpu_gb = 0.0
73
+ measured = "1024×1024: 17 s per image (1.9 s per step), 23 s for the first (RTX 4080 Laptop, 12 GB)"
74
+ notes = "photorealistic, writes text well: 8 steps"
lcode/media_catalog.py ADDED
@@ -0,0 +1,331 @@
1
+ """The image and video models lcode can run (media.toml): what they need, whether they fit, installing them.
2
+
3
+ A model is a workflow plus the files it uses in ComfyUI's model folders. Files are listed once, because models share
4
+ text encoders and VAEs, and a file counts as installed under its own name or one of its alternatives (an fp8 or a
5
+ full-precision build of the same weights). Installing downloads the missing files into ComfyUI's model folders:
6
+ resumably (a `.part` file grows until it's complete), checked against Hugging Face's SHA-256, after a disk check.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import os
13
+ import shutil
14
+ import sys
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass, field
17
+ from functools import cache
18
+ from importlib import resources
19
+ from pathlib import Path
20
+ from urllib.parse import urlsplit
21
+
22
+ import requests
23
+
24
+ from lcode.hardware import GIB, Hardware
25
+
26
+ if sys.version_info >= (3, 11):
27
+ import tomllib
28
+ else: # pragma: no cover
29
+ import tomli as tomllib
30
+
31
+ DEFAULT_IMAGE = "flux2-klein-4b"
32
+ DISK_MARGIN_GB = 2.0 # left free after a download
33
+ GPU_MARGIN_GIB = 0.5
34
+ WORKING_GB = 1.5 # activations and buffers on top of the largest file, for models not measured yet
35
+ CHUNK = 1 << 20
36
+ http_get = requests.get # what downloads use (tests replace it)
37
+
38
+
39
+ class CatalogError(Exception):
40
+ pass
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class File:
45
+ key: str
46
+ folder: str # ComfyUI's model folder
47
+ name: str
48
+ url: str
49
+ gb: float
50
+ alternatives: tuple[str, ...] = ()
51
+
52
+ @property
53
+ def names(self) -> tuple[str, ...]:
54
+ return (self.name, *self.alternatives)
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class MediaModel:
59
+ key: str
60
+ name: str
61
+ mode: str # image or video
62
+ publisher: str
63
+ license: str
64
+ tasks: tuple[str, ...]
65
+ workflow: str
66
+ files: dict[str, File] = field(hash=False) # role -> file
67
+ gpu_gb: float = 0.0 # measured peak GPU memory; 0 when not measured yet
68
+ measured: str = ""
69
+ notes: str = ""
70
+
71
+ @property
72
+ def size_gb(self) -> float:
73
+ return sum(f.gb for f in {f.key: f for f in self.files.values()}.values())
74
+
75
+ @property
76
+ def need_gb(self) -> float:
77
+ """GPU memory at the peak: measured, or estimated. ComfyUI loads the text encoder, the model and the VAE
78
+ one after another and moves what it doesn't need off the GPU, so the largest file sets the peak."""
79
+ return self.gpu_gb or max(f.gb for f in self.files.values()) + WORKING_GB
80
+
81
+ def fit(self, hw: Hardware) -> tuple[str, str]:
82
+ """(fits | slower | too big | cpu, a short note) on this machine."""
83
+ weights_gib = self.size_gb * 1e9 / GIB
84
+ need_gib = self.need_gb * 1e9 / GIB
85
+ if hw.unified:
86
+ if need_gib <= hw.budget_gib - GPU_MARGIN_GIB:
87
+ return "fits", "fits in unified memory"
88
+ return "too big", f"needs ~{need_gib:.0f} GB, ~{hw.budget_gib:.0f} GB available"
89
+ if not hw.gpu or not hw.vram_gib:
90
+ return "cpu", "no GPU lcode can use: very slow on the CPU"
91
+ if need_gib <= hw.vram_gib - GPU_MARGIN_GIB:
92
+ return "fits", f"~{need_gib:.0f} GB of {hw.vram_gib:.0f} GB GPU memory"
93
+ if weights_gib <= hw.vram_gib + 0.6 * hw.ram_gib: # ComfyUI keeps the rest in RAM and swaps it in
94
+ return "slower", f"part runs from RAM ({need_gib:.0f} GB needed, {hw.vram_gib:.0f} GB GPU)"
95
+ return "too big", f"needs ~{weights_gib:.0f} GB, more than GPU and RAM together"
96
+
97
+
98
+ @dataclass
99
+ class Catalog:
100
+ files: dict[str, File]
101
+ models: dict[str, MediaModel]
102
+
103
+ def for_mode(self, mode: str) -> list[MediaModel]:
104
+ return [m for m in self.models.values() if m.mode == mode]
105
+
106
+ def find(self, wanted: str, mode: str | None = None) -> MediaModel | None:
107
+ """A model by key or name (any case), or by its number in the mode's list."""
108
+ candidates = self.for_mode(mode) if mode else list(self.models.values())
109
+ if wanted.isdigit() and 1 <= int(wanted) <= len(candidates):
110
+ return candidates[int(wanted) - 1]
111
+ low = wanted.strip().lower()
112
+ return next((m for m in candidates if low in (m.key, m.name.lower())), None)
113
+
114
+
115
+ @cache
116
+ def load() -> Catalog:
117
+ data = tomllib.loads(resources.files("lcode").joinpath("media.toml").read_text())
118
+ files = {
119
+ key: File(key, f["folder"], f["name"], f["url"], float(f["gb"]), tuple(f.get("alternatives", ())))
120
+ for key, f in data.get("files", {}).items()
121
+ }
122
+ models = {}
123
+ for key, m in data.get("models", {}).items():
124
+ try:
125
+ roles = {role: files[ref] for role, ref in m["files"].items()}
126
+ except KeyError as e:
127
+ raise CatalogError(f"media.toml: {key} uses the file {e}, which isn't listed") from e
128
+ models[key] = MediaModel(
129
+ key,
130
+ m["name"],
131
+ m["mode"],
132
+ m.get("publisher", ""),
133
+ m.get("license", ""),
134
+ tuple(m.get("tasks", ())),
135
+ m["workflow"],
136
+ roles,
137
+ float(m.get("gpu_gb", 0.0)),
138
+ m.get("measured", ""),
139
+ m.get("notes", ""),
140
+ )
141
+ return Catalog(files, models)
142
+
143
+
144
+ # ----------------------------------------------------------------------------- what's installed
145
+
146
+
147
+ Installed = Callable[[str], set[str]] # ComfyUI folder -> the file names in it
148
+
149
+
150
+ def chosen_files(model: MediaModel, installed: Installed) -> tuple[dict[str, str], list[File]]:
151
+ """For each role, the installed file name to use; and the files that aren't installed."""
152
+ chosen, missing = {}, []
153
+ for role, f in model.files.items():
154
+ found = next((n for n in f.names if n in installed(f.folder)), None)
155
+ if found:
156
+ chosen[role] = found
157
+ elif f not in missing:
158
+ missing.append(f)
159
+ return chosen, missing
160
+
161
+
162
+ def lister(server=None, install=None) -> Installed:
163
+ """What's in each model folder: asked from a running ComfyUI, or read from the install's folders."""
164
+ from lcode import comfy
165
+
166
+ seen: dict[str, set[str]] = {}
167
+
168
+ def installed(folder: str) -> set[str]:
169
+ if folder not in seen:
170
+ if server is not None:
171
+ seen[folder] = set(server.models(folder))
172
+ elif install is not None:
173
+ seen[folder] = set(comfy.local_models(install, folder))
174
+ else:
175
+ seen[folder] = set()
176
+ return seen[folder]
177
+
178
+ return installed
179
+
180
+
181
+ def model_folder(folder: str, server=None, install=None) -> Path | None:
182
+ """Where ComfyUI keeps one kind of model: from the running server, or the install's models/ folder."""
183
+ if server is not None:
184
+ paths = server.folder_paths().get(folder)
185
+ if paths:
186
+ return Path(paths[0])
187
+ if install is not None:
188
+ return install.path / "models" / folder
189
+ return None
190
+
191
+
192
+ # ----------------------------------------------------------------------------- downloading
193
+
194
+
195
+ def disk_problem(folder: Path, need_gb: float) -> str:
196
+ """Why `need_gb` won't fit on the disk of `folder` (with a margin), or empty."""
197
+ probe = folder
198
+ while not probe.exists() and probe != probe.parent:
199
+ probe = probe.parent
200
+ free_gb = shutil.disk_usage(probe).free / 1e9
201
+ if need_gb + DISK_MARGIN_GB > free_gb:
202
+ return f"needs {need_gb:.1f} GB, and {probe} has {free_gb:.1f} GB free (lcode keeps {DISK_MARGIN_GB:.0f} GB)"
203
+ return ""
204
+
205
+
206
+ def remaining_gb(f: File, folder: Path) -> float:
207
+ part = folder / (f.name + ".part")
208
+ have = part.stat().st_size / 1e9 if part.exists() else 0.0
209
+ return max(f.gb - have, 0.0)
210
+
211
+
212
+ def auth_headers(url: str) -> dict[str, str]:
213
+ token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
214
+ if token and urlsplit(url).hostname == "huggingface.co":
215
+ return {"Authorization": f"Bearer {token}"} # requests drops it when redirected to another host
216
+ return {}
217
+
218
+
219
+ def download(
220
+ url: str,
221
+ dest: Path,
222
+ on_progress: Callable[[int, int], None] = lambda done, total: None,
223
+ get=None,
224
+ ) -> None:
225
+ """Download `url` to `dest`, resuming a `.part` file left by an earlier try, and check its SHA-256."""
226
+ dest.parent.mkdir(parents=True, exist_ok=True)
227
+ part = dest.with_name(dest.name + ".part")
228
+ have = part.stat().st_size if part.exists() else 0
229
+ headers = {**auth_headers(url), **({"Range": f"bytes={have}-"} if have else {})}
230
+ try:
231
+ r = (get or http_get)(url, headers=headers, stream=True, timeout=(15, 120), allow_redirects=True)
232
+ except requests.RequestException as e:
233
+ raise CatalogError(f"can't download {dest.name}: {e}") from e
234
+ with r:
235
+ if r.status_code == 416 and have: # nothing left to fetch: the .part is complete
236
+ pass
237
+ elif r.status_code in (401, 403):
238
+ raise CatalogError(
239
+ f"{dest.name} needs a Hugging Face login: accept its license on huggingface.co, then set HF_TOKEN"
240
+ )
241
+ elif r.status_code not in (200, 206):
242
+ raise CatalogError(f"can't download {dest.name}: HTTP {r.status_code}")
243
+ else:
244
+ if r.status_code == 200 and have: # the server ignored the range: start over
245
+ have = 0
246
+ total = have + int(r.headers.get("Content-Length") or 0)
247
+ with part.open("ab" if have else "wb") as out:
248
+ done = have
249
+ for chunk in r.iter_content(CHUNK):
250
+ out.write(chunk)
251
+ done += len(chunk)
252
+ on_progress(done, total)
253
+ expected = sha256_of(r)
254
+ if expected:
255
+ actual = file_sha256(part)
256
+ if actual != expected:
257
+ part.unlink()
258
+ raise CatalogError(f"{dest.name} arrived damaged (its SHA-256 doesn't match): it will download again")
259
+ part.replace(dest)
260
+
261
+
262
+ def sha256_of(response) -> str:
263
+ """The SHA-256 Hugging Face gives for a large file (its X-Linked-Etag, on the redirect), or empty."""
264
+ for r in [*getattr(response, "history", []), response]:
265
+ tag = (r.headers.get("X-Linked-Etag") or "").strip('"')
266
+ if len(tag) == 64 and all(c in "0123456789abcdef" for c in tag):
267
+ return tag
268
+ return ""
269
+
270
+
271
+ def file_sha256(path: Path) -> str:
272
+ digest = hashlib.sha256()
273
+ with path.open("rb") as f:
274
+ for chunk in iter(lambda: f.read(CHUNK * 8), b""):
275
+ digest.update(chunk)
276
+ return digest.hexdigest()
277
+
278
+
279
+ def install(model: MediaModel, server=None, install=None, console=None, ask=None, get=None) -> list[Path]:
280
+ """Download the model's missing files into ComfyUI's model folders. Raises CatalogError with what to do.
281
+
282
+ `ask(question) -> bool` confirms the download (None: go ahead, for `--yes`).
283
+ """
284
+ from rich.console import Console
285
+ from rich.progress import BarColumn, DownloadColumn, Progress, TextColumn, TimeRemainingColumn, TransferSpeedColumn
286
+
287
+ console = console or Console()
288
+ _, missing = chosen_files(model, lister(server, install))
289
+ if not missing:
290
+ return []
291
+ folders = {}
292
+ for f in missing:
293
+ folder = model_folder(f.folder, server, install)
294
+ if folder is None:
295
+ raise CatalogError("lcode doesn't know where ComfyUI keeps its models: set comfyui_path")
296
+ folders[f.key] = folder
297
+ need = sum(remaining_gb(f, folders[f.key]) for f in missing)
298
+ problem = disk_problem(next(iter(folders.values())), need)
299
+ if problem:
300
+ raise CatalogError(f"not enough disk space for {model.name}: {problem}")
301
+ listing = ", ".join(f"{f.name} ({f.gb:.1f} GB)" for f in missing)
302
+ if ask is not None and not ask(
303
+ f"Download {model.name}: {listing}, {need:.1f} GB into {folders[missing[0].key].parent}?"
304
+ ):
305
+ raise CatalogError(f"{model.name} isn't installed (download declined)")
306
+ saved = []
307
+ columns = (TextColumn("{task.description}"), BarColumn(), DownloadColumn(), TransferSpeedColumn())
308
+ with Progress(*columns, TimeRemainingColumn(), console=console, transient=False) as progress:
309
+ for f in missing:
310
+ task = progress.add_task(f.name, total=int(f.gb * 1e9))
311
+ dest = folders[f.key] / f.name
312
+ try:
313
+ download(f.url, dest, lambda done, total, t=task: progress.update(t, completed=done, total=total), get)
314
+ except KeyboardInterrupt:
315
+ raise CatalogError(f"Paused {f.name}: install {model.key} again to resume where it stopped.") from None
316
+ saved.append(dest)
317
+ return saved
318
+
319
+
320
+ def describe(model: MediaModel, hw: Hardware, installed: Installed | None) -> str:
321
+ """One line for a list: tasks, size, fit, installed."""
322
+ status, note = model.fit(hw)
323
+ parts = [", ".join(model.tasks), f"{model.size_gb:.1f} GB"]
324
+ if installed is not None:
325
+ _, missing = chosen_files(model, installed)
326
+ if missing:
327
+ parts.append(f"{sum(f.gb for f in missing):.1f} GB to download")
328
+ else:
329
+ parts.append("installed")
330
+ parts.append({"fits": "fits", "slower": "slower", "too big": "too big", "cpu": "CPU only"}[status] + f" ({note})")
331
+ return " · ".join(parts)
lcode/modes.py CHANGED
@@ -41,7 +41,7 @@ def ready(agent: Agent) -> str:
41
41
  from lcode import media
42
42
 
43
43
  return (
44
- f"Describe an image and press Enter: {media.image_model()} makes it on your GPU, into "
44
+ f"Describe an image and press Enter: {media.current(agent).name} makes it on your GPU, into "
45
45
  f"{agent.settings.media_dir}. /mode code switches back."
46
46
  )
47
47
  return COMING.get(agent.settings.mode, "")
lcode/repl.py CHANGED
@@ -218,9 +218,9 @@ def model_lines(agent: Agent) -> str:
218
218
  """The banner's model lines: the coding model and its context, or the mode's own model."""
219
219
  s = agent.settings
220
220
  if s.mode == "image":
221
- return f"[dim]model[/] {escape(media.image_model())} [dim]· ComfyUI · {escape(s.media_dir)}[/]\n"
221
+ return f"[dim]model[/] {escape(media.current(agent).name)} [dim]· ComfyUI · {escape(s.media_dir)}[/]\n"
222
222
  if s.mode == "video":
223
- return "[dim]model[/] [dim]video models come with #150 and #153[/]\n"
223
+ return "[dim]model[/] [dim]video models come with video generation (#153)[/]\n"
224
224
  server = f" [dim]· {escape(agent.ollama.describe())}[/]" if backends.is_openai(agent.ollama) else ""
225
225
  return f"[dim]model[/] {escape(s.model)}{server}\n[dim]context[/] {format_tokens(s.context)} tokens\n"
226
226
 
@@ -891,6 +891,45 @@ def model_command(agent: Agent, arg: str, hardware: Hardware) -> None:
891
891
  switch_model(agent, arg, hardware)
892
892
 
893
893
 
894
+ def media_model_command(agent: Agent, arg: str, hardware: Hardware) -> None:
895
+ """/models and /model in image or video mode: the catalog's models for the mode, numbered; pick one."""
896
+ from lcode import media_catalog as catalog
897
+
898
+ c, s = agent.console, agent.settings
899
+ choices = catalog.load().for_mode(s.mode)
900
+ if not choices:
901
+ c.print(f"[yellow]{escape(media.SOON.get(s.mode, modes.ready(agent)))}[/]")
902
+ return
903
+ current = media.current(agent)
904
+ installed = media.installed_lister(agent)
905
+ if not arg:
906
+ for i, m in enumerate(choices, 1):
907
+ mark = "[green]●[/]" if m.key == current.key else " "
908
+ c.print(
909
+ f" {i}. {mark} [bold]{escape(m.name)}[/] [dim]({m.key})[/] "
910
+ f"{escape(catalog.describe(m, hardware, installed))}"
911
+ )
912
+ if m.measured:
913
+ c.print(f" [dim]{escape(m.measured)}[/]")
914
+ try:
915
+ arg = input(f" Switch to which model? (number or name, Enter to keep {current.name}): ").strip()
916
+ except EOFError:
917
+ return
918
+ if not arg:
919
+ return
920
+ model = catalog.load().find(arg, s.mode)
921
+ if model is None:
922
+ c.print(f"[red]There's no {s.mode} model {escape(arg)!r}: choose 1 to {len(choices)}, or a name.[/]")
923
+ return
924
+ s.image_model = model.key
925
+ c.print(f"[green]Image model: {escape(model.name)}.[/]")
926
+ if installed is not None:
927
+ _, missing = catalog.chosen_files(model, installed)
928
+ if missing:
929
+ size = sum(f.gb for f in missing)
930
+ c.print(f"[dim]Not installed yet: {size:.1f} GB download with your next image (you're asked first).[/]")
931
+
932
+
894
933
  def switch_model(agent: Agent, wanted: str, hardware: Hardware) -> None:
895
934
  """Switch to a catalog key or an installed model, and size the context for it."""
896
935
  from lcode.cli import NotInstalled, resolve_model
@@ -1116,13 +1155,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
1116
1155
  elif cmd in ("/context", "/ctx"):
1117
1156
  context_command(agent, arg, hardware)
1118
1157
  elif cmd in ("/models", "/model"):
1119
- if s.mode == "image":
1120
- c.print(
1121
- f"Image model: [bold]{media.image_model()}[/], through ComfyUI. "
1122
- "[dim]More image models, and installing them from lcode, come with #150.[/]"
1123
- )
1124
- elif s.mode != "code":
1125
- c.print(f"[yellow]{escape(modes.ready(agent))}[/]")
1158
+ if s.mode != "code":
1159
+ media_model_command(agent, arg, hardware)
1126
1160
  else:
1127
1161
  model_command(agent, arg, hardware)
1128
1162
  elif cmd == "/think":
@@ -1,12 +1,5 @@
1
1
  {
2
- "name": "FLUX.2 klein 4B",
3
- "task": "text-to-image",
4
2
  "source": "ComfyUI's template image_flux2_klein_text_to_image (the distilled 4B subgraph), in API format",
5
- "models": {
6
- "diffusion_models": ["flux-2-klein-4b-fp8.safetensors", "flux-2-klein-4b.safetensors"],
7
- "text_encoders": ["qwen_3_4b.safetensors"],
8
- "vae": ["flux2-vae.safetensors"]
9
- },
10
3
  "defaults": {"width": 1024, "height": 1024, "steps": 4, "batch": 1},
11
4
  "labels": {
12
5
  "1": "loading the model",
@@ -18,8 +11,8 @@
18
11
  "13": "saving"
19
12
  },
20
13
  "graph": {
21
- "1": {"class_type": "UNETLoader", "inputs": {"unet_name": "$diffusion_models", "weight_dtype": "default"}},
22
- "2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "$text_encoders", "type": "flux2", "device": "default"}},
14
+ "1": {"class_type": "UNETLoader", "inputs": {"unet_name": "$model", "weight_dtype": "default"}},
15
+ "2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "$text_encoder", "type": "flux2", "device": "default"}},
23
16
  "3": {"class_type": "VAELoader", "inputs": {"vae_name": "$vae"}},
24
17
  "4": {"class_type": "CLIPTextEncode", "inputs": {"text": "$prompt", "clip": ["2", 0]}},
25
18
  "5": {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}},
@@ -0,0 +1,31 @@
1
+ {
2
+ "source": "ComfyUI's template image_z_image_turbo_int8 (its Text to Image subgraph), in API format",
3
+ "defaults": {"width": 1024, "height": 1024, "steps": 8, "batch": 1},
4
+ "labels": {
5
+ "1": "loading the model",
6
+ "2": "loading the text encoder",
7
+ "3": "loading the VAE",
8
+ "4": "reading the prompt",
9
+ "8": "drawing",
10
+ "9": "decoding the image",
11
+ "10": "saving"
12
+ },
13
+ "graph": {
14
+ "1": {"class_type": "UNETLoader", "inputs": {"unet_name": "$model", "weight_dtype": "default"}},
15
+ "2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "$text_encoder", "type": "lumina2", "device": "default"}},
16
+ "3": {"class_type": "VAELoader", "inputs": {"vae_name": "$vae"}},
17
+ "4": {"class_type": "CLIPTextEncode", "inputs": {"text": "$prompt", "clip": ["2", 0]}},
18
+ "5": {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}},
19
+ "6": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["1", 0], "shift": 3}},
20
+ "7": {"class_type": "EmptySD3LatentImage", "inputs": {"width": "$width", "height": "$height", "batch_size": "$batch"}},
21
+ "8": {
22
+ "class_type": "KSampler",
23
+ "inputs": {
24
+ "model": ["6", 0], "seed": "$seed", "steps": "$steps", "cfg": 1, "sampler_name": "res_multistep",
25
+ "scheduler": "simple", "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["7", 0], "denoise": 1
26
+ }
27
+ },
28
+ "9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["3", 0]}},
29
+ "10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": "lcode/image"}}
30
+ }
31
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.40.0
3
+ Version: 0.41.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,9 +1,9 @@
1
- lcode/__init__.py,sha256=5OHrzT0Z3PkCU2LFU_hQ-JKJZ--Z4VVNqZ-UA5bp6xc,473
1
+ lcode/__init__.py,sha256=cP-2ftJ2hz9SKhg5hKS4FosPfEa3qxeHu6hAb4URJQ8,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=LUccXchM3_p9OBnkaz8LmZ8fnzs7nduuEHusYFF8KjE,87254
6
- lcode/api.py,sha256=EOxX1K0zuCcFVbS0i4o_3s-8zhSddZueABWKJZJw54g,17592
5
+ lcode/agent.py,sha256=-RZXqheMdbwjRkwMPGBnj_kkRfrAUYkqOLMc888gXgo,87528
6
+ lcode/api.py,sha256=jQmx-ddJC6MoNcRcaWoERs2Ci6KYzBto9GxdVBE7JkU,17632
7
7
  lcode/backends.py,sha256=343alwyDZin-bEhb5-Es4f8ek1oBQUzEvV6RFWR1VZo,21160
8
8
  lcode/bench.py,sha256=HxeUfy9Kv_0Rug1HAlDLyHYfYd9dw2bMgbXsvUy9nd4,43096
9
9
  lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
@@ -11,12 +11,12 @@ 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=haYYIPhadKavGYEhh8YWAaWrPMcfzrpT2i6638f4xjQ,12933
14
- lcode/cli.py,sha256=svC4ipRs3n27brmBKc1vTWMM1JItP5KtV_BwTXEbfaU,48619
14
+ lcode/cli.py,sha256=WuQW4Ao_TbJ0HKFOaFtJnyjvv-lwGYRg_tb0vI-zIg0,51296
15
15
  lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
16
16
  lcode/cloud.py,sha256=q5Dl5GuqWvKzBCIso-eh6yomvEOsnQ0LW8ZgaAGwBxc,2131
17
17
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
18
- lcode/comfy.py,sha256=N2yh3bem1cJ0mEwlRFkViZY3tR7l5_IBrV-OEXYhkoo,26510
19
- lcode/config.py,sha256=jJ0-nVAiwGR8tZPbCRbFVGH-FhsOUTHXBaclUS58Ipk,13202
18
+ lcode/comfy.py,sha256=tCXSWH9Lk0MsU6031-a6LSOTNSZ1h9G3ZvFwh3d_LPo,26357
19
+ lcode/config.py,sha256=EwMp3h0kHoLqHJc1sGCsXYjzj79gpg2HgMhGz1Ol1q0,13569
20
20
  lcode/context.py,sha256=wgK16ZcNNnq4ngHx0EmtyKHDFTImciGEmtC5NP8tKJQ,20435
21
21
  lcode/escalate.py,sha256=f-_6_moPaHi2kLuJw2JtkzvTkCl6DoBQgYBOCYLhunM,15120
22
22
  lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
@@ -28,17 +28,19 @@ lcode/jobs.py,sha256=P1P3FqI6EqL4LgdMX9YGZwCW0Bh5BRXFA_FnQxpHNjE,5618
28
28
  lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
29
29
  lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
30
30
  lcode/matching.py,sha256=2N3_qwKJ5gd8el2m_y_egKkFM9rsF1eUhYJcRCVIp98,14015
31
- lcode/media.py,sha256=f_CrXKhL6UgGOlP01AhexXczT7oVbpOvM55uwCeOnJw,10352
31
+ lcode/media.py,sha256=laRxRuBPGoOK1e2W6cjNHySps8OO9N6UiauuAtGmlyk,11870
32
+ lcode/media.toml,sha256=8vqFwqE8ECizDkUlnWGLfhcx94vEG1WfRCpJPHTi3UQ,3225
33
+ lcode/media_catalog.py,sha256=rtJPS3S7ZgJjaY3HIRerhf4ciqzXt4BsOikoQavzPAI,13076
32
34
  lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
33
35
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
34
- lcode/modes.py,sha256=RLfkWK5ColTnOd-Yp3g1rkKhNHVhxRR7HHvjSNYbZWk,4984
36
+ lcode/modes.py,sha256=Ke_mEeYF_SskbVUXMpJMeV5d5fFEQ-Vb-MKp4SIvT0Y,4990
35
37
  lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
36
38
  lcode/ollama.py,sha256=xFHU2XW47ANJ34-1aeSMQVj28GhqcWiFi6zASH9FiYY,8053
37
39
  lcode/permissions.py,sha256=q7CjDsc_Xrblw7t3_BbxA5LUjxFSswGs1RiJmtMi0po,12288
38
40
  lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
39
41
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
40
42
  lcode/repeats.py,sha256=OTrPdb6BKArDLQEgHaXmJZ-tIFsY_1bk4P0wzzANKLY,5964
41
- lcode/repl.py,sha256=PV1vjWfGhedufacf_BK8AntXLeJWmf59_W0ZCnSsrt4,61198
43
+ lcode/repl.py,sha256=Jjs45rvONRX2F3hx9Q-o3WkcepC_rKIqOE9okdlhNe8,62649
42
44
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
43
45
  lcode/residency.py,sha256=B9pwqG6yx5aQD2M5xyQ-bMnYmhD7fv3wCVw_X6VIeAo,7012
44
46
  lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
@@ -70,9 +72,10 @@ lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
70
72
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
71
73
  lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
72
74
  lcode/workflows/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
73
- lcode/workflows/flux2-klein-4b-t2i.json,sha256=-ThgsKHMhEsfSUAcn51rs9SJMM64iDPpDkQI4U9B1Xk,2052
74
- lcode_cli-0.40.0.dist-info/METADATA,sha256=j6QWyDsxjUKhQwOhCEnYBo-1CkjZPTTO3w8KEDHGQn8,13386
75
- lcode_cli-0.40.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
76
- lcode_cli-0.40.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
77
- lcode_cli-0.40.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
78
- lcode_cli-0.40.0.dist-info/RECORD,,
75
+ lcode/workflows/flux2-klein-4b-t2i.json,sha256=yGrYDT8pG2bns9DS1JavhlX5fwhDgIKfAwrHrRCyE8k,1788
76
+ lcode/workflows/z-image-turbo-t2i.json,sha256=sHsL7xnnuULOuWGYlQrDl4jOTySdAmb2T82c_cChv0s,1585
77
+ lcode_cli-0.41.0.dist-info/METADATA,sha256=MitknSeSrdiXm2qjDMKu5eT_xBdeAcFIqxT9oEfvwhM,13386
78
+ lcode_cli-0.41.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
79
+ lcode_cli-0.41.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
80
+ lcode_cli-0.41.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
81
+ lcode_cli-0.41.0.dist-info/RECORD,,