@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.
- package/AGENTS.md +4 -2
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +5 -3
- package/dist/lib/activity.d.ts +5 -1
- package/dist/lib/activity.js +31 -2
- package/dist/lib/bindings.d.ts +5 -1
- package/dist/lib/bindings.js +9 -2
- package/dist/lib/bus-api.d.ts +2 -2
- package/dist/lib/bus-api.js +7 -2
- package/dist/lib/bus-follower.d.ts +49 -3
- package/dist/lib/bus-follower.js +313 -146
- package/dist/lib/bus-leader.d.ts +3 -0
- package/dist/lib/bus-leader.js +99 -18
- package/dist/lib/bus.d.ts +5 -3
- package/dist/lib/bus.js +13 -10
- package/dist/lib/delivery.d.ts +4 -0
- package/dist/lib/delivery.js +48 -15
- package/dist/lib/extension.js +56 -6
- package/dist/lib/lifecycle.d.ts +68 -0
- package/dist/lib/lifecycle.js +178 -58
- package/dist/lib/machine.d.ts +23 -0
- package/dist/lib/machine.js +37 -0
- package/dist/lib/media.d.ts +114 -28
- package/dist/lib/media.js +292 -119
- package/dist/lib/model.d.ts +54 -15
- package/dist/lib/model.js +98 -53
- package/dist/lib/polling.js +2 -0
- package/dist/lib/preview.d.ts +128 -113
- package/dist/lib/preview.js +448 -366
- package/dist/lib/queue.d.ts +2 -2
- package/dist/lib/queue.js +3 -3
- package/dist/lib/routing.d.ts +14 -2
- package/dist/lib/routing.js +546 -207
- package/dist/lib/runtime.d.ts +77 -11
- package/dist/lib/runtime.js +174 -92
- package/dist/lib/sections.js +0 -1
- package/dist/lib/telegram-api.d.ts +11 -9
- package/dist/lib/telegram-api.js +53 -4
- package/dist/lib/text-groups.d.ts +0 -26
- package/dist/lib/text-groups.js +51 -98
- package/dist/lib/thread-display.js +14 -6
- package/dist/lib/threads.d.ts +20 -5
- package/dist/lib/threads.js +111 -32
- package/dist/lib/turns.d.ts +2 -0
- package/dist/lib/turns.js +6 -1
- package/dist/lib/updates.d.ts +11 -4
- package/dist/lib/updates.js +54 -19
- package/dist/package.json +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +16 -15
- package/docs/delivery.md +7 -5
- package/docs/multi-instance-bus.md +18 -8
- package/docs/public-api.md +2 -2
- package/docs/ui-style.md +8 -6
- package/lib/activity.ts +29 -5
- package/lib/bindings.ts +13 -1
- package/lib/bus-api.ts +11 -3
- package/lib/bus-follower.ts +417 -203
- package/lib/bus-leader.ts +88 -14
- package/lib/bus.ts +17 -15
- package/lib/delivery.ts +47 -19
- package/lib/extension.ts +59 -9
- package/lib/lifecycle.ts +229 -55
- package/lib/machine.ts +60 -0
- package/lib/media.ts +438 -172
- package/lib/model.ts +169 -74
- package/lib/polling.ts +2 -0
- package/lib/preview.ts +715 -566
- package/lib/queue.ts +5 -5
- package/lib/routing.ts +644 -283
- package/lib/runtime.ts +260 -104
- package/lib/sections.ts +0 -2
- package/lib/telegram-api.ts +61 -8
- package/lib/text-groups.ts +75 -136
- package/lib/thread-display.ts +15 -4
- package/lib/threads.ts +130 -38
- package/lib/turns.ts +8 -1
- package/lib/updates.ts +58 -21
- package/package.json +1 -1
package/lib/extension.ts
CHANGED
|
@@ -70,6 +70,8 @@ const telegramBusProtocolIdentity =
|
|
|
70
70
|
Bus.TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
|
|
71
71
|
Bus.TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
|
|
72
72
|
Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE,
|
|
73
|
+
Bus.TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND,
|
|
74
|
+
Bus.TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION,
|
|
73
75
|
Bus.TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SAVE,
|
|
74
76
|
Bus.TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY,
|
|
75
77
|
Bus.TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE,
|
|
@@ -389,10 +391,6 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
389
391
|
buttonActionStore,
|
|
390
392
|
configControls.getAssistantRenderingMode,
|
|
391
393
|
);
|
|
392
|
-
const pendingModelSwitchStore =
|
|
393
|
-
Model.createPendingModelSwitchStore<
|
|
394
|
-
Model.ScopedTelegramModel<ActivePiModel>
|
|
395
|
-
>();
|
|
396
394
|
const modelMenuRuntime = Menu.createTelegramModelMenuRuntime<ActivePiModel>();
|
|
397
395
|
const sectionRegistry = Sections.createAndBindTelegramSectionRegistry();
|
|
398
396
|
|
|
@@ -424,8 +422,25 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
424
422
|
rawTelegramQueueStore,
|
|
425
423
|
telegramTransportStampRuntime.getStamp,
|
|
426
424
|
);
|
|
425
|
+
const captureCleanupDrain = Lifecycle.createTelegramSessionDrainObserver({
|
|
426
|
+
context: telegramSessionContextStore,
|
|
427
|
+
captureAuthority() {
|
|
428
|
+
const epoch = getCurrentLeaderEpoch();
|
|
429
|
+
return epoch === undefined ? undefined : {
|
|
430
|
+
epoch, operator: configStore.getAllowedUserId(), stamp: telegramTransportStampRuntime.getStamp(),
|
|
431
|
+
};
|
|
432
|
+
},
|
|
433
|
+
isAuthorityActive(authority) {
|
|
434
|
+
return getCurrentLeaderEpoch() === authority.epoch &&
|
|
435
|
+
configStore.getAllowedUserId() === authority.operator &&
|
|
436
|
+
telegramTransportStampRuntime.isActive(authority.stamp);
|
|
437
|
+
},
|
|
438
|
+
onDrained(ctx) { inboundRouteRuntime.onDeliverySettled(ctx); },
|
|
439
|
+
});
|
|
427
440
|
const telegramApiTargetActivityRuntime =
|
|
428
|
-
TelegramApi.createTelegramApiTargetActivityRuntime(
|
|
441
|
+
TelegramApi.createTelegramApiTargetActivityRuntime({
|
|
442
|
+
captureDrain: captureCleanupDrain,
|
|
443
|
+
});
|
|
429
444
|
// Retirement protection and dead-owner reclamation must inspect the same journal sources.
|
|
430
445
|
const workspaceRetirementJournalPorts = {
|
|
431
446
|
getActiveTurnTarget: activeTurnRuntime.getTarget,
|
|
@@ -632,6 +647,13 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
632
647
|
instanceId: telegramInstanceId,
|
|
633
648
|
listRecords: threadStore.list,
|
|
634
649
|
listWorkspaceBindings: threadStore.listWorkspaceBindings,
|
|
650
|
+
resolveAutomaticDisplayTitle(binding) {
|
|
651
|
+
return ThreadDisplay.resolveTelegramInitialWorkspaceDisplayName({
|
|
652
|
+
bindings: threadStore.listWorkspaceBindings(),
|
|
653
|
+
binding,
|
|
654
|
+
mode: Config.resolveTelegramThreadDisplayMode(configStore.get()),
|
|
655
|
+
});
|
|
656
|
+
},
|
|
635
657
|
getFollowerDisplayTitle:
|
|
636
658
|
telegramBusFollowerRegistrationState.getDisplayTitle,
|
|
637
659
|
getActiveTurnTarget: activeTurnRuntime.getTarget,
|
|
@@ -689,7 +711,10 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
689
711
|
hasDispatchPending: lifecycle.hasDispatchPending,
|
|
690
712
|
isCompactionInProgress: lifecycle.isCompactionInProgress,
|
|
691
713
|
getActiveToolExecutions: lifecycle.getActiveToolExecutions,
|
|
692
|
-
|
|
714
|
+
// Status is composed before the model-switch controller; it reads the controller's state when it renders.
|
|
715
|
+
hasPendingModelSwitch() {
|
|
716
|
+
return modelSwitchController.hasPendingSwitch();
|
|
717
|
+
},
|
|
693
718
|
getQueuedItems: telegramQueueStore.getQueuedItems,
|
|
694
719
|
getQueuedItemCount: Queue.countExecutableTelegramQueueItems,
|
|
695
720
|
formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
|
|
@@ -732,6 +757,11 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
732
757
|
const directTelegramApiRuntime =
|
|
733
758
|
TelegramApi.createDefaultTelegramBridgeApiRuntime({
|
|
734
759
|
getBotToken: configStore.getBotToken,
|
|
760
|
+
isThreadedMode() {
|
|
761
|
+
return threadStore.getBotState().threadMode === "enabled" ||
|
|
762
|
+
telegramBusFollowerRegistrationState.getTarget()?.threadId !== undefined ||
|
|
763
|
+
telegramBusLeaderState.getTarget()?.threadId !== undefined;
|
|
764
|
+
},
|
|
735
765
|
getBotScope() {
|
|
736
766
|
const config = configStore.get();
|
|
737
767
|
return (
|
|
@@ -830,9 +860,13 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
830
860
|
lifecycle,
|
|
831
861
|
typing,
|
|
832
862
|
getDefaultChatId: proactivePushChatIdGetter,
|
|
863
|
+
isThreadedMode() {
|
|
864
|
+
return deliveryTargetPolicyRuntime.getTargetPolicyView().threaded === true;
|
|
865
|
+
},
|
|
833
866
|
sendTypingAction,
|
|
834
867
|
updateStatus,
|
|
835
868
|
isContextActive: telegramSessionContextStore.isCurrent,
|
|
869
|
+
getSessionGeneration: telegramSessionContextStore.getGeneration,
|
|
836
870
|
getTransportAuthority() {
|
|
837
871
|
if (ownsTelegramDirectDelivery()) {
|
|
838
872
|
const epoch = getCurrentLeaderEpoch();
|
|
@@ -868,6 +902,11 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
868
902
|
const deliveryTargetPolicyRuntime =
|
|
869
903
|
Delivery.createTelegramDeliveryTargetPolicyRuntime({
|
|
870
904
|
ownsDirect: lockRuntime.owns,
|
|
905
|
+
isThreadedMode() {
|
|
906
|
+
return threadStore.getBotState().threadMode === "enabled" ||
|
|
907
|
+
telegramBusFollowerRegistrationState.getTarget()?.threadId !== undefined ||
|
|
908
|
+
telegramBusLeaderState.getTarget()?.threadId !== undefined;
|
|
909
|
+
},
|
|
871
910
|
isFollowerRegistered: telegramBusFollowerRegistrationState.isRegistered,
|
|
872
911
|
getAllowedChatId: configStore.getAllowedUserId,
|
|
873
912
|
getFollowerTarget: telegramBusFollowerRegistrationState.getTarget,
|
|
@@ -881,6 +920,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
881
920
|
const deliveryLifecycleRuntime =
|
|
882
921
|
Delivery.createTelegramBridgeDeliveryLifecycleHooks({
|
|
883
922
|
generationSeed: deliveryGenerationSeed,
|
|
923
|
+
captureDrain: captureCleanupDrain,
|
|
884
924
|
getTargetPolicyView: deliveryTargetPolicyRuntime.getTargetPolicyView,
|
|
885
925
|
getTransportStamp: telegramTransportStampRuntime.getStamp,
|
|
886
926
|
isTransportStampActive: telegramTransportStampRuntime.isActive,
|
|
@@ -941,6 +981,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
941
981
|
publicationRuntime,
|
|
942
982
|
} = Bindings.createTelegramActivityBindingRuntime({
|
|
943
983
|
generation: deliveryGenerationSeed,
|
|
984
|
+
captureDrain: captureCleanupDrain,
|
|
944
985
|
assistantOutput: {
|
|
945
986
|
prepareTelegramPreview: previewRuntime.preparePublication,
|
|
946
987
|
authority: {
|
|
@@ -1022,8 +1063,6 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1022
1063
|
const modelSwitchController =
|
|
1023
1064
|
Model.createTelegramModelSwitchControllerRuntime({
|
|
1024
1065
|
isIdle,
|
|
1025
|
-
getPendingModelSwitch: pendingModelSwitchStore.get,
|
|
1026
|
-
setPendingModelSwitch: pendingModelSwitchStore.set,
|
|
1027
1066
|
getActiveTurn: activeTurnRuntime.get,
|
|
1028
1067
|
getAbortHandler: abort.getHandler,
|
|
1029
1068
|
hasAbortHandler: abort.hasHandler,
|
|
@@ -1251,7 +1290,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1251
1290
|
lifecycle: bridgeRuntime.lifecycle,
|
|
1252
1291
|
isIdle,
|
|
1253
1292
|
hasPendingMessages,
|
|
1254
|
-
hasPendingControl:
|
|
1293
|
+
hasPendingControl: modelSwitchController.hasPendingSwitch,
|
|
1255
1294
|
publication: publicationRuntime,
|
|
1256
1295
|
activity: activityRuntime,
|
|
1257
1296
|
delivery: deliveryLifecycleRuntime,
|
|
@@ -1429,6 +1468,14 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1429
1468
|
)
|
|
1430
1469
|
);
|
|
1431
1470
|
},
|
|
1471
|
+
canUsePreDispatchRebind(recipient) {
|
|
1472
|
+
return lockRuntime.owns() && telegramBusFollowerRegistry.list().every(function (follower) {
|
|
1473
|
+
return Bus.hasTelegramBusCapability(follower.protocol, Bus.TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND);
|
|
1474
|
+
}) && (recipient?.kind !== "follower" || Bus.hasTelegramBusCapability(
|
|
1475
|
+
telegramBusFollowerRegistry.get(recipient.instanceId)?.protocol,
|
|
1476
|
+
Bus.TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION,
|
|
1477
|
+
));
|
|
1478
|
+
},
|
|
1432
1479
|
hasWorkspaceLiveRebindAuthority() {
|
|
1433
1480
|
return (
|
|
1434
1481
|
lockRuntime.owns() &&
|
|
@@ -2186,6 +2233,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
2186
2233
|
followerRegistry: telegramBusFollowerRegistry,
|
|
2187
2234
|
authSecret: telegramBusAuthSecret,
|
|
2188
2235
|
protocolIdentity: telegramBusProtocolIdentity,
|
|
2236
|
+
requiresPreDispatchRebindReader() { return threadStore.hasPreDispatchRebindEvidence(); },
|
|
2189
2237
|
selectedMenuDelivery,
|
|
2190
2238
|
startPolling: pollingAdmissionRuntime.start,
|
|
2191
2239
|
stopPolling: pollingAdmissionRuntime.stop,
|
|
@@ -3022,10 +3070,12 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
3022
3070
|
isSessionContextActive(ctx) {
|
|
3023
3071
|
return telegramSessionContextStore.isCurrent(ctx);
|
|
3024
3072
|
},
|
|
3073
|
+
getSessionGeneration: telegramSessionContextStore.getGeneration,
|
|
3025
3074
|
isTurnTransportActive(turn) {
|
|
3026
3075
|
return telegramTransportStampRuntime.isActive(turn.transportStamp);
|
|
3027
3076
|
},
|
|
3028
3077
|
updateStatus,
|
|
3029
3078
|
recordRuntimeEvent,
|
|
3079
|
+
onSessionSettled: inboundRouteRuntime.onSessionSettled,
|
|
3030
3080
|
});
|
|
3031
3081
|
}
|
package/lib/lifecycle.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
UIPromptEndEvent,
|
|
30
30
|
UIPromptStartEvent,
|
|
31
31
|
} from "./pi.ts";
|
|
32
|
+
import * as Machine from "./machine.ts";
|
|
32
33
|
import * as Queue from "./queue.ts";
|
|
33
34
|
import { formatTelegramConnectionFailure } from "./status.ts";
|
|
34
35
|
import * as TextGroups from "./text-groups.ts";
|
|
@@ -411,6 +412,30 @@ export function createTelegramSessionContextStore<TContext>(
|
|
|
411
412
|
};
|
|
412
413
|
}
|
|
413
414
|
|
|
415
|
+
/** Capture drain hints at work admission; late completion never borrows a replacement session or transport. */
|
|
416
|
+
export function createTelegramSessionDrainObserver<TContext, TAuthority>(deps: {
|
|
417
|
+
context: Pick<TelegramSessionContextStore<TContext>, "get" | "getGeneration" | "isCurrent">;
|
|
418
|
+
captureAuthority: () => TAuthority | undefined;
|
|
419
|
+
isAuthorityActive: (authority: TAuthority) => boolean;
|
|
420
|
+
onDrained: (ctx: TContext) => void;
|
|
421
|
+
}): () => (() => void) | undefined {
|
|
422
|
+
return () => {
|
|
423
|
+
try {
|
|
424
|
+
const ctx = deps.context.get(), generation = deps.context.getGeneration(), authority = deps.captureAuthority();
|
|
425
|
+
if (ctx === undefined || authority === undefined) return undefined;
|
|
426
|
+
return () => {
|
|
427
|
+
queueMicrotask(() => {
|
|
428
|
+
try {
|
|
429
|
+
if (!deps.context.isCurrent(ctx, generation) || !deps.isAuthorityActive(authority)) return;
|
|
430
|
+
const emitted: unknown = deps.onDrained(ctx);
|
|
431
|
+
if (emitted) void Promise.resolve(emitted).catch(() => undefined);
|
|
432
|
+
} catch { /* A drain hint is observational, not execution authority. */ }
|
|
433
|
+
});
|
|
434
|
+
};
|
|
435
|
+
} catch { return undefined; }
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
414
439
|
export function createTelegramSessionGenerationFence(
|
|
415
440
|
store: TelegramSessionContextStore<ExtensionContext>,
|
|
416
441
|
hooks: TelegramSessionLifecycleHooks,
|
|
@@ -670,6 +695,121 @@ export interface TelegramCompactionObserverRuntime<TContext> {
|
|
|
670
695
|
onSessionShutdown: () => void;
|
|
671
696
|
}
|
|
672
697
|
|
|
698
|
+
/** The whole compaction-observer state; only `transitionTelegramCompactionObserver` produces a new one. */
|
|
699
|
+
export interface TelegramCompactionObserverState {
|
|
700
|
+
/** The fallback timer's observation; a timer from any other one is inert. */
|
|
701
|
+
readonly observation: number | null;
|
|
702
|
+
/** The typing loop is the observer's to stop; a loop the agent already ran is never stopped by compaction. */
|
|
703
|
+
readonly typingOwned: boolean;
|
|
704
|
+
readonly nextObservation: number;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export type TelegramCompactionObserverEvent =
|
|
708
|
+
| { type: "beforeCompact" }
|
|
709
|
+
/** The outcome of the observer's own typing start, reported back by the shell. */
|
|
710
|
+
| { type: "typingStarted"; started: boolean }
|
|
711
|
+
| { type: "compacted" }
|
|
712
|
+
| { type: "compactFailed" }
|
|
713
|
+
| { type: "timerFired"; observation: number; contextActive: boolean }
|
|
714
|
+
| { type: "shutdown" };
|
|
715
|
+
|
|
716
|
+
export type TelegramCompactionObserverEffect =
|
|
717
|
+
| { type: "setInProgress"; inProgress: boolean }
|
|
718
|
+
| { type: "startTyping" }
|
|
719
|
+
| { type: "stopTyping" }
|
|
720
|
+
| { type: "updateStatus" }
|
|
721
|
+
| { type: "armTimer"; observation: number }
|
|
722
|
+
| { type: "cancelTimer" }
|
|
723
|
+
| { type: "recordTimeout" }
|
|
724
|
+
| { type: "abandoned" }
|
|
725
|
+
| { type: "requestDispatch" };
|
|
726
|
+
|
|
727
|
+
export function createTelegramCompactionObserverState(): TelegramCompactionObserverState {
|
|
728
|
+
return { observation: null, typingOwned: false, nextObservation: 0 };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Pure compaction-observer transition. A completion or failure always releases presence; the timeout releases only its
|
|
733
|
+
* own observation. Typing ownership is sticky: a repeated start while the observer's own loop runs reports `false`
|
|
734
|
+
* from the shared loop, and must not hand that loop to nobody.
|
|
735
|
+
*/
|
|
736
|
+
export function transitionTelegramCompactionObserver(
|
|
737
|
+
state: TelegramCompactionObserverState,
|
|
738
|
+
event: TelegramCompactionObserverEvent,
|
|
739
|
+
): Machine.TelegramMachineStep<
|
|
740
|
+
TelegramCompactionObserverState,
|
|
741
|
+
TelegramCompactionObserverEffect
|
|
742
|
+
> {
|
|
743
|
+
const cancel: TelegramCompactionObserverEffect[] =
|
|
744
|
+
state.observation === null ? [] : [{ type: "cancelTimer" }];
|
|
745
|
+
const release = (
|
|
746
|
+
...tail: TelegramCompactionObserverEffect[]
|
|
747
|
+
): TelegramCompactionObserverEffect[] => [
|
|
748
|
+
{ type: "setInProgress", inProgress: false },
|
|
749
|
+
...(state.typingOwned ? [{ type: "stopTyping" } as const] : []),
|
|
750
|
+
{ type: "updateStatus" },
|
|
751
|
+
...tail,
|
|
752
|
+
];
|
|
753
|
+
switch (event.type) {
|
|
754
|
+
case "beforeCompact": {
|
|
755
|
+
const observation = state.nextObservation + 1;
|
|
756
|
+
return {
|
|
757
|
+
state: { ...state, observation, nextObservation: observation },
|
|
758
|
+
effects: [
|
|
759
|
+
{ type: "setInProgress", inProgress: true },
|
|
760
|
+
{ type: "startTyping" },
|
|
761
|
+
{ type: "updateStatus" },
|
|
762
|
+
...cancel,
|
|
763
|
+
{ type: "armTimer", observation },
|
|
764
|
+
],
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
case "typingStarted":
|
|
768
|
+
return {
|
|
769
|
+
state: { ...state, typingOwned: state.typingOwned || event.started },
|
|
770
|
+
effects: [],
|
|
771
|
+
};
|
|
772
|
+
case "compacted":
|
|
773
|
+
return {
|
|
774
|
+
state: { ...state, observation: null, typingOwned: false },
|
|
775
|
+
effects: [...cancel, ...release({ type: "requestDispatch" })],
|
|
776
|
+
};
|
|
777
|
+
case "compactFailed":
|
|
778
|
+
return {
|
|
779
|
+
state: { ...state, observation: null, typingOwned: false },
|
|
780
|
+
effects: [
|
|
781
|
+
...cancel,
|
|
782
|
+
...release({ type: "abandoned" }, { type: "requestDispatch" }),
|
|
783
|
+
],
|
|
784
|
+
};
|
|
785
|
+
case "timerFired":
|
|
786
|
+
if (state.observation !== event.observation)
|
|
787
|
+
return { state, effects: [] };
|
|
788
|
+
// A replaced context consumes its observation without releasing the replacement's presence.
|
|
789
|
+
if (!event.contextActive)
|
|
790
|
+
return { state: { ...state, observation: null }, effects: [] };
|
|
791
|
+
return {
|
|
792
|
+
state: { ...state, observation: null, typingOwned: false },
|
|
793
|
+
effects: release(
|
|
794
|
+
{ type: "recordTimeout" },
|
|
795
|
+
{ type: "requestDispatch" },
|
|
796
|
+
),
|
|
797
|
+
};
|
|
798
|
+
case "shutdown":
|
|
799
|
+
return {
|
|
800
|
+
state: { ...state, observation: null, typingOwned: false },
|
|
801
|
+
effects: [
|
|
802
|
+
...cancel,
|
|
803
|
+
...(state.typingOwned ? [{ type: "stopTyping" } as const] : []),
|
|
804
|
+
],
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* The shell around `transitionTelegramCompactionObserver`: it fences inactive contexts, runs effects with the calling
|
|
811
|
+
* context, reports the typing start back and owns the fallback timer, which keeps the context it was armed under.
|
|
812
|
+
*/
|
|
673
813
|
export function createTelegramCompactionObserverRuntime<TContext>(
|
|
674
814
|
deps: TelegramCompactionObserverRuntimeDeps<TContext>,
|
|
675
815
|
): TelegramCompactionObserverRuntime<TContext> {
|
|
@@ -677,74 +817,105 @@ export function createTelegramCompactionObserverRuntime<TContext>(
|
|
|
677
817
|
const setTimer = deps.setTimer ?? setTimeout;
|
|
678
818
|
const clearTimer = deps.clearTimer ?? clearTimeout;
|
|
679
819
|
let fallbackTimer: TelegramLifecycleTimer | undefined;
|
|
680
|
-
|
|
681
|
-
let
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
820
|
+
// The context of the event being dispatched; effects run synchronously inside that dispatch.
|
|
821
|
+
let call: TContext | undefined;
|
|
822
|
+
const machine = Machine.createTelegramMachineRunner<
|
|
823
|
+
TelegramCompactionObserverState,
|
|
824
|
+
TelegramCompactionObserverEvent,
|
|
825
|
+
TelegramCompactionObserverEffect
|
|
826
|
+
>({
|
|
827
|
+
initial: createTelegramCompactionObserverState(),
|
|
828
|
+
transition: transitionTelegramCompactionObserver,
|
|
829
|
+
run(effect) {
|
|
830
|
+
const ctx = call as TContext;
|
|
831
|
+
switch (effect.type) {
|
|
832
|
+
case "setInProgress":
|
|
833
|
+
deps.setCompactionInProgress(effect.inProgress);
|
|
834
|
+
return;
|
|
835
|
+
case "startTyping": {
|
|
836
|
+
const result = deps.startTypingLoop?.(ctx);
|
|
837
|
+
machine.dispatch({
|
|
838
|
+
type: "typingStarted",
|
|
839
|
+
started: !!deps.startTypingLoop && result !== false,
|
|
840
|
+
});
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
case "stopTyping":
|
|
844
|
+
deps.stopTypingLoop?.();
|
|
845
|
+
return;
|
|
846
|
+
case "updateStatus":
|
|
847
|
+
deps.updateStatus(ctx);
|
|
848
|
+
return;
|
|
849
|
+
case "armTimer": {
|
|
850
|
+
const armedCtx = ctx;
|
|
851
|
+
fallbackTimer = setTimer(() => {
|
|
852
|
+
fallbackTimer = undefined;
|
|
853
|
+
dispatchFor(armedCtx, {
|
|
854
|
+
type: "timerFired",
|
|
855
|
+
observation: effect.observation,
|
|
856
|
+
contextActive:
|
|
857
|
+
!deps.isContextActive || deps.isContextActive(armedCtx),
|
|
858
|
+
});
|
|
859
|
+
}, timeoutMs);
|
|
860
|
+
unrefTelegramLifecycleTimer(fallbackTimer);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
case "cancelTimer":
|
|
864
|
+
if (fallbackTimer !== undefined) clearTimer(fallbackTimer);
|
|
865
|
+
fallbackTimer = undefined;
|
|
866
|
+
return;
|
|
867
|
+
case "recordTimeout":
|
|
868
|
+
deps.recordRuntimeEvent?.(
|
|
869
|
+
"compact",
|
|
870
|
+
new Error("Compaction observer timed out"),
|
|
871
|
+
);
|
|
872
|
+
return;
|
|
873
|
+
case "abandoned":
|
|
874
|
+
deps.onCompactionAbandoned?.();
|
|
875
|
+
return;
|
|
876
|
+
case "requestDispatch":
|
|
877
|
+
// Observer expiry releases local presence, not Pi's eventual terminal result.
|
|
878
|
+
deps.requestDeferredDispatchNextQueuedTelegramTurn(
|
|
879
|
+
deps.dispatchNextQueuedTelegramTurn,
|
|
880
|
+
);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
},
|
|
884
|
+
});
|
|
885
|
+
const dispatchFor = (
|
|
886
|
+
ctx: TContext | undefined,
|
|
887
|
+
event: TelegramCompactionObserverEvent,
|
|
888
|
+
): void => {
|
|
889
|
+
const previous = call;
|
|
890
|
+
call = ctx;
|
|
891
|
+
try {
|
|
892
|
+
machine.dispatch(event);
|
|
893
|
+
} finally {
|
|
894
|
+
call = previous;
|
|
895
|
+
}
|
|
699
896
|
};
|
|
897
|
+
const isActive = (ctx: TContext): boolean =>
|
|
898
|
+
!deps.isContextActive || deps.isContextActive(ctx);
|
|
700
899
|
return {
|
|
701
900
|
onSessionBeforeCompact: (_event, ctx) => {
|
|
702
|
-
if (
|
|
703
|
-
deps.setCompactionInProgress(true);
|
|
704
|
-
const typingStartResult = deps.startTypingLoop?.(ctx);
|
|
705
|
-
typingStartedByObserver =
|
|
706
|
-
!!deps.startTypingLoop && typingStartResult !== false;
|
|
707
|
-
deps.updateStatus(ctx);
|
|
708
|
-
clearFallbackTimer();
|
|
709
|
-
const admittedGeneration = observationGeneration;
|
|
710
|
-
fallbackTimer = setTimer(() => {
|
|
711
|
-
if (observationGeneration !== admittedGeneration) return;
|
|
712
|
-
observationGeneration += 1;
|
|
713
|
-
fallbackTimer = undefined;
|
|
714
|
-
if (deps.isContextActive && !deps.isContextActive(ctx)) return;
|
|
715
|
-
releaseCompactionPresence(ctx);
|
|
716
|
-
deps.recordRuntimeEvent?.(
|
|
717
|
-
"compact",
|
|
718
|
-
new Error("Compaction observer timed out"),
|
|
719
|
-
);
|
|
720
|
-
// Observer expiry releases local presence, not Pi's eventual terminal result.
|
|
721
|
-
requestDispatch();
|
|
722
|
-
}, timeoutMs);
|
|
723
|
-
unrefTelegramLifecycleTimer(fallbackTimer);
|
|
901
|
+
if (isActive(ctx)) dispatchFor(ctx, { type: "beforeCompact" });
|
|
724
902
|
},
|
|
725
903
|
onSessionCompact: (_event, ctx) => {
|
|
726
|
-
if (
|
|
727
|
-
clearFallbackTimer();
|
|
728
|
-
releaseCompactionPresence(ctx);
|
|
729
|
-
requestDispatch();
|
|
904
|
+
if (isActive(ctx)) dispatchFor(ctx, { type: "compacted" });
|
|
730
905
|
},
|
|
731
906
|
onSessionCompactFailed: (_event, ctx) => {
|
|
732
|
-
if (
|
|
733
|
-
clearFallbackTimer();
|
|
734
|
-
releaseCompactionPresence(ctx);
|
|
735
|
-
deps.onCompactionAbandoned?.();
|
|
736
|
-
requestDispatch();
|
|
907
|
+
if (isActive(ctx)) dispatchFor(ctx, { type: "compactFailed" });
|
|
737
908
|
},
|
|
738
909
|
onSessionShutdown: () => {
|
|
739
|
-
|
|
740
|
-
if (typingStartedByObserver) deps.stopTypingLoop?.();
|
|
741
|
-
typingStartedByObserver = false;
|
|
910
|
+
dispatchFor(undefined, { type: "shutdown" });
|
|
742
911
|
},
|
|
743
912
|
};
|
|
744
913
|
}
|
|
745
914
|
|
|
746
915
|
export interface TelegramMessageActivityTypingDeps<TContext> {
|
|
747
916
|
hasActiveTurn: () => boolean;
|
|
917
|
+
isContextActive?: (ctx: TContext) => boolean;
|
|
918
|
+
getSessionGeneration?: () => number;
|
|
748
919
|
startTypingLoop: (ctx: TContext) => void;
|
|
749
920
|
onMessageStart: TelegramLifecycleRegistrationDeps["onMessageStart"];
|
|
750
921
|
onMessageUpdate: TelegramLifecycleRegistrationDeps["onMessageUpdate"];
|
|
@@ -773,13 +944,16 @@ export function createTelegramMessageActivityTypingHooks<
|
|
|
773
944
|
inner: (event: TEvent, ctx: ExtensionContext) => Promise<void>,
|
|
774
945
|
): Promise<void> => {
|
|
775
946
|
const typedCtx = ctx as TContext;
|
|
776
|
-
|
|
947
|
+
const generation = deps.getSessionGeneration?.();
|
|
948
|
+
const isCurrent = () => deps.isContextActive?.(typedCtx) !== false &&
|
|
949
|
+
(!deps.getSessionGeneration || Object.is(deps.getSessionGeneration(), generation));
|
|
950
|
+
if (isCurrent()) ensureTyping(typedCtx);
|
|
777
951
|
try {
|
|
778
952
|
await inner(event, ctx);
|
|
779
953
|
} catch (error) {
|
|
780
954
|
deps.recordRuntimeEvent?.("message-activity", error, { phase });
|
|
781
955
|
} finally {
|
|
782
|
-
ensureTyping(typedCtx);
|
|
956
|
+
if (isCurrent()) ensureTyping(typedCtx);
|
|
783
957
|
}
|
|
784
958
|
};
|
|
785
959
|
return {
|
package/lib/machine.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
|
|
8
|
+
/** One transition result: the next state plus effects as data. A machine may add fields, such as a call result. */
|
|
9
|
+
export interface TelegramMachineStep<State, Effect> {
|
|
10
|
+
state: State;
|
|
11
|
+
effects: readonly Effect[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TelegramMachineRunner<State, Event, Step> {
|
|
15
|
+
readonly state: State;
|
|
16
|
+
/** Commits the next state, runs its effects (which may dispatch again), wakes waiters and returns the step. */
|
|
17
|
+
dispatch(event: Event): Step;
|
|
18
|
+
/** Resolves once `ready` holds for the committed state, checking now and after every later dispatch. */
|
|
19
|
+
waitFor(ready: (state: State) => boolean): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createTelegramMachineRunner<
|
|
23
|
+
State,
|
|
24
|
+
Event,
|
|
25
|
+
Effect,
|
|
26
|
+
Step extends TelegramMachineStep<State, Effect> = TelegramMachineStep<
|
|
27
|
+
State,
|
|
28
|
+
Effect
|
|
29
|
+
>,
|
|
30
|
+
>(options: {
|
|
31
|
+
initial: State;
|
|
32
|
+
transition: (state: State, event: Event) => Step;
|
|
33
|
+
run: (effect: Effect) => void;
|
|
34
|
+
}): TelegramMachineRunner<State, Event, Step> {
|
|
35
|
+
let state = options.initial;
|
|
36
|
+
const waiters = new Set<() => void>();
|
|
37
|
+
return {
|
|
38
|
+
get state(): State {
|
|
39
|
+
return state;
|
|
40
|
+
},
|
|
41
|
+
dispatch(event: Event): Step {
|
|
42
|
+
const next = options.transition(state, event);
|
|
43
|
+
state = next.state;
|
|
44
|
+
for (const effect of next.effects) options.run(effect);
|
|
45
|
+
for (const waiter of [...waiters]) waiter();
|
|
46
|
+
return next;
|
|
47
|
+
},
|
|
48
|
+
waitFor(ready: (state: State) => boolean): Promise<void> {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
if (ready(state)) return resolve();
|
|
51
|
+
const waiter = (): void => {
|
|
52
|
+
if (!ready(state)) return;
|
|
53
|
+
waiters.delete(waiter);
|
|
54
|
+
resolve();
|
|
55
|
+
};
|
|
56
|
+
waiters.add(waiter);
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|