@llblab/pi-telegram 0.27.12 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.27.12",
3
+ "version": "0.28.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -27,7 +27,7 @@
27
27
  "node": ">=22.19.0"
28
28
  },
29
29
  "scripts": {
30
- "test": "node --experimental-strip-types --test --test-reporter=dot tests/*.test.ts",
30
+ "test": "node --experimental-strip-types --test --test-concurrency=4 --test-reporter=dot tests/*.test.ts",
31
31
  "test:verbose": "node --experimental-strip-types --test --test-reporter=spec tests/*.test.ts",
32
32
  "typecheck": "tsc --noEmit",
33
33
  "audit": "npm audit --omit=peer",
@@ -64,7 +64,7 @@
64
64
  "extensions": [
65
65
  "./index.ts"
66
66
  ],
67
- "image": "https://github.com/llblab/pi-telegram/raw/main/screenshot.png"
67
+ "image": "https://raw.githubusercontent.com/llblab/pi-telegram/main/screenshot.png"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@earendil-works/pi-agent-core": ">=0.80.6",
@@ -0,0 +1,435 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Conservative pre-downgrade journal authority check.
5
+ * Usage: node scripts/check-downgrade.mjs [agent-dir]
6
+ *
7
+ * This is an intentionally simpler schema validator than `lib/journal.ts`.
8
+ * It only needs to prove whether unresolved journal authority remains, so it
9
+ * errs toward BLOCKED and does not cross-check the rules the runtime owns:
10
+ *
11
+ * - Operator dispositions are not reconciled against surviving entries
12
+ * (`lib/journal.ts` rejects a "discard" disposition whose entry is still
13
+ * present).
14
+ * - `queueOwner` and `failure` are presence-checked, not deep-validated
15
+ * (acquisition shape, retry/terminal derivation, and disposition id
16
+ * generation stay in `lib/journal.ts`).
17
+ *
18
+ * The shared entry rules (keys, state enum, update_id agreement, and
19
+ * queue/failure metadata presence) MUST stay in sync with
20
+ * `lib/journal.ts` `validateJournalEntry`; `tests/journal-downgrade.test.ts`
21
+ * reconciles the two surfaces on those rules.
22
+ */
23
+
24
+ import {
25
+ existsSync,
26
+ lstatSync,
27
+ readFileSync,
28
+ readdirSync,
29
+ } from "node:fs";
30
+ import { homedir } from "node:os";
31
+ import { basename, join, resolve } from "node:path";
32
+
33
+ const SNAPSHOT_PATTERN = /^(?:inbox|follower-inbox-[a-f0-9]{16})(?:\.[a-zA-Z0-9._-]+)?\.json$/u;
34
+ const SEGMENT_NAME_PATTERN = /^\d{16}\.json$/u;
35
+ const AUTHORITY_STATES = new Set(["pending", "retry-wait", "queued", "failed"]);
36
+ const SNAPSHOT_KEYS = new Set([
37
+ "version",
38
+ "revision",
39
+ "profile",
40
+ "botIdentity",
41
+ "entries",
42
+ "operatorDispositions",
43
+ ]);
44
+ const SEGMENT_KEYS = new Set([
45
+ "version",
46
+ "revision",
47
+ "previousRevision",
48
+ "profile",
49
+ "botIdentity",
50
+ "upsertedEntries",
51
+ "removedUpdateIds",
52
+ "operatorDispositions",
53
+ ]);
54
+ const ENTRY_KEYS = new Set([
55
+ "updateId",
56
+ "update",
57
+ "admittedAtMs",
58
+ "state",
59
+ "queueKind",
60
+ "queueReceiptId",
61
+ "queueOwner",
62
+ "queueHandoff",
63
+ "failure",
64
+ "nextRetryAtMs",
65
+ "terminalAtMs",
66
+ "terminalReason",
67
+ "terminalFailureId",
68
+ ]);
69
+ const DISPOSITION_KEYS = new Set([
70
+ "failureId",
71
+ "updateId",
72
+ "action",
73
+ "committedAtMs",
74
+ "attemptCount",
75
+ "failureClass",
76
+ "terminalAtMs",
77
+ "terminalReason",
78
+ ]);
79
+
80
+ function resolveAgentDir() {
81
+ if (process.argv[2]) return resolve(process.argv[2]);
82
+ if (process.env.PI_CODING_AGENT_DIR) {
83
+ return resolve(process.env.PI_CODING_AGENT_DIR);
84
+ }
85
+ const executableName = basename(process.execPath).toLowerCase();
86
+ const invokedName = basename(process.argv[1] ?? "").toLowerCase();
87
+ return join(
88
+ homedir(),
89
+ executableName.startsWith("omp") || invokedName.startsWith("omp")
90
+ ? ".omp"
91
+ : ".pi",
92
+ "agent",
93
+ );
94
+ }
95
+
96
+ function block(message) {
97
+ console.error(`BLOCKED: ${message}`);
98
+ process.exitCode = 1;
99
+ }
100
+
101
+ function hasOnlyKeys(value, allowed) {
102
+ return Object.keys(value).every((key) => allowed.has(key));
103
+ }
104
+
105
+ function isRecord(value) {
106
+ return typeof value === "object" && value !== null && !Array.isArray(value);
107
+ }
108
+
109
+ function isPositiveInteger(value) {
110
+ return Number.isSafeInteger(value) && value > 0;
111
+ }
112
+
113
+ function isNonNegativeInteger(value) {
114
+ return Number.isSafeInteger(value) && value >= 0;
115
+ }
116
+
117
+ function readJson(path) {
118
+ try {
119
+ return JSON.parse(readFileSync(path, "utf8"));
120
+ } catch (error) {
121
+ block(
122
+ `cannot verify ${path}: ${error instanceof Error ? error.message : String(error)}`,
123
+ );
124
+ return undefined;
125
+ }
126
+ }
127
+
128
+ function validateBotIdentity(value) {
129
+ return (
130
+ isRecord(value) &&
131
+ hasOnlyKeys(value, new Set(["botId", "tokenSha256"])) &&
132
+ (value.botId === undefined || isPositiveInteger(value.botId)) &&
133
+ typeof value.tokenSha256 === "string" &&
134
+ /^[a-f0-9]{64}$/u.test(value.tokenSha256)
135
+ );
136
+ }
137
+
138
+ function identitiesMatch(left, right) {
139
+ if (
140
+ left.botId !== undefined &&
141
+ right.botId !== undefined &&
142
+ left.botId !== right.botId
143
+ ) {
144
+ return false;
145
+ }
146
+ return (
147
+ (left.botId !== undefined && left.botId === right.botId) ||
148
+ left.tokenSha256 === right.tokenSha256
149
+ );
150
+ }
151
+
152
+ function mergeBotIdentity(stored, current) {
153
+ return {
154
+ ...(current.botId !== undefined
155
+ ? { botId: current.botId }
156
+ : stored.botId !== undefined
157
+ ? { botId: stored.botId }
158
+ : {}),
159
+ tokenSha256: current.tokenSha256,
160
+ };
161
+ }
162
+
163
+ function validateEntries(value) {
164
+ if (!Array.isArray(value)) return undefined;
165
+ const entries = new Map();
166
+ let previousId = -1;
167
+ for (const entry of value) {
168
+ if (
169
+ !isRecord(entry) ||
170
+ !hasOnlyKeys(entry, ENTRY_KEYS) ||
171
+ !isNonNegativeInteger(entry.updateId) ||
172
+ entry.updateId <= previousId ||
173
+ !AUTHORITY_STATES.has(entry.state) ||
174
+ !isRecord(entry.update) ||
175
+ entry.update.update_id !== entry.updateId ||
176
+ !isNonNegativeInteger(entry.admittedAtMs)
177
+ ) {
178
+ return undefined;
179
+ }
180
+ const hasQueueMetadata =
181
+ entry.queueKind !== undefined ||
182
+ entry.queueReceiptId !== undefined ||
183
+ entry.queueOwner !== undefined ||
184
+ entry.queueHandoff !== undefined;
185
+ const hasFailureMetadata =
186
+ entry.failure !== undefined ||
187
+ entry.nextRetryAtMs !== undefined ||
188
+ entry.terminalAtMs !== undefined ||
189
+ entry.terminalReason !== undefined ||
190
+ entry.terminalFailureId !== undefined;
191
+ if (
192
+ (entry.state === "pending" && (hasQueueMetadata || hasFailureMetadata)) ||
193
+ (entry.state === "queued" &&
194
+ ((entry.queueKind !== "prompt" && entry.queueKind !== "control") ||
195
+ typeof entry.queueReceiptId !== "string" ||
196
+ entry.queueReceiptId.length === 0 ||
197
+ entry.queueOwner === undefined ||
198
+ hasFailureMetadata)) ||
199
+ ((entry.state === "retry-wait" || entry.state === "failed") &&
200
+ (hasQueueMetadata || !isRecord(entry.failure)))
201
+ ) {
202
+ return undefined;
203
+ }
204
+ previousId = entry.updateId;
205
+ entries.set(entry.updateId, entry);
206
+ }
207
+ return entries;
208
+ }
209
+
210
+ function validateOperatorDispositions(value) {
211
+ if (value === undefined) return true;
212
+ if (!Array.isArray(value)) return false;
213
+ const failureIds = new Set();
214
+ for (const disposition of value) {
215
+ if (
216
+ !isRecord(disposition) ||
217
+ !hasOnlyKeys(disposition, DISPOSITION_KEYS) ||
218
+ typeof disposition.failureId !== "string" ||
219
+ disposition.failureId.length === 0 ||
220
+ failureIds.has(disposition.failureId) ||
221
+ !isNonNegativeInteger(disposition.updateId) ||
222
+ (disposition.action !== "retry" && disposition.action !== "discard") ||
223
+ !isNonNegativeInteger(disposition.committedAtMs) ||
224
+ !isPositiveInteger(disposition.attemptCount) ||
225
+ typeof disposition.failureClass !== "string" ||
226
+ disposition.failureClass.length === 0 ||
227
+ !isNonNegativeInteger(disposition.terminalAtMs) ||
228
+ disposition.committedAtMs < disposition.terminalAtMs ||
229
+ typeof disposition.terminalReason !== "string" ||
230
+ disposition.terminalReason.length === 0
231
+ ) {
232
+ return false;
233
+ }
234
+ failureIds.add(disposition.failureId);
235
+ }
236
+ return true;
237
+ }
238
+
239
+ function validateSnapshot(value) {
240
+ if (
241
+ !isRecord(value) ||
242
+ !hasOnlyKeys(value, SNAPSHOT_KEYS) ||
243
+ value.version !== 1 ||
244
+ (value.revision !== undefined && !isPositiveInteger(value.revision)) ||
245
+ typeof value.profile !== "string" ||
246
+ value.profile.length === 0 ||
247
+ !validateBotIdentity(value.botIdentity) ||
248
+ !validateOperatorDispositions(value.operatorDispositions)
249
+ ) {
250
+ return undefined;
251
+ }
252
+ const entries = validateEntries(value.entries);
253
+ if (!entries) return undefined;
254
+ return {
255
+ revision: value.revision ?? 0,
256
+ profile: value.profile,
257
+ botIdentity: value.botIdentity,
258
+ entries,
259
+ };
260
+ }
261
+
262
+ function validateSegment(value) {
263
+ if (
264
+ !isRecord(value) ||
265
+ !hasOnlyKeys(value, SEGMENT_KEYS) ||
266
+ value.version !== 1 ||
267
+ !isPositiveInteger(value.revision) ||
268
+ !isNonNegativeInteger(value.previousRevision) ||
269
+ value.previousRevision !== value.revision - 1 ||
270
+ typeof value.profile !== "string" ||
271
+ value.profile.length === 0 ||
272
+ !validateBotIdentity(value.botIdentity) ||
273
+ !Array.isArray(value.upsertedEntries) ||
274
+ !Array.isArray(value.removedUpdateIds) ||
275
+ !validateOperatorDispositions(value.operatorDispositions)
276
+ ) {
277
+ return undefined;
278
+ }
279
+ const upsertedEntries = validateEntries(
280
+ [...value.upsertedEntries].sort((left, right) =>
281
+ Number(left?.updateId) - Number(right?.updateId),
282
+ ),
283
+ );
284
+ if (!upsertedEntries || upsertedEntries.size !== value.upsertedEntries.length) {
285
+ return undefined;
286
+ }
287
+ const removedUpdateIds = new Set();
288
+ for (const updateId of value.removedUpdateIds) {
289
+ if (
290
+ !isNonNegativeInteger(updateId) ||
291
+ removedUpdateIds.has(updateId) ||
292
+ upsertedEntries.has(updateId)
293
+ ) {
294
+ return undefined;
295
+ }
296
+ removedUpdateIds.add(updateId);
297
+ }
298
+ return {
299
+ revision: value.revision,
300
+ previousRevision: value.previousRevision,
301
+ profile: value.profile,
302
+ botIdentity: value.botIdentity,
303
+ upsertedEntries,
304
+ removedUpdateIds,
305
+ };
306
+ }
307
+
308
+ const agentDir = resolveAgentDir();
309
+ const runtimeDir = join(agentDir, "tmp", "telegram");
310
+ if (!existsSync(runtimeDir)) {
311
+ console.log("SAFE: no Telegram runtime directory exists.");
312
+ process.exit(0);
313
+ }
314
+
315
+ let runtimeNames;
316
+ try {
317
+ runtimeNames = readdirSync(runtimeDir);
318
+ } catch (error) {
319
+ block(
320
+ `cannot verify Telegram runtime directory ${runtimeDir}: ${error instanceof Error ? error.message : String(error)}`,
321
+ );
322
+ process.exit(1);
323
+ }
324
+
325
+ const snapshotNames = runtimeNames.filter((name) => SNAPSHOT_PATTERN.test(name));
326
+ const snapshotSet = new Set(snapshotNames);
327
+ for (const name of runtimeNames) {
328
+ const snapshotName = name.endsWith(".segments")
329
+ ? name.slice(0, -".segments".length)
330
+ : undefined;
331
+ if (snapshotName && SNAPSHOT_PATTERN.test(snapshotName)) {
332
+ if (!snapshotSet.has(snapshotName)) {
333
+ block(
334
+ `cannot verify orphan journal segments without ${join(runtimeDir, snapshotName)}`,
335
+ );
336
+ }
337
+ continue;
338
+ }
339
+ if (
340
+ (name.startsWith("inbox") || name.startsWith("follower-inbox")) &&
341
+ !SNAPSHOT_PATTERN.test(name)
342
+ ) {
343
+ block(`cannot verify unrecognized journal-like artifact ${join(runtimeDir, name)}`);
344
+ }
345
+ }
346
+
347
+ for (const name of snapshotNames) {
348
+ const path = join(runtimeDir, name);
349
+ let pathStat;
350
+ try {
351
+ pathStat = lstatSync(path);
352
+ } catch (error) {
353
+ block(`cannot verify journal snapshot ${path}: ${String(error)}`);
354
+ continue;
355
+ }
356
+ if (!pathStat.isFile()) {
357
+ block(`cannot verify non-file journal snapshot ${path}`);
358
+ continue;
359
+ }
360
+ const snapshot = validateSnapshot(readJson(path));
361
+ if (!snapshot) {
362
+ block(`cannot verify malformed journal snapshot ${path}`);
363
+ continue;
364
+ }
365
+
366
+ let revision = snapshot.revision;
367
+ let botIdentity = snapshot.botIdentity;
368
+ const entries = snapshot.entries;
369
+ const segmentDir = `${path}.segments`;
370
+ if (existsSync(segmentDir)) {
371
+ let segmentNames;
372
+ try {
373
+ if (!lstatSync(segmentDir).isDirectory()) {
374
+ block(`cannot verify non-directory journal segments ${segmentDir}`);
375
+ continue;
376
+ }
377
+ segmentNames = readdirSync(segmentDir);
378
+ } catch (error) {
379
+ block(`cannot verify journal segments ${segmentDir}: ${String(error)}`);
380
+ continue;
381
+ }
382
+ const unrecognized = segmentNames.filter(
383
+ (segmentName) => !SEGMENT_NAME_PATTERN.test(segmentName),
384
+ );
385
+ for (const segmentName of unrecognized) {
386
+ block(`cannot verify unrecognized journal segment ${join(segmentDir, segmentName)}`);
387
+ }
388
+ for (const segmentName of segmentNames.filter((candidate) =>
389
+ SEGMENT_NAME_PATTERN.test(candidate),
390
+ ).sort()) {
391
+ const nameRevision = Number(segmentName.slice(0, 16));
392
+ if (nameRevision <= revision) continue;
393
+ const segmentPath = join(segmentDir, segmentName);
394
+ let segmentStat;
395
+ try {
396
+ segmentStat = lstatSync(segmentPath);
397
+ } catch (error) {
398
+ block(`cannot verify journal segment ${segmentPath}: ${String(error)}`);
399
+ break;
400
+ }
401
+ const segment = segmentStat.isFile()
402
+ ? validateSegment(readJson(segmentPath))
403
+ : undefined;
404
+ if (
405
+ !segment ||
406
+ segment.revision !== nameRevision ||
407
+ segment.previousRevision !== revision
408
+ ) {
409
+ block(`cannot verify malformed or gapped journal segment ${segmentPath}`);
410
+ break;
411
+ }
412
+ if (
413
+ segment.profile !== snapshot.profile ||
414
+ !identitiesMatch(segment.botIdentity, botIdentity)
415
+ ) {
416
+ block(`cannot verify foreign journal segment identity ${segmentPath}`);
417
+ break;
418
+ }
419
+ for (const updateId of segment.removedUpdateIds) entries.delete(updateId);
420
+ for (const [updateId, entry] of segment.upsertedEntries) {
421
+ entries.set(updateId, entry);
422
+ }
423
+ revision = segment.revision;
424
+ botIdentity = mergeBotIdentity(botIdentity, segment.botIdentity);
425
+ }
426
+ }
427
+ if (entries.size > 0) {
428
+ block(
429
+ `${path} retains ${entries.size} unresolved update(s); drain with 0.28.x before downgrade.`,
430
+ );
431
+ }
432
+ }
433
+
434
+ if (process.exitCode) process.exit(1);
435
+ console.log(`SAFE: ${snapshotNames.length} Telegram journal(s) contain no unresolved updates.`);