@llblab/pi-telegram 0.23.1 → 0.23.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.
package/AGENTS.md CHANGED
@@ -87,7 +87,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
87
87
  - Instance slots are extension-owned ring-order metadata. Allocate fresh slots from the `bot.lastSlot` cursor as `A` → `B` → ... → `Z` → `A`, allowing the cursor to move to lower letters after wraparound. Only current live/recovering bindings plus unexpired pending provisions and explicit reservations occupy slots; historical records must not block allocation indefinitely. Preserve a slot on ordinary current binding/thread reuse without advancing or rewinding the fresh-allocation cursor. Follower bindings survive process absence as stable restoration hints until explicit stale/deleted/offline evidence invalidates them. Successful target reuse must refresh the binding timestamp and replacement runtime identity; generic leader startup or heartbeat pruning must not erase the target merely because no follower registration is currently live. An authenticated live follower whose carried exact target is missing from current persisted bindings should recover that target instead of creating another Telegram thread; preserve a carried slot only when it does not collide with another current record. Preserve a cursor that belongs to a live or recoverable binding and continue skipping retained restart hints plus unexpired pending/reserved slots. Explicit stale/deleted/offline reconciliation may release that slot; generic process absence may not. The alphabet cap is a feature — compact single-letter tabs are scannable.
88
88
  - Instance thread names are provisioner-authored identity metadata, not model-authored output. Fresh Threaded Mode threads select one of five curated compact 4-6 letter Latin names for the assigned slot letter using provisioning timestamp entropy, then create the Telegram thread with that thread name immediately. The title is the thread name itself, not `{slot} {threadName}` or `{slot} — {threadName}`. Do not register or reintroduce an agent-facing thread rename tool; removing the extra rename prompt/tool turn is an intentional simplicity constraint. Thread names must mirror into terminal status, live diagnostics, and `[telegram|thread:name]` through one target-aware current-instance identity resolver. Registered follower/leader metadata takes precedence over a stale shared record for the same target, with the record used only as fallback; do not hide a valid baked/custom thread name behind old slot-prefix validation. A follower that later becomes leader keeps its existing name; leadership changes are transport role changes, not identity resets.
89
89
  - Telegram private-chat Threaded Mode treats `All` as an aggregate/control surface, not a durable `General` thread or process launcher, and leader activation proactively creates or reuses its private-DM thread. When the owner writes in an unknown thread, the leader reclaims and routes the first one only if it lacks an active binding; later unknown threads remain intact and receive an in-thread chooser to reroute the captured prompt or restore a stale binding. Destructive cleanup requires explicit confirmation through `thread-reconciler`; another Pi instance still requires a manually started process followed by `/telegram-connect`. Without Threaded Mode, process the message through classic routing.
90
- - Telegram extension work should not hold Pi's core agent lifecycle hostage once Pi has produced the semantic result. For Telegram-originated turns, final Telegram delivery, attachment upload, and transport cleanup are extension-owned side effects; schedule them off the critical `agent_end` path when ordering can still be preserved, record failures as runtime events, and keep dispatch of the next Telegram turn behind the delivery task when needed to avoid interleaving previews/finals. Public activity handlers run through isolated asynchronous per-handler queues; lifecycle hooks enqueue normalized events and never await consumer work. Proactive push defaults enabled and requires explicit `assistant.proactivePush: false` to opt out. While enabled, core delivery projects each completed Pi-visible assistant text block from local/autonomous work once and in order; bind admitted blocks to the exact target, profile/token transport stamp, direct leader epoch or follower registration generation, and session generation. Token deltas, hidden reasoning, tool traffic, stale authority, and Telegram-owned turns never enter that projection.
90
+ - Telegram extension work should not hold Pi's core agent lifecycle hostage once Pi has produced the semantic result. For Telegram-originated turns, final Telegram delivery, attachment upload, and transport cleanup are extension-owned side effects; schedule them off the critical `agent_end` path when ordering can still be preserved, record failures as runtime events, and keep dispatch of the next Telegram turn behind the delivery task when needed to avoid interleaving previews/finals. Treat a low-level error `agent_end` as retryable while its Telegram turn remains active; only a later successful `agent_end` or Pi's `agent_settled` boundary may finalize and release that turn. Public activity handlers run through isolated asynchronous per-handler queues; lifecycle hooks enqueue normalized events and never await consumer work. Proactive push defaults enabled and requires explicit `assistant.proactivePush: false` to opt out. While enabled, core delivery projects each completed Pi-visible assistant text block from local/autonomous work once and in order; bind admitted blocks to the exact target, profile/token transport stamp, direct leader epoch or follower registration generation, and session generation. Token deltas, hidden reasoning, tool traffic, stale authority, and Telegram-owned turns never enter that projection.
91
91
  - Telegram runtime state should be treated as event-driven reconciliation of extension assumptions against observable Telegram signals, not as a full Telegram bot read-model and not as a reason to query Telegram on every action. Keep a local assumption model for bot identity/capabilities, pairing assumptions, thread support, known thread lifecycle, bound instance targets, reservations, and transport health. Invalidate and reconcile the relevant slice on meaningful events: startup/reload, lifecycle service messages, stale-send/API errors, setup/pairing changes, follower register/prune, explicit status/debug requests, and bounded low-frequency leader health ticks. `lib/sync.ts` owns sync slices, observation intake, invalidation triggers, status/debug freshness, and reconciliation scheduling; it must not promise complete bot-state mirroring because Bot API lacks a complete topic listing surface. `lib/thread-reconciler.ts` owns pure Threaded Mode lifecycle planning and should become the only policy authority for destructive thread cleanup decisions. `threads` owns current Telegram UI thread/tab binding primitives and thread-name helpers mapped to Bot API topic transport, `status` renders projections, and `index.ts` only wires ports. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging and resets on extension start / runtime scope changes; it is diagnostics only, not routing/provisioning authority. `state.json` should be an observable/debug snapshot aligned with `/telegram-status` (extension runtime, bot capabilities including `bot.lastSlot`, runtime role, live roster projection, reservations, diagnostics), not routing/provisioning authority. Because every process on one Telegram profile shares this file, only the active transport lock owner may persist it; followers read current state and gain write authority only after promotion. Status-only writes must refresh disk-backed bindings before serialization so a stale loaded snapshot cannot erase newer leader state. Live bus/runtime state is authoritative; file reservations and fresh capability observations may be startup hints/collision guards only; stale snapshots must re-probe before suppressing bus/topic behavior. Do not persist stale/offline/failed thread target history as source of truth.
92
92
  - Thread bindings are bidirectional. From the Pi-instance side, an instance knows and preserves its target, slot, thread name, lifecycle state, and direct-delivery defaults. From the Telegram-client/bot side, the bridge observes thread creation/close/reopen/stale-send signals and reconciles them into instance binding state. Lifecycle transitions that affect operator understanding should be Telegram-visible when a live transport remains: every successful follower registration/re-registration gets a compact connected notice in the assigned thread, while heartbeat pruning stays silent because it is only liveness suspicion. These notices should use the instance thread name when known and fall back to the slot letter only while the thread is still unnamed. New unknown threads (owner writing in `All`) are preserved until the user explicitly chooses a reroute/restore/new-instance action that makes cleanup eligible; any destructive cleanup must go through `thread-reconciler`. Unknown `forum_topic_created` service events are observations, not destructive cleanup proof.
93
93
  - The multi-instance bus uses private bot DMs with Telegram private-chat Threaded Mode enabled for the bot. No supergroup, group, or forum configuration is needed; the thread chat is always the private chat with the paired owner. Threaded capability checks must use bot/private-chat evidence such as `getMe.has_topics_enabled`, incoming `message_thread_id`, and topic operation success/failure; do not use group chat metadata as the control-plane truth for Telegram private-chat Threaded Mode.
@@ -116,12 +116,12 @@ The core product loop is mobile continuation: start or supervise work in the ter
116
116
  - `telegram_attach` is the canonical outbound file-delivery path for Telegram-originated requests; outside active Telegram turns it may send immediately to the paired/default chat, a registered follower's assigned target, or explicit `chat_id` plus optional `thread_id` for explicit local/TUI delivery requests only when this Pi instance owns `/telegram-connect` or is registered with the Threaded Mode multi-instance bus. `telegram_message` is the first-class direct Telegram Markdown text tool for local/TUI prompts and follows the same ownership-or-bus-registration gate; neither direct tool replaces normal active-turn replies. It reuses top-level `telegram_button` comments for inline buttons; buttons must be attached to a text message, never sent as standalone actions
117
117
  - Telegram prompt guidance is layered and context-frugal: unconfigured sessions receive no bridge suffix, configured sessions receive compact direct-delivery/help guidance, and Telegram-originated turns receive only the minimal per-turn contract. Detailed voice/button/direct-delivery/Threaded Mode/formatting/debug guidance belongs in the agent-facing `telegram_help` tool instead of being repeated in every prompt.
118
118
  - Telegram inbound prompt topology keeps current user material contiguous: `[outputs]` produced from current attachments follows `[attachments]` immediately, then independent `[reply]` or forwarded-source context follows the complete current-user block. Forwarded Rich Message media belongs under source-attributed `[attachments|from:<identity>]` immediately after its `[forward|from:<identity>]` block and remains available as model image content. Do not place source context between an attachment and its transcription or handler output.
119
- - Forward-comment grouping uses a bounded one-second candidate window for ordinary short human text so an adjacent same-sender/chat/thread forward can join across polling responses; a matching forward flushes immediately. Commands, bots, captions, media groups, target/sender mismatches, reversed ids, and distant message ids bypass grouping. Preserve the grouped candidate across session replacement and follower forwarding without weakening exact routing-generation authority.
119
+ - Forward-annotation grouping distinguishes two semantic layers: the forward owns its source text/caption/media, while an optional separate owner-authored annotation normally arrives immediately before it. Use a bounded one-second candidate window in both transport orders: ordinary short human text may wait for an adjacent same-sender/chat/thread forward, and a forwarded message—including a media-only forward with no source caption—may wait for one adjacent owner annotation. A matching opposite-kind message flushes immediately; same-kind messages, commands, bots, ordinary non-forward captions, media groups, target/sender mismatches, reversed ids, and distant ids stay separate. Preserve the candidate across session replacement and follower forwarding without weakening exact routing-generation authority.
120
120
  - `telegram_voice` and `telegram_button` are not Pi tools; keep prompts/docs explicit that agents should author markup while voice synthesis provider extensions own TTS/OGG conversion, and pi-telegram owns button routing plus Telegram delivery
