From 38b49aa0f6595d8a37b968c72ce651918fa4c749 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 20:11:01 +0000 Subject: [PATCH] feat: expand skill command registration --- CHANGELOG.md | 2 +- docs/tools/slash-commands.md | 4 ++ src/agents/agent-scope.ts | 14 +++++ src/agents/skills/workspace.ts | 15 ++++- ...ccepts-thinking-xhigh-codex-models.test.ts | 49 +++++++++++++++ src/auto-reply/reply/get-reply-directives.ts | 20 ++++++ .../reply/get-reply-inline-actions.ts | 19 +++--- src/auto-reply/reply/get-reply.ts | 4 ++ src/auto-reply/skill-commands.test.ts | 62 ++++++++++++++++++- src/auto-reply/skill-commands.ts | 26 ++++++++ src/config/commands.test.ts | 48 ++++++++++++++ src/config/commands.ts | 12 ++++ src/config/schema.ts | 9 +++ src/config/types.messages.ts | 4 ++ src/config/zod-schema.core.ts | 1 + src/config/zod-schema.session.ts | 3 +- src/discord/monitor/provider.ts | 19 +++--- src/slack/monitor/slash.ts | 18 +++--- src/telegram/bot-native-commands.ts | 12 ++-- src/telegram/bot.test.ts | 8 +-- src/telegram/bot.ts | 7 +++ 21 files changed, 311 insertions(+), 45 deletions(-) create mode 100644 src/config/commands.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c9007fb..4389fe1ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ - Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007. - Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee. - Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows. -- Skills: add user-invocable skill commands with sanitized names and an opt-out for model invocation. +- Skills: add user-invocable skill commands (sanitized + unique), native skill registration gating, and an opt-out for model invocation. - Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker. - TUI: show provider/model labels for the active session and default model. - Heartbeat: add per-agent heartbeat configuration and multi-agent docs example. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index f2f814be3..d4c7c6c43 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -26,6 +26,7 @@ They run immediately, are stripped before the model sees the message, and the re { commands: { native: "auto", + nativeSkills: "auto", text: true, bash: false, bashForegroundMs: 2000, @@ -43,6 +44,9 @@ They run immediately, are stripped before the model sees the message, and the re - Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support. - Set `channels.discord.commands.native`, `channels.telegram.commands.native`, or `channels.slack.commands.native` to override per provider (bool or `"auto"`). - `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically. +- `commands.nativeSkills` (default `"auto"`) registers **skill** commands natively when supported. + - Auto: on for Discord/Telegram; off for Slack (Slack requires creating a slash command per skill). + - Set `channels.discord.commands.nativeSkills`, `channels.telegram.commands.nativeSkills`, or `channels.slack.commands.nativeSkills` to override per provider (bool or `"auto"`). - `commands.bash` (default `false`) enables `! ` to run host shell commands (`/bash ` is an alias; requires `tools.elevated` allowlists). - `commands.bashForegroundMs` (default `2000`) controls how long bash waits before switching to background mode (`0` backgrounds immediately). - `commands.config` (default `false`) enables `/config` (reads/writes `clawdbot.json`). diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index db48002e3..a765bc109 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -38,6 +38,20 @@ function listAgents(cfg: ClawdbotConfig): AgentEntry[] { return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object")); } +export function listAgentIds(cfg: ClawdbotConfig): string[] { + const agents = listAgents(cfg); + if (agents.length === 0) return [DEFAULT_AGENT_ID]; + const seen = new Set(); + const ids: string[] = []; + for (const entry of agents) { + const id = normalizeAgentId(entry?.id); + if (seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids.length > 0 ? ids : [DEFAULT_AGENT_ID]; +} + export function resolveDefaultAgentId(cfg: ClawdbotConfig): string { const agents = listAgents(cfg); if (agents.length === 0) return DEFAULT_AGENT_ID; diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index 46911dfb6..e6f88c142 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -321,17 +321,26 @@ export function buildWorkspaceSkillCommandSpecs( const specs: SkillCommandSpec[] = []; for (const entry of userInvocable) { - const base = sanitizeSkillCommandName(entry.skill.name); + const rawName = entry.skill.name; + const base = sanitizeSkillCommandName(rawName); + if (base !== rawName) { + console.warn(`[skills] Sanitized skill command name "${rawName}" to "/${base}".`); + } const unique = resolveUniqueSkillCommandName(base, used); + if (unique !== base) { + console.warn( + `[skills] De-duplicated skill command name for "${rawName}" to "/${unique}".`, + ); + } used.add(unique.toLowerCase()); - const rawDescription = entry.skill.description?.trim() || entry.skill.name; + const rawDescription = entry.skill.description?.trim() || rawName; const description = rawDescription.length > SKILL_COMMAND_DESCRIPTION_MAX_LENGTH ? rawDescription.slice(0, SKILL_COMMAND_DESCRIPTION_MAX_LENGTH - 1) + "…" : rawDescription; specs.push({ name: unique, - skillName: entry.skill.name, + skillName: rawName, description, }); } diff --git a/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts b/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts index 0fe82b0d7..b627f4142 100644 --- a/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; @@ -8,6 +9,17 @@ import { getReplyFromConfig } from "./reply.js"; const MAIN_SESSION_KEY = "agent:main:main"; +async function writeSkill(params: { workspaceDir: string; name: string; description: string }) { + const { workspaceDir, name, description } = params; + const skillDir = path.join(workspaceDir, "skills", name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`, + "utf-8", + ); +} + vi.mock("../agents/pi-embedded.js", () => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), runEmbeddedPiAgent: vi.fn(), @@ -174,6 +186,43 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); + it("treats skill commands as reserved for model aliases", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + const workspace = path.join(home, "clawd"); + await writeSkill({ + workspaceDir: workspace, + name: "demo-skill", + description: "Demo skill", + }); + + await getReplyFromConfig( + { + Body: "/demo_skill", + From: "+1222", + To: "+1222", + }, + {}, + { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace, + models: { + "anthropic/claude-opus-4-5": { alias: "demo_skill" }, + }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + expect(runEmbeddedPiAgent).toHaveBeenCalled(); + const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; + expect(prompt).toContain('Use the "demo-skill" skill'); + }); + }); it("errors on invalid queue options", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index 788c6c3cc..f0c062d4b 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -1,8 +1,10 @@ import type { ModelAliasIndex } from "../../agents/model-selection.js"; +import type { SkillCommandSpec } from "../../agents/skills.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import type { ClawdbotConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { listChatCommands, shouldHandleTextCommands } from "../commands-registry.js"; +import { listSkillCommandsForWorkspace } from "../skill-commands.js"; import type { MsgContext, TemplateContext } from "../templating.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; @@ -24,6 +26,7 @@ export type ReplyDirectiveContinuation = { commandSource: string; command: ReturnType; allowTextCommands: boolean; + skillCommands?: SkillCommandSpec[]; directives: InlineDirectives; cleanedBody: string; messageProviderKey: string; @@ -65,6 +68,7 @@ export async function resolveReplyDirectives(params: { cfg: ClawdbotConfig; agentId: string; agentDir: string; + workspaceDir: string; agentCfg: AgentDefaults; sessionCtx: TemplateContext; sessionEntry?: SessionEntry; @@ -83,6 +87,7 @@ export async function resolveReplyDirectives(params: { model: string; typing: TypingController; opts?: GetReplyOptions; + skillFilter?: string[]; }): Promise { const { ctx, @@ -90,6 +95,7 @@ export async function resolveReplyDirectives(params: { agentId, agentCfg, agentDir, + workspaceDir, sessionCtx, sessionEntry, sessionStore, @@ -106,6 +112,7 @@ export async function resolveReplyDirectives(params: { model: initialModel, typing, opts, + skillFilter, } = params; let provider = initialProvider; let model = initialModel; @@ -132,11 +139,23 @@ export async function resolveReplyDirectives(params: { surface: command.surface, commandSource: ctx.CommandSource, }); + const shouldResolveSkillCommands = + allowTextCommands && command.commandBodyNormalized.includes("/"); + const skillCommands = shouldResolveSkillCommands + ? listSkillCommandsForWorkspace({ + workspaceDir, + cfg, + skillFilter, + }) + : []; const reservedCommands = new Set( listChatCommands().flatMap((cmd) => cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()), ), ); + for (const command of skillCommands) { + reservedCommands.add(command.name.toLowerCase()); + } const configuredAliases = Object.values(cfg.agents?.defaults?.models ?? {}) .map((entry) => entry.alias?.trim()) .filter((alias): alias is string => Boolean(alias)) @@ -385,6 +404,7 @@ export async function resolveReplyDirectives(params: { commandSource, command, allowTextCommands, + skillCommands, directives, cleanedBody, messageProviderKey, diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 1a46aa926..c1e648198 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -1,4 +1,5 @@ import { getChannelDock } from "../../channels/dock.js"; +import type { SkillCommandSpec } from "../../agents/skills.js"; import type { ClawdbotConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { MsgContext, TemplateContext } from "../templating.js"; @@ -39,6 +40,7 @@ export async function handleInlineActions(params: { allowTextCommands: boolean; inlineStatusRequested: boolean; command: Parameters[0]["command"]; + skillCommands?: SkillCommandSpec[]; directives: InlineDirectives; cleanedBody: string; elevatedEnabled: boolean; @@ -99,13 +101,16 @@ export async function handleInlineActions(params: { let cleanedBody = initialCleanedBody; const shouldLoadSkillCommands = command.commandBodyNormalized.startsWith("/"); - const skillCommands = shouldLoadSkillCommands - ? listSkillCommandsForWorkspace({ - workspaceDir, - cfg, - skillFilter, - }) - : []; + const skillCommands = + shouldLoadSkillCommands && params.skillCommands + ? params.skillCommands + : shouldLoadSkillCommands + ? listSkillCommandsForWorkspace({ + workspaceDir, + cfg, + skillFilter, + }) + : []; const skillInvocation = allowTextCommands && skillCommands.length > 0 diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index b696a4231..26d945675 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -118,6 +118,7 @@ export async function getReplyFromConfig( cfg, agentId, agentDir, + workspaceDir, agentCfg, sessionCtx, sessionEntry, @@ -136,6 +137,7 @@ export async function getReplyFromConfig( model, typing, opts, + skillFilter: opts?.skillFilter, }); if (directiveResult.kind === "reply") { return directiveResult.reply; @@ -145,6 +147,7 @@ export async function getReplyFromConfig( commandSource, command, allowTextCommands, + skillCommands, directives, cleanedBody, elevatedEnabled, @@ -187,6 +190,7 @@ export async function getReplyFromConfig( allowTextCommands, inlineStatusRequested, command, + skillCommands, directives, cleanedBody, elevatedEnabled, diff --git a/src/auto-reply/skill-commands.test.ts b/src/auto-reply/skill-commands.test.ts index dcf844c7d..639a5d289 100644 --- a/src/auto-reply/skill-commands.test.ts +++ b/src/auto-reply/skill-commands.test.ts @@ -1,5 +1,24 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveSkillCommandInvocation } from "./skill-commands.js"; +import { listSkillCommandsForAgents, resolveSkillCommandInvocation } from "./skill-commands.js"; + +async function writeSkill(params: { + workspaceDir: string; + dirName: string; + name: string; + description: string; +}) { + const { workspaceDir, dirName, name, description } = params; + const skillDir = path.join(workspaceDir, "skills", dirName); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`, + "utf-8", + ); +} describe("resolveSkillCommandInvocation", () => { it("matches skill commands and parses args", () => { @@ -19,3 +38,44 @@ describe("resolveSkillCommandInvocation", () => { expect(invocation).toBeNull(); }); }); + +describe("listSkillCommandsForAgents", () => { + it("merges command names across agents and de-duplicates", async () => { + const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-skills-")); + const mainWorkspace = path.join(baseDir, "main"); + const researchWorkspace = path.join(baseDir, "research"); + await writeSkill({ + workspaceDir: mainWorkspace, + dirName: "demo", + name: "demo-skill", + description: "Demo skill", + }); + await writeSkill({ + workspaceDir: researchWorkspace, + dirName: "demo2", + name: "demo-skill", + description: "Demo skill 2", + }); + await writeSkill({ + workspaceDir: researchWorkspace, + dirName: "extra", + name: "extra-skill", + description: "Extra skill", + }); + + const commands = listSkillCommandsForAgents({ + cfg: { + agents: { + list: [ + { id: "main", workspace: mainWorkspace }, + { id: "research", workspace: researchWorkspace }, + ], + }, + }, + }); + const names = commands.map((entry) => entry.name); + expect(names).toContain("demo_skill"); + expect(names).toContain("demo_skill_2"); + expect(names).toContain("extra_skill"); + }); +}); diff --git a/src/auto-reply/skill-commands.ts b/src/auto-reply/skill-commands.ts index f9266ea8e..9e7fc0b92 100644 --- a/src/auto-reply/skill-commands.ts +++ b/src/auto-reply/skill-commands.ts @@ -1,4 +1,7 @@ +import fs from "node:fs"; + import type { ClawdbotConfig } from "../config/config.js"; +import { listAgentIds, resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { getRemoteSkillEligibility } from "../infra/skills-remote.js"; import { buildWorkspaceSkillCommandSpecs, type SkillCommandSpec } from "../agents/skills.js"; import { listChatCommands } from "./commands-registry.js"; @@ -29,6 +32,29 @@ export function listSkillCommandsForWorkspace(params: { }); } +export function listSkillCommandsForAgents(params: { + cfg: ClawdbotConfig; + agentIds?: string[]; +}): SkillCommandSpec[] { + const used = resolveReservedCommandNames(); + const entries: SkillCommandSpec[] = []; + const agentIds = params.agentIds ?? listAgentIds(params.cfg); + for (const agentId of agentIds) { + const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId); + if (!fs.existsSync(workspaceDir)) continue; + const commands = buildWorkspaceSkillCommandSpecs(workspaceDir, { + config: params.cfg, + eligibility: { remote: getRemoteSkillEligibility() }, + reservedNames: used, + }); + for (const command of commands) { + used.add(command.name.toLowerCase()); + entries.push(command); + } + } + return entries; +} + export function resolveSkillCommandInvocation(params: { commandBodyNormalized: string; skillCommands: SkillCommandSpec[]; diff --git a/src/config/commands.test.ts b/src/config/commands.test.ts new file mode 100644 index 000000000..9324d82df --- /dev/null +++ b/src/config/commands.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { resolveNativeSkillsEnabled } from "./commands.js"; + +describe("resolveNativeSkillsEnabled", () => { + it("uses provider defaults for auto", () => { + expect( + resolveNativeSkillsEnabled({ + providerId: "discord", + globalSetting: "auto", + }), + ).toBe(true); + expect( + resolveNativeSkillsEnabled({ + providerId: "telegram", + globalSetting: "auto", + }), + ).toBe(true); + expect( + resolveNativeSkillsEnabled({ + providerId: "slack", + globalSetting: "auto", + }), + ).toBe(false); + expect( + resolveNativeSkillsEnabled({ + providerId: "whatsapp", + globalSetting: "auto", + }), + ).toBe(false); + }); + + it("honors explicit provider settings", () => { + expect( + resolveNativeSkillsEnabled({ + providerId: "slack", + providerSetting: true, + globalSetting: "auto", + }), + ).toBe(true); + expect( + resolveNativeSkillsEnabled({ + providerId: "discord", + providerSetting: false, + globalSetting: true, + }), + ).toBe(false); + }); +}); diff --git a/src/config/commands.ts b/src/config/commands.ts index 5b8b9fe65..4e3074cf2 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -10,6 +10,18 @@ function resolveAutoDefault(providerId?: ChannelId): boolean { return false; } +export function resolveNativeSkillsEnabled(params: { + providerId: ChannelId; + providerSetting?: NativeCommandsSetting; + globalSetting?: NativeCommandsSetting; +}): boolean { + const { providerId, providerSetting, globalSetting } = params; + const setting = providerSetting === undefined ? globalSetting : providerSetting; + if (setting === true) return true; + if (setting === false) return false; + return resolveAutoDefault(providerId); +} + export function resolveNativeCommandsEnabled(params: { providerId: ChannelId; providerSetting?: NativeCommandsSetting; diff --git a/src/config/schema.ts b/src/config/schema.ts index 923ed4455..475d02130 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -158,6 +158,7 @@ const FIELD_LABELS: Record = { "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", "agents.defaults.cliBackends": "CLI Backends", "commands.native": "Native Commands", + "commands.nativeSkills": "Native Skill Commands", "commands.text": "Text Commands", "commands.bash": "Allow Bash Chat Command", "commands.bashForegroundMs": "Bash Foreground Window (ms)", @@ -323,6 +324,8 @@ const FIELD_HELP: Record = { "agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).", "commands.native": "Register native commands with channels that support it (Discord/Slack/Telegram).", + "commands.nativeSkills": + "Register native skill commands (user-invocable skills) with channels that support it.", "commands.text": "Allow text command parsing (slash commands only).", "commands.bash": "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", @@ -349,8 +352,14 @@ const FIELD_HELP: Record = { "channels.msteams.configWrites": "Allow Microsoft Teams to write config in response to channel events/commands (default: true).", "channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").', + "channels.discord.commands.nativeSkills": + 'Override native skill commands for Discord (bool or "auto").', "channels.telegram.commands.native": 'Override native commands for Telegram (bool or "auto").', + "channels.telegram.commands.nativeSkills": + 'Override native skill commands for Telegram (bool or "auto").', "channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").', + "channels.slack.commands.nativeSkills": + 'Override native skill commands for Slack (bool or "auto").', "session.agentToAgent.maxPingPongTurns": "Max reply-back turns between requester and target (0–5).", "channels.telegram.customCommands": diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 61a4655f3..40ed2d614 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -95,6 +95,8 @@ export type NativeCommandsSetting = boolean | "auto"; export type CommandsConfig = { /** Enable native command registration when supported (default: "auto"). */ native?: NativeCommandsSetting; + /** Enable native skill command registration when supported (default: "auto"). */ + nativeSkills?: NativeCommandsSetting; /** Enable text command parsing (default: true). */ text?: boolean; /** Allow bash chat command (`!`; `/bash` alias) (default: false). */ @@ -114,4 +116,6 @@ export type CommandsConfig = { export type ProviderCommandsConfig = { /** Override native command registration for this provider (bool or "auto"). */ native?: NativeCommandsSetting; + /** Override native skill command registration for this provider (bool or "auto"). */ + nativeSkills?: NativeCommandsSetting; }; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 5510e8d74..78327f120 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -252,5 +252,6 @@ export const NativeCommandsSettingSchema = z.union([z.boolean(), z.literal("auto export const ProviderCommandsSchema = z .object({ native: NativeCommandsSettingSchema.optional(), + nativeSkills: NativeCommandsSettingSchema.optional(), }) .optional(); diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index d0ffecfc3..8f5f6573b 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -72,6 +72,7 @@ export const MessagesSchema = z export const CommandsSchema = z .object({ native: NativeCommandsSettingSchema.optional().default("auto"), + nativeSkills: NativeCommandsSettingSchema.optional().default("auto"), text: z.boolean().optional(), bash: z.boolean().optional(), bashForegroundMs: z.number().int().min(0).max(30_000).optional(), @@ -81,4 +82,4 @@ export const CommandsSchema = z useAccessGroups: z.boolean().optional(), }) .optional() - .default({ native: "auto" }); + .default({ native: "auto", nativeSkills: "auto" }); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index e856afbd9..c4c329c15 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -3,12 +3,12 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"; import { Routes } from "discord-api-types/v10"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js"; -import { listSkillCommandsForWorkspace } from "../../auto-reply/skill-commands.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; import type { HistoryEntry } from "../../auto-reply/reply/history.js"; import { isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, + resolveNativeSkillsEnabled, } from "../../config/commands.js"; import type { ClawdbotConfig, ReplyToMode } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; @@ -99,6 +99,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { providerSetting: discordCfg.commands?.native, globalSetting: cfg.commands?.native, }); + const nativeSkillsEnabled = resolveNativeSkillsEnabled({ + providerId: "discord", + providerSetting: discordCfg.commands?.nativeSkills, + globalSetting: cfg.commands?.nativeSkills, + }); const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({ providerSetting: discordCfg.commands?.native, globalSetting: cfg.commands?.native, @@ -109,7 +114,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { if (shouldLogVerbose()) { logVerbose( - `discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`, + `discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`, ); } @@ -118,12 +123,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { throw new Error("Failed to resolve Discord application id"); } - const skillCommands = nativeEnabled - ? listSkillCommandsForWorkspace({ - workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), - cfg, - }) - : []; + const skillCommands = + nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : []; const commandSpecs = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) : []; const commands = commandSpecs.map((spec) => createDiscordNativeCommand({ diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index 7bf03bc9e..3d856750e 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -8,10 +8,9 @@ import { parseCommandArgs, resolveCommandArgMenu, } from "../../auto-reply/commands-registry.js"; -import { listSkillCommandsForWorkspace } from "../../auto-reply/skill-commands.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; -import { resolveNativeCommandsEnabled } from "../../config/commands.js"; +import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js"; import { danger, logVerbose } from "../../globals.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { @@ -405,12 +404,13 @@ export function registerSlackMonitorSlashCommands(params: { providerSetting: account.config.commands?.native, globalSetting: cfg.commands?.native, }); - const skillCommands = nativeEnabled - ? listSkillCommandsForWorkspace({ - workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), - cfg, - }) - : []; + const nativeSkillsEnabled = resolveNativeSkillsEnabled({ + providerId: "slack", + providerSetting: account.config.commands?.nativeSkills, + globalSetting: cfg.commands?.nativeSkills, + }); + const skillCommands = + nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : []; const nativeCommands = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) : []; diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 74f04e21b..4c9cd1543 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -9,8 +9,7 @@ import { parseCommandArgs, resolveCommandArgMenu, } from "../auto-reply/commands-registry.js"; -import { listSkillCommandsForWorkspace } from "../auto-reply/skill-commands.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; import type { CommandArgs } from "../auto-reply/commands-registry.js"; import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; @@ -39,18 +38,15 @@ export const registerTelegramNativeCommands = ({ textLimit, useAccessGroups, nativeEnabled, + nativeSkillsEnabled, nativeDisabledExplicit, resolveGroupPolicy, resolveTelegramGroupConfig, shouldSkipUpdate, opts, }) => { - const skillCommands = nativeEnabled - ? listSkillCommandsForWorkspace({ - workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), - cfg, - }) - : []; + const skillCommands = + nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : []; const nativeCommands = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) : []; diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 67a9448c0..00098c1df 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -6,18 +6,14 @@ import { listNativeCommandSpecs, listNativeCommandSpecsForConfig, } from "../auto-reply/commands-registry.js"; -import { listSkillCommandsForWorkspace } from "../auto-reply/skill-commands.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import * as replyModule from "../auto-reply/reply.js"; import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { resolveTelegramFetch } from "./fetch.js"; function resolveSkillCommands(config: Parameters[0]) { - return listSkillCommandsForWorkspace({ - workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)), - cfg: config, - }); + return listSkillCommandsForAgents({ cfg: config }); } const { loadWebMedia } = vi.hoisted(() => ({ diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 31030516c..53755ec00 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -10,6 +10,7 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/re import { isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, + resolveNativeSkillsEnabled, } from "../config/commands.js"; import type { ClawdbotConfig, ReplyToMode } from "../config/config.js"; import { loadConfig } from "../config/config.js"; @@ -190,6 +191,11 @@ export function createTelegramBot(opts: TelegramBotOptions) { providerSetting: telegramCfg.commands?.native, globalSetting: cfg.commands?.native, }); + const nativeSkillsEnabled = resolveNativeSkillsEnabled({ + providerId: "telegram", + providerSetting: telegramCfg.commands?.nativeSkills, + globalSetting: cfg.commands?.nativeSkills, + }); const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({ providerSetting: telegramCfg.commands?.native, globalSetting: cfg.commands?.native, @@ -297,6 +303,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { textLimit, useAccessGroups, nativeEnabled, + nativeSkillsEnabled, nativeDisabledExplicit, resolveGroupPolicy, resolveTelegramGroupConfig,