@llblab/pi-kit 0.1.8 → 0.1.10
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/CHANGELOG.md +8 -0
- package/README.md +2 -2
- package/node_modules/@llblab/pi-grow-loop/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +6 -0
- package/node_modules/@llblab/pi-grow-loop/README.md +2 -1
- package/node_modules/@llblab/pi-grow-loop/index.ts +11 -2
- package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +4 -4
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +34 -0
- package/node_modules/@llblab/pi-telegram/README.md +3 -3
- package/node_modules/@llblab/pi-telegram/api/voice.ts +0 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -5
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +2 -3
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +4 -13
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +22 -8
- package/node_modules/@llblab/pi-telegram/docs/voice.md +9 -37
- package/node_modules/@llblab/pi-telegram/index.ts +20 -7
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +49 -13
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +127 -25
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +17 -4
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +56 -2
- package/node_modules/@llblab/pi-telegram/lib/command-templates.ts +65 -4
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +122 -28
- package/node_modules/@llblab/pi-telegram/lib/config.ts +10 -7
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +2 -9
- package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +20 -18
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +6 -1
- package/node_modules/@llblab/pi-telegram/lib/menu-queue.ts +15 -4
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +15 -11
- package/node_modules/@llblab/pi-telegram/lib/menu.ts +10 -7
- package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +3 -18
- package/node_modules/@llblab/pi-telegram/lib/prompts.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +49 -12
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +5 -2
- package/node_modules/@llblab/pi-telegram/lib/status.ts +4 -2
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +17 -0
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +91 -45
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +5 -0
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +17 -9
- package/node_modules/@llblab/pi-telegram/lib/voice.ts +7 -31
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +1 -1
- package/package.json +3 -3
|
@@ -43,12 +43,12 @@ Voice provider extensions can also register STT backends with `registerTelegramV
|
|
|
43
43
|
|
|
44
44
|
## Voice Reply Policy
|
|
45
45
|
|
|
46
|
-
The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`). Missing
|
|
46
|
+
The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`). Missing and invalid values resolve to the `manual` default; the former `hidden` value remains a read-only compatibility alias for `manual`.
|
|
47
47
|
|
|
48
48
|
### Modes
|
|
49
49
|
|
|
50
|
-
- **`
|
|
51
|
-
- **`mirror`:** voice/audio input activates automatic voice delivery. Text input follows `
|
|
50
|
+
- **`manual` (default):** no `voice.replyMode` is stored and no automatic voice context is added; explicit agent-authored `telegram_voice` actions still work.
|
|
51
|
+
- **`mirror`:** voice/audio input activates automatic voice delivery. Text input follows `manual` behavior.
|
|
52
52
|
- **`always`:** every Telegram turn activates automatic voice delivery.
|
|
53
53
|
|
|
54
54
|
**Warning:** In `always` mode, the bridge transparently intercepts ALL text replies and converts them to voice on success. Users will only receive voice messages when voice generation succeeds. If voice generation fails, the bridge falls back to sending the planned text reply.
|
|
@@ -70,7 +70,7 @@ A voice extension may combine three public seams:
|
|
|
70
70
|
|
|
71
71
|
- `registerTelegramVoiceTranscriptionProvider()` for inbound STT fallback on voice/audio files
|
|
72
72
|
- `registerTelegramVoiceSynthesisProvider()` for outbound TTS/synthesis fallback to Telegram voice messages
|
|
73
|
-
- `registerTelegramSection()` for provider-specific Telegram UI such as voice, language, style,
|
|
73
|
+
- `registerTelegramSection()` for provider-specific Telegram UI such as voice, language, style, or provider on/off controls
|
|
74
74
|
|
|
75
75
|
The reply policy itself remains a built-in pi-telegram setting (`voice.replyMode`) rather than a provider-owned menu.
|
|
76
76
|
|
|
@@ -81,10 +81,8 @@ Voice synthesis provider extensions register themselves through `registerTelegra
|
|
|
81
81
|
- Text optimisation / speech-style rewriting
|
|
82
82
|
- Adding speech tags (when desired)
|
|
83
83
|
- Running TTS + ffmpeg conversion to OGG/Opus
|
|
84
|
-
- Deciding whether to return `transcriptText` at all based on the bridge-owned `voice.sendTranscript` preference when the provider has access to the current Telegram config
|
|
85
|
-
- `transcriptText` (when returned) is attached by the bridge as the voice message **caption** only. Separate transcript messages are no longer sent.
|
|
86
84
|
|
|
87
|
-
The bridge shows a `record_voice` action while delivering and sends the final audio with Telegram `sendVoice`.
|
|
85
|
+
The bridge shows a `record_voice` action while delivering and sends the final audio with Telegram `sendVoice`.
|
|
88
86
|
|
|
89
87
|
Providers can implement `getVoicePromptContribution(view)` to inject voice-specific instructions into voice-tagged prompts (for example: "Reply only with the spoken text"). The bridge appends the first non-empty provider contribution when `mirror` or `always` mode tags the turn.
|
|
90
88
|
|
|
@@ -95,7 +93,6 @@ The provider receives the raw agent text plus optional `{ lang?, rate? }`.
|
|
|
95
93
|
It must return one of:
|
|
96
94
|
|
|
97
95
|
- `string` — path to a ready `.ogg` or `.opus` file
|
|
98
|
-
- `{ audioPath: string, transcriptText?: string }` — `audioPath` must be OGG/Opus. When `transcriptText` is present it is attached as the voice message **caption**. Providers should treat pi-telegram's `voice.sendTranscript` as the bridge-owned transcript preference instead of inventing a second reply-policy UI.
|
|
99
96
|
- `undefined` — skip this text block
|
|
100
97
|
|
|
101
98
|
**Important:** Providers are fully responsible for producing a clean, TTS-optimised native voice file. The bridge may also run configured outbound voice command templates for users who prefer process-boundary handlers instead of provider extensions.
|
|
@@ -106,7 +103,7 @@ Registration returns a disposer function for cleanup. Stable provider registrati
|
|
|
106
103
|
|
|
107
104
|
## Outbound Voice Handlers
|
|
108
105
|
|
|
109
|
-
Users can also configure `outboundHandlers` with `type: "voice"` in `telegram.json`. This is the command-template path for TTS without a provider extension. Reply modes (`
|
|
106
|
+
Users can also configure `outboundHandlers` with `type: "voice"` in `telegram.json`. This is the command-template path for TTS without a provider extension. Reply modes (`manual`, `mirror`, `always`) affect these handlers the same way they affect providers: explicit `telegram_voice` blocks and automatic mirror/always interception both produce a voice reply plan, then delivery tries configured outbound voice handlers first and registered synthesis providers as progressive fallbacks.
|
|
110
107
|
|
|
111
108
|
Voice handlers receive the text on stdin in composed pipelines and can use `{text}`, `{lang}`, `{rate}`, `{mp3}`, and `{ogg}` placeholders. Set `output` to `"ogg"` or another placeholder name when the template writes to a known path:
|
|
112
109
|
|
|
@@ -128,31 +125,6 @@ Voice handlers receive the text on stdin in composed pipelines and can use `{tex
|
|
|
128
125
|
|
|
129
126
|
Priority for outbound voice delivery is: configured `outboundHandlers` with `type: "voice"` in their `telegram.json` order, then programmatic `voice` outbound handlers, then registered voice synthesis providers. Provider extensions are the zero-config tail of the same pipeline: they handle voice when no explicit configured handler succeeds, but they do not override operator-configured handlers. If multiple providers are registered, only one handles a given voice reply: the first provider that returns a valid `.ogg`/`.opus` artifact wins. Providers that return `undefined` explicitly pass to the next provider; providers that throw or return invalid output are recorded and the next fallback is tried.
|
|
130
127
|
|
|
131
|
-
### Provider with transcript caption (controlled by user toggle)
|
|
132
|
-
|
|
133
|
-
When the user's "Send Transcript" toggle is ON, return the clean spoken text as `transcriptText`. The bridge attaches it as the caption on the voice message. When the toggle is OFF, return only the audio path (no `transcriptText`).
|
|
134
|
-
|
|
135
|
-
```typescript
|
|
136
|
-
import {
|
|
137
|
-
getTelegramVoiceSendTranscript,
|
|
138
|
-
registerTelegramVoiceSynthesisProvider,
|
|
139
|
-
} from "@llblab/pi-telegram/voice";
|
|
140
|
-
|
|
141
|
-
registerTelegramVoiceSynthesisProvider(
|
|
142
|
-
async (text, options) => {
|
|
143
|
-
const rewritten = rewriteWithSpeechTags(text);
|
|
144
|
-
const audioPath = await myTTS(rewritten, { language: options?.lang });
|
|
145
|
-
const sendTranscript = getTelegramVoiceSendTranscript(
|
|
146
|
-
getCurrentTelegramConfigView(),
|
|
147
|
-
);
|
|
148
|
-
return sendTranscript ? { audioPath, transcriptText: text } : { audioPath };
|
|
149
|
-
},
|
|
150
|
-
{ id: "my-voice-provider/tts" },
|
|
151
|
-
);
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
`getCurrentTelegramConfigView()` represents whatever current `TelegramConfig` view your extension already owns or receives; pi-telegram does not require providers to read config directly. The bridge never sends a separate transcript message. Caption-only is the "ON" behavior.
|
|
155
|
-
|
|
156
128
|
### Surfacing provider diagnostics
|
|
157
129
|
|
|
158
130
|
Voice provider extensions can record runtime events that appear in `/telegram-status` alongside pi-telegram's own events:
|
|
@@ -170,13 +142,13 @@ recordTelegramRuntimeEvent("voice-provider", new Error("TTS failed"), {
|
|
|
170
142
|
|
|
171
143
|
## Voice Extension Section
|
|
172
144
|
|
|
173
|
-
Voice provider extensions can register a Voice Extension Section (settings UI) via `registerTelegramSection`. The section can expose provider-specific controls such as TTS voice, language, speech style,
|
|
145
|
+
Voice provider extensions can register a Voice Extension Section (settings UI) via `registerTelegramSection`. The section can expose provider-specific controls such as TTS voice, language, speech style, or STT/TTS enablement. Reply mode is a core pi-telegram setting and belongs in the built-in Settings menu.
|
|
174
146
|
|
|
175
147
|
**Note on resume:** Because the previous automatic persistent re-registration system has been removed, extensions are responsible for re-registering their Voice Extension Section on `session_start` if they want the menu to survive a `pi resume`. See `registerTelegramSection` from `@llblab/pi-telegram/sections`.
|
|
176
148
|
|
|
177
149
|
## Prompt Guidance
|
|
178
150
|
|
|
179
|
-
The bridge keeps voice prompt context compact, effective, and policy-owned. `
|
|
151
|
+
The bridge keeps voice prompt context compact, effective, and policy-owned. `manual` and text-originated `mirror` turns add no voice line. Voice/audio-originated `mirror` turns and every `always` turn add exactly `[voice] delivery: automatic voice`, describing the current delivery environment without exposing the underlying mode matrix or an instruction list. The marker is appended after `[outputs]` when handler output exists, otherwise after `[attachments]`. Voice inputs also appear in `[attachments]` with their downloaded file names, MIME data, and handler output, so agents can infer concrete voice-file context from attachment metadata.
|
|
180
152
|
|
|
181
153
|
Voice synthesis providers can supply prompt guidance through `getVoicePromptContribution(view)`, but provider text should stay optional and provider-specific. Reply-mode context belongs to pi-telegram.
|
|
182
154
|
|
|
@@ -224,4 +196,4 @@ The bridge reads `voice.replyMode` from the config when building a turn.
|
|
|
224
196
|
|
|
225
197
|
### Provider config
|
|
226
198
|
|
|
227
|
-
Provider-specific settings (voice ID, language, speech style,
|
|
199
|
+
Provider-specific settings (voice ID, language, speech style, STT/TTS enablement) are owned by the voice provider extension. Reply mode is owned by pi-telegram's `voice.replyMode` and configured from the built-in pi-telegram Settings menu, not duplicated in provider UIs.
|
|
@@ -359,6 +359,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
359
359
|
getActiveToolExecutions: lifecycle.getActiveToolExecutions,
|
|
360
360
|
hasPendingModelSwitch: pendingModelSwitchStore.has,
|
|
361
361
|
getQueuedItems: telegramQueueStore.getQueuedItems,
|
|
362
|
+
getQueuedItemCount: Queue.countExecutableTelegramQueueItems,
|
|
362
363
|
formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
|
|
363
364
|
getRecentRuntimeEvents: runtimeEvents.getEvents,
|
|
364
365
|
getRuntimeLockState: lockRuntime.getStatusLabel,
|
|
@@ -415,6 +416,8 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
415
416
|
},
|
|
416
417
|
getRegistrationGeneration:
|
|
417
418
|
telegramBusFollowerRegistrationState.getGeneration,
|
|
419
|
+
waitForRegistrationGeneration:
|
|
420
|
+
telegramBusFollowerRegistrationState.waitForGeneration,
|
|
418
421
|
getForwardCommentBatchPosition:
|
|
419
422
|
textGroupRuntime.getPreparedForwardingPosition,
|
|
420
423
|
recordRuntimeEvent,
|
|
@@ -841,6 +844,16 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
841
844
|
});
|
|
842
845
|
},
|
|
843
846
|
);
|
|
847
|
+
const staleTopicApiErrorRecoveryDeps = {
|
|
848
|
+
topicTargetStore: threadStore,
|
|
849
|
+
getSyncState: telegramSyncStateRuntime.getState,
|
|
850
|
+
setSyncState: telegramSyncStateRuntime.setState,
|
|
851
|
+
recordEvent: recordRuntimeEvent,
|
|
852
|
+
};
|
|
853
|
+
const recoverStaleTelegramTopicApiError =
|
|
854
|
+
Sync.createTelegramStaleTopicApiErrorRecoveryRuntime(
|
|
855
|
+
staleTopicApiErrorRecoveryDeps,
|
|
856
|
+
);
|
|
844
857
|
const {
|
|
845
858
|
owner: updateWorkerOwnerRuntime,
|
|
846
859
|
leader: updateAdmissionLifecycleRuntime,
|
|
@@ -864,6 +877,12 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
864
877
|
worker: {
|
|
865
878
|
defaultHandle: inboundRouteRuntime.handleUpdate,
|
|
866
879
|
onStateChange: runtimeDiagnostics.scheduleSnapshotPersist,
|
|
880
|
+
settleTerminalExecutionFailure(error) {
|
|
881
|
+
return Sync.settleStaleTelegramTopicExecutionFailure(
|
|
882
|
+
error,
|
|
883
|
+
staleTopicApiErrorRecoveryDeps,
|
|
884
|
+
);
|
|
885
|
+
},
|
|
867
886
|
},
|
|
868
887
|
leader: {
|
|
869
888
|
resolveBinding: resolveTelegramUpdateJournalBinding,
|
|
@@ -1053,13 +1072,6 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1053
1072
|
onPollingStateChange: runtimeDiagnostics.scheduleSnapshotPersist,
|
|
1054
1073
|
recordRuntimeEvent,
|
|
1055
1074
|
});
|
|
1056
|
-
const recoverStaleTelegramTopicApiError =
|
|
1057
|
-
Sync.createTelegramStaleTopicApiErrorRecoveryRuntime({
|
|
1058
|
-
topicTargetStore: threadStore,
|
|
1059
|
-
getSyncState: telegramSyncStateRuntime.getState,
|
|
1060
|
-
setSyncState: telegramSyncStateRuntime.setState,
|
|
1061
|
-
recordEvent: recordRuntimeEvent,
|
|
1062
|
-
});
|
|
1063
1075
|
const authorizeFollowerApiCall = Bus.createTelegramFollowerApiCallAuthorizer({
|
|
1064
1076
|
isMessageOwned: messageOwnershipRuntime.isOwnedByFollower,
|
|
1065
1077
|
});
|
|
@@ -1232,6 +1244,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1232
1244
|
prepareTempDir,
|
|
1233
1245
|
updateStatus,
|
|
1234
1246
|
unbindDeferredDispatchContext: deferredQueueDispatchRuntime.unbind,
|
|
1247
|
+
discardQueuedItems: queueMutationRuntime.clear,
|
|
1235
1248
|
clearModelMenuState: modelMenuRuntime.clear,
|
|
1236
1249
|
getActiveTurnChatId: activeTurnRuntime.getChatId,
|
|
1237
1250
|
getActiveTurnTarget: activeTurnRuntime.getTarget,
|
|
@@ -95,11 +95,28 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
|
|
|
95
95
|
sendUserMessage: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendUserMessage"];
|
|
96
96
|
recordRuntimeEvent?: TelegramRuntimeEventRecorder;
|
|
97
97
|
}): TelegramQueueBindingRuntime<TContext> {
|
|
98
|
+
const settleDiscardedItems = (
|
|
99
|
+
items: readonly Queue.TelegramQueueItem<TContext>[],
|
|
100
|
+
ctx: TContext,
|
|
101
|
+
): boolean => {
|
|
102
|
+
const durableItems = items.filter(
|
|
103
|
+
(item) => (item.admissionReceipts?.length ?? 0) > 0,
|
|
104
|
+
);
|
|
105
|
+
if (durableItems.length === 0) return true;
|
|
106
|
+
const settlement = deps.admission.getSettlement();
|
|
107
|
+
if (!settlement) return false;
|
|
108
|
+
settlement.onItemsDiscarded(durableItems, ctx);
|
|
109
|
+
return durableItems.every((item) => !settlement.isItemReady(item));
|
|
110
|
+
};
|
|
98
111
|
const mutation = Queue.createTelegramQueueMutationController({
|
|
99
112
|
...deps.store,
|
|
100
113
|
allocateLaneOrder: deps.queue.allocateItemOrder,
|
|
101
114
|
onItemsDiscarded(items, ctx) {
|
|
102
|
-
|
|
115
|
+
if (!settleDiscardedItems(items, ctx)) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
"Telegram queue items could not be discarded durably.",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
103
120
|
},
|
|
104
121
|
updateStatus: deps.updateStatus,
|
|
105
122
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
@@ -136,6 +153,9 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
|
|
|
136
153
|
onControlSettled(item, ctx) {
|
|
137
154
|
deps.admission.getSettlement()?.onControlSettled(item, ctx);
|
|
138
155
|
},
|
|
156
|
+
onPromptSkipped(item, ctx) {
|
|
157
|
+
return settleDiscardedItems([item], ctx);
|
|
158
|
+
},
|
|
139
159
|
updateStatus: deps.updateStatus,
|
|
140
160
|
sendTextReply: deps.sendTextReply,
|
|
141
161
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
@@ -909,6 +929,25 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
909
929
|
{ replyToPrompt: false },
|
|
910
930
|
);
|
|
911
931
|
};
|
|
932
|
+
let activeTurnDeliveryTail = Promise.resolve();
|
|
933
|
+
const scheduleActiveTurnDelivery = (task: () => Promise<void>): void => {
|
|
934
|
+
const previous = activeTurnDeliveryTail;
|
|
935
|
+
activeTurnDeliveryTail = (async () => {
|
|
936
|
+
await previous;
|
|
937
|
+
await new Promise<void>((resolve) => {
|
|
938
|
+
const timer = setTimeout(resolve, 0);
|
|
939
|
+
timer.unref?.();
|
|
940
|
+
});
|
|
941
|
+
await task();
|
|
942
|
+
})().catch((error) => {
|
|
943
|
+
recordRuntimeEvent("delivery", error, {
|
|
944
|
+
phase: "agent-end-background-delivery",
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
};
|
|
948
|
+
const waitForActiveTurnDelivery = async (): Promise<void> => {
|
|
949
|
+
await activeTurnDeliveryTail;
|
|
950
|
+
};
|
|
912
951
|
const agentLifecycleHooks = Queue.createTelegramAgentLifecycleHooks<
|
|
913
952
|
Queue.PendingTelegramTurn,
|
|
914
953
|
Pi.ExtensionContext,
|
|
@@ -950,16 +989,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
950
989
|
dispatchNextQueuedTelegramTurn,
|
|
951
990
|
requestDeferredDispatchNextQueuedTelegramTurn:
|
|
952
991
|
deferredQueueDispatchRuntime.request,
|
|
953
|
-
scheduleActiveTurnDelivery
|
|
954
|
-
const timer = setTimeout(() => {
|
|
955
|
-
void task().catch((error) => {
|
|
956
|
-
recordRuntimeEvent("delivery", error, {
|
|
957
|
-
phase: "agent-end-background-delivery",
|
|
958
|
-
});
|
|
959
|
-
});
|
|
960
|
-
}, 0);
|
|
961
|
-
timer.unref?.();
|
|
962
|
-
},
|
|
992
|
+
scheduleActiveTurnDelivery,
|
|
963
993
|
clearPreview: previewRuntime.clear,
|
|
964
994
|
setPreviewPendingText: previewRuntime.setPendingText,
|
|
965
995
|
finalizeMarkdownPreview,
|
|
@@ -1079,7 +1109,11 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
1079
1109
|
activityRuntime.onCompactionStart(Pi.getSessionCompactionReason(event));
|
|
1080
1110
|
compactionObserver.onSessionBeforeCompact(event, ctx);
|
|
1081
1111
|
if (shouldNotify) {
|
|
1082
|
-
await
|
|
1112
|
+
await waitForActiveTurnDelivery();
|
|
1113
|
+
if (!isSessionContextActive(ctx)) return;
|
|
1114
|
+
await sendCompactionNotice(
|
|
1115
|
+
Commands.TELEGRAM_COMPACTION_STARTED_MARKDOWN,
|
|
1116
|
+
);
|
|
1083
1117
|
}
|
|
1084
1118
|
},
|
|
1085
1119
|
async onSessionCompact(event, ctx) {
|
|
@@ -1088,7 +1122,9 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
1088
1122
|
compactionObserver.onSessionCompact(event, ctx);
|
|
1089
1123
|
if (observedAutomaticCompaction) {
|
|
1090
1124
|
observedAutomaticCompaction = false;
|
|
1091
|
-
await sendCompactionNotice(
|
|
1125
|
+
await sendCompactionNotice(
|
|
1126
|
+
Commands.TELEGRAM_COMPACTION_COMPLETED_MARKDOWN,
|
|
1127
|
+
);
|
|
1092
1128
|
}
|
|
1093
1129
|
},
|
|
1094
1130
|
async onAgentStart(event, ctx) {
|
|
@@ -20,6 +20,7 @@ import type { TelegramTarget } from "./target.ts";
|
|
|
20
20
|
import {
|
|
21
21
|
isTelegramApiMethodRetrySafe,
|
|
22
22
|
TelegramApiCommitUnknownError,
|
|
23
|
+
TelegramApiStaleTargetError,
|
|
23
24
|
} from "./telegram-api.ts";
|
|
24
25
|
import {
|
|
25
26
|
createTelegramBusFollowerDeliveryIdentity,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
export const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
|
|
48
49
|
export const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30_000;
|
|
49
50
|
export const TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS = 30_000;
|
|
51
|
+
export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS = 30_000;
|
|
50
52
|
export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS =
|
|
51
53
|
TELEGRAM_BUS_REGISTRATION_RETRY.attempts;
|
|
52
54
|
export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_DELAY_MS =
|
|
@@ -167,6 +169,9 @@ export interface TelegramBusFollowerRegistrationState {
|
|
|
167
169
|
getSlot: () => string | undefined;
|
|
168
170
|
getThreadName: () => string | undefined;
|
|
169
171
|
getGeneration: () => string | undefined;
|
|
172
|
+
beginRecovery: () => number;
|
|
173
|
+
cancelRecovery: () => void;
|
|
174
|
+
waitForGeneration: (timeoutMs?: number) => Promise<string | undefined>;
|
|
170
175
|
getLeaderProtocol: () => TelegramBusProtocolIdentity | undefined;
|
|
171
176
|
getEligibleElectionSlots: () => readonly string[];
|
|
172
177
|
setEligibleElectionSlots: (slots: readonly string[]) => void;
|
|
@@ -214,6 +219,9 @@ export interface TelegramBusFollowerClientRuntimeDeps<TMessage = unknown> {
|
|
|
214
219
|
getApiAuthSecret?: () => string | undefined;
|
|
215
220
|
getForwardingAuthSecret?: () => string | undefined;
|
|
216
221
|
getRegistrationGeneration: () => string | undefined;
|
|
222
|
+
waitForRegistrationGeneration?: (
|
|
223
|
+
timeoutMs?: number,
|
|
224
|
+
) => Promise<string | undefined>;
|
|
217
225
|
getForwardCommentBatchPosition?: (
|
|
218
226
|
message: TMessage,
|
|
219
227
|
) => "comment" | "forward" | undefined;
|
|
@@ -231,6 +239,9 @@ export interface TelegramBusFollowerApiCallerDeps {
|
|
|
231
239
|
createRequestId: () => string;
|
|
232
240
|
getAuthSecret?: () => string | undefined;
|
|
233
241
|
getRegistrationGeneration: () => string | undefined;
|
|
242
|
+
waitForRegistrationGeneration?: (
|
|
243
|
+
timeoutMs?: number,
|
|
244
|
+
) => Promise<string | undefined>;
|
|
234
245
|
getNowMs?: () => number;
|
|
235
246
|
timeoutMs?: number;
|
|
236
247
|
}
|
|
@@ -417,6 +428,7 @@ export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
|
|
|
417
428
|
| "getSlot"
|
|
418
429
|
| "getThreadName"
|
|
419
430
|
| "getEligibleElectionSlots"
|
|
431
|
+
| "beginRecovery"
|
|
420
432
|
| "setRegistered"
|
|
421
433
|
>;
|
|
422
434
|
getRegistrationRuntime: () => TelegramBusFollowerRegistrationRuntime<TContext>;
|
|
@@ -638,6 +650,7 @@ export function createTelegramBusFollowerClientRuntime<
|
|
|
638
650
|
socketPath: deps.socketPath,
|
|
639
651
|
createRequestId,
|
|
640
652
|
timeoutMs,
|
|
653
|
+
waitForRegistrationGeneration: deps.waitForRegistrationGeneration,
|
|
641
654
|
};
|
|
642
655
|
return {
|
|
643
656
|
createRequestId,
|
|
@@ -695,14 +708,14 @@ export function createTelegramBusFollowerQueueHandoffClient(
|
|
|
695
708
|
const timeoutMs =
|
|
696
709
|
deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS;
|
|
697
710
|
return async (input) => {
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
711
|
+
const registration = await resolveTelegramBusFollowerRegistration(
|
|
712
|
+
deps,
|
|
713
|
+
timeoutMs,
|
|
714
|
+
);
|
|
702
715
|
const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
|
|
703
716
|
const response = await sendTelegramBusLocalEnvelope({
|
|
704
717
|
socketPath,
|
|
705
|
-
timeoutMs,
|
|
718
|
+
timeoutMs: registration.remainingTimeoutMs,
|
|
706
719
|
retry: getTelegramBusTransportRetryPolicy({
|
|
707
720
|
endpoint: socketPath,
|
|
708
721
|
operation: "operation",
|
|
@@ -712,7 +725,7 @@ export function createTelegramBusFollowerQueueHandoffClient(
|
|
|
712
725
|
requestId: deps.createRequestId(),
|
|
713
726
|
auth: deps.getAuthSecret?.(),
|
|
714
727
|
instanceId: deps.instanceId,
|
|
715
|
-
registrationGeneration,
|
|
728
|
+
registrationGeneration: registration.generation,
|
|
716
729
|
...input,
|
|
717
730
|
sentAtMs: getNowMs(),
|
|
718
731
|
},
|
|
@@ -770,11 +783,12 @@ export function createTelegramBusAgentMessageClient(
|
|
|
770
783
|
envelope:
|
|
771
784
|
| Extract<TelegramBusEnvelope, { kind: "follower.resolveAgentTarget" }>
|
|
772
785
|
| Extract<TelegramBusEnvelope, { kind: "follower.routeAgentMessage" }>,
|
|
786
|
+
requestTimeoutMs = timeoutMs,
|
|
773
787
|
): Promise<unknown> => {
|
|
774
788
|
const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
|
|
775
789
|
const response = await sendTelegramBusLocalEnvelope({
|
|
776
790
|
socketPath,
|
|
777
|
-
timeoutMs,
|
|
791
|
+
timeoutMs: requestTimeoutMs,
|
|
778
792
|
retry: getTelegramBusTransportRetryPolicy({
|
|
779
793
|
endpoint: socketPath,
|
|
780
794
|
operation: "operation",
|
|
@@ -788,26 +802,30 @@ export function createTelegramBusAgentMessageClient(
|
|
|
788
802
|
: "Telegram bus agent message did not return an acknowledgement.",
|
|
789
803
|
);
|
|
790
804
|
};
|
|
791
|
-
const registrationFields = () => {
|
|
792
|
-
const
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
805
|
+
const registrationFields = async () => {
|
|
806
|
+
const registration = await resolveTelegramBusFollowerRegistration(
|
|
807
|
+
deps,
|
|
808
|
+
timeoutMs,
|
|
809
|
+
);
|
|
796
810
|
return {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
811
|
+
fields: {
|
|
812
|
+
auth: deps.getAuthSecret?.(),
|
|
813
|
+
instanceId: deps.instanceId,
|
|
814
|
+
registrationGeneration: registration.generation,
|
|
815
|
+
},
|
|
816
|
+
remainingTimeoutMs: registration.remainingTimeoutMs,
|
|
800
817
|
};
|
|
801
818
|
};
|
|
802
819
|
return {
|
|
803
820
|
async resolveTarget(selector) {
|
|
821
|
+
const registration = await registrationFields();
|
|
804
822
|
const result = await request({
|
|
805
823
|
kind: "follower.resolveAgentTarget",
|
|
806
824
|
requestId: deps.createRequestId(),
|
|
807
|
-
...
|
|
825
|
+
...registration.fields,
|
|
808
826
|
selector,
|
|
809
827
|
sentAtMs: getNowMs(),
|
|
810
|
-
});
|
|
828
|
+
}, registration.remainingTimeoutMs);
|
|
811
829
|
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
812
830
|
throw new Error("Telegram bus returned an invalid agent target.");
|
|
813
831
|
}
|
|
@@ -821,13 +839,14 @@ export function createTelegramBusAgentMessageClient(
|
|
|
821
839
|
return { chatId: target.chatId, threadId: target.threadId };
|
|
822
840
|
},
|
|
823
841
|
async routeMessage(message) {
|
|
842
|
+
const registration = await registrationFields();
|
|
824
843
|
await request({
|
|
825
844
|
kind: "follower.routeAgentMessage",
|
|
826
845
|
requestId: deps.createRequestId(),
|
|
827
|
-
...
|
|
846
|
+
...registration.fields,
|
|
828
847
|
message,
|
|
829
848
|
sentAtMs: getNowMs(),
|
|
830
|
-
});
|
|
849
|
+
}, registration.remainingTimeoutMs);
|
|
831
850
|
},
|
|
832
851
|
};
|
|
833
852
|
}
|
|
@@ -839,16 +858,16 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
839
858
|
const timeoutMs =
|
|
840
859
|
deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS;
|
|
841
860
|
return async (method, args) => {
|
|
861
|
+
const registration = await resolveTelegramBusFollowerRegistration(
|
|
862
|
+
deps,
|
|
863
|
+
timeoutMs,
|
|
864
|
+
);
|
|
842
865
|
const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
|
|
843
|
-
const registrationGeneration = deps.getRegistrationGeneration();
|
|
844
|
-
if (!registrationGeneration) {
|
|
845
|
-
throw new Error("Telegram bus follower is not registered.");
|
|
846
|
-
}
|
|
847
866
|
let response: TelegramBusEnvelope | undefined;
|
|
848
867
|
try {
|
|
849
868
|
response = await sendTelegramBusLocalEnvelope({
|
|
850
869
|
socketPath,
|
|
851
|
-
timeoutMs,
|
|
870
|
+
timeoutMs: registration.remainingTimeoutMs,
|
|
852
871
|
retry: getTelegramBusTransportRetryPolicy({
|
|
853
872
|
endpoint: socketPath,
|
|
854
873
|
operation: "operation",
|
|
@@ -858,7 +877,7 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
858
877
|
requestId: deps.createRequestId(),
|
|
859
878
|
auth: deps.getAuthSecret?.(),
|
|
860
879
|
instanceId: deps.instanceId,
|
|
861
|
-
registrationGeneration,
|
|
880
|
+
registrationGeneration: registration.generation,
|
|
862
881
|
method,
|
|
863
882
|
args,
|
|
864
883
|
sentAtMs: getNowMs(),
|
|
@@ -880,6 +899,20 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
880
899
|
response?.kind === "bus.ack"
|
|
881
900
|
? response.message
|
|
882
901
|
: "Telegram bus API call did not return an acknowledgement.";
|
|
902
|
+
if (
|
|
903
|
+
response?.kind === "bus.ack" &&
|
|
904
|
+
response.error?.code === "stale-target" &&
|
|
905
|
+
response.error.chatId !== undefined &&
|
|
906
|
+
response.error.threadId !== undefined
|
|
907
|
+
) {
|
|
908
|
+
throw new TelegramApiStaleTargetError(
|
|
909
|
+
message ?? "Telegram thread target is stale.",
|
|
910
|
+
{
|
|
911
|
+
chatId: response.error.chatId,
|
|
912
|
+
threadId: response.error.threadId,
|
|
913
|
+
},
|
|
914
|
+
);
|
|
915
|
+
}
|
|
883
916
|
if (
|
|
884
917
|
response?.kind === "bus.ack" &&
|
|
885
918
|
response.error?.code === "commit-unknown"
|
|
@@ -893,6 +926,29 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
893
926
|
};
|
|
894
927
|
}
|
|
895
928
|
|
|
929
|
+
async function resolveTelegramBusFollowerRegistration(
|
|
930
|
+
deps: Pick<
|
|
931
|
+
TelegramBusFollowerApiCallerDeps,
|
|
932
|
+
| "getRegistrationGeneration"
|
|
933
|
+
| "waitForRegistrationGeneration"
|
|
934
|
+
| "getNowMs"
|
|
935
|
+
>,
|
|
936
|
+
timeoutMs: number,
|
|
937
|
+
): Promise<{ generation: string; remainingTimeoutMs: number }> {
|
|
938
|
+
const current = deps.getRegistrationGeneration();
|
|
939
|
+
if (current) return { generation: current, remainingTimeoutMs: timeoutMs };
|
|
940
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
941
|
+
const startedAtMs = getNowMs();
|
|
942
|
+
const restored = await deps.waitForRegistrationGeneration?.(
|
|
943
|
+
timeoutMs,
|
|
944
|
+
);
|
|
945
|
+
const remainingTimeoutMs = Math.max(0, timeoutMs - (getNowMs() - startedAtMs));
|
|
946
|
+
if (restored && remainingTimeoutMs > 0) {
|
|
947
|
+
return { generation: restored, remainingTimeoutMs };
|
|
948
|
+
}
|
|
949
|
+
throw new Error("Telegram bus follower is not registered.");
|
|
950
|
+
}
|
|
951
|
+
|
|
896
952
|
function isTelegramStaleContextError(error: unknown): boolean {
|
|
897
953
|
return (
|
|
898
954
|
error instanceof Error &&
|
|
@@ -1046,12 +1102,52 @@ export function createTelegramBusFollowerRegistrationState(
|
|
|
1046
1102
|
let generation: string | undefined;
|
|
1047
1103
|
let leaderProtocol: TelegramBusProtocolIdentity | undefined;
|
|
1048
1104
|
let eligibleElectionSlots: string[] = [];
|
|
1105
|
+
let recoveryEpoch = 0;
|
|
1106
|
+
let activeRecoveryEpoch: number | undefined;
|
|
1107
|
+
const generationWaiters = new Set<{
|
|
1108
|
+
epoch: number;
|
|
1109
|
+
settle: (value: string | undefined) => void;
|
|
1110
|
+
}>();
|
|
1111
|
+
const settleGenerationWaiters = (
|
|
1112
|
+
value: string | undefined,
|
|
1113
|
+
epoch?: number,
|
|
1114
|
+
) => {
|
|
1115
|
+
for (const waiter of [...generationWaiters]) {
|
|
1116
|
+
if (epoch === undefined || waiter.epoch === epoch) waiter.settle(value);
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1049
1119
|
return {
|
|
1050
1120
|
isRegistered: () => registered,
|
|
1051
1121
|
getTarget: () => (target ? { ...target } : undefined),
|
|
1052
1122
|
getSlot: () => slot,
|
|
1053
1123
|
getThreadName: () => threadName,
|
|
1054
1124
|
getGeneration: () => generation,
|
|
1125
|
+
beginRecovery: () => {
|
|
1126
|
+
if (activeRecoveryEpoch !== undefined) return activeRecoveryEpoch;
|
|
1127
|
+
activeRecoveryEpoch = ++recoveryEpoch;
|
|
1128
|
+
return activeRecoveryEpoch;
|
|
1129
|
+
},
|
|
1130
|
+
cancelRecovery: () => {
|
|
1131
|
+
const epoch = activeRecoveryEpoch;
|
|
1132
|
+
activeRecoveryEpoch = undefined;
|
|
1133
|
+
if (epoch !== undefined) settleGenerationWaiters(undefined, epoch);
|
|
1134
|
+
},
|
|
1135
|
+
waitForGeneration: (timeoutMs = TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS) => {
|
|
1136
|
+
if (generation) return Promise.resolve(generation);
|
|
1137
|
+
const epoch = activeRecoveryEpoch;
|
|
1138
|
+
if (epoch === undefined) return Promise.resolve(undefined);
|
|
1139
|
+
return new Promise((resolve) => {
|
|
1140
|
+
let timer: NodeJS.Timeout | undefined;
|
|
1141
|
+
const settle = (value: string | undefined) => {
|
|
1142
|
+
generationWaiters.delete(waiter);
|
|
1143
|
+
if (timer) clearTimeout(timer);
|
|
1144
|
+
resolve(value);
|
|
1145
|
+
};
|
|
1146
|
+
const waiter = { epoch, settle };
|
|
1147
|
+
generationWaiters.add(waiter);
|
|
1148
|
+
timer = setTimeout(() => settle(undefined), Math.max(0, timeoutMs));
|
|
1149
|
+
});
|
|
1150
|
+
},
|
|
1055
1151
|
getLeaderProtocol: () =>
|
|
1056
1152
|
leaderProtocol
|
|
1057
1153
|
? { ...leaderProtocol, capabilities: [...leaderProtocol.capabilities] }
|
|
@@ -1077,6 +1173,10 @@ export function createTelegramBusFollowerRegistrationState(
|
|
|
1077
1173
|
}
|
|
1078
1174
|
: undefined;
|
|
1079
1175
|
if (availabilityChanged) options.onAvailabilityChanged?.();
|
|
1176
|
+
if (generation) {
|
|
1177
|
+
activeRecoveryEpoch = undefined;
|
|
1178
|
+
settleGenerationWaiters(generation);
|
|
1179
|
+
}
|
|
1080
1180
|
},
|
|
1081
1181
|
};
|
|
1082
1182
|
}
|
|
@@ -1253,6 +1353,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
1253
1353
|
): Promise<void> => {
|
|
1254
1354
|
if (promotionPending) return;
|
|
1255
1355
|
promotionPending = true;
|
|
1356
|
+
deps.registrationState.beginRecovery();
|
|
1256
1357
|
try {
|
|
1257
1358
|
const initialBinding = carriedBinding ?? snapshotBinding();
|
|
1258
1359
|
const state = deps.getLeaderState();
|
|
@@ -1366,6 +1467,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
1366
1467
|
heartbeatPromise = undefined;
|
|
1367
1468
|
heartbeatPromiseGeneration = undefined;
|
|
1368
1469
|
deps.setActiveAuthSecret?.(undefined);
|
|
1470
|
+
deps.registrationState?.cancelRecovery();
|
|
1369
1471
|
deps.registrationState?.setRegistered(false);
|
|
1370
1472
|
lastKnownTarget = undefined;
|
|
1371
1473
|
lastKnownSlot = undefined;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import * as Sync from "./sync.ts";
|
|
9
9
|
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
10
10
|
import {
|
|
11
|
+
getTelegramApiErrorRequestTarget,
|
|
11
12
|
isTelegramApiCommitUnknownError,
|
|
12
13
|
type TelegramApiCallOptions,
|
|
13
14
|
} from "./telegram-api.ts";
|
|
@@ -1756,6 +1757,10 @@ async function handleFollowerApiCall(
|
|
|
1756
1757
|
result,
|
|
1757
1758
|
};
|
|
1758
1759
|
} catch (error) {
|
|
1760
|
+
const staleTarget =
|
|
1761
|
+
Threads.isTelegramTopicTargetStaleError(error)
|
|
1762
|
+
? getTelegramApiErrorRequestTarget(error)
|
|
1763
|
+
: undefined;
|
|
1759
1764
|
return {
|
|
1760
1765
|
kind: "bus.ack",
|
|
1761
1766
|
requestId: envelope.requestId,
|
|
@@ -1764,14 +1769,22 @@ async function handleFollowerApiCall(
|
|
|
1764
1769
|
error instanceof Error
|
|
1765
1770
|
? error.message
|
|
1766
1771
|
: "Telegram bus API call failed.",
|
|
1767
|
-
...(
|
|
1772
|
+
...(staleTarget
|
|
1768
1773
|
? {
|
|
1769
1774
|
error: {
|
|
1770
|
-
code: "
|
|
1771
|
-
|
|
1775
|
+
code: "stale-target" as const,
|
|
1776
|
+
chatId: staleTarget.chatId,
|
|
1777
|
+
threadId: staleTarget.threadId,
|
|
1772
1778
|
},
|
|
1773
1779
|
}
|
|
1774
|
-
:
|
|
1780
|
+
: isTelegramApiCommitUnknownError(error)
|
|
1781
|
+
? {
|
|
1782
|
+
error: {
|
|
1783
|
+
code: "commit-unknown" as const,
|
|
1784
|
+
method: error.method,
|
|
1785
|
+
},
|
|
1786
|
+
}
|
|
1787
|
+
: {}),
|
|
1775
1788
|
};
|
|
1776
1789
|
}
|
|
1777
1790
|
}
|