@llblab/pi-kit 0.37.0 → 0.38.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 (152) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +7 -3
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +679 -2
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +17 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +30 -14
  7. package/node_modules/@llblab/pi-state-flow/api/modules.ts +11 -0
  8. package/node_modules/@llblab/pi-state-flow/dist/api/modules.d.ts +3 -0
  9. package/node_modules/@llblab/pi-state-flow/dist/api/modules.js +3 -0
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.d.ts +7 -4
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.js +77 -36
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +2 -0
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +26 -27
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +3 -0
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +13 -1
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +27 -21
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +144 -46
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +14 -18
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +6 -0
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +60 -50
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +1049 -310
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/history.d.ts +4 -4
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +15 -3
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/json.d.ts +2 -0
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/json.js +2 -1
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.d.ts +3 -1
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.js +7 -7
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.d.ts +29 -0
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +186 -0
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/model-interface.d.ts +17 -0
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/model-interface.js +158 -0
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/module-registry.d.ts +21 -0
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/module-registry.js +108 -0
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/operation.js +3 -2
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/ownership.d.ts +4 -4
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/ownership.js +5 -5
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/planes.d.ts +29 -0
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/planes.js +84 -0
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +6 -3
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +33 -16
  41. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +21 -4
  42. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +53 -13
  43. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +5 -2
  44. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.d.ts +3 -0
  45. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.js +10 -1
  46. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +84 -5
  47. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +570 -20
  48. package/node_modules/@llblab/pi-state-flow/dist/lib/selection.d.ts +10 -0
  49. package/node_modules/@llblab/pi-state-flow/dist/lib/selection.js +32 -0
  50. package/node_modules/@llblab/pi-state-flow/dist/lib/semantic-module.d.ts +79 -0
  51. package/node_modules/@llblab/pi-state-flow/dist/lib/semantic-module.js +187 -0
  52. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +98 -1
  53. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +398 -3
  54. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.d.ts +7 -2
  55. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.js +55 -3
  56. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +67 -14
  57. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +129 -9
  58. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +26 -14
  59. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +61 -19
  60. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +14 -9
  61. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +59 -22
  62. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +111 -10
  63. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +728 -11
  64. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +10 -7
  65. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +50 -14
  66. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +40 -4
  67. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +247 -43
  68. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +41 -8
  69. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +132 -39
  70. package/node_modules/@llblab/pi-state-flow/dist/package.json +6 -1
  71. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +28 -16
  72. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +12 -8
  73. package/node_modules/@llblab/pi-state-flow/docs/README.md +9 -0
  74. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +76 -43
  75. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +9 -1
  76. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +6 -0
  77. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +23 -19
  78. package/node_modules/@llblab/pi-state-flow/docs/protocol-composition-decision.md +66 -0
  79. package/node_modules/@llblab/pi-state-flow/docs/public-module-contract.md +73 -0
  80. package/node_modules/@llblab/pi-state-flow/docs/scope-selection-contract.md +814 -0
  81. package/node_modules/@llblab/pi-state-flow/docs/semantic-compatibility-decision.md +212 -0
  82. package/node_modules/@llblab/pi-state-flow/docs/semantic-extensibility-decision.md +75 -0
  83. package/node_modules/@llblab/pi-state-flow/docs/semantic-handoff-contract.md +174 -0
  84. package/node_modules/@llblab/pi-state-flow/docs/semantic-memory-evaluation.md +166 -0
  85. package/node_modules/@llblab/pi-state-flow/docs/semantic-module-contract.md +84 -0
  86. package/node_modules/@llblab/pi-state-flow/docs/single-scope-interface-contract.md +201 -0
  87. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +22 -1
  88. package/node_modules/@llblab/pi-state-flow/docs/usage.md +32 -18
  89. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +68 -31
  90. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +21 -22
  91. package/node_modules/@llblab/pi-state-flow/lib/config.ts +14 -1
  92. package/node_modules/@llblab/pi-state-flow/lib/context.ts +142 -46
  93. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +14 -17
  94. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +53 -44
  95. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +897 -297
  96. package/node_modules/@llblab/pi-state-flow/lib/history.ts +16 -7
  97. package/node_modules/@llblab/pi-state-flow/lib/json.ts +2 -1
  98. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +10 -9
  99. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +189 -0
  100. package/node_modules/@llblab/pi-state-flow/lib/model-interface.ts +136 -0
  101. package/node_modules/@llblab/pi-state-flow/lib/module-registry.ts +119 -0
  102. package/node_modules/@llblab/pi-state-flow/lib/operation.ts +3 -2
  103. package/node_modules/@llblab/pi-state-flow/lib/ownership.ts +6 -6
  104. package/node_modules/@llblab/pi-state-flow/lib/planes.ts +98 -0
  105. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +37 -16
  106. package/node_modules/@llblab/pi-state-flow/lib/query.ts +59 -18
  107. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +5 -2
  108. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +10 -1
  109. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +574 -21
  110. package/node_modules/@llblab/pi-state-flow/lib/selection.ts +39 -0
  111. package/node_modules/@llblab/pi-state-flow/lib/semantic-module.ts +239 -0
  112. package/node_modules/@llblab/pi-state-flow/lib/session.ts +387 -3
  113. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +45 -4
  114. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +156 -16
  115. package/node_modules/@llblab/pi-state-flow/lib/state.ts +69 -28
  116. package/node_modules/@llblab/pi-state-flow/lib/status.ts +65 -31
  117. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +770 -18
  118. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +58 -20
  119. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +267 -50
  120. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +184 -52
  121. package/node_modules/@llblab/pi-state-flow/package.json +6 -1
  122. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +28 -16
  123. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +12 -8
  124. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  125. package/node_modules/@llblab/pi-telegram/BACKLOG.md +8 -10
  126. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +10 -0
  127. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-buttons.js +20 -18
  128. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.d.ts +6 -6
  129. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.js +23 -65
  130. package/node_modules/@llblab/pi-telegram/dist/lib/outbound.js +3 -3
  131. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.d.ts +1 -1
  132. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.js +1 -1
  133. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +1 -0
  134. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +1 -0
  135. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +4 -2
  136. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +24 -13
  137. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  138. package/node_modules/@llblab/pi-telegram/dist/skills/telegram-bridge/SKILL.md +5 -5
  139. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -5
  140. package/node_modules/@llblab/pi-telegram/docs/compact-matrix-literal.md +1 -1
  141. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
  142. package/node_modules/@llblab/pi-telegram/docs/outbound.md +8 -8
  143. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -2
  144. package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +17 -15
  145. package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +28 -68
  146. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +3 -3
  147. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +1 -1
  148. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +2 -1
  149. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +25 -12
  150. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  151. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +5 -5
  152. package/package.json +3 -3
@@ -49,7 +49,7 @@ An app name is a unique lowercase ASCII identifier accepted by the runtime's pat
49
49
 
50
50
  ## Inference-Bypass Syntax
51
51
 
