497 lines
14 KiB
TypeScript
497 lines
14 KiB
TypeScript
import type { ClawdbotConfig } from "../config/types.js";
|
|
import { listProviderDocks } from "../providers/dock.js";
|
|
|
|
export type CommandScope = "text" | "native" | "both";
|
|
|
|
export type ChatCommandDefinition = {
|
|
key: string;
|
|
nativeName?: string;
|
|
description: string;
|
|
textAliases: string[];
|
|
acceptsArgs?: boolean;
|
|
scope: CommandScope;
|
|
};
|
|
|
|
export type NativeCommandSpec = {
|
|
name: string;
|
|
description: string;
|
|
acceptsArgs: boolean;
|
|
};
|
|
|
|
type TextAliasSpec = {
|
|
canonical: string;
|
|
acceptsArgs: boolean;
|
|
};
|
|
|
|
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] : [])
|
|
)
|
|
.map((alias) => alias.trim())
|
|
.filter(Boolean);
|
|
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: aliases,
|
|
scope,
|
|
};
|
|
}
|
|
|
|
function registerAlias(
|
|
commands: ChatCommandDefinition[],
|
|
key: string,
|
|
...aliases: string[]
|
|
): void {
|
|
const command = commands.find((entry) => entry.key === key);
|
|
if (!command) {
|
|
throw new Error(`registerAlias: unknown command key: ${key}`);
|
|
}
|
|
const existing = new Set(
|
|
command.textAliases.map((alias) => alias.trim().toLowerCase()),
|
|
);
|
|
for (const alias of aliases) {
|
|
const trimmed = alias.trim();
|
|
if (!trimmed) continue;
|
|
const lowered = trimmed.toLowerCase();
|
|
if (existing.has(lowered)) continue;
|
|
existing.add(lowered);
|
|
command.textAliases.push(trimmed);
|
|
}
|
|
}
|
|
|
|
function assertCommandRegistry(commands: ChatCommandDefinition[]): void {
|
|
const keys = new Set<string>();
|
|
const nativeNames = new Set<string>();
|
|
const textAliases = new Set<string>();
|
|
for (const command of commands) {
|
|
if (keys.has(command.key)) {
|
|
throw new Error(`Duplicate command key: ${command.key}`);
|
|
}
|
|
keys.add(command.key);
|
|
|
|
const nativeName = command.nativeName?.trim();
|
|
if (command.scope === "text") {
|
|
if (nativeName) {
|
|
throw new Error(`Text-only command has native name: ${command.key}`);
|
|
}
|
|
if (command.textAliases.length === 0) {
|
|
throw new Error(`Text-only command missing text alias: ${command.key}`);
|
|
}
|
|
} else if (!nativeName) {
|
|
throw new Error(`Native command missing native name: ${command.key}`);
|
|
} else {
|
|
const nativeKey = nativeName.toLowerCase();
|
|
if (nativeNames.has(nativeKey)) {
|
|
throw new Error(`Duplicate native command: ${nativeName}`);
|
|
}
|
|
nativeNames.add(nativeKey);
|
|
}
|
|
|
|
if (command.scope === "native" && command.textAliases.length > 0) {
|
|
throw new Error(`Native-only command has text aliases: ${command.key}`);
|
|
}
|
|
|
|
for (const alias of command.textAliases) {
|
|
if (!alias.startsWith("/")) {
|
|
throw new Error(`Command alias missing leading '/': ${alias}`);
|
|
}
|
|
const aliasKey = alias.toLowerCase();
|
|
if (textAliases.has(aliasKey)) {
|
|
throw new Error(`Duplicate command alias: ${alias}`);
|
|
}
|
|
textAliases.add(aliasKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
|
const commands: ChatCommandDefinition[] = [
|
|
defineChatCommand({
|
|
key: "help",
|
|
nativeName: "help",
|
|
description: "Show available commands.",
|
|
textAlias: "/help",
|
|
}),
|
|
defineChatCommand({
|
|
key: "commands",
|
|
nativeName: "commands",
|
|
description: "List all slash commands.",
|
|
textAlias: "/commands",
|
|
}),
|
|
defineChatCommand({
|
|
key: "status",
|
|
nativeName: "status",
|
|
description: "Show current status.",
|
|
textAlias: "/status",
|
|
}),
|
|
defineChatCommand({
|
|
key: "whoami",
|
|
nativeName: "whoami",
|
|
description: "Show your sender id.",
|
|
textAlias: "/whoami",
|
|
}),
|
|
defineChatCommand({
|
|
key: "config",
|
|
nativeName: "config",
|
|
description: "Show or set config values.",
|
|
textAlias: "/config",
|
|
acceptsArgs: true,
|
|
}),
|
|
defineChatCommand({
|
|
key: "debug",
|
|
nativeName: "debug",
|
|
description: "Set runtime debug overrides.",
|
|
textAlias: "/debug",
|
|
acceptsArgs: true,
|
|
}),
|
|
defineChatCommand({
|
|
key: "cost",
|
|
nativeName: "cost",
|
|
description: "Toggle per-response usage line.",
|
|
textAlias: "/cost",
|
|
acceptsArgs: true,
|
|
}),
|
|
defineChatCommand({
|
|
key: "stop",
|
|
nativeName: "stop",
|
|
description: "Stop the current run.",
|
|
textAlias: "/stop",
|
|
}),
|
|
defineChatCommand({
|
|
key: "restart",
|
|
nativeName: "restart",
|
|
description: "Restart Clawdbot.",
|
|
textAlias: "/restart",
|
|
}),
|
|
defineChatCommand({
|
|
key: "activation",
|
|
nativeName: "activation",
|
|
description: "Set group activation mode.",
|
|
textAlias: "/activation",
|
|
acceptsArgs: true,
|
|
}),
|
|
defineChatCommand({
|
|
key: "send",
|
|
nativeName: "send",
|
|
description: "Set send policy.",
|
|
textAlias: "/send",
|
|
acceptsArgs: true,
|
|
}),
|
|
defineChatCommand({
|
|
key: "reset",
|
|
nativeName: "reset",
|
|
description: "Reset the current session.",
|
|
textAlias: "/reset",
|
|
}),
|
|
defineChatCommand({
|
|
key: "new",
|
|
nativeName: "new",
|
|
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",
|
|
description: "Set thinking level.",
|
|
textAlias: "/think",
|
|
acceptsArgs: true,
|
|
}),
|
|
defineChatCommand({
|
|
key: "verbose",
|
|
nativeName: "verbose",
|
|
description: "Toggle verbose mode.",
|
|
textAlias: "/verbose",
|
|
acceptsArgs: true,
|
|
}),
|
|
defineChatCommand({
|
|
key: "reasoning",
|
|
nativeName: "reasoning",
|
|
description: "Toggle reasoning visibility.",
|
|
textAlias: "/reasoning",
|
|
acceptsArgs: true,
|
|
}),
|
|
defineChatCommand({
|
|
key: "elevated",
|
|
nativeName: "elevated",
|
|
description: "Toggle elevated mode.",
|
|
textAlias: "/elevated",
|
|
acceptsArgs: true,
|
|
}),
|
|
defineChatCommand({
|
|
key: "model",
|
|
nativeName: "model",
|
|
description: "Show or set the model.",
|
|
textAlias: "/model",
|
|
acceptsArgs: true,
|
|
}),
|
|
defineChatCommand({
|
|
key: "queue",
|
|
nativeName: "queue",
|
|
description: "Adjust queue settings.",
|
|
textAlias: "/queue",
|
|
acceptsArgs: true,
|
|
}),
|
|
];
|
|
|
|
registerAlias(commands, "status", "/usage");
|
|
registerAlias(commands, "whoami", "/id");
|
|
registerAlias(commands, "think", "/thinking", "/t");
|
|
registerAlias(commands, "verbose", "/v");
|
|
registerAlias(commands, "reasoning", "/reason");
|
|
registerAlias(commands, "elevated", "/elev");
|
|
registerAlias(commands, "model", "/models");
|
|
|
|
assertCommandRegistry(commands);
|
|
return commands;
|
|
})();
|
|
let cachedNativeCommandSurfaces: Set<string> | null = null;
|
|
|
|
const getNativeCommandSurfaces = (): Set<string> => {
|
|
if (!cachedNativeCommandSurfaces) {
|
|
cachedNativeCommandSurfaces = new Set(
|
|
listProviderDocks()
|
|
.filter((dock) => dock.capabilities.nativeCommands)
|
|
.map((dock) => dock.id),
|
|
);
|
|
}
|
|
return cachedNativeCommandSurfaces;
|
|
};
|
|
|
|
const TEXT_ALIAS_MAP: Map<string, TextAliasSpec> = (() => {
|
|
const map = new Map<string, TextAliasSpec>();
|
|
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<string>;
|
|
regex: RegExp;
|
|
}
|
|
| undefined;
|
|
|
|
function escapeRegExp(value: string) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
export function listChatCommands(): ChatCommandDefinition[] {
|
|
return [...CHAT_COMMANDS];
|
|
}
|
|
|
|
export function isCommandEnabled(
|
|
cfg: ClawdbotConfig,
|
|
commandKey: string,
|
|
): boolean {
|
|
if (commandKey === "config") return cfg.commands?.config === true;
|
|
if (commandKey === "debug") return cfg.commands?.debug === true;
|
|
return true;
|
|
}
|
|
|
|
export function listChatCommandsForConfig(
|
|
cfg: ClawdbotConfig,
|
|
): ChatCommandDefinition[] {
|
|
return CHAT_COMMANDS.filter((command) => isCommandEnabled(cfg, command.key));
|
|
}
|
|
|
|
export function listNativeCommandSpecs(): NativeCommandSpec[] {
|
|
return CHAT_COMMANDS.filter(
|
|
(command) => command.scope !== "text" && command.nativeName,
|
|
).map((command) => ({
|
|
name: command.nativeName ?? command.key,
|
|
description: command.description,
|
|
acceptsArgs: Boolean(command.acceptsArgs),
|
|
}));
|
|
}
|
|
|
|
export function listNativeCommandSpecsForConfig(
|
|
cfg: ClawdbotConfig,
|
|
): NativeCommandSpec[] {
|
|
return listChatCommandsForConfig(cfg)
|
|
.filter((command) => command.scope !== "text" && command.nativeName)
|
|
.map((command) => ({
|
|
name: command.nativeName ?? command.key,
|
|
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.scope !== "text" &&
|
|
command.nativeName?.toLowerCase() === normalized,
|
|
);
|
|
}
|
|
|
|
export function buildCommandText(commandName: string, args?: string): string {
|
|
const trimmedArgs = args?.trim();
|
|
return trimmedArgs ? `/${commandName} ${trimmedArgs}` : `/${commandName}`;
|
|
}
|
|
|
|
export type CommandNormalizeOptions = {
|
|
botUsername?: string;
|
|
};
|
|
|
|
export function normalizeCommandBody(
|
|
raw: string,
|
|
options?: CommandNormalizeOptions,
|
|
): string {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed.startsWith("/")) return trimmed;
|
|
|
|
const newline = trimmed.indexOf("\n");
|
|
const singleLine =
|
|
newline === -1 ? trimmed : trimmed.slice(0, newline).trim();
|
|
|
|
const colonMatch = singleLine.match(/^\/([^\s:]+)\s*:(.*)$/);
|
|
const normalized = colonMatch
|
|
? (() => {
|
|
const [, command, rest] = colonMatch;
|
|
const normalizedRest = rest.trimStart();
|
|
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
|
|
})()
|
|
: singleLine;
|
|
|
|
const normalizedBotUsername = options?.botUsername?.trim().toLowerCase();
|
|
const mentionMatch = normalizedBotUsername
|
|
? normalized.match(/^\/([^\s@]+)@([^\s]+)(.*)$/)
|
|
: null;
|
|
const commandBody =
|
|
mentionMatch && mentionMatch[2].toLowerCase() === normalizedBotUsername
|
|
? `/${mentionMatch[1]}${mentionMatch[3] ?? ""}`
|
|
: normalized;
|
|
|
|
const lowered = commandBody.toLowerCase();
|
|
const exact = TEXT_ALIAS_MAP.get(lowered);
|
|
if (exact) return exact.canonical;
|
|
|
|
const tokenMatch = commandBody.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
|
|
if (!tokenMatch) return commandBody;
|
|
const [, token, rest] = tokenMatch;
|
|
const tokenKey = `/${token.toLowerCase()}`;
|
|
const tokenSpec = TEXT_ALIAS_MAP.get(tokenKey);
|
|
if (!tokenSpec) return commandBody;
|
|
if (rest && !tokenSpec.acceptsArgs) return commandBody;
|
|
const normalizedRest = rest?.trimStart();
|
|
return normalizedRest
|
|
? `${tokenSpec.canonical} ${normalizedRest}`
|
|
: tokenSpec.canonical;
|
|
}
|
|
|
|
export function isCommandMessage(raw: string): boolean {
|
|
const trimmed = normalizeCommandBody(raw);
|
|
return trimmed.startsWith("/");
|
|
}
|
|
|
|
export function getCommandDetection(_cfg?: ClawdbotConfig): {
|
|
exact: Set<string>;
|
|
regex: RegExp;
|
|
} {
|
|
if (cachedDetection) return cachedDetection;
|
|
const exact = new Set<string>();
|
|
const patterns: string[] = [];
|
|
for (const cmd of CHAT_COMMANDS) {
|
|
for (const alias of cmd.textAliases) {
|
|
const normalized = alias.trim().toLowerCase();
|
|
if (!normalized) continue;
|
|
exact.add(normalized);
|
|
const escaped = escapeRegExp(normalized);
|
|
if (!escaped) continue;
|
|
if (cmd.acceptsArgs) {
|
|
patterns.push(`${escaped}(?:\\s+.+|\\s*:\\s*.*)?`);
|
|
} else {
|
|
patterns.push(`${escaped}(?:\\s*:\\s*)?`);
|
|
}
|
|
}
|
|
}
|
|
cachedDetection = {
|
|
exact,
|
|
regex: patterns.length
|
|
? new RegExp(`^(?:${patterns.join("|")})$`, "i")
|
|
: /$^/,
|
|
};
|
|
return cachedDetection;
|
|
}
|
|
|
|
export function maybeResolveTextAlias(raw: string, cfg?: ClawdbotConfig) {
|
|
const trimmed = normalizeCommandBody(raw).trim();
|
|
if (!trimmed.startsWith("/")) return null;
|
|
const detection = getCommandDetection(cfg);
|
|
const normalized = trimmed.toLowerCase();
|
|
if (detection.exact.has(normalized)) return normalized;
|
|
if (!detection.regex.test(normalized)) return null;
|
|
const tokenMatch = normalized.match(/^\/([^\s:]+)(?:\s|$)/);
|
|
if (!tokenMatch) return null;
|
|
const tokenKey = `/${tokenMatch[1]}`;
|
|
return TEXT_ALIAS_MAP.has(tokenKey) ? tokenKey : null;
|
|
}
|
|
|
|
export function resolveTextCommand(
|
|
raw: string,
|
|
cfg?: ClawdbotConfig,
|
|
): {
|
|
command: ChatCommandDefinition;
|
|
args?: string;
|
|
} | null {
|
|
const trimmed = normalizeCommandBody(raw).trim();
|
|
const alias = maybeResolveTextAlias(trimmed, cfg);
|
|
if (!alias) return null;
|
|
const spec = TEXT_ALIAS_MAP.get(alias);
|
|
if (!spec) return null;
|
|
const command = CHAT_COMMANDS.find(
|
|
(entry) => `/${entry.key}` === spec.canonical,
|
|
);
|
|
if (!command) return null;
|
|
if (!spec.acceptsArgs) return { command };
|
|
const args = trimmed.slice(alias.length).trim();
|
|
return { command, args: args || undefined };
|
|
}
|
|
|
|
export function isNativeCommandSurface(surface?: string): boolean {
|
|
if (!surface) return false;
|
|
return getNativeCommandSurfaces().has(surface.toLowerCase());
|
|
}
|
|
|
|
export function shouldHandleTextCommands(params: {
|
|
cfg: ClawdbotConfig;
|
|
surface: string;
|
|
commandSource?: "text" | "native";
|
|
}): boolean {
|
|
if (params.commandSource === "native") return true;
|
|
if (params.cfg.commands?.text !== false) return true;
|
|
return !isNativeCommandSurface(params.surface);
|
|
}
|