@llblab/pi-kit 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +2 -2
  3. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +1 -1
  4. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +6 -0
  5. package/node_modules/@llblab/pi-grow-loop/README.md +2 -1
  6. package/node_modules/@llblab/pi-grow-loop/index.ts +11 -2
  7. package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
  8. package/node_modules/@llblab/pi-telegram/AGENTS.md +4 -4
  9. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +34 -0
  10. package/node_modules/@llblab/pi-telegram/README.md +3 -3
  11. package/node_modules/@llblab/pi-telegram/api/voice.ts +0 -1
  12. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -5
  13. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +2 -2
  14. package/node_modules/@llblab/pi-telegram/docs/outbound.md +2 -3
  15. package/node_modules/@llblab/pi-telegram/docs/public-api.md +4 -13
  16. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +22 -8
  17. package/node_modules/@llblab/pi-telegram/docs/voice.md +9 -37
  18. package/node_modules/@llblab/pi-telegram/index.ts +20 -7
  19. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +49 -13
  20. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +127 -25
  21. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +17 -4
  22. package/node_modules/@llblab/pi-telegram/lib/bus.ts +56 -2
  23. package/node_modules/@llblab/pi-telegram/lib/command-templates.ts +65 -4
  24. package/node_modules/@llblab/pi-telegram/lib/commands.ts +122 -28
  25. package/node_modules/@llblab/pi-telegram/lib/config.ts +10 -7
  26. package/node_modules/@llblab/pi-telegram/lib/journal.ts +2 -9
  27. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +20 -18
  28. package/node_modules/@llblab/pi-telegram/lib/locks.ts +6 -1
  29. package/node_modules/@llblab/pi-telegram/lib/menu-queue.ts +15 -4
  30. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +15 -11
  31. package/node_modules/@llblab/pi-telegram/lib/menu.ts +10 -7
  32. package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +3 -18
  33. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +2 -2
  34. package/node_modules/@llblab/pi-telegram/lib/queue.ts +49 -12
  35. package/node_modules/@llblab/pi-telegram/lib/routing.ts +5 -2
  36. package/node_modules/@llblab/pi-telegram/lib/status.ts +4 -2
  37. package/node_modules/@llblab/pi-telegram/lib/sync.ts +17 -0
  38. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +91 -45
  39. package/node_modules/@llblab/pi-telegram/lib/threads.ts +5 -0
  40. package/node_modules/@llblab/pi-telegram/lib/updates.ts +17 -9
  41. package/node_modules/@llblab/pi-telegram/lib/voice.ts +7 -31
  42. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  43. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +1 -1
  44. package/package.json +3 -3
@@ -73,6 +73,10 @@ function toTelegramQueueMenuItems<Context>(
73
73
  });
74
74
  }
75
75
 
