e7294e5b20
Five passes produced a working document that said on its first line it was temporary. This is it being spent rather than abandoned. CLAUDE.md gains eleven paragraphs, each a thing that had shipped looking correct: a handler bound to a shared variable rather than its own socket, a script the base template already loads being loaded again, a control that stays clickable while it awaits permission, "is this name taken?" asked about visibility instead of ownership, root running a file the service account can write, sourcing anything under $PREFIX, a read-only command name that is not a read-only command, 0.0.0.0 being this machine, a folder that is not a label, a file that is not deleted by the row that named it, and a measuring harness that measured an unstyled page and reported a dramatic finding that was entirely an artefact. PLAN.md carries the seven things the audit found and deliberately did not fix, each with why: they change what something does rather than fix what it claims to do, which is not an audit's job. deploy/README.md says why root runs a copy, and that a host installed before this keeps the old wiring until the installer is re-run -- the button cannot fix it, because the button runs the old unit. docs/notes/audit-0.9.md is deleted, having been all three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1925 lines
120 KiB
Markdown
1925 lines
120 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,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 # 2283 tests, ~2.5min
|
||
pytest -m "not slow" # skips the 365 that stand up a real
|
||
# server, shell, PTY or git repository
|
||
# 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, quotas -- list plus detail,
|
||
with "what can this person actually do?" answered
|
||
sharing.py the share panel: search, and one grant per request
|
||
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_branding.py the name, the logo, the wording and the themes
|
||
admin_extraction.py what a file may cost, and what finds it afterwards
|
||
admin_updates.py what is running, what is available, and a button that
|
||
writes a file rather than doing the work
|
||
branding.py /branding.css and the assets behind it, both unauthenticated
|
||
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
|
||
usage.py what an account spent this month, and whether it may spend
|
||
more -- written even when the reply failed
|
||
updates.py two channels (release tags, or the branch tip), what is
|
||
running, and the request file the helper watches for
|
||
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
|
||
branding.py whose instance this is: the name, the artwork, the
|
||
wording and the themes, cached once per process
|
||
llm/embeddings.py /v1/embeddings, batched, normalised on the way out
|
||
library/chunks.py splitting a record, and packing a vector
|
||
library/indexing.py keeping the semantic index current, and rebuilding it
|
||
library/retrieval.py keywords and meaning, fused
|
||
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, the
|
||
opt-in update helper, and an LXC bootstrap
|
||
Dockerfile one stage; no secret and no data baked in
|
||
docker-compose.yml loopback only, one replica, TLS proxy expected in front
|
||
docs/notes/ the rest of "Things that will bite you", by topic
|
||
```
|
||
|
||
## Things that will bite you
|
||
|
||
**Several 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/permissions-and-sharing.md` -- the union rule shown rather than
|
||
thrown away, where read and write are split and why not everywhere, quotas as
|
||
the union rule applied to numbers (and why zero wins), where each is enforced,
|
||
and the three deletes that have to forget a share because nothing cascades.
|
||
- `docs/notes/search-and-extraction.md` -- extraction limits as a snapshot, why
|
||
reciprocal rank fusion and not a weight, how a record scores as its best chunk,
|
||
why vectors from two models never meet, and the session event that notices a
|
||
library record changing.
|
||
- `docs/notes/branding.md` -- the branding snapshot and why it is a Jinja global,
|
||
where the instance name went and how an upgrade keeps it, how a custom theme
|
||
inherits through `data-base`, and why `/branding.css` is a route.
|
||
- `docs/notes/release-checklist.md` -- the manual pass before a release. Not a
|
||
corner of the code but the opposite: the things a machine cannot test, which
|
||
is everything needing a real endpoint, a real machine, real hardware or a real
|
||
pair of eyes. Read it when cutting a release, not while writing code.
|
||
- `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.
|
||
|
||
**Library search is keyword *and* meaning, and neither is a mode.** `fts.search_ids`
|
||
was already the one seam; `library/retrieval.search` sits in front of it and fuses
|
||
its ranking with a vector one by reciprocal rank fusion -- ranks, not scores,
|
||
because bm25 and cosine are not comparable and normalising them means picking a
|
||
constant nobody can tune. With no embedding model configured it returns exactly
|
||
what FTS returned, in that order, and no chunk row is ever written. See
|
||
`docs/notes/search-and-extraction.md`.
|
||
|
||
**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.
|
||
|
||
**A handler bound to a shared variable is bound to the wrong thing.**
|
||
`terminal.js`'s `socket.onclose` set the module-level `socket` to null, and
|
||
`close()` *queues* its event rather than firing it -- so on either reconnect
|
||
path the old socket's close arrived **after** a new one had been assigned and
|
||
nulled the live one. Output kept arriving, because `onmessage` is bound to the
|
||
object; every send gates on the variable, so each keystroke was dropped and no
|
||
resize was ever sent again, on a panel that looked perfectly healthy. It then
|
||
said "Disconnected" about a shell that had just reconnected, because
|
||
`closedOnPurpose` had already been cleared for the new connection. Each handler
|
||
captures its own socket in a local and returns early when `socket !== opened`.
|
||
|
||
**A script the base template loads must not be loaded again by a page.**
|
||
`messages/index.html` listed `composer.js` and `commands.js` in its `scripts`
|
||
block and `base.html` already loads both, so that screen ran each **twice**.
|
||
Each is an IIFE with its own state, and `stopPropagation()` does not stop a
|
||
second listener already bound to the same node: two composer menus stacked, one
|
||
Enter running a command twice, and `Alt+B`/`Alt+E`/`Alt+T`/`Alt+I` toggling
|
||
their panel twice, which is to say doing nothing at all. Nothing about that
|
||
looks like a double load. `tests/test_ui_js.py` sweeps every template against
|
||
`base.html`'s list.
|
||
|
||
**Leave the state a control was in before you await.** `audio.js` moved the
|
||
microphone out of `idle` only once `getUserMedia` had resolved, and the
|
||
browser's permission prompt sits in between -- so every click while it was up
|
||
opened another stream and another recorder, of which only the last was ever
|
||
stopped. The browser's recording indicator then stayed on until the tab closed.
|
||
`working` already existed and was already styled; it was the state the button
|
||
was missing.
|
||
|
||
**"Is this name taken?" is a question about ownership, not visibility.**
|
||
`skills.create` checked with `by_name`, which is scoped through
|
||
`sharing.visible_to` -- owned *or* shared -- against a `(owner_id, name)`
|
||
constraint. So a skill somebody shared with you took that name out of your
|
||
library, and refused yours with "edit it instead", naming a row you cannot edit
|
||
because sharing grants reading only. `by_name` is right for `skill_get` and
|
||
`skill_edit`, where a shared skill is what the model is reaching for;
|
||
`owned_by_name` is the uniqueness question. `documents.create_base` next door
|
||
had always asked it correctly.
|
||
|
||
**Root must not run a file the service account can write.** The update helper's
|
||
unit pointed `ExecStart` inside the checkout, which `install.sh` clones *as* the
|
||
service user -- so anything able to write as that account could rewrite the
|
||
script, create the request file it also owns, and be root. Worse, no compromise
|
||
was needed: an update pulls new code as that user and root then runs whatever it
|
||
fetched, so control of the branch was control of root. `install.sh` puts a
|
||
root-owned copy at `/usr/local/lib/lembas/update.sh`. The cost -- changing
|
||
`update.sh` needs the installer re-run -- is the right one, and `update.sh`
|
||
detects both the stale copy and the old wiring. **The test that existed asserted
|
||
the vulnerable `ExecStart` line verbatim**, and had pinned it in place.
|
||
|
||
**Nothing under `$PREFIX` may be sourced.** `update.sh` did `. "$PREFIX/.deploy-env"`.
|
||
The file is root-owned, having been written with `sudo tee` -- but `$PREFIX` is
|
||
the service account's own directory at mode 755, and write permission on a
|
||
*directory* is all it takes to unlink a file and put another there. The two
|
||
values it wants are parsed with patterns now. The test asserts nothing under
|
||
`$PREFIX` is sourced rather than naming `.deploy-env`, because the next file
|
||
read from there would have the same problem.
|
||
|
||
**A read-only *name* is not a read-only *command*.** `policy._UNSAFE` stops a
|
||
line being composed out of two commands and says nothing about one command that
|
||
composes another itself -- and the obvious read-only tools do: `find -exec` runs
|
||
a program, `-fprintf` writes a file, `-delete` removes one, `rg --pre` runs a
|
||
preprocessor per file. None needs a character `_UNSAFE` refuses. `find *` was on
|
||
`SAFE_COMMANDS`, which is what a **subagent** is pinned to in every mode,
|
||
unattended, with no card anybody could approve. `policy._ACTION` refuses those
|
||
flags in `subject()` rather than the list being trimmed: a glob cannot say "and
|
||
no dangerous flags", and "this one looks read-only" is exactly the reasoning
|
||
that put `find` there.
|
||
|
||
**`0.0.0.0` is this machine.** `hosts._literal` answered from `is_loopback`, and
|
||
the unspecified addresses are not loopback -- so it returned a *decided* False,
|
||
which also short-circuited `resolves_here` and skipped the DNS half. Connecting
|
||
to `0.0.0.0` or `::` goes to loopback on Linux, so an SSH profile pointed there
|
||
reached this host's own sshd with the guard still reading "off".
|
||
|
||
**A folder is not a label.** `effective_system_prompt` walks up from the chat
|
||
through its folder and that folder's parents, so a chat filed in somebody else's
|
||
folder takes their system prompt. `_new_chat` resolved the folder, discarded it
|
||
when it was not the caller's -- and then stored the raw `folder_id` anyway, so
|
||
the ownership check governed which *seeds* were applied and not where the chat
|
||
went.
|
||
|
||
**A file is not deleted by the row that named it.** `db.delete(chat)` cascades
|
||
to messages and attachment *rows* and leaves every file on disk, with nothing
|
||
that will ever look at them again -- `sweep_orphans` only considers uploads that
|
||
were never attached. `chat_service.delete_chats` is the one way to delete a chat
|
||
and unlinks first, while the rows still say which files they are. Underneath it,
|
||
`files.claim` bound `message_id` and never `chat_id`, so anything picked on the
|
||
*new-chat* screen kept an empty `chat_id` for life -- and six readers filter on
|
||
that column, so those files were unnamed in the prompt, unopenable in the canvas
|
||
and invisible to the one caller the cleanup had.
|
||
|
||
**Drive the JavaScript, and check the harness before believing it.** Three of
|
||
the four bugs the testing pass found came from a Node DOM stub. The fourth
|
||
lesson is about the instrument: a headless-Chromium harness that renders a page
|
||
through `TestClient` and rewrites its asset URLs reported **all five tab panels
|
||
visible at once** -- because the templates emit absolute `http://testserver/static/…`
|
||
URLs via `url_for`, not `/static/…`, so the rewrite matched nothing and it was
|
||
measuring an unstyled document. A dramatic finding that was entirely an
|
||
artefact. Any such harness must fail loudly on an unrewritten asset URL.
|
||
|
||
## Releasing, and the changelog
|
||
|
||
**Every version bump gets a `CHANGELOG.md` entry, in the same commit.** Not
|
||
afterwards and not at release time: the reason a change was made is known while
|
||
it is being made and is gone a week later, and a changelog assembled from commit
|
||
subjects at the end is a list of things nobody can act on.
|
||
|
||
Four rules, and the last is the one that earns the file its place:
|
||
|
||
- **Newest first, one section per version**, headed by the version alone.
|
||
`## Unreleased` sits at the top between bumps.
|
||
- **Written for somebody using or running this**, not for somebody reading the
|
||
diff. "Deleting a group left every share naming it behind" is an entry; "call
|
||
forget_principal in delete_group" is not.
|
||
- **The version is `__version__` and nothing else.** `pyproject.toml` reads it
|
||
from there, the service worker cache is keyed on it, and the footer shows it.
|
||
- **A fix to something that *looked* like it worked gets a line, always.** Those
|
||
are the entries somebody stops working around a bug because of, and they are
|
||
invisible from the outside: nobody reports a control that silently does
|
||
nothing, they just stop using it.
|
||
|
||
A release is a **signed annotated tag** whose message is that version's entry --
|
||
`git tag -s v1.0.0 -m "$(…)"`. That is not decoration: `/admin/updates` reads
|
||
release notes out of the tag object with `git for-each-ref`, so the tag message
|
||
*is* what an administrator sees on the update page. `services/updates.py` strips
|
||
the signature block, PGP and SSH both.
|
||
|
||
The first tagged release is **1.0.0**. Everything before it shipped as a running
|
||
deployment, which is why `CHANGELOG.md` covers versions that were never released
|
||
-- 1.0.0's notes are assembled from them.
|
||
|
||
## 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. Every template is parameterised (`__PREFIX__`,
|
||
`__SITE_HOST__`, …) and substituted at install time, so nothing host-specific is
|
||
committed here. See `deploy/README.md`.
|
||
|
||
**Updates follow a *channel*, not a commit.** `stable` is the newest `vX.Y.Z`
|
||
tag, `edge` is the branch tip; a branch tip is not a release, and following one
|
||
means deploying whatever was pushed five minutes ago. Read with git plumbing and
|
||
never a forge API: a token on the deployment host to answer a read-only question
|
||
is a bad trade, it ties this to one forge, and the Gitea API this was checked
|
||
against 500s on exactly that endpoint. Release notes travel inside **annotated**
|
||
tags, which `git for-each-ref` reads with no API anywhere. A tag with a suffix is
|
||
not a release: git's version sort puts `v1.1.0-rc1` *above* `v1.1.0`, so
|
||
accepting one would step a stable host onto a candidate on the strength of a
|
||
hyphen.
|
||
|
||
**The update button cannot do the work, and that is the design.** The service
|
||
runs unprivileged and cannot restart itself; a web application that *can* is one
|
||
whose worst day is much worse. So `/admin/updates` writes a file and an **opt-in**
|
||
systemd `.path` unit runs `deploy/update.sh` as root. Three properties hold it
|
||
together: the request file carries nothing that reaches a command line (the
|
||
channel is baked into the unit at install time, so the button is always
|
||
"deploy the channel this host was configured with"), it is off unless
|
||
`INSTALL_UPDATE_HELPER=1`, and without it the page says so and prints the manual
|
||
command. `services/updates.py` runs git with a fixed argv and `shell=False` --
|
||
which is not the "nothing executes on this machine" rule being bent, since that
|
||
rule is about a *model's* commands and this is an administrator pressing a
|
||
button.
|
||
|
||
**Docker is one stage and bakes nothing.** No secret key (one in an image is one
|
||
every copy shares, and rotating it makes stored API keys unreadable), no data,
|
||
and no `.git` -- so `/admin/updates` inside a container correctly says it was not
|
||
installed from a checkout. `docker-compose.yml` publishes on loopback and expects
|
||
a TLS proxy: the service worker and the microphone both require HTTPS or
|
||
localhost, so plain http on a LAN address is a constraint rather than a
|
||
preference.
|
||
|
||
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.
|