@llblab/pi-telegram 0.2.10 → 0.4.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 (47) hide show
  1. package/README.md +52 -19
  2. package/docs/README.md +2 -3
  3. package/docs/architecture.md +62 -31
  4. package/docs/locks.md +136 -0
  5. package/index.ts +323 -1880
  6. package/lib/api.ts +396 -60
  7. package/lib/attachments.ts +128 -16
  8. package/lib/commands.ts +648 -14
  9. package/lib/config.ts +169 -0
  10. package/lib/handlers.ts +474 -0
  11. package/lib/locks.ts +306 -0
  12. package/lib/media.ts +196 -46
  13. package/lib/menu.ts +920 -338
  14. package/lib/model.ts +647 -0
  15. package/lib/pi.ts +90 -0
  16. package/lib/polling.ts +240 -14
  17. package/lib/preview.ts +420 -25
  18. package/lib/queue.ts +1137 -110
  19. package/lib/registration.ts +214 -31
  20. package/lib/rendering.ts +560 -366
  21. package/lib/replies.ts +198 -8
  22. package/lib/routing.ts +217 -0
  23. package/lib/runtime.ts +475 -0
  24. package/lib/setup.ts +143 -1
  25. package/lib/status.ts +432 -13
  26. package/lib/turns.ts +217 -36
  27. package/lib/updates.ts +340 -109
  28. package/package.json +18 -3
  29. package/AGENTS.md +0 -91
  30. package/BACKLOG.md +0 -5
  31. package/CHANGELOG.md +0 -34
  32. package/lib/model-switch.ts +0 -62
  33. package/lib/types.ts +0 -137
  34. package/tests/api.test.ts +0 -331
  35. package/tests/attachments.test.ts +0 -132
  36. package/tests/commands.test.ts +0 -85
  37. package/tests/config.test.ts +0 -80
  38. package/tests/media.test.ts +0 -166
  39. package/tests/menu.test.ts +0 -676
  40. package/tests/polling.test.ts +0 -202
  41. package/tests/preview.test.ts +0 -480
  42. package/tests/queue.test.ts +0 -3245
  43. package/tests/registration.test.ts +0 -268
  44. package/tests/rendering.test.ts +0 -526
  45. package/tests/replies.test.ts +0 -142
  46. package/tests/turns.test.ts +0 -247
  47. package/tests/updates.test.ts +0 -416
@@ -3,28 +3,62 @@
3
3
  * Owns tool, command, and lifecycle-hook registration so index.ts can stay focused on runtime orchestration state and side effects
4
4
  */
5
5
 
6
+ import { Type } from "@sinclair/typebox";
7
+
8
+ import {
9
+ queueTelegramAttachments,
10
+ TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES,
11
+ type TelegramAttachmentQueueTargetView,
12
+ } from "./attachments.ts";
6
13
  import type {
14
+ AgentEndEvent,
15
+ AgentStartEvent,
16
+ BeforeAgentStartEvent,
7
17
  ExtensionAPI,
8
18
  ExtensionCommandContext,
9
19
  ExtensionContext,
10
- } from "@mariozechner/pi-coding-agent";
11
- import { Type } from "@sinclair/typebox";
20
+ SessionShutdownEvent,
21
+ SessionStartEvent,
22
+ } from "./pi.ts";
23
+ import { TELEGRAM_PREFIX } from "./turns.ts";
12
24
 
13
- import { queueTelegramAttachments } from "./attachments.ts";
14
- import type { PendingTelegramTurn } from "./queue.ts";
25
+ const MAX_ATTACHMENTS_PER_TURN = 10;
26
+
27
+ const SYSTEM_PROMPT_SUFFIX = `
28
+
29
+ Telegram bridge extension is active.
30
+ - Messages forwarded from Telegram are prefixed with "[telegram]".
31
+ - [telegram] messages may include [attachments] sections with a base directory plus relative local file entries. Resolve and read those files as needed.
32
+ - Telegram is often read on narrow phone screens, so prefer narrow table columns when presenting tabular data; wide monospace tables can become unreadable.
33
+ - If a [telegram] user asked for a file or generated artifact, use the telegram_attach tool with the local file path so the extension can send it with your next final reply.
34
+ - Do not assume mentioning a local file path in plain text will send it to Telegram. Use telegram_attach.`;
15
35
 
16
36
  // --- Tool Registration ---
17
37
 
