@llblab/pi-telegram 0.56.2 → 0.56.3

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,11 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.56.3: Button Matrices and Custom-Message Starts
8
+
9
+ - `Button grammar`: Adjacent complete JSON/CML cells and matrices now compose one button surface in source order instead of dropping fenced controls or truncating footer controls. Each matrix contributes its existing rows; horizontal grouping, disabled controls and callback semantics remain unchanged. Invalid fenced groups register no callbacks. This extends button syntax only; rendering mode and voice syntax are unchanged.
10
+ - `Pi compatibility`: Extension-triggered runs on Pi 1.2 no longer fail when `before_agent_start` carries a custom message without a user prompt. Custom starts retain connected bridge guidance without being mistaken for fresh Telegram user input; legacy user events, unavailable-transport filtering and ordered system-prompt blocks remain supported.
11
+
7
12
  ## 0.56.2: Canonical Queue Reactions
8
13
 
9
14
  - `Queue reactions`: Only thumbs-up selects Priority, thumbs-down selects Skip, and fire selects Steer. Heart, dove, ghost, broken-heart, poop and wastebasket aliases are no longer controls. Unsupported reactions neither flush waiting input nor change queue intent; removing a supported reaction does not fall back to a retired alias. Only thumbs-down cancels a waiting standalone Continue.
@@ -463,23 +463,33 @@ function parseTelegramAdaptiveActionPayloadRows(source, parseCompactPayload, opt
463
463
  }
464
464
  return undefined;
465
465
  };
