@llblab/pi-telegram 0.51.5 → 0.52.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 (181) hide show
  1. package/AGENTS.md +10 -8
  2. package/BACKLOG.md +1 -8
  3. package/CHANGELOG.md +23 -0
  4. package/LICENSE +21 -0
  5. package/README.md +16 -6
  6. package/dist/lib/activity-verbosity.d.ts +1 -5
  7. package/dist/lib/activity-verbosity.js +5 -7
  8. package/dist/lib/bindings.d.ts +9 -2
  9. package/dist/lib/bindings.js +22 -13
  10. package/dist/lib/bus-api.js +4 -2
  11. package/dist/lib/bus-follower.d.ts +55 -57
  12. package/dist/lib/bus-follower.js +213 -140
  13. package/dist/lib/bus-leader.d.ts +21 -2
  14. package/dist/lib/bus-leader.js +181 -25
  15. package/dist/lib/bus-transport.d.ts +4 -1
  16. package/dist/lib/bus-transport.js +28 -4
  17. package/dist/lib/bus.d.ts +52 -56
  18. package/dist/lib/bus.js +78 -246
  19. package/dist/lib/channel-posts.d.ts +1 -3
  20. package/dist/lib/channel-posts.js +23 -26
  21. package/dist/lib/command-templates.d.ts +0 -5
  22. package/dist/lib/command-templates.js +5 -5
  23. package/dist/lib/commands.d.ts +12 -26
  24. package/dist/lib/commands.js +152 -98
  25. package/dist/lib/config.d.ts +0 -17
  26. package/dist/lib/config.js +12 -14
  27. package/dist/lib/delivery.d.ts +0 -4
  28. package/dist/lib/delivery.js +2 -5
  29. package/dist/lib/extension.js +281 -61
  30. package/dist/lib/generative-apps.d.ts +0 -19
  31. package/dist/lib/inbound.d.ts +0 -2
  32. package/dist/lib/inbound.js +2 -2
  33. package/dist/lib/journal.d.ts +177 -16
  34. package/dist/lib/journal.js +1115 -249
  35. package/dist/lib/keyboard.d.ts +0 -2
  36. package/dist/lib/keyboard.js +2 -2
  37. package/dist/lib/lifecycle.d.ts +57 -2
  38. package/dist/lib/lifecycle.js +110 -5
  39. package/dist/lib/locks.d.ts +100 -12
  40. package/dist/lib/locks.js +380 -14
  41. package/dist/lib/logging.d.ts +30 -12
  42. package/dist/lib/logging.js +129 -72
  43. package/dist/lib/media.d.ts +28 -5
  44. package/dist/lib/media.js +26 -8
  45. package/dist/lib/menu-model.d.ts +0 -11
  46. package/dist/lib/menu-model.js +8 -8
  47. package/dist/lib/menu-settings.d.ts +0 -18
  48. package/dist/lib/menu-settings.js +18 -18
  49. package/dist/lib/menu-status.d.ts +4 -4
  50. package/dist/lib/menu-status.js +12 -7
  51. package/dist/lib/menu-thinking.d.ts +1 -1
  52. package/dist/lib/menu-thinking.js +1 -1
  53. package/dist/lib/menu.d.ts +1 -0
  54. package/dist/lib/menu.js +4 -3
  55. package/dist/lib/model.js +2 -4
  56. package/dist/lib/outbound-attachments.d.ts +0 -1
  57. package/dist/lib/outbound-attachments.js +1 -1
  58. package/dist/lib/outbound-buttons.js +1 -8
  59. package/dist/lib/outbound-markup.d.ts +1 -6
  60. package/dist/lib/outbound-markup.js +4 -21
  61. package/dist/lib/outbound.d.ts +0 -8
  62. package/dist/lib/outbound.js +5 -5
  63. package/dist/lib/paths.d.ts +50 -7
  64. package/dist/lib/paths.js +155 -17
  65. package/dist/lib/pi.d.ts +1 -0
  66. package/dist/lib/pi.js +2 -1
  67. package/dist/lib/polling.d.ts +0 -5
  68. package/dist/lib/polling.js +5 -5
  69. package/dist/lib/preview.d.ts +0 -3
  70. package/dist/lib/preview.js +3 -3
  71. package/dist/lib/prompt-templates.d.ts +0 -1
  72. package/dist/lib/prompt-templates.js +1 -1
  73. package/dist/lib/queue.d.ts +3 -15
  74. package/dist/lib/queue.js +69 -8
  75. package/dist/lib/recovery.d.ts +29 -9
  76. package/dist/lib/recovery.js +104 -33
  77. package/dist/lib/replies.d.ts +0 -4
  78. package/dist/lib/replies.js +1 -1
  79. package/dist/lib/routing.d.ts +73 -8
  80. package/dist/lib/routing.js +2206 -307
  81. package/dist/lib/sections.d.ts +0 -2
  82. package/dist/lib/sections.js +1 -1
  83. package/dist/lib/status.d.ts +46 -9
  84. package/dist/lib/status.js +179 -27
  85. package/dist/lib/sync.d.ts +0 -5
  86. package/dist/lib/sync.js +9 -8
  87. package/dist/lib/telegram-api.d.ts +11 -3
  88. package/dist/lib/telegram-api.js +34 -23
  89. package/dist/lib/thread-cleanup-manager.d.ts +1 -0
  90. package/dist/lib/thread-cleanup-manager.js +18 -21
  91. package/dist/lib/thread-naming.d.ts +32 -5
  92. package/dist/lib/thread-naming.js +191 -7
  93. package/dist/lib/thread-reconciler.d.ts +2 -0
  94. package/dist/lib/thread-reconciler.js +1 -1
  95. package/dist/lib/threads.d.ts +292 -59
  96. package/dist/lib/threads.js +1664 -373
  97. package/dist/lib/turns.d.ts +0 -1
  98. package/dist/lib/turns.js +1 -1
  99. package/dist/lib/updates.d.ts +184 -28
  100. package/dist/lib/updates.js +1032 -114
  101. package/dist/lib/wire.d.ts +12 -0
  102. package/dist/lib/wire.js +21 -0
  103. package/dist/lib/workspace-admission.d.ts +25 -17
  104. package/dist/lib/workspace-admission.js +95 -20
  105. package/dist/lib/workspace-identity.d.ts +20 -0
  106. package/dist/lib/workspace-identity.js +103 -0
  107. package/dist/lib/workspace-retirement.d.ts +45 -10
  108. package/dist/lib/workspace-retirement.js +196 -64
  109. package/dist/package.json +6 -6
  110. package/dist/skills/generated-control-surface/SKILL.md +3 -3
  111. package/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
  112. package/docs/README.md +1 -1
  113. package/docs/activity.md +1 -1
  114. package/docs/architecture.md +158 -39
  115. package/docs/callback-namespaces.md +1 -1
  116. package/docs/delivery.md +1 -1
  117. package/docs/generative-apps.md +2 -2
  118. package/docs/inbound.md +1 -1
  119. package/docs/multi-instance-bus.md +127 -19
  120. package/docs/public-api.md +5 -3
  121. package/docs/ui-style.md +26 -8
  122. package/docs/updates.md +1 -1
  123. package/lib/activity-verbosity.ts +5 -9
  124. package/lib/bindings.ts +23 -10
  125. package/lib/bus-api.ts +5 -2
  126. package/lib/bus-follower.ts +251 -226
  127. package/lib/bus-leader.ts +178 -31
  128. package/lib/bus-transport.ts +30 -8
  129. package/lib/bus.ts +110 -334
  130. package/lib/channel-posts.ts +29 -29
  131. package/lib/command-templates.ts +5 -5
  132. package/lib/commands.ts +150 -112
  133. package/lib/config.ts +12 -17
  134. package/lib/delivery.ts +2 -8
  135. package/lib/extension.ts +282 -72
  136. package/lib/generative-apps.ts +0 -22
  137. package/lib/inbound.ts +2 -2
  138. package/lib/journal.ts +1182 -326
  139. package/lib/keyboard.ts +2 -2
  140. package/lib/lifecycle.ts +141 -5
  141. package/lib/locks.ts +402 -23
  142. package/lib/logging.ts +151 -101
  143. package/lib/media.ts +51 -9
  144. package/lib/menu-model.ts +8 -8
  145. package/lib/menu-settings.ts +18 -18
  146. package/lib/menu-status.ts +11 -0
  147. package/lib/menu-thinking.ts +2 -2
  148. package/lib/menu.ts +5 -1
  149. package/lib/model.ts +2 -4
  150. package/lib/outbound-attachments.ts +1 -1
  151. package/lib/outbound-buttons.ts +1 -10
  152. package/lib/outbound-markup.ts +5 -24
  153. package/lib/outbound.ts +5 -5
  154. package/lib/paths.ts +177 -17
  155. package/lib/pi.ts +5 -2
  156. package/lib/polling.ts +5 -5
  157. package/lib/preview.ts +3 -3
  158. package/lib/prompt-templates.ts +1 -1
  159. package/lib/queue.ts +67 -9
  160. package/lib/recovery.ts +104 -44
  161. package/lib/replies.ts +1 -1
  162. package/lib/routing.ts +1955 -375
  163. package/lib/sections.ts +1 -1
  164. package/lib/status.ts +185 -36
  165. package/lib/sync.ts +8 -8
  166. package/lib/telegram-api.ts +48 -33
  167. package/lib/thread-cleanup-manager.ts +24 -21
  168. package/lib/thread-naming.ts +267 -9
  169. package/lib/thread-reconciler.ts +3 -1
  170. package/lib/threads.ts +1718 -491
  171. package/lib/turns.ts +1 -1
  172. package/lib/updates.ts +1072 -151
  173. package/lib/wire.ts +28 -0
  174. package/lib/workspace-admission.ts +97 -47
  175. package/lib/workspace-identity.ts +147 -0
  176. package/lib/workspace-retirement.ts +234 -102
  177. package/package.json +6 -6
  178. package/scripts/audit-exports.mjs +100 -0
  179. package/scripts/check-downgrade.mjs +80 -43
  180. package/skills/generated-control-surface/SKILL.md +3 -3
  181. package/skills/telegram-bridge/references/diagnosis.md +3 -3