52
- Compact Matrix Literal and full JSON buttons keep their existing `label + prompt` contract. App output may use hidden `telegram_button` comments for footer controls or standalone triple-backtick `telegram_button` blocks between paragraphs; both singleton cells and mixed matrices retain the same generation/revision-bound routing. In-body controls acknowledge clicks without recoloring the message; HTML compatibility moves them to the footer. A bound action is encoded entirely in the prompt string:
52
+ Compact Matrix Literal and full JSON buttons keep their existing `label + prompt` contract. App output may use hidden `telegram_button` comments for footer controls or standalone `telegram_button` fenced blocks between paragraphs (three or more backticks or tildes, horizontal indentation/whitespace tolerated, same-marker closure no shorter than the opening); both singleton cells and mixed matrices retain the same generation/revision-bound routing. In-body controls acknowledge clicks without recoloring the message; HTML compatibility moves them to the footer. A bound action is encoded entirely in the prompt string:
53
53
 
54
54
  ```ebnf
55
55
  bound-action = app "::" method [ "(" json-value ")" ]
@@ -2,7 +2,7 @@
2
2
 
3
3
  `pi-telegram` maps hidden assistant-authored HTML comments to Telegram-native outbound actions.
4
4
 
5
- Normal Telegram-turn replies are intentionally prompt-driven: the agent writes Markdown plus small hidden top-level blocks, and the bridge performs transport after `agent_end`. `telegram_voice` and `telegram_button` are not Pi tools. Action activation remains restricted to recognized top-level column-zero comments, but Telegram preview and final delivery remove every assistant-authored `<!-- … -->` block regardless of Markdown position or owning extension. Unclosed comment tails are withheld, comment-only text plans send no message, and the Pi terminal transcript remains unchanged. For local/TUI-initiated work where the user explicitly asks to send something to Telegram, the bridge also exposes direct tools: `telegram_message` for Markdown text and `telegram_attach` for file delivery when no Telegram turn is active. In classic mode, direct local/TUI delivery requires this Pi instance to own `/telegram-connect`; in Threaded Mode, a registered follower may route direct-tool sends through the leader-owned bus transport. During an active Telegram turn, `telegram_message` rejects an implicit or same-turn target so ordinary final delivery remains the sole current-target response. Its `thread` argument accepts a case-insensitive live thread name or numeric id: the bridge preflights one live owner, sends visibly, then admits the text as a source-attributed turn in that instance. Unknown, ambiguous, same-target, offline, and cross-chat destinations fail before sending. Existing `chat_id` plus `thread_id` targeting stays compatible; registered followers use authenticated, generation-fenced bus routing. Outbound behavior combines assistant prompt markup, text command-template handlers, registered voice synthesis providers, generated artifacts, direct Telegram tools, and reply delivery. Direct `telegram_message` text is planned through the same reply markup path, so embedded top-level `telegram_button` comments become buttons attached to that text message.
5
+ Normal Telegram-turn replies are intentionally prompt-driven: the agent writes Markdown plus small hidden HTML comments, and the bridge performs transport after `agent_end`. `telegram_voice` and `telegram_button` are not Pi tools. Complete action comments activate by their delimiters and accepted payload regardless of surrounding whitespace or Markdown, including code fences; Telegram preview and final delivery remove every assistant-authored `<!-- … -->` block regardless of Markdown position or owning extension. Unclosed comment tails are withheld, comment-only text plans send no message, and the Pi terminal transcript remains unchanged. For local/TUI-initiated work where the user explicitly asks to send something to Telegram, the bridge also exposes direct tools: `telegram_message` for Markdown text and `telegram_attach` for file delivery when no Telegram turn is active. In classic mode, direct local/TUI delivery requires this Pi instance to own `/telegram-connect`; in Threaded Mode, a registered follower may route direct-tool sends through the leader-owned bus transport. During an active Telegram turn, `telegram_message` rejects an implicit or same-turn target so ordinary final delivery remains the sole current-target response. Its `thread` argument accepts a case-insensitive live thread name or numeric id: the bridge preflights one live owner, sends visibly, then admits the text as a source-attributed turn in that instance. Unknown, ambiguous, same-target, offline, and cross-chat destinations fail before sending. Existing `chat_id` plus `thread_id` targeting stays compatible; registered followers use authenticated, generation-fenced bus routing. Outbound behavior combines assistant prompt markup, text command-template handlers, registered voice synthesis providers, generated artifacts, direct Telegram tools, and reply delivery. Direct `telegram_message` text is planned through the same reply markup path, so embedded `telegram_button` comments become buttons attached to that text message.
6
6
 
7
7
  Text handlers use the portable [Command Template Standard](./command-templates.md). Programmatic outbound handlers use `registerTelegramOutboundHandler(kind, handler)`. Voice replies can use configured command-template handlers or the provider API described in [Voice Integration](./voice.md).
8
8
 
@@ -163,11 +163,11 @@ Full text answer stays here.
163
163
  <!-- telegram_voice {"text":"First line.\nSecond line.","lang":"ru"} -->
164
164
  ```
165
165
 
166
- The bridge strips the comment from Telegram text. On `agent_end`, it maps each `telegram_voice` action to a provider call, generates one file per action, and sends each file as an independent Telegram-native voice message. Prefer `{text}`, `{text|lang}`, or `{text|lang|rate}`. Use JSON when multiline content, named fields, or escaping earns the extra syntax; equivalent JSON `text` or `value` supplies the spoken payload, with explicit `text` taking precedence. Each comment creates one voice artifact, so voice cells do not accept button-style matrix composition. The opening marker must start at column zero on a top-level line outside fenced code, quotes, lists, and indented examples; otherwise it does not activate a voice action and is still removed from the Telegram surface.
166
+ The bridge strips the comment from Telegram text. On `agent_end`, it maps each `telegram_voice` action to a provider call, generates one file per action, and sends each file as an independent Telegram-native voice message. Prefer `{text}`, `{text|lang}`, or `{text|lang|rate}`. Use JSON when multiline content, named fields, or escaping earns the extra syntax; equivalent JSON `text` or `value` supplies the spoken payload, with explicit `text` taking precedence. Each comment creates one voice artifact, so voice cells do not accept button-style matrix composition. Complete voice comments activate anywhere, including inline, indented, quoted and fenced text; surrounding Markdown is not an action boundary. Escape the opening delimiter as `&lt;!--` for literal examples.
167
167
 
168
168
  ## Buttons Markup
169
169
 
170
- Assistant replies can include one or many button actions through a top-level `telegram_button` comment:
170
+ Assistant replies can include one or many button actions through a `telegram_button` HTML comment:
171
171
 
