@llblab/pi-telegram 0.20.5 → 0.21.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.
package/lib/lifecycle.ts CHANGED
@@ -6,10 +6,13 @@
6
6
 
7
7
  import type {
8
8
  AgentEndEvent,
9
+ AgentSettledEvent,
9
10
  AgentStartEvent,
11
+ AssistantMessageEvent,
10
12
  BeforeAgentStartEvent,
11
13
  ExtensionAPI,
12
14
  ExtensionContext,
15
+ InputEvent,
13
16
  SessionBeforeCompactEvent,
14
17
  SessionCompactEvent,
15
18
  SessionShutdownEvent,
@@ -47,6 +50,7 @@ type TelegramLifecycleModel = ExtensionContext["model"];
47
50
  type TelegramLifecycleMessage = AgentEndEvent["messages"][number];
48
51
 
49
52
  export interface TelegramLifecycleRegistrationDeps {
53
+ onInput?: (event: InputEvent, ctx: ExtensionContext) => Promise<void> | void;
50
54
  onSessionStart: (
51
55
  event: SessionStartEvent,
52
56
  ctx: ExtensionContext,
@@ -92,10 +96,17 @@ export interface TelegramLifecycleRegistrationDeps {
92
96
  ctx: ExtensionContext,
93
97
  ) => Promise<void>;
94
98
  onMessageUpdate: (
95
- event: { message: TelegramLifecycleMessage },
99
+ event: {
100
+ message: TelegramLifecycleMessage;
101
+ assistantMessageEvent?: AssistantMessageEvent;
102
+ },
96
103
  ctx: ExtensionContext,
97
104
  ) => Promise<void>;
98
105
  onAgentEnd: (event: AgentEndEvent, ctx: ExtensionContext) => Promise<void>;
106
+ onAgentSettled?: (
107
+ event: AgentSettledEvent,
108
+ ctx: ExtensionContext,
109
+ ) => Promise<void> | void;
99
110
  }
100
111
 
101
112
  export interface TelegramSessionLifecycleHooks {
@@ -160,6 +171,7 @@ export interface TelegramCompactionObserverRuntimeDeps<TContext> {
160
171
  ) => void;
161
172
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
162
173
  recordRuntimeEvent?: (category: string, error: unknown) => void;
174
+ onCompactionAbandoned?: () => void;
163
175
  timeoutMs?: number;
164
176
  setTimer?: (callback: () => void, ms: number) => TelegramLifecycleTimer;
165
177
  clearTimer?: (timer: TelegramLifecycleTimer) => void;
@@ -210,6 +222,7 @@ export function createTelegramCompactionObserverRuntime<TContext>(
210
222
  "compact",
211
223
  new Error("Compaction observer timed out"),
212
224
  );
225
+ deps.onCompactionAbandoned?.();
213
226
  requestDispatch();
214
227
  }, timeoutMs);
215
228
  unrefTelegramLifecycleTimer(fallbackTimer);
@@ -253,11 +266,11 @@ export function createTelegramMessageActivityTypingHooks<
253
266
  const ensureTyping = (ctx: TContext): void => {
254
267
  if (deps.hasActiveTurn()) deps.startTypingLoop(ctx);
255
268
  };
256
- const handleMessageActivity = async (
269
+ const handleMessageActivity = async <TEvent>(
257
270
  phase: "start" | "update",
258
- event: Parameters<TelegramLifecycleRegistrationDeps["onMessageStart"]>[0],
271
+ event: TEvent,
259
272
  ctx: ExtensionContext,
260
- inner: TelegramLifecycleRegistrationDeps["onMessageStart"],
273
+ inner: (event: TEvent, ctx: ExtensionContext) => Promise<void>,
261
274
  ): Promise<void> => {
262
275
  const typedCtx = ctx as TContext;
263
276
  ensureTyping(typedCtx);
@@ -318,6 +331,9 @@ export function registerTelegramLifecycleHooks(
318
331
  pi: ExtensionAPI,
319
332
  deps: TelegramLifecycleRegistrationDeps,
320
333
  ): void {
334
+ pi.on("input", async (event, ctx) => {
335
+ await deps.onInput?.(event, ctx);
336
+ });
321
337
  pi.on("session_start", async (event, ctx) => {
322
338
  await deps.onSessionStart(event, ctx);
323
339
  });
@@ -357,4 +373,7 @@ export function registerTelegramLifecycleHooks(
357
373
  pi.on("agent_end", async (event, ctx) => {
358
374
  await deps.onAgentEnd(event, ctx);
359
375
  });
376
+ pi.on("agent_settled", async (event, ctx) => {
377
+ await deps.onAgentSettled?.(event, ctx);
378
+ });
360
379
  }
package/lib/model.ts CHANGED
@@ -20,7 +20,8 @@ export type ThinkingLevel =
20
20
  | "low"
21
21
  | "medium"
22
22
  | "high"
23
- | "xhigh";
23
+ | "xhigh"
24
+ | "max";
24
25
 
25
26
  export interface ScopedTelegramModel<TModel extends MenuModel = MenuModel> {
26
27
  model: TModel;
@@ -34,6 +35,7 @@ export const THINKING_LEVELS: readonly ThinkingLevel[] = [
34
35
  "medium",
35
36
  "high",
36
37
  "xhigh",
38
+ "max",
37
39
  ];
38
40
 
39
41
  export interface CurrentModelStore<
@@ -64,6 +64,36 @@ function isTopLevelClosingFence(
64
64
  );
65
65
  }
66
66
 
67
+ function collectPairedTelegramVoiceActionBody(
68
+ markdown: string,
69
+ bodyStart: number,
70
+ commentContent: string,
71
+ ): { content: string; end: number } | undefined {
72
+ const normalizedContent = commentContent.trim();
73
+ if (
74
+ !normalizedContent.startsWith("telegram_voice") ||
75
+ !isTelegramActionCommentContent(commentContent)
76
+ ) {
77
+ return undefined;
78
+ }
79
+ let offset = bodyStart;
80
+ while (offset < markdown.length) {
81
+ const lineEnd = getMarkdownLineEnd(markdown, offset);
82
+ const line = getMarkdownLineText(markdown, offset, lineEnd);
83
+ if (line === "<!-- /telegram_voice -->") {
84
+ const body = markdown.slice(bodyStart, offset).trim();
85
+ if (!body) return undefined;
86
+ return {
87
+ content: `${commentContent.trimEnd()}\n${body}`,
88
+ end: lineEnd,
89
+ };
90
+ }
91
+ if (line.startsWith("<!--")) return undefined;
92
+ offset = lineEnd;
93
+ }
94
+ return undefined;
95
+ }
96
+
67
97
  function collectInlineClosedTelegramActionBody(
68
98
  markdown: string,
69
99
  bodyStart: number,
@@ -117,14 +147,19 @@ export function collectTopLevelHtmlComments(markdown: string): {
117
147
  const closesOnOpeningLine = closeIndex < lineEnd;
118
148
  const hasOnlyWhitespaceAfterClose =
119
149
  line.slice(closeColumn + 3).trim() === "";
120
- const inlineBody =
150
+ const pairedVoiceBody =
121
151
  closesOnOpeningLine && hasOnlyWhitespaceAfterClose
152
+ ? collectPairedTelegramVoiceActionBody(markdown, lineEnd, content)
153
+ : undefined;
154
+ const inlineBody =
155
+ !pairedVoiceBody && closesOnOpeningLine && hasOnlyWhitespaceAfterClose
122
156
  ? collectInlineClosedTelegramActionBody(markdown, lineEnd, content)
123
157
  : undefined;
124
- if (inlineBody) {
125
- end = inlineBody.end;
158
+ const recoveredBody = pairedVoiceBody ?? inlineBody;
159
+ if (recoveredBody) {
160
+ end = recoveredBody.end;
126
161
  raw = markdown.slice(offset, end);
127
- content = inlineBody.content;
162
+ content = recoveredBody.content;
128
163
  }
129
164
  comments.push({ raw, content, start: offset, end });
130
165
  offset = getMarkdownLineEnd(markdown, end);
package/lib/pi.ts CHANGED
@@ -4,13 +4,16 @@
4
4
  * Owns direct pi SDK imports and exposes narrow bridge-facing helpers/types for the extension composition layer
5
5
  */
6
6
 
7
+ import type { AssistantMessageEvent } from "@earendil-works/pi-ai";
7
8
  import {
8
9
  type AgentEndEvent,
10
+ type AgentSettledEvent,
9
11
  type AgentStartEvent,
10
12
  type BeforeAgentStartEvent,
11
13
  type ExtensionAPI,
12
14
  type ExtensionCommandContext,
13
15
  type ExtensionContext,
16
+ type InputEvent,
14
17
  type SessionBeforeCompactEvent,
15
18
  type SessionCompactEvent,
16
19
  type SessionShutdownEvent,
@@ -21,11 +24,14 @@ import {
21
24
 
22
25
  export type {
23
26
  AgentEndEvent,
27
+ AgentSettledEvent,
24
28
  AgentStartEvent,
29
+ AssistantMessageEvent,
25
30
  BeforeAgentStartEvent,
26
31
  ExtensionAPI,
27
32
  ExtensionCommandContext,
28
33
  ExtensionContext,
34
+ InputEvent,
29
35
  SessionBeforeCompactEvent,
30
36
  SessionCompactEvent,
31
37
  SessionShutdownEvent,
@@ -68,10 +74,7 @@ export type PiRunMode = "tui" | "rpc" | "json" | "print";
68
74
 
69
75
  function isPiRunMode(value: unknown): value is PiRunMode {
70
76
  return (
71
- value === "tui" ||
72
- value === "rpc" ||
73
- value === "json" ||
74
- value === "print"
77
+ value === "tui" || value === "rpc" || value === "json" || value === "print"
75
78
  );
76
79
  }
77
80
 
@@ -99,6 +102,18 @@ export function formatPollingStartBlockedByRunMode(ctx: unknown): string {
99
102
  : "Telegram polling is unavailable in this Pi run mode.";
100
103
  }
101
104
 
105
+ export function getSessionCompactionReason(
106
+ event: unknown,
107
+ ): "manual" | "threshold" | "overflow" | "unknown" {
108
+ const reason =
109
+ event && typeof event === "object" && "reason" in event
110
+ ? (event as { reason?: unknown }).reason
111
+ : undefined;
112
+ return reason === "manual" || reason === "threshold" || reason === "overflow"
113
+ ? reason
114
+ : "unknown";
115
+ }
116
+
102
117
  export type PiSendUserMessageOptions = NonNullable<
103
118
  Parameters<ExtensionAPI["sendUserMessage"]>[1]
104
119
  >;
@@ -124,7 +139,8 @@ export function createExtensionApiRuntimePorts(
124
139
  >,
125
140
  ): PiExtensionApiRuntimePorts {
126
141
  return {
127
- sendUserMessage: (content, options) => api.sendUserMessage(content, options),
142
+ sendUserMessage: (content, options) =>
143
+ api.sendUserMessage(content, options),
128
144
  exec: (command, args, options) => api.exec(command, args, options),
129
145
  getCommands: () => api.getCommands(),
130
146
  getThinkingLevel: () => api.getThinkingLevel(),
package/lib/prompts.ts CHANGED
@@ -16,7 +16,7 @@ Telegram bridge available. Do not use it from local/TUI prompts unless explicitl
16
16
 
17
17
  const TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX = `
18
18
 
19
- Telegram turn note: If context was compacted or you need the pi-telegram bridge contract, call tool \`telegram_help\`; hidden comments are valid only for explicit \`telegram_voice\` or \`telegram_button\` actions with payload.`;
19
+ Telegram turn note: If context was compacted or you need the pi-telegram bridge contract, call tool \`telegram_help\`; hidden comments are valid only for explicit \`telegram_voice\` or \`telegram_button\` actions with payload. For voice use a top-level HTML action: \`<!-- telegram_voice: Speak this. -->\`, multiline \`<!-- telegram_voice lang=ru\nSpeak this.\n-->\`, or paired \`<!-- telegram_voice lang=ru -->\nSpeak this.\n<!-- /telegram_voice -->\`.`;
20
20
 
21
21
  function buildTelegramHelpText(profileName?: string): string {
22
22
  const diagnosticsPaths = getTelegramDiagnosticsDisplayPaths(profileName);
@@ -37,7 +37,8 @@ How to answer Telegram turns:
37
37
  Assistant-authored Telegram actions:
38
38
  - \`telegram_voice\` and \`telegram_button\` are hidden top-level HTML comments, not Pi tools.
39
39
  - Put action comments at column zero, outside code, quotes, lists, and indented examples.
40
- - Voice forms: \`<!-- telegram_voice text="Short summary" -->\` or \`<!-- telegram_voice: Short summary -->\`.
40
+ - Voice forms: \`<!-- telegram_voice text="Short summary" -->\`, \`<!-- telegram_voice: Short summary -->\`, multiline \`<!-- telegram_voice lang=ru\nShort summary.\n-->\`, or paired \`<!-- telegram_voice lang=ru -->\nShort summary.\n<!-- /telegram_voice -->\`.
41
+ - Keep the complete action at top level and include a non-empty voice payload.
41
42
  - Keep voice text TTS-friendly; avoid raw Markdown, code, and tables in voice text.
42
43
  - Voice delivery generates and attaches OGG automatically; do not also call \`telegram_attach\` for the same audio.
43
44
  - 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-->\`.
package/lib/routing.ts CHANGED
@@ -21,10 +21,82 @@ import * as TextGroups from "./text-groups.ts";
21
21
  import * as ThreadReconciler from "./thread-reconciler.ts";
22
22
  import * as Turns from "./turns.ts";
23
23
 
24
- function formatTelegramPromptPeer(user: { id?: unknown; username?: unknown } | undefined): string | undefined {
25
- if (!user) return undefined;
26
- if (typeof user.username === "string" && user.username.length > 0) return user.username;
27
- return typeof user.id === "number" ? String(user.id) : undefined;
24
+ interface TelegramPromptPeerView {
25
+ id?: unknown;
26
+ is_bot?: unknown;
27
+ username?: unknown;
28
+ first_name?: unknown;
29
+ last_name?: unknown;
30
+ title?: unknown;
31
+ }
32
+
33
+ function formatTelegramPromptPeer(
34
+ peer: TelegramPromptPeerView | undefined,
35
+ ): string | undefined {
36
+ if (!peer) return undefined;
37
+ if (typeof peer.username === "string" && peer.username.length > 0) {
38
+ return peer.username;
39
+ }
40
+ const displayName = [peer.first_name, peer.last_name]
41
+ .filter(
42
+ (part): part is string =>
43
+ typeof part === "string" && part.length > 0,
44
+ )
45
+ .join(" ");
46
+ if (displayName) return displayName;
47
+ if (typeof peer.title === "string" && peer.title.length > 0) {
48
+ return peer.title;
49
+ }
50
+ return typeof peer.id === "number" ? String(peer.id) : undefined;
51
+ }
52
+
53
+ function isTelegramPromptOwnerPeer(
54
+ peer: TelegramPromptPeerView | undefined,
55
+ ownerUserId: number | undefined,
56
+ ): boolean {
57
+ return ownerUserId !== undefined && peer?.id === ownerUserId;
58
+ }
59
+
60
+ function isTelegramPromptBotPeer(
61
+ peer: TelegramPromptPeerView | undefined,
62
+ ): boolean {
63
+ return peer?.is_bot === true;
64
+ }
65
+
66
+ export function resolveTelegramGuestPromptPeer(input: {
67
+ chatType?: string;
68
+ chat?: TelegramPromptPeerView;
69
+ from?: TelegramPromptPeerView;
70
+ replyFrom?: TelegramPromptPeerView;
71
+ guestBotCallerUser?: TelegramPromptPeerView;
72
+ guestBotCallerChat?: TelegramPromptPeerView;
73
+ ownerUserId?: number;
74
+ }): string | undefined {
75
+ if (input.chatType !== "private") {
76
+ return formatTelegramPromptPeer(input.chat);
77
+ }
78
+ if (
79
+ !isTelegramPromptOwnerPeer(input.from, input.ownerUserId) &&
80
+ !isTelegramPromptBotPeer(input.from)
81
+ ) {
82
+ return formatTelegramPromptPeer(input.from);
83
+ }
84
+ for (const candidate of [
85
+ input.chat,
86
+ input.guestBotCallerUser,
87
+ input.guestBotCallerChat,
88
+ input.replyFrom,
89
+ ]) {
90
+ if (
91
+ isTelegramPromptOwnerPeer(candidate, input.ownerUserId) ||
92
+ isTelegramPromptBotPeer(candidate)
93
+ ) {
94
+ continue;
95
+ }
96
+ const peer = formatTelegramPromptPeer(candidate);
97
+ if (peer) return peer;
98
+ }
99
+ return undefined;
28
100
  }
29
101
 
30
102
  function appendTelegramSourceAttachmentSection(
@@ -1812,22 +1884,43 @@ export function createTelegramInboundRouteRuntime<
1812
1884
  const gm = guestMessage as unknown as Record<string, unknown>;
1813
1885
  // Build telegram prefix with guest context
1814
1886
  const chatRaw = gm.chat as Record<string, unknown>;
1815
- const chatTitle = chatRaw?.title as string | undefined;
1816
1887
  const chatType = chatRaw?.type as string;
1817
1888
  const fromRaw = gm.from as Record<string, unknown> | undefined;
1818
1889
  const replyMsg = gm.reply_to_message as Record<string, unknown> | undefined;
1819
1890
  const replyFromRaw = replyMsg?.from as Record<string, unknown> | undefined;
1820
- const fromPeer = formatTelegramPromptPeer(fromRaw);
1891
+ const guestBotCallerUser = gm.guest_bot_caller_user as
1892
+ | Record<string, unknown>
1893
+ | undefined;
1894
+ const guestBotCallerChat = gm.guest_bot_caller_chat as
1895
+ | Record<string, unknown>
1896
+ | undefined;
1897
+ const ownerUserId = deps.configStore.getAllowedUserId();
1821
1898
  const replyPeer = formatTelegramPromptPeer(replyFromRaw);
1822
- const fromIsOwner = fromRaw?.id === deps.configStore.getAllowedUserId();
1823
- const guestPeer = chatType === "private" && fromIsOwner && replyPeer
1824
- ? replyPeer
1825
- : fromPeer;
1899
+ const guestPeer = resolveTelegramGuestPromptPeer({
1900
+ chatType,
1901
+ chat: chatRaw,
1902
+ from: fromRaw,
1903
+ replyFrom: replyFromRaw,
1904
+ guestBotCallerUser,
1905
+ guestBotCallerChat,
1906
+ ownerUserId,
1907
+ });
1826
1908
  const prefixParts = ["telegram"];
1827
- if (chatType !== "private" && chatTitle) {
1828
- prefixParts.push(`guest:${chatTitle}`);
1829
- } else if (chatType === "private" && guestPeer) {
1909
+ if (guestPeer) {
1830
1910
  prefixParts.push(`guest:${guestPeer}`);
1911
+ } else if (chatType === "private") {
1912
+ deps.recordRuntimeEvent?.(
1913
+ "guest",
1914
+ new Error("Private Guest Mode remote peer could not be resolved"),
1915
+ {
1916
+ phase: "peer-attribution",
1917
+ chatId: typeof chatRaw?.id === "number" ? chatRaw.id : undefined,
1918
+ fromId: typeof fromRaw?.id === "number" ? fromRaw.id : undefined,
1919
+ hasReplyFrom: !!replyFromRaw,
1920
+ hasCallerUser: !!guestBotCallerUser,
1921
+ hasCallerChat: !!guestBotCallerChat,
1922
+ },
1923
+ );
1831
1924
  }
1832
1925
  const telegramPrefix = `[${prefixParts.join("|")}]`;
1833
1926
  // Extract reply context
package/lib/sections.ts CHANGED
@@ -25,7 +25,7 @@ export type TelegramSectionCallbackResult = "handled" | "pass";
25
25
  export interface TelegramSectionView {
26
26
  text: string;
27
27
  /**
28
- * Source format for companion section content.
28
+ * Source format for extension-provided section content.
29
29
  * Defaults to "html" for explicit Telegram UI markup; use "markdown"
30
30
  * when a section naturally owns Markdown content, or "plain" for text.
31
31
  */
package/lib/status.ts CHANGED
@@ -428,10 +428,10 @@ function getOrCreateTelegramStatusLineProviderRegistry(): Map<
428
428
  }
429
429
 
430
430
  /**
431
- * Register a compact companion-extension line for the Telegram status menu.
431
+ * Register a compact extension-provided line for the Telegram status menu.
432
432
  *
433
433
  * Providers are synchronous and should return undefined when their line is not
434
- * relevant for the active model. Errors are isolated so optional companion
434
+ * relevant for the active model. Errors are isolated so optional extension
435
435
  * status cannot break the core Telegram menu.
436
436
  */
437
437
  export function registerTelegramStatusLineProvider(
package/lib/updates.ts CHANGED
@@ -1248,7 +1248,7 @@ export interface TelegramUpdateHandlerRegistry {
1248
1248
  /**
1249
1249
  * Run all registered handlers against an update.
1250
1250
  *
1251
- * Used by pi-telegram's polling runtime; companion extensions should call
1251
+ * Used by pi-telegram's polling runtime; extension consumers should call
1252
1252
  * {@link registerTelegramUpdateHandler} or `add` instead of dispatching directly.
1253
1253
  */
1254
1254
  dispatch: (update: unknown) => Promise<TelegramUpdateHandlerVerdict>;
@@ -1297,7 +1297,7 @@ function getOrCreateUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
1297
1297
 
1298
1298
  /**
1299
1299
  * Called by pi-telegram's own runtime to obtain the registry it dispatches
1300
- * through. Companion extensions should not call this; use
1300
+ * through. Extension consumers should not call this; use
1301
1301
  * {@link registerTelegramUpdateHandler} instead.
1302
1302
  */
1303
1303
  export function getTelegramUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
@@ -1328,8 +1328,8 @@ export function createTelegramUpdateHandle<TUpdate, TContext>(
1328
1328
  * Register a handler that runs before pi-telegram routes a Telegram update
1329
1329
  * through its built-in handlers.
1330
1330
  *
1331
- * This is the low-level public surface for companion extensions that share
1332
- * the same bot and pi process with pi-telegram.
1331
+ * This is the low-level public surface for extensions that share the same bot
1332
+ * and Pi process with pi-telegram.
1333
1333
  */
1334
1334
  export function registerTelegramUpdateHandler(
1335
1335
  handler: TelegramUpdateHandler,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.20.5",
3
+ "version": "0.21.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -48,6 +48,8 @@
48
48
  ".": "./index.ts",
49
49
  "./inbound": "./api/inbound.ts",
50
50
  "./outbound": "./api/outbound.ts",
51
+ "./delivery": "./api/delivery.ts",
52
+ "./activity": "./api/activity.ts",
51
53
  "./updates": "./api/updates.ts",
52
54
  "./commands": "./api/commands.ts",
53
55
  "./sections": "./api/sections.ts",
@@ -62,9 +64,9 @@
62
64
  "image": "https://github.com/llblab/pi-telegram/raw/main/screenshot.png"
63
65
  },
64
66
  "peerDependencies": {
65
- "@earendil-works/pi-agent-core": "*",
66
- "@earendil-works/pi-ai": "*",
67
- "@earendil-works/pi-coding-agent": "*",
67
+ "@earendil-works/pi-agent-core": ">=0.80.6",
68
+ "@earendil-works/pi-ai": ">=0.80.6",
69
+ "@earendil-works/pi-coding-agent": ">=0.80.6",
68
70
  "@sinclair/typebox": "*"
69
71
  },
70
72
  "devDependencies": {