Commit Graph
14 Commits
Author SHA1 Message Date
HomerandClaude Opus 5 a16510aba8 The interface speaks Slovak
969 strings, an instance default and a per-person choice, and no half-done corner:
the admin prose is translated too. Design and reasoning: LLeMbas.wiki/Translations.

KEYED BY THE ENGLISH SENTENCE

A missing entry renders the key, which is the English -- so an untranslated string
looks as it always did, an English instance is byte-for-byte 1.6.0, and a
half-finished catalogue is a half-translated page rather than a page of dotted key
names. The cost is that editing an English sentence orphans its translation
silently, which is what tests/test_translations.py asserts in both directions.

No gettext: .po -> .mo is a build step and this project does not have one.

A JINJA GLOBAL, AND THEREFORE A CONTEXTVAR

`t()` is a global for the reason `brand` already documents -- render() is bypassed
by 25 TemplateResponse calls and 8 get_template().render() calls, the latter being
the SSE frames, which have no Request at all. A global is bound once at import and
the language is per person, so the active language is a ContextVar set per request.

🚨 `get_current_user` had to become `async def`. FastAPI runs a sync dependency in a
threadpool, and anyio copies the context in and discards it on the way out -- so
the language was set where nothing could see it and every page rendered in the
instance's language whatever anybody had chosen, with no error anywhere.

NOT TRANSLATED, ON PURPOSE

Everything a model reads: the 60 prompt fragments, and the dates in harness.py,
schedule/runner.py and schedule/compile.py. Only `i18n.stamp` is localised, and
only where a person reads it -- with the *format* translatable as well as the
words, because "26. septembra 2026" is a different pattern rather than the same one
with different words in it. A process locale is not an option: global, not
thread-safe, two people's pages at once.

A second fragment telling models to answer in the reader's language was written
during this work and removed: `core.style` has said it since long before, and
test_an_empty_override_turns_a_fragment_off caught the duplicate.

THE BULK PASS

1213 sites wrapped by a one-off script that only touched patterns it could not
misread. It got three wrong in a way that mattered -- `t('…')` inside `attr="…"`
where the sentence held an apostrophe, closing the Jinja string and 500ing two
pages whose partials no test renders. tests/test_translations.py now compiles all
110 templates. It also wrapped the product's own name, an SSH key header, a
keystroke hint and an example URL, all taken back out: a string is not
translatable just because it is a string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 14:46:33 +00:00
HomerandClaude Opus 5 ac51dd46cc A personality belongs to a person
Owner's correction to 1.4.0: a model's character is per (model, person), and only
the description and the notes stay instance-wide. Two people talking to one model
are not talking to the same personality, and neither can see the other's.

The administrator's box becomes the DEFAULT, resolved by `personas.effective` as
a fallback and never as a layer -- two personalities at once contradict each
other with nothing to say which is losing, which is the reasoning behind "system
prompts replace, never stack". `persona_write` takes no argument naming a model
or a person; both come from the ToolContext, so it can only write the character
it has with whoever it is talking to, and it never touches the default.

Impressions move to their own table. Not a `kind` column: 1.4.0 shipped
`UNIQUE(model_key, owner_id)`, SQLite cannot alter a constraint and this schema
is additive-only, so a discriminator would leave an upgraded instance unable to
hold both rows for one pair. That leaves the first MANUAL_STEPS entry this
project has had -- the two shapes are indistinguishable, so nothing rewrites
them: a repair would be guessing at text that is read back in the first person.

TWO BUGS FROM A PHONE

`min-width` beats both `width` and `max-width` -- CSS clamps width to max-width
and then raises the result to min-width -- so `.canvas` and `.terminal` were
384px wide on every screen narrower than that, their `min(…, 100vw)` cap
overruled, and `.inspector` had no cap at all on a width that is a preference
draggable to 2400px. None of it scrolled sideways, because all three are
`position: fixed` and fixed overflow does not extend the scrollable area -- which
is exactly why the 1.1.0 narrow pass reported these pages clean. `min-width: 0`
in the overlay query, full width below the phone breakpoint, tablet column kept.

And the install button now says why it is absent. Measured against the live
instance: the manifest meets every Chrome criterion and the blocker is a
certificate from a private CA, so the origin is not trustworthy, the service
worker is refused and no install is offered. `base.html` had been swallowing that
with an empty catch -- which kept the page working, the reason it was there, and
threw away the only evidence. It now records the outcome and `app.js` turns it
into a sentence naming the certificate, which is the cause the old hint did not
mention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 12:20:40 +00:00
HomerandClaude Opus 5 df52ec9d96 Models that know about each other, and have a self
Three features sharing one idea: a model here started from nothing every
conversation and had no notion that anything else existed.