172
172
  ```md
173
173
  I can continue.
@@ -181,15 +181,15 @@ Rules:
181
181
 
182
182
  - The payload may be a JSON object, adaptive JSON/CML matrix, or positional [Compact Matrix Literal](./compact-matrix-literal.md). Named JSON objects and positional cells may coexist in one matrix or row. Commas are optional between completed elements, and one trailing comma before a closing delimiter is tolerated; JSON object validation likewise tolerates trailing commas but does not invent missing values, property names, or internal separators. CML uses `{value}`, `{label|prompt}`, `{|prompt}`, or the corresponding three-atom form with `selected_style`; the optional third atom requires a non-empty prompt and accepts only `primary`, `success`, or `danger`. A fourth atom accepts `1` or `true` (disabled), and `0` or `false` (enabled), with exact lowercase spelling, permitting an empty style atom in this form: `{|Next||1}`. An omitted fourth position stays enabled. JSON uses boolean `disabled`; legacy attributes accept `disabled="true"` or `disabled="false"`. Invalid disabled values reject the candidate matrix atomically. Disabled cells require no prompt or selected style: `{Next|||1}` is label-only and `{|||1}` is blank, equivalent to JSON `{"label":"Next","disabled":true}` and `{"disabled":true}`. They retain their row and label, serialize as `{ text, disabled: {} }` (non-breaking-space text for a blank cell), and register no callback, prompt, or bound app action. It trims every atom, preserves non-structural printable text, and decodes only `\|`, `\}`, and `\\`.
183
183
  - Use `label` plus `prompt`, or the compact `value` key when both strings are identical. If only `label`, only `prompt`, one-field `{value}`, or prompt-only `{|prompt}` is present, that string supplies both visible label and queued prompt. An explicit counterpart takes precedence over `value`. Use JSON with `\n` escapes for multiline prompts.
184
- - The opening marker must start at column zero on a top-level line outside fenced code, quotes, lists, and indented examples; otherwise it does not activate a button action and is still removed from the Telegram surface.
184
+ - Complete HTML comments activate by their delimiters and accepted payload, regardless of indentation, surrounding text, lists, quotes or code fences. Multiple comments on one line are independent. Escape the opening delimiter as `&lt;!--` for literal examples; backticks and backslashes do not disable action comments.
185
185
  - 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 fourth unescaped CML separator, empty enabled-cell prompts or three-atom styles, invalid disabled flags, empty one-atom cells, unknown selected styles, and deeper nesting are rejected atomically. Multi-atom cells may omit the label; four-atom cells may additionally omit the selected style, and disabled cells may omit the prompt. Generated surfaces default to five columns and expand to six through eight only for short position-bearing labels. Repeated singular comments remain valid.
186
186
  - Button actions are stored in memory with short `callback_data`; Telegram never sees the full prompt in the button payload.
187
- - Standalone column-zero fences opened by exactly three backticks and `telegram_button` use the same singleton/matrix JSON/CML grammar. Button wrappers accept adjacent complete JSON/CML cells and matrices with optional commas between groups. Their normalized rows concatenate in source order: `[{A|a}] [{B|b}]` equals `[{A|a}{B|b}]`; a singleton cell is one vertical row and horizontal peers still require a row nested inside a matrix. Any invalid group in a fence rejects the entire block before callback registration. Complete valid blocks become native `<tg-button-row>` elements in `InputRichMessage.markdown`, at their original positions. Each row supports at most eight buttons and must fit a Rich Message chunk; validation precedes callback registration for the entire block. Labels are escaped, prompts never enter the native markup, and disabled cells have no callback. Missing closures, trailing payload garbage, and invalid blocks produce no controls. Other language names and enclosing code examples stay literal. Draft previews withhold complete and unfinished action fences without registering actions. HTML compatibility projects fenced rows into the bottom keyboard before comment-authored footer rows; source ordering within each group is retained.
187
+ - Standalone fences opened by at least three backticks or tildes and the exact name `telegram_button` use the same singleton/matrix JSON/CML grammar. Delimiter lines accept horizontal indentation, optional spaces/tabs before the name, and trailing whitespace. A closing line must use the same marker with at least the opening length and only surrounding horizontal whitespace; inline, quoted/list-prefixed delimiters and name suffixes do not activate a block. Button wrappers accept adjacent complete JSON/CML cells and matrices with optional commas between groups. Their normalized rows concatenate in source order: `[{A|a}] [{B|b}]` equals `[{A|a}{B|b}]`; a singleton cell is one vertical row and horizontal peers still require a row nested inside a matrix. Any invalid group in a fence rejects the entire block before callback registration. Complete valid blocks become native `<tg-button-row>` elements in `InputRichMessage.markdown`, at their original positions. Each row supports at most eight buttons and must fit a Rich Message chunk; validation precedes callback registration for the entire block. Labels are escaped, prompts never enter the native markup, and disabled cells have no callback. Missing closures, trailing payload garbage, and invalid blocks produce no controls. Other language names and enclosing code examples stay literal. Draft previews withhold complete and unfinished action fences without registering actions. HTML compatibility projects fenced rows into the bottom keyboard before comment-authored footer rows; source ordering within each group is retained.
188
188
  - In-body controls share prompt/app dispatch, TTL, generation/revision checks, and sent-message target ownership with footer controls. Their acknowledgement is the selection feedback; `selected_style` does not recolor the Rich body. No callback-triggered body rewrite is introduced, preventing a stale click from overwriting newer app output. The operator confirmed the post-reload current-client smoke: singleton CML and mixed JSON/CML in-body rows, their ordinary prompt callbacks, a labeled disabled control, and the separate footer callback work as intended. Client type was not recorded. The operator also confirmed that HTML mode projects the in-body groups into the bottom keyboard as intended. Second-client coverage, follower routing, blank cells, and app-method dispatch/revision rejection remain live-gated. Bot API evidence: `InputRichBlockButtons` / Rich Markdown `tg-button-row` and `RichMessageButton` use ordinary `callback_data` and support disabled controls.
189
189
  - After Telegram accepts a generated footer 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.
190
190
  - 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.
191
191
 
192
- Do not emit inline comments after visible text, standalone button actions, or tool calls for ordinary Telegram-turn buttons. The agent writes Markdown plus hidden comments; the bridge strips comments and attaches Telegram `reply_markup` after `agent_end`. For local/TUI-originated direct sends, put the same Markdown and `telegram_button` comments in `telegram_message(text)`.
192
+ Prefer separate comment lines for readability; inline comments also activate. Do not use tool calls for ordinary Telegram-turn buttons. The agent writes Markdown plus hidden comments; the bridge strips comments and attaches Telegram `reply_markup` after `agent_end`. For local/TUI-originated direct sends, put the same Markdown and `telegram_button` comments in `telegram_message(text)`.
193
193
 
194
194
  Buttons are built in and do not need a command template because they are pure Telegram reply markup plus callback routing.
195
195
 
@@ -203,8 +203,8 @@ The extension injects prompt guidance by context:
203
203
  - For Telegram-originated turns, the compact note routes the agent to `telegram-bridge` for voice/button/direct-delivery/Threaded Mode/formatting/debug guidance and to `show-me` when the user needs a visual explanation of work or behavior.
204
204
  - For ordinary Telegram-originated answers, write the full technical answer as phone-width Markdown. `show-me` may instead deliver a focused self-contained HTML artifact when explicitly requested or when spatial density earns it, while keeping the immediate reply useful.
205
205
  - Add `telegram_voice` with positional CML by default or JSON when multiline content, named fields, or escaping requires it. A companion summary is optional, no specific summary format is required.
206
- - Add `telegram_button` with a JSON object, JSON matrix, or positional CML. Use a hidden comment for footer placement or a standalone triple-backtick `telegram_button` block for placement between paragraphs. Prefer one matrix for multiple controls. Use `label` plus `prompt`, or `value` when they are identical; `selected_style` and boolean `disabled` are optional. Keep at least one useful enabled action on an interactive surface. A button-only reply may omit parent text because the bridge supplies `☑️ **Choose an option:**` automatically.
207
- - For ordinary Telegram-turn replies, do not call transport tools for voice or buttons; the bridge owns delivery, while registered voice synthesis providers own TTS and OGG/Opus conversion. For explicit local/TUI direct sends, `telegram_message` may include top-level `telegram_button` comments in its Markdown text because those buttons are attached to that text message.
206
+ - Add `telegram_button` with a JSON object, JSON matrix, or positional CML. Use a hidden comment for footer placement or a standalone `telegram_button` fenced block for placement between paragraphs. Prefer one matrix for multiple controls. Use `label` plus `prompt`, or `value` when they are identical; `selected_style` and boolean `disabled` are optional. Keep at least one useful enabled action on an interactive surface. A button-only reply may omit parent text because the bridge supplies `☑️ **Choose an option:**` automatically.
207
+ - For ordinary Telegram-turn replies, do not call transport tools for voice or buttons; the bridge owns delivery, while registered voice synthesis providers own TTS and OGG/Opus conversion. For explicit local/TUI direct sends, `telegram_message` may include `telegram_button` comments in its Markdown text because those buttons are attached to that text message.
208
208
  - Prefer meaningful visible parent text when it adds context; for a button-only answer, rely on the bridge's automatic `☑️ **Choose an option:**` fallback rather than manufacturing duplicate text.
209
209
 
210
210
  This keeps the agent focused on semantics, prevents Telegram action syntax from leaking into normal local replies, and lets the bridge handle low-latency Telegram adaptation.
@@ -68,7 +68,7 @@ An unbound Thread chooser offers **⛔️ Cancel routing** to the current paired
68
68
 
69
69
  ### Tools and assistant-authored actions
70
70
 
71
- Every assistant-authored HTML comment is transport-private on Telegram: previews and final replies remove `<!-- … -->` blocks regardless of Markdown nesting or which extension owns the comment, while only recognized top-level column-zero comments can activate voice or buttons. Unclosed comment tails are withheld and a comment-only result sends no text message; Pi's terminal transcript remains unchanged.
71
+ Every assistant-authored HTML comment is transport-private on Telegram: previews and final replies remove `<!-- … -->` blocks regardless of Markdown nesting or which extension owns the comment, and complete recognized comments activate voice or buttons independently of surrounding whitespace or Markdown, including code fences. Escape the opening delimiter as `&lt;!--` for literal examples. Unclosed comment tails are withheld and a comment-only result sends no text message; Pi's terminal transcript remains unchanged.
72
72
 
73
73
  - `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 are not implemented.
