@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.
@@ -0,0 +1,683 @@
1
+ /**
2
+ * Telegram activity lifecycle normalization and extension dispatch
3
+ * Zones: pi agent lifecycle, extension API, operational delivery
4
+ * Owns stable handler registration, evidence-based activity/source identity, assistant segment and reasoning normalization, executed-tool and compaction events, isolated non-blocking queues, shutdown fencing, diagnostics, and fresh delivery contexts; excludes Pi hook wiring, visibility policy, Telegram rendering, and consumer-extension behavior
5
+ */
6
+
7
+ import {
8
+ deleteTelegramView,
9
+ editTelegramView,
10
+ sendTelegramChatAction,
11
+ sendTelegramView,
12
+ type TelegramDeliveryChatAction,
13
+ type TelegramDeliveryHandle,
14
+ type TelegramDeliveryResult,
15
+ type TelegramDeliveryScope,
16
+ type TelegramDeliveryTarget,
17
+ type TelegramDeliveryView,
18
+ } from "./delivery.ts";
19
+
20
+ const TELEGRAM_ACTIVITY_REGISTRY_KEY = "__piTelegramActivityRegistry__";
21
+
22
+ export type TelegramActivitySource =
23
+ | "telegram"
24
+ | "local"
25
+ | "autonomous"
26
+ | "unknown";
27
+
28
+ export type TelegramActivityTarget = Readonly<TelegramDeliveryTarget>;
29
+
30
+ export interface TelegramActivityEnvelope {
31
+ activityId: string;
32
+ sequence: number;
33
+ source: TelegramActivitySource;
34
+ target?: TelegramActivityTarget;
35
+ timestamp: number;
36
+ }
37
+
38
+ export type TelegramActivityPayload =
39
+ | { type: "agent-start" }
40
+ | {
41
+ type: "assistant-text-delta";
42
+ contentIndex: number;
43
+ delta: string;
44
+ }
45
+ | {
46
+ type: "assistant-segment";
47
+ contentIndex: number;
48
+ text: string;
49
+ placement: "intermediate" | "final" | "terminal-partial";
50
+ }
51
+ | {
52
+ type: "reasoning-delta";
53
+ contentIndex: number;
54
+ delta: string;
55
+ }
56
+ | {
57
+ type: "reasoning-end";
58
+ contentIndex: number;
59
+ text: string;
60
+ }
61
+ | {
62
+ type: "tool-start";
63
+ toolCallId: string;
64
+ toolName: string;
65
+ args: unknown;
66
+ }
67
+ | {
68
+ type: "tool-update";
69
+ toolCallId: string;
70
+ toolName: string;
71
+ update: unknown;
72
+ }
73
+ | {
74
+ type: "tool-end";
75
+ toolCallId: string;
76
+ toolName: string;
77
+ result: unknown;
78
+ isError: boolean;
79
+ }
80
+ | {
81
+ type: "compaction-start";
82
+ reason: "manual" | "threshold" | "overflow" | "unknown";
83
+ }
84
+ | {
85
+ type: "compaction-end";
86
+ reason: "manual" | "threshold" | "overflow" | "unknown";
87
+ }
88
+ | { type: "agent-end" }
89
+ | { type: "agent-settled" };
90
+
91
+ export type TelegramActivityEvent = TelegramActivityEnvelope &
92
+ TelegramActivityPayload;
93
+
94
+ export interface TelegramActivityContext {
95
+ activityId: string;
96
+ sequence: number;
97
+ source: TelegramActivitySource;
98
+ defaultScope: TelegramDeliveryScope;
99
+ send: (
100
+ view: TelegramDeliveryView,
101
+ options?: {
102
+ scope?: TelegramDeliveryScope;
103
+ replyToMessageId?: number;
104
+ },
105
+ ) => Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
106
+ edit: (
107
+ handle: TelegramDeliveryHandle,
108
+ view: TelegramDeliveryView,
109
+ ) => Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
110
+ delete: (
111
+ handle: TelegramDeliveryHandle,
112
+ ) => Promise<TelegramDeliveryResult<void>>;
113
+ chatAction: (
114
+ action: TelegramDeliveryChatAction,
115
+ options?: { scope?: TelegramDeliveryScope },
116
+ ) => Promise<TelegramDeliveryResult<void>>;
117
+ }
118
+
119
+ export interface TelegramActivityHandlerRegistration {
120
+ id: string;
121
+ order?: number;
122
+ handle: (
123
+ event: TelegramActivityEvent,
124
+ ctx: TelegramActivityContext,
125
+ ) => void | Promise<void>;
126
+ }
127
+
128
+ interface RegisteredTelegramActivityHandler
129
+ extends TelegramActivityHandlerRegistration {
130
+ id: string;
131
+ order: number;
132
+ }
133
+
134
+ interface TelegramActivityRegistry {
135
+ handlers: Map<string, RegisteredTelegramActivityHandler>;
136
+ }
137
+
138
+ function getOrCreateTelegramActivityRegistry(): TelegramActivityRegistry {
139
+ const globals = globalThis as Record<string, unknown>;
140
+ const existing = globals[TELEGRAM_ACTIVITY_REGISTRY_KEY];
141
+ if (
142
+ existing &&
143
+ typeof existing === "object" &&
144
+ "handlers" in existing &&
145
+ existing.handlers instanceof Map
146
+ ) {
147
+ return existing as TelegramActivityRegistry;
148
+ }
149
+ const registry: TelegramActivityRegistry = { handlers: new Map() };
150
+ globals[TELEGRAM_ACTIVITY_REGISTRY_KEY] = registry;
151
+ return registry;
152
+ }
153
+
154
+ export function registerTelegramActivityHandler(
155
+ registration: TelegramActivityHandlerRegistration,
156
+ ): () => void {
157
+ const id = registration.id.trim();
158
+ if (!id) throw new Error("Telegram activity handler id is required.");
159
+ const registry = getOrCreateTelegramActivityRegistry();
160
+ if (registry.handlers.has(id)) {
161
+ throw new Error(`Telegram activity handler is already registered: ${id}`);
162
+ }
163
+ const handler: RegisteredTelegramActivityHandler = {
164
+ ...registration,
165
+ id,
166
+ order: registration.order ?? 0,
167
+ };
168
+ registry.handlers.set(id, handler);
169
+ return () => {
170
+ if (registry.handlers.get(id) === handler) registry.handlers.delete(id);
171
+ };
172
+ }
173
+
174
+ /** @internal */
175
+ export function clearTelegramActivityHandlers(): void {
176
+ getOrCreateTelegramActivityRegistry().handlers.clear();
177
+ }
178
+
179
+ function getTelegramActivityHandlers(): RegisteredTelegramActivityHandler[] {
180
+ return Array.from(
181
+ getOrCreateTelegramActivityRegistry().handlers.values(),
182
+ ).sort(function (left, right) {
183
+ return left.order - right.order || left.id.localeCompare(right.id);
184
+ });
185
+ }
186
+
187
+ function cloneActivityTarget(
188
+ target: TelegramActivityTarget,
189
+ ): TelegramActivityTarget {
190
+ return Object.freeze(
191
+ target.threadId === undefined
192
+ ? { chatId: target.chatId }
193
+ : { chatId: target.chatId, threadId: target.threadId },
194
+ );
195
+ }
196
+
197
+ function createTelegramActivityContext(
198
+ event: TelegramActivityEvent,
199
+ isActive: () => boolean,
200
+ ): TelegramActivityContext {
201
+ const defaultScope: TelegramDeliveryScope = event.target
202
+ ? { kind: "target", target: cloneActivityTarget(event.target) }
203
+ : event.source === "telegram"
204
+ ? { kind: "active-turn" }
205
+ : { kind: "instance" };
206
+ const inactive = <T>(): Promise<TelegramDeliveryResult<T>> =>
207
+ Promise.resolve({
208
+ ok: false,
209
+ reason: "runtime-unavailable",
210
+ message: "Telegram activity context belongs to an inactive session.",
211
+ });
212
+ return {
213
+ activityId: event.activityId,
214
+ sequence: event.sequence,
215
+ source: event.source,
216
+ defaultScope,
217
+ send(view, options) {
218
+ if (!isActive()) return inactive();
219
+ return sendTelegramView(view, {
220
+ scope: options?.scope ?? defaultScope,
221
+ replyToMessageId: options?.replyToMessageId,
222
+ });
223
+ },
224
+ edit(handle, view) {
225
+ return isActive() ? editTelegramView(handle, view) : inactive();
226
+ },
227
+ delete(handle) {
228
+ return isActive() ? deleteTelegramView(handle) : inactive();
229
+ },
230
+ chatAction(action, options) {
231
+ if (!isActive()) return inactive();
232
+ return sendTelegramChatAction(action, {
233
+ scope: options?.scope ?? defaultScope,
234
+ });
235
+ },
236
+ };
237
+ }
238
+
239
+ interface TelegramActivityHandlerQueue {
240
+ registration: RegisteredTelegramActivityHandler;
241
+ events: TelegramActivityEvent[];
242
+ running: boolean;
243
+ active: boolean;
244
+ }
245
+
246
+ function canCoalesceActivityEvents(
247
+ previous: TelegramActivityEvent,
248
+ next: TelegramActivityEvent,
249
+ ): boolean {
250
+ if (
251
+ previous.activityId !== next.activityId ||
252
+ previous.type !== next.type
253
+ ) {
254
+ return false;
255
+ }
256
+ if (
257
+ previous.type === "assistant-text-delta" &&
258
+ next.type === "assistant-text-delta"
259
+ ) {
260
+ return previous.contentIndex === next.contentIndex;
261
+ }
262
+ if (
263
+ previous.type === "reasoning-delta" &&
264
+ next.type === "reasoning-delta"
265
+ ) {
266
+ return previous.contentIndex === next.contentIndex;
267
+ }
268
+ if (previous.type === "tool-update" && next.type === "tool-update") {
269
+ return previous.toolCallId === next.toolCallId;
270
+ }
271
+ return false;
272
+ }
273
+
274
+ function coalesceActivityEvents(
275
+ previous: TelegramActivityEvent,
276
+ next: TelegramActivityEvent,
277
+ ): TelegramActivityEvent {
278
+ if (
279
+ previous.type === "assistant-text-delta" &&
280
+ next.type === "assistant-text-delta"
281
+ ) {
282
+ return { ...next, delta: previous.delta + next.delta };
283
+ }
284
+ if (
285
+ previous.type === "reasoning-delta" &&
286
+ next.type === "reasoning-delta"
287
+ ) {
288
+ return { ...next, delta: previous.delta + next.delta };
289
+ }
290
+ return next;
291
+ }
292
+
293
+ /** @internal */
294
+ export interface TelegramActivityDispatcher {
295
+ dispatch: (event: TelegramActivityEvent) => void;
296
+ stop: () => void;
297
+ }
298
+
299
+ /** @internal */
300
+ export function createTelegramActivityDispatcher(deps: {
301
+ recordFailure?: (
302
+ handlerId: string,
303
+ event: TelegramActivityEvent,
304
+ error: unknown,
305
+ ) => void;
306
+ } = {}): TelegramActivityDispatcher {
307
+ const queues = new Map<string, TelegramActivityHandlerQueue>();
308
+ let stopped = false;
309
+ const drain = async (queue: TelegramActivityHandlerQueue): Promise<void> => {
310
+ if (queue.running || !queue.active) return;
311
+ queue.running = true;
312
+ try {
313
+ while (queue.active) {
314
+ const event = queue.events.shift();
315
+ if (!event) break;
316
+ if (
317
+ getOrCreateTelegramActivityRegistry().handlers.get(
318
+ queue.registration.id,
319
+ ) !== queue.registration
320
+ ) {
321
+ queue.active = false;
322
+ queue.events = [];
323
+ break;
324
+ }
325
+ try {
326
+ await queue.registration.handle(
327
+ event,
328
+ createTelegramActivityContext(event, () =>
329
+ queue.active &&
330
+ !stopped &&
331
+ getOrCreateTelegramActivityRegistry().handlers.get(
332
+ queue.registration.id,
333
+ ) === queue.registration,
334
+ ),
335
+ );
336
+ } catch (error) {
337
+ deps.recordFailure?.(queue.registration.id, event, error);
338
+ }
339
+ }
340
+ } finally {
341
+ queue.running = false;
342
+ }
343
+ };
344
+ return {
345
+ dispatch(event) {
346
+ if (stopped) return;
347
+ for (const registration of getTelegramActivityHandlers()) {
348
+ let queue = queues.get(registration.id);
349
+ if (!queue || queue.registration !== registration) {
350
+ queue = {
351
+ registration,
352
+ events: [],
353
+ running: false,
354
+ active: true,
355
+ };
356
+ queues.set(registration.id, queue);
357
+ }
358
+ const previous = queue.events.at(-1);
359
+ if (previous && canCoalesceActivityEvents(previous, event)) {
360
+ queue.events[queue.events.length - 1] = coalesceActivityEvents(
361
+ previous,
362
+ event,
363
+ );
364
+ } else {
365
+ queue.events.push(event);
366
+ }
367
+ queueMicrotask(function () {
368
+ void drain(queue!);
369
+ });
370
+ }
371
+ },
372
+ stop() {
373
+ stopped = true;
374
+ for (const queue of queues.values()) {
375
+ queue.active = false;
376
+ queue.events = [];
377
+ }
378
+ queues.clear();
379
+ },
380
+ };
381
+ }
382
+
383
+ /** @internal */
384
+ export function createTelegramActivityBridgeRuntime(deps: {
385
+ generation: string;
386
+ recordFailure?: (
387
+ handlerId: string,
388
+ event: TelegramActivityEvent,
389
+ error: unknown,
390
+ ) => void;
391
+ now?: () => number;
392
+ }): TelegramActivityRuntime {
393
+ let generationSequence = 0;
394
+ let runtime: TelegramActivityRuntime | undefined;
395
+ const getRuntime = (): TelegramActivityRuntime | undefined => runtime;
396
+ return {
397
+ onSessionStart() {
398
+ runtime?.onSessionShutdown();
399
+ runtime = createTelegramActivityRuntime({
400
+ generation: `${deps.generation}:${++generationSequence}`,
401
+ dispatcher: createTelegramActivityDispatcher({
402
+ recordFailure: deps.recordFailure,
403
+ }),
404
+ now: deps.now,
405
+ });
406
+ },
407
+ recordInputSource(source) {
408
+ getRuntime()?.recordInputSource(source);
409
+ },
410
+ onAgentStart(target) {
411
+ getRuntime()?.onAgentStart(target);
412
+ },
413
+ onAssistantEvent(event) {
414
+ getRuntime()?.onAssistantEvent(event);
415
+ },
416
+ onToolStart(event) {
417
+ getRuntime()?.onToolStart(event);
418
+ },
419
+ onToolUpdate(event) {
420
+ getRuntime()?.onToolUpdate(event);
421
+ },
422
+ onToolEnd(event) {
423
+ getRuntime()?.onToolEnd(event);
424
+ },
425
+ onCompactionStart(reason) {
426
+ getRuntime()?.onCompactionStart(reason);
427
+ },
428
+ onCompactionEnd(reason) {
429
+ getRuntime()?.onCompactionEnd(reason);
430
+ },
431
+ onCompactionAbandoned() {
432
+ getRuntime()?.onCompactionAbandoned();
433
+ },
434
+ onAgentEnd() {
435
+ getRuntime()?.onAgentEnd();
436
+ },
437
+ onAgentSettled() {
438
+ getRuntime()?.onAgentSettled();
439
+ },
440
+ onSessionShutdown() {
441
+ runtime?.onSessionShutdown();
442
+ runtime = undefined;
443
+ },
444
+ };
445
+ }
446
+
447
+ export type TelegramActivityInputSource =
448
+ | "interactive"
449
+ | "rpc"
450
+ | "extension"
451
+ | "unknown";
452
+
453
+ export type TelegramAssistantStreamEvent =
454
+ | { type: "start" }
455
+ | { type: "text_start"; contentIndex: number }
456
+ | { type: "text_delta"; contentIndex: number; delta: string }
457
+ | { type: "text_end"; contentIndex: number; content: string }
458
+ | { type: "thinking_delta"; contentIndex: number; delta: string }
459
+ | { type: "thinking_end"; contentIndex: number; content: string }
460
+ | { type: "thinking_start"; contentIndex: number }
461
+ | { type: "toolcall_start"; contentIndex: number }
462
+ | { type: "toolcall_delta"; contentIndex: number; delta: string }
463
+ | { type: "toolcall_end"; contentIndex: number }
464
+ | { type: "done" }
465
+ | { type: "error" };
466
+
467
+ /** @internal */
468
+ export interface TelegramActivityRuntime {
469
+ onSessionStart?: () => void;
470
+ recordInputSource: (source: TelegramActivityInputSource) => void;
471
+ onAgentStart: (activeTelegramTarget?: TelegramActivityTarget) => void;
472
+ onAssistantEvent: (event: TelegramAssistantStreamEvent) => void;
473
+ onToolStart: (event: {
474
+ toolCallId: string;
475
+ toolName: string;
476
+ args: unknown;
477
+ }) => void;
478
+ onToolUpdate: (event: {
479
+ toolCallId: string;
480
+ toolName: string;
481
+ update: unknown;
482
+ }) => void;
483
+ onToolEnd: (event: {
484
+ toolCallId: string;
485
+ toolName: string;
486
+ result: unknown;
487
+ isError: boolean;
488
+ }) => void;
489
+ onCompactionStart: (
490
+ reason: "manual" | "threshold" | "overflow" | "unknown",
491
+ ) => void;
492
+ onCompactionEnd: (
493
+ reason: "manual" | "threshold" | "overflow" | "unknown",
494
+ ) => void;
495
+ onCompactionAbandoned: () => void;
496
+ onAgentEnd: () => void;
497
+ onAgentSettled: () => void;
498
+ onSessionShutdown: () => void;
499
+ }
500
+
501
+ interface PendingAssistantSegment {
502
+ contentIndex: number;
503
+ text: string;
504
+ }
505
+
506
+ /** @internal */
507
+ export function createTelegramActivityRuntime(deps: {
508
+ generation: string;
509
+ dispatcher: TelegramActivityDispatcher;
510
+ now?: () => number;
511
+ }): TelegramActivityRuntime {
512
+ const now = deps.now ?? Date.now;
513
+ let nextActivityNumber = 0;
514
+ let activityId: string | undefined;
515
+ let activitySource: TelegramActivitySource = "unknown";
516
+ let activityTarget: TelegramActivityTarget | undefined;
517
+ let sequence = 0;
518
+ let pendingInputSource: TelegramActivityInputSource = "unknown";
519
+ let pendingAssistantSegment: PendingAssistantSegment | undefined;
520
+ let compactionInProgress = false;
521
+ let compactionOwnedActivity = false;
522
+ const ensureActivity = (
523
+ activeTelegramTarget?: TelegramActivityTarget,
524
+ ): string => {
525
+ if (activityId) return activityId;
526
+ nextActivityNumber += 1;
527
+ activityId = `${deps.generation}:${nextActivityNumber}`;
528
+ activitySource = activeTelegramTarget
529
+ ? "telegram"
530
+ : pendingInputSource === "interactive" || pendingInputSource === "rpc"
531
+ ? "local"
532
+ : pendingInputSource === "extension"
533
+ ? "autonomous"
534
+ : "unknown";
535
+ activityTarget = activeTelegramTarget
536
+ ? cloneActivityTarget(activeTelegramTarget)
537
+ : undefined;
538
+ sequence = 0;
539
+ pendingInputSource = "unknown";
540
+ return activityId;
541
+ };
542
+ const emit = (event: TelegramActivityPayload): void => {
543
+ const currentActivityId = ensureActivity();
544
+ sequence += 1;
545
+ deps.dispatcher.dispatch({
546
+ ...event,
547
+ activityId: currentActivityId,
548
+ sequence,
549
+ source: activitySource,
550
+ ...(activityTarget ? { target: activityTarget } : {}),
551
+ timestamp: now(),
552
+ } as TelegramActivityEvent);
553
+ };
554
+ const flushPendingSegment = (
555
+ placement: "intermediate" | "final" | "terminal-partial",
556
+ ): void => {
557
+ const segment = pendingAssistantSegment;
558
+ pendingAssistantSegment = undefined;
559
+ if (!segment?.text.trim()) return;
560
+ emit({
561
+ type: "assistant-segment",
562
+ contentIndex: segment.contentIndex,
563
+ text: segment.text,
564
+ placement,
565
+ });
566
+ };
567
+ const clearActivity = (): void => {
568
+ activityId = undefined;
569
+ activitySource = "unknown";
570
+ activityTarget = undefined;
571
+ sequence = 0;
572
+ pendingAssistantSegment = undefined;
573
+ compactionInProgress = false;
574
+ compactionOwnedActivity = false;
575
+ };
576
+ const abandonCompaction = (): void => {
577
+ if (!compactionInProgress) return;
578
+ const shouldClearActivity = compactionOwnedActivity;
579
+ compactionInProgress = false;
580
+ compactionOwnedActivity = false;
581
+ if (shouldClearActivity) clearActivity();
582
+ };
583
+ return {
584
+ recordInputSource(source) {
585
+ pendingInputSource = source;
586
+ },
587
+ onAgentStart(activeTelegramTarget) {
588
+ abandonCompaction();
589
+ ensureActivity(activeTelegramTarget);
590
+ emit({ type: "agent-start" });
591
+ },
592
+ onAssistantEvent(event) {
593
+ if (event.type === "text_start") {
594
+ flushPendingSegment("intermediate");
595
+ return;
596
+ }
597
+ if (event.type === "text_delta") {
598
+ if (!event.delta) return;
599
+ emit({
600
+ type: "assistant-text-delta",
601
+ contentIndex: event.contentIndex,
602
+ delta: event.delta,
603
+ });
604
+ return;
605
+ }
606
+ if (event.type === "text_end") {
607
+ pendingAssistantSegment = {
608
+ contentIndex: event.contentIndex,
609
+ text: event.content,
610
+ };
611
+ return;
612
+ }
613
+ if (event.type === "thinking_delta") {
614
+ if (!event.delta) return;
615
+ emit({
616
+ type: "reasoning-delta",
617
+ contentIndex: event.contentIndex,
618
+ delta: event.delta,
619
+ });
620
+ return;
621
+ }
622
+ if (event.type === "thinking_end") {
623
+ if (!event.content.trim()) return;
624
+ emit({
625
+ type: "reasoning-end",
626
+ contentIndex: event.contentIndex,
627
+ text: event.content,
628
+ });
629
+ return;
630
+ }
631
+ if (event.type === "toolcall_start") {
632
+ flushPendingSegment("intermediate");
633
+ return;
634
+ }
635
+ if (event.type === "done") {
636
+ flushPendingSegment("final");
637
+ return;
638
+ }
639
+ if (event.type === "error") flushPendingSegment("terminal-partial");
640
+ },
641
+ onToolStart(event) {
642
+ emit({ type: "tool-start", ...event });
643
+ },
644
+ onToolUpdate(event) {
645
+ emit({ type: "tool-update", ...event });
646
+ },
647
+ onToolEnd(event) {
648
+ emit({ type: "tool-end", ...event });
649
+ },
650
+ onCompactionStart(reason) {
651
+ abandonCompaction();
652
+ compactionOwnedActivity = !activityId;
653
+ compactionInProgress = true;
654
+ ensureActivity();
655
+ emit({ type: "compaction-start", reason });
656
+ },
657
+ onCompactionEnd(reason) {
658
+ if (!compactionInProgress || !activityId) return;
659
+ const shouldClearActivity = compactionOwnedActivity;
660
+ compactionInProgress = false;
661
+ compactionOwnedActivity = false;
662
+ emit({ type: "compaction-end", reason });
663
+ if (shouldClearActivity) clearActivity();
664
+ },
665
+ onCompactionAbandoned() {
666
+ abandonCompaction();
667
+ },
668
+ onAgentEnd() {
669
+ if (activityId) emit({ type: "agent-end" });
670
+ },
671
+ onAgentSettled() {
672
+ if (!activityId) return;
673
+ flushPendingSegment("terminal-partial");
674
+ emit({ type: "agent-settled" });
675
+ clearActivity();
676
+ },
677
+ onSessionShutdown() {
678
+ pendingInputSource = "unknown";
679
+ clearActivity();
680
+ deps.dispatcher.stop();
681
+ },
682
+ };
683
+ }