@llblab/pi-telegram 0.11.1 → 0.12.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 (45) hide show
  1. package/AGENTS.md +17 -12
  2. package/BACKLOG.md +0 -10
  3. package/CHANGELOG.md +33 -2
  4. package/README.md +42 -25
  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 +176 -135
  13. package/docs/callback-namespaces.md +3 -3
  14. package/docs/command-templates.md +81 -24
  15. package/docs/{inbound-handlers.md → inbound.md} +13 -10
  16. package/docs/locks.md +3 -3
  17. package/docs/{outbound-handlers.md → outbound.md} +13 -10
  18. package/docs/public-api.md +266 -0
  19. package/docs/{extension-sections.md → sections.md} +31 -27
  20. package/docs/ui-style.md +165 -0
  21. package/docs/{external-handlers.md → updates.md} +33 -31
  22. package/docs/voice.md +17 -14
  23. package/index.ts +86 -261
  24. package/lib/bindings.ts +301 -0
  25. package/lib/command-templates.ts +163 -32
  26. package/lib/commands.ts +114 -1
  27. package/lib/config.ts +45 -4
  28. package/lib/{inbound-handlers.ts → inbound.ts} +5 -4
  29. package/lib/lifecycle.ts +122 -1
  30. package/lib/menu-model.ts +3 -3
  31. package/lib/menu-queue.ts +1 -1
  32. package/lib/menu-settings.ts +63 -32
  33. package/lib/menu-status.ts +1 -1
  34. package/lib/menu.ts +1 -1
  35. package/lib/{outbound-handlers.ts → outbound.ts} +21 -11
  36. package/lib/pi.ts +4 -0
  37. package/lib/polling.ts +4 -3
  38. package/lib/preview.ts +1 -1
  39. package/lib/routing.ts +45 -13
  40. package/lib/{extension-sections.ts → sections.ts} +37 -8
  41. package/lib/time-injection.ts +1 -1
  42. package/lib/updates.ts +121 -1
  43. package/lib/voice.ts +33 -14
  44. package/package.json +11 -1
  45. package/lib/external-handlers.ts +0 -166
@@ -10,7 +10,7 @@
10
10
 
11
11
  Telegram Extension Sections let ordinary pi extensions add structured UI surfaces to the `pi-telegram` inline application menu. The platform mirrors π's own extensibility model: small, composable extensions that plug into a shared shell without owning transport, polling, authorization, or menu lifecycle.
12
12
 
13
- `pi-telegram` stays the single bot operator. Extensions register typed sections; the bridge handles rendering, callback routing, token mapping, navigation hierarchy, and diagnostics. No second poller, no new loader — just one `registerTelegramSection()` call.
13
+ `pi-telegram` stays the single bot operator. Extensions register typed sections; the bridge handles rendering, callback routing, token mapping, navigation hierarchy, and diagnostics. No second polling loop, no new loader — just one `registerTelegramSection()` call.
14
14
 
15
15
  ## 2. Contract Layers
16
16
 
@@ -39,7 +39,7 @@ The `id` is the owner identity. No separate `owner` field. Used for registry own
39
39
  ## 4. Registration Shape
40
40
 
