@llblab/pi-telegram 0.46.0 → 0.48.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 (204) hide show
  1. package/AGENTS.md +4 -2
  2. package/BACKLOG.md +0 -6
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +5 -2
  5. package/dist/api/activity.d.ts +6 -0
  6. package/dist/api/activity.js +6 -0
  7. package/dist/api/commands.d.ts +6 -0
  8. package/dist/api/commands.js +6 -0
  9. package/dist/api/delivery.d.ts +6 -0
  10. package/dist/api/delivery.js +6 -0
  11. package/dist/api/inbound.d.ts +6 -0
  12. package/dist/api/inbound.js +6 -0
  13. package/dist/api/keyboard.d.ts +6 -0
  14. package/dist/api/keyboard.js +6 -0
  15. package/dist/api/outbound.d.ts +6 -0
  16. package/dist/api/outbound.js +6 -0
  17. package/dist/api/sections.d.ts +7 -0
  18. package/dist/api/sections.js +6 -0
  19. package/dist/api/status.d.ts +6 -0
  20. package/dist/api/status.js +6 -0
  21. package/dist/api/updates.d.ts +6 -0
  22. package/dist/api/updates.js +6 -0
  23. package/dist/api/voice.d.ts +6 -0
  24. package/dist/api/voice.js +6 -0
  25. package/dist/index.d.ts +6 -0
  26. package/dist/index.js +6 -0
  27. package/dist/lib/activity-verbosity.d.ts +52 -0
  28. package/dist/lib/activity-verbosity.js +596 -0
  29. package/dist/lib/activity.d.ts +220 -0
  30. package/dist/lib/activity.js +574 -0
  31. package/dist/lib/agent-messages.d.ts +28 -0
  32. package/dist/lib/agent-messages.js +86 -0
  33. package/dist/lib/bindings.d.ts +250 -0
  34. package/dist/lib/bindings.js +936 -0
  35. package/dist/lib/bus-api.d.ts +18 -0
  36. package/dist/lib/bus-api.js +254 -0
  37. package/dist/lib/bus-follower.d.ts +464 -0
  38. package/dist/lib/bus-follower.js +1686 -0
  39. package/dist/lib/bus-leader.d.ts +292 -0
  40. package/dist/lib/bus-leader.js +2242 -0
  41. package/dist/lib/bus-transport.d.ts +64 -0
  42. package/dist/lib/bus-transport.js +140 -0
  43. package/dist/lib/bus.d.ts +518 -0
  44. package/dist/lib/bus.js +2055 -0
  45. package/dist/lib/channel-posts.d.ts +170 -0
  46. package/dist/lib/channel-posts.js +611 -0
  47. package/dist/lib/command-templates.d.ts +70 -0
  48. package/dist/lib/command-templates.js +744 -0
  49. package/dist/lib/commands.d.ts +541 -0
  50. package/dist/lib/commands.js +1155 -0
  51. package/dist/lib/config.d.ts +252 -0
  52. package/dist/lib/config.js +889 -0
  53. package/dist/lib/delivery.d.ts +163 -0
  54. package/dist/lib/delivery.js +542 -0
  55. package/dist/lib/extension.d.ts +7 -0
  56. package/dist/lib/extension.js +1604 -0
  57. package/dist/lib/generative-app-worker.mjs +104 -0
  58. package/dist/lib/generative-apps.d.ts +212 -0
  59. package/dist/lib/generative-apps.js +1006 -0
  60. package/dist/lib/inbound.d.ts +91 -0
  61. package/dist/lib/inbound.js +502 -0
  62. package/dist/lib/journal.d.ts +665 -0
  63. package/dist/lib/journal.js +3701 -0
  64. package/dist/lib/keyboard.d.ts +23 -0
  65. package/dist/lib/keyboard.js +37 -0
  66. package/dist/lib/lifecycle.d.ts +157 -0
  67. package/dist/lib/lifecycle.js +395 -0
  68. package/dist/lib/locks.d.ts +164 -0
  69. package/dist/lib/locks.js +1208 -0
  70. package/dist/lib/logging.d.ts +50 -0
  71. package/dist/lib/logging.js +274 -0
  72. package/dist/lib/media.d.ts +181 -0
  73. package/dist/lib/media.js +602 -0
  74. package/dist/lib/menu-model.d.ts +217 -0
  75. package/dist/lib/menu-model.js +663 -0
  76. package/dist/lib/menu-queue.d.ts +37 -0
  77. package/dist/lib/menu-queue.js +408 -0
  78. package/dist/lib/menu-settings.d.ts +115 -0
  79. package/dist/lib/menu-settings.js +595 -0
  80. package/dist/lib/menu-status.d.ts +32 -0
  81. package/dist/lib/menu-status.js +112 -0
  82. package/dist/lib/menu-thinking.d.ts +29 -0
  83. package/dist/lib/menu-thinking.js +82 -0
  84. package/dist/lib/menu.d.ts +171 -0
  85. package/dist/lib/menu.js +323 -0
  86. package/dist/lib/model.d.ts +127 -0
  87. package/dist/lib/model.js +409 -0
  88. package/dist/lib/outbound-attachments.d.ts +241 -0
  89. package/dist/lib/outbound-attachments.js +639 -0
  90. package/dist/lib/outbound-buttons.d.ts +69 -0
  91. package/dist/lib/outbound-buttons.js +248 -0
  92. package/dist/lib/outbound-markup.d.ts +42 -0
  93. package/dist/lib/outbound-markup.js +678 -0
  94. package/dist/lib/outbound-voice.d.ts +55 -0
  95. package/dist/lib/outbound-voice.js +152 -0
  96. package/dist/lib/outbound.d.ts +185 -0
  97. package/dist/lib/outbound.js +516 -0
  98. package/dist/lib/ownership.d.ts +77 -0
  99. package/dist/lib/ownership.js +174 -0
  100. package/dist/lib/paths.d.ts +40 -0
  101. package/dist/lib/paths.js +94 -0
  102. package/dist/lib/pi.d.ts +72 -0
  103. package/dist/lib/pi.js +121 -0
  104. package/dist/lib/polling.d.ts +351 -0
  105. package/dist/lib/polling.js +1196 -0
  106. package/dist/lib/preview.d.ts +192 -0
  107. package/dist/lib/preview.js +614 -0
  108. package/dist/lib/prompt-templates.d.ts +24 -0
  109. package/dist/lib/prompt-templates.js +118 -0
  110. package/dist/lib/prompts.d.ts +57 -0
  111. package/dist/lib/prompts.js +167 -0
  112. package/dist/lib/queue.d.ts +766 -0
  113. package/dist/lib/queue.js +1965 -0
  114. package/dist/lib/recovery.d.ts +86 -0
  115. package/dist/lib/recovery.js +285 -0
  116. package/dist/lib/rendering.d.ts +20 -0
  117. package/dist/lib/rendering.js +983 -0
  118. package/dist/lib/replies.d.ts +214 -0
  119. package/dist/lib/replies.js +737 -0
  120. package/dist/lib/routing.d.ts +207 -0
  121. package/dist/lib/routing.js +2282 -0
  122. package/dist/lib/runtime.d.ts +172 -0
  123. package/dist/lib/runtime.js +402 -0
  124. package/dist/lib/sections.d.ts +165 -0
  125. package/dist/lib/sections.js +327 -0
  126. package/dist/lib/setup.d.ts +82 -0
  127. package/dist/lib/setup.js +150 -0
  128. package/dist/lib/skills.d.ts +8 -0
  129. package/dist/lib/skills.js +12 -0
  130. package/dist/lib/status.d.ts +442 -0
  131. package/dist/lib/status.js +1008 -0
  132. package/dist/lib/sync.d.ts +179 -0
  133. package/dist/lib/sync.js +782 -0
  134. package/dist/lib/target.d.ts +20 -0
  135. package/dist/lib/target.js +27 -0
  136. package/dist/lib/telegram-api.d.ts +541 -0
  137. package/dist/lib/telegram-api.js +1159 -0
  138. package/dist/lib/text-groups.d.ts +89 -0
  139. package/dist/lib/text-groups.js +317 -0
  140. package/dist/lib/thread-cleanup-manager.d.ts +288 -0
  141. package/dist/lib/thread-cleanup-manager.js +560 -0
  142. package/dist/lib/thread-display.d.ts +46 -0
  143. package/dist/lib/thread-display.js +257 -0
  144. package/dist/lib/thread-naming.d.ts +46 -0
  145. package/dist/lib/thread-naming.js +78 -0
  146. package/dist/lib/thread-reconciler.d.ts +239 -0
  147. package/dist/lib/thread-reconciler.js +644 -0
  148. package/dist/lib/threads.d.ts +621 -0
  149. package/dist/lib/threads.js +3679 -0
  150. package/dist/lib/time-injection.d.ts +15 -0
  151. package/dist/lib/time-injection.js +56 -0
  152. package/dist/lib/turns.d.ts +109 -0
  153. package/dist/lib/turns.js +500 -0
  154. package/dist/lib/updates.d.ts +1290 -0
  155. package/dist/lib/updates.js +3634 -0
  156. package/dist/lib/voice.d.ts +117 -0
  157. package/dist/lib/voice.js +174 -0
  158. package/dist/lib/workspace-admission.d.ts +264 -0
  159. package/dist/lib/workspace-admission.js +1136 -0
  160. package/dist/lib/workspace-retirement.d.ts +219 -0
  161. package/dist/lib/workspace-retirement.js +587 -0
  162. package/dist/lib/workspace-slots.d.ts +30 -0
  163. package/dist/lib/workspace-slots.js +54 -0
  164. package/dist/package.json +126 -0
  165. package/dist/pi-telegram/index.js +1 -0
  166. package/dist/skills/generated-control-surface/SKILL.md +107 -0
  167. package/dist/skills/generated-control-surface/references/capability-adapters.md +27 -0
  168. package/dist/skills/generated-control-surface/references/layout-and-state.md +37 -0
  169. package/dist/skills/generative-apps/SKILL.md +115 -0
  170. package/dist/skills/show-me/SKILL.md +166 -0
  171. package/dist/skills/show-me/references/telegram-surfaces.md +43 -0
  172. package/dist/skills/telegram-bridge/SKILL.md +129 -0
  173. package/dist/skills/telegram-bridge/references/configuration.md +15 -0
  174. package/dist/skills/telegram-bridge/references/delivery-and-threads.md +27 -0
  175. package/dist/skills/telegram-bridge/references/diagnosis.md +18 -0
  176. package/docs/README.md +2 -0
  177. package/docs/architecture.md +1 -1
  178. package/docs/compact-matrix-literal.md +10 -3
  179. package/docs/generative-apps.md +15 -13
  180. package/docs/multi-instance-bus.md +6 -4
  181. package/docs/outbound.md +1 -1
  182. package/docs/public-api.md +3 -3
  183. package/lib/bindings.ts +89 -6
  184. package/lib/bus-follower.ts +7 -3
  185. package/lib/bus-leader.ts +44 -19
  186. package/lib/bus.ts +4 -1
  187. package/lib/commands.ts +2 -2
  188. package/lib/config.ts +14 -7
  189. package/lib/delivery.ts +38 -6
  190. package/lib/extension.ts +7 -0
  191. package/lib/generative-apps.ts +379 -13
  192. package/lib/menu-settings.ts +20 -6
  193. package/lib/outbound-markup.ts +16 -7
  194. package/lib/status.ts +15 -12
  195. package/lib/telegram-api.ts +12 -1
  196. package/lib/thread-display.ts +96 -18
  197. package/package.json +56 -13
  198. package/scripts/build-dist.mjs +44 -0
  199. package/scripts/measure-bus.mjs +8 -1
  200. package/scripts/measure-workspace.mjs +8 -1
  201. package/skills/generative-apps/SKILL.md +1 -1
  202. package/skills/show-me/SKILL.md +1 -1
  203. package/skills/show-me/references/telegram-surfaces.md +1 -1
  204. package/skills/telegram-bridge/SKILL.md +1 -1
