@llblab/pi-kit 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +2 -2
  4. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -0
  5. package/node_modules/@llblab/pi-grow-loop/README.md +6 -6
  6. package/node_modules/@llblab/pi-grow-loop/index.ts +6 -3
  7. package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
  8. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  9. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +20 -0
  10. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  11. package/node_modules/@llblab/pi-telegram/docs/architecture.md +2 -2
  12. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +4 -0
  13. package/node_modules/@llblab/pi-telegram/docs/outbound.md +27 -5
  14. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -0
  15. package/node_modules/@llblab/pi-telegram/index.ts +24 -19
  16. package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +43 -25
  17. package/node_modules/@llblab/pi-telegram/lib/activity.ts +79 -6
  18. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +110 -91
  19. package/node_modules/@llblab/pi-telegram/lib/config.ts +1 -1
  20. package/node_modules/@llblab/pi-telegram/lib/delivery.ts +18 -18
  21. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +14 -3
  22. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +2 -2
  23. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +41 -37
  24. package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +39 -42
  25. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +28 -17
  26. package/node_modules/@llblab/pi-telegram/lib/preview.ts +134 -73
  27. package/node_modules/@llblab/pi-telegram/lib/queue.ts +113 -72
  28. package/node_modules/@llblab/pi-telegram/lib/replies.ts +46 -38
  29. package/node_modules/@llblab/pi-telegram/lib/routing.ts +192 -58
  30. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +36 -3
  31. package/node_modules/@llblab/pi-telegram/lib/updates.ts +27 -35
  32. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  33. package/node_modules/@llblab/skills/abcd-context/AGENTS.md +1 -0
  34. package/node_modules/@llblab/skills/abcd-context/CHANGELOG.md +6 -2
  35. package/node_modules/@llblab/skills/abcd-context/SKILL.md +1 -1
  36. package/node_modules/@llblab/skills/abcd-context/docs/validation-design.md +11 -5
  37. package/node_modules/@llblab/skills/abcd-context/scripts/_self-test.mjs +61 -0
  38. package/node_modules/@llblab/skills/abcd-context/scripts/validate-context.mjs +67 -0
  39. package/node_modules/@llblab/skills/package.json +1 -1
  40. package/node_modules/@llblab/skills/release-flow/SKILL.md +2 -4
  41. package/package.json +4 -4
@@ -448,7 +448,19 @@ async function deleteReservedTelegramTopicThroughReconciler(
448
448
  );
449
449
  }
450
450
 
451
- export type TelegramRoutedMessage = Updates.TelegramUpdateMessage &
451
+ export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
452
+
453
+ export function isTelegramAllTabCommandExpired(
454
+ message: { date?: number; message_thread_id?: number },
455
+ nowMs = Date.now(),
456
+ ): boolean {
457
+ return message.message_thread_id === undefined &&
458
+ typeof message.date === "number" && Number.isFinite(message.date) &&
459
+ message.date > 0 && Number.isFinite(nowMs) &&
460
+ nowMs - message.date * 1000 >= TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS;
461
+ }
462
+
463
+ export type TelegramRoutedMessage = { date?: number } & Updates.TelegramUpdateMessage &
452
464
  Media.TelegramMediaMessage &
453
465
  Media.TelegramMediaGroupMessage &
454
466
  Commands.TelegramCommandRuntimeMessage &
@@ -738,6 +750,12 @@ export function createTelegramInboundRouteRuntime<
738
750
  messages: TMessage[];
739
751
  createdAtMs: number;
740
752
  dispatchKind: "prompt" | "command";
753
+ expiresAtMs?: number;
754
+ destinationSelected?: boolean;
755
+ selectionAttempted?: boolean;
756
+ dispatching?: boolean;
757
+ pauseExpiry?: () => void;
758
+ stopExpiry?: () => void;
741
759
  cleanup?: PendingRerouteCleanup;
742
760
  foreignRetry?: {
743
761
  instanceId: string;
@@ -822,24 +840,71 @@ export function createTelegramInboundRouteRuntime<
822
840
  ): void => {
823
841
  Updates.reportTelegramQueueAdmission(sources, receipts);
824
842
  };
