# Agent Guidelines for hermes-chatto-plugin

## This document stays contradiction-free

**AGENTS.md is a rulebook — every rule in it must be readable without having
to guess which one wins.**

If two passages turn out to conflict, or a rule admits two readings with
different outcomes, do not quietly choose one: point out the contradiction
where you hit it, name both passages, and resolve it here in the text so the
next reader cannot stumble into the same fork.

## What this plugin is

This plugin exists to connect a Hermes Agent to a Chatto server. All Chatto
API interaction goes through `chattolib` (vendored under `vendor/`) — never
hand-roll ConnectRPC/WebSocket calls the library already covers. When behaviour
is unclear on our side, the Chatto server source is the reference:

```bash
git clone https://github.com/chattocorp/chatto.git
```

The vendored copy is upstream `chattolib[realtime]` 0.4.20.post1 (realtime
protocol v1); the matching server floor is Chatto v0.4.20. Refreshing `vendor/`
via `vendor_chattolib.sh` can move that floor (see VENDORING.md) — after each
refresh, restate the new version here and in README.md.

## README.md is the human README

**Two audiences: AGENTS.md is read by agents with this source tree open;
README.md is read by humans who have not. That makes README.md the document
where ambiguity is not allowed.**

Everything a user meets first lives there — what the plugin is, how it
behaves, how to configure it. Unambiguous in practice means:

- **Say which Chatto you mean.** Server, chattolib and plugin all answer to
  "Chatto"; write the one you mean. (A capability-matrix column labelled just
  "Chatto" recently had to be asked whether it meant the plugin.)
- **One sentence, one reading.** If a claim can be parsed two ways, it is
  false in at least one of them — rewrite until only one parse remains.
- **No shorthand that needs the source.** Config keys, env vars and RPC names
  appear spelled out, never as "see adapter.py".
