@llblab/pi-kit 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +2 -2
  4. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -0
  5. package/node_modules/@llblab/pi-grow-loop/README.md +6 -6
  6. package/node_modules/@llblab/pi-grow-loop/index.ts +6 -3
  7. package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
  8. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  9. package/node_modules/@llblab/pi-telegram/BACKLOG.md +0 -1
  10. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +9 -5
  11. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  12. package/node_modules/@llblab/pi-telegram/docs/outbound.md +8 -2
  13. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -0
  14. package/node_modules/@llblab/pi-telegram/index.ts +22 -19
  15. package/node_modules/@llblab/pi-telegram/lib/activity.ts +19 -5
  16. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +9 -1
  17. package/node_modules/@llblab/pi-telegram/lib/config.ts +1 -1
  18. package/node_modules/@llblab/pi-telegram/lib/delivery.ts +18 -18
  19. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +7 -1
  20. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +2 -2
  21. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +23 -30
  22. package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +28 -42
  23. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +18 -14
  24. package/node_modules/@llblab/pi-telegram/lib/preview.ts +115 -70
  25. package/node_modules/@llblab/pi-telegram/lib/queue.ts +18 -8
  26. package/node_modules/@llblab/pi-telegram/lib/replies.ts +46 -38
  27. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +36 -3
  28. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  29. package/node_modules/@llblab/skills/abcd-context/AGENTS.md +1 -0
  30. package/node_modules/@llblab/skills/abcd-context/CHANGELOG.md +6 -2
  31. package/node_modules/@llblab/skills/abcd-context/SKILL.md +1 -1
  32. package/node_modules/@llblab/skills/abcd-context/docs/validation-design.md +11 -5
  33. package/node_modules/@llblab/skills/abcd-context/scripts/_self-test.mjs +61 -0
  34. package/node_modules/@llblab/skills/abcd-context/scripts/validate-context.mjs +67 -0
  35. package/node_modules/@llblab/skills/package.json +1 -1
  36. package/node_modules/@llblab/skills/release-flow/SKILL.md +2 -4
  37. package/package.json +4 -4
@@ -21,7 +21,7 @@ import {
21
21
  TELEGRAM_MESSAGE_PROMPT_SNIPPET,
22
22
  } from "./prompts.ts";
23
23
  import {
24
- buildTelegramMultipartReplyParameters,
24
+ withTelegramReplyParameters,
25
25
  normalizeTelegramNativeMarkdown,
26
26
  } from "./replies.ts";
