diff --git a/CLAUDE.md b/CLAUDE.md
index 76bbb24..4f527cf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
-pytest # 1009 tests, ~58s
+pytest # 1051 tests, ~60s
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
@@ -392,6 +392,22 @@ than appear as an empty heading. Anything else wanting the listing gets the same
deal: the `@` picker offers no files until one exists, because a keystroke must
never wait on a machine.
+**And it only ever goes stale in one direction.** `_warm_index` returns early
+whenever anything is cached, so within the 300s TTL a reply never re-walks;
+after it lapses, the next reply rebuilds. What that misses is the tree changing
+underneath — so `file_write` calls `index.forget_dir` for the directory it just
+wrote into (the one place the cache is *known* wrong, and a model reading a
+stale listing concludes the file it created does not exist), and `/index` →
+`POST /api/chats/{id}/index` is the "look again now" for everything else,
+notably anything done by hand in the terminal panel. Read-only, so it is outside
+`agent/policy.py` for the reason the directory browser is.
+
+**The ladder falls through on failure, not just on absence.** `_from_git` and
+`_from_find` raising `ExecError` — an SFTP-only account, a forced command, a
+shell of `/bin/false` — used to escape the loop and be caught outside it,
+returning an empty listing without ever trying the SFTP rung that exists for
+exactly that host. Each rung catches its own now.
+
**A listing is budgeted, not dumped.** A tree of a thousand files costs the
window on every request forever and buries the four names that mattered.
`index.render` collapses what will not fit to `src/vendor/ (412 files)` and says
@@ -448,6 +464,34 @@ selects in the composer — the agent mode and the effort — belong to empty
`form="…"`. A form cannot nest inside another; the browser silently drops the
inner one, and the control then posts nothing at all.
+**`form="…"` scopes the values; it does not route the event.** That is half of
+the paragraph above, and taking it for the whole cost both those selects an
+entire release in which they wrote nothing. htmx binds a trigger listener to the
+annotated element itself unless `from:` says otherwise —
+`if(c.from){t=m(l,c.from)}else{t=[l]}` — and `change` fires on the select and
+bubbles through its **DOM ancestors**, which a sibling form is not. So the verb
+goes **on the control**; the empty form stays, earning its keep as the answer to
+htmx's "whose values are these", which is `function Nt(e){return e.form||g(e,"form")}`
+— `e.form` first, so a form-associated control resolves to the empty form and
+the PATCH carries that one field. Without it, `closest("form")` finds the
+composer and the request carries `project_dir`, which `update_chat` answers with
+a 409. `tests/conftest.py:control_named` exists to pin this: the element
+carrying the `name` must be the element carrying the verb. The three failures in
+this feature — `hx-post` at a PATCH-only route, a menu built after it was
+written to, a trigger bound where the event does not go — were all *silent*, and
+all three had passing tests that asserted the markup rather than the property.
+
+**A rule written for one context matches every context.** `.tok-mention` styles
+a mention in the transcript: accent colour, monospace, 0.95em. Nothing scoped it
+there, so it also hit the composer mirror's spans — and `.composer__mirror`'s
+`color: transparent` is *inherited*, which loses to a colour the span declares
+itself. The mirror painted its token visibly, in a different font, over the
+textarea's own text: doubled, and shifted from that point on because the metrics
+differ. Transcript token styles are `.msg .tok-*`; the mirror's restate
+`color: transparent` and `font: inherit` rather than relying on inheritance, and
+bleed with `box-shadow` rather than padding and a negative margin, because a
+shadow cannot move a glyph.
+
**Unused columns are worse than missing ones.** `Model.params_json` documented
itself as "default sampling params applied to new chats using this model" and
was applied nowhere for its entire existence, which is how a per-model default
@@ -463,6 +507,17 @@ where it came from into `chat.document_context`'s tag, because a model handed
asked to change something. Those two are attribute values in a tag we write, so
`_attr` strips quotes and angle brackets rather than escaping them.
+**`@` offers everything a chat can reach, and a knowledge base is the exception
+that proves the rule.** Project files, documents, notes, skills, this chat's
+earlier attachments and a URL all resolve to *an attachment*, copied — a
+transcript must not change because somebody edited a note afterwards, the same
+rule as PDF extraction. A **base** is a reference instead: `POST
+/api/chats/{id}/bases` puts it on `Chat.knowledge_bases`, which already narrows
+`knowledge_search`, and the harness already names the attached bases. Copying a
+folder of contracts into the window would cost the context on every request
+forever to answer one question. It therefore needs an existing chat, so it is
+absent on the new-chat screen — the same reason project files are.
+
**Unread is polled, not pushed.** A browser on another chat has no connection
to the one that finished. `/api/chats/unread` returns out-of-band dot spans and
an `HX-Trigger` for the toast; `unread_notified` stops the same arrival being
@@ -526,6 +581,43 @@ the attribute to win. Anything toggled with `hidden` depends on that line.
`ui.js` flips `data-composer-action` plus `type` (`submit` ↔ `button`) when a
message in the thread is still streaming. Do not add a second button back.
+**A second message while a reply streams is queued, not sent.** It used to be
+accepted outright: a second assistant placeholder, a second concurrent
+`Generation` answering the same chat from a different prefix of it, and
+`ui.js`'s first-match `querySelector(".msg[sse-connect]")` pointing Stop at
+whichever bubble came first in the document. A queued turn is a real `Message`
+with `queued` set — so it survives a restart, is in the transcript the moment it
+is typed, and can be withdrawn before it is ever sent. `build_messages` skips
+it. It must never carry `sse-connect`; the streaming shell is still the only
+thing that starts a generation, so one on a queued bubble is that second
+generation again.
+
+**Delivery is two places, and neither is a special case.** `_drain` runs in
+`_run`'s `finally:` between `_persist` and `done` — after the row is
+authoritative, before the flag `_follow` breaks on, because the `done` frame is
+the last thing that reaches a browser and has to carry the next turn's bubbles
+out of band. It takes **one** waiting prompt, not all of them: draining the lot
+puts two consecutive user turns in the next request. `_inject` takes one *into*
+a reply at a tool-round boundary, which is the point of queueing in an agent
+chat — steering work already under way — and restamps the assistant
+placeholder's `created_at` so the reply still sorts before the prompt it
+answered. It refuses on the last round: a prompt delivered into a reply that
+then runs out of budget is marked delivered and never sent again.
+
+**Stop leaves the queue alone**, deliberately, and `_drain` refuses on stopped,
+errored and superseded. Stopped is the reader's decision; errored would feed the
+next prompt into an endpoint that has just failed; superseded is the same test
+`_persist` makes, without which regenerating drains the queue as a side effect.
+Cancellation sets `stopped`, so a restart never fires off a reply with nobody
+watching.
+
+**An interjection is sent verbatim, in the user role.** Everything else this
+codebase injects is quoted and attributed because it came out of a file, a page
+or a machine; this one genuinely is the person at the keyboard. Wrapping it
+would teach a model that a user turn can be a quotation, which is the exact
+distinction `execute_plan` and `Capture.as_text` rely on. What the model needs —
+that this can happen at all — is the `core.interjection` harness fragment.
+
**The tool loop is inside one generation.** `services/generation.py:_run()` runs
up to `tools_service.MAX_ROUNDS` request rounds for a single reply: stream,
accumulate tool calls, run them, append the results, ask again. `Generation`
@@ -838,6 +930,16 @@ therefore no markers — at which point Copy and Send fall back to scraping the
screen and say so, and the automatic toggle is **disabled rather than degraded**.
Forty arbitrary lines attached to every message is worse than nothing attached.
+**The automatic toggle has three states, and a select to say which.** Off, copy,
+send. It was a boolean doing the wrong one of them: it appended into the
+composer, on top of whatever was being typed there. `send` posts straight to
+`/api/chats/{id}/messages` and never touches the composer — which is what makes
+the queue load-bearing, since commands finish while a reply is running. Not
+persisted between page loads, deliberately: a switch that forwards everything
+you type in a shell to a model is not something to inherit from last week's
+session. A cycling icon button was the obvious shape and cannot say which of
+three states it is in.
+
**The nginx vhost must pass upgrades through.** `deploy/nginx-vhost.conf` used
to set `Connection ""`, which is right for SSE and fails every WebSocket
handshake — and a failed handshake tells the browser nothing: no status, no
diff --git a/pyproject.toml b/pyproject.toml
index 25f7d14..76dd452 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "lembas"
-version = "0.6.1"
+version = "0.6.2"
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
readme = "README.md"
requires-python = ">=3.11"
diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py
index d5e23b9..c87f999 100644
--- a/src/lembas/__init__.py
+++ b/src/lembas/__init__.py
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
-__version__ = "0.6.1"
+__version__ = "0.6.2"
diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py
index 29f38ba..c5d89b2 100644
--- a/src/lembas/api/chats.py
+++ b/src/lembas/api/chats.py
@@ -10,8 +10,8 @@ from collections.abc import AsyncIterator
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
-from fastapi.responses import HTMLResponse, Response, StreamingResponse
-from sqlalchemy import select
+from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
+from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser, require_permission
@@ -49,6 +49,12 @@ router = APIRouter(prefix="/api/chats", tags=["chats"])
# Well under nginx's 60s default; see services/sse.py:KEEPALIVE.
KEEPALIVE_AFTER = 15.0
+# How many prompts may wait behind a reply at once. The terminal panel's Auto
+# send is what this exists for: a `for` loop in a shell can produce commands
+# faster than any model answers them, and a bound with a sentence attached is
+# better than four hundred rows nobody meant to write.
+MAX_QUEUED = 10
+
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
chat = db.get(Chat, chat_id)
@@ -70,6 +76,7 @@ def _new_chat(
ssh_profile_id: str = "",
project_dir: str = "",
agent_mode: str = "",
+ reasoning_effort: str = "",
) -> Chat:
"""Create a chat row, resolving which model it should use.
@@ -81,7 +88,8 @@ def _new_chat(
before the first word. Without it, reaching Plan mode meant starting a chat
in Manual, sending something to make the chat exist, and only then being
offered the control -- by which point the model had already answered under
- the wrong rules.
+ the wrong rules. The reasoning effort is accepted for the same reason, and
+ wins over the model's default: an explicit choice beats an inherited one.
"""
chosen = None
if model_id:
@@ -126,6 +134,12 @@ def _new_chat(
# Left alone entirely on a plain chat, where it means nothing.
if profile is not None and agent_mode.strip() in agent_policy.MODES:
chat.agent_mode = agent_mode.strip()
+ # After the model's defaults, so choosing one on the new-chat screen wins
+ # over the administrator's. Empty means "whatever the model said", not
+ # "none" -- clearing it is what the blank option on an existing chat does.
+ wanted_effort = reasoning_effort.strip().lower()
+ if wanted_effort in chat_service.EFFORTS:
+ chat.params_json = {**chat.params_json, "reasoning_effort": wanted_effort}
db.add(chat)
db.commit()
return chat
@@ -144,6 +158,7 @@ async def start_chat(
ssh_profile_id: str = Form(""),
project_dir: str = Form(""),
agent_mode: str = Form(""),
+ reasoning_effort: str = Form(""),
) -> Response:
"""Create a chat from its first message.
@@ -166,6 +181,7 @@ async def start_chat(
ssh_profile_id=ssh_profile_id,
project_dir=project_dir,
agent_mode=agent_mode,
+ reasoning_effort=reasoning_effort,
)
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
@@ -400,6 +416,108 @@ async def compact_chat(request: Request, db: Db, user: RequiredUser, chat_id: st
)
+@router.post("/{chat_id}/index")
+async def reindex_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
+ """Walk the project directory again, now.
+
+ The listing is cached for five minutes and only ever built when a reply
+ starts, so a tree that has just changed under somebody's hands -- a checkout,
+ a build, anything done in the terminal panel rather than through
+ `file_write` -- stays wrong until the next reply after the TTL lapses. This
+ is the "look again" that was missing.
+
+ Read-only, and therefore outside `agent/policy.py` for the reason the
+ directory browser is: it is LLeMbas acting on a person's instruction, not a
+ model choosing to look, and a listing that asked permission would be
+ useless. The gate is ownership of the connection, checked here rather than
+ trusted from the chat.
+ """
+ from lembas.db.models import SshProfile
+ from lembas.services.agent import index as index_service
+ from lembas.services.agent import ssh as ssh_service
+
+ chat = _owned_chat(db, chat_id, user.id)
+ if chat.kind != KIND_AGENT or not chat.ssh_profile_id:
+ raise HTTPException(status.HTTP_409_CONFLICT, "This chat has no project directory.")
+ if not permissions.has(db, user, "tools.agent"):
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not use agent connections.")
+
+ profile = db.get(SshProfile, chat.ssh_profile_id)
+ if profile is None or profile.owner_id != user.id or not profile.enabled:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection is not available.")
+ if not profile.host_key:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "This connection's host key has not been accepted yet.",
+ )
+
+ project_dir = chat.project_dir or profile.default_dir or ""
+ try:
+ found = await index_service.ensure(
+ ssh_service.SshExecutor(ssh_service.spec_from(profile), project_dir),
+ profile.id,
+ project_dir,
+ refresh=True,
+ )
+ except Exception as exc: # noqa: BLE001 - surfaced to the reader, not swallowed
+ log.warning("could not index %s for chat %s: %s", project_dir, chat.id, exc)
+ raise HTTPException(
+ status.HTTP_502_BAD_GATEWAY, "Could not read the project directory."
+ ) from exc
+
+ listed = len(found.paths)
+ return JSONResponse(
+ {
+ "ok": True,
+ "files": found.total,
+ "listed": listed,
+ "truncated": found.truncated,
+ "message": (
+ f"{found.total} files under {project_dir or '~'}"
+ + (f", {listed} listed." if listed != found.total else ".")
+ ),
+ }
+ )
+
+
+@router.post("/{chat_id}/bases")
+async def attach_base(
+ request: Request, db: Db, user: RequiredUser, chat_id: str, base_id: str = Form("")
+) -> Response:
+ """Scope this chat to a knowledge base, from the `@` menu.
+
+ A base is a *reference*, not an attachment: `Chat.knowledge_bases` already
+ narrows `knowledge_search`, and the harness already names the attached bases
+ so the model can tell "there is nothing about this" from "I can only see
+ this folder". Copying a folder of documents into the window instead would
+ cost the context on every request forever to answer one question.
+
+ Additive, and idempotent -- choosing the same base twice is not an error.
+ Removing one is a checkbox in the chat's settings, where the whole set is
+ visible at once.
+ """
+ from lembas.db.models import KnowledgeBase
+ from lembas.services.library import documents as documents_service
+
+ chat = _owned_chat(db, chat_id, user.id)
+ if not permissions.has(db, user, "library.use"):
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not use the library.")
+
+ base = db.scalar(
+ documents_service.visible_bases(db, user).where(KnowledgeBase.id == base_id)
+ )
+ if base is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
+
+ if base.id not in {b.id for b in chat.knowledge_bases}:
+ chat.knowledge_bases = [*chat.knowledge_bases, base]
+ db.commit()
+
+ return templates.TemplateResponse(
+ request, "chat/_base_chip.html", {"request": request, "base": base}
+ )
+
+
@router.post("/{chat_id}/keep")
async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
"""Stop a temporary chat being temporary.
@@ -489,6 +607,22 @@ async def post_message(
return _send(request, db, chat, user, content, file_ids=file_ids)
+def _reply_in_flight(db: DBSession, chat: Chat) -> bool:
+ """Whether this chat already has a reply being written.
+
+ The row is the authority, not the registry: a restart leaves an incomplete
+ assistant message behind with no `Generation` anywhere, and that row is what
+ starts the reply again on the next page load. The registry is consulted too,
+ for the sliver in which a generation is still running and its row has
+ already been written -- `_persist` sets `complete` before `_run` sets
+ `done`.
+ """
+ unfinished = db.scalar(
+ select(Message).where(Message.chat_id == chat.id, Message.complete.is_(False))
+ )
+ return unfinished is not None or generation_service.running_for(chat.id) is not None
+
+
def _note_rewind(chat: Chat) -> None:
"""Record that an agent chat's transcript went back and the machine did not.
@@ -516,11 +650,53 @@ def _send(
Shared by the composer and by anything else that puts words into a
conversation on somebody's behalf -- carrying out a plan, for one. One path
rather than two, so a second way of sending cannot drift from the first.
+
+ If a reply is already being written, the turn is *queued* instead: written,
+ shown, and not sent. Starting a second reply here is what used to happen,
+ and it produced two generations answering the same chat from two different
+ prefixes of it, with Stop pointing at whichever bubble came first in the
+ document.
"""
- user_message = chat_service.create_message(db, chat, ROLE_USER, content)
+ if queued := _reply_in_flight(db, chat):
+ waiting = db.scalar(
+ select(func.count())
+ .select_from(Message)
+ .where(Message.chat_id == chat.id, Message.queued.is_(True))
+ )
+ if waiting >= MAX_QUEUED:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ f"There are already {MAX_QUEUED} messages waiting to be sent.",
+ )
+
+ user_message = chat_service.create_message(db, chat, ROLE_USER, content, queued=queued)
if file_ids:
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)
db.refresh(user_message)
+
+ if queued:
+ # One bubble and no assistant placeholder. The streaming shell is the
+ # only thing that starts a generation, so a placeholder here would be a
+ # second concurrent reply -- exactly what the queue exists to prevent.
+ if (live := generation_service.running_for(chat.id)) is not None:
+ # So a reply between two rounds of tool calls notices it, and so
+ # anybody following sees the status change.
+ live.touch()
+ return templates.TemplateResponse(
+ request,
+ "chat/_message.html",
+ {
+ "request": request,
+ "message": user_message,
+ "chat": chat,
+ "user": user,
+ "models_by_id": {
+ m.model_id: m for m in chat_service.available_models(db, user)
+ },
+ **audio_service.template_flags(db, user),
+ },
+ )
+
assistant_message = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
)
@@ -680,10 +856,90 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
)
title_html = templates.get_template("chat/_title_oob.html").render({"chat": chat})
- yield sse.event("done", final_html + title_html)
+ # What the queue did while this reply was running. There is no push
+ # channel that outlives one message's stream, and this is the last frame
+ # that reaches the browser -- so it carries the rest out of band, the
+ # way the chat title already does.
+ moved_html, queue_html = _queue_frames(db, chat, owner, generation)
+
+ yield sse.event("done", moved_html + final_html + queue_html + title_html)
yield sse.event("close", "")
+def _render_bubble(db: DBSession, chat: Chat, owner: User | None, message: Message) -> str:
+ """One finished bubble, rendered the way the `done` frame renders its own."""
+ return templates.get_template("chat/_message.html").render(
+ {
+ "message": message,
+ "body_html": (
+ render_markdown(message.content) if message.role == ROLE_ASSISTANT else ""
+ ),
+ "chat": chat,
+ "user": owner,
+ "models_by_id": {m.model_id: m for m in chat_service.available_models(db, None)},
+ **audio_service.template_flags(db, owner),
+ }
+ )
+
+
+def _queue_frames(
+ db: DBSession, chat: Chat, owner: User | None, generation
+) -> tuple[str, str]:
+ """The bubbles the queue produced during this reply, as out-of-band HTML.
+
+ Two pieces, because they swap differently. Anything taken *into* this reply
+ mid-round now sorts before it, so it is rendered ahead of the finished
+ bubble in the same `outerHTML` swap and its stale node is deleted out of
+ band -- one frame, and the DOM ends up in the order the database is in.
+ Anything drained *after* the reply is a new pair appended to the thread.
+ """
+ moved: list[str] = []
+ out_of_band: list[str] = []
+
+ for injected_id in generation.injected_ids:
+ row = db.get(Message, injected_id)
+ if row is None:
+ continue
+ moved.append(_render_bubble(db, chat, owner, row))
+ # Removed where it was; it is about to reappear above the reply.
+ out_of_band.append(f'')
+
+ if generation.drained:
+ fresh = list(
+ db.scalars(
+ select(Message)
+ .where(Message.chat_id == chat.id, Message.complete.is_(False))
+ .order_by(Message.created_at)
+ )
+ )
+ for assistant in fresh:
+ # The user turn that was waiting has just lost its Send now and
+ # Discard, so it is re-rendered in place.
+ delivered = db.scalars(
+ select(Message)
+ .where(
+ Message.chat_id == chat.id,
+ Message.role == ROLE_USER,
+ Message.created_at <= assistant.created_at,
+ )
+ .order_by(Message.created_at.desc())
+ .limit(1)
+ ).first()
+ if delivered is not None:
+ out_of_band.append(
+ f'
"
+ )
+
+ return "".join(moved), "".join(out_of_band)
+
+
def _metrics_html(generation) -> str:
"""The metric chips for a reply still being written.
@@ -799,6 +1055,16 @@ async def edit_message(
if not content and not message.attachments:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "A message cannot be empty.")
+ # Editing rewinds and then starts a reply, unconditionally. Doing that while
+ # one is already being written is a second concurrent generation -- the
+ # thing the queue exists to prevent -- reachable here by a button that is on
+ # screen throughout. It was reachable before the queue too; nothing made it
+ # obvious.
+ if _reply_in_flight(db, chat):
+ raise HTTPException(
+ status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
+ )
+
message.content = content
# Attachments cascade with their message, so the files go too.
@@ -884,6 +1150,67 @@ async def stop_message(db: Db, user: RequiredUser, chat_id: str, message_id: str
return Response(status_code=status.HTTP_204_NO_CONTENT)
+def _waiting_message(db: DBSession, chat: Chat, message_id: str) -> Message:
+ message = db.get(Message, message_id)
+ if message is None or message.chat_id != chat.id or not message.queued:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND, "That message is not waiting to be sent."
+ )
+ return message
+
+
+@router.post("/{chat_id}/messages/{message_id}/discard")
+async def discard_queued(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> Response:
+ """Withdraw a prompt that has not been sent.
+
+ Deleted outright rather than marked: it never reached a model, nothing in
+ the transcript refers to it, and a conversation full of tombstones for
+ things nobody said is worse than the row being gone. Attachments cascade.
+
+ An empty body rather than a 204, because htmx does not swap on a 204 and the
+ bubble has to disappear.
+ """
+ chat = _owned_chat(db, chat_id, user.id)
+ message = _waiting_message(db, chat, message_id)
+
+ db.delete(message)
+ db.commit()
+ return HTMLResponse("")
+
+
+@router.post("/{chat_id}/messages/{message_id}/send-now")
+async def send_queued_now(
+ request: Request, db: Db, user: RequiredUser, chat_id: str, message_id: str
+) -> Response:
+ """Deliver a waiting prompt at once.
+
+ Refused while a reply is being written rather than allowed to jump ahead of
+ it: that is what the queue *is*, and starting a second generation here is
+ the thing this whole mechanism exists to stop. Stop the reply first.
+
+ The whole thread comes back, which is the rewind and compaction idiom, and
+ is safe only because of the refusal above -- there is no live bubble to
+ destroy.
+ """
+ chat = _owned_chat(db, chat_id, user.id)
+ message = _waiting_message(db, chat, message_id)
+ if _reply_in_flight(db, chat):
+ raise HTTPException(
+ status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
+ )
+
+ message.queued = False
+ db.commit()
+ assistant = chat_service.create_message(
+ db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
+ )
+ generation_service.ensure(chat.id, assistant.id)
+
+ return templates.TemplateResponse(
+ request, "chat/_thread.html", {"request": request, **_thread_context(db, chat, user)}
+ )
+
+
@router.post("/{chat_id}/interaction/{interaction_id}")
async def answer_interaction(
request: Request,
diff --git a/src/lembas/api/files.py b/src/lembas/api/files.py
index e06bc19..ee7ec4f 100644
--- a/src/lembas/api/files.py
+++ b/src/lembas/api/files.py
@@ -16,14 +16,17 @@ from fastapi import (
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
+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__)
@@ -144,6 +147,102 @@ async def attach_from_knowledge(
)
+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 = ""
@@ -210,9 +309,14 @@ async def mention_picker(
][: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(
@@ -221,6 +325,48 @@ async def mention_picker(
.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,
@@ -230,6 +376,11 @@ async def mention_picker(
"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,
diff --git a/src/lembas/db/models/chat.py b/src/lembas/db/models/chat.py
index a44c765..9a090b6 100644
--- a/src/lembas/db/models/chat.py
+++ b/src/lembas/db/models/chat.py
@@ -223,6 +223,13 @@ class Message(UUIDPrimaryKey, Timestamps, Base):
# 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")
attachments: Mapped[list[Attachment]] = relationship( # noqa: F821
diff --git a/src/lembas/services/agent/index.py b/src/lembas/services/agent/index.py
index 8287322..d5a8b44 100644
--- a/src/lembas/services/agent/index.py
+++ b/src/lembas/services/agent/index.py
@@ -223,12 +223,21 @@ 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):
- found = await attempt(executor, project_dir)
+ 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
- else:
- found = None
if found is None:
found = await _from_sftp(executor, project_dir)
except ExecError as exc:
@@ -491,6 +500,17 @@ def forget(profile_id: str) -> int:
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()
@@ -503,5 +523,6 @@ __all__ = [
"clear",
"ensure",
"forget",
+ "forget_dir",
"render",
]
diff --git a/src/lembas/services/agent/session.py b/src/lembas/services/agent/session.py
index 20ebb04..be7fee4 100644
--- a/src/lembas/services/agent/session.py
+++ b/src/lembas/services/agent/session.py
@@ -36,6 +36,10 @@ class AgentContext:
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, ...] = ()
@@ -112,6 +116,7 @@ def resolve(db: DBSession, chat: Chat, user: User | None) -> AgentContext | None
chat_id=chat.id,
label=profile.label,
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 ()),
diff --git a/src/lembas/services/agent/tools.py b/src/lembas/services/agent/tools.py
index 28b15b0..482f835 100644
--- a/src/lembas/services/agent/tools.py
+++ b/src/lembas/services/agent/tools.py
@@ -21,7 +21,7 @@ import json
import logging
from typing import Any
-from lembas.services.agent import policy
+from lembas.services.agent import index, policy
from lembas.services.agent.base import ExecError, ExecRequest
from lembas.services.agent.session import AgentContext
from lembas.services.tools import (
@@ -198,6 +198,13 @@ async def _run_write(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
exc.message, _event("file_write", agent, path, status="error", error=exc.message)
)
+ # 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)
+
return ToolOutcome(
f"Wrote {written} bytes to {path}.",
_event("file_write", agent, path, status="ok", text=f"{written} bytes"),
diff --git a/src/lembas/services/chat.py b/src/lembas/services/chat.py
index e2f2bd6..69c81d9 100644
--- a/src/lembas/services/chat.py
+++ b/src/lembas/services/chat.py
@@ -226,6 +226,11 @@ def build_messages(
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:
@@ -447,6 +452,7 @@ def create_message(
*,
complete_: bool = True,
model_id: str = "",
+ queued: bool = False,
) -> Message:
message = Message(
chat_id=chat.id,
@@ -454,6 +460,7 @@ def create_message(
content=content,
complete=complete_,
model_id=model_id,
+ queued=queued,
)
db.add(message)
db.commit()
diff --git a/src/lembas/services/compaction.py b/src/lembas/services/compaction.py
index a967ba9..f7ea707 100644
--- a/src/lembas/services/compaction.py
+++ b/src/lembas/services/compaction.py
@@ -98,8 +98,12 @@ def split(
return [], list(messages)
boundary = moment(cutoff)
return (
- [m for m in messages if moment(m) <= boundary],
- [m for m in messages if moment(m) > boundary],
+ # 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],
)
@@ -133,6 +137,9 @@ def transcript(db: DBSession, chat: Chat, *, upto: Message) -> str:
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)
diff --git a/src/lembas/services/files.py b/src/lembas/services/files.py
index 8850117..6ada44b 100644
--- a/src/lembas/services/files.py
+++ b/src/lembas/services/files.py
@@ -375,6 +375,43 @@ def store_text(
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:
@@ -407,6 +444,12 @@ def copy_document(
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()
diff --git a/src/lembas/services/generation.py b/src/lembas/services/generation.py
index fdf18bc..2d18928 100644
--- a/src/lembas/services/generation.py
+++ b/src/lembas/services/generation.py
@@ -129,6 +129,13 @@ class Generation:
# the reply and is written onto the message, so the Execute button sends
# exactly what was proposed rather than something parsed back out of prose.
plan: dict | None = None
+ # The queue, seen from the reply's side. `drained` says this reply's ending
+ # handed the next waiting prompt to a fresh one; `injected_ids` names the
+ # prompts taken into *this* reply between two rounds of tool calls. Both are
+ # read only by `_follow`, which turns them into bubbles on the `done` frame
+ # -- the one frame that reaches a browser after a reply is over.
+ drained: bool = False
+ injected_ids: list[str] = field(default_factory=list)
def touch(self) -> None:
self.version += 1
@@ -187,6 +194,22 @@ def answer(
return False
+def running_for(chat_id: str) -> Generation | None:
+ """The reply being written in this chat, if there is one.
+
+ A linear scan for the reason `answer` gives above: one entry per reply in
+ flight, consulted at human speed. `_prune` first, because a finished
+ generation lingers `KEEP_FINISHED` so that late followers still get the
+ final frames -- and without the sweep those five minutes would look like a
+ chat that is permanently busy, and queue everything typed into it.
+ """
+ _prune()
+ for generation in _RUNNING.values():
+ if generation.chat_id == chat_id and not generation.done:
+ return generation
+ return None
+
+
_VERDICTS = (interaction.ALLOW, interaction.ALLOW_ALWAYS, interaction.DENY)
@@ -328,6 +351,12 @@ async def _run(generation: Generation) -> None:
model = chat_service.model_for(db, chat)
generation.context_limit = model.context_length if model is not None else 0
+ # Kept for `_inject`, which builds a user turn after this session
+ # has closed. A turn taken in mid-reply has to be shaped exactly as
+ # the same words typed a moment later would have been -- images to a
+ # vision model, a plain string to anything else, or the endpoint
+ # rejects the whole request.
+ vision = chat_service.model_supports(db, chat, "vision")
generation.prompt_estimate = tokens.estimate_request(payload)
@@ -406,10 +435,17 @@ async def _run(generation: Generation) -> None:
if generation.stopped or not calls:
break
- if round_number == tools_service.MAX_ROUNDS:
+ if round_number == budget:
# Out of rounds with the model still asking for tools. Recorded
# rather than silently dropped: an answer that stops here needs
# to be explicable.
+ #
+ # `budget`, not `MAX_ROUNDS`. The loop is sized by the budget on
+ # the line above and the message below has always reported it,
+ # but the comparison was against the global 3 -- so an agent
+ # chat allowed forty steps stopped after three and said it had
+ # taken forty. Two numbers, one of them wrong, in code whose
+ # whole job is to say what happened.
generation.tool_events.append(
{
"name": calls[0]["name"],
@@ -454,6 +490,22 @@ async def _run(generation: Generation) -> None:
generation.plan = outcome.event["plan"]
generation.touch()
+ # Something typed while this reply was working. Taken in here, at a
+ # round boundary, rather than made to wait for the whole reply: an
+ # agent that has just finished one loop and is about to start
+ # another is exactly when "actually, do it the other way" is worth
+ # having.
+ #
+ # Only while there is a round left to answer in. Injecting into the
+ # last one would deliver the prompt into a reply that then runs out
+ # of budget without addressing it -- and it is marked delivered, so
+ # nothing would ever send it again. Below that line it waits for
+ # `_drain`, which always gives it a reply of its own.
+ if round_number + 1 < budget and (
+ added := _inject(generation, generation.chat_id, vision)
+ ):
+ messages.append(added)
+
payload = {**payload, "messages": messages}
# A plan ends the turn. One more request so the model can say what
@@ -525,6 +577,11 @@ async def _run(generation: Generation) -> None:
# the row. The other order left a window in which the finished frame
# showed the previous turn's stored values.
_persist(generation, title, time.monotonic() - started)
+ # After the row is authoritative and before `done`, for the same reason
+ # `_persist` is: `_follow` breaks the instant it sees that flag, and the
+ # frame it then sends is the one that has to carry the next turn's
+ # bubbles. There is no push channel that outlives a single reply.
+ _drain(generation)
generation.done = True
generation.finished_at = datetime.now(UTC)
generation.touch()
@@ -1006,6 +1063,117 @@ def _question_from(payload: dict) -> str:
return ""
+def _next_waiting(db, chat_id: str) -> Message | None:
+ """The oldest prompt in this chat that has not been sent."""
+ return db.scalars(
+ select(Message)
+ .where(
+ Message.chat_id == chat_id,
+ Message.role == ROLE_USER,
+ Message.queued.is_(True),
+ )
+ .order_by(Message.created_at)
+ .limit(1)
+ ).first()
+
+
+def _drain(generation: Generation) -> None:
+ """Hand the next waiting prompt to a reply of its own, if there is one.
+
+ Exactly one, not all of them. Draining the lot would put two consecutive
+ user turns into the next request, which several local chat templates refuse
+ outright -- `build_messages` already goes to some trouble over that around
+ the compaction lead. "One after another" is also what was asked for: the
+ second waiting prompt is drained by the reply the first one starts, and so
+ on down the chain.
+
+ Three refusals, and none of them is a special case:
+
+ - **Superseded.** The same test `_persist` makes, for the same reason: a
+ regeneration cancels its predecessor and the predecessor's `finally:`
+ still runs. Without this, regenerating would drain the queue *and* leave
+ a third generation running.
+ - **Stopped.** Stop means stop, and the queue stays visible and
+ undelivered with Send now beside it. This is also what makes shutdown
+ safe -- cancellation sets `stopped`, so a restart never fires off a reply
+ with nobody watching.
+ - **Errored.** The endpoint has just failed. Feeding the next prompt into it
+ produces a second failure and spends somebody's words to do it.
+ """
+ owner = _RUNNING.get(generation.message_id)
+ if owner is not None and owner is not generation:
+ return
+ if generation.stopped or generation.error:
+ return
+
+ try:
+ with session_scope() as db:
+ chat = db.get(Chat, generation.chat_id)
+ if chat is None:
+ return
+ waiting = _next_waiting(db, chat.id)
+ if waiting is None:
+ return
+
+ waiting.queued = False
+ assistant = chat_service.create_message(
+ db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
+ )
+ chat_id, assistant_id = chat.id, assistant.id
+ except Exception: # noqa: BLE001 - the reply is over either way
+ log.exception("could not drain the queue for chat %s", generation.chat_id)
+ return
+
+ # Outside the session: this starts a task, and a task is not something to
+ # hold a database session open across.
+ ensure(chat_id, assistant_id)
+ generation.drained = True
+
+
+def _inject(generation: Generation, chat_id: str, vision: bool) -> dict | None:
+ """Take the oldest waiting prompt into this reply, between two rounds.
+
+ Marked delivered and committed *before* the request goes out, so this is
+ at-most-once. A crash in between loses the turn, which is recoverable --
+ the words are still in the transcript with Send now beside them. The other
+ way round would ask the same question twice and let an agent act on it
+ twice, which is not.
+
+ Sent verbatim, in the user role, with no framing. Everything else this
+ codebase injects is quoted and attributed because it came out of a file, a
+ page or a machine; this one genuinely *is* the person at the keyboard,
+ authenticated by the session cookie and stored as a `Message` whose role
+ says so. Wrapping it would teach a model that a user turn can be a
+ quotation, which is the exact distinction the other two rely on. What the
+ model needs -- that this can happen at all -- is one sentence in the
+ harness, where authored wording lives.
+ """
+ try:
+ with session_scope() as db:
+ waiting = _next_waiting(db, chat_id)
+ if waiting is None:
+ return None
+
+ waiting.queued = False
+ entry = chat_service.message_payload(waiting, vision=vision)
+ # The reply that answers it must sort *before* it, or the next
+ # turn's transcript reads "answer, then the question it answered"
+ # and a small model dutifully answers again. Moving the placeholder
+ # rather than the prompt keeps several interjections in the order
+ # they were typed.
+ placeholder = db.get(Message, generation.message_id)
+ if placeholder is not None:
+ placeholder.created_at = datetime.now(UTC)
+ generation.injected_ids.append(waiting.id)
+ except Exception: # noqa: BLE001 - a lost interjection is not a failed reply
+ log.exception("could not take a queued prompt into chat %s", chat_id)
+ return None
+
+ generation.status = "Taking in what you just added…"
+ generation.touch()
+ return entry
+
+
def _persist(generation: Generation, title: str, elapsed: float) -> None:
"""Write the finished reply, name the chat, and set the unread flag.
diff --git a/src/lembas/services/prompts.py b/src/lembas/services/prompts.py
index 13cf361..cd28c3a 100644
--- a/src/lembas/services/prompts.py
+++ b/src/lembas/services/prompts.py
@@ -587,6 +587,22 @@ BUILTIN: tuple[Fragment, ...] = (
"within that budget: two careful searches beat six that run out halfway."
),
),
+ Fragment(
+ key="core.interjection",
+ label="Being interrupted",
+ group=GROUP_CORE,
+ order=115,
+ when_tools=True,
+ hint="A message typed while you are working is handed to you between two "
+ "rounds of tool calls. Without this a model reads it as a fresh "
+ "conversation and starts the whole task again.",
+ default=(
+ "A new message from the person you are working for can arrive between "
+ "rounds of tool calls, while you are still working. Take it into account "
+ "from that point on. You do not need to start again or to re-explain what "
+ "you have already done — carry on, adjusted."
+ ),
+ ),
Fragment(
key="core.no_replay",
label="Results are not kept",
diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css
index 812dc36..717a061 100644
--- a/src/lembas/web/static/css/chat.css
+++ b/src/lembas/web/static/css/chat.css
@@ -576,6 +576,24 @@
.msg:focus-within .msg__actions { opacity: 1; }
.msg__actions .is-copied { color: var(--success); }
+/* --- A turn that is waiting to be sent ------------------------------------
+ Its actions do not fade in on hover like the others: they are the only way
+ to withdraw something that has not happened yet, and a control you have to
+ find by hovering is one somebody will not find. */
+.msg--queued { opacity: 0.75; }
+.msg--queued .msg__body {
+ border-inline-start: 2px dashed var(--border-strong);
+ padding-inline-start: var(--sp-2);
+}
+.msg__actions--queued { opacity: 1; align-items: center; }
+.msg__note {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-1);
+ color: var(--ink-muted);
+ font-size: var(--text-xs);
+}
+
/* --- Rendered Markdown ---------------------------------------------------- */
.msg__body > :first-child { margin-top: 0; }
.msg__body > :last-child { margin-bottom: 0; }
@@ -761,16 +779,32 @@
.composer__mirror .tok-mention,
.composer__mirror .tok-command {
border-radius: var(--radius-sm);
- /* Bled sideways so the rectangle does not sit hard against the next word,
- and the negative margin keeps the text metrics identical. */
- padding: 0 2px;
- margin: 0 -2px;
+ /* Restated rather than inherited. `color: transparent` on the mirror is an
+ inherited value, and a colour the span declares itself beats it -- which is
+ exactly what the transcript's rule below used to do from across the file,
+ painting the token in accent-coloured mono at 0.95em on top of the
+ textarea's own text. Doubled, and shifted from there on, because the
+ metrics differ. The font must be restated for the same reason. */
+ color: transparent;
+ font: inherit;
+ /* Bled sideways by a shadow, not by padding: a rectangle that spreads cannot
+ move a glyph, and the negative margin that used to do this was the only
+ thing in the mirror that could. */
+ padding: 0;
+ margin: 0;
+}
+.composer__mirror .tok-mention {
+ background: var(--accent-soft);
+ box-shadow: 0 0 0 2px var(--accent-soft);
+}
+.composer__mirror .tok-command {
+ background: var(--leaf-soft);
+ box-shadow: 0 0 0 2px var(--leaf-soft);
}
-.composer__mirror .tok-mention { background: var(--accent-soft); }
-.composer__mirror .tok-command { background: var(--leaf-soft); }
-/* The same two in a sent message, where they are text rather than a backdrop. */
-.tok-mention {
+/* The same two in a sent message, where they are text rather than a backdrop --
+ and scoped to it, because unscoped they also matched the mirror's spans. */
+.msg .tok-mention {
border-radius: var(--radius-sm);
padding: 0 2px;
background: var(--accent-soft);
diff --git a/src/lembas/web/static/js/commands.js b/src/lembas/web/static/js/commands.js
index f8e44e1..c50b6b3 100644
--- a/src/lembas/web/static/js/commands.js
+++ b/src/lembas/web/static/js/commands.js
@@ -75,7 +75,11 @@
name: "effort",
summary: "How hard a reasoning model should think",
argument: "low | medium | high",
- when: function () { return !!chat() && !!el("[data-effort]"); },
+ /* Offered wherever there is a model, not only where the control is.
+ `available()` filters `find()` and `run()` as well as the menu, so a
+ command hidden here is not merely unlisted -- typing it in full stops
+ being a command and gets sent as a message. Better to answer. */
+ when: function () { return !!el('[name="model_id"]'); },
run: function (rest) { setEffort(rest); }
},
{
@@ -115,6 +119,12 @@
});
}
},
+ {
+ name: "index",
+ summary: "Read the project directory again",
+ when: function () { return !!chat() && isAgent(); },
+ run: function () { reindex(); }
+ },
{
name: "terminal",
summary: "Show or hide the terminal",
@@ -163,6 +173,32 @@
return function () { window.location = url; };
}
+ /* --- Reading the project directory again --------------------------------
+ The listing is cached for five minutes and only ever built when a reply
+ starts, so anything done in the terminal panel -- a checkout, a build --
+ is invisible to it until then. This is the "look again now". */
+ var indexing = false;
+
+ function reindex() {
+ if (indexing) return note("Already reading the project directory.");
+ indexing = true;
+ note("Reading the project directory…");
+ post("/api/chats/" + chat() + "/index")
+ .then(function (response) {
+ return response.json().then(function (body) {
+ return { ok: response.ok, body: body };
+ });
+ })
+ .then(function (result) {
+ if (!result.ok) {
+ return note(result.body.detail || "Could not read the project directory.", "error");
+ }
+ note(result.body.message);
+ })
+ .catch(function () { note("Could not read the project directory.", "error"); })
+ .finally(function () { indexing = false; });
+ }
+
/* --- Reasoning effort ---------------------------------------------------
The command drives the same select the composer shows, so there is one
piece of state and the control updates itself when the command is used. */
@@ -171,7 +207,11 @@
function setEffort(rest) {
var select = el("[data-effort]");
if (!select) {
- return note("This model is not marked as a reasoning model.", "error");
+ return note(
+ "This model is not marked as a reasoning model, so effort would do " +
+ "nothing. An administrator can mark it on the model's page.",
+ "error"
+ );
}
var wanted = (rest || "").trim().toLowerCase();
if (!wanted) {
diff --git a/src/lembas/web/static/js/composer.js b/src/lembas/web/static/js/composer.js
index 6e84669..f68c771 100644
--- a/src/lembas/web/static/js/composer.js
+++ b/src/lembas/web/static/js/composer.js
@@ -273,6 +273,24 @@
} else if (option.dataset.mentionKnowledge) {
body.append("document_id", option.dataset.mentionKnowledge);
attach("/api/files/from-knowledge", body);
+ } else if (option.dataset.mentionNote) {
+ body.append("note_id", option.dataset.mentionNote);
+ attach("/api/files/from-note", body);
+ } else if (option.dataset.mentionSkill) {
+ body.append("skill_id", option.dataset.mentionSkill);
+ attach("/api/files/from-skill", body);
+ } else if (option.dataset.mentionAttachment) {
+ body.append("attachment_id", option.dataset.mentionAttachment);
+ attach("/api/files/from-attachment", body);
+ } else if (option.dataset.mentionUrl) {
+ body.append("url", option.dataset.mentionUrl);
+ attach("/api/files/link", body);
+ } else if (option.dataset.mentionBase) {
+ /* Not an attachment: nothing is copied, and what changes is what this
+ chat is allowed to search. It goes on the chat, so the route is the
+ chat's own. */
+ body.append("base_id", option.dataset.mentionBase);
+ attach("/api/chats/" + where.chatId + "/bases", body);
}
}
diff --git a/src/lembas/web/static/js/terminal.js b/src/lembas/web/static/js/terminal.js
index d48ed7b..1105dc2 100644
--- a/src/lembas/web/static/js/terminal.js
+++ b/src/lembas/web/static/js/terminal.js
@@ -35,7 +35,11 @@
/* Whether this shell tells us where commands begin and end -- "live",
"loading" or "none". Everything the three buttons do keys off it. */
var integration = "loading";
- var autoSend = false;
+ /* "off" | "copy" | "send". Three states rather than a boolean, because the
+ old one did the wrong one of them: it appended into the composer, on top of
+ whatever was being typed there. A select rather than a cycling button --
+ a button cannot say which of three states it is in. */
+ var autoMode = "off";
var lastCommand = null;
function say(text, isError) {
@@ -169,8 +173,12 @@
and the buttons fetch what they need when they are pressed. */
if (integration !== "live") { integration = "live"; applyIntegration(); }
showLast(payload.command);
- if (autoSend) {
- capture(true).then(function (text) { intoComposer(text, true); });
+ if (autoMode !== "off") {
+ capture(true).then(function (text) {
+ if (!text) return;
+ if (autoMode === "copy") return intoComposer(text, true);
+ sendStraightToChat(text);
+ });
}
return;
}
@@ -287,10 +295,10 @@
var usable = integration === "live";
auto.disabled = !usable;
auto.title = usable
- ? "Attach every command you run to your next message"
+ ? "What to do with each command you run"
: "This shell did not load LLeMbas's command markers, so there is no way " +
"to tell where one command's output ends.";
- if (!usable && autoSend) setAuto(false);
+ if (!usable && autoMode !== "off") setAuto("off");
}
function showLast(command) {
@@ -301,16 +309,32 @@
slot.textContent = lastCommand ? lastCommand.summary : "";
}
- function setAuto(on) {
- autoSend = !!on;
- var button = panel.querySelector("[data-terminal-auto]");
- if (button) {
- button.setAttribute("aria-pressed", autoSend ? "true" : "false");
- button.classList.toggle("is-active", autoSend);
- }
- say(autoSend
- ? "Every command you run will be attached to your next message."
- : "Commands are no longer attached automatically.");
+ var AUTO_SAID = {
+ off: "Commands are no longer attached automatically.",
+ copy: "Every command you run will be put into the message box.",
+ send: "Every command you run will be sent as a message on its own."
+ };
+
+ function setAuto(mode) {
+ autoMode = AUTO_SAID[mode] ? mode : "off";
+ var select = panel && panel.querySelector("[data-terminal-auto]");
+ if (select && select.value !== autoMode) select.value = autoMode;
+ say(AUTO_SAID[autoMode]);
+ }
+
+ /* Sent, not typed. The composer is left entirely alone -- somebody may be
+ half-way through a sentence in it, and overwriting that is the complaint
+ this replaces. The thread receives whatever the server decides the message
+ is: a streaming pair, or a single queued bubble if a reply is already being
+ written. Nothing here needs to know which. */
+ function sendStraightToChat(text) {
+ var url = panel.dataset.url.replace(/\/terminal\/ws$/, "/messages");
+ if (!window.htmx) return;
+ window.htmx.ajax("POST", url, {
+ target: "#thread",
+ swap: "beforeend",
+ values: { content: text }
+ });
}
/* A selection always wins, in every state. People rely on it, and it is the
@@ -427,10 +451,11 @@
event.preventDefault();
return copyToClipboard();
}
- if (event.target.closest("[data-terminal-auto]")) {
- event.preventDefault();
- return setAuto(!autoSend);
- }
+ });
+
+ panel.addEventListener("change", function (event) {
+ var select = event.target.closest("[data-terminal-auto]");
+ if (select) setAuto(select.value);
});
/* xterm holds colours as values, not as variables, so a theme change has
diff --git a/src/lembas/web/templates/chat/_base_chip.html b/src/lembas/web/templates/chat/_base_chip.html
new file mode 100644
index 0000000..b0f8747
--- /dev/null
+++ b/src/lembas/web/templates/chat/_base_chip.html
@@ -0,0 +1,19 @@
+{% from "_macros.html" import icon %}
+{#
+ A knowledge base attached from the `@` menu.
+
+ Deliberately not an attachment chip: nothing was copied and there is no
+ `file_ids` input to submit. The base is already on the chat, and what it does
+ is narrow what `knowledge_search` may see -- so this says so, and says it in
+ the present tense, because it is already in force before the message is sent.
+
+ No remove button. Removing one is a checkbox in the chat's settings, where
+ the whole set is visible at once rather than only whichever was added last.
+#}
+
+ {{ icon("archive", "icon--sm") }}
+
+ {{ base.name }}
+ This chat now searches only the bases it is attached to
+
+
diff --git a/src/lembas/web/templates/chat/_composer.html b/src/lembas/web/templates/chat/_composer.html
index 5e5f52e..60d7ea2 100644
--- a/src/lembas/web/templates/chat/_composer.html
+++ b/src/lembas/web/templates/chat/_composer.html
@@ -215,10 +215,16 @@
they were taking a slot in a row that has work to do.
Its own form: nesting one inside the composer's form is invalid HTML
- and the browser drops the inner one. #}
+ and the browser drops the inner one.
+
+ The verb is on the select, not on that form. htmx binds a trigger to
+ the annotated element itself, and `change` fires here and bubbles
+ through this element's *ancestors* -- which a sibling form is not.
+ `form=` scopes the values, and only the values. #}
- {# Outside the composer's form, and referenced by the mode select's `form`
- attribute above. hx-patch and not hx-post: there is no POST for a chat,
- only PATCH, and htmx shows nothing when a request 405s -- which is how
- this control spent its whole life doing nothing. #}
+ {# Outside the composer's form, and referenced by the two selects' `form`
+ attributes above. These carry no htmx of their own: they exist so that
+ `Nt(e)` -- htmx's "which form do the values come from", which reads
+ `e.form` before falling back to `closest("form")` -- resolves to a form
+ holding exactly one control. Without them the PATCH would carry the
+ composer's `content`, `model_id` and `project_dir`, and `update_chat`
+ answers `project_dir` with a 409.
+
+ hx-patch and not hx-post: there is no POST for a chat, only PATCH, and
+ htmx shows nothing when a request 405s -- which is how these controls
+ spent the first half of their lives doing nothing. #}
{% if chat and chat.kind == "agent" %}
-
+
{% endif %}
{% if chat %}
-
+
{% endif %}
diff --git a/src/lembas/web/templates/chat/_mention_picker.html b/src/lembas/web/templates/chat/_mention_picker.html
index 7a9d7d4..5d0424d 100644
--- a/src/lembas/web/templates/chat/_mention_picker.html
+++ b/src/lembas/web/templates/chat/_mention_picker.html
@@ -11,7 +11,8 @@
all of it is escaped by autoescaping and none of it is marked safe.
#}
- {% if not files and not documents %}
+ {% if not files and not documents and not notes and not skills
+ and not bases and not attachments and not website %}
+ {% endif %}
+
+ {% if bases %}
+ {# A base is a reference and not a copy: choosing one narrows what this chat
+ may search rather than putting anything into the message. #}
+
Search only these
+
+ {% for base in bases %}
+
+
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% if attachments %}
+
Already in this chat
+
+ {% for attachment in attachments %}
+
+
+
+ {% endfor %}
+
+ {% endif %}
+
{% endif %}
diff --git a/src/lembas/web/templates/chat/_message.html b/src/lembas/web/templates/chat/_message.html
index 6f68c3c..2bf2227 100644
--- a/src/lembas/web/templates/chat/_message.html
+++ b/src/lembas/web/templates/chat/_message.html
@@ -13,8 +13,16 @@
escaped plain text for everyone else.
#}
{% set streaming = (message.role == "assistant" and not message.complete) %}
+{#
+ 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. It must never
+ carry `sse-connect` -- a queued turn with a streaming shell on it would be the
+ second concurrent reply the queue exists to prevent.
+#}
+{% set queued = (message.role == "user" and message.queued) %}
-
{% endif %}
- {% if not streaming %}
+ {% if queued %}
+ {# Nothing has been sent to any model. Both buttons sit on the bubble they
+ act on rather than in a toast somewhere, and Edit is deliberately absent:
+ editing rewinds and then starts a reply, which on a row you can press
+ while another reply is streaming is a second concurrent generation behind
+ a pencil. Discard and retype is the honest affordance. #}
+
+