lcode-cli 0.37.0__py3-none-any.whl → 0.39.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.37.0"
6
+ __version__ = "0.39.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/agent.py CHANGED
@@ -99,7 +99,9 @@ def stall_note(trouble: str) -> str:
99
99
  }[trouble]
100
100
  return (
101
101
  f"[lcode] {first} Don't deliberate at length now. Either make your next tool call, or, if you're stuck, "
102
- "tell the user in a few lines what you did, what fails, and what they could do."
102
+ "tell the user in a few lines what you did, what fails, and what they could do. If you were about to check "
103
+ "something by running it, run it now instead of reasoning about it: for a window, take a screenshot (with "
104
+ "actions for clicks or keys)."
103
105
  )
104
106
 
105
107
 
@@ -302,6 +304,8 @@ class Progress:
302
304
  class Settings:
303
305
  model: str # Ollama model name to call
304
306
  context: int
307
+ mode: str = "code" # what lcode makes: code, image or video (lcode.modes)
308
+ comfyui_url: str = "http://127.0.0.1:8188"
305
309
  num_batch: int | None = None
306
310
  keep_alive: str = "30m"
307
311
  think: bool = True
@@ -678,6 +682,7 @@ class Agent:
678
682
  data = {
679
683
  "cwd": str(self.workspace), # where `lcode -c` there finds it, wherever the shell moved
680
684
  "model": self.settings.model,
685
+ "mode": self.settings.mode,
681
686
  "name": self.session_name,
682
687
  "title": self.session_title,
683
688
  "messages": self.messages,
@@ -724,6 +729,8 @@ class Agent:
724
729
  self.session_id = info.id
725
730
  self.checkpoints.load(info.id, data.get("checkpoints") or [])
726
731
  self.session_name = data.get("name", "")
732
+ if data.get("mode") in ("code", "image", "video"):
733
+ self.settings.mode = data["mode"] # a session belongs to the mode it was made in
727
734
  self.session_title = data.get("title") or sessions.title_from(self.messages)
728
735
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
729
736
  self.tools.todos = list(data.get("todos") or [])
@@ -961,6 +968,12 @@ class Agent:
961
968
  return []
962
969
  return checks.unrun_scripts(project_root_or_cwd(self.cwd), list(self.tools.before), commands)
963
970
 
971
+ def unseen_windows(self, tools_used: set[str]) -> dict[str, str]:
972
+ """Window code the model changed in this request and never looked at (only where the checks would run)."""
973
+ if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
974
+ return {}
975
+ return checks.unseen_windows(project_root_or_cwd(self.cwd), list(self.tools.before), tools_used)
976
+
964
977
  def project_check(self) -> checks.Check | None:
965
978
  """The project's check command, for the main agent outside plan mode and reviews (None otherwise)."""
966
979
  if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
@@ -1164,6 +1177,8 @@ class Agent:
1164
1177
  stalls = 0 # replies that went round in circles or ended without a word
1165
1178
  commands: list[str] = [] # shell commands run in this request
1166
1179
  scripts_noted = False # the model was told about scripts it wrote and never ran
1180
+ tools_used: set[str] = set() # tools called in this request
1181
+ windows_noted = False # the model was told about windows it changed and never looked at
1167
1182
  for step in range(self.max_steps):
1168
1183
  if self.cancel is not None and self.cancel.is_set():
1169
1184
  raise KeyboardInterrupt
@@ -1215,10 +1230,11 @@ class Agent:
1215
1230
  continue
1216
1231
  if not calls and (self.reply_trouble == "circles" or not (reply.get("content") or "").strip()):
1217
1232
  stalls += 1
1218
- self.messages.pop() # nothing to keep: no answer, and circles would only fill the context
1233
+ dropped = self.messages.pop() # no answer, and circles would only fill the context
1234
+ kept = self.keep_dropped(dropped)
1219
1235
  if stalls < MAX_STALLS:
1220
1236
  self.console.print(
1221
- Text(f" ⎿ {STALLED[self.reply_trouble]}: asking for a short answer", style="yellow")
1237
+ Text(f" ⎿ {STALLED[self.reply_trouble]}: asking for a short answer{kept}", style="yellow")
1222
1238
  )
1223
1239
  self.messages.append({"role": "user", "content": stall_note(self.reply_trouble)})
1224
1240
  continue
@@ -1234,7 +1250,7 @@ class Agent:
1234
1250
  self.console.print(
1235
1251
  Text(
1236
1252
  "Stopped: the model's replies went round in circles or ended without an answer, twice in "
1237
- "this request. Try again with a smaller step, or tell it what to do differently.",
1253
+ f"this request{kept}. Try again with a smaller step, or tell it what to do differently.",
1238
1254
  style="yellow",
1239
1255
  )
1240
1256
  )
@@ -1253,6 +1269,14 @@ class Agent:
1253
1269
  self.console.print(Text(f" ⎿ never ran {', '.join(unrun)}: asking the model to", style="yellow"))
1254
1270
  self.messages.append({"role": "user", "content": checks.unrun_note(unrun)})
1255
1271
  continue
1272
+ unseen = {} if windows_noted else self.unseen_windows(tools_used)
1273
+ if unseen:
1274
+ windows_noted = True
1275
+ self.console.print(
1276
+ Text(f" ⎿ never looked at {', '.join(unseen)}: asking the model to", style="yellow")
1277
+ )
1278
+ self.messages.append({"role": "user", "content": checks.unseen_note(unseen)})
1279
+ continue
1256
1280
  check = self.project_check() if changed and not verified else None
1257
1281
  if check is not None and check_rounds < checks.MAX_ROUNDS:
1258
1282
  check_rounds += 1
@@ -1345,6 +1369,7 @@ class Agent:
1345
1369
  )
1346
1370
  if out_of_reach.stop:
1347
1371
  blocked = out_of_reach
1372
+ tools_used.add(name)
1348
1373
  if name == "bash":
1349
1374
  commands.append(str(args.get("command", "")))
1350
1375
  if name in ("edit_file", "write_file", "apply_patch") and not result.startswith(REFUSED):
@@ -1412,6 +1437,14 @@ class Agent:
1412
1437
  self.turn_status = "success"
1413
1438
  self.print_stats()
1414
1439
 
1440
+ def keep_dropped(self, reply: dict) -> str:
1441
+ """Save a reply lcode dropped (its thinking too), so the user can see what went round in circles."""
1442
+ text = "\n\n".join(part for part in (reply.get("thinking"), reply.get("content")) if part)
1443
+ if not text.strip():
1444
+ return ""
1445
+ path = context_tools.save_output(text, "dropped reply")
1446
+ return f" (the dropped reply is in {path})" if path else ""
1447
+
1415
1448
  def can_escalate(self) -> bool:
1416
1449
  """Whether a stronger model may be asked here: one is set, and this isn't a subagent or plan mode."""
1417
1450
  model = (self.settings.escalate_model or "").strip().lower()
lcode/api.py CHANGED
@@ -54,6 +54,7 @@ class Options:
54
54
  model: str | None = None
55
55
  context: int | str | None = None
56
56
  permission_mode: str | None = None # ask, plan, auto-edit or yolo
57
+ mode: str | None = None # code, image or video
57
58
  allowed_tools: list[str] | None = None # only these tools (default: all)
58
59
  max_steps: int | None = None # model steps per request
59
60
  think: bool | None = None
@@ -179,6 +180,8 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
179
180
  think=cfg["think"] if options.think is None else options.think,
180
181
  show_thinking=options.show_thinking,
181
182
  permission_mode=options.permission_mode or cfg["permission_mode"],
183
+ mode=options.mode or cfg["mode"],
184
+ comfyui_url=cfg["comfyui_url"],
182
185
  web=switch(options.web, cfg["web"], "on"),
183
186
  search_backend=cfg["search_backend"],
184
187
  searxng_url=cfg["searxng_url"],
lcode/checks.py CHANGED
@@ -50,10 +50,28 @@ def find(root: Path, setting: str, trusted: bool) -> Check | None:
50
50
  return detect(root)
51
51
 
52
52
 
53
- def python() -> str:
53
+ VENVS = (".venv", "venv", "env")
54
+ PYTEST_USE = re.compile(r"^\s*(?:import pytest\b|from pytest\b)|^\s*@pytest\.", re.M)
55
+
56
+
57
+ def python(root: Path | None = None) -> str:
58
+ """The project's own Python (its .venv, venv or env), where its dependencies are; else the one on PATH."""
59
+ if root is not None:
60
+ for env in VENVS:
61
+ for exe in ("bin/python", "Scripts/python.exe"):
62
+ if (root / env / exe).is_file():
63
+ return f"{env}/{exe}" # the checks run in the project's root
54
64
  return "python" if shutil.which("python") else "python3"
55
65
 
56
66
 