466
- const parseVerticalSequence = () => {
466
+ const parseTopLevelRows = () => {
467
467
  const rows = [];
468
468
  while (offset < source.length) {
469
469
  skipWhitespace();
470
- if (source[offset] !== "{")
470
+ const character = source[offset];
471
+ if (character === "{") {
472
+ const cell = parseCell();
473
+ if (!cell)
474
+ return undefined;
475
+ rows.push([cell]);
476
+ }
477
+ else if (character === "[" && options.allowMatrixGroups) {
478
+ const matrix = parseMatrix();
479
+ if (!matrix)
480
+ return undefined;
481
+ rows.push(...matrix);
482
+ }
483
+ else {
471
484
  break;
472
- const cell = parseCell();
473
- if (!cell)
474
- return undefined;
475
- rows.push([cell]);
485
+ }
476
486
  if (!consumeOptionalSeparator())
477
487
  return undefined;
478
488
  }
479
489
  return rows.length > 0 ? rows : undefined;
480
490
  };
481
491
  skipWhitespace();
482
- const rows = source[offset] === "{" ? parseVerticalSequence() : parseMatrix();
492
+ const rows = parseTopLevelRows();
483
493
  if (!rows)
484
494
  return undefined;
485
495
  skipWhitespace();
@@ -527,10 +537,10 @@ function isPlausibleTelegramMatrixStart(source, start) {
527
537
  return (source[offset] === "{" || source[offset] === "[" || source[offset] === "]");
528
538
  }
529
539
  export function parseTelegramButtonPayloadRows(source) {
530
- return parseTelegramAdaptiveActionPayloadRows(source.trim(), parseTelegramButtonCompactActionPayload, { maxCompactAtoms: 4 })?.rows;
540
+ return parseTelegramAdaptiveActionPayloadRows(source.trim(), parseTelegramButtonCompactActionPayload, { maxCompactAtoms: 4, allowMatrixGroups: true })?.rows;
531
541
  }
532
542
  export function parseTelegramActionPayloadRows(comment, command) {
533
- const scanned = scanTelegramActionComment(comment, command, (content, offset) => parseTelegramAdaptiveActionPayloadRows(content.slice(offset), parseTelegramButtonCompactActionPayload, { allowTrailing: true, maxCompactAtoms: 4 })?.rows);
543
+ const scanned = scanTelegramActionComment(comment, command, (content, offset) => parseTelegramAdaptiveActionPayloadRows(content.slice(offset), parseTelegramButtonCompactActionPayload, { allowTrailing: true, maxCompactAtoms: 4, allowMatrixGroups: true })?.rows);
534
544
  if (!scanned)
535
545
  return undefined;
536
546
  if ("payload" in scanned)
@@ -29,7 +29,9 @@ export declare function createTelegramModelContextAvailabilityRuntime(deps: {
29
29
  memory?: TelegramModelContextAvailabilityMemory;
30
30
  }): TelegramModelContextAvailabilityRuntime;
31
31
  export type TelegramSystemPrompt = string | string[];
32
- type TelegramBeforeAgentStartEvent = Omit<BeforeAgentStartEvent, "systemPrompt"> & {
32
+ type TelegramBeforeAgentStartEvent = Omit<BeforeAgentStartEvent, "systemPrompt" | "prompt" | "messageType"> & {
33
+ messageType?: "user" | "custom";
34
+ prompt?: string;
33
35
  systemPrompt?: TelegramSystemPrompt | null;
34
36
  };
35
37
  type TelegramBeforeAgentStartResult = {
@@ -37,7 +39,7 @@ type TelegramBeforeAgentStartResult = {
37
39
  };
38
40
  type TelegramBeforeAgentStartHook = (event: TelegramBeforeAgentStartEvent) => TelegramBeforeAgentStartResult;
39
41
  export declare function buildTelegramBridgeSystemPrompt(options: {
40
- prompt: string;
42
+ prompt?: string;
41
43
  systemPrompt?: TelegramSystemPrompt | null;
42
44
  telegramPrefix?: string;
43
45
  localSystemPromptSuffix: string;
@@ -113,7 +113,7 @@ export function buildTelegramBridgeSystemPrompt(options) {
113
113
  const telegramHead = telegramPrefix.endsWith("]")
114
114
  ? telegramPrefix.slice(0, -1)
115
115
  : telegramPrefix;
116
- const trimmedPrompt = options.prompt.trimStart();
116
+ const trimmedPrompt = options.prompt?.trimStart() ?? "";
117
117
  const telegramTurn = trimmedPrompt.startsWith(`${telegramHead}]`) ||
118
118
  trimmedPrompt.startsWith(`${telegramHead}|`);
119
119
  const telegramSuffix = telegramTurn
@@ -127,7 +127,7 @@ export function buildTelegramBridgeSystemPrompt(options) {
127
127
  }
128
128
  export function createTelegramBeforeAgentStartHook(options = {}) {
129
129
  return (event) => buildTelegramBridgeSystemPrompt({
130
- prompt: event.prompt,
130
+ prompt: event.messageType === "custom" ? undefined : event.prompt,
131
131
  systemPrompt: event.systemPrompt,
132
132
  telegramPrefix: options.telegramPrefix,
133
133
  localSystemPromptSuffix: options.localSystemPromptSuffix ?? LOCAL_SYSTEM_PROMPT_SUFFIX,
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.56.2",
3
+ "version": "0.56.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -60,7 +60,7 @@ Every enabled button has a self-contained prompt and an optional selection style
60
60
  - `{label|prompt}` separates visible label from queued prompt.
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
- - Top-level cells form vertical rows; inside a fenced block, adjacent top-level JSON/CML objects may omit the outer array and commas. One nested row groups horizontal peers.
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
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.
65
65
  - Both placements share prompt/app routing. In-body clicks acknowledge without recoloring; selected-style highlighting remains footer-only.
66
66
 
@@ -715,7 +715,7 @@ Assistant-authored final-message actions use hidden top-level comments, with an
715
715
  - `telegram_voice` accepts one positional compact action cell or JSON object and creates one voice artifact through configured outbound handlers, programmatic voice handlers, or registered synthesis providers.
716
716
  - `telegram_button` accepts a JSON object, adaptive JSON/CML matrix, or positional Compact Matrix Literal. Named JSON objects and positional cells may coexist, with commas optional only between completed matrix or row elements. Each top-level cell creates one full-width row, while a nested row groups buttons horizontally without an artificial parser-width cap; every callback enqueues its configured prompt text as a normal Telegram prompt turn. The JSON-first grammar, positional trim/escape rules, atomic rejection, and renderer-owned width policy are specified in [Adaptive Button Literal](./compact-matrix-literal.md).
717
717
 
718
- Standalone column-zero triple-backtick `telegram_button` blocks reuse the same cell/matrix grammar and callback store, including adjacent top-level JSON/CML objects as bracketless comma-free vertical rows, and compile to native Rich Markdown button rows between paragraphs. Rendering validates each complete block before registering any callbacks, escapes label text, preserves disabled cells, and leaves larger enclosing fences literal. Previews hide complete and unfinished action fences. HTML mode projects those controls into the footer. In-body callbacks acknowledge without rewriting the Rich message; selected-style highlighting remains footer-only.
718
+ Standalone column-zero triple-backtick `telegram_button` blocks reuse the same cell/matrix grammar and callback store, including adjacent complete cells and matrices whose normalized rows concatenate in source order, and compile to native Rich Markdown button rows between paragraphs. Rendering validates each complete block before registering any callbacks, escapes label text, preserves disabled cells, and leaves larger enclosing fences literal. Previews hide complete and unfinished action fences. HTML mode projects those controls into the footer. In-body callbacks acknowledge without rewriting the Rich message; selected-style highlighting remains footer-only.
719
719
 
720
720
  Action recognition remains restricted to top-level column-zero comments and exact-name button fences so nested examples cannot trigger voice, buttons, or callbacks. The Telegram surface independently strips every complete assistant-authored HTML comment from previews and final delivery regardless of Markdown position or comment owner; an unclosed comment is withheld through the remaining tail, and a comment-only result sends no text message. Pi's terminal transcript and model context remain unchanged.
721
721
 
@@ -150,11 +150,11 @@ For `telegram_button` hidden comments and fenced blocks:
150
150
  - Positional `{label|prompt|selected_style|1}` is equivalent to JSON `{"label":"label","prompt":"prompt","selected_style":"selected_style","disabled":true}` with a valid selected style; `{|prompt||1}` is equivalent to `{"prompt":"prompt","disabled":true}`. Fourth-position `0` or `false` matches `disabled: false`; `true` is equivalent to `1`.
151
151
  - `{label|||1}` matches JSON `{"label":"label","disabled":true}`; `{|||1}` matches `{"disabled":true}`. Disabled cells require no prompt and do not retain action or selected-style semantics.
152
152
  - Disabled buttons serialize as `{ text, disabled: {} }`, without `callback_data`; they neither enqueue prompts nor invoke bound app methods. Enabled controls keep existing selection and callback behavior.
153
- - Top-level cells become full-width rows. Inside a fenced block, a whitespace-separated sequence of top-level JSON/CML objects is an alias for the same objects inside an outer matrix, so vertical rows need neither brackets nor commas.
153
+ - Top-level cells become full-width rows. Telegram button wrappers additionally accept adjacent complete JSON/CML cells and matrices, with optional commas between groups. Normalize each group independently and concatenate its rows in source order: `[{A|a}] [{B|b}]` and `{A|a} [{B|b}]` both equal `[{A|a}{B|b}]`. Horizontal rows remain nested inside a matrix: `[[{A|a}{B|b}]] [{C|c}]` equals `[[{A|a}{B|b}]{C|c}]`. This is a Telegram button wrapper extension, not a change to portable CML v4 or voice syntax.
154
154
  - Nested rows become horizontal keyboard rows; horizontal intent still requires the explicit nested array.
155
155
  - Invalid payloads are stripped with their recognized action comment and register no callbacks.
156
156
 
157
- The wrapper selects placement without changing cell semantics. A hidden `telegram_button` HTML comment builds the footer keyboard. A standalone column-zero fenced block opened by exactly three backticks plus `telegram_button` renders rows between paragraphs in Native Rich Markdown. A singleton JSON/CML object needs no array in either wrapper. Fenced content must be one complete payload without trailing envelope text. Larger outer fences and ordinary code blocks remain literal examples; unclosed action fences are withheld. Native Rich rows support at most eight buttons and must fit one message chunk; these are renderer constraints, not grammar limits. HTML compatibility places fenced controls in the footer. See [Outbound](./outbound.md) for delivery and callback behavior.
157
+ The wrapper selects placement without changing cell semantics. A hidden `telegram_button` HTML comment builds the footer keyboard. A standalone column-zero fenced block opened by exactly three backticks plus `telegram_button` renders rows between paragraphs in Native Rich Markdown. A singleton JSON/CML object needs no array in either wrapper. Fenced content must be one complete group sequence without trailing envelope text; any invalid group rejects the entire block before callback registration. Larger outer fences and ordinary code blocks remain literal examples; unclosed action fences are withheld. Native Rich rows support at most eight buttons and must fit one message chunk; these are renderer constraints, not grammar limits. HTML compatibility places fenced controls in the footer. See [Outbound](./outbound.md) for delivery and callback behavior.
158
158
 
159
159
  ````markdown
160
160
  Description.
package/docs/outbound.md CHANGED
@@ -182,7 +182,7 @@ Rules:
182
182
  - 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.
183
183
  - 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.
184
184
  - Button actions are stored in memory with short `callback_data`; Telegram never sees the full prompt in the button payload.
185
- - Standalone column-zero fences opened by exactly three backticks and `telegram_button` use the same singleton/matrix JSON/CML grammar. Adjacent top-level JSON/CML objects inside one fence may omit the outer array and commas; each remains a vertical singleton row, while horizontal peers still require a nested array. 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.
185
+ - 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.
186
186
  - 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.
187
187
  - 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.
188
188
  - 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.
@@ -197,6 +197,7 @@ The extension injects prompt guidance by context:
197
197
 
198
198
  - If no bot token is configured, no Telegram bridge suffix is injected.
199
199
  - For ordinary local/TUI prompts, the compact routing note points to bundled Skills, including portable `show-me`, while forbidding Telegram use unless explicitly requested.
200
+ - Pi 1.2 custom-message starts may omit the user `prompt`. They retain connected bridge guidance without acquiring Telegram-user origin from their message content; explicit custom origin also overrides any stale prompt field. Legacy user events without `messageType` retain prefix-based Telegram detection. Unavailable transport still strips Telegram tool guidance.
200
201
  - 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.
201
202
  - 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.
202
203
  - 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.
@@ -75,7 +75,7 @@ Every assistant-authored HTML comment is transport-private on Telegram: previews
75
75
  - `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.
76
76
  - 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.
77
77
  - `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.
78
- - `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; fenced blocks also accept adjacent top-level JSON/CML objects without an outer array or commas as vertical singleton rows. 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.
78
+ - `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.
79
79
 
80
80
  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.
81
81
 
@@ -364,7 +364,7 @@ function parseTelegramVoiceCompactActionPayload(
364
364
  function parseTelegramAdaptiveActionPayloadRows(
365
365
  source: string,
366
366
  parseCompactPayload: TelegramCompactActionPayloadParser,
367
- options: { allowTrailing?: boolean; maxCompactAtoms?: number } = {},
367
+ options: { allowTrailing?: boolean; maxCompactAtoms?: number; allowMatrixGroups?: boolean } = {},
368
368
  ): { rows: Record<string, unknown>[][]; end: number } | undefined {
369
369
  let offset = 0;
370
370
  const isStructuralWhitespace = (character: string | undefined): boolean =>
@@ -512,21 +512,29 @@ function parseTelegramAdaptiveActionPayloadRows(
512
512
  return undefined;
513
513
  };
514
514
 
515
- const parseVerticalSequence = (): Record<string, unknown>[][] | undefined => {
515
+ const parseTopLevelRows = (): Record<string, unknown>[][] | undefined => {
516
516
  const rows: Record<string, unknown>[][] = [];
517
517
  while (offset < source.length) {
518
518
  skipWhitespace();
519
- if (source[offset] !== "{") break;
520
- const cell = parseCell();
521
- if (!cell) return undefined;
522
- rows.push([cell]);
519
+ const character = source[offset];
520
+ if (character === "{") {
521
+ const cell = parseCell();
522
+ if (!cell) return undefined;
523
+ rows.push([cell]);
524
+ } else if (character === "[" && options.allowMatrixGroups) {
525
+ const matrix = parseMatrix();
526
+ if (!matrix) return undefined;
527
+ rows.push(...matrix);
528
+ } else {
529
+ break;
530
+ }
523
531
  if (!consumeOptionalSeparator()) return undefined;
524
532
  }
525
533
  return rows.length > 0 ? rows : undefined;
526
534
  };
527
535
 
528
536
  skipWhitespace();
529
- const rows = source[offset] === "{" ? parseVerticalSequence() : parseMatrix();
537
+ const rows = parseTopLevelRows();
530
538
  if (!rows) return undefined;
531
539
  skipWhitespace();
532
540
  if (!options.allowTrailing && offset !== source.length) return undefined;
@@ -581,7 +589,7 @@ export function parseTelegramButtonPayloadRows(
581
589
  return parseTelegramAdaptiveActionPayloadRows(
582
590
  source.trim(),
583
591
  parseTelegramButtonCompactActionPayload,
584
- { maxCompactAtoms: 4 },
592
+ { maxCompactAtoms: 4, allowMatrixGroups: true },
585
593
  )?.rows;
586
594
  }
587
595
 
@@ -596,7 +604,7 @@ export function parseTelegramActionPayloadRows(
596
604
  parseTelegramAdaptiveActionPayloadRows(
597
605
  content.slice(offset),
598
606
  parseTelegramButtonCompactActionPayload,
599
- { allowTrailing: true, maxCompactAtoms: 4 },
607
+ { allowTrailing: true, maxCompactAtoms: 4, allowMatrixGroups: true },
600
608
  )?.rows,
601
609
  );
602
610
  if (!scanned) return undefined;
package/lib/prompts.ts CHANGED
@@ -150,10 +150,13 @@ export function createTelegramModelContextAvailabilityRuntime(deps: {
150
150
 
151
151
  export type TelegramSystemPrompt = string | string[];
152
152
 
153
+ // Pi 1.0 user events lack messageType; Pi 1.2 custom starts carry no user prompt.
153
154
  type TelegramBeforeAgentStartEvent = Omit<
154
155
  BeforeAgentStartEvent,
155
- "systemPrompt"
156
+ "systemPrompt" | "prompt" | "messageType"
156
157
  > & {
158
+ messageType?: "user" | "custom";
159
+ prompt?: string;
157
160
  systemPrompt?: TelegramSystemPrompt | null;
158
161
  };
159
162
 
@@ -166,7 +169,7 @@ type TelegramBeforeAgentStartHook = (
166
169
  ) => TelegramBeforeAgentStartResult;
167
170
 
168
171
  export function buildTelegramBridgeSystemPrompt(options: {
169
- prompt: string;
172
+ prompt?: string;
170
173
  systemPrompt?: TelegramSystemPrompt | null;
171
174
  telegramPrefix?: string;
172
175
  localSystemPromptSuffix: string;
@@ -177,7 +180,7 @@ export function buildTelegramBridgeSystemPrompt(options: {
177
180
  const telegramHead = telegramPrefix.endsWith("]")
178
181
  ? telegramPrefix.slice(0, -1)
179
182
  : telegramPrefix;
180
- const trimmedPrompt = options.prompt.trimStart();
183
+ const trimmedPrompt = options.prompt?.trimStart() ?? "";
181
184
  const telegramTurn =
182
185
  trimmedPrompt.startsWith(`${telegramHead}]`) ||
183
186
  trimmedPrompt.startsWith(`${telegramHead}|`);
@@ -200,7 +203,7 @@ export function createTelegramBeforeAgentStartHook(
200
203
  ): TelegramBeforeAgentStartHook {
201
204
  return (event) =>
202
205
  buildTelegramBridgeSystemPrompt({
203
- prompt: event.prompt,
206
+ prompt: event.messageType === "custom" ? undefined : event.prompt,
204
207
  systemPrompt: event.systemPrompt,
205
208
  telegramPrefix: options.telegramPrefix,
206
209
  localSystemPromptSuffix:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.56.2",
3
+ "version": "0.56.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -60,7 +60,7 @@ Every enabled button has a self-contained prompt and an optional selection style
60
60
  - `{label|prompt}` separates visible label from queued prompt.
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
- - Top-level cells form vertical rows; inside a fenced block, adjacent top-level JSON/CML objects may omit the outer array and commas. One nested row groups horizontal peers.
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
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.
65
65
  - Both placements share prompt/app routing. In-body clicks acknowledge without recoloring; selected-style highlighting remains footer-only.
66
66