74
74
  - `telegram_attach(paths, chat_id?, thread_id?, caption?)` is the stable artifact delivery tool for generated files. During Telegram turns it queues files before any separate final text; with `assistant.rendering: "rich"`, exactly one PNG/JPEG, MP4, or MP3 artifact plus non-empty final Markdown can become one reply-anchored Rich Message with media first. 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.
@@ -77,7 +77,7 @@ Every assistant-authored HTML comment is transport-private on Telegram: previews
77
77
  - `telegram_message(text, chat_id?, media?, channel?, thread_id?, thread?)` sends a direct Telegram Markdown message when this Pi instance owns `/telegram-connect` or is registered with the multi-instance bus. A public `@username`, or an exact negative numeric channel ID with `channel: true`, is passed as `chat_id` without a local registry; channel delivery requires the direct leader, and Telegram enforces whether the bot has channel posting permission. Channel delivery accepts `media` as one local `.jpg`/`.jpeg`/`.png`/`.webp` photo or `.mp4` video (photo ≤ 10 MiB, video ≤ 50 MiB), uploaded through multipart `sendPhoto`/`sendVideo` with `text` as its HTML caption (≤ 1024 visible characters); unsupported media types and albums are rejected before issuance, and the durable channel-post journal binds media identity and caption so duplicate or lost-acknowledgement retries never re-upload. `thread` accepts a live numeric Thread id or its current acknowledged display title; name matching is case-insensitive and fails closed when absent or ambiguous, while delivery captures the numeric target. 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. 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.
78
78
  - The bundled `telegram-bridge` Skill owns action syntax, target routing, Threaded Mode, formatting, Generative App operation, and profile-specific debugging guidance. The bundled `show-me` Skill owns portable evidence-honest explanations and adapts them to phone-width Markdown or self-contained HTML artifacts when Telegram is the active surface. The regular prompt routes applicable turns to these and the other bundled Skills. Compiled npm/git installations expose them through the package `pi.skills` manifest so Pi resource filters remain authoritative; a raw TypeScript checkout under Pi's `extensions` directory contributes its source Skill root at runtime instead. `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.
79
79
  - `telegram_voice` hidden comments request Telegram-native voice delivery through `{text}`, `{text|lang}`, `{text|lang|rate}`, or a JSON object. JSON is the fallback for multiline content, named fields, or escaping; equivalent `text` or `value` supplies the spoken payload, with explicit `text` taking precedence.