121
- - Voice reply policy and prompt context are owned by pi-telegram's `telegram.json` `voice.replyMode`: missing/invalid config behaves as `manual` but does not add a `[voice]` prompt-context block; only an explicit valid `voice.replyMode` renders context. Render a single voice field as `[voice] reply mode: manual|mirror|always`, and render multiple fields as a `[voice]` list; place voice context after `[outputs]` when handler output exists, otherwise after `[attachments]`; provider prompt contributions are optional provider-specific additions, not the default policy channel
121
+ - Voice reply policy and prompt context are owned by pi-telegram's `telegram.json` `voice.replyMode`: missing, invalid, `hidden`, and legacy `manual` config resolve to `hidden` and add no `[voice]` context; `mirror` adds `[voice] delivery: automatic voice` only for voice/audio input, while `always` adds that same single factual line to every Telegram turn. Place voice context after `[outputs]` when handler output exists, otherwise after `[attachments]`; provider prompt contributions are optional provider-specific additions, not the default policy or delivery-composition channel.
122
122
  - Optional `telegram.json` `time` may add `[time] YYYY-MM-DD HH:mm:ss <timezone>` to Telegram-originated prompts for wall-clock context. It is hidden by default, uses `time.injectionMode` values `hidden|always|interval`, stores `time.interval` in milliseconds, uses the system timezone, and should render last after `[attachments]`, `[outputs]`, and `[voice]` sections. The Settings row `🕒 Time injection: hidden|always|interval` controls `time.injectionMode` only.
123
123
  - Agent activity status is native-only for the release path: Telegram uses `sendChatAction(typing)` and renders its native `…typing` indicator as the automatic work signal. When a connected instance starts any real agent run, send that indicator to the instance's own thread (and aggregate `All` surface), including Telegram turns, local/TUI prompts, and autonomous continuations such as Grow Loop; stop it at agent/session completion. This signal is independent from terminal `Active`, which remains scoped to Telegram-owned turns. Confirmed manual `/compact` and automatic compaction tied to an active Telegram turn also use native activity. Do not send activity merely for startup/connect/reload/recovery or add extra in-chat work surfaces without a new explicit design decision.
124
- - Voice reply mode Settings UI standard: the top-level Settings row is `👄 Voice reply: hidden|manual|mirror|always`; `hidden` is the true default and means no valid `voice.replyMode` is persisted, behavior is manual, and no voice policy is added to prompt context; explicit `manual` behaves the same operationally but renders reply-mode context. The submenu title is `👄 Voice reply mode:`; choice buttons use lowercase labels with a model-style active dot (`🟢 hidden`, `🟢 mirror`) rather than per-mode emoji; the explanatory submenu body uses compact HTML-code bullets such as `<code>-</code> <code>hidden</code> (default): ...`. Preserve this wording/icons unless the operator explicitly asks to redesign it
124
+ - Voice reply mode Settings UI standard: the top-level Settings row is `👄 Voice reply: hidden|mirror|always`; `hidden` is the true default, persists no `voice.replyMode`, adds no voice context, and still allows explicit agent-authored `telegram_voice`; `mirror` activates automatic voice context only for voice/audio input, and `always` activates it for every Telegram turn. The submenu title is `👄 Voice reply mode:`; choice buttons use lowercase labels with a model-style active dot (`🟢 hidden`, `🟢 mirror`) rather than per-mode emoji; the explanatory submenu body uses compact HTML-code bullets such as `<code>-</code> <code>hidden</code> (default): ...`. Preserve this wording/icons unless the operator explicitly asks to redesign it
125
125
  - Outbound voice delivery is one fallback pipeline: configured `outboundHandlers` with `type: "voice"` run first in `telegram.json` order, then programmatic voice handlers, then registered voice synthesis providers as zero-config progressive fallbacks; provider extensions must not override operator-configured handlers
126
126
  - `telegram_voice` text is arbitrary TTS-target text and the prompt should show only forms that include text to speak: `<!-- telegram_voice text="Short summary" -->` or `<!-- telegram_voice: Short summary -->`. Avoid raw Markdown/code/tables in voice text.
127
127
  - `telegram_button` has three canonical forms: `<!-- telegram_button: OK -->` for label-only buttons, `<!-- telegram_button label=Continue prompt="Continue with the current plan." -->` for one-line prompts, or `<!-- telegram_button label="Show risks"\nList the main risks first.\n-->` for multiline prompts. Do not author JSON button specs, inline-after-text comments, standalone button tools, or comments inside code/quotes/lists/indented examples; write normal Markdown plus top-level hidden comments, and add visible parent text when buttons would otherwise be the only output
@@ -149,7 +149,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
149
149
 
150
150
  - Treat queue handling, compaction interaction, and lifecycle-hook state transitions as regression-prone areas; validate them after changing dispatch logic
151
151
  - Keep the standard `npm run typecheck` gate strict about unused locals and parameters so stale imports and abandoned adapters fail validation instead of accumulating silently
152
- - Route important runtime failures through the recent runtime event recorder so `/telegram-status` remains useful for post-mortem debugging, not just transient status-bar errors
152
+ - Route important runtime failures through the recent runtime event recorder so `/telegram-status` remains useful for post-mortem debugging. Keep the compact TUI status bar bounded to the generic `error` state; detailed error messages belong only in diagnostics/logs, never inline beside the status label.
153
153
  - Treat remaining Markdown-to-HTML rendering as Telegram UI/compat output work, not generic Markdown rendering or assistant reply delivery
154
154
  - Preserve literal code content in Telegram rendering
155
155
  - Avoid HTML chunk splits that break tags
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.3: Thread-Scoped Settings Hotfix
4
+
5
+ - `Thread-Scoped Settings Rehydration`: Preserved `message_thread_id` when rebuilding full Settings menu state after session reload or TTL expiry. Impact: a stale Settings message in Threaded Mode retains its exact Telegram target when later callbacks reopen menus or cross into model/status controls.
6
+ - `Composition Root Hygiene`: Sorted the local domain imports in `index.ts` and normalized adjacent wrapping without changing runtime composition. Impact: the entrypoint remains easier to scan and review while preserving the existing dependency graph and behavior.
7
+
8
+ ## 0.23.2: Voice Policy And Turn Delivery Hotfix
9
+
10
+ - `Settings Persistence Race`: Rebuilt expired Settings callback state from the live model-menu context before applying mutations. Polling now persists only its monotonic `lastUpdateId` into the current config-store snapshot instead of later submitting the detached full config object captured when polling started. Impact: a subsequent Telegram update can no longer erase freshly persisted `voice.replyMode`, `assistant.proactivePush`, or other Settings values and return the menu to stale defaults.
11
+ - `Compact Error Status`: Reduced pi-telegram's TUI error projection to the single `error` state while retaining detailed failures in runtime diagnostics and logs. Impact: provider and transport messages no longer consume the status line or displace the surrounding model/session indicators.
12
+ - `Forward Annotation Pairing`: Extended the bounded one-second coalescer so one optional owner-authored annotation and adjacent forward join in either transport order, including photo-only forwards without their own source caption. Prompt construction keeps the layers distinct: owner annotation first, then `[forward|from:...]` with the forward's own source text/caption, then source-attributed media; same-kind messages and existing command/sender/target/id-gap exclusions remain separate. Impact: Telegram forwarding gestures no longer split the user's explanation from the forwarded source or conflate it with the forward's own content.
13
+ - `Voice Reply Policy`: Collapsed the redundant explicit `manual` mode into the silent `hidden` default, kept `mirror` automatic only for voice/audio input, and kept `always` automatic for every Telegram turn. Active automatic turns now carry exactly `[voice] delivery: automatic voice`; the per-turn system suffix only points to `telegram_help`, explicit `telegram_voice` remains an agent-authored override, and legacy `manual` config resolves to `hidden`. Impact: models receive one effective delivery fact instead of a noisy mode matrix, while providers remain responsible for synthesis rather than text/voice composition.
14
+ - `Provider Retry Delivery`: Retained an active Telegram turn across low-level `agent_end` errors until Pi either produced a successful retry result or emitted `agent_settled`. Retry recovery now delivers the eventual semantic result once to the original target, while an exhausted retry finalizes the retained error once and releases queue state; redacted diagnostics distinguish retention, recovery, and settled failure. Impact: transient provider transport failures can no longer orphan a later successful reply from its originating Telegram turn.
15
+
3
16
  ## 0.23.1: Context Budget And Runtime Simplification
4
17
 
5
18
  - `Bus Runtime Simplification`: Removed the no-op follower-binding recovery timer and grace configuration, the unused disconnected-announcement helper, and the duplicate follower-prune callback. Heartbeat pruning now emits one accurate diagnostic while preserving durable thread bindings. Impact: leader/follower recovery carries less dormant state and cannot imply offline cleanup that never occurs.
package/README.md CHANGED
@@ -92,7 +92,7 @@ The first Telegram user to message the bot becomes the allowed owner. Other user
92
92
 
93
93
  | Surface | What you can do | Why it matters |
94
94
  | --- | --- | --- |
95
- | Prompt intake | Send text, replies, edits, images, files, albums, voice notes, and handler output into Pi. | Telegram becomes a real mobile input surface with file/context references, not just a text tunnel. |
95
+ | Prompt intake | Send text, replies, edits, images, files, albums, voice notes, forwards with adjacent comments, and handler output into Pi. | Telegram becomes a real mobile input surface; one forward-plus-comment gesture stays one attributed prompt even for photo-only forwards. |
96
96
  | Queue control | Inspect waiting turns, delete stale work, promote important prompts, continue, abort, stop, or force the next queued item. | Long Pi tasks keep running while new mobile prompts stay visible and controllable instead of interrupting or disappearing. |
97
97
  | Operator menu | Use `/start` for status, prompt templates, model, thinking, settings, queue, extension sections, and diagnostics. | The bot is an operator panel, not a command cheat sheet. |
98
98
  | Prompt templates | Run Pi prompt templates as Telegram-safe commands such as `/fix_tests`. | Reusable local workflows become phone-accessible without exposing arbitrary terminal commands. |
@@ -104,7 +104,7 @@ The first Telegram user to message the bot becomes the allowed owner. Other user
104
104
  | Inbound files | Download inbound files to the Pi agent temp directory with size limits. | Screenshots, PDFs, datasets, and artifacts enter Pi as inspectable local files. |
105
105
  | Outbound artifacts | Return generated files through `telegram_attach` during active turns or explicit direct delivery. | Agents send real artifacts as files, not pasted blobs. |
106
106
  | Voice input | Route audio through configured command-template handlers, programmatic handlers, or STT providers. | Voice notes become usable prompt context. |
107
- | Voice output | Use `telegram_voice`, reply modes, configured voice handlers, and TTS providers. | Replies can become Telegram voice messages when the workflow calls for it. |
107
+ | Voice output | Choose `hidden`, `mirror`, or `always`; active automatic turns carry one compact `[voice] delivery: automatic voice` line, while explicit `telegram_voice` remains available. | Voice policy stays dynamic and model-legible without duplicating the full action contract in every prompt. |
108
108
  | Buttons | Turn top-level `telegram_button` comments into inline buttons. | Assistant-authored choices become native Telegram interactions. |
109
109
  | Callback routing | Route known callbacks to the owner extension and unknown callbacks back into Pi. | Companion extensions can build UI without polling Telegram themselves. |
110
110
  | Threaded Mode | Run one leader plus visible follower Pi instances through named private-chat threads. | One bot can host a local multi-instance Pi organism without hidden process spawning. |
