@llblab/pi-telegram 0.54.4 → 0.55.1

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 +16 -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 +3 -1
  8. package/dist/lib/bindings.js +8 -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 +50 -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 +7 -3
  16. package/dist/lib/bus.js +24 -16
  17. package/dist/lib/delivery.d.ts +4 -0
  18. package/dist/lib/delivery.js +48 -15
  19. package/dist/lib/extension.js +48 -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 +7 -2
  34. package/dist/lib/routing.js +585 -229
  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 +18 -5
  44. package/dist/lib/threads.js +107 -30
  45. package/dist/lib/turns.d.ts +6 -0
  46. package/dist/lib/turns.js +12 -1
  47. package/dist/lib/updates.d.ts +13 -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 +14 -11
  52. package/docs/delivery.md +7 -5
  53. package/docs/multi-instance-bus.md +20 -8
  54. package/docs/public-api.md +2 -2
  55. package/docs/ui-style.md +2 -2
  56. package/lib/activity.ts +29 -5
  57. package/lib/bindings.ts +9 -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 +30 -18
  62. package/lib/delivery.ts +47 -19
  63. package/lib/extension.ts +51 -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 +614 -257
  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 +120 -36
  78. package/lib/turns.ts +15 -1
  79. package/lib/updates.ts +60 -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,
@@ -533,7 +551,10 @@ export default function (pi) {
533
551
  hasDispatchPending: lifecycle.hasDispatchPending,
534
552
  isCompactionInProgress: lifecycle.isCompactionInProgress,
535
553
  getActiveToolExecutions: lifecycle.getActiveToolExecutions,
536
- 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
+ },
537
558
  getQueuedItems: telegramQueueStore.getQueuedItems,
538
559
  getQueuedItemCount: Queue.countExecutableTelegramQueueItems,
539
560
  formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