843
+ const removePendingReroute = (id: string): void => {
844
+ pendingUnboundReroutes.get(id)?.stopExpiry?.();
845
+ pendingUnboundReroutes.delete(id);
846
+ };
847
+ const expirePendingCommand = (id: string, pending: PendingUnboundReroute): boolean => {
848
+ if (pending.destinationSelected || pending.expiresAtMs === undefined ||
849
+ Date.now() < pending.expiresAtMs) return false;
850
+ if (pendingUnboundReroutes.get(id) !== pending) return true;
851
+ for (const message of pending.messages) Updates.reportTelegramUpdateCompleted(message);
852
+ removePendingReroute(id);
853
+ return true;
854
+ };
855
+ const armPendingCommandExpiry = (id: string, pending: PendingUnboundReroute): void => {
856
+ if (pending.dispatchKind !== "command" || pending.sourceTarget.threadId !== undefined) return;
857
+ pending.stopExpiry?.();
858
+ const execution = Updates.getTelegramUpdateExecutionFence(pending.messages[0]);
859
+ const onAbort = () => removePendingReroute(id);
860
+ let timer: ReturnType<typeof setTimeout> | undefined;
861
+ let stopped = false;
862
+ pending.pauseExpiry = () => {
863
+ if (timer !== undefined) clearTimeout(timer);
864
+ timer = undefined;
865
+ };
866
+ pending.stopExpiry = () => {
867
+ stopped = true;
868
+ pending.pauseExpiry?.();
869
+ execution?.signal.removeEventListener("abort", onAbort);
870
+ };
871
+ execution?.signal.addEventListener("abort", onAbort, { once: true });
872
+ if (execution?.signal.aborted) {
873
+ onAbort();
874
+ return;
875
+ }
876
+ if (pending.expiresAtMs === undefined) {
877
+ const date = pending.messages[0]?.date;
878
+ if (typeof date !== "number" || !Number.isFinite(date) || date <= 0 || date * 1000 > Date.now()) return;
879
+ pending.expiresAtMs = date * 1000 + TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS;
880
+ }
881
+ const schedule = (): void => {
882
+ if (stopped || pending.destinationSelected || pendingUnboundReroutes.get(id) !== pending) return;
883
+ if (expirePendingCommand(id, pending)) return;
884
+ const delay = Math.min(TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS, pending.expiresAtMs! - Date.now());
885
+ timer = setTimeout(schedule, Math.max(1, delay));
886
+ timer.unref?.();
887
+ };
888
+ schedule();
889
+ };
825
890
  const prunePendingUnboundReroutes = () => {
826
891
  const nowMs = Date.now();
827
892
  for (const [id, entry] of pendingUnboundReroutes) {
828
- if (nowMs - entry.createdAtMs > 30 * 60_000) {
829
- pendingUnboundReroutes.delete(id);
893
+ if (entry.dispatchKind === "command" && entry.sourceTarget.threadId === undefined) {
894
+ expirePendingCommand(id, entry);
895
+ } else if (nowMs - entry.createdAtMs > 30 * 60_000) {
896
+ removePendingReroute(id);
830
897
  }
831
898
  }
832
- while (pendingUnboundReroutes.size > 100) {
833
- const oldest = pendingUnboundReroutes.keys().next().value;
834
- if (!oldest) break;
835
- pendingUnboundReroutes.delete(oldest);
836
- }
837
899
  };
838
900
  const storePendingUnboundReroute = (
839
901
  messages: TMessage[],
840
902
  dispatchKind: "prompt" | "command" = "prompt",
841
903
  ): string => {
842
904
  prunePendingUnboundReroutes();
905
+ if (pendingUnboundReroutes.size >= 100) {
906
+ throw new Error("Telegram route chooser capacity reached; source remains retryable.");
907
+ }
843
908
  nextUnboundRerouteId += 1;
844
909
  const id = nextUnboundRerouteId.toString(36);
845
910
  pendingUnboundReroutes.set(id, {
@@ -853,11 +918,33 @@ export function createTelegramInboundRouteRuntime<
853
918
  createdAtMs: Date.now(),
854
919
  dispatchKind,
855
920
  });
921
+ const pending = pendingUnboundReroutes.get(id)!;
922
+ armPendingCommandExpiry(id, pending);
856
923
  return id;
857
924
  };
858
925
  const rememberRerouteChooser = (id: string, messageId: number | undefined): void => {
859
926
  const pending = pendingUnboundReroutes.get(id);
860
- if (pending) pending.chooserMessageId = messageId;
927
+ if (!pending) return;
928
+ pending.chooserMessageId = messageId;
929
+ if (messageId === undefined || pending.dispatchKind !== "command" ||
930
+ pending.sourceTarget.threadId !== undefined || pending.selectionAttempted) return;
931
+ const source = pending.messages[0];
932
+ const text = source?.text?.trim();
933
+ const execution = Updates.getTelegramUpdateExecutionFence(source);
934
+ const sourceIds = Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages);
935
+ if (!text || Commands.parseTelegramCommand(text)?.name !== "start" ||
936
+ source?.from?.id === undefined || !execution?.isCurrent() || sourceIds.length !== 1 ||
937
+ expirePendingCommand(id, pending)) return;
938
+ for (const [oldId, old] of pendingUnboundReroutes) {
939
+ if (oldId === id || old.dispatchKind !== "command" || old.selectionAttempted || old.dispatching ||
940
+ old.sourceTarget.threadId !== undefined || old.sourceTarget.chatId !== pending.sourceTarget.chatId) continue;
941
+ const oldSource = old.messages[0];
942
+ const oldIds = Updates.collectTelegramAdmissionSourceUpdateIds(old.messages);
943
+ if (oldSource?.from?.id !== source.from.id || oldSource?.text?.trim() !== text ||
944
+ oldIds.length !== 1 || oldIds[0]! >= sourceIds[0]! ||
945
+ Updates.getTelegramUpdateExecutionFence(oldSource)?.signal !== execution.signal) continue;
946
+ if (Updates.reportTelegramUpdateCompleted(oldSource)) removePendingReroute(oldId);
947
+ }
861
948
  };
