@llblab/pi-telegram 0.24.3 → 0.24.4

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/lib/config.ts CHANGED
@@ -87,6 +87,10 @@ export interface TelegramConfig {
87
87
  sendTranscript?: boolean;
88
88
  };
89
89
  time?: TelegramTimeConfig;
90
+ threads?: {
91
+ /** Delete this instance's bound Telegram thread on graceful Pi quit. */
92
+ automaticCleanup?: boolean;
93
+ };
90
94
  /** Canonical bot/session profiles, including profiles.default. */
91
95
  profiles?: Record<string, TelegramBotProfile>;
92
96
  }
@@ -140,6 +144,7 @@ export interface TelegramConfigStore {
140
144
  getOutboundHandlers: () => TelegramOutboundHandlerConfig[] | undefined;
141
145
  setAllowedUserId: (userId: number) => void;
142
146
  load: () => Promise<void>;
147
+ didLastLoadRecoverInvalidConfig: () => boolean;
143
148
  persist: (config?: TelegramConfig) => Promise<void>;
144
149
  }
145
150
 
@@ -188,6 +193,7 @@ type TelegramMutableConfigStore = Pick<
188
193
  "get" | "set" | "persist"
189
194
  > & {
190
195
  load?: () => Promise<void>;
196
+ didLastLoadRecoverInvalidConfig?: () => boolean;
191
197
  };
192
198
 
