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.
Files changed (75) hide show
  1. package/package.json +6 -5
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/stream.ts +76 -53
  4. package/src/backend/remote-server/turn.ts +58 -55
  5. package/src/bootstrap.ts +120 -79
  6. package/src/cli/setup.ts +375 -349
  7. package/src/core/background/cron-spec.ts +273 -0
  8. package/src/core/background/heartbeat/agent.ts +167 -104
  9. package/src/core/engine/backend-controller/pool.ts +2 -0
  10. package/src/core/engine/backend-controller/state.ts +25 -12
  11. package/src/core/engine/gateway-actions/cron.ts +28 -292
  12. package/src/core/engine/gateway-routes.ts +239 -0
  13. package/src/core/engine/gateway.ts +66 -238
  14. package/src/core/vfs/mounts/files.ts +128 -115
  15. package/src/core/weaver/shuttle.ts +29 -2
  16. package/src/core/weaver/weaver.ts +37 -6
  17. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  18. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  19. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  20. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  21. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  22. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  23. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  24. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  25. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  26. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  27. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  28. package/src/frontend/discord/callbacks/index.ts +4 -4
  29. package/src/frontend/discord/connection.ts +36 -0
  30. package/src/frontend/discord/diagnostics.ts +62 -0
  31. package/src/frontend/discord/guild-policy.ts +89 -0
  32. package/src/frontend/discord/index.ts +44 -305
  33. package/src/frontend/discord/outbound.ts +61 -0
  34. package/src/frontend/discord/ready.ts +73 -0
  35. package/src/frontend/discord/runtime.ts +55 -0
  36. package/src/frontend/native/chat-lifecycle.ts +39 -0
  37. package/src/frontend/native/chat-wire.ts +69 -0
  38. package/src/frontend/native/context.ts +106 -0
  39. package/src/frontend/native/control.ts +78 -0
  40. package/src/frontend/native/emit.ts +167 -0
  41. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  42. package/src/frontend/native/handlers.ts +121 -0
  43. package/src/frontend/native/history.ts +101 -0
  44. package/src/frontend/native/index.ts +90 -1293
  45. package/src/frontend/native/media.ts +43 -0
  46. package/src/frontend/native/models.ts +221 -0
  47. package/src/frontend/native/queue.ts +47 -0
  48. package/src/frontend/native/reset.ts +50 -0
  49. package/src/frontend/native/routes/chats.ts +115 -0
  50. package/src/frontend/native/routes/daemon.ts +70 -0
  51. package/src/frontend/native/routes/host.ts +151 -0
  52. package/src/frontend/native/routes/index.ts +22 -0
  53. package/src/frontend/native/routes/mesh.ts +72 -0
  54. package/src/frontend/native/routes/models.ts +54 -0
  55. package/src/frontend/native/routes/params.ts +29 -0
  56. package/src/frontend/native/routes/pre-auth.ts +94 -0
  57. package/src/frontend/native/routes/table.ts +92 -0
  58. package/src/frontend/native/runtime.ts +109 -0
  59. package/src/frontend/native/server.ts +30 -555
  60. package/src/frontend/native/status.ts +26 -0
  61. package/src/frontend/native/tool-result.ts +48 -0
  62. package/src/frontend/native/turn.ts +341 -0
  63. package/src/frontend/whatsapp/access.ts +67 -0
  64. package/src/frontend/whatsapp/connection.ts +280 -0
  65. package/src/frontend/whatsapp/inbound.ts +327 -0
  66. package/src/frontend/whatsapp/index.ts +28 -599
  67. package/src/frontend/whatsapp/runtime.ts +74 -0
  68. package/src/plugins/github/provision.ts +1 -1
  69. package/src/storage/metrics.ts +18 -0
  70. package/src/storage/session-record.ts +29 -0
  71. package/src/storage/sessions.ts +24 -0
  72. package/src/util/boot-timer.ts +31 -0
  73. package/src/util/concurrency.ts +28 -0
  74. package/src/frontend/discord/callbacks/components.ts +0 -793
  75. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -24,9 +24,11 @@ import type { ContextManager, ExecuteParams, ExecuteResult } from "../types.js";