80
- - `telegram_button` hidden comments create footer buttons; standalone column-zero triple-backtick `telegram_button` blocks create button rows between paragraphs in Native Rich Markdown. Both accept the same singleton or mixed JSON/CML matrix and share prompt/app routing; both wrappers also accept adjacent complete JSON/CML cells and matrices with optional separators, concatenating their normalized rows in source order. Cells remain singleton rows and nested horizontal rows retain their grouping. Native rows allow at most eight buttons and must fit one Rich Message chunk; invalid or incomplete blocks register nothing. Drafts hide action fences. HTML compatibility projects fenced controls into the footer. In-body clicks acknowledge without recoloring the Rich body; selected-style highlighting remains footer-only. One marker accepts a JSON object, adaptive JSON/CML matrix, or positional [Compact Matrix Literal](./compact-matrix-literal.md). Named JSON objects and positional cells may coexist in one matrix or row; separators are optional and one trailing comma is tolerated at matrix, row, and JSON-object boundaries. Top-level 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}`, prompt-only `{|prompt}`, or the corresponding three-atom form with `selected_style`; an omitted label uses the existing prompt-as-label fallback, and the optional third atom requires a non-empty prompt and accepts only `primary`, `success`, or `danger`. A fourth atom accepts `1` or `true` (disabled), and `0` or `false` (enabled), with exact lowercase spelling; an omitted fourth position stays enabled, and the third atom may be empty in this form (`{|Next||1}`). JSON uses boolean `disabled`. Disabled cells need no prompt or selected style: `{Next|||1}` is label-only and `{|||1}` is blank (JSON `{"label":"Next","disabled":true}` and `{"disabled":true}`). The Telegram renderer supplies a non-breaking space only when the label is empty. Disabled cells stay visible but carry `disabled: {}` instead of callback data and register no prompt or bound action; invalid disabled values reject the candidate matrix. 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 action wrappers, outside quotes, lists, or enclosing code examples. Ordinary code fences and larger outer fences preserve literal examples; bare JSON/CML in prose never activates.
80
+ - `telegram_button` hidden comments create footer buttons; standalone `telegram_button` fenced blocks create button rows between paragraphs in Native Rich Markdown. Delimiter lines tolerate horizontal indentation and whitespace before the exact name. Open with at least three backticks or tildes; close on a separate line with the same marker and no shorter a run, allowing only surrounding spaces/tabs. Enclosing code fences keep button-block examples literal. Both accept the same singleton or mixed JSON/CML matrix and share prompt/app routing; both wrappers also accept adjacent complete JSON/CML cells and matrices with optional separators, concatenating their normalized rows in source order. Cells remain singleton rows and nested horizontal rows retain their grouping. Native rows allow at most eight buttons and must fit one Rich Message chunk; invalid or incomplete blocks register nothing. Drafts hide action fences. HTML compatibility projects fenced controls into the footer. In-body clicks acknowledge without recoloring the Rich body; selected-style highlighting remains footer-only. One marker accepts a JSON object, adaptive JSON/CML matrix, or positional [Compact Matrix Literal](./compact-matrix-literal.md). Named JSON objects and positional cells may coexist in one matrix or row; separators are optional and one trailing comma is tolerated at matrix, row, and JSON-object boundaries. Top-level 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}`, prompt-only `{|prompt}`, or the corresponding three-atom form with `selected_style`; an omitted label uses the existing prompt-as-label fallback, and the optional third atom requires a non-empty prompt and accepts only `primary`, `success`, or `danger`. A fourth atom accepts `1` or `true` (disabled), and `0` or `false` (enabled), with exact lowercase spelling; an omitted fourth position stays enabled, and the third atom may be empty in this form (`{|Next||1}`). JSON uses boolean `disabled`. Disabled cells need no prompt or selected style: `{Next|||1}` is label-only and `{|||1}` is blank (JSON `{"label":"Next","disabled":true}` and `{"disabled":true}`). The Telegram renderer supplies a non-breaking space only when the label is empty. Disabled cells stay visible but carry `disabled: {}` instead of callback data and register no prompt or bound action; invalid disabled values reject the candidate matrix. 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. HTML action comments activate regardless of surrounding Markdown; escape their opening delimiter for literal examples. Button fences require standalone delimiter lines outside enclosing code examples and quote/list prefixes, but allow horizontal indentation. Bare JSON/CML in prose never activates.
81
81
 
82
82
  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.
83
83
 
@@ -15,7 +15,7 @@ import {
15
15
  parseTelegramActionPayloadRows,
16
16
  parseTelegramButtonPayloadRows,
17
17
  replaceTelegramButtonFences,
18
- replaceTopLevelHtmlComments,
18
+ replaceTelegramHtmlComments,
19
19
  } from "./outbound-markup.ts";
20
20
  import {
21
21
  type PendingTelegramTurn,
@@ -237,8 +237,21 @@ export function planTelegramButtonReply(
237
237
  ),
238
238
  );
239
239
  };
