lcode-cli 0.5.0__py3-none-any.whl → 0.6.1__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
@@ -1,3 +1,3 @@
1
1
  """lcode — a local-first terminal coding agent powered by open-weight models via Ollama."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.6.1"
lcode/agent.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import base64
5
6
  import datetime as dt
6
7
  import json
7
8
  import platform
@@ -19,7 +20,7 @@ from rich.markup import escape
19
20
  from rich.panel import Panel
20
21
  from rich.text import Text
21
22
 
22
- from lcode import catalog, limits, sessions, web
23
+ from lcode import catalog, limits, sessions, vision, web
23
24
  from lcode.checkpoints import Checkpoints
24
25
  from lcode.config import format_tokens
25
26
  from lcode.mcp import McpManager
@@ -29,9 +30,11 @@ from lcode.render import MarkdownStreamer
29
30
  from lcode.sandbox import Sandbox, SandboxError, project_root
30
31
  from lcode.tools import (
31
32
  SCHEMAS,
33
+ VIEW_IMAGE_SCHEMA,
32
34
  WEB_FETCH_SCHEMA,
33
35
  WEB_SEARCH_SCHEMA,
34
36
  Toolbox,
37
+ ToolError,
35
38
  is_binary,
36
39
  parse_text_tool_calls,
37
40
  tree,
@@ -144,6 +147,7 @@ class Settings:
144
147
  sandbox: str = "off" # off, docker or podman: where the model's shell commands run
145
148
  sandbox_image: str | None = None
146
149
  sandbox_network: bool = False
150
+ vision_model: str = "auto" # auto, off or an Ollama model that can see images
147
151
 
148
152
 
149
153
  class Agent:
@@ -163,6 +167,7 @@ class Agent:
163
167
  else None
164
168
  )
165
169
  self._mcp_prompt = "" # the MCP part at the end of the system prompt
170
+ self._vision: str | bool | None = False # the model that looks at images; False = not decided yet
166
171
  self.session_name = ""
167
172
  self.session_title = ""
168
173
  self.ctx_used = 0
@@ -204,10 +209,65 @@ class Agent:
204
209
  schemas = list(SCHEMAS)
205
210
  if self.settings.web != "off":
206
211
  schemas += [*([WEB_SEARCH_SCHEMA] if self.search_backend() else []), WEB_FETCH_SCHEMA]
212
+ if self.vision_model():
213
+ schemas.append(VIEW_IMAGE_SCHEMA)
207
214
  if self.mcp:
208
215
  schemas += self.mcp.schemas(self.settings.context)
209
216
  return schemas
210
217
 
218
+ def vision_model(self) -> str | None:
219
+ """The model that looks at images for this session, if any (see lcode.vision)."""
220
+ if self._vision is False:
221
+ self._vision = vision.pick_model(self.ollama, self.settings.model, self.settings.vision_model)
222
+ return self._vision or None
223
+
224
+ def free_gpu(self) -> list[str]:
225
+ """Unload lcode's models from Ollama so another program can use the GPU; the next request reloads.
226
+
227
+ Only lcode's own models: the session's and the one that looks at images.
228
+ """
229
+ mine = {self.settings.model, *([self._vision] if isinstance(self._vision, str) else [])}
230
+ freed = []
231
+ for entry in self.ollama.running():
232
+ name = entry.get("name") or entry.get("model") or ""
233
+ if name in mine or name.removesuffix(":latest") in mine:
234
+ try:
235
+ self.ollama.unload(name)
236
+ freed.append(name.removesuffix(":latest"))
237
+ except OllamaError:
238
+ pass
239
+ return freed
240
+
241
+ def look(self, path: Path, question: str = "") -> str:
242
+ """Describe an image with the vision model. Raises ToolError if it can't."""
243
+ model = self.vision_model()
244
+ if not model:
245
+ raise ToolError(f"can't look at {path.name}: {vision.INSTALL_HINT}")
246
+ try:
247
+ image = vision.read_image(path)
248
+ # The session's own model keeps its settings, so Ollama doesn't reload it.
249
+ options = self.options() if model == self.settings.model else None
250
+ loading = "" if model == self.settings.model else " (loads it; the next request reloads the main model)"
251
+ with self.console.status(f"Looking at {path.name} with {model}{loading}…", spinner="dots"):
252
+ return vision.describe(self.ollama, model, image, question, options, self.settings.keep_alive)
253
+ except vision.VisionError as e:
254
+ raise ToolError(str(e)) from e
255
+
256
+ def describe_image_data(self, data: str, mime: str) -> str:
257
+ """For images that tools return (e.g. an MCP browser's screenshots): a description, or a note."""
258
+ model = self.vision_model()
259
+ if not model:
260
+ return f"[{mime} image not shown: {vision.INSTALL_HINT}]"
261
+ options = self.options() if model == self.settings.model else None
262
+ try:
263
+ with self.console.status(f"Looking at the {mime} image with {model}…", spinner="dots"):
264
+ text = vision.describe(
265
+ self.ollama, model, base64.b64decode(data), "", options, self.settings.keep_alive
266
+ )
267
+ except (vision.VisionError, ValueError) as e:
268
+ return f"[{mime} image not shown: {e}]"
269
+ return f"[{mime} image, as described by {model}]\n{text}"
270
+
211
271
  def prepare_mcp(self) -> None:
212
272
  """Before a request: wait for MCP servers still starting and describe them in the system prompt."""
213
273
  if not self.mcp:
@@ -559,7 +619,17 @@ class Agent:
559
619
  attached = []
560
620
  for ref in re.findall(r"(?<!\S)@([\w./~\-]+)", text):
561
621
  p = self.tools.resolve(ref)
562
- if p.is_file() and not is_binary(p) and p.stat().st_size < 200_000:
622
+ if p.is_file() and vision.is_image(p):
623
+ try:
624
+ description = self.look(p, re.sub(r"(?<!\S)@[\w./~\-]+", "", text))
625
+ who = f' described_by="{self.vision_model()}"'
626
+ except ToolError as e:
627
+ description, who = f"(lcode couldn't look at this image: {e})", ""
628
+ self.console.print(Text(f" ⎿ {e}", style="yellow"))
629
+ else:
630
+ self.console.print(Text(f" ⎿ looked at {self.tools.rel(p)}", style="dim"))
631
+ attached.append(f'<image path="{self.tools.rel(p)}"{who}>\n{description}\n</image>')
632
+ elif p.is_file() and not is_binary(p) and p.stat().st_size < 200_000:
563
633
  attached.append(f'<file path="{self.tools.rel(p)}">\n{p.read_text(errors="replace")}\n</file>')
564
634
  self.tools.read_mtimes[str(p)] = p.stat().st_mtime
565
635
  self.console.print(Text(f" ⎿ attached {self.tools.rel(p)}", style="dim"))
lcode/cli.py CHANGED
@@ -273,6 +273,17 @@ def cmd_doctor(args) -> None:
273
273
  if spec:
274
274
  detail += f" · ~{spec.memory_gib(ctx):.0f} GB needed, ~{hw.budget_gib:.0f} GB available"
275
275
  line("Context", detail + (f" ({note})" if note else ""), None if note else True)
276
+ from lcode import vision
277
+
278
+ seer = vision.pick_model(ollama, model, cfg["vision_model"])
279
+ if seer:
280
+ line(
281
+ "Vision",
282
+ f"{seer} looks at screenshots and images" + ("" if seer == model else " (loaded when needed)"),
283
+ True,
284
+ )
285
+ else:
286
+ line("Vision", "off" if cfg["vision_model"] == "off" else vision.INSTALL_HINT, None)
276
287
  if limits.get(model):
277
288
  line(
278
289
  "Limit",
@@ -497,6 +508,7 @@ def cmd_chat(args) -> None:
497
508
  sandbox=(cfg["sandbox"] if cfg["sandbox"] != "off" else "docker") if args.sandbox else cfg["sandbox"],
498
509
  sandbox_image=cfg["sandbox_image"],
499
510
  sandbox_network=cfg["sandbox_network"],
511
+ vision_model=cfg["vision_model"],
500
512
  )
501
513
  agent = Agent(ollama, settings, cwd, console=console)
502
514
  if agent.sandbox:
lcode/config.py CHANGED
@@ -43,6 +43,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
43
43
  "sandbox": ("off", str, "run the model's shell commands in a container: off | docker | podman"),
44
44
  "sandbox_image": (None, str, "container image for the sandbox (default: lcode's, built on first use)"),
45
45
  "sandbox_network": (False, bool, "let commands in the sandbox use the network"),
