@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
@@ -7,8 +7,9 @@
7
7
  import { setTimeout as waitForTimeout } from "node:timers/promises";
8
8
 
9
9
  import {
10
- createTelegramPendingGroupLifecycle,
10
+ createTelegramPendingGroups,
11
11
  extractTelegramMessageText,
12
+ type TelegramPendingGroup,
12
13
  type TelegramMessageForwardOrigin,
13
14
  type TelegramMessageUser,
14
15
  type TelegramRichMessage,
@@ -32,19 +33,6 @@ export interface TelegramTextGroupMessage {
32
33
  forward_sender_name?: string;
33
34
  }
34
35
 
35
- export interface TelegramTextGroupState<TMessage, TContext = unknown> {
36
- messages: TMessage[];
37
- context?: TContext;
38
- flushTimer?: ReturnType<typeof setTimeout>;
39
- dispatching?: boolean;
40
- suspended?: boolean;
41
- reschedule?: (delayMs?: number) => void;
42
- dispatchNow?: () => Promise<void>;
43
- dispatchPromise?: Promise<void>;
44
- dispatchLimit?: number;
45
- forwardPairCandidate?: TelegramForwardCommentBatchPosition;
46
- }
47
-
48
36
  export type TelegramForwardCommentBatchPosition = "comment" | "forward";
49
37
 
50
38
  export interface TelegramTextGroupController<TMessage, TContext = unknown> {
@@ -157,11 +145,11 @@ function canStartTelegramTextGroup(
157
145
  function canAppendTelegramTextGroupMessage<
158
146
  TMessage extends TelegramTextGroupMessage,
159
147
  >(
160
- state: TelegramTextGroupState<TMessage, unknown>,
148
+ group: TelegramPendingGroup<TMessage, unknown, unknown>,
161
149
  message: TMessage,
162
150
  ): boolean {
163
151
  const text = extractTelegramTextGroupText(message);
164
- const previous = state.messages.at(-1);
152
+ const previous = group.messages.at(-1);
165
153
  return (
166
154
  !!previous &&
167
155
  message.message_id > previous.message_id &&
@@ -172,114 +160,12 @@ function canAppendTelegramTextGroupMessage<
172
160
  );
173
161
  }
174
162
 
175
- export function queueTelegramTextGroupMessage<
176
- TMessage extends TelegramTextGroupMessage,
177
- TContext = unknown,
178
- >(options: {
179
- message: TMessage;
180
- context: TContext;
181
- groups: Map<string, TelegramTextGroupState<TMessage, TContext>>;
182
- debounceMs: number;
183
- minSplitLength: number;
184
- setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
185
- clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
186
- dispatchMessages: (
187
- messages: TMessage[],
188
- ctx: TContext,
189
- ) => unknown | Promise<unknown>;
190
- forceStart?: boolean;
191
- dispatchImmediately?: boolean;
192
- forwardPairCandidate?: TelegramForwardCommentBatchPosition;
193
- delayMs?: number;
194
- }): boolean {
195
- const key = getTelegramTextGroupKey(options.message);
196
- if (!key) return false;
197
- const existing = options.groups.get(key);
198
- const duplicateIndex = existing?.messages.findIndex(
199
- (message) => message.message_id === options.message.message_id,
200
- );
201
- if (existing && duplicateIndex !== undefined && duplicateIndex >= 0) {
202
- existing.messages[duplicateIndex] = options.message;
203
- existing.context = options.context;
204
- existing.forwardPairCandidate = options.forwardPairCandidate;
205
- if (!existing.suspended && !existing.dispatching && !existing.flushTimer) {
206
- existing.reschedule?.();
207
- }
208
- return true;
209
- }
210
- if (
211
- !existing &&
212
- !options.forceStart &&
213
- !canStartTelegramTextGroup(options.message, options.minSplitLength)
214
- )
215
- return false;
216
- if (existing && !canAppendTelegramTextGroupMessage(existing, options.message))
217
- return false;
218
- const state = existing ?? { messages: [] };
219
- state.messages.push(options.message);
220
- state.context = options.context;
221
- state.forwardPairCandidate = options.forwardPairCandidate;
222
- const dispatchQueued = (): Promise<void> => {
223
- state.flushTimer = undefined;
224
- const queued = options.groups.get(key);
225
- if (!queued || queued.context === undefined) return Promise.resolve();
226
- if (queued.dispatching) return queued.dispatchPromise ?? Promise.resolve();
227
- const dispatchCount = queued.dispatchLimit ?? queued.messages.length;
228
- queued.dispatchLimit = undefined;
229
- const dispatchedMessages = queued.messages.slice(0, dispatchCount);
230
- const dispatchedIds = new Set(
231
- dispatchedMessages.map((message) => message.message_id),
232
- );
233
- queued.dispatching = true;
234
- const operation = Promise.resolve(
235
- options.dispatchMessages(dispatchedMessages, queued.context),
236
- ).then(
237
- () => {
238
- if (options.groups.get(key) !== queued) return;
239
- queued.messages = queued.messages.filter(
240
- (message) => !dispatchedIds.has(message.message_id),
241
- );
242
- queued.dispatching = false;
243
- queued.dispatchPromise = undefined;
244
- if (queued.messages.length === 0) options.groups.delete(key);
245
- else if (!queued.flushTimer) scheduleDispatch();
246
- },
247
- (error) => {
248
- if (options.groups.get(key) === queued) {
249
- queued.dispatching = false;
250
- queued.dispatchPromise = undefined;
251
- if (!queued.flushTimer) scheduleDispatch();
252
- }
253
- throw error;
254
- },
255
- );
256
- queued.dispatchPromise = operation;
257
- return operation;
258
- };
259
- const scheduleDispatch = (delayMs = options.debounceMs): void => {
260
- if (state.suspended) return;
261
- state.flushTimer = options.setTimer(() => {
262
- void dispatchQueued().catch(() => undefined);
263
- }, delayMs);
264
- state.flushTimer.unref?.();
265
- };
266
- state.reschedule = scheduleDispatch;
267
- state.dispatchNow = dispatchQueued;
268
- if (state.flushTimer) options.clearTimer(state.flushTimer);
269
- scheduleDispatch(
270
- options.dispatchImmediately ? 0 : (options.delayMs ?? options.debounceMs),
271
- );
272
- options.groups.set(key, state);
273
- return true;
274
- }
275
-
276
163
  export function createTelegramTextGroupController<
277
164
  TMessage extends TelegramTextGroupMessage,
278
165
  TContext = unknown,
279
166
  >(
280
167
  options: TelegramTextGroupControllerOptions = {},
281
168
  ): TelegramTextGroupController<TMessage, TContext> {
282
- const groups = new Map<string, TelegramTextGroupState<TMessage, TContext>>();
283
169
  const plannedForwardCommentStarts = new Set<string>();
284
170
  const plannedForwardCommentEnds = new Set<string>();
285
171
  const debounceMs = options.debounceMs ?? TELEGRAM_TEXT_GROUP_DEBOUNCE_MS;
@@ -305,7 +191,65 @@ export function createTelegramTextGroupController<
305
191
  : (timer: ReturnType<typeof setTimeout>): void => {
306
192
  (timer as unknown as AbortController).abort();
307
193
  });
308
- const lifecycle = createTelegramPendingGroupLifecycle(groups, clearTimer);
194
+ const groups = createTelegramPendingGroups<
195
+ TMessage,
196
+ TContext,
197
+ TelegramForwardCommentBatchPosition
198
+ >({ debounceMs, requireContext: true, setTimer, clearTimer });
199
+ /** Admission policy for one message; the shared group machine owns timers, batches and dispatch. */
200
+ const queueTextGroupMessage = (options: {
201
+ key: string;
202
+ message: TMessage;
203
+ context: TContext;
204
+ dispatchMessages: (
205
+ messages: TMessage[],
206
+ ctx: TContext,
207
+ ) => unknown | Promise<unknown>;
208
+ forceStart: boolean;
209
+ dispatchImmediately: boolean;
210
+ forwardPairCandidate?: TelegramForwardCommentBatchPosition;
211
+ delayMs?: number;
212
+ }): boolean => {
213
+ const existing = groups.get(options.key);
214
+ const dispatchMessages = (messages: TMessage[], ctx?: TContext) =>
215
+ options.dispatchMessages(messages, ctx as TContext);
216
+ const queued = {
217
+ key: options.key,
218
+ message: options.message,
219
+ context: options.context,
220
+ ...(options.forwardPairCandidate === undefined
221
+ ? {}
222
+ : { meta: options.forwardPairCandidate }),
223
+ dispatchMessages,
224
+ };
225
+ if (
226
+ existing?.messages.some(
227
+ (message) => message.message_id === options.message.message_id,
228
+ )
229
+ ) {
230
+ groups.queue({ ...queued, delayMs: debounceMs, rearm: "ifIdle" });
231
+ return true;
232
+ }
233
+ if (
234
+ !existing &&
235
+ !options.forceStart &&
236
+ !canStartTelegramTextGroup(options.message, minSplitLength)
237
+ )
238
+ return false;
239
+ if (
240
+ existing &&
241
+ !canAppendTelegramTextGroupMessage(existing, options.message)
242
+ )
243
+ return false;
244
+ groups.queue({
245
+ ...queued,
246
+ delayMs: options.dispatchImmediately
247
+ ? 0
248
+ : (options.delayMs ?? debounceMs),
249
+ rearm: "always",
250
+ });
251
+ return true;
252
+ };
309
253
  return {
310
254
  prepareUpdateBatch(updates) {
311
255
  for (let index = 0; index + 1 < updates.length; index += 1) {
@@ -354,23 +298,19 @@ export function createTelegramTextGroupController<
354
298
  const forwarded = isTelegramForwardedMessage(message);
355
299
  const existing = key ? groups.get(key) : undefined;
356
300
  const text = extractTelegramTextGroupText(message);
357
- if (existing && isTelegramTextGroupClearingCommand(text)) {
358
- if (existing.flushTimer) clearTimer(existing.flushTimer);
359
- groups.delete(key!);
360
- }
301
+ if (existing && isTelegramTextGroupClearingCommand(text))
302
+ groups.clearKey(key!);
361
303
  const candidatePosition: TelegramForwardCommentBatchPosition = forwarded
362
304
  ? "forward"
363
305
  : "comment";
364
- const existingCandidate = existing?.forwardPairCandidate;
306
+ const existingCandidate = existing?.meta;
365
307
  const pairCompleted =
366
308
  existingCandidate !== undefined &&
367
309
  existingCandidate !== candidatePosition;
368
310
  const separateFromCandidate =
369
311
  existingCandidate === candidatePosition &&
370
312
  !isTelegramTextGroupCommand(text);
371
- if (separateFromCandidate && existing) {
372
- existing.dispatchLimit = existing.messages.length;
373
- }
313
+ if (separateFromCandidate && existing) groups.closeBatch(key!);
374
314
  const forceStart =
375
315
  plannedStart ||
376
316
  (forwardCommentWaitMs !== false &&
@@ -384,15 +324,12 @@ export function createTelegramTextGroupController<
384
324
  pairCompleted ||
385
325
  separateFromCandidate ||
386
326
  plannedForwardCommentEnds.delete(identity) ||
387
- (forwarded && !!key && groups.has(key));
388
- return queueTelegramTextGroupMessage({
327
+ (forwarded && !!key && groups.get(key) !== undefined);
328
+ if (!key) return false;
329
+ return queueTextGroupMessage({
330
+ key,
389
331
  message,
390
332
  context,
391
- groups,
392
- debounceMs,
393
- minSplitLength,
394
- setTimer,
395
- clearTimer,
396
333
  dispatchMessages,
397
334
  forceStart,
398
335
  dispatchImmediately,
@@ -408,9 +345,11 @@ export function createTelegramTextGroupController<
408
345
  : undefined,
409
346
  });
410
347
  },
411
- ...lifecycle,
348
+ flushMessage: groups.flushMessage,
349
+ suspend: groups.suspend,
350
+ resume: groups.resume,
412
351
  clear: () => {
413
- lifecycle.clear();
352
+ groups.clear();
414
353
  plannedForwardCommentStarts.clear();
415
354
  plannedForwardCommentEnds.clear();
416
355
  },
@@ -4,9 +4,11 @@
4
4
  * Owns mode-specific labels and serialized, authority-fenced title application.
5
5
  * Excludes routing, name allocation, profile mutation, and live-owner discovery.
6
6
  */
7
+ import { homedir } from "node:os";
7
8
  import { isDeepStrictEqual } from "node:util";
8
9
 
9
10
  import type { TelegramThreadDisplayMode } from "./config.ts";
11
+ import { normalizeTelegramWorkspacePath } from "./workspace-identity.ts";
10
12
  import type { TelegramApiCallOptions } from "./telegram-api.ts";
11
13
  import type {
12
14
  TelegramTopicTargetStore,
@@ -34,8 +36,11 @@ function directorySegments(cwd: string): string[] {
34
36
  function distinguishingDirectorySegments(
35
37
  cwd: string,
36
38
  directories: readonly string[],
39
+ homeDirectory?: string,
37
40
  ): string[] {
38
- const parts = directorySegments(cwd);
41
+ // Substitute only exact home identity; qualification must still distinguish ordinary folders named home.
42
+ const segments = (directory: string) => directory === homeDirectory ? ["home"] : directorySegments(directory);
43
+ const parts = segments(cwd);
39
44
  if (!parts.length) return [];
40
45
  for (let depth = 1; depth <= parts.length; depth++) {
41
46
  const candidate = labelText(parts.slice(-depth).join("/"));
@@ -43,7 +48,7 @@ function distinguishingDirectorySegments(
43
48
  (other) =>
44
49
  other !== cwd &&
45
50
  labelText(
46
- directorySegments(other).slice(-depth).join("/"),
51
+ segments(other).slice(-depth).join("/"),
47
52
  ).toLowerCase() === candidate.toLowerCase(),
48
53
  );
49
54
  if (!collides) return parts.slice(-depth);
@@ -69,8 +74,9 @@ function formatDirectoryLabel(
69
74
  cwd: string,
70
75
  directories: readonly string[],
71
76
  mode: "directory-snake" | "directory-title",
77
+ homeDirectory?: string,
72
78
  ): string {
73
- const segments = distinguishingDirectorySegments(cwd, directories);
79
+ const segments = distinguishingDirectorySegments(cwd, directories, homeDirectory);
74
80
  if (!segments.length) return "/";
75
81
  const formatted = segments
76
82
  .map((segment) => {
@@ -143,6 +149,11 @@ export function resolveTelegramWorkspaceDisplayNames(
143
149
  liveBindingKeys: ReadonlySet<string> = new Set(),
144
150
  ): Map<string, string> {
145
151
  const labels = new Map<string, string>();
152
+ const homeDirectory =
153
+ (mode === "directory-snake" || mode === "directory-title") &&
154
+ (process.platform === "linux" || process.platform === "darwin")
155
+ ? normalizeTelegramWorkspacePath(homedir())
156
+ : undefined;
146
157
  const directories = Array.from(
147
158
  new Set(bindings.map((binding) => binding.cwd)),
148
159
  );
@@ -172,7 +183,7 @@ export function resolveTelegramWorkspaceDisplayNames(
172
183
  const base =
173
184
  mode === "directories"
174
185
  ? directoryLabel(binding.cwd, directories)
175
- : formatDirectoryLabel(binding.cwd, directories, mode);
186
+ : formatDirectoryLabel(binding.cwd, directories, mode, homeDirectory);
176
187
  bases.set(binding.bindingKey, base);
177
188
  const showSuffix =
178
189
  mode === "directories"