@llblab/pi-telegram 0.11.2 → 0.13.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.
Files changed (53) hide show
  1. package/AGENTS.md +20 -15
  2. package/BACKLOG.md +1 -11
  3. package/CHANGELOG.md +41 -1
  4. package/README.md +15 -41
  5. package/api/inbound.ts +14 -0
  6. package/api/keyboard.ts +10 -0
  7. package/api/outbound.ts +11 -0
  8. package/api/sections.ts +17 -0
  9. package/api/updates.ts +11 -0
  10. package/api/voice.ts +24 -0
  11. package/docs/README.md +7 -5
  12. package/docs/architecture.md +162 -226
  13. package/docs/callback-namespaces.md +3 -3
  14. package/docs/command-templates.md +18 -16
  15. package/docs/{inbound-handlers.md → inbound.md} +14 -11
  16. package/docs/locks.md +3 -3
  17. package/docs/{outbound-handlers.md → outbound.md} +14 -11
  18. package/docs/public-api.md +420 -0
  19. package/docs/{extension-sections.md → sections.md} +34 -30
  20. package/docs/ui-style.md +165 -0
  21. package/docs/{external-handlers.md → updates.md} +33 -31
  22. package/docs/voice.md +27 -19
  23. package/index.ts +88 -242
  24. package/lib/bindings.ts +299 -0
  25. package/lib/command-templates.ts +249 -60
  26. package/lib/commands.ts +114 -1
  27. package/lib/config.ts +44 -4
  28. package/lib/{inbound-handlers.ts → inbound.ts} +31 -21
  29. package/lib/lifecycle.ts +41 -6
  30. package/lib/locks.ts +4 -1
  31. package/lib/menu-model.ts +3 -3
  32. package/lib/menu-queue.ts +1 -1
  33. package/lib/menu-settings.ts +21 -10
  34. package/lib/menu-status.ts +1 -1
  35. package/lib/menu.ts +1 -1
  36. package/lib/outbound-buttons.ts +226 -0
  37. package/lib/outbound-markup.ts +357 -0
  38. package/lib/outbound-voice.ts +263 -0
  39. package/lib/outbound.ts +908 -0
  40. package/lib/polling.ts +4 -3
  41. package/lib/preview.ts +2 -2
  42. package/lib/queue.ts +3 -0
  43. package/lib/replies.ts +4 -1
  44. package/lib/routing.ts +44 -3
  45. package/lib/{extension-sections.ts → sections.ts} +37 -8
  46. package/lib/status.ts +13 -0
  47. package/lib/{api.ts → telegram-api.ts} +4 -4
  48. package/lib/text-groups.ts +3 -2
  49. package/lib/updates.ts +121 -1
  50. package/lib/voice.ts +67 -21
  51. package/package.json +13 -3
  52. package/lib/external-handlers.ts +0 -166
  53. package/lib/outbound-handlers.ts +0 -1663
@@ -33,14 +33,14 @@ There is no portable `command` field. The command is derived from `template`: af
33
33
  Common object fields:
34
34
 
35
35
  - `label`: Optional human label for diagnostics and parallel branch reports.
36
- - `mode`: Optional execution mode for array templates. Default is `"sequence"`; `"parallel"` runs children concurrently.
36
+ - `parallel`: Optional boolean execution flag for array templates. Default is sequential execution; `true` runs children concurrently when the host execution layer supports branch fanout.
37
+ - `when`: Optional boolean or condition string. Falsy values skip the node. String forms may reference a flag name, `!flag`, or a placeholder expression such as `{flag?yes:}`.
37
38
  - `args`: Optional placeholder declarations. Untyped names remain valid; compact typed forms such as `file:path`, `timeout:int`, `speed:number`, `dry_run:bool`, `prompts:array`, and `mode:enum(check,fix)` are valid when the host supports typed tool schemas. Defaults belong in `defaults` or inline placeholder defaults; hosts may normalize interactive shorthand such as `timeout:int=60000` before persistence.
38
39
  - `defaults`: Placeholder default values by name.
39
- - `timeout`: Optional execution timeout in milliseconds. Omit it, or set `0`, to leave the command unbounded. Set an explicit positive timeout when a tool must fail closed instead of waiting indefinitely.
40
- - `delay`: Optional wait in milliseconds before starting this node. Default is no delay.
40
+ - `timeout`: Optional execution timeout in milliseconds, as a number or placeholder-resolved string. Omit it, or set `0`, to leave the command unbounded. Set an explicit positive timeout when a tool must fail closed instead of waiting indefinitely.
41
+ - `delay`: Optional wait in milliseconds before starting this node, as a number or placeholder-resolved string. Default is no delay.
41
42
  - `output`: Optional result selector. Default is `"stdout"`; runtime values such as `"ogg"` are valid.
