@llblab/pi-telegram 0.17.4 → 0.18.0

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 (62) hide show
  1. package/AGENTS.md +67 -32
  2. package/BACKLOG.md +59 -14
  3. package/CHANGELOG.md +40 -15
  4. package/README.md +63 -35
  5. package/docs/README.md +3 -1
  6. package/docs/architecture.md +55 -23
  7. package/docs/callback-namespaces.md +1 -1
  8. package/docs/inbound.md +1 -1
  9. package/docs/locks.md +0 -2
  10. package/docs/multi-instance-bus.md +483 -0
  11. package/docs/outbound.md +4 -3
  12. package/docs/public-api.md +12 -10
  13. package/docs/sections.md +2 -2
  14. package/docs/ui-style.md +76 -0
  15. package/index.ts +789 -32
  16. package/lib/bindings.ts +68 -12
  17. package/lib/bus-api.ts +314 -0
  18. package/lib/bus-follower.ts +853 -0
  19. package/lib/bus-leader.ts +915 -0
  20. package/lib/bus.ts +866 -0
  21. package/lib/command-templates.ts +9 -11
  22. package/lib/commands.ts +133 -47
  23. package/lib/config.ts +53 -5
  24. package/lib/lifecycle.ts +23 -7
  25. package/lib/locks.ts +230 -66
  26. package/lib/media.ts +30 -2
  27. package/lib/menu-model.ts +48 -17
  28. package/lib/menu-queue.ts +51 -20
  29. package/lib/menu-settings.ts +9 -5
  30. package/lib/menu-status.ts +3 -0
  31. package/lib/menu-thinking.ts +3 -0
  32. package/lib/menu.ts +67 -26
  33. package/lib/outbound-attachments.ts +102 -17
  34. package/lib/outbound-buttons.ts +6 -2
  35. package/lib/outbound-voice.ts +31 -11
  36. package/lib/outbound.ts +6 -4
  37. package/lib/ownership.ts +119 -0
  38. package/lib/pi.ts +26 -3
  39. package/lib/polling.ts +477 -7
  40. package/lib/preview.ts +141 -88
  41. package/lib/prompt-templates.ts +3 -3
  42. package/lib/prompts.ts +80 -30
  43. package/lib/queue.ts +193 -91
  44. package/lib/rendering.ts +0 -25
  45. package/lib/replies.ts +187 -55
  46. package/lib/routing.ts +1673 -9
  47. package/lib/runtime-log.ts +123 -0
  48. package/lib/runtime.ts +84 -12
  49. package/lib/sections.ts +28 -21
  50. package/lib/setup.ts +1 -1
  51. package/lib/status.ts +532 -9
  52. package/lib/sync.ts +618 -0
  53. package/lib/target.ts +49 -0
  54. package/lib/telegram-api.ts +405 -40
  55. package/lib/text-groups.ts +5 -1
  56. package/lib/thread-reconciler.ts +915 -0
  57. package/lib/threads.ts +2205 -0
  58. package/lib/turns.ts +48 -3
  59. package/lib/updates.ts +355 -32
  60. package/package.json +24 -2
  61. package/screenshot.png +0 -0
  62. package/docs/telegram-bot-api-rich-messages.md +0 -890
package/lib/preview.ts CHANGED
@@ -6,6 +6,10 @@
6
6
 
7
7
  import { normalizeTelegramNativeMarkdown } from "./replies.ts";
8
8
  import { stripTelegramCommentMarkupForPreview } from "./outbound.ts";
9
+ import {
10
+ getTelegramTargetThreadParams,
11
+ type TelegramTarget,
12
+ } from "./target.ts";
9
13
  import { shouldSuppressPreviewForVoice } from "./voice.ts";
10
14
 
11
15
  const TELEGRAM_PREVIEW_THROTTLE_MS = 0;
@@ -29,9 +33,7 @@ export interface TelegramPreviewRuntimeState extends TelegramPreviewState {
29
33
 
30
34
  export type TelegramPreviewReplyMarkup = unknown;
31
35
 
32
- export interface TelegramPreviewRuntimeDeps<
33
- TReplyMarkup = TelegramPreviewReplyMarkup,
34
- > {
36
+ export interface TelegramPreviewRuntimeDeps {
35
37
  getState: () => TelegramPreviewRuntimeState | undefined;
36
38
  setState: (state: TelegramPreviewRuntimeState | undefined) => void;
37
39
  clearScheduledFlush: (state: TelegramPreviewRuntimeState) => void;
@@ -59,6 +61,8 @@ export interface TelegramPreviewRuntimeDeps<
59
61
 
60
62
  export interface TelegramPreviewActiveTurn {
61
63
  chatId: number;
64
+ replyToMessageId?: number;
65
+ target?: TelegramTarget;
62
66
  voiceReplyPreferred?: boolean;
63
67
  voiceReplyRequired?: boolean;
64
68
  }
@@ -77,7 +81,7 @@ export interface TelegramAssistantMessagePreviewStartDeps<
77
81
  chatId: number,
78
82
  markdown: string,
79
83
  replyToMessageId?: number,
80
- options?: { replyMarkup?: TReplyMarkup },
84
+ options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
81
85
  ) => Promise<boolean>;
82
86
  }
83
87
 
@@ -88,7 +92,10 @@ export interface TelegramAssistantMessagePreviewUpdateDeps<TMessage> {
88
92
  setState: (state: TelegramPreviewRuntimeState | undefined) => void;
89
93
  createPreviewState: () => TelegramPreviewRuntimeState;
90
94
  getMessageText: (message: TMessage) => string;
91
- schedulePreviewFlush: (chatId: number) => void;
95
+ schedulePreviewFlush: (
96
+ chatId: number,
97
+ options?: { target?: TelegramTarget },
98
+ ) => void;
92
99
  }
93
100
 
94
101
  export type TelegramAssistantMessagePreviewHookDeps<
@@ -110,9 +117,7 @@ export interface TelegramAssistantMessagePreviewHooks<TMessage> {
110
117
  ) => Promise<void>;
111
118
  }
