@llblab/pi-telegram 0.54.3 → 0.55.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 (80) hide show
  1. package/AGENTS.md +4 -2
  2. package/BACKLOG.md +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +5 -3
  5. package/dist/lib/activity.d.ts +5 -1
  6. package/dist/lib/activity.js +31 -2
  7. package/dist/lib/bindings.d.ts +5 -1
  8. package/dist/lib/bindings.js +9 -2
  9. package/dist/lib/bus-api.d.ts +2 -2
  10. package/dist/lib/bus-api.js +7 -2
  11. package/dist/lib/bus-follower.d.ts +49 -3
  12. package/dist/lib/bus-follower.js +313 -146
  13. package/dist/lib/bus-leader.d.ts +3 -0
  14. package/dist/lib/bus-leader.js +99 -18
  15. package/dist/lib/bus.d.ts +5 -3
  16. package/dist/lib/bus.js +13 -10
  17. package/dist/lib/delivery.d.ts +4 -0
  18. package/dist/lib/delivery.js +48 -15
  19. package/dist/lib/extension.js +56 -6
  20. package/dist/lib/lifecycle.d.ts +68 -0
  21. package/dist/lib/lifecycle.js +178 -58
  22. package/dist/lib/machine.d.ts +23 -0
  23. package/dist/lib/machine.js +37 -0
  24. package/dist/lib/media.d.ts +114 -28
  25. package/dist/lib/media.js +292 -119
  26. package/dist/lib/model.d.ts +54 -15
  27. package/dist/lib/model.js +98 -53
  28. package/dist/lib/polling.js +2 -0
  29. package/dist/lib/preview.d.ts +128 -113
  30. package/dist/lib/preview.js +448 -366
  31. package/dist/lib/queue.d.ts +2 -2
  32. package/dist/lib/queue.js +3 -3
  33. package/dist/lib/routing.d.ts +14 -2
  34. package/dist/lib/routing.js +546 -207
  35. package/dist/lib/runtime.d.ts +77 -11
  36. package/dist/lib/runtime.js +174 -92
  37. package/dist/lib/sections.js +0 -1
  38. package/dist/lib/telegram-api.d.ts +11 -9
  39. package/dist/lib/telegram-api.js +53 -4
  40. package/dist/lib/text-groups.d.ts +0 -26
  41. package/dist/lib/text-groups.js +51 -98
  42. package/dist/lib/thread-display.js +14 -6
  43. package/dist/lib/threads.d.ts +20 -5
  44. package/dist/lib/threads.js +111 -32
  45. package/dist/lib/turns.d.ts +2 -0
  46. package/dist/lib/turns.js +6 -1
  47. package/dist/lib/updates.d.ts +11 -4
  48. package/dist/lib/updates.js +54 -19
  49. package/dist/package.json +1 -1
  50. package/docs/activity.md +1 -1
  51. package/docs/architecture.md +16 -15
  52. package/docs/delivery.md +7 -5
  53. package/docs/multi-instance-bus.md +18 -8
  54. package/docs/public-api.md +2 -2
  55. package/docs/ui-style.md +8 -6
  56. package/lib/activity.ts +29 -5
  57. package/lib/bindings.ts +13 -1
  58. package/lib/bus-api.ts +11 -3
  59. package/lib/bus-follower.ts +417 -203
  60. package/lib/bus-leader.ts +88 -14
  61. package/lib/bus.ts +17 -15
  62. package/lib/delivery.ts +47 -19
  63. package/lib/extension.ts +59 -9
  64. package/lib/lifecycle.ts +229 -55
  65. package/lib/machine.ts +60 -0
  66. package/lib/media.ts +438 -172
  67. package/lib/model.ts +169 -74
  68. package/lib/polling.ts +2 -0
  69. package/lib/preview.ts +715 -566
  70. package/lib/queue.ts +5 -5
  71. package/lib/routing.ts +644 -283
  72. package/lib/runtime.ts +260 -104
  73. package/lib/sections.ts +0 -2
  74. package/lib/telegram-api.ts +61 -8
  75. package/lib/text-groups.ts +75 -136
  76. package/lib/thread-display.ts +15 -4
  77. package/lib/threads.ts +130 -38
  78. package/lib/turns.ts +8 -1
  79. package/lib/updates.ts +58 -21
  80. package/package.json +1 -1
@@ -65,6 +65,8 @@ const telegramBusProtocolIdentity = Bus.createTelegramCurrentBusProtocolIdentity
65
65
  Bus.TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
