@llblab/pi-kit 0.3.2 → 0.4.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 (115) hide show
  1. package/AGENTS.md +3 -3
  2. package/BACKLOG.md +1 -1
  3. package/CHANGELOG.md +6 -0
  4. package/README.md +19 -10
  5. package/node_modules/@llblab/pi-actors/AGENTS.md +1 -0
  6. package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
  7. package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +16 -6
  8. package/node_modules/@llblab/pi-actors/dist/skills/music-player/SKILL.md +3 -3
  9. package/node_modules/@llblab/pi-actors/dist/skills/music-player/genapps/music-player.mjs +6 -4
  10. package/node_modules/@llblab/pi-actors/dist/skills/music-player/scripts/playback.mjs +85 -18
  11. package/node_modules/@llblab/pi-actors/dist/skills/swarm/SKILL.md +2 -6
  12. package/node_modules/@llblab/pi-actors/dist/skills/swarm/references/development-swarm.md +2 -31
  13. package/node_modules/@llblab/pi-actors/docs/recipe-library.md +1 -1
  14. package/node_modules/@llblab/pi-actors/package.json +1 -1
  15. package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +16 -6
  16. package/node_modules/@llblab/pi-actors/skills/music-player/SKILL.md +3 -3
  17. package/node_modules/@llblab/pi-actors/skills/music-player/genapps/music-player.mjs +6 -4
  18. package/node_modules/@llblab/pi-actors/skills/music-player/scripts/playback.mjs +85 -18
  19. package/node_modules/@llblab/pi-actors/skills/swarm/SKILL.md +2 -6
  20. package/node_modules/@llblab/pi-actors/skills/swarm/references/development-swarm.md +2 -31
  21. package/node_modules/@llblab/pi-clean-room/AGENTS.md +5 -0
  22. package/node_modules/@llblab/pi-clean-room/BACKLOG.md +3 -0
  23. package/node_modules/@llblab/pi-clean-room/CHANGELOG.md +15 -0
  24. package/node_modules/@llblab/pi-clean-room/README.md +61 -0
  25. package/node_modules/@llblab/pi-clean-room/banner.jpg +0 -0
  26. package/node_modules/@llblab/pi-clean-room/index.ts +178 -0
  27. package/node_modules/@llblab/pi-clean-room/package.json +53 -0
  28. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +1 -1
  29. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -0
  30. package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
  31. package/node_modules/@llblab/pi-grow-loop/skills/grow-loop/SKILL.md +3 -17
  32. package/node_modules/@llblab/pi-state-flow/AGENTS.md +28 -0
  33. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -0
  34. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +79 -0
  35. package/node_modules/@llblab/pi-state-flow/README.md +197 -0
  36. package/node_modules/@llblab/pi-state-flow/index.ts +4 -0
  37. package/node_modules/@llblab/pi-state-flow/lib/context.ts +88 -0
  38. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +29 -0
  39. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +238 -0
  40. package/node_modules/@llblab/pi-state-flow/lib/json.ts +78 -0
  41. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +38 -0
  42. package/node_modules/@llblab/pi-state-flow/lib/session.ts +49 -0
  43. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +58 -0
  44. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +103 -0
  45. package/node_modules/@llblab/pi-state-flow/lib/state.ts +20 -0
  46. package/node_modules/@llblab/pi-state-flow/lib/status.ts +16 -0
  47. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +148 -0
  48. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +47 -0
  49. package/node_modules/@llblab/pi-state-flow/lib/validation.ts +27 -0
  50. package/node_modules/@llblab/pi-state-flow/package.json +55 -0
  51. package/node_modules/@llblab/pi-telegram/BACKLOG.md +2 -0
  52. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +12 -0
  53. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +17 -5
  54. package/node_modules/@llblab/pi-telegram/index.ts +9 -0
  55. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +29 -18
  56. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -6
  57. package/node_modules/@llblab/pi-telegram/lib/bus.ts +11 -4
  58. package/node_modules/@llblab/pi-telegram/lib/routing.ts +89 -32
  59. package/node_modules/@llblab/pi-telegram/lib/sync.ts +74 -15
  60. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +32 -1
  61. package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +17 -0
  62. package/node_modules/@llblab/pi-telegram/lib/threads.ts +123 -15
  63. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  64. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +4 -2
  65. package/node_modules/@llblab/skills/abcd-context/AGENTS.md +40 -0
  66. package/node_modules/@llblab/skills/abcd-context/BACKLOG.md +3 -0
  67. package/node_modules/@llblab/skills/abcd-context/CHANGELOG.md +19 -0
  68. package/node_modules/@llblab/skills/abcd-context/README.md +30 -0
  69. package/node_modules/@llblab/skills/abcd-context/SKILL.md +122 -0
  70. package/node_modules/@llblab/skills/abcd-context/docs/README.md +9 -0
  71. package/node_modules/@llblab/skills/abcd-context/docs/protocols.md +199 -0
  72. package/node_modules/@llblab/skills/abcd-context/docs/templates.md +194 -0
  73. package/node_modules/@llblab/skills/abcd-context/docs/validation-design.md +117 -0
  74. package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/AGENTS.md +10 -0
  75. package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/BACKLOG.md +5 -0
  76. package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/CHANGELOG.md +5 -0
  77. package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/README.md +10 -0
  78. package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/docs/README.md +7 -0
  79. package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/docs/overview.md +3 -0
  80. package/node_modules/@llblab/skills/abcd-context/recipes/validate-context.json +7 -0
  81. package/node_modules/@llblab/skills/abcd-context/scripts/_self-test.mjs +244 -0
  82. package/node_modules/@llblab/skills/abcd-context/scripts/validate-context.mjs +497 -0
  83. package/node_modules/@llblab/skills/brain-storm/AGENTS.md +5 -0
  84. package/node_modules/@llblab/skills/brain-storm/SKILL.md +321 -0
  85. package/node_modules/@llblab/skills/coding-contract/SKILL.md +228 -0
  86. package/node_modules/@llblab/skills/domain-dag/AGENTS.md +6 -0
  87. package/node_modules/@llblab/skills/domain-dag/SKILL.md +257 -0
  88. package/node_modules/@llblab/skills/domain-dag/domain-dag.json +9 -0
  89. package/node_modules/@llblab/skills/domain-dag/recipes/validate-domain-dag.json +7 -0
  90. package/node_modules/@llblab/skills/domain-dag/scripts/validate-domain-dag.mjs +771 -0
  91. package/node_modules/@llblab/skills/domain-dag/scripts/validate-domain-dag.sh +4 -0
  92. package/node_modules/@llblab/skills/edge-tts/AGENTS.md +19 -0
  93. package/node_modules/@llblab/skills/edge-tts/SKILL.md +56 -0
  94. package/node_modules/@llblab/skills/edge-tts/recipes/say-edge.json +9 -0
  95. package/node_modules/@llblab/skills/edge-tts/scripts/say.mjs +1548 -0
  96. package/node_modules/@llblab/skills/edge-tts/scripts/say.sh +4 -0
  97. package/node_modules/@llblab/skills/extra-self/SKILL.md +229 -0
  98. package/node_modules/@llblab/skills/frontend-design/SKILL.md +78 -0
  99. package/node_modules/@llblab/skills/groq-stt/AGENTS.md +15 -0
  100. package/node_modules/@llblab/skills/groq-stt/SKILL.md +35 -0
  101. package/node_modules/@llblab/skills/groq-stt/recipes/transcribe-groq.json +10 -0
  102. package/node_modules/@llblab/skills/groq-stt/scripts/transcribe.mjs +190 -0
  103. package/node_modules/@llblab/skills/groq-stt/scripts/transcribe.sh +4 -0
  104. package/node_modules/@llblab/skills/mistral-stt/AGENTS.md +15 -0
  105. package/node_modules/@llblab/skills/mistral-stt/SKILL.md +35 -0
  106. package/node_modules/@llblab/skills/mistral-stt/recipes/transcribe-mistral.json +10 -0
  107. package/node_modules/@llblab/skills/mistral-stt/scripts/transcribe.mjs +191 -0
  108. package/node_modules/@llblab/skills/mistral-stt/scripts/transcribe.sh +4 -0
  109. package/node_modules/@llblab/skills/package.json +46 -0
  110. package/node_modules/@llblab/skills/re-review/SKILL.md +318 -0
  111. package/node_modules/@llblab/skills/release-flow/SKILL.md +456 -0
  112. package/node_modules/@llblab/skills/show-me/SKILL.md +144 -0
  113. package/package.json +20 -8
  114. package/node_modules/@llblab/pi-actors/dist/skills/music-player/scripts/playback-client.mjs +0 -143
  115. package/node_modules/@llblab/pi-actors/skills/music-player/scripts/playback-client.mjs +0 -143
