Quellcode durchsuchen

Merge docs/read-tools-review: cursor paging and membership checks for the read tools

Two changes that never met each other, so the conflicts are worth recording:

The branch taught chatto_get_history to page with opaque cursors and made the
read tools refuse an unjoined room. The branch point predates the tools and the
search work on main, so its documentation still said the read tools reach any
room the bot can see — which its own membership check had just made false, and
after-install.md was not updated for it. The merged text says membership is the
rule for `chatto_get_message`/`chatto_get_history`, while the mention lists say
nothing about any read tool.

`chatto_search_messages` is the one read tool left outside that check on
purpose: it does not read a room, it asks for hits and the server returns only
those from rooms the caller may read. Gating it here would refuse searches the
server would answer.

`_not_joined_refusal` grew an `action` argument on the branch; the two callers
that arrived on main in the meantime (chatto_send_ephemeral, chatto_pin_message)
still used the single-argument form, which basedpyright caught but the suite did
not — both now name their own action, and a test each pins the wording so the
pin cannot inherit "cannot post there".
Paul Klumpp vor 1 Woche
Ursprung
Commit
d967002479
6 geänderte Dateien mit 308 neuen und 39 gelöschten Zeilen
  1. 42 10
      AGENTS.md
  2. 7 7
      README.md
  3. 64 12
      adapter.py
  4. 1 1
      plugin.yaml
  5. 9 7
      skills/chatto-platform-plugin/SKILL.md
  6. 185 2
      test_adapter.py

+ 42 - 10
AGENTS.md

@@ -228,10 +228,15 @@ changes must keep them, or consciously renegotiate the docs.**
   posted there would break the read-only promise the same paragraph makes, and
   `chatto_pin_message` refuses it too — a pin changes what the room shows
   everyone even though it posts nothing. The read tools are deliberately
-  exempt — `chatto_get_message`, `chatto_get_history` and
-  `chatto_search_messages` post nothing and
-  answer nothing, so reading a room grants no privilege the bot's token did not
-  already have and the server's own visibility rules are the only gate.
+  exempt from *that list* — `chatto_get_message`, `chatto_get_history` and
+  `chatto_search_messages` post nothing and answer nothing, so the mention
+  lists have nothing to say about them — but not from membership:
+  `chatto_get_message` and `chatto_get_history` refuse an unjoined room through
+  `_not_joined_refusal`, because every read RPC they use documents room
+  membership as a precondition (`GetRoomEvents`, `GetMessage`,
+  `BatchGetMessages`), and a bare PERMISSION_DENIED names neither the room nor
+  the way in. `chatto_search_messages` needs no such check: it searches only
+  the rooms the caller can read and the server re-checks every hit.
   `CHATTO_HOME_CHANNEL` is orthogonal: it is the default outbound target for
   context-less cron/notification delivery, not an answer destination.
 - **Threads stay threads.** An inbound thread reply keeps its thread context;
@@ -564,7 +569,7 @@ months).
 
 **The two send paths, one implementation.** A user cannot tell whether the agent
 replied on its own or through a tool, so the two must not behave differently.
-Three rules already exist once each, and each answers for both callers:
+Two rules already exist once each, and each answers for both callers:
 
 - **`_room_answers_in`** — whether the bot takes part in a room. It is both the
   *inbound* gate (`ChattoAdapter._answers_in_room`) and the *outbound* one
@@ -583,16 +588,28 @@ Three rules already exist once each, and each answers for both callers:
   is a third caller and posts through `_post_chunks`, so it inherits the split
   and the partial-failure refusal; a posting tool that called `post_message`
   directly would be the copy this section exists to prevent.
-- **`_resolve_thread_root`** — "this event ID is a root of a thread in *this*
-  room", which both tools taking a `thread_root_event_id` need
-  (`_send_room` posting into one, `_get_history` reading one). Two copies
-  would drift into two tools disagreeing about the same event ID, which is
-  exactly the failure the shared helper exists to prevent.
 
 The standalone cron sender keeps its own copy on purpose (out of process, no
 adapter — see *Why `vendor/` exists*), so agreement there is on the shared
 constants, not on shared code.
 
+**One rule, several tools — and the send path is not the only case.** The same
+shape appears wherever two tools answer the same question about the same event:
+**`_resolve_thread_root`** decides "is this event ID a root of a thread in *this*
+room" for both tools taking a `thread_root_event_id` (`_send_room` posting into
+one, `_get_history` reading one), and **`_not_joined_refusal`** answers "may the
+bot act here at all" for all three (`_send_room` posting, `_get_message` and
+`_get_history` reading — every read RPC documents room membership as a
+precondition, so reading is not a way around the send gate).
+`chatto_search_messages` sits outside that rule on purpose: it never reads a
+room, it asks for hits and the server returns only those from rooms the caller
+may read, so a membership gate here would refuse searches the server would
+answer. Only one of the
+first pair sends, so neither rule belongs to the pair above — but the drift they
+prevent is identical: two copies would leave two tools disagreeing about the
+same room or the same event ID, and the caller has no way to tell which one to
+believe.
+
 **State questions: check what exists before adding.** The sweep above catches
 dead state after the fact; the cheaper move is to not create parallel state
 in the first place. Before introducing a new instance field, look through the
