Commit Graph

10 Commits

Author SHA1 Message Date
Jaroslav Beneš 78e5717f77 An instance that can be somebody else's
A name, a tagline, a logo, a favicon and the launcher icons derived from it; the
Middle-earth strings as data; themes as token sets; and a stylesheet for what
none of that reaches. All four are on one page, in one settings group.

The snapshot is a Jinja global over a process-level cache, because render() has
no session and four render paths never reach it at all -- the sign-in page, the
error pages, the offline page and the SSE fragments. A context value would have
had to be threaded through every one and would still have missed those. It being
a global is also what lets mark() branch on an uploaded logo without any of its
six call sites learning about branding; the macro that renders the sidebar link
is called brandlink now, because a macro imported as `brand` shadows the global
for the whole template and took out every page at once.

Defaults in code and overrides in the database, as the prompt fragments do, with
one difference stated in the module: an empty fragment means off, an empty
flavour string means the shipped wording. And blanked rather than dropped --
settings_store.update merges, so an omitted key leaves what was stored last time
and "I typed the default back in" would store something different from "I changed
nothing".

A custom theme sets a handful of tokens and inherits the rest, and the
inheritance is a CSS fact: tokens.css matches [data-base="shire"] as well as
[data-theme="shire"], so a custom light theme lands on parchment rather than four
light colours on near-black. Values are validated on read rather than on save,
because a theme written straight into the settings table still has to produce a
stylesheet that parses -- a `}` in a value ends the rule and silently breaks
every rule after it. The soft variants are derived from the accent, or a changed
accent leaves focus rings in the old hue and reads as half-working.

/branding.css is a route, not an inline block: an external stylesheet has no HTML
context to escape from. The link carries a content hash, so a save is not left to
the browser's cache, and it is deliberately outside the service worker's precache
list, which is versioned by the release.

The instance name moved off /admin/general rather than being duplicated there.
An upgrade keeps it: the general row is read as a seed exactly while the branding
row has never mentioned the name, which is `key in row` and not `row[key] is
truthy` -- the two read alike would resurrect the old name underneath a cleared
one.

The theme list stops being a hard-coded pair in five places. Every failure mode
in that area is silent, so it is driven under a DOM stub as well as tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:42:25 +02:00
Jaroslav Beneš 9ddc0a2103 Something can happen because time passed, and land somewhere worth reading
Nothing in LLeMbas ever happened on its own. Every reply was downstream of
somebody pressing Send, and the one exception -- jobs.wake, waking a chat when a
background job finishes -- was downstream of a command they had run. PLAN.md
never listed scheduling as unbuilt because services/chat.py:618 had recorded it
as a decision: "a scheduler is a whole new concern for a single-worker
application". This is that concern, taken on deliberately, plus the two places
its output goes.

Reports first, because it is useful with no scheduling at all. A report is not a
Chat with one Message in it: it has no turns and no reply, it is read top to
bottom, and it must be writable with no chat behind it -- being the fallback for
a run whose own chat has gone. As a Chat it would need a sidebar row per daily
report, a title that regenerates itself, a composer to suppress and a bubble with
a rewind button around something that is not a turn. The section's character is
enforced by absence: nothing under reports/ includes the composer or renders
chat/_message.html, so there is no sse-connect anywhere and nothing on those
pages *can* start a generation. The test reads that off the OpenAPI schema, not
by walking app.routes -- this FastAPI keeps an included router wrapped rather
than flattening it, so the walk finds nothing and the assertion passes for the
wrong reason.

rule.py is pure, total, and was finished before anything called it. No session,
no wall clock, nothing that raises: validate clamps what it recognises, drops
what it does not, and answers {} for prose -- at which point the caller shows the
manual form. It had to be that way because the compile step's output is model
output that becomes a *timer*, which is the sharpest case of hard rule 6 here.
The invariant, pinned: anything validate accepts has a computable next
occurrence. A schedule that can never fire looks exactly like a working one on
every screen it appears on.