42
- - `retry`: Optional max attempts including the first. Default is `1`.
43
- - `critical`: Optional boolean. Backward-compatible alias for `failure: "root"`.
43
+ - `retry`: Optional max attempts including the first, as a number or placeholder-resolved string. Default is `1`.
44
44
  - `failure`: Optional failure propagation scope: `continue`, `branch`, or `root`. Default is `continue`.
45
45
  - `recover`: Optional command template run between failed retry attempts. Recovery output is ignored; recovery failure stops retries.
46
46
  - `template`: Required command string or ordered composition array.
@@ -68,6 +68,8 @@ Supported forms:
68
68
  | `{name}` | Required value from runtime values or `defaults` |
69
69
  | `{name=default}` | Inline default when no value is provided |
70
70
  | `{items[index]}` | Array item selected by literal or repeat index |
71
+ | `{value??fallback}` | Fallback when the value is absent or falsy |
72
+ | `{flag?yes:no}` | Conditional text selected by flag truthiness |
71
73
 
72
74
  Resolution order is runtime values → `defaults` → inline default → error. Default values that are themselves a single placeholder, such as `{prompt}` resolving to `{prompts[index]}`, are resolved recursively with a small depth guard. A repeat node may set `repeat` to `{items.length}` when an array arg should determine fanout width.
73
75
 
@@ -127,8 +129,8 @@ template="echo 'literal words' {text}"
127
129
 
128
130
  Composition rules:
129
131
 
130
- - Execute leaves in order when `mode` is omitted or set to `"sequence"`
131
- - Execute child templates concurrently when `mode` is set to `"parallel"`
132
+ - Execute leaves in order by default
133
+ - Execute child templates concurrently when `parallel` is `true`
132
134
  - Parallel composition uses soft-quorum semantics by default: failed children are reported as degraded branches unless failure propagation escalates
133
135
  - Non-critical failures are recorded and execution continues, while `failure: "branch"` stops the current branch and `failure: "root"` aborts the root composition
134
136
  - Treat the whole composition as one handler for selector matching and fallback
@@ -163,7 +165,7 @@ Composition rules:
163
165
 
164
166
  ```json
165
167
  {
166
- "mode": "parallel",
168
+ "parallel": true,
167
169
  "repeat": 8,
168
170
  "template": "render page{_(index+1)}.html --prev page{_(prev+1)}.html --next page{_(next+1)}.html --zero page{_index}.html"
169
171
  }
@@ -198,7 +200,7 @@ Parallel nodes use the same object shape. Flags come first and `template` stays
198
200
  "template": [
199
201
  "prepare {out_dir}",
200
202
  {
201
- "mode": "parallel",
203
+ "parallel": true,
202
204
  "template": [
203
205
  {
204
206
  "label": "gpt-5.5",
@@ -232,7 +234,7 @@ exit: 1
232
234
  stderr: provider balance exhausted
233
235
  ```
234
236
 
235
- Legacy local schemas may accept `pipe` as an alias, but the portable standard is `template: [...]`.
237
+ Use `template: [...]` for ordered composition. Older local `pipe` aliases are not part of the 0.13.0 command-template standard.
236
238
 
237
239
  ## Fail-Open Default Policy
238
240
 
@@ -250,7 +252,7 @@ Use `failure` when a node should stop more aggressively:
250
252
 
251
253
  ```json
252
254
  {
253
- "mode": "parallel",
255
+ "parallel": true,
254
256
  "template": [
255
257
  {
256
258
  "label": "agent-a",
@@ -276,7 +278,7 @@ Use `failure` when a node should stop more aggressively:
276
278
 
277
279
  If `agent-a-validate` fails, `agent-a-push` is skipped, `agent-b` can still finish, and the parallel join reports degraded branch coverage.
278
280
 
279
- `critical: true` remains a backward-compatible alias for `failure: "root"`. Prefer `failure` for new templates because it names the propagation scope directly.
281
+ Use `failure: "root"` to abort the root composition. Older local `critical: true` shapes are not part of the 0.13.0 command-template standard.
280
282
 
281
283
  ## Retry
282
284
 
@@ -334,13 +336,13 @@ The standard uses a single `template` field that grows with the user's needs:
334
336
  string → leaf command
335
337
  string[] → sequential composition
336
338
  { template } → leaf command object
337
- { mode, template } → sequence or parallel subtree
338
- { mode, args, defaults, delay, retry, failure, recover, output, template } → full node
339
+ { parallel, template } → parallel subtree
340
+ { parallel, when, args, defaults, delay, retry, failure, recover, output, template } → full node
339
341
  ```
340
342
 
341
- Start with a string. Add composition when needed. Add `mode: "parallel"` when independent work can run concurrently. Add delay when launch pacing matters. Add retry when flaky. Add `failure` when propagation scope matters. Add `recover` when a retried node needs cleanup before another attempt. Same contract, growing capability, no dead weight.
343
+ Start with a string. Add composition when needed. Add `parallel: true` when independent work can run concurrently. Add `when` for conditional nodes. Add delay when launch pacing matters. Add retry when flaky. Add `failure` when propagation scope matters. Add `recover` when a retried node needs cleanup before another attempt. Same contract, growing capability, no dead weight.
342
344
 
