@llblab/pi-kit 0.35.1 → 0.35.3

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 (50) hide show
  1. package/BACKLOG.md +2 -0
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +3 -3
  4. package/extensions/pi-actors/index.js +4 -0
  5. package/extensions/pi-claude-usage/index.ts +4 -0
  6. package/extensions/pi-clean-room/index.ts +4 -0
  7. package/extensions/pi-codex-usage/index.ts +4 -0
  8. package/extensions/pi-grow-loop/index.js +4 -0
  9. package/extensions/pi-state-flow/index.js +4 -0
  10. package/extensions/pi-telegram/index.js +4 -0
  11. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -1
  12. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -11
  13. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +12 -0
  14. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +22 -0
  15. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +75 -16
  16. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +1 -1
  17. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +11 -1
  18. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +30 -19
  19. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +21 -1
  20. package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +36 -0
  21. package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +80 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +2 -1
  23. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +18 -3
  24. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +13 -0
  25. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +33 -12
  26. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +11 -0
  27. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +31 -8
  28. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +2 -0
  29. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +2 -1
  30. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +5 -1
  31. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +5 -1
  32. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +3 -2
  33. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +6 -2
  34. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +57 -12
  35. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  36. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +3 -1
  37. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +111 -15
  38. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -0
  39. package/node_modules/@llblab/pi-telegram/lib/bus.ts +30 -21
  40. package/node_modules/@llblab/pi-telegram/lib/extension.ts +21 -1
  41. package/node_modules/@llblab/pi-telegram/lib/logging.ts +122 -0
  42. package/node_modules/@llblab/pi-telegram/lib/paths.ts +22 -3
  43. package/node_modules/@llblab/pi-telegram/lib/polling.ts +44 -10
  44. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +40 -7
  45. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +8 -5
  46. package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +6 -1
  47. package/node_modules/@llblab/pi-telegram/lib/threads.ts +12 -5
  48. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +69 -17
  49. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  50. package/package.json +10 -9
@@ -6,6 +6,16 @@
6
6
 
7
7
  import * as Machine from "./machine.ts";
8
8
 
9
+ /**
10
+ * Structural port for run-collapsing diagnostics: a repeated identical failure extends its run instead of logging
11
+ * again, and the closing success records one outcome. The composition root owns the collapsed implementation.
12
+ */
13
+ export interface TelegramRepeatedFailureLogPort {
14
+ record: (error: unknown, details?: Record<string, unknown>) => void;
15
+ settled: () => void;
16
+ reset: () => void;
17
+ }
18
+
9
19
  const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 3_000;
10
20
  const TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS = 250;
11
21
 
@@ -344,6 +354,8 @@ function updateTelegramRuntimeStatusSafely<TContext>(
344
354
  export interface TelegramTypingLoopStarterDeps<
345
355
  TContext,
346
356
  > extends TelegramRuntimeEventRecorderPort {
357
+ /** Collapses one sustained presence failure run; the caller supplies its owner. */
358
+ failureLog: TelegramRepeatedFailureLogPort;
347
359
  typing: TelegramRuntimeTypingPort;
348
360
  getDefaultChatId: () => number | undefined;
349
361
  sendTypingAction: (
@@ -374,6 +386,31 @@ export function createTelegramTypingLoopStarter<TContext>(
374
386
  threadId: number | undefined;
375
387
  canContinue: () => boolean;
376
388
  } | undefined;
389
+ // An unreachable transport repeats one identical presence failure every tick; the port collapses the run.
390
+ // Running it through one guard keeps a faulty diagnostics port from escaping the in-flight action owner.
391
+ const typingFailureLog: TelegramRepeatedFailureLogPort = {
392
+ record(error, details) {
393
+ try {
394
+ deps.failureLog.record(error, details);
395
+ } catch {
396
+ // Typing diagnostics cannot escape the in-flight action owner.
397
+ }
398
+ },
399
+ settled() {
400
+ try {
401
+ deps.failureLog.settled();
402
+ } catch {
403
+ // A successful presence outranks its own diagnostic.
404
+ }
405
+ },
406
+ reset() {
407
+ try {
408
+ deps.failureLog.reset();
409
+ } catch {
410
+ // Typing diagnostics cannot fail a loop stop.
411
+ }
412
+ },
413
+ };
377
414
  return (ctx, chatId, options) => {
378
415
  const sessionGeneration = deps.getSessionGeneration?.();
379
416
  const transportAuthority = deps.getTransportAuthority?.();
@@ -422,24 +459,20 @@ export function createTelegramTypingLoopStarter<TContext>(
422
459
  if (!active || !canContinue()) throw new Error("Typing context authority changed.");
423
460
  },
424
461
  });
462
+ typingFailureLog.settled();
425
463
  } catch (error) {
426
464
  if (!active) return;
427
465
  if (!canContinue()) {
428
466
  deps.typing.stop();
429
467
  return;
430
468
  }
431
- try {
432
- deps.recordRuntimeEvent?.("typing", error, {
433
- chatId: targetChatId,
434
- });
435
- } catch {
436
- // Typing diagnostics cannot escape the in-flight action owner.
437
- }
469
+ typingFailureLog.record(error, { chatId: targetChatId });
438
470
  }
439
471
  },
