pyproject and lembas.__version__ are two separate strings and only the first
was moved. The one that matters at runtime is the second: base.html registers
the service worker as sw.js?v={{ version }}, so a release that does not change
it leaves every installed browser serving the previous release's JavaScript and
CSS out of cache. A visible redesign shipped behind a stale worker is a
redesign nobody sees.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A real shell on the chat's own connection, opened and closed like the
inspector and never beside it. The modes govern the model; what a person
types is theirs, since they hold the credential and could open the same
shell with an ssh client. The model cannot see the panel -- a button
copies the output you choose into the composer.
The session outlives the socket: closing the panel leaves a build
running, and coming back reattaches with the scrollback. Two tabs share
one shell and the smaller window decides the size. It ends on an idle
timeout, on deleting the chat, on disabling, moving or deleting the
connection, and on a restart -- which says why rather than quietly
opening a fresh shell that has lost the working directory.
The nginx template's `Connection ""` is right for SSE and fails every
WebSocket handshake, so `location /` now uses a `map $http_upgrade`;
update.sh grows a drift check for it, because the only symptom on a
stale vhost is a panel that cannot connect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A row in custom_tools becomes a ToolDef like any built-in, offered beside
the thirteen. The registry had to stop being an import-time constant for
that: `resolve_tools` now returns the schemas *and* the runners together,
carried to the loop on the ToolContext.
That closes a hole on the way. `run_tool` looked names up in the global
REGISTRY with no reference to what had been offered, so a model naming a
tool its chat was gated out of -- a family switched off, a permission the
reader lacks -- had it run anyway. The resolved set is now authoritative.
Arguments come from a model, so an argument may fill a hole but never move
the target: the scheme and host of a URL template are literal, values are
escaped for where they land, and the origin is pinned afterwards. Every
redirect hop is checked the way services/fetch.py checks one, and the
secret is dropped if a hop leaves the origin it was issued for.
Also fixes the tool-activity block claiming every library tool had
"searched the web", which it has done since the second family landed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Filling the composer and waiting for Enter made the card a form to review
rather than a thing to press. One click, one reply.
That changes what a prompt has to be. The built-ins ended mid-sentence --
"My plan: " -- because nothing was sent until the person finished the
thought; sent cold they are a model guessing at material nobody gave it. All
three are rewritten to ask for what they need, so the first reply is the
right question instead. There is a test that they end as complete sentences,
since the failure is silent and only visible in the answer.
requestSubmit, not submit: it fires the submit event, which is what htmx
listens for. Same call the Enter key already makes.
Version bumped because app.js is what changed, and the service worker caches
it -- without the bump the first load after this would still only fill the
box.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A regenerate button that works, per-reply metrics, temporary chats, prompt
suggestions, an admin request inspector and compaction. The bump also
invalidates the service worker's cache, which is keyed on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The instructions LLeMbas puts in front of a model were hard-coded: six
strings in a GUIDANCE dict, two headings, and the title request inline in
chat.py. An operator could not see what was being sent, let alone change
it, and there was nowhere for a custom tool to contribute its own guidance
when custom tools land.
services/prompts.py now holds each piece as a Fragment, and /admin/prompts
edits them with a preview of the whole assembled system message including
unsaved edits. harness.py keeps only the decisions -- which fragments apply
to this request, and what their variables resolve to.
The design turns on one choice: a fragment carries its gate as data
(families, requires, when_tools) rather than as a callable, because a
database row can carry the same three fields. Custom tools will therefore
register a fragment source and change nothing else -- there is a test that
says exactly that, and it is the reason the rest of the shape is what it is.
Consequences worth knowing:
- Defaults live in code, overrides in the database, and text equal to its
default is never stored. Otherwise pressing Save once would freeze
today's wording forever and no later release could improve it.
- An empty override means off. A fragment that was not submitted at all
keeps what it had, because it may be missing from the page only because
whatever contributes it is currently switched off.
- requires= replaced the hand-written pair of memory guidance variants.
The sentence that refers to a section now lives inside that section, so
it cannot outlive it. That was the general problem the pair was a
special case of.
- {{name}}, with anything unrecognised passing through verbatim. The name
grammar is the guard: {"total": 1} and ${PATH} are not candidates.
Substitution is one pass and never recursive, because {{memories}}
carries text a model wrote.
The wording is also overhauled, and a model now gets the core fragments
even with no tools -- the date above all. "An empty harness is worse than
none" was about tokens that say nothing; a model with no clock being asked
about the present is not that. Clearing those boxes restores the old
silence exactly. New: today's date, who it is talking to, the three-round
tool budget, that tool results are not replayed, that anything a tool
returns is data rather than instruction, and what the <document> wrapper
around an attachment is. Extended: memory_forget, notes_edit/delete,
skill_create/edit, and reading a knowledge document in full rather than
answering from an extract.
Tool descriptions stay in code and are listed read-only. They are schema
and they state facts about what a runner does; an edit would make the text
a lie with nothing to catch it.
No schema change -- one JSON row in the settings table.
488 tests. Version 0.2.0, which also invalidates the service worker cache
so the green artwork appears without a hard reload.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Establish the LLeMbas foundation: FastAPI/Jinja/SQLite layout, the ORM
schema, and the original SVG identity.
Notable decisions, all recorded in comments at the point they matter:
- No Alembic. SQLite only, schema created at startup, so models carry a
few columns nothing reads yet (Message.parent_id for branching,
content_parts_json for multimodal turns). Adding them later to a live
database without migrations is the painful path.
- Sessions are server-side rows keyed by a SHA-256 of the cookie value,
not JWTs, so logout and bans revoke access immediately.
- Upstream API keys are Fernet-encrypted with a key derived from
LEMBAS_SECRET_KEY. decrypt() fails soft to "" so rotating the secret
degrades to re-entering keys rather than crashing the admin UI.
- Artwork is generated by scripts/build_artwork.py rather than hand-drawn
per file: the mallorn leaf appears in the icon, favicon, lockup and
banner, and one source is the only way those stay in sync. The wordmark
is Source Serif 4 (OFL) converted to outlines, because a README banner
cannot load a webfont and <text> would render in whatever serif the
viewer happens to have.
- Icons live in a template partial, not assets/, because same-document
<use href="#id"> is universally supported and the cross-document form
is not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>