Files
LLeMbas/CLAUDE.md
T
Jaroslav Beneš e16bede85b Work handed to a second model, which may not ask
subagent_run gives a self-contained piece of work to a helper carrying the
parent's connection, directory, model and effort, and hands its answer back as
the tool result. The mechanism is the one scheduled runs already use -- a hidden
chat, one turn, wake_chat, and a poll -- so tools, rounds, budgets, metrics and
steps all work with no second implementation. The two alternatives were
rejected where they had already been rejected once: a nested Generation is two
replies writing one transcript, and a one-shot complete() has no tools, which
schedule/runner.py records as useless for exactly this case.

Every restriction is a property of the child's row, applied by resolve_tools
after the gates, because a rule that lives in a system message is one a page the
model just read can argue with. No questions, no recursion, nothing that writes
unless the call asked for it and the parent's own mode would not have stopped
first, and commands only from a fixed read-only list -- in every mode including
Auto, because the task text can have come from a page.

Withdrawing ask_user turned out to be half of "nobody is watching". An approval
still built a card nobody could see and parked the reply until approval_timeout,
which from every screen is the feature not working. Chat.unattended is the
question now, and not the kind: _authorise answers with a refusal instead. A
scheduled task's chat had the same hole and is covered by the same flag.

Three bounds, counted where each is knowable: per reply on the parent's
Generation, instance-wide in a set a restart clears, and per helper in settings
of its own so one runs out of room long before the reply that asked. Past the
clock the helper is stopped rather than abandoned, so a partial answer comes
back with a sentence saying so.

Also: four gates had shipped into the scope menu with no name, taking the first
tool's label instead -- the canvas switch read "Canvas written". There is a test
that refuses a family without one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:05:30 +02:00

