diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index a69bf194d6a..852e122587e 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -35,6 +35,7 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe Text + native (when enabled): - `/help` +- `/commands` - `/status` (show current status; includes a short usage line when available) - `/usage` (alias: `/status`) - `/debug show|set|unset|reset` (runtime overrides, owner-only) diff --git a/src/auto-reply/command-detection.test.ts b/src/auto-reply/command-detection.test.ts index b43bbed1b03..e3c99a808ca 100644 --- a/src/auto-reply/command-detection.test.ts +++ b/src/auto-reply/command-detection.test.ts @@ -40,8 +40,16 @@ describe("control command parsing", () => { it("treats bare commands as non-control", () => { expect(hasControlCommand("send")).toBe(false); expect(hasControlCommand("help")).toBe(false); + expect(hasControlCommand("/commands")).toBe(true); + expect(hasControlCommand("/commands:")).toBe(true); + expect(hasControlCommand("commands")).toBe(false); + expect(hasControlCommand("/status")).toBe(true); + expect(hasControlCommand("/status:")).toBe(true); expect(hasControlCommand("status")).toBe(false); expect(hasControlCommand("usage")).toBe(false); + expect(hasControlCommand("/compact")).toBe(true); + expect(hasControlCommand("/compact:")).toBe(true); + expect(hasControlCommand("compact")).toBe(false); for (const command of listChatCommands()) { for (const alias of command.textAliases) { diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 9952af7ebc0..58815ded1db 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -18,10 +18,14 @@ describe("commands registry", () => { const specs = listNativeCommandSpecs(); expect(specs.find((spec) => spec.name === "help")).toBeTruthy(); expect(specs.find((spec) => spec.name === "stop")).toBeTruthy(); + expect(specs.find((spec) => spec.name === "compact")).toBeFalsy(); }); it("detects known text commands", () => { const detection = getCommandDetection(); + expect(detection.exact.has("/help")).toBe(true); + expect(detection.exact.has("/commands")).toBe(true); + expect(detection.exact.has("/compact")).toBe(true); for (const command of listChatCommands()) { for (const alias of command.textAliases) { expect(detection.exact.has(alias.toLowerCase())).toBe(true); diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 63cf44a7dd8..e3932192b81 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -1,11 +1,14 @@ import type { ClawdbotConfig } from "../config/types.js"; +export type CommandScope = "text" | "native" | "both"; + export type ChatCommandDefinition = { key: string; - nativeName: string; + nativeName?: string; description: string; textAliases: string[]; acceptsArgs?: boolean; + scope: CommandScope; }; export type NativeCommandSpec = { @@ -14,15 +17,27 @@ export type NativeCommandSpec = { acceptsArgs: boolean; }; -function defineChatCommand( - command: Omit & { textAlias: string }, -): ChatCommandDefinition { +function defineChatCommand(command: { + key: string; + nativeName?: string; + description: string; + acceptsArgs?: boolean; + textAlias?: string; + textAliases?: string[]; + scope?: CommandScope; +}): ChatCommandDefinition { + const aliases = + command.textAliases ?? (command.textAlias ? [command.textAlias] : []); + const scope = + command.scope ?? + (command.nativeName ? (aliases.length ? "both" : "native") : "text"); return { key: command.key, nativeName: command.nativeName, description: command.description, acceptsArgs: command.acceptsArgs, - textAliases: [command.textAlias], + textAliases: aliases, + scope, }; } @@ -53,6 +68,12 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { description: "Show available commands.", textAlias: "/help", }), + defineChatCommand({ + key: "commands", + nativeName: "commands", + description: "List all slash commands.", + textAlias: "/commands", + }), defineChatCommand({ key: "status", nativeName: "status", @@ -111,6 +132,13 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { description: "Start a new session.", textAlias: "/new", }), + defineChatCommand({ + key: "compact", + description: "Compact the session context.", + textAlias: "/compact", + scope: "text", + acceptsArgs: true, + }), defineChatCommand({ key: "think", nativeName: "think", @@ -167,27 +195,6 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]); -type TextAliasSpec = { - canonical: string; - acceptsArgs: boolean; -}; - -const TEXT_ALIAS_MAP: Map = (() => { - const map = new Map(); - for (const command of CHAT_COMMANDS) { - const canonical = `/${command.key}`; - const acceptsArgs = Boolean(command.acceptsArgs); - for (const alias of command.textAliases) { - const normalized = alias.trim().toLowerCase(); - if (!normalized) continue; - if (!map.has(normalized)) { - map.set(normalized, { canonical, acceptsArgs }); - } - } - } - return map; -})(); - let cachedDetection: | { exact: Set; @@ -204,8 +211,10 @@ export function listChatCommands(): ChatCommandDefinition[] { } export function listNativeCommandSpecs(): NativeCommandSpec[] { - return CHAT_COMMANDS.map((command) => ({ - name: command.nativeName, + return CHAT_COMMANDS.filter( + (command) => command.scope !== "text" && command.nativeName, + ).map((command) => ({ + name: command.nativeName ?? command.key, description: command.description, acceptsArgs: Boolean(command.acceptsArgs), })); @@ -216,7 +225,9 @@ export function findCommandByNativeName( ): ChatCommandDefinition | undefined { const normalized = name.trim().toLowerCase(); return CHAT_COMMANDS.find( - (command) => command.nativeName.toLowerCase() === normalized, + (command) => + command.nativeName?.toLowerCase() === normalized && + command.scope !== "text", ); } @@ -228,31 +239,11 @@ export function buildCommandText(commandName: string, args?: string): string { export function normalizeCommandBody(raw: string): string { const trimmed = raw.trim(); if (!trimmed.startsWith("/")) return trimmed; - - const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/); - const normalized = colonMatch - ? (() => { - const [, command, rest] = colonMatch; - const normalizedRest = rest.trimStart(); - return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`; - })() - : trimmed; - - const lowered = normalized.toLowerCase(); - const exact = TEXT_ALIAS_MAP.get(lowered); - if (exact) return exact.canonical; - - const tokenMatch = normalized.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/); - if (!tokenMatch) return normalized; - const [, token, rest] = tokenMatch; - const tokenKey = `/${token.toLowerCase()}`; - const tokenSpec = TEXT_ALIAS_MAP.get(tokenKey); - if (!tokenSpec) return normalized; - if (rest && !tokenSpec.acceptsArgs) return normalized; - const normalizedRest = rest?.trimStart(); - return normalizedRest - ? `${tokenSpec.canonical} ${normalizedRest}` - : tokenSpec.canonical; + const match = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/); + if (!match) return trimmed; + const [, command, rest] = match; + const normalizedRest = rest.trimStart(); + return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`; } export function getCommandDetection(): { exact: Set; regex: RegExp } { diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 7299a846dba..c5ffd26a2fe 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -57,6 +57,7 @@ import { } from "../group-activation.js"; import { parseSendPolicyCommand } from "../send-policy.js"; import { + buildCommandsMessage, buildHelpMessage, buildStatusMessage, formatContextUsageShort, @@ -592,6 +593,17 @@ export async function handleCommands(params: { return { shouldContinue: false, reply: { text: buildHelpMessage() } }; } + const commandsRequested = command.commandBodyNormalized === "/commands"; + if (allowTextCommands && commandsRequested) { + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /commands from unauthorized sender: ${command.senderE164 || ""}`, + ); + return { shouldContinue: false }; + } + return { shouldContinue: false, reply: { text: buildCommandsMessage() } }; + } + const statusRequested = directives.hasStatusDirective || command.commandBodyNormalized === "/status"; diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index bbd272f2c9c..459dd0d3c11 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import { withTempHome } from "../../test/helpers/temp-home.js"; import type { ClawdbotConfig } from "../config/config.js"; -import { buildStatusMessage } from "./status.js"; +import { buildCommandsMessage, buildStatusMessage } from "./status.js"; afterEach(() => { vi.restoreAllMocks(); @@ -296,3 +296,16 @@ describe("buildStatusMessage", () => { ); }); }); + +describe("buildCommandsMessage", () => { + it("lists commands with aliases and text-only hints", () => { + const text = buildCommandsMessage(); + expect(text).toContain("/commands - List all slash commands."); + expect(text).toContain( + "/think (aliases: /thinking, /t) - Set thinking level.", + ); + expect(text).toContain( + "/compact (text-only) - Compact the session context.", + ); + }); +}); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 9b9f96520ce..5784d03f842 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -28,6 +28,7 @@ import { resolveModelCostConfig, } from "../utils/usage-format.js"; import { VERSION } from "../version.js"; +import { listChatCommands } from "./commands-registry.js"; import type { ElevatedLevel, ReasoningLevel, @@ -358,5 +359,32 @@ export function buildHelpMessage(): string { "ℹ️ Help", "Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)", "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model | /cost on|off | /debug show", + "More: /commands for all slash commands", ].join("\n"); } + +export function buildCommandsMessage(): string { + const lines = ["ℹ️ Slash commands"]; + for (const command of listChatCommands()) { + const primary = command.nativeName + ? `/${command.nativeName}` + : command.textAliases[0]?.trim() || `/${command.key}`; + const seen = new Set(); + const aliases = command.textAliases + .map((alias) => alias.trim()) + .filter(Boolean) + .filter((alias) => alias.toLowerCase() !== primary.toLowerCase()) + .filter((alias) => { + const key = alias.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + const aliasLabel = aliases.length + ? ` (aliases: ${aliases.join(", ")})` + : ""; + const scopeLabel = command.scope === "text" ? " (text-only)" : ""; + lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`); + } + return lines.join("\n"); +}