862
949
  const matchesRerouteChooser = (
863
950
  pending: PendingUnboundReroute,
@@ -1123,12 +1210,15 @@ export function createTelegramInboundRouteRuntime<
1123
1210
  | ((messages: TMessage[], ctx: TContext) => Promise<void>)
1124
1211
  | undefined;
1125
1212
  const dispatchPendingRerouteMessages = async (
1126
- pending: { dispatchKind: "prompt" | "command" },
1213
+ pending: Pick<PendingUnboundReroute, "dispatchKind" | "sourceTarget">,
1127
1214
  messages: TMessage[],
1128
1215
  ctx: TContext,
1129
1216
  ): Promise<void> => {
1130
1217
  if (pending.dispatchKind === "command" && dispatchReroutedCommandMessages) {
1131
1218
  await dispatchReroutedCommandMessages(messages, ctx);
1219
+ if (pending.sourceTarget.threadId === undefined) {
1220
+ for (const message of messages) Updates.reportTelegramUpdateCompleted(message);
1221
+ }
1132
1222
  return;
1133
1223
  }
1134
1224
  await promptEnqueue(messages, ctx);
@@ -1147,7 +1237,7 @@ export function createTelegramInboundRouteRuntime<
1147
1237
  );
1148
1238
  assertExecutionCurrent();
1149
1239
  if (dismissed) {
1150
- pendingUnboundReroutes.delete(rerouteId);
1240
+ removePendingReroute(rerouteId);
1151
1241
  await deps.answerCallbackQuery(query.id, successMessage);
1152
1242
  return;
1153
1243
  }
@@ -1184,6 +1274,9 @@ export function createTelegramInboundRouteRuntime<
1184
1274
  outcome?.status === "fulfilled" &&
1185
1275
  outcome.value.status === "accepted"
1186
1276
  ) {
1277
+ if (pending.dispatchKind === "command" && pending.sourceTarget.threadId === undefined) {
1278
+ Updates.reportTelegramUpdateCompleted(pending.messages[index]);
1279
+ }
1187
1280
  return false;
1188
1281
  }
1189
1282
  if (outcome?.status === "rejected") {
@@ -1216,7 +1309,8 @@ export function createTelegramInboundRouteRuntime<
1216
1309
  typeof messageId !== "number" ||
1217
1310
  !deps.threadStore ||
1218
1311
  !pending ||
1219
- !matchesRerouteChooser(pending, query)
1312
+ !matchesRerouteChooser(pending, query) ||
1313
+ expirePendingCommand(parsed.rerouteId, pending)
1220
1314
  ) {
1221
1315
  await deps.answerCallbackQuery(query.id, "Message route expired.");
1222
1316
  return true;
@@ -1258,7 +1352,7 @@ export function createTelegramInboundRouteRuntime<
1258
1352
  await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
1259
1353
  return true;
1260
1354
  };
1261
- const handleUnboundRerouteCallback = async (
1355
+ const executeUnboundRerouteCallback = async (
1262
1356
  query: TCallbackQuery,
1263
1357
  ctx: TContext,
1264
1358
  ): Promise<boolean> => {
@@ -1270,12 +1364,17 @@ export function createTelegramInboundRouteRuntime<
1270
1364
  const chatId = query.message?.chat?.id;
1271
1365
  const pending = pendingUnboundReroutes.get(parsed.rerouteId);
1272
1366
  if (typeof chatId !== "number" || !deps.threadStore || !pending ||
1273
- !matchesRerouteChooser(pending, query)) {
1367
+ !matchesRerouteChooser(pending, query) || expirePendingCommand(parsed.rerouteId, pending)) {
1274
1368
  await deps.answerCallbackQuery(query.id, "Message route expired.");
1275
1369
  return true;
1276
1370
  }
1277
1371
  await deps.threadStore.load();
1278
1372
  assertExecutionCurrent();
1373
+ if (pendingUnboundReroutes.get(parsed.rerouteId) !== pending ||
1374
+ expirePendingCommand(parsed.rerouteId, pending)) {
1375
+ await deps.answerCallbackQuery(query.id, "Message route expired.");
1376
+ return true;
1377
+ }
1279
1378
  if (pending.finalizeMessage) {
1280
1379
  await finalizePendingReroute(
1281
1380
  parsed.rerouteId,
@@ -1361,6 +1460,9 @@ export function createTelegramInboundRouteRuntime<
1361
1460
  await deps.answerCallbackQuery(query.id, "🚫 Thread restore source is already owned.");
1362
1461
  return true;
1363
1462
  }
1463
+ pending.destinationSelected = true;
1464
+ pending.selectionAttempted = true;
1465
+ pending.pauseExpiry?.();
1364
1466
  const currentInstanceId = deps.getCurrentInstanceId?.();
1365
1467
  const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
1366
1468
  const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(
@@ -1657,6 +1759,33 @@ export function createTelegramInboundRouteRuntime<
1657
1759
  );
1658
1760
  return true;
1659
1761
  };
1762
+ const handleUnboundRerouteCallback = async (
1763
+ query: TCallbackQuery,
1764
+ ctx: TContext,
1765
+ ): Promise<boolean> => {
1766
+ const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
1767
+ const pending = parsed && pendingUnboundReroutes.get(parsed.rerouteId);
1768
+ if (!parsed || !pending || pending.dispatchKind !== "command" ||
1769
+ pending.sourceTarget.threadId !== undefined || !matchesRerouteChooser(pending, query)) {
1770
+ return executeUnboundRerouteCallback(query, ctx);
1771
+ }
1772
+ if (pending.dispatching) {
1773
+ await deps.answerCallbackQuery(query.id, "Command routing is already in progress.");
1774
+ return true;
1775
+ }
1776
+ pending.dispatching = true;
1777
+ try {
1778
+ return await executeUnboundRerouteCallback(query, ctx);
1779
+ } finally {
1780
+ pending.dispatching = false;
1781
+ if (pendingUnboundReroutes.get(parsed.rerouteId) === pending &&
1782
+ pending.destinationSelected && pending.messages.length > 0 &&
1783
+ !pending.cleanup && !pending.finalizeMessage) {
1784
+ pending.destinationSelected = false;
1785
+ armPendingCommandExpiry(parsed.rerouteId, pending);
1786
+ }
1787
+ }
1788
+ };
1660
1789
  const callbackHandler = async (
1661
1790
  query: TCallbackQuery,
1662
1791
  ctx: TContext,
@@ -2122,11 +2251,11 @@ export function createTelegramInboundRouteRuntime<
2122
2251
  deps.getLiveThreadTargets?.(),
2123
2252
  );
2124
2253
  if (activeRecords.length === 0) return false;
2125
- const commandMessage = {
2254
+ const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
2126
2255
  ...message,
2127
2256
  text: commandText,
2128
2257
  caption: undefined,
2129
- } as TMessage;
2258
+ } as TMessage);
2130
2259
  const rerouteId = storePendingUnboundReroute([commandMessage], "command");
2131
2260
  Updates.reportTelegramUpdateDeferred(commandMessage);
2132
2261
  const text = formatTelegramAllTabMenuChooserText(command.name);
@@ -2135,49 +2264,52 @@ export function createTelegramInboundRouteRuntime<
2135
2264
  activeRecords,
2136
2265
  { canRestore: typeof message.message_thread_id === "number" },
2137
2266
  );
2138
- if (deps.sendInteractiveMessage) {
2139
- const chooserId = await deps.sendInteractiveMessage(
2140
- message.chat.id,
2141
- text,
2142
- "html",
2143
- replyMarkup,
2144
- options.target || options.replyToSource
2145
- ? {
2146
- ...(options.target ? { target: options.target } : {}),
2147
- ...(options.replyToSource
2148
- ? { replyToMessageId: message.message_id }
2149
- : {}),
2150
- }
2151
- : undefined,
2152
- );
2153
- rememberRerouteChooser(rerouteId, chooserId);
2154
- return true;
2155
- }
2156
- if (deps.callApi) {
2157
- const chooser = await deps.callApi<{ message_id?: number }>("sendMessage", {
2158
- chat_id: message.chat.id,
2159
- text,
2160
- parse_mode: "HTML",
2161
- reply_markup: replyMarkup,
2162
- ...(typeof options.target?.threadId === "number"
2163
- ? { message_thread_id: options.target.threadId }
2164
- : {}),
2165
- ...(options.replyToSource
2166
- ? {
2167
- reply_parameters: {
2168
- message_id: message.message_id,
2169
- allow_sending_without_reply: true,
2170
- },
2171
- }
2172
- : {}),
2173
- });
2174
- rememberRerouteChooser(rerouteId, chooser?.message_id);
2175
- return true;
2267
+ let chooserId: number | undefined;
2268
+ try {
2269
+ if (deps.sendInteractiveMessage) {
2270
+ chooserId = await deps.sendInteractiveMessage(
2271
+ message.chat.id,
2272
+ text,
2273
+ "html",
2274
+ replyMarkup,
2275
+ options.target || options.replyToSource
2276
+ ? {
2277
+ ...(options.target ? { target: options.target } : {}),
2278
+ ...(options.replyToSource
2279
+ ? { replyToMessageId: message.message_id }
2280
+ : {}),
2281
+ }
2282
+ : undefined,
2283
+ );
2284
+ } else if (deps.callApi) {
2285
+ const chooser = await deps.callApi<{ message_id?: number }>("sendMessage", {
2286
+ chat_id: message.chat.id,
2287
+ text,
2288
+ parse_mode: "HTML",
2289
+ reply_markup: replyMarkup,
2290
+ ...(typeof options.target?.threadId === "number"
2291
+ ? { message_thread_id: options.target.threadId }
2292
+ : {}),
2293
+ ...(options.replyToSource
2294
+ ? {
2295
+ reply_parameters: {
2296
+ message_id: message.message_id,
2297
+ allow_sending_without_reply: true,
2298
+ },
2299
+ }
2300
+ : {}),
2301
+ });
2302
+ chooserId = chooser?.message_id;
2303
+ } else {
2304
+ chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
2305
+ parseMode: "HTML",
2306
+ target: options.target,
2307
+ });
2308
+ }
2309
+ } catch (error) {
2310
+ removePendingReroute(rerouteId);
2311
+ throw error;
2176
2312
  }
2177
- const chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
2178
- parseMode: "HTML",
2179
- target: options.target,
2180
- });
2181
2313
  rememberRerouteChooser(rerouteId, chooserId);
2182
2314
  return true;
2183
2315
  };
@@ -2497,6 +2629,8 @@ export function createTelegramInboundRouteRuntime<
2497
2629
  deps.getLiveThreadTargets?.(),
2498
2630
  );
2499
2631
  const command = getKnownTelegramAllTabCommand(text);
2632
+ // Returning before deferral lets the admission worker terminally settle expired replay.
2633
+ if (command && command.name !== "thread" && isTelegramAllTabCommandExpired(message)) return;
2500
2634
  if (bindings.length > 0 && command && command.name !== "thread") {
2501
2635
  if (
2502
2636
  await sendAllTabCommandChooser(command, text, message as TMessage, {
@@ -1783,11 +1783,44 @@ export function createTelegramBridgeApiRuntime(
1783
1783
  */
1784
1784
  export function createTelegramApiClient(
1785
1785
  getBotToken: () => string | undefined,
1786
- options: TelegramAnswerCallbackQueryOptions = {},
1786
+ options: TelegramAnswerCallbackQueryOptions & { now?: () => number } = {},
1787
1787
  ): TelegramApiClient {
1788
+ const now = options.now ?? Date.now;
1789
+ const draftRetryNotBeforeByTarget = new Map<string, number>();
1788
1790
  return {
1789
- call: async (method, body, options) => {
1790
- return callTelegram(getBotToken(), method, body, options);
1791
+ call: async <TResponse>(
1792
+ method: string,
1793
+ body: Record<string, unknown>,
1794
+ options?: TelegramApiCallOptions,
1795
+ ): Promise<TResponse> => {
1796
+ const token = getBotToken();
1797
+ // Cooldown keys retain only the public bot-id prefix, not the credential.
1798
+ const botId = token?.match(/^(\d+):/)?.[1];
1799
+ const isDraft = method === "sendMessageDraft" || method === "sendRichMessageDraft";
1800
+ const draftKey = isDraft && botId
1801
+ ? `${botId}:${String(body.chat_id)}:${String(body.message_thread_id ?? "all")}`
1802
+ : undefined;
1803
+ if (draftKey) {
1804
+ const nowMs = now();
1805
+ for (const [key, deadline] of draftRetryNotBeforeByTarget) {
1806
+ if (nowMs >= deadline) draftRetryNotBeforeByTarget.delete(key);
1807
+ }
1808
+ if (draftRetryNotBeforeByTarget.has(draftKey)) return false as TResponse;
1809
+ }
1810
+ try {
1811
+ // A draft is a replaceable snapshot, not a body to replay after backoff.
1812
+ return await callTelegram<TResponse>(
1813
+ token, method, body, isDraft ? { ...options, maxAttempts: 1 } : options,
1814
+ );
1815
+ } catch (error) {
1816
+ if (draftKey && isRetryableTelegramApiError(error)) {
1817
+ draftRetryNotBeforeByTarget.set(draftKey, Math.max(
1818
+ draftRetryNotBeforeByTarget.get(draftKey) ?? 0,
1819
+ now() + getTelegramRetryDelayMs(error, 0, options?.retryBaseDelayMs ?? 500),
1820
+ ));
1821
+ }
1822
+ throw error;
1823
+ }
1791
1824
  },
1792
1825
  callMultipart: async (
1793
1826
  method,
@@ -481,12 +481,7 @@ const TELEGRAM_UPDATE_ADMISSION_BINDING = Symbol(
481
481
 
482
482
  interface TelegramUpdateAdmissionBinding {
483
483
  sourceUpdateId: number;
484
- report: (
485
- outcome: Extract<
486
- TelegramUpdateAdmissionOutcome,
487
- { kind: "deferred" | "queued" }
488
- >,
489
- ) => void;
484
+ report: (outcome: TelegramUpdateAdmissionOutcome) => void;
490
485
  }
491
486
 
492
487
  export type TelegramQueueAdmissionReceiptLike = TelegramQueueAdmissionReceipt;
@@ -589,6 +584,16 @@ export function collectTelegramAdmissionSourceUpdateIds(
589
584
  return [...sourceUpdateIds].sort((left, right) => left - right);
590
585
  }
591
586
 
587
+ /** Report source completion; true means reported, not a durable settlement acknowledgement. */
588
+ export function reportTelegramUpdateCompleted(value: unknown): boolean {
589
+ const binding = getTelegramUpdateAdmissionBinding(value);
590
+ if (!binding) return false;
591
+ const execution = getTelegramUpdateExecutionFence(value);
592
+ if (execution && !execution.isCurrent()) return false;
593
+ binding.report({ kind: "complete" });
594
+ return true;
595
+ }
596
+
592
597
  export function reportTelegramUpdateDeferred(value: unknown): boolean {
593
598
  const binding = getTelegramUpdateAdmissionBinding(value);
594
599
  if (!binding) return false;
@@ -1810,10 +1815,7 @@ export interface TelegramUpdateWorkerRuntime<TContext> {
1810
1815
  signal: () => void;
1811
1816
  settleDeferred: (input: {
1812
1817
  updateId: number;
1813
- outcome: Extract<
1814
- TelegramUpdateAdmissionOutcome,
1815
- { kind: "deferred" | "queued" }
1816
- >;
1818
+ outcome: TelegramUpdateAdmissionOutcome;
1817
1819
  signal: AbortSignal;
1818
1820
  }) => void;
1819
1821
  isQueueReceiptCommitted: (
@@ -3018,6 +3020,13 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3018
3020
  return;
3019
3021
  }
3020
3022
  const claim = claims.get(input.updateId);
3023
+ if (input.outcome.kind === "complete") {
3024
+ // Expiry may retire only a still-deferred source, never accepted queue work.
3025
+ if (claim !== "deferred") return;
3026
+ const result = commitCompletedBatch(expectedOwner, [input.updateId]);
3027
+ if (!result) transition("idle", input.updateId);
3028
+ return;
3029
+ }
3021
3030
  if (input.outcome.kind === "deferred") {
3022
3031
  if (claim === "queued") return;
3023
3032
  if (claim !== "deferred") {
@@ -3453,10 +3462,7 @@ export interface TelegramUpdateAdmissionHandleDeps<
3453
3462
  ) => Promise<void>;
3454
3463
  registry?: TelegramUpdateHandlerRegistry;
3455
3464
  onLateOutcome?: (
3456
- outcome: Extract<
3457
- TelegramUpdateAdmissionOutcome,
3458
- { kind: "deferred" | "queued" }
3459
- >,
3465
+ outcome: TelegramUpdateAdmissionOutcome,
3460
3466
  details: {
3461
3467
  updateId: number;
3462
3468
  ctx: TContext;
@@ -3467,24 +3473,15 @@ export interface TelegramUpdateAdmissionHandleDeps<
3467
3473
  }
3468
3474
 
3469
3475
  function mergeTelegramReportedAdmissionOutcome(
3470
- current:
3471
- | Extract<
3472
- TelegramUpdateAdmissionOutcome,
3473
- { kind: "deferred" | "queued" }
3474
- >
3475
- | undefined,
3476
- next: Extract<
3477
- TelegramUpdateAdmissionOutcome,
3478
- { kind: "deferred" | "queued" }
3479
- >,
3476
+ current: TelegramUpdateAdmissionOutcome | undefined,
3477
+ next: TelegramUpdateAdmissionOutcome,
3480
3478
  updateId: number,
3481
- ): Extract<
3482
- TelegramUpdateAdmissionOutcome,
3483
- { kind: "deferred" | "queued" }
3484
- > {
3479
+ ): TelegramUpdateAdmissionOutcome {
3485
3480
  if (!current || current.kind === "deferred") return next;
3486
3481
  if (next.kind === "deferred") return current;
3487
- if (areTelegramQueueAdmissionReceiptsEqual(current, next)) return current;
3482
+ if (current.kind === "complete" && next.kind === "complete") return current;
3483
+ if (current.kind === "queued" && next.kind === "queued" &&
3484
+ areTelegramQueueAdmissionReceiptsEqual(current, next)) return current;
3488
3485
  throw new TelegramUpdateAdmissionOutcomeError(
3489
3486
  `Telegram update ${updateId} reported conflicting queue outcomes.`,
3490
3487
  );
@@ -3529,12 +3526,7 @@ export function createTelegramUpdateAdmissionHandle<
3529
3526
  execution.assertCurrent();
3530
3527
  if (verdict === "consume") return { kind: "complete" };
3531
3528
  let immediate = true;
3532
- let outcome:
3533
- | Extract<
3534
- TelegramUpdateAdmissionOutcome,
3535
- { kind: "deferred" | "queued" }
3536
- >
3537
- | undefined;
3529
+ let outcome: TelegramUpdateAdmissionOutcome | undefined;
3538
3530
  const boundUpdate = bindTelegramUpdateExecutionFence(
3539
3531
  bindTelegramUpdateAdmissionSource(update, (next) => {
3540
3532
  if (immediate) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.43.1",
3
+ "version": "0.44.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -36,5 +36,6 @@
36
36
  - `Path Portability`: Resolve paths through Node rather than platform-specific shell utilities.
37
37
  - `Human Output`: Emit concise readable logs and a summary; `NO_COLOR=1` suppresses color.
38
38
  - `Bounded Scanning`: Skip link scanning above the configured byte threshold while validating the surrounding graph.
39
+ - `Contiguous Lists`: Fail blank lines inside one Markdown list; list items are contiguous structural peers, not paragraphs. Ignore fenced examples and bounded-scan exclusions.
39
40
  - `Compact Tables`: Fail non-compact delimiter cells; require exactly three hyphens with one space inside each pipe, while table rows over 120 characters only warn.
40
41
  - `Core Shape Flexibility`: Accept both numbered mature-project sections and compact skill-style durable sections.
@@ -1,8 +1,12 @@
1
1
  # Changelog
2
2
 
3
- ## 1.5.3
3
+ ## 1.14.1
4
4
 
5
- - `Markdown Freedom`: LaTeX syntax is now allowed, while table checks always enforce compact three-hyphen delimiter cells and warn on rows over 120 characters without imposing a maximum. Indented backtick and tilde code fences remain excluded from table validation. Impact: context authors can use mathematical notation and fenced examples while receiving default, noise-focused table formatting guidance.
5
+ - `Contiguous Markdown Lists`: The validator now rejects blank lines inside continuous lists, including multiline and nested item boundaries, while ignoring fenced examples and bounded large-reference scans. Impact: list items remain structural peers instead of being formatted as separate paragraphs.
6
+
7
+ ## 1.13.2
8
+
9
+ - `Markdown Freedom`: LaTeX syntax is now allowed, while table checks always enforce compact three-hyphen delimiter cells and warn on rows over 120 characters without imposing a maximum. Indented backtick and tilde code fences remain excluded from table validation. Impact: context authors can use mathematical notation and fenced examples while receiving default, noise-focused table formatting guidance
6
10
 
7
11
  ## 1.5.2
8
12
 
@@ -102,7 +102,7 @@ Run the Node validator from the project root or pass an explicit root:
102
102
  node "${SKILL_DIR}/scripts/validate-context.mjs" /path/to/project
103
103
  ```
104
104
 
105
- Output is human-readable. Markdown table checks always require compact delimiter cells such as `| --- | ---: | :--- |`; non-compact delimiters fail validation, while rows longer than 120 characters only warn without imposing a maximum length.
105
+ Output is human-readable. Markdown list items must remain contiguous: blank lines inside one list fail validation, including between multiline or nested items, while fenced examples are ignored. Markdown table checks always require compact delimiter cells such as `| --- | ---: | :--- |`; non-compact delimiters fail validation, while rows longer than 120 characters only warn without imposing a maximum length.
106
106
 
107
107
  Useful environment controls:
108
108
 
@@ -28,10 +28,11 @@ A missing or non-directory root fails before validation.
28
28
  6. `README reachability — Warning`: Finds subtree README files with no inbound Markdown link.
29
29
  7. `Meta-protocol presence — Warning`: Checks the durable file for `Meta-Protocol Principles`.
30
30
  8. `Bloat signals — Warning`: Reports low information density or sparse structure in the durable file.
31
- 9. `Markdown tables — Error/Warning`: Always fails non-compact delimiter rows and warns about table rows longer than 120 characters.
32
- 10. `Freshness — Warning`: Reports durable files older than 30 days.
33
- 11. `Docs directory — Warning`: Checks for `/docs`.
34
- 12. `Docs index coverage — Warning`: Detects docs missing from `docs/README.md` and indexed files that do not exist.
31
+ 9. `Markdown lists — Error`: Fails blank lines within one continuous list, including multiline and nested items, while ignoring fenced examples and files above the bounded Markdown scan limit.
32
+ 10. `Markdown tables Error/Warning`: Always fails non-compact delimiter rows and warns about table rows longer than 120 characters.
33
+ 11. `Freshness — Warning`: Reports durable files older than 30 days.
34
+ 12. `Docs directory — Warning`: Checks for `/docs`.
35
+ 13. `Docs index coverage — Warning`: Detects docs missing from `docs/README.md` and indexed files that do not exist.
35
36
 
36
37
  ## Severity Contract
37
38
 
@@ -70,6 +71,10 @@ The validator avoids a hard file-length limit. It checks independent signals:
70
71
 
71
72
  Signals suggest consolidation; they do not replace judgment.
72
73
 
74
+ ## Markdown Lists
75
+
76
+ List checks apply to Markdown within the bounded scan size and outside fenced code blocks. A blank line fails when it separates sibling list items, a list item from its nested list, nested sibling items, or a multiline item's continuation from its next item. Paragraphs and independent blocks remain separated by blank lines normally.
77
+
73
78
  ## Markdown Tables
74
79
 
75
80
  Table checks always run and have no enabling option.
@@ -93,7 +98,8 @@ Validation prints each check and a summary by default. `NO_COLOR=1` disables ANS
93
98
  3. A missing path, which must fail clearly.
94
99
  4. The removed `--json` option, which must fail clearly.
95
100
  5. A temporary fixture with an out-of-range line reference, which must fail clearly.
96
- 6. Temporary fixtures proving that LaTeX and compact table delimiters pass, non-compact delimiters fail, and rows over 120 characters warn.
101
+ 6. Temporary fixtures proving that contiguous multiline/nested lists and fenced examples pass while blank separators inside lists fail.
102
+ 7. Temporary fixtures proving that LaTeX and compact table delimiters pass, non-compact delimiters fail, and rows over 120 characters warn.
97
103
 
98
104
  The fixture remains linked from [its README](../fixtures/abcd-project/README.md).
99
105