diff --git a/.vscodeignore b/.vscodeignore
index 7bc3fc3..e483338 100644
--- a/.vscodeignore
+++ b/.vscodeignore
@@ -72,3 +72,9 @@ docs/assets/**
# absolute raw.githubusercontent URL, exactly as it does the icon, so the Marketplace
# listing renders them without a megabyte of PNG riding along in every install.
docs/images/**
+
+# Benchmark fixtures. Six photographs, 3.6 MB, read only by `tools/bench-vision.js` on a
+# developer's machine. They shipped in no release — this rule was added the same day the
+# folder was, because `docs/**` is not ignored wholesale and a new subdirectory under it
+# is packaged by default. Anything else added there needs the same consideration.
+docs/test-images/**
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 99682f9..f0719a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,192 @@ All notable changes to HirayaCoder are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.1.0] — 2026-08-21
+
+HirayaCoder can look at pictures. Attach a screenshot, a photo, or a sketch of a screen
+you want built, and what is in it becomes part of the conversation — in Ask mode as the
+subject of the answer, in Agent and Plan mode as part of the job.
+
+The release also cuts the README down to what a first-time reader needs and moves
+everything else into `doc/`.
+
+### Added — image recognition
+
+`core/imageRecognition` asks a vision model what is in an attached image and returns the
+answer as plain text. Three separate reasons that indirection exists, and each would be
+enough on its own:
+
+**Most models cannot see.** `llama3.2` is what a lot of people are actually running, and
+it has no vision capability at all. Attaching an image to it used to be refused outright.
+Now the picture goes to whichever installed model *can* see, the description comes back,
+and the coding model works from that. Nobody has to switch models to use a screenshot,
+and the attach button is gated on the machine having a vision model rather than on the
+selected one being it.
+
+**The picture does not survive the loop.** Images ride on the first message only —
+re-uploading 5 MB of base64 on every turn of an eight-step run spends more time on the
+upload than on the thinking. So by turn four the model was reasoning about an image it
+could no longer see. A description is a hundred tokens and is carried on every turn,
+which is why one is produced even for a model with perfectly good vision.
+
+**A description can be checked.** A model that hallucinates an image is invisible when
+the image is a blob on a message. As a paragraph in the transcript, the user reads "a red
+error dialog" over their green one and knows immediately.
+
+The describer is chosen automatically: the selected model when it can see, since that
+costs no extra load, and otherwise the **smallest** installed vision model. Smallest
+because Ollama holds one model resident at a time, so a second model is paid for in load
+time, and describing a picture is a read rather than a piece of reasoning.
+`hirayacoder.vision.describeModel` overrides the choice; `hirayacoder.vision.enabled`
+turns the pass off.
+
+### Added — you can see what it read
+
+Every message with an image gets a collapsed panel holding the exact description the
+vision model produced.
+
+This is the part that makes the feature usable rather than merely present. When an answer
+about a picture is wrong there are two causes — the picture was misread, or it was read
+correctly and the reasoning went wrong — and they need opposite fixes. Without the
+description on screen they are indistinguishable, and the user's only move is to try
+again and hope.
+
+### Fixed — a redraft that argued the model out of a correct answer
+
+`agentSession._rethink` sends a mismatched reply back for one correction. It was sending
+the context and **not** the images, so a reply written from a photograph was being
+corrected by a model that could not see the photograph. The redraft turned good answers
+into hedges about not being able to see anything. It now carries whatever the draft
+carried.
+
+### Fixed — "I cannot see this image", stored as the description
+
+Found by the new benchmark, once in twenty-four runs of `qwen3.5:0.8b`: a model that
+reports the `vision` capability, that was sent the image, and that had described the same
+photograph correctly one sample earlier, replied *"I cannot see this image. I am an AI
+model designed to process text…"*. A small model's text-only training reasserting itself
+over what it was actually handed.
+
+Left alone, that paragraph becomes the description — stored, put in front of the coding
+model as "what is in this picture", and shown in the panel. Reasoning from a statement
+that no image exists is worse than being told the read failed, so the shape is now
+recognised and reported as a failure.
+
+The detector is narrow on purpose. The recognition prompt asks the describer to say when
+it cannot make something out, so *"I cannot see the licence plate clearly"* is the
+instruction working. It requires a first-person inability **plus** a visual object, and
+examines only the opening 400 characters, so a real description that ends with a
+boilerplate disclaimer survives.
+
+### Added — every reply says which model wrote it and how long it took
+
+A small line under each answer:
+
+```
+minicpm-v4.6:latest • 25s
+```
+
+On this hardware the model is the main lever anyone has — a task is one to five minutes,
+and the only way to make it faster is to run something smaller — so choosing between them
+means comparing replies that are minutes and several messages apart. The status line
+could not serve that: it is per-tab and the next turn overwrites it, so it says what is
+happening and never what happened. Attached to the reply, the comparison is just
+scrolling.
+
+The duration is the run itself, not the wait: a turn queued behind another tab does not
+start its clock until it owns the lane, so the number is time spent on *this* model
+rather than on somebody else's.
+
+When a **different** model read an attached image, it is named separately:
+
+```
+llama3.2:latest • 17s + minicpm-v4.6:latest read the image, 8s
+```
+
+That is not decoration. It is where the extra eight seconds went, and without it the
+coding model looks like it got slower.
+
+### Fixed — the project description drowning the picture
+
+Found by trying the packaged build rather than by any test. In Ask mode on
+`minicpm-v4.6`, "describe the image" with a photograph of a dog attached returned a
+description of HirayaCoder.
+
+Not a vision failure: the same model and the same photograph score 24/24 in
+`tools/bench-vision.js`. It was an instruction-following failure, and the instructions it
+followed were ours. Ask mode's system prompt opens with *"everything below this line is
+what you know about the user's project… Answer from it"*, and underneath sat 2,500
+characters of project description and file listing with `Task: describe the image` on the
+last line. A small model resolves that conflict by weight, and forty file paths outweigh
+one line of task.
+
+Two changes. On a turn with no tools that carries an image, the project overview and the
+workspace listing are left out — they are orientation the model cannot act on, and they
+were the thing being answered from. And every prompt gains a short instruction saying an
+image is attached and is the subject of the question. After both, the user turn went from
+2,510 characters to 24 and the same model answered: *"a cheerful Corgi with a fluffy
+white and tan coat, lying comfortably in a sunlit grassy field… wears a green collar"*.
+
+The listing is kept in Agent and Plan mode, where it is load-bearing: a model asked to
+build the screen in a mockup needs to know which paths exist, and there the picture is a
+fact about the job rather than a competitor to it.
+
+`contextBuilder` already carried a comment describing this exact shape for the chat route
+— a model handed a project description and the message "Hello Hiraya" answers with the
+project description. Same bug, different trigger, and the connection was missed.
+
+### Added — the image-recognition benchmark
+
+`tools/bench-vision.js`, graded against six photographs in `docs/test-images/` on four
+axes: subject, detail, text read, and confusion. Confusion can only be lost, because
+vague is usable and wrong is worse than nothing.
+
+The 1.1.0 baseline, machine B, 24 graded descriptions per model:
+
+| Model | Params | Subject | Detail | Text read | Confused | Cold |
+|---|---|---|---|---|---|---|
+| `minicpm-v4.6` | 752M | **24/24** | 42/48 | 4/8 | 0 | 11s |
+| `qwen3.5:0.8b` | 873M | 23/24 | **46/48** | **8/8** | 0 | 13s |
+
+Naming the subject is solved at this size. Reading text is not, and it turns out to
+depend on the size of the *text* rather than of the model: all four of `minicpm-v4.6`'s
+text failures are the same photograph, whose only text is a small badge on a car grille,
+and it read a large clear airline logo on every attempt.
+
+The timings needed a `--cold` flag to mean anything. Ollama caches the tokenized prompt,
+and for these requests the prompt is mostly the image, so the second sample of a picture
+returned in one second where the first took fifteen — and the cache survived between
+runs, reporting one second for everything half an hour later. `--cold` unloads the model
+between samples. None of it touches the accuracy scores.
+
+### Changed — the README is a fifth of the size
+
+514 lines to 259, cut to what somebody deciding whether to install this needs: what it
+does, what it cannot do, whether their machine will run it, and four steps. Everything
+else moved, whole, into `doc/`:
+
+| New file | What moved into it |
+|---|---|
+| `doc/GETTING-STARTED.md` | The full first-run walkthrough |
+| `doc/USING-IT.md` | Modes, watching a run, request habits |
+| `doc/LIMITATIONS.md` | **New.** Everything it cannot do, by design and otherwise |
+| `doc/IMAGE-RECOGNITION.md` | **New.** Images, in full |
+| `doc/TROUBLESHOOTING.md` | When something goes wrong, and the command allow-list |
+| `doc/CHOOSING-A-MODEL.md` | Which model to download |
+| `doc/DEVELOPING.md` | The engineering, benchmarks, building, contributing |
+
+`LIMITATIONS.md` is the one worth calling out. The limits were previously spread across
+five sections and phrased apologetically where they were phrased at all, and the two
+kinds — deliberate design choices, and things a small model is simply not good at — were
+mixed together. They are separated now, because the first kind is the reason to choose
+this tool and the second is the reason not to.
+
+### Security
+
+Full report in `security/sast-report-2026-08-21-1.1.0.md`. Zero production dependencies,
+zero advisories, no Critical or High findings. One ordering hardening applied in
+`agentSession`, and the `imageRecognition` regex measured rather than assumed.
+
## [1.0.0] — 2026-08-20
The version number is the news, and it is a claim about stability rather than about
diff --git a/README.md b/README.md
index be1d893..682f6a8 100644
--- a/README.md
+++ b/README.md
@@ -5,454 +5,224 @@
-
+
*A local Filipino-inspired AI coder that brings imagination and speed to your VS Code workflow.*
-> **1.0.0 is here.** Everything described below is implemented, tested on Windows, macOS
-> and Linux in CI, and stable enough to put a 1.0 on. The one thing still missing is the
-> one-click install: the Marketplace listing is not up yet, so releases are published as
-> a `.vsix` on the
-> [Releases page](https://github.com/jaymar921/HirayaCoder/releases) and installed by
-> hand — [Step 4](#step-4--install-hirayacoder) has the one command it takes.
-
**HirayaCoder is a free AI coding assistant that runs entirely on your own computer.**
-You type what you want in plain English, and it writes and edits the files for you — no
+You type what you want in plain English, and it writes and edits the files for you. No
account, no subscription, no internet connection, and nothing you write ever leaves your
laptop.
-> **Hiraya** (Filipino) — imagination, aspiration, the spark of an idea before it becomes real.
-
----
-
-## New here? Start with this
-
-**You do not need to know how to code to try it.** You do need about twenty minutes and
-a computer with a bit of room to spare. Here is the honest version of what you are
-signing up for.
-
-### What it actually does
-
-You open a folder on your computer, type something like *"make me a webpage with a
-to-do list"*, and HirayaCoder creates the files, writes the code, and shows you what it
-changed before saving anything. You can then ask it to change things: *"make the buttons
-blue"*, *"add a delete button"*.
+> **Hiraya** (Filipino): imagination, aspiration, the spark of an idea before it becomes real.
-### What makes it different from ChatGPT or Copilot
-
-| | HirayaCoder | ChatGPT / Copilot |
-|---|---|---|
-| Cost | Free, forever | Usually a monthly fee |
-| Internet | Not needed after setup | Required |
-| Your code | Never leaves your computer | Sent to a company's servers |
-| Quality | Good, not great — see below | Better |
-| Speed | Seconds to minutes, depends on your PC | Fast |
-
-**The trade is real and you should know it before you start.** HirayaCoder runs a small
-AI model on your own hardware, and small models are not as clever as the big paid ones.
-It handles ordinary tasks well. It struggles with big, vague requests. If you ask for
-"a full social media app" you will be disappointed; if you ask for one page, one feature,
-or one fix at a time, it does a decent job.
-
-### Will it run on my computer?
-
-The main question is **how much RAM (memory) you have**. Here's a quick guide — you do
-not need a fancy graphics card.
-
-| Your computer | Will it work? | What to expect |
-|---|---|---|
-| 8 GB RAM | Yes, just barely | Slow, and only simple single-file tasks |
-| 16 GB RAM, no graphics card | **Yes — this is what it was built for** | A task takes 1–5 minutes. Very usable |
-| 16 GB+ with a gaming graphics card | Yes, comfortably | A task takes 20–60 seconds |
-| Mac with Apple Silicon (M1–M4) | Yes, very well | A task takes 10–30 seconds |
-
-To check on Windows: press `Ctrl+Shift+Esc`, click **Performance**, then **Memory**.
-On a Mac: Apple menu → **About This Mac**.
+> **Not on the Marketplace yet.** Releases are published as a `.vsix` on the
+> [Releases page](https://github.com/jaymar921/HirayaCoder/releases) and installed with
+> one command. See [step 4](#quick-start).
---
-## Getting started
-
-Four steps. Copy and paste the commands exactly.
-
-### Step 1 — Install VS Code
-
-If you do not already have it, download it free from
-[code.visualstudio.com](https://code.visualstudio.com). This is the program you will
-actually be working in. You need **version 1.85 or newer** — any download from this year
-is fine.
+## What it does
-### Step 2 — Install Ollama
+You open a folder, type *"make me a webpage with a to-do list"*, and it creates the
+files, writes the code, and shows you every change before saving anything. Then you keep
+talking to it: *"make the buttons blue"*, *"add a delete button"*.
-[Ollama](https://ollama.com) is the free program that runs the AI on your computer.
-Download it, install it, and leave it running in the background. It has no window — it
-just sits in your system tray or menu bar, and that is normal.
-
-### Step 3 — Download an AI model
+
+
+
-Open a terminal and paste one line. (On Windows press the Start button, type
-`PowerShell`, and hit Enter. On a Mac press `Cmd+Space`, type `Terminal`, and hit Enter.)
+It runs the AI model on your own hardware through [Ollama](https://ollama.com), so it
+works with your Wi-Fi switched off. That is the whole point of it.
-**Pick the line that matches your computer:**
+---
-```bash
-# 16 GB RAM or more — the best all-round choice, start here
-ollama pull gemma4:e2b
+## New in 1.1.0: it can look at pictures
-# 8 GB RAM, or if the one above is too slow — smaller and faster, but more limited
-ollama pull llama3.2:1b
-```
+Attach a screenshot, a photo, or a sketch to your message, and HirayaCoder reads it.
-This downloads a few gigabytes, so it takes a while on a slow connection. You only ever
-do it once.
+- **Ask a question about it.** *"What is this error saying?"*
+- **Give it a job.** *"Build the screen in this mockup."* The picture becomes part of
+ the task.
-### Step 4 — Install HirayaCoder
+The useful part is that **this works even if your coding model cannot see**. Most AI
+models only handle text. A few, called vision models, can also take an image. If the
+model you picked is a text-only one, HirayaCoder quietly hands the picture to a vision
+model instead, gets back a written description of what is in it, and passes that
+description to your coding model. You do not have to switch models.
-Download the `.vsix` file from the
-[Releases page](https://github.com/jaymar921/HirayaCoder/releases), then run:
+If nothing you have installed can read images, one command fixes it:
```bash
-code --install-extension hirayacoder-.vsix
+ollama pull minicpm-v4.6
```
-Replace `` with the number in the filename you downloaded.
-
-### You're ready
+That is 1.6 GB, and it sits alongside whatever you already use for coding.
-1. Open VS Code.
-2. Go to **File → Open Folder** and pick a folder — an empty new one is perfect for a
- first try. **This step is required**: HirayaCoder refuses to do anything without a
- folder open, so that it can never touch files outside it.
-3. Press `Ctrl+Shift+H` (`Cmd+Shift+H` on a Mac).
-4. Type something and press Enter.
+**You can always see what it read.** Under your message there is a panel showing the
+exact description the vision model produced. Open it when an answer looks wrong, because
+it tells you whether the picture was misread or the answer was.
-Good first things to type:
-
-- `make a webpage that says hello with a big blue button`
-- `create a simple to-do list app in one HTML file`
-- `explain what this project does` *(in a folder that already has code)*
-
-**If anything above is unclear once you are in there, press *Guide* in the chat header.**
-It opens the same four setup steps and — more usefully — what to expect: how long a task
-takes, why a refusal is usually the checks working, and what a small model is and is not
-good at.
-
-There is a longer, friendlier walkthrough in
-[TUTORIAL.md](https://github.com/jaymar921/HirayaCoder/blob/main/doc/TUTORIAL.md).
+Full detail, including what it is bad at, is in
+[IMAGE-RECOGNITION.md](https://github.com/jaymar921/HirayaCoder/blob/main/doc/IMAGE-RECOGNITION.md).
---
-## Using it day to day
-
-
-
-
-
-### Watching a run happen
-
-
-
-
-
-A local model can take the better part of a minute per step, so the panel shows you each
-one as it happens: what it is doing, which file, and the reason the model gave for it.
-It opens when the first step arrives and folds away when the turn ends — and if you open
-or close it yourself, it stays how you left it.
-
-That matters most when a run is going wrong. Six steps in, you can see it re-reading the
-same file or editing something you never asked about, and stop it — rather than finding
-out from the summary ten minutes later.
-
-### Small models that finish
-
-
-
-
-
-The classic failure of a very small model is not bad code — it is the same correct-looking
-action forever. HirayaCoder keeps its own record of every file the agent has read, written
-and deleted, every folder it has listed and every command it has run, and puts that record
-in front of the model on each turn.
-
-A repeated read is no longer fatal either. Asking twice for a directory listing used to end
-the run; now the agent is handed back what it already had, told what to do next, and only
-stopped if it asks a third time.
-
-### Long requests, one step at a time
+## Limitations
-
-
-
-
-If you paste in a long, structured request — headings, numbered steps, a folder tree —
-HirayaCoder works through it **one section at a time**, in your order, using your words.
-It does not ask the model to summarise your request first: your headings already are the
-plan, and reading them takes no guesswork, so this works the same on a 0.8B model as on a
-large one.
-
-Two details worth knowing, because they let you steer it:
+Read this part. It is the honest description of the tool, and it will save you an
+evening if this is not what you need.
-- **Draw the folder structure you want** and it will be read as real paths. `src/` plus
- `components/` plus `TodoItem.jsx` becomes `src/components/TodoItem.jsx`.
-- **Put a comment next to a file** — `TodoItem.jsx # one todo row, with edit and
- delete` — and that comment becomes the instruction for writing it. A file with a
- comment beside it may be rewritten if it already exists; a file without one is left
- alone. That is how you say "this one is yours to write" and "this one came from the
- scaffolding tool, don't touch it".
+**It is not as clever as a paid cloud assistant.** It runs a small model on your own
+hardware. Small models handle ordinary, specific tasks well and vague, large ones badly.
+Ask for one page, one feature, or one fix at a time and it does a decent job. Ask for
+"a full social media app" and you will be disappointed.
-A section that only states rules — "React functional components only, no UI libraries" —
-is not treated as a step. It is carried underneath every step instead.
+**It does not finish a whole application.** This is measured, not guessed. On a brief for
+a complete React app, a small model scaffolds the project, writes every file you named,
+and installs the dependencies. Then the build fails, and no model at this size finished
+the app in any of our test runs.
-Short requests are unaffected. "Fix the typo in the heading" runs exactly as it always
-did.
+**It is slow.** On a 16 GB laptop with no graphics card, expect one to five minutes for
+an ordinary task. A graphics card makes it faster, not smarter. A bigger model makes it
+smarter, not faster.
-### Asked the wrong way
-
-
-
-
+**It never goes online.** No cloud models, no looking things up, no fetching
+documentation. The only address it will connect to is your own machine.
-The smallest models have a problem that looks like incompetence and is not. Asked to
-choose a tool and fill in its arguments as JSON, a 1B model will reliably answer with the
-simplest thing that satisfies the format — often just "done". Asked in plain English to
-write a file, the same model writes it correctly.
+**It cannot leave the folder you opened,** and it only runs commands from a fixed list
+(`node`, `npm`, `git`, `pytest` and about twenty more). There is no shell, so `&&`, `|`
+and `>` are refused rather than interpreted.
-So for a file **your request named**, HirayaCoder stops asking the model what to do. The
-decision is already made: the action is a file write, the path came from your own
-request, and the only question left is what goes in the file.
+**Small models make specific mistakes.** They repeat themselves, they report success
+having written nothing, and they occasionally write a file that parses fine but has an
+export deleted. There are checks for each of these, and the checks catch most of it
+rather than all of it.
-It is worth being precise about what that does and does not allow, because it is the one
-place the model is not choosing:
+**Image reading is approximate.** Large text and obvious subjects are reliable. Small
+text in a screenshot, exact numbers, and precise layout are not, and a misread arrives
+as a confident paragraph rather than as an apology.
-- It can only ever write a file **you named** in your request.
-- It never touches `package.json`, lockfiles, `.env`, or anything inside `node_modules`,
- `dist` or `.git`.
-- It never replaces a file that already exists unless you put a comment next to it.
-- Every write still shows you the diff and waits for approval, exactly as before, and
- still goes in the audit log.
-
-When it writes a file, it first reads what the files around it actually export, so the
-imports line up rather than being guessed at.
-
-### The three modes
-
-There is a row of buttons at the top of the chat. You can ignore them at first —
-**Agent** is the default and is what you want most of the time.
-
-| Mode | Use it when |
-|---|---|
-| **Agent** | You want it to actually write or change files. The normal choice. |
-| **Plan** | You want to see what it *would* do first, without it touching anything. |
-| **Ask** | You just have a question. It will not change any files. |
+The full version, including everything that is a deliberate design choice rather than a
+shortcoming, is in
+[LIMITATIONS.md](https://github.com/jaymar921/HirayaCoder/blob/main/doc/LIMITATIONS.md).
-You do not have to switch to Ask to ask a question — if you say "hello" or "what does
-this file do", Agent mode notices and just answers you.
-
-### Nothing changes without your say-so
+---
-By default, every time it wants to write to a file, you get a prompt with a **Review
-diff** button showing exactly what changes. Nothing is saved until you approve.
+## Will it run on my computer?
-Once you trust it, you can turn on **Auto Edit** to skip those prompts. Deleting a file
-always asks, even then.
+The question that matters is **how much RAM you have**. You do not need a graphics card.
-### Tips that make a real difference
+| Your computer | Will it work? | What to expect |
+|---|---|---|
+| 8 GB RAM | Just barely | Slow, simple single-file tasks only |
+| 16 GB RAM, no graphics card | **Yes, this is what it was built for** | A task takes 1 to 5 minutes |
+| 16 GB+ with a gaming graphics card | Comfortably | A task takes 20 to 60 seconds |
+| Mac with Apple Silicon (M1 to M4) | Very well | A task takes 10 to 30 seconds |
-- **Ask for one thing at a time — or structure the big ask.** "Add a delete button" works
- far better than "add delete, edit, sorting, and dark mode" thrown in as one sentence.
- If you do want the big one, give it headings, numbered steps or a folder tree, and it
- will work through them one at a time — see
- [Long requests, one step at a time](#long-requests-one-step-at-a-time).
-- **Name the file** if you know it. "Change the title in index.html" beats "change the
- title".
-- **It remembers this conversation**, so you can say "make it bigger" and it knows what
- "it" is.
-- **Be specific about what is wrong.** "The button doesn't do anything when I click it"
- is much more useful than "it's broken".
+To check on Windows: press `Ctrl+Shift+Esc`, click **Performance**, then **Memory**. On
+a Mac: Apple menu, then **About This Mac**.
---
-## When something goes wrong
+## Quick start
-Small AI models make mistakes. HirayaCoder has built-in checks that catch the common
-ones, so a few of the messages below are the system **working**, not breaking.
+Four steps. The
+[full walkthrough](https://github.com/jaymar921/HirayaCoder/blob/main/doc/GETTING-STARTED.md)
+explains each one properly if you want it.
-### "It refused to write the file"
+**1.** Install [VS Code](https://code.visualstudio.com), version 1.85 or newer.
-**This is usually a good thing.** HirayaCoder checks the AI's work before saving and
-blocks writes that would damage your files — a half-written file, code with a missing
-bracket, or a rewrite that quietly deletes something other files depend on.
+**2.** Install [Ollama](https://ollama.com) and leave it running. It has no window. It
+just sits in your system tray, and that is normal.
-Just ask again. It usually gets it right the second time.
+**3.** Download a model. Open a terminal and paste one line:
-### "It said it was done but nothing changed"
+```bash
+# 16 GB RAM or more, the best all-round choice. Reads images too
+ollama pull gemma4:e2b
-A known habit of small models: reporting success without doing the work. HirayaCoder
-checks and will tell you plainly when this happens. Ask again, and include the exact
-file name this time.
+# 8 GB RAM, or if the above is too slow
+ollama pull llama3.2:1b
+```
-### "It's taking forever"
+**4.** Download the `.vsix` from the
+[Releases page](https://github.com/jaymar921/HirayaCoder/releases) and install it:
-Normal on a laptop with no graphics card — a few minutes per task is expected. If it is
-much worse than that, the model is probably too big for your RAM. Try `llama3.2:1b`.
+```bash
+code --install-extension hirayacoder-.vsix
+```
-### "The code it wrote doesn't work"
+Then open a folder in VS Code (this is required, it refuses to work without one), press
+`Ctrl+Shift+H`, and type something. Good first tries:
-Try, in order:
+- `make a webpage that says hello with a big blue button`
+- `create a simple to-do list app in one HTML file`
+- `explain what this project does`
-1. Paste the error message into the chat. It is quite good at fixing errors it can see.
-2. Ask for a smaller piece of the problem.
-3. Switch to a bigger model if your RAM allows it.
+There is a **Guide** button in the chat header that repeats all of this from inside the
+extension.
-### "It can't run my program"
+---
-HirayaCoder can only run programs already installed on your computer. If you ask it to
-run a Python script, you need Python installed. It will still *write* the code — it just
-cannot run it for you.
+## The three modes
-It also only runs a fixed list of well-known commands. **This is the whole list** —
-anything else is refused on purpose, so a mistake by the AI cannot damage your system:
+There is a row of buttons at the top of the chat. Agent is the default and is what you
+want most of the time.
-| For | It may run |
+| Mode | Use it when |
|---|---|
-| JavaScript / Node | `node`, `npm`, `npx`, `yarn`, `pnpm` |
-| Python | `python`, `python3`, `pip`, `pip3`, `pytest` |
-| Java | `java`, `javac`, `mvn`, `gradle` |
-| Go, Rust, .NET | `go`, `cargo`, `dotnet` |
-| Testing and formatting | `jest`, `mocha`, `vitest`, `ava`, `tsc`, `eslint`, `prettier` |
-| Other | `git`, `make`, `ollama` |
-
-Two things worth knowing before they surprise you:
+| **Agent** | You want it to write or change files. The normal choice |
+| **Plan** | You want to see what it *would* do first, without it touching anything |
+| **Ask** | You just have a question |
-- **Everyday shell commands are not on the list** — `rm`, `ls`, `mkdir`, `curl` are all
- refused. Creating files and folders happens through the safe file tools instead, so
- `mkdir` is never needed: writing a file creates the folders above it.
-- **No shell is involved.** Commands run directly, so `&&`, `|`, and `>` are refused
- rather than interpreted. One command at a time.
-
-You can add to the list in HirayaCoder's settings. The AI cannot add to it itself.
+You do not have to switch to Ask to ask something. If you say "hello" or "what does this
+file do", Agent mode notices and just answers.
---
## Your privacy
-This is the part worth being blunt about, because it is the main reason to choose this
-over the alternatives.
+This is the main reason to choose this over the alternatives, so it is worth being blunt.
- **Nothing you type or open is sent anywhere.** The extension only ever talks to
`127.0.0.1`, which is your own computer. A non-local address is rejected in the code
itself, before any connection is opened.
- **No account, no sign-up, no telemetry.** Nobody is counting your keystrokes.
-- **It works with your Wi-Fi off.** Try it — that is the proof.
-- **The AI cannot leave your folder.** Every file operation is confined to the folder you
- opened.
+- **It works with your Wi-Fi off.** Try it. That is the proof.
+- **The AI cannot leave your folder.** Every file operation is confined to the folder
+ you opened.
- **No third-party code ships in the extension.** Zero production dependencies.
-Full detail: [SECURITY.md](https://github.com/jaymar921/HirayaCoder/blob/main/doc/SECURITY.md).
+Full detail in
+[SECURITY.md](https://github.com/jaymar921/HirayaCoder/blob/main/doc/SECURITY.md).
---
-## Choosing a model
-
-Model names are confusing. Here is a plain-language ranking, based on
-[real measurements](https://github.com/jaymar921/HirayaCoder/blob/main/doc/MODELS.md)
-rather than on the descriptions.
-
-| Model | Download | Needs | Verdict |
-|---|---|---|---|
-| `gemma4:e2b` | 7.2 GB | 16 GB RAM | **Best starting point.** Fastest to a correct answer |
-| `qwen3.5:4b` | 3.4 GB | 8–16 GB RAM | Good, smaller download |
-| `llama3.2:1b` | 1.3 GB | 8 GB RAM | For low-spec machines. Simple single-file jobs only |
-| `gemma4:e4b` | 9.6 GB | 32 GB RAM or a Mac | The strongest, if you have room |
-| `qwen3.5:0.8b` | 1.0 GB | — | Last resort. Writes files correctly; does not finish an app |
+## Documentation
-### What a very small model will and will not do
-
-Worth setting expectations, because the honest answer is more useful than the
-encouraging one. Measured on a 98-line brief for a complete React app:
-
-- **A 0.8B model now scaffolds the project, writes every file the request named, and
- installs the dependencies.** In the previous release the same model spent its whole
- session listing the directory and wrote nothing.
-- **It does not get the app working.** The build still fails, and no model at this size
- finished the app in any of our runs.
-
-So a very small model is genuinely useful for *"write me this file"* and for the parts of
-a big request that are mechanical. Handing it a whole application and walking away is not
-something we can recommend yet, and the numbers behind that are in
-[the evaluation notes](https://github.com/jaymar921/HirayaCoder/blob/main/doc/SESSION-ANALYSIS-0.9.0.md).
-
-Switch models any time from the dropdown at the top of the chat — no reinstall needed.
-
-**One thing that surprises people:** a graphics card makes it *faster*, not *smarter*.
-A bigger model gives better answers; a better GPU gives the same answer sooner.
+| | |
+|---|---|
+| [Getting started](https://github.com/jaymar921/HirayaCoder/blob/main/doc/GETTING-STARTED.md) | The complete first-run walkthrough |
+| [Using it day to day](https://github.com/jaymar921/HirayaCoder/blob/main/doc/USING-IT.md) | Modes, watching a run, and the habits that make small models work |
+| [Reading images](https://github.com/jaymar921/HirayaCoder/blob/main/doc/IMAGE-RECOGNITION.md) | Attaching screenshots and photos |
+| [Limitations](https://github.com/jaymar921/HirayaCoder/blob/main/doc/LIMITATIONS.md) | What it cannot do, by design and otherwise |
+| [Troubleshooting](https://github.com/jaymar921/HirayaCoder/blob/main/doc/TROUBLESHOOTING.md) | When something goes wrong |
+| [Choosing a model](https://github.com/jaymar921/HirayaCoder/blob/main/doc/CHOOSING-A-MODEL.md) | Which one to download, in plain language |
+| [Tutorial](https://github.com/jaymar921/HirayaCoder/blob/main/doc/TUTORIAL.md) | A longer, friendlier walkthrough |
+
+**For developers:**
+[Developing](https://github.com/jaymar921/HirayaCoder/blob/main/doc/DEVELOPING.md) ·
+[Architecture](https://github.com/jaymar921/HirayaCoder/blob/main/doc/ARCHITECTURE.md) ·
+[Features and settings](https://github.com/jaymar921/HirayaCoder/blob/main/doc/FEATURES.md) ·
+[Measurements](https://github.com/jaymar921/HirayaCoder/blob/main/doc/MODELS.md) ·
+[Security model](https://github.com/jaymar921/HirayaCoder/blob/main/doc/SECURITY.md) ·
+[Benchmarks](https://github.com/jaymar921/HirayaCoder/blob/main/benchmarks/README.md)
---
-## For developers
-
-Everything above is the beginner's path. The rest is the engineering, and it is
-documented properly elsewhere.
-
-**Features and settings** — [FEATURES.md](https://github.com/jaymar921/HirayaCoder/blob/main/doc/FEATURES.md)
-· **How it is built** — [ARCHITECTURE.md](https://github.com/jaymar921/HirayaCoder/blob/main/doc/ARCHITECTURE.md)
-· **Measurements** — [MODELS.md](https://github.com/jaymar921/HirayaCoder/blob/main/doc/MODELS.md)
-· **Security model** — [SECURITY.md](https://github.com/jaymar921/HirayaCoder/blob/main/doc/SECURITY.md)
-
-### What is interesting about it technically
-
-- **Agentic on every model, down to 1B.** It plans, reads, edits, deletes, and runs
- scripts across multiple files on its own. Two loop strategies — native tool-calling for
- capable models, a constrained one-action-per-turn JSON loop for small ones — behind one
- driver, so the mechanism changes with the model but the reach never does.
-- **Three layers of local memory.** A plain-text session log, the conversation itself,
- and typed facts about the project that persist across sessions, so the second session
- does not rediscover what the first one paid for.
-- **"Done" has to be true.** A run that reports success having written nothing, or having
- left `// Implement this here` inside a function it just wrote, gets sent back once with
- the specific problem named. Completion is judged from what changed on disk, never from
- what the model says about itself.
-- **It learns from what actually happened.** Outcomes are recorded locally — counts and
- guard codes, never your code — and a model that trips the same guard three times gets
- the matching correction added to its prompt. It adapts what the model is *told*, never
- what it is *allowed to do*.
-- **Every guard names a real failure.** The write guards exist because four different
- models produced six damaged files in one seventeen-run sweep: deleted exports, a
- CommonJS module silently rewritten as ESM, an implementation replaced by an empty
- object. Every one of them parsed cleanly.
-
-### Benchmarks
-
-Measured on three named machines, with the delete declined at the prompt on purpose — a
-model that claims it deleted the file has failed the task whatever else it got right.
-There are four harnesses: editing an existing project, building one from an empty folder,
-wiring an existing project together, and — new in 0.9.0 — building a whole application
-from one brief and then **driving the finished thing**, either by clicking every control
-in a headless browser or by calling its service layer directly.
-
-That last harness runs four briefs, so a model's trouble with the work can be told apart
-from its trouble with the language it was asked to work in:
-
-| Brief | Stack | Graded by |
-|---|---|---|
-| TODO app | React + Vite + Tailwind | 12 features, clicked in a browser |
-| Contact manager | React + Vite + Vitest | 12 features, clicked in a browser |
-| Point of sale | Java + Swing + Maven | 8 features, through the service layer |
-| Point of sale | Python + Tkinter, stdlib only | 8 features, through the service layer |
-
-It exists because of a result worth stating plainly: a model passed the scaffold,
-structure, install and build checks, and shipped an app whose only button incremented
-Vite's demo counter. Four green gates and nothing that worked. The full
-tables, including what each model broke and how, are in
-[MODELS.md](https://github.com/jaymar921/HirayaCoder/blob/main/doc/MODELS.md) and
-[benchmarks/](https://github.com/jaymar921/HirayaCoder/blob/main/benchmarks/README.md).
-
-The finding worth repeating here: **the mocked test suite passes clean while a real model
-destroys a real file.** Nearly every serious bug in this project was found by running an
-actual model, never by the unit tests.
-
-### Building from source
+## Building from source
Needs Node.js 18 or newer.
@@ -462,53 +232,28 @@ npm run test:all # lint + unit + integration, against a real VS Code
npm run package # builds builds/v/hirayacoder-.vsix
```
-### Contributing
-
-Contributions are welcome, with **one hard rule: pull requests only — never push
-directly to `main`.** CI runs the suite on Ubuntu, macOS, and Windows, and that matrix is
-the only evidence this project has that anything works on the two platforms the
-maintainer does not own.
-
-Read [CONTRIBUTING.md](https://github.com/jaymar921/HirayaCoder/blob/main/CONTRIBUTING.md)
-first. The short version:
-
-- Branch and commit as `feat/…`, `fix/…`, `docs/…`.
-- `npm run test:all` must pass.
-- **If you touched the agent loop, prompts, translator, or tools, run a real model**
- (`node tools/bench-agent.js gemma4:e2b agent auto full`) and put the outcome in the PR.
-- Don't weaken a guard or a permission prompt to make something pass.
-- Comments explain *why*, not *what*.
-
-Security issues: please contact [jaymar921](https://github.com/jaymar921) directly rather
-than opening a public issue.
-
-### Repository layout
-
-```
-HirayaCoder/
-├── app/ # Extension source — agent loops, tools, security layer, webview
-├── test/ # Unit + integration tests
-├── doc/ # Architecture, features, models, tutorial, security, publishing
-├── setup/ # AI build prompt + versioned model/translator system prompts
-├── security/ # Threat model, SAST reports
-├── scripts/ # Packaging
-├── tools/ # Live-model benchmark harnesses
-└── builds/ # Packaged .vsix output, per version (gitignored)
-```
+Contributions are welcome, with one hard rule: **pull requests only, never push directly
+to `main`.** Read
+[CONTRIBUTING.md](https://github.com/jaymar921/HirayaCoder/blob/main/CONTRIBUTING.md)
+first, and see
+[DEVELOPING.md](https://github.com/jaymar921/HirayaCoder/blob/main/doc/DEVELOPING.md)
+for what is worth knowing before you change the agent loop.
---
## Built from an AI prompt
This project was scaffolded from a single structured specification designed for AI
-coding agents. See [PROMPT.md](https://github.com/jaymar921/HirayaCoder/blob/main/setup/PROMPT.md)
-for the full build order, feature list, and security requirements.
+coding agents. See
+[PROMPT.md](https://github.com/jaymar921/HirayaCoder/blob/main/setup/PROMPT.md) for the
+full build order, feature list, and security requirements.
## Author
-Built by [**jaymar921**](https://github.com/jaymar921) — practical, resourceful, and made
+Built by [**jaymar921**](https://github.com/jaymar921). Practical, resourceful, and made
for real hardware rather than top-spec dev machines.
## License
-Licensed under the terms in [LICENSE](https://github.com/jaymar921/HirayaCoder/blob/main/LICENSE).
+Licensed under the terms in
+[LICENSE](https://github.com/jaymar921/HirayaCoder/blob/main/LICENSE).
diff --git a/app/agent/agentSession.js b/app/agent/agentSession.js
index 5ca93c7..3bdc8f8 100644
--- a/app/agent/agentSession.js
+++ b/app/agent/agentSession.js
@@ -28,6 +28,7 @@ const fs = require('fs');
const logger = require('../utils/logger');
const pathGuard = require('../security/pathGuard');
const promptRouter = require('../core/promptRouter');
+const imageRecognition = require('../core/imageRecognition');
const requestPlan = require('../core/requestPlan');
const fileTree = require('../core/fileTree');
const fileSpec = require('../core/fileSpec');
@@ -194,6 +195,15 @@ class ChangeSet {
* @property {'agent' | 'plan' | 'ask'} mode
* @property {string[]} [plan] Plan-mode steps, ready for the checklist UI.
* @property {import('./todoList').TodoItem[]} [todos] Items and their outcomes, when a list was used.
+ * @property {string} [model] Which model produced this reply.
+ * @property {number} [ms] Wall clock for the run, excluding any queue wait —
+ * `run` is not entered until the turn owns the lane, so this is time the user spent
+ * watching *this* model rather than another tab's.
+ * @property {number} [modelMs] Of that, time spent waiting on Ollama.
+ * @property {{model: string, ms: number}} [vision] Set only when a *different* model
+ * read the attached images. Without it the headline duration silently includes a
+ * second model's load and inference, which is the one thing the footer exists to
+ * make visible.
*/
/** No item can succeed in fewer than a read, a write, and a finish. */
@@ -611,6 +621,46 @@ class AgentSession {
* trace and observations carry the conversation from there.
*/
this.images = Array.isArray(options.images) ? options.images : [];
+ /**
+ * The same images, with their names and their bytes, for recognition.
+ *
+ * `images` is base64 alone because that is the shape Ollama's message format wants.
+ * Describing one needs a name to put in front of the description — "screenshot.png:
+ * a login form" is readable where three unlabelled paragraphs are not — so the
+ * fuller records are carried alongside rather than reconstructed.
+ *
+ * Falls back to wrapping `images` when only those were given, which is what every
+ * existing caller and every older test does.
+ *
+ * @type {Array<{name?: string, base64: string}>}
+ */
+ this.imageFiles =
+ Array.isArray(options.imageFiles) && options.imageFiles.length > 0
+ ? options.imageFiles
+ : this.images.map((base64, index) => ({ name: `image ${index + 1}`, base64 }));
+ /**
+ * How an attached image is turned into words, and by which model.
+ *
+ * `{ enabled, describeModel }` — `describeModel` is the name chosen by
+ * `imageRecognition.pickDescriber`, resolved by the caller because only it has the
+ * list of installed models. Null or absent means no vision model is installed, in
+ * which case an image reaches the model only if the selected model can see it
+ * directly, and otherwise not at all.
+ *
+ * `activeCanSee` is stated separately rather than inferred from
+ * `describeModel === model`, because the two answer different questions: whether
+ * the raw picture may ride on the message, and who writes the description. A user
+ * who pins a describer in settings makes those diverge.
+ *
+ * @type {{enabled?: boolean, describeModel?: string | null, activeCanSee?: boolean} | null}
+ */
+ this.vision = options.vision || null;
+ /**
+ * This turn's descriptions, produced once and carried on every turn after.
+ *
+ * @type {import('../core/imageRecognition').ImageDescription[]}
+ */
+ this._imageDescriptions = [];
this.scriptTimeoutMs = options.scriptTimeoutMs;
/**
* Where this session's evidence goes, and where the previous ones' came from.
@@ -835,12 +885,19 @@ class AgentSession {
// "Read the README" should not be able to reach `run_script`, whatever the
// planner decides in between. See `intentRouter.isReadOnlyRequest`.
readOnlyTurn: intentRouter.isReadOnlyRequest(task, this.conversation),
+ // Changes the instruction, never the tools. See `promptRouter.IMAGE_SUFFIX`.
+ hasImages: this.imageFiles.length > 0,
});
if (activeRoute.readOnlyTurn) {
logger.info('This message asks to look, not to change; the mutating tools are not offered this turn.');
}
+ // Before the first context is built and before any loop starts, so every turn
+ // downstream sees the same words. Routed first because what the description is
+ // for — answering a question, or doing a job — depends on which strategy won.
+ const imageNotes = await this._describeImages(activeRoute, task, emit);
+
emit({
type: 'start',
mode,
@@ -854,7 +911,9 @@ class AgentSession {
// Ask mode, and Agent mode answering conversation: one response, no loop, no
// tools in existence.
if (activeRoute.strategy === 'none' || activeRoute.strategy === 'chat') {
- const askContext = await this._buildContext(task, activeRoute, options.editor);
+ const askContext = await this._buildContext(task, activeRoute, options.editor, {
+ imageDescriptions: imageNotes,
+ });
const summary = await this._answerDirectly(task, activeRoute, askContext);
// Nothing else records a conversational turn: no steps, no change set, no file
// history. Without this the whole exchange leaves memory empty.
@@ -892,7 +951,9 @@ class AgentSession {
client: this.client,
model: this.model,
task,
- context: await this._buildContext(task, activeRoute, options.editor),
+ context: await this._buildContext(task, activeRoute, options.editor, {
+ imageDescriptions: imageNotes,
+ }),
signal: this._controller.signal,
})
: null;
@@ -904,7 +965,9 @@ class AgentSession {
// Tier B budget is a measurable waste and reads to the model as emphasis it
// was never meant to carry.
const effectiveTask = plan && plan.length > 0 ? `${task}\n\nYour plan:\n${plan.join('\n')}` : task;
- const context = await this._buildContext(effectiveTask, activeRoute, options.editor);
+ const context = await this._buildContext(effectiveTask, activeRoute, options.editor, {
+ imageDescriptions: imageNotes,
+ });
const loop = activeRoute.strategy === 'native' ? nativeToolLoop : reactLoop;
const outcome = await loop.run({
@@ -919,7 +982,7 @@ class AgentSession {
verifyDone: mode === 'agent' ? this._doneVerifier(task, changeSet, 0) : undefined,
onEvent: emit,
signal: this._controller.signal,
- images: this.images,
+ images: this._visibleImages(),
});
await this._remember(outcome.steps);
@@ -1758,8 +1821,17 @@ class AgentSession {
.replace(/[.,;:!?)\]]+$/, '');
if (!target || seen.has(target)) continue;
seen.add(target);
- if (UNDICTATABLE.test(target)) continue;
+ // Length-bounded check first, then the pattern.
+ //
+ // Both are `continue` guards with no side effects, so the order is free to
+ // choose — and this way the only unbounded regex in this path never sees a string
+ // longer than `MAX_TARGET_PATH_CHARS`. `UNDICTATABLE` measures linear even on a
+ // 40,000-character adversarial input (0.03 ms), so this is not a fix for a live
+ // ReDoS; it is the cheap ordering that means one is never reachable here if the
+ // pattern is ever edited into an ambiguous one. `target` is model output, which
+ // is the input class this project does not extend trust to.
if (!isDictatableFilename(target)) continue;
+ if (UNDICTATABLE.test(target)) continue;
// You cannot fill in a project that has not been created yet.
//
@@ -2286,6 +2358,28 @@ class AgentSession {
const modelMs =
typeof this._modelMsAtStart === 'number' ? this._modelMsSoFar() - this._modelMsAtStart : undefined;
+ // Attached to the result as well as logged. The panel shows this under every reply
+ // so that switching models is a comparison rather than a guess — which is the whole
+ // reason anyone switches on this hardware.
+ //
+ // Mutating the caller's object rather than threading a return value through: every
+ // path in `run` awaits this before returning the same object, so there is exactly
+ // one place to set it.
+ result.model = this.model;
+ if (typeof ms === 'number') result.ms = ms;
+ if (typeof modelMs === 'number') result.modelMs = modelMs;
+
+ // Only when a *different* model did the looking. When the selected model described
+ // its own image there is no second cost to account for, and naming it twice reads
+ // as though there were.
+ const described = this._imageDescriptions.filter((entry) => entry.model !== this.model);
+ if (described.length > 0) {
+ result.vision = {
+ model: described[0].model,
+ ms: described.reduce((total, entry) => total + (entry.ms || 0), 0),
+ };
+ }
+
// Logged whether or not the ledger is on. Adaptation is a choice about whether the
// extension *learns*; how long a turn took is the first thing anyone needs when a
// session felt slow, and the output channel is where they will look for it.
@@ -2391,9 +2485,11 @@ class AgentSession {
* @param {string} task
* @param {import('../core/promptRouter').Route} activeRoute
* @param {object} [editor]
- * @param {{recallAbout?: string}} [opts] What this turn is about, for targeted
- * memory recall. Empty means recency, which is the behaviour everywhere but a
- * briefed step.
+ * @param {{recallAbout?: string, imageDescriptions?: string}} [opts]
+ * `recallAbout` is what this turn is about, for targeted memory recall; empty means
+ * recency, which is the behaviour everywhere but a briefed step. `imageDescriptions`
+ * is the rendered block from `_describeImages`, threaded through rather than read
+ * off the session so a briefed step can decide for itself whether to carry it.
* @returns {Promise}
* @private
*/
@@ -2428,7 +2524,23 @@ class AgentSession {
// twice in one session, once for a greeting and once for "I'm Jay". The chat
// route exists for messages that are not about the project, and giving it the
// one block that is guarantees the reply will be.
- projectOverview: activeRoute.strategy === 'chat' ? '' : projectOverview.build(this.workspaceRoot),
+ //
+ // Also dropped on a loopless turn that carries an image, which is the same
+ // failure with a different trigger. Measured on `minicpm-v4.6`: "describe the
+ // image" with a photograph attached returned a description of HirayaCoder. The
+ // picture was on the message and the model can see — it scores 24/24 on this
+ // exact photograph in `tools/bench-vision.js` — but it was handed 2,500
+ // characters of project description and file listing with the task on the last
+ // line, and a small model resolves that by weight.
+ //
+ // Only the loopless routes. In Agent and Plan the listing is load-bearing: a
+ // model asked to build the screen in a mockup needs to know which paths exist,
+ // and there the picture is not competing with the project, it is a fact about
+ // the job.
+ projectOverview:
+ activeRoute.strategy === 'chat' || (loopless && this.imageFiles.length > 0)
+ ? ''
+ : projectOverview.build(this.workspaceRoot),
// A model that has to discover the file tree spends steps on it and, worse,
// invents paths when it guesses. Seeding the listing costs a fraction of the
// budget and removes the most common failure on Tier B outright.
@@ -2439,7 +2551,14 @@ class AgentSession {
// routinely asked what is *in* the project, and with an empty listing it answered
// "There are no files listed in your workspace" — in a workspace of several
// hundred files. Naming what exists is not an action; it is the answer.
- workspaceFiles: await this._workspaceFiles(activeRoute),
+ // Suppressed alongside the project overview, and for the same measured reason: on
+ // a turn with no tools, the listing is orientation the model cannot act on, and
+ // forty file paths outweigh one line of task.
+ workspaceFiles: loopless && this.imageFiles.length > 0 ? [] : await this._workspaceFiles(activeRoute),
+ // Carried on every turn of the run, unlike the image itself. That is the whole
+ // point of producing it: the picture reaches turn one and the words reach all of
+ // them.
+ imageDescriptions: opts.imageDescriptions || '',
});
return built.text;
@@ -2582,6 +2701,9 @@ class AgentSession {
* @param {string} [opts.systemPrompt] Reused for the redraft, so the corrected answer
* is bound by the same mode rules as the draft. Falls back to a minimal instruction.
* @param {string} [opts.context] The context the draft was written from.
+ * @param {boolean} [opts.images] Re-attach this turn's images to the redraft.
+ * Set by the Ask path, where the draft was written from a picture; a redraft
+ * without it argues the model out of an answer it got right.
* @returns {Promise}
* @private
*/
@@ -2607,7 +2729,15 @@ class AgentSession {
opts.systemPrompt ||
'You are HirayaCoder. Answer the user\'s question directly and concisely, in prose.',
},
- ...(opts.context ? [{ role: 'user', content: opts.context }] : []),
+ ...(opts.context
+ ? [
+ {
+ role: 'user',
+ content: opts.context,
+ ...(opts.images && this._visibleImages().length > 0 ? { images: this._visibleImages() } : {}),
+ },
+ ]
+ : []),
{
role: 'user',
content:
@@ -2647,6 +2777,114 @@ class AgentSession {
}
}
+ /**
+ * The base64 images that may ride on an Ollama message this turn.
+ *
+ * Empty whenever the selected model has no vision capability. Sending an image to a
+ * model that cannot see one is not an error at the API level — it is silently
+ * dropped, after being uploaded — so the only effect is a slower turn. The picture
+ * still reaches such a model, as the description assembled by `_describeImages`.
+ *
+ * @returns {string[]}
+ * @private
+ */
+ _visibleImages() {
+ if (this.images.length === 0) return [];
+ // Absent vision config is the pre-1.1.0 shape, where the caller had already refused
+ // to attach an image to a model that could not see it. Trusting it keeps every
+ // existing caller and test working unchanged.
+ if (!this.vision) return this.images;
+ return this.vision.activeCanSee === false ? [] : this.images;
+ }
+
+ /**
+ * Turn this turn's attached images into text, once.
+ *
+ * ## Why Ask mode on a vision model skips this entirely
+ *
+ * There the picture itself goes on the message and the model answers from it
+ * directly. A description pass would be a second full generation — a minute or more
+ * on CPU — to produce a paraphrase that is strictly worse than the original for the
+ * one model that was going to look at the original anyway.
+ *
+ * Every other combination needs it. Agent and Plan run a loop whose later turns have
+ * no picture, and a model without vision has no picture on any turn.
+ *
+ * @param {import('../core/promptRouter').Route} activeRoute
+ * @param {string} task
+ * @param {(event: object) => void} emit
+ * @returns {Promise} The rendered prompt block, or '' when there is none.
+ * @private
+ */
+ async _describeImages(activeRoute, task, emit) {
+ this._imageDescriptions = [];
+ if (this.imageFiles.length === 0) return '';
+
+ const vision = this.vision || {};
+ if (vision.enabled === false) {
+ logger.info('Image recognition is turned off in settings; the attached image is not being read.');
+ return '';
+ }
+
+ const loopless = activeRoute.strategy === 'none' || activeRoute.strategy === 'chat';
+ const purpose = loopless ? 'answer' : 'task';
+
+ if (loopless && vision.activeCanSee !== false) return '';
+
+ const describer = vision.describeModel || (vision.activeCanSee === false ? null : this.model);
+ if (!describer) {
+ logger.warn('An image was attached but no installed model can read one.');
+ return '';
+ }
+
+ const started = Date.now();
+ this._imageDescriptions = await imageRecognition.describeAll({
+ client: this.client,
+ model: describer,
+ images: this.imageFiles,
+ purpose,
+ // Only the answering purpose gets the question. A task-purpose description is
+ // read by the loop on every turn, and steering it with turn one's wording makes
+ // it progressively less useful as the run moves on to the other items.
+ question: purpose === 'answer' ? task : '',
+ signal: this._controller ? this._controller.signal : undefined,
+ onProgress: ({ index, total, name }) => {
+ // A vision pass on CPU is thirty seconds to two minutes with no step counter
+ // moving, which reads as a hang. This is the only thing on screen during it.
+ emit({
+ type: 'status',
+ text:
+ total === 1
+ ? `Reading ${name} with ${describer}…`
+ : `Reading ${name} with ${describer} (${index} of ${total})…`,
+ });
+ },
+ });
+
+ const failed = this._imageDescriptions.filter((entry) => !entry.ok);
+ if (failed.length > 0) {
+ logger.warn(`${failed.length} of ${this._imageDescriptions.length} image(s) could not be described.`);
+ }
+
+ const readable = this._imageDescriptions.filter((entry) => entry.ok);
+ logger.info(
+ `Described ${readable.length} image(s) with ${describer} in ${Math.round((Date.now() - started) / 1000)}s.`
+ );
+
+ // Shown in the transcript rather than only logged: the description is the whole
+ // basis of the answer that follows, and a user who can read it can tell a
+ // misreading from a bad answer. That distinction is invisible otherwise.
+ if (readable.length > 0) {
+ emit({
+ type: 'images-described',
+ model: describer,
+ descriptions: readable.map((entry) => ({ name: entry.name, description: entry.description })),
+ });
+ }
+
+ return imageRecognition.renderForPrompt(readable);
+ }
+
/**
* Ask mode. One call, no tools offered at all.
*
@@ -2664,7 +2902,10 @@ class AgentSession {
messages: [
{ role: 'system', content: activeRoute.systemPrompt },
// Ollama takes images as base64 on the message itself.
- { role: 'user', content: context, ...(this.images.length > 0 ? { images: this.images } : {}) },
+ ...(() => {
+ const visible = this._visibleImages();
+ return [{ role: 'user', content: context, ...(visible.length > 0 ? { images: visible } : {}) }];
+ })(),
],
options: { temperature: 0.3 },
},
@@ -2673,7 +2914,16 @@ class AgentSession {
const draft =
String((response && response.message && response.message.content) || '').trim() || 'No answer was produced.';
// Nothing this path does can change a file, so a changelog here is always wrong.
- return this._rethink(task, draft, { changedFiles: false, systemPrompt: activeRoute.systemPrompt, context });
+ return this._rethink(task, draft, {
+ changedFiles: false,
+ systemPrompt: activeRoute.systemPrompt,
+ context,
+ // The redraft used to go out blind. A reply about a photograph, sent back to be
+ // corrected without the photograph, was rewritten into a hedge about not being
+ // able to see it — the check firing on a good answer and replacing it with a
+ // worse one. Whatever the draft could see, the redraft sees too.
+ images: true,
+ });
} catch (err) {
return `The model could not be reached: ${/** @type {Error} */ (err).message}`;
}
diff --git a/app/core/contextBuilder.js b/app/core/contextBuilder.js
index 254c983..77fa8be 100644
--- a/app/core/contextBuilder.js
+++ b/app/core/contextBuilder.js
@@ -16,7 +16,8 @@
*
* The priority order below encodes that judgment:
*
- * task > latest observation > session memory > selection > context files > open file
+ * task > latest observation > attached images > session memory > selection >
+ * context files > open file
*
* The task is non-negotiable. The latest observation ranks second because a ReAct
* loop that forgets what its last action returned will repeat it forever. Memory
@@ -48,6 +49,12 @@ const PRIORITY = {
// are the thing being referred to. Notes are a compression of the same material, so
// when only one survives the budget it should be the primary source.
conversation: 85,
+ // Above the conversation and everything below it, and only just below the last
+ // observation. When the selected model cannot see, this block is the *only* form the
+ // attached image takes — dropping it does not degrade the answer, it removes the
+ // thing the user was asking about. It is also small: a description is capped at
+ // ~1600 characters, so ranking it high costs little.
+ imageDescriptions: 88,
memory: 80,
// Above the selection and everything below it. This block is ~100 tokens that say
// what the project is in its author's own words, and without it the model answers
@@ -82,6 +89,7 @@ const PRIORITY = {
* @property {string} [contextFiles] Rendered block from contextFilesManager.
* @property {string} [observation] Result of the previous agent step.
* @property {string[]} [workspaceFiles] Nearby paths, for orientation.
+ * @property {string} [imageDescriptions] Rendered block from core/imageRecognition.
* @property {Array<{role: string, text: string}>} [conversation]
* Earlier turns of this chat, oldest first, excluding the message being answered.
*/
@@ -179,6 +187,30 @@ function build(request) {
}
}
+ if (request.imageDescriptions) {
+ sections.push({
+ name: 'Images',
+ // Redacted like every other block here, and for a reason that is easy to miss: a
+ // description is model prose, but the prose is *about a picture of the user's
+ // screen*. A screenshot of a terminal with `export OPENAI_API_KEY=sk-…` in it
+ // produces a description containing the key, and the recognition prompt asks the
+ // describer to copy visible text exactly — so this path does not merely permit
+ // that, it requests it.
+ //
+ // `redact` replaces the matched secret and leaves the sentence around it standing,
+ // so the description stays useful.
+ content: redact(request.imageDescriptions),
+ priority: PRIORITY.imageDescriptions,
+ // High, and higher than any other section's floor. A description trimmed to two
+ // sentences keeps "a screenshot of a form" and loses the error message on it,
+ // which is invariably the part the user attached it for. Better to drop the
+ // block whole and have the model say it was not given the image than to hand it
+ // a fragment it will answer confidently from.
+ minTokens: 120,
+ keep: 'head',
+ });
+ }
+
if (request.projectOverview) {
sections.push({
name: 'Project',
diff --git a/app/core/imageRecognition.js b/app/core/imageRecognition.js
new file mode 100644
index 0000000..168b0f7
--- /dev/null
+++ b/app/core/imageRecognition.js
@@ -0,0 +1,474 @@
+'use strict';
+
+/**
+ * Image recognition — turning a picture into words the rest of the agent can use.
+ *
+ * `features/imageContext` reads an image off disk and hands back base64. This module is
+ * the step after: it asks a vision model what the picture actually contains and returns
+ * that as plain text. Everything downstream — the ReAct loop, the native tool loop, Ask
+ * mode's single call, the summary — consumes text and nothing else, so a description is
+ * the only form in which an image can reach them.
+ *
+ * ## Why a description exists at all, when Ollama accepts images directly
+ *
+ * Three separate reasons, and each one on its own would be enough.
+ *
+ * **The picture does not survive the loop.** Images ride on the first message only —
+ * see `AgentSession.images` for why re-uploading 5 MB of base64 every turn is not an
+ * option. So on turn four of an eight-step run the model is working from a conversation
+ * about an image it can no longer see. A description is a hundred tokens and can be
+ * carried on every turn.
+ *
+ * **Most models cannot see.** `llama3.2` is the model a lot of people are actually
+ * running, and it has no vision capability at all. Before this, attaching an image to
+ * it was simply refused. Now the picture goes to whichever vision model is installed,
+ * the description comes back, and the coding model works from that. The user does not
+ * have to switch models to use a screenshot.
+ *
+ * **A description can be checked.** A model that hallucinates an image is invisible when
+ * the image is a blob on a message; when it is a paragraph of text in the transcript,
+ * the user reads "a red error dialog" over their green one and knows immediately.
+ *
+ * ## Why the active model is preferred even when it is the worse describer
+ *
+ * Ollama holds one model resident at a time. Describing with a different model evicts
+ * the coding model and pays its load time twice — on a 16 GB laptop with no GPU that is
+ * thirty to sixty seconds of nothing happening, before the actual request has started.
+ * When the selected model can see, using it is free; when it cannot, the smallest
+ * installed vision model is chosen, because a description is a read rather than a piece
+ * of reasoning and the smallest model that can do it is the one that finishes soonest.
+ *
+ * @module core/imageRecognition
+ */
+
+const crypto = require('crypto');
+
+const logger = require('../utils/logger');
+
+/**
+ * What the describer is asked for, per purpose.
+ *
+ * ## Why there are two of these and not one
+ *
+ * The two callers want genuinely different things out of the same picture, and a single
+ * prompt serves neither well.
+ *
+ * Ask mode is a person holding up a photograph. What matters is the subject, what it is
+ * doing, and how it looks — the answer to "what is this?" is a sentence about a dog, not
+ * an inventory of pixel regions.
+ *
+ * Agent and Plan mode are a coding agent that will act on what it is told. There the
+ * priority inverts: every character of text in the image outranks every adjective,
+ * because a misread identifier sends the agent to edit a file that does not exist, and
+ * a wrong colour costs nothing. So the task prompt puts transcription first and says
+ * plainly that guessing is worse than admitting the text is unreadable.
+ *
+ * Both are short and numbered on purpose. A 0.8B model given a paragraph of nuanced
+ * instruction answers the paragraph; given four numbered points it answers the points.
+ */
+const PROMPTS = {
+ answer: [
+ 'Look at this image and describe what is in it.',
+ '',
+ 'Cover these, in order:',
+ '1. The main subject, in a few words.',
+ '2. What it is doing, or how it is arranged.',
+ '3. The setting, the colours, and anything else that stands out.',
+ '4. Any text, numbers, or labels you can read, copied exactly.',
+ '',
+ 'Rules:',
+ '- Describe only what you can actually see. Do not guess at anything outside the frame.',
+ '- If you are unsure what something is, say so. A wrong confident answer is worse than an unsure one.',
+ '- Write plain prose, under 150 words. No headings and no bullet points.',
+ ].join('\n'),
+
+ task: [
+ 'You are the eyes of a coding agent. It cannot see this image. Your description is',
+ 'the only thing it will ever get, so write down what it needs in order to do the work.',
+ '',
+ 'Cover these, in order:',
+ '1. What kind of image this is — a screenshot, a diagram, a photo, a UI mockup, an error message.',
+ '2. Every piece of text, code, number, or label in it, copied exactly as written.',
+ '3. The layout: what is where, what is grouped with what, what is a button, a field, or a panel.',
+ '4. Colours, sizes, and spacing, where they look deliberate rather than incidental.',
+ '',
+ 'Rules:',
+ '- Point 2 matters more than the rest put together. A misread filename sends the agent to the wrong file.',
+ '- If text is too small or too blurred to read, write "unreadable" rather than guessing at it.',
+ '- Never invent a filename, an error code, or a line of code that is not visibly there.',
+ '- Describe only. Do not suggest what should be done about any of it.',
+ '- Under 250 words.',
+ ].join('\n'),
+};
+
+/**
+ * A describer denying that it can see.
+ *
+ * ## Why this is checked rather than trusted
+ *
+ * Found by `tools/bench-vision.js`, once in twenty-four runs of `qwen3.5:0.8b` — a
+ * model that reports the vision capability, that was sent the image, and that had
+ * described the same photograph correctly on the previous sample. The reply was:
+ *
+ * "I cannot see this image. I am an AI model designed to process text and provide
+ * information, but I do not have the ability to view or interpret visual content…"
+ *
+ * A small model's text-only training reasserting itself over what it was actually
+ * handed. Left alone, that paragraph becomes the description: it is stored, put in
+ * front of the coding model as "what is in this picture", and shown to the user in the
+ * panel. The coding model then works from a statement that no image exists, which is
+ * strictly worse than being told the read failed.
+ *
+ * So it is treated as a failed call, not as a description. `describe` already has an
+ * honest failure path, and this is one.
+ *
+ * The pattern is deliberately narrow. It requires a first-person inability *plus* a
+ * visual object, so an ordinary description containing "I cannot see the licence plate
+ * clearly" — which is exactly the hedging the prompt asks for — is not caught by it.
+ */
+const REFUSAL =
+ /\b(?:i (?:cannot|can't|can not|am unable to|don't have the ability to|do not have the ability to)|as an ai(?: model| language model)?,? i (?:cannot|can't))\b[^.]{0,80}\b(?:see|view|interpret|process|analyz|analys|look at|access)\w*\b[^.]{0,60}\b(?:image|images|picture|photo|photograph|visual|screenshot)/i;
+
+/**
+ * @param {string} text
+ * @returns {boolean}
+ */
+function looksLikeRefusal(text) {
+ // Only the opening matters. A model that produces a real description and then
+ // volunteers a disclaimer at the end has still described the picture, and discarding
+ // that would throw away a good answer over its last sentence.
+ return REFUSAL.test(text.slice(0, 400));
+}
+
+/**
+ * Hard ceiling on a stored description, in characters.
+ *
+ * A describer that ignores the word limit and free-associates for two thousand words
+ * would otherwise spend the whole Tier B prompt budget on one picture, evicting the
+ * task itself. Truncation is from the head, which is where the subject is.
+ */
+const MAX_DESCRIPTION_CHARS = 1600;
+
+/**
+ * Token ceiling for one description call.
+ *
+ * Roughly twice what the 250-word limit needs. Generous enough that a model that
+ * numbers its points is not cut off mid-sentence, tight enough that a model stuck in a
+ * loop stops rather than running to the request timeout.
+ */
+const DESCRIBE_NUM_PREDICT = 640;
+
+/**
+ * Descriptions already produced, keyed by image content plus purpose plus model.
+ *
+ * Attaching the same screenshot to three consecutive messages is completely ordinary,
+ * and each description is a full model call — thirty seconds or more on CPU. The key
+ * includes the model and the purpose because a different model or a different prompt is
+ * a different answer, not a cache hit.
+ *
+ * Bounded rather than unbounded: this lives for the lifetime of the extension host, and
+ * a base64 fingerprint is small but a description is not free.
+ *
+ * @type {Map}
+ */
+const cache = new Map();
+
+/** Descriptions kept before the oldest is evicted. */
+const CACHE_LIMIT = 32;
+
+/**
+ * @param {string} base64
+ * @param {string} purpose
+ * @param {string} model
+ * @returns {string}
+ */
+function fingerprint(base64, purpose, model) {
+ // sha256 of the bytes, not the whole base64 string as a key: the string is megabytes
+ // and would be held in the Map forever.
+ const digest = crypto.createHash('sha256').update(base64).digest('hex').slice(0, 32);
+ return `${model} ${purpose} ${digest}`;
+}
+
+/**
+ * @param {string} key
+ * @param {string} description
+ */
+function remember(key, description) {
+ // Re-inserting moves the entry to the end of the Map's insertion order, so a
+ // repeatedly used image is not the one evicted.
+ if (cache.has(key)) cache.delete(key);
+ cache.set(key, description);
+ while (cache.size > CACHE_LIMIT) {
+ const oldest = cache.keys().next();
+ if (oldest.done) break;
+ cache.delete(oldest.value);
+ }
+}
+
+/**
+ * Choose which model does the looking.
+ *
+ * @param {import('./modelDiscovery').DiscoveredModel[]} models Everything installed.
+ * @param {object} [opts]
+ * @param {string} [opts.activeModel] The model the user selected for coding.
+ * @param {string} [opts.preferred] From settings. Honoured only if it is installed
+ * and can actually see — a stale name in settings must not silently disable images.
+ * @returns {{name: string, reason: string, isActive: boolean} | null}
+ * Null when nothing installed can see, which is a real state and not an error.
+ */
+function pickDescriber(models, opts = {}) {
+ const installed = Array.isArray(models) ? models.filter((m) => m && m.name) : [];
+ const capable = installed.filter((m) => m.supportsVision);
+ if (capable.length === 0) return null;
+
+ const preferred = String(opts.preferred || '').trim();
+ if (preferred) {
+ const match = capable.find((m) => m.name === preferred);
+ if (match) {
+ return { name: match.name, reason: 'chosen in settings', isActive: match.name === opts.activeModel };
+ }
+ // Named but unusable. Worth a log line rather than silence: the user set this
+ // deliberately and is entitled to know it is not being honoured.
+ const installedButBlind = installed.find((m) => m.name === preferred);
+ logger.warn(
+ installedButBlind
+ ? `hirayacoder.vision.describeModel is set to ${preferred}, which is installed but cannot read images. Picking another.`
+ : `hirayacoder.vision.describeModel is set to ${preferred}, which is not installed. Picking another.`
+ );
+ }
+
+ const active = capable.find((m) => m.name === opts.activeModel);
+ if (active) {
+ return {
+ name: active.name,
+ reason: 'the selected model can read images itself',
+ isActive: true,
+ };
+ }
+
+ // Smallest first. A description is a read, not a piece of reasoning, and the whole
+ // cost of using a second model here is the time it takes to load and answer.
+ // `params` is null when Ollama did not report a size; those sort last rather than
+ // first, so an unknown quantity is never chosen over a known small one.
+ const smallest = capable
+ .slice()
+ .sort((a, b) => {
+ const left = typeof a.params === 'number' ? a.params : Number.POSITIVE_INFINITY;
+ const right = typeof b.params === 'number' ? b.params : Number.POSITIVE_INFINITY;
+ if (left !== right) return left - right;
+ return a.name.localeCompare(b.name);
+ })[0];
+
+ return {
+ name: smallest.name,
+ reason: `${opts.activeModel || 'the selected model'} cannot read images, so the smallest installed vision model does the looking`,
+ isActive: false,
+ };
+}
+
+/**
+ * @typedef {object} ImageDescription
+ * @property {string} name The file's basename, so the user can tell two apart.
+ * @property {string} description What the model saw, or the reason there is nothing.
+ * @property {string} model Which model produced it.
+ * @property {number} ms How long it took, for the status line and the benchmark.
+ * @property {boolean} ok False when the call failed; `description` is then the error.
+ */
+
+/**
+ * Describe one image.
+ *
+ * @param {object} options
+ * @param {import('./ollamaClient').OllamaClient} options.client
+ * @param {string} options.model
+ * @param {{name?: string, base64: string}} options.image
+ * @param {'answer' | 'task'} [options.purpose]
+ * @param {string} [options.question] What the user typed. Included for the `answer`
+ * purpose so a describer asked "what breed is this?" volunteers the breed rather
+ * than a generic caption that the answering model then cannot use.
+ * @param {AbortSignal} [options.signal]
+ * @returns {Promise}
+ */
+async function describe(options) {
+ const purpose = options.purpose === 'task' ? 'task' : 'answer';
+ const image = options.image || { base64: '' };
+ const name = image.name || 'the attached image';
+ const started = Date.now();
+
+ if (!image.base64) {
+ return { name, description: 'The image could not be read.', model: options.model, ms: 0, ok: false };
+ }
+
+ const key = fingerprint(image.base64, purpose, options.model);
+ const cached = cache.get(key);
+ if (cached !== undefined) {
+ logger.debug(`Reusing the description already produced for ${name}.`);
+ return { name, description: cached, model: options.model, ms: 0, ok: true };
+ }
+
+ // Appended rather than replacing the prompt: the four numbered points are what keeps
+ // a small model on task, and a question alone gets a one-word answer that is useless
+ // to anything downstream.
+ const question = String(options.question || '').trim();
+ // `purpose` is narrowed to one of two string literals at the top of this function,
+ // so this is a lookup of a constant key on a module constant.
+ // eslint-disable-next-line security/detect-object-injection
+ const basePrompt = PROMPTS[purpose];
+ const prompt =
+ purpose === 'answer' && question
+ ? `${basePrompt}\n\nThe user asked: "${question.slice(0, 300)}". Make sure your description covers whatever that question is about.`
+ : basePrompt;
+
+ try {
+ const response = await options.client.chat(
+ {
+ model: options.model,
+ messages: [{ role: 'user', content: prompt, images: [image.base64] }],
+ // A description is not a reasoning task, and on a hybrid model the thinking
+ // trace is pure cost here — it is not shown, not stored, and eats the token
+ // budget the description itself needs.
+ think: false,
+ options: {
+ // Low but not zero. At zero a small vision model repeats one clause until it
+ // hits the token ceiling, which is the single most common way these calls
+ // waste a minute.
+ temperature: 0.2,
+ num_predict: DESCRIBE_NUM_PREDICT,
+ },
+ },
+ { signal: options.signal }
+ );
+
+ let text = String((response && response.message && response.message.content) || '').trim();
+ if (!text) {
+ return {
+ name,
+ description: 'The model returned nothing for this image.',
+ model: options.model,
+ ms: Date.now() - started,
+ ok: false,
+ };
+ }
+
+ if (looksLikeRefusal(text)) {
+ logger.warn(`${options.model} replied that it cannot see images, having been sent one. Treating as a failed read.`);
+ return {
+ name,
+ description:
+ `${options.model} replied that it cannot read images, although it was sent one and reports ` +
+ 'that it can. Sending the message again usually works; a different vision model always does.',
+ model: options.model,
+ ms: Date.now() - started,
+ ok: false,
+ };
+ }
+
+ if (text.length > MAX_DESCRIPTION_CHARS) {
+ text = `${text.slice(0, MAX_DESCRIPTION_CHARS)}…`;
+ }
+
+ remember(key, text);
+ return { name, description: text, model: options.model, ms: Date.now() - started, ok: true };
+ } catch (err) {
+ const message = /** @type {Error} */ (err).message;
+ logger.warn(`Could not describe ${name}: ${message}`);
+ return {
+ name,
+ description: `This image could not be read (${message}).`,
+ model: options.model,
+ ms: Date.now() - started,
+ ok: false,
+ };
+ }
+}
+
+/**
+ * Describe several images, one after another.
+ *
+ * Sequential rather than parallel, and not as an oversight: Ollama serialises requests
+ * to one model anyway, so firing three at once produces three queued requests and one
+ * progress indicator that jumps from nothing to everything. In sequence the caller can
+ * say "reading screenshot 2 of 3", which on a minute-per-image machine is the
+ * difference between a slow feature and a broken one.
+ *
+ * @param {object} options
+ * @param {import('./ollamaClient').OllamaClient} options.client
+ * @param {string} options.model
+ * @param {Array<{name?: string, base64: string}>} options.images
+ * @param {'answer' | 'task'} [options.purpose]
+ * @param {string} [options.question]
+ * @param {AbortSignal} [options.signal]
+ * @param {(progress: {index: number, total: number, name: string}) => void} [options.onProgress]
+ * @returns {Promise}
+ */
+async function describeAll(options) {
+ const images = Array.isArray(options.images) ? options.images : [];
+ /** @type {ImageDescription[]} */
+ const out = [];
+
+ for (let index = 0; index < images.length; index += 1) {
+ // `index` is this loop's own counter over a local array.
+ // eslint-disable-next-line security/detect-object-injection
+ const image = images[index];
+ if (options.onProgress) {
+ options.onProgress({ index: index + 1, total: images.length, name: (image && image.name) || 'image' });
+ }
+ // Sequential on purpose; see the note above this function.
+ out.push(
+ await describe({
+ client: options.client,
+ model: options.model,
+ image,
+ purpose: options.purpose,
+ question: options.question,
+ signal: options.signal,
+ })
+ );
+ }
+
+ return out;
+}
+
+/**
+ * Render descriptions as the prompt block the context builder carries.
+ *
+ * ## Why the block says the agent cannot see the picture
+ *
+ * Without that sentence, a model handed "A screenshot of a login form with a red error
+ * reading 'Invalid credentials'" replies "I can see the error in your screenshot" — and
+ * then, one turn later, offers to look more closely at a part of it, which it cannot do.
+ * Naming the description as second-hand is what stops both.
+ *
+ * @param {ImageDescription[]} descriptions
+ * @returns {string} Empty when there is nothing to say, so the caller can skip the section.
+ */
+function renderForPrompt(descriptions) {
+ const usable = (descriptions || []).filter((entry) => entry && entry.description);
+ if (usable.length === 0) return '';
+
+ const plural = usable.length === 1 ? 'an image' : `${usable.length} images`;
+ const lines = usable.map((entry) => `${entry.name}:\n${entry.description}`);
+
+ return (
+ `The user attached ${plural}. You are not looking at ${usable.length === 1 ? 'it' : 'them'} — ` +
+ `what follows is a written description produced by a vision model, and it is everything you have. ` +
+ `Work from it, and do not offer to look at the ${usable.length === 1 ? 'image' : 'images'} more closely.\n\n` +
+ lines.join('\n\n')
+ );
+}
+
+/** Test seam. Nothing in the extension calls this. */
+function _clearCache() {
+ cache.clear();
+}
+
+module.exports = {
+ pickDescriber,
+ looksLikeRefusal,
+ describe,
+ describeAll,
+ renderForPrompt,
+ PROMPTS,
+ MAX_DESCRIPTION_CHARS,
+ _clearCache,
+};
diff --git a/app/core/promptRouter.js b/app/core/promptRouter.js
index 3726731..7c62bfc 100644
--- a/app/core/promptRouter.js
+++ b/app/core/promptRouter.js
@@ -138,6 +138,43 @@ answer; "I have no tools" is not, and on its own it is not even true of what you
Answer the question that was actually asked, directly and concisely.`;
+/**
+ * Appended to any prompt when the user attached an image to this message.
+ *
+ * ## Why a prompt was needed at all, when the image is right there on the message
+ *
+ * Because everything else in the turn is about the project, and it drowns the picture.
+ *
+ * Measured on `minicpm-v4.6` in Ask mode, "describe the image" with a photograph of a
+ * dog attached. The image reached the model. The reply described HirayaCoder. That is
+ * not a vision failure — the same model, the same photograph and a bare describe prompt
+ * scores 24/24 in `tools/bench-vision.js`. It is an instruction-following failure, and
+ * the instructions it followed were ours: Ask mode's prompt opens with "everything below
+ * this line is what you know about the user's project… Answer from it", and the context
+ * under it was 2,500 characters of project description and file listing with "Task:
+ * describe the image" on the last line.
+ *
+ * A small model resolves that conflict by weight, and the project block outweighs one
+ * short task line. `contextBuilder` is what stops the block from being there at all on
+ * this kind of turn; this is what re-points the instruction.
+ *
+ * The same shape is already documented in `agentSession._buildContext` for the chat
+ * route — a model handed a project description and the message "Hello Hiraya" answers
+ * with the project description. It is the same bug with a different trigger.
+ */
+const IMAGE_SUFFIX = `
+
+The user has attached an image to this message, and it is the subject of what they
+asked. Answer about the image.
+
+If you were given the picture itself, look at it. If you were given a written
+description of it instead, that description is everything you have — work from it, and
+do not offer to look at the image more closely.
+
+Do not answer with a description of this project, its files, or what it does unless the
+user's message actually asks about those. A question sent with a picture attached is
+almost always about the picture.`;
+
/**
* Agent mode, for a message that turned out to be conversation.
*
@@ -276,6 +313,8 @@ function renderEarnedHints(hints) {
* six-item TODO run does not re-detect it per step.
* @property {string[]} [earnedHints] Sentences from `agent/earnedHints`, already selected.
* @property {'chat' | 'task'} [intent] From `core/intentRouter`. Only consulted in Agent mode.
+ * @property {boolean} [hasImages] The user attached at least one image to this
+ * message. Changes the instruction rather than the tools — see `IMAGE_SUFFIX`.
* @property {boolean} [readOnlyTurn] From `intentRouter.isReadOnlyRequest`. Drops the
* mutating tools for one message. Only consulted in Agent mode — Plan has already
* dropped them, and Ask never had any.
@@ -317,7 +356,7 @@ function route(request) {
tools: [],
ollamaTools: [],
allowedActions: new Set(),
- systemPrompt: withIdentity(ASK_SYSTEM),
+ systemPrompt: withIdentity(ASK_SYSTEM) + (request.hasImages ? IMAGE_SUFFIX : ''),
budgets: { ...budgets, maxSteps: 0 },
readOnly: true,
// Read-only because the user chose a mode, not because this message was a
@@ -341,7 +380,7 @@ function route(request) {
tools: [],
ollamaTools: [],
allowedActions: new Set(),
- systemPrompt: withIdentity(CHAT_SYSTEM),
+ systemPrompt: withIdentity(CHAT_SYSTEM) + (request.hasImages ? IMAGE_SUFFIX : ''),
budgets: { ...budgets, maxSteps: 0 },
readOnly: true,
readOnlyTurn: false,
@@ -374,6 +413,7 @@ function route(request) {
withEnvironment(systemPrompt, request.environment, { mutating: toolMode !== 'plan' })
);
+ if (request.hasImages) systemPrompt += IMAGE_SUFFIX;
if (mode === 'plan') systemPrompt += PLAN_SUFFIX;
if (readOnlyTurn) systemPrompt += READ_ONLY_SUFFIX;
diff --git a/app/extension.js b/app/extension.js
index 97f3f63..efe631b 100644
--- a/app/extension.js
+++ b/app/extension.js
@@ -82,6 +82,8 @@ let lastActiveSessionId = null;
* @property {{autoEdit: boolean, autoApproveScripts: boolean}} permissions
* @property {boolean} statusBarEnabled
* @property {{enabled: boolean, hintThreshold: number}} adaptation
+ * @property {{enabled: boolean, describeModel: string}} vision
+ * Image recognition. See `core/imageRecognition`.
* @property {boolean} stepSessions Experimental; see `agent/stepBrief`.
* @property {import('./utils/logger').LogLevel} logLevel
*/
@@ -116,6 +118,13 @@ function readSettings() {
enabled: cfg.get('adaptation.enabled', true),
hintThreshold: cfg.get('adaptation.hintThreshold', earnedHints.DEFAULT_THRESHOLD),
},
+ vision: {
+ enabled: cfg.get('vision.enabled', true),
+ // Empty means "pick one", which is what almost everybody wants. A name here is
+ // honoured only if it is installed and actually reports vision — see
+ // `imageRecognition.pickDescriber`, which logs rather than silently substituting.
+ describeModel: cfg.get('vision.describeModel', ''),
+ },
stepSessions: cfg.get('experimental.stepSessions', false),
logLevel: cfg.get('logLevel', 'info'),
};
diff --git a/app/features/chatTab.js b/app/features/chatTab.js
index 2738e67..d7cfbe7 100644
--- a/app/features/chatTab.js
+++ b/app/features/chatTab.js
@@ -26,6 +26,7 @@ const { budgetsFor } = require('../core/modelCapability');
const { TranscriptStore } = require('../core/transcriptStore');
const { AgentSession, ChangeSet } = require('../agent/agentSession');
const { readImage, modelSupportsImages } = require('./imageContext');
+const imageRecognition = require('../core/imageRecognition');
/** Bytes of entropy behind the CSP nonce. */
const NONCE_BYTES = 16;
@@ -419,13 +420,59 @@ class ChatTab {
});
}
+ /**
+ * Who, if anyone, can look at an attached image right now.
+ *
+ * ## Why this is no longer just "does the selected model have vision"
+ *
+ * Until 1.1.0 the answer was exactly that, and the attach button was disabled on
+ * every text-only model. That was correct while an image could only reach the model
+ * on the message itself: a model without vision does not error on a picture, it
+ * ignores it and answers confidently from the text alone, which is worse than a
+ * refusal.
+ *
+ * It stopped being correct once `core/imageRecognition` existed. The picture now
+ * reaches a text-only model as a description written by whichever vision model is
+ * installed, so the real question is whether *any* installed model can see — and on a
+ * machine that has one, refusing the attachment denies a capability that is present.
+ *
+ * The button is still disabled when nothing installed can see, for the original
+ * reason, which has not changed.
+ *
+ * @returns {{supported: boolean, describer: string | null, viaOtherModel: boolean}}
+ * @private
+ */
+ _visionState() {
+ const models = this.app.models || [];
+ const active = models.find((m) => m.name === this.app.activeModel) || null;
+ const activeCanSee = modelSupportsImages(active);
+ const settings = (this.app.settings && this.app.settings.vision) || {};
+
+ if (settings.enabled === false) {
+ // Turned off deliberately. A model that can see for itself still can: that path
+ // costs no extra call, and the setting is about the recognition pass rather than
+ // about images.
+ return { supported: activeCanSee, describer: activeCanSee ? this.app.activeModel : null, viaOtherModel: false };
+ }
+
+ const pick = imageRecognition.pickDescriber(models, {
+ activeModel: this.app.activeModel,
+ preferred: settings.describeModel || '',
+ });
+
+ if (!pick) return { supported: false, describer: null, viaOtherModel: false };
+ return { supported: true, describer: pick.name, viaOtherModel: !activeCanSee };
+ }
+
/** @private */
_postVision() {
- const active = (this.app.models || []).find((m) => m.name === this.app.activeModel) || null;
+ const state = this._visionState();
this._post({
type: 'vision',
- supported: modelSupportsImages(active),
+ supported: state.supported,
model: this.app.activeModel,
+ describer: state.describer,
+ viaOtherModel: state.viaOtherModel,
});
}
@@ -496,13 +543,14 @@ class ChatTab {
/** @private */
async _attachImage() {
- const active = (this.app.models || []).find((m) => m.name === this.app.activeModel) || null;
- if (!modelSupportsImages(active)) {
- // Checked again on this side: the button being disabled is a convenience, not
- // a guarantee, and a text-only model would silently ignore the image.
+ const vision = this._visionState();
+ if (!vision.supported) {
+ // Checked again on this side: the button being disabled is a convenience, not a
+ // guarantee. The message names the whole machine rather than the selected model,
+ // because switching models is no longer the fix — nothing installed can see.
vscode.window.showWarningMessage(
- `HirayaCoder: ${this.app.activeModel || 'The selected model'} cannot read images. ` +
- 'Switch to a vision model such as gemma4 or qwen3.5.'
+ 'HirayaCoder: none of your installed models can read images. ' +
+ 'Pull one that can, such as `ollama pull minicpm-v4.6` or `ollama pull qwen3.5:2b`.'
);
return;
}
@@ -530,7 +578,22 @@ class ChatTab {
dataUri: result.image.dataUri,
},
});
- logger.info(`Attached image ${result.image.name} (${Math.round(result.image.bytes / 1024)} KB).`);
+ logger.info(
+ `Attached image ${result.image.name} (${Math.round(result.image.bytes / 1024)} KB), ` +
+ `to be read by ${vision.describer}.`
+ );
+ }
+
+ // Said once, after the picker, rather than as a warning before it: the user has
+ // already decided to attach something, and the useful information is that this will
+ // cost an extra model load — not that they picked the "wrong" model.
+ if (vision.viaOtherModel && this.pendingImages.length > 0) {
+ this._post({
+ type: 'status',
+ text:
+ `${this.app.activeModel} cannot read images, so ${vision.describer} will describe ` +
+ 'them first. That is one extra model load before the turn starts.',
+ });
}
}
@@ -634,6 +697,10 @@ class ChatTab {
const images = this.pendingImages.slice();
this.pendingImages = [];
+ // Snapshotted with the images rather than read when the session is built: a model
+ // switch between the two would otherwise describe this turn's picture with a model
+ // that was not the one the user was told about.
+ const imageVision = this._visionState();
// Set between the guard above and the queue below, so a message sent while this
// tab is still waiting for the lane is refused rather than queued behind itself.
@@ -695,6 +762,16 @@ class ChatTab {
environment: this.app.environment,
stepSessions: this.stepSessions,
images: images.map((image) => image.base64),
+ // The same pictures with their names attached, so a description can say which
+ // image it is describing. See `AgentSession.imageFiles`.
+ imageFiles: images.map((image) => ({ name: image.name, base64: image.base64 })),
+ // Resolved here rather than inside the session, because this is the only side
+ // that holds the list of installed models.
+ vision: {
+ enabled: !(this.app.settings && this.app.settings.vision && this.app.settings.vision.enabled === false),
+ describeModel: imageVision.describer,
+ activeCanSee: !imageVision.viaOtherModel && imageVision.supported,
+ },
// How the session reaches the user when it is stuck or the request was
// ambiguous. Bound to this tab, so the question appears in the conversation it
// came from rather than in whichever tab happens to be focused.
@@ -721,6 +798,15 @@ class ChatTab {
todos: result.todos || null,
changes: result.changeSet instanceof ChangeSet ? result.changeSet.list() : [],
plan: result.plan || null,
+ // Which model answered, and how long it took. Shown under every reply so that
+ // switching models is a comparison rather than a guess.
+ //
+ // `result.model` rather than `this.app.activeModel`: the user is free to change
+ // the dropdown while a turn is running, and the footer has to name the model
+ // that actually produced the words above it.
+ model: result.model || this.app.activeModel,
+ ms: typeof result.ms === 'number' ? result.ms : null,
+ vision: result.vision || null,
});
} catch (err) {
const message = /** @type {Error} */ (err).message;
@@ -792,6 +878,20 @@ class ChatTab {
// find out why.
case 'interpretation':
return this._post({ type: 'status', text: String(event.note || '').slice(0, 160) });
+ // What the vision model actually saw. Shown in the conversation rather than only
+ // logged, because everything the answer says about the picture is downstream of
+ // this paragraph — and a user who can read it can tell "the model misread my
+ // screenshot" from "the model read it and answered badly". Those two need
+ // different fixes and look identical without this.
+ case 'images-described':
+ return this._post({
+ type: 'images-described',
+ model: event.model,
+ descriptions: (event.descriptions || []).map((entry) => ({
+ name: String(entry.name || 'image'),
+ description: String(entry.description || '').slice(0, 2000),
+ })),
+ });
default:
return undefined;
}
diff --git a/app/webview/components/messageBubble.js b/app/webview/components/messageBubble.js
index d518b1b..c1636b5 100644
--- a/app/webview/components/messageBubble.js
+++ b/app/webview/components/messageBubble.js
@@ -59,6 +59,125 @@ export function appendImages(body, images) {
body.appendChild(row);
}
+/**
+ * Seconds, rounded the way a person reads a stopwatch.
+ *
+ * Sub-second turns are the cached ones and would otherwise read "0s", which looks like
+ * a bug rather than like a fast answer.
+ *
+ * Exported for the unit suite. `appendRunMeta` needs a document and is covered by the
+ * integration tests; this part is arithmetic and is worth pinning cheaply.
+ *
+ * @param {number} ms
+ * @returns {string}
+ */
+export function seconds(ms) {
+ if (ms < 950) return `${(ms / 1000).toFixed(1)}s`;
+ if (ms < 60000) return `${Math.round(ms / 1000)}s`;
+ const mins = Math.floor(ms / 60000);
+ return `${mins}m ${Math.round((ms % 60000) / 1000)}s`;
+}
+
+/**
+ * Which model produced this reply, and how long it took.
+ *
+ * ## Why this is on every reply rather than in the status line
+ *
+ * The status line is per-tab and is overwritten by the next turn, so it can say what is
+ * happening but never what happened. On this hardware a model swap is the main lever
+ * anyone has — a task is one to five minutes and the only way to make it faster is to
+ * run something smaller — and choosing between them means comparing replies that are
+ * minutes and several messages apart. Attached to the reply, the comparison is just
+ * scrolling.
+ *
+ * A second model is named only when a *different* one read an attached image. That is
+ * not decoration: it is where the extra twenty seconds went, and without it the headline
+ * number looks like the coding model got slower.
+ *
+ * @param {HTMLElement} body
+ * @param {{model?: string, ms?: number | null, vision?: {model: string, ms: number} | null}} meta
+ */
+export function appendRunMeta(body, meta) {
+ if (!meta || (!meta.model && typeof meta.ms !== 'number')) return;
+
+ const line = document.createElement('div');
+ line.className = 'run-meta';
+
+ const name = document.createElement('span');
+ name.className = 'run-meta-model';
+ // Model names come from Ollama. Data, not markup.
+ name.textContent = meta.model || 'unknown model';
+ line.appendChild(name);
+
+ if (typeof meta.ms === 'number') {
+ const dot = document.createElement('span');
+ dot.className = 'run-meta-sep';
+ dot.textContent = '•';
+ line.appendChild(dot);
+
+ const took = document.createElement('span');
+ took.textContent = seconds(meta.ms);
+ line.appendChild(took);
+ }
+
+ if (meta.vision && meta.vision.model) {
+ const via = document.createElement('span');
+ via.className = 'run-meta-via';
+ via.textContent = `+ ${meta.vision.model} read the image, ${seconds(meta.vision.ms || 0)}`;
+ line.appendChild(via);
+ }
+
+ body.appendChild(line);
+}
+
+/**
+ * What the vision model read out of the attached images.
+ *
+ * ## Why this is on screen at all, rather than only in the log
+ *
+ * Everything the reply says about a picture is downstream of this paragraph. When the
+ * answer is wrong, there are two very different causes — the describer misread the
+ * image, or it read it correctly and the answering model went wrong afterwards — and
+ * they need opposite fixes. Without the description on screen the two are
+ * indistinguishable, and the user's only move is to try again and hope.
+ *
+ * Collapsed by default and styled like the agent trace, because it is the same kind of
+ * thing: evidence for the answer, not the answer.
+ *
+ * @param {HTMLElement} body
+ * @param {string} model
+ * @param {Array<{name: string, description: string}>} descriptions
+ */
+export function appendVisionNote(body, model, descriptions) {
+ if (!descriptions || descriptions.length === 0) return;
+
+ const box = document.createElement('details');
+ box.className = 'trace vision-note';
+
+ const summary = document.createElement('summary');
+ summary.textContent =
+ descriptions.length === 1
+ ? `What ${model} saw in ${descriptions[0].name}`
+ : `What ${model} saw in ${descriptions.length} images`;
+ box.appendChild(summary);
+
+ for (const entry of descriptions) {
+ if (descriptions.length > 1) {
+ const name = document.createElement('div');
+ name.className = 'vision-name';
+ name.textContent = entry.name;
+ box.appendChild(name);
+ }
+ const text = document.createElement('p');
+ text.className = 'vision-text';
+ // Model output. `textContent`, never markup — see the note at the top of this file.
+ text.textContent = entry.description;
+ box.appendChild(text);
+ }
+
+ body.appendChild(box);
+}
+
/**
* What each tool call is called, in the language of the thing it does.
*
diff --git a/app/webview/main.js b/app/webview/main.js
index 9bcaeae..aa8897d 100644
--- a/app/webview/main.js
+++ b/app/webview/main.js
@@ -7,7 +7,15 @@
* nonsense and no more.
*/
-import { createMessage, appendImages, TraceView, renderTodos, renderChanges } from './components/messageBubble.js';
+import {
+ createMessage,
+ appendImages,
+ appendVisionNote,
+ appendRunMeta,
+ TraceView,
+ renderTodos,
+ renderChanges,
+} from './components/messageBubble.js';
import { ThinkingIndicator } from './components/thinkingIndicator.js';
import { renderPlanChecklist } from './components/planChecklist.js';
import { renderClarification } from './components/clarificationCard.js';
@@ -330,6 +338,9 @@ const handlers = {
else state.body.appendChild(fresh);
}
if (msg.changes && msg.changes.length > 0) state.body.appendChild(renderChanges(msg.changes));
+ // Last, so it reads as a footer on everything above it rather than as part of the
+ // answer.
+ appendRunMeta(state.body, { model: msg.model, ms: msg.ms, vision: msg.vision });
scrollToEnd();
},
@@ -355,14 +366,24 @@ const handlers = {
scrollToEnd();
},
+ 'images-described'(msg) {
+ if (!state.body) return;
+ appendVisionNote(state.body, msg.model || 'the vision model', msg.descriptions || []);
+ scrollToEnd();
+ },
+
vision(msg) {
el.addImage.dataset.unsupported = msg.supported ? 'false' : 'true';
el.addImage.disabled = !msg.supported || state.running;
- el.addImage.title = msg.supported
- ? 'Attach an image'
- : `${msg.model || 'This model'} cannot read images. Pick a vision model such as gemma4 or qwen3.5.`;
+ // Three states, not two. "Supported by another model" is the new one, and saying so
+ // up front is what stops the extra model load from looking like a stall later.
+ el.addImage.title = !msg.supported
+ ? 'No installed model can read images. Pull one, such as minicpm-v4.6 or qwen3.5:2b.'
+ : msg.viaOtherModel
+ ? `Attach an image — ${msg.model} cannot read one, so ${msg.describer} will describe it first`
+ : 'Attach an image';
if (!msg.supported) {
- // Images already staged would be silently ignored by a text-only model.
+ // Images already staged have nothing left that could look at them.
const dropped = attachments.filter((a) => a.kind === 'image');
if (dropped.length > 0) {
attachments = attachments.filter((a) => a.kind !== 'image');
diff --git a/app/webview/style.css b/app/webview/style.css
index 045de17..e2c9192 100644
--- a/app/webview/style.css
+++ b/app/webview/style.css
@@ -314,6 +314,72 @@ select.control {
content: '▾ ';
}
+/*
+ * Which model replied, and how long it took. One line, under everything else.
+ *
+ * Small but deliberately not faint: this is a number people compare across replies that
+ * are minutes apart, so it has to survive a glance. `--fs-xs` with the theme's own
+ * description colour is the same weight the attachment chips carry, which is the
+ * quietest step in this panel that is still meant to be read.
+ */
+.run-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var(--sp-1);
+ margin-top: var(--sp-3);
+ padding-top: var(--sp-2);
+ border-top: 1px solid var(--border);
+ font-size: var(--fs-xs);
+ color: var(--muted);
+ /* A long model name must not widen the panel. */
+ overflow-wrap: anywhere;
+}
+
+.run-meta-model {
+ font-family: var(--vscode-editor-font-family, Consolas, monospace);
+ color: var(--vscode-foreground);
+}
+
+.run-meta-sep {
+ opacity: 0.6;
+}
+
+/* The second model, when a different one read an attached image. Set back a step so the
+ headline pair still reads first. */
+.run-meta-via {
+ margin-left: var(--sp-2);
+ opacity: 0.85;
+}
+
+/*
+ * What the vision model read out of an attached image. Same shape as the trace above,
+ * because it is the same kind of thing — evidence for the answer, kept out of the way
+ * until someone wants it.
+ */
+.vision-note > summary::before {
+ content: '▸ ';
+}
+
+.vision-note[open] > summary::before {
+ content: '▾ ';
+}
+
+.vision-name {
+ margin-top: var(--sp-2);
+ font-size: var(--fs-xs);
+ color: var(--muted);
+}
+
+.vision-text {
+ margin: var(--sp-1) 0 0;
+ font-size: var(--fs-sm);
+ line-height: 1.5;
+ white-space: pre-wrap;
+ /* A describer that emits one long unbroken line must not widen the panel. */
+ overflow-wrap: anywhere;
+}
+
.step {
display: flex;
gap: var(--sp-2);
diff --git a/benchmarks/README.md b/benchmarks/README.md
index 115fb2d..de1f43a 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -60,7 +60,7 @@ node tools/bench-build.js ornith:9b --machine C --lang java,python --keep
| `--out ` | Results root, default `benchmarks/results` |
Requires Ollama running with the model pulled. See
-[the requirements section of the README](../README.md#requirements).
+[Getting started](../doc/GETTING-STARTED.md).
### Recording the CPU/GPU split
@@ -199,6 +199,102 @@ The compiled, human-readable tables are generated from these files afterwards an
[README.md](../README.md) and [doc/MODELS.md](../doc/MODELS.md). Those are the derived
artefacts; this directory is the source of truth.
+## The image-recognition benchmark — `vision__*.json`
+
+*New in 1.1.0.* `tools/bench-vision.js`. Where every other harness here asks whether a
+model can do the work, this asks whether it can **see** — because 1.1.0 lets an attached
+image reach a text-only model as a written description, and if that description is wrong
+nothing downstream can tell.
+
+That is the failure worth designing a harness around. A vision model that cannot see does
+not error and does not hedge. It writes a fluent, confident paragraph about something
+else, that paragraph is stored as "what is in this picture", and the coding model works
+from it. So this benchmark exists to catch a describer that is **wrong while sounding
+right**.
+
+### The fixtures
+
+Six photographs in `docs/test-images/`, each with a hand-written expectation of what is
+actually in it. Written by looking at them rather than by reading the filenames:
+`dog-1.jpg` is one corgi and `dog.jpg` is two retriever puppies, and grading both against
+"dog" would hide a model that cannot count.
+
+### Four axes, because they fail separately
+
+| Axis | What it asks | How it is scored |
+|---|---|---|
+| **subject** | Did it name the right thing? | Any accepted synonym counts. `puppy` passes for `dog` |
+| **detail** | Colour, setting, count | One word from each expectation group has to appear |
+| **text** | Did it read the words printed in the image? | Only scored on the two fixtures that have any |
+| **confusion** | Did it assert something the photo contradicts? | Can only be lost. A cat called a dog fails outright |
+
+A confusion sinks the fixture whatever else it scored, and that is the distinction the
+harness exists for: **vague is usable, wrong is worse than nothing**, because the user
+cannot tell.
+
+Matching is a word list, deliberately. Grading one local model's output with another
+local model would make the benchmark's own reliability the thing in question, and a word
+list cannot be talked into a false positive. It is word-boundary matched, which is not
+pedantry: the first draft used `includes` and scored a description of a cat on a *carpet*
+as having identified a car.
+
+### The timings need `--cold`
+
+Ollama caches the tokenized prompt, and for these requests the prompt is mostly the
+image. So the second sample of a picture returns in about a second where the first took
+fifteen, **and the cache survives between runs** — re-running this harness half an hour
+later reported one second for everything, for a model that genuinely takes eleven.
+
+`--cold` unloads the model between samples, which makes the `cold` column mean what it
+says. Without it, treat that column as a lower bound and nothing more. None of this
+touches the accuracy scores: a cached answer is the same answer.
+
+### Running it
+
+```bash
+node tools/bench-vision.js --machine [options]
+
+node tools/bench-vision.js minicpm-v4.6:latest --machine B --purpose both --repeat 2 --cold
+node tools/bench-vision.js qwen3.5:2b --machine A --images cat,plane
+```
+
+| Flag | Meaning |
+|---|---|
+| `--machine ` | **Required.** Which device this is; picks the results directory |
+| `--purpose