@@ -639,6 +656,21 @@ configuration docs must be able to predict what happens when they set nothing.
 Secrets (`CHATTO_TOKEN`, `CHATTO_PASSWORD`) belong in `~/.hermes/.env`, never
 in `config.yaml` — docs and setup wizard rely on that split.
 
+### `plugin.yaml`'s `version:` names a branch, not a release
+
+**This plugin ships no releases, so the field says `main` and is not bumped per
+feature.** The alternative — a `1.0.1` that quietly goes stale, as it did while
+two tools were added without a bump — promises a release cadence nobody runs.
+
+Hermes does not validate the field (`plugins_manifest.py` reads it as a plain
+optional string) and, for a git-class install, never compares it: the update
+check resolves SHA against SHA via `ls-remote` (`plugins_updates.py:222-234`).
+The one reader that parses it is the release-feed branch, where a non-PEP-440
+value yields `update_available = None` — "unknown", not a false update
+(`_version_is_newer`). So the cost lands only if a feed ever appears, and by then
+it is a decision to make, not an accident to inherit. A day that happens, the
+feed's `version:` and this paragraph are the two things that move together.
+
 A raw value from **anywhere** is parsed by the field's kind before anything
 reads it. `.env` lines are strings whatever the field means: a list field
 holding `"a,b"` turns `room_id in require_mention + optional_mention` into a

+ 7 - 7
README.md

@@ -218,15 +218,15 @@ Hermes sends messages with full Markdown formatting. Chatto renders Markdown nat
 
 When you reply to a message in Chatto (creating a thread), Hermes responds within that thread. Thread context stays isolated from the parent room — each thread has its own session namespace. With auto-threading enabled (default), fresh room replies open a thread under the incoming message, keeping busy-room timelines clean. The bot auto-follows threads it participates in.
 
-#### Thread history on the first turn
+#### Thread history in context
 
-A thread's session starts empty, so the turn that opens a thread would otherwise be answered with nothing but the reply itself. The first turn into a thread therefore also carries what the thread already said, as `[Earlier in this thread]` above the message text — one line per message, oldest first, at most 20 messages and 2000 characters. Over that budget the oldest messages are dropped and counted in an `… N earlier message(s) omitted` line.
+A thread's session starts empty, so the turn that opens a thread would otherwise be answered with nothing but the reply itself. Turns inside that thread therefore also carry what the thread said, as `[Earlier in this thread]` above the message text — one line per message, oldest first, at most 20 messages and 2000 characters. Over that budget the oldest messages are dropped and counted in an `… N earlier message(s) omitted` line, and the rest arrives with the following turns rather than all at once. For anything further back, the agent can read it on request with `chatto_get_history`.
 
-This matters most for a reply to something the bot posted itself. A cron job's message is a plain room message, and the bot's own realtime echo is filtered as a self-event, so it never enters any session. When someone replies to it, the thread it roots is new — without this block the agent answers a message it cannot read. Messages the bot posted are labelled `hermes_bot (this bot):` so it recognises them as its own output.
+Messages the bot posted are labelled `hermes_bot (this bot):` so it recognises them as its own output. Two situations turn on that. A reply to something the bot posted itself — a cron report, say, which enters no session because the bot's own realtime echo is filtered as a self-event — roots a thread whose first turn would otherwise be blind: without the block the agent answers a message it cannot read. And a message the bot posts into a live thread through a tool never becomes input either, which is what later turns keep carrying it for.
 
 Messages in the thread from people outside `CHATTO_ALLOWED_USERS` are prefixed `(unverified)`. Only the message that triggers a turn passes the auth gate, so the rest of a shared thread is reference text, not trusted input; an author the adapter has never seen is marked unverified too. With `CHATTO_ALLOW_ALL_USERS=true` nothing is marked.
 