@@ -165,7 +165,7 @@ Named profile identifiers contain only lowercase ASCII letters and digits (maxim
165
165
 
166
166
  ### Queue Runtime
167
167
 
168
- Messages sent while Pi is busy become queued turns. Priority lanes support control actions and model-switch continuations. Queue controls let you inspect, delete, promote, and dispatch work from Telegram without touching the terminal.
168
+ Messages sent while Pi is busy become queued turns. Priority lanes support control actions and model-switch continuations. Queue controls let you inspect, delete, promote, and dispatch work from Telegram without touching the terminal. If Pi automatically retries a transient provider failure, the active Telegram turn stays bound until the successful reply arrives or Pi confirms that the run has settled.
169
169
 
170
170
  ### Native Rich Markdown
171
171
 
@@ -135,7 +135,7 @@ Telegram private-chat Threaded Mode is the public switch for multi-instance Tele
135
135
 
136
136
  Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes singleton locks, diagnostics logs, state files, thread/bus owner keys, 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 profile. A different selected profile is a parallel bot runtime: its locks, `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 from other named profiles while shared bridge settings remain top-level/global. The default profile preserves legacy state, log, socket, and named-pipe paths for compatibility.
137
137
 
138
- Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; profile bot/session fields and 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. 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 is limited to stale scratch files and cannot redirect live traffic.
138
+ Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; profile bot/session fields and 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 is limited to stale scratch files and cannot redirect live traffic.
139
139
 
140
140
  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: it preserves the follower thread binding 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. 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. 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.
141
141
 
@@ -167,7 +167,7 @@ All inbound updates are gated by the configured authorized user id.
167
167
  2. Persist update offsets only after successful handling; repeated handler failures are bounded.
168
168
  3. Filter to the paired private user; guest-mode updates require an existing paired user and cannot establish first pairing.
169
169
  4. Dispatch owned callbacks and controls before fallback prompt forwarding.
170
- 5. Coalesce media groups, likely split long text, and a short human comment followed by an adjacent forwarded message when needed.
170
+ 5. Coalesce media groups, likely split long text, and one adjacent forward-plus-comment pair in either order when needed.
171
171
  6. Download files into `~/.pi/agent/tmp/telegram` with size limits and partial-download cleanup.
172
172
  7. Run configured/programmatic inbound handlers in order, appending successful stdout under `[outputs]`.
173
173
  8. Add local attachments under `[attachments]`, optional voice context, and optional final `[time]` context.
@@ -175,7 +175,7 @@ All inbound updates are gated by the configured authorized user id.
175
175
  10. Handle `edited_message` updates separately while the original turn is still queued.
176
176
  11. Dispatch only when all safety gates are clear.
177
177
 
178
- Long-text split recovery remains conservative: only human text at or above the near-limit threshold opens its debounce window. A separate bounded one-second comment window applies to ordinary short human text so a forwarded message arriving in the next polling response can join the same Pi turn; an adjacent matching forward flushes immediately. Commands, bots, captions, media groups, different senders/targets, reversed ids, and distant message ids do not enter this pairing path.
178
+ Long-text split recovery remains conservative: only human text at or above the near-limit threshold opens its debounce window. Forward annotation has two semantic layers: the forward owns its source text/caption/media, while an optional separate owner-authored annotation normally precedes it. A bounded one-second pairing window joins that annotation and adjacent forward in either transport order, including a media-only forward without source caption text; the matching opposite-kind message flushes immediately. Same-kind rapid messages, commands, bots, ordinary non-forward captions, media groups, different senders/targets, reversed ids, and distant message ids do not enter this pairing path. Prompt construction always places the owner annotation first, followed by `[forward|from:...]` with the forward's own source text/caption, then source-attributed forwarded attachments, regardless of arrival order.
179
179
 
180
180
  ### Queue And Dispatch Safety
181
181
 
@@ -200,7 +200,7 @@ Dispatch requires:
200
200
  - `ctx.isIdle()` is true.
201
201
  - `ctx.hasPendingMessages()` is false.
202
202
 
203
- A dispatched prompt remains queued until `agent_start` consumes it. This keeps the active Telegram turn bound for previews, attachments, aborts, and final replies.
203
+ A dispatched prompt remains queued until `agent_start` consumes it. This keeps the active Telegram turn bound for previews, attachments, aborts, and final replies. A low-level `agent_end` error also retains that active turn because Pi may retry automatically; a later successful `agent_end` delivers through the original target and metadata, while `agent_settled` proves that an unrecovered error can be finalized once before queue dispatch resumes.
204
204
 
205
205
  Post-agent-end queue dispatch uses a session-bound deferred dispatcher. It is activated on session start, clears timers on shutdown, and skips callbacks from older generations before touching `ExtensionContext`. Dispatch stays session-bound after polling ownership moves elsewhere. When a queued Telegram prompt is forwarded into Pi, it uses a normal `sendUserMessage(content)` turn after the bridge's idle/dispatch guards pass; it does not use Pi's `followUp` delivery option or inject terminal input.
206
206
 
@@ -299,11 +299,11 @@ Status rendering distinguishes connected, active, dispatching, queued, tool-runn
299
299
 
300
300
  Queue reactions are shortcut controls for waiting turns. Promotion reactions (`👍`, `⚡️`, `❤️`, `🕊`, `🔥`) move prompts to priority; removal reactions (`👎`, `👻`, `💔`, `💩`, `🗑`) remove waiting turns because ordinary Telegram DM deletions are not exposed through Bot API polling.
301
301
 
302
- `/telegram-status` records grouped diagnostics for transport/API, polling/update, prompt dispatch, controls, typing, compaction, setup, session lifecycle, attachment queue/delivery, and recent redacted runtime events. Expected preview noise such as unchanged edit responses is filtered out.
302
+ `/telegram-status` records grouped diagnostics for transport/API, polling/update, prompt dispatch, controls, typing, compaction, setup, session lifecycle, attachment queue/delivery, and recent redacted runtime events. Expected preview noise such as unchanged edit responses is filtered out. The compact TUI status renders only `error`; detailed failure text remains in diagnostics and profile-scoped logs instead of expanding the status line.
303
303
 
304
304
  When `assistant.proactivePush` is enabled and this instance has exact direct or follower transport authority, completed public assistant text blocks from local/autonomous work are sent once and in source order to the instance's authorized target. Visible commentary/checkpoints and the final block use the configured Rich or HTML renderer. Hidden reasoning, tool traffic, token deltas, local prompt text, Telegram-owned turns, and stale generations are not mirrored. Each admitted block remains fenced to its exact target, profile/token stamp, leader epoch or follower registration generation, and session generation; non-idempotent acknowledgement ambiguity never authorizes replay.
305
305
 
306
- Telegram prompt guidance is context-aware. Unconfigured sessions receive no bridge suffix. Local/TUI prompts receive only explicit direct-delivery guidance so ordinary terminal replies do not learn raw Telegram action-comment syntax. Telegram-originated turns receive the full inbound context, phone-width output, and native action contract, including the 37-display-cell mobile readability hint.
306
+ Telegram prompt guidance is context-aware. Unconfigured sessions receive no bridge suffix. Local/TUI prompts receive only explicit direct-delivery guidance. Telegram-originated turns receive a compact pointer to `telegram_help` plus dynamic prompt blocks such as `[voice] delivery: automatic voice`; full voice/button/direct-delivery/Threaded Mode syntax stays in the help tool rather than every system prompt.
307
307
 
308
308
  ## In-Flight Model Switching
309
309
 
package/docs/outbound.md CHANGED
@@ -30,7 +30,7 @@ When `assistant.rendering` is `"rich"`, a Telegram-originated turn that queues e
30
30
 
31
31
  The optimization is deliberately narrow. HTML rendering, empty final text, multiple files, documents and other unsupported formats, Guest Mode, explicit `telegram_voice`, voice-preferred turns, and OGG/Opus artifacts retain their established text/attachment/voice paths. A known-safe Rich upload rejection falls back to those paths. A `commit-unknown` transport outcome or a nominally successful upload without a verifiable message id never falls back or replays because the first non-idempotent send may already have committed.
32
32
 
33
- This behavior does not generate media or alter voice policy. `telegram_attach` still represents an explicit assistant artifact decision, while `manual`, `mirror`, and `always` continue to decide voice synthesis independently.
33
+ This behavior does not generate media or alter voice policy. `telegram_attach` still represents an explicit assistant artifact decision, while `hidden`, `mirror`, and `always` continue to decide voice synthesis independently.
34
34
 
35
35
  Core assistant output accepts only the Markdown or HTML `InputRichMessage` forms and does not construct explicit block arrays or `InputRichBlockThinking`. Telegram's Thinking block is draft-only and must never become a projection of hidden reasoning or chain-of-thought. Any future use for Activity would require an explicitly public user-visible summary rather than provider reasoning content.
36
36
 
@@ -93,7 +93,7 @@ interface TelegramConfig {
93
93
  proactivePush?: boolean;
94
94
  };
95
95
  voice?: {
96
- replyMode?: "manual" | "mirror" | "always";
96
+ replyMode?: "hidden" | "mirror" | "always";
97
97
  sendTranscript?: boolean;
98
98
  };
99
99
  time?: {
@@ -106,7 +106,7 @@ interface TelegramConfig {
106
106
  Hidden/default semantics are represented by absence:
107
107
 
108
108
  - `assistant.proactivePush` defaults to `true`; omit it to keep projection enabled, or set it explicitly to `false` to disable it. When enabled, each completed public assistant text block from local or autonomous work is projected to the authorized Telegram target once and in source order. This includes visible intermediate commentary/checkpoints and the final block. It excludes token deltas, hidden reasoning, tool calls/arguments/results, Telegram-owned turns, empty blocks, and stale authority. Projection uses the configured Rich or HTML assistant renderer and binds admitted work to the exact target, profile/token transport generation, direct leader epoch or follower registration generation, and session generation. The old top-level `proactivePush` key is ignored; move the setting manually under `assistant`.
109
- - Voice Reply `hidden`: no `voice.replyMode` key is persisted.
109
+ - Voice Reply `hidden`: no `voice.replyMode` key is persisted; legacy `manual` resolves to this silent default. `mirror` adds `[voice] delivery: automatic voice` only to voice/audio-input turns, while `always` adds the same effective line to every Telegram turn.
110
110
  - Agent activity status is not configurable in this release. Telegram uses native `sendChatAction(typing)` / product `...active` status as the only automatic in-chat work signal before the final reply.
111
111
  - Time Injection `hidden`: no `time.injectionMode` key is persisted; if `time` becomes empty, the whole `time` object may be omitted.
112
112
 
package/docs/ui-style.md CHANGED
@@ -171,8 +171,8 @@ Rules:
171
171
 
172
172
  Examples:
173
173
 
174
+ - `hidden`
174
175
  - `🟢 mirror`
175
- - `manual`
176
176
  - `always`
177
177
 
178
178
  ## Navigation
package/docs/voice.md CHANGED
@@ -33,14 +33,13 @@ Voice provider extensions can also register STT backends with `registerTelegramV
33
33
 
34
34
  ## Voice Reply Policy
35
35
 
36
- The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`). If config does not set a valid mode, the bridge uses the `hidden` default: manual behavior without adding voice policy text to the prompt context.
36
+ The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`). Missing, invalid, `hidden`, and legacy `manual` values resolve to the silent `hidden` default.
37
37
 