66
66
  Bus.TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
67
67
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE,
68
+ Bus.TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND,
69
+ Bus.TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION,
68
70
  Bus.TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SAVE,
69
71
  Bus.TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY,
70
72
  Bus.TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE,
@@ -303,7 +305,6 @@ export default function (pi) {
303
305
  const proactivePushChatIdGetter = Config.createTelegramProactivePushChatIdGetter(proactivePushTargetGetter);
304
306
  const buttonActionStore = Outbound.createTelegramButtonActionStore();
305
307
  const planGenerativeAppOutput = Outbound.createTelegramOutboundReplyPlanner(buttonActionStore, configControls.getAssistantRenderingMode);
306
- const pendingModelSwitchStore = Model.createPendingModelSwitchStore();
307
308
  const modelMenuRuntime = Menu.createTelegramModelMenuRuntime();
308
309
  const sectionRegistry = Sections.createAndBindTelegramSectionRegistry();
309
310
  const timeInjectionRuntime = TimeInjection.createTimeInjectionRuntime({
@@ -323,7 +324,24 @@ export default function (pi) {
323
324
  getBotToken: configStore.getBotToken,
324
325
  });
325
326
  const telegramQueueStore = Queue.createTelegramTransportStampedQueueStore(rawTelegramQueueStore, telegramTransportStampRuntime.getStamp);
326
- const telegramApiTargetActivityRuntime = TelegramApi.createTelegramApiTargetActivityRuntime();
327
+ const captureCleanupDrain = Lifecycle.createTelegramSessionDrainObserver({
328
+ context: telegramSessionContextStore,
329
+ captureAuthority() {
330
+ const epoch = getCurrentLeaderEpoch();
331
+ return epoch === undefined ? undefined : {
332
+ epoch, operator: configStore.getAllowedUserId(), stamp: telegramTransportStampRuntime.getStamp(),
333
+ };
334
+ },
335
+ isAuthorityActive(authority) {
336
+ return getCurrentLeaderEpoch() === authority.epoch &&
337
+ configStore.getAllowedUserId() === authority.operator &&
338
+ telegramTransportStampRuntime.isActive(authority.stamp);
339
+ },
340
+ onDrained(ctx) { inboundRouteRuntime.onDeliverySettled(ctx); },
341
+ });
342
+ const telegramApiTargetActivityRuntime = TelegramApi.createTelegramApiTargetActivityRuntime({
343
+ captureDrain: captureCleanupDrain,
344
+ });
327
345
  // Retirement protection and dead-owner reclamation must inspect the same journal sources.
328
346
  const workspaceRetirementJournalPorts = {
329
347
  getActiveTurnTarget: activeTurnRuntime.getTarget,
@@ -478,6 +496,13 @@ export default function (pi) {
478
496
  instanceId: telegramInstanceId,
479
497
  listRecords: threadStore.list,
480
498
  listWorkspaceBindings: threadStore.listWorkspaceBindings,
499
+ resolveAutomaticDisplayTitle(binding) {
500
+ return ThreadDisplay.resolveTelegramInitialWorkspaceDisplayName({
501
+ bindings: threadStore.listWorkspaceBindings(),
502
+ binding,
503
+ mode: Config.resolveTelegramThreadDisplayMode(configStore.get()),
504
+ });
505
+ },
481
506
  getFollowerDisplayTitle: telegramBusFollowerRegistrationState.getDisplayTitle,
482
507
  getActiveTurnTarget: activeTurnRuntime.getTarget,
483
508
  getFollowerTarget: telegramBusFollowerRegistrationState.getTarget,
@@ -526,7 +551,10 @@ export default function (pi) {
526
551
  hasDispatchPending: lifecycle.hasDispatchPending,
527
552
  isCompactionInProgress: lifecycle.isCompactionInProgress,
528
553
  getActiveToolExecutions: lifecycle.getActiveToolExecutions,
529
- hasPendingModelSwitch: pendingModelSwitchStore.has,
554
+ // Status is composed before the model-switch controller; it reads the controller's state when it renders.
555
+ hasPendingModelSwitch() {
556
+ return modelSwitchController.hasPendingSwitch();
557
+ },
530
558
  getQueuedItems: telegramQueueStore.getQueuedItems,
531
559
  getQueuedItemCount: Queue.countExecutableTelegramQueueItems,
532
560
  formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
@@ -566,6 +594,11 @@ export default function (pi) {
566
594
  // --- Telegram API ---
567
595
  const directTelegramApiRuntime = TelegramApi.createDefaultTelegramBridgeApiRuntime({
568
596
  getBotToken: configStore.getBotToken,
597
+ isThreadedMode() {
598
+ return threadStore.getBotState().threadMode === "enabled" ||
599
+ telegramBusFollowerRegistrationState.getTarget()?.threadId !== undefined ||
600
+ telegramBusLeaderState.getTarget()?.threadId !== undefined;
601
+ },
569
602
  getBotScope() {
570
603
  const config = configStore.get();
571
604
  return (config.botUsername ??
@@ -624,9 +657,13 @@ export default function (pi) {
624
657
  lifecycle,
625
658
  typing,
626
659
  getDefaultChatId: proactivePushChatIdGetter,
660
+ isThreadedMode() {
661
+ return deliveryTargetPolicyRuntime.getTargetPolicyView().threaded === true;
662
+ },
627
663
  sendTypingAction,
628
664
  updateStatus,
629
665
  isContextActive: telegramSessionContextStore.isCurrent,
666
+ getSessionGeneration: telegramSessionContextStore.getGeneration,
630
667
  getTransportAuthority() {
631
668
  if (ownsTelegramDirectDelivery()) {
632
669
  const epoch = getCurrentLeaderEpoch();
@@ -654,6 +691,11 @@ export default function (pi) {
654
691
  const { replyTransport, editInteractiveMessage, sendInteractiveMessage, sendSectionRichMessage, } = replyRuntime;
655
692
  const deliveryTargetPolicyRuntime = Delivery.createTelegramDeliveryTargetPolicyRuntime({
656
693
  ownsDirect: lockRuntime.owns,
694
+ isThreadedMode() {
695
+ return threadStore.getBotState().threadMode === "enabled" ||
696
+ telegramBusFollowerRegistrationState.getTarget()?.threadId !== undefined ||
697
+ telegramBusLeaderState.getTarget()?.threadId !== undefined;
698
+ },
657
699
  isFollowerRegistered: telegramBusFollowerRegistrationState.isRegistered,
658
700
  getAllowedChatId: configStore.getAllowedUserId,
659
701
  getFollowerTarget: telegramBusFollowerRegistrationState.getTarget,
@@ -665,6 +707,7 @@ export default function (pi) {
665
707
  const deliveryGenerationSeed = Delivery.createTelegramDeliveryGenerationSeed(telegramInstanceId);
666
708
  const deliveryLifecycleRuntime = Delivery.createTelegramBridgeDeliveryLifecycleHooks({
667
709
  generationSeed: deliveryGenerationSeed,
710
+ captureDrain: captureCleanupDrain,
668
711
  getTargetPolicyView: deliveryTargetPolicyRuntime.getTargetPolicyView,
669
712
  getTransportStamp: telegramTransportStampRuntime.getStamp,
670
713
  isTransportStampActive: telegramTransportStampRuntime.isActive,
@@ -716,6 +759,7 @@ export default function (pi) {
716
759
  });
717
760
  const { activityRuntime, activityVerbosityRuntime, assistantOutputRuntime, publicationRuntime, } = Bindings.createTelegramActivityBindingRuntime({
718
761
  generation: deliveryGenerationSeed,
762
+ captureDrain: captureCleanupDrain,
719
763
  assistantOutput: {
720
764
  prepareTelegramPreview: previewRuntime.preparePublication,
721
765
  authority: {
@@ -781,8 +825,6 @@ export default function (pi) {
781
825
  // --- Model And Menu Setup ---
782
826
  const modelSwitchController = Model.createTelegramModelSwitchControllerRuntime({
783
827
  isIdle,
784
- getPendingModelSwitch: pendingModelSwitchStore.get,
785
- setPendingModelSwitch: pendingModelSwitchStore.set,
786
828
  getActiveTurn: activeTurnRuntime.get,
787
829
  getAbortHandler: abort.getHandler,
788
830
  hasAbortHandler: abort.hasHandler,
@@ -980,7 +1022,7 @@ export default function (pi) {
980
1022
  lifecycle: bridgeRuntime.lifecycle,
981
1023
  isIdle,
982
1024
  hasPendingMessages,
983
- hasPendingControl: pendingModelSwitchStore.has,
1025
+ hasPendingControl: modelSwitchController.hasPendingSwitch,
984
1026
  publication: publicationRuntime,
985
1027
  activity: activityRuntime,
986
1028
  delivery: deliveryLifecycleRuntime,
@@ -1118,6 +1160,11 @@ export default function (pi) {
1118
1160
  return (lockRuntime.owns() &&
1119
1161
  Bus.hasTelegramBusCapability(telegramBusProtocolIdentity, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE));
1120
1162
  },
1163
+ canUsePreDispatchRebind(recipient) {
1164
+ return lockRuntime.owns() && telegramBusFollowerRegistry.list().every(function (follower) {
1165
+ return Bus.hasTelegramBusCapability(follower.protocol, Bus.TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND);
1166
+ }) && (recipient?.kind !== "follower" || Bus.hasTelegramBusCapability(telegramBusFollowerRegistry.get(recipient.instanceId)?.protocol, Bus.TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION));
1167
+ },
1121
1168
  hasWorkspaceLiveRebindAuthority() {
1122
1169
  return (lockRuntime.owns() &&
1123
1170
  Bus.hasTelegramBusCapability(telegramBusProtocolIdentity, Bus.TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SAVE) &&
@@ -1732,6 +1779,7 @@ export default function (pi) {
1732
1779
  followerRegistry: telegramBusFollowerRegistry,
1733
1780
  authSecret: telegramBusAuthSecret,
1734
1781
  protocolIdentity: telegramBusProtocolIdentity,
1782
+ requiresPreDispatchRebindReader() { return threadStore.hasPreDispatchRebindEvidence(); },
1735
1783
  selectedMenuDelivery,
1736
1784
  startPolling: pollingAdmissionRuntime.start,
1737
1785
  stopPolling: pollingAdmissionRuntime.stop,
@@ -2435,10 +2483,12 @@ export default function (pi) {
2435
2483
  isSessionContextActive(ctx) {
2436
2484
  return telegramSessionContextStore.isCurrent(ctx);
2437
2485
  },
2486
+ getSessionGeneration: telegramSessionContextStore.getGeneration,
2438
2487
  isTurnTransportActive(turn) {
2439
2488
  return telegramTransportStampRuntime.isActive(turn.transportStamp);
2440
2489
  },
2441
2490
  updateStatus,
2442
2491
  recordRuntimeEvent,
2492
+ onSessionSettled: inboundRouteRuntime.onSessionSettled,
2443
2493
  });
2444
2494
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import * as BusFollower from "./bus-follower.ts";
8
8
  import type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AssistantMessageEvent, BeforeAgentStartEvent, ExtensionAPI, ExtensionContext, InputEvent, MessageEndEvent, SessionBeforeCompactEvent, SessionCompactEvent, SessionCompactFailedEvent, SessionShutdownEvent, SessionStartEvent, ToolExecutionEndEvent, ToolExecutionStartEvent, ToolExecutionUpdateEvent, UIPromptEndEvent, UIPromptStartEvent } from "./pi.ts";
9
+ import * as Machine from "./machine.ts";
9
10
  import * as Queue from "./queue.ts";
10
11
  export declare function createAgentStartDedupHook(inner: (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>, resetReplyDedup: () => void, schedulePublication?: (task: () => Promise<void>) => void): (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>;
11
12
  type TelegramBeforeAgentStartEvent = Omit<BeforeAgentStartEvent, "systemPrompt"> & {
@@ -112,6 +113,13 @@ export interface TelegramSessionContextStore<TContext> {
112
113
  export declare function createTelegramSessionContextStore<TContext>(options?: {
113
114
  getIdentity?: (ctx: TContext) => unknown;
114
115
  }): TelegramSessionContextStore<TContext>;
116
+ /** Capture drain hints at work admission; late completion never borrows a replacement session or transport. */
117
+ export declare function createTelegramSessionDrainObserver<TContext, TAuthority>(deps: {
118
+ context: Pick<TelegramSessionContextStore<TContext>, "get" | "getGeneration" | "isCurrent">;
119
+ captureAuthority: () => TAuthority | undefined;
120
+ isAuthorityActive: (authority: TAuthority) => boolean;
121
+ onDrained: (ctx: TContext) => void;
122
+ }): () => (() => void) | undefined;
115
123
  export declare function createTelegramSessionGenerationFence(store: TelegramSessionContextStore<ExtensionContext>, hooks: TelegramSessionLifecycleHooks): TelegramSessionLifecycleHooks;
116
124
  export interface TelegramBridgeSessionServiceRuntime {
117
125
  connection?: TelegramConnectionLifecycle;
@@ -192,9 +200,69 @@ export interface TelegramCompactionObserverRuntime<TContext> {
192
200
  onSessionCompactFailed: (event: SessionCompactFailedEvent, ctx: TContext) => void;
193
201
  onSessionShutdown: () => void;
194
202
  }
203
+ /** The whole compaction-observer state; only `transitionTelegramCompactionObserver` produces a new one. */
204
+ export interface TelegramCompactionObserverState {
205
+ /** The fallback timer's observation; a timer from any other one is inert. */
206
+ readonly observation: number | null;
207
+ /** The typing loop is the observer's to stop; a loop the agent already ran is never stopped by compaction. */
208
+ readonly typingOwned: boolean;
209
+ readonly nextObservation: number;
210
+ }
211
+ export type TelegramCompactionObserverEvent = {
212
+ type: "beforeCompact";
213
+ }
214
+ /** The outcome of the observer's own typing start, reported back by the shell. */
215
+ | {
216
+ type: "typingStarted";
217
+ started: boolean;
218
+ } | {
219
+ type: "compacted";
220
+ } | {
221
+ type: "compactFailed";
222
+ } | {
223
+ type: "timerFired";
224
+ observation: number;
225
+ contextActive: boolean;
226
+ } | {
227
+ type: "shutdown";
228
+ };
229
+ export type TelegramCompactionObserverEffect = {
230
+ type: "setInProgress";
231
+ inProgress: boolean;
232
+ } | {
233
+ type: "startTyping";
234
+ } | {
235
+ type: "stopTyping";
236
+ } | {
237
+ type: "updateStatus";
238
+ } | {
239
+ type: "armTimer";
240
+ observation: number;
241
+ } | {
242
+ type: "cancelTimer";
243
+ } | {
244
+ type: "recordTimeout";
245
+ } | {
246
+ type: "abandoned";
247
+ } | {
248
+ type: "requestDispatch";
249
+ };
250
+ export declare function createTelegramCompactionObserverState(): TelegramCompactionObserverState;
251
+ /**
252
+ * Pure compaction-observer transition. A completion or failure always releases presence; the timeout releases only its
253
+ * own observation. Typing ownership is sticky: a repeated start while the observer's own loop runs reports `false`
254
+ * from the shared loop, and must not hand that loop to nobody.
255
+ */
256
+ export declare function transitionTelegramCompactionObserver(state: TelegramCompactionObserverState, event: TelegramCompactionObserverEvent): Machine.TelegramMachineStep<TelegramCompactionObserverState, TelegramCompactionObserverEffect>;
257
+ /**
258
+ * The shell around `transitionTelegramCompactionObserver`: it fences inactive contexts, runs effects with the calling
259
+ * context, reports the typing start back and owns the fallback timer, which keeps the context it was armed under.
260
+ */
195
261
  export declare function createTelegramCompactionObserverRuntime<TContext>(deps: TelegramCompactionObserverRuntimeDeps<TContext>): TelegramCompactionObserverRuntime<TContext>;
196
262
  export interface TelegramMessageActivityTypingDeps<TContext> {
197
263
  hasActiveTurn: () => boolean;
264
+ isContextActive?: (ctx: TContext) => boolean;
265
+ getSessionGeneration?: () => number;
198
266
  startTypingLoop: (ctx: TContext) => void;
199
267
  onMessageStart: TelegramLifecycleRegistrationDeps["onMessageStart"];
200
268
  onMessageUpdate: TelegramLifecycleRegistrationDeps["onMessageUpdate"];
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { randomUUID } from "node:crypto";
8
8
  import * as BusFollower from "./bus-follower.js";
9
+ import * as Machine from "./machine.js";
9
10
  import * as Queue from "./queue.js";
10
11
  import { formatTelegramConnectionFailure } from "./status.js";
11
12
  import * as TextGroups from "./text-groups.js";
@@ -195,6 +196,31 @@ export function createTelegramSessionContextStore(options = {}) {
195
196
  },
196
197
  };
197
198
  }
199
+ /** Capture drain hints at work admission; late completion never borrows a replacement session or transport. */
200
+ export function createTelegramSessionDrainObserver(deps) {
201
+ return () => {
202
+ try {
203
+ const ctx = deps.context.get(), generation = deps.context.getGeneration(), authority = deps.captureAuthority();
204
+ if (ctx === undefined || authority === undefined)
205
+ return undefined;
206
+ return () => {
207
+ queueMicrotask(() => {
208
+ try {
209
+ if (!deps.context.isCurrent(ctx, generation) || !deps.isAuthorityActive(authority))
210
+ return;
211
+ const emitted = deps.onDrained(ctx);
212
+ if (emitted)
213
+ void Promise.resolve(emitted).catch(() => undefined);
214
+ }
215
+ catch { /* A drain hint is observational, not execution authority. */ }
216
+ });
217
+ };
218
+ }
219
+ catch {
220
+ return undefined;
221
+ }
222
+ };
223
+ }
198
224
  export function createTelegramSessionGenerationFence(store, hooks) {
199
225
  return {
200
226
  async onSessionStart(event, ctx) {
@@ -329,76 +355,165 @@ function unrefTelegramLifecycleTimer(timer) {
329
355
  if (typeof timer.unref === "function")
330
356
  timer.unref();
331
357
  }
358
+ export function createTelegramCompactionObserverState() {
359
+ return { observation: null, typingOwned: false, nextObservation: 0 };
360
+ }
361
+ /**
362
+ * Pure compaction-observer transition. A completion or failure always releases presence; the timeout releases only its
363
+ * own observation. Typing ownership is sticky: a repeated start while the observer's own loop runs reports `false`
364
+ * from the shared loop, and must not hand that loop to nobody.
365
+ */
366
+ export function transitionTelegramCompactionObserver(state, event) {
367
+ const cancel = state.observation === null ? [] : [{ type: "cancelTimer" }];
368
+ const release = (...tail) => [
369
+ { type: "setInProgress", inProgress: false },
370
+ ...(state.typingOwned ? [{ type: "stopTyping" }] : []),
371
+ { type: "updateStatus" },
372
+ ...tail,
373
+ ];
374
+ switch (event.type) {
375
+ case "beforeCompact": {
376
+ const observation = state.nextObservation + 1;
377
+ return {
378
+ state: { ...state, observation, nextObservation: observation },
379
+ effects: [
380
+ { type: "setInProgress", inProgress: true },
381
+ { type: "startTyping" },
382
+ { type: "updateStatus" },
383
+ ...cancel,
384
+ { type: "armTimer", observation },
385
+ ],
386
+ };
387
+ }
388
+ case "typingStarted":
389
+ return {
390
+ state: { ...state, typingOwned: state.typingOwned || event.started },
391
+ effects: [],
392
+ };
393
+ case "compacted":
394
+ return {
395
+ state: { ...state, observation: null, typingOwned: false },
396
+ effects: [...cancel, ...release({ type: "requestDispatch" })],
397
+ };
398
+ case "compactFailed":
399
+ return {
400
+ state: { ...state, observation: null, typingOwned: false },
401
+ effects: [
402
+ ...cancel,
403
+ ...release({ type: "abandoned" }, { type: "requestDispatch" }),
404
+ ],
405
+ };
406
+ case "timerFired":
407
+ if (state.observation !== event.observation)
408
+ return { state, effects: [] };
409
+ // A replaced context consumes its observation without releasing the replacement's presence.
410
+ if (!event.contextActive)
411
+ return { state: { ...state, observation: null }, effects: [] };
412
+ return {
413
+ state: { ...state, observation: null, typingOwned: false },
414
+ effects: release({ type: "recordTimeout" }, { type: "requestDispatch" }),
415
+ };
416
+ case "shutdown":
417
+ return {
418
+ state: { ...state, observation: null, typingOwned: false },
419
+ effects: [
420
+ ...cancel,
421
+ ...(state.typingOwned ? [{ type: "stopTyping" }] : []),
422
+ ],
423
+ };
424
+ }
425
+ }
426
+ /**
427
+ * The shell around `transitionTelegramCompactionObserver`: it fences inactive contexts, runs effects with the calling
428
+ * context, reports the typing start back and owns the fallback timer, which keeps the context it was armed under.
429
+ */
332
430
  export function createTelegramCompactionObserverRuntime(deps) {
333
431
  const timeoutMs = deps.timeoutMs ?? 300_000;
334
432
  const setTimer = deps.setTimer ?? setTimeout;
335
433
  const clearTimer = deps.clearTimer ?? clearTimeout;
336
434
  let fallbackTimer;
337
- let observationGeneration = 0;
338
- let typingStartedByObserver = false;
339
- const clearFallbackTimer = () => {
340
- observationGeneration += 1;
341
- if (!fallbackTimer)
342
- return;
343
- clearTimer(fallbackTimer);
344
- fallbackTimer = undefined;
345
- };
346
- // Stop only a typing loop this observer started; a pre-existing agent-owned loop is preserved.
347
- const releaseCompactionPresence = (ctx) => {
348
- deps.setCompactionInProgress(false);
349
- if (typingStartedByObserver)
350
- deps.stopTypingLoop?.();
351
- typingStartedByObserver = false;
352
- deps.updateStatus(ctx);
353
- };
354
- const requestDispatch = () => {
355
- deps.requestDeferredDispatchNextQueuedTelegramTurn(deps.dispatchNextQueuedTelegramTurn);
435
+ // The context of the event being dispatched; effects run synchronously inside that dispatch.
436
+ let call;
437
+ const machine = Machine.createTelegramMachineRunner({
438
+ initial: createTelegramCompactionObserverState(),
439
+ transition: transitionTelegramCompactionObserver,
440
+ run(effect) {
441
+ const ctx = call;
442
+ switch (effect.type) {
443
+ case "setInProgress":
444
+ deps.setCompactionInProgress(effect.inProgress);
445
+ return;
446
+ case "startTyping": {
447
+ const result = deps.startTypingLoop?.(ctx);
448
+ machine.dispatch({
449
+ type: "typingStarted",
450
+ started: !!deps.startTypingLoop && result !== false,
451
+ });
452
+ return;
453
+ }
454
+ case "stopTyping":
455
+ deps.stopTypingLoop?.();
456
+ return;
457
+ case "updateStatus":
458
+ deps.updateStatus(ctx);
459
+ return;
460
+ case "armTimer": {
461
+ const armedCtx = ctx;
462
+ fallbackTimer = setTimer(() => {
463
+ fallbackTimer = undefined;
464
+ dispatchFor(armedCtx, {
465
+ type: "timerFired",
466
+ observation: effect.observation,
467
+ contextActive: !deps.isContextActive || deps.isContextActive(armedCtx),
468
+ });
469
+ }, timeoutMs);
470
+ unrefTelegramLifecycleTimer(fallbackTimer);
471
+ return;
472
+ }
473
+ case "cancelTimer":
474
+ if (fallbackTimer !== undefined)
475
+ clearTimer(fallbackTimer);
476
+ fallbackTimer = undefined;
477
+ return;
478
+ case "recordTimeout":
479
+ deps.recordRuntimeEvent?.("compact", new Error("Compaction observer timed out"));
480
+ return;
481
+ case "abandoned":
482
+ deps.onCompactionAbandoned?.();
483
+ return;
484
+ case "requestDispatch":
485
+ // Observer expiry releases local presence, not Pi's eventual terminal result.
486
+ deps.requestDeferredDispatchNextQueuedTelegramTurn(deps.dispatchNextQueuedTelegramTurn);
487
+ return;
488
+ }
489
+ },
490
+ });
491
+ const dispatchFor = (ctx, event) => {
492
+ const previous = call;
493
+ call = ctx;
494
+ try {
495
+ machine.dispatch(event);
496
+ }
497
+ finally {
498
+ call = previous;
499
+ }
356
500
  };
501
+ const isActive = (ctx) => !deps.isContextActive || deps.isContextActive(ctx);
357
502
  return {
358
503
  onSessionBeforeCompact: (_event, ctx) => {
359
- if (deps.isContextActive && !deps.isContextActive(ctx))
360
- return;
361
- deps.setCompactionInProgress(true);
362
- const typingStartResult = deps.startTypingLoop?.(ctx);
363
- typingStartedByObserver =
364
- !!deps.startTypingLoop && typingStartResult !== false;
365
- deps.updateStatus(ctx);
366
- clearFallbackTimer();
367
- const admittedGeneration = observationGeneration;
368
- fallbackTimer = setTimer(() => {
369
- if (observationGeneration !== admittedGeneration)
370
- return;
371
- observationGeneration += 1;
372
- fallbackTimer = undefined;
373
- if (deps.isContextActive && !deps.isContextActive(ctx))
374
- return;
375
- releaseCompactionPresence(ctx);
376
- deps.recordRuntimeEvent?.("compact", new Error("Compaction observer timed out"));
377
- // Observer expiry releases local presence, not Pi's eventual terminal result.
378
- requestDispatch();
379
- }, timeoutMs);
380
- unrefTelegramLifecycleTimer(fallbackTimer);
504
+ if (isActive(ctx))
505
+ dispatchFor(ctx, { type: "beforeCompact" });
381
506
  },
382
507
  onSessionCompact: (_event, ctx) => {
383
- if (deps.isContextActive && !deps.isContextActive(ctx))
384
- return;
385
- clearFallbackTimer();
386
- releaseCompactionPresence(ctx);
387
- requestDispatch();
508
+ if (isActive(ctx))
509
+ dispatchFor(ctx, { type: "compacted" });
388
510
  },
389
511
  onSessionCompactFailed: (_event, ctx) => {
390
- if (deps.isContextActive && !deps.isContextActive(ctx))
391
- return;
392
- clearFallbackTimer();
393
- releaseCompactionPresence(ctx);
394
- deps.onCompactionAbandoned?.();
395
- requestDispatch();
512
+ if (isActive(ctx))
513
+ dispatchFor(ctx, { type: "compactFailed" });
396
514
  },
397
515
  onSessionShutdown: () => {
398
- clearFallbackTimer();
399
- if (typingStartedByObserver)
400
- deps.stopTypingLoop?.();
401
- typingStartedByObserver = false;
516
+ dispatchFor(undefined, { type: "shutdown" });
402
517
  },
403
518
  };
404
519
  }
@@ -409,7 +524,11 @@ export function createTelegramMessageActivityTypingHooks(deps) {
409
524
  };
410
525
  const handleMessageActivity = async (phase, event, ctx, inner) => {
411
526
  const typedCtx = ctx;
412
- ensureTyping(typedCtx);
527
+ const generation = deps.getSessionGeneration?.();
528
+ const isCurrent = () => deps.isContextActive?.(typedCtx) !== false &&
529
+ (!deps.getSessionGeneration || Object.is(deps.getSessionGeneration(), generation));
530
+ if (isCurrent())
531
+ ensureTyping(typedCtx);
413
532
  try {
414
533
  await inner(event, ctx);
415
534
  }
@@ -417,7 +536,8 @@ export function createTelegramMessageActivityTypingHooks(deps) {
417
536
  deps.recordRuntimeEvent?.("message-activity", error, { phase });
418
537
  }
419
538
  finally {
420
- ensureTyping(typedCtx);
539
+ if (isCurrent())
540
+ ensureTyping(typedCtx);
421
541
  }
422
542
  };
423
543
  return {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Pure state-machine runner
3
+ * Zones: shared utils
4
+ * Owns committing a pure transition's next state, running the effects it returns and waking state waiters
5
+ * Knows nothing about the machines it drives: their state, events, effects, timers and transports stay with each domain
6
+ */
7
+ /** One transition result: the next state plus effects as data. A machine may add fields, such as a call result. */
8
+ export interface TelegramMachineStep<State, Effect> {
9
+ state: State;
10
+ effects: readonly Effect[];
11
+ }
12
+ export interface TelegramMachineRunner<State, Event, Step> {
13
+ readonly state: State;
14
+ /** Commits the next state, runs its effects (which may dispatch again), wakes waiters and returns the step. */
15
+ dispatch(event: Event): Step;
16
+ /** Resolves once `ready` holds for the committed state, checking now and after every later dispatch. */
17
+ waitFor(ready: (state: State) => boolean): Promise<void>;
18
+ }
19
+ export declare function createTelegramMachineRunner<State, Event, Effect, Step extends TelegramMachineStep<State, Effect> = TelegramMachineStep<State, Effect>>(options: {
20
+ initial: State;
21
+ transition: (state: State, event: Event) => Step;
22
+ run: (effect: Effect) => void;
23
+ }): TelegramMachineRunner<State, Event, Step>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pure state-machine runner
3
+ * Zones: shared utils
4
+ * Owns committing a pure transition's next state, running the effects it returns and waking state waiters
5
+ * Knows nothing about the machines it drives: their state, events, effects, timers and transports stay with each domain
6
+ */
7
+ export function createTelegramMachineRunner(options) {
8
+ let state = options.initial;
9
+ const waiters = new Set();
10
+ return {
11
+ get state() {
12
+ return state;
13
+ },
14
+ dispatch(event) {
15
+ const next = options.transition(state, event);
16
+ state = next.state;
17
+ for (const effect of next.effects)
18
+ options.run(effect);
19
+ for (const waiter of [...waiters])
20
+ waiter();
21
+ return next;
22
+ },
23
+ waitFor(ready) {
24
+ return new Promise((resolve) => {
25
+ if (ready(state))
26
+ return resolve();
27
+ const waiter = () => {
28
+ if (!ready(state))
29
+ return;
30
+ waiters.delete(waiter);
31
+ resolve();
32
+ };
33
+ waiters.add(waiter);
34
+ });
35
+ },
36
+ };
37
+ }