240
+ const footerKeyboard: TelegramOutboundButtonMarkup["inline_keyboard"] = [];
241
+ const stripped = replaceTelegramHtmlComments(markdown, (comment) => {
242
+ const command = "telegram_button";
243
+ const normalizedContent = comment.content
244
+ .replace(/^\s+/, "")
245
+ .replace(/^!/, "");
246
+ if (!normalizedContent.startsWith(command)) return comment.raw;
247
+ const payloadRows = parseTelegramActionPayloadRows(comment, command);
248
+ if (!payloadRows) return "";
249
+ const rows = buildRows(payloadRows, false);
250
+ if (rows) footerKeyboard.push(...rows);
251
+ return "";
252
+ });
240
253
  const withRichButtons = replaceTelegramButtonFences(
241
- markdown,
254
+ stripped,
242
255
  (payload, closed) => {
243
256
  if (!closed) return "";
244
257
  const payloadRows = parseTelegramButtonPayloadRows(payload);
@@ -253,19 +266,8 @@ export function planTelegramButtonReply(
253
266
  return `\n${rows.map(renderTelegramRichButtonRow).join("\n\n")}\n`;
254
267
  },
255
268
  );
256
- const stripped = replaceTopLevelHtmlComments(withRichButtons, (comment) => {
257
- const command = "telegram_button";
258
- const normalizedContent = comment.content
259
- .replace(/^\s+/, "")
260
- .replace(/^!/, "");
261
- if (!normalizedContent.startsWith(command)) return comment.raw;
262
- const payloadRows = parseTelegramActionPayloadRows(comment, command);
263
- if (!payloadRows) return "";
264
- const rows = buildRows(payloadRows, false);
265
- if (rows) keyboard.push(...rows);
266
- return "";
267
- });
268
- const visibleMarkdown = normalizeMarkdownAfterButtonExtraction(stripped);
269
+ keyboard.push(...footerKeyboard);
270
+ const visibleMarkdown = normalizeMarkdownAfterButtonExtraction(withRichButtons);
269
271
  return {
270
272
  markdown:
271
273
  keyboard.length > 0 && !visibleMarkdown
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Telegram outbound markup parsing helpers
3
3
  * Zones: telegram outbound, assistant markup
4
- * Owns top-level assistant action comment extraction, attribute parsing, and markup stripping shared by voice and outbound delivery
4
+ * Owns delimiter-based assistant action comment extraction, attribute parsing, and markup stripping shared by voice and outbound delivery
5
5
  */
6
6
 
7
7
  import { isWireRecord as isTelegramActionPayload } from "./wire.ts";
8
8
 
9
- export interface TelegramTopLevelHtmlComment {
9
+ export interface TelegramHtmlComment {
10
10
  raw: string;
11
11
  content: string;
12
12
  start: number;
@@ -34,7 +34,7 @@ function getMarkdownLineText(
34
34
  function getTopLevelOpeningFence(
35
35
  line: string,
36
36
  ): TelegramTopLevelFenceState | undefined {
37
- const match = line.match(/^(?: {0,3})(`{3,}|~{3,})/);
37
+ const match = line.match(/^[ \t]*(`{3,}|~{3,})/);
38
38
  const sequence = match?.[1];
39
39
  if (!sequence) return undefined;
40
40
  return {
@@ -47,7 +47,7 @@ function isTopLevelClosingFence(
47
47
  line: string,
48
48
  fence: TelegramTopLevelFenceState,
49
49
  ): boolean {
50
- const match = line.match(/^(?: {0,3})(`{3,}|~{3,})([ \t]*)$/);
50
+ const match = line.match(/^[ \t]*(`{3,}|~{3,})[ \t]*$/);
51
51
  const sequence = match?.[1];
52
52
  return (
53
53
  !!sequence &&
@@ -56,38 +56,21 @@ function isTopLevelClosingFence(
56
56
  );
57
57
  }
58
58
 
59
- export function collectTopLevelHtmlComments(markdown: string): {
60
- comments: TelegramTopLevelHtmlComment[];
59
+ export function collectTelegramHtmlComments(markdown: string): {
60
+ comments: TelegramHtmlComment[];
61
61
  openCommentStart?: number;
62
62
  } {
63
- const comments: TelegramTopLevelHtmlComment[] = [];
63
+ const comments: TelegramHtmlComment[] = [];
64
64
  let offset = 0;
65
- let fence: TelegramTopLevelFenceState | undefined;
66
65
  while (offset < markdown.length) {
67
- const lineEnd = getMarkdownLineEnd(markdown, offset);
68
- const line = getMarkdownLineText(markdown, offset, lineEnd);
69
- if (fence) {
70
- if (isTopLevelClosingFence(line, fence)) fence = undefined;
71
- offset = lineEnd;
72
- continue;
73
- }
74
- const nextFence = getTopLevelOpeningFence(line);
75
- if (nextFence) {
76
- fence = nextFence;
77
- offset = lineEnd;
78
- continue;
79
- }
80
- if (line.startsWith("<!--")) {
81
- const closeIndex = markdown.indexOf("-->", offset + 4);
82
- if (closeIndex === -1) return { comments, openCommentStart: offset };
83
- const end = closeIndex + 3;
84
- const raw = markdown.slice(offset, end);
85
- const content = raw.slice(4, -3);
86
- comments.push({ raw, content, start: offset, end });
87
- offset = getMarkdownLineEnd(markdown, end);
88
- continue;
89
- }
90
- offset = lineEnd;
66
+ const start = markdown.indexOf("<!--", offset);
67
+ if (start === -1) break;
68
+ const closeIndex = markdown.indexOf("-->", start + 4);
69
+ if (closeIndex === -1) return { comments, openCommentStart: start };
70
+ const end = closeIndex + 3;
71
+ const raw = markdown.slice(start, end);
72
+ comments.push({ raw, content: raw.slice(4, -3), start, end });
73
+ offset = end;
91
74
  }
92
75
  return { comments };
93
76
  }
@@ -122,7 +105,7 @@ export function replaceTelegramButtonFences(
122
105
  continue;
123
106
  } else {
124
107
  fence = getTopLevelOpeningFence(line);
125
- if (fence && /^```telegram_button[ \t]*$/.test(line)) {
108
+ if (fence && /^[ \t]*(?:`{3,}|~{3,})[ \t]*telegram_button[ \t]*$/.test(line)) {
126
109
  actionStart = offset;
127
110
  contentStart = end;
128
111
  }
@@ -136,11 +119,11 @@ export function replaceTelegramButtonFences(
136
119
  return result + markdown.slice(copied);
137
120
  }
138
121
 
139
- export function replaceTopLevelHtmlComments(
122
+ export function replaceTelegramHtmlComments(
140
123
  markdown: string,
141
- replacer: (comment: TelegramTopLevelHtmlComment) => string,
124
+ replacer: (comment: TelegramHtmlComment) => string,
142
125
  ): string {
143
- const { comments } = collectTopLevelHtmlComments(markdown);
126
+ const { comments } = collectTelegramHtmlComments(markdown);
144
127
  if (comments.length === 0) return markdown;
145
128
  let result = "";
146
129
  let offset = 0;
@@ -152,32 +135,9 @@ export function replaceTopLevelHtmlComments(
152
135
  return result + markdown.slice(offset);
153
136
  }
154
137
 
155
- function findTopLevelOpenOrPartialHtmlCommentIndex(markdown: string): number {
156
- const { openCommentStart } = collectTopLevelHtmlComments(markdown);
157
- if (openCommentStart !== undefined) return openCommentStart;
158
- let offset = 0;
159
- let fence: TelegramTopLevelFenceState | undefined;
160
- while (offset < markdown.length) {
161
- const lineEnd = getMarkdownLineEnd(markdown, offset);
162
- const line = getMarkdownLineText(markdown, offset, lineEnd);
163
- const isLastLine = lineEnd >= markdown.length;
164
- if (fence) {
165
- if (isTopLevelClosingFence(line, fence)) fence = undefined;
166
- offset = lineEnd;
167
- continue;
168
- }
169
- const nextFence = getTopLevelOpeningFence(line);
170
- if (nextFence) {
171
- fence = nextFence;
172
- offset = lineEnd;
173
- continue;
174
- }
175
- if (isLastLine && (line === "<" || line === "<!" || line === "<!-")) {
176
- return offset;
177
- }
178
- offset = lineEnd;
179
- }
180
- return -1;
138
+ function findOpenOrPartialHtmlCommentIndex(markdown: string): number {
139
+ const start = markdown.indexOf("<!--");
140
+ return start >= 0 ? start : (markdown.match(/<(?:!-?)?$/u)?.index ?? -1);
181
141
  }
182
142
 
183
143
  function parseTolerantTelegramAttributes(
@@ -263,7 +223,7 @@ function blankTelegramActionSpan(
263
223
  * with every structured span blanked; implausible `[` starts are skipped without a parse attempt.
264
224
  */
265
225
  function scanTelegramActionComment<T>(
266
- comment: TelegramTopLevelHtmlComment,
226
+ comment: TelegramHtmlComment,
267
227
  command: string,
268
228
  parseStructured: (content: string, offset: number) => T | undefined,
269
229
  ): { payload: T } | { attributeEnvelope: string } | undefined {
@@ -287,7 +247,7 @@ function scanTelegramActionComment<T>(
287
247
  }
288
248
 
289
249
  function parseTelegramActionPayload(
290
- comment: TelegramTopLevelHtmlComment,
250
+ comment: TelegramHtmlComment,
291
251
  command: string,
292
252
  ): Record<string, unknown> | undefined {
293
253
  const scanned = scanTelegramActionComment(
@@ -594,7 +554,7 @@ export function parseTelegramButtonPayloadRows(
594
554
  }
595
555
 
596
556
  export function parseTelegramActionPayloadRows(
597
- comment: TelegramTopLevelHtmlComment,
557
+ comment: TelegramHtmlComment,
598
558
  command: string,
599
559
  ): Record<string, unknown>[][] | undefined {
600
560
  const scanned = scanTelegramActionComment(
@@ -665,7 +625,7 @@ export function stripTelegramCommentMarkupForPreview(markdown: string): string {
665
625
  replaceTelegramButtonFences(markdown, () => ""),
666
626
  );
667
627
  const openBlockIndex =
668
- findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
628
+ findOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
669
629
  const previewMarkdown =
670
630
  openBlockIndex >= 0
671
631
  ? withoutClosedBlocks.slice(0, openBlockIndex)
@@ -678,7 +638,7 @@ export function stripTelegramCommentMarkupForDelivery(
678
638
  ): string {
679
639
  const withoutClosedBlocks = stripTelegramHtmlCommentBlocks(markdown);
680
640
  const openBlockIndex =
681
- findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
641
+ findOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
682
642
  const deliveryMarkdown =
683
643
  openBlockIndex >= 0
684
644
  ? withoutClosedBlocks.slice(0, openBlockIndex)
@@ -716,7 +676,7 @@ export function planTelegramVoiceReply(
716
676
  const voiceReplies: TelegramVoiceReplyItem[] = [];
717
677
  let lang: string | undefined;
718
678
  let rate: string | undefined;
719
- const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
679
+ const stripped = replaceTelegramHtmlComments(markdown, (comment) => {
720
680
  const command = "telegram_voice";
721
681
  const normalizedContent = comment.content
722
682
  .replace(/^\s+/, "")
@@ -849,11 +849,11 @@ export function createTelegramOutboundReplyPlanner(
849
849
  ...(options?.binding ? { binding: options.binding } : {}),
850
850
  });
851
851
 
852
- // Button replies can also contain <!-- telegram_voice --> markup
853
- const voiceReply = planTelegramVoiceReply(buttonReply.markdown);
852
+ // Extract voice from the original text before button-fence rendering consumes it.
853
+ const voiceReply = planTelegramVoiceReply(markdown);
854
854
 
855
855
  return {
856
- markdown: voiceReply.markdown,
856
+ markdown: stripTelegramCommentMarkupForDelivery(buttonReply.markdown),
857
857
  ...(buttonReply.replyMarkup
858
858
  ? { replyMarkup: buttonReply.replyMarkup }
859
859
  : {}),
@@ -34,7 +34,7 @@ export const TELEGRAM_MESSAGE_PROMPT_GUIDELINES = [
34
34
  "For an explicitly requested channel post, pass its exact numeric id or public @username as chat_id; no local channel registry is required, and Telegram remains the authority on the bot's posting permission.",
35
35
  "For an explicitly requested channel media post, pass one local .jpg/.jpeg/.png/.webp photo or .mp4 video as media; the text becomes its caption (max 1024 characters), and albums or other media types are rejected.",
36
36
  "For a live Pi thread target, provide thread as its case-insensitive name or numeric id; the bridge sends visibly and admits one attributed turn to that live instance. Unknown, ambiguous, same, or offline targets fail before sending.",
37
- "Add buttons by embedding the same top-level telegram_button HTML comments used in normal Telegram replies; Telegram does not support standalone buttons.",
37
+ "Add buttons by embedding the same telegram_button HTML comments used in normal Telegram replies; Telegram does not support standalone buttons.",
38
38
  "During an active Telegram turn, omit telegram_message for the current target and answer normally; use thread only when the user requests delivery to a different live Pi thread.",
39
39
  ] as const;
40
40
 
@@ -360,7 +360,7 @@ export interface TelegramTypingLoopStarterDeps<
360
360
  getDefaultChatId: () => number | undefined;
361
361
  sendTypingAction: (
362
362
  chatId: number,
363
- options?: { message_thread_id?: number; assertAuthority?: () => void },
363
+ options?: { message_thread_id?: number; assertAuthority?: () => void; isCancelled?: () => boolean },
364
364
  ) => Promise<unknown>;
365
365
  updateStatus: (ctx: TContext, error?: string) => void;
366
366
  isContextActive?: (ctx: TContext) => boolean;
@@ -454,6 +454,7 @@ export function createTelegramTypingLoopStarter<TContext>(
454
454
  try {
455
455
  await deps.sendTypingAction(targetChatId, {
456
456
  ...actionOptions,
457
+ isCancelled: () => !active,
457
458
  // This stays process-local; adapters recheck it before HTTP/IPC issuance, not just loop ticks.
458
459
  assertAuthority() {
459
460
  if (!active || !canContinue()) throw new Error("Typing context authority changed.");
@@ -407,8 +407,8 @@ export interface TelegramApiCallOptions {
407
407
 
408
408
  export class TelegramApiAuthorityError extends Error {
409
409
  readonly requestIssued: boolean;
410
- constructor(requestIssued: boolean) {
411
- super("Telegram API call authority is unavailable.");
410
+ constructor(requestIssued: boolean, options?: ErrorOptions) {
411
+ super("Telegram API call authority is unavailable.", options);
412
412
  this.name = "TelegramApiAuthorityError";
413
413
  this.requestIssued = requestIssued;
414
414
  }
@@ -418,11 +418,12 @@ export class TelegramApiAuthorityError extends Error {
418
418
  export function assertTelegramApiCallAuthority(
419
419
  assertAuthority: (() => void) | undefined,
420
420
  requestIssued: boolean,
421
+ errorOptions?: ErrorOptions,
421
422
  ): void {
422
423
  try {
423
424
  assertAuthority?.();
424
425
  } catch {
425
- throw new TelegramApiAuthorityError(requestIssued);
426
+ throw new TelegramApiAuthorityError(requestIssued, errorOptions);
426
427
  }
427
428
  }
428
429
 
@@ -847,6 +848,8 @@ export interface TelegramBridgeApiRuntimeDeps {
847
848
  /** Typing issuance guard is local authority, never Bot API body or IPC data. */
848
849
  export interface TelegramTypingActionOptions extends Pick<TelegramApiCallOptions, "assertAuthority"> {
849
850
  message_thread_id?: number;
851
+ /** Diagnostic observation only: an ended loop does not grant issuance, success or retry. */
852
+ isCancelled?: () => boolean;
850
853
  }
851
854
 
852
855
  export interface TelegramBridgeApiRuntime extends Pick<
@@ -1649,7 +1652,7 @@ async function callTelegramWithRetry<TResponse>(
1649
1652
  } catch (error) {
1650
1653
  // Authority refusal is never a transport failure, retry grant or acknowledged success.
1651
1654
  if (error instanceof TelegramApiAuthorityError) throw error;
1652
- assertTelegramApiCallAuthority(assertAuthority, requestIssued);
1655
+ assertTelegramApiCallAuthority(assertAuthority, requestIssued, { cause: error });
1653
1656
  const retryable =
1654
1657
  isRetryableTelegramApiError(error) &&
1655
1658
  !(
@@ -2189,11 +2192,14 @@ export function createTelegramBridgeApiRuntime(
2189
2192
  );
2190
2193
  return true as TResponse;
2191
2194
  }
2192
- deps.recordRuntimeEvent(
2193
- "api",
2194
- error,
2195
- withTelegramTransportDiagnostics(error, { method }),
2196
- );
2195
+ const diagnosticError = error instanceof TelegramApiAuthorityError && Object.hasOwn(error, "cause")
2196
+ ? error.cause : error;
2197
+ if (!options?.isExpectedError?.(error))
2198
+ deps.recordRuntimeEvent(
2199
+ "api",
2200
+ diagnosticError,
2201
+ withTelegramTransportDiagnostics(diagnosticError, { method }),
2202
+ );
2197
2203
  throw error;
2198
2204
  })
2199
2205
  .finally(() => {
@@ -2311,14 +2317,21 @@ export function createTelegramBridgeApiRuntime(
2311
2317
  : {}),
2312
2318
  }),
2313
2319
  sendTypingAction: createTelegramChatActionSender(
2314
- (chatId, action, options) =>
2315
- callRecorded<boolean>("sendChatAction", {
2320
+ (chatId, action, options) => {
2321
+ const isCancelled = options?.isCancelled;
2322
+ return callRecorded<boolean>("sendChatAction", {
2316
2323
  chat_id: chatId,
2317
2324
  action,
2318
2325
  ...(options?.message_thread_id !== undefined
2319
2326
  ? { message_thread_id: options.message_thread_id }
2320
2327
  : {}),
2321
- }, { assertAuthority: options?.assertAuthority }),
2328
+ }, {
2329
+ assertAuthority: options?.assertAuthority,
2330
+ ...(isCancelled ? {
2331
+ isExpectedError: (error: unknown) => error instanceof TelegramApiAuthorityError && !Object.hasOwn(error, "cause") && isCancelled() === true,
2332
+ } : {}),
2333
+ });
2334
+ },
2322
2335
  "typing",
2323
2336
  ),
2324
2337
  sendRecordVoiceAction: createTelegramChatActionSender(
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.58.1",
3
+ "version": "0.58.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -16,12 +16,12 @@ Use Telegram as a mobile companion to the current Pi session. Preserve the exact
16
16
  | Explicitly send from local/TUI to Telegram | `telegram_message` or `telegram_attach` |
17
17
  | Explicitly send to a different live Thread | `telegram_message(thread=...)` |
18
18
  | Add buttons | Hidden comment for footer; `telegram_button` fence for in-body rows |
19
- | Add explicit voice | Top-level hidden action comment |
19
+ | Add explicit voice | Hidden action comment |
20
20
  | Build a repeated deterministic interaction | Follow `generative-apps` |
21
21
 
22
22
  A connected Telegram session proves capability, not user intent. Use Telegram features on Telegram-originated turns or explicit Telegram delivery requests only. Never call `telegram_message` for the current active target.
23
23
 
24
- For direct delivery, Thread routing, configuration, or diagnosis, read only the applicable reference listed under [Conditional References](#conditional-references).
24
+ For delivery, Thread routing, configuration or diagnosis, read the applicable reference under [Conditional References](#conditional-references).
25
25
 
26
26
  ## Turn Context
27
27
 
@@ -40,7 +40,7 @@ Reply in concise, phone-width Telegram Rich Markdown. Use `$...$` and `$$...$$`
40
40
 
41
41
  ## Assistant Actions
42
42
 
43
- `telegram_button` and `telegram_voice` are markup, not tools. Emit action comments at column zero outside lists, quotes, code, and indentation. Comments create footer buttons or voice artifacts. For buttons between paragraphs, use a column-zero triple-backtick `telegram_button` block. Both button wrappers accept the same singleton JSON/CML cell or mixed matrix; the wrapper determines placement. Telegram removes every assistant-authored HTML comment from previews and final replies regardless of owner or Markdown position; only recognized top-level wrappers activate actions; comment-only output sends no text message.
43
+ `telegram_button` and `telegram_voice` are markup, not tools. Complete comments activate regardless of surrounding Markdown. Escape `<!--` as `&lt;!--` for literal examples; backticks and backslashes do not disable actions. Comments create footer buttons or voice artifacts. In-body fences: ≥3 backticks/tildes, optional indentation/whitespace, exact `telegram_button`; separate same-marker closure, no shorter. Both wrappers accept a singleton JSON/CML cell or mixed matrix; the wrapper determines placement. Telegram removes every assistant-authored HTML comment from previews and final replies regardless of owner or Markdown position; only complete recognized comments or valid button fences activate actions; comment-only output sends no text message.
44
44
 
45
45
  ### Shared Encoding Rule
46
46
 
@@ -61,7 +61,7 @@ Every enabled button has a self-contained prompt and an optional selection style
61
61
  - `{label|prompt|selected_style}` and `{|prompt|selected_style}` accept `primary`, `success`, or `danger`.
62
62
  - Fourth-position `1`/`true` disables, `0`/`false` enables; omission means enabled. JSON uses boolean `disabled`. `{|Next||1}` omits label/style; `{Next|||1}` omits prompt/style; `{|||1}` is blank (Telegram receives a non-breaking space). Prefer meaningful labels and retain a useful enabled action. Enabled CML requires a prompt. Disabled controls stay visible but have no callback, queued prompt, or bound-method invocation.
63
63
  - Both wrappers concatenate rows from adjacent JSON/CML cells or matrices, with optional commas. Cells are vertical; horizontal peers require a nested row: `[[{A|a}{B|b}]]`.
64
- - Prefer one matrix per related group. Fenced blocks stay in place in Rich mode; HTML compatibility moves them to the footer. Native rows allow eight buttons. Malformed/oversized/unclosed blocks activate nothing; drafts hide them. Outer code fences and quoted/indented examples remain literal.
64
+ - Prefer one matrix per related group. Fenced blocks stay in place in Rich mode; HTML compatibility moves them to the footer. Native rows allow eight buttons. Malformed/oversized/unclosed blocks activate nothing; drafts hide them. Enclosing code fences and quoted/list-prefixed button fences remain literal.
65
65
  - Both placements share prompt/app routing. In-body clicks acknowledge without recoloring; selected-style highlighting remains footer-only.
66
66
 
67
67
  A single in-body button (the four-backtick wrapper below makes this a literal example):
@@ -124,6 +124,6 @@ Before replying:
124
124
 
125
125
  - Use the ordinary path for the current target and direct tools only for explicit other delivery.
126
126
  - Attach requested files rather than only mentioning them.
127
- - Keep action comments and button fences top-level, complete, and canonical: CML first, JSON when necessary; either syntax may coexist within one matrix.
127
+ - Keep comments complete and button fences top-level and canonical: CML first, JSON when necessary; either syntax may coexist within one matrix.
128
128
  - Give every enabled button a self-contained prompt; preserve confirmation for dangerous actions.
129
129
  - Expose no secret or hidden reasoning.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -49,8 +49,8 @@
49
49
  "@llblab/pi-clean-room": "0.3.0",
50
50
  "@llblab/pi-codex-usage": "0.13.2",
51
51
  "@llblab/pi-grow-loop": "0.9.0",
52
- "@llblab/pi-state-flow": "0.26.5",
53
- "@llblab/pi-telegram": "0.58.1",
52
+ "@llblab/pi-state-flow": "0.27.1",
53
+ "@llblab/pi-telegram": "0.58.3",
54
54
  "@llblab/skills": "1.15.0"
55
55
  },
56
56
  "bundledDependencies": [