ecb52e9978
A server is a row with a URL; its tools are discovered by a button and cached, then offered beside the built-in ones. Written by hand rather than taken from the reference SDK, because that SDK's transport does its own connecting -- and the one thing that must not be bypassed is check_url on every hop. Owning the transport is the point; the framing beside it is the small part. Sessions are per call: initialize, initialized, the call, a best-effort DELETE. Caching one wants an owner, a TTL, eviction, a lock and a shutdown hook, and the server may expire it under all of that anyway -- ToolContext is a session-free snapshot precisely so nothing in a tool holds live state. A server's names and descriptions reach the model as instructions and are bounded before they do; what it returns is escaped preformatted text, never markdown. Tools are namespaced per server, so two servers exposing "search" do not collide and neither shadows a built-in. Also: a round's calls now run together under a semaphore, results indexed so each tool turn stays paired with its call, and generation.status names what is running -- a remote tool is latency-bound, and a silent pause is what a hang looks like. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
620 lines
35 KiB
Markdown
620 lines
35 KiB
Markdown
# 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.
|
||
|
||
**Stopping sets a flag the producer checks.** `generation.request_stop()`;
|
||
whatever arrived is kept and the message is marked `stopped`, which is distinct
|
||
from `error`. In-process, so single-worker only.
|
||
|
||
**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.
|