343
- `mode: "parallel"` is the synchronous fanout shape. Saved JSON recipes and detached lifecycle concerns such as logs, cancellation, and durable state belong to host-specific recipe/async-run standards, not to command templates.
345
+ `parallel: true` is the synchronous fanout shape. Saved JSON recipes and detached lifecycle concerns such as logs, cancellation, and durable state belong to host-specific recipe/async-run standards, not to command templates.
344
346
 
345
347
  ## Trust Boundary
346
348
 
@@ -49,7 +49,7 @@ Legacy `telegram.json` files may still define `attachmentHandlers` for media/fil
49
49
 
50
50
  At runtime, `attachmentHandlers` is appended after `inboundHandlers`. Existing configs continue to work, while new configs should use `inboundHandlers`.
51
51
 
52
- Handlers match by optional `type`, `mime`, or `match`. `mime` and `type` are independent selectors: if `mime` is present, `type` is not required. Wildcards such as `audio/*` or `text/*` are accepted. Each matching handler must provide `template`; a string is one command, and an array is ordered composition. Top-level `args` and `defaults` apply to composed steps unless a step defines private values. The command-template default timeout applies automatically. Legacy configs may still use `pipe` as a local alias.
52
+ Handlers match by optional `type`, `mime`, or `match`. `mime` and `type` are independent selectors: if `mime` is present, `type` is not required. Wildcards such as `audio/*` or `text/*` are accepted. Each matching handler must provide `template`; a string is one command, and an array is ordered composition. Top-level `args` and `defaults` apply to composed steps unless a step defines private values. The command-template default timeout applies automatically. Use `template: [...]` for composition; the old local `pipe` alias is removed in 0.13.0.
53
53
 
54
54
  `defaults` may provide additional placeholder values such as `{lang}` or `{model}`. `args` is only a string-array declaration of supported placeholders; defaults belong in `defaults` or inline placeholders such as `{lang=ru}`. Examples prefer explicit flag-style CLIs such as `--file {file}` and `--lang {lang=ru}` for readability, but positional forms such as `/path/to/stt {file} {lang=ru} {model=voxtral-mini-latest}` are equally valid when the target script supports them.
55
55
 
@@ -88,9 +88,9 @@ If a matching handler fails with a non-zero exit code, the runtime records diagn
88
88
 
89
89
  ## Programmatic Inbound Handlers And STT Fallbacks
90
90
 
91
- Extensions can register programmatic inbound handlers with `registerTelegramInboundHandler(kind, handler)` from `@llblab/pi-telegram/lib/inbound-handlers.ts`. This is the code-level counterpart to configured `inboundHandlers`; use it for extension-owned transformations that are not voice-specific.
91
+ Extensions can register programmatic inbound handlers with `registerTelegramInboundHandler(kind, handler)` from `@llblab/pi-telegram/inbound`. This is the code-level counterpart to configured `inboundHandlers`; use it for extension-owned transformations that are not voice-specific.
92
92
 
93
- Voice extensions can register STT providers with `registerTelegramVoiceTranscriptionProvider()` from `@llblab/pi-telegram/lib/voice.ts`. This is the zero-config extension path for voice/audio input: an extension such as `pi-xai-voice` can transcribe Telegram voice notes without requiring the operator to write an `inboundHandlers` command template.
93
+ Voice extensions can register STT providers with `registerTelegramVoiceTranscriptionProvider()` from `@llblab/pi-telegram/voice`. This is the zero-config extension path for voice/audio input: a companion extension can transcribe Telegram voice notes without requiring the operator to write an `inboundHandlers` command template.
94
94
 
95
95
  Priority stays explicit and predictable:
96
96
 
@@ -101,14 +101,17 @@ Priority stays explicit and predictable:
101
101
  5. built-in text-file fallback for text attachments
102
102
 
