@inceptionstack/roundhouse 0.5.11 → 0.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.5.11",
3
+ "version": "0.5.12",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -23,7 +23,7 @@ import { isCommand as _isCmd, isCommandWithArgs as _isCmdArgs, resolveAgentThrea
23
23
  import { saveAttachments as _saveAttachments, type AttachmentResult } from "./attachments";
24
24
  import { handleStreaming as _handleStream } from "./streaming";
25
25
  import { handleNew, handleRestart, handleUpdate, handleCompact, handleStatus, handleStop, handleVerbose, handleDoctor, handleCrons, type CommandContext } from "./commands";
26
- import { handleModel } from "./model-command";
26
+ import { handleModel, handleModelAction, MODEL_ACTION_ID } from "./model-command";
27
27
  import { handleLater } from "./later-command";
28
28
  import { TelegramAdapter } from "../transports";
29
29
  import type { TransportAdapter } from "../transports";
@@ -327,6 +327,11 @@ export class Gateway {
327
327
  await handleOrAbort(thread, message);
328
328
  });
329
329
 
330
+ // ── Handle inline keyboard callbacks ───
331
+ this.chat.onAction(MODEL_ACTION_ID, async (event: any) => {
332
+ await handleModelAction({ value: event.value, thread: event.thread });
333
+ });
334
+
330
335
  await this.chat.initialize();
331
336
 
332
337
  const platforms = Object.keys(this.config.chat.adapters).join(", ");
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Allows switching the default AI model from Telegram.
5
5
  * Reads/writes ~/.pi/agent/settings.json (defaultProvider + defaultModel).
6
+ *
7
+ * When called without arguments, shows an inline keyboard with model buttons.
8
+ * When a button is clicked, the onAction handler applies the selection.
6
9
  */
7
10
 
8
11
  import { homedir } from "node:os";
@@ -10,18 +13,36 @@ import { join } from "node:path";
10
13
  import { readFileSync, writeFileSync } from "node:fs";
11
14
 
12
15
  /** Known model aliases → Bedrock model IDs */
