From 2d5f7adc7974110debbed09edcd1ab059cb84570 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 8 Aug 2026 19:10:59 -0700 Subject: [PATCH 1/3] docs: design unified command system --- docs/design-docs/slash-commands.md | 382 ++++++++++++++++++++--------- 1 file changed, 268 insertions(+), 114 deletions(-) diff --git a/docs/design-docs/slash-commands.md b/docs/design-docs/slash-commands.md index eeec692d0..a50db94ee 100644 --- a/docs/design-docs/slash-commands.md +++ b/docs/design-docs/slash-commands.md @@ -2,13 +2,15 @@ Slash commands are the power-user interface to Spacebot — session control, memory, tasks, skills, and agent state, accessible from any platform without leaving the conversation. -Currently, Slack has config-driven slash commands that route raw text to an agent. That's not a command system — it's a message routing alias. This doc defines a real one: a central registry, a consistent command set, and support across Discord, Slack, Telegram, Portal, and text-based adapters. +Today there is no command system. There are 11 command strings and 9 behaviors spread across three dispatch sites in `src/agent/channel.rs`: an inline full-string `match` (`/status`, `/quiet`, `/observe`, `/active`, `/mention-only`, `/help`), a special-cased `/agent-id` check, and three prompt rewrites (`/tasks`, `/today`, `/digest`). `/help` is a hand-maintained string array that has already drifted from the match arms. Slack's "slash commands" are config-driven routing aliases, and the `slack_command_agent_id` metadata they attach is written but never read. Portal isn't in the supported-source list, so commands typed in the web UI silently fall through to the LLM as plain text. No command takes arguments. No command checks who sent it. + +This doc defines the real system: one typed registry, declarative access control, per-command busy policy, and native support across Discord, Slack, Telegram, Portal, and text adapters. --- ## Central Registry -All commands are defined once. Every platform derives its command list, help text, and routing from the same source. No per-adapter command tables, no duplication. +All commands are defined once, in a static table. Every surface — adapter registration, parsing, dispatch, help, access checks, busy handling, the agent's prompt block — derives from the same source. Adding a command is one table entry; nothing else to keep in sync. ```rust pub struct CommandDef { @@ -24,178 +26,318 @@ pub struct CommandDef { /// Alternative names that resolve to this command pub aliases: &'static [&'static str], - /// Usage hint shown in help (e.g. "[on|off]", "") - pub args_hint: &'static str, - - /// Tab-completable subcommand options for platforms that support it - pub subcommands: &'static [&'static str], + /// Argument shape — drives validation, help hints, and native + /// platform option types + pub args: ArgSpec, - /// How this command is handled + /// How this command executes pub handler: CommandHandler, /// Which platforms this command is available on pub availability: CommandAvailability, + + /// Who may run it + pub access: CommandAccess, + + /// What happens when it arrives while a turn is in flight + /// (Agent commands only — Control commands never wait) + pub busy: BusyPolicy, } +``` -pub enum CommandHandler { - /// Handled locally by the adapter or frontend — never sent to agent - /// (e.g. /help, /status, /new) - Local, +### `ArgSpec` — arguments as data, not doc strings + +A free-text `args_hint` forces every handler to re-parse its own arguments and gives native platform registration nothing to generate typed options from. A small enum covers every command we have: - /// Forwarded to the agent as a structured message - /// The agent sees the command name and args, not raw text +```rust +pub enum ArgSpec { + None, + /// Optional free text, named for the hint: "[query]" + Optional(&'static str), + /// Required free text: "" + Required(&'static str), + /// Closed set, tab-completable, validated centrally: "[on|off|status]" + Choice(&'static [&'static str]), +} +``` + +The registry validates before dispatch: a `Required` command with no args gets a usage reply without touching its handler; a `Choice` command with an unknown value gets the valid set. Discord option types, Telegram hints, and Portal palette completion all generate from this. + +### `CommandHandler` — two execution planes + +```rust +pub enum CommandHandler { + /// Executes on the channel control plane — settings store, + /// ChannelControlHandle, ProcessControl. Never consumes an agent + /// turn, never enters the channel message queue. Works mid-turn + /// by construction. + Control(fn(&CommandContext, &str) -> ControlOutcome), + + /// Forwarded to the agent as a structured message. The agent sees + /// the command name and args, not raw text. Agent, } +``` -pub struct CommandAvailability { - pub portal: bool, - pub discord: bool, - pub slack: bool, - pub telegram: bool, - pub text_adapters: bool, // Signal, Mattermost, Email, Webhook +The `Control` plane is the important design move. The channel is a serial actor: `run` awaits each turn inline, so anything routed through the message queue sits behind the current turn — today a `/status` sent mid-task waits minutes for an answer that reads two `ArcSwap` fields. Control commands bypass the queue entirely and execute against the handles that already exist outside the turn (`ChannelControlHandle`, `ProcessControl`, `ChannelSettingsStore`). That is why `/stop` can cancel a running turn and `/status` answers instantly while one is in flight — not because of a priority queue, but because they never enter the queue at all. + +`ControlOutcome` covers the few control commands with side effects on the running turn: + +```rust +pub enum ControlOutcome { + /// Reply, nothing else + Reply(CommandReply), + /// Cancel active work on this channel, then reply + CancelThenReply(CommandReply), +} + +pub struct CommandReply { + /// Canonical, adapter-independent core text + pub text: String, + /// Structured values behind the text, for surfaces that render + /// natively (Portal tables, Discord embeds) without recomputing + pub data: Option, } ``` -### `CommandCategory` +Two contracts on Control output: + +- **Surface independence.** A Control handler's output depends only on its args and channel state — never on which adapter invoked it. The core text is identical everywhere; adapters apply only their own decoration (markdown flavor, ephemeral delivery, entity escaping). A registry test pins this by running each Control command against a fixed context across every adapter surface. +- **Data beside text.** `/status`, `/workers`, and `/usage` derive structured values to build their text; `data` carries them so Portal renders a real table and the API returns machine-readable output, without a second code path computing the same numbers. + +### `CommandAccess` — declarative access control + +```rust +pub enum CommandAccess { + Everyone, + /// Requires the sender to be in the authority list for this scope + Authority, +} +``` + +Commands that mutate channel or agent state — `/quiet`, `/active`, `/mention-only`, `/new`, `/stop`, `/model` — are `Authority`. Read-only commands are `Everyone`. Anyone who can post in a bound channel today can flip the agent to observe mode permanently; that ends here. + +Authority is configured on bindings (and as an adapter-instance default): + +```toml +[[bindings]] +agent_id = "orion" +channel = "discord" +guild_id = 123456 +authority = ["91827364"] # platform user ids +``` + +Semantics: + +- **Opt-in by absence.** No `authority` list configured → every command is open to everyone the binding already admits. Zero-migration: existing configs behave exactly as before. +- **Scope-local.** Authority on a guild binding does not grant authority in DMs or another guild. Lists never cross scopes. +- **Discovery floor.** `/help` and `/status` are always allowed regardless of access config, so a denied user can see what they *can* do. Denials name the commands available to that user rather than a bare "no". +- Authorization (who may talk to the agent) stays where it is — bindings and adapter permission snapshots. `CommandAccess` layers *authority* (who may change state) on top; it never widens admission. + +### `BusyPolicy` — Agent commands mid-turn + +Control commands are busy-immune by construction, so this applies only to `Agent` commands: + +```rust +pub enum BusyPolicy { + /// Wait for the current turn, then run as a normal turn (default) + Queue, + /// Refuse mid-turn with a pointer to /stop + Reject, +} +``` + +The invariant: **a recognized command is never silently swallowed.** It is validated, queued with an acknowledgment, rejected with a reason, or executed — but the sender always learns what happened. Unknown `/words` are not commands; they flow to the model as ordinary text, preserving current behavior for messages that merely start with a slash. + +### `CommandCategory` and `CommandAvailability` ```rust pub enum CommandCategory { Session, + Response, // response-mode controls Memory, Tasks, Skills, Info, Config, } + +pub struct CommandAvailability { + pub portal: bool, + pub discord: bool, + pub slack: bool, + pub telegram: bool, + pub text_adapters: bool, // Signal, Mattermost, Twitch, Email, Webhook +} ``` --- ## Command Set +Every command that exists today keeps a home. `Ctl` = Control handler, `Agt` = Agent handler. + ### Session -| Command | Aliases | Description | Args | Handler | -|---------|---------|-------------|------|---------| -| `/new` | `reset` | Start a new conversation | — | Local | -| `/retry` | — | Resend the last message | — | Agent | -| `/undo` | — | Remove the last exchange | — | Agent | -| `/compress` | — | Manually trigger context compaction | — | Agent | -| `/stop` | — | Cancel all active workers and branches | — | Agent | -| `/background` | `bg` | Run a prompt without blocking the conversation | `` | Agent | +| Command | Aliases | Args | Handler | Busy | Access | Description | +|---------|---------|------|---------|------|--------|-------------| +| `/new` | `reset` | — | Ctl | — | Authority | Start a new conversation (cancels active work) | +| `/stop` | `cancel` | — | Ctl | — | Authority | Cancel active workers, branches, and the current turn | +| `/retry` | — | — | Agt | Reject | Everyone | Resend the last message | +| `/undo` | — | — | Agt | Reject | Authority | Remove the last exchange | +| `/compress` | `compact` | — | Agt | Reject | Authority | Manually trigger context compaction | +| `/background` | `bg` | `` | Agt | Queue | Everyone | Run a prompt in a branch without blocking the conversation | + +### Response mode + +| Command | Aliases | Args | Handler | Busy | Access | Description | +|---------|---------|------|---------|------|--------|-------------| +| `/active` | — | — | Ctl | — | Authority | Respond to all messages | +| `/mention-only` | — | — | Ctl | — | Authority | Respond only when mentioned | +| `/quiet` | `observe` | — | Ctl | — | Authority | Observe without responding | + +`/quiet` absorbs today's `/observe` as an alias. Mode commands work in every mode — `/active` must be able to rescue an observing channel, so control dispatch runs before response-mode suppression, as the current code already orders it. ### Memory -| Command | Aliases | Description | Args | Handler | -|---------|---------|-------------|------|---------| -| `/memory` | `memories` | Search or list memories | `[query]` | Agent | -| `/remember` | — | Save something to memory immediately | `` | Agent | +| Command | Aliases | Args | Handler | Busy | Access | Description | +|---------|---------|------|---------|------|--------|-------------| +| `/memory` | `memories` | `[query]` | Agt | Queue | Everyone | Search or list memories | +| `/remember` | — | `` | Agt | Queue | Everyone | Save something to memory immediately | ### Tasks & Goals -| Command | Aliases | Description | Args | Handler | -|---------|---------|-------------|------|---------| -| `/tasks` | — | List tasks | `[status]` | Agent | -| `/goals` | — | List active goals | — | Agent | -| `/approve` | — | Approve a pending task | `[id]` | Agent | +| Command | Aliases | Args | Handler | Busy | Access | Description | +|---------|---------|------|---------|------|--------|-------------| +| `/tasks` | — | `[status]` | Agt | Queue | Everyone | List tasks | +| `/today` | — | — | Agt | Queue | Everyone | Tasks snapshot: in progress and up next | +| `/digest` | — | — | Agt | Queue | Everyone | Day digest: decisions, themes, open loops | +| `/goals` | — | — | Agt | Queue | Everyone | List active goals | +| `/approve` | — | `[id]` | Agt | Queue | Authority | Approve a pending task | + +`/tasks`, `/today`, `/digest` stop being raw-text prompt rewrites; they arrive as structured commands and the prompt template renders the instruction, so the wording lives in Jinja with the rest of the prompts instead of Rust string literals. ### Skills -| Command | Aliases | Description | Args | Handler | -|---------|---------|-------------|------|---------| -| `/skills` | — | List installed skills | — | Agent | -| `/skill` | — | Invoke a skill by name | `` | Agent | +| Command | Aliases | Args | Handler | Busy | Access | Description | +|---------|---------|------|---------|------|--------|-------------| +| `/skills` | — | — | Agt | Queue | Everyone | List installed skills | +| `/skill` | — | `` | Agt | Queue | Everyone | Invoke a skill by name | ### Info -| Command | Aliases | Description | Args | Handler | -|---------|---------|-------------|------|---------| -| `/help` | `commands` | Show available commands | — | Local | -| `/status` | — | Show conversation info: model, turn count, context usage | — | Local | -| `/workers` | — | List active workers and their status | — | Agent | -| `/usage` | — | Show token usage for this conversation | — | Local | +| Command | Aliases | Args | Handler | Busy | Access | Description | +|---------|---------|------|---------|------|--------|-------------| +| `/help` | `commands` | — | Ctl | — | Everyone | Show available commands | +| `/status` | — | — | Ctl | — | Everyone | Conversation info: model, mode, context usage, active work | +| `/agent-id` | — | — | Ctl | — | Everyone | Print the agent id (deterministic, for identity checks) | +| `/workers` | — | — | Ctl | — | Everyone | List active workers and branches with status | +| `/usage` | — | — | Ctl | — | Everyone | Token usage for this conversation | + +`/workers` moves from Agent to Control: worker state is readable from `ProcessControl` without an LLM turn, and its main use is checking on work that is currently running — exactly when an Agent command would be stuck in the queue. ### Config -| Command | Aliases | Description | Args | Handler | -|---------|---------|-------------|------|---------| -| `/model` | — | Switch model for this conversation | `[model]` | Local | -| `/voice` | — | Toggle voice mode | `[on\|off\|status]` | Local | +| Command | Aliases | Args | Handler | Busy | Access | Description | +|---------|---------|------|---------|------|--------|-------------| +| `/model` | — | `[model]` | Ctl | — | Authority | Show or switch the model for this conversation | +| `/voice` | — | `[on\|off\|status]` | Ctl | — | Authority | Toggle voice mode | --- -## Platform Behaviour +## Parsing -### Portal - -Portal has no slash commands today. Commands are parsed client-side when input starts with `/`. A command palette appears as the user types, showing matching commands with descriptions and args hints. Submit runs the command. +One shared parser in the registry, used by every adapter: -`Local` commands are handled in the frontend (no round trip). `Agent` commands are sent to `POST /api/channels/:id/command` with `{ name, args }`, which formats a structured inbound message and injects it into the channel. +```rust +pub struct ParsedCommand { + pub def: &'static CommandDef, + pub args: String, +} -### Discord +impl CommandRegistry { + pub fn parse(&self, text: &str) -> ParseResult; +} -Native Discord slash command interactions via Serenity. Commands are registered with Discord's application commands API on startup (global or per-guild, configurable). Discord surfaces them in its built-in command palette with descriptions and argument hints. +pub enum ParseResult { + /// Recognized command, args validated against ArgSpec + Command(ParsedCommand), + /// Recognized command, invalid args — reply with usage, no dispatch + Usage(&'static CommandDef, String), + /// Leading slash but no matching name — ordinary text + NotACommand, +} +``` -`Local` commands return an ephemeral interaction response immediately. `Agent` commands ack the interaction within 3 seconds and defer — the real response comes through the normal message path. +Normalization rules, applied in `parse`: -### Slack +- strip the leading slash; split on first whitespace; case-insensitive lookup across names and aliases; +- strip a `@botname` suffix from the command token (Telegram sends `/status@spacebot` in groups); +- reject tokens containing `/` so file paths pasted at line start never parse as commands; +- normalize smart dashes in args — iOS autocorrects `--` to `—` and `-` to `–`, which silently breaks any flag-style argument typed from a phone. -Replaces the current config-driven slash command system. Instead of per-command Slack app registrations pointing at different agents, a single `/spacebot` command with subcommands covers everything: `/spacebot retry`, `/spacebot tasks`, etc. Aliases work: `/spacebot bg do the thing`. +Coalescing interaction: command messages are already exempt from coalescing, but the current flush ordering runs a full LLM turn on the buffered batch *before* handling the command. Control commands dispatch immediately without flushing; the buffer keeps its own debounce clock. Agent commands with `Queue` flush first — they're joining the conversation, so order matters. -Slack is acked immediately; response delivered as a follow-up message. Removes `SlackCommandConfig` from config — no more per-command agent routing config required. +--- -### Telegram +## Dispatch -Telegram's `/command` syntax is native. The bot's command menu (set via `setMyCommands`) is generated from the registry at startup — description truncated to Telegram's 256-char limit, name to 32 chars. Commands are parsed from incoming messages that start with `/`. +Parsing happens in the messaging layer, before the message reaches a channel. The flow per inbound message starting with `/`: -`Local` commands reply with formatted text. `Agent` commands forward to the channel handler as a structured message. +1. `CommandRegistry::parse(text)`. +2. `NotACommand` → normal message path, untouched. +3. `Usage` → reply (ephemeral where supported), done. +4. Access check against the binding's authority list. Denied → reply naming the commands the sender *can* run, done. +5. `Control` → execute on the control plane, reply, done. No inbound message is created. +6. `Agent` → construct `MessageContent::Command { name: &str, args: String }` (a new variant beside `Interaction`) and inject into the channel. Busy policy applies at the channel boundary: `Reject` while a turn is in flight replies immediately; `Queue` enqueues normally. -### Text Adapters (Signal, Mattermost, Email, Webhook) +Channels receive the structured command, not raw text. The system prompt includes a commands block generated from the registry — the agent doesn't parse `/approve 3` out of a string, and the block stays current by construction. -Messages starting with `/` are parsed as commands. If the first word matches a known command name or alias, it's dispatched as a command with the remainder as args. No native platform command UI — `/help` is the discovery mechanism. +Replies from Control commands and rejections use `OutboundResponse::Ephemeral` — already implemented and correctly degraded to a plain message by every adapter that lacks ephemeral support. --- -## Routing +## Platform Behaviour -Command dispatch lives in the messaging layer, before the message reaches a channel. Each adapter calls `CommandRegistry::parse(text)` which returns `Option`. If it's a `Local` command, the adapter handles it directly and no inbound message is created. If it's an `Agent` command, an `InboundMessage` is created with `MessageKind::Command { name, args }` instead of `MessageKind::Text`. +### Portal -Channels receive the structured command, not raw text. The agent's system prompt includes a commands block explaining what commands exist and what they mean — the agent doesn't have to parse `/approve 3` from a text string. +Commands are parsed client-side as the user types `/` — a command palette shows matching commands, descriptions, and arg hints, generated from the registry (served at `GET /api/commands`). Control commands round-trip through `POST /api/channels/:id/command` `{ name, args }`; Agent commands go through the same endpoint and inject as structured messages. Portal joins the supported sources — the current silent no-op in the web UI is a bug, not a policy. -```rust -pub struct ParsedCommand { - pub def: &'static CommandDef, - pub args: String, -} -``` +### Discord -`CommandRegistry::parse()` strips the leading slash, splits on the first whitespace, and does a case-insensitive lookup across names and aliases. +Native application commands via Serenity, registered on startup (global or per-guild, configurable). Discord's constraints need explicit handling: ---- +- **100-command cap, all-or-nothing.** One over-limit command makes Discord reject the entire batch. Registration is cap-aware: core commands register first in table order, overflow is dropped with one actionable log line counting what was cut. +- **Diff-only sync.** Fetch live commands, key by name, delete obsolete entries *first* (to free cap headroom), then create/update changed ones. Re-registering an identical set is a no-op — no churn on every restart. +- `ArgSpec` maps to typed options: `Choice` becomes a native choice list, `Required`/`Optional` become string options with the hint as description. +- `Control` commands answer with an ephemeral interaction response. `Agent` commands defer the interaction within the 3-second window; the real response arrives through the normal message path. -## `/help` Output +### Slack -Grouped by category, adapts to platform: +Replaces the config-driven alias system. One `/spacebot` app command with subcommands covers everything: `/spacebot status`, `/spacebot bg do the thing`. Aliases resolve normally. Acked immediately; response delivered as a follow-up, ephemeral for Control replies. `SlackCommandConfig`, its loader, and the never-read `slack_command_agent_id` metadata are deleted; any existing `/command → agent_id` config maps to a binding. -**Portal / Discord (rich):** -``` -Session - /new Start a new conversation - /retry Resend the last message - /stop Cancel active workers - -Memory - /memory Search or list memories - /remember Save something to memory -... -``` +### Telegram + +`/command` syntax is native. The bot's menu is generated from the registry at startup via `setMyCommands` — descriptions truncated to Telegram's 256-char limit, names to 32 chars, `Choice` values appended to the description as hints. Group-chat `/cmd@botname` addressing is handled in the shared parser. + +### Text adapters (Signal, Mattermost, Twitch, Email, Webhook) + +Messages starting with `/` go through the shared parser. No native command UI — `/help` is the discovery mechanism. Twitch and Signal keep the behavior they have today, minus the inline match. + +### Parity enforcement + +A registry test walks every command × platform and fails when a command is available on one platform but silently missing from another without an explicit, named exemption (e.g. Slack's reserved slash names). Platform caps become compile-time decisions instead of silent drops. + +--- + +## `/help` + +Generated from the registry, grouped by category, filtered by the caller's availability and access — a user never sees a command they can't run. Rich format for Portal/Discord, compact for Telegram/text: -**Telegram / text (compact):** ``` /new — new conversation -/retry — resend last message +/status — conversation info /memory [query] — search memories -/tasks [status] — list tasks /help — show this ``` @@ -205,35 +347,47 @@ Memory **`src/commands/`** — new module -- `registry.rs` — `CommandDef`, `CommandRegistry`, static registry, `parse()` -- `handler.rs` — local command dispatch (help, status, usage, model, voice, new) +- `registry.rs` — `CommandDef`, `ArgSpec`, `CommandRegistry`, the static table, `parse()` +- `control.rs` — Control handler implementations against `CommandContext` (channel handle, settings store, process control, runtime config) +- `access.rs` — authority resolution from bindings + +**Core changes:** -**Changes per adapter:** -- `portal.rs` — parse `/` prefix in inbound messages, route local commands before forwarding -- `discord.rs` — register application commands on startup, handle interaction events -- `slack.rs` — replace `SlackCommandConfig` dispatch with registry parse on all messages; single `/spacebot` app command -- `telegram.rs` — generate `setMyCommands` from registry, parse `/command` prefix -- text adapters — parse `/` prefix via shared utility +- `MessageContent::Command { name, args }` variant; arms in the handful of existing matches +- binding config gains `authority`; adapter instances gain a default list; hot-reload through the existing permission-snapshot path +- `channel.rs` loses `try_handle_builtin_ops_commands`, the `/agent-id` special case, `rewrite_tool_routed_command_prompt`, and the hand-written help array +- prompt template gains a commands block rendered from the registry **API:** + ``` -POST /api/channels/:id/command -Body: { name: string, args: string } +GET /api/commands registry projection for Portal palette +POST /api/channels/:id/command { name, args } → { text, data } ``` -Used by Portal frontend for `Agent` commands. `Local` commands never hit the API. +Control commands return their `CommandReply` directly in the response body; Portal renders `data` natively when present and falls back to `text`. ---- +### Phase 1 — Registry and port + +Registry, parser, `MessageContent::Command`, control plane. Port all 11 existing command strings behavior-preserving (same replies, same ordering guarantees), delete the three dispatch sites. Portal joins supported sources. `/help` generates. + +### Phase 2 — Access and busy policy + +Binding `authority` config, access checks with the discovery floor, denial messages. Busy handling: Control bypass, `Reject` replies, queue acknowledgments. Coalesce-flush ordering fix. + +### Phase 3 — Native registration + +Discord application commands with cap-aware diff sync. Telegram `setMyCommands`. Slack `/spacebot` umbrella; delete `SlackCommandConfig`. Parity test. -## What This Replaces +### Phase 4 — Command set completion -`SlackCommandConfig` and the current per-command agent routing config in `config.messaging.slack.commands` are removed. Slack slash commands go through the registry. The migration path: any existing `/command → agent_id` config becomes a binding. +New commands from the tables above (`/new`, `/stop`, `/retry`, `/memory`, `/remember`, `/approve`, `/workers`, `/usage`, `/model`, `/voice`, …), prompt commands block, structured `/tasks`/`/today`/`/digest` through templates. --- ## Non-Goals -- **Custom user-defined commands** — skills serve this purpose -- **Per-channel command availability** — registry is instance-wide -- **Command permissions** — commands respect existing channel permission rules; no separate command ACL -- **CLI/TUI** — out of scope +- **Custom user-defined commands** — skills serve this purpose; the registry stays static +- **Per-channel command availability** — availability is per-platform, access is per-binding; no per-channel toggles +- **CLI flag parity** — the clap CLI is a separate surface (see `cli-coverage.md`); the registry is a plain static table precisely so a future `spacebot chat` REPL can consume it, but nothing in these phases depends on that +- **Role systems** — authority is a flat id list per scope; roles, groups, and wildcards wait for a real multi-tenant story From bca84f9a254764684677758392b42040a62a21c0 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 8 Aug 2026 19:39:35 -0700 Subject: [PATCH 2/3] feat(commands): typed slash-command registry All slash commands now live in one static table in src/commands. Parsing, dispatch, and /help derive from it, replacing the three dispatch sites in channel.rs (inline match, /agent-id special case, prompt rewrites) and the hand-maintained help array that had drifted from the real command set. - CommandDef with ArgSpec (None/Optional/Required/Choice), Control vs Agent handlers, and category grouping - shared parser: case-insensitive names+aliases, telegram /cmd@botname strip, iOS smart-dash normalization in args, file-path rejection, trailing text on no-arg commands stays conversational - MessageContent::Command wire variant, renders as "/name args" so the text path dispatches it identically - commands now work on every adapter, including portal (previously a silent no-op in the web UI) - /quiet gains /observe as an alias; /help gains /commands --- src/agent/channel.rs | 199 ++++++--------- src/commands/mod.rs | 10 + src/commands/registry.rs | 524 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 17 ++ 4 files changed, 626 insertions(+), 124 deletions(-) create mode 100644 src/commands/mod.rs create mode 100644 src/commands/registry.rs diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 301b146c2..87edf17ab 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -1100,37 +1100,6 @@ impl Channel { self.control_handle.clone() } - fn rewrite_tool_routed_command_prompt(&self, raw_text: &str) -> Option { - match raw_text.trim() { - "/tasks" => Some( - "use channel tools to fetch my ready tasks (limit 10) and reply exactly with:\n\ - - header: tasks (ready):\n\ - - each line: - # [] \n\ - if no tasks are ready, reply exactly: tasks (ready): none" - .to_string(), - ), - "/today" => Some( - "use channel tools to build a local tasks snapshot and reply exactly in this format:\n\ - - first line: today (local tasks snapshot):\n\ - - section 1: in-progress tasks (up to 5), each line: #<task_number> [<priority>] <title>\n\ - - section 2: up next ready tasks (up to 5), each line: #<task_number> [<priority>] <title>\n\ - if a section is empty use:\n\ - - in progress: none\n\ - - up next (ready): none" - .to_string(), - ), - "/digest" => Some( - "using available tools and channel context, generate a concise day digest from local 00:00 to now with exactly this order:\n\ - 1) top decisions\n\ - 2) key convo themes\n\ - 3) open loops\n\ - keep it practical and concise; if there are no meaningful updates, reply exactly: no material updates today." - .to_string(), - ), - _ => None, - } - } - fn compute_listen_mode_invocation( &self, message: &InboundMessage, @@ -1212,32 +1181,20 @@ impl Channel { } } - async fn try_handle_builtin_ops_commands( + /// Execute a control-plane command. These run deterministically against + /// channel state and never consume an agent turn. + async fn handle_control_command( &mut self, - raw_text: &str, - message: &InboundMessage, - ) -> Result<bool> { - if message.source == "system" { - return Ok(false); - } - let supported_source = matches!( - message.source.as_str(), - "telegram" | "discord" | "slack" | "twitch" | "signal" - ); - if !supported_source { - return Ok(false); - } - - let text = raw_text.trim(); - if !text.starts_with('/') { - return Ok(false); - } - - let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); - let now_line = temporal_context.current_time_line(); + def: &'static crate::commands::CommandDef, + action: crate::commands::ControlAction, + ) { + use crate::commands::ControlAction; - match text { - "/status" => { + match action { + ControlAction::Status => { + let temporal_context = + TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); + let now_line = temporal_context.current_time_line(); let routing = self.deps.runtime_config.routing.load(); let channel_model = self .resolved_settings @@ -1270,59 +1227,33 @@ impl Channel { branch_model, now_line ); - self.send_builtin_text(body, "status").await; - return Ok(true); - } - "/quiet" | "/observe" => { - self.set_response_mode(ResponseMode::Observe).await; - self.send_builtin_text( - "observe mode enabled. i'll learn from this conversation but won't respond." - .to_string(), - "observe", - ) - .await; - return Ok(true); + self.send_builtin_text(body, def.name).await; } - "/active" => { - self.set_response_mode(ResponseMode::Active).await; - self.send_builtin_text( - "active mode enabled. i'll respond normally in this chat.".to_string(), - "active", - ) - .await; - return Ok(true); + ControlAction::SetResponseMode(mode) => { + self.set_response_mode(mode).await; + let confirmation = match mode { + ResponseMode::Active => { + "active mode enabled. i'll respond normally in this chat." + } + ResponseMode::Observe => { + "observe mode enabled. i'll learn from this conversation but won't respond." + } + ResponseMode::MentionOnly => { + "mention-only mode enabled. i'll only respond when @mentioned or replied to." + } + }; + self.send_builtin_text(confirmation.to_string(), def.name) + .await; } - "/mention-only" => { - self.set_response_mode(ResponseMode::MentionOnly).await; - self.send_builtin_text( - "mention-only mode enabled. i'll only respond when @mentioned or replied to." - .to_string(), - "mention-only", - ) - .await; - return Ok(true); + ControlAction::Help => { + self.send_builtin_text(crate::commands::REGISTRY.help_text(), def.name) + .await; } - "/help" => { - let lines = [ - "commands:".to_string(), - "- /status: current mode, models, binding snapshot".to_string(), - "- /today: in-progress + ready task snapshot".to_string(), - "- /tasks: ready task list".to_string(), - "- /digest: one-shot day digest (00:00 -> now)".to_string(), - "- /observe: learn from conversation, never respond".to_string(), - "- /mention-only: only respond when @mentioned, replied to, or given a command" - .to_string(), - "- /active: normal reply mode".to_string(), - "- /agent-id: runtime agent id".to_string(), - ]; - let body = lines.join("\n"); - self.send_builtin_text(body, "help").await; - return Ok(true); + ControlAction::AgentId => { + self.send_builtin_text(self.deps.agent_id.to_string(), def.name) + .await; } - _ => {} } - - Ok(false) } /// Run the channel event loop. @@ -1483,6 +1414,7 @@ impl Channel { .as_deref() .is_some_and(|value| value.trim_start().starts_with('/')), crate::MessageContent::Interaction { .. } => false, + crate::MessageContent::Command { .. } => true, }; if looks_like_command { return false; @@ -1684,8 +1616,10 @@ impl Channel { crate::MessageContent::Media { text, attachments } => { (text.clone().unwrap_or_default(), attachments.clone()) } - // Render interactions as their Display form so the LLM sees plain text. - crate::MessageContent::Interaction { .. } => { + // Render interactions and commands as their Display form + // so the LLM sees plain text. + crate::MessageContent::Interaction { .. } + | crate::MessageContent::Command { .. } => { (message.content.to_string(), Vec::new()) } }; @@ -2073,8 +2007,12 @@ impl Channel { crate::MessageContent::Media { text, attachments } => { (text.clone().unwrap_or_default(), attachments.clone()) } - // Render interactions as their Display form so the LLM sees plain text. - crate::MessageContent::Interaction { .. } => (message.content.to_string(), Vec::new()), + // Render interactions and commands as their Display form so the + // LLM sees plain text; a Command renders as "/name args" and is + // dispatched by the same parse below. + crate::MessageContent::Interaction { .. } | crate::MessageContent::Command { .. } => { + (message.content.to_string(), Vec::new()) + } }; // Save attachments to disk when enabled, capturing bytes for LLM reuse @@ -2107,11 +2045,27 @@ impl Channel { self.persist_inbound_user_message(&message, &raw_text, saved_metas.as_deref()); self.track_participant_from_message(&message).await; - // Deterministic built-in command: bypass model output drift for agent identity checks. - if message.source != "system" && raw_text.trim() == "/agent-id" { - self.send_builtin_text(self.deps.agent_id.to_string(), "agent-id") - .await; - return Ok(()); + // Slash-command dispatch. Control commands execute deterministically + // on the spot and never consume an agent turn; agent commands are + // rewritten into their instruction below. System messages are never + // commands, and unrecognized "/words" flow to the model as text. + let parsed_command = if message.source == "system" { + crate::commands::ParseResult::NotACommand + } else { + crate::commands::REGISTRY.parse(&raw_text) + }; + match &parsed_command { + crate::commands::ParseResult::Command(cmd) => { + if let crate::commands::CommandHandler::Control(action) = cmd.def.handler { + self.handle_control_command(cmd.def, action).await; + return Ok(()); + } + } + crate::commands::ParseResult::Usage(_, usage) => { + self.send_builtin_text(usage.clone(), "command-usage").await; + return Ok(()); + } + crate::commands::ParseResult::NotACommand => {} } // Deterministic liveness ping for Telegram mentions. @@ -2155,18 +2109,15 @@ impl Channel { )?); } - if self - .try_handle_builtin_ops_commands(&raw_text, &message) - .await? - { - return Ok(()); - } - - let rewritten_text = if message.source == "system" { - raw_text.clone() - } else { - self.rewrite_tool_routed_command_prompt(&raw_text) - .unwrap_or_else(|| raw_text.clone()) + let rewritten_text = match &parsed_command { + crate::commands::ParseResult::Command(cmd) => match cmd.def.handler { + crate::commands::CommandHandler::Agent( + crate::commands::AgentAction::PromptRewrite(instruction), + ) => instruction.to_string(), + // Control commands returned above. + crate::commands::CommandHandler::Control(_) => raw_text.clone(), + }, + _ => raw_text.clone(), }; let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 000000000..37aee95b2 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,10 @@ +//! Slash commands: the typed registry and its dispatch types. +//! +//! Design: `docs/design-docs/slash-commands.md`. + +mod registry; + +pub use registry::{ + AgentAction, ArgSpec, COMMANDS, CommandCategory, CommandDef, CommandHandler, CommandRegistry, + ControlAction, ParseResult, ParsedCommand, REGISTRY, +}; diff --git a/src/commands/registry.rs b/src/commands/registry.rs new file mode 100644 index 000000000..209808124 --- /dev/null +++ b/src/commands/registry.rs @@ -0,0 +1,524 @@ +//! Typed slash-command registry. +//! +//! Every command is defined once in [`COMMANDS`]. Parsing, dispatch, and +//! `/help` all derive from the same table, so adding a command is one entry +//! here and a handler arm — nothing else to keep in sync. See +//! `docs/design-docs/slash-commands.md` for the full design. + +use crate::conversation::settings::ResponseMode; + +/// Definition of a single slash command. +#[derive(Debug)] +pub struct CommandDef { + /// Canonical name without the slash (e.g. "status"). + pub name: &'static str, + /// Short description shown in `/help` and platform menus. + pub description: &'static str, + /// Grouping for `/help` display. + pub category: CommandCategory, + /// Alternative names that resolve to this command. + pub aliases: &'static [&'static str], + /// Argument shape — drives validation and help hints. + pub args: ArgSpec, + /// How this command executes. + pub handler: CommandHandler, +} + +/// Grouping for `/help` display, rendered in [`CATEGORY_ORDER`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandCategory { + Session, + Response, + Memory, + Tasks, + Skills, + Info, + Config, +} + +/// Argument shape for a command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArgSpec { + /// Takes no arguments. Trailing text means the message is conversation + /// that happens to start with a command word ("/status update: shipped") + /// and must reach the model untouched. + None, + /// Optional free text, named for the help hint: "[query]". + Optional(&'static str), + /// Required free text: "<prompt>". Missing args produce a usage reply. + Required(&'static str), + /// Closed set, validated case-insensitively: "[on|off|status]". + /// Empty args are allowed (commands treat that as a status query). + Choice(&'static [&'static str]), +} + +impl ArgSpec { + /// Help hint for this argument shape, e.g. "[query]" or "[on|off]". + pub fn hint(&self) -> Option<String> { + match self { + ArgSpec::None => None, + ArgSpec::Optional(hint) | ArgSpec::Required(hint) => Some((*hint).to_string()), + ArgSpec::Choice(options) => Some(format!("[{}]", options.join("|"))), + } + } +} + +/// How a command executes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandHandler { + /// Executes deterministically on the channel control plane — settings + /// store, process control, runtime config. Never consumes an agent turn. + Control(ControlAction), + /// Forwarded to the agent as a normal turn. + Agent(AgentAction), +} + +/// Control-plane operations. The channel executes these directly; the +/// registry stays free of channel internals. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ControlAction { + /// Report mode, resolved models, adapter, and current time. + Status, + /// Persist a new response mode for this channel. + SetResponseMode(ResponseMode), + /// Render the generated command list. + Help, + /// Print the runtime agent id. Deterministic so identity checks bypass + /// model output drift. + AgentId, +} + +/// Agent-turn commands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentAction { + /// Replace the raw text with a deterministic instruction rendered as a + /// normal agent turn. The instruction wording moves into the prompt + /// template system when commands arrive structured (design phase 4). + PromptRewrite(&'static str), +} + +/// A successfully parsed command with validated, normalized arguments. +#[derive(Debug)] +pub struct ParsedCommand { + pub def: &'static CommandDef, + pub args: String, +} + +/// Result of [`CommandRegistry::parse`]. +#[derive(Debug)] +pub enum ParseResult { + /// Recognized command with valid arguments. + Command(ParsedCommand), + /// Recognized command with invalid arguments — reply with usage text, + /// do not dispatch. + Usage(&'static CommandDef, String), + /// Not a command; the text flows to the model untouched. + NotACommand, +} + +/// Lookup and parsing over a static command table. +pub struct CommandRegistry { + defs: &'static [CommandDef], +} + +/// Category display order and labels for `/help`. +const CATEGORY_ORDER: &[(CommandCategory, &str)] = &[ + (CommandCategory::Session, "session"), + (CommandCategory::Response, "response mode"), + (CommandCategory::Memory, "memory"), + (CommandCategory::Tasks, "tasks"), + (CommandCategory::Skills, "skills"), + (CommandCategory::Info, "info"), + (CommandCategory::Config, "config"), +]; + +impl CommandRegistry { + pub const fn new(defs: &'static [CommandDef]) -> Self { + Self { defs } + } + + /// Resolve a bare command token (no slash) across names and aliases, + /// case-insensitively. + pub fn resolve(&self, name: &str) -> Option<&'static CommandDef> { + self.defs.iter().find(|def| { + def.name.eq_ignore_ascii_case(name) + || def + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(name)) + }) + } + + /// Parse message text into a command. + /// + /// Normalization: strips a Telegram-style `@botname` suffix from the + /// command token, rejects tokens containing `/` so pasted file paths + /// never parse as commands, and un-mangles smart dashes in arguments + /// (iOS autocorrects `--` to `—` and `-` to `–`). + pub fn parse(&self, text: &str) -> ParseResult { + let trimmed = text.trim(); + let Some(body) = trimmed.strip_prefix('/') else { + return ParseResult::NotACommand; + }; + let (token, rest) = match body.split_once(char::is_whitespace) { + Some((token, rest)) => (token, rest), + None => (body, ""), + }; + if token.is_empty() || token.contains('/') { + return ParseResult::NotACommand; + } + let token = token.split('@').next().unwrap_or(token); + let Some(def) = self.resolve(token) else { + return ParseResult::NotACommand; + }; + + let args = normalize_smart_dashes(rest.trim()); + match def.args { + ArgSpec::None => { + if args.is_empty() { + ParseResult::Command(ParsedCommand { def, args }) + } else { + ParseResult::NotACommand + } + } + ArgSpec::Optional(_) => ParseResult::Command(ParsedCommand { def, args }), + ArgSpec::Required(hint) => { + if args.is_empty() { + ParseResult::Usage(def, format!("usage: /{} {}", def.name, hint)) + } else { + ParseResult::Command(ParsedCommand { def, args }) + } + } + ArgSpec::Choice(options) => { + if args.is_empty() || options.iter().any(|opt| opt.eq_ignore_ascii_case(&args)) { + ParseResult::Command(ParsedCommand { + def, + args: args.to_ascii_lowercase(), + }) + } else { + ParseResult::Usage(def, format!("usage: /{} [{}]", def.name, options.join("|"))) + } + } + } + } + + /// Render the `/help` body: every command grouped by category, with + /// aliases and argument hints. Generated so it cannot drift from the + /// table. + pub fn help_text(&self) -> String { + let mut out = String::from("commands:"); + for (category, label) in CATEGORY_ORDER { + let defs: Vec<&CommandDef> = self + .defs + .iter() + .filter(|def| def.category == *category) + .collect(); + if defs.is_empty() { + continue; + } + out.push_str("\n\n"); + out.push_str(label); + out.push(':'); + for def in defs { + out.push_str("\n- /"); + out.push_str(def.name); + if let Some(hint) = def.args.hint() { + out.push(' '); + out.push_str(&hint); + } + if !def.aliases.is_empty() { + let alias_list = def + .aliases + .iter() + .map(|alias| format!("/{alias}")) + .collect::<Vec<_>>() + .join(", "); + out.push_str(&format!(" (or {alias_list})")); + } + out.push_str(": "); + out.push_str(def.description); + } + } + out + } + + pub fn defs(&self) -> &'static [CommandDef] { + self.defs + } +} + +/// iOS autocorrects `--` to an em dash and `-` to an en dash, silently +/// breaking flag-style arguments typed from a phone. The double-em case is +/// replaced first so a mangled `----` collapses correctly. +fn normalize_smart_dashes(args: &str) -> String { + args.replace("\u{2014}\u{2014}", "--") + .replace('\u{2014}', "--") + .replace('\u{2013}', "-") +} + +const TASKS_PROMPT: &str = "use channel tools to fetch my ready tasks (limit 10) and reply exactly with:\n\ + - header: tasks (ready):\n\ + - each line: - #<task_number> [<priority>] <title>\n\ + if no tasks are ready, reply exactly: tasks (ready): none"; + +const TODAY_PROMPT: &str = "use channel tools to build a local tasks snapshot and reply exactly in this format:\n\ + - first line: today (local tasks snapshot):\n\ + - section 1: in-progress tasks (up to 5), each line: #<task_number> [<priority>] <title>\n\ + - section 2: up next ready tasks (up to 5), each line: #<task_number> [<priority>] <title>\n\ + if a section is empty use:\n\ + - in progress: none\n\ + - up next (ready): none"; + +const DIGEST_PROMPT: &str = "using available tools and channel context, generate a concise day digest from local 00:00 to now with exactly this order:\n\ + 1) top decisions\n\ + 2) key convo themes\n\ + 3) open loops\n\ + keep it practical and concise; if there are no meaningful updates, reply exactly: no material updates today."; + +/// The command table. Order within a category is display order in `/help`. +pub static COMMANDS: &[CommandDef] = &[ + CommandDef { + name: "active", + description: "normal reply mode", + category: CommandCategory::Response, + aliases: &[], + args: ArgSpec::None, + handler: CommandHandler::Control(ControlAction::SetResponseMode(ResponseMode::Active)), + }, + CommandDef { + name: "mention-only", + description: "only respond when @mentioned, replied to, or given a command", + category: CommandCategory::Response, + aliases: &[], + args: ArgSpec::None, + handler: CommandHandler::Control(ControlAction::SetResponseMode(ResponseMode::MentionOnly)), + }, + CommandDef { + name: "quiet", + description: "learn from conversation, never respond", + category: CommandCategory::Response, + aliases: &["observe"], + args: ArgSpec::None, + handler: CommandHandler::Control(ControlAction::SetResponseMode(ResponseMode::Observe)), + }, + CommandDef { + name: "tasks", + description: "ready task list", + category: CommandCategory::Tasks, + aliases: &[], + args: ArgSpec::None, + handler: CommandHandler::Agent(AgentAction::PromptRewrite(TASKS_PROMPT)), + }, + CommandDef { + name: "today", + description: "in-progress + ready task snapshot", + category: CommandCategory::Tasks, + aliases: &[], + args: ArgSpec::None, + handler: CommandHandler::Agent(AgentAction::PromptRewrite(TODAY_PROMPT)), + }, + CommandDef { + name: "digest", + description: "one-shot day digest (00:00 -> now)", + category: CommandCategory::Tasks, + aliases: &[], + args: ArgSpec::None, + handler: CommandHandler::Agent(AgentAction::PromptRewrite(DIGEST_PROMPT)), + }, + CommandDef { + name: "status", + description: "current mode, models, binding snapshot", + category: CommandCategory::Info, + aliases: &[], + args: ArgSpec::None, + handler: CommandHandler::Control(ControlAction::Status), + }, + CommandDef { + name: "help", + description: "show available commands", + category: CommandCategory::Info, + aliases: &["commands"], + args: ArgSpec::None, + handler: CommandHandler::Control(ControlAction::Help), + }, + CommandDef { + name: "agent-id", + description: "runtime agent id", + category: CommandCategory::Info, + aliases: &[], + args: ArgSpec::None, + handler: CommandHandler::Control(ControlAction::AgentId), + }, +]; + +/// The instance-wide registry over [`COMMANDS`]. +pub static REGISTRY: CommandRegistry = CommandRegistry::new(COMMANDS); + +#[cfg(test)] +mod tests { + use super::*; + + fn parsed(result: ParseResult) -> ParsedCommand { + match result { + ParseResult::Command(cmd) => cmd, + other => panic!("expected Command, got {other:?}"), + } + } + + #[test] + fn parses_basic_command() { + let cmd = parsed(REGISTRY.parse("/status")); + assert_eq!(cmd.def.name, "status"); + assert!(cmd.args.is_empty()); + } + + #[test] + fn parses_with_surrounding_whitespace() { + let cmd = parsed(REGISTRY.parse(" /help ")); + assert_eq!(cmd.def.name, "help"); + } + + #[test] + fn resolves_aliases_to_canonical_def() { + assert_eq!(parsed(REGISTRY.parse("/observe")).def.name, "quiet"); + assert_eq!(parsed(REGISTRY.parse("/commands")).def.name, "help"); + } + + #[test] + fn lookup_is_case_insensitive() { + assert_eq!(parsed(REGISTRY.parse("/STATUS")).def.name, "status"); + assert_eq!(parsed(REGISTRY.parse("/Observe")).def.name, "quiet"); + } + + #[test] + fn strips_telegram_botname_suffix() { + let cmd = parsed(REGISTRY.parse("/status@spacebot_bot")); + assert_eq!(cmd.def.name, "status"); + } + + #[test] + fn file_paths_are_not_commands() { + assert!(matches!( + REGISTRY.parse("/usr/bin/env python"), + ParseResult::NotACommand + )); + assert!(matches!(REGISTRY.parse("/"), ParseResult::NotACommand)); + } + + #[test] + fn unknown_slash_words_are_not_commands() { + assert!(matches!( + REGISTRY.parse("/shrug whatever"), + ParseResult::NotACommand + )); + } + + #[test] + fn trailing_text_on_no_arg_command_is_conversation() { + // "/status update: we shipped" is a sentence, not a command. + assert!(matches!( + REGISTRY.parse("/status update: we shipped"), + ParseResult::NotACommand + )); + } + + static TEST_COMMANDS: &[CommandDef] = &[ + CommandDef { + name: "memory", + description: "search memories", + category: CommandCategory::Memory, + aliases: &[], + args: ArgSpec::Optional("[query]"), + handler: CommandHandler::Control(ControlAction::Help), + }, + CommandDef { + name: "background", + description: "run in background", + category: CommandCategory::Session, + aliases: &[], + args: ArgSpec::Required("<prompt>"), + handler: CommandHandler::Control(ControlAction::Help), + }, + CommandDef { + name: "voice", + description: "toggle voice", + category: CommandCategory::Config, + aliases: &[], + args: ArgSpec::Choice(&["on", "off", "status"]), + handler: CommandHandler::Control(ControlAction::Help), + }, + ]; + + static TEST_REGISTRY: CommandRegistry = CommandRegistry::new(TEST_COMMANDS); + + #[test] + fn optional_args_pass_through() { + let cmd = parsed(TEST_REGISTRY.parse("/memory launch plans")); + assert_eq!(cmd.args, "launch plans"); + assert!(parsed(TEST_REGISTRY.parse("/memory")).args.is_empty()); + } + + #[test] + fn required_args_produce_usage_when_missing() { + match TEST_REGISTRY.parse("/background") { + ParseResult::Usage(def, usage) => { + assert_eq!(def.name, "background"); + assert_eq!(usage, "usage: /background <prompt>"); + } + other => panic!("expected Usage, got {other:?}"), + } + } + + #[test] + fn choice_args_validate_against_the_set() { + assert_eq!(parsed(TEST_REGISTRY.parse("/voice ON")).args, "on"); + assert!(parsed(TEST_REGISTRY.parse("/voice")).args.is_empty()); + match TEST_REGISTRY.parse("/voice loud") { + ParseResult::Usage(_, usage) => { + assert_eq!(usage, "usage: /voice [on|off|status]"); + } + other => panic!("expected Usage, got {other:?}"), + } + } + + #[test] + fn smart_dashes_are_normalized_in_args() { + // iOS turns "--preview" into "—preview" and "-n" into "–n". + assert_eq!( + parsed(TEST_REGISTRY.parse("/memory \u{2014}preview \u{2013}n")).args, + "--preview -n" + ); + } + + #[test] + fn help_lists_every_command_exactly_once() { + let help = REGISTRY.help_text(); + for def in COMMANDS { + let needle = format!("- /{}", def.name); + assert_eq!( + help.matches(&needle).count(), + 1, + "help must list /{} exactly once", + def.name + ); + } + } + + #[test] + fn help_annotates_aliases() { + let help = REGISTRY.help_text(); + assert!(help.contains("- /quiet (or /observe):")); + assert!(help.contains("- /help (or /commands):")); + } + + #[test] + fn names_and_aliases_are_globally_unique() { + let mut seen = std::collections::HashSet::new(); + for def in COMMANDS { + assert!(seen.insert(def.name), "duplicate command name {}", def.name); + for alias in def.aliases { + assert!(seen.insert(alias), "duplicate alias {alias}"); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 87af2d3fd..c96e6e74d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod agent; pub mod api; pub mod auth; +pub mod commands; pub mod config; pub mod conversation; pub mod cron; @@ -606,6 +607,15 @@ pub enum MessageContent { /// Platform-specific message reference (`ts` on Slack, message ID on Discord). message_ts: Option<String>, }, + /// A structured slash command: name without the slash, plus raw args. + /// + /// The wire form for surfaces that parse commands client-side (Portal) + /// or receive them natively (platform command menus). Renders as + /// "/name args", so text-path parsing handles it identically. + Command { + name: String, + args: String, + }, } impl std::fmt::Display for MessageContent { @@ -633,6 +643,13 @@ impl std::fmt::Display for MessageContent { write!(f, "[interaction: {}]", action_id) } } + MessageContent::Command { name, args } => { + if args.is_empty() { + write!(f, "/{}", name) + } else { + write!(f, "/{} {}", name, args) + } + } } } } From 08adbf517657584f656a70405744fc3d1b7e40ff Mon Sep 17 00:00:00 2001 From: Jamie Pine <ijamespine@me.com> Date: Sat, 8 Aug 2026 20:41:19 -0700 Subject: [PATCH 3/3] fix(commands): address review findings - module root moves to src/commands.rs (repo bans mod.rs files) - the three agent-turn instructions move from Rust constants to prompts/en/commands/*.md.j2, rendered through the prompt engine with raw-text fallback; a new test renders every PromptTemplate key through the real engine so an unregistered key fails at test time - uniqueness test normalizes case to match resolve() semantics - doc code fences get languages --- docs/design-docs/slash-commands.md | 4 +- prompts/en/commands/digest.md.j2 | 5 +++ prompts/en/commands/tasks.md.j2 | 4 ++ prompts/en/commands/today.md.j2 | 7 ++++ src/agent/channel.rs | 19 ++++++++- src/{commands/mod.rs => commands.rs} | 2 +- src/commands/registry.rs | 61 +++++++++++++++------------- src/prompts/engine.rs | 14 +++++++ src/prompts/text.rs | 5 +++ 9 files changed, 88 insertions(+), 33 deletions(-) create mode 100644 prompts/en/commands/digest.md.j2 create mode 100644 prompts/en/commands/tasks.md.j2 create mode 100644 prompts/en/commands/today.md.j2 rename src/{commands/mod.rs => commands.rs} (94%) diff --git a/docs/design-docs/slash-commands.md b/docs/design-docs/slash-commands.md index a50db94ee..1c47d8b95 100644 --- a/docs/design-docs/slash-commands.md +++ b/docs/design-docs/slash-commands.md @@ -334,7 +334,7 @@ A registry test walks every command × platform and fails when a command is avai Generated from the registry, grouped by category, filtered by the caller's availability and access — a user never sees a command they can't run. Rich format for Portal/Discord, compact for Telegram/text: -``` +```text /new — new conversation /status — conversation info /memory [query] — search memories @@ -360,7 +360,7 @@ Generated from the registry, grouped by category, filtered by the caller's avail **API:** -``` +```text GET /api/commands registry projection for Portal palette POST /api/channels/:id/command { name, args } → { text, data } ``` diff --git a/prompts/en/commands/digest.md.j2 b/prompts/en/commands/digest.md.j2 new file mode 100644 index 000000000..92dff356d --- /dev/null +++ b/prompts/en/commands/digest.md.j2 @@ -0,0 +1,5 @@ +using available tools and channel context, generate a concise day digest from local 00:00 to now with exactly this order: +1) top decisions +2) key convo themes +3) open loops +keep it practical and concise; if there are no meaningful updates, reply exactly: no material updates today. diff --git a/prompts/en/commands/tasks.md.j2 b/prompts/en/commands/tasks.md.j2 new file mode 100644 index 000000000..1af27fef8 --- /dev/null +++ b/prompts/en/commands/tasks.md.j2 @@ -0,0 +1,4 @@ +use channel tools to fetch my ready tasks (limit 10) and reply exactly with: +- header: tasks (ready): +- each line: - #<task_number> [<priority>] <title> +if no tasks are ready, reply exactly: tasks (ready): none diff --git a/prompts/en/commands/today.md.j2 b/prompts/en/commands/today.md.j2 new file mode 100644 index 000000000..6a326d277 --- /dev/null +++ b/prompts/en/commands/today.md.j2 @@ -0,0 +1,7 @@ +use channel tools to build a local tasks snapshot and reply exactly in this format: +- first line: today (local tasks snapshot): +- section 1: in-progress tasks (up to 5), each line: #<task_number> [<priority>] <title> +- section 2: up next ready tasks (up to 5), each line: #<task_number> [<priority>] <title> +if a section is empty use: +- in progress: none +- up next (ready): none diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 87edf17ab..9a0cd38e4 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -2112,8 +2112,23 @@ impl Channel { let rewritten_text = match &parsed_command { crate::commands::ParseResult::Command(cmd) => match cmd.def.handler { crate::commands::CommandHandler::Agent( - crate::commands::AgentAction::PromptRewrite(instruction), - ) => instruction.to_string(), + crate::commands::AgentAction::PromptTemplate(template), + ) => { + let prompt_engine = self.deps.runtime_config.prompts.load(); + match prompt_engine.render_static(template) { + Ok(instruction) => instruction, + Err(error) => { + tracing::error!( + channel_id = %self.id, + command = cmd.def.name, + %template, + %error, + "failed to render command prompt template; using raw text" + ); + raw_text.clone() + } + } + } // Control commands returned above. crate::commands::CommandHandler::Control(_) => raw_text.clone(), }, diff --git a/src/commands/mod.rs b/src/commands.rs similarity index 94% rename from src/commands/mod.rs rename to src/commands.rs index 37aee95b2..2b5e9132e 100644 --- a/src/commands/mod.rs +++ b/src/commands.rs @@ -2,7 +2,7 @@ //! //! Design: `docs/design-docs/slash-commands.md`. -mod registry; +pub mod registry; pub use registry::{ AgentAction, ArgSpec, COMMANDS, CommandCategory, CommandDef, CommandHandler, CommandRegistry, diff --git a/src/commands/registry.rs b/src/commands/registry.rs index 209808124..d9d401066 100644 --- a/src/commands/registry.rs +++ b/src/commands/registry.rs @@ -91,10 +91,10 @@ pub enum ControlAction { /// Agent-turn commands. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AgentAction { - /// Replace the raw text with a deterministic instruction rendered as a - /// normal agent turn. The instruction wording moves into the prompt - /// template system when commands arrive structured (design phase 4). - PromptRewrite(&'static str), + /// Replace the raw text with the named prompt template, rendered as a + /// normal agent turn. The value is a template key in the prompt engine + /// (e.g. "commands/tasks"). + PromptTemplate(&'static str), } /// A successfully parsed command with validated, normalized arguments. @@ -256,25 +256,6 @@ fn normalize_smart_dashes(args: &str) -> String { .replace('\u{2013}', "-") } -const TASKS_PROMPT: &str = "use channel tools to fetch my ready tasks (limit 10) and reply exactly with:\n\ - - header: tasks (ready):\n\ - - each line: - #<task_number> [<priority>] <title>\n\ - if no tasks are ready, reply exactly: tasks (ready): none"; - -const TODAY_PROMPT: &str = "use channel tools to build a local tasks snapshot and reply exactly in this format:\n\ - - first line: today (local tasks snapshot):\n\ - - section 1: in-progress tasks (up to 5), each line: #<task_number> [<priority>] <title>\n\ - - section 2: up next ready tasks (up to 5), each line: #<task_number> [<priority>] <title>\n\ - if a section is empty use:\n\ - - in progress: none\n\ - - up next (ready): none"; - -const DIGEST_PROMPT: &str = "using available tools and channel context, generate a concise day digest from local 00:00 to now with exactly this order:\n\ - 1) top decisions\n\ - 2) key convo themes\n\ - 3) open loops\n\ - keep it practical and concise; if there are no meaningful updates, reply exactly: no material updates today."; - /// The command table. Order within a category is display order in `/help`. pub static COMMANDS: &[CommandDef] = &[ CommandDef { @@ -307,7 +288,7 @@ pub static COMMANDS: &[CommandDef] = &[ category: CommandCategory::Tasks, aliases: &[], args: ArgSpec::None, - handler: CommandHandler::Agent(AgentAction::PromptRewrite(TASKS_PROMPT)), + handler: CommandHandler::Agent(AgentAction::PromptTemplate("commands/tasks")), }, CommandDef { name: "today", @@ -315,7 +296,7 @@ pub static COMMANDS: &[CommandDef] = &[ category: CommandCategory::Tasks, aliases: &[], args: ArgSpec::None, - handler: CommandHandler::Agent(AgentAction::PromptRewrite(TODAY_PROMPT)), + handler: CommandHandler::Agent(AgentAction::PromptTemplate("commands/today")), }, CommandDef { name: "digest", @@ -323,7 +304,7 @@ pub static COMMANDS: &[CommandDef] = &[ category: CommandCategory::Tasks, aliases: &[], args: ArgSpec::None, - handler: CommandHandler::Agent(AgentAction::PromptRewrite(DIGEST_PROMPT)), + handler: CommandHandler::Agent(AgentAction::PromptTemplate("commands/digest")), }, CommandDef { name: "status", @@ -511,13 +492,37 @@ mod tests { assert!(help.contains("- /help (or /commands):")); } + #[test] + fn prompt_template_keys_are_registered() { + // A PromptTemplate key that isn't in the prompt engine falls back to + // raw text at runtime; catch the drift here instead. + let engine = crate::prompts::engine::PromptEngine::new("en").expect("prompt engine"); + for def in COMMANDS { + if let CommandHandler::Agent(AgentAction::PromptTemplate(template)) = def.handler { + engine.render_static(template).unwrap_or_else(|_| { + panic!("/{} references unregistered template {template}", def.name) + }); + } + } + } + #[test] fn names_and_aliases_are_globally_unique() { + // Resolution is case-insensitive, so uniqueness must be checked on + // normalized names or a future "STATUS" entry would collide with + // "status" at runtime while passing here. let mut seen = std::collections::HashSet::new(); for def in COMMANDS { - assert!(seen.insert(def.name), "duplicate command name {}", def.name); + assert!( + seen.insert(def.name.to_ascii_lowercase()), + "duplicate command name {}", + def.name + ); for alias in def.aliases { - assert!(seen.insert(alias), "duplicate alias {alias}"); + assert!( + seen.insert(alias.to_ascii_lowercase()), + "duplicate alias {alias}" + ); } } } diff --git a/src/prompts/engine.rs b/src/prompts/engine.rs index d178c5408..1f568640f 100644 --- a/src/prompts/engine.rs +++ b/src/prompts/engine.rs @@ -93,6 +93,20 @@ impl PromptEngine { crate::prompts::text::get("adapters/signal"), )?; + // Slash-command agent-turn instructions + env.add_template( + "commands/tasks", + crate::prompts::text::get("commands/tasks"), + )?; + env.add_template( + "commands/today", + crate::prompts::text::get("commands/today"), + )?; + env.add_template( + "commands/digest", + crate::prompts::text::get("commands/digest"), + )?; + // Fragment templates env.add_template( "fragments/worker_capabilities", diff --git a/src/prompts/text.rs b/src/prompts/text.rs index 5cd507f79..755eb01e8 100644 --- a/src/prompts/text.rs +++ b/src/prompts/text.rs @@ -80,6 +80,11 @@ fn lookup(lang: &str, key: &str) -> &'static str { ("en", "adapters/cron") => include_str!("../../prompts/en/adapters/cron.md.j2"), ("en", "adapters/signal") => include_str!("../../prompts/en/adapters/signal.md.j2"), + // Slash-command agent-turn instructions + ("en", "commands/tasks") => include_str!("../../prompts/en/commands/tasks.md.j2"), + ("en", "commands/today") => include_str!("../../prompts/en/commands/today.md.j2"), + ("en", "commands/digest") => include_str!("../../prompts/en/commands/digest.md.j2"), + // Fragment Templates ("en", "fragments/worker_capabilities") => { include_str!("../../prompts/en/fragments/worker_capabilities.md.j2")