440
472
  shouldContinue: canContinue,
441
473
  onStopped: () => {
442
474
  active = false;
475
+ typingFailureLog.reset();
443
476
  if (currentOwner === owner) currentOwner = undefined;
444
477
  },
445
478
  });
@@ -401,6 +401,8 @@ export interface TelegramApiCallOptions {
401
401
  sleep?: (ms: number) => Promise<void>;
402
402
  /** Observability hook fired before a 429 retry wait, never for 5xx waits. */
403
403
  onRetryWait?: (wait: TelegramApiRetryWait) => void;
404
+ /** Caller-owned expected failure: still thrown, but the caller records the outcome instead of the API log. */
405
+ isExpectedError?: (error: unknown) => boolean;
404
406
  }
405
407
 
406
408
  export class TelegramApiAuthorityError extends Error {
@@ -2197,11 +2199,12 @@ export function createTelegramBridgeApiRuntime(
2197
2199
  ) {
2198
2200
  return true as TResponse;
2199
2201
  }
2200
- deps.recordRuntimeEvent(
2201
- "api",
2202
- error,
2203
- withTelegramTransportDiagnostics(error, { method }),
2204
- );
2202
+ if (!options?.isExpectedError?.(error))
2203
+ deps.recordRuntimeEvent(
2204
+ "api",
2205
+ error,
2206
+ withTelegramTransportDiagnostics(error, { method }),
2207
+ );
2205
2208
  throw error;
2206
2209
  }
2207
2210
  };
@@ -719,7 +719,12 @@ export async function applyThreadReconciliationPlan(
719
719
  }
720
720
  let deleteConfirmed = false;
721
721
  let superseded = false;
