talon-agent 5.25.0 → 5.26.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 (66) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/backend/agy/one-shot.ts +34 -3
  4. package/src/backend/codex/auth.ts +31 -2
  5. package/src/backend/codex/constants.ts +21 -7
  6. package/src/backend/codex/discovery.ts +32 -1
  7. package/src/backend/codex/factory.ts +3 -5
  8. package/src/backend/codex/handler/message.ts +7 -7
  9. package/src/backend/codex/models.ts +53 -11
  10. package/src/backend/codex/one-shot.ts +139 -37
  11. package/src/backend/codex/state.ts +11 -0
  12. package/src/core/agents/abort-reason.ts +46 -0
  13. package/src/core/agents/registry.ts +3 -2
  14. package/src/core/background/dream/index.ts +2 -2
  15. package/src/core/background/heartbeat/agent.ts +1 -1
  16. package/src/core/background/isolated-agent.ts +1 -1
  17. package/src/core/backup/index.ts +1 -0
  18. package/src/core/backup/status.ts +30 -1
  19. package/src/core/config/index.ts +8 -0
  20. package/src/core/engine/gateway.ts +5 -0
  21. package/src/core/mesh/credentials/store.ts +60 -9
  22. package/src/core/tools/bridge.ts +58 -4
  23. package/src/frontend/discord/callbacks/components/effort.ts +4 -4
  24. package/src/frontend/discord/callbacks/components/index.ts +2 -0
  25. package/src/frontend/discord/commands/backup-panel.ts +219 -0
  26. package/src/frontend/discord/commands/backup.ts +25 -34
  27. package/src/frontend/discord/commands/info.ts +17 -5
  28. package/src/frontend/discord/commands/settings.ts +12 -9
  29. package/src/frontend/discord/render.ts +1 -1
  30. package/src/frontend/native/bridge/credentials/claims.ts +5 -5
  31. package/src/frontend/native/bridge/routes/chats.ts +40 -20
  32. package/src/frontend/native/bridge/routes/host.ts +15 -2
  33. package/src/frontend/native/bridge/routes/table.ts +4 -0
  34. package/src/frontend/native/commands/admin.ts +64 -0
  35. package/src/frontend/native/commands/backup.ts +191 -0
  36. package/src/frontend/native/commands/definitions.ts +113 -0
  37. package/src/frontend/native/commands/format.ts +24 -0
  38. package/src/frontend/native/commands/index.ts +106 -0
  39. package/src/frontend/native/commands/info.ts +97 -0
  40. package/src/frontend/native/commands/session.ts +130 -0
  41. package/src/frontend/native/commands/types.ts +26 -0
  42. package/src/frontend/native/protocol.ts +18 -0
  43. package/src/frontend/native/surface/handlers.ts +14 -1
  44. package/src/frontend/native/surface/status.ts +9 -1
  45. package/src/frontend/native/turn/emit.ts +32 -1
  46. package/src/frontend/presentation/backup-panel.ts +425 -0
  47. package/src/frontend/presentation/memory-report.ts +109 -0
  48. package/src/frontend/presentation/text-commands.ts +243 -0
  49. package/src/frontend/telegram/admin/sessions.ts +20 -5
  50. package/src/frontend/telegram/admin.ts +9 -2
  51. package/src/frontend/telegram/callbacks/backup.ts +153 -22
  52. package/src/frontend/telegram/callbacks/effort.ts +5 -5
  53. package/src/frontend/telegram/callbacks/index.ts +2 -2
  54. package/src/frontend/telegram/callbacks/settings.ts +3 -40
  55. package/src/frontend/telegram/commands/admin.ts +8 -7
  56. package/src/frontend/telegram/commands/backup.ts +56 -46
  57. package/src/frontend/telegram/commands/index.ts +3 -2
  58. package/src/frontend/telegram/commands/info.ts +45 -26
  59. package/src/frontend/telegram/commands/memory.ts +8 -92
  60. package/src/frontend/telegram/commands/settings.ts +13 -45
  61. package/src/frontend/telegram/commands/whatsapp-pairing.ts +19 -15
  62. package/src/frontend/telegram/render/backup-panel.ts +47 -0
  63. package/src/frontend/telegram/render/menu.ts +21 -30
  64. package/src/frontend/terminal/builtins/model.ts +20 -11
  65. package/src/frontend/whatsapp/commands.ts +16 -216
  66. package/src/frontend/whatsapp/messages/inbound.ts +33 -2
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Markdown replies for the text-only command layers — `/model`, `/effort`,
3
+ * `/settings`, `/status`.
4
+ *
5
+ * WhatsApp and the native bridge have no inline keyboards, so where
6
+ * Telegram and Discord open a picker they print a listing and take a typed
7
+ * argument instead: `/model` prints a numbered catalog, `/model 3` (or
8
+ * `/model <id>`, `/model <backend>`) picks from it. The state changes come
9
+ * from model-commands.ts and session-status.ts; this module renders them
10
+ * as Markdown and routes the typed argument, so the two frontends share
11
+ * one wording and one parser. Each frontend translates the Markdown into
12
+ * its own dialect on the way out.
13
+ */
14
+
15
+ import { getChatSettings } from "../../storage/chat-settings.js";
16
+ import { isPulseEnabled } from "../../core/background/pulse/pulse.js";
17
+ import {
18
+ DEFAULT_PULSE_INTERVAL_MS,
19
+ formatBytes,
20
+ formatDuration,
21
+ formatModelLabel,
22
+ formatTokenCount,
23
+ formatUsd,
24
+ } from "./format.js";
25
+ import {
26
+ collectSessionStatus,
27
+ type SessionStatusData,
28
+ } from "./session-status.js";
29
+ import { formatCacheTempLine, formatDaemonLine } from "./status-context.js";
30
+ import {
31
+ describeChatEffort,
32
+ describeChatModels,
33
+ matchBackendArg,
34
+ resetChatBackend,
35
+ resetChatModel,
36
+ resolveChatBackendPair,
37
+ selectChatModel,
38
+ setChatEffortLevel,
39
+ switchChatBackend,
40
+ type ModelCommandDeps,
41
+ type ModelOverview,
42
+ } from "./model-commands.js";
43
+
44
+ /** How many catalog entries `/model` prints before pointing at `/model <id>`. */
45
+ const MAX_LISTED_MODELS = 40;
46
+
47
+ // ── Renderers ───────────────────────────────────────────────────────────────
48
+
49
+ function renderModelOverview(view: ModelOverview): string {
50
+ const lines: string[] = [];
51
+ lines.push(
52
+ view.activeModel
53
+ ? `**Model:** \`${view.activeModel}\`${
54
+ view.activeDisplay && view.activeDisplay !== view.activeModel
55
+ ? ` (${view.activeDisplay})`
56
+ : ""
57
+ }${view.hasModelOverride ? " — chat override" : ""}`
58
+ : "**Model:** none selected — messages will be refused until you pick one",
59
+ );
60
+ lines.push(
61
+ `**Backend:** ${view.backendLabel} (\`${view.backendId}\`)${view.hasBackendOverride ? " — chat override" : ""}`,
62
+ );
63
+ if (view.backends.length > 1) {
64
+ lines.push(
65
+ `Backends: ${view.backends.map((b) => `\`${b.id}\``).join(", ")} — \`/model <backend>\` switches (session restarts).`,
66
+ );
67
+ }
68
+ if (view.choices.length === 0) {
69
+ lines.push(
70
+ "",
71
+ "This backend has no browsable catalog; `/model <id>` sets one directly.",
72
+ );
73
+ } else {
74
+ lines.push("", `**Models** (${view.choices.length}):`);
75
+ for (const c of view.choices.slice(0, MAX_LISTED_MODELS)) {
76
+ const marker = c.id === view.activeModel ? " ✓" : "";
77
+ const free = c.free ? " · free" : "";
78
+ lines.push(`${c.index}. ${c.displayName} — \`${c.id}\`${free}${marker}`);
79
+ }
80
+ if (view.choices.length > MAX_LISTED_MODELS) {
81
+ lines.push(
82
+ `… ${view.choices.length - MAX_LISTED_MODELS} more — \`/model <id>\` picks any of them.`,
83
+ );
84
+ }
85
+ }
86
+ lines.push(
87
+ "",
88
+ "`/model <number>` or `/model <id>` picks; `/model default` clears the chat's pick" +
89
+ (view.hasBackendOverride
90
+ ? "; `/model backend default` reverts the backend."
91
+ : "."),
92
+ );
93
+ return lines.join("\n");
94
+ }
95
+
96
+ function renderSettings(
97
+ view: ModelOverview,
98
+ effort: { current: string; levels: string[] },
99
+ pulseOn: boolean,
100
+ pulseIntervalMs: number | undefined,
101
+ freeOnly: boolean,
102
+ ): string {
103
+ const lines = [
104
+ "**Settings**",
105
+ `Model: \`${view.activeModel ?? "none selected"}\`${view.hasModelOverride ? " (chat override)" : ""}`,
106
+ `Backend: ${view.backendLabel} (\`${view.backendId}\`)${view.hasBackendOverride ? " (chat override)" : ""}`,
107
+ `Effort: ${effort.current}${effort.levels.length ? ` — levels: ${effort.levels.join(", ")}` : ""}`,
108
+ `Pulse: ${pulseOn ? "on" : "off"} (every ${formatDuration(pulseIntervalMs ?? DEFAULT_PULSE_INTERVAL_MS)})`,
109
+ ];
110
+ if (freeOnly) lines.push("Free-only models: on");
111
+ lines.push(
112
+ "",
113
+ "Change with `/model`, `/effort`; `/reset` starts a fresh session.",
114
+ );
115
+ return lines.join("\n");
116
+ }
117
+
118
+ function renderStatus(s: SessionStatusData): string {
119
+ const used = s.context.known ? formatTokenCount(s.context.used) : "unknown";
120
+ const max = s.context.max > 0 ? formatTokenCount(s.context.max) : "unknown";
121
+ const pct = s.context.known ? `${s.context.pct}%` : "unknown";
122
+ const lines = [
123
+ `**Talon** · \`${formatModelLabel(s.activeModel)}\`${s.backendLabel ? ` · ${s.backendLabel}` : ""} · effort: ${s.effortName}${s.turnInProgress ? " · ⏳ turn running" : ""}`,
124
+ "",
125
+ `**Context** ${used} / ${max} (${pct})${s.context.warn ? " ⚠️ consider /reset" : ""}`,
126
+ `\`${s.context.bar}\``,
127
+ "",
128
+ "**Session stats**",
129
+ `Response: last ${s.lastResponseMs ? formatDuration(s.lastResponseMs) : "—"} · avg ${s.avgResponseMs ? formatDuration(s.avgResponseMs) : "—"} · best ${s.fastestMs ? formatDuration(s.fastestMs) : "—"}`,
130
+ `Turns: ${s.turns}${s.turnsModelLabel ? ` (${formatModelLabel(s.turnsModelLabel)})` : ""}`,
131
+ `Tokens: in ${formatTokenCount(s.inputTokens)} · out ${formatTokenCount(s.outputTokens)}${s.costUsd > 0 ? ` · cost ${formatUsd(s.costUsd)}` : ""}`,
132
+ ];
133
+ if (s.cache) {
134
+ lines.push(
135
+ `Cache: ${s.cache.hitPct}% hit · read ${formatTokenCount(s.cache.read)}${s.cache.showsWrite ? ` · write ${formatTokenCount(s.cache.write)}` : ""}`,
136
+ );
137
+ }
138
+ if (s.cacheTemp) lines.push(formatCacheTempLine(s.cacheTemp));
139
+ if (s.plan) {
140
+ lines.push(
141
+ "",
142
+ `**Plan**${s.plan.plan ? ` ${s.plan.plan}` : ""}${s.plan.ageLabel ? ` (${s.plan.ageLabel})` : ""}`,
143
+ ...s.plan.windows.map(
144
+ (w) =>
145
+ `\`${w.label.padEnd(6)}${w.bar} ${String(w.percent).padStart(3)}%\`${w.resetLabel ? ` reset ${w.resetLabel}` : ""}`,
146
+ ),
147
+ );
148
+ }
149
+ lines.push(
150
+ "",
151
+ `**Pulse** ${s.pulseOn ? "on" : "off"}`,
152
+ `**Workspace** ${formatBytes(s.diskBytes)}`,
153
+ `**Session** ${s.sessionName ? `"${s.sessionName}" ` : ""}${s.sessionId ? `\`${s.sessionId.slice(0, 8)}…\`` : "(new)"} · ${s.sessionAge} old`,
154
+ `**Uptime** ${s.uptime} · ${s.activeSessionCount} active session${s.activeSessionCount === 1 ? "" : "s"}`,
155
+ `**Runtime** ${s.runtime} · ${formatBytes(s.rssBytes)} RSS`,
156
+ formatDaemonLine(s.daemon),
157
+ );
158
+ return lines.join("\n");
159
+ }
160
+
161
+ // ── Replies ─────────────────────────────────────────────────────────────────
162
+
163
+ /**
164
+ * How a frontend changes backend. By default the shared switch runs, with
165
+ * `keepHistory` deciding whether the local chat log survives it; a
166
+ * frontend that owns more per-chat state than the shared stores (the
167
+ * native bridge's turn meta and cached readouts) supplies its own.
168
+ */
169
+ export type BackendSwitchHooks = {
170
+ keepHistory?: boolean;
171
+ switchBackend?: (target: { id: string; label: string }) => Promise<string>;
172
+ resetBackend?: () => Promise<string>;
173
+ };
174
+
175
+ /** `/model [arg]`: the listing, or the pick the argument names. */
176
+ export async function modelCommandReply(
177
+ chatId: string,
178
+ arg: string,
179
+ deps: ModelCommandDeps,
180
+ hooks: BackendSwitchHooks = {},
181
+ ): Promise<string> {
182
+ if (!arg) return renderModelOverview(await describeChatModels(chatId, deps));
183
+ const lower = arg.toLowerCase();
184
+ const keepHistory = hooks.keepHistory === true;
185
+ if (lower === "backend default" || lower === "backend reset") {
186
+ if (hooks.resetBackend) return hooks.resetBackend();
187
+ return (await resetChatBackend(chatId, deps, { keepHistory })).text;
188
+ }
189
+ if (lower === "reset" || lower === "default") {
190
+ return (await resetChatModel(chatId, deps)).text;
191
+ }
192
+ const backend = matchBackendArg(arg, deps.config);
193
+ if (backend) {
194
+ if (hooks.switchBackend) return hooks.switchBackend(backend);
195
+ return (await switchChatBackend(chatId, backend, deps, { keepHistory }))
196
+ .text;
197
+ }
198
+ return (await selectChatModel(chatId, arg, deps)).text;
199
+ }
200
+
201
+ /** `/effort [level]`: the current level and the choices, or the change. */
202
+ export async function effortCommandReply(
203
+ chatId: string,
204
+ arg: string,
205
+ deps: ModelCommandDeps,
206
+ ): Promise<string> {
207
+ if (arg) return (await setChatEffortLevel(chatId, arg, deps)).text;
208
+ const effort = await describeChatEffort(chatId, deps);
209
+ if (effort.levels.length === 0) {
210
+ return `No reasoning levels available for ${effort.activeModel ?? "the active model"} on backend ${effort.backendId}.`;
211
+ }
212
+ return `**Effort:** ${effort.current}\nLevels: ${effort.levels.join(", ")}, or adaptive — \`/effort <level>\` sets one.`;
213
+ }
214
+
215
+ /** `/settings`: model, backend, effort and pulse in one listing. */
216
+ export async function settingsCommandReply(
217
+ chatId: string,
218
+ deps: ModelCommandDeps,
219
+ ): Promise<string> {
220
+ const [view, effort] = await Promise.all([
221
+ describeChatModels(chatId, deps),
222
+ describeChatEffort(chatId, deps),
223
+ ]);
224
+ const sets = getChatSettings(chatId);
225
+ return renderSettings(
226
+ view,
227
+ effort,
228
+ isPulseEnabled(chatId),
229
+ sets.pulseIntervalMs,
230
+ sets.freeOnly === true,
231
+ );
232
+ }
233
+
234
+ /** `/status`: session, context, usage and daemon stats. */
235
+ export async function statusCommandReply(
236
+ chatId: string,
237
+ deps: ModelCommandDeps,
238
+ ): Promise<string> {
239
+ const { backend, backendId } = resolveChatBackendPair(chatId, deps);
240
+ return renderStatus(
241
+ await collectSessionStatus(chatId, deps.config, backend, backendId),
242
+ );
243
+ }
@@ -5,8 +5,10 @@
5
5
  import type { Bot, Context } from "grammy";