38
38
  ### Modes
39
39
 
40
- - **`hidden` (default):** no `voice.replyMode` is stored; same behavior as manual, no prompt voice context.
41
- - **`manual`:** only reply with voice when the agent authors `<!-- telegram_voice -->` markup; explicit prompt context is added.
42
- - **`mirror`:** reply with voice when the inbound message was a voice note or audio file. Text input stays on the manual path where explicit `telegram_voice` markup still works.
43
- - **`always`:** always reply with voice.
40
+ - **`hidden` (default):** no `voice.replyMode` is stored and no automatic voice context is added; explicit agent-authored `telegram_voice` actions still work.
41
+ - **`mirror`:** voice/audio input activates automatic voice delivery. Text input follows `hidden` behavior.
42
+ - **`always`:** every Telegram turn activates automatic voice delivery.
44
43
 
45
44
  **Warning:** In `always` mode, the bridge transparently intercepts ALL text replies and converts them to voice on success. Users will only receive voice messages when voice generation succeeds. If voice generation fails, the bridge falls back to sending the planned text reply.
46
45
 
@@ -97,7 +96,7 @@ Registration returns a disposer function for cleanup. Stable provider registrati
97
96
 
98
97
  ## Outbound Voice Handlers
99
98
 
100
- Users can also configure `outboundHandlers` with `type: "voice"` in `telegram.json`. This is the command-template path for TTS without a provider extension. Reply modes (`manual`, `mirror`, `always`) affect these handlers the same way they affect providers: explicit `telegram_voice` blocks and automatic mirror/always interception both produce a voice reply plan, then delivery tries configured outbound voice handlers first and registered synthesis providers as progressive fallbacks.
99
+ Users can also configure `outboundHandlers` with `type: "voice"` in `telegram.json`. This is the command-template path for TTS without a provider extension. Reply modes (`hidden`, `mirror`, `always`) affect these handlers the same way they affect providers: explicit `telegram_voice` blocks and automatic mirror/always interception both produce a voice reply plan, then delivery tries configured outbound voice handlers first and registered synthesis providers as progressive fallbacks.
101
100
 
102
101
  Voice handlers receive the text on stdin in composed pipelines and can use `{text}`, `{lang}`, `{rate}`, `{mp3}`, and `{ogg}` placeholders. Set `output` to `"ogg"` or another placeholder name when the template writes to a known path:
103
102
 
@@ -167,7 +166,7 @@ Voice provider extensions can register a Voice Extension Section (settings UI) v
167
166
 
168
167
  ## Prompt Guidance
169
168
 
170
- The bridge keeps voice prompt context compact and policy-owned. It adds `[voice] reply mode: ...` only when `telegram.json` explicitly contains a valid `voice.replyMode`. `hidden`/no configured mode behaves like manual, but prompts stay silent. When explicit, voice-originated `manual` turns add `[voice] reply mode: manual`, voice-originated `mirror` turns add `[voice] reply mode: mirror`, and `always` mode adds `[voice] reply mode: always` for every turn. If voice context later contains multiple fields, the bridge renders it as a `[voice]` list. The marker is appended after `[outputs]` when handler output exists, otherwise after `[attachments]`. Voice inputs also appear in `[attachments]` with their downloaded file names, MIME data, and handler output, so agents can infer concrete voice-file context from attachment metadata.
169
+ The bridge keeps voice prompt context compact, effective, and policy-owned. `hidden` and text-originated `mirror` turns add no voice line. Voice/audio-originated `mirror` turns and every `always` turn add exactly `[voice] delivery: automatic voice`, describing the current delivery environment without exposing the underlying mode matrix or an instruction list. The marker is appended after `[outputs]` when handler output exists, otherwise after `[attachments]`. Voice inputs also appear in `[attachments]` with their downloaded file names, MIME data, and handler output, so agents can infer concrete voice-file context from attachment metadata.
171
170
 
172
171
  Voice synthesis providers can supply prompt guidance through `getVoicePromptContribution(view)`, but provider text should stay optional and provider-specific. Reply-mode context belongs to pi-telegram.
173
172
 
@@ -204,12 +203,12 @@ Voice synthesis providers can supply prompt guidance through `getVoicePromptCont
204
203
  ```json
205
204
  {
206
205
  "voice": {
207
- "replyMode": "manual"
206
+ "replyMode": "mirror"
208
207
  }
209
208
  }
210
209
  ```
211
210
 
212
- Valid stored values: `"manual"`, `"mirror"`, `"always"`. Missing or invalid values are shown in Settings as `hidden`, behave like manual, and stay silent in prompt context.
211
+ Valid modes are `"hidden"`, `"mirror"`, and `"always"`; selecting `hidden` removes the key. Missing, invalid, and legacy `"manual"` values resolve to `hidden` and stay silent in prompt context.
213
212
 
214
213
  The bridge reads `voice.replyMode` from the config when building a turn.
215
214
 
package/index.ts CHANGED
@@ -7,18 +7,18 @@
7
7
  import * as Activity from "./lib/activity.ts";
8
8
  import * as Bindings from "./lib/bindings.ts";
9
9
  import * as BusApi from "./lib/bus-api.ts";
10
- import * as Bus from "./lib/bus.ts";
11
10
  import * as BusFollower from "./lib/bus-follower.ts";
12
11
  import * as BusLeader from "./lib/bus-leader.ts";
13
12
  import * as BusTransport from "./lib/bus-transport.ts";
13
+ import * as Bus from "./lib/bus.ts";
14
14
  import * as CommandTemplates from "./lib/command-templates.ts";
15
15
  import * as Commands from "./lib/commands.ts";
16
16
  import * as Config from "./lib/config.ts";
17
17
  import * as Delivery from "./lib/delivery.ts";
18
- import * as Threads from "./lib/threads.ts";
19
18
  import * as Inbound from "./lib/inbound.ts";
20
19
  import * as Lifecycle from "./lib/lifecycle.ts";
21
20
  import * as Locks from "./lib/locks.ts";
21
+ import * as Logs from "./lib/logs.ts";
22
22
  import * as Media from "./lib/media.ts";
23
23
  import * as MenuQueue from "./lib/menu-queue.ts";
24
24
  import * as MenuSettings from "./lib/menu-settings.ts";
@@ -35,13 +35,13 @@ import * as Queue from "./lib/queue.ts";
35
35
  import * as Replies from "./lib/replies.ts";
36
36
  import * as Routing from "./lib/routing.ts";
37
37
  import * as Runtime from "./lib/runtime.ts";
38
- import * as Logs from "./lib/logs.ts";
39
38
  import * as Sections from "./lib/sections.ts";
40
39
  import * as Status from "./lib/status.ts";
41
40
  import * as Sync from "./lib/sync.ts";
42
41
  import * as TelegramApi from "./lib/telegram-api.ts";
43
42
  import * as TextGroups from "./lib/text-groups.ts";
44
43
  import * as ThreadReconciler from "./lib/thread-reconciler.ts";
44
+ import * as Threads from "./lib/threads.ts";
45
45
  import * as TimeInjection from "./lib/time-injection.ts";
46
46
  import * as Updates from "./lib/updates.ts";
47
47
  import * as Voice from "./lib/voice.ts";
@@ -222,6 +222,11 @@ export default function (pi: Pi.ExtensionAPI) {
222
222
  persist: configStore.persist,
223
223
  markConfigChange: telegramSyncStateRuntime.markConfigChange,
224
224
  });
225
+ const persistTelegramPollingOffset =
226
+ Config.createTelegramPollingOffsetPersister(
227
+ configStore,
228
+ persistTelegramConfigWithSync,
229
+ );
225
230
  const currentInstanceThreadRuntime =
226
231
  Threads.createTelegramCurrentInstanceThreadRuntime({
227
232
  instanceId: telegramInstanceId,
@@ -460,8 +465,7 @@ export default function (pi: Pi.ExtensionAPI) {
460
465
  isTransportStampActive: telegramTransportStampRuntime.isActive,
461
466
  ownsDirect: lockRuntime.owns,
462
467
  getDirectEpoch: lockRuntime.getOwnedLeaderEpoch,
463
- isFollowerRegistered:
464
- telegramBusFollowerRegistrationState.isRegistered,
468
+ isFollowerRegistered: telegramBusFollowerRegistrationState.isRegistered,
465
469
  getFollowerGeneration:
466
470
  telegramBusFollowerRegistrationState.getGeneration,
467
471
  },
@@ -771,8 +775,7 @@ export default function (pi: Pi.ExtensionAPI) {
771
775
  return telegramActiveBusAuthSecret;
772
776
  },
773
777
  ...forwardedRouteHandlers,
774
- prepareForwardedMessage:
775
- textGroupRuntime.prepareForwardedMessage,
778
+ prepareForwardedMessage: textGroupRuntime.prepareForwardedMessage,
776
779
  recordRuntimeEvent,
777
780
  },
778
781
  targetReplacement: {
@@ -822,7 +825,7 @@ export default function (pi: Pi.ExtensionAPI) {
822
825
  hasBotToken: configStore.hasBotToken,
823
826
  deleteWebhook,
824
827
  getUpdates,
825
- persistConfig: persistTelegramConfigWithSync,
828
+ persistConfig: persistTelegramPollingOffset,
826
829
  prepareUpdateBatch: textGroupRuntime.prepareUpdateBatch,
827
830
  handleUpdate: Updates.createTelegramUpdateHandle({
828
831
  defaultHandle: inboundRouteRuntime.handleUpdate,
package/lib/bindings.ts CHANGED
@@ -36,9 +36,7 @@ type TelegramRuntimeEventRecorder = (
36
36
  type TelegramBridgeStatusUpdater =
37
37
  Status.TelegramStatusRuntime<Pi.ExtensionContext>["updateStatus"];
38
38
 
39
- export interface TelegramAssistantOutputBindingRuntime<
40
- TTransportStamp,
41
- > {
39
+ export interface TelegramAssistantOutputBindingRuntime<TTransportStamp> {
42
40
  runtime: Activity.TelegramAssistantOutputRuntime;
43
41
  observeEvent: (event: Activity.TelegramActivityEvent) => void;
44
42
  authority: Routing.TelegramAssistantOutputAuthorityRuntime<TTransportStamp>;
@@ -49,7 +47,9 @@ export function createTelegramAssistantOutputBindingRuntime<
49
47
  >(deps: {
50
48
  isEnabled: () => boolean;
51
49
  authority: {
52
- getPreferredTarget: () => OutboundAttachments.TelegramQueuedOutboundAttachmentTurnView["target"] | undefined;
50
+ getPreferredTarget: () =>
51
+ | OutboundAttachments.TelegramQueuedOutboundAttachmentTurnView["target"]
52
+ | undefined;
53
53
  getFallbackChatId: () => number | undefined;
54
54
  getTransportStamp: () => TTransportStamp;
55
55
  isTransportStampActive: (stamp: TTransportStamp) => boolean;
@@ -63,8 +63,9 @@ export function createTelegramAssistantOutputBindingRuntime<
63
63
  >[0];
64
64
  recordRuntimeEvent: TelegramRuntimeEventRecorder;
65
65
  }): TelegramAssistantOutputBindingRuntime<TTransportStamp> {
66
- const authority =
67
- Routing.createTelegramAssistantOutputAuthorityRuntime(deps.authority);
66
+ const authority = Routing.createTelegramAssistantOutputAuthorityRuntime(
67
+ deps.authority,
68
+ );
68
69
  const send =
69
70
  OutboundHandlers.createTelegramAssistantOutputSender<TTransportStamp>(
70
71
  deps.sender,
@@ -640,6 +641,7 @@ export function registerTelegramLifecycleRuntimeHooks({
640
641
  },
641
642
  async onSessionShutdown(event, ctx) {
642
643
  if (!isSessionContextActive(ctx)) return;
644
+ agentLifecycleHooks.clearRetainedAgentEnd();
643
645
  activityRuntime.onSessionShutdown();
644
646
  assistantOutputRuntime.stop();
645
647
  compactionObserver.onSessionShutdown();
@@ -706,8 +708,9 @@ export function registerTelegramLifecycleRuntimeHooks({
706
708
  activityRuntime.onAgentEnd();
707
709
  await agentLifecycleHooks.onAgentEnd(event, ctx);
708
710
  },
709
- onAgentSettled(_event, ctx) {
711
+ async onAgentSettled(event, ctx) {
710
712
  if (!isSessionContextActive(ctx)) return;
713
+ await agentLifecycleHooks.onAgentSettled(event, ctx);
711
714
  activityRuntime.onAgentSettled();
712
715
  },
713
716
  onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
package/lib/config.ts CHANGED
@@ -17,8 +17,8 @@ import {
17
17
  import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
18
18
  import { resolveAgentDir, resolveTelegramConfigPath } from "./paths.ts";
19
19
 
20
- import type { TelegramInboundHandlerConfig } from "./inbound.ts";
21
20
  import type { CommandTemplateObjectConfig } from "./command-templates.ts";
21
+ import type { TelegramInboundHandlerConfig } from "./inbound.ts";
22
22
  import { withTelegramFileTransaction } from "./locks.ts";
23
23
 
24
24
  const CONFIG_RUNTIME_KEY = "__piTelegramConfigRuntime__";
@@ -72,7 +72,7 @@ export interface TelegramConfig {
72
72
  /** @deprecated use assistant.rendering */
73
73
  assistantRendering?: TelegramAssistantRenderingMode;
74
74
  voice?: {
75
- replyMode?: "manual" | "mirror" | "always";
75
+ replyMode?: "hidden" | "mirror" | "always";
76
76
  /** Whether to attach the provider's transcriptText as caption on voice messages */
77
77
  sendTranscript?: boolean;
78
78
  };
@@ -543,6 +543,27 @@ export function createTelegramConfigStore(
543
543
  };
544
544
  }
545
545
 
546
+ export function createTelegramPollingOffsetPersister(
547
+ configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
548
+ persist: () => Promise<void> = () => configStore.persist(),
549
+ ): (pollingConfig: { lastUpdateId?: number }) => Promise<void> {
550
+ return async (pollingConfig) => {
551
+ const nextOffset = pollingConfig.lastUpdateId;
552
+ if (typeof nextOffset === "number") {
553
+ const current = configStore.get();
554
+ const currentOffset = current.lastUpdateId;
555
+ configStore.set({
556
+ ...current,
557
+ lastUpdateId:
558
+ typeof currentOffset === "number"
559
+ ? Math.max(currentOffset, nextOffset)
560
+ : nextOffset,
561
+ });
562
+ }
563
+ await persist();
564
+ };
565
+ }
566
+
546
567
  export function createTelegramProactivePushChecker(
547
568
  configStore: Pick<TelegramConfigStore, "get">,
548
569
  ): () => boolean {
@@ -625,12 +646,10 @@ export function createTelegramAssistantRenderingModeSetter(
625
646
 
626
647
  export function createTelegramVoiceReplyModeGetter(
627
648
  configStore: Pick<TelegramConfigStore, "get">,
628
- ): () => "manual" | "mirror" | "always" {
649
+ ): () => "hidden" | "mirror" | "always" {
629
650
  return () => {
630
651
  const mode = configStore.get().voice?.replyMode;
631
- return mode === "mirror" || mode === "always" || mode === "manual"
632
- ? mode
633
- : "manual";
652
+ return mode === "mirror" || mode === "always" ? mode : "hidden";
634
653
  };
635
654
  }
636
655
 
@@ -639,17 +658,17 @@ export function createTelegramVoiceReplyModeConfiguredChecker(
639
658
  ): () => boolean {
640
659
  return () => {
641
660
  const mode = configStore.get().voice?.replyMode;
642
- return mode === "mirror" || mode === "always" || mode === "manual";
661
+ return mode === "mirror" || mode === "always";
643
662
  };
644
663
  }
645
664
 
646
665
  export function createTelegramVoiceReplyModeSetter(
647
666
  configStore: TelegramMutableConfigStore,
648
- ): (replyMode: "manual" | "mirror" | "always" | undefined) => Promise<void> {
667
+ ): (replyMode: "hidden" | "mirror" | "always" | undefined) => Promise<void> {
649
668
  return async (replyMode) => {
650
669
  await loadLatestTelegramConfig(configStore);
651
670
  const current = configStore.get();
652
- if (replyMode === undefined) {
671
+ if (replyMode === undefined || replyMode === "hidden") {
653
672
  const { replyMode: _replyMode, ...remainingVoice } = current.voice ?? {};
654
673
  const next = { ...current };
655
674
  if (Object.keys(remainingVoice).length > 0) next.voice = remainingVoice;
@@ -4,10 +4,6 @@
4
4
  * Owns hidden settings-menu rendering, settings callbacks, and persisted toggle wiring
5
5
  */
6
6
 
7
- import {
8
- getTelegramExtensionSettingsRows,
9
- type TelegramSectionRegistry,
10
- } from "./sections.ts";
11
7
  import type {
12
8
  TelegramAssistantRenderingMode,
13
9
  TelegramTimeMode,
@@ -15,6 +11,10 @@ import type {
15
11
  import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
16
12
  import type { TelegramModelMenuState } from "./menu-model.ts";
17
13
  import type { MenuModel } from "./model.ts";
14
+ import {
15
+ getTelegramExtensionSettingsRows,
16
+ type TelegramSectionRegistry,
17
+ } from "./sections.ts";
18
18
  import type { TelegramVoiceReplyMode } from "./voice.ts";
19
19
 
20
20
  export type TelegramSettingsMenuReplyMarkup = TelegramInlineKeyboardMarkup;
@@ -74,7 +74,11 @@ export interface TelegramSettingsMenuRuntime<TContext> {
74
74
  query: {
75
75
  id: string;
76
76
  data?: string;
77
- message?: { message_id?: number; chat?: { id?: number } };
77
+ message?: {
78
+ message_id?: number;
79
+ message_thread_id?: number;
80
+ chat?: { id?: number };
81
+ };
78
82
  },
79
83
  ctx: TContext,
80
84
  ) => Promise<boolean>;
@@ -98,6 +102,7 @@ export interface TelegramSettingsMenuRuntimeDeps<
98
102
  getModelMenuState: (
99
103
  chatId: number,
100
104
  ctx: TContext,
105
+ threadId?: number,
101
106
  ) => Promise<TelegramModelMenuState<TModel>>;
102
107
  getStoredModelMenuState: (
103
108
  messageId: number | undefined,
@@ -201,10 +206,9 @@ export function buildVoiceReplyModeSettingsText(
201
206
  "",
202
207
  "Controls when pi-telegram converts assistant text replies into Telegram voice messages.",
203
208
  "",
204
- "<code>-</code> <code>hidden</code> (default): same behavior as 'manual', but no voice policy is added to prompt context.",
205
- "<code>-</code> <code>manual</code>: agent decides; explicit 'telegram_voice' markup still works and reply mode is visible in prompt context.",
206
- "<code>-</code> <code>mirror</code>: voice input prefers a voice reply; text input gracefully follows 'manual' behavior.",
207
- "<code>-</code> <code>always</code>: every reply is converted to voice when delivery succeeds.",
209
+ "<code>-</code> <code>hidden</code> (default): add no automatic voice context; explicit 'telegram_voice' actions still work.",
210
+ "<code>-</code> <code>mirror</code>: voice input activates automatic voice delivery; text input follows 'hidden' behavior.",
211
+ "<code>-</code> <code>always</code>: activate automatic voice delivery for every reply.",
208
212
  ].join("\n");
209
213
  }
210
214
 
@@ -225,18 +229,19 @@ export function buildTimeInjectionModeSettingsText(
225
229
  export function buildTelegramSettingsMenuReplyMarkup(
226
230
  proactivePushEnabled: boolean,
227
231
  draftPreviewsEnabled: boolean,
228
- assistantRenderingModeOrVoiceReplyMode: TelegramAssistantRenderingMode | TelegramVoiceReplyMode,
232
+ assistantRenderingModeOrVoiceReplyMode:
233
+ TelegramAssistantRenderingMode | TelegramVoiceReplyMode,
229
234
  voiceReplyModeOrTimeInjectionMode: TelegramVoiceReplyMode | TelegramTimeMode,
230
- timeInjectionModeOrSectionRegistry?: TelegramTimeMode | TelegramSectionRegistry,
235
+ timeInjectionModeOrSectionRegistry?:
236
+ TelegramTimeMode | TelegramSectionRegistry,
231
237
  sectionRegistryOrVoiceReplyModeConfigured?: TelegramSectionRegistry | boolean,
232
238
  voiceReplyModeConfigured = true,
233
239
  ): TelegramSettingsMenuReplyMarkup {
234
240
  const hasRenderingMode =
235
241
  assistantRenderingModeOrVoiceReplyMode === "rich" ||
236
242
  assistantRenderingModeOrVoiceReplyMode === "html";
237
- const assistantRenderingMode: TelegramAssistantRenderingMode = hasRenderingMode
238
- ? assistantRenderingModeOrVoiceReplyMode
239
- : "rich";
243
+ const assistantRenderingMode: TelegramAssistantRenderingMode =
244
+ hasRenderingMode ? assistantRenderingModeOrVoiceReplyMode : "rich";
240
245
  const voiceReplyMode = hasRenderingMode
241
246
  ? (voiceReplyModeOrTimeInjectionMode as TelegramVoiceReplyMode)
242
247
  : (assistantRenderingModeOrVoiceReplyMode as TelegramVoiceReplyMode);
@@ -245,9 +250,9 @@ export function buildTelegramSettingsMenuReplyMarkup(
245
250
  : (voiceReplyModeOrTimeInjectionMode as TelegramTimeMode);
246
251
  const sectionRegistry = hasRenderingMode
247
252
  ? (sectionRegistryOrVoiceReplyModeConfigured as
248
- | TelegramSectionRegistry
249
- | undefined)
250
- : (timeInjectionModeOrSectionRegistry as TelegramSectionRegistry | undefined);
253
+ TelegramSectionRegistry | undefined)
254
+ : (timeInjectionModeOrSectionRegistry as
255
+ TelegramSectionRegistry | undefined);
251
256
  const effectiveVoiceReplyModeConfigured = hasRenderingMode
252
257
  ? voiceReplyModeConfigured
253
258
  : typeof sectionRegistryOrVoiceReplyModeConfigured === "boolean"
@@ -268,7 +273,10 @@ export function buildTelegramSettingsMenuReplyMarkup(
268
273
  {
269
274
  text: `👄 Voice reply: ${getTelegramSettingsStateValueLabel(
270
275
  getVoiceReplyModeLabel(
271
- getVoiceReplyModeSetting(voiceReplyMode, effectiveVoiceReplyModeConfigured),
276
+ getVoiceReplyModeSetting(
277
+ voiceReplyMode,
278
+ effectiveVoiceReplyModeConfigured,
279
+ ),
272
280
  ),
273
281
  )}`,
274
282
  callback_data: "settings:open:voice-reply",
@@ -361,7 +369,7 @@ export function buildDraftPreviewsSettingsReplyMarkup(
361
369
  },
362
370
  {
363
371
  text: enabled ? "⚫️ Off" : "🟡 Off",
364
- callback_data: "settings:set:draft-previews:off"
372
+ callback_data: "settings:set:draft-previews:off",
365
373
  },
366
374
  ],
367
375
  ],
@@ -407,12 +415,7 @@ export function buildVoiceReplyModeSettingsReplyMarkup(
407
415
  configured = true,
408
416
  ): TelegramSettingsMenuReplyMarkup {
409
417
  const activeMode = getVoiceReplyModeSetting(mode, configured);
410
- const modes: TelegramVoiceReplyModeSetting[] = [
411
- "hidden",
412
- "manual",
413
- "mirror",
414
- "always",
415
- ];
418
+ const modes: TelegramVoiceReplyModeSetting[] = ["hidden", "mirror", "always"];
416
419
  return {
417
420
  inline_keyboard: [
418
421
  [{ text: "⬆️ Back", callback_data: "settings:list" }],
@@ -511,7 +514,10 @@ export async function handleTelegramSettingsMenuCallbackAction(
511
514
  await deps.answerCallbackQuery(callbackQueryId);
512
515
  return true;
513
516
  }
514
- if (data === "settings:open:draft-previews" || data === "settings:open:rich-drafts") {
517
+ if (
518
+ data === "settings:open:draft-previews" ||
519
+ data === "settings:open:rich-drafts"
520
+ ) {
515
521
  await updateDraftPreviewsSettingsMessage(deps);
516
522
  await deps.answerCallbackQuery(callbackQueryId);
517
523
  return true;
@@ -536,12 +542,7 @@ export async function handleTelegramSettingsMenuCallbackAction(
536
542
  }
537
543
  if (data.startsWith("settings:set:voice-reply:")) {
538
544
  const mode = data.slice("settings:set:voice-reply:".length);
539
- if (
540
- mode === "hidden" ||
541
- mode === "manual" ||
542
- mode === "mirror" ||
543
- mode === "always"
544
- ) {
545
+ if (mode === "hidden" || mode === "mirror" || mode === "always") {
545
546
  await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
546
547
  await updateVoiceReplyModeSettingsMessage(deps);
547
548
  await deps.answerCallbackQuery(
@@ -663,79 +664,27 @@ export function createTelegramSettingsMenuRuntime<
663
664
  },
664
665
  sectionRegistry,
665
666
  ),
666
- handleCallbackQuery: async (query) => {
667
+ handleCallbackQuery: async (query, ctx) => {
667
668
  if (!query.data?.startsWith("settings:")) return false;
668
- const state = deps.getStoredModelMenuState(
669
- query.message?.message_id,
670
- query.message?.chat?.id,
671
- );
669
+ const messageId = query.message?.message_id;
670
+ const chatId = query.message?.chat?.id;
671
+ let state = deps.getStoredModelMenuState(messageId, chatId);
672
672
  if (!state) {
673
- const voiceMode = query.data.slice("settings:set:voice-reply:".length);
674
- if (
675
- query.data.startsWith("settings:set:voice-reply:") &&
676
- (voiceMode === "hidden" ||
677
- voiceMode === "manual" ||
678
- voiceMode === "mirror" ||
679
- voiceMode === "always")
680
- ) {
681
- await deps.setVoiceReplyMode(
682
- voiceMode === "hidden" ? undefined : voiceMode,
683
- );
684
- await deps.answerCallbackQuery(
685
- query.id,
686
- `Voice reply mode: ${voiceMode}`,
687
- );
688
- return true;
689
- }
690
- if (
691
- query.data === "settings:set:draft-previews:on" ||
692
- query.data === "settings:set:draft-previews:off" ||
693
- query.data === "settings:set:rich-drafts:on" ||
694
- query.data === "settings:set:rich-drafts:off"
695
- ) {
696
- const enabled = query.data.endsWith(":on");
697
- await deps.setDraftPreviewsEnabled(enabled);
698
- await deps.answerCallbackQuery(
699
- query.id,
700
- `Draft previews ${enabled ? "enabled" : "disabled"}`,
701
- );
702
- return true;
703
- }
704
- if (query.data.startsWith("settings:set:assistant-rendering:")) {
705
- const mode = query.data.slice("settings:set:assistant-rendering:".length);
706
- if (mode === "rich" || mode === "html") {
707
- await deps.setAssistantRenderingMode(mode);
708
- await deps.answerCallbackQuery(query.id, `Rendering: ${mode}`);
709
- return true;
710
- }
711
- }
712
- const hasTimeInjectionPrefix = query.data.startsWith(
713
- "settings:set:time-injection:",
714
- );
715
- const timeMode = hasTimeInjectionPrefix
716
- ? query.data.slice("settings:set:time-injection:".length)
717
- : query.data.slice("settings:set:time:".length);
718
- if (
719
- (hasTimeInjectionPrefix ||
720
- query.data.startsWith("settings:set:time:")) &&
721
- (timeMode === "off" ||
722
- timeMode === "hidden" ||
723
- timeMode === "always" ||
724
- timeMode === "interval")
725
- ) {
726
- const normalizedMode = timeMode === "off" ? "hidden" : timeMode;
727
- await deps.setTimeInjectionMode(normalizedMode);
673
+ if (typeof messageId !== "number" || typeof chatId !== "number") {
728
674
  await deps.answerCallbackQuery(
729
675
  query.id,
730
- `Time injection: ${normalizedMode}`,
676
+ "Interactive message expired.",
731
677
  );
732
678
  return true;
733
679
  }
734
- await deps.answerCallbackQuery(
735
- query.id,
736
- "Interactive message expired.",
680
+ state = await deps.getModelMenuState(
681
+ chatId,
682
+ ctx,
683
+ query.message?.message_thread_id,
737
684
  );
738
- return true;
685
+ state.messageId = messageId;
686
+ state.mode = "settings";
687
+ deps.storeModelMenuState(state);
739
688
  }
740
689
  return handleTelegramSettingsMenuCallbackAction(query.id, query.data, {
741
690
  isProactivePushEnabled: deps.isProactivePushEnabled,
package/lib/prompts.ts CHANGED
@@ -16,7 +16,7 @@ Telegram bridge available. Do not use it from local/TUI prompts unless explicitl
16
16
 
17
17
  const TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX = `
