@llblab/pi-telegram 0.54.4 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/AGENTS.md +4 -2
  2. package/BACKLOG.md +1 -1
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +5 -3
  5. package/dist/lib/activity.d.ts +5 -1
  6. package/dist/lib/activity.js +31 -2
  7. package/dist/lib/bindings.d.ts +3 -1
  8. package/dist/lib/bindings.js +8 -2
  9. package/dist/lib/bus-api.d.ts +2 -2
  10. package/dist/lib/bus-api.js +7 -2
  11. package/dist/lib/bus-follower.d.ts +49 -3
  12. package/dist/lib/bus-follower.js +313 -146
  13. package/dist/lib/bus-leader.d.ts +3 -0
  14. package/dist/lib/bus-leader.js +99 -18
  15. package/dist/lib/bus.d.ts +5 -3
  16. package/dist/lib/bus.js +13 -10
  17. package/dist/lib/delivery.d.ts +4 -0
  18. package/dist/lib/delivery.js +48 -15
  19. package/dist/lib/extension.js +48 -6
  20. package/dist/lib/lifecycle.d.ts +68 -0
  21. package/dist/lib/lifecycle.js +178 -58
  22. package/dist/lib/machine.d.ts +23 -0
  23. package/dist/lib/machine.js +37 -0
  24. package/dist/lib/media.d.ts +114 -28
  25. package/dist/lib/media.js +292 -119
  26. package/dist/lib/model.d.ts +54 -15
  27. package/dist/lib/model.js +98 -53
  28. package/dist/lib/polling.js +2 -0
  29. package/dist/lib/preview.d.ts +128 -113
  30. package/dist/lib/preview.js +448 -366
  31. package/dist/lib/queue.d.ts +2 -2
  32. package/dist/lib/queue.js +3 -3
  33. package/dist/lib/routing.d.ts +6 -1
  34. package/dist/lib/routing.js +334 -210
  35. package/dist/lib/runtime.d.ts +77 -11
  36. package/dist/lib/runtime.js +174 -92
  37. package/dist/lib/sections.js +0 -1
  38. package/dist/lib/telegram-api.d.ts +11 -9
  39. package/dist/lib/telegram-api.js +53 -4
  40. package/dist/lib/text-groups.d.ts +0 -26
  41. package/dist/lib/text-groups.js +51 -98
  42. package/dist/lib/thread-display.js +14 -6
  43. package/dist/lib/threads.d.ts +18 -5
  44. package/dist/lib/threads.js +107 -30
  45. package/dist/lib/turns.d.ts +2 -0
  46. package/dist/lib/turns.js +6 -1
  47. package/dist/lib/updates.d.ts +11 -4
  48. package/dist/lib/updates.js +54 -19
  49. package/dist/package.json +1 -1
  50. package/docs/activity.md +1 -1
  51. package/docs/architecture.md +12 -11
  52. package/docs/delivery.md +7 -5
  53. package/docs/multi-instance-bus.md +18 -8
  54. package/docs/public-api.md +2 -2
  55. package/docs/ui-style.md +2 -2
  56. package/lib/activity.ts +29 -5
  57. package/lib/bindings.ts +9 -1
  58. package/lib/bus-api.ts +11 -3
  59. package/lib/bus-follower.ts +417 -203
  60. package/lib/bus-leader.ts +88 -14
  61. package/lib/bus.ts +17 -15
  62. package/lib/delivery.ts +47 -19
  63. package/lib/extension.ts +51 -9
  64. package/lib/lifecycle.ts +229 -55
  65. package/lib/machine.ts +60 -0
  66. package/lib/media.ts +438 -172
  67. package/lib/model.ts +169 -74
  68. package/lib/polling.ts +2 -0
  69. package/lib/preview.ts +715 -566
  70. package/lib/queue.ts +5 -5
  71. package/lib/routing.ts +325 -238
  72. package/lib/runtime.ts +260 -104
  73. package/lib/sections.ts +0 -2
  74. package/lib/telegram-api.ts +61 -8
  75. package/lib/text-groups.ts +75 -136
  76. package/lib/thread-display.ts +15 -4
  77. package/lib/threads.ts +120 -36
  78. package/lib/turns.ts +8 -1
  79. package/lib/updates.ts +58 -21
  80. package/package.json +1 -1
@@ -2,7 +2,9 @@
2
2
  * Telegram preview streaming helpers
3
3
  * Zones: telegram outbound, native rich markdown drafts
4
4
  * Owns safe draft preview selection, runtime updates, and preview finalization
5
+ * The streaming state machine is a pure transition function driven by one imperative shell per runtime.
5
6
  */
7
+ import * as Machine from "./machine.js";
6
8
  import { stripTelegramCommentMarkupForPreview } from "./outbound-markup.js";
7
9
  import { isTelegramMarkdownFenceClosing, normalizeTelegramNativeMarkdown, parseTelegramMarkdownFenceOpening, } from "./replies.js";
8
10
  import { getTelegramTargetThreadParams, } from "./target.js";
@@ -13,188 +15,412 @@ const TELEGRAM_DRAFT_PREVIEW_MAX_CHARS = 4096;
13
15
  // segment holds its first frame for one full interval so the opening frame is