6
6
  import type { TalonConfig } from "../../../core/config/index.js";
7
7
  import { escapeHtml } from "../formatting.js";
8
- import { resetSession, getAllSessions } from "../../../storage/sessions.js";
9
- import { clearHistory } from "../../../storage/history.js";
8
+ import { getAllSessions } from "../../../storage/sessions.js";
9
+ import { performSessionReset } from "../../presentation/session-status.js";
10
+ import { resolveBackendForChat } from "../model-menu.js";
11
+ import type { Backend } from "../../../core/agent-runtime/capabilities.js";
10
12
  import { getChatSettings } from "../../../storage/chat-settings.js";
11
13
  import { formatModelLabel } from "../../presentation/format.js";
12
14
  import { replyHtmlChunked } from "./chunked-reply.js";
@@ -93,13 +95,26 @@ export async function broadcast(
93
95
  );
94
96
  }
95
97
 
96
- export async function killSession(ctx: Context, rest: string[]): Promise<void> {
98
+ /** The default backend; per-chat overrides are resolved from it. */
99
+ export type AdminGateway = { backend: Backend | null };
100
+
101
+ /**
102
+ * `/admin kill <chatId>` — the same reset /reset performs in that chat:
103
+ * session + history stores, pulse checkpoint, and the backend's own
104
+ * per-chat session (resolved through the chat's backend override).
105
+ */
106
+ export async function killSession(
107
+ ctx: Context,
108
+ rest: string[],
109
+ _bot: Bot,
110
+ _config: TalonConfig,
111
+ gateway?: AdminGateway,
112
+ ): Promise<void> {
97
113
  const target = rest[0];
98
114
  if (!target) {
99
115
  await ctx.reply("Usage: /admin kill <chatId>");
100
116
  return;
101
117
  }
102
- resetSession(target);
103
- clearHistory(target);
118
+ await performSessionReset(target, resolveBackendForChat(target, gateway));
104
119
  await ctx.reply(`Session ${target} reset.`);
105
120
  }
@@ -11,7 +11,12 @@
11
11
 
12
12
  import type { Bot, Context } from "grammy";
13
13
  import type { TalonConfig } from "../../core/config/index.js";
14
- import { replyActiveChats, broadcast, killSession } from "./admin/sessions.js";
14
+ import {
15
+ replyActiveChats,
16
+ broadcast,
17
+ killSession,
18
+ type AdminGateway,
19
+ } from "./admin/sessions.js";
15
20
  import {
16
21
  replyStats,
17
22
  replyRecentErrors,
@@ -25,6 +30,7 @@ type AdminSubcommand = (
25
30
  rest: string[],
26
31
  bot: Bot,
27
32
  config: TalonConfig,
33
+ gateway?: AdminGateway,
28
34
  ) => Promise<void>;
29
35
 
30
36
  // Null-prototype so `/admin constructor` can't resolve an inherited
@@ -80,6 +86,7 @@ export async function handleAdminCommand(
80
86
  ctx: Context,
81
87
  bot: Bot,
82
88
  config: TalonConfig,
89
+ gateway?: AdminGateway,
83
90
  ): Promise<void> {
84
91
  const args = ((ctx.match as string) ?? "").trim();
85
92
  const [subcommand, ...rest] = args.split(/\s+/);
@@ -94,5 +101,5 @@ export async function handleAdminCommand(
94
101
  );
95
102
  return;
96
103
  }
97
- await run(ctx, rest, bot, config);
104
+ await run(ctx, rest, bot, config, gateway);
98
105
  }
@@ -1,39 +1,117 @@
1
1
  /**
2
- * `backup:*` callbacks — the /backup restore confirmation.
2
+ * `backup:*` callbacks — the /backup panel and the restore confirmation.
3
3
  *
4
- * backup:restore:<id> stage the restore and restart
5
- * backup:cancel do nothing, say so
4
+ * backup:panel status panel (Refresh, Back)
5
+ * backup:now take a snapshot, editing the panel in place
6
+ * backup:list:<page> a page of snapshots
7
+ * backup:guide how restore works
8
+ * backup:pin|unpin:<p>:<id> pin or release, then re-render the page
9
+ * backup:ask:<p>:<id> open the restore confirmation (nothing happens yet)
10
+ * backup:restore:<id> stage the restore and restart
11
+ * backup:cancel do nothing, say so
6
12
  *
7
- * The button is the whole safety mechanism: a restore replaces the
8
- * database, the memory and the identity of a running agent, so nothing
9
- * here happens without a second, explicit admin tap.
13
+ * Every tap re-checks the admin: a button is only a message, and anyone
14
+ * who can see it can press it. The confirmation is the whole safety
15
+ * mechanism for restore — it replaces the database, the memory and the
16
+ * identity of a running agent, so nothing stages without a second,
17
+ * explicit admin tap on `backup:restore:<id>`, which only the
18
+ * confirmation offers. The grammar lives in
19
+ * frontend/presentation/backup-panel.ts.
10
20
  */
11
21
 
12
22
  import type { Context } from "grammy";
13
- import { isSnapshotId } from "../../../core/backup/index.js";
14
- import { stageRestore } from "../commands/backup.js";
23
+ import { readManifest, setSnapshotPinned } from "../../../core/backup/index.js";
24
+ import {
25
+ PANEL_BUTTONS,
26
+ parseBackupAction,
27
+ restoreConfirmView,
28
+ type PanelView,
29
+ } from "../../presentation/backup-panel.js";
30
+ import { runSnapshotForChat, stageRestore } from "../commands/backup.js";
15
31
  import { isAuthorizedAdmin } from "../commands/state.js";
32
+ import {
33
+ inlineKeyboard,
34
+ loadSnapshotPage,
35
+ loadStatusPanel,
36
+ restoreGuideView,
37
+ } from "../render/backup-panel.js";
38
+ import { TELEGRAM_REPORTS } from "../render/html.js";
16
39
  import { logError } from "../../../util/log.js";
17
- import { answerCallbackQuerySafe } from "./query.js";
40
+ import { answerCallbackQuerySafe, editOrIgnoreSame } from "./query.js";
18
41
 
19
- export async function handleBackupCallback(
20
- ctx: Context,
21
- data: string,
22
- ): Promise<void> {
23
- if (!isAuthorizedAdmin(ctx)) {
24
- await answerCallbackQuerySafe(ctx, { text: "Not authorized." });
42
+ /** One panel snapshot at a time: a double tap must not take two. */
43
+ let panelSnapshotRunning = false;
44
+
45
+ /** Tests only. */
46
+ export function _resetBackupPanelState(): void {
47
+ panelSnapshotRunning = false;
48
+ }
49
+
50
+ function show(ctx: Context, view: PanelView): Promise<void> {
51
+ return editOrIgnoreSame(ctx, view.text, inlineKeyboard(view.buttons));
52
+ }
53
+
54
+ async function backUpNow(ctx: Context): Promise<void> {
55
+ if (panelSnapshotRunning) {
56
+ await answerCallbackQuerySafe(ctx, {
57
+ text: "A snapshot is already running.",
58
+ });
25
59
  return;
26
60
  }
27
- const [, action, id] = data.split(":");
28
- if (action === "cancel") {
29
- await answerCallbackQuerySafe(ctx, { text: "Cancelled." });
30
- await ctx.editMessageText("Restore cancelled.").catch(() => {});
31
- return;
61
+ panelSnapshotRunning = true;
62
+ try {
63
+ await answerCallbackQuerySafe(ctx, { text: "📸 Snapshot started" });
64
+ await editOrIgnoreSame(
65
+ ctx,
66
+ "📸 <b>Taking a snapshot…</b>\n" +
67
+ "Building it, then pruning and uploading to any targets. " +
68
+ "This can take a few minutes — this message updates when it is done.",
69
+ [],
70
+ );
71
+ const result = await runSnapshotForChat();
72
+ // The snapshot's outcome must land even if the status read after it
73
+ // does not: fall back to the bare result line under the panel buttons.
74
+ const view = await loadStatusPanel(result.text).catch((err: unknown) => {
75
+ logError("backup", "Backup panel status read failed", err);
76
+ return { text: result.text, buttons: PANEL_BUTTONS };
77
+ });
78
+ await show(ctx, view);
79
+ } finally {
80
+ panelSnapshotRunning = false;
32
81
  }
33
- if (action !== "restore" || !id || !isSnapshotId(id)) {
34
- await answerCallbackQuerySafe(ctx, { text: "Invalid callback data" });
82
+ }
83
+
84
+ async function togglePin(
85
+ ctx: Context,
86
+ id: string,
87
+ page: number,
88
+ pinned: boolean,
89
+ ): Promise<void> {
90
+ const ok = await setSnapshotPinned(id, pinned);
91
+ await answerCallbackQuerySafe(ctx, {
92
+ text: ok ? (pinned ? "📌 Pinned" : "Unpinned") : "No such snapshot",
93
+ });
94
+ await show(ctx, await loadSnapshotPage(page));
95
+ }
96
+
97
+ /** The panel's Restore button: the confirmation, never the restore. */
98
+ async function askToRestore(
99
+ ctx: Context,
100
+ id: string,
101
+ page: number,
102
+ ): Promise<void> {
103
+ const manifest = await readManifest(id);
104
+ if (!manifest) {
105
+ await answerCallbackQuerySafe(ctx, {
106
+ text: "That snapshot has no copy on this machine.",
107
+ });
35
108
  return;
36
109
  }
110
+ await answerCallbackQuerySafe(ctx);
111
+ await show(ctx, restoreConfirmView(TELEGRAM_REPORTS, manifest, page));
112
+ }
113
+
114
+ async function confirmRestore(ctx: Context, id: string): Promise<void> {
37
115
  await answerCallbackQuerySafe(ctx, { text: "Restoring…" });
38
116
  await ctx
39
117
  .editMessageText(
@@ -53,3 +131,56 @@ export async function handleBackupCallback(
53
131
  .catch(() => {});
54
132
  }
55
133
  }
134
+
135
+ export async function handleBackupCallback(
136
+ ctx: Context,
137
+ data: string,
138
+ ): Promise<void> {
139
+ if (!isAuthorizedAdmin(ctx)) {
140
+ await answerCallbackQuerySafe(ctx, { text: "Not authorized." });
141
+ return;
142
+ }
143
+ const action = parseBackupAction(data);
144
+ if (!action) {
145
+ await answerCallbackQuerySafe(ctx, { text: "Invalid callback data" });
146
+ return;
147
+ }
148
+ try {
149
+ switch (action.kind) {
150
+ case "cancel":
151
+ await answerCallbackQuerySafe(ctx, { text: "Cancelled." });
152
+ await ctx.editMessageText("Restore cancelled.").catch(() => {});
153
+ return;
154
+ case "restore":
155
+ await confirmRestore(ctx, action.id);
156
+ return;
157
+ case "now":
158
+ await backUpNow(ctx);
159
+ return;
160
+ case "pin":
161
+ case "unpin":
162
+ await togglePin(ctx, action.id, action.page, action.kind === "pin");
163
+ return;
164
+ case "ask":
165
+ await askToRestore(ctx, action.id, action.page);
166
+ return;
167
+ case "panel":
168
+ await answerCallbackQuerySafe(ctx);
169
+ await show(ctx, await loadStatusPanel());
170
+ return;
171
+ case "list":
172
+ await answerCallbackQuerySafe(ctx);
173
+ await show(ctx, await loadSnapshotPage(action.page));
174
+ return;
175
+ case "guide":
176
+ await answerCallbackQuerySafe(ctx);
177
+ await show(ctx, restoreGuideView());
178
+ return;
179
+ }
180
+ } catch (err) {
181
+ logError("backup", `Backup panel action ${action.kind} failed`, err);
182
+ await answerCallbackQuerySafe(ctx, {
183
+ text: "⚠️ That did not work — see the log.",
184
+ });
185
+ }
186
+ }
@@ -33,15 +33,15 @@ export async function handleEffortCallback(
33
33
  backendId: beId,
34
34
  config,
35
35
  });
36
- if (reasoning.levels.length === 0) {
36
+ if (level === "adaptive") {
37
+ // Needs no model levels — reachable even when the model has none.
38
+ setChatEffort(cid, undefined);
39
+ await answerCallbackQuerySafe(ctx, { text: "Effort: adaptive" });
40
+ } else if (reasoning.levels.length === 0) {
37
41
  await answerCallbackQuerySafe(ctx, {
38
42
  text: "No valid reasoning levels found",
39
43
  });
40
44
  return;
41
- }
42
- if (level === "adaptive") {
43
- setChatEffort(cid, undefined);
44
- await answerCallbackQuerySafe(ctx, { text: "Effort: adaptive" });
45
45
  } else if (supportsReasoningLevel(level, reasoning.levels)) {
46
46
  setChatEffort(cid, level as EffortLevel);
47
47
  await answerCallbackQuerySafe(ctx, { text: `Effort: ${level}` });
@@ -11,7 +11,7 @@
11
11
  * - `model` — `model:*` (menu / backend / browse controller)
12
12
  * - `auth` — `auth:*` (backend sign-in panel, admin only)
13
13
  * - `whatsapp` — `whatsapp:*` (WhatsApp link panel, admin only)
14
- * - `backup` — `backup:*` (restore confirmation, admin only)
14
+ * - `backup` — `backup:*` (the /backup panel + restore confirmation, admin only)
15
15
  * - `usage-reset` — `ureset:*` (spend a banked limit reset, admin DM only)
16
16
  *
17
17
  * `registerCallbacks` installs one `callback_query:data` listener that
@@ -54,7 +54,7 @@ export function registerCallbacks(
54
54
  return;
55
55
  }
56
56
 
57
- // Restore confirmation — destructive, so it is behind its own tap.
57
+ // The /backup panel. Restore, the destructive one, is behind its own tap.
58
58
  if (data.startsWith("backup:")) {
59
59
  await handleBackupCallback(ctx, data);
60
60
  return;
@@ -16,11 +16,7 @@ import {
16
16
  isPulseEnabled,
17
17
  } from "../../../core/background/pulse/pulse.js";
18
18
  import { getBackendIdForChat } from "../../../core/engine/backend-controller/index.js";
19
- import {
20
- renderSettingsText,
21
- renderSettingsKeyboard,
22
- type SettingsButton,
23
- } from "../render/menu.js";
19
+ import { renderSettingsText, renderSettingsKeyboard } from "../render/menu.js";
24
20
  import { resolveBackendForChat } from "../model-menu.js";
25
21
  import { resolveActiveModelForChat } from "../../../core/models/active-model.js";
26
22
  import {
@@ -117,36 +113,6 @@ export async function handleSettingsCallback(
117
113
  config,
118
114
  });
119
115
  const effortName = displayReasoningEffort(chatSets.effort, reasoning.levels);
120
- let modelButtons: Array<SettingsButton> | undefined;
121
- let pager:
122
- | {
123
- page: number;
124
- totalPages: number;
125
- filter: "all" | "free";
126
- freeCount: number;
127
- totalCount: number;
128
- provider?: string;
129
- }
130
- | undefined;
131
- let view: "models" | "groups" = "models";
132
- let activeProvider: string | undefined;
133
- if (settingsBe?.models?.getSettingsPresentation && resolvedSettingsModel) {
134
- const pres = await settingsBe.models?.getSettingsPresentation(
135
- resolvedSettingsModel,
136
- );
137
- modelButtons = pres.modelButtons;
138
- pager = {
139
- page: pres.page,
140
- totalPages: pres.totalPages,
141
- filter: pres.filter,
142
- freeCount: pres.freeCount,
143
- totalCount: pres.totalCount,
144
- provider: pres.provider,
145
- };
146
- view = pres.view;
147
- activeProvider = pres.provider;
148
- }
149
-
150
116
  try {
151
117
  await ctx.editMessageText(
152
118
  renderSettingsText(
@@ -159,14 +125,11 @@ export async function handleSettingsCallback(
159
125
  parse_mode: "HTML",
160
126
  reply_markup: {
161
127
  inline_keyboard: renderSettingsKeyboard(
162
- activeModel,
128
+ resolvedSettingsModel,
163
129
  effortName,
164
130
  pulseOn,
165
- modelButtons,
166
- pager,
167
- view,
168
- activeProvider,
169
131
  reasoning.levels,
132
+ Boolean(settingsBe?.models?.getSettingsPresentation),
170
133
  ),
171
134
  },
172
135
  },
@@ -36,13 +36,16 @@ import { getTodayMetrics } from "../../../storage/metrics.js";
36
36
  import { isAuthorizedAdmin, type RegisterDeps } from "./state.js";
37
37
  import { telegramCommandMenu } from "./definitions.js";
38
38
 
39
- function registerAdminCommand(bot: Bot, config: TalonConfig): void {
39
+ function registerAdminCommand(
40
+ bot: Bot,
41
+ { config, gateway }: RegisterDeps,
42
+ ): void {
40
43
  bot.command("admin", async (ctx) => {
41
44
  if (!isAuthorizedAdmin(ctx)) {
42
45
  await ctx.reply("Not authorized.");
43
46
  return;
44
47
  }
45
- await handleAdminCommand(ctx, bot, config);
48
+ await handleAdminCommand(ctx, bot, config, gateway);
46
49
  });
47
50
  }
48
51
 
@@ -258,11 +261,9 @@ function registerUnknownCommandSuggester(bot: Bot, config: TalonConfig): void {
258
261
  });
259
262
  }
260
263
 
261
- export function registerAdminCommands(
262
- bot: Bot,
263
- { config }: RegisterDeps,
264
- ): void {
265
- registerAdminCommand(bot, config);
264
+ export function registerAdminCommands(bot: Bot, deps: RegisterDeps): void {
265
+ const { config } = deps;
266
+ registerAdminCommand(bot, deps);
266
267
  registerMetricsCommand(bot);
267
268
  registerUsageCommand(bot, config);
268
269
  registerDoctorCommand(bot, config);