@llblab/pi-kit 0.6.0 → 0.7.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 (123) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +4 -4
  3. package/node_modules/@llblab/pi-actors/AGENTS.md +1 -1
  4. package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
  5. package/node_modules/@llblab/pi-actors/README.md +1 -1
  6. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +3 -0
  7. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +14 -1
  8. package/node_modules/@llblab/pi-actors/dist/lib/command-templates.js +45 -3
  9. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +1 -1
  10. package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +16 -3
  11. package/node_modules/@llblab/pi-actors/dist/lib/observability.js +92 -7
  12. package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +0 -1
  13. package/node_modules/@llblab/pi-actors/dist/lib/pi.js +15 -24
  14. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.d.ts +17 -0
  15. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.js +44 -0
  16. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +4 -0
  17. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +102 -4
  18. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +58 -39
  19. package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +14 -6
  20. package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +1 -1
  21. package/node_modules/@llblab/pi-actors/docs/async-runs.md +1 -1
  22. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +18 -23
  23. package/node_modules/@llblab/pi-actors/lib/async-runs.ts +18 -1
  24. package/node_modules/@llblab/pi-actors/lib/command-templates.ts +41 -3
  25. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +1 -1
  26. package/node_modules/@llblab/pi-actors/lib/observability.ts +119 -5
  27. package/node_modules/@llblab/pi-actors/lib/pi.ts +15 -28
  28. package/node_modules/@llblab/pi-actors/lib/run-delivery-lineage.ts +68 -0
  29. package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +120 -4
  30. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +69 -44
  31. package/node_modules/@llblab/pi-actors/lib/runtime.ts +17 -6
  32. package/node_modules/@llblab/pi-actors/package.json +1 -1
  33. package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +1 -1
  34. package/node_modules/@llblab/pi-state-flow/AGENTS.md +33 -12
  35. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +125 -2
  36. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +87 -45
  37. package/node_modules/@llblab/pi-state-flow/README.md +203 -107
  38. package/node_modules/@llblab/pi-state-flow/docs/README.md +4 -0
  39. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +188 -0
  40. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +42 -0
  41. package/node_modules/@llblab/pi-state-flow/index.ts +164 -2
  42. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +138 -0
  43. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +273 -0
  44. package/node_modules/@llblab/pi-state-flow/lib/config.ts +48 -0
  45. package/node_modules/@llblab/pi-state-flow/lib/context.ts +18 -5
  46. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +268 -0
  47. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +117 -0
  48. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +562 -0
  49. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +24 -12
  50. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +606 -70
  51. package/node_modules/@llblab/pi-state-flow/lib/git.ts +666 -0
  52. package/node_modules/@llblab/pi-state-flow/lib/history.ts +95 -0
  53. package/node_modules/@llblab/pi-state-flow/lib/json.ts +24 -0
  54. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +141 -0
  55. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +52 -0
  56. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +88 -0
  57. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +296 -0
  58. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +23 -7
  59. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +79 -0
  60. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +264 -0
  61. package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -0
  62. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +99 -7
  63. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +305 -48
  64. package/node_modules/@llblab/pi-state-flow/lib/state.ts +74 -7
  65. package/node_modules/@llblab/pi-state-flow/lib/status.ts +125 -6
  66. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +196 -0
  67. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +233 -0
  68. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +70 -24
  69. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +254 -29
  70. package/node_modules/@llblab/pi-state-flow/package.json +8 -2
  71. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +128 -0
  72. package/node_modules/@llblab/pi-telegram/AGENTS.md +13 -8
  73. package/node_modules/@llblab/pi-telegram/BACKLOG.md +20 -3
  74. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +13 -0
  75. package/node_modules/@llblab/pi-telegram/README.md +12 -8
  76. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -0
  77. package/node_modules/@llblab/pi-telegram/docs/architecture.md +220 -18
  78. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
  79. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +70 -19
  80. package/node_modules/@llblab/pi-telegram/docs/outbound.md +6 -6
  81. package/node_modules/@llblab/pi-telegram/docs/public-api.md +12 -5
  82. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +3 -1
  83. package/node_modules/@llblab/pi-telegram/index.ts +4 -1418
  84. package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +6 -3
  85. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +28 -1
  86. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +600 -135
  87. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +962 -55
  88. package/node_modules/@llblab/pi-telegram/lib/bus.ts +350 -26
  89. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +544 -0
  90. package/node_modules/@llblab/pi-telegram/lib/commands.ts +234 -11
  91. package/node_modules/@llblab/pi-telegram/lib/config.ts +177 -24
  92. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1792 -0
  93. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +20 -2
  94. package/node_modules/@llblab/pi-telegram/lib/journal.ts +2184 -126
  95. package/node_modules/@llblab/pi-telegram/lib/locks.ts +38 -1
  96. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +152 -13
  97. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +51 -10
  98. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -0
  99. package/node_modules/@llblab/pi-telegram/lib/polling.ts +85 -17
  100. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +5 -2
  101. package/node_modules/@llblab/pi-telegram/lib/queue.ts +52 -18
  102. package/node_modules/@llblab/pi-telegram/lib/replies.ts +2 -2
  103. package/node_modules/@llblab/pi-telegram/lib/routing.ts +305 -112
  104. package/node_modules/@llblab/pi-telegram/lib/status.ts +10 -0
  105. package/node_modules/@llblab/pi-telegram/lib/sync.ts +308 -39
  106. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +279 -4
  107. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +664 -0
  108. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +226 -0
  109. package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +118 -0
  110. package/node_modules/@llblab/pi-telegram/lib/threads.ts +1686 -129
  111. package/node_modules/@llblab/pi-telegram/lib/updates.ts +1319 -97
  112. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +1643 -0
  113. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +968 -0
  114. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +84 -0
  115. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  116. package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
  117. package/node_modules/@llblab/pi-telegram/scripts/measure-bus.mjs +83 -0
  118. package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +101 -0
  119. package/node_modules/@llblab/{skills → pi-telegram/skills}/show-me/SKILL.md +28 -6
  120. package/node_modules/@llblab/pi-telegram/skills/show-me/references/telegram-surfaces.md +43 -0
  121. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +1 -1
  122. package/node_modules/@llblab/skills/package.json +2 -3
  123. package/package.json +6 -5
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Durable journal for Telegram channel posts authored by this agent path
3
+ * Zones: telegram outbound, filesystem authority
4
+ * Owns publication intent, outcome-unknown fencing, confirmed post identity, and bounded local listing
5
+ */
6
+
7
+ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync,
8
+ readFileSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { dirname } from "node:path";
10
+ import { randomUUID } from "node:crypto";
11
+
12
+ import { Type } from "@sinclair/typebox";
13
+
14
+ import { renameTelegramPathWithRetry, withTelegramFileTransaction } from "./locks.ts";
15
+ import type { ExtensionAPI } from "./pi.ts";
16
+
17
+ const CHANNEL_POST_JOURNAL_VERSION = 1;
18
+ const DEFAULT_MAX_RECORDS = 256;
19
+ const DEFAULT_MAX_BYTES = 4 * 1024 * 1024;
20
+ const MAX_ID_LENGTH = 256;
21
+ const MAX_MARKDOWN_LENGTH = 100_000;
22
+ const MAX_CHANNEL_TITLE_LENGTH = 255;
23
+
24
+ type ChannelPostJournalCode = "invalid" | "conflict" | "capacity" | "io";
25
+
26
+ export class TelegramChannelPostJournalError extends Error {
27
+ readonly code: ChannelPostJournalCode;
28
+
29
+ constructor(code: ChannelPostJournalCode, message: string, cause?: unknown) {
30
+ super(message, { cause });
31
+ this.name = "TelegramChannelPostJournalError";
32
+ this.code = code;
33
+ }
34
+ }
35
+
36
+ export type TelegramChannelPostAddress = number | `@${string}`;
37
+
38
+ interface TelegramChannelPostRecordBase {
39
+ operationId: string;
40
+ requestedChannel: TelegramChannelPostAddress;
41
+ markdown: string;
42
+ createdAtMs: number;
43
+ updatedAtMs: number;
44
+ }
45
+
46
+ interface TelegramPublishedChannelPostIdentity {
47
+ issuedAtMs: number;
48
+ publishedAtMs: number;
49
+ channelId: number;
50
+ messageId: number;
51
+ channelUsername?: `@${string}`;
52
+ channelTitle?: string;
53
+ lastMutationId?: string;
54
+ }
55
+
56
+ export type TelegramChannelPostRecord = TelegramChannelPostRecordBase & (
57
+ | { state: "prepared" }
58
+ | { state: "outcome-unknown"; issuedAtMs: number }
59
+ | ({ state: "published" } & TelegramPublishedChannelPostIdentity)
60
+ | ({ state: "edit-outcome-unknown"; mutationId: string; attemptedMarkdown: string;
61
+ mutationIssuedAtMs: number } & TelegramPublishedChannelPostIdentity)
62
+ | ({ state: "delete-outcome-unknown"; mutationId: string;
63
+ mutationIssuedAtMs: number } & TelegramPublishedChannelPostIdentity)
64
+ | ({ state: "deleted"; mutationId: string; deletedAtMs: number } &
65
+ TelegramPublishedChannelPostIdentity)
66
+ );
67
+
68
+ interface TelegramChannelPostJournalFile {
69
+ version: typeof CHANNEL_POST_JOURNAL_VERSION;
70
+ profile: string;
71
+ tokenSha256: string;
72
+ records: TelegramChannelPostRecord[];
73
+ }
74
+
75
+ export interface TelegramChannelPostJournalStoreOptions {
76
+ path: string;
77
+ profileName: string;
78
+ tokenSha256: string;
79
+ maxRecords?: number;
80
+ maxBytes?: number;
81
+ getNowMs?: () => number;
82
+ }
83
+
84
+ export interface TelegramChannelPostJournalStore {
85
+ prepare(input: { operationId: string; channel: TelegramChannelPostAddress; markdown: string }):
86
+ { prepared: boolean; record: TelegramChannelPostRecord };
87
+ beginPublication(operationId: string): { began: boolean; record: TelegramChannelPostRecord };
88
+ confirmPublished(input: { operationId: string; channelId: number; messageId: number;
89
+ channelUsername?: `@${string}`; channelTitle?: string }):
90
+ { confirmed: boolean; record: TelegramChannelPostRecord };
91
+ beginEdit(input: { operationId: string; mutationId: string; markdown: string }):
92
+ { began: boolean; record: TelegramChannelPostRecord };
93
+ confirmEdited(input: { operationId: string; mutationId: string }):
94
+ { confirmed: boolean; record: TelegramChannelPostRecord };
95
+ beginDelete(input: { operationId: string; mutationId: string }):
96
+ { began: boolean; record: TelegramChannelPostRecord };
97
+ confirmDeleted(input: { operationId: string; mutationId: string }):
98
+ { confirmed: boolean; record: TelegramChannelPostRecord };
99
+ list(input?: { channel?: TelegramChannelPostAddress; limit?: number }): TelegramChannelPostRecord[];
100
+ }
101
+
102
+ export async function publishTelegramChannelPost(input: {
103
+ store: TelegramChannelPostJournalStore;
104
+ operationId: string;
105
+ channel: TelegramChannelPostAddress;
106
+ markdown: string;
107
+ observeChannel(channel: TelegramChannelPostAddress): Promise<{
108
+ id: number; type: string; username?: string; title?: string;
109
+ }>;
110
+ send(channel: TelegramChannelPostAddress, markdown: string): Promise<{
111
+ messageId: number; chat: { id: number; type: string };
112
+ }>;
113
+ }): Promise<TelegramChannelPostRecord> {
114
+ const observed = await input.observeChannel(input.channel);
115
+ if (observed.type !== "channel" || !Number.isSafeInteger(observed.id) || observed.id >= 0 ||
116
+ (typeof input.channel === "number" && observed.id !== input.channel) ||
117
+ (observed.username !== undefined && !/^[A-Za-z0-9_]{5,32}$/u.test(observed.username)) ||
118
+ (observed.title !== undefined && (observed.title.length === 0 ||
119
+ observed.title.length > MAX_CHANNEL_TITLE_LENGTH))) {
120
+ throw new Error("Telegram channel delivery requires bounded exact getChat channel identity.");
121
+ }
122
+ input.store.prepare({ operationId: input.operationId, channel: input.channel, markdown: input.markdown });
123
+ const issuance = input.store.beginPublication(input.operationId);
124
+ if (!issuance.began) {
125
+ if (issuance.record.state === "published") return issuance.record;
126
+ throw new Error("Telegram channel post outcome is unknown; refusing automatic replay.");
127
+ }
128
+ const sent = await input.send(input.channel, input.markdown);
129
+ if (sent.chat.type !== "channel" || sent.chat.id !== observed.id) {
130
+ throw new Error("Telegram channel post response identity did not match the verified channel.");
131
+ }
132
+ return input.store.confirmPublished({ operationId: input.operationId,
133
+ channelId: sent.chat.id, messageId: sent.messageId,
134
+ ...(observed.username ? { channelUsername: `@${observed.username}` as `@${string}` } : {}),
135
+ ...(observed.title ? { channelTitle: observed.title } : {}) }).record;
136
+ }
137
+
138
+ export function registerTelegramChannelPostMutationTool(
139
+ pi: ExtensionAPI,
140
+ deps: { mutate(input: { action: "edit" | "delete"; operationId: string;
141
+ mutationId: string; markdown?: string }): Promise<TelegramChannelPostRecord> },
142
+ ): void {
143
+ pi.registerTool({
144
+ name: "telegram_channel_post",
145
+ label: "Edit or Delete Telegram Channel Post",
146
+ description: "Edit or delete one exact published post retained by telegram_channel_posts. Unknown outcomes are never replayed.",
147
+ parameters: Type.Object({
148
+ action: Type.Union([Type.Literal("edit"), Type.Literal("delete")]),
149
+ operation_id: Type.String({ minLength: 1, maxLength: MAX_ID_LENGTH }),
150
+ markdown: Type.Optional(Type.String({ minLength: 1, maxLength: MAX_MARKDOWN_LENGTH })),
151
+ }),
152
+ async execute(toolCallId, params) {
153
+ try {
154
+ const record = await deps.mutate({ action: params.action, operationId: params.operation_id,
155
+ mutationId: toolCallId, ...(params.markdown === undefined ? {} : { markdown: params.markdown }) });
156
+ return { content: [{ type: "text" as const, text: JSON.stringify(record, null, 2) }],
157
+ details: { record } };
158
+ } catch {
159
+ throw new Error("Telegram channel post mutation failed; inspect the retained local record before retrying.");
160
+ }
161
+ },
162
+ });
163
+ }
164
+
165
+ export function registerTelegramChannelPostListTool(
166
+ pi: ExtensionAPI,
167
+ deps: { list: TelegramChannelPostJournalStore["list"] },
168
+ ): void {
169
+ pi.registerTool({
170
+ name: "telegram_channel_posts",
171
+ label: "Telegram Channel Posts",
172
+ description: "List bounded local records for channel posts authored by this agent path. This does not read Telegram channel history.",
173
+ parameters: Type.Object({
174
+ chat_id: Type.Optional(Type.Union([
175
+ Type.Number(), Type.String({ pattern: "^@[A-Za-z0-9_]{5,32}$" }),
176
+ ])),
177
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_RECORDS })),
178
+ }),
179
+ async execute(_toolCallId, params) {
180
+ try {
181
+ const records = deps.list({
182
+ channel: params.chat_id as TelegramChannelPostAddress | undefined,
183
+ limit: params.limit,
184
+ });
185
+ return { content: [{ type: "text" as const, text: JSON.stringify(records, null, 2) }],
186
+ details: { records } };
187
+ } catch {
188
+ throw new Error("Telegram channel post listing failed without exposing retained content or storage details.");
189
+ }
190
+ },
191
+ });
192
+ }
193
+
194
+ function isRecord(value: unknown): value is Record<string, unknown> {
195
+ return !!value && typeof value === "object" && !Array.isArray(value);
196
+ }
197
+
198
+ function hasOnlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
199
+ const allowed = new Set(keys);
200
+ return Object.keys(value).every(key => allowed.has(key));
201
+ }
202
+
203
+ function isSafeTime(value: unknown): value is number {
204
+ return Number.isSafeInteger(value) && (value as number) >= 0;
205
+ }
206
+
207
+ function normalizeChannel(value: unknown): TelegramChannelPostAddress {
208
+ if (Number.isSafeInteger(value) && (value as number) < 0) return value as number;
209
+ if (typeof value === "string" && /^@[A-Za-z0-9_]{5,32}$/u.test(value)) {
210
+ return value as `@${string}`;
211
+ }
212
+ throw new TelegramChannelPostJournalError("invalid", "Telegram channel post requires an exact negative channel ID or public @username.");
213
+ }
214
+
215
+ function validateRecord(value: unknown): TelegramChannelPostRecord {
216
+ if (!isRecord(value) || !hasOnlyKeys(value, ["operationId", "requestedChannel", "markdown",
217
+ "createdAtMs", "updatedAtMs", "state", "issuedAtMs", "publishedAtMs", "channelId",
218
+ "messageId", "channelUsername", "mutationId", "attemptedMarkdown",
219
+ "mutationIssuedAtMs", "deletedAtMs", "lastMutationId", "channelTitle"]) || typeof value.operationId !== "string" ||
220
+ value.operationId.length === 0 || value.operationId.length > MAX_ID_LENGTH ||
221
+ typeof value.markdown !== "string" || value.markdown.length === 0 ||
222
+ value.markdown.length > MAX_MARKDOWN_LENGTH || !isSafeTime(value.createdAtMs) ||
223
+ !isSafeTime(value.updatedAtMs) || value.updatedAtMs < value.createdAtMs) {
224
+ throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains an invalid record.");
225
+ }
226
+ const base: TelegramChannelPostRecordBase = { operationId: value.operationId,
227
+ requestedChannel: normalizeChannel(value.requestedChannel), markdown: value.markdown,
228
+ createdAtMs: value.createdAtMs, updatedAtMs: value.updatedAtMs };
229
+ if (value.state === "prepared" && value.issuedAtMs === undefined && value.publishedAtMs === undefined &&
230
+ value.channelId === undefined && value.messageId === undefined && value.channelUsername === undefined &&
231
+ value.mutationId === undefined && value.attemptedMarkdown === undefined &&
232
+ value.mutationIssuedAtMs === undefined && value.deletedAtMs === undefined &&
233
+ value.lastMutationId === undefined && value.channelTitle === undefined) {
234
+ return { ...base, state: "prepared" };
235
+ }
236
+ if (!isSafeTime(value.issuedAtMs) || value.issuedAtMs < value.createdAtMs) {
237
+ throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains invalid issuance evidence.");
238
+ }
239
+ if (value.state === "outcome-unknown" && value.publishedAtMs === undefined && value.channelId === undefined &&
240
+ value.messageId === undefined && value.channelUsername === undefined && value.mutationId === undefined &&
241
+ value.attemptedMarkdown === undefined && value.mutationIssuedAtMs === undefined &&
242
+ value.deletedAtMs === undefined && value.lastMutationId === undefined &&
243
+ value.channelTitle === undefined) {
244
+ return { ...base, state: "outcome-unknown", issuedAtMs: value.issuedAtMs };
245
+ }
246
+ if (!isSafeTime(value.publishedAtMs) || value.publishedAtMs < value.issuedAtMs ||
247
+ !Number.isSafeInteger(value.channelId) || (value.channelId as number) >= 0 ||
248
+ !Number.isSafeInteger(value.messageId) || (value.messageId as number) <= 0 ||
249
+ (value.channelUsername !== undefined && (typeof value.channelUsername !== "string" ||
250
+ !/^@[A-Za-z0-9_]{5,32}$/u.test(value.channelUsername))) ||
251
+ (value.channelTitle !== undefined && (typeof value.channelTitle !== "string" ||
252
+ value.channelTitle.length === 0 || value.channelTitle.length > MAX_CHANNEL_TITLE_LENGTH))) {
253
+ throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains invalid published identity.");
254
+ }
255
+ const identity: TelegramPublishedChannelPostIdentity = { issuedAtMs: value.issuedAtMs,
256
+ publishedAtMs: value.publishedAtMs, channelId: value.channelId as number,
257
+ messageId: value.messageId as number,
258
+ ...(value.channelUsername ? { channelUsername: value.channelUsername as `@${string}` } : {}),
259
+ ...(typeof value.channelTitle === "string" ? { channelTitle: value.channelTitle } : {}),
260
+ ...(typeof value.lastMutationId === "string" && value.lastMutationId.length > 0 &&
261
+ value.lastMutationId.length <= MAX_ID_LENGTH ? { lastMutationId: value.lastMutationId } : {}) };
262
+ if (value.lastMutationId !== undefined && identity.lastMutationId === undefined) {
263
+ throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains invalid mutation identity.");
264
+ }
265
+ if (value.state === "published" && value.mutationId === undefined &&
266
+ value.attemptedMarkdown === undefined && value.mutationIssuedAtMs === undefined &&
267
+ value.deletedAtMs === undefined) return { ...base, ...identity, state: "published" };
268
+ const validMutation = typeof value.mutationId === "string" && value.mutationId.length > 0 &&
269
+ value.mutationId.length <= MAX_ID_LENGTH && isSafeTime(value.mutationIssuedAtMs) &&
270
+ value.mutationIssuedAtMs >= value.publishedAtMs &&
271
+ value.mutationIssuedAtMs === value.updatedAtMs;
272
+ if (value.state === "edit-outcome-unknown" && validMutation &&
273
+ typeof value.attemptedMarkdown === "string" && value.attemptedMarkdown.length > 0 &&
274
+ value.attemptedMarkdown.length <= MAX_MARKDOWN_LENGTH && value.deletedAtMs === undefined) {
275
+ return { ...base, ...identity, state: "edit-outcome-unknown", mutationId: value.mutationId as string,
276
+ attemptedMarkdown: value.attemptedMarkdown, mutationIssuedAtMs: value.mutationIssuedAtMs as number };
277
+ }
278
+ if (value.state === "delete-outcome-unknown" && validMutation &&
279
+ value.attemptedMarkdown === undefined && value.deletedAtMs === undefined) {
280
+ return { ...base, ...identity, state: "delete-outcome-unknown", mutationId: value.mutationId as string,
281
+ mutationIssuedAtMs: value.mutationIssuedAtMs as number };
282
+ }
283
+ if (value.state === "deleted" && typeof value.mutationId === "string" &&
284
+ value.mutationId.length > 0 && value.mutationId.length <= MAX_ID_LENGTH &&
285
+ isSafeTime(value.deletedAtMs) && value.deletedAtMs >= value.publishedAtMs &&
286
+ value.deletedAtMs === value.updatedAtMs &&
287
+ value.mutationIssuedAtMs === undefined && value.attemptedMarkdown === undefined) {
288
+ return { ...base, ...identity, state: "deleted", mutationId: value.mutationId,
289
+ deletedAtMs: value.deletedAtMs };
290
+ }
291
+ throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains an invalid state.");
292
+ }
293
+
294
+ export function createTelegramChannelPostJournalStore(
295
+ options: TelegramChannelPostJournalStoreOptions,
296
+ ): TelegramChannelPostJournalStore {
297
+ const maxRecords = options.maxRecords ?? DEFAULT_MAX_RECORDS;
298
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
299
+ const now = options.getNowMs ?? Date.now;
300
+ if (!options.path || !options.profileName || !/^[a-f0-9]{64}$/u.test(options.tokenSha256) ||
301
+ !Number.isSafeInteger(maxRecords) || maxRecords <= 0 || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
302
+ throw new Error("Telegram channel post journal options are invalid.");
303
+ }
304
+ const empty = (): TelegramChannelPostJournalFile => ({ version: CHANNEL_POST_JOURNAL_VERSION,
305
+ profile: options.profileName, tokenSha256: options.tokenSha256, records: [] });
306
+ const read = (): TelegramChannelPostJournalFile => {
307
+ let before;
308
+ try {
309
+ before = lstatSync(options.path, { bigint: true });
310
+ } catch (error) {
311
+ if ((error as { code?: unknown })?.code === "ENOENT") return empty();
312
+ throw new TelegramChannelPostJournalError("io", "Could not inspect Telegram channel post journal.", error);
313
+ }
314
+ const uid = process.getuid?.();
315
+ if (!constants.O_NOFOLLOW || !constants.O_NONBLOCK || uid === undefined || !before.isFile() ||
316
+ before.isSymbolicLink() || before.uid !== BigInt(uid) || before.nlink !== 1n ||
317
+ (before.mode & 0o077n) !== 0n || before.size > BigInt(maxBytes)) {
318
+ throw new TelegramChannelPostJournalError(before.size > BigInt(maxBytes) ? "capacity" : "invalid",
319
+ "Telegram channel post journal is not a bounded no-follow regular file.");
320
+ }
321
+ let fd: number | undefined;
322
+ try {
323
+ fd = openSync(options.path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
324
+ const opened = fstatSync(fd, { bigint: true });
325
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino ||
326
+ opened.uid !== before.uid || opened.nlink !== 1n || (opened.mode & 0o077n) !== 0n ||
327
+ opened.size !== before.size || opened.mtimeNs !== before.mtimeNs) {
328
+ throw new TelegramChannelPostJournalError("conflict", "Telegram channel post journal changed during inspection.");
329
+ }
330
+ const value = JSON.parse(readFileSync(fd, "utf8")) as unknown;
331
+ if (!isRecord(value) || !hasOnlyKeys(value, ["version", "profile", "tokenSha256", "records"]) ||
332
+ value.version !== CHANNEL_POST_JOURNAL_VERSION || value.profile !== options.profileName ||
333
+ value.tokenSha256 !== options.tokenSha256 || !Array.isArray(value.records)) {
334
+ throw new TelegramChannelPostJournalError("conflict", "Telegram channel post journal identity or schema does not match.");
335
+ }
336
+ const records = value.records.map(validateRecord);
337
+ if (new Set(records.map(record => record.operationId)).size !== records.length) {
338
+ throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains duplicate operation IDs.");
339
+ }
340
+ return { version: CHANNEL_POST_JOURNAL_VERSION, profile: options.profileName,
341
+ tokenSha256: options.tokenSha256, records };
342
+ } catch (error) {
343
+ if (error instanceof TelegramChannelPostJournalError) throw error;
344
+ throw new TelegramChannelPostJournalError("io", "Could not read Telegram channel post journal.", error);
345
+ } finally {
346
+ if (fd !== undefined) closeSync(fd);
347
+ }
348
+ };
349
+ const publish = (file: TelegramChannelPostJournalFile): void => {
350
+ if (file.records.length > maxRecords) {
351
+ throw new TelegramChannelPostJournalError("capacity", "Telegram channel post journal record limit reached.");
352
+ }
353
+ const serialized = `${JSON.stringify(file, null, 2)}\n`;
354
+ if (Buffer.byteLength(serialized) > maxBytes) {
355
+ throw new TelegramChannelPostJournalError("capacity", "Telegram channel post journal byte limit reached.");
356
+ }
357
+ const temporaryPath = `${options.path}.${process.pid}.${randomUUID()}.tmp`;
358
+ mkdirSync(dirname(options.path), { recursive: true, mode: 0o700 });
359
+ try {
360
+ writeFileSync(temporaryPath, serialized, { encoding: "utf8", mode: 0o600 });
361
+ chmodSync(temporaryPath, 0o600);
362
+ if (!renameTelegramPathWithRetry(temporaryPath, options.path)) {
363
+ throw new Error("Telegram channel post journal staging file disappeared before publication.");
364
+ }
365
+ chmodSync(options.path, 0o600);
366
+ } finally {
367
+ try { unlinkSync(temporaryPath); } catch { /* Atomic rename consumes the temporary path. */ }
368
+ }
369
+ };
370
+ const mutate = <T>(operation: (file: TelegramChannelPostJournalFile) => T): T => {
371
+ try {
372
+ return withTelegramFileTransaction(`${options.path}.transaction`, () => operation(read()));
373
+ } catch (error) {
374
+ if (error instanceof TelegramChannelPostJournalError) throw error;
375
+ throw new TelegramChannelPostJournalError("io", "Telegram channel post journal mutation failed.", error);
376
+ }
377
+ };
378
+ return {
379
+ prepare(input) {
380
+ const operationId = input.operationId;
381
+ const channel = normalizeChannel(input.channel);
382
+ if (typeof operationId !== "string" || operationId.length === 0 || operationId.length > MAX_ID_LENGTH ||
383
+ typeof input.markdown !== "string" || input.markdown.length === 0 ||
384
+ input.markdown.length > MAX_MARKDOWN_LENGTH) {
385
+ throw new TelegramChannelPostJournalError("invalid", "Telegram channel post intent is invalid.");
386
+ }
387
+ return mutate(file => {
388
+ const existing = file.records.find(record => record.operationId === operationId);
389
+ if (existing) {
390
+ if (existing.requestedChannel !== channel || existing.markdown !== input.markdown) {
391
+ throw new TelegramChannelPostJournalError("conflict", "Telegram channel post operation conflicts with retained intent.");
392
+ }
393
+ return { prepared: false, record: structuredClone(existing) };
394
+ }
395
+ const atMs = now();
396
+ if (!isSafeTime(atMs)) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
397
+ const record: TelegramChannelPostRecord = { operationId, requestedChannel: channel,
398
+ markdown: input.markdown, createdAtMs: atMs, updatedAtMs: atMs, state: "prepared" };
399
+ publish({ ...file, records: [...file.records, record] });
400
+ return { prepared: true, record: structuredClone(record) };
401
+ });
402
+ },
403
+ beginPublication(operationId) {
404
+ return mutate(file => {
405
+ const index = file.records.findIndex(record => record.operationId === operationId);
406
+ if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post intent is missing.");
407
+ const current = file.records[index]!;
408
+ if (current.state !== "prepared") return { began: false, record: structuredClone(current) };
409
+ const atMs = now();
410
+ if (!isSafeTime(atMs) || atMs < current.createdAtMs) {
411
+ throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
412
+ }
413
+ const record: TelegramChannelPostRecord = { ...current, state: "outcome-unknown",
414
+ issuedAtMs: atMs, updatedAtMs: atMs };
415
+ const records = [...file.records]; records[index] = record; publish({ ...file, records });
416
+ return { began: true, record: structuredClone(record) };
417
+ });
418
+ },
419
+ confirmPublished(input) {
420
+ return mutate(file => {
421
+ const index = file.records.findIndex(record => record.operationId === input.operationId);
422
+ if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post intent is missing.");
423
+ const current = file.records[index]!;
424
+ if (current.state === "published") {
425
+ if (current.channelId !== input.channelId || current.messageId !== input.messageId ||
426
+ current.channelUsername !== input.channelUsername ||
427
+ current.channelTitle !== input.channelTitle) {
428
+ throw new TelegramChannelPostJournalError("conflict", "Telegram channel post confirmation conflicts with retained identity.");
429
+ }
430
+ return { confirmed: false, record: structuredClone(current) };
431
+ }
432
+ if (current.state !== "outcome-unknown" || !Number.isSafeInteger(input.channelId) || input.channelId >= 0 ||
433
+ !Number.isSafeInteger(input.messageId) || input.messageId <= 0 ||
434
+ (input.channelUsername !== undefined && !/^@[A-Za-z0-9_]{5,32}$/u.test(input.channelUsername)) ||
435
+ (input.channelTitle !== undefined && (input.channelTitle.length === 0 ||
436
+ input.channelTitle.length > MAX_CHANNEL_TITLE_LENGTH))) {
437
+ throw new TelegramChannelPostJournalError("conflict", "Telegram channel post confirmation is invalid or premature.");
438
+ }
439
+ const atMs = now();
440
+ if (!isSafeTime(atMs) || atMs < current.issuedAtMs) {
441
+ throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
442
+ }
443
+ const record: TelegramChannelPostRecord = { ...current, state: "published",
444
+ publishedAtMs: atMs, updatedAtMs: atMs, channelId: input.channelId,
445
+ messageId: input.messageId, ...(input.channelUsername ? { channelUsername: input.channelUsername } : {}),
446
+ ...(input.channelTitle ? { channelTitle: input.channelTitle } : {}) };
447
+ const records = [...file.records]; records[index] = record; publish({ ...file, records });
448
+ return { confirmed: true, record: structuredClone(record) };
449
+ });
450
+ },
451
+ beginEdit(input) {
452
+ return mutate(file => {
453
+ const index = file.records.findIndex(record => record.operationId === input.operationId);
454
+ if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
455
+ const current = file.records[index]!;
456
+ if (current.state === "edit-outcome-unknown") {
457
+ if (current.mutationId === input.mutationId && current.attemptedMarkdown === input.markdown)
458
+ return { began: false, record: structuredClone(current) };
459
+ throw new TelegramChannelPostJournalError("conflict", "Telegram channel post already has an unresolved mutation.");
460
+ }
461
+ if (current.state === "published" && current.lastMutationId === input.mutationId) {
462
+ if (current.markdown === input.markdown) return { began: false, record: structuredClone(current) };
463
+ throw new TelegramChannelPostJournalError("conflict", "Telegram channel post mutation identity conflicts with retained edit.");
464
+ }
465
+ if (current.state !== "published" || !input.mutationId || input.mutationId.length > MAX_ID_LENGTH ||
466
+ !input.markdown || input.markdown.length > MAX_MARKDOWN_LENGTH) {
467
+ throw new TelegramChannelPostJournalError("conflict", "Telegram channel post edit is invalid or unavailable.");
468
+ }
469
+ const atMs = now();
470
+ if (!isSafeTime(atMs) || atMs < current.updatedAtMs) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
471
+ const record: TelegramChannelPostRecord = { ...current, state: "edit-outcome-unknown",
472
+ mutationId: input.mutationId, attemptedMarkdown: input.markdown,
473
+ mutationIssuedAtMs: atMs, updatedAtMs: atMs };
474
+ const records = [...file.records]; records[index] = record; publish({ ...file, records });
475
+ return { began: true, record: structuredClone(record) };
476
+ });
477
+ },
478
+ confirmEdited(input) {
479
+ return mutate(file => {
480
+ const index = file.records.findIndex(record => record.operationId === input.operationId);
481
+ if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
482
+ const current = file.records[index]!;
483
+ if (current.state === "published" && current.lastMutationId === input.mutationId)
484
+ return { confirmed: false, record: structuredClone(current) };
485
+ if (current.state !== "edit-outcome-unknown" || current.mutationId !== input.mutationId)
486
+ throw new TelegramChannelPostJournalError("conflict", "Telegram channel post edit confirmation is stale.");
487
+ const atMs = now();
488
+ if (!isSafeTime(atMs) || atMs < current.mutationIssuedAtMs) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
489
+ const { mutationId, attemptedMarkdown, mutationIssuedAtMs, ...prior } = current;
490
+ const record: TelegramChannelPostRecord = { ...prior, state: "published", markdown: attemptedMarkdown,
491
+ lastMutationId: mutationId, updatedAtMs: atMs };
492
+ const records = [...file.records]; records[index] = record; publish({ ...file, records });
493
+ return { confirmed: true, record: structuredClone(record) };
494
+ });
495
+ },
496
+ beginDelete(input) {
497
+ return mutate(file => {
498
+ const index = file.records.findIndex(record => record.operationId === input.operationId);
499
+ if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
500
+ const current = file.records[index]!;
501
+ if (current.state === "delete-outcome-unknown") {
502
+ if (current.mutationId === input.mutationId) return { began: false, record: structuredClone(current) };
503
+ throw new TelegramChannelPostJournalError("conflict", "Telegram channel post already has an unresolved deletion.");
504
+ }
505
+ if (current.state === "deleted" && current.mutationId === input.mutationId)
506
+ return { began: false, record: structuredClone(current) };
507
+ if (current.state !== "published" || !input.mutationId || input.mutationId.length > MAX_ID_LENGTH)
508
+ throw new TelegramChannelPostJournalError("conflict", "Telegram channel post deletion is invalid or unavailable.");
509
+ const atMs = now();
510
+ if (!isSafeTime(atMs) || atMs < current.updatedAtMs) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
511
+ const record: TelegramChannelPostRecord = { ...current, state: "delete-outcome-unknown",
512
+ mutationId: input.mutationId, mutationIssuedAtMs: atMs, updatedAtMs: atMs };
513
+ const records = [...file.records]; records[index] = record; publish({ ...file, records });
514
+ return { began: true, record: structuredClone(record) };
515
+ });
516
+ },
517
+ confirmDeleted(input) {
518
+ return mutate(file => {
519
+ const index = file.records.findIndex(record => record.operationId === input.operationId);
520
+ if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
521
+ const current = file.records[index]!;
522
+ if (current.state === "deleted" && current.mutationId === input.mutationId)
523
+ return { confirmed: false, record: structuredClone(current) };
524
+ if (current.state !== "delete-outcome-unknown" || current.mutationId !== input.mutationId)
525
+ throw new TelegramChannelPostJournalError("conflict", "Telegram channel post deletion confirmation is stale.");
526
+ const atMs = now();
527
+ if (!isSafeTime(atMs) || atMs < current.mutationIssuedAtMs) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
528
+ const { mutationIssuedAtMs, ...prior } = current;
529
+ const record: TelegramChannelPostRecord = { ...prior, state: "deleted", deletedAtMs: atMs, updatedAtMs: atMs };
530
+ const records = [...file.records]; records[index] = record; publish({ ...file, records });
531
+ return { confirmed: true, record: structuredClone(record) };
532
+ });
533
+ },
534
+ list(input = {}) {
535
+ const limit = input.limit ?? 20;
536
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > maxRecords) {
537
+ throw new TelegramChannelPostJournalError("invalid", "Telegram channel post list limit is invalid.");
538
+ }
539
+ const channel = input.channel === undefined ? undefined : normalizeChannel(input.channel);
540
+ return read().records.filter(record => channel === undefined || record.requestedChannel === channel)
541
+ .slice(-limit).reverse().map(record => structuredClone(record));
542
+ },
543
+ };
544
+ }