103
103
  ```ts
104
- import { registerTelegramInboundHandler } from "@llblab/pi-telegram/lib/inbound-handlers.ts";
105
- import { registerTelegramVoiceTranscriptionProvider } from "@llblab/pi-telegram/lib/voice.ts";
106
-
107
- const disposeInbound = registerTelegramInboundHandler("document", async ({ file }) => {
108
- if (!file?.mimeType?.includes("pdf")) return undefined;
109
- const text = await extractPdf(file.path);
110
- return text || undefined;
111
- });
104
+ import { registerTelegramInboundHandler } from "@llblab/pi-telegram/inbound";
105
+ import { registerTelegramVoiceTranscriptionProvider } from "@llblab/pi-telegram/voice";
106
+
107
+ const disposeInbound = registerTelegramInboundHandler(
108
+ "document",
109
+ async ({ file }) => {
110
+ if (!file?.mimeType?.includes("pdf")) return undefined;
111
+ const text = await extractPdf(file.path);
112
+ return text || undefined;
113
+ },
114
+ );
112
115
 
113
116
  const dispose = registerTelegramVoiceTranscriptionProvider(
114
117
  async (file) => {
package/docs/locks.md CHANGED
@@ -58,13 +58,13 @@ During a user-initiated start/connect event, an extension should:
58
58
  1. Read its lock entry
59
59
  2. If `pid` is stale, replace the entry
60
60
  3. If `pid` and `cwd` match the current pi instance, refresh or keep the entry
61
- 4. If a live external owner exists, ask interactively whether to move singleton ownership here
61
+ 4. If a live polling owner exists, ask interactively whether to move singleton ownership here
62
62
 
63
63
  ## Acquisition timing
64
64
 
65
65
  Lock writes must be caused by an explicit user-initiated runtime event, such as a start/connect command or a confirmed takeover prompt.
66
66
 
67
- Extension initialization and session-start hooks may read `locks.json`, update local status, install ownership watchers, and resume local work when the existing lock already points at the current `pid`/`cwd`. After a full process restart, a session-start hook may replace a stale lock from the same `cwd` to restore explicitly requested ownership. They must not create ownership from an inactive lock, take over a live external owner, or replace a stale lock from another directory by themselves. Such locks should stay visible as state until the user runs the start/connect command. Session replacement should suspend local runtime work and ownership watchers without releasing the lock, so the next session in the same `pid`/`cwd` can resume from explicit ownership.
67
+ Extension initialization and session-start hooks may read `locks.json`, update local status, install ownership watchers, and resume local work when the existing lock already points at the current `pid`/`cwd`. After a full process restart, a session-start hook may replace a stale lock from the same `cwd` to restore explicitly requested ownership. They must not create ownership from an inactive lock, take over a live polling owner, or replace a stale lock from another directory by themselves. Such locks should stay visible as state until the user runs the start/connect command. Session replacement should suspend local runtime work and ownership watchers without releasing the lock, so the next session in the same `pid`/`cwd` can resume from explicit ownership.
68
68
 
69
69
  ## Optional fields
70
70
 
@@ -105,7 +105,7 @@ Extensions may prefix those states with their own compact name, such as `wakeup
105
105
  Start/connect commands should make singleton moves easy:
106
106
 
107
107
  1. If no live owner exists, take ownership without an extra prompt
108
- 2. If a live external owner exists, ask whether to move singleton ownership to this pi instance
108
+ 2. If a live polling owner exists, ask whether to move singleton ownership to this pi instance
109
109
  3. On confirmation, write the current `{ "pid": ..., "cwd": ... }` to this extension's key in `locks.json`
110
110
  4. The previous owner must notice that `locks.json` no longer points at its own `pid`/`cwd` and stop local runtime work without deleting the new lock
111
111
 
@@ -13,12 +13,12 @@ An outbound handler is selected by `type`. Text replies and assistant markup map
13
13
  | Source | Handler | Action |
14
14
  | ----------------- | ----------------------------- | ----------------------- |
15
15
  | Final text | `outboundHandlers[type=text]` | Transform before render |
16
- | `telegram_voice` | Voice pipeline | OGG/Opus `sendVoice` |
16
+ | `telegram_voice` | Voice pipeline | OGG/Opus `sendVoice` |
17
17
  | `telegram_button` | Built-in | Attach inline button |
18
18
 
19
19
  The voice pipeline is detailed below: configured `type: "voice"` handlers first, then programmatic handlers, then registered synthesis providers.
20
20
 
21
- Configured text handlers provide `template`. A string is one command; an array is ordered composition. Top-level `args` and `defaults` apply to all composed steps unless a step defines private values. The command-template default timeout applies automatically. Legacy configs may still use `pipe`, but `template: [...]` is the preferred standard shape.
21
+ Configured text handlers provide `template`. A string is one command; an array is ordered composition. Top-level `args` and `defaults` apply to all composed steps unless a step defines private values. The command-template default timeout applies automatically. Use `template: [...]` for composition; the old local `pipe` alias is removed in 0.13.0.
22
22
 
23
23
  ## Text Handler Config
24
24
 
@@ -60,24 +60,27 @@ Voice replies use one fallback pipeline:
60
60
 
61
61
  1. configured `outboundHandlers` with `type: "voice"` in `telegram.json` order
62
62
  2. programmatic `registerTelegramOutboundHandler("voice", ...)` handlers
63
- 3. registered voice synthesis providers from `@llblab/pi-telegram/lib/voice.ts`
63
+ 3. registered voice synthesis providers from `@llblab/pi-telegram/voice`
64
64
 
