lcode-cli 0.38.0__py3-none-any.whl → 0.40.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 +9 -0
- lcode/api.py +43 -11
- lcode/cli.py +85 -0
- lcode/comfy.py +658 -0
- lcode/config.py +7 -0
- lcode/mcp/catalog.toml +0 -48
- lcode/mcp/commands.py +6 -0
- lcode/media.py +262 -0
- lcode/modes.py +124 -0
- lcode/repl.py +116 -19
- lcode/workflows/__init__.py +0 -0
- lcode/workflows/flux2-klein-4b-t2i.json +38 -0
- {lcode_cli-0.38.0.dist-info → lcode_cli-0.40.0.dist-info}/METADATA +4 -1
- {lcode_cli-0.38.0.dist-info → lcode_cli-0.40.0.dist-info}/RECORD +18 -13
- {lcode_cli-0.38.0.dist-info → lcode_cli-0.40.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.38.0.dist-info → lcode_cli-0.40.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.38.0.dist-info → lcode_cli-0.40.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -304,6 +304,10 @@ class Progress:
|
|
|
304
304
|
class Settings:
|
|
305
305
|
model: str # Ollama model name to call
|
|
306
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"
|
|
309
|
+
comfyui_path: str | None = None # a ComfyUI install lcode doesn't find by itself (lcode.comfy)
|
|
310
|
+
media_dir: str = ".lcode/media" # where image and video mode save their files (lcode.media)
|
|
307
311
|
num_batch: int | None = None
|
|
308
312
|
keep_alive: str = "30m"
|
|
309
313
|
think: bool = True
|
|
@@ -368,6 +372,8 @@ class Agent:
|
|
|
368
372
|
self.session_name = ""
|
|
369
373
|
self.session_title = ""
|
|
370
374
|
self.interactive = True # False for `lcode -p`: no end-of-session questions
|
|
375
|
+
self.comfy_process = None # the ComfyUI lcode started for image and video mode, stopped when it exits
|
|
376
|
+
self.no_coding_model = "" # why there's no coding model, when lcode started in image or video mode without
|
|
371
377
|
self.reflected = 0 # messages before this index were already checked for notes to remember
|
|
372
378
|
self._memory: memory_notes.Memory | None = None
|
|
373
379
|
self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
|
|
@@ -680,6 +686,7 @@ class Agent:
|
|
|
680
686
|
data = {
|
|
681
687
|
"cwd": str(self.workspace), # where `lcode -c` there finds it, wherever the shell moved
|
|
682
688
|
"model": self.settings.model,
|
|
689
|
+
"mode": self.settings.mode,
|
|
683
690
|
"name": self.session_name,
|
|
684
691
|
"title": self.session_title,
|
|
685
692
|
"messages": self.messages,
|
|
@@ -726,6 +733,8 @@ class Agent:
|
|
|
726
733
|
self.session_id = info.id
|
|
727
734
|
self.checkpoints.load(info.id, data.get("checkpoints") or [])
|
|
728
735
|
self.session_name = data.get("name", "")
|
|
736
|
+
if data.get("mode") in ("code", "image", "video"):
|
|
737
|
+
self.settings.mode = data["mode"] # a session belongs to the mode it was made in
|
|
729
738
|
self.session_title = data.get("title") or sessions.title_from(self.messages)
|
|
730
739
|
self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
|
|
731
740
|
self.tools.todos = list(data.get("todos") or [])
|
lcode/api.py
CHANGED
|
@@ -15,6 +15,7 @@ A session is one conversation: each `run` continues it. `stream` yields the same
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
+
import contextlib
|
|
18
19
|
import io
|
|
19
20
|
import queue
|
|
20
21
|
import threading
|
|
@@ -54,6 +55,7 @@ class Options:
|
|
|
54
55
|
model: str | None = None
|
|
55
56
|
context: int | str | None = None
|
|
56
57
|
permission_mode: str | None = None # ask, plan, auto-edit or yolo
|
|
58
|
+
mode: str | None = None # code, image or video
|
|
57
59
|
allowed_tools: list[str] | None = None # only these tools (default: all)
|
|
58
60
|
max_steps: int | None = None # model steps per request
|
|
59
61
|
think: bool | None = None
|
|
@@ -129,6 +131,24 @@ def switch(wanted: bool | None, configured: str, default_on: str) -> str:
|
|
|
129
131
|
return configured if configured != "off" else default_on
|
|
130
132
|
|
|
131
133
|
|
|
134
|
+
def coding_model(ollama, options: Options, cfg: dict, requested: int | None, hw: Hardware, console: Console):
|
|
135
|
+
"""The coding model to use, its catalog entry and the context window; SetupError when there's none."""
|
|
136
|
+
from lcode import cli
|
|
137
|
+
|
|
138
|
+
check_server(ollama, hw)
|
|
139
|
+
try:
|
|
140
|
+
model, spec = cli.resolve_model(ollama, options.model or cfg["model"])
|
|
141
|
+
except cli.NotInstalled as e:
|
|
142
|
+
first_run = not options.model and cfg["model"] == config.DEFAULT_MODEL and not config.CONFIG_PATH.exists()
|
|
143
|
+
if first_run and not backends.is_openai(ollama):
|
|
144
|
+
raise SetupError("lcode isn't set up yet. Run: lcode setup") from e
|
|
145
|
+
raise SetupError(str(e)) from e
|
|
146
|
+
context, note = cli.choose_context(ollama, model, spec, requested, hw)
|
|
147
|
+
if note:
|
|
148
|
+
console.print(f"[yellow]Context {config.format_tokens(context)}: {note}[/]")
|
|
149
|
+
return model, spec, context
|
|
150
|
+
|
|
151
|
+
|
|
132
152
|
def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | None = None) -> Agent:
|
|
133
153
|
"""An agent set up the way `lcode` sets up a session: model, context, hooks, language servers, MCP."""
|
|
134
154
|
from lcode import catalog, cli, extensions, hooks
|
|
@@ -152,17 +172,14 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
152
172
|
except OllamaError as e:
|
|
153
173
|
raise SetupError(str(e)) from e
|
|
154
174
|
hw = hw or detect()
|
|
155
|
-
|
|
175
|
+
no_coding_model = "" # why, when image or video mode starts without one
|
|
156
176
|
try:
|
|
157
|
-
model, spec =
|
|
158
|
-
except
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
context, note = cli.choose_context(ollama, model, spec, requested, hw)
|
|
164
|
-
if note:
|
|
165
|
-
console.print(f"[yellow]Context {config.format_tokens(context)}: {note}[/]")
|
|
177
|
+
model, spec, context = coding_model(ollama, options, cfg, requested, hw, console)
|
|
178
|
+
except SetupError as e:
|
|
179
|
+
if (options.mode or cfg["mode"]) == "code":
|
|
180
|
+
raise
|
|
181
|
+
# Image and video mode don't need the coding model: start without it, and say so on /mode code.
|
|
182
|
+
model, spec, context, no_coding_model = options.model or cfg["model"], None, requested or 32768, str(e)
|
|
166
183
|
num_batch = cfg["num_batch"]
|
|
167
184
|
if num_batch is None and spec and spec.num_batch:
|
|
168
185
|
if model == spec.local_name:
|
|
@@ -179,6 +196,10 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
179
196
|
think=cfg["think"] if options.think is None else options.think,
|
|
180
197
|
show_thinking=options.show_thinking,
|
|
181
198
|
permission_mode=options.permission_mode or cfg["permission_mode"],
|
|
199
|
+
mode=options.mode or cfg["mode"],
|
|
200
|
+
comfyui_url=cfg["comfyui_url"],
|
|
201
|
+
comfyui_path=cfg["comfyui_path"],
|
|
202
|
+
media_dir=cfg["media_dir"],
|
|
182
203
|
web=switch(options.web, cfg["web"], "on"),
|
|
183
204
|
search_backend=cfg["search_backend"],
|
|
184
205
|
searxng_url=cfg["searxng_url"],
|
|
@@ -208,6 +229,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
208
229
|
)
|
|
209
230
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
210
231
|
agent.hardware = hw
|
|
232
|
+
agent.no_coding_model = no_coding_model
|
|
211
233
|
agent.autosave = True
|
|
212
234
|
agent.interactive = options.interactive
|
|
213
235
|
if options.max_steps:
|
|
@@ -247,15 +269,25 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
247
269
|
|
|
248
270
|
|
|
249
271
|
def close_agent(agent: Agent) -> None:
|
|
272
|
+
"""Stop what lcode started (background jobs, servers, the sandbox, ComfyUI), then say so: the terminal may be
|
|
273
|
+
gone already (closed), and saying it then fails."""
|
|
274
|
+
from lcode import media
|
|
275
|
+
|
|
276
|
+
said = []
|
|
250
277
|
stopped = agent.jobs.stop_all()
|
|
251
278
|
if stopped:
|
|
252
|
-
|
|
279
|
+
said.append(f"Stopped {stopped} background job(s).")
|
|
280
|
+
if media.stop_engine(agent):
|
|
281
|
+
said.append("Stopped ComfyUI.")
|
|
253
282
|
if agent.lsp is not None:
|
|
254
283
|
agent.lsp.close()
|
|
255
284
|
if agent.mcp:
|
|
256
285
|
agent.mcp.close()
|
|
257
286
|
if agent.sandbox:
|
|
258
287
|
agent.sandbox.stop()
|
|
288
|
+
for line in said:
|
|
289
|
+
with contextlib.suppress(OSError):
|
|
290
|
+
agent.console.print(f"[dim]{line}[/]")
|
|
259
291
|
|
|
260
292
|
|
|
261
293
|
# ----------------------------------------------------------------------------- running
|
lcode/cli.py
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import contextlib
|
|
6
7
|
import platform
|
|
7
8
|
import re
|
|
8
9
|
import shutil
|
|
10
|
+
import signal
|
|
9
11
|
import sys
|
|
10
12
|
import tempfile
|
|
11
13
|
import time
|
|
@@ -466,6 +468,7 @@ def cmd_doctor(args) -> None:
|
|
|
466
468
|
except ValueError as e:
|
|
467
469
|
line("MCP", str(e), False)
|
|
468
470
|
ok = False
|
|
471
|
+
line("ComfyUI", *comfyui_status(cfg))
|
|
469
472
|
if cfg["sandbox"] == "off":
|
|
470
473
|
line("Sandbox", "off (lcode config set sandbox docker)", True)
|
|
471
474
|
else:
|
|
@@ -714,6 +717,7 @@ def cmd_chat(args) -> None:
|
|
|
714
717
|
model=args.model,
|
|
715
718
|
context=args.context,
|
|
716
719
|
permission_mode=mode,
|
|
720
|
+
mode=args.mode,
|
|
717
721
|
allowed_tools=tools,
|
|
718
722
|
max_steps=args.max_steps,
|
|
719
723
|
think=False if args.no_think else None,
|
|
@@ -732,7 +736,10 @@ def cmd_chat(args) -> None:
|
|
|
732
736
|
print(json.dumps({"type": "result", "status": "error", "error": str(e)}), flush=True)
|
|
733
737
|
sys.exit(1)
|
|
734
738
|
fail(str(e))
|
|
739
|
+
exit_on_hangup()
|
|
735
740
|
exit_code = 0
|
|
741
|
+
if args.prompt and agent.settings.mode != "code":
|
|
742
|
+
media_request(agent, args.prompt, out, quiet) # makes the image or video, then exits
|
|
736
743
|
if args.escalate and args.prompt:
|
|
737
744
|
agent.escalate_approved = True # the flag is the user's OK for this run
|
|
738
745
|
try:
|
|
@@ -754,6 +761,83 @@ def cmd_chat(args) -> None:
|
|
|
754
761
|
sys.exit(exit_code)
|
|
755
762
|
|
|
756
763
|
|
|
764
|
+
def exit_on_hangup() -> None:
|
|
765
|
+
"""A closed terminal (SIGHUP) or a plain kill (SIGTERM) ends lcode the usual way: it saves the session and stops
|
|
766
|
+
what it started (ComfyUI, MCP servers, background jobs) instead of leaving them running."""
|
|
767
|
+
|
|
768
|
+
def leave(signum, frame):
|
|
769
|
+
raise SystemExit(128 + signum)
|
|
770
|
+
|
|
771
|
+
for name in ("SIGHUP", "SIGTERM"):
|
|
772
|
+
sig = getattr(signal, name, None)
|
|
773
|
+
if sig is not None:
|
|
774
|
+
with contextlib.suppress(ValueError, OSError): # only the main thread can
|
|
775
|
+
signal.signal(sig, leave)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def comfyui_status(cfg: dict) -> tuple[str, bool | None]:
|
|
779
|
+
"""lcode doctor's ComfyUI line: running or found, and whether the image model is there."""
|
|
780
|
+
from lcode import comfy, media
|
|
781
|
+
|
|
782
|
+
workflow = comfy.load_workflow(media.IMAGE_WORKFLOW)
|
|
783
|
+
server = comfy.Server(cfg["comfyui_url"])
|
|
784
|
+
stats = server.stats()
|
|
785
|
+
install = None if stats else comfy.find_install(cfg["comfyui_path"])
|
|
786
|
+
if not stats and install is None:
|
|
787
|
+
return "not found: image mode offers to install it (or lcode config set comfyui_path <folder>)", None
|
|
788
|
+
if stats:
|
|
789
|
+
where = f"{(stats.get('system') or {}).get('comfyui_version', '?')} running at {server.url}"
|
|
790
|
+
installed = {folder: set(server.models(folder)) for folder in workflow.models}
|
|
791
|
+
else:
|
|
792
|
+
where = f"{install.path} (lcode starts it in image mode)"
|
|
793
|
+
installed = {folder: set(comfy.local_models(install, folder)) for folder in workflow.models}
|
|
794
|
+
missing = [f"{f}/{names[0]}" for f, names in workflow.models.items() if not installed[f] & set(names)]
|
|
795
|
+
if missing:
|
|
796
|
+
return f"{where} · {workflow.name} not installed (missing {', '.join(missing)})", None
|
|
797
|
+
return f"{where} · image model: {workflow.name}", True
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def media_request(agent, prompt: str, out: Console, quiet: bool) -> None:
|
|
801
|
+
"""`lcode --mode image -p "…"`: make the image, say where it went (or, with --output json, a result object)."""
|
|
802
|
+
import json
|
|
803
|
+
|
|
804
|
+
from lcode import api, media, modes
|
|
805
|
+
|
|
806
|
+
if agent.settings.mode == "video":
|
|
807
|
+
message = modes.COMING["video"]
|
|
808
|
+
if quiet:
|
|
809
|
+
print(json.dumps({"type": "result", "status": "error", "error": message}), flush=True)
|
|
810
|
+
else:
|
|
811
|
+
out.print(f"[yellow]{escape(message)}[/]")
|
|
812
|
+
api.close_agent(agent)
|
|
813
|
+
sys.exit(2)
|
|
814
|
+
try:
|
|
815
|
+
made = media.generate_image(agent, prompt)
|
|
816
|
+
except (media.MediaError, KeyboardInterrupt) as e:
|
|
817
|
+
message = str(e) or "Cancelled."
|
|
818
|
+
if quiet:
|
|
819
|
+
print(json.dumps({"type": "result", "status": "error", "error": message}), flush=True)
|
|
820
|
+
else:
|
|
821
|
+
out.print(f"[red]{escape(message)}[/]")
|
|
822
|
+
api.close_agent(agent)
|
|
823
|
+
sys.exit(1)
|
|
824
|
+
if quiet:
|
|
825
|
+
result = {
|
|
826
|
+
"type": "result",
|
|
827
|
+
"status": "success",
|
|
828
|
+
"mode": "image",
|
|
829
|
+
"files": [str(path) for path in made.files],
|
|
830
|
+
"model": made.model,
|
|
831
|
+
"seed": made.seed,
|
|
832
|
+
"width": made.width,
|
|
833
|
+
"height": made.height,
|
|
834
|
+
"seconds": round(made.seconds, 1),
|
|
835
|
+
}
|
|
836
|
+
print(json.dumps(result, ensure_ascii=False), flush=True)
|
|
837
|
+
api.close_agent(agent)
|
|
838
|
+
sys.exit(0)
|
|
839
|
+
|
|
840
|
+
|
|
757
841
|
def compact_tokens(value: str) -> int:
|
|
758
842
|
"""`--compact-at`: a token count such as 64k, or 0."""
|
|
759
843
|
try:
|
|
@@ -798,6 +882,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
798
882
|
help="work in a git worktree of its own, on branch NAME (new, or continue one); removed if nothing changed",
|
|
799
883
|
)
|
|
800
884
|
parser.add_argument("--plan", action="store_true", help="start in plan mode: agree on a plan before any change")
|
|
885
|
+
parser.add_argument("--mode", choices=config.WORK_MODES, help="what lcode makes: code (default), image or video")
|
|
801
886
|
parser.add_argument("--auto-edit", action="store_true", help="apply file edits without asking")
|
|
802
887
|
parser.add_argument("--yolo", action="store_true", help="never ask for permission (edits and commands)")
|
|
803
888
|
parser.add_argument(
|