diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index c54d4bd..a582da1 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -23,6 +23,7 @@ import { isCommand as _isCmd, isCommandWithArgs as _isCmdArgs, resolveAgentThrea import { saveAttachments as _saveAttachments, type AttachmentResult } from "./attachments"; import { handleStreaming as _handleStream } from "./streaming"; import { handleNew, handleRestart, handleUpdate, handleCompact, handleStatus, handleStop, handleVerbose, handleDoctor, handleCrons, type CommandContext } from "./commands"; +import { handleModel } from "./model-command"; import { TelegramAdapter } from "../transports"; import type { TransportAdapter } from "../transports"; import { hostname } from "node:os"; @@ -264,6 +265,12 @@ export class Gateway { return; } + // Handle /model command + if (isCommandWithArgs(userText.trim(), "/model") || isCommand(userText.trim(), "/model")) { + await handleModel({ thread, text: userText.trim(), postWithFallback: (t, txt) => this.postWithFallback(t, txt) }); + return; + } + // Dispatch to agent turn handler await this.handleAgentTurn(thread, agentThreadId, userText, rawAttachments, verboseThreads, threadLocks, abortControllers); }; diff --git a/src/gateway/model-command.ts b/src/gateway/model-command.ts new file mode 100644 index 0000000..f625248 --- /dev/null +++ b/src/gateway/model-command.ts @@ -0,0 +1,96 @@ +/** + * gateway/model-command.ts — Handle the /model command + * + * Allows switching the default AI model from Telegram. + * Reads/writes ~/.pi/agent/settings.json (defaultProvider + defaultModel). + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { readFileSync, writeFileSync } from "node:fs"; + +/** Known model aliases → Bedrock model IDs */ +const MODEL_ALIASES: Record = { + "opus": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-6", label: "Claude Opus 4.6" }, + "opus-4.6": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-6", label: "Claude Opus 4.6" }, + "opus-4.7": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-7", label: "Claude Opus 4.7" }, + "sonnet": { provider: "amazon-bedrock", model: "us.anthropic.claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, + "sonnet-4.6": { provider: "amazon-bedrock", model: "us.anthropic.claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, + "haiku": { provider: "amazon-bedrock", model: "us.anthropic.claude-haiku-4-5", label: "Claude Haiku 4.5" }, + "haiku-4.5": { provider: "amazon-bedrock", model: "us.anthropic.claude-haiku-4-5", label: "Claude Haiku 4.5" }, +}; + +const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json"); + +export interface ModelCommandContext { + thread: any; + text: string; + postWithFallback: (thread: any, text: string) => Promise; +} + +function readSettings(): Record { + try { + return JSON.parse(readFileSync(SETTINGS_PATH, "utf8")); + } catch { + return {}; + } +} + +function writeSettings(settings: Record): void { + writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n"); +} + +function getCurrentModel(settings: Record): string { + const provider = settings.defaultProvider ?? "unknown"; + const model = settings.defaultModel ?? "unknown"; + // Try to find a friendly label + for (const [alias, info] of Object.entries(MODEL_ALIASES)) { + if (info.provider === provider && info.model === model) return `${info.label} (${alias})`; + } + return `${provider}/${model}`; +} + +export async function handleModel(ctx: ModelCommandContext): Promise { + const { thread, text, postWithFallback } = ctx; + const parts = text.split(/\s+/).slice(1); + const target = parts[0]?.toLowerCase(); + + const settings = readSettings(); + + // No argument: show current model + available options + if (!target) { + const current = getCurrentModel(settings); + const aliases = Object.entries(MODEL_ALIASES) + .filter(([alias]) => !alias.includes(".")) // Show short aliases only + .map(([alias, info]) => ` \`${alias}\` → ${info.label}`) + .join("\n"); + + await postWithFallback(thread, `šŸ¤– *Current model:* ${current}\n\n*Available:*\n${aliases}\n\n_Usage:_ \`/model sonnet\``); + return; + } + + // Resolve alias or use as raw model ID + const resolved = MODEL_ALIASES[target]; + if (!resolved) { + // Check if it looks like a full model ID (contains a dot or slash) + if (target.includes(".") || target.includes("/")) { + // Use as-is with current provider + const provider = settings.defaultProvider ?? "amazon-bedrock"; + settings.defaultModel = target; + settings.defaultProvider = provider; + writeSettings(settings); + await postWithFallback(thread, `āœ… Model set to: \`${provider}/${target}\`\n\nāš ļø Restart needed: \`/restart\``); + } else { + const aliases = Object.keys(MODEL_ALIASES).filter(a => !a.includes(".")).join(", "); + await postWithFallback(thread, `āŒ Unknown model: \`${target}\`\n\nAvailable: ${aliases}`); + } + return; + } + + settings.defaultProvider = resolved.provider; + settings.defaultModel = resolved.model; + writeSettings(settings); + + await postWithFallback(thread, `āœ… Model switched to: *${resolved.label}*\n\nāš ļø Takes effect on next agent turn (new sessions use new model).`); + console.log(`[roundhouse] /model: switched to ${resolved.provider}/${resolved.model}`); +} diff --git a/src/transports/telegram/bot-commands.ts b/src/transports/telegram/bot-commands.ts index 671ed12..e1763fa 100644 --- a/src/transports/telegram/bot-commands.ts +++ b/src/transports/telegram/bot-commands.ts @@ -13,6 +13,7 @@ export interface BotCommand { export const BOT_COMMANDS: BotCommand[] = [ { command: "new", description: "Start a fresh conversation" }, { command: "compact", description: "Compact context window" }, + { command: "model", description: "Show or switch AI model" }, { command: "verbose", description: "Toggle verbose tool output" }, { command: "stop", description: "Stop the current agent run" }, { command: "restart", description: "Restart agent process" },