Jelajahi Sumber

Add chatto_search_messages

Finding something said last week meant paging history room by room and
guessing which one it was in — the server has had full-text search all
along (MessageSearchService), the plugin just never exposed it.

The tool hands the query to the server unchanged rather than parsing it:
Chatto's own `in:`/`from:`/`before:`/`after:`/`has:attachment` filters are
part of the index's contract, and a second parser here would drift from
them. What the plugin does own is the two failure modes that look like
"no matches" and are not: FAILED_PRECONDITION (the operator turned search
off) and UNAVAILABLE (the provider is still catching up). Both are
answered with GetSearchStatus — disabled, starting, indexing with the
server's retry hint, degraded, or no provider — because telling someone
their message never existed is the one wrong answer.

A hit is a pointer, not a record: event id, room, author, time, body, and
for thread grouping the root, reply count and latest reply. Reactions and
attachments stay with chatto_get_message, which is what reads a hit in
full; repeating them for twenty hits only spends context.

Read-only, so it is exempt from the mention-list gate like the other read
tools, and the docs say out loud that the server feature has to be on.
Paul Klumpp 1 Minggu lalu
induk
melakukan
2797ad0f37
8 mengubah file dengan 770 tambahan dan 8 penghapusan
  1. 2 1
      AGENTS.md
  2. 33 0
      CONCEPTS.md
  3. 5 3
      README.md
  4. 423 0
      adapter.py
  5. 7 1
      after-install.md
  6. 1 0
      plugin.yaml
  7. 12 1
      skills/chatto-platform-plugin/SKILL.md
  8. 287 2
      test_adapter.py

+ 2 - 1
AGENTS.md

@@ -228,7 +228,8 @@ 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` and `chatto_get_history` post nothing and
+  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.
   `CHATTO_HOME_CHANNEL` is orthogonal: it is the default outbound target for

+ 33 - 0
CONCEPTS.md

@@ -357,3 +357,36 @@ The callers differ only in what they do with the result: `send()` shapes it into
 `SendResult`, `_post_chunks` into a refusal that names which chunk failed and
 lists the IDs that went out — so a retry repeats only what is missing instead of
 duplicating a message the room already has.
+
+## Search: a state, not an empty list
+
+`chatto_search_messages` is the first tool whose feature can be absent on the
+server side while the API call itself still exists. Chatto returns
+`FAILED_PRECONDITION` for a server whose operator left `search.enabled` off and
+`UNAVAILABLE` for one whose provider is not reachable — both arrive at the tool
+as a failed RPC, and neither means "no message contains these words".
+
+Reporting them as an empty result list would be the one wrong answer: the agent
+would conclude the conversation never happened and say so, and the user would go
+looking for a missing message instead of a server setting. So the handler catches
+those two codes and calls `GetSearchStatus`, which stays available while search
+is disabled, and answers with the real state — disabled, starting, indexing
+(with the server's own `retry_after`), degraded, or no provider. The distinction
+that matters to the caller is whether it resolves by itself: indexing does,
+disabled never does.
+
+A hit is deliberately a pointer rather than a record. `MessageSearchResult`
+carries a whole `Message`, so returning `_message_out()` would repeat the
+reaction, attachment and link-preview detail for twenty hits — most of it
+irrelevant to "which of these is the one I meant", and all of it out of the
+agent's context budget. The leaner shape names where the hit is, who said it,
+when and what it says; `chatto_get_message` is the call that reads one in full.
+Thread grouping is the exception: there the row stands for a conversation, so
+the root, reply count and latest reply are part of identifying it.
+
+The tool also does not resolve `author_login` to a user ID. Chatto's own `from:`
+filter in the query does that job with the login the user already knows; a
+login→ID lookup on our side would have to scan member directories of rooms the
+search has not named yet, and `_find_user_by_login` deliberately only looks at
+DM rooms (a bot cannot read the server-wide directory). `author_id` is offered
+for the case where an ID is already in hand.

+ 5 - 3
README.md

@@ -44,7 +44,7 @@ Capabilities natively implemented by the Chatto plugin adapter:
 | file attachments | yes (chunked upload) |
 | image / video / audio / documents | yes (native attachments) |
 | DM participation | only once the person has DMed the bot — a bot cannot start a DM on Chatto, see [DM Initiation](#dm-initiation) |
-| plugin tools | yes (fourteen tools covering sending, self-deleting notices, reading, pinning, attachments, custom status, link previews and room groups — see [Plugin tools](#plugin-tools)) |
+| plugin tools | yes (fifteen tools covering sending, self-deleting notices, reading, searching, pinning, attachments, custom status, link previews and room groups — see [Plugin tools](#plugin-tools)) |
 | room creation | yes |
 | room join/leave over DM | yes (`/join`, `/leave`) |
 | member directory | yes (cached) |
@@ -62,6 +62,7 @@ Capabilities natively implemented by the Chatto plugin adapter:
 2. **A Chatto bot account** — the adapter authenticates with a bot API key. Create one in Chatto under **Settings → Bot Accounts**.
 3. **Room membership** — the bot account must be a member of any room where you want it to respond; the adapter picks up every room the account has joined. Membership can be granted natively in Chatto (invite the bot account), or by DMing the bot `/join <room-id or #name>` (see [Managing Rooms over DM](#managing-rooms-over-dm)). For DMs, simply start a direct message with the bot.
 4. **Network access** — the Hermes host must reach the Chatto server URL over HTTPS (or HTTP) and establish a WebSocket connection to `/api/realtime`.
