feat(commands): unify chat commands (#275)

* Chat commands: registry, access groups, Carbon

* Chat commands: clear native commands on disable

* fix(commands): align command surface typing

* docs(changelog): note commands registry (PR #275)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Shadow
2026-01-06 14:17:56 -06:00
committed by GitHub
parent 1bf44bf30c
commit 9b22e1f6e9
40 changed files with 2357 additions and 1459 deletions

View File

@@ -32,4 +32,11 @@ describe("control command parsing", () => {
expect(hasControlCommand("/status")).toBe(true);
expect(hasControlCommand("status")).toBe(false);
});
it("requires commands to be the full message", () => {
expect(hasControlCommand("hello /status")).toBe(false);
expect(hasControlCommand("/status please")).toBe(false);
expect(hasControlCommand("prefix /send on")).toBe(false);
expect(hasControlCommand("/send on")).toBe(true);
});
});

View File

@@ -1,22 +1,20 @@
const CONTROL_COMMAND_RE =
/(?:^|\s)\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)(?=$|\s|:)\b/i;
const CONTROL_COMMAND_EXACT = new Set([
"/help",
"/status",
"/restart",
"/activation",
"/send",
"/reset",
"/new",
"/compact",
]);
import { listChatCommands } from "./commands-registry.js";
export function hasControlCommand(text?: string): boolean {
if (!text) return false;
const trimmed = text.trim();
if (!trimmed) return false;
const lowered = trimmed.toLowerCase();
if (CONTROL_COMMAND_EXACT.has(lowered)) return true;
return CONTROL_COMMAND_RE.test(text);
for (const command of listChatCommands()) {
for (const alias of command.textAliases) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;
if (lowered === normalized) return true;
if (command.acceptsArgs && lowered.startsWith(normalized)) {
const nextChar = trimmed.charAt(normalized.length);
if (nextChar && /\s/.test(nextChar)) return true;
}
}
}
return false;
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
buildCommandText,
getCommandDetection,
listNativeCommandSpecs,
shouldHandleTextCommands,
} from "./commands-registry.js";
describe("commands registry", () => {
it("builds command text with args", () => {
expect(buildCommandText("status")).toBe("/status");
expect(buildCommandText("model", "gpt-5")).toBe("/model gpt-5");
});
it("exposes native specs", () => {
const specs = listNativeCommandSpecs();
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/help")).toBe(true);
expect(detection.regex.test("/status")).toBe(true);
expect(detection.regex.test("try /status")).toBe(false);
});
it("respects text command gating", () => {
const cfg = { commands: { text: false } };
expect(
shouldHandleTextCommands({
cfg,
surface: "discord",
commandSource: "text",
}),
).toBe(false);
expect(
shouldHandleTextCommands({
cfg,
surface: "whatsapp",
commandSource: "text",
}),
).toBe(true);
expect(
shouldHandleTextCommands({
cfg,
surface: "discord",
commandSource: "native",
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,178 @@
import type { ClawdbotConfig } from "../config/types.js";
export type ChatCommandDefinition = {
key: string;
nativeName: string;
description: string;
textAliases: string[];
acceptsArgs?: boolean;
};
export type NativeCommandSpec = {
name: string;
description: string;
acceptsArgs: boolean;
};
const CHAT_COMMANDS: ChatCommandDefinition[] = [
{
key: "help",
nativeName: "help",
description: "Show available commands.",
textAliases: ["/help"],
},
{
key: "status",
nativeName: "status",
description: "Show current status.",
textAliases: ["/status"],
},
{
key: "restart",
nativeName: "restart",
description: "Restart Clawdbot.",
textAliases: ["/restart"],
},
{
key: "activation",
nativeName: "activation",
description: "Set group activation mode.",
textAliases: ["/activation"],
acceptsArgs: true,
},
{
key: "send",
nativeName: "send",
description: "Set send policy.",
textAliases: ["/send"],
acceptsArgs: true,
},
{
key: "reset",
nativeName: "reset",
description: "Reset the current session.",
textAliases: ["/reset"],
},
{
key: "new",
nativeName: "new",
description: "Start a new session.",
textAliases: ["/new"],
},
{
key: "think",
nativeName: "think",
description: "Set thinking level.",
textAliases: ["/thinking", "/think", "/t"],
acceptsArgs: true,
},
{
key: "verbose",
nativeName: "verbose",
description: "Toggle verbose mode.",
textAliases: ["/verbose", "/v"],
acceptsArgs: true,
},
{
key: "elevated",
nativeName: "elevated",
description: "Toggle elevated mode.",
textAliases: ["/elevated", "/elev"],
acceptsArgs: true,
},
{
key: "model",
nativeName: "model",
description: "Show or set the model.",
textAliases: ["/model"],
acceptsArgs: true,
},
{
key: "queue",
nativeName: "queue",
description: "Adjust queue settings.",
textAliases: ["/queue"],
acceptsArgs: true,
},
];
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
let cachedDetection:
| {
exact: Set<string>;
regex: RegExp;
}
| undefined;
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function listChatCommands(): ChatCommandDefinition[] {
return [...CHAT_COMMANDS];
}
export function listNativeCommandSpecs(): NativeCommandSpec[] {
return CHAT_COMMANDS.map((command) => ({
name: command.nativeName,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
}));
}
export function findCommandByNativeName(
name: string,
): ChatCommandDefinition | undefined {
const normalized = name.trim().toLowerCase();
return CHAT_COMMANDS.find(
(command) => command.nativeName.toLowerCase() === normalized,
);
}
export function buildCommandText(commandName: string, args?: string): string {
const trimmedArgs = args?.trim();
return trimmedArgs ? `/${commandName} ${trimmedArgs}` : `/${commandName}`;
}
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
if (cachedDetection) return cachedDetection;
const exact = new Set<string>();
const patterns: string[] = [];
for (const command of CHAT_COMMANDS) {
for (const alias of command.textAliases) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;
exact.add(normalized);
const escaped = escapeRegExp(normalized);
if (!escaped) continue;
if (command.acceptsArgs) {
patterns.push(`${escaped}(?:\\s+.+)?`);
} else {
patterns.push(escaped);
}
}
}
const regex = patterns.length
? new RegExp(`^(?:${patterns.join("|")})$`, "i")
: /$^/;
cachedDetection = { exact, regex };
return cachedDetection;
}
export function supportsNativeCommands(surface?: string): boolean {
if (!surface) return false;
return NATIVE_COMMAND_SURFACES.has(surface.toLowerCase());
}
export function shouldHandleTextCommands(params: {
cfg: ClawdbotConfig;
surface?: string;
commandSource?: "text" | "native";
}): boolean {
const { cfg, surface, commandSource } = params;
const textEnabled = cfg.commands?.text !== false;
if (commandSource === "native") return true;
if (textEnabled) return true;
return !supportsNativeCommands(surface);
}

View File

@@ -16,7 +16,7 @@ export function parseActivationCommand(raw?: string): {
if (!raw) return { hasCommand: false };
const trimmed = raw.trim();
if (!trimmed) return { hasCommand: false };
const match = trimmed.match(/^\/activation\b(?:\s+([a-zA-Z]+))?/i);
const match = trimmed.match(/^\/activation(?:\s+([a-zA-Z]+))?\s*$/i);
if (!match) return { hasCommand: false };
const mode = normalizeGroupActivation(match[1]);
return { hasCommand: true, mode };

View File

@@ -512,7 +512,7 @@ describe("directive parsing", () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const ctx = {
Body: "please do the thing /verbose on",
Body: "please do the thing",
From: "+1004",
To: "+2000",
};
@@ -546,6 +546,21 @@ describe("directive parsing", () => {
};
});
await getReplyFromConfig(
{ Body: "/verbose on", From: ctx.From, To: ctx.To },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
whatsapp: {
allowFrom: ["*"],
},
session: { store: storePath },
},
);
const res = await getReplyFromConfig(
ctx,
{},
@@ -827,7 +842,7 @@ describe("directive parsing", () => {
});
});
it("uses model override for inline /model", async () => {
it("ignores inline /model and uses the default model", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
@@ -867,8 +882,8 @@ describe("directive parsing", () => {
expect(texts).toContain("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
expect(call?.provider).toBe("openai");
expect(call?.model).toBe("gpt-4.1-mini");
expect(call?.provider).toBe("anthropic");
expect(call?.model).toBe("claude-opus-4-5");
});
});

View File

@@ -115,8 +115,15 @@ describe("trigger handling", () => {
});
});
it("reports status when /status appears inline", async () => {
it("ignores inline /status and runs the agent", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await getReplyFromConfig(
{
Body: "please /status now",
@@ -127,8 +134,8 @@ describe("trigger handling", () => {
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Status");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(text).not.toContain("Status");
expect(runEmbeddedPiAgent).toHaveBeenCalled();
});
});
@@ -265,8 +272,15 @@ describe("trigger handling", () => {
});
});
it("rejects elevated inline directive for unapproved sender", async () => {
it("ignores inline elevated directive for unapproved sender", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
@@ -293,8 +307,8 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("elevated is not available right now.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(text).not.toBe("elevated is not available right now.");
expect(runEmbeddedPiAgent).toHaveBeenCalled();
});
});

View File

@@ -31,6 +31,7 @@ import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
import { defaultRuntime } from "../runtime.js";
import { resolveCommandAuthorization } from "./command-auth.js";
import { hasControlCommand } from "./command-detection.js";
import { shouldHandleTextCommands } from "./commands-registry.js";
import { getAbortMemory } from "./reply/abort.js";
import { runReplyAgent } from "./reply/agent-runner.js";
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
@@ -38,6 +39,7 @@ import { applySessionHints } from "./reply/body.js";
import { buildCommandContext, handleCommands } from "./reply/commands.js";
import {
handleDirectiveOnly,
type InlineDirectives,
isDirectiveOnly,
parseInlineDirectives,
persistInlineDirectives,
@@ -48,7 +50,7 @@ import {
defaultGroupActivation,
resolveGroupRequireMention,
} from "./reply/groups.js";
import { stripMentions } from "./reply/mentions.js";
import { stripMentions, stripStructuralPrefixes } from "./reply/mentions.js";
import {
createModelSelectionState,
resolveContextTokens,
@@ -83,9 +85,6 @@ export type { GetReplyOptions, ReplyPayload } from "./types.js";
const BARE_SESSION_RESET_PROMPT =
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning.";
const CONTROL_COMMAND_PREFIX_RE =
/^\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)\b/i;
function normalizeAllowToken(value?: string) {
if (!value) return "";
return value.trim().toLowerCase();
@@ -254,7 +253,7 @@ export async function getReplyFromConfig(
}
const commandAuthorized = ctx.CommandAuthorized ?? true;
const commandAuth = resolveCommandAuthorization({
resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized,
@@ -281,7 +280,47 @@ export async function getReplyFromConfig(
} = sessionState;
const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
const parsedDirectives = parseInlineDirectives(rawBody);
const clearInlineDirectives = (cleaned: string): InlineDirectives => ({
cleaned,
hasThinkDirective: false,
thinkLevel: undefined,
rawThinkLevel: undefined,
hasVerboseDirective: false,
verboseLevel: undefined,
rawVerboseLevel: undefined,
hasElevatedDirective: false,
elevatedLevel: undefined,
rawElevatedLevel: undefined,
hasStatusDirective: false,
hasModelDirective: false,
rawModelDirective: undefined,
hasQueueDirective: false,
queueMode: undefined,
queueReset: false,
rawQueueMode: undefined,
debounceMs: undefined,
cap: undefined,
dropPolicy: undefined,
rawDebounce: undefined,
rawCap: undefined,
rawDrop: undefined,
hasQueueOptions: false,
});
let parsedDirectives = parseInlineDirectives(rawBody);
const hasDirective =
parsedDirectives.hasThinkDirective ||
parsedDirectives.hasVerboseDirective ||
parsedDirectives.hasElevatedDirective ||
parsedDirectives.hasStatusDirective ||
parsedDirectives.hasModelDirective ||
parsedDirectives.hasQueueDirective;
if (hasDirective) {
const stripped = stripStructuralPrefixes(parsedDirectives.cleaned);
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
if (noMentions.trim().length > 0) {
parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned);
}
}
const directives = commandAuthorized
? parsedDirectives
: {
@@ -468,6 +507,11 @@ export async function getReplyFromConfig(
triggerBodyNormalized,
commandAuthorized,
});
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: command.surface,
commandSource: ctx.CommandSource,
});
const isEmptyConfig = Object.keys(cfg).length === 0;
if (
command.isWhatsAppProvider &&
@@ -538,20 +582,15 @@ export async function getReplyFromConfig(
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
const rawBodyTrimmed = (ctx.Body ?? "").trim();
const baseBodyTrimmedRaw = baseBody.trim();
const strippedCommandBody = isGroup
? stripMentions(triggerBodyNormalized, ctx, cfg)
: triggerBodyNormalized;
if (
!commandAuth.isAuthorizedSender &&
CONTROL_COMMAND_PREFIX_RE.test(strippedCommandBody.trim())
allowTextCommands &&
!commandAuthorized &&
!baseBodyTrimmedRaw &&
hasControlCommand(rawBody)
) {
typing.cleanup();
return undefined;
}
if (!commandAuthorized && !baseBodyTrimmedRaw && hasControlCommand(rawBody)) {
typing.cleanup();
return undefined;
}
const isBareSessionReset =
isNewSession &&
baseBodyTrimmedRaw.length === 0 &&

View File

@@ -27,6 +27,7 @@ import { normalizeE164 } from "../../utils.js";
import { resolveHeartbeatSeconds } from "../../web/reconnect.js";
import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import { shouldHandleTextCommands } from "../commands-registry.js";
import {
normalizeGroupActivation,
parseActivationCommand,
@@ -47,6 +48,7 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import { incrementCompactionCount } from "./session-updates.js";
export type CommandContext = {
surface: string;
provider: string;
isWhatsAppProvider: boolean;
ownerList: string[];
@@ -123,7 +125,8 @@ export function buildCommandContext(params: {
cfg,
commandAuthorized: params.commandAuthorized,
});
const provider = (ctx.Provider ?? "").trim().toLowerCase();
const surface = (ctx.Surface ?? ctx.Provider ?? "").trim().toLowerCase();
const provider = (ctx.Provider ?? surface).trim().toLowerCase();
const abortKey =
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
const rawBodyNormalized = triggerBodyNormalized;
@@ -132,6 +135,7 @@ export function buildCommandContext(params: {
: rawBodyNormalized;
return {
surface,
provider,
isWhatsAppProvider: auth.isWhatsAppProvider,
ownerList: auth.ownerList,
@@ -207,8 +211,13 @@ export async function handleCommands(params: {
const sendPolicyCommand = parseSendPolicyCommand(
command.commandBodyNormalized,
);
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: command.surface,
commandSource: ctx.CommandSource,
});
if (activationCommand.hasCommand) {
if (allowTextCommands && activationCommand.hasCommand) {
if (!isGroup) {
return {
shouldContinue: false,
@@ -255,7 +264,7 @@ export async function handleCommands(params: {
};
}
if (sendPolicyCommand.hasCommand) {
if (allowTextCommands && sendPolicyCommand.hasCommand) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /send from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
@@ -292,10 +301,7 @@ export async function handleCommands(params: {
};
}
if (
command.commandBodyNormalized === "/restart" ||
command.commandBodyNormalized.startsWith("/restart ")
) {
if (allowTextCommands && command.commandBodyNormalized === "/restart") {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /restart from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
@@ -311,10 +317,8 @@ export async function handleCommands(params: {
};
}
const helpRequested =
command.commandBodyNormalized === "/help" ||
/(?:^|\s)\/help(?=$|\s|:)\b/i.test(command.commandBodyNormalized);
if (helpRequested) {
const helpRequested = command.commandBodyNormalized === "/help";
if (allowTextCommands && helpRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /help from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
@@ -326,9 +330,8 @@ export async function handleCommands(params: {
const statusRequested =
directives.hasStatusDirective ||
command.commandBodyNormalized === "/status" ||
command.commandBodyNormalized.startsWith("/status ");
if (statusRequested) {
command.commandBodyNormalized === "/status";
if (allowTextCommands && statusRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
@@ -451,7 +454,7 @@ export async function handleCommands(params: {
}
const abortRequested = isAbortTrigger(command.rawBodyNormalized);
if (abortRequested) {
if (allowTextCommands && abortRequested) {
if (sessionEntry && sessionStore && sessionKey) {
sessionEntry.abortedLastRun = true;
sessionEntry.updatedAt = Date.now();

View File

@@ -17,7 +17,7 @@ export function parseSendPolicyCommand(raw?: string): {
if (!raw) return { hasCommand: false };
const trimmed = raw.trim();
if (!trimmed) return { hasCommand: false };
const match = trimmed.match(/^\/send\b(?:\s+([a-zA-Z]+))?/i);
const match = trimmed.match(/^\/send(?:\s+([a-zA-Z]+))?\s*$/i);
if (!match) return { hasCommand: false };
const token = match[1]?.trim().toLowerCase();
if (!token) return { hasCommand: true };

View File

@@ -28,8 +28,11 @@ export type MsgContext = {
SenderE164?: string;
/** Provider label (whatsapp|telegram|discord|imessage|...). */
Provider?: string;
/** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */
Surface?: string;
WasMentioned?: boolean;
CommandAuthorized?: boolean;
CommandSource?: "text" | "native";
};
export type TemplateContext = MsgContext & {