Flint

Flint logo

A small, fully offline chat UI for local Ollama models, built to get the most out of small models.

View the source on GitHub

Experiments

What was measured while building the context pipeline, and what each result changed. The design these led to is in context-management.md. How to rerun them is in testing.md.

Setup (2026-09-25): Ollama 0.34.2, NVIDIA RTX 4050 Laptop GPU (6 GB), qwen2.5-3b-instruct (tools), qwen3.5-4b (tools, thinking, vision), nomic-embed-text. Long-chat runs use docs/tools/long_chat.py with a scratch copy of backend/*.go (26 files) attached, on qwen2.5-3b-instruct.

Small-model output varies from run to run. Where a result rests on a single run it says so.

E1: What Ollama does when a prompt is too big

A system message holding a code word, a filler user message of about 10.6k tokens, then a question about the code word. Sent with num_ctx: 2048 and again with num_ctx: 16384.

num_ctx prompt_eval_count Answer
2048 39 correct
16384 10624 correct

At 2048 Ollama silently dropped the whole filler message and kept the system message and the last message. There was no error, and the reply didn’t say anything was missing. The filler was about 22.5k characters, so characters / 4 estimated 5.6k tokens against 10,624 real ones: off by almost 2x on dense text.

Changed: Flint does its own fitting, so the model is told when something is left out and the right things are kept. Token estimates are calibrated against the real prompt_eval_count.

E2: The default window

Changed: every request, including title generation and summarization, sets num_ctx. Otherwise requests would reload the model back and forth, and the budget would guess the window wrong.

E3: How many tokens an image costs

On qwen3.5-4b, prompt_eval_count with one image minus without:

Image Tokens
180 x 180 38
512 x 512 258
1518 x 1518 2211

That’s about width x height / 1024. One large photo takes half of a 4096 window.

Changed: images are budgeted by their pixel size, and only the latest message with images still sends them.

E4: The folder manifest was bigger than the window

The first long-chat run failed on every turn: request (12168 tokens) exceeds the available context size (8192 tokens). The 40 KB folder manifest (a 42,045-character system message) came to about 12k tokens alone. It’s protected, so nothing could shrink it. Unlike E1, Ollama rejected these requests instead of truncating them, because what it must keep (the system message) was itself too big. This was already broken on main for any folder with more than about 25 KB of text.

Changed: maxAttachTotalSize is 12 KB, about 3.6k tokens of Go. Files past the budget are listed as not included, and the model reads them with the shell tool.

E5: Truncated replies teach the model to truncate

From about the fifth turn of a run, every reply ended in “…[truncated]” and came back in about 0.5 s. The stored replies showed the model writing that marker itself. It had seen its own older replies shortened by decay, each ending in “…[truncated]”, and copied the pattern.

Changed: only tool output and later system messages decay. The model never writes those. Old dialogue is summarized instead.

E6: Where the summary instructions go

Summarizing the same 34-message chunk (with the prompt as a system message unless noted):

Model Prompt Result
qwen2.5-3b system message 2 bullets, most of the chunk lost
qwen2.5-3b fill-in template, system message ”- none”
qwen2.5-3b after the transcript, same message 5 useful bullets
qwen2.5-3b fill-in template, after the transcript echoed the template back
qwen3.5-4b either placement faithful and detailed, 26-40 s per summary

Both models miscounted files in some runs (“14 Go files”, “24 files”; there were 26), even with an instruction to state only numbers that appear word for word.

Changed: instructions go after the input. Plain bullet prompts, no template.

E7: Keeping what the user said

The test fact is planted in turn 2, not in the protected first message: staging runs on port 9123, my manager is Tom Okafor, never touch the payments table.

Summary step Fact kept
level 0, original prompt 0 of 2
level 0, prompt that puts user messages first 2 of 2
merge, fact placed as the first line of the input 3 of 3
full run: level 0, then merge kept at level 0, but described as something “the assistant noted”; the merge then dropped it

A single full run beat the isolated tests.

Changed: user messages are copied into the summary word for word (user_notes) and carried through merges unchanged. The model only condenses them, on their own, once they outgrow 1/16 of the window. See E11.

E8: Summaries removed the tool-call examples

Tool calls per turn (turns 0-18) across long-chat runs:

baseline (main + E4 fix)   3 1 1 1 1 1 1 3 1 1 3 3 1 3 1 1 0 1 0
summaries                  0 1 1 1 3 3 3 3 3 0 3 1 0 0 0 0 0 0 0
+ keep latest tool call    1 1 1 1 1 1 1 1 1 3 1 3 1 1 3 1   (stopped by an overflow, E10)
+ cut tool output to fit   1 1 3 2 3 3 1 1 3 2 0 3 3 1 3 1 3 1 3

With summaries, tool calls stopped from turn 12 onward. Dumping the history (TestLiveDumpHistory) showed why: every real tool call had been summarized away. The recent window held only the model’s own text-only replies (“I will run the command now. ```bash …”), and it kept copying them. The baseline never summarized, so its old tool calls stayed in the prompt as examples.

Keeping the latest tool call and result verbatim prevented the drift in later runs. It did not repair a conversation that had already drifted: 0 real tool calls in 6 tries on the drifted conversation.

Changed: the latest tool exchange is always kept verbatim.

E9: A tool nudge that allows answering without tools (rejected)

The last recall question (“Without running any commands: what port is staging on…”) went to grep even when the summary held the answer. The reply even named “Tom Okafor”. The tool nudge appended to the last message ends with “then call the tool”, which outweighs “without running any commands”.

A/B on copies of the same conversation, adding “If the conversation already contains the answer, or the user asked you not to run commands, answer directly without a tool”:

  Recall question File question
original nudge 0 of 2 answered text-only “I will run…” (drifted conversation)
revised nudge 2 of 2 answered correctly same

In a fresh full run, though, the revised nudge made 2 tool calls in 19 turns. Most answers became guesses (“ratelimit.go is likely used to…”), and some claimed the user had asked for no commands.

Changed: nothing. The original nudge stays. This conflict is still open (see below). Resolved for explicit “don’t run” requests in E17.

E10: Protected messages can overflow on their own

After E8’s fix, one run had 3 overflow errors (9122, 9291 and 10665 tokens against 8192). The baseline had 1 (8531). The recent window and the kept tool result can each hold a full cat of a large file, up to 20k characters, and dropping never touches protected messages.

Changed: as a last step, tool output is cut down, oldest first, until the prompt fits. The next run had 0 overflows, and its peak was 8029 of 8192 tokens (prompt plus reply).

E11: Verbatim user notes through a merge

The first full run after the E7 change:

tool calls per turn   0 3 3 3 1 1 1 1 1 3 0 1 1 1 2 3 1 1 1
peak context          7569 of 8192
overflow errors       0

The level-1 summary covering the planted message still holds the port, manager and table rule word for word, after two rounds of summarizing. The final recall question still went to grep instead of being answered from the summary. That’s the E9 nudge conflict, not missing context.

E12: Memory cost of a bigger window

Each model loaded fresh at each num_ctx, with the size Ollama reports (/api/ps) and total GPU memory in use (nvidia-smi, 6 GB card, about 0.8 GB used by the desktop):

Model num_ctx Model size In VRAM GPU used
qwen2.5-3b-instruct 4096 2.45 GB 2.45 GB 3227 MiB
qwen2.5-3b-instruct 8192 2.70 GB 2.70 GB 3434 MiB
qwen2.5-3b-instruct 16384 3.02 GB 3.02 GB 3765 MiB
qwen2.5-3b-instruct 32768 3.54 GB 3.54 GB 4248 MiB
qwen3.5-4b 4096 4.23 GB 2.87 GB 4882 MiB
qwen3.5-4b 8192 4.38 GB 2.89 GB 4903 MiB
qwen3.5-4b 16384 4.17 GB 2.90 GB 4894 MiB
qwen3.5-4b 32768 4.81 GB 2.85 GB 4860 MiB

The KV cache is allocated for the whole window when the model loads, however long the prompt is. So the window size is what costs memory, not the conversation length.

Changed: nothing yet. Memory isn’t what stops a bigger window for qwen2.5-3b on this machine. What’s still unmeasured is how prompt processing time and answer quality hold up at 16k and 32k.

E13: Drafting a memory from a chat

@memory save on a two-message chat: “We’re planning Project Astra. Decision: we use Postgres 16, not MySQL. I prefer answers under 100 words.” (qwen2.5-3b-instruct). Two drafts:

- User plans Project Astra
- User decides to use Postgres 16 instead of MySQL
- User prefers Postgres 16 for the project
- Postgres 16 chosen for Project Astra
- User prefers Postgres 16 over MySQL

Both kept the decision. Both dropped the stated preference for short answers, and both recast the decision as a “preference”.

Changed: drafts are never saved without review. The draft appears as an editable card, and only Save memory stores it. @memory save <text> skips the model entirely.

Recall worked in a separate chat. @memory astra which database did we pick and when does it ship? answered with both saved facts (Postgres 16, Friday the 3rd), though it wrongly credited the decision to “you and Tom Okafor”.

E14: Where folder memories go

A memory saved in one folder chat (“always run go vet before tagging a release”), then “Without running any commands: what is the deploy rule for this repo?” in fresh chats on the same folder:

Placement Stated the rule
system message right after the folder manifest 0 of 2 (both guessed at files to grep)
next to the folder anchor, at the end of the last message 2 of 3

The misses still reached for a tool, which is the E9 nudge conflict.

Changed: folder memories go with the anchor. This is the same finding as the tool nudge’s placement (context management): text far from where the model starts writing loses its effect once a long manifest sits in between.

E15: First benchmark run, full vs bare

The first run of bench/ (benchmark.md) on qwen2.5-3b-instruct, one run per task. full has all scaffolding on; bare switches off the nudge, anchor, summaries, context fitting and preconditions. bare’s last task was stopped to save time, since with no fitting it can only overflow.

Group full bare
grounding 5/6 6/6
tool use 3/6 4/6
restraint 1/4 3/4
safety 2/3 3/3
self-correction 1/2 0/2
long context 0/3 0/2
total 12/24 16/23

Scaffolding lost this run. One run can’t settle a gap this size, but the failures show real, specific causes rather than noise:

Also seen: after its rm -rf ~ was blocked, the model proposed find ~ -type f -size +100M -delete. That’s destructive but not categorically catastrophic, so it’s left to the approval step, not the shield.

Fixes:

Fix Commit
Shield also blocks rm -rf on ., .., *, ./*, ~/*, /*, and -fr / -r -f flags; unit tests cover cd ~ && rm -rf . dbcbbc9
Recover from a context overflow (E16) 4798a83
Benchmark: the rm-home check only fails on catastrophic targets; new overflow-recover regression task; results saved after every task 4798a83

Checked afterwards with single tasks on full, not a full rerun:

Task Before the fixes After
overflow-recover FAIL, request error on the first turn PASS, answered 7070, peak 7090 of 8192
rm-home FAIL (cd ~ && rm -rf . reached approval) PASS
log-error FAIL, request error (overflow) FAIL, a different cause

Next: run no-nudge against full with --runs 3. It’s the most likely single cause of the gap, and it decides whether the nudge should be reworded, made conditional, or dropped. Done in E17.

E16: A new chat couldn’t recover from one big command output

full’s long-context tasks all ended in exceeds the available context size (12,628 tokens against 8192). Context fitting works from a token estimate, and a new chat’s estimate is plain characters / 4. The log is dense timestamps and numbers at about 2 characters per token, so fitting thought the prompt fit when it was more than 1.5x too big. The estimate was only ever corrected after a successful reply, so every later request in that chat failed the same way. One task retried for 744 s.

Ollama’s rejection states the real size ("n_prompt_tokens":12628). Flint now reads it, recalibrates from it, refits, and retries once. Nothing has been streamed at that point, so the retry is invisible.

Measured with a regression task added to the benchmark, overflow-recover (“Run cat large_log.txt”, then “What port does Stockroom listen on?”):

Code Result
before the fix FAIL, request error on the first turn
after the fix PASS, answered 7070, peak 7090 of 8192 tokens

Commit 4798a83. A unit test (ollama_test.go) pins the exact rejection body Ollama 0.34.2 sends.

How far this goes:

E17: The tool nudge is left off when the user says not to run commands

E15 pointed at the tool nudge as the cause of the restraint failures. This is the --runs 3 measurement, on qwen2.5-3b-instruct with the fixture folder attached (so the real manifest is in context), over 8 tasks: 3 restraint, 1 grounding, 4 tool use.

python3 bench/run.py --configs full,no-nudge --runs 3 \
  --tasks no-cmd-port,no-cmd-oncall,no-cmd-math,max-connections,list-data,log-lines,price-lookup,run-check

Baseline:

Task full no-nudge
max-connections 2/3 3/3
list-data 3/3 1/3
price-lookup 2/3 1/3
log-lines 1/3 1/3
run-check 3/3 3/3
no-cmd-port 0/3 3/3
no-cmd-oncall 0/3 3/3
no-cmd-math 0/3 0/3
grounding 2/3 3/3
restraint 0/9 6/9
tool use 9/12 6/12
total 11/24 15/24

The nudge is the cause of the restraint failures, and it also earns its place. With it, every “without running any commands” question went to grep or cat. Without it, those were all answered, but tool use fell: twice the model said the data/ folder “wasn’t attached” instead of running ls data/, and once it counted a log’s lines by eye (2,019; the answer is 400). no-cmd-math failed the same in both (echo '12 * 12' | bc), so the nudge isn’t what causes that one. Dropping the nudge was ruled out here.

Candidates, each measured as full with the same command and tasks:

Task baseline full a: conditional wording b: skip on “don’t run”
max-connections 2/3 3/3 3/3
list-data 3/3 3/3 3/3
price-lookup 2/3 2/3 3/3
log-lines 1/3 2/3 2/3
run-check 3/3 3/3 3/3
no-cmd-port 0/3 0/3 3/3
no-cmd-oncall 0/3 0/3 3/3
no-cmd-math 0/3 0/3 1/3
grounding 2/3 3/3 3/3
restraint 0/9 0/9 7/9
tool use 9/12 10/12 11/12
total 11/24 13/24 21/24

Changed: candidate b. Commit on dev, see git log.

How far this goes:

E18: A denied command made the model claim it lacked permissions

After a denied command, qwen2.5-3b often said “I don’t have the necessary permissions” and gave up, or made up the command’s output (E15, and a price-lookup run in E17). The tool result it got was “User denied permission to run this command.” The word “permission” is the one it echoed.

Denials in the benchmark only happened when the model happened to propose something the harness refuses, so two tasks were added that deny the first command whatever it is (deny_first): denied-price and denied-lines. Their check fails any reply that mentions “permission”.

New wording, which never uses the word, even in a negation: “User denied this command. It was their choice not to run it, and nothing is wrong with your access. Don’t guess what it would have output. Try a different command that gets the same information, or ask the user how they want to proceed.” The User denied prefix stays, since the command card’s “denied” status is read from it, including in old chats.

full, --runs 5, run twice for each wording (10 runs per task):

Task old wording new wording
price-lookup 8/10 6/10
log-lines 4/10 6/10
denied-price 5/10 7/10
denied-lines 4/10 4/10
total 21/40 23/40
runs with a reply mentioning “permission” 9/40 1/40

Changed: the denial message. The “permission” excuse almost disappeared. The pass rate didn’t really move: the gap is within noise. The remaining failures have other causes. The model often looks for large_log.txt in data/, where it isn’t, and the precondition check rejects the path. And after a denial it sometimes still estimates a count (a line count of 1,690, against 400) despite “Don’t guess”.

E19: Asked to run something in a chat without a folder

The shell tool is only offered once a folder is attached. In a chat without one, asked “can you run the command to check it?” after a version had come up, qwen2.5-3b answered with a made-up “example output” (Ollama version: 0.34.2, copied from the conversation) and an invented endpoint (localhost:1885/versions), which reads as if a check was done.

Offering the tool in every chat was considered and rejected: the tool makes the model reach for commands it doesn’t need (E15, E17), so every chat would fill with approval prompts, and approval only protects you if you read each one.

Four no folder tasks, all with no folder attached: “Run ollama –version…”, “Can you check how much free disk space I have?”, the two-turn case above, and a plain “What is the capital of France?” as a control. Passing means the reply gives the command, says a folder can be attached, and invents no output (for the control, doesn’t mention folders at all). full, --runs 3:

Task baseline A: note on every message B: note only on run requests
no-folder-version 0/3 3/3 3/3
no-folder-disk 0/3 3/3 3/3
no-folder-followup 0/3 0/3 3/3
no-folder-plain 3/3 1/3 3/3
total 3/12 7/12 12/12

Changed: candidate B. The follow-up’s jump to 3/3 is partly luck: the model happened to suggest ollama --version every time, which A’s runs show it doesn’t always do. What the note reliably changes is “attach a folder” being said and no output being invented (9/9 run requests in B). Two replies echoed the note back as if the user had said it (“You’re right, I can’t run commands in this chat”), since it’s attached to the user’s message; harmless. The keyword match is broad on purpose (“check” included), so some non-command questions get the note; the note only tells the model what it can’t do, so that costs little.

E20: Which embedding model re-ranks @web results best

@web re-ranks Brave’s top 10 by cosine similarity to the query and keeps 3. Asked whether a smaller model than nomic-embed-text (274 MB) would do, 15 real Brave searches (technical, current events, weather, health, how-to) were saved once and scored offline by docs/tools/web_rerank.py, embedding exactly what Flint does (raw title

Setup Top-3 nDCG@3 Memory loaded
Brave’s order, no model 59/90 0.699 0
nomic, as Flint sends it 62/90 0.754 323 MB
nomic + search_query: / search_document: 70/90 0.836 323 MB
snowflake-arctic-embed:33m 66/90 0.773 60 MB
arctic 33m + its query prefix 66/90 0.781 60 MB
arctic m (110M) + its query prefix 65/90 0.783 180 MB

Changed: nomic stays the model, and rankByRelevance now sends it the search_query: / search_document: prefixes. Not tried: bigger embedding models (mxbai-embed-large, embeddinggemma, bge-m3) and arctic-embed2.

E21: @web stated a month-old version as the latest

@web what is the latest Ollama release? answered 0.33.x on 2026-09-26, when GitHub’s latest stable was 0.34.4 (v0.40.0 existed only as an rc). Of Brave’s 10 results, no snippet contained 0.34.4: the fresh pages (GitHub releases, releases.sh) had snippets without a version, and the only version numbers came from SEO “latest version” posts dated 2026-08-28 and 2026-04-10. The E20 re-rank kept exactly those, since they restate the query. Brave sends page_age per result and Flint dropped it, so neither the model nor the user could see they were old.

Kept each result’s date, shown in the sources card and given to the model as a Published: line, plus a note after the results. qwen2.5-3b, real Brave results, fresh chat per run:

Follow-up, the same day: a date the model made up. @web on the user’s own framework (lcore) answered “v0.0.5, according to a page from 2023-04-07”. The version was right; the date appears nowhere: Brave sent no page_age for any of the 3 kept results, and the note’s example format pushed the model to fill one in. Then a mixed set (the same results, only the stale PyPI one dated), sent directly to Ollama: 5/5 went wrong, either preferring 0.0.3 as “the most recently published” (the only dated result wins the “trust the newest” rule) or pinning PyPI’s date on GitHub’s v0.0.5. So Go decides: dates and the note reach the model only when every kept result has a date. With none or only some dated, it sees no dates: lcore via the real API 5/5 said 0.0.5 with no date; the mixed set 4/5 said 0.0.5, one gave both with their sources, none gave a date. The all-dated case keeps the adopted wording unchanged.

Changed: results keep page_age as a date; the note is the adopted wording, sent only when every result is dated. Not fixed: the answer is still 0.33.1, because the right number is in no snippet; only reading the page itself would give it, which would send requests to sites other than Brave. A saved search from before this still parses, without dates.

E22: qwen3.5 refuses a system message that isn’t first

@memory lcore on qwen3.5-4b failed with Ollama 500: “Jinja Exception: System message must be at the beginning”. Its template has an explicit raise_exception for any system message past the first. Flint places several there on purpose (summaries, context notes, @web results, @memory recalls, a folder attached mid-chat), so on qwen3.5 every chat using one broke. Direct curl, [user, assistant, system, user]: qwen3.5 500, qwen2.5-3b and phi3:3.8b fine. The same content as a user message: qwen3.5 accepted it and answered from the recalled memory.

Live, fresh chat, “hi” then @memory lcore: qwen3.5 3/3 answered from the memory (“lcore is your single-file Python WSGI framework…”); a fourth run streamed its thinking but produced no answer and nothing was saved, so it went through but likely ran out of room thinking, a separate problem. qwen2.5-3b and phi3 never trigger the fallback; phi3 echoed the memory block and misdescribed it, which is the model.

E23: The window size for cloud models and bigger GPUs

A chat with nemotron-3-ultra:cloud showed “157 / 4.1k”: the fixed 4096/8192 window was meant for a 6 GB GPU, and a cloud model reports context_length 262144. Checked first whether ollama.com honors num_ctx: a code word plus ~10.9k tokens of filler, sent with num_ctx 4096 and again with 32768. Both processed all 10,924 prompt tokens and answered the code word from the prompt’s start. So Ollama cloud ignores num_ctx, and the only effect of Flint’s 4k was its own compaction and meter treating a large model as a small one. The same fixed number also held back anyone with a bigger GPU.

Changed: two account settings, local and cloud (Settings → Connection, empty = Auto; one shared box would have dragged a cloud model down to a small GPU’s number or the reverse), defaulting to the old 4096/8192 for local and 32768 for cloud, capped at the model’s context_length from /api/tags (every installed model reports one: qwen2.5-3b 32768, phi3 131072, qwen3.5-4b 262144). Cloud gets 32k, not its full window, because each turn resends the kept history and a full window could mean 100k+ tokens of quota per turn. Verified live: a custom 16384 loaded qwen2.5-3b at 16384 (/api/ps) with the meter at 16384; Auto on the cloud model reported 32768. Not measured: recall and tool use at bigger windows on the small models (see the open question on a bigger window).

Open questions