talon-agent 5.26.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.26.0",
3
+ "version": "5.26.1",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "The Falconry",
6
6
  "license": "Apache-2.0",
@@ -71,19 +71,68 @@ function isSelectedModel(currentModel: string, candidateId: string): boolean {
71
71
 
72
72
  // ── Public API ─────────────────────────────────────────────────────────────
73
73
 
74
- export async function resolveModel(
75
- query: string,
76
- ): Promise<UnifiedModelResolution> {
77
- const canonicalId = resolveModelId(query);
78
- const model = getModel(canonicalId);
74
+ const ONE_MILLION_SUFFIX = "[1m]";
79
75
 
80
- if (model) {
76
+ /**
77
+ * Split a `[1m]` context-variant suffix off a query ("opus[1m]" →
78
+ * { stem: "opus", oneMillion: true }). Claude Code accepts the suffix on
79
+ * any alias or id; the SDK's `supportedModels()` list does not enumerate
80
+ * the suffixed forms, so the catalog can't match them directly.
81
+ */
82
+ function splitOneMillion(query: string): { stem: string; oneMillion: boolean } {
83
+ const trimmed = query.trim();
84
+ if (trimmed.toLowerCase().endsWith(ONE_MILLION_SUFFIX)) {
81
85
  return {
82
- kind: "exact",
83
- model: toUnified(model),
84
- storedValue: model.id,
86
+ stem: trimmed.slice(0, -ONE_MILLION_SUFFIX.length).trim(),
87
+ oneMillion: true,
85
88
  };
86
89
  }
90
+ return { stem: trimmed, oneMillion: false };
91
+ }
92
+
93
+ /**
94
+ * Exact-match a query against the registry: its id/alias directly, or —
95
+ * for a `[1m]` query the registry doesn't list — the base model, returned
96
+ * as a 1M variant whose id keeps the suffix so the SDK actually selects
97
+ * the 1M context window.
98
+ *
99
+ * The SDK reports no context-window metadata per model, so there's no
100
+ * evidence to reject a `[1m]` request on; the binary is the authority.
101
+ * The one form refused is `default[1m]`: "default" is a moving target,
102
+ * not an alias Claude Code suffixes.
103
+ */
104
+ function resolveExactUnified(query: string): UnifiedModelInfo | undefined {
105
+ const direct = getModel(resolveModelId(query));
106
+ if (direct) return toUnified(direct);
107
+
108
+ const { stem, oneMillion } = splitOneMillion(query);
109
+ if (!oneMillion || !stem) return undefined;
110
+ const baseId = resolveModelId(stem);
111
+ const base = getModel(baseId);
112
+ if (!base || stem.toLowerCase() === "default") return undefined;
113
+
114
+ // Pass the SDK a form it knows: the canonical id when it's concrete, the
115
+ // user's own stem when the canonical is the "default" alias (e.g. "opus"
116
+ // folds into "default" when default currently serves Opus).
117
+ const sdkStem = baseId === "default" ? stem : baseId;
118
+ const displayName = /\(1m context\)/i.test(base.displayName)
119
+ ? base.displayName
120
+ : `${base.displayName} (1M context)`;
121
+ return {
122
+ ...toUnified(base),
123
+ id: `${sdkStem}${ONE_MILLION_SUFFIX}`,
124
+ displayName,
125
+ contextWindow: 1_000_000,
126
+ };
127
+ }
128
+
129
+ export async function resolveModel(
130
+ query: string,
131
+ ): Promise<UnifiedModelResolution> {
132
+ const exact = resolveExactUnified(query);
133
+ if (exact) {
134
+ return { kind: "exact", model: exact, storedValue: exact.id };
135
+ }
87
136
 
88
137
  // No exact match -- try a substring search across display names and aliases
89
138
  const allModels = getModels(PROVIDER_ID);
@@ -112,9 +161,7 @@ export async function resolveModel(
112
161
  export async function getModelInfo(
113
162
  id: string,
114
163
  ): Promise<UnifiedModelInfo | undefined> {
115
- const canonicalId = resolveModelId(id);
116
- const model = getModel(canonicalId);
117
- return model ? toUnified(model) : undefined;
164
+ return resolveExactUnified(id);
118
165
  }
119
166
 
120
167
  export async function getSettingsPresentation(
@@ -7,7 +7,10 @@
7
7
  * wiring, which does the same thing privately). Alerts raised before that
8
8
  * — early boot is exactly when restore reports and security alerts fire —
9
9
  * are held in a small bounded queue and flushed, oldest first, the moment
10
- * a notifier is wired. With nothing ever wired (tests, terminal mode with
10
+ * a notifier is wired. An alert carrying a key (operator alerts do) is
11
+ * deduplicated while it waits: a re-raise replaces the queued copy instead
12
+ * of queueing another, and a key that resolves before anyone could hear it
13
+ * is withdrawn outright. With nothing ever wired (tests, terminal mode with
11
14
  * no admin) they stay a log line; nothing throws.
12
15
  *
13
16
  * First consumer: WhatsApp pairing. When WhatsApp unlinks the device,
@@ -25,7 +28,14 @@ let deliver: Deliver | null = null;
25
28
  /** Most alerts held while no notifier is wired; the oldest are dropped past it. */
26
29
  export const ADMIN_NOTIFY_QUEUE_MAX = 20;
27
30
 
28
- type Pending = { text: string; at: number };
31
+ type Pending = {
32
+ text: string;
33
+ at: number;
34
+ /** Dedup key (operator alert key); unkeyed alerts never coalesce. */
35
+ key?: string;
36
+ /** Re-raises folded into this entry while it waited. */
37
+ repeats: number;
38
+ };
29
39
  const pending: Pending[] = [];
30
40
  let droppedWhileUnwired = 0;
31
41
  let flushing: Promise<void> | null = null;
@@ -68,6 +78,7 @@ async function flushPending(fn: Deliver): Promise<void> {
68
78
  batch.unshift({
69
79
  text: `${dropped} earlier admin alert(s) were dropped before a notifier was wired (queue holds ${ADMIN_NOTIFY_QUEUE_MAX}); see the daemon log.`,
70
80
  at: Date.now(),
81
+ repeats: 0,
71
82
  });
72
83
  }
73
84
  log("notify", `Flushing ${batch.length} queued admin alert(s)`);
@@ -79,7 +90,10 @@ async function flushPending(fn: Deliver): Promise<void> {
79
90
  continue;
80
91
  }
81
92
  const ageS = Math.round((Date.now() - item.at) / 1000);
82
- const text = ageS >= 5 ? `(delayed ${ageS}s) ${item.text}` : item.text;
93
+ const repeated =
94
+ item.repeats > 0 ? `\n(raised ${item.repeats + 1}× while starting)` : "";
95
+ const text =
96
+ (ageS >= 5 ? `(delayed ${ageS}s) ${item.text}` : item.text) + repeated;
83
97
  try {
84
98
  await fn(text);
85
99
  log("notify", `Admin notified (queued): ${preview(item.text)}`);
@@ -95,6 +109,17 @@ async function flushPending(fn: Deliver): Promise<void> {
95
109
  }
96
110
 
97
111
  function enqueue(item: Pending): void {
112
+ if (item.key !== undefined) {
113
+ const i = pending.findIndex((p) => p.key === item.key);
114
+ if (i >= 0) {
115
+ // Same fault raised again while waiting: keep one entry, newest text,
116
+ // original timestamp (so the "delayed" note stays honest).
117
+ const prior = pending[i];
118
+ prior.text = item.text;
119
+ prior.repeats += item.repeats + 1;
120
+ return;
121
+ }
122
+ }
98
123
  pending.push(item);
99
124
  while (pending.length > ADMIN_NOTIFY_QUEUE_MAX) {
100
125
  const lost = pending.shift();
@@ -111,14 +136,29 @@ function preview(text: string): string {
111
136
  return text.slice(0, 80).replace(/\n/g, " ");
112
137
  }
113
138
 
139
+ /**
140
+ * Withdraw a queued, not-yet-delivered alert by key (its fault cleared
141
+ * before a notifier was wired). Returns whether one was withdrawn.
142
+ */
143
+ export function withdrawAdminNotification(key: string): boolean {
144
+ const i = pending.findIndex((p) => p.key === key);
145
+ if (i < 0) return false;
146
+ const [gone] = pending.splice(i, 1);
147
+ log("notify", `Withdrew queued admin alert ${key}: ${preview(gone.text)}`);
148
+ return true;
149
+ }
150
+
114
151
  /**
115
152
  * Send `text` to the admin chat. Never throws; returns whether it was
116
153
  * delivered now (false = failed, or queued because no notifier is wired
117
- * yet — it is sent when one is).
154
+ * yet — it is sent when one is). `key` deduplicates while queued.
118
155
  */
119
- export async function notifyAdmin(text: string): Promise<boolean> {
156
+ export async function notifyAdmin(
157
+ text: string,
158
+ key?: string,
159
+ ): Promise<boolean> {
120
160
  if (!deliver) {
121
- enqueue({ text, at: Date.now() });
161
+ enqueue({ text, at: Date.now(), key, repeats: 0 });
122
162
  logWarn(
123
163
  "notify",
124
164
  `No admin notifier wired yet; queued (${pending.length}/${ADMIN_NOTIFY_QUEUE_MAX}): ${text.slice(0, 120)}`,
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { log, logWarn } from "../../util/log.js";
18
- import { notifyAdmin } from "./admin-notify.js";
18
+ import { notifyAdmin, withdrawAdminNotification } from "./admin-notify.js";
19
19
 
20
20
  export type AlertSeverity = "warn" | "error" | "critical";
21
21
 
@@ -39,7 +39,8 @@ const RANK: Record<AlertSeverity, number> = { warn: 0, error: 1, critical: 2 };
39
39
  const active = new Map<string, ActiveAlert>();
40
40
  let cooldownMs = DEFAULT_COOLDOWN_MS;
41
41
  let enabled = true;
42
- let send: (text: string) => Promise<unknown> = notifyAdmin;
42
+ type Send = (text: string, key?: string) => Promise<unknown>;
43
+ let send: Send = notifyAdmin;
43
44
 
44
45
  /** Apply operator settings (config `alerts`). */
45
46
  export function configureAlerts(opts: {
@@ -86,7 +87,9 @@ export function raiseAlert(
86
87
  // Nobody heard it: don't let the cooldown swallow the next raise.
87
88
  if (active.get(key) === entry) entry.lastSentAt = 0;
88
89
  };
89
- void send(`${ICON[severity]} ${message}${repeat}`).then((ok) => {
90
+ // The key lets a still-queued copy (no notifier wired yet) be replaced by
91
+ // this raise rather than queued twice.
92
+ void send(`${ICON[severity]} ${message}${repeat}`, key).then((ok) => {
90
93
  if (ok === false) undelivered();
91
94
  }, undelivered);
92
95
  }
@@ -99,6 +102,9 @@ export function resolveAlert(key: string, message?: string): void {
99
102
  const mins = Math.max(1, Math.round((Date.now() - prior.firstAt) / 60_000));
100
103
  log("alert", `resolved ${key} after ${mins} min`);
101
104
  if (!enabled) return;
105
+ // Still queued for a notifier that never got to send it: withdraw it, and
106
+ // there is nothing to announce a recovery from.
107
+ if (withdrawAdminNotification(key)) return;
102
108
  void send(`✅ ${message ?? `Recovered: ${key}`} (after ${mins} min)`).catch(
103
109
  () => {},
104
110
  );
@@ -120,9 +126,7 @@ export function activeAlerts(): ReadonlyArray<{
120
126
  }
121
127
 
122
128
  /** Test seam: reset state and swap the delivery function. */
123
- export function resetAlertsForTest(
124
- deliver: (text: string) => Promise<unknown> = notifyAdmin,
125
- ): void {
129
+ export function resetAlertsForTest(deliver: Send = notifyAdmin): void {
126
130
  active.clear();
127
131
  cooldownMs = DEFAULT_COOLDOWN_MS;
128
132
  enabled = true;
@@ -36,19 +36,20 @@
36
36
  * 1. `modelByBackend[B]` — per-chat-per-backend pick, if it still
37
37
  * validates against B's catalog. Cross-backend orphans surface
38
38
  * as `kind: "missing"` and fall through.
39
- * 2. `backend.models?.getDefaultModelId()` — backend's canonical default.
39
+ * 2. `config.backendDefaults[B]` — operator-configured per-backend
40
+ * default in `talon.json`.
41
+ * 3. `config.model` — only when B is the global chat-role backend
42
+ * (`config.backend === B`).
43
+ * Operator picks (2/3) must validate against B's catalog when B
44
+ * has a canonical default to fall back to; an unknown pin falls
45
+ * through to step 4 (what the boot-time model audit warns about).
46
+ * 4. `backend.models?.getDefaultModelId()` — backend's canonical default.
40
47
  * Codex picks auth-aware (`gpt-5-codex` on API key, `gpt-5.5`
41
48
  * on ChatGPT OAuth). Claude SDK returns the `"default"` alias.
42
49
  * Stock OpenAI Agents returns a constant.
43
50
  * Catalog-driven backends without a canonical (Kilo, OpenCode,
44
51
  * OpenAI Agents on OpenRouter / custom OpenAI-compatible) do
45
- * NOT implement this — they fall through to step 3.
46
- * 3. `config.backendDefaults[B]` — operator-configured per-backend
47
- * default in `talon.json`. Escape hatch for "no canonical"
48
- * backends.
49
- * 4. `config.model` — only when B is the global chat-role backend
50
- * (`config.backend === B`). Back-compat for installs that
51
- * predate `backendDefaults`.
52
+ * NOT implement this.
52
53
  * 5. `null` → UI renders "No model selected", send guard refuses
53
54
  * with a "use /model to pick one" reply.
54
55
  *
@@ -56,6 +57,10 @@
56
57
  * is called. Only `kind: "exact"` with `selectable: true` honours the
57
58
  * stored override. Anything else falls through to step 2.
58
59
  *
60
+ * Operator config ranks above the canonical so a pinned `config.model`
61
+ * is actually honoured (it used to lose to Claude's `"default"`, so a
62
+ * claude install pinned to any model silently ran the default).
63
+ *
59
64
  * Backends with no `resolveModel` (rare — defensive fallback only)
60
65
  * have their stored override returned verbatim — no way to validate.
61
66
  */
@@ -157,8 +162,8 @@ async function runChain(
157
162
  }
158
163
  }
159
164
 
160
- // ── Step 2-5: backend canonical → config.backendDefaults →
161
- // config.model (chat-role only) → null
165
+ // ── Steps 2-5: backendDefaults → config.model (chat-role only) →
166
+ // backend canonical → null
162
167
  return stepsTwoThroughFive(backend, backendId, config, null);
163
168
  }
164
169
 
@@ -196,57 +201,61 @@ async function stepsTwoThroughFive(
196
201
  config: TalonConfig,
197
202
  fallbackSourceOverride: "override-invalid-fallback" | null,
198
203
  ): Promise<{ model: string | null; source: ActiveModelSource }> {
199
- // Step 2: backend.models.getDefaultModelId()
200
- if (backend?.models) {
201
- const canonical = await safeBackendDefault(backend);
202
- if (canonical) {
203
- return {
204
- model: canonical,
205
- source: fallbackSourceOverride ?? "backend-canonical",
206
- };
204
+ const withSource = (model: string, source: ActiveModelSource) => ({
205
+ model,
206
+ source: fallbackSourceOverride ?? source,
207
+ });
208
+
209
+ const operatorPick = pickOperatorDefault(backendId, config);
210
+ const canonical = backend?.models ? await safeBackendDefault(backend) : null;
211
+
212
+ // Operator config (steps 2/3) beats the backend canonical (step 4): a
213
+ // pinned `config.model` is an explicit choice, and letting the canonical
214
+ // win meant a claude install pinned to e.g. "opus[1m]" silently ran
215
+ // "default" on every turn. The pin must still validate — a withdrawn id
216
+ // falls back to the canonical, exactly what the boot-time model audit
217
+ // warns about. Without a canonical to fall back to, the pin is returned
218
+ // unvalidated (catalog-driven backends with no default, unchanged).
219
+ if (operatorPick) {
220
+ if (!canonical) return withSource(operatorPick.model, operatorPick.source);
221
+ if (await validateModelOnBackend(backend, operatorPick.model)) {
222
+ return withSource(operatorPick.model, operatorPick.source);
207
223
  }
208
224
  }
209
225
 
210
- // Step 3: config.backendDefaults[backendId]
226
+ // Step 4: backend.models.getDefaultModelId()
227
+ if (canonical) return withSource(canonical, "backend-canonical");
228
+
229
+ // Step 5: null. Callers must render "No model selected" / refuse send.
230
+ return { model: null, source: "none" };
231
+ }
232
+
233
+ /**
234
+ * The operator-configured default for a backend, if any:
235
+ * - `config.backendDefaults[B]`;
236
+ * - else `config.model` — only when B is the global chat-role backend
237
+ * (`config.backend === B`), or when no backend id is known at all
238
+ * (pre-bootstrap callers passing null).
239
+ */
240
+ function pickOperatorDefault(
241
+ backendId: string | null,
242
+ config: TalonConfig,
243
+ ): { model: string; source: ActiveModelSource } | null {
211
244
  if (backendId && config.backendDefaults) {
212
245
  const operatorDefault = config.backendDefaults[backendId];
213
246
  if (operatorDefault && operatorDefault.length > 0) {
214
- return {
215
- model: operatorDefault,
216
- source: fallbackSourceOverride ?? "config-backend-defaults",
217
- };
247
+ return { model: operatorDefault, source: "config-backend-defaults" };
218
248
  }
219
249
  }
220
-
221
- // Step 4: legacy config.model — only for the global chat-role backend
250
+ const modelAppliesHere = !backendId || backendId === config.backend;
222
251
  if (
223
- backendId &&
224
- backendId === config.backend &&
252
+ modelAppliesHere &&
225
253
  typeof config.model === "string" &&
226
254
  config.model.length > 0
227
255
  ) {
228
- return {
229
- model: config.model,
230
- source: fallbackSourceOverride ?? "config-legacy-global",
231
- };
256
+ return { model: config.model, source: "config-legacy-global" };
232
257
  }
233
-
234
- // Step 4b: even without a backendId, honour config.model if no
235
- // backend is bound at all (callers passing null for both — rare,
236
- // typically pre-bootstrap code paths).
237
- if (
238
- !backendId &&
239
- typeof config.model === "string" &&
240
- config.model.length > 0
241
- ) {
242
- return {
243
- model: config.model,
244
- source: fallbackSourceOverride ?? "config-legacy-global",
245
- };
246
- }
247
-
248
- // Step 5: null. Callers must render "No model selected" / refuse send.
249
- return { model: null, source: "none" };
258
+ return null;
250
259
  }
251
260
 
252
261
  async function validateModelOnBackend(
@@ -98,6 +98,15 @@ function registerPluginInstance(
98
98
  return loaded;
99
99
  }
100
100
 
101
+ /**
102
+ * Run a plugin's init, waiting at most `timeoutMs` for it before boot moves
103
+ * on. The deadline bounds how long boot waits, not the init itself: a
104
+ * plugin stays registered either way (its tools are served from
105
+ * `mcpServer` whether or not init finished), so an init that outlives the
106
+ * deadline keeps running and, when it does finish, clears the alert the
107
+ * timeout raised — a slow handshake on a busy boot is a delay, not a
108
+ * failure that lingers until the next restart.
109
+ */
101
110
  export async function initPluginWithTimeout(
102
111
  plugin: TalonPlugin,
103
112
  config: Record<string, unknown>,
@@ -108,28 +117,54 @@ export async function initPluginWithTimeout(
108
117
  if (!plugin.init) return;
109
118
 
110
119
  let timer: ReturnType<typeof setTimeout> | undefined;
111
-
112
120
  const alertKey = `plugin.${plugin.name}`;
121
+ const startedAt = Date.now();
122
+ // Started (and timed) only here, when this plugin's own init begins.
123
+ const init = Promise.resolve().then(() => plugin.init!(config));
124
+ const TIMED_OUT = Symbol("timed out");
125
+
113
126
  try {
114
- await Promise.race([
115
- Promise.resolve(plugin.init(config)),
116
- new Promise<never>((_, reject) => {
117
- timer = setTimeout(() => {
118
- reject(
119
- new Error(`${timeoutLabel} timed out after ${timeoutMs / 1000}s`),
120
- );
121
- }, timeoutMs);
127
+ const outcome = await Promise.race([
128
+ init,
129
+ new Promise<typeof TIMED_OUT>((settle) => {
130
+ timer = setTimeout(() => settle(TIMED_OUT), timeoutMs);
122
131
  timer.unref?.();
123
132
  }),
124
133
  ]);
125
- resolveAlert(alertKey, `Plugin "${plugin.name}" initialised normally.`);
134
+ if (outcome !== TIMED_OUT) {
135
+ resolveAlert(alertKey, `Plugin "${plugin.name}" initialised normally.`);
136
+ return;
137
+ }
138
+ const message = `${timeoutLabel} timed out after ${timeoutMs / 1000}s`;
139
+ logError("plugin", `${errorPrefix}: ${message}; still waiting for it`);
140
+ raiseAlert(
141
+ alertKey,
142
+ `Plugin "${plugin.name}" failed to initialise: ${message}. Its tools stay registered; this clears itself if init finishes late.`,
143
+ );
144
+ void init.then(
145
+ () => {
146
+ // Reloaded or unloaded meanwhile: this instance's verdict is moot.
147
+ if (registry.getByName(plugin.name)?.plugin !== plugin) return;
148
+ const took = Math.round((Date.now() - startedAt) / 1000);
149
+ log("plugin", `${plugin.name} init finished late (${took}s)`);
150
+ resolveAlert(
151
+ alertKey,
152
+ `Plugin "${plugin.name}" finished initialising late (${took}s).`,
153
+ );
154
+ },
155
+ (err: unknown) =>
156
+ logError(
157
+ "plugin",
158
+ `${errorPrefix} (after timing out): ${err instanceof Error ? err.message : err}`,
159
+ ),
160
+ );
126
161
  } catch (err) {
127
162
  logError(
128
163
  "plugin",
129
164
  `${errorPrefix}: ${err instanceof Error ? err.message : err}`,
130
165
  );
131
- // Init runs once per boot or reload: a plugin that failed it stays
132
- // half-loaded until someone fixes it, so this needs no threshold.
166
+ // An init that threw won't retry on its own: it needs a reload or
167
+ // restart, so this needs no threshold.
133
168
  raiseAlert(
134
169
  alertKey,
135
170
  `Plugin "${plugin.name}" failed to initialise: ${faultText(err)}. Its tools may not work until the next reload or restart.`,
@@ -54,8 +54,8 @@ export function resolveMediaInput(
54
54
 
55
55
  /** Telegram's hard limit on media captions, counted after entity parsing. */
56
56
  export const TELEGRAM_MAX_CAPTION = 1024;
57
- /** Visible length a truncated caption is cut to, leaving room for the "…". */
58
- const TRUNCATED_CAPTION_LEN = 1000;
57
+ /** Below this head budget the splitter is fighting pathological markup; give up. */
58
+ const MIN_CAPTION_HEAD = 64;
59
59
 
60
60
  /**
61
61
  * Drop every `<...>` tag in one linear pass. Only used to *measure* the
@@ -104,35 +104,76 @@ export function visibleCaptionText(html: string): string {
104
104
  export type FittedCaption = {
105
105
  caption?: string;
106
106
  parse_mode?: "HTML";
107
- /** Full caption text to deliver as a follow-up message when it was cut. */
107
+ /** Caption text that did not fit, to deliver as follow-up message(s). */
108
108
  overflow?: string;
109
109
  };
110
110
 
111
+ /** Visible (Telegram-counted) length of a markdown caption once rendered. */
112
+ export function captionUnits(markdown: string): number {
113
+ // Telegram counts UTF-16 code units, which is what .length measures.
114
+ return visibleCaptionText(markdownToTelegramHtml(markdown)).length;
115
+ }
116
+
117
+ /**
118
+ * Split a markdown caption into the leading part that fits `max` visible
119
+ * units and the remainder. Splits the *markdown source* with the shared
120
+ * message splitter (paragraph → newline → space boundaries, surrogate-safe,
121
+ * ``` fences closed/reopened), so the head is rendered to HTML on its own
122
+ * and can never strand a tag or entity. Returns null if no clean head fits.
123
+ */
124
+ export function splitCaption(
125
+ text: string,
126
+ max: number = TELEGRAM_MAX_CAPTION,
127
+ ): { head: string; rest: string } | null {
128
+ // Rendering usually shrinks markdown (markers and link URLs vanish), so
129
+ // the first try nearly always fits; shrink the budget if it does not.
130
+ for (let budget = max; budget >= MIN_CAPTION_HEAD;) {
131
+ const chunks = splitMessage(text, budget);
132
+ const head = chunks[0] ?? "";
133
+ if (head.trim() && captionUnits(head) <= max) {
134
+ const at = text.indexOf(head);
135
+ // The splitter only trims at boundaries, so the head is normally a
136
+ // verbatim prefix; when it closed a ``` fence it is not, and the
137
+ // splitter's own reopened chunks carry the remainder instead.
138
+ const rest =
139
+ at >= 0
140
+ ? text.slice(at + head.length).replace(/^\s+/, "")
141
+ : chunks.slice(1).join("\n\n");
142
+ return { head, rest };
143
+ }
144
+ budget = Math.floor(budget * 0.8);
145
+ }
146
+ return null;
147
+ }
148
+
111
149
  /**
112
150
  * Convert a markdown caption to what Telegram accepts. Captions over 1024
113
151
  * visible characters are rejected outright ("message caption is too long"),
114
- * losing the media along with them — so an oversized caption is cut to a
115
- * plain-text preview (no parse_mode: a cut through HTML could strand a tag
116
- * or entity) and the full text is returned as `overflow` for the caller to
117
- * send as a normal, chunked text message.
152
+ * losing the media along with them — so an oversized caption is split: the
153
+ * leading part that fits rides on the media (still formatted), and the rest
154
+ * is returned as `overflow` for the caller to send as follow-up text.
118
155
  */
119
156
  export function fitCaption(raw: unknown): FittedCaption {
120
157
  if (!raw) return {};
121
158
  const text = String(raw);
122
159
  const html = markdownToTelegramHtml(text);
123
- const visible = visibleCaptionText(html);
124
- // Telegram counts UTF-16 code units, which is what .length measures.
125
- if (visible.length <= TELEGRAM_MAX_CAPTION)
160
+ if (visibleCaptionText(html).length <= TELEGRAM_MAX_CAPTION)
126
161
  return { caption: html, parse_mode: "HTML" };
127
- let cut = visible.slice(0, TRUNCATED_CAPTION_LEN);
128
- // Don't strand half a surrogate pair at the cut.
129
- const last = cut.charCodeAt(cut.length - 1);
130
- if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1);
131
- return { caption: `${cut.trimEnd()}…`, overflow: text };
162
+ const split = splitCaption(text);
163
+ if (split) {
164
+ return {
165
+ caption: markdownToTelegramHtml(split.head),
166
+ parse_mode: "HTML",
167
+ ...(split.rest ? { overflow: split.rest } : {}),
168
+ };
169
+ }
170
+ // No clean boundary fits (pathological markup): send the media bare and
171
+ // deliver the whole caption as text rather than cut through it.
172
+ return { overflow: text };
132
173
  }
133
174
 
134
175
  /**
135
- * Deliver the full text of a caption that did not fit, threaded as a reply
176
+ * Deliver the part of a caption that did not fit, threaded as a reply
136
177
  * to the media it belongs to. Best-effort: the media already landed, so a
137
178
  * failure here is reported as a warning rather than failing the send.
138
179
  */
@@ -167,7 +208,7 @@ async function sendCaptionOverflow(
167
208
  `Caption overflow follow-up failed (chat=${chatId}): ${msg}`,
168
209
  );
169
210
  return {
170
- warning: `Media sent with a truncated caption, but sending the full caption text failed: ${msg}`,
211
+ warning: `Media sent, but the rest of its caption (past Telegram's ${TELEGRAM_MAX_CAPTION}-char limit) failed to send: ${msg}`,
171
212
  };
172
213
  }
173
214
  }
@@ -176,8 +217,8 @@ function overflowResult(
176
217
  r: { message_ids: number[] } | { warning: string },
177
218
  ): Record<string, unknown> {
178
219
  return "warning" in r
179
- ? { caption_truncated: true, warning: r.warning }
180
- : { caption_truncated: true, caption_message_ids: r.message_ids };
220
+ ? { caption_split: true, warning: r.warning }
221
+ : { caption_split: true, caption_message_ids: r.message_ids };
181
222
  }
182
223
 
183
224
  const sendMediaFile: TelegramActionHandlers[string] = async (
@@ -21,6 +21,7 @@ import {
21
21
  richMessagesAvailable,
22
22
  } from "./rich-messages.js";
23
23
  import { toPositiveId } from "./coerce.js";
24
+ import { captionUnits, TELEGRAM_MAX_CAPTION } from "./media.js";
24
25
  import { resolveThreadId } from "../topics.js";
25
26
  import { TELEGRAM_MAX_TEXT, type TelegramActionHandlers } from "./types.js";
26
27
 
@@ -260,6 +261,14 @@ export const messagingHandlers: TelegramActionHandlers = {
260
261
  // Media messages have captions, not text — editMessageText on them fails
261
262
  // with "there is no text in the message to edit".
262
263
  if (body.is_caption === true) {
264
+ // An edit cannot spill into a follow-up message the way a send does,
265
+ // so refuse up front with guidance rather than a raw Bot API 400.
266
+ const units = captionUnits(text);
267
+ if (units > TELEGRAM_MAX_CAPTION)
268
+ return {
269
+ ok: false,
270
+ error: `Caption too long (${units} chars, max ${TELEGRAM_MAX_CAPTION}) — shorten it, or send the rest as a separate message`,
271
+ };
263
272
  await withRetry(async () => {
264
273
  try {
265
274
  await bot.api.editMessageCaption(chatId, Number(body.message_id), {