- **Keep versions current.** Compatibility claims must match reality — chattolib
  tracks Chatto upstream, so every `vendor/` refresh can move the supported
  server floor, and README.md has to move with it. ("Tested against Chatto
  v0.4.19" outlived its truth the moment the vendored copy became 0.4.20.)

### after-install.md

**Hermes Agent displays `after-install.md` to the user right after plugin
installation — that is the platform-plugin convention, not our timing choice.
By then the installer has already walked through `plugin.yaml`'s
`requires_env`/`optional_env` prompts and written the answers into
`~/.hermes/.env` (`hermes_setup_fn`, `save_env_value`).**

It is the first instruction anyone reads, ahead of README.md, config files and
source. Hold it to the same no-ambiguity bar and keep it in step with reality:
every env var, command and next step printed there must work exactly as
written, so it moves whenever setup, configuration or first-run behavior
changes. Because the values were just collected, after-install.md treats
`.env`/`config.yaml` as a **review** step — "check what the installer wrote",
not "edit these files to begin".

## Behavioural contract

**These behaviours are promises to users, documented in README.md — adapter
changes must keep them, or consciously renegotiate the docs.**

- **Mention gating covers channels only.** With `require_mention` enabled,
  channel-kind rooms answer only messages @-mentioning the bot; group-kind
  rooms and DMs answer regardless. In channels with the gate off, a message
  aimed at someone else gets a 🫥 acknowledgement instead of an answer.
- **Respond rooms are a positive list, inbound only.** With
  `CHATTO_RESPOND_ROOMS` set, the bot reads and answers only in listed rooms;
  other joined memberships stay read-only — still marked as read, never
  seeded into context, never answered, no processing reactions. The gate sits
  at the top of `_dispatch_message_posted`, before any API call; unknown room
  kinds fail closed. DMs are exempt so `/join` stays reachable, and
  `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;
  a fresh room reply opens a thread under the incoming message unless
  `auto_thread` is disabled or the room is a DM.
- **Processing reactions.** 👀 while working, then ✅, ❌, or 🚫 (cancelled) —
  driven by `on_processing_start`/`on_processing_complete` and the `reactions`
  setting, never applied to the bot's own events.
- **Room membership is server-side, commands ride over DMs.** `/join` and
  `/leave` in a DM call RoomService/JoinRoom/LeaveRoom, so membership survives
  restarts; the joined list mirrors it on every `_refresh_rooms()`. DMs and the
  configured home channel refuse `/leave`. Commands never reach the agent
  pipeline and are not intercepted outside DMs.
- **Length handling.** `send()` splits at 9900 chars against the 10000-char
  server limit; `edit_message()` refuses over-long content so callers fall
  back to `send()` rather than receiving silent truncation.

### Auth defaults to closed

Without `CHATTO_ALLOWED_USERS` and without `CHATTO_ALLOW_ALL_USERS=true`,
`_check_auth` denies everyone. Keep that fail-closed stance when touching the
auth path. `hermes_validate_config` rejects setting both options at once on
purpose, and `allow_all_users` hands every Chatto user full agent access
including tool use.

## Development

Work on this plugin happens against a real Hermes Agent checkout — set that up
first, everything else builds on it:

1. Fetch the Hermes Agent:

   ```bash
   git clone https://github.com/NousResearch/hermes-agent.git
   ```

2. Put this plugin at `hermes-agent/plugins/platforms/hermes-chatto-plugin`.
   Either clone this repo straight into that path, or link an existing checkout
   so you can keep working in your own copy:

   ```bash
   ln -s /path/to/hermes-chatto-plugin \
     hermes-agent/plugins/platforms/hermes-chatto-plugin
   ```

### Tests

`adapter.py` imports from `gateway.*`, which lives in the Hermes Agent rather
than this repo. Without its source on the `PYTHONPATH`, collection dies with
`ModuleNotFoundError: No module named 'gateway'` before a single test runs.
With the layout above:

```bash
PYTHONPATH=/path/to/hermes-agent uv run --with pyyaml pytest -q
```

`gateway.config` needs `pyyaml`, which is not among our dev dependencies.

### Readability

**Code is written for humans first and machines second — follow Clean Code
principles, with PEP 8 as the baseline.**

Everything in this repo gets read far more often than it gets written: by
maintainers revisiting it months later, and by agents working from these
guidelines. Optimising for that reader is not optional polish. Concretely:

- **PEP 8** for naming, layout and structure — Ruff enforces most of it
  mechanically, but the rules below are judgement calls no linter makes for you.
- **Small functions with one job.** If a function needs paragraphs of `#`
  commentary to explain its flow, split it until the code explains itself.
- **Names that say what they mean.** `_seed_room` beats `do_init`; a boolean
  reads as a question (`is_seen`, `retryable`). No single-letter names outside
  throwaway loop variables.
- **Comments explain why, not what.** The code already says what happens;
  the comment earns its line by recording intent, constraints, or the bug a
  guard exists to prevent (see `adapter.py` for the house style).
- **Explicit over clever.** Idiomatic Python beats micro-optimised trickery —
  comprehension over `map`/`filter` chains, dataclasses over dict-shaped blobs,
  early returns over nested conditionals.
- **Docstrings on every public method**, following PEP 257 — plus the override
  marker required by *Overriding* below.

### Linting & Type Checking

**Both gates run over the repo's whole first-party code — not just the lines
you touched. Someone always forgets an older file otherwise.**

```bash
ruff check .          # lint
ruff format .         # format
uv run basedpyright   # types
```

Ruff excludes `vendor/` and `dist/` via `pyproject.toml`. basedpyright's scope
lives in `pyrightconfig.json`, which likewise skips `vendor/`, build output and
the mock-heavy test modules; its `extraPaths` resolve both Hermes layouts from
*Development* above.

Fix what the tools report instead of adding `# noqa` — if a rule genuinely
does not fit here, that is a change to make deliberately, not inline. The same
goes for silencing type findings with `cast(Any, ...)` or `# type: ignore` —
see *Typed access* below for why that trade hides real bugs.

**Sporadic dead-field check.** Neither gate catches instance state that is
written but never read: Ruff's F841 stops at locals, and pyright counts an
attribute assignment as a use. Leftovers from long-gone features therefore
survive indefinitely (`_dedup`, `_resume_cursor`, the `_ws_*` trio and the
`_token`/`_user_*` mirrors all lived here unnoticed). Every few feature
cycles, sweep the adapter's `self.*` fields: count assignments vs. other
mentions per name, confirm each suspect against the test fixtures and the
Hermes base class, and delete whatever nothing touches.

## Configuration surface

**One field, three coordinated names — declared once on `ChattoConfiguration`.**

A config value lives as a `ConfigField` class attribute in `platform_config.py`.
From the attribute name it derives the `config.yaml` key under
`gateway.platforms.chatto.extra` (the *config_key*, overridable per field) and
the `CHATTO_*` environment variable (upper-cased config_key). Resolution order
per instance: env var → extra → default. Declaring a field is enough for the
adapter, the env-enablement seeding and the setup wizard to pick it up;
`plugin.yaml` additionally repeats the env names as wizard prompts, so keep
those entries in step. Every `optional_env` entry states its default right in
its description (`(default: …)`): at install time the prompt is all the user
sees — "what happens if I leave this empty?" must be answerable without
opening README.md. Required entries have no default to state; they say what
happens when left blank instead (see `CHATTO_BASE_URL`).

When adding or renaming a field, also update the environment-variable table in
README.md — and document each option's default next to it, for env vars
(*Default* column) and `config.yaml` keys alike. A user reading only the
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.

## Typed access

**Work against the declared types. Do not reach for values dynamically when a
typed attribute exists.**

`chattolib` ships dataclasses with `py.typed` for everything it returns —
`Message`, `Asset`, `AssetUpload`, `Room`, `User`. Read their fields directly, so
a wrong name is a loud `AttributeError` at the first call and a type checker can
see it before that:

```python
upload_id = upload.upload_id  # yes
upload_id = str(getattr(cast(Any, upload), "id", ""))  # no
```

The second form cost us every single file upload. `AssetUpload` has no `id`
field — it is called `upload_id` — but `cast(Any, ...)` disables the check and
the `""` default turns the mismatch into an empty string. The result was an
error message blaming the server (`CreateUpload returned no upload ID`) for a
name we got wrong ourselves, and it survived in production because it never
raised.

So: no `cast(Any, ...)` on a value whose type is known, and no `getattr` with a
fallback default to paper over a field you are not sure about — look the field up
in `vendor/common/chattolib/types.py` instead. `getattr` is fine for genuinely
optional or duck-typed things (a handler that may not be set, a payload from an
untyped source), not for dodging a type.

Tests should exercise real library types too, not mocks shaped like them. The
upload bug was invisible because the tests replaced `_upload_asset` wholesale
with an `AsyncMock`, so no test ever touched a real `AssetUpload`.

## Overriding

**Every method that overrides a base-class method must say so in its
docstring, closing with the marker line `BasePlatformAdapter override` — the
pattern already used throughout `adapter.py`.**

This plugin lives by subclassing (`ChattoAdapter` overrides `BasePlatformAdapter`
methods throughout), so an override is not an implementation detail — it is the
contract between plugin and platform. Follow the existing form: first line says
what the method does, the body spells out what changes about the inherited
behaviour, and the last line names the parent class:

```python
async def edit_message(self, chat_id, message_id, content, *, finalize=False):
    """Edit a message we previously sent, via MessageService/UpdateMessage.

    Without this override the base class reports "Not supported" and every
    incremental streaming update arrives as a *new* message. Content beyond
    the per-message limit is refused so callers fall back to ``send()``.

    BasePlatformAdapter override
    """
```

## Capabilities

**When you extend what the adapter can do, keep the startup banner current.**

`_capabilities()` in `adapter.py` logs what the plugin supports at registration,
so starting it up tells you what is available instead of making you read the
source. Whether a capability counts is derived from real overrides: it only
counts when `ChattoAdapter` replaces the `BasePlatformAdapter` method. The label
for it, however, lives in `_CAPABILITY_LABELS` and is *not* discovered
automatically.

So when you add an overridden `BasePlatformAdapter` method:

1. Add an entry to `_CAPABILITY_LABELS` (method name → wording that means
   something to someone who has not read the code). Skip this and the plugin can
   do the thing but says so nowhere.
2. Check whether the `platform_hint` in `register()` needs to mention it. That
   hint goes into the system prompt and is the only way the model learns what the
   channel can do — without it the agent falls back to shelling out.
3. Add a test in `TestRegistration` if the capability is user-visible.
4. Update the capability matrix and feature notes in README.md — the
   user-facing counterpart of the startup list. It may say more than the
   banner (setup-level features such as markdown or reconnect belong there),
   but nothing less.

The reverse holds too: drop a method and its line disappears from the log on its
own, but its `_CAPABILITY_LABELS` entry should go with it, along with its row in
the user docs.

## Shell Scripts

**Always run `shellcheck` after editing any shell scripts.**

```bash
shellcheck path/to/script.sh
```

Or validate all shell scripts in the project:

```bash
find . -name "*.sh" -exec shellcheck {} \;
```

### Configuration

Project-specific shellcheck rules are defined in `.shellcheckrc`. Default severity is `error` to catch all issues.

### Why

- Prevents syntax errors and common pitfalls (e.g., missing quotes, unsafe variable expansions)
- Ensures portability across different shell environments
- Maintains code quality and security standards

### Integration

Consider adding a pre-commit hook for automatic validation:

```yaml
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/koalaman/shellcheck-precommit
    rev: v0.9.0
    hooks:
      - id: shellcheck
        args: [--severity=error]
```
