@llblab/pi-kit 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -0
  4. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +17 -0
  5. package/node_modules/@llblab/pi-telegram/README.md +3 -3
  6. package/node_modules/@llblab/pi-telegram/api/voice.ts +0 -1
  7. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -5
  8. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +2 -2
  9. package/node_modules/@llblab/pi-telegram/docs/outbound.md +2 -3
  10. package/node_modules/@llblab/pi-telegram/docs/public-api.md +5 -15
  11. package/node_modules/@llblab/pi-telegram/docs/voice.md +9 -37
  12. package/node_modules/@llblab/pi-telegram/index.ts +30 -7
  13. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +112 -25
  14. package/node_modules/@llblab/pi-telegram/lib/config.ts +38 -46
  15. package/node_modules/@llblab/pi-telegram/lib/journal.ts +107 -2
  16. package/node_modules/@llblab/pi-telegram/lib/menu-model.ts +4 -2
  17. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +15 -11
  18. package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +3 -18
  19. package/node_modules/@llblab/pi-telegram/lib/polling.ts +66 -24
  20. package/node_modules/@llblab/pi-telegram/lib/setup.ts +0 -1
  21. package/node_modules/@llblab/pi-telegram/lib/status.ts +2 -2
  22. package/node_modules/@llblab/pi-telegram/lib/updates.ts +11 -4
  23. package/node_modules/@llblab/pi-telegram/lib/voice.ts +7 -31
  24. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  25. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +1 -1
  26. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.1.9 - 2026-08-24
6
+
7
+ - `Telegram Release`: Advances `@llblab/pi-telegram` to `0.38.0`, presenting the default voice mode as `manual`, retaining legacy `hidden` compatibility, and removing the redundant transcript-caption configuration and provider contract.
8
+
9
+ ## 0.1.8 - 2026-08-23
10
+
11
+ - `Telegram Releases`: Advances `@llblab/pi-telegram` to `0.37.1`, bringing journal-owned polling admission state from `0.37.0` plus the settings-manager compatibility hotfix for `/start`, model, and queue menus.
12
+ - `Release Verification`: Allows up to ten minutes for Trusted Publisher releases to become publicly readable before package identity and bundled inventory verification, avoiding false failures from npm registry propagation delay.
13
+
5
14
  ## 0.1.7 - 2026-08-22
6
15
 
7
16
  - `Telegram Hotfix`: Advances `@llblab/pi-telegram` to `0.36.11`, bringing at-most-once durable prompt dispatch that prevents session or process replacement from automatically replaying an already-admitted Telegram prompt.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  | `@llblab/pi-actors` | `0.50.0` | Extension and Skills |
10
10
  | `@llblab/pi-codex-usage` | `0.9.3` | Extension |
11
11
  | `@llblab/pi-grow-loop` | `0.7.2` | Extension and Skills |
12
- | `@llblab/pi-telegram` | `0.36.11` | Extension and Skills |
12
+ | `@llblab/pi-telegram` | `0.38.0` | Extension and Skills |
13
13
 
14
14
  Versions are exact by design. Updating an extension does not change an installed kit until this repository explicitly advances that dependency and publishes a new kit version.
15
15
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  _This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
4
4
 
5
+ - [ ] `Coherent voice reply policy`: Release the validated `manual`, `mirror`, and `always` policy cleanup as `0.38.0`, then reconcile issue #235 and open exact migration issues for `johnlam1968/pi-voice-telegram` and `luxus/pi-xai-voice`, whose TTS providers still consume the removed transcript-caption API.
5
6
  - [ ] `Operator-confirmed pairing`: Replace first-contact automatic ownership with an explicit pending request that the trusted Pi interface can allow or reject before `allowedUserId` is persisted or any Telegram prompt executes; retain manual preconfiguration, bound pending-request lifetime and multiplicity, fail closed across session/transport replacement, and cover unauthorized-first-contact races.
6
7
  - [ ] `Environment-backed bot tokens`: Let each profile store an exact Pi-native `$ENV_VAR` or `${ENV_VAR}` reference in `telegram.json` instead of copying the resolved secret; preserve the originating alias when `/telegram-setup` prefills from a supported environment variable, resolve only at validation/activation boundaries, fail closed with a redacted named-variable diagnostic when unresolved, retain literal-token compatibility, and cover named profiles, reload, persistence, status, and secret-redaction paths.
7
8
  - [ ] [`Inference bypass Generative Apps`](./docs/generative-apps.md): Let the agent install and bind one managed JavaScript app owner for a generated-prompt prefix so deterministic controls can bypass model inference without creating another button grammar.
