import type { ClawdbotConfig } from "../config/types.js"; import type { SkillCommandSpec } from "../agents/skills.js"; import { getChatCommands, getNativeCommandSurfaces } from "./commands-registry.data.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import type { ChatCommandDefinition, CommandArgChoiceContext, CommandArgDefinition, CommandArgMenuSpec, CommandArgValues, CommandArgs, CommandDetection, CommandNormalizeOptions, NativeCommandSpec, ShouldHandleTextCommandsParams, } from "./commands-registry.types.js"; export type { ChatCommandDefinition, CommandArgChoiceContext, CommandArgDefinition, CommandArgMenuSpec, CommandArgValues, CommandArgs, CommandDetection, CommandNormalizeOptions, CommandScope, NativeCommandSpec, ShouldHandleTextCommandsParams, } from "./commands-registry.types.js"; type TextAliasSpec = { key: string; canonical: string; acceptsArgs: boolean; }; let cachedTextAliasMap: Map | null = null; let cachedTextAliasCommands: ChatCommandDefinition[] | null = null; let cachedDetection: CommandDetection | undefined; let cachedDetectionCommands: ChatCommandDefinition[] | null = null; function getTextAliasMap(): Map { const commands = getChatCommands(); if (cachedTextAliasMap && cachedTextAliasCommands === commands) return cachedTextAliasMap; const map = new Map(); for (const command of commands) { // Canonicalize to the *primary* text alias, not `/${key}`. Some command keys are // internal identifiers (e.g. `dock:telegram`) while the public text command is // the alias (e.g. `/dock-telegram`). const canonical = command.textAliases[0]?.trim() || `/${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, { key: command.key, canonical, acceptsArgs }); } } } cachedTextAliasMap = map; cachedTextAliasCommands = commands; return map; } function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function buildSkillCommandDefinitions(skillCommands?: SkillCommandSpec[]): ChatCommandDefinition[] { if (!skillCommands || skillCommands.length === 0) return []; return skillCommands.map((spec) => ({ key: `skill:${spec.skillName}`, nativeName: spec.name, description: spec.description, textAliases: [`/${spec.name}`], acceptsArgs: true, argsParsing: "none", scope: "both", })); } export function listChatCommands(params?: { skillCommands?: SkillCommandSpec[]; }): ChatCommandDefinition[] { const commands = getChatCommands(); if (!params?.skillCommands?.length) return [...commands]; return [...commands, ...buildSkillCommandDefinitions(params.skillCommands)]; } export function isCommandEnabled(cfg: ClawdbotConfig, commandKey: string): boolean { if (commandKey === "config") return cfg.commands?.config === true; if (commandKey === "debug") return cfg.commands?.debug === true; if (commandKey === "bash") return cfg.commands?.bash === true; return true; } export function listChatCommandsForConfig( cfg: ClawdbotConfig, params?: { skillCommands?: SkillCommandSpec[] }, ): ChatCommandDefinition[] { const base = getChatCommands().filter((command) => isCommandEnabled(cfg, command.key)); if (!params?.skillCommands?.length) return base; return [...base, ...buildSkillCommandDefinitions(params.skillCommands)]; } export function listNativeCommandSpecs(params?: { skillCommands?: SkillCommandSpec[]; }): NativeCommandSpec[] { return listChatCommands({ skillCommands: params?.skillCommands }) .filter((command) => command.scope !== "text" && command.nativeName) .map((command) => ({ name: command.nativeName ?? command.key, description: command.description, acceptsArgs: Boolean(command.acceptsArgs), args: command.args, })); } export function listNativeCommandSpecsForConfig( cfg: ClawdbotConfig, params?: { skillCommands?: SkillCommandSpec[] }, ): NativeCommandSpec[] { return listChatCommandsForConfig(cfg, params) .filter((command) => command.scope !== "text" && command.nativeName) .map((command) => ({ name: command.nativeName ?? command.key, description: command.description, acceptsArgs: Boolean(command.acceptsArgs), args: command.args, })); } export function findCommandByNativeName(name: string): ChatCommandDefinition | undefined { const normalized = name.trim().toLowerCase(); return getChatCommands().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}`; } function parsePositionalArgs(definitions: CommandArgDefinition[], raw: string): CommandArgValues { const values: CommandArgValues = {}; const trimmed = raw.trim(); if (!trimmed) return values; const tokens = trimmed.split(/\s+/).filter(Boolean); let index = 0; for (const definition of definitions) { if (index >= tokens.length) break; if (definition.captureRemaining) { values[definition.name] = tokens.slice(index).join(" "); index = tokens.length; break; } values[definition.name] = tokens[index]; index += 1; } return values; } function formatPositionalArgs( definitions: CommandArgDefinition[], values: CommandArgValues, ): string | undefined { const parts: string[] = []; for (const definition of definitions) { const value = values[definition.name]; if (value == null) continue; let rendered: string; if (typeof value === "string") { rendered = value.trim(); } else { rendered = String(value); } if (!rendered) continue; parts.push(rendered); if (definition.captureRemaining) break; } return parts.length > 0 ? parts.join(" ") : undefined; } export function parseCommandArgs( command: ChatCommandDefinition, raw?: string, ): CommandArgs | undefined { const trimmed = raw?.trim(); if (!trimmed) return undefined; if (!command.args || command.argsParsing === "none") { return { raw: trimmed }; } return { raw: trimmed, values: parsePositionalArgs(command.args, trimmed), }; } export function serializeCommandArgs( command: ChatCommandDefinition, args?: CommandArgs, ): string | undefined { if (!args) return undefined; const raw = args.raw?.trim(); if (raw) return raw; if (!args.values || !command.args) return undefined; if (command.formatArgs) return command.formatArgs(args.values); return formatPositionalArgs(command.args, args.values); } export function buildCommandTextFromArgs( command: ChatCommandDefinition, args?: CommandArgs, ): string { const commandName = command.nativeName ?? command.key; return buildCommandText(commandName, serializeCommandArgs(command, args)); } function resolveDefaultCommandContext(cfg?: ClawdbotConfig): { provider: string; model: string; } { const resolved = resolveConfiguredModelRef({ cfg: cfg ?? ({} as ClawdbotConfig), defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); return { provider: resolved.provider ?? DEFAULT_PROVIDER, model: resolved.model ?? DEFAULT_MODEL, }; } export function resolveCommandArgChoices(params: { command: ChatCommandDefinition; arg: CommandArgDefinition; cfg?: ClawdbotConfig; provider?: string; model?: string; }): string[] { const { command, arg, cfg } = params; if (!arg.choices) return []; const provided = arg.choices; if (Array.isArray(provided)) return provided; const defaults = resolveDefaultCommandContext(cfg); const context: CommandArgChoiceContext = { cfg, provider: params.provider ?? defaults.provider, model: params.model ?? defaults.model, command, arg, }; return provided(context); } export function resolveCommandArgMenu(params: { command: ChatCommandDefinition; args?: CommandArgs; cfg?: ClawdbotConfig; }): { arg: CommandArgDefinition; choices: string[]; title?: string } | null { const { command, args, cfg } = params; if (!command.args || !command.argsMenu) return null; if (command.argsParsing === "none") return null; const argSpec = command.argsMenu; const argName = argSpec === "auto" ? command.args.find((arg) => resolveCommandArgChoices({ command, arg, cfg }).length > 0)?.name : argSpec.arg; if (!argName) return null; if (args?.values && args.values[argName] != null) return null; if (args?.raw && !args.values) return null; const arg = command.args.find((entry) => entry.name === argName); if (!arg) return null; const choices = resolveCommandArgChoices({ command, arg, cfg }); if (choices.length === 0) return null; const title = argSpec !== "auto" ? argSpec.title : undefined; return { arg, choices, title }; } 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 textAliasMap = getTextAliasMap(); const exact = textAliasMap.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 = textAliasMap.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): CommandDetection { const commands = getChatCommands(); if (cachedDetection && cachedDetectionCommands === commands) return cachedDetection; const exact = new Set(); const patterns: string[] = []; for (const cmd of 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") : /$^/, }; cachedDetectionCommands = commands; 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 getTextAliasMap().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 = getTextAliasMap().get(alias); if (!spec) return null; const command = getChatCommands().find((entry) => entry.key === spec.key); 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: ShouldHandleTextCommandsParams): boolean { if (params.commandSource === "native") return true; if (params.cfg.commands?.text !== false) return true; return !isNativeCommandSurface(params.surface); }