From 7674a70928725b974085db47475445974f3e90e9 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 26 Sep 2026 20:15:43 +0200 Subject: [PATCH 1/3] feat(archivist): answer when addressed by name People in a family room write "Archivist, when does the insurance renew?" rather than picking the bot from a list. Only a picked mention counted, so that question went unanswered. A message on the main timeline that addresses a bot by its name, at the start ("Archivist, ...", "hey archivist ...") or at the end ("..., Archivist?"), now counts as a mention, and the address is dropped from the question. A name in the middle of a sentence does not count. In a thread or a reply only a picked mention counts, so a message there keeps its meaning: for the archivist, a correction of the filing. The rule is the one the agent already used. It moves to `lib/stack/name_trigger.py`; the agent container mounts that one file, so both agree on what counts as being addressed. --- docs/design/brain/who-answers.md | 2 +- docs/design/brain/write-layer.md | 6 +-- .../runtime => lib/stack}/name_trigger.py | 30 +++++++++--- stacklets/agent/Dockerfile | 6 ++- stacklets/agent/docker-compose.yml | 6 +++ stacklets/core/bot-runner/microbot.py | 24 ++++++++-- stacklets/docs/bot/archivist.py | 1 + .../test_name_trigger.py} | 25 +++++++--- .../stacklets/test_agent_runtime_shims.py | 5 +- tests/unit/stacklets/test_microbot_source.py | 48 +++++++++++++++++++ 10 files changed, 131 insertions(+), 22 deletions(-) rename {stacklets/agent/runtime => lib/stack}/name_trigger.py (76%) rename tests/unit/{stacklets/test_agent_name_trigger.py => framework/test_name_trigger.py} (84%) diff --git a/docs/design/brain/who-answers.md b/docs/design/brain/who-answers.md index 4d4c598b..d84f712f 100644 --- a/docs/design/brain/who-answers.md +++ b/docs/design/brain/who-answers.md @@ -50,7 +50,7 @@ make that true, and any new bot has to state which gate it uses. |---|---|---| | **Reaction** | This message, this action, chosen per message | archivist `_on_reaction` (πŸ”– πŸ“Œ bookmark, πŸ“Ž πŸ“„ archive, πŸ” πŸ”„ retry) | | **@-mention** | Deliberate address, overrides everything ambient | every bot, `MicroBot._is_bot_mentioned`; the agent adds nanobot's own pill check | -| **Name in the vocative** | "Merlin, what is missing?" - how people actually talk | agent only, `name_trigger.addressed_by_name` | +| **Name in the vocative** | "Merlin, what is missing?" - how people actually talk | every bot on the main timeline (`MicroBot._is_bot_mentioned`), and the agent, through the same `stack.name_trigger` | | **Thread ownership** | Inside a bounded conversation, the thread is the address | agent `AgentThreads`, archivist `MicroBot.thread_owner` | | **Room** | The room's default job, and its `!config process` mode | archivist (`documents` room means search; `react` mode means reactions only) | | **Message shape** | A URL, a long paste, a file | archivist only, and only on the main timeline | diff --git a/docs/design/brain/write-layer.md b/docs/design/brain/write-layer.md index 2e5109a8..e0f939a5 100644 --- a/docs/design/brain/write-layer.md +++ b/docs/design/brain/write-layer.md @@ -77,9 +77,9 @@ that started the session. agent exists. 10. **The signal is free.** `AGENT_NAME=Stacky` and `AGENT_BOT_ID=stacky-bot` are already in the bot-runner's environment. -11. **But the matcher is not shared.** `name_trigger.py` lives in the agent - stacklet, and the agent container mounts no `lib/stack`. If the two ever - disagree about "was the agent addressed", either both act or neither does. +11. **The matcher is shared.** `lib/stack/name_trigger.py` is imported by the + bot-runner and mounted as one file into the agent container, so the bots + agree on "was I addressed by name". 12. **The two answerers are not redundant.** The archivist's search is dual (Paperless plus vault, with synthesis and deep-dive); the agent's `memory_search` is vault-only, and `stack docs search` does not exist. diff --git a/stacklets/agent/runtime/name_trigger.py b/lib/stack/name_trigger.py similarity index 76% rename from stacklets/agent/runtime/name_trigger.py rename to lib/stack/name_trigger.py index 35a5e429..edcc892f 100644 --- a/stacklets/agent/runtime/name_trigger.py +++ b/lib/stack/name_trigger.py @@ -1,16 +1,18 @@ -"""Does this message address the agent by name? +"""Does this message address a bot by name? -nanobot's group policy answers "is the bot mentioned?" by reading the -`m.mentions` payload, which only exists when the sender picked the bot -out of an autocomplete list. That is a fine rule for Slack and a poor -one for a family room, where people type what they would say out loud: +Matrix answers "is the bot mentioned?" with the `m.mentions` payload, +which only exists when the sender picked the bot out of an autocomplete +list. That is a fine rule for Slack and a poor one for a family room, +where people type what they would say out loud: Stacky, what's on our list? No pill, no `m.mentions`, no reply. This module supplies the missing half of the question, and nothing else -- it is pure text in, bool out, so the rule can be argued with in tests rather than in a running -container. +container. It is stdlib-only and imports nothing from the framework: +the bot-runner uses it as `stack.name_trigger`, and the agent container +mounts this one file as `name_trigger`, so both agree on what counts. WHAT COUNTS AS BEING ADDRESSED @@ -111,3 +113,19 @@ def addressed_by_name(body: str, name: str) -> bool: if not text: return False return bool(_pattern(name).search(text)) + + +def strip_address(body: str, name: str) -> str: + """The message without the words that address `name`. + + "hey Archivist, when does the insurance renew?" becomes "when does + the insurance renew?", so the bot hears the question, not its own + name. Unchanged when the message does not address `name`. + """ + text = strip_reply_fallback(body).strip() + if not addressed_by_name(text, name): + return body or "" + n = re.escape(name.strip()) + text = re.sub(rf"^\W*(?:{_GREETINGS}\W+)?{n}(?!\w)[\s,:;.!-]*", "", text, count=1, flags=re.IGNORECASE) + text = re.sub(rf"[,;]\s*{n}(?!\w)(\W*)$", r"\1", text, count=1, flags=re.IGNORECASE) + return text.strip() diff --git a/stacklets/agent/Dockerfile b/stacklets/agent/Dockerfile index 89fa9c86..00d271ee 100644 --- a/stacklets/agent/Dockerfile +++ b/stacklets/agent/Dockerfile @@ -33,6 +33,9 @@ COPY workspace/ /app/workspace-seed/ # Runtime-context shim: per-turn family briefing injected into nanobot's prompt. # See runtime/README.md for what it patches and why. COPY runtime/ /app/runtime/ +# name_trigger.py is not baked: compose mounts it from lib/stack, where the +# bot-runner reads the same file. Without the mount the shim logs and the +# agent answers pill mentions only. # Container-side `stack` shim: forwards allowlisted commands to the host's # famstack API (host.docker.internal) and prints the CLI's plaintext output. # See client/stack and stacklets/core/famstack-api.py. @@ -44,7 +47,8 @@ USER nanobot ENV HOME=/home/nanobot # Put the runtime shim on the path so its sitecustomize.py auto-loads at # interpreter startup and patches nanobot's per-turn context assembly. -ENV PYTHONPATH=/app/runtime +# /app/shared holds framework files compose mounts one by one (name_trigger). +ENV PYTHONPATH=/app/runtime:/app/shared # Where the container-side `stack` shim reaches the host's famstack API (core). ENV STACK_API_ADDR=host.docker.internal:42001 diff --git a/stacklets/agent/docker-compose.yml b/stacklets/agent/docker-compose.yml index 2dd518c3..01d02750 100644 --- a/stacklets/agent/docker-compose.yml +++ b/stacklets/agent/docker-compose.yml @@ -48,6 +48,12 @@ services: # image runs standalone, but a bind mount means an edit + restart takes # effect without a rebuild. Keep in sync with the Dockerfile COPY. - ./runtime:/app/runtime:ro + # The name trigger is shared with the bot-runner, so every bot agrees + # on "was I addressed by name". It lives in lib/stack; only this one + # stdlib file is mounted, under the name the shim imports. Not into + # /app/runtime: that is a read-only mount itself, and Docker cannot + # create a mountpoint inside it. /app/shared is on PYTHONPATH. + - ../../lib/stack/name_trigger.py:/app/shared/name_trigger.py:ro # The `stack` shim, live for the same reason. It is the agent's only # route to the CLI, so a bug in it takes out every vault tool at once β€” # and it stayed baked-only long enough for one to go unnoticed. diff --git a/stacklets/core/bot-runner/microbot.py b/stacklets/core/bot-runner/microbot.py index 5d7bae64..b73642ba 100644 --- a/stacklets/core/bot-runner/microbot.py +++ b/stacklets/core/bot-runner/microbot.py @@ -77,6 +77,7 @@ from room_context import RoomContext, context_for from stack.ai.client import LLM, LLMError, LLMUnavailableError, Transcriber from stack.users import TECH_ADMIN_USERNAME +from stack.name_trigger import addressed_by_name, strip_address # The framework's "I picked this up and I'm working on it" signal. A # bot reacts with πŸ‘€ on the source message the moment it starts a @@ -1319,7 +1320,7 @@ def _room_context(self, room) -> RoomContext: def _is_bot_mentioned(self, event) -> bool: """True if the event explicitly addresses this bot. - Two paths, OR-ed: + Three paths, OR-ed: 1. ``m.mentions.user_ids`` (MSC3952) β€” modern Matrix clients (Element X, Element-web) populate this list when the user @@ -1330,6 +1331,12 @@ def _is_bot_mentioned(self, event) -> bool: ``@bot:server`` form. The check is on the full mxid, not the localpart, so a casual mention of the bot's name in chat doesn't trip the gate. + 3. Addressed by name, the way people talk: "Archivist, when does + the insurance renew?" (`stack.name_trigger`, the same rule the + agent uses, from the bot's `name` in bot.toml). Only on the + main timeline: in a thread or a reply the conversation already + has an address, and there a message without a pick from the + list keeps its meaning (for the archivist, a correction). """ content = ( event.source.get("content", {}) if hasattr(event, "source") else {} @@ -1338,7 +1345,12 @@ def _is_bot_mentioned(self, event) -> bool: if self.user_id in (mentions.get("user_ids") or []): return True body = getattr(event, "body", "") or "" - return self.user_id in body + if self.user_id in body: + return True + relates = content.get("m.relates_to") or {} + if relates.get("rel_type") == "m.thread" or relates.get("m.in_reply_to"): + return False + return addressed_by_name(body, self.display_name or "") async def _should_react(self, ctx: RoomContext, *, mentioned: bool) -> bool: """Decide whether the bot acts on the current event at all. @@ -1520,6 +1532,7 @@ def normalize_emoji(key: str) -> str: @staticmethod def strip_mention( body: str, bot_user_id: str, *, formatted_body: str | None = None, + display_name: str | None = None, ) -> str: """Remove the bot mention (and any clinging punctuation/whitespace) from a message body. @@ -1538,7 +1551,9 @@ def strip_mention( only inside the HTML ``formatted_body``. We pull the display name from the formatted_body's anchor element and strip it from the start of ``body``. - 3. Neither: nothing to strip. + 3. Addressed by name ("Archivist, find MLX", "..., Archivist?"): + the address is dropped, see `stack.name_trigger`. + 4. None of these: nothing to strip. Conservative on punctuation: trims a single trailing ``:`` or ``,`` after the mention (common in tab-complete output) and @@ -1576,6 +1591,9 @@ def strip_mention( rest = rest[1:].lstrip() return rest.strip() + # Path 3: addressed by name, no pill. + if display_name: + return strip_address(body, display_name) return body async def emit_event(self, room_id: str, event_type: str, body: dict) -> bool: diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 4bad34c1..71c550b0 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -1729,6 +1729,7 @@ async def _on_text(self, room, event: RoomMessageText) -> None: formatted_body = event.source.get("content", {}).get("formatted_body") query = self.strip_mention( query, self.user_id, formatted_body=formatted_body, + display_name=self.display_name, ) if not query: query = "help" diff --git a/tests/unit/stacklets/test_agent_name_trigger.py b/tests/unit/framework/test_name_trigger.py similarity index 84% rename from tests/unit/stacklets/test_agent_name_trigger.py rename to tests/unit/framework/test_name_trigger.py index 6e73349d..a6b76e28 100644 --- a/tests/unit/stacklets/test_agent_name_trigger.py +++ b/tests/unit/framework/test_name_trigger.py @@ -12,15 +12,9 @@ from __future__ import annotations -import sys -from pathlib import Path - import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent -sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "agent" / "runtime")) - -from name_trigger import addressed_by_name # noqa: E402 +from stack.name_trigger import addressed_by_name, strip_address class TestSpeakingToTheAgent: @@ -122,3 +116,20 @@ def test_a_blank_name_never_triggers(self, name): def test_an_empty_message_is_not_an_address(self): assert not addressed_by_name("", "Stacky") assert not addressed_by_name("> <@marge:simpson> quoted only\n", "Stacky") + + +class TestWhatIsLeftForTheBot: + """After the address, the bot hears the question, not its own name.""" + + @pytest.mark.parametrize("body, question", [ + ("Archivist, when does the car insurance renew?", "when does the car insurance renew?"), + ("archivist when does the car insurance renew?", "when does the car insurance renew?"), + ("hey Archivist: what did Lenny suggest?", "what did Lenny suggest?"), + ("what did Lenny suggest, Archivist?", "what did Lenny suggest?"), + ]) + def test_the_address_is_dropped(self, body, question): + assert strip_address(body, "Archivist") == question + + def test_a_message_about_the_bot_is_left_as_it_is(self): + body = "I asked the Archivist and it said no" + assert strip_address(body, "Archivist") == body diff --git a/tests/unit/stacklets/test_agent_runtime_shims.py b/tests/unit/stacklets/test_agent_runtime_shims.py index 9002df8d..a65874b6 100644 --- a/tests/unit/stacklets/test_agent_runtime_shims.py +++ b/tests/unit/stacklets/test_agent_runtime_shims.py @@ -49,6 +49,9 @@ def _load(drop: str | None = None): monkeypatch.setitem(sys.modules, name, module) for name in SHIMMED_MODULES: monkeypatch.delitem(sys.modules, name, raising=False) + # The container mounts lib/stack/name_trigger.py as `name_trigger`. + monkeypatch.setitem(sys.modules, "name_trigger", + importlib.import_module("stack.name_trigger")) importlib.import_module("sitecustomize") return mods @@ -121,7 +124,7 @@ def test_being_named_counts_as_a_mention(nanobot, monkeypatch): """Without this shim the agent ignores everyone who does not use a pill. Driven through nanobot's own gate rather than the matcher directly: - the matcher is specified in `test_agent_name_trigger.py`, and what + the matcher is specified in `tests/unit/framework/test_name_trigger.py`, and what is at stake here is that nanobot actually asks it. """ monkeypatch.setenv("AGENT_NAME", "Stacky") diff --git a/tests/unit/stacklets/test_microbot_source.py b/tests/unit/stacklets/test_microbot_source.py index 62b105a6..d55998a0 100644 --- a/tests/unit/stacklets/test_microbot_source.py +++ b/tests/unit/stacklets/test_microbot_source.py @@ -9,6 +9,7 @@ from __future__ import annotations import sys +from types import SimpleNamespace from pathlib import Path import pytest @@ -167,3 +168,50 @@ async def test_noop_when_unset(self, tmp_path): b._client = self._ProfileClient(current="whatever") await b._sync_display_name() assert b._client.set_calls == [] + + +class TestAddressedByName: + """People write "Archivist, when does the insurance renew?" rather than + picking the bot from a list. On the main timeline that is an address; + in a thread or a reply it is not, because there the conversation + already has one (for the archivist, a message there is a correction).""" + + def _event(self, body, relates=None): + content = {"body": body, "msgtype": "m.text"} + if relates: + content["m.relates_to"] = relates + return SimpleNamespace(body=body, source={"content": content}) + + def _named_bot(self, tmp_path): + b = _bot(tmp_path) + b.display_name = "Archivist" + return b + + def test_the_name_at_the_start_is_an_address(self, tmp_path): + assert self._named_bot(tmp_path)._is_bot_mentioned( + self._event("archivist, when does the car insurance renew?")) + + def test_talking_about_the_bot_is_not(self, tmp_path): + assert not self._named_bot(tmp_path)._is_bot_mentioned( + self._event("I asked the Archivist and it said no")) + + def test_in_a_thread_the_name_alone_is_not_an_address(self, tmp_path): + thread = {"rel_type": "m.thread", "event_id": "$root"} + assert not self._named_bot(tmp_path)._is_bot_mentioned( + self._event("Archivist, this is Marge's car", thread)) + + def test_in_a_reply_the_name_alone_is_not_an_address(self, tmp_path): + reply = {"m.in_reply_to": {"event_id": "$filed"}} + assert not self._named_bot(tmp_path)._is_bot_mentioned( + self._event("Archivist, this is Marge's car", reply)) + + def test_a_picked_mention_still_counts_in_a_thread(self, tmp_path): + b = self._named_bot(tmp_path) + event = self._event("Archivist: search this", {"rel_type": "m.thread", "event_id": "$r"}) + event.source["content"]["m.mentions"] = {"user_ids": [b.user_id]} + assert b._is_bot_mentioned(event) + + def test_the_question_is_what_is_left(self, tmp_path): + assert MicroBot.strip_mention( + "Archivist, when does the car insurance renew?", "@t:hs", + display_name="Archivist") == "when does the car insurance renew?" From 70933bdc491293f10ede95eca099e058cde82e82 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 26 Sep 2026 20:15:43 +0200 Subject: [PATCH 2/3] fix(archivist): tell the family to ask by name instead of a ? prefix The archivist's welcome in topic, personal and capture rooms told the family to start a message with `?` to search. Nothing ever handled that prefix, so such a question got no answer at all, not even "nothing found". The welcome texts (English and German) and the user guide now say to start with the archivist's name, which searches and answers, limited to the topic's folder in a topic room. Closes: #151 --- docs/user-guide.md | 4 ++-- stacklets/docs/bot/messages/archivist.yml | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 6623589a..45567e1c 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -111,7 +111,7 @@ Name a room `Topic: Powerplant Picnic` (German: `Thema: ...`), disable encryptio The room becomes the filing system. Plan the picnic in chat, drop the gear list, the location ideas, the photo of the flyer. When it's over you have a complete, searchable record of the project without ever having organized anything. -Questions asked inside a topic room automatically search just that topic. Ask "@archivist what did Lenny suggest?" in the picnic room and the answer comes from the picnic material, not from your tax documents. +Questions asked inside a topic room automatically search just that topic. Ask "Archivist, what did Lenny suggest?" in the picnic room and the answer comes from the picnic material, not from your tax documents. --- @@ -177,7 +177,7 @@ Memories work the same way, in the thread under each memory: see [Put a memory r Two ways to ask: - In **#Documents**, just type. Search is the default there. -- In **any other room**, @-mention the bot: `@archivist when does the car insurance renew?` +- In **any other room**, start with its name: `Archivist, when does the car insurance renew?` Picking it from the `@` list works too. Inside a thread the name alone is not enough, because a message there corrects the filing: pick the archivist from the `@` list to ask something instead. Plain words work as search ("Duff Insurance", "vaccination Bart"). Questions get real answers: the archivist searches the document archive and the family knowledge vault, reads what it finds, and answers with numbered sources you can check: diff --git a/stacklets/docs/bot/messages/archivist.yml b/stacklets/docs/bot/messages/archivist.yml index 292fc976..b620da0b 100644 --- a/stacklets/docs/bot/messages/archivist.yml +++ b/stacklets/docs/bot/messages/archivist.yml @@ -183,7 +183,7 @@ en: - 🎀 **Voice memos:** record straight from the chat β†’ transcribed + filed - πŸ“· **Photos & PDFs:** drop any file β†’ text-extracted, summarised, and filed - 🎀 **Multi-memo notes:** type `(` then send multiple voice memos, then `)` β†’ combined into one note - - πŸ” **Search this topic:** start a message with `?` (e.g. `?how did we pack the trunk last trip`) β†’ I search only this topic's folder + - πŸ” **Ask about this topic:** start with my name (e.g. `Archivist, how did we pack the trunk last trip?`) β†’ I answer from this topic's folder only Every capture in this room is tagged `{slug}` automatically, so the wiki's **{display}** page accumulates everything in one place. @@ -196,7 +196,7 @@ en: - πŸ“ **Notes & links:** paste text or paste a URL β†’ filed under your personal bucket - 🎀 **Voice memos:** record straight from the chat β†’ transcribed + filed - πŸ“· **Photos & PDFs:** drop any file β†’ text-extracted, summarised, and filed - - πŸ” **Ask me anything:** start a message with `?` (e.g. `?when does our car insurance expire`) β†’ I search your captures and our household documents + - πŸ” **Ask me anything:** start with my name (e.g. `Archivist, when does our car insurance expire?`) β†’ I search your captures and our household documents Type **help** to see this message again. @@ -210,7 +210,7 @@ en: - πŸ“Œ **Notes:** react πŸ“Œ to any message to keep it (in a room with just you, a long paste is kept on its own) - 🎀 **Voice memos:** record straight from the chat β†’ transcribed + filed - πŸ“· **Photos & PDFs:** drop any file β†’ text-extracted, summarised, and filed - - πŸ” **Ask me anything:** start a message with `?` (e.g. `?did we get the dentist confirmation`) β†’ I search captures and documents + - πŸ” **Ask me anything:** start with my name (e.g. `Archivist, did we get the dentist confirmation?`) β†’ I search captures and documents Type **help** to see this message again. @@ -391,7 +391,7 @@ de: - 🎀 **Sprachnachrichten:** direkt im Chat aufnehmen β†’ wird transkribiert und abgelegt - πŸ“· **Fotos & PDFs:** beliebige Datei droppen β†’ Text wird extrahiert, zusammengefasst und abgelegt - 🎀 **Mehrere Sprachnachrichten:** `(` tippen, mehrere Memos senden, dann `)` β†’ werden zu einer Notiz kombiniert - - πŸ” **In diesem Thema suchen:** Nachricht mit `?` beginnen (z.B. `?wie haben wir letztes Mal die Kisten gepackt`) β†’ ich suche nur in diesem Topic-Ordner + - πŸ” **Zu diesem Thema fragen:** mit meinem Namen beginnen (z.B. `Archivist, wie haben wir letztes Mal die Kisten gepackt?`) β†’ ich antworte nur aus diesem Topic-Ordner Jeder Eintrag in diesem Raum wird automatisch mit `{slug}` getaggt, damit die **{display}**-Wiki-Seite alles an einem Ort sammelt. @@ -404,7 +404,7 @@ de: - πŸ“ **Notizen & Links:** Text einfΓΌgen oder URL pasten β†’ wird in deinem persΓΆnlichen Bucket abgelegt - 🎀 **Sprachnachrichten:** direkt im Chat aufnehmen β†’ wird transkribiert und abgelegt - πŸ“· **Fotos & PDFs:** beliebige Datei droppen β†’ Text wird extrahiert, zusammengefasst und abgelegt - - πŸ” **Frag mich was:** Nachricht mit `?` beginnen (z.B. `?wann lΓ€uft die KFZ-Versicherung ab`) β†’ ich durchsuche deine Captures und unsere Dokumente + - πŸ” **Frag mich was:** mit meinem Namen beginnen (z.B. `Archivist, wann lΓ€uft die KFZ-Versicherung ab?`) β†’ ich durchsuche deine Captures und unsere Dokumente **help** tippen fΓΌr diese Nachricht. @@ -418,7 +418,7 @@ de: - πŸ“Œ **Notizen:** mit πŸ“Œ auf eine Nachricht reagieren, dann merke ich sie mir (bist du allein im Raum, wird ein langer Text automatisch abgelegt) - 🎀 **Sprachnachrichten:** direkt im Chat aufnehmen β†’ wird transkribiert und abgelegt - πŸ“· **Fotos & PDFs:** beliebige Datei droppen β†’ Text wird extrahiert, zusammengefasst und abgelegt - - πŸ” **Frag mich was:** Nachricht mit `?` beginnen (z.B. `?haben wir die Zahnarzt-BestΓ€tigung bekommen`) β†’ ich durchsuche Captures und Dokumente + - πŸ” **Frag mich was:** mit meinem Namen beginnen (z.B. `Archivist, haben wir die Zahnarzt-BestΓ€tigung bekommen?`) β†’ ich durchsuche Captures und Dokumente **help** tippen fΓΌr diese Nachricht. From b4a02f21b5f256d6c3860d4bcb6f5ed7f963d7d1 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 26 Sep 2026 20:22:25 +0200 Subject: [PATCH 3/3] docs(archivist): map when the archivist answers as a decision tree The archivist decides what to do with a message through a dozen gates and a command ladder spread over `_on_text`, `_on_file`, `_on_reaction` and `MicroBot`. The rules existed only in the code. The design note on who answers now has the tree for a text message, a file and a reaction, first match wins, read from the code: the Memories room, handoffs, room modes, thread ownership, corrections, and what counts as addressing the archivist. --- docs/design/brain/who-answers.md | 93 ++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/docs/design/brain/who-answers.md b/docs/design/brain/who-answers.md index d84f712f..189deb6f 100644 --- a/docs/design/brain/who-answers.md +++ b/docs/design/brain/who-answers.md @@ -224,6 +224,99 @@ and the archivist when it posts a filing card). Their output carries `dev.famstack.source` / `dev.famstack.event`, and other bots read the envelope rather than the prose. +## The archivist, as a decision tree + +What the archivist does with one event, read from the code +(`stacklets/docs/bot/archivist.py`, gates in `MicroBot`). The first +matching line wins. "Addressed" means: picked from the `@` list +(`m.mentions`), or its Matrix ID in the text, or, on the main timeline +only, its name at the start or end of the message ("Archivist, ...", +"..., Archivist?", `stack.name_trigger`). + +### A text message (`_on_text`) + +``` +1. sent by the archivist itself ............................ ignore +2. in the Memories room .................................... diary_room.on_text: + in a thread under one of its diary cards, with words ... correct that card + anything else ......................................... silence +3. carries a dev.famstack.source envelope (mail bot) ....... file the source +4. sent by another bot (localpart ends in -bot) ............ ignore +5. empty ................................................... ignore +6. a `!config` command ..................................... change the room's settings + (first message in a room: post the welcome first) +7. not addressed, and the room's mode is `react` ........... ignore (reactions only) +8. not addressed, in a thread the archivist does not own ... ignore + (except a handoff: mail attachments posted under a mail card) +9. not addressed, and it lands on one of its filings + (a reply to it, or in its thread) ....................... correction: re-file +10. the command ladder: + help / hilfe / ? ....................................... the room's welcome text + `(` ... `)` ............................................ start / finish a scan session + `show 42` .............................................. post document 42 + only a URL ............................................. Documents room: archive in Paperless + elsewhere: bookmark + not addressed, text with a URL ......................... same, the words are the hint + addressed, or in the Documents room .................... search and answer + (topic room: that topic only) + a voice transcript, or a long paste with one + person in the room (bots and the tech admin not + counted) ............................................... note + anything else .......................................... ignore: people talking +``` + +### A voice message (`MicroBot._dispatch`) + +``` +Every m.audio with a plain mxc:// payload is transcribed before any +handler sees it (voice.is_voice, _decode_voice): + no speech, or no transcriber ................................ nothing at all + otherwise ................................................... the transcript goes through + "A text message" above, same + event id and thread, marked + as transcribed +In the Memories room only a voice reply in a thread is decoded +(a correction to a card); memos are left for the diary. +``` + +Speech and typing are routed identically, with one difference: a +transcript that reaches the end of the ladder is filed as a note, where +typed text would need to be a long paste in a one-person room. + +### A file: photo or PDF (`_on_file`) + +``` +1. sent by the archivist, unsupported type, or no mxc:// .... ignore +2. in the Memories room ..................................... ignore; the diary reads it + on its own schedule +3. not addressed, room mode `react` ......................... ignore +4. not addressed, in a thread it does not own ............... ignore (handoffs excepted) +5. the sender has an open scan session ...................... add as a page +6. in the Documents room .................................... file in Paperless +7. elsewhere ................................................ capture: text extracted, + summarised, filed +``` + +### A reaction (`_on_reaction`) + +``` +1. by a bot or the tech admin, or in the Memories room ...... ignore +2. πŸ”– πŸ“Œ ...................................................... keep the message: bookmark or note + πŸ“Ž πŸ“„ ...................................................... archive the source in Paperless + πŸ” πŸ”„ ...................................................... retry the filing + any other emoji ........................................... ignore +``` + +### What the tree says about priorities + +- An address beats every guess: steps 7 and 8 only apply when the + archivist was not addressed, and step 9 (correction) only then too. +- Inside a thread the name alone is not an address, so "Archivist, this + is Marge's car" in a filing's thread stays a correction (step 9). To + search from a thread, pick the archivist from the `@` list. +- The Memories room is decided first and keeps the archivist silent + except for corrections to its own cards. + ## The gaps 1. **The vocative is not shared.** "Merlin, save this" on the main