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 +1 -1
- lcode/agent.py +37 -4
- lcode/api.py +3 -0
- lcode/checks.py +95 -17
- lcode/cli.py +12 -0
- lcode/config.py +5 -0
- lcode/mcp/catalog.toml +0 -48
- lcode/mcp/commands.py +6 -0
- lcode/modes.py +102 -0
- lcode/repeats.py +11 -2
- lcode/repl.py +57 -10
- lcode/screenshots.py +237 -10
- lcode/tools.py +15 -1
- {lcode_cli-0.37.0.dist-info → lcode_cli-0.39.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.37.0.dist-info → lcode_cli-0.39.0.dist-info}/RECORD +18 -17
- {lcode_cli-0.37.0.dist-info → lcode_cli-0.39.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.37.0.dist-info → lcode_cli-0.39.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.37.0.dist-info → lcode_cli-0.39.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
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() #
|
|
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
|
-
|
|
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
|
|
65
|
-
"""
|
|
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
|
-
|
|
86
|
+
found: list[Path] = []
|
|
87
|
+
|
|
88
|
+
def walk(folder: Path, depth: int) -> None:
|
|
69
89
|
try:
|
|
70
|
-
entries =
|
|
90
|
+
entries = sorted(folder.iterdir())
|
|
71
91
|
except OSError:
|
|
72
|
-
return
|
|
73
|
-
if
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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"{
|
|
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"{
|
|
122
|
+
return Check(f"{py} -m unittest", "test files")
|
|
94
123
|
if (root / "tests").is_dir():
|
|
95
|
-
return Check(f"{
|
|
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.
|
|
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": "
|
|
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__}[/] —
|
|
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[/] {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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=
|
|
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=
|
|
6
|
-
lcode/api.py,sha256=
|
|
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=
|
|
14
|
-
lcode/cli.py,sha256=
|
|
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=
|
|
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=
|
|
38
|
-
lcode/repl.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
70
|
-
lcode_cli-0.
|
|
71
|
-
lcode_cli-0.
|
|
72
|
-
lcode_cli-0.
|
|
73
|
-
lcode_cli-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|