-Seeding happens once per session, not per message and not per restart: later turns in the thread need no block because the session already holds that history, and neither does the first turn after the Hermes gateway restarts. If the lookup fails, the turn proceeds without it and the next turn in that thread retries. Direct messages never thread and never seed.
+Nothing is delivered twice: each turn gets what the thread gained since the last one, and a Hermes gateway restart does not lose that position. If the lookup fails, the turn proceeds without the block and the next turn in that thread retries. Direct messages never thread and never seed.
 
 How the plugin keeps track of that is a developer question:
 [CONCEPTS.md](CONCEPTS.md#thread-sessions-and-the-frontier).
@@ -415,7 +415,7 @@ Beyond answering inbound messages, the agent can act on Chatto on its own initia
 | `chatto_send_room` | Posts a message into a channel or DM room, by `room_id` or `room_name`. Can post inside an existing thread (`thread_root_event_id`) or open a new one (`create_thread`). Either way the bot follows the thread afterwards, so later replies in it reach the agent. |
 | `chatto_send_dm` | Posts a message into the DM room you already have with a person, addressed by `user_id` or `user_login`. |
 | `chatto_get_message` | Reads one message (or several, via `event_ids`, answered in the order you list them) with all its data: body, author, timestamps, reply and thread references, attachments, reactions, pinned and deleted status. `follow_reply_chain` (0-10) additionally resolves the messages that message replies to, oldest-first — use it to resolve a `reply_to=` reference. It works with a single `event_id` only, because a chain belongs to one message. |
-| `chatto_get_history` | Reads the most recent messages of a room (or of one thread, via `thread_root_event_id`), oldest-first, in a page of up to `limit` messages (1-100, default 20). `has_older` says whether older history exists. |
+| `chatto_get_history` | Reads the most recent messages of a room (or of one thread, via `thread_root_event_id`), oldest-first, in a page of up to `limit` messages (1-100, default 20). To read further back, pass the previous answer's `start_cursor` as `before` (or `end_cursor` as `after`); `has_older` tells you whether there is anything left to page to, `has_newer` whether anything landed after the page. One call reads one page; `before` and `after` cannot be combined. |
 | `chatto_search_messages` | Full-text search over the messages the bot can read. Use it to find something said earlier when you know the words but not the room, thread or day. The query supports required words, `"exact phrases"`, `AND`, and the filters `in:`, `from:`, `before:`, `after:` and `has:attachment`; it can additionally be scoped by `room_id`/`room_name`, `author_id` and `created_after`/`created_before`. `group_by: thread` returns one row per matching thread with its root, reply count and latest reply; `scope: followed_threads` restricts the search to threads the bot follows. Pages of up to `limit` (1-100, default 20) with `next_cursor` for the next one. Requires the Chatto server operators to have enabled search — see [Prerequisites](#prerequisites). |
 | `chatto_list_room_attachments` | Lists the file attachments of a room with their ID, filename, MIME type and size, in pages of up to `limit` (default 40, offset-paged). `has_more` and `total_count` describe the rest of the list. |
 | `chatto_list_pinned_messages` | Lists a room's pinned messages with their event ID, body, author and posting time, in pages of up to `limit` (default 20, offset-paged). |
@@ -428,11 +428,11 @@ Beyond answering inbound messages, the agent can act on Chatto on its own initia
 
 Five boundaries are deliberate, and each tool reports them as an error instead of half-doing the job:
 
-- **A channel must be opted in.** `chatto_send_room` and `chatto_send_ephemeral` only post into a room listed in `CHATTO_REQUIRE_MENTION_ROOMS` or `CHATTO_OPTIONAL_MENTION_ROOMS` (DM rooms are never gated). A room on neither list stays read-only — a human message there would never have produced output either. The refusal names both settings so you can paste the room ID into `~/.hermes/.env`. The read tools are exempt: `chatto_get_message`, `chatto_get_history`, `chatto_search_messages`, `chatto_list_room_attachments`, `chatto_list_pinned_messages` and `chatto_list_room_groups` read any room the server lets the bot see, because reading answers nothing and posts nothing — the server's own visibility rules are the only gate.
+- **A channel must be opted in.** `chatto_send_room` and `chatto_send_ephemeral` only post into a room listed in `CHATTO_REQUIRE_MENTION_ROOMS` or `CHATTO_OPTIONAL_MENTION_ROOMS` (DM rooms are never gated). A room on neither list stays read-only — a human message there would never have produced output either. The refusal names both settings so you can paste the room ID into `~/.hermes/.env`. The read tools are exempt from that rule — `chatto_get_message`, `chatto_get_history`, `chatto_search_messages`, `chatto_list_room_attachments`, `chatto_list_pinned_messages` and `chatto_list_room_groups` post nothing and answer nothing, so the mention lists have nothing to say about them. Membership is a separate rule, covered by the last bullet.
 - **Pinning is a write.** `chatto_pin_message` changes what the room shows everyone, so it obeys the same opt-in rule as `chatto_send_room`: an unlisted channel refuses the pin and names both settings. A DM is never gated.
 - **The bot cannot open a DM.** Only a person can start a direct message on Chatto, so `chatto_send_dm` needs a recipient who has written to the bot at least once. To reach someone else, mention them in a channel message.
 - **A name is not an identity.** If several rooms share the name you passed, `chatto_send_room` returns their IDs and asks for `room_id` instead of picking one. Same rule as `/join #name`. The read tools resolve names by the same rule.
-- **Visible is not joined.** `chatto_list_rooms` lists rooms the bot can see, which includes channels it has not joined; check `is_member` before posting into one. `chatto_send_room`, `chatto_send_ephemeral` and `chatto_pin_message` refuse an unjoined room and say how to get in, because the server would reject the write with an error naming neither the room nor the fix. Reading needs no membership beyond what the server allows — a room the bot merely sees stays readable if the server permits the timeline.
+- **Visible is not joined.** `chatto_list_rooms` lists rooms the bot can see, which includes channels it has not joined; check `is_member` before posting into one. `chatto_send_room`, `chatto_send_ephemeral` and `chatto_pin_message` refuse an unjoined room and say how to get in, because the server would reject the write with an error naming neither the room nor the fix. Reading is no free pass: Chatto requires membership for timeline and message reads, so `chatto_get_message` and `chatto_get_history` refuse an unjoined room the same way and name the same remedy. `chatto_search_messages` needs no check of its own — it searches only the rooms the caller can read, and the server re-checks every hit it returns.
 
 Credentials never come from the model: no tool takes a server URL or a token as an argument, so the configured bot token cannot be pointed at another host. Inside a channel, mention people in the message text (`@login, …`) — there is no separate mention parameter.
 

+ 64 - 12
adapter.py

@@ -5230,7 +5230,8 @@ CHATTO_GET_HISTORY_SCHEMA = {
         "the thread's events are read instead of the room timeline; the root "
         "has to belong to the room and BE a root, like in chatto_send_room. "
         "`limit` (1-100, default 20) bounds the page; `has_older` says whether "
-        "older history exists."
+        "older history exists. To read further back, pass the previous "
+        "result's `start_cursor` as `before` — one call reads one page."
     ),
     "parameters": {
         "type": "object",
@@ -5263,6 +5264,21 @@ CHATTO_GET_HISTORY_SCHEMA = {
                     "Maximum number of messages to return, 1-100 (default 20)."
                 ),
             },
+            "before": {
+                "type": "string",
+                "description": (
+                    "Optional: page backwards. Pass the `start_cursor` of the "
+                    "previous result — cursors are opaque positions, not event "
+                    "IDs. Mutually exclusive with `after`."
+                ),
+            },
+            "after": {
+                "type": "string",
+                "description": (
+                    "Optional: page forwards. Pass the `end_cursor` of the "
+                    "previous result. Mutually exclusive with `before`."
+                ),
+            },
         },
         "required": [],
         "additionalProperties": False,
@@ -5548,13 +5564,15 @@ def _room_is_writable(cfg: ChattoConfiguration | None, room: Room) -> bool:
     return _room_answers_in(cfg, room.id, room.kind)
 
 
-def _not_joined_refusal(room: Room) -> _ToolRefusal:
+def _not_joined_refusal(room: Room, action: str) -> _ToolRefusal:
     """The refusal for a room the bot can see but has not joined.
 
-    The room kinds need different advice, and giving a channel the DM remedy
-    sends the user down a path that cannot work: a direct message to the bot is
-    not how you get it into a channel. For a channel the paths are an invite in
-    Chatto, or the ``/join`` the bot answers over DM.
+    ``action`` names what the caller wanted to do there — "post there", "read
+    its history there" — because the remedy is identical for both while the
+    symptom is not, and a read that reports a sending problem sends the reader
+    looking in the wrong place. The room kinds differ in the remedy: giving a
+    channel the DM remedy sends the user down a path that cannot work, since a
+    direct message to the bot is not how you get it into a channel.
 
     The test is on ``DM`` rather than on ``CHANNEL`` on purpose: a server that
     never sets ``kind`` reports ``UNSPECIFIED``, and this plugin counts an unknown
@@ -5569,7 +5587,7 @@ def _not_joined_refusal(room: Room) -> _ToolRefusal:
     )
     return _refuse(
         f"Room '{room.name or room.id}' ({room.id}) is not one the bot has "
-        f"joined, so it cannot post there. {remedy}, then try again."
+        f"joined, so it cannot {action}. {remedy}, then try again."
     )
 
 
@@ -5928,7 +5946,7 @@ async def _send_room(args: dict) -> str:
         # posting into one fails server-side with an error that names neither
         # the room nor the fix.
         if not resolved.is_member:
-            raise _not_joined_refusal(room)
+            raise _not_joined_refusal(room, "post there")
 
         # A thread root has to belong to this room and has to BE a root, or
         # the message attaches to a thread somewhere else entirely — or to
@@ -6006,7 +6024,7 @@ async def _send_ephemeral(args: dict) -> str:
         if not _room_is_writable(cfg, room):
             raise _read_only_room_refusal(room.name or room.id)
         if not resolved.is_member:
-            raise _not_joined_refusal(room)
+            raise _not_joined_refusal(room, "post there")
 
         message_ids, _root = await _post_chunks(client, room.id, message)
 
@@ -6215,7 +6233,7 @@ async def _pin_message(args: dict) -> str:
         if not _room_is_writable(cfg, room):
             raise _read_only_room_refusal(room.name or room.id)
         if not resolved.is_member:
-            raise _not_joined_refusal(room)
+            raise _not_joined_refusal(room, "pin a message there")
 
         if pin:
             await client.pin_message(room.id, event_id)
@@ -6394,6 +6412,13 @@ async def _get_message(args: dict) -> str:
         resolved = await _resolve_room(client, room_id, room_name)
         room = resolved.room
 
+        # Every read RPC the plugin uses documents room membership as a
+        # precondition (GetRoomEvents, GetMessage, BatchGetMessages), so an
+        # unjoined room is unreadable whatever the mention lists say. Saying so
+        # here beats a PERMISSION_DENIED that names neither the room nor the fix.
+        if not resolved.is_member:
+            raise _not_joined_refusal(room, "read it there")
+
         if event_id:
             # One message is one lookup: the single-message RPC is the
             # authoritative one, so there is no batch to fall back from.
@@ -6452,22 +6477,42 @@ async def _get_history(args: dict) -> str:
     room_name = args.get("room_name") or ""
     thread_root_event_id = args.get("thread_root_event_id") or ""
     limit = _int_arg(args, "limit", HISTORY_PAGE_SIZE, low=1, high=HISTORY_PAGE_MAX)
+    before = args.get("before") or ""
+    after = args.get("after") or ""
 
     if not room_id and not room_name:
         raise _refuse("Exactly one of room_id or room_name is required")
+    if before and after:
+        # The client sends `before` OR `after` and drops the second without a
+        # word, so both would answer a question nobody asked.
+        raise _refuse(
+            "before and after are mutually exclusive — pick the direction you "
+            "want to page in"
+        )
 
     async with _tool_client(_chatto_config_for_tools()) as client:
         resolved = await _resolve_room(client, room_id, room_name)
         room = resolved.room
 
+        # A read of an unjoined room fails server-side whatever the mention
+        # lists say; saying so here names the room and the way in.
+        if not resolved.is_member:
+            raise _not_joined_refusal(room, "read its history there")
+
         root: Message | None = None
         if thread_root_event_id:
             root = await _resolve_thread_root(client, room, thread_root_event_id)
             page = await client.get_thread_events(
-                room.id, thread_root_event_id, limit=limit
+                room.id,
+                thread_root_event_id,
+                limit=limit,
+                before=before or None,
+                after=after or None,
             )
         else:
-            page = await client.get_room_events(room.id, limit=limit)
+            page = await client.get_room_events(
+                room.id, limit=limit, before=before or None, after=after or None
+            )
 
         messages = [ev.message for ev in page.events if ev.message is not None]
         if root is not None and all(m.id != root.id for m in messages):
@@ -6505,6 +6550,13 @@ async def _get_history(args: dict) -> str:
                 "thread_root_event_id": thread_root_event_id or None,
                 "count": len(messages),
                 "has_older": page.has_older,
+                "has_newer": page.has_newer,
+                # Paging is the caller's next call, so this answer has to carry
+                # the positions that next call takes as its arguments —
+                # otherwise the model would have to guess which message to
+                # anchor on.
+                "start_cursor": page.start_cursor,
+                "end_cursor": page.end_cursor,
                 "messages": [_message_out(m, author_logins) for m in messages],
             }
         )

+ 1 - 1
plugin.yaml

@@ -1,7 +1,7 @@
 name: chatto-platform
 label: Chatto
 kind: platform
-version: 1.0.1
+version: main
 description: >
   Chatto gateway adapter for Hermes Agent.
   Connects to a Chatto server (cloud-hosted or self-hosted team chat) and relays messages

+ 9 - 7
skills/chatto-platform-plugin/SKILL.md

@@ -13,11 +13,11 @@ metadata:
 
 ## Where knowledge lives — what goes where
 
-Three homes, one rule each. Pick the destination by asking "does this travel with the plugin?":
+Three homes, one rule each. Pick the destination by asking "does this travel with the plugin?" — and note that only the first two ship with it:
 
 - **This skill** — plugin behavior that holds on ANY installation: what the tools do, debugging procedures tied to plugin code (gates, realtime stream, tool contract), lessons a fresh checkout would rediscover the hard way. If it references `~/.hermes` paths of a particular machine, a particular server, or a local Hermes checkout's quirks, it does NOT belong here.
 - **The repo root's `AGENTS.md`** — the binding rulebook for WORK ON THE REPO: editing, gates, vendoring, structure rules (e.g. how tools register through two doors). This skill does not repeat those rules; when a lesson belongs there, fix `AGENTS.md` instead.
-- **The operator's local skill (`chatto-local-preferences`)** — installation facts and personal workflow: server URL, room IDs, cron jobs, how to run the suite against THIS machine's Hermes checkout, baseline quirks of THIS dev environment (pre-existing failures, checker noise), the user's git/merge preferences.
+- **The operator's own local skill**, where the operator keeps one (`chatto-local-preferences` on this installation) — installation facts and personal workflow: server URL, room IDs, cron jobs, how to run the suite against THIS machine's Hermes checkout, baseline quirks of THIS dev environment (pre-existing failures, checker noise), the user's git/merge preferences. Nothing here depends on it existing.
 
 When in doubt: a lesson written because of one bug is upstream-worthy only if another installation could hit the same bug from the plugin's own code — not from a stale local checkout.
 
@@ -50,15 +50,17 @@ Resolves the room (same `_resolve_room` as the send tools) and fetches the messa
 
 - `follow_reply_chain` (0-10): walks the `in_reply_to` chain backwards (`client.get_message` per hop) and returns the ancestors oldest-first in `reply_chain`. A single `event_id` only — a chain belongs to one message, so the tool refuses the combination rather than picking one. A hop the server cannot resolve ends the chain.
 - `event_ids[]`: batch fetch, answered in the order asked for; unknown IDs come back in `missing_event_ids` instead of failing the whole call. A single unknown `event_id` refuses cleanly.
-- **Not gated on the mention lists**: reading posts nothing and answers nothing, so any room the server lets the bot see is readable — the server's visibility rules are the only gate.
+- **Not gated on the mention lists**: reading posts nothing and answers nothing, so any joined room is readable, listed or not. Room membership still applies — the server requires it for every read — and an unjoined room is refused with the `/join` remedy rather than a raw `PERMISSION_DENIED`.
 
-### chatto_get_history(room_id|room_name, thread_root_event_id?, limit?) — read recent context
+### chatto_get_history(room_id|room_name, thread_root_event_id?, limit?, before?, after?) — read recent context
 
-Reads the newest messages of a room (`client.get_room_events`) or of one thread (`client.get_thread_events`), returned oldest-first (`_oldest_first`, the same ordering the thread seeding uses — the server's own ordering is not a contract). `limit` 1-100 (default 20), `has_older` in the result says whether more history exists. Author logins come from the timeline page's own `users_by_id` directory first; actors missing there are hydrated via `batch_get_room_members`. Same room resolution and the same thread-root rule as `chatto_send_room` — both go through `_resolve_thread_root`, so the two tools cannot disagree about what a root is; a thread page that omits its root gets it prepended (the root is already in hand from that check). Not gated on the mention lists either — the server's own visibility rules decide what the bot may read.
+Reads the newest messages of a room (`client.get_room_events`) or of one thread (`client.get_thread_events`), returned oldest-first (`_oldest_first`, the same ordering the thread seeding uses — the server's own ordering is not a contract). `limit` 1-100 (default 20). Paging: `before` (a previous result's `start_cursor`) walks towards older events, `after` (an `end_cursor`) towards newer ones; the cursors are opaque positions, NOT event IDs, and the two are mutually exclusive because chattolib sends `before` OR `after` and would drop the second silently — one call reads one page, and the answer carries `start_cursor`/`end_cursor`/`has_older`/`has_newer` so the next call needs nothing else. Author logins come from the timeline page's own `users_by_id` directory first; actors missing there are hydrated via `batch_get_room_members`. Same room resolution and the same thread-root rule as `chatto_send_room` — both go through `_resolve_thread_root`, so the two tools cannot disagree about what a root is; a thread page that omits its root gets it prepended (the root is already in hand from that check).
+
+Not gated on the mention lists — but room membership is still required (`GetRoomEvents` says so, and so do `GetMessage`/`BatchGetMessages`), so both of these tools refuse an unjoined room via `_not_joined_refusal` rather than handing the model a bare `PERMISSION_DENIED`.
 
 ### chatto_search_messages(query, …) — find messages by their words
 
-Full-text search (`client.search_messages`). Use it when you remember the words but not the room, thread or day — `chatto_get_history` pages a window you already know, this finds a needle. Same room resolution as the other tools, and it is likewise not gated on the mention lists.
+Full-text search (`client.search_messages`). Use it when you remember the words but not the room, thread or day — `chatto_get_history` pages a window you already know, this finds a needle. Same room resolution as the other tools, and likewise not gated on the mention lists. Unlike the message-read tools it needs no membership check of its own: the search covers only rooms the caller can read, and the server re-checks every hit before returning it.
 
 - **Query syntax is the server's, not ours.** Words are required terms (all must match), `"quoted text"` is an exact phrase, `AND` joins terms, and the filters `in:room-name`, `from:user-login`, `before:DATE`, `after:DATE`, `has:attachment` may be combined with them — including alone.
 - **Structured scoping** via `room_id`/`room_name` (mutually exclusive, resolved through `_resolve_room`), `author_id`, `created_after`/`created_before` (ISO-8601, exclusive bounds) and `has_attachments`. A malformed date is refused rather than dropped: silently ignoring a bound answers a wider question than the one asked.
@@ -119,5 +121,5 @@ A message that produced no dispatch, no reaction and no log line usually was not
 2. **Gateway restart is REQUIRED after plugin code changes** — the running Hermes session keeps the old module in memory. The user's `/restart` chat command restarts in-band (gateway exits with code 75, the service manager relaunches); a restart issued from a tool terminal inside the gateway is blocked, because the gateway kills its own child processes on shutdown. Verify via `tool_call` afterwards.
 3. **Before requesting a restart, verify config-dependent fixes in an isolated subprocess** simulating the cron scope: pop the relevant env vars (`CHATTO_TOKEN`, `CHATTO_BASE_URL`), `sys.path.insert` the Hermes kernel and plugin dir, set `HERMES_HOME`, call the handler directly. A green isolated test proves the cron path before burning a restart cycle.
 4. **Bundled skills:** add a `skills/<name>/SKILL.md` and register it via `_register_bundled_skills(ctx)`, which passes the SKILL.md frontmatter `description` through — `register_skill` stores it verbatim and does not read the frontmatter itself, so an empty description leaves the skill listed with nothing explaining when to load it.
-5. **Run the suite against a Hermes checkout:** the Hermes kernel is not a plugin dependency and `yaml` is not in the dev group, so a plain run dies at import. The invocation — kernel on `PYTHONPATH`, the two `--with` packages, and the reason to re-check them when the checkout moves — is `AGENTS.md` → *Development*; which checkout sits where on this machine is the operator's local skill.
+5. **Run the suite against a Hermes checkout:** the Hermes kernel is not a plugin dependency and `yaml` is not in the dev group, so a plain run dies at import. The invocation — kernel on `PYTHONPATH`, the two `--with` packages, and the reason to re-check them when the checkout moves — is `AGENTS.md` → *Development*; which checkout sits where is machine-local, and belongs in the operator's local skill where there is one.
 6. **Mock event payloads to the wire shape, not to the attributes the code happens to read:** a bare `MagicMock()` payload answers any attribute, so tests pass on fields the real event never carries — use `MagicMock(spec=[...])` with exactly the proto's fields. Same family: a MagicMock stub client auto-creates plain (non-async) Mocks for unassigned methods, so assign `AsyncMock` explicitly before any `assert_not_awaited`.

+ 185 - 2
test_adapter.py

@@ -6061,8 +6061,9 @@ class TestPluginToolHandlers:
 
     async def test_get_history_reads_a_room_the_send_tool_would_refuse(self):
         """Reading breaks no read-only promise — the send gate exists for
-        posting, and a visible room's history stays readable without the
-        mention lists."""
+        posting, and a joined room's history stays readable without the
+        mention lists. Membership is a separate requirement the read tools
+        share with the send one (see the refusal below)."""
         room = _make_room("room-9", "Lurk", RoomKind.CHANNEL)
         client = self._stub_client()
         client.get_room = AsyncMock(return_value=_make_room_state(room))
@@ -6076,6 +6077,144 @@ class TestPluginToolHandlers:
         )
         assert result["success"] is True
 
