From 1bcadcf950711df97e262f43b2a050774fc2505c Mon Sep 17 00:00:00 2001 From: ARIA Date: Fri, 21 Aug 2026 10:18:48 +0200 Subject: [PATCH] Added slash command handling --- .../kotlin/iris/net/GatewayClient.kt | 4 + .../kotlin/iris/protocol/Protocol.kt | 28 +++ .../kotlin/iris/state/IrisController.kt | 26 +++ .../kotlin/iris/ui/screens/ChatScreen.kt | 116 +++++++++++- .../src/commonMain/kotlin/iris/util/Fuzzy.kt | 29 +++ .../commonMain/kotlin/iris/util/IrisLog.kt | 13 ++ .../commonTest/kotlin/iris/util/FuzzyTest.kt | 42 +++++ docs/04-wire-protocol.md | 18 +- docs/10-android-app.md | 26 ++- docs/13-testing.md | 9 + docs/17-future-control-surface.md | 173 ++++++++++++++++++ docs/README.md | 1 + docs/protocol/frames.schema.json | 8 +- gateway-plugin/adapter.py | 72 ++++++++ gateway-plugin/protocol.py | 21 +++ gateway-plugin/ws_server.py | 2 + 16 files changed, 567 insertions(+), 21 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/iris/util/Fuzzy.kt create mode 100644 app/shared/src/commonMain/kotlin/iris/util/IrisLog.kt create mode 100644 app/shared/src/commonTest/kotlin/iris/util/FuzzyTest.kt create mode 100644 docs/17-future-control-surface.md diff --git a/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt b/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt index f05506f..e9c073c 100644 --- a/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt +++ b/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt @@ -23,6 +23,7 @@ import iris.protocol.mediaUploadStartFrame import iris.protocol.messageSendFrame import iris.protocol.pingFrame import iris.protocol.syncFrame +import iris.util.IrisLog import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -198,6 +199,7 @@ class GatewayClient( request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { + IrisLog.d("ws open (${response.code})") webSocket.send( helloFrame( token = token, @@ -256,10 +258,12 @@ class GatewayClient( } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + IrisLog.w("ws closed code=$code reason=\"$reason\"") closed.complete(Unit) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + IrisLog.e("ws failure: ${t.javaClass.simpleName}: ${t.message} (http=${response?.code})") fail.complete(t.message ?: "connection failed") closed.complete(Unit) } diff --git a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt index f0c0669..fa20fc9 100644 --- a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt +++ b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt @@ -79,6 +79,9 @@ const val TYPE_CHANNEL_DELETED = "channel.deleted" const val TYPE_CHANNEL_LIST = "channel.list" const val TYPE_SEARCH = "search" const val TYPE_SEARCH_RESULTS = "search.results" + +// Slash-command catalog (the composer's "/" drawer) +const val TYPE_COMMANDS_CATALOG = "commands.catalog" const val TYPE_SYNC = "sync" const val TYPE_SYNC_DONE = "sync.done" const val TYPE_HISTORY = "history" @@ -145,6 +148,8 @@ data class ServerCaps( val tools: Boolean = false, val media: Boolean = false, val search: Boolean = false, + /** The gateway answers `commands.catalog` (the composer's "/" drawer). */ + @SerialName("commands_catalog") val commandsCatalog: Boolean = false, val push: String = "fcm", @SerialName("push_ntfy_server") val pushNtfyServer: String = "", val pickers: Boolean = false, @@ -389,6 +394,24 @@ data class SearchResultsPayload( val hits: List = emptyList(), ) +// ── commands.catalog (slash-command catalog for the "/" drawer) ────────── + +/** One slash command from the gateway catalog. [name]/[aliases] carry the + * leading slash ("/new", "/reset"); the app fuzzy-matches client-side. */ +@Serializable +data class SlashCommand( + val name: String, + val description: String = "", + @SerialName("args_hint") val argsHint: String = "", + val category: String = "", + val aliases: List = emptyList(), +) + +@Serializable +data class CommandsCatalogPayload( + val commands: List = emptyList(), +) + // ── M3: sync (reconnect catch-up) ─────────────────────────────────────── @Serializable @@ -594,6 +617,11 @@ fun searchFrame( ), ) +/** Request the gateway's slash-command catalog (the composer's "/" drawer). + * Answered by a `commands.catalog` frame carrying the same id. */ +fun commandsCatalogFrame(id: Int): Frame = + Frame(id = id, type = TYPE_COMMANDS_CATALOG) + fun syncFrame(id: Int, cursor: Long): Frame = Frame( id = id, diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index 8a42947..fcc5656 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -15,6 +15,7 @@ import iris.platform.mediaCacheBaseDir import iris.platform.postSystemNotification import iris.protocol.ChannelDeletedPayload import iris.protocol.ChannelInfo +import iris.protocol.CommandsCatalogPayload import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS import iris.protocol.HistoryMessage import iris.protocol.HistoryPayload @@ -27,6 +28,7 @@ import iris.protocol.ReadReceiptPayload import iris.protocol.ROLE_ASSISTANT import iris.protocol.SearchHit import iris.protocol.SearchResultsPayload +import iris.protocol.SlashCommand import iris.protocol.StatusPayload import iris.protocol.SyncDonePayload import iris.protocol.TYPE_ERROR @@ -36,6 +38,7 @@ import iris.protocol.TYPE_CHANNEL_CREATED import iris.protocol.TYPE_CHANNEL_DELETED import iris.protocol.TYPE_CHANNEL_LIST import iris.protocol.TYPE_CHANNEL_RENAMED +import iris.protocol.TYPE_COMMANDS_CATALOG import iris.protocol.TYPE_COMMENTARY import iris.protocol.TYPE_MEDIA_OFFER import iris.protocol.TYPE_MESSAGE @@ -61,6 +64,7 @@ import iris.protocol.channelListFrame import iris.protocol.channelRenameFrame import iris.protocol.channelSetAutomationFrame import iris.protocol.channelSetDefaultFrame +import iris.protocol.commandsCatalogFrame import iris.protocol.historyFrame import iris.protocol.messageDeleteFrame import iris.protocol.searchFrame @@ -231,6 +235,13 @@ class IrisController( private val _lastQuery = MutableStateFlow("") val lastQuery: StateFlow = _lastQuery.asStateFlow() + // ── Slash-command catalog (the composer's "/" drawer) ───────────────── + // The gateway's slash commands (commands.catalog). Static per gateway + // run; requested on connect and lazily when the drawer opens with an + // empty cache. The app fuzzy-matches the typed prefix client-side. + private val _slashCommands = MutableStateFlow>(emptyList()) + val slashCommands: StateFlow> = _slashCommands.asStateFlow() + // ── M4: media ───────────────────────────────────────────────────────── private val mediaCache = MediaCache(mediaCacheBaseDir()) @@ -397,6 +408,9 @@ class IrisController( frame.payloadAs()?.let { _searchResults.value = it.hits } _searching.value = false } + TYPE_COMMANDS_CATALOG -> { + frame.payloadAs()?.let { _slashCommands.value = it.commands } + } TYPE_SYNC_DONE -> { // Replayed frames already flowed through [events]; the // cursor is authoritative server-side (outbox). @@ -480,6 +494,9 @@ class IrisController( } // M5: a deep link tapped before we were connected. applyDeepLink() + // Slash-command catalog for the composer's "/" drawer + // (static per gateway run; re-fetched on every (re)connect). + requestCommandsCatalog() // Gateway came back after a restart -> announce it (hermes // routine, same icon + wording on all platforms). The core // does not send a startup/online notice to this platform, so @@ -590,6 +607,15 @@ class IrisController( _searching.value = false } + // ── Slash-command catalog (the composer's "/" drawer) ───────────────── + + /** Request the gateway's slash-command catalog. No-op while disconnected + * (sendFrame drops silently); the response lands via [slashCommands]. */ + fun requestCommandsCatalog() { + if (client.state.value !is GatewayClient.State.Connected) return + client.sendFrame(commandsCatalogFrame(0)) + } + // ── M3: sync (reconnect catch-up) ───────────────────────────────────── fun sync(cursor: Long) { diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index b674ca5..acd9194 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -122,6 +124,7 @@ import iris.protocol.KIND_IMAGE import iris.protocol.ROLE_ASSISTANT import iris.protocol.ROLE_USER import iris.protocol.SearchHit +import iris.protocol.SlashCommand import iris.state.IrisController import iris.state.ToolDetail import iris.ui.MarkdownText @@ -132,6 +135,7 @@ import iris.ui.theme.avatarColor import iris.ui.theme.contrastText import iris.util.formatDayLabel import iris.util.formatTime +import iris.util.fuzzyScore import iris.util.localDayKey import iris.util.prepareForMarkdown import iris.util.preserveNewlinesAsHardBreaks @@ -256,6 +260,37 @@ fun ChatScreen(controller: IrisController) { if (idx >= 0) railIndex = idx } + // Slash-command drawer (the composer's "/" autocomplete): while the input + // is a bare "/…" (no space yet), fuzzy-match the typed prefix against the + // gateway catalog. Tapping a row sends the command; the drawer closes + // when the input leaves the "/" form, the match list empties (unknown + // command), or a command is sent. + val slashCommands by controller.slashCommands.collectAsState() + val slashQuery = + if (input.startsWith("/") && !input.contains(" ")) input.removePrefix("/") else null + val slashMatches = remember(slashQuery, slashCommands) { + if (slashQuery == null) emptyList() + else slashCommands + .map { it to bestSlashScore(slashQuery, it) } + .filter { it.second >= 0 } + .sortedWith(compareBy({ it.second }, { it.first.name })) + .map { it.first } + } + val slashDrawerOpen = slashQuery != null && slashMatches.isNotEmpty() + + // The catalog is fetched on connect; if the drawer opens before the first + // response lands (or the gateway predates the frame), fetch it lazily. + LaunchedEffect(slashQuery) { + if (slashQuery != null && slashCommands.isEmpty()) controller.requestCommandsCatalog() + } + + fun onSlashPick(cmd: SlashCommand) { + if (state !is GatewayClient.State.Connected) return + input = "" + controller.send(cmd.name) + focusManager.clearFocus(force = true) + } + // M6: keyboard shortcuts (docs/11 §11.3) — desktop only. val shortcutsModifier = if (isDesktop) { Modifier.onPreviewKeyEvent { e -> @@ -272,6 +307,8 @@ fun ChatScreen(controller: IrisController) { } ctrlOrMeta && e.key == Key.F -> { showSearch = true; true } ctrlOrMeta && e.key == Key.K -> { showPalette = true; true } + // Escape closes the slash drawer (clears the "/" input). + e.key == Key.Escape && slashDrawerOpen -> { input = ""; true } e.key == Key.Escape && overlayOpen -> { showSearch = false showPicker = false @@ -550,6 +587,67 @@ fun ChatScreen(controller: IrisController) { } } + // Slash-command drawer: rolls up over the composer while the input is a + // bare "/…". Fuzzy-matched against the gateway catalog; tapping a + // row sends the command (and closes the drawer). + AnimatedVisibility( + visible = slashDrawerOpen && !isAutomation && !selectionMode, + enter = fadeIn(tween(120)) + slideInVertically(tween(160)) { it / 3 }, + exit = fadeOut(tween(100)) + slideOutVertically(tween(120)) { it / 3 }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clip(RoundedCornerShape(16.dp)) + .background(IrisColors.panel) + .border(1.dp, IrisColors.divider, RoundedCornerShape(16.dp)), + ) { + slashMatches.take(SLASH_DRAWER_MAX).forEachIndexed { index, cmd -> + if (index > 0) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(IrisColors.divider), + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onSlashPick(cmd) } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + cmd.name, + color = IrisColors.primary, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + ) + if (cmd.argsHint.isNotEmpty()) { + Text( + " ${cmd.argsHint}", + color = IrisColors.textTertiary, + fontSize = 13.sp, + ) + } + } + Text( + cmd.description, + color = IrisColors.textSecondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + // Selection toolbar (replaces the composer while messages are selected). if (selectionMode) { SelectionToolbar( @@ -2429,4 +2527,20 @@ internal val ToolDetail.label: String ToolDetail.EVERYTHING -> "everything" ToolDetail.TRUNCATED -> "truncated" ToolDetail.NOTHING -> "nothing" - } \ No newline at end of file + } + +// ── Slash-command drawer ────────────────────────────────────────────────── + +/** Max rows in the slash drawer (the rest is reachable by typing more). */ +private const val SLASH_DRAWER_MAX = 8 + +/** Best fuzzy rank of [query] against a command's name or any of its + * aliases; -1 when nothing matches. */ +private fun bestSlashScore(query: String, cmd: SlashCommand): Int { + var best = fuzzyScore(query, cmd.name.removePrefix("/")) + for (alias in cmd.aliases) { + val s = fuzzyScore(query, alias.removePrefix("/")) + if (s >= 0 && (best < 0 || s < best)) best = s + } + return best +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/iris/util/Fuzzy.kt b/app/shared/src/commonMain/kotlin/iris/util/Fuzzy.kt new file mode 100644 index 0000000..ef8fa87 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/iris/util/Fuzzy.kt @@ -0,0 +1,29 @@ +package iris.util + +/** + * Fuzzy subsequence scoring for the slash-command drawer. + * + * [fuzzyScore] returns a non-negative rank (lower = better) when every + * character of [query] appears in [target] in order (case-insensitive), or + * -1 when it does not. Prefix matches rank best (1); a consecutive run of + * matched characters ranks better than a gapped one, so "mod" beats "m...d" + * for "/model". + */ +fun fuzzyScore(query: String, target: String): Int { + val q = query.lowercase() + if (q.isEmpty()) return 0 + val t = target.lowercase() + if (t.startsWith(q)) return 1 + var qi = 0 + var score = 0 + var prevMatch = -2 + for (i in t.indices) { + if (t[i] == q[qi]) { + score += if (prevMatch == i - 1) 1 else 4 + prevMatch = i + qi++ + if (qi == q.length) return score + } + } + return -1 +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/iris/util/IrisLog.kt b/app/shared/src/commonMain/kotlin/iris/util/IrisLog.kt new file mode 100644 index 0000000..1001d9b --- /dev/null +++ b/app/shared/src/commonMain/kotlin/iris/util/IrisLog.kt @@ -0,0 +1,13 @@ +package iris.util + +/** Minimal logging. On Android, stdout goes to logcat; on desktop it goes to + * the console. Kept dependency-free (common stdlib only). */ +object IrisLog { + private const val TAG = "Iris" + + fun d(msg: String) = println("$TAG: $msg") + + fun w(msg: String) = println("$TAG: $msg") + + fun e(msg: String) = println("$TAG: $msg") +} \ No newline at end of file diff --git a/app/shared/src/commonTest/kotlin/iris/util/FuzzyTest.kt b/app/shared/src/commonTest/kotlin/iris/util/FuzzyTest.kt new file mode 100644 index 0000000..348be9e --- /dev/null +++ b/app/shared/src/commonTest/kotlin/iris/util/FuzzyTest.kt @@ -0,0 +1,42 @@ +package iris.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FuzzyTest { + @Test + fun emptyQueryMatchesEverything() { + assertEquals(0, fuzzyScore("", "new")) + assertEquals(0, fuzzyScore("", "")) + } + + @Test + fun prefixMatchRanksBest() { + assertEquals(1, fuzzyScore("mod", "model")) + assertEquals(1, fuzzyScore("NEW", "new")) + } + + @Test + fun subsequenceMatchesInOrder() { + assertTrue(fuzzyScore("nw", "new") >= 0) + assertTrue(fuzzyScore("mdl", "model") >= 0) + } + + @Test + fun outOfOrderDoesNotMatch() { + assertEquals(-1, fuzzyScore("wn", "new")) + assertEquals(-1, fuzzyScore("xyz", "model")) + } + + @Test + fun consecutiveRunBeatsGapped() { + // "sta" is a consecutive run in "status"; "stu" is gapped in "status". + assertTrue(fuzzyScore("sta", "status") < fuzzyScore("stu", "status")) + } + + @Test + fun prefixBeatsSubsequence() { + assertTrue(fuzzyScore("mod", "model") < fuzzyScore("mdl", "model")) + } +} \ No newline at end of file diff --git a/docs/04-wire-protocol.md b/docs/04-wire-protocol.md index a09ec6a..7a9bbc3 100644 --- a/docs/04-wire-protocol.md +++ b/docs/04-wire-protocol.md @@ -150,18 +150,22 @@ request. The app uses this to **populate the initial view** when a channel is opened (complements `sync`, which only replays undelivered outbox frames). ### `commands.catalog` -Response to a `commands.catalog` request. Full slash-command list. +Request (app → server, empty payload) and response: the gateway's +slash-command catalog for the app's `/` drawer. Derived from hermes' central +`COMMAND_REGISTRY` (the same source the gateway help and the Telegram command +menu use), restricted to commands available on gateway surfaces, plus +plugin-registered commands. The app fuzzy-matches the typed prefix +client-side (no `commands.complete` round-trip). ```json {"type":"commands.catalog","id":21,"payload":{ "commands":[ - {"name":"/new","description":"Start a new session","args_hint":"","category":"session"}, - {"name":"/model","description":"Switch model","args_hint":"","category":"config"}, - {"name":"/reasoning","description":"Toggle reasoning effort","args_hint":"[low|medium|high]","category":"config"}, - {"name":"/status","description":"Show session status","args_hint":"","category":"info"}, - {"name":"/cron","description":"Manage cron jobs","args_hint":"","category":"automation"}, - {"name":"/tools","description":"List available tools","args_hint":"","category":"info"} + {"name":"/new","description":"Start a new session (fresh session ID + history)","args_hint":"[name]","category":"Session","aliases":["/reset"]}, + {"name":"/model","description":"Switch model","args_hint":"","category":"Configuration","aliases":[]}, + {"name":"/status","description":"Show session status","args_hint":"","category":"Info","aliases":[]} ]}} ``` +`name`/`aliases` carry the leading slash; `args_hint` is the registry's +argument placeholder (empty when the command takes none). ### `commands.complete` Response to a `commands.complete` request. Autocomplete matches for a typed prefix. diff --git a/docs/10-android-app.md b/docs/10-android-app.md index 14d845f..7bfca2a 100644 --- a/docs/10-android-app.md +++ b/docs/10-android-app.md @@ -81,17 +81,25 @@ app/shared/src/ auto-grow field, attach (paperclip), mic/send (right). Send on Enter (configurable: Enter=send vs Enter=newline). -### Menu button → all slash commands -- `Menü` opens a **bottom sheet** listing the command catalog. The catalog is - served by the gateway via `commands.catalog` request → response with - `{name, description, args_hint, category}` per command, so it always matches - hermes (`/new`, `/model`, `/reasoning`, `/status`, `/cron`, `/tools`, …). -- Typing `/` in the field shows **autocomplete** via `commands.complete` - request (gateway matches the typed prefix). Selecting inserts `/cmd `. -- Selecting a command sends `message.send {text:"/cmd args"}`. +### Slash commands +- **`/` drawer (implemented):** typing `/` in the composer rolls a drawer up + over the input listing the command catalog. The catalog is served by the + gateway via `commands.catalog` request → response with + `{name, description, args_hint, category, aliases}` per command (derived + from hermes' `COMMAND_REGISTRY`, gateway-available subset + plugin commands), + so it always matches hermes (`/new`, `/model`, `/reasoning`, `/status`, + `/cron`, …). The drawer is shown while the input is a bare `/…` (no space + yet); typing fuzzy-filters it **client-side** (subsequence match over name + + aliases — no `commands.complete` round-trip). Tapping a row sends + `message.send {text:"/cmd"}` and closes the drawer; an unknown command + empties the match list and closes it (the raw text can still be sent — + hermes answers with its unknown-command reply). Escape (desktop) clears the + `/` input. +- **Menu button (planned):** `Menü` opens a **bottom sheet** listing the same + catalog for discovery without typing. - **Interactive commands** (`/model`, `/reasoning`, `/fast`, approvals, clarifies) render as **native pickers** from `picker.*` frames (a dialog / - sheet with the options; answer via `picker.select`). + sheet with the options; answer via `picker.select`) — planned. ### Streaming (app-controlled) - **Settings → "Streaming"** toggle (default on). When off, the app ignores diff --git a/docs/13-testing.md b/docs/13-testing.md index 0dad897..32ce7de 100644 --- a/docs/13-testing.md +++ b/docs/13-testing.md @@ -37,6 +37,10 @@ without the app (critical for verifying frame shapes early). carry the new `thread_id`; no-op with an existing `thread_id`, for slash commands, or for media-only sends; the LLM upgrade renames the thread (`channel.renamed`). +- **Slash catalog:** `commands.catalog` request → response with the + gateway-available `COMMAND_REGISTRY` subset + plugin commands (each entry + `name`/`description`/`args_hint`/`category`/`aliases`); `cli_only` commands + excluded; response `id` matches the request `id`. - **No `~/.hermes` writes in tests** — use the `_isolate_hermes_home` fixture pattern (temp `HERMES_HOME`). Profile tests also mock `Path.home()`. @@ -122,6 +126,11 @@ adb logcat -d > /tmp/logcat.txt flat lane → a new topic appears (derived name), the app jumps into it, the reply streams there, and the topic is renamed to the AI's title a moment later. Slash commands / media-only sends stay in the flat lane. +14. **Slash drawer:** type `/` in the composer → the drawer rolls up over the + input with the gateway catalog; keep typing → fuzzy filtering (unrelated + entries drop out); tap a row → the command is sent and the drawer closes; + type an unknown command → the drawer closes (the raw text can still be + sent; hermes answers with its unknown-command reply). ## 13.5 Debugging tips diff --git a/docs/17-future-control-surface.md b/docs/17-future-control-surface.md new file mode 100644 index 0000000..6794399 --- /dev/null +++ b/docs/17-future-control-surface.md @@ -0,0 +1,173 @@ +# 17 — Future Control Surface (post-M7) + +> **Status: research / backlog — not scheduled.** M0–M7 cover the chat surface +> (messages, streaming, tools, media, push, channels, search). This doc records +> what the app could *additionally* control through the gateway, based on a +> survey of the hermes-agent source (2026-08-21). Nothing here is locked; it is +> a menu of options, each with the hermes backend that already implements it. + +## The key insight + +Hermes already ships a **complete REST API** — `hermes_cli/web_server.py` plus +`hermes_cli/web_routers/` — which is the backend of the desktop/dashboard UI. +Our gateway plugin runs **inside the same `hermes gateway` process**, so every +capability below is reachable by importing and calling the same functions the +web server calls. Exposing one from the app = a new request/response frame pair +in our protocol (`protocol.py` → `Protocol.kt` → `frames.schema.json`, the +usual three-way mirror). + +The constraint is never "can hermes do it?" (it can — all of it) — it is how +many frame pairs we want to add and the security review each one needs. + +## Implementation paths + +1. **Direct function calls (preferred).** The plugin imports the hermes + modules (`cron.jobs`, `hermes_cli.kanban_db`, config loaders, …) and calls + them. No extra process, no extra port, zero new deps. +2. **Proxy to the web server's REST API.** Only works if `hermes web` / + `hermes dashboard` is running — it is not by default. Rejected for v1. + +## Capability menu + +### Cron jobs — fully controllable + +| App action | Hermes backend | +|---|---| +| List jobs (incl. disabled) | `cron/jobs.py` `list_jobs()` | +| Create job | `cron/jobs.py` `create_job()` (schedules: `30m`, `every monday 9am`, 5-field cron, ISO one-shot) | +| Edit job | `cron/jobs.py` `update_job(job_id, updates)` | +| Pause / resume | `cron/jobs.py` `pause_job()` / `resume_job()` | +| Trigger now | `cron/jobs.py` `trigger_job()` | +| Delete job | web router `web_routers/cron.py` `DELETE /api/cron/jobs/{id}` | +| Run history | `web_routers/cron.py` `GET /api/cron/jobs/{id}/runs`; `hermes_state_portability.py` `list_cron_job_runs()` | +| Delivery targets | `web_routers/cron.py` `GET /api/cron/delivery-targets` | +| One-tap suggestions | `cron/suggestions.py` — ready-to-run job specs the user accepts/dismisses; `accept_suggestion()` calls `create_job` directly. **Designed for a UI; ideal first feature.** | +| Blueprints | `cron/blueprint_catalog.py`, `web_routers/cron.py` `GET /api/cron/blueprints` + `POST /api/cron/blueprints/instantiate` | + +Per-job fields worth surfacing: `model`/`provider` overrides, `skills`, +`script`, `context_from` (chain job A's output into job B), `workdir`, +multi-platform delivery. + +### Kanban board — fully controllable + +| App action | Hermes backend | +|---|---| +| Boards: list / create / rename / delete / switch | `hermes_cli/kanban_db.py` `create_board()` / `list_boards()`; REST `plugins/kanban/dashboard/plugin_api.py` `/boards*` | +| Tasks: create / list / show / update / delete | `kanban_db.py` `create_task()` / `list_tasks()` / `complete_task()`; REST `/tasks*` | +| Comments / links / attachments | REST `/tasks/{id}/comments`, `/links`, `/tasks/{id}/attachments` | +| Bulk operations | REST `POST /tasks/bulk` | +| Dispatch / reassign / reclaim / estimate | REST `/dispatch`, `/tasks/{id}/reassign`, `/tasks/{id}/reclaim`, `/estimate` | +| Runs: inspect / terminate | REST `/runs/{run_id}`, `/runs/{run_id}/terminate` | +| Stats / diagnostics / active workers | REST `/stats`, `/diagnostics`, `/workers/active` | +| Per-task model options | REST `/model-options` | + +Bonus: the kanban dispatcher already runs **inside the gateway** by default +(`kanban.dispatch_in_gateway: true`), so a phone app would see dispatch +activity live. The full REST surface (~30 endpoints) is in +`plugins/kanban/dashboard/plugin_api.py`. + +### Model / provider registry — fully controllable + +| App action | Hermes backend | +|---|---| +| Set active model | `web_server.py` `POST /api/model/set` | +| List model options / info / recommended default | `GET /api/model/options`, `/api/model/info`, `/api/model/recommended-default` | +| Auxiliary (side-LLM) models | `GET/PUT /api/model/auxiliary` | +| MoA config | `GET/PUT /api/model/moa` | +| Per-profile model | `web_routers/profiles.py` `PUT /api/profiles/{name}/model` | +| Per-toolset model / provider / env | `web_routers/tools.py` `PUT /api/tools/toolsets/{name}/model` / `/provider` / `/env` | +| Custom endpoints: CRUD + validate + activate | `web_server.py` `/api/providers/custom-endpoints*` | +| Provider validation / OAuth flows | `POST /api/providers/validate`, `/api/providers/oauth*` | +| API keys (`.env`): set / reveal / delete | `web_server.py` `GET/PUT/DELETE /api/env`, `POST /api/env/reveal` | +| `config.yaml`: read / write / schema | `GET/PUT /api/config`, `GET /api/config/schema` | + +### Webhooks + +| App action | Hermes backend | +|---|---| +| Subscribe / list / remove / test inbound webhook routes | `hermes_cli/webhook.py` (`hermes webhook` CLI: `_cmd_subscribe`, `_cmd_list`, `_cmd_remove`, `_cmd_test`); subscriptions stored in `~/.hermes/webhooks/subscriptions.json` | + +### Sessions + +List, FTS search, rename, delete, bulk-delete, export (md/html), prune — +`web_routers/sessions.py` (`/api/sessions*`). Overlaps with what the app +already shows from its own chat; useful for browsing *other* platforms' +sessions (Telegram, CLI, cron runs). + +### Profiles + +CRUD, switch active profile, edit soul/description, per-profile model, +export/import — `web_routers/profiles.py` (`/api/profiles*`). + +### Skills + +List, toggle, create/edit content, hub search/install/uninstall/update — +`web_routers/skills.py` (`/api/skills*`, `/api/skills/hub/*`). + +### Tools / toolsets + +Enable/disable per platform, per-toolset config/model/provider/env — +`web_routers/tools.py` (`/api/tools/toolsets*`). + +### MCP servers + +CRUD, test, auth/OAuth flows, catalog browse + install — +`web_routers/mcp.py` (`/api/mcp/*`). + +### Git + +Status, branches, worktrees, and a full review flow (stage/unstage/revert/ +commit/push/create-PR) — `web_routers/git.py` (`/api/git/*`). + +### Files + +Upload/download/read/write, fs list/mkdir — `web_server.py` `/api/files*`, +`/api/fs/*`. (Complements the existing media frames in `07-media.md`.) + +### Messaging platforms + +List/configure/test other platforms (Telegram, WhatsApp onboarding) — +`web_server.py` `/api/messaging/*`. + +### Memory providers + +Config + setup per provider — `web_server.py` `/api/memory/providers/*`. + +### Ops / system + +| App action | Hermes backend | +|---|---| +| Gateway restart / drain | `web_server.py` `POST /api/gateway/restart`, `/api/gateway/drain` | +| Hermes update check / install | `POST /api/hermes/update`, `GET /api/hermes/update/check` | +| Curator (skill maintenance) status / pause / run | `GET /api/curator`, `PUT /api/curator/paused`, `POST /api/curator/run` | +| Audio: transcribe / TTS / voices | `POST /api/audio/transcribe`, `POST /api/audio/speak`, `GET /api/audio/elevenlabs/voices` | +| System stats / status / health | `GET /api/system/stats`, `/api/status`, `/api/health` | +| Learning graph node CRUD | `GET/PUT/DELETE /api/learning/node*` | + +## Design notes + +1. **Protocol cost is low.** The existing frame model (`type` + payload, + request/response correlated by `id`) already fits; we would add pairs like + `cron.list`/`cron.create`, `kanban.task.create`, `model.set`, following the + standard three-way mirror (`gateway-plugin/protocol.py` → + `app/shared/.../protocol/Protocol.kt` → `docs/protocol/frames.schema.json`). +2. **Security is the real gate.** The single-user model in + `09-pairing-security.md` still holds, but control frames widen the blast + radius of a leaked `ANDROID_TOKEN`. Sensitive operations (env/secrets, + config writes, gateway restart, profile deletion) should get either an + in-app confirmation step or a capability flag negotiated at pairing. +3. **Read-heavy first.** Most of the value is in list/view frames (cheap, + low-risk); write frames can follow per domain. +4. **Broadcast routing applies.** Frames broadcast to all connected devices + (locked decision, `16-open-questions.md`); a control *response* should be + routed to the requesting device only, like other request/response pairs. + +## Natural first picks (suggested order) + +1. **Cron** — list / create / pause / trigger + one-tap suggestions (the + suggestion flow was literally designed for a UI). +2. **Kanban** — view board, add task, complete task. +3. **Model switch** — `model.set` + `model.options` (small, high value). +4. **Sessions** — list/search across platforms. + +All four are read-heavy with cheap writes and low security risk. \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 9173bec..e12ab85 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,6 +38,7 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing. | 14 | [`14-milestones.md`](14-milestones.md) | Planning work / tracking progress. | | 15 | [`15-hermes-reference.md`](15-hermes-reference.md) | **Cheat-sheet** of hermes-agent source to read. | | 16 | [`16-open-questions.md`](16-open-questions.md) | Decisions made + open items. | +| 17 | [`17-future-control-surface.md`](17-future-control-surface.md) | **Backlog** — what the app could control beyond chat (cron, kanban, models, …). | Machine-readable / diagrams: - [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema. diff --git a/docs/protocol/frames.schema.json b/docs/protocol/frames.schema.json index 12c0124..f0de422 100644 --- a/docs/protocol/frames.schema.json +++ b/docs/protocol/frames.schema.json @@ -62,7 +62,8 @@ "sync.done": { "payload": { "cursor": { "type": "integer" } } }, "history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } }, "media.pull.end": { "payload": { "ok": { "type": "boolean" } } }, - "media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } } + "media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } }, + "commands.catalog": { "description": "Response to a commands.catalog request: the gateway's slash-command catalog for the app's '/' drawer. Derived from hermes' COMMAND_REGISTRY (gateway-available subset) plus plugin-registered commands. The app fuzzy-matches the typed prefix client-side.", "payload": { "commands": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "Canonical command with leading slash, e.g. \"/new\"." }, "description": { "type": "string" }, "args_hint": { "type": "string", "description": "Argument placeholder, e.g. \"[name]\"; empty when none." }, "category": { "type": "string", "description": "Registry category (Session, Configuration, Tools & Skills, Info, Exit, Plugin)." }, "aliases": { "type": "array", "items": { "type": "string" }, "description": "Alternative names with leading slash, e.g. [\"/reset\"] for /new." } } } } } } }, "app_to_server": { "hello": { "description": "First frame; auth + caps.", "payload": { "token": { "type": "string" }, "device_id": { "type": "string" }, "device_name": { "type": "string" }, "caps": { "type": "object", "properties": { "min_protocol": {"type":"integer"}, "media": {"type":"boolean"}, "push": {"type":"string"} } }, "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } }, @@ -78,6 +79,7 @@ "channel.set_automation": { "description": "Mark a channel as automation (read-only for the user; it only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. The default channel cannot be marked (error not_found). Answered by a channel.renamed carrying the full entry.", "payload": { "on": { "type": "boolean" } } }, "channel.delete": { "payload": {} }, "channel.list": { "description": "Request the full channel directory; answered by the server_to_app channel.list frame.", "payload": {} }, + "commands.catalog": { "description": "Request the gateway's slash-command catalog (the app's '/' drawer); answered by the server_to_app commands.catalog frame carrying the same id.", "payload": {} }, "search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } }, "sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } }, "history": { "description": "Load a page of full message history for a chat/thread (initial open, scroll-up pagination).", "payload": { "before_message_id": { "type": "string", "description": "Return messages older than this (omit for newest page)." }, "limit": { "type": "integer", "description": "Max messages (default 50, max 200)." } } }, @@ -98,9 +100,7 @@ { "name": "picker.approval", "direction": "server_to_app", "note": "Approval picker prompt. Planned, not implemented (approvals arrive as notification)." }, { "name": "picker.confirm", "direction": "server_to_app", "note": "Confirmation picker prompt. Planned, not implemented." }, { "name": "picker.select", "direction": "app_to_server", "note": "Picker answer. Planned, not implemented." }, - { "name": "commands.catalog", "direction": "server_to_app", "note": "Slash-command catalog. Planned, not implemented." }, - { "name": "commands.catalog", "direction": "app_to_server", "note": "Slash-command catalog request. Planned, not implemented." }, - { "name": "commands.complete", "direction": "server_to_app", "note": "Slash-command autocomplete. Planned, not implemented." }, + { "name": "commands.complete", "direction": "server_to_app", "note": "Slash-command autocomplete. Planned, not implemented (the app fuzzy-matches the commands.catalog list client-side)." }, { "name": "commands.complete", "direction": "app_to_server", "note": "Slash-command autocomplete request. Planned, not implemented." }, { "name": "agent.busy", "direction": "server_to_app", "note": "Agent-busy indicator. Planned, not implemented (typing frames cover it)." }, { "name": "agent.idle", "direction": "server_to_app", "note": "Agent-idle indicator. Planned, not implemented." }, diff --git a/gateway-plugin/adapter.py b/gateway-plugin/adapter.py index 23a219b..03eb676 100644 --- a/gateway-plugin/adapter.py +++ b/gateway-plugin/adapter.py @@ -126,6 +126,66 @@ from .pairing import ( # noqa: E402 from .ws_server import WsServer # noqa: E402 +# --------------------------------------------------------------------------- +# Slash-command catalog (the app's "/" drawer) +# +# Derived from hermes' central ``COMMAND_REGISTRY`` (``hermes_cli/commands.py``) +# — the same source the gateway help text and the Telegram command menu use — +# restricted to commands available on gateway surfaces, plus plugin-registered +# commands. Never raises: any import/attribute problem (code skew between the +# plugin and the hermes checkout) degrades to an empty catalog, so the app's +# drawer simply stays closed. +# --------------------------------------------------------------------------- + +def _slash_command_catalog() -> List[Dict[str, Any]]: + try: + from hermes_cli import commands as hermes_commands + except Exception: + logger.warning( + "android: slash catalog unavailable (hermes_cli.commands import failed)", + exc_info=True, + ) + return [] + + def _entry(name: str, description: str, args_hint: str, category: str, + aliases: List[str]) -> Dict[str, Any]: + return { + "name": f"/{name}", + "description": description, + "args_hint": args_hint or "", + "category": category, + "aliases": [f"/{a}" for a in aliases], + } + + entries: List[Dict[str, Any]] = [] + try: + overrides = hermes_commands._resolve_config_gates() + for cmd in hermes_commands.COMMAND_REGISTRY: + if not hermes_commands._is_gateway_available(cmd, overrides): + continue + entries.append( + _entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, + list(cmd.aliases)) + ) + except Exception: + # Code skew: the private helpers moved. Fall back to the plain + # cli_only filter (config-gated commands are dropped, acceptable). + logger.warning("android: slash catalog fell back to cli_only filter", + exc_info=True) + entries = [ + _entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, + list(cmd.aliases)) + for cmd in hermes_commands.COMMAND_REGISTRY + if not cmd.cli_only + ] + try: + for name, description, args_hint in hermes_commands._iter_plugin_command_entries(): + entries.append(_entry(name, description, args_hint, "Plugin", [])) + except Exception: + pass + return entries + + # --------------------------------------------------------------------------- # M2 — reasoning capture (streaming) # @@ -2244,6 +2304,17 @@ class AndroidAdapter(BasePlatformAdapter): resp.id = frame.id await self._ws_server.send_to(device_id, resp) + # ── Slash-command catalog (app's "/" drawer) ────────────────────────── + + async def on_commands_catalog(self, frame: protocol.Frame, device_id: str) -> None: + """Handle an inbound ``commands.catalog`` request: reply with the + gateway's slash-command catalog (hermes ``COMMAND_REGISTRY``, + gateway-available subset + plugin commands). The app fuzzy-matches + the typed prefix client-side; the catalog is static per gateway run, + so no caching is needed here.""" + resp = protocol.commands_catalog(_slash_command_catalog(), id=frame.id) + await self._ws_server.send_to(device_id, resp) + # ── M3: search (app -> agent) ───────────────────────────────────────── async def on_search(self, frame: protocol.Frame, device_id: str) -> None: @@ -2562,6 +2633,7 @@ class AndroidAdapter(BasePlatformAdapter): "tools": True, # M2: tool.start/progress/end "media": True, # M4: media.upload/offer/pull "search": True, # M3: search frame + "commands_catalog": True, # commands.catalog frame (the "/" drawer) "push": self.push_backend, # M5: ntfy server URL (app listener discovery; "" when not ntfy). "push_ntfy_server": ( diff --git a/gateway-plugin/protocol.py b/gateway-plugin/protocol.py index 8d94242..5a71b71 100644 --- a/gateway-plugin/protocol.py +++ b/gateway-plugin/protocol.py @@ -71,6 +71,9 @@ TYPE_CHANNEL_LIST = "channel.list" TYPE_SEARCH = "search" TYPE_SEARCH_RESULTS = "search.results" +# Slash-command catalog (app's "/" drawer) +TYPE_COMMANDS_CATALOG = "commands.catalog" + # Reconnect catch-up (M3 outbox; extended by M5 push) TYPE_SYNC = "sync" TYPE_SYNC_DONE = "sync.done" @@ -502,6 +505,24 @@ def search_results( ) +# --------------------------------------------------------------------------- +# Slash-command catalog frames +# --------------------------------------------------------------------------- + +def commands_catalog(commands: List[Dict[str, Any]], *, id: Optional[int] = None) -> Frame: + """Response to a ``commands.catalog`` request: the gateway's slash-command + catalog for the app's ``/`` drawer. + + Each entry: ``{name, description, args_hint, category, aliases}`` where + ``name``/``aliases`` carry the leading slash (``"/new"``, ``["/reset"]``). + """ + return Frame( + type=TYPE_COMMANDS_CATALOG, + id=id, + payload={"commands": commands}, + ) + + # --------------------------------------------------------------------------- # Sync frames (M3 outbox) # --------------------------------------------------------------------------- diff --git a/gateway-plugin/ws_server.py b/gateway-plugin/ws_server.py index f67fbe5..24c8ae6 100644 --- a/gateway-plugin/ws_server.py +++ b/gateway-plugin/ws_server.py @@ -380,6 +380,8 @@ class WsServer: await self._adapter.on_channel_delete(frame, device_id) elif frame.type == protocol.TYPE_CHANNEL_LIST: await self._adapter.on_channel_list(frame, device_id) + elif frame.type == protocol.TYPE_COMMANDS_CATALOG: + await self._adapter.on_commands_catalog(frame, device_id) elif frame.type == protocol.TYPE_SEARCH: await self._adapter.on_search(frame, device_id) elif frame.type == protocol.TYPE_SYNC: