@llblab/pi-telegram 0.20.6 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,8 @@ The bridge is a mobile companion for a live Pi session, not a remote terminal. I
14
14
  This document is the architectural map. Focused behavior standards live in sibling docs:
15
15
 
16
16
  - [Public API](./public-api.md) — stable commands, config, package entrypoints, assistant markup, extension APIs, and compatibility boundaries.
17
+ - [Telegram Delivery API](./delivery.md) — target-aware operational views, logical message handles, lifecycle fencing, and leader/follower transport.
18
+ - [Telegram Activity API](./activity.md) — normalized Pi lifecycle events, activity/source identity, non-blocking extension dispatch, and delivery contexts.
17
19
  - [UI Style](./ui-style.md) — inline UI labels, navigation, state markers, cards, and dialogs.
18
20
  - [Callback Namespaces](./callback-namespaces.md) — callback prefix ownership and fallback rules.
19
21
  - [Sections](./sections.md) — structured Telegram menu sections.
@@ -64,6 +66,8 @@ The repository uses a **Flat Domain DAG**:
64
66
  - `sections`: Telegram menu-section registry, opaque section callback tokens, render/callback dispatch, safe section ports, and diagnostics.
65
67
  - `keyboard`: shared inline-keyboard reply-markup shape only; feature domains own labels, callback data, and behavior.
66
68
  - `preview` / `replies` / `rendering`: throttled native Rich Markdown draft delivery, native final reply delivery, reply parameters, transport-limit chunking, and remaining Telegram HTML rendering for bridge-owned UI/compatibility surfaces.
69
+ - `delivery`: public extension operational-view delivery, active-turn/instance/aggregate/authorized target policy, logical chunk handles, per-target ordering, runtime generation fencing, and the process-local runtime membrane. Its bridge adapter composes the established UI/compat reply renderer with narrow bus-aware Telegram API and ownership ports; it never exposes bot clients or Pi contexts.
70
+ - `activity`: public normalized Pi lifecycle registration, activity/source identity, assistant segment and reasoning normalization, executed-tool events, non-blocking per-handler queues, delivery contexts, compatibility adapters, and shutdown fencing. It does not own visibility policy or Telegram rendering.
67
71
  - `outbound-markup`: top-level assistant action comment parsing, attribute parsing, voice reply planning, and preview/delivery stripping.
68
72
  - `outbound`: outbound text transformations, voice/button artifact delivery, and generated callback actions.
69
73
  - `outbound-attachments`: `telegram_attach`, queued outbound files, stat/limit checks, and photo/document delivery classification.
@@ -233,7 +237,7 @@ Assistant delivery guarantees:
233
237
 
234
238
  - Model-authored Markdown is the source of truth; the bridge does not pre-render assistant Markdown to HTML unless the operator selects `assistant.rendering: "html"` for compatibility.
235
239
  - Before native Rich Markdown delivery, the bridge normalizes known Bot-API-fragile source forms without changing visible meaning, including space-after-marker blockquotes and dollar-prefixed ticker atoms that Telegram may otherwise treat as unterminated math.
236
- - Prompt context blocks use compact metadata (`[tag|key:value]`) as the stable inbound contract. `[telegram...]` names the current surface only: owner/current turns use `[telegram]` or `[telegram|thread:<name>]`; guest-mode turns use `[telegram|guest:<group-title-or-peer-username-or-id>]`. In a private Guest Mode turn the paired owner's `from` identity is never the guest: an explicit replied peer wins, then the remote private-chat identity, then non-owner caller metadata; username falls back to the remote display name and numeric id. Source authors for quoted/forwarded material and their files are carried by `[reply|from:<username-or-id>]`, `[forward|from:<username-or-id>]`, and `[attachments|from:<username-or-id>]`, while plain `[attachments]` remains current-turn attachments and is ordered before reply/forward/source context.
240
+ - Prompt context blocks use compact metadata (`[tag|key:value]`) as the stable inbound contract. `[telegram...]` names the current surface only: owner/current turns use `[telegram]` or `[telegram|thread:<name>]`; guest-mode turns use `[telegram|guest:<group-title-or-peer-username-or-id>]`. In a private Guest Mode turn the paired owner's `from` identity is never the guest: the remote private-chat identity wins, then non-owner caller metadata, with a non-bot replied peer available only as a final identity fallback when stronger conversation evidence is absent; username falls back to the remote display name and numeric id. Reply attribution still belongs independently in `[reply|from:...]`, and a replied bot can never define or replace the current `[telegram|guest:...]` location identity. Source authors for quoted/forwarded material and their files are carried by `[reply|from:<username-or-id>]`, `[forward|from:<username-or-id>]`, and `[attachments|from:<username-or-id>]`, while plain `[attachments]` remains current-turn attachments and is ordered before reply/forward/source context.
237
241
  - Quoted rich replies use Telegram `rich_message` blocks as the prompt-context source when available, so `[reply]` context receives rendered plain text instead of raw `InputRichMessage.markdown` fallback text.
