talon-agent 3.16.1 → 3.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/app.ts +2 -0
- package/src/backend/codex/factory.ts +9 -0
- package/src/backend/codex/plan-usage.ts +160 -0
- package/src/backend/kilo/factory.ts +2 -19
- package/src/backend/opencode/factory.ts +2 -15
- package/src/backend/remote-server/model-catalog/presentation.ts +102 -25
- package/src/backend/remote-server/model-catalog/provider.ts +10 -6
- package/src/bootstrap.ts +14 -0
- package/src/cli/doctor.ts +11 -1
- package/src/core/background/plan-alerts.ts +115 -0
- package/src/core/doctor.ts +71 -8
- package/src/frontend/discord/callbacks/components.ts +260 -16
- package/src/frontend/discord/commands/admin.ts +188 -14
- package/src/frontend/discord/commands/definitions.ts +37 -2
- package/src/frontend/discord/commands/info.ts +38 -1
- package/src/frontend/discord/commands/router.ts +21 -1
- package/src/frontend/discord/commands/settings.ts +10 -18
- package/src/frontend/discord/helpers.ts +148 -1
- package/src/frontend/discord/index.ts +10 -1
- package/src/frontend/discord/middleware.ts +24 -0
- package/src/frontend/discord/model-picker.ts +234 -0
- package/src/frontend/shared/plan-usage-report.ts +72 -0
- package/src/frontend/telegram/commands/admin.ts +10 -0
- package/src/frontend/telegram/commands/definitions.ts +1 -0
- package/src/frontend/telegram/helpers/diagnostics.ts +38 -4
- package/src/util/config.ts +10 -0
package/src/core/doctor.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface DoctorCheck {
|
|
|
29
29
|
* starts but a backend won't work.
|
|
30
30
|
*/
|
|
31
31
|
issue?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* A backend that is configured but not serving chats. Renderers group
|
|
34
|
+
* these away from the environment so a handful of idle providers can't
|
|
35
|
+
* bury the checks that describe the running deployment.
|
|
36
|
+
*/
|
|
37
|
+
inactive?: boolean;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
/** One embedded native module: provenance plus a live self-test result. */
|
|
@@ -59,6 +65,8 @@ export interface DoctorReport {
|
|
|
59
65
|
export interface DoctorConfigSlice {
|
|
60
66
|
frontend: string | string[];
|
|
61
67
|
backend?: string;
|
|
68
|
+
/** Backends config exposes. Empty / absent means every registered one. */
|
|
69
|
+
enabledBackends?: string[];
|
|
62
70
|
model?: string;
|
|
63
71
|
heartbeatModel?: string;
|
|
64
72
|
heartbeatBackend?: string;
|
|
@@ -284,12 +292,55 @@ async function checkClaudeConfiguredModels(
|
|
|
284
292
|
return checks;
|
|
285
293
|
}
|
|
286
294
|
|
|
287
|
-
/**
|
|
295
|
+
/** Every backend id doctor knows how to inspect. */
|
|
296
|
+
const KNOWN_BACKENDS = [
|
|
297
|
+
"claude",
|
|
298
|
+
"codex",
|
|
299
|
+
"kilo",
|
|
300
|
+
"opencode",
|
|
301
|
+
"openai-agents",
|
|
302
|
+
] as const;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Binary / auth checks across every backend the config exposes.
|
|
306
|
+
*
|
|
307
|
+
* Only the active one counts toward the issue total; the rest are reported
|
|
308
|
+
* so a switch doesn't have to be the thing that discovers a backend can't
|
|
309
|
+
* run. That distinction matters now that a chat can be rebound at runtime —
|
|
310
|
+
* a report of all-green while two backends are one click from failing is
|
|
311
|
+
* worse than no report.
|
|
312
|
+
*/
|
|
288
313
|
async function checkBackend(
|
|
289
314
|
config: DoctorConfigSlice | undefined,
|
|
315
|
+
): Promise<DoctorCheck[]> {
|
|
316
|
+
const active = config?.backend ?? "claude";
|
|
317
|
+
const exposed = config?.enabledBackends?.length
|
|
318
|
+
? config.enabledBackends
|
|
319
|
+
: [...KNOWN_BACKENDS];
|
|
320
|
+
|
|
321
|
+
const checks = await checkOneBackend(active, config, true);
|
|
322
|
+
for (const id of exposed) {
|
|
323
|
+
if (id === active || !KNOWN_BACKENDS.includes(id as never)) continue;
|
|
324
|
+
// An idle backend's missing binary is a heads-up, not a fault of this
|
|
325
|
+
// deployment: downgrade it and keep it out of the issue count.
|
|
326
|
+
for (const check of await checkOneBackend(id, config, false)) {
|
|
327
|
+
checks.push({
|
|
328
|
+
...check,
|
|
329
|
+
status: check.status === "fail" ? "warn" : check.status,
|
|
330
|
+
issue: false,
|
|
331
|
+
inactive: true,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return checks;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function checkOneBackend(
|
|
339
|
+
backend: string,
|
|
340
|
+
config: DoctorConfigSlice | undefined,
|
|
341
|
+
isActive: boolean,
|
|
290
342
|
): Promise<DoctorCheck[]> {
|
|
291
343
|
const checks: DoctorCheck[] = [];
|
|
292
|
-
const backend = config?.backend ?? "claude";
|
|
293
344
|
|
|
294
345
|
if (backend === "claude") {
|
|
295
346
|
if (config?.claudeBinary) {
|
|
@@ -313,7 +364,9 @@ async function checkBackend(
|
|
|
313
364
|
: { label: "Claude Code not found", status: "fail" },
|
|
314
365
|
);
|
|
315
366
|
}
|
|
316
|
-
|
|
367
|
+
// Model resolution spawns a probe — worth it for the backend actually
|
|
368
|
+
// serving chats, wasteful for one nobody is using.
|
|
369
|
+
if (isActive) checks.push(...(await checkClaudeConfiguredModels(config)));
|
|
317
370
|
} else if (backend === "codex") {
|
|
318
371
|
if (!binaryOnPath("codex")) {
|
|
319
372
|
checks.push({
|
|
@@ -349,11 +402,21 @@ async function checkBackend(
|
|
|
349
402
|
});
|
|
350
403
|
}
|
|
351
404
|
} else if (backend === "kilo" || backend === "opencode") {
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
405
|
+
// The SDK ships as an npm dep but only talks to a server it spawns from
|
|
406
|
+
// the CLI of the same name (`cross-spawn` → PATH lookup). A present
|
|
407
|
+
// package with an absent binary fails at the first turn with a bare
|
|
408
|
+
// ENOENT, so check what actually gets executed.
|
|
409
|
+
const label = backend === "kilo" ? "Kilo" : "OpenCode";
|
|
410
|
+
checks.push(
|
|
411
|
+
binaryOnPath(backend)
|
|
412
|
+
? { label: `${label} CLI installed`, status: "ok" }
|
|
413
|
+
: {
|
|
414
|
+
label: `${label} CLI not found`,
|
|
415
|
+
status: "fail",
|
|
416
|
+
detail: `the ${label} SDK spawns \`${backend}\` — install it or this backend cannot start`,
|
|
417
|
+
issue: true,
|
|
418
|
+
},
|
|
419
|
+
);
|
|
357
420
|
} else if (backend === "openai-agents") {
|
|
358
421
|
checks.push({ label: "OpenAI Agents SDK bundled", status: "ok" });
|
|
359
422
|
const hasEnvKey = Boolean(process.env.OPENAI_API_KEY);
|
|
@@ -29,6 +29,14 @@ import {
|
|
|
29
29
|
} from "discord.js";
|
|
30
30
|
import type { TalonConfig } from "../../../util/config.js";
|
|
31
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";
|
|
32
40
|
import {
|
|
33
41
|
getChatSettings,
|
|
34
42
|
setChatModelForBackend,
|
|
@@ -52,7 +60,12 @@ import { execute } from "../../../core/engine/dispatcher.js";
|
|
|
52
60
|
import {
|
|
53
61
|
getBackendIdForChat,
|
|
54
62
|
resolveChatBackend,
|
|
63
|
+
listAvailableBackends,
|
|
64
|
+
rebindChat,
|
|
55
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";
|
|
56
69
|
import { resolveActiveModelForChat } from "../../../core/models/active-model.js";
|
|
57
70
|
import {
|
|
58
71
|
displayReasoningEffort,
|
|
@@ -286,6 +299,38 @@ export async function handleComponentInteraction(
|
|
|
286
299
|
const be = resolveChatBackend(chatId, gateway?.backend);
|
|
287
300
|
const beId = getBackendIdForChat(chatId);
|
|
288
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
|
+
|
|
289
334
|
if (value === "reset") {
|
|
290
335
|
// Clear THIS backend's slot only — other backends stay intact.
|
|
291
336
|
setChatModelForBackend(chatId, beId, undefined);
|
|
@@ -319,35 +364,234 @@ export async function handleComponentInteraction(
|
|
|
319
364
|
const current = resolvedCurrent ?? "";
|
|
320
365
|
const pres = await be.models?.getSettingsPresentation(current, {
|
|
321
366
|
callbackPrefix: "model:",
|
|
367
|
+
navCallbackPrefix: MODEL_NAV_PREFIX,
|
|
368
|
+
pageSize: MODEL_PAGE_SIZE,
|
|
322
369
|
});
|
|
323
370
|
const modelInfo = resolvedCurrent
|
|
324
371
|
? await be.models?.getRawModelInfo?.(resolvedCurrent)
|
|
325
372
|
: undefined;
|
|
326
373
|
const displayName =
|
|
327
374
|
modelInfo?.displayName ?? resolvedCurrent ?? "_No model selected_";
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
.
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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),
|
|
340
397
|
);
|
|
341
398
|
try {
|
|
342
399
|
await interaction.update({
|
|
343
|
-
content:
|
|
344
|
-
|
|
345
|
-
),
|
|
346
|
-
components: [row],
|
|
400
|
+
content: view.content,
|
|
401
|
+
components: view.components,
|
|
347
402
|
});
|
|
348
403
|
} catch {
|
|
349
404
|
/* ignore */
|
|
350
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 */
|
|
351
595
|
}
|
|
352
596
|
return;
|
|
353
597
|
}
|
|
@@ -3,16 +3,37 @@
|
|
|
3
3
|
* All gate on `isAdmin`.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type ChatInputCommandInteraction,
|
|
8
|
+
ActionRowBuilder,
|
|
9
|
+
ButtonBuilder,
|
|
10
|
+
ButtonStyle,
|
|
11
|
+
MessageFlags,
|
|
12
|
+
} from "discord.js";
|
|
7
13
|
import type { TalonConfig } from "../../../util/config.js";
|
|
8
14
|
import type { Gateway } from "../../../core/engine/gateway.js";
|
|
9
15
|
import { respawnSelf } from "../../../util/respawn.js";
|
|
10
16
|
import { forceDream } from "../../../core/background/dream.js";
|
|
11
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
formatDuration,
|
|
19
|
+
renderMetricsMessages,
|
|
20
|
+
renderDoctorMessages,
|
|
21
|
+
} from "../helpers.js";
|
|
22
|
+
import { collectDoctorReport } from "../../../core/doctor.js";
|
|
23
|
+
import { getSoul } from "../../../core/soul/service.js";
|
|
12
24
|
import { getMetrics, getTodayMetrics } from "../../../storage/metrics.js";
|
|
13
25
|
import { handleAdminSubcommand } from "../admin.js";
|
|
14
26
|
import { isAdmin } from "../handlers/index.js";
|
|
15
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
suppressMentions,
|
|
29
|
+
DISCORD_MAX_TEXT,
|
|
30
|
+
safeSlice,
|
|
31
|
+
escapeForCodeBlock,
|
|
32
|
+
} from "../formatting.js";
|
|
33
|
+
import {
|
|
34
|
+
getRepoRoot,
|
|
35
|
+
runSelfUpdate,
|
|
36
|
+
} from "../../../core/update/self-update.js";
|
|
16
37
|
import { reply } from "./shared.js";
|
|
17
38
|
|
|
18
39
|
export async function handleRestart(
|
|
@@ -34,18 +55,112 @@ export async function handleMetrics(
|
|
|
34
55
|
return;
|
|
35
56
|
}
|
|
36
57
|
// Ephemeral — admin counters (token usage, latencies, errors) shouldn't leak
|
|
37
|
-
// into a public channel where non-admins can read them.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
// into a public channel where non-admins can read them. Both views are one
|
|
59
|
+
// button apart rather than two bursts of messages.
|
|
60
|
+
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
61
|
+
const messages = renderMetricsMessages(
|
|
62
|
+
getTodayMetrics(),
|
|
63
|
+
undefined,
|
|
64
|
+
"📊 Metrics — today (UTC)",
|
|
65
|
+
);
|
|
66
|
+
await i.editReply({
|
|
67
|
+
content: messages[0]!,
|
|
68
|
+
components: [metricsViewRow("today").toJSON()],
|
|
69
|
+
});
|
|
70
|
+
for (const extra of messages.slice(1)) {
|
|
71
|
+
await i.followUp({ content: extra, flags: MessageFlags.Ephemeral });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Today / all-time toggle under the metrics panel. */
|
|
76
|
+
export function metricsViewRow(
|
|
77
|
+
view: MetricsView,
|
|
78
|
+
): ActionRowBuilder<ButtonBuilder> {
|
|
79
|
+
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
80
|
+
new ButtonBuilder()
|
|
81
|
+
.setCustomId("metrics:today")
|
|
82
|
+
.setLabel("Today")
|
|
83
|
+
.setStyle(view === "today" ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
|
84
|
+
.setDisabled(view === "today"),
|
|
85
|
+
new ButtonBuilder()
|
|
86
|
+
.setCustomId("metrics:all")
|
|
87
|
+
.setLabel("All time")
|
|
88
|
+
.setStyle(view === "all" ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
|
89
|
+
.setDisabled(view === "all"),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type MetricsView = "today" | "all";
|
|
94
|
+
|
|
95
|
+
/** Panel body for one view — shared by the command and the toggle. */
|
|
96
|
+
export function renderMetricsView(view: MetricsView): string[] {
|
|
97
|
+
return view === "today"
|
|
98
|
+
? renderMetricsMessages(
|
|
99
|
+
getTodayMetrics(),
|
|
100
|
+
undefined,
|
|
101
|
+
"📊 Metrics — today (UTC)",
|
|
102
|
+
)
|
|
103
|
+
: renderMetricsMessages(getMetrics(), undefined, "📊 Metrics — all time");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function handleDoctor(
|
|
107
|
+
i: ChatInputCommandInteraction,
|
|
108
|
+
config: TalonConfig,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
if (!isAdmin(i.user.id)) {
|
|
111
|
+
await reply(i, "Not authorized.", true);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
115
|
+
await i.editReply("🩺 Running checks...");
|
|
116
|
+
try {
|
|
117
|
+
// Same checks as `talon doctor` — config exists by definition when the
|
|
118
|
+
// bot is processing this command.
|
|
119
|
+
const report = await collectDoctorReport({ config, hasConfigFile: true });
|
|
120
|
+
const messages = renderDoctorMessages(report);
|
|
121
|
+
await i.editReply(messages[0]!);
|
|
122
|
+
for (const extra of messages.slice(1))
|
|
123
|
+
await i.followUp({
|
|
124
|
+
content: extra,
|
|
125
|
+
flags: MessageFlags.Ephemeral,
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
129
|
+
await i.editReply(`🩺 Doctor failed: ${msg}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* /soul — read-only introspection of the compiled identity. `action:dream`
|
|
135
|
+
* runs the organic maintenance pass and is admin-only. Inert while the soul
|
|
136
|
+
* is disabled, so it is safe to ship dormant.
|
|
137
|
+
*/
|
|
138
|
+
export async function handleSoul(
|
|
139
|
+
i: ChatInputCommandInteraction,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const soul = getSoul();
|
|
142
|
+
if (!soul.enabled) {
|
|
143
|
+
await reply(
|
|
144
|
+
i,
|
|
145
|
+
"Soul is disabled (set TALON_SOUL_ENABLED to enable).",
|
|
146
|
+
true,
|
|
147
|
+
);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (i.options.getString("action") === "dream") {
|
|
151
|
+
if (!isAdmin(i.user.id)) {
|
|
152
|
+
await reply(i, "Not authorized.", true);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
156
|
+
await i.editReply("🧠 Soul dreaming...");
|
|
157
|
+
soul
|
|
158
|
+
.dream()
|
|
159
|
+
.then(() => i.editReply("🧠 Soul dream complete."))
|
|
160
|
+
.catch(() => undefined);
|
|
161
|
+
return;
|
|
48
162
|
}
|
|
163
|
+
await reply(i, suppressMentions(soul.introspect()), true);
|
|
49
164
|
}
|
|
50
165
|
|
|
51
166
|
export async function handleDream(
|
|
@@ -71,6 +186,65 @@ export async function handleDream(
|
|
|
71
186
|
});
|
|
72
187
|
}
|
|
73
188
|
|
|
189
|
+
/**
|
|
190
|
+
* /update — pull, reinstall, run setup, restart. Only reachable on developer
|
|
191
|
+
* builds running from a git checkout; the command is not registered at all
|
|
192
|
+
* otherwise (see buildCommandDefinitions).
|
|
193
|
+
*/
|
|
194
|
+
export async function handleUpdate(
|
|
195
|
+
i: ChatInputCommandInteraction,
|
|
196
|
+
config: TalonConfig,
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
if (!isAdmin(i.user.id)) {
|
|
199
|
+
await reply(i, "Not authorized.", true);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const repoRoot = config.devBuild ? getRepoRoot() : null;
|
|
203
|
+
if (!repoRoot) {
|
|
204
|
+
await reply(i, "Update is only available on developer builds.", true);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const remote = config.update?.remote ?? "origin";
|
|
208
|
+
const branch = config.update?.branch ?? "main";
|
|
209
|
+
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
210
|
+
await i.editReply(`⏳ Updating from \`${remote}/${branch}\`…`);
|
|
211
|
+
const edit = (text: string) => i.editReply(safeSlice(text, DISCORD_MAX_TEXT));
|
|
212
|
+
|
|
213
|
+
// Fire-and-forget so the gateway keeps processing other interactions.
|
|
214
|
+
runSelfUpdate({
|
|
215
|
+
remote,
|
|
216
|
+
branch,
|
|
217
|
+
setup: config.update?.setup,
|
|
218
|
+
repoRoot,
|
|
219
|
+
})
|
|
220
|
+
.then(async (res) => {
|
|
221
|
+
if (!res.ok) {
|
|
222
|
+
const tail = res.steps[res.steps.length - 1]?.output ?? "";
|
|
223
|
+
await edit(
|
|
224
|
+
`⚠️ Update failed: ${res.error ?? "unknown error"}` +
|
|
225
|
+
(tail
|
|
226
|
+
? `\n\`\`\`\n${escapeForCodeBlock(tail.slice(-1200))}\n\`\`\``
|
|
227
|
+
: ""),
|
|
228
|
+
);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!res.changed) {
|
|
232
|
+
await edit(
|
|
233
|
+
`✅ Already up to date at \`${res.before ?? "?"}\` — no restart needed.`,
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
await edit(
|
|
238
|
+
`✅ Updated \`${res.before ?? "?"}\` → \`${res.after ?? "?"}\`. ♻️ Restarting…`,
|
|
239
|
+
);
|
|
240
|
+
respawnSelf("discord /update");
|
|
241
|
+
})
|
|
242
|
+
.catch(async (err: unknown) => {
|
|
243
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
244
|
+
await edit(`⚠️ Update crashed: ${msg}`);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
74
248
|
export async function handleAdmin(
|
|
75
249
|
i: ChatInputCommandInteraction,
|
|
76
250
|
config: TalonConfig,
|