@llblab/pi-kit 0.35.0 → 0.35.2

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 (68) hide show
  1. package/BACKLOG.md +2 -0
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +1 -1
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +3 -2
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -6
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +3 -2
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +11 -3
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +2 -2
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +22 -0
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +75 -16
  12. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +1 -1
  13. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +11 -1
  14. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +30 -19
  15. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +2 -0
  16. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +21 -8
  17. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +61 -46
  18. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +7 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +80 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +36 -0
  21. package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +80 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +12 -0
  23. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +33 -3
  24. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +2 -1
  25. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +18 -3
  26. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +13 -0
  27. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +33 -12
  28. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +6 -2
  29. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +46 -4
  30. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +5 -1
  31. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +2 -2
  32. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +2 -0
  33. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +137 -13
  34. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +11 -0
  35. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +31 -8
  36. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +12 -5
  37. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +21 -3
  38. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +5 -1
  39. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +5 -1
  40. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +26 -13
  41. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +6 -2
  42. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +57 -12
  43. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  44. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -1
  45. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +6 -2
  46. package/node_modules/@llblab/pi-telegram/docs/outbound.md +4 -2
  47. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +13 -2
  48. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +2 -1
  49. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +111 -15
  50. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -0
  51. package/node_modules/@llblab/pi-telegram/lib/bus.ts +30 -21
  52. package/node_modules/@llblab/pi-telegram/lib/commands.ts +21 -8
  53. package/node_modules/@llblab/pi-telegram/lib/extension.ts +64 -56
  54. package/node_modules/@llblab/pi-telegram/lib/journal.ts +80 -0
  55. package/node_modules/@llblab/pi-telegram/lib/logging.ts +122 -0
  56. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +36 -3
  57. package/node_modules/@llblab/pi-telegram/lib/paths.ts +22 -3
  58. package/node_modules/@llblab/pi-telegram/lib/polling.ts +44 -10
  59. package/node_modules/@llblab/pi-telegram/lib/queue.ts +43 -4
  60. package/node_modules/@llblab/pi-telegram/lib/replies.ts +3 -2
  61. package/node_modules/@llblab/pi-telegram/lib/routing.ts +140 -18
  62. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +40 -7
  63. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -10
  64. package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +6 -1
  65. package/node_modules/@llblab/pi-telegram/lib/threads.ts +37 -20
  66. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +69 -17
  67. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  68. package/package.json +2 -2
@@ -1667,7 +1667,7 @@ export function createTelegramCommandHandler(deps) {
1667
1667
  const captured = { ...deps }, parsed = { ...command }, message = parsed.name === "status"
1668
1668
  ? copySelectedMenuCarrier(original)
1669
1669
  : copySelectedReplyAnchor(original);
1670
- const assertRecipientCurrent = admission.assertRecipientCurrent, report = admission.reportCompleted;
1670
+ const assertRecipientCurrent = admission.assertRecipientCurrent, report = admission.reportCompleted, beforeEffects = admission.beforeEffects;
1671
1671
  const effects = parsed.name === "status"
1672
1672
  ? undefined
1673
1673
  : {
@@ -1693,8 +1693,13 @@ export function createTelegramCommandHandler(deps) {
1693
1693
  throw new Error(`Selected ${parsed.name} plan owner is unavailable.`);
1694
1694
  };
1695
1695
  return issueSelectedOnce(assertCurrent, async () => {
1696
- if (plan.completion === "before-effect" && !report.call(admission))
1697
- return false;
1696
+ if (plan.completion === "before-effect") {
1697
+ if (!report.call(admission))
1698
+ return false;
1699
+ if (beforeEffects)
1700
+ await beforeEffects();
1701
+ assertRecipientCurrent();
1702
+ }
1698
1703
  return prepared.run({
1699
1704
  message,
1700
1705
  command: parsed,
@@ -1720,7 +1725,7 @@ export function createTelegramCommandHandler(deps) {
1720
1725
  ...copySelectedReplyAnchor(original),
1721
1726
  from: { ...original.from },
1722
1727
  };
1723
- const assertRecipientCurrent = admission.assertRecipientCurrent, reportCompleted = admission.reportCompleted;
1728
+ const assertRecipientCurrent = admission.assertRecipientCurrent, reportCompleted = admission.reportCompleted, beforeEffects = admission.beforeEffects;
1724
1729
  const assertAdmissionCurrent = createSelectedSemanticGuard(admission, () => captured.assertExecutionCurrent?.(original), false);
1725
1730
  const assertSemanticCurrent = () => {
1726
1731
  assertAdmissionCurrent();
@@ -1732,7 +1737,7 @@ export function createTelegramCommandHandler(deps) {
1732
1737
  persistAllowedUserId: async () => {
1733
1738
  throw new Error(`Selected ${name} cannot acquire pairing authority.`);
1734
1739
  },
1735
- }, { assertSemanticCurrent, assertRecipientCurrent, reportCompleted }));
1740
+ }, { assertSemanticCurrent, assertRecipientCurrent, reportCompleted, beforeEffects }));
1736
1741
  };
1737
1742
  const prepareSelectedConfirmation = (name) => (command, messages, ctx, admission) => {
1738
1743
  const original = messages[0], captured = { ...deps };
@@ -2036,7 +2041,7 @@ export function createTelegramCommandHandler(deps) {
2036
2041
  typeof admission.reportCompleted !== "function")
2037
2042
  return undefined;
2038
2043
  const assertSourceCurrent = admission.assertSourceCurrent, assertRecipientCurrent = admission.assertRecipientCurrent;
2039
- const reportCompleted = admission.reportCompleted, assertExecutionCurrent = deps.assertExecutionCurrent;
2044
+ const reportCompleted = admission.reportCompleted, beforeEffects = admission.beforeEffects, assertExecutionCurrent = deps.assertExecutionCurrent;
2040
2045
  const assertIssuanceCurrent = () => {
2041
2046
  assertSourceCurrent.call(admission);
2042
2047
  assertExecutionCurrent?.call(deps, original);
@@ -2060,8 +2065,14 @@ export function createTelegramCommandHandler(deps) {
2060
2065
  openSettingsMenu: menuDeps.openSettingsMenu &&
2061
2066
  ((value, context) => menuDeps.openSettingsMenu(value, context, recipientOptions)),
2062
2067
  };
2063
- return issueSelectedOnce(assertIssuanceCurrent, async () => reportCompleted.call(admission) &&
2064
- handleTelegramCommandRuntime(name, message, ctx, recipientDeps, args));
2068
+ return issueSelectedOnce(assertIssuanceCurrent, async () => {
2069
+ if (!reportCompleted.call(admission))
2070
+ return false;
2071
+ if (beforeEffects)
2072
+ await beforeEffects();
2073
+ assertRecipientCurrent();
2074
+ return handleTelegramCommandRuntime(name, message, ctx, recipientDeps, args);
2075
+ });
2065
2076
  },
2066
2077
  });
2067
2078
  }
@@ -2157,6 +2168,8 @@ async function handleTelegramHelpCommand(commandName, message, ctx, deps, admiss
2157
2168
  assertSemanticCurrent();
2158
2169
  if (!admission.reportCompleted())
2159
2170
  return false;
2171
+ if (admission.beforeEffects)
2172
+ await admission.beforeEffects();
2160
2173
  admission.assertRecipientCurrent();
2161
2174
  }
2162
2175
  const assertEffectCurrent = admission?.assertRecipientCurrent ??
@@ -658,6 +658,12 @@ export default function (pi) {
658
658
  const promptDispatchRuntime = Runtime.createTelegramPromptDispatchRuntime({
659
659
  lifecycle,
660
660
  typing,
661
+ failureLog: runtimeDiagnostics.createFailureLog({
662
+ category: "typing",
663
+ failurePhase: "typing-presence",
664
+ outcomePhase: "typing-presence-recovered",
665
+ label: "typing presence",
666
+ }),
661
667
  getDefaultChatId: proactivePushChatIdGetter,
662
668
  isThreadedMode() {
663
669
  return deliveryTargetPolicyRuntime.getTargetPolicyView().threaded === true;
@@ -1033,6 +1039,29 @@ export default function (pi) {
1033
1039
  delivery: deliveryLifecycleRuntime,
1034
1040
  api: telegramApiTargetActivityRuntime,
1035
1041
  });
1042
+ const prepareTemporaryThreadJournalInspection = function (target, requiredJournalBindingKeys, ownInputs) {
1043
+ const binding = resolveTelegramUpdateJournalBinding();
1044
+ const botToken = configStore.getBotToken();
1045
+ if (!binding || !botToken)
1046
+ return undefined;
1047
+ const profile = configStore.getActiveProfileName() ?? "default";
1048
+ const botIdentity = Journal.createTelegramUpdateJournalBotIdentity({ botToken, botId: getTelegramBotId() });
1049
+ return {
1050
+ directory: Paths.resolveTelegramTempDir(), profile, botIdentity,
1051
+ pollingPath: telegramLeaderJournalPath.resolve(configStore.getActiveProfileName()),
1052
+ requiredJournalBindingKeys: [binding.recoveryKey, ...requiredJournalBindingKeys],
1053
+ ...(target.threadId !== undefined && ownInputs
1054
+ ? { cleanup: { target: { chatId: target.chatId, threadId: target.threadId }, ownInputs } } : {}),
1055
+ limits: { maxDirectoryEntries: 10_000, maxFiles: 4096, maxBytes: 64 * 1024 * 1024,
1056
+ maxEntries: 10_000, maxWork: 100_000 },
1057
+ withSourceReference(path, operation) {
1058
+ return telegramJournalReferenceRegistry.withReference({
1059
+ referenceClass: "operator-disposition",
1060
+ recoveryKey: Journal.createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity }),
1061
+ }, operation);
1062
+ },
1063
+ };
1064
+ };
1036
1065
  const inboundRouteRuntime = Routing.createTelegramInboundRouteRuntime({
1037
1066
  configStore,
1038
1067
  callApi: callTelegramApi,
@@ -1112,53 +1141,24 @@ export default function (pi) {
1112
1141
  });
1113
1142
  },
1114
1143
  inspectTemporaryThreadSources(target, requiredJournalBindingKeys, ownInputs) {
1115
- const binding = resolveTelegramUpdateJournalBinding();
1116
- const botToken = configStore.getBotToken();
1117
- if (!binding || !botToken)
1144
+ const input = prepareTemporaryThreadJournalInspection(target, requiredJournalBindingKeys, ownInputs);
1145
+ if (!input)
1118
1146
  return undefined;
1119
- const profile = configStore.getActiveProfileName() ?? "default";
1120
- const botIdentity = Journal.createTelegramUpdateJournalBotIdentity({
1121
- botToken,
1122
- botId: getTelegramBotId(),
1123
- });
1124
1147
  return withTelegramJournalSourceSerialization(function () {
1125
- return Journal.isTelegramThreadCleanupJournalNamespaceClear({
1126
- directory: Paths.resolveTelegramTempDir(),
1127
- profile,
1128
- botIdentity,
1129
- pollingPath: telegramLeaderJournalPath.resolve(configStore.getActiveProfileName()),
1130
- requiredJournalBindingKeys: [
1131
- binding.recoveryKey,
1132
- ...requiredJournalBindingKeys,
1133
- ],
1134
- ...(target.threadId !== undefined && ownInputs
1135
- ? {
1136
- cleanup: {
1137
- target: { chatId: target.chatId, threadId: target.threadId },
1138
- ownInputs,
1139
- },
1140
- }
1141
- : {}),
1142
- limits: {
1143
- maxDirectoryEntries: 10_000,
1144
- maxFiles: 4096,
1145
- maxBytes: 64 * 1024 * 1024,
1146
- maxEntries: 10_000,
1147
- maxWork: 100_000,
1148
- },
1149
- withSourceReference(path, operation) {
1150
- return telegramJournalReferenceRegistry.withReference({
1151
- referenceClass: "operator-disposition",
1152
- recoveryKey: Journal.createTelegramUpdateJournalBindingKey({
1153
- path,
1154
- profileName: profile,
1155
- botIdentity,
1156
- }),
1157
- }, operation);
1158
- },
1159
- })
1160
- ? []
1161
- : undefined;
1148
+ return Journal.isTelegramThreadCleanupJournalNamespaceClear(input) ? [] : undefined;
1149
+ });
1150
+ },
1151
+ recoverTemporaryThreadDeadQueueOwners(target, requiredJournalBindingKeys, ownInputs, isCurrent) {
1152
+ const input = prepareTemporaryThreadJournalInspection(target, requiredJournalBindingKeys, ownInputs);
1153
+ if (!input || !isCurrent())
1154
+ return 0;
1155
+ return Journal.recoverTelegramThreadCleanupDeadQueueOwners({
1156
+ ...input, isCurrent, withSourceSerialization: withTelegramJournalSourceSerialization,
1157
+ recoveryOwner: {
1158
+ instanceId: telegramInstanceId, processId: telegramProcessId,
1159
+ processBirthId: telegramQueueProcessBirthId,
1160
+ sessionGeneration: telegramSessionContextStore.getGeneration(),
1161
+ },
1162
1162
  });
1163
1163
  },
1164
1164
  hasWorkspaceRestoreAuthority() {
@@ -1252,12 +1252,14 @@ export default function (pi) {
1252
1252
  if (telegramBusFollowerRegistrationState.isRegistered() ||
1253
1253
  target.threadId === undefined)
1254
1254
  return undefined;
1255
+ // Pi creates a fresh context object per event and command, so authority follows the session, not the object.
1256
+ const sessionGeneration = telegramSessionContextStore.getGeneration();
1255
1257
  return Threads.createTelegramWorkspaceThreadRenameRecipient({
1256
1258
  store: threadStore,
1257
1259
  instanceId: telegramInstanceId,
1258
1260
  target,
1259
1261
  assertAuthority() {
1260
- if (telegramSessionContextStore.get() !== ctx)
1262
+ if (!telegramSessionContextStore.isCurrent(ctx, sessionGeneration))
1261
1263
  throw new Error("Telegram Thread name dialog lost Pi context.");
1262
1264
  },
1263
1265
  getAuthority: threadNameRecipientAuthority.getAuthority,
@@ -1571,6 +1573,12 @@ export default function (pi) {
1571
1573
  });
1572
1574
  const telegramBusFollowerAssembly = BusFollower.createTelegramBusFollowerRuntimeAssembly({
1573
1575
  instanceId: telegramInstanceId,
1576
+ failureLog: runtimeDiagnostics.createFailureLog({
1577
+ category: "bus",
1578
+ failurePhase: "follower-heartbeat",
1579
+ outcomePhase: "follower-heartbeat-recovered",
1580
+ label: "follower heartbeat",
1581
+ }),
1574
1582
  registrationState: telegramBusFollowerRegistrationState,
1575
1583
  recordRuntimeEvent,
1576
1584
  receiver: {
@@ -1900,6 +1908,12 @@ export default function (pi) {
1900
1908
  });
1901
1909
  const telegramThreadCapabilityRuntime = Polling.createTelegramThreadCapabilityOrchestration({
1902
1910
  state: telegramThreadCapabilityState,
1911
+ failureLog: runtimeDiagnostics.createFailureLog({
1912
+ category: "bus",
1913
+ failurePhase: "capability-monitor",
1914
+ outcomePhase: "capability-monitor-recovered",
1915
+ label: "Threaded Mode capability probe",
1916
+ }),
1903
1917
  getAllowedUserId: configStore.getAllowedUserId,
1904
1918
  callApi: callTelegramApi,
1905
1919
  topicTargetStore: threadStore,
@@ -2485,6 +2499,7 @@ export default function (pi) {
2485
2499
  deleteMessage: deleteTelegramMessage,
2486
2500
  sendGuestReply,
2487
2501
  editGuestReply,
2502
+ editGuestInlineMessage: telegramApiRuntime.editGuestInlineMessage,
2488
2503
  stopGuestPlaceholder: guestPlaceholderRuntime.stop,
2489
2504
  finalizeMarkdownPreview,
2490
2505
  preparePreviewDelivery,
@@ -751,6 +751,13 @@ export declare function isTelegramThreadCleanupJournalNamespaceClear(input: Tele
751
751
  }[];
752
752
  };
753
753
  }): boolean;
754
+ /** Demand-driven recovery only; caller holds temporary-cleanup admission. Never a deletion grant. */
755
+ export declare function recoverTelegramThreadCleanupDeadQueueOwners(input: Parameters<typeof isTelegramThreadCleanupJournalNamespaceClear>[0] & {
756
+ recoveryOwner: TelegramUpdateJournalQueueOwnerIdentity;
757
+ isCurrent: () => boolean;
758
+ withSourceSerialization: NonNullable<TelegramUpdateJournalStoreOptions["withSourceSerialization"]>;
759
+ getQueueProcessLiveness?: TelegramUpdateJournalStoreOptions["getQueueProcessLiveness"];
760
+ }): number;
754
761
  export declare function publishTelegramUpdateJournalSegment(path: string, segment: TelegramUpdateJournalSegment): TelegramUpdateJournalSegmentPublicationResult;
755
762
  export declare function createTelegramUpdateQueueHandoffToken(): string;
756
763
  export declare function createTelegramUpdateJournalBotIdentity(input: {
@@ -1988,6 +1988,86 @@ export function isTelegramThreadCleanupJournalNamespaceClear(input) {
1988
1988
  return namespace.sources.every((source) => source.evidence.kind === "absent" ||
1989
1989
  source.evidence.file.entries.every((entry) => isUnrelatedPlainInput(entry, source.path)));
1990
1990
  }
1991
+ /** Demand-driven recovery only; caller holds temporary-cleanup admission. Never a deletion grant. */
1992
+ export function recoverTelegramThreadCleanupDeadQueueOwners(input) {
1993
+ return input.withSourceSerialization(() => {
1994
+ const assertCurrent = () => {
1995
+ if (!input.isCurrent())
1996
+ throw new Error("Temporary cleanup queue recovery lost authority.");
1997
+ };
1998
+ assertCurrent();
1999
+ if (!input.cleanup)
2000
+ return 0;
2001
+ // Validate exact references and complete namespace coverage before considering any mutation.
2002
+ if (isTelegramThreadCleanupJournalNamespaceClear(input))
2003
+ return 0;
2004
+ const namespace = inspectTelegramSessionJournalNamespace(input);
2005
+ if (namespace.retainedInputs?.some(value => value.state !== "committed") ||
2006
+ input.requiredJournalBindingKeys.some(key => !namespace.sources.some(source => source.path === getTelegramUpdateJournalBindingPath(key) && source.evidence.kind === "present")))
2007
+ return 0;
2008
+ const plans = [];
2009
+ for (const source of namespace.sources) {
2010
+ if (source.evidence.kind !== "present")
2011
+ continue;
2012
+ const file = source.evidence.file;
2013
+ if (file.version !== TELEGRAM_UPDATE_JOURNAL_VERSION)
2014
+ continue;
2015
+ const groups = new Map();
2016
+ for (const entry of file.entries) {
2017
+ if (!entry.queueReceiptId)
2018
+ continue;
2019
+ const group = groups.get(entry.queueReceiptId) ?? [];
2020
+ group.push(entry);
2021
+ groups.set(entry.queueReceiptId, group);
2022
+ }
2023
+ for (const group of groups.values()) {
2024
+ const entry = group[0];
2025
+ if (entry.state !== "queued" || !entry.queueReceiptId || !entry.queueOwner ||
2026
+ !entry.queueKind || entry.queueHandoff || entry.inputClaim)
2027
+ continue;
2028
+ if (group.some(value => value.state !== "queued" || value.queueHandoff || value.inputClaim ||
2029
+ value.queueKind !== entry.queueKind || !isDeepStrictEqual(value.queueOwner, entry.queueOwner)))
2030
+ continue;
2031
+ plans.push({ path: source.path, recovery: {
2032
+ queueKind: entry.queueKind, receiptId: entry.queueReceiptId,
2033
+ sourceUpdateIds: group.map(value => value.updateId).sort((a, b) => a - b),
2034
+ deadOwner: cloneJournalQueueOwner(entry.queueOwner), recoveryOwner: { ...input.recoveryOwner },
2035
+ } });
2036
+ }
2037
+ }
2038
+ const liveness = input.getQueueProcessLiveness ?? getTelegramProcessLiveness;
2039
+ const dead = plans.filter(plan => {
2040
+ assertCurrent();
2041
+ const result = liveness(plan.recovery.deadOwner);
2042
+ assertCurrent();
2043
+ return result === "dead";
2044
+ });
2045
+ let removed = 0;
2046
+ for (const plan of dead) {
2047
+ assertCurrent();
2048
+ removed += input.withSourceReference(plan.path, () => {
2049
+ const journal = createTelegramUpdateJournalStore({
2050
+ path: plan.path, profileName: input.profile, botIdentity: input.botIdentity,
2051
+ queueRuntimeIdentity: input.recoveryOwner,
2052
+ // This private synchronous store stays inside the cross-family serializer already held above.
2053
+ withSourceSerialization(operation) { assertCurrent(); return operation(); },
2054
+ sourceAccess: { directory: dirname(plan.path), limits: input.limits },
2055
+ getQueueProcessLiveness(owner) {
2056
+ assertCurrent();
2057
+ const result = liveness(owner);
2058
+ assertCurrent();
2059
+ return result;
2060
+ },
2061
+ withWriterAdmission(operation) { assertCurrent(); return operation(); },
2062
+ });
2063
+ // The journal transaction rechecks whole-group identity, absence of handoff and fresh process-death proof.
2064
+ return journal.recoverDeadQueueOwner(plan.recovery).recoveredUpdateIds.length;
2065
+ });
2066
+ }
2067
+ assertCurrent();
2068
+ return removed;
2069
+ });
2070
+ }
1991
2071
  function cloneEntry(entry) {
1992
2072
  return {
1993
2073
  ...entry,
@@ -4,6 +4,40 @@
4
4
  * Owns bounded JSONL runtime evidence files, previous-log preservation, and profile-aware log paths without becoming routing state
5
5
  */
6
6
  import * as Status from "./status.ts";
7
+ /** Sink shape shared with the runtime event recorder this domain already owns. */
8
+ export type TelegramFailureRecordSink = (category: string, errorOrMessage: unknown, details: Record<string, unknown>) => void;
9
+ export interface TelegramFailureRunSummary {
10
+ signature: string;
11
+ failedAttempts: number;
12
+ suppressedFailures: number;
13
+ failedForMs: number;
14
+ }
15
+ export interface TelegramFailureRunLog {
16
+ /** Records one failure; a repeated identical failure only extends the current run and returns false. */
17
+ record: (error: unknown, details?: Record<string, unknown>) => boolean;
18
+ /** Closes the run after a success, emitting its single outcome record. */
19
+ settled: () => TelegramFailureRunSummary | undefined;
20
+ /** Ends the run without an outcome record, for a stop or replacement that reports its own outcome. */
21
+ reset: () => void;
22
+ }
23
+ export interface TelegramFailureRunLogInput {
24
+ category: string;
25
+ failurePhase: string;
26
+ outcomePhase: string;
27
+ /** Human-readable subject for the outcome record, e.g. `follower heartbeat`. */
28
+ label: string;
29
+ record?: TelegramFailureRecordSink;
30
+ /** Static context reapplied to every record of this phase, e.g. a session or profile key. */
31
+ context?: () => Record<string, unknown>;
32
+ getNowMs?: () => number;
33
+ }
34
+ /** Stable identity of one failure kind: errno when present, otherwise the failure text. */
35
+ export declare function getTelegramFailureSignature(error: unknown): string;
36
+ /**
37
+ * Consecutive identical failure runs for one diagnostic phase: a repeat extends the run instead of logging again,
38
+ * and a closing success or reset emits at most one outcome record. This never gates detection or recovery.
39
+ */
40
+ export declare function createTelegramFailureRunLog(input: TelegramFailureRunLogInput): TelegramFailureRunLog;
7
41
  type TelegramLogPathInput = string | (() => string);
8
42
  interface TelegramRuntimeJsonlEvent {
9
43
  at: number;
@@ -47,6 +81,8 @@ interface TelegramRuntimeDiagnosticsStatusPorts<TContext> {
47
81
  interface TelegramRuntimeDiagnosticsRuntime<TContext> {
48
82
  events: Status.TelegramRuntimeEventRecorder;
49
83
  recordRuntimeEvent(category: string, error: unknown, details?: Record<string, unknown>): void;
84
+ /** A phase-scoped failure log whose records travel through this runtime's own event pipeline. */
85
+ createFailureLog(input: Omit<TelegramFailureRunLogInput, "record">): TelegramFailureRunLog;
50
86
  bindStorage(ports: {
51
87
  getBotToken(): string | undefined;
52
88
  getProfileName(): string | undefined;
@@ -8,6 +8,82 @@ import { basename, dirname, join } from "node:path";
8
8
  import { withTelegramFileTransaction } from "./locks.js";
9
9
  import { resolveAgentDir, resolveTelegramPreviousSharedRuntimeLogPath, resolveTelegramProfileTempFilePath, resolveTelegramRuntimeLogPath, } from "./paths.js";
10
10
  import * as Status from "./status.js";
11
+ /** Stable identity of one failure kind: errno when present, otherwise the failure text. */
12
+ export function getTelegramFailureSignature(error) {
13
+ const code = error?.code;
14
+ if (typeof code === "string" && code)
15
+ return code;
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
18
+ /**
19
+ * Consecutive identical failure runs for one diagnostic phase: a repeat extends the run instead of logging again,
20
+ * and a closing success or reset emits at most one outcome record. This never gates detection or recovery.
21
+ */
22
+ export function createTelegramFailureRunLog(input) {
23
+ const getNowMs = input.getNowMs ?? Date.now;
24
+ let run;
25
+ const emit = (errorOrMessage, details) => {
26
+ try {
27
+ input.record?.(input.category, errorOrMessage, {
28
+ ...(input.context?.() ?? {}),
29
+ ...details,
30
+ });
31
+ }
32
+ catch {
33
+ // A diagnostic sink never converts a recorded failure into another failure.
34
+ }
35
+ };
36
+ return {
37
+ record(error, details) {
38
+ const signature = getTelegramFailureSignature(error);
39
+ const admitted = { ...(details ?? {}) };
40
+ if (run?.signature === signature) {
41
+ run.suppressed += 1;
42
+ Object.assign(run.details, admitted);
43
+ return false;
44
+ }
45
+ const previous = run;
46
+ run = { signature, suppressed: 0, startedAtMs: getNowMs(), details: admitted };
47
+ emit(error, {
48
+ ...admitted,
49
+ phase: input.failurePhase,
50
+ failureSignature: signature,
51
+ ...(previous
52
+ ? {
53
+ previousFailureSignature: previous.signature,
54
+ previousFailedAttempts: previous.suppressed + 1,
55
+ previousFailedForMs: getNowMs() - previous.startedAtMs,
56
+ }
57
+ : {}),
58
+ });
59
+ return true;
60
+ },
61
+ settled() {
62
+ const closed = run;
63
+ if (!closed)
64
+ return undefined;
65
+ run = undefined;
66
+ const summary = {
67
+ signature: closed.signature,
68
+ failedAttempts: closed.suppressed + 1,
69
+ suppressedFailures: closed.suppressed,
70
+ failedForMs: getNowMs() - closed.startedAtMs,
71
+ };
72
+ emit(`Telegram ${input.label} recovered after ${summary.failedAttempts} failed attempts.`, {
73
+ ...closed.details,
74
+ phase: input.outcomePhase,
75
+ failureSignature: summary.signature,
76
+ failedAttempts: summary.failedAttempts,
77
+ suppressedFailures: summary.suppressedFailures,
78
+ failedForMs: summary.failedForMs,
79
+ });
80
+ return summary;
81
+ },
82
+ reset() {
83
+ run = undefined;
84
+ },
85
+ };
86
+ }
11
87
  const DEFAULT_MAX_LOG_BYTES = 5 * 1024 * 1024;
12
88
  export function getTelegramRuntimeLogPath(agentDir = resolveAgentDir(), profileName) {
13
89
  return resolveTelegramProfileTempFilePath("logs", "jsonl", agentDir, profileName);
@@ -333,6 +409,10 @@ export function createTelegramRuntimeDiagnosticsRuntime(options = {}) {
333
409
  };
334
410
  return {
335
411
  events,
412
+ createFailureLog: (input) => createTelegramFailureRunLog({
413
+ ...input,
414
+ record: (category, errorOrMessage, details) => recordRuntimeEvent(category, errorOrMessage, details),
415
+ }),
336
416
  recordRuntimeEvent,
337
417
  bindStorage(ports) {
338
418
  getBotToken = ports.getBotToken;
@@ -73,6 +73,7 @@ export interface TelegramQueuedOutboundAttachmentView {
73
73
  export interface TelegramOutboundAttachmentQueueTargetView {
74
74
  queuedAttachments: TelegramQueuedOutboundAttachmentView[];
75
75
  guestQueryId?: string;
76
+ guestInlineMessageId?: string;
76
77
  }
77
78
  export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutboundAttachmentQueueTargetView {
78
79
  chatId: number;
@@ -161,6 +162,17 @@ export declare function queueTelegramOutboundAttachments(options: {
161
162
  }): Promise<TelegramOutboundAttachmentToolResult>;
162
163
  export declare function deliverTelegramGuestCachedAttachment(options: {
163
164
  guestQueryId: string;
165
+ guestInlineMessageId?: string;
166
+ editGuestInlineMessage?: (inlineMessageId: string, content: {
167
+ media: {
168
+ type: "photo" | "document" | "audio";
169
+ media: string;
170
+ caption?: string;
171
+ };
172
+ }, options: {
173
+ assertAuthority: () => void;
174
+ }) => Promise<void>;
175
+ isDeliveryActive?: () => boolean;
164
176
  stagingChatId: number;
165
177
  stagingTarget?: TelegramTarget;
166
178
  attachment: TelegramQueuedOutboundAttachmentView;
@@ -375,6 +375,9 @@ export async function queueTelegramOutboundAttachments(options) {
375
375
  maxAttachmentSizeBytes: options.maxAttachmentSizeBytes,
376
376
  statPath: options.statPath,
377
377
  });
378
+ if (options.activeTurn.guestInlineMessageId && pendingAttachments.some((attachment) => getTelegramGuestAttachmentTransport(attachment.path).fileField === "voice")) {
379
+ throw new Error("Telegram Guest Mode cannot replace an inline placeholder with a voice note; no attachment was queued. Use MP3 audio or a normal bot chat.");
380
+ }
378
381
  options.activeTurn.queuedAttachments.push(...pendingAttachments);
379
382
  const added = pendingAttachments.map((attachment) => attachment.path);
380
383
  return {
@@ -389,6 +392,16 @@ export async function queueTelegramOutboundAttachments(options) {
389
392
  }
390
393
  export async function deliverTelegramGuestCachedAttachment(options) {
391
394
  const transport = getTelegramGuestAttachmentTransport(options.attachment.path);
395
+ const isActive = () => options.isDeliveryActive?.() !== false;
396
+ const assertActive = () => {
397
+ if (!isActive())
398
+ throw new Error("Guest attachment delivery lost authority.");
399
+ };
400
+ if (!isActive())
401
+ return;
402
+ if (options.guestInlineMessageId && (!options.editGuestInlineMessage || transport.fileField === "voice")) {
403
+ throw new Error("Guest inline attachment editing is unavailable for this media.");
404
+ }
392
405
  let stagingMessageId;
393
406
  let answerAttempted = false;
394
407
  try {
@@ -397,6 +410,8 @@ export async function deliverTelegramGuestCachedAttachment(options) {
397
410
  ...getTelegramMultipartTargetFields(options.stagingTarget),
398
411
  }, transport.fileField, options.attachment.path, options.attachment.fileName));
399
412
  stagingMessageId = message.message_id;
413
+ if (!isActive())
414
+ return;
400
415
  const caption = options.caption
401
416
  ? Array.from(options.caption).slice(0, 1024).join("")
402
417
  : undefined;
@@ -446,11 +461,26 @@ export async function deliverTelegramGuestCachedAttachment(options) {
446
461
  ...(caption ? { caption } : {}),
447
462
  };
448
463
  }
464
+ if (!isActive())
465
+ return;
449
466
  answerAttempted = true;
450
- await options.answerGuestQuery(options.guestQueryId, result);
467
+ if (options.guestInlineMessageId && options.editGuestInlineMessage) {
468
+ if (result.type === "voice")
469
+ throw new Error("Voice notes cannot replace inline placeholders.");
470
+ const media = result.type === "photo" ? result.photo_file_id
471
+ : result.type === "audio" ? result.audio_file_id : result.document_file_id;
472
+ await options.editGuestInlineMessage(options.guestInlineMessageId, {
473
+ media: { type: result.type, media, ...(caption ? { caption } : {}) },
474
+ }, { assertAuthority: assertActive });
475
+ }
476
+ else {
477
+ await options.answerGuestQuery(options.guestQueryId, result);
478
+ }
451
479
  }
452
480
  catch (error) {
453
- if (!answerAttempted && options.answerGuestText && options.fallbackText) {
481
+ if (!isActive())
482
+ return;
483
+ if (!options.guestInlineMessageId && !answerAttempted && options.answerGuestText && options.fallbackText) {
454
484
  answerAttempted = true;
455
485
  await options.answerGuestText(options.guestQueryId, options.fallbackText);
456
486
  }
@@ -459,7 +489,7 @@ export async function deliverTelegramGuestCachedAttachment(options) {
459
489
  }
460
490
  }
461
491
  finally {
462
- if (stagingMessageId !== undefined) {
492
+ if (stagingMessageId !== undefined && isActive()) {
463
493
  try {
464
494
  await options.deleteMessage(options.stagingChatId, stagingMessageId);
465
495
  }
@@ -43,7 +43,8 @@ export declare function resolveLegacyTelegramOwnersPath(agentDir?: string): stri
43
43
  export declare function resolveTelegramOwnersPath(): string;
44
44
  export declare function getTelegramProfilePathSuffix(profileName?: string): string;
45
45
  export declare function resolveTelegramProfileTempFilePath(baseName: string, extension: string, agentDir?: string, profileName?: string): string;
46
- export declare function getTelegramDiagnosticsDisplayPaths(_profileName?: string): {
46
+ /** Shared state/log paths for every profile, under the resolved agent dir (honors `PI_CODING_AGENT_DIR`). */
47
+ export declare function getTelegramDiagnosticsDisplayPaths(_profileName?: string, agentDir?: string): {
47
48
  state: string;
48
49
  logs: string;
49
50
  };
@@ -111,10 +111,25 @@ export function getTelegramProfilePathSuffix(profileName) {
111
111
  export function resolveTelegramProfileTempFilePath(baseName, extension, agentDir = resolveAgentDir(), profileName) {
112
112
  return join(resolveTelegramTempDir(agentDir), `${baseName}${getTelegramProfilePathSuffix(profileName)}.${extension}`);
113
113
  }
114
- export function getTelegramDiagnosticsDisplayPaths(_profileName) {
114
+ /** Shared state/log paths for every profile, under the resolved agent dir (honors `PI_CODING_AGENT_DIR`). */
115
+ export function getTelegramDiagnosticsDisplayPaths(_profileName, agentDir = resolveAgentDir()) {
116
+ // Storage paths are canonical, so also match a canonical home (e.g. /home -> /var/home).
117
+ const homes = [homedir()];
118
+ try {
119
+ homes.push(realpathSync(homes[0]));
120
+ }
121
+ catch {
122
+ /* An unreadable home only loses the ~ abbreviation. */
123
+ }
124
+ const display = (path) => {
125
+ const home = homes.find((candidate) => path === candidate || path.startsWith(`${candidate}${sep}`));
126
+ return home
127
+ ? `~${path.slice(home.length).split(sep).join("/")}`
128
+ : path;
129
+ };
115
130
  return {
116
- state: "~/.pi/agent/tmp/pi-telegram/state.json",
117
- logs: "~/.pi/agent/tmp/pi-telegram/logs.jsonl",
131
+ state: display(resolveTelegramStatePath(agentDir)),
132
+ logs: display(resolveTelegramRuntimeLogPath(agentDir)),
118
133
  };
119
134
  }
120
135
  /** Durable Workspace admission ledger (<agentDir>/tmp/pi-telegram/workspace-admission[.<profile>].json). */
@@ -4,6 +4,15 @@
4
4
  * Owns polling request builders, stop conditions, and the long-poll loop runtime for Telegram updates
5
5
  */
6
6
  type MaybePromise<T> = T | Promise<T>;
7
+ /**
8
+ * Structural port for run-collapsing diagnostics: a repeated identical failure extends its run instead of logging
9
+ * again, and the closing success records one outcome. The composition root owns the collapsed implementation.
10
+ */
11
+ export interface TelegramRepeatedFailureLogPort {
12
+ record: (error: unknown, details?: Record<string, unknown>) => void;
13
+ settled: () => void;
14
+ reset: () => void;
15
+ }
7
16
  export interface TelegramPollingConfig {
8
17
  botToken?: string;
9
18
  }
@@ -174,6 +183,8 @@ export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThread
174
183
  getNowMs?: () => number;
175
184
  }
176
185
  export interface TelegramThreadCapabilityRuntimeDeps<TContext> extends TelegramThreadCapabilityReaderDeps {
186
+ /** Collapses one sustained probe failure run; the caller supplies its owner. */
187
+ failureLog: TelegramRepeatedFailureLogPort;
177
188
  lifecycle?: TelegramThreadCapabilityLifecycle;
178
189
  topicTargetStore: TelegramThreadCapabilityStore;
179
190
  ownsLock: (ctx: TContext) => boolean;
@@ -251,6 +262,8 @@ export interface TelegramThreadAwarePollingDeps<TContext, TOwner> extends Telegr
251
262
  setForceFreshLeaderThreadOnNextStart: (forceFresh: boolean) => void;
252
263
  }
253
264
  export interface TelegramThreadCapabilityOrchestrationDeps<TContext, TOwner> extends TelegramThreadCapabilityReaderDeps, TelegramThreadAwarePollingControls<TContext, TOwner> {
265
+ /** Collapses one sustained probe failure run; the composition root supplies its owner. */
266
+ failureLog: TelegramRepeatedFailureLogPort;
254
267
  state: TelegramThreadCapabilityStateRuntime;
255
268
  topicTargetStore: TelegramThreadCapabilityStore;
256
269
  ownsLock: (ctx: TContext) => boolean;