talon-agent 3.21.0 → 3.22.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": "3.21.0",
3
+ "version": "3.22.1",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -113,7 +113,7 @@
113
113
  "cross-spawn": "^7.0.6",
114
114
  "discord.js": "^14.16.3",
115
115
  "file-type": "^22.0.1",
116
- "grammy": "^1.42.0",
116
+ "grammy": "^1.45.1",
117
117
  "liquidjs": "^10.27.0",
118
118
  "marked": "^18.0.0",
119
119
  "mem0ai": "^3.0.13",
package/src/app.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import { getFrontends } from "./util/config.js";
10
10
  import { startUploadCleanup, stopUploadCleanup } from "./util/workspace.js";
11
11
  import { flushDatabase } from "./storage/db.js";
12
- import { getActiveCount } from "./core/engine/dispatcher.js";
12
+ import { getActiveCount, stopAllTurns } from "./core/engine/dispatcher.js";
13
13
  import { startPulseTimer, stopPulseTimer } from "./core/background/pulse.js";
14
14
  import { stopPlanAlerts } from "./core/background/plan-alerts.js";
15
15
  import {
@@ -121,6 +121,7 @@ async function gracefulShutdown(signal: string): Promise<void> {
121
121
  shuttingDown = true;
122
122
  log("shutdown", `${signal} received, shutting down gracefully...`);
123
123
 
124
+ const deadlineAt = Date.now() + SHUTDOWN_TIMEOUT_MS;
124
125
  const forceTimer = setTimeout(() => {
125
126
  logError("shutdown", "Timeout exceeded, forcing exit");
126
127
  // Hand off even on the forced path. A restart must survive a
@@ -135,12 +136,17 @@ async function gracefulShutdown(signal: string): Promise<void> {
135
136
  }, SHUTDOWN_TIMEOUT_MS);
136
137
  forceTimer.unref();
137
138
 
138
- // Drain in-flight queries: poll instead of a fixed sleep, so an idle
139
- // daemon exits immediately and a busy one gets the full budget.
139
+ // Drain in-flight queries. A turn can legitimately run for minutes, so a
140
+ // drain that only waits can never succeed against one — ask every running
141
+ // turn to abort first, then poll for the aborts to settle so backends can
142
+ // flush partial state before the process exits.
140
143
  if (getActiveCount() > 0) {
144
+ const aborted = stopAllTurns();
141
145
  log(
142
146
  "shutdown",
143
- `Waiting for ${getActiveCount()} in-flight queries to drain...`,
147
+ `Waiting for ${getActiveCount()} in-flight queries to drain` +
148
+ (aborted > 0 ? ` (abort requested for ${aborted})` : "") +
149
+ `...`,
144
150
  );
145
151
  const deadline = Date.now() + DRAIN_TIMEOUT_MS;
146
152
  while (getActiveCount() > 0 && Date.now() < deadline) {
@@ -177,7 +183,11 @@ async function gracefulShutdown(signal: string): Promise<void> {
177
183
  await shutdownStep("pulse timer", stopPulseTimer);
178
184
  await shutdownStep("heartbeat", async () => {
179
185
  stopHeartbeatTimer();
180
- await awaitHeartbeat();
186
+ // Cap the wait by what's left of the force-timer budget (minus margin
187
+ // for the steps below). The default 10s wait plus a full 5s drain used
188
+ // to consume the entire 15s budget, so a slow heartbeat tripped the
189
+ // forced exit even though teardown was proceeding normally.
190
+ await awaitHeartbeat(Math.max(0, deadlineAt - Date.now() - 3_000));
181
191
  });
182
192
  await shutdownStep("cron timer", stopCronTimer);
183
193
  await shutdownStep("plan alerts", stopPlanAlerts);
@@ -67,8 +67,9 @@ import {
67
67
  recordFailedTurnAccounting,
68
68
  recordFlowViolation,
69
69
  formatTurnCache,
70
- exceedsLookbackWindow,
71
- estimateTurnBlocks,
70
+ crossTurnVerdict,
71
+ priorLookbackOverflow,
72
+ noteLookbackRisk,
72
73
  CACHE_LOOKBACK_BLOCKS,
73
74
  } from "../shared/index.js";
74
75
 
@@ -594,16 +595,24 @@ export async function* runChatTurn(
594
595
 
595
596
  // The aggregate `cache=NN%` can't distinguish a turn that reused the
596
597
  // previous turn's prefix from one that re-wrote it — see
597
- // shared/cache-telemetry.ts. Append the cross-turn verdict when the
598
- // provider gave us per-request usage to derive it from.
599
- if (state.cacheStats && exceedsLookbackWindow(state.toolCalls)) {
600
- logWarn(
601
- "agent",
602
- `[${chatId}] turn emitted ~${estimateTurnBlocks(state.toolCalls)} content ` +
603
- `blocks (> ${CACHE_LOOKBACK_BLOCKS} lookback) — the next turn's cache ` +
604
- `breakpoint may not find this turn's prefix`,
605
- );
598
+ // shared/cache-telemetry.ts. A lookback overflow only *predicts* a miss,
599
+ // so warn when this turn's verdict proves the previous turn's overflow
600
+ // cost a prefix re-write, then record this turn's overflow for the next.
601
+ if (state.cacheStats) {
602
+ const overflow = priorLookbackOverflow(chatId);
603
+ if (
604
+ overflow !== undefined &&
605
+ crossTurnVerdict(state.cacheStats) === "miss"
606
+ ) {
607
+ logWarn(
608
+ "agent",
609
+ `[${chatId}] previous turn emitted ~${overflow} content blocks ` +
610
+ `(> ${CACHE_LOOKBACK_BLOCKS} lookback) and this turn's prefix ` +
611
+ `missed — that turn's cache write was likely never read`,
612
+ );
613
+ }
606
614
  }
615
+ noteLookbackRisk(chatId, state.toolCalls);
607
616
 
608
617
  log(
609
618
  "agent",
@@ -29,7 +29,7 @@
29
29
  * registrations run concurrently through the hub; later turns skip them.
30
30
  */
31
31
 
32
- import { log, logWarn } from "../../util/log.js";
32
+ import { log, logDebug, logWarn } from "../../util/log.js";
33
33
  import {
34
34
  talonHubUrl,
35
35
  pluginHubUrl,
@@ -62,6 +62,9 @@ export const TALON_PLUGIN_MCP_SERVER_NAME = "talon-plugin";
62
62
  */
63
63
  const SLOW_MCP_REGISTRATION_MS = 1000;
64
64
 
65
+ /** Servers whose registration failure was already warned about (see below). */
66
+ const warnedMcpRegistrationFailures = new Set<string>();
67
+
65
68
  /**
66
69
  * OpenCode's `/experimental/tool/ids` endpoint omits dynamically registered
67
70
  * MCP tools despite its API description. Synthesize Talon's known MCP ids so
@@ -253,6 +256,7 @@ export async function ensurePluginMcpServers<TClient extends RemoteAgentClient>(
253
256
  state.registeredMcpServers.add(serverName);
254
257
  state.registeredMcpTools.set(serverName, toolNames);
255
258
  byPlugin.set(name, serverName);
259
+ warnedMcpRegistrationFailures.delete(serverName);
256
260
  const ms = Date.now() - startedAt;
257
261
  log(
258
262
  "agent",
@@ -261,7 +265,16 @@ export async function ensurePluginMcpServers<TClient extends RemoteAgentClient>(
261
265
  );
262
266
  return serverName;
263
267
  } catch (err) {
264
- logWarn(
268
+ // Warn once per server, then debug: registration re-runs at the
269
+ // head of every turn, so a dead backing service (an offline
270
+ // browser endpoint, say) would otherwise emit one warning per
271
+ // turn for the whole outage. Success clears the latch so the
272
+ // next outage warns again.
273
+ const level = warnedMcpRegistrationFailures.has(serverName)
274
+ ? logDebug
275
+ : logWarn;
276
+ warnedMcpRegistrationFailures.add(serverName);
277
+ level(
265
278
  "agent",
266
279
  `Plugin MCP registration failed for ${serverName}: ${errMsg(err)}`,
267
280
  );
@@ -154,6 +154,48 @@ export function exceedsLookbackWindow(toolCalls: number): boolean {
154
154
  return estimateTurnBlocks(toolCalls) > CACHE_LOOKBACK_BLOCKS;
155
155
  }
156
156
 
157
+ /**
158
+ * Per-chat estimated block count of the last turn that overflowed the
159
+ * lookback window. Overflow is only a *prediction* of a cache miss — the
160
+ * proof is the NEXT turn's cross-turn verdict, so the overflow is recorded
161
+ * here and the warning waits for that verdict instead of firing on every
162
+ * tool-heavy turn. Bounded like `lastToolSets` below.
163
+ */
164
+ const lookbackOverflows = new Map<string, number>();
165
+
166
+ /**
167
+ * Record whether this turn plausibly overflowed the lookback window, so the
168
+ * next turn can attribute a cross-turn miss to it.
169
+ */
170
+ export function noteLookbackRisk(chatId: string, toolCalls: number): void {
171
+ if (!exceedsLookbackWindow(toolCalls)) {
172
+ lookbackOverflows.delete(chatId);
173
+ return;
174
+ }
175
+ if (
176
+ lookbackOverflows.size >= MAX_TRACKED_CHATS &&
177
+ !lookbackOverflows.has(chatId)
178
+ ) {
179
+ const oldest = lookbackOverflows.keys().next().value;
180
+ if (oldest !== undefined) lookbackOverflows.delete(oldest);
181
+ }
182
+ lookbackOverflows.set(chatId, estimateTurnBlocks(toolCalls));
183
+ }
184
+
185
+ /**
186
+ * Estimated block count of the chat's previous turn IF it overflowed the
187
+ * lookback window, else undefined. Read this before `noteLookbackRisk`
188
+ * records the current turn.
189
+ */
190
+ export function priorLookbackOverflow(chatId: string): number | undefined {
191
+ return lookbackOverflows.get(chatId);
192
+ }
193
+
194
+ /** Drop all recorded overflows (tests / explicit reset). */
195
+ export function resetLookbackRisk(): void {
196
+ lookbackOverflows.clear();
197
+ }
198
+
157
199
  // ── Cacheable minimum ──────────────────────────────────────────────────────
158
200
 
159
201
  /**
@@ -73,8 +73,9 @@ export {
73
73
  // barrel discipline the prompt/ barrel was just trimmed to.
74
74
  export {
75
75
  formatTurnCache,
76
- estimateTurnBlocks,
77
- exceedsLookbackWindow,
76
+ crossTurnVerdict,
77
+ priorLookbackOverflow,
78
+ noteLookbackRisk,
78
79
  CACHE_LOOKBACK_BLOCKS,
79
80
  } from "./cache-telemetry.js";
80
81
 
@@ -66,6 +66,16 @@ export function stopCurrentTurn(chatId: string): KillOutcome {
66
66
  return taskTable.killRunningTurn(chatId);
67
67
  }
68
68
 
69
+ /**
70
+ * Request an abort of every chat's running turn. The shutdown drain calls
71
+ * this before polling `getActiveCount` — a turn can legitimately run for
72
+ * minutes, so a drain that only waits can never succeed against one.
73
+ * Returns the number of kill requests issued.
74
+ */
75
+ export function stopAllTurns(): number {
76
+ return taskTable.killAllRunningTurns();
77
+ }
78
+
69
79
  /**
70
80
  * Execute an AI query with full lifecycle management.
71
81
  * Same-chat queries are serialized (FIFO) to avoid session conflicts.
@@ -33,6 +33,17 @@ type ErrorReason =
33
33
  const USAGE_LIMIT_RE =
34
34
  /you['’]ve hit your .{0,40}limit|you['’]re out of extra usage|claude ai usage limit reached|usage limit reached/i;
35
35
 
36
+ /**
37
+ * Ceiling on how long a failed attempt may have run and still earn the
38
+ * frontend queues' blind retry. `retryable` means "a short pause may clear
39
+ * it", which holds for a 429 or a dropped socket but not for an attempt
40
+ * that already burned minutes before failing (e.g. the 600s remote turn
41
+ * deadline — its rejection is `name: "TimeoutError"`, classified as
42
+ * transient network). Turns serialize per chat, so retrying such an
43
+ * attempt doubles the stall for every message queued behind it.
44
+ */
45
+ export const RETRY_ELAPSED_CAP_MS = 120_000;
46
+
36
47
  // ── TalonError class ────────────────────────────────────────────────────────
37
48
 
38
49
  export class TalonError extends Error {
@@ -62,6 +62,31 @@ type ChildEntry = {
62
62
  const children = new Map<string, ChildEntry>();
63
63
  const inflight = new Map<string, Promise<ChildHandle>>();
64
64
 
65
+ /**
66
+ * Negative cache for spawn failures. A child whose backing service is down
67
+ * (e.g. playwright-tools with its browser endpoint offline) dies at the
68
+ * connect handshake, and without this every single turn re-paid the
69
+ * spawn+handshake (~600ms) and re-logged the failure for the whole outage.
70
+ * Failures back off exponentially; the first attempt after the window
71
+ * clears the entry on success, so recovery costs one turn.
72
+ */
73
+ type SpawnFailure = { at: number; count: number; error: unknown };
74
+ const spawnFailures = new Map<string, SpawnFailure>();
75
+ const FAILURE_BACKOFF_BASE_MS = 30_000;
76
+ const FAILURE_BACKOFF_MAX_MS = 10 * 60_000;
77
+
78
+ function failureBackoffMs(count: number): number {
79
+ return Math.min(
80
+ FAILURE_BACKOFF_BASE_MS * 2 ** (count - 1),
81
+ FAILURE_BACKOFF_MAX_MS,
82
+ );
83
+ }
84
+
85
+ /** Test seam: forget recorded spawn failures. */
86
+ export function resetSpawnFailures(): void {
87
+ spawnFailures.clear();
88
+ }
89
+
65
90
  /** Idle TTL for hub children; tunable for tests / tight deployments. */
66
91
  function idleTtlMs(): number {
67
92
  const raw = Number(process.env.TALON_MCP_HUB_IDLE_MS);
@@ -168,9 +193,28 @@ export function acquireChild(
168
193
  const pending = inflight.get(key);
169
194
  if (pending) return pending;
170
195
 
196
+ const failure = spawnFailures.get(key);
197
+ if (failure && Date.now() - failure.at < failureBackoffMs(failure.count)) {
198
+ return Promise.reject(
199
+ failure.error instanceof Error
200
+ ? failure.error
201
+ : new Error(String(failure.error)),
202
+ );
203
+ }
204
+
171
205
  const promise = (async () => {
172
206
  try {
173
- return await spawnChild(key, spec());
207
+ const handle = await spawnChild(key, spec());
208
+ spawnFailures.delete(key);
209
+ return handle;
210
+ } catch (err) {
211
+ const prior = spawnFailures.get(key);
212
+ spawnFailures.set(key, {
213
+ at: Date.now(),
214
+ count: (prior?.count ?? 0) + 1,
215
+ error: err,
216
+ });
217
+ throw err;
174
218
  } finally {
175
219
  inflight.delete(key);
176
220
  }
@@ -122,6 +122,20 @@ export class TaskTable {
122
122
  return { ok: true };
123
123
  }
124
124
 
125
+ /**
126
+ * Request an abort of every running turn, regardless of chat — the
127
+ * shutdown drain's lever. Returns the number of kill requests issued.
128
+ */
129
+ killAllRunningTurns(): number {
130
+ let killed = 0;
131
+ for (const [id, task] of this.live) {
132
+ if (task.record.kind === "turn" && task.record.state === "running") {
133
+ if (this.kill(id).ok) killed++;
134
+ }
135
+ }
136
+ return killed;
137
+ }
138
+
125
139
  /**
126
140
  * Request an abort for the turn currently running in one chat. Queued turns
127
141
  * are deliberately ignored: `/stop` means "stop what is happening now",
@@ -22,6 +22,7 @@ import { webTools } from "./web.js";
22
22
  import { adminTools } from "./admin.js";
23
23
  import { modelTools } from "./models.js";
24
24
  import { meshTools } from "./mesh.js";
25
+ import { moderationTools } from "./moderation.js";
25
26
  import { nativeTools } from "./native.js";
26
27
 
27
28
  /** All built-in tool definitions. */
@@ -41,6 +42,7 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
41
42
  ...adminTools,
42
43
  ...modelTools,
43
44
  ...meshTools,
45
+ ...moderationTools,
44
46
  ];
45
47
 
46
48
  /**
@@ -135,7 +135,10 @@ Examples:
135
135
  Dice: send(type="dice")
136
136
  Location: send(type="location", latitude=37.7749, longitude=-122.4194)
137
137
  Sticker by feeling: send(type="sticker", emoji="😂") — picks a matching sticker from your saved packs (add set_name to pin one pack)
138
- Sticker by id: send(type="sticker", file_id="CAACAgI...")`,
138
+ Sticker by id: send(type="sticker", file_id="CAACAgI...")
139
+ Album: send(type="album", media=[{"type":"photo","file_path":"a.jpg"},{"type":"photo","url":"https://…/b.jpg","caption":"the good one"}]) — 2-10 photos/videos as one grouped message
140
+ Round video: send(type="video_note", file_path="/path/clip.mp4") — circular video bubble (square video, ≤60s)
141
+ Venue: send(type="venue", latitude=53.34, longitude=-6.26, title="The Long Hall", address="51 South Great George's St")`,
139
142
  schema: {
140
143
  type: z
141
144
  .enum([
@@ -151,6 +154,9 @@ Examples:
151
154
  "location",
152
155
  "contact",
153
156
  "dice",
157
+ "album",
158
+ "video_note",
159
+ "venue",
154
160
  ])
155
161
  .describe("Content type to send"),
156
162
  text: z
@@ -208,7 +214,30 @@ Examples:
208
214
  phone_number: z.string().optional().describe("Contact phone"),
209
215
  first_name: z.string().optional().describe("Contact first name"),
210
216
  last_name: z.string().optional().describe("Contact last name"),
211
- title: z.string().optional().describe("Audio title (for type=audio)"),
217
+ title: z
218
+ .string()
219
+ .optional()
220
+ .describe("Audio title (type=audio) or venue name (type=venue)"),
221
+ address: z
222
+ .string()
223
+ .optional()
224
+ .describe("Venue street address (for type=venue)"),
225
+ media: z
226
+ .array(
227
+ z.object({
228
+ type: z
229
+ .enum(["photo", "video", "document", "audio"])
230
+ .describe("Kind of this album item"),
231
+ file_path: z.string().optional(),
232
+ url: z.string().optional(),
233
+ file_id: z.string().optional(),
234
+ caption: z.string().optional(),
235
+ }),
236
+ )
237
+ .optional()
238
+ .describe(
239
+ "Album items (for type=album): 2-10 entries, each sourced from file_path, url, or file_id. Photos and videos mix; documents/audio group only with their own kind.",
240
+ ),
212
241
  performer: z
213
242
  .string()
214
243
  .optional()
@@ -230,9 +259,41 @@ Examples:
230
259
  .describe(
231
260
  "Target chat ID. Omit to send to the current chat (chat mode). Required from heartbeat mode where there is no ambient chat — use list_chats or known IDs from memory. Telegram supergroup/channel IDs are negative (e.g. -1001426819337); user DMs are positive.",
232
261
  ),
262
+ silent: z
263
+ .boolean()
264
+ .optional()
265
+ .describe("Send without a notification sound (Telegram)"),
266
+ protect: z
267
+ .boolean()
268
+ .optional()
269
+ .describe("Protect content from forwarding and saving (Telegram)"),
270
+ spoiler: z
271
+ .boolean()
272
+ .optional()
273
+ .describe(
274
+ "Blur photo/video/animation behind a spoiler tap-to-reveal (Telegram)",
275
+ ),
276
+ no_link_preview: z
277
+ .boolean()
278
+ .optional()
279
+ .describe("Disable the link preview for type=text (Telegram)"),
280
+ thread_id: z
281
+ .union([z.number(), z.literal("general")])
282
+ .optional()
283
+ .describe(
284
+ 'Forum topic to post into (Telegram supergroups with topics). Defaults to the topic the conversation is happening in; pass "general" to force the General topic.',
285
+ ),
233
286
  },
234
287
  execute: async (params, bridge) => {
235
288
  const { type } = params;
289
+ // Delivery modifiers every Telegram send action understands. Harmless
290
+ // on frontends that don't (handlers read only the fields they know).
291
+ const mods = {
292
+ silent: params.silent,
293
+ protect: params.protect,
294
+ spoiler: params.spoiler,
295
+ thread_id: params.thread_id,
296
+ };
236
297
  // Thread chat_id through to every bridge call so heartbeat / dream
237
298
  // outbound (no ambient chat) gets routed by the explicit chat_id.
238
299
  // `createBridge` at src/core/tools/bridge.ts:29 reads
@@ -253,6 +314,7 @@ Examples:
253
314
  delay_seconds: params.delay_seconds,
254
315
  rows: params.buttons,
255
316
  reply_to_message_id: params.reply_to,
317
+ ...mods,
256
318
  chat_id,
257
319
  });
258
320
  }
@@ -261,12 +323,15 @@ Examples:
261
323
  text: params.text,
262
324
  rows: params.buttons,
263
325
  reply_to_message_id: params.reply_to,
326
+ ...mods,
264
327
  chat_id,
265
328
  });
266
329
  }
267
330
  return bridge("send_message", {
268
331
  text: params.text,
269
332
  reply_to_message_id: params.reply_to,
333
+ no_link_preview: params.no_link_preview,
334
+ ...mods,
270
335
  chat_id,
271
336
  });
272
337
  }
@@ -277,6 +342,7 @@ Examples:
277
342
  file_id: params.file_id,
278
343
  caption: params.caption,
279
344
  reply_to: params.reply_to,
345
+ ...mods,
280
346
  chat_id,
281
347
  });
282
348
  case "file":
@@ -286,6 +352,7 @@ Examples:
286
352
  file_id: params.file_id,
287
353
  caption: params.caption,
288
354
  reply_to: params.reply_to,
355
+ ...mods,
289
356
  chat_id,
290
357
  });
291
358
  case "video":
@@ -295,6 +362,7 @@ Examples:
295
362
  file_id: params.file_id,
296
363
  caption: params.caption,
297
364
  reply_to: params.reply_to,
365
+ ...mods,
298
366
  chat_id,
299
367
  });
300
368
  case "voice":
@@ -304,6 +372,7 @@ Examples:
304
372
  file_id: params.file_id,
305
373
  caption: params.caption,
306
374
  reply_to: params.reply_to,
375
+ ...mods,
307
376
  chat_id,
308
377
  });
309
378
  case "audio":
@@ -315,6 +384,7 @@ Examples:
315
384
  title: params.title,
316
385
  performer: params.performer,
317
386
  reply_to: params.reply_to,
387
+ ...mods,
318
388
  chat_id,
319
389
  });
320
390
  case "animation":
@@ -324,6 +394,7 @@ Examples:
324
394
  file_id: params.file_id,
325
395
  caption: params.caption,
326
396
  reply_to: params.reply_to,
397
+ ...mods,
327
398
  chat_id,
328
399
  });
329
400
  case "sticker":
@@ -333,6 +404,7 @@ Examples:
333
404
  emoji: params.emoji,
334
405
  set_name: params.set_name,
335
406
  reply_to: params.reply_to,
407
+ ...mods,
336
408
  chat_id,
337
409
  });
338
410
  case "poll":
@@ -344,6 +416,7 @@ Examples:
344
416
  explanation: params.explanation,
345
417
  type: params.correct_option_id !== undefined ? "quiz" : "regular",
346
418
  reply_to: params.reply_to,
419
+ ...mods,
347
420
  chat_id,
348
421
  });
349
422
  case "location":
@@ -351,6 +424,7 @@ Examples:
351
424
  latitude: params.latitude,
352
425
  longitude: params.longitude,
353
426
  reply_to: params.reply_to,
427
+ ...mods,
354
428
  chat_id,
355
429
  });
356
430
  case "contact":
@@ -359,12 +433,40 @@ Examples:
359
433
  first_name: params.first_name,
360
434
  last_name: params.last_name,
361
435
  reply_to: params.reply_to,
436
+ ...mods,
362
437
  chat_id,
363
438
  });
364
439
  case "dice":
365
440
  return bridge("send_dice", {
366
441
  emoji: params.emoji,
367
442
  reply_to: params.reply_to,
443
+ ...mods,
444
+ chat_id,
445
+ });
446
+ case "album":
447
+ return bridge("send_media_group", {
448
+ media: params.media,
449
+ reply_to: params.reply_to,
450
+ ...mods,
451
+ chat_id,
452
+ });
453
+ case "video_note":
454
+ return bridge("send_video_note", {
455
+ file_path: params.file_path,
456
+ url: params.url,
457
+ file_id: params.file_id,
458
+ reply_to: params.reply_to,
459
+ ...mods,
460
+ chat_id,
461
+ });
462
+ case "venue":
463
+ return bridge("send_venue", {
464
+ latitude: params.latitude,
465
+ longitude: params.longitude,
466
+ title: params.title,
467
+ address: params.address,
468
+ reply_to: params.reply_to,
469
+ ...mods,
368
470
  chat_id,
369
471
  });
370
472
  default:
@@ -479,8 +581,16 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
479
581
  // ── edit_message ──────────────────────────────────────────────────────
480
582
  {
481
583
  name: "edit_message",
482
- description: "Edit a previously sent message.",
483
- schema: { message_id: snowflakeOrIdSchema, text: z.string() },
584
+ description:
585
+ "Edit a previously sent message. For a media message (photo/video/file), pass is_caption=true to edit its caption instead of message text.",
586
+ schema: {
587
+ message_id: snowflakeOrIdSchema,
588
+ text: z.string(),
589
+ is_caption: z
590
+ .boolean()
591
+ .optional()
592
+ .describe("Edit the media caption rather than message text (Telegram)"),
593
+ },
484
594
  execute: (params, bridge) => bridge("edit_message", params),
485
595
  frontends: ["telegram", "discord", "native"],
486
596
  tag: "messaging",
@@ -489,8 +599,15 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
489
599
  // ── delete_message ────────────────────────────────────────────────────
490
600
  {
491
601
  name: "delete_message",
492
- description: "Delete a message.",
493
- schema: { message_id: snowflakeOrIdSchema },
602
+ description:
603
+ "Delete a message — or several at once via message_ids (Telegram; ids the bot can't delete are skipped).",
604
+ schema: {
605
+ message_id: snowflakeOrIdSchema.optional(),
606
+ message_ids: z
607
+ .array(snowflakeOrIdSchema)
608
+ .optional()
609
+ .describe("Bulk delete these message IDs (Telegram)"),
610
+ },
494
611
  execute: (params, bridge) => bridge("delete_message", params),
495
612
  frontends: ["telegram", "discord", "native"],
496
613
  tag: "messaging",
@@ -499,13 +616,42 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
499
616
  // ── forward_message ───────────────────────────────────────────────────
500
617
  {
501
618
  name: "forward_message",
502
- description: "Forward a message within the chat.",
503
- schema: { message_id: snowflakeOrIdSchema },
619
+ description:
620
+ "Forward a message. Defaults to within the current chat; from_chat_id / to_chat_id forward across chats the bot is in, and message_ids forwards a batch (albums stay grouped) (Telegram).",
621
+ schema: {
622
+ message_id: snowflakeOrIdSchema.optional(),
623
+ message_ids: z
624
+ .array(snowflakeOrIdSchema)
625
+ .optional()
626
+ .describe("Forward these messages as a batch (Telegram)"),
627
+ from_chat_id: chatIdSchema
628
+ .optional()
629
+ .describe("Source chat (default: current chat)"),
630
+ to_chat_id: chatIdSchema
631
+ .optional()
632
+ .describe("Destination chat (default: current chat)"),
633
+ },
504
634
  execute: (params, bridge) => bridge("forward_message", params),
505
635
  frontends: ["telegram", "discord"],
506
636
  tag: "messaging",
507
637
  },
508
638
 
639
+ // ── copy_message ──────────────────────────────────────────────────────
640
+ {
641
+ name: "copy_message",
642
+ description:
643
+ "Repost a message without the 'forwarded from' header. Same cross-chat and batch semantics as forward_message (Telegram).",
644
+ schema: {
645
+ message_id: snowflakeOrIdSchema.optional(),
646
+ message_ids: z.array(snowflakeOrIdSchema).optional(),
647
+ from_chat_id: chatIdSchema.optional(),
648
+ to_chat_id: chatIdSchema.optional(),
649
+ },
650
+ execute: (params, bridge) => bridge("copy_message", params),
651
+ frontends: ["telegram"],
652
+ tag: "messaging",
653
+ },
654
+
509
655
  // ── pin_message ───────────────────────────────────────────────────────
510
656
  {
511
657
  name: "pin_message",