Wall-clock and elapsed time are kept apart because they mean different things.
at.times are wall-clock in the owner's zone, so 15:00 stays 15:00 across a
daylight-saving change -- that is what "every Monday at 3PM" means. every is
elapsed real time, so six hours stays six hours across a 23- or 25-hour day --
that is what a timer means. Conflating them gets one of the two wrong twice a
year. A time inside the spring-forward gap fires at the first minute that exists;
left to zoneinfo's own resolution it lands an hour away wearing a wall-clock time
that did not happen, and a daily 02:30 report vanishing once a year on a machine
nobody watches is the failure this file is arranged around.

The ticker claims and commits *before* it fires. The other order is a hot loop: a
firing that raises is retried every tick for ever against whatever it was that
failed, and the only symptom is load. Its blanket except is copied from the
terminal reaper for a sharper reason -- a ticker that dies on one bad row stops
every schedule on the instance and says nothing at all. No request fails, no
reply errors, no dot appears. The reports simply stop.

Three rules that look like bugs from outside: a firing arriving while the chat is
still answering queues rather than starting a second reply, and past max_queued
is skipped with the reason on the row; Run now does not advance next_fire_at, or
testing a schedule silently consumes the run it was testing; resuming recomputes
from now, or a schedule paused for a month fires the instant it comes back, once
per occurrence it missed. Catching up lives in the sweep and not in a startup
hook, because a suspended host and a long stall reproduce "its time passed while
nothing was running" with no restart to hang one on.

services/wake.py is the lock discipline extracted rather than copied. A finished
job and a due schedule are the same problem, and both depend on there being no
await between the running_for check and the writes; two lock dictionaries for one
invariant is how one of them drifts. jobs.wake is now a caller that supplies
wording, and _completion_text stayed exactly where it was because tool.background
quotes its opening sentence.

A scheduled run has no reader, so ask_user is withdrawn from resolve_tools rather
than merely discouraged in core.unattended -- a rule living only in a system
message is one a page the model just read can argue with, and a parked question
holds the reply for the whole approval_timeout with nobody to answer it. For the
same reason a task chat may not be an agent chat in v1: Manual, Edit and Plan all
stop to ask on RISK_EXECUTE, so the only two outcomes would be unattended
execution and a reply that stalls. That deserves its own pass.

Messages is bounded in the request and unbounded on disk. Only the latest chunk
is sent; everything else stays exactly where it was written. 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. should_compact refuses this kind for the
matching reason: two mechanisms narrowing one transcript is how a summary ends up
summarising a summary. The history route is the mirror of thread_tail and keeps
its four properties; the fifth is its own, that prepending moves the scroll
position, so app.js records scrollHeight before the swap and adds the difference
back after.

An empty Chat.kind meant "both sides of the switch" and had been read as "no
filter" since there were only two of them. The sidebar passes "" precisely when
agent chats are switched off -- so the moment a third kind existed, every task
chat and every Messages conversation appeared in somebody's ordinary chat list,
on exactly the instances whose owners would never think to look. KINDS stays the
two-sided fork, because set_sidebar_kind validates against it and a third entry
there makes the tree filterable to a side with no button to leave it; ALL_KINDS
is what a row may be. Both narrowings are pinned, because they are two
implementations of one rule and only one of them is SQL.

Per-user timezone had to exist for any of this: harness.py:179 was telling every
reader the *server's* idea of the date, which is survivable while the answer is
prose and stops being survivable the moment somebody says "every Monday at 3" and
something has to work out when that is.