193
199
  function isEmptyTelegramConfig(config: TelegramConfig): boolean {
@@ -498,6 +504,7 @@ export function createTelegramConfigStore(
498
504
  let mutationVersion = 0;
499
505
  let persistQueue: Promise<void> = Promise.resolve();
500
506
  let activeProfileName: string | undefined;
507
+ let lastLoadRecoveredInvalidConfig = false;
501
508
  const agentDir = options.agentDir ?? resolveAgentDir();
502
509
  const configPath = options.configPath ?? getConfigPath();
503
510
  const getEffectiveConfig = () =>
@@ -556,8 +563,10 @@ export function createTelegramConfigStore(
556
563
  setEffectiveConfig(nextConfig);
557
564
  },
558
565
  load: async () => {
566
+ lastLoadRecoveredInvalidConfig = false;
559
567
  const loadedConfig = await readTelegramConfig(configPath, {
560
568
  onInvalidConfig: (recovery) => {
569
+ lastLoadRecoveredInvalidConfig = true;
561
570
  options.recordRuntimeEvent?.("config", recovery.error, {
562
571
  phase: "load",
563
572
  configPath: recovery.configPath,
@@ -593,6 +602,7 @@ export function createTelegramConfigStore(
593
602
  persistedConfig = cloneTelegramConfig(config);
594
603
  mutationVersion += 1;
595
604
  },
605
+ didLastLoadRecoverInvalidConfig: () => lastLoadRecoveredInvalidConfig,
596
606
  persist: (nextConfig = getEffectiveConfig()) => {
597
607
  const profileName = activeProfileName;
598
608
  const desiredConfig = storeTelegramEffectiveConfig(
@@ -862,6 +872,41 @@ export function createTelegramProactivePushTargetGetter(deps: {
862
872
  };
863
873
  }
864
874
 
875
+ export function createTelegramAutomaticThreadCleanupChecker(
876
+ configStore: Pick<TelegramConfigStore, "get">,
877
+ ): () => boolean {
878
+ return () => configStore.get().threads?.automaticCleanup ?? true;
879
+ }
880
+
881
+ export function createTelegramAutomaticThreadCleanupResolver(
882
+ configStore: TelegramMutableConfigStore,
883
+ ): () => Promise<boolean> {
884
+ return async () => {
885
+ await loadLatestTelegramConfig(configStore);
886
+ if (configStore.didLastLoadRecoverInvalidConfig?.()) {
887
+ throw new Error(
888
+ "Automatic thread cleanup setting is unavailable after invalid Telegram config recovery.",
889
+ );
890
+ }
891
+ return createTelegramAutomaticThreadCleanupChecker(configStore)();
892
+ };
893
+ }
894
+
895
+ export function createTelegramAutomaticThreadCleanupSetter(
896
+ configStore: TelegramMutableConfigStore,
897
+ ): (enabled: boolean) => Promise<void> {
898
+ return async (enabled) => {
899
+ await loadLatestTelegramConfig(configStore);
900
+ const current = configStore.get();
901
+ const config = {
902
+ ...current,
903
+ threads: { ...current.threads, automaticCleanup: enabled },
904
+ };
905
+ configStore.set(config);
906
+ await configStore.persist(config);
907
+ };
908
+ }
909
+
865
910
  export function createTelegramConfigControls(
866
911
  configStore: TelegramMutableConfigStore,
867
912
  ) {
@@ -880,6 +925,12 @@ export function createTelegramConfigControls(
880
925
  setVoiceReplyMode: createTelegramVoiceReplyModeSetter(configStore),
881
926
  getTimeInjectionMode: createTelegramTimeInjectionModeGetter(configStore),
882
927
  setTimeInjectionMode: createTelegramTimeInjectionModeSetter(configStore),
928
+ isAutomaticThreadCleanupEnabled:
929
+ createTelegramAutomaticThreadCleanupChecker(configStore),
930
+ resolveAutomaticThreadCleanupEnabled:
931
+ createTelegramAutomaticThreadCleanupResolver(configStore),
932
+ setAutomaticThreadCleanupEnabled:
933
+ createTelegramAutomaticThreadCleanupSetter(configStore),
883
934
  };
884
935
  }
885
936
 
package/lib/locks.ts CHANGED
@@ -1139,6 +1139,7 @@ export interface TelegramLockedPollingRuntimeDeps<
1139
1139
  owner: TelegramLockEntry,
1140
1140
  ) => boolean | undefined | Promise<boolean | undefined>;
1141
1141
  stopFollowerRegistration?: () => void;
1142
+ onTransportAvailabilityChanged?: () => void;
1142
1143
  updateStatus: (ctx: TContext) => void;
1143
1144
  recordRuntimeEvent?: (
1144
1145
  category: string,
@@ -1189,6 +1190,7 @@ export function createTelegramLockedPollingRuntime<
1189
1190
  const stopAfterOwnershipLoss = () => {
1190
1191
  if (ownershipStop) return;
1191
1192
  stopOwnershipWatcher();
1193
+ deps.onTransportAvailabilityChanged?.();
1192
1194
  ownershipStop = deps
1193
1195
  .stopPolling()
1194
1196
  .catch((error) =>
@@ -1242,12 +1244,14 @@ export function createTelegramLockedPollingRuntime<
1242
1244
  });
1243
1245
  }
1244
1246
  deps.lock.release();
1247
+ deps.onTransportAvailabilityChanged?.();
1245
1248
  throw error;
1246
1249
  }
1247
1250
  if (deps.lock.owns(ctx)) return true;
1248
1251
  stopOwnershipWatcher();
1249
1252
  if (ownershipStop) await ownershipStop;
1250
1253
  await deps.stopPolling();
1254
+ deps.onTransportAvailabilityChanged?.();
1251
1255
  return false;
1252
1256
  };
1253
1257
  const canStartPolling = (ctx: TContext): boolean =>
@@ -1337,6 +1341,7 @@ export function createTelegramLockedPollingRuntime<
1337
1341
  message: "Telegram leadership changed during polling startup.",
1338
1342
  };
1339
1343
  }
1344
+ deps.onTransportAvailabilityChanged?.();
1340
1345
  deps.updateStatus(ctx);
1341
1346
  const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
1342
1347
  return { ok: true, message: `Telegram bridge connected.${staleSuffix}` };
@@ -1344,6 +1349,7 @@ export function createTelegramLockedPollingRuntime<
1344
1349
  stop: async () => {
1345
1350
  await suspendPolling();
1346
1351
  const state = deps.lock.release();
1352
+ deps.onTransportAvailabilityChanged?.();
1347
1353
  if (state.kind === "active-elsewhere") {
1348
1354
  return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
1349
1355
  }
@@ -1391,6 +1397,7 @@ export function createTelegramLockedPollingRuntime<
1391
1397
  if (generation !== sessionAutoStartGeneration) return;
1392
1398
  if (!(await runOwnedPollingStart(ctx, {}))) return;
1393
1399
  if (generation !== sessionAutoStartGeneration) return;
1400
+ deps.onTransportAvailabilityChanged?.();
1394
1401
  deps.updateStatus(ctx);
1395
1402
  deps.recordRuntimeEvent?.("lock", "Telegram auto-start completed", {
1396
1403
  phase: "auto-start-complete",
@@ -26,6 +26,7 @@ export interface TelegramSettingsStateDeps {
26
26
  getTimeInjectionMode: () => TelegramTimeMode;
27
27
  getVoiceReplyMode: () => TelegramVoiceReplyMode;
28
28
  isVoiceReplyModeConfigured: () => boolean;
29
+ isAutomaticThreadCleanupEnabled: () => boolean;
29
30
  }
30
31
 
31
32
  export interface TelegramSettingsMutationDeps extends TelegramSettingsStateDeps {
@@ -38,6 +39,7 @@ export interface TelegramSettingsMutationDeps extends TelegramSettingsStateDeps
38
39
  mode: TelegramVoiceReplyMode | undefined,
39
40
  ) => Promise<void>;
40
41
  setTimeInjectionMode: (mode: TelegramTimeMode) => Promise<void>;
42
+ setAutomaticThreadCleanupEnabled: (enabled: boolean) => Promise<void>;
41
43
  }
42
44
 
43
45
  export interface TelegramSettingsMenuOpenDeps<
@@ -99,6 +101,7 @@ export interface TelegramSettingsMenuRuntimeDeps<
99
101
  TContext,
100
102
  TModel extends MenuModel = MenuModel,
101
103
  > extends TelegramSettingsMutationDeps {
104
+ reloadConfig?: () => Promise<void>;
102
105
  getModelMenuState: (
103
106
  chatId: number,
104
107
  ctx: TContext,
@@ -129,6 +132,8 @@ export interface TelegramSettingsMenuRuntimeDeps<
129
132
  }
130
133
 
131
134
  export const SETTINGS_MENU_TITLE = "<b>⚙️ Settings:</b>";
135
+ export const AUTOMATIC_THREAD_CLEANUP_SETTINGS_TITLE =
136
+ "<b>🧹 Automatic thread cleanup:</b>";
132
137
  export const PROACTIVE_PUSH_SETTINGS_TITLE = "<b>📌 Proactive push:</b>";
133
138
  export const DRAFT_PREVIEWS_SETTINGS_TITLE = "<b>📝 Draft previews:</b>";
134
139
  export const ASSISTANT_RENDERING_SETTINGS_TITLE =
@@ -158,6 +163,19 @@ export function buildTelegramSettingsMenuText(): string {
158
163
  return SETTINGS_MENU_TITLE;
159
164
  }
160
165
 
166
+ export function buildAutomaticThreadCleanupSettingsText(
167
+ enabled: boolean,
168
+ ): string {
169
+ return [
170
+ `${AUTOMATIC_THREAD_CLEANUP_SETTINGS_TITLE} <code>${enabled ? "on" : "off"}</code>`,
171
+ "",
172
+ "Delete this Pi instance's Telegram tab when Pi quits normally.",
173
+ "",
174
+ "<code>-</code> <code>on</code> (default): delete the bound thread and release Telegram authority on graceful quit.",
175
+ "<code>-</code> <code>off</code>: preserve the tab as a restart hint; manual /telegram-disconnect still confirms and deletes it.",
176
+ ].join("\n");
177
+ }
178
+
161
179
  export function buildProactivePushSettingsText(
162
180
  proactivePushEnabled: boolean,
163
181
  ): string {
@@ -236,6 +254,7 @@ export function buildTelegramSettingsMenuReplyMarkup(
236
254
  TelegramTimeMode | TelegramSectionRegistry,
237
255
  sectionRegistryOrVoiceReplyModeConfigured?: TelegramSectionRegistry | boolean,
238
256
  voiceReplyModeConfigured = true,
257
+ automaticThreadCleanupEnabled = true,
239
258
  ): TelegramSettingsMenuReplyMarkup {
240
259
  const hasRenderingMode =
241
260
  assistantRenderingModeOrVoiceReplyMode === "rich" ||
@@ -269,6 +288,12 @@ export function buildTelegramSettingsMenuReplyMarkup(
269
288
  }
270
289
  }
271
290
  rows.push(
291
+ [
292
+ {
293
+ text: `🧹 Auto thread cleanup: ${automaticThreadCleanupEnabled ? "on" : "off"}`,
294
+ callback_data: "settings:open:automatic-thread-cleanup",
295
+ },
296
+ ],
272
297
  [
273
298
  {
274
299
  text: `👄 Voice reply: ${getTelegramSettingsStateValueLabel(
@@ -328,6 +353,7 @@ export async function openTelegramSettingsMenu<
328
353
  deps.getTimeInjectionMode(),
329
354
  sectionRegistry,
330
355
  deps.isVoiceReplyModeConfigured(),
356
+ deps.isAutomaticThreadCleanupEnabled(),
331
357
  ),
332
358
  );
333
359
  if (messageId === undefined) return;
@@ -336,6 +362,26 @@ export async function openTelegramSettingsMenu<
336
362
  deps.storeModelMenuState(state);
337
363
  }
338
364
 
365
+ export function buildAutomaticThreadCleanupSettingsReplyMarkup(
366
+ enabled: boolean,
367
+ ): TelegramSettingsMenuReplyMarkup {
368
+ return {
369
+ inline_keyboard: [
370
+ [{ text: "⬆️ Back", callback_data: "settings:list" }],
371
+ [
372
+ {
373
+ text: enabled ? "🟢 On" : "⚫️ On",
374
+ callback_data: "settings:set:automatic-thread-cleanup:on",
375
+ },
376
+ {
377
+ text: enabled ? "⚫️ Off" : "🟡 Off",
378
+ callback_data: "settings:set:automatic-thread-cleanup:off",
379
+ },
380
+ ],
381
+ ],
382
+ };
383
+ }
384
+
339
385
  export function buildProactivePushSettingsReplyMarkup(
340
386
  proactivePushEnabled: boolean,
341
387
  ): TelegramSettingsMenuReplyMarkup {
@@ -443,10 +489,21 @@ export async function updateTelegramSettingsMenuMessage(
443
489
  deps.getTimeInjectionMode(),
444
490
  sectionRegistry,
445
491
  deps.isVoiceReplyModeConfigured(),
492
+ deps.isAutomaticThreadCleanupEnabled(),
446
493
  ),
447
494
  );
448
495
  }
449
496
 
497
+ export async function updateAutomaticThreadCleanupSettingsMessage(
498
+ deps: TelegramSettingsMenuCallbackDeps,
499
+ ): Promise<void> {
500
+ const enabled = deps.isAutomaticThreadCleanupEnabled();
501
+ await deps.updateSettingsMessage(
502
+ buildAutomaticThreadCleanupSettingsText(enabled),
503
+ buildAutomaticThreadCleanupSettingsReplyMarkup(enabled),
504
+ );
505
+ }
506
+
450
507
  export async function updateProactivePushSettingsMessage(
451
508
  deps: TelegramSettingsMenuCallbackDeps,
452
509
  ): Promise<void> {
@@ -509,6 +566,11 @@ export async function handleTelegramSettingsMenuCallbackAction(
509
566
  await deps.answerCallbackQuery(callbackQueryId);
510
567
  return true;
511
568
  }
569
+ if (data === "settings:open:automatic-thread-cleanup") {
570
+ await updateAutomaticThreadCleanupSettingsMessage(deps);
571
+ await deps.answerCallbackQuery(callbackQueryId);
572
+ return true;
573
+ }
512
574
  if (data === "settings:open:proactive") {
513
575
  await updateProactivePushSettingsMessage(deps);
514
576
  await deps.answerCallbackQuery(callbackQueryId);
@@ -598,6 +660,19 @@ export async function handleTelegramSettingsMenuCallbackAction(
598
660
  return true;
599
661
  }
600
662
  }
663
+ if (
664
+ data === "settings:set:automatic-thread-cleanup:on" ||
665
+ data === "settings:set:automatic-thread-cleanup:off"
666
+ ) {
667
+ const enabled = data.endsWith(":on");
668
+ await deps.setAutomaticThreadCleanupEnabled(enabled);
669
+ await updateAutomaticThreadCleanupSettingsMessage(deps);
670
+ await deps.answerCallbackQuery(
671
+ callbackQueryId,
672
+ `Automatic thread cleanup ${enabled ? "enabled" : "disabled"}`,
673
+ );
674
+ return true;
675
+ }
601
676
  if (
602
677
  data === "settings:set:proactive:on" ||
603
678
  data === "settings:set:proactive:off"
@@ -623,8 +698,9 @@ export function createTelegramSettingsMenuRuntime<
623
698
  sectionRegistry?: TelegramSectionRegistry,
624
699
  ): TelegramSettingsMenuRuntime<TContext> {
625
700
  return {
626
- openSettingsMenu: (chatId, _replyToMessageId, ctx) =>
627
- openTelegramSettingsMenu(
701
+ openSettingsMenu: async (chatId, _replyToMessageId, ctx) => {
702
+ await deps.reloadConfig?.();
703
+ return openTelegramSettingsMenu(
628
704
  {
629
705
  getModelMenuState: () => deps.getModelMenuState(chatId, ctx),
630
706
  isProactivePushEnabled: deps.isProactivePushEnabled,
@@ -633,6 +709,8 @@ export function createTelegramSettingsMenuRuntime<
633
709
  getVoiceReplyMode: deps.getVoiceReplyMode,
634
710
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
635
711
  getTimeInjectionMode: deps.getTimeInjectionMode,
712
+ isAutomaticThreadCleanupEnabled:
713
+ deps.isAutomaticThreadCleanupEnabled,
636
714
  sendSettingsMenu: (state, text, replyMarkup) =>
637
715
  deps.sendInteractiveMessage(
638
716
  state.chatId,
@@ -643,9 +721,11 @@ export function createTelegramSettingsMenuRuntime<
643
721
  storeModelMenuState: deps.storeModelMenuState,
644
722
  },
645
723
  sectionRegistry,
646
- ),
647
- updateSettingsMenuMessage: (state) =>
648
- updateTelegramSettingsMenuMessage(
724
+ );
725
+ },
726
+ updateSettingsMenuMessage: async (state) => {
727
+ await deps.reloadConfig?.();
728
+ return updateTelegramSettingsMenuMessage(
649
729
  {
650
730
  isProactivePushEnabled: deps.isProactivePushEnabled,
651
731
  areDraftPreviewsEnabled: deps.areDraftPreviewsEnabled,
@@ -653,6 +733,8 @@ export function createTelegramSettingsMenuRuntime<
653
733
  getVoiceReplyMode: deps.getVoiceReplyMode,
654
734
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
655
735
  getTimeInjectionMode: deps.getTimeInjectionMode,
736
+ isAutomaticThreadCleanupEnabled:
737
+ deps.isAutomaticThreadCleanupEnabled,
656
738
  updateSettingsMessage: (text, replyMarkup) =>
657
739
  deps.editInteractiveMessage(
658
740
  state.chatId,
@@ -663,9 +745,11 @@ export function createTelegramSettingsMenuRuntime<
663
745
  ),
664
746
  },
665
747
  sectionRegistry,
666
- ),
748
+ );
749
+ },
667
750
  handleCallbackQuery: async (query, ctx) => {
668
751
  if (!query.data?.startsWith("settings:")) return false;
752
+ await deps.reloadConfig?.();
669
753
  const messageId = query.message?.message_id;
670
754
  const chatId = query.message?.chat?.id;
671
755
  let state = deps.getStoredModelMenuState(messageId, chatId);
@@ -693,11 +777,15 @@ export function createTelegramSettingsMenuRuntime<
693
777
  getVoiceReplyMode: deps.getVoiceReplyMode,
694
778
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
695
779
  getTimeInjectionMode: deps.getTimeInjectionMode,
780
+ isAutomaticThreadCleanupEnabled:
781
+ deps.isAutomaticThreadCleanupEnabled,
696
782
  setProactivePushEnabled: deps.setProactivePushEnabled,
697
783
  setDraftPreviewsEnabled: deps.setDraftPreviewsEnabled,
698
784
  setAssistantRenderingMode: deps.setAssistantRenderingMode,
699
785
  setVoiceReplyMode: deps.setVoiceReplyMode,
700
786
  setTimeInjectionMode: deps.setTimeInjectionMode,
787
+ setAutomaticThreadCleanupEnabled:
788
+ deps.setAutomaticThreadCleanupEnabled,
701
789
  updateSettingsMessage: (text, replyMarkup) =>
702
790
  deps.editInteractiveMessage(
703
791
  state.chatId,
@@ -10,6 +10,12 @@ import { basename } from "node:path";
10
10
  import { Type } from "@sinclair/typebox";
11
11
 
12
12
  import type { ExtensionAPI } from "./pi.ts";
13
+ import {
14
+ TELEGRAM_ATTACH_PROMPT_GUIDELINES,
15
+ TELEGRAM_ATTACH_PROMPT_SNIPPET,
16
+ TELEGRAM_MESSAGE_PROMPT_GUIDELINES,
17
+ TELEGRAM_MESSAGE_PROMPT_SNIPPET,
18
+ } from "./prompts.ts";
13
19
  import {
14
20
  buildTelegramMultipartReplyParameters,
15
21
  normalizeTelegramNativeMarkdown,
@@ -432,13 +438,8 @@ export function registerTelegramOutboundAttachmentTool(
432
438
  label: "Telegram Attach",
433
439
  description:
434
440
  "Queue one or more local files for the active Telegram reply, or send them immediately to Telegram when no Telegram turn is active.",
435
- promptSnippet:
436
- "Queue files for the active Telegram reply; outside Telegram turns, send files directly to Telegram.",
437
- promptGuidelines: [
438
- "When handling a [telegram] message and the user asked for a file or generated artifact, call telegram_attach with the local path instead of only mentioning the path in text.",
439
- "When a local/TUI user explicitly asks to send a generated file to Telegram, telegram_attach can deliver it to the paired/default Telegram chat even without an active Telegram turn.",
440
- "For an explicit thread target, provide chat_id plus thread_id; registered multi-instance followers default to their assigned thread target.",
441
- ],
441
+ promptSnippet: TELEGRAM_ATTACH_PROMPT_SNIPPET,
442
+ promptGuidelines: [...TELEGRAM_ATTACH_PROMPT_GUIDELINES],
442
443
  parameters: Type.Object({
443
444
  paths: Type.Array(
444
445
  Type.String({ description: "Local file path to attach" }),
@@ -499,14 +500,8 @@ export function registerTelegramOutboundMessageTool(
499
500
  label: "Telegram Message",
500
501
  description:
501
502
  "Send a Markdown text message directly to the paired/default Telegram chat or an explicit chat_id. Hidden telegram_button comments in the text become attached inline prompt buttons.",
502
- promptSnippet:
503
- "Send direct Telegram Markdown text when the user explicitly asks for Telegram delivery outside the normal reply flow.",
504
- promptGuidelines: [
505
- "Use telegram_message only when the user explicitly asks to send a message to Telegram from the local/TUI side, or names a concrete Telegram delivery target.",
506
- "For an explicit thread target, provide chat_id plus thread_id; registered multi-instance followers default to their assigned thread target.",
507
- "Add buttons by embedding the same top-level telegram_button HTML comments used in normal Telegram replies; Telegram does not support standalone buttons.",
508
- "Do not use this tool for ordinary Telegram-originated replies; answer normally so the bridge can deliver the active turn reply.",
509
- ],
503
+ promptSnippet: TELEGRAM_MESSAGE_PROMPT_SNIPPET,
504
+ promptGuidelines: [...TELEGRAM_MESSAGE_PROMPT_GUIDELINES],
510
505
  parameters: Type.Object({
511
506
  text: Type.String({ description: "Message text to send" }),
512
507
  chat_id: Type.Optional(
package/lib/pi.ts CHANGED
@@ -120,6 +120,8 @@ export interface PiExtensionApiRuntimePorts {
120
120
  getCommands: ExtensionAPI["getCommands"];
121
121
  getThinkingLevel: ExtensionAPI["getThinkingLevel"];
122
122
  setThinkingLevel: ExtensionAPI["setThinkingLevel"];
123
+ getActiveTools: ExtensionAPI["getActiveTools"];
124
+ setActiveTools: ExtensionAPI["setActiveTools"];
123
125
  setModel: ExtensionAPI["setModel"];
124
126
  }
125
127
 
@@ -131,6 +133,8 @@ export function createExtensionApiRuntimePorts(
131
133
  | "getCommands"
132
134
  | "getThinkingLevel"
133
135
  | "setThinkingLevel"
136
+ | "getActiveTools"
137
+ | "setActiveTools"
134
138
  | "setModel"
135
139
  >,
136
140
  ): PiExtensionApiRuntimePorts {
@@ -141,6 +145,8 @@ export function createExtensionApiRuntimePorts(
141
145
  getCommands: () => api.getCommands(),
142
146
  getThinkingLevel: () => api.getThinkingLevel(),
143
147
  setThinkingLevel: (level) => api.setThinkingLevel(level),
148
+ getActiveTools: () => api.getActiveTools(),
149
+ setActiveTools: (names) => api.setActiveTools(names),
144
150
  setModel: (model) => api.setModel(model),
145
151
  };
146
152
  }
package/lib/prompts.ts CHANGED
@@ -18,6 +18,106 @@ const TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX = `
18
18
 
19
19
  Telegram turn note: Call \`telegram_help\` if you need the pi-telegram bridge action contract.`;
20
20
 
21
+ export const TELEGRAM_ATTACH_PROMPT_SNIPPET =
22
+ "Queue files for the active Telegram reply; outside Telegram turns, send files directly to Telegram.";
23
+ export const TELEGRAM_ATTACH_PROMPT_GUIDELINES = [
24
+ "When handling a [telegram] message and the user asked for a file or generated artifact, call telegram_attach with the local path instead of only mentioning the path in text.",
25
+ "When a local/TUI user explicitly asks to send a generated file to Telegram, telegram_attach can deliver it to the paired/default Telegram chat even without an active Telegram turn.",
26
+ "For an explicit thread target, provide chat_id plus thread_id; registered multi-instance followers default to their assigned thread target.",
27
+ ] as const;
28
+ export const TELEGRAM_MESSAGE_PROMPT_SNIPPET =
29
+ "Send direct Telegram Markdown text when the user explicitly asks for Telegram delivery outside the normal reply flow.";
30
+ export const TELEGRAM_MESSAGE_PROMPT_GUIDELINES = [
31
+ "Use telegram_message only when the user explicitly asks to send a message to Telegram from the local/TUI side, or names a concrete Telegram delivery target.",
32
+ "For an explicit thread target, provide chat_id plus thread_id; registered multi-instance followers default to their assigned thread target.",
33
+ "Add buttons by embedding the same top-level telegram_button HTML comments used in normal Telegram replies; Telegram does not support standalone buttons.",
34
+ "Do not use this tool for ordinary Telegram-originated replies; answer normally so the bridge can deliver the active turn reply.",
35
+ ] as const;
36
+
37
+ const TELEGRAM_MODEL_CONTEXT_TOOL_NAMES = new Set([
38
+ "telegram_attach",
39
+ "telegram_message",
40
+ "telegram_help",
41
+ ]);
42
+ const TELEGRAM_MODEL_CONTEXT_MEMORY_KEY = Symbol.for(
43
+ "@llblab/pi-telegram:model-context-suspended-tools",
44
+ );
45
+
46
+ export interface TelegramModelContextAvailabilityMemory {
47
+ suspended: boolean;
48
+ toolNames: Set<string>;
49
+ }
50
+
51
+ function getTelegramModelContextAvailabilityMemory(): TelegramModelContextAvailabilityMemory {
52
+ const globals = globalThis as unknown as Record<symbol, unknown>;
53
+ const existing = globals[TELEGRAM_MODEL_CONTEXT_MEMORY_KEY];
54
+ if (
55
+ existing &&
56
+ typeof existing === "object" &&
57
+ "toolNames" in existing &&
58
+ (existing as { toolNames?: unknown }).toolNames instanceof Set
59
+ ) {
60
+ return existing as TelegramModelContextAvailabilityMemory;
61
+ }
62
+ const memory: TelegramModelContextAvailabilityMemory = {
63
+ suspended: false,
64
+ toolNames: new Set<string>(),
65
+ };
66
+ globals[TELEGRAM_MODEL_CONTEXT_MEMORY_KEY] = memory;
67
+ return memory;
68
+ }
69
+
70
+ export interface TelegramModelContextAvailabilityRuntime {
71
+ reconcile: () => void;
72
+ }
73
+
74
+ export function createTelegramModelContextAvailabilityRuntime(deps: {
75
+ getActiveTools: () => string[];
76
+ setActiveTools: (names: string[]) => void;
77
+ isAvailable: () => boolean;
78
+ canReconcile?: () => boolean;
79
+ memory?: TelegramModelContextAvailabilityMemory;
80
+ }): TelegramModelContextAvailabilityRuntime {
81
+ const memory =
82
+ deps.memory ?? getTelegramModelContextAvailabilityMemory();
83
+ return {
84
+ reconcile() {
85
+ if (deps.canReconcile && !deps.canReconcile()) return;
86
+ const activeTools = deps.getActiveTools();
87
+ if (!deps.isAvailable()) {
88
+ if (!memory.suspended) {
89
+ memory.toolNames.clear();
90
+ for (const name of activeTools) {
91
+ if (TELEGRAM_MODEL_CONTEXT_TOOL_NAMES.has(name)) {
92
+ memory.toolNames.add(name);
93
+ }
94
+ }
95
+ memory.suspended = true;
96
+ }
97
+ const nextTools = activeTools.filter(
98
+ (name) => !TELEGRAM_MODEL_CONTEXT_TOOL_NAMES.has(name),
99
+ );
100
+ if (nextTools.length !== activeTools.length) {
101
+ deps.setActiveTools(nextTools);
102
+ }
103
+ return;
104
+ }
105
+ if (!memory.suspended) return;
106
+ const nextTools = [...activeTools];
107
+ for (const name of TELEGRAM_MODEL_CONTEXT_TOOL_NAMES) {
108
+ if (memory.toolNames.has(name) && !nextTools.includes(name)) {
109
+ nextTools.push(name);
110
+ }
111
+ }
112
+ memory.toolNames.clear();
113
+ memory.suspended = false;
114
+ if (nextTools.length !== activeTools.length) {
115
+ deps.setActiveTools(nextTools);
116
+ }
117
+ },
118
+ };
119
+ }
120
+
21
121
  function buildTelegramHelpText(profileName?: string): string {
22
122
  const diagnosticsPaths = getTelegramDiagnosticsDisplayPaths(profileName);
23
123
  return `--- TELEGRAM BRIDGE HELP ---
@@ -143,11 +243,25 @@ export function createTelegramBeforeAgentStartHook(
143
243
  });
144
244
  }
145
245
 
246
+ function stripTelegramToolMetadataFromSystemPrompt(
247
+ systemPrompt: string,
248
+ ): string {
249
+ const telegramLines = new Set([
250
+ `- telegram_attach: ${TELEGRAM_ATTACH_PROMPT_SNIPPET}`,
251
+ `- telegram_message: ${TELEGRAM_MESSAGE_PROMPT_SNIPPET}`,
252
+ ...TELEGRAM_ATTACH_PROMPT_GUIDELINES.map((line) => `- ${line}`),
253
+ ...TELEGRAM_MESSAGE_PROMPT_GUIDELINES.map((line) => `- ${line}`),
254
+ ]);
255
+ return systemPrompt
256
+ .split("\n")
257
+ .filter((line) => !telegramLines.has(line))
258
+ .join("\n");
259
+ }
260
+
146
261
  export interface TelegramProactivePromptHookDeps<TContext> {
147
262
  baseHook?: (event: BeforeAgentStartEvent) => { systemPrompt: string };
148
- isConfigured: () => boolean;
149
- isProactivePushEnabled: () => boolean;
150
- isCurrentOwner: (ctx: TContext) => boolean;
263
+ reconcileAvailability?: () => void;
264
+ isAvailable: (ctx: TContext) => boolean;
151
265
  }
152
266
 
153
267
  export function createTelegramProactiveBeforeAgentStartHook<TContext>(
@@ -158,10 +272,14 @@ export function createTelegramProactiveBeforeAgentStartHook<TContext>(
158
272
  ) => Promise<{ systemPrompt: string }> {
159
273
  const baseHook = deps.baseHook ?? createTelegramBeforeAgentStartHook();
160
274
  return async (event, ctx) => {
161
- if (!deps.isConfigured()) return { systemPrompt: event.systemPrompt };
162
- const result = baseHook(event);
163
- if (!deps.isProactivePushEnabled()) return result;
164
- if (!deps.isCurrentOwner(ctx)) return result;
165
- return result;
275
+ deps.reconcileAvailability?.();
276
+ if (!deps.isAvailable(ctx)) {
277
+ return {
278
+ systemPrompt: stripTelegramToolMetadataFromSystemPrompt(
279
+ event.systemPrompt,
280
+ ),
281
+ };
282
+ }
283
+ return baseHook(event);
166
284
  };
167
285
  }