@@ -2,6 +2,23 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.38.0: Coherent Voice Reply Policy
6
+
7
+ - `Voice Reply Policy`: Renames the default user-facing `hidden` mode to `manual` while retaining `hidden` as a read-only configuration and callback compatibility alias. `manual`, `mirror`, and `always` remain the complete policy set: explicit `telegram_voice`, modality mirroring, or automatic voice replacement.
8
+ - `Voice Provider Contract`: Removes the redundant `voice.sendTranscript` configuration, `getTelegramVoiceSendTranscript()` public helper, provider-returned `transcriptText`, and voice-caption path. Synthesis providers now return only an OGG/Opus path or `undefined`; text-plus-voice remains an explicit agent composition rather than a parallel automatic policy.
9
+
10
+ ## 0.37.2: Follower Recovery Delivery Hotfix
11
+
12
+ - `Follower Recovery Delivery`: Holds follower Bot API calls behind a bounded registration wait when heartbeat recovery temporarily clears local authority, then sends once with the restored exact generation. Calls still fail closed if registration is not restored, and acknowledgement ambiguity remains non-retryable.
13
+
14
+ ## 0.37.1: Settings Manager Compatibility Hotfix
15
+
16
+ - `Menu Compatibility`: Keeps `/start`, model, and queue menu rendering compatible with Pi settings-manager implementations that do not expose `reload()`. Hosts with reload retain explicit refresh behavior; other hosts use the freshly constructed settings snapshot instead of failing with `settingsManager.reload is not a function`.
17
+
18
+ ## 0.37.0: Journal-Owned Telegram Admission
19
+
20
+ - `Configuration-Only State`: Moves the per-profile polling cursor out of `telegram.json` into atomic durable journal revisions, including cursor-only initial sync, compaction, reconstruction, and recovery. A journal-first one-shot cutover removes legacy config state idempotently, exact bot/profile fences remain enforced, status reads journal authority, and unsafe downgrade now fails closed.
21
+
5
22
  ## 0.36.11: At-Most-Once Prompt Dispatch Hotfix
6
23
 
7
24
  - `At-Most-Once Prompt Dispatch`: Commits each exact durable Telegram receipt synchronously before Pi model admission and blocks dispatch when that commit fails, closing the session/process-replacement replay window that could deliver an already-processed old prompt again; the narrow commit-before-admission crash boundary now favors no duplicate over retry.
