diff --git a/docs/design/brain/capture-paths.md b/docs/design/brain/capture-paths.md new file mode 100644 index 0000000..0416f40 --- /dev/null +++ b/docs/design/brain/capture-paths.md @@ -0,0 +1,203 @@ +# 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` | + +**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 + 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/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..a967aba --- /dev/null +++ b/docs/design/brain/who-answers.md @@ -0,0 +1,270 @@ +# Who answers a message? + +> Status: Rule in force for threads, gaps named below +> Created: 2026-08-06 +> 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 + +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. + +**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. + 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. + +## 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 | +|---|---|---| +| **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. +- `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/stacklets/core/bot-runner/microbot.py b/stacklets/core/bot-runner/microbot.py index 528c40d..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. @@ -857,6 +869,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..943f840 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -1056,6 +1056,52 @@ 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. + """ + # 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 + 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 +1528,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 +1673,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" @@ -1746,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 ──────────────────────── @@ -2277,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/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/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/integration/test_bot_arbitration_e2e.py b/tests/integration/test_bot_arbitration_e2e.py new file mode 100644 index 0000000..9238049 --- /dev/null +++ b/tests/integration/test_bot_arbitration_e2e.py @@ -0,0 +1,288 @@ +"""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: 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 +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 + +from nio import AsyncClient + +from tests.integration.matrix import ( + event_type, + fetch_room_events, + mxid, + wait_for_room_event, + wait_for_room_events_until, +) + +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. +# +# 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: + """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, scope): + """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-{scope.uid}", + 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(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; " + "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, 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 + 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. + """ + # 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-{scope.uid}", invite=[ARCHIVIST, STACKER], + ) + 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(scope)) + 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 True, 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(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 " + "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" + ) diff --git a/tests/stacklets/test_archivist_corrections.py b/tests/stacklets/test_archivist_corrections.py index 528beac..8bd9d9b 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 @@ -92,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", @@ -106,9 +130,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 +151,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] @@ -311,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 @@ -406,6 +471,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_archivist_routing.py b/tests/stacklets/test_archivist_routing.py index 4e5868e..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. @@ -692,17 +800,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 +908,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) 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. 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"} 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 ────────────────────────────