lcode-cli 0.5.0__tar.gz → 0.6.1__tar.gz

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.
Files changed (92) hide show
  1. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/CHANGELOG.md +28 -1
  2. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/PKG-INFO +5 -2
  3. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/README.md +4 -1
  4. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/configuration.md +1 -0
  5. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/how-it-works.md +1 -0
  6. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/mcp.md +53 -0
  7. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/models.md +4 -2
  8. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/quickstart.md +1 -1
  9. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/usage.md +28 -0
  10. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/__init__.py +1 -1
  11. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/agent.py +72 -2
  12. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/cli.py +12 -0
  13. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/config.py +1 -0
  14. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/mcp/catalog.toml +28 -0
  15. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/mcp/config.py +5 -1
  16. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/mcp/manager.py +8 -3
  17. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/mcp/protocol.py +5 -2
  18. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/ollama.py +4 -0
  19. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/tools.py +31 -2
  20. lcode_cli-0.6.1/src/lcode/vision.py +109 -0
  21. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/conftest.py +14 -2
  22. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/test_mcp.py +34 -0
  23. lcode_cli-0.6.1/tests/test_vision.py +104 -0
  24. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/.editorconfig +0 -0
  25. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/.github/CODEOWNERS +0 -0
  26. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  27. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  28. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  29. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  30. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  31. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/.github/dependabot.yml +0 -0
  32. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/.github/workflows/ci.yml +0 -0
  33. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/.github/workflows/docs.yml +0 -0
  34. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/.github/workflows/release.yml +0 -0
  35. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/.gitignore +0 -0
  36. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/.pre-commit-config.yaml +0 -0
  37. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/AGENTS.md +0 -0
  38. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/CODE_OF_CONDUCT.md +0 -0
  39. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/CONTRIBUTING.md +0 -0
  40. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/LICENSE +0 -0
  41. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/SECURITY.md +0 -0
  42. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/assets/demo.svg +0 -0
  43. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/assets/extra.css +0 -0
  44. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/assets/logo.svg +0 -0
  45. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/assets/social-preview.png +0 -0
  46. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/bench.md +0 -0
  47. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/changelog.md +0 -0
  48. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/contributing.md +0 -0
  49. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/index.md +0 -0
  50. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/installation.md +0 -0
  51. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/sandbox.md +0 -0
  52. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/docs/troubleshooting.md +0 -0
  53. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/install.sh +0 -0
  54. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/mkdocs.yml +0 -0
  55. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/overrides/main.html +0 -0
  56. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/pyproject.toml +0 -0
  57. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/scripts/record_demo.py +0 -0
  58. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/scripts/social-preview.html +0 -0
  59. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/__main__.py +0 -0
  60. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/bench.py +0 -0
  61. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/catalog.py +0 -0
  62. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/checkpoints.py +0 -0
  63. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/hardware.py +0 -0
  64. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/limits.py +0 -0
  65. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/mcp/__init__.py +0 -0
  66. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/mcp/auth.py +0 -0
  67. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/mcp/catalog.py +0 -0
  68. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/mcp/client.py +0 -0
  69. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/mcp/commands.py +0 -0
  70. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/mcp/http.py +0 -0
  71. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/mcp/stdio.py +0 -0
  72. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/models.toml +0 -0
  73. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/permissions.py +0 -0
  74. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/render.py +0 -0
  75. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/repl.py +0 -0
  76. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/sandbox.py +0 -0
  77. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/sessions.py +0 -0
  78. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/src/lcode/web.py +0 -0
  79. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/fake_mcp_server.py +0 -0
  80. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/test_agent.py +0 -0
  81. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/test_bench.py +0 -0
  82. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/test_catalog.py +0 -0
  83. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/test_checkpoints.py +0 -0
  84. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/test_cli.py +0 -0
  85. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/test_config.py +0 -0
  86. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/test_context_picker.py +0 -0
  87. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/test_permissions.py +0 -0
  88. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/test_sandbox.py +0 -0
  89. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/test_sessions.py +0 -0
  90. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/test_tools.py +0 -0
  91. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/tests/test_web.py +0 -0
  92. {lcode_cli-0.5.0 → lcode_cli-0.6.1}/uv.lock +0 -0