Three things were caught by a test being wrong rather than by the code being
wrong. The task-chat "no composer" assertions were passing against a page
rendering its no-models-configured branch. A permission test asserted the same
thing twice because the administrator bypasses every permission. And every
Messages test passed with default_model never called, because none of them
configured a model -- so the pair it returns was being assigned straight to
model_id, and SQLite refuses a tuple in a String column. The fixtures now say why
they exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:31:36 +02:00
Jaroslav Beneš 20040f53a8 Three things that said one thing and did another
All three shipped in the last two commits, and all three are the same kind of
mistake: an interface that looks right and is not.

The folder settings page could not be scrolled. `.main` is a flex column with
`min-height: 0`, so a `.page` dropped straight into it overflows the viewport
with nothing to scroll -- Save and Back end up below the bottom of the window,
reachable by zooming out or by dragging the prompt textarea up out of the way.
Every other page of this shape already wraps its content in `.admin-scroll`;
this one did not. The two class names that scroll are one rule in admin.css
precisely so this is a wrapper somebody forgot rather than a value they got
wrong, and now it is noted.

The project directory was a text box, on the one screen that asks for an
absolute path on another machine. It is the same button-and-hidden-field the
new-chat screen uses, wired by `[data-dir-field]` in ui.js -- scoped to that
attribute so this 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. With no
connection chosen it says so rather than opening onto nothing, and Clear is
always there, because browsing somewhere and changing your mind before saving
needs a way back to "no opinion" as much as clearing a saved one does.

And "New chat" did not follow the Chat/Agent switch. The button sits above the
scroll area rather than inside the tree the switch swaps, so it went on saying
"New chat" over a list of agent chats. It moves to its own partial and arrives
out of band, the way the chat title already does. Renaming it to something
neutral would have hidden the bug rather than fixed it, and would have cost the
`?kind=agent` preselection the label is there to explain.

The tests that existed asserted a page load, which re-renders the button
anyway -- which is exactly why nobody saw it. The new ones assert the fragment.
The directory field was driven under a DOM stub first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:40:01 +02:00
Jaroslav Beneš 5766446b84 Files, open beside the conversation
A third side panel, built the way the terminal is and filled the way the
inspector is: tabs holding open files. Project files over SFTP in an agent
chat; notes, skills, knowledge documents, this chat's text attachments and its
own scratch document everywhere. Read with pygments, edited in a plain
textarea, saved with a conflict check.

A bug found on the way in, and the reason this needed its own read path.
`ssh.read_file` ends in `clean_output`, which strips ANSI escapes and decodes
with errors="replace" -- right for the output of a command, and fatal for an
editor: open a file containing an escape byte, press Save, and you have
silently rewritten it with the escapes gone and every undecodable byte replaced
by U+FFFD. `read_text`/`write_text` decode strictly, report binary rather than
mangling it, carry an mtime:size token for a file that moved underneath, and
refuse an oversize write rather than truncating -- `write_file` truncates
because a model is told how many bytes it wrote, and somebody pressing Save is
not. The model-facing pair is untouched: what it returns is a contract a model
has been shown. A truncated read opens read-only for the mirror-image reason.

Six sources go through one dispatch table, for the reason tool_labels.py is a
table: six independently written permission checks is how one ends up written
slightly differently, and that failure looks like editing somebody else's note.

A save on a project file bypasses agent/policy.py, which makes it the fourth
documented exception to "the modes do not govern the keyboard" and the first
that writes. Same argument as the terminal panel -- whoever owns the credential
could write the file with scp -- but the consequence is larger and is now said
out loud rather than left to be inferred.

The model opens tabs from the file tools it was already calling, so no new
schema and no tokens. It never brings one to the front: an agent reads forty
files in a long reply, and taking the screen each time would drag somebody
through all of them and lose any edit in progress. Only the strip is streamed,
guarded on truthiness so the frame can never blank itself -- an empty one would
close every open tab, the approval card you could press twice with the sign
reversed. Both halves are settled on the server, which is why canvas.js needs
no guard against a swap at all.