67
+ def uses_pytest(root: Path) -> bool:
68
+ """Whether the tests are written for pytest (they import it), or the requirements list it, without a config."""
69
+ for name in ("requirements.txt", "requirements-dev.txt", "requirements_dev.txt", "dev-requirements.txt"):
70
+ if re.search(r"^\s*pytest\b", text(root / name), re.M | re.I):
71
+ return True
72
+ return any(PYTEST_USE.search(text(path)[:20000]) for path in test_files(root)[:50])
73
+
74
+
57
75
  def text(path: Path) -> str:
58
76
  try:
59
77
  return path.read_text(errors="replace") if path.is_file() else ""
@@ -61,38 +79,49 @@ def text(path: Path) -> str:
61
79
  return ""
62
80
 
63
81
 
64
- def python_tests(root: Path) -> bool:
65
- """Whether the project has Python test files (a few levels deep, outside environments and caches)."""
82
+ def test_files(root: Path, limit: int = 200) -> list[Path]:
83
+ """The project's Python test files, a few levels deep, outside environments and caches."""
66
84
  from lcode.tools import IGNORE_DIRS
67
85
 
68
- def walk(folder: Path, depth: int) -> bool:
86
+ found: list[Path] = []
87
+
88
+ def walk(folder: Path, depth: int) -> None:
69
89
  try:
70
- entries = list(folder.iterdir())
90
+ entries = sorted(folder.iterdir())
71
91
  except OSError:
72
- return False
73
- if any(e.is_file() and re.fullmatch(r"test_.*\.py|.*_test\.py", e.name) for e in entries):
74
- return True
75
- return depth > 0 and any(
76
- walk(e, depth - 1)
77
- for e in entries
78
- if e.is_dir() and e.name not in IGNORE_DIRS and not e.name.startswith(".")
79
- )
92
+ return
93
+ found.extend(e for e in entries if e.is_file() and re.fullmatch(r"test_.*\.py|.*_test\.py", e.name))
94
+ if depth > 0:
95
+ for e in entries:
96
+ if len(found) >= limit:
97
+ return
98
+ if e.is_dir() and e.name not in IGNORE_DIRS and e.name not in VENVS and not e.name.startswith("."):
99
+ walk(e, depth - 1)
80
100
 
81
- return walk(root, 3)
101
+ walk(root, 3)
102
+ return found[:limit]
103
+
104
+
105
+ def python_tests(root: Path) -> bool:
106
+ """Whether the project has Python test files (a few levels deep, outside environments and caches)."""
107
+ return bool(test_files(root, limit=1))
82
108
 
83
109
 
84
110
  def detect(root: Path) -> Check | None:
85
111
  """A check command from what the project looks like. Python checks need test files: pytest configured in a
86
112
  project without tests "fails" with nothing to run."""
87
113
  if python_tests(root):
114
+ py = python(root)
88
115
  configs = text(root / "pyproject.toml") + text(root / "setup.cfg") + text(root / "tox.ini")
89
116
  if "pytest" in configs or any((root / name).is_file() for name in ("pytest.ini", "conftest.py")):
90
- runner = "uv run pytest -q" if (root / "uv.lock").is_file() else f"{python()} -m pytest -q"
117
+ runner = "uv run pytest -q" if (root / "uv.lock").is_file() else f"{py} -m pytest -q"
91
118
  return Check(runner, "pytest in the project's configuration")
119
+ if uses_pytest(root):
120
+ return Check(f"{py} -m pytest -q", "tests written for pytest")
92
121
  if list(root.glob("test_*.py")) or list(root.glob("*_test.py")):
93
- return Check(f"{python()} -m unittest", "test files")
122
+ return Check(f"{py} -m unittest", "test files")
94
123
  if (root / "tests").is_dir():
95
- return Check(f"{python()} -m unittest discover -s tests -t .", "tests/")
124
+ return Check(f"{py} -m unittest discover -s tests -t .", "tests/")
96
125
  package = text(root / "package.json")
97
126
  if package:
98
127
  try:
@@ -229,3 +258,52 @@ def unrun_note(scripts: list[str]) -> str:
229
258
  f"deploy, delete data) or needs something you don't have, don't: tell the user plainly that {it} is untested "
230
259
  "and why."
231
260
  )
261
+
262
+
263
+ # ----------------------------------------------------------------------------- windows it never looked at
264
+
265
+ GUI_IMPORT = re.compile(r"^\s*(?:from|import)\s+(PyQt[56]|PySide[26]|tkinter|kivy|wx|gi\.repository)\b", re.M)
266
+ LOOKED = frozenset({"screenshot", "view_image"})
267
+
268
+
269
+ def unseen_windows(root: Path, changed: list[str], tools_used: set[str]) -> dict[str, str]:
270
+ """Python files that draw a window (they import a GUI toolkit), changed in this request when the model never
271
+ looked at one: file -> toolkit.
272
+
273
+ A small model checks a window change by importing the module and running unrelated tests, then calls it done;
274
+ the user finds a blank window. Tests and imports don't show what's on the screen; a screenshot does.
275
+ """
276
+ if tools_used & LOOKED:
277
+ return {}
278
+ found = {}
279
+ for rel in changed:
280
+ path = root / rel
281
+ if path.suffix != ".py" or not path.is_file():
282
+ continue
283
+ try:
284
+ text = path.read_text(errors="replace")[:200_000]
285
+ except OSError:
286
+ continue
287
+ match = GUI_IMPORT.search(text)
288
+ if match:
289
+ found[rel] = match.group(1)
290
+ return found
291
+
292
+
293
+ def unseen_note(found: dict[str, str]) -> str:
294
+ names = ", ".join(found)
295
+ kits = sorted(set(found.values()))
296
+ qt = any(kit.startswith(("PyQt", "PySide")) for kit in kits)
297
+ how = (
298
+ "take a screenshot with the screenshot tool: it runs the program without a screen and shows what it draws, "
299
+ "and what the program printed (Qt names layout mistakes there). For something that only shows after a click "
300
+ 'or a key, give actions, e.g. actions=["double_click Camera 1"]'
301
+ if qt
302
+ else "take a screenshot with the screenshot tool: it runs the program on a virtual display and shows what it "
303
+ "draws"
304
+ )
305
+ return (
306
+ f"[lcode] You changed {names}, which draws a window ({', '.join(kits)}), but never looked at it: tests and "
307
+ f"imports don't show what's on the screen. Before you say it works, {how}. Check the picture against what the "
308
+ "user asked for. If the program can't run here, tell the user plainly that you haven't seen it working."
309
+ )
lcode/cli.py CHANGED
@@ -714,6 +714,7 @@ def cmd_chat(args) -> None:
714
714
  model=args.model,
715
715
  context=args.context,
716
716
  permission_mode=mode,
717
+ mode=args.mode,
717
718
  allowed_tools=tools,
718
719
  max_steps=args.max_steps,
719
720
  think=False if args.no_think else None,
@@ -733,6 +734,16 @@ def cmd_chat(args) -> None:
733
734
  sys.exit(1)
734
735
  fail(str(e))
735
736
  exit_code = 0
737
+ 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)
736
747
  if args.escalate and args.prompt:
737
748
  agent.escalate_approved = True # the flag is the user's OK for this run
738
749
  try:
@@ -798,6 +809,7 @@ def build_parser() -> argparse.ArgumentParser:
798
809
  help="work in a git worktree of its own, on branch NAME (new, or continue one); removed if nothing changed",
799
810
  )
800
811
  parser.add_argument("--plan", action="store_true", help="start in plan mode: agree on a plan before any change")
812
+ parser.add_argument("--mode", choices=config.WORK_MODES, help="what lcode makes: code (default), image or video")
801
813
  parser.add_argument("--auto-edit", action="store_true", help="apply file edits without asking")
802
814
  parser.add_argument("--yolo", action="store_true", help="never ask for permission (edits and commands)")
