Преглед на файлове

Correct the docs the read tools landed next to

Four of these are statements that stopped being true when the work landed,
and one is a promise the tool cannot keep.

The README said thread history is seeded "once per session, not per
message": later turns do carry an [Earlier in this thread] block, because
the cursor work made them deliver whatever arrived since the last turn —
above all the agent's own tool-posted messages, whose realtime echo is a
self-event and never becomes input. A user debugging "why did Hermes quote
itself back at me" would have read that section and concluded it cannot
happen. Same paragraph now points at chatto_get_history as the way past
the 20-message/2000-character block budget.

after-install.md still called chatto_list_rooms "a third tool" and told the
reader `hermes tools` lists all three; there are five. It now names the two
read tools and says why they are not gated on the mention lists, so the
read-only promise is not read as covering reading.

`has_older` was documented as information with no stated limit: the tool
exposes no `before` argument, so a true there reports history nothing can
reach. README now says so as a capability limit, and the skill records that
client.get_room_events already takes one, making a follow-up argument cheap.

AGENTS.md filed _resolve_thread_root under "The two send paths, one
implementation", but its two callers are two tools and only one of them
sends. It gets its own paragraph instead, so the next reader is not left
guessing which frame governs it.

SKILL.md's "three homes" for knowledge named chatto-local-preferences as if
it shipped; AGENTS.md is explicit that two skills register. The third home is
now marked local, and the map says so up front.
Paul Klumpp преди 1 седмица
родител
ревизия
09286ea940
променени са 4 файла, в които са добавени 23 реда и са изтрити 17 реда
  1. 10 6
      AGENTS.md
  2. 5 5
      README.md
  3. 4 2
      after-install.md
  4. 4 4
      skills/chatto-platform-plugin/SKILL.md

+ 10 - 6
AGENTS.md

@@ -519,7 +519,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
@@ -535,16 +535,20 @@ Three rules already exist once each, and each answers for both callers:
   contracts genuinely differ; the *sending* is the same job and must not be.
   `on_posted` is how the adapter marks its own output seen — without it the
   realtime echo of its own message would come back as input.
-- **`_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). Only one of them sends, so this rule does not
+belong to the pair above — but the drift it prevents is identical: two copies
+would leave two tools disagreeing about 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

+ 5 - 5
README.md

@@ -212,15 +212,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).
@@ -387,7 +387,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). The page is always the newest one — `has_older` tells you a room holds more history than this reaches, but there is no way to ask for the page before it. |
 
 Four boundaries are deliberate, and each tool reports them as an error instead of half-doing the job:
 

+ 4 - 2
after-install.md

@@ -97,5 +97,7 @@ can live in either file. Environment variables take precedence over
    answers in, or to DM someone who has already written to it. Those are the
    `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.
+   read-only. `chatto_list_rooms` turns a channel name into the room ID those
+   tools want. `chatto_get_history` and `chatto_get_message` read instead of
+   posting, so they work in any room the server shows the bot, listed or not.
+   `hermes tools` lists all five.

+ 4 - 4
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.
 
@@ -54,7 +54,7 @@ Resolves the room (same `_resolve_room` as the send tools) and fetches the messa
 
 ### chatto_get_history(room_id|room_name, thread_root_event_id?, limit?) — 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), `has_older` in the result says whether more history exists — but there is no `before` argument, so the newest page is the only one reachable (`client.get_room_events` takes one; adding it to the schema is the whole change). 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_send_dm(user_login|user_id, message) — DM a user
 
@@ -108,5 +108,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`.