@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
package/lib/status.ts CHANGED
@@ -3,8 +3,7 @@
3
3
  * Builds usage, cost, and context summaries for the interactive Telegram status view
4
4
  */
5
5
 
6
- import type { Model } from "@mariozechner/pi-ai";
7
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
6
+ export type TelegramStatusQueueLane = "control" | "priority" | "default";
8
7
 
9
8
  export interface TelegramUsageStats {
10
9
  totalInput: number;
@@ -14,6 +13,421 @@ export interface TelegramUsageStats {
14
13
  totalCost: number;
15
14
  }
16
15
 
16
+ interface TelegramUsageMessage {
17
+ role: string;
18
+ usage?: {
19
+ input: number;
20
+ output: number;
21
+ cacheRead: number;
22
+ cacheWrite: number;
23
+ cost: { total: number };
24
+ };
25
+ }
26
+
27
+ interface TelegramStatusSessionEntry {
28
+ type: string;
29
+ message?: TelegramUsageMessage;
30
+ }
31
+
32
+ interface TelegramContextUsage {
33
+ contextWindow?: number;
34
+ percent: number | null;
35
+ }
36
+
37
+ export interface TelegramStatusActiveModel {
38
+ contextWindow?: number;
39
+ }
40
+
41
+ export interface TelegramStatusContext {
42
+ sessionManager: { getEntries(): TelegramStatusSessionEntry[] };
43
+ getContextUsage(): TelegramContextUsage | undefined;
44
+ modelRegistry: {
45
+ isUsingOAuth(model: TelegramStatusActiveModel): boolean;
46
+ };
47
+ }
48
+
49
+ export type TelegramRuntimeEventDetailValue = string | number | boolean | null;
50
+
51
+ const MAX_RECENT_TELEGRAM_RUNTIME_EVENTS = 10;
52
+
53
+ export interface TelegramRuntimeEvent {
54
+ at: number;
55
+ category: string;
56
+ message: string;
57
+ details?: Record<string, TelegramRuntimeEventDetailValue>;
58
+ }
59
+
60
+ export interface TelegramRuntimeEventInput {
61
+ category: string;
62
+ error?: unknown;
63
+ message?: string;
64
+ details?: Record<string, unknown>;
65
+ }
66
+
67
+ export interface TelegramRuntimeEventRecorder {
68
+ record: (
69
+ category: string,
70
+ error: unknown,
71
+ details?: Record<string, unknown>,
72
+ ) => void;
73
+ getEvents: () => TelegramRuntimeEvent[];
74
+ clear: () => void;
75
+ }
76
+
77
+ export interface TelegramRuntimeEventRecorderOptions {
78
+ getBotToken: () => string | undefined;
79
+ maxEvents?: number;
80
+ now?: () => number;
81
+ }
82
+
83
+ export interface TelegramBridgeStatusLineState {
84
+ botUsername?: string;
85
+ allowedUserId?: number;
86
+ lockState?: string;
87
+ pollingActive: boolean;
88
+ lastUpdateId?: number;
89
+ activeSourceMessageIds?: number[];
90
+ pendingDispatch: boolean;
91
+ compactionInProgress: boolean;
92
+ activeToolExecutions: number;
93
+ pendingModelSwitch: boolean;
94
+ queuedItems: Array<{ queueLane: TelegramStatusQueueLane }>;
95
+ recentRuntimeEvents: TelegramRuntimeEvent[];
96
+ }
97
+
98
+ export interface TelegramStatusBarTheme {
99
+ fg: (
100
+ token: "accent" | "error" | "muted" | "warning" | "success",
101
+ text: string,
102
+ ) => string;
103
+ }
104
+
105
+ export interface TelegramStatusBarState {
106
+ hasBotToken: boolean;
107
+ pollingActive: boolean;
108
+ paired: boolean;
109
+ compactionInProgress: boolean;
110
+ processing: boolean;
111
+ queuedStatus: string;
112
+ error?: string;
113
+ }
114
+
115
+ export interface TelegramStatusRuntimeContext {
116
+ ui: {
117
+ theme: TelegramStatusBarTheme;
118
+ setStatus: (key: string, text: string) => void;
119
+ };
120
+ }
121
+
122
+ export interface TelegramStatusRuntimeDeps<
123
+ TContext extends TelegramStatusRuntimeContext,
124
+ > {
125
+ statusKey?: string;
126
+ getStatusBarState: (ctx: TContext, error?: string) => TelegramStatusBarState;
127
+ getBridgeStatusLineState: () => TelegramBridgeStatusLineState;
128
+ }
129
+
130
+ export interface TelegramBridgeStatusConfig {
131
+ botToken?: string;
132
+ botUsername?: string;
133
+ allowedUserId?: number;
134
+ lastUpdateId?: number;
135
+ }
136
+
137
+ export interface TelegramBridgeStatusRuntimeDeps<
138
+ TQueueItem extends { queueLane: TelegramStatusQueueLane },
139
+ > {
140
+ statusKey?: string;
141
+ getConfig: () => TelegramBridgeStatusConfig;
142
+ isPollingActive: () => boolean;
143
+ getActiveSourceMessageIds: () => number[] | undefined;
144
+ hasActiveTurn: () => boolean;
145
+ hasDispatchPending: () => boolean;
146
+ isCompactionInProgress: () => boolean;
147
+ getActiveToolExecutions: () => number;
148
+ hasPendingModelSwitch: () => boolean;
149
+ getQueuedItems: () => TQueueItem[];
150
+ formatQueuedStatus: (items: TQueueItem[]) => string;
151
+ getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
152
+ getRuntimeLockState?: () => string;
153
+ }
154
+
155
+ export interface TelegramStatusRuntime<
156
+ TContext extends TelegramStatusRuntimeContext,
157
+ > {
158
+ updateStatus: (ctx: TContext, error?: string) => void;
159
+ getStatusLines: () => string[];
160
+ }
161
+
162
+ export function redactTelegramRuntimeMessage(
163
+ message: string,
164
+ botToken: string | undefined,
165
+ ): string {
166
+ if (!botToken) return message;
167
+ return message.split(botToken).join("<redacted-token>");
168
+ }
169
+
170
+ function normalizeTelegramRuntimeEventDetails(
171
+ details: Record<string, unknown> | undefined,
172
+ botToken: string | undefined,
173
+ ): Record<string, TelegramRuntimeEventDetailValue> | undefined {
174
+ if (!details) return undefined;
175
+ const normalized: Record<string, TelegramRuntimeEventDetailValue> = {};
176
+ for (const [key, value] of Object.entries(details)) {
177
+ if (value === undefined) continue;
178
+ if (typeof value === "string") {
179
+ normalized[key] = redactTelegramRuntimeMessage(value, botToken);
180
+ continue;
181
+ }
182
+ if (typeof value === "number" || typeof value === "boolean") {
183
+ normalized[key] = value;
184
+ continue;
185
+ }
186
+ if (value === null) {
187
+ normalized[key] = null;
188
+ continue;
189
+ }
190
+ normalized[key] = redactTelegramRuntimeMessage(String(value), botToken);
191
+ }
192
+ return Object.keys(normalized).length > 0 ? normalized : undefined;
193
+ }
194
+
195
+ function getTelegramRuntimeEventMessage(
196
+ input: TelegramRuntimeEventInput,
197
+ ): string {
198
+ if (input.message !== undefined) return input.message;
199
+ if (input.error instanceof Error) return input.error.message;
200
+ return String(input.error);
201
+ }
202
+
203
+ export function recordStructuredTelegramRuntimeEvent(
204
+ events: TelegramRuntimeEvent[],
205
+ input: TelegramRuntimeEventInput,
206
+ options: { botToken?: string; maxEvents: number; now?: number },
207
+ ): void {
208
+ const details = normalizeTelegramRuntimeEventDetails(
209
+ input.details,
210
+ options.botToken,
211
+ );
212
+ events.push({
213
+ at: options.now ?? Date.now(),
214
+ category: input.category,
215
+ message: redactTelegramRuntimeMessage(
216
+ getTelegramRuntimeEventMessage(input),
217
+ options.botToken,
218
+ ),
219
+ ...(details ? { details } : {}),
220
+ });
221
+ while (events.length > options.maxEvents) {
222
+ events.shift();
223
+ }
224
+ }
225
+
226
+ export function recordTelegramRuntimeEvent(
227
+ events: TelegramRuntimeEvent[],
228
+ category: string,
229
+ error: unknown,
230
+ options: { botToken?: string; maxEvents: number; now?: number },
231
+ ): void {
232
+ recordStructuredTelegramRuntimeEvent(events, { category, error }, options);
233
+ }
234
+
235
+ export function createTelegramRuntimeEventRecorder(
236
+ options: TelegramRuntimeEventRecorderOptions,
237
+ ): TelegramRuntimeEventRecorder {
238
+ const events: TelegramRuntimeEvent[] = [];
239
+ return {
240
+ record: (category, error, details) => {
241
+ recordStructuredTelegramRuntimeEvent(
242
+ events,
243
+ { category, error, details },
244
+ {
245
+ botToken: options.getBotToken(),
246
+ maxEvents: options.maxEvents ?? MAX_RECENT_TELEGRAM_RUNTIME_EVENTS,
247
+ now: options.now?.(),
248
+ },
249
+ );
250
+ },
251
+ getEvents: () => events,
252
+ clear: () => {
253
+ events.length = 0;
254
+ },
255
+ };
256
+ }
257
+
258
+ function formatTelegramRuntimeEventCategory(
259
+ event: TelegramRuntimeEvent,
260
+ ): string {
261
+ const method = event.details?.method;
262
+ return typeof method === "string"
263
+ ? `${event.category}:${method}`
264
+ : event.category;
265
+ }
266
+
267
+ function formatTelegramRuntimeEventDetails(
268
+ event: TelegramRuntimeEvent,
269
+ ): string {
270
+ if (!event.details) return "";
271
+ const details = Object.entries(event.details)
272
+ .filter(([key]) => key !== "method")
273
+ .map(([key, value]) => `${key}=${JSON.stringify(value)}`);
274
+ return details.length > 0 ? ` (${details.join(", ")})` : "";
275
+ }
276
+
277
+ function formatTelegramRuntimeEventSummary(
278
+ event: TelegramRuntimeEvent,
279
+ ): string {
280
+ return `${formatTelegramRuntimeEventCategory(event)}: ${event.message}${formatTelegramRuntimeEventDetails(event)}`;
281
+ }
282
+
283
+ function formatTelegramRuntimeEvent(event: TelegramRuntimeEvent): string {
284
+ return `${new Date(event.at).toISOString()} ${formatTelegramRuntimeEventSummary(event)}`;
285
+ }
286
+
287
+ export function buildTelegramRuntimeEventLines(
288
+ events: TelegramRuntimeEvent[],
289
+ ): string[] {
290
+ if (events.length === 0) return ["recent runtime events: none"];
291
+ return [
292
+ "recent runtime events:",
293
+ ...events
294
+ .slice()
295
+ .reverse()
296
+ .map((event) => `- ${formatTelegramRuntimeEvent(event)}`),
297
+ ];
298
+ }
299
+
300
+ export function createTelegramStatusHtmlBuilder<TContext>(deps: {
301
+ getActiveModel: (ctx: TContext) => TelegramStatusActiveModel | undefined;
302
+ }): (ctx: TContext & TelegramStatusContext) => string {
303
+ return (ctx) => buildStatusHtml(ctx, deps.getActiveModel(ctx));
304
+ }
305
+
306
+ export function createTelegramStatusRuntime<
307
+ TContext extends TelegramStatusRuntimeContext,
308
+ >(deps: TelegramStatusRuntimeDeps<TContext>): TelegramStatusRuntime<TContext> {
309
+ const statusKey = deps.statusKey ?? "telegram";
310
+ return {
311
+ updateStatus: (ctx, error) => {
312
+ ctx.ui.setStatus(
313
+ statusKey,
314
+ buildTelegramStatusBarText(
315
+ ctx.ui.theme,
316
+ deps.getStatusBarState(ctx, error),
317
+ ),
318
+ );
319
+ },
320
+ getStatusLines: () =>
321
+ buildTelegramBridgeStatusLines(deps.getBridgeStatusLineState()),
322
+ };
323
+ }
324
+
325
+ export function createTelegramBridgeStatusRuntime<
326
+ TContext extends TelegramStatusRuntimeContext,
327
+ TQueueItem extends { queueLane: TelegramStatusQueueLane },
328
+ >(
329
+ deps: TelegramBridgeStatusRuntimeDeps<TQueueItem>,
330
+ ): TelegramStatusRuntime<TContext> {
331
+ return createTelegramStatusRuntime({
332
+ statusKey: deps.statusKey,
333
+ getStatusBarState: (_ctx, error) => {
334
+ const config = deps.getConfig();
335
+ const queuedItems = deps.getQueuedItems();
336
+ const compactionInProgress = deps.isCompactionInProgress();
337
+ return {
338
+ hasBotToken: !!config.botToken,
339
+ pollingActive: deps.isPollingActive(),
340
+ paired: !!config.allowedUserId,
341
+ compactionInProgress,
342
+ processing:
343
+ deps.hasActiveTurn() ||
344
+ deps.hasDispatchPending() ||
345
+ queuedItems.length > 0,
346
+ queuedStatus: deps.formatQueuedStatus(queuedItems),
347
+ error,
348
+ };
349
+ },
350
+ getBridgeStatusLineState: () => {
351
+ const config = deps.getConfig();
352
+ return {
353
+ botUsername: config.botUsername,
354
+ allowedUserId: config.allowedUserId,
355
+ lockState: deps.getRuntimeLockState?.(),
356
+ pollingActive: deps.isPollingActive(),
357
+ lastUpdateId: config.lastUpdateId,
358
+ activeSourceMessageIds: deps.getActiveSourceMessageIds(),
359
+ pendingDispatch: deps.hasDispatchPending(),
360
+ compactionInProgress: deps.isCompactionInProgress(),
361
+ activeToolExecutions: deps.getActiveToolExecutions(),
362
+ pendingModelSwitch: deps.hasPendingModelSwitch(),
363
+ queuedItems: deps.getQueuedItems(),
364
+ recentRuntimeEvents: deps.getRecentRuntimeEvents(),
365
+ };
366
+ },
367
+ });
368
+ }
369
+
370
+ export function buildTelegramStatusBarText(
371
+ theme: TelegramStatusBarTheme,
372
+ state: TelegramStatusBarState,
373
+ ): string {
374
+ const label = theme.fg("accent", "telegram");
375
+ if (state.error) {
376
+ return `${label} ${theme.fg("error", "error")} ${theme.fg("muted", state.error)}`;
377
+ }
378
+ if (!state.hasBotToken)
379
+ return `${label} ${theme.fg("muted", "not configured")}`;
380
+ if (!state.pollingActive)
381
+ return `${label} ${theme.fg("muted", "disconnected")}`;
382
+ if (!state.paired)
383
+ return `${label} ${theme.fg("warning", "awaiting pairing")}`;
384
+ const queued = theme.fg("muted", state.queuedStatus);
385
+ if (state.compactionInProgress) {
386
+ return `${label} ${theme.fg("accent", "compacting")}${queued}`;
387
+ }
388
+ if (state.processing) {
389
+ return `${label} ${theme.fg("accent", "processing")}${queued}`;
390
+ }
391
+ return `${label} ${theme.fg("success", "connected")}`;
392
+ }
393
+
394
+ export function buildTelegramBridgeStatusLines(
395
+ state: TelegramBridgeStatusLineState,
396
+ ): string[] {
397
+ const controlQueueCount = state.queuedItems.filter(
398
+ (item) => item.queueLane === "control",
399
+ ).length;
400
+ const priorityQueueCount = state.queuedItems.filter(
401
+ (item) => item.queueLane === "priority",
402
+ ).length;
403
+ const defaultQueueCount = state.queuedItems.filter(
404
+ (item) => item.queueLane === "default",
405
+ ).length;
406
+ return [
407
+ "connection:",
408
+ `- bot: ${state.botUsername ? `@${state.botUsername}` : "not configured"}`,
409
+ `- allowed user: ${state.allowedUserId ?? "not paired"}`,
410
+ ...(state.lockState ? [`- owner: ${state.lockState}`] : []),
411
+ "",
412
+ "polling:",
413
+ `- state: ${state.pollingActive ? "running" : "stopped"}`,
414
+ `- last update id: ${state.lastUpdateId ?? "none"}`,
415
+ "",
416
+ "execution:",
417
+ `- active turn: ${state.activeSourceMessageIds?.join(",") || "no"}`,
418
+ `- pending dispatch: ${state.pendingDispatch ? "yes" : "no"}`,
419
+ `- compaction: ${state.compactionInProgress ? "running" : "idle"}`,
420
+ `- active tools: ${state.activeToolExecutions}`,
421
+ `- pending model switch: ${state.pendingModelSwitch ? "yes" : "no"}`,
422
+ "",
423
+ "queue:",
424
+ `- queued turns: ${state.queuedItems.length}`,
425
+ `- lanes: control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount}`,
426
+ "",
427
+ ...buildTelegramRuntimeEventLines(state.recentRuntimeEvents),
428
+ ];
429
+ }
430
+
17
431
  function escapeHtml(text: string): string {
18
432
  return text
19
433
  .replace(/&/g, "&amp;")
@@ -29,7 +443,7 @@ function formatTokens(count: number): string {
29
443
  return `${Math.round(count / 1000000)}M`;
30
444
  }
31
445
 
32
- export function collectUsageStats(ctx: ExtensionContext): TelegramUsageStats {
446
+ function collectUsageStats(ctx: TelegramStatusContext): TelegramUsageStats {
33
447
  const stats: TelegramUsageStats = {
34
448
  totalInput: 0,
35
449
  totalOutput: 0,
@@ -38,14 +452,19 @@ export function collectUsageStats(ctx: ExtensionContext): TelegramUsageStats {
38
452
  totalCost: 0,
39
453
  };
40
454
  for (const entry of ctx.sessionManager.getEntries()) {
41
- if (entry.type !== "message" || entry.message.role !== "assistant") {
455
+ const usage = entry.message?.usage;
456
+ if (
457
+ entry.type !== "message" ||
458
+ entry.message?.role !== "assistant" ||
459
+ !usage
460
+ ) {
42
461
  continue;
43
462
  }
44
- stats.totalInput += entry.message.usage.input;
45
- stats.totalOutput += entry.message.usage.output;
46
- stats.totalCacheRead += entry.message.usage.cacheRead;
47
- stats.totalCacheWrite += entry.message.usage.cacheWrite;
48
- stats.totalCost += entry.message.usage.cost.total;
463
+ stats.totalInput += usage.input;
464
+ stats.totalOutput += usage.output;
465
+ stats.totalCacheRead += usage.cacheRead;
466
+ stats.totalCacheWrite += usage.cacheWrite;
467
+ stats.totalCost += usage.cost.total;
49
468
  }
50
469
  return stats;
51
470
  }
@@ -74,8 +493,8 @@ function buildCostSummary(
74
493
  }
75
494
 
76
495
  function buildContextSummary(
77
- ctx: ExtensionContext,
78
- activeModel: Model<any> | undefined,
496
+ ctx: TelegramStatusContext,
497
+ activeModel: TelegramStatusActiveModel | undefined,
79
498
  ): string {
80
499
  const usage = ctx.getContextUsage();
81
500
  if (!usage) return "unknown";
@@ -85,8 +504,8 @@ function buildContextSummary(
85
504
  }
86
505
 
87
506
  export function buildStatusHtml(
88
- ctx: ExtensionContext,
89
- activeModel: Model<any> | undefined,
507
+ ctx: TelegramStatusContext,
508
+ activeModel: TelegramStatusActiveModel | undefined,
90
509
  ): string {
91
510
  const stats = collectUsageStats(ctx);
92
511
  const usesSubscription = activeModel