fix: add /skill fallback for native limits
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user