@@ -573,6 +594,11 @@ export default function (pi) {
573
594
  // --- Telegram API ---
574
595
  const directTelegramApiRuntime = TelegramApi.createDefaultTelegramBridgeApiRuntime({
575
596
  getBotToken: configStore.getBotToken,
597
+ isThreadedMode() {
598
+ return threadStore.getBotState().threadMode === "enabled" ||
599
+ telegramBusFollowerRegistrationState.getTarget()?.threadId !== undefined ||
600
+ telegramBusLeaderState.getTarget()?.threadId !== undefined;
601
+ },
576
602
  getBotScope() {
577
603
  const config = configStore.get();
578
604
  return (config.botUsername ??
@@ -631,9 +657,13 @@ export default function (pi) {
631
657
  lifecycle,
632
658
  typing,
633
659
  getDefaultChatId: proactivePushChatIdGetter,
660
+ isThreadedMode() {
661
+ return deliveryTargetPolicyRuntime.getTargetPolicyView().threaded === true;
662
+ },
634
663
  sendTypingAction,
635
664
  updateStatus,
636
665
  isContextActive: telegramSessionContextStore.isCurrent,
666
+ getSessionGeneration: telegramSessionContextStore.getGeneration,
637
667
  getTransportAuthority() {
638
668
  if (ownsTelegramDirectDelivery()) {
639
669
  const epoch = getCurrentLeaderEpoch();
@@ -661,6 +691,11 @@ export default function (pi) {
661
691
  const { replyTransport, editInteractiveMessage, sendInteractiveMessage, sendSectionRichMessage, } = replyRuntime;
662
692
  const deliveryTargetPolicyRuntime = Delivery.createTelegramDeliveryTargetPolicyRuntime({
663
693
  ownsDirect: lockRuntime.owns,
694
+ isThreadedMode() {
695
+ return threadStore.getBotState().threadMode === "enabled" ||
696
+ telegramBusFollowerRegistrationState.getTarget()?.threadId !== undefined ||
697
+ telegramBusLeaderState.getTarget()?.threadId !== undefined;
698
+ },
664
699
  isFollowerRegistered: telegramBusFollowerRegistrationState.isRegistered,
665
700
  getAllowedChatId: configStore.getAllowedUserId,
666
701
  getFollowerTarget: telegramBusFollowerRegistrationState.getTarget,
@@ -672,6 +707,7 @@ export default function (pi) {
672
707
  const deliveryGenerationSeed = Delivery.createTelegramDeliveryGenerationSeed(telegramInstanceId);
673
708
  const deliveryLifecycleRuntime = Delivery.createTelegramBridgeDeliveryLifecycleHooks({
674
709
  generationSeed: deliveryGenerationSeed,
710
+ captureDrain: captureCleanupDrain,
675
711
  getTargetPolicyView: deliveryTargetPolicyRuntime.getTargetPolicyView,
676
712
  getTransportStamp: telegramTransportStampRuntime.getStamp,
677
713
  isTransportStampActive: telegramTransportStampRuntime.isActive,
@@ -723,6 +759,7 @@ export default function (pi) {
723
759
  });
724
760
  const { activityRuntime, activityVerbosityRuntime, assistantOutputRuntime, publicationRuntime, } = Bindings.createTelegramActivityBindingRuntime({
725
761
  generation: deliveryGenerationSeed,
762
+ captureDrain: captureCleanupDrain,
726
763
  assistantOutput: {
727
764
  prepareTelegramPreview: previewRuntime.preparePublication,
728
765
  authority: {
@@ -788,8 +825,6 @@ export default function (pi) {
788
825
  // --- Model And Menu Setup ---
789
826
  const modelSwitchController = Model.createTelegramModelSwitchControllerRuntime({
790
827
  isIdle,
791
- getPendingModelSwitch: pendingModelSwitchStore.get,
792
- setPendingModelSwitch: pendingModelSwitchStore.set,
793
828
  getActiveTurn: activeTurnRuntime.get,
794
829
  getAbortHandler: abort.getHandler,
795
830
  hasAbortHandler: abort.hasHandler,
@@ -987,7 +1022,7 @@ export default function (pi) {
987
1022
  lifecycle: bridgeRuntime.lifecycle,
988
1023
  isIdle,
989
1024
  hasPendingMessages,
990
- hasPendingControl: pendingModelSwitchStore.has,
1025
+ hasPendingControl: modelSwitchController.hasPendingSwitch,
991
1026
  publication: publicationRuntime,
992
1027
  activity: activityRuntime,
993
1028
  delivery: deliveryLifecycleRuntime,
@@ -1125,6 +1160,11 @@ export default function (pi) {
1125
1160
  return (lockRuntime.owns() &&
1126
1161
  Bus.hasTelegramBusCapability(telegramBusProtocolIdentity, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE));
1127
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
+ },
1128
1168
  hasWorkspaceLiveRebindAuthority() {
1129
1169
  return (lockRuntime.owns() &&
1130
1170
  Bus.hasTelegramBusCapability(telegramBusProtocolIdentity, Bus.TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SAVE) &&
@@ -1739,6 +1779,7 @@ export default function (pi) {
1739
1779
  followerRegistry: telegramBusFollowerRegistry,
1740
1780
  authSecret: telegramBusAuthSecret,
1741
1781
  protocolIdentity: telegramBusProtocolIdentity,
1782
+ requiresPreDispatchRebindReader() { return threadStore.hasPreDispatchRebindEvidence(); },
1742
1783
  selectedMenuDelivery,
1743
1784
  startPolling: pollingAdmissionRuntime.start,
1744
1785
  stopPolling: pollingAdmissionRuntime.stop,
@@ -2442,6 +2483,7 @@ export default function (pi) {
2442
2483
  isSessionContextActive(ctx) {
2443
2484
  return telegramSessionContextStore.isCurrent(ctx);
2444
2485
  },
2486
+ getSessionGeneration: telegramSessionContextStore.getGeneration,
2445
2487
  isTurnTransportActive(turn) {
2446
2488
  return telegramTransportStampRuntime.isActive(turn.transportStamp);
2447
2489
  },
@@ -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
+ }