65
65
  This makes provider extensions a zero-config convenience without overriding explicit operator-owned `telegram.json` handlers. If several synthesis providers are registered, they are tried in registration order; the first provider that returns a valid `.ogg`/`.opus` artifact handles the reply. Returning `undefined` passes to the next provider, while thrown errors or invalid files are recorded before the next fallback is tried.
66
66
 
67
67
  ## Voice Synthesis Provider API
68
68
 
69
- Voice replies can be delivered by synthesis providers registered through `@llblab/pi-telegram/lib/voice.ts`:
69
+ Voice replies can be delivered by synthesis providers registered through `@llblab/pi-telegram/voice`:
70
70
 
71
71
  ```ts
72
- import { registerTelegramVoiceSynthesisProvider } from "@llblab/pi-telegram/lib/voice.ts";
73
-
74
- const dispose = registerTelegramVoiceSynthesisProvider(async (text, options) => {
75
- const audioPath = await synthesizeToOggOpus(text, options);
76
- return { audioPath, transcriptText: text };
77
- });
72
+ import { registerTelegramVoiceSynthesisProvider } from "@llblab/pi-telegram/voice";
73
+
74
+ const dispose = registerTelegramVoiceSynthesisProvider(
75
+ async (text, options) => {
76
+ const audioPath = await synthesizeToOggOpus(text, options);
77
+ return { audioPath, transcriptText: text };
78
+ },
79
+ { id: "my-extension/tts" },
80
+ );
78
81
  ```
79
82
 
80
- Synthesis providers receive the extracted `telegram_voice` text plus optional `lang`/`rate` hints. They 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.
83
+ 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.
81
84
 
82
85
  ## Voice Markup
83
86
 
