From 4d813406c3cf5fea4f85e264a738bccdeee956a3 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 6 Aug 2026 13:41:42 +0200 Subject: [PATCH 1/9] fix(archivist): stay out of your conversation with the agent Talking to the family agent in a thread got filed. A long message became a note, and every reply after it read as a correction to that note, so the archivist kept reclassifying while you were talking to someone else. Inside a thread the archivist now only acts where it answered first -- its own filing threads, where corrections belong. @-mentioning it still works anywhere. The main timeline is unchanged. --- stacklets/core/bot-runner/microbot.py | 61 ++++++++ stacklets/docs/bot/archivist.py | 50 ++++++ tests/stacklets/test_archivist_corrections.py | 146 +++++++++++++++++- tests/stacklets/test_microbot.py | 118 +++++++++++++- 4 files changed, 368 insertions(+), 7 deletions(-) diff --git a/stacklets/core/bot-runner/microbot.py b/stacklets/core/bot-runner/microbot.py index 528c40d..c2eec2f 100644 --- a/stacklets/core/bot-runner/microbot.py +++ b/stacklets/core/bot-runner/microbot.py @@ -857,6 +857,67 @@ async def _thread_envelopes( self.name, root_event_id, e) return envelopes + async def thread_owner( + self, room_id: str, root_event_id: str, *, limit: int = 20, + ) -> str | None: + """Which bot the thread at ``root_event_id`` belongs to. + + famstack's bots all thread, so one room carries several + conversations at once: the archivist's filing under an upload, + the mail bot's email under its card, the family agent answering a + question. Inside a thread nobody repeats a name on every line — + the thread *is* the tie between a message and its responder — so + a bot that treats every thread it can see as its own ends up + answering, and filing, someone else's conversation. + + **A thread belongs to the first bot that replied into it, other + than whoever started it.** Two halves, both load-bearing: + + *First reply*, because that is what created the thread. Our + convention is that a bot answers by threading under the message + it answers, so the root is normally the human's own upload or + question; the reply is the bot's claim on it. Being first is also + a fact that never changes, which is what makes ownership stable — + a bot that merely spoke most recently could take a thread away + from the bot the family is actually mid-conversation with. + + *Other than the starter*, because a producer posting under its + own root is still publishing, not conversing. The mail bot posts + an email's card, then its full body and attachments underneath; + the archivist's filing is the first real answer there, and the + family must be able to correct it in that thread. + + Returns a bot mxid (possibly this bot's own), or None when no bot + has answered — a thread between people, which belongs to nobody. + Bounded by ``limit`` so one chat message can never become an + unbounded walk. Best-effort: a homeserver failure reads as + "nobody's", so a transient error leaves bots quiet rather than + letting one act on a thread it may not own. + """ + try: + resp = await self.client.room_get_event(room_id, root_event_id) + except Exception as e: + logger.debug("[{}] thread root fetch failed for {}: {}", + self.name, root_event_id, e) + return None + starter = getattr(getattr(resp, "event", None), "sender", None) + try: + examined = 0 + async for related in self.client.room_get_event_relations( + room_id, root_event_id, RelationshipType.thread, + direction=MessageDirection.front, + ): + examined += 1 + if examined > limit: + break + sender = getattr(related, "sender", None) + if sender and sender != starter and self.is_bot_user(sender): + return sender + except Exception as e: + logger.debug("[{}] thread relations fetch failed for {}: {}", + self.name, root_event_id, e) + return None + def _ensure_http(self) -> aiohttp.ClientSession: """The shared aiohttp session, created on first use. diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 84c2f80..8524991 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -1056,6 +1056,45 @@ def _is_filing_envelope(envelope: dict) -> bool: """ return envelope.get("type", "").endswith((".filed", ".reclassified")) + async def _thread_is_ours(self, room_id: str, event) -> bool: + """Whether `event` is ours to act on, as far as threads decide it. + + A thread is a bounded conversation with an owner, so the + archivist acts inside one only when the thread is its own -- the + filing thread hanging off an upload, where "this is Marge's, not + Homer's" is a correction. Every other thread is someone else's + conversation and is left alone, including a thread no bot has + answered in: two people talking to each other is not material + dropped for filing. + + The main timeline is unchanged. A message that is in no thread is + always ours to route -- that is where dropping something for the + archivist happens. + + This is the gate that was missing. The family agent lives in the + same rooms and answers in threads, and a person mid-conversation + with it does not repeat its name on every line, so from here + those lines looked like free-typed material: an agent error + message became a note titled "Fehler beim Speichern der + Packliste", and every reply typed afterwards read as a correction + to that note, because the thread now held one of our filing + cards. + + An @-mention skips this gate at the call site: deliberate address + beats ambient context, the same rule corrections already follow. + """ + root = self.get_thread_root(event) + if root is None: + return True + owner = await self.thread_owner(room_id, root) + if owner == self.user_id: + return True + logger.info( + "[archivist] thread {} belongs to {}; ignoring {} in {}", + root, owner or "nobody", event.sender, room_id, + ) + return False + async def _correction_anchor( self, room_id: str, event, ) -> tuple[str, dict] | None: @@ -1482,6 +1521,11 @@ async def _on_file(self, room, event) -> None: ) return + # An upload dropped into someone else's thread is material for + # that conversation, not for us. Same gate as `_on_text`. + if not mentioned and not await self._thread_is_ours(room.room_id, event): + return + # On modern Matrix clients an upload can carry a human caption # alongside the file (Element X, FluffyChat, anything honoring # MSC4274). When present, the caption rides into the classify @@ -1622,6 +1666,12 @@ async def _on_text(self, room, event: RoomMessageText) -> None: ) return + # Inside a thread, only our own -- see `_thread_is_ours`. Checked + # before every routing branch so a message in someone else's + # conversation is neither filed nor read as a correction. + if not mentioned and not await self._thread_is_ours(room.room_id, event): + return + # When the bot is mentioned, the mxid is conversational noise — # strip it so the remaining body drives command matching and # the search query. A bare ping with no content becomes "help" diff --git a/tests/stacklets/test_archivist_corrections.py b/tests/stacklets/test_archivist_corrections.py index 528beac..082baca 100644 --- a/tests/stacklets/test_archivist_corrections.py +++ b/tests/stacklets/test_archivist_corrections.py @@ -23,6 +23,11 @@ A fixture built from our own `_send` output would only prove we agree with ourselves. The bug these tests pin was exactly a disagreement between our sender (thread-aware) and our reader (single reply hop). + +Reading the thread answers *which* filing a message corrects. It does +not answer whether the message was for us at all -- other bots thread in +the same rooms -- so `TestOnlyOurOwnThreads` states the gate in front of +all of this: inside a thread, the archivist acts only in its own. """ from __future__ import annotations @@ -106,9 +111,10 @@ class FakeMatrix: """The slice of `nio.AsyncClient` the correction path reads. Holds a room's events and, per thread root, the ids of the events - hanging off it. `room_get_event_relations` is an async iterator that - yields newest-first, which is what Synapse returns for the default - backwards direction. + hanging off it in timeline order. `room_get_event_relations` is an + async iterator honouring `direction`: newest-first for the default + backwards direction, which is what Synapse returns, and oldest-first + for `front` — the order that says which reply came first. """ def __init__(self): @@ -126,10 +132,14 @@ async def room_get_event(self, room_id, event_id): return SimpleNamespace(event=self.events.get(event_id)) async def room_get_event_relations( - self, room_id, event_id, rel_type=None, **kwargs, + self, room_id, event_id, rel_type=None, direction=None, **kwargs, ): + from nio.api import MessageDirection self.relation_calls.append(event_id) - for child_id in reversed(self.threads.get(event_id, [])): + children = self.threads.get(event_id, []) + if direction is not MessageDirection.front: + children = reversed(children) + for child_id in children: yield self.events[child_id] @@ -406,6 +416,132 @@ async def test_reply_to_another_users_message_is_not_a_correction(self, bot): assert bot.routed == [("search", "nice try")] +class TestOnlyOurOwnThreads: + """Reading the thread is what makes a correction findable, and it is + also what let the archivist walk into conversations it has no part in. + + The family agent lives in the same rooms and answers in threads, and + nobody repeats its name on every line of one. Those lines reached + `_on_text` looking like free-typed material: a long one got filed as + a note, and from then on the thread held one of our filing cards, so + every reply after it read as a correction to that note. One paste + became an unstoppable reclassification loop. + + So the thread gate is ownership, not content: inside a thread the + archivist acts only where it answered first. `thread_owner` has the + framework-level tests; these state what the family sees. + """ + + AGENT = "@merlin-bot:server" + + @staticmethod + def _family_room(): + """A topic room -- not the documents room, so free text is + capture-or-nothing rather than search.""" + return SimpleNamespace( + room_id="!camping:server", canonical_alias="#camping:server", + name="Camping", + users={uid: object() for uid in (BOT_ID, HOMER, "@merlin-bot:server")}, + ) + + @pytest.fixture + def agent_thread(self, bot): + """Homer asking the agent something, and the agent answering in a + thread on his question -- the ordinary shape of talking to it.""" + client = bot._client + client.add(_message("$ask", HOMER, "Merlin, what is still missing for camping?")) + client.add( + _message("$agent", self.AGENT, "The gas cartridge and the sleeping mats.", + content=_thread_relation("$ask")), + thread_root="$ask", + ) + return client + + @pytest.mark.asyncio + async def test_a_paste_in_the_agents_thread_is_not_filed(self, bot, agent_thread): + """The first wrong turn. Homer pastes the list he is working on + into the conversation; it is for the agent to act on, not for us + to file as a note.""" + event = _message( + "$paste", HOMER, + "Error saving the packing list, please clean it up and save it " + "again because the shoe rack entry is still duplicated in there.", + content=_thread_relation("$ask", falls_back_to="$agent"), + ) + await bot._on_text(self._family_room(), event) + assert bot.routed == [] + + @pytest.mark.asyncio + async def test_a_reply_in_the_agents_thread_is_not_a_correction( + self, bot, agent_thread, + ): + """The cascade. Even once one of our cards sits in the thread -- + which is exactly how the loop sustained itself -- Homer's next + words are still aimed at the agent.""" + agent_thread.add( + _message("$filed", BOT_ID, "Saved: error saving the packing list", + content=_thread_relation("$ask"), + envelope=_filed(topics=["Camping"])), + thread_root="$ask", + ) + event = _message( + "$reply", HOMER, "no it is not, write the file!", + content=_thread_relation("$ask", falls_back_to="$filed"), + ) + await bot._on_text(self._family_room(), event) + assert bot.routed == [] + + @pytest.mark.asyncio + async def test_a_mention_in_the_agents_thread_still_reaches_us( + self, bot, agent_thread, + ): + """Ownership is ambient; an @-mention is deliberate address, and + that beats it -- the same rule corrections already follow.""" + event = _message( + "$q", HOMER, f"{BOT_ID} what did we pack last summer", + content={ + "m.mentions": {"user_ids": [BOT_ID]}, + **_thread_relation("$ask", falls_back_to="$agent"), + }, + ) + await bot._on_text(self._family_room(), event) + assert bot.routed == [("search", "what did we pack last summer")] + + @pytest.mark.asyncio + async def test_two_people_talking_in_a_thread_are_left_alone(self, bot): + """A thread no bot answered in belongs to nobody. Two family + members working something out is a conversation, not material + dropped for filing.""" + client = bot._client + client.add(_message("$plan", HOMER, "when are we leaving on Friday?")) + client.add( + _message("$marge", "@marge:server", "after Lisa's rehearsal", + content=_thread_relation("$plan")), + thread_root="$plan", + ) + event = _message( + "$paste", HOMER, + "Right, so the plan is to load the car at four, leave by five, and " + "stop at the halfway services for dinner around seven in the evening.", + content=_thread_relation("$plan"), + ) + await bot._on_text(self._family_room(), event) + assert bot.routed == [] + + @pytest.mark.asyncio + async def test_the_main_timeline_is_untouched(self, bot, agent_thread): + """The gate is about threads only. Dropping something into the + room itself is still how you hand the archivist material, even + while a conversation with the agent is open alongside it.""" + event = _message( + "$paste", HOMER, + "Campsite booking reference DUFF-4417, arrival Friday after six, " + "pitch 12 by the water, cancellation free up to two days before.", + ) + await bot._on_text(self._family_room(), event) + assert [r[0] for r in bot.routed] == ["capture_text"] + + class TestChainedCorrections: """Correcting a correction. Each round adds a user turn and a bot confirmation; the pipeline gets every human turn back to the original diff --git a/tests/stacklets/test_microbot.py b/tests/stacklets/test_microbot.py index 7a2eed9..ee2c29e 100644 --- a/tests/stacklets/test_microbot.py +++ b/tests/stacklets/test_microbot.py @@ -86,10 +86,14 @@ async def room_get_event(self, room_id, event_id): return SimpleNamespace(event=self.parent_events.get(event_id)) async def room_get_event_relations(self, room_id, event_id, rel_type=None, - **kwargs): + direction=None, **kwargs): if self.relations_raise is not None: raise self.relations_raise - for event in reversed(self.thread_children.get(event_id, [])): + from nio.api import MessageDirection + children = self.thread_children.get(event_id, []) + if direction is not MessageDirection.front: + children = reversed(children) + for event in children: yield event @@ -891,6 +895,116 @@ async def test_empty_when_the_fetch_fails(self, tmp_path): assert await bot._thread_envelopes("!r:server", "$root") == [] +# ── Thread ownership ─────────────────────────────────────────────────────── + + +class TestThreadOwner: + """`thread_owner` answers "whose conversation is this thread". + + A famstack room holds several at once -- the archivist's filing under + an upload, the mail bot's email under its card, the family agent + answering a question -- and inside a thread nobody repeats a name on + every line. Without an owner every bot reads every thread as spoken + to it, which is how a family's chat with the agent ended up filed as + notes. + + The rule is the first bot to *reply*, ignoring whoever started the + thread. First, because that reply is what created the thread and is a + fact that never changes; not the starter, because a producer posting + under its own root is still publishing.""" + + @staticmethod + def _msg(event_id, sender): + return SimpleNamespace( + event_id=event_id, sender=sender, + source={"content": {"msgtype": "m.text", "body": "…"}}, + ) + + def _rooted_at(self, client, root_id, sender): + client.parent_events[root_id] = self._msg(root_id, sender) + + @pytest.mark.asyncio + async def test_the_bot_that_answered_first_owns_the_thread(self, tmp_path): + """Our convention: a bot answers by threading under the message it + answers, so the root is the human's and the reply is the claim.""" + bot, client = _bare_bot(tmp_path) + self._rooted_at(client, "$upload", "@homer:server") + client.thread_children["$upload"] = [self._msg("$filed", "@test-bot:server")] + assert await bot.thread_owner("!r:server", "$upload") == "@test-bot:server" + + @pytest.mark.asyncio + async def test_a_later_bot_cannot_take_the_thread_over(self, tmp_path): + """Ownership is settled by the first answer, so a bot that chimes + into an existing conversation does not inherit it. Were it "who + spoke last", the family agent replying once inside a filing thread + would silently stop corrections landing there.""" + bot, client = _bare_bot(tmp_path) + self._rooted_at(client, "$upload", "@homer:server") + client.thread_children["$upload"] = [ + self._msg("$filed", "@archivist-bot:server"), + self._msg("$aside", "@test-bot:server"), + ] + assert await bot.thread_owner("!r:server", "$upload") == "@archivist-bot:server" + + @pytest.mark.asyncio + async def test_posting_under_your_own_root_is_not_answering(self, tmp_path): + """The mail bot's shape: it posts an email as a card, then the full + body and the attachments underneath. None of that is a + conversation, so the archivist's filing is the thread's first real + answer and the family can still correct it there.""" + bot, client = _bare_bot(tmp_path) + self._rooted_at(client, "$card", "@mail-bot:server") + client.thread_children["$card"] = [ + self._msg("$body", "@mail-bot:server"), + self._msg("$attachment", "@mail-bot:server"), + self._msg("$filed", "@test-bot:server"), + ] + assert await bot.thread_owner("!r:server", "$card") == "@test-bot:server" + + @pytest.mark.asyncio + async def test_a_thread_between_people_belongs_to_nobody(self, tmp_path): + """Two family members talking is not material dropped for a bot, + and no bot should answer into it.""" + bot, client = _bare_bot(tmp_path) + self._rooted_at(client, "$chat", "@homer:server") + client.thread_children["$chat"] = [self._msg("$reply", "@marge:server")] + assert await bot.thread_owner("!r:server", "$chat") is None + + @pytest.mark.asyncio + async def test_an_unanswered_thread_belongs_to_nobody(self, tmp_path): + bot, client = _bare_bot(tmp_path) + self._rooted_at(client, "$chat", "@homer:server") + assert await bot.thread_owner("!r:server", "$chat") is None + + @pytest.mark.asyncio + async def test_bounded_by_events_examined(self, tmp_path): + """One chat message must never become an unbounded walk, so a bot + answering far down a long thread is simply not found.""" + bot, client = _bare_bot(tmp_path) + self._rooted_at(client, "$chat", "@homer:server") + client.thread_children["$chat"] = [ + *[self._msg(f"$chat{i}", "@homer:server") for i in range(5)], + self._msg("$late", "@test-bot:server"), + ] + assert await bot.thread_owner("!r:server", "$chat", limit=3) is None + + @pytest.mark.asyncio + async def test_nobodys_when_the_root_cannot_be_read(self, tmp_path): + """Without the starter we cannot tell publishing from answering, + and a wrong claim files someone else's conversation. Staying quiet + is the cheaper failure.""" + bot, client = _bare_bot(tmp_path) + client.get_event_raises = ConnectionError("synapse down") + assert await bot.thread_owner("!r:server", "$upload") is None + + @pytest.mark.asyncio + async def test_nobodys_when_the_thread_cannot_be_read(self, tmp_path): + bot, client = _bare_bot(tmp_path) + self._rooted_at(client, "$upload", "@homer:server") + client.relations_raise = ConnectionError("synapse down") + assert await bot.thread_owner("!r:server", "$upload") is None + + # ── Per-room config + emoji + !config command ──────────────────────────── From 0355347e2a8edd44aec6baaf41cba35a7205b633 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 6 Aug 2026 13:42:58 +0200 Subject: [PATCH 2/9] docs: map out which bot answers a message One room, several bots. Writes down the signals each of them reads, the thread-ownership rule, and the three gaps still open (the vocative matcher is not shared, scribe has no gate, no per-room off switch). --- docs/design/brain/interaction-patterns.md | 1 + docs/design/brain/who-answers.md | 144 ++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 docs/design/brain/who-answers.md diff --git a/docs/design/brain/interaction-patterns.md b/docs/design/brain/interaction-patterns.md index e2b7605..33b7a2c 100644 --- a/docs/design/brain/interaction-patterns.md +++ b/docs/design/brain/interaction-patterns.md @@ -165,6 +165,7 @@ A design note, not a prescriptive plan. Captures the direction agreed in the 202 ## Related +- [[who-answers]] — which bot answers at all, once several share a room - [[topic-rooms]] — room-state-as-intent, the seed of this design - [[knowledge-architecture]] — the broader event bus and storage layout - [[wiki-engine]] — the deriver work the reply-chain UX will lean on for confidence signals diff --git a/docs/design/brain/who-answers.md b/docs/design/brain/who-answers.md new file mode 100644 index 0000000..a0a292c --- /dev/null +++ b/docs/design/brain/who-answers.md @@ -0,0 +1,144 @@ +# Who answers a message? + +> Status: Rule in force for threads, gaps named below +> Created: 2026-08-06 +> Related: [interaction-patterns.md](interaction-patterns.md) (how one bot reads intent), [write-layer.md](write-layer.md) (finding 9, order-of-work step 4) + +## Why this exists + +A famstack room holds several bots and several people. The archivist +watches for material to file, the agent answers questions, the mail bot +posts email, scribe transcribes voice. Each of them decides on its own +whether an incoming message is for it, and until now nothing said what +happens when two of them say yes. + +The failure that forced the question, from a real family room: + +``` +Homer Merlin, the packing list is still wrong (to the agent) + └─ thread + Merlin The list is correct now, the shoe rack is ticked off. + Homer (to the agent) + Archivist Saved: "Error saving the packing list" <- filed it + Merlin The file is already saved correctly. + Homer No it is not. Write the file! (to the agent) + Archivist Reclassified with your hint: "Error saving..." <- and again + Homer The saved list still has the unclean version. (to the agent) + Archivist Reclassified with your hint: "Packing list ..." <- and again +``` + +Nobody addressed the archivist. It filed one side of a conversation as +knowledge, and because its own card was then sitting in the thread, every +following line looked like a correction to that card. One paste turned +into a loop that could not be stopped by talking. + +The rule was already written down on the agent's side. `thread_trigger.py` +says it plainly: *"An agent that claimed every thread would answer into +every filing discussion in the house, breaking the rule the archivist +already applies to itself: exactly one component responds to a message."* +The archivist applied that rule to its own filings and had no idea the +agent existed. This document is the missing half. + +## The invariant + +**Exactly one component answers a message.** Every gate below exists to +make that true, and any new bot has to state which gate it uses. + +## The signals, strongest first + +| Signal | Meaning | Who reads it today | +|---|---|---| +| **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` | +| **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 | + +The order matters. Everything above "room" is the user saying who they +mean. Everything at or below it is the bot guessing. A guess must never +beat an address, which is why the mention check sits in front of the +thread gate, and the thread gate in front of the shape ladder. + +## The thread rule + +**A thread belongs to the first bot that replied into it, other than +whoever started it.** A bot acts on a threaded message only when it owns +the thread, or when it was addressed explicitly. + +Implemented as `MicroBot.thread_owner` (`stacklets/core/bot-runner/microbot.py`). +Both halves are load-bearing: + +- **First reply**, because that is what created the thread. Our + convention is that a bot answers by threading under the message it + answers, so the root is normally the person's own upload or question + and the reply is the bot's claim on it. Being first also never changes, + which is what makes ownership stable. Under a "who spoke last" rule the + agent could take a filing thread away from the archivist by saying one + thing in it, and corrections would silently stop working. +- **Not the starter**, because a producer posting under its own root is + still publishing. The mail bot posts an email as a card, then the full + body and the attachments underneath it. None of that is conversation. + The archivist's filing is the thread's first real answer, and the + family has to be able to correct it there. + +Consequences worth knowing: + +- A thread no bot answered in belongs to nobody, and no bot acts in it. + Two people working something out is a conversation, not material + dropped for filing. This is a deliberate narrowing: the archivist used + to capture pastes inside any thread. +- The main timeline is untouched. Dropping something into the room is + still how you hand the archivist material. +- An @-mention reaches any bot in any thread. + +## Where each bot stands + +| Bot | Answers when | Gate | +|---|---|---| +| **archivist** | mentioned; or on the main timeline; or in a thread it owns; or reacted to | `_on_text` / `_on_file` -> `_should_react`, `_thread_is_ours`; `_on_reaction` | +| **agent** | pill-mentioned; or named in the vocative; or in a thread it is part of | nanobot's gate, extended by `name_trigger.py` + `thread_trigger.py` shims | +| **mail bot** | never answers; it only produces source cards | n/a | +| **scribe** | every voice message in a room it is in | none | + +Two producers write into rooms without answering anything (mail bot, +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 gaps + +1. **The vocative is not shared.** "Merlin, save this" on the main + timeline is an address to the agent, and the archivist cannot see it: + `addressed_by_name` lives in the agent stacklet, which mounts no + `lib/stack`. A long enough opening line still gets filed. This is + step 4 of [write-layer.md](write-layer.md) and the remaining half of + the fix above. Moving the matcher into the framework and mounting it + both ways is the honest version; duplicating the regex is how the two + drift apart (finding 11). + +2. **Scribe has no gate at all.** It transcribes every voice message it + can see, including ones sent inside another bot's thread, and the + archivist has its own voice path. Worth settling before both are in + the same room in front of a family. + +3. **Ownership is not cached.** Each threaded message costs a root fetch + plus one relations page against local Synapse. Ownership never + changes once settled, so it is cacheable the way `AgentThreads` caches + its positives. Not done, because the cost is noise next to the LLM + calls on the same path. Revisit if a busy room says otherwise. + +4. **Room-level arbitration is untouched.** `!config process react` + quiets one bot in one room. There is no way to say "the archivist does + not work in this room at all", which is the blunt instrument a family + would reach for first. + +## Tests that state these rules + +- `tests/stacklets/test_microbot.py::TestThreadOwner` - the ownership + rule itself, including the mail bot's shape. +- `tests/stacklets/test_archivist_corrections.py::TestOnlyOurOwnThreads` - + what the family sees: the agent's thread is left alone, an @-mention + still lands, the main timeline is unchanged. +- `tests/stacklets/test_agent_thread_trigger.py` - the same contract from + the agent's side. From b5503354ab43b1903027e281a2179fbab8ff8999 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 6 Aug 2026 14:48:53 +0200 Subject: [PATCH 3/9] fix(archivist): keep filing email attachments The thread gate was dropping them. An email's attachments arrive in the thread under its own card, seconds before the archivist's filing lands there, so at that moment the thread belongs to nobody and the school permission slip went on the floor. A source card or an attachment is addressed to the archivist by contract, so no room or thread rule gets a say. Also adds the sequence diagrams for the four ways a family message gets answered. --- docs/design/brain/who-answers.md | 118 +++++++++++++++++++++++ stacklets/core/bot-runner/microbot.py | 12 +++ stacklets/docs/bot/archivist.py | 7 ++ tests/stacklets/test_archivist_source.py | 21 ++++ 4 files changed, 158 insertions(+) diff --git a/docs/design/brain/who-answers.md b/docs/design/brain/who-answers.md index a0a292c..3cb075e 100644 --- a/docs/design/brain/who-answers.md +++ b/docs/design/brain/who-answers.md @@ -82,6 +82,14 @@ Both halves are load-bearing: The archivist's filing is the thread's first real answer, and the family has to be able to correct it there. +**A handoff is exempt.** An event carrying `dev.famstack.source` or +`dev.famstack.attachment` (`MicroBot.HANDOFF_KEYS`) is addressed by +contract rather than by who is in the room, so no ambient rule gets a +say. This is not a special case bolted on: the mail bot posts an email's +attachments into the thread under its own card seconds before the +archivist's filing lands there, so at that instant the thread is nobody's +and an ownership-only gate drops the school permission slip. + Consequences worth knowing: - A thread no bot answered in belongs to nobody, and no bot acts in it. @@ -92,6 +100,116 @@ Consequences worth knowing: still how you hand the archivist material. - An @-mention reaches any bot in any thread. +## The patterns + +Every bot sees every message in the room. What the gates decide is which +one of them acts, so the interesting part of each diagram is the arrow +that stops. + +### Filing something, then correcting it + +The pattern the archivist exists for, and the one the thread rule has to +keep intact. The upload is on the main timeline, so nothing gates it; the +archivist's answer creates the thread and claims it, which is what makes +"this is Marge's" a correction rather than a stray note. + +```mermaid +sequenceDiagram + actor Homer + participant R as Matrix room + participant A as archivist + participant M as agent + + Homer->>R: uploads invoice.pdf (main timeline) + R-->>M: not addressed, ignores + R->>A: _on_file + Note over A: not in a thread → ours to route + A->>R: "Filed: Duff Insurance (#42)"
opens a thread on the upload + + Homer->>R: "this is Marge's, not Homer's"
in that thread + R-->>M: thread is not the agent's, ignores + R->>A: _on_text + Note over A: thread_owner = archivist
(first to reply) → ours + A->>R: re-runs classification with the hint +``` + +### Talking to the agent + +The tragedy, and where it now stops. Note the two different gates: the +first line is judged by name and shape, everything after it by the +thread. + +```mermaid +sequenceDiagram + actor Homer + participant R as Matrix room + participant A as archivist + participant M as agent + + Homer->>R: "Merlin, what is missing for camping?" + R->>M: name in the vocative → answers + R->>A: _on_text, not in a thread + rect rgb(90, 60, 60) + Note over A: GAP: only the shape ladder guards this.
A long enough opening line is still filed. + end + M->>R: "The gas cartridge and the mats."
opens a thread on Homer's question + + Homer->>R: pastes the broken list, in that thread + R->>M: thread is the agent's → answers + R->>A: _on_text + Note over A: thread_owner = agent → stop + Note over A: nothing filed, so no card in the thread,
so no correction loop to sustain +``` + +### Email arriving + +Two bots and one thread, settled by the handoff marker rather than by +ownership. The archivist's filing is the thread's first real answer, so +the family can still correct it there afterwards. + +```mermaid +sequenceDiagram + participant Mail as mail bot + participant R as Matrix room + participant A as archivist + actor Marge + + Mail->>R: source card (dev.famstack.source) + Mail->>R: full body, threaded under the card + Mail->>R: slip.pdf (dev.famstack.attachment),
threaded under the card + + R->>A: card → handoff, files it + R--)A: body → plain bot chatter, ignored + R->>A: slip.pdf → handoff, files it + Note over A: the thread is still nobody's at this point;
the handoff exemption is what saves the slip + A->>R: "Filed: Permission slip"
in the card's thread + + Marge->>R: "this is Bart's, not Lisa's"
in the card's thread + Note over A: mail bot started the thread, so it does not
own it; archivist replied first → ours + A->>R: re-runs classification with the hint +``` + +### Two people in a thread + +No bot answered, so nobody owns it and nobody acts. This is the +deliberate narrowing: the archivist used to capture pastes here. + +```mermaid +sequenceDiagram + actor Homer + actor Marge + participant R as Matrix room + participant A as archivist + + Homer->>R: "when are we leaving on Friday?" + Marge->>R: "after Lisa's rehearsal", in a thread + Homer->>R: pastes the whole plan, in that thread + R->>A: _on_text + Note over A: thread_owner = nobody → stop + Homer->>R: "@archivist save that" + R->>A: mention beats ambient → files it +``` + ## Where each bot stands | Bot | Answers when | Gate | diff --git a/stacklets/core/bot-runner/microbot.py b/stacklets/core/bot-runner/microbot.py index c2eec2f..01a31a3 100644 --- a/stacklets/core/bot-runner/microbot.py +++ b/stacklets/core/bot-runner/microbot.py @@ -708,6 +708,18 @@ async def _answer( # person and carries provenance for the capture's tags. ATTACHMENT_KEY = "dev.famstack.attachment" + # The markers above are the two ways one component hands work to + # another. An event carrying one is addressed by contract, not by who + # is in the room, so the ambient rules that decide ordinary chat + # (thread ownership, mention) have no say over it. + HANDOFF_KEYS = (SOURCE_KEY, ATTACHMENT_KEY) + + @classmethod + def is_handoff(cls, event) -> bool: + """Whether ``event`` was posted for another component to act on.""" + content = (getattr(event, "source", None) or {}).get("content", {}) + return any(key in content for key in cls.HANDOFF_KEYS) + @staticmethod def is_bot_user(user_id: str) -> bool: """Whether a Matrix user is a famstack bot, by convention. diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 8524991..1e11975 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -1083,6 +1083,13 @@ async def _thread_is_ours(self, room_id: str, event) -> bool: An @-mention skips this gate at the call site: deliberate address beats ambient context, the same rule corrections already follow. """ + # A handoff is addressed to us by contract, so no ambient rule + # gets a say. The mail bot posts an email's attachments into the + # thread under its own card, seconds before our filing lands + # there, so at that moment the thread is still nobody's -- and + # without this the school permission slip is silently dropped. + if self.is_handoff(event): + return True root = self.get_thread_root(event) if root is None: return True diff --git a/tests/stacklets/test_archivist_source.py b/tests/stacklets/test_archivist_source.py index 01fedb1..f1aed5f 100644 --- a/tests/stacklets/test_archivist_source.py +++ b/tests/stacklets/test_archivist_source.py @@ -342,6 +342,27 @@ async def test_bot_attachment_files_without_bot_as_person(tmp_path): assert "Sender: office@school.example" in captured["extra_seed_topics"] +@pytest.mark.asyncio +async def test_bot_attachment_files_inside_the_source_card_thread(tmp_path): + """The shape the mail bot actually sends. + + An email's attachments are posted into the thread under its own + source card, moments after the card and well before the archivist's + filing lands there. At that instant the thread is nobody's, so a bot + that only acts in threads it owns would drop the school permission + slip on the floor. A handoff carries its own address; thread + ownership decides ordinary chat, not this. + """ + bot = _bot(tmp_path) + captured = _wire_file(bot) + threaded = { + **_ATTACH_CONTENT, + "m.relates_to": {"rel_type": "m.thread", "event_id": "$card:server"}, + } + await bot._on_file(_room(), _file_event(threaded)) + assert captured["filename"] == "slip.pdf" + + @pytest.mark.asyncio async def test_unmarked_file_keeps_sender_attribution(tmp_path): # A human upload (no attachment marker) still attributes the sender. From 596a5ab28e438d4756258c8a57c3a9b45fa804d4 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 6 Aug 2026 15:01:26 +0200 Subject: [PATCH 4/9] docs: map the two capture paths and where the agent fits Document filing and notes/bookmarks, what each is for, and the route each takes. The split is fine; the grain is not. Proposes the thread as the unit the agent judges, with unconditional timeline capture kept underneath so a model failure never loses anything. --- docs/design/brain/capture-paths.md | 194 +++++++++++++++++++++++++++++ docs/design/brain/who-answers.md | 2 +- 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 docs/design/brain/capture-paths.md diff --git a/docs/design/brain/capture-paths.md b/docs/design/brain/capture-paths.md new file mode 100644 index 0000000..e7c8f00 --- /dev/null +++ b/docs/design/brain/capture-paths.md @@ -0,0 +1,194 @@ +# What happens to something you want kept + +> Status: Map of what exists, plus a proposed direction for the agent's part +> Created: 2026-08-06 +> Related: [who-answers.md](who-answers.md) (which bot acts at all), [write-layer.md](write-layer.md) (findings 5, 12, 14, 15), [interaction-patterns.md](interaction-patterns.md) + +## Two use cases, and why they stay separate + +**Document filing** is for an artifact you must be able to produce again. +A scanned passport, an invoice, a school permission slip. The bytes are +the point. Paperless is the system of record: it keeps the original, +OCRs it, and gives it a durable numeric id. + +**Notes and bookmarks** are for something you want to recall. A pasted +list, a link, a voice memo, a line from an email. There is no original to +retain; the meaning is the whole thing. A markdown page in the vault +carries it, classified and attributed, versioned by git. + +The test is one question: *would I ever need to produce the original?* +Passport, yes. "Bart has a peanut allergy", no. + +Both paths end in the vault. Only one also ends in Paperless, and the +vault page it writes carries `paperless_id` back to the original. That is +the entire difference in storage, and it is worth keeping. Everything +else that differs between the two is accident, not design. + +## The paths as they are + +| | Document filing | Notes and bookmarks | +|---|---|---| +| **Trigger** | a file or URL in the documents room; a `(` … `)` scan session | a paste, a link, an image or PDF outside the documents room, a voice memo, an email source card, `stack memory capture` | +| **Pipeline** | `DocumentPipeline` (`stacklets/docs/bot/document_pipeline.py`) | `CapturePipeline` (`stacklets/docs/bot/capture_pipeline.py`) | +| **Storage** | Paperless, plus a mirrored vault page | a vault page, e.g. `homer/notes/2026/08/peanut-allergy-3a338e.md` | +| **Identity** | `paperless_id` (int) | vault path | +| **Enrichment** | correspondent, document type, tags, title, date | title, summary, tags, topics, persons, action items to todos | +| **Correction** | reply in the filing thread, `reprocess(doc_id, hint)` | reply in the filing thread, `reprocess(vault_path, hint)` | +| **Envelope** | `document.filed` / `document.reclassified` | `capture.filed` / `capture.reclassified` | + +```mermaid +flowchart LR + subgraph In + F[file upload] + U[link] + P[paste] + V[voice memo] + E[email source card] + C["stack memory capture"] + end + + F -->|documents room| DP[DocumentPipeline] + U -->|documents room| DP + F -->|any other room| CP[CapturePipeline] + U --> CP + P --> CP + V --> CP + E --> CP + C --> CP + + DP --> PL[(Paperless
the original)] + DP --> VM[vault page
+ paperless_id] + CP --> VP[vault page] + + VM --> G[(git mirror)] + VP --> G +``` + +The shape of that picture is right. Two ways in, one of which also keeps +the bytes. The problem is not here. + +## What is actually wrong + +Two things, and neither is the split above. + +**The grain is per message.** A conversation that produces one packing +list produces fourteen notes. write-layer finding 5 recorded it happening: +six re-posts of the same list became six notes and six extractions. The +archivist sees one message at a time and has no concept of "these belong +together", so it cannot produce one artifact from a working session. + +**The judge is the message shape.** Whether something is kept is decided +by whether it is a bare URL, has a URL in it, or is at least 100 +characters long. That decision is made with strictly less information +than anyone in the room has, which is why it produces both silent drops +and filed cookie banners. + +Nobody in the room has that problem. A participant knows the list is +finished and knows it is the same list as before. + +## The direction: the thread is the unit of judgment + +Stacky is a participant. Inside a conversation it is already reading +every turn, so asking it "has this produced something worth keeping" is +close to free, and it is the only component that can answer. + +That maps exactly onto the ownership rule already in force +([who-answers.md](who-answers.md)): + +- **A thread with Stacky is Stacky's.** It reads the turns anyway. When + the work is done it files or, better, *updates* one artifact through + the capture door. The archivist does not touch that thread. +- **The main timeline is the archivist's inbox.** Unconditional, no model + judgment, nothing lost. Drop a receipt, a link, a paste, and it is + kept. +- **Files and the documents room stay the document path**, unchanged. + +The two-tier shape is the load-bearing part. Letting a model decide what +is worth keeping is only safe when the fallback is "kept anyway, just not +consolidated". A single-tier design where Stacky is the sole judge turns +every model failure into a silent, unrecoverable loss, which is the one +failure mode famstack cannot afford: nobody ever finds out that the thing +they typed was never kept. + +```mermaid +sequenceDiagram + actor Marge + participant R as Matrix room + participant S as Stacky + participant A as archivist + + Marge->>R: "Stacky, what do we still need for camping?" + S->>R: answers, opens a thread + + loop the working session + Marge->>R: "add the gas cartridge", in the thread + R--)A: thread is Stacky's, ignores + S->>R: updates vault/family/camping/todos.md + end + + Note over S: one page, updated in place
not fourteen notes + + Marge->>R: drops a campsite booking link
on the main timeline + R->>A: no thread, no judgment, kept + A->>R: "Saved: Campsite booking" +``` + +## What Stacky needs + +Less than it looks, and one of the three is a bug fix that stands on its +own. + +1. **The capture door.** `stack memory capture` already exists and is + explicitly "the same pipeline the archivist runs, not a second way + in". It is not in the agent's skill. So today the agent writes vault + pages with `write_file`, which skips classification, tags, summary, + attribution and the mirror. Two write doors with different guarantees + is the split-brain to close first, independent of everything else + here. **~1h.** + +2. **A consolidation instruction.** At the end of its own turn, decide + whether the conversation has produced something durable, and if so + *update the page* rather than append a note. Updating is what makes it + idempotent, and idempotence is what kills the six-notes problem. + `vault_write.py` was built for exactly this. **~3h, mostly prompt.** + +3. **Nothing else.** In particular: + +**Stacky should not get the document path.** A file upload is already +unambiguous, so there is no judgment to add, and Paperless is the one +store where a wrong write is expensive. It keeps read access +(`stack docs show --content`, which it already has) and files +nothing. Handing it a filing tool would be scope for its own sake. + +## What this deletes + +If the thread carries the judgment and the timeline is unconditional, +the shape tier in `_on_text` has nothing left to decide: `_is_just_url`, +`_first_url`, `looks_like_paste` and the `else: ignored` branch all +collapse into "a human posted on the main timeline, keep it". That is +four branches and the entire class of silent-drop bug. + +Not proposed for today. It is the payoff that makes the direction worth +taking, and it should follow the week of real use, not precede it. + +## Open questions + +1. **When does Stacky decide it is done?** Per turn is cheap but chatty. + End-of-conversation needs a timer, and there is no natural end to a + family chat. Explicit ("save that") is reliable and puts the work back + on the family. Start with per-turn and idempotent updates, since a + wrong "yes" costs a rewrite of a page that keeps its history anyway. + +2. **Where does capture live?** finding 14: `capture_pipeline.py` is in + the docs stacklet and writes no Paperless document. It is a memory + concern wearing a docs coat, and its one docs dependency is + `paperless.get_tags()` for the person roster. + +3. **Two answerers, still.** The archivist's search and the agent's + `memory_search` both answer questions, and `stack docs search` does not + exist (finding 12). Out of scope here; noted so it is not rediscovered. + +4. **Rooms without Stacky.** The two-tier design assumes the archivist's + unconditional timeline capture stays. It does. But a family that never + invites Stacky gets tier one only, and that has to remain a complete + product on its own. diff --git a/docs/design/brain/who-answers.md b/docs/design/brain/who-answers.md index 3cb075e..45cd268 100644 --- a/docs/design/brain/who-answers.md +++ b/docs/design/brain/who-answers.md @@ -2,7 +2,7 @@ > Status: Rule in force for threads, gaps named below > Created: 2026-08-06 -> Related: [interaction-patterns.md](interaction-patterns.md) (how one bot reads intent), [write-layer.md](write-layer.md) (finding 9, order-of-work step 4) +> Related: [capture-paths.md](capture-paths.md) (what happens once a bot does act), [interaction-patterns.md](interaction-patterns.md) (how one bot reads intent), [write-layer.md](write-layer.md) (finding 9, order-of-work step 4) ## Why this exists From 09c8d2a68beb3227c7159882e9f3e62470da7cb0 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 6 Aug 2026 15:21:52 +0200 Subject: [PATCH 5/9] fix(archivist): stop turning chat into notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a room with other people in it, a long message is usually just someone talking. Filing those produced notes named after error messages and stray remarks nobody meant to keep. Now the archivist only files a text message on its own when you are the only person in the room. Everywhere else, react 📌 to keep something, which already works at any length and in any room mode. Links, photos, PDFs, voice memos and documents are unchanged. --- stacklets/docs/bot/archivist.py | 30 +++++++--- stacklets/docs/bot/messages/archivist.yml | 12 ++-- tests/stacklets/test_archivist_routing.py | 70 ++++++++++++++++++++++- 3 files changed, 96 insertions(+), 16 deletions(-) diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 1e11975..a5c0e80 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -1803,22 +1803,34 @@ async def _on_text(self, room, event: RoomMessageText) -> None: sender=event.sender, ) - elif self._looks_like_paste(query): - # Capture room + paste-shaped message, no mention → file - # as text capture. The user is dropping content into the - # room; without an @-tag we treat it as material to keep, - # not a question to answer. + elif self._looks_like_paste(query) and self._count_humans_in_room(room) < 2: + # One person in the room, paste-shaped message, no mention → + # file as text capture. Nobody is being talked *to* here, so + # a long message is material dropped for us to keep. + # + # Above one human the room is a conversation, and length + # stops meaning anything: people write long messages to each + # other all day and almost none of it is meant to be kept. + # Guessing there produced notes titled after somebody's error + # message. So the ambient path is off and 📌 is how you keep + # something -- explicit, visible in the timeline, and already + # the one trigger that ignores every room mode. + # + # Links and files are unaffected in either case: pasting a URL + # or dropping a PDF is a deliberate act, not a turn in a + # conversation. await self._handle_text_capture( room.room_id, query, event.sender, reply_to, capture_id=event.event_id, ) else: - # Short message in a capture room — ignored. Pasting more - # context will trigger capture; chat-shaped messages don't. + # Chat, and nothing else matched. Either short (a capture + # room's own threshold) or long in a room with other people + # in it, where keeping something is 📌 rather than a guess. logger.debug( - "[archivist] capture room {} ignored short text: {!r}", - room.room_id, query[:60], + "[archivist] {} ignored text from {}: {!r}", + room.room_id, event.sender, query[:60], ) # ── Reactions: user → bot per-message routing ──────────────────────── diff --git a/stacklets/docs/bot/messages/archivist.yml b/stacklets/docs/bot/messages/archivist.yml index 33c7677..fef7d14 100644 --- a/stacklets/docs/bot/messages/archivist.yml +++ b/stacklets/docs/bot/messages/archivist.yml @@ -175,7 +175,8 @@ en: **What I'll do here:** - - 📝 **Notes & links:** paste text or paste a URL → filed under `{bucket}/` with the `{slug}` tag + - 🔗 **Links:** paste a URL → filed under `{bucket}/` with the `{slug}` tag + - 📌 **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 - 🎤 **Multi-memo notes:** type `(` then send multiple voice memos, then `)` → combined into one note @@ -202,7 +203,8 @@ en: **What I'll do here:** - - 📝 **Notes & links:** paste text or paste a URL → summarised and filed under the sender's bucket + - 🔗 **Links:** paste a URL → summarised and filed under the sender's bucket + - 📌 **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 @@ -371,7 +373,8 @@ de: **Was ich hier mache:** - - 📝 **Notizen & Links:** Text einfügen oder URL pasten → wird unter `{bucket}/` mit dem Tag `{slug}` abgelegt + - 🔗 **Links:** URL einfügen → wird unter `{bucket}/` mit dem Tag `{slug}` abgelegt + - 📌 **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 - 🎤 **Mehrere Sprachnachrichten:** `(` tippen, mehrere Memos senden, dann `)` → werden zu einer Notiz kombiniert @@ -398,7 +401,8 @@ de: **Was ich hier mache:** - - 📝 **Notizen & Links:** Text einfügen oder URL pasten → wird zusammengefasst und im Bucket des Absenders abgelegt + - 🔗 **Links:** URL einfügen → wird zusammengefasst und im Bucket des Absenders abgelegt + - 📌 **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 diff --git a/tests/stacklets/test_archivist_routing.py b/tests/stacklets/test_archivist_routing.py index 4e5868e..1970fe9 100644 --- a/tests/stacklets/test_archivist_routing.py +++ b/tests/stacklets/test_archivist_routing.py @@ -692,17 +692,76 @@ async def test_mention_in_group_room_routes_to_search(self, bot_with_recorder): assert calls == [("search", "Pollos")] @pytest.mark.asyncio - async def test_paste_without_mention_still_captures(self, bot_with_recorder): + async def test_paste_alone_in_a_room_still_captures(self, bot_with_recorder): """The mention is the gate. Without it, a long paste in a non-docs room continues to route to text capture — mention is - an additive signal, not a replacement for the capture flow.""" + an additive signal, not a replacement for the capture flow. + + One human, so nobody is being talked *to*: a long message here + is material dropped for the archivist.""" bot, calls = bot_with_recorder - room = self._room_obj() + room = self._room_obj(members=[BOT_ID, "@homer:server"]) long_text = "x" * 150 event = self._text_event(long_text) await bot._on_text(room, event) assert calls == [("capture_text", long_text)] + @pytest.mark.asyncio + async def test_paste_with_other_people_present_is_left_alone( + self, bot_with_recorder, + ): + """Two or more people means the room is a conversation, and + length stops meaning anything there: people write long messages + to each other all day and almost none of it is meant to be kept. + Guessing produced notes titled after somebody's error message. + + The archivist keeps quiet. Keeping something is 📌, which is + explicit and visible in the timeline.""" + bot, calls = bot_with_recorder + room = self._room_obj( + members=[BOT_ID, "@homer:server", "@marge:server"], + ) + event = self._text_event("x" * 150) + await bot._on_text(room, event) + assert calls == [] + + @pytest.mark.asyncio + async def test_a_link_is_still_bookmarked_with_people_present( + self, bot_with_recorder, + ): + """Only the guessing stops. Pasting a URL is a deliberate act + rather than a turn in a conversation, so it is filed in any room + exactly as before.""" + bot, calls = bot_with_recorder + room = self._room_obj( + members=[BOT_ID, "@homer:server", "@marge:server"], + ) + event = self._text_event("https://example.com/tent-poles") + await bot._on_text(room, event) + assert calls == [("capture_url", "https://example.com/tent-poles")] + + @pytest.mark.asyncio + async def test_pinning_keeps_it_anyway(self, bot_with_recorder): + """The escape hatch, and the reason turning the guess off is + safe: 📌 on the very message the ambient path ignored files it, + at any length, in any room, under any room mode.""" + bot, calls = bot_with_recorder + room = self._room_obj( + members=[BOT_ID, "@homer:server", "@marge:server"], + ) + long_text = "x" * 150 + target = self._text_event(long_text) + await bot._on_text(room, target) + assert calls == [] + + bot._client = SimpleNamespace( + room_get_event=lambda _r, _e: _resp(target), + ) + await bot._on_reaction(room, SimpleNamespace( + sender="@marge:server", key="📌", reacts_to="$evt:server", + )) + assert calls == [("capture_text", long_text)] + @pytest.mark.asyncio async def test_mention_overrides_capture_for_long_text(self, bot_with_recorder): """A long pasted query *with* a mention is the user explicitly @@ -741,3 +800,8 @@ async def test_mention_with_url_still_routes_url(self, bot_with_recorder): async def _none_coro(): return None + + +async def _resp(event): + """A `room_get_event` response wrapping one event.""" + return SimpleNamespace(event=event) From 6abdf302fc0190a49bace9ddb7d58d5cbf68d7ad Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 6 Aug 2026 15:39:32 +0200 Subject: [PATCH 6/9] test(archivist): pin who answers a message against the rig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two scenarios: chat between people is not filed but 📌 keeps it, and the archivist stays out of a thread another bot is answering in while an @-mention still reaches it there. Parked as `unverified` until a green rig run. --- docs/design/brain/who-answers.md | 8 + tests/integration/test_bot_arbitration_e2e.py | 279 ++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 tests/integration/test_bot_arbitration_e2e.py diff --git a/docs/design/brain/who-answers.md b/docs/design/brain/who-answers.md index 45cd268..a967aba 100644 --- a/docs/design/brain/who-answers.md +++ b/docs/design/brain/who-answers.md @@ -260,3 +260,11 @@ envelope rather than the prose. still lands, the main timeline is unchanged. - `tests/stacklets/test_agent_thread_trigger.py` - the same contract from the agent's side. +- `tests/integration/test_bot_arbitration_e2e.py` - the whole contract + against real containers: real Synapse relations, a real second bot in + the thread, a real 📌. Marked `unverified` until a green rig run. + +Not covered end to end yet: the handoff exemption. A module test pins it +(`test_bot_attachment_files_inside_the_source_card_thread`), but proving +it against a live mail bot needs GreenMail plus the archivist in one +rig lane, which `test_email_imap_e2e.py` does not currently reach. diff --git a/tests/integration/test_bot_arbitration_e2e.py b/tests/integration/test_bot_arbitration_e2e.py new file mode 100644 index 0000000..88036ca --- /dev/null +++ b/tests/integration/test_bot_arbitration_e2e.py @@ -0,0 +1,279 @@ +"""Which bot acts on a message, and which stays quiet — end-to-end INTENT. + +This file is an executable specification of `docs/design/brain/who-answers.md`. +It is the contract; the implementation is expected to satisfy it. + +> STATUS: written 2026-08-06 alongside the fix, NOT yet reconciled against +> the rig. Marked `unverified` until a green run confirms it, per the +> marker's contract in pyproject.toml. + +A famstack room holds several bots and several people, and the invariant +is that exactly one component answers a message. Two rules decide it, and +both were learned the hard way: + + **Chat is not material.** In a room with more than one person in it, + the archivist stops guessing from message shape. A long message there + is usually just somebody talking, and filing those produced notes named + after an agent's error message. Keeping something is 📌, which is + explicit, visible in the timeline, and works at any length under any + room mode. Alone in a room there is nobody to talk to, so a long paste + is still material and is still filed. + + **A thread has an owner.** It belongs to the first bot that replied + into it, other than whoever started it. The archivist acts inside a + thread only when it owns one. Without this, a family's conversation + with the agent was filed as notes, and because the archivist's own card + then sat in the thread, every following line read as a correction to + it: one paste became a reclassification loop that could not be stopped + by talking. + +Why the two rooms below are shaped as they are: + + * the group room has two humans, which is what turns the ambient text + path off. Reactions, links and files are unaffected, and the test + checks that too — the point is that the *guessing* stopped, not that + the archivist went deaf. + * the solo room has one human, so ambient capture is live. That is what + makes it the honest place to test the thread rule: a paste there is + filed on the main timeline and ignored inside another bot's thread, + so the only thing that can explain the difference is ownership. + +stacker-bot stands in for the family agent. The archivist reads the +framework's `-bot` convention, not a specific bot's name, so any famstack +bot exercises the same branch, and stacker-bot is already in the rig +while the agent stacklet may not be. +""" +from __future__ import annotations + +import time + +import pytest +from nio import AsyncClient + +from tests.integration.matrix import ( + event_type, + fetch_room_events, + mxid, + wait_for_room_event, + wait_for_room_events_until, +) + +pytestmark = pytest.mark.unverified + +ARCHIVIST = mxid("archivist-bot") +STACKER = mxid("stacker-bot") +MARGE = mxid("marge") +EYES, CHECK = "👀", "✅" + +# Comfortably over `looks_like_paste`'s 100-character threshold, so the +# only reason the archivist could ignore it is the rule under test. +WALL_OF_TEXT = ( + "Right, the plan is to load the car at four, leave by five, and stop " + "at the halfway services for dinner around seven so nobody has to " + "cook when we arrive." +) + + +def _norm(key: str) -> str: + """Drop the variation selector clients append to an emoji key.""" + return (key or "").replace("\uFE0F", "").strip() + + +def _bot_reacted(events, *, key: str, target: str) -> bool: + """Did the archivist add reaction `key` to event `target`?""" + return any( + event_type(e) == "m.reaction" + and getattr(e, "sender", None) == ARCHIVIST + and getattr(e, "reacts_to", None) == target + and _norm(getattr(e, "key", "")) == key + for e in events + ) + + +def _bot_replied_to(events, target: str) -> bool: + """Did the archivist post a message answering `target`? + + Anchored on the reply relation rather than "the archivist said + something", because its join welcome is already in every room's + timeline and would satisfy the looser check on its own. + """ + for e in events: + if getattr(e, "sender", None) != ARCHIVIST: + continue + relates = ( + (getattr(e, "source", None) or {}) + .get("content", {}) + .get("m.relates_to", {}) + ) + if relates.get("m.in_reply_to", {}).get("event_id") == target: + return True + return False + + +async def _send(client, room_id: str, body: str, **content) -> str: + r = await client.room_send( + room_id, "m.room.message", + {"msgtype": "m.text", "body": body, **content}, + ) + return r.event_id + + +async def _send_in_thread(client, room_id: str, root: str, body: str) -> str: + return await _send(client, room_id, body, **{"m.relates_to": { + "rel_type": "m.thread", "event_id": root, + "is_falling_back": True, "m.in_reply_to": {"event_id": root}, + }}) + + +async def _react(client, room_id: str, target: str, key: str) -> None: + await client.room_send(room_id, "m.reaction", {"m.relates_to": { + "rel_type": "m.annotation", "event_id": target, "key": key}}) + + +async def _wait_until_listening(client, room_id: str) -> None: + """Wait for the archivist's welcome, not merely for it to join. + + A join is an `m.room.member` state event, so a sender-only predicate + clears the instant the invite is accepted, before the bot is + processing anything. Its welcome is its own "I am listening" signal. + Cold start can take ~40s. (Same trap as test_room_modes_e2e.py.) + """ + posted = await wait_for_room_event( + client, room_id, + lambda e: ( + getattr(e, "sender", None) == ARCHIVIST + and (getattr(e, "body", "") or "").strip() != "" + ), + timeout=130, + ) + assert posted, "archivist never posted its welcome, so it is not listening" + + +async def test_chat_between_people_is_not_filed_but_a_pin_is(homer, matrix): + """Two humans in a room: the archivist stops reading intent from + shape, and 📌 is how you override it. + + 1. A wall of text is left alone. No pickup, no reply. + 2. 📌 on that same message files it: 👀 then ✅. + 3. A pasted link is still filed with no reaction needed, because + pasting a URL is a deliberate act rather than a turn in a + conversation. + """ + marge_creds = matrix["marge"] + marge = AsyncClient(marge_creds.homeserver, marge_creds.user_id) + marge.access_token = marge_creds.access_token + marge.device_id = marge_creds.device_id + try: + created = await homer.room_create( + name=f"arbitration-group-{int(time.time())}", + invite=[MARGE, ARCHIVIST], + ) + room = created.room_id + await marge.join(room) + await _wait_until_listening(homer, room) + + # 1. Somebody talking is not material to file. + chat = await _send(homer, room, WALL_OF_TEXT) + quiet = await fetch_room_events(homer, room, duration=40) + assert not _bot_reacted(quiet, key=EYES, target=chat), ( + "a long message in a room with other people in it is chat; " + "the archivist must not pick it up" + ) + + # 2. The override. 📌 says "this one I do want kept", and it is + # the same capture the ambient path would have made. + await _react(homer, room, chat, "📌") + pinned = await wait_for_room_events_until( + homer, room, + lambda events: _bot_reacted(events, key=CHECK, target=chat), + timeout=120, + ) + assert _bot_reacted(pinned, key=EYES, target=chat), \ + "📌 should make the archivist pick the message up (👀)" + assert _bot_reacted(pinned, key=CHECK, target=chat), \ + "a successful pin should be marked ✅" + + # 3. Only the guessing stopped. Links still file on their own. + link = await _send(homer, room, "https://en.wikipedia.org/wiki/Camping") + filed = await wait_for_room_events_until( + homer, room, + lambda events: _bot_reacted(events, key=CHECK, target=link), + timeout=120, + ) + assert _bot_reacted(filed, key=CHECK, target=link), ( + "a pasted link is a deliberate drop, not conversation, and " + "must still be filed without a reaction" + ) + finally: + await marge.close() + + +async def test_the_archivist_stays_out_of_another_bots_thread( + homer, matrix, test_stack, +): + """One human in the room, so ambient capture is live. The same paste + is filed on the main timeline and ignored inside a thread another bot + owns, which leaves ownership as the only explanation. + + An @-mention still reaches the archivist in that thread: deliberate + address beats ambient context, and it is the escape hatch that keeps + the rule from locking the family out. + """ + created = await homer.room_create( + name=f"arbitration-solo-{int(time.time())}", invite=[ARCHIVIST], + ) + room = created.room_id + await _wait_until_listening(homer, room) + + # Baseline: alone in the room there is nobody to talk to, so a long + # paste is material and the ambient path files it. Without this the + # negative below would also pass on a bot that had simply stopped. + solo = await _send(homer, room, WALL_OF_TEXT) + filed = await wait_for_room_events_until( + homer, room, + lambda events: _bot_reacted(events, key=CHECK, target=solo), + timeout=120, + ) + assert _bot_reacted(filed, key=CHECK, target=solo), ( + "with one human in the room the ambient capture path must still " + "file a paste, otherwise this test proves nothing below" + ) + + # Another bot opens a conversation: Homer asks, the bot answers in a + # thread on his question. First reply, and not the thread's starter, + # so the thread is the bot's. + ask = await _send(homer, room, "what do we still need for camping?") + sent = test_stack.run( + "messages", "send", room, + "The gas cartridge and the sleeping mats.", "--thread", ask, + ) + assert sent.get("ok") is not False, f"stacker-bot send failed: {sent}" + claimed = await wait_for_room_event( + homer, room, + lambda e: getattr(e, "sender", None) == STACKER, + timeout=60, + ) + assert claimed, "stacker-bot never posted, so no thread was claimed" + + # The same paste, now inside that conversation. It is Homer talking + # to the other bot, and the archivist has no part in it. + in_thread = await _send_in_thread(homer, room, ask, WALL_OF_TEXT) + ignored = await fetch_room_events(homer, room, duration=40) + assert not _bot_reacted(ignored, key=EYES, target=in_thread), ( + "the thread belongs to the bot that answered in it first; the " + "archivist must not pick up a message there" + ) + + # The escape hatch: addressed on purpose, it answers anyway. + asked = await _send_in_thread( + homer, room, ask, f"{ARCHIVIST} what did we pack last summer", + ) + answered = await wait_for_room_events_until( + homer, room, + lambda events: _bot_replied_to(events, asked), + timeout=120, + ) + assert _bot_replied_to(answered, asked), ( + "an @-mention is deliberate address and must reach the archivist " + "even inside a thread it does not own" + ) From f00520a466548cd90df70a6a46c37557e1641168 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 6 Aug 2026 15:50:31 +0200 Subject: [PATCH 7/9] fix(archivist): let you correct a scan it could not read Replying to "no text recognised, please tag it manually" ran your words as a search instead of applying them. The document that most needs you to describe it was the only one that would not accept a description. Every filed document now carries its filing marker, whether or not the classifier had anything to say about it, so a reply in its thread reclassifies it with your message as the input. --- docs/design/brain/capture-paths.md | 9 +++ stacklets/docs/bot/document_pipeline.py | 44 +++++++++++---- tests/stacklets/test_archivist_corrections.py | 55 +++++++++++++++++++ tests/stacklets/test_document_pipeline.py | 38 ++++++++++++- 4 files changed, 133 insertions(+), 13 deletions(-) diff --git a/docs/design/brain/capture-paths.md b/docs/design/brain/capture-paths.md index e7c8f00..0416f40 100644 --- a/docs/design/brain/capture-paths.md +++ b/docs/design/brain/capture-paths.md @@ -36,6 +36,15 @@ else that differs between the two is accident, not design. | **Correction** | reply in the filing thread, `reprocess(doc_id, hint)` | reply in the filing thread, `reprocess(vault_path, hint)` | | **Envelope** | `document.filed` / `document.reclassified` | `capture.filed` / `capture.reclassified` | +**Invariant: anything filed carries an envelope.** The envelope names +*which* item a message is about; it is not a report of what the +classifier concluded. Both paths emit one as soon as the artifact exists +(a Paperless id, a vault path), including when there was nothing to say +about it. Withholding it makes the bot's own reply a dead end, because +the reply-to-correct walker finds a filing by its envelope: a scan with +no text layer was uncorrectable for exactly this reason, and that is the +case where the family types the classification in themselves. + ```mermaid flowchart LR subgraph In diff --git a/stacklets/docs/bot/document_pipeline.py b/stacklets/docs/bot/document_pipeline.py index bca2e75..0eedaf7 100644 --- a/stacklets/docs/bot/document_pipeline.py +++ b/stacklets/docs/bot/document_pipeline.py @@ -227,6 +227,7 @@ async def process( return FilingOutcome( status="filed_no_details", display_name=display_name, doc_id=doc_id, link=link, + envelope=self._filed_envelope(doc_id, {}), ) ocr_text = doc.get("content", "") or "" @@ -278,18 +279,13 @@ async def process( paperless_tags=paperless_tags, summary=result.summary, ) - envelope = None - if classification: - envelope = build_document_event( - doc_id, classification, - resolved_topics=result.resolved_topics, - resolved_persons=result.resolved_persons, - resolved_correspondent=result.resolved_correspondent, - resolved_type=result.resolved_type, - link_base_url=self.link_base_url, - actor=self.actor, - ts=utc_now_isoformat(), - ) + envelope = self._filed_envelope( + doc_id, classification, + resolved_topics=result.resolved_topics, + resolved_persons=result.resolved_persons, + resolved_correspondent=result.resolved_correspondent, + resolved_type=result.resolved_type, + ) return FilingOutcome( status="enriched", display_name=display_name, doc_id=doc_id, link=link, @@ -376,6 +372,30 @@ def _mirror_body(self, is_text, file_data, formatted, ocr_text, *, reformatted): return body_text, "ai_formatted", model return body_text, "ocr", None + def _filed_envelope(self, doc_id: int, classification: dict, **resolved) -> dict: + """The `document.filed` envelope for a document that reached Paperless. + + Emitted for every filed document, including the ones we could + say nothing about. The envelope names *which document a message + is about*; it is not a report of what the classifier concluded, + and the document exists either way. + + That distinction is load-bearing. It is what makes the bot's + reply correctable: the reply-to-correct walker finds a filing by + its envelope, so a filing without one silently becomes a dead + end, and the user's correction routes to search instead. A scan + with no text layer is exactly the case where the family types + the classification in themselves, so it is the last filing that + can afford to be uncorrectable. + """ + return build_document_event( + doc_id, classification, + link_base_url=self.link_base_url, + actor=self.actor, + ts=utc_now_isoformat(), + **resolved, + ) + async def reprocess( self, *, doc_id: int, user_hint: str, date_filed: str | None = None, initial_classification: dict | None = None, diff --git a/tests/stacklets/test_archivist_corrections.py b/tests/stacklets/test_archivist_corrections.py index 082baca..8bd9d9b 100644 --- a/tests/stacklets/test_archivist_corrections.py +++ b/tests/stacklets/test_archivist_corrections.py @@ -97,6 +97,25 @@ def _filed(paperless_id=DOC_ID, **data): } +def _filed_bare(paperless_id=DOC_ID): + """The envelope a filing with nothing to say still carries. + + A scan with no text layer reaches Paperless and the classifier + produces nothing, so every field is empty except the one that + matters: which document this message is about. + """ + return { + "source": "docs", "type": "document.filed", + "summary": f"Document #{paperless_id} filed", + "data": { + "paperless_id": paperless_id, "title": "", "date": None, + "topics": [], "persons": [], "correspondent": None, + "document_type": None, "summary": "", "facts": [], + "action_items": [], + }, + } + + def _reclassified(paperless_id=DOC_ID, **data): return { "source": "docs", "type": "document.reclassified", @@ -321,6 +340,42 @@ async def test_latest_classification_in_the_thread_wins(self, bot, filed_thread) assert bot.routed[0][0] == "reprocess" assert bot.routed[0][3] == {"paperless_id": DOC_ID, "persons": ["Marge"]} + @pytest.mark.asyncio + async def test_a_filing_with_nothing_to_say_is_still_correctable(self, bot): + """The case that matters most, and the one that was broken. + + A scan with no text layer files fine and the archivist has + nothing to add: "no text recognised, please tag it manually in + Paperless". That is exactly the moment a person types the + classification in themselves. Their words are the only + description the document will ever have, so the reply has to + reach reprocess and carry them as the hint. + + It used to run as a search, which answered a question nobody + asked and quietly lost the correction. + """ + client = bot._client + client.add(_message("$scan", HOMER, "Gescannt_20260806-1532.pdf")) + client.add( + _message("$filed", BOT_ID, + "Filed: Gescannt_20260806-1532.pdf — no text recognised", + content=_thread_relation("$scan"), + envelope=_filed_bare()), + thread_root="$scan", + ) + hint = ('Classify as "Grundriss". It is a house plan for the ' + 'Mühlenstr. Tag it "haus" and "mühlenstr".') + event = _message( + "$correction", HOMER, hint, + content=_thread_relation("$scan", falls_back_to="$filed"), + ) + await bot._on_text(_docs_room(), event) + assert [r[0] for r in bot.routed] == ["reprocess"], \ + f"expected a reclassification, got {bot.routed}" + assert bot.routed[0][1] == DOC_ID + assert bot.routed[0][2] == hint, \ + "the user's own words are the prompt input for the reclassify" + @pytest.mark.asyncio async def test_capture_thread_reaches_the_capture_pipeline(self, bot): """Captures thread the same way and correct the same way; the diff --git a/tests/stacklets/test_document_pipeline.py b/tests/stacklets/test_document_pipeline.py index a1573c9..1f331d1 100644 --- a/tests/stacklets/test_document_pipeline.py +++ b/tests/stacklets/test_document_pipeline.py @@ -150,9 +150,45 @@ async def test_no_text_skips_classification(self): assert out.status == "enriched" assert out.has_text is False assert out.classification == {} - assert out.envelope is None assert len(mirror.published) == 1 # mirrored regardless + @pytest.mark.asyncio + async def test_a_filing_with_nothing_to_say_still_names_its_document(self): + """A scan with no text layer lands in Paperless and the LLM has + nothing to add. It still gets an envelope. + + The envelope's job is to say *which document this message is + about*, not to report what we concluded, and that is true the + moment the document exists. Withholding it made the reply + uncorrectable: the archivist could not tell that "classify this + as a floor plan" was aimed at document #5, so it ran the words + as a search instead. The document that most needs a human was + the only one that could not accept one. + """ + doc = {"id": 5, "content": "x"} + out = await _process( + _pipeline(FakePaperless(doc_id=5, doc=doc), mirror=FakeMirror()), + data=b"x", + ) + assert out.classification == {}, "nothing was classified" + assert out.envelope is not None, \ + "a filed document must be correctable even with no details" + assert out.envelope["type"] == "document.filed" + assert out.envelope["data"]["paperless_id"] == 5 + + @pytest.mark.asyncio + async def test_an_unreadable_filing_also_names_its_document(self): + """Same rule one branch earlier: the upload was accepted and the + document exists, so a reply about it has a target, even though + Paperless never gave us anything back to read.""" + out = await _process( + _pipeline(FakePaperless(doc_id=7, doc=None), mirror=FakeMirror()), + ) + assert out.status == "filed_no_details" + assert out.envelope is not None, \ + "a filed document must be correctable even when unreadable" + assert out.envelope["data"]["paperless_id"] == 7 + @pytest.mark.asyncio async def test_classify_disabled_files_without_llm(self): doc = {"id": 6, "content": "a fully readable document body here"} From 11b0d9f255835a2ae19839dfdba5c3b4cb0ce3a8 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 6 Aug 2026 17:08:08 +0200 Subject: [PATCH 8/9] fix(archivist): tell you a pinned note landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning a message filed it in silence and only showed a result once the classifier was done, which on a local model is half a minute of nothing. It now reacts 👀 the moment it picks the message up, the same as a pasted link or an uploaded file. Notes were the one capture shape without it, and 📌 is the only way to keep something in a room with other people in it, so it was the gesture that most needed the reassurance. --- stacklets/docs/bot/archivist.py | 8 ++ tests/stacklets/test_archivist_routing.py | 108 ++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index a5c0e80..943f840 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -2346,6 +2346,14 @@ async def _handle_text_capture( self, room_id: str, text: str, sender_mxid: str, reply_to: str | None = None, *, capture_id: str | None = None, ) -> None: + # 👀 before the work, like every other capture shape: a link gets + # it from the pipeline's notifier, an upload from the handler. A + # note was the one path that filed in silence, and classification + # on a local model is seconds of it. That silence matters more + # now than it did -- in a room with other people 📌 is the only + # way to keep something, so this is the gesture with no fallback. + if reply_to: + await self._react(room_id, reply_to, EYES) binding = await self._topic_binding( self._room_by_id(room_id), sender_mxid, ) diff --git a/tests/stacklets/test_archivist_routing.py b/tests/stacklets/test_archivist_routing.py index 1970fe9..af70d3f 100644 --- a/tests/stacklets/test_archivist_routing.py +++ b/tests/stacklets/test_archivist_routing.py @@ -394,6 +394,114 @@ async def test_bot_authored_target_not_bookmarked(self, tmp_path): assert not cap and not txt +class TestPinnedNoteCapture: + """What a person gets back for pinning a message. + + 📌 is now the only way to keep a message in a room with other + people in it: the ambient path reads a long message there as + conversation and leaves it alone. That promotion is what makes + both of these matter. + + A gesture that is the sole way in has to say it registered. Filing + a note runs a classifier, which is seconds of nothing on a local + model, and the reactor is left wondering whether the pin took. 👀 + is the same "picked this up" the bot already gives a pasted link + and an uploaded file; a pinned note was the one path without it. + + And a note kept in a topic room belongs to that topic. The reactor + is not necessarily the author and neither of them is the point -- + the room is. Filing it under whoever tapped the emoji buries a + shared topic's note in a personal bucket, where the rest of the + family's search scopes (`['family/', '/']`) will never see + it. + """ + + TOPIC_BINDING = SimpleNamespace( + bucket="family/camping", + seed_topics=["camping"], + display_name="Camping", + scope="shared", + slug="camping", + ) + + def _bot(self, tmp_path, *, binding, target_body="remember the boiler service"): + """A bot whose capture pipeline records instead of filing. + + `_reply_for_capture` is stubbed: the outcome-to-reply mapping + (✅ / ❌ / the threaded card) is pinned in its own tests, and + letting it run here would drag the presenter in for no gain. + What this class asserts is what happens *before* the reply -- + the acknowledgement and the routing. + """ + bot = _build_bot(tmp_path) + captures, reactions = [], [] + + class _RecordingPipeline: + async def capture_text(self, **kwargs): + captures.append(kwargs) + return SimpleNamespace(status="captured") + + async def _react(room_id, event_id, emoji): + reactions.append((event_id, emoji)) + + async def _binding(_room, _sender): + return binding + + async def _reply(*_a, **_kw): + return None + + bot._capture = _RecordingPipeline() + bot._react = _react + bot._topic_binding = _binding + bot._reply_for_capture = _reply + + async def _get_event(room_id, event_id): + return SimpleNamespace(event=SimpleNamespace( + sender="@marge:server", body=target_body, + source={"content": {"body": target_body}}, + )) + + bot._client = SimpleNamespace( + room_get_event=_get_event, rooms={"!r:server": _room()}, + ) + return bot, captures, reactions + + @staticmethod + def _pin(sender="@homer:server"): + return SimpleNamespace( + key="📌", reacts_to="$tgt", sender=sender, source={"content": {}}, + ) + + async def test_pinning_acknowledges_the_message_it_will_file(self, tmp_path): + """👀 lands on the pinned message, so the reactor knows the pin + took while the classifier is still working.""" + bot, _captures, reactions = self._bot(tmp_path, binding=None) + await bot._on_reaction(_room(), self._pin()) + assert ("$tgt", "\U0001F440") in reactions, ( + "a pinned message must be acknowledged on the message itself" + ) + + async def test_a_pin_in_a_topic_room_files_under_that_topic(self, tmp_path): + """The room decides the bucket. A note pinned in `Thema: Camping` + is the family's camping note, not the pinner's.""" + bot, captures, _reactions = self._bot( + tmp_path, binding=self.TOPIC_BINDING, + ) + await bot._on_reaction(_room(), self._pin()) + assert len(captures) == 1 + assert captures[0]["bucket"] == "family/camping" + assert captures[0]["seed_topics"] == ["camping"] + + async def test_a_pin_outside_a_topic_room_keeps_sender_routing(self, tmp_path): + """No topic binding, no override. A plain room still files under + the message's author, which is what the personal bucket is for.""" + bot, captures, _reactions = self._bot(tmp_path, binding=None) + await bot._on_reaction(_room(), self._pin()) + assert len(captures) == 1 + assert captures[0]["bucket"] is None + assert captures[0]["sender_mxid"] == "@marge:server" + + class TestRetryReaction: """🔁 / 🔄 — the recovery gesture for a filing that failed. From cd8e9c8f11bab68e33bd81cef5d4272ac0e6858b Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 6 Aug 2026 17:08:17 +0200 Subject: [PATCH 9/9] test(archivist): confirm who answers a message against the rig The arbitration spec runs green now, so the unverified marker comes off. stacker-bot is invited when the room is created: the room is private, and the send path joins as a plain client, which Synapse refuses. The failed send returned an error dict that the guard read as a pass, so it now checks for success outright. Room names and pasted text carry the scope uid so a run's leavings can be found in a live vault. --- tests/integration/test_bot_arbitration_e2e.py | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/tests/integration/test_bot_arbitration_e2e.py b/tests/integration/test_bot_arbitration_e2e.py index 88036ca..9238049 100644 --- a/tests/integration/test_bot_arbitration_e2e.py +++ b/tests/integration/test_bot_arbitration_e2e.py @@ -3,9 +3,13 @@ This file is an executable specification of `docs/design/brain/who-answers.md`. It is the contract; the implementation is expected to satisfy it. -> STATUS: written 2026-08-06 alongside the fix, NOT yet reconciled against -> the rig. Marked `unverified` until a green run confirms it, per the -> marker's contract in pyproject.toml. +> STATUS: verified green through `stacktests` on 2026-08-06. Two things +> had to change to get there. stacker-bot was left to join the room on +> its own, but the room is private and `stack messages send` joins as a +> plain client, so Synapse turned it away and the send guard read the +> error dict as a pass; it is invited at creation now. And the pinned +> note filed in silence -- 👀 was missing from the one capture shape +> that had no notifier -- which is what the first test caught. A famstack room holds several bots and several people, and the invariant is that exactly one component answers a message. Two rules decide it, and @@ -45,9 +49,6 @@ """ from __future__ import annotations -import time - -import pytest from nio import AsyncClient from tests.integration.matrix import ( @@ -58,8 +59,6 @@ wait_for_room_events_until, ) -pytestmark = pytest.mark.unverified - ARCHIVIST = mxid("archivist-bot") STACKER = mxid("stacker-bot") MARGE = mxid("marge") @@ -67,11 +66,17 @@ # Comfortably over `looks_like_paste`'s 100-character threshold, so the # only reason the archivist could ignore it is the rule under test. -WALL_OF_TEXT = ( - "Right, the plan is to load the car at four, leave by five, and stop " - "at the halfway services for dinner around seven so nobody has to " - "cook when we arrive." -) +# +# A function, not a constant: the rig runs against a live instance whose +# vault keeps whatever gets filed, and an LLM titles a capture from its +# content, so nothing downstream carries the test's scope uid unless the +# text does. Stamping it here is what makes a run's leavings findable. +def wall_of_text(scope) -> str: + return ( + "Right, the plan is to load the car at four, leave by five, and stop " + "at the halfway services for dinner around seven so nobody has to " + f"cook when we arrive. [ref: {scope.uid}]" + ) def _norm(key: str) -> str: @@ -149,7 +154,7 @@ async def _wait_until_listening(client, room_id: str) -> None: assert posted, "archivist never posted its welcome, so it is not listening" -async def test_chat_between_people_is_not_filed_but_a_pin_is(homer, matrix): +async def test_chat_between_people_is_not_filed_but_a_pin_is(homer, matrix, scope): """Two humans in a room: the archivist stops reading intent from shape, and 📌 is how you override it. @@ -165,7 +170,7 @@ async def test_chat_between_people_is_not_filed_but_a_pin_is(homer, matrix): marge.device_id = marge_creds.device_id try: created = await homer.room_create( - name=f"arbitration-group-{int(time.time())}", + name=f"arbitration-group-{scope.uid}", invite=[MARGE, ARCHIVIST], ) room = created.room_id @@ -173,7 +178,7 @@ async def test_chat_between_people_is_not_filed_but_a_pin_is(homer, matrix): await _wait_until_listening(homer, room) # 1. Somebody talking is not material to file. - chat = await _send(homer, room, WALL_OF_TEXT) + chat = await _send(homer, room, wall_of_text(scope)) quiet = await fetch_room_events(homer, room, duration=40) assert not _bot_reacted(quiet, key=EYES, target=chat), ( "a long message in a room with other people in it is chat; " @@ -209,7 +214,7 @@ async def test_chat_between_people_is_not_filed_but_a_pin_is(homer, matrix): async def test_the_archivist_stays_out_of_another_bots_thread( - homer, matrix, test_stack, + homer, matrix, test_stack, scope, ): """One human in the room, so ambient capture is live. The same paste is filed on the main timeline and ignored inside a thread another bot @@ -219,8 +224,12 @@ async def test_the_archivist_stays_out_of_another_bots_thread( address beats ambient context, and it is the escape hatch that keeps the rule from locking the family out. """ + # stacker-bot is invited at creation, not left to join: the room is + # private, `stack messages send` joins as a plain client, and Synapse + # turns that away. Bots don't count toward the human total that gates + # ambient capture, so its presence leaves the premise intact. created = await homer.room_create( - name=f"arbitration-solo-{int(time.time())}", invite=[ARCHIVIST], + name=f"arbitration-solo-{scope.uid}", invite=[ARCHIVIST, STACKER], ) room = created.room_id await _wait_until_listening(homer, room) @@ -228,7 +237,7 @@ async def test_the_archivist_stays_out_of_another_bots_thread( # Baseline: alone in the room there is nobody to talk to, so a long # paste is material and the ambient path files it. Without this the # negative below would also pass on a bot that had simply stopped. - solo = await _send(homer, room, WALL_OF_TEXT) + solo = await _send(homer, room, wall_of_text(scope)) filed = await wait_for_room_events_until( homer, room, lambda events: _bot_reacted(events, key=CHECK, target=solo), @@ -247,7 +256,7 @@ async def test_the_archivist_stays_out_of_another_bots_thread( "messages", "send", room, "The gas cartridge and the sleeping mats.", "--thread", ask, ) - assert sent.get("ok") is not False, f"stacker-bot send failed: {sent}" + assert sent.get("ok") is True, f"stacker-bot send failed: {sent}" claimed = await wait_for_room_event( homer, room, lambda e: getattr(e, "sender", None) == STACKER, @@ -257,7 +266,7 @@ async def test_the_archivist_stays_out_of_another_bots_thread( # The same paste, now inside that conversation. It is Homer talking # to the other bot, and the archivist has no part in it. - in_thread = await _send_in_thread(homer, room, ask, WALL_OF_TEXT) + in_thread = await _send_in_thread(homer, room, ask, wall_of_text(scope)) ignored = await fetch_room_events(homer, room, duration=40) assert not _bot_reacted(ignored, key=EYES, target=in_thread), ( "the thread belongs to the bot that answered in it first; the "