talon-agent 1.6.1 → 1.7.0

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.
@@ -22,12 +22,33 @@ import type { TalonConfig } from "../util/config.js";
22
22
 
23
23
  // ── Plugin interfaces ──────────────────────────────────────────────────────
24
24
 
25
- /** Configuration entry for a plugin in talon.json. */
26
- export interface PluginEntry {
25
+ /** Path-based plugin entry (loaded as a Node module). */
26
+ export interface PluginPathEntry {
27
27
  path: string;
28
28
  config?: Record<string, unknown>;
29
29
  }
30
30
 
31
+ /** Standalone MCP server entry (command + args, not a loadable module). */
32
+ export interface PluginMcpEntry {
33
+ name: string;
34
+ command: string;
35
+ args?: string[];
36
+ env?: Record<string, string>;
37
+ }
38
+
39
+ /** Configuration entry for a plugin in config.json. */
40
+ export type PluginEntry = PluginPathEntry | PluginMcpEntry;
41
+
42
+ /** Type guard: is this a path-based plugin? */
43
+ export function isPathPlugin(entry: PluginEntry): entry is PluginPathEntry {
44
+ return "path" in entry;
45
+ }
46
+
47
+ /** Type guard: is this a standalone MCP server entry? */
48
+ export function isMcpPlugin(entry: PluginEntry): entry is PluginMcpEntry {
49
+ return "command" in entry && "name" in entry && !("path" in entry);
50
+ }
51
+
31
52
  /**
32
53
  * Core plugin interface — only `name` is required.
33
54
  * All other capabilities are optional (Interface Segregation).
@@ -119,28 +140,58 @@ export interface LoadedPlugin {
119
140
 
120
141
  class PluginRegistry {
121
142
  private readonly plugins: LoadedPlugin[] = [];
143
+ private readonly standaloneMcpServers: PluginMcpEntry[] = [];
122
144
 
123
145
  get all(): readonly LoadedPlugin[] {
124
146
  return this.plugins;
125
147
  }
126
148
 
149
+ get mcpEntries(): readonly PluginMcpEntry[] {
150
+ return this.standaloneMcpServers;
151
+ }
152
+
127
153
  get count(): number {
128
154
  return this.plugins.length;
129
155
  }
130
156
 
131
- register(loaded: LoadedPlugin): void {
132
- // Guard against duplicate names
133
- const existing = this.plugins.find(
134
- (p) => p.plugin.name === loaded.plugin.name,
157
+ private getRegistrationSource(name: string): string | undefined {
158
+ const existingPlugin = this.plugins.find(
159
+ (entry) => entry.plugin.name === name,
135
160
  );
136
- if (existing) {
161
+ if (existingPlugin) return existingPlugin.path;
162
+
163
+ const existingMcpEntry = this.standaloneMcpServers.find(
164
+ (entry) => entry.name === name,
165
+ );
166
+ if (existingMcpEntry) return "standalone MCP entry";
167
+
168
+ return undefined;
169
+ }
170
+
171
+ register(loaded: LoadedPlugin): boolean {
172
+ const existingSource = this.getRegistrationSource(loaded.plugin.name);
173
+ if (existingSource) {
137
174
  logWarn(
138
175
  "plugin",
139
- `Duplicate plugin name "${loaded.plugin.name}" — skipping (already loaded from ${existing.path})`,
176
+ `Duplicate plugin/MCP name "${loaded.plugin.name}" — skipping (already registered from ${existingSource})`,
140
177
  );
141
- return;
178
+ return false;
142
179
  }
143
180
  this.plugins.push(loaded);
181
+ return true;
182
+ }
183
+
184
+ registerMcpEntry(entry: PluginMcpEntry): boolean {
185
+ const existingSource = this.getRegistrationSource(entry.name);
186
+ if (existingSource) {
187
+ logWarn(
188
+ "plugin",
189
+ `Duplicate plugin/MCP name "${entry.name}" — skipping (already registered from ${existingSource})`,
190
+ );
191
+ return false;
192
+ }
193
+ this.standaloneMcpServers.push(entry);
194
+ return true;
144
195
  }
145
196
 
146
197
  getByName(name: string): LoadedPlugin | undefined {
@@ -170,6 +221,7 @@ class PluginRegistry {
170
221
  }
171
222
  await this.destroyAll();
172
223
  this.plugins.length = 0;
224
+ this.standaloneMcpServers.length = 0;
173
225
  }
174
226
  }
175
227
 
@@ -204,6 +256,13 @@ export async function loadPlugins(
204
256
  activeFrontends?: string[],
205
257
  ): Promise<void> {
206
258
  for (const entry of pluginConfigs) {
259
+ // Standalone MCP servers are registered for getPluginMcpServers, not loaded as modules
260
+ if (isMcpPlugin(entry)) {
261
+ if (registry.registerMcpEntry(entry)) {
262
+ log("plugin", `Registered standalone MCP server: ${entry.name}`);
263
+ }
264
+ continue;
265
+ }
207
266
  try {
208
267
  await loadSinglePlugin(entry, activeFrontends);
209
268
  } catch (err) {
@@ -215,8 +274,81 @@ export async function loadPlugins(
215
274
  }
216
275
  }
217
276
 
277
+ function applyEnvVars(envVars: Record<string, string>): void {
278
+ for (const [key, value] of Object.entries(envVars)) {
279
+ process.env[key] = value;
280
+ }
281
+ }
282
+
283
+ function registerPluginInstance(
284
+ plugin: TalonPlugin,
285
+ config: Record<string, unknown>,
286
+ path: string,
287
+ ): LoadedPlugin | null {
288
+ const errors = plugin.validateConfig?.(config);
289
+ if (errors && errors.length > 0) {
290
+ logError(
291
+ "plugin",
292
+ `${path === "(built-in)" ? `Built-in plugin "${plugin.name}"` : `Plugin "${plugin.name}"`} config validation failed:\n ${errors.join("\n ")}`,
293
+ );
294
+ return null;
295
+ }
296
+
297
+ const envVars = plugin.getEnvVars?.(config) ?? {};
298
+ const loaded: LoadedPlugin = { plugin, config, envVars, path };
299
+ if (!registry.register(loaded)) return null;
300
+
301
+ applyEnvVars(envVars);
302
+ return loaded;
303
+ }
304
+
305
+ async function initPluginWithTimeout(
306
+ plugin: TalonPlugin,
307
+ config: Record<string, unknown>,
308
+ timeoutMs: number,
309
+ timeoutLabel: string,
310
+ errorPrefix: string,
311
+ ): Promise<void> {
312
+ if (!plugin.init) return;
313
+
314
+ let timer: ReturnType<typeof setTimeout> | undefined;
315
+
316
+ try {
317
+ await Promise.race([
318
+ Promise.resolve(plugin.init(config)),
319
+ new Promise<never>((_, reject) => {
320
+ timer = setTimeout(() => {
321
+ reject(
322
+ new Error(`${timeoutLabel} timed out after ${timeoutMs / 1000}s`),
323
+ );
324
+ }, timeoutMs);
325
+ timer.unref?.();
326
+ }),
327
+ ]);
328
+ } catch (err) {
329
+ logError(
330
+ "plugin",
331
+ `${errorPrefix}: ${err instanceof Error ? err.message : err}`,
332
+ );
333
+ } finally {
334
+ if (timer) clearTimeout(timer);
335
+ }
336
+ }
337
+
338
+ function buildBridgeEnv(
339
+ bridgeUrl: string,
340
+ chatId: string,
341
+ envVars?: Record<string, string>,
342
+ ): Record<string, string> {
343
+ return {
344
+ ...envVars,
345
+ TALON_BRIDGE_URL: bridgeUrl,
346
+ TALON_CHAT_ID: chatId,
347
+ };
348
+ }
349
+
218
350
  async function loadSinglePlugin(
219
- entry: PluginEntry,
351
+ entry: PluginPathEntry,
220
352
  activeFrontends?: string[],
221
353
  ): Promise<void> {
222
354
  const pluginDir = resolve(entry.path);
@@ -255,49 +387,23 @@ async function loadSinglePlugin(
255
387
  }
256
388
 
257
389
  const config = entry.config ?? {};
258
-
259
- // Validate config if the plugin provides validation
260
- const errors = plugin.validateConfig?.(config);
261
- if (errors && errors.length > 0) {
262
- logError(
263
- "plugin",
264
- `Plugin "${plugin.name}" config validation failed:\n ${errors.join("\n ")}`,
265
- );
266
- return;
267
- }
268
-
269
- // Resolve env vars
270
- const envVars = plugin.getEnvVars?.(config) ?? {};
271
-
272
- // Set env vars on main process for action handlers
273
- for (const [k, v] of Object.entries(envVars)) {
274
- process.env[k] = v;
275
- }
276
-
277
- // Register before init (so other plugins can discover it)
278
- const loaded: LoadedPlugin = { plugin, config, envVars, path: pluginDir };
279
- registry.register(loaded);
390
+ const loaded = registerPluginInstance(plugin, config, pluginDir);
391
+ if (!loaded) return;
280
392
 
281
393
  // Run init hook
282
- const INIT_TIMEOUT = 30_000;
283
- try {
284
- await Promise.race([
285
- plugin.init?.(config),
286
- new Promise((_, reject) =>
287
- setTimeout(() => reject(new Error("init timeout (30s)")), INIT_TIMEOUT),
288
- ),
289
- ]);
290
- } catch (err) {
291
- logError(
292
- "plugin",
293
- `Plugin "${plugin.name}" init failed: ${err instanceof Error ? err.message : err}`,
294
- );
295
- // Still registered — tools may work even if init partially failed
296
- }
394
+ await initPluginWithTimeout(
395
+ loaded.plugin,
396
+ loaded.config,
397
+ 30_000,
398
+ "init",
399
+ `Plugin "${loaded.plugin.name}" init failed`,
400
+ );
297
401
 
298
- const version = plugin.version ? ` v${plugin.version}` : "";
299
- const desc = plugin.description ? ` — ${plugin.description}` : "";
300
- log("plugin", `Loaded: ${plugin.name}${version}${desc}`);
402
+ const version = loaded.plugin.version ? ` v${loaded.plugin.version}` : "";
403
+ const desc = loaded.plugin.description
404
+ ? ` ${loaded.plugin.description}`
405
+ : "";
406
+ log("plugin", `Loaded: ${loaded.plugin.name}${version}${desc}`);
301
407
  }
302
408
 
303
409
  function resolveEntryPoint(pluginDir: string): string | null {
@@ -383,17 +489,15 @@ export async function loadBuiltinPlugins(config: TalonConfig): Promise<void> {
383
489
  const { createGitHubPlugin } = await import("../plugins/github/index.js");
384
490
  const gh = createGitHubPlugin({ token: github.token });
385
491
  const ghConfig = github as unknown as Record<string, unknown>;
386
- registerPlugin(gh, ghConfig);
387
- if (registry.getByName("github")) {
388
- await Promise.race([
389
- gh.init?.(ghConfig),
390
- new Promise((_, reject) =>
391
- setTimeout(
392
- () => reject(new Error("GitHub init timed out after 15s")),
393
- 15_000,
394
- ),
395
- ),
396
- ]);
492
+ const loaded = registerPlugin(gh, ghConfig);
493
+ if (loaded) {
494
+ await initPluginWithTimeout(
495
+ loaded.plugin,
496
+ loaded.config,
497
+ 15_000,
498
+ "GitHub init",
499
+ "GitHub init",
500
+ );
397
501
  }
398
502
  } catch (err) {
399
503
  logError(
@@ -413,17 +517,15 @@ export async function loadBuiltinPlugins(config: TalonConfig): Promise<void> {
413
517
  const palacePath = mempalace.palacePath ?? dirs.palace;
414
518
  const mp = createMempalacePlugin({ pythonPath, palacePath });
415
519
  const mpConfig = mempalace as unknown as Record<string, unknown>;
416
- registerPlugin(mp, mpConfig);
417
- if (registry.getByName("mempalace")) {
418
- await Promise.race([
419
- mp.init?.(mpConfig),
420
- new Promise((_, reject) =>
421
- setTimeout(
422
- () => reject(new Error("MemPalace init timed out after 30s")),
423
- 30_000,
424
- ),
425
- ),
426
- ]);
520
+ const loaded = registerPlugin(mp, mpConfig);
521
+ if (loaded) {
522
+ await initPluginWithTimeout(
523
+ loaded.plugin,
524
+ loaded.config,
525
+ 30_000,
526
+ "MemPalace init",
527
+ "MemPalace init",
528
+ );
427
529
  }
428
530
  } catch (err) {
429
531
  logError(
@@ -438,22 +540,22 @@ export async function loadBuiltinPlugins(config: TalonConfig): Promise<void> {
438
540
  try {
439
541
  const { createPlaywrightPlugin } =
440
542
  await import("../plugins/playwright/index.js");
543
+ const pwConfig = playwright as unknown as Record<string, unknown>;
441
544
  const pw = createPlaywrightPlugin({
442
545
  browser: playwright.browser,
443
546
  headless: playwright.headless,
547
+ endpoint: playwright.endpoint,
548
+ endpointFile: playwright.endpointFile,
444
549
  });
445
- const pwConfig = playwright as unknown as Record<string, unknown>;
446
- registerPlugin(pw, pwConfig);
447
- if (registry.getByName("playwright")) {
448
- await Promise.race([
449
- pw.init?.(pwConfig),
450
- new Promise((_, reject) =>
451
- setTimeout(
452
- () => reject(new Error("Playwright init timed out after 15s")),
453
- 15_000,
454
- ),
455
- ),
456
- ]);
550
+ const loaded = registerPlugin(pw, pwConfig);
551
+ if (loaded) {
552
+ await initPluginWithTimeout(
553
+ loaded.plugin,
554
+ loaded.config,
555
+ 15_000,
556
+ "Playwright init",
557
+ "Playwright init",
558
+ );
457
559
  }
458
560
  } catch (err) {
459
561
  logError(
@@ -518,35 +620,16 @@ export async function reloadPlugins(
518
620
  export function registerPlugin(
519
621
  plugin: TalonPlugin,
520
622
  config: Record<string, unknown> = {},
521
- ): void {
522
- // Check for duplicates first — avoids re-running expensive validation
523
- if (registry.getByName(plugin.name)) {
524
- logWarn(
525
- "plugin",
526
- `Built-in plugin "${plugin.name}" already registered — skipping`,
527
- );
528
- return;
529
- }
530
-
531
- const errors = plugin.validateConfig?.(config);
532
- if (errors && errors.length > 0) {
533
- logError(
534
- "plugin",
535
- `Built-in plugin "${plugin.name}" config validation failed:\n ${errors.join("\n ")}`,
536
- );
537
- return;
538
- }
539
-
540
- const envVars = plugin.getEnvVars?.(config) ?? {};
541
- for (const [k, v] of Object.entries(envVars)) {
542
- process.env[k] = v;
543
- }
544
- const loaded: LoadedPlugin = { plugin, config, envVars, path: "(built-in)" };
545
- registry.register(loaded);
546
-
547
- const version = plugin.version ? ` v${plugin.version}` : "";
548
- const desc = plugin.description ? ` — ${plugin.description}` : "";
549
- log("plugin", `Registered built-in: ${plugin.name}${version}${desc}`);
623
+ ): LoadedPlugin | null {
624
+ const loaded = registerPluginInstance(plugin, config, "(built-in)");
625
+ if (!loaded) return null;
626
+
627
+ const version = loaded.plugin.version ? ` v${loaded.plugin.version}` : "";
628
+ const desc = loaded.plugin.description
629
+ ? ` — ${loaded.plugin.description}`
630
+ : "";
631
+ log("plugin", `Registered built-in: ${loaded.plugin.name}${version}${desc}`);
632
+ return loaded;
550
633
  }
551
634
 
552
635
  /**
@@ -636,11 +719,7 @@ export function getPluginMcpServers(
636
719
  for (const { plugin, envVars } of registry.all) {
637
720
  // Skip plugins not in the allow-list when filtering
638
721
  if (only !== undefined && !only.includes(plugin.name)) continue;
639
- const baseEnv = {
640
- TALON_BRIDGE_URL: bridgeUrl,
641
- TALON_CHAT_ID: chatId,
642
- ...envVars,
643
- };
722
+ const baseEnv = buildBridgeEnv(bridgeUrl, chatId, envVars);
644
723
 
645
724
  if (plugin.mcpServer) {
646
725
  // Custom command/args (Python, Go, etc.) — no tsx wrapper
@@ -662,5 +741,15 @@ export function getPluginMcpServers(
662
741
  }
663
742
  }
664
743
 
744
+ // Include standalone MCP server entries from config
745
+ for (const entry of registry.mcpEntries) {
746
+ if (only !== undefined && !only.includes(entry.name)) continue;
747
+ servers[`${entry.name}-tools`] = {
748
+ command: entry.command,
749
+ args: [...(entry.args ?? [])],
750
+ env: buildBridgeEnv(bridgeUrl, chatId, entry.env),
751
+ };
752
+ }
753
+
665
754
  return servers;
666
755
  }
@@ -21,8 +21,14 @@ import {
21
21
  } from "../../core/pulse.js";
22
22
  import { handleCallbackQuery } from "./handlers.js";
23
23
  import { escapeHtml } from "./formatting.js";
24
- import { renderSettingsText, renderSettingsKeyboard } from "./helpers.js";
25
- import { getModels } from "../../core/models.js";
24
+ import {
25
+ formatModelLabel,
26
+ formatModelOptionLabel,
27
+ getTelegramModelOptions,
28
+ isSelectedModel,
29
+ renderSettingsText,
30
+ renderSettingsKeyboard,
31
+ } from "./helpers.js";
26
32
 
27
33
  export function registerCallbacks(bot: Bot, config: TalonConfig): void {
28
34
  // ── Callback query handler ──────────────────────────────────────────────────
@@ -205,23 +211,23 @@ export function registerCallbacks(bot: Bot, config: TalonConfig): void {
205
211
  if (model === "reset") {
206
212
  setChatModel(cid, undefined);
207
213
  await ctx.answerCallbackQuery({
208
- text: `Model: ${config.model} (default)`,
214
+ text: `Model: ${formatModelLabel(config.model)} (default)`,
209
215
  });
210
216
  } else {
211
217
  const resolved = resolveModelName(model);
212
218
  setChatModel(cid, resolved);
213
219
  await ctx.answerCallbackQuery({
214
- text: `Model: ${resolved}`,
220
+ text: `Model: ${formatModelLabel(resolved)}`,
215
221
  });
216
222
  }
217
223
  const current = getChatSettings(cid).model ?? config.model;
218
224
  // Build model buttons dynamically from the registry
219
- const models = getModels();
225
+ const models = getTelegramModelOptions();
220
226
  const modelButtons = models.map((m) => ({
221
- text: current.includes(m.id)
222
- ? `\u2713 ${m.displayName}`
223
- : m.displayName,
224
- callback_data: `model:${m.aliases[0] ?? m.id}`,
227
+ text: isSelectedModel(current, m.id)
228
+ ? `\u2713 ${formatModelOptionLabel(m)}`
229
+ : formatModelOptionLabel(m),
230
+ callback_data: `model:${m.id}`,
225
231
  }));
226
232
  const rows: Array<Array<{ text: string; callback_data: string }>> = [];
227
233
  for (let i = 0; i < modelButtons.length; i += 2) {
@@ -230,7 +236,7 @@ export function registerCallbacks(bot: Bot, config: TalonConfig): void {
230
236
  rows.push([{ text: "Reset to default", callback_data: "model:reset" }]);
231
237
  try {
232
238
  await ctx.editMessageText(
233
- `<b>Model:</b> <code>${escapeHtml(current)}</code>`,
239
+ `<b>Model:</b> <code>${escapeHtml(formatModelLabel(current))}</code>`,
234
240
  { parse_mode: "HTML", reply_markup: { inline_keyboard: rows } },
235
241
  );
236
242
  } catch {
@@ -36,6 +36,12 @@ import { isUserClientReady } from "./userbot.js";
36
36
  import { getWorkspaceDiskUsage } from "../../util/workspace.js";
37
37
  import { appendDailyLog } from "../../storage/daily-log.js";
38
38
  import { escapeHtml } from "./formatting.js";
39
+ import {
40
+ formatModelLabel,
41
+ formatModelOptionLabel,
42
+ getTelegramModelOptions,
43
+ isSelectedModel,
44
+ } from "./helpers.js";
39
45
  import { handleAdminCommand } from "./admin.js";
40
46
  import { getLoadedPlugins } from "../../core/plugin.js";
41
47
  import { getModels } from "../../core/models.js";
@@ -184,12 +190,12 @@ export function registerCommands(
184
190
  if (!arg) {
185
191
  const current = settings.model ?? config.model;
186
192
  // Build model buttons dynamically from the registry
187
- const models = getModels();
193
+ const models = getTelegramModelOptions();
188
194
  const modelButtons = models.map((m) => ({
189
- text: current.includes(m.id)
190
- ? `\u2713 ${m.displayName}`
191
- : m.displayName,
192
- callback_data: `model:${m.aliases[0] ?? m.id}`,
195
+ text: isSelectedModel(current, m.id)
196
+ ? `\u2713 ${formatModelOptionLabel(m)}`
197
+ : formatModelOptionLabel(m),
198
+ callback_data: `model:${m.id}`,
193
199
  }));
194
200
  // Two models per row, plus a reset button on the last row
195
201
  const rows: Array<Array<{ text: string; callback_data: string }>> = [];
@@ -199,7 +205,7 @@ export function registerCommands(
199
205
  rows.push([{ text: "Reset to default", callback_data: "model:reset" }]);
200
206
 
201
207
  await ctx.reply(
202
- `<b>Model:</b> <code>${escapeHtml(current)}</code>\nSelect a model:`,
208
+ `<b>Model:</b> <code>${escapeHtml(formatModelLabel(current))}</code>\nSelect a model:`,
203
209
  { parse_mode: "HTML", reply_markup: { inline_keyboard: rows } },
204
210
  );
205
211
  return;
@@ -208,7 +214,7 @@ export function registerCommands(
208
214
  if (arg === "reset" || arg === "default") {
209
215
  setChatModel(cid, undefined);
210
216
  await ctx.reply(
211
- `Model reset to default: <code>${escapeHtml(config.model)}</code>`,
217
+ `Model reset to default: <code>${escapeHtml(formatModelLabel(config.model))}</code>`,
212
218
  { parse_mode: "HTML" },
213
219
  );
214
220
  return;
@@ -216,9 +222,12 @@ export function registerCommands(
216
222
 
217
223
  const model = resolveModelName(arg);
218
224
  setChatModel(cid, model);
219
- await ctx.reply(`Model set to <code>${escapeHtml(model)}</code>.`, {
220
- parse_mode: "HTML",
221
- });
225
+ await ctx.reply(
226
+ `Model set to <code>${escapeHtml(formatModelLabel(model))}</code>.`,
227
+ {
228
+ parse_mode: "HTML",
229
+ },
230
+ );
222
231
  });
223
232
 
224
233
  bot.command("effort", async (ctx) => {
@@ -3,8 +3,10 @@
3
3
  */
4
4
 
5
5
  import { escapeHtml } from "./formatting.js";
6
- import { getModels } from "../../core/models.js";
6
+ import type { ModelInfo } from "../../core/models.js";
7
+ import { getModels, resolveModel, resolveModelId } from "../../core/models.js";
7
8
  const DEFAULT_PULSE_INTERVAL_MS = 5 * 60 * 1000;
9
+ const FAMILY_VERSION_PATTERN = /\b([A-Za-z][A-Za-z-]*)\s+(\d+(?:\.\d+)*)\b/;
8
10
 
9
11
  /** Parse a duration string like "30m", "2h", "1h30m" into milliseconds. */
10
12
  export function parseInterval(input: string): number | null {
@@ -38,6 +40,60 @@ export function formatBytes(bytes: number): string {
38
40
  return `${bytes} B`;
39
41
  }
40
42
 
43
+ function toDisplayFamilyName(family: string): string {
44
+ return family
45
+ .split("-")
46
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
47
+ .join(" ");
48
+ }
49
+
50
+ function formatResolvedModelLabel(model: ModelInfo): string {
51
+ const match = `${model.displayName} ${model.description ?? ""}`.match(
52
+ FAMILY_VERSION_PATTERN,
53
+ );
54
+ if (match) {
55
+ return `${toDisplayFamilyName(match[1])} ${match[2]}`;
56
+ }
57
+
58
+ const familyAlias = model.aliases.find(
59
+ (alias) =>
60
+ !alias.startsWith("claude-") &&
61
+ !alias.endsWith("[1m]") &&
62
+ !/[-.]\d/.test(alias),
63
+ );
64
+ const baseName = familyAlias
65
+ ? toDisplayFamilyName(familyAlias)
66
+ : model.displayName.replace(/\s*\([^)]*\)/g, "").trim();
67
+ return baseName;
68
+ }
69
+
70
+ export function formatModelLabel(modelId: string): string {
71
+ const model = resolveModel(modelId);
72
+ return model ? formatResolvedModelLabel(model) : modelId;
73
+ }
74
+
75
+ export function formatModelOptionLabel(model: ModelInfo): string {
76
+ return formatResolvedModelLabel(model);
77
+ }
78
+
79
+ export function formatCompactModelLabel(model: ModelInfo): string {
80
+ return formatResolvedModelLabel(model).replace(/\s+\d+(?:\.\d+)*$/, "");
81
+ }
82
+
83
+ export function getTelegramModelOptions(): ModelInfo[] {
84
+ const options: ModelInfo[] = [];
85
+ const seenKeys = new Set<string>();
86
+
87
+ for (const model of getModels()) {
88
+ const key = formatResolvedModelLabel(model).toLowerCase();
89
+ if (seenKeys.has(key)) continue;
90
+ seenKeys.add(key);
91
+ options.push(model);
92
+ }
93
+
94
+ return options;
95
+ }
96
+
41
97
  export function renderSettingsText(
42
98
  model: string,
43
99
  effort: string,
@@ -50,23 +106,38 @@ export function renderSettingsText(
50
106
  return [
51
107
  "<b>\uD83E\uDD85 Settings</b>",
52
108
  "",
53
- `<b>Model:</b> <code>${escapeHtml(model)}</code>`,
109
+ `<b>Model:</b> <code>${escapeHtml(formatModelLabel(model))}</code>`,
54
110
  `<b>Effort:</b> ${effort}`,
55
111
  `<b>Pulse:</b> ${proactive ? "on" : "off"} (every ${intervalStr})`,
56
112
  ].join("\n");
57
113
  }
58
114
 
115
+ export function isSelectedModel(
116
+ currentModel: string,
117
+ modelId: string,
118
+ ): boolean {
119
+ const current = resolveModel(currentModel);
120
+ const candidate = resolveModel(modelId);
121
+ if (current && candidate) {
122
+ return (
123
+ formatResolvedModelLabel(current).toLowerCase() ===
124
+ formatResolvedModelLabel(candidate).toLowerCase()
125
+ );
126
+ }
127
+ return resolveModelId(currentModel) === modelId;
128
+ }
129
+
59
130
  export function renderSettingsKeyboard(
60
131
  model: string,
61
132
  effort: string,
62
133
  proactive: boolean,
63
134
  ): Array<Array<{ text: string; callback_data: string }>> {
64
135
  // Build model buttons dynamically from the registry, chunked into rows of 3
65
- const modelButtons = getModels().map((m) => ({
66
- text: model.includes(m.id)
67
- ? `\u2713 ${m.displayName.split(" ")[0]}`
68
- : m.displayName.split(" ")[0],
69
- callback_data: `settings:model:${m.aliases[0] ?? m.id}`,
136
+ const modelButtons = getTelegramModelOptions().map((m) => ({
137
+ text: isSelectedModel(model, m.id)
138
+ ? `\u2713 ${formatCompactModelLabel(m)}`
139
+ : formatCompactModelLabel(m),
140
+ callback_data: `settings:model:${m.id}`,
70
141
  }));
71
142
  const modelRows: Array<Array<{ text: string; callback_data: string }>> = [];
72
143
  for (let i = 0; i < modelButtons.length; i += 3) {