No vendored editor. CodeMirror 6 needs a bundler, which is hard rule 1;
CodeMirror 5 would be a larger payload than xterm on every page, and xterm is
the one heavy dependency precisely because it loads only where it can be used.
So: server-rendered highlighting for reading, a textarea for writing, and the
panel says there is no colour while you type rather than pretending.

Also here: a scratch document per chat, with `scratch_write` at RISK_READ on
plan_update's argument, and a test pinning the three numbers that decide a
panel's width -- LAYOUT_BOUNDS drops an unknown variable silently, so a panel
missing from it has a drag handle that works and forgets.

Driven under a DOM stub and against the running application.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:21:03 +02:00
Jaroslav Beneš d7a614c96b Two kinds of work, and a switch to say which
The sidebar rendered an agent chat and an ordinary one identically, in one
list, so hours of machine work sat among a morning's questions. A switch
below the pinned models now shows one kind at a time, stored on the account
so it follows the reader to another browser.

Three things it does that are not the obvious version:

The switch is inside the fragment it swaps. Targeting only the tree would
leave the two buttons showing the side you had just left -- the request
works and the interface says otherwise, which is the failure this codebase
keeps cataloguing.

A folder can be emptied by the filter, or have been empty all along, and
only the first is a reason to hide it. `shown_in` is that line: a folder
somebody made a moment ago and has not filled yet stays on both sides, or
it can never be found again, let alone filed into.

With agent chats switched off there is no switch, and the sidebar goes back
to showing everything rather than to one side of a fork nobody can move.
An administrator turning the feature off would otherwise strand whoever
last left the switch on Agents in an empty sidebar with no way out.

The control reuses the composer's `.segmented`, which is the same choice in
a different place, and the verb goes on the input rather than the wrapper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 08:19:17 +02:00
Jaroslav Beneš 6bbd398707 The terminal learns where one command ends, and can be dragged wider
"The last command and its output" was not something the panel could honestly
offer. sendToChat took the last forty rows of the screen buffer, hard-wrapped at
the terminal's width with no way to tell a wrap from a newline -- its own comment
said so. So bash and zsh are given the OSC 133 markers VS Code and WezTerm use,
and Copy, Send and an Auto toggle are built on those.

The integration is written by the PTY command string itself, with printf. sshd
runs that string through $SHELL -c, so it can case on the shell's own name and
needs no probe, no second channel and no writable home. Passing it through the
environment does not work -- every distribution ships AcceptEnv LANG LC_*, so
anything else is dropped silently -- and feeding `source ...` in as keystrokes
races a slow .zshrc, echoes into the scrollback and lands in shell history.

Nothing needs hiding, which is the point of choosing it: the setup runs before
the shell exists and never writes to the PTY's input side, so there is nothing
to echo and no fan-out gate to build.

Two things were wrong in the first version and both were found by running it
against real shells rather than the fake one. bash: the DEBUG trap fires before
every simple command *including each one inside PROMPT_COMMAND*, so $? read from
there is whatever ran a moment ago -- every command reported success. The status
is captured in the trap now, which also removes the two-entry PROMPT_COMMAND
dance entirely. zsh: $ZDOTDIR is already ours by the time .zshenv runs, so the
shims were sourcing themselves and none of the user's configuration loaded; the
original is passed on the exec line.

Parsing is server-side. The `behind` path resets the terminal and replays a
truncated scrollback, so a client parser routinely sees a finish with no start;
two tabs share one shell and can disagree; and what comes out of this ends up
inside a prompt, so deriving it here leaves nothing to disbelieve. The bytes are
fanned out unchanged -- xterm consumes an OSC it has no handler for.

Output is bounded head and tail, 48KB and 16KB: a build that fails ten megabytes
in has the invocation at the top and the error at the bottom. Carriage returns
collapse to the last state of each line, which is the difference between a
usable prompt and two megabytes of spinner. The fence is sized to its content,
because output containing three backticks would otherwise break out and read as
prose.

