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.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/__tests__/chat-settings.test.ts +47 -36
- package/src/__tests__/claude-sdk-models.test.ts +157 -0
- package/src/__tests__/claude-sdk-options.test.ts +118 -0
- package/src/__tests__/config.test.ts +112 -8
- package/src/__tests__/dream.test.ts +3 -3
- package/src/__tests__/fuzz.test.ts +15 -15
- package/src/__tests__/plugin.test.ts +155 -2
- package/src/__tests__/telegram-helpers.test.ts +113 -0
- package/src/backend/claude-sdk/models.ts +385 -68
- package/src/backend/claude-sdk/options.ts +6 -4
- package/src/backend/claude-sdk/stream.ts +1 -1
- package/src/cli.ts +1 -1
- package/src/core/models.ts +49 -5
- package/src/core/plugin.ts +207 -118
- package/src/frontend/telegram/callbacks.ts +16 -10
- package/src/frontend/telegram/commands.ts +19 -10
- package/src/frontend/telegram/helpers.ts +78 -7
- package/src/plugins/playwright/index.ts +54 -20
- package/src/util/config.ts +98 -15
package/src/core/plugin.ts
CHANGED
|
@@ -22,12 +22,33 @@ import type { TalonConfig } from "../util/config.js";
|
|
|
22
22
|
|
|
23
23
|
// ── Plugin interfaces ──────────────────────────────────────────────────────
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
export interface
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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 =
|
|
300
|
-
|
|
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 (
|
|
388
|
-
await
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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 (
|
|
418
|
-
await
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
):
|
|
522
|
-
|
|
523
|
-
if (
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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 {
|
|
25
|
-
|
|
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 =
|
|
225
|
+
const models = getTelegramModelOptions();
|
|
220
226
|
const modelButtons = models.map((m) => ({
|
|
221
|
-
text: current
|
|
222
|
-
? `\u2713 ${m
|
|
223
|
-
: m
|
|
224
|
-
callback_data: `model:${m.
|
|
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 =
|
|
193
|
+
const models = getTelegramModelOptions();
|
|
188
194
|
const modelButtons = models.map((m) => ({
|
|
189
|
-
text: current
|
|
190
|
-
? `\u2713 ${m
|
|
191
|
-
: m
|
|
192
|
-
callback_data: `model:${m.
|
|
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(
|
|
220
|
-
|
|
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 {
|
|
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 =
|
|
66
|
-
text: model
|
|
67
|
-
? `\u2713 ${m
|
|
68
|
-
: m
|
|
69
|
-
callback_data: `settings:model:${m.
|
|
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) {
|