14
16
  // an accumulated passage rather than a single streamed word.
15
17
  const TELEGRAM_DRAFT_INTERVAL_MS = 2_000;
16
- export function createTelegramPreviewControllerRuntime(deps) {
17
- return createTelegramPreviewController({
18
- getDefaultReplyToMessageId: deps.getDefaultReplyToMessageId,
19
- maxMessageLength: deps.maxMessageLength,
20
- initialDraftSupport: deps.initialDraftSupport,
21
- sendDraft: deps.sendDraft,
22
- canSend: deps.canSend,
23
- maxDraftId: deps.maxDraftId,
24
- recordRuntimeEvent: deps.recordRuntimeEvent,
25
- });
26
- }
27
- function sealTelegramPreviewState(state) {
28
- if (!state)
29
- return;
30
- state.sealed = true;
31
- state.flushRequested = false;
32
- if (state.flushTimer)
33
- clearTimeout(state.flushTimer);
34
- state.flushTimer = undefined;
18
+ export function createTelegramPreviewMachineState() {
19
+ return {
20
+ segment: null,
21
+ generation: 0,
22
+ nextSegmentId: 0,
23
+ nextDraftId: 0,
24
+ nextDraftAt: 0,
25
+ inFlight: null,
26
+ pending: null,
27
+ publications: [],
28
+ timerSegmentId: null,
29
+ };
35
30
  }
36
- export function createTelegramNativeMarkdownPreviewFinalizer(deps) {
37
- return (...args) => prepareTelegramNativeMarkdownPreviewFinalizer(deps)(...args);
31
+ const step = (state, ...effects) => ({ state, effects });
32
+ /** Drops the armed throttle timer, if any; every caller replaces or seals its segment. */
33
+ const withoutTimer = (state) => state.timerSegmentId === null
34
+ ? step(state)
35
+ : step({ ...state, timerSegmentId: null }, { type: "cancelTimer" });
36
+ /** A replaced segment takes its pending request and its throttle timer with it. */
37
+ function replaceSegment(state, segment) {
38
+ return withoutTimer({ ...state, segment, pending: null });
38
39
  }
39
- function prepareTelegramNativeMarkdownPreviewFinalizer(deps) {
40
- const state = deps.getState();
41
- sealTelegramPreviewState(state);
42
- const inFlight = state?.flushPromise ?? state?.precedingFlush;
43
- return async (chatId, markdown, replyToMessageId, options) => {
44
- if (deps.isDeliveryActive?.() === false)
45
- return false;
46
- await inFlight?.catch(() => { });
47
- if (deps.getState() !== state || deps.isDeliveryActive?.() === false)
48
- return false;
49
- await deps.sendMarkdownReply(chatId, replyToMessageId, markdown, options);
50
- if (deps.getState() === state && deps.isDeliveryActive?.() !== false)
51
- deps.discard?.();
52
- return true;
53
- };
40
+ function startSegment(state, carry = {}) {
41
+ const id = state.nextSegmentId + 1;
42
+ return replaceSegment({ ...state, nextSegmentId: id }, {
43
+ id,
44
+ mode: "draft",
45
+ pendingText: "",
46
+ lastSentText: "",
47
+ sealed: false,
48
+ ...carry,
49
+ });
54
50
  }
55
- export function createTelegramAssistantPreviewRuntime(deps) {
56
- const controller = createTelegramPreviewControllerRuntime(deps);
57
- const finalizerDeps = {
58
- getState: controller.getState,
59
- clear: controller.clear,
60
- discard: () => controller.setState(undefined),
61
- sendMarkdownReply: deps.sendMarkdownReply,
62
- };
51
+ /** One flush attempt for the current segment: gate, throttle, safe snapshot and draft id. */
52
+ function attemptFlush(state, request, clock, config) {
53
+ const segment = state.segment;
54
+ if (!segment || segment.sealed)
55
+ return step(state);
56
+ if (!clock.canSend)
57
+ return step(state, { type: "clearClosedGate", request });
58
+ const delay = state.nextDraftAt - clock.now;
59
+ if (delay > 0) {
60
+ if (state.timerSegmentId !== null)
61
+ return step(state);
62
+ return step({ ...state, timerSegmentId: segment.id }, { type: "armTimer", delay, request, segmentId: segment.id });
63
+ }
64
+ const idle = withoutTimer(state);
65
+ const snapshot = buildTelegramNativeMarkdownPreviewSnapshot(segment, config.maxMessageLength);
66
+ if (!snapshot)
67
+ return idle;
68
+ const draftId = segment.draftId ??
69
+ allocateTelegramDraftId(state.nextDraftId, config.maxDraftId);
63
70
  return {
64
- ...controller,
65
- finalizeMarkdown: createTelegramNativeMarkdownPreviewFinalizer(finalizerDeps),
66
- prepareDelivery(isDeliveryActive) {
67
- const state = controller.getState();
68
- return {
69
- setPreviewPendingText(text) {
70
- if (controller.getState() === state && isDeliveryActive())
71
- controller.setPendingText(text);
72
- },
73
- clearPreview: async (chatId, options) => {
74
- if (controller.getState() !== state || !isDeliveryActive())
75
- return;
76
- await controller.prepareClear(chatId, {
77
- ...options,
78
- isDeliveryActive,
79
- })();
80
- },
81
- finalizeMarkdownPreview: prepareTelegramNativeMarkdownPreviewFinalizer({
82
- ...finalizerDeps,
83
- isDeliveryActive,
84
- }),
85
- };
71
+ state: {
72
+ ...idle.state,
73
+ segment: { ...segment, draftId },
74
+ nextDraftId: segment.draftId === undefined ? draftId : state.nextDraftId,
75
+ nextDraftAt: clock.now + config.minDraftIntervalMs,
76
+ inFlight: segment.id,
86
77
  },
87
- ...createTelegramAssistantMessagePreviewHooks({
88
- getActiveTurn: deps.getActiveTurn,
89
- isAssistantMessage: deps.isAssistantMessage,
90
- getState: controller.getState,
91
- setState: controller.setState,
92
- createPreviewState: controller.createState,
93
- canSend: deps.canSend,
94
- getMessageText: deps.getMessageText,
95
- minDraftIntervalMs: TELEGRAM_DRAFT_INTERVAL_MS,
96
- schedulePreviewFlush: controller.scheduleFlush,
97
- }),
78
+ effects: [
79
+ ...idle.effects,
80
+ {
81
+ type: "sendDraft",
82
+ request,
83
+ draftId,
84
+ text: snapshot.text,
85
+ segmentId: segment.id,
86
+ generation: state.generation,
87
+ },
88
+ ],
98
89
  };
99
90
  }
100
- export function createTelegramPreviewController(deps) {
101
- let state;
102
- let generation = 0;
103
- const maxDraftId = deps.maxDraftId ?? TELEGRAM_DRAFT_ID_MAX;
104
- const maxMessageLength = deps.maxMessageLength ?? TELEGRAM_DRAFT_PREVIEW_MAX_CHARS;
105
- let draftSupport = deps.initialDraftSupport ?? "unknown";
106
- let nextDraftId = 0;
107
- const setState = (nextState) => {
108
- if (state !== nextState)
109
- sealTelegramPreviewState(state);
110
- state = nextState;
91
+ /** Single flight: a request during an in-flight draft or behind a publication waits as the one pending request. */
92
+ function requestFlush(state, request, clock, config) {
93
+ // Only a live, unsealed segment accepts flush requests, whatever the caller checked.
94
+ if (!state.segment || state.segment.sealed)
95
+ return step(state);
96
+ if (state.inFlight !== null || state.publications.length > 0)
97
+ return step({ ...state, pending: request });
98
+ return attemptFlush(state, request, clock, config);
99
+ }
100
+ /** A coalesced request runs once the in-flight draft and every open publication have settled. */
101
+ function drainPending(state, clock, config) {
102
+ const request = state.pending;
103
+ if (!request || state.inFlight !== null || state.publications.length > 0)
104
+ return step(state);
105
+ return attemptFlush({ ...state, pending: null }, request, clock, config);
106
+ }
107
+ export function transitionTelegramPreview(state, event, config) {
108
+ switch (event.type) {
109
+ case "reset":
110
+ case "invalidated": {
111
+ const replacement = {
112
+ ...state,
113
+ generation: state.generation + 1,
114
+ inFlight: null,
115
+ publications: [],
116
+ nextDraftAt: 0,
117
+ };
118
+ return event.type === "reset"
119
+ ? startSegment(replacement)
120
+ : replaceSegment(replacement, null);
121
+ }
122
+ case "segmentStarted": {
123
+ // Carry the previous delivery boundary; permanent text remains with its publication owner.
124
+ const draftId = state.segment?.draftId;
125
+ return startSegment(state, draftId === undefined ? {} : { draftId });
126
+ }
127
+ case "segmentEnsured":
128
+ return state.segment ? step(state) : startSegment(state);
129
+ case "sealed": {
130
+ const segment = state.segment;
131
+ return withoutTimer({
132
+ ...state,
133
+ segment: segment && !segment.sealed ? { ...segment, sealed: true } : segment,
134
+ pending: null,
135
+ });
136
+ }
137
+ case "discarded":
138
+ return replaceSegment(state, null);
139
+ case "textSet":
140
+ return state.segment
141
+ ? step({
142
+ ...state,
143
+ segment: { ...state.segment, pendingText: event.text },
144
+ })
145
+ : step(state);
146
+ case "draftWindowOpened":
147
+ return step({
148
+ ...state,
149
+ nextDraftAt: Math.max(state.nextDraftAt, event.at),
150
+ });
151
+ case "flushRequested":
152
+ return requestFlush(state, event.request, event, config);
153
+ case "timerFired": {
154
+ if (state.timerSegmentId !== event.segmentId ||
155
+ state.segment?.id !== event.segmentId)
156
+ return step(state);
157
+ return requestFlush({ ...state, timerSegmentId: null }, event.request, event, config);
158
+ }
159
+ case "draftSettled": {
160
+ if (state.generation !== event.generation ||
161
+ state.inFlight !== event.segmentId)
162
+ return step(state);
163
+ const segment = state.segment;
164
+ // A delivered draft updates only the segment and generation that issued it.
165
+ const applies = event.text !== undefined &&
166
+ segment?.id === event.segmentId &&
167
+ event.canSend;
168
+ return drainPending({
169
+ ...state,
170
+ inFlight: null,
171
+ segment: applies && segment
172
+ ? { ...segment, lastSentText: event.text }
173
+ : segment,
174
+ }, event, config);
175
+ }
176
+ case "publicationOpened":
177
+ return step({
178
+ ...state,
179
+ publications: [...state.publications, event.id],
180
+ });
181
+ case "publicationSettled":
182
+ if (!state.publications.includes(event.id))
183
+ return step(state);
184
+ return drainPending({
185
+ ...state,
186
+ publications: state.publications.filter((id) => id !== event.id),
187
+ }, event, config);
188
+ }
189
+ }
190
+ /** True while a draft request is in flight, or, when publications block, a pending request waits behind one. */
191
+ export function isTelegramPreviewBusy(state, blockedByPublication = true) {
192
+ return (state.inFlight !== null ||
193
+ (blockedByPublication &&
194
+ state.pending !== null &&
195
+ state.publications.length > 0));
196
+ }
197
+ /**
198
+ * The imperative shell around `transitionTelegramPreview`: it samples the clock and send gate into events, runs
199
+ * effects, records every transport failure in one place and resolves promise-shaped ports through `waitFor`.
200
+ */
201
+ function createTelegramPreviewModel(deps) {
202
+ const config = {
203
+ maxDraftId: deps.maxDraftId ?? TELEGRAM_DRAFT_ID_MAX,
204
+ maxMessageLength: deps.maxMessageLength ?? TELEGRAM_DRAFT_PREVIEW_MAX_CHARS,
205
+ minDraftIntervalMs: deps.minDraftIntervalMs ?? TELEGRAM_DRAFT_INTERVAL_MS,
111
206
  };
112
- const getRuntimeDeps = (operationGeneration = generation) => ({
113
- getState: () => state,
114
- setState,
115
- maxMessageLength,
116
- minDraftIntervalMs: TELEGRAM_DRAFT_INTERVAL_MS,
117
- getDraftSupport: () => draftSupport,
118
- setDraftSupport: (support) => {
119
- draftSupport = support;
120
- },
121
- allocateDraftId: () => {
122
- nextDraftId = allocateTelegramDraftId(nextDraftId, maxDraftId);
123
- return nextDraftId;
124
- },
125
- sendDraft: deps.sendDraft,
126
- canSend: () => operationGeneration === generation && (deps.canSend?.() ?? true),
127
- recordRuntimeEvent: deps.recordRuntimeEvent,
207
+ let timer;
208
+ let publicationIds = 0;
209
+ const clock = () => ({
210
+ now: Date.now(),
211
+ canSend: deps.canSend?.() !== false,
128
212
  });
129
- return {
130
- getState: () => state,
131
- setState,
132
- setPendingText: (text) => {
133
- if (state)
134
- state.pendingText = text;
135
- },
136
- createState: () => createTelegramPreviewRuntimeState(),
137
- resetState: () => {
138
- generation += 1;
139
- setState({
140
- ...createTelegramPreviewRuntimeState(),
141
- nextDraftAt: state?.nextDraftAt,
213
+ const machine = Machine.createTelegramMachineRunner({
214
+ initial: createTelegramPreviewMachineState(),
215
+ transition: (state, event) => transitionTelegramPreview(state, event, config),
216
+ run: (effect) => run(effect),
217
+ });
218
+ const { dispatch, waitFor } = machine;
219
+ const whenIdle = () => {
220
+ const generation = machine.state.generation;
221
+ return waitFor((current) => current.generation !== generation || !isTelegramPreviewBusy(current));
222
+ };
223
+ /** Captures predecessors only: a later draft/publication must never extend the boundary that blocks it. */
224
+ const whenBoundarySettles = (includePublications = true) => {
225
+ const { generation, inFlight, publications } = machine.state;
226
+ return waitFor((current) => current.generation !== generation ||
227
+ ((inFlight === null || current.inFlight !== inFlight) &&
228
+ (!includePublications ||
229
+ publications.every((id) => !current.publications.includes(id)))));
230
+ };
231
+ const canSendFor = (generation) => machine.state.generation === generation && deps.canSend?.() !== false;
232
+ /** The one place preview transport failures become diagnostics; a failed call reports `false`. */
233
+ const callTransport = async (phase, request, draftId, text) => {
234
+ try {
235
+ return await deps.sendDraft(request.chatId, draftId, text, {
236
+ ...getTelegramTargetThreadParams(request.target ?? { chatId: request.chatId }),
142
237
  });
143
- },
144
- invalidate: () => {
145
- generation += 1;
146
- setState(undefined);
147
- },
148
- seal: () => sealTelegramPreviewState(state),
238
+ }
239
+ catch (error) {
240
+ deps.recordRuntimeEvent?.("preview", error, {
241
+ phase,
242
+ chatId: request.chatId,
243
+ draftId,
244
+ });
245
+ return false;
246
+ }
247
+ };
248
+ const sendDraft = async (effect) => {
249
+ const delivered = await callTransport("draft", effect.request, effect.draftId, normalizeTelegramNativeMarkdown(effect.text));
250
+ dispatch({
251
+ type: "draftSettled",
252
+ segmentId: effect.segmentId,
253
+ generation: effect.generation,
254
+ ...(delivered === false ? {} : { text: effect.text }),
255
+ ...clock(),
256
+ });
257
+ };
258
+ const run = (effect) => {
259
+ switch (effect.type) {
260
+ case "sendDraft":
261
+ void sendDraft(effect);
262
+ return;
263
+ case "armTimer":
264
+ timer = setTimeout(() => {
265
+ timer = undefined;
266
+ dispatch({
267
+ type: "timerFired",
268
+ request: effect.request,
269
+ segmentId: effect.segmentId,
270
+ ...clock(),
271
+ });
272
+ }, effect.delay);
273
+ timer.unref?.();
274
+ return;
275
+ case "cancelTimer":
276
+ if (timer)
277
+ clearTimeout(timer);
278
+ timer = undefined;
279
+ return;
280
+ case "clearClosedGate":
281
+ void clear(effect.request.chatId, {
282
+ awaitFlush: false,
283
+ target: effect.request.target,
284
+ });
285
+ return;
286
+ }
287
+ };
288
+ const seal = () => {
289
+ dispatch({ type: "sealed" });
290
+ };
291
+ const clear = async (chatId, options = {}, generation = machine.state.generation) => {
292
+ const segment = machine.state.segment;
293
+ if (!segment || options.isDeliveryActive?.() === false)
294
+ return;
295
+ seal();
296
+ if (machine.state.inFlight !== null && options.awaitFlush !== false) {
297
+ await whenBoundarySettles(false);
298
+ if (machine.state.segment?.id !== segment.id)
299
+ return;
300
+ }
301
+ if (options.isDeliveryActive?.() === false)
302
+ return;
303
+ const issued = machine.state.segment ?? segment;
304
+ dispatch({ type: "discarded" });
305
+ if (issued.draftId !== undefined && canSendFor(generation))
306
+ await callTransport("clear-draft", { chatId, target: options.target }, issued.draftId, undefined);
307
+ };
308
+ const flush = async (chatId, options = {}) => {
309
+ if (!machine.state.segment || machine.state.segment.sealed)
310
+ return;
311
+ dispatch({
312
+ type: "flushRequested",
313
+ request: { chatId, target: options.target },
314
+ ...clock(),
315
+ });
316
+ await whenIdle();
317
+ };
318
+ const prepareFinalizer = (input) => {
319
+ const segmentId = machine.state.segment?.id;
320
+ const generation = machine.state.generation;
321
+ seal();
322
+ const predecessors = whenBoundarySettles();
323
+ return async (chatId, markdown, replyToMessageId, options) => {
324
+ if (input.isDeliveryActive?.() === false)
325
+ return false;
326
+ await predecessors;
327
+ if (machine.state.generation !== generation ||
328
+ machine.state.segment?.id !== segmentId ||
329
+ input.isDeliveryActive?.() === false)
330
+ return false;
331
+ await input.sendMarkdownReply(chatId, replyToMessageId, markdown, options);
332
+ if (machine.state.segment?.id === segmentId &&
333
+ input.isDeliveryActive?.() !== false)
334
+ dispatch({ type: "discarded" });
335
+ return true;
336
+ };
337
+ };
338
+ const controller = {
339
+ getState: () => machine.state.segment ?? undefined,
340
+ isFlushing: () => isTelegramPreviewBusy(machine.state),
341
+ seal,
342
+ setPendingText: (text) => dispatch({ type: "textSet", text }),
343
+ resetState: () => dispatch({ type: "reset" }),
344
+ invalidate: () => dispatch({ type: "invalidated" }),
149
345
  preparePublication: () => {
150
- if (!state)
346
+ if (!machine.state.segment)
151
347
  return undefined;
152
- sealTelegramPreviewState(state);
153
- const prior = state.publicationPromise ?? state.flushPromise ?? state.precedingFlush;
154
- let settle;
155
- state.publicationPromise = new Promise((resolve) => {
156
- settle = resolve;
157
- });
348
+ seal();
349
+ const id = (publicationIds += 1);
350
+ const prior = whenBoundarySettles();
351
+ dispatch({ type: "publicationOpened", id });
352
+ let settled = false;
158
353
  return {
159
- wait: async () => {
160
- await prior?.catch(() => { });
354
+ wait: () => prior,
355
+ settle: () => {
356
+ if (settled)
357
+ return;
358
+ settled = true;
359
+ dispatch({ type: "publicationSettled", id, ...clock() });
161
360
  },
162
- settle,
163
361
  };
164
362
  },
165
363
  prepareClear: (chatId, options) => {
166
- const admittedState = state;
167
- const runtime = getRuntimeDeps();
364
+ const admitted = machine.state.segment?.id;
365
+ const generation = machine.state.generation;
168
366
  return async () => {
169
- if (state !== admittedState)
367
+ if (machine.state.segment?.id !== admitted)
170
368
  return;
171
- await clearTelegramPreview(chatId, runtime, {
369
+ await clear(chatId, {
172
370
  ...options,
173
- isDeliveryActive: () => runtime.canSend?.() !== false &&
174
- options?.isDeliveryActive?.() !== false,
175
- });
371
+ isDeliveryActive: () => canSendFor(generation) && options?.isDeliveryActive?.() !== false,
372
+ }, generation);
176
373
  };
177
374
  },
178
- clear: (chatId, options) => clearTelegramPreview(chatId, getRuntimeDeps(), options),
179
- flush: (chatId, options) => flushTelegramPreview(chatId, getRuntimeDeps(), options),
375
+ clear: (chatId, options) => clear(chatId, options),
376
+ flush,
180
377
  scheduleFlush: (chatId, options) => {
181
- if (!state)
378
+ if (!machine.state.segment)
182
379
  return;
183
- void flushTelegramPreview(chatId, getRuntimeDeps(), options);
380
+ void flush(chatId, options);
381
+ },
382
+ finalize: async (chatId, _replyToMessageId, options) => {
383
+ const segment = machine.state.segment;
384
+ if (!segment)
385
+ return false;
386
+ if (!canSendFor(machine.state.generation)) {
387
+ await clear(chatId, options);
388
+ return false;
389
+ }
390
+ await flush(chatId, options);
391
+ const current = machine.state.segment;
392
+ if (current?.id !== segment.id)
393
+ return false;
394
+ const finalText = buildTelegramPreviewFinalText(current);
395
+ if (!finalText) {
396
+ await clear(chatId, options);
397
+ return false;
398
+ }
399
+ dispatch({ type: "discarded" });
400
+ return false;
184
401
  },
185
- finalize: (chatId, _replyToMessageId, options) => finalizeTelegramPreview(chatId, getRuntimeDeps(), options),
186
402
  };
187
- }
188
- function createTelegramAssistantMessagePreviewHooks(deps) {
189
403
  return {
190
- onMessageStart: async (event) => {
191
- await handleTelegramAssistantMessagePreviewStart(event.message, deps);
404
+ controller,
405
+ prepareFinalizer,
406
+ startSegment: () => {
407
+ dispatch({ type: "segmentStarted" });
408
+ },
409
+ ensureSegment: () => {
410
+ dispatch({ type: "segmentEnsured" });
411
+ return machine.state.segment;
192
412
  },
193
- onMessageUpdate: async (event) => {
194
- await handleTelegramAssistantMessagePreviewUpdate(event.message, deps);
413
+ openDraftWindow: (at) => {
414
+ dispatch({ type: "draftWindowOpened", at });
415
+ },
416
+ discard: () => {
417
+ dispatch({ type: "discarded" });
195
418
  },
196
419
  };
197
420
  }
421
+ export function createTelegramPreviewController(deps) {
422
+ return createTelegramPreviewModel(deps).controller;
423
+ }
198
424
  /**
199
425
  * Returns true when the active turn is a Telegram Guest Mode query. A guest
200
426
  * query allows exactly one answer within a limited Telegram response window,
@@ -203,66 +429,77 @@ function createTelegramAssistantMessagePreviewHooks(deps) {
203
429
  export function shouldSuppressPreviewForGuestTurn(turn) {
204
430
  return !!turn?.guestQueryId;
205
431
  }
206
- async function handleTelegramAssistantMessagePreviewStart(message, deps) {
207
- const turn = deps.getActiveTurn();
208
- if (!turn || !deps.isAssistantMessage(message))
209
- return;
210
- if (deps.canSend && !deps.canSend()) {
211
- deps.setState(undefined);
212
- return;
213
- }
214
- if (shouldSuppressPreviewForVoice(turn)) {
215
- deps.setState(undefined);
216
- return;
217
- }
218
- if (shouldSuppressPreviewForGuestTurn(turn)) {
219
- deps.setState(undefined);
220
- return;
221
- }
222
- const state = deps.getState();
223
- sealTelegramPreviewState(state);
224
- const next = deps.createPreviewState();
225
- // Carry the previous delivery boundary; permanent text remains with its publication owner.
226
- next.draftId = state?.draftId;
227
- next.nextDraftAt = state?.nextDraftAt;
228
- next.precedingFlush =
229
- state?.publicationPromise ?? state?.flushPromise ?? state?.precedingFlush;
230
- deps.setState(next);
231
- }
232
- async function handleTelegramAssistantMessagePreviewUpdate(message, deps) {
233
- const turn = deps.getActiveTurn();
234
- if (!turn || !deps.isAssistantMessage(message))
235
- return;
236
- if (deps.canSend && !deps.canSend()) {
237
- deps.setState(undefined);
238
- return;
239
- }
240
- if (shouldSuppressPreviewForVoice(turn))
241
- return;
242
- if (shouldSuppressPreviewForGuestTurn(turn))
243
- return;
244
- let state = deps.getState();
245
- if (!state) {
246
- state = deps.createPreviewState();
247
- deps.setState(state);
248
- }
249
- if (state.sealed)
250
- return;
251
- const hadVisibleText = state.pendingText.length > 0;
252
- state.pendingText = stripTelegramCommentMarkupForPreview(deps.getMessageText(message));
253
- // The first visible text of a preview segment opens an initial accumulation
254
- // window, so the segment's first frame cannot ship as a single word even
255
- // when the previous cadence boundary has already passed (fresh turn, slow
256
- // first token, or message rollover after tool work). Later deltas keep the
257
- // trailing deadline instead of sliding it on every update.
258
- const interval = deps.minDraftIntervalMs ?? 0;
259
- if (interval > 0 &&
260
- !hadVisibleText &&
261
- !state.lastSentText &&
262
- state.pendingText.length > 0) {
263
- state.nextDraftAt = Math.max(state.nextDraftAt ?? 0, Date.now() + interval);
264
- }
265
- deps.schedulePreviewFlush(turn.chatId, { target: turn.target });
432
+ export function createTelegramAssistantPreviewRuntime(deps) {
433
+ const model = createTelegramPreviewModel(deps);
434
+ const { controller } = model;
435
+ const suppressed = (turn) => shouldSuppressPreviewForVoice(turn) ||
436
+ shouldSuppressPreviewForGuestTurn(turn);
437
+ return {
438
+ ...controller,
439
+ finalizeMarkdown: (...args) => model.prepareFinalizer({
440
+ sendMarkdownReply: deps.sendMarkdownReply,
441
+ })(...args),
442
+ prepareDelivery(isDeliveryActive) {
443
+ const admitted = controller.getState()?.id;
444
+ return {
445
+ setPreviewPendingText(text) {
446
+ if (controller.getState()?.id === admitted && isDeliveryActive())
447
+ controller.setPendingText(text);
448
+ },
449
+ clearPreview: async (chatId, options) => {
450
+ if (controller.getState()?.id !== admitted || !isDeliveryActive())
451
+ return;
452
+ await controller.prepareClear(chatId, {
453
+ ...options,
454
+ isDeliveryActive,
455
+ })();
456
+ },
457
+ finalizeMarkdownPreview: model.prepareFinalizer({
458
+ sendMarkdownReply: deps.sendMarkdownReply,
459
+ isDeliveryActive,
460
+ }),
461
+ };
462
+ },
463
+ onMessageStart: async ({ message }) => {
464
+ const turn = deps.getActiveTurn();
465
+ if (!turn || !deps.isAssistantMessage(message))
466
+ return;
467
+ if ((deps.canSend && !deps.canSend()) || suppressed(turn)) {
468
+ model.discard();
469
+ return;
470
+ }
471
+ model.startSegment();
472
+ },
473
+ onMessageUpdate: async ({ message }) => {
474
+ const turn = deps.getActiveTurn();
475
+ if (!turn || !deps.isAssistantMessage(message))
476
+ return;
477
+ if (deps.canSend && !deps.canSend()) {
478
+ model.discard();
479
+ return;
480
+ }
481
+ if (suppressed(turn))
482
+ return;
483
+ const segment = model.ensureSegment();
484
+ if (segment.sealed)
485
+ return;
486
+ const hadVisibleText = segment.pendingText.length > 0;
487
+ const pendingText = stripTelegramCommentMarkupForPreview(deps.getMessageText(message));
488
+ controller.setPendingText(pendingText);
489
+ // The first visible text of a preview segment opens an initial accumulation
490
+ // window, so the segment's first frame cannot ship as a single word even
491
+ // when the previous cadence boundary has already passed (fresh turn, slow
492
+ // first token, or message rollover after tool work). Later deltas keep the
493
+ // trailing deadline instead of sliding it on every update.
494
+ const interval = deps.minDraftIntervalMs ?? TELEGRAM_DRAFT_INTERVAL_MS;
495
+ if (interval > 0 &&
496
+ !hadVisibleText &&
497
+ !segment.lastSentText &&
498
+ pendingText.length > 0)
499
+ model.openDraftWindow(Date.now() + interval);
500
+ controller.scheduleFlush(turn.chatId, { target: turn.target });
501
+ },
502
+ };
266
503
  }
267
504
  export function buildTelegramPreviewFinalText(state) {
268
505
  const finalText = state.pendingText.trim();
@@ -270,51 +507,9 @@ export function buildTelegramPreviewFinalText(state) {
270
507
  return finalText;
271
508
  return state.lastSentText.trim() || undefined;
272
509
  }
273
- export function createTelegramPreviewRuntimeState() {
274
- return {
275
- mode: "draft",
276
- pendingText: "",
277
- lastSentText: "",
278
- };
279
- }
280
510
  export function allocateTelegramDraftId(currentDraftId, maxDraftId) {
281
511
  return currentDraftId >= maxDraftId ? 1 : currentDraftId + 1;
282
512
  }
283
- export function shouldUseTelegramDraftPreview(_options) {
284
- return true;
285
- }
286
- export async function clearTelegramPreview(chatId, deps, options = {}) {
287
- const state = deps.getState();
288
- if (!state || options.isDeliveryActive?.() === false)
289
- return;
290
- sealTelegramPreviewState(state);
291
- const inFlight = state.flushPromise ?? state.precedingFlush;
292
- if (inFlight && options.awaitFlush !== false) {
293
- state.flushRequested = false;
294
- await inFlight.catch(() => { });
295
- if (deps.getState() !== state)
296
- return;
297
- }
298
- if (options.isDeliveryActive?.() === false)
299
- return;
300
- deps.setState(undefined);
301
- if (state.mode === "draft" &&
302
- state.draftId !== undefined &&
303
- deps.canSend?.() !== false) {
304
- try {
305
- await deps.sendDraft(chatId, state.draftId, undefined, {
306
- ...getTelegramTargetThreadParams(options.target ?? { chatId }),
307
- });
308
- }
309
- catch (error) {
310
- deps.recordRuntimeEvent?.("preview", error, {
311
- phase: "clear-draft",
312
- chatId,
313
- draftId: state.draftId,
314
- });
315
- }
316
- }
317
- }
318
513
  function createTelegramDraftInlineState() {
319
514
  return {
320
515
  codeTicks: 0,
@@ -519,116 +714,3 @@ function buildTelegramNativeMarkdownPreviewSnapshot(state, maxMessageLength) {
519
714
  return undefined;
520
715
  return { text: safeText };
521
716
  }
522
- async function performTelegramPreviewFlush(chatId, state, deps, options = {}) {
523
- if (deps.canSend && !deps.canSend()) {
524
- await clearTelegramPreview(chatId, deps, {
525
- awaitFlush: false,
526
- target: options.target,
527
- });
528
- return;
529
- }
530
- const snapshot = buildTelegramNativeMarkdownPreviewSnapshot(state, deps.maxMessageLength);
531
- if (!snapshot)
532
- return;
533
- if (shouldUseTelegramDraftPreview({
534
- draftSupport: deps.getDraftSupport(),
535
- snapshot,
536
- })) {
537
- const draftId = state.draftId ?? deps.allocateDraftId();
538
- state.draftId = draftId;
539
- state.nextDraftAt = Date.now() + (deps.minDraftIntervalMs ?? 0);
540
- try {
541
- const delivered = await deps.sendDraft(chatId, draftId, normalizeTelegramNativeMarkdown(snapshot.text), { ...getTelegramTargetThreadParams(options.target ?? { chatId }) });
542
- if (delivered === false ||
543
- deps.getState() !== state ||
544
- deps.canSend?.() === false)
545
- return;
546
- deps.setDraftSupport("supported");
547
- state.mode = "draft";
548
- state.lastSentText = snapshot.text;
549
- return;
550
- }
551
- catch (error) {
552
- deps.recordRuntimeEvent?.("preview", error, {
553
- phase: "draft",
554
- chatId,
555
- draftId,
556
- });
557
- return;
558
- }
559
- }
560
- }
561
- export async function flushTelegramPreview(chatId, deps, options = {}) {
562
- const state = deps.getState();
563
- if (!state || state.sealed)
564
- return;
565
- if (state.flushPromise) {
566
- state.flushRequested = true;
567
- await state.flushPromise;
568
- return;
569
- }
570
- state.flushPromise = (async () => {
571
- if (state.precedingFlush) {
572
- await state.precedingFlush.catch(() => { });
573
- state.precedingFlush = undefined;
574
- if (deps.getState() !== state || state.sealed)
575
- return;
576
- }
577
- do {
578
- state.flushRequested = false;
579
- const delay = (state.nextDraftAt ?? 0) - Date.now();
580
- if (delay > 0) {
581
- if (!state.flushTimer) {
582
- state.flushTimer = setTimeout(() => {
583
- state.flushTimer = undefined;
584
- if (deps.getState() === state && !state.sealed)
585
- void flushTelegramPreview(chatId, deps, options);
586
- }, delay);
587
- state.flushTimer.unref?.();
588
- }
589
- break;
590
- }
591
- if (state.flushTimer)
592
- clearTimeout(state.flushTimer);
593
- state.flushTimer = undefined;
594
- try {
595
- await performTelegramPreviewFlush(chatId, state, deps, options);
596
- }
597
- catch (error) {
598
- deps.recordRuntimeEvent?.("preview", error, {
599
- phase: "flush",
600
- chatId,
601
- draftId: state.draftId,
602
- });
603
- break;
604
- }
605
- } while (deps.getState() === state &&
606
- !state.sealed &&
607
- state.flushRequested);
608
- })();
609
- try {
610
- await state.flushPromise;
611
- }
612
- finally {
613
- if (deps.getState() === state) {
614
- state.flushPromise = undefined;
615
- }
616
- }
617
- }
618
- export async function finalizeTelegramPreview(chatId, deps, options = {}) {
619
- const state = deps.getState();
620
- if (!state)
621
- return false;
622
- if (deps.canSend && !deps.canSend()) {
623
- await clearTelegramPreview(chatId, deps, options);
624
- return false;
625
- }
626
- await flushTelegramPreview(chatId, deps, options);
627
- const finalText = buildTelegramPreviewFinalText(state);
628
- if (!finalText) {
629
- await clearTelegramPreview(chatId, deps, options);
630
- return false;
631
- }
632
- deps.setState(undefined);
633
- return false;
634
- }