talon-agent 3.33.3 → 3.34.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/package.json +6 -5
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +29 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/plugins/github/provision.ts +1 -1
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/frontend/discord/callbacks/components.ts +0 -793
- package/src/frontend/discord/callbacks/shared.ts +0 -22
|
@@ -1,793 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Component (button + select menu) interaction handler.
|
|
3
|
-
*
|
|
4
|
-
* Settings panel callbacks update the original message in place (Discord
|
|
5
|
-
* supports update() on the interaction). AI-generated buttons (`ai:` prefix)
|
|
6
|
-
* are forwarded to the agent verbatim.
|
|
7
|
-
*
|
|
8
|
-
* Custom-id namespace:
|
|
9
|
-
* settings:done
|
|
10
|
-
* settings:model (select menu, value=modelId)
|
|
11
|
-
* settings:effort:select (select menu)
|
|
12
|
-
* settings:proactive:on|off
|
|
13
|
-
* pulse:on | pulse:off | pulse:interval (button → opens modal)
|
|
14
|
-
* effort:select (select menu, standalone /effort)
|
|
15
|
-
* model:select (select menu, standalone /model)
|
|
16
|
-
* ai:<id> (AI-generated buttons — forwarded to agent)
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
type ButtonInteraction,
|
|
21
|
-
ActionRowBuilder,
|
|
22
|
-
ButtonBuilder,
|
|
23
|
-
ButtonStyle,
|
|
24
|
-
StringSelectMenuBuilder,
|
|
25
|
-
ModalBuilder,
|
|
26
|
-
TextInputBuilder,
|
|
27
|
-
TextInputStyle,
|
|
28
|
-
MessageFlags,
|
|
29
|
-
} from "discord.js";
|
|
30
|
-
import type { TalonConfig } from "../../../util/config.js";
|
|
31
|
-
import type { Gateway } from "../../../core/engine/gateway.js";
|
|
32
|
-
import {
|
|
33
|
-
buildModelPickerView,
|
|
34
|
-
buildBackendPickerView,
|
|
35
|
-
decodeModelNav,
|
|
36
|
-
MODEL_NAV_PREFIX,
|
|
37
|
-
MODEL_PAGE_SIZE,
|
|
38
|
-
} from "../model-picker.js";
|
|
39
|
-
import { metricsViewRow, renderMetricsView } from "../commands/admin.js";
|
|
40
|
-
import {
|
|
41
|
-
getChatSettings,
|
|
42
|
-
setChatModelForBackend,
|
|
43
|
-
setChatBackend,
|
|
44
|
-
setChatEffort,
|
|
45
|
-
type EffortLevel,
|
|
46
|
-
} from "../../../storage/chat-settings.js";
|
|
47
|
-
import { resolveModelId as resolveModelName } from "../../../core/models/catalog.js";
|
|
48
|
-
import {
|
|
49
|
-
registerChat,
|
|
50
|
-
disablePulse,
|
|
51
|
-
enablePulse,
|
|
52
|
-
isPulseEnabled,
|
|
53
|
-
} from "../../../core/background/pulse.js";
|
|
54
|
-
import {
|
|
55
|
-
isInteractionAllowed,
|
|
56
|
-
registerDiscordChat,
|
|
57
|
-
} from "../handlers/index.js";
|
|
58
|
-
import { renderSettingsText, EFFORT_DESCRIPTIONS } from "../helpers.js";
|
|
59
|
-
import { execute } from "../../../core/engine/dispatcher.js";
|
|
60
|
-
import {
|
|
61
|
-
getBackendIdForChat,
|
|
62
|
-
resolveChatBackend,
|
|
63
|
-
listAvailableBackends,
|
|
64
|
-
rebindChat,
|
|
65
|
-
} from "../../../core/engine/backend-controller/index.js";
|
|
66
|
-
import { resetSession } from "../../../storage/sessions.js";
|
|
67
|
-
import { clearHistory } from "../../../storage/history.js";
|
|
68
|
-
import { resetPulseCheckpoint } from "../../../core/background/pulse.js";
|
|
69
|
-
import { resolveActiveModelForChat } from "../../../core/models/active-model.js";
|
|
70
|
-
import {
|
|
71
|
-
displayReasoningEffort,
|
|
72
|
-
getActiveReasoningLevels,
|
|
73
|
-
supportsReasoningLevel,
|
|
74
|
-
} from "../../shared/reasoning-levels.js";
|
|
75
|
-
import { appendDailyLog } from "../../../storage/daily-log.js";
|
|
76
|
-
import { logError } from "../../../util/log.js";
|
|
77
|
-
import {
|
|
78
|
-
suppressMentions,
|
|
79
|
-
splitMessage,
|
|
80
|
-
safeSlice,
|
|
81
|
-
DISCORD_MAX_TEXT,
|
|
82
|
-
} from "../formatting.js";
|
|
83
|
-
import { chatIdFromInteraction, type ComponentInteraction } from "./shared.js";
|
|
84
|
-
|
|
85
|
-
export async function handleComponentInteraction(
|
|
86
|
-
interaction: ComponentInteraction,
|
|
87
|
-
config: TalonConfig,
|
|
88
|
-
gateway: Gateway,
|
|
89
|
-
): Promise<void> {
|
|
90
|
-
// Access control
|
|
91
|
-
const access = isInteractionAllowed(
|
|
92
|
-
interaction.inGuild(),
|
|
93
|
-
interaction.user.id,
|
|
94
|
-
interaction.guildId,
|
|
95
|
-
interaction.channelId,
|
|
96
|
-
);
|
|
97
|
-
if (!access.ok) {
|
|
98
|
-
await interaction.reply({
|
|
99
|
-
content: `⚠️ ${access.reason}`,
|
|
100
|
-
flags: MessageFlags.Ephemeral,
|
|
101
|
-
});
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const { chatId, numericChatId } = chatIdFromInteraction(interaction);
|
|
106
|
-
registerDiscordChat({
|
|
107
|
-
channelId: interaction.channelId!,
|
|
108
|
-
guildId: interaction.guildId,
|
|
109
|
-
userId: interaction.guildId ? null : interaction.user.id,
|
|
110
|
-
numericChatId,
|
|
111
|
-
chatId,
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
const customId = interaction.customId;
|
|
115
|
-
|
|
116
|
-
// ── Settings panel callbacks ───────────────────────────────────────────────
|
|
117
|
-
if (customId.startsWith("settings:")) {
|
|
118
|
-
const parts = customId.split(":");
|
|
119
|
-
const category = parts[1];
|
|
120
|
-
|
|
121
|
-
if (category === "done") {
|
|
122
|
-
try {
|
|
123
|
-
await interaction.update({ components: [], content: "Done." });
|
|
124
|
-
} catch {
|
|
125
|
-
/* ignore */
|
|
126
|
-
}
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (category === "model" && interaction.isStringSelectMenu()) {
|
|
131
|
-
const value = interaction.values[0];
|
|
132
|
-
const settingsBe = resolveChatBackend(chatId, gateway?.backend);
|
|
133
|
-
const settingsBeId = getBackendIdForChat(chatId);
|
|
134
|
-
if (value === "reset") {
|
|
135
|
-
// Clear THIS backend's slot only — other backends' picks stay.
|
|
136
|
-
setChatModelForBackend(chatId, settingsBeId, undefined);
|
|
137
|
-
} else if (settingsBe?.models?.resolveModelInfo) {
|
|
138
|
-
const resolution = await settingsBe.models?.resolveModelInfo(value);
|
|
139
|
-
if (resolution.kind !== "exact" || !resolution.model.selectable) {
|
|
140
|
-
await interaction.reply({
|
|
141
|
-
content: `Model unavailable.`,
|
|
142
|
-
flags: MessageFlags.Ephemeral,
|
|
143
|
-
});
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
setChatModelForBackend(chatId, settingsBeId, resolution.storedValue);
|
|
147
|
-
setChatBackend(chatId, settingsBeId);
|
|
148
|
-
} else {
|
|
149
|
-
setChatModelForBackend(chatId, settingsBeId, resolveModelName(value));
|
|
150
|
-
setChatBackend(chatId, settingsBeId);
|
|
151
|
-
}
|
|
152
|
-
await refreshSettingsPanel(interaction, config, gateway, chatId);
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (category === "effort" && interaction.isStringSelectMenu()) {
|
|
157
|
-
// settings:effort:select — current shape (emitted by handleSettings +
|
|
158
|
-
// refreshSettingsPanel).
|
|
159
|
-
const level = interaction.values[0];
|
|
160
|
-
const settingsBe = resolveChatBackend(chatId, gateway?.backend);
|
|
161
|
-
const settingsBeId = getBackendIdForChat(chatId);
|
|
162
|
-
const reasoning = await getActiveReasoningLevels({
|
|
163
|
-
chatId,
|
|
164
|
-
backend: settingsBe,
|
|
165
|
-
backendId: settingsBeId,
|
|
166
|
-
config,
|
|
167
|
-
});
|
|
168
|
-
if (level === "adaptive") {
|
|
169
|
-
setChatEffort(chatId, undefined);
|
|
170
|
-
} else if (supportsReasoningLevel(level, reasoning.levels)) {
|
|
171
|
-
setChatEffort(chatId, level as EffortLevel);
|
|
172
|
-
} else {
|
|
173
|
-
await interaction.reply({
|
|
174
|
-
content: "No valid reasoning level for this model.",
|
|
175
|
-
flags: MessageFlags.Ephemeral,
|
|
176
|
-
});
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
await refreshSettingsPanel(interaction, config, gateway, chatId);
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (category === "proactive") {
|
|
184
|
-
const value = parts[2] ?? "";
|
|
185
|
-
if (value === "on") {
|
|
186
|
-
enablePulse(chatId);
|
|
187
|
-
registerChat(chatId);
|
|
188
|
-
} else {
|
|
189
|
-
disablePulse(chatId);
|
|
190
|
-
}
|
|
191
|
-
await refreshSettingsPanel(interaction, config, gateway, chatId);
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ── /pulse "Set interval…" button → opens modal ───────────────────────
|
|
197
|
-
// Must come BEFORE the `pulse:` startsWith branch.
|
|
198
|
-
if (customId === "pulse:interval" && interaction.isButton()) {
|
|
199
|
-
const modal = new ModalBuilder()
|
|
200
|
-
.setCustomId("modal:pulse-interval")
|
|
201
|
-
.setTitle("Pulse interval");
|
|
202
|
-
const input = new TextInputBuilder()
|
|
203
|
-
.setCustomId("interval")
|
|
204
|
-
.setLabel("How often (min 5m)")
|
|
205
|
-
.setPlaceholder("30m, 2h, 90m, 1d…")
|
|
206
|
-
.setStyle(TextInputStyle.Short)
|
|
207
|
-
.setMinLength(2)
|
|
208
|
-
.setMaxLength(16)
|
|
209
|
-
.setRequired(true);
|
|
210
|
-
modal.addComponents(
|
|
211
|
-
new ActionRowBuilder<TextInputBuilder>().addComponents(input),
|
|
212
|
-
);
|
|
213
|
-
await interaction.showModal(modal);
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// ── /effort select-menu ────────────────────────────────────────────────
|
|
218
|
-
// Must come BEFORE the `effort:` startsWith branch.
|
|
219
|
-
if (customId === "effort:select" && interaction.isStringSelectMenu()) {
|
|
220
|
-
const level = interaction.values[0];
|
|
221
|
-
const be = resolveChatBackend(chatId, gateway?.backend);
|
|
222
|
-
const beId = getBackendIdForChat(chatId);
|
|
223
|
-
const reasoning = await getActiveReasoningLevels({
|
|
224
|
-
chatId,
|
|
225
|
-
backend: be,
|
|
226
|
-
backendId: beId,
|
|
227
|
-
config,
|
|
228
|
-
});
|
|
229
|
-
if (reasoning.levels.length === 0) {
|
|
230
|
-
await interaction.update({
|
|
231
|
-
content: "No valid reasoning levels found for this model.",
|
|
232
|
-
components: [],
|
|
233
|
-
});
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
if (level === "adaptive") setChatEffort(chatId, undefined);
|
|
237
|
-
else if (supportsReasoningLevel(level, reasoning.levels))
|
|
238
|
-
setChatEffort(chatId, level as EffortLevel);
|
|
239
|
-
else {
|
|
240
|
-
await interaction.update({
|
|
241
|
-
content: "Invalid reasoning level for this model.",
|
|
242
|
-
components: [],
|
|
243
|
-
});
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
try {
|
|
247
|
-
await interaction.update({
|
|
248
|
-
content: `**Effort:** ${level}`,
|
|
249
|
-
components: [],
|
|
250
|
-
});
|
|
251
|
-
} catch {
|
|
252
|
-
/* ignore */
|
|
253
|
-
}
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ── Standalone /pulse on|off buttons ──────────────────────────────────────
|
|
258
|
-
if (customId.startsWith("pulse:")) {
|
|
259
|
-
const val = customId.slice(6);
|
|
260
|
-
if (val === "on") {
|
|
261
|
-
enablePulse(chatId);
|
|
262
|
-
registerChat(chatId);
|
|
263
|
-
} else if (val === "off") {
|
|
264
|
-
disablePulse(chatId);
|
|
265
|
-
}
|
|
266
|
-
const enabled = isPulseEnabled(chatId);
|
|
267
|
-
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
268
|
-
new ButtonBuilder()
|
|
269
|
-
.setCustomId("pulse:on")
|
|
270
|
-
.setLabel(enabled ? "✓ On" : "On")
|
|
271
|
-
.setStyle(enabled ? ButtonStyle.Success : ButtonStyle.Secondary),
|
|
272
|
-
new ButtonBuilder()
|
|
273
|
-
.setCustomId("pulse:off")
|
|
274
|
-
.setLabel(!enabled ? "✓ Off" : "Off")
|
|
275
|
-
.setStyle(ButtonStyle.Secondary),
|
|
276
|
-
);
|
|
277
|
-
try {
|
|
278
|
-
await (interaction as ButtonInteraction).update({
|
|
279
|
-
content: [
|
|
280
|
-
`**🔔 Pulse:** ${enabled ? "on" : "off"}`,
|
|
281
|
-
"",
|
|
282
|
-
"Reads along every few minutes and jumps in when there's something to add.",
|
|
283
|
-
].join("\n"),
|
|
284
|
-
components: [row],
|
|
285
|
-
});
|
|
286
|
-
} catch {
|
|
287
|
-
/* ignore */
|
|
288
|
-
}
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// ── /model select-menu (separate from settings) ─────────────────────────
|
|
293
|
-
if (customId === "model:select" && interaction.isStringSelectMenu()) {
|
|
294
|
-
const value = interaction.values[0];
|
|
295
|
-
// Resolve and snapshot against the per-chat backend — never the
|
|
296
|
-
// global default — so this works in channels with a backend
|
|
297
|
-
// override pinned (e.g. switched to openai-agents to use
|
|
298
|
-
// OpenRouter models).
|
|
299
|
-
const be = resolveChatBackend(chatId, gateway?.backend);
|
|
300
|
-
const beId = getBackendIdForChat(chatId);
|
|
301
|
-
|
|
302
|
-
// In the provider view the select carries chips, not models — picking
|
|
303
|
-
// one drills into that provider rather than choosing anything.
|
|
304
|
-
if (value?.startsWith("provider:") && be?.models?.getSettingsPresentation) {
|
|
305
|
-
const providerId = value.slice("provider:".length);
|
|
306
|
-
const { model: activeNow } = await resolveActiveModelForChat(
|
|
307
|
-
chatId,
|
|
308
|
-
be,
|
|
309
|
-
beId,
|
|
310
|
-
config,
|
|
311
|
-
);
|
|
312
|
-
const pres = await be.models.getSettingsPresentation(activeNow ?? "", {
|
|
313
|
-
callbackPrefix: "model:",
|
|
314
|
-
navCallbackPrefix: MODEL_NAV_PREFIX,
|
|
315
|
-
pageSize: MODEL_PAGE_SIZE,
|
|
316
|
-
provider: providerId,
|
|
317
|
-
});
|
|
318
|
-
const view = buildModelPickerView(
|
|
319
|
-
pres,
|
|
320
|
-
activeNow ?? "_No model selected_",
|
|
321
|
-
beId,
|
|
322
|
-
);
|
|
323
|
-
try {
|
|
324
|
-
await interaction.update({
|
|
325
|
-
content: view.content,
|
|
326
|
-
components: view.components,
|
|
327
|
-
});
|
|
328
|
-
} catch {
|
|
329
|
-
/* ignore */
|
|
330
|
-
}
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
if (value === "reset") {
|
|
335
|
-
// Clear THIS backend's slot only — other backends stay intact.
|
|
336
|
-
setChatModelForBackend(chatId, beId, undefined);
|
|
337
|
-
} else if (be?.models?.resolveModelInfo) {
|
|
338
|
-
const resolution = await be.models?.resolveModelInfo(value);
|
|
339
|
-
if (resolution.kind === "exact" && resolution.model.selectable) {
|
|
340
|
-
setChatModelForBackend(chatId, beId, resolution.storedValue);
|
|
341
|
-
setChatBackend(chatId, beId);
|
|
342
|
-
} else {
|
|
343
|
-
await interaction.reply({
|
|
344
|
-
content: "Model unavailable.",
|
|
345
|
-
flags: MessageFlags.Ephemeral,
|
|
346
|
-
});
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
} else {
|
|
350
|
-
setChatModelForBackend(chatId, beId, resolveModelName(value));
|
|
351
|
-
setChatBackend(chatId, beId);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Resolve display through the active-model helper so the panel
|
|
355
|
-
// matches what queries will actually run. Null surfaces as
|
|
356
|
-
// "No model selected".
|
|
357
|
-
const { model: resolvedCurrent } = await resolveActiveModelForChat(
|
|
358
|
-
chatId,
|
|
359
|
-
be,
|
|
360
|
-
beId,
|
|
361
|
-
config,
|
|
362
|
-
);
|
|
363
|
-
if (be?.models?.getSettingsPresentation) {
|
|
364
|
-
const current = resolvedCurrent ?? "";
|
|
365
|
-
const pres = await be.models?.getSettingsPresentation(current, {
|
|
366
|
-
callbackPrefix: "model:",
|
|
367
|
-
navCallbackPrefix: MODEL_NAV_PREFIX,
|
|
368
|
-
pageSize: MODEL_PAGE_SIZE,
|
|
369
|
-
});
|
|
370
|
-
const modelInfo = resolvedCurrent
|
|
371
|
-
? await be.models?.getRawModelInfo?.(resolvedCurrent)
|
|
372
|
-
: undefined;
|
|
373
|
-
const displayName =
|
|
374
|
-
modelInfo?.displayName ?? resolvedCurrent ?? "_No model selected_";
|
|
375
|
-
const view = buildModelPickerView(pres, displayName, beId);
|
|
376
|
-
try {
|
|
377
|
-
await interaction.update({
|
|
378
|
-
content: view.content,
|
|
379
|
-
components: view.components,
|
|
380
|
-
});
|
|
381
|
-
} catch {
|
|
382
|
-
/* ignore */
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// ── /model pager — page arrows, free-tier toggle, provider drill ────────
|
|
389
|
-
// Pure navigation: re-render the same panel at the requested page without
|
|
390
|
-
// touching the chat's selected model.
|
|
391
|
-
if (customId.startsWith(`${MODEL_NAV_PREFIX}:`)) {
|
|
392
|
-
// Backend submenu — the one nav action that isn't a re-page.
|
|
393
|
-
if (customId === `${MODEL_NAV_PREFIX}:backends`) {
|
|
394
|
-
const view = buildBackendPickerView(
|
|
395
|
-
listAvailableBackends(config),
|
|
396
|
-
getBackendIdForChat(chatId),
|
|
397
|
-
);
|
|
398
|
-
try {
|
|
399
|
-
await interaction.update({
|
|
400
|
-
content: view.content,
|
|
401
|
-
components: view.components,
|
|
402
|
-
});
|
|
403
|
-
} catch {
|
|
404
|
-
/* ignore */
|
|
405
|
-
}
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const target = decodeModelNav(customId);
|
|
410
|
-
if (!target) {
|
|
411
|
-
try {
|
|
412
|
-
await interaction.deferUpdate();
|
|
413
|
-
} catch {
|
|
414
|
-
/* ignore */
|
|
415
|
-
}
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
const be = resolveChatBackend(chatId, gateway?.backend);
|
|
419
|
-
const beId = getBackendIdForChat(chatId);
|
|
420
|
-
const { model: current } = await resolveActiveModelForChat(
|
|
421
|
-
chatId,
|
|
422
|
-
be,
|
|
423
|
-
beId,
|
|
424
|
-
config,
|
|
425
|
-
);
|
|
426
|
-
if (!be?.models?.getSettingsPresentation) {
|
|
427
|
-
try {
|
|
428
|
-
await interaction.deferUpdate();
|
|
429
|
-
} catch {
|
|
430
|
-
/* ignore */
|
|
431
|
-
}
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
const pres = await be.models.getSettingsPresentation(current ?? "", {
|
|
435
|
-
callbackPrefix: "model:",
|
|
436
|
-
navCallbackPrefix: MODEL_NAV_PREFIX,
|
|
437
|
-
pageSize: MODEL_PAGE_SIZE,
|
|
438
|
-
page: target.page,
|
|
439
|
-
filter: target.filter,
|
|
440
|
-
...(target.provider ? { provider: target.provider } : {}),
|
|
441
|
-
});
|
|
442
|
-
const modelInfo = current
|
|
443
|
-
? await be.models?.getRawModelInfo?.(current)
|
|
444
|
-
: undefined;
|
|
445
|
-
const view = buildModelPickerView(
|
|
446
|
-
pres,
|
|
447
|
-
modelInfo?.displayName ?? current ?? "_No model selected_",
|
|
448
|
-
beId,
|
|
449
|
-
);
|
|
450
|
-
try {
|
|
451
|
-
await interaction.update({
|
|
452
|
-
content: view.content,
|
|
453
|
-
components: view.components,
|
|
454
|
-
});
|
|
455
|
-
} catch {
|
|
456
|
-
/* ignore */
|
|
457
|
-
}
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// ── /model backend switch ───────────────────────────────────────────────
|
|
462
|
-
// Rebinding drops this chat's session (it isn't portable across backends)
|
|
463
|
-
// but keeps each backend's remembered model pick, so switching back
|
|
464
|
-
// restores what was selected there before.
|
|
465
|
-
if (customId === "model:backend-select" && interaction.isStringSelectMenu()) {
|
|
466
|
-
const backendId = interaction.values[0] ?? "";
|
|
467
|
-
const available = listAvailableBackends(config);
|
|
468
|
-
if (!available.some((b) => b.id === backendId)) {
|
|
469
|
-
await interaction.reply({
|
|
470
|
-
content: "Backend not available.",
|
|
471
|
-
flags: MessageFlags.Ephemeral,
|
|
472
|
-
});
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Booting a cold backend takes seconds — Kilo and OpenCode spawn a server
|
|
477
|
-
// and sweep MCP registrations — and Discord drops an interaction that
|
|
478
|
-
// isn't acknowledged within three. Ack first, edit the panel after.
|
|
479
|
-
try {
|
|
480
|
-
await interaction.deferUpdate();
|
|
481
|
-
} catch {
|
|
482
|
-
/* already acknowledged */
|
|
483
|
-
}
|
|
484
|
-
const fail = async (text: string): Promise<void> => {
|
|
485
|
-
try {
|
|
486
|
-
await interaction.followUp({
|
|
487
|
-
content: text,
|
|
488
|
-
flags: MessageFlags.Ephemeral,
|
|
489
|
-
});
|
|
490
|
-
} catch {
|
|
491
|
-
/* ignore */
|
|
492
|
-
}
|
|
493
|
-
};
|
|
494
|
-
|
|
495
|
-
// Resolve the outgoing backend before rebinding — afterwards the chat
|
|
496
|
-
// already points at the new one and the old instance is unreachable.
|
|
497
|
-
const previousId = getBackendIdForChat(chatId);
|
|
498
|
-
const previous = resolveChatBackend(chatId, gateway?.backend);
|
|
499
|
-
const alreadyThere = previousId === backendId;
|
|
500
|
-
const result = alreadyThere
|
|
501
|
-
? { ok: true as const }
|
|
502
|
-
: await rebindChat(chatId, backendId, config);
|
|
503
|
-
if (!result.ok) {
|
|
504
|
-
await fail(result.error?.slice(0, 200) ?? "Rebind failed.");
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
setChatBackend(chatId, backendId);
|
|
508
|
-
|
|
509
|
-
// The pool accepts a backend whose SDK is installed, but the SDK may only
|
|
510
|
-
// be a client for a CLI that isn't (OpenCode and Kilo spawn `opencode` /
|
|
511
|
-
// `kilo`). That surfaces here, on the first real call. Reading the
|
|
512
|
-
// catalog is the cheapest way to find out, and it is needed for the panel
|
|
513
|
-
// anyway — so treat a throw as "this backend can't serve" and put the
|
|
514
|
-
// chat back where it was rather than stranding it somewhere unusable.
|
|
515
|
-
let next = resolveChatBackend(chatId, gateway?.backend);
|
|
516
|
-
let newModel: string | undefined;
|
|
517
|
-
let pres;
|
|
518
|
-
try {
|
|
519
|
-
newModel =
|
|
520
|
-
(await resolveActiveModelForChat(chatId, next, backendId, config))
|
|
521
|
-
.model ?? undefined;
|
|
522
|
-
if (next?.models?.getSettingsPresentation) {
|
|
523
|
-
pres = await next.models.getSettingsPresentation(newModel ?? "", {
|
|
524
|
-
callbackPrefix: "model:",
|
|
525
|
-
navCallbackPrefix: MODEL_NAV_PREFIX,
|
|
526
|
-
pageSize: MODEL_PAGE_SIZE,
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
} catch (err) {
|
|
530
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
531
|
-
logError("discord", `Backend switch to ${backendId} failed`, err);
|
|
532
|
-
if (!alreadyThere) {
|
|
533
|
-
await rebindChat(chatId, previousId, config).catch(() => undefined);
|
|
534
|
-
setChatBackend(chatId, previousId);
|
|
535
|
-
}
|
|
536
|
-
await fail(
|
|
537
|
-
`⚠️ \`${backendId}\` could not start — staying on \`${previousId}\`.\n` +
|
|
538
|
-
`\`\`\`\n${safeSlice(reason, 300)}\n\`\`\``,
|
|
539
|
-
);
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// Only now is the switch known to hold. Session state doesn't port across
|
|
544
|
-
// backends, so it goes — but each backend's remembered model pick stays.
|
|
545
|
-
// A re-pick of the backend already in use clears nothing: the retry after
|
|
546
|
-
// a timed-out interaction must not cost the session a second time.
|
|
547
|
-
if (!alreadyThere) {
|
|
548
|
-
resetSession(chatId);
|
|
549
|
-
clearHistory(chatId);
|
|
550
|
-
resetPulseCheckpoint(chatId);
|
|
551
|
-
previous?.sessions?.resetChat?.(chatId);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
next = resolveChatBackend(chatId, gateway?.backend);
|
|
555
|
-
// Warming is fire-and-forget: it can take seconds on OpenCode/Kilo and
|
|
556
|
-
// the interaction still has a panel to redraw.
|
|
557
|
-
if (next && next !== previous) {
|
|
558
|
-
void Promise.resolve(next.sessions?.warmSession?.(chatId)).catch(
|
|
559
|
-
() => undefined,
|
|
560
|
-
);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
try {
|
|
564
|
-
await interaction.editReply(
|
|
565
|
-
pres
|
|
566
|
-
? {
|
|
567
|
-
...buildModelPickerView(
|
|
568
|
-
pres,
|
|
569
|
-
newModel ?? "_No model selected_",
|
|
570
|
-
backendId,
|
|
571
|
-
),
|
|
572
|
-
}
|
|
573
|
-
: {
|
|
574
|
-
content: `**Backend:** \`${backendId}\` · model \`${newModel ?? "none"}\``,
|
|
575
|
-
components: [],
|
|
576
|
-
},
|
|
577
|
-
);
|
|
578
|
-
} catch {
|
|
579
|
-
/* ignore */
|
|
580
|
-
}
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// ── /metrics today ↔ all-time toggle ────────────────────────────────────
|
|
585
|
-
if (customId === "metrics:today" || customId === "metrics:all") {
|
|
586
|
-
const view = customId === "metrics:all" ? "all" : "today";
|
|
587
|
-
const messages = renderMetricsView(view);
|
|
588
|
-
try {
|
|
589
|
-
await interaction.update({
|
|
590
|
-
content: messages[0]!,
|
|
591
|
-
components: [metricsViewRow(view).toJSON()],
|
|
592
|
-
});
|
|
593
|
-
} catch {
|
|
594
|
-
/* ignore */
|
|
595
|
-
}
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// ── AI-generated buttons (prefixed `ai:`) → forward to agent ──────────
|
|
600
|
-
if (customId.startsWith("ai:")) {
|
|
601
|
-
await forwardToAgent(interaction, gateway);
|
|
602
|
-
return;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// Unknown custom_id — log + silently ack so Discord doesn't show
|
|
606
|
-
// "Interaction failed". This catches stale buttons from older panels.
|
|
607
|
-
logError("discord", `Unknown custom_id: ${customId}`);
|
|
608
|
-
try {
|
|
609
|
-
await interaction.deferUpdate();
|
|
610
|
-
} catch {
|
|
611
|
-
/* ignore */
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
async function refreshSettingsPanel(
|
|
616
|
-
interaction: ComponentInteraction,
|
|
617
|
-
config: TalonConfig,
|
|
618
|
-
gateway: Gateway,
|
|
619
|
-
chatId: string,
|
|
620
|
-
): Promise<void> {
|
|
621
|
-
const chatSets = getChatSettings(chatId);
|
|
622
|
-
const settingsBe = resolveChatBackend(chatId, gateway?.backend);
|
|
623
|
-
const settingsBeId = getBackendIdForChat(chatId);
|
|
624
|
-
// Match the Telegram path — resolve via the 5-step chain so the
|
|
625
|
-
// panel's "Model:" line stays coherent on backend-overridden
|
|
626
|
-
// chats and surfaces "No model selected" when applicable.
|
|
627
|
-
const { model: resolvedActive } = await resolveActiveModelForChat(
|
|
628
|
-
chatId,
|
|
629
|
-
settingsBe,
|
|
630
|
-
settingsBeId,
|
|
631
|
-
config,
|
|
632
|
-
);
|
|
633
|
-
const activeModel = resolvedActive ?? "No model selected";
|
|
634
|
-
const pulseOn = isPulseEnabled(chatId);
|
|
635
|
-
const reasoning = await getActiveReasoningLevels({
|
|
636
|
-
chatId,
|
|
637
|
-
backend: settingsBe,
|
|
638
|
-
backendId: settingsBeId,
|
|
639
|
-
config,
|
|
640
|
-
});
|
|
641
|
-
const effortName = displayReasoningEffort(chatSets.effort, reasoning.levels);
|
|
642
|
-
|
|
643
|
-
let modelDetails: Array<string> | undefined;
|
|
644
|
-
let modelButtons: Array<{ text: string; callback_data: string }> | undefined;
|
|
645
|
-
// `settingsBe` already resolved above for the activeModel lookup —
|
|
646
|
-
// reuse it instead of re-resolving (it points at the per-chat
|
|
647
|
-
// backend, override-aware).
|
|
648
|
-
if (settingsBe?.models?.getSettingsPresentation) {
|
|
649
|
-
const presModelId = resolvedActive ?? "";
|
|
650
|
-
const pres = await settingsBe.models?.getSettingsPresentation(presModelId);
|
|
651
|
-
modelDetails = pres.modelDetails;
|
|
652
|
-
modelButtons = pres.modelButtons;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
const components: ActionRowBuilder<
|
|
656
|
-
StringSelectMenuBuilder | ButtonBuilder
|
|
657
|
-
>[] = [];
|
|
658
|
-
|
|
659
|
-
if (modelButtons?.length) {
|
|
660
|
-
const menu = new StringSelectMenuBuilder()
|
|
661
|
-
.setCustomId("settings:model")
|
|
662
|
-
.setPlaceholder("Select a model")
|
|
663
|
-
.addOptions(
|
|
664
|
-
modelButtons.slice(0, 25).map((b) => ({
|
|
665
|
-
label: safeSlice(b.text.replace(/^✓ /, ""), 100),
|
|
666
|
-
value: safeSlice(
|
|
667
|
-
b.callback_data.replace(/^settings:model:/, ""),
|
|
668
|
-
100,
|
|
669
|
-
),
|
|
670
|
-
default: b.text.startsWith("✓"),
|
|
671
|
-
})),
|
|
672
|
-
);
|
|
673
|
-
components.push(
|
|
674
|
-
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(menu),
|
|
675
|
-
);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
if (reasoning.levels.length > 0) {
|
|
679
|
-
const effortMenu = new StringSelectMenuBuilder()
|
|
680
|
-
.setCustomId("settings:effort:select")
|
|
681
|
-
.setPlaceholder(`Effort: ${effortName}`)
|
|
682
|
-
.addOptions(
|
|
683
|
-
[...reasoning.levels, "adaptive"].map((v) => ({
|
|
684
|
-
label: v,
|
|
685
|
-
value: v,
|
|
686
|
-
description: EFFORT_DESCRIPTIONS[v],
|
|
687
|
-
default: effortName === v,
|
|
688
|
-
})),
|
|
689
|
-
);
|
|
690
|
-
components.push(
|
|
691
|
-
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(effortMenu),
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
components.push(
|
|
696
|
-
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
697
|
-
new ButtonBuilder()
|
|
698
|
-
.setCustomId(`settings:proactive:${pulseOn ? "off" : "on"}`)
|
|
699
|
-
.setLabel(pulseOn ? "Pulse: ON" : "Pulse: OFF")
|
|
700
|
-
.setStyle(pulseOn ? ButtonStyle.Success : ButtonStyle.Secondary),
|
|
701
|
-
new ButtonBuilder()
|
|
702
|
-
.setCustomId("settings:done")
|
|
703
|
-
.setLabel("Done")
|
|
704
|
-
.setStyle(ButtonStyle.Secondary),
|
|
705
|
-
),
|
|
706
|
-
);
|
|
707
|
-
|
|
708
|
-
try {
|
|
709
|
-
await interaction.update({
|
|
710
|
-
content: renderSettingsText(
|
|
711
|
-
activeModel,
|
|
712
|
-
effortName,
|
|
713
|
-
pulseOn,
|
|
714
|
-
chatSets.pulseIntervalMs,
|
|
715
|
-
modelDetails,
|
|
716
|
-
),
|
|
717
|
-
components,
|
|
718
|
-
});
|
|
719
|
-
} catch (err) {
|
|
720
|
-
logError(
|
|
721
|
-
"discord",
|
|
722
|
-
`Failed to refresh settings panel: ${err instanceof Error ? err.message : err}`,
|
|
723
|
-
);
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
async function forwardToAgent(
|
|
728
|
-
interaction: ComponentInteraction,
|
|
729
|
-
_gateway: Gateway,
|
|
730
|
-
): Promise<void> {
|
|
731
|
-
// Acknowledge so Discord doesn't show "interaction failed"
|
|
732
|
-
try {
|
|
733
|
-
await interaction.deferUpdate();
|
|
734
|
-
} catch {
|
|
735
|
-
/* might already be acked */
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
const { chatId, numericChatId } = chatIdFromInteraction(interaction);
|
|
739
|
-
const sender =
|
|
740
|
-
interaction.member?.user?.username ?? interaction.user.username ?? "User";
|
|
741
|
-
const isGroup = interaction.guildId !== null;
|
|
742
|
-
// Strip the `ai:` prefix so the agent sees the original callback_data it
|
|
743
|
-
// emitted in send_message_with_buttons, not our routing namespace.
|
|
744
|
-
const rawId = interaction.customId.startsWith("ai:")
|
|
745
|
-
? interaction.customId.slice(3)
|
|
746
|
-
: interaction.customId;
|
|
747
|
-
const prompt = `[Button pressed] User clicked component with custom_id: "${rawId}"`;
|
|
748
|
-
const chatTitle = isGroup
|
|
749
|
-
? `${interaction.guild?.name ?? interaction.guildId} #${(interaction.channel as { name?: string } | null)?.name ?? interaction.channelId}`
|
|
750
|
-
: undefined;
|
|
751
|
-
|
|
752
|
-
appendDailyLog(sender, `Button: ${rawId}`, { chatTitle });
|
|
753
|
-
|
|
754
|
-
const onTextBlock = async (text: string) => {
|
|
755
|
-
if (!text.trim()) return;
|
|
756
|
-
if (!interaction.channel?.isSendable()) return;
|
|
757
|
-
const chunks = splitMessage(suppressMentions(text), DISCORD_MAX_TEXT);
|
|
758
|
-
for (const c of chunks) {
|
|
759
|
-
try {
|
|
760
|
-
await interaction.channel.send({
|
|
761
|
-
content: c,
|
|
762
|
-
allowedMentions: { parse: [] },
|
|
763
|
-
});
|
|
764
|
-
} catch (err) {
|
|
765
|
-
logError(
|
|
766
|
-
"discord",
|
|
767
|
-
`forwardToAgent send failed: ${err instanceof Error ? err.message : err}`,
|
|
768
|
-
);
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
};
|
|
772
|
-
|
|
773
|
-
try {
|
|
774
|
-
await execute({
|
|
775
|
-
chatId,
|
|
776
|
-
numericChatId,
|
|
777
|
-
prompt,
|
|
778
|
-
senderName: sender,
|
|
779
|
-
isGroup,
|
|
780
|
-
source: "message",
|
|
781
|
-
onEvent: async (event) => {
|
|
782
|
-
if (event.type === "assistant_message") {
|
|
783
|
-
await onTextBlock(event.text);
|
|
784
|
-
}
|
|
785
|
-
},
|
|
786
|
-
});
|
|
787
|
-
} catch (err) {
|
|
788
|
-
logError(
|
|
789
|
-
"discord",
|
|
790
|
-
`Component → agent forward failed: ${err instanceof Error ? err.message : err}`,
|
|
791
|
-
);
|
|
792
|
-
}
|
|
793
|
-
}
|