18
18
 
19
- Telegram turn note: If context was compacted or you need the pi-telegram bridge contract, call tool \`telegram_help\`; hidden comments are valid only for explicit \`telegram_voice\` or \`telegram_button\` actions with payload. For voice use a top-level HTML action: \`<!-- telegram_voice: Speak this. -->\`, multiline \`<!-- telegram_voice lang=ru\nSpeak this.\n-->\`, or paired \`<!-- telegram_voice lang=ru -->\nSpeak this.\n<!-- /telegram_voice -->\`.`;
19
+ Telegram turn note: Call \`telegram_help\` if you need the pi-telegram bridge action contract.`;
20
20
 
21
21
  function buildTelegramHelpText(profileName?: string): string {
22
22
  const diagnosticsPaths = getTelegramDiagnosticsDisplayPaths(profileName);
@@ -26,7 +26,8 @@ How to understand Telegram turns:
26
26
  - \`[telegram|thread:name|from:user|guest:group]\` marks Telegram origin and attributes.
27
27
  - \`thread\` is the visible Thread identity in Threaded Mode; it is not a bus role.
28
28
  - \`[reply]\` is quoted context only; act on the user's current instruction.
29
- - \`[attachments]\` are local files; \`[outputs]\` are handler results/transcripts; \`[time]\` is wall-clock context; \`[voice]\` gives reply-mode policy.
29
+ - \`[attachments]\` are local files; \`[outputs]\` are handler results/transcripts; \`[time]\` is wall-clock context.
30
+ - \`[voice] delivery: automatic voice\` means pi-telegram will synthesize ordinary assistant text for this turn; no \`[voice]\` line means no automatic voice policy.
30
31
 
31
32
  How to answer Telegram turns:
32
33
  - Reply in concise, scannable mobile Telegram Rich Markdown.
@@ -41,6 +42,7 @@ Assistant-authored Telegram actions:
41
42
  - Keep the complete action at top level and include a non-empty voice payload.
42
43
  - Keep voice text TTS-friendly; avoid raw Markdown, code, and tables in voice text.
43
44
  - Voice delivery generates and attaches OGG automatically; do not also call \`telegram_attach\` for the same audio.
45
+ - Voice reply modes are compact: \`hidden\` emits no automatic context, \`mirror\` emits it for voice/audio input, and \`always\` emits it for every Telegram turn. Explicit \`telegram_voice\` remains available for an intentionally distinct spoken payload.
44
46
  - Button forms: \`<!-- telegram_button: OK -->\`, \`<!-- telegram_button label=Continue prompt="Continue with the current plan." -->\`, or multiline \`<!-- telegram_button label="Show risks"\nList the main risks first.\n-->\`.
45
47
  - If hidden comments would be the whole reply, add visible text such as \`Choose one:\`.
46
48
 
package/lib/queue.ts CHANGED
@@ -850,14 +850,55 @@ export function createTelegramAgentLifecycleHooks<
850
850
  TReplyMarkup
851
851
  >,
852
852
  ) {
853
+ const onAgentStart = createTelegramAgentStartHook<TTurn, TContext>(deps);
854
+ const deliverAgentEnd = createTelegramAgentEndHook<
855
+ TTurn,
856
+ TContext,
857
+ TMessage,
858
+ TReplyMarkup
859
+ >(deps);
860
+ let retainedErrorEvent: TelegramAgentEndHookEvent<TMessage> | undefined;
853
861
  return {
854
- onAgentStart: createTelegramAgentStartHook<TTurn, TContext>(deps),
855
- onAgentEnd: createTelegramAgentEndHook<
856
- TTurn,
857
- TContext,
858
- TMessage,
859
- TReplyMarkup
860
- >(deps),
862
+ onAgentStart,
863
+ async onAgentEnd(
864
+ event: TelegramAgentEndHookEvent<TMessage>,
865
+ ctx: TContext,
866
+ ): Promise<void> {
867
+ const turn = deps.getActiveTurn();
868
+ const assistant = turn ? deps.extractAssistant(event.messages) : {};
869
+ if (turn && assistant.stopReason === "error") {
870
+ retainedErrorEvent = event;
871
+ deps.recordRuntimeEvent?.(
872
+ "provider-retry",
873
+ new Error("Retained Telegram turn after retryable agent error"),
874
+ { phase: "retained", hasFinalText: !!assistant.text?.trim() },
875
+ );
876
+ return;
877
+ }
878
+ if (retainedErrorEvent) {
879
+ retainedErrorEvent = undefined;
880
+ deps.recordRuntimeEvent?.(
881
+ "provider-retry",
882
+ new Error("Recovered retained Telegram turn after agent retry"),
883
+ { phase: "recovered" },
884
+ );
885
+ }
886
+ await deliverAgentEnd(event, ctx, assistant);
887
+ },
888
+ async onAgentSettled(_event: unknown, ctx: TContext): Promise<void> {
889
+ const event = retainedErrorEvent;
890
+ if (!event) return;
891
+ retainedErrorEvent = undefined;
892
+ deps.recordRuntimeEvent?.(
893
+ "provider-retry",
894
+ new Error("Finalized retained Telegram turn after agent settled"),
895
+ { phase: "settled-failure" },
896
+ );
897
+ await deliverAgentEnd(event, ctx);
898
+ },
899
+ clearRetainedAgentEnd(): void {
900
+ retainedErrorEvent = undefined;
901
+ },
861
902
  ...createTelegramToolExecutionHooks<TContext>(deps),
862
903
  };
863
904
  }
@@ -1132,13 +1173,16 @@ export function createTelegramAgentEndHook<
1132
1173
  return async (
1133
1174
  event: TelegramAgentEndHookEvent<TMessage>,
1134
1175
  ctx: TContext,
1176
+ assistantOverride?: TelegramAgentEndAssistantResult,
1135
1177
  ): Promise<void> => {
1136
1178
  await deps.loadConfig?.();
1137
1179
  if (deps.isSessionActive && !deps.isSessionActive(ctx)) return;
1138
1180
  const turn = deps.getActiveTurn();
1139
1181
  await handleTelegramAgentEndRuntime({
1140
1182
  turn,
1141
- assistant: turn ? deps.extractAssistant(event.messages) : {},
1183
+ assistant:
1184
+ assistantOverride ??
1185
+ (turn ? deps.extractAssistant(event.messages) : {}),
1142
1186
  foldQueuedPromptsIntoHistory: deps.getFoldQueuedPromptsIntoHistory(),
1143
1187
  resetRuntimeState: deps.resetRuntimeState,
1144
1188
  isSessionActive: () => deps.isSessionActive?.(ctx) ?? true,
package/lib/routing.ts CHANGED
@@ -9,7 +9,6 @@ import { basename, dirname } from "node:path";
9
9
  import * as Bus from "./bus.ts";
10
10
  import * as Commands from "./commands.ts";
11
11
  import type { TelegramConfigStore } from "./config.ts";
12
- import type { TelegramSectionRegistry } from "./sections.ts";
13
12
  import type { TelegramInboundHandlerRuntime } from "./inbound.ts";
14
13
  import * as Media from "./media.ts";
15
14
  import * as Menu from "./menu.ts";
@@ -18,12 +17,13 @@ import * as OutboundHandlers from "./outbound.ts";
18
17
  import * as PromptTemplates from "./prompt-templates.ts";
19
18
  import * as Queue from "./queue.ts";
20
19
  import type { TelegramBridgeRuntime } from "./runtime.ts";
20
+ import type { TelegramSectionRegistry } from "./sections.ts";
21
21
  import * as TextGroups from "./text-groups.ts";
22
+ import * as ThreadReconciler from "./thread-reconciler.ts";
22
23
  import type {
23
24
  TelegramInstanceThreadIdentityCandidate,
24
25
  TelegramTopicTargetRecord,
25
26
  } from "./threads.ts";
26
- import * as ThreadReconciler from "./thread-reconciler.ts";
27
27
  import * as Turns from "./turns.ts";
28
28
 
29
29
  interface TelegramPromptPeerView {
@@ -408,10 +408,10 @@ function formatTelegramUnboundRerouteChooserText(
408
408
  : [formatTelegramUnboundTopicGuidance(), "", rerouteText].join("\n");
409
409
  }
410
410
 
411
- import { getTelegramVoiceReplyMode } from "./voice.ts";
412
- import type { TelegramUser } from "./updates.ts";
413
411
  import * as Threads from "./threads.ts";
412
+ import type { TelegramUser } from "./updates.ts";
414
413
  import * as Updates from "./updates.ts";
414
+ import { getTelegramVoiceReplyMode } from "./voice.ts";
415
415
 
416
416
  async function deleteReservedTelegramTopicThroughReconciler(
417
417
  deps: {
@@ -1524,13 +1524,8 @@ export function createTelegramInboundRouteRuntime<
1524
1524
  resolveTimeLine: deps.resolveTimeLine,
1525
1525
  getAllowedUserId: deps.configStore.getAllowedUserId,
1526
1526
 
1527
- // Voice policy for the current turn. Missing config still behaves as manual,
1528
- // but only explicit telegram.json voice.replyMode is shown in prompt context.
1527
+ // Voice policy resolves missing, invalid, and legacy manual config to hidden.
1529
1528
  getVoiceReplyMode: () => getTelegramVoiceReplyMode(deps.configStore.get()),
1530
- isVoiceReplyModeConfigured: () => {
1531
- const mode = deps.configStore.get().voice?.replyMode;
1532
- return mode === "manual" || mode === "mirror" || mode === "always";
1533
- },
1534
1529
  getTelegramThreadLabel(message) {
1535
1530
  if (!deps.threadStore) return undefined;
1536
1531
  const chatId = message.chat.id;
@@ -2490,7 +2485,9 @@ export interface TelegramAssistantOutputAuthorityRuntime<TTransportStamp> {
2490
2485
  canDeliver: () => boolean;
2491
2486
  }
2492
2487
 
2493
- export function createTelegramAssistantOutputAuthorityRuntime<TTransportStamp>(deps: {
2488
+ export function createTelegramAssistantOutputAuthorityRuntime<
2489
+ TTransportStamp,
2490
+ >(deps: {
2494
2491
  getPreferredTarget: () => Queue.TelegramQueueTarget | undefined;
2495
2492
  getFallbackChatId: () => number | undefined;
2496
2493
  getTransportStamp: () => TTransportStamp;
@@ -2509,9 +2506,7 @@ export function createTelegramAssistantOutputAuthorityRuntime<TTransportStamp>(d
2509
2506
  return {
2510
2507
  captureAuthority() {
2511
2508
  const target = getCurrentTarget();
2512
- const directEpoch = deps.ownsDirect()
2513
- ? deps.getDirectEpoch()
2514
- : undefined;
2509
+ const directEpoch = deps.ownsDirect() ? deps.getDirectEpoch() : undefined;
2515
2510
  const followerGeneration = deps.isFollowerRegistered()
2516
2511
  ? deps.getFollowerGeneration()
2517
2512
  : undefined;
@@ -2540,8 +2535,7 @@ export function createTelegramAssistantOutputAuthorityRuntime<TTransportStamp>(d
2540
2535
  }
2541
2536
  if (authority.route === "direct") {
2542
2537
  return (
2543
- deps.ownsDirect() &&
2544
- deps.getDirectEpoch() === authority.directEpoch
2538
+ deps.ownsDirect() && deps.getDirectEpoch() === authority.directEpoch
2545
2539
  );
2546
2540
  }
2547
2541
  if (authority.route === "follower") {
package/lib/status.ts CHANGED
@@ -767,7 +767,7 @@ export function buildTelegramStatusBarText(
767
767
  ): string {
768
768
  const label = theme.fg("accent", getTelegramStatusBarLabel(state));
769
769
  if (state.error) {
770
- return `${label} ${theme.fg("error", "error")} ${theme.fg("muted", state.error)}`;
770
+ return `${label} ${theme.fg("error", "error")}`;
771
771
  }
772
772
  const queued = state.queuedStatus
773
773
  ? theme.fg("success", state.queuedStatus)
@@ -39,7 +39,7 @@ export interface TelegramTextGroupState<TMessage, TContext = unknown> {
39
39
  suspended?: boolean;
40
40
  reschedule?: (delayMs?: number) => void;
41
41
  dispatchLimit?: number;
42
- forwardCommentCandidate?: boolean;
42
+ forwardPairCandidate?: TelegramForwardCommentBatchPosition;
43
43
  }
44
44
 
45
45
  export type TelegramForwardCommentBatchPosition = "comment" | "forward";
@@ -131,7 +131,12 @@ function getTelegramTextGroupKey(
131
131
  ): string | undefined {
132
132
  if (message.media_group_id) return undefined;
133
133
  if (!message.from || message.from.is_bot) return undefined;
134
- if (!extractTelegramTextGroupText(message)) return undefined;
134
+ if (
135
+ !extractTelegramTextGroupText(message) &&
136
+ !isTelegramForwardedMessage(message)
137
+ ) {
138
+ return undefined;
139
+ }
135
140
  const threadKey =
136
141
  typeof message.message_thread_id === "number"
137
142
  ? `thread:${message.message_thread_id}`
@@ -160,7 +165,7 @@ function canAppendTelegramTextGroupMessage<
160
165
  message.message_id > previous.message_id &&
161
166
  message.message_id <=
162
167
  previous.message_id + TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP &&
163
- text.length > 0 &&
168
+ (text.length > 0 || isTelegramForwardedMessage(message)) &&
164
169
  !isTelegramTextGroupCommand(text)
165
170
  );
166
171
  }
@@ -182,7 +187,7 @@ export function queueTelegramTextGroupMessage<
182
187
  ) => unknown | Promise<unknown>;
183
188
  forceStart?: boolean;
184
189
  dispatchImmediately?: boolean;
185
- forwardCommentCandidate?: boolean;
190
+ forwardPairCandidate?: TelegramForwardCommentBatchPosition;
186
191
  delayMs?: number;
187
192
  }): boolean {
188
193
  const key = getTelegramTextGroupKey(options.message);
@@ -202,7 +207,7 @@ export function queueTelegramTextGroupMessage<
202
207
  const state = existing ?? { messages: [] };
203
208
  state.messages.push(options.message);
204
209
  state.context = options.context;
205
- state.forwardCommentCandidate = options.forwardCommentCandidate;
210
+ state.forwardPairCandidate = options.forwardPairCandidate;
206
211
  const dispatchQueued = (): void => {
207
212
  state.flushTimer = undefined;
208
213
  const queued = options.groups.get(key);
@@ -335,21 +340,30 @@ export function createTelegramTextGroupController<
335
340
  if (existing.flushTimer) clearTimer(existing.flushTimer);
336
341
  groups.delete(key!);
337
342
  }
343
+ const candidatePosition: TelegramForwardCommentBatchPosition = forwarded
344
+ ? "forward"
345
+ : "comment";
346
+ const existingCandidate = existing?.forwardPairCandidate;
347
+ const pairCompleted =
348
+ existingCandidate !== undefined &&
349
+ existingCandidate !== candidatePosition;
338
350
  const separateFromCandidate =
339
- !!existing?.forwardCommentCandidate &&
340
- !forwarded &&
351
+ existingCandidate === candidatePosition &&
341
352
  !isTelegramTextGroupCommand(text);
342
- if (separateFromCandidate) {
353
+ if (separateFromCandidate && existing) {
343
354
  existing.dispatchLimit = existing.messages.length;
344
355
  }
345
356
  const forceStart =
346
357
  plannedStart ||
347
358
  (forwardCommentWaitMs !== false &&
348
- !forwarded &&
349
359
  !!key &&
350
- typeof message.text === "string" &&
351
- !isTelegramTextGroupCommand(extractTelegramTextGroupText(message)));
360
+ (forwarded ||
361
+ (typeof message.text === "string" &&
362
+ !isTelegramTextGroupCommand(
363
+ extractTelegramTextGroupText(message),
364
+ ))));
352
365
  const dispatchImmediately =
366
+ pairCompleted ||
353
367
  separateFromCandidate ||
354
368
  plannedForwardCommentEnds.delete(identity) ||
355
369
  (forwarded && !!key && groups.has(key));
@@ -364,10 +378,10 @@ export function createTelegramTextGroupController<
364
378
  dispatchMessages,
365
379
  forceStart,
366
380
  dispatchImmediately,
367
- forwardCommentCandidate:
368
- forceStart &&
369
- !forwarded &&
370
- !canStartTelegramTextGroup(message, minSplitLength),
381
+ forwardPairCandidate:
382
+ forceStart && !canStartTelegramTextGroup(message, minSplitLength)
383
+ ? candidatePosition
384
+ : undefined,
371
385
  delayMs:
372
386
  forceStart && !canStartTelegramTextGroup(message, minSplitLength)
373
387
  ? forwardCommentWaitMs === false
package/lib/turns.ts CHANGED
@@ -13,9 +13,9 @@ import {
13
13
  collectTelegramMessageIds,
14
14
  downloadTelegramMessageFiles,
15
15
  extractTelegramForwardContextText,
16
- extractTelegramMessageText,
17
16
  extractTelegramMessagesPromptText,
18
17
  extractTelegramMessagesText,
18
+ extractTelegramMessageText,
19
19
  formatTelegramHistoryText,
20
20
  guessMediaType,
21
21
  type DownloadedTelegramMessageFile,
@@ -59,14 +59,19 @@ export interface TelegramTurnMessage {
59
59
 
60
60
  export type DownloadedTelegramTurnFile = DownloadedTelegramMessageFile;
61
61
 
62
- function getTelegramTurnTarget(message: TelegramTurnMessage): TelegramTurnTarget {
62
+ function getTelegramTurnTarget(
63
+ message: TelegramTurnMessage,
64
+ ): TelegramTurnTarget {
63
65
  return Number.isInteger(message.message_thread_id)
64
66
  ? { chatId: message.chat.id, threadId: message.message_thread_id }
65
67
  : { chatId: message.chat.id };
66
68
  }
67
69
 
68
70
  function formatTelegramPrefixAttributeValue(value: string): string {
69
- return value.replace(/[\]\n\r|]+/g, " ").replace(/\s+/g, " ").trim();
71
+ return value
72
+ .replace(/[\]\n\r|]+/g, " ")
73
+ .replace(/\s+/g, " ")
74
+ .trim();
70
75
  }
71
76
 
72
77
  export function createTelegramTurnPrefix(
@@ -142,7 +147,6 @@ function appendTelegramSourceContext(
142
147
  return text ? `${text}\n\n${sourceContext}` : sourceContext;
143
148
  }
144
149
 
145
-
146
150
  function buildTelegramForwardContextBlock(options: {
147
151
  context: string;
148
152
  text: string;
@@ -411,7 +415,6 @@ export interface BuildTelegramPromptTurnOptions {
411
415
  readBinaryFile: (path: string) => Promise<Uint8Array>;
412
416
  inferImageMimeType: (path: string) => string | undefined;
413
417
  voiceReplyMode?: TelegramVoiceReplyMode;
414
- voiceReplyModeConfigured?: boolean;
415
418
  voicePromptContribution?: string;
416
419
  }
417
420
 
@@ -435,9 +438,11 @@ export interface TelegramPromptTurnRuntimeBuilderDeps<
435
438
  }>;
436
439
  resolveTimeLine?: (chatId: number) => string | null;
437
440
  getVoiceReplyMode?: () => TelegramVoiceReplyMode;
438
- isVoiceReplyModeConfigured?: () => boolean;
439
441
  /** Returns the visible thread label for a message target, used to add thread context to the prompt prefix. */
440
- getTelegramThreadLabel?: (message: { chat: { id: number }; message_thread_id?: number }) => string | undefined;
442
+ getTelegramThreadLabel?: (message: {
443
+ chat: { id: number };
444
+ message_thread_id?: number;
445
+ }) => string | undefined;
441
446
  getAllowedUserId?: () => number | undefined;
442
447
  }
443
448
 
@@ -475,9 +480,7 @@ export function createTelegramPromptTurnRuntimeBuilder<
475
480
  text: extractTelegramMessageText(message),
476
481
  message,
477
482
  fileNames: new Set(
478
- collectTelegramFileInfos(message.rich_message ? [message] : []).map(
479
- (file) => file.fileName,
480
- ),
483
+ collectTelegramFileInfos([message]).map((file) => file.fileName),
481
484
  ),
482
485
  },
483
486
  ]
@@ -567,7 +570,6 @@ export function createTelegramPromptTurnRuntimeBuilder<
567
570
  timeLine,
568
571
  inferImageMimeType: guessMediaType,
569
572
  voiceReplyMode,
570
- voiceReplyModeConfigured: deps.isVoiceReplyModeConfigured?.(),
571
573
  voicePromptContribution: computeVoicePromptContribution(
572
574
  voiceReplyMode,
573
575
  files,
@@ -581,9 +583,13 @@ function getTelegramVoicePromptContext(
581
583
  voiceReplyMode: TelegramVoiceReplyMode,
582
584
  hasVoiceFile: boolean,
583
585
  ): Record<string, string> | undefined {
584
- if (voiceReplyMode === "always") return { "reply mode": "always" };
585
- if (!hasVoiceFile) return undefined;
586
- return { "reply mode": voiceReplyMode };
586
+ if (
587
+ voiceReplyMode !== "always" &&
588
+ !(voiceReplyMode === "mirror" && hasVoiceFile)
589
+ ) {
590
+ return undefined;
591
+ }
592
+ return { delivery: "automatic voice" };
587
593
  }
588
594
 
589
595
  export async function buildTelegramPromptTurn(
@@ -597,8 +603,6 @@ export async function buildTelegramPromptTurn(
597
603
  (f) => f.kind === "voice" || f.kind === "audio",
598
604
  );
599
605
  const voiceReplyMode = options.voiceReplyMode ?? getTelegramVoiceReplyMode();
600
- const showVoiceContext =
601
- options.voiceReplyModeConfigured ?? options.voiceReplyMode !== undefined;
602
606
  const content: TelegramPromptContent[] = [
603
607
  {
604
608
  type: "text",
@@ -615,9 +619,10 @@ export async function buildTelegramPromptTurn(
615
619
  sourceContext: options.sourceContext,
616
620
  historyTurns: options.historyTurns,
617
621
  timeLine: options.timeLine,
618
- voiceContext: showVoiceContext
619
- ? getTelegramVoicePromptContext(voiceReplyMode, hasVoiceFile)
620
- : undefined,
622
+ voiceContext: getTelegramVoicePromptContext(
623
+ voiceReplyMode,
624
+ hasVoiceFile,
625
+ ),
621
626
  }),
622
627
  },
623
628
  ];
@@ -634,8 +639,7 @@ export async function buildTelegramPromptTurn(
634
639
  }
635
640
  if (options.voicePromptContribution?.trim()) {
636
641
  const textItem = content.find((c) => c.type === "text") as
637
- | { type: "text"; text: string }
638
- | undefined;
642
+ { type: "text"; text: string } | undefined;
639
643
  if (textItem) {
640
644
  textItem.text = `${textItem.text}\n\n${options.voicePromptContribution.trim()}`;
641
645
  }
package/lib/voice.ts CHANGED
@@ -38,7 +38,7 @@ function getNextAvailableProviderId<T>(
38
38
  return id;
39
39
  }
40
40
 
41
- export type TelegramVoiceReplyMode = "mirror" | "always" | "manual";
41
+ export type TelegramVoiceReplyMode = "hidden" | "mirror" | "always";
42
42
 
43
43
  export type TelegramVoiceSynthesisProviderResult =
44
44
  | string
@@ -67,9 +67,7 @@ export interface TelegramVoiceSynthesisProvider {
67
67
  }
68
68
 
69
69
  export type TelegramVoiceTranscriptionProviderResult =
70
- | string
71
- | { text: string; language?: string }
72
- | undefined;
70
+ string | { text: string; language?: string } | undefined;
73
71
 
74
72
  export interface TelegramVoiceTranscriptionFile {
75
73
  path: string;
@@ -217,16 +215,17 @@ export function clearTelegramVoiceTranscriptionProviders(): void {
217
215
  // --- Voice Reply Modes ---
218
216
 
219
217
  export const TELEGRAM_VOICE_REPLY_MODES = [
218
+ "hidden",
220
219
  "mirror",
221
220
  "always",
222
- "manual",
223
221
  ] as const;
224
222
 
225
223
  /**
226
224
  * Returns the active voice reply mode for the current session.
227
225
  *
228
226
  * Pi-telegram owns reply-mode policy through telegram.json. If
229
- * config.voice.replyMode is missing or invalid, the safe default is manual.
227
+ * config.voice.replyMode is missing, invalid, or legacy `manual`, the effective
228
+ * mode is hidden.
230
229
  */
231
230
  export function getTelegramVoiceReplyMode(config?: {
232
231
  voice?: { replyMode?: string };
@@ -238,7 +237,7 @@ export function getTelegramVoiceReplyMode(config?: {
238
237
  ) {
239
238
  return configMode as TelegramVoiceReplyMode;
240
239
  }
241
- return "manual";
240
+ return "hidden";
242
241
  }
243
242
 
244
243
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.23.1",
3
+ "version": "0.23.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"