talon-agent 5.25.1 → 5.26.1

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 (73) 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/claude-sdk/model-provider.ts +59 -12
  5. package/src/backend/codex/auth.ts +31 -2
  6. package/src/backend/codex/constants.ts +21 -7
  7. package/src/backend/codex/discovery.ts +32 -1
  8. package/src/backend/codex/factory.ts +3 -5
  9. package/src/backend/codex/handler/message.ts +7 -7
  10. package/src/backend/codex/models.ts +53 -11
  11. package/src/backend/codex/one-shot.ts +139 -37
  12. package/src/backend/codex/state.ts +11 -0
  13. package/src/core/agents/abort-reason.ts +46 -0
  14. package/src/core/agents/registry.ts +3 -2
  15. package/src/core/background/dream/index.ts +2 -2
  16. package/src/core/background/heartbeat/agent.ts +1 -1
  17. package/src/core/background/isolated-agent.ts +1 -1
  18. package/src/core/backup/index.ts +1 -0
  19. package/src/core/backup/status.ts +30 -1
  20. package/src/core/config/index.ts +8 -0
  21. package/src/core/engine/gateway.ts +5 -0
  22. package/src/core/frontend-runtime/admin-notify.ts +46 -6
  23. package/src/core/frontend-runtime/alerts.ts +10 -6
  24. package/src/core/mesh/credentials/store.ts +60 -9
  25. package/src/core/models/active-model.ts +57 -48
  26. package/src/core/plugin/loader.ts +47 -12
  27. package/src/core/tools/bridge.ts +58 -4
  28. package/src/frontend/discord/callbacks/components/effort.ts +4 -4
  29. package/src/frontend/discord/callbacks/components/index.ts +2 -0
  30. package/src/frontend/discord/commands/backup-panel.ts +219 -0
  31. package/src/frontend/discord/commands/backup.ts +25 -34
  32. package/src/frontend/discord/commands/info.ts +17 -5
  33. package/src/frontend/discord/commands/settings.ts +12 -9
  34. package/src/frontend/discord/render.ts +1 -1
  35. package/src/frontend/native/bridge/credentials/claims.ts +5 -5
  36. package/src/frontend/native/bridge/routes/chats.ts +40 -20
  37. package/src/frontend/native/bridge/routes/host.ts +15 -2
  38. package/src/frontend/native/bridge/routes/table.ts +4 -0
  39. package/src/frontend/native/commands/admin.ts +64 -0
  40. package/src/frontend/native/commands/backup.ts +191 -0
  41. package/src/frontend/native/commands/definitions.ts +113 -0
  42. package/src/frontend/native/commands/format.ts +24 -0
  43. package/src/frontend/native/commands/index.ts +106 -0
  44. package/src/frontend/native/commands/info.ts +97 -0
  45. package/src/frontend/native/commands/session.ts +130 -0
  46. package/src/frontend/native/commands/types.ts +26 -0
  47. package/src/frontend/native/protocol.ts +18 -0
  48. package/src/frontend/native/surface/handlers.ts +14 -1
  49. package/src/frontend/native/surface/status.ts +9 -1
  50. package/src/frontend/native/turn/emit.ts +32 -1
  51. package/src/frontend/presentation/backup-panel.ts +425 -0
  52. package/src/frontend/presentation/memory-report.ts +109 -0
  53. package/src/frontend/presentation/text-commands.ts +243 -0
  54. package/src/frontend/telegram/actions/media.ts +60 -19
  55. package/src/frontend/telegram/actions/messaging.ts +9 -0
  56. package/src/frontend/telegram/admin/sessions.ts +20 -5
  57. package/src/frontend/telegram/admin.ts +9 -2
  58. package/src/frontend/telegram/callbacks/backup.ts +153 -22
  59. package/src/frontend/telegram/callbacks/effort.ts +5 -5
  60. package/src/frontend/telegram/callbacks/index.ts +2 -2
  61. package/src/frontend/telegram/callbacks/settings.ts +3 -40
  62. package/src/frontend/telegram/commands/admin.ts +8 -7
  63. package/src/frontend/telegram/commands/backup.ts +56 -46
  64. package/src/frontend/telegram/commands/index.ts +3 -2
  65. package/src/frontend/telegram/commands/info.ts +45 -26
  66. package/src/frontend/telegram/commands/memory.ts +8 -92
  67. package/src/frontend/telegram/commands/settings.ts +13 -45
  68. package/src/frontend/telegram/commands/whatsapp-pairing.ts +19 -15
  69. package/src/frontend/telegram/render/backup-panel.ts +47 -0
  70. package/src/frontend/telegram/render/menu.ts +21 -30
  71. package/src/frontend/terminal/builtins/model.ts +20 -11
  72. package/src/frontend/whatsapp/commands.ts +16 -216
  73. 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
