@llblab/pi-kit 0.1.4 → 0.1.6
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/CHANGELOG.md +9 -0
- package/README.md +5 -5
- package/node_modules/@llblab/pi-actors/BACKLOG.md +1 -1
- package/node_modules/@llblab/pi-actors/CHANGELOG.md +14 -1
- package/node_modules/@llblab/pi-actors/README.md +19 -3
- package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +29 -10
- package/node_modules/@llblab/pi-actors/dist/lib/inspector-overlay.d.ts +3 -0
- package/node_modules/@llblab/pi-actors/dist/lib/inspector-overlay.js +120 -38
- package/node_modules/@llblab/pi-actors/dist/lib/limits.d.ts +1 -0
- package/node_modules/@llblab/pi-actors/dist/lib/limits.js +1 -0
- package/node_modules/@llblab/pi-actors/dist/lib/observability.js +6 -1
- package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.d.ts +10 -3
- package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +109 -52
- package/node_modules/@llblab/pi-actors/dist/lib/runs-artifacts.js +24 -4
- package/node_modules/@llblab/pi-actors/dist/lib/runtime.d.ts +2 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +24 -4
- package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.d.ts +1 -0
- package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.js +3 -2
- package/node_modules/@llblab/pi-actors/dist/lib/state-readers.d.ts +5 -1
- package/node_modules/@llblab/pi-actors/dist/lib/state-readers.js +30 -3
- package/node_modules/@llblab/pi-actors/dist/lib/tools-access.d.ts +1 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-access.js +13 -11
- package/node_modules/@llblab/pi-actors/dist/lib/tools-inspect.js +5 -5
- package/node_modules/@llblab/pi-actors/dist/lib/tools-message.d.ts +1 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-message.js +3 -1
- package/node_modules/@llblab/pi-actors/docs/README.md +1 -0
- package/node_modules/@llblab/pi-actors/docs/actor-inspector.md +21 -5
- package/node_modules/@llblab/pi-actors/docs/async-runs.md +16 -4
- package/node_modules/@llblab/pi-actors/docs/command-templates.md +5 -4
- package/node_modules/@llblab/pi-actors/docs/inspection.md +83 -0
- package/node_modules/@llblab/pi-actors/docs/recipe-library.md +1 -1
- package/node_modules/@llblab/pi-actors/docs/template-recipes.md +225 -66
- package/node_modules/@llblab/pi-actors/docs/tool-registry.md +24 -3
- package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +26 -10
- package/node_modules/@llblab/pi-actors/lib/inspector-overlay.ts +161 -33
- package/node_modules/@llblab/pi-actors/lib/limits.ts +1 -0
- package/node_modules/@llblab/pi-actors/lib/observability.ts +5 -1
- package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +128 -50
- package/node_modules/@llblab/pi-actors/lib/runs-artifacts.ts +34 -4
- package/node_modules/@llblab/pi-actors/lib/runtime.ts +26 -7
- package/node_modules/@llblab/pi-actors/lib/session-evidence.ts +7 -2
- package/node_modules/@llblab/pi-actors/lib/state-readers.ts +45 -3
- package/node_modules/@llblab/pi-actors/lib/tools-access.ts +26 -12
- package/node_modules/@llblab/pi-actors/lib/tools-inspect.ts +9 -5
- package/node_modules/@llblab/pi-actors/lib/tools-message.ts +8 -1
- package/node_modules/@llblab/pi-actors/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +13 -0
- package/node_modules/@llblab/pi-telegram/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +3 -3
- package/node_modules/@llblab/pi-telegram/index.ts +25 -0
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +192 -9
- package/node_modules/@llblab/pi-telegram/lib/runtime.ts +52 -2
- package/node_modules/@llblab/pi-telegram/lib/status.ts +3 -3
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +5 -1
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +14 -6
- package/package.json +4 -4
|
@@ -57,7 +57,7 @@ Paste the bot token. If `~/.pi/agent/telegram.json` already contains a saved tok
|
|
|
57
57
|
|
|
58
58
|
The connected Pi instance owns Telegram polling. Use `/telegram-connect <name>` to activate a named profile. Each profile is a parallel bot runtime with isolated polling, diagnostics, Threaded Mode state, and local bus transport; the `default` profile keeps unsuffixed runtime paths. In classic mode each profile uses a singleton lock. When Telegram private-chat Threaded Mode is available, one live instance becomes the profile's leader and later visible Pi instances register as followers.
|
|
59
59
|
|
|
60
|
-
After an unclean computer shutdown, `/telegram-connect` detects truncated or structurally invalid temporary ownership/routing files, quarantines only the damaged files under `tmp/telegram/recovery/`, and retries once. Saved `telegram.json` configuration and runtime diagnostics remain intact. Recovery never replaces a verifiable live owner; if safe automatic recovery cannot complete, the command gives one explicit Pi-restart instruction instead of requiring deletion of the whole `tmp/` directory.
|
|
60
|
+
After an unclean computer shutdown, `/telegram-connect` detects truncated or structurally invalid temporary ownership/routing files, quarantines only the damaged files under `tmp/telegram/recovery/`, and retries once. A journal snapshot removed by older broad temp cleanup is rebuilt when its complete segment history proves an empty result, while a revisionless snapshot is repaired from the first surviving segment's exact predecessor when the reconstructed tail validates. Otherwise the snapshot and segments are quarantined as recovery evidence, a fresh journal is published, and startup continues with an informational diagnostic instead of requiring manual JSON repair. Saved `telegram.json` configuration and runtime diagnostics remain intact. Recovery never replaces a verifiable live owner; if safe automatic recovery cannot complete, the command gives one explicit Pi-restart instruction instead of requiring deletion of the whole `tmp/` directory.
|
|
61
61
|
|
|
62
62
|
### 4. Pair your Telegram account
|
|
63
63
|
|
|
@@ -163,7 +163,7 @@ Telegram private-chat Threaded Mode is the public switch for multi-instance Tele
|
|
|
163
163
|
|
|
164
164
|
Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes the `owners.json` slot, diagnostics logs, state files, thread/bus ownership, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the `default` slot. A different selected profile is a parallel bot runtime: its owner slot, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and other named profiles while shared bridge settings remain top-level/global.
|
|
165
165
|
|
|
166
|
-
Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so named-profile offsets and unrelated global/profile updates do not stale-replace one another. Polling publishes only its monotonic `lastUpdateId` into the current config-store snapshot; it never persists the detached full config object captured at poll start, which would interpret newer Settings fields as deletions. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup
|
|
166
|
+
Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so named-profile offsets and unrelated global/profile updates do not stale-replace one another. Polling publishes only its monotonic `lastUpdateId` into the current config-store snapshot; it never persists the detached full config object captured at poll start, which would interpret newer Settings fields as deletions. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup recognizes only those UUID-prefixed scratch files. It never age-deletes journals, ownership, state, logs, or other top-level runtime files and therefore cannot redirect live traffic or orphan immutable journal segments.
|
|
167
167
|
|
|
168
168
|
When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Follower liveness is intentionally fast because heartbeat traffic is local IPC: followers heartbeat every `1s`, the leader treats them as stale after `2s`, and the prune loop runs every `1s` so stopped followers are detected promptly while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Pruning alone preserves the binding; when Thread cleanup is enabled, only a subsequent OS check that confirms the exact registered PID absent may create fenced cleanup intent, and that cleanup serializes ahead of replacement registration. Successful follower target reuse refreshes the binding's recovery timestamp. Absent follower bindings remain durable restoration hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; process absence and heartbeat pruning alone do not remove them. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Bot capability monitoring does not probe through the bus until the process either owns that direct lock or has completed authenticated follower registration. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
|
|
169
169
|
|
|
@@ -205,9 +205,9 @@ All inbound updates are gated by the configured authorized user id.
|
|
|
205
205
|
|
|
206
206
|
Here, **durable** means recovery across ordinary process exit, crash, kill, and replacement after a successful atomic rename is visible to the filesystem. It does not promise survival across host, kernel, filesystem, storage-device, or power failure: journal and offset publication do not call `fsync`/`fdatasync`, and parent directories are not flushed. A host-level failure may therefore lose a recently acknowledged rename despite correct process-level ordering. Operators requiring that stronger boundary must place the agent directory on storage with an independently managed durability/backup policy; `0.28.0` must not be described as power-loss durable.
|
|
207
207
|
|
|
208
|
-
The profile-scoped journal separates transport progress from semantic progress. Leader/classic snapshots live at `tmp/telegram/inbox[.<profile>].json`; follower paths add a stable target-binding hash. The selected post-v1 storage design is one revisioned compacted snapshot plus immutable atomic transaction segments beside it. Existing v1 files load as implicit revision `0`, while positive snapshot revisions are explicit. Immutable revision segments publish privately and atomically under the existing journal transaction lock; exact repeats are idempotent, while gaps and conflicting duplicate revisions fail closed. Each segment carries one complete mutation, and readers reconstruct ordered upserts, removals, and operator-disposition state only from revisions newer than the snapshot. Malformed or gapped segments, filename/revision disagreement, and foreign journal identity fail closed. After the initial snapshot, append, batch completion, queue receipt/owner/handoff, retry/terminal, recovery, and operator dispositions publish only changed upserts/removals and disposition replacement in one segment. This avoids rewriting retained raw updates during completion-heavy drains without splitting exact queue, failure, recovery, or disposition transactions.
|
|
208
|
+
The profile-scoped journal separates transport progress from semantic progress. Leader/classic snapshots live at `tmp/telegram/inbox[.<profile>].json`; follower paths add a stable target-binding hash. The selected post-v1 storage design is one revisioned compacted snapshot plus immutable atomic transaction segments beside it. Existing v1 files load as implicit revision `0`, while positive snapshot revisions are explicit. Immutable revision segments publish privately and atomically under the existing journal transaction lock; exact repeats are idempotent, while gaps and conflicting duplicate revisions fail closed. Each segment carries one complete mutation, and readers reconstruct ordered upserts, removals, and operator-disposition state only from revisions newer than the snapshot. Malformed or gapped segments, filename/revision disagreement, and foreign journal identity fail closed. Compatibility recovery for the former broad temp-cleanup bug rebuilds a missing snapshot when its complete revision-1 segment chain removes known base authority before any upsert and reconstructs to an empty journal, and repairs a revisionless snapshot when the first surviving segment supplies its exact positive predecessor revision and the reconstructed tail validates. If repair fails, the transaction-locked loader atomically moves the snapshot when present plus its segment directory under `tmp/telegram/recovery/`, publishes a fresh empty private journal, records an informational recovery event with the quarantine path, and continues startup; no uncertain files are silently deleted. After the initial snapshot, append, batch completion, queue receipt/owner/handoff, retry/terminal, recovery, and operator dispositions publish only changed upserts/removals and disposition replacement in one segment. This avoids rewriting retained raw updates during completion-heavy drains without splitting exact queue, failure, recovery, or disposition transactions.
|
|
209
209
|
|
|
210
|
-
Compaction runs under the journal transaction lock when either 256 unapplied segments or 4 MiB of segment bytes is reached. It publishes the complete private (`0600`) snapshot at revision `R` before best-effort deletion of segments `<= R`; failed cleanup leaves redundant segments that readers ignore. Interrupted cleanup therefore leaves either an older snapshot plus newer authoritative segments or a newer snapshot plus harmless redundant older segments. Revision gaps, conflicting duplicates, malformed segments, and identity mismatches fail closed. The logical reconstructed journal and aggregate unapplied segment bytes are independently bounded at 10,000 entries and 32 MiB as applicable; rejected growth publishes neither snapshot nor segment bytes. Compaction may temporarily require exactly one private complete snapshot of at most 32 MiB. Capacity pauses polling and authority files are never automatically deleted, reset, or quarantined.
|
|
210
|
+
Compaction runs under the journal transaction lock when either 256 unapplied segments or 4 MiB of segment bytes is reached. It publishes the complete private (`0600`) snapshot at revision `R` before best-effort deletion of segments `<= R`; failed cleanup leaves redundant segments that readers ignore. Interrupted cleanup therefore leaves either an older snapshot plus newer authoritative segments or a newer snapshot plus harmless redundant older segments. Revision gaps, conflicting duplicates, malformed segments, and identity mismatches fail closed. The logical reconstructed journal and aggregate unapplied segment bytes are independently bounded at 10,000 entries and 32 MiB as applicable; rejected growth publishes neither snapshot nor segment bytes. Compaction may temporarily require exactly one private complete snapshot of at most 32 MiB. Capacity pauses polling and valid authority files are never automatically deleted, reset, or quarantined. Only a missing-snapshot history that cannot be reconstructed safely uses the explicit evidence-preserving quarantine-and-reset compatibility fallback above.
|
|
211
211
|
|
|
212
212
|
`pending` entries remain immediately executable while raw interception, routing, or grouping is incomplete. Every execution failure becomes `retry-wait` with durable attempt count, next eligible time, failure class, bounded summary, and latest failure time. The `failed` state remains schema-compatible only for legacy candidate journals and is converted to automatic retry during lifecycle startup. `queued` entries carry exact prompt/control receipts plus the acquiring Pi runtime instance, OS pid/birth identity, session generation, acquisition id, and acquisition time. Queueing alone is never completion.
|
|
213
213
|
|
|
@@ -132,6 +132,22 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
132
132
|
getProfileName: configStore.getActiveProfileName,
|
|
133
133
|
getBotToken: configStore.getBotToken,
|
|
134
134
|
getBotId: getTelegramBotId,
|
|
135
|
+
onRecovery(event) {
|
|
136
|
+
recordRuntimeEvent(
|
|
137
|
+
"recovery",
|
|
138
|
+
event.kind === "repaired"
|
|
139
|
+
? "Telegram update journal was repaired automatically."
|
|
140
|
+
: "Telegram update journal was reset after its damaged files were quarantined.",
|
|
141
|
+
{
|
|
142
|
+
phase: "journal-auto-recovery",
|
|
143
|
+
recoveryKind: event.kind,
|
|
144
|
+
journalPath: event.path,
|
|
145
|
+
revision: event.revision,
|
|
146
|
+
quarantinePath: event.quarantinePath,
|
|
147
|
+
reason: event.reason,
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
},
|
|
135
151
|
getQueueRuntimeIdentity() {
|
|
136
152
|
return {
|
|
137
153
|
instanceId: telegramInstanceId,
|
|
@@ -449,6 +465,15 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
449
465
|
BusApi.createTelegramAggregateTypingActionSender(telegramApiRuntime),
|
|
450
466
|
updateStatus,
|
|
451
467
|
isContextActive: telegramSessionContextStore.isCurrent,
|
|
468
|
+
getTransportAuthority() {
|
|
469
|
+
if (ownsTelegramDirectDelivery()) {
|
|
470
|
+
const epoch = getCurrentLeaderEpoch();
|
|
471
|
+
return epoch === undefined ? undefined : `direct:${epoch}`;
|
|
472
|
+
}
|
|
473
|
+
if (!telegramBusFollowerRegistrationState.isRegistered()) return undefined;
|
|
474
|
+
const generation = telegramBusFollowerRegistrationState.getGeneration();
|
|
475
|
+
return generation ? `follower:${generation}` : undefined;
|
|
476
|
+
},
|
|
452
477
|
recordRuntimeEvent,
|
|
453
478
|
});
|
|
454
479
|
const currentModelRuntime = Model.createCurrentModelRuntime({
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
unlinkSync,
|
|
18
18
|
writeFileSync,
|
|
19
19
|
} from "node:fs";
|
|
20
|
-
import { dirname, join } from "node:path";
|
|
20
|
+
import { basename, dirname, join } from "node:path";
|
|
21
21
|
import { isDeepStrictEqual } from "node:util";
|
|
22
22
|
|
|
23
23
|
import {
|
|
@@ -349,6 +349,14 @@ export type TelegramUpdateJournalPublicationBoundary =
|
|
|
349
349
|
| "before-write"
|
|
350
350
|
| "after-write-before-rename";
|
|
351
351
|
|
|
352
|
+
export interface TelegramUpdateJournalRecoveryEvent {
|
|
353
|
+
kind: "repaired" | "reset";
|
|
354
|
+
path: string;
|
|
355
|
+
revision?: number;
|
|
356
|
+
quarantinePath?: string;
|
|
357
|
+
reason: string;
|
|
358
|
+
}
|
|
359
|
+
|
|
352
360
|
export interface TelegramUpdateJournalStoreOptions {
|
|
353
361
|
path: string;
|
|
354
362
|
profileName?: string;
|
|
@@ -356,6 +364,7 @@ export interface TelegramUpdateJournalStoreOptions {
|
|
|
356
364
|
maxEntries?: number;
|
|
357
365
|
maxBytes?: number;
|
|
358
366
|
getNowMs?: () => number;
|
|
367
|
+
onRecovery?: (event: TelegramUpdateJournalRecoveryEvent) => void;
|
|
359
368
|
queueRuntimeIdentity?: TelegramUpdateJournalQueueRuntimeIdentity;
|
|
360
369
|
getQueueProcessLiveness?: (
|
|
361
370
|
owner: TelegramUpdateJournalQueueProcessIdentity,
|
|
@@ -1472,6 +1481,7 @@ export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
|
|
|
1472
1481
|
getBotId: () => number | undefined;
|
|
1473
1482
|
getJournalPath: (profileName?: string) => string;
|
|
1474
1483
|
getQueueRuntimeIdentity?: () => TelegramUpdateJournalQueueRuntimeIdentity;
|
|
1484
|
+
onRecovery?: (event: TelegramUpdateJournalRecoveryEvent) => void;
|
|
1475
1485
|
}
|
|
1476
1486
|
|
|
1477
1487
|
export function createTelegramUpdateJournalRuntimeBindingResolver(
|
|
@@ -1506,6 +1516,7 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
|
|
|
1506
1516
|
...(deps.getQueueRuntimeIdentity
|
|
1507
1517
|
? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() }
|
|
1508
1518
|
: {}),
|
|
1519
|
+
...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}),
|
|
1509
1520
|
}),
|
|
1510
1521
|
};
|
|
1511
1522
|
};
|
|
@@ -1546,6 +1557,7 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
|
|
|
1546
1557
|
...(includeQueueRuntimeIdentity && deps.base.getQueueRuntimeIdentity
|
|
1547
1558
|
? { getQueueRuntimeIdentity: deps.base.getQueueRuntimeIdentity }
|
|
1548
1559
|
: {}),
|
|
1560
|
+
...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
|
|
1549
1561
|
getJournalPath(profileName) {
|
|
1550
1562
|
return deps.getFollowerJournalPath(bindingKey, profileName);
|
|
1551
1563
|
},
|
|
@@ -1601,6 +1613,13 @@ export function createTelegramUpdateJournalStore(
|
|
|
1601
1613
|
);
|
|
1602
1614
|
const getNowMs = options.getNowMs ?? Date.now;
|
|
1603
1615
|
const onPublicationBoundary = options.onPublicationBoundary;
|
|
1616
|
+
const notifyRecovery = (event: TelegramUpdateJournalRecoveryEvent): void => {
|
|
1617
|
+
try {
|
|
1618
|
+
options.onRecovery?.(event);
|
|
1619
|
+
} catch {
|
|
1620
|
+
// Recovery diagnostics must not break recovered journal authority.
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1604
1623
|
const getQueueProcessLiveness =
|
|
1605
1624
|
options.getQueueProcessLiveness ?? getTelegramProcessLiveness;
|
|
1606
1625
|
|
|
@@ -1742,8 +1761,9 @@ export function createTelegramUpdateJournalStore(
|
|
|
1742
1761
|
entries: [],
|
|
1743
1762
|
});
|
|
1744
1763
|
|
|
1745
|
-
const
|
|
1764
|
+
const readCurrentStrict = (): ReadTelegramUpdateJournalResult => {
|
|
1746
1765
|
let source: string;
|
|
1766
|
+
let recoveringMissingSnapshot = false;
|
|
1747
1767
|
try {
|
|
1748
1768
|
const size = statSync(path).size;
|
|
1749
1769
|
if (size > maxBytes) {
|
|
@@ -1775,15 +1795,14 @@ export function createTelegramUpdateJournalStore(
|
|
|
1775
1795
|
);
|
|
1776
1796
|
}
|
|
1777
1797
|
if (orphanedSegmentNames.length > 0) {
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
);
|
|
1798
|
+
recoveringMissingSnapshot = true;
|
|
1799
|
+
source = serializeJournalFile(emptyFile());
|
|
1800
|
+
} else {
|
|
1801
|
+
return { file: emptyFile(), exists: false, serializedBytes: 0 };
|
|
1783
1802
|
}
|
|
1784
|
-
|
|
1803
|
+
} else {
|
|
1804
|
+
throw createJournalError("io", path, "could not be read", error);
|
|
1785
1805
|
}
|
|
1786
|
-
throw createJournalError("io", path, "could not be read", error);
|
|
1787
1806
|
}
|
|
1788
1807
|
let parsed: unknown;
|
|
1789
1808
|
try {
|
|
@@ -1808,6 +1827,9 @@ export function createTelegramUpdateJournalStore(
|
|
|
1808
1827
|
segmentNames.sort();
|
|
1809
1828
|
let revision = file.revision ?? 0;
|
|
1810
1829
|
let unappliedSegmentBytes = 0;
|
|
1830
|
+
let orphanRecoverySawUpsert = false;
|
|
1831
|
+
let orphanRecoverySawBaseRemoval = false;
|
|
1832
|
+
let orphanRecoveryUnsafe = false;
|
|
1811
1833
|
for (const name of segmentNames) {
|
|
1812
1834
|
const nameRevision = Number(name.slice(0, 16));
|
|
1813
1835
|
if (nameRevision <= revision) continue;
|
|
@@ -1871,11 +1893,16 @@ export function createTelegramUpdateJournalStore(
|
|
|
1871
1893
|
file.entries.map((entry) => [entry.updateId, entry]),
|
|
1872
1894
|
);
|
|
1873
1895
|
for (const updateId of segment.removedUpdateIds) {
|
|
1896
|
+
if (recoveringMissingSnapshot && !entriesById.has(updateId)) {
|
|
1897
|
+
orphanRecoverySawBaseRemoval = true;
|
|
1898
|
+
if (orphanRecoverySawUpsert) orphanRecoveryUnsafe = true;
|
|
1899
|
+
}
|
|
1874
1900
|
entriesById.delete(updateId);
|
|
1875
1901
|
}
|
|
1876
1902
|
for (const entry of segment.upsertedEntries) {
|
|
1877
1903
|
entriesById.set(entry.updateId, entry);
|
|
1878
1904
|
}
|
|
1905
|
+
if (segment.upsertedEntries.length > 0) orphanRecoverySawUpsert = true;
|
|
1879
1906
|
file = parseJournalFile(
|
|
1880
1907
|
{
|
|
1881
1908
|
version: TELEGRAM_UPDATE_JOURNAL_VERSION,
|
|
@@ -1935,10 +1962,166 @@ export function createTelegramUpdateJournalStore(
|
|
|
1935
1962
|
}
|
|
1936
1963
|
return { file, exists: true, serializedBytes: reboundBytes };
|
|
1937
1964
|
}
|
|
1965
|
+
if (recoveringMissingSnapshot) {
|
|
1966
|
+
if (
|
|
1967
|
+
orphanRecoveryUnsafe ||
|
|
1968
|
+
!orphanRecoverySawBaseRemoval ||
|
|
1969
|
+
file.entries.length > 0
|
|
1970
|
+
) {
|
|
1971
|
+
throw createJournalError(
|
|
1972
|
+
"invalid",
|
|
1973
|
+
path,
|
|
1974
|
+
`is missing while ${segmentDirectory} retains revision segments`,
|
|
1975
|
+
);
|
|
1976
|
+
}
|
|
1977
|
+
const recovered = serializeJournalFile(file);
|
|
1978
|
+
const recoveredBytes = assertCapacity(file, recovered);
|
|
1979
|
+
writeJournalFile(path, recovered, onPublicationBoundary);
|
|
1980
|
+
notifyRecovery({
|
|
1981
|
+
kind: "repaired",
|
|
1982
|
+
path,
|
|
1983
|
+
revision: file.revision,
|
|
1984
|
+
reason: "Recovered a missing snapshot from a complete empty segment history.",
|
|
1985
|
+
});
|
|
1986
|
+
return { file, exists: true, serializedBytes: recoveredBytes };
|
|
1987
|
+
}
|
|
1938
1988
|
const serializedBytes = assertCapacity(file);
|
|
1939
1989
|
return { file, exists: true, serializedBytes };
|
|
1940
1990
|
};
|
|
1941
1991
|
|
|
1992
|
+
const readCurrent = (): ReadTelegramUpdateJournalResult => {
|
|
1993
|
+
try {
|
|
1994
|
+
return readCurrentStrict();
|
|
1995
|
+
} catch (error) {
|
|
1996
|
+
if (
|
|
1997
|
+
!(error instanceof TelegramUpdateJournalError) ||
|
|
1998
|
+
error.code !== "invalid"
|
|
1999
|
+
) {
|
|
2000
|
+
throw error;
|
|
2001
|
+
}
|
|
2002
|
+
let snapshotExists = false;
|
|
2003
|
+
try {
|
|
2004
|
+
statSync(path);
|
|
2005
|
+
snapshotExists = true;
|
|
2006
|
+
} catch (snapshotError) {
|
|
2007
|
+
if ((snapshotError as { code?: unknown })?.code !== "ENOENT") throw error;
|
|
2008
|
+
}
|
|
2009
|
+
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
2010
|
+
let segmentNames: string[];
|
|
2011
|
+
try {
|
|
2012
|
+
segmentNames = readdirSync(segmentDirectory)
|
|
2013
|
+
.filter((name) => /^\d{16}\.json$/u.test(name))
|
|
2014
|
+
.sort();
|
|
2015
|
+
} catch {
|
|
2016
|
+
throw error;
|
|
2017
|
+
}
|
|
2018
|
+
if (segmentNames.length === 0) throw error;
|
|
2019
|
+
|
|
2020
|
+
if (snapshotExists) {
|
|
2021
|
+
try {
|
|
2022
|
+
const snapshot = parseJournalFile(
|
|
2023
|
+
JSON.parse(readFileSync(path, "utf8")) as unknown,
|
|
2024
|
+
path,
|
|
2025
|
+
);
|
|
2026
|
+
const firstSegmentPath = join(segmentDirectory, segmentNames[0]);
|
|
2027
|
+
const firstSegment = parseJournalSegment(
|
|
2028
|
+
JSON.parse(readFileSync(firstSegmentPath, "utf8")) as unknown,
|
|
2029
|
+
firstSegmentPath,
|
|
2030
|
+
);
|
|
2031
|
+
if (
|
|
2032
|
+
snapshot.revision === undefined &&
|
|
2033
|
+
firstSegment.previousRevision > 0 &&
|
|
2034
|
+
snapshot.profile === firstSegment.profile &&
|
|
2035
|
+
identitiesMatch(snapshot.botIdentity, firstSegment.botIdentity)
|
|
2036
|
+
) {
|
|
2037
|
+
writeJournalFile(
|
|
2038
|
+
path,
|
|
2039
|
+
serializeJournalFile({
|
|
2040
|
+
...snapshot,
|
|
2041
|
+
revision: firstSegment.previousRevision,
|
|
2042
|
+
}),
|
|
2043
|
+
onPublicationBoundary,
|
|
2044
|
+
);
|
|
2045
|
+
const repaired = readCurrentStrict();
|
|
2046
|
+
notifyRecovery({
|
|
2047
|
+
kind: "repaired",
|
|
2048
|
+
path,
|
|
2049
|
+
revision: repaired.file.revision,
|
|
2050
|
+
reason: `Recovered a revisionless snapshot from segment revision ${firstSegment.revision}.`,
|
|
2051
|
+
});
|
|
2052
|
+
return repaired;
|
|
2053
|
+
}
|
|
2054
|
+
} catch {
|
|
2055
|
+
// Fall through to evidence-preserving quarantine and reset.
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
const recoveryDirectory = join(
|
|
2060
|
+
dirname(path),
|
|
2061
|
+
"recovery",
|
|
2062
|
+
`${getNowMs()}-${process.pid}-${randomUUID()}`,
|
|
2063
|
+
);
|
|
2064
|
+
mkdirSync(recoveryDirectory, { recursive: true, mode: 0o700 });
|
|
2065
|
+
const snapshotQuarantinePath = join(recoveryDirectory, basename(path));
|
|
2066
|
+
const segmentQuarantinePath = join(
|
|
2067
|
+
recoveryDirectory,
|
|
2068
|
+
basename(segmentDirectory),
|
|
2069
|
+
);
|
|
2070
|
+
if (
|
|
2071
|
+
snapshotExists &&
|
|
2072
|
+
!renameTelegramPathWithRetry(path, snapshotQuarantinePath)
|
|
2073
|
+
) {
|
|
2074
|
+
throw createJournalError(
|
|
2075
|
+
"io",
|
|
2076
|
+
path,
|
|
2077
|
+
"could not quarantine an unrecoverable journal snapshot",
|
|
2078
|
+
error,
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
if (!renameTelegramPathWithRetry(segmentDirectory, segmentQuarantinePath)) {
|
|
2082
|
+
if (snapshotExists) {
|
|
2083
|
+
renameTelegramPathWithRetry(snapshotQuarantinePath, path);
|
|
2084
|
+
}
|
|
2085
|
+
throw createJournalError(
|
|
2086
|
+
"io",
|
|
2087
|
+
segmentDirectory,
|
|
2088
|
+
"could not quarantine an unrecoverable journal segment history",
|
|
2089
|
+
error,
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
const reset = emptyFile();
|
|
2093
|
+
const serialized = serializeJournalFile(reset);
|
|
2094
|
+
const serializedBytes = assertCapacity(reset, serialized);
|
|
2095
|
+
try {
|
|
2096
|
+
writeJournalFile(path, serialized, onPublicationBoundary);
|
|
2097
|
+
} catch (publicationError) {
|
|
2098
|
+
const segmentsRestored = renameTelegramPathWithRetry(
|
|
2099
|
+
segmentQuarantinePath,
|
|
2100
|
+
segmentDirectory,
|
|
2101
|
+
);
|
|
2102
|
+
const snapshotRestored =
|
|
2103
|
+
!snapshotExists ||
|
|
2104
|
+
renameTelegramPathWithRetry(snapshotQuarantinePath, path);
|
|
2105
|
+
if (!segmentsRestored || !snapshotRestored) {
|
|
2106
|
+
throw createJournalError(
|
|
2107
|
+
"io",
|
|
2108
|
+
path,
|
|
2109
|
+
"reset publication failed after journal evidence was quarantined",
|
|
2110
|
+
publicationError,
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
throw publicationError;
|
|
2114
|
+
}
|
|
2115
|
+
notifyRecovery({
|
|
2116
|
+
kind: "reset",
|
|
2117
|
+
path,
|
|
2118
|
+
quarantinePath: recoveryDirectory,
|
|
2119
|
+
reason: error.message,
|
|
2120
|
+
});
|
|
2121
|
+
return { file: reset, exists: true, serializedBytes };
|
|
2122
|
+
}
|
|
2123
|
+
};
|
|
2124
|
+
|
|
1942
2125
|
const runMutation = <T>(operation: () => T): T => {
|
|
1943
2126
|
try {
|
|
1944
2127
|
return withTelegramFileTransaction(`${path}.transaction`, operation);
|
|
@@ -320,6 +320,8 @@ export interface TelegramTypingLoopDeps {
|
|
|
320
320
|
options?: { message_thread_id?: number },
|
|
321
321
|
) => Promise<unknown>;
|
|
322
322
|
sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
|
|
323
|
+
shouldContinue?: () => boolean;
|
|
324
|
+
onStopped?: () => void;
|
|
323
325
|
}
|
|
324
326
|
|
|
325
327
|
export interface TelegramRuntimeEventRecorderPort {
|
|
@@ -365,6 +367,8 @@ export interface TelegramTypingLoopStarterDeps<
|
|
|
365
367
|
sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
|
|
366
368
|
updateStatus: (ctx: TContext, error?: string) => void;
|
|
367
369
|
isContextActive?: (ctx: TContext) => boolean;
|
|
370
|
+
isTransportAvailable?: () => boolean;
|
|
371
|
+
getTransportAuthority?: () => string | number | undefined;
|
|
368
372
|
intervalMs?: number;
|
|
369
373
|
}
|
|
370
374
|
|
|
@@ -376,15 +380,33 @@ export function createTelegramTypingLoopStarter<TContext>(
|
|
|
376
380
|
options?: { target?: TelegramTypingLoopTarget },
|
|
377
381
|
) => void {
|
|
378
382
|
return (ctx, chatId, options) => {
|
|
383
|
+
const transportAuthority = deps.getTransportAuthority?.();
|
|
384
|
+
const hasTransport = (): boolean =>
|
|
385
|
+
deps.getTransportAuthority
|
|
386
|
+
? transportAuthority !== undefined &&
|
|
387
|
+
Object.is(deps.getTransportAuthority(), transportAuthority)
|
|
388
|
+
: deps.isTransportAvailable?.() !== false;
|
|
389
|
+
if (!hasTransport()) return;
|
|
390
|
+
let active = true;
|
|
379
391
|
deps.typing.start({
|
|
380
392
|
chatId: chatId ?? deps.getDefaultChatId(),
|
|
381
393
|
target: options?.target,
|
|
382
394
|
intervalMs: deps.intervalMs ?? TELEGRAM_TYPING_ACTION_INTERVAL_MS,
|
|
383
395
|
sendTypingAction: async (targetChatId, actionOptions) => {
|
|
396
|
+
if (!active) return;
|
|
397
|
+
if (!hasTransport()) {
|
|
398
|
+
deps.typing.stop();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
384
401
|
try {
|
|
385
402
|
await deps.sendTypingAction(targetChatId, actionOptions);
|
|
386
403
|
} catch (error) {
|
|
387
404
|
if (deps.isContextActive?.(ctx) === false) return;
|
|
405
|
+
if (!active) return;
|
|
406
|
+
if (!hasTransport()) {
|
|
407
|
+
deps.typing.stop();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
388
410
|
const message =
|
|
389
411
|
error instanceof Error ? error.message : String(error);
|
|
390
412
|
updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
|
|
@@ -404,10 +426,20 @@ export function createTelegramTypingLoopStarter<TContext>(
|
|
|
404
426
|
},
|
|
405
427
|
sendAggregateTypingAction: deps.sendAggregateTypingAction
|
|
406
428
|
? async (targetChatId) => {
|
|
429
|
+
if (!active) return;
|
|
430
|
+
if (!hasTransport()) {
|
|
431
|
+
deps.typing.stop();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
407
434
|
try {
|
|
408
435
|
await deps.sendAggregateTypingAction?.(targetChatId);
|
|
409
436
|
} catch (error) {
|
|
410
437
|
if (deps.isContextActive?.(ctx) === false) return;
|
|
438
|
+
if (!active) return;
|
|
439
|
+
if (!hasTransport()) {
|
|
440
|
+
deps.typing.stop();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
411
443
|
const message =
|
|
412
444
|
error instanceof Error ? error.message : String(error);
|
|
413
445
|
updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
|
|
@@ -427,6 +459,10 @@ export function createTelegramTypingLoopStarter<TContext>(
|
|
|
427
459
|
}
|
|
428
460
|
}
|
|
429
461
|
: undefined,
|
|
462
|
+
shouldContinue: hasTransport,
|
|
463
|
+
onStopped: () => {
|
|
464
|
+
active = false;
|
|
465
|
+
},
|
|
430
466
|
});
|
|
431
467
|
};
|
|
432
468
|
}
|
|
@@ -442,7 +478,12 @@ export function startTelegramTypingLoop(
|
|
|
442
478
|
): boolean {
|
|
443
479
|
if (deps.chatId === undefined || deps.chatId === 0) return false;
|
|
444
480
|
const previousKey = state.typingLoopKey;
|
|
481
|
+
const previousDeps = state.typingLoopDeps;
|
|
445
482
|
const nextKey = getTelegramTypingLoopKey(deps);
|
|
483
|
+
if (previousDeps && previousDeps !== deps) {
|
|
484
|
+
previousDeps.onStopped?.();
|
|
485
|
+
state.typingInFlight = undefined;
|
|
486
|
+
}
|
|
446
487
|
state.typingLoopDeps = deps;
|
|
447
488
|
state.typingLoopKey = nextKey;
|
|
448
489
|
const sendTyping = (): void => {
|
|
@@ -450,10 +491,14 @@ export function startTelegramTypingLoop(
|
|
|
450
491
|
if (
|
|
451
492
|
!activeDeps ||
|
|
452
493
|
activeDeps.chatId === undefined ||
|
|
453
|
-
activeDeps.chatId === 0
|
|
454
|
-
state.typingInFlight
|
|
494
|
+
activeDeps.chatId === 0
|
|
455
495
|
)
|
|
456
496
|
return;
|
|
497
|
+
if (activeDeps.shouldContinue?.() === false) {
|
|
498
|
+
stopTelegramTypingLoop(state);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (state.typingInFlight) return;
|
|
457
502
|
const targetChatId = activeDeps.chatId;
|
|
458
503
|
const threadParams = getTelegramTypingLoopThreadParams(activeDeps.target);
|
|
459
504
|
const typing = Promise.resolve()
|
|
@@ -486,9 +531,12 @@ export function stopTelegramTypingLoop(
|
|
|
486
531
|
): boolean {
|
|
487
532
|
if (!state.typingInterval) return false;
|
|
488
533
|
clearInterval(state.typingInterval);
|
|
534
|
+
const activeDeps = state.typingLoopDeps;
|
|
489
535
|
state.typingInterval = undefined;
|
|
490
536
|
state.typingLoopDeps = undefined;
|
|
491
537
|
state.typingLoopKey = undefined;
|
|
538
|
+
state.typingInFlight = undefined;
|
|
539
|
+
activeDeps?.onStopped?.();
|
|
492
540
|
return true;
|
|
493
541
|
}
|
|
494
542
|
|
|
@@ -571,6 +619,8 @@ export interface TelegramPromptDispatchRuntimeDeps<
|
|
|
571
619
|
sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
|
|
572
620
|
updateStatus: (ctx: TContext, error?: string) => void;
|
|
573
621
|
isContextActive?: (ctx: TContext) => boolean;
|
|
622
|
+
isTransportAvailable?: () => boolean;
|
|
623
|
+
getTransportAuthority?: () => string | number | undefined;
|
|
574
624
|
intervalMs?: number;
|
|
575
625
|
}
|
|
576
626
|
|
|
@@ -871,9 +871,6 @@ export function buildTelegramStatusBarText(
|
|
|
871
871
|
state: TelegramStatusBarState,
|
|
872
872
|
): string {
|
|
873
873
|
const label = theme.fg("accent", getTelegramStatusBarLabel(state));
|
|
874
|
-
if (state.error) {
|
|
875
|
-
return `${label} ${theme.fg("error", "error")}`;
|
|
876
|
-
}
|
|
877
874
|
const queued = state.queuedStatus
|
|
878
875
|
? theme.fg("success", state.queuedStatus)
|
|
879
876
|
: "";
|
|
@@ -885,6 +882,9 @@ export function buildTelegramStatusBarText(
|
|
|
885
882
|
return `${label} ${theme.fg("warning", "electing")}${queued}`;
|
|
886
883
|
if (!state.pollingActive && state.busRole !== "follower")
|
|
887
884
|
return `${theme.fg("accent", "telegram")} ${theme.fg("dim", "disconnected")}${queued}`;
|
|
885
|
+
if (state.error) {
|
|
886
|
+
return `${label} ${theme.fg("error", "error")}`;
|
|
887
|
+
}
|
|
888
888
|
if (state.processing) {
|
|
889
889
|
const processingStatus = state.queuedStatus
|
|
890
890
|
? "active"
|
|
@@ -37,6 +37,8 @@ function getTelegramApiTempDir(): string {
|
|
|
37
37
|
return resolveTelegramTempDir();
|
|
38
38
|
}
|
|
39
39
|
const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
40
|
+
const TELEGRAM_TEMP_SCRATCH_FILE_PATTERN =
|
|
41
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-/u;
|
|
40
42
|
const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(
|
|
41
43
|
process.env,
|
|
42
44
|
["PI_TELEGRAM_INBOUND_FILE_MAX_BYTES", "TELEGRAM_MAX_FILE_SIZE_BYTES"],
|
|
@@ -1116,7 +1118,9 @@ export async function cleanupTelegramTempFiles(
|
|
|
1116
1118
|
return 0;
|
|
1117
1119
|
}
|
|
1118
1120
|
for (const entry of entries) {
|
|
1119
|
-
if (!entry.isFile())
|
|
1121
|
+
if (!entry.isFile() || !TELEGRAM_TEMP_SCRATCH_FILE_PATTERN.test(entry.name)) {
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1120
1124
|
const path = join(tempDir, entry.name);
|
|
1121
1125
|
try {
|
|
1122
1126
|
const stats = await stat(path);
|
|
@@ -54,9 +54,15 @@ Console programs are one capability source, not the defining boundary. Use the r
|
|
|
54
54
|
|
|
55
55
|
## Control Admission
|
|
56
56
|
|
|
57
|
-
Buttons are optional
|
|
57
|
+
Buttons are optional only when no candidate passes the admission test. Bias toward offering them whenever they materially shorten a likely feedback loop. If the user can approve, reject, refine, prioritize, redirect, inspect, or choose a concrete next step faster by tapping than by composing a reply, proactively expose the smallest useful control set without waiting to be asked for buttons.
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
On a Telegram-originated turn, emitting controls is required when the response asks the user for one or more bounded confirmations or choices, the likely answers can be represented truthfully in 2–6 safe controls, and no secret-bearing or high-impact ambiguity blocks their formulation. This includes blocking questions such as confirming scope, version, workflow classification, approval, or the next release step. Ask in prose when explanation is necessary, but attach the controls in the same reply; the availability of free-form typing is not a reason to omit them. A high-impact operation still uses the required confirmation flow rather than a one-tap execution shortcut.
|
|
60
|
+
|
|
61
|
+
Apply the same requirement at a workflow handoff even when the current user message itself was a complete correction or implementation command. Before concluding a Telegram reply, project the user's likely next intent from the active goal, recent trajectory, newly completed act, and available capabilities—not only from an explicitly pending question. Typical phase transitions include “prepare → release”, “inspect → approve”, “fix → rerun”, and “draft → send”. If one next action or a small alternative set is high-confidence, newly unblocked, truthfully expressible, and safe to request, emit 2–6 controls now instead of waiting for the user to restate the obvious next step. A narrowly completed subtask does not erase the parent intent or make the predictive handoff decorative.
|
|
62
|
+
|
|
63
|
+
This is anticipatory interaction, not generic suggestion generation. Prefer controls that advance the user's demonstrated workflow over broad capability menus, speculative side quests, or “What next?” buttons. A predicted high-impact action is offered as an explicit self-contained request or confirmation, never silently executed. When confidence is low or materially different next intents compete, omit controls or expose only the smallest clarifying choice.
|
|
64
|
+
|
|
65
|
+
Zero buttons remains preferable when controls would only decorate the answer, restate visible prose, solicit generic “What next?” input, expose an unclear consequence, save negligible effort, or when the user already issued a complete command and neither that command nor the inferred active workflow leaves a high-confidence immediate decision. A button earns its place by reducing response effort, ambiguity, turnaround time, or supervision cost while preserving an ordinary typed reply as a first-class option.
|
|
60
66
|
|
|
61
67
|
For status requests, show a compact `Refresh` control and bounded inspect/drill-down controls only when work is active, blocked, stale-sensitive, or otherwise actionable. A completed static status needs no buttons. Do not add destructive shortcuts or actions whose target and consequence are not yet clear.
|
|
62
68
|
|
|
@@ -130,11 +136,11 @@ Use the transport's canonical prompt-button syntax. For pi-telegram, one top-lev
|
|
|
130
136
|
|
|
131
137
|
Model the control surface as an ordered ragged sequence of independently sized rows, not as a rectangular matrix to fill. Rectangular grids are one specialization for genuinely spatial or coordinate-bearing state; most interfaces should vary row width according to hierarchy, grouping, label pressure, and action priority.
|
|
132
138
|
|
|
133
|
-
- Put controls in one compact row only when they are genuine peers that answer the same local question or form one coherent toolbar/navigation group.
|
|
134
|
-
- Use a singleton full-width row for a structurally independent, pinned, primary, summary, or high-consequence action
|
|
139
|
+
- Default to one full-width button per row for non-spatial controls. Put controls in one compact row only when they are genuine peers that answer the same local question or form one coherent toolbar/navigation group **and** their rendered labels comfortably fit a narrow phone-width chat.
|
|
140
|
+
- Use a singleton full-width row for a structurally independent, pinned, primary, summary, or high-consequence action, and whenever label length makes horizontal grouping cramped or ambiguous.
|
|
135
141
|
- Vary row widths intentionally—for example `1 → 2 → 4 → 1 → 2`—and never pad a row with empty, duplicate, or no-op controls merely to produce uniform dimensions.
|
|
136
142
|
- Preserve reading order across rows: orientation and structural navigation first, primary content or choices next, secondary controls afterward, and destructive actions visibly separated when present.
|
|
137
|
-
-
|
|
143
|
+
- Treat two columns as an earned compact mode, not the default: a pair normally fits when each label is no more than one emoji plus roughly two average-length words. If either label has more words, unusually long words, qualifiers, or likely wrapping, place each button on its own row. Use at most two columns for readable text labels; move additional peer choices into more semantic rows rather than compressing textual buttons across a phone-width line. Three through five columns are for short symbols, glyphs, coordinates, or compact codes whose position carries meaning. Six through eight may be used only for single-glyph or similarly minimal position-bearing labels whose grouping materially improves the interaction; a row of emoji-only controls may therefore legitimately use up to eight columns. Eight is the phone-width UX maximum: never generate a row of nine or more controls even though the parser has no artificial width cap. Never shorten necessary wording merely to increase row density; regroup or use full-width rows when labels need explanation, wrap ambiguously, or lose meaning without prose.
|
|
138
144
|
|
|
139
145
|
Treat vertical extent independently from horizontal density. A genuinely spatial surface may retain many rows—such as an `8×16` field—when vertical continuity, coordinates, and one-glance topology matter; do not paginate merely to make its height match its width. For non-spatial collections, however, a tall button wall should yield to semantic grouping, progressive disclosure, or pagination. Keep compact state and instructions above a tall surface, preserve stable coordinates across regeneration, and avoid repeating prose between rows.
|
|
140
146
|
|
|
@@ -181,7 +187,7 @@ Button prompts must:
|
|
|
181
187
|
- Request fresh inspection when state may have changed.
|
|
182
188
|
- Avoid embedding volatile output that should be rediscovered.
|
|
183
189
|
|
|
184
|
-
Labels stay short, distinct, and scannable. Emoji are
|
|
190
|
+
Labels stay short, distinct, and scannable. Prefer an explicit `label` over exposing a long prompt as button text. Emoji are explicitly allowed and encouraged when one consistent semantic marker improves scanning or expressiveness; keep their meaning consistent across sibling controls, avoid decorative noise, and do not rely on emoji or color alone. If buttons are unavailable, render the same control surface as a numbered choice list.
|
|
185
191
|
|
|
186
192
|
## Capability Adapters
|
|
187
193
|
|
|
@@ -230,6 +236,8 @@ Buttons may represent explicit alternatives without live system inspection. Stat
|
|
|
230
236
|
|
|
231
237
|
Before sending a surface, verify:
|
|
232
238
|
|
|
239
|
+
- If the reply asks a Telegram user for bounded confirmation or selection, qualifying controls are present; do not ship a prose-only blocking question merely because the answer is short.
|
|
240
|
+
- If the completed act unblocks a high-confidence next intent inferred from the parent goal and workflow trajectory, qualifying handoff controls are present even when no explicit pending question exists and the latest user message was itself a complete command.
|
|
233
241
|
- State and controls share one clear owner and target.
|
|
234
242
|
- Live claims come from current evidence.
|
|
235
243
|
- Complete versus filtered or adapted output is labeled honestly.
|