46
+ "vision_model": ("auto", str, "model that looks at images: auto | off | an Ollama model with vision"),
46
47
  "mcp_tools": ("auto", str, "how MCP tools reach the model: auto | direct | search (on demand, saves context)"),
47
48
  }
48
49
  ENV_OVERRIDES = {
lcode/mcp/catalog.toml CHANGED
@@ -130,6 +130,34 @@ 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
+
133
161
  [context7]
134
162
  name = "Context7"
135
163
  description = "Current documentation and code examples for thousands of libraries"
lcode/mcp/config.py CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  Both use the `mcpServers` format that most MCP servers document, so a snippet from a server's README
4
4
  can be pasted in as is. lcode adds a few optional keys per server: `disabled`, `tools` (only offer
5
- these tools to the model), `allow` (tools that don't need approval), `timeout` and `oauth`.
5
+ these tools to the model), `allow` (tools that don't need approval), `timeout`, `oauth` and
6
+ `free_gpu` (tools that need the GPU to themselves, so lcode unloads its model first).
6
7
  Values can refer to environment variables as ${NAME} or ${NAME:-default}.
7
8
  """
8
9
 
@@ -69,6 +70,7 @@ class ServerConfig:
69
70
  timeout: float = DEFAULT_TIMEOUT
70
71
  oauth: dict = field(default_factory=dict)
71
72
  disabled: bool = False
73
+ free_gpu: list[str] | bool = False # tools (or all, if True) that need lcode's model off the GPU
72
74
  error: str = "" # a problem with the settings; the server can't start
73
75
 
74
76
  @property
@@ -121,6 +123,8 @@ def parse(name: str, raw: dict, source: str = "user") -> ServerConfig:
121
123
  cfg.error = f"the environment variable {e} isn't set"
122
124
  tools = raw.get("tools")
123
125
  cfg.tools = [str(t) for t in tools] if isinstance(tools, list) else None
126
+ free_gpu = raw.get("free_gpu", False)
127
+ cfg.free_gpu = [str(t) for t in free_gpu] if isinstance(free_gpu, list) else bool(free_gpu)
124
128
  cfg.allow = [str(t) for t in raw.get("allow") or []] if isinstance(raw.get("allow"), list) else []
125
129
  try:
126
130
  cfg.timeout = float(raw.get("timeout", DEFAULT_TIMEOUT))
lcode/mcp/manager.py CHANGED
@@ -317,15 +317,20 @@ class McpManager:
317
317
  raise McpError("`arguments` must be a JSON object")
318
318
  return found[0], found[1], arguments
319
319
 
320
+ def needs_gpu(self, state: ServerState, tool: dict) -> bool:
321
+ """Whether lcode should free the GPU (unload its model) before this tool runs."""
322
+ wanted = state.cfg.free_gpu
323
+ return wanted is True or (isinstance(wanted, list) and tool["name"] in wanted)
324
+
320
325
  def allowed(self, state: ServerState, tool: dict) -> bool:
321
326
  """Tools the user allowed in mcp.json run without asking."""
322
327
  return "*" in state.cfg.allow or tool["name"] in state.cfg.allow
323
328
 
324
- def call(self, state: ServerState, tool: dict, arguments: dict) -> str:
329
+ def call(self, state: ServerState, tool: dict, arguments: dict, image_text=None) -> str:
325
330
  try:
326
331
  if state.conn is None:
327
332
  raise TransportError("the server isn't connected")
328
- return result_text(state.conn.call_tool(tool, arguments, state.cfg.timeout))
333
+ return result_text(state.conn.call_tool(tool, arguments, state.cfg.timeout), image_text)
329
334
  except AuthRequired:
330
335
  state.status = "login"
331
336
  state.error = f"sign-in expired: run /mcp login {state.name}"
@@ -336,7 +341,7 @@ class McpManager:
336
341
  self.restart(state.name)
337
342
  if state.status == "ready" and state.conn:
338
343
  try:
339
- return result_text(state.conn.call_tool(tool, arguments, state.cfg.timeout))
344
+ return result_text(state.conn.call_tool(tool, arguments, state.cfg.timeout), image_text)
340
345
  except McpError as again:
341
346
  return f"Error: {again}"
342
347
  return f"Error: {state.name}: {e}"
lcode/mcp/protocol.py CHANGED
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import base64
11
11
  import json
12
12
  import re
13
+ from collections.abc import Callable
13
14
 
14
15
  from lcode import __version__
15
16
 
@@ -99,8 +100,8 @@ def param_headers(schema: dict, arguments: dict) -> dict[str, str] | None:
99
100
  return headers if walk(schema, arguments) else None
100
101
 
101
102
 
102
- def result_text(result: dict) -> str:
103
- """Turn a tools/call result into text for the model."""
103
+ def result_text(result: dict, image_text: Callable[[str, str], str] | None = None) -> str:
104
+ """Turn a tools/call result into text for the model. `image_text(data, mime)` describes images."""
104
105
  parts = []
105
106
  for item in result.get("content") or []:
106
107
  if not isinstance(item, dict):
@@ -108,6 +109,8 @@ def result_text(result: dict) -> str:
108
109
  kind = item.get("type")
109
110
  if kind == "text":
110
111
  parts.append(str(item.get("text", "")))
112
+ elif kind == "image" and image_text and item.get("data"):
113
+ parts.append(image_text(item["data"], item.get("mimeType", "image/png")))
111
114
  elif kind in ("image", "audio"):
112
115
  parts.append(f"[{kind} ({item.get('mimeType', 'unknown type')}) not shown]")
113
116
  elif kind == "resource_link":
lcode/ollama.py CHANGED
@@ -68,6 +68,10 @@ class Ollama:
68
68
  names.add(m["name"][: -len(":latest")])
69
69
  return names
70
70
 
71
+ def chat(self, payload: dict, timeout: float = 900) -> dict:
72
+ """One chat request without streaming."""
73
+ return self._post("/api/chat", {**payload, "stream": False}, timeout=timeout)
74
+
71
75
  def show(self, model: str) -> dict:
72
76
  return self._post("/api/show", {"model": model})
73
77
 
lcode/tools.py CHANGED
@@ -25,6 +25,7 @@ from rich.text import Text
25
25
  from lcode import web
26
26
  from lcode.permissions import bash_key, is_read_only
27
27
  from lcode.sandbox import SandboxError
28
+ from lcode.vision import is_image
28
29
 
29
30
  if TYPE_CHECKING:
30
31
  from lcode.agent import Agent
@@ -162,7 +163,17 @@ WEB_FETCH_SCHEMA = _fn(
162
163
  },
163
164
  ["url"],
164
165
  )
165
- TOOL_NAMES = {s["function"]["name"] for s in [*SCHEMAS, WEB_SEARCH_SCHEMA, WEB_FETCH_SCHEMA]}
166
+ VIEW_IMAGE_SCHEMA = _fn(
167
+ "view_image",
168
+ "Look at an image file (screenshot, diagram, photo, mockup): returns a detailed description with all visible "
169
+ "text transcribed. Ask a specific question to focus it.",
170
+ {
171
+ "path": {"type": "string", "description": "Image file: png, jpg, webp or gif"},
172
+ "question": {"type": "string", "description": "What you need to know from the image (optional)"},
173
+ },
174
+ ["path"],
175
+ )
176
+ TOOL_NAMES = {s["function"]["name"] for s in [*SCHEMAS, WEB_SEARCH_SCHEMA, WEB_FETCH_SCHEMA, VIEW_IMAGE_SCHEMA]}
166
177
 
167
178
 
168
179
  # ----------------------------------------------------------------------------- helpers
@@ -329,6 +340,11 @@ class Toolbox:
329
340
  valid = ", ".join(p if params[p].default is inspect.Parameter.empty else f"{p} (optional)" for p in params)
330
341
  return f"{'; '.join(parts)}. Valid arguments: {valid}."
331
342
 
343
+ # -- images
344
+ def t_view_image(self, path: str, question: str = "") -> str:
345
+ p = self.path(path)
346
+ return f"[{self.rel(p)}, as described by {self.agent.vision_model()}]\n" + self.agent.look(p, question)
347
+
332
348
  # -- MCP servers
333
349
  def _mcp(self, name: str, args: dict) -> str:
334
350
  mcp = self.agent.mcp
@@ -348,7 +364,18 @@ class Toolbox:
348
364
  ok, feedback = self.agent.perms.request(f"mcp:{state.name}:{tool['name']}", "mcp", title, body)
349
365
  if not ok:
350
366
  return feedback
351
- result = mcp.call(state, tool, arguments)
367
+ if mcp.needs_gpu(state, tool):
368
+ # The tool must finish before lcode's model comes back: a job left running in the
369
+ # background would compete with it for the GPU.
370
+ properties = (tool.get("inputSchema") or {}).get("properties") or {}
371
+ if (properties.get("wait") or {}).get("type") == "boolean" and arguments.get("wait") is not True:
372
+ arguments = {**arguments, "wait": True}
373
+ freed = self.agent.free_gpu()
374
+ if freed:
375
+ self.console.print(
376
+ Text(f" ⎿ freed the GPU for {state.name} ({', '.join(freed)} reloads afterwards)", style="dim")
377
+ )
378
+ result = mcp.call(state, tool, arguments, image_text=self.agent.describe_image_data)
352
379
  if result.startswith("Error:"):
353
380
  return result
354
381
  self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s) from {state.name}", style="dim"))
@@ -361,6 +388,8 @@ class Toolbox:
361
388
  raise ToolError(f"{path} does not exist")
362
389
  if p.is_dir():
363
390
  raise ToolError(f"{path} is a directory; use list_dir")
391
+ if is_image(p):
392
+ raise ToolError(f"{path} is an image; look at it with view_image")
364
393
  if is_binary(p):
365
394
  raise ToolError(f"{path} is a binary file ({p.stat().st_size} bytes)")
366
395
  lines = p.read_text(errors="replace").splitlines()
lcode/vision.py ADDED
@@ -0,0 +1,109 @@
1
+ """Looking at images: screenshots, diagrams and photos the user attaches or the model opens.
2
+
3
+ The model that runs the conversation often can't see: lcode's text-only variants leave out the
4
+ vision projector to save GPU memory. So lcode asks a model that can see to describe the image in
5
+ detail (all text transcribed, layout, colors), with the user's question in mind, and gives the
6
+ description to the conversation. Which model looks (`vision_model = "auto"`):
7
+
8
+ 1. the session's model, if it has vision;
9
+ 2. otherwise the original Ollama model behind a text-only catalog variant (the same weights plus
10
+ the vision projector; `lcode setup` already downloaded it), e.g. qwen3.6:35b-a3b-coding;
11
+ 3. otherwise none, unless `vision_model` names one (for example qwen3-vl:8b).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ from pathlib import Path
18
+
19
+ from lcode import catalog
20
+ from lcode.ollama import Ollama, OllamaError
21
+
22
+ IMAGE_TYPES = {
23
+ ".png": "image/png",
24
+ ".jpg": "image/jpeg",
25
+ ".jpeg": "image/jpeg",
26
+ ".webp": "image/webp",
27
+ ".gif": "image/gif",
28
+ }
29
+ MAX_IMAGE_BYTES = 20 * 1024 * 1024
30
+ DESCRIBE_CONTEXT = 16384 # enough for one image and a long description
31
+
32
+ PROMPT = """You are the eyes of a coding assistant that cannot see images. Describe this image so the \
33
+ assistant can act on it without seeing it.
34
+ - Transcribe all visible text exactly: code, error messages, terminal output, labels, menu items, numbers.
35
+ - Describe the layout and every important element: what it is, where it is, its size, color and state \
36
+ (enabled, selected, highlighted, misaligned, overlapping, cut off).
37
+ - For diagrams, charts and architecture drawings, describe the parts and how they connect.
38
+ - Say what looks wrong or unusual, if anything.
39
+ Don't guess at what isn't visible.{question}"""
40
+
41
+ INSTALL_HINT = (
42
+ "no model that can see images is available: run `ollama pull qwen3-vl:8b` and "
43
+ "`lcode config set vision_model qwen3-vl:8b`, or use a catalog model (lcode setup)"
44
+ )
45
+
46
+
47
+ class VisionError(Exception):
48
+ pass
49
+
50
+
51
+ def is_image(path: Path) -> bool:
52
+ return path.suffix.lower() in IMAGE_TYPES
53
+
54
+
55
+ def can_see(ollama: Ollama, model: str) -> bool:
56
+ try:
57
+ return "vision" in (ollama.show(model).get("capabilities") or [])
58
+ except (OllamaError, AttributeError, TypeError):
59
+ return False
60
+
61
+
62
+ def pick_model(ollama: Ollama, model: str, setting: str) -> str | None:
63
+ """The model that looks at images for a session running `model` (see the module docstring)."""
64
+ if setting == "off":
65
+ return None
66
+ if setting != "auto":
67
+ return setting
68
+ if can_see(ollama, model):
69
+ return model
70
+ spec = catalog.find(model)
71
+ if spec and spec.tag != model and can_see(ollama, spec.tag):
72
+ return spec.tag
73
+ return None
74
+
75
+
76
+ def read_image(path: Path) -> bytes:
77
+ if not path.is_file():
78
+ raise VisionError(f"{path} doesn't exist")
79
+ if not is_image(path):
80
+ raise VisionError(f"{path.name} isn't an image lcode can read ({', '.join(sorted(IMAGE_TYPES))})")
81
+ size = path.stat().st_size
82
+ if size > MAX_IMAGE_BYTES:
83
+ raise VisionError(f"{path.name} is {size // (1024 * 1024)} MB; images up to 20 MB are supported")
84
+ return path.read_bytes()
85
+
86
+
87
+ def describe(ollama: Ollama, model: str, image: bytes, question: str = "", options: dict | None = None,
88
+ keep_alive: str = "30m") -> str: # fmt: skip
89
+ """A detailed description of the image, focused on the question if there is one."""
90
+ focus = (
91
+ f"\n\nThe user asks: {question.strip()}\nPay special attention to what that needs." if question.strip() else ""
92
+ )
93
+ payload = {
94
+ "model": model,
95
+ "messages": [
96
+ {"role": "user", "content": PROMPT.format(question=focus), "images": [base64.b64encode(image).decode()]}
97
+ ],
98
+ "think": False,
99
+ "keep_alive": keep_alive,
100
+ "options": options or {"num_ctx": DESCRIBE_CONTEXT},
101
+ }
102
+ try:
103
+ reply = ollama.chat(payload)
104
+ except OllamaError as e:
105
+ raise VisionError(f"{model} couldn't look at the image: {e}") from e
106
+ text = ((reply.get("message") or {}).get("content") or "").strip()
107
+ if not text:
108
+ raise VisionError(f"{model} returned no description")
109
+ return text
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.5.0
3
+ Version: 0.6.1
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/
@@ -86,7 +86,10 @@ through [Ollama](https://ollama.com), and when it needs current information it c
86
86
  with tool calling.
87
87
  - **MCP servers, ready to go.** Connect Jira and Confluence, GitHub, AWS, Google Drive, Grafana,
88
88
  Google Cloud, Sentry, Linear, Notion, Postgres, Kubernetes and more with one command
89
- (`lcode mcp add atlassian`), or any other MCP server. Browser sign-in (OAuth) is built in.
89
+ (`lcode mcp add atlassian`), image generation with ComfyUI, or any other MCP server. Browser sign-in (OAuth) is built in.
90
+ - **Sees images.** Attach a screenshot or mockup with `@path` and lcode looks at it, using the
91
+ vision part of your model. With ComfyUI (`lcode mcp add comfyui`) it can also generate and edit
92
+ images with local models such as FLUX, SDXL and Qwen-Image.
90
93
  - **Web search when needed.** Looks up the latest versions, docs and error messages with Ollama web
91
94
  search, Brave, Tavily or your own SearXNG, and reads pages as clean text.
92
95
  - **Private.** The model runs on your machine, lcode never uploads your files and has no telemetry.
@@ -1,35 +1,36 @@
1
- lcode/__init__.py,sha256=-nk7j2Efg9_MFgFlQo5YWln10lcT6SR31CYBoENbTLo,117
1
+ lcode/__init__.py,sha256=FEZ3iJP_gDCy8weyeZaUXt4OwUzCoqcyUbhby34kJ30,117
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
- lcode/agent.py,sha256=evwGEJg8zWvA2SnP4yI_vkWba3ILICK9OzVb0cbk6bw,28478
3
+ lcode/agent.py,sha256=AhUf1l0OEjpizU74HI7yfdRz0q_UAnqFddbyQwdmFx0,32256
4
4
  lcode/bench.py,sha256=FCn94Gs7L8bpq4hzf6ILoQG8dKrbbd5WC4BRwrTp4LM,37950
5
5
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
6
6
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
7
- lcode/cli.py,sha256=iGP5qywc7tUFuLNpzcrlYTicFX6dAaQpeXCfElqxPdQ,27041
8
- lcode/config.py,sha256=1_dhAwLRZD2rEhD47rBl3W4kvW0lQ6-45TQdq1KHz58,6956
7
+ lcode/cli.py,sha256=53MucKCHe9NreYXwDaMEwfHtPS-26kxj7Xg3Bl4yoR8,27506
8
+ lcode/config.py,sha256=VyRHq3ElCGmTQqTdtcGRHGuselm2U8-04ZC3tnEQ6L8,7063
9
9
  lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
10
10
  lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
11
11
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
12
- lcode/ollama.py,sha256=-EB_8DKh87larG7iV0neyndPRom1Hxh4y8AAfI1Ebq8,6690
12
+ lcode/ollama.py,sha256=8zxJcMhcxKF3L0vdgrzcoXAj_4MvbybVDSRPbLek0QE,6892
13
13
  lcode/permissions.py,sha256=OcWUDLrSoohUWHDUQ3UuBE3zI2JnBnOPizLE3bIgqyU,3982
14
14
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
15
15
  lcode/repl.py,sha256=2p6UG1xCmn85YEYcWUtgfoLJc3rHLaGLb_VV-RvzYvw,30332
16
16
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
17
17
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
18
- lcode/tools.py,sha256=Y23bVt7Bow72v1QJUpvSahoEep77XW8e44Mg-s9igds,29146
18
+ lcode/tools.py,sha256=ApAmM5hRxLVWjjF7eqqQYVyMDFAJkU4mLNZNYTbuMC8,30688
19
+ lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
19
20
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
20
21
  lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
21
22
  lcode/mcp/auth.py,sha256=vyH3oolgIBNJLYabr2h5Soui99NTY1QxjQEgtczfF6A,16655
22
23
  lcode/mcp/catalog.py,sha256=GnIF1fyeHSut9h2y_VJNAHc2FCJT81EIhLFM40V780k,3504
23
- lcode/mcp/catalog.toml,sha256=rb9Q6NgenYVzlsf5Ej-8yJbFwBMLuLCMegzrU5Rs_kc,7891
24
+ lcode/mcp/catalog.toml,sha256=_kFzKD2FD78KD2URuE5B-kcQehW9Y3Zi2xf5tu3v4Xc,9230
24
25
  lcode/mcp/client.py,sha256=3Gen7zRvWehngXn_dAMOAdKqFkFkQZIQW96hQcW4N4k,7119
25
26
  lcode/mcp/commands.py,sha256=Rlnc-LFydRjHTiW-CSxjmQqX0d2MSiEViLF9kczYIi4,16420
26
- lcode/mcp/config.py,sha256=iSjjucG4VuP9u7aJQrTZsPA9k9oeRyMmz4hV1GE5ljw,8454
27
+ lcode/mcp/config.py,sha256=Ej6WX2b_55a84H5WdAwSk9DzERKoq5ifUBQ9uDgSG_o,8780
27
28
  lcode/mcp/http.py,sha256=sG9MajwU4_6sqvZhwD4WfyzRKzaR_Lc8C02jwePz18E,7380
28
- lcode/mcp/manager.py,sha256=UPpY97jwW_KIi2G4hR860Iq4zgfqNFHjsyeu48aEa44,14784
29
- lcode/mcp/protocol.py,sha256=8uhyzzDQmaXr6pdt9bWD5ccvWZ93rvxd4v3Z5J84vKw,4938
29
+ lcode/mcp/manager.py,sha256=79nNWoT5pxgoQD_Pv4ZH0obHIm7rQ-LyB8OmJnQgc6k,15104
30
+ lcode/mcp/protocol.py,sha256=-xwhA2Tz-vzR2zdSdbPx4IjTu29uRrXMaePg0vEz_Vc,5223
30
31
  lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
31
- lcode_cli-0.5.0.dist-info/METADATA,sha256=AKzBZkJaIZLAFgClC2t3OhO36jiVaAAXB7d1EXFEV8Q,9965
32
- lcode_cli-0.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
33
- lcode_cli-0.5.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
34
- lcode_cli-0.5.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
35
- lcode_cli-0.5.0.dist-info/RECORD,,
32
+ lcode_cli-0.6.1.dist-info/METADATA,sha256=4PLti7RbtKHi303yE3frVKOVMu5brqijn8LgZw_UXl0,10251
33
+ lcode_cli-0.6.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
34
+ lcode_cli-0.6.1.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
35
+ lcode_cli-0.6.1.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
36
+ lcode_cli-0.6.1.dist-info/RECORD,,