+ }
@@ -54,8 +54,8 @@ export function resolveMediaInput(
54
54
 
55
55
  /** Telegram's hard limit on media captions, counted after entity parsing. */
56
56
  export const TELEGRAM_MAX_CAPTION = 1024;
57
- /** Visible length a truncated caption is cut to, leaving room for the "…". */
58
- const TRUNCATED_CAPTION_LEN = 1000;
57
+ /** Below this head budget the splitter is fighting pathological markup; give up. */
58
+ const MIN_CAPTION_HEAD = 64;
59
59
 
60
60
  /**
61
61
  * Drop every `<...>` tag in one linear pass. Only used to *measure* the
@@ -104,35 +104,76 @@ export function visibleCaptionText(html: string): string {
104
104
  export type FittedCaption = {
105
105
  caption?: string;
106
106
  parse_mode?: "HTML";
107
- /** Full caption text to deliver as a follow-up message when it was cut. */
107
+ /** Caption text that did not fit, to deliver as follow-up message(s). */
108
108
  overflow?: string;
109
109
  };
110
110
 
111
+ /** Visible (Telegram-counted) length of a markdown caption once rendered. */
112
+ export function captionUnits(markdown: string): number {
113
+ // Telegram counts UTF-16 code units, which is what .length measures.
114
+ return visibleCaptionText(markdownToTelegramHtml(markdown)).length;
115
+ }
116
+
117
+ /**
118
+ * Split a markdown caption into the leading part that fits `max` visible
119
+ * units and the remainder. Splits the *markdown source* with the shared
120
+ * message splitter (paragraph → newline → space boundaries, surrogate-safe,
121
+ * ``` fences closed/reopened), so the head is rendered to HTML on its own
122
+ * and can never strand a tag or entity. Returns null if no clean head fits.
123
+ */
124
+ export function splitCaption(
125
+ text: string,
126
+ max: number = TELEGRAM_MAX_CAPTION,
127
+ ): { head: string; rest: string } | null {
128
+ // Rendering usually shrinks markdown (markers and link URLs vanish), so
129
+ // the first try nearly always fits; shrink the budget if it does not.
130
+ for (let budget = max; budget >= MIN_CAPTION_HEAD;) {
131
+ const chunks = splitMessage(text, budget);
132
+ const head = chunks[0] ?? "";
133
+ if (head.trim() && captionUnits(head) <= max) {
134
+ const at = text.indexOf(head);
135
+ // The splitter only trims at boundaries, so the head is normally a
136
+ // verbatim prefix; when it closed a ``` fence it is not, and the
137
+ // splitter's own reopened chunks carry the remainder instead.
138
+ const rest =
139
+ at >= 0
140
+ ? text.slice(at + head.length).replace(/^\s+/, "")
141
+ : chunks.slice(1).join("\n\n");
142
+ return { head, rest };
143
+ }
144
+ budget = Math.floor(budget * 0.8);
145
+ }
146
+ return null;
147
+ }
148
+
111
149
  /**
112
150
  * Convert a markdown caption to what Telegram accepts. Captions over 1024
113
151
  * visible characters are rejected outright ("message caption is too long"),
114
- * losing the media along with them — so an oversized caption is cut to a
115
- * plain-text preview (no parse_mode: a cut through HTML could strand a tag
116
- * or entity) and the full text is returned as `overflow` for the caller to
117
- * send as a normal, chunked text message.
152
+ * losing the media along with them — so an oversized caption is split: the
153
+ * leading part that fits rides on the media (still formatted), and the rest
154
+ * is returned as `overflow` for the caller to send as follow-up text.
118
155
  */
