lcode-cli 0.5.0__tar.gz → 0.6.0__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.
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/CHANGELOG.md +19 -1
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/PKG-INFO +5 -2
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/README.md +4 -1
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/configuration.md +1 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/how-it-works.md +1 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/mcp.md +53 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/models.md +4 -2
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/usage.md +28 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/agent.py +72 -2
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/cli.py +12 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/config.py +1 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/mcp/catalog.toml +28 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/mcp/config.py +5 -1
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/mcp/manager.py +8 -3
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/mcp/protocol.py +5 -2
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/ollama.py +4 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/tools.py +26 -2
- lcode_cli-0.6.0/src/lcode/vision.py +109 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/conftest.py +14 -2
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/test_mcp.py +18 -0
- lcode_cli-0.6.0/tests/test_vision.py +104 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/.editorconfig +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/.gitignore +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/AGENTS.md +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/LICENSE +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/SECURITY.md +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/bench.md +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/changelog.md +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/contributing.md +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/index.md +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/installation.md +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/install.sh +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/mkdocs.yml +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/overrides/main.html +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/pyproject.toml +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/repl.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/test_agent.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/test_config.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/tests/test_web.py +0 -0
- {lcode_cli-0.5.0 → lcode_cli-0.6.0}/uv.lock +0 -0
|
@@ -6,6 +6,23 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.6.0] - 2026-10-01
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- lcode can look at images: attach a screenshot, mockup or diagram with `@path`, and the model can
|
|
14
|
+
open images itself with the new `view_image` tool. A model that can see describes the image in
|
|
15
|
+
detail (all text transcribed, with your question in mind): your model itself if it can see, or
|
|
16
|
+
the original model behind lcode's text-only variant, which `lcode setup` already downloaded
|
|
17
|
+
(`vision_model` setting; `lcode doctor` shows which). Screenshots returned by MCP tools, such as
|
|
18
|
+
Playwright's, are described too.
|
|
19
|
+
- `lcode mcp add comfyui`: generate and edit images with models you run locally in ComfyUI (FLUX,
|
|
20
|
+
SDXL, Stable Diffusion 1.5, Qwen-Image), through ComfyUI's official MCP server, with only its local
|
|
21
|
+
tools enabled.
|
|
22
|
+
- MCP servers can mark tools that need the GPU to themselves (`free_gpu` in `mcp.json`): lcode
|
|
23
|
+
unloads its own model before they run and reloads it afterwards. On for ComfyUI's generation tools,
|
|
24
|
+
so image models work on a 12 GB GPU next to a large coding model.
|
|
25
|
+
|
|
9
26
|
## [0.5.0] - 2026-10-01
|
|
10
27
|
|
|
11
28
|
### Added
|
|
@@ -186,7 +203,8 @@ First public release.
|
|
|
186
203
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
187
204
|
- One-line installer for Linux and macOS.
|
|
188
205
|
|
|
189
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
206
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.6.0...HEAD
|
|
207
|
+
[0.6.0]: https://github.com/nasser1941/lcode/compare/v0.5.0...v0.6.0
|
|
190
208
|
[0.5.0]: https://github.com/nasser1941/lcode/compare/v0.4.1...v0.5.0
|
|
191
209
|
[0.4.1]: https://github.com/nasser1941/lcode/compare/v0.4.0...v0.4.1
|
|
192
210
|
[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.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -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
|
|
229
|
-
|
|
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)).
|
|
@@ -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 |
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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,13 @@ 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
|
-
|
|
367
|
+
if mcp.needs_gpu(state, tool):
|
|
368
|
+
freed = self.agent.free_gpu()
|
|
369
|
+
if freed:
|
|
370
|
+
self.console.print(
|
|
371
|
+
Text(f" ⎿ freed the GPU for {state.name} ({', '.join(freed)} reloads afterwards)", style="dim")
|
|
372
|
+
)
|
|
373
|
+
result = mcp.call(state, tool, arguments, image_text=self.agent.describe_image_data)
|
|
352
374
|
if result.startswith("Error:"):
|
|
353
375
|
return result
|
|
354
376
|
self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s) from {state.name}", style="dim"))
|
|
@@ -361,6 +383,8 @@ class Toolbox:
|
|
|
361
383
|
raise ToolError(f"{path} does not exist")
|
|
362
384
|
if p.is_dir():
|
|
363
385
|
raise ToolError(f"{path} is a directory; use list_dir")
|
|
386
|
+
if is_image(p):
|
|
387
|
+
raise ToolError(f"{path} is an image; look at it with view_image")
|
|
364
388
|
if is_binary(p):
|
|
365
389
|
raise ToolError(f"{path} is a binary file ({p.stat().st_size} bytes)")
|
|
366
390
|
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
|
-
|
|
47
|
+
self.unloaded.append(model)
|
|
36
48
|
|
|
37
49
|
def running(self) -> list[dict]:
|
|
38
|
-
return
|
|
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,21 @@ 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()
|
|
@@ -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
|
|
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
|
|
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
|