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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/backend/agy/one-shot.ts +34 -3
- package/src/backend/claude-sdk/model-provider.ts +59 -12
- 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/frontend-runtime/admin-notify.ts +46 -6
- package/src/core/frontend-runtime/alerts.ts +10 -6
- package/src/core/mesh/credentials/store.ts +60 -9
- package/src/core/models/active-model.ts +57 -48
- package/src/core/plugin/loader.ts +47 -12
- 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/actions/media.ts +60 -19
- package/src/frontend/telegram/actions/messaging.ts +9 -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
|
+
}
|
|
@@ -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
|
-
/**
|
|
58
|
-
const
|
|
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
|
-
/**
|
|
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
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|
|
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
|
-
? {
|
|
180
|
-
: {
|
|
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 {
|
|
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
|
+
}
|