diff --git a/src/channels/discord.ts b/src/channels/discord.ts index dca9aac..a926342 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -56,7 +56,7 @@ export class DiscordAdapter implements ChannelAdapter { private attachmentsMaxBytes?: number; onMessage?: (msg: InboundMessage) => Promise; - onCommand?: (command: string, chatId?: string) => Promise; + onCommand?: (command: string, chatId?: string, args?: string) => Promise; constructor(config: DiscordConfig) { this.config = { @@ -246,14 +246,16 @@ Ask the bot owner to approve with: if (!content && attachments.length === 0) return; if (content.startsWith('/')) { - const command = content.slice(1).split(/\s+/)[0]?.toLowerCase(); + const parts = content.slice(1).split(/\s+/); + const command = parts[0]?.toLowerCase(); + const cmdArgs = parts.slice(1).join(' ') || undefined; if (command === 'help' || command === 'start') { await message.channel.send(HELP_TEXT); return; } if (this.onCommand) { - if (command === 'status' || command === 'reset' || command === 'heartbeat') { - const result = await this.onCommand(command, message.channel.id); + if (command === 'status' || command === 'reset' || command === 'heartbeat' || command === 'model') { + const result = await this.onCommand(command, message.channel.id, cmdArgs); if (result) { await message.channel.send(result); } diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 77d38a6..b81ef84 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -163,7 +163,7 @@ export class SignalAdapter implements ChannelAdapter { private baseUrl: string; onMessage?: (msg: InboundMessage) => Promise; - onCommand?: (command: string, chatId?: string) => Promise; + onCommand?: (command: string, chatId?: string, args?: string) => Promise; constructor(config: SignalConfig) { this.config = { @@ -810,12 +810,12 @@ This code expires in 1 hour.`; } // Handle slash commands - const command = parseCommand(messageText); - if (command) { - if (command === 'help' || command === 'start') { + const parsed = parseCommand(messageText); + if (parsed) { + if (parsed.command === 'help' || parsed.command === 'start') { await this.sendMessage({ chatId, text: HELP_TEXT }); } else if (this.onCommand) { - const result = await this.onCommand(command, chatId); + const result = await this.onCommand(parsed.command, chatId, parsed.args || undefined); if (result) await this.sendMessage({ chatId, text: result }); } return; // Don't pass commands to agent diff --git a/src/channels/slack.ts b/src/channels/slack.ts index 5207773..083d02a 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -40,7 +40,7 @@ export class SlackAdapter implements ChannelAdapter { private attachmentsMaxBytes?: number; onMessage?: (msg: InboundMessage) => Promise; - onCommand?: (command: string, chatId?: string) => Promise; + onCommand?: (command: string, chatId?: string, args?: string) => Promise; constructor(config: SlackConfig) { this.config = config; @@ -114,12 +114,12 @@ export class SlackAdapter implements ChannelAdapter { } // Handle slash commands - const command = parseCommand(text); - if (command) { - if (command === 'help' || command === 'start') { + const parsed = parseCommand(text); + if (parsed) { + if (parsed.command === 'help' || parsed.command === 'start') { await say(await markdownToSlackMrkdwn(HELP_TEXT)); } else if (this.onCommand) { - const result = await this.onCommand(command, channelId); + const result = await this.onCommand(parsed.command, channelId, parsed.args || undefined); if (result) await say(await markdownToSlackMrkdwn(result)); } return; // Don't pass commands to agent @@ -231,12 +231,12 @@ export class SlackAdapter implements ChannelAdapter { } // Handle slash commands - const command = parseCommand(text); - if (command) { - if (command === 'help' || command === 'start') { + const parsed = parseCommand(text); + if (parsed) { + if (parsed.command === 'help' || parsed.command === 'start') { await this.sendMessage({ chatId: channelId, text: HELP_TEXT, threadId: threadTs }); } else if (this.onCommand) { - const result = await this.onCommand(command, channelId); + const result = await this.onCommand(parsed.command, channelId, parsed.args || undefined); if (result) await this.sendMessage({ chatId: channelId, text: result, threadId: threadTs }); } return; // Don't pass commands to agent diff --git a/src/channels/telegram-mtproto.ts b/src/channels/telegram-mtproto.ts index 7401358..44497ea 100644 --- a/src/channels/telegram-mtproto.ts +++ b/src/channels/telegram-mtproto.ts @@ -78,7 +78,7 @@ export class TelegramMTProtoAdapter implements ChannelAdapter { private pendingPairingApprovals = new Map(); onMessage?: (msg: InboundMessage) => Promise; - onCommand?: (command: string, chatId?: string) => Promise; + onCommand?: (command: string, chatId?: string, args?: string) => Promise; constructor(config: TelegramMTProtoConfig) { this.config = { diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index d7af701..609ce37 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -44,7 +44,7 @@ export class TelegramAdapter implements ChannelAdapter { private attachmentsMaxBytes?: number; onMessage?: (msg: InboundMessage) => Promise; - onCommand?: (command: string, chatId?: string) => Promise; + onCommand?: (command: string, chatId?: string, args?: string) => Promise; constructor(config: TelegramConfig) { this.config = { @@ -258,6 +258,15 @@ export class TelegramAdapter implements ChannelAdapter { await ctx.reply(result || 'Reset complete'); } }); + + // Handle /model [handle] + this.bot.command('model', async (ctx) => { + if (this.onCommand) { + const args = ctx.match?.trim() || undefined; + const result = await this.onCommand('model', String(ctx.chat.id), args); + await ctx.reply(result || 'No model info available'); + } + }); // Handle text messages this.bot.on('message:text', async (ctx) => { diff --git a/src/channels/types.ts b/src/channels/types.ts index 13547c6..d7d82d4 100644 --- a/src/channels/types.ts +++ b/src/channels/types.ts @@ -32,7 +32,7 @@ export interface ChannelAdapter { // Event handlers (set by bot core) onMessage?: (msg: InboundMessage) => Promise; - onCommand?: (command: string, chatId?: string) => Promise; + onCommand?: (command: string, chatId?: string, args?: string) => Promise; } /** diff --git a/src/channels/whatsapp/index.ts b/src/channels/whatsapp/index.ts index 23e5a56..39c5e28 100644 --- a/src/channels/whatsapp/index.ts +++ b/src/channels/whatsapp/index.ts @@ -181,7 +181,7 @@ export class WhatsAppAdapter implements ChannelAdapter { // Event handlers (set by bot core) onMessage?: (msg: InboundMessage) => Promise; - onCommand?: (command: string, chatId?: string) => Promise; + onCommand?: (command: string, chatId?: string, args?: string) => Promise; // Pre-bound handlers (created once to avoid bind() overhead) private boundHandleConnectionUpdate: (update: Partial) => void; @@ -809,12 +809,12 @@ export class WhatsAppAdapter implements ChannelAdapter { } // Handle slash commands (before debouncing) - const command = parseCommand(body); - if (command && !isHistory) { - if (command === 'help' || command === 'start') { + const parsed = parseCommand(body); + if (parsed && !isHistory) { + if (parsed.command === 'help' || parsed.command === 'start') { await this.sendMessage({ chatId, text: HELP_TEXT }); } else if (this.onCommand) { - const result = await this.onCommand(command, chatId); + const result = await this.onCommand(parsed.command, chatId, parsed.args || undefined); if (result) await this.sendMessage({ chatId, text: result }); } return; // Don't pass commands to agent diff --git a/src/core/bot.ts b/src/core/bot.ts index 45ed44d..0687cce 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -13,7 +13,7 @@ import type { ChannelAdapter } from '../channels/types.js'; import type { BotConfig, InboundMessage, TriggerContext } from './types.js'; import type { AgentSession } from './interfaces.js'; import { Store } from './store.js'; -import { updateAgentName, getPendingApprovals, rejectApproval, cancelRuns, recoverOrphanedConversationApproval, getLatestRunError } from '../tools/letta-api.js'; +import { updateAgentName, getPendingApprovals, rejectApproval, cancelRuns, recoverOrphanedConversationApproval, getLatestRunError, getAgentModel, updateAgentModel } from '../tools/letta-api.js'; import { installSkillsToAgent, withAgentSkillsOnPath, getAgentSkillExecutableDirs, isVoiceMemoConfigured } from '../skills/loader.js'; import { formatMessageEnvelope, formatGroupBatchEnvelope, type SessionContextOptions } from './formatter.js'; import type { GroupBatcher } from './group-batcher.js'; @@ -1358,7 +1358,7 @@ export class LettaBot implements AgentSession { registerChannel(adapter: ChannelAdapter): void { adapter.onMessage = (msg) => this.handleMessage(msg, adapter); - adapter.onCommand = (cmd, chatId) => this.handleCommand(cmd, adapter.id, chatId); + adapter.onCommand = (cmd, chatId, args) => this.handleCommand(cmd, adapter.id, chatId, args); // Wrap outbound methods when any redaction layer is active. // Secrets are enabled by default unless explicitly disabled. @@ -1419,8 +1419,8 @@ export class LettaBot implements AgentSession { // Commands // ========================================================================= - private async handleCommand(command: string, channelId?: string, chatId?: string): Promise { - log.info(`Received: /${command}`); + private async handleCommand(command: string, channelId?: string, chatId?: string, args?: string): Promise { + log.info(`Received: /${command}${args ? ` ${args}` : ''}`); switch (command) { case 'status': { const info = this.store.getInfo(); @@ -1470,6 +1470,32 @@ export class LettaBot implements AgentSession { return `Conversation reset for ${scope}. Other conversations are unaffected. (Agent memory is preserved.)`; } } + case 'model': { + const agentId = this.store.agentId; + if (!agentId) return 'No agent configured.'; + + if (args) { + const success = await updateAgentModel(agentId, args); + if (success) { + return `Model updated to: ${args}`; + } + return 'Failed to update model. Check the handle is valid.\nUse /model to list available models.'; + } + + const current = await getAgentModel(agentId); + const { models: recommendedModels } = await import('../utils/model-selection.js'); + const lines = [ + `Current model: ${current || '(unknown)'}`, + '', + 'Recommended models:', + ]; + for (const m of recommendedModels) { + const marker = m.handle === current ? ' (current)' : ''; + lines.push(` ${m.label} - ${m.handle}${marker}`); + } + lines.push('', 'Use /model to switch.'); + return lines.join('\n'); + } default: return null; } diff --git a/src/core/commands.test.ts b/src/core/commands.test.ts index e350052..1fc11ae 100644 --- a/src/core/commands.test.ts +++ b/src/core/commands.test.ts @@ -3,24 +3,28 @@ import { parseCommand, COMMANDS, HELP_TEXT } from './commands.js'; describe('parseCommand', () => { describe('valid commands', () => { - it('returns "status" for /status', () => { - expect(parseCommand('/status')).toBe('status'); + it('returns { command, args } for /status', () => { + expect(parseCommand('/status')).toEqual({ command: 'status', args: '' }); }); - it('returns "heartbeat" for /heartbeat', () => { - expect(parseCommand('/heartbeat')).toBe('heartbeat'); + it('returns { command, args } for /heartbeat', () => { + expect(parseCommand('/heartbeat')).toEqual({ command: 'heartbeat', args: '' }); }); - it('returns "help" for /help', () => { - expect(parseCommand('/help')).toBe('help'); + it('returns { command, args } for /help', () => { + expect(parseCommand('/help')).toEqual({ command: 'help', args: '' }); }); - it('returns "start" for /start', () => { - expect(parseCommand('/start')).toBe('start'); + it('returns { command, args } for /start', () => { + expect(parseCommand('/start')).toEqual({ command: 'start', args: '' }); }); - it('returns "reset" for /reset', () => { - expect(parseCommand('/reset')).toBe('reset'); + it('returns { command, args } for /reset', () => { + expect(parseCommand('/reset')).toEqual({ command: 'reset', args: '' }); + }); + + it('returns { command, args } for /model', () => { + expect(parseCommand('/model')).toEqual({ command: 'model', args: '' }); }); }); @@ -47,19 +51,33 @@ describe('parseCommand', () => { }); describe('command parsing', () => { - it('handles commands with extra text after', () => { - expect(parseCommand('/status please')).toBe('status'); - expect(parseCommand('/help me')).toBe('help'); + it('captures trailing text as args', () => { + expect(parseCommand('/status please')).toEqual({ command: 'status', args: 'please' }); + expect(parseCommand('/help me')).toEqual({ command: 'help', args: 'me' }); }); it('is case insensitive', () => { - expect(parseCommand('/STATUS')).toBe('status'); - expect(parseCommand('/Help')).toBe('help'); - expect(parseCommand('/HEARTBEAT')).toBe('heartbeat'); + expect(parseCommand('/STATUS')).toEqual({ command: 'status', args: '' }); + expect(parseCommand('/Help')).toEqual({ command: 'help', args: '' }); + expect(parseCommand('/HEARTBEAT')).toEqual({ command: 'heartbeat', args: '' }); }); - it('handles commands with leading/trailing whitespace in args', () => { - expect(parseCommand('/status ')).toBe('status'); + it('handles commands with trailing whitespace', () => { + expect(parseCommand('/status ')).toEqual({ command: 'status', args: '' }); + }); + + it('parses /model with a handle argument', () => { + expect(parseCommand('/model anthropic/claude-sonnet-4-5-20250929')).toEqual({ + command: 'model', + args: 'anthropic/claude-sonnet-4-5-20250929', + }); + }); + + it('preserves multi-word args', () => { + expect(parseCommand('/model some handle with spaces')).toEqual({ + command: 'model', + args: 'some handle with spaces', + }); }); }); }); @@ -71,11 +89,11 @@ describe('COMMANDS', () => { expect(COMMANDS).toContain('reset'); expect(COMMANDS).toContain('help'); expect(COMMANDS).toContain('start'); - expect(COMMANDS).toContain('reset'); + expect(COMMANDS).toContain('model'); }); - it('has exactly 5 commands', () => { - expect(COMMANDS).toHaveLength(5); + it('has exactly 6 commands', () => { + expect(COMMANDS).toHaveLength(6); }); }); @@ -84,6 +102,7 @@ describe('HELP_TEXT', () => { expect(HELP_TEXT).toContain('/status'); expect(HELP_TEXT).toContain('/heartbeat'); expect(HELP_TEXT).toContain('/help'); + expect(HELP_TEXT).toContain('/model'); }); it('contains LettaBot branding', () => { diff --git a/src/core/commands.ts b/src/core/commands.ts index 3ac5375..8701da5 100644 --- a/src/core/commands.ts +++ b/src/core/commands.ts @@ -4,25 +4,34 @@ * Shared command parsing and help text for all channels. */ -export const COMMANDS = ['status', 'heartbeat', 'reset', 'help', 'start'] as const; +export const COMMANDS = ['status', 'heartbeat', 'reset', 'help', 'start', 'model'] as const; export type Command = typeof COMMANDS[number]; +export interface ParsedCommand { + command: Command; + args: string; +} + export const HELP_TEXT = `LettaBot - AI assistant with persistent memory Commands: /status - Show current status /heartbeat - Trigger heartbeat /reset - Reset conversation (keeps agent memory) +/model - Show current model and list available models +/model - Switch to a different model /help - Show this message Just send a message to get started!`; /** * Parse a slash command from message text. - * Returns the command name if valid, null otherwise. + * Returns the command and any trailing arguments, or null if not a valid command. */ -export function parseCommand(text: string | undefined | null): Command | null { +export function parseCommand(text: string | undefined | null): ParsedCommand | null { if (!text?.startsWith('/')) return null; - const cmd = text.slice(1).split(/\s+/)[0]?.toLowerCase(); - return COMMANDS.includes(cmd as Command) ? (cmd as Command) : null; + const parts = text.slice(1).split(/\s+/); + const cmd = parts[0]?.toLowerCase(); + if (!COMMANDS.includes(cmd as Command)) return null; + return { command: cmd as Command, args: parts.slice(1).join(' ') }; } diff --git a/src/models.json b/src/models.json index 984857d..524fa8d 100644 --- a/src/models.json +++ b/src/models.json @@ -1,38 +1,45 @@ [ { - "id": "sonnet-4.5", - "handle": "anthropic/claude-sonnet-4-5-20250929", - "label": "Sonnet 4.5", - "description": "The recommended default model", + "id": "sonnet-4.6", + "handle": "anthropic/claude-sonnet-4-6", + "label": "Sonnet 4.6", + "description": "Anthropic's new Sonnet model", "isDefault": true, "isFeatured": true }, { - "id": "opus", - "handle": "anthropic/claude-opus-4-5-20251101", - "label": "Opus 4.5", + "id": "opus-4.6", + "handle": "anthropic/claude-opus-4-6", + "label": "Opus 4.6", "description": "Anthropic's best model", "isFeatured": true }, { "id": "haiku", - "handle": "anthropic/claude-haiku-4-5-20251001", + "handle": "anthropic/claude-haiku-4-5", "label": "Haiku 4.5", "description": "Anthropic's fastest model", "isFeatured": true }, { - "id": "gpt-5.2-medium", - "handle": "openai/gpt-5.2", - "label": "GPT-5.2", - "description": "Latest general-purpose GPT (med reasoning)", + "id": "gpt-5.3-codex", + "handle": "openai/gpt-5.3-codex", + "label": "GPT-5.3 Codex", + "description": "OpenAI's best coding model", "isFeatured": true }, { - "id": "gemini-3", - "handle": "google_ai/gemini-3-pro-preview", - "label": "Gemini 3 Pro", - "description": "Google's smartest model", + "id": "gpt-5.2", + "handle": "openai/gpt-5.2", + "label": "GPT-5.2", + "description": "Latest general-purpose GPT", + "isFeatured": true + }, + { + "id": "gemini-3.1", + "handle": "google_ai/gemini-3.1-pro-preview", + "label": "Gemini 3.1 Pro", + "description": "Google's latest and smartest model", "isFeatured": true }, { @@ -43,17 +50,24 @@ "isFeatured": true }, { - "id": "glm-4.7", - "handle": "zai/glm-4.7", - "label": "GLM-4.7", + "id": "kimi-k2.5", + "handle": "openrouter/moonshotai/kimi-k2.5", + "label": "Kimi K2.5", + "description": "Kimi's latest coding model", + "isFeatured": true + }, + { + "id": "glm-5", + "handle": "zai/glm-5", + "label": "GLM-5", "description": "zAI's latest coding model", "isFeatured": true, "free": true }, { - "id": "minimax-m2.1", - "handle": "minimax/MiniMax-M2.1", - "label": "MiniMax 2.1", + "id": "minimax-m2.5", + "handle": "minimax/MiniMax-M2.5", + "label": "MiniMax 2.5", "description": "MiniMax's latest coding model", "isFeatured": true, "free": true diff --git a/src/test/mock-channel.ts b/src/test/mock-channel.ts index 81abffa..045953a 100644 --- a/src/test/mock-channel.ts +++ b/src/test/mock-channel.ts @@ -17,7 +17,7 @@ export class MockChannelAdapter implements ChannelAdapter { private responseResolvers: Array<(msg: OutboundMessage) => void> = []; onMessage?: (msg: InboundMessage) => Promise; - onCommand?: (command: string) => Promise; + onCommand?: (command: string, chatId?: string, args?: string) => Promise; async start(): Promise { this.running = true; @@ -74,12 +74,12 @@ export class MockChannelAdapter implements ChannelAdapter { const chatId = options.chatId || 'test-chat-123'; // Handle slash commands locally (like real channels do) - const command = parseCommand(text); - if (command) { - if (command === 'help' || command === 'start') { + const parsed = parseCommand(text); + if (parsed) { + if (parsed.command === 'help' || parsed.command === 'start') { return HELP_TEXT; } else if (this.onCommand) { - const result = await this.onCommand(command); + const result = await this.onCommand(parsed.command, chatId, parsed.args || undefined); return result || '(No response)'; } return '(Command not handled)';