@@ -281,19 +281,17 @@ function formatTelegramAllTabMenuChooserText(command: string): string {
281
281
  "",
282
282
  `You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.`,
283
283
  "Select the Pi thread that should handle it:",
284
+ "To restore into a new thread, send a plain message in that destination thread first.",
284
285
  ].join("\n");
285
286
  }
286
287
 
287
288
  function buildTelegramUnboundRerouteChooserMarkup(
288
289
  rerouteId: string,
289
290
  records: readonly Threads.TelegramTopicTargetRecord[],
290
- _options: {
291
- currentLeaderProfileKey?: string;
292
- currentInstanceId?: string;
293
- } = {},
291
+ options: { canRestore: boolean },
294
292
  ): Menu.TelegramReplyMarkup {
295
293
  const activeRecords = records.filter((record) => record.status === "active");
296
- const canRestoreAnyLiveThread = activeRecords.length > 0;
294
+ const canRestoreAnyLiveThread = options.canRestore && activeRecords.length > 0;
297
295
  const rows = activeRecords.map((record) => [
298
296
  {
299
297
  text: getTelegramRouteThreadButtonLabel(record),
@@ -433,6 +431,7 @@ async function deleteReservedTelegramTopicThroughReconciler(
433
431
  });
434
432
  deps.recordThreadReconciliationPlan?.(plan);
435
433
  await ThreadReconciler.applyThreadReconciliationPlan(plan, {
434
+ isCleanupTargetProtected: Threads.createTelegramCleanupTargetProtection(deps.threadStore),
436
435
  callApi: deps.callApi,
437
436
  markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
438
437
  deps.threadStore?.markStaleByTarget(
@@ -734,6 +733,8 @@ export function createTelegramInboundRouteRuntime<
734
733
  instanceId?: string;
735
734
  };
736
735
  type PendingUnboundReroute = {
736
+ sourceTarget: Queue.TelegramQueueTarget;
737
+ chooserMessageId?: number;
737
738
  messages: TMessage[];
738
739
  createdAtMs: number;
739
740
  dispatchKind: "prompt" | "command";
@@ -842,12 +843,33 @@ export function createTelegramInboundRouteRuntime<
842
843
  nextUnboundRerouteId += 1;
843
844
  const id = nextUnboundRerouteId.toString(36);
844
845
  pendingUnboundReroutes.set(id, {
846
+ sourceTarget: {
847
+ chatId: messages[0]!.chat.id,
848
+ ...(typeof messages[0]!.message_thread_id === "number"
849
+ ? { threadId: messages[0]!.message_thread_id }
850
+ : {}),
851
+ },
845
852
  messages,
846
853
  createdAtMs: Date.now(),
847
854
  dispatchKind,
848
855
  });
849
856
  return id;
850
857
  };
858
+ const rememberRerouteChooser = (id: string, messageId: number | undefined): void => {
859
+ const pending = pendingUnboundReroutes.get(id);
860
+ if (pending) pending.chooserMessageId = messageId;
861
+ };
862
+ const matchesRerouteChooser = (
863
+ pending: PendingUnboundReroute,
864
+ query: TCallbackQuery,
865
+ ): boolean => {
866
+ const message = query.message;
867
+ return !!message && pending.chooserMessageId !== undefined &&
868
+ message.message_id === pending.chooserMessageId &&
869
+ message.chat.id === pending.sourceTarget.chatId &&
870
+ (message.message_thread_id === undefined ||
871
+ message.message_thread_id === pending.sourceTarget.threadId);
872
+ };
851
873
  const pendingUnboundRerouteMediaGroups = new Map<
852
874
  string,
853
875
  {
@@ -951,6 +973,10 @@ export function createTelegramInboundRouteRuntime<
951
973
  assertExecutionCurrent?.();
952
974
  deps.recordThreadReconciliationPlan?.(plan);
953
975
  const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
976
+ isCleanupTargetProtected(target) {
977
+ assertExecutionCurrent?.();
978
+ return isRerouteTargetProtected(target);
979
+ },
954
980
  callApi: deps.callApi,
955
981
  markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
956
982
  deps.threadStore?.markStaleByTarget(
@@ -967,6 +993,14 @@ export function createTelegramInboundRouteRuntime<
967
993
  assertExecutionCurrent?.();
968
994
  return (result.incompleteActions?.length ?? 0) === 0;
969
995
  };
996
+ const isRerouteTargetProtected = (target: Queue.TelegramQueueTarget): boolean => {
997
+ const matches = (candidate: Queue.TelegramQueueTarget): boolean =>
998
+ candidate.chatId === target.chatId && candidate.threadId === target.threadId;
999
+ return (deps.getLiveThreadTargets?.() ?? []).some(matches) ||
1000
+ (deps.threadStore?.list() ?? []).some((record) => matches(record.target)) ||
1001
+ (deps.threadStore?.listReservations() ?? []).some((record) => matches(record.target)) ||
1002
+ (deps.threadStore?.listPendingProvisions() ?? []).some((record) => !!record.target && matches(record.target));
1003
+ };
970
1004
  const dismissRerouteChooserMessage = async (
971
1005
  query: TCallbackQuery,
972
1006
  assertExecutionCurrent?: () => void,
@@ -986,6 +1020,7 @@ export function createTelegramInboundRouteRuntime<
986
1020
  assertExecutionCurrent?.();
987
1021
  return true;
988
1022
  } catch (error) {
1023
+ assertExecutionCurrent?.();
989
1024
  deps.recordRuntimeEvent?.("telegram", error, {
990
1025
  phase: "reroute-chooser-delete",
991
1026
  chatId,
@@ -1103,12 +1138,14 @@ export function createTelegramInboundRouteRuntime<
1103
1138
  pending: PendingUnboundReroute,
1104
1139
  query: TCallbackQuery,
1105
1140
  successMessage: string,
1106
- assertExecutionCurrent?: () => void,
1141
+ assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query),
1107
1142
  ): Promise<void> => {
1143
+ assertExecutionCurrent();
1108
1144
  const dismissed = await dismissRerouteChooserMessage(
1109
1145
  query,
1110
1146
  assertExecutionCurrent,
1111
1147
  );
1148
+ assertExecutionCurrent();
1112
1149
  if (dismissed) {
1113
1150
  pendingUnboundReroutes.delete(rerouteId);
1114
1151
  await deps.answerCallbackQuery(query.id, successMessage);
@@ -1169,6 +1206,8 @@ export function createTelegramInboundRouteRuntime<
1169
1206
  query.data,
1170
1207
  );
1171
1208
  if (!parsed) return false;
1209
+ const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query);
1210
+ assertExecutionCurrent();
1172
1211
  const chatId = query.message?.chat?.id;
1173
1212
  const messageId = query.message?.message_id;
1174
1213
  const pending = pendingUnboundReroutes.get(parsed.rerouteId);
@@ -1176,12 +1215,18 @@ export function createTelegramInboundRouteRuntime<
1176
1215
  typeof chatId !== "number" ||
1177
1216
  typeof messageId !== "number" ||
1178
1217
  !deps.threadStore ||
1179
- !pending
1218
+ !pending ||
1219
+ !matchesRerouteChooser(pending, query)
1180
1220
  ) {
1181
1221
  await deps.answerCallbackQuery(query.id, "Message route expired.");
1182
1222
  return true;
1183
1223
  }
1224
+ if (pending.sourceTarget.threadId === undefined) {
1225
+ await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
1226
+ return true;
1227
+ }
1184
1228
  await deps.threadStore.load();
1229
+ assertExecutionCurrent();
1185
1230
  const activeRecords = getTelegramRoutableThreadRecords(
1186
1231
  deps.threadStore.list(),
1187
1232
  deps.getLiveThreadTargets?.(),
@@ -1199,19 +1244,17 @@ export function createTelegramInboundRouteRuntime<
1199
1244
  replyMarkup,
1200
1245
  );
1201
1246
  } else if (deps.sendInteractiveMessage) {
1202
- await deps.sendInteractiveMessage(
1247
+ const chooserId = await deps.sendInteractiveMessage(
1203
1248
  chatId,
1204
1249
  formatTelegramUnboundRerouteRestoreChooserText(),
1205
1250
  "html",
1206
1251
  replyMarkup,
1207
- typeof query.message?.message_thread_id === "number"
1208
- ? {
1209
- target: { chatId, threadId: query.message.message_thread_id },
1210
- replyToMessageId: messageId,
1211
- }
1212
- : undefined,
1252
+ { target: pending.sourceTarget, replyToMessageId: messageId },
1213
1253
  );
1254
+ assertExecutionCurrent();
1255
+ rememberRerouteChooser(parsed.rerouteId, chooserId);
1214
1256
  }
1257
+ assertExecutionCurrent();
1215
1258
  await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
1216
1259
  return true;
1217
1260
  };
@@ -1226,7 +1269,8 @@ export function createTelegramInboundRouteRuntime<
1226
1269
  assertExecutionCurrent();
1227
1270
  const chatId = query.message?.chat?.id;
1228
1271
  const pending = pendingUnboundReroutes.get(parsed.rerouteId);
1229
- if (typeof chatId !== "number" || !deps.threadStore || !pending) {
1272
+ if (typeof chatId !== "number" || !deps.threadStore || !pending ||
1273
+ !matchesRerouteChooser(pending, query)) {
1230
1274
  await deps.answerCallbackQuery(query.id, "Message route expired.");
1231
1275
  return true;
1232
1276
  }
@@ -1300,10 +1344,23 @@ export function createTelegramInboundRouteRuntime<
1300
1344
  parsed.threadId,
1301
1345
  );
1302
1346
  const sourceTarget =
1303
- typeof query.message?.message_thread_id === "number"
1304
- ? { chatId, threadId: query.message.message_thread_id }
1347
+ typeof pending.sourceTarget.threadId === "number"
1348
+ ? { chatId, threadId: pending.sourceTarget.threadId }
1305
1349
  : undefined;
1306
- const sourceMessageId = query.message?.message_id;
1350
+ const sourceMessageId = pending.chooserMessageId;
1351
+ if (parsed.useNewSlot && !sourceTarget) {
1352
+ await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
1353
+ return true;
1354
+ }
1355
+ if (parsed.useNewSlot && sourceTarget && record.target.chatId === sourceTarget.chatId &&
1356
+ record.target.threadId === sourceTarget.threadId) {
1357
+ await deps.answerCallbackQuery(query.id, "🚫 Selected thread is already the destination.");
1358
+ return true;
1359
+ }
1360
+ if (parsed.useNewSlot && sourceTarget && isRerouteTargetProtected(sourceTarget)) {
1361
+ await deps.answerCallbackQuery(query.id, "🚫 Thread restore source is already owned.");
1362
+ return true;
1363
+ }
1307
1364
  const currentInstanceId = deps.getCurrentInstanceId?.();
1308
1365
  const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
1309
1366
  const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(
@@ -1507,6 +1564,7 @@ export function createTelegramInboundRouteRuntime<
1507
1564
  rerouteConfirmedAtMs: nowMs,
1508
1565
  });
1509
1566
  await deps.threadStore.persist();
1567
+ assertExecutionCurrent();
1510
1568
  deps.setCurrentLeaderIdentity?.({
1511
1569
  target: sourceTarget,
1512
1570
  slot,
@@ -1944,7 +2002,7 @@ export function createTelegramInboundRouteRuntime<
1944
2002
  };
1945
2003
  const sendUnboundRerouteChooserNow = async (
1946
2004
  messages: TMessage[],
1947
- ctx: TContext,
2005
+ _ctx: TContext,
1948
2006
  reportDeferred = true,
1949
2007
  ): Promise<void> => {
1950
2008
  const message = messages[0];
@@ -1988,20 +2046,13 @@ export function createTelegramInboundRouteRuntime<
1988
2046
  const text = formatTelegramUnboundRerouteChooserText(activeRecords, {
1989
2047
  includeGuidance,
1990
2048
  });
1991
- const currentInstanceId = deps.getCurrentInstanceId?.();
1992
2049
  const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
1993
2050
  rerouteId,
1994
2051
  activeRecords,
1995
- {
1996
- currentLeaderProfileKey: getLeaderTopicProfileKey(
1997
- ctx,
1998
- currentInstanceId,
1999
- ),
2000
- currentInstanceId,
2001
- },
2052
+ { canRestore: sourceTarget !== undefined },
2002
2053
  );
2003
2054
  if (deps.sendInteractiveMessage) {
2004
- await deps.sendInteractiveMessage(
2055
+ const chooserId = await deps.sendInteractiveMessage(
2005
2056
  message.chat.id,
2006
2057
  text,
2007
2058
  "html",
@@ -2010,12 +2061,14 @@ export function createTelegramInboundRouteRuntime<
2010
2061
  ? { target: sourceTarget, replyToMessageId: message.message_id }
2011
2062
  : { replyToMessageId: message.message_id },
2012
2063
  );
2064
+ rememberRerouteChooser(rerouteId, chooserId);
2013
2065
  return;
2014
2066
  }
2015
- await deps.sendTextReply(message.chat.id, message.message_id, text, {
2067
+ const chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
2016
2068
  parseMode: "HTML",
2017
2069
  target: sourceTarget,
2018
2070
  });
2071
+ rememberRerouteChooser(rerouteId, chooserId);
2019
2072
  };
2020
2073
  const sendUnboundRerouteChooser = async (
2021
2074
  message: TMessage,
@@ -2080,9 +2133,10 @@ export function createTelegramInboundRouteRuntime<
2080
2133
  const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
2081
2134
  rerouteId,
2082
2135
  activeRecords,
2136
+ { canRestore: typeof message.message_thread_id === "number" },
2083
2137
  );
2084
2138
  if (deps.sendInteractiveMessage) {
2085
- await deps.sendInteractiveMessage(
2139
+ const chooserId = await deps.sendInteractiveMessage(
2086
2140
  message.chat.id,
2087
2141
  text,
2088
2142
  "html",
@@ -2096,10 +2150,11 @@ export function createTelegramInboundRouteRuntime<
2096
2150
  }
2097
2151
  : undefined,
2098
2152
  );
2153
+ rememberRerouteChooser(rerouteId, chooserId);
2099
2154
  return true;
2100
2155
  }
2101
2156
  if (deps.callApi) {
2102
- await deps.callApi("sendMessage", {
2157
+ const chooser = await deps.callApi<{ message_id?: number }>("sendMessage", {
2103
2158
  chat_id: message.chat.id,
2104
2159
  text,
2105
2160
  parse_mode: "HTML",
@@ -2116,12 +2171,14 @@ export function createTelegramInboundRouteRuntime<
2116
2171
  }
2117
2172
  : {}),
2118
2173
  });
2174
+ rememberRerouteChooser(rerouteId, chooser?.message_id);
2119
2175
  return true;
2120
2176
  }
2121
- await deps.sendTextReply(message.chat.id, message.message_id, text, {
2177
+ const chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
2122
2178
  parseMode: "HTML",
2123
2179
  target: options.target,
2124
2180
  });
2181
+ rememberRerouteChooser(rerouteId, chooserId);
2125
2182
  return true;
2126
2183
  };
2127
2184
  const commandOrPrompt = Commands.createTelegramCommandOrPromptRuntime<
@@ -4,10 +4,11 @@
4
4
  * Owns pure contracts for deciding when local Telegram mirror state should be refreshed without querying Telegram on every action
5
5
  */
6
6
 
7
- import { getTelegramApiErrorRequestTarget } from "./telegram-api.ts";
7
+ import { getTelegramApiErrorRequestTarget, isTelegramStaleTargetHttpError } from "./telegram-api.ts";
8
8
  import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
9
9
  import * as ThreadReconciler from "./thread-reconciler.ts";
10
10
  import {
11
+ createTelegramCleanupTargetProtection,
11
12
  getTelegramTargetFromApiBody,
12
13
  isTelegramTopicTargetStaleError,
13
14
  provisionOwnBusTopic,
@@ -121,6 +122,7 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
121
122
  | undefined;
122
123
  topicTargetStore: Pick<
123
124
  TelegramTopicTargetStore,
125
+ | "list"
124
126
  | "markStaleByTarget"
125
127
  | "persist"
126
128
  | "upsertPendingCleanup"
@@ -247,16 +249,21 @@ export function createTelegramManualThreadDisconnectHandler<
247
249
  target,
248
250
  requestedAtMs: (deps.getNowMs ?? Date.now)(),
249
251
  };
252
+ const departingRecord = deps.topicTargetStore.list().find((record) => record.instanceId === currentRecord.instanceId &&
253
+ record.target.chatId === target.chatId && record.target.threadId === target.threadId);
254
+ const isCleanupTargetProtected = createTelegramCleanupTargetProtection(deps.topicTargetStore, departingRecord);
250
255
  deps.topicTargetStore.upsertPendingCleanup(intent);
251
256
  await deps.topicTargetStore.persist();
257
+ const cleanupPlan = ThreadReconciler.planThreadReconciliation({
258
+ nowMs: (deps.getNowMs ?? Date.now)(),
259
+ currentLeaderEpoch: leaderEpoch,
260
+ records: [],
261
+ pendingCleanups: [intent],
262
+ });
252
263
  const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(
253
- ThreadReconciler.planThreadReconciliation({
254
- nowMs: (deps.getNowMs ?? Date.now)(),
255
- currentLeaderEpoch: leaderEpoch,
256
- records: [],
257
- pendingCleanups: [intent],
258
- }),
264
+ cleanupPlan,
259
265
  {
266
+ isCleanupTargetProtected,
260
267
  callApi(method, body) {
261
268
  return deps.callApi(method, body);
262
269
  },
@@ -277,6 +284,9 @@ export function createTelegramManualThreadDisconnectHandler<
277
284
  recordRuntimeEvent: deps.recordRuntimeEvent,
278
285
  },
279
286
  );
287
+ if (cleanupPlan.actions.some((action) => isCleanupTargetProtected(action.target, action))) {
288
+ return "Thread disconnect superseded by a new binding.";
289
+ }
280
290
  cleanupPending = Boolean(cleanup.incompleteActions?.length);
281
291
  }
282
292
  const leaderTarget = deps.getLeaderTarget();
@@ -404,7 +414,7 @@ export interface TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> {
404
414
  topicTargetStore: Pick<
405
415
  TelegramTopicTargetStore,
406
416
  "load" | "markStaleByTarget" | "persist"
407
- >;
417
+ > & Partial<Pick<TelegramTopicTargetStore, "invalidateTarget">>;
408
418
  getSyncState: () => TSyncState;
409
419
  setSyncState: (state: TSyncState) => void;
410
420
  recordEvent: (
@@ -413,6 +423,46 @@ export interface TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> {
413
423
  details?: Record<string, unknown>,
414
424
  ) => void;
415
425
  getNowMs?: () => number;
426
+ isCurrent?: () => boolean;
427
+ isAuthorityCurrent?: () => boolean;
428
+ }
429
+
430
+ export function captureTelegramStaleTargetRequestRecovery<TSyncState extends TelegramSyncState>(
431
+ body: Record<string, unknown>,
432
+ deps: TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> & {
433
+ topicTargetStore: Pick<TelegramTopicTargetStore, "load" | "list" | "markStaleByTarget" | "persist" | "invalidateTarget">;
434
+ getCurrentLeaderEpoch: () => number | string | undefined;
435
+ getSessionGeneration: () => number;
436
+ getProfileName: () => string | undefined;
437
+ onRecovered: () => void;
438
+ },
439
+ ): ((error: unknown) => Promise<void>) | undefined {
440
+ const target = getTelegramTargetFromApiBody(body);
441
+ const epoch = deps.getCurrentLeaderEpoch();
442
+ if (!target || epoch === undefined) return undefined;
443
+ const key = getTelegramTargetKey(target);
444
+ const record = deps.topicTargetStore.list().find((candidate) => getTelegramTargetKey(candidate.target) === key);
445
+ if (!record) return undefined;
446
+ const generation = deps.getSessionGeneration();
447
+ const profile = deps.getProfileName();
448
+ const isAuthorityCurrent = (): boolean => deps.getCurrentLeaderEpoch() === epoch &&
449
+ deps.getSessionGeneration() === generation && deps.getProfileName() === profile;
450
+ const isCurrent = (): boolean => {
451
+ const current = deps.topicTargetStore.list().find((candidate) => getTelegramTargetKey(candidate.target) === key);
452
+ return isAuthorityCurrent() &&
453
+ current?.instanceId === record.instanceId && current?.profileKey === record.profileKey &&
454
+ current?.updatedAtMs === record.updatedAtMs && current?.createdAtMs === record.createdAtMs;
455
+ };
456
+ return async (error) => {
457
+ const requestTarget = getTelegramApiErrorRequestTarget(error);
458
+ if (!isTelegramStaleTargetHttpError(error) || !requestTarget ||
459
+ getTelegramTargetKey(requestTarget) !== key || !isCurrent()) return;
460
+ if (await recoverStaleTelegramTopicApiError(
461
+ { chat_id: target.chatId, message_thread_id: target.threadId }, error, { ...deps, isCurrent, isAuthorityCurrent },
462
+ )) {
463
+ deps.onRecovered();
464
+ }
465
+ };
416
466
  }
417
467
 
418
468
  export function createTelegramStaleTopicApiErrorRecoveryRuntime<
@@ -448,12 +498,16 @@ export async function recoverStaleTelegramTopicApiError<
448
498
  deps: TelegramStaleTopicApiErrorRecoveryDeps<TSyncState>,
449
499
  ): Promise<boolean> {
450
500
  const target = getTelegramTargetFromApiBody(apiBody);
451
- if (!target || !isTelegramTopicTargetStaleError(error)) return false;
452
- await deps.topicTargetStore.load();
453
- if (
454
- !deps.topicTargetStore.markStaleByTarget(target, "deleted", String(error))
455
- ) {
456
- return false;
501
+ if (!target || !isTelegramTopicTargetStaleError(error) || deps.isCurrent?.() === false) return false;
502
+ if (deps.isCurrent) {
503
+ if (!deps.topicTargetStore.invalidateTarget || !await deps.topicTargetStore.invalidateTarget(
504
+ target, deps.isCurrent, String(error),
505
+ )) return false;
506
+ if (deps.isAuthorityCurrent?.() === false) return false;
507
+ } else {
508
+ await deps.topicTargetStore.load();
509
+ if (!deps.topicTargetStore.markStaleByTarget(target, "deleted", String(error))) return false;
510
+ await deps.topicTargetStore.persist();
457
511
  }
458
512
  const nowMs = (deps.getNowMs ?? Date.now)();
459
513
  let state = markTelegramSyncSliceSuspect(deps.getSyncState(), "topic-state", {
@@ -466,8 +520,12 @@ export async function recoverStaleTelegramTopicApiError<
466
520
  reason: "stale-api-error",
467
521
  action: "topic-target-stale",
468
522
  }) as TSyncState;
523
+ state = markTelegramSyncSliceSuspect(state, "target-bindings", {
524
+ nowMs,
525
+ reason: "stale-api-error",
526
+ action: "topic-target-stale",
527
+ }) as TSyncState;
469
528
  deps.setSyncState(state);
470
- await deps.topicTargetStore.persist();
471
529
  deps.recordEvent("bus", error, {
472
530
  phase: "topic-target-stale",
473
531
  chatId: target.chatId,
@@ -606,6 +664,7 @@ export async function ensureTelegramLeaderThreadBinding(
606
664
  });
607
665
  deps.recordThreadReconciliationPlan?.(replacementPlan);
608
666
  await ThreadReconciler.applyThreadReconciliationPlan(replacementPlan, {
667
+ isCleanupTargetProtected: createTelegramCleanupTargetProtection(deps.topicTargetStore),
609
668
  callApi: deps.callApi,
610
669
  markStaleByTarget: (target, syncStatus, lastSyncError) =>
611
670
  deps.topicTargetStore.markStaleByTarget(
@@ -466,6 +466,8 @@ export interface TelegramApiClient {
466
466
  }
467
467
 
468
468
  export interface TelegramBridgeApiRuntimeDeps {
469
+ captureRequestErrorHandler?: (body: Record<string, unknown>) =>
470
+ ((error: unknown) => Promise<void>) | undefined;
469
471
  client: TelegramApiClient;
470
472
  tempDir: string;
471
473
  maxFileSizeBytes: number;
@@ -634,6 +636,11 @@ export function getTelegramApiErrorRequestTarget(
634
636
  return target ? { ...target } : undefined;
635
637
  }
636
638
 
639
+ export function isTelegramStaleTargetHttpError(error: unknown): boolean {
640
+ if (!(error instanceof TelegramApiHttpError) || error.status !== 400) return false;
641
+ return /^Telegram API \w+ failed: HTTP 400: Bad Request: (message thread not found|thread not found|topic not found|topic deleted|topic closed|thread closed|forum topic closed|message thread closed|topic_id_invalid|topic_closed)$/i.test(error.message);
642
+ }
643
+
637
644
  export function isTelegramMessageNotModifiedError(error: unknown): boolean {
638
645
  return (
639
646
  error instanceof Error && error.message.includes("message is not modified")
@@ -1438,6 +1445,7 @@ export function createTelegramAssistantDraftSender(deps: {
1438
1445
  export function createDefaultTelegramBridgeApiRuntime(deps: {
1439
1446
  getBotToken: () => string | undefined;
1440
1447
  recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
1448
+ captureRequestErrorHandler?: TelegramBridgeApiRuntimeDeps["captureRequestErrorHandler"];
1441
1449
  }): TelegramBridgeApiRuntime {
1442
1450
  return createTelegramBridgeApiRuntime({
1443
1451
  client: createTelegramApiClient(deps.getBotToken, {
@@ -1447,12 +1455,23 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
1447
1455
  maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
1448
1456
  tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
1449
1457
  recordRuntimeEvent: deps.recordRuntimeEvent,
1458
+ captureRequestErrorHandler: deps.captureRequestErrorHandler,
1450
1459
  });
1451
1460
  }
1452
1461
 
1453
1462
  export function createTelegramBridgeApiRuntime(
1454
1463
  deps: TelegramBridgeApiRuntimeDeps,
1455
1464
  ): TelegramBridgeApiRuntime {
1465
+ const recoverRequestError = async (
1466
+ handler: ((error: unknown) => Promise<void>) | undefined,
1467
+ error: unknown,
1468
+ ): Promise<void> => {
1469
+ try {
1470
+ await handler?.(error);
1471
+ } catch (recoveryError) {
1472
+ deps.recordRuntimeEvent("api", recoveryError, { phase: "stale-target-recovery" });
1473
+ }
1474
+ };
1456
1475
  const now = deps.now ?? Date.now;
1457
1476
  const chatActionMinIntervalMs = Math.max(
1458
1477
  0,
@@ -1488,6 +1507,7 @@ export function createTelegramBridgeApiRuntime(
1488
1507
  body: Record<string, unknown>,
1489
1508
  options?: TelegramApiCallOptions,
1490
1509
  ): Promise<TResponse> => {
1510
+ const recoverError = deps.captureRequestErrorHandler?.(body);
1491
1511
  const chatActionKey = getChatActionKey(method, body);
1492
1512
  if (chatActionKey) {
1493
1513
  const nowMs = now();
@@ -1516,7 +1536,8 @@ export function createTelegramBridgeApiRuntime(
1516
1536
  gate.notBeforeMs = now() + chatActionMinIntervalMs;
1517
1537
  return result;
1518
1538
  })
1519
- .catch((error: unknown) => {
1539
+ .catch(async (error: unknown) => {
1540
+ await recoverRequestError(recoverError, error);
1520
1541
  if (error instanceof TelegramApiHttpError && error.status === 429) {
1521
1542
  const retryAfterMs = Math.max(
1522
1543
  chatActionMinIntervalMs,
@@ -1550,6 +1571,12 @@ export function createTelegramBridgeApiRuntime(
1550
1571
  try {
1551
1572
  return await deps.client.call<TResponse>(method, body, options);
1552
1573
  } catch (error) {
1574
+ await recoverRequestError(recoverError, error);
1575
+ if (method === "deleteMessage" && error instanceof TelegramApiHttpError &&
1576
+ error.status === 400 && error.message ===
1577
+ "Telegram API deleteMessage failed: HTTP 400: Bad Request: message to delete not found") {
1578
+ return true as TResponse;
1579
+ }
1553
1580
  deps.recordRuntimeEvent(
1554
1581
  "api",
1555
1582
  error,
@@ -1574,6 +1601,7 @@ export function createTelegramBridgeApiRuntime(
1574
1601
  fileName,
1575
1602
  options,
1576
1603
  ) => {
1604
+ const recoverError = deps.captureRequestErrorHandler?.(fields);
1577
1605
  try {
1578
1606
  return await deps.client.callMultipart(
1579
1607
  method,
@@ -1584,6 +1612,7 @@ export function createTelegramBridgeApiRuntime(
1584
1612
  options,
1585
1613
  );
1586
1614
  } catch (error) {
1615
+ await recoverRequestError(recoverError, error);
1587
1616
  deps.recordRuntimeEvent(
1588
1617
  "multipart",
1589
1618
  error,
@@ -1676,11 +1705,13 @@ export function createTelegramBridgeApiRuntime(
1676
1705
  sendRichMessageDraft: (body) =>
1677
1706
  callRecorded<boolean>("sendRichMessageDraft", body),
1678
1707
  editMessageText: async (body) => {
1708
+ const recoverError = deps.captureRequestErrorHandler?.(body);
1679
1709
  try {
1680
1710
  await deps.client.call("editMessageText", body);
1681
1711
  return "edited";
1682
1712
  } catch (error) {
1683
1713
  if (isTelegramMessageNotModifiedError(error)) return "unchanged";
1714
+ await recoverRequestError(recoverError, error);
1684
1715
  deps.recordRuntimeEvent(
1685
1716
  "api",
1686
1717
  error,
@@ -202,6 +202,7 @@ export interface ThreadReconciliationApplyResult {
202
202
  }
203
203
 
204
204
  export interface ThreadReconciliationApplyPorts {
205
+ isCleanupTargetProtected?: (target: ThreadTarget, action: ThreadReconciliationAction) => boolean;
205
206
  callApi?: <TResponse>(
206
207
  method: string,
207
208
  body: Record<string, unknown>,
@@ -576,6 +577,7 @@ export async function applyThreadReconciliationPlan(
576
577
  incompleteActions.push(action);
577
578
  continue;
578
579
  }
580
+ if (ports.isCleanupTargetProtected?.(action.target, action)) continue;
579
581
  let closeConfirmed = false;
580
582
  try {
581
583
  await ports.callApi("closeForumTopic", {
@@ -598,6 +600,7 @@ export async function applyThreadReconciliationPlan(
598
600
  incompleteActions.push(action);
599
601
  continue;
600
602
  }
603
+ if (ports.isCleanupTargetProtected?.(action.target, action)) continue;
601
604
  const changed =
602
605
  ports.markStaleByTarget?.(action.target, "closed") ?? false;
603
606
  if (changed) persistFences.push(action);
@@ -632,7 +635,12 @@ export async function applyThreadReconciliationPlan(
632
635
  continue;
633
636
  }
634
637
  let deleteConfirmed = false;
638
+ let superseded = false;
635
639
  for (const method of ["closeForumTopic", "deleteForumTopic"]) {
640
+ if (ports.isCleanupTargetProtected?.(action.target, action)) {
641
+ superseded = true;
642
+ break;
643
+ }
636
644
  if (shouldSkipForStaleLeaderEpoch(action, ports)) break;
637
645
  try {
638
646
  await ports.callApi(method, {
@@ -660,6 +668,15 @@ export async function applyThreadReconciliationPlan(
660
668
  incompleteActions.push(action);
661
669
  continue;
662
670
  }
671
+ if (superseded || ports.isCleanupTargetProtected?.(action.target, action)) {
672
+ ports.recordRuntimeEvent?.("telegram", "Cancelled cleanup of a protected Telegram target", {
673
+ phase: "thread-reconciler-cleanup-target-reused",
674
+ action: action.kind,
675
+ chatId: action.target.chatId,
676
+ threadId: action.target.threadId,
677
+ });
678
+ continue;
679
+ }
663
680
  if (!deleteConfirmed) {
664
681
  ports.recordRuntimeEvent?.(
665
682
  "telegram",