+5. **Message search (optional)** — `chatto_search_messages` needs the Chatto server's search feature, which is off by default. Server operators enable it with `[search] enabled = true` and `[search_provider] enabled = true` and restart Chatto; the index is then built from the existing message history, and the server reports **Indexing** until that finishes. Without search, the tool says which state the server is in — off, no provider reachable, or still indexing, with a retry hint — instead of answering "no matches". Every other tool works without it.
 
 > **Info:** The adapter speaks realtime protocol 4 (the revision chattolib sends in the subscribe handshake) and needs a Chatto server that accepts it. Ensure your Chatto server is up to date.
 
@@ -405,7 +406,7 @@ Two refusals by design: direct messages cannot be left, and leaving the configur
 
 ## Plugin tools
 
-Beyond answering inbound messages, the agent can act on Chatto on its own initiative. The plugin registers fourteen tools, available to the agent in the gateway and, with `hermes tools`, in the CLI:
+Beyond answering inbound messages, the agent can act on Chatto on its own initiative. The plugin registers fifteen tools, available to the agent in the gateway and, with `hermes tools`, in the CLI:
 
 | Tool | What it does |
 |------|--------------|
@@ -415,6 +416,7 @@ Beyond answering inbound messages, the agent can act on Chatto on its own initia
 | `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_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). |
 | `chatto_pin_message` | Pins or unpins one message by event ID (`pin: true` or `false`). |
@@ -426,7 +428,7 @@ 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_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: `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.
 - **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.

+ 423 - 0
adapter.py

