@llblab/pi-telegram 0.35.1 → 0.36.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.
@@ -0,0 +1,310 @@
1
+ # Generative Apps Runtime For Telegram
2
+
3
+ _Status: incremental implementation. Canonical installation and explicit transactional replacement, agent-side method invocation, state/history commits, partial-tail recovery, cross-process transition locking with dead-owner recovery, installation-generation plus revision rejection for direct app-output controls, lifecycle-cancelled worker-isolated methods, the bounded non-shell process port, strict bound-action parsing, pre-model-queue `tgbtn` dispatch, new-message default views, and opt-in in-place bound-action edits with explicit-action send fallback are implemented locally. Agent-mediated initial-surface revision capture, process-birth lock proof, voice delivery, automatic refresh scheduling, removal, and complete lifecycle diagnostics remain open in the backlog._
4
+
5
+ ## Purpose
6
+
7
+ This document specifies the concrete Generative App runtime implemented by `pi-telegram`. The transport-independent concept, vocabulary, application shapes, hybrid action model, and agent operating workflow belong to the bundled [`generative-apps` Skill](../skills/generative-apps/SKILL.md).
8
+
9
+ The Telegram implementation provides managed installation, method execution, persistence, button binding, callback routing, and message delivery:
10
+
11
+ ```text
12
+ Telegram control → bound method → state/capability owner
13
+ Telegram view ← rendered output ← fresh result
14
+ ```
15
+
16
+ This runtime coexists with ordinary prompt buttons, companion-extension callbacks, Sections, and the Delivery API. It ships no application catalog or `examples/` tree; reusable domain scripts remain with their capability owners.
17
+
18
+ ## Ownership Split
19
+
20
+ - The bundled [`generative-apps` Skill](../skills/generative-apps/SKILL.md) owns the general concept and agent operation: category definition, `generated` versus `generative`, application shapes, hybrid method/prompt surfaces, selection, authorship, review, workflow, safety, and validation judgment.
21
+ - [`architecture.md`](./architecture.md#generative-apps) owns this runtime's place inside the Telegram bridge and its domain boundaries.
22
+ - This document owns only `pi-telegram` implementation contracts: canonical managed identity, executable ABI, Telegram wire syntax, state timeline, installation/replacement, bounded ports, callback routing, delivery, lifecycle, and current limitations.
23
+ - [`generated-control-surface`](../skills/generated-control-surface/SKILL.md) owns the separate ephemeral control-surface operating protocol.
24
+
25
+ Keep conceptual guidance out of this document and Telegram runtime mechanics out of the Generative Apps Skill.
26
+
27
+ ## Canonical Layout And Identity
28
+
29
+ Generative Apps live under the active Pi agent directory, never in package installation files or temporary storage:
30
+
31
+ ```text
32
+ <agent-dir>/genapps/
33
+ └── poker/
34
+ ├── poker.mjs
35
+ ├── state.json
36
+ └── states.jsonl
37
+ ```
38
+
39
+ Identity is structural:
40
+
41
+ ```text
42
+ app = directory name = module stem
43
+ poker = poker = poker.mjs
44
+ ```
45
+
46
+ No app manifest, per-app `package.json`, duplicated `name`, class registration, or default export is required. The `.mjs` extension supplies ESM semantics directly.
47
+
48
+ An app name is a unique lowercase ASCII identifier accepted by the runtime's path-safe validation. It must not contain path separators, `..`, `::`, or a native callback namespace delimiter.
49
+
50
+ ## Inference-Bypass Syntax
51
+
52
+ Compact Matrix Literal and full JSON buttons keep their existing `label + prompt` contract. A bound action is encoded entirely in the prompt string:
53
+
54
+ ```ebnf
55
+ bound-action = app "::" method [ "(" json-value ")" ]
56
+ ```
57
+
58
+ Examples:
59
+
60
+ ```text
61
+ poker::fold
62
+ poker::call(18)
63
+ poker::init({"seed":"abc"})
64
+ media::seek("+30s")
65
+ ```
66
+
67
+ No argument means no decorative empty parentheses. One optional argument is a strict JSON value; the runtime never evaluates JavaScript source from the argument.
68
+
69
+ CML:
70
+
71
+ ```text
72
+ [{Fold|poker::fold}{Call 18|poker::call(18)}]
73
+ ```
74
+
75
+ Equivalent JSON:
76
+
77
+ ```json
78
+ [
79
+ [
80
+ { "label": "Fold", "prompt": "poker::fold" },
81
+ { "label": "Call 18", "prompt": "poker::call(18)" }
82
+ ]
83
+ ]
84
+ ```
85
+
86
+ `app` is not a button property. Both representations normalize to the same prompt string, and routing happens afterward.
87
+
88
+ The double colon is the inference-bypass operator: it routes a generated prompt control to a registered deterministic owner before Pi queue admission. Native extension callbacks retain their existing single-colon grammar:
89
+
90
+ ```text
91
+ myext:action:payload native callback_data namespace
92
+ poker::call(18) generated prompt routed to a Generative App
93
+ ```
94
+
95
+ These routes do not conflict. Native callbacks are direct by construction. `::` exists only because an ordinary generated button prompt would otherwise enter the model queue.
96
+
97
+ An absent, stale, or invalid bound app fails closed and never degrades into an accidental model prompt.
98
+
99
+ ## `telegram_bind` Tool
100
+
101
+ One agent Tool owns installation and deliberate invocation through two mutually exclusive shapes.
102
+
103
+ Install an external self-contained module and initialize it:
104
+
105
+ ```ts
106
+ telegram_bind({
107
+ app: "poker",
108
+ script: "/path/to/poker.mjs",
109
+ argument: { seed: "abc" }
110
+ })
111
+ ```
112
+
113
+ The runtime copies the module to `<agent-dir>/genapps/poker/poker.mjs`, validates the canonical identity and required exports, transactionally invokes `init(argument)`, and initializes state. Existing installation is never overwritten without explicit replacement authority. Installed canonical modules are discovered directly when a bound action resolves; there is no separate app registry.
114
+
115
+ Explicitly replace an installed app after editing its script:
116
+
117
+ ```ts
118
+ telegram_bind({
119
+ app: "poker",
120
+ script: "/path/to/poker.mjs",
121
+ replace: true,
122
+ argument: { seed: "abc" }
123
+ })
124
+ ```
125
+
126
+ Replacement validates and initializes a complete staging app before publishing it under the existing app name. A failed `init` preserves the installed module, state, and timeline. Omitting `replace: true` keeps duplicate installation fail-closed, while setting it for an absent app also fails instead of silently changing replacement into installation.
127
+
128
+ Discover or reuse an app already written at its canonical path and invoke a named method:
129
+
130
+ ```ts
131
+ telegram_bind({
132
+ app: "poker",
133
+ method: "init",
134
+ argument: { seed: "abc" }
135
+ })
136
+ ```
137
+
138
+ Agent-side diagnostic invocation uses the same shape:
139
+
140
+ ```ts
141
+ telegram_bind({ app: "poker", method: "inspect" })
142
+ ```
143
+
144
+ `script` and `method` are mutually exclusive, and `replace` is valid only with `script`. Script installation or replacement implicitly invokes mandatory `init`; existing-app invocation names its method explicitly. Folder presence supplies durable discoverability across runtime replacement without introducing a manifest.
145
+
146
+ During an active Telegram turn, `telegram_bind` displays successful app output directly through the current outbound planner and exact turn target by default, including initial `init` output; its Tool result tells the agent not to repeat or reformat the delivered view. Set `display: false` for agent-only diagnosis. Outside an active Telegram turn, the Tool returns bounded output for exact caller-owned presentation rather than choosing a Telegram target implicitly. The same method invoked through `app::method(argument)` routes its rendered output directly to the owning Telegram surface.
147
+
148
+ ## Module Contract
149
+
150
+ A Generative App exports plain named async or synchronous functions. `init` is mandatory. Classes and default exports are outside the contract.
151
+
152
+ ```js
153
+ export async function init({ argument, run, signal }) {
154
+ const seed = argument?.seed ?? "default";
155
+ return {
156
+ state: { seed, turn: 0 },
157
+ output: "**Ready**\n\n<!-- telegram_button {Start|poker::start} -->"
158
+ };
159
+ }
160
+
161
+ export async function start({ state }) {
162
+ const nextState = { ...state, turn: state.turn + 1 };
163
+ return {
164
+ state: nextState,
165
+ output: `**Turn:** \`${nextState.turn}\``
166
+ };
167
+ }
168
+
169
+ export async function inspect({ state }) {
170
+ return { output: JSON.stringify(state) };
171
+ }
172
+ ```
173
+
174
+ The runtime context may contain only bounded capabilities required by the contract:
175
+
176
+ - Current immutable app state, absent for first initialization.
177
+ - Parsed optional JSON argument.
178
+ - Cancellation signal and current app revision.
179
+ - A bounded non-shell process port for coherent CLI adapters.
180
+ - Redacted app/target metadata needed for diagnostics and rendering ownership.
181
+
182
+ The runtime does not pass a raw Telegram client, bot token, Pi extension context, arbitrary transport operation, or mutable queue/session state.
183
+
184
+ A method result contains:
185
+
186
+ ```ts
187
+ interface GenerativeAppResult {
188
+ state?: JsonValue;
189
+ output: string;
190
+ viewMode?: "new" | "edit";
191
+ }
192
+ ```
193
+
194
+ `output` is ordinary assistant Markdown plus existing top-level voice/button markup. It passes through the established outbound planner rather than defining a second rendering language. Omitted `viewMode` defaults to `"new"`: the result arrives as a fresh message and the clicked button remains visibly selected on its prior surface. `viewMode: "edit"` opts one result into replacing the callback message and keyboard in place when Telegram permits it; edit failure after that explicit action may fall back to one new message.
195
+
196
+ Returning `state` requests a committed transition. Omitting `state` makes the method output-only, which supports inspection and live refresh without appending duplicate history. Invalid, oversized, non-serializable, or malformed results fail before state or Telegram effects commit.
197
+
198
+ ## Current State And State Timeline
199
+
200
+ `state.json` is the compact current projection read by the runtime and, when useful, by the agent:
201
+
202
+ ```json
203
+ {
204
+ "seed": "abc",
205
+ "turn": 2
206
+ }
207
+ ```
208
+
209
+ `states.jsonl` is the committed-state timeline. Its first line is the successful initial state; each later state-changing method appends one complete snapshot envelope:
210
+
211
+ ```jsonl
212
+ {"revision":0,"method":"init","argument":{"seed":"abc"},"state":{"seed":"abc","turn":0}}
213
+ {"revision":1,"method":"start","state":{"seed":"abc","turn":1}}
214
+ {"revision":2,"method":"start","state":{"seed":"abc","turn":2}}
215
+ ```
216
+
217
+ The state in `state.json` equals the state in the latest complete journal line. Runtime-owned locking, revision checks, complete-line append, atomic replacement, and recovery preserve that relation across concurrent clicks and interruption. A partial final JSONL line is never treated as committed state.
218
+
219
+ A successful `init` is a hard new-run boundary. It transactionally clears current state and prior history, writes the new initial snapshot as revision zero, and publishes the initial output. Initialization failure preserves the previous working state and journal unchanged.
220
+
221
+ Application state is the app's complete persistent checkpoint: it includes interaction/configuration state plus the latest normalized external projection needed to render, diagnose, or reconstruct the current view. An agent reading `state.json` should be able to identify material app reality such as selected track, playback state, queue position, backend, and last observation without executing the app first. This projection is explicitly a last-observed cache, not the external domain authority: before a mutation, explicit status, or refresh, a CLI-backed app re-reads the actual owner, then commits a new complete snapshot only when retained app state materially changes.
222
+
223
+ ## CLI Capability Adapters
224
+
225
+ A Generative App may compose several existing CLI tools when they belong to one coherent domain or user journey:
226
+
227
+ ```text
228
+ media Generative App → playerctl + mpv + local media library
229
+ git Generative App → git + gh
230
+ actors Generative App → documented Actor runtime capabilities
231
+ ```
232
+
233
+ A bounded process port uses executable plus argument arrays, an explicit working directory, output limits, timeout, cancellation, and redacted evidence. Shell interpolation is not the default contract.
234
+
235
+ ```js
236
+ const result = await run({
237
+ command: "playerctl",
238
+ args: ["metadata", "--format", "{{artist}} — {{title}}"],
239
+ timeoutMs: 10_000
240
+ });
241
+ ```
242
+
243
+ A generic `exec(arbitrary-shell-command)` Generative App is forbidden. It would turn Telegram into a remote terminal, bypass bounded capability ownership, and violate the mobile companion boundary. A direct button click authorizes only the installed app method and its validated argument, never arbitrary process execution.
244
+
245
+ ## Live Views
246
+
247
+ A Generative App sends a new message after a successful bound user action by default. This simple mode preserves prior surfaces and their visibly selected buttons, is robust across ordinary Telegram constraints, and remains a first-class behavior rather than a fallback to eliminate. A method may opt into `viewMode: "edit"` to replace the callback message and keyboard in place; if that explicit action cannot edit a deleted or otherwise unavailable message, it may send one fresh view because the click itself supplies recreation authority.
248
+
249
+ Automatic refresh is not implemented in the current runtime. The intended future contract uses an exported `refresh` method and a bounded scheduling hint; applications must not return or rely on that hint until the backlog item is complete:
250
+
251
+ ```js
252
+ export async function refresh({ state, run }) {
253
+ return {
254
+ output: await renderPlayer(state.player, run),
255
+ refreshAfterMs: 5000
256
+ };
257
+ }
258
+ ```
259
+
260
+ The runtime contract is:
261
+
262
+ - Missing `refreshAfterMs` stops automatic refresh.
263
+ - Values below two seconds clamp to two seconds.
264
+ - The next interval starts only after the prior refresh and Telegram edit settle; calls never overlap or accumulate.
265
+ - One refresh schedule exists per app, profile, target, and logical surface.
266
+ - An unchanged normalized frame digest causes no Telegram edit.
267
+ - Telegram `retry_after`, bounded backoff, lifecycle cancellation, target authority, and execution generation remain authoritative.
268
+ - Refresh is session-bound and does not silently resume after process replacement until the surface is opened again.
269
+ - Output-only refresh does not change `state.json` or append `states.jsonl`.
270
+
271
+ The runtime retains the latest `TelegramDeliveryHandle` in memory for each live app surface. The first frame sends a logical view; later app actions and refreshes edit that same view rather than creating message traffic.
272
+
273
+ Telegram does not reliably report deletion of every ordinary private bot message. When a supported deletion update identifies the handle, the runtime invalidates it immediately. When edit returns a known message-not-found result, the runtime forgets the handle and stops refresh. It never recreates a user-deleted view automatically; the next explicit user action or app opening may create a fresh view.
274
+
275
+ ## Lifecycle And Safety
276
+
277
+ Generative Apps are trusted local code and therefore an explicit capability grant, not a sandbox promise. The runtime still narrows accidental authority and operational failure:
278
+
279
+ - Installation validates canonical paths and rejects traversal, symlinks outside the managed root, identity mismatch, and silent replacement; explicit replacement stages and initializes the new app before swapping it under the same app.
280
+ - App execution cannot own Telegram polling, credentials, raw transport, Pi queue state, or another app's files through the provided contract.
281
+ - Per-app transitions serialize and compare immutable installation generation plus state revision so stale buttons cannot cross replacement or mutate newer state.
282
+ - State commit and Telegram effect ordering are explicit; ambiguous non-idempotent transport outcomes never replay blindly.
283
+ - Time, output, state-size, refresh-rate, and process bounds prevent one app from monopolizing the extension.
284
+ - Session/profile/target generation replacement makes old scheduled work inert.
285
+ - Diagnostics redact secrets and preserve app name, method, revision, failure class, and bounded stderr/result evidence.
286
+ - Removal cancels refresh, invalidates the live binding, and keeps destructive state deletion as a separate explicit operation.
287
+
288
+ ## Application Roles
289
+
290
+ The runtime supports two ownership roles without shipping application templates:
291
+
292
+ - A standalone deterministic app owns its complete application state and transition rules. Poker-like games are the reference shape for app-owned state, not a bundled catalog entry.
293
+ - A view/controller adapter owns only validated adapter configuration and a last-observed projection. A music-player remote is the reference shape: the Actor remains authoritative, while the Generative App samples structured status, invokes bounded controls, and renders the next view.
294
+
295
+ Both roles use the same module, state, bound-action, and safety contracts. Selection and authoring procedure belong to the bundled `generative-apps` Skill.
296
+
297
+ ## Validation Contract
298
+
299
+ Implementation is not complete until evidence covers:
300
+
301
+ - Canonical path identity, direct app discovery, copy/install, explicit replacement with failure preservation, removal, and traversal rejection.
302
+ - Mandatory `init`, named method dispatch, no-argument and strict-JSON argument parsing, missing exports, and result validation.
303
+ - Transactional initialization, state/history equality, concurrent/stale actions, partial journal recovery, and output-only methods.
304
+ - CML and full JSON button equivalence, inference bypass before Pi queue admission, absent-owner failure, and unchanged native callback routing.
305
+ - Direct classic, leader, and follower target delivery with generation fencing and no model turn.
306
+ - CLI process timeout, cancellation, output bounds, stderr diagnostics, and arbitrary-shell rejection.
307
+ - Live-view handle retention, unchanged-frame suppression, two-second minimum, non-overlap, coalescing, Telegram backoff, deletion invalidation, message-not-found handling, and lifecycle cancellation.
308
+ - Poker-style internal state and media-style external-state reference applications.
309
+
310
+ The canonical open implementation work remains in [`../BACKLOG.md`](../BACKLOG.md). This document owns the proposed subsystem contract and its architectural boundaries.
@@ -316,7 +316,7 @@ Threaded Mode should make follower threads behave like normal Telegram instance
316
316
  | Surface | Leader behavior | Follower requirement | Routing/ownership invariant | Regression evidence |
317
317
  | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
318
318
  | Prompt intake | Thread prompt queues locally | Thread prompt is forwarded and queued by the owning follower | Target ownership routes by `{ chatId, threadId }` before local handling | Routing tests for foreign target message forwarding |
319
- | Queued-message removal reactions | 👎/👻/💔/💩/🗑 removes pending prompt/media turn | Same reaction on a queued follower prompt removes that follower's pending turn before dispatch | When the leader forwards a prompt to a follower, it records `chatId/messageId -> follower instance` because Bot API reaction updates expose chat/message but not thread id | Update runtime regression records forwarded message ownership and forwards the later reaction |
319
+ | Queued-message removal reactions | 👎/👻/💔/💩/🗑 marks a pending prompt/media turn for deletion when it reaches dispatch | Same reaction on a queued follower prompt marks that follower's pending turn for deletion before model dispatch | When the leader forwards a prompt to a follower, it records `chatId/messageId -> follower instance` because Bot API reaction updates expose chat/message but not thread id | Update runtime regression records forwarded message ownership and forwards the later reaction |
320
320
  | Queue priority reactions | 👍/⚡/❤/🕊/🔥 prioritizes queued prompts | Same reactions prioritize follower queued prompts | Reaction forwarding uses stored message ownership, then follower mutates its local queue | Reaction mutation tests plus forwarded-reaction coverage |
321
321
  | Message edits | Edits update matching queued prompt text | Edits in a follower thread update that follower's queued prompt | Message target ownership forwards edits to the owning instance; stored message ownership is the fallback when Telegram edit payloads omit thread id | Update routing tests for foreign target and message-owned edited-message forwarding |
322
322
  | Callbacks/buttons/menus | Callback handled by the owning instance/menu state | Follower callbacks are forwarded to the owning follower; follower menu sends/edits/deletes route through leader transport | Leader records ownership for follower-sent Bot API messages so callbacks can route by message id even when Telegram omits thread id; Bot API edit/delete lacks thread id, so follower bus allows validated same-chat message operations | Callback forwarding, generated-button target, bus follower-sent ownership, and bus edit/delete allowlist tests |
package/docs/outbound.md CHANGED
@@ -164,11 +164,11 @@ I can continue.
164
164
 
165
165
  Rules:
166
166
 
167
- - `telegram_button` accepts a JSON object, JSON matrix, [Compact Matrix Literal](./compact-matrix-literal.md), or double-quoted HTML-like attributes; `telegram_buttons` is an exact plural alias. CML uses `{value}` or `{label|prompt}`, trims both atoms, preserves non-structural printable text, and decodes only `\|`, `\}`, and `\\`. Shorthand, body, paired-comment, unquoted-attribute, and single-quoted-attribute forms are rejected.
167
+ - `telegram_button` accepts a JSON object, JSON matrix, [Compact Matrix Literal](./compact-matrix-literal.md), or double-quoted HTML-like attributes; `telegram_buttons` is an exact plural alias. CML uses `{value}`, `{label|prompt}`, or `{label|prompt|selected_style}`; the optional third atom requires a prompt and accepts only `primary`, `success`, or `danger`. It trims every atom, preserves non-structural printable text, and decodes only `\|`, `\}`, and `\\`. Shorthand, body, paired-comment, unquoted-attribute, and single-quoted-attribute forms are rejected.
168
168
  - A colon after either button marker is rejected so every payload form shares one unambiguous action marker.
169
169
  - Use `label` plus `prompt`, or the compact `value` key when both strings are identical. Explicit `label` or `prompt` takes precedence over its `value` fallback. Use JSON with `\n` escapes for multiline prompts.
170
170
  - The opening marker must start at column zero on a top-level line outside fenced code, quotes, lists, and indented examples; otherwise it remains literal Markdown.
171
- - Prefer one matrix comment for multiple buttons. Each top-level JSON object or CML cell becomes one full-width inline-keyboard row in source order; a nested row groups one or more buttons horizontally. The parser imposes no artificial per-row width cap; empty rows, malformed cells, unknown/trailing CML escapes, a second unescaped CML separator, and deeper nesting are rejected atomically. Generated surfaces default to five columns and expand to six through eight only for short position-bearing labels. Repeated singular comments remain valid.
171
+ - Prefer one matrix comment for multiple buttons. Each top-level JSON object or CML cell becomes one full-width inline-keyboard row in source order; a nested row groups one or more buttons horizontally. The parser imposes no artificial per-row width cap; empty rows, malformed cells, unknown/trailing CML escapes, a third unescaped CML separator, empty atoms, unknown selected styles, and deeper nesting are rejected atomically. Generated surfaces default to five columns and expand to six through eight only for short position-bearing labels. Repeated singular comments remain valid.
172
172
  - Button actions are stored in memory with short `callback_data`; Telegram never sees the full prompt in the button payload.
173
173
  - After Telegram accepts a generated button callback as a queued prompt, the bridge changes that exact button to its configured selection style without changing agent-authored text or emoji. Set `selected_style` to `primary` (blue), `success` (green), or `danger` (red); omitted or invalid values fall back to `primary`. The style never suppresses queue admission. Other choices stay visually unchanged and remain available; the callback acknowledgement remains the fallback on clients that do not render button styles.
174
174
  - When generated button markup is the entire assistant reply, the bridge supplies the standard `☑️ **Choose an option:**` heading as visible message text so Telegram has a message to which it can attach the inline keyboard.
@@ -61,11 +61,12 @@ This command surface is a mobile companion subset, not a raw terminal-command br
61
61
 
62
62
  ### Tools and assistant-authored actions
63
63
 
64
+ - `telegram_bind({ app, script, argument? } | { app, method, argument? })` installs and initializes one canonical managed Generative App module under `<agent-dir>/genapps/<app>/<app>.mjs`, or invokes one named method on an installed app. Installation rejects silent replacement and noncanonical/symlink sources. Methods receive immutable JSON state, one optional JSON argument, cancellation, revision, and a bounded non-shell process port; successful state changes commit to `state.json` plus `states.jsonl`, while output-only methods leave history unchanged. After one-shot `tgbtn` resolution, a complete `app::method` or `app::method(<strict JSON>)` prompt invokes the installed app before Pi queue admission and sends its planned Markdown/buttons directly; malformed or failed bound actions never fall back to a model prompt. Direct app-output buttons retain hidden source revisions and stale actions fail before method execution; sibling processes serialize transitions and recover dead lock owners. Bound actions send a fresh message by default and retain the clicked button's selected state on its prior surface. A result may opt into `viewMode: "edit"` to replace the callback message and keyboard in place, with one fresh-send fallback only for that explicit action. Agent-mediated initial-surface revisions, process-birth lock proof, automatic refresh, and voice output remain open.
64
65
  - `telegram_attach(paths, chat_id?, thread_id?, caption?)` is the stable artifact delivery tool for generated files. During Telegram turns it queues files for the active reply; with `assistant.rendering: "rich"`, exactly one PNG/JPEG, MP4, or MP3 artifact plus non-empty final Markdown can become one reply-anchored Rich Message. HTML mode, multiple/unsupported files, Guest Mode, and voice outputs retain their established paths. Outside Telegram turns the tool sends files directly to the paired/default chat, the registered follower's assigned thread, or an explicit `chat_id` plus optional `thread_id` when this Pi instance owns `/telegram-connect` or is registered with the multi-instance bus.
65
66
  - `telegram_message(text, chat_id?, thread_id?)` sends a direct Telegram Markdown message when this Pi instance owns `/telegram-connect` or is registered with the multi-instance bus. During an active Telegram turn, omitted targeting and an explicit target equal to that turn are rejected so the ordinary final-reply path remains the sole current-target response; an explicit different chat/thread target remains allowed for requested cross-target delivery. Outside active turns, paired/default local/TUI delivery remains unchanged. Top-level `telegram_button` comments inside `text` are parsed with the same planner used for normal replies and attached to that message; buttons are never standalone Telegram messages.
66
- - The bundled `telegram-bridge` Skill owns action syntax, target routing, Threaded Mode, formatting, and profile-specific debugging guidance. The regular prompt routes applicable turns to that Skill. `telegram_attach` and `telegram_message` remain registered but are model-active only while this instance owns direct transport or holds a live follower registration; disconnect/loss suppresses their schemas and prompt metadata, and recovery restores only the operator's previously active pi-telegram subset.
67
+ - The bundled `telegram-bridge` Skill owns action syntax, target routing, Threaded Mode, formatting, Generative App operation, and profile-specific debugging guidance. The regular prompt routes applicable turns to that Skill. `telegram_attach`, `telegram_bind`, and `telegram_message` remain registered but are model-active only while this instance owns direct transport or holds a live follower registration; disconnect/loss suppresses their schemas and prompt metadata, and recovery restores only the operator's previously active pi-telegram subset.
67
68
  - `telegram_voice` hidden comments request Telegram-native voice delivery through either a JSON object or double-quoted attributes. Equivalent `text` or `value` supplies the spoken payload; explicit `text` takes precedence.
68
- - `telegram_button` hidden comments create inline buttons whose taps enqueue prompts. One marker accepts a JSON object, JSON matrix, [Compact Matrix Literal](./compact-matrix-literal.md), or double-quoted attributes; `telegram_buttons` is an exact plural alias. Top-level matrix cells become full-width rows, while nested rows group one or more buttons horizontally without an artificial parser-width cap. CML uses `{value}` or `{label|prompt}`, trims atom boundaries, and supports only the minimal escapes `\|`, `\}`, and `\\`. Prefer one matrix comment for multiple buttons. Use JSON `label` plus `prompt`, or `value` when both strings are identical. Action markers are colon-free; colon-prefixed payloads are rejected. Use top-level column-zero comments outside code, quotes, lists, and indented examples; do not emit standalone button actions.
69
+ - `telegram_button` hidden comments create inline buttons whose taps enqueue prompts. One marker accepts a JSON object, JSON matrix, [Compact Matrix Literal](./compact-matrix-literal.md), or double-quoted attributes; `telegram_buttons` is an exact plural alias. Top-level matrix cells become full-width rows, while nested rows group one or more buttons horizontally without an artificial parser-width cap. CML uses `{value}`, `{label|prompt}`, or `{label|prompt|selected_style}`; the optional third atom requires a prompt and accepts only `primary`, `success`, or `danger`. It trims atom boundaries and supports only the minimal escapes `\|`, `\}`, and `\\`. Prefer one matrix comment for multiple buttons. Use JSON `label` plus `prompt`, or `value` when both strings are identical. Action markers are colon-free; colon-prefixed payloads are rejected. Use top-level column-zero comments outside code, quotes, lists, and indented examples; do not emit standalone button actions.
69
70
 
70
71
  Prompt guidance is context-aware: local/TUI prompts see only explicit direct-delivery guidance, while Telegram-originated turns receive the full action-comment syntax and phone-width output contract.
71
72
 
package/docs/ui-style.md CHANGED
@@ -54,7 +54,7 @@ Use emoji as stable semantic markers, not decoration. Emoji carry transportable
54
54
  | `➡️` | Choose replacement target | Thread replace/restore target buttons that select which Pi instance should move to the current thread | Use inside the second replace/restore chooser, not for ordinary reroutes. |
55
55
  | `☑️` | Activate / choose this item | Model detail activation action, generated button-only choice heading | Positive selection cue; use `🟢 Active` for already-current state. |
56
56
  | `❌` | No / cancel | Confirmation cancel buttons | Use for safe cancellation, not destructive removal. |
57
- | `🗑` | Delete / suppress | Queue delete actions, destructive confirmations, suppression reaction | The explicit queue button deletes; the reaction reversibly suppresses a waiting turn. |
57
+ | `🗑` | Delete / defer removal | Destructive confirmations and removal reaction | In the queue menu, reversible Keep/Skip selectors replace immediate deletion. |
58
58
 
59
59
  ### State Indicators And Button Grammars
60
60
 
@@ -68,7 +68,14 @@ Use emoji as stable semantic markers, not decoration. Emoji carry transportable
68
68
 
69
69
  ### Queue Reaction Shortcuts
70
70
 
71
- Queue reactions are shortcut controls for waiting turns. Preserve their semantics across Telegram reactions, queue-menu rows, status previews, and tests.
71
+ Queue reactions are shortcut controls for waiting turns. Preserve their semantics across Telegram reactions, queue-menu rows, status previews, and tests. Positive emoji control the Priority/Normal lane; negative emoji control Keep/Skip. These categories are independent, may coexist, and mutate only their own dimension. Crossing lanes appends the prompt at the destination FIFO tail; changing Keep/Skip or changing emoji within one category preserves lane position. Skip wins only when dispatch reaches the prompt.
72
+
73
+ Queue item detail renders two independent selector rows:
74
+
75
+ - `🟡 Priority` / `⚫️ Normal` or `⚫️ Priority` / `🟣 Normal` selects the lane.
76
+ - `🟢 Keep` / `⚫️ Skip` or `⚫️ Keep` / `🟡 Skip` selects dispatch disposition.
77
+
78
+ The menu may clear internal Skip but cannot remove a reaction created by the user through Telegram's Bot API.
72
79
 
73
80
  | Emoji | Meaning | Canonical surfaces | Notes |
74
81
  | --- | --- | --- | --- |
@@ -77,11 +84,11 @@ Queue reactions are shortcut controls for waiting turns. Preserve their semantic
77
84
  | `❤` / `❤️` | Promote to priority | Queue reaction shortcut | Normalize display consistently where code normalizes reactions. |
78
85
  | `🕊` / `🕊️` | Promote to priority | Queue reaction shortcut | Soft/peaceful promotion gesture. |
79
86
  | `🔥` | Promote to priority | Queue reaction shortcut | Urgent/hot promotion gesture. |
80
- | `👎` | Suppress waiting turn | Queue reaction shortcut and suppressed queue badge | Suppression is reversible and is not negative feedback to the agent. |
81
- | `👻` | Suppress waiting turn | Queue reaction shortcut and suppressed queue badge | Disappear/suppress metaphor. |
82
- | `💔` | Suppress waiting turn | Queue reaction shortcut and suppressed queue badge | Reversible cancel metaphor. |
83
- | `💩` | Suppress waiting turn | Queue reaction shortcut and suppressed queue badge | Reversible reject metaphor. |
84
- | `🗑` | Suppress or explicitly delete | Queue reaction shortcut, suppressed queue badge, and queue delete UI | The reaction is reversible suppression; only the explicit queue button is destructive. |
87
+ | `👎` | Defer removal of waiting turn | Queue reaction shortcut and queue emoji marker | Reversible until the marked turn reaches dispatch; not negative feedback to the agent. |
88
+ | `👻` | Defer removal of waiting turn | Queue reaction shortcut and queue emoji marker | Disappear/remove metaphor. |
89
+ | `💔` | Defer removal of waiting turn | Queue reaction shortcut and queue emoji marker | Reversible cancel metaphor. |
90
+ | `💩` | Defer removal of waiting turn | Queue reaction shortcut and queue emoji marker | Reversible reject metaphor. |
91
+ | `🗑` | Defer removal | Queue reaction shortcut and queue emoji marker | Like Skip, the reaction remains reversible until dispatch reaches the marked turn. |
85
92
 
86
93
  ### Decorative Or Local-Example Emoji
87
94
 
package/index.ts CHANGED
@@ -228,6 +228,8 @@ export default function (pi: Pi.ExtensionAPI) {
228
228
  const proactivePushChatIdGetter =
229
229
  Config.createTelegramProactivePushChatIdGetter(proactivePushTargetGetter);
230
230
  const buttonActionStore = Outbound.createTelegramButtonActionStore();
231
+ const planGenerativeAppOutput =
232
+ Outbound.createTelegramOutboundReplyPlanner(buttonActionStore);
231
233
  const pendingModelSwitchStore =
232
234
  Model.createPendingModelSwitchStore<
233
235
  Model.ScopedTelegramModel<ActivePiModel>
@@ -502,6 +504,16 @@ export default function (pi: Pi.ExtensionAPI) {
502
504
  getHandlers: configStore.getOutboundHandlers,
503
505
  recordRuntimeEvent,
504
506
  });
507
+ const invokeGenerativeAppBoundButtonAction =
508
+ Bindings.createTelegramGenerativeAppBoundButtonActionInvoker({
509
+ agentDir: Paths.resolveAgentDir(),
510
+ assertExecutionCurrent: Updates.assertTelegramUpdateExecutionCurrent,
511
+ getExecutionFence: Updates.getTelegramUpdateExecutionFence,
512
+ planOutput: planGenerativeAppOutput,
513
+ sendMarkdownReply,
514
+ editInteractiveMessage,
515
+ recordRuntimeEvent,
516
+ });
505
517
  const {
506
518
  activityRuntime,
507
519
  activityVerbosityRuntime,
@@ -760,6 +772,7 @@ export default function (pi: Pi.ExtensionAPI) {
760
772
  settingsMenuCallbackHandler: settingsMenuRuntime.handleCallbackQuery,
761
773
  sectionRegistry,
762
774
  buttonActionStore,
775
+ invokeBoundButtonAction: invokeGenerativeAppBoundButtonAction,
763
776
  inboundHandlerRuntime,
764
777
  threadStore,
765
778
  updateStatus,
@@ -1219,6 +1232,7 @@ export default function (pi: Pi.ExtensionAPI) {
1219
1232
 
1220
1233
  Bindings.registerTelegramCommandsAndTools({
1221
1234
  pi,
1235
+ agentDir: Paths.resolveAgentDir(),
1222
1236
  configStore,
1223
1237
  persistConfig: persistTelegramConfigWithSync,
1224
1238
  setup,
package/lib/bindings.ts CHANGED
@@ -26,6 +26,7 @@ import * as Setup from "./setup.ts";
26
26
  import * as Status from "./status.ts";
27
27
  import * as TelegramApi from "./telegram-api.ts";
28
28
  import type { TelegramTarget } from "./target.ts";
29
+ import * as GenerativeApps from "./generative-apps.ts";
29
30
 
30
31
  type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
31
32
 
@@ -53,10 +54,7 @@ export interface TelegramQueueBindingRuntime<TContext> {
53
54
 
54
55
  export function createTelegramQueueBindingRuntime<TContext>(deps: {
55
56
  store: Queue.TelegramQueueStateStore<TContext>;
56
- queue: Pick<
57
- Runtime.TelegramBridgeRuntime["queue"],
58
- "getNextPriorityReactionOrder" | "incrementNextPriorityReactionOrder"
59
- >;
57
+ queue: Pick<Runtime.TelegramBridgeRuntime["queue"], "allocateItemOrder">;
60
58
  lifecycle: Pick<
61
59
  Runtime.TelegramBridgeRuntime["lifecycle"],
62
60
  "isCompactionInProgress" | "hasDispatchPending"
@@ -95,9 +93,7 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
95
93
  }): TelegramQueueBindingRuntime<TContext> {
96
94
  const mutation = Queue.createTelegramQueueMutationController({
97
95
  ...deps.store,
98
- getNextPriorityReactionOrder: deps.queue.getNextPriorityReactionOrder,
99
- incrementNextPriorityReactionOrder:
100
- deps.queue.incrementNextPriorityReactionOrder,
96
+ allocateLaneOrder: deps.queue.allocateItemOrder,
101
97
  onItemsDiscarded(items, ctx) {
102
98
  deps.admission.getSettlement()?.onItemsDiscarded(items, ctx);
103
99
  },
@@ -146,6 +142,108 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
146
142
  };
147
143
  }
148
144
 
145
+ export function createTelegramGenerativeAppBoundButtonActionInvoker<
146
+ TQuery extends {
147
+ message?: { chat?: { id?: number }; message_id?: number };
148
+ },
149
+ >(deps: {
150
+ agentDir: string;
151
+ assertExecutionCurrent: (query: TQuery) => void;
152
+ getExecutionFence: (query: TQuery) => GenerativeApps.GenerativeAppExecutionFence | undefined;
153
+ planOutput: ReturnType<typeof OutboundHandlers.createTelegramOutboundReplyPlanner>;
154
+ sendMarkdownReply: (
155
+ chatId: number,
156
+ replyToMessageId: number,
157
+ markdown: string,
158
+ options?: { replyMarkup?: OutboundHandlers.TelegramOutboundButtonMarkup },
159
+ ) => Promise<unknown>;
160
+ editInteractiveMessage?: (
161
+ chatId: number,
162
+ messageId: number,
163
+ markdown: string,
164
+ mode: "markdown",
165
+ replyMarkup: OutboundHandlers.TelegramOutboundButtonMarkup,
166
+ ) => Promise<void>;
167
+ recordRuntimeEvent: TelegramRuntimeEventRecorder;
168
+ }): (
169
+ action: OutboundHandlers.TelegramOutboundButtonAction,
170
+ query: TQuery,
171
+ ) => Promise<false | "new" | "edit"> {
172
+ return async (action, query) => {
173
+ let boundAction: GenerativeApps.GenerativeAppBoundAction | undefined;
174
+ try {
175
+ boundAction = GenerativeApps.parseGenerativeAppBoundAction(action.prompt);
176
+ if (!boundAction) return false;
177
+ deps.assertExecutionCurrent(query);
178
+ const result = await GenerativeApps.invokeGenerativeApp({
179
+ agentDir: deps.agentDir,
180
+ ...(deps.getExecutionFence(query)
181
+ ? { execution: deps.getExecutionFence(query) }
182
+ : {}),
183
+ ...(boundAction.argument !== undefined
184
+ ? { argument: boundAction.argument }
185
+ : {}),
186
+ ...(action.binding?.app === boundAction.app
187
+ ? {
188
+ expectedGeneration: action.binding.generation,
189
+ expectedRevision: action.binding.revision,
190
+ }
191
+ : {}),
192
+ method: boundAction.method,
193
+ app: boundAction.app,
194
+ });
195
+ deps.assertExecutionCurrent(query);
196
+ const chatId = query.message?.chat?.id;
197
+ const messageId = query.message?.message_id;
198
+ if (typeof chatId !== "number" || typeof messageId !== "number") {
199
+ throw new Error("Generative App callback target is unavailable.");
200
+ }
201
+ const reply = deps.planOutput(result.output, {
202
+ binding: {
203
+ generation: result.generation,
204
+ app: result.app,
205
+ revision: result.revision,
206
+ },
207
+ });
208
+ if (result.viewMode === "edit" && deps.editInteractiveMessage) {
209
+ let editFailed = false;
210
+ try {
211
+ await deps.editInteractiveMessage(
212
+ chatId,
213
+ messageId,
214
+ reply.markdown,
215
+ "markdown",
216
+ reply.replyMarkup ?? { inline_keyboard: [] },
217
+ );
218
+ } catch (error) {
219
+ editFailed = true;
220
+ deps.recordRuntimeEvent("generative-app", error, {
221
+ phase: "bound-action-edit-fallback",
222
+ app: boundAction.app,
223
+ method: boundAction.method,
224
+ });
225
+ }
226
+ deps.assertExecutionCurrent(query);
227
+ if (!editFailed) return "edit";
228
+ }
229
+ deps.assertExecutionCurrent(query);
230
+ await deps.sendMarkdownReply(chatId, messageId, reply.markdown, {
231
+ replyMarkup: reply.replyMarkup,
232
+ });
233
+ deps.assertExecutionCurrent(query);
234
+ return "new";
235
+ } catch (error) {
236
+ deps.recordRuntimeEvent("generative-app", error, {
237
+ phase: "bound-action",
238
+ ...(boundAction
239
+ ? { app: boundAction.app, method: boundAction.method }
240
+ : {}),
241
+ });
242
+ throw error;
243
+ }
244
+ };
245
+ }
246
+
149
247
  export interface TelegramAgentMessageToolRoutingRuntime {
150
248
  resolveAgentTarget: TelegramAgentTargetResolver;
151
249
  routeAgentMessage: TelegramAgentMessageRouter;
@@ -331,6 +429,7 @@ export function createTelegramActivityBindingRuntime<TTransportStamp>(deps: {
331
429
 
332
430
  interface TelegramCommandsAndToolsBindingDeps {
333
431
  pi: Pi.ExtensionAPI;
432
+ agentDir: string;
334
433
  configStore: Config.TelegramConfigStore;
335
434
  persistConfig: (config?: Config.TelegramConfig) => Promise<void>;
336
435
  setup: Setup.TelegramSetupGuard;
@@ -348,7 +447,10 @@ interface TelegramCommandsAndToolsBindingDeps {
348
447
  chatId: number,
349
448
  replyToMessageId: number | undefined,
350
449
  markdown: string,
351
- options?: { replyMarkup?: unknown },
450
+ options?: {
451
+ replyMarkup?: unknown;
452
+ target?: { chatId: number; threadId?: number };
453
+ },
352
454
  ) => Promise<number | undefined>;
353
455
  callMultipart: OutboundHandlers.TelegramVoiceReplySenderDeps["sendMultipart"];
354
456
  getDefaultChatId: () => number | undefined;
@@ -362,6 +464,7 @@ interface TelegramCommandsAndToolsBindingDeps {
362
464
 
363
465
  export function registerTelegramCommandsAndTools({
364
466
  pi,
467
+ agentDir,
365
468
  configStore,
366
469
  persistConfig,
367
470
  setup,
@@ -383,6 +486,13 @@ export function registerTelegramCommandsAndTools({
383
486
  recordRuntimeEvent,
384
487
  updateStatus,
385
488
  }: TelegramCommandsAndToolsBindingDeps): void {
489
+ GenerativeApps.registerTelegramBindTool(pi, {
490
+ agentDir,
491
+ getActiveTurn: activeTurnRuntime.get,
492
+ planOutput: OutboundHandlers.createTelegramOutboundReplyPlanner(buttonActionStore),
493
+ sendMarkdownReply,
494
+ recordRuntimeEvent,
495
+ });
386
496
  OutboundAttachments.registerTelegramOutboundAttachmentTool(pi, {
387
497
  getActiveTurn: activeTurnRuntime.get,
388
498
  getDefaultChatId,