fix: add /skill fallback for native limits

Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-20 13:19:55 +00:00
parent 63797e841d
commit 6e17c463ae
9 changed files with 101 additions and 2 deletions

View File

@@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.

View File

@@ -58,6 +58,7 @@ They run immediately, are stripped before the model sees the message, and the re
Text + native (when enabled):
- `/help`
- `/commands`
- `/skill <name> [input]` (run a skill by name)
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/whoami` (show your sender id; alias: `/id`)
@@ -102,6 +103,7 @@ Notes:
- Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`).
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`).
- `/skill <name> [input]` runs a skill by name (useful when native command limits prevent per-skill commands).
- By default, skill commands are forwarded to the model as a normal request.
- Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model).
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.

View File

@@ -131,6 +131,26 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "List all slash commands.",
textAlias: "/commands",
}),
defineChatCommand({
key: "skill",
nativeName: "skill",
description: "Run a skill by name.",
textAlias: "/skill",
args: [
{
name: "name",
description: "Skill name",
type: "string",
required: true,
},
{
name: "input",
description: "Skill input",
type: "string",
captureRemaining: true,
},
],
}),
defineChatCommand({
key: "status",
nativeName: "status",

View File

@@ -31,6 +31,7 @@ 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 === "skill")).toBeTruthy();
expect(specs.find((spec) => spec.name === "whoami")).toBeTruthy();
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
});
@@ -81,6 +82,7 @@ describe("commands registry", () => {
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true);
expect(detection.exact.has("/skill")).toBe(true);
expect(detection.exact.has("/compact")).toBe(true);
expect(detection.exact.has("/whoami")).toBe(true);
expect(detection.exact.has("/id")).toBe(true);

View File

@@ -30,6 +30,24 @@ describe("resolveSkillCommandInvocation", () => {
expect(invocation?.args).toBe("do the thing");
});
it("supports /skill with name argument", () => {
const invocation = resolveSkillCommandInvocation({
commandBodyNormalized: "/skill demo_skill do the thing",
skillCommands: [{ name: "demo_skill", skillName: "demo-skill", description: "Demo" }],
});
expect(invocation?.command.name).toBe("demo_skill");
expect(invocation?.args).toBe("do the thing");
});
it("normalizes /skill lookup names", () => {
const invocation = resolveSkillCommandInvocation({
commandBodyNormalized: "/skill demo-skill",
skillCommands: [{ name: "demo_skill", skillName: "demo-skill", description: "Demo" }],
});
expect(invocation?.command.name).toBe("demo_skill");
expect(invocation?.args).toBeUndefined();
});
it("returns null for unknown commands", () => {
const invocation = resolveSkillCommandInvocation({
commandBodyNormalized: "/unknown arg",

View File

@@ -55,6 +55,31 @@ export function listSkillCommandsForAgents(params: {
return entries;
}
function normalizeSkillCommandLookup(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[\s_]+/g, "-");
}
function findSkillCommand(
skillCommands: SkillCommandSpec[],
rawName: string,
): SkillCommandSpec | undefined {
const trimmed = rawName.trim();
if (!trimmed) return undefined;
const lowered = trimmed.toLowerCase();
const normalized = normalizeSkillCommandLookup(trimmed);
return skillCommands.find((entry) => {
if (entry.name.toLowerCase() === lowered) return true;
if (entry.skillName.toLowerCase() === lowered) return true;
return (
normalizeSkillCommandLookup(entry.name) === normalized ||
normalizeSkillCommandLookup(entry.skillName) === normalized
);
});
}
export function resolveSkillCommandInvocation(params: {
commandBodyNormalized: string;
skillCommands: SkillCommandSpec[];
@@ -65,6 +90,16 @@ export function resolveSkillCommandInvocation(params: {
if (!match) return null;
const commandName = match[1]?.trim().toLowerCase();
if (!commandName) return null;
if (commandName === "skill") {
const remainder = match[2]?.trim();
if (!remainder) return null;
const skillMatch = remainder.match(/^([^\s]+)(?:\s+([\s\S]+))?$/);
if (!skillMatch) return null;
const skillCommand = findSkillCommand(params.skillCommands, skillMatch[1] ?? "");
if (!skillCommand) return null;
const args = skillMatch[2]?.trim();
return { command: skillCommand, args: args || undefined };
}
const command = params.skillCommands.find((entry) => entry.name.toLowerCase() === commandName);
if (!command) return null;
const args = match[2]?.trim();

View File

@@ -366,6 +366,7 @@ describe("buildCommandsMessage", () => {
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).toContain("/commands - List all slash commands.");
expect(text).toContain("/skill - Run a skill by name.");
expect(text).toContain("/think (aliases: /thinking, /t) - Set thinking level.");
expect(text).toContain("/compact (text-only) - Compact the session context.");
expect(text).not.toContain("/config");
@@ -394,6 +395,7 @@ describe("buildHelpMessage", () => {
const text = buildHelpMessage({
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).toContain("Skills: /skill <name> [input]");
expect(text).not.toContain("/config");
expect(text).not.toContain("/debug");
});

View File

@@ -391,6 +391,7 @@ export function buildHelpMessage(cfg?: ClawdbotConfig): string {
" Help",
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
`Options: ${options.join(" | ")}`,
"Skills: /skill <name> [input]",
"More: /commands for all slash commands",
].join("\n");
}

View File

@@ -321,9 +321,27 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
throw new Error("Failed to resolve Discord application id");
}
const skillCommands =
const maxDiscordCommands = 100;
let skillCommands =
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
const commandSpecs = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) : [];
let commandSpecs = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) : [];
const initialCommandCount = commandSpecs.length;
if (nativeEnabled && nativeSkillsEnabled && commandSpecs.length > maxDiscordCommands) {
skillCommands = [];
commandSpecs = listNativeCommandSpecsForConfig(cfg, { skillCommands: [] });
runtime.log?.(
warn(
`discord: ${initialCommandCount} commands exceeds limit; removing per-skill commands and keeping /skill.`,
),
);
}
if (nativeEnabled && commandSpecs.length > maxDiscordCommands) {
runtime.log?.(
warn(
`discord: ${commandSpecs.length} commands exceeds limit; some commands may fail to deploy.`,
),
);
}
const commands = commandSpecs.map((spec) =>
createDiscordNativeCommand({
command: spec,