Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/gateway/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
};
Expand Down
96 changes: 96 additions & 0 deletions src/gateway/model-command.ts
Original file line number Diff line number Diff line change
@@ -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<string, { provider: string; model: string; label: string }> = {
"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<void>;
}

function readSettings(): Record<string, any> {
try {
return JSON.parse(readFileSync(SETTINGS_PATH, "utf8"));
} catch {
return {};
}
}

function writeSettings(settings: Record<string, any>): void {
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Ensure settings path exists before persisting /model changes

writeSettings writes ~/.pi/agent/settings.json directly, but /model is now registered globally in Telegram and can run on installs where ~/.pi/agent does not exist yet (for example non-Pi setups or first-run environments). In that case writeFileSync throws ENOENT, and this handler has no local error handling, so the command fails and can bubble an unhandled rejection through the message callback. Create the parent directory (or catch and report write errors) before writing.

Useful? React with 👍 / 👎.

}

function getCurrentModel(settings: Record<string, any>): 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<void> {
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;
Comment on lines +79 to +80
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Split provider/model when accepting slash-form model input

The slash-path branch treats any input containing / as a model id, but then keeps the existing provider and stores the entire string into defaultModel. For inputs like /model amazon-bedrock/us.anthropic..., this persists defaultProvider=amazon-bedrock and defaultModel=amazon-bedrock/us..., which later gets recombined as provider/model (e.g. amazon-bedrock/amazon-bedrock/us...) in status/reporting paths and produces a malformed configured identifier. Parse provider/model explicitly when / is present instead of storing the unsplit string.

Useful? React with 👍 / 👎.

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}`);
}
1 change: 1 addition & 0 deletions src/transports/telegram/bot-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Loading