THE ROSTER. `chat.roster_block` builds one line per model this *person* can
reach -- through `permissions.models_visible_to`, never the table -- and
`{{model_roster}}` carries it, gated on the `friend` family for the reason the
memories block is gated on `memory`: a list of peers a model cannot talk to is
context spent on nothing, and one checkbox is then the whole switch. New
`Model.notes` column, a column and not a `capabilities_json` key for the reason
`context_length` and `reasoning_efforts` both carry.

ASKING A FRIEND. A second entry point in `services/subagent.py` rather than a
second module, so one place still owns the bounds and the lifecycle. `_create_
child` takes the friend's (model_id, connection_id) *pair*, because Model is
unique on both and an id alone does not say which endpoint. Three things differ
from a helper: the effort is the friend's own default and never the parent's (the
1.3.0 bug by another door -- the vocabularies differ and a level a model does not
take raises inside its chat template), the chat is ordinary even when the asker's
is an agent chat, and `scope_json["role"]` marks it so `core.friend` speaks
instead of `core.subagent`. `friend` joins the unattended withdrawal set: a
friend that could ask a friend is the same unbounded fan-out in politer clothes.
Budget, concurrency and quota are shared with helpers, so one reply cannot spend
the allowance twice.

PERSONALITY. One table, two roles, `owner_id IS NULL` the discriminator: the
model's own persona, and its read of one person. Keyed on the model's *text* id
with no foreign key, because "Test & refresh" deletes a model the endpoint has
stopped listing and a personality must not be collateral. `PersonaRevision`
copies SkillRevision, and so does the argument: the safety story for a model
rewriting itself is a record and a way back, not a gate. The reflection is shown
to the person it is about, in their own settings, which is the whole of why
keeping one is acceptable. `persona` is withdrawn from any unattended chat --
a helper's task, a friend's question and a schedule's instruction are all words
nobody watched being written.

Two bugs found while reading for this, both silent:

`review_model_id` stored a `Model` primary key, so a refresh taken while an
endpoint was not listing that model unset the administrator's choice -- and
`_reviewer` then fell back to the chat's own model, so pictures were judged by
a model nobody chose. Now the text id, with the primary key still accepted.

`_messages_after` used a bare `>` on `created_at`, so a row sharing the edited
turn's microsecond survived a rewind -- and `_send` writes a user turn and its
placeholder back to back, which is exactly that tie. Deliberately NOT
`thread_tail`'s `(created_at, id)` tiebreak: ids are random UUIDs, so that
settles a tie by coin toss. A tie now reads as "later", which is the safe
direction for an operation whose purpose is to discard what follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 02:04:55 +00:00
HomerandClaude Opus 5 54fee49810 A page that could not save, and said nothing
The model page has been unable to save anything below the reasoning efforts
since 1.3.0. "Save changes" did nothing at all, so the description, the system
prompt, every capability and tool switch and the whole availability card
silently would not take -- while the fields above it saved normally, which is
what made the page look as though it worked.

"Detect from the endpoint" had stopped detecting too: it submitted the page as
an ordinary save carrying only the top half of the form, so every field below
took its empty default. Pressing it would have cleared that model's description
and system prompt and switched the model off with all of its tools disabled.

One HTML rule causes both. A form inside another form is not allowed, and rather
than complaining a browser discards the inner start tag and lets the matching
end tag close the *outer* form -- so from that point down the page was in no
form, and a button in no form does nothing. The detect form is now declared
before the main one and the button reaches it by id.

Nothing in the markup reads wrong, and no test that posts to a route can see
this, because such a test supplies the fields itself. tests/test_form_structure.py
reads every template the way a browser parses it instead, including that rule,
and was checked against the old markup before being trusted: it reports the same
orphaned "Save changes" that headless Chromium did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 01:21:18 +00:00
HomerandClaude Opus 5 b1dbca7db6 Reading the answer instead of asking somebody to know it
llama-server publishes the loaded model's Jinja chat template on /props, and
that template is the very thing that rejects a reasoning effort it does not
recognise -- so the accepted set is written down, authoritatively, in a place
this application can simply read. There is a button on the model's page that
does.

The parser handles both shapes a template uses: the values inline in the test
that rejects them (Bonsai), and a named list set elsewhere with nothing near
the mention spelling them out (gpt-oss). It is deliberately conservative,
because a wrong answer here silently removes a level somebody is entitled to:
only known efforts count, an unrelated list of quoted strings is ignored, and a
single match is read as a default -- `{%- set reasoning_effort = 'medium' %}`
-- rather than as a vocabulary of one.

An endpoint with no such route says so. OpenAI and vLLM do not publish a
template, and "this cannot tell us" must not be recorded as "this model accepts
nothing".