package/lib/sections.ts CHANGED
@@ -284,7 +284,7 @@ function buildTelegramSectionCallbackContext(
284
284
  // --- GlobalThis Bridge ---
285
285
 
286
286
  /** @internal */
287
- export function setGlobalTelegramSectionRegistry(
287
+ function setGlobalTelegramSectionRegistry(
288
288
  registry: TelegramSectionRegistry,
289
289
  ): void {
290
290
  (globalThis as Record<string, unknown>)[SECTION_REGISTRY_KEY] = registry;
package/lib/status.ts CHANGED
@@ -1,9 +1,41 @@
1
1
  /**
2
2
  * Telegram status rendering helpers
3
3
  * Zones: telegram ui, pi agent diagnostics, tui
4
- * Builds usage, cost, and context summaries for the interactive Telegram status view
4
+ * Owns status summaries, redacted diagnostics, projection lifecycle and compact connection-failure copy
5
+ * Excludes canonical state, transport admission and filesystem policy
5
6
  */
6
7
 
8
+ import { isDeepStrictEqual } from "node:util";
9
+
10
+ /** UI copy is allowlisted; raw exception text belongs only in redacted diagnostics. */
11
+ export function formatTelegramConnectionFailure(error: unknown): string {
12
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
13
+ const status = error && typeof error === "object" && "status" in error ? error.status : undefined;
14
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
15
+ if (status === 401) return "Telegram token rejected. Run /telegram-setup.";
16
+ if (status === 403) return "Telegram access denied. Check /telegram-status --debug.";
17
+ if ((typeof code === "string" && ["ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "ECONNRESET"].includes(code)) ||
18
+ /network unavailable|fetch failed/i.test(message)) {
19
+ return "Telegram network unavailable. Retry /telegram-connect.";
20
+ }
21
+ if (/Workspace slots?.*(?:unavailable|exhausted)|no free.*slot/i.test(message)) {
22
+ return "No Telegram slot available. Check /telegram-status --debug.";
23
+ }
24
+ if (code === "incompatible-protocol" || /protocol.*(?:incompatible|mismatch)|incompatible.*protocol/i.test(message)) {
25
+ return "Telegram instances are incompatible. Update them together.";
26
+ }
27
+ if (/unsupported.*(?:journal|version)|(?:journal|version).*unsupported/i.test(message)) {
28
+ return "Telegram state version unsupported. Use a compatible runtime.";
29
+ }
30
+ if (/follower registration failed|active in another Pi instance/i.test(message)) {
31
+ return "Telegram leader is active but unavailable. Check /telegram-status --debug.";
32
+ }
33
+ if (/unfinished Thread creation does not match/i.test(message)) {
34
+ return "Telegram Thread creation is unresolved. Check /telegram-status --debug.";
35
+ }
36
+ return "Telegram connection failed. Check /telegram-status --debug.";
37
+ }
38
+
7
39
  const TELEGRAM_STATUS_DEFAULT_PROFILE_NAME = "default";
8
40
 
9
41
  export type TelegramStatusQueueLane = "control" | "priority" | "default";
@@ -434,7 +466,7 @@ function truncateTelegramRuntimeEventText(
434
466
  return `${text.slice(0, maxLength).trimEnd()}… [truncated ${text.length - maxLength} chars]`;
435
467
  }
436
468
 
437
- export function redactTelegramRuntimeMessage(
469
+ function redactTelegramRuntimeMessage(
438
470
  message: string,
439
471
  botToken: string | undefined,
440
472
  ): string {
@@ -550,7 +582,7 @@ export function registerTelegramStatusLineProvider(
550
582
  };
551
583
  }
552
584
 
553
- export function getTelegramStatusLineProviderResults(
585
+ function getTelegramStatusLineProviderResults(
554
586
  ctx: TelegramStatusLineProviderContext,
555
587
  ): TelegramStatusLineProviderResult[] {
556
588
  const results: TelegramStatusLineProviderResult[] = [];
@@ -813,13 +845,19 @@ export function createTelegramRuntimeLogScope(input: {
813
845
  };
814
846
  }
815
847
 
816
- export function createTelegramStatusSnapshot(
817
- state: TelegramBridgeStatusLineState,
818
- ): {
848
+ export interface TelegramStatusSnapshot {
819
849
  runtime: Record<string, unknown>;
820
850
  liveRoster: Record<string, unknown>;
821
851
  diagnostics: Record<string, unknown>;
822
- } {
852
+ }
853
+ export interface TelegramStoredRuntimeProjection extends TelegramStatusSnapshot {
854
+ version: 1;
855
+ source: "snapshot";
856
+ writtenAtMs: number;
857
+ }
858
+ export function createTelegramStatusSnapshot(
859
+ state: TelegramBridgeStatusLineState,
860
+ ): TelegramStatusSnapshot {
823
861
  return {
824
862
  runtime: {
825
863
  busRole: state.busRole,
@@ -840,7 +878,6 @@ export function createTelegramStatusSnapshot(
840
878
  ...(state.localBus ? { localBus: state.localBus } : {}),
841
879
  topicTargets: state.topicTargets ?? [],
842
880
  reservations: state.threadReservations ?? [],
843
- syncObservations: state.topicSyncObservations ?? [],
844
881
  },
845
882
  diagnostics: {
846
883
  pendingDispatch: state.pendingDispatch,
@@ -854,17 +891,94 @@ export function createTelegramStatusSnapshot(
854
891
  };
855
892
  }
856
893
 
894
+ function isRuntimeProjection(value: unknown): value is TelegramStoredRuntimeProjection {
895
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
896
+ const record = value as Record<string, unknown>;
897
+ return record.version === 1 && record.source === "snapshot" && Number.isSafeInteger(record.writtenAtMs) && (record.writtenAtMs as number) >= 0 &&
898
+ Object.keys(record).every(key => ["version", "source", "writtenAtMs", "runtime", "liveRoster", "diagnostics"].includes(key)) &&
899
+ ["runtime", "liveRoster", "diagnostics"].every(key => record[key] !== null && typeof record[key] === "object" && !Array.isArray(record[key]));
900
+ }
901
+ export interface TelegramRuntimeProjectionScope {
902
+ path: string;
903
+ profile: string;
904
+ }
905
+ export interface TelegramRuntimeProjectionStorage {
906
+ read: (scope: TelegramRuntimeProjectionScope) => unknown;
907
+ /** The adapter must enforce exact owner plus the supplied physical/logical scope in one transaction. */
908
+ publish: (
909
+ scope: TelegramRuntimeProjectionScope,
910
+ mutate: (current: unknown) => { value: unknown; changed: boolean },
911
+ isCurrent: () => boolean,
912
+ ) => boolean;
913
+ }
914
+ export interface TelegramRuntimeProjectionStoreOptions {
915
+ getPath: () => string;
916
+ getProfile: () => string | undefined;
917
+ /** Capture exact owner/context/session authority at submission, never renew it after the queued await. */
918
+ captureAuthority: () => (() => boolean) | undefined;
919
+ storage: TelegramRuntimeProjectionStorage;
920
+ getNowMs?: () => number;
921
+ }
922
+ /** Observational runtime storage only; Workspace, admission, transport, logs and recovery remain with their owners. */
923
+ export function createTelegramRuntimeProjectionStore(options: TelegramRuntimeProjectionStoreOptions): {
924
+ read: () => TelegramStoredRuntimeProjection | undefined;
925
+ persist: (snapshot: TelegramStatusSnapshot) => Promise<boolean>;
926
+ } {
927
+ let publicationQueue = Promise.resolve();
928
+ const { getPath, getProfile: readProfile, captureAuthority } = options;
929
+ const { read, publish } = options.storage;
930
+ const getNowMs = options.getNowMs ?? Date.now;
931
+ const getProfile = (): string => readProfile() || TELEGRAM_STATUS_DEFAULT_PROFILE_NAME;
932
+ return {
933
+ read() {
934
+ try {
935
+ const path = getPath(), profile = getProfile(), value = read({ path, profile });
936
+ return getPath() === path && getProfile() === profile && isRuntimeProjection(value) ? value : undefined;
937
+ } catch { return undefined; }
938
+ },
939
+ async persist(snapshot) {
940
+ const path = getPath(), profile = getProfile(), authority = captureAuthority();
941
+ if (!authority) return false;
942
+ // Freeze wire content at submission, but do not duplicate the event log in persistent runtime state.
943
+ const semantic = JSON.parse(JSON.stringify({ version: 1, source: "snapshot", runtime: snapshot.runtime,
944
+ liveRoster: snapshot.liveRoster, diagnostics: snapshot.diagnostics })) as Omit<TelegramStoredRuntimeProjection, "writtenAtMs">;
945
+ if (!isRuntimeProjection({ ...semantic, writtenAtMs: 0 })) throw new Error("Telegram runtime projection is malformed.");
946
+ delete semantic.diagnostics.recentRuntimeEvents;
947
+ const isCurrent = (): boolean => getPath() === path && getProfile() === profile && authority() === true;
948
+ const publication = publicationQueue.then(() => {
949
+ if (!isCurrent()) return false;
950
+ return publish({ path, profile }, current => {
951
+ if (isRuntimeProjection(current)) {
952
+ const { writtenAtMs: _writtenAtMs, ...existing } = current;
953
+ if (isDeepStrictEqual(existing, semantic)) return { value: current, changed: false };
954
+ }
955
+ const writtenAtMs = getNowMs();
956
+ if (!Number.isSafeInteger(writtenAtMs) || writtenAtMs < 0) throw new Error("Telegram runtime projection timestamp is invalid.");
957
+ return { value: { ...semantic, writtenAtMs }, changed: true };
958
+ }, isCurrent) === true;
959
+ });
960
+ publicationQueue = publication.then(() => undefined, () => undefined);
961
+ return publication;
962
+ },
963
+ };
964
+ }
965
+
857
966
  const TELEGRAM_DIAGNOSTICS_SNAPSHOT_COALESCE_MS = 100;
858
967
 
859
968
  export function createTelegramRuntimeDiagnosticsSnapshotScheduler(deps: {
860
- persistSnapshot: () => Promise<void>;
969
+ persistSnapshot: (isCurrent: () => boolean) => Promise<void>;
861
970
  recordError: (error: unknown) => void;
862
- setTimer?: (callback: () => void, ms: number) => { unref?: () => void };
863
- }): () => void {
971
+ captureScope?: () => (() => boolean) | undefined;
972
+ setTimer?: (callback: () => void, ms: number) => { unref?: () => void } | number;
973
+ clearTimer?: (timer: { unref?: () => void } | number) => void;
974
+ }): (() => void) & { resume(): void; suspend(): Promise<void> } {
864
975
  const setTimer = deps.setTimer ?? setTimeout;
865
- let timer: { unref?: () => void } | number | undefined;
976
+ const clearTimer = deps.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
977
+ let generation = 0;
978
+ let enabled = true;
979
+ let timer: { token: object; handle: { unref?: () => void } | number } | undefined;
866
980
  let persistPromise: Promise<void> | undefined;
867
- let pending = false;
981
+ let pending: (() => boolean) | undefined;
868
982
  const recordError = (error: unknown): void => {
869
983
  try {
870
984
  deps.recordError(error);
@@ -872,30 +986,72 @@ export function createTelegramRuntimeDiagnosticsSnapshotScheduler(deps: {
872
986
  // Snapshot diagnostics cannot create an unhandled scheduler rejection.
873
987
  }
874
988
  };
875
- const request = (): void => {
876
- if (timer || persistPromise) {
877
- pending = true;
878
- return;
989
+ const cancelTimer = (): void => {
990
+ const previous = timer;
991
+ timer = undefined;
992
+ if (previous) {
993
+ try { clearTimer(previous.handle); } catch (error) { recordError(error); }
879
994
  }
880
- timer = setTimer(() => {
995
+ };
996
+ const capture = (): (() => boolean) | undefined => {
997
+ if (!enabled) return undefined;
998
+ const expectedGeneration = generation;
999
+ let scope: (() => boolean) | undefined;
1000
+ try { scope = deps.captureScope ? deps.captureScope() : () => true; }
1001
+ catch (error) { recordError(error); return undefined; }
1002
+ if (!scope) return undefined;
1003
+ return () => {
1004
+ if (!enabled || generation !== expectedGeneration) return false;
1005
+ try { return scope!(); } catch (error) { recordError(error); return false; }
1006
+ };
1007
+ };
1008
+ const arm = (): void => {
1009
+ if (timer || persistPromise || !pending) return;
1010
+ const authority = pending;
1011
+ if (!authority()) { pending = undefined; return; }
1012
+ const token = {};
1013
+ const handle = setTimer(() => {
1014
+ if (timer?.token !== token) return;
881
1015
  timer = undefined;
1016
+ const publicationAuthority = pending;
1017
+ pending = undefined;
1018
+ if (!authority() || !publicationAuthority?.()) return;
882
1019
  let tracked: Promise<void>;
883
1020
  tracked = Promise.resolve()
884
- .then(deps.persistSnapshot)
1021
+ .then(() => publicationAuthority() ? deps.persistSnapshot(publicationAuthority) : undefined)
885
1022
  .catch(recordError)
886
1023
  .finally(() => {
887
1024
  if (persistPromise !== tracked) return;
888
1025
  persistPromise = undefined;
889
- if (pending) {
890
- pending = false;
891
- request();
892
- }
1026
+ // Only an explicitly captured new request can arm a successor publication.
1027
+ arm();
893
1028
  });
894
1029
  persistPromise = tracked;
895
1030
  }, TELEGRAM_DIAGNOSTICS_SNAPSHOT_COALESCE_MS);
896
- if (typeof timer !== "number") timer?.unref?.();
1031
+ timer = { token, handle };
1032
+ if (typeof handle !== "number") handle.unref?.();
1033
+ };
1034
+ const request = (): void => {
1035
+ const authority = capture();
1036
+ if (!authority) return;
1037
+ pending = authority;
1038
+ arm();
897
1039
  };
898
- return request;
1040
+ return Object.assign(request, {
1041
+ resume() {
1042
+ generation += 1;
1043
+ cancelTimer();
1044
+ pending = undefined;
1045
+ enabled = true;
1046
+ },
1047
+ suspend() {
1048
+ enabled = false;
1049
+ generation += 1;
1050
+ cancelTimer();
1051
+ pending = undefined;
1052
+ return persistPromise ?? Promise.resolve();
1053
+ },
1054
+ });
899
1055
  }
900
1056
 
901
1057
  export function getTelegramStatusBarProcessingStatus(state: {
@@ -1353,16 +1509,9 @@ function buildTelegramBridgeCompactStatusLines(
1353
1509
  : state.activeSourceMessageIds?.length
1354
1510
  ? "active"
1355
1511
  : "idle";
1356
- const diagnosticsProfileName =
1357
- state.activeProfileName === TELEGRAM_STATUS_DEFAULT_PROFILE_NAME
1358
- ? undefined
1359
- : state.activeProfileName;
1360
- const profileSuffix = diagnosticsProfileName
1361
- ? `.${diagnosticsProfileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}`
1362
- : "";
1363
1512
  const diagnosticsPaths = state.diagnosticPaths ?? {
1364
- state: `~/.pi/agent/tmp/telegram/state${profileSuffix}.json`,
1365
- logs: `~/.pi/agent/tmp/telegram/logs${profileSuffix}.jsonl`,
1513
+ state: "~/.pi/agent/tmp/pi-telegram/state.json",
1514
+ logs: "~/.pi/agent/tmp/pi-telegram/logs.jsonl",
1366
1515
  };
1367
1516
  return [
1368
1517
  "connection:",
@@ -1414,7 +1563,7 @@ export function buildTelegramBridgeStatusLines(
1414
1563
  return buildTelegramBridgeCompactStatusLines(state);
1415
1564
  }
1416
1565
 
1417
- export function buildTelegramBridgeDiagnosticStatusLines(
1566
+ function buildTelegramBridgeDiagnosticStatusLines(
1418
1567
  state: TelegramBridgeStatusLineState,
1419
1568
  ): string[] {
1420
1569
  const controlQueueCount = state.queuedItems.filter(
@@ -1591,7 +1740,7 @@ function buildTelegramStatusRoleSuffix(
1591
1740
  return ` @${state.busRole}`;
1592
1741
  }
1593
1742
 
1594
- export function buildStatusHtml(
1743
+ function buildStatusHtml(
1595
1744
  ctx: TelegramStatusContext,
1596
1745
  activeModel: TelegramStatusActiveModel | undefined,
1597
1746
  bridgeStatus?: TelegramBridgeStatusLineState,
package/lib/sync.ts CHANGED
@@ -8,6 +8,7 @@ import { getTelegramApiErrorRequestTarget, isTelegramStaleTargetHttpError } from
8
8
  import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
9
9
  import * as ThreadReconciler from "./thread-reconciler.ts";
10
10
  import { TelegramWorkspaceSlotUnavailableError } from "./workspace-slots.ts";
11
+ import { normalizeTelegramWorkspacePath } from "./workspace-identity.ts";
11
12
  import {
12
13
  createTelegramWorkspaceAdmissionOperationId,
13
14
  runWithTelegramWorkspaceAdmissionsAsync,
@@ -20,7 +21,6 @@ import {
20
21
  getTelegramThreadOwnerKey,
21
22
  isSameTelegramProcessInstance,
22
23
  isTelegramTopicTargetStaleError,
23
- normalizeTelegramWorkspacePath,
24
24
  provisionOwnBusTopic,
25
25
  type TelegramOwnTopicProvisionResult,
26
26
  type TelegramTopicTargetStore,
@@ -179,7 +179,7 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
179
179
  getNowMs?: () => number;
180
180
  }
181
181
 
182
- export function markTelegramConfigSyncChange<
182
+ function markTelegramConfigSyncChange<
183
183
  TSyncState extends TelegramSyncState,
184
184
  >(state: TSyncState, action: string, options?: { nowMs?: number }): TSyncState {
185
185
  const nowMs = options?.nowMs ?? Date.now();
@@ -245,7 +245,7 @@ export interface TelegramSessionRestartThreadCleanupDeps<
245
245
  suspendPolling: () => Promise<void>;
246
246
  }
247
247
 
248
- export function createTelegramSessionRestartThreadCleanupHandler<
248
+ function createTelegramSessionRestartThreadCleanupHandler<
249
249
  TSyncState extends TelegramSyncState,
250
250
  >(
251
251
  deps: TelegramSessionRestartThreadCleanupDeps<TSyncState>,
@@ -773,6 +773,7 @@ export async function ensureTelegramLeaderThreadBinding(
773
773
  : undefined;
774
774
  const legacyWorkspaceBinding =
775
775
  workspaceIdentity?.instanceSlot === "a" &&
776
+ deps.sessionId === undefined &&
776
777
  !persistedWorkspaceBinding &&
777
778
  typeof legacyLeaderRecord?.target.threadId === "number"
778
779
  ? {
@@ -885,10 +886,9 @@ export async function ensureTelegramLeaderThreadBinding(
885
886
  (record.status === "active" || record.status === "starting")
886
887
  );
887
888
  });
888
- // Short-circuit: when the instance already has an active thread and we are not
889
- // force-freshing, reuse it without re-provisioning. A thread belongs to the
890
- // live instance binding, not to one transient Pi session lifecycle.
891
- if (!deps.forceFreshUnnamed && priorTargets.length > 0) {
889
+ // Legacy callers without session identity may reuse the active instance target.
890
+ // Session-aware callers must resolve their exact Workspace binding instead.
891
+ if (deps.sessionId === undefined && !deps.forceFreshUnnamed && priorTargets.length > 0) {
892
892
  const record = priorTargets[0];
893
893
  deps.recordEvent(
894
894
  "telegram",
@@ -997,7 +997,7 @@ export async function ensureTelegramLeaderThreadBinding(
997
997
  }
998
998
  }
999
999
 
1000
- export const TELEGRAM_SYNC_SLICE_TARGET_BINDINGS = "target-bindings";
1000
+ const TELEGRAM_SYNC_SLICE_TARGET_BINDINGS = "target-bindings";
1001
1001
 
1002
1002
  export const TELEGRAM_SYNC_SLICES = [
1003
1003
  "bot-identity",
@@ -8,14 +8,15 @@
8
8
 
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { createWriteStream, openAsBlob } from "node:fs";
11
- import { mkdir, readdir, stat, unlink, writeFile } from "node:fs/promises";
11
+ import { mkdir, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
12
12
  import { request as requestHttps } from "node:https";
13
13
  import { join } from "node:path";
14
- import { resolveTelegramTempDir } from "./paths.ts";
14
+ import type { TelegramAttachmentSource } from "./media.ts";
15
+ import { resolveTelegramAttachmentsDir } from "./paths.ts";
15
16
  import { Readable, Transform } from "node:stream";
16
17
  import { pipeline } from "node:stream/promises";
17
18
 
18
- export const TELEGRAM_API_BASE = "https://api.telegram.org";
19
+ const TELEGRAM_API_BASE = "https://api.telegram.org";
19
20
 
20
21
  export const TELEGRAM_FILE_MAX_BYTES = 50 * 1024 * 1024;
21
22
 
@@ -34,12 +35,10 @@ export function getTelegramInboundFileByteLimitFromEnv(
34
35
  }
35
36
 
36
37
  function getTelegramApiTempDir(): string {
37
- return resolveTelegramTempDir();
38
+ return resolveTelegramAttachmentsDir();
38
39
  }
39
40
  const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
40
41
  const activeTelegramApiWorkspaceAdmissionOperationIds = new Set<string>();
41
- const TELEGRAM_TEMP_SCRATCH_FILE_PATTERN =
42
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-/u;
43
42
  const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(
44
43
  process.env,
45
44
  ["PI_TELEGRAM_INBOUND_FILE_MAX_BYTES", "TELEGRAM_MAX_FILE_SIZE_BYTES"],
@@ -750,6 +749,8 @@ export interface TelegramBridgeApiRuntimeDeps {
750
749
  tempDir: string;
751
750
  maxFileSizeBytes: number;
752
751
  tempFileMaxAgeMs: number;
752
+ /** Bot `@username` (or numeric id) naming private-chat attachments. */
753
+ getBotScope?: () => string | undefined;
753
754
  recordRuntimeEvent: (
754
755
  kind: "api" | "multipart" | "download",
755
756
  error: unknown,
@@ -774,7 +775,11 @@ export interface TelegramBridgeApiRuntime {
774
775
  fileName: string,
775
776
  options?: TelegramApiCallOptions,
776
777
  ) => Promise<TResponse>;
777
- downloadFile: (fileId: string, suggestedName: string) => Promise<string>;
778
+ downloadFile: (
779
+ fileId: string,
780
+ suggestedName: string,
781
+ source?: TelegramAttachmentSource,
782
+ ) => Promise<string>;
778
783
  deleteWebhook: (signal?: AbortSignal) => Promise<boolean>;
779
784
  getUpdates: (
780
785
  body: Record<string, unknown>,
@@ -853,6 +858,28 @@ function sanitizeFileName(name: string): string {
853
858
  return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
854
859
  }
855
860
 
861
+ /** Names follow `<kind>-<scope>-<messageId>[-<index>][-<name>|.<ext>]`. */
862
+ /**
863
+ * `scope` is the bot `@username` (id fallback) in a private chat and the public chat `@username` (id fallback) elsewhere.
864
+ * A negative chat id loses its sign, so the name never contains two adjacent dashes.
865
+ */
866
+ export function createTelegramAttachmentFileName(
867
+ source: TelegramAttachmentSource,
868
+ generatedName: string,
869
+ botScope?: string,
870
+ ): string {
871
+ const chat = source.chat;
872
+ const clean = (value: string): string => sanitizeFileName(value.replace(/^@/u, "")).replace(/^[-.]+/u, "");
873
+ const scope = source.scope ? clean(source.scope) : chat?.type === "private" || !chat
874
+ ? botScope ? clean(botScope) : chat ? String(Math.abs(chat.id)) : ""
875
+ : chat.username ? clean(chat.username) : String(Math.abs(chat.id));
876
+ const dot = generatedName.lastIndexOf(".");
877
+ const extension = dot > 0 ? sanitizeFileName(generatedName.slice(dot)) : "";
878
+ const tail = source.userFileName ? `-${clean(source.userFileName)}` : extension;
879
+ return [sanitizeFileName(source.kind), scope, String(source.messageId)].filter(Boolean).join("-") +
880
+ (source.index !== undefined ? `-${source.index}` : "") + tail;
881
+ }
882
+
856
883
  class TelegramApiMalformedSuccessError extends Error {
857
884
  constructor(method: string, detail: string) {
858
885
  super(`Telegram API ${method} ${detail}`);
@@ -1490,9 +1517,8 @@ export async function cleanupTelegramTempFiles(
1490
1517
  return 0;
1491
1518
  }
1492
1519
  for (const entry of entries) {
1493
- if (!entry.isFile() || !TELEGRAM_TEMP_SCRATCH_FILE_PATTERN.test(entry.name)) {
1494
- continue;
1495
- }
1520
+ // The directory holds only attachment scratch files, so age alone decides.
1521
+ if (!entry.isFile()) continue;
1496
1522
  const path = join(tempDir, entry.name);
1497
1523
  try {
1498
1524
  const stats = await stat(path);
@@ -1643,10 +1669,9 @@ export async function downloadTelegramFile(
1643
1669
  );
1644
1670
  assertTelegramFileSizeWithinLimit(file.file_size, options?.maxFileSizeBytes);
1645
1671
  await mkdir(tempDir, { recursive: true, mode: 0o700 });
1646
- const targetPath = join(
1647
- tempDir,
1648
- `${randomUUID()}-${sanitizeFileName(suggestedName)}`,
1649
- );
1672
+ // Names carry scope and message id, so the same message maps to the same path; publish by rename.
1673
+ const targetPath = join(tempDir, sanitizeFileName(suggestedName));
1674
+ const partPath = `${targetPath}.${randomUUID()}.part`;
1650
1675
  const response = await callTelegramTransportRequest((family) =>
1651
1676
  telegramFetch(
1652
1677
  `${TELEGRAM_API_BASE}/file/bot${configuredBotToken}/${file.file_path}`,
@@ -1665,11 +1690,12 @@ export async function downloadTelegramFile(
1665
1690
  try {
1666
1691
  await writeTelegramDownloadResponse(
1667
1692
  response,
1668
- targetPath,
1693
+ partPath,
1669
1694
  options?.maxFileSizeBytes,
1670
1695
  );
1696
+ await rename(partPath, targetPath);
1671
1697
  } catch (error) {
1672
- await removeTelegramPartialDownload(targetPath);
1698
+ await removeTelegramPartialDownload(partPath);
1673
1699
  throw error;
1674
1700
  }
1675
1701
  return targetPath;
@@ -1700,21 +1726,6 @@ export async function answerTelegramCallbackQuery(
1700
1726
  }
1701
1727
  }
1702
1728
 
1703
- export async function deleteTelegramMessage(
1704
- botToken: string | undefined,
1705
- chatId: number,
1706
- messageId: number,
1707
- ): Promise<void> {
1708
- try {
1709
- await callTelegram<boolean>(botToken, "deleteMessage", {
1710
- chat_id: chatId,
1711
- message_id: messageId,
1712
- });
1713
- } catch {
1714
- // ignore
1715
- }
1716
- }
1717
-
1718
1729
  export function createTelegramChatActionSender<TAction extends string>(
1719
1730
  sendChatAction: (
1720
1731
  chatId: number,
@@ -1802,6 +1813,7 @@ export type TelegramWorkspaceThreadDeletionTransport = (
1802
1813
 
1803
1814
  export function createDefaultTelegramBridgeApiRuntime(deps: {
1804
1815
  getBotToken: () => string | undefined;
1816
+ getBotScope?: () => string | undefined;
1805
1817
  recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
1806
1818
  captureRequestErrorHandler?: TelegramBridgeApiRuntimeDeps["captureRequestErrorHandler"];
1807
1819
  targetActivity?: TelegramApiTargetActivityRuntime;
@@ -1831,6 +1843,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
1831
1843
  tempDir: getTelegramApiTempDir(),
1832
1844
  maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
1833
1845
  tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
1846
+ ...(deps.getBotScope ? { getBotScope: deps.getBotScope } : {}),
1834
1847
  recordRuntimeEvent: deps.recordRuntimeEvent,
1835
1848
  captureRequestErrorHandler: deps.captureRequestErrorHandler,
1836
1849
  });
@@ -2040,11 +2053,13 @@ export function createTelegramBridgeApiRuntime(
2040
2053
  * Downloads a file from the Telegram servers into the local temp directory.
2041
2054
  * Used for inbound voice messages, photos, documents, etc.
2042
2055
  */
2043
- downloadFile: async (fileId, suggestedName) => {
2056
+ downloadFile: async (fileId, suggestedName, source) => {
2044
2057
  try {
2045
2058
  return await deps.client.downloadFile(
2046
2059
  fileId,
2047
- suggestedName,
2060
+ source
2061
+ ? createTelegramAttachmentFileName(source, suggestedName, deps.getBotScope?.())
2062
+ : suggestedName,
2048
2063
  deps.tempDir,
2049
2064
  {
2050
2065
  maxFileSizeBytes: deps.maxFileSizeBytes,
@@ -10,6 +10,12 @@ import { dirname } from "node:path";
10
10
  import { createHash, randomUUID } from "node:crypto";
11
11
 
12
12
  import { renameTelegramPathWithRetry, withTelegramFileTransaction } from "./locks.ts";
13
+ import { getTelegramJournalPublicationPaths } from "./paths.ts";
14
+ import {
15
+ isWireRecord as isObject,
16
+ hasOnlyWireKeys as onlyKeys,
17
+ isNonNegativeWireInteger as safeTime,
18
+ } from "./wire.ts";
13
19
  import type { TelegramWorkspaceDeletionPermit,
14
20
  TelegramWorkspaceDestructiveFence,
15
21
  TelegramWorkspaceRetirementFence } from "./workspace-admission.ts";
@@ -247,15 +253,6 @@ export interface TelegramThreadCleanupWorkStore {
247
253
  list(): TelegramThreadCleanupWorkSet[];
248
254
  }
249
255
 
250
- function isObject(value: unknown): value is Record<string, unknown> {
251
- return !!value && typeof value === "object" && !Array.isArray(value);
252
- }
253
- function onlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
254
- const allowed = new Set(keys); return Object.keys(value).every(key => allowed.has(key));
255
- }
256
- function safeTime(value: unknown): value is number {
257
- return Number.isSafeInteger(value) && (value as number) >= 0;
258
- }
259
256
  function validateWorkSet(value: unknown, profileName: string): TelegramThreadCleanupWorkSet {
260
257
  if (!isObject(value) || !onlyKeys(value, ["operationId", "createdAtMs", "entries"]) ||
261
258
  typeof value.operationId !== "string" || !value.operationId || !safeTime(value.createdAtMs) ||
@@ -311,11 +308,23 @@ function sameCandidates(left: readonly TelegramThreadCleanupCandidate[], right:
311
308
  return JSON.stringify(left) === JSON.stringify(right);
312
309
  }
313
310
 
311
+ function parseTelegramThreadCleanupWorkFile(value: unknown, profileName: string, tokenSha256: string): TelegramThreadCleanupWorkFile {
312
+ if (!isObject(value) || !onlyKeys(value, ["version", "profileName", "tokenSha256", "workSets"]) ||
313
+ value.version !== 1 || value.profileName !== profileName || value.tokenSha256 !== tokenSha256 || !Array.isArray(value.workSets))
314
+ throw new Error("Telegram Thread cleanup store identity or schema does not match.");
315
+ const workSets = value.workSets.map(workSet => validateWorkSet(workSet, profileName));
316
+ if (new Set(workSets.map(workSet => workSet.operationId)).size !== workSets.length)
317
+ throw new Error("Telegram Thread cleanup operation identity is ambiguous.");
318
+ return { version: 1, profileName, tokenSha256, workSets };
319
+ }
320
+
314
321
  export function createTelegramThreadCleanupWorkStore(options: {
315
- path: string; profileName: string; tokenSha256: string; maxWorkSets?: number;
322
+ path: string; runtimeDir?: string; profileName: string; tokenSha256: string; maxWorkSets?: number;
316
323
  maxBytes?: number; getNowMs?: () => number;
317
324
  onPublicationBoundary?: (boundary: "after-write-before-rename" | "after-rename") => void;
318
325
  }): TelegramThreadCleanupWorkStore {
326
+ options = { ...options };
327
+ const publicationPaths = getTelegramJournalPublicationPaths(options.path, options.runtimeDir);
319
328
  const maxWorkSets = options.maxWorkSets ?? 32;
320
329
  const maxBytes = options.maxBytes ?? 1024 * 1024;
321
330
  const now = options.getNowMs ?? Date.now;
@@ -344,21 +353,15 @@ export function createTelegramThreadCleanupWorkStore(options: {
344
353
  opened.mtimeNs !== before.mtimeNs) throw new Error("Telegram Thread cleanup store changed during inspection.");
345
354
  value = JSON.parse(readFileSync(fd, "utf8")) as unknown;
346
355
  } finally { closeSync(fd); }
347
- if (!isObject(value) || !onlyKeys(value, ["version", "profileName", "tokenSha256", "workSets"]) ||
348
- value.version !== 1 || value.profileName !== options.profileName ||
349
- value.tokenSha256 !== options.tokenSha256 || !Array.isArray(value.workSets))
350
- throw new Error("Telegram Thread cleanup store identity or schema does not match.");
351
- const workSets = value.workSets.map(workSet => validateWorkSet(workSet, options.profileName));
352
- if (new Set(workSets.map(workSet => workSet.operationId)).size !== workSets.length)
353
- throw new Error("Telegram Thread cleanup operation identity is ambiguous.");
354
- return { version: 1, profileName: options.profileName, tokenSha256: options.tokenSha256, workSets };
356
+ return parseTelegramThreadCleanupWorkFile(value, options.profileName, options.tokenSha256);
355
357
  };
356
358
  const publish = (file: TelegramThreadCleanupWorkFile): void => {
357
359
  if (file.workSets.length > maxWorkSets) throw new Error("Telegram Thread cleanup work-set capacity reached.");
358
360
  const serialized = `${JSON.stringify(file, null, 2)}\n`;
359
361
  if (Buffer.byteLength(serialized) > maxBytes) throw new Error("Telegram Thread cleanup byte capacity reached.");
360
- const temporaryPath = `${options.path}.${process.pid}.${randomUUID()}.tmp`;
362
+ const temporaryPath = `${publicationPaths.temporaryBasePath}.${process.pid}.${randomUUID()}.tmp`;
361
363
  mkdirSync(dirname(options.path), { recursive: true, mode: 0o700 });
364
+ mkdirSync(dirname(temporaryPath), { recursive: true, mode: 0o700 });
362
365
  try {
363
366
  writeFileSync(temporaryPath, serialized, { mode: 0o600 });
364
367
  chmodSync(temporaryPath, 0o600);
@@ -370,7 +373,7 @@ export function createTelegramThreadCleanupWorkStore(options: {
370
373
  } finally { try { unlinkSync(temporaryPath); } catch { /* rename consumed it */ } }
371
374
  };
372
375
  const mutate = <T>(operation: (file: TelegramThreadCleanupWorkFile) => T): T =>
373
- withTelegramFileTransaction(`${options.path}.transaction`, () => operation(read()));
376
+ withTelegramFileTransaction(publicationPaths.transactionPath, () => operation(read()));
374
377
  return {
375
378
  prepare(operationId, candidates) {
376
379
  if (!operationId || candidates.length === 0 || candidates.some(candidate => candidate.profileName !== options.profileName))
@@ -435,7 +438,7 @@ export function createTelegramThreadCleanupWorkStore(options: {
435
438
  },
436
439
  list() {
437
440
  return structuredClone(withTelegramFileTransaction(
438
- `${options.path}.transaction`,
441
+ publicationPaths.transactionPath,
439
442
  () => read().workSets,
440
443
  ));
441
444
  },