feat(plugin): runtime API bootstrap + capability auto-detect + canonical uninstaller
Adds hermes_relay_bootstrap/ — a Python package shipped via .pth in the hermes-agent venv site-packages — that monkey-patches aiohttp.web.Application at interpreter startup. When the gateway builds its app, the bootstrap intercepts app["api_server_adapter"] = self and injects 14 management handlers onto the same router: /api/sessions/* CRUD, /api/memory, /api/skills, /api/config, /api/available-models. Feature-detects on route path and no-ops cleanly when these routes are already present, so it's safe to ship across all hermes-agent versions. Chat streaming continues to use standard upstream /v1/runs (which already emits structured tool events). The Android client gains a new ServerCapabilities probe + streamingEndpoint = "auto" default that picks the best chat path automatically based on what each server actually exposes (Auto/Sessions/Runs). Also adds canonical uninstall.sh — reverses every install.sh step in the opposite order, idempotent, never touches state shared with other Hermes tools (.env, sessions DB, hermes-agent venv core). Flags: --dry-run, --keep-clone, --remove-secret. install.sh header + success summary + README + user-docs/guide/getting-started.md + user-docs/reference/api.md all updated to mention the uninstall path. Verified end-to-end on the server: bootstrap loads via .pth, intercepts aiohttp.web import, injects 11 unique paths onto a vanilla aiohttp app, GET /api/sessions returns real production data via SessionDB, OPTIONS probes return the expected 405/404 codes for capability detection, and feature detection no-ops cleanly when routes already exist. See docs/decisions.md ADR 16 for full rationale and DEVLOG.md 2026-04-12 entries for the work breakdown. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
44732f5502
commit
b6db95caf9
@@ -33,16 +33,26 @@ Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is
|
||||
| `GET /health` | Health check | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management | — |
|
||||
|
||||
**Non-standard endpoints (may be version-specific):**
|
||||
**Non-standard endpoints (provided by fork OR by plugin bootstrap):**
|
||||
|
||||
These endpoints work on our hermes-agent v0.7.0 but are **not in the upstream source**. They may be fork-specific, version-specific, or added by plugins. Always use `detectChatMode()` to probe availability.
|
||||
These endpoints are not in stock upstream `gateway/platforms/api_server.py`. There are three ways a hermes-agent install can serve them:
|
||||
|
||||
| Endpoint | Purpose | Fallback |
|
||||
|----------|---------|----------|
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Use `/v1/runs` or `/v1/chat/completions` |
|
||||
| `GET/POST/PATCH/DELETE /api/sessions` | Session CRUD | Use `X-Hermes-Session-Id` header with `/v1/chat/completions` |
|
||||
| `GET /api/skills` | Skill discovery | Hardcoded command list |
|
||||
| `GET /api/config` | Server config (personalities, model) | No fallback — personality picker empty |
|
||||
1. **Codename-11 fork** (`feat/api-server-enhancements` branch, currently merged into `axiom`) — adds them natively in `gateway/platforms/api_server.py`. Submitted upstream as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556).
|
||||
2. **Bootstrap injection** (`hermes_relay_bootstrap/`) — installed alongside the plugin via `install.sh`, runs at Python interpreter startup (via a `.pth` file in the venv's site-packages), monkey-patches `aiohttp.web.Application` so that when `APIServerAdapter.connect()` builds its app, our extra routes are added to the same router. **Vanilla upstream + plugin = these endpoints work too.** The bootstrap deliberately does NOT inject `/api/sessions/{id}/chat/stream` — chat goes through standard `/v1/runs` instead, which has live tool events and avoids touching `_create_agent` / `run_conversation` internals.
|
||||
3. **Upstream-merged** (post PR #8556) — same paths, native upstream support. The bootstrap feature-detects on route paths and no-ops in this case.
|
||||
|
||||
| Endpoint | Purpose | Provided by |
|
||||
|----------|---------|-------------|
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/sessions/search` | Full-text message search | Fork OR bootstrap OR upstream-merged |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Fork OR upstream-merged ONLY (NOT bootstrap — use `/v1/runs`) |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/skills`, `/categories`, `/{name}` | Skill discovery | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/available-models` | Provider model list | Fork OR bootstrap OR upstream-merged |
|
||||
|
||||
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"` (the default for new installs), `ConnectionViewModel.resolveStreamingEndpoint()` reads the capability snapshot and picks `sessions` (when the chat-stream handler is present) or `runs` (otherwise). Users can still force `sessions` or `runs` manually in Settings → Chat → Streaming endpoint.
|
||||
|
||||
**Tool call rendering paths:**
|
||||
1. **Runs API** (`/v1/runs`) — Best for tool display. Emits `tool.started`/`tool.completed` as real SSE events → rendered as ToolProgressCards in real-time.
|
||||
@@ -50,9 +60,10 @@ These endpoints work on our hermes-agent v0.7.0 but are **not in the upstream so
|
||||
3. **Annotation parser** (`ChatHandler.parseAnnotationLine` + `finalizeAnnotations`) — Fallback for servers that inject inline markdown annotations (`` `💻 terminal` ``). Parses during streaming + reconciliation pass on stream end. If your Hermes version uses a different format, check `adb logcat -s HermesApiClient` for raw SSE events and update the regex.
|
||||
|
||||
## Key Instructions
|
||||
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document it as non-standard and implement a fallback.
|
||||
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document whether the bootstrap injects it (`hermes_relay_bootstrap/_handlers.py`) or if it requires the fork.
|
||||
- When building features that interface with hermes-agent, reference the upstream source — not just our spec docs. Our spec may be aspirational or based on a specific server version.
|
||||
- If we use a non-standard endpoint, mark it clearly in code comments and ensure `detectChatMode()` handles its absence gracefully.
|
||||
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver in `ConnectionViewModel.resolveStreamingEndpoint()` (or equivalent) degrades gracefully.
|
||||
- **Bootstrap maintenance:** When upstream PR #8556 merges and reaches a released hermes-agent version, the entire `hermes_relay_bootstrap/` package and its `.pth` file in `install.sh` can be deleted. The bootstrap is no-op-compatible with both fork and upstream-merged installs (feature detection by route path), so leaving it in place during the rollout window is harmless.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
@@ -156,6 +167,11 @@ hermes-android/ ← Android Studio opens this root
|
||||
| `app/src/main/res/drawable/splash_icon.xml` | Splash screen icon (0.9x scale) |
|
||||
| `app/src/main/res/drawable/splash_icon_animated.xml` | Animated splash (scale + overshoot + fade) |
|
||||
| `plugin/relay/server.py` | Canonical relay server — WSS + HTTP routes (health, /pairing, /pairing/register) |
|
||||
| `hermes_relay_bootstrap/` | Runtime patch package for vanilla upstream hermes-agent. Loaded via `hermes_relay_bootstrap.pth` in the venv site-packages (dropped by `install.sh` step 2). `__init__.py` installs a `sys.meta_path` finder; `_patch.py` swaps `aiohttp.web.Application` for a subclass that intercepts `app["api_server_adapter"] = self` and triggers `_handlers.register_routes()`; `_handlers.py` ports ~14 management handlers (sessions CRUD, memory, skills, config, available-models) from the fork. Feature-detects on route paths so it no-ops on fork or upstream-merged installs. Removable in one PR once PR #8556 lands. |
|
||||
| `hermes_relay_bootstrap.pth` | Single-line `.pth` file at repo root: `import hermes_relay_bootstrap`. `install.sh` copies this into the hermes-agent venv's `site-packages/` so Python's `site` module loads the bootstrap at every interpreter startup. NOT installed automatically by `pip install -e` — setuptools' data-files doesn't ship to site-packages reliably for editable installs. |
|
||||
| `uninstall.sh` | Canonical uninstaller — reverses every `install.sh` step in opposite order: stops + disables systemd unit, removes shim, removes skills external_dirs entry from config.yaml (preserves other entries), removes plugin symlink + legacy stales, removes bootstrap `.pth` from site-packages, `pip uninstall hermes-relay`, removes the clone (unless `--keep-clone`). Idempotent. Never touches `~/.hermes/.env`, `state.db`, or the hermes-agent venv core. Flags: `--dry-run`, `--keep-clone`, `--remove-secret` (the last preserves QR signing identity by default). |
|
||||
| `app/src/main/kotlin/.../network/HermesApiClient.kt` (capability detection) | `data class ServerCapabilities(sessionsApi, sessionsChatStream, runs, portable, healthy)` + `suspend fun probeCapabilities()`. Uses OPTIONS-method probes against `/api/sessions/probe/chat/stream` and `/v1/runs` to distinguish "endpoint exists, just POST-only" (405) from "endpoint missing" (404). The result drives `streamingEndpoint = "auto"` resolution. |
|
||||
| `app/src/main/kotlin/.../viewmodel/ConnectionViewModel.kt` (auto-resolver) | `resolveStreamingEndpoint(preference)` collapses `"auto"` to a concrete `"sessions"` or `"runs"` based on the latest `serverCapabilities` snapshot. Manual `"sessions"` / `"runs"` settings pass through unchanged. |
|
||||
| `plugin/relay/auth.py` | PairingManager (generate + register_code), SessionManager, RateLimiter |
|
||||
| `plugin/relay/config.py` | RelayConfig + PAIRING_ALPHABET (full A-Z / 0-9 as of 2026-04-11) |
|
||||
| `plugin/relay/channels/terminal.py` | Phase 2 PTY-backed terminal handler |
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-04-12 — Add canonical uninstall.sh + bootstrap docs
|
||||
|
||||
Companion to the bootstrap injection work below. There was no formal uninstall path before — `install.sh` is idempotent so most update flows worked, but cleanly removing the plugin (e.g., to test that install.sh works on a truly fresh state) required manually undoing 6 install steps. New `uninstall.sh` reverses them in opposite order:
|
||||
|
||||
1. Stops + disables `hermes-relay.service`, removes the systemd unit, daemon-reloads
|
||||
2. Removes `~/.local/bin/hermes-pair` shim
|
||||
3. Scrubs the relay's `skills.external_dirs` entry from `~/.hermes/config.yaml` via the same yaml parsing pattern install.sh uses, with a `.bak` backup before write — preserves all other entries
|
||||
4. Removes `~/.hermes/plugins/hermes-relay` symlink + any legacy stales (`hermes-android`, etc.)
|
||||
5. Removes `hermes_relay_bootstrap.pth` from venv site-packages, `pip uninstall hermes-relay`
|
||||
6. Removes `~/.hermes/hermes-relay` clone (sanity-checked: refuses to delete a directory that doesn't have `.git` + `install.sh`)
|
||||
|
||||
What it never touches: `~/.hermes/.env` (other tools authenticate against this), `~/.hermes/state.db` (sessions DB shared with the gateway), `~/.hermes/hermes-agent/` (the agent itself), `~/.hermes/hermes-agent/venv/` (only our `.pth` is removed, not the venv core), and `~/.hermes/hermes-relay-qr-secret` (kept by default — the QR signing identity is precious; opt in to wipe with `--remove-secret`).
|
||||
|
||||
Flags: `--dry-run` previews without changing anything, `--keep-clone` leaves the git tree in place, `--remove-secret` wipes the QR secret. Help text: `bash uninstall.sh --help`.
|
||||
|
||||
`install.sh` header docs updated to mention `bootstrap injection` (step 2) and the uninstall path. Success summary now prints both `git pull && bash install.sh` for updates and `bash uninstall.sh --dry-run` for previewing removal. README.md and `user-docs/guide/getting-started.md` got equivalent updates with the bootstrap explanation + uninstall flags.
|
||||
|
||||
## 2026-04-12 — Bootstrap injection: vanilla upstream hermes-agent now works with the plugin
|
||||
|
||||
Closed the "you must run our hermes-agent fork to get full features" gap. The Codename-11 fork (`feat/api-server-enhancements`, 13 commits, submitted as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556)) adds 20 management endpoints — `/api/sessions/*` CRUD, `/api/memory`, `/api/skills`, `/api/config`, `/api/available-models`, `/api/sessions/{id}/chat/stream` — that the Android app depends on. Until #8556 merges and reaches a release, vanilla upstream users were missing the sessions browser, conversation-history-on-restart, personality picker, command palette, and memory management.
|
||||
|
||||
**The fix: a single `.pth` file that runs at Python interpreter startup.** New `hermes_relay_bootstrap/` package ships with the plugin, gets loaded by Python's `site` module before anything in hermes-agent imports `aiohttp.web`. The bootstrap installs a `sys.meta_path` finder that wraps the loader for `aiohttp.web`. When the import resolves, our wrapper replaces `web.Application` with a thin subclass. The subclass overrides `__setitem__` to detect `app["api_server_adapter"] = self` — the line at `gateway/platforms/api_server.py:1735` where the gateway gives us a reference to the adapter while the router is still mutable. At that moment we feature-detect by route path and bind ~14 management handlers from `_handlers.py` directly onto the same router. The gateway then continues with its own route registrations and starts the server. From the outside, vanilla upstream now serves all the fork's management endpoints.
|
||||
|
||||
**What is NOT injected and why:** the chat-stream handler (`/api/sessions/{id}/chat/stream`). It depends on `_create_agent` and `agent.run_conversation` with multimodal content — the riskiest cross-cutting upstream methods that the fork may have implicitly modified. Instead, chat goes through standard upstream `/v1/runs`, which already emits structured `tool.started`/`tool.completed` SSE events. This is arguably an upgrade — `/v1/runs` has live tool events whereas the sessions chat-stream path required a post-stream message-history reload to render tool cards. The Android client adapts via a new `streamingEndpoint = "auto"` mode (default for new installs).
|
||||
|
||||
**Files added:**
|
||||
- `hermes_relay_bootstrap/__init__.py` (~30 lines) — installs the meta_path finder
|
||||
- `hermes_relay_bootstrap/_patch.py` (~170 lines) — `_AioHttpWebFinder`, `_PatchingLoader`, `_PatchedApplication`, `_maybe_register_routes` with feature detection by route path
|
||||
- `hermes_relay_bootstrap/_handlers.py` (~500 lines) — 14 ported management handlers + helpers (sessions CRUD, memory CRUD, skills, config, available-models). Handlers take `adapter` as a closure parameter rather than being bound methods, so we don't pollute upstream's class.
|
||||
- `hermes_relay_bootstrap.pth` — single line: `import hermes_relay_bootstrap`
|
||||
|
||||
**Files changed:**
|
||||
- `pyproject.toml` — added `hermes_relay_bootstrap*` to packages.find include list
|
||||
- `install.sh` step 2 — copies the `.pth` into the venv's `site-packages/` after `pip install -e`. Verified empirically: setuptools' editable install does NOT ship `data-files` to site-packages reliably (it puts them in `venv/data/` instead, where Python's `site` module never looks). Manual copy is necessary.
|
||||
- `app/src/main/kotlin/.../HermesApiClient.kt` — new `ServerCapabilities` data class + `probeCapabilities()` method that returns per-endpoint presence. Uses OPTIONS-method probes against `/api/sessions/probe/chat/stream` and `/v1/runs` to distinguish "endpoint exists, just POST-only" (405) from "endpoint missing" (404). `detectChatMode()` becomes a thin compatibility wrapper around `probeCapabilities().toChatMode()`.
|
||||
- `app/src/main/kotlin/.../ConnectionViewModel.kt` — exposes `serverCapabilities: StateFlow<ServerCapabilities>`, populates it from `probeCapabilities()` in `rebuildApiClient()`, adds `resolveStreamingEndpoint(preference)` helper that collapses `"auto"` to a concrete `"sessions"` or `"runs"` based on the latest snapshot. Default endpoint preference for new installs flipped from `"sessions"` to `"auto"`.
|
||||
- `app/src/main/kotlin/.../ChatViewModel.kt` — `streamingEndpoint` default flipped from `"sessions"` to `"runs"` (the safer fallback before RelayApp pushes the resolved value).
|
||||
- `app/src/main/kotlin/.../ui/RelayApp.kt` — `LaunchedEffect(streamingEndpoint, serverCapabilities)` recomputes the resolved endpoint when either changes, pushes into ChatViewModel.
|
||||
- `app/src/main/kotlin/.../ui/screens/ChatSettingsScreen.kt` — Settings → Streaming endpoint dropdown gains an "Auto" option (alongside Sessions/Runs). Helper text dynamically shows which path Auto is currently using.
|
||||
- `CLAUDE.md` — non-standard endpoints table rewritten to show all three "provided by" mechanisms (fork, bootstrap, upstream-merged), plus key files entries for bootstrap + capability detection.
|
||||
- `docs/decisions.md` — new ADR 16 covering the runtime injection rationale, options considered (A/B/C/D), risks accepted, removal path.
|
||||
- `vault/Hermes-Relay.md` — new "Bootstrap Injection Architecture" section with the compatibility matrix; updated "Hermes Integration" table to show two install paths (fork or bootstrap).
|
||||
|
||||
**Compatibility matrix** (all three combinations safe to ship the bootstrap with):
|
||||
|
||||
| Gateway version | Bootstrap behavior | Result |
|
||||
|---|---|---|
|
||||
| Codename-11 fork (`axiom`) | Detects existing `/api/sessions`, no-ops | Fork serves everything natively ✓ |
|
||||
| Vanilla upstream main | Detects no `/api/sessions`, injects routes | Bootstrap-injected endpoints serve ✓ |
|
||||
| Post-PR-#8556 upstream-merged | Detects existing `/api/sessions`, no-ops | Upstream serves everything natively ✓ |
|
||||
|
||||
**Removal path** (when PR #8556 reaches a released hermes-agent version): delete `hermes_relay_bootstrap/`, delete `hermes_relay_bootstrap.pth`, remove the `.pth` drop block from `install.sh`. The Android client `probeCapabilities()` + `streamingEndpoint = "auto"` plumbing stays — it's permanent infrastructure that handles mixed-version deployments.
|
||||
|
||||
## 2026-04-12 — Fix: TTS waveform stays alive through multi-sentence playback
|
||||
|
||||
The waveform was flatlinining after the first sentence while audio kept playing. Root cause: `maybeAutoResume()` fired after every sentence in the TTS consumer loop. The SSE stream finishes before TTS plays all queued sentences, so `streamObserverJob?.isActive` was already false → state flipped to Idle → amplitude bridge stopped → waveform died. Fix: restructured TTS consumer from `for` loop to `while` + `tryReceive` peek. `maybeAutoResume` only fires when the queue is actually drained (`tryReceive` returns failure), not between sentences. Between sentences within the same response, `tryReceive` succeeds immediately and the consumer skips the Idle transition. Additionally, the consumer re-asserts Speaking state before each synthesis call to handle the edge case where the queue was briefly empty between observer pushes.
|
||||
|
||||
@@ -61,14 +61,16 @@ On the machine running your Hermes agent:
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
|
||||
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, and drops a thin `hermes-pair` shim into `~/.local/bin/`. After restart, pair your phone via either of these equivalent entry points:
|
||||
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, drops a thin `hermes-pair` shim into `~/.local/bin/`, and (optionally) installs a systemd user service for the WSS relay. After restart, pair your phone via either of these equivalent entry points:
|
||||
|
||||
- **From any Hermes chat surface** (CLI, Discord, Telegram, etc.): type `/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR inline. Shortest path if you're already chatting with the agent.
|
||||
- **From a shell**: `hermes-pair` (dashed) — a thin wrapper around `python -m plugin.pair` in the hermes-agent venv. Use this in scripts or when you want the raw output.
|
||||
|
||||
Scan the QR from the Android app's onboarding screen and you're connected. One scan configures **both** the direct-chat API server **and** the WSS relay (for terminal/bridge) — if a local relay is running at `localhost:8767`, the pair command pre-registers a fresh 6-char pairing code with it and embeds the relay URL + code in the same QR. If you only want direct chat, pass `--no-relay` (or just don't start the relay). Plain-text connection details are always printed alongside the QR so you can copy values by hand if your terminal can't render QR blocks.
|
||||
|
||||
**Updating:** `cd ~/.hermes/hermes-relay && git pull` — pulls new plugin, skill, and docs in one step. Because the installer uses `pip install -e` and `external_dirs`, nothing needs to be re-copied; restart hermes-agent and the updated skill + plugin are picked up on next load.
|
||||
**Updating:** `cd ~/.hermes/hermes-relay && git pull && bash install.sh` — pulls new code and re-runs the installer (idempotent). Restart `hermes-gateway` and `hermes-relay` to pick up changes. For routine plugin/skill updates a plain `git pull` is enough.
|
||||
|
||||
**Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` reverses every install step in the opposite order. Idempotent, never touches state shared with other Hermes tools (`.env`, sessions DB, hermes-agent venv core). Flags: `--dry-run`, `--keep-clone`, `--remove-secret`. Or pull the script via curl if you've already removed the clone.
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26), [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+.
|
||||
|
||||
|
||||
@@ -50,6 +50,53 @@ enum class ChatMode {
|
||||
DISCONNECTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-endpoint capability snapshot. Populated by [HermesApiClient.probeCapabilities].
|
||||
*
|
||||
* The Android client uses this to pick the best chat path automatically when
|
||||
* `streamingEndpoint = "auto"`. The bootstrap-injected vanilla-upstream case
|
||||
* is the interesting one: `sessionsApi=true` (we injected it) but
|
||||
* `sessionsChatStream=false` (we deliberately didn't inject the chat
|
||||
* handler — runs is better). The auto-resolver picks `runs` for chat in that
|
||||
* case while still using sessions endpoints for browse/rename/delete.
|
||||
*/
|
||||
data class ServerCapabilities(
|
||||
/** `/api/sessions` (CRUD) — true on fork, upstream-merged, OR bootstrap-injected. */
|
||||
val sessionsApi: Boolean,
|
||||
/** `/api/sessions/{id}/chat/stream` (SSE) — true ONLY on fork or upstream-merged. */
|
||||
val sessionsChatStream: Boolean,
|
||||
/** `/v1/runs` (structured-event SSE) — standard upstream chat path. */
|
||||
val runs: Boolean,
|
||||
/** `/v1/chat/completions` — OpenAI-compatible fallback. */
|
||||
val portable: Boolean,
|
||||
/** `/health` — basic reachability. */
|
||||
val healthy: Boolean,
|
||||
) {
|
||||
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
|
||||
fun preferredChatEndpoint(): String = when {
|
||||
sessionsChatStream -> "sessions"
|
||||
runs -> "runs"
|
||||
else -> "sessions" // last-resort: try sessions, will surface a clear error
|
||||
}
|
||||
|
||||
fun toChatMode(): ChatMode = when {
|
||||
!healthy -> ChatMode.DISCONNECTED
|
||||
sessionsApi -> ChatMode.ENHANCED_HERMES
|
||||
portable || runs -> ChatMode.PORTABLE
|
||||
else -> ChatMode.DISCONNECTED
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DISCONNECTED = ServerCapabilities(
|
||||
sessionsApi = false,
|
||||
sessionsChatStream = false,
|
||||
runs = false,
|
||||
portable = false,
|
||||
healthy = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct HTTP/SSE client for the Hermes API Server.
|
||||
*
|
||||
@@ -770,37 +817,99 @@ class HermesApiClient(
|
||||
|
||||
/**
|
||||
* Probe the server to determine which chat API is available.
|
||||
* Checks /health, then /api/sessions (enhanced), then /v1/models (portable).
|
||||
* Convenience wrapper around [probeCapabilities] that collapses the
|
||||
* per-endpoint result into the older 3-state ChatMode enum for callers
|
||||
* that don't need the detail.
|
||||
*/
|
||||
suspend fun detectChatMode(): ChatMode = withContext(Dispatchers.IO) {
|
||||
// 1. Basic connectivity
|
||||
try {
|
||||
val healthReq = authRequest("$baseUrl/health").get().build()
|
||||
client.newCall(healthReq).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext ChatMode.DISCONNECTED
|
||||
suspend fun detectChatMode(): ChatMode = probeCapabilities().toChatMode()
|
||||
|
||||
/**
|
||||
* Probe each endpoint we care about and return a per-route capability
|
||||
* snapshot. This is the source of truth for "which chat path should we
|
||||
* use" — see [ServerCapabilities.preferredChatEndpoint].
|
||||
*
|
||||
* Probe order:
|
||||
* 1. `/health` — if this fails, everything else is moot.
|
||||
* 2. `/api/sessions?limit=1` — sessions CRUD (true on fork OR
|
||||
* bootstrap-injected vanilla upstream).
|
||||
* 3. `OPTIONS /api/sessions/probe/chat/stream` — chat-stream handler
|
||||
* presence. We use OPTIONS because the actual handler only accepts
|
||||
* POST. aiohttp returns 405 (Method Not Allowed) when the route is
|
||||
* registered but the method doesn't match, and 404 when the route
|
||||
* doesn't exist at all. The 405 is the positive signal we want.
|
||||
* 4. `OPTIONS /v1/runs` — runs endpoint presence (same 405 vs 404
|
||||
* logic).
|
||||
* 5. `/v1/models` — OpenAI-compat reachability.
|
||||
*
|
||||
* All probes are bearer-auth'd. A 401 still tells us "endpoint exists,
|
||||
* just not authorised right now" — that counts as present for capability
|
||||
* purposes, since the user will retry once auth is fixed.
|
||||
*/
|
||||
suspend fun probeCapabilities(): ServerCapabilities = withContext(Dispatchers.IO) {
|
||||
// 1. Health
|
||||
val healthy = try {
|
||||
val req = authRequest("$baseUrl/health").get().build()
|
||||
client.newCall(req).execute().use { it.isSuccessful }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
|
||||
|
||||
// 2. Sessions CRUD
|
||||
val sessionsApi = try {
|
||||
val req = authRequest("$baseUrl/api/sessions?limit=1").get().build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
// 200 = present + authed; 401 = present but not authed
|
||||
response.code in setOf(200, 401)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
return@withContext ChatMode.DISCONNECTED
|
||||
false
|
||||
}
|
||||
|
||||
// 2. Try enhanced sessions API
|
||||
try {
|
||||
val sessionsReq = authRequest("$baseUrl/api/sessions?limit=1").get().build()
|
||||
client.newCall(sessionsReq).execute().use { response ->
|
||||
if (response.isSuccessful) return@withContext ChatMode.ENHANCED_HERMES
|
||||
// 3. Sessions chat stream — OPTIONS probe so we don't kick off a real
|
||||
// chat. aiohttp returns 405 when the path is registered but the
|
||||
// method isn't allowed (POST-only), and 404 when the path is
|
||||
// unknown. 405 = endpoint exists.
|
||||
val sessionsChatStream = try {
|
||||
val req = authRequest("$baseUrl/api/sessions/probe/chat/stream")
|
||||
.method("OPTIONS", null)
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
response.code in setOf(200, 401, 405)
|
||||
}
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
// 3. Try OpenAI-compatible models endpoint
|
||||
try {
|
||||
val modelsReq = authRequest("$baseUrl/v1/models").get().build()
|
||||
client.newCall(modelsReq).execute().use { response ->
|
||||
if (response.isSuccessful) return@withContext ChatMode.PORTABLE
|
||||
// 4. /v1/runs — same OPTIONS-probe trick.
|
||||
val runs = try {
|
||||
val req = authRequest("$baseUrl/v1/runs")
|
||||
.method("OPTIONS", null)
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
response.code in setOf(200, 401, 405)
|
||||
}
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
// Server is reachable but neither API is available
|
||||
ChatMode.DISCONNECTED
|
||||
// 5. /v1/models — OpenAI-compat reachability
|
||||
val portable = try {
|
||||
val req = authRequest("$baseUrl/v1/models").get().build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
response.code in setOf(200, 401)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
ServerCapabilities(
|
||||
sessionsApi = sessionsApi,
|
||||
sessionsChatStream = sessionsChatStream,
|
||||
runs = runs,
|
||||
portable = portable,
|
||||
healthy = true,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
@@ -232,10 +232,14 @@ fun RelayApp() {
|
||||
connectionViewModel.chatHandler.parseToolAnnotations = parseAnnotations
|
||||
}
|
||||
|
||||
// Sync streaming endpoint preference to chat
|
||||
// Sync streaming endpoint preference to chat. Resolves "auto" against the
|
||||
// current server capabilities so vanilla upstream + bootstrap-injected
|
||||
// sessions API picks /v1/runs for chat (which has live tool events)
|
||||
// while still using /api/sessions/* for browse/rename/delete.
|
||||
val streamingEndpoint by connectionViewModel.streamingEndpoint.collectAsState()
|
||||
LaunchedEffect(streamingEndpoint) {
|
||||
chatViewModel.streamingEndpoint = streamingEndpoint
|
||||
val serverCapabilities by connectionViewModel.serverCapabilities.collectAsState()
|
||||
LaunchedEffect(streamingEndpoint, serverCapabilities) {
|
||||
chatViewModel.streamingEndpoint = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
|
||||
}
|
||||
|
||||
// What's New auto-show
|
||||
|
||||
@@ -273,14 +273,28 @@ fun ChatSettingsScreen(
|
||||
text = "Streaming endpoint",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
val serverCaps by connectionViewModel.serverCapabilities.collectAsState()
|
||||
val resolvedHelp = when (streamingEndpoint) {
|
||||
"auto" -> {
|
||||
val resolved = serverCaps.preferredChatEndpoint()
|
||||
"Auto: picks the best path based on what your server exposes. " +
|
||||
"Currently using: $resolved" +
|
||||
if (!serverCaps.sessionsChatStream && serverCaps.sessionsApi)
|
||||
" (sessions browse via /api/sessions, chat via /v1/runs)"
|
||||
else ""
|
||||
}
|
||||
"sessions" -> "Sessions: tool calls shown as inline text annotations."
|
||||
"runs" -> "Runs: structured tool events with real-time progress cards."
|
||||
else -> ""
|
||||
}
|
||||
Text(
|
||||
text = "Sessions: tool calls shown as inline text annotations. Runs: structured tool events with real-time progress cards.",
|
||||
text = resolvedHelp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
val endpointOptions = listOf("sessions", "runs")
|
||||
val endpointLabels = listOf("Sessions", "Runs")
|
||||
val endpointOptions = listOf("auto", "sessions", "runs")
|
||||
val endpointLabels = listOf("Auto", "Sessions", "Runs")
|
||||
val selectedEndpointIndex = endpointOptions.indexOf(streamingEndpoint).coerceAtLeast(0)
|
||||
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
|
||||
@@ -125,8 +125,18 @@ class ChatViewModel : ViewModel() {
|
||||
/** Whether to include the brief app context system message */
|
||||
var appContextEnabled: Boolean = true
|
||||
|
||||
/** Streaming endpoint: "sessions" or "runs" */
|
||||
var streamingEndpoint: String = "sessions"
|
||||
/**
|
||||
* Streaming endpoint to use for the next chat turn. Always one of
|
||||
* "sessions" or "runs" — never "auto", since the auto-resolver in
|
||||
* ConnectionViewModel.resolveStreamingEndpoint() collapses "auto" to
|
||||
* a concrete value before this field is written from RelayApp.
|
||||
*
|
||||
* Defaults to "runs" so that a fresh ChatViewModel (before RelayApp
|
||||
* pushes the resolved value) prefers the standard upstream chat path.
|
||||
* That's the safer fallback than the previous "sessions" default,
|
||||
* which would 404 on vanilla upstream installs.
|
||||
*/
|
||||
var streamingEndpoint: String = "runs"
|
||||
|
||||
fun selectPersonality(name: String) {
|
||||
_selectedPersonality.value = name
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.hermesandroid.relay.network.ChatMode
|
||||
import com.hermesandroid.relay.network.ConnectionManager
|
||||
import com.hermesandroid.relay.network.ConnectionState
|
||||
import com.hermesandroid.relay.network.HermesApiClient
|
||||
import com.hermesandroid.relay.network.ServerCapabilities
|
||||
import com.hermesandroid.relay.network.RelayHttpClient
|
||||
import com.hermesandroid.relay.network.handlers.ChatHandler
|
||||
import com.hermesandroid.relay.util.MediaCacheWriter
|
||||
@@ -170,6 +171,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
private val _chatMode = MutableStateFlow(ChatMode.DISCONNECTED)
|
||||
val chatMode: StateFlow<ChatMode> = _chatMode.asStateFlow()
|
||||
|
||||
// Per-endpoint capability snapshot from the most recent probe. Used by
|
||||
// ChatViewModel to resolve `streamingEndpoint = "auto"` to a concrete
|
||||
// sessions/runs choice without round-tripping to the network on every
|
||||
// send. Refreshed inside `rebuildApiClient()`.
|
||||
private val _serverCapabilities = MutableStateFlow(ServerCapabilities.DISCONNECTED)
|
||||
val serverCapabilities: StateFlow<ServerCapabilities> = _serverCapabilities.asStateFlow()
|
||||
|
||||
// Chat is ready when API client exists and server is reachable
|
||||
val chatReady: StateFlow<Boolean> = combine(_apiClient, _apiServerReachable) { client, reachable ->
|
||||
client != null && reachable
|
||||
@@ -296,10 +304,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming endpoint: "sessions" = /api/sessions/{id}/chat/stream, "runs" = /v1/runs
|
||||
// Streaming endpoint preference. Three values:
|
||||
// "auto" — pick based on per-endpoint capability detection (default
|
||||
// for new installs as of v0.3.0). Resolves to "sessions"
|
||||
// when the server has /api/sessions/{id}/chat/stream
|
||||
// (fork or upstream-merged), otherwise "runs".
|
||||
// "sessions" — force /api/sessions/{id}/chat/stream
|
||||
// "runs" — force /v1/runs
|
||||
//
|
||||
// Existing users keep whatever they previously chose. Only fresh installs
|
||||
// (no value persisted yet) get the new "auto" default.
|
||||
val streamingEndpoint: StateFlow<String> = application.relayDataStore.data
|
||||
.map { it[KEY_STREAMING_ENDPOINT] ?: "sessions" }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "sessions")
|
||||
.map { it[KEY_STREAMING_ENDPOINT] ?: "auto" }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "auto")
|
||||
|
||||
fun setStreamingEndpoint(endpoint: String) {
|
||||
viewModelScope.launch {
|
||||
@@ -309,6 +326,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the user's `streamingEndpoint` preference to a concrete value
|
||||
* based on the latest capability probe. Returns "sessions" or "runs"
|
||||
* (never "auto"). Used by ChatViewModel right before kicking off a stream.
|
||||
*
|
||||
* - "sessions" / "runs" pass through unchanged (manual override wins).
|
||||
* - "auto" → reads `serverCapabilities.value.preferredChatEndpoint()`.
|
||||
*/
|
||||
fun resolveStreamingEndpoint(preference: String): String = when (preference) {
|
||||
"sessions", "runs" -> preference
|
||||
else -> _serverCapabilities.value.preferredChatEndpoint()
|
||||
}
|
||||
|
||||
// Parse tool annotations from text markers toggle
|
||||
val parseToolAnnotations: StateFlow<Boolean> = application.relayDataStore.data
|
||||
.map { it[KEY_PARSE_TOOL_ANNOTATIONS] ?: false }
|
||||
@@ -524,14 +554,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
oldClient?.shutdown()
|
||||
_apiServerReachable.value = client.checkHealth()
|
||||
|
||||
// Detect chat mode
|
||||
val mode = client.detectChatMode()
|
||||
_chatMode.value = mode
|
||||
// Probe per-endpoint capabilities. The result drives both the
|
||||
// legacy chatMode flow and the auto-resolver in ChatViewModel.
|
||||
val caps = client.probeCapabilities()
|
||||
_serverCapabilities.value = caps
|
||||
_chatMode.value = caps.toChatMode()
|
||||
} else {
|
||||
_apiClient.value = null
|
||||
oldClient?.shutdown()
|
||||
_apiServerReachable.value = false
|
||||
_chatMode.value = ChatMode.DISCONNECTED
|
||||
_serverCapabilities.value = ServerCapabilities.DISCONNECTED
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+49
-1
@@ -80,7 +80,13 @@ The app supports two streaming endpoints, selectable in Settings:
|
||||
| **Sessions** (`/api/sessions/{id}/chat/stream`) | Inline text annotations (`` `💻 terminal` ``) — client parses from markdown | Hermes-native SSE (assistant.delta, tool.progress, etc.) or OpenAI-format (delta.content) |
|
||||
| **Runs** (`/v1/runs` + `/v1/runs/{run_id}/events`) | **Structured events** (tool.started, tool.completed) — real-time tool cards | Hermes lifecycle events (message.delta, tool.started, tool.completed, run.completed) |
|
||||
|
||||
**Important upstream note:** The `/api/sessions` CRUD endpoints may not exist in all hermes-agent versions. The upstream codebase registers `/v1/chat/completions`, `/v1/responses`, and `/v1/runs` as the standard endpoints. Session management via `/api/sessions` may be version-specific (confirmed working in v0.7.0). The app's `detectChatMode()` probes the server and falls back gracefully.
|
||||
**Important upstream note:** The `/api/sessions` CRUD endpoints are not in vanilla upstream hermes-agent. They are provided by one of three mechanisms:
|
||||
|
||||
1. The Codename-11 fork (`feat/api-server-enhancements` branch, deployed on the `axiom` deploy branch). Submitted upstream as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556).
|
||||
2. **Bootstrap injection** — `hermes_relay_bootstrap/` ships with the plugin and runs at Python interpreter startup (via a `.pth` file in the venv site-packages). It monkey-patches `aiohttp.web.Application` to add the management endpoints to upstream's `APIServerAdapter` at the moment it builds its app. See ADR 8 below.
|
||||
3. Once PR #8556 merges, upstream-merged. The bootstrap detects this and no-ops.
|
||||
|
||||
The app's `probeCapabilities()` returns a per-endpoint snapshot, and `ConnectionViewModel.resolveStreamingEndpoint()` collapses `streamingEndpoint = "auto"` (the default for new installs) to a concrete `"sessions"` or `"runs"` choice based on what the server actually exposes.
|
||||
|
||||
**Tool call transparency:** In `/v1/chat/completions` streaming, tool calls are NOT emitted as separate SSE events. They are injected as inline markdown text (e.g., `` `💻 pwd` ``). The app's annotation parser (`ChatHandler.parseAnnotationLine`) detects these and renders them as tool progress cards. The `/v1/runs` endpoint is the only path that provides structured `tool.started`/`tool.completed` events.
|
||||
|
||||
@@ -476,6 +482,48 @@ Adopting from ARC's workflow patterns:
|
||||
3. **Concurrency groups:** Cancel in-progress CI on new push to same branch
|
||||
4. **Dependabot:** Auto-merge minor/patch dependency updates
|
||||
|
||||
### 16. Runtime API Server Patch via .pth Bootstrap (2026-04-12)
|
||||
|
||||
**Context:** The Codename-11 fork of hermes-agent (`feat/api-server-enhancements` branch) adds ~14 management endpoints — `/api/sessions/*` CRUD, `/api/memory`, `/api/skills`, `/api/config`, `/api/available-models` — that the Android app depends on for its sessions browser, personality picker, command palette, and history-on-restart. These are submitted upstream as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) but not yet merged. Until then, users running vanilla upstream hermes-agent + our plugin would lose these features and see a blank chat window when reopening the app to a previous session.
|
||||
|
||||
We considered four options:
|
||||
- **A. Stay fork-only.** Reject vanilla upstream users until PR #8556 lands. Penalises onboarding.
|
||||
- **B. Read-only sessions browser via plugin relay.** Add `GET /api/sessions/*` to the relay at port 8767. Forces the client to know which URL each operation goes to.
|
||||
- **C. Full parity by porting all 800 lines onto the plugin relay.** Same architectural pollution as B, plus duplicates ~250 lines of chat-stream handler with cross-cutting `_create_agent` / `run_conversation` dependencies that the fork may have implicitly modified.
|
||||
- **D. Runtime injection via Python interpreter startup hook.** Ship a `.pth` file in the venv site-packages that imports a bootstrap module, which installs a `sys.meta_path` finder for `aiohttp.web`. When the gateway eventually imports `aiohttp.web`, our finder wraps the loader and replaces `web.Application` with a thin subclass. The subclass overrides `__setitem__` to detect `app["api_server_adapter"] = self` (the line in upstream's `connect()` that gives us a reference to the adapter while the router is still mutable). At that point we feature-detect by route path and bind our extra handlers directly onto the same router the gateway is in the middle of populating.
|
||||
|
||||
**Decision: D, scoped to management endpoints only.** The chat-stream handler is intentionally NOT injected — chat goes through standard upstream `/v1/runs`, which already emits structured `tool.started`/`tool.completed` events. This avoids touching `_create_agent` / `run_conversation` (the fork's riskiest cross-cutting dependencies) and is arguably an upgrade — `/v1/runs` has live tool events whereas the sessions chat-stream path required a post-stream message-history reload to render tool cards.
|
||||
|
||||
**Why this is the right answer despite being a clever hack:**
|
||||
|
||||
1. **Zero modifications to hermes-agent's filesystem.** `git pull` / `hermes update` see no local changes, so they always work cleanly. The patch lives entirely in `hermes_relay_bootstrap/` inside our own repo.
|
||||
2. **Single-file containment of all ported logic.** `_handlers.py` is 500 lines of straight-line aiohttp handler code with explicit `adapter` parameters (closures, not bound methods). Easy to audit, easy to delete.
|
||||
3. **Feature detection by route path, not method name.** The bootstrap checks if `/api/sessions` is already in the router and no-ops if so. This means fork users, bootstrap-injected vanilla-upstream users, AND post-PR-#8556 upstream-merged users all run safely with the bootstrap installed. Three of the four valid combinations require zero changes; only the bootstrap-injected one actively patches.
|
||||
4. **Trust model already established.** The user installed our plugin into their hermes-agent venv. They've already consented to having the plugin import hermes-agent internals (it does this for relay tools, voice endpoints, media registry). Monkey-patching `aiohttp.web.Application` is in the same trust bucket.
|
||||
5. **Trivial removal.** When PR #8556 reaches a released hermes-agent version: delete `hermes_relay_bootstrap/`, delete the `.pth` from `install.sh` step 2, bump plugin version. The Android client's capability detection still works because it probes routes by path, not by source.
|
||||
6. **`/v1/runs` is genuinely better for chat than `/api/sessions/{id}/chat/stream`.** It's standard upstream, supports `X-Hermes-Session-Id` for continuation, and emits live structured tool events. The fork's chat handler exists because upstream didn't HAVE this clean structured-event runs API at the time the fork was cut — but upstream does now.
|
||||
|
||||
**The Android client adapts via `streamingEndpoint = "auto"`.** New `ServerCapabilities` data class returned by `HermesApiClient.probeCapabilities()` captures per-endpoint presence (`sessionsApi`, `sessionsChatStream`, `runs`, `portable`, `healthy`). `ConnectionViewModel.resolveStreamingEndpoint()` collapses `"auto"` to `"sessions"` (when chat-stream handler is present, i.e. fork or upstream-merged) or `"runs"` (otherwise, i.e. bootstrap-injected vanilla upstream). The setting still supports manual `"sessions"` / `"runs"` overrides for debugging.
|
||||
|
||||
**Risks accepted:**
|
||||
- **Plugin load order** — verified: `.pth` files are processed by Python's `site` module BEFORE any application code runs, so our import hook is in place before hermes-agent imports `aiohttp.web`.
|
||||
- **Upstream refactor of the route-registration block in `connect()`** — handled by feature detection on route path. Worst case: bootstrap logs a warning and gateway runs without injected routes. The Android client falls back to `/v1/runs` automatically.
|
||||
- **Editable pip install doesn't ship `.pth` files reliably** — verified empirically (test in `/tmp/pth-test` on the server during scoping). Solved by `install.sh` copying the `.pth` directly into the venv's `site-packages/` after `pip install -e`.
|
||||
|
||||
**File locations:**
|
||||
- `hermes_relay_bootstrap/__init__.py` — installs the meta_path finder (~30 lines)
|
||||
- `hermes_relay_bootstrap/_patch.py` — `_AioHttpWebFinder`, `_PatchingLoader`, `_PatchedApplication`, `_maybe_register_routes` (~170 lines)
|
||||
- `hermes_relay_bootstrap/_handlers.py` — 14 ported handlers + helpers (~500 lines)
|
||||
- `hermes_relay_bootstrap.pth` — single line: `import hermes_relay_bootstrap`
|
||||
- `install.sh` step 2 — copies the `.pth` into the venv site-packages
|
||||
|
||||
**Removal path** (when PR #8556 lands and reaches a released hermes-agent):
|
||||
1. Delete `hermes_relay_bootstrap/` directory
|
||||
2. Delete `hermes_relay_bootstrap.pth` from repo root
|
||||
3. Remove the `.pth` drop block from `install.sh` step 2
|
||||
4. Update CLAUDE.md to drop the bootstrap reference
|
||||
5. The Android client `probeCapabilities()` and `streamingEndpoint = "auto"` plumbing stays — it's permanent infrastructure that handles mixed-version deployments.
|
||||
|
||||
---
|
||||
|
||||
## Voice Mode — Architecture
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import hermes_relay_bootstrap
|
||||
@@ -0,0 +1,36 @@
|
||||
"""hermes_relay_bootstrap — runtime patch for vanilla upstream hermes-agent.
|
||||
|
||||
This package is loaded at Python interpreter startup via a `.pth` file in the
|
||||
hermes-agent venv's site-packages. It installs a `sys.meta_path` import hook
|
||||
that waits for `aiohttp.web` to be imported, then replaces `web.Application`
|
||||
with a thin subclass that detects when hermes-agent's `APIServerAdapter`
|
||||
attaches itself to a fresh app and injects extra `/api/sessions/*`,
|
||||
`/api/memory`, `/api/skills`, `/api/config`, and `/api/available-models`
|
||||
routes.
|
||||
|
||||
The point: a vanilla upstream hermes-agent install with no custom server-side
|
||||
patches still serves the management endpoints the Hermes-Relay Android app
|
||||
expects, as long as the hermes-relay plugin is installed in the same venv.
|
||||
Chat streaming continues to use upstream's standard `/v1/runs` endpoint, which
|
||||
already emits structured tool events — that's why we don't need to inject any
|
||||
chat handlers.
|
||||
|
||||
This module is removed in its entirety once upstream PR
|
||||
https://github.com/NousResearch/hermes-agent/pull/8556 lands and reaches a
|
||||
released hermes-agent version. The bootstrap feature-detects on route paths
|
||||
and silently no-ops when the upstream-merged or fork-built endpoints are
|
||||
already present, so it stays harmless during the rollout window.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import and install the meta_path finder. Kept in a sub-module so this file
|
||||
# stays tiny and the actual patch logic is easy to audit / disable.
|
||||
from . import _patch # noqa: E402
|
||||
|
||||
_patch.install_finder()
|
||||
@@ -0,0 +1,656 @@
|
||||
"""Ported handlers from `feat/api-server-enhancements` (Codename-11/hermes-agent).
|
||||
|
||||
This file mirrors the management endpoints from the fork branch, adapted to
|
||||
take the `APIServerAdapter` instance as an explicit parameter rather than
|
||||
relying on `self`. That keeps the patch loosely coupled to upstream's class
|
||||
shape — we don't bind methods onto the adapter, just register closures that
|
||||
capture an `adapter` reference.
|
||||
|
||||
Endpoints injected (all bearer-auth gated via `adapter._check_auth`):
|
||||
|
||||
GET /api/sessions — list sessions
|
||||
POST /api/sessions — create a new session
|
||||
GET /api/sessions/search?q=... — full-text message search
|
||||
GET /api/sessions/{session_id} — fetch one session
|
||||
GET /api/sessions/{session_id}/messages — fetch session messages
|
||||
PATCH /api/sessions/{session_id} — rename / update metadata
|
||||
DELETE /api/sessions/{session_id} — delete a session
|
||||
POST /api/sessions/{session_id}/fork — clone a session
|
||||
|
||||
GET /api/memory — read memory state
|
||||
POST /api/memory — append memory entry
|
||||
PATCH /api/memory — replace memory entry
|
||||
DELETE /api/memory — remove memory entry
|
||||
|
||||
GET /api/skills — list skills (optional ?category=)
|
||||
GET /api/skills/categories — list skill categories
|
||||
GET /api/skills/{name} — fetch skill body
|
||||
|
||||
GET /api/config — read model + config
|
||||
PATCH /api/config — update model/provider/base_url
|
||||
|
||||
GET /api/available-models — provider model list
|
||||
|
||||
NOT injected — chat streaming intentionally goes through upstream's standard
|
||||
`/v1/runs` endpoint, which already emits `tool.started`/`tool.completed` SSE
|
||||
events. Avoiding the chat handlers also means we don't have to coordinate
|
||||
with `_create_agent` / `agent.run_conversation`, which the fork modified in
|
||||
ways we'd otherwise have to mirror exactly.
|
||||
|
||||
Removal note: when upstream PR #8556 merges and a released hermes-agent
|
||||
version contains these endpoints, this entire file becomes dead weight. The
|
||||
bootstrap's feature detection no-ops on the existing routes, so leaving it in
|
||||
place is harmless during the rollout window. Cleanup is a clean delete of
|
||||
the `hermes_relay_bootstrap/` package and its `.pth` file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy upstream imports
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# These get pulled in only when `register_routes()` runs (i.e. only when the
|
||||
# gateway is actually starting up an APIServerAdapter against vanilla
|
||||
# upstream). The bootstrap's `__init__.py` deliberately avoids importing
|
||||
# anything from hermes-agent so it stays cheap for unrelated Python processes
|
||||
# in the same venv.
|
||||
|
||||
def _resolve_upstream():
|
||||
"""Pull in upstream symbols. Returns a dict or raises if anything is missing."""
|
||||
from aiohttp import web
|
||||
|
||||
from hermes_state import SessionDB
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_cli.models import (
|
||||
curated_models_for_provider,
|
||||
list_available_providers,
|
||||
)
|
||||
from tools.skills_tool import skill_view, skills_categories, skills_list
|
||||
|
||||
# MemoryStore lives at tools/memory_tool.py upstream. We import it lazily
|
||||
# because it pulls in a chain of optional deps that we don't want to crash
|
||||
# the bootstrap over if memory tooling is misconfigured.
|
||||
try:
|
||||
from tools.memory_tool import MemoryStore
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("hermes_relay_bootstrap: MemoryStore unavailable: %s", exc)
|
||||
MemoryStore = None # type: ignore[assignment]
|
||||
|
||||
return {
|
||||
"web": web,
|
||||
"SessionDB": SessionDB,
|
||||
"MemoryStore": MemoryStore,
|
||||
"load_config": load_config,
|
||||
"save_config": save_config,
|
||||
"curated_models_for_provider": curated_models_for_provider,
|
||||
"list_available_providers": list_available_providers,
|
||||
"skills_list": skills_list,
|
||||
"skills_categories": skills_categories,
|
||||
"skill_view": skill_view,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-adapter state cache
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# We can't add attributes directly to upstream's `APIServerAdapter` instance
|
||||
# without risking name collisions on future refactors. Instead we keep a
|
||||
# small WeakKeyDictionary keyed on the adapter, holding our SessionDB and
|
||||
# MemoryStore references. The lifetime of the cache entries matches the
|
||||
# adapter's lifetime — when the adapter is garbage-collected, the cache
|
||||
# entries follow.
|
||||
|
||||
import weakref
|
||||
|
||||
_adapter_state: "weakref.WeakKeyDictionary[Any, Dict[str, Any]]" = weakref.WeakKeyDictionary()
|
||||
|
||||
|
||||
def _state_for(adapter) -> Dict[str, Any]:
|
||||
state = _adapter_state.get(adapter)
|
||||
if state is None:
|
||||
state = {}
|
||||
_adapter_state[adapter] = state
|
||||
return state
|
||||
|
||||
|
||||
def _get_session_db(adapter, upstream):
|
||||
state = _state_for(adapter)
|
||||
db = state.get("session_db")
|
||||
if db is None:
|
||||
db = upstream["SessionDB"]()
|
||||
state["session_db"] = db
|
||||
return db
|
||||
|
||||
|
||||
def _get_memory_store(adapter, upstream):
|
||||
if upstream["MemoryStore"] is None:
|
||||
return None
|
||||
state = _state_for(adapter)
|
||||
store = state.get("memory_store")
|
||||
if store is None:
|
||||
store = upstream["MemoryStore"]()
|
||||
store.load_from_disk()
|
||||
state["memory_store"] = store
|
||||
return store
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure helpers (no adapter coupling)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalize_session_record(session: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""Parse serialized session fields into API-friendly JSON."""
|
||||
if session is None:
|
||||
return None
|
||||
normalized = dict(session)
|
||||
model_config = normalized.get("model_config")
|
||||
if model_config:
|
||||
try:
|
||||
normalized["model_config"] = json.loads(model_config)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
pass
|
||||
return normalized
|
||||
|
||||
|
||||
def _current_model_settings(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract model/provider/base_url/api_mode from config.yaml."""
|
||||
model_cfg = config.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
return {
|
||||
"model": str(model_cfg.get("default") or model_cfg.get("model") or "").strip(),
|
||||
"provider": str(model_cfg.get("provider") or "").strip(),
|
||||
"api_mode": str(model_cfg.get("api_mode") or "").strip(),
|
||||
"base_url": str(model_cfg.get("base_url") or "").strip(),
|
||||
}
|
||||
if isinstance(model_cfg, str):
|
||||
return {
|
||||
"model": model_cfg.strip(),
|
||||
"provider": "",
|
||||
"api_mode": "",
|
||||
"base_url": "",
|
||||
}
|
||||
return {"model": "", "provider": "", "api_mode": "", "base_url": ""}
|
||||
|
||||
|
||||
def _parse_int(value: Any, default: int, minimum: int = 0) -> int:
|
||||
"""Parse an integer query parameter with bounds."""
|
||||
if value in (None, ""):
|
||||
return default
|
||||
parsed = int(value)
|
||||
if parsed < minimum:
|
||||
raise ValueError(f"Value must be >= {minimum}")
|
||||
return parsed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sessions handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_sessions_handlers(adapter, upstream):
|
||||
web = upstream["web"]
|
||||
|
||||
async def list_sessions(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
try:
|
||||
limit = _parse_int(request.query.get("limit"), 50)
|
||||
offset = _parse_int(request.query.get("offset"), 0)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
|
||||
source = (request.query.get("source") or "").strip() or None
|
||||
db = _get_session_db(adapter, upstream)
|
||||
items = [
|
||||
_normalize_session_record(item)
|
||||
for item in db.list_sessions_rich(source=source, limit=limit, offset=offset)
|
||||
]
|
||||
total = db.session_count(source=source)
|
||||
return web.json_response({"items": items, "total": total})
|
||||
|
||||
async def create_session(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response({"error": "Invalid JSON in request body"}, status=400)
|
||||
|
||||
title = body.get("title")
|
||||
source = str(body.get("source") or "api_server").strip() or "api_server"
|
||||
model = body.get("model")
|
||||
system_prompt = body.get("system_prompt")
|
||||
session_id = f"sess_{uuid.uuid4().hex}"
|
||||
db = _get_session_db(adapter, upstream)
|
||||
|
||||
try:
|
||||
db.create_session(
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
model=model,
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
if title is not None:
|
||||
db.set_session_title(session_id, str(title))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
session = _normalize_session_record(db.get_session(session_id))
|
||||
return web.json_response({"session": session})
|
||||
|
||||
async def search_sessions(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
query = (request.query.get("q") or "").strip()
|
||||
if not query:
|
||||
return web.json_response({"error": "Missing query parameter: q"}, status=400)
|
||||
try:
|
||||
limit = _parse_int(request.query.get("limit"), 20)
|
||||
offset = _parse_int(request.query.get("offset"), 0)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
|
||||
db = _get_session_db(adapter, upstream)
|
||||
results = db.search_messages(query=query, limit=limit, offset=offset)
|
||||
return web.json_response({"query": query, "count": len(results), "results": results})
|
||||
|
||||
async def get_session(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
session_id = request.match_info["session_id"]
|
||||
db = _get_session_db(adapter, upstream)
|
||||
session = _normalize_session_record(db.get_session(session_id))
|
||||
if session is None:
|
||||
return web.json_response({"error": "Session not found"}, status=404)
|
||||
return web.json_response({"session": session})
|
||||
|
||||
async def get_session_messages(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
session_id = request.match_info["session_id"]
|
||||
db = _get_session_db(adapter, upstream)
|
||||
if db.get_session(session_id) is None:
|
||||
db.ensure_session(session_id, source="web")
|
||||
items = db.get_messages(session_id)
|
||||
return web.json_response({"items": items, "total": len(items)})
|
||||
|
||||
async def update_session(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
session_id = request.match_info["session_id"]
|
||||
db = _get_session_db(adapter, upstream)
|
||||
if db.get_session(session_id) is None:
|
||||
return web.json_response({"error": "Session not found"}, status=404)
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response({"error": "Invalid JSON in request body"}, status=400)
|
||||
|
||||
try:
|
||||
if "title" in body:
|
||||
db.set_session_title(session_id, body.get("title"))
|
||||
if "system_prompt" in body:
|
||||
db.update_system_prompt(session_id, body.get("system_prompt"))
|
||||
if "end_reason" in body:
|
||||
db.end_session(session_id, str(body.get("end_reason") or "updated"))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
session = _normalize_session_record(db.get_session(session_id))
|
||||
return web.json_response({"session": session})
|
||||
|
||||
async def delete_session(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
session_id = request.match_info["session_id"]
|
||||
db = _get_session_db(adapter, upstream)
|
||||
deleted = db.delete_session(session_id)
|
||||
if not deleted:
|
||||
return web.json_response({"error": "Session not found"}, status=404)
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
async def fork_session(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
session_id = request.match_info["session_id"]
|
||||
db = _get_session_db(adapter, upstream)
|
||||
original = db.get_session(session_id)
|
||||
if original is None:
|
||||
return web.json_response({"error": "Session not found"}, status=404)
|
||||
|
||||
forked_id = f"sess_{uuid.uuid4().hex}"
|
||||
try:
|
||||
db.create_session(
|
||||
session_id=forked_id,
|
||||
source=original.get("source") or "api_server",
|
||||
model=original.get("model"),
|
||||
system_prompt=original.get("system_prompt"),
|
||||
user_id=original.get("user_id"),
|
||||
parent_session_id=session_id,
|
||||
)
|
||||
for message in db.get_messages(session_id):
|
||||
db.append_message(
|
||||
session_id=forked_id,
|
||||
role=message.get("role"),
|
||||
content=message.get("content"),
|
||||
tool_name=message.get("tool_name"),
|
||||
tool_calls=message.get("tool_calls"),
|
||||
tool_call_id=message.get("tool_call_id"),
|
||||
token_count=message.get("token_count"),
|
||||
finish_reason=message.get("finish_reason"),
|
||||
reasoning=message.get("reasoning"),
|
||||
)
|
||||
except Exception as exc:
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
session = _normalize_session_record(db.get_session(forked_id))
|
||||
return web.json_response({"session": session, "forked_from": session_id})
|
||||
|
||||
return {
|
||||
"list_sessions": list_sessions,
|
||||
"create_session": create_session,
|
||||
"search_sessions": search_sessions,
|
||||
"get_session": get_session,
|
||||
"get_session_messages": get_session_messages,
|
||||
"update_session": update_session,
|
||||
"delete_session": delete_session,
|
||||
"fork_session": fork_session,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Memory handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_memory_handlers(adapter, upstream):
|
||||
web = upstream["web"]
|
||||
|
||||
def _memory_unavailable_response():
|
||||
return web.json_response(
|
||||
{"error": "MemoryStore unavailable in this hermes-agent install"},
|
||||
status=503,
|
||||
)
|
||||
|
||||
async def get_memory(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
target = (request.query.get("target") or "all").strip().lower()
|
||||
if target not in {"all", "memory", "user"}:
|
||||
return web.json_response(
|
||||
{"error": "target must be one of: all, memory, user"},
|
||||
status=400,
|
||||
)
|
||||
store = _get_memory_store(adapter, upstream)
|
||||
if store is None:
|
||||
return _memory_unavailable_response()
|
||||
store.load_from_disk()
|
||||
targets = []
|
||||
if target in {"all", "memory"}:
|
||||
targets.append({
|
||||
"target": "memory",
|
||||
"entries": store.memory_entries,
|
||||
"entry_count": len(store.memory_entries),
|
||||
})
|
||||
if target in {"all", "user"}:
|
||||
targets.append({
|
||||
"target": "user",
|
||||
"entries": store.user_entries,
|
||||
"entry_count": len(store.user_entries),
|
||||
})
|
||||
return web.json_response({"targets": targets})
|
||||
|
||||
async def add_memory(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response({"error": "Invalid JSON in request body"}, status=400)
|
||||
|
||||
target = str(body.get("target") or "").strip().lower()
|
||||
content = str(body.get("content") or "")
|
||||
if target not in {"memory", "user"}:
|
||||
return web.json_response({"error": "target must be 'memory' or 'user'"}, status=400)
|
||||
store = _get_memory_store(adapter, upstream)
|
||||
if store is None:
|
||||
return _memory_unavailable_response()
|
||||
result = store.add(target, content)
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response(result, status=status)
|
||||
|
||||
async def replace_memory(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response({"error": "Invalid JSON in request body"}, status=400)
|
||||
|
||||
target = str(body.get("target") or "").strip().lower()
|
||||
old_text = str(body.get("old_text") or "")
|
||||
content = str(body.get("content") or "")
|
||||
if target not in {"memory", "user"}:
|
||||
return web.json_response({"error": "target must be 'memory' or 'user'"}, status=400)
|
||||
store = _get_memory_store(adapter, upstream)
|
||||
if store is None:
|
||||
return _memory_unavailable_response()
|
||||
result = store.replace(target, old_text, content)
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response(result, status=status)
|
||||
|
||||
async def delete_memory(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response({"error": "Invalid JSON in request body"}, status=400)
|
||||
|
||||
target = str(body.get("target") or "").strip().lower()
|
||||
old_text = str(body.get("old_text") or "")
|
||||
if target not in {"memory", "user"}:
|
||||
return web.json_response({"error": "target must be 'memory' or 'user'"}, status=400)
|
||||
store = _get_memory_store(adapter, upstream)
|
||||
if store is None:
|
||||
return _memory_unavailable_response()
|
||||
result = store.remove(target, old_text)
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response(result, status=status)
|
||||
|
||||
return {
|
||||
"get_memory": get_memory,
|
||||
"add_memory": add_memory,
|
||||
"replace_memory": replace_memory,
|
||||
"delete_memory": delete_memory,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skills handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_skills_handlers(adapter, upstream):
|
||||
web = upstream["web"]
|
||||
skills_list = upstream["skills_list"]
|
||||
skills_categories = upstream["skills_categories"]
|
||||
skill_view = upstream["skill_view"]
|
||||
|
||||
async def list_skills(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
category = (request.query.get("category") or "").strip() or None
|
||||
return web.json_response(json.loads(skills_list(category=category)))
|
||||
|
||||
async def skill_categories(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
return web.json_response(json.loads(skills_categories()))
|
||||
|
||||
async def view_skill(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
name = request.match_info["name"]
|
||||
file_path = (request.query.get("file_path") or "").strip() or None
|
||||
return web.json_response(json.loads(skill_view(name, file_path=file_path)))
|
||||
|
||||
return {
|
||||
"list_skills": list_skills,
|
||||
"skill_categories": skill_categories,
|
||||
"view_skill": view_skill,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config + available-models handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_config_handlers(adapter, upstream):
|
||||
web = upstream["web"]
|
||||
load_config = upstream["load_config"]
|
||||
save_config = upstream["save_config"]
|
||||
curated_models_for_provider = upstream["curated_models_for_provider"]
|
||||
list_available_providers = upstream["list_available_providers"]
|
||||
|
||||
async def get_config(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
config = load_config()
|
||||
current = _current_model_settings(config)
|
||||
return web.json_response({
|
||||
"model": current["model"],
|
||||
"provider": current["provider"],
|
||||
"api_mode": current["api_mode"],
|
||||
"base_url": current["base_url"],
|
||||
"config": config,
|
||||
})
|
||||
|
||||
async def update_config(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response({"error": "Invalid JSON in request body"}, status=400)
|
||||
|
||||
config = load_config()
|
||||
model_cfg = config.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
updated_model_cfg = dict(model_cfg)
|
||||
elif isinstance(model_cfg, str) and model_cfg.strip():
|
||||
updated_model_cfg = {"default": model_cfg.strip()}
|
||||
else:
|
||||
updated_model_cfg = {}
|
||||
|
||||
if "model" in body:
|
||||
updated_model_cfg["default"] = str(body.get("model") or "").strip()
|
||||
if "provider" in body:
|
||||
updated_model_cfg["provider"] = str(body.get("provider") or "").strip()
|
||||
if "base_url" in body:
|
||||
updated_model_cfg["base_url"] = str(body.get("base_url") or "").strip()
|
||||
|
||||
config["model"] = updated_model_cfg
|
||||
try:
|
||||
save_config(config)
|
||||
except Exception as exc:
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
current = _current_model_settings(config)
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"model": current["model"],
|
||||
"provider": current["provider"],
|
||||
"base_url": current["base_url"],
|
||||
})
|
||||
|
||||
async def available_models(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
config = load_config()
|
||||
current = _current_model_settings(config)
|
||||
provider = (request.query.get("provider") or current["provider"] or "openrouter").strip()
|
||||
models = [
|
||||
{"id": model_id, "description": description}
|
||||
for model_id, description in curated_models_for_provider(provider)
|
||||
]
|
||||
providers = list_available_providers()
|
||||
return web.json_response({"provider": provider, "models": models, "providers": providers})
|
||||
|
||||
return {
|
||||
"get_config": get_config,
|
||||
"update_config": update_config,
|
||||
"available_models": available_models,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register_routes(app, adapter) -> None:
|
||||
"""Bind every injected route to the live aiohttp router.
|
||||
|
||||
Called from `_patch._maybe_register_routes()` after feature detection
|
||||
determines we're on a vanilla upstream server and the adapter has just
|
||||
finished its own setup. Routes are added directly to `app.router`, which
|
||||
aiohttp keeps mutable until `AppRunner.setup()` freezes it shortly after
|
||||
`connect()` returns.
|
||||
"""
|
||||
upstream = _resolve_upstream()
|
||||
|
||||
sessions = _make_sessions_handlers(adapter, upstream)
|
||||
memory = _make_memory_handlers(adapter, upstream)
|
||||
skills = _make_skills_handlers(adapter, upstream)
|
||||
config = _make_config_handlers(adapter, upstream)
|
||||
|
||||
app.router.add_get("/api/sessions", sessions["list_sessions"])
|
||||
app.router.add_post("/api/sessions", sessions["create_session"])
|
||||
app.router.add_get("/api/sessions/search", sessions["search_sessions"])
|
||||
app.router.add_get("/api/sessions/{session_id}", sessions["get_session"])
|
||||
app.router.add_get("/api/sessions/{session_id}/messages", sessions["get_session_messages"])
|
||||
app.router.add_patch("/api/sessions/{session_id}", sessions["update_session"])
|
||||
app.router.add_delete("/api/sessions/{session_id}", sessions["delete_session"])
|
||||
app.router.add_post("/api/sessions/{session_id}/fork", sessions["fork_session"])
|
||||
|
||||
app.router.add_get("/api/memory", memory["get_memory"])
|
||||
app.router.add_post("/api/memory", memory["add_memory"])
|
||||
app.router.add_patch("/api/memory", memory["replace_memory"])
|
||||
app.router.add_delete("/api/memory", memory["delete_memory"])
|
||||
|
||||
app.router.add_get("/api/skills", skills["list_skills"])
|
||||
app.router.add_get("/api/skills/categories", skills["skill_categories"])
|
||||
app.router.add_get("/api/skills/{name}", skills["view_skill"])
|
||||
|
||||
app.router.add_get("/api/config", config["get_config"])
|
||||
app.router.add_patch("/api/config", config["update_config"])
|
||||
app.router.add_get("/api/available-models", config["available_models"])
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Import-hook plumbing + Application monkey-patch.
|
||||
|
||||
When `aiohttp.web` is imported anywhere in the running interpreter, our
|
||||
`_AioHttpWebFinder` intercepts the import, lets the real loader finish,
|
||||
then swaps `web.Application` for a `_PatchedApplication` subclass.
|
||||
|
||||
The subclass overrides `__setitem__` so we can detect the moment hermes-agent's
|
||||
`APIServerAdapter` does `self._app["api_server_adapter"] = self` — that's the
|
||||
single line in the upstream `connect()` method that gives us a reference to
|
||||
the adapter while the app is still being built (router not yet frozen).
|
||||
|
||||
At that point, we feature-detect by route path and call `_register_routes()`
|
||||
to bind our handlers to the same router the gateway is in the middle of
|
||||
populating. The gateway then continues with its own route registrations and
|
||||
starts the server normally.
|
||||
|
||||
Failure modes are silent-but-logged: if anything in this chain breaks
|
||||
(unexpected upstream refactor, aiohttp version incompatibility, missing
|
||||
SessionDB methods, etc.), we log a warning and let the gateway start without
|
||||
the injected routes. The Android client's capability detection will fall back
|
||||
to the standard upstream endpoints automatically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Routes we inject. The keys MUST match the path strings registered by
|
||||
# `_register_routes()` in `_handlers.py`. The values are unused — only the
|
||||
# set of paths matters for feature detection.
|
||||
_INJECTED_PATHS: frozenset[str] = frozenset({
|
||||
"/api/sessions",
|
||||
"/api/sessions/search",
|
||||
"/api/sessions/{session_id}",
|
||||
"/api/sessions/{session_id}/messages",
|
||||
"/api/sessions/{session_id}/fork",
|
||||
"/api/memory",
|
||||
"/api/skills",
|
||||
"/api/skills/categories",
|
||||
"/api/skills/{name}",
|
||||
"/api/config",
|
||||
"/api/available-models",
|
||||
})
|
||||
|
||||
|
||||
class _AioHttpWebFinder:
|
||||
"""sys.meta_path finder that wraps the loader for `aiohttp.web`.
|
||||
|
||||
Removes itself from `sys.meta_path` after firing once, so subsequent
|
||||
imports of unrelated modules don't pay the find-spec cost.
|
||||
"""
|
||||
|
||||
def find_spec(self, fullname, path, target=None):
|
||||
if fullname != "aiohttp.web":
|
||||
return None
|
||||
|
||||
# Remove ourselves so this only runs on the first import.
|
||||
try:
|
||||
sys.meta_path.remove(self)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Resolve the real spec via the remaining finders. We can't recurse
|
||||
# back into our own find_spec because we just removed ourselves.
|
||||
for finder in sys.meta_path:
|
||||
if not hasattr(finder, "find_spec"):
|
||||
continue
|
||||
spec = finder.find_spec(fullname, path, target)
|
||||
if spec is None:
|
||||
continue
|
||||
|
||||
original_loader = spec.loader
|
||||
spec.loader = _PatchingLoader(original_loader)
|
||||
return spec
|
||||
|
||||
# No finder could resolve aiohttp.web — fall through, normal import
|
||||
# error will surface.
|
||||
return None
|
||||
|
||||
|
||||
class _PatchingLoader:
|
||||
"""Loader wrapper that runs `_apply_patch()` after the module is exec'd."""
|
||||
|
||||
def __init__(self, wrapped):
|
||||
self._wrapped = wrapped
|
||||
|
||||
def create_module(self, spec):
|
||||
if hasattr(self._wrapped, "create_module"):
|
||||
return self._wrapped.create_module(spec)
|
||||
return None
|
||||
|
||||
def exec_module(self, module):
|
||||
self._wrapped.exec_module(module)
|
||||
try:
|
||||
_apply_patch(module)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: failed to install Application patch: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def install_finder() -> None:
|
||||
"""Insert the import-hook finder at the front of `sys.meta_path`."""
|
||||
finder = _AioHttpWebFinder()
|
||||
sys.meta_path.insert(0, finder)
|
||||
|
||||
|
||||
def _apply_patch(web_module) -> None:
|
||||
"""Replace `web_module.Application` with `_PatchedApplication`.
|
||||
|
||||
No-op if `web_module` lacks the expected `Application` attribute (e.g.
|
||||
aiohttp version skew or stripped-down build).
|
||||
"""
|
||||
original_application = getattr(web_module, "Application", None)
|
||||
if original_application is None:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: aiohttp.web has no Application attribute "
|
||||
"— version mismatch? skipping injection."
|
||||
)
|
||||
return
|
||||
|
||||
if getattr(original_application, "_hermes_relay_patched", False):
|
||||
return # Idempotent — already patched in a previous import.
|
||||
|
||||
class _PatchedApplication(original_application):
|
||||
"""Subclass of aiohttp.web.Application with adapter-detection hook.
|
||||
|
||||
Hermes-agent's `APIServerAdapter.connect()` does
|
||||
`self._app["api_server_adapter"] = self` immediately after building
|
||||
the application. We catch that and use the adapter reference to
|
||||
register our extra routes on `self.router` while it's still mutable.
|
||||
"""
|
||||
|
||||
_hermes_relay_patched = True
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
super().__setitem__(key, value)
|
||||
if key != "api_server_adapter":
|
||||
return
|
||||
try:
|
||||
_maybe_register_routes(self, value)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: route injection failed: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
web_module.Application = _PatchedApplication
|
||||
|
||||
|
||||
def _maybe_register_routes(app, adapter) -> None:
|
||||
"""Feature-detect on route paths and register if absent.
|
||||
|
||||
The fork (`feat/api-server-enhancements`) and the upstream-merged version
|
||||
(post-PR-#8556) both register the same paths. If any of our injected
|
||||
paths are already on the router, we no-op so we don't double-register.
|
||||
"""
|
||||
existing_paths: set[str] = set()
|
||||
try:
|
||||
for resource in app.router.resources():
|
||||
canonical = getattr(resource, "canonical", None)
|
||||
if canonical:
|
||||
existing_paths.add(canonical)
|
||||
except Exception:
|
||||
# If router introspection fails, err on the side of NOT injecting —
|
||||
# double-registration would crash the whole gateway startup.
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: cannot inspect existing routes; "
|
||||
"skipping injection (gateway will run with whatever routes "
|
||||
"it natively provides)."
|
||||
)
|
||||
return
|
||||
|
||||
if existing_paths & _INJECTED_PATHS:
|
||||
logger.info(
|
||||
"hermes_relay_bootstrap: detected existing /api/* routes "
|
||||
"(fork or upstream-merged); skipping injection."
|
||||
)
|
||||
return
|
||||
|
||||
# Defer the heavy import until we're actually going to register. This
|
||||
# keeps the bootstrap cheap for `python -c "1+1"` style invocations
|
||||
# that never use aiohttp meaningfully.
|
||||
try:
|
||||
from . import _handlers
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: cannot import _handlers (%s); "
|
||||
"skipping injection.",
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
_handlers.register_routes(app, adapter)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: register_routes raised %s; "
|
||||
"the gateway will run without injected sessions API.",
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"hermes_relay_bootstrap: injected %d /api/* routes onto upstream "
|
||||
"hermes-agent gateway",
|
||||
len(_INJECTED_PATHS),
|
||||
)
|
||||
+54
-3
@@ -6,8 +6,18 @@
|
||||
#
|
||||
# Installs:
|
||||
# 1. The hermes-relay repo to ~/.hermes/hermes-relay (editable, git-backed)
|
||||
# 2. The Python package (plugin + relay server) via `pip install -e` into
|
||||
# the hermes-agent venv so `python -m plugin.pair` works from anywhere
|
||||
# 2. The Python package (plugin + relay server + bootstrap injection) via
|
||||
# `pip install -e` into the hermes-agent venv so `python -m plugin.pair`
|
||||
# works from anywhere AND the hermes_relay_bootstrap package is on the
|
||||
# Python path. The bootstrap is also wired up via a `.pth` file dropped
|
||||
# directly into the venv's site-packages so Python's `site` module
|
||||
# auto-loads it at every interpreter startup. The bootstrap monkey-
|
||||
# patches `aiohttp.web.Application` so when the gateway builds its app,
|
||||
# our extra `/api/sessions/*`, `/api/memory`, `/api/skills`, `/api/config`
|
||||
# and `/api/available-models` routes get injected onto the same router
|
||||
# the gateway is in the middle of populating. Feature-detected by route
|
||||
# path — if your hermes-agent build already has these endpoints natively,
|
||||
# the bootstrap no-ops cleanly so it's safe to ship across all versions.
|
||||
# 3. A symlink at ~/.hermes/plugins/hermes-relay → the clone's plugin/ dir
|
||||
# so Hermes's plugin loader discovers + enables the plugin
|
||||
# 4. The skill(s) under skills/ into ~/.hermes/config.yaml as a scanned
|
||||
@@ -25,7 +35,20 @@
|
||||
# Updates:
|
||||
# cd ~/.hermes/hermes-relay && git pull
|
||||
# (No reinstall needed — editable pip install + external_dirs scan mean
|
||||
# changes go live on the next invocation.)
|
||||
# changes go live on the next invocation. The bootstrap .pth file is
|
||||
# overwritten on every install.sh re-run, so changes to the bootstrap
|
||||
# package itself need a fresh `bash install.sh` to land in site-packages.)
|
||||
#
|
||||
# Uninstall:
|
||||
# bash ~/.hermes/hermes-relay/uninstall.sh
|
||||
# (Or, if you don't have the clone any more:
|
||||
# curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/uninstall.sh | bash)
|
||||
#
|
||||
# The uninstaller reverses every step here in the opposite order. It is
|
||||
# idempotent and never touches shared state (~/.hermes/.env, the gateway's
|
||||
# state.db, the hermes-agent venv core). Use --keep-clone to leave the git
|
||||
# tree in place, --remove-secret to also wipe the QR signing identity,
|
||||
# --dry-run to preview without changing anything.
|
||||
#
|
||||
# Overrides:
|
||||
# HERMES_RELAY_HOME Target directory (default: ~/.hermes/hermes-relay)
|
||||
@@ -104,6 +127,28 @@ info "[2/6] Installing plugin into hermes venv (editable)..."
|
||||
|| die "pip install -e $RELAY_HOME failed"
|
||||
ok "Installed $("$VENV_PY" -m pip show hermes-relay 2>/dev/null | awk '/^Name:/{n=$2}/^Version:/{print n" "$2}')"
|
||||
|
||||
# Drop the bootstrap .pth into the venv's site-packages so Python loads
|
||||
# `hermes_relay_bootstrap` at interpreter startup. This is what allows the
|
||||
# plugin to inject `/api/sessions/*` (and friends) onto the gateway's
|
||||
# aiohttp app at startup. The .pth has to live directly in site-packages —
|
||||
# setuptools' editable install does NOT ship data-files there, so we drop
|
||||
# it manually here. Idempotent: a second run overwrites the same file.
|
||||
#
|
||||
# Removal: the bootstrap package and its .pth come out together via
|
||||
# `bash uninstall.sh`. The bootstrap also feature-detects on route paths,
|
||||
# so it cleanly no-ops on hermes-agent builds that already serve the same
|
||||
# routes natively — leaving it installed is safe across all versions.
|
||||
SITE_PKGS="$("$VENV_PY" -c 'import site; print(site.getsitepackages()[0])' 2>/dev/null || true)"
|
||||
PTH_SRC="$RELAY_HOME/hermes_relay_bootstrap.pth"
|
||||
if [ -n "$SITE_PKGS" ] && [ -d "$SITE_PKGS" ] && [ -f "$PTH_SRC" ]; then
|
||||
cp "$PTH_SRC" "$SITE_PKGS/hermes_relay_bootstrap.pth"
|
||||
ok "Installed bootstrap .pth → $SITE_PKGS/hermes_relay_bootstrap.pth"
|
||||
else
|
||||
info " Could not determine venv site-packages — bootstrap .pth NOT installed"
|
||||
info " This means /api/sessions/* won't be injected onto vanilla upstream"
|
||||
info " hermes-agent. Manually copy $PTH_SRC into your venv's site-packages."
|
||||
fi
|
||||
|
||||
# ── 3/6 Symlink plugin into Hermes plugin dir ─────────────────────────────
|
||||
info "[3/6] Registering plugin with Hermes..."
|
||||
mkdir -p "$(dirname "$PLUGIN_LINK")"
|
||||
@@ -272,7 +317,13 @@ echo " $VENV_PY -m plugin.pair"
|
||||
echo ""
|
||||
echo " To update later:"
|
||||
echo " cd $RELAY_HOME && git pull"
|
||||
echo " bash $RELAY_HOME/install.sh # re-runs all steps idempotently"
|
||||
echo " systemctl --user restart hermes-relay # if installed as a service"
|
||||
echo " systemctl --user restart hermes-gateway # if you changed bootstrap code"
|
||||
echo ""
|
||||
echo " To uninstall:"
|
||||
echo " bash $RELAY_HOME/uninstall.sh"
|
||||
echo " bash $RELAY_HOME/uninstall.sh --dry-run # preview without changing anything"
|
||||
echo ""
|
||||
echo " Manage the relay service (if installed):"
|
||||
echo " systemctl --user status hermes-relay"
|
||||
|
||||
+7
-1
@@ -33,9 +33,15 @@ tmux = ["libtmux>=0.37.0"] # optional — enables persistent tmux sessions in t
|
||||
# keeps working via the compat layer.
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["plugin*", "relay_server*"]
|
||||
include = ["plugin*", "relay_server*", "hermes_relay_bootstrap*"]
|
||||
exclude = ["plugin.tests*", "plugin.skills*", "app*", "docs*", "user-docs*", "scripts*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
plugin = ["skill.md", "plugin.yaml"]
|
||||
"plugin.skills" = ["android/*.md"]
|
||||
|
||||
# Note: `hermes_relay_bootstrap.pth` at the repo root is NOT installed by
|
||||
# `pip install -e` automatically — setuptools' data-files doesn't ship to
|
||||
# site-packages reliably for editable installs. install.sh copies the .pth
|
||||
# file into the venv's site-packages as a separate step. The bootstrap
|
||||
# package itself (hermes_relay_bootstrap/) IS installed via the find above.
|
||||
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hermes-Relay — canonical uninstaller.
|
||||
#
|
||||
# Reverses every step of install.sh in the opposite order. Idempotent: safe
|
||||
# to run twice; missing artifacts produce a warning, not an error. Never
|
||||
# touches state shared with other tools (~/.hermes/.env, the gateway's
|
||||
# state.db, the hermes-agent venv core, etc.).
|
||||
#
|
||||
# Usage:
|
||||
# bash uninstall.sh # Standard uninstall (keeps QR secret)
|
||||
# bash uninstall.sh --remove-secret # Also wipe ~/.hermes/hermes-relay-qr-secret
|
||||
# bash uninstall.sh --keep-clone # Don't remove ~/.hermes/hermes-relay
|
||||
# bash uninstall.sh --dry-run # Print what would be removed, don't touch anything
|
||||
#
|
||||
# What it removes (in reverse install order):
|
||||
# [6] systemd user service — `systemctl --user disable --now`,
|
||||
# unit file deletion, daemon-reload
|
||||
# [5] hermes-pair shell shim — ~/.local/bin/hermes-pair
|
||||
# [4] skills external_dirs entry — removes the relay's path from
|
||||
# ~/.hermes/config.yaml (other
|
||||
# entries preserved)
|
||||
# [3] plugin symlink + legacy stales — ~/.hermes/plugins/hermes-relay
|
||||
# and any deprecated names
|
||||
# [2] bootstrap .pth + pip package — venv site-packages drop and
|
||||
# `pip uninstall hermes-relay`
|
||||
# [1] git clone — ~/.hermes/hermes-relay (skipped
|
||||
# with --keep-clone)
|
||||
#
|
||||
# What it does NOT touch (ever):
|
||||
# - ~/.hermes/.env (other tools authenticate against this)
|
||||
# - ~/.hermes/state.db (sessions DB shared with the gateway)
|
||||
# - ~/.hermes/hermes-agent/ (the agent itself)
|
||||
# - ~/.hermes/hermes-agent/venv/ (venv core; we only remove our own .pth)
|
||||
# - ~/.hermes/hermes-relay-qr-secret (kept by default — wipe with --remove-secret)
|
||||
# - The phone's stored session token (becomes stale on relay restart anyway)
|
||||
#
|
||||
# Overrides (mirror install.sh):
|
||||
# HERMES_HOME Hermes config home (default: ~/.hermes)
|
||||
# HERMES_RELAY_HOME Target directory (default: $HERMES_HOME/hermes-relay)
|
||||
# HERMES_VENV_PY Path to hermes-agent venv python
|
||||
# (default: $HERMES_HOME/hermes-agent/venv/bin/python)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Argument parsing ───────────────────────────────────────────────────────
|
||||
KEEP_CLONE=""
|
||||
REMOVE_SECRET=""
|
||||
DRY_RUN=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--keep-clone) KEEP_CLONE=1 ;;
|
||||
--remove-secret) REMOVE_SECRET=1 ;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,40p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown flag: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# ── Config ─────────────────────────────────────────────────────────────────
|
||||
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
|
||||
RELAY_HOME="${HERMES_RELAY_HOME:-$HERMES_HOME/hermes-relay}"
|
||||
VENV_PY="${HERMES_VENV_PY:-$HERMES_HOME/hermes-agent/venv/bin/python}"
|
||||
PLUGIN_LINK="$HERMES_HOME/plugins/hermes-relay"
|
||||
HERMES_CONFIG="$HERMES_HOME/config.yaml"
|
||||
QR_SECRET="$HERMES_HOME/hermes-relay-qr-secret"
|
||||
SHIM_PATH="$HOME/.local/bin/hermes-pair"
|
||||
SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
||||
SERVICE_DST="$SYSTEMD_USER_DIR/hermes-relay.service"
|
||||
PTH_NAME="hermes_relay_bootstrap.pth"
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
info() { echo " $*"; }
|
||||
ok() { echo " [ok] $*"; }
|
||||
warn() { echo " [skip] $*"; }
|
||||
note() { echo " [note] $*"; }
|
||||
|
||||
run() {
|
||||
if [ -n "$DRY_RUN" ]; then
|
||||
echo " [dry-run] $*"
|
||||
else
|
||||
eval "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Banner ─────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo " Hermes-Relay Uninstaller"
|
||||
echo " ------------------------"
|
||||
echo " Hermes home: $HERMES_HOME"
|
||||
echo " Clone: $RELAY_HOME"
|
||||
echo " Venv python: $VENV_PY"
|
||||
[ -n "$KEEP_CLONE" ] && echo " --keep-clone: yes (clone will be left in place)"
|
||||
[ -n "$REMOVE_SECRET" ] && echo " --remove-secret: yes (QR secret will be wiped)"
|
||||
[ -n "$DRY_RUN" ] && echo " --dry-run: yes (no filesystem changes)"
|
||||
echo ""
|
||||
|
||||
# ── 6/6 Stop + remove systemd user service ────────────────────────────────
|
||||
info "[6/6] Removing systemd user service..."
|
||||
if command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then
|
||||
if systemctl --user list-unit-files hermes-relay.service >/dev/null 2>&1 && \
|
||||
systemctl --user list-unit-files hermes-relay.service 2>/dev/null | grep -q hermes-relay.service; then
|
||||
run "systemctl --user disable --now hermes-relay.service >/dev/null 2>&1 || true"
|
||||
ok "Stopped + disabled hermes-relay.service"
|
||||
else
|
||||
warn "hermes-relay.service was not registered with systemd — nothing to stop"
|
||||
fi
|
||||
|
||||
if [ -f "$SERVICE_DST" ]; then
|
||||
run "rm -f \"$SERVICE_DST\""
|
||||
run "systemctl --user daemon-reload >/dev/null 2>&1 || true"
|
||||
ok "Removed $SERVICE_DST"
|
||||
else
|
||||
warn "$SERVICE_DST does not exist"
|
||||
fi
|
||||
else
|
||||
warn "systemd user session not available — nothing to stop"
|
||||
# Catch a stray manually-launched relay if there is one
|
||||
if pgrep -f "python -m plugin.relay" >/dev/null 2>&1; then
|
||||
note "A manual 'python -m plugin.relay' is still running."
|
||||
note "Stop it yourself with: pkill -f 'python -m plugin.relay'"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 5/6 Remove hermes-pair shell shim ─────────────────────────────────────
|
||||
info "[5/6] Removing hermes-pair shell shim..."
|
||||
if [ -f "$SHIM_PATH" ] || [ -L "$SHIM_PATH" ]; then
|
||||
run "rm -f \"$SHIM_PATH\""
|
||||
ok "Removed $SHIM_PATH"
|
||||
else
|
||||
warn "$SHIM_PATH does not exist"
|
||||
fi
|
||||
|
||||
# ── 4/6 Remove skills external_dirs entry from config.yaml ────────────────
|
||||
info "[4/6] Removing skills external_dirs entry..."
|
||||
if [ -f "$HERMES_CONFIG" ] && [ -x "$VENV_PY" ]; then
|
||||
if [ -n "$DRY_RUN" ]; then
|
||||
echo " [dry-run] would scrub skills.external_dirs entry pointing at $RELAY_HOME/skills"
|
||||
else
|
||||
"$VENV_PY" - <<PY
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print(" [skip] pyyaml not available — config.yaml left untouched")
|
||||
sys.exit(0)
|
||||
|
||||
cfg_path = Path("$HERMES_CONFIG")
|
||||
target_str = "$RELAY_HOME/skills"
|
||||
target = str(Path(target_str).expanduser().resolve()) if Path(target_str).expanduser().exists() else target_str
|
||||
|
||||
if not cfg_path.is_file():
|
||||
print(" [skip] $HERMES_CONFIG not present")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
|
||||
except Exception as exc:
|
||||
print(f" [skip] Could not parse {cfg_path}: {exc}")
|
||||
sys.exit(0)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
print(" [skip] config.yaml is not a mapping — nothing to remove")
|
||||
sys.exit(0)
|
||||
|
||||
skills_section = data.get("skills")
|
||||
if not isinstance(skills_section, dict):
|
||||
print(" [skip] no skills section in config.yaml")
|
||||
sys.exit(0)
|
||||
|
||||
external_dirs = skills_section.get("external_dirs")
|
||||
if not isinstance(external_dirs, list):
|
||||
print(" [skip] no external_dirs entry to clean")
|
||||
sys.exit(0)
|
||||
|
||||
# Match by both raw string AND resolved path so we catch both forms
|
||||
remaining = []
|
||||
removed = 0
|
||||
for entry in external_dirs:
|
||||
if not isinstance(entry, str):
|
||||
remaining.append(entry)
|
||||
continue
|
||||
try:
|
||||
resolved = str(Path(entry).expanduser().resolve())
|
||||
except Exception:
|
||||
resolved = entry
|
||||
if entry == target_str or resolved == target or entry.endswith("/hermes-relay/skills"):
|
||||
removed += 1
|
||||
else:
|
||||
remaining.append(entry)
|
||||
|
||||
if removed == 0:
|
||||
print(" [skip] no relay skills entry was registered")
|
||||
sys.exit(0)
|
||||
|
||||
if remaining:
|
||||
skills_section["external_dirs"] = remaining
|
||||
else:
|
||||
skills_section.pop("external_dirs", None)
|
||||
if not skills_section:
|
||||
data.pop("skills", None)
|
||||
|
||||
backup = cfg_path.with_suffix(cfg_path.suffix + ".bak")
|
||||
backup.write_text(cfg_path.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
print(f" [ok] Backed up existing config to {backup}")
|
||||
cfg_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
||||
print(f" [ok] Removed {removed} relay skills entry from {cfg_path}")
|
||||
PY
|
||||
fi
|
||||
else
|
||||
warn "$HERMES_CONFIG missing or venv python unavailable — skipped"
|
||||
fi
|
||||
|
||||
# ── 3/6 Remove plugin symlink (and legacy stales) ─────────────────────────
|
||||
info "[3/6] Removing plugin symlink..."
|
||||
removed_any=""
|
||||
for path in "$PLUGIN_LINK" \
|
||||
"$HERMES_HOME/plugins/hermes-android" \
|
||||
"$HERMES_HOME/hermes-agent/plugins/hermes-android" \
|
||||
"$HERMES_HOME/hermes-agent/plugins/hermes-relay"; do
|
||||
if [ -L "$path" ] || [ -e "$path" ]; then
|
||||
run "rm -rf \"$path\""
|
||||
ok "Removed $path"
|
||||
removed_any=1
|
||||
fi
|
||||
done
|
||||
[ -z "$removed_any" ] && warn "No plugin symlinks were registered"
|
||||
|
||||
# ── 2/6 Remove bootstrap .pth + pip package ───────────────────────────────
|
||||
info "[2/6] Removing bootstrap .pth + pip package..."
|
||||
|
||||
if [ -x "$VENV_PY" ]; then
|
||||
SITE_PKGS="$("$VENV_PY" -c 'import site; print(site.getsitepackages()[0])' 2>/dev/null || true)"
|
||||
if [ -n "$SITE_PKGS" ] && [ -f "$SITE_PKGS/$PTH_NAME" ]; then
|
||||
run "rm -f \"$SITE_PKGS/$PTH_NAME\""
|
||||
ok "Removed $SITE_PKGS/$PTH_NAME"
|
||||
else
|
||||
warn "$PTH_NAME not present in venv site-packages"
|
||||
fi
|
||||
|
||||
if "$VENV_PY" -m pip show hermes-relay >/dev/null 2>&1; then
|
||||
run "\"$VENV_PY\" -m pip uninstall --quiet --yes hermes-relay >/dev/null 2>&1 || true"
|
||||
ok "pip uninstall hermes-relay"
|
||||
else
|
||||
warn "hermes-relay pip package was not installed"
|
||||
fi
|
||||
else
|
||||
warn "Venv python not found at $VENV_PY — skipped pip uninstall + .pth removal"
|
||||
fi
|
||||
|
||||
# ── 1/6 Remove the git clone ──────────────────────────────────────────────
|
||||
info "[1/6] Removing git clone..."
|
||||
if [ -n "$KEEP_CLONE" ]; then
|
||||
note "--keep-clone set — leaving $RELAY_HOME in place"
|
||||
elif [ -d "$RELAY_HOME" ]; then
|
||||
# Sanity guard: refuse to remove a directory that doesn't look like our clone
|
||||
if [ -d "$RELAY_HOME/.git" ] && [ -f "$RELAY_HOME/install.sh" ]; then
|
||||
run "rm -rf \"$RELAY_HOME\""
|
||||
ok "Removed $RELAY_HOME"
|
||||
else
|
||||
warn "$RELAY_HOME exists but doesn't look like a hermes-relay clone — left untouched"
|
||||
fi
|
||||
else
|
||||
warn "$RELAY_HOME does not exist"
|
||||
fi
|
||||
|
||||
# ── Optional: QR signing secret ────────────────────────────────────────────
|
||||
info "[opt] QR signing secret..."
|
||||
if [ -f "$QR_SECRET" ]; then
|
||||
if [ -n "$REMOVE_SECRET" ]; then
|
||||
run "rm -f \"$QR_SECRET\""
|
||||
ok "Removed $QR_SECRET (--remove-secret)"
|
||||
note "All paired phones will need to re-trust the next QR code on their next pair."
|
||||
else
|
||||
note "$QR_SECRET preserved (use --remove-secret to wipe)."
|
||||
note "Without removing it, re-installing keeps the same QR signing identity"
|
||||
note "and any phones still holding their session tokens stay valid."
|
||||
fi
|
||||
else
|
||||
warn "$QR_SECRET does not exist"
|
||||
fi
|
||||
|
||||
# ── Done ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
if [ -n "$DRY_RUN" ]; then
|
||||
echo " [dry-run complete] No changes made."
|
||||
else
|
||||
echo " [OK] Hermes-Relay uninstalled."
|
||||
fi
|
||||
echo ""
|
||||
echo " Preserved (other tools depend on these):"
|
||||
echo " - $HERMES_HOME/.env"
|
||||
echo " - $HERMES_HOME/state.db (sessions database)"
|
||||
echo " - $HERMES_HOME/config.yaml (only the relay's skills entry was removed)"
|
||||
echo " - $HERMES_HOME/hermes-agent/ (the agent itself)"
|
||||
[ -z "$REMOVE_SECRET" ] && [ -f "$QR_SECRET" ] && echo " - $QR_SECRET (QR signing secret)"
|
||||
echo ""
|
||||
echo " To reinstall:"
|
||||
echo " curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash"
|
||||
echo ""
|
||||
@@ -33,13 +33,16 @@ The installer follows Hermes's canonical skill-distribution pattern:
|
||||
3. Adds `~/.hermes/hermes-relay/skills` to `skills.external_dirs` in `~/.hermes/config.yaml` (idempotent YAML edit) so the `hermes-relay-pair` skill is picked up on every hermes-agent load
|
||||
4. Symlinks `~/.hermes/plugins/hermes-relay` → the clone's `plugin/` subdir
|
||||
5. Installs a thin `~/.local/bin/hermes-pair` shim that execs `python -m plugin.pair` inside the hermes-agent venv
|
||||
6. Installs a systemd user unit at `~/.config/systemd/user/hermes-relay.service` (optional — skipped on macOS, WSL-without-systemd, bare chroots)
|
||||
|
||||
Restart hermes-agent after install.
|
||||
|
||||
::: tip What you get
|
||||
- **Full Hermes-Relay Android app features** — sessions browser, conversation history on app restart, personality picker, command palette, memory management. Just install the plugin and it works.
|
||||
- **14 `android_*` device control tools** (tap, type, read screen, screenshot, open apps, etc.) — registered by the plugin
|
||||
- **`/hermes-relay-pair` slash command** — backed by the `devops/hermes-relay-pair` skill and usable from any Hermes chat surface
|
||||
- **`hermes-pair` shell shim** — for scripts and power-user flows
|
||||
- **Voice mode endpoints** on the WSS relay (transcribe, synthesize, voice config) wired into the Android app's voice mode UI
|
||||
|
||||
No separate skill install, no `qrencode` binary needed.
|
||||
:::
|
||||
@@ -48,10 +51,35 @@ No separate skill install, no `qrencode` binary needed.
|
||||
Because the installer uses `pip install -e` for the plugin and `external_dirs` for the skill, updates are a single command:
|
||||
|
||||
```bash
|
||||
cd ~/.hermes/hermes-relay && git pull
|
||||
cd ~/.hermes/hermes-relay && git pull && bash install.sh
|
||||
systemctl --user restart hermes-gateway hermes-relay
|
||||
```
|
||||
|
||||
Restart hermes-agent and the updated plugin, skill, and docs are live. There's no separate `hermes skills update` step — `external_dirs` is scanned fresh on every hermes-agent invocation.
|
||||
`bash install.sh` is idempotent — safe to re-run as often as you like. It re-applies every step against the existing install, picks up any new files, and rebuilds the systemd unit from the latest template.
|
||||
:::
|
||||
|
||||
::: info Uninstalling
|
||||
A clean uninstaller ships in the same repo:
|
||||
|
||||
```bash
|
||||
bash ~/.hermes/hermes-relay/uninstall.sh
|
||||
```
|
||||
|
||||
Or if you don't have the clone any more:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/uninstall.sh | bash
|
||||
```
|
||||
|
||||
The uninstaller reverses every install step in the opposite order, is idempotent, and never touches state shared with other Hermes tools (`~/.hermes/.env`, the gateway's `state.db`, the `hermes-agent` venv core). Useful flags:
|
||||
|
||||
```bash
|
||||
bash uninstall.sh --dry-run # preview without changing anything
|
||||
bash uninstall.sh --keep-clone # leave ~/.hermes/hermes-relay in place
|
||||
bash uninstall.sh --remove-secret # also wipe the QR signing identity
|
||||
```
|
||||
|
||||
By default the QR signing secret at `~/.hermes/hermes-relay-qr-secret` is preserved, so re-installing keeps the same identity and any phones still holding their session tokens stay valid.
|
||||
:::
|
||||
|
||||
### 3. Pair your phone
|
||||
|
||||
@@ -14,6 +14,12 @@ http(s)://<server>:8642
|
||||
Authorization: Bearer <API_SERVER_KEY> (optional — only if server has a key configured)
|
||||
```
|
||||
|
||||
## How endpoints get served
|
||||
|
||||
Installing the plugin via `install.sh` is enough to make all of the endpoints below work — including the management ones (`/api/sessions/*`, `/api/memory`, `/api/skills`, `/api/config`, `/api/available-models`). The plugin wires the gateway up at install time so these are served on the same `:8642` host as the standard `/v1/*` endpoints, with the same `Authorization: Bearer …` auth.
|
||||
|
||||
**Chat streaming uses standard `/v1/runs`** by default — it emits structured `tool.started`/`tool.completed` SSE events for live tool progress cards in the Android app. The app's `Settings → Chat → Streaming endpoint = "Auto"` (default) probes per-endpoint capability and picks the best chat path automatically; you can manually force `Sessions` or `Runs` mode for debugging.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Health Check
|
||||
|
||||
Reference in New Issue
Block a user