1716 lines
106 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
Working notes for LLeMbas. Read this before changing anything.
## What it is
A self-hosted web UI for OpenAI-compatible LLM endpoints, written in Python and
themed after Middle-earth. Server-rendered FastAPI + Jinja + htmx; SQLite;
no JavaScript build step.
## Commands
```bash
. .venv/bin/activate
pip install -e ".[dev,search,ssh]" # `search` adds ddgs for DuckDuckGo,
# `ssh` adds asyncssh for agent chats
lembas serve # http://127.0.0.1:8080
lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
pytest # 1949 tests, ~2min
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
# needs fonttools and cairosvg)
python scripts/fetch_vendor.py # verify vendored JS against the lockfile
```
## Hard rules
These are the constraints the project is built around. Breaking one is a
redesign, not a tweak.
1. **No Node, no npm, no build step.** Browser libraries are downloaded once by
`scripts/fetch_vendor.py`, hash-pinned in `scripts/vendor.lock.json`, and
committed under `web/static/vendor/`. Adding one is a `--update`, same as
bumping one: a name in `PACKAGES` with no lock entry has nothing to verify
against, and the script refuses rather than writing it unpinned. xterm is
the one heavy dependency — 280KB, more than everything else together — and
is loaded only on a chat that can open a terminal.
2. **Nothing loads from a CDN at runtime.** A self-hosted tool must work
offline and must not report page views to a third party.
3. **No hard-coded values outside `tokens.css`.** Every colour, space, radius
and control height resolves through a CSS variable. `--control-h` is why
buttons, inputs and selects line up: they all take their height from it, so
a mixed row is flush by construction rather than by nudging.
4. **Additive-only schema changes.** SQLite only, no Alembic. `init_db()` runs
`db/migrations.py:sync_schema()`, which creates missing tables *and* adds
missing columns by diffing the models against the database. Renames, drops
and retypes are still manual. See "Changing the schema" below.
5. **Secrets never reach the browser.** API keys are Fernet-encrypted at rest
and only ever rendered masked.
6. **Model output is untrusted.** Everything from an endpoint goes through
`services/markdown.py` (markdown-it → nh3) or `escape_text()`. Never
`|safe` on anything that has not.
## The flavour rule
Middle-earth lives in the **artwork, theme names, empty states, loading lines
and error pages**. It does not live in the functional UI.
Chats are called *Chats*, not *Tales*. Folders are *Folders*, not *Chapters*.
Buttons say what they do. Someone who has never read the books must be able to
use this without a glossary. The two themes are named `moria` and `shire`, and
the 404 says "Not all those who wander are lost. This page, however, is." —
that is the right amount.
## Layout
```
src/lembas/
main.py app factory, lifespan, error handlers
config.py pydantic-settings, all LEMBAS_* variables
cli.py typer entry points
api/
deps.py Db / CurrentUser / RequiredUser / AdminUser
auth.py register, login, logout
pages.py full-page routes (chat shell, settings)
chats.py messaging + the SSE stream
folders.py folder CRUD
admin.py connections + instance settings
admin_models.py model ordering, defaults, images, access
admin_users.py users, groups, permissions
admin_audio.py speech-to-text and text-to-speech endpoints
admin_search.py web search provider and credentials
admin_images.py the ComfyUI, and the workflow templates on it
admin_prompts.py the prompt fragment editor and its preview
admin_suggestions.py the cards offered on the new-chat screen
admin_tools.py custom HTTP tools and MCP servers
admin_agents.py whether agent chats exist, and what they may spend
agents.py SSH connections, kept by the people who own them
terminal.py the terminal panel's WebSocket, and its two locks
audio.py transcribe, speak, voice discovery
library.py knowledge, notes, skills pages; memory CRUD
messages.py the one long conversation, and paging back through it
reports.py the Reports feed, and one report on its own page
schedules.py the Scheduled list, the rule form, and a task's controls
files.py upload, serve, remove attachments
preferences.py per-user theme, default model, password, audio
db/
base.py Base, UUID/Timestamp mixins
session.py engine, SQLite pragmas, init_db, session_scope
migrations.py additive schema sync (tables + columns)
models/ user, chat, connection, setting
security/ passwords (argon2), sessions, permissions
services/
llm/openai_client.py httpx streaming + model discovery
search/ ddgs, SearXNG and Firecrawl behind one shape
images/ drawing on a ComfyUI: comfy.py speaks HTTP,
workflow.py fills a template, tool.py ties them
to a chat and decides whether to keep the result
library/ documents, notes, memories, skills, FTS
mcp/ remote MCP servers: framing, transport, rows to tools
agent/ agent chats: the mode table, SSH, the six tools,
terminal.py (shells held open behind the panel),
shell_marks.py + capture.py (where one command ends),
index.py (what is in the project directory),
instructions.py (the project's own AGENTS.md),
patch.py (applying a unified diff, and rendering one)
audio.py OpenAI-shaped /v1/audio/* client
push.py web push: the only thing here that talks to an outside
service, and only because there is no other way to reach
a browser that is closed
fetch.py URL retrieval, HTML to text, the SSRF guard
messages.py the one conversation per person: bounded in the request,
unbounded on disk
reports.py filing a finished piece of work, and finding it again
schedules.py making, changing and stopping a schedule
schedule/ work that happens because time passed: clock.py (whose
"now"), rule.py (the recurrence, pure and total),
ticker.py (the loop and the claim), runner.py (firing),
compile.py (plain words into a rule), tool.py (the four
a model calls)
wake.py starting a reply from outside a request -- one lock
discipline, shared by finished jobs and by schedules
sharing.py one visibility rule for every library store
prompts.py every injected prompt fragment, and {{variables}}
metrics.py tokens, context percentage and tokens/second
tokens.py the chars/4 estimate, for endpoints that report none
compaction.py summarising the earlier turns of a long chat
suggestions.py new-chat starting points, seeded once
harness.py the operational prompt built from what a model has
tools.py tool registry, schemas, streamed-call reassembly
tool_labels.py what each tool is called and looks like, in one table
plans.py a plan's shape, and keeping one current
custom_tools.py the admin-defined HTTP tool runner
tool_access.py who may be offered which admin-defined tool
interaction.py pausing a reply to ask the reader something
chat.py request building, endpoint resolution, titles
markdown.py markdown-it + pygments + nh3
crypto.py Fernet encrypt/decrypt/mask
files.py attachment validation, images, PDF/text extraction
reasoning.py splits thinking from the answer
subagent.py a helper another model sent: a hidden chat, one turn,
and everything it may not do
settings_store.py runtime instance settings
canvas.py what is open in the canvas panel, and where it comes from
scratch.py a chat's own working document
uploads.py validated image storage
sse.py event framing
web/
templating.py render() -- always use this, not TemplateResponse
templates/ Jinja
static/ css, js, vendor, img, sw.js
js/commands.js the / table, and the keyboard that does the same jobs
js/canvas.js the canvas panel's three behaviours
js/composer.js the menu / and @ open, and the mirror that marks them
assets/ SVG masters and PWA icons (generated)
deploy/ systemd unit, nginx vhost, install/update scripts
docs/notes/ the rest of "Things that will bite you", by topic
```
## Things that will bite you
**Three topics live in `docs/notes/`, and are not loaded with this file.** They
were split out because this one is read in full on every session and each of them
is only wanted while you are in that corner of the code. Read the file before
touching the code it names -- these are the same notes, not a summary.
- `docs/notes/agent-chats.md` -- the four modes and where they are enforced, how
a round is authorised and how "always allow this" derives a pattern, the agent
harness fragments, SSH, background jobs, the file tools and the patch matcher,
the project listing and a project's own AGENTS.md, and the terminal panel.
- `docs/notes/schedules-and-reports.md` -- claiming a schedule before firing it,
the pure recurrence rule, the wake lock, task chats, Reports, why an empty
`kind` means both sides of the sidebar switch and never "no filter", and the
four scheduling tools a model calls (which did not exist, so a model asked to
schedule something wrote a note and said it had).
- `docs/notes/image-generation.md` -- the ComfyUI workflow with holes in it, what
substitution walks, the review-and-retry loop, and how a failure reports itself.
- `docs/notes/subagents.md` -- the hidden chat a helper runs in, why `unattended`
is a column and not a kind, the two halves that stop a helper stalling on a
card nobody can see, what it may run and why Auto is never inherited, and where
the three bounds are counted.
**`render()`, not `TemplateResponse`.** `web/templating.py:render()` injects
`user`, `theme`, `layout`, `version` and `allow_signup`. Templates assume they
exist. If you must call `templates.TemplateResponse` directly (the SSE path does,
because there is no `Request`), pass `user` explicitly — `chat/_message.html`
renders both roles and the user branch dereferences it.
**The message template is the state machine.** `chat/_message.html` renders an
incomplete assistant message as a streaming shell carrying `sse-connect`, and a
complete one as finished output. That is the *only* thing that starts a
generation. A consequence worth knowing: loading a page whose last reply is
unfinished restarts it, which is how a dropped connection recovers.
**SSE framing.** `services/sse.py:event()` splits payloads on newlines into
several `data:` lines. A raw newline in a single `data:` line truncates the
event — the failure shows up the first time a model emits a code block.
**Streaming opens its own database session.** `api/chats.py:_generate()` uses
`session_scope()`, not the request's session, because streaming outlives the
request handler.
**Escaping is chunk-safe on purpose.** `escape_text()` is `html.escape`, which
works character by character, so escaping stream chunks separately equals
escaping the whole string. `nh3.clean_text` would also be safe but escapes
spaces and slashes, tripling the size of every streamed token.
**The fence renderer is replaced, not configured.** markdown-it's `highlight`
option re-wraps output in `<pre><code>` unless the string starts with `<pre`,
which would nest a second `<pre>` inside our wrapper. `markdown.py` overrides
`renderer.rules["fence"]` instead. There is a regression test for this.
**SVG `<style>` is document-scoped.** Two text runs in one SVG sharing a class
name means the later rule recolours both. `build_artwork.py` takes class names
as parameters for exactly this reason.
**Gradient ids are document-global.** The `mark()` macro takes a `uid` because
two marks on one page with identical ids make the second silently reuse the
first one's gradients.
**Two kinds of settings.** `lembas.config` is deployment configuration read
from the environment at startup. `services/settings_store.py` is instance
settings an admin edits at runtime, stored in the `settings` table. Environment
variables seed the latter as an *initial* value only — once stored, the database
wins, or a toggle in the UI would silently revert on the next restart.
**Permissions are a union, and admins bypass them.** `security/permissions.py`
resolves a baseline (instance setting) widened by each group. A group grants;
it never denies — otherwise "why can this user not do X" needs a simulation of
every group to answer. Model *access* is separate: `models_visible_to()`.
**FastAPI cannot tell an empty form field from an absent one.** With
`x: str | None = Form(None)`, a submitted `x=` arrives as `None`, so "clear this
field" is indistinguishable from "leave it alone". `api/chats.py:update_chat`
reads `await request.form()` and checks key presence instead. Anything with a
clearable field must do the same.
**`Mapped[list]` without an element type is not a collection.** SQLAlchemy
treats a bare `Mapped[list]` as a scalar and hands back `None` instead of `[]`.
Always write `Mapped[list[Group]]`, with a `TYPE_CHECKING` import if the class
lives in another module.
**Auto-titling is a request like any other, and it was reading the wrong field
of one.** `complete()` hands back `message.content` verbatim, and a model that
emits `<think>` inline puts its thinking in exactly that field -- so a title
came back as "<think>Okay, the user wants a short title for", or, once the
too-long guard caught that, as the first prompt trimmed, which looks precisely
like titling never having run. `generate_title` puts the reply through
`reasoning.strip_reasoning` now. The budget was 24 tokens, which is ample for
six words and nowhere near enough for a model that thinks first: too small is
not a shorter title, it is no title, because the thinking consumes the budget
and content comes back empty. It is `TITLE_MAX_TOKENS` and generous.
Deliberately **not** `apply_effort(body, "low")`, tempting as that is. Those two
fields appear only where somebody has opted in, precisely so a provider strict
about unknown parameters sees the request it always did -- and an LLMError here
is caught and turned into a fallback title, so a 400 would be titling silently
switching itself off. The token budget is what makes room for the thinking.
**Reasoning arrives two ways.** A `reasoning_content` delta field (llama.cpp,
llama-swap, vLLM) or `<think>` tags inline in `content` (Ollama and friends).
`services/reasoning.py` handles the second with a streaming splitter, because
the tags arrive split across chunks. Reasoning is stored in `Message.reasoning`
and is deliberately **not** replayed as context on the next turn.
**Attachments are typed by their bytes, not their name.** `services/files.py`
sniffs magic numbers; a `.png` full of text is stored as text. Images are
downscaled and re-encoded (a phone photo is megabytes of base64), PDFs have
their text extracted **once at upload** — re-extracting per request would let a
reply change because a parser was upgraded.
**Images only go to models marked `vision`.** Sending content parts to an
endpoint without multimodal support is not graceful degradation; most reject
the whole request. `build_request()` checks the capability and falls back to a
plain string. A plain text turn must *stay* a plain string for the same reason.
**Attachments are served, never linked.** Images reach the model as base64 data
URIs: a local endpoint has no route back to LLeMbas and a hosted one has no
credentials. Non-images are served `Content-Disposition: attachment` with
`nosniff`, so an uploaded `.html` cannot execute in this origin.
**Uploads are unbound until the message is sent.** `Attachment.message_id` is
null in the composer; `files.claim()` binds them, and only unclaimed rows owned
by that user, so a forged id cannot pull in someone else's file. Abandoned ones
are swept at startup.
**Generation is a background task; the SSE endpoint only follows it.**
`services/generation.py` owns the work and the registry; `api/chats.py:_follow`
watches a `Generation` and streams what it sees. Closing the connection does
NOT stop the reply -- that was the old behaviour and it cut answers off when
the reader navigated away. Any route that creates an assistant placeholder must
also call `generation.ensure()`.
**`ensure` attaches, `restart` replaces.** The registry is keyed on message id
and finished generations linger `KEEP_FINISHED` so a follower arriving at the
last moment still gets the final frames. `ensure` is idempotent because a page
load finding an unfinished reply must attach rather than start a second one.
Regeneration is the only caller that reuses a `Message` row, and therefore the
only one for which idempotence is wrong -- it got the finished generation back,
made no request, and left the browser reconnecting to a stream with nothing to
say. It calls `restart`. `_persist` refuses to write when another generation
owns the message, because a cancelled predecessor's `finally:` still runs.
**The row is written before `done` is set.** `_follow` breaks out the instant it
sees that flag and re-renders the bubble from the database, so the row has to be
authoritative first. The other order silently showed the previous turn's stored
metrics.
**A thinking block reports its own round.** `Message.reasoning_ms` was the
reply's *first* burst, written once, so on a forty-round reply only the first
block could honestly claim a duration and the rest said "Thought" and nothing.
`Generation.thinking_ms` accumulates per round — the interval between a round's
first and last reasoning delta, not a sum of per-delta gaps, which would count
the network's latency as the model's thinking — and `close_step` stamps it
cumulatively, so `steps.py` diffs it exactly as it diffs the three lengths. The
last round closes no mark (it is the round that stopped calling tools), so its
duration is what the reply spent beyond the last mark, which is why `_persist`
stores `thinking_ms` into `reasoning_ms` in preference: it is the same
measurement done properly.
The live block's numbers come from a `think` frame, and `round_thinking_ms` is
written by the *producer*. Computing it in the follower from a start time would
keep the clock running after the model had stopped thinking and moved on to a
tool — a timer rather than a measurement. The animated ellipsis is a `content`
keyframe in CSS: no timer to start, stop or clean up when the block is swapped
away, and it stops existing when the element does.
**`include_open` and `live` are two questions, and conflating them put a caret
on every finished reply.** One says whether to emit the trailing step, the other
whether it is still being written. `for_message` wants the first without the
second. The test that existed asserted the caret was on the *right* step and
passed; it never asked whether a finished reply should have one.
**A reply is a sequence of steps, and the marks are what make it one.** The
three stores a reply writes into -- `content`, `reasoning`, `tool_events` -- are
each append-only and each correct, and none of them records *interleaving*. So a
bubble was rendered as three zones (all the thinking, then every tool block, then
all the prose), which reads fine on a two-round answer and is unusable on a
forty-round one. `Message.steps_json` is a **table of contents** over the three,
not a fourth copy of anything: one entry per closed step holding the cumulative
length of each at that moment, written by `Generation.close_step()`. Because they
are marks, `build_messages`, compaction, titling and the copy button all still
see `message.content` as the one string it always was. `services/steps.py` does
the walk; **no marks means the old layout**, which is what every row written
before this reads back, with no version flag and no branch in the template.
`close_step` is called where a round ends *and* in `_gave_up`/`_wrap_up`, which
append outside the round loop -- without their own mark the one line saying why
the reply stopped lands in the step still being written, where the live view has
no tools slot to show it in.
Splitting Markdown at those boundaries can leave a code fence open, which
markdown-it then runs to the end of the segment and mispairs every later fence
in the reply. `markdown.open_fence` plus a carry in `steps.py` closes it at the
end of one piece and reopens it at the start of the next. **Rendering only** --
`message.content` is never touched.
**Stream frames carry whole blocks, not deltas, and the split is closed versus
open.** `steps` carries every finished step and moves only when a round ends;
`reasoning` and `render` carry the step still being written and move at
streaming speed; `metrics` and `status` send the complete value each time. All
are swapped with `innerHTML`. That is what makes reattaching mid-reply work: a
follower arriving late has no earlier fragments to append to, and `_follow`'s
`rendered` list is a render cache rather than a wire protocol -- it starts empty
per follower, so the first frame carries the whole prefix.
The split is what makes it affordable. The old `tools` frame re-rendered every
tool call in the reply twelve times a second, against an `output_bytes` budget of
a megabyte, so a long agent reply spent most of its wall clock re-rendering its
own transcript. Do not "simplify" this back into one frame.
**Two frames must never blank themselves, and the rest must be able to.**
`steps` and `canvas` are sent only when they have something in them, so neither
can wipe what is on screen: `steps` carries the whole reply so far, and an empty
`canvas` would close every tab somebody had open. `metrics`, `status`, `ask`,
`reasoning`, `think` and `render` are sent on every version bump *including
empty*, because each has to be able to clear — an approval card that survived
being answered would be a button you could press twice, and the live tail has to
empty when a round closes and its contents become a step *above*.
**Which side a frame is on follows from what it carries, and that is what
changed.** `reasoning` and `render` once carried the whole reply, so blanking
them would have wiped the answer and they needed the never-blank guard; clearing
the tail then took a separate fragment re-emitted inside the `steps` payload, and
an ordering constraint between the frames to go with it. Now they carry the
**open tail only**`steps_service.tail` — so an empty one honestly means the
tail is empty, the fragment is gone and the ordering constraint with it.
**One `sse-swap` element must never contain another.** The live containers are
**siblings** of the steps container, never inside it. That one is swapped whole
at every round boundary, so anything nested in it is torn out and rebuilt exactly
when the frames aimed at it arrive — which made an agent reply render nothing at
all from its first tool call onwards, while an ordinary chat was fine, because an
ordinary chat closes no steps and the swap never happened. There is a test.
**An opened block has to survive the swap, and the ids are how.** The steps
container is replaced with `innerHTML` up to twelve times a second and the `done`
frame replaces the whole article, so a `<details>` somebody expanded shut itself
again — which at that rate is not an annoyance, it is a block that cannot be
opened at all. `web/static/js/steps.js` records which are open before a swap and
puts them back after. It works only because the ids are stable:
`tool-{message}-{step}-{n}` and `think-{message}-{step}` come from the mark
index, the marks are append-only, and **the finished bubble emits the same
container id and the same inner ids as the live one**. `hx-preserve` cannot do
this — it keeps a node *and its contents*, and these have to update.
The other half was the scroll. `scrollThread` refused to scroll only when the
reader was far from the bottom, but somebody near the bottom who opens a block is
reading, and the next frame dragged them back down. There is an explicit `stick`
flag now: opening a `<details>` in the thread clears it, and returning to the
bottom sets it. The `toggle` listener **must** be registered in the capture
phase — `toggle` does not bubble, and without the third argument the whole
feature is silently dead in every browser. `tests/test_ui_js.py` pins that.
**Stopping sets a flag the producer checks -- except while it is paused.**
`generation.request_stop()`; whatever arrived is kept and the message is marked
`stopped`, distinct from `error`. In-process, so single-worker only. `cancel` is
read in exactly one place, between streamed chunks, and a reply waiting on an
approval produces no chunks -- so `request_stop` also resolves
`generation.pending`, and that is the wakeup. Without it the Stop button does
nothing at all while a card is on screen, silently.
**Asking a person is one primitive with three uses.**
`services/interaction.py`: a command waiting to be allowed, a question the model
asked, and "this reply is waiting for you" are all *pause, render a block in the
bubble, wait for a POST, resume*. It pauses a **round, not a call** -- a round's
calls run together under a semaphore, and parking four coroutines on four
separate answers inside that gather would queue them behind each other
invisibly, and hand the reader four cards for commands whose order matters. So
one card covers everything in the round, and `_authorise` returns pre-decided
outcomes keyed by call index, which is what keeps
`zip(calls, outcomes, strict=True)` aligned.
**One `ask_user` call may carry several questions, and they come back at once.**
Each becomes an `Item` with its own `key`; several items can share an `index`
because they belong to one call, and one tool turn answers them all with each
answer quoted beside its question. Asking one at a time would cost a round trip
and an interruption each, and answering the third would mean having forgotten
the first. `_questions_in` reads the singular form and bare strings too, and
`_options_in` reads an option written as a bare string as well as one written as
`{label, description}`: a small model sends something close to the schema rather
than the schema, and getting it wrong costs a whole round trip to show a card
that says nothing.
**"Something else" is added by the template, never by the model, and that is why
the model is told not to offer one.** Every question gets it, with a text box
behind it revealed by `:has()` — no JavaScript, and nothing that can fall out of
step with the control. An "Other" the model wrote would be an option with no box
behind it: a choice that submits a word and means nothing. It carries
`interaction.OTHER` as its value rather than an answer, and the endpoint replaces
it with whatever was typed beside it — or drops it entirely when the box is
empty, because telling the model the answer is `__other__` is exactly the shape
of thing it would try to act on.
Options are **required** now, and stacked one per line rather than in a row: an
option carries an optional `description`, and a row of chips has nowhere to put
the second and no room to read the first. A question with no options is a blank
box, which asks the reader to do the thinking the model was meant to do.
**The model says whether its options are exclusive**, because only it knows
whether they are alternatives or a set — `multiple` picks radios or checkboxes,
and the default is exclusive because that is the cheaper mistake: a radio group
where checkboxes were meant costs one clarifying round, while checkboxes for
alternatives invite an answer that contradicts itself. A `multiple` question
posts the same field name once per ticked box, so the endpoint gathers `choice.`
fields into a **list** and joins them; the `setdefault` it used to do kept the
first and lost the rest, which is an answer that says something the reader did
not. Typing no longer beats picking — that rule belonged to a box that was always
visible, and this one only exists when its own option is chosen.
**A paused reply is deliberately not `done`.** That is what lets a page reload
reattach to it. Its *timeout* is what stops it lingering, not `_prune`, which
only drops finished ones -- so `approval_timeout` is clamped to at least a
minute on read, and `_prune` resolves anything whose deadline is long past as a
backstop.
**Nothing is persisted while paused.** A restart abandons the pending question
along with the reply, and a reload starts the turn afresh -- the model asks
again. That is consistent with "a restart abandons replies in flight", but it
means an approval is not a durable record of consent.
**There is no test runner for the JavaScript, so drive it under a DOM stub.**
Hard rule 1 keeps Node out of the *project*; it does not stop using the `node`
on this machine as a development instrument, the way `curl` is used. This is
not a nicety. `composer.js` built its menu lazily inside `show()` while
`refresh()` wrote to `list` before calling it — so the first `/` or `@` ever
typed threw on a null and took the handler with it, and the menu never appeared
in any browser for the whole life of the feature. `node --check` parses that
file happily. A forty-line stub of `document`, `window` and `fetch` that fires
one `input` event catches it in a second, and caught two more on the same run:
choosing a command from the menu left `/help` sitting in the box, and Tab did
not complete. Anything touching these files gets driven before it is committed.
**htmx fires afterSwap and afterSettle before afterRequest.** The composer
empties itself from `hx-on::after-request`, and the mirror repainted on the
first two -- so every repaint ran while the box still held the message, and the
highlighting sat over an empty field until the next keystroke. It repaints on
`htmx:afterRequest` and on `reset` as well now, both deferred a frame: a form's
`reset` event fires *before* its fields are actually cleared, so reading the
value in the same turn paints the text that is about to vanish.
**Two things must be sized the same or the composer's highlighting slides off.**
A `<textarea>` cannot style its own contents, so `.composer__mirror` sits behind
it holding the same text with every character transparent, contributing nothing
but a rounded rectangle behind each token. Every property that decides where a
character lands — font, size, line height, letter spacing, padding, wrapping —
is declared once for both. The usual version of this trick hides the textarea's
text and shows the mirror's; drawing only backgrounds instead means a pixel of
drift is a rectangle slightly out of place rather than a doubled glyph. The
scroll positions are synced, because the textarea scrolls past
`data-max-height`.
**A slash command must never swallow a message.** `static/js/commands.js`
intercepts only an exact match against its table; `//` escapes, and anything
unrecognised is sent as written. Eating somebody's message because it began with
a slash is a far worse failure than an unknown command, and it is the one the
implementation has to be arranged around rather than patched for afterwards.
**A shortcut clicks the button that already does the job.** `Alt+M` dictates,
`Alt+R` reads the last reply aloud, `Ctrl/⌘+Enter` sends from anywhere — and all
three dispatch by finding the existing control and calling `.click()`, so
`audio.js` keeps its one delegated listener and there is no second copy of the
recording state machine. `Alt+M` and not `Alt+D`: Alt+D is the address bar in
Chrome and Firefox, and a shortcut the browser wins looks broken. Ctrl+Enter
never means Stop, because Send and Stop are the *same element* and Esc already
stops. Every key is matched on `event.code`, and `tests/test_commands_js.py`
pins that each one has a row in `SHORTCUTS``/help` reads that list, so a key
missing from it is a key nobody can discover, and that is the direction this
actually rots.
**The directory chip shows a name, not a path.** A real project path is long
enough that showing it whole filled the chip's 16rem basis and pushed the mode
select off the end of the row -- and the leading directories are the part nobody
reads: what you check before sending is that you are in `myproject` rather than
`myproject-old`. `setDir` shows `baseName(path)` and puts the whole thing in the
tooltip; the hidden field still submits the full path, because shortening a
label must never shorten a value, and there is a test on the row for that. Three
CSS rules hold the row together and none of them is visible from the markup:
`.composer__agent` needs `min-width: 0` or the group will not shrink below its
content and the *last* child is what falls off, `.composer__dir` is capped
because it is the only child here whose content is unbounded, and the mode
select is `flex: none` because it is the control being read and changed
constantly. The topbar's copy of the same path was already bounded and
truncating, and is left alone.
**The composer's toolbar is one row, always.** It used to wrap, and
`.composer__actions` is last in the DOM with `margin-left: auto` — so the moment
an agent chat added a connection, a directory and a mode, Send and the
microphone were what dropped to a second line. `chat.css` has no media queries by
design and the fix is not to add one: `.composer__context` is the single child
allowed to shrink past its content and scroll sideways, everything else is
`flex: none`. There is a test asserting the file contains no `@media`, so nobody
"fixes" a future version of this with a breakpoint.
**The scope menu is on the new-chat screen too, and there it writes nothing.**
It used to appear only once a chat existed, on the reasoning that there was no
row to post to -- which was true and was the wrong conclusion. The harness puts
a tool's guidance in front of the model the moment the tool is offered, so the
menu could not be reached until after the model had been told how to keep notes
and handed the tools to do it; switching it off then does not un-send that turn.
`_scope_context` builds a stand-in `Chat` for the prospective one, which is
`agent/draft.py:as_chat`'s trick again -- `resolve_tools` reads the kind, the
model and the scope and never queries or writes the row, so one constructed and
never added satisfies it unchanged. Deliberately an *ordinary* chat even though
the kind is still switchable on that screen: an agent chat's tools depend on a
connection that is not settled until the chat is created.
**Checked means on, and a browser submits only the ticked boxes** -- so every
gate also renders a hidden input naming it, always submitted, and `start_chat`
subtracts one list from the other. Inverting the control so ticking means "off"
would read backwards under a menu that says everything is on unless you say
otherwise. `_new_chat` writes only the *off* ones, because absent means on and
one representation of "on" is what keeps "why is this off?" to a single answer.
Nothing needs validating against the offered set: `scope_json` narrows inside
`resolve_tools` after every gate, so naming a gate that was never offered
switches off something that was not on.
**The `@` button became the scope menu, and is called Toggle.** It only ever
inserted the character, which the `@` key already does without a button. It then
kept that as a *row inside the menu*, which was the same redundancy one level
down — a menu you open in order to press a button that types one character — and
that is gone too. Typing `@` is untouched: `composer.js` recognises the token on
its own and knows nothing about this menu.
With the row gone the guard is `has_scope` alone rather than `has_scope or can
upload`, because there is now nothing to show a chat with no scope and an empty
menu is worse than no button. The switches inside are `<label>`s that
deliberately carry **no** `role="menuitem"`, because `ui.js` closes a picker when
a menuitem is clicked, which is right for an action menu and wrong for a list of
switches you want to set several of. That is the whole reason the menu needs no
JavaScript at all. The verb is on the checkbox, per the usual rule.
**`hx-target` is inherited, and the composer's form targets the transcript.**
The chip below sits inside `<form class="composer__form" hx-target="#thread"
hx-swap="beforeend">` — that target is what makes a sent message append a
bubble. htmx resolves `hx-target` by walking up the DOM, so an element in there
that fetches and does not name its own target aims at `#thread` too. The jobs
chip declared `hx-swap="outerHTML"` and nothing else, which reads as "replace
yourself" and meant "replace the whole transcript with yourself" — on load, and
then again every five seconds. Every agent chat rendered its reply and then went
blank, the reader's own prompt with it, while the server logged nothing at all
because nothing had gone wrong there.
Anything inside that form that fetches must carry `hx-target` (or
`hx-swap="none"`). `tests/test_chat.py` walks the form and refuses the rest. Note
what the failing version looked like: correct, idiomatic markup whose meaning
came from an ancestor — the same family as the trigger bound where the event does
not go, and the reason that test asserts the resolved property rather than the
attributes.
**And htmx events bubble, which is the same lesson a second time.** The form
also declares `hx-on::after-request` so it can clear itself after sending — and
`htmx:afterRequest` bubbles, so every request made by anything inside the form
ran that handler. Six things do: the two scope switches, "ask me about these
again", the agent mode select, the effort select and the jobs chip. Changing the
mode or the effort while typing therefore called `this.reset()` on the composer
and dragged the view to the bottom, and had done for as long as those controls
existed; the chip only made it periodic, and therefore visible. The guard is
`event.target === this`. A form's own handler answers its own request.
Both bugs had correct markup whose meaning came from an ancestor. That is why
both tests assert the *resolved* behaviour — one walks the form and refuses a
descendant that fetches without a target, the other drives the handler under a
DOM stub and fires the event from a descendant.
**The open chat page polls for turns it has not got.** `jobs.wake` starts a reply
without any request from the browser, and there is no channel to say so: the only
stream is per-message and it is opened by the `sse-connect` on an incomplete
assistant bubble — a bubble the page does not have, because the reply that made it
began elsewhere. `_queue_frames` proves the swap works but can only ride a stream
already open, so a job finishing on an idle chat lit the sidebar dot for the chat
the reader was *looking at* and did nothing else until a reload.
`GET /api/chats/{id}/tail?after=` is the answer, polled for the reason `/unread`
is. Four things about it:
- **A cursor it cannot place is answered with 204, never with the transcript.**
An absent `after`, one from another chat, one a rewind deleted: returning the
thread would append a second copy of every bubble the page still holds. A page
whose history was rewritten underneath it is one only a reload can reconcile,
and that is not this route's call to make with a half-typed message in the box.
- **The cut is read from the row**, so `_inject`'s restamp of the placeholder
moves it too, and the comparison is done **in SQL** — a row read back from
SQLite is naive and one still in the session is aware. The `id >` tie-breaker
is not decoration: under a bare `>` a row sharing the cut's microsecond is
skipped forever.
- **The cursor comes from the DOM** (`app.js`, on `htmx:configRequest`), because
the DOM is the honest answer to what the page holds — the composer's POST, the
`done` frame and the last poll all move it, and a variable would have to be
updated by each of them forever. Not `hx-vals="js:…"`: two of the three things
that handler does are *cancellations*, which `hx-vals` cannot express. Not
`article.msg:last-of-type` either — that is per-parent, so on a compacted chat
it answers with the last article inside the `<details>` rather than the newest
message, and the poll then re-appends half the conversation.
- **It is silent while a reply is streaming**, and a `htmx:beforeSwap` listener
drops any answer containing a bubble the page already has. `hx-sync` cannot
reach that race — the two requests come from different elements — and a
duplicate here is not cosmetic, it is a second `sse-connect` for one message.
The route also clears `unread`/`unread_notified` on every tick **including the
204**: `_persist` marks a reply unread whenever `followers == 0`, which is true of
a job-woken reply with the reader watching it. The element lives outside `#thread`
(a rewind swaps that container's contents and would take the poller with it) and
outside the composer form (which would lend it `hx-target="#thread"`, the jobs
chip's old bug); `tests/test_chat_tail.py` walks the page and refuses both.
**A background job's completion is a user turn on the wire and a machine event on
screen.** The role is load-bearing — `_inject` sends a queued turn verbatim and
`build_messages` must keep seeing a user turn — so `Message.machine` marks the
bubble instead, and nothing about the request changes. Without it the transcript
rendered a machine's report under the reader's name with their initial beside it
and a pencil offering to rewrite it, which is the application putting words in
their mouth; the route refuses the edit too, because a hidden button is a
courtesy. `_completion_text` is deliberately untouched: the `tool.background`
fragment quotes its opening sentence to the model, so rewording it would break
that instruction with nothing anywhere to notice. The body skips the `tokens`
filter — an `@` in a command line is not a mention of anybody's files.
**Reasoning effort goes out twice, and only when it is set.** There is no field
that works everywhere. OpenAI and vLLM read `reasoning_effort`; llama.cpp's own
documentation says other values "have no effect", its maintainer says
"llama-server cannot support reasoning_effort at all" and that the field "simply
gets dropped without error or logging", and what actually reaches a gpt-oss
behind it is `chat_template_kwargs`. So `chat.apply_effort` writes both. The
second half is what makes that safe: neither field appears unless a chat has an
effort set, so a provider strict about unknown parameters sees exactly the
request it always did until somebody opts in. `EFFORTS` lives in `services/chat.py`
and the command, the control and the admin default all read it, so they cannot
disagree about what a valid effort is.
**The picker shows the level in force, never the word "default".** "Effort:
default" named no level and was true of nothing in particular.
`chat.resolved_effort` is the chat's own value and nothing else, and
`build_request` reads the same field, so what is shown is what is sent by
construction. The model's default is a **seed** — copied onto the row by
`_new_chat` and by a model change, and deliberately never consulted at request
time. A fallback would resurrect it underneath a cleared effort and make "off"
silently do nothing, which is precisely the failure this codebase keeps
cataloguing. The seed on a model change only applies when the key is **absent**;
`None` means somebody cleared it deliberately.
**"Effort: off" has to be a sentinel, not an empty value.** `start_chat`
declares `reasoning_effort: str = Form("")`, so an absent field and an empty one
are indistinguishable there — the FastAPI trap already documented for
`update_chat`. With `value=""` the reader picks off, the value falls out of
`EFFORTS`, the model's seeded default stays, and they silently get "high". The
option sends `"off"`, and `_new_chat`, `update_chat` and `/effort` all know it.
**A control that writes needs a form it is allowed to be outside of.** Two
selects in the composer — the agent mode and the effort — belong to empty
`<form>` elements that are siblings of the composer's own form, referenced by
`form="…"`. A form cannot nest inside another; the browser silently drops the
inner one, and the control then posts nothing at all.
**`form="…"` scopes the values; it does not route the event.** That is half of
the paragraph above, and taking it for the whole cost both those selects an
entire release in which they wrote nothing. htmx binds a trigger listener to the
annotated element itself unless `from:` says otherwise —
`if(c.from){t=m(l,c.from)}else{t=[l]}` — and `change` fires on the select and
bubbles through its **DOM ancestors**, which a sibling form is not. So the verb
goes **on the control**; the empty form stays, earning its keep as the answer to
htmx's "whose values are these", which is `function Nt(e){return e.form||g(e,"form")}`
`e.form` first, so a form-associated control resolves to the empty form and
the PATCH carries that one field. Without it, `closest("form")` finds the
composer and the request carries `project_dir`, which `update_chat` answers with
a 409. `tests/conftest.py:control_named` exists to pin this: the element
carrying the `name` must be the element carrying the verb. The three failures in
this feature — `hx-post` at a PATCH-only route, a menu built after it was
written to, a trigger bound where the event does not go — were all *silent*, and
all three had passing tests that asserted the markup rather than the property.
**A rule written for one context matches every context.** `.tok-mention` styles
a mention in the transcript: accent colour, monospace, 0.95em. Nothing scoped it
there, so it also hit the composer mirror's spans — and `.composer__mirror`'s
`color: transparent` is *inherited*, which loses to a colour the span declares
itself. The mirror painted its token visibly, in a different font, over the
textarea's own text: doubled, and shifted from that point on because the metrics
differ. Transcript token styles are `.msg .tok-*`; the mirror's restate
`color: transparent` and `font: inherit` rather than relying on inheritance, and
bleed with `box-shadow` rather than padding and a negative margin, because a
shadow cannot move a glyph.
**Unused columns are worse than missing ones.** `Model.params_json` documented
itself as "default sampling params applied to new chats using this model" and
was applied nowhere for its entire existence, which is how a per-model default
effort looked like it needed a new column. `_new_chat` seeds from it now. It is
empty on every existing row, so honouring it changed nothing for anyone.
**`@` inserts a reference *and* attaches the contents.** The token stays in the
sentence so "change the thing in @main.py" reads as one, and the file arrives as
an attachment chip — the same component every other attach path returns, so the
composer learns nothing new. `Attachment.source_path` and `source_label` carry
where it came from into `chat.document_context`'s tag, because a model handed
`main.py` cannot tell which of four it is looking at and cannot name it back when
asked to change something. Those two are attribute values in a tag we write, so
`_attr` strips quotes and angle brackets rather than escaping them.
**`@` offers everything a chat can reach, and a knowledge base is the exception
that proves the rule.** Project files, documents, notes, skills, this chat's
earlier attachments and a URL all resolve to *an attachment*, copied — a
transcript must not change because somebody edited a note afterwards, the same
rule as PDF extraction. A **base** is a reference instead: `POST
/api/chats/{id}/bases` puts it on `Chat.knowledge_bases`, which already narrows
`knowledge_search`, and the harness already names the attached bases. Copying a
folder of contracts into the window would cost the context on every request
forever to answer one question. It therefore needs an existing chat, so it is
absent on the new-chat screen — the same reason project files are.
**Tabs remember their scroll position, and that made short panels look empty.**
A tab is a radio and a panel is shown by CSS, so switching one changes nothing
about `.tabs__body` -- the element that actually scrolls. Read half way down the
long Tools panel on `/admin/prompts`, switch to a short one, and the browser
clamps the kept scrollTop to that panel's bottom: what lands on screen is the end
of it above a screen of nothing, which reads as a page that failed to load.
Nothing in CSS can reset a scroll position, so `ui.js` does it on change,
delegated and keyed on the class -- the next tabbed screen would otherwise have
the same bug and no sign of it.
**The shell has one line along its bottom edge, and it took a token.** The
sidebar's footer and the composer sit either side of the same vertical edge and
were both content-sized, so their top borders met it at different heights and
read as one line that had been broken. Neither could match the other by
accident: the footer's height depends on which entries a reader's permissions
allow, and the composer's on how much has been typed. `--footer-height` is a
calc of the pieces the footer is built from -- four rows at `--control-h`, the
gaps, the padding -- applied as a `min-height` to both, which holds the footer
at full height for somebody who sees fewer entries and lifts the composer to
meet it. Exactly what `--header-height` already does at the top of the shell. A
composer that grows past it as somebody types is expected; nothing is pretending
the sidebar should follow.
**An SSH connection may not point at this machine unless an administrator says
so.** "Nothing runs on the LLeMbas host" is the sentence the absent sandbox, the
absent local MCP and the whole security story rest on — and a profile pointed at
`127.0.0.1` walked straight past it, looking from the SSH layer down exactly
like a container on the network. `services/agent/hosts.py` is one switch in
three positions: **off** (the default, and the default on upgrade), **port**
(one named port, for a container that published SSH on the loopback interface —
22 is refused there regardless, being this host's own sshd), and **on**.
Enforced in five places because a row can predate a setting: saving a profile,
`session.resolve` (the control — every agent tool, the terminal and the canvas
go through it), the composer's picker, `browse`, and the draft endpoint that the
panels open against before a chat exists. Check refuses *before* opening its
socket.
**And the recognition never resolves a name on the request path.**
`is_loopback` reads the string only. A *name* pointing at loopback needs
`getaddrinfo`, which blocks, and `refusal` is called several times per page
render — the first version resolved inline and the suite went from two minutes
to not finishing. So resolution happens where a network call is already expected
(saving, and Check) and is written to `SshProfile.resolves_here`, which the
request path reads for free. The gap this leaves is stated rather than
discovered: a name whose DNS moves *after* it was saved is not noticed until it
is saved or checked again. `tests/conftest.py` runs the suite with the switch
**open**, because the tests that stand up a real asyncssh server can only listen
on loopback; `tests/test_agent_hosts.py` closes it explicitly.
**An empty htmx verb is a request, not a no-op.** htmx looks for the
*attribute*`if(s(t,"hx-"+r))` is `hasAttribute` — so `hx-get=""` is a real
request for the empty path, which the browser resolves against the current
document. `chat/_canvas.html` rendered exactly that before a chat existed, so
opening the canvas on the new-chat screen fetched the new-chat screen and
swapped the entire site into the panel. The attribute is now *omitted* rather
than emptied, and `tests/test_canvas.py` refuses an empty one anywhere on the
page — the same shape would do the same thing at any other site, and it looks
like a rendering bug rather than a request.
**Which panel buttons exist is the server's answer; which are offered is the
browser's.** The canvas and the terminal both need an agent chat on a chosen
connection, and before a chat exists both of those are controls in the
composer. `_agent_context` answered with `profiles[0]` and stopped there, so
both buttons appeared on an ordinary new chat with nothing selected. They render
`hidden` carrying `data-agent-only` now and follow `lembas:agent-target`, the
event `ui.js:wire()` already dispatched for the panels themselves. On a chat
that exists neither attribute appears and the server's answer stands. An open
panel whose target goes away is closed, or it shows one machine's files under a
heading naming another.
**One scroll container per screen, and `.tabs__body` is only sometimes it.**
`.tabs` assumes it is a bounded flex child: true for `.main > .tabs` on the
settings page, false under the admin layout, where it sits inside
`.admin-scroll > .admin-page` — a plain block — so `flex: 1` and `min-height: 0`
mean nothing, `.tabs__body` has `height: auto`, and the page is what scrolls.
The scroller rule names the position now (`.main > .tabs > .tabs__body`) rather
than the class alone. The consequence worth knowing is why this went unnoticed:
`ui.js` reset `.tabs__body.scrollTop` on every tab change, and **setting
scrollTop on an element that does not scroll is silent** — so on `/admin/prompts`
the fix had never once run, while the reader was dragged to the bottom of a
document that had just got shorter. It walks up for the first ancestor that can
actually scroll now, and brings the tab bar back into view rather than the page
to zero, because there is content above the tabs there.
**The bottom edge of the shell is not drawn.** `.sidebar__footer` and
`.composer` both carried a top border and met the sidebar's edge at different
heights, which is what `--footer-height` was added to fix. The borders are gone
— content scrolls under both, and an undrawn edge reads better than one that has
to be aligned — and the token stays, because two ends at different heights is
visible without a border to prove it. The top of the shell keeps its line:
`.topbar` and `.panel-head` are both `--header-height` and align by
construction, so removing one of those would recreate the broken line in the
other direction.
**A page that uses `.page` needs `.admin-scroll` around it.** `.main` is a flex
column with `min-height: 0`, so content dropped straight into it overflows the
viewport with nothing to scroll — Save ends up below the bottom of the window,
reachable only by zooming out. `settings.html` gets this from `.tabs__body` and
the admin pages from `.admin-scroll`; the folder settings page shipped without
either. The two class names are one rule in `admin.css` for that reason.
**A path is chosen, not typed.** `[data-dir-field]` in `ui.js` is the directory
picker on a form that is not the composer, scoped to that attribute so it and
the composer's own handler cannot both answer one click and open two dialogs.
The composer keeps its own because it does more: it follows the selected
profile's default directory until somebody picks their own, which only means
something while a chat is being created.
**Canvas asks the same way**, having been the last control in the application
expecting somebody to remember an absolute path on another machine.
`GET /api/agents/{id}/browse` takes `pick=file`, and `agents/_browse.html` then
makes files buttons carrying `data-file-open` while directories stay a step.
**One fragment for both modes**, because a second copy of that listing is a
second place for the path arithmetic to be got subtly differently -- and
differently means a file that opens to the wrong path, or to nothing.
`app.js:chooseFile` is its own function rather than a flag on `chooseDirectory`:
what a click does, what finishes it, whether there is a "use this" button at all
and what the dialog is called all differ. What they share is the listing, and
that is shared where it matters -- on the server.
The button carries `data-canvas-open` rather than an `hx-post`, because **the
path is not known until the dialog closes**; `ui.js` posts it afterwards through
`htmx.ajax` so the response lands in the panel exactly as every other canvas
action's does. `fetch` would mean swapping the fragment by hand, and then there
would be two ways the canvas gets replaced. The key is `agent:<path>` -- the same
key a tool call's read produces, so a file opened by hand and one opened by the
model are one tab rather than two spellings of it, which is `path_key`'s whole
job and why the prefix is added in code rather than asked of the reader.
**Messages is bounded in the request and unbounded on disk.** One conversation
per person, meant to run for years, so it cannot all be sent -- `build_messages`
takes the last `LIVE_CHUNK` turns and nothing before them. **Nothing is folded
into text and nothing is deleted.** The visible conversation is identical either
way, so destroying the older rows would buy only disk, against being
irreversible and losing every attachment and tool call in the range -- and it
would contradict the rule compaction already holds, that hiding turns is not
deleting them. `compaction.should_compact` refuses this kind for the matching
reason: two mechanisms narrowing one transcript is how a summary ends up
summarising a summary.
The consequence is worth stating rather than discovering: past the live chunk
the model genuinely does not see what was said.
`GET /api/messages/history?before=` is the mirror of `thread_tail` and keeps its
four properties -- 204 on a cursor it cannot place, the comparison in SQL with an
`id` tie-breaker (without which a row sharing the cursor's microsecond can never
be reached, and a message that cannot be scrolled back to is gone), an explicit
`hx-target`, and a sentinel outside the composer form. The fifth is its own:
**prepending moves the scroll position**, so `app.js` records `scrollHeight`
before the swap and adds the difference back after. Without it the reader is
dragged up the page the instant the sentinel fires, which reads as a browser bug.
**`TemplateResponse` injects nothing.** The history route renders
`chat/_message.html` outside `render()`, so `user` *and* `chat` are passed by
hand -- the same reason the SSE path does. Missing either is a 500 on scroll from
a page that rendered perfectly.
**A command can be corrected before it is allowed, and the edit lands in exactly
one place.** `arguments` is the list `_run_calls` hands to `run_tool` as
`parsed=`, and `run_tool` never re-parses — so writing into it inside
`_authorise` is the only mutation the runner sees. Editing the `Item` does
nothing: it is frozen and display-only. Two things move with it. The raw
`call["arguments"]` string is rewritten, and the assistant turn is built **after**
`_authorise` rather than before it, or the model is told it ran what it proposed
while something else ran and every later round reasons from a transcript that is
quietly false. And `_remember_always` reads the edit, or "always allow this"
stores a standing permission for a command nobody approved — it still derives
the pattern itself through `policy.subject`, which yields nothing for a composed
command line. The box is offered only where the detail *is* an argument
(`tool_labels.DETAIL_KEYS`); anything whose detail is a `k=repr(v)` summary
cannot be put back, and a box there would silently change nothing.
**htmx's `hx-prompt` cannot be intercepted, and `hx-confirm` can.** htmx calls
the browser's `prompt()` synchronously and *then* fires `htmx:prompt` with the
answer already in hand, so cancelling the event only aborts the request and the
grey box appears regardless — `htmx:confirm` fires before, which is why that one
works. `data-prompt` in `ui.js` follows the `data-confirm-button` shape instead:
swallow the click, ask in the themed dialog, write the answer into `hx-vals`,
click again behind a guard flag. `JSON.stringify`, never concatenation, or a
folder called `"` produces `hx-vals` that does not parse and the request goes out
with the field missing rather than with the name. There is a test that no
template brings `hx-prompt` back.
**An agent chat is named from its first prompt and costs no model call.**
Somebody starting one states an objective, not a topic. An ordinary chat opens
with a question whose *answer* is what makes a title worth asking a model for,
and is unchanged. `update_chat` answers a rename with the same out-of-band pair
the `done` frame sends, so one response moves the heading and the sidebar row —
only on a rename, because sending it for every PATCH would overwrite the heading
from an unrelated save.
**Three numbers per panel decide a width, in three files, and two of them fail
silently.** `LAYOUT_BOUNDS` drops an unknown CSS variable on purpose, so a panel
missing from it has a drag handle that appears to work and forgets by the next
page load. The allowlist's lower bound, the handle's `data-resize-min` and the
`--*-width-min` token are pinned equal by `tests/test_layout_bounds.py`.
**Unread is polled, not pushed.** A browser on another chat has no connection
to the one that finished. `/api/chats/unread` returns out-of-band dot spans and
an `HX-Trigger` carrying **items** — each with a kind, a title and a URL, because
a browser notification is a thing you click and a title alone cannot say where.
`unread_notified` (on `Chat` *and* on `Report`) stops the same arrival being
announced every tick. Re-rendering the whole sidebar instead would reset the
folder open/closed state every 10 seconds.
**Everything that can arrive is announced, not only chats.** The dots covered
Reports and Messages from the day those sections existed; the *announcement* did
not, so a scheduled run that filed a report lit a dot in a corner and said
nothing. That is exactly the arrival nobody is watching for — a chat reply is one
you asked for a moment ago.
**One arrival, three channels, and they must not all fire.** A toast for somebody
looking at the page; a count in the tab title (`(3) LLeMbas`) while it is hidden,
cleared on focus; and a system notification for somebody elsewhere entirely. The
service worker is the only place that can tell them apart — it skips
`showNotification` when one of its own windows is `focused`, because the server
cannot see focus and the page cannot see a push it did not receive.
**Web push is the one thing here that contacts an outside service.**
`services/push.py`, hand-rolled against RFC 8291 and RFC 8292 with
`cryptography`, which is already a dependency. It exists because everything else
is polled *by an open page*, and the arrival worth notifying about is a schedule
firing at seven in the morning with the browser shut. The trade is real and
written down in the module: the POST goes to Google's or Mozilla's push service,
the payload is encrypted end to end so they cannot read it, and what they do
learn is that this server sent something and when. Opt-in **per device**, because
the permission and the subscription both belong to a browser.
Three things about it that are easy to get wrong:
- **`announce_later` is called where something arrives**, never from the poll —
the poll needs a page, and this is the case where there is not one. Each
arrival site runs exactly once, which is what makes it fire once with no flag
of its own; borrowing `unread_notified` would let whichever channel got there
first silence the other. It checks for a running loop **before** building the
coroutine, or every synchronous caller raises "never awaited" at its own line.
- **The VAPID keypair is generated once and never regenerated.** Its public half
is inside every subscription a browser holds, so a new one silently
invalidates all of them — notifications simply stop with nothing saying why.
- **404 and 410 delete the subscription**; anything else keeps it. Those two are
the normal end of a subscription's life, not a failure.
**Editing rewinds, it does not branch.** `POST .../messages/{id}/edit` rewrites
a user turn and **deletes everything after it**. Branching would need a UI for
choosing between versions; "go back and try again from here" is what was asked
for and what other clients do. `Message.parent_id` still exists unused.
**Dialogs and toasts are ours, not the browser's.** `static/js/ui.js` provides
`lembas.notify/confirm/prompt`, and intercepts htmx's `htmx:confirm` so every
existing `hx-confirm` gets the themed dialog with no change at the call site.
Plain forms opt in with `data-confirm`, lone submit buttons with
`data-confirm-button`. Never add a `window.confirm` back.
**The model picker is hand-built.** A `<select>` renders only text in an
`<option>` -- no avatar, no description, no badges. `chat/_model_picker.html`
plus the picker block in `ui.js`; the value lives in a hidden input so it still
behaves as a form field.
**The panels work before the chat does, and a draft is how.** The terminal and
the canvas both needed a `Chat`, which meant they were unavailable on the one
screen where you are deciding *which machine to work on*. `services/agent/draft.py`
holds an id and the three facts behind it -- owner, connection, directory -- and
`as_chat` builds a **transient `Chat`**, constructed and never added to a
session. That is the whole trick: `canvas.agent_ready`, `_executor`,
`_load_agent`/`_save_agent` and `agent_session.resolve` read only `user_id`,
`kind`, `ssh_profile_id` and `project_dir`, and none of them queries or writes
the row, so every one of them works unchanged and none had to learn what a draft
is. `id` and `canvas_json` are set explicitly: both are *column* defaults, which
SQLAlchemy applies at flush, and this row is never flushed.
The id is **derived** from (owner, connection, directory) rather than invented,
so returning to the same new-chat screen finds the shell already running there
instead of quietly opening a second. The owner is in the hash because two people
pointed at the same directory would otherwise share a *shell*.
**Two canvas sources are refused on a draft, by name.** `scratch` needs a row --
`scratch_service.for_chat` would write a `ScratchDoc` keyed on a chat that does
not exist. `file` is the one that matters: `_load_file` authorises with
`attachment.chat_id != chat.id`, and an upload made on the new-chat screen is
stored with `chat_id=None`, so a draft whose chat carried no id would make that
`None != None` -- False -- and open every unclaimed attachment its owner has.
`as_chat` does set an id, so the comparison already fails; the refusal is stated
anyway, because a guarantee that lives in an id-shaped coincidence is one the
next change breaks silently.
**Adoption is a re-key and a copy, and the redirect does most of it.**
`start_chat` already answers `204` + `HX-Redirect`, so the browser reloads and
both panels re-render with the real id -- the canvas adopts by construction,
since its URLs are server-rendered, and the terminal reconnects to the re-keyed
session and replays its scrollback. `terminal.rekey` moves **both the registry
key and `session.chat_id`**: `close_for_profile`, `close_for_owner` and the
reaper all pop by the field, so a stale one would leave a dead session that
`get` keeps handing out. The shell is adopted only when its profile and
directory match the chat **as finally resolved** -- `_new_chat` falls back to the
connection's login directory when the field is empty -- and otherwise left where
it is rather than transplanted onto a chat that says it runs somewhere else.
`lembas:agent-target` is what re-points them when the selection changes; `ui.js`
dispatches it from `setDir` and the connection select, because assigning to a
hidden field's `.value` fires nothing on its own.
**Chats are created lazily.** There is no endpoint that makes an empty chat.
"New chat" is a link to `/chat`, which renders a composer with no row behind
it; `POST /api/chats/start` writes the chat together with its first message.
That is why an opened-and-abandoned chat never appears in the sidebar. Tests
that just need a chat use the `make_chat` fixture rather than the HTTP flow.
**Admin lists are list-plus-detail, never a form per row.** `/admin/models`
renders compact rows with search, filter tabs and pagination; the full form
lives at `/admin/models/{id}/edit`. A connection can advertise a hundred models,
and a page that renders a form for each is unusable. Any future admin list
(tools, agents) should follow the same shape.
**Route order matters for static path segments.** FastAPI matches in
registration order, so `/admin/models/bulk` must be registered *before*
`/admin/models/{model_id}` or "bulk" is parsed as a model id and 404s. This has
already been a bug once.
**Pinning is not ordering.** The model picker is always in the administrator's
`position` order. Pinned models get shortcuts in the chat sidebar and nothing
else -- a picker whose order differs from the admin screen is just confusing.
The shortcuts come from `sidebar_context`, not from `_chat_context` where they
began: they are sidebar content, the fragment route that re-renders the sidebar
has only the former, and the library and connections pages carry the sidebar
without ever calling the latter -- so the shortcuts were simply absent on all of
them. They carry `&kind=agent` with the switch, and they sit above the tree it
swaps, so they arrive out of band exactly as the New chat button does. The group
is rendered **even when empty**, because a block that vanished when the last
model was unpinned would leave that out-of-band fragment with nowhere to land,
and htmx says nothing at all when a target is missing; `.nav-group--pinned:empty`
is what stops the empty one taking room.
**System prompts are precedence, not concatenation.** chat > folder > model >
instance, most specific wins outright
(`services/chat.py:effective_system_prompt`). Stacking them reads well in a
settings screen and badly in practice: two layers that disagree give the model
contradictory instructions and nobody can tell which is losing.
The folder rung goes *above* the model deliberately: a model's prompt describes
the model wherever it is used, a folder's describes this piece of work whichever
model is pointed at it. It is read when a reply is built and never copied onto
the chat, so editing a folder reaches the chats already in it, and the walk up
the parents is bounded and cycle-safe because it runs on the request path.
`api/pages.py` mirrors the ladder for the settings panel's "inherited from"
hint, and has to keep mirroring it layer for layer — a panel naming the wrong
source is worse than one naming none, because it is believed.
A folder's other settings are **seeds**, copied by `_new_chat` into whatever the
request left empty and nothing it filled in: the folder says what this work
usually needs, the screen in front of somebody says what they want this time.
`Folder.ssh_profile_id` is a plain string rather than a ForeignKey, for the
reason `compacted_through_id` is, and is validated on read.
**JSON columns need reassignment.** `user.settings_json["theme"] = x` on a
plain dict is not detected. The columns use `MutableDict` (`db/types.py`), but
the safe habit is `obj.field = {**obj.field, "k": v}`.
**`[hidden]` needs `!important`.** The browser's rule is `[hidden] { display:
none }`, which any class setting `display` outranks — and `.btn` is
`display: inline-flex`. That is not theoretical: it is why the old Stop button,
created and then `hidden = true`, sat permanently beside Send. `app.css` forces
the attribute to win. Anything toggled with `hidden` depends on that line.
**Send and Stop are one button.** `chat/_composer.html` renders both icons and
`ui.js` flips `data-composer-action` plus `type` (`submit``button`) when a
message in the thread is still streaming. Do not add a second button back.
**A second message while a reply streams is queued, not sent.** It used to be
accepted outright: a second assistant placeholder, a second concurrent
`Generation` answering the same chat from a different prefix of it, and
`ui.js`'s first-match `querySelector(".msg[sse-connect]")` pointing Stop at
whichever bubble came first in the document. A queued turn is a real `Message`
with `queued` set — so it survives a restart, is in the transcript the moment it
is typed, and can be withdrawn before it is ever sent. `build_messages` skips
it. It must never carry `sse-connect`; the streaming shell is still the only
thing that starts a generation, so one on a queued bubble is that second
generation again.
**Delivery is two places, and neither is a special case.** `_drain` runs in
`_run`'s `finally:` between `_persist` and `done` — after the row is
authoritative, before the flag `_follow` breaks on, because the `done` frame is
the last thing that reaches a browser and has to carry the next turn's bubbles
out of band. It takes **one** waiting prompt, not all of them: draining the lot
puts two consecutive user turns in the next request. `_inject` takes one *into*
a reply at a tool-round boundary, which is the point of queueing in an agent
chat — steering work already under way — and restamps the assistant
placeholder's `created_at` so the reply still sorts before the prompt it
answered. It refuses on the last round: a prompt delivered into a reply that
then runs out of budget is marked delivered and never sent again.
**Stop leaves the queue alone**, deliberately, and `_drain` refuses on stopped,
errored and superseded. Stopped is the reader's decision; errored would feed the
next prompt into an endpoint that has just failed; superseded is the same test
`_persist` makes, without which regenerating drains the queue as a side effect.
Cancellation sets `stopped`, so a restart never fires off a reply with nobody
watching.
**An interjection is sent verbatim, in the user role.** Everything else this
codebase injects is quoted and attributed because it came out of a file, a page
or a machine; this one genuinely is the person at the keyboard. Wrapping it
would teach a model that a user turn can be a quotation, which is the exact
distinction `execute_plan` and `Capture.as_text` rely on. What the model needs —
that this can happen at all — is the `core.interjection` harness fragment.
**The tool loop is inside one generation.** `services/generation.py:_run()` runs
request rounds for a single reply: stream, accumulate tool calls, run them,
append the results, ask again. `Generation` accumulates content across all of
them, so text emitted before a tool call survives. Tools are only offered when
search is enabled, the user has `tools.web_search`, **and** the model is flagged
`tools` — sending a `tools` array to an endpoint without support fails the whole
request, exactly as images do without `vision`.
**A round ceiling is a ceiling, not a schedule.** The loop ends the moment a
round comes back with no tool calls — that is the model saying it has what it
needs, and it is the same termination condition every agentic harness uses. The
number only catches the case where it never says so: a small model that has
decided searching is the answer, searching until the context runs out at a full
request each.
It was 1, then 5, and it is **0 by default now** — no ceiling, the loop falling
back to `MAX_TOOL_ROUNDS` as a runaway backstop, which is the shape
`Limits.steps` already had for an agent chat. Both numbers were the same mistake
at different scales: low enough to be reached by ordinary work is low enough to
be a schedule rather than a ceiling, overriding the model's judgement on every
turn instead of catching a runaway. One left the library searchable and not
readable, several built-ins being **two-step pairs**`knowledge_get` and
`notes_get` read a document *"by the id a search returned"*. Five ended a small
local model's genuinely good piece of research at its sixth search. What bounds
an ordinary chat is the context window, which is a real limit rather than a
guess at how much looking-up a question deserves. `settings_store.chat_rounds()`
is the number; `tools.MAX_ROUNDS` is only the fallback for callers with no
session, and a test pins the two equal.
**A budget must never end a reply in silence.** Every one of them used to
`break` where it was noticed, leaving whatever prose the model had emitted —
which for a model that goes straight to tool calls is *nothing*, so the reader
got an empty bubble with a red line under it and the whole reply thrown away.
`_wrap_up` withdraws the tools and asks once more instead: what was gathered is
in the transcript either way, and one request turns it into an answer. That is
the move `plan_submit` already makes — a turn should not end mid-sentence — and
it is why the loop runs to `budget + 2`: the round at `budget` notices the
overrun, the one after it answers. The event still goes in the transcript,
because an answer the model chose to give and one it gave because it ran out of
room read identically otherwise. `_too_big` is the single exception and stays a
hard stop: it *is* the finding that there is no room for another request, so a
wrap-up round would be the same overflow with an upstream error in place of an
explanation.
**`core.rounds` and `core.keep_working` are gated on complements, so exactly one
appears.** `round_budget` is set only when an administrator has put a ceiling on
an ordinary chat; `unbounded` is set precisely when it is not, and an agent chat
always has the second. Neither renders anywhere — they exist to be `requires`.
A model told it has a budget rations it and stops early to report progress, and
one told to keep going does the work; those are different sentences rather than
the same sentence with a different number in it.
An agent chat is sized by `agent/policy.py:Limits` instead, where **`steps` is a
runaway backstop and not a working budget**. It was 40 and it was reached; a step
count low enough to be the thing that ends a reply is a count that ends it
halfway. What actually bounds one is the wall clock and `completion_tokens`.
These are different sentences rather than the same sentence with a different
number in it, which is why `core.rounds` and `core.keep_working` are two
fragments gated on `round_budget` — blank in an agent chat, and blank again when
an administrator has set no ceiling, so the fragment vanishes rather than
promising zero rounds.
**The token ceiling would have worked on OpenAI and silently done nothing
elsewhere.** `generation.completion_tokens` is only populated when the endpoint
sends a usage block, and llama.cpp, Ollama and friends never do; the fallback
estimate is computed once, in `_run`'s `finally:`, long after the loop that needs
it. `_written()` takes `max(reported, estimated)` so the limit fires everywhere.
The worst kind of limit is one that looks configured.
**The metrics interpolate between usage blocks, and never overwrite one.** Usage
arrives once per *round*, so `reported or estimated` stopped consulting the
estimate the moment round one's chunk landed — and a forty-round agent reply then
sat at round one's numbers for minutes at a time while text streamed underneath.
The other direction is just as wrong: a reported count must never be replaced by
four-characters-to-a-token, which is a downgrade dressed as a fix.
`metrics._since_counted` is the seam. `Generation.counted_chars` is stamped at
the **end of each round** — not where the usage chunk is read, because
`ReasoningSplitter` is still holding that round's last characters back then, and
stamping there made the stored figure a token or two above what the endpoint
actually said — so the interpolation is zero exactly when a count lands and the
displayed figures converge on the reported total rather than drifting past it.
Two consequences worth knowing. `estimated` is now a recorded fact
(`Generation.reported_usage`) rather than an inference from "are both counts
non-zero?", which the end-of-reply fallback made true of a reply nobody had
counted — so the `~` showed all the way through and vanished at the moment the
numbers were written down. And that fallback is **gone**: `from_generation` is
the single place the three figures are worked out, live and at rest, because two
copies of one rule is how the prompt figure came to jump at the `done` frame
(one used `prompt_estimate`, the other `prompt_estimate_total`).
`_follow` also sends `metrics` on a **clock** (`METRICS_INTERVAL`) as well as on
a version bump. There is no `touch()` inside `_run_calls`, so during a
five-minute build on the far side nothing moved on screen while the elapsed
clock advanced — and frozen chips beside a spinner read as a hang.
Neither chip was ever *wrong*, which is why this looked like arithmetic and was
not: the first is what the reply **cost** (prompt + completion summed over
rounds, the prompt paid for once per round) and the second is what the
conversation now **occupies** (the last round's prompt plus its completion). On
a multi-round reply those differ by a lot, and `chat/_metrics.html` says which is
which in the titles now, because two token counts a few centimetres apart with no
labels just look like one of them is broken.
**A model that stops is believed, unless something objective says otherwise —
and there are two such things.** `core.keep_working` is the cheap half of
stopping-halfway; `generation._nudge` is the other half, and it never fires on a
hunch. The signals, in order:
1. **An open task on the chat's own plan.** The strongest there is: the model
wrote the list and has not crossed the item off.
2. **A long reply that touched nothing.** No plan, no tool call anywhere in the
reply, and more than `NUDGE_MIN_CHARS` of prose. That is a model deliberating
itself to a standstill — announcing the call, reconsidering, announcing it
again — and the reply simply *ended*, because a round with no tool calls is a
model saying it has finished and it is taken at its word. `core.commit` is the
prompt half.
The second is narrow on purpose. `tool_events` being empty is what keeps it away
from the common case: a reply that did some work and then said it was done has
made a claim anybody can check by reading the transcript, and arguing with that
is how a model gets nagged for finishing. `NUDGE_MIN_CHARS` keeps it away from
the other one — a question asked in an agent chat and answered in two lines is
not a stalled agent.
It is asked at most `MAX_NUDGES` times **in a row** for a plan (the count resets
the moment a tool is called again) and **once** for the second signal, because
the text is cumulative across rounds — the signal stays true however the model
answers — and the nudge explicitly invites a one-line "I have finished". Asking
again would be refusing the answer we asked for. Never in Plan mode, and never
past `plan_submit` — that ends the turn deliberately and nudging it would argue
with the point of the mode. Giving up is recorded as an event rather than left
silent. The model's own words go back with the nudge, or it is asked to carry on
from a transcript in which it never spoke.
**A round's text is flushed before it is echoed back.** `ReasoningSplitter`
holds back a few characters against a `<think>` tag split across chunks, so
`round_text` at the end of a round was missing its last words — and that text is
echoed as an assistant turn, both for a tool round and for a nudge. A turn
missing its tail is one the model is asked to continue from having apparently
trailed off mid-sentence.
**A chat can narrow what it may use, and can never widen it.** `Chat.scope_json`
is filtered inside `resolve_tools` *after* the capability, permission and
instance gates — exactly as `chat.knowledge_bases` narrows `knowledge_search`
so a crafted POST turning something on reaches a tool the gates already removed.
**Absent means on**, for every key, so "why is this off?" has one answer. It is
keyed on the *gate*, not the tool name, so `notes` is one switch rather than
five, and a row-backed tool's `custom:weather` gets per-row control for free.
Skills are narrowed both in the listing and in `_run_skill_get`: without the
second the narrowing is advisory, since a model can name a skill it was never
shown.
**With no skills, nothing should mention them.** `tool.skills` was gated on the
family alone, so somebody with an empty library was told "the list below gives
each one's name" above no list, handed `skill_get`, and watched the model spend a
round finding out. It now `requires=("skills",)`; the *writing* half moved to
`tool.skills_write`, which is deliberately not gated, because saving the first
one is what somebody with none most needs. `resolve_tools` drops `skill_get` and
`skill_edit` at zero. And `core.tool_list` finally reads `tool_names`, which had
been resolved and documented with no fragment using it — a model that has to
discover its own tool list by calling something and being told it does not exist
spends a round, and with one round that is the whole reply.
**The registry is resolved per request, not imported.** `REGISTRY` holds the
built-ins; a custom tool or an MCP tool is a row. `tools.resolve_tools()` returns
a `ToolSet` carrying the schemas *and* the runners, and the runners travel to the
loop on `ToolContext.tools` — because a generation outlives the session that
could look them up. `run_tool` consults that map, so what may be *run* is what
was *offered*. `None` means nobody resolved a set and falls back to the built-ins;
an empty dict is authoritative. Reaching for the global registry instead is how a
model naming a tool its chat was gated out of used to get it run anyway.
**A row-backed tool's family is `gate:slug`.** `custom:weather`, `mcp:github`.
The capability flag and the permission are named after the *gate*
(`tool_custom` / `tools.custom`), so a server advertising forty tools does not
mean forty checkboxes on every model; the full family exists so each row can
carry its own prompt fragment, gated to appear exactly when its tool is offered.
`harness._families` and `admin_prompts` therefore take a `db`. Custom and MCP
deliberately do **not** require `library.use`: an endpoint an administrator wrote
has nothing to do with anyone's own notes.
**An argument may fill a hole; it may never move the target.** A custom tool's
URL is a template. The scheme and host must be literal — checked at save *and*
again at call time, since a row can predate a check — values are escaped for
where they land (`quote(safe="")` in a URL, JSON-escaped in a body, control
characters stripped in a header), and the filled URL's origin is compared with
the template's afterwards. An undeclared `{{name}}` becomes nothing rather than
passing through, which is the opposite of `prompts.substitute` and deliberately
so: a literal `{{x}}` in a URL is not a feature.
**Three places now follow redirects by hand.** `fetch.fetch`,
`custom_tools._send` and `mcp.client.Session._post`, each re-running
`check_url` on every hop. `fetch()` itself is not reusable — GET-only and
bodyless. The duplication is deliberate; bending a page fetcher into a general
HTTP client is not, and a fourth hand-rolled loop is how one of them loses its
SSRF check. A secret is dropped when a hop leaves the origin it was issued for.
**The content-type sniff was widened by exactly one list.** It used to raise on
anything that was not HTML or `text/*`, which is every JSON API there is —
already wrong for the `@`-link attach path, and unusable once a model can ask for
a URL itself. `_TEXTUAL` plus the `+json` / `+xml` suffixes now come back as
text; images, PDFs and `octet-stream` still raise, because handing a model five
megabytes of binary is what the refusal was for. That is a sniff being fixed, not
a page fetcher becoming an HTTP client.
**`fetch` is a tool, with its own family and its own switch.** Separate from web
search, because an administrator may reasonably want a model that can look things
up but not follow an arbitrary URL it read somewhere — and the whole SSRF surface
is on this side. The instance switch is separate again from `allow_private_fetch`
and earns its keep: turning it off stops a *model* fetching while the composer's
Link option keeps working, because that one is a person's instruction rather than
a model's choice. `MAX_FETCH_CHARS` caps what reaches the model at 20k, since
`fetch()` returns up to 120k — one call would otherwise fill an ordinary window
and spend an agent chat's whole output budget on a single page.
**MCP sessions are per call.** Initialize, `notifications/initialized`, the call,
then a best-effort `DELETE`. Caching one would need an owner, a TTL, eviction, a
lock (a round runs its tools concurrently) and a shutdown hook, and the server
can expire it underneath all of that anyway — `ToolContext` is a session-free
snapshot precisely so nothing in a tool holds live state. The cost is one POST in
front of a call that is already a network round trip. `307`/`308` are followed;
`301`/`302`/`303` turn a POST into a GET and are refused rather than guessed at.
**A discovered MCP tool is JSON, not a row.** `McpServer.tools_json` caches
`tools/list`. `Model` is a table because each row carries eight independent admin
decisions; a discovered tool carries one (offered or not, in
`tool_overrides_json`, where absent means on), credentials and guidance are per
server, and the list is replaced wholesale on every refresh — a table would mean
reconciling rows against a cache of somebody else's document.
**An MCP tool has two names.** The server's own, which `tools/call` needs, and
the offered one in the schema — `slug_tool`, lowercased into
`[a-z0-9_-]{1,64}` because endpoints accept less than MCP does. Built-ins claim
their names first and can never be shadowed; a custom tool whose slug collides is
*refused at save*, an MCP tool is renamed silently, since the one that can adapt
should be the one that has to. The rename never leaves `mcp/registry.py`.
**A server's tool metadata is untrusted input that becomes instructions.**
Names, descriptions and schemas from `tools/list` are bounded and sanitised in
`mcp/protocol.clean_tool` before anything reaches a model. What a tool *returns*
is untrusted too, and is rendered as escaped preformatted text — never through
`services/markdown.py`, which is the one path allowed to emit HTML.
**A round's tool calls run together.** `generation._run_calls` gathers them under
a semaphore of four and keeps the results **indexed, not appended as they
finish**: each tool turn must line up with the assistant turn's `tool_calls` or
an endpoint matching on `tool_call_id` pairs the right id with the wrong content.
Safe because `run_tool` never raises and every runner opens its own session.
`generation.status` names what is running, because a remote tool taking seconds
with nothing streaming is exactly what a hang looks like.
**Tool-call arguments arrive in fragments.** `delta.tool_calls` carries an
`index`, a name that appears once, and an `arguments` string split across
chunks. `tools.ToolCallAccumulator` rejoins them keyed on `index` — not on
name, which breaks the moment a model calls one tool twice in a turn.
**Four stores, four different reasons.** `services/library/``documents`
(uploaded by a person, searched by the model), `notes` (written by the model,
searched), `memories` (short, and *injected whole* every turn), `skills` (index
injected, body fetched by tool). The shape of each follows from how it reaches
the model: a memory is capped short because it costs tokens on every request
forever, a note is not injected because a dozen would fill the window.
**Documents live in knowledge bases, and the base is what is shared.** A
`Document` always belongs to a `KnowledgeBase`; visibility comes from the base,
never the document, which is why `Document` is absent from
`sharing.RESOURCE_TYPES` and `documents.visible()` filters on
`base_id IN (visible bases)`. Per-document grants would mean answering "who can
see this?" by checking every file. `Document.base_id` is nullable only because
the column had to be added to a table that already had rows;
`documents.sweep_unfiled()` runs at startup and files anything predating bases
into its owner's default.
**A chat attached to bases is scoped to them.** `Chat.knowledge_bases` is
many-to-many; empty means "everything the owner can see", not "nothing".
`tools.context_for(db, user, chat)` carries the ids and `knowledge_search`
filters on them — and the harness names the bases, because otherwise the model
cannot tell "there is nothing about this" from "I am only allowed to see the
contracts folder".
**Sharing goes through one helper, and admins do not bypass it.**
`services/sharing.py:visible_to()` is the only definition of who can see a
library item, and every listing and tool uses it. `permissions.resolve` gives an
admin everything, deliberately — but that is about configuration, which an admin
can grant themselves anyway. Reading someone's private notes is not the same
act, so `sharing` has no admin branch. Sharing grants **reading only**.
**FTS5 tables are outside the model-driven schema sync.** They are not
SQLAlchemy models, so `sync_schema()` cannot diff them; `db/migrations.py:
ensure_fts()` writes them out with `IF NOT EXISTS` and creates the triggers that
keep an external-content index correct. It runs at every startup and converges,
like the column sync beside it. `tests/conftest.py` calls `sync_schema` rather
than `create_all` so tests run against the same schema.
**A failed search rolls back.** One broken FTS statement otherwise leaves the
session unusable and every later query in the request fails too, which looks
nothing like a search problem.
**Knowledge attachments are copies.** Attaching a library document to a message
duplicates its text and its file (`files.copy_document`). Referencing it would
mean a conversation changing when a document is edited or deleted later — the
same reason PDF text is extracted once at upload.
**The link fetcher is an SSRF hole unless guarded.** `services/fetch.py` refuses
loopback, private and link-local addresses **after resolution** — a hostname
pointing at 127.0.0.1 walks past any check that only reads the URL — and follows
redirects by hand so every hop is checked. An admin can open it deliberately.
The URL can come from a model, which can be talked into things by a page it just
read.
**The harness is an exception to the prompt-precedence rule, on purpose.**
"System prompts are precedence, not concatenation" governs the three *authored*
layers, and it stands: exactly one still wins, and `effective_system_prompt`
still decides which. `services/harness.py` is a different axis — it describes
the machinery rather than the behaviour, nobody authored it, and there is
nothing for it to disagree with. It is prepended to whichever authored prompt
won, in one system message (several endpoints reject a second one), and
`build_request` is where the two meet.
**The harness holds no text.** Every piece of it is a `Fragment` in
`services/prompts.py`, edited on `/admin/prompts`. `harness.py` decides which
fragments apply and what their variables resolve to; `prompts.py` owns the
wording, the storage and the substitution, and knows nothing about chats or
tools. Four rules hold the whole thing up:
- **Defaults live in code, overrides live in the database**, and text equal to
its default is never stored. That is what lets a later release improve a
default and have it reach an instance whose administrator once pressed Save.
- **An empty override means off**, which is why there is no separate enable
flag: clearing the box in the admin page *is* the switch. A fragment that was
not submitted at all keeps whatever it had — it may be missing from the page
because the thing contributing it is switched off.
- **A fragment carries its gate as data** (`families`, `requires`,
`when_tools`), never as a callable, because a database row can carry the same
three fields. `requires` is why there is no longer a hand-written pair of
memory-guidance variants: the sentence that refers to a section lives *inside*
that section, so it cannot outlive it.
- **`{{name}}`, and anything unrecognised passes through verbatim.** Names are
lowercase letters, digits and underscores, so `{"total": 1}` and `${PATH}` are
never candidates. Substitution is one pass and never recursive — `{{memories}}`
carries text a model wrote, and a memory reading `{{skills}}` must not expand.
A model with no tools now gets the core fragments too, the date above all.
"An empty harness is worse than none" was about tokens that say nothing, and a
model with no clock being asked about the present is not that. Clearing those
fragments restores the old silence exactly.
**Tool descriptions are not fragments.** They are schema, sent verbatim in the
`tools` array, and they state facts about what a runner does — an administrator
editing `notes_edit`'s "omit a field to leave it alone" would make the text a
lie with nothing to catch it. The page lists them read-only so nothing injected
is hidden. A *custom* tool's description will be editable, because it is a row.
**A model's tool flags default to on when `tools` is on.** Rows configured
before the per-tool split have no `tool_*` keys. Reading absent as off would
silently take web search away from every model already set up for it, so
`tools.enabled_tools` treats absent as inherited.
**Tool results are not replayed.** Like reasoning, `Message.tool_calls_json` is
stored and rendered but never fed back as context. The answer already contains
what the model made of the results; replaying stale results and the schema into
every later request wastes the window and reliably sends a small model into a
search loop. The sources stay visible in the transcript.
**Search results are untrusted.** Hard rule 6 covers them as much as model
output. `chat/_tool_activity.html` escapes everything and only renders `http`
and `https` URLs as links — a result carrying a `javascript:` URL must never
become an anchor.
**A message bubble is rendered from four places.** `pages.py`,
`chats.post_message`, `chats.regenerate` and `chats._follow`. Each needs
`audio_service.template_flags(db, user)` or the speaker button's conditions are
undefined; the template uses `| default(false)` so a missed one degrades to no
button rather than an exception. `_follow` also passes `just_finished`, which is
what read-aloud-automatically keys off — without it, reopening a chat would
start reading its last reply out loud. **`tool_label` and `tool_icon` are Jinja
globals for exactly this reason** — a fifth thing every one of the four would
have to remember is a fifth thing one of them will forget.
**What a tool is called lives in one table, and the static one wins.**
`services/tool_labels.py` is read by the transcript, the status line while a
round runs, and the approval card; those three disagreed for the whole life of
the feature — one said "homeserver", one said "shell_run", one said "Run a
command" — and nothing checked. The precedence is inverted on purpose: tool
events are **persisted** in `Message.tool_calls_json`, so every agent row already
on disk carries `label` set to the SSH profile's name, and a resolver preferring
the stored value would fix nothing for any transcript that already exists. So a
name the table knows resolves from the table; a name it does not — a custom HTTP
tool, an MCP tool, whose labels are per row and cannot be tabulated — keeps its
own. One rule, both cases correct. The machine now travels in `detail`, where
"where this ran" belongs.
**Dictation audio never touches disk.** `api/audio.py` reads it into memory,
capped, and streams it upstream. It is not an attachment: it has no owner, no
row, and nothing would ever sweep it.
**The service worker must skip `/api/`.** A reply is an endless event stream and
passing one through a worker turns it into one delivery at the end, or nothing.
`static/js/sw.js` bails out on `/api/`, `/auth/`, `/admin/` and any request
accepting `text/event-stream`. It is served from `GET /sw.js` rather than the
static mount because a worker's scope is the path it came from.
**`ToolContext` gained `chat_id`, and that fixed a tool nobody had ever run.**
`_run_scratch_write` read `context.chat_id` on a dataclass that had no such
field, so **every `scratch_write` call raised `AttributeError`** — swallowed by
`run_tool`'s blanket except into "the scratch_write tool failed", which reads
exactly like a model calling it wrongly. The test that existed asserted the
family and the risk, which are properties of the declaration rather than of the
code. There is one that runs it now.
**A control wired to a method its route does not serve fails silently.** The
agent-mode select posted with `hx-post` against a route that only answers
`PATCH`, so every change returned 405 and the mode never moved — for the whole
life of the feature. htmx surfaces nothing on a failed request, so the select
stayed where it was put and the server ignored it, which looks exactly like
working. `tests/test_agent_mode.py` asserts the method is *refused* as well as
that the right one works, because only the second half would have passed
throughout. When adding a control that writes, check the verb against the route,
and assert on the row rather than on the response.
## Changing the schema
There is no Alembic, but there *is* `db/migrations.py`. It compares the declared
models against the live database and issues `ALTER TABLE ... ADD COLUMN` for
anything missing, so adding a column to a model is free: restart and it appears,
with existing rows backfilled from a type-derived default.
**A nullable column is added with no default**, so existing rows get NULL --
the value the model treats as absent. Only a NOT NULL column gets one, because
SQLite refuses to add one without. An earlier version defaulted every column by
type, which meant an added foreign key arrived as `""` on old rows and every
"is this set?" check downstream was wrong about them.
It cannot rename, drop or retype a column, or add a UNIQUE/PRIMARY KEY to an
existing table — SQLite mostly cannot do those with ALTER TABLE either. Those
need the create-copy-swap dance by hand; record them in `MANUAL_STEPS` so a
failure has somewhere to point.
Because the runner exists, forward-looking columns are cheap now. `Message.parent_id`
and `content_parts_json` (branching, multimodal) predate it and are still unread.
## Artwork
Do not hand-edit files in `assets/` — they are generated. Change
`scripts/build_artwork.py` and re-run it. It also copies the few files the app
serves into `web/static/img/`.
The leaf geometry is defined once (`LEAF_BLADE`, `LEAF_MIDRIB`, …) and reused by
the icon, favicon, lockup and banner. The 64×64 mark must stay legible at 16px:
the favicon variant drops the score lines, rim and veins because they turn to
mud at that size. The icon sprite is a **template partial**
(`templates/partials/icons.html`), not an asset, because same-document
`<use href="#id">` is universally supported and the cross-document form is not.
The `mark()` macro in `_macros.html` duplicates the mark geometry so it can be
inlined and themed. If the mark changes, update both.
## Deployment
`deploy/` holds a systemd unit template, an nginx vhost template, and
install/update scripts. Both templates are parameterised (`__PREFIX__`,
`__SITE_HOST__`, …) and substituted at install time, so nothing host-specific is
committed here. See `deploy/README.md`.
This repository is **public**. Keep deployment-specific hostnames, ports and
internal infrastructure detail out of it — those belong in whatever private
notes describe the machine.
## Not built yet
Nothing large. The tool loop in `services/generation.py` is what a new
capability plugs into — a tool is a `ToolDef` reaching `tools.resolve_tools()`
plus a permission and a capability flag, not a new code path. Its guidance is
the same shape: a `prompts.register_source` yielding one `Fragment` per row puts
it in the harness, on the admin page and in the preview without touching the
assembler, the save handler or a template. Custom HTTP tools, MCP servers, agent
chats and image generation are the four worked examples.
**Nothing executes on this machine, and that is the design.** Agent chats run
their commands on a host reached over SSH. A local sandbox was designed in
detail — bubblewrap, a masked data directory, a curated bind list — and dropped,
because every hard problem in it came from running on the machine that holds the
database and the encryption key: the service user cannot traverse `/home`,
granting it needs ACLs, `RLIMIT_NPROC` is counted per *uid* so a fork bomb
starves the server too, `--size` applies only to tmpfs so there is no disk quota,
and a bind list is a standing invitation to widen until the sandbox is
decoration. Over SSH, isolation is somebody's considered choice of host, using
tools far better at it than anything that could be built here — and it is the
only version that is honestly multi-user.
Two consequences worth stating plainly. **The security of an agent chat is the
security of the host behind its profile**, and nothing here can tell a throwaway
container from a production server. And there is no equivalent of the
`network: False` switch the local sandbox would have had, because the network
belongs to the far side — so an instruction injected through something the model
read can, in principle, be carried out from there.
**Local MCP is absent for the same reason.** Only remote servers over streamable
HTTP. Spawning `npx` would be a subprocess on this machine, which is the thing
that is deliberately not done.
**Unknown is not zero.** `Model.context_length` of 0 means nobody has said how
big the window is, which is different from "small". The context percentage is
omitted rather than computed, and automatic compaction never fires. Token counts
fall back to `services/tokens.py` -- four characters to a token -- and anything
derived from an estimate is shown with a `~`. Compaction *does* act on an
estimate, because a premature compaction costs one turn of answer quality rather
than data: the messages are kept.
**Compaction hides turns, it does not delete them.** `Chat.compact_summary` plus
`compacted_through_id` say how far it reached; the messages stay in the
transcript behind a `<details>` divider and simply stop being part of the
request. The summary is carried by a **user turn and an assistant turn**, not
one: a leading `assistant` breaks templates that require the first non-system
message to be `user`, and a lone leading `user` produces `user, user` whenever
the kept history starts on a user turn -- which it always does, because the
cutoff lands on a finished reply. `compacted_through_id` is a plain id, not a
foreign key, because `migrations.py` compiles only the column type and a
`REFERENCES` clause would exist on a fresh database and not on an upgraded one;
`compaction.cutoff_message` validates it on every read instead.
**Compare message timestamps through `compaction.moment()`.** SQLite does not
store the offset, so a row loaded from disk is naive while one still in the
session's identity map keeps its tzinfo. Comparing the two raises.
No OCR: a scanned PDF is stored with an explanatory `extraction_error` rather
than silently contributing nothing.