18
- export interface TelegramAttachmentToolRegistrationDeps {
19
- maxAttachmentsPerTurn: number;
20
- getActiveTurn: () => PendingTelegramTurn | undefined;
21
- statPath: (path: string) => Promise<{ isFile(): boolean }>;
38
+ export interface TelegramRuntimeEventRecorderPort {
39
+ recordRuntimeEvent?: (
40
+ category: string,
41
+ error: unknown,
42
+ details?: Record<string, unknown>,
43
+ ) => void;
44
+ }
45
+
46
+ export interface TelegramAttachmentToolRegistrationDeps
47
+ extends TelegramRuntimeEventRecorderPort {
48
+ maxAttachmentsPerTurn?: number;
49
+ maxAttachmentSizeBytes?: number;
50
+ getActiveTurn: () => TelegramAttachmentQueueTargetView | undefined;
51
+ statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
22
52
  }
23
53
 
24
54
  export function registerTelegramAttachmentTool(
25
55
  pi: ExtensionAPI,
26
56
  deps: TelegramAttachmentToolRegistrationDeps,
27
57
  ): void {
58
+ const maxAttachmentsPerTurn =
59
+ deps.maxAttachmentsPerTurn ?? MAX_ATTACHMENTS_PER_TURN;
60
+ const maxAttachmentSizeBytes =
61
+ deps.maxAttachmentSizeBytes ?? TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES;
28
62
  pi.registerTool({
29
63
  name: "telegram_attach",
30
64
  label: "Telegram Attach",
@@ -37,32 +71,74 @@ export function registerTelegramAttachmentTool(
37
71
  parameters: Type.Object({
38
72
  paths: Type.Array(
39
73
  Type.String({ description: "Local file path to attach" }),
40
- { minItems: 1, maxItems: deps.maxAttachmentsPerTurn },
74
+ { minItems: 1, maxItems: maxAttachmentsPerTurn },
41
75
  ),
42
76
  }),
43
77
  async execute(_toolCallId, params) {
44
- return queueTelegramAttachments({
45
- activeTurn: deps.getActiveTurn(),
46
- paths: params.paths,
47
- maxAttachmentsPerTurn: deps.maxAttachmentsPerTurn,
48
- statPath: deps.statPath,
49
- });
78
+ try {
79
+ return await queueTelegramAttachments({
80
+ activeTurn: deps.getActiveTurn(),
81
+ paths: params.paths,
82
+ maxAttachmentsPerTurn,
83
+ maxAttachmentSizeBytes,
84
+ statPath: deps.statPath,
85
+ });
86
+ } catch (error) {
87
+ deps.recordRuntimeEvent?.("attachment", error, {
88
+ phase: "queue",
89
+ count: params.paths.length,
90
+ });
91
+ throw error;
92
+ }
50
93
  },
51
94
  });
52
95
  }
53
96
 
54
97
  // --- Command Registration ---
55
98
 
99
+ export interface TelegramCommandStartPollingOptions {
100
+ force?: boolean;
101
+ }
102
+
103
+ export interface TelegramCommandStartPollingResult {
104
+ ok: boolean;
105
+ message?: string;
106
+ canTakeover?: boolean;
107
+ owner?: string;
108
+ }
109
+
56
110
  export interface TelegramCommandRegistrationDeps {
57
111
  promptForConfig: (ctx: ExtensionCommandContext) => Promise<void>;
58
112
  getStatusLines: () => string[];
59
113
  reloadConfig: () => Promise<void>;
60
114
  hasBotToken: () => boolean;
61
- startPolling: (ctx: ExtensionCommandContext) => Promise<void>;
62
- stopPolling: () => Promise<void>;
115
+ startPolling: (
116
+ ctx: ExtensionCommandContext,
117
+ options?: TelegramCommandStartPollingOptions,
118
+ ) =>
119
+ | void
120
+ | Promise<void | TelegramCommandStartPollingResult>
121
+ | TelegramCommandStartPollingResult;
122
+ stopPolling: () => Promise<void | string>;
63
123
  updateStatus: (ctx: ExtensionCommandContext) => void;
64
124
  }
65
125
 
126
+ function formatTelegramTakeoverTitle(ctx: ExtensionCommandContext): string {
127
+ return ctx.ui.theme.fg("accent", "pi-telegram");
128
+ }
129
+
130
+ function formatTelegramTakeoverPrompt(
131
+ ctx: ExtensionCommandContext,
132
+ owner?: string,
133
+ ): string {
134
+ const theme = ctx.ui.theme;
135
+ const action = theme.fg("warning", "move singleton lock here?");
136
+ const from = theme.fg("muted", "from:");
137
+ const to = theme.fg("muted", "to:");
138
+ const source = owner ?? "another pi instance";
139
+ return `${action}\n\n${from} ${source}\n${to} ${ctx.cwd}`;
140
+ }
141
+
66
142
  export function registerTelegramCommands(
67
143
  pi: ExtensionAPI,
68
144
  deps: TelegramCommandRegistrationDeps,
@@ -76,7 +152,7 @@ export function registerTelegramCommands(
76
152
  pi.registerCommand("telegram-status", {
77
153
  description: "Show Telegram bridge status",
78
154
  handler: async (_args, ctx) => {
79
- ctx.ui.notify(deps.getStatusLines().join(" | "), "info");
155
+ ctx.ui.notify(deps.getStatusLines().join("\n"), "info");
80
156
  },
81
157
  });
82
158
  pi.registerCommand("telegram-connect", {
@@ -87,14 +163,30 @@ export function registerTelegramCommands(
87
163
  await deps.promptForConfig(ctx);
88
164
  return;
89
165
  }
90
- await deps.startPolling(ctx);
166
+ let result = await deps.startPolling(ctx);
167
+ if (result && !result.ok && result.canTakeover) {
168
+ const confirmed = await ctx.ui.confirm(
169
+ formatTelegramTakeoverTitle(ctx),
170
+ formatTelegramTakeoverPrompt(ctx, result.owner),
171
+ );
172
+ if (!confirmed) {
173
+ ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
174
+ deps.updateStatus(ctx);
175
+ return;
176
+ }
177
+ result = await deps.startPolling(ctx, { force: true });
178
+ }
179
+ if (result?.message) {
180
+ ctx.ui.notify(result.message, result.ok ? "info" : "warning");
181
+ }
91
182
  deps.updateStatus(ctx);
92
183
  },
93
184
  });
94
185
  pi.registerCommand("telegram-disconnect", {
95
186
  description: "Stop the Telegram bridge in this pi session",
96
187
  handler: async (_args, ctx) => {
97
- await deps.stopPolling();
188
+ const message = await deps.stopPolling();
189
+ if (message) ctx.ui.notify(message, "info");
98
190
  deps.updateStatus(ctx);
99
191
  },
100
192
  });
@@ -102,18 +194,67 @@ export function registerTelegramCommands(
102
194
 
103
195
  // --- Lifecycle Hook Registration ---
104
196
 
197
+ export function buildTelegramBridgeSystemPrompt(options: {
198
+ prompt: string;
199
+ systemPrompt: string;
200
+ telegramPrefix?: string;
201
+ systemPromptSuffix: string;
202
+ }): { systemPrompt: string } {
203
+ const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
204
+ const suffix = options.prompt.trimStart().startsWith(telegramPrefix)
205
+ ? `${options.systemPromptSuffix}\n- The current user message came from Telegram.`
206
+ : options.systemPromptSuffix;
207
+ return { systemPrompt: options.systemPrompt + suffix };
208
+ }
209
+
210
+ export function createTelegramBeforeAgentStartHook(
211
+ options: {
212
+ telegramPrefix?: string;
213
+ systemPromptSuffix?: string;
214
+ } = {},
215
+ ): (event: BeforeAgentStartEvent) => { systemPrompt: string } {
216
+ return (event) =>
217
+ buildTelegramBridgeSystemPrompt({
218
+ prompt: event.prompt,
219
+ systemPrompt: event.systemPrompt,
220
+ telegramPrefix: options.telegramPrefix,
221
+ systemPromptSuffix: options.systemPromptSuffix ?? SYSTEM_PROMPT_SUFFIX,
222
+ });
223
+ }
224
+
225
+ export interface TelegramBeforeAgentStartResult {
226
+ systemPrompt?: string;
227
+ }
228
+
229
+ type TelegramBeforeAgentStartReturn =
230
+ | Promise<TelegramBeforeAgentStartResult | undefined>
231
+ | TelegramBeforeAgentStartResult
232
+ | undefined;
233
+
234
+ type TelegramLifecycleModel = ExtensionContext["model"];
235
+ type TelegramLifecycleMessage = AgentEndEvent["messages"][number];
236
+
105
237
  export interface TelegramLifecycleRegistrationDeps {
106
- onSessionStart: (event: unknown, ctx: ExtensionContext) => Promise<void>;
107
- onSessionShutdown: (event: unknown, ctx: ExtensionContext) => Promise<void>;
238
+ onSessionStart: (
239
+ event: SessionStartEvent,
240
+ ctx: ExtensionContext,
241
+ ) => Promise<void>;
242
+ onSessionShutdown: (
243
+ event: SessionShutdownEvent,
244
+ ctx: ExtensionContext,
245
+ ) => Promise<void>;
108
246
  onBeforeAgentStart: (
109
- event: unknown,
247
+ event: BeforeAgentStartEvent,
110
248
  ctx: ExtensionContext,
111
- ) => Promise<unknown> | unknown;
249
+ ) => TelegramBeforeAgentStartReturn;
112
250
  onModelSelect: (
113
- event: unknown,
251
+ event: { model: TelegramLifecycleModel },
114
252
  ctx: ExtensionContext,
115
253
  ) => Promise<void> | void;
116
- onAgentStart: (event: unknown, ctx: ExtensionContext) => Promise<void>;
254
+ onAgentStart: (
255
+ event: AgentStartEvent,
256
+ ctx: ExtensionContext,
257
+ ) => Promise<void>;
117
258
  onToolExecutionStart: (
118
259
  event: unknown,
119
260
  ctx: ExtensionContext,
@@ -122,9 +263,50 @@ export interface TelegramLifecycleRegistrationDeps {
122
263
  event: unknown,
123
264
  ctx: ExtensionContext,
124
265
  ) => Promise<void> | void;
125
- onMessageStart: (event: unknown, ctx: ExtensionContext) => Promise<void>;
126
- onMessageUpdate: (event: unknown, ctx: ExtensionContext) => Promise<void>;
127
- onAgentEnd: (event: unknown, ctx: ExtensionContext) => Promise<void>;
266
+ onMessageStart: (
267
+ event: { message: TelegramLifecycleMessage },
268
+ ctx: ExtensionContext,
269
+ ) => Promise<void>;
270
+ onMessageUpdate: (
271
+ event: { message: TelegramLifecycleMessage },
272
+ ctx: ExtensionContext,
273
+ ) => Promise<void>;
274
+ onAgentEnd: (event: AgentEndEvent, ctx: ExtensionContext) => Promise<void>;
275
+ }
276
+
277
+ export interface TelegramSessionLifecycleHooks {
278
+ onSessionStart: (event: SessionStartEvent, ctx: ExtensionContext) => Promise<void>;
279
+ onSessionShutdown: (
280
+ event: SessionShutdownEvent,
281
+ ctx: ExtensionContext,
282
+ ) => Promise<void>;
283
+ }
284
+
285
+ export interface TelegramExtraLifecycleHooks {
286
+ onSessionStart?: (
287
+ event: SessionStartEvent,
288
+ ctx: ExtensionContext,
289
+ ) => Promise<void>;
290
+ onSessionShutdown?: (
291
+ event: SessionShutdownEvent,
292
+ ctx: ExtensionContext,
293
+ ) => Promise<void>;
294
+ }
295
+
296
+ export function appendTelegramLifecycleHooks(
297
+ base: TelegramSessionLifecycleHooks,
298
+ extra: TelegramExtraLifecycleHooks,
299
+ ): TelegramSessionLifecycleHooks {
300
+ return {
301
+ onSessionStart: async (event, ctx) => {
302
+ await base.onSessionStart(event, ctx);
303
+ await extra.onSessionStart?.(event, ctx);
304
+ },
305
+ onSessionShutdown: async (event, ctx) => {
306
+ await base.onSessionShutdown(event, ctx);
307
+ await extra.onSessionShutdown?.(event, ctx);
308
+ },
309
+ };
128
310
  }
129
311
 
130
312
  export function registerTelegramLifecycleHooks(
@@ -137,8 +319,9 @@ export function registerTelegramLifecycleHooks(
137
319
  pi.on("session_shutdown", async (event, ctx) => {
138
320
  await deps.onSessionShutdown(event, ctx);
139
321
  });
140
- pi.on("before_agent_start", (async (event: unknown, ctx: ExtensionContext) =>
141
- deps.onBeforeAgentStart(event, ctx)) as never);
322
+ pi.on("before_agent_start", async (event, ctx) => {
323
+ return deps.onBeforeAgentStart(event, ctx);
324
+ });
142
325
  pi.on("model_select", async (event, ctx) => {
143
326
  await deps.onModelSelect(event, ctx);
144
327
  });