238
242
  - Long native Markdown replies are split only at Telegram Rich Message transport limits; oversized fenced code, display-math, and fully wrapped inline-formatting blocks are rewrapped per chunk so persisted Rich Markdown chunks remain structurally valid.
239
243
  - When Draft previews are enabled, streaming previews pass structurally closed assistant Markdown prefixes through to `sendRichMessageDraft` with ownership checks, voice suppression, and serialized flushes. Unclosed inline spans, links, fenced code, comments, and display-math blocks are held back until a safe boundary exists. Draft failures are recorded and the failing frame is skipped instead of degrading to raw plain-message previews, because partial Markdown can be invalid while the final message remains valid.
@@ -265,6 +269,8 @@ Unknown callback data outside owned prefixes is forwarded as `[callback] <data>`
265
269
 
266
270
  - Raw update observation/consumption: [Updates](./updates.md).
267
271
  - Telegram-native slash commands: `registerTelegramCommand()` from [Public API](./public-api.md#commands).
272
+ - Target-aware operational views and chat actions: [Telegram Delivery API](./delivery.md).
273
+ - Normalized non-blocking Pi lifecycle events: [Telegram Activity API](./activity.md); the separate [`pi-telegram-extension-demo`](https://github.com/llblab/pi-telegram-extension-demo) project remains the companion-extension reference.
268
274
  - Structured inline UI sections: [Sections](./sections.md).
269
275
  - Callback namespace discipline: [Callback Namespaces](./callback-namespaces.md).
270
276
  - Voice/STT/TTS providers: [Voice Integration](./voice.md).
@@ -0,0 +1,224 @@
1
+ # Telegram Delivery API
2
+
3
+ ## Purpose
4
+
5
+ The Telegram Delivery API gives trusted extension consumers a safe programmatic way to render operational Telegram UI without importing bridge internals or owning Telegram transport.
6
+
7
+ It fills the boundary between callback-scoped `TelegramSectionContext.open()` and agent-callable `telegram_message` / `telegram_attach`. It does not replace normal active-turn final replies, Sections, outbound handlers, or raw update handlers.
8
+
9
+ The public package membrane is:
10
+
11
+ ```ts
12
+ import {
13
+ deleteTelegramView,
14
+ editTelegramView,
15
+ sendTelegramChatAction,
16
+ sendTelegramView,
17
+ } from "@llblab/pi-telegram/delivery";
18
+ ```
19
+
20
+ ## Ownership Boundary
21
+
22
+ Consumer extension code owns:
23
+
24
+ - When an operational view should appear.
25
+ - View text, parse mode, and optional inline keyboard.
26
+ - Its own settings and callback policy.
27
+ - Retaining a returned handle only for the current live extension generation.
28
+
29
+ pi-telegram owns:
30
+
31
+ - Active-turn and current-instance target resolution.
32
+ - Pairing and target authorization.
33
+ - Classic direct transport versus follower-to-leader routing.
34
+ - Named-profile isolation.
35
+ - Per-target operation ordering.
36
+ - Telegram text limits, rendering, and chunk reconciliation.
37
+ - Runtime generation checks, shutdown behavior, and diagnostics.
38
+ - Bot credentials, polling, offsets, retries, and raw API clients.
39
+
40
+ ## Public Contract
41
+
42
+ ### Views
43
+
44
+ ```ts
45
+ export type TelegramDeliveryParseMode = "plain" | "html" | "markdown";
46
+
47
+ export interface TelegramDeliveryView {
48
+ text: string;
49
+ parseMode?: TelegramDeliveryParseMode;
50
+ replyMarkup?: TelegramInlineKeyboardMarkup;
51
+ }
52
+ ```
53
+
54
+ `plain` is the default. Operational activity should prefer `plain` or explicit `html`. `markdown` exists for extension-authored content that naturally owns Markdown; the bridge converts it through the existing UI/compat Markdown-to-HTML renderer rather than entering the native assistant final-reply pipeline.
55
+
56
+ `replyMarkup` accepts only structural keyboard data. Callback ownership stays with Sections or a registered raw update handler. The documented issue #126 consumer shape uses Sections for interactive Settings toggles and keeps delivered activity rows non-interactive, so a second managed callback registry would duplicate token, answer, edit, navigation, and cleanup ownership without a proven use case. Revisit only when a public-import-only consumer must generate managed callbacks independently of a registered Section context for arbitrary delivered messages.
57
+
58
+ ### Target scopes
59
+
60
+ ```ts
61
+ export type TelegramDeliveryScope =
62
+ | { kind: "active-turn" }
63
+ | { kind: "instance" }
64
+ | { kind: "aggregate" }
65
+ | { kind: "target"; target: TelegramDeliveryTarget };
66
+
67
+ export interface TelegramDeliveryTarget {
68
+ chatId: number;
69
+ threadId?: number;
70
+ }
71
+ ```
72
+
73
+ Resolution rules:
74
+
75
+ - `active-turn` requires a current Telegram-owned turn and resolves its exact `{ chatId, threadId? }`.
76
+ - `instance` resolves the current process's assigned follower/leader thread, or the paired private chat in classic mode. It does not silently fall back to an unrelated active thread.
77
+ - `aggregate` resolves the paired private chat without `threadId`; it is the Threaded Mode `All` surface and the ordinary classic chat. Follower aggregate messages carry an internal authenticated-bus marker that the leader validates for the assigned chat and strips before Bot API transport; unmarked or cross-chat threadless follower writes remain denied.
78
+ - `target` validates an explicit destination against the active profile and current runtime authority. A classic owner may target its paired private chat. A follower may target only its assigned thread or aggregate surface. A leader may target its own thread, aggregate surface, or a currently live bound thread for that profile. Unknown, stale, cross-profile, and unpaired targets are rejected.
79
+
80
+ No scope selects another named profile. Profile activation remains session-local bridge state.
81
+
82
+ ### Handles
83
+
84
+ ```ts
85
+ export interface TelegramDeliveryHandle {
86
+ readonly target: TelegramDeliveryTarget;
87
+ readonly messageIds: readonly number[];
88
+ readonly generation: string;
89
+ }
90
+ ```
91
+
92
+ A handle represents one logical view, which may span multiple Telegram messages after chunking. `generation` is an opaque runtime identity, not an authorization secret. Callers must not construct handles or persist them across reload/session replacement.
93
+
94
+ Edit reconciles the logical view as one operation:
95
+
96
+ - Existing chunks are edited in order.
97
+ - Additional chunks are sent when the new view grows.
98
+ - Surplus old chunks are deleted when the new view shrinks.
99
+ - The returned handle replaces the previous handle.
100
+
101
+ Delete removes every message still represented by the handle. Partial transport failure returns a structured failure and records diagnostics; it never pretends the whole logical view succeeded.
102
+
103
+ ### Results
104
+
105
+ ```ts
106
+ export type TelegramDeliveryFailureReason =
107
+ | "runtime-unavailable"
108
+ | "target-unavailable"
109
+ | "target-unauthorized"
110
+ | "stale-handle"
111
+ | "invalid-view"
112
+ | "transport-failed";
113
+
114
+ export type TelegramDeliveryResult<T> =
115
+ | { ok: true; value: T }
116
+ | {
117
+ ok: false;
118
+ reason: TelegramDeliveryFailureReason;
119
+ message: string;
120
+ partial?: T;
121
+ };
122
+ ```
123
+
124
+ Expected availability, authorization, and lifecycle outcomes return failures rather than throwing. Programmer errors may still throw for malformed objects that cannot satisfy the TypeScript contract. Transport failures are redacted before reaching callers and are also recorded in bridge runtime diagnostics.
125
+
126
+ When a multi-chunk `send` or growing `edit` fails after materializing part of the logical view, `partial` contains a valid handle for every message still visible from that operation. Callers may pass it to `editTelegramView` to reconcile the view or to `deleteTelegramView` for cleanup. A failure before any message exists omits `partial`; callers must never infer message ids or construct a handle.
127
+
128
+ ### Operations
129
+
130
+ ```ts
131
+ export interface SendTelegramViewOptions {
132
+ scope: TelegramDeliveryScope;
133
+ replyToMessageId?: number;
134
+ }
135
+
136
+ export function sendTelegramView(
137
+ view: TelegramDeliveryView,
138
+ options: SendTelegramViewOptions,
139
+ ): Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
140
+
141
+ export function editTelegramView(
142
+ handle: TelegramDeliveryHandle,
143
+ view: TelegramDeliveryView,
144
+ ): Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
145
+
146
+ export function deleteTelegramView(
147
+ handle: TelegramDeliveryHandle,
148
+ ): Promise<TelegramDeliveryResult<void>>;
149
+
150
+ export function sendTelegramChatAction(
151
+ action: "typing" | "upload_document" | "upload_photo" | "record_voice",
152
+ options: { scope: TelegramDeliveryScope },
153
+ ): Promise<TelegramDeliveryResult<void>>;
154
+ ```
155
+
156
+ `replyToMessageId` applies only to the first chunk and must belong to the resolved chat. The initial contract exposes only actions already used by bridge-owned activity and delivery paths; it is not a generic Bot API action string.
157
+
158
+ ## Runtime Binding
159
+
160
+ The public functions resolve a process-local runtime binding on every call. They never capture a Pi `ExtensionContext` or command context.
161
+
162
+ The bridge constructs and binds a genuinely fresh delivery runtime during every `session_start`. It unbinds and shuts down the current runtime during `session_shutdown` before session-bound transport state is discarded; binding an unexpected replacement also shuts down the displaced runtime. Reload and session replacement therefore produce these outcomes:
163
+
164
+ - A new call resolves the newly bound runtime after startup.
165
+ - An old handle returns `stale-handle` for edit and delete.
166
+ - A call while no generation is bound returns `runtime-unavailable`.
167
+ - Queued operations from the old generation stop before transport begins.
168
+ - An already-issued Telegram request may resolve during shutdown, but the old operation returns `runtime-unavailable` and cannot issue another edit, delete, chunk, or chat action afterward.
169
+ - Old operations never adopt the replacement generation implicitly.
170
+
171
+ The binding uses the same `globalThis` membrane pattern as other extension registries so package load order does not expose bridge internals. Only pi-telegram may bind or replace the runtime port.
172
+
173
+ ## Ordering And Delivery Semantics
174
+
175
+ - Operations serialize per profile and concrete target. Different targets may progress independently.
176
+ - `send`, logical `edit`, and logical `delete` preserve caller order for the same target.
177
+ - Delivery uses the existing bridge API runtime so followers route allowlisted calls through the leader rather than contacting Telegram directly.
178
+ - Text is chunked through the existing parse-mode-appropriate renderer/splitter. HTML chunks remain balanced, extension-authored Markdown becomes balanced UI/compat HTML, and plain chunks preserve text.
179
+ - Inline keyboard markup attaches only to the final chunk of a logical view. Reply parameters attach only to the first chunk.
180
+ - Edit growth sends additional chunks; edit shrink deletes surplus chunks; delete removes every chunk in handle order.
181
+ - A partial send failure returns the ids already sent. A partial edit failure returns the original surviving ids plus every newly sent id, minus any surplus ids already deleted during shrink. The returned handle therefore remains sufficient for deterministic retry or cleanup.
182
+ - The API does not participate in assistant preview/final deduplication and does not mutate the Telegram turn queue.
183
+ - A successful operational send does not imply agent work, create a Pi prompt, or alter terminal status.
184
+
185
+ ## Diagnostics
186
+
187
+ Failures record redacted runtime events under a delivery-specific category with operation, scope kind, profile, and failure reason. Diagnostics must not include bot tokens, unrestricted message bodies, callback payload secrets, or raw transport responses.
188
+
189
+ A future `getTelegramDeliveryDiagnostics()` is unnecessary for the first slice because callers receive structured results and `/telegram-status` already owns bridge diagnostics. Add a dedicated diagnostics getter only if a real consumer needs registry-level introspection.
190
+
191
+ ## Security And Non-Goals
192
+
193
+ The API does not expose:
194
+
195
+ - Bot tokens or raw Telegram clients.
196
+ - Arbitrary Bot API methods.
197
+ - A second polling loop.
198
+ - Cross-profile delivery.
199
+ - Unrestricted cross-instance targeting.
200
+ - Session replacement, reload, process launch, or Pi slash-command dispatch.
201
+ - File/media uploads in the first 0.21 slice.
202
+ - Captured Pi contexts or mutable queue/session state.
203
+
204
+ Extension consumers remain trusted local code, but the contract still preserves product ownership boundaries so accidental misuse cannot silently bypass Threaded Mode routing or target identity.
205
+
206
+ ## Validation Contract
207
+
208
+ The implementation must cover:
209
+
210
+ - Classic `instance` and `aggregate` resolution.
211
+ - Leader own-thread, aggregate, and live-bound explicit target resolution.
212
+ - Follower assigned-thread and aggregate routing through the leader.
213
+ - Missing active turns and disconnected runtimes.
214
+ - Cross-profile, unknown, stale, and unauthorized explicit targets.
215
+ - Plain, HTML, and Markdown chunking.
216
+ - Reply-first and keyboard-last chunk placement.
217
+ - Send/edit growth/edit shrink/delete ordering.
218
+ - Reload/session-replacement generation invalidation.
219
+ - In-flight shutdown fencing and redacted diagnostics.
220
+ - Package-boundary imports with no `/lib` access.
221
+
222
+ ## Relationship To Activity
223
+
224
+ The Activity API builds on this contract rather than duplicating transport. Activity handlers receive a fresh target-aware context whose `send`, `edit`, `delete`, and chat-action methods delegate to the same delivery runtime. The Activity API owns lifecycle normalization; this API owns delivery only.
@@ -9,6 +9,8 @@
9
9
  - **Compatibility:** older import/config paths that remain supported but should not be used for new code.
10
10
  - **Internal:** exported from source for tests or domain reuse, but not a compatibility promise.
11
11
 
12
+ The 0.21 Activity surface requires Pi `0.80.6` or newer. This minimum belongs to the package peer contract because `agent_settled` provides the only safe terminal boundary after retries, compaction, and queued continuations.
13
+
12
14
  ## Package Entrypoints
13
15
 
14
16
  Preferred public imports:
@@ -21,6 +23,8 @@ import { registerTelegramUpdateHandler } from "@llblab/pi-telegram/updates";
21
23
  import { registerTelegramCommand } from "@llblab/pi-telegram/commands";
22
24
  import { registerTelegramInboundHandler } from "@llblab/pi-telegram/inbound";
23
25
  import { registerTelegramOutboundHandler } from "@llblab/pi-telegram/outbound";
26
+ import { sendTelegramView } from "@llblab/pi-telegram/delivery";
27
+ import { registerTelegramActivityHandler } from "@llblab/pi-telegram/activity";
24
28
  import {
25
29
  registerTelegramVoiceSynthesisProvider,
26
30
  registerTelegramVoiceTranscriptionProvider,
@@ -118,6 +122,12 @@ High-level stable APIs:
118
122
  - `registerTelegramStatusLineProvider()`
119
123
  - Identity: required `id`.
120
124
  - Purpose: compact companion status rows in the `/start` menu status text.
125
+ - `sendTelegramView()` / `editTelegramView()` / `deleteTelegramView()` / `sendTelegramChatAction()`
126
+ - Identity: current process-local delivery generation and returned logical message handles.
127
+ - Purpose: ownership-gated operational delivery to active-turn, current-instance, aggregate, or explicitly authorized targets.
128
+ - `registerTelegramActivityHandler()`
129
+ - Identity: required stable `id`.
130
+ - Purpose: normalized non-blocking Pi lifecycle activity with source identity and fresh delivery contexts.
121
131
  - `registerTelegramVoiceTranscriptionProvider()`
122
132
  - Identity: required stable `id` for new code.
123
133
  - Purpose: STT fallback for voice/audio input.
@@ -145,6 +155,45 @@ Advanced stable diagnostics:
145
155
 
146
156
  All registration APIs return a disposer. Companion extensions should call disposers on shutdown and re-register on session start when they recreate runtime state. Low-level bus APIs intentionally avoid ids and run in registration order. High-level provider/UI APIs require stable identity in their public contract so diagnostics, replacement, and cleanup are understandable. Generated voice-provider ids remain a temporary compatibility path where documented.
147
157
 
158
+ ## Capability Inventory And Gap Classification
159
+
160
+ This inventory maps the complete bridge capability plane to its supported extension boundary. A capability may stay private deliberately; completeness means every meaningful capability has an explicit classification, not that every internal helper becomes public.
161
+
162
+ ### Public now
163
+
164
+ - **Extension loading:** The root export loads the bridge as a Pi extension; companion code uses the domain subpaths below rather than importing root runtime state.
165
+ - **Telegram commands:** `/commands` registers explicit Telegram-native slash commands with scoped reply and prompt-enqueue ports.
166
+ - **Managed menu and Settings UI:** `/sections` registers main-menu views, Settings rows, namespaced callbacks, standalone callback-scoped messages, and diagnostics.
167
+ - **Programmatic target-aware delivery:** `/delivery` sends, edits, deletes, and signals operational views against active-turn, current-instance, aggregate, or explicitly authorized targets through generation-bound logical handles.
168
+ - **Normalized lifecycle activity:** `/activity` registers non-blocking extension handlers for evidence-based run/source identity, assistant prose/reasoning segments, executed tools, compaction, and settlement with fresh delivery contexts.
169
+ - **Compact status projection:** `/status` contributes synchronous status rows to the `/start` menu.
170
+ - **Raw inbound update interception:** `/updates` observes or consumes Telegram updates before default routing and remains the low-level callback escape hatch.
171
+ - **Inbound content transforms:** `/inbound` adds Telegram-to-Pi text/media preprocessing after operator-configured handlers.
172
+ - **Final outbound transforms:** `/outbound` adds final text/voice transformation fallbacks and exposes redacted runtime-event recording.
173
+ - **Voice providers and policy helpers:** `/voice` registers STT/TTS providers and exposes stable voice-mode projections.
174
+ - **Keyboard structures:** `/keyboard` exposes inline-keyboard structural types without transport operations.
175
+ - **Agent-callable delivery:** `telegram_message` and `telegram_attach` provide ownership-gated text/file delivery to the agent, not a JavaScript companion-extension transport API.
176
+
177
+ ### Intentionally private
178
+
179
+ - **Credentials and raw transport:** Bot tokens, Telegram clients, unrestricted Bot API calls, polling, retry loops, offsets, and multipart/download internals stay private so companions cannot bypass pairing or open a second transport owner.
180
+ - **Ownership and multi-instance routing:** Locks, named-profile isolation, leader/follower IPC, authorization capabilities, thread provisioning, reconciliation, and sync assumptions stay bridge-owned.
181
+ - **Session and queue coordination:** Active turns, queue lanes, dispatch gates, abort/compaction state, previews, final-reply ordering, and session-bound context stores stay internal invariants rather than shared mutable extension state.
182
+ - **Core operator UI:** Built-in menus, model/thinking controls, rendering internals, prompt-template expansion, status diagnostics assembly, and thread naming remain core policy; companions extend them through commands, sections, and status providers.
183
+ - **Raw Pi runtime objects:** Companion APIs never return captured `ExtensionContext`, `ExtensionCommandContext`, session managers, or private session-replacement/runtime handles.
184
+
185
+ ### Assessed and not required for 0.21
186
+
187
+ - **General managed callbacks outside Sections:** The documented issue #126 consumer shape needs interactive Settings toggles, not interactive activity rows. `/sections` already owns stable callback namespacing, callback answers, edits, navigation, and cleanup for those toggles; `/delivery` can render the resulting non-interactive activity views. A second callback registry would duplicate ownership without a proven use case, while `/updates` remains the deliberate low-level escape hatch for consumers that truly need raw callback interception. Revisit only when a public-import-only consumer must generate managed callbacks independently of a registered Section context for arbitrary delivered messages.
188
+
189
+ ### Explicitly deferred
190
+
191
+ - **Programmatic artifact/media delivery:** `telegram_attach` covers agent-authored artifacts, while companion JavaScript has no general file/media send contract. The first 0.21 delivery slice targets operational text/activity views; media should earn a typed extension only from a concrete companion use case.
192
+ - **General configuration mutation:** Companions own their configuration and Settings state. pi-telegram does not expose unrestricted mutation of `telegram.json`, profile identity, pairing, rendering, queue, or transport settings.
193
+ - **Process and session control:** Reload, new-session, fork, resume, process launch, and arbitrary Pi slash-command dispatch remain outside the Telegram companion API until Pi exposes safe async extension hooks.
194
+
195
+ The 0.21 platform boundary lets a public-import-only consumer own reasoning, intermediate-prose, and tool-row policy while pi-telegram retains target selection, transport, authorization, lifecycle safety, and delivery ordering. Activity-specific examples live in this documentation; the separate [`pi-telegram-extension-demo`](https://github.com/llblab/pi-telegram-extension-demo) project remains the maintained companion-extension reference.
196
+
148
197
  ## Commands
149
198
 
150
199
  Import from `@llblab/pi-telegram/commands`. This registers Telegram slash commands only; it does not expose Pi slash commands and is unrelated to command-template handlers.
@@ -209,6 +258,48 @@ Contract:
209
258
 
210
259
  Full behavior: [Extension Sections](./sections.md).
211
260
 
261
+ ## Telegram Delivery API
262
+
263
+ Import from `@llblab/pi-telegram/delivery`.
264
+
265
+ ```ts
266
+ const sent = await sendTelegramView(
267
+ {
268
+ text: "<b>Indexing…</b>",
269
+ parseMode: "html",
270
+ },
271
+ { scope: { kind: "instance" } },
272
+ );
273
+ if (!sent.ok) {
274
+ recordLocalDiagnostic(sent.reason, sent.message);
275
+ }
276
+ ```
277
+
278
+ The delivery runtime resolves its live binding on every call and returns structured failures for unavailable runtimes, missing or unauthorized targets, stale handles, invalid views, and transport failures. A logical handle may represent several chunked Telegram messages; edit and delete reconcile the whole logical view. If send or edit growth fails after materializing messages, the failure carries a valid partial handle for deterministic retry or cleanup. Followers route through the existing leader transport, and reload/session replacement invalidates old handles rather than retaining Pi contexts.
279
+
280
+ Full behavior: [Telegram Delivery API](./delivery.md).
281
+
282
+ ## Telegram Activity API
283
+
284
+ Import from `@llblab/pi-telegram/activity`.
285
+
286
+ ```ts
287
+ const off = registerTelegramActivityHandler({
288
+ id: "@scope/activity-view",
289
+ async handle(event, ctx) {
290
+ if (event.type !== "tool-start") return;
291
+ await ctx.send({
292
+ text: `Tool: ${event.toolName}`,
293
+ parseMode: "plain",
294
+ });
295
+ },
296
+ });
297
+ ```
298
+
299
+ Handlers receive ordered normalized events but run outside Pi's critical lifecycle path. Each handler has an isolated asynchronous queue; adjacent high-frequency deltas may coalesce while semantic boundaries remain ordered. Activity contexts choose active-turn delivery for Telegram-owned work and instance delivery for local/autonomous/unknown work, delegating every operation through the current `/delivery` generation.
300
+
301
+ Full behavior and consumer policy examples: [Telegram Activity API](./activity.md).
302
+
212
303
  ## Status Lines
213
304
 
214
305
  Import from `@llblab/pi-telegram/status`.
@@ -458,7 +549,7 @@ async function synthesizeDemoOgg(_text: string): Promise<string> {
458
549
 
459
550
  ### Smoke Checklist
460
551
 
461
- - The extension imports only public package membranes: `@llblab/pi-telegram`, `/commands`, `/sections`, `/status`, `/updates`, `/inbound`, `/outbound`, `/voice`, or `/keyboard`.
552
+ - The extension imports only public package membranes: `@llblab/pi-telegram`, `/commands`, `/sections`, `/status`, `/delivery`, `/activity`, `/updates`, `/inbound`, `/outbound`, `/voice`, or `/keyboard`.
462
553
  - It does not import `@llblab/pi-telegram/lib/*`.
463
554
  - It registers on `session_start` and disposes on `session_shutdown`.
464
555
  - Stable high-level registrations use durable ids.
package/docs/sections.md CHANGED
@@ -441,10 +441,11 @@ Available programmatically via `getTelegramSectionDiagnostics()`. Main-menu/sett
441
441
 
442
442
  ## 15. Demo Extension
443
443
 
444
- `@llblab/pi-telegram-extension-demo` (`extensions/pi-telegram-extension-demo/`) is the reference implementation:
444
+ [`@llblab/pi-telegram-extension-demo`](https://github.com/llblab/pi-telegram-extension-demo) is the maintained companion-extension reference:
445
445
 
446
- - Main menu: `🧪 Demo submenu` enqueue prompt, answer callback, show info, interactive counter
447
- - Settings: `🧪 Demo settings` ON/OFF toggle with dynamic `getLabel()` status indicator, enqueue from settings
448
- - Navigation: full Back/Main menu hierarchy across all three levels
446
+ - Main-menu and Settings surfaces with dynamic labels.
447
+ - Managed section callbacks, buttons, edits, navigation, and cleanup.
448
+ - Public `@llblab/pi-telegram/*` imports rather than package-private `/lib` paths.
449
+ - Independent package and lifecycle ownership outside pi-telegram core.
449
450
 
450
- Use it as a template for new section-based extensions.
451
+ Use it as a template for section-based extensions. Activity-specific registration and delivery patterns remain in [Telegram Activity API](./activity.md); pi-telegram does not duplicate the demo as an in-package `examples/` directory.
package/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * Keeps the runtime wiring in one place while delegating reusable domain logic to /lib modules
5
5
  */
6
6
 
7
+ import * as Activity from "./lib/activity.ts";
7
8
  import * as Bindings from "./lib/bindings.ts";
8
9
  import * as BusApi from "./lib/bus-api.ts";
9
10
  import * as Bus from "./lib/bus.ts";
@@ -13,6 +14,7 @@ import * as BusTransport from "./lib/bus-transport.ts";
13
14
  import * as CommandTemplates from "./lib/command-templates.ts";
14
15
  import * as Commands from "./lib/commands.ts";
15
16
  import * as Config from "./lib/config.ts";
17
+ import * as Delivery from "./lib/delivery.ts";
16
18
  import * as Threads from "./lib/threads.ts";
17
19
  import * as Inbound from "./lib/inbound.ts";
18
20
  import * as Lifecycle from "./lib/lifecycle.ts";
@@ -531,6 +533,54 @@ export default function (pi: Pi.ExtensionAPI) {
531
533
  });
532
534
  const { replyTransport, editInteractiveMessage, sendInteractiveMessage } =
533
535
  replyRuntime;
536
+ const getDeliveryTargetPolicyView = function (): Delivery.TelegramDeliveryTargetPolicyView {
537
+ const ownsDirect = lockRuntime.owns();
538
+ const followerTarget = telegramBusFollowerRegistrationState.getTarget();
539
+ return {
540
+ canDeliver:
541
+ ownsDirect || telegramBusFollowerRegistrationState.isRegistered(),
542
+ ownsDirect,
543
+ allowedChatId: configStore.getAllowedUserId(),
544
+ followerTarget,
545
+ leaderTarget: telegramBusLeaderTarget,
546
+ liveTargets: threadStore.list().map(function (record) {
547
+ return record.target;
548
+ }),
549
+ };
550
+ };
551
+ const deliveryGenerationSeed = `${telegramInstanceId}:${Date.now()}`;
552
+ const deliveryLifecycleRuntime =
553
+ Delivery.createTelegramBridgeDeliveryLifecycleHooks({
554
+ generationSeed: deliveryGenerationSeed,
555
+ getTargetPolicyView: getDeliveryTargetPolicyView,
556
+ getActiveTurnTarget() {
557
+ if (activeTurnRuntime.getGuestQueryId()) return undefined;
558
+ return activeTurnRuntime.getTarget();
559
+ },
560
+ api: telegramApiRuntime,
561
+ recordOwnership(input) {
562
+ messageOwnershipStore.record({
563
+ ...input,
564
+ instanceId: telegramInstanceId,
565
+ });
566
+ },
567
+ recordFailure(operation, error, target) {
568
+ recordRuntimeEvent("delivery", error, {
569
+ operation,
570
+ scope: target?.threadId === undefined ? "aggregate" : "thread",
571
+ });
572
+ },
573
+ });
574
+ const activityRuntime = Activity.createTelegramActivityBridgeRuntime({
575
+ generation: deliveryGenerationSeed,
576
+ recordFailure(handlerId, event, error) {
577
+ recordRuntimeEvent("activity", error, {
578
+ handlerId,
579
+ eventType: event.type,
580
+ activityId: event.activityId,
581
+ });
582
+ },
583
+ });
534
584
  const { sendTextReply, sendMarkdownReply } =
535
585
  Outbound.createTelegramOutboundTextReplyRuntime({
536
586
  sendTextReply: replyRuntime.sendTextReply,
@@ -1238,11 +1288,13 @@ export default function (pi: Pi.ExtensionAPI) {
1238
1288
  queueSessionLifecycle,
1239
1289
  {
1240
1290
  async onSessionStart(event, ctx) {
1291
+ await deliveryLifecycleRuntime.onSessionStart();
1241
1292
  await lockedPollingRuntime.onSessionStart(event, ctx);
1242
1293
  telegramThreadCapabilityMonitor.start(ctx);
1243
1294
  queueDispatchWatchdogRuntime.start(ctx);
1244
1295
  },
1245
1296
  async onSessionShutdown() {
1297
+ await deliveryLifecycleRuntime.onSessionShutdown();
1246
1298
  queueDispatchWatchdogRuntime.stop();
1247
1299
  telegramThreadCapabilityMonitor.stop();
1248
1300
  },
@@ -1301,6 +1353,7 @@ export default function (pi: Pi.ExtensionAPI) {
1301
1353
  ...sessionLifecycleRuntime,
1302
1354
  onModelSelect: currentModelRuntime.onModelSelect,
1303
1355
  },
1356
+ activityRuntime,
1304
1357
  configStore,
1305
1358
  abort,
1306
1359
  typing,