Commands: add dynamic arg menus
This commit is contained in:
committed by
Peter Steinberger
parent
7e1e7ba2d8
commit
74bc5bfd7c
66
src/auto-reply/commands-args.ts
Normal file
66
src/auto-reply/commands-args.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { CommandArgValues } from "./commands-registry.types.js";
|
||||
|
||||
export type CommandArgsFormatter = (values: CommandArgValues) => string | undefined;
|
||||
|
||||
function normalizeArgValue(value: unknown): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
const text = typeof value === "string" ? value.trim() : String(value).trim();
|
||||
return text ? text : undefined;
|
||||
}
|
||||
|
||||
const formatConfigArgs: CommandArgsFormatter = (values) => {
|
||||
const action = normalizeArgValue(values.action)?.toLowerCase();
|
||||
const path = normalizeArgValue(values.path);
|
||||
const value = normalizeArgValue(values.value);
|
||||
if (!action) return undefined;
|
||||
if (action === "show" || action === "get") {
|
||||
return path ? `${action} ${path}` : action;
|
||||
}
|
||||
if (action === "unset") {
|
||||
return path ? `${action} ${path}` : action;
|
||||
}
|
||||
if (action === "set") {
|
||||
if (!path) return action;
|
||||
if (!value) return `${action} ${path}`;
|
||||
return `${action} ${path}=${value}`;
|
||||
}
|
||||
return action;
|
||||
};
|
||||
|
||||
const formatDebugArgs: CommandArgsFormatter = (values) => {
|
||||
const action = normalizeArgValue(values.action)?.toLowerCase();
|
||||
const path = normalizeArgValue(values.path);
|
||||
const value = normalizeArgValue(values.value);
|
||||
if (!action) return undefined;
|
||||
if (action === "show" || action === "reset") {
|
||||
return action;
|
||||
}
|
||||
if (action === "unset") {
|
||||
return path ? `${action} ${path}` : action;
|
||||
}
|
||||
if (action === "set") {
|
||||
if (!path) return action;
|
||||
if (!value) return `${action} ${path}`;
|
||||
return `${action} ${path}=${value}`;
|
||||
}
|
||||
return action;
|
||||
};
|
||||
|
||||
const formatQueueArgs: CommandArgsFormatter = (values) => {
|
||||
const mode = normalizeArgValue(values.mode);
|
||||
const debounce = normalizeArgValue(values.debounce);
|
||||
const cap = normalizeArgValue(values.cap);
|
||||
const drop = normalizeArgValue(values.drop);
|
||||
const parts: string[] = [];
|
||||
if (mode) parts.push(mode);
|
||||
if (debounce) parts.push(`debounce:${debounce}`);
|
||||
if (cap) parts.push(`cap:${cap}`);
|
||||
if (drop) parts.push(`drop:${drop}`);
|
||||
return parts.length > 0 ? parts.join(" ") : undefined;
|
||||
};
|
||||
|
||||
export const COMMAND_ARG_FORMATTERS: Record<string, CommandArgsFormatter> = {
|
||||
config: formatConfigArgs,
|
||||
debug: formatDebugArgs,
|
||||
queue: formatQueueArgs,
|
||||
};
|
||||
@@ -1,10 +1,16 @@
|
||||
import { listChannelDocks } from "../channels/dock.js";
|
||||
import { listThinkingLevels } from "./thinking.js";
|
||||
import { COMMAND_ARG_FORMATTERS } from "./commands-args.js";
|
||||
import type { ChatCommandDefinition, CommandScope } from "./commands-registry.types.js";
|
||||
|
||||
type DefineChatCommandInput = {
|
||||
key: string;
|
||||
nativeName?: string;
|
||||
description: string;
|
||||
args?: ChatCommandDefinition["args"];
|
||||
argsParsing?: ChatCommandDefinition["argsParsing"];
|
||||
formatArgs?: ChatCommandDefinition["formatArgs"];
|
||||
argsMenu?: ChatCommandDefinition["argsMenu"];
|
||||
acceptsArgs?: boolean;
|
||||
textAlias?: string;
|
||||
textAliases?: string[];
|
||||
@@ -17,11 +23,17 @@ function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefiniti
|
||||
.filter(Boolean);
|
||||
const scope =
|
||||
command.scope ?? (command.nativeName ? (aliases.length ? "both" : "native") : "text");
|
||||
const acceptsArgs = command.acceptsArgs ?? Boolean(command.args?.length);
|
||||
const argsParsing = command.argsParsing ?? (command.args?.length ? "positional" : "none");
|
||||
return {
|
||||
key: command.key,
|
||||
nativeName: command.nativeName,
|
||||
description: command.description,
|
||||
acceptsArgs: command.acceptsArgs,
|
||||
acceptsArgs,
|
||||
args: command.args,
|
||||
argsParsing,
|
||||
formatArgs: command.formatArgs,
|
||||
argsMenu: command.argsMenu,
|
||||
textAliases: aliases,
|
||||
scope,
|
||||
};
|
||||
@@ -35,7 +47,6 @@ function defineDockCommand(dock: ChannelDock): ChatCommandDefinition {
|
||||
nativeName: `dock_${dock.id}`,
|
||||
description: `Switch to ${dock.id} for replies.`,
|
||||
textAliases: [`/dock-${dock.id}`, `/dock_${dock.id}`],
|
||||
acceptsArgs: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,21 +149,69 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
||||
nativeName: "config",
|
||||
description: "Show or set config values.",
|
||||
textAlias: "/config",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "action",
|
||||
description: "show | get | set | unset",
|
||||
type: "string",
|
||||
choices: ["show", "get", "set", "unset"],
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
description: "Config path",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
name: "value",
|
||||
description: "Value for set",
|
||||
type: "string",
|
||||
captureRemaining: true,
|
||||
},
|
||||
],
|
||||
argsParsing: "none",
|
||||
formatArgs: COMMAND_ARG_FORMATTERS.config,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "debug",
|
||||
nativeName: "debug",
|
||||
description: "Set runtime debug overrides.",
|
||||
textAlias: "/debug",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "action",
|
||||
description: "show | reset | set | unset",
|
||||
type: "string",
|
||||
choices: ["show", "reset", "set", "unset"],
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
description: "Debug path",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
name: "value",
|
||||
description: "Value for set",
|
||||
type: "string",
|
||||
captureRemaining: true,
|
||||
},
|
||||
],
|
||||
argsParsing: "none",
|
||||
formatArgs: COMMAND_ARG_FORMATTERS.debug,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "cost",
|
||||
nativeName: "cost",
|
||||
description: "Toggle per-response usage line.",
|
||||
textAlias: "/cost",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "on or off",
|
||||
type: "string",
|
||||
choices: ["on", "off"],
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "stop",
|
||||
@@ -171,14 +230,30 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
||||
nativeName: "activation",
|
||||
description: "Set group activation mode.",
|
||||
textAlias: "/activation",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "mention or always",
|
||||
type: "string",
|
||||
choices: ["mention", "always"],
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "send",
|
||||
nativeName: "send",
|
||||
description: "Set send policy.",
|
||||
textAlias: "/send",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "on, off, or inherit",
|
||||
type: "string",
|
||||
choices: ["on", "off", "inherit"],
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "reset",
|
||||
@@ -197,56 +272,133 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
||||
description: "Compact the session context.",
|
||||
textAlias: "/compact",
|
||||
scope: "text",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "instructions",
|
||||
description: "Extra compaction instructions",
|
||||
type: "string",
|
||||
captureRemaining: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "think",
|
||||
nativeName: "think",
|
||||
description: "Set thinking level.",
|
||||
textAlias: "/think",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "level",
|
||||
description: "off, minimal, low, medium, high, xhigh",
|
||||
type: "string",
|
||||
choices: ({ provider, model }) => listThinkingLevels(provider, model),
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "verbose",
|
||||
nativeName: "verbose",
|
||||
description: "Toggle verbose mode.",
|
||||
textAlias: "/verbose",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "on or off",
|
||||
type: "string",
|
||||
choices: ["on", "off"],
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "reasoning",
|
||||
nativeName: "reasoning",
|
||||
description: "Toggle reasoning visibility.",
|
||||
textAlias: "/reasoning",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "on, off, or stream",
|
||||
type: "string",
|
||||
choices: ["on", "off", "stream"],
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "elevated",
|
||||
nativeName: "elevated",
|
||||
description: "Toggle elevated mode.",
|
||||
textAlias: "/elevated",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "on or off",
|
||||
type: "string",
|
||||
choices: ["on", "off"],
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "model",
|
||||
nativeName: "model",
|
||||
description: "Show or set the model.",
|
||||
textAlias: "/model",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "model",
|
||||
description: "Model id (provider/model or id)",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "queue",
|
||||
nativeName: "queue",
|
||||
description: "Adjust queue settings.",
|
||||
textAlias: "/queue",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "queue mode",
|
||||
type: "string",
|
||||
choices: ["steer", "interrupt", "followup", "collect", "steer-backlog"],
|
||||
},
|
||||
{
|
||||
name: "debounce",
|
||||
description: "debounce duration (e.g. 500ms, 2s)",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
name: "cap",
|
||||
description: "queue cap",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
name: "drop",
|
||||
description: "drop policy",
|
||||
type: "string",
|
||||
choices: ["old", "new", "summarize"],
|
||||
},
|
||||
],
|
||||
argsParsing: "none",
|
||||
formatArgs: COMMAND_ARG_FORMATTERS.queue,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "bash",
|
||||
description: "Run host shell commands (host-only).",
|
||||
textAlias: "/bash",
|
||||
scope: "text",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "command",
|
||||
description: "Shell command",
|
||||
type: "string",
|
||||
captureRemaining: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
...listChannelDocks()
|
||||
.filter((dock) => dock.capabilities.nativeCommands)
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { ClawdbotConfig } from "../config/types.js";
|
||||
import { CHAT_COMMANDS, 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,
|
||||
@@ -11,6 +18,11 @@ import type {
|
||||
export { CHAT_COMMANDS } from "./commands-registry.data.js";
|
||||
export type {
|
||||
ChatCommandDefinition,
|
||||
CommandArgChoiceContext,
|
||||
CommandArgDefinition,
|
||||
CommandArgMenuSpec,
|
||||
CommandArgValues,
|
||||
CommandArgs,
|
||||
CommandDetection,
|
||||
CommandNormalizeOptions,
|
||||
CommandScope,
|
||||
@@ -70,6 +82,7 @@ export function listNativeCommandSpecs(): NativeCommandSpec[] {
|
||||
name: command.nativeName ?? command.key,
|
||||
description: command.description,
|
||||
acceptsArgs: Boolean(command.acceptsArgs),
|
||||
args: command.args,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -81,6 +94,7 @@ export function listNativeCommandSpecsForConfig(cfg: ClawdbotConfig): NativeComm
|
||||
name: command.nativeName ?? command.key,
|
||||
description: command.description,
|
||||
acceptsArgs: Boolean(command.acceptsArgs),
|
||||
args: command.args,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -96,6 +110,137 @@ export function buildCommandText(commandName: string, args?: string): string {
|
||||
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;
|
||||
const rendered = typeof value === "string" ? value.trim() : 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;
|
||||
|
||||
@@ -2,12 +2,51 @@ import type { ClawdbotConfig } from "../config/types.js";
|
||||
|
||||
export type CommandScope = "text" | "native" | "both";
|
||||
|
||||
export type CommandArgType = "string" | "number" | "boolean";
|
||||
|
||||
export type CommandArgChoiceContext = {
|
||||
cfg?: ClawdbotConfig;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
command: ChatCommandDefinition;
|
||||
arg: CommandArgDefinition;
|
||||
};
|
||||
|
||||
export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => string[];
|
||||
|
||||
export type CommandArgDefinition = {
|
||||
name: string;
|
||||
description: string;
|
||||
type: CommandArgType;
|
||||
required?: boolean;
|
||||
choices?: string[] | CommandArgChoicesProvider;
|
||||
captureRemaining?: boolean;
|
||||
};
|
||||
|
||||
export type CommandArgMenuSpec = {
|
||||
arg: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type CommandArgValues = Record<string, unknown>;
|
||||
|
||||
export type CommandArgs = {
|
||||
raw?: string;
|
||||
values?: CommandArgValues;
|
||||
};
|
||||
|
||||
export type CommandArgsParsing = "none" | "positional";
|
||||
|
||||
export type ChatCommandDefinition = {
|
||||
key: string;
|
||||
nativeName?: string;
|
||||
description: string;
|
||||
textAliases: string[];
|
||||
acceptsArgs?: boolean;
|
||||
args?: CommandArgDefinition[];
|
||||
argsParsing?: CommandArgsParsing;
|
||||
formatArgs?: (values: CommandArgValues) => string | undefined;
|
||||
argsMenu?: CommandArgMenuSpec | "auto";
|
||||
scope: CommandScope;
|
||||
};
|
||||
|
||||
@@ -15,6 +54,7 @@ export type NativeCommandSpec = {
|
||||
name: string;
|
||||
description: string;
|
||||
acceptsArgs: boolean;
|
||||
args?: CommandArgDefinition[];
|
||||
};
|
||||
|
||||
export type CommandNormalizeOptions = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import type { InternalMessageChannel } from "../utils/message-channel.js";
|
||||
import type { CommandArgs } from "./commands-registry.types.js";
|
||||
|
||||
/** Valid message channels for routing. */
|
||||
export type OriginatingChannelType = ChannelId | InternalMessageChannel;
|
||||
@@ -15,6 +16,7 @@ export type MsgContext = {
|
||||
* Prefer for command detection; RawBody is treated as legacy alias.
|
||||
*/
|
||||
CommandBody?: string;
|
||||
CommandArgs?: CommandArgs;
|
||||
From?: string;
|
||||
To?: string;
|
||||
SessionKey?: string;
|
||||
|
||||
Reference in New Issue
Block a user