@@ -130,7 +130,7 @@ Enable the optional capabilities the bridge needs in the [@BotFather](https://t.
130
130
  | 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. |
131
131
  | Outbound artifacts | Return generated files through `telegram_attach` during active turns or explicit direct delivery. | Agents send real artifacts as files, not pasted blobs. |
132
132
  | Voice input | Route audio through configured command-template handlers, programmatic handlers, or STT providers. | Voice notes become usable prompt context. |
133
- | 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. |
133
+ | Voice output | Choose `manual`, `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. |
134
134
  | Buttons | Turn top-level `telegram_button` comments into inline buttons. | Assistant-authored choices become native Telegram interactions. |
135
135
  | Generative Apps | Install or explicitly replace a reviewed `.mjs` application whose generated JSON button view may mix direct `app::method` actions with ordinary model prompts. | Repeated games, controls, tutors, and adapters compile routine interaction without losing selective model interpretation, explanation, or adaptation. |
136
136
  | Callback routing | Route known callbacks to the owner extension and unknown callbacks back into Pi. | Companion extensions can build UI without polling Telegram themselves. |
@@ -218,7 +218,7 @@ Inbound files land under `<agent-dir>/tmp/telegram` and default to a 50 MiB limi
218
218
 
219
219
  ### Voice And Media
220
220
 
221
- Voice notes, audio, images, PDFs, and other media can pass through configured inbound handlers, programmatic handlers, or registered STT providers. Outbound voice can use configured `outboundHandlers` or registered TTS providers; `pi-telegram` owns reply policy and Telegram transport, while providers own synthesis. Configure provider-neutral local/API pipelines and ordered fallbacks through [`telegram.json` command templates](./docs/voice.md#choose-an-integration-path). The default `hidden` reply mode still supports intentional voice delivery through explicit `telegram_voice` actions; `mirror` and `always` add automatic voice policy. Explicit actions accept either a JSON object or compact double-quoted attributes, with equivalent `text` and `value` payload keys and one colon-free action marker.
221
+ Voice notes, audio, images, PDFs, and other media can pass through configured inbound handlers, programmatic handlers, or registered STT providers. Outbound voice can use configured `outboundHandlers` or registered TTS providers; `pi-telegram` owns reply policy and Telegram transport, while providers own synthesis. Configure provider-neutral local/API pipelines and ordered fallbacks through [`telegram.json` command templates](./docs/voice.md#choose-an-integration-path). The default `manual` reply mode still supports intentional voice delivery through explicit `telegram_voice` actions; `mirror` and `always` add automatic voice policy. Explicit actions accept either a JSON object or compact double-quoted attributes, with equivalent `text` and `value` payload keys and one colon-free action marker.
222
222
 
223
223
  ### Buttons And Callbacks
224
224
 
@@ -274,7 +274,7 @@ Stable public entrypoints are documented in [Public API](./docs/public-api.md),
274
274
 
275
275
  ## Safety Boundaries
276
276
 
277
- Durable inbound admission is a **process-crash recovery** guarantee. Atomic private-file replacement preserves acknowledged journal authority across ordinary process exit, crash, kill, and replacement, but the extension does not flush files or parent directories for host/kernel/filesystem/device/power-loss durability. Keep `~/.pi/agent` on appropriately managed storage and backups if that stronger operational guarantee is required. Before downgrading below `0.28.0`, run `node scripts/check-downgrade.mjs`; a blocked result means `0.28.x` must drain the retained journal first. See [Durable Admission And Recovery](./docs/architecture.md#durable-admission-and-recovery).
277
+ Durable inbound admission is a **process-crash recovery** guarantee. Atomic private-file replacement preserves acknowledged journal authority and its journal-owned `acceptedThroughUpdateId` polling cursor across ordinary process exit, crash, kill, and replacement, but the extension does not flush files or parent directories for host/kernel/filesystem/device/power-loss durability. `telegram.json` contains configuration only. Keep `~/.pi/agent` on appropriately managed storage and backups if that stronger operational guarantee is required. Before downgrading below `0.37.0`, run `node scripts/check-downgrade.mjs`; any retained cursor-schema journal blocks downgrade because an older runtime could repoll admitted updates. See [Durable Admission And Recovery](./docs/architecture.md#durable-admission-and-recovery).
278
278
 
279
279
  `pi-telegram` intentionally does not:
280
280
 
@@ -9,7 +9,6 @@ export {
9
9
  computeVoicePromptContribution,
10
10
  computeVoiceTurnFlags,
11
11
  getTelegramVoiceReplyMode,
12
- getTelegramVoiceSendTranscript,
13
12
  isVoiceTurn,
14
13
  registerTelegramVoiceSynthesisProvider,
15
14
  registerTelegramVoiceTranscriptionProvider,
@@ -116,9 +116,9 @@ Mirrored domain regressions live in `/tests/*.test.ts`. Shared test fixtures sho
116
116
 
117
117
  ## Configuration And Ownership
118
118
 
119
- Telegram configuration lives in `~/.pi/agent/telegram.json`. Bot/session identity (`botToken`, `botUsername`, `botId`, `allowedUserId`, `lastUpdateId`) persists only under `profiles.default` or `profiles.<name>`; shared handlers and assistant/voice/time settings stay top-level. Authoritative transport ownership lives separately in the pi-telegram-private `~/.pi/agent/tmp/telegram/owners.json` store. Its top-level slots are `default` and validated named profile names; unrelated extensions never read or write this file.
119
+ Telegram configuration lives in `~/.pi/agent/telegram.json`. Bot/session identity (`botToken`, `botUsername`, `botId`, `allowedUserId`) persists only under `profiles.default` or `profiles.<name>`; shared handlers and assistant/voice/time settings stay top-level. Per-profile polling/admission state lives only in the durable update journal as `acceptedThroughUpdateId`. Authoritative transport ownership lives separately in the pi-telegram-private `~/.pi/agent/tmp/telegram/owners.json` store. Its top-level slots are `default` and validated named profile names; unrelated extensions never read or write this file.
120
120
 
121
- `telegram.json` is one global cross-instance configuration document. Ordinary reads rely on atomic publication and do not take the mutation guard. Every cooperating Pi instance persists only its recursive delta from the snapshot it loaded, merges that delta into the latest disk document inside `telegram.json.transaction`, and publishes atomically only when the semantic result differs; a no-op merge adopts the newer disk snapshot in memory without replacing the file. Unrelated global and profile changes therefore survive stale writers, while `lastUpdateId` additionally merges monotonically. Two serialized writers changing the same leaf use commit order, so the later local delta wins. A non-transactional external editor cannot participate in that conflict protocol: it should write through same-directory atomic replacement while Pi is idle, then let instances reload; an editor racing the transaction may lose its same-leaf change and must retry from the resulting file.
121
+ `telegram.json` is one global cross-instance configuration document. Ordinary reads rely on atomic publication and do not take the mutation guard. Every cooperating Pi instance persists only its recursive delta from the snapshot it loaded, merges that delta into the latest disk document inside `telegram.json.transaction`, and publishes atomically only when the semantic result differs; a no-op merge adopts the newer disk snapshot in memory without replacing the file. Unrelated global and profile changes therefore survive stale writers. Two serialized writers changing the same leaf use commit order, so the later local delta wins. A non-transactional external editor cannot participate in that conflict protocol: it should write through same-directory atomic replacement while Pi is idle, then let instances reload; an editor racing the transaction may lose its same-leaf change and must retry from the resulting file.
122
122
 
123
123
  ### Setup Flow
124
124
 
@@ -161,9 +161,9 @@ Version `0.24.0` intentionally does not read or migrate the former agent-level `
161
161
 
162
162
  Telegram private-chat Threaded Mode is the public switch for multi-instance Telegram operation. Classic single-DM polling is the base mode. When Telegram private-chat threads are available for the bot, the bridge enables the local leader/follower bus automatically; when threads are unavailable or later disabled, the bridge returns to classic single-DM polling as a first-class mode. Before a non-owner `/telegram-connect` chooses follower registration or singleton takeover, it discards process-local status/capability projections and reads the current owner-published mode: `enabled` registers a follower without a takeover prompt, while `disabled` uses the classic confirmation flow.
163
163
 
164
- Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes the `owners.json` slot, diagnostics logs, state files, thread/bus ownership, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the `default` slot. A different selected profile is a parallel bot runtime: its owner slot, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and other named profiles while shared bridge settings remain top-level/global.
164
+ Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`) and scopes its durable journal cursor, the `owners.json` slot, diagnostics logs, state files, thread/bus ownership, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the `default` slot. A different selected profile is a parallel bot runtime: its owner slot, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and other named profiles while shared bridge settings remain top-level/global.
165
165
 
166
- Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so named-profile offsets and unrelated global/profile updates do not stale-replace one another. Polling publishes only its monotonic `lastUpdateId` into the current config-store snapshot; it never persists the detached full config object captured at poll start, which would interpret newer Settings fields as deletions. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup recognizes only those UUID-prefixed scratch files. It never age-deletes journals, ownership, state, logs, or other top-level runtime files and therefore cannot redirect live traffic or orphan immutable journal segments.
166
+ Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so unrelated global/profile updates do not stale-replace one another. Each profile's journal transaction independently publishes its monotonic admission cursor together with admitted work; polling never writes runtime state through config persistence. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup recognizes only those UUID-prefixed scratch files. It never age-deletes journals, ownership, state, logs, or other top-level runtime files and therefore cannot redirect live traffic or orphan immutable journal segments.
167
167
 
168
168
  When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Follower liveness is intentionally fast because heartbeat traffic is local IPC: followers heartbeat every `1s`, the leader treats them as stale after `2s`, and the prune loop runs every `1s` so stopped followers are detected promptly while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Pruning alone preserves the binding; when Thread cleanup is enabled, only a subsequent OS check that confirms the exact registered PID absent may create fenced cleanup intent, and that cleanup serializes ahead of replacement registration. Successful follower target reuse refreshes the binding's recovery timestamp. Absent follower bindings remain durable restoration hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; process absence and heartbeat pruning alone do not remove them. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Bot capability monitoring does not probe through the bus until the process either owns that direct lock or has completed authenticated follower registration. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
169
169
 
@@ -240,7 +240,7 @@ The canonical update transition contract is:
240
240
 
241
241
  The worker executes at most 64 eligible entries from one validated journal snapshot, commits ordinary completions through one journal transaction, then yields through a generation-checked event-loop boundary. Retry, queue, or prior-generation boundaries first flush completed ids and force a fresh snapshot, preserving exact state-transition atomicity without per-entry parse/rewrite churn. A deterministic 2,048-entry stress gate requires exactly 32 completion publications, 33 reads including the final empty snapshot, continued 1ms timer progress, and less than 250ms maximum observed heartbeat delay. Byte-capacity tests cover failed and retry-wait diagnostics, queue receipt/owner and handoff metadata, and operator dispositions; every rejected growth leaves the prior authority bytes unchanged. It still scans later independent entries after retry or terminal persistence. An unresolved reaction remains a queue-mutation dependency even in `retry-wait` or `failed`, but dispatch checks that dependency against the candidate queue item's exact chat and source message ids instead of globally blocking unrelated targets. Successful replay or an exact discard disposition releases the dependency. Worker state, debug status, state snapshots, and redacted runtime events expose journal depth, retry/terminal counts, the next retry, latest terminal identity, copyable operator commands, and the exact first foreign queued owner identity (instance, PID/birth, session, and acquisition) when semantic authority belongs to another process.
242
242
 
243
- Upgrades create journals lazily before the first post-upgrade offset advance. A bot/profile identity change with unresolved authority fails closed. Once reconstructed authority is empty, the next read atomically rebinds profile and bot identity under the journal transaction and removes redundant old-identity segments best-effort; stable-`botId` token rotation remains valid even with entries. Downgrading below `0.28.0` with a non-empty journal is unsafe because the older runtime cannot drain updates whose Telegram offsets already advanced. Run `node scripts/check-downgrade.mjs [agent-dir]`; a blocked result requires draining with a compatible `0.28.x` runtime, while a safe result confirms all reconstructed journal authority is empty before downgrade. Runtime state from an older release must recover without deleting `telegram.json`, ownership state, or journal authority.
243
+ The journal is the sole polling/admission authority. Each atomic journal revision publishes the admitted batch and monotonic `acceptedThroughUpdateId` together; cursor-only initial synchronization uses an empty batch revision. Existing config cursors are transferred once before polling: journal publication precedes config removal, restart retries are idempotent, established journal authority never regresses, and an unprovable non-empty journal fails closed. Upgrades create journals lazily before the first post-upgrade offset advance. A bot/profile identity change with unresolved authority fails closed. Once reconstructed authority is empty, the next read atomically rebinds profile and bot identity under the journal transaction and removes redundant old-identity segments best-effort; stable-`botId` token rotation remains valid even with entries. Downgrading below `0.37.0` with a cursor-schema journal is unsafe because an older runtime cannot recover `acceptedThroughUpdateId` and could repoll admitted updates. Run `node scripts/check-downgrade.mjs [agent-dir]`; the conservative older-schema validator rejects that retained authority even when entries are drained. Runtime state from an older release must recover without deleting `telegram.json`, ownership state, or journal authority.
244
244
 
245
245
  Polling and inbound-worker diagnostics remain separate so an executing, deferred, locally queued, foreign-queued, or blocked journal head cannot masquerade as a stalled `getUpdates` request.
246
246
 
@@ -157,7 +157,7 @@ Leader election is heartbeat-gated and lock-backed. The polling owner checks exa
157
157
  4. If the leader heartbeat is stale, attempt an atomic leadership takeover; ordinary `/telegram-connect` on a follower is not a leadership move while the leader is live.
158
158
  5. Heartbeat acknowledgements carry the authenticated live follower-slot roster. If several followers detect stale leadership, the lowest observed live slot attempts promotion immediately; higher slots defer one bounded election grace and re-check the lock. Atomic compare/write acquisition remains the final ownership authority, and a missing lower-slot follower cannot block a higher survivor beyond that grace.
159
159
 
160
- Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target, slot, and thread name so the new leader can reuse the same binding. After the grace window they promote only when the exact observed leader lease has become stale or inactive; an unavailable IPC endpoint never authorizes replacing a still-live owner. If the exact carried target is absent from persisted bindings, the leader first runs the same synchronous visibility probe: success recovers it instead of creating another Telegram thread, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. An ambiguous absent-target probe persists only non-routable `probe-required` restoration evidence, so targetless retries and leader reloads must probe that exact target again instead of activating it or provisioning a speculative replacement. A carried slot survives only when that slot remains free. Every successful reuse refreshes the binding timestamp. The leader never restores persisted followers into the live registry speculatively. Absent follower records remain durable restart hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; only fresh authenticated registration creates live routing authority. This preserves real thread bindings through reload and process-absence gaps without allowing historical records or competing pollers to masquerade as live state.
160
+ Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target, slot, and thread name so the new leader can reuse the same binding. Follower Bot API calls already admitted by the active Pi turn wait for that bounded re-registration and capture its new exact generation before entering transport; they do not fail merely because recovery temporarily cleared local registration, and they never replay after an ambiguous transport commit. After the grace window followers promote only when the exact observed leader lease has become stale or inactive; an unavailable IPC endpoint never authorizes replacing a still-live owner. If the exact carried target is absent from persisted bindings, the leader first runs the same synchronous visibility probe: success recovers it instead of creating another Telegram thread, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. An ambiguous absent-target probe persists only non-routable `probe-required` restoration evidence, so targetless retries and leader reloads must probe that exact target again instead of activating it or provisioning a speculative replacement. A carried slot survives only when that slot remains free. Every successful reuse refreshes the binding timestamp. The leader never restores persisted followers into the live registry speculatively. Absent follower records remain durable restart hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; only fresh authenticated registration creates live routing authority. This preserves real thread bindings through reload and process-absence gaps without allowing historical records or competing pollers to masquerade as live state.
161
161
 
162
162
  ## Leader/Follower Communication
163
163
 
@@ -314,7 +314,7 @@ Every outbound request carries its target. The leader injects `message_thread_id
314
314
  Threaded Mode should make follower threads behave like normal Telegram instance surfaces, with the leader acting only as transport owner. Any feature in the matrix below that works for the leader must either work for followers or have an explicit documented exception.
315
315
 
316
316
  | Surface | Leader behavior | Follower requirement | Routing/ownership invariant | Regression evidence |
317
- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
317
+ | --- | --- | --- | --- | --- |
318
318
  | Prompt intake | Thread prompt queues locally | Thread prompt is forwarded and queued by the owning follower | Target ownership routes by `{ chatId, threadId }` before local handling | Routing tests for foreign target message forwarding |
319
319
  | Queued-message removal reactions | 👎/👻/💔/💩/🗑 marks a pending prompt/media turn for deletion when it reaches dispatch | Same reaction on a queued follower prompt marks that follower's pending turn for deletion before model dispatch | When the leader forwards a prompt to a follower, it records `chatId/messageId -> follower instance` because Bot API reaction updates expose chat/message but not thread id | Update runtime regression records forwarded message ownership and forwards the later reaction |
320
320
  | Queue priority reactions | 👍/⚡/❤/🕊/🔥 prioritizes queued prompts | Same reactions prioritize follower queued prompts | Reaction forwarding uses stored message ownership, then follower mutates its local queue | Reaction mutation tests plus forwarded-reaction coverage |
@@ -119,14 +119,13 @@ import { registerTelegramVoiceSynthesisProvider } from "@llblab/pi-telegram/voic
119
119
 
120
120
  const dispose = registerTelegramVoiceSynthesisProvider(
121
121
  async (text, options) => {
122
- const audioPath = await synthesizeToOggOpus(text, options);
123
- return { audioPath, transcriptText: text };
122
+ return await synthesizeToOggOpus(text, options);
124
123
  },
125
124
  { id: "my-extension/tts" },
126
125
  );
127
126
  ```
128
127
 
129
- Synthesis providers receive the extracted `telegram_voice` text plus optional `lang`/`rate` hints. Stable registrations pass a durable `id`; omitted ids remain a compatibility path for older providers. Providers own translation, TTS, speech rewriting, transcript choice, and OGG/Opus conversion. The bridge validates that the returned file ends in `.ogg` or `.opus`, sends it through Telegram `sendVoice`, and falls back to planned text if delivery fails before any visible text was delivered. Providers run after configured and programmatic voice handlers in the priority chain above.
128
+ Synthesis providers receive the extracted `telegram_voice` text plus optional `lang`/`rate` hints. Stable registrations pass a durable `id`; omitted ids remain a compatibility path for older providers. Providers own translation, TTS, speech rewriting, and OGG/Opus conversion. The bridge validates that the returned file ends in `.ogg` or `.opus`, sends it through Telegram `sendVoice`, and falls back to planned text if delivery fails before any visible text was delivered. Providers run after configured and programmatic voice handlers in the priority chain above.
130
129
 
131
130
  ## Voice Markup
132
131
 
@@ -84,7 +84,6 @@ interface TelegramBotProfile {
84
84
  botUsername?: string; // runtime-managed
85
85
  botId?: number; // runtime-managed
86
86
  allowedUserId?: number;
87
- lastUpdateId?: number; // runtime-managed
88
87
  }
89
88
 
90
89
  interface TelegramConfig {
@@ -100,8 +99,7 @@ interface TelegramConfig {
100
99
  timeInjection?: "hidden" | "always" | "interval";
101
100
  };
102
101
  voice?: {
103
- replyMode?: "hidden" | "mirror" | "always";
104
- sendTranscript?: boolean;
102
+ replyMode?: "manual" | "mirror" | "always";
105
103
  };
106
104
  time?: {
107
105
  interval?: number;
@@ -114,7 +112,7 @@ interface TelegramConfig {
114
112
 
115
113
  Bot/session identity always persists under `profiles.<name>`. The ordinary setup path uses `profiles.default`; `/telegram-setup default` and `/telegram-connect default` are exact aliases for the bare commands. Named profiles use the same shape. Shared handlers plus `assistant`, `voice`, `time`, and `threads` remain top-level. On the first `0.24.0` load, unambiguous legacy root identity moves atomically into `profiles.default`; identical duplicates collapse, complementary fields merge, and conflicting values fail closed without modifying the file.
116
114
 
117
- The file is global across Pi instances. Cooperating instances serialize recursive delta merges through `telegram.json.transaction`, preserve unrelated global/profile changes from newer disk snapshots, and merge `lastUpdateId` monotonically. A semantically unchanged merge adopts the latest disk state in memory without replacing the file; later commits win when two deltas intentionally change the same leaf. Same-parent temp-file replacement retries bounded transient `EPERM`, `EACCES`, and `EBUSY` destination contention without deleting the live config or leaving transaction serialization. For manual edits, stop or idle the connected instances, publish a complete valid file atomically, and let them reload. A non-transactional editor racing Pi persistence has no same-leaf conflict guarantee.
115
+ The file is global across Pi instances and contains configuration only. The per-profile polling/admission cursor is `acceptedThroughUpdateId` in that profile's private durable update journal; it is not a config key. On first connection after this cut, a legacy config cursor is transferred directly into the journal before polling and then removed from config. Journal publication failure preserves the legacy source; config publication failure leaves the journal authoritative so retry is idempotent. Cooperating instances serialize recursive config delta merges through `telegram.json.transaction` and preserve unrelated global/profile changes from newer disk snapshots. A semantically unchanged merge adopts the latest disk state in memory without replacing the file; later commits win when two deltas intentionally change the same leaf. Same-parent temp-file replacement retries bounded transient `EPERM`, `EACCES`, and `EBUSY` destination contention without deleting the live config or leaving transaction serialization. For manual edits, stop or idle the connected instances, publish a complete valid file atomically, and let them reload. A non-transactional editor racing Pi persistence has no same-leaf conflict guarantee.
118
116
 
119
117
  Hidden/default semantics are represented by absence:
120
118
 
@@ -410,16 +408,13 @@ const offStt = registerTelegramVoiceTranscriptionProvider(
410
408
 
411
409
  const offTts = registerTelegramVoiceSynthesisProvider(
412
410
  async (text, options) => {
413
- const audioPath = await synthesizeOggOpus(text, options);
414
- return getTelegramVoiceSendTranscript(getCurrentTelegramConfigView())
415
- ? { audioPath, transcriptText: text }
416
- : { audioPath };
411
+ return await synthesizeOggOpus(text, options);
417
412
  },
418
413
  { id: "@scope/my-extension/tts" },
419
414
  );
420
415
  ```
421
416
 
422
- Stable voice-provider registrations pass a durable `id`. Omitting `id` is a compatibility path for older providers and receives a generated session-local id. Providers return `undefined` to pass. TTS providers must return `.ogg` or `.opus` files for native Telegram voice notes. `voice.sendTranscript` is the bridge-owned transcript preference; providers that expose captions should gate `transcriptText` with `getTelegramVoiceSendTranscript(config)` instead of defining a second reply-policy toggle.
417
+ Stable voice-provider registrations pass a durable `id`. Omitting `id` is a compatibility path for older providers and receives a generated session-local id. Providers return `undefined` to pass. TTS providers must return `.ogg` or `.opus` files for native Telegram voice notes.
423
418
 
424
419
  Full behavior: [Voice Integration](./voice.md).
425
420
 
@@ -526,7 +521,6 @@ export default function demoOutbound(pi: ExtensionAPI) {
526
521
  ```ts
527
522
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
528
523
  import {
529
- getTelegramVoiceSendTranscript,
530
524
  registerTelegramVoiceSynthesisProvider,
531
525
  registerTelegramVoiceTranscriptionProvider,
532
526
  } from "@llblab/pi-telegram/voice";
@@ -534,16 +528,12 @@ import {
534
528
  export default function demoVoice(pi: ExtensionAPI) {
535
529
  let unregisterTts: (() => void) | undefined;
536
530
  let unregisterStt: (() => void) | undefined;
537
- let currentConfig: { voice?: { sendTranscript?: boolean } } = {};
538
531
  pi.on("session_start", async () => {
539
532
  unregisterTts?.();
540
533
  unregisterStt?.();
541
534
  unregisterTts = registerTelegramVoiceSynthesisProvider(
542
535
  async (text) => {
543
- const audioPath = await synthesizeDemoOgg(text);
544
- return getTelegramVoiceSendTranscript(currentConfig)
545
- ? { audioPath, transcriptText: text }
546
- : { audioPath };
536
+ return await synthesizeDemoOgg(text);
547
537
  },
548
538
  { id: "demo-voice/tts" },
549
539
  );
@@ -43,12 +43,12 @@ Voice provider extensions can also register STT backends with `registerTelegramV
43
43
 
44
44
  ## Voice Reply Policy
45
45
 
46
- 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.
46
+ The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`). Missing and invalid values resolve to the `manual` default; the former `hidden` value remains a read-only compatibility alias for `manual`.
47
47
 
48
48
  ### Modes
49
49
 
50
- - **`hidden` (default):** no `voice.replyMode` is stored and no automatic voice context is added; explicit agent-authored `telegram_voice` actions still work.
51
- - **`mirror`:** voice/audio input activates automatic voice delivery. Text input follows `hidden` behavior.
50
+ - **`manual` (default):** no `voice.replyMode` is stored and no automatic voice context is added; explicit agent-authored `telegram_voice` actions still work.
51
+ - **`mirror`:** voice/audio input activates automatic voice delivery. Text input follows `manual` behavior.
52
52
  - **`always`:** every Telegram turn activates automatic voice delivery.
53
53
 
54
54
  **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.
@@ -70,7 +70,7 @@ A voice extension may combine three public seams:
70
70
 
71
71
  - `registerTelegramVoiceTranscriptionProvider()` for inbound STT fallback on voice/audio files
72
72
  - `registerTelegramVoiceSynthesisProvider()` for outbound TTS/synthesis fallback to Telegram voice messages
73
- - `registerTelegramSection()` for provider-specific Telegram UI such as voice, language, style, transcript, or provider on/off controls
73
+ - `registerTelegramSection()` for provider-specific Telegram UI such as voice, language, style, or provider on/off controls
74
74
 
75
75
  The reply policy itself remains a built-in pi-telegram setting (`voice.replyMode`) rather than a provider-owned menu.
76
76
 
@@ -81,10 +81,8 @@ Voice synthesis provider extensions register themselves through `registerTelegra
81
81
  - Text optimisation / speech-style rewriting
82
82
  - Adding speech tags (when desired)
83
83
  - Running TTS + ffmpeg conversion to OGG/Opus
84
- - Deciding whether to return `transcriptText` at all based on the bridge-owned `voice.sendTranscript` preference when the provider has access to the current Telegram config
85
- - `transcriptText` (when returned) is attached by the bridge as the voice message **caption** only. Separate transcript messages are no longer sent.
86
84
 
87
- The bridge shows a `record_voice` action while delivering and sends the final audio with Telegram `sendVoice`. When a provider returns `transcriptText`, the bridge attaches it as the voice caption.
85
+ The bridge shows a `record_voice` action while delivering and sends the final audio with Telegram `sendVoice`.
88
86
 
89
87
  Providers can implement `getVoicePromptContribution(view)` to inject voice-specific instructions into voice-tagged prompts (for example: "Reply only with the spoken text"). The bridge appends the first non-empty provider contribution when `mirror` or `always` mode tags the turn.
90
88
 
@@ -95,7 +93,6 @@ The provider receives the raw agent text plus optional `{ lang?, rate? }`.
95
93
  It must return one of:
96
94
 
97
95
  - `string` — path to a ready `.ogg` or `.opus` file
98
- - `{ audioPath: string, transcriptText?: string }` — `audioPath` must be OGG/Opus. When `transcriptText` is present it is attached as the voice message **caption**. Providers should treat pi-telegram's `voice.sendTranscript` as the bridge-owned transcript preference instead of inventing a second reply-policy UI.
99
96
  - `undefined` — skip this text block
100
97
 
101
98
  **Important:** Providers are fully responsible for producing a clean, TTS-optimised native voice file. The bridge may also run configured outbound voice command templates for users who prefer process-boundary handlers instead of provider extensions.
@@ -106,7 +103,7 @@ Registration returns a disposer function for cleanup. Stable provider registrati
106
103
 
107
104
  ## Outbound Voice Handlers
108
105
 
109
- 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.
106
+ 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.
110
107
 
111
108
  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:
112
109
 
@@ -128,31 +125,6 @@ Voice handlers receive the text on stdin in composed pipelines and can use `{tex
128
125
 
129
126
  Priority for outbound voice delivery is: configured `outboundHandlers` with `type: "voice"` in their `telegram.json` order, then programmatic `voice` outbound handlers, then registered voice synthesis providers. Provider extensions are the zero-config tail of the same pipeline: they handle voice when no explicit configured handler succeeds, but they do not override operator-configured handlers. If multiple providers are registered, only one handles a given voice reply: the first provider that returns a valid `.ogg`/`.opus` artifact wins. Providers that return `undefined` explicitly pass to the next provider; providers that throw or return invalid output are recorded and the next fallback is tried.
130
127
 
131
- ### Provider with transcript caption (controlled by user toggle)
132
-
133
- When the user's "Send Transcript" toggle is ON, return the clean spoken text as `transcriptText`. The bridge attaches it as the caption on the voice message. When the toggle is OFF, return only the audio path (no `transcriptText`).
134
-
135
- ```typescript
136
- import {
137
- getTelegramVoiceSendTranscript,
138
- registerTelegramVoiceSynthesisProvider,
139
- } from "@llblab/pi-telegram/voice";
140
-
141
- registerTelegramVoiceSynthesisProvider(
142
- async (text, options) => {
143
- const rewritten = rewriteWithSpeechTags(text);
144
- const audioPath = await myTTS(rewritten, { language: options?.lang });
145
- const sendTranscript = getTelegramVoiceSendTranscript(
146
- getCurrentTelegramConfigView(),
147
- );
148
- return sendTranscript ? { audioPath, transcriptText: text } : { audioPath };
149
- },
150
- { id: "my-voice-provider/tts" },
151
- );
152
- ```
153
-
154
- `getCurrentTelegramConfigView()` represents whatever current `TelegramConfig` view your extension already owns or receives; pi-telegram does not require providers to read config directly. The bridge never sends a separate transcript message. Caption-only is the "ON" behavior.
155
-
156
128
  ### Surfacing provider diagnostics
157
129
 
158
130
  Voice provider extensions can record runtime events that appear in `/telegram-status` alongside pi-telegram's own events:
@@ -170,13 +142,13 @@ recordTelegramRuntimeEvent("voice-provider", new Error("TTS failed"), {
170
142
 
171
143
  ## Voice Extension Section
172
144
 
173
- Voice provider extensions can register a Voice Extension Section (settings UI) via `registerTelegramSection`. The section can expose provider-specific controls such as TTS voice, language, speech style, transcript behavior, or STT/TTS enablement. Reply mode is a core pi-telegram setting and belongs in the built-in Settings menu.
145
+ Voice provider extensions can register a Voice Extension Section (settings UI) via `registerTelegramSection`. The section can expose provider-specific controls such as TTS voice, language, speech style, or STT/TTS enablement. Reply mode is a core pi-telegram setting and belongs in the built-in Settings menu.
174
146
 
175
147
  **Note on resume:** Because the previous automatic persistent re-registration system has been removed, extensions are responsible for re-registering their Voice Extension Section on `session_start` if they want the menu to survive a `pi resume`. See `registerTelegramSection` from `@llblab/pi-telegram/sections`.
176
148
 
177
149
  ## Prompt Guidance
178
150
 
179
- 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.
151
+ The bridge keeps voice prompt context compact, effective, and policy-owned. `manual` 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.
180
152
 
181
153
  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.
182
154
 
@@ -224,4 +196,4 @@ The bridge reads `voice.replyMode` from the config when building a turn.
224
196
 
225
197
  ### Provider config
226
198
 
227
- Provider-specific settings (voice ID, language, speech style, transcript behavior, STT/TTS enablement) are owned by the voice provider extension. Reply mode is owned by pi-telegram's `voice.replyMode` and configured from the built-in pi-telegram Settings menu, not duplicated in provider UIs.
199
+ Provider-specific settings (voice ID, language, speech style, STT/TTS enablement) are owned by the voice provider extension. Reply mode is owned by pi-telegram's `voice.replyMode` and configured from the built-in pi-telegram Settings menu, not duplicated in provider UIs.
@@ -302,11 +302,6 @@ export default function (pi: Pi.ExtensionAPI) {
302
302
  persist: configStore.persist,
303
303
  markConfigChange: telegramSyncStateRuntime.markConfigChange,
304
304
  });
305
- const persistTelegramPollingOffset =
306
- Config.createTelegramPollingOffsetPersister(
307
- configStore,
308
- persistTelegramConfigWithSync,
309
- );
310
305
  const {
311
306
  current: currentInstanceThreadRuntime,
312
307
  status: threadStatusProjectionRuntime,
@@ -353,6 +348,10 @@ export default function (pi: Pi.ExtensionAPI) {
353
348
  getInboundWorkerState() {
354
349
  return updateAdmissionRuntimeBinding.getActive()?.getState();
355
350
  },
351
+ getAcceptedThroughUpdateId() {
352
+ return resolveTelegramUpdateJournalBinding()?.journal.read()
353
+ .acceptedThroughUpdateId;
354
+ },
356
355
  getActiveSourceMessageIds: activeTurnRuntime.getSourceMessageIds,
357
356
  hasActiveTurn: activeTurnRuntime.has,
358
357
  hasDispatchPending: lifecycle.hasDispatchPending,
@@ -416,6 +415,8 @@ export default function (pi: Pi.ExtensionAPI) {
416
415
  },
417
416
  getRegistrationGeneration:
418
417
  telegramBusFollowerRegistrationState.getGeneration,
418
+ waitForRegistrationGeneration:
419
+ telegramBusFollowerRegistrationState.waitForGeneration,
419
420
  getForwardCommentBatchPosition:
420
421
  textGroupRuntime.getPreparedForwardingPosition,
421
422
  recordRuntimeEvent,
@@ -1009,14 +1010,36 @@ export default function (pi: Pi.ExtensionAPI) {
1009
1010
  hasBotToken: configStore.hasBotToken,
1010
1011
  deleteWebhook,
1011
1012
  getUpdates,
1012
- persistConfig: persistTelegramPollingOffset,
1013
+ persistConfig: persistTelegramConfigWithSync,
1013
1014
  prepareUpdateBatch: textGroupRuntime.prepareUpdateBatch,
1014
1015
  journal: {
1015
- appendBatch(updates) {
1016
+ appendBatch(updates, acceptedThroughUpdateId) {
1016
1017
  return updateAdmissionLifecycleRuntime.appendBatch(
1017
1018
  updates as Journal.TelegramJournaledUpdate[],
1019
+ acceptedThroughUpdateId,
1018
1020
  );
1019
1021
  },
1022
+ getAcceptedThroughUpdateId() {
1023
+ return resolveTelegramUpdateJournalBinding()?.journal.read()
1024
+ .acceptedThroughUpdateId;
1025
+ },
1026
+ async prepareCursorCutover() {
1027
+ const binding = resolveTelegramUpdateJournalBinding();
1028
+ if (!binding) {
1029
+ throw new Error("Telegram update journal binding is unavailable.");
1030
+ }
1031
+ await Polling.cutOverTelegramPollingCursor({
1032
+ getLegacyCursor: configStore.getLegacyPollingCursor,
1033
+ readJournal: binding.journal.read,
1034
+ publishJournalCursor(acceptedThroughUpdateId) {
1035
+ binding.journal.appendBatch([], acceptedThroughUpdateId);
1036
+ },
1037
+ async removeLegacyCursor() {
1038
+ configStore.removeLegacyPollingCursor();
1039
+ await persistTelegramConfigWithSync();
1040
+ },
1041
+ });
1042
+ },
1020
1043
  getEntryCount: updateAdmissionLifecycleRuntime.getJournalEntryCount,
1021
1044
  signalWorker: updateAdmissionLifecycleRuntime.signal,
1022
1045
  getBootstrapEntryCount() {