Any shell that is not bash or zsh starts exactly as it did before. The buttons
then scrape the screen and say so, and Auto is disabled rather than degraded:
forty arbitrary lines on every message is worse than nothing.

Also a generic [data-resize] handle, keyboard included, persisted the way the
theme is. The inspector and sidebar can have it whenever they want it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 17:28:38 +02:00
Jaroslav Beneš 436226370a PWA, one send/stop button, audio in and out, web search as a tool
Four pieces of work.

**Installable.** A manifest carrying the instance name, PWA icons rasterised
from the existing mark at design time, a service worker and a themed offline
page. The worker caches the shell only and bails out on /api/, /auth/, /admin/
and anything accepting text/event-stream -- passing a reply stream through a
worker turns it into one delivery at the end, or nothing. It is served from
GET /sw.js rather than the static mount because a worker's scope is the path it
came from.

**Send and Stop are one button.** They were two, and the hidden one was never
hidden: `.btn` is display: inline-flex, which outranks the browser's own
`[hidden] { display: none }`, so Stop sat permanently beside Send. app.css now
forces the attribute to win -- every control toggled with `hidden` depended on
that -- and the composer renders one button carrying both icons, with ui.js
flipping data-composer-action and the type with it.

**Audio.** Speech to text and text to speech against any OpenAI-shaped
/v1/audio/* endpoint: dictate into the composer, have a reply read out.
Instance settings in Admin, per-reader overrides in Settings, with the voice
list discovered from the server where it offers one. Recorded audio is capped
and never written to disk -- it is not an attachment, it has no owner, and
nothing would ever sweep it.

**Web search, as a tool.** This is the tool loop PLAN.md described as the real
work: one reply is now a bounded sequence of requests rather than one. The model
asks, the tool runs, the result goes back and it is asked again, up to three
rounds. Providers are DuckDuckGo (no setup), SearXNG and Firecrawl.

Two decisions worth stating. Tools are only offered to models flagged `tools`,
because an endpoint without support rejects the whole request rather than
ignoring the array -- the same reason images only reach models flagged
`vision`. And tool results are not replayed as context on the next turn, for the
same reasons reasoning is not: the answer already contains what the model made
of them, and replaying stale results into every later request wastes the window
and reliably sends a small model into a search loop. The sources stay visible in
the transcript instead.

Search results are untrusted third-party text and are treated as such: escaped,
and only http/https URLs rendered as links.

338 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:56:50 +02:00
Jaroslav Beneš 1d3f6c450b Users, groups, permissions, model settings and reasoning display
Four features, plus the schema machinery they needed.

**Schema sync.** The first live instance had data in it, and create_all
only creates missing *tables* -- a new column silently never appeared.
db/migrations.py now diffs the declared models against the database and
ALTER TABLE ... ADD COLUMN for what is missing, deriving a backfill
default from the column type (SQLite refuses a NOT NULL column without
one, and a Python-side `default=dict` cannot be expressed in DDL).
Verified against a copy of the live database: eight changes applied, all
rows preserved, second run a no-op. Renames, drops and retypes are still
manual and say so.

**Permissions.** A flat set of named booleans: an instance baseline
widened by each group the user belongs to. A group grants and never
denies -- with denies, "why can this user not do X" cannot be answered
without simulating every group. Admins bypass entirely, because an admin
can grant it back to themselves in two clicks and pretending otherwise
is theatre. Model *access* is separate: public, or granted to groups.
The picker is not the boundary -- switching a chat to a model you cannot
reach is a 403.

**Model settings.** Ordering, pinned-first, an instance default and a
per-user default, display names, descriptions, capability flags, and
uploaded images. Images are stored and served locally rather than by
URL: a remote URL makes every page render a request to a third party.
Uploads are validated by magic number, not the declared content type,
and stored under a random name. Models with no image get a generated
initial whose hue is derived from the model id, so it is stable.

**Reasoning display.** Streams into its own collapsible block above the
answer, labelled "Thought for 14 seconds", collapsed once finished, and
never replayed as context on the next turn. Two sources: the
reasoning_content delta field, and <think> tags inline in content -- the
latter needs a streaming splitter because the tags arrive split across
chunks. Models emitting no reasoning show nothing, via a :has() rule
rather than JavaScript. Verified against qwen35-9b on llama-swap: 694
reasoning events, 52 answer tokens, cleanly separated.

Two bugs found and fixed while testing:

- A bare `Mapped[list]` relationship is treated by SQLAlchemy as a scalar
  and returns None instead of []. It needs the element type.
- FastAPI substitutes the default for an empty form value, so with
  `x: str | None = Form(None)` a submitted `x=` is indistinguishable from
  an absent field. That silently broke clearing a system prompt or a
  temperature. update_chat now reads the raw form and checks key presence.

143 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:49:32 +02:00
Jaroslav Beneš 9179461bfe Add registration toggle and password change; genericise deploy
Two things the running instance needed.

**Registration toggle.** Admin -> General, backed by a new settings table
group rather than the environment. LEMBAS_ALLOW_SIGNUP now seeds only the
initial value: once an administrator saves the setting, the stored value
wins. The alternative -- environment always winning -- means a toggle in
the UI silently reverts on the next restart, which is worse than not
offering one. Closing registration also removes the "Create one" link
from the sign-in page, so the link never leads somewhere that refuses.

**Password change**, on the user settings page. Changing a password
revokes every other session and immediately re-issues a cookie for the
current one: if the reason for the change is that somebody else knows
the password, leaving their session alive defeats the point, but signing
the user out of the tab they are standing in is merely rude.

**deploy/ is now host-agnostic.** This repository is public, so the unit
and vhost became templates with __PREFIX__ / __SITE_HOST__ / __APP_PORT__
substituted at install time, and every path, hostname and port moved to
environment variables. REPO_URL defaults to the checkout's own origin so
a fork deploys itself. Machine-specific values belong in private notes,
not here -- CLAUDE.md now says so.

83 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:14:33 +02:00
Jaroslav Beneš 0f44e8d24c Working chat: auth, connections, streaming, folders
LLeMbas now runs end to end. Register, add an OpenAI-compatible
connection, and hold a real streaming conversation organised into
folders. Verified against the local llama-swap instance.

Streaming is the one genuinely tricky part. Sending a message returns
two HTML fragments -- the user bubble and an empty assistant bubble
carrying an sse-connect -- and that attribute is the ONLY thing that
starts a generation. Rendering an incomplete assistant message as a
streaming shell falls out of the same template, which means loading a
page whose last reply never finished simply picks it up again.

Details worth knowing about, each commented where it matters:

- SSE payloads are split across several data: lines. A raw newline in
  one data: line truncates the event, which shows up the first time a
  model emits a code block.
- Markdown is rendered server-side by the same helper for both the page
  and the final streamed frame, so the two cannot disagree. The fence
  renderer is replaced outright rather than using markdown-it's
  highlight option, which re-wraps output in a second <pre>.
- escape_text is html.escape, not nh3.clean_text: it escapes character
  by character, so escaping stream chunks separately equals escaping
  the whole string.
- The stream opens its own session via session_scope(); it outlives the
  request handler and the dependency-scoped session may be closed.
- Deleting a folder keeps the chats inside it (FK is SET NULL). Losing
  a conversation to a mis-clicked folder delete is unforgivable.
- Login failures use one message for "no such account" and "wrong
  password" so the form cannot enumerate registered addresses.

Also adds deploy/ for the gamebox install at https://chat.lan: system
unit, nginx vhost with buffering off (buffering on turns streaming into
one lump at the end), and install/update scripts following the same
service-user and /srv bind-mount conventions as llama-swap and comfyui.

70 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:04:13 +02:00