A folder that carries something, and a way to name one

A folder was a name and nothing else -- and not even that, since PATCH could
rename one and nothing in the interface ever called it. It now carries a
description, a system prompt, and seeds for the model, the kind and the agent
target, with a settings page behind the row.

The prompt is a fourth rung on the ladder, chat > folder > model > instance,
and it goes above the model deliberately: a model's prompt describes the model
wherever it is used, a folder's describes this piece of work whichever model
is pointed at it. It is read when a reply is built rather than copied when a
chat is made, so editing it reaches the chats already there, and the walk up
the parents is bounded and cycle-safe because it runs on the request path.
`api/pages.py` mirrors the ladder for the settings panel and had to gain the
same rung -- a panel naming the wrong source is worse than one naming none,
because it is believed.

The seeds fill in what the request left empty and nothing it filled in: the
folder says what this work usually needs, the screen in front of somebody says
what they want this time. `ssh_profile_id` is a plain string rather than a
foreign key, for the reason `compacted_through_id` is, so it is validated on
read.

Getting *into* a folder needed fixing too. `/api/chats/start` has accepted a
folder_id since folders existed and nothing ever sent one, so the only route in
was to make the chat elsewhere and move it. There is a New chat here on the row
now, and `?folder=` on the new-chat screen.

Naming is a themed dialog, and deliberately not htmx's hx-prompt: htmx calls
the browser's prompt() synchronously and only then fires htmx:prompt with the
answer already in hand, so intercepting the event cannot supply a different one
and the grey box appears anyway. `data-prompt` follows the data-confirm-button
shape instead -- swallow the click, ask, write the answer into hx-vals,
click again behind a guard. JSON.stringify rather than concatenation, or a
folder called `"` produces hx-vals that does not parse and the rename silently
does nothing. Driven under a DOM stub, and there is a test that no template
brings hx-prompt back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 08:30:49 +02:00
parent 9c61e40662
commit b602657450
12 changed files with 821 additions and 16 deletions
+32 -2
View File
@@ -149,23 +149,53 @@ def message_payload(message: Message, *, vision: bool) -> dict[str, Any]:
return {"role": message.role, "content": parts}
def folder_system_prompt(db: DBSession, chat: Chat) -> str:
"""The nearest prompt on the chat's folder, or on a folder above it.
Walks up rather than reading one level, because folders nest and a project's
prompt belongs on the project rather than on each sub-folder of it. The
nearest one wins, which is the same rule the ladder as a whole follows.
Bounded and cycle-safe the way `api/folders.py:_depth_of` is. Reparenting
already refuses to build a cycle, but this runs on the request path for
every reply and a row written by something else must not be able to hang it.
"""
from lembas.db.models import Folder
folder = chat.folder
seen: set[str] = set()
while folder is not None and folder.id not in seen:
seen.add(folder.id)
if (folder.system_prompt or "").strip():
return folder.system_prompt.strip()
folder = db.get(Folder, folder.parent_id) if folder.parent_id else None
return ""
def effective_system_prompt(db: DBSession, chat: Chat) -> str:
"""The system prompt a chat actually runs with.
Three layers, most specific wins outright:
Four layers, most specific wins outright:
chat > model > instance
chat > folder > model > instance
Precedence rather than concatenation. Stacking them reads well in a
settings screen and badly in practice: the moment two layers disagree the
model gets contradictory instructions and nobody can tell which one is
losing. With precedence, "why is it behaving like this" has one answer.
The folder sits above the model because it is the more specific statement:
a model's prompt describes the model wherever it is used, and a folder's
describes this piece of work whichever model is pointed at it.
"""
from lembas.services import settings_store
if chat.system_prompt.strip():
return chat.system_prompt.strip()
if inherited := folder_system_prompt(db, chat):
return inherited
model = db.scalar(
select(Model).where(Model.model_id == chat.model_id).order_by(Model.position)
)