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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/backend/agy/one-shot.ts +34 -3
- package/src/backend/codex/auth.ts +31 -2
- package/src/backend/codex/constants.ts +21 -7
- package/src/backend/codex/discovery.ts +32 -1
- package/src/backend/codex/factory.ts +3 -5
- package/src/backend/codex/handler/message.ts +7 -7
- package/src/backend/codex/models.ts +53 -11
- package/src/backend/codex/one-shot.ts +139 -37
- package/src/backend/codex/state.ts +11 -0
- package/src/core/agents/abort-reason.ts +46 -0
- package/src/core/agents/registry.ts +3 -2
- package/src/core/background/dream/index.ts +2 -2
- package/src/core/background/heartbeat/agent.ts +1 -1
- package/src/core/background/isolated-agent.ts +1 -1
- package/src/core/backup/index.ts +1 -0
- package/src/core/backup/status.ts +30 -1
- package/src/core/config/index.ts +8 -0
- package/src/core/engine/gateway.ts +5 -0
- package/src/core/mesh/credentials/store.ts +60 -9
- package/src/core/tools/bridge.ts +58 -4
- package/src/frontend/discord/callbacks/components/effort.ts +4 -4
- package/src/frontend/discord/callbacks/components/index.ts +2 -0
- package/src/frontend/discord/commands/backup-panel.ts +219 -0
- package/src/frontend/discord/commands/backup.ts +25 -34
- package/src/frontend/discord/commands/info.ts +17 -5
- package/src/frontend/discord/commands/settings.ts +12 -9
- package/src/frontend/discord/render.ts +1 -1
- package/src/frontend/native/bridge/credentials/claims.ts +5 -5
- package/src/frontend/native/bridge/routes/chats.ts +40 -20
- package/src/frontend/native/bridge/routes/host.ts +15 -2
- package/src/frontend/native/bridge/routes/table.ts +4 -0
- package/src/frontend/native/commands/admin.ts +64 -0
- package/src/frontend/native/commands/backup.ts +191 -0
- package/src/frontend/native/commands/definitions.ts +113 -0
- package/src/frontend/native/commands/format.ts +24 -0
- package/src/frontend/native/commands/index.ts +106 -0
- package/src/frontend/native/commands/info.ts +97 -0
- package/src/frontend/native/commands/session.ts +130 -0
- package/src/frontend/native/commands/types.ts +26 -0
- package/src/frontend/native/protocol.ts +18 -0
- package/src/frontend/native/surface/handlers.ts +14 -1
- package/src/frontend/native/surface/status.ts +9 -1
- package/src/frontend/native/turn/emit.ts +32 -1
- package/src/frontend/presentation/backup-panel.ts +425 -0
- package/src/frontend/presentation/memory-report.ts +109 -0
- package/src/frontend/presentation/text-commands.ts +243 -0
- package/src/frontend/telegram/admin/sessions.ts +20 -5
- package/src/frontend/telegram/admin.ts +9 -2
- package/src/frontend/telegram/callbacks/backup.ts +153 -22
- package/src/frontend/telegram/callbacks/effort.ts +5 -5
- package/src/frontend/telegram/callbacks/index.ts +2 -2
- package/src/frontend/telegram/callbacks/settings.ts +3 -40
- package/src/frontend/telegram/commands/admin.ts +8 -7
- package/src/frontend/telegram/commands/backup.ts +56 -46
- package/src/frontend/telegram/commands/index.ts +3 -2
- package/src/frontend/telegram/commands/info.ts +45 -26
- package/src/frontend/telegram/commands/memory.ts +8 -92
- package/src/frontend/telegram/commands/settings.ts +13 -45
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +19 -15
- package/src/frontend/telegram/render/backup-panel.ts +47 -0
- package/src/frontend/telegram/render/menu.ts +21 -30
- package/src/frontend/terminal/builtins/model.ts +20 -11
- package/src/frontend/whatsapp/commands.ts +16 -216
- 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 {
|
|
9
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
5
|
-
* backup:
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 {
|
|
14
|
-
import {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
await answerCallbackQuerySafe(ctx, { text: "
|
|
30
|
-
await
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
263
|
-
|
|
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);
|