diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a4557e5..6bbf680e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ - Auto-reply: add per-channel/topic skill filters + system prompts for Discord/Slack/Telegram. Thanks @kitze for PR #286. - Auto-reply: refresh `/status` output with build info, compact context, and queue depth. - Commands: add `/stop` to the registry and route native aborts to the active chat session. Thanks @nachoiacovino for PR #295. +- Commands: allow `/` shorthand for `/model` using `agent.models.*.alias`, without shadowing built-ins. Thanks @azade-c for PR #393. - Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275. - Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes. - Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 17caaec78..58af62b71 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -42,7 +42,7 @@ Text + native (when enabled): - `/verbose on|off` (alias: `/v`) - `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only) - `/elevated on|off` (alias: `/elev`) -- `/model ` +- `/model ` (or `/` from `agent.models.*.alias`) - `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`) Text-only: diff --git a/src/auto-reply/model.test.ts b/src/auto-reply/model.test.ts index f4dd64221..85a3b3560 100644 --- a/src/auto-reply/model.test.ts +++ b/src/auto-reply/model.test.ts @@ -32,14 +32,18 @@ describe("extractModelDirective", () => { describe("alias shortcuts", () => { it("recognizes /gpt as model directive when alias is configured", () => { - const result = extractModelDirective("/gpt", { aliases: ["gpt", "sonnet", "opus"] }); + const result = extractModelDirective("/gpt", { + aliases: ["gpt", "sonnet", "opus"], + }); expect(result.hasDirective).toBe(true); expect(result.rawModel).toBe("gpt"); expect(result.cleaned).toBe(""); }); it("recognizes /sonnet as model directive", () => { - const result = extractModelDirective("/sonnet", { aliases: ["gpt", "sonnet", "opus"] }); + const result = extractModelDirective("/sonnet", { + aliases: ["gpt", "sonnet", "opus"], + }); expect(result.hasDirective).toBe(true); expect(result.rawModel).toBe("sonnet"); }); @@ -60,18 +64,24 @@ describe("extractModelDirective", () => { }); it("does not match alias without leading slash", () => { - const result = extractModelDirective("gpt is great", { aliases: ["gpt"] }); + const result = extractModelDirective("gpt is great", { + aliases: ["gpt"], + }); expect(result.hasDirective).toBe(false); }); it("does not match unknown aliases", () => { - const result = extractModelDirective("/unknown", { aliases: ["gpt", "sonnet"] }); + const result = extractModelDirective("/unknown", { + aliases: ["gpt", "sonnet"], + }); expect(result.hasDirective).toBe(false); expect(result.cleaned).toBe("/unknown"); }); it("prefers /model over alias when both present", () => { - const result = extractModelDirective("/model haiku", { aliases: ["gpt"] }); + const result = extractModelDirective("/model haiku", { + aliases: ["gpt"], + }); expect(result.hasDirective).toBe(true); expect(result.rawModel).toBe("haiku"); }); diff --git a/src/auto-reply/model.ts b/src/auto-reply/model.ts index 37adeeab8..f85cb4ba5 100644 --- a/src/auto-reply/model.ts +++ b/src/auto-reply/model.ts @@ -17,7 +17,9 @@ export function extractModelDirective( /(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i, ); - const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean); + const aliases = (options?.aliases ?? []) + .map((alias) => alias.trim()) + .filter(Boolean); const aliasMatch = modelMatch || aliases.length === 0 ? null diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 9f9105d44..a6014e8f9 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -144,6 +144,36 @@ describe("directive parsing", () => { expect(res.cleaned).toBe("please now"); }); + it("keeps reserved command aliases from matching after trimming", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/help", + From: "+1222", + To: "+1222", + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": { alias: " help " }, + }, + }, + whatsapp: { allowFrom: ["*"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Help"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("errors on invalid queue options", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index e7a063ee4..1ac4fabb7 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -321,7 +321,7 @@ export async function getReplyFromConfig( ), ); const configuredAliases = Object.values(cfg.agent?.models ?? {}) - .map((entry) => entry.alias) + .map((entry) => entry.alias?.trim()) .filter((alias): alias is string => Boolean(alias)) .filter((alias) => !reservedCommands.has(alias.toLowerCase())); let parsedDirectives = parseInlineDirectives(rawBody, {