From 6e17c463ae270bede6ae6a5cb92638359dea9a0b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 20 Jan 2026 13:19:55 +0000 Subject: [PATCH] fix: add /skill fallback for native limits Co-authored-by: thewilloftheshadow --- CHANGELOG.md | 1 + docs/tools/slash-commands.md | 2 ++ src/auto-reply/commands-registry.data.ts | 20 ++++++++++++++ src/auto-reply/commands-registry.test.ts | 2 ++ src/auto-reply/skill-commands.test.ts | 18 ++++++++++++ src/auto-reply/skill-commands.ts | 35 ++++++++++++++++++++++++ src/auto-reply/status.test.ts | 2 ++ src/auto-reply/status.ts | 1 + src/discord/monitor/provider.ts | 22 +++++++++++++-- 9 files changed, 101 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5a0971e3..7c450e17d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 7ddc2f4e7..89886e926 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -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 [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 [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. diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 547f92e88..3a124b9dc 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -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", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 8863e6ff5..54fb558bd 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -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); diff --git a/src/auto-reply/skill-commands.test.ts b/src/auto-reply/skill-commands.test.ts index 639a5d289..591e9574b 100644 --- a/src/auto-reply/skill-commands.test.ts +++ b/src/auto-reply/skill-commands.test.ts @@ -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", diff --git a/src/auto-reply/skill-commands.ts b/src/auto-reply/skill-commands.ts index 9e7fc0b92..e29960299 100644 --- a/src/auto-reply/skill-commands.ts +++ b/src/auto-reply/skill-commands.ts @@ -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(); diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index fd1e4d36f..6e1088707 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -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 [input]"); expect(text).not.toContain("/config"); expect(text).not.toContain("/debug"); }); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index cd3a4e332..fcaa29a8b 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -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 [input]", "More: /commands for all slash commands", ].join("\n"); } diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 213c774b0..9cc375491 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -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,