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.
|
- 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: 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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):
|
Text + native (when enabled):
|
||||||
- `/help`
|
- `/help`
|
||||||
- `/commands`
|
- `/commands`
|
||||||
|
- `/skill <name> [input]` (run a skill by name)
|
||||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
- `/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)
|
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||||
- `/whoami` (show your sender id; alias: `/id`)
|
- `/whoami` (show your sender id; alias: `/id`)
|
||||||
@@ -102,6 +103,7 @@ Notes:
|
|||||||
- Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`).
|
- Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`).
|
||||||
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
|
- 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 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.
|
- 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).
|
- 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.
|
- **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.",
|
description: "List all slash commands.",
|
||||||
textAlias: "/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({
|
defineChatCommand({
|
||||||
key: "status",
|
key: "status",
|
||||||
nativeName: "status",
|
nativeName: "status",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ describe("commands registry", () => {
|
|||||||
const specs = listNativeCommandSpecs();
|
const specs = listNativeCommandSpecs();
|
||||||
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
|
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
|
||||||
expect(specs.find((spec) => spec.name === "stop")).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 === "whoami")).toBeTruthy();
|
||||||
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
|
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
|
||||||
});
|
});
|
||||||
@@ -81,6 +82,7 @@ describe("commands registry", () => {
|
|||||||
it("detects known text commands", () => {
|
it("detects known text commands", () => {
|
||||||
const detection = getCommandDetection();
|
const detection = getCommandDetection();
|
||||||
expect(detection.exact.has("/commands")).toBe(true);
|
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("/compact")).toBe(true);
|
||||||
expect(detection.exact.has("/whoami")).toBe(true);
|
expect(detection.exact.has("/whoami")).toBe(true);
|
||||||
expect(detection.exact.has("/id")).toBe(true);
|
expect(detection.exact.has("/id")).toBe(true);
|
||||||
|
|||||||
@@ -30,6 +30,24 @@ describe("resolveSkillCommandInvocation", () => {
|
|||||||
expect(invocation?.args).toBe("do the thing");
|
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", () => {
|
it("returns null for unknown commands", () => {
|
||||||
const invocation = resolveSkillCommandInvocation({
|
const invocation = resolveSkillCommandInvocation({
|
||||||
commandBodyNormalized: "/unknown arg",
|
commandBodyNormalized: "/unknown arg",
|
||||||
|
|||||||
@@ -55,6 +55,31 @@ export function listSkillCommandsForAgents(params: {
|
|||||||
return entries;
|
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: {
|
export function resolveSkillCommandInvocation(params: {
|
||||||
commandBodyNormalized: string;
|
commandBodyNormalized: string;
|
||||||
skillCommands: SkillCommandSpec[];
|
skillCommands: SkillCommandSpec[];
|
||||||
@@ -65,6 +90,16 @@ export function resolveSkillCommandInvocation(params: {
|
|||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const commandName = match[1]?.trim().toLowerCase();
|
const commandName = match[1]?.trim().toLowerCase();
|
||||||
if (!commandName) return null;
|
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);
|
const command = params.skillCommands.find((entry) => entry.name.toLowerCase() === commandName);
|
||||||
if (!command) return null;
|
if (!command) return null;
|
||||||
const args = match[2]?.trim();
|
const args = match[2]?.trim();
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ describe("buildCommandsMessage", () => {
|
|||||||
commands: { config: false, debug: false },
|
commands: { config: false, debug: false },
|
||||||
} as ClawdbotConfig);
|
} as ClawdbotConfig);
|
||||||
expect(text).toContain("/commands - List all slash commands.");
|
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("/think (aliases: /thinking, /t) - Set thinking level.");
|
||||||
expect(text).toContain("/compact (text-only) - Compact the session context.");
|
expect(text).toContain("/compact (text-only) - Compact the session context.");
|
||||||
expect(text).not.toContain("/config");
|
expect(text).not.toContain("/config");
|
||||||
@@ -394,6 +395,7 @@ describe("buildHelpMessage", () => {
|
|||||||
const text = buildHelpMessage({
|
const text = buildHelpMessage({
|
||||||
commands: { config: false, debug: false },
|
commands: { config: false, debug: false },
|
||||||
} as ClawdbotConfig);
|
} as ClawdbotConfig);
|
||||||
|
expect(text).toContain("Skills: /skill <name> [input]");
|
||||||
expect(text).not.toContain("/config");
|
expect(text).not.toContain("/config");
|
||||||
expect(text).not.toContain("/debug");
|
expect(text).not.toContain("/debug");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -391,6 +391,7 @@ export function buildHelpMessage(cfg?: ClawdbotConfig): string {
|
|||||||
"ℹ️ Help",
|
"ℹ️ Help",
|
||||||
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
|
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
|
||||||
`Options: ${options.join(" | ")}`,
|
`Options: ${options.join(" | ")}`,
|
||||||
|
"Skills: /skill <name> [input]",
|
||||||
"More: /commands for all slash commands",
|
"More: /commands for all slash commands",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,9 +321,27 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
throw new Error("Failed to resolve Discord application id");
|
throw new Error("Failed to resolve Discord application id");
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillCommands =
|
const maxDiscordCommands = 100;
|
||||||
|
let skillCommands =
|
||||||
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
|
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) =>
|
const commands = commandSpecs.map((spec) =>
|
||||||
createDiscordNativeCommand({
|
createDiscordNativeCommand({
|
||||||
command: spec,
|
command: spec,
|
||||||
|
|||||||
Reference in New Issue
Block a user