76
+ function formatSkippedTelegramQueuePosition(position: number): string {
77
+ return Array.from(String(position), (char) => `${char}\u0335`).join("");
78
+ }
79
+
76
80
  function buildTelegramQueueMenuReplyMarkup(
77
81
  items: readonly TelegramQueueMenuItem[],
78
82
  emptyRefreshIndex = 0,
@@ -86,7 +90,7 @@ function buildTelegramQueueMenuReplyMarkup(
86
90
  : "queue:refresh";
87
91
  const refreshRow = [{ text: "🌀 Refresh", callback_data: refreshData }];
88
92
  if (items.length === 0) return { inline_keyboard: [backRow, refreshRow] };
89
- const rows = items.map((item, index) => {
93
+ const rows = items.map((item) => {
90
94
  const prefix = item.reactionSuppressionEmoji
91
95
  ? `${item.reactionSuppressionEmoji} `
92
96
  : item.isPriority
@@ -94,7 +98,11 @@ function buildTelegramQueueMenuReplyMarkup(
94
98
  : item.hasAttachments
95
99
  ? "📎 "
96
100
  : "";
97
- const label = `${index + 1}. ${prefix}${item.statusSummary}`;
101
+ const position = item.reactionSuppressionEmoji
102
+ ? formatSkippedTelegramQueuePosition(item.queuePosition)
103
+ : String(item.queuePosition);
104
+ const ordinalSeparator = item.reactionSuppressionEmoji ? "\u200A" : "";
105
+ const label = `${position}${ordinalSeparator}. ${prefix}${item.statusSummary}`;
98
106
  return [
99
107
  {
100
108
  text: label,
@@ -162,7 +170,10 @@ function getTelegramQueueMenuItemText(item: TelegramQueueMenuItem): string {
162
170
  : item.isPriority
163
171
  ? ` ${item.priorityEmoji ?? "⚡"}`
164
172
  : "";
165
- const heading = `<b>${item.queuePosition}.</b>${badge}`;
173
+ const position = item.reactionSuppressionEmoji
174
+ ? `<s>${item.queuePosition}</s>.`
175
+ : `<b>${item.queuePosition}.</b>`;
176
+ const heading = `${position}${badge}`;
166
177
  const preview = `<pre>${escapeTelegramQueueMenuHtmlPreview(item.promptText)}</pre>`;
167
178
  return `${heading}\n${preview}`;
168
179
  }
@@ -182,7 +193,7 @@ function buildTelegramQueueItemSubmenuReplyMarkup(
182
193
  callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:priority`,
183
194
  },
184
195
  {
185
- text: isPriority ? "⚫️ Normal" : "🟣 Normal",
196
+ text: isPriority ? "⚫️ Normal" : "🔵 Normal",
186
197
  callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:normal`,
187
198
  },
188
199
  ],
@@ -149,9 +149,7 @@ export const TIME_INJECTION_MODE_SETTINGS_TITLE =
149
149
  "<b>🕒 Time injection mode:</b>";
150
150
  export const VOICE_REPLY_MODE_SETTINGS_TITLE = "<b>👄 Voice reply mode:</b>";
151
151
 
152
- type TelegramVoiceReplyModeSetting = TelegramVoiceReplyMode | "hidden";
153
-
154
- function getVoiceReplyModeLabel(mode: TelegramVoiceReplyModeSetting): string {
152
+ function getVoiceReplyModeLabel(mode: TelegramVoiceReplyMode): string {
155
153
  return mode;
156
154
  }
157
155
 
@@ -162,8 +160,8 @@ function getTelegramSettingsStateValueLabel(value: string): string {
162
160
  function getVoiceReplyModeSetting(
163
161
  mode: TelegramVoiceReplyMode,
164
162
  configured: boolean,
165
- ): TelegramVoiceReplyModeSetting {
166
- return configured ? mode : "hidden";
163
+ ): TelegramVoiceReplyMode {
164
+ return configured ? mode : "manual";
167
165
  }
168
166
 
169
167
  export function buildTelegramSettingsMenuText(): string {
@@ -246,8 +244,8 @@ export function buildVoiceReplyModeSettingsText(
246
244
  "",
247
245
  "Controls when pi-telegram converts assistant text replies into Telegram voice messages.",
248
246
  "",
249
- "<code>-</code> <code>hidden</code> (default): add no automatic voice context; explicit 'telegram_voice' actions still work.",
250
- "<code>-</code> <code>mirror</code>: voice input activates automatic voice delivery; text input follows 'hidden' behavior.",
247
+ "<code>-</code> <code>manual</code> (default): add no automatic voice context; explicit 'telegram_voice' actions still work.",
248
+ "<code>-</code> <code>mirror</code>: voice input activates automatic voice delivery; text input follows 'manual' behavior.",
251
249
  "<code>-</code> <code>always</code>: activate automatic voice delivery for every reply.",
252
250
  ].join("\n");
253
251
  }
@@ -502,7 +500,7 @@ export function buildVoiceReplyModeSettingsReplyMarkup(
502
500
  configured = true,
503
501
  ): TelegramSettingsMenuReplyMarkup {
504
502
  const activeMode = getVoiceReplyModeSetting(mode, configured);
505
- const modes: TelegramVoiceReplyModeSetting[] = ["hidden", "mirror", "always"];
503
+ const modes: TelegramVoiceReplyMode[] = ["manual", "mirror", "always"];
506
504
  return {
507
505
  inline_keyboard: [
508
506
  [{ text: "⬆️ Back", callback_data: "settings:list" }],
@@ -661,12 +659,18 @@ export async function handleTelegramSettingsMenuCallbackAction(
661
659
  }
662
660
  if (data.startsWith("settings:set:voice-reply:")) {
663
661
  const mode = data.slice("settings:set:voice-reply:".length);
664
- if (mode === "hidden" || mode === "mirror" || mode === "always") {
665
- await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
662
+ if (
663
+ mode === "manual" ||
664
+ mode === "hidden" ||
665
+ mode === "mirror" ||
666
+ mode === "always"
667
+ ) {
668
+ const normalizedMode = mode === "hidden" ? "manual" : mode;
669
+ await deps.setVoiceReplyMode(normalizedMode);
666
670
  await updateVoiceReplyModeSettingsMessage(deps);
667
671
  await deps.answerCallbackQuery(
668
672
  callbackQueryId,
669
- `Voice reply mode: ${mode}`,
673
+ `Voice reply mode: ${normalizedMode}`,
670
674
  );
671
675
  return true;
672
676
  }
@@ -255,7 +255,10 @@ export interface TelegramMenuActionRuntimeDeps<
255
255
  chatId: number,
256
256
  replyToMessageId: number,
257
257
  text: string,
258
- options?: { target?: { chatId: number; threadId?: number } },
258
+ options?: {
259
+ target?: { chatId: number; threadId?: number };
260
+ parseMode?: "HTML";
261
+ },
259
262
  ) => Promise<unknown>;
260
263
  sectionRegistry?: TelegramSectionRegistry;
261
264
  isVoiceReplyActive?: () => boolean;
@@ -798,8 +801,8 @@ export function createTelegramMenuActionRuntime<
798
801
  await deps.sendTextReply(
799
802
  chatId,
800
803
  replyToMessageId,
801
- "Cannot open status while Pi is busy. Send /abort, /next, or /stop.",
802
- { target: { chatId, threadId } },
804
+ "<b>⏳ Cannot open status while Pi is busy. Send /abort, /next, or /stop.</b>",
805
+ { target: { chatId, threadId }, parseMode: "HTML" },
803
806
  );
804
807
  },
805
808
  getModelMenuState: () => deps.getModelMenuState(chatId, ctx, threadId),
@@ -835,16 +838,16 @@ export function createTelegramMenuActionRuntime<
835
838
  await deps.sendTextReply(
836
839
  chatId,
837
840
  replyToMessageId,
838
- "Cannot switch model while Pi is busy. Send /abort, /next, or /stop.",
839
- { target: { chatId, threadId } },
841
+ "<b>⏳ Cannot switch model while Pi is busy. Send /abort, /next, or /stop.</b>",
842
+ { target: { chatId, threadId }, parseMode: "HTML" },
840
843
  );
841
844
  },
842
845
  sendNoModelsMessage: async () => {
843
846
  await deps.sendTextReply(
844
847
  chatId,
845
848
  replyToMessageId,
846
- "No available models with configured auth.",
847
- { target: { chatId, threadId } },
849
+ "<b>🚫 No available models with configured auth.</b>",
850
+ { target: { chatId, threadId }, parseMode: "HTML" },
848
851
  );
849
852
  },
850
853
  getModelMenuState: () => deps.getModelMenuState(chatId, ctx, threadId),
@@ -100,17 +100,6 @@ async function ensureTelegramVoiceFileFormat(
100
100
  );
101
101
  }
102
102
 
103
- function extractVoiceResult(result: any): {
104
- filePath: string;
105
- transcriptText?: string;
106
- } {
107
- if (typeof result === "string") return { filePath: result };
108
- return {
109
- filePath: result.audioPath,
110
- transcriptText: result.transcriptText,
111
- };
112
- }
113
-
114
103
  async function sendVoiceChatAction(
115
104
  deps: TelegramVoiceReplySenderDeps,
116
105
  chatId: number,
@@ -132,7 +121,6 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
132
121
  options?: {
133
122
  replyToPrompt?: boolean;
134
123
  replyMarkup?: unknown;
135
- transcriptText?: string;
136
124
  },
137
125
  ): Promise<void> => {
138
126
  const voiceFilePath = await ensureTelegramVoiceFileFormat(filePath);
@@ -148,7 +136,6 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
148
136
  "sendVoice",
149
137
  {
150
138
  chat_id: String(turn.chatId),
151
- ...(options?.transcriptText ? { caption: options.transcriptText } : {}),
152
139
  ...(replyParameters ? { reply_parameters: replyParameters } : {}),
153
140
  ...(turn.target
154
141
  ? Object.fromEntries(
@@ -257,13 +244,11 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
257
244
  continue;
258
245
  }
259
246
 
260
- const { filePath, transcriptText } = extractVoiceResult(providerResult);
261
- voiceFilePath = filePath;
262
- originalFilePath = filePath;
263
- await uploadVoiceFile(turn, filePath, {
247
+ voiceFilePath = providerResult;
248
+ originalFilePath = providerResult;
249
+ await uploadVoiceFile(turn, providerResult, {
264
250
  replyToPrompt: options?.replyToPrompt,
265
251
  replyMarkup: options?.replyMarkup,
266
- transcriptText,
267
252
  });
268
253
  return;
269
254
  } catch (error) {
@@ -14,11 +14,11 @@ export const TELEGRAM_DISCONNECTED_CONTEXT_MESSAGE =
14
14
 
15
15
  const LOCAL_SYSTEM_PROMPT_SUFFIX = `
16
16
 
17
- ${TELEGRAM_CONNECTED_CONTEXT_MESSAGE} Load the \`telegram-bridge\` Skill for Telegram-originated turns or explicit requests involving Telegram delivery, actions, Threaded Mode, or diagnosis. Do not use Telegram-specific features from unrelated local/TUI prompts.`;
17
+ ${TELEGRAM_CONNECTED_CONTEXT_MESSAGE} For Telegram work, consult bundled Skills in routing order: \`telegram-bridge\` for the transport and turn protocol, \`generated-control-surface\` when contextual controls materially shorten feedback, then \`generative-apps\` when the interaction warrants a reusable deterministic app. Load a Skill only if its instructions are not already present in the current context. Do not use Telegram-specific features from unrelated local/TUI prompts.`;
18
18
 
19
19
  const TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX = `
20
20
 
21
- Telegram turn note: Load and follow the \`telegram-bridge\` Skill.`;
21
+ Telegram turn note: Follow the applicable bundled Telegram Skills in routing order; load only missing instructions.`;
22
22
 
23
23
  export const TELEGRAM_ATTACH_PROMPT_SNIPPET =
24
24
  "Queue files for the active Telegram reply; outside Telegram turns, send files directly to Telegram.";
@@ -427,12 +427,22 @@ export function createTelegramTransportStampedQueueStore<TContext>(
427
427
  };
428
428
  }
429
429
 
430
+ export function isTelegramQueueItemSkipped<TContext = unknown>(
431
+ item: TelegramQueueItem<TContext>,
432
+ ): boolean {
433
+ return item.kind === "prompt" && Boolean(item.reactionSuppressionEmoji);
434
+ }
435
+
436
+ export function countExecutableTelegramQueueItems<TContext = unknown>(
437
+ items: readonly TelegramQueueItem<TContext>[],
438
+ ): number {
439
+ return items.filter((item) => !isTelegramQueueItemSkipped(item)).length;
440
+ }
441
+
430
442
  export function createTelegramQueueItemCountGetter<TContext = unknown>(
431
443
  store: Pick<TelegramQueueStore<TContext>, "getQueuedItems">,
432
444
  ): () => number {
433
- return () => {
434
- return store.getQueuedItems().length;
435
- };
445
+ return () => countExecutableTelegramQueueItems(store.getQueuedItems());
436
446
  }
437
447
 
438
448
  export function createTelegramActiveTurnStore<
@@ -993,7 +1003,8 @@ export function consumeDispatchedTelegramPrompt<TContext = unknown>(
993
1003
  export function formatQueuedTelegramItemsStatus<TContext = unknown>(
994
1004
  items: TelegramQueueItem<TContext>[],
995
1005
  ): string {
996
- return items.length === 0 ? "" : ` +${items.length}`;
1006
+ const count = countExecutableTelegramQueueItems(items);
1007
+ return count === 0 ? "" : ` +${count}`;
997
1008
  }
998
1009
 
999
1010
  export function truncateTelegramQueueSummary(
@@ -2062,6 +2073,7 @@ export interface TelegramSessionStartRuntimeDeps<TContext, TModel = unknown> {
2062
2073
  export interface TelegramSessionShutdownRuntimeDeps<TQueueItem> {
2063
2074
  isSessionActive?: () => boolean;
2064
2075
  unbindDeferredDispatchContext?: () => void;
2076
+ discardQueuedItems?: () => void;
2065
2077
  applyState: (state: TelegramSessionShutdownState<TQueueItem>) => void;
2066
2078
  clearPendingMediaGroups: () => void;
2067
2079
  clearModelMenuState: () => void;
@@ -2090,6 +2102,7 @@ export interface TelegramSessionLifecycleHookRuntimeDeps<
2090
2102
  updateStatus: (ctx: TContext) => void;
2091
2103
  isSessionActive?: (ctx: TContext) => boolean;
2092
2104
  unbindDeferredDispatchContext?: () => void;
2105
+ discardQueuedItems?: (ctx: TContext) => void;
2093
2106
  applySessionShutdownState: (
2094
2107
  state: TelegramSessionShutdownState<TQueueItem>,
2095
2108
  ) => void;
@@ -2286,6 +2299,7 @@ export async function shutdownTelegramSessionRuntime<TQueueItem>(
2286
2299
  deps.unbindDeferredDispatchContext?.();
2287
2300
  await deps.stopPolling();
2288
2301
  if (deps.isSessionActive?.() === false) return;
2302
+ deps.discardQueuedItems?.();
2289
2303
  deps.applyState(buildTelegramSessionShutdownState<TQueueItem>());
2290
2304
  deps.clearPendingMediaGroups();
2291
2305
  deps.clearModelMenuState();
@@ -2339,6 +2353,7 @@ export function createTelegramSessionLifecycleRuntime<
2339
2353
  updateStatus: deps.updateStatus,
2340
2354
  isSessionActive: deps.isSessionActive,
2341
2355
  unbindDeferredDispatchContext: deps.unbindDeferredDispatchContext,
2356
+ discardQueuedItems: deps.discardQueuedItems,
2342
2357
  applySessionShutdownState: stateApplier.applyShutdownState,
2343
2358
  clearPendingMediaGroups: deps.clearPendingMediaGroups,
2344
2359
  clearModelMenuState: deps.clearModelMenuState,
@@ -2387,6 +2402,10 @@ export function createTelegramSessionLifecycleHooks<
2387
2402
  isSessionActive: () =>
2388
2403
  ctx === undefined ? true : (deps.isSessionActive?.(ctx) ?? true),
2389
2404
  unbindDeferredDispatchContext: deps.unbindDeferredDispatchContext,
2405
+ discardQueuedItems:
2406
+ ctx === undefined || !deps.discardQueuedItems
2407
+ ? undefined
2408
+ : () => deps.discardQueuedItems!(ctx),
2390
2409
  applyState: deps.applySessionShutdownState,
2391
2410
  clearPendingMediaGroups: deps.clearPendingMediaGroups,
2392
2411
  clearModelMenuState: deps.clearModelMenuState,
@@ -2476,14 +2495,8 @@ export function clearTelegramQueueItemsRuntime<TContext>(
2476
2495
  const removedItems = deps.getQueuedItems();
2477
2496
  const removedCount = removedItems.length;
2478
2497
  if (removedCount === 0) return 0;
2498
+ deps.onItemsDiscarded?.(removedItems, deps.ctx);
2479
2499
  deps.setQueuedItems([]);
2480
- try {
2481
- deps.onItemsDiscarded?.(removedItems, deps.ctx);
2482
- } catch (error) {
2483
- deps.recordRuntimeEvent?.("queue", error, {
2484
- phase: "discard-receipt-settlement",
2485
- });
2486
- }
2487
2500
  updateTelegramQueueStatusRuntime(deps);
2488
2501
  return removedCount;
2489
2502
  }
@@ -2877,6 +2890,7 @@ export interface TelegramQueueDispatchControllerDeps<
2877
2890
  item: PendingTelegramControlItem<TContext>,
2878
2891
  ctx: TContext,
2879
2892
  ) => void;
2893
+ onPromptSkipped?: (item: PendingTelegramTurn, ctx: TContext) => boolean;
2880
2894
  }
2881
2895
 
2882
2896
  export interface TelegramQueueDispatchController<TContext = unknown> {
@@ -2940,6 +2954,7 @@ export function createTelegramQueueDispatchRuntime<TContext = unknown>(
2940
2954
  deps.hasPendingInboundQueueMutationForItem,
2941
2955
  isQueueItemAdmissionReady: deps.isQueueItemAdmissionReady,
2942
2956
  onControlSettled: deps.onControlSettled,
2957
+ onPromptSkipped: deps.onPromptSkipped,
2943
2958
  recordRuntimeEvent: deps.recordRuntimeEvent,
2944
2959
  });
2945
2960
  }
@@ -3007,7 +3022,29 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3007
3022
  deps.updateStatus(ctx);
3008
3023
  return;
3009
3024
  }
3025
+ try {
3026
+ if (deps.onPromptSkipped && !deps.onPromptSkipped(candidate, ctx)) {
3027
+ deps.updateStatus(
3028
+ ctx,
3029
+ "Telegram skipped prompt could not be settled durably.",
3030
+ );
3031
+ return;
3032
+ }
3033
+ } catch (error) {
3034
+ deps.recordRuntimeEvent?.("dispatch", error, {
3035
+ phase: "skip-receipt-settlement",
3036
+ });
3037
+ deps.updateStatus(
3038
+ ctx,
3039
+ "Telegram skipped prompt could not be settled durably.",
3040
+ );
3041
+ return;
3042
+ }
3010
3043
  nextActiveIndex += 1;
3044
+ deps.setQueuedItems([
3045
+ ...activeItems.slice(nextActiveIndex),
3046
+ ...protectedInactiveItems,
3047
+ ]);
3011
3048
  }
3012
3049
  }
3013
3050
  const dispatchableItems = activeItems.slice(nextActiveIndex);
@@ -3031,7 +3068,7 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3031
3068
  dispatchableItems,
3032
3069
  canDispatch,
3033
3070
  );
3034
- if (nextActiveIndex > 0 || dispatchPlan.kind !== "none") {
3071
+ if (dispatchPlan.kind !== "none") {
3035
3072
  deps.setQueuedItems([
3036
3073
  ...dispatchPlan.remainingItems,
3037
3074
  ...protectedInactiveItems,
@@ -1677,9 +1677,12 @@ export function createTelegramInboundRouteRuntime<
1677
1677
  })
1678
1678
  : undefined,
1679
1679
  stopTypingLoop: deps.stopTypingLoop,
1680
- sendTextReply: (text) =>
1680
+ sendTextReply: (text, options) =>
1681
1681
  deps
1682
- .sendTextReply(chatId, replyToMessageId, text, { target })
1682
+ .sendTextReply(chatId, replyToMessageId, text, {
1683
+ target,
1684
+ parseMode: options?.parseMode,
1685
+ })
1683
1686
  .then(() => {}),
1684
1687
  suppressStartNotice: true,
1685
1688
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -344,6 +344,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
344
344
  getActiveToolExecutions: () => number;
345
345
  hasPendingModelSwitch: () => boolean;
346
346
  getQueuedItems: () => TQueueItem[];
347
+ getQueuedItemCount?: (items: TQueueItem[]) => number;
347
348
  formatQueuedStatus: (items: TQueueItem[]) => string;
348
349
  getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
349
350
  getRuntimeLockState?: () => string;
@@ -658,6 +659,7 @@ export function createTelegramBridgeStatusRuntime<
658
659
  getStatusBarState: (_ctx, error) => {
659
660
  const config = deps.getConfig();
660
661
  const queuedItems = deps.getQueuedItems();
662
+ const queuedItemCount = deps.getQueuedItemCount?.(queuedItems) ?? queuedItems.length;
661
663
  const hasActiveTurn = deps.hasActiveTurn();
662
664
  const hasPendingDispatch = deps.hasDispatchPending();
663
665
  const hasPendingModelSwitch = deps.hasPendingModelSwitch();
@@ -677,13 +679,13 @@ export function createTelegramBridgeStatusRuntime<
677
679
  hasPendingDispatch ||
678
680
  hasPendingModelSwitch ||
679
681
  activeToolExecutions > 0 ||
680
- queuedItems.length > 0,
682
+ queuedItemCount > 0,
681
683
  processingStatus: getTelegramStatusBarProcessingStatus({
682
684
  hasActiveTurn,
683
685
  hasPendingDispatch,
684
686
  hasPendingModelSwitch,
685
687
  activeToolExecutions,
686
- queuedItems: queuedItems.length,
688
+ queuedItems: queuedItemCount,
687
689
  }),
688
690
  queuedStatus: deps.formatQueuedStatus(queuedItems),
689
691
  error,
@@ -4,6 +4,7 @@
4
4
  * Owns pure contracts for deciding when local Telegram mirror state should be refreshed without querying Telegram on every action
5
5
  */
6
6
 
7
+ import { getTelegramApiErrorRequestTarget } from "./telegram-api.ts";
7
8
  import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
8
9
  import * as ThreadReconciler from "./thread-reconciler.ts";
9
10
  import {
@@ -423,6 +424,22 @@ export function createTelegramStaleTopicApiErrorRecoveryRuntime<
423
424
  recoverStaleTelegramTopicApiError(apiBody, error, deps);
424
425
  }
425
426
 
427
+ export async function settleStaleTelegramTopicExecutionFailure<
428
+ TSyncState extends TelegramSyncState,
429
+ >(
430
+ error: unknown,
431
+ deps: TelegramStaleTopicApiErrorRecoveryDeps<TSyncState>,
432
+ ): Promise<boolean> {
433
+ const target = getTelegramApiErrorRequestTarget(error);
434
+ if (!target || !isTelegramTopicTargetStaleError(error)) return false;
435
+ await recoverStaleTelegramTopicApiError(
436
+ { chat_id: target.chatId, message_thread_id: target.threadId },
437
+ error,
438
+ deps,
439
+ );
440
+ return true;
441
+ }
442
+
426
443
  export async function recoverStaleTelegramTopicApiError<
427
444
  TSyncState extends TelegramSyncState,
428
445
  >(
@@ -587,6 +587,7 @@ export function isTelegramApiCommitUnknownError(
587
587
  class TelegramApiHttpError extends Error {
588
588
  readonly status: number | undefined;
589
589
  readonly retryAfterSeconds: number | undefined;
590
+ requestTarget?: { chatId: number; threadId: number };
590
591
  constructor(
591
592
  message: string,
592
593
  status: number | undefined,
@@ -598,6 +599,41 @@ class TelegramApiHttpError extends Error {
598
599
  }
599
600
  }
600
601
 
602
+ function attachTelegramApiRequestTarget(
603
+ error: unknown,
604
+ body: Record<string, unknown> | Record<string, string>,
605
+ ): void {
606
+ if (!(error instanceof TelegramApiHttpError)) return;
607
+ const chatId = Number(body.chat_id);
608
+ const threadId = Number(body.message_thread_id);
609
+ if (!Number.isSafeInteger(chatId) || !Number.isSafeInteger(threadId)) return;
610
+ error.requestTarget = { chatId, threadId };
611
+ }
612
+
613
+ export class TelegramApiStaleTargetError extends Error {
614
+ readonly requestTarget: { chatId: number; threadId: number };
615
+
616
+ constructor(
617
+ message: string,
618
+ requestTarget: { chatId: number; threadId: number },
619
+ ) {
620
+ super(message);
621
+ this.name = "TelegramApiStaleTargetError";
622
+ this.requestTarget = { ...requestTarget };
623
+ }
624
+ }
625
+
626
+ export function getTelegramApiErrorRequestTarget(
627
+ error: unknown,
628
+ ): { chatId: number; threadId: number } | undefined {
629
+ const target =
630
+ error instanceof TelegramApiHttpError ||
631
+ error instanceof TelegramApiStaleTargetError
632
+ ? error.requestTarget
633
+ : undefined;
634
+ return target ? { ...target } : undefined;
635
+ }
636
+
601
637
  export function isTelegramMessageNotModifiedError(error: unknown): boolean {
602
638
  return (
603
639
  error instanceof Error && error.message.includes("message is not modified")
@@ -1156,21 +1192,26 @@ export async function callTelegram<TResponse>(
1156
1192
  options?: TelegramApiCallOptions,
1157
1193
  ): Promise<TResponse> {
1158
1194
  const configuredBotToken = assertTelegramBotTokenConfigured(botToken);
1159
- return callTelegramWithRetry(
1160
- method,
1161
- async (family) =>
1162
- telegramFetch(
1163
- `${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`,
1164
- {
1165
- method: "POST",
1166
- headers: { "content-type": "application/json" },
1167
- body: JSON.stringify(body),
1168
- signal: options?.signal,
1169
- },
1170
- family,
1171
- ),
1172
- options,
1173
- );
1195
+ try {
1196
+ return await callTelegramWithRetry(
1197
+ method,
1198
+ async (family) =>
1199
+ telegramFetch(
1200
+ `${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`,
1201
+ {
1202
+ method: "POST",
1203
+ headers: { "content-type": "application/json" },
1204
+ body: JSON.stringify(body),
1205
+ signal: options?.signal,
1206
+ },
1207
+ family,
1208
+ ),
1209
+ options,
1210
+ );
1211
+ } catch (error) {
1212
+ attachTelegramApiRequestTarget(error, body);
1213
+ throw error;
1214
+ }
1174
1215
  }
1175
1216
 
1176
1217
  export type TelegramBotIdentityResponse = Pick<
@@ -1206,43 +1247,48 @@ export async function callTelegramMultipart<TResponse>(
1206
1247
  ): Promise<TResponse> {
1207
1248
  const configuredBotToken = assertTelegramBotTokenConfigured(botToken);
1208
1249
  const fileBlob = await openAsBlob(filePath);
1209
- return callTelegramWithRetry(
1210
- method,
1211
- async (family) => {
1212
- if (family) {
1213
- const multipart = await buildTelegramMultipartBody(
1214
- fields,
1215
- fileField,
1216
- fileBlob,
1217
- fileName,
1218
- );
1250
+ try {
1251
+ return await callTelegramWithRetry(
1252
+ method,
1253
+ async (family) => {
1254
+ if (family) {
1255
+ const multipart = await buildTelegramMultipartBody(
1256
+ fields,
1257
+ fileField,
1258
+ fileBlob,
1259
+ fileName,
1260
+ );
1261
+ return telegramFetch(
1262
+ `${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`,
1263
+ {
1264
+ method: "POST",
1265
+ headers: { "content-type": multipart.contentType },
1266
+ body: multipart.body as unknown as BodyInit,
1267
+ signal: options?.signal,
1268
+ },
1269
+ family,
1270
+ );
1271
+ }
1272
+ const form = new FormData();
1273
+ for (const [key, value] of Object.entries(fields)) {
1274
+ form.set(key, value);
1275
+ }
1276
+ form.set(fileField, fileBlob, fileName);
1219
1277
  return telegramFetch(
1220
1278
  `${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`,
1221
1279
  {
1222
1280
  method: "POST",
1223
- headers: { "content-type": multipart.contentType },
1224
- body: multipart.body as unknown as BodyInit,
1281
+ body: form,
1225
1282
  signal: options?.signal,
1226
1283
  },
1227
- family,
1228
1284
  );
1229
- }
1230
- const form = new FormData();
1231
- for (const [key, value] of Object.entries(fields)) {
1232
- form.set(key, value);
1233
- }
1234
- form.set(fileField, fileBlob, fileName);
1235
- return telegramFetch(
1236
- `${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`,
1237
- {
1238
- method: "POST",
1239
- body: form,
1240
- signal: options?.signal,
1241
- },
1242
- );
1243
- },
1244
- options,
1245
- );
1285
+ },
1286
+ options,
1287
+ );
1288
+ } catch (error) {
1289
+ attachTelegramApiRequestTarget(error, fields);
1290
+ throw error;
1291
+ }
1246
1292
  }
1247
1293
 
1248
1294
  export async function downloadTelegramFile(
@@ -2882,6 +2882,11 @@ export function getTelegramTargetFromApiBody(
2882
2882
 
2883
2883
  export function isTelegramTopicTargetStaleError(error: unknown): boolean {
2884
2884
  if (!(error instanceof Error)) return false;
2885
+ const status =
2886
+ "status" in error && typeof error.status === "number"
2887
+ ? error.status
2888
+ : undefined;
2889
+ if (status !== undefined && status !== 400) return false;
2885
2890
  const message = error.message.toLowerCase();
2886
2891
  return (
2887
2892
  message.includes("topic_id_invalid") ||