112
119
 
113
- export interface TelegramPreviewControllerDeps<
114
- TReplyMarkup = TelegramPreviewReplyMarkup,
115
- > {
120
+ export interface TelegramPreviewControllerDeps {
116
121
  getDefaultReplyToMessageId?: () => number | undefined;
117
122
  maxMessageLength?: number;
118
123
  initialDraftSupport?: TelegramDraftSupport;
@@ -141,29 +146,36 @@ export interface TelegramPreviewControllerDeps<
141
146
  ) => void;
142
147
  }
143
148
 
144
- export interface TelegramPreviewController<
145
- TReplyMarkup = TelegramPreviewReplyMarkup,
146
- > {
149
+ export interface TelegramPreviewController {
147
150
  getState: () => TelegramPreviewRuntimeState | undefined;
148
151
  setState: (state: TelegramPreviewRuntimeState | undefined) => void;
149
152
  setPendingText: (text: string) => void;
150
153
  createState: () => TelegramPreviewRuntimeState;
151
154
  resetState: () => void;
152
- clear: (chatId: number) => Promise<void>;
153
- flush: (chatId: number) => Promise<void>;
154
- scheduleFlush: (chatId: number) => void;
155
- finalize: (chatId: number, replyToMessageId?: number) => Promise<boolean>;
155
+ clear: (
156
+ chatId: number,
157
+ options?: { target?: TelegramTarget },
158
+ ) => Promise<void>;
159
+ flush: (
160
+ chatId: number,
161
+ options?: { target?: TelegramTarget },
162
+ ) => Promise<void>;
163
+ scheduleFlush: (
164
+ chatId: number,
165
+ options?: { target?: TelegramTarget },
166
+ ) => void;
167
+ finalize: (
168
+ chatId: number,
169
+ replyToMessageId?: number,
170
+ options?: { target?: TelegramTarget },
171
+ ) => Promise<boolean>;
156
172
  }
157
173
 
158
- export type TelegramPreviewControllerRuntimeDeps<
159
- TReplyMarkup = TelegramPreviewReplyMarkup,
160
- > = TelegramPreviewControllerDeps<TReplyMarkup>;
174
+ export type TelegramPreviewControllerRuntimeDeps = TelegramPreviewControllerDeps;
161
175
 
162
- export function createTelegramPreviewControllerRuntime<
163
- TReplyMarkup = TelegramPreviewReplyMarkup,
164
- >(
165
- deps: TelegramPreviewControllerRuntimeDeps<TReplyMarkup>,
166
- ): TelegramPreviewController<TReplyMarkup> {
176
+ export function createTelegramPreviewControllerRuntime(
177
+ deps: TelegramPreviewControllerRuntimeDeps,
178
+ ): TelegramPreviewController {
167
179
  return createTelegramPreviewController({
168
180
  getDefaultReplyToMessageId: deps.getDefaultReplyToMessageId,
169
181
  maxMessageLength: deps.maxMessageLength,
@@ -180,7 +192,7 @@ export function createTelegramPreviewControllerRuntime<
180
192
  export interface TelegramAssistantPreviewRuntimeDeps<
181
193
  TMessage,
182
194
  TReplyMarkup = TelegramPreviewReplyMarkup,
183
- > extends TelegramPreviewControllerRuntimeDeps<TReplyMarkup> {
195
+ > extends TelegramPreviewControllerRuntimeDeps {
184
196
  getActiveTurn: () => TelegramPreviewActiveTurn | undefined;
185
197
  isAssistantMessage: (message: TMessage) => boolean;
186
198
  getMessageText: (message: TMessage) => string;
@@ -188,38 +200,43 @@ export interface TelegramAssistantPreviewRuntimeDeps<
188
200
  chatId: number,
189
201
  replyToMessageId: number | undefined,
190
202
  markdown: string,
191
- options?: { replyMarkup?: TReplyMarkup },
203
+ options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
192
204
  ) => Promise<number | undefined>;
193
205
  }
194
206
 
195
207
  export type TelegramAssistantPreviewRuntime<
196
208
  TMessage,
197
209
  TReplyMarkup = TelegramPreviewReplyMarkup,
198
- > = TelegramPreviewController<TReplyMarkup> &
210
+ > = TelegramPreviewController &
199
211
  TelegramAssistantMessagePreviewHooks<TMessage> & {
200
212
  finalizeMarkdown: (
201
213
  chatId: number,
202
214
  markdown: string,
203
215
  replyToMessageId?: number,
204
- options?: { replyMarkup?: TReplyMarkup },
216
+ options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
205
217
  ) => Promise<boolean>;
206
218
  };
207
219
 
208
- export function createTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>(deps: {
220
+ export function createTelegramNativeMarkdownPreviewFinalizer<
221
+ TReplyMarkup,
222
+ >(deps: {
209
223
  getState: () => TelegramPreviewRuntimeState | undefined;
210
- clear: (chatId: number) => Promise<void>;
224
+ clear: (
225
+ chatId: number,
226
+ options?: { target?: TelegramTarget },
227
+ ) => Promise<void>;
211
228
  discard?: () => void;
212
229
  sendMarkdownReply: (
213
230
  chatId: number,
214
231
  replyToMessageId: number | undefined,
215
232
  markdown: string,
216
- options?: { replyMarkup?: TReplyMarkup },
233
+ options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
217
234
  ) => Promise<number | undefined>;
218
235
  }): (
219
236
  chatId: number,
220
237
  markdown: string,
221
238
  replyToMessageId?: number,
222
- options?: { replyMarkup?: TReplyMarkup },
239
+ options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
223
240
  ) => Promise<boolean> {
224
241
  return async (chatId, markdown, replyToMessageId, options) => {
225
242
  const state = deps.getState();
@@ -262,11 +279,9 @@ export function createTelegramAssistantPreviewRuntime<
262
279
  };
263
280
  }
264
281
 
265
- export function createTelegramPreviewController<
266
- TReplyMarkup = TelegramPreviewReplyMarkup,
267
- >(
268
- deps: TelegramPreviewControllerDeps<TReplyMarkup>,
269
- ): TelegramPreviewController<TReplyMarkup> {
282
+ export function createTelegramPreviewController(
283
+ deps: TelegramPreviewControllerDeps,
284
+ ): TelegramPreviewController {
270
285
  let state: TelegramPreviewRuntimeState | undefined;
271
286
  const clearTimer = deps.clearTimer ?? clearTimeout;
272
287
  const setTimer =
@@ -279,7 +294,7 @@ export function createTelegramPreviewController<
279
294
  deps.maxMessageLength ?? TELEGRAM_DRAFT_PREVIEW_MAX_CHARS;
280
295
  let draftSupport = deps.initialDraftSupport ?? "unknown";
281
296
  let nextDraftId = 0;
282
- const getRuntimeDeps = (): TelegramPreviewRuntimeDeps<TReplyMarkup> => ({
297
+ const getRuntimeDeps = (): TelegramPreviewRuntimeDeps => ({
283
298
  getState: () => state,
284
299
  setState: (nextState) => {
285
300
  state = nextState;
@@ -310,25 +325,27 @@ export function createTelegramPreviewController<
310
325
  setPendingText: (text) => {
311
326
  if (state) state.pendingText = text;
312
327
  },
313
- createState: () => createTelegramPreviewRuntimeState(draftSupport),
328
+ createState: () => createTelegramPreviewRuntimeState(),
314
329
  resetState: () => {
315
- state = createTelegramPreviewRuntimeState(draftSupport);
330
+ state = createTelegramPreviewRuntimeState();
316
331
  },
317
- clear: (chatId) => clearTelegramPreview(chatId, getRuntimeDeps()),
318
- flush: (chatId) => flushTelegramPreview(chatId, getRuntimeDeps()),
319
- scheduleFlush: (chatId) => {
332
+ clear: (chatId, options) =>
333
+ clearTelegramPreview(chatId, getRuntimeDeps(), options),
334
+ flush: (chatId, options) =>
335
+ flushTelegramPreview(chatId, getRuntimeDeps(), options),
336
+ scheduleFlush: (chatId, options) => {
320
337
  if (!state || state.flushTimer) return;
321
338
  if (throttleMs <= 0) {
322
- void flushTelegramPreview(chatId, getRuntimeDeps());
339
+ void flushTelegramPreview(chatId, getRuntimeDeps(), options);
323
340
  return;
324
341
  }
325
342
  state.flushTimer = setTimer(() => {
326
- void flushTelegramPreview(chatId, getRuntimeDeps());
343
+ void flushTelegramPreview(chatId, getRuntimeDeps(), options);
327
344
  }, throttleMs);
328
345
  state.flushTimer.unref?.();
329
346
  },
330
- finalize: (chatId, _replyToMessageId) =>
331
- finalizeTelegramPreview(chatId, getRuntimeDeps()),
347
+ finalize: (chatId, _replyToMessageId, options) =>
348
+ finalizeTelegramPreview(chatId, getRuntimeDeps(), options),
332
349
  };
333
350
  }
334
351
 
@@ -373,7 +390,14 @@ export async function handleTelegramAssistantMessagePreviewStart<
373
390
  ) {
374
391
  const previousText = state.pendingText.trim();
375
392
  if (previousText.length > 0) {
376
- await deps.finalizeMarkdownPreview(turn.chatId, previousText);
393
+ await deps.finalizeMarkdownPreview(
394
+ turn.chatId,
395
+ previousText,
396
+ turn.replyToMessageId,
397
+ {
398
+ target: turn.target,
399
+ },
400
+ );
377
401
  } else {
378
402
  await deps.finalizePreview(turn.chatId);
379
403
  }
@@ -396,7 +420,7 @@ export async function handleTelegramAssistantMessagePreviewUpdate<TMessage>(
396
420
  state.pendingText = stripTelegramCommentMarkupForPreview(
397
421
  deps.getMessageText(message),
398
422
  );
399
- deps.schedulePreviewFlush(turn.chatId);
423
+ deps.schedulePreviewFlush(turn.chatId, { target: turn.target });
400
424
  }
401
425
 
402
426
  export function buildTelegramPreviewFinalText(
@@ -407,9 +431,7 @@ export function buildTelegramPreviewFinalText(
407
431
  return state.lastSentText.trim() || undefined;
408
432
  }
409
433
 
410
- export function createTelegramPreviewRuntimeState(
411
- draftSupport: TelegramDraftSupport,
412
- ): TelegramPreviewRuntimeState {
434
+ export function createTelegramPreviewRuntimeState(): TelegramPreviewRuntimeState {
413
435
  return {
414
436
  mode: "draft",
415
437
  pendingText: "",
@@ -435,12 +457,10 @@ export function shouldUseTelegramDraftPreview(_options: {
435
457
  return true;
436
458
  }
437
459
 
438
- export async function clearTelegramPreview<
439
- TReplyMarkup = TelegramPreviewReplyMarkup,
440
- >(
460
+ export async function clearTelegramPreview(
441
461
  chatId: number,
442
- deps: TelegramPreviewRuntimeDeps<TReplyMarkup>,
443
- options: { awaitFlush?: boolean } = {},
462
+ deps: TelegramPreviewRuntimeDeps,
463
+ options: { awaitFlush?: boolean; target?: TelegramTarget } = {},
444
464
  ): Promise<void> {
445
465
  const state = deps.getState();
446
466
  if (!state) return;
@@ -453,7 +473,9 @@ export async function clearTelegramPreview<
453
473
  deps.setState(undefined);
454
474
  if (state.mode === "draft" && state.draftId !== undefined) {
455
475
  try {
456
- await deps.sendDraft(chatId, state.draftId, undefined);
476
+ await deps.sendDraft(chatId, state.draftId, undefined, {
477
+ ...getTelegramTargetThreadParams(options.target ?? { chatId }),
478
+ });
457
479
  } catch (error) {
458
480
  deps.recordRuntimeEvent?.("preview", error, {
459
481
  phase: "clear-draft",
@@ -493,8 +515,11 @@ function createTelegramDraftInlineState(): TelegramDraftInlineState {
493
515
  };
494
516
  }
495
517
 
496
- function isTelegramDraftInlineStateClosed(state: TelegramDraftInlineState): boolean {
497
- return state.codeTicks === 0 &&
518
+ function isTelegramDraftInlineStateClosed(
519
+ state: TelegramDraftInlineState,
520
+ ): boolean {
521
+ return (
522
+ state.codeTicks === 0 &&
498
523
  !state.htmlComment &&
499
524
  !state.displayMath &&
500
525
  !state.fence &&
@@ -504,7 +529,8 @@ function isTelegramDraftInlineStateClosed(state: TelegramDraftInlineState): bool
504
529
  !state.emphasisUnderscore &&
505
530
  !state.strike &&
506
531
  !state.linkText &&
507
- !state.linkDestination;
532
+ !state.linkDestination
533
+ );
508
534
  }
509
535
 
510
536
  function countRepeatedChars(text: string, index: number, char: string): number {
@@ -515,16 +541,25 @@ function countRepeatedChars(text: string, index: number, char: string): number {
515
541
 
516
542
  function isEscapedMarkdownChar(text: string, index: number): boolean {
517
543
  let slashCount = 0;
518
- for (let cursor = index - 1; cursor >= 0 && text[cursor] === "\\"; cursor -= 1) {
544
+ for (
545
+ let cursor = index - 1;
546
+ cursor >= 0 && text[cursor] === "\\";
547
+ cursor -= 1
548
+ ) {
519
549
  slashCount += 1;
520
550
  }
521
551
  return slashCount % 2 === 1;
522
552
  }
523
553
 
524
- function isInlineDelimiterCandidate(text: string, index: number, length: number): boolean {
554
+ function isInlineDelimiterCandidate(
555
+ text: string,
556
+ index: number,
557
+ length: number,
558
+ ): boolean {
525
559
  const previous = text[index - 1] ?? "";
526
560
  const next = text[index + length] ?? "";
527
- if (!next || /\s/.test(next)) return previous.length > 0 && !/\s/.test(previous);
561
+ if (!next || /\s/.test(next))
562
+ return previous.length > 0 && !/\s/.test(previous);
528
563
  if (!previous || /\s/.test(previous)) return true;
529
564
  return /[\p{P}\p{S}]/u.test(previous) || /[\p{P}\p{S}]/u.test(next);
530
565
  }
@@ -580,12 +615,18 @@ function updateTelegramDraftInlineStateForLine(
580
615
  state.linkDestination = false;
581
616
  continue;
582
617
  }
583
- if (line.startsWith("~~", index) && isInlineDelimiterCandidate(line, index, 2)) {
618
+ if (
619
+ line.startsWith("~~", index) &&
620
+ isInlineDelimiterCandidate(line, index, 2)
621
+ ) {
584
622
  state.strike = !state.strike;
585
623
  index += 1;
586
624
  continue;
587
625
  }
588
- if (line.startsWith("**", index) && isInlineDelimiterCandidate(line, index, 2)) {
626
+ if (
627
+ line.startsWith("**", index) &&
628
+ isInlineDelimiterCandidate(line, index, 2)
629
+ ) {
589
630
  state.strongAsterisk = !state.strongAsterisk;
590
631
  index += 1;
591
632
  continue;
@@ -594,7 +635,10 @@ function updateTelegramDraftInlineStateForLine(
594
635
  state.emphasisAsterisk = !state.emphasisAsterisk;
595
636
  continue;
596
637
  }
597
- if (line.startsWith("__", index) && isInlineDelimiterCandidate(line, index, 2)) {
638
+ if (
639
+ line.startsWith("__", index) &&
640
+ isInlineDelimiterCandidate(line, index, 2)
641
+ ) {
598
642
  state.strongUnderscore = !state.strongUnderscore;
599
643
  index += 1;
600
644
  continue;
@@ -611,7 +655,11 @@ function updateTelegramDraftBlockStateForLine(
611
655
  ): boolean {
612
656
  const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
613
657
  if (state.fence) {
614
- if (new RegExp(`^ {0,3}${state.fence.marker}{${state.fence.length},}\\s*$`).test(line)) {
658
+ if (
659
+ new RegExp(
660
+ `^ {0,3}${state.fence.marker}{${state.fence.length},}\\s*$`,
661
+ ).test(line)
662
+ ) {
615
663
  state.fence = undefined;
616
664
  }
617
665
  return true;
@@ -622,7 +670,10 @@ function updateTelegramDraftBlockStateForLine(
622
670
  }
623
671
  if (fenceMatch) {
624
672
  const markerText = fenceMatch[1] ?? "```";
625
- state.fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
673
+ state.fence = {
674
+ marker: markerText[0] as "`" | "~",
675
+ length: markerText.length,
676
+ };
626
677
  return true;
627
678
  }
628
679
  if (line.trim() === "$$") {
@@ -654,9 +705,10 @@ export function getSafeTelegramRichMarkdownDraftPrefix(
654
705
  ): string | undefined {
655
706
  const source = markdown.trim();
656
707
  if (!source) return undefined;
657
- const limited = source.length > maxMessageLength
658
- ? source.slice(0, maxMessageLength)
659
- : source;
708
+ const limited =
709
+ source.length > maxMessageLength
710
+ ? source.slice(0, maxMessageLength)
711
+ : source;
660
712
  const safeEnd = findSafeTelegramRichMarkdownDraftEnd(limited);
661
713
  if (safeEnd > 0) return limited.slice(0, safeEnd).trimEnd() || undefined;
662
714
  let candidateEnd = limited.length;
@@ -683,15 +735,17 @@ function buildTelegramNativeMarkdownPreviewSnapshot(
683
735
  return { text: safeText };
684
736
  }
685
737
 
686
- async function performTelegramPreviewFlush<
687
- TReplyMarkup = TelegramPreviewReplyMarkup,
688
- >(
738
+ async function performTelegramPreviewFlush(
689
739
  chatId: number,
690
740
  state: TelegramPreviewRuntimeState,
691
- deps: TelegramPreviewRuntimeDeps<TReplyMarkup>,
741
+ deps: TelegramPreviewRuntimeDeps,
742
+ options: { target?: TelegramTarget } = {},
692
743
  ): Promise<void> {
693
744
  if (deps.canSend && !deps.canSend()) {
694
- await clearTelegramPreview(chatId, deps, { awaitFlush: false });
745
+ await clearTelegramPreview(chatId, deps, {
746
+ awaitFlush: false,
747
+ target: options.target,
748
+ });
695
749
  return;
696
750
  }
697
751
  const snapshot = buildTelegramNativeMarkdownPreviewSnapshot(
@@ -712,6 +766,7 @@ async function performTelegramPreviewFlush<
712
766
  chatId,
713
767
  draftId,
714
768
  normalizeTelegramNativeMarkdown(snapshot.text),
769
+ { ...getTelegramTargetThreadParams(options.target ?? { chatId }) },
715
770
  );
716
771
  deps.setDraftSupport("supported");
717
772
  state.mode = "draft";
@@ -728,11 +783,10 @@ async function performTelegramPreviewFlush<
728
783
  }
729
784
  }
730
785
 
731
- export async function flushTelegramPreview<
732
- TReplyMarkup = TelegramPreviewReplyMarkup,
733
- >(
786
+ export async function flushTelegramPreview(
734
787
  chatId: number,
735
- deps: TelegramPreviewRuntimeDeps<TReplyMarkup>,
788
+ deps: TelegramPreviewRuntimeDeps,
789
+ options: { target?: TelegramTarget } = {},
736
790
  ): Promise<void> {
737
791
  const state = deps.getState();
738
792
  if (!state) return;
@@ -746,7 +800,7 @@ export async function flushTelegramPreview<
746
800
  do {
747
801
  state.flushRequested = false;
748
802
  try {
749
- await performTelegramPreviewFlush(chatId, state, deps);
803
+ await performTelegramPreviewFlush(chatId, state, deps, options);
750
804
  } catch (error) {
751
805
  deps.recordRuntimeEvent?.("preview", error, {
752
806
  phase: "flush",
@@ -766,22 +820,21 @@ export async function flushTelegramPreview<
766
820
  }
767
821
  }
768
822
 
769
- export async function finalizeTelegramPreview<
770
- TReplyMarkup = TelegramPreviewReplyMarkup,
771
- >(
823
+ export async function finalizeTelegramPreview(
772
824
  chatId: number,
773
- deps: TelegramPreviewRuntimeDeps<TReplyMarkup>,
825
+ deps: TelegramPreviewRuntimeDeps,
826
+ options: { target?: TelegramTarget } = {},
774
827
  ): Promise<boolean> {
775
828
  const state = deps.getState();
776
829
  if (!state) return false;
777
830
  if (deps.canSend && !deps.canSend()) {
778
- await clearTelegramPreview(chatId, deps);
831
+ await clearTelegramPreview(chatId, deps, options);
779
832
  return false;
780
833
  }
781
- await flushTelegramPreview(chatId, deps);
834
+ await flushTelegramPreview(chatId, deps, options);
782
835
  const finalText = buildTelegramPreviewFinalText(state);
783
836
  if (!finalText) {
784
- await clearTelegramPreview(chatId, deps);
837
+ await clearTelegramPreview(chatId, deps, options);
785
838
  return false;
786
839
  }
787
840
  deps.setState(undefined);
@@ -1,7 +1,7 @@
1
1
  /**
2
- * π prompt-template bridge helpers
2
+ * Pi prompt-template bridge helpers
3
3
  * Zones: pi agent prompts, telegram controls, filesystem
4
- * Discovers π prompt-template slash commands and expands them before Telegram queue dispatch
4
+ * Discovers Pi prompt-template slash commands and expands them before Telegram queue dispatch
5
5
  */
6
6
 
7
7
  import { readFileSync } from "node:fs";
@@ -129,7 +129,7 @@ export function getTelegramPromptTemplateCommands(
129
129
  export function createTelegramPromptTemplateCommandGetter(
130
130
  deps: TelegramPromptTemplateCommandGetterDeps,
131
131
  ): () => TelegramPromptTemplateCommand[] {
132
- return function getPromptTemplateCommands() {
132
+ return () => {
133
133
  return getTelegramPromptTemplateCommands(
134
134
  deps.getCommands(),
135
135
  new Set([
package/lib/prompts.ts CHANGED
@@ -4,41 +4,85 @@
4
4
  * Owns Telegram-specific system prompt suffixes injected into pi agent turns
5
5
  */
6
6
 
7
- import type { BeforeAgentStartEvent } from "./pi.ts";
7
+ import { Type } from "@sinclair/typebox";
8
+
9
+ import type { BeforeAgentStartEvent, ExtensionAPI } from "./pi.ts";
8
10
  import { TELEGRAM_PREFIX } from "./turns.ts";
9
11
 
10
12
  const LOCAL_SYSTEM_PROMPT_SUFFIX = `
11
13
 
12
- Telegram bridge extension is available.
13
-
14
- Local/TUI Telegram delivery:
15
- - Answer ordinary local prompts normally; do not add Telegram action comments unless the user explicitly asks for Telegram delivery.
16
- - For explicit Telegram file delivery, call \`telegram_attach(local_path)\`. For explicit Telegram text delivery, call \`telegram_message(...)\`.
17
- - Direct local/TUI Telegram delivery requires this π instance to own \`/telegram-connect\`; if ownership is elsewhere, connect/take over first instead of bypassing the lock.
18
- `;
14
+ Telegram bridge available. Do not use it from local/TUI prompts unless explicitly asked.`;
19
15
 
20
16
  const TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX = `
21
17
 
22
- Telegram-originated turn context:
23
- - \`[telegram]\` marks Telegram-originated messages. Suffixes \`|from:user\` (sender) and \`|guest:group\` (guest mode — message from another chat where the bot is not a member) may be present; the bot sees the message as if forwarded from that user/chat.
24
- - \`[reply]\` is quoted context from the replied-to message, not a new instruction by itself. Suffix \`|from:user\` identifies the original author in guest-mode replies. Use it to resolve references like "this", "it", or "that message"; the actual instruction is before [reply] unless it explicitly asks to act on the quote.
25
- - \`[attachments]\` gives a base directory plus relative local files; resolve and read them as needed. \`[outputs]\` contains inbound-handler stdout such as transcriptions or extracted text for those attachments.
26
- - \`[time]\` gives the wall-clock time for this Telegram turn when the operator enabled time injection. Use it for relative-date requests like "today", "now", or scheduling; otherwise do not mention it.
27
- - \`[voice]\` describes Telegram voice reply policy for this turn. \`manual\` means answer normally and use explicit \`telegram_voice\` markup only when a spoken reply is useful; \`mirror\` means voice input prefers a voice reply; \`always\` means the final reply is expected to be converted to voice, so keep it TTS-friendly.
28
- - Unknown \`[callback] ...\` messages may be intended for another extension; if you see one, say the callback was not handled and the environment may be misconfigured.
29
-
30
- Telegram-visible output:
31
- - Telegram is mobile-first: keep answers easy to scan, use headings/lists when useful, and avoid unnecessarily huge blocks of text.
32
- - For formulas, use math delimiters like \`$E = mc^2$\` for inline formulas and \`$$\\nE = mc^2\\n$$\` for block formulas; do not wrap formulas in backticks unless they should render as literal code.
33
- - Use inline code for short copyable literals (commands, paths, IDs, symbols); avoid wide monospace blocks unless structure or literal code requires them.
34
- - For requested/generated files, call \`telegram_attach(local_path)\`; during Telegram turns it attaches files to the active reply, and during explicit local/TUI Telegram-delivery requests it sends files directly to the paired/default chat or an explicit \`chat_id\`. If a local/TUI user explicitly asks to send a text message to Telegram, use \`telegram_message\` with Markdown text; embed the same top-level \`telegram_button\` comments when inline prompt buttons are needed, because Telegram buttons must belong to a message. Direct local/TUI Telegram delivery requires this π instance to own \`/telegram-connect\`; if ownership is elsewhere, connect/take over first instead of bypassing the lock.
35
-
36
- Native outbound actions:
37
- - Use normal Rich Markdown for visible text. Use top-level column-zero hidden Markdown comments outside code, quotes, and lists only for native actions; the bridge strips them after agent_end and turns them into Telegram-native artifacts/reply_markup. Do not render button JSON, do not invent standalone button tools, and do not call/register transport/TTS/text-to-OGG tools for ordinary Telegram-turn voice/buttons.
38
- - \`telegram_voice\`: text is synthesized by the registered voice synthesis provider and delivered by pi-telegram. Use body text for multiline voice, \`<!-- telegram_voice text="Short summary" -->\` for explicit one-line text, or \`<!-- telegram_voice: Short summary -->\` for one-line text with no attributes. A companion summary is optional, no specific summary format is required. Keep it TTS-friendly; avoid raw Markdown, code, formulas, tables, or long lists.
39
- - \`telegram_button\`: callback prompt is routed back as a normal Telegram turn. Use \`<!-- telegram_button: OK -->\` when prompt equals label, \`<!-- telegram_button label=Continue prompt="Continue with the current plan." -->\` for one-line prompts, or body form \`<!-- telegram_button label="Show risks"\nList the main risks first.\n-->\` for multiline prompts. Do not put button comments inline after visible text, inside code fences, block quotes, lists, or indented examples; those are literal Markdown, not buttons.
40
- - If only hidden action comments would remain, add visible parent text like "Choose one:" so Telegram has a message to attach buttons to.
41
- `;
18
+ Telegram turn note: If context was compacted or you need the pi-telegram bridge contract, call tool \`telegram_help\`.`;
19
+
20
+ const TELEGRAM_HELP_TEXT = `--- TELEGRAM BRIDGE HELP ---
21
+
22
+ How to understand Telegram turns:
23
+ - \`[telegram|thread:name|from:user|guest:group]\` marks Telegram origin and attributes.
24
+ - \`thread\` is the visible Thread identity in Threaded Mode; it is not a bus role.
25
+ - \`[reply]\` is quoted context only; act on the user's current instruction.
26
+ - \`[attachments]\` are local files; \`[outputs]\` are handler results/transcripts; \`[time]\` is wall-clock context; \`[voice]\` gives reply-mode policy.
27
+
28
+ How to answer Telegram turns:
29
+ - Reply in concise, scannable mobile Telegram Rich Markdown.
30
+ - Use \`$...$\` for inline math and \`$$...$$\` for block math.
31
+ - Real code blocks must stay literal.
32
+ - For generated/requested files, call \`telegram_attach(local_path)\`; do not only mention the path.
33
+
34
+ Assistant-authored Telegram actions:
35
+ - \`telegram_voice\` and \`telegram_button\` are hidden top-level HTML comments, not Pi tools.
36
+ - Put action comments at column zero, outside code, quotes, lists, and indented examples.
37
+ - Voice forms: \`<!-- telegram_voice text="Short summary" -->\` or \`<!-- telegram_voice: Short summary -->\`.
38
+ - Keep voice text TTS-friendly; avoid raw Markdown, code, and tables in voice text.
39
+ - Voice delivery generates and attaches OGG automatically; do not also call \`telegram_attach\` for the same audio.
40
+ - Button forms: \`<!-- telegram_button: OK -->\`, \`<!-- telegram_button label=Continue prompt="Continue with the current plan." -->\`, or multiline \`<!-- telegram_button label="Show risks"\nList the main risks first.\n-->\`.
41
+ - If hidden comments would be the whole reply, add visible text such as \`Choose one:\`.
42
+
43
+ Local/TUI direct delivery:
44
+ - Do not send Telegram actions from local/TUI prompts unless explicitly asked.
45
+ - Use \`telegram_attach\` for files and \`telegram_message\` for direct Markdown text.
46
+ - Direct delivery requires this Pi instance to own \`/telegram-connect\` or be registered with the Threaded Mode bus.
47
+ - For explicit targets, pass \`chat_id\` plus optional \`thread_id\`; registered followers default to their assigned Thread target.
48
+ - Do not use \`telegram_message\` for ordinary Telegram-originated replies; answer normally and let the bridge deliver the active turn reply.
49
+
50
+ Threaded Mode:
51
+ - pi-telegram supports private-chat Threaded Mode when BotFather Topics/Threaded Mode are enabled.
52
+ - Product/user language is Thread; Bot API primitive names may still say topic.
53
+ - Threaded Mode has one leader transport and visible follower Pi processes joined manually through \`/telegram-connect\`.
54
+ - Thread names are bridge-assigned or preserved identities; do not invent rename prompts or use a rename tool.
55
+ - The \`All\` surface is for routing/control, not hidden Pi process creation.
56
+
57
+ Configurable handlers:
58
+ - \`telegram.json\` can add no-code \`inboundHandlers\`/\`outboundHandlers\` using command templates before writing an extension.
59
+ - For speech-to-text, configure an \`inboundHandlers\` entry matching \`type: "voice"\` or \`mime: "audio/*"\`; stdout becomes \`[outputs]\` prompt context.
60
+ - If command-template config is not enough, build a companion extension through the public pi-telegram APIs; do not import package-private \`lib/*\` paths.
61
+
62
+ Debugging pi-telegram:
63
+ - Inspect \`~/.pi/agent/tmp/telegram/state.json\` for runtime state, roster, bindings, slots, reservations, and diagnostics.
64
+ - Inspect \`~/.pi/agent/tmp/telegram/logs.jsonl\` for redacted runtime event evidence.
65
+ - Use terminal \`telegram-status\` for compact human health; use \`telegram-status --debug\` for the full human-readable diagnostic dump.`;
66
+
67
+ export function getTelegramHelpText(): string {
68
+ return TELEGRAM_HELP_TEXT;
69
+ }
70
+
71
+ export function registerTelegramHelpTool(pi: ExtensionAPI): void {
72
+ pi.registerTool({
73
+ name: "telegram_help",
74
+ label: "Telegram Help",
75
+ description:
76
+ "Read pi-telegram usage guidance for delivery actions, Threaded Mode, handlers, formatting, and debugging.",
77
+ parameters: Type.Object({}),
78
+ async execute() {
79
+ return {
80
+ content: [{ type: "text", text: getTelegramHelpText() }],
81
+ details: {},
82
+ };
83
+ },
84
+ });
85
+ }
42
86
 
43
87
  export function buildTelegramBridgeSystemPrompt(options: {
44
88
  prompt: string;
@@ -48,7 +92,13 @@ export function buildTelegramBridgeSystemPrompt(options: {
48
92
  telegramTurnSystemPromptSuffix: string;
49
93
  }): { systemPrompt: string } {
50
94
  const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
51
- const telegramTurn = options.prompt.trimStart().startsWith(telegramPrefix);
95
+ const telegramHead = telegramPrefix.endsWith("]")
96
+ ? telegramPrefix.slice(0, -1)
97
+ : telegramPrefix;
98
+ const trimmedPrompt = options.prompt.trimStart();
99
+ const telegramTurn =
100
+ trimmedPrompt.startsWith(`${telegramHead}]`) ||
101
+ trimmedPrompt.startsWith(`${telegramHead}|`);
52
102
  const telegramSuffix = telegramTurn
53
103
  ? `${options.telegramTurnSystemPromptSuffix}\n- The current user message came from Telegram.`
54
104
  : "";
@@ -92,7 +142,7 @@ export function createTelegramProactiveBeforeAgentStartHook<TContext>(
92
142
  ctx: TContext,
93
143
  ) => Promise<{ systemPrompt: string }> {
94
144
  const baseHook = deps.baseHook ?? createTelegramBeforeAgentStartHook();
95
- return async function onBeforeAgentStart(event, ctx) {
145
+ return async (event, ctx) => {
96
146
  if (!deps.isConfigured()) return { systemPrompt: event.systemPrompt };
97
147
  const result = baseHook(event);
98
148
  if (!deps.isProactivePushEnabled()) return result;