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
|
@@ -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
|
|
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
|
+
}
|