803
815
  parser.add_argument(
lcode/config.py CHANGED
@@ -23,6 +23,7 @@ else:
23
23
 
24
24
  DEFAULT_MODEL = "qwen3.6-35b"
25
25
  PERMISSION_MODES = ("ask", "plan", "auto-edit", "yolo") # Shift+Tab cycles in this order
26
+ WORK_MODES = ("code", "image", "video") # what lcode makes (lcode.modes)
26
27
  WEB_MODES = ("on", "ask", "off")
27
28
  SEARCH_BACKENDS = ("auto", "ollama", "brave", "tavily", "searxng")
28
29
  MCP_TOOL_MODES = ("auto", "direct", "search")
@@ -40,6 +41,8 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
40
41
  "backend": ("ollama", str, "model server: ollama | lmstudio | llama.cpp | vllm | mlx | openai (any OpenAI API)"),
41
42
  "base_url": (None, str, "OpenAI-compatible server address, e.g. http://localhost:1234/v1 (default: the backend's)"),
42
43
  "permission_mode": ("ask", str, "ask | plan | auto-edit | yolo"),
44
+ "mode": ("code", str, "what lcode makes when it starts: code | image | video"),
45
+ "comfyui_url": ("http://127.0.0.1:8188", str, "ComfyUI, for image and video mode"),
43
46
  "think": (True, bool, "let the model reason before answering (slower, better)"),
44
47
  "web": ("on", str, "web search and page fetching for the model: on | ask | off"),
45
48
  "search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
@@ -203,6 +206,8 @@ def coerce(key: str, value: object) -> object:
203
206
  raise ConfigError(f"search_backend must be one of {', '.join(SEARCH_BACKENDS)}")
204
207
  if key == "permission_mode" and value not in PERMISSION_MODES:
205
208
  raise ConfigError(f"permission_mode must be one of {', '.join(PERMISSION_MODES)}")
209
+ if key == "mode" and value not in WORK_MODES:
210
+ raise ConfigError(f"mode must be one of {', '.join(WORK_MODES)}")
206
211
  if typ is bool and isinstance(value, str):
207
212
  if value.lower() not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
208
213
  raise ConfigError(f"{key} must be true or false")
lcode/mcp/catalog.toml CHANGED
@@ -130,34 +130,6 @@ requires = ["npx"]
130
130
  setup = "Runs a headless browser; the first use may download it."
131
131
  server = { command = "npx", args = ["-y", "@playwright/mcp@latest", "--headless"] }
132
132
 
133
- [comfyui]
134
- name = "ComfyUI"
135
- description = "Generate and edit images with models you run locally in ComfyUI: FLUX, SDXL, SD 1.5, Qwen-Image"
136
- homepage = "https://github.com/Comfy-Org/comfy-mcp"
137
- requires = ["uvx"]
138
- setup = """Needs ComfyUI on this machine (http://127.0.0.1:8188) with at least one image model:
139
- uv tool install comfy-cli && comfy install
140
- comfy launch --background -- --disable-smart-memory
141
- --disable-smart-memory makes ComfyUI give the GPU back after each image. Before generating, lcode
142
- unloads its own model so ComfyUI has the GPU; it reloads afterwards. Only the local tools are on;
143
- Comfy Cloud's partner tools are left out, so nothing leaves this machine."""
144
-
145
- [comfyui.server]
146
- command = "uvx"
147
- args = ["--python", "3.12", "--from", "comfy-mcp", "--with", "comfy-cli>=1.14.0", "comfy-mcp"]
148
- env = { COMFYUI_URL = "${COMFYUI_URL}" }
149
- tools = [
150
- "server_info", "generate_image", "run_workflow", "run_template", "search_templates", "get_template", "fetch_template",
151
- "list_workflow_slots", "set_workflow_slot", "job", "fetch_outputs", "upload_file", "search_models",
152
- "download_model", "system_stats", "free_memory", "launch_comfyui",
153
- ]
154
- free_gpu = ["generate_image", "run_workflow", "run_template"]
155
-
156
- [[comfyui.inputs]]
157
- var = "COMFYUI_URL"
158
- prompt = "ComfyUI address (Enter for this machine, 127.0.0.1:8188)"
159
- optional = true
160
-
161
133
  [context7]
162
134
  name = "Context7"
163
135
  description = "Current documentation and code examples for thousands of libraries"
@@ -282,23 +254,3 @@ var = "VALOHAI_HOST"
282
254
  prompt = "Valohai address (Enter for app.valohai.com; for a self-hosted installation, its URL)"
283
255
  optional = true
284
256
 
285
- [xai]
286
- name = "xAI Grok Imagine"
287
- description = "Generate and edit images, and generate videos and speech, with xAI's Grok Imagine and voice API (paid, in the cloud)"
288
- homepage = "https://nasser1941.github.io/lcode/mcp/#xai"
289
- requires = ["uvx"]
290
- setup = """Uses xAI's API: your prompts, and images you edit or animate, go to xAI, and every call is billed to your xAI
291
- account (an image $0.02-0.05, video $0.02-0.08 a second, speech per character). Create an API key at https://console.x.ai.
292
- Files are saved in generated/ in the project. lcode asks before each call; to stop asking for some tools, list them
293
- under "allow" for this server in mcp.json. xAI has no music generation."""
294
-
295
- [xai.server]
296
- command = "uvx"
297
- args = ["--isolated", "--python", "3.12", "--from", "lcode-cli", "lcode-mcp-xai"]
298
- env = { XAI_API_KEY = "${XAI_API_KEY}" }
299
- timeout = 900 # a video takes minutes
300
-
301
- [[xai.inputs]]
302
- var = "XAI_API_KEY"
303
- prompt = "xAI API key (create one at https://console.x.ai)"
304
- secret = true
lcode/mcp/commands.py CHANGED
@@ -60,6 +60,12 @@ def start_session(cwd: Path, tool_mode: str, console: Console, interactive: bool
60
60
  servers, problems = mcp_config.load_all(cwd, include_project=use_project)
61
61
  for problem in problems:
62
62
  console.print(f"[yellow]MCP: {escape(problem)}[/]")
63
+ from lcode import modes
64
+
65
+ servers, left_out = modes.code_mode_servers(servers) # images and videos have their own modes
66
+ note = modes.note_once(left_out)
67
+ if note and interactive:
68
+ console.print(f"[dim]{escape(note)}[/]")
63
69
  if not servers:
64
70
  return None
65
71
  manager = McpManager(cwd, servers, tool_mode)
lcode/modes.py ADDED
@@ -0,0 +1,102 @@
1
+ """What lcode makes: code, images or videos.
2
+
3
+ - **code** is the coding agent: a language model with tools, and nothing else. It doesn't generate images or
4
+ videos: the MCP servers that do (`comfyui`, `xai`) aren't loaded in code mode.
5
+ - **image** and **video** generate media on the GPU with image and video models (through ComfyUI, #149). They
6
+ don't need the big coding model: lcode unloads it when you switch to them, and frees ComfyUI's memory when you
7
+ switch back, so one model has the GPU at a time.
8
+
9
+ Until the generation engine lands, image and video mode say what's coming instead of sending the prompt anywhere.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from typing import TYPE_CHECKING
16
+
17
+ import requests
18
+
19
+ from lcode import config
20
+
21
+ if TYPE_CHECKING:
22
+ from lcode.agent import Agent
23
+ from lcode.mcp.config import ServerConfig
24
+
25
+ MODES = ("code", "image", "video")
26
+ TITLES = {"code": "local coding agent", "image": "local image generation", "video": "local video generation"}
27
+ MEDIA_SERVERS = frozenset({"comfyui", "xai"}) # the MCP catalog's image and video servers
28
+ MEDIA_COMMANDS = ("comfy-mcp", "lcode-mcp-xai") # the same servers, under any name
29
+ NOTED_FILE = "media-servers-noted"
30
+ COMING = {
31
+ "image": (
32
+ "Image mode doesn't generate yet: lcode's own ComfyUI engine (#149), the image models (#150) and "
33
+ "generation (#151) come next. /mode code switches back."
34
+ ),
35
+ "video": (
36
+ "Video mode doesn't generate yet: lcode's own ComfyUI engine (#149), the video models (#150) and "
37
+ "generation (#153) come next. /mode code switches back."
38
+ ),
39
+ }
40
+
41
+
42
+ def is_media_server(server: ServerConfig) -> bool:
43
+ """An MCP server that generates images or videos: those belong to image and video mode, not code mode."""
44
+ if server.name.lower() in MEDIA_SERVERS:
45
+ return True
46
+ target = " ".join(str(part) for part in [*server.command, server.url])
47
+ return any(name in target for name in MEDIA_COMMANDS)
48
+
49
+
50
+ def code_mode_servers(servers: list[ServerConfig]) -> tuple[list[ServerConfig], list[str]]:
51
+ """The servers code mode loads, and the names of the media servers it leaves out."""
52
+ kept = [s for s in servers if not is_media_server(s)]
53
+ return kept, [s.name for s in servers if is_media_server(s)]
54
+
55
+
56
+ def note_once(left_out: list[str]) -> str:
57
+ """The note about media servers left out of code mode, the first time only."""
58
+ if not left_out:
59
+ return ""
60
+ marker = config.STATE_DIR / NOTED_FILE
61
+ if marker.exists():
62
+ return ""
63
+ try:
64
+ marker.parent.mkdir(parents=True, exist_ok=True)
65
+ marker.write_text(json.dumps(sorted(left_out)) + "\n")
66
+ except OSError:
67
+ pass
68
+ names = ", ".join(left_out)
69
+ return (
70
+ f"Code mode makes code only, so the MCP servers that make images and videos ({names}) aren't loaded "
71
+ "here any more. Image and video generation move to their own modes: /mode image, /mode video."
72
+ )
73
+
74
+
75
+ def free_comfyui(url: str) -> bool:
76
+ """Ask ComfyUI at `url` to give the GPU back (unload its models, free its memory). Whether it answered."""
77
+ try:
78
+ r = requests.post(url.rstrip("/") + "/free", json={"unload_models": True, "free_memory": True}, timeout=3)
79
+ except requests.RequestException:
80
+ return False
81
+ return r.status_code == 200
82
+
83
+
84
+ def switch(agent: Agent, new: str) -> list[str]:
85
+ """Switch the agent to another mode, handing the GPU over. The lines to show."""
86
+ old = agent.settings.mode
87
+ if new == old:
88
+ return [f"Already in {new} mode."]
89
+ lines = []
90
+ if old == "code" and getattr(agent.ollama, "kind", "ollama") == "ollama":
91
+ try:
92
+ agent.ollama.unload(agent.settings.model) # the image or video model needs the GPU
93
+ lines.append(f"Unloaded {agent.settings.model} to free the GPU for {new}s.")
94
+ except Exception: # not loaded, or Ollama gone: nothing to free
95
+ pass
96
+ if new == "code" and free_comfyui(agent.settings.comfyui_url):
97
+ lines.append("ComfyUI gave the GPU back.")
98
+ agent.settings.mode = new
99
+ lines.insert(0, f"Mode: {new} ({TITLES[new]}).")
100
+ if new != "code":
101
+ lines.append(COMING[new])
102
+ return lines
lcode/repeats.py CHANGED
@@ -6,7 +6,8 @@ one exactly: the same tool, the same arguments and the same result. At the `limi
6
6
  failed: one sooner) the result gets a note that tells the model so, and if it still goes on, lcode ends the
7
7
  request.
8
8
 
9
- Results that change aren't repeats: a test run again after a fix, a background job read while it runs. And
9
+ Results that change aren't repeats: a test run again after a fix, a background job read while it runs. Timings
10
+ don't count as a change, though: `75 passed in 0.12s` and then `in 0.14s` is the same result. And
10
11
  a call that may have changed something (a file edit, a shell command that isn't read-only) starts the count
11
12
  again for every other call, so checking a build after each edit never adds up. Its own repeats still count:
12
13
  running the same failing command over and over is a loop.
@@ -23,6 +24,9 @@ from dataclasses import dataclass
23
24
  from lcode.permissions import is_read_only
24
25
 
25
26
  STOP_AFTER_NOTE = 2 # repeats after the note before the request ends
27
+ TIMING = re.compile(
28
+ r"\b\d+(?:\.\d+)?\s?(?:ms|µs|us|s|secs?|seconds?|mins?|minutes?)\b|\b\d{1,2}:\d{2}:\d{2}(?:[.,]\d+)?\b", re.I
29
+ )
26
30
  FAILED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
27
31
  UNCHANGING = frozenset(
28
32
  {
@@ -64,7 +68,7 @@ class Repeats:
64
68
  """Count this call and its result."""
65
69
  if self.limit <= 0:
66
70
  return Verdict(1)
67
- key = hashlib.sha256(json.dumps([name, args, result], sort_keys=True, default=str).encode()).hexdigest()
71
+ key = hashlib.sha256(json.dumps([name, args, steady(result)], sort_keys=True, default=str).encode()).hexdigest()
68
72
  count = self.seen.get(key, 0) + 1
69
73
  if changes_things(name, args, result):
70
74
  self.seen.clear()
@@ -95,6 +99,11 @@ class Repeats:
95
99
  )
96
100
 
97
101
 
102
+ def steady(result: str) -> str:
103
+ """The result without its timings and clock times, which change on every run of the same command."""
104
+ return TIMING.sub("#", result)
105
+
106
+
98
107
  def changes_things(name: str, args: dict, result: str) -> bool:
99
108
  """Whether the call may have changed files (or anything else a later call could see)."""
100
109
  if name in UNCHANGING:
lcode/repl.py CHANGED
@@ -36,6 +36,7 @@ from lcode import (
36
36
  extensions,
37
37
  gitflow,
38
38
  limits,
39
+ modes,
39
40
  residency,
40
41
  sessions,
41
42
  web,
@@ -75,7 +76,8 @@ COMMANDS = {
75
76
  "/think": "Toggle model reasoning on/off",
76
77
  "/verbose": "Toggle showing the model's reasoning text",
77
78
  "/plan": "Plan before changing anything: /plan <request>, or /plan to show the approved plan",
78
- "/mode": "Permission mode: ask | plan | auto-edit | yolo (Shift+Tab cycles)",
79
+ "/mode": "What lcode makes: code, image or video, e.g. /mode image",
80
+ "/permissions": "Permission mode: ask | plan | auto-edit | yolo (Shift+Tab cycles)",
79
81
  "/cd": "Change the working directory",
80
82
  "/todos": "Show the current todo list",
81
83
  "/status": "The task sheet: your requests, the plan and notes, files changed, the last check",
@@ -193,7 +195,7 @@ def build_session(agent: Agent) -> PromptSession:
193
195
  color = {"ask": "ansigreen", "plan": "ansicyan", "auto-edit": "ansiyellow", "yolo": "ansired"}[agent.perms.mode]
194
196
  return HTML(
195
197
  f" <b>{html.escape(s.model)}</b> · ctx {format_tokens(agent.ctx_used)}/{format_tokens(s.context)} "
196
- f"({pct:.0f}%) · mode <{color}>{agent.perms.mode}</{color}> (shift+tab) · "
198
+ f"({pct:.0f}%) · <b>{s.mode}</b> · <{color}>{agent.perms.mode}</{color}> (shift+tab) · "
197
199
  f"think {'on' if s.think else 'off'} · {html.escape(agent.cwd.name)}/"
198
200
  + (" · <ansiyellow>loading the model into memory…</ansiyellow>" if agent.load_state == "loading" else "")
199
201
  + (f" · <b>{html.escape(agent.session_name)}</b>" if agent.session_name else "")
@@ -216,14 +218,15 @@ def banner(agent: Agent) -> None:
216
218
  agent.console.print(
217
219
  Panel(
218
220
  Group(
219
- Text.from_markup(f"[bold cyan]lcode[/] [dim]v{__version__}[/] — local coding agent\n"),
221
+ Text.from_markup(f"[bold cyan]lcode[/] [dim]v{__version__}[/] — {modes.TITLES[s.mode]}\n"),
220
222
  Text.from_markup(
221
223
  f"[dim]model[/] {escape(s.model)}"
222
224
  + (f" [dim]· {escape(agent.ollama.describe())}[/]" if backends.is_openai(agent.ollama) else "")
223
225
  + "\n"
224
226
  f"[dim]context[/] {format_tokens(s.context)} tokens\n"
225
227
  f"[dim]cwd[/] {agent.cwd}\n"
226
- f"[dim]mode[/] {agent.perms.mode} [dim](Shift+Tab to cycle)[/]\n"
228
+ f"[dim]mode[/] {s.mode} [dim](/mode code, image or video)[/]\n"
229
+ f"[dim]perms[/] {agent.perms.mode} [dim](Shift+Tab to cycle)[/]\n"
227
230
  f"[dim]web[/] {web_status(agent)}\n"
228
231
  + (f"[dim]mcp[/] {escape(', '.join(agent.mcp.servers))} [dim](/mcp)[/]\n" if agent.mcp else "")
229
232
  + (
@@ -826,6 +829,30 @@ def restore_checkpoint(agent: Agent, target: Checkpoint, rewind: bool) -> None:
826
829
  agent.save()
827
830
 
828
831
 
832
+ def mode_command(agent: Agent, arg: str) -> None:
833
+ """/mode: show the mode, or switch to code, image or video (handing the GPU over)."""
834
+ c = agent.console
835
+ if not arg:
836
+ c.print(
837
+ f"Mode: [bold]{agent.settings.mode}[/] ({modes.TITLES[agent.settings.mode]}). Switch with /mode code, "
838
+ "/mode image or /mode video. The permission mode is /permissions."
839
+ )
840
+ return
841
+ if arg in PERMISSION_MODES: # what /mode used to set
842
+ agent.perms.mode = arg
843
+ c.print(f"Permission mode: {arg}. [dim](/permissions sets it now; /mode switches code, image and video.)[/]")
844
+ return
845
+ if arg not in modes.MODES:
846
+ c.print(f"[red]Unknown mode {escape(arg)!r}: choose code, image or video.[/]")
847
+ return
848
+ lines = modes.switch(agent, arg)
849
+ c.print(f"[green]{escape(lines[0])}[/]")
850
+ for line in lines[1:]:
851
+ c.print(f"[dim]{escape(line)}[/]")
852
+ if arg == "code":
853
+ preload(agent) # the coding model loads in the background, as at the start
854
+
855
+
829
856
  def model_command(agent: Agent, arg: str, hardware: Hardware) -> None:
830
857
  """/models and /model: the numbered list to pick from, or straight to a number or a name (/model 2)."""
831
858
  from lcode.cli import model_choices, print_models
@@ -1081,7 +1108,10 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
1081
1108
  elif cmd in ("/context", "/ctx"):
1082
1109
  context_command(agent, arg, hardware)
1083
1110
  elif cmd in ("/models", "/model"):
1084
- model_command(agent, arg, hardware)
1111
+ if s.mode != "code":
1112
+ c.print(f"[yellow]{escape(modes.COMING[s.mode])}[/]")
1113
+ else:
1114
+ model_command(agent, arg, hardware)
1085
1115
  elif cmd == "/think":
1086
1116
  s.think = (arg == "on") if arg in ("on", "off") else not s.think
1087
1117
  c.print(f"Reasoning {'on' if s.think else 'off'}.")
@@ -1103,10 +1133,12 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
1103
1133
  c.print(Rule(style="dim"))
1104
1134
  run_safely(agent, arg)
1105
1135
  elif cmd == "/mode":
1136
+ mode_command(agent, arg.strip().lower())
1137
+ elif cmd == "/permissions":
1106
1138
  if arg in PERMISSION_MODES:
1107
1139
  agent.perms.mode = arg
1108
1140
  elif arg:
1109
- c.print(f"[red]Unknown mode; choose from {', '.join(PERMISSION_MODES)}[/]")
1141
+ c.print(f"[red]Unknown permission mode; choose from {', '.join(PERMISSION_MODES)}[/]")
1110
1142
  c.print(f"Permission mode: {agent.perms.mode}")
1111
1143
  elif cmd == "/cd":
1112
1144
  p = Path(os.path.expanduser(arg or "~"))
@@ -1256,6 +1288,9 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
1256
1288
  if info:
1257
1289
  resume_session(agent, info)
1258
1290
  session_start(agent)
1291
+ if prompt and agent.settings.mode != "code":
1292
+ agent.console.print(f"[yellow]{escape(modes.COMING[agent.settings.mode])}[/]")
1293
+ prompt = None
1259
1294
  if prompt:
1260
1295
  run_safely(agent, prompt)
1261
1296
  return
@@ -1266,12 +1301,16 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
1266
1301
  if warning:
1267
1302
  agent.say(warning, warning=True)
1268
1303
  session = build_session(agent)
1269
- preload(agent, session)
1304
+ if agent.settings.mode == "code":
1305
+ preload(agent, session)
1306
+ else:
1307
+ agent.console.print(f"[yellow]{escape(modes.COMING[agent.settings.mode])}[/]")
1270
1308
  while True:
1271
1309
  try:
1272
1310
  agent.console.print()
1273
1311
  unsent, agent.unsent = agent.unsent, ""
1274
- line = session.prompt(HTML("<ansicyan><b>❯ </b></ansicyan>"), default=unsent).strip()
1312
+ marker = "❯ " if agent.settings.mode == "code" else f"{agent.settings.mode} ❯ "
1313
+ line = session.prompt(HTML(f"<ansicyan><b>{marker}</b></ansicyan>"), default=unsent).strip()
1275
1314
  except KeyboardInterrupt:
1276
1315
  continue
1277
1316
  except EOFError:
@@ -1284,12 +1323,20 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
1284
1323
  if not handle_command(agent, line, hardware):
1285
1324
  break
1286
1325
  continue
1287
- agent.console.print(Rule(style="dim"))
1288
- run_safely(agent, line)
1326
+ respond(agent, line)
1289
1327
  memory_notes.reflect(agent)
1290
1328
  agent.console.print("[dim]Bye.[/]")
1291
1329
 
1292
1330
 
1331
+ def respond(agent: Agent, line: str) -> None:
1332
+ """What a typed request does: the coding agent works on it, or image and video mode take it."""
1333
+ if agent.settings.mode != "code": # image and video: no coding model involved
1334
+ agent.console.print(f"[yellow]{escape(modes.COMING[agent.settings.mode])}[/]")
1335
+ return
1336
+ agent.console.print(Rule(style="dim"))
1337
+ run_safely(agent, line)
1338
+
1339
+
1293
1340
  def preload(agent: Agent, session: PromptSession | None = None) -> None:
1294
1341
  """Start loading the model in the background so the first answer comes sooner; the status bar says so."""
1295
1342
  if is_cloud(agent.settings.model):
lcode/screenshots.py CHANGED
@@ -6,7 +6,9 @@ returns what the model can't otherwise see: a picture (described by the vision m
6
6
 
7
7
  - A Python Qt program (PyQt5/6, PySide2/6) runs on Qt's "offscreen" platform, with a screen of the asked size. A
8
8
  small startup hook (a sitecustomize module on PYTHONPATH) waits for the program's event loop, then grabs its
9
- windows, measures them and their widgets, and quits it.
9
+ windows, measures them and their widgets, and quits it. First it can take `actions`, the way a user would: click
10
+ or double-click a point or a widget by its text, press a key, type, wait. Bugs that only show after a click need
11
+ that, and Qt's warnings (in what the program printed) often name the cause.
10
12
  - Other desktop programs run on a virtual X display (Xvfb, Linux), captured with ImageMagick's `import` or `xwd`.
11
13
  The Qt hook still measures a Python Qt program there.
12
14
  - A web page, or a local server started with `command`, opens in headless Chrome (or Chromium, Edge), driven
@@ -43,6 +45,14 @@ from pathlib import Path
43
45
  DEFAULT_SCREEN = (1920, 1080)
44
46
  DEFAULT_WAIT = 3 # seconds for a program or page to start and draw
45
47
  MAX_WAIT = 60
48
+ MAX_ACTIONS = 20
49
+ ACTION_PAUSE = 0.4 # seconds after each action, for the program to react and redraw
50
+ MOUSE_ACTIONS = ("click", "double_click", "right_click")
51
+ KEY_NAMES = {"enter": "Return", "return": "Return", "esc": "Escape", "escape": "Escape", "space": "Space",
52
+ "tab": "Tab", "backspace": "Backspace", "delete": "Delete", "del": "Delete", "home": "Home", "end": "End",
53
+ "pageup": "PageUp", "pagedown": "PageDown", "up": "Up", "down": "Down", "left": "Left",
54
+ "right": "Right"} # fmt: skip
55
+ MODIFIERS = ("ctrl", "shift", "alt", "meta")
46
56
  SERVER_TIMEOUT = 60 # seconds for a server started with `command` to answer at `url`
47
57
  GRACE = 25 # seconds beyond `wait` before a program that never showed a window is stopped
48
58
  QT_BINDINGS = ("PyQt6", "PyQt5", "PySide6", "PySide2")
@@ -169,15 +179,19 @@ def _lcode_shot():
169
179
  out = _os.environ.get("LCODE_SHOT_OUT")
170
180
  wait_ms = int(float(_os.environ.get("LCODE_SHOT_WAIT", "3")) * 1000)
171
181
  quit_after = _os.environ.get("LCODE_SHOT_QUIT", "1") == "1"
182
+ actions = json.loads(_os.environ.get("LCODE_SHOT_ACTIONS") or "[]")
183
+ pause_ms = int(float(_os.environ.get("LCODE_SHOT_PAUSE", "0.4")) * 1000)
172
184
  bindings = ("PyQt6", "PyQt5", "PySide6", "PySide2")
173
185
  state = {"scheduled": False}
174
186
 
175
- def capture(binding):
187
+ def capture(binding, steps=None):
176
188
  QtCore = _sys.modules[binding + ".QtCore"]
177
189
  QtGui = _sys.modules[binding + ".QtGui"]
178
190
  QtWidgets = _sys.modules.get(binding + ".QtWidgets")
179
191
  app = QtGui.QGuiApplication.instance()
180
192
  result = {"binding": binding, "windows": [], "screen": None, "platform": app.platformName()}
193
+ if steps is not None:
194
+ result["actions"] = steps
181
195
  screen = app.primaryScreen()
182
196
  if screen is not None:
183
197
  g, a = screen.geometry(), screen.availableGeometry()
@@ -257,6 +271,100 @@ def _lcode_shot():
257
271
  app.quit()
258
272
  QtCore.QTimer.singleShot(3000, lambda: _os._exit(0))
259
273
 
274
+ def text_of(widget):
275
+ for getter in ("text", "windowTitle", "title", "placeholderText"):
276
+ fn = getattr(widget, getter, None)
277
+ if callable(fn):
278
+ try:
279
+ text = str(fn() or "")
280
+ except Exception:
281
+ text = ""
282
+ if text:
283
+ return text
284
+ return ""
285
+
286
+ def act(binding, then):
287
+ """Take the steps one by one, pausing after each so the program reacts, then call then(log)."""
288
+ QtCore = _sys.modules[binding + ".QtCore"]
289
+ QtWidgets = _sys.modules[binding + ".QtWidgets"]
290
+ Qt = QtCore.Qt
291
+ try:
292
+ QTest = importlib.import_module(binding + ".QtTest").QTest
293
+ except Exception as e:
294
+ then([{"step": s["raw"], "error": "Qt's QtTest module isn't available (%s)" % e} for s in actions])
295
+ return
296
+ app = QtWidgets.QApplication.instance()
297
+
298
+ def enum(group, name): # scoped enums (PyQt6, PySide6), or flat ones (PyQt5, PySide2)
299
+ scope = getattr(Qt, group, None)
300
+ if scope is not None and hasattr(scope, name):
301
+ return getattr(scope, name)
302
+ return getattr(Qt, name)
303
+
304
+ def window():
305
+ tops = [w for w in app.topLevelWidgets() if w.isVisible() and w.width() > 1 and w.height() > 1]
306
+ active = app.activeWindow()
307
+ return active if active in tops else (tops[0] if tops else None)
308
+
309
+ def describe(widget):
310
+ text = text_of(widget)[:40]
311
+ return type(widget).__name__ + (' "%s"' % text if text else "")
312
+
313
+ def by_text(top, wanted):
314
+ wanted = wanted.casefold()
315
+ found = [c for c in top.findChildren(QtWidgets.QWidget)
316
+ if c.isVisible() and wanted in text_of(c).casefold()] # fmt: skip
317
+ return min(found, key=lambda c: c.width() * c.height()) if found else None
318
+
319
+ log = []
320
+
321
+ def step(i):
322
+ if i >= len(actions):
323
+ then(log)
324
+ return
325
+ s, note, pause = actions[i], {"step": actions[i]["raw"]}, pause_ms
326
+ try:
327
+ top = window()
328
+ if s["do"] == "wait":
329
+ pause = int(s["seconds"] * 1000)
330
+ elif top is None:
331
+ note["error"] = "no window to act on"
332
+ elif s["do"] in ("click", "double_click", "right_click"):
333
+ if "text" in s:
334
+ target = by_text(top, s["text"])
335
+ pos = target.rect().center() if target is not None else None
336
+ else:
337
+ point = QtCore.QPoint(s["x"], s["y"])
338
+ target = top.childAt(point) or top
339
+ pos = target.mapFrom(top, point)
340
+ if target is None:
341
+ note["error"] = 'no visible widget shows "%s"' % s["text"]
342
+ else:
343
+ button = enum("MouseButton", "RightButton" if s["do"] == "right_click" else "LeftButton")
344
+ none = enum("KeyboardModifier", "NoModifier")
345
+ at = target.mapTo(top, pos) # before the click moves things around
346
+ note["target"] = "%s at (%d, %d)" % (describe(target), at.x(), at.y())
347
+ click = QTest.mouseDClick if s["do"] == "double_click" else QTest.mouseClick
348
+ click(target, button, none, pos)
349
+ else:
350
+ target = app.focusWidget() or top
351
+ if s["do"] == "key":
352
+ mods = enum("KeyboardModifier", "NoModifier")
353
+ names = {"ctrl": "ControlModifier", "shift": "ShiftModifier", "alt": "AltModifier",
354
+ "meta": "MetaModifier"} # fmt: skip
355
+ for m in s["mods"]:
356
+ mods = mods | enum("KeyboardModifier", names[m])
357
+ QTest.keyClick(target, enum("Key", "Key_" + s["key"]), mods)
358
+ else:
359
+ QTest.keyClicks(target, s["text"])
360
+ note["target"] = describe(target)
361
+ except Exception as e: # one step failing doesn't stop the picture
362
+ note["error"] = "%s: %s" % (type(e).__name__, e)
363
+ log.append(note)
364
+ QtCore.QTimer.singleShot(pause, lambda: step(i + 1))
365
+
366
+ step(0)
367
+
260
368
  def patch(binding):
261
369
  """Wrap exec() of the application classes loaded so far (QtWidgets may come after QtGui)."""
262
370
  QtCore = _sys.modules.get(binding + ".QtCore")
@@ -275,14 +383,23 @@ def _lcode_shot():
275
383
  state["scheduled"] = True
276
384
  open(_os.path.join(out, "started"), "w").close() # the event loop runs: a capture is coming
277
385
 
278
- def run():
386
+ def run(steps=None):
279
387
  try:
280
- capture(binding)
388
+ capture(binding, steps)
281
389
  except Exception as e: # the program goes on; lcode reports what went wrong
282
390
  with open(_os.path.join(out, "qt.json"), "w") as f:
283
391
  json.dump({"error": "%s: %s" % (type(e).__name__, e)}, f)
284
392
 
285
- QtCore.QTimer.singleShot(wait_ms, run)
393
+ def start():
394
+ if not actions:
395
+ run()
396
+ return
397
+ try:
398
+ act(binding, run)
399
+ except Exception as e:
400
+ run([{"step": "actions", "error": "%s: %s" % (type(e).__name__, e)}])
401
+
402
+ QtCore.QTimer.singleShot(wait_ms, start)
286
403
 
287
404
  class Wrapped:
288
405
  """In place of exec(): schedules the capture, then runs the original, static or not."""
@@ -372,6 +489,69 @@ def hook_dir(tmp: Path) -> Path:
372
489
  # --------------------------------------------------------------------------------------------- running a program
373
490
 
374
491
 
492
+ def parse_actions(actions) -> list[dict]:
493
+ """The steps to take before the picture, from the tool's `actions` (a list, or one string split on ; and lines).
494
+
495
+ `click X,Y`, `double_click X,Y`, `right_click X,Y` (a point in the window), or the same with a widget's text
496
+ (`double_click "Camera 1"`); `key Escape` or `key ctrl+s`; `type some text`; `wait 2` (seconds).
497
+ """
498
+ if not actions:
499
+ return []
500
+ if isinstance(actions, str):
501
+ actions = [part for line in actions.splitlines() for part in line.split(";")]
502
+ steps = []
503
+ for raw in actions:
504
+ raw = str(raw).strip()
505
+ if not raw:
506
+ continue
507
+ verb, _, rest = raw.partition(" ")
508
+ verb, rest = verb.lower().replace("-", "_"), rest.strip()
509
+ if verb == "doubleclick":
510
+ verb = "double_click"
511
+ step: dict = {"do": verb, "raw": raw}
512
+ if verb in MOUSE_ACTIONS:
513
+ point = re.fullmatch(r"\(?\s*(-?\d+)\s*[, ]\s*(-?\d+)\s*\)?", rest)
514
+ if point:
515
+ step["x"], step["y"] = int(point.group(1)), int(point.group(2))
516
+ elif rest.strip("\"'"):
517
+ step["text"] = rest.strip("\"'")
518
+ else:
519
+ raise ShotError(f"{raw!r}: say where, as X,Y in the window or the widget's text, e.g. {verb} 200,150")
520
+ elif verb == "key":
521
+ parts = [p for p in re.split(r"[+\s]+", rest.lower()) if p]
522
+ if not parts:
523
+ raise ShotError(f"{raw!r}: name the key, e.g. key Escape or key ctrl+s")
524
+ *mods, key = parts
525
+ if any(m not in MODIFIERS for m in mods):
526
+ raise ShotError(f"{raw!r}: modifiers are {', '.join(MODIFIERS)}")
527
+ if key in KEY_NAMES:
528
+ key = KEY_NAMES[key]
529
+ elif re.fullmatch(r"f([1-9]|1[0-2])", key) or (len(key) == 1 and key.isalnum()):
530
+ key = key.upper()
531
+ else:
532
+ raise ShotError(f"{raw!r}: unknown key {key!r} (Escape, Return, Tab, Space, arrows, F1-F12, a letter)")
533
+ step["key"], step["mods"] = key, mods
534
+ elif verb == "type":
535
+ if not rest:
536
+ raise ShotError(f"{raw!r}: give the text to type")
537
+ step["text"] = rest
538
+ elif verb == "wait":
539
+ try:
540
+ step["seconds"] = max(0.0, min(float(rest or 1), 10.0))
541
+ except ValueError as e:
542
+ raise ShotError(f"{raw!r}: wait takes seconds, e.g. wait 2") from e
543
+ else:
544
+ raise ShotError(f"{raw!r}: actions are click, double_click, right_click, key, type and wait")
545
+ steps.append(step)
546
+ if len(steps) > MAX_ACTIONS:
547
+ raise ShotError(f"at most {MAX_ACTIONS} actions in one screenshot")
548
+ return steps
549
+
550
+
551
+ def actions_time(steps: list[dict]) -> float:
552
+ return sum(s.get("seconds", 0) for s in steps) + ACTION_PAUSE * len(steps)
553
+
554
+
375
555
  def run_program(command: str, cwd: Path, env: dict[str, str], log: Path) -> subprocess.Popen:
376
556
  out = log.open("wb")
377
557
  try:
@@ -405,6 +585,27 @@ def stop(proc: subprocess.Popen | None) -> None:
405
585
  proc.wait(timeout=3)
406
586
 
407
587
 
588
+ QT_WARNING = re.compile(r"^(?:Q[A-Za-z0-9]+::\w+|qt\.[\w.]+:|QML |Qt[: ])")
589
+ NOT_A_PROBLEM = re.compile(
590
+ r"propagateSizeHints|QFont::|This plugin does not support|qt\.qpa\.fonts"
591
+ ) # offscreen platform noise
592
+ MAX_WARNINGS = 6
593
+
594
+
595
+ def qt_warnings(log: Path) -> list[str]:
596
+ """Qt's own warnings in everything the program printed (not just the end that's shown), without repeats."""
597
+ try:
598
+ text = log.read_bytes().decode(errors="replace")
599
+ except OSError:
600
+ return []
601
+ found: list[str] = []
602
+ for line in text.splitlines():
603
+ line = line.strip()
604
+ if QT_WARNING.match(line) and not NOT_A_PROBLEM.search(line) and line[:300] not in found:
605
+ found.append(line[:300])
606
+ return found[:MAX_WARNINGS]
607
+
608
+
408
609
  def printed(log: Path) -> str:
409
610
  try:
410
611
  text = log.read_bytes().decode(errors="replace")
@@ -423,7 +624,9 @@ def base_env(hook: Path) -> dict[str, str]:
423
624
  return env
424
625
 
425
626
 
426
- def qt_offscreen(command: str, cwd: Path, screen: tuple[int, int], wait: int, tmp: Path, cancel=None) -> Shot:
627
+ def qt_offscreen(
628
+ command: str, cwd: Path, screen: tuple[int, int], wait: int, tmp: Path, cancel=None, actions: list | None = None
629
+ ) -> Shot:
427
630
  """Run a Python Qt program on Qt's offscreen platform and capture its windows."""
428
631
  config = tmp / "screens.json"
429
632
  w, h = screen
@@ -453,12 +656,14 @@ def qt_offscreen(command: str, cwd: Path, screen: tuple[int, int], wait: int, tm
453
656
  LCODE_SHOT_OUT=str(out),
454
657
  LCODE_SHOT_WAIT=str(wait),
455
658
  LCODE_SHOT_QUIT="1",
659
+ LCODE_SHOT_ACTIONS=json.dumps(actions or []),
660
+ LCODE_SHOT_PAUSE=str(ACTION_PAUSE),
456
661
  )
457
662
  env.pop("DISPLAY", None)
458
663
  log = tmp / "output.txt"
459
664
  proc = run_program(command, cwd, env, log)
460
665
  try:
461
- deadline = time.time() + wait + GRACE
666
+ deadline = time.time() + wait + actions_time(actions or []) + GRACE
462
667
  while time.time() < deadline and proc.poll() is None and not (out / "qt.json").exists():
463
668
  if cancel is not None and cancel():
464
669
  raise KeyboardInterrupt
@@ -482,6 +687,9 @@ def qt_offscreen(command: str, cwd: Path, screen: tuple[int, int], wait: int, tm
482
687
  if image is None and not windows:
483
688
  raise ShotError("the program started Qt but showed no window")
484
689
  report = qt_report(data)
690
+ warnings = qt_warnings(log)
691
+ if warnings:
692
+ report[:0] = ["Qt printed warnings (they often name the cause):", *[f"- {line}" for line in warnings]]
485
693
  if shot_screen != screen: # Qt 5's offscreen platform has a fixed screen
486
694
  report.append(
487
695
  f"(Qt used a {shot_screen[0]}x{shot_screen[1]} screen offscreen, not the {screen[0]}x{screen[1]} asked "
@@ -509,8 +717,17 @@ def qt_report(data: dict) -> list[str]:
509
717
  screen = data.get("screen") or [0, 0]
510
718
  sw, sh = screen
511
719
  windows = data.get("windows") or []
720
+ if data.get("actions"):
721
+ lines.append("Steps, before the picture:")
722
+ for note in data["actions"]:
723
+ done = (
724
+ f"couldn't: {note['error']}"
725
+ if note.get("error")
726
+ else (f"on {note['target']}" if note.get("target") else "done")
727
+ )
728
+ lines.append(f"- {note['step']}: {done}")
512
729
  if not windows:
513
- return ["No visible Qt window."]
730
+ return [*lines, "No visible Qt window."]
514
731
  for win in windows:
515
732
  title = f'"{win["title"]}" ' if win.get("title") else ""
516
733
  x, y, w, h = win["x"], win["y"], win["width"], win["height"]
@@ -1059,12 +1276,15 @@ def take(
1059
1276
  full_page: bool = False,
1060
1277
  mode: str = "auto",
1061
1278
  cancel=None,
1279
+ actions: list | None = None,
1062
1280
  ) -> Shot:
1063
- """Take the screenshot. `cancel` is checked while waiting (return True to stop)."""
1281
+ """Take the screenshot. `cancel` is checked while waiting (return True to stop); `actions` come first."""
1064
1282
  wait = max(0, min(int(wait), MAX_WAIT))
1065
1283
  path = new_path(folder, label_for(command, url))
1066
1284
  with tempfile.TemporaryDirectory(prefix="lcode-shot-") as name:
1067
1285
  tmp = Path(name)
1286
+ if actions and url:
1287
+ raise ShotError("actions work for Python Qt programs (PyQt, PySide), not web pages yet")
1068
1288
  if url:
1069
1289
  url = normalize_url(url, cwd)
1070
1290
  server = None
@@ -1086,7 +1306,14 @@ def take(
1086
1306
  raise ShotError("give a command that starts the program, or a url")
1087
1307
  if mode == "auto":
1088
1308
  mode = "qt" if looks_like_qt(command, cwd) or not xvfb_available() else "xvfb"
1089
- shot = (qt_offscreen if mode == "qt" else virtual_display)(command, cwd, screen, wait, tmp, cancel)
1309
+ if actions and mode != "qt":
1310
+ raise ShotError(
1311
+ "actions work for Python Qt programs (PyQt, PySide) only: take this one's screenshot without them"
1312
+ )
1313
+ if mode == "qt":
1314
+ shot = qt_offscreen(command, cwd, screen, wait, tmp, cancel, actions)
1315
+ else:
1316
+ shot = virtual_display(command, cwd, screen, wait, tmp, cancel)
1090
1317
  shutil.copyfile(shot.image, path)
1091
1318
  kept = []
1092
1319
  for i, window in enumerate(shot.windows):
lcode/tools.py CHANGED
@@ -192,7 +192,8 @@ SCHEMAS = [
192
192
  "widgets (Qt) or of the page, and the browser's console errors. Use it to check GUI and layout work instead "
193
193
  "of guessing. Python Qt programs run offscreen; other desktop programs on a virtual display (Linux); web pages "
194
194
  "in a headless browser. For a web app, give the command that starts its server and the url (lcode starts and "
195
- "stops it); if a server is already running, or for an .html file, give only the url.",
195
+ "stops it); if a server is already running, or for an .html file, give only the url. For something that "
196
+ "only shows after a click or a key (a Python Qt program), give `actions`: they run first, then the picture.",
196
197
  {
197
198
  "command": {"type": "string", "description": "Command that starts the program, or the web server"},
198
199
  "url": {"type": "string", "description": "Page to open: a URL, localhost:PORT/path or an .html file"},
@@ -203,6 +204,14 @@ SCHEMAS = [
203
204
  "screen": {"type": "string", "description": "Screen (or browser window) size, WxH (default 1920x1080)"},
204
205
  "wait": {"type": "integer", "description": "Seconds to let it start and draw first (default 3)"},
205
206
  "full_page": {"type": "boolean", "description": "Web pages: the whole page, not just the window"},
207
+ "actions": {
208
+ "type": "array",
209
+ "items": {"type": "string"},
210
+ "description": "Python Qt programs: steps to take before the picture, as a user would: "
211
+ '"click X,Y", "double_click X,Y", "right_click X,Y" (a point in the window, as the sizes show), '
212
+ 'or with the widget\'s text: "double_click Camera 1"; "key Escape", "key ctrl+s"; "type some text"; '
213
+ '"wait 2". The result says what each step hit.',
214
+ },
206
215
  "mode": {
207
216
  "type": "string",
208
217
  "enum": ["auto", "qt", "xvfb"],
@@ -698,6 +707,7 @@ class Toolbox:
698
707
  wait: int = screenshots.DEFAULT_WAIT,
699
708
  full_page: bool = False,
700
709
  mode: str = "auto",
710
+ actions: list | str | None = None,
701
711
  ) -> str:
702
712
  command, url = (command or "").strip(), (url or "").strip()
703
713
  if not command and not url:
@@ -708,6 +718,7 @@ class Toolbox:
708
718
  size = screenshots.parse_screen(screen)
709
719
  if url:
710
720
  url = screenshots.normalize_url(url, self.agent.cwd)
721
+ steps = screenshots.parse_actions(actions)
711
722
  except screenshots.ShotError as e:
712
723
  raise ToolError(str(e)) from e
713
724
  if command:
@@ -748,6 +759,7 @@ class Toolbox:
748
759
  full_page=full_page,
749
760
  mode=mode,
750
761
  cancel=cancelled,
762
+ actions=steps,
751
763
  )
752
764
  except screenshots.ShotError as e:
753
765
  raise ToolError(f"no screenshot: {e}") from e
@@ -762,6 +774,8 @@ class Toolbox:
762
774
  lines.append(f"What the {'server' if url else 'program'} printed:\n{shot.output}")
763
775
  if self.agent.vision_model():
764
776
  what = f"the page {url}" if url else f"the program `{command}` on a {w}x{h} screen"
777
+ if steps:
778
+ what += ", after these steps: " + "; ".join(s["raw"] for s in steps)
765
779
  ask = f"This is a screenshot of {what}." + (f" {question.strip()}" if question.strip() else "")
766
780
  try:
767
781
  seen = self.agent.look(shot.image, ask)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.37.0
3
+ Version: 0.39.0
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -1,21 +1,21 @@
1
- lcode/__init__.py,sha256=lKIIi9DSWXNWEjJvCuOOtYqAkmR0gtWHsaKllXOu7vc,473
1
+ lcode/__init__.py,sha256=_Rgyn4icfC6nuHTEHeJBx-sny88eraeJgPJOpzzaqK0,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=vjWiK79BgUTMAbeC6SPslGaYT86YELwTHIGB21tc4to,84751
6
- lcode/api.py,sha256=mIfmiN3jDv8jg-HOJtqfr-MQ3cOI8nfFmlYYRZR1r1Y,16164
5
+ lcode/agent.py,sha256=BcuJ79IdVodbOmHhaCbnkH6bOU8M_Prg0awBM8YYfl8,86830
6
+ lcode/api.py,sha256=UV91TGBUVUINM9TVNWbkLmX-DwtoYD06dcqfEhtGz7s,16298
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
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/checks.py,sha256=iPaNaGOGo7zHc_mue41ELEHSNq0VIH_xVYL_bHkYLXU,9373
14
- lcode/cli.py,sha256=mJBM3Loqs9kOE94IRe00d1A1izW5caAjSR7ITUxC27I,45042
13
+ lcode/checks.py,sha256=haYYIPhadKavGYEhh8YWAaWrPMcfzrpT2i6638f4xjQ,12933
14
+ lcode/cli.py,sha256=rRD462mr0JkKE0h83aN2617uNfa5kjIe_q3ZgxbvvEI,45560
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/config.py,sha256=hAjuHg7plRxwFgdD4gOGnDH8-FwyT7NRV0YI5grd0qE,12604
18
+ lcode/config.py,sha256=SCYQYgRRKKy-C6ktPprjIAEHQcTvmkEMy-Q9g98Kw5E,12974
19
19
  lcode/context.py,sha256=wgK16ZcNNnq4ngHx0EmtyKHDFTImciGEmtC5NP8tKJQ,20435
20
20
  lcode/escalate.py,sha256=f-_6_moPaHi2kLuJw2JtkzvTkCl6DoBQgYBOCYLhunM,15120
21
21
  lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
@@ -29,23 +29,24 @@ lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
29
29
  lcode/matching.py,sha256=2N3_qwKJ5gd8el2m_y_egKkFM9rsF1eUhYJcRCVIp98,14015
30
30
  lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
31
31
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
32
+ lcode/modes.py,sha256=gVYuGDP0IGfRPgBYJinEU8DYIrKjFbgjb0gjK3n9jx8,4202
32
33
  lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
33
34
  lcode/ollama.py,sha256=xFHU2XW47ANJ34-1aeSMQVj28GhqcWiFi6zASH9FiYY,8053
34
35
  lcode/permissions.py,sha256=q7CjDsc_Xrblw7t3_BbxA5LUjxFSswGs1RiJmtMi0po,12288
35
36
  lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
36
37
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
37
- lcode/repeats.py,sha256=vpXR5pzKX9GsTzQONGS30V-rEU3gunaj_hBzJxg43Q4,5542
38
- lcode/repl.py,sha256=elnJeSQYt0oEtE14UzW2k-fkDHVFr_Lf2L6XWFo0ne0,56891
38
+ lcode/repeats.py,sha256=OTrPdb6BKArDLQEgHaXmJZ-tIFsY_1bk4P0wzzANKLY,5964
39
+ lcode/repl.py,sha256=NpYV0D6YoCsddV2YGmIdseY57mowx9Nv3TwH82R_o4U,59075
39
40
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
40
41
  lcode/residency.py,sha256=B9pwqG6yx5aQD2M5xyQ-bMnYmhD7fv3wCVw_X6VIeAo,7012
41
42
  lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
42
43
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
43
- lcode/screenshots.py,sha256=bcWSXS4xTsBk8HhpjBy5jvu705uH9qV8bXANr--Mf2M,46405
44
+ lcode/screenshots.py,sha256=pmrw_tp8eXzNyCZa5uot5rTgsxqsi3AdmkGn-XF9saY,57056
44
45
  lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
45
46
  lcode/sessions.py,sha256=ojkpP453JyIll8_iFeJgW4BG4Zz2FKMRsLiij6spiTo,4185
46
47
  lcode/steer.py,sha256=0vaqLxgRKgC0yGp8xoQfV1f9VEOzH3U4rCIb74eFnZ8,12127
47
48
  lcode/subagents.py,sha256=thR2ZzFZ1DeoAf0Ctr_oeKjYajhvd3uRs2kGdw6LlJM,29349
48
- lcode/tools.py,sha256=glp18sbnxaK5iugDxrCFlDaT0SEbO-GTOL_-O3Afp44,68005
49
+ lcode/tools.py,sha256=lpCIlaabydbmutaXWuOZWS4X-Z_7aIsyNUQdHJzkSC4,68902
49
50
  lcode/unreachable.py,sha256=TXrv41ljwX_amxdcf4CY6qGF2YkWetORvPUSvwceJXI,8685
50
51
  lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
51
52
  lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
@@ -53,9 +54,9 @@ lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
53
54
  lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
54
55
  lcode/mcp/auth.py,sha256=vyH3oolgIBNJLYabr2h5Soui99NTY1QxjQEgtczfF6A,16655
55
56
  lcode/mcp/catalog.py,sha256=I_O10N7cBl0ZW6O7x2kwKn89pvEXUXODUh8FsPXnB-E,3790
56
- lcode/mcp/catalog.toml,sha256=N8MKyyS_FeCo-RbV3RVZ3ZODro2yk55yqUlymXsWdcQ,12641
57
+ lcode/mcp/catalog.toml,sha256=LTby0I98ajZS2aXZpaBq09p1nnt77xs6bngpVMWO-t4,10324
57
58
  lcode/mcp/client.py,sha256=3Gen7zRvWehngXn_dAMOAdKqFkFkQZIQW96hQcW4N4k,7119
58
- lcode/mcp/commands.py,sha256=mnp0G-GW5ZsARi2tkHuT3hl883sBGFUVRAV3AapA5oU,16478
59
+ lcode/mcp/commands.py,sha256=hOND7SsJjz5Hp2CY81PHPsB4R9i8CwhBQeQi-oB8Wg4,16721
59
60
  lcode/mcp/config.py,sha256=Ej6WX2b_55a84H5WdAwSk9DzERKoq5ifUBQ9uDgSG_o,8780
60
61
  lcode/mcp/http.py,sha256=sG9MajwU4_6sqvZhwD4WfyzRKzaR_Lc8C02jwePz18E,7380
61
62
  lcode/mcp/manager.py,sha256=79nNWoT5pxgoQD_Pv4ZH0obHIm7rQ-LyB8OmJnQgc6k,15104
@@ -66,8 +67,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
66
67
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
67
68
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
68
69
  lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
69
- lcode_cli-0.37.0.dist-info/METADATA,sha256=fgaRfrIMEYuJXyDpy8Kj18AL7S2IybZqNmvd09c6IgI,13141
70
- lcode_cli-0.37.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
71
- lcode_cli-0.37.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
72
- lcode_cli-0.37.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
73
- lcode_cli-0.37.0.dist-info/RECORD,,
70
+ lcode_cli-0.39.0.dist-info/METADATA,sha256=JCZieRCoNShMIppO-MNzLvxmd240V4EFbdPmAbPWN-I,13141
71
+ lcode_cli-0.39.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
72
+ lcode_cli-0.39.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
73
+ lcode_cli-0.39.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
74
+ lcode_cli-0.39.0.dist-info/RECORD,,