24
24
  import { bus } from "../bus/index.js";
25
25
  import { taskTable, type TaskHandle } from "../tasks/index.js";
26
26
  import { log, logDebug, logWarn } from "../../util/log.js";
27
+ import { recordSessionTurnPhases } from "../../storage/sessions.js";
28
+ import type { TurnPhase } from "../../storage/session-record.js";
27
29
  import { TalonError } from "../errors.js";
28
30
  import { Loom } from "./loom.js";
29
- import { carryTurnEvents } from "./shuttle.js";
31
+ import { carryTurnEvents, startShuttleTiming } from "./shuttle.js";
30
32
  import type { Thread, ThreadSnapshot } from "./thread.js";
31
33
  import { startTypingLoop } from "./typing-loop.js";
32
34
  import { resolveWarp } from "./warp-resolver.js";
@@ -72,7 +74,7 @@ export class Weaver {
72
74
  // interrupt the same chat's currently running one.
73
75
  const chat = this.deps.getBackend(params.chatId).chat;
74
76
  const interrupt = chat?.interruptChatTurn?.bind(chat);
75
- const lifecycle = { started: false, killed: false };
77
+ const lifecycle = { started: false, killed: false, enqueuedAt: Date.now() };
76
78
  // Registered before enqueueing so a turn waiting in its chat's FIFO is
77
79
  // visible as `queued` in the task table, not invisible until it runs.
78
80
  const task = taskTable.enqueue({
@@ -109,7 +111,7 @@ export class Weaver {
109
111
  thread: Thread,
110
112
  params: ExecuteParams,
111
113
  task: TaskHandle,
112
- lifecycle: { started: boolean; killed: boolean },
114
+ lifecycle: { started: boolean; killed: boolean; enqueuedAt: number },
113
115
  ): Promise<ExecuteResult> {
114
116
  if (lifecycle.killed) {
115
117
  // Killed while queued — the turn never reaches the backend. The
@@ -122,7 +124,9 @@ export class Weaver {
122
124
  this.activeCount++;
123
125
  task.start();
124
126
  try {
125
- const result = await this.executeInner(thread, params, task);
127
+ const result = await this.executeInner(thread, params, task, {
128
+ queueWait: Date.now() - lifecycle.enqueuedAt,
129
+ });
126
130
  const usage = {
127
131
  inputTokens: result.inputTokens,
128
132
  outputTokens: result.outputTokens,
@@ -162,17 +166,20 @@ export class Weaver {
162
166
  thread: Thread,
163
167
  params: ExecuteParams,
164
168
  task: TaskHandle,
169
+ phases: Partial<Record<TurnPhase, number>>,
165
170
  ): Promise<ExecuteResult> {
166
171
  const { context } = this.deps;
167
172
  const backend = this.deps.getBackend(params.chatId);
168
173
  const reqId = randomBytes(4).toString("hex");
169
174
 
175
+ const warpStartedAt = Date.now();
170
176
  const warp = await resolveWarp(this.deps, {
171
177
  chatId: params.chatId,
172
178
  modelOverride: params.modelOverride,
173
179
  source: params.source,
174
180
  reqId,
175
181
  });
182
+ phases.warpResolve = Date.now() - warpStartedAt;
176
183
  if (!warp.ok) {
177
184
  // Refusals are delivered through the same event sink the backend
178
185
  // would use for output (as an `assistant_message` event, so the
@@ -246,7 +253,15 @@ export class Weaver {
246
253
  isGroup: params.isGroup,
247
254
  messageId: params.messageId,
248
255
  });
249
- const agentResult = await carryTurnEvents(stream, params.onEvent);
256
+ const timing = startShuttleTiming();
257
+ const streamStartedAt = Date.now();
258
+ const agentResult = await carryTurnEvents(stream, params.onEvent, timing);
259
+ phases.stream = Date.now() - streamStartedAt;
260
+ phases.delivery = timing.deliveryMs;
261
+ if (timing.firstEventAt !== undefined) {
262
+ phases.firstToken = timing.firstEventAt - streamStartedAt;
263
+ }
264
+ recordSessionTurnPhases(params.chatId, phases);
250
265
 
251
266
  // Completion signal — pulse (and any other liveness subscriber) keys
252
267
  // off this. Failures throw past it; refusals never get this far.
@@ -261,7 +276,8 @@ export class Weaver {
261
276
  logDebug(
262
277
  "dispatcher",
263
278
  `[${reqId}] completed in ${agentResult?.durationMs ?? 0}ms ` +
264
- `(in=${agentResult?.usage.inputTokens ?? 0} out=${agentResult?.usage.outputTokens ?? 0})`,
279
+ `(in=${agentResult?.usage.inputTokens ?? 0} out=${agentResult?.usage.outputTokens ?? 0}) ` +
280
+ formatPhases(phases),
265
281
  );
266
282
 
267
283
  return this.toExecuteResult(agentResult, params);
@@ -305,6 +321,21 @@ export class Weaver {
305
321
  }
306
322
  }
307
323
 
324
+ /** `queue=12ms warp=3ms ttft=1840ms stream=6200ms delivery=310ms` */
325
+ function formatPhases(phases: Partial<Record<TurnPhase, number>>): string {
326
+ const labels: Record<TurnPhase, string> = {
327
+ queueWait: "queue",
328
+ warpResolve: "warp",
329
+ firstToken: "ttft",
330
+ stream: "stream",
331
+ delivery: "delivery",
332
+ };
333
+ return (Object.keys(labels) as TurnPhase[])
334
+ .filter((phase) => phases[phase] !== undefined)
335
+ .map((phase) => `${labels[phase]}=${phases[phase]}ms`)
336
+ .join(" ");
337
+ }
338
+
308
339
  let weaver: Weaver | null = null;
309
340
 
310
341
  export function initWeaver(deps: WeaverDeps): Weaver {
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `ai:*` — AI-generated buttons (send_message_with_buttons), forwarded to
3
+ * the agent verbatim with the routing prefix stripped.
4
+ */
5
+
6
+ import { execute } from "../../../../core/engine/dispatcher.js";
7
+ import { appendDailyLog } from "../../../../storage/daily-log.js";
8
+ import { logError } from "../../../../util/log.js";
9
+ import {
10
+ suppressMentions,
11
+ splitMessage,
12
+ DISCORD_MAX_TEXT,
13
+ } from "../../formatting.js";
14
+ import type { ComponentContext, ComponentInteraction } from "./types.js";
15
+
16
+ export async function forwardToAgent(
17
+ interaction: ComponentInteraction,
18
+ { chatId, numericChatId }: ComponentContext,
19
+ ): Promise<boolean> {
20
+ // Acknowledge so Discord doesn't show "interaction failed"
21
+ try {
22
+ await interaction.deferUpdate();
23
+ } catch {
24
+ /* might already be acked */
25
+ }
26
+
27
+ const sender =
28
+ interaction.member?.user?.username ?? interaction.user.username ?? "User";
29
+ const isGroup = interaction.guildId !== null;
30
+ // Strip the `ai:` prefix so the agent sees the original callback_data it
31
+ // emitted in send_message_with_buttons, not our routing namespace.
32
+ const rawId = interaction.customId.startsWith("ai:")
33
+ ? interaction.customId.slice(3)
34
+ : interaction.customId;
35
+ const prompt = `[Button pressed] User clicked component with custom_id: "${rawId}"`;
36
+ const chatTitle = isGroup
37
+ ? `${interaction.guild?.name ?? interaction.guildId} #${(interaction.channel as { name?: string } | null)?.name ?? interaction.channelId}`
38
+ : undefined;
39
+
40
+ appendDailyLog(sender, `Button: ${rawId}`, { chatTitle });
41
+
42
+ const onTextBlock = async (text: string) => {
43
+ if (!text.trim()) return;
44
+ if (!interaction.channel?.isSendable()) return;
45
+ const chunks = splitMessage(suppressMentions(text), DISCORD_MAX_TEXT);
46
+ for (const c of chunks) {
47
+ try {
48
+ await interaction.channel.send({
49
+ content: c,
50
+ allowedMentions: { parse: [] },
51
+ });
52
+ } catch (err) {
53
+ logError(
54
+ "discord",
55
+ `forwardToAgent send failed: ${err instanceof Error ? err.message : err}`,
56
+ );
57
+ }
58
+ }
59
+ };
60
+
61
+ try {
62
+ await execute({
63
+ chatId,
64
+ numericChatId,
65
+ prompt,
66
+ senderName: sender,
67
+ isGroup,
68
+ source: "message",
69
+ onEvent: async (event) => {
70
+ if (event.type === "assistant_message") {
71
+ await onTextBlock(event.text);
72
+ }
73
+ },
74
+ });
75
+ } catch (err) {
76
+ logError(
77
+ "discord",
78
+ `Component → agent forward failed: ${err instanceof Error ? err.message : err}`,
79
+ );
80
+ }
81
+ return true;
82
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `model:backend-select` — the /model backend switch.
3
+ *
4
+ * Rebinding drops this chat's session (it isn't portable across backends)
5
+ * but keeps each backend's remembered model pick, so switching back
6
+ * restores what was selected there before.
7
+ */
8
+
9
+ import { type StringSelectMenuInteraction, MessageFlags } from "discord.js";
10
+ import {
11
+ buildModelPickerView,
12
+ MODEL_NAV_PREFIX,
13
+ MODEL_PAGE_SIZE,
14
+ } from "../../model-picker.js";
15
+ import { setChatBackend } from "../../../../storage/chat-settings.js";
16
+ import {
17
+ getBackendIdForChat,
18
+ resolveChatBackend,
19
+ listAvailableBackends,
20
+ rebindChat,
21
+ } from "../../../../core/engine/backend-controller/index.js";
22
+ import { resetSession } from "../../../../storage/sessions.js";
23
+ import { clearHistory } from "../../../../storage/history.js";
24
+ import { resetPulseCheckpoint } from "../../../../core/background/pulse.js";
25
+ import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
26
+ import { logError } from "../../../../util/log.js";
27
+ import { safeSlice } from "../../formatting.js";
28
+ import type { ComponentContext } from "./types.js";
29
+
30
+ export async function handleBackendSelect(
31
+ interaction: StringSelectMenuInteraction,
32
+ { config, gateway, chatId }: ComponentContext,
33
+ ): Promise<void> {
34
+ const backendId = interaction.values[0] ?? "";
35
+ const available = listAvailableBackends(config);
36
+ if (!available.some((b) => b.id === backendId)) {
37
+ await interaction.reply({
38
+ content: "Backend not available.",
39
+ flags: MessageFlags.Ephemeral,
40
+ });
41
+ return;
42
+ }
43
+
44
+ // Booting a cold backend takes seconds — Kilo and OpenCode spawn a server
45
+ // and sweep MCP registrations — and Discord drops an interaction that
46
+ // isn't acknowledged within three. Ack first, edit the panel after.
47
+ try {
48
+ await interaction.deferUpdate();
49
+ } catch {
50
+ /* already acknowledged */
51
+ }
52
+ const fail = async (text: string): Promise<void> => {
53
+ try {
54
+ await interaction.followUp({
55
+ content: text,
56
+ flags: MessageFlags.Ephemeral,
57
+ });
58
+ } catch {
59
+ /* ignore */
60
+ }
61
+ };
62
+
63
+ // Resolve the outgoing backend before rebinding — afterwards the chat
64
+ // already points at the new one and the old instance is unreachable.
65
+ const previousId = getBackendIdForChat(chatId);
66
+ const previous = resolveChatBackend(chatId, gateway?.backend);
67
+ const alreadyThere = previousId === backendId;
68
+ const result = alreadyThere
69
+ ? { ok: true as const }
70
+ : await rebindChat(chatId, backendId, config);
71
+ if (!result.ok) {
72
+ await fail(result.error?.slice(0, 200) ?? "Rebind failed.");
73
+ return;
74
+ }
75
+ setChatBackend(chatId, backendId);
76
+
77
+ // The pool accepts a backend whose SDK is installed, but the SDK may only
78
+ // be a client for a CLI that isn't (OpenCode and Kilo spawn `opencode` /
79
+ // `kilo`). That surfaces here, on the first real call. Reading the
80
+ // catalog is the cheapest way to find out, and it is needed for the panel
81
+ // anyway — so treat a throw as "this backend can't serve" and put the
82
+ // chat back where it was rather than stranding it somewhere unusable.
83
+ let next = resolveChatBackend(chatId, gateway?.backend);
84
+ let newModel: string | undefined;
85
+ let pres;
86
+ try {
87
+ newModel =
88
+ (await resolveActiveModelForChat(chatId, next, backendId, config))
89
+ .model ?? undefined;
90
+ if (next?.models?.getSettingsPresentation) {
91
+ pres = await next.models.getSettingsPresentation(newModel ?? "", {
92
+ callbackPrefix: "model:",
93
+ navCallbackPrefix: MODEL_NAV_PREFIX,
94
+ pageSize: MODEL_PAGE_SIZE,
95
+ });
96
+ }
97
+ } catch (err) {
98
+ const reason = err instanceof Error ? err.message : String(err);
99
+ logError("discord", `Backend switch to ${backendId} failed`, err);
100
+ if (!alreadyThere) {
101
+ await rebindChat(chatId, previousId, config).catch(() => undefined);
102
+ setChatBackend(chatId, previousId);
103
+ }
104
+ await fail(
105
+ `⚠️ \`${backendId}\` could not start — staying on \`${previousId}\`.\n` +
106
+ `\`\`\`\n${safeSlice(reason, 300)}\n\`\`\``,
107
+ );
108
+ return;
109
+ }
110
+
111
+ // Only now is the switch known to hold. Session state doesn't port across
112
+ // backends, so it goes — but each backend's remembered model pick stays.
113
+ // A re-pick of the backend already in use clears nothing: the retry after
114
+ // a timed-out interaction must not cost the session a second time.
115
+ if (!alreadyThere) {
116
+ resetSession(chatId);
117
+ clearHistory(chatId);
118
+ resetPulseCheckpoint(chatId);
119
+ previous?.sessions?.resetChat?.(chatId);
120
+ }
121
+
122
+ next = resolveChatBackend(chatId, gateway?.backend);
123
+ // Warming is fire-and-forget: it can take seconds on OpenCode/Kilo and
124
+ // the interaction still has a panel to redraw.
125
+ if (next && next !== previous) {
126
+ void Promise.resolve(next.sessions?.warmSession?.(chatId)).catch(
127
+ () => undefined,
128
+ );
129
+ }
130
+
131
+ try {
132
+ await interaction.editReply(
133
+ pres
134
+ ? {
135
+ ...buildModelPickerView(
136
+ pres,
137
+ newModel ?? "_No model selected_",
138
+ backendId,
139
+ ),
140
+ }
141
+ : {
142
+ content: `**Backend:** \`${backendId}\` · model \`${newModel ?? "none"}\``,
143
+ components: [],
144
+ },
145
+ );
146
+ } catch {
147
+ /* ignore */
148
+ }
149
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `effort:select` — the standalone /effort select menu.
3
+ */
4
+
5
+ import type { StringSelectMenuInteraction } from "discord.js";
6
+ import {
7
+ setChatEffort,
8
+ type EffortLevel,
9
+ } from "../../../../storage/chat-settings.js";
10
+ import {
11
+ getBackendIdForChat,
12
+ resolveChatBackend,
13
+ } from "../../../../core/engine/backend-controller/index.js";
14
+ import {
15
+ getActiveReasoningLevels,
16
+ supportsReasoningLevel,
17
+ } from "../../../shared/reasoning-levels.js";
18
+ import type { ComponentContext, ComponentInteraction } from "./types.js";
19
+
20
+ async function selectEffort(
21
+ interaction: StringSelectMenuInteraction,
22
+ { config, gateway, chatId }: ComponentContext,
23
+ ): Promise<void> {
24
+ const level = interaction.values[0];
25
+ const be = resolveChatBackend(chatId, gateway?.backend);
26
+ const beId = getBackendIdForChat(chatId);
27
+ const reasoning = await getActiveReasoningLevels({
28
+ chatId,
29
+ backend: be,
30
+ backendId: beId,
31
+ config,
32
+ });
33
+ if (reasoning.levels.length === 0) {
34
+ await interaction.update({
35
+ content: "No valid reasoning levels found for this model.",
36
+ components: [],
37
+ });
38
+ return;
39
+ }
40
+ if (level === "adaptive") setChatEffort(chatId, undefined);
41
+ else if (supportsReasoningLevel(level, reasoning.levels))
42
+ setChatEffort(chatId, level as EffortLevel);
43
+ else {
44
+ await interaction.update({
45
+ content: "Invalid reasoning level for this model.",
46
+ components: [],
47
+ });
48
+ return;
49
+ }
50
+ try {
51
+ await interaction.update({
52
+ content: `**Effort:** ${level}`,
53
+ components: [],
54
+ });
55
+ } catch {
56
+ /* ignore */
57
+ }
58
+ }
59
+
60
+ export async function handleEffortComponent(
61
+ interaction: ComponentInteraction,
62
+ context: ComponentContext,
63
+ ): Promise<boolean> {
64
+ if (
65
+ interaction.customId === "effort:select" &&
66
+ interaction.isStringSelectMenu()
67
+ ) {
68
+ await selectEffort(interaction, context);
69
+ return true;
70
+ }
71
+ return false;
72
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Component (button + select menu) interaction router.
3
+ *
4
+ * Custom ids are namespaced by a `:`-terminated prefix; the table below maps
5
+ * each prefix to the module that owns it. A handler returns `false` for an
6
+ * id under its prefix it doesn't recognise (a stale button from an older
7
+ * panel, a select-menu id arriving from a button), and the router acks
8
+ * those exactly as it acks a prefix with no handler.
9
+ *
10
+ * Custom-id namespace:
11
+ * settings:done
12
+ * settings:model (select menu, value=modelId)
13
+ * settings:effort:select (select menu)
14
+ * settings:proactive:on|off
15
+ * pulse:on | pulse:off | pulse:interval (button → opens modal)
16
+ * effort:select (select menu, standalone /effort)
17
+ * model:select (select menu, standalone /model)
18
+ * model:nav:* (pager)
19
+ * model:backend-select (select menu)
20
+ * metrics:today | metrics:all
21
+ * ai:<id> (AI-generated buttons — forwarded to agent)
22
+ */
23
+
24
+ import { MessageFlags } from "discord.js";
25
+ import type { TalonConfig } from "../../../../util/config.js";
26
+ import type { Gateway } from "../../../../core/engine/gateway.js";
27
+ import { deriveNumericChatId } from "../../../../util/chat-id.js";
28
+ import { logError } from "../../../../util/log.js";
29
+ import {
30
+ isInteractionAllowed,
31
+ registerDiscordChat,
32
+ } from "../../handlers/index.js";
33
+ import { forwardToAgent } from "./agent-buttons.js";
34
+ import { handleEffortComponent } from "./effort.js";
35
+ import { handleMetricsComponent } from "./metrics.js";
36
+ import { handleModelComponent } from "./model.js";
37
+ import { handlePulseComponent } from "./pulse.js";
38
+ import { handleSettingsComponent } from "./settings.js";
39
+ import type { ComponentHandlers, ComponentInteraction } from "./types.js";
40
+
41
+ // Null-prototype so a custom id of "toString:" / "constructor:" / etc.
42
+ // can't resolve an inherited Object.prototype method via `handlers[key]`.
43
+ export const COMPONENT_HANDLERS: ComponentHandlers = Object.assign(
44
+ Object.create(null),
45
+ {
46
+ "settings:": handleSettingsComponent,
47
+ "pulse:": handlePulseComponent,
48
+ "effort:": handleEffortComponent,
49
+ "model:": handleModelComponent,
50
+ "metrics:": handleMetricsComponent,
51
+ "ai:": forwardToAgent,
52
+ } satisfies ComponentHandlers,
53
+ );
54
+
55
+ /**
56
+ * The table key for a custom id: its first segment, colon included
57
+ * (`"settings:done"` → `"settings:"`). An id with no colon keys to `""`,
58
+ * which no handler claims.
59
+ */
60
+ export function componentRouteKey(customId: string): string {
61
+ return customId.slice(0, customId.indexOf(":") + 1);
62
+ }
63
+
64
+ function chatIdFromInteraction(interaction: ComponentInteraction): {
65
+ chatId: string;
66
+ numericChatId: number;
67
+ } {
68
+ const chatId = interaction.guildId
69
+ ? `discord_guild_${interaction.guildId}_${interaction.channelId}`
70
+ : `discord_dm_${interaction.user.id}`;
71
+ return { chatId, numericChatId: deriveNumericChatId(chatId) };
72
+ }
73
+
74
+ export async function handleComponentInteraction(
75
+ interaction: ComponentInteraction,
76
+ config: TalonConfig,
77
+ gateway: Gateway,
78
+ ): Promise<void> {
79
+ // Access control
80
+ const access = isInteractionAllowed(
81
+ interaction.inGuild(),
82
+ interaction.user.id,
83
+ interaction.guildId,
84
+ interaction.channelId,
85
+ );
86
+ if (!access.ok) {
87
+ await interaction.reply({
88
+ content: `⚠️ ${access.reason}`,
89
+ flags: MessageFlags.Ephemeral,
90
+ });
91
+ return;
92
+ }
93
+
94
+ const { chatId, numericChatId } = chatIdFromInteraction(interaction);
95
+ registerDiscordChat({
96
+ channelId: interaction.channelId!,
97
+ guildId: interaction.guildId,
98
+ userId: interaction.guildId ? null : interaction.user.id,
99
+ numericChatId,
100
+ chatId,
101
+ });
102
+
103
+ const customId = interaction.customId;
104
+ const handler = COMPONENT_HANDLERS[componentRouteKey(customId)];
105
+ if (
106
+ handler &&
107
+ (await handler(interaction, { config, gateway, chatId, numericChatId }))
108
+ ) {
109
+ return;
110
+ }
111
+
112
+ // Unknown custom_id — log + silently ack so Discord doesn't show
113
+ // "Interaction failed". This catches stale buttons from older panels.
114
+ logError("discord", `Unknown custom_id: ${customId}`);
115
+ try {
116
+ await interaction.deferUpdate();
117
+ } catch {
118
+ /* ignore */
119
+ }
120
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `metrics:today` | `metrics:all` — the /metrics today ↔ all-time toggle.
3
+ */
4
+
5
+ import { metricsViewRow, renderMetricsView } from "../../commands/admin.js";
6
+ import type { ComponentInteraction } from "./types.js";
7
+
8
+ export async function handleMetricsComponent(
9
+ interaction: ComponentInteraction,
10
+ ): Promise<boolean> {
11
+ const customId = interaction.customId;
12
+ if (customId !== "metrics:today" && customId !== "metrics:all") return false;
13
+ const view = customId === "metrics:all" ? "all" : "today";
14
+ const messages = renderMetricsView(view);
15
+ try {
16
+ await interaction.update({
17
+ content: messages[0]!,
18
+ components: [metricsViewRow(view).toJSON()],
19
+ });
20
+ } catch {
21
+ /* ignore */
22
+ }
23
+ return true;
24
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `model:nav:*` — the /model pager: page arrows, free-tier toggle, provider
3
+ * drill, and the backend submenu. Pure navigation: re-renders the same
4
+ * panel at the requested page without touching the chat's selected model.
5
+ */
6
+
7
+ import {
8
+ buildModelPickerView,
9
+ buildBackendPickerView,
10
+ decodeModelNav,
11
+ MODEL_NAV_PREFIX,
12
+ MODEL_PAGE_SIZE,
13
+ } from "../../model-picker.js";
14
+ import {
15
+ getBackendIdForChat,
16
+ resolveChatBackend,
17
+ listAvailableBackends,
18
+ } from "../../../../core/engine/backend-controller/index.js";
19
+ import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
20
+ import type { ComponentContext, ComponentInteraction } from "./types.js";
21
+
22
+ export async function handleModelNav(
23
+ interaction: ComponentInteraction,
24
+ { config, gateway, chatId }: ComponentContext,
25
+ ): Promise<void> {
26
+ const customId = interaction.customId;
27
+ // Backend submenu — the one nav action that isn't a re-page.
28
+ if (customId === `${MODEL_NAV_PREFIX}:backends`) {
29
+ const view = buildBackendPickerView(
30
+ listAvailableBackends(config),
31
+ getBackendIdForChat(chatId),
32
+ );
33
+ try {
34
+ await interaction.update({
35
+ content: view.content,
36
+ components: view.components,
37
+ });
38
+ } catch {
39
+ /* ignore */
40
+ }
41
+ return;
42
+ }
43
+
44
+ const target = decodeModelNav(customId);
45
+ if (!target) {
46
+ try {
47
+ await interaction.deferUpdate();
48
+ } catch {
49
+ /* ignore */
50
+ }
51
+ return;
52
+ }
53
+ const be = resolveChatBackend(chatId, gateway?.backend);
54
+ const beId = getBackendIdForChat(chatId);
55
+ const { model: current } = await resolveActiveModelForChat(
56
+ chatId,
57
+ be,
58
+ beId,
59
+ config,
60
+ );
61
+ if (!be?.models?.getSettingsPresentation) {
62
+ try {
63
+ await interaction.deferUpdate();
64
+ } catch {
65
+ /* ignore */
66
+ }
67
+ return;
68
+ }
69
+ const pres = await be.models.getSettingsPresentation(current ?? "", {
70
+ callbackPrefix: "model:",
71
+ navCallbackPrefix: MODEL_NAV_PREFIX,
72
+ pageSize: MODEL_PAGE_SIZE,
73
+ page: target.page,
74
+ filter: target.filter,
75
+ ...(target.provider ? { provider: target.provider } : {}),
76
+ });
77
+ const modelInfo = current
78
+ ? await be.models?.getRawModelInfo?.(current)
79
+ : undefined;
80
+ const view = buildModelPickerView(
81
+ pres,
82
+ modelInfo?.displayName ?? current ?? "_No model selected_",
83
+ beId,
84
+ );
85
+ try {
86
+ await interaction.update({
87
+ content: view.content,
88
+ components: view.components,
89
+ });
90
+ } catch {
91
+ /* ignore */
92
+ }
93
+ }