722
- for (const method of ports.skipCloseBeforeDelete
722
+ // Private-chat topics (positive chat ids) cannot be closed; Telegram only supports closeForumTopic in forums.
723
+ const skipClose =
724
+ ports.skipCloseBeforeDelete ||
725
+ (action.kind === "close-delete-graceful-shutdown-topic" &&
726
+ action.target.chatId > 0);
727
+ for (const method of skipClose
723
728
  ? ["deleteForumTopic"]
724
729
  : ["closeForumTopic", "deleteForumTopic"]) {
725
730
  if (ports.isCleanupTargetProtected?.(action.target, action)) {
@@ -252,6 +252,13 @@ export interface TelegramWorkspaceExternalProtectionEvidence {
252
252
  deliveryAuthority: TelegramWorkspaceProtectionState;
253
253
  }
254
254
 
255
+ /** Per-binding probe; `withSnapshot` lets one occupancy capture share binding-independent evidence reads. */
256
+ export type TelegramWorkspaceExternalProtectionCapture = ((
257
+ binding: TelegramWorkspaceThreadBinding,
258
+ ) => TelegramWorkspaceExternalProtectionEvidence) & {
259
+ withSnapshot?: <T>(capture: () => T) => T;
260
+ };
261
+
255
262
  export interface TelegramWorkspaceSlotOccupancySnapshot {
256
263
  bindings: TelegramWorkspaceSlotOccupancy[];
257
264
  reservedSlots: string[];
@@ -876,9 +883,7 @@ export interface TelegramTopicTargetStore {
876
883
  ) => Promise<boolean>;
877
884
  /** Caller must separately prove no external live owner, accepted work, or delivery authority. */
878
885
  captureWorkspaceSlotOccupancy: (
879
- getExternalProtection: (
880
- binding: TelegramWorkspaceThreadBinding,
881
- ) => TelegramWorkspaceExternalProtectionEvidence,
886
+ getExternalProtection: TelegramWorkspaceExternalProtectionCapture,
882
887
  options?: { expectedRetirement?: TelegramWorkspaceRetirementIntent },
883
888
  ) => TelegramWorkspaceSlotOccupancySnapshot;
884
889
  hasWorkspaceBinding: (cwd: string, sessionId?: string) => boolean;
@@ -7359,7 +7364,9 @@ export function createTelegramTopicTargetStore(
7359
7364
  },
7360
7365
  captureWorkspaceSlotOccupancy(getExternalProtection, options) {
7361
7366
  const nowMs = getNowMs();
7362
- const bindings = Array.from(workspaceBindings.values()).map((binding) => {
7367
+ const inSnapshot =
7368
+ getExternalProtection.withSnapshot ?? ((capture) => capture());
7369
+ const bindings = inSnapshot(() => Array.from(workspaceBindings.values()).map((binding) => {
7363
7370
  const slot = binding.slot?.toLowerCase() ?? "";
7364
7371
  const locallyProtected = isWorkspaceBindingLocallyProtected(
7365
7372
  binding,
@@ -7398,7 +7405,7 @@ export function createTelegramTopicTargetStore(
7398
7405
  : {}),
7399
7406
  protection,
7400
7407
  };
7401
- });
7408
+ }));
7402
7409
  const localReservedSlots = [
7403
7410
  ...Array.from(workspaceClaims.values()).map(
7404
7411
  (claim) => claim.identity.slot,
@@ -146,6 +146,8 @@ export function captureTelegramWorkspaceJournalProtectionSources(input: {
146
146
  path: string,
147
147
  ) => () => TelegramWorkspaceJournalReader | undefined;
148
148
  };
149
+ /** Snapshot-scoped reuse of binding-independent (shared/discovered) source reads. */
150
+ sharedSourceReads?: Map<string, TelegramWorkspaceJournalProtectionSource>;
149
151
  }): TelegramWorkspaceJournalProtectionCapture {
150
152
  const sources: TelegramWorkspaceJournalProtectionSource[] = [];
151
153
  let complete =
@@ -155,26 +157,43 @@ export function captureTelegramWorkspaceJournalProtectionSources(input: {
155
157
  scope: TelegramWorkspaceJournalProtectionSource["scope"],
156
158
  resolve: () => TelegramWorkspaceJournalReader | undefined,
157
159
  ): void => {
160
+ const sharedKey =
161
+ scope.kind === "shared"
162
+ ? "shared"
163
+ : scope.kind === "discovered"
164
+ ? `discovered:${scope.path}`
165
+ : undefined;
166
+ let source =
167
+ sharedKey === undefined
168
+ ? undefined
169
+ : input.sharedSourceReads?.get(sharedKey);
170
+ if (!source) {
171
+ source = readSource(scope, resolve);
172
+ if (sharedKey !== undefined)
173
+ input.sharedSourceReads?.set(sharedKey, source);
174
+ }
175
+ if (source.kind === "unknown") complete = false;
176
+ sources.push(source);
177
+ };
178
+ const readSource = (
179
+ scope: TelegramWorkspaceJournalProtectionSource["scope"],
180
+ resolve: () => TelegramWorkspaceJournalReader | undefined,
181
+ ): TelegramWorkspaceJournalProtectionSource => {
158
182
  try {
159
183
  const binding = resolve();
160
- if (!binding) {
161
- complete = false;
162
- sources.push({ kind: "unknown", scope });
163
- return;
164
- }
184
+ if (!binding) return { kind: "unknown", scope };
165
185
  const read = binding.readForProtection ?? binding.journal.read;
166
186
  const snapshot = input.withJournalReference
167
187
  ? input.withJournalReference(binding, read)
168
188
  : read();
169
- sources.push({
189
+ return {
170
190
  kind: "available",
171
191
  scope,
172
192
  entries: snapshot.entries,
173
193
  ...(snapshot.exists === undefined ? {} : { exists: snapshot.exists }),
174
- });
194
+ };
175
195
  } catch {
176
- complete = false;
177
- sources.push({ kind: "unknown", scope });
196
+ return { kind: "unknown", scope };
178
197
  }
179
198
  };
180
199
  capture({ kind: "shared" }, input.resolveLeader);
@@ -586,10 +605,12 @@ interface TelegramWorkspaceProtectionObserverDeps {
586
605
  }
587
606
 
588
607
  export interface TelegramWorkspaceProtectionObserver {
589
- capture: (
608
+ capture: ((
590
609
  binding: TelegramWorkspaceThreadBinding,
591
610
  options?: { requireBindingProvenance?: boolean },
592
- ) => TelegramWorkspaceExternalProtectionEvidence;
611
+ ) => TelegramWorkspaceExternalProtectionEvidence) & {
612
+ withSnapshot?: <T>(capture: () => T) => T;
613
+ };
593
614
  captureJournalSources: (
594
615
  binding: TelegramWorkspaceThreadBinding,
595
616
  ) => TelegramWorkspaceJournalProtectionCapture;
@@ -602,9 +623,39 @@ export interface TelegramWorkspaceProtectionObserver {
602
623
  export function createTelegramWorkspaceProtectionObserver(
603
624
  deps: TelegramWorkspaceProtectionObserverDeps,
604
625
  ): TelegramWorkspaceProtectionObserver {
626
+ // One occupancy snapshot probes every binding synchronously; namespace and shared/discovered
627
+ // journal reads are binding-independent, so read them once per snapshot instead of per binding.
628
+ // Never widen this beyond one synchronous capture: post-fence rechecks need fresh evidence.
629
+ let snapshot:
630
+ | {
631
+ namespace?: { value?: TelegramJournalNamespaceInspection; error?: unknown };
632
+ reads: Map<string, TelegramWorkspaceJournalProtectionSource>;
633
+ }
634
+ | undefined;
635
+ const inspectNamespace = (): TelegramJournalNamespaceInspection | undefined => {
636
+ if (!snapshot) return deps.inspectJournalNamespace?.();
637
+ if (!snapshot.namespace) {
638
+ try {
639
+ snapshot.namespace = { value: deps.inspectJournalNamespace?.() };
640
+ } catch (error) {
641
+ snapshot.namespace = { error };
642
+ }
643
+ }
644
+ if ("error" in snapshot.namespace) throw snapshot.namespace.error;
645
+ return snapshot.namespace.value;
646
+ };
647
+ const withSnapshot = <T>(run: () => T): T => {
648
+ if (snapshot) return run();
649
+ snapshot = { reads: new Map() };
650
+ try {
651
+ return run();
652
+ } finally {
653
+ snapshot = undefined;
654
+ }
655
+ };
605
656
  const captureJournalSources: TelegramWorkspaceProtectionObserver["captureJournalSources"] =
606
657
  (candidate) => {
607
- const namespace = deps.inspectJournalNamespace?.();
658
+ const namespace = inspectNamespace();
608
659
  if (
609
660
  deps.inspectJournalNamespace &&
610
661
  (!namespace ||
@@ -658,12 +709,13 @@ export function createTelegramWorkspaceProtectionObserver(
658
709
  },
659
710
  }
660
711
  : {}),
712
+ ...(snapshot ? { sharedSourceReads: snapshot.reads } : {}),
661
713
  });
662
714
  };
663
- const capture: TelegramWorkspaceProtectionObserver["capture"] = function (
664
- binding,
665
- options,
666
- ) {
715
+ const captureOne = function (
716
+ binding: TelegramWorkspaceThreadBinding,
717
+ options?: { requireBindingProvenance?: boolean },
718
+ ): TelegramWorkspaceExternalProtectionEvidence {
667
719
  return captureTelegramWorkspaceExternalProtection({
668
720
  binding,
669
721
  requireBindingProvenance: options?.requireBindingProvenance,
@@ -714,7 +766,7 @@ export function createTelegramWorkspaceProtectionObserver(
714
766
  });
715
767
  };
716
768
  return {
717
- capture,
769
+ capture: Object.assign(captureOne, { withSnapshot }),
718
770
  captureJournalSources,
719
771
  getJournalWriterProtection:
720
772
  deps.getJournalWriterProtection ?? (() => "unknown"),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.57.1",
3
+ "version": "0.57.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.35.1",
3
+ "version": "0.35.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -33,6 +33,7 @@
33
33
  "validate": "npm test && npm run audit && npm run pack:check"
34
34
  },
35
35
  "files": [
36
+ "extensions/",
36
37
  "README.md",
37
38
  "AGENTS.md",
38
39
  "BACKLOG.md",
@@ -47,7 +48,7 @@
47
48
  "@llblab/pi-codex-usage": "0.13.1",
48
49
  "@llblab/pi-grow-loop": "0.9.0",
49
50
  "@llblab/pi-state-flow": "0.26.4",
50
- "@llblab/pi-telegram": "0.57.1",
51
+ "@llblab/pi-telegram": "0.57.2",
51
52
  "@llblab/skills": "1.15.0"
52
53
  },
53
54
  "bundledDependencies": [
@@ -62,13 +63,13 @@
62
63
  ],
63
64
  "pi": {
64
65
  "extensions": [
65
- "./node_modules/@llblab/pi-actors/dist/pi-actors/index.js",
66
- "./node_modules/@llblab/pi-claude-usage/index.ts",
67
- "./node_modules/@llblab/pi-clean-room/index.ts",
68
- "./node_modules/@llblab/pi-codex-usage/index.ts",
69
- "./node_modules/@llblab/pi-grow-loop/dist/pi-grow-loop/index.js",
70
- "./node_modules/@llblab/pi-state-flow/dist/pi-state-flow/index.js",
71
- "./node_modules/@llblab/pi-telegram/dist/pi-telegram/index.js"
66
+ "./extensions/pi-actors/index.js",
67
+ "./extensions/pi-claude-usage/index.ts",
68
+ "./extensions/pi-clean-room/index.ts",
69
+ "./extensions/pi-codex-usage/index.ts",
70
+ "./extensions/pi-grow-loop/index.js",
71
+ "./extensions/pi-state-flow/index.js",
72
+ "./extensions/pi-telegram/index.js"
72
73
  ],
73
74
  "skills": [
74
75
  "./node_modules/@llblab/pi-actors/dist/skills",