@@ -0,0 +1,560 @@
1
+ /**
2
+ * Proof-only candidate planning for inactive Telegram Workspace Thread cleanup
3
+ * Zones: telegram threads, workspace lifecycle
4
+ * Owns fail-closed cleanup eligibility projection without persistence or Bot API effects
5
+ */
6
+ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import { createHash, randomUUID } from "node:crypto";
9
+ import { renameTelegramPathWithRetry, withTelegramFileTransaction } from "./locks.js";
10
+ function targetKey(target) {
11
+ return `${target.chatId}:${target.threadId}`;
12
+ }
13
+ function validTarget(target) {
14
+ return Number.isSafeInteger(target.chatId) && Number.isSafeInteger(target.threadId) &&
15
+ target.threadId > 0;
16
+ }
17
+ function validSessionIdentity(value) {
18
+ if (value.sessionId === undefined && value.sessionKey === undefined)
19
+ return true;
20
+ if (typeof value.sessionId !== "string" || !value.sessionId ||
21
+ value.sessionId !== value.sessionId.trim() ||
22
+ Buffer.byteLength(value.sessionId, "utf8") > 256 ||
23
+ typeof value.sessionKey !== "string" || !/^[a-f0-9]{64}$/u.test(value.sessionKey)) {
24
+ return false;
25
+ }
26
+ return createHash("sha256").update(value.sessionId).digest("hex") ===
27
+ value.sessionKey;
28
+ }
29
+ /** Returns no candidates when any identity/evidence ambiguity exists. */
30
+ export function planTelegramInactiveThreadCleanup(input) {
31
+ if (!input.profileName)
32
+ return [];
33
+ const bindingKeys = new Set();
34
+ const bindingTargets = new Set();
35
+ for (const binding of input.bindings) {
36
+ const key = targetKey(binding.target);
37
+ const slot = binding.slot;
38
+ if (!binding.bindingKey || !binding.cwd || !binding.workspaceKey || !binding.instanceSlot ||
39
+ typeof slot !== "string" || !slot || !validSessionIdentity(binding) ||
40
+ !validTarget(binding.target) || bindingKeys.has(binding.bindingKey) || bindingTargets.has(key))
41
+ return [];
42
+ bindingKeys.add(binding.bindingKey);
43
+ bindingTargets.add(key);
44
+ }
45
+ const evidenceByBinding = new Map();
46
+ for (const evidence of input.protection) {
47
+ if (!evidence.bindingKey || evidenceByBinding.has(evidence.bindingKey) || !validTarget(evidence.target))
48
+ return [];
49
+ evidenceByBinding.set(evidence.bindingKey, evidence);
50
+ }
51
+ const competingTargets = [
52
+ ...(input.reservedTargets ?? []),
53
+ ...(input.provisioningTargets ?? []),
54
+ ...(input.cleanupTargets ?? []),
55
+ ];
56
+ if (competingTargets.some(target => !validTarget(target)))
57
+ return [];
58
+ const competing = new Set(competingTargets.map(targetKey));
59
+ const candidates = [];
60
+ for (const binding of input.bindings) {
61
+ const inactiveSinceMs = binding.inactiveSinceMs;
62
+ const slot = binding.slot;
63
+ if (typeof slot !== "string" || !slot || !Number.isSafeInteger(inactiveSinceMs) ||
64
+ inactiveSinceMs === undefined || inactiveSinceMs < 0 ||
65
+ !Number.isSafeInteger(binding.updatedAtMs) || binding.updatedAtMs < inactiveSinceMs)
66
+ continue;
67
+ const evidence = evidenceByBinding.get(binding.bindingKey);
68
+ if (!evidence || targetKey(evidence.target) !== targetKey(binding.target) ||
69
+ evidence.liveOwner !== "clear" || evidence.acceptedWork !== "clear" ||
70
+ evidence.deliveryAuthority !== "clear" || competing.has(targetKey(binding.target)))
71
+ continue;
72
+ candidates.push({ profileName: input.profileName, bindingKey: binding.bindingKey,
73
+ cwd: binding.cwd, workspaceKey: binding.workspaceKey,
74
+ ...(binding.sessionId && binding.sessionKey
75
+ ? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
76
+ : {}),
77
+ instanceSlot: binding.instanceSlot, slot, target: { ...binding.target }, inactiveSinceMs,
78
+ bindingUpdatedAtMs: binding.updatedAtMs });
79
+ }
80
+ return candidates.sort((left, right) => left.bindingKey.localeCompare(right.bindingKey));
81
+ }
82
+ export function captureTelegramInactiveThreadCleanupEvidence(input) {
83
+ const sourceBindings = input.listBindings();
84
+ const bindings = sourceBindings.map(binding => ({
85
+ cwd: binding.cwd, workspaceKey: binding.workspaceKey,
86
+ ...(binding.sessionId && binding.sessionKey
87
+ ? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
88
+ : {}),
89
+ instanceSlot: binding.instanceSlot,
90
+ ...(binding.slot === undefined ? {} : { slot: binding.slot }), bindingKey: binding.bindingKey, target: { ...binding.target },
91
+ ...(binding.inactiveSinceMs === undefined ? {} : { inactiveSinceMs: binding.inactiveSinceMs }),
92
+ updatedAtMs: binding.updatedAtMs,
93
+ }));
94
+ const protection = sourceBindings.map((source, index) => {
95
+ const binding = bindings[index];
96
+ try {
97
+ return { bindingKey: binding.bindingKey, target: { ...binding.target },
98
+ ...input.getProtection(source) };
99
+ }
100
+ catch {
101
+ return { bindingKey: binding.bindingKey, target: { ...binding.target },
102
+ liveOwner: "unknown", acceptedWork: "unknown",
103
+ deliveryAuthority: "unknown" };
104
+ }
105
+ });
106
+ return {
107
+ profileName: input.profileName,
108
+ bindings,
109
+ protection,
110
+ reservedTargets: input.listReservations().map(entry => ({ ...entry.target })),
111
+ provisioningTargets: input.listPendingProvisions().flatMap(entry => entry.target ? [{ ...entry.target }] : []),
112
+ cleanupTargets: input.listPendingCleanups().map(entry => ({ ...entry.target })),
113
+ };
114
+ }
115
+ export function createTelegramInactiveThreadCleanupReviewRuntime(deps) {
116
+ return {
117
+ review() {
118
+ return deps.runWorkspaceOperation({ operationId: `thread-cleanup-review:${randomUUID()}`,
119
+ operationKind: "thread-cleanup-review", scopes: [{ kind: "profile" }] }, async () => {
120
+ const evidence = captureTelegramInactiveThreadCleanupEvidence({
121
+ profileName: deps.getProfileName(), listBindings: deps.listBindings,
122
+ getProtection: deps.getProtection, listReservations: deps.listReservations,
123
+ listPendingProvisions: deps.listPendingProvisions,
124
+ listPendingCleanups: deps.listPendingCleanups,
125
+ });
126
+ const candidates = planTelegramInactiveThreadCleanup(evidence);
127
+ if (candidates.length === 0)
128
+ return { count: 0 };
129
+ const digest = createHash("sha256").update(JSON.stringify(candidates)).digest("hex").slice(0, 32);
130
+ const operationId = `thread-cleanup:${digest}`;
131
+ deps.getWorkStore().prepare(operationId, candidates);
132
+ return { count: candidates.length, operationId };
133
+ });
134
+ },
135
+ };
136
+ }
137
+ function isObject(value) {
138
+ return !!value && typeof value === "object" && !Array.isArray(value);
139
+ }
140
+ function onlyKeys(value, keys) {
141
+ const allowed = new Set(keys);
142
+ return Object.keys(value).every(key => allowed.has(key));
143
+ }
144
+ function safeTime(value) {
145
+ return Number.isSafeInteger(value) && value >= 0;
146
+ }
147
+ function validateWorkSet(value, profileName) {
148
+ if (!isObject(value) || !onlyKeys(value, ["operationId", "createdAtMs", "entries"]) ||
149
+ typeof value.operationId !== "string" || !value.operationId || !safeTime(value.createdAtMs) ||
150
+ !Array.isArray(value.entries) || value.entries.length === 0)
151
+ throw new Error("Telegram Thread cleanup work-set schema is invalid.");
152
+ const createdAtMs = value.createdAtMs;
153
+ const entries = value.entries.map(raw => {
154
+ if (!isObject(raw) || !onlyKeys(raw, ["profileName", "bindingKey", "cwd", "workspaceKey",
155
+ "sessionId", "sessionKey", "instanceSlot", "slot", "target", "inactiveSinceMs", "bindingUpdatedAtMs", "state", "updatedAtMs",
156
+ "issuedAtMs", "permitOperationId", "permitIntentId", "permitLeaderEpoch", "deletedAtMs"]) || raw.profileName !== profileName || typeof raw.bindingKey !== "string" ||
157
+ !raw.bindingKey || typeof raw.cwd !== "string" || !raw.cwd || typeof raw.workspaceKey !== "string" ||
158
+ !raw.workspaceKey || !validSessionIdentity(raw) ||
159
+ typeof raw.instanceSlot !== "string" || !raw.instanceSlot ||
160
+ typeof raw.slot !== "string" || !raw.slot || !isObject(raw.target) ||
161
+ !onlyKeys(raw.target, ["chatId", "threadId"]) || !validTarget(raw.target) ||
162
+ !safeTime(raw.inactiveSinceMs) || !safeTime(raw.bindingUpdatedAtMs) ||
163
+ raw.bindingUpdatedAtMs < raw.inactiveSinceMs || !safeTime(raw.updatedAtMs))
164
+ throw new Error("Telegram Thread cleanup entry schema is invalid.");
165
+ const base = { profileName, bindingKey: raw.bindingKey, cwd: raw.cwd, workspaceKey: raw.workspaceKey,
166
+ ...(typeof raw.sessionId === "string" && typeof raw.sessionKey === "string"
167
+ ? { sessionId: raw.sessionId, sessionKey: raw.sessionKey }
168
+ : {}),
169
+ instanceSlot: raw.instanceSlot, slot: raw.slot, target: { chatId: raw.target.chatId,
170
+ threadId: raw.target.threadId }, inactiveSinceMs: raw.inactiveSinceMs,
171
+ bindingUpdatedAtMs: raw.bindingUpdatedAtMs, updatedAtMs: raw.updatedAtMs };
172
+ if (base.updatedAtMs < base.bindingUpdatedAtMs)
173
+ throw new Error("Telegram Thread cleanup entry clock is invalid.");
174
+ const noPermit = raw.permitOperationId === undefined && raw.permitIntentId === undefined &&
175
+ raw.permitLeaderEpoch === undefined;
176
+ if (raw.state === "prepared" && raw.issuedAtMs === undefined && raw.deletedAtMs === undefined && noPermit)
177
+ return { ...base, state: "prepared" };
178
+ const validPermit = typeof raw.permitOperationId === "string" && !!raw.permitOperationId &&
179
+ typeof raw.permitIntentId === "string" && !!raw.permitIntentId &&
180
+ (typeof raw.permitLeaderEpoch === "string" || Number.isSafeInteger(raw.permitLeaderEpoch));
181
+ if (raw.state === "outcome-unknown" && safeTime(raw.issuedAtMs) && validPermit &&
182
+ raw.issuedAtMs === base.updatedAtMs && raw.deletedAtMs === undefined)
183
+ return { ...base, state: "outcome-unknown", issuedAtMs: raw.issuedAtMs,
184
+ permitOperationId: raw.permitOperationId, permitIntentId: raw.permitIntentId,
185
+ permitLeaderEpoch: raw.permitLeaderEpoch };
186
+ if (raw.state === "deleted" && safeTime(raw.issuedAtMs) && validPermit && safeTime(raw.deletedAtMs) &&
187
+ raw.deletedAtMs === base.updatedAtMs && raw.deletedAtMs >= raw.issuedAtMs)
188
+ return { ...base, state: "deleted", issuedAtMs: raw.issuedAtMs,
189
+ permitOperationId: raw.permitOperationId, permitIntentId: raw.permitIntentId,
190
+ permitLeaderEpoch: raw.permitLeaderEpoch, deletedAtMs: raw.deletedAtMs };
191
+ throw new Error("Telegram Thread cleanup entry state is invalid.");
192
+ });
193
+ if (entries.some(entry => entry.updatedAtMs < createdAtMs) ||
194
+ new Set(entries.map(entry => entry.bindingKey)).size !== entries.length ||
195
+ new Set(entries.map(entry => targetKey(entry.target))).size !== entries.length)
196
+ throw new Error("Telegram Thread cleanup work-set identity is ambiguous.");
197
+ return { operationId: value.operationId, createdAtMs, entries };
198
+ }
199
+ function sameCandidates(left, right) {
200
+ return JSON.stringify(left) === JSON.stringify(right);
201
+ }
202
+ export function createTelegramThreadCleanupWorkStore(options) {
203
+ const maxWorkSets = options.maxWorkSets ?? 32;
204
+ const maxBytes = options.maxBytes ?? 1024 * 1024;
205
+ const now = options.getNowMs ?? Date.now;
206
+ if (!options.path || !options.profileName || !/^[a-f0-9]{64}$/u.test(options.tokenSha256) ||
207
+ !Number.isSafeInteger(maxWorkSets) || maxWorkSets <= 0 ||
208
+ !Number.isSafeInteger(maxBytes) || maxBytes <= 0)
209
+ throw new Error("Telegram Thread cleanup store options are invalid.");
210
+ const empty = () => ({ version: 1, profileName: options.profileName,
211
+ tokenSha256: options.tokenSha256, workSets: [] });
212
+ const read = () => {
213
+ let before;
214
+ try {
215
+ before = lstatSync(options.path, { bigint: true });
216
+ }
217
+ catch (error) {
218
+ if (error.code === "ENOENT")
219
+ return empty();
220
+ throw error;
221
+ }
222
+ const uid = process.getuid?.();
223
+ if (!constants.O_NOFOLLOW || !constants.O_NONBLOCK || uid === undefined || !before.isFile() ||
224
+ before.isSymbolicLink() || before.uid !== BigInt(uid) || before.nlink !== 1n ||
225
+ (before.mode & 63n) !== 0n || before.size > BigInt(maxBytes))
226
+ throw new Error("Telegram Thread cleanup store is not a bounded private regular file.");
227
+ const fd = openSync(options.path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
228
+ let value;
229
+ try {
230
+ const opened = fstatSync(fd, { bigint: true });
231
+ if (opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size ||
232
+ opened.mtimeNs !== before.mtimeNs)
233
+ throw new Error("Telegram Thread cleanup store changed during inspection.");
234
+ value = JSON.parse(readFileSync(fd, "utf8"));
235
+ }
236
+ finally {
237
+ closeSync(fd);
238
+ }
239
+ if (!isObject(value) || !onlyKeys(value, ["version", "profileName", "tokenSha256", "workSets"]) ||
240
+ value.version !== 1 || value.profileName !== options.profileName ||
241
+ value.tokenSha256 !== options.tokenSha256 || !Array.isArray(value.workSets))
242
+ throw new Error("Telegram Thread cleanup store identity or schema does not match.");
243
+ const workSets = value.workSets.map(workSet => validateWorkSet(workSet, options.profileName));
244
+ if (new Set(workSets.map(workSet => workSet.operationId)).size !== workSets.length)
245
+ throw new Error("Telegram Thread cleanup operation identity is ambiguous.");
246
+ return { version: 1, profileName: options.profileName, tokenSha256: options.tokenSha256, workSets };
247
+ };
248
+ const publish = (file) => {
249
+ if (file.workSets.length > maxWorkSets)
250
+ throw new Error("Telegram Thread cleanup work-set capacity reached.");
251
+ const serialized = `${JSON.stringify(file, null, 2)}\n`;
252
+ if (Buffer.byteLength(serialized) > maxBytes)
253
+ throw new Error("Telegram Thread cleanup byte capacity reached.");
254
+ const temporaryPath = `${options.path}.${process.pid}.${randomUUID()}.tmp`;
255
+ mkdirSync(dirname(options.path), { recursive: true, mode: 0o700 });
256
+ try {
257
+ writeFileSync(temporaryPath, serialized, { mode: 0o600 });
258
+ chmodSync(temporaryPath, 0o600);
259
+ options.onPublicationBoundary?.("after-write-before-rename");
260
+ if (!renameTelegramPathWithRetry(temporaryPath, options.path))
261
+ throw new Error("Telegram Thread cleanup staging file disappeared.");
262
+ chmodSync(options.path, 0o600);
263
+ options.onPublicationBoundary?.("after-rename");
264
+ }
265
+ finally {
266
+ try {
267
+ unlinkSync(temporaryPath);
268
+ }
269
+ catch { /* rename consumed it */ }
270
+ }
271
+ };
272
+ const mutate = (operation) => withTelegramFileTransaction(`${options.path}.transaction`, () => operation(read()));
273
+ return {
274
+ prepare(operationId, candidates) {
275
+ if (!operationId || candidates.length === 0 || candidates.some(candidate => candidate.profileName !== options.profileName))
276
+ throw new Error("Telegram Thread cleanup work-set is invalid.");
277
+ return mutate(file => {
278
+ const existing = file.workSets.find(workSet => workSet.operationId === operationId);
279
+ if (existing) {
280
+ const projected = existing.entries.map(({ state: _state, updatedAtMs: _updated, issuedAtMs: _issued, permitOperationId: _permitOperation, permitIntentId: _permitIntent, permitLeaderEpoch: _permitEpoch, deletedAtMs: _deleted, ...candidate }) => candidate);
281
+ if (!sameCandidates(projected, candidates))
282
+ throw new Error("Telegram Thread cleanup operation conflicts.");
283
+ return { prepared: false, workSet: structuredClone(existing) };
284
+ }
285
+ const atMs = now();
286
+ if (!safeTime(atMs))
287
+ throw new Error("Telegram Thread cleanup clock is invalid.");
288
+ const workSet = validateWorkSet({ operationId, createdAtMs: atMs,
289
+ entries: candidates.map(candidate => ({ ...structuredClone(candidate), state: "prepared", updatedAtMs: atMs })) }, options.profileName);
290
+ publish({ ...file, workSets: [...file.workSets, workSet] });
291
+ return { prepared: true, workSet: structuredClone(workSet) };
292
+ });
293
+ },
294
+ recordDeletionIssued(input) {
295
+ return mutate(file => {
296
+ const workSet = file.workSets.find(candidate => candidate.operationId === input.operationId);
297
+ const entry = workSet?.entries.find(candidate => candidate.bindingKey === input.bindingKey);
298
+ const permit = input.permit;
299
+ if (!workSet || !entry || entry.bindingUpdatedAtMs !== input.bindingUpdatedAtMs ||
300
+ permit.destructiveKind !== "manual-thread-cleanup" || permit.profileKey !== entry.profileName || permit.bindingKey !== entry.bindingKey ||
301
+ permit.slot !== entry.slot || targetKey(permit.target) !== targetKey(entry.target) ||
302
+ !permit.operationId || !permit.retirementIntentId || !safeTime(permit.issuedAtMs))
303
+ throw new Error("Telegram Thread cleanup entry or deletion permit is stale or mismatched.");
304
+ if (entry.state !== "prepared") {
305
+ const exact = entry.permitOperationId === permit.operationId &&
306
+ entry.permitIntentId === permit.retirementIntentId &&
307
+ entry.permitLeaderEpoch === permit.leaderEpoch;
308
+ if (!exact)
309
+ throw new Error("Telegram Thread cleanup deletion permit conflicts with retained work.");
310
+ return { recorded: false, entry: structuredClone(entry) };
311
+ }
312
+ const atMs = now();
313
+ if (!safeTime(atMs) || atMs < entry.updatedAtMs || atMs < permit.issuedAtMs)
314
+ throw new Error("Telegram Thread cleanup clock is invalid.");
315
+ entry.state = "outcome-unknown";
316
+ entry.issuedAtMs = atMs;
317
+ entry.updatedAtMs = atMs;
318
+ entry.permitOperationId = permit.operationId;
319
+ entry.permitIntentId = permit.retirementIntentId;
320
+ entry.permitLeaderEpoch = permit.leaderEpoch;
321
+ publish(file);
322
+ return { recorded: true, entry: structuredClone(entry) };
323
+ });
324
+ },
325
+ confirmDeleted(input) {
326
+ return mutate(file => {
327
+ const entry = file.workSets.find(candidate => candidate.operationId === input.operationId)
328
+ ?.entries.find(candidate => candidate.bindingKey === input.bindingKey);
329
+ if (!entry)
330
+ throw new Error("Telegram Thread cleanup entry is missing.");
331
+ if (entry.state === "deleted")
332
+ return { confirmed: false, entry: structuredClone(entry) };
333
+ if (entry.state !== "outcome-unknown")
334
+ throw new Error("Telegram Thread cleanup deletion confirmation is premature.");
335
+ const atMs = now();
336
+ if (!safeTime(atMs) || atMs < entry.updatedAtMs)
337
+ throw new Error("Telegram Thread cleanup clock is invalid.");
338
+ entry.state = "deleted";
339
+ entry.deletedAtMs = atMs;
340
+ entry.updatedAtMs = atMs;
341
+ publish(file);
342
+ return { confirmed: true, entry: structuredClone(entry) };
343
+ });
344
+ },
345
+ list() { return structuredClone(read().workSets); },
346
+ };
347
+ }
348
+ export async function commitTelegramInactiveThreadCleanup(input) {
349
+ if (!await input.commitBinding())
350
+ return false;
351
+ input.store.confirmDeleted({ operationId: input.operationId, bindingKey: input.bindingKey });
352
+ return true;
353
+ }
354
+ export async function executeTelegramInactiveThreadCleanup(input) {
355
+ return input.withWorkspaceDeletionBoundary(async () => {
356
+ const workSet = input.store.list().find(candidate => candidate.operationId === input.operationId);
357
+ const retained = workSet?.entries.find(candidate => candidate.bindingKey === input.bindingKey);
358
+ if (!retained)
359
+ return { status: "blocked" };
360
+ if (retained.state === "deleted")
361
+ return { status: "deleted", entry: retained };
362
+ if (retained.state === "outcome-unknown")
363
+ return { status: "outcome-unknown", entry: retained };
364
+ const planned = planTelegramInactiveThreadCleanup(await input.loadFreshEvidence());
365
+ const fresh = planned.find(candidate => candidate.bindingKey === retained.bindingKey);
366
+ const retainedCandidate = (({ state: _state, updatedAtMs: _updated, issuedAtMs: _issued, permitOperationId: _permitOperation, permitIntentId: _permitIntent, permitLeaderEpoch: _permitEpoch, deletedAtMs: _deleted, ...candidate }) => candidate)(retained);
367
+ if (!fresh || !sameCandidates([fresh], [retainedCandidate]))
368
+ return { status: "blocked" };
369
+ const permitResult = await input.acquireDeletionPermit(fresh);
370
+ if (permitResult.kind !== "issued")
371
+ return { status: "blocked" };
372
+ const recorded = input.store.recordDeletionIssued({ operationId: input.operationId,
373
+ bindingKey: input.bindingKey, bindingUpdatedAtMs: retained.bindingUpdatedAtMs,
374
+ permit: permitResult.permit });
375
+ if (!recorded.recorded)
376
+ return { status: "outcome-unknown", entry: recorded.entry };
377
+ await input.deleteWithPermit(permitResult.permit, fresh);
378
+ const confirmed = input.store.confirmDeleted({ operationId: input.operationId,
379
+ bindingKey: input.bindingKey });
380
+ return { status: "deleted", entry: confirmed.entry };
381
+ });
382
+ }
383
+ export function createTelegramThreadCleanupPermitRuntime(deps) {
384
+ const now = deps.getNowMs ?? Date.now;
385
+ const findExactFence = (candidate, workSetId) => {
386
+ const operationId = `thread-cleanup-fence:${createHash("sha256").update(`${workSetId}\u0000${candidate.bindingKey}`, "utf8").digest("hex")}`;
387
+ const fence = deps.ledger.read().fence;
388
+ return fence?.operationId === operationId && fence.destructiveKind === "manual-thread-cleanup" &&
389
+ fence.profileKey === candidate.profileName && fence.retirementIntentId === workSetId && fence.bindingKey === candidate.bindingKey &&
390
+ fence.slot === candidate.slot && targetKey(fence.target) === targetKey(candidate.target)
391
+ ? fence : undefined;
392
+ };
393
+ const adoptExactFence = (candidate, workSetId) => {
394
+ let fence = findExactFence(candidate, workSetId);
395
+ const leaderEpoch = deps.getLeaderEpoch();
396
+ if (!fence || leaderEpoch === undefined)
397
+ return undefined;
398
+ const owner = deps.getOwner();
399
+ if (fence.leaderEpoch !== leaderEpoch || fence.owner.processId !== owner.processId ||
400
+ fence.owner.processBirthId !== owner.processBirthId) {
401
+ if (!deps.canAdoptFence(fence))
402
+ return undefined;
403
+ try {
404
+ fence = deps.ledger.adoptThreadCleanupFence(fence, { owner, leaderEpoch });
405
+ }
406
+ catch {
407
+ return undefined;
408
+ }
409
+ }
410
+ return deps.getLeaderEpoch() === leaderEpoch ? fence : undefined;
411
+ };
412
+ return {
413
+ diagnoseRecovery(candidate, workSetId) {
414
+ const fence = deps.ledger.read().fence;
415
+ if (!fence)
416
+ return "none";
417
+ const exact = findExactFence(candidate, workSetId);
418
+ if (!exact)
419
+ return "authority-blocked";
420
+ const owner = deps.getOwner();
421
+ const leaderEpoch = deps.getLeaderEpoch();
422
+ const owns = exact.owner.processId === owner.processId &&
423
+ exact.owner.processBirthId === owner.processBirthId && exact.leaderEpoch === leaderEpoch;
424
+ return owns || deps.canAdoptFence(exact) ? exact.phase : "authority-blocked";
425
+ },
426
+ findCommitReady(candidate, workSetId) {
427
+ const fence = adoptExactFence(candidate, workSetId);
428
+ return fence?.phase === "commit-ready" ? fence : undefined;
429
+ },
430
+ async acquire(candidate, workSetId) {
431
+ const leaderEpoch = deps.getLeaderEpoch();
432
+ if (leaderEpoch === undefined || candidate.profileName.length === 0 ||
433
+ candidate.profileName !== deps.getProfileName())
434
+ return { kind: "blocked" };
435
+ const operationId = `thread-cleanup-fence:${createHash("sha256")
436
+ .update(workSetId).update("\0").update(candidate.bindingKey).digest("hex")}`;
437
+ const existing = adoptExactFence(candidate, workSetId) ?? deps.ledger.read().fence;
438
+ if (existing && existing.operationId === operationId &&
439
+ existing.destructiveKind === "manual-thread-cleanup" &&
440
+ existing.retirementIntentId === workSetId && existing.bindingKey === candidate.bindingKey &&
441
+ existing.slot === candidate.slot && targetKey(existing.target) === targetKey(candidate.target) &&
442
+ existing.leaderEpoch === leaderEpoch && existing.phase !== "fenced") {
443
+ return { kind: "already-issued" };
444
+ }
445
+ let acquired;
446
+ try {
447
+ acquired = deps.ledger.acquireThreadCleanupFence({ operationId,
448
+ cleanupWorkSetId: workSetId, bindingKey: candidate.bindingKey, slot: candidate.slot,
449
+ target: candidate.target, leaderEpoch, cleanupRequestedAtMs: now() });
450
+ }
451
+ catch {
452
+ return { kind: "blocked" };
453
+ }
454
+ if (acquired.kind === "blocked")
455
+ return { kind: "blocked" };
456
+ if (deps.getLeaderEpoch() !== leaderEpoch || !await deps.revalidateUnderFence(candidate)) {
457
+ if (acquired.fence.phase === "fenced")
458
+ deps.ledger.releaseUnissuedThreadCleanupFence(acquired.fence);
459
+ return { kind: "blocked" };
460
+ }
461
+ const issued = deps.ledger.issueThreadCleanupDeletionPermit(acquired.fence);
462
+ return issued.kind === "issued"
463
+ ? { kind: "issued", fence: issued.fence, permit: issued.permit }
464
+ : { kind: "already-issued" };
465
+ },
466
+ async settleDeleted(fence, commit) {
467
+ const ready = deps.ledger.confirmThreadCleanupAbsence(fence);
468
+ if (!await commit())
469
+ return "commit-pending";
470
+ deps.ledger.completeThreadCleanupFence(ready);
471
+ return "completed";
472
+ },
473
+ };
474
+ }
475
+ export function createTelegramInactiveThreadCleanupSettingsPort(deps) {
476
+ return operationId => cleanReviewedInactiveThreads({ ...deps, operationId });
477
+ }
478
+ export async function cleanReviewedInactiveThreads(input) {
479
+ if (!/^thread-cleanup:[a-f0-9]{32}$/u.test(input.operationId))
480
+ throw new Error("Telegram Thread cleanup confirmation identity is invalid.");
481
+ const workSet = input.store.list().find(candidate => candidate.operationId === input.operationId);
482
+ if (!workSet)
483
+ throw new Error("Telegram Thread cleanup review is missing.");
484
+ let deleted = 0;
485
+ let outcomeUnknown = 0;
486
+ let blocked = 0;
487
+ for (const entry of workSet.entries) {
488
+ if (entry.state === "deleted") {
489
+ const ready = input.permitRuntime.findCommitReady(entry, input.operationId);
490
+ if (ready)
491
+ await input.permitRuntime.settleDeleted(ready, async () => true);
492
+ deleted += 1;
493
+ continue;
494
+ }
495
+ if (entry.state === "outcome-unknown") {
496
+ const ready = input.permitRuntime.findCommitReady(entry, input.operationId);
497
+ if (!ready) {
498
+ outcomeUnknown += 1;
499
+ continue;
500
+ }
501
+ const settlement = await input.permitRuntime.settleDeleted(ready, () => commitTelegramInactiveThreadCleanup({ store: input.store, operationId: input.operationId,
502
+ bindingKey: entry.bindingKey, commitBinding: () => input.commitBinding(entry) }));
503
+ if (settlement === "completed")
504
+ deleted += 1;
505
+ else
506
+ outcomeUnknown += 1;
507
+ continue;
508
+ }
509
+ const fullBinding = await input.resolveFullBinding(entry);
510
+ if (!fullBinding) {
511
+ blocked += 1;
512
+ continue;
513
+ }
514
+ const authority = await input.permitRuntime.acquire(entry, input.operationId);
515
+ if (authority.kind === "blocked") {
516
+ blocked += 1;
517
+ continue;
518
+ }
519
+ if (authority.kind === "already-issued") {
520
+ outcomeUnknown += 1;
521
+ continue;
522
+ }
523
+ try {
524
+ const recorded = input.store.recordDeletionIssued({ operationId: input.operationId,
525
+ bindingKey: entry.bindingKey, bindingUpdatedAtMs: entry.bindingUpdatedAtMs,
526
+ permit: authority.permit,
527
+ });
528
+ if (!recorded.recorded) {
529
+ if (recorded.entry.state === "deleted") {
530
+ const settlement = await input.permitRuntime.settleDeleted(authority.fence, async () => true);
531
+ if (settlement === "completed")
532
+ deleted += 1;
533
+ else
534
+ outcomeUnknown += 1;
535
+ }
536
+ else
537
+ outcomeUnknown += 1;
538
+ continue;
539
+ }
540
+ await input.deleteWithPermit(authority.permit, entry);
541
+ const settlement = await input.permitRuntime.settleDeleted(authority.fence, () => commitTelegramInactiveThreadCleanup({ store: input.store, operationId: input.operationId,
542
+ bindingKey: entry.bindingKey, commitBinding: () => input.commitBinding(entry, fullBinding) }));
543
+ if (settlement === "completed")
544
+ deleted += 1;
545
+ else
546
+ outcomeUnknown += 1;
547
+ }
548
+ catch {
549
+ outcomeUnknown += 1;
550
+ }
551
+ }
552
+ const retainedEntries = input.store.list().find(candidate => candidate.operationId === input.operationId)?.entries ?? [];
553
+ if (outcomeUnknown > 0) {
554
+ const diagnosis = retainedEntries.map(entry => input.permitRuntime.diagnoseRecovery(entry, input.operationId)).find(value => value !== "none") ?? "none";
555
+ const recovery = diagnosis === "commit-ready" ? "commit-ready"
556
+ : diagnosis === "deletion-issued" ? "deletion-outcome-unknown" : "authority-blocked";
557
+ return { deleted, outcomeUnknown, blocked, recovery };
558
+ }
559
+ return { deleted, outcomeUnknown, blocked };
560
+ }
@@ -0,0 +1,46 @@
1
+ import type { TelegramThreadDisplayMode } from "./config.ts";
2
+ import type { TelegramTopicTargetStore, TelegramWorkspaceDisplayBinding, TelegramWorkspaceThreadBinding } from "./threads.ts";
3
+ import type { TelegramApiCallOptions } from "./telegram-api.ts";
4
+ /** Pure directory tokenization shared by previews, initial titles, and reconciliation. */
5
+ export declare function tokenizeTelegramDirectorySegment(segment: string): string[];
6
+ /** Maps one leader-captured authenticated owner roster onto retained binding identities. */
7
+ export declare function resolveTelegramLiveWorkspaceBindingKeys(bindings: readonly TelegramWorkspaceThreadBinding[], leaderTarget: {
8
+ chatId: number;
9
+ threadId?: number;
10
+ } | undefined, followers: readonly {
11
+ target?: {
12
+ chatId: number;
13
+ threadId?: number;
14
+ };
15
+ }[]): ReadonlySet<string>;
16
+ /** Missing or ambiguous metadata yields no label rather than inventing identity. */
17
+ export declare function resolveTelegramWorkspaceDisplayNames(bindings: readonly TelegramWorkspaceDisplayBinding[], mode: TelegramThreadDisplayMode, liveBindingKeys?: ReadonlySet<string>): Map<string, string>;
18
+ export declare function resolveTelegramInitialWorkspaceDisplayName(input: {
19
+ bindings: readonly TelegramWorkspaceDisplayBinding[];
20
+ binding: TelegramWorkspaceDisplayBinding;
21
+ mode: TelegramThreadDisplayMode;
22
+ preserveRetainedManualName?: boolean;
23
+ liveBindingKeys?: ReadonlySet<string>;
24
+ }): string | undefined;
25
+ export declare function applyTelegramThreadDisplaySetting(mode: TelegramThreadDisplayMode, deps: {
26
+ getProfileKey(): string | undefined;
27
+ ownsLeader(): boolean;
28
+ getLeaderSetter(): ((mode: TelegramThreadDisplayMode) => Promise<void>) | undefined;
29
+ getFollowerSetter(): ((mode: TelegramThreadDisplayMode) => Promise<void>) | undefined;
30
+ reloadConfig(): Promise<void>;
31
+ }): Promise<void>;
32
+ export interface TelegramThreadDisplayReconcilerDeps {
33
+ store: Pick<TelegramTopicTargetStore, "listWorkspaceBindings" | "setWorkspaceDisplayTitle" | "persist">;
34
+ getMode(): TelegramThreadDisplayMode;
35
+ getProfileKey(): string;
36
+ getLeaderEpoch(): string | number | undefined;
37
+ captureBindingAuthority(binding: TelegramWorkspaceThreadBinding): (() => boolean) | undefined;
38
+ captureLiveBindingKeys(bindings: readonly TelegramWorkspaceThreadBinding[]): ReadonlySet<string>;
39
+ callApi<TResponse>(method: string, body: Record<string, unknown>, options?: TelegramApiCallOptions): Promise<TResponse>;
40
+ }
41
+ /** Caller owns triggering and reporting; no timer or background retry is created. */
42
+ export declare function createTelegramThreadDisplayReconciler(deps: TelegramThreadDisplayReconcilerDeps): {
43
+ reconcile(): Promise<{
44
+ changed: number;
45
+ }>;
46
+ };