+    async def test_get_history_pages_backwards_with_a_cursor(self):
+        """`before` is a start_cursor from the previous page, not an event ID:
+        the server walks the timeline from an opaque position. One call reads
+        one page — paging is the caller's next call, not a loop inside this one.
+        """
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        page = self._timeline_page([_make_message(message_id="msg-1")])
+        page.start_cursor = "cur-start"
+        page.end_cursor = "cur-end"
+        page.has_older = True
+        page.has_newer = True
+        client.get_room_events = AsyncMock(return_value=page)
+        result, _, _ = await self._run(
+            _handle_chatto_get_history,
+            {"room_id": "room-1", "before": "cur-start"},
+            client=client,
+        )
+        assert result["success"] is True
+        assert client.get_room_events.await_args.kwargs["before"] == "cur-start"
+        # The next page is one call away only if this answer carries what it
+        # takes to make that call.
+        assert result["start_cursor"] == "cur-start"
+        assert result["end_cursor"] == "cur-end"
+        assert result["has_older"] is True
+        assert result["has_newer"] is True
+
+    async def test_get_history_pages_forwards_with_a_cursor(self):
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.get_room_events = AsyncMock(
+            return_value=self._timeline_page([_make_message(message_id="msg-2")])
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_get_history,
+            {"room_id": "room-1", "after": "cur-end"},
+            client=client,
+        )
+        assert result["success"] is True
+        assert client.get_room_events.await_args.kwargs["after"] == "cur-end"
+
+    async def test_get_history_cursors_reach_a_thread_read_too(self):
+        """One timeline, one rule: a thread page pages the same way a room page
+        does, or a long thread goes unreachable while a room does not."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.get_message = AsyncMock(
+            return_value=_make_message(message_id="msg-1", body="root")
+        )
+        client.get_thread_events = AsyncMock(
+            return_value=self._timeline_page([_make_message(message_id="msg-1")])
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_get_history,
+            {
+                "room_id": "room-1",
+                "thread_root_event_id": "msg-1",
+                "before": "cur-start",
+            },
+            client=client,
+        )
+        assert result["success"] is True
+        assert client.get_thread_events.await_args.kwargs["before"] == "cur-start"
+
+    async def test_get_history_refuses_both_cursors_at_once(self):
+        """chattolib sends `before` OR `after`, never both — the second is
+        dropped without a word. Refusing beats handing back a page that is not
+        the one that was asked for."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.get_room_events = AsyncMock()
+        result, _, _ = await self._run(
+            _handle_chatto_get_history,
+            {"room_id": "room-1", "before": "a", "after": "b"},
+            client=client,
+        )
+        assert result["success"] is False
+        assert "before" in result["error"] and "after" in result["error"]
+        client.get_room_events.assert_not_awaited()
+
+    async def test_get_history_omits_the_cursor_when_none_was_asked_for(self):
+        """An unset cursor must reach the client as None, not as "": an empty
+        string is a value, and the server reads a cursor as a position."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.get_room_events = AsyncMock(
+            return_value=self._timeline_page([_make_message(message_id="msg-1")])
+        )
+        await self._run(
+            _handle_chatto_get_history, {"room_id": "room-1"}, client=client
+        )
+        kwargs = client.get_room_events.await_args.kwargs
+        assert kwargs.get("before") is None
+        assert kwargs.get("after") is None
+
+    async def test_get_history_refuses_a_room_the_bot_has_not_joined(self):
+        """Room membership is required by every read RPC the plugin makes —
+        GetRoomEvents, GetMessage and BatchGetMessages all say so. Pre-empting
+        it with the /join remedy beats handing the agent a bare
+        PERMISSION_DENIED that names neither the room nor the fix."""
+        room = _make_room("room-9", "Lurk", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(
+            return_value=_make_room_state(room, is_member=False)
+        )
+        client.get_room_events = AsyncMock()
+        result, _, _ = await self._run(
+            _handle_chatto_get_history, {"room_id": "room-9"}, client=client
+        )
+        assert result["success"] is False
+        assert "/join room-9" in result["error"]
+        client.get_room_events.assert_not_awaited()
+
+    async def test_get_message_refuses_a_room_the_bot_has_not_joined(self):
+        """The same server rule covers GetMessage/BatchGetMessages, and the
+        remedy is the same — one refusal, two tools, one room rule."""
+        room = _make_room("room-9", "Lurk", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(
+            return_value=_make_room_state(room, is_member=False)
+        )
+        client.get_message = AsyncMock()
+        client.batch_get_messages = AsyncMock()
+        result, _, _ = await self._run(
+            _handle_chatto_get_message,
+            {"room_id": "room-9", "event_id": "msg-1"},
+            client=client,
+        )
+        assert result["success"] is False
+        assert "/join room-9" in result["error"]
+        client.get_message.assert_not_awaited()
+        client.batch_get_messages.assert_not_awaited()
+
     async def test_get_history_refuses_an_out_of_range_limit(self):
         result, _, _ = await self._run(
             _handle_chatto_get_history, {"room_id": "room-1", "limit": 101}
@@ -6600,6 +6739,26 @@ class TestPluginToolHandlers:
         assert "read-only" in result["error"]
         client.pin_message.assert_not_awaited()
 
+    async def test_pin_message_refuses_a_room_the_bot_has_not_joined(self):
+        """A pin is a write without a message, and the refusal says so — the
+        generic "cannot post" would describe an action nobody took."""
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(
+            return_value=_make_room_state(room, is_member=False)
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_pin_message,
+            {"room_id": "room-1", "event_id": "msg-1", "pin": True},
+            client=client,
+            optional_mention_rooms="room-1",
+        )
+
+        assert result["success"] is False
+        assert "cannot pin a message there" in result["error"]
+        assert "/join room-1" in result["error"]
+        client.pin_message.assert_not_awaited()
+
     async def test_pin_message_pins_in_an_opted_in_channel(self):
         room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
         client = self._stub_client()
@@ -6669,6 +6828,30 @@ class TestPluginToolHandlers:
         client.post_message.assert_not_awaited()
         schedule.assert_not_called()
 
+    async def test_send_ephemeral_refuses_a_room_the_bot_has_not_joined(self):
+        """The opt-in list says the room is fair game; membership still says
+        otherwise, and the refusal has to name *this* tool's action so a reader
+        is not told about a send that was never attempted."""
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(
+            return_value=_make_room_state(room, is_member=False)
+        )
+        schedule = MagicMock()
+        with patch("adapter._schedule_ephemeral_deletion", schedule):
+            result, _, _ = await self._run(
+                _handle_chatto_send_ephemeral,
+                {"room_id": "room-1", "message": "working"},
+                client=client,
+                optional_mention_rooms="room-1",
+            )
+
+        assert result["success"] is False
+        assert "cannot post there" in result["error"]
+        assert "/join room-1" in result["error"]
+        client.post_message.assert_not_awaited()
+        schedule.assert_not_called()
+
     async def test_delete_after_ttl_removes_every_chunk(self):
         """A split notice is several messages; the TTL covers all of them."""
         from adapter import _delete_after_ttl