diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index b8722e3..af578d6 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -55,6 +55,11 @@ KEEPALIVE_AFTER = 15.0 # better than four hundred rows nobody meant to write. MAX_QUEUED = 10 +# How many things one chat may have switched off. There are a dozen families and +# sixty skills at most, so this is not a limit anybody reaches by hand -- it is +# there so a crafted POST cannot grow the column without bound. +MAX_SCOPE_KEYS = 200 + def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat: chat = db.get(Chat, chat_id) @@ -135,10 +140,19 @@ def _new_chat( 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. + # over the administrator's. + # + # `"off"` is a sentinel, and it has to be: `reasoning_effort` arrives as + # `Form("")`, so an absent field and an empty one are indistinguishable -- + # the FastAPI trap this codebase has already been bitten by once. With + # `value=""` on the off option, the reader would pick "off", the value would + # fall out of EFFORTS, the model's default seeded above would stay, and they + # would silently get "high". The picker shows what will be sent, so the two + # have to agree. wanted_effort = reasoning_effort.strip().lower() - if wanted_effort in chat_service.EFFORTS: + if wanted_effort == "off": + chat.params_json = {k: v for k, v in chat.params_json.items() if k != "reasoning_effort"} + elif wanted_effort in chat_service.EFFORTS: chat.params_json = {**chat.params_json, "reasoning_effort": wanted_effort} db.add(chat) db.commit() @@ -518,6 +532,54 @@ async def attach_base( ) +@router.post("/{chat_id}/scope") +async def set_scope( + db: Db, + user: RequiredUser, + chat_id: str, + kind: str = Form(""), + name: str = Form(""), + on: bool = Form(False), +) -> Response: + """Turn one thing this chat may use on or off. **Narrowing only.** + + Nothing here widens anything. `resolve_tools` applies this *after* the + model's capabilities, the reader's permissions and the instance + configuration, so a crafted POST turning something on reaches a tool those + gates have already removed -- there is a test for exactly that. + + On is stored by **removing** the key rather than by writing True, so absent + stays the single representation of "on" and the column cannot grow a row per + family per chat. Bounded, so a crafted request cannot grow it either. + + JSON reassignment rather than mutation: a plain dict assignment into a JSON + column is not detected. + """ + chat = _owned_chat(db, chat_id, user.id) + bucket = {"family": "families", "skill": "skills"}.get(kind.strip()) + wanted = name.strip()[:64] + if bucket is None or not wanted: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Say what to turn on or off.") + + scope = dict(chat.scope_json or {}) + entries = dict(scope.get(bucket) or {}) + if on: + entries.pop(wanted, None) + else: + if len(entries) >= MAX_SCOPE_KEYS: + raise HTTPException(status.HTTP_409_CONFLICT, "Too many things switched off.") + entries[wanted] = False + + if entries: + scope[bucket] = entries + else: + scope.pop(bucket, None) + chat.scope_json = scope + db.commit() + + return Response(status_code=status.HTTP_204_NO_CONTENT) + + @router.post("/{chat_id}/keep") async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response: """Stop a temporary chat being temporary. @@ -1373,20 +1435,33 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str **_clean_params(**{k: str(v) for k, v in submitted_params.items()}), } - # Not a number, so it cannot go through _PARAM_RANGES. Empty means clear it, - # the same as every other parameter here; anything that is not one of the - # three is ignored rather than refused, so a typo does not cost a message. + # Not a number, so it cannot go through _PARAM_RANGES. `"off"` and empty + # both clear it -- the sentinel because that is what the picker sends now, + # empty because anything still posting the old value must keep working. + # Anything that is neither is ignored rather than refused, so a typo does + # not cost a message. if "reasoning_effort" in form: if not allowed.get("chat.params"): raise HTTPException( status.HTTP_403_FORBIDDEN, "You may not change sampling parameters." ) wanted = str(form["reasoning_effort"]).strip().lower() - if not wanted: + if not wanted or wanted == "off": chat.params_json = {**(chat.params_json or {}), "reasoning_effort": None} elif wanted in chat_service.EFFORTS: chat.params_json = {**(chat.params_json or {}), "reasoning_effort": wanted} + # Switching model re-seeds an effort that was never chosen, so "what the + # picker shows is what is sent" stays true afterwards. Only when the key is + # ABSENT: `None` means somebody cleared it deliberately, and resurrecting + # that would make "off" silently do nothing on the next model change. + if model_id and "reasoning_effort" not in (chat.params_json or {}): + seeded = ((match.params_json if match is not None else None) or {}).get( + "reasoning_effort" + ) + if seeded in chat_service.EFFORTS: + chat.params_json = {**(chat.params_json or {}), "reasoning_effort": seeded} + db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index 7038006..b0115eb 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -62,11 +62,87 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict: # command, the control and the request builder cannot disagree about # what is a valid effort. "efforts": chat_service.EFFORTS, + # What the picker shows, and what `build_request` will send. One + # resolver so the two cannot disagree. + "resolved_effort": chat_service.resolved_effort(chat) if chat else "", + **_scope_context(db, user, chat), **_agent_context(db, user, chat), **audio_service.template_flags(db, user), } +def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict: + """What this chat may use, for the menu that narrows it. + + Only for an existing chat: there is no row to write to before one exists, + and a menu whose choices went nowhere would be worse than no menu. The + families listed are the ones actually offered *right now*, so the menu never + shows a switch for something the model, the reader's permissions or the + instance has already ruled out -- turning that on would do nothing, since + `resolve_tools` applies this after the gates. + """ + from lembas.services import tool_labels + from lembas.services import tools as tools_service + from lembas.services.library import skills as skills_service + + if chat is None: + return {"scope_families": [], "scope_skills": []} + + off = tools_service.scoped_off(chat) + skills_off = tools_service.scoped_skills_off(chat) + + # Gates rather than tool names: `notes` is one switch, not five, which is + # the same reasoning the per-model capability checkboxes carry. + seen: dict[str, str] = {} + for tool in tools_service.resolve_tools(db, chat, user).defs: + seen.setdefault(tools_service.gate_of(tool.family), tool.name) + # Anything already switched off is absent from the offered set, so it has to + # be put back or there would be no way to turn it on again. + for gate in off: + seen.setdefault(gate, "") + + families = [ + { + "gate": gate, + "label": _GATE_LABELS.get(gate) or tool_labels.label_for(example) or gate, + "on": gate not in off, + } + for gate, example in sorted(seen.items()) + ] + + skills = [] + if permissions.has(db, user, "library.use"): + skills = [ + { + "name": skill.name, + "description": skill.description, + "on": skill.name not in skills_off, + } + for skill in skills_service.enabled_for(db, user) + ] + for name in sorted(skills_off): + if name not in {s["name"] for s in skills}: + skills.append({"name": name, "description": "", "on": False}) + + return {"scope_families": families, "scope_skills": skills} + + +# What a gate is called in the menu. A gate covers several tools, so no single +# tool's label is the right name for it. +_GATE_LABELS = { + "web_search": "Web search", + "fetch": "Fetching pages", + "knowledge": "Your knowledge library", + "notes": "Notes", + "memory": "Memory", + "skills": "Skills", + "ask": "Asking you questions", + "agent": "Running commands", + "custom": "Custom tools", + "mcp": "MCP servers", +} + + def _agent_context(db: DBSession, user: User, chat: Chat | None) -> dict: """What the composer and the chat header need to know about agent chats. diff --git a/src/lembas/db/models/chat.py b/src/lembas/db/models/chat.py index 901d2ec..10d00c4 100644 --- a/src/lembas/db/models/chat.py +++ b/src/lembas/db/models/chat.py @@ -151,6 +151,12 @@ class Chat(UUIDPrimaryKey, Timestamps, Base): # message with a plan" -- `context_variables` is synchronous and on the # request path. A plan a model cannot see is a plan it cannot keep current. plan_message_id: Mapped[str | None] = mapped_column(String(32)) + # What this chat has switched off, narrowing what it is already allowed. + # {"families": {"web_search": false}, "skills": {"weekly-report": false}}. + # **Absent means on**, for every key -- the same convention + # `McpServer.tool_overrides_json` uses, and for the same reason: two + # representations of "on" makes "why is this off?" unanswerable. + scope_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) # --- Compaction ---------------------------------------------------------- # A summary of the turns up to `compacted_through_id`, sent in their place. diff --git a/src/lembas/services/chat.py b/src/lembas/services/chat.py index 69c81d9..e4e49a8 100644 --- a/src/lembas/services/chat.py +++ b/src/lembas/services/chat.py @@ -332,6 +332,25 @@ def build_request( EFFORTS = ("low", "medium", "high") +def resolved_effort(chat) -> str: + """The effort this chat will actually send, or "" for none. + + Its own value, and nothing else. The model's default is a **seed** applied + when the chat is created (`api/chats.py:_new_chat`) and on a model change, + and is deliberately not consulted here for two reasons. A chat's request + should be a function of the chat row alone -- the same rule that has PDF + text extracted once at upload and knowledge attachments copied. And a + fallback would break "off": `update_chat` stores `None` for a cleared + effort, a fallback would resurrect the model's default underneath it, and + the off option would silently do nothing. + + The picker shows exactly this, which is the whole point of it existing: + "Effort: default" named no level and was true of nothing in particular. + """ + value = (getattr(chat, "params_json", None) or {}).get("reasoning_effort") + return value if value in EFFORTS else "" + + def apply_effort(body: dict[str, Any], effort: str | None) -> None: """Put a chosen reasoning effort into a request body, in both forms.""" if not effort or effort not in EFFORTS: diff --git a/src/lembas/services/harness.py b/src/lembas/services/harness.py index 5f86163..fe0254b 100644 --- a/src/lembas/services/harness.py +++ b/src/lembas/services/harness.py @@ -133,7 +133,11 @@ def context_variables( "memory_limit": str(memories_service.MAX_MEMORY_CHARS), "tool_names": _tool_names(offered), "memories": memories_service.block(db, user) if "memory" in families else "", - "skills": skills_service.index_block(db, user) if "skills" in families else "", + "skills": ( + skills_service.index_block(db, user, exclude=tools_service.scoped_skills_off(chat)) + if "skills" in families + else "" + ), "knowledge_bases": "", "document_names": "", "agent_target": "", diff --git a/src/lembas/services/library/memories.py b/src/lembas/services/library/memories.py index 6dcec30..6560df2 100644 --- a/src/lembas/services/library/memories.py +++ b/src/lembas/services/library/memories.py @@ -57,23 +57,45 @@ def get(db: DBSession, memory_id: str, user: User | None) -> Memory | None: def add(db: DBSession, *, owner: User, content: str, author: str = AUTHOR_MODEL) -> Memory: - """Record a fact. Raises ValueError when there is no room or nothing to say.""" + """Record a fact. Raises ValueError when there is no room or nothing to say. + + An exact repeat returns the record that already exists rather than making a + second one. The prompt asks the model to check before adding -- it is shown + every memory, so it can -- but the same preference saved four times in + slightly different words is the commonest failure here, and it is worse than + wasted tokens: it makes `memory_forget` ambiguous for every one of them. + Wording handles the near-duplicates; this handles the exact ones, which is + the half a prompt cannot be relied on for. + """ content = " ".join((content or "").split()) if not content: raise ValueError("A memory cannot be empty.") + content = content[:MAX_MEMORY_CHARS] + + existing = db.scalars( + select(Memory).where(Memory.owner_id == owner.id, Memory.content == content) + ).first() + if existing is not None: + return existing count = db.scalar( select(func.count()).select_from(Memory).where(Memory.owner_id == owner.id) ) if (count or 0) >= MAX_RECORDS: + # Deliberately does NOT say "remove one first". Past MAX_TOTAL_CHARS the + # injected block is truncated, so the model is not shown every memory + # and would be choosing blind -- and deleting the wrong one is not + # something anybody finds out about. raise ValueError( - f"There are already {MAX_RECORDS} memories. Remove one first, or put " - f"this in a note instead." + f"There are already {MAX_RECORDS} memories, which is the limit, so " + f"nothing was saved. Do not remove one to make room — you are not " + f"shown all of them and would be guessing. Say that the limit has " + f"been reached, and put this in a note instead." ) memory = Memory( owner_id=owner.id, - content=content[:MAX_MEMORY_CHARS], + content=content, author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_MODEL, ) db.add(memory) diff --git a/src/lembas/services/library/skills.py b/src/lembas/services/library/skills.py index 22c99dd..5acd45b 100644 --- a/src/lembas/services/library/skills.py +++ b/src/lembas/services/library/skills.py @@ -23,6 +23,7 @@ from __future__ import annotations import logging import re +from collections.abc import Iterable from sqlalchemy import select from sqlalchemy.orm import Session as DBSession @@ -72,18 +73,34 @@ def by_name(db: DBSession, name: str, user: User | None) -> Skill | None: return db.scalar(visible(db, user).where(Skill.name == slugify(name))) -def enabled_for(db: DBSession, user: User | None) -> list[Skill]: - """Skills that should appear in the index, oldest first for a stable order.""" +def enabled_for( + db: DBSession, user: User | None, *, exclude: Iterable[str] = () +) -> list[Skill]: + """Skills that should appear in the index, oldest first for a stable order. + + `exclude` is what one chat has switched off by name -- a narrowing of what + the library already allows, never a widening of it. + """ if user is None: return [] - return list( - db.scalars( - visible(db, user) - .where(Skill.enabled.is_(True)) - .order_by(Skill.name) - .limit(MAX_INDEX_SKILLS) - ) + hidden = {slugify(name) for name in exclude} + rows = db.scalars( + visible(db, user) + .where(Skill.enabled.is_(True)) + .order_by(Skill.name) + .limit(MAX_INDEX_SKILLS + len(hidden)) ) + return [skill for skill in rows if skill.name not in hidden][:MAX_INDEX_SKILLS] + + +def count_enabled(db: DBSession, user: User | None, *, exclude: Iterable[str] = ()) -> int: + """How many skills are available here at all. + + Zero is what withdraws `skill_get` and `skill_edit`: reading and improving + are meaningless with nothing to read, and a model told to "read one with + skill_get" above a list that is not there spends a round finding out. + """ + return len(enabled_for(db, user, exclude=exclude)) def search(db: DBSession, user: User | None, needle: str, *, limit: int = 10) -> list[Skill]: @@ -199,9 +216,9 @@ def delete(db: DBSession, skill: Skill) -> None: db.commit() -def index_block(db: DBSession, user: User | None) -> str: +def index_block(db: DBSession, user: User | None, *, exclude: Iterable[str] = ()) -> str: """The one-line-per-skill listing that goes into the prompt.""" - skills = enabled_for(db, user) + skills = enabled_for(db, user, exclude=exclude) if not skills: return "" return "\n".join(f"- {skill.name}: {skill.description}" for skill in skills) diff --git a/src/lembas/services/prompts.py b/src/lembas/services/prompts.py index 3cedcdf..c844221 100644 --- a/src/lembas/services/prompts.py +++ b/src/lembas/services/prompts.py @@ -801,7 +801,8 @@ BUILTIN: tuple[Fragment, ...] = ( "would be tedious to work out again: a procedure, a decision and its reasons, " "a summary of a long document. Correct one with notes_edit when it turns out " "to be wrong, and remove it with notes_delete when it is no longer true — a " - "stale note is worse than no note." + "stale note is worse than no note. Anything short and durable about the " + "person themselves is a memory rather than a note." ), ), Fragment( @@ -813,32 +814,58 @@ BUILTIN: tuple[Fragment, ...] = ( variables=("memory_limit",), hint="Appears when memory_add and memory_forget are offered. What is " "remembered costs tokens on every request forever, which is why the " - "wording is about restraint.", + "wording is about restraint — and why it says to read what is already " + "there first: the same fact stored twice in different words costs the " + "window twice and makes either one ambiguous to remove afterwards.", default=( "- You can remember durable facts about this person — a preference, a " "constraint, a name, how they like to be addressed. Use memory_add for those: " - "one fact each, under {{memory_limit}} characters. Do not remember the details " - "of a single task, anything that will be untrue next month, or anything " - "secret — keys, passwords, or health details they have not asked you to keep. " + "one fact each, under {{memory_limit}} characters. Everything remembered is " + "already in this message, so read it before adding: saying the same thing " + "again in different words costs the window twice and makes either one hard " + "to remove afterwards. Do not remember the details of a single task, anything " + "that will be untrue next month, or anything secret — keys, passwords, or " + "health details they have not asked you to keep. Anything longer than a " + "sentence, or about the work rather than about them, does not belong here. " "When something you remembered turns out to be wrong, remove it with " - "memory_forget rather than adding a correction beside it." + "memory_forget, quoting it in full, rather than adding a correction beside it." ), ), Fragment( key="tool.skills", - label="Skills", + label="Skills: reading one", group=GROUP_TOOLS, order=240, families=("skills",), - hint="Appears when the skill tools are offered.", + requires=("skills",), + hint="Only once there is at least one skill. This used to be one " + "fragment gated on the family alone, so a person with no skills got " + "'the list below gives each one's name' above no list, and skill_get " + "in the tools array — which is exactly why models hunt for skills that " + "do not exist. The writing half is its own fragment below, because " + "that half is most useful precisely when there are none.", default=( "- Skills are procedures you have saved. The list below gives only each one's " "name and when to use it; read the full instructions with skill_get before " - "following one. If you work out a repeatable way to do something, save it with " - "skill_create. If following one shows it to be wrong or incomplete, improve it " + "following one. If following one shows it to be wrong or incomplete, improve it " "with skill_edit and say why — the previous version is kept and can be restored." ), ), + Fragment( + key="tool.skills_write", + label="Skills: saving one", + group=GROUP_TOOLS, + order=241, + families=("skills",), + hint="The other half, and deliberately NOT gated on there being any: " + "somebody with no skills is exactly who most needs to be told they can " + "save the first one.", + default=( + "- If you work out a repeatable way to do something you expect to be asked for " + "again, save it with skill_create. The description has to say when to use it, " + "since that is all you will see next time." + ), + ), # --- Context ------------------------------------------------------------- Fragment( key="context.knowledge_scope", @@ -864,11 +891,15 @@ BUILTIN: tuple[Fragment, ...] = ( variables=("memories",), requires=("memories",), hint="The remembered facts themselves, injected whole on every turn. " - "Skipped entirely when there are none.", + "Skipped entirely when there are none. It used to say these 'still " + "apply', which nothing checks — and which taught a model to trust a " + "stale memory over what the person had just said.", default=( "### What you know about this person\n" "\n" - "The following was remembered in earlier conversations and still applies.\n" + "These were remembered in earlier conversations. If something here is " + "contradicted by what they say now, believe them and remove it with " + "memory_forget.\n" "\n" "{{memories}}" ), diff --git a/src/lembas/services/tools.py b/src/lembas/services/tools.py index 2d06bc4..5460dce 100644 --- a/src/lembas/services/tools.py +++ b/src/lembas/services/tools.py @@ -160,6 +160,11 @@ class ToolContext: # the decrypted credential. None everywhere else, which is what every agent # runner checks first. `generation` clears it when the reply ends. agent: Any = None + # Skills this chat has switched off, by name. Enforced in `_run_skill_get` + # and not only in the listing: without that the narrowing is advisory, since + # a model can name a skill it was never shown and the runner would fetch it + # anyway. Same rule as "what may be run is what was offered". + skills_off: frozenset[str] = field(default_factory=frozenset) @dataclass @@ -530,18 +535,46 @@ async def _run_memory_add(context: ToolContext, args: dict[str, Any]) -> ToolOut async def _run_memory_forget(context: ToolContext, args: dict[str, Any]) -> ToolOutcome: + """Remove one memory, or refuse and say why. + + Exact match first, then substring, and an ambiguous substring removes + nothing. This used to be a case-insensitive substring FIRST-match delete + with nothing warning about it, so `memory_forget("coffee")` against "Drinks + coffee black" and "Allergic to coffee" silently deleted whichever was older + -- a wrong deletion nobody would ever find out about, from a tool whose + description invited exactly the short fragment that misfires. + + Exact-first is not a nicety: without it, quoting a memory in full still + fails whenever that text happens to be a substring of another one. + """ wanted = str(args.get("content") or "").strip().lower() with session_scope() as db: user = db.get(User, context.owner_id) records = memories_service.all_for(db, user) - match = next((m for m in records if wanted and wanted in m.content.lower()), None) - if match is None: + if not wanted: + return ToolOutcome( + "Say which memory to remove, quoting its text.", + {"name": "memory_forget", "status": "error", "error": "Nothing given."}, + ) + + exact = [m for m in records if m.content.strip().lower() == wanted] + matches = exact or [m for m in records if wanted in m.content.lower()] + + if not matches: return ToolOutcome( "No memory matches that. The full list is in the prompt already.", {"name": "memory_forget", "status": "error", "error": "No match."}, ) - content = match.content - memories_service.delete(db, match) + if len(matches) > 1: + listed = "\n".join(f"- {m.content}" for m in matches[:10]) + return ToolOutcome( + f"That matches {len(matches)} memories, so nothing was removed. " + f"Quote the whole text of the one you mean:\n{listed}", + {"name": "memory_forget", "status": "error", "error": "Ambiguous."}, + ) + + content = matches[0].content + memories_service.delete(db, matches[0]) return ToolOutcome( f"Forgotten: {content}", {"name": "memory_forget", "query": content, "status": "ok", "results": []}, @@ -554,6 +587,13 @@ async def _run_skill_get(context: ToolContext, args: dict[str, Any]) -> ToolOutc with session_scope() as db: user = db.get(User, context.owner_id) skill = skills_service.by_name(db, name, user) + # Enforced here and not only in the listing. Without this the per-chat + # narrowing is advisory: a model can name a skill it was never shown -- + # from an earlier turn, from a note -- and the runner would fetch it. + if skill is not None and skill.name in { + skills_service.slugify(off) for off in context.skills_off + }: + skill = None if skill is None: return ToolOutcome( f"There is no skill called {name!r}.", @@ -778,9 +818,13 @@ REGISTRY: dict[str, ToolDef] = { family=FAMILY_MEMORY, description=( "Remember one short, durable fact about the user — a preference, a " - "constraint, how they like to be addressed. You are shown every " - "memory on every turn, so keep them few and short, and never store " - "passwords, keys or anything else secret." + "constraint, a name, how they like to be addressed. Every memory is " + "put in front of you on every turn, up to a budget, so keep them few " + "and keep them short; text over the limit is shortened rather than " + "refused, and you are told. Check what is already remembered before " + "adding: a fact you have stored already in slightly different words " + "costs the same again and makes both of them harder to remove. Never " + "store a password, a key or anything else secret." ), parameters=_object( {"content": {**_STRING, "description": "One fact, in one sentence."}}, @@ -793,10 +837,16 @@ REGISTRY: dict[str, ToolDef] = { name="memory_forget", family=FAMILY_MEMORY, description=( - "Remove a memory that has become wrong. Give enough of its text to " - "identify it." + "Remove a memory that is no longer true. Quote it in full — the " + "whole sentence as it appears in your prompt. A fragment that " + "matches more than one removes nothing and tells you which ones it " + "matched, because deleting the wrong memory is not something anyone " + "would find out about." + ), + parameters=_object( + {"content": {**_STRING, "description": "The memory's whole text."}}, + ["content"], ), - parameters=_object({"content": _STRING}, ["content"]), run=_run_memory_forget, risk=RISK_WRITE, ), @@ -1035,6 +1085,17 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet: # Resolved against what this reader may see, not against everything that # exists: a tool restricted to a group is not offered outside it. book = _book([*_row_defs(db, user), *_agent_defs(db, chat, user)]) + + # What this chat has switched off, applied AFTER the gates and never + # instead of them. A chat can only ever *narrow* what the model's + # capabilities, the reader's permissions and the instance configuration + # already allow -- exactly as `chat.knowledge_bases` narrows + # `knowledge_search` and can never widen it. A crafted request that turned + # something on here would still be reaching for a tool the gates had + # already removed. + off = scoped_off(chat) + empty_library = not skills_service.count_enabled(db, user, exclude=scoped_skills_off(chat)) + return ToolSet( tuple( tool @@ -1042,10 +1103,42 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet: if _family_allowed( tool.family, config=config, capabilities=capabilities, allowed=allowed ) + and gate_of(tool.family) not in off + # Nothing to read and nothing to improve. Offering `skill_get` with + # no skills is what makes a model spend a round looking one up and + # being told it does not exist -- and `context.skills` already + # vanishes, so the prompt says "read one with skill_get" above a + # list that is not there. `skill_create` stays: writing the first + # one is exactly what somebody with none needs. + and not (empty_library and tool.name in _NEEDS_A_SKILL) ) ) +# Skills tools that are meaningless with an empty library. +_NEEDS_A_SKILL = frozenset({"skill_get", "skill_edit"}) + + +def scoped_off(chat: Chat | None) -> frozenset[str]: + """Gates this chat has switched off. **Absent means on**, always. + + One representation of "on" -- the key not being there -- so that "why is + this off?" has one answer rather than two. + """ + if chat is None: + return frozenset() + wanted = (getattr(chat, "scope_json", None) or {}).get("families") or {} + return frozenset(str(name) for name, on in wanted.items() if on is False) + + +def scoped_skills_off(chat: Chat | None) -> frozenset[str]: + """Individual skills this chat has switched off, by name.""" + if chat is None: + return frozenset() + wanted = (getattr(chat, "scope_json", None) or {}).get("skills") or {} + return frozenset(str(name) for name, on in wanted.items() if on is False) + + def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str, Any]]: """The tool schemas to offer for this chat. @@ -1070,6 +1163,7 @@ def context_for( owner_id=user.id if user else "", search_config=settings_store.search(db), base_ids=[base.id for base in chat.knowledge_bases] if chat is not None else [], + skills_off=scoped_skills_off(chat), tools=tools.by_name if tools is not None else None, interaction_timeout=float(settings_store.agents(db)["approval_timeout"]), ) diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index fa9ceec..7268f22 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -1053,6 +1053,27 @@ body.is-resizing .terminal__screen { pointer-events: none; } right: auto; } .picker__menu--compact { width: min(18rem, calc(100vw - var(--sp-8))); padding: var(--sp-1); } + +/* "What this chat can use": a list of switches rather than a list of actions. + Wider than the compact menu because a skill's description has to fit, and + scrollable because a library of sixty skills would otherwise run off the top + of the window -- this menu opens upward. */ +.picker__menu--scope { + width: min(22rem, calc(100vw - var(--sp-8))); + max-height: min(26rem, 60vh); + overflow-y: auto; + padding: var(--sp-1); +} +.picker__lede { + margin: 0; + padding: var(--sp-2) var(--sp-3); + font-size: var(--text-xs); + color: var(--ink-faint); +} +/* A label, not a button, so several can be set without the menu closing -- + ui.js closes on `[role="menuitem"]`, and these deliberately have none. */ +.picker__option--toggle { align-items: center; } +.picker__option--toggle input { flex: none; margin: 0; } .picker__option-note { font-size: var(--text-xs); color: var(--ink-faint); diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 04eb597..cd42aa1 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -846,25 +846,54 @@ font-size: 0.95em; } -/* Everything that acts on the message, on one line under it. It wraps rather - than scrolls: on a narrow window the context controls drop to their own row - and attach/send stay where the thumb expects them. */ +/* Everything that acts on the message, on one line under it. ONE line, always. + + It used to wrap, and .composer__actions is last in the DOM with + margin-left:auto -- so the moment an agent chat added a connection, a + directory and a mode to this row, Send and the microphone were what dropped + to a second line. There are no media queries in this file, deliberately, and + the fix is not to add one: it is to say which child gives. */ .composer__toolbar { display: flex; align-items: center; - flex-wrap: wrap; - gap: var(--sp-2); -} -.composer__tools { display: flex; align-items: center; gap: var(--sp-1); flex: none; } -.composer__actions { display: flex; align-items: center; gap: var(--sp-1); margin-left: auto; } -.composer__context { - display: flex; - align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; gap: var(--sp-2); min-width: 0; } -.composer__agent { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; } +.composer__tools { display: flex; align-items: center; gap: var(--sp-1); flex: none; } + +/* Never shrinks, never wraps, always at the end of the line. This is where the + hand is going. */ +.composer__actions { + display: flex; + align-items: center; + gap: var(--sp-1); + flex: none; + margin-left: auto; +} +[data-effort] { flex: none; } + +/* The one thing allowed to give. It shrinks past its content and scrolls + sideways rather than wrapping. The scrollbar is hidden: the controls are + already visibly cut off, and a scrollbar under a --control-h row would change + the row's height, which is the one thing --control-h exists to prevent. */ +.composer__context { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: var(--sp-2); + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; +} +.composer__context::-webkit-scrollbar { display: none; } +.composer__agent { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: nowrap; } + +/* Floors, not fixed widths: a select narrower than this shows no text at all, + which is worse than the scrolling it was avoiding. */ +.composer__context .select { flex: 0 1 auto; min-width: 6rem; } +/* `.segmented` already declares flex: none further down, where it is defined. */ /* Round, and the same size as each other: attach and send read as one pair bracketing the row. */ @@ -872,7 +901,12 @@ /* The directory, on a new chat. Monospace because it is a path, and it grows to fit rather than being pinned to a width that truncates every real one. */ -.composer__dir { max-width: 16rem; font-family: var(--font-mono); font-weight: 400; } +.composer__dir { + flex: 0 1 16rem; + min-width: 5rem; + font-family: var(--font-mono); + font-weight: 400; +} .composer__dir-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .composer__hint { diff --git a/src/lembas/web/static/js/commands.js b/src/lembas/web/static/js/commands.js index c50b6b3..a2dde4e 100644 --- a/src/lembas/web/static/js/commands.js +++ b/src/lembas/web/static/js/commands.js @@ -37,6 +37,9 @@ /* --- Shortcuts ---------------------------------------------------------- */ var SHORTCUTS = [ { keys: "Ctrl/⌘ + K", what: "Open the command menu" }, + { keys: "Ctrl/⌘ + Enter", what: "Send, from anywhere on the page" }, + { keys: "Alt + M", what: "Dictate" }, + { keys: "Alt + R", what: "Read the last reply aloud" }, { keys: "Alt + 1 … 4", what: "Manual, Edit, Auto, Plan" }, { keys: "Alt + T", what: "Terminal" }, { keys: "Alt + I", what: "Inspector" }, @@ -74,7 +77,7 @@ { name: "effort", summary: "How hard a reasoning model should think", - argument: "low | medium | high", + argument: "low | medium | high | off", /* 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 @@ -216,21 +219,25 @@ var wanted = (rest || "").trim().toLowerCase(); if (!wanted) { return note( - select.value - ? "Effort is " + select.value + ". /effort low, medium, high, or default." - : "Effort is whatever the model does by default. Try low, medium or high." + EFFORTS.indexOf(select.value) === -1 + ? "No effort is being sent. Try low, medium or high." + : "Effort is " + select.value + ". /effort low, medium, high, or off." ); } - if (wanted === "default" || wanted === "none") wanted = ""; - else if (EFFORTS.indexOf(wanted) === -1) { - return note("“" + wanted + "” is not an effort. Try low, medium or high.", "error"); + /* "off" is the option's real value, not an empty string: the new-chat form + cannot tell an absent field from an empty one, so the picker sends a + sentinel and this has to match it. "default" and "none" still work, + because somebody's fingers will type them. */ + if (wanted === "default" || wanted === "none") wanted = "off"; + else if (wanted !== "off" && EFFORTS.indexOf(wanted) === -1) { + return note("“" + wanted + "” is not an effort. Try low, medium, high or off.", "error"); } select.value = wanted; select.dispatchEvent(new Event("change", { bubbles: true })); note( - wanted - ? "Effort set to " + wanted + "." - : "Effort cleared; the model decides." + wanted === "off" + ? "Effort cleared; nothing is sent." + : "Effort set to " + wanted + "." ); } @@ -467,8 +474,51 @@ return; } + /* Send, from anywhere on the page. + + Enter already sends, but only with the caret inside the box (app.js), and + deliberately not at all on a touch device. This covers both: after + clicking a message to copy it, after using the model picker, after + answering an approval card, or with a hardware keyboard on a tablet. + + Never Stop. Send and Stop are the same element, so Ctrl+Enter meaning + "abandon the reply" would be a trap -- and Esc already stops. */ + if ((event.ctrlKey || event.metaKey) && + (event.code === "Enter" || event.code === "NumpadEnter")) { + var action = el("[data-composer-action]"); + var box = el("[data-composer-input]"); + if (action && action.dataset.composerAction === "send" && box && box.value.trim()) { + event.preventDefault(); + action.click(); + } + return; + } + if (!event.altKey || event.ctrlKey || event.metaKey) return; + /* Dictation and read-aloud both work by clicking the button that already + does the job, so audio.js keeps its one delegated click listener and + there is no second copy of the recording state machine. Alt+M rather than + Alt+D: Alt+D is the address bar in Chrome and Firefox. */ + if (event.code === "KeyM") { + var mic = el("[data-mic]"); + if (mic) { + event.preventDefault(); + mic.click(); + } + return; + } + if (event.code === "KeyR") { + var speakers = document.querySelectorAll("#thread .msg--assistant [data-speak]"); + if (speakers.length) { + event.preventDefault(); + /* A second press stops it: audio.js already toggles a message that is + speaking, so this costs nothing and is the obvious second press. */ + speakers[speakers.length - 1].click(); + } + return; + } + if (event.code === "KeyT" && el("#terminal")) { event.preventDefault(); return toggle("#terminal", "side"); diff --git a/src/lembas/web/templates/admin/model_detail.html b/src/lembas/web/templates/admin/model_detail.html index 77f6e7d..47dbd84 100644 --- a/src/lembas/web/templates/admin/model_detail.html +++ b/src/lembas/web/templates/admin/model_detail.html @@ -107,7 +107,7 @@

- Where new chats on this model start. Anyone can change it per chat with + A seed, not a per-request setting: it is copied onto a chat when + the chat is created and when somebody switches to this model, and from + then on the chat's own value is what is sent. Changing it here therefore + does nothing to chats that already exist. The composer's picker shows + whichever level is actually in force, so what somebody sees there is + what goes out. Anyone can change it per chat with /effort, and the control only appears on a model marked Reasoning above.
diff --git a/src/lembas/web/templates/chat/_composer.html b/src/lembas/web/templates/chat/_composer.html index 60d7ea2..0a87333 100644 --- a/src/lembas/web/templates/chat/_composer.html +++ b/src/lembas/web/templates/chat/_composer.html @@ -140,13 +140,100 @@

- {# The same menu the `@` key opens, for anyone who would rather press - than type. It inserts the character and gets out of the way. #} - + {% endif %} + + {# + What this chat may use. + + This slot used to be an `@` button that inserted the character and + got out of the way -- which the `@` key already does, from the + keyboard, without a button. Typing `@` is untouched; composer.js + recognises the token on its own and knows nothing about this menu. + + The rows are `