119
156
  export function fitCaption(raw: unknown): FittedCaption {
120
157
  if (!raw) return {};
121
158
  const text = String(raw);
122
159
  const html = markdownToTelegramHtml(text);
123
- const visible = visibleCaptionText(html);
124
- // Telegram counts UTF-16 code units, which is what .length measures.
125
- if (visible.length <= TELEGRAM_MAX_CAPTION)
160
+ if (visibleCaptionText(html).length <= TELEGRAM_MAX_CAPTION)
126
161
  return { caption: html, parse_mode: "HTML" };
127
- let cut = visible.slice(0, TRUNCATED_CAPTION_LEN);
128
- // Don't strand half a surrogate pair at the cut.
129
- const last = cut.charCodeAt(cut.length - 1);
130
- if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1);
131
- return { caption: `${cut.trimEnd()}…`, overflow: text };
162
+ const split = splitCaption(text);
163
+ if (split) {
164
+ return {
165
+ caption: markdownToTelegramHtml(split.head),
166
+ parse_mode: "HTML",
167
+ ...(split.rest ? { overflow: split.rest } : {}),
168
+ };
169
+ }
170
+ // No clean boundary fits (pathological markup): send the media bare and
171
+ // deliver the whole caption as text rather than cut through it.
172
+ return { overflow: text };
132
173
  }
133
174
 
134
175
  /**
135
- * Deliver the full text of a caption that did not fit, threaded as a reply
176
+ * Deliver the part of a caption that did not fit, threaded as a reply
136
177
  * to the media it belongs to. Best-effort: the media already landed, so a
137
178
  * failure here is reported as a warning rather than failing the send.
138
179
  */
@@ -167,7 +208,7 @@ async function sendCaptionOverflow(
167
208
  `Caption overflow follow-up failed (chat=${chatId}): ${msg}`,
168
209
  );
169
210
  return {
170
- warning: `Media sent with a truncated caption, but sending the full caption text failed: ${msg}`,
211
+ warning: `Media sent, but the rest of its caption (past Telegram's ${TELEGRAM_MAX_CAPTION}-char limit) failed to send: ${msg}`,
171
212
  };
172
213
  }
173
214
  }
@@ -176,8 +217,8 @@ function overflowResult(
176
217
  r: { message_ids: number[] } | { warning: string },
177
218
  ): Record<string, unknown> {
178
219
  return "warning" in r
179
- ? { caption_truncated: true, warning: r.warning }
180
- : { caption_truncated: true, caption_message_ids: r.message_ids };
220
+ ? { caption_split: true, warning: r.warning }
221
+ : { caption_split: true, caption_message_ids: r.message_ids };
181
222
  }
182
223
 
183
224
  const sendMediaFile: TelegramActionHandlers[string] = async (
@@ -21,6 +21,7 @@ import {
21
21
  richMessagesAvailable,
22
22
  } from "./rich-messages.js";
23
23
  import { toPositiveId } from "./coerce.js";
24
+ import { captionUnits, TELEGRAM_MAX_CAPTION } from "./media.js";
24
25
  import { resolveThreadId } from "../topics.js";
25
26
  import { TELEGRAM_MAX_TEXT, type TelegramActionHandlers } from "./types.js";
26
27
 
@@ -260,6 +261,14 @@ export const messagingHandlers: TelegramActionHandlers = {
260
261
  // Media messages have captions, not text — editMessageText on them fails
261
262
  // with "there is no text in the message to edit".
262
263
  if (body.is_caption === true) {
264
+ // An edit cannot spill into a follow-up message the way a send does,
265
+ // so refuse up front with guidance rather than a raw Bot API 400.
266
+ const units = captionUnits(text);
267
+ if (units > TELEGRAM_MAX_CAPTION)
268
+ return {
269
+ ok: false,
270
+ error: `Caption too long (${units} chars, max ${TELEGRAM_MAX_CAPTION}) — shorten it, or send the rest as a separate message`,
271
+ };
263
272
  await withRetry(async () => {
264
273
  try {
265
274
  await bot.api.editMessageCaption(chatId, Number(body.message_id), {
@@ -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
+ }