27
27
  import {
@@ -189,18 +189,10 @@ export function planTelegramRichOutboundAttachment(options: {
189
189
  ],
190
190
  skip_entity_detection: true,
191
191
  };
192
- const replyParameters =
193
- options.turn.replyToMessageId > 0
194
- ? JSON.stringify({
195
- message_id: options.turn.replyToMessageId,
196
- allow_sending_without_reply: true,
197
- })
198
- : undefined;
199
192
  return {
200
193
  method: "sendRichMessage",
201
194
  fields: {
202
195
  chat_id: String(options.turn.chatId),
203
- ...(replyParameters ? { reply_parameters: replyParameters } : {}),
204
196
  ...getTelegramMultipartTargetFields(options.turn.target),
205
197
  rich_message: JSON.stringify(richMessage),
206
198
  ...(options.replyMarkup
@@ -230,12 +222,15 @@ export function createTelegramRichOutboundAttachmentSender(
230
222
  });
231
223
  if (!plan) return false;
232
224
  try {
233
- const result = await deps.sendMultipart(
234
- plan.method,
235
- plan.fields,
236
- plan.fileField,
237
- plan.filePath,
238
- plan.fileName,
225
+ const result = await withTelegramReplyParameters(
226
+ turn.chatId, turn.replyToMessageId, turn.target,
227
+ (replyParameters) => deps.sendMultipart(
228
+ plan.method,
229
+ { ...plan.fields, ...(replyParameters ? { reply_parameters: JSON.stringify(replyParameters) } : {}) },
230
+ plan.fileField,
231
+ plan.filePath,
232
+ plan.fileName,
233
+ ),
239
234
  );
240
235
  const messageId =
241
236
  result && typeof result === "object" &&
@@ -964,21 +959,19 @@ export async function sendQueuedTelegramOutboundAttachments(
964
959
  const isPhoto = isTelegramOutboundPhotoAttachmentPath(attachment.path);
965
960
  const method = isPhoto ? "sendPhoto" : "sendDocument";
966
961
  const fieldName = isPhoto ? "photo" : "document";
967
- const replyParameters = buildTelegramMultipartReplyParameters(
968
- turn.chatId,
969
- turn.replyToMessageId,
970
- turn.target,
971
- );
972
- await deps.sendMultipart(
973
- method,
974
- {
975
- chat_id: String(turn.chatId),
976
- ...(replyParameters ? { reply_parameters: replyParameters } : {}),
977
- ...getTelegramMultipartTargetFields(turn.target),
978
- },
979
- fieldName,
980
- attachment.path,
981
- attachment.fileName,
962
+ await withTelegramReplyParameters(
963
+ turn.chatId, turn.replyToMessageId, turn.target,
964
+ (replyParameters) => deps.sendMultipart(
965
+ method,
966
+ {
967
+ chat_id: String(turn.chatId),
968
+ ...(replyParameters ? { reply_parameters: JSON.stringify(replyParameters) } : {}),
969
+ ...getTelegramMultipartTargetFields(turn.target),
970
+ },
971
+ fieldName,
972
+ attachment.path,
973
+ attachment.fileName,
974
+ ),
982
975
  );
983
976
  } catch (error) {
984
977
  if (deps.isDeliveryActive?.() === false) return;
@@ -8,7 +8,7 @@ import { unlink } from "node:fs/promises";
8
8
  import { basename, extname } from "node:path";
9
9
 
10
10
  import { assertTelegramInlineKeyboardCallbackData } from "./keyboard.ts";
11
- import { buildTelegramMultipartReplyParameters } from "./replies.ts";
11
+ import { withTelegramReplyParameters } from "./replies.ts";
12
12
  import {
13
13
  getTelegramTargetThreadParams,
14
14
  type TelegramTarget,
@@ -80,17 +80,6 @@ export interface TelegramVoiceReplySenderPorts<THandler = unknown> {
80
80
  getProgrammaticVoiceHandlers?: () => TelegramOutboundProgrammaticVoiceHandler[];
81
81
  }
82
82
 
83
- function buildVoiceReplyParameters(
84
- chatId: number,
85
- replyToPrompt: boolean | undefined,
86
- replyToMessageId: number | undefined,
87
- target?: TelegramTarget,
88
- ): string | undefined {
89
- if (replyToPrompt === false || replyToMessageId === undefined)
90
- return undefined;
91
- return buildTelegramMultipartReplyParameters(chatId, replyToMessageId, target);
92
- }
93
-
94
83
  async function ensureTelegramVoiceFileFormat(
95
84
  filePath: string,
96
85
  ): Promise<string> {
@@ -131,36 +120,33 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
131
120
  if (deps.isDeliveryActive?.() === false) return;
132
121
  await sendVoiceChatAction(deps, turn.chatId);
133
122
  if (deps.isDeliveryActive?.() === false) return;
134
- const replyParameters = buildVoiceReplyParameters(
135
- turn.chatId,
136
- options?.replyToPrompt,
137
- turn.replyToMessageId,
138
- turn.target,
139
- );
140
- await deps.sendMultipart(
141
- "sendVoice",
142
- {
143
- chat_id: String(turn.chatId),
144
- ...(replyParameters ? { reply_parameters: replyParameters } : {}),
145
- ...(turn.target
146
- ? Object.fromEntries(
147
- Object.entries(getTelegramTargetThreadParams(turn.target)).map(
148
- ([key, value]) => [key, String(value)],
149
- ),
150
- )
151
- : {}),
152
- ...(options?.replyMarkup !== undefined && options.replyMarkup !== null
153
- ? {
154
- reply_markup:
155
- typeof options.replyMarkup === "string"
156
- ? options.replyMarkup
157
- : JSON.stringify(options.replyMarkup),
158
- }
159
- : {}),
160
- },
161
- "voice",
162
- voiceFilePath,
163
- basename(voiceFilePath),
123
+ await withTelegramReplyParameters(
124
+ turn.chatId, options?.replyToPrompt === false ? undefined : turn.replyToMessageId, turn.target,
125
+ (replyParameters) => deps.sendMultipart(
126
+ "sendVoice",
127
+ {
128
+ chat_id: String(turn.chatId),
129
+ ...(replyParameters ? { reply_parameters: JSON.stringify(replyParameters) } : {}),
130
+ ...(turn.target
131
+ ? Object.fromEntries(
132
+ Object.entries(getTelegramTargetThreadParams(turn.target)).map(
133
+ ([key, value]) => [key, String(value)],
134
+ ),
135
+ )
136
+ : {}),
137
+ ...(options?.replyMarkup !== undefined && options.replyMarkup !== null
138
+ ? {
139
+ reply_markup:
140
+ typeof options.replyMarkup === "string"
141
+ ? options.replyMarkup
142
+ : JSON.stringify(options.replyMarkup),
143
+ }
144
+ : {}),
145
+ },
146
+ "voice",
147
+ voiceFilePath,
148
+ basename(voiceFilePath),
149
+ ),
164
150
  );
165
151
  };
166
152
 
@@ -11,6 +11,7 @@ import { join } from "node:path";
11
11
  import type { TelegramAssistantSegmentEvent } from "./activity.ts";
12
12
  import { resolveTelegramTempDir } from "./paths.ts";
13
13
  import * as Replies from "./replies.ts";
14
+ import type { TelegramPreparedPreviewDelivery } from "./preview.ts";
14
15
  import { isTelegramApiCommitUnknownError } from "./telegram-api.ts";
15
16
  import type {
16
17
  TelegramEditMessageTextBody,
@@ -249,12 +250,8 @@ export interface TelegramOutboundTextPreviewRuntimeDeps<
249
250
  > {
250
251
  execCommand: TelegramVoiceReplySenderDeps["execCommand"];
251
252
  getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
252
- finalizeMarkdownPreview: (
253
- chatId: number,
254
- markdown: string,
255
- replyToMessageId: number,
256
- options?: { replyMarkup?: TReplyMarkup },
257
- ) => Promise<boolean>;
253
+ finalizeMarkdownPreview: TelegramPreparedPreviewDelivery<TReplyMarkup>["finalizeMarkdownPreview"];
254
+ preparePreviewDelivery?: (isDeliveryActive: () => boolean) => TelegramPreparedPreviewDelivery<TReplyMarkup>;
258
255
  cwd?: string;
259
256
  recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
260
257
  }
@@ -729,12 +726,13 @@ export function createTelegramOutboundTextPreviewRuntime<
729
726
  TReplyMarkup = unknown,
730
727
  >(
731
728
  deps: TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
732
- ): Pick<
733
- TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
734
- "finalizeMarkdownPreview"
735
- > {
736
- return {
737
- finalizeMarkdownPreview: async (
729
+ ): {
730
+ finalizeMarkdownPreview: TelegramPreparedPreviewDelivery<TReplyMarkup>["finalizeMarkdownPreview"];
731
+ preparePreviewDelivery: (isDeliveryActive: () => boolean) => TelegramPreparedPreviewDelivery<TReplyMarkup> | undefined;
732
+ } {
733
+ const wrap = (
734
+ finalize: TelegramPreparedPreviewDelivery<TReplyMarkup>["finalizeMarkdownPreview"],
735
+ ): TelegramPreparedPreviewDelivery<TReplyMarkup>["finalizeMarkdownPreview"] => async (
738
736
  chatId,
739
737
  markdown,
740
738
  replyToMessageId,
@@ -747,7 +745,7 @@ export function createTelegramOutboundTextPreviewRuntime<
747
745
  recordRuntimeEvent: deps.recordRuntimeEvent,
748
746
  replyMarkup: options?.replyMarkup,
749
747
  });
750
- return deps.finalizeMarkdownPreview(
748
+ return finalize(
751
749
  chatId,
752
750
  transformed.text,
753
751
  replyToMessageId,
@@ -758,6 +756,12 @@ export function createTelegramOutboundTextPreviewRuntime<
758
756
  : {}),
759
757
  },
760
758
  );
759
+ };
760
+ return {
761
+ finalizeMarkdownPreview: wrap(deps.finalizeMarkdownPreview),
762
+ preparePreviewDelivery(isDeliveryActive) {
763
+ const prepared = deps.preparePreviewDelivery?.(isDeliveryActive);
764
+ return prepared ? { ...prepared, finalizeMarkdownPreview: wrap(prepared.finalizeMarkdownPreview) } : undefined;
761
765
  },
762
766
  };
763
767
  }
@@ -1018,7 +1022,7 @@ export function createTelegramAssistantOutputSender<
1018
1022
  };
1019
1023
  await outboundRuntime.sendMarkdownReply(
1020
1024
  target.chatId,
1021
- undefined,
1025
+ event.source === "telegram" ? event.replyToMessageId : undefined,
1022
1026
  buttonReply.markdown,
1023
1027
  {
1024
1028
  target,
@@ -5,7 +5,8 @@
5
5
  */
6
6
 
7
7
  import { normalizeTelegramNativeMarkdown } from "./replies.ts";
8
- import { stripTelegramCommentMarkupForPreview } from "./outbound.ts";
8
+ import type { TelegramAssistantOutputPreparation } from "./activity.ts";
9
+ import { stripTelegramCommentMarkupForPreview } from "./outbound-markup.ts";
9
10
  import {
10
11
  getTelegramTargetThreadParams,
11
12
  type TelegramTarget,
@@ -14,6 +15,7 @@ import { shouldSuppressPreviewForVoice } from "./voice.ts";
14
15
 
15
16
  const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
16
17
  const TELEGRAM_DRAFT_PREVIEW_MAX_CHARS = 4096;
18
+ const TELEGRAM_DRAFT_INTERVAL_MS = 2_000;
17
19
 
18
20
  export type TelegramDraftSupport = "unknown" | "supported";
19
21
 
@@ -27,6 +29,11 @@ export interface TelegramPreviewState {
27
29
  export interface TelegramPreviewRuntimeState extends TelegramPreviewState {
28
30
  flushPromise?: Promise<void>;
29
31
  flushRequested?: boolean;
32
+ precedingFlush?: Promise<void>;
33
+ publicationPromise?: Promise<void>;
34
+ sealed?: boolean;
35
+ nextDraftAt?: number;
36
+ flushTimer?: ReturnType<typeof setTimeout>;
30
37
  }
31
38
 
32
39
  export type TelegramPreviewReplyMarkup = unknown;
@@ -35,6 +42,7 @@ export interface TelegramPreviewRuntimeDeps {
35
42
  getState: () => TelegramPreviewRuntimeState | undefined;
36
43
  setState: (state: TelegramPreviewRuntimeState | undefined) => void;
37
44
  maxMessageLength: number;
45
+ minDraftIntervalMs?: number;
38
46
  getDraftSupport: () => TelegramDraftSupport;
39
47
  setDraftSupport: (support: TelegramDraftSupport) => void;
40
48
  allocateDraftId: () => number;
@@ -64,23 +72,13 @@ export interface TelegramPreviewActiveTurn {
64
72
  voiceReplyRequired?: boolean;
65
73
  }
66
74
 
67
- export interface TelegramAssistantMessagePreviewStartDeps<
68
- TMessage,
69
- TReplyMarkup = TelegramPreviewReplyMarkup,
70
- > {
75
+ export interface TelegramAssistantMessagePreviewStartDeps<TMessage> {
71
76
  getActiveTurn: () => TelegramPreviewActiveTurn | undefined;
72
77
  isAssistantMessage: (message: TMessage) => boolean;
73
78
  getState: () => TelegramPreviewRuntimeState | undefined;
74
79
  setState: (state: TelegramPreviewRuntimeState | undefined) => void;
75
80
  createPreviewState: () => TelegramPreviewRuntimeState;
76
81
  canSend?: () => boolean;
77
- finalizePreview: (chatId: number) => Promise<boolean>;
78
- finalizeMarkdownPreview: (
79
- chatId: number,
80
- markdown: string,
81
- replyToMessageId?: number,
82
- options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
83
- ) => Promise<boolean>;
84
82
  }
85
83
 
86
84
  export interface TelegramAssistantMessagePreviewUpdateDeps<TMessage> {
@@ -97,10 +95,7 @@ export interface TelegramAssistantMessagePreviewUpdateDeps<TMessage> {
97
95
  ) => void;
98
96
  }
99
97
 
100
- export type TelegramAssistantMessagePreviewHookDeps<
101
- TMessage,
102
- TReplyMarkup = TelegramPreviewReplyMarkup,
103
- > = TelegramAssistantMessagePreviewStartDeps<TMessage, TReplyMarkup> &
98
+ export type TelegramAssistantMessagePreviewHookDeps<TMessage> = TelegramAssistantMessagePreviewStartDeps<TMessage> &
104
99
  TelegramAssistantMessagePreviewUpdateDeps<TMessage>;
105
100
 
106
101
  export interface TelegramAssistantMessagePreviewHookEvent<TMessage> {
@@ -140,6 +135,8 @@ export interface TelegramPreviewControllerDeps {
140
135
  }
141
136
 
142
137
  export interface TelegramPreviewController {
138
+ seal: () => void;
139
+ preparePublication: () => TelegramAssistantOutputPreparation | undefined;
143
140
  prepareClear: (
144
141
  chatId: number,
145
142
  options?: { target?: TelegramTarget; isDeliveryActive?: () => boolean },
@@ -201,11 +198,18 @@ export interface TelegramAssistantPreviewRuntimeDeps<
201
198
  ) => Promise<number | undefined>;
202
199
  }
203
200
 
201
+ export interface TelegramPreparedPreviewDelivery<TReplyMarkup = unknown> {
202
+ clearPreview: TelegramPreviewController["clear"];
203
+ setPreviewPendingText: TelegramPreviewController["setPendingText"];
204
+ finalizeMarkdownPreview: ReturnType<typeof createTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>>;
205
+ }
206
+
204
207
  export type TelegramAssistantPreviewRuntime<
205
208
  TMessage,
206
209
  TReplyMarkup = TelegramPreviewReplyMarkup,
207
210
  > = TelegramPreviewController &
208
211
  TelegramAssistantMessagePreviewHooks<TMessage> & {
212
+ prepareDelivery: (isDeliveryActive: () => boolean) => TelegramPreparedPreviewDelivery<TReplyMarkup>;
209
213
  finalizeMarkdown: (
210
214
  chatId: number,
211
215
  markdown: string,
@@ -214,6 +218,14 @@ export type TelegramAssistantPreviewRuntime<
214
218
  ) => Promise<boolean>;
215
219
  };
216
220
 
221
+ function sealTelegramPreviewState(state: TelegramPreviewRuntimeState | undefined): void {
222
+ if (!state) return;
223
+ state.sealed = true;
224
+ state.flushRequested = false;
225
+ if (state.flushTimer) clearTimeout(state.flushTimer);
226
+ state.flushTimer = undefined;
227
+ }
228
+
217
229
  export function createTelegramNativeMarkdownPreviewFinalizer<
218
230
  TReplyMarkup,
219
231
  >(deps: {
@@ -223,6 +235,7 @@ export function createTelegramNativeMarkdownPreviewFinalizer<
223
235
  options?: { awaitFlush?: boolean; target?: TelegramTarget },
224
236
  ) => Promise<void>;
225
237
  discard?: () => void;
238
+ isDeliveryActive?: () => boolean;
226
239
  sendMarkdownReply: (
227
240
  chatId: number,
228
241
  replyToMessageId: number | undefined,
@@ -235,14 +248,21 @@ export function createTelegramNativeMarkdownPreviewFinalizer<
235
248
  replyToMessageId?: number,
236
249
  options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
237
250
  ) => Promise<boolean> {
251
+ return (...args) => prepareTelegramNativeMarkdownPreviewFinalizer(deps)(...args);
252
+ }
253
+
254
+ function prepareTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>(
255
+ deps: Parameters<typeof createTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>>[0],
256
+ ): ReturnType<typeof createTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>> {
257
+ const state = deps.getState();
258
+ sealTelegramPreviewState(state);
259
+ const inFlight = state?.flushPromise ?? state?.precedingFlush;
238
260
  return async (chatId, markdown, replyToMessageId, options) => {
239
- const state = deps.getState();
240
- if (state?.flushPromise) {
241
- await state.flushPromise.catch(() => {});
242
- if (deps.getState() !== state) return false;
243
- }
261
+ if (deps.isDeliveryActive?.() === false) return false;
262
+ await inFlight?.catch(() => {});
263
+ if (deps.getState() !== state || deps.isDeliveryActive?.() === false) return false;
244
264
  await deps.sendMarkdownReply(chatId, replyToMessageId, markdown, options);
245
- if (deps.getState() === state) deps.discard?.();
265
+ if (deps.getState() === state && deps.isDeliveryActive?.() !== false) deps.discard?.();
246
266
  return true;
247
267
  };
248
268
  }
@@ -254,15 +274,28 @@ export function createTelegramAssistantPreviewRuntime<
254
274
  deps: TelegramAssistantPreviewRuntimeDeps<TMessage, TReplyMarkup>,
255
275
  ): TelegramAssistantPreviewRuntime<TMessage, TReplyMarkup> {
256
276
  const controller = createTelegramPreviewControllerRuntime(deps);
257
- const finalizeMarkdownPreview = createTelegramNativeMarkdownPreviewFinalizer({
277
+ const finalizerDeps = {
258
278
  getState: controller.getState,
259
279
  clear: controller.clear,
260
280
  discard: () => controller.setState(undefined),
261
281
  sendMarkdownReply: deps.sendMarkdownReply,
262
- });
282
+ };
263
283
  return {
264
284
  ...controller,
265
- finalizeMarkdown: finalizeMarkdownPreview,
285
+ finalizeMarkdown: createTelegramNativeMarkdownPreviewFinalizer(finalizerDeps),
286
+ prepareDelivery(isDeliveryActive) {
287
+ const state = controller.getState();
288
+ return {
289
+ setPreviewPendingText(text) {
290
+ if (controller.getState() === state && isDeliveryActive()) controller.setPendingText(text);
291
+ },
292
+ clearPreview: async (chatId, options) => {
293
+ if (controller.getState() !== state || !isDeliveryActive()) return;
294
+ await controller.prepareClear(chatId, { ...options, isDeliveryActive })();
295
+ },
296
+ finalizeMarkdownPreview: prepareTelegramNativeMarkdownPreviewFinalizer({ ...finalizerDeps, isDeliveryActive }),
297
+ };
298
+ },
266
299
  ...createTelegramAssistantMessagePreviewHooks({
267
300
  getActiveTurn: deps.getActiveTurn,
268
301
  isAssistantMessage: deps.isAssistantMessage,
@@ -270,8 +303,6 @@ export function createTelegramAssistantPreviewRuntime<
270
303
  setState: controller.setState,
271
304
  createPreviewState: controller.createState,
272
305
  canSend: deps.canSend,
273
- finalizePreview: controller.finalize,
274
- finalizeMarkdownPreview,
275
306
  getMessageText: deps.getMessageText,
276
307
  schedulePreviewFlush: controller.scheduleFlush,
277
308
  }),
@@ -288,14 +319,17 @@ export function createTelegramPreviewController(
288
319
  deps.maxMessageLength ?? TELEGRAM_DRAFT_PREVIEW_MAX_CHARS;
289
320
  let draftSupport = deps.initialDraftSupport ?? "unknown";
290
321
  let nextDraftId = 0;
322
+ const setState = (nextState: TelegramPreviewRuntimeState | undefined): void => {
323
+ if (state !== nextState) sealTelegramPreviewState(state);
324
+ state = nextState;
325
+ };
291
326
  const getRuntimeDeps = (
292
327
  operationGeneration = generation,
293
328
  ): TelegramPreviewRuntimeDeps => ({
294
329
  getState: () => state,
295
- setState: (nextState) => {
296
- state = nextState;
297
- },
330
+ setState,
298
331
  maxMessageLength,
332
+ minDraftIntervalMs: TELEGRAM_DRAFT_INTERVAL_MS,
299
333
  getDraftSupport: () => draftSupport,
300
334
  setDraftSupport: (support) => {
301
335
  draftSupport = support;
@@ -311,20 +345,27 @@ export function createTelegramPreviewController(
311
345
  });
312
346
  return {
313
347
  getState: () => state,
314
- setState: (nextState) => {
315
- state = nextState;
316
- },
348
+ setState,
317
349
  setPendingText: (text) => {
318
350
  if (state) state.pendingText = text;
319
351
  },
320
352
  createState: () => createTelegramPreviewRuntimeState(),
321
353
  resetState: () => {
322
354
  generation += 1;
323
- state = createTelegramPreviewRuntimeState();
355
+ setState({ ...createTelegramPreviewRuntimeState(), nextDraftAt: state?.nextDraftAt });
324
356
  },
325
357
  invalidate: () => {
326
358
  generation += 1;
327
- state = undefined;
359
+ setState(undefined);
360
+ },
361
+ seal: () => sealTelegramPreviewState(state),
362
+ preparePublication: () => {
363
+ if (!state) return undefined;
364
+ sealTelegramPreviewState(state);
365
+ const prior = state.publicationPromise ?? state.flushPromise ?? state.precedingFlush;
366
+ let settle!: () => void;
367
+ state.publicationPromise = new Promise<void>((resolve) => { settle = resolve; });
368
+ return { wait: async () => { await prior?.catch(() => {}); }, settle };
328
369
  },
329
370
  prepareClear: (chatId, options) => {
330
371
  const admittedState = state;
@@ -350,11 +391,8 @@ export function createTelegramPreviewController(
350
391
  };
351
392
  }
352
393
 
353
- export function createTelegramAssistantMessagePreviewHooks<
354
- TMessage,
355
- TReplyMarkup = TelegramPreviewReplyMarkup,
356
- >(
357
- deps: TelegramAssistantMessagePreviewHookDeps<TMessage, TReplyMarkup>,
394
+ export function createTelegramAssistantMessagePreviewHooks<TMessage>(
395
+ deps: TelegramAssistantMessagePreviewHookDeps<TMessage>,
358
396
  ): TelegramAssistantMessagePreviewHooks<TMessage> {
359
397
  return {
360
398
  onMessageStart: async (
@@ -370,12 +408,9 @@ export function createTelegramAssistantMessagePreviewHooks<
370
408
  };
371
409
  }
372
410
 
373
- export async function handleTelegramAssistantMessagePreviewStart<
374
- TMessage,
375
- TReplyMarkup = TelegramPreviewReplyMarkup,
376
- >(
411
+ export async function handleTelegramAssistantMessagePreviewStart<TMessage>(
377
412
  message: TMessage,
378
- deps: TelegramAssistantMessagePreviewStartDeps<TMessage, TReplyMarkup>,
413
+ deps: TelegramAssistantMessagePreviewStartDeps<TMessage>,
379
414
  ): Promise<void> {
380
415
  const turn = deps.getActiveTurn();
381
416
  if (!turn || !deps.isAssistantMessage(message)) return;
@@ -388,26 +423,13 @@ export async function handleTelegramAssistantMessagePreviewStart<
388
423
  return;
389
424
  }
390
425
  const state = deps.getState();
391
- if (
392
- state &&
393
- (state.pendingText.trim().length > 0 ||
394
- state.lastSentText.trim().length > 0)
395
- ) {
396
- const previousText = state.pendingText.trim();
397
- if (previousText.length > 0) {
398
- await deps.finalizeMarkdownPreview(
399
- turn.chatId,
400
- previousText,
401
- turn.replyToMessageId,
402
- {
403
- target: turn.target,
404
- },
405
- );
406
- } else {
407
- await deps.finalizePreview(turn.chatId);
408
- }
409
- }
410
- deps.setState(deps.createPreviewState());
426
+ sealTelegramPreviewState(state);
427
+ const next = deps.createPreviewState();
428
+ // Carry the previous delivery boundary; permanent text remains with its publication owner.
429
+ next.draftId = state?.draftId;
430
+ next.nextDraftAt = state?.nextDraftAt;
431
+ next.precedingFlush = state?.publicationPromise ?? state?.flushPromise ?? state?.precedingFlush;
432
+ deps.setState(next);
411
433
  }
412
434
 
413
435
  export async function handleTelegramAssistantMessagePreviewUpdate<TMessage>(
@@ -426,6 +448,7 @@ export async function handleTelegramAssistantMessagePreviewUpdate<TMessage>(
426
448
  state = deps.createPreviewState();
427
449
  deps.setState(state);
428
450
  }
451
+ if (state.sealed) return;
429
452
  state.pendingText = stripTelegramCommentMarkupForPreview(
430
453
  deps.getMessageText(message),
431
454
  );
@@ -473,9 +496,11 @@ export async function clearTelegramPreview(
473
496
  ): Promise<void> {
474
497
  const state = deps.getState();
475
498
  if (!state || options.isDeliveryActive?.() === false) return;
476
- if (state.flushPromise && options.awaitFlush !== false) {
499
+ sealTelegramPreviewState(state);
500
+ const inFlight = state.flushPromise ?? state.precedingFlush;
501
+ if (inFlight && options.awaitFlush !== false) {
477
502
  state.flushRequested = false;
478
- await state.flushPromise.catch(() => {});
503
+ await inFlight.catch(() => {});
479
504
  if (deps.getState() !== state) return;
480
505
  }
481
506
  if (options.isDeliveryActive?.() === false) return;
@@ -782,13 +807,15 @@ async function performTelegramPreviewFlush(
782
807
  ) {
783
808
  const draftId = state.draftId ?? deps.allocateDraftId();
784
809
  state.draftId = draftId;
810
+ state.nextDraftAt = Date.now() + (deps.minDraftIntervalMs ?? 0);
785
811
  try {
786
- await deps.sendDraft(
812
+ const delivered = await deps.sendDraft(
787
813
  chatId,
788
814
  draftId,
789
815
  normalizeTelegramNativeMarkdown(snapshot.text),
790
816
  { ...getTelegramTargetThreadParams(options.target ?? { chatId }) },
791
817
  );
818
+ if (delivered === false || deps.getState() !== state || deps.canSend?.() === false) return;
792
819
  deps.setDraftSupport("supported");
793
820
  state.mode = "draft";
794
821
  state.lastSentText = snapshot.text;
@@ -810,15 +837,33 @@ export async function flushTelegramPreview(
810
837
  options: { target?: TelegramTarget } = {},
811
838
  ): Promise<void> {
812
839
  const state = deps.getState();
813
- if (!state) return;
840
+ if (!state || state.sealed) return;
814
841
  if (state.flushPromise) {
815
842
  state.flushRequested = true;
816
843
  await state.flushPromise;
817
844
  return;
818
845
  }
819
846
  state.flushPromise = (async () => {
847
+ if (state.precedingFlush) {
848
+ await state.precedingFlush.catch(() => {});
849
+ state.precedingFlush = undefined;
850
+ if (deps.getState() !== state || state.sealed) return;
851
+ }
820
852
  do {
821
853
  state.flushRequested = false;
854
+ const delay = (state.nextDraftAt ?? 0) - Date.now();
855
+ if (delay > 0) {
856
+ if (!state.flushTimer) {
857
+ state.flushTimer = setTimeout(() => {
858
+ state.flushTimer = undefined;
859
+ if (deps.getState() === state && !state.sealed) void flushTelegramPreview(chatId, deps, options);
860
+ }, delay);
861
+ state.flushTimer.unref?.();
862
+ }
863
+ break;
864
+ }
865
+ if (state.flushTimer) clearTimeout(state.flushTimer);
866
+ state.flushTimer = undefined;
822
867
  try {
823
868
  await performTelegramPreviewFlush(chatId, state, deps, options);
824
869
  } catch (error) {
@@ -829,7 +874,7 @@ export async function flushTelegramPreview(
829
874
  });
830
875
  break;
831
876
  }
832
- } while (deps.getState() === state && state.flushRequested);
877
+ } while (deps.getState() === state && !state.sealed && state.flushRequested);
833
878
  })();
834
879
  try {
835
880
  await state.flushPromise;