@@ -82,6 +82,7 @@ try:
     )
     from chattolib.exceptions import (
         ChattoAuthError,
+        ChattoConnectError,
         ChattoError,
     )
     from chattolib.realtime import (
@@ -93,6 +94,11 @@ try:
     from chattolib.types import (
         Message,
         MessageAttachment,
+        MessageSearchGroupBy,
+        MessageSearchOrder,
+        MessageSearchResult,
+        MessageSearchScope,
+        MessageSearchState,
         PresenceStatus,
         Room,
         RoomKind,
@@ -4633,6 +4639,47 @@ def _int_arg(args: dict, key: str, default: int, *, low: int, high: int) -> int:
     return value
 
 
+def _timestamp_arg(args: dict, key: str) -> datetime | None:
+    """An ISO-8601 tool argument as a datetime, or a refusal.
+
+    Search bounds are the first date arguments these tools take, so they get
+    the same treatment as numbers: an absent value means "no bound", and a
+    wrong one is refused rather than silently dropped — a caller who mistyped
+    a date is better served by hearing it than by getting an unbounded search.
+    A date without a time is midnight, and a value without an offset is read as
+    UTC, matching how everything else here treats a bare timestamp.
+    """
+    raw = args.get(key)
+    if raw is None or raw == "":
+        return None
+    if isinstance(raw, datetime):
+        return raw if raw.tzinfo is not None else raw.replace(tzinfo=UTC)
+    try:
+        parsed = datetime.fromisoformat(str(raw).strip())
+    except ValueError as exc:
+        raise _refuse(
+            f"{key} must be an ISO-8601 date or timestamp, e.g. '2026-01-31' "
+            "or '2026-01-31T12:00:00Z'"
+        ) from exc
+    return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
+
+
+def _enum_arg(args: dict, key: str, mapping: dict[str, Any], default: Any) -> Any:
+    """A named tool argument resolved through ``mapping``, or a refusal.
+
+    The choices are spelled out in the schema's enum, so an unknown name means
+    the caller invented one; picking a default instead would answer a
+    different question than the one asked.
+    """
+    raw = args.get(key)
+    if raw is None or raw == "":
+        return default
+    value = mapping.get(str(raw).strip().lower())
+    if value is None:
+        raise _refuse(f"{key} must be one of: {', '.join(sorted(mapping))}")
+    return value
+
+
 @asynccontextmanager
 async def _tool_client(cfg: ChattoConfiguration | None) -> AsyncIterator[ChattoClient]:
     """A short-lived client for one tool call, closed however the call ends.
@@ -4675,6 +4722,47 @@ HISTORY_PAGE_MAX = 100
 # kept small enough that a model can digest the output without choking.
 PINNED_PAGE_SIZE = 20
 ATTACHMENT_PAGE_SIZE = 40
+# Search pages are bounded the same way, and for the same reason: a hit is
+# something to read, not a dump. The server's own ceiling is 100, and a page
+# may come back short while `next_cursor` is set — stale index hits are dropped
+# after the provider matches them, which is not an error.
+SEARCH_PAGE_SIZE = 20
+SEARCH_PAGE_MAX = 100
+
+# The model names a search order the way a person would; the proto spells it
+# out. One table maps the two, so the schema's enum and the lookup cannot
+# disagree about what "newest" means.
+_SEARCH_ORDERS = {
+    "relevance": MessageSearchOrder.RELEVANCE,
+    "newest": MessageSearchOrder.NEWEST,
+    "thread_activity": MessageSearchOrder.THREAD_ACTIVITY,
+    "thread_created": MessageSearchOrder.THREAD_CREATED,
+}
+_SEARCH_SCOPES = {"followed_threads": MessageSearchScope.FOLLOWED_THREADS}
+_SEARCH_GROUPS = {"thread": MessageSearchGroupBy.THREAD}
+
+# Why search cannot answer right now, in terms someone can act on. The server
+# returns FAILED_PRECONDITION for "the operator turned this off" and
+# UNAVAILABLE for "the provider is not ready yet" — the second one resolves
+# itself, the first one never does, and the caller should not have to guess
+# which it hit.
+_SEARCH_STATE_HINTS = {
+    MessageSearchState.DISABLED: (
+        "message search is turned off on this Chatto server; the operator has "
+        "to enable it (`search.enabled = true`) and restart"
+    ),
+    MessageSearchState.STARTING: (
+        "the search provider is starting up; try again shortly"
+    ),
+    MessageSearchState.INDEXING: (
+        "the search provider is still building its index from the message "
+        "history, so recent results may be missing; try again later"
+    ),
+    MessageSearchState.DEGRADED: (
+        "the search provider is degraded and answering only partially"
+    ),
+    MessageSearchState.UNAVAILABLE: ("no search provider is reachable for this server"),
+}
 # A self-deleting notice: long enough to read, short enough not to linger.
 # The floor keeps a TTL from deleting a message before anyone sees it; the
 # ceiling keeps a "transient" notice from becoming a day-long fixture.
@@ -5182,6 +5270,137 @@ CHATTO_GET_HISTORY_SCHEMA = {
 }
 
 
+CHATTO_SEARCH_MESSAGES_SCHEMA = {
+    "name": "chatto_search_messages",
+    "description": (
+        "Full-text search over Chatto messages the bot can read. Use it to find "
+        "something said earlier when you do not know which room, thread or day: "
+        "`chatto_get_history` reads a window, this finds a needle. Search is a "
+        "server feature the operator has to enable; when it is off or still "
+        "building its index, the answer says so instead of reporting no "
+        'matches. Query syntax: words are required terms, "quoted text" is an '
+        "exact phrase, `AND` separates terms, and `in:`, `from:`, `before:`, "
+        "`after:`, `has:attachment` are filters. Scope it with `room_id`/"
+        "`room_name`, `author_id` or the time bounds; `group_by: thread` "
+        "answers one row per matching thread instead of per message."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "query": {
+                "type": "string",
+                "description": (
+                    "Required search expression. Words are required terms "
+                    '(all must match), "a quoted phrase" matches exactly, and '
+                    "`AND` may join terms. Filters: `in:room-name`, "
+                    "`from:user-login`, `before:2026-03-01`, "
+                    "`after:2026-01-01`, `has:attachment`. A filter may be used "
+                    "without a word or phrase."
+                ),
+            },
+            "room_id": {
+                "type": "string",
+                "description": (
+                    "Optional: search only this room (e.g. 'RbmwiHk4vs9a3Ql'). "
+                    "Mutually exclusive with `room_name`. Without either, the "
+                    "search covers every room the bot is a member of."
+                ),
+            },
+            "room_name": {
+                "type": "string",
+                "description": (
+                    "Optional: search only the room with this display name "
+                    "(case-insensitive). Mutually exclusive with `room_id`; "
+                    "ambiguous names are refused with the candidate IDs."
+                ),
+            },
+            "author_id": {
+                "type": "string",
+                "description": (
+                    "Optional: only messages posted by this Chatto user ID. To "
+                    "filter by login instead, put `from:<login>` inside "
+                    "`query`."
+                ),
+            },
+            "created_after": {
+                "type": "string",
+                "description": (
+                    "Optional ISO-8601 lower bound on posting time, exclusive "
+                    "(e.g. '2026-01-31' or '2026-01-31T12:00:00Z'). When "
+                    "`query` also carries `after:`, the stricter bound wins."
+                ),
+            },
+            "created_before": {
+                "type": "string",
+                "description": (
+                    "Optional ISO-8601 upper bound on posting time, exclusive. "
+                    "When `query` also carries `before:`, the stricter bound "
+                    "wins."
+                ),
+            },
+            "has_attachments": {
+                "type": "boolean",
+                "description": (
+                    "Optional: true returns only messages that currently have "
+                    "attachments."
+                ),
+            },
+            "order": {
+                "type": "string",
+                "enum": [
+                    "relevance",
+                    "newest",
+                    "thread_activity",
+                    "thread_created",
+                ],
+                "description": (
+                    "Result order (default relevance). `thread_activity` and "
+                    "`thread_created` require `group_by: thread`."
+                ),
+            },
+            "group_by": {
+                "type": "string",
+                "enum": ["thread"],
+                "description": (
+                    "Optional: with `thread`, return one row per matching "
+                    "thread instead of one per message, with the thread's root, "
+                    "reply count and latest reply."
+                ),
+            },
+            "scope": {
+                "type": "string",
+                "enum": ["followed_threads"],
+                "description": (
+                    "Optional: with `followed_threads`, search only threads the "
+                    "bot follows. Combine with `group_by: thread` for a thread "
+                    "list."
+                ),
+            },
+            "limit": {
+                "type": "integer",
+                "description": (
+                    "Maximum results per page, 1-100 (default 20). The page is "
+                    "counted in messages, or in threads when `group_by` is "
+                    "`thread`."
+                ),
+            },
+            "cursor": {
+                "type": "string",
+                "description": (
+                    "Opaque `next_cursor` from a previous call, for the next "
+                    "page. Bound to this caller and to every other query field, "
+                    "so repeat the same query with it. Results come from a live "
+                    "index: a page may repeat or omit a hit that moved while "
+                    "paging."
+                ),
+            },
+        },
+        "required": ["query"],
+        "additionalProperties": False,
+    },
+}
+
+
 def _chatto_config_for_tools() -> ChattoConfiguration | None:
     """Resolve the Chatto configuration for plugin tool handlers.
 
@@ -6291,6 +6510,202 @@ async def _get_history(args: dict) -> str:
         )
 
 
+def _search_result_out(
+    result: MessageSearchResult, author_logins: dict[str, str]
+) -> dict[str, Any]:
+    """One search hit as the tool's JSON shape.
+
+    A hit is a pointer, not a record: ``chatto_get_message`` is what reads one
+    in full, so this carries what decides *which* hit to read — where it is,
+    who said it, when, what it says — and leaves the reaction and attachment
+    detail to that call.
+    """
+    message = result.message
+    out: dict[str, Any] = {"relevance": round(result.relevance_score, 4)}
+    if message is not None:
+        out.update(
+            {
+                "event_id": message.id,
+                "room_id": message.room_id,
+                "author_user_id": message.actor_id,
+                "author_login": author_logins.get(message.actor_id),
+                "created_at": _iso_or_none(message.created_at),
+                "body": message.body,
+                "in_reply_to": message.in_reply_to or None,
+                "thread_root_event_id": message.thread_root_event_id or None,
+            }
+        )
+
+    context = result.thread_context
+    if context is not None:
+        # Only THREAD grouping asks for this: the row stands for a whole
+        # conversation, so the caller needs its opening line and how far it got,
+        # not just the message the provider matched.
+        root = context.root_message
+        latest = context.latest_reply
+        thread = context.thread
+        out["thread"] = {
+            "root_event_id": (
+                thread.thread_root_event_id
+                if thread is not None
+                else (root.id if root is not None else None)
+            ),
+            "root_body": root.body if root is not None else None,
+            "room_id": context.room.id if context.room is not None else None,
+            "room_name": context.room.name if context.room is not None else None,
+            "reply_count": thread.reply_count if thread is not None else None,
+            "participant_count": (
+                thread.participant_count if thread is not None else None
+            ),
+            "last_reply_at": (
+                _iso_or_none(thread.last_reply_at) if thread is not None else None
+            ),
+            "latest_reply_event_id": latest.id if latest is not None else None,
+            "latest_reply_body": latest.body if latest is not None else None,
+        }
+    return out
+
+
+async def _search_unavailable_reason(
+    client: ChattoClient, exc: ChattoConnectError
+) -> str:
+    """Why search cannot answer, in terms the caller can act on.
+
+    The server reports one of two codes for two very different situations:
+    the operator turned search off, or the provider is not ready yet. The first
+    never resolves on its own, the second does — saying which one it is changes
+    what the caller should do next. ``GetStatus`` stays available while search
+    is disabled, so it is the one call that can tell them apart.
+    """
+    try:
+        status = await client.get_search_status()
+    except ChattoError as status_exc:
+        logger.debug("Chatto tools: search status unreadable: %s", status_exc)
+        return f"Message search is unavailable on this Chatto server ({exc.code})"
+
+    hint = _SEARCH_STATE_HINTS.get(status.state)
+    if hint is None:
+        return f"Message search is unavailable on this Chatto server ({exc.code})"
+    reason = f"Message search is unavailable: {hint}"
+    if status.retry_after_seconds:
+        reason += f" (retry in about {status.retry_after_seconds}s)"
+    return reason
+
+
+async def _search_messages(args: dict) -> str:
+    """Search message bodies. See _handle_chatto_search_messages."""
+    query = str(args.get("query") or "").strip()
+    room_id = args.get("room_id") or ""
+    room_name = args.get("room_name") or ""
+    author_id = args.get("author_id") or ""
+    cursor = args.get("cursor") or ""
+    limit = _int_arg(args, "limit", SEARCH_PAGE_SIZE, low=1, high=SEARCH_PAGE_MAX)
+    created_after = _timestamp_arg(args, "created_after")
+    created_before = _timestamp_arg(args, "created_before")
+    order = _enum_arg(args, "order", _SEARCH_ORDERS, MessageSearchOrder.RELEVANCE)
+    group_by = _enum_arg(
+        args, "group_by", _SEARCH_GROUPS, MessageSearchGroupBy.UNSPECIFIED
+    )
+    scope = _enum_arg(args, "scope", _SEARCH_SCOPES, MessageSearchScope.UNSPECIFIED)
+    has_attachments = args.get("has_attachments")
+
+    if not query:
+        raise _refuse("query is required and must not be empty")
+    if room_id and room_name:
+        raise _refuse("room_id and room_name are mutually exclusive")
+    if has_attachments is not None and not isinstance(has_attachments, bool):
+        raise _refuse("has_attachments must be true or false")
+    if order is MessageSearchOrder.THREAD_ACTIVITY and (
+        group_by is not MessageSearchGroupBy.THREAD
+    ):
+        # The server refuses this combination; saying which two arguments
+        # collide is more useful than relaying INVALID_ARGUMENT.
+        raise _refuse("order 'thread_activity' requires group_by 'thread'")
+    if created_after and created_before and created_after >= created_before:
+        raise _refuse("created_after must be earlier than created_before")
+
+    async with _tool_client(_chatto_config_for_tools()) as client:
+        if room_id or room_name:
+            room_id = (await _resolve_room(client, room_id, room_name)).room.id
+
+        try:
+            page = await client.search_messages(
+                query,
+                room_id=room_id or None,
+                author_id=author_id or None,
+                created_after=created_after,
+                created_before=created_before,
+                has_attachments=has_attachments,
+                order=order,
+                page_size=limit,
+                cursor=cursor or None,
+                scope=scope,
+                group_by=group_by,
+            )
+        except ChattoConnectError as exc:
+            if exc.code not in {"failed_precondition", "unavailable"}:
+                raise
+            raise _refuse(await _search_unavailable_reason(client, exc)) from exc
+
+        results = page.results
+
+        # The page carries its own user directory, which answers author names
+        # without a member-list call. A cross-room search can still miss an
+        # author, so the remaining ones are resolved per room — the directory
+        # call is per room, and a hit names the room it came from.
+        author_logins = {
+            user_id: user.login
+            for user_id, user in page.users_by_id.items()
+            if user.login
+        }
+        unresolved_by_room: dict[str, list[str]] = {}
+        for result in results:
+            message = result.message
+            if message is None or not message.actor_id:
+                continue
+            if message.actor_id in author_logins:
+                continue
+            unresolved_by_room.setdefault(message.room_id, []).append(message.actor_id)
+        for hit_room_id, actor_ids in unresolved_by_room.items():
+            if hit_room_id:
+                author_logins.update(
+                    await _batch_author_logins(client, hit_room_id, actor_ids)
+                )
+
+        return json.dumps(
+            {
+                "success": True,
+                "query": query,
+                "room_id": room_id or None,
+                "count": len(results),
+                "grouped_by_thread": group_by is MessageSearchGroupBy.THREAD,
+                "thread_total_count": page.thread_total_count,
+                "next_cursor": page.next_cursor or None,
+                "results": [
+                    _search_result_out(result, author_logins) for result in results
+                ],
+            }
+        )
+
+
+async def _handle_chatto_search_messages(args: dict) -> str:
+    """Tool handler for chatto_search_messages.
+
+    Returns a JSON string: Hermes' tool contract accepts no other type, and a
+    dict (or an unflagged coroutine) fails at dispatch with "unsupported result
+    type". Every early exit arrives here as a _ToolRefusal and leaves as that
+    refusal's own JSON, so no path escapes as an unhandled error.
+    """
+    try:
+        return await _search_messages(args)
+    except _ToolRefusal as refusal:
+        return refusal.result_json
+    except ChattoAuthError as exc:
+        return json.dumps({"success": False, "error": f"Authentication error: {exc}"})
+    except ChattoError as exc:
+        return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
+
+
 async def _handle_chatto_send_dm(args: dict) -> str:
     """Tool handler for chatto_send_dm.
 
@@ -6693,6 +7108,11 @@ _PLUGIN_TOOLS = (
     ("chatto_list_rooms", CHATTO_LIST_ROOMS_SCHEMA, _handle_chatto_list_rooms),
     ("chatto_get_message", CHATTO_GET_MESSAGE_SCHEMA, _handle_chatto_get_message),
     ("chatto_get_history", CHATTO_GET_HISTORY_SCHEMA, _handle_chatto_get_history),
+    (
+        "chatto_search_messages",
+        CHATTO_SEARCH_MESSAGES_SCHEMA,
+        _handle_chatto_search_messages,
+    ),
     (
         "chatto_list_room_attachments",
         CHATTO_LIST_ROOM_ATTACHMENTS_SCHEMA,
@@ -6807,5 +7227,8 @@ def register(ctx) -> None:
             "only once they have written to the bot themselves. "
             "chatto_get_history and chatto_get_message read instead of posting: use them to "
             "look up what was said in a room or thread, and to resolve an event ID you were given. "
+            "chatto_search_messages finds messages by their words when you do not know the room, "
+            "thread or day — it only answers when the server has search enabled, and says so when "
+            "it has not. "
         ),
     )

+ 7 - 1
after-install.md

@@ -98,4 +98,10 @@ can live in either file. Environment variables take precedence over
    `chatto_send_room` / `chatto_send_dm` tools, which obey the same room rules
    as inbound messages — a channel listed in neither mention room list stays
    read-only. A third tool, `chatto_list_rooms`, turns a channel name into the
-   room ID those tools want. `hermes tools` lists all three.
+   room ID those tools want. `hermes tools` lists all of them.
+
+   Reading needs no setup of its own: `chatto_get_history` and
+   `chatto_get_message` open what a room or thread already says.
+   `chatto_search_messages` additionally needs message search switched on for
+   the Chatto server itself — if it is off, that one tool says so and every
+   other tool keeps working.

+ 1 - 0
plugin.yaml

@@ -17,6 +17,7 @@ provides_tools:
   - chatto_list_rooms
   - chatto_get_message
   - chatto_get_history
+  - chatto_search_messages
   - chatto_list_room_attachments
   - chatto_list_pinned_messages
   - chatto_pin_message

+ 12 - 1
skills/chatto-platform-plugin/SKILL.md

@@ -23,7 +23,7 @@ When in doubt: a lesson written because of one bug is upstream-worthy only if an
 
 ## When to Use
 
-Using or developing/debugging the Chatto gateway plugin: its plugin tools (`chatto_send_dm`, `chatto_send_room`, `chatto_list_rooms`, `chatto_get_message`, `chatto_get_history`) or adding new tools to the plugin. Repo-internal rules (editing, linting, vendoring) live in the plugin root's `AGENTS.md` — see Development workflow.
+Using or developing/debugging the Chatto gateway plugin: its plugin tools (`chatto_send_dm`, `chatto_send_room`, `chatto_list_rooms`, `chatto_get_message`, `chatto_get_history`, `chatto_search_messages`) or adding new tools to the plugin. Repo-internal rules (editing, linting, vendoring) live in the plugin root's `AGENTS.md` — see Development workflow.
 
 ## The plugin tools and when to use them
 
@@ -56,6 +56,17 @@ Resolves the room (same `_resolve_room` as the send tools) and fetches the messa
 
 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.
 
+### 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.
+
+- **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.
+- **`order`**: `relevance` (default), `newest`, `thread_activity`, `thread_created`. `thread_activity` requires `group_by: thread` and is refused without it, because that is the server's rule and naming both arguments beats relaying INVALID_ARGUMENT.
+- **`group_by: thread`** returns one row per matching thread with a `thread` block (root event/body, room name, reply count, participant count, last reply, latest reply event/body) instead of one row per message. **`scope: followed_threads`** narrows to threads the bot follows.
+- **Paging**: `limit` 1-100 (default 20), `next_cursor` in the result feeds the next call's `cursor`. The cursor is bound to the caller and to every other query field, and it walks a live index — a page may repeat or skip a hit that moved while paging. Do not reuse a cursor across different queries.
+- **Search is a server feature, off by default.** `FAILED_PRECONDITION` and `UNAVAILABLE` are translated into the real state via `get_search_status()`: disabled (operator has to enable `search.enabled`), starting, indexing (retry — the server gives `retry_after_seconds`), degraded, or no provider reachable. Never report "nothing found" in those cases; the answer says which of them it is. A hit is a pointer, not a record: fetch it with `chatto_get_message` for reactions, attachments and link preview.
+
 ### chatto_send_dm(user_login|user_id, message) — DM a user
 
 Looks up the user by login or ID and posts into the DM room.

+ 287 - 2
test_adapter.py

@@ -42,7 +42,7 @@ setup_vendor_path()
 
 from chattolib._pb.chatto.realtime.v1.events_pb2 import MessageMention
 from chattolib.client import ChattoClient
-from chattolib.exceptions import ChattoError
+from chattolib.exceptions import ChattoConnectError, ChattoError
 from chattolib.types import (
     Asset,
     AssetUpload,
@@ -52,6 +52,11 @@ from chattolib.types import (
     Message,
     MessageAttachment,
     MessageReaction,
+    MessageSearchGroupBy,
+    MessageSearchOrder,
+    MessageSearchResult,
+    MessageSearchScope,
+    MessageSearchState,
     Page,
     PinnedMessage,
     PinnedMessagesPage,
@@ -59,8 +64,12 @@ from chattolib.types import (
     Room,
     RoomGroup,
     RoomKind,
+    RoomSummary,
     RoomViewerState,
     RoomWithViewerState,
+    SearchResultsPage,
+    SearchStatus,
+    ThreadSearchContext,
     ThreadSummary,
     TimelineEvent,
     TimelinePage,
@@ -83,6 +92,7 @@ from adapter import (
     CHATTO_GET_HISTORY_SCHEMA,
     CHATTO_GET_MESSAGE_SCHEMA,
     CHATTO_LIST_ROOMS_SCHEMA,
+    CHATTO_SEARCH_MESSAGES_SCHEMA,
     CHATTO_SEND_DM_SCHEMA,
     CHATTO_SEND_ROOM_SCHEMA,
     ChattoAdapter,
@@ -100,6 +110,7 @@ from adapter import (
     _handle_chatto_list_rooms,
     _handle_chatto_mark_thread_as_read,
     _handle_chatto_pin_message,
+    _handle_chatto_search_messages,
     _handle_chatto_send_dm,
     _handle_chatto_send_ephemeral,
     _handle_chatto_send_room,
@@ -5048,8 +5059,16 @@ class TestPluginToolSchemas:
             CHATTO_SEND_ROOM_SCHEMA,
             CHATTO_GET_MESSAGE_SCHEMA,
             CHATTO_GET_HISTORY_SCHEMA,
+            CHATTO_SEARCH_MESSAGES_SCHEMA,
+        ],
+        ids=[
+            "list_rooms",
+            "send_dm",
+            "send_room",
+            "get_message",
+            "get_history",
+            "search_messages",
         ],
-        ids=["list_rooms", "send_dm", "send_room", "get_message", "get_history"],
     )
     def test_schema_advertises_no_credentials(self, schema):
         """A base_url argument would send the configured bot token to a host
@@ -5070,6 +5089,7 @@ class TestPluginToolSchemas:
             "chatto_list_rooms",
             "chatto_get_message",
             "chatto_get_history",
+            "chatto_search_messages",
             "chatto_list_room_attachments",
             "chatto_list_pinned_messages",
             "chatto_pin_message",
@@ -6233,6 +6253,271 @@ class TestPluginToolHandlers:
         assert result["success"] is True
         client.post_message.assert_awaited()
 
+    # -- chatto_search_messages --
+
+    @staticmethod
+    def _search_page(results, *, next_cursor="", thread_total_count=None, users=None):
+        """A SearchResultsPage as the server returns one."""
+        return SearchResultsPage(
+            results=results,
+            next_cursor=next_cursor,
+            thread_total_count=thread_total_count,
+            users_by_id=dict(users or {}),
+        )
+
+    @staticmethod
+    def _hit(message, relevance=0.9, thread_context=None):
+        return MessageSearchResult(
+            message=message, relevance_score=relevance, thread_context=thread_context
+        )
+
+    async def test_search_returns_hits_with_their_room_and_author(self):
+        """A hit is a pointer: it needs to say where it is, who said it and
+        what it says, or the caller cannot decide which one to read."""
+        client = self._stub_client()
+        message = _make_message(
+            message_id="msg-7",
+            room_id="room-1",
+            body="the deploy finished at noon",
+            actor_id="user-7",
+            created_at=datetime(2026, 10, 20, 12, 0, tzinfo=UTC),
+        )
+        client.search_messages = AsyncMock(
+            return_value=self._search_page(
+                [self._hit(message)],
+                users={"user-7": _make_user("user-7", "seven")},
+            )
+        )
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages, {"query": "deploy noon"}, client=client
+        )
+
+        assert result["success"] is True
+        assert result["count"] == 1
+        hit = result["results"][0]
+        assert hit["event_id"] == "msg-7"
+        assert hit["room_id"] == "room-1"
+        assert hit["author_login"] == "seven"
+        assert hit["body"] == "the deploy finished at noon"
+        assert hit["relevance"] == 0.9
+
+    async def test_search_passes_every_filter_through(self):
+        """The server owns the filtering; the tool's job is to hand it over
+        intact rather than second-guess it."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.search_messages = AsyncMock(return_value=self._search_page([]))
+
+        await self._run(
+            _handle_chatto_search_messages,
+            {
+                "query": "deploy",
+                "room_id": "room-1",
+                "author_id": "user-7",
+                "created_after": "2026-10-01",
+                "created_before": "2026-10-31T00:00:00Z",
+                "has_attachments": True,
+                "order": "newest",
+                "group_by": "thread",
+                "scope": "followed_threads",
+                "limit": 5,
+                "cursor": "cur-1",
+            },
+            client=client,
+        )
+
+        kwargs = client.search_messages.await_args.kwargs
+        assert kwargs["room_id"] == "room-1"
+        assert kwargs["author_id"] == "user-7"
+        assert kwargs["created_after"] == datetime(2026, 10, 1, tzinfo=UTC)
+        assert kwargs["created_before"] == datetime(2026, 10, 31, 0, 0, tzinfo=UTC)
+        assert kwargs["has_attachments"] is True
+        assert kwargs["order"] is MessageSearchOrder.NEWEST
+        assert kwargs["group_by"] is MessageSearchGroupBy.THREAD
+        assert kwargs["scope"] is MessageSearchScope.FOLLOWED_THREADS
+        assert kwargs["page_size"] == 5
+        assert kwargs["cursor"] == "cur-1"
+
+    async def test_search_resolves_a_room_name_before_searching(self):
+        """A name is not an ID: the room has to be resolved first, and the
+        server is then scoped by the ID it stands for."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.list_rooms = AsyncMock(return_value=[_make_room_state(room)])
+        client.search_messages = AsyncMock(return_value=self._search_page([]))
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages,
+            {"query": "deploy", "room_name": "Ops"},
+            client=client,
+        )
+
+        assert result["room_id"] == "room-1"
+        assert client.search_messages.await_args.kwargs["room_id"] == "room-1"
+
+    async def test_search_answers_a_disabled_feature_with_the_reason(self):
+        """Search off is not "no matches". The server says FAILED_PRECONDITION
+        and GetStatus says why — an empty result list would be a lie."""
+        client = self._stub_client()
+        client.search_messages = AsyncMock(
+            side_effect=ChattoConnectError("failed_precondition", "search disabled")
+        )
+        client.get_search_status = AsyncMock(
+            return_value=SearchStatus(state=MessageSearchState.DISABLED)
+        )
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages, {"query": "deploy"}, client=client
+        )
+
+        assert result["success"] is False
+        assert "turned off" in result["error"]
+        assert "search.enabled" in result["error"]
+
+    async def test_search_reports_a_building_index_with_its_retry_hint(self):
+        """Indexing resolves itself, so the caller must not be told the feature
+        is broken — it should retry, and the server says when."""
+        client = self._stub_client()
+        client.search_messages = AsyncMock(
+            side_effect=ChattoConnectError("unavailable", "provider not ready")
+        )
+        client.get_search_status = AsyncMock(
+            return_value=SearchStatus(
+                state=MessageSearchState.INDEXING, retry_after_seconds=30
+            )
+        )
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages, {"query": "deploy"}, client=client
+        )
+
+        assert result["success"] is False
+        assert "building its index" in result["error"]
+        assert "30s" in result["error"]
+
+    async def test_search_relays_a_real_error_instead_of_blaming_the_feature(self):
+        """Only the two search-state codes mean "the feature is not ready". A
+        permission error is the caller's problem and must not be dressed up as
+        a disabled feature — that would send them to the operator for nothing."""
+        client = self._stub_client()
+        client.search_messages = AsyncMock(
+            side_effect=ChattoConnectError("permission_denied", "no")
+        )
+        client.get_search_status = AsyncMock()
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages, {"query": "deploy"}, client=client
+        )
+
+        assert result["success"] is False
+        assert "Chatto API error" in result["error"]
+        client.get_search_status.assert_not_awaited()
+
+    async def test_search_reports_thread_grouping_with_the_thread_it_stands_for(self):
+        """With group_by=thread a row is a whole conversation, so the root and
+        the latest reply are what makes it readable."""
+        client = self._stub_client()
+        root = _make_message(message_id="msg-1", body="deploy question")
+        reply = _make_message(
+            message_id="msg-2", body="deploy answer", thread_root_event_id="msg-1"
+        )
+        context = ThreadSearchContext(
+            root_message=root,
+            room=RoomSummary(id="room-1", name="Ops"),
+            thread=ThreadSummary(
+                thread_root_event_id="msg-1",
+                reply_count=2,
+                participant_count=2,
+                last_reply_at=datetime(2026, 10, 20, 13, 0, tzinfo=UTC),
+            ),
+            latest_reply=reply,
+        )
+        client.search_messages = AsyncMock(
+            return_value=self._search_page(
+                [self._hit(root, thread_context=context)], thread_total_count=1
+            )
+        )
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages,
+            {"query": "deploy", "group_by": "thread"},
+            client=client,
+        )
+
+        assert result["grouped_by_thread"] is True
+        assert result["thread_total_count"] == 1
+        thread = result["results"][0]["thread"]
+        assert thread["root_event_id"] == "msg-1"
+        assert thread["root_body"] == "deploy question"
+        assert thread["room_name"] == "Ops"
+        assert thread["reply_count"] == 2
+        assert thread["latest_reply_event_id"] == "msg-2"
+
+    async def test_search_pages_with_the_cursor_it_was_given(self):
+        """The cursor is this tool's only way past one page, so it has to come
+        back out as next_cursor — and be usable as an argument again."""
+        client = self._stub_client()
+        client.search_messages = AsyncMock(
+            return_value=self._search_page([], next_cursor="cur-2")
+        )
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages,
+            {"query": "deploy", "cursor": "cur-1"},
+            client=client,
+        )
+
+        assert result["next_cursor"] == "cur-2"
+        assert client.search_messages.await_args.kwargs["cursor"] == "cur-1"
+
+    async def test_search_refuses_an_empty_query(self):
+        """The schema marks it required, but a model can still send "" — an
+        empty query would search for nothing and return everything."""
+        result, _, _ = await self._run(_handle_chatto_search_messages, {"query": "   "})
+
+        assert result["success"] is False
+        assert "query is required" in result["error"]
+
+    async def test_search_refuses_thread_activity_order_without_thread_grouping(self):
+        """The server rejects this pair; naming both arguments is more useful
+        than relaying INVALID_ARGUMENT."""
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages,
+            {"query": "deploy", "order": "thread_activity"},
+        )
+
+        assert result["success"] is False
+        assert "requires group_by" in result["error"]
+
+    async def test_search_refuses_a_malformed_date_instead_of_ignoring_it(self):
+        """Silently dropping a bound would answer a wider question than the one
+        asked, which reads as a correct answer."""
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages,
+            {"query": "deploy", "created_after": "last tuesday"},
+        )
+
+        assert result["success"] is False
+        assert "created_after" in result["error"]
+
+    async def test_search_refuses_an_inverted_time_window(self):
+        client = self._stub_client()
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages,
+            {
+                "query": "deploy",
+                "created_after": "2026-10-31",
+                "created_before": "2026-10-01",
+            },
+            client=client,
+        )
+
+        assert result["success"] is False
+        assert "earlier" in result["error"]
+
     # -- chatto_list_room_attachments --
 
     async def test_list_room_attachments_reports_the_server_page(self):