@@ -6,6 +6,31 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.1] - 2026-10-01
10
+
11
+ ### Fixed
12
+
13
+ - Tools marked `free_gpu` (such as ComfyUI's `run_workflow`) now always run to completion
14
+ (`wait: true`): a job left running in the background competed with lcode's reloading model for
15
+ the GPU.
16
+
17
+ ## [0.6.0] - 2026-10-01
18
+
19
+ ### Added
20
+
21
+ - lcode can look at images: attach a screenshot, mockup or diagram with `@path`, and the model can
22
+ open images itself with the new `view_image` tool. A model that can see describes the image in
23
+ detail (all text transcribed, with your question in mind): your model itself if it can see, or
24
+ the original model behind lcode's text-only variant, which `lcode setup` already downloaded
25
+ (`vision_model` setting; `lcode doctor` shows which). Screenshots returned by MCP tools, such as
26
+ Playwright's, are described too.
27
+ - `lcode mcp add comfyui`: generate and edit images with models you run locally in ComfyUI (FLUX,
28
+ SDXL, Stable Diffusion 1.5, Qwen-Image), through ComfyUI's official MCP server, with only its local
29
+ tools enabled.
30
+ - MCP servers can mark tools that need the GPU to themselves (`free_gpu` in `mcp.json`): lcode
31
+ unloads its own model before they run and reloads it afterwards. On for ComfyUI's generation tools,
32
+ so image models work on a 12 GB GPU next to a large coding model.
33
+
9
34
  ## [0.5.0] - 2026-10-01
10
35
 
11
36
  ### Added
@@ -186,7 +211,9 @@ First public release.
186
211
  - `AGENTS.md` project instructions and `/init` to generate them.
187
212
  - One-line installer for Linux and macOS.
188
213
 
189
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.5.0...HEAD
214
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.6.1...HEAD
215
+ [0.6.1]: https://github.com/nasser1941/lcode/compare/v0.6.0...v0.6.1
216
+ [0.6.0]: https://github.com/nasser1941/lcode/compare/v0.5.0...v0.6.0
190
217
  [0.5.0]: https://github.com/nasser1941/lcode/compare/v0.4.1...v0.5.0
191
218
  [0.4.1]: https://github.com/nasser1941/lcode/compare/v0.4.0...v0.4.1
192
219
  [0.4.0]: https://github.com/nasser1941/lcode/compare/v0.3.1...v0.4.0
@@ -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.
@@ -52,7 +52,10 @@ through [Ollama](https://ollama.com), and when it needs current information it c
52
52
  with tool calling.
53
53
  - **MCP servers, ready to go.** Connect Jira and Confluence, GitHub, AWS, Google Drive, Grafana,
54
54
  Google Cloud, Sentry, Linear, Notion, Postgres, Kubernetes and more with one command
55
- (`lcode mcp add atlassian`), or any other MCP server. Browser sign-in (OAuth) is built in.
55
+ (`lcode mcp add atlassian`), image generation with ComfyUI, or any other MCP server. Browser sign-in (OAuth) is built in.
56
+ - **Sees images.** Attach a screenshot or mockup with `@path` and lcode looks at it, using the
57
+ vision part of your model. With ComfyUI (`lcode mcp add comfyui`) it can also generate and edit
58
+ images with local models such as FLUX, SDXL and Qwen-Image.
56
59
  - **Web search when needed.** Looks up the latest versions, docs and error messages with Ollama web
57
60
  search, Brave, Tavily or your own SearXNG, and reads pages as clean text.
58
61
  - **Private.** The model runs on your machine, lcode never uploads your files and has no telemetry.
@@ -28,6 +28,7 @@ lcode config path # print the file location
28
28
  | `sandbox` | `off` | Run shell commands in a container: `docker` or `podman` ([Sandbox](sandbox.md)) |
29
29
  | `sandbox_image` | lcode's image | Container image for the sandbox; any image with bash and setsid |
30
30
  | `sandbox_network` | `false` | Let commands in the sandbox use the network |
31
+ | `vision_model` | `auto` | The model that [looks at images](usage.md#images): `auto`, `off` or an Ollama model that can see |
31
32
  | `mcp_tools` | `auto` | How [MCP](mcp.md#context) tool definitions reach the model: `auto`, `direct` or `search` (on demand) |
32
33
  | `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
33
34
 
@@ -77,6 +77,7 @@ the Ollama server you configure, it only contacts the web when the model searche
77
77
  | `permissions.py` | Approval prompts and the read-only allowlist |
78
78
  | `mcp/` | MCP client: stdio and HTTP transports, OAuth sign-in, server settings, the catalog, `lcode mcp` |
79
79
  | `bench.py` | `lcode bench`: the benchmark tasks, their checks and the reports |
80
+ | `vision.py` | Looking at images with a model that can see |
80
81
  | `sandbox.py` | The optional container for shell commands |
81
82
  | `checkpoints.py` | Snapshots before the model changes files, for `/undo` and `/rewind` |
82
83
  | `catalog.py`, `models.toml` | Model catalog and memory estimates |
@@ -28,6 +28,7 @@ and find the code for the top one."*
28
28
  | `gcp` | `gcloud` commands on your Google Cloud projects | `npx` and the `gcloud` CLI, signed in |
29
29
  | `github` | Repositories, issues, pull requests, Actions | a GitHub token (or the GitHub CLI) |
30
30
  | `playwright` | A real (headless) browser: open pages, click, fill forms | `npx` |
31
+ | `comfyui` | Generate and edit images with local models (FLUX, SDXL, SD 1.5, Qwen-Image) | `uvx` and [ComfyUI](#images-with-comfyui) running locally |
31
32
  | `context7` | Up-to-date docs and examples for thousands of libraries | nothing (an API key is optional) |
32
33
  | `sentry` | Errors, issues, traces and releases | a Sentry account (browser sign-in) |
33
34
  | `postgres` | Schemas, read-only queries, query performance | `uvx`, a connection URL |
@@ -82,6 +83,57 @@ Google Cloud project:
82
83
  See [Google's guide](https://developers.google.com/workspace/guides/configure-mcp-servers) for
83
84
  details.
84
85
 
86
+ ### Images with ComfyUI
87
+
88
+ [ComfyUI](https://github.com/comfyanonymous/ComfyUI) runs image generation and editing models
89
+ locally; its official MCP server lets lcode use it, for app icons, illustrations, mockups or
90
+ placeholder art.
91
+
92
+ ```bash
93
+ uv tool install comfy-cli && comfy install # ComfyUI, once
94
+ comfy launch --background -- --disable-smart-memory # start it (http://127.0.0.1:8188)
95
+ lcode mcp add comfyui
96
+ ```
97
+
98
+ Then add models in ComfyUI (its model manager, or ask lcode: *"download FLUX.2 Klein 4B in
99
+ ComfyUI"*), and ask for images: *"make a 512×512 app icon of a paper plane and save it in
100
+ assets/"*. To edit an image, ask lcode to upload it and run an editing workflow (Kontext or
101
+ Qwen-Image-Edit).
102
+
103
+ | Model | Good for | On a 12 GB GPU |
104
+ |---|---|---|
105
+ | FLUX.2 Klein 4B | Generation and editing, fast | Fits (about 8 GB) |
106
+ | SDXL and its community models | Generation, inpainting | Fits |
107
+ | Stable Diffusion 1.5 and its community models | Light generation, inpainting | Fits easily |
108
+ | FLUX.1 Dev and finetunes | High-quality generation | Needs an fp8 or GGUF version; slower |
109
+ | FLUX.1 Kontext Dev | Editing an image from instructions | Needs an fp8 or GGUF version; slower |
110
+ | Qwen-Image / Qwen-Image-Edit | Generation and editing, good text in images | Heavy: a GGUF version and RAM offloading |
111
+
112
+ Only ComfyUI's local tools are turned on; Comfy Cloud's partner tools are left out, so prompts and
113
+ images stay on your machine.
114
+
115
+ **Sharing one GPU.** A GPU rarely holds a coding model and an image model at the same time, so the
116
+ two take turns: before ComfyUI generates, lcode unloads its own model (the `free_gpu` setting), and
117
+ ComfyUI started with `--disable-smart-memory` gives the GPU back after each image. lcode's model
118
+ then reloads for the next step, which adds a few seconds to half a minute per image request. With
119
+ enough GPU memory for both (or on a Mac with plenty of memory), remove `free_gpu` from the server's
120
+ settings in `mcp.json`. lcode can also look at the results ([Images](usage.md#images)).
121
+
122
+ Measured on an RTX 4080 Laptop GPU (12 GB) with qwen3.6-35b at 64K context and FLUX.2 Klein Base 4B
123
+ (fp8, 512×512, 20 steps):
124
+
125
+ | | |
126
+ |---|---|
127
+ | Generating while lcode's model is loaded | Fails: ComfyUI runs out of GPU memory |
128
+ | Freeing the GPU (lcode unloads its model) | 0.2 s |
129
+ | Generating one image | about 12 s |
130
+ | Reloading lcode's model afterwards | about 20 s |
131
+
132
+ The first time, the model has to find the right template and fill in its settings (model file names,
133
+ size, prompt), which can take several minutes of trial and error. Once an image comes out right, ask
134
+ lcode to save that workflow in your project (for example `assets/icon.workflow.json`) and reuse it:
135
+ later images are a single `run_workflow` call.
136
+
85
137
  ## Adding your own servers
86
138
 
87
139
  Any MCP server works. For a remote server, give its URL; for a local one, the command that starts it:
@@ -122,6 +174,7 @@ document, so you can also paste a server's example config into that file:
122
174
  | `allow` | Tools that run without asking; `["*"]` for all of the server's tools |
123
175
  | `timeout` | Seconds a tool call may take (default 300) |
124
176
  | `disabled` | `true` to turn the server off (`lcode mcp disable <name>`) |
177
+ | `free_gpu` | Tools that need the GPU to themselves (`true` for all): lcode unloads its model first and reloads it afterwards |
125
178
  | `oauth` | For servers that need a pre-registered OAuth client: `client_id`, `client_secret`, `scopes`, `authorize_params` |
126
179
 
127
180
  Values can use environment variables: `${NAME}`, or `${NAME:-default}`. Keep tokens in environment
@@ -225,7 +225,9 @@ GPU's share of unified memory. Results from other Macs are very welcome in the
225
225
 
226
226
  ## Text-only variants
227
227
 
228
- Some models (the Qwen3.6 family, for example) ship with a vision encoder that a coding agent doesn't
229
- use. `lcode setup` creates a text-only variant named `lcode-<key>` that reuses the downloaded
228
+ Some models (the Qwen3.6 family, for example) ship with a vision encoder that a coding agent rarely
229
+ needs. `lcode setup` creates a text-only variant named `lcode-<key>` that reuses the downloaded
230
230
  weights, so it takes no extra disk space, and frees about 1 GB of GPU memory for the context cache
231
231
  (and for a larger prompt batch, where it fits; see [tuning](configuration.md#tuning-for-speed-and-memory)).
232
+ When you show lcode an image, it borrows the original model, vision encoder included, to look at it
233
+ (see [Images](usage.md#images)).
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.5.0 — local coding agent │
15
+ │ lcode v0.6.1 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -86,6 +86,34 @@ reasoning is on.
86
86
  lcode refuses to edit a file the model hasn't read in the session, or one that changed on disk since
87
87
  it was read, so the model always edits the current version.
88
88
 
89
+ ## Images
90
+
91
+ lcode can look at screenshots, mockups, diagrams and photos:
92
+
93
+ ```text
94
+ ❯ the layout breaks on mobile, see @screenshots/mobile.png
95
+ ❯ make the settings page match @design/settings.png
96
+ ```
97
+
98
+ - Attach an image with `@path`, like a file. lcode describes it in detail, with all visible text
99
+ transcribed and with your question in mind, and the model works from that description.
100
+ - The model can open images itself with the `view_image` tool, for example a screenshot a test wrote.
101
+ - Screenshots that [MCP](mcp.md) tools return, such as the Playwright browser's, are described too.
102
+
103
+ **Which model looks.** lcode's text-only model variants leave out the vision part to save GPU
104
+ memory, so lcode borrows the original model, which `lcode setup` already downloaded (for example
105
+ `qwen3.6:35b-a3b-coding` for `qwen3.6-35b`). If your model can see, it looks itself. `lcode doctor`
106
+ shows which one is used. With another model, install one that can see and point lcode to it:
107
+
108
+ ```bash
109
+ ollama pull qwen3-vl:8b
110
+ lcode config set vision_model qwen3-vl:8b
111
+ ```
112
+
113
+ Loading a second model takes a moment: on a 12 GB GPU, describing an image with the original
114
+ `qwen3.6:35b-a3b-coding` takes about a minute, and the next request reloads the main model. Turn
115
+ image support off with `lcode config set vision_model off`.
116
+
89
117
  ## Permissions
90
118
 
91
119
  | Mode | File edits | Shell commands |
@@ -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"
@@ -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"))
@@ -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:
@@ -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 = {
@@ -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"
@@ -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))
@@ -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}"
@@ -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":
@@ -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
 
@@ -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()
@@ -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
@@ -17,6 +17,18 @@ class FakeOllama:
17
17
  self.payloads: list[dict] = []
18
18
  self.max_ctx = max_ctx
19
19
  self.host = "http://fake"
20
+ self.capabilities: dict[str, list[str]] = {} # model -> Ollama capabilities, e.g. ["vision"]
21
+ self.chats: list[dict] = [] # non-streaming requests (image descriptions)
22
+ self.loaded = [{"name": "lcode-qwen3.6-35b:latest", "size": 23_000_000_000, "size_vram": 11_500_000_000}]
23
+ self.unloaded: list[str] = []
24
+ self.description = "A login form with a red 'Sign in' button."
25
+
26
+ def show(self, model: str) -> dict:
27
+ return {"capabilities": self.capabilities.get(model, ["completion", "tools"])}
28
+
29
+ def chat(self, payload: dict) -> dict:
30
+ self.chats.append(payload)
31
+ return {"message": {"role": "assistant", "content": self.description}}
20
32
 
21
33
  def chat_stream(self, payload: dict):
22
34
  self.payloads.append(payload)
@@ -32,10 +44,10 @@ class FakeOllama:
32
44
  pass
33
45
 
34
46
  def unload(self, model: str) -> None:
35
- pass
47
+ self.unloaded.append(model)
36
48
 
37
49
  def running(self) -> list[dict]:
38
- return [{"name": "lcode-qwen3.6-35b:latest", "size": 23_000_000_000, "size_vram": 11_500_000_000}]
50
+ return self.loaded
39
51
 
40
52
 
41
53
  def reply(content: str = "", tool_calls: list[dict] | None = None, thinking: str = "") -> list[dict]:
@@ -522,3 +522,37 @@ def test_options_and_the_server_command_are_kept_apart():
522
522
 
523
523
  args = build_parser().parse_args(["add", "db", "--env", "URL=postgres://x", "--", "uvx", "pg", "--read-only"])
524
524
  assert (args.name, args.env, args.command) == ("db", ["URL=postgres://x"], ["uvx", "pg", "--read-only"])
525
+
526
+
527
+ def test_gpu_hungry_tools_get_the_gpu(make_agent, tmp_path, monkeypatch):
528
+ """Before a tool listed under free_gpu runs, lcode unloads its own models (and nobody else's)."""
529
+ agent = make_agent(
530
+ [
531
+ reply(tool_calls=[call("mcp__img__add", a=1, b=2)]), # listed under free_gpu
532
+ reply(tool_calls=[call("mcp__img__echo", text="x")]), # not listed
533
+ reply("done"),
534
+ ]
535
+ )
536
+ agent.ollama.loaded = [{"name": "lcode-qwen3.6-35b:latest"}, {"name": "someone-elses:7b"}]
537
+ agent.mcp = ready_manager(tmp_path, server_config("img", free_gpu=["add"]))
538
+ agent.run_turn("make an image")
539
+ assert agent.ollama.unloaded == ["lcode-qwen3.6-35b:latest"]
540
+ assert "freed the GPU for img (lcode-qwen3.6-35b reloads afterwards)" in output(agent)
541
+ assert mcp_config.parse("x", {"command": "y", "free_gpu": True}).free_gpu is True
542
+ agent.mcp.close()
543
+
544
+
545
+ def test_gpu_tools_run_to_completion(make_agent, tmp_path, monkeypatch):
546
+ """A free_gpu tool with a `wait` option always waits, so it doesn't share the GPU with lcode's model."""
547
+ agent = make_agent([reply(tool_calls=[call("mcp__img__add", a=1, b=2, wait=False)]), reply("done")])
548
+ agent.mcp = ready_manager(tmp_path, server_config("img", free_gpu=["add"]))
549
+ state = agent.mcp.servers["img"]
550
+ add = next(t for t in state.tools if t["name"] == "add")
551
+ add["inputSchema"]["properties"]["wait"] = {"type": "boolean", "default": True}
552
+ sent = []
553
+ monkeypatch.setattr(
554
+ agent.mcp, "call", lambda state, tool, arguments, image_text=None: sent.append(arguments) or "ok"
555
+ )
556
+ agent.run_turn("make an image")
557
+ assert sent == [{"a": 1, "b": 2, "wait": True}]
558
+ agent.mcp.close()
@@ -0,0 +1,104 @@
1
+ import base64
2
+
3
+ import pytest
4
+
5
+ from conftest import FakeOllama, call, output, reply
6
+ from lcode import vision
7
+ from lcode.mcp.protocol import result_text
8
+
9
+ PNG = base64.b64decode( # a 1x1 transparent PNG
10
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
11
+ )
12
+
13
+
14
+ def seeing(agent, *models):
15
+ for model in models:
16
+ agent.ollama.capabilities[model] = ["completion", "vision", "tools"]
17
+ agent._vision = False # decide again
18
+ return agent
19
+
20
+
21
+ def test_which_model_looks():
22
+ ollama = FakeOllama()
23
+ assert vision.pick_model(ollama, "lcode-qwen3.6-35b", "auto") is None
24
+ ollama.capabilities["qwen3.6:35b-a3b-coding"] = ["vision"]
25
+ assert vision.pick_model(ollama, "lcode-qwen3.6-35b", "auto") == "qwen3.6:35b-a3b-coding" # the base model
26
+ ollama.capabilities["qwen3.5:9b"] = ["vision"]
27
+ assert vision.pick_model(ollama, "qwen3.5:9b", "auto") == "qwen3.5:9b" # the model itself
28
+ assert vision.pick_model(ollama, "qwen3.5:9b", "off") is None
29
+ assert vision.pick_model(ollama, "custom:7b", "qwen3-vl:8b") == "qwen3-vl:8b"
30
+
31
+
32
+ def test_images_are_checked(tmp_path, monkeypatch):
33
+ (tmp_path / "a.png").write_bytes(PNG)
34
+ (tmp_path / "notes.txt").write_text("x")
35
+ assert vision.read_image(tmp_path / "a.png") == PNG
36
+ with pytest.raises(vision.VisionError, match="isn't an image"):
37
+ vision.read_image(tmp_path / "notes.txt")
38
+ with pytest.raises(vision.VisionError, match="doesn't exist"):
39
+ vision.read_image(tmp_path / "missing.png")
40
+ monkeypatch.setattr(vision, "MAX_IMAGE_BYTES", 10)
41
+ with pytest.raises(vision.VisionError, match="up to 20 MB"):
42
+ vision.read_image(tmp_path / "a.png")
43
+
44
+
45
+ def test_the_model_can_look_at_screenshots(make_agent, repo):
46
+ (repo / "screenshot.png").write_bytes(PNG)
47
+ agent = make_agent(
48
+ [reply(tool_calls=[call("view_image", path="screenshot.png", question="what does the button say?")]),
49
+ reply("The button says 'Sign in'.")]
50
+ ) # fmt: skip
51
+ assert "view_image" not in {s["function"]["name"] for s in agent.tool_schemas()} # nothing can see yet
52
+ seeing(agent, "qwen3.6:35b-a3b-coding")
53
+ assert "view_image" in {s["function"]["name"] for s in agent.tool_schemas()}
54
+ agent.run_turn("check the login screenshot")
55
+ result = next(m["content"] for m in agent.messages if m.get("tool_name") == "view_image")
56
+ assert result.startswith("[screenshot.png, as described by qwen3.6:35b-a3b-coding]") and "red 'Sign in'" in result
57
+ request = agent.ollama.chats[0]
58
+ assert request["model"] == "qwen3.6:35b-a3b-coding" and request["think"] is False
59
+ assert request["messages"][0]["images"] == [base64.b64encode(PNG).decode()]
60
+ assert "what does the button say?" in request["messages"][0]["content"]
61
+ assert request["options"] == {"num_ctx": vision.DESCRIBE_CONTEXT}
62
+
63
+
64
+ def test_a_model_that_sees_keeps_its_settings(make_agent, repo):
65
+ (repo / "a.png").write_bytes(PNG)
66
+ agent = seeing(make_agent(model="qwen3.5:9b", context=65536, num_batch=512), "qwen3.5:9b")
67
+ agent.look(repo / "a.png")
68
+ assert agent.ollama.chats[0]["options"] == {"num_ctx": 65536, "num_batch": 512} # no reload
69
+
70
+
71
+ def test_attached_images_are_described_with_the_question(make_agent, repo):
72
+ (repo / "bug.png").write_bytes(PNG)
73
+ agent = seeing(make_agent([reply("Fixed.")]), "qwen3.6:35b-a3b-coding")
74
+ agent.run_turn("why is the layout broken in @bug.png ?")
75
+ sent = agent.messages[1]["content"]
76
+ assert '<image path="bug.png" described_by="qwen3.6:35b-a3b-coding">' in sent and "red 'Sign in'" in sent
77
+ assert "why is the layout broken in ?" in agent.ollama.chats[0]["messages"][0]["content"]
78
+ assert "looked at bug.png" in output(agent)
79
+
80
+
81
+ def test_without_a_vision_model_lcode_says_how_to_get_one(make_agent, repo):
82
+ (repo / "bug.png").write_bytes(PNG)
83
+ agent = make_agent([reply("ok")])
84
+ agent.run_turn("look at @bug.png")
85
+ assert "ollama pull qwen3-vl:8b" in agent.messages[1]["content"]
86
+ assert agent.tools.run("read_file", {"path": "bug.png"}) == "Error: bug.png is an image; look at it with view_image"
87
+
88
+
89
+ def test_images_from_mcp_tools_are_described(make_agent):
90
+ agent = seeing(make_agent(), "qwen3.6:35b-a3b-coding")
91
+ image = {"type": "image", "mimeType": "image/png", "data": base64.b64encode(PNG).decode()}
92
+ shot = {"content": [{"type": "text", "text": "Took a screenshot"}, image]}
93
+ text = result_text(shot, agent.describe_image_data)
94
+ assert text.startswith("Took a screenshot\n[image/png image, as described by qwen3.6:35b-a3b-coding]")
95
+ assert "red 'Sign in'" in text
96
+ blind = make_agent()
97
+ assert "not shown" in result_text(shot, blind.describe_image_data)
98
+
99
+
100
+ def test_view_image_respects_the_sandbox(make_agent, tmp_path_factory):
101
+ outside = tmp_path_factory.mktemp("elsewhere") / "x.png"
102
+ outside.write_bytes(PNG)
103
+ agent = seeing(make_agent(sandbox="docker"), "qwen3.6:35b-a3b-coding")
104
+ assert agent.tools.run("view_image", {"path": str(outside)}).startswith("Error:")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes