@llblab/pi-kit 0.27.5 → 0.28.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 (194) hide show
  1. package/BACKLOG.md +3 -1
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +4 -4
  4. package/node_modules/@llblab/pi-state-flow/AGENTS.md +1 -1
  5. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +6 -2
  6. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +4 -1
  7. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +4 -3
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +26 -13
  9. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  10. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +3 -1
  11. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +2 -2
  12. package/node_modules/@llblab/pi-state-flow/docs/usage.md +2 -1
  13. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +4 -3
  14. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +25 -12
  15. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  16. package/node_modules/@llblab/pi-telegram/AGENTS.md +10 -8
  17. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -9
  18. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +13 -0
  19. package/node_modules/@llblab/pi-telegram/LICENSE +21 -0
  20. package/node_modules/@llblab/pi-telegram/README.md +15 -5
  21. package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.d.ts +1 -5
  22. package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.js +5 -7
  23. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +5 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +6 -1
  25. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +4 -2
  26. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +54 -56
  27. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +206 -139
  28. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +21 -2
  29. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +181 -25
  30. package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.d.ts +4 -1
  31. package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.js +28 -4
  32. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +52 -56
  33. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +78 -246
  34. package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.d.ts +1 -3
  35. package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.js +23 -26
  36. package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.d.ts +0 -5
  37. package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.js +5 -5
  38. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +0 -23
  39. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +15 -15
  40. package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +0 -17
  41. package/node_modules/@llblab/pi-telegram/dist/lib/config.js +12 -14
  42. package/node_modules/@llblab/pi-telegram/dist/lib/delivery.d.ts +0 -4
  43. package/node_modules/@llblab/pi-telegram/dist/lib/delivery.js +2 -5
  44. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +255 -61
  45. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +0 -19
  46. package/node_modules/@llblab/pi-telegram/dist/lib/inbound.d.ts +0 -2
  47. package/node_modules/@llblab/pi-telegram/dist/lib/inbound.js +2 -2
  48. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +177 -16
  49. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +1115 -249
  50. package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.d.ts +0 -2
  51. package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.js +2 -2
  52. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +1 -1
  53. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +100 -12
  54. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +380 -14
  55. package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +30 -12
  56. package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +129 -72
  57. package/node_modules/@llblab/pi-telegram/dist/lib/media.d.ts +28 -5
  58. package/node_modules/@llblab/pi-telegram/dist/lib/media.js +26 -8
  59. package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.d.ts +0 -11
  60. package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.js +8 -8
  61. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +0 -18
  62. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +18 -18
  63. package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.d.ts +4 -4
  64. package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.js +12 -7
  65. package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.d.ts +1 -1
  66. package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.js +1 -1
  67. package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +1 -0
  68. package/node_modules/@llblab/pi-telegram/dist/lib/menu.js +4 -3
  69. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +0 -1
  70. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +1 -1
  71. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-buttons.js +1 -8
  72. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.d.ts +1 -6
  73. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.js +4 -21
  74. package/node_modules/@llblab/pi-telegram/dist/lib/outbound.d.ts +0 -8
  75. package/node_modules/@llblab/pi-telegram/dist/lib/outbound.js +5 -5
  76. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +50 -7
  77. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +155 -17
  78. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +1 -0
  79. package/node_modules/@llblab/pi-telegram/dist/lib/pi.js +2 -1
  80. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +0 -5
  81. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +5 -5
  82. package/node_modules/@llblab/pi-telegram/dist/lib/preview.d.ts +0 -3
  83. package/node_modules/@llblab/pi-telegram/dist/lib/preview.js +3 -3
  84. package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.d.ts +0 -1
  85. package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.js +1 -1
  86. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +3 -15
  87. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +69 -8
  88. package/node_modules/@llblab/pi-telegram/dist/lib/recovery.d.ts +29 -9
  89. package/node_modules/@llblab/pi-telegram/dist/lib/recovery.js +104 -33
  90. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +0 -4
  91. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +1 -1
  92. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +73 -8
  93. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +2206 -307
  94. package/node_modules/@llblab/pi-telegram/dist/lib/sections.d.ts +0 -2
  95. package/node_modules/@llblab/pi-telegram/dist/lib/sections.js +1 -1
  96. package/node_modules/@llblab/pi-telegram/dist/lib/status.d.ts +44 -9
  97. package/node_modules/@llblab/pi-telegram/dist/lib/status.js +149 -27
  98. package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +0 -5
  99. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +5 -4
  100. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -3
  101. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +34 -23
  102. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +1 -0
  103. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +18 -21
  104. package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.d.ts +32 -5
  105. package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.js +191 -7
  106. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.d.ts +2 -0
  107. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +1 -1
  108. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +291 -59
  109. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +1647 -369
  110. package/node_modules/@llblab/pi-telegram/dist/lib/turns.d.ts +0 -1
  111. package/node_modules/@llblab/pi-telegram/dist/lib/turns.js +1 -1
  112. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +184 -28
  113. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +1032 -114
  114. package/node_modules/@llblab/pi-telegram/dist/lib/wire.d.ts +12 -0
  115. package/node_modules/@llblab/pi-telegram/dist/lib/wire.js +21 -0
  116. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +25 -17
  117. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +95 -20
  118. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.d.ts +20 -0
  119. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.js +103 -0
  120. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +43 -8
  121. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +148 -52
  122. package/node_modules/@llblab/pi-telegram/dist/package.json +6 -6
  123. package/node_modules/@llblab/pi-telegram/dist/skills/generated-control-surface/SKILL.md +3 -3
  124. package/node_modules/@llblab/pi-telegram/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
  125. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -1
  126. package/node_modules/@llblab/pi-telegram/docs/activity.md +1 -1
  127. package/node_modules/@llblab/pi-telegram/docs/architecture.md +152 -34
  128. package/node_modules/@llblab/pi-telegram/docs/callback-namespaces.md +1 -1
  129. package/node_modules/@llblab/pi-telegram/docs/delivery.md +1 -1
  130. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +2 -2
  131. package/node_modules/@llblab/pi-telegram/docs/inbound.md +1 -1
  132. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +125 -19
  133. package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -1
  134. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +22 -8
  135. package/node_modules/@llblab/pi-telegram/docs/updates.md +1 -1
  136. package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +5 -9
  137. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +6 -0
  138. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +5 -2
  139. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +243 -224
  140. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +178 -31
  141. package/node_modules/@llblab/pi-telegram/lib/bus-transport.ts +30 -8
  142. package/node_modules/@llblab/pi-telegram/lib/bus.ts +110 -334
  143. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +29 -29
  144. package/node_modules/@llblab/pi-telegram/lib/command-templates.ts +5 -5
  145. package/node_modules/@llblab/pi-telegram/lib/commands.ts +15 -15
  146. package/node_modules/@llblab/pi-telegram/lib/config.ts +12 -17
  147. package/node_modules/@llblab/pi-telegram/lib/delivery.ts +2 -8
  148. package/node_modules/@llblab/pi-telegram/lib/extension.ts +257 -72
  149. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +0 -22
  150. package/node_modules/@llblab/pi-telegram/lib/inbound.ts +2 -2
  151. package/node_modules/@llblab/pi-telegram/lib/journal.ts +1182 -326
  152. package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +2 -2
  153. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +1 -1
  154. package/node_modules/@llblab/pi-telegram/lib/locks.ts +402 -23
  155. package/node_modules/@llblab/pi-telegram/lib/logging.ts +151 -101
  156. package/node_modules/@llblab/pi-telegram/lib/media.ts +51 -9
  157. package/node_modules/@llblab/pi-telegram/lib/menu-model.ts +8 -8
  158. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +18 -18
  159. package/node_modules/@llblab/pi-telegram/lib/menu-status.ts +11 -0
  160. package/node_modules/@llblab/pi-telegram/lib/menu-thinking.ts +2 -2
  161. package/node_modules/@llblab/pi-telegram/lib/menu.ts +5 -1
  162. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +1 -1
  163. package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +1 -10
  164. package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +5 -24
  165. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +5 -5
  166. package/node_modules/@llblab/pi-telegram/lib/paths.ts +177 -17
  167. package/node_modules/@llblab/pi-telegram/lib/pi.ts +5 -2
  168. package/node_modules/@llblab/pi-telegram/lib/polling.ts +5 -5
  169. package/node_modules/@llblab/pi-telegram/lib/preview.ts +3 -3
  170. package/node_modules/@llblab/pi-telegram/lib/prompt-templates.ts +1 -1
  171. package/node_modules/@llblab/pi-telegram/lib/queue.ts +67 -9
  172. package/node_modules/@llblab/pi-telegram/lib/recovery.ts +104 -44
  173. package/node_modules/@llblab/pi-telegram/lib/replies.ts +1 -1
  174. package/node_modules/@llblab/pi-telegram/lib/routing.ts +1955 -375
  175. package/node_modules/@llblab/pi-telegram/lib/sections.ts +1 -1
  176. package/node_modules/@llblab/pi-telegram/lib/status.ts +156 -36
  177. package/node_modules/@llblab/pi-telegram/lib/sync.ts +4 -4
  178. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +48 -33
  179. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +24 -21
  180. package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +267 -9
  181. package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +3 -1
  182. package/node_modules/@llblab/pi-telegram/lib/threads.ts +1697 -488
  183. package/node_modules/@llblab/pi-telegram/lib/turns.ts +1 -1
  184. package/node_modules/@llblab/pi-telegram/lib/updates.ts +1072 -151
  185. package/node_modules/@llblab/pi-telegram/lib/wire.ts +28 -0
  186. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +97 -47
  187. package/node_modules/@llblab/pi-telegram/lib/workspace-identity.ts +147 -0
  188. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +188 -89
  189. package/node_modules/@llblab/pi-telegram/package.json +6 -6
  190. package/node_modules/@llblab/pi-telegram/scripts/audit-exports.mjs +100 -0
  191. package/node_modules/@llblab/pi-telegram/scripts/check-downgrade.mjs +80 -43
  192. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +3 -3
  193. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +3 -3
  194. package/package.json +3 -3
@@ -8,12 +8,13 @@
8
8
  import { isDeepStrictEqual } from "node:util";
9
9
 
10
10
  import type {
11
+ TelegramJournalNamespaceInspection,
11
12
  TelegramUpdateJournalDeadQueueOwnerRecoveryInput,
12
13
  TelegramUpdateJournalDeadQueueOwnerRecoveryResult,
13
14
  TelegramUpdateJournalEntry,
14
15
  TelegramUpdateJournalQueueOwnerIdentity,
15
16
  } from "./journal.ts";
16
- import type { TelegramTarget } from "./target.ts";
17
+ import { areTelegramTargetsEqual as sameTarget, type TelegramTarget } from "./target.ts";
17
18
  import {
18
19
  getTelegramApiErrorRequestTarget,
19
20
  isTelegramApiRequestRejected,
@@ -21,6 +22,7 @@ import {
21
22
  } from "./telegram-api.ts";
22
23
  import type {
23
24
  TelegramTopicTargetStore,
25
+ TelegramWorkspaceJournalSource,
24
26
  TelegramWorkspaceExternalProtectionEvidence,
25
27
  TelegramWorkspaceRetirementIntent,
26
28
  TelegramWorkspaceProtectionState,
@@ -104,8 +106,8 @@ export interface TelegramWorkspaceJournalProtectionCapture {
104
106
 
105
107
  interface TelegramWorkspaceJournalReader {
106
108
  recoveryKey?: string;
107
- journal: { read: () => { entries: readonly { update: unknown }[] } };
108
- readForProtection?: () => { entries: readonly { update: unknown }[] };
109
+ journal: { read: () => { entries: readonly { update: unknown }[]; exists?: boolean } };
110
+ readForProtection?: () => { entries: readonly { update: unknown }[]; exists?: boolean };
109
111
  }
110
112
 
111
113
  export function captureTelegramWorkspaceJournalProtectionSources(input: {
@@ -114,6 +116,10 @@ export function captureTelegramWorkspaceJournalProtectionSources(input: {
114
116
  createFollowerResolver: (
115
117
  journalBindingKey: string,
116
118
  ) => () => TelegramWorkspaceJournalReader | undefined;
119
+ createSessionResolver?: (
120
+ recipientBindingKey: string,
121
+ sessionId: string,
122
+ ) => () => TelegramWorkspaceJournalReader | undefined;
117
123
  withJournalReference?: <T>(binding: TelegramWorkspaceJournalReader,
118
124
  operation: () => T) => T;
119
125
  discovery?: {
@@ -140,7 +146,8 @@ export function captureTelegramWorkspaceJournalProtectionSources(input: {
140
146
  const snapshot = input.withJournalReference
141
147
  ? input.withJournalReference(binding, read)
142
148
  : read();
143
- sources.push({ kind: "available", scope, entries: snapshot.entries });
149
+ sources.push({ kind: "available", scope, entries: snapshot.entries,
150
+ ...(snapshot.exists === undefined ? {} : { exists: snapshot.exists }) });
144
151
  } catch {
145
152
  complete = false;
146
153
  sources.push({ kind: "unknown", scope });
@@ -157,6 +164,11 @@ export function captureTelegramWorkspaceJournalProtectionSources(input: {
157
164
  input.createFollowerResolver(journalBindingKey),
158
165
  );
159
166
  }
167
+ for (const source of input.binding.journalSources ?? []) {
168
+ capture({ kind: "binding", bindingKey: input.binding.bindingKey,
169
+ journalBindingKey: source.recipientBindingKey, sessionId: source.sessionId },
170
+ input.createSessionResolver?.(source.recipientBindingKey, source.sessionId) ?? (() => undefined));
171
+ }
160
172
  for (const path of input.discovery?.paths ?? []) {
161
173
  capture({ kind: "discovered", path }, input.discovery!.createResolver(path));
162
174
  }
@@ -168,15 +180,17 @@ export type TelegramWorkspaceJournalProtectionSource =
168
180
  kind: "available";
169
181
  scope:
170
182
  | { kind: "shared" }
171
- | { kind: "binding"; bindingKey: string; journalBindingKey: string }
183
+ | { kind: "binding"; bindingKey: string; journalBindingKey: string; sessionId?: string }
172
184
  | { kind: "discovered"; path: string };
173
185
  entries: readonly { update: unknown }[];
186
+ /** Positive filesystem presence is distinct from a complete namespace with a missing historical family. */
187
+ exists?: boolean;
174
188
  }
175
189
  | {
176
190
  kind: "unknown";
177
191
  scope:
178
192
  | { kind: "shared" }
179
- | { kind: "binding"; bindingKey: string; journalBindingKey: string }
193
+ | { kind: "binding"; bindingKey: string; journalBindingKey: string; sessionId?: string }
180
194
  | { kind: "discovered"; path: string };
181
195
  };
182
196
 
@@ -206,18 +220,13 @@ function getJournalUpdateTarget(update: unknown):
206
220
  };
207
221
  }
208
222
 
209
- function sameTarget(
210
- left: { chatId: number; threadId?: number },
211
- right: { chatId: number; threadId?: number },
212
- ): boolean {
213
- return left.chatId === right.chatId && left.threadId === right.threadId;
214
- }
215
-
216
223
  export function resolveTelegramWorkspaceAcceptedWorkProtection(input: {
217
224
  binding: TelegramWorkspaceThreadBinding;
218
225
  localAcceptedTargets: readonly { chatId: number; threadId?: number }[];
219
226
  journalSources: readonly TelegramWorkspaceJournalProtectionSource[];
220
227
  sourcesComplete: boolean;
228
+ /** Relocation cannot infer execution ownership from the original Telegram message target. */
229
+ requireBindingProvenance?: boolean;
221
230
  }): TelegramWorkspaceProtectionState {
222
231
  if (input.localAcceptedTargets.some((target) => sameTarget(target, input.binding.target))) {
223
232
  return "protected";
@@ -231,9 +240,17 @@ export function resolveTelegramWorkspaceAcceptedWorkProtection(input: {
231
240
  unknown = true;
232
241
  continue;
233
242
  }
243
+ if (input.requireBindingProvenance && source.scope.kind === "binding" && source.exists === false) {
244
+ unknown = true;
245
+ continue;
246
+ }
234
247
  if (source.scope.kind === "binding" && source.entries.length > 0) {
235
248
  return "protected";
236
249
  }
250
+ if (input.requireBindingProvenance && source.entries.length > 0) {
251
+ unknown = true;
252
+ continue;
253
+ }
237
254
  for (const entry of source.entries) {
238
255
  const target = getJournalUpdateTarget(entry.update);
239
256
  if (!target) {
@@ -251,19 +268,22 @@ export type TelegramWorkspaceJournalPruneResult =
251
268
  kind: "committed";
252
269
  binding: TelegramWorkspaceThreadBinding;
253
270
  removedKeys: string[];
271
+ removedSources?: TelegramWorkspaceJournalSource[];
254
272
  }
255
273
  | {
256
274
  kind: "blocked";
257
- reason: "incomplete-evidence" | "writer-not-quiescent" | "state-changed";
275
+ reason: "incomplete-evidence" | "writer-not-quiescent" | "state-changed" | "publication-refused";
258
276
  };
259
277
 
260
278
  export async function pruneTelegramWorkspaceJournalEvidence(input: {
261
279
  store: Pick<
262
280
  TelegramTopicTargetStore,
263
- "commitWorkspaceJournalEvidence" | "persist"
281
+ "commitWorkspaceJournalEvidence" | "persistWorkspaceJournalEvidence"
264
282
  >;
265
283
  binding: TelegramWorkspaceThreadBinding;
266
- capture: TelegramWorkspaceJournalProtectionCapture;
284
+ /** Capture fresh source evidence after admission, not a snapshot taken before the lease. */
285
+ capture: () => TelegramWorkspaceJournalProtectionCapture;
286
+ runExclusive: TelegramWorkspaceOperationGate["runExclusive"];
267
287
  getJournalWriterProtection: (
268
288
  journalBindingKey: string,
269
289
  ) => TelegramWorkspaceProtectionState;
@@ -289,72 +309,68 @@ export async function pruneTelegramWorkspaceJournalEvidence(input: {
289
309
  "Telegram Workspace journal pruning requires current leader authority.",
290
310
  );
291
311
  }
292
- const shared = input.capture.sources.filter(
293
- (source) => source.scope.kind === "shared",
294
- );
295
- const bindingSources = input.capture.sources.filter(
296
- (source) =>
297
- source.scope.kind === "binding" &&
298
- source.scope.bindingKey === input.binding.bindingKey,
299
- );
300
- const sourceByKey = new Map(
301
- bindingSources.flatMap((source) =>
302
- source.scope.kind === "binding"
303
- ? [[source.scope.journalBindingKey, source] as const]
304
- : [],
305
- ),
306
- );
307
- if (
308
- !input.capture.complete ||
309
- shared.length !== 1 ||
310
- shared[0]?.kind !== "available" ||
311
- bindingSources.length !==
312
- (input.binding.journalBindingKeys ?? []).length ||
313
- sourceByKey.size !== (input.binding.journalBindingKeys ?? []).length ||
314
- Array.from(sourceByKey.values()).some(
315
- (source) => source.kind !== "available",
316
- )
317
- ) {
312
+ let capture: TelegramWorkspaceJournalProtectionCapture;
313
+ try { capture = input.capture(); }
314
+ catch { return { kind: "blocked", reason: "incomplete-evidence" }; }
315
+ const address = (key: string, sessionId?: string) => JSON.stringify([sessionId ?? null, key]);
316
+ const keys = input.binding.journalBindingKeys ?? [];
317
+ const sessions = input.binding.journalSources ?? [];
318
+ const expected = [
319
+ ...keys.map(key => address(key)),
320
+ ...sessions.map(source => address(source.recipientBindingKey, source.sessionId)),
321
+ ];
322
+ const shared = capture.sources.filter(source => source.scope.kind === "shared");
323
+ const bindingSources = capture.sources.filter(source => source.scope.kind === "binding" &&
324
+ source.scope.bindingKey === input.binding.bindingKey);
325
+ const sourceByAddress = new Map(bindingSources.flatMap(source => source.scope.kind === "binding"
326
+ ? [[address(source.scope.journalBindingKey, source.scope.sessionId), source] as const] : []));
327
+ if (!capture.complete || shared.length !== 1 || shared[0]?.kind !== "available" ||
328
+ bindingSources.length !== expected.length || sourceByAddress.size !== expected.length ||
329
+ expected.some(key => sourceByAddress.get(key)?.kind !== "available")) {
318
330
  return { kind: "blocked", reason: "incomplete-evidence" };
319
331
  }
320
- const emptyKeys = (input.binding.journalBindingKeys ?? []).filter((key) => {
321
- const source = sourceByKey.get(key);
332
+ const empty = new Set(expected.filter(key => {
333
+ const source = sourceByAddress.get(key);
322
334
  return source?.kind === "available" && source.entries.length === 0;
323
- });
324
- if (
325
- emptyKeys.some(
326
- (key) => input.getJournalWriterProtection(key) !== "clear",
327
- )
328
- ) {
335
+ }));
336
+ const emptyWriterKeys = new Set([
337
+ ...keys.filter(key => empty.has(address(key))),
338
+ ...sessions.filter(source => empty.has(address(source.recipientBindingKey, source.sessionId)))
339
+ .map(source => source.recipientBindingKey),
340
+ ]);
341
+ const quiescent = new Set([...emptyWriterKeys].filter(key => input.getJournalWriterProtection(key) === "clear"));
342
+ const removedKeys = keys.filter(key => empty.has(address(key)) && quiescent.has(key));
343
+ const removedSources = sessions.filter(source => empty.has(address(source.recipientBindingKey, source.sessionId)) &&
344
+ quiescent.has(source.recipientBindingKey));
345
+ if (empty.size > 0 && removedKeys.length === 0 && removedSources.length === 0) {
329
346
  return { kind: "blocked", reason: "writer-not-quiescent" };
330
347
  }
331
- const retainedKeys = (input.binding.journalBindingKeys ?? []).filter(
332
- (key) => !emptyKeys.includes(key),
333
- );
348
+ const retainedKeys = keys.filter(key => !removedKeys.includes(key));
349
+ const retainedSources = sessions.filter(source => !removedSources.includes(source));
334
350
  if (!isCurrent()) {
335
351
  throw new Error("Telegram Workspace journal pruning lost leader authority.");
336
352
  }
337
353
  const binding = input.store.commitWorkspaceJournalEvidence(
338
- input.binding,
339
- retainedKeys,
340
- input.binding.journalBindingsComplete === true,
354
+ input.binding, retainedKeys, input.binding.journalBindingsComplete === true, retainedSources,
341
355
  );
342
356
  if (!binding) return { kind: "blocked", reason: "state-changed" };
343
- const removedKeys = (input.binding.journalBindingKeys ?? []).filter(
344
- (key) => !retainedKeys.includes(key),
345
- );
346
- if (removedKeys.length > 0) await input.store.persist();
357
+ if (removedKeys.length > 0 || removedSources.length > 0) {
358
+ const published = await input.store.persistWorkspaceJournalEvidence(binding, isCurrent);
359
+ if (!isCurrent()) throw new Error("Telegram Workspace journal pruning lost leader authority.");
360
+ if (!published) return { kind: "blocked", reason: "publication-refused" };
361
+ }
347
362
  if (!isCurrent()) {
348
363
  throw new Error("Telegram Workspace journal pruning lost leader authority.");
349
364
  }
350
- return { kind: "committed", binding, removedKeys };
365
+ return { kind: "committed", binding, removedKeys,
366
+ ...(removedSources.length ? { removedSources: removedSources.map(source => ({ ...source })) } : {}) };
351
367
  };
352
368
  return runWithTelegramWorkspaceAdmissionsAsync({
353
369
  ledger: input.admission,
354
370
  operationId: createTelegramWorkspaceAdmissionOperationId(),
355
371
  operationKind: "workspace.prune-journal-evidence",
356
372
  scopes: [{ kind: "target", target: input.binding.target }],
357
- operation,
373
+ operation: () => input.runExclusive(operation),
358
374
  onReleaseError(error) {
359
375
  input.onAdmissionReleaseError?.(error);
360
376
  },
@@ -363,6 +379,7 @@ export async function pruneTelegramWorkspaceJournalEvidence(input: {
363
379
 
364
380
  export function captureTelegramWorkspaceExternalProtection(input: {
365
381
  binding: TelegramWorkspaceThreadBinding;
382
+ requireBindingProvenance?: boolean;
366
383
  getLiveOwnerProtection: (
367
384
  binding: TelegramWorkspaceThreadBinding,
368
385
  ) => TelegramWorkspaceProtectionState;
@@ -392,6 +409,7 @@ export function captureTelegramWorkspaceExternalProtection(input: {
392
409
  localAcceptedTargets: local.targets,
393
410
  journalSources: journals.sources,
394
411
  sourcesComplete: journals.complete && local.complete,
412
+ requireBindingProvenance: input.requireBindingProvenance,
395
413
  });
396
414
  } catch {
397
415
  // Unavailable queue or journal evidence must not clear accepted work.
@@ -404,7 +422,7 @@ export function captureTelegramWorkspaceExternalProtection(input: {
404
422
  return { liveOwner, acceptedWork, deliveryAuthority };
405
423
  }
406
424
 
407
- export function createTelegramWorkspaceExternalProtectionCapture(deps: {
425
+ interface TelegramWorkspaceProtectionObserverDeps {
408
426
  listFollowers: () => readonly { target?: TelegramTarget }[];
409
427
  getActiveTurnTarget: () => TelegramTarget | undefined;
410
428
  getQueuedItems: () => readonly { chatId: number; target?: TelegramTarget }[];
@@ -412,7 +430,13 @@ export function createTelegramWorkspaceExternalProtectionCapture(deps: {
412
430
  createFollowerJournalResolver: (
413
431
  journalBindingKey: string,
414
432
  ) => () => TelegramWorkspaceJournalReader | undefined;
433
+ createSessionJournalResolver?: (
434
+ recipientBindingKey: string,
435
+ sessionId: string,
436
+ ) => () => TelegramWorkspaceJournalReader | undefined;
415
437
  discoverFollowerJournals?: () => { paths: readonly string[]; complete: boolean };
438
+ /** Strict read-only namespace evidence; it never substitutes for writer closure. */
439
+ inspectJournalNamespace?: () => TelegramJournalNamespaceInspection;
416
440
  createJournalPathResolver?: (
417
441
  path: string,
418
442
  ) => () => TelegramWorkspaceJournalReader | undefined;
@@ -424,19 +448,56 @@ export function createTelegramWorkspaceExternalProtectionCapture(deps: {
424
448
  getDeliveryAuthorityProtection?: (
425
449
  binding: TelegramWorkspaceThreadBinding,
426
450
  ) => TelegramWorkspaceProtectionState;
427
- }): (
428
- binding: TelegramWorkspaceThreadBinding,
429
- ) => TelegramWorkspaceExternalProtectionEvidence {
430
- return function (binding) {
451
+ }
452
+
453
+ export interface TelegramWorkspaceProtectionObserver {
454
+ capture: (binding: TelegramWorkspaceThreadBinding,
455
+ options?: { requireBindingProvenance?: boolean }) => TelegramWorkspaceExternalProtectionEvidence;
456
+ captureJournalSources: (binding: TelegramWorkspaceThreadBinding) => TelegramWorkspaceJournalProtectionCapture;
457
+ getJournalWriterProtection: (journalBindingKey: string) => TelegramWorkspaceProtectionState;
458
+ }
459
+
460
+ /** Shared read-only observer: protection and metadata pruning use the same scoped evidence path. */
461
+ export function createTelegramWorkspaceProtectionObserver(deps: TelegramWorkspaceProtectionObserverDeps): TelegramWorkspaceProtectionObserver {
462
+ const captureJournalSources: TelegramWorkspaceProtectionObserver["captureJournalSources"] = (candidate) => {
463
+ const namespace = deps.inspectJournalNamespace?.();
464
+ if (deps.inspectJournalNamespace && (!namespace || namespace.sources.filter(source => source.role === "polling").length !== 1))
465
+ throw new Error("Telegram strict namespace evidence is unavailable or incomplete.");
466
+ if (namespace?.retainedInputs?.some(original => original.state !== "committed")) throw new Error(
467
+ "Telegram namespace contains uncommitted private retention evidence.",
468
+ );
469
+ const discovery = namespace
470
+ ? { paths: namespace.sources.filter(source => source.role !== "polling" && source.evidence.kind === "present")
471
+ .map(source => source.path), complete: true }
472
+ : candidate.journalBindingsComplete === true ? undefined : deps.discoverFollowerJournals?.();
473
+ if (namespace && !deps.createJournalPathResolver) throw new Error(
474
+ "Telegram strict namespace protection requires discovered source resolution.",
475
+ );
476
+ return captureTelegramWorkspaceJournalProtectionSources({
477
+ binding: candidate,
478
+ resolveLeader: deps.resolveLeaderJournal,
479
+ createFollowerResolver: deps.createFollowerJournalResolver,
480
+ ...(deps.createSessionJournalResolver ? { createSessionResolver: deps.createSessionJournalResolver } : {}),
481
+ ...(deps.withJournalReference ? { withJournalReference: deps.withJournalReference } : {}),
482
+ ...(discovery && deps.createJournalPathResolver
483
+ ? { discovery: { ...discovery, createResolver: deps.createJournalPathResolver } } : {}),
484
+ });
485
+ };
486
+ const capture: TelegramWorkspaceProtectionObserver["capture"] = function (binding, options) {
431
487
  return captureTelegramWorkspaceExternalProtection({
432
488
  binding,
489
+ requireBindingProvenance: options?.requireBindingProvenance,
433
490
  getLiveOwnerProtection(candidate) {
434
491
  if (deps.listFollowers().some((follower) =>
435
492
  !!follower.target && sameTarget(follower.target, candidate.target),
436
493
  )) return "protected";
437
494
  if (!deps.getJournalWriterProtection) return "unknown";
438
495
  let unknown = candidate.journalBindingsComplete !== true;
439
- for (const journalBindingKey of candidate.journalBindingKeys ?? []) {
496
+ const writerKeys = new Set([
497
+ ...(candidate.journalBindingKeys ?? []),
498
+ ...(candidate.journalSources ?? []).map(source => source.recipientBindingKey),
499
+ ]);
500
+ for (const journalBindingKey of writerKeys) {
440
501
  const protection = deps.getJournalWriterProtection(journalBindingKey);
441
502
  if (protection === "protected") return "protected";
442
503
  if (protection === "unknown") unknown = true;
@@ -455,29 +516,40 @@ export function createTelegramWorkspaceExternalProtectionCapture(deps: {
455
516
  }
456
517
  return { targets, complete };
457
518
  },
458
- captureJournalSources(candidate) {
459
- const discovery = candidate.journalBindingsComplete === true
460
- ? undefined
461
- : deps.discoverFollowerJournals?.();
462
- return captureTelegramWorkspaceJournalProtectionSources({
463
- binding: candidate,
464
- resolveLeader: deps.resolveLeaderJournal,
465
- createFollowerResolver: deps.createFollowerJournalResolver,
466
- ...(deps.withJournalReference
467
- ? { withJournalReference: deps.withJournalReference } : {}),
468
- ...(discovery && deps.createJournalPathResolver
469
- ? { discovery: {
470
- ...discovery,
471
- createResolver: deps.createJournalPathResolver,
472
- } }
473
- : {}),
474
- });
475
- },
519
+ captureJournalSources,
476
520
  ...(deps.getDeliveryAuthorityProtection
477
521
  ? { getDeliveryAuthorityProtection: deps.getDeliveryAuthorityProtection }
478
522
  : {}),
479
523
  });
480
524
  };
525
+ return { capture, captureJournalSources,
526
+ getJournalWriterProtection: deps.getJournalWriterProtection ?? (() => "unknown") };
527
+ }
528
+
529
+ /** Callable protection view retained for callers that need no metadata observation ports. */
530
+ export function createTelegramWorkspaceExternalProtectionCapture(deps: TelegramWorkspaceProtectionObserverDeps): TelegramWorkspaceProtectionObserver["capture"] {
531
+ return createTelegramWorkspaceProtectionObserver(deps).capture;
532
+ }
533
+
534
+ export function createTelegramWorkspaceJournalEvidencePruner(deps: TelegramWorkspaceOperationGate & {
535
+ store: TelegramTopicTargetStore;
536
+ getAdmission: () => TelegramWorkspaceAdmissionLedger | undefined;
537
+ getLeaderEpoch: () => number | string | undefined;
538
+ protection: Pick<TelegramWorkspaceProtectionObserver, "captureJournalSources" | "getJournalWriterProtection">;
539
+ }): (binding: TelegramWorkspaceThreadBinding, isCurrent: () => boolean) => Promise<TelegramWorkspaceJournalPruneResult> {
540
+ return async (binding, isCurrent) => {
541
+ const admission = deps.getAdmission();
542
+ if (!admission) return { kind: "blocked", reason: "state-changed" };
543
+ const profileKey = admission.getProfileKey();
544
+ return pruneTelegramWorkspaceJournalEvidence({
545
+ store: deps.store, binding, admission, runExclusive: deps.runExclusive,
546
+ capture: () => deps.protection.captureJournalSources(binding),
547
+ getJournalWriterProtection: deps.protection.getJournalWriterProtection,
548
+ getLeaderEpoch: deps.getLeaderEpoch,
549
+ getProfileKey: () => deps.getAdmission()?.getProfileKey() ?? "",
550
+ isCurrent: () => isCurrent() && deps.getAdmission()?.getProfileKey() === profileKey,
551
+ });
552
+ };
481
553
  }
482
554
 
483
555
  export function isCurrentTelegramWorkspaceBinding(
@@ -530,6 +602,10 @@ export function createTelegramWorkspaceDeadOwnerQueueReclaimer(deps: {
530
602
  createFollowerJournalResolver: (
531
603
  journalBindingKey: string,
532
604
  ) => () => TelegramWorkspaceDeadQueueJournalBinding | undefined;
605
+ createSessionJournalResolver?: (
606
+ recipientBindingKey: string,
607
+ sessionId: string,
608
+ ) => () => TelegramWorkspaceDeadQueueJournalBinding | undefined;
533
609
  discoverFollowerJournals?: () => { paths: readonly string[]; complete: boolean };
534
610
  createJournalPathResolver?: (
535
611
  path: string,
@@ -564,6 +640,9 @@ export function createTelegramWorkspaceDeadOwnerQueueReclaimer(deps: {
564
640
  if (!isCurrent() || !deps.isBindingCurrent(binding)) {
565
641
  return { kind: "blocked", reason: "authority-changed" };
566
642
  }
643
+ // Session addresses require exact resolution; never substitute a flat same-hash journal.
644
+ if (binding.journalSources?.length && !deps.createSessionJournalResolver)
645
+ return { kind: "blocked", reason: "incomplete-source" };
567
646
  const initial = deps.getExternalProtection(binding);
568
647
  if (initial.liveOwner !== "clear" || initial.deliveryAuthority !== "clear") {
569
648
  return { kind: "blocked", reason: "live-owner" };
@@ -607,6 +686,9 @@ export function createTelegramWorkspaceDeadOwnerQueueReclaimer(deps: {
607
686
  for (const key of binding.journalBindingKeys ?? []) {
608
687
  capture("binding", deps.createFollowerJournalResolver(key));
609
688
  }
689
+ for (const source of binding.journalSources ?? []) {
690
+ capture("binding", deps.createSessionJournalResolver!(source.recipientBindingKey, source.sessionId));
691
+ }
610
692
  for (const path of discovery?.paths ?? []) {
611
693
  if (!deps.createJournalPathResolver) {
612
694
  complete = false;
@@ -1339,6 +1421,8 @@ export type TelegramWorkspaceCapacityRunner = <T>(operation: () => Promise<T>) =
1339
1421
  export interface TelegramWorkspaceSlotRotationPorts extends TelegramWorkspaceOperationGate {
1340
1422
  getAdmission: () => TelegramWorkspaceAdmissionLedger | undefined;
1341
1423
  deleteThread: TelegramWorkspaceThreadDeletionTransport;
1424
+ pruneJournalEvidence?: (binding: TelegramWorkspaceThreadBinding,
1425
+ isCurrent: () => boolean) => Promise<TelegramWorkspaceJournalPruneResult>;
1342
1426
  reclaimDeadOwnerQueuedWork?: (
1343
1427
  binding: TelegramWorkspaceThreadBinding,
1344
1428
  isCurrent: () => boolean,
@@ -1417,14 +1501,16 @@ export function createTelegramWorkspaceSlotRotation(input: TelegramWorkspaceSlot
1417
1501
  try { return await operation(); }
1418
1502
  catch (error) {
1419
1503
  if (!(error instanceof TelegramWorkspaceSlotUnavailableError)) throw error;
1420
- if (input.reclaimDeadOwnerQueuedWork) {
1504
+ if (input.pruneJournalEvidence || input.reclaimDeadOwnerQueuedWork) {
1421
1505
  const candidates = await input.runExclusive(async () => {
1422
1506
  await input.store.load();
1423
1507
  if (!isCurrent()) throw new Error("Telegram Workspace reclamation lost leader authority.");
1424
1508
  const snapshot = input.store.captureWorkspaceSlotOccupancy(input.getExternalProtection);
1425
1509
  const allocation = planTelegramWorkspaceSlotAllocation({ ...snapshot, nowMs: Date.now() });
1426
- if (allocation.kind !== "blocked" || allocation.reason !== "protected-capacity") return [];
1510
+ if (allocation.kind === "free" || (allocation.kind === "blocked" && allocation.reason !== "protected-capacity")) return [];
1511
+ if (allocation.kind === "reclaim" && !input.pruneJournalEvidence) return [];
1427
1512
  return input.store.listWorkspaceBindings().filter((binding) =>
1513
+ (allocation.kind !== "reclaim" || binding.bindingKey === allocation.candidate.bindingKey) &&
1428
1514
  typeof binding.inactiveSinceMs === "number" &&
1429
1515
  Number.isFinite(binding.inactiveSinceMs) &&
1430
1516
  binding.inactiveSinceMs >= 0,
@@ -1433,7 +1519,20 @@ export function createTelegramWorkspaceSlotRotation(input: TelegramWorkspaceSlot
1433
1519
  (left.slot ?? "").localeCompare(right.slot ?? ""),
1434
1520
  );
1435
1521
  });
1436
- for (const binding of candidates) {
1522
+ for (const original of candidates) {
1523
+ let binding = original;
1524
+ if (input.pruneJournalEvidence && ((binding.journalBindingKeys?.length ?? 0) + (binding.journalSources?.length ?? 0) > 0)) {
1525
+ const pruned = await input.pruneJournalEvidence(binding, isCurrent);
1526
+ if (!isCurrent()) throw new Error("Telegram Workspace pruning lost leader authority.");
1527
+ if (pruned.kind !== "committed") continue;
1528
+ binding = pruned.binding;
1529
+ const removed = pruned.removedKeys.length + (pruned.removedSources?.length ?? 0);
1530
+ if (removed > 0) {
1531
+ try { input.recordEvent("Telegram Workspace journal evidence pruned.", { slot: binding.slot, removed }); }
1532
+ catch { /* Diagnostics cannot revoke a published metadata subset. */ }
1533
+ }
1534
+ }
1535
+ if (!input.reclaimDeadOwnerQueuedWork) continue;
1437
1536
  const evidence = input.getExternalProtection(binding);
1438
1537
  if (evidence.liveOwner !== "clear" ||
1439
1538
  evidence.acceptedWork !== "protected" ||
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.51.6",
3
+ "version": "0.52.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -27,8 +27,8 @@
27
27
  "node": ">=22.19.0"
28
28
  },
29
29
  "scripts": {
30
- "test": "node --experimental-strip-types --test --test-concurrency=4 --test-reporter=dot tests/*.test.ts",
31
- "test:verbose": "node --experimental-strip-types --test --test-reporter=spec tests/*.test.ts",
30
+ "test": "node --experimental-strip-types --import ./tests/fixtures/canonical-tmpdir.ts --test --test-concurrency=4 --test-reporter=dot tests/*.test.ts",
31
+ "test:verbose": "node --experimental-strip-types --import ./tests/fixtures/canonical-tmpdir.ts --test --test-reporter=spec tests/*.test.ts",
32
32
  "typecheck": "tsc --noEmit",
33
33
  "build": "node scripts/build-dist.mjs",
34
34
  "build:check": "node scripts/build-dist.mjs --check",
@@ -109,9 +109,9 @@
109
109
  "image": "https://raw.githubusercontent.com/llblab/pi-telegram/main/screenshot.png"
110
110
  },
111
111
  "peerDependencies": {
112
- "@earendil-works/pi-agent-core": ">=0.84.4",
113
- "@earendil-works/pi-ai": ">=0.84.4",
114
- "@earendil-works/pi-coding-agent": ">=0.84.4",
112
+ "@earendil-works/pi-agent-core": ">=1.0.0",
113
+ "@earendil-works/pi-ai": ">=1.0.0",
114
+ "@earendil-works/pi-coding-agent": ">=1.0.0",
115
115
  "@sinclair/typebox": "*"
116
116
  },
117
117
  "devDependencies": {
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Read-only export-reference inventory for package-private lib modules.
3
+ * Zones: developer tooling
4
+ * Usage: node scripts/audit-exports.mjs [project-root]
5
+ * Candidates are not deletion proof: review docs, generated code, dynamic
6
+ * namespace access and public declaration reachability before changing them.
7
+ */
8
+ import { resolve, relative } from "node:path";
9
+ import ts from "typescript";
10
+
11
+ const root = resolve(process.argv[2] ?? ".");
12
+ const configPath = resolve(root, "tsconfig.json");
13
+ if (!ts.sys.fileExists(configPath)) throw new Error("Export audit requires tsconfig.json.");
14
+ const config = ts.readConfigFile(configPath, ts.sys.readFile);
15
+ if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n"));
16
+ const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root);
17
+ if (parsed.errors.length) throw new Error(parsed.errors.map(error =>
18
+ ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"));
19
+ const files = ts.sys.readDirectory(root, [".ts", ".mjs", ".js"], ["node_modules", "dist", ".git"]);
20
+ const program = ts.createProgram(files, { ...parsed.options, allowJs: true, noEmit: true });
21
+ const checker = program.getTypeChecker();
22
+ const sources = program.getSourceFiles().filter(source => files.includes(source.fileName));
23
+ const candidates = new Map();
24
+ const modules = new Map();
25
+
26
+ function original(symbol) {
27
+ return symbol && symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
28
+ }
29
+
30
+ for (const source of sources) {
31
+ if (!relative(root, source.fileName).replaceAll("\\", "/").startsWith("lib/")) continue;
32
+ const module = checker.getSymbolAtLocation(source);
33
+ if (!module) continue;
34
+ const scope = { file: relative(root, source.fileName).replaceAll("\\", "/"), rows: [], escapes: new Set() };
35
+ modules.set(module, scope);
36
+ for (const exported of checker.getExportsOfModule(module)) {
37
+ const symbol = original(exported);
38
+ const declarations = symbol?.getDeclarations() ?? [];
39
+ const declaration = declarations.find(node => node.getSourceFile() === source);
40
+ if (!declaration || exported.name === "default") continue;
41
+ const row = {
42
+ file: scope.file,
43
+ name: exported.name,
44
+ line: source.getLineAndCharacterOfPosition(declaration.getStart()).line + 1,
45
+ typeOnly: declarations.every(node => ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)),
46
+ localReferences: 0,
47
+ externalReferences: new Set(),
48
+ namespaceEscapes: scope.escapes,
49
+ };
50
+ candidates.set(symbol, row);
51
+ scope.rows.push(row);
52
+ }
53
+ }
54
+
55
+ for (const source of sources) {
56
+ const file = relative(root, source.fileName).replaceAll("\\", "/");
57
+ // Star reexports need no identifier token naming the individual symbol.
58
+ if (file.startsWith("api/")) {
59
+ const module = checker.getSymbolAtLocation(source);
60
+ for (const exported of module ? checker.getExportsOfModule(module) : []) {
61
+ const symbol = original(exported);
62
+ candidates.get(symbol)?.externalReferences.add(file);
63
+ // A public namespace exposes every member without naming each one.
64
+ for (const row of modules.get(symbol)?.rows ?? []) row.externalReferences.add(file);
65
+ }
66
+ }
67
+ const visit = node => {
68
+ if (ts.isIdentifier(node) || ts.isStringLiteral(node)) {
69
+ const symbol = original(checker.getSymbolAtLocation(node));
70
+ const scope = modules.get(symbol);
71
+ const parent = node.parent;
72
+ if (scope && scope.file !== file && ts.isIdentifier(node) &&
73
+ !ts.isNamespaceImport(parent) &&
74
+ !(ts.isPropertyAccessExpression(parent) && parent.expression === node) &&
75
+ !(ts.isQualifiedName(parent) && parent.left === node) &&
76
+ !(ts.isElementAccessExpression(parent) && parent.expression === node &&
77
+ parent.argumentExpression && ts.isStringLiteral(parent.argumentExpression))) {
78
+ scope.escapes.add(file);
79
+ }
80
+ const row = candidates.get(symbol);
81
+ if (row && !symbol.getDeclarations()?.some(declaration => declaration.name === node)) {
82
+ if (file === row.file) row.localReferences++;
83
+ else row.externalReferences.add(file);
84
+ }
85
+ }
86
+ ts.forEachChild(node, visit);
87
+ };
88
+ visit(source);
89
+ }
90
+
91
+ const rows = [...candidates.values()].map(row => ({
92
+ ...row, externalReferences: [...row.externalReferences].sort(),
93
+ namespaceEscapes: [...row.namespaceEscapes].sort(),
94
+ })).sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
95
+ const unused = rows.filter(row => row.externalReferences.length === 0);
96
+ console.log(JSON.stringify({
97
+ scannedSources: sources.length,
98
+ libExports: rows.length,
99
+ candidates: unused,
100
+ }, null, 2));