M0: toolchain, monorepo scaffold, gateway plugin skeleton, CMP app

- gateway-plugin/: android platform plugin (plugin.yaml + adapter.py
  register(ctx) + no-op AndroidAdapter) + stub modules for M1-M5
- app/: Compose Multiplatform project (shared KMP + androidApp +
  desktopApp) with Gradle wrapper; builds :androidApp:assembleDebug
  and :desktopApp:compileKotlin
- scripts/guard_hermes_agent.sh + pre-commit hook: fail if hermes-agent/
  is staged (read-only reference, never committed)
- .gitignore excludes hermes-agent/; docs/ reference library
This commit is contained in:
ARIA committed 2026-08-19 11:27:02 +02:00
commit 59acf66c89
49 files changed
+3950

No files matched your search

+263
View File
@@ -0,0 +1,263 @@
# 03 — Gateway Plugin (Python)
The plugin is a **community-style hermes platform plugin** named `android`.
It follows the "Plugin Path" in `hermes-agent/gateway/platforms/ADDING_A_PLATFORM.md`
and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
**Zero hermes-core changes. Zero new Python dependencies** (`websockets` and
`httpx` are already core deps).
> Source map for every hermes integration point: see `15-hermes-reference.md`.
## 3.1 `plugin.yaml` (manifest)
```yaml
name: android-platform
label: Android
kind: platform
version: 0.1.0
description: >
Native Android / Desktop client gateway adapter for Hermes Agent.
Runs a WebSocket server inside the gateway; the app connects with a
pairing token. Supports streaming, reasoning, structured tool events,
channels/threads, media, FTS5 search, and FCM/ntfy push.
author: <you>
requires_env:
- name: ANDROID_TOKEN
description: "Shared pairing token the app presents on connect"
prompt: "Android pairing token"
password: true
optional_env:
- name: ANDROID_WS_HOST
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "WS host"
password: false
- name: ANDROID_WS_PORT
description: "WS port (default 8790)"
prompt: "WS port"
password: false
- name: ANDROID_HOME_CHANNEL
description: "Default chat id for cron/notification delivery (default android:default)"
prompt: "Home channel"
password: false
- name: ANDROID_ALLOWED_USERS
description: "Comma-separated allowed device_ids (empty = token-only auth)"
prompt: "Allowed device ids"
password: false
- name: ANDROID_ALLOW_ALL_USERS
description: "Allow any paired device (dev only)"
prompt: "Allow all devices? (true/false)"
password: false
- name: ANDROID_PUSH_BACKEND
description: "Push backend: fcm (default) or ntfy"
prompt: "Push backend"
password: false
- name: ANDROID_FCM_SERVICE_ACCOUNT
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
prompt: "FCM service account path"
password: true
- name: ANDROID_FCM_SERVER_KEY
description: "Legacy FCM server key (fallback if no service account)"
prompt: "FCM server key"
password: true
- name: NTFY_TOPIC
description: "ntfy topic for push (when ANDROID_PUSH_BACKEND=ntfy)"
prompt: "ntfy topic"
password: false
- name: NTFY_SERVER_URL
description: "ntfy server URL (default https://ntfy.sh)"
prompt: "ntfy server URL"
password: false
- name: ANDROID_WS_CERT
description: "TLS cert path for WSS (optional)"
prompt: "WSS cert"
password: false
- name: ANDROID_WS_KEY
description: "TLS key path for WSS (optional)"
prompt: "WSS key"
password: false
```
Behavioral (non-secret) settings live in `config.yaml` under
`gateway.platforms.android.extra` (host, port, home_channel, outbox retention,
max upload bytes, tls). Secrets live in `.env`. (hermes policy: `.env` = secrets
only.)
## 3.2 `register(ctx)` entry point
```python
def register(ctx):
ctx.register_platform(
name="android",
label="Android",
adapter_factory=lambda cfg: AndroidAdapter(cfg),
check_fn=check_requirements, # passive: websockets importable + token set
validate_config=validate_config, # host/port/token present
is_connected=is_connected,
required_env=["ANDROID_TOKEN"],
install_hint="No extra packages needed (websockets + httpx are core deps)",
setup_fn=interactive_setup, # hermes gateway setup flow
env_enablement_fn=_env_enablement, # seed extra + home_channel from env
cron_deliver_env_var="ANDROID_HOME_CHANNEL",
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
parse_target_ref_fn=_parse_target_ref, # "android:<chat>[:<thread>]"
allowed_users_env="ANDROID_ALLOWED_USERS",
allow_all_env="ANDROID_ALLOW_ALL_USERS",
max_message_length=0, # 0 = no limit (WS has none)
emoji="📱",
pii_safe=False,
platform_hint=(
"You are chatting with the user through their native Iris app "
"(Android/Desktop). It renders Markdown, inline code, images, "
"audio and video, and shows your reasoning and tool activity. "
"Conversations are organized into channels and optional threads. "
"Keep formatting rich but readable."
),
)
```
Field reference (all from `PlatformEntry`, `gateway/platform_registry.py:63`):
`adapter_factory`, `check_fn`, `validate_config`, `is_connected`, `required_env`,
`install_hint`, `setup_fn`, `env_enablement_fn`, `apply_yaml_config_fn`,
`cron_deliver_env_var`, `parse_target_ref_fn`, `allowed_users_env`,
`allow_all_env`, `max_message_length`, `pii_safe`, `platform_hint`, `emoji`,
`ensure_deps_fn`.
- **`check_requirements()`** — passive probe: `import websockets` succeeds and
`ANDROID_TOKEN` is set. Never installs.
- **`_env_enablement()`** — returns a dict seeding `PlatformConfig.extra`
(host/port/home_channel/push_backend) + a `home_channel` key
`{"chat_id": "android:default", "name": "Default"}` so `hermes gateway status`
and cron home-channel resolution work without instantiating the adapter.
- **`_parse_target_ref(ref)`** — if `ref` starts with `android:`, return
`(chat_id, thread_id)` parsed from `android:<chat>[:<thread>]`; else `None`.
- **`interactive_setup()`** — prompts for token (or generates one), host/port,
push backend + credentials, prints a QR code (pairing) and the app URL.
## 3.3 `AndroidAdapter(BasePlatformAdapter)`
Constructor: `super().__init__(config=config, platform=Platform("android"))`.
Reads `config.extra` (env overrides win). Initializes: WS server (not started
until `connect()`), connection registry, outbox (SQLite under
`get_hermes_home()/"android"`), push backend, pairing store, channel directory.
### Lifecycle
- **`connect(*, is_reconnect=False) -> bool`**
- Acquire scoped lock (`gateway.status.acquire_scoped_lock("android", key)`)
so two profiles can't bind the same port/identity.
- Start the `websockets` server on `host:port` (TLS if cert/key set).
- `_mark_connected()`; return True.
- **`disconnect()`**
- Stop server, close all device sockets, release lock, `_mark_disconnected()`.
### Inbound (app → agent)
- WS `message.send {text, reply_to?, media_refs?}` → build `SessionSource` via
`self.build_source(chat_id, chat_name, chat_type, user_id, user_name,
thread_id)` → build `MessageEvent(text=…, message_type=TEXT, source=…,
media_urls=[cached paths], media_types=[…], reply_to_message_id=…)` →
`await self.handle_message(event)`.
- Slash commands arrive as plain text starting with `/`; the gateway's command
pipeline resolves + dispatches them (no special handling needed).
- `picker.select {picker_id, value}` → route to the gateway-side resolvers
(model picker / choice picker / clarify / approval / slash-confirm) using the
shared callback-id conventions (`cl:<id>:<idx>`, `appr:<id>:<choice>`,
`sc:<choice>:<id>`).
- `channel.create` / `channel.rename` / `channel.set_default` → mutate the
channel directory (SQLite) + emit `channel.*` frames to all devices.
- `search {query, scope, chat_id?, thread_id?}` → `search.py` → `search.results`.
- `media.upload` (chunked) → `media.py` → `cache_*_from_bytes` → `media_ref`.
- `media.pull {media_id}` → stream cached bytes as binary frames.
- `fcm.register {token}` / `hello` → update device registry.
- `read.receipt {message_id}` → mark delivered/read (drives ✓✓), ack.
- `sync {cursor}` → `outbox.py` → replay frames since cursor.
### Outbound (agent → app)
- **`send(chat_id, content, reply_to=None, metadata=None) -> SendResult`**
- Split reasoning prefix (see `05-streaming.md`) → `reasoning` field.
- If **any** device is connected: broadcast `message` frame to all.
- Else (no live devices): append to **outbox** + fire **push** (FCM/ntfy).
- Return `SendResult(success=True, message_id=<id>)`.
- **`edit_message(chat_id, message_id, content)`** → `message.update` frame
(drives streaming). If no device, no-op (outbox holds the final `send`).
- **`send_typing(chat_id, metadata=None)`** → `typing` frame.
- **`get_chat_info(chat_id) -> dict`** → `{"name": <channel name>, "type": "dm"|"channel"}`
from the channel directory.
- **Media send** — `send_image / send_video / send_document / send_voice /
send_image_file / send_multiple_images`: stage the file in the media cache,
mint a `media_id`, emit `media.offer {media_id, mime, size, filename, kind}`,
serve bytes on `media.pull`. (Base-class `extract_media`/`extract_images`
already pull `MEDIA:`/image tags out of agent text and call these.)
- **Interactive pickers** — `send_model_picker(...)`, `send_choice_picker(...)`,
`send_clarify(...)`, `send_exec_approval(...)`, `send_slash_confirm(...)`:
emit `picker.model` / `picker.choice` / `picker.clarify` / `picker.approval` /
`picker.confirm` frames with options; store pending state keyed by
`picker_id`; resolve on `picker.select`.
- **`create_handoff_thread(chat_id, name)`** → create a thread id, register in
channel directory, return it (used by cron "continuable" threads).
### Streaming hooks
The main gateway drives delivery through the **legacy callback path**:
- `stream_delta_callback` → `GatewayStreamConsumer` → `send()` (first) +
`edit_message()` (updates) → `message.start` / `message.update`.
- `tool_progress_callback` → progress queue → `send_progress_messages` →
`send()` → `tool.*` frames (structured; classified in the adapter).
- `interim_assistant_callback` → consumer `on_commentary` → `send()` →
`commentary` frames.
The adapter tracks per-chat **turn state** (in-turn, current streaming
`message_id`, last tool index) to classify outbound `send()` calls into
`message` vs `tool.*` vs `commentary`. The exact classification markers are
verified empirically in M2 (see `13-testing.md`).
## 3.4 WebSocket server (`ws_server.py`)
- Library: **`websockets`** (core dep, v15). `websockets.serve(handler, host,
port, ssl=ctx)`.
- **Handler** per connection:
1. Await first frame; must be `hello {token, device_id, device_name, caps,
fcm_token?}`. Verify token (constant-time) + allowlist. On failure: send
`error {code:"auth"}` and close.
2. On success: register in connection registry
(`device_id → {ws, caps, fcm_token}`), send
`hello.ack {server_caps, sync_cursor, channels[]}`.
3. Loop: decode frames, dispatch to adapter inbound handlers.
4. On close: deregister; if no devices remain, ensure pending outbox
frames have push fired.
- **Routing:** `emit(chat_id, frame)` → broadcast to **all** connected
devices (no per-chat subscribe; single-user model). Global frames
(`channel.*`, `status`) also broadcast to all.
- **Heartbeat:** WS ping/pong + app-level `ping`/`pong`; dead peers reaped.
- **Backpressure:** per-connection send queue with a bounded buffer; drop
`message.update` (coalesce to latest) under pressure, never drop
`message`/`tool.end`/`notification`.
## 3.5 State & storage (all under `get_hermes_home()/"android"`)
> Use `get_hermes_home()` from `hermes_constants` for **all** paths (profile-safe).
> Never hardcode `~/.hermes`.
- `devices.db` — device registry (device_id, name, caps, fcm_token, ntfy_topic,
last_seen, created).
- `channels.db` — channel directory (chat_id, name, kind: default|channel|thread,
parent_chat_id, created, is_default).
- `outbox.db` — undelivered frames per chat_id + monotonic cursor.
- `media/` — inbound + outbound media cache (reuse hermes `cache_*_from_bytes`
dirs where possible).
## 3.6 Config resolution
- **Secrets (`.env`):** `ANDROID_TOKEN`, `ANDROID_FCM_SERVICE_ACCOUNT`,
`ANDROID_FCM_SERVER_KEY`, `ANDROID_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
- **Behavioral (`config.yaml` → `gateway.platforms.android.extra`):** `host`,
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
`max_upload_bytes`, `tls`.
- Env vars override `config.yaml` (hermes convention). Read secrets with the
scope-aware `_get_scoped_secret` pattern (see `plugins/platforms/irc/adapter.py:42`)
so multiplexed profiles don't leak each other's tokens.
## 3.7 Failure & lifecycle safety
- WS server bind failure → `_set_fatal_error("bind_failed", …, retryable=True)`.
- All outbound sends are best-effort; a dead socket latches and the frame falls
to the outbox.
- `disconnect()` cancels the server task and closes sockets cleanly.
- Token/PII redaction in all logs (hermes PII policy).