41
41
  ```ts
42
- import { registerTelegramSection } from "@llblab/pi-telegram/lib/extension-sections.ts";
42
+ import { registerTelegramSection } from "@llblab/pi-telegram/sections";
43
43
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
44
44
 
45
45
  export default function (pi: ExtensionAPI) {
@@ -127,12 +127,12 @@ unregister(); // removes from main menu, settings, and callback routing
127
127
 
128
128
  Two paths, same registry:
129
129
 
130
- **Typed import (preferred):** Extension imports `registerTelegramSection` from `@llblab/pi-telegram/lib/extension-sections.ts`. The function reads from a `globalThis` registry set by `pi-telegram` at startup.
130
+ **Typed import (preferred):** Extension imports `registerTelegramSection` from `@llblab/pi-telegram/sections`. The function reads from a `globalThis` registry set by `pi-telegram` at startup. In `0.12.0`, package-private `@llblab/pi-telegram/lib/*.ts` deep imports are no longer exported.
131
131
 
132
- **Relative import (local):** When the extension cannot resolve `@llblab/pi-telegram` as an npm package, use a relative path:
132
+ **Relative import (local):** When the extension cannot resolve `@llblab/pi-telegram` as an npm package, use the public API membrane via a relative path:
133
133
 
134
134
  ```ts
135
- import { registerTelegramSection } from "../pi-telegram/lib/extension-sections.ts";
135
+ import { registerTelegramSection } from "../pi-telegram/api/sections.ts";
136
136
  ```
137
137
 
138
138
  **GlobalThis bridge (zero-coupling):** `pi-telegram` exposes `__piTelegramSectionRegistry__` on `globalThis`. The typed import is a thin wrapper. Extensions never touch the raw registry.
@@ -187,18 +187,19 @@ section:<token>:<action>:<payload>
187
187
 
188
188
  Example: `section:0:counter:5`
189
189
 
190
- The token is an implementation detail. Section authors **never** write `section:` strings manually. Use `ctx.callbackData(action, payload?)` which fills in the correct token.
190
+ The token is an implementation detail. Section authors **never** write `section:` strings manually. Use `ctx.callbackData(action, payload?)` which fills in the correct token and rejects callback data above Telegram's 64-byte limit.
191
191
 
192
192
  ### Routing order
193
193
 
194
- 1. Telegram update arrives through the single `pi-telegram` poller
195
- 2. External handlers observe/consume (raw update interception)
194
+ 1. Telegram update arrives through the single `pi-telegram` polling loop
195
+ 2. Update handlers observe/consume (raw update interception)
196
196
  3. Button action store (`tgbtn:*`)
197
- 4. Queue menu callbacks (`queue:*`)
198
- 5. Settings menu callbacks (`settings:*`)
199
- 6. Built-in menu callbacks (`menu:*`, `model:*`, `thinking:*`, `status:*`)
200
- 7. Section callbacks (`section:*`) dispatched before step 6's full handler
201
- 8. Unknown callbacks fall back to `[callback]` prompt text
197
+ 4. Compact confirmation callbacks (`compact:*`)
198
+ 5. Queue menu callbacks (`queue:*`)
199
+ 6. Settings menu callbacks (`settings:*`)
200
+ 7. Built-in menu callbacks (`menu:*`, `model:*`, `thinking:*`, `status:*`)
201
+ 8. Section callbacks (`section:*`) dispatched before step 7's full handler
202
+ 9. Unknown callbacks fall back to `[callback]` prompt text
202
203
 
203
204
  ### Handler return values
204
205
 
@@ -221,7 +222,7 @@ If a section is unregistered or a token is unknown, the callback is answered wit
221
222
 
222
223
  > "This section is no longer available."
223
224
 
224
- Section errors are caught and surfaced as popup text. No unhandled exceptions leak to the poller.
225
+ Section errors are caught and surfaced as popup text. No unhandled exceptions leak to polling.
225
226
 
226
227
  ## 8. Navigation Hierarchy
227
228
 
@@ -298,7 +299,7 @@ Context ports are intentionally narrow. Sections **cannot**:
298
299
 
299
300
  - Read/write filesystem
300
301
  - Access raw process or bot clients
301
- - Start a second poller
302
+ - Start a second polling loop
302
303
  - Mutate session state
303
304
  - Send arbitrary Telegram API calls
304
305
 
@@ -320,12 +321,15 @@ handleCallback: async (ctx) => {
320
321
  text: `<b>Delete ${ctx.payload}?</b>\n\nThis cannot be undone.`,
321
322
  parseMode: "html",
322
323
  replyMarkup: {
323
- inline_keyboard: [[
324
- { text: "✅ Yes, delete",
325
- callback_data: ctx.callbackData("confirm-delete", ctx.payload) },
326
- { text: " Cancel",
327
- callback_data: ctx.callbackData("cancel") },
328
- ]],
324
+ inline_keyboard: [
325
+ [
326
+ {
327
+ text: " Yes, delete",
328
+ callback_data: ctx.callbackData("confirm-delete", ctx.payload),
329
+ },
330
+ { text: "❌ Cancel", callback_data: ctx.callbackData("cancel") },
331
+ ],
332
+ ],
329
333
  },
330
334
  });
331
335
  return "handled";
@@ -339,7 +343,7 @@ handleCallback: async (ctx) => {
339
343
  await ctx.deleteMessage();
340
344
  return "handled";
341
345
  }
342
- }
346
+ };
343
347
  ```
344
348
 
345
349
  `ctx.deleteMessage()` removes the dialog from chat after the user makes a choice. Callbacks from chat buttons route through the same `handleCallback` — the same `ctx.callbackData()` works regardless of where the button lives. The extension owns its callback namespace; the bridge owns transport.
@@ -356,7 +360,7 @@ section:0:settings:open → open settings root
356
360
  section:0:<action>:<payload> → forwarded to handleCallback
357
361
  ```
358
362
 
359
- `section:` is listed in `TELEGRAM_OWNED_CALLBACK_PREFIXES` alongside `menu:`, `model:`, `settings:`, `status:`, `tgbtn:`, `thinking:`, `queue:`. Layered extensions must not use this prefix.
363
+ `section:` is listed in `TELEGRAM_OWNED_CALLBACK_PREFIXES` alongside `compact:`, `menu:`, `model:`, `settings:`, `status:`, `tgbtn:`, `thinking:`, `queue:`. Layered extensions must not use this prefix.
360
364
 
361
365
  ### Inline keyboard layout
362
366
 
@@ -378,7 +382,7 @@ The platform inherits from π's own extension model:
378
382
 
379
383
  - `export default function(pi)` → `registerTelegramSection(section)`
380
384
  - `pi.on("shutdown", ...)` → disposer from `registerTelegramSection`
381
- - Typed imports → typed import from `@llblab/pi-telegram/lib/extension-sections.ts`
385
+ - Typed imports → typed import from `@llblab/pi-telegram/sections`
382
386
  - `globalThis` registry → `__piTelegramSectionRegistry__` on `globalThis`
383
387
  - Identity from `package.json/name` → same identity rules as Locks Standard
384
388
  - Narrow typed context ports → `TelegramSectionContext` / `TelegramSectionCallbackContext`
@@ -419,7 +423,7 @@ Available programmatically via `getTelegramSectionDiagnostics()`. Section runtim
419
423
 
420
424
  ### Non-goals:
421
425
 
422
- - No second Telegram poller
426
+ - No second Telegram polling loop
423
427
  - No new pi extension loader
424
428
  - No generic webview system
425
429
  - No default filesystem mutation API
@@ -429,8 +433,8 @@ Available programmatically via `getTelegramSectionDiagnostics()`. Section runtim
429
433
  ## 14. Relationship to Other Standards
430
434
 
431
435
  - [Callback Namespaces](./callback-namespaces.md): defines `section:` as pi-telegram-owned prefix. Sections use namespaced callbacks but authors never hand-roll them
432
- - [External Handlers](./external-handlers.md): raw update interception for direct Telegram update access. Sections are the structured UI layer above
433
- - [Extension Locks](../docs/locks.md) (external): same identity key rules (`package.json/name` → canonical id)
436
+ - [Updates](./updates.md): raw update interception for direct Telegram update access. Sections are the structured UI layer above
437
+ - [Extension Locks](../docs/locks.md) (polling): same identity key rules (`package.json/name` → canonical id)
434
438
  - [Command Templates](./command-templates.md): sections do not execute command templates by default. UI registration + callback routing, not shell execution
435
439
 
436
440
  ## 15. Demo Extension
@@ -0,0 +1,165 @@
1
+ # UI Style Guide
2
+
3
+ Small standard for inline buttons, menu rows, state controls, cards, and confirmation dialogs.
4
+
5
+ ## Principles
6
+
7
+ - Keep UI compact and phone-readable.
8
+ - Put emoji where they help scanning, not everywhere.
9
+ - Use one strong indicator for current selection; avoid emoji noise on every option.
10
+ - Match label casing to control role.
11
+ - Prefer minimal, clear configuration UI over exhaustive explanation.
12
+ - Preserve domain-owned callback prefixes and behavior in the owning module.
13
+
14
+ ## Action Buttons
15
+
16
+ Action buttons perform an operation.
17
+
18
+ Rules:
19
+
20
+ - Use an emoji plus capitalized action text.
21
+ - Prefer direct verb or action noun.
22
+ - Keep labels short.
23
+
24
+ Examples:
25
+
26
+ - `🗜 Yes, compact`
27
+ - `❌ No`
28
+ - `🗑 Yes, delete`
29
+ - `☑️ Activate`
30
+
31
+ ## State & Navigation Buttons
32
+
33
+ State buttons show the current state and navigate to a submenu or detail rather than performing an operation directly.
34
+
35
+ Rules:
36
+
37
+ - Use an emoji that reflects the current state.
38
+ - Use Capitalized, descriptive state text.
39
+ - Tapping opens a submenu or returns to the parent list.
40
+
41
+ Examples:
42
+
43
+ - `🟢 Active` — model detail, navigates back to model list
44
+ - `📌 Proactive push: On` — settings row, opens the toggle submenu
45
+ - `👄 Voice reply: Mirror` — settings row, opens the option list
46
+
47
+ ## Boolean Toggles
48
+
49
+ Boolean settings use a horizontal `On` / `Off` pair, like a checkbox stretched across two buttons.
50
+
51
+ Rules:
52
+
53
+ - Keep the pair in one row: `On` left, `Off` right.
54
+ - Use Capitalized labels.
55
+ - Always show an indicator on both buttons to avoid horizontal label shift.
56
+ - Mark active `On` with `🟢`.
57
+ - Mark active `Off` with `🟡`.
58
+ - Mark the inactive value with `⚫️`.
59
+
60
+ Examples:
61
+
62
+ - `🟢 On` / `⚫️ Off`
63
+ - `⚫️ On` / `🟡 Off`
64
+
65
+ ## Horizontal Tabs
66
+
67
+ Tabs or small mutually-exclusive scopes use a horizontal row.
68
+
69
+ Rules:
70
+
71
+ - Use Capitalized labels.
72
+ - Always show an indicator on every tab to avoid horizontal label shift.
73
+ - Use active tab color to convey semantics:
74
+ - `🟣` for the default / normal state (All models, Normal priority).
75
+ - `🟡` for an elevated or filtered state (Scoped models, Priority).
76
+ - `🟣` for neutral navigation controls (page picker).
77
+ - Mark inactive tabs with `⚫️`.
78
+
79
+ Examples:
80
+
81
+ - `🟡 Scoped` / `⚫️ All`
82
+ - `⚫️ Priority` / `🟣 Normal`
83
+ - `1` / `🟣 2` / `3`
84
+
85
+ ## Vertical Option Lists
86
+
87
+ Vertical option lists choose one value from a potentially longer list, for example model selection, thinking level, voice reply mode, or time injection mode.
88
+
89
+ Rules:
90
+
91
+ - Put each option on its own row.
92
+ - Mark only the current value with `🟢`.
93
+ - Leave non-current values without emoji.
94
+ - Use lowercase labels when the option is a value.
95
+
96
+ Examples:
97
+
98
+ - `🟢 mirror`
99
+ - `manual`
100
+ - `always`
101
+
102
+ ## Navigation
103
+
104
+ Inline submenu navigation is hierarchical.
105
+
106
+ Rules:
107
+
108
+ - Put the navigation row first.
109
+ - First-level submenus opened from the main inline menu start with `⬆️ Main menu`.
110
+ - Deeper submenus start with `⬆️ Back`.
111
+ - `Main menu` returns to the root inline menu.
112
+ - `Back` returns one level up, never directly to the root unless the parent is the root.
113
+
114
+ Examples:
115
+
116
+ - Main menu → Settings: first row is `⬆️ Main menu`.
117
+ - Settings → Voice reply mode: first row is `⬆️ Back`.
118
+
119
+ ## Message Cards
120
+
121
+ Message cards sent by the bot should start with a strong heading.
122
+
123
+ Rules:
124
+
125
+ - Start with a bold heading or, for dialogs, a bold question.
126
+ - Setting detail cards may include an emoji in the heading, then a colon and the current value in `<code>`.
127
+ - Explain what the setting does and what the options mean only as much as needed.
128
+ - Keep descriptions short and clear.
129
+
130
+ Examples:
131
+
132
+ ```html
133
+ <b>👄 Voice reply mode:</b> <code>mirror</code>
134
+ ```
135
+
136
+ ```html
137
+ <b>Queue</b>
138
+ ```
139
+
140
+ ## Confirmation Dialogs
141
+
142
+ Confirmation dialogs protect risky or disruptive actions.
143
+
144
+ Rules:
145
+
146
+ - Body text is one bold text-only question.
147
+ - Do not put emoji in the dialog question.
148
+ - Do not add explanatory body copy unless the risk cannot be understood from the question and action labels.
149
+ - Put emoji on the buttons, not in the question.
150
+ - Preserve dialog-specific button order by intent.
151
+
152
+ Example:
153
+
154
+ ```html
155
+ <b>Compact session?</b>
156
+ ```
157
+
158
+ Buttons:
159
+
160
+ - `🗜 Yes, compact`
161
+ - `❌ No`
162
+
163
+ ## Callback Ownership
164
+
165
+ UI style does not change callback ownership. Callback prefixes remain owned by their feature domain and must be listed in callback namespace documentation when they become public collision risks.
@@ -1,10 +1,12 @@
1
- # External Handlers
1
+ # Updates
2
2
 
3
- `pi-telegram` owns a single `getUpdates` long-poll connection per bot. Other pi extensions cannot open a competing poller against the same bot — the Telegram Bot API uses a per-bot `offset` cursor, and two pollers race each other and lose updates.
3
+ `updates` owns Telegram update classification, default-routing plans, and the public update-handler registry. The internal `polling` domain owns the actual `getUpdates` loop, offsets, and abort/controller state.
4
+
5
+ `pi-telegram` owns a single `getUpdates` long-poll connection per bot. Other pi extensions cannot open a competing polling connection against the same bot — the Telegram Bot API uses a per-bot `offset` cursor, and two loops race each other and lose updates.
4
6
 
5
7
  This document describes the registry that lets layered pi extensions running in the same pi process hook into `pi-telegram`'s polling loop and react to inbound Telegram updates **before** `pi-telegram`'s default routing fires.
6
8
 
7
- It is the runtime counterpart to [Callback Namespaces](./callback-namespaces.md): callback namespaces define how to share `callback_data` cleanly; external handlers define how to observe and optionally short-circuit the dispatch of those updates.
9
+ It is the runtime counterpart to [Callback Namespaces](./callback-namespaces.md): callback namespaces define how to share `callback_data` cleanly; update handlers define how to observe and optionally short-circuit the dispatch of those updates.
8
10
 
9
11
  ## When to use it
10
12
 
@@ -16,34 +18,34 @@ Use it when a layered extension needs to:
16
18
 
17
19
  If the layered extension only needs to read assistant-visible callbacks, the existing `[callback] <data>` fallback documented in [Callback Namespaces](./callback-namespaces.md) is enough.
18
20
 
19
- If the extension needs a durable top-level Telegram menu section with managed rendering, callback routing, authorization, and diagnostics, use the higher-level [Telegram Extension Sections](./extension-sections.md) contract instead of a raw external handler.
21
+ If the extension needs a durable top-level Telegram menu section with managed rendering, callback routing, authorization, and diagnostics, use the higher-level [Telegram Extension Sections](./sections.md) contract instead of a raw update handler.
20
22
 
21
23
  ## Constraints
22
24
 
23
- - One bot, one pi process, one `getUpdates` poller. This registry does **not** enable running multiple pi instances against the same bot.
24
- - Interceptors run in the polling loop. They must return quickly; long awaits delay subsequent updates.
25
- - Interceptor errors are caught and logged silently so polling never breaks. If you need durable error reporting, do it inside your interceptor.
25
+ - One bot, one pi process, one `getUpdates` loop. This registry does **not** enable running multiple pi instances against the same bot.
26
+ - Handlers run in the polling loop. They must return quickly; long awaits delay subsequent updates.
27
+ - Handler errors are caught and logged silently so polling never breaks. If you need durable error reporting, do it inside your handler.
26
28
  - The registry lives on `globalThis`. Module instance identity is not required, so layered extensions can reach it without importing `@llblab/pi-telegram`.
27
29
 
28
30
  ## Verdicts
29
31
 
30
- Each interceptor returns one of:
32
+ Each handler returns one of:
31
33
 
32
34
  - `"consume"` — `pi-telegram` skips its default routing for this update.
33
- - `"pass"` or `void` / `undefined` — `pi-telegram` routes the update normally. Other interceptors registered after this one still run for the same update.
35
+ - `"pass"` or `void` / `undefined` — `pi-telegram` routes the update normally. Other handlers registered after this one still run for the same update.
34
36
 
35
- The first interceptor that returns `"consume"` wins; later interceptors are not called for that update.
37
+ The first handler that returns `"consume"` wins; later handlers are not called for that update.
36
38
 
37
- ## Registering an interceptor
39
+ ## Registering a handler
38
40
 
39
41
  Two equivalent paths.
40
42
 
41
43
  ### Typed import (recommended when you can depend on `@llblab/pi-telegram`)
42
44
 
43
45
  ```ts
44
- import { onTelegramExternalUpdate } from "@llblab/pi-telegram/lib/external-handlers.ts";
46
+ import { registerTelegramUpdateHandler } from "@llblab/pi-telegram/updates";
45
47
 
46
- const off = onTelegramExternalUpdate(async (update) => {
48
+ const off = registerTelegramUpdateHandler(async (update) => {
47
49
  const cb = (update as { callback_query?: { id?: string; data?: string } })
48
50
  .callback_query;
49
51
  if (!cb?.data?.startsWith("myext:")) return "pass";
@@ -57,7 +59,7 @@ off();
57
59
 
58
60
  ### Zero-coupling globalThis lookup
59
61
 
60
- When the layered extension prefers no `import` from `@llblab/pi-telegram`, so load order between the two extensions does not matter and either can be installed first, it must implement the **full v1 registry contract**, not just `version` and `add`. pi-telegram's polling runtime calls `dispatch` on whatever object it finds at `globalThis.__piTelegramExternalHandlerRegistry__`, so a partial object would silently break the first update.
62
+ When the layered extension prefers no `import` from `@llblab/pi-telegram`, so load order between the two extensions does not matter and either can be installed first, it must implement the **full v1 registry contract**, not just `version` and `add`. pi-telegram's polling runtime calls `dispatch` on whatever object it finds at `globalThis.__piTelegramUpdateHandlerRegistry__`, so a partial object would silently break the first update.
61
63
 
62
64
  pi-telegram defensively re-creates the registry if the object on `globalThis` is missing `add` or `dispatch`, validated as `version === 1`, `typeof add === "function"`, and `typeof dispatch === "function"`. Handlers registered against a malformed object are dropped — make sure your bootstrap implements all three fields.
63
65
 
@@ -67,21 +69,21 @@ type PiTelegramVerdict =
67
69
  | "pass"
68
70
  | void
69
71
  | Promise<"consume" | "pass" | void>;
70
- type PiTelegramInterceptor = (update: unknown) => PiTelegramVerdict;
72
+ type PiTelegramUpdateHandler = (update: unknown) => PiTelegramVerdict;
71
73
 
72
- interface PiTelegramExternalHandlerRegistry {
74
+ interface PiTelegramUpdateHandlerRegistry {
73
75
  readonly version: 1;
74
- add: (handler: PiTelegramInterceptor) => () => void;
76
+ add: (handler: PiTelegramUpdateHandler) => () => void;
75
77
  // Required: pi-telegram's polling loop calls this on every update.
76
78
  dispatch: (update: unknown) => Promise<"consume" | "pass">;
77
79
  }
78
80
 
79
- const REGISTRY_KEY = "__piTelegramExternalHandlerRegistry__";
81
+ const REGISTRY_KEY = "__piTelegramUpdateHandlerRegistry__";
80
82
 
81
- function getOrCreateRegistry(): PiTelegramExternalHandlerRegistry {
83
+ function getOrCreateRegistry(): PiTelegramUpdateHandlerRegistry {
82
84
  const g = globalThis as Record<string, unknown>;
83
85
  const existing = g[REGISTRY_KEY] as
84
- | PiTelegramExternalHandlerRegistry
86
+ | PiTelegramUpdateHandlerRegistry
85
87
  | undefined;
86
88
  if (
87
89
  existing &&
@@ -91,8 +93,8 @@ function getOrCreateRegistry(): PiTelegramExternalHandlerRegistry {
91
93
  ) {
92
94
  return existing;
93
95
  }
94
- const handlers = new Set<PiTelegramInterceptor>();
95
- const registry: PiTelegramExternalHandlerRegistry = {
96
+ const handlers = new Set<PiTelegramUpdateHandler>();
97
+ const registry: PiTelegramUpdateHandlerRegistry = {
96
98
  version: 1,
97
99
  add(handler) {
98
100
  handlers.add(handler);
@@ -104,7 +106,7 @@ function getOrCreateRegistry(): PiTelegramExternalHandlerRegistry {
104
106
  const result = await handler(update);
105
107
  if (result === "consume") return "consume";
106
108
  } catch {
107
- // Never break polling because of an interceptor error.
109
+ // Never break polling because of a handler error.
108
110
  }
109
111
  }
110
112
  return "pass";
@@ -120,32 +122,32 @@ const off = getOrCreateRegistry().add((update) => {
120
122
  });
121
123
  ```
122
124
 
123
- The registry object on `globalThis.__piTelegramExternalHandlerRegistry__` is versioned (`version: 1`) and stable across pi-telegram releases; future breaking changes will use a new schema version and a new key.
125
+ The registry object on `globalThis.__piTelegramUpdateHandlerRegistry__` is versioned (`version: 1`) and stable across pi-telegram releases; future breaking changes will use a new schema version and a new key.
124
126
 
125
127
  ## Interaction with built-in routing
126
128
 
127
- `pi-telegram` invokes registered interceptors first, then routes the update through its own handlers: commands, app menu, queue menu, model menu, default prompt routing, and callback namespace fallback. If any interceptor returns `"consume"`, `pi-telegram` skips the rest of routing for that update.
129
+ `pi-telegram` invokes registered handlers first, then routes the update through its own handlers: commands, app menu, queue menu, model menu, default prompt routing, and callback namespace fallback. If any handler returns `"consume"`, `pi-telegram` skips the rest of routing for that update.
128
130
 
129
131
  This means:
130
132
 
131
133
  - Extensions can claim callback namespaces that `pi-telegram` would otherwise forward as `[callback] <data>` text.
132
134
  - Extensions can observe updates by always returning `"pass"`.
133
- - Extensions must not consume updates that belong to `pi-telegram`'s own prefixes (`tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`) unless they are deliberately replacing that behavior.
135
+ - Extensions must not consume updates that belong to `pi-telegram`'s own prefixes (`compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, `section:`) unless they are deliberately replacing that behavior.
134
136
 
135
137
  ## Ownership semantics
136
138
 
137
- The interceptor registry is ownership-agnostic and does not interact with the `locks.json` singleton lock documented in [Locks](./locks.md). When the locked polling runtime stops `pi-telegram`'s poller, for example after ownership is moved to another pi process, interceptors stop receiving updates because no updates are being fetched. They are not unregistered.
139
+ The handler registry is ownership-agnostic and does not interact with the `locks.json` singleton lock documented in [Locks](./locks.md). When the locked polling runtime stops `pi-telegram`'s `getUpdates` loop, for example after ownership is moved to another pi process, handlers stop receiving updates because no updates are being fetched. They are not unregistered.
138
140
 
139
- If a layered extension needs to react to ownership changes, it should observe `pi-telegram` lifecycle events through the standard pi extension hooks rather than through the interceptor registry.
141
+ If a layered extension needs to react to ownership changes, it should observe `pi-telegram` lifecycle events through the standard pi extension hooks rather than through the handler registry.
140
142
 
141
143
  ## Not a multiplexer
142
144
 
143
- This registry does not multiplex one bot across multiple pi processes, and it does not bypass Telegram's single-poller-per-bot constraint. To run multiple pi instances on Telegram, give each instance its own bot and its own `~/.pi/agent` directory; the registry is for layered extensions inside **one** pi process.
145
+ This registry does not multiplex one bot across multiple pi processes, and it does not bypass Telegram's single-polling-connection-per-bot constraint. To run multiple pi instances on Telegram, give each instance its own bot and its own `~/.pi/agent` directory; the registry is for layered extensions inside **one** pi process.
144
146
 
145
147
  ## Relationship to extension sections
146
148
 
147
- External handlers are the raw update primitive. Extension sections are the structured Telegram UI layer above that primitive.
149
+ Update handlers are the raw update primitive. Extension sections are the structured Telegram UI layer above that primitive.
148
150
 
149
- Use external handlers for immediate update interception, custom callback namespaces, out-of-band Promise resolution, and update types that should not become a Telegram menu surface.
151
+ Use update handlers for immediate update interception, custom callback namespaces, out-of-band Promise resolution, and update types that should not become a Telegram menu surface.
150
152
 
151
153
  Use extension sections when the desired behavior is a menu-integrated UI: `render(ctx)`, managed callback dispatch, safe runtime ports, stale-callback handling, and diagnostics owned by `pi-telegram`.
package/docs/voice.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Voice Integration
2
2
 
3
- Voice messages flow through an **inbound transcription → outbound voice reply** pipeline. This document describes the bridge's role in that pipeline; provider-specific mechanics (TTS/STT backends, voice IDs, languages) are owned by voice provider extensions. In `0.11.0`, this is a first-class extension surface: one companion extension can provide STT fallbacks for inbound voice/audio files and TTS fallbacks for outbound Telegram voice replies without owning a second bot poller.
3
+ Voice messages flow through an **inbound transcription → outbound voice reply** pipeline. This document describes the bridge's role in that pipeline; provider-specific mechanics (TTS/STT backends, voice IDs, languages) are owned by voice provider extensions. In `0.11.0`, this is a first-class extension surface: one companion extension can provide STT fallbacks for inbound voice/audio files and TTS fallbacks for outbound Telegram voice replies without owning a second bot polling loop.
4
4
 
5
5
  ## Overview
6
6
 
@@ -29,7 +29,7 @@ Inbound handlers match `kind: "voice"` or `mime: "audio/*"` to run a transcripti
29
29
 
30
30
  The transcription output becomes the raw text of the prompt.
31
31
 
32
- Voice provider extensions can also register STT backends with `registerTelegramVoiceTranscriptionProvider()` from `@llblab/pi-telegram/lib/voice.ts`. Inbound command-template handlers and programmatic inbound handlers remain the stronger generic paths and run first; if no matching handler produces output for a voice/audio file, registered transcription providers are tried as fallback in registration order. The first provider that returns non-empty text wins; providers that return `undefined` pass to the next provider, and provider failures are recorded before trying the next provider. This lets a full voice extension provide both TTS and STT without requiring `telegram.json` handler templates, while still preserving operator-configured inbound handlers as the stronger choice.
32
+ Voice provider extensions can also register STT backends with `registerTelegramVoiceTranscriptionProvider()` from `@llblab/pi-telegram/voice`. Inbound command-template handlers and programmatic inbound handlers remain the stronger generic paths and run first; if no matching handler produces output for a voice/audio file, registered transcription providers are tried as fallback in registration order. The first provider that returns non-empty text wins; providers that return `undefined` pass to the next provider, and provider failures are recorded before trying the next provider. This lets a full voice extension provide both TTS and STT without requiring `telegram.json` handler templates, while still preserving operator-configured inbound handlers as the stronger choice.
33
33
 
34
34
  ## Voice Reply Policy
35
35
 
@@ -79,7 +79,7 @@ The bridge shows a `record_voice` action while delivering and sends the final au
79
79
 
80
80
  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.
81
81
 
82
- See the TSDoc on `registerTelegramVoiceSynthesisProvider` and `TelegramVoiceSynthesisProviderResult` in `lib/voice.ts` for the exact interface.
82
+ Import provider APIs from `@llblab/pi-telegram/voice`; see the TSDoc on `registerTelegramVoiceSynthesisProvider` and `TelegramVoiceSynthesisProviderResult` there for the exact interface.
83
83
 
84
84
  The provider receives the raw agent text plus optional `{ lang?, rate? }`.
85
85
 
@@ -93,7 +93,7 @@ It must return one of:
93
93
 
94
94
  **File format:** Telegram `sendVoice` requires **OGG/Opus** to display the message as a native voice note (waveform, inline playback). MP3 and other formats are accepted by the API but render as regular audio attachments (music note icon, filename visible). **Providers and outbound voice handlers must return `.ogg` or `.opus` files.** Returning non-OGG files causes the bridge to throw and fall back to text delivery.
95
95
 
96
- Registration returns a disposer function for cleanup. Extensions should call it on shutdown or re-register safely on session start when their runtime is recreated.
96
+ Registration returns a disposer function for cleanup. Stable provider registrations pass a durable `id` in options; omitted ids remain a compatibility path for older providers and receive generated session-local ids. Extensions should call disposers on shutdown or re-register safely on session start when their runtime is recreated.
97
97
 
98
98
  ## Outbound Voice Handlers
99
99
 
@@ -124,14 +124,17 @@ Priority for outbound voice delivery is: configured `outboundHandlers` with `typ
124
124
  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`).
125
125
 
126
126
  ```typescript
127
- import { registerTelegramVoiceSynthesisProvider } from "@llblab/pi-telegram/lib/voice.ts";
128
-
129
- registerTelegramVoiceSynthesisProvider(async (text, options) => {
130
- const rewritten = rewriteWithSpeechTags(text);
131
- const audioPath = await myTTS(rewritten, { language: options?.lang });
132
- const sendTranscript = getUserSendTranscriptPreference(); // from your UI + telegram.json
133
- return sendTranscript ? { audioPath, transcriptText: text } : { audioPath };
134
- });
127
+ import { registerTelegramVoiceSynthesisProvider } from "@llblab/pi-telegram/voice";
128
+
129
+ registerTelegramVoiceSynthesisProvider(
130
+ async (text, options) => {
131
+ const rewritten = rewriteWithSpeechTags(text);
132
+ const audioPath = await myTTS(rewritten, { language: options?.lang });
133
+ const sendTranscript = getUserSendTranscriptPreference(); // from your UI + telegram.json
134
+ return sendTranscript ? { audioPath, transcriptText: text } : { audioPath };
135
+ },
136
+ { id: "my-voice-provider/tts" },
137
+ );
135
138
  ```
136
139
 
137
140
  The bridge never sends a separate transcript message. Caption-only is the "ON" behavior.
@@ -141,7 +144,7 @@ The bridge never sends a separate transcript message. Caption-only is the "ON" b
141
144
  Voice provider extensions can record runtime events that appear in `/telegram-status` alongside pi-telegram's own events:
142
145
 
143
146
  ```typescript
144
- import { recordTelegramRuntimeEvent } from "@llblab/pi-telegram/lib/outbound-handlers.ts";
147
+ import { recordTelegramRuntimeEvent } from "@llblab/pi-telegram/outbound";
145
148
 
146
149
  recordTelegramRuntimeEvent("xai-voice", new Error("TTS failed"), {
147
150
  phase: "tts",
@@ -155,7 +158,7 @@ recordTelegramRuntimeEvent("xai-voice", new Error("TTS failed"), {
155
158
 
156
159
  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.
157
160
 
158
- **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` in `lib/extension-sections.ts`.
161
+ **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`.
159
162
 
160
163
  ## Prompt Guidance
161
164