13
- const MODEL_ALIASES: Record<string, { provider: string; model: string; label: string }> = {
14
- "opus": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-6", label: "Claude Opus 4.6" },
15
- "opus-4.6": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-6", label: "Claude Opus 4.6" },
16
+ export const MODEL_ALIASES: Record<string, { provider: string; model: string; label: string }> = {
17
+ // Anthropic Claude
16
18
  "opus-4.7": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-7", label: "Claude Opus 4.7" },
19
+ "opus": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-6", label: "Claude Opus 4.6" },
17
20
  "sonnet": { provider: "amazon-bedrock", model: "us.anthropic.claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
18
- "sonnet-4.6": { provider: "amazon-bedrock", model: "us.anthropic.claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
19
21
  "haiku": { provider: "amazon-bedrock", model: "us.anthropic.claude-haiku-4-5", label: "Claude Haiku 4.5" },
20
- "haiku-4.5": { provider: "amazon-bedrock", model: "us.anthropic.claude-haiku-4-5", label: "Claude Haiku 4.5" },
22
+ // DeepSeek
23
+ "deepseek": { provider: "amazon-bedrock", model: "us.deepseek.r1-v1:0", label: "DeepSeek R1" },
24
+ // Meta Llama
25
+ "llama": { provider: "amazon-bedrock", model: "us.meta.llama4-maverick-17b-instruct-v1:0", label: "Llama 4 Maverick" },
26
+ // Amazon Nova
27
+ "nova-pro": { provider: "amazon-bedrock", model: "us.amazon.nova-pro-v1:0", label: "Amazon Nova Pro" },
28
+ // Mistral
29
+ "mistral": { provider: "amazon-bedrock", model: "us.mistral.mistral-large-2411-v1:0", label: "Mistral Large" },
21
30
  };
22
31
 
32
+ /** Models shown in the inline keyboard (max 8, ordered by preference) */
33
+ const KEYBOARD_MODELS = [
34
+ "opus-4.7", "opus", "sonnet", "haiku",
35
+ "deepseek", "llama", "nova-pro", "mistral",
36
+ ] as const;
37
+
38
+ /** Action ID for model selection callbacks */
39
+ export const MODEL_ACTION_ID = "model_select";
40
+
23
41
  const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
24
42
 
43
+ /** Callback data prefix used by @chat-adapter/telegram (coupled: if adapter changes this, buttons break) */
44
+ const CALLBACK_PREFIX = "chat:";
45
+
25
46
  export interface ModelCommandContext {
26
47
  thread: any;
27
48
  text: string;
@@ -43,11 +64,30 @@ function writeSettings(settings: Record<string, any>): void {
43
64
  function getCurrentModel(settings: Record<string, any>): string {
44
65
  const provider = settings.defaultProvider ?? "unknown";
45
66
  const model = settings.defaultModel ?? "unknown";
46
- // Try to find a friendly label
47
67
  for (const [alias, info] of Object.entries(MODEL_ALIASES)) {
48
- if (info.provider === provider && info.model === model) return `${info.label} (${alias})`;
68
+ if (info.provider === provider && info.model === model) return `${info.label}`;
49
69
  }
50
- return `${provider}/${model}`;
70
+ return `${model}`;
71
+ }
72
+
73
+ function encodeCallbackData(actionId: string, value: string): string {
74
+ return `${CALLBACK_PREFIX}${JSON.stringify({ a: actionId, v: value })}`;
75
+ }
76
+
77
+ function buildInlineKeyboard(): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
78
+ // Layout: 2 buttons per row for compact display
79
+ const buttons = KEYBOARD_MODELS.map(alias => {
80
+ const info = MODEL_ALIASES[alias];
81
+ return {
82
+ text: info.label,
83
+ callback_data: encodeCallbackData(MODEL_ACTION_ID, alias),
84
+ };
85
+ });
86
+ const rows: Array<Array<{ text: string; callback_data: string }>> = [];
87
+ for (let i = 0; i < buttons.length; i += 2) {
88
+ rows.push(buttons.slice(i, i + 2));
89
+ }
90
+ return { inline_keyboard: rows };
51
91
  }
52
92
 
53
93
  export async function handleModel(ctx: ModelCommandContext): Promise<void> {
@@ -57,31 +97,61 @@ export async function handleModel(ctx: ModelCommandContext): Promise<void> {
57
97
 
58
98
  const settings = readSettings();
59
99
 
60
- // No argument: show current model + available options
100
+ // No argument: show inline keyboard
61
101
  if (!target) {
62
102
  const current = getCurrentModel(settings);
63
- const aliases = Object.entries(MODEL_ALIASES)
64
- .filter(([alias]) => !alias.includes(".")) // Show short aliases only
65
- .map(([alias, info]) => ` \`${alias}\` ${info.label}`)
66
- .join("\n");
103
+ const msgText = `🤖 Current model: <b>${current}</b>\n\nSelect a model:`;
104
+
105
+ // Try to send with inline keyboard via telegramFetch
106
+ const adapter = thread?.adapter;
107
+ if (adapter?.telegramFetch) {
108
+ const chatId = thread?.platformThreadId?.split(":")?.[1] ?? thread?.id?.split(":")?.[1];
109
+ if (chatId) {
110
+ try {
111
+ await adapter.telegramFetch("sendMessage", {
112
+ chat_id: chatId,
113
+ text: msgText,
114
+ parse_mode: "HTML",
115
+ reply_markup: buildInlineKeyboard(),
116
+ });
117
+ return;
118
+ } catch (err) {
119
+ console.warn("[roundhouse] /model inline keyboard failed, falling back:", (err as Error).message);
120
+ }
121
+ }
122
+ }
67
123
 
124
+ // Fallback: plain text
125
+ const aliases = KEYBOARD_MODELS.map(a => ` \`${a}\` → ${MODEL_ALIASES[a].label}`).join("\n");
68
126
  await postWithFallback(thread, `🤖 *Current model:* ${current}\n\n*Available:*\n${aliases}\n\n_Usage:_ \`/model sonnet\``);
69
127
  return;
70
128
  }
71
129
 
72
- // Resolve alias or use as raw model ID
130
+ // Resolve alias
131
+ await applyModelSelection(target, settings, thread, postWithFallback);
132
+ }
133
+
134
+ /**
135
+ * Apply a model selection (used by both /model <arg> and inline keyboard callback).
136
+ */
137
+ export async function applyModelSelection(
138
+ target: string,
139
+ settings: Record<string, any> | null,
140
+ thread: any,
141
+ postWithFallback: (thread: any, text: string) => Promise<void>,
142
+ ): Promise<void> {
143
+ if (!settings) settings = readSettings();
144
+
73
145
  const resolved = MODEL_ALIASES[target];
74
146
  if (!resolved) {
75
- // Check if it looks like a full model ID (contains a dot or slash)
76
147
  if (target.includes(".") || target.includes("/")) {
77
- // Use as-is with current provider
78
148
  const provider = settings.defaultProvider ?? "amazon-bedrock";
79
149
  settings.defaultModel = target;
80
150
  settings.defaultProvider = provider;
81
151
  writeSettings(settings);
82
- await postWithFallback(thread, `✅ Model set to: \`${provider}/${target}\`\n\n⚠️ Restart needed: \`/restart\``);
152
+ await postWithFallback(thread, `✅ Model set to: \`${provider}/${target}\``);
83
153
  } else {
84
- const aliases = Object.keys(MODEL_ALIASES).filter(a => !a.includes(".")).join(", ");
154
+ const aliases = Object.keys(MODEL_ALIASES).join(", ");
85
155
  await postWithFallback(thread, `❌ Unknown model: \`${target}\`\n\nAvailable: ${aliases}`);
86
156
  }
87
157
  return;
@@ -91,6 +161,26 @@ export async function handleModel(ctx: ModelCommandContext): Promise<void> {
91
161
  settings.defaultModel = resolved.model;
92
162
  writeSettings(settings);
93
163
 
94
- await postWithFallback(thread, `✅ Model switched to: *${resolved.label}*\n\n⚠️ Takes effect on next agent turn (new sessions use new model).`);
164
+ await postWithFallback(thread, `✅ Switched to *${resolved.label}*`);
95
165
  console.log(`[roundhouse] /model: switched to ${resolved.provider}/${resolved.model}`);
96
166
  }
167
+
168
+ /**
169
+ * Handle inline keyboard callback for model selection.
170
+ * Call this from chat.onAction(MODEL_ACTION_ID, ...).
171
+ */
172
+ export async function handleModelAction(event: {
173
+ value?: string;
174
+ thread: any;
175
+ }): Promise<void> {
176
+ const alias = event.value;
177
+ if (!alias || !MODEL_ALIASES[alias]) return;
178
+
179
+ const postFn = async (_t: any, text: string) => {
180
+ if (!event.thread) return;
181
+ try { await event.thread.post({ markdown: text }); }
182
+ catch { try { await event.thread.post(text); } catch {} }
183
+ };
184
+
185
+ await applyModelSelection(alias, null, event.thread, postFn);
186
+ }