Files
LLeMbas/CLAUDE.md
T
Jaroslav Beneš 671e49cae8 Ask several questions on one card
One `ask_user` call can now carry several questions, and they come back in a
single submit. Asking one at a time cost a round trip and an interruption
each, and by the third you had forgotten the first.

Each question becomes an item with its own key; several items share a call
index, because they belong to one call and one tool turn has to answer them
all. Each answer is quoted beside the question it belongs to -- with four on
a card, a bare list would leave the model matching them up by position and
sometimes getting it wrong.

Options are radios rather than submit buttons, so picking one does not send
the form while two other questions are still blank. What you type beats what
you picked: someone who writes in the box after clicking an option meant the
writing.

`_questions_in` also reads the shapes a small model actually sends -- a bare
`question` string, a list of plain strings, one object where a list belonged.
Getting that wrong costs a whole round trip and shows a card saying nothing.

Two test fixes, both mine. `test_posting_a_message_stores_both_turns` raced
the background generation it started: against a connection that refuses
instantly the reply sometimes won, writing the error and marking the row
complete before the assertions could read it. And the generation registry is
module-global, so a test that started a reply left an entry -- and a Task
belonging to a closed event loop -- for the rest of the session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:01:46 +02:00

662 lines
37 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]" # `search` adds ddgs for DuckDuckGo
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 # 698 tests, ~40s
# 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/`.
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_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
audio.py transcribe, speak, voice discovery
library.py knowledge, notes, skills pages; memory CRUD
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
library/ documents, notes, memories, skills, FTS
mcp/ remote MCP servers: framing, transport, rows to tools
audio.py OpenAI-shaped /v1/audio/* client
fetch.py URL retrieval, HTML to text, the SSRF guard
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
custom_tools.py the admin-defined HTTP tool runner
tool_access.py who may be offered which admin-defined tool
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
settings_store.py runtime instance settings
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
assets/ SVG masters and PWA icons (generated)
deploy/ systemd unit, nginx vhost, install/update scripts
```
## Things that will bite you
**`render()`, not `TemplateResponse`.** `web/templating.py:render()` injects
`user`, `theme`, `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.
**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.
**Stream frames carry whole blocks, not deltas.** `render`, `reasoning`,
`metrics` and `status` all send the complete value each time, and every one of
them is swapped with `innerHTML`. `reasoning` used `beforeend` and so repeated
everything already shown on every frame. That is what makes reattaching mid-reply
work: a follower arriving late has no earlier fragments to append to. It also
means Markdown is re-rendered whole, which is required anyway -- a list or code
fence is only correct once its context exists.
**Two frames must be able to blank themselves, and the rest must not.**
`reasoning`, `tools` and `render` are only sent when they have something in
them, so a frame can never wipe what is on screen. `metrics`, `status` and
`ask` 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.
**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: 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.
**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.
**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` for the toast; `unread_notified` stops the same arrival being
announced every tick. Re-rendering the whole sidebar instead would reset the
folder open/closed state every 10 seconds.
**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.
**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.
**System prompts are precedence, not concatenation.** chat > 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.
**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.
**The tool loop is inside one generation.** `services/generation.py:_run()` runs
up to `tools_service.MAX_ROUNDS` 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`.
**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,
bodyless, and it *raises* on any content type that is not HTML or text, which is
every JSON API there is. The duplication is deliberate; bending a page fetcher
into a general HTTP client is not. A secret is dropped when a hop leaves the
origin it was issued for.
**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.
**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.
## 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
Agentic execution (local subprocess and SSH connection profiles), image
generation. A nav entry marks where each one goes. The tool loop in
`services/generation.py` is what they plug into — a new 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 and MCP servers are built and are the worked example of both.
**Local MCP is deliberately absent.** Only remote servers over streamable HTTP.
Spawning `npx` is the agentic-execution feature, which wants a confirmation model
before it does anything; a URL is a different act with a different blast radius.
**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.