/props sits at the server root, beside the OpenAI-compatible surface rather
than inside it, so a base URL written as .../v1 needs the suffix stripped.
Getting that wrong is a silent 404 that looks like detection simply not
working, so there is a test on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 22:23:22 +00:00
HomerandClaude Opus 5 32e2326d41 An effort the model had never heard of
Reported from a live instance, on Bonsai:

  Jinja Exception: Unexpected reasoning effort high. Supported types are
  xhigh (default), medium, and low.

Effort goes out two ways because no single field works, and the second --
chat_template_kwargs -- is not a parameter the server interprets. It is
rendered into the model's own chat template, which does not ignore a value it
does not know: it calls raise_exception, and the request dies before a token.
So a perfectly ordinary option, drawn by this application in its own menu, took
the whole reply with it.

The vocabulary is per model and nobody agrees. gpt-oss takes low/medium/high.
Bonsai takes low/medium/xhigh and refuses high. OpenAI has added minimal, xhigh
and max at different points, and which of them a given model accepts varies
again. One global tuple was going to be wrong for somebody whatever it held.

A model carries its own list now, and the picker, the slash command and the
request builder all read it. A column rather than a key in capabilities_json,
for the reason context_length is one: that dict is rebuilt wholesale from the
submitted checkboxes on every save.

And it corrects itself. A refusal retries the reply once without the effort
rather than losing it -- safe only because the template renders before any
token, so nothing has been emitted, and there is a guard that keeps it that way
-- then narrows the model's list. Bonsai's error states what it does take, so
that is what gets stored.

Note the parser bug, because it is a good one: "high" is a substring of
"xhigh", so reading the advertised list by substring learned `high` from a
sentence explaining that `high` is the problem. Whole words now, with a test
named after it.

/effort reads its levels off the picker instead of a second copy of the list
kept in the browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 21:20:06 +00:00
Jaroslav BenešandClaude Opus 5 20bb569b00 Finding a thing that does not use your words
Three pieces, and the first one is that they are all optional.

Extraction stops being constants. Upload size, image edge, JPEG quality, PDF
pages, extracted characters, orphan age and the text-extension list are settings
now, read through a process-level snapshot rather than a session -- `prepare` and
everything under it are called from routes, tool runners and the startup sweep,
and several of those have no session in hand. Two things deliberately stayed
constants: the decompression-bomb guard, which is a guard and not a preference,
and ORPHAN_AGE, which would have been evaluated at import if it stayed in the
signature and pinned the shipped 24 hours whatever anybody set.

An embedding model is picked from the models an administrator flagged for it, and
one that has since lost its flag is *named* rather than dropped from the picker:
a setting that vanishes is one nobody can tell from a setting never made. Nothing
here is required. Choosing none means no chunk rows, no requests, and
retrieval.search returning exactly what fts.search_ids returns in exactly that
order -- asserted, because it is what makes this safe to land on an instance that
never asked for it.

The two rankings are fused by reciprocal rank fusion: ranks and not scores,
because bm25 is a corpus-dependent negative and cosine is 0..1, and normalising
them onto one scale means picking a constant nobody can tune without a labelled
set they do not have. RRF's one constant is famously insensitive and degrades to
whichever list is non-empty -- which is what turns "no embedding model" into a
branch that does not exist.

A record scores as its best chunk rather than its average, or a long document
about something else outranks a short one that says the thing. Width and model
are stored beside every vector and a mismatch is skipped, because vectors from
two spaces score against each other perfectly happily and mean nothing -- a
search that works and is wrong is the worst failure this can have, and a model
change now leaves stale rows ignored rather than trusted.

Indexing is fired and forgotten, and how a change is noticed is a session event
rather than a call in each of the ten library writers. That is a departure from
this codebase's taste for explicit seams, for the reason tool_label is a Jinja
global: a step every writer has to remember is one that gets forgotten, and here
forgetting is silent -- the record saves, keyword search still finds it, and only
its recall goes stale. Chunks are embedded before anything is deleted, so a
failure leaves the old index rather than half a new one.

Also: `embeddings` joins the model capabilities, and the three tool flags that
had shipped with no checkbox -- canvas, scheduling and helpers -- have one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:15:21 +02:00
Jaroslav BenešandClaude Opus 5 78e5717f77 An instance that can be somebody else's
A name, a tagline, a logo, a favicon and the launcher icons derived from it; the
Middle-earth strings as data; themes as token sets; and a stylesheet for what
none of that reaches. All four are on one page, in one settings group.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:42:25 +02:00
Jaroslav BenešandClaude Opus 5 0452e742e8 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šandClaude Opus 5 439f1a5d84 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šandClaude Opus 5 90623b461d 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šandClaude Opus 5 71dc46455c 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šandClaude Opus 4.8 1eba860d39 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šandClaude Opus 4.8 75edae039b 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