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
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * /backup — the snapshot panel (admin only).
3
3
  *
4
- * /backup status: schedule, sizes, targets
4
+ * /backup the interactive panel: status + buttons
5
5
  * /backup now take a snapshot now
6
6
  * /backup checkpoint <label> take a labelled, pinned-on-request snapshot
7
7
  * /backup list recent snapshots
8
8
  * /backup pin|unpin <id> keep past retention, or release
9
9
  * /backup restore <id> confirm button → staged restore + restart
10
10
  *
11
- * Restore is the only destructive one, so it is the only one behind a
12
- * button. It does not restore in place: a running daemon holds the
11
+ * The bare command opens a panel (render/backup-panel.ts) whose buttons —
12
+ * Back up now, Snapshots, How restore works, Refresh — are handled in
13
+ * callbacks/backup.ts. Restore is the only destructive action, so it is
14
+ * always behind an explicit confirmation button. It does not restore in place: a running daemon holds the
13
15
  * database it would replace, so the request is staged to
14
16
  * ~/.talon/restore-pending.json and applied by the next boot before
15
17
  * anything opens the database (see core/backup/restore.ts).
@@ -17,8 +19,6 @@
17
19
 
18
20
  import type { Bot, Context } from "grammy";
19
21
  import {
20
- collectBackupStatus,
21
- formatBackupStatus,
22
22
  formatSnapshotList,
23
23
  isSnapshotId,
24
24
  listSnapshots,
@@ -29,13 +29,16 @@ import {
29
29
  } from "../../../core/backup/index.js";
30
30
  import { respawnSelf } from "../../../core/daemon/respawn.js";
31
31
  import { escapeHtml } from "../formatting.js";
32
+ import { renderRestoreConfirm } from "../../presentation/backup-panel.js";
33
+ import { TELEGRAM_REPORTS } from "../render/html.js";
34
+ import { inlineKeyboard, loadStatusPanel } from "../render/backup-panel.js";
32
35
  import { logError } from "../../../util/log.js";
33
36
  import { isAuthorizedAdmin } from "./state.js";
34
37
 
35
38
  const HELP = [
36
39
  "<b>/backup</b> — snapshots and checkpoints",
37
40
  "",
38
- "<code>/backup</code> — status",
41
+ "<code>/backup</code> — the backup panel",
39
42
  "<code>/backup now</code> — take a snapshot",
40
43
  "<code>/backup checkpoint &lt;label&gt;</code> — labelled checkpoint",
41
44
  "<code>/backup list</code> — recent snapshots",
@@ -48,8 +51,11 @@ function pre(text: string): string {
48
51
  }
49
52
 
50
53
  async function sendStatus(ctx: Context): Promise<void> {
51
- const status = await collectBackupStatus();
52
- await ctx.reply(pre(formatBackupStatus(status)), { parse_mode: "HTML" });
54
+ const view = await loadStatusPanel();
55
+ await ctx.reply(view.text, {
56
+ parse_mode: "HTML",
57
+ reply_markup: { inline_keyboard: inlineKeyboard(view.buttons) },
58
+ });
53
59
  }
54
60
 
55
61
  async function sendList(ctx: Context): Promise<void> {
@@ -57,13 +63,14 @@ async function sendList(ctx: Context): Promise<void> {
57
63
  await ctx.reply(pre(formatSnapshotList(snapshots)), { parse_mode: "HTML" });
58
64
  }
59
65
 
60
- async function takeSnapshot(ctx: Context, label?: string): Promise<void> {
61
- const sent = await ctx.reply(
62
- label
63
- ? `📸 Taking checkpoint “${escapeHtml(label)}”…`
64
- : "📸 Taking a snapshot…",
65
- { parse_mode: "HTML" },
66
- );
66
+ /**
67
+ * Run one snapshot and describe the outcome as a single HTML line. Shared
68
+ * by `/backup now|checkpoint` and the panel's 📸 button, which edit their
69
+ * own messages with it. Never throws: a failure is the line.
70
+ */
71
+ export async function runSnapshotForChat(
72
+ label?: string,
73
+ ): Promise<{ ok: boolean; text: string }> {
67
74
  try {
68
75
  const manifest = await runBackup({
69
76
  kind: label ? "checkpoint" : "backup",
@@ -71,25 +78,35 @@ async function takeSnapshot(ctx: Context, label?: string): Promise<void> {
71
78
  pinned: Boolean(label),
72
79
  trigger: "command",
73
80
  });
74
- await ctx.api.editMessageText(
75
- ctx.chat!.id,
76
- sent.message_id,
77
- `✅ <code>${escapeHtml(manifest.id)}</code> — ${manifest.parts.length} part(s), ` +
81
+ return {
82
+ ok: true,
83
+ text:
84
+ `✅ <code>${escapeHtml(manifest.id)}</code> — ${manifest.parts.length} part(s), ` +
78
85
  `${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB` +
79
86
  (label ? " (pinned)" : ""),
80
- { parse_mode: "HTML" },
81
- );
87
+ };
82
88
  } catch (err) {
83
89
  logError("backup", "/backup now failed", err);
84
- await ctx.api.editMessageText(
85
- ctx.chat!.id,
86
- sent.message_id,
87
- `⚠️ Backup failed: ${escapeHtml(err instanceof Error ? err.message : String(err))}`,
88
- { parse_mode: "HTML" },
89
- );
90
+ return {
91
+ ok: false,
92
+ text: `⚠️ Backup failed: ${escapeHtml(err instanceof Error ? err.message : String(err))}`,
93
+ };
90
94
  }
91
95
  }
92
96
 
97
+ async function takeSnapshot(ctx: Context, label?: string): Promise<void> {
98
+ const sent = await ctx.reply(
99
+ label
100
+ ? `📸 Taking checkpoint “${escapeHtml(label)}”…`
101
+ : "📸 Taking a snapshot…",
102
+ { parse_mode: "HTML" },
103
+ );
104
+ const result = await runSnapshotForChat(label);
105
+ await ctx.api.editMessageText(ctx.chat!.id, sent.message_id, result.text, {
106
+ parse_mode: "HTML",
107
+ });
108
+ }
109
+
93
110
  async function setPinned(
94
111
  ctx: Context,
95
112
  id: string,
@@ -124,27 +141,20 @@ async function askToRestore(ctx: Context, id: string): Promise<void> {
124
141
  );
125
142
  return;
126
143
  }
127
- await ctx.reply(
128
- `♻️ <b>Restore <code>${escapeHtml(id)}</code>?</b>\n` +
129
- (manifest.label ? `“${escapeHtml(manifest.label)}”\n` : "") +
130
- `Taken ${new Date(manifest.createdAt).toISOString()}\n\n` +
131
- "This replaces config, prompts, keys, sessions, the database and memory, " +
132
- "then restarts. A pinned checkpoint of the current state is taken first.",
133
- {
134
- parse_mode: "HTML",
135
- reply_markup: {
136
- inline_keyboard: [
137
- [
138
- {
139
- text: "♻️ Restore and restart",
140
- callback_data: `backup:restore:${id}`,
141
- },
142
- { text: "Cancel", callback_data: "backup:cancel" },
143
- ],
144
+ await ctx.reply(renderRestoreConfirm(TELEGRAM_REPORTS, manifest), {
145
+ parse_mode: "HTML",
146
+ reply_markup: {
147
+ inline_keyboard: [
148
+ [
149
+ {
150
+ text: "♻️ Restore and restart",
151
+ callback_data: `backup:restore:${id}`,
152
+ },
153
+ { text: "Cancel", callback_data: "backup:cancel" },
144
154
  ],
145
- },
155
+ ],
146
156
  },
147
- );
157
+ });
148
158
  }
149
159
 
150
160
  /**
@@ -34,10 +34,11 @@ export { setAdminUserId } from "./state.js";
34
34
  export function registerCommands(
35
35
  bot: Bot,
36
36
  config: TalonConfig,
37
- gateway?: { backend: Backend | null },
37
+ gateway?: { backend: Backend | null; isListening?: () => boolean },
38
38
  ): void {
39
39
  const deps = { config, gateway };
40
- registerInfoCommands(bot);
40
+ const isListening = gateway?.isListening?.bind(gateway);
41
+ registerInfoCommands(bot, { bridgeListening: isListening });
41
42
  registerMemoryCommand(bot);
42
43
  registerSessionCommands(bot, deps);
43
44
  registerSettingsCommands(bot, deps);
@@ -16,7 +16,50 @@ import { getLoadedPlugins } from "../../../core/plugin/index.js";
16
16
  import { getMeshService } from "../../../core/mesh/index.js";
17
17
  import type { MeshPingResult } from "../../../core/mesh/devices/service.js";
18
18
 
19
- export function registerInfoCommands(bot: Bot): void {
19
+ /**
20
+ * `/ping` — Bot API round trip, plus live bridge and userbot state. The
21
+ * reply landing at all is the Bot API check; its round trip is the
22
+ * latency. The Bridge field is omitted when no health source is wired.
23
+ */
24
+ function registerPingCommand(
25
+ bot: Bot,
26
+ bridgeListening: (() => boolean) | undefined,
27
+ ): void {
28
+ bot.command("ping", async (ctx) => {
29
+ const start = Date.now();
30
+ const sent = await ctx.reply("...");
31
+ const latency = Date.now() - start;
32
+
33
+ const userbotOk = isUserClientReady();
34
+ const uptime = formatDuration(process.uptime() * 1000);
35
+
36
+ const statusLine = [
37
+ ...(bridgeListening ? [`Bridge: ${bridgeListening() ? "✓" : "✗"}`] : []),
38
+ `Userbot: ${userbotOk ? "✓" : "✗"}`,
39
+ `Uptime: ${uptime}`,
40
+ ].join(" | ");
41
+
42
+ try {
43
+ await bot.api.editMessageText(
44
+ ctx.chat.id,
45
+ sent.message_id,
46
+ `Pong! ${latency}ms\n${statusLine}`,
47
+ );
48
+ } catch {
49
+ // ignore edit failure
50
+ }
51
+ });
52
+ }
53
+
54
+ export type InfoCommandDeps = {
55
+ /** Tool-bridge health; the Bridge field is omitted when absent. */
56
+ bridgeListening?: () => boolean;
57
+ };
58
+
59
+ export function registerInfoCommands(
60
+ bot: Bot,
61
+ { bridgeListening }: InfoCommandDeps = {},
62
+ ): void {
20
63
  bot.command("start", (ctx) =>
21
64
  ctx.reply(
22
65
  [
@@ -87,31 +130,7 @@ export function registerInfoCommands(bot: Bot): void {
87
130
  ),
88
131
  );
89
132
 
90
- bot.command("ping", async (ctx) => {
91
- const start = Date.now();
92
- const sent = await ctx.reply("...");
93
- const latency = Date.now() - start;
94
-
95
- const bridgeOk = true;
96
- const userbotOk = isUserClientReady();
97
- const uptime = formatDuration(process.uptime() * 1000);
98
-
99
- const statusLine = [
100
- `Bridge: ${bridgeOk ? "✓" : "✗"}`,
101
- `Userbot: ${userbotOk ? "✓" : "✗"}`,
102
- `Uptime: ${uptime}`,
103
- ].join(" | ");
104
-
105
- try {
106
- await bot.api.editMessageText(
107
- ctx.chat.id,
108
- sent.message_id,
109
- `Pong! ${latency}ms\n${statusLine}`,
110
- );
111
- } catch {
112
- // ignore edit failure
113
- }
114
- });
133
+ registerPingCommand(bot, bridgeListening);
115
134
 
116
135
  /** True only in a 1:1 chat with the bot — never a group or channel. */
117
136
  const isPrivate = (ctx: Context): boolean => ctx.chat?.type === "private";
@@ -2,10 +2,8 @@
2
2
  * `/memory` — a read-only window on the typed memory store.
3
3
  *
4
4
  * Four shapes, all reads: the ranked listing, a full-text search, one
5
- * row's provenance (`why <id>`) and a per-kind listing. Nothing here
6
- * writes: asserting, superseding and dropping are the write path's job
7
- * (rollout PR 6), so the operator can always ask what Talon remembers
8
- * without the answer being able to change it.
5
+ * row's provenance (`why <id>`) and a per-kind listing — rendered by
6
+ * frontend/presentation/memory-report.ts, which the native bridge shares.
9
7
  *
10
8
  * Admin only, and only in a private chat. The store holds the operator's
11
9
  * private notes — people, places, health, relationships — so a read of it
@@ -13,28 +11,16 @@
13
11
  * reply, and anyone else who can reach the bot must not see it at all.
14
12
  *
15
13
  * Every line is model- or user-authored text reaching an HTML-parsed
16
- * send, so it goes through `escapeHtml` before it is joined; the reply
17
- * is chunked because a listing of 15 rows can outgrow Telegram's
18
- * 4096-char cap on its own.
14
+ * send, so the report escapes it with the HTML dialect; the reply is
15
+ * chunked because a listing of 15 rows can outgrow Telegram's 4096-char
16
+ * cap on its own.
19
17
  */
20
18
 
21
19
  import type { Bot, Context } from "grammy";
22
- import { escapeHtml } from "../formatting.js";
23
20
  import { replyHtmlChunked } from "../admin/chunked-reply.js";
21
+ import { TELEGRAM_REPORTS } from "../render/html.js";
22
+ import { renderMemoryReport } from "../../presentation/memory-report.js";
24
23
  import { isAuthorizedAdmin } from "./state.js";
25
- import {
26
- formatMemory,
27
- getMemory,
28
- isMemoryKind,
29
- listMemories,
30
- memoryHistory,
31
- searchMemories,
32
- MEMORY_KINDS,
33
- type MemoryRow,
34
- } from "../../../storage/memory.js";
35
-
36
- /** Rows per reply — a chat listing is a glance, not an export. */
37
- const LIST_LIMIT = 15;
38
24
 
39
25
  export function registerMemoryCommand(bot: Bot): void {
40
26
  bot.command("memory", async (ctx: Context) => {
@@ -48,7 +34,7 @@ export function registerMemoryCommand(bot: Bot): void {
48
34
  return;
49
35
  }
50
36
  const arg = (ctx.match ?? "").toString().trim();
51
- await replyHtmlChunked(ctx, renderMemory(arg));
37
+ await replyHtmlChunked(ctx, renderMemoryReport(arg, TELEGRAM_REPORTS));
52
38
  });
53
39
  }
54
40
 
@@ -62,73 +48,3 @@ function memoryAccess(ctx: Context): "ok" | "not-admin" | "not-private" {
62
48
  if (ctx.chat?.type !== "private") return "not-private";
63
49
  return "ok";
64
50
  }
65
-
66
- /** Route the argument to one of the four reads. Returns ready HTML. */
67
- function renderMemory(arg: string): string {
68
- const why = /^why\b\s*(.*)$/is.exec(arg);
69
- if (why) return renderWhy(why[1]!.trim());
70
- const kind = /^kind\b\s*(.*)$/is.exec(arg);
71
- if (kind) return renderKind(kind[1]!.trim());
72
- if (!arg)
73
- return renderRows(
74
- listMemories({ limit: LIST_LIMIT }),
75
- "Nothing remembered yet.",
76
- );
77
- return renderRows(
78
- searchMemories(arg, { limit: LIST_LIMIT }),
79
- `No memories matching "${arg}".`,
80
- );
81
- }
82
-
83
- /** One escaped line per row, or the (escaped) empty-case sentence. */
84
- function renderRows(rows: MemoryRow[], empty: string): string {
85
- if (rows.length === 0) return escapeHtml(empty);
86
- return rows.map((row) => escapeHtml(formatMemory(row))).join("\n");
87
- }
88
-
89
- function renderKind(kind: string): string {
90
- if (!isMemoryKind(kind))
91
- return escapeHtml(
92
- `No such kind "${kind}". Valid kinds: ${MEMORY_KINDS.join(", ")}.`,
93
- );
94
- return renderRows(
95
- listMemories({ kind, limit: LIST_LIMIT }),
96
- `Nothing remembered under ${kind}.`,
97
- );
98
- }
99
-
100
- /**
101
- * Provenance for one row: the row itself, the numbers that decide where
102
- * it ranks, and its audit trail. Reads by id rather than by the live
103
- * listing, so a superseded or dropped row still explains itself.
104
- */
105
- function renderWhy(raw: string): string {
106
- const id = Number(raw);
107
- if (!raw || !Number.isInteger(id))
108
- return escapeHtml(`No memory with id ${raw || "(none given)"}.`);
109
- const row = getMemory(id);
110
- if (!row) return escapeHtml(`No memory with id ${id}.`);
111
- const lines = [
112
- escapeHtml(formatMemory(row)),
113
- "",
114
- escapeHtml(
115
- `trust ${row.trust} · confidence ${row.confidence} · hits ${row.hitCount} · salience ${row.salience}`,
116
- ),
117
- escapeHtml(
118
- `created ${isoTime(row.createdAt)} · last seen ${isoTime(row.lastSeenAt)}`,
119
- ),
120
- ];
121
- const history = memoryHistory(row.id);
122
- if (history.length > 0) {
123
- lines.push("", "<b>History</b>");
124
- for (const entry of history) {
125
- const reason = entry.reason ? ` — ${entry.reason}` : "";
126
- lines.push(escapeHtml(`${isoTime(entry.at)} ${entry.op}${reason}`));
127
- }
128
- }
129
- return lines.join("\n");
130
- }
131
-
132
- function isoTime(ms: number): string {
133
- return new Date(ms).toISOString();
134
- }
@@ -30,7 +30,6 @@ import {
30
30
  renderEffortRows,
31
31
  renderModelMenuText,
32
32
  renderModelMenuKeyboard,
33
- type SettingsButton,
34
33
  } from "../render/menu.js";
35
34
  import {
36
35
  buildModelMenuViewForChat,
@@ -175,6 +174,17 @@ function registerEffortCommand(bot: Bot, { config, gateway }: RegisterDeps) {
175
174
  config,
176
175
  });
177
176
 
177
+ // Reset first: adaptive needs no model levels, so it must stay
178
+ // reachable on a model that registers none.
179
+ if (arg === "reset" || arg === "default" || arg === "adaptive") {
180
+ setChatEffort(cid, undefined);
181
+ await ctx.reply(
182
+ "Effort reset to <b>adaptive</b> (model decides when to think)",
183
+ { parse_mode: "HTML" },
184
+ );
185
+ return;
186
+ }
187
+
178
188
  if (reasoning.levels.length === 0) {
179
189
  const modelText = reasoning.activeModel
180
190
  ? `<code>${escapeHtml(formatModelLabel(reasoning.activeModel))}</code>`
@@ -201,15 +211,6 @@ function registerEffortCommand(bot: Bot, { config, gateway }: RegisterDeps) {
201
211
  return;
202
212
  }
203
213
 
204
- if (arg === "reset" || arg === "default" || arg === "adaptive") {
205
- setChatEffort(cid, undefined);
206
- await ctx.reply(
207
- "Effort reset to <b>adaptive</b> (model decides when to think)",
208
- { parse_mode: "HTML" },
209
- );
210
- return;
211
- }
212
-
213
214
  if (supportsReasoningLevel(arg, reasoning.levels)) {
214
215
  setChatEffort(cid, arg as EffortLevel);
215
216
  await ctx.reply(`Effort set to <b>${arg}</b>`, { parse_mode: "HTML" });
@@ -311,36 +312,6 @@ function registerSettingsCommand(bot: Bot, { config, gateway }: RegisterDeps) {
311
312
  reasoning.levels,
312
313
  );
313
314
  const pulseOn = isPulseEnabled(cid);
314
- let modelButtons: Array<SettingsButton> | undefined;
315
- let pager:
316
- | {
317
- page: number;
318
- totalPages: number;
319
- filter: "all" | "free";
320
- freeCount: number;
321
- totalCount: number;
322
- provider?: string;
323
- }
324
- | undefined;
325
- let view: "models" | "groups" = "models";
326
- let activeProvider: string | undefined;
327
- if (settingsBe?.models?.getSettingsPresentation && resolvedSettingsModel) {
328
- const pres = await settingsBe.models?.getSettingsPresentation(
329
- resolvedSettingsModel,
330
- );
331
- modelButtons = pres.modelButtons;
332
- pager = {
333
- page: pres.page,
334
- totalPages: pres.totalPages,
335
- filter: pres.filter,
336
- freeCount: pres.freeCount,
337
- totalCount: pres.totalCount,
338
- provider: pres.provider,
339
- };
340
- view = pres.view;
341
- activeProvider = pres.provider;
342
- }
343
-
344
315
  await ctx.reply(
345
316
  renderSettingsText(
346
317
  activeModel,
@@ -352,14 +323,11 @@ function registerSettingsCommand(bot: Bot, { config, gateway }: RegisterDeps) {
352
323
  parse_mode: "HTML",
353
324
  reply_markup: {
354
325
  inline_keyboard: renderSettingsKeyboard(
355
- activeModel,
326
+ resolvedSettingsModel,
356
327
  effortName,
357
328
  pulseOn,
358
- modelButtons,
359
- pager,
360
- view,
361
- activeProvider,
362
329
  reasoning.levels,
330
+ Boolean(settingsBe?.models?.getSettingsPresentation),
363
331
  ),
364
332
  },
365
333
  },
@@ -33,23 +33,27 @@ export function whatsAppPanel(): WhatsAppPanel {
33
33
  if (!provider) {
34
34
  return { text: WHATSAPP_NOT_ENABLED, keyboard: [] };
35
35
  }
36
- const linked = provider.isLinked();
37
- const text = linked
38
- ? "<b>WhatsApp</b>\n✅ Linked and connected.\n\n" +
39
- "Re-link only if the phone has unlinked this device."
40
- : "<b>WhatsApp</b>\n📴 Not linked.\n\n" +
41
- "Tap <b>Pair device</b> when you're holding the phone — I'll reply " +
42
- "with a QR to scan (WhatsApp → ⋮ → Linked devices → Link a device).";
36
+ const refresh = { text: "🔄 Refresh", callback_data: "whatsapp:refresh" };
37
+ // No pair button while linked: pairing refuses on a linked account, and
38
+ // unlinking from here would drop a working link. When the phone unlinks
39
+ // this device the connection loop parks, isLinked() turns false, and
40
+ // the button comes back on the next refresh.
41
+ if (provider.isLinked()) {
42
+ return {
43
+ text:
44
+ "<b>WhatsApp</b>\n✅ Linked and connected.\n\n" +
45
+ "To link a different phone, unlink this device on the phone " +
46
+ "first, then pair here.",
47
+ keyboard: [[refresh]],
48
+ };
49
+ }
43
50
  return {
44
- text,
51
+ text:
52
+ "<b>WhatsApp</b>\n📴 Not linked.\n\n" +
53
+ "Tap <b>Pair device</b> when you're holding the phone — I'll reply " +
54
+ "with a QR to scan (WhatsApp → ⋮ → Linked devices → Link a device).",
45
55
  keyboard: [
46
- [
47
- {
48
- text: linked ? "🔗 Re-pair" : "🔗 Pair device",
49
- callback_data: "whatsapp:pair",
50
- },
51
- { text: "🔄 Refresh", callback_data: "whatsapp:refresh" },
52
- ],
56
+ [{ text: "🔗 Pair device", callback_data: "whatsapp:pair" }, refresh],
53
57
  ],
54
58
  };
55
59
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Telegram's spelling of the /backup panel: the shared views from
3
+ * `frontend/presentation/backup-panel.ts`, rendered in HTML and mapped
4
+ * onto inline keyboards. The command (`commands/backup.ts`) sends these;
5
+ * the `backup:*` callbacks (`callbacks/backup.ts`) edit them in place.
6
+ */
7
+
8
+ import {
9
+ collectBackupStatus,
10
+ listSnapshots,
11
+ } from "../../../core/backup/index.js";
12
+ import {
13
+ GUIDE_BUTTONS,
14
+ backupPanelView,
15
+ renderRestoreGuide,
16
+ snapshotPageView,
17
+ type PanelButton,
18
+ type PanelView,
19
+ } from "../../presentation/backup-panel.js";
20
+ import { TELEGRAM_REPORTS } from "./html.js";
21
+ import type { SettingsButton } from "./menu.js";
22
+
23
+ export function inlineKeyboard(
24
+ rows: readonly PanelButton[][],
25
+ ): SettingsButton[][] {
26
+ return rows.map((row) =>
27
+ row.map((button) => ({ text: button.label, callback_data: button.data })),
28
+ );
29
+ }
30
+
31
+ /** The status panel, freshly collected. `notice` leads it (a result line). */
32
+ export async function loadStatusPanel(notice?: string): Promise<PanelView> {
33
+ return backupPanelView(
34
+ TELEGRAM_REPORTS,
35
+ await collectBackupStatus(),
36
+ Date.now(),
37
+ notice,
38
+ );
39
+ }
40
+
41
+ export async function loadSnapshotPage(page: number): Promise<PanelView> {
42
+ return snapshotPageView(TELEGRAM_REPORTS, await listSnapshots(), page);
43
+ }
44
+
45
+ export function restoreGuideView(): PanelView {
46
+ return { text: renderRestoreGuide(TELEGRAM_REPORTS), buttons: GUIDE_BUTTONS };
47
+ }
@@ -18,6 +18,7 @@ import {
18
18
  type ModelInfo,
19
19
  } from "../../../core/models/catalog.js";
20
20
  import { renderSettingsText as renderSettingsTextWith } from "../../presentation/reports.js";
21
+ import { formatModelLabel } from "../../presentation/format.js";
21
22
  import { TELEGRAM_REPORTS } from "./html.js";
22
23
 
23
24
  /** Display name for a known ModelInfo. */
@@ -440,49 +441,39 @@ export function renderModelBrowseKeyboard(
440
441
  ];
441
442
  }
442
443
 
444
+ /**
445
+ * The /settings keyboard: one button that opens the /model menu (the single
446
+ * place models are browsed and picked), the effort levels the active model
447
+ * registers, and the pulse toggle. `hasModelMenu` is false when the backend
448
+ * has no model catalog — the /model menu cannot render there, so the button
449
+ * would do nothing.
450
+ */
443
451
  export function renderSettingsKeyboard(
444
- model: string,
452
+ model: string | null,
445
453
  effort: string,
446
454
  proactive: boolean,
447
- modelButtons?: Array<SettingsButton>,
448
- pager?: SettingsPager,
449
- view: "models" | "groups" = "models",
450
- activeProvider?: string,
451
455
  reasoningLevels: readonly ReasoningEffortLevel[] = [],
456
+ hasModelMenu = true,
452
457
  ): Array<Array<SettingsButton>> {
453
- const selectedButtons = modelButtons?.length
454
- ? modelButtons
455
- : getTelegramModelOptions().map((m) => ({
456
- text: isSelectedModel(model, m.id)
457
- ? `✓ ${formatCompactModelLabel(m)}`
458
- : formatCompactModelLabel(m),
459
- callback_data: `settings:model:${m.id}`,
460
- }));
461
- const cols = modelButtons?.length ? 2 : 3;
462
- const modelRows: Array<Array<SettingsButton>> = [];
463
- for (let i = 0; i < selectedButtons.length; i += cols) {
464
- modelRows.push(selectedButtons.slice(i, i + cols));
465
- }
466
-
467
- const controlRows = pager
468
- ? renderModelPickerControlRows(
469
- pager,
470
- "settings:models",
471
- "settings:noop",
472
- view,
473
- activeProvider,
474
- )
475
- : [];
476
-
477
458
  const effortRows = renderEffortRows(
478
459
  effort,
479
460
  reasoningLevels,
480
461
  "settings:effort:",
481
462
  );
482
463
 
464
+ const modelRows: Array<Array<SettingsButton>> = hasModelMenu
465
+ ? [
466
+ [
467
+ {
468
+ text: model ? `Model: ${formatModelLabel(model)}` : "Choose model",
469
+ callback_data: "model:menu",
470
+ },
471
+ ],
472
+ ]
473
+ : [];
474
+
483
475
  return [
484
476
  ...modelRows,
485
- ...controlRows,
486
477
  ...effortRows,
487
478
  [
488
479
  {