@@ -0,0 +1,420 @@
1
+ # Public API
2
+
3
+ `pi-telegram` is both a π extension and a small Telegram platform for companion extensions. This document defines the stable public surface. Everything outside this document is implementation detail unless another focused doc explicitly marks it stable.
4
+
5
+ ## Stability Levels
6
+
7
+ - **Stable:** documented here and covered by compatibility expectations.
8
+ - **Advanced stable:** public for extension authors, but lower-level; prefer higher-level APIs when possible.
9
+ - **Compatibility:** older import/config paths that remain supported but should not be used for new code.
10
+ - **Internal:** exported from source for tests or domain reuse, but not a compatibility promise.
11
+
12
+ ## Package Entrypoints
13
+
14
+ Preferred public imports:
15
+
16
+ ```ts
17
+ import telegram from "@llblab/pi-telegram";
18
+ import { registerTelegramSection } from "@llblab/pi-telegram/sections";
19
+ import { registerTelegramUpdateHandler } from "@llblab/pi-telegram/updates";
20
+ import { registerTelegramInboundHandler } from "@llblab/pi-telegram/inbound";
21
+ import { registerTelegramOutboundHandler } from "@llblab/pi-telegram/outbound";
22
+ import {
23
+ registerTelegramVoiceSynthesisProvider,
24
+ registerTelegramVoiceTranscriptionProvider,
25
+ } from "@llblab/pi-telegram/voice";
26
+ ```
27
+
28
+ `0.12.0` intentionally removes the published `@llblab/pi-telegram/lib/*.ts` compatibility wildcard. Integrations should use the public API domain subpaths above. Package exports point at `/api/*.ts` membranes that re-export only stable companion-extension symbols; implementation modules under `lib/` remain package-private. See [Public API Smoke Examples](#public-api-smoke-examples) below for minimal companion-extension patterns that avoid implementation imports.
29
+
30
+ ## User-Facing API
31
+
32
+ ### π commands
33
+
34
+ Stable commands inside π:
35
+
36
+ - `/telegram-setup` — configure/update the bot token.
37
+ - `/telegram-connect` — start polling in the current session and acquire ownership.
38
+ - `/telegram-disconnect` — stop polling and release ownership.
39
+ - `/telegram-status` — show connection, polling, execution, queue, and recent event diagnostics.
40
+
41
+ ### Telegram commands
42
+
43
+ Stable commands inside the paired Telegram DM:
44
+
45
+ - `/start` — pair when needed and open the main application menu.
46
+ - `/compact` — open confirmation and compact when idle.
47
+ - `/next` — dispatch the next queued turn, aborting active work first when needed.
48
+ - `/continue` — enqueue a priority `continue` prompt.
49
+ - `/abort` — abort active Telegram-owned work and keep the queue.
50
+ - `/stop` — abort active Telegram-owned work and clear waiting Telegram queue items.
51
+
52
+ Hidden compatibility shortcuts may open sections directly: `/help`, `/status`, `/model`, `/thinking`, `/queue`, and `/settings`.
53
+
54
+ ### Tools and assistant-authored actions
55
+
56
+ - `telegram_attach(paths)` is the stable artifact delivery tool for generated files.
57
+ - `telegram_voice` hidden comments request Telegram-native voice delivery.
58
+ - `telegram_button` hidden comments create inline buttons whose taps enqueue prompts.
59
+
60
+ See [Outbound Handlers](./outbound.md) for exact markup forms.
61
+
62
+ ## Configuration API
63
+
64
+ Configuration lives in `~/.pi/agent/telegram.json` unless `PI_CODING_AGENT_DIR` changes the agent root.
65
+
66
+ Stable config keys:
67
+
68
+ ```ts
69
+ interface TelegramConfig {
70
+ botToken?: string;
71
+ botUsername?: string; // runtime-managed
72
+ botId?: number; // runtime-managed
73
+ allowedUserId?: number;
74
+ lastUpdateId?: number; // runtime-managed
75
+ proactivePush?: boolean;
76
+ inboundHandlers?: TelegramInboundHandlerConfig[];
77
+ attachmentHandlers?: TelegramInboundHandlerConfig[]; // compatibility alias
78
+ outboundHandlers?: TelegramOutboundHandlerConfig[];
79
+ voice?: {
80
+ replyMode?: "manual" | "mirror" | "always";
81
+ sendTranscript?: boolean;
82
+ };
83
+ time?: {
84
+ injectionMode?: "hidden" | "always" | "interval";
85
+ interval?: number;
86
+ };
87
+ }
88
+ ```
89
+
90
+ Hidden/default semantics are represented by absence:
91
+
92
+ - Voice Reply `hidden`: no `voice.replyMode` key is persisted.
93
+ - Time Injection `hidden`: no `time.injectionMode` key is persisted; if `time` becomes empty, the whole `time` object may be omitted.
94
+
95
+ Environment variables are stable only where documented in the README: bot-token bootstrap, proxy behavior, agent root, and inbound/outbound file size limits.
96
+
97
+ ## Programmatic API Matrix
98
+
99
+ High-level stable APIs:
100
+
101
+ - `registerTelegramSection()`
102
+ - Identity: required `id`.
103
+ - Purpose: managed menu/settings UI surfaces.
104
+ - `registerTelegramVoiceTranscriptionProvider()`
105
+ - Identity: required stable `id` for new code.
106
+ - Purpose: STT fallback for voice/audio input.
107
+ - `registerTelegramVoiceSynthesisProvider()`
108
+ - Identity: required stable `id` for new code.
109
+ - Purpose: TTS fallback for Telegram voice replies.
110
+
111
+ Low-level stable buses:
112
+
113
+ - `registerTelegramUpdateHandler()`
114
+ - Identity: no id.
115
+ - Purpose: observe or consume raw Telegram updates before default routing.
116
+ - `registerTelegramInboundHandler()`
117
+ - Identity: no id.
118
+ - Purpose: generic Telegram-to-π transforms.
119
+ - `registerTelegramOutboundHandler()`
120
+ - Identity: no id.
121
+ - Purpose: generic final-reply transforms or voice command fallbacks.
122
+
123
+ Advanced stable diagnostics:
124
+
125
+ - `recordTelegramRuntimeEvent()`
126
+ - Identity: caller supplies category.
127
+ - Purpose: surface companion diagnostics in `/telegram-status`.
128
+
129
+ 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.
130
+
131
+ ## Sections
132
+
133
+ Import from `@llblab/pi-telegram/sections`.
134
+
135
+ ```ts
136
+ const unregister = registerTelegramSection({
137
+ id: "@scope/my-extension",
138
+ label: "🧩 My extension",
139
+ order: 10,
140
+ render: async (ctx) => ({
141
+ text: "<b>My extension</b>",
142
+ parseMode: "html",
143
+ replyMarkup: {
144
+ inline_keyboard: [
145
+ [{ text: "▶️ Run", callback_data: ctx.callbackData("run") }],
146
+ ],
147
+ },
148
+ }),
149
+ handleCallback: async (ctx) => {
150
+ if (ctx.action !== "run") return "pass";
151
+ await ctx.enqueuePrompt("Run my extension workflow.");
152
+ await ctx.answerCallback("Queued");
153
+ return "handled";
154
+ },
155
+ });
156
+ ```
157
+
158
+ Contract:
159
+
160
+ - `id` is unique per active registry. Duplicate ids are rejected.
161
+ - `ctx.callbackData(action, payload?)` builds compact `section:` callbacks and validates Telegram's 64-byte limit.
162
+ - `ctx.edit()` auto-prepends the correct Back/Main-menu row. `ctx.open()` sends a standalone chat message without auto-navigation.
163
+ - Section errors are isolated and surfaced as callback popups/diagnostics.
164
+
165
+ Full behavior: [Extension Sections](./sections.md).
166
+
167
+ ## Updates
168
+
169
+ Import from `@llblab/pi-telegram/updates`.
170
+
171
+ ```ts
172
+ const off = registerTelegramUpdateHandler(async (update) => {
173
+ const data = (update as { callback_query?: { data?: string } }).callback_query
174
+ ?.data;
175
+ if (!data?.startsWith("myext:")) return "pass";
176
+ await handleMyCallback(data);
177
+ return "consume";
178
+ });
179
+ ```
180
+
181
+ Use this as a low-level escape hatch. Prefer sections for menu-integrated UI.
182
+
183
+ Full behavior: [Updates](./updates.md).
184
+
185
+ ## Inbound
186
+
187
+ Import from `@llblab/pi-telegram/inbound`.
188
+
189
+ ```ts
190
+ const off = registerTelegramInboundHandler("document", async ({ file }) => {
191
+ if (!file?.mimeType?.includes("pdf")) return undefined;
192
+ return await extractPdfText(file.path);
193
+ });
194
+ ```
195
+
196
+ Priority order:
197
+
198
+ 1. configured `inboundHandlers`
199
+ 2. compatibility `attachmentHandlers`
200
+ 3. programmatic inbound handlers
201
+ 4. voice transcription providers
202
+ 5. built-in text-file fallback
203
+
204
+ Full behavior: [Inbound Handlers](./inbound.md).
205
+
206
+ ## Outbound
207
+
208
+ Import from `@llblab/pi-telegram/outbound`.
209
+
210
+ ```ts
211
+ const off = registerTelegramOutboundHandler("text", async (text) => {
212
+ return await rewriteFinalText(text);
213
+ });
214
+ ```
215
+
216
+ Programmatic outbound handlers are fallbacks/transformers behind operator-owned `telegram.json` configuration. Voice delivery priority is configured voice handlers, then programmatic `voice` handlers, then synthesis providers.
217
+
218
+ Full behavior: [Outbound Handlers](./outbound.md).
219
+
220
+ ## Voice Providers
221
+
222
+ Import from `@llblab/pi-telegram/voice`.
223
+
224
+ ```ts
225
+ const offStt = registerTelegramVoiceTranscriptionProvider(
226
+ async (file) => {
227
+ if (file.kind !== "voice" && file.kind !== "audio") return undefined;
228
+ return { text: await transcribe(file.path) };
229
+ },
230
+ { id: "@scope/my-extension/stt" },
231
+ );
232
+
233
+ const offTts = registerTelegramVoiceSynthesisProvider(
234
+ async (text, options) => {
235
+ const audioPath = await synthesizeOggOpus(text, options);
236
+ return getTelegramVoiceSendTranscript(getCurrentTelegramConfigView())
237
+ ? { audioPath, transcriptText: text }
238
+ : { audioPath };
239
+ },
240
+ { id: "@scope/my-extension/tts" },
241
+ );
242
+ ```
243
+
244
+ 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.
245
+
246
+ Full behavior: [Voice Integration](./voice.md).
247
+
248
+ ## Public API Smoke Examples
249
+
250
+ Minimal companion-extension examples that import only stable `@llblab/pi-telegram/*` public membranes. Copy one into an extension `index.ts`, load it beside `pi-telegram`, and verify that it starts without importing any `@llblab/pi-telegram/lib/*` implementation path.
251
+
252
+ ### Extension Sections
253
+
254
+ ```ts
255
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
256
+ import { registerTelegramSection } from "@llblab/pi-telegram/sections";
257
+
258
+ export default function demoSection(pi: ExtensionAPI) {
259
+ let unregister: (() => void) | undefined;
260
+ pi.on("session_start", async () => {
261
+ unregister?.();
262
+ unregister = registerTelegramSection({
263
+ id: "demo-section/status",
264
+ label: "🧩 Demo section",
265
+ order: 50,
266
+ render: () => ({
267
+ text: "<b>Demo section</b>\n\nThis section was rendered by a companion extension.",
268
+ replyMarkup: { inline_keyboard: [] },
269
+ }),
270
+ });
271
+ });
272
+ pi.on("session_shutdown", async () => {
273
+ unregister?.();
274
+ unregister = undefined;
275
+ });
276
+ }
277
+ ```
278
+
279
+ ### Raw Update Handler
280
+
281
+ ```ts
282
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
283
+ import { registerTelegramUpdateHandler } from "@llblab/pi-telegram/updates";
284
+
285
+ export default function demoUpdates(pi: ExtensionAPI) {
286
+ let unregister: (() => void) | undefined;
287
+ pi.on("session_start", async () => {
288
+ unregister?.();
289
+ unregister = registerTelegramUpdateHandler((update) => {
290
+ if (!update || typeof update !== "object") return "pass";
291
+ return "pass";
292
+ });
293
+ });
294
+ pi.on("session_shutdown", async () => {
295
+ unregister?.();
296
+ unregister = undefined;
297
+ });
298
+ }
299
+ ```
300
+
301
+ ### Inbound Handler
302
+
303
+ ```ts
304
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
305
+ import { registerTelegramInboundHandler } from "@llblab/pi-telegram/inbound";
306
+
307
+ export default function demoInbound(pi: ExtensionAPI) {
308
+ let unregister: (() => void) | undefined;
309
+ pi.on("session_start", async () => {
310
+ unregister?.();
311
+ unregister = registerTelegramInboundHandler("text/*", async (file) => {
312
+ if (!file.path.endsWith(".demo.txt")) return undefined;
313
+ return `Demo inbound handler saw ${file.fileName ?? file.path}`;
314
+ });
315
+ });
316
+ pi.on("session_shutdown", async () => {
317
+ unregister?.();
318
+ unregister = undefined;
319
+ });
320
+ }
321
+ ```
322
+
323
+ ### Outbound Handler
324
+
325
+ ```ts
326
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
327
+ import { registerTelegramOutboundHandler } from "@llblab/pi-telegram/outbound";
328
+
329
+ export default function demoOutbound(pi: ExtensionAPI) {
330
+ let unregister: (() => void) | undefined;
331
+ pi.on("session_start", async () => {
332
+ unregister?.();
333
+ unregister = registerTelegramOutboundHandler("text", async (text) => {
334
+ if (!text.includes("[demo-outbound]")) return undefined;
335
+ return text.replace("[demo-outbound]", "Demo outbound handler:");
336
+ });
337
+ });
338
+ pi.on("session_shutdown", async () => {
339
+ unregister?.();
340
+ unregister = undefined;
341
+ });
342
+ }
343
+ ```
344
+
345
+ ### Voice Providers
346
+
347
+ ```ts
348
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
349
+ import {
350
+ getTelegramVoiceSendTranscript,
351
+ registerTelegramVoiceSynthesisProvider,
352
+ registerTelegramVoiceTranscriptionProvider,
353
+ } from "@llblab/pi-telegram/voice";
354
+
355
+ export default function demoVoice(pi: ExtensionAPI) {
356
+ let unregisterTts: (() => void) | undefined;
357
+ let unregisterStt: (() => void) | undefined;
358
+ let currentConfig: { voice?: { sendTranscript?: boolean } } = {};
359
+ pi.on("session_start", async () => {
360
+ unregisterTts?.();
361
+ unregisterStt?.();
362
+ unregisterTts = registerTelegramVoiceSynthesisProvider(
363
+ async (text) => {
364
+ const audioPath = await synthesizeDemoOgg(text);
365
+ return getTelegramVoiceSendTranscript(currentConfig)
366
+ ? { audioPath, transcriptText: text }
367
+ : { audioPath };
368
+ },
369
+ { id: "demo-voice/tts" },
370
+ );
371
+ unregisterStt = registerTelegramVoiceTranscriptionProvider(
372
+ async (file) => {
373
+ if (file.kind !== "voice" && file.kind !== "audio") return undefined;
374
+ return { text: `Demo transcript for ${file.fileName ?? file.path}` };
375
+ },
376
+ { id: "demo-voice/stt" },
377
+ );
378
+ });
379
+ pi.on("session_shutdown", async () => {
380
+ unregisterTts?.();
381
+ unregisterStt?.();
382
+ unregisterTts = undefined;
383
+ unregisterStt = undefined;
384
+ });
385
+ }
386
+
387
+ async function synthesizeDemoOgg(_text: string): Promise<string> {
388
+ throw new Error("Replace synthesizeDemoOgg with a real OGG/Opus generator.");
389
+ }
390
+ ```
391
+
392
+ ### Smoke Checklist
393
+
394
+ - The extension imports only `@llblab/pi-telegram/sections`, `/updates`, `/inbound`, `/outbound`, `/voice`, or `/keyboard`.
395
+ - It does not import `@llblab/pi-telegram/lib/*`.
396
+ - It registers on `session_start` and disposes on `session_shutdown`.
397
+ - Stable high-level registrations use durable ids.
398
+ - Failures are visible during manual testing through `/telegram-status` or extension-owned logging.
399
+
400
+ ## Callback Namespaces
401
+
402
+ Owned prefixes are reserved by `pi-telegram`: `compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, and `section:`.
403
+
404
+ Companion extensions should use their own short prefix for raw callbacks or use `ctx.callbackData()` inside sections. Unknown unowned callbacks may be forwarded to π as `[callback] <data>` after built-in handlers decline them.
405
+
406
+ Full behavior: [Callback Namespaces](./callback-namespaces.md).
407
+
408
+ ## Internal Surface
409
+
410
+ The following are not stable public contracts unless explicitly documented elsewhere:
411
+
412
+ - queue/runtime/lifecycle stores and planners
413
+ - menu implementation helpers
414
+ - polling/lock internals
415
+ - Telegram API transport helpers
416
+ - rendering internals
417
+ - command implementation helpers
418
+ - test support functions
419
+
420
+ They are intentionally not exposed through a `./lib/*.ts` export wildcard in `0.12.0`.