talon-agent 3.16.1 → 3.18.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.
@@ -15,8 +15,9 @@
15
15
  import { type Client, REST, Routes, SlashCommandBuilder } from "discord.js";
16
16
  import type { TalonConfig } from "../../../util/config.js";
17
17
  import { log, logError, logWarn } from "../../../util/log.js";
18
+ import { getRepoRoot } from "../../../core/update/self-update.js";
18
19
 
19
- export function buildCommandDefinitions(): unknown[] {
20
+ export function buildCommandDefinitions(devBuild = false): unknown[] {
20
21
  return [
21
22
  new SlashCommandBuilder()
22
23
  .setName("start")
@@ -97,6 +98,40 @@ export function buildCommandDefinitions(): unknown[] {
97
98
  .setName("plugins")
98
99
  .setDescription("List loaded plugins")
99
100
  .toJSON(),
101
+ new SlashCommandBuilder()
102
+ .setName("usage")
103
+ .setDescription("Plan limits across every backend")
104
+ .toJSON(),
105
+ new SlashCommandBuilder()
106
+ .setName("doctor")
107
+ .setDescription("Environment and native-module health (admin)")
108
+ .toJSON(),
109
+ new SlashCommandBuilder()
110
+ .setName("mesh")
111
+ .setDescription("Ping and list mesh devices")
112
+ .toJSON(),
113
+ new SlashCommandBuilder()
114
+ .setName("soul")
115
+ .setDescription("Inspect the compiled identity")
116
+ .addStringOption((o) =>
117
+ o
118
+ .setName("action")
119
+ .setDescription("Leave empty to introspect")
120
+ .setRequired(false)
121
+ .addChoices({ name: "dream", value: "dream" }),
122
+ )
123
+ .toJSON(),
124
+ // /update only exists on developer builds running from a git checkout —
125
+ // a packaged binary has no source tree to pull into, so the command is
126
+ // never registered there (same gate as the Telegram handler).
127
+ ...(devBuild && getRepoRoot()
128
+ ? [
129
+ new SlashCommandBuilder()
130
+ .setName("update")
131
+ .setDescription("Pull latest, reinstall, restart (admin)")
132
+ .toJSON(),
133
+ ]
134
+ : []),
100
135
  new SlashCommandBuilder()
101
136
  .setName("admin")
102
137
  .setDescription("Admin operations (admin only)")
@@ -128,7 +163,7 @@ export async function registerCommandsForGuilds(
128
163
  ): Promise<void> {
129
164
  const dc = config.discord!;
130
165
  const rest = new REST({ version: "10" }).setToken(dc.botToken);
131
- const definitions = buildCommandDefinitions();
166
+ const definitions = buildCommandDefinitions(config.devBuild);
132
167
 
133
168
  // Step 1: clear or set global commands depending on DM setting.
134
169
  try {
@@ -7,8 +7,16 @@ import {
7
7
  type Client,
8
8
  MessageFlags,
9
9
  } from "discord.js";
10
- import { formatDuration } from "../helpers.js";
10
+ import {
11
+ formatDuration,
12
+ renderMeshReport,
13
+ renderUsageMessage,
14
+ } from "../helpers.js";
15
+ import type { TalonConfig } from "../../../util/config.js";
16
+ import { collectPlanUsage } from "../../shared/plan-usage-report.js";
11
17
  import { getLoadedPlugins } from "../../../core/plugin/index.js";
18
+ import { getMeshService } from "../../../core/mesh/index.js";
19
+ import type { MeshPingResult } from "../../../core/mesh/service.js";
12
20
  import { reply } from "./shared.js";
13
21
 
14
22
  export async function handleStart(
@@ -67,6 +75,35 @@ export async function handleHelp(
67
75
  );
68
76
  }
69
77
 
78
+ /**
79
+ * /usage — plan limits across every exposed backend, not just this chat's.
80
+ * Not admin-gated: it says how close the shared account is to a wall, which
81
+ * is exactly what a user hitting one needs to know.
82
+ */
83
+ export async function handleUsage(
84
+ i: ChatInputCommandInteraction,
85
+ config: TalonConfig,
86
+ ): Promise<void> {
87
+ await i.deferReply({ flags: MessageFlags.Ephemeral });
88
+ const entries = await collectPlanUsage(config);
89
+ await i.editReply(renderUsageMessage(entries));
90
+ }
91
+
92
+ export async function handleMesh(
93
+ i: ChatInputCommandInteraction,
94
+ ): Promise<void> {
95
+ await i.deferReply({ flags: MessageFlags.Ephemeral });
96
+ await i.editReply("Pinging mesh devices…");
97
+ let results: MeshPingResult[];
98
+ try {
99
+ results = await getMeshService().pingAll();
100
+ } catch {
101
+ await i.editReply("Could not reach the mesh service.");
102
+ return;
103
+ }
104
+ await i.editReply(renderMeshReport(results));
105
+ }
106
+
70
107
  export async function handlePing(
71
108
  i: ChatInputCommandInteraction,
72
109
  ): Promise<void> {
@@ -22,7 +22,14 @@ import {
22
22
  handleModalSubmit,
23
23
  } from "../callbacks/index.js";
24
24
  import { chatIdFromInteraction, reply, client } from "./shared.js";
25
- import { handleStart, handleHelp, handlePing, handlePlugins } from "./info.js";
25
+ import {
26
+ handleStart,
27
+ handleHelp,
28
+ handlePing,
29
+ handlePlugins,
30
+ handleMesh,
31
+ handleUsage,
32
+ } from "./info.js";
26
33
  import { handleReset, handleStatus } from "./session.js";
27
34
  import {
28
35
  handleModel,
@@ -35,6 +42,9 @@ import {
35
42
  handleMetrics,
36
43
  handleDream,
37
44
  handleAdmin,
45
+ handleDoctor,
46
+ handleSoul,
47
+ handleUpdate,
38
48
  } from "./admin.js";
39
49
 
40
50
  export function registerInteractionRouter(
@@ -141,6 +151,16 @@ async function routeSlashCommand(
141
151
  return handleDream(interaction);
142
152
  case "plugins":
143
153
  return handlePlugins(interaction);
154
+ case "usage":
155
+ return handleUsage(interaction, config);
156
+ case "doctor":
157
+ return handleDoctor(interaction, config);
158
+ case "mesh":
159
+ return handleMesh(interaction);
160
+ case "soul":
161
+ return handleSoul(interaction);
162
+ case "update":
163
+ return handleUpdate(interaction, config);
144
164
  case "admin":
145
165
  return handleAdmin(interaction, config, gateway);
146
166
  default:
@@ -46,6 +46,11 @@ import {
46
46
  import { resolveActiveModelForChat } from "../../../core/models/active-model.js";
47
47
  import { safeSlice } from "../formatting.js";
48
48
  import { reply } from "./shared.js";
49
+ import {
50
+ buildModelPickerView,
51
+ MODEL_NAV_PREFIX,
52
+ MODEL_PAGE_SIZE,
53
+ } from "../model-picker.js";
49
54
 
50
55
  export async function handleModel(
51
56
  i: ChatInputCommandInteraction,
@@ -94,6 +99,8 @@ export async function handleModel(
94
99
  if (be?.models?.getSettingsPresentation) {
95
100
  const pres = await be.models?.getSettingsPresentation(activeModel, {
96
101
  callbackPrefix: "model:",
102
+ navCallbackPrefix: MODEL_NAV_PREFIX,
103
+ pageSize: MODEL_PAGE_SIZE,
97
104
  });
98
105
  const modelInfo = activeModel
99
106
  ? await be.models?.getRawModelInfo?.(activeModel)
@@ -102,25 +109,10 @@ export async function handleModel(
102
109
  modelInfo?.displayName ??
103
110
  (activeModel ? formatModelLabel(activeModel) : "_No model selected_");
104
111
 
105
- // Build select menu from the top 25 models. Discord caps select-menu
106
- // options at 25; if the backend exposes more, use `/model name:<value>`
107
- // (autocomplete-backed) to pick anything outside this list.
108
- const options = pres.modelButtons.slice(0, 25).map((b) => ({
109
- label: safeSlice(b.text.replace(/^✓ /, ""), 100),
110
- value: safeSlice(b.callback_data.replace(/^model:/, ""), 100),
111
- default: b.text.startsWith("✓"),
112
- }));
113
- const menu = new StringSelectMenuBuilder()
114
- .setCustomId("model:select")
115
- .setPlaceholder("Pick a model")
116
- .addOptions(options);
117
- const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
118
- menu,
119
- );
120
- const lines = [`**Model:** \`${displayName}\``, ...pres.modelDetails];
112
+ const view = buildModelPickerView(pres, displayName, beId);
121
113
  await i.editReply({
122
- content: lines.join("\n"),
123
- components: [row],
114
+ content: view.content,
115
+ components: view.components,
124
116
  allowedMentions: { parse: [] },
125
117
  });
126
118
  } else {
@@ -8,10 +8,18 @@
8
8
  */
9
9
 
10
10
  import { REASONING_LEVEL_DESCRIPTIONS } from "../../core/models/reasoning-levels.js";
11
- import { DISCORD_MAX_TEXT, DISCORD_SAFE_RESERVE } from "./formatting.js";
11
+ import {
12
+ DISCORD_MAX_TEXT,
13
+ DISCORD_SAFE_RESERVE,
14
+ splitMessage,
15
+ } from "./formatting.js";
16
+ import type { DoctorReport } from "../../core/doctor.js";
17
+ import type { MeshPingResult } from "../../core/mesh/service.js";
18
+ import type { BackendUsageEntry } from "../shared/plan-usage-report.js";
12
19
  import {
13
20
  DEFAULT_PULSE_INTERVAL_MS,
14
21
  formatDuration,
22
+ formatBytes,
15
23
  formatModelLabel,
16
24
  } from "../shared/format.js";
17
25
 
@@ -157,6 +165,145 @@ export function renderMetricsMessages(
157
165
  return chunks;
158
166
  }
159
167
 
168
+ const DOCTOR_ICONS: Record<string, string> = {
169
+ ok: "✅",
170
+ warn: "⚠️",
171
+ fail: "❌",
172
+ info: "▫️",
173
+ };
174
+
175
+ /**
176
+ * Render a DoctorReport as Discord markdown, split to fit the message cap.
177
+ * Same data as `talon doctor` and Telegram's /doctor.
178
+ */
179
+ export function renderDoctorMessages(
180
+ report: DoctorReport,
181
+ maxLen = DEFAULT_METRICS_MESSAGE_MAX,
182
+ ): string[] {
183
+ const lines = ["**🩺 Talon Doctor**", "", "**Environment**"];
184
+
185
+ const render = (check: DoctorReport["checks"][number]): string =>
186
+ `${DOCTOR_ICONS[check.status]} ${check.label}${check.detail ? ` (${check.detail})` : ""}`;
187
+
188
+ for (const check of report.checks.filter((c) => !c.inactive)) {
189
+ lines.push(render(check));
190
+ }
191
+
192
+ // Configured-but-idle backends get their own block: they describe what a
193
+ // switch would run into, not the state of the running deployment.
194
+ const idle = report.checks.filter((c) => c.inactive);
195
+ if (idle.length > 0) {
196
+ lines.push("", "**Other backends**", ...idle.map(render));
197
+ }
198
+
199
+ lines.push("", "**Native modules**");
200
+ for (const mod of report.native) {
201
+ const size =
202
+ mod.sizeBytes !== undefined ? ` · ${formatBytes(mod.sizeBytes)}` : "";
203
+ const note = mod.note ? ` (${mod.note})` : "";
204
+ lines.push(
205
+ `${mod.ok ? DOCTOR_ICONS.ok : DOCTOR_ICONS.fail} \`${mod.name}\` — ${mod.language} → ${mod.target}${size}${note}`,
206
+ );
207
+ }
208
+
209
+ lines.push(
210
+ "",
211
+ "**Process**",
212
+ `Uptime ${formatDuration(process.uptime() * 1000)} · PID ${process.pid} · Node ${process.versions.node}`,
213
+ "",
214
+ report.issues === 0
215
+ ? `${DOCTOR_ICONS.ok} All checks passed.`
216
+ : `${DOCTOR_ICONS.warn} ${report.issues} issue(s) found.`,
217
+ );
218
+
219
+ return splitMessage(lines.join("\n"), maxLen);
220
+ }
221
+
222
+ function meshDeviceLine(r: MeshPingResult, now: number): string {
223
+ const d = r.device;
224
+ const bits: string[] = [d.platform];
225
+ if (r.reachable && typeof r.latencyMs === "number") {
226
+ bits.push(`${r.latencyMs} ms`);
227
+ } else if (d.online && r.error) {
228
+ bits.push(r.error);
229
+ } else if (!d.online) {
230
+ bits.push(`last seen ${formatDuration(now - d.lastSeen)} ago`);
231
+ }
232
+ if (typeof d.battery === "number") {
233
+ bits.push(`${d.battery}%${d.charging ? " charging" : ""}`);
234
+ }
235
+ return ` **${d.name}** — ${bits.join(" · ")}`;
236
+ }
237
+
238
+ /**
239
+ * Render the /mesh fleet report as Discord markdown. Devices group under a
240
+ * state heading; empty groups are omitted.
241
+ */
242
+ export function renderMeshReport(
243
+ results: MeshPingResult[],
244
+ now = Date.now(),
245
+ ): string {
246
+ if (results.length === 0) {
247
+ return "**Mesh**\n\n_No devices have registered yet._";
248
+ }
249
+
250
+ const responding = results
251
+ .filter((r) => r.reachable)
252
+ .sort((a, b) => (a.latencyMs ?? Infinity) - (b.latencyMs ?? Infinity));
253
+ const unreachable = results
254
+ .filter((r) => !r.reachable && r.device.online)
255
+ .sort((a, b) => a.device.name.localeCompare(b.device.name));
256
+ const offline = results
257
+ .filter((r) => !r.reachable && !r.device.online)
258
+ .sort((a, b) => b.device.lastSeen - a.device.lastSeen);
259
+
260
+ const summary = [
261
+ `${results.length} device${results.length === 1 ? "" : "s"}`,
262
+ `${responding.length} responding`,
263
+ ...(unreachable.length > 0 ? [`${unreachable.length} unreachable`] : []),
264
+ ...(offline.length > 0 ? [`${offline.length} offline`] : []),
265
+ ].join(" · ");
266
+
267
+ const lines = ["**Mesh**", summary];
268
+ const section = (title: string, entries: MeshPingResult[]): void => {
269
+ if (entries.length === 0) return;
270
+ lines.push(
271
+ "",
272
+ `**${title}**`,
273
+ ...entries.map((r) => meshDeviceLine(r, now)),
274
+ );
275
+ };
276
+ section("Responding", responding);
277
+ section("Unreachable", unreachable);
278
+ section("Offline", offline);
279
+
280
+ return lines.join("\n");
281
+ }
282
+
283
+ /** Render the `/usage` report — one block per exposed backend. */
284
+ export function renderUsageMessage(entries: BackendUsageEntry[]): string {
285
+ const lines = ["**📊 Plan usage**"];
286
+
287
+ for (const entry of entries) {
288
+ const name = entry.label || entry.id;
289
+ if (!entry.plan) {
290
+ lines.push("", `**${name}** — _${entry.note ?? ""}_`);
291
+ continue;
292
+ }
293
+ const age = entry.plan.ageLabel ? ` *(${entry.plan.ageLabel})*` : "";
294
+ const plan = entry.plan.plan ? ` · ${entry.plan.plan}` : "";
295
+ lines.push("", `**${name}**${plan}${age}`);
296
+ for (const w of entry.plan.windows) {
297
+ const reset = w.resetLabel ? ` reset ${w.resetLabel}` : "";
298
+ lines.push(
299
+ ` \`${w.label.padEnd(6)}${w.bar} ${String(w.percent).padStart(3)}%\`${reset}`,
300
+ );
301
+ }
302
+ }
303
+
304
+ return lines.join("\n");
305
+ }
306
+
160
307
  /** Settings panel: build the markdown body. */
161
308
  export function renderSettingsText(
162
309
  model: string,
@@ -73,8 +73,17 @@ export function createDiscordFrontend(
73
73
  GatewayIntentBits.MessageContent,
74
74
  GatewayIntentBits.DirectMessages,
75
75
  GatewayIntentBits.GuildMessageReactions,
76
+ GatewayIntentBits.DirectMessageReactions,
77
+ ],
78
+ // Partials.Reaction is what makes reactions on messages that predate the
79
+ // current cache arrive at all — without it the soul's reaction tap only
80
+ // ever sees freshly-cached messages.
81
+ partials: [
82
+ Partials.Channel,
83
+ Partials.Message,
84
+ Partials.User,
85
+ Partials.Reaction,
76
86
  ],
77
- partials: [Partials.Channel, Partials.Message, Partials.User],
78
87
  allowedMentions: { parse: [] }, // never ping anyone unless we explicitly opt in
79
88
  });
80
89
 
@@ -21,6 +21,7 @@ import { pushMessage } from "../../storage/history.js";
21
21
  import { registerChat } from "../../core/background/pulse.js";
22
22
  import { deriveNumericChatId } from "../../util/chat-id.js";
23
23
  import { handleMessage, getSenderName } from "./handlers/index.js";
24
+ import { recordReactionToBot } from "../../core/soul/taps.js";
24
25
 
25
26
  export function registerMiddleware(client: Client, config: TalonConfig): void {
26
27
  client.on("messageCreate", (msg: Message) => {
@@ -85,4 +86,27 @@ export function registerMiddleware(client: Client, config: TalonConfig): void {
85
86
  /* logged inside */
86
87
  });
87
88
  });
89
+
90
+ // ── Reaction tap — feed reactions on Talon's own messages to the soul ────
91
+ // The gateway records outgoing message ids as `Number(snowflake)`, so the
92
+ // lookup here must use the same conversion to match. Inert unless the soul
93
+ // is enabled.
94
+ client.on("messageReactionAdd", async (reaction, user) => {
95
+ if (user.id === client.user?.id) return;
96
+ try {
97
+ // A partial arrives when the message predates the cache; the chat id
98
+ // can't be built without the channel/guild it belongs to.
99
+ if (reaction.partial) await reaction.fetch();
100
+ } catch {
101
+ return;
102
+ }
103
+ const msg = reaction.message;
104
+ const emoji = reaction.emoji.name;
105
+ if (!emoji) return;
106
+ const chatId =
107
+ msg.channel.type === ChannelType.DM
108
+ ? `discord_dm_${msg.author?.id ?? user.id}`
109
+ : `discord_guild_${msg.guildId}_${msg.channelId}`;
110
+ recordReactionToBot(chatId, Number(msg.id), [emoji]);
111
+ });
88
112
  }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Model picker rendering — select menu plus pager controls.
3
+ *
4
+ * The backend already paginates and filters the catalog
5
+ * (`getSettingsPresentation` returns page / totalPages / filter / provider);
6
+ * this only lays that out as Discord components. A select menu caps at 25
7
+ * options, so without the pager everything past the first 25 models was
8
+ * unreachable from the UI.
9
+ *
10
+ * Navigation state rides in the custom_id — `model:nav:page:3:free` — which
11
+ * is what keeps the handler stateless across re-renders.
12
+ */
13
+
14
+ import {
15
+ ActionRowBuilder,
16
+ ButtonBuilder,
17
+ ButtonStyle,
18
+ StringSelectMenuBuilder,
19
+ type APIActionRowComponent,
20
+ type APIComponentInMessageActionRow,
21
+ } from "discord.js";
22
+ import type { ModelPickerResult } from "../../core/types.js";
23
+ import { safeSlice } from "./formatting.js";
24
+
25
+ export const MODEL_NAV_PREFIX = "model:nav";
26
+ /** Discord's hard cap on select-menu options — also the page size we request. */
27
+ export const MODEL_PAGE_SIZE = 25;
28
+
29
+ /** Where a nav button points. Decoded from a `model:nav:…` custom_id. */
30
+ export type ModelNavTarget = {
31
+ page: number;
32
+ filter: "all" | "free";
33
+ provider?: string;
34
+ };
35
+
36
+ export function decodeModelNav(customId: string): ModelNavTarget | null {
37
+ if (!customId.startsWith(`${MODEL_NAV_PREFIX}:`)) return null;
38
+ const parts = customId.slice(MODEL_NAV_PREFIX.length + 1).split(":");
39
+ const [kind] = parts;
40
+
41
+ if (kind === "providers") return { page: 1, filter: "all" };
42
+ if (kind === "provider") {
43
+ return { page: 1, filter: "all", provider: parts[1] };
44
+ }
45
+ if (kind === "page") {
46
+ const page = Number(parts[1]);
47
+ const filter = parts[2] === "free" ? "free" : "all";
48
+ return {
49
+ page: Number.isFinite(page) && page > 0 ? page : 1,
50
+ filter,
51
+ ...(parts[3] ? { provider: parts[3] } : {}),
52
+ };
53
+ }
54
+ if (kind === "filter") {
55
+ const filter = parts[1] === "free" ? "free" : "all";
56
+ return { page: 1, filter, ...(parts[2] ? { provider: parts[2] } : {}) };
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function navId(suffix: string, provider?: string): string {
62
+ const base = `${MODEL_NAV_PREFIX}:${suffix}`;
63
+ return provider ? `${base}:${provider}` : base;
64
+ }
65
+
66
+ /**
67
+ * Buttons under the select: page arrows, a page counter, the free-tier
68
+ * toggle, and a way back to the provider list. Five buttons is exactly one
69
+ * Action Row, so this never spills into a second row.
70
+ */
71
+ function controlRow(
72
+ pres: ModelPickerResult,
73
+ ): ActionRowBuilder<ButtonBuilder> | null {
74
+ const buttons: ButtonBuilder[] = [];
75
+ const { page, totalPages, filter, freeCount, provider } = pres;
76
+
77
+ if (totalPages > 1) {
78
+ buttons.push(
79
+ new ButtonBuilder()
80
+ .setCustomId(navId(`page:${page - 1}:${filter}`, provider))
81
+ .setLabel("◀")
82
+ .setStyle(ButtonStyle.Secondary)
83
+ .setDisabled(page <= 1),
84
+ // A disabled button is the only way to render a static counter
85
+ // inline with the arrows.
86
+ new ButtonBuilder()
87
+ .setCustomId(navId("noop"))
88
+ .setLabel(`${page}/${totalPages}`)
89
+ .setStyle(ButtonStyle.Secondary)
90
+ .setDisabled(true),
91
+ new ButtonBuilder()
92
+ .setCustomId(navId(`page:${page + 1}:${filter}`, provider))
93
+ .setLabel("▶")
94
+ .setStyle(ButtonStyle.Secondary)
95
+ .setDisabled(page >= totalPages),
96
+ );
97
+ }
98
+
99
+ // A filter that would keep everything is just a button that does nothing —
100
+ // offer it only when part of the catalog costs money.
101
+ if (freeCount > 0 && freeCount < pres.totalCount) {
102
+ buttons.push(
103
+ new ButtonBuilder()
104
+ .setCustomId(
105
+ navId(`filter:${filter === "free" ? "all" : "free"}`, provider),
106
+ )
107
+ .setLabel(filter === "free" ? `All models` : `Free only (${freeCount})`)
108
+ .setStyle(ButtonStyle.Secondary),
109
+ );
110
+ }
111
+
112
+ if (provider) {
113
+ buttons.push(
114
+ new ButtonBuilder()
115
+ .setCustomId(navId("providers"))
116
+ .setLabel("← Providers")
117
+ .setStyle(ButtonStyle.Secondary),
118
+ );
119
+ }
120
+
121
+ if (buttons.length === 0) return null;
122
+ return new ActionRowBuilder<ButtonBuilder>().addComponents(buttons);
123
+ }
124
+
125
+ export interface ModelPickerView {
126
+ content: string;
127
+ components: APIActionRowComponent<APIComponentInMessageActionRow>[];
128
+ }
129
+
130
+ /** Select of the backends config exposes, plus a way back to the models. */
131
+ export function buildBackendPickerView(
132
+ backends: { id: string; label: string }[],
133
+ currentId: string,
134
+ ): ModelPickerView {
135
+ const menu = new StringSelectMenuBuilder()
136
+ .setCustomId("model:backend-select")
137
+ .setPlaceholder("Pick a backend")
138
+ .addOptions(
139
+ backends.map((b) => ({
140
+ label: safeSlice(b.label || b.id, 100),
141
+ value: safeSlice(b.id, 100),
142
+ description: b.id === currentId ? "current" : undefined,
143
+ default: b.id === currentId,
144
+ })),
145
+ );
146
+
147
+ const back = new ActionRowBuilder<ButtonBuilder>().addComponents(
148
+ new ButtonBuilder()
149
+ .setCustomId(navId("page:1:all"))
150
+ .setLabel("← Models")
151
+ .setStyle(ButtonStyle.Secondary),
152
+ );
153
+
154
+ return {
155
+ content: [
156
+ `**Backend:** \`${currentId}\``,
157
+ "",
158
+ "_Switching clears this chat's session — each backend keeps its own model pick._",
159
+ ].join("\n"),
160
+ components: [
161
+ new ActionRowBuilder<StringSelectMenuBuilder>()
162
+ .addComponents(menu)
163
+ .toJSON(),
164
+ back.toJSON(),
165
+ ],
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Lay out one page of the picker. In the `groups` view the select lists
171
+ * providers to drill into; in `models` it lists selectable models.
172
+ */
173
+ export function buildModelPickerView(
174
+ pres: ModelPickerResult,
175
+ displayName: string,
176
+ backendId: string,
177
+ ): ModelPickerView {
178
+ const isGroups = pres.view === "groups";
179
+ const options = pres.modelButtons.slice(0, MODEL_PAGE_SIZE).map((b) => ({
180
+ // Discord's own guidance is ~38 characters before a label starts
181
+ // truncating visually, well under the 100-character hard cap.
182
+ label: safeSlice(b.text.replace(/^✓ /, ""), 100),
183
+ value: safeSlice(
184
+ isGroups
185
+ ? b.callback_data.replace(/^.*:provider:/, "provider:")
186
+ : b.callback_data.replace(/^model:/, ""),
187
+ 100,
188
+ ),
189
+ default: !isGroups && b.text.startsWith("✓"),
190
+ }));
191
+
192
+ const components: APIActionRowComponent<APIComponentInMessageActionRow>[] =
193
+ [];
194
+
195
+ if (options.length > 0) {
196
+ const menu = new StringSelectMenuBuilder()
197
+ .setCustomId("model:select")
198
+ .setPlaceholder(isGroups ? "Pick a provider" : "Pick a model")
199
+ .addOptions(options);
200
+ components.push(
201
+ new ActionRowBuilder<StringSelectMenuBuilder>()
202
+ .addComponents(menu)
203
+ .toJSON(),
204
+ );
205
+ }
206
+
207
+ const controls = controlRow(pres);
208
+ if (controls) components.push(controls.toJSON());
209
+
210
+ // Own row: the control row is already at Discord's five-button ceiling
211
+ // whenever the catalog pages.
212
+ components.push(
213
+ new ActionRowBuilder<ButtonBuilder>()
214
+ .addComponents(
215
+ new ButtonBuilder()
216
+ .setCustomId(navId("backends"))
217
+ .setLabel(`⚙ Backend: ${backendId}`)
218
+ .setStyle(ButtonStyle.Secondary),
219
+ )
220
+ .toJSON(),
221
+ );
222
+
223
+ const scope = pres.provider ? ` · ${pres.provider}` : "";
224
+ const counts =
225
+ pres.totalPages > 1
226
+ ? ` · ${pres.totalCount} model${pres.totalCount === 1 ? "" : "s"}`
227
+ : "";
228
+ const lines = [
229
+ `**Model:** \`${displayName}\`${scope}${counts}`,
230
+ ...pres.modelDetails,
231
+ ];
232
+
233
+ return { content: lines.join("\n"), components };
234
+ }