@llblab/pi-kit 0.1.13 → 0.3.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.
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +10 -0
- package/README.md +2 -2
- package/node_modules/@llblab/pi-actors/AGENTS.md +5 -3
- package/node_modules/@llblab/pi-actors/BACKLOG.md +1 -1
- package/node_modules/@llblab/pi-actors/CHANGELOG.md +15 -0
- package/node_modules/@llblab/pi-actors/README.md +5 -3
- package/node_modules/@llblab/pi-actors/dist/index.js +4 -1
- package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +2 -2
- package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +42 -19
- package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.d.ts +2 -1
- package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +7 -2
- package/node_modules/@llblab/pi-actors/dist/lib/limits.d.ts +9 -0
- package/node_modules/@llblab/pi-actors/dist/lib/limits.js +9 -0
- package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +10 -11
- package/node_modules/@llblab/pi-actors/dist/lib/observability.js +81 -56
- package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +31 -0
- package/node_modules/@llblab/pi-actors/dist/lib/pi.js +180 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +115 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +623 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.d.ts +3 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +341 -13
- package/node_modules/@llblab/pi-actors/dist/lib/runs-trace.d.ts +1 -1
- package/node_modules/@llblab/pi-actors/dist/lib/runs-trace.js +5 -3
- package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.d.ts +16 -0
- package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.js +143 -0
- package/node_modules/@llblab/pi-actors/dist/lib/temp.js +1 -1
- package/node_modules/@llblab/pi-actors/dist/lib/tools-inspect.js +3 -1
- package/node_modules/@llblab/pi-actors/dist/scripts/async-runner.mjs +5 -19
- package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +2 -2
- package/node_modules/@llblab/pi-actors/dist/skills/actors/references/runs.md +1 -1
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/SKILL.md +1 -1
- package/node_modules/@llblab/pi-actors/docs/README.md +1 -0
- package/node_modules/@llblab/pi-actors/docs/async-runs.md +8 -4
- package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +207 -0
- package/node_modules/@llblab/pi-actors/index.ts +4 -1
- package/node_modules/@llblab/pi-actors/lib/async-runs.ts +42 -21
- package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +8 -3
- package/node_modules/@llblab/pi-actors/lib/limits.ts +9 -0
- package/node_modules/@llblab/pi-actors/lib/observability.ts +97 -78
- package/node_modules/@llblab/pi-actors/lib/pi.ts +210 -0
- package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +800 -0
- package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +370 -18
- package/node_modules/@llblab/pi-actors/lib/runs-trace.ts +6 -4
- package/node_modules/@llblab/pi-actors/lib/session-evidence.ts +153 -0
- package/node_modules/@llblab/pi-actors/lib/temp.ts +1 -1
- package/node_modules/@llblab/pi-actors/lib/tools-inspect.ts +4 -1
- package/node_modules/@llblab/pi-actors/package.json +3 -3
- package/node_modules/@llblab/pi-actors/scripts/async-runner.mjs +5 -19
- package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +2 -2
- package/node_modules/@llblab/pi-actors/skills/actors/references/runs.md +1 -1
- package/node_modules/@llblab/pi-actors/skills/swarm/SKILL.md +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +4 -4
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +25 -0
- package/node_modules/@llblab/pi-telegram/README.md +4 -4
- package/node_modules/@llblab/pi-telegram/docs/activity.md +4 -4
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +10 -10
- package/node_modules/@llblab/pi-telegram/docs/compact-matrix-literal.md +16 -14
- package/node_modules/@llblab/pi-telegram/docs/inbound.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +13 -22
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +6 -7
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +4 -5
- package/node_modules/@llblab/pi-telegram/docs/voice.md +12 -2
- package/node_modules/@llblab/pi-telegram/index.ts +0 -1
- package/node_modules/@llblab/pi-telegram/lib/activity.ts +10 -5
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +16 -17
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +16 -6
- package/node_modules/@llblab/pi-telegram/lib/config.ts +25 -25
- package/node_modules/@llblab/pi-telegram/lib/media.ts +12 -2
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +10 -73
- package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +9 -8
- package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +212 -82
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +78 -36
- package/node_modules/@llblab/pi-telegram/lib/status.ts +23 -4
- package/node_modules/@llblab/pi-telegram/lib/turns.ts +10 -1
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +1 -1
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +61 -205
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/references/capability-adapters.md +27 -0
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/references/layout-and-state.md +35 -0
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +70 -110
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/configuration.md +15 -0
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +27 -0
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +14 -0
- package/package.json +3 -3
|
@@ -50,7 +50,7 @@ Stable commands inside the paired Telegram DM:
|
|
|
50
50
|
|
|
51
51
|
- `/start` — pair when needed and open the main application menu.
|
|
52
52
|
- `/compact` — open confirmation and compact when idle.
|
|
53
|
-
- `/next` — dispatch the next queued turn, aborting active work first when needed.
|
|
53
|
+
- `/next` — dispatch the next queued turn, aborting active work first when needed; one Pi-aligned informational reply anchors to a pre-abort snapshot of the Telegram turn or falls back to the command, and aborted pending assistant text is suppressed.
|
|
54
54
|
- `/continue` — enqueue a priority `continue` prompt.
|
|
55
55
|
- `/abort` — abort active work and keep the queue; abort-history is scoped to Telegram-owned active turns.
|
|
56
56
|
- `/stop` — abort active Telegram-owned work and clear waiting Telegram queue items.
|
|
@@ -65,8 +65,8 @@ This command surface is a mobile companion subset, not a raw terminal-command br
|
|
|
65
65
|
- `telegram_attach(paths, chat_id?, thread_id?, caption?)` is the stable artifact delivery tool for generated files. During Telegram turns it queues files for the active reply; with `assistant.rendering: "rich"`, exactly one PNG/JPEG, MP4, or MP3 artifact plus non-empty final Markdown can become one reply-anchored Rich Message. HTML mode, multiple/unsupported files, Guest Mode, and voice outputs retain their established paths. Outside Telegram turns the tool sends files directly to the paired/default chat, the registered follower's assigned thread, or an explicit `chat_id` plus optional `thread_id` when this Pi instance owns `/telegram-connect` or is registered with the multi-instance bus.
|
|
66
66
|
- `telegram_message(text, chat_id?, thread_id?)` sends a direct Telegram Markdown message when this Pi instance owns `/telegram-connect` or is registered with the multi-instance bus. During an active Telegram turn, omitted targeting and an explicit target equal to that turn are rejected so the ordinary final-reply path remains the sole current-target response; an explicit different chat/thread target remains allowed for requested cross-target delivery. Outside active turns, paired/default local/TUI delivery remains unchanged. Top-level `telegram_button` comments inside `text` are parsed with the same planner used for normal replies and attached to that message; buttons are never standalone Telegram messages.
|
|
67
67
|
- The bundled `telegram-bridge` Skill owns action syntax, target routing, Threaded Mode, formatting, Generative App operation, and profile-specific debugging guidance. The regular prompt routes applicable turns to that Skill. `telegram_attach`, `telegram_bind`, and `telegram_message` remain registered but are model-active only while this instance owns direct transport or holds a live follower registration; disconnect/loss suppresses their schemas and prompt metadata, and recovery restores only the operator's previously active pi-telegram subset.
|
|
68
|
-
- `telegram_voice` hidden comments request Telegram-native voice delivery through
|
|
69
|
-
- `telegram_button` hidden comments create inline buttons whose taps enqueue prompts. One marker accepts a JSON object, adaptive JSON/CML matrix, positional [Compact Matrix Literal](./compact-matrix-literal.md)
|
|
68
|
+
- `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.
|
|
69
|
+
- `telegram_button` hidden comments create inline buttons whose taps enqueue prompts. 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}`, or `{label|prompt|selected_style}`; the optional third atom requires a prompt and accepts only `primary`, `success`, or `danger`. It trims atom boundaries and supports only the minimal escapes `\|`, `\}`, and `\\`. Prefer one matrix comment for multiple buttons. Use JSON `label` plus `prompt`, or `value` when both strings are identical. Action markers are colon-free; colon-prefixed payloads are rejected. Use top-level column-zero comments outside code, quotes, lists, and indented examples; do not emit standalone button actions.
|
|
70
70
|
|
|
71
71
|
Prompt guidance is context-aware: local/TUI prompts see only explicit direct-delivery guidance, while Telegram-originated turns receive the full action-comment syntax and phone-width output contract.
|
|
72
72
|
|
|
@@ -94,7 +94,6 @@ interface TelegramConfig {
|
|
|
94
94
|
assistant?: {
|
|
95
95
|
draftPreviews?: boolean;
|
|
96
96
|
rendering?: "rich" | "html";
|
|
97
|
-
proactivePush?: boolean;
|
|
98
97
|
activity?: "quiet" | "thinking" | "tools" | "verbose";
|
|
99
98
|
timeInjection?: "hidden" | "always" | "interval";
|
|
100
99
|
};
|
|
@@ -117,10 +116,10 @@ The file is global across Pi instances and contains configuration only. The per-
|
|
|
117
116
|
Hidden/default semantics are represented by absence:
|
|
118
117
|
|
|
119
118
|
- `threads.automaticCleanup` defaults to `true`; graceful Pi quit deletes the instance's bound Threaded Mode tab without prompting but preserves the owner slot as independent restart intent. Set it to `false`, or use `🧹 Thread cleanup` in Telegram Settings, to preserve the tab too. A confirmed `/telegram-disconnect`, unlike quit, clears restart ownership. Settings views and cleanup reload shared config before evaluating this switch, so another live Pi instance's update takes effect without restarting. Confirmed leader/follower teardown persists an exact target/runtime-generation cleanup intent before Telegram deletion; an interrupted attempt remains retryable by the current or successor leader under current authority and clears only after confirmed deletion. A same-profile replacement leader first adopts any still-active binding and cancels its superseded cleanup, so startup never deletes and recreates a reusable thread. If a follower's graceful envelope is missed, the leader may create the same fenced cleanup only after its heartbeat is stale, the OS confirms the exact registered PID no longer exists, cleanup remains enabled, and no replacement registration can overtake deletion. Heartbeat loss alone, live/unknown process liveness, IPC failure, and auth failure remain non-destructive. Invalid-config recovery makes the setting unresolved and therefore skips destructive cleanup. Manual `/telegram-disconnect` keeps its confirmation and teardown behavior regardless of this setting.
|
|
120
|
-
- Every complete intermediate assistant text block from a Telegram-originated turn is delivered once to its immutable target before the existing final reply
|
|
119
|
+
- Every complete intermediate assistant text block from a Telegram-originated turn is delivered once to its immutable target before the existing final reply; final and terminal-partial segments stay with settlement to prevent duplicate replies. While Telegram transport remains authorized, local/autonomous work also projects every completed public block, including commentary and the final block. This connected companion projection is always active and excludes token deltas, hidden reasoning, tool calls/arguments/results, empty blocks, unknown sources, and stale authority. Projection uses the configured Rich or HTML assistant renderer and binds admitted work to the exact target, profile/token transport generation, direct leader epoch or follower registration generation, and session generation. Retired top-level and `assistant.proactivePush` keys are ignored; the nested key is removed during config normalization.
|
|
121
120
|
- `assistant.activity` accepts exactly `"quiet"`, `"thinking"`, `"tools"`, or `"verbose"`; omitted values default to `"verbose"`, explicit values remain unchanged, and invalid values fail closed to `"quiet"`. Each Pi process reloads the shared file-backed value at `agent-start`, so multi-instance activity isolation never relies on a stale process-local config snapshot. `thinking` shows only provider-exposed thinking, `tools` shows only completed tool activity, and `verbose` shows both. Thinking uses persistent ordinary HTML `sendMessage`/`editMessageText` disclosure with a standard expandable blockquote, a `🧠` header carrying the current Pi thinking level, and bounded redacted text whose inline Markdown renders as Telegram HTML. Tools use native Rich Messages with one header followed by separate closed details and JSON pre blocks for bounded redacted arguments, retained updates, and results/errors. Thinking disables link previews on every HTML send/edit and neutralizes HTTP(S) auto-link detection; Rich tool output disables automatic entity detection, with the same protections retained by its HTML fallback. Consecutive tools coalesce only inside the same ordered activity segment and bounded message. Legacy `assistant.activityVerbosity` is read only when `assistant.activity` is absent and is removed by the next Activity Settings write.
|
|
122
121
|
- Voice Reply `hidden`: no `voice.replyMode` key is persisted; legacy `manual` resolves to this silent default. `mirror` adds `[voice] delivery: automatic voice` only to voice/audio-input turns, while `always` adds the same effective line to every Telegram turn.
|
|
123
|
-
- Agent activity status is not configurable
|
|
122
|
+
- Agent activity status is not configurable. While Telegram transport remains authorized, Telegram uses native `sendChatAction(typing)` / product `...active` status as the automatic in-chat work signal for unsettled agent and compaction work. Extension-owned blocking UI prompts pause it and completion resumes it while either work owner remains active.
|
|
124
123
|
- `assistant.timeInjection` accepts `hidden`, `always`, or `interval` and defaults to `interval` when absent without migrating an explicit stored value. Settings writes the selected value there, including `hidden`; the old `time.injectionMode` key is ignored and is not migrated. `time.interval` remains the optional interval duration in milliseconds.
|
|
125
124
|
|
|
126
125
|
With `assistant.rendering: "rich"` (the default), assistant Markdown delivery is native: final replies are sent as `InputRichMessage.markdown` via `sendRichMessage`, and draft previews use `sendRichMessageDraft` when a structurally closed preview frame is available. Draft-frame failures are recorded and skipped rather than converted into raw plain preview messages, because partial Markdown can be temporarily invalid while the final answer remains valid. Long native replies are split at Telegram Rich Message transport limits, with oversized fenced code, display-math, and fully wrapped inline-formatting blocks rewrapped per chunk so persisted chunks remain structurally valid. Guest replies use `InputRichMessageContent` in `answerGuestQuery` results. Bridge-owned UI surfaces such as menus, status, queue controls, commands, and sections keep explicit Telegram HTML/plain rendering by default because those texts are authored by the bridge or companion extensions for Telegram UI. Companion extension sections may explicitly request `"markdown"`, `"html"`, or `"plain"` per view. `assistant.rendering: "html"` keeps the compatibility path that converts assistant Markdown to Telegram HTML before ordinary message delivery. The bridge sets `skip_entity_detection: true` for assistant and guest Markdown so technical text such as `/commands`, hashtags, URLs, phone numbers, and card-like numbers does not gain unintended automatic entities; explicit Markdown links still belong in the Markdown source.
|
|
@@ -337,7 +336,7 @@ Contract:
|
|
|
337
336
|
- Providers are synchronous because `/start` status text is rendered inline with the menu.
|
|
338
337
|
- Return `undefined` when the line is not relevant for the active model.
|
|
339
338
|
- Provider failures are isolated and skipped so optional companion status cannot break the core Telegram menu.
|
|
340
|
-
- The bridge renders rows as `<Label>: <value>` in the same HTML status block as Status,
|
|
339
|
+
- The bridge renders rows as `<Label>: <value>` in the same HTML status block as Status, Tokens, Cache, Cost, and Context, capitalizing the first label character for Telegram UI consistency. Tokens contains only `↑` input and `↓` output totals. Cache groups `R` cache-read tokens, `W` cache-write tokens, and `CH` for the latest assistant request's `cacheRead / (input + cacheRead + cacheWrite)`, shown to one decimal place only after the session reports cache activity. These precise labels avoid conflating token telemetry with companion-provided usage-limit rows.
|
|
341
340
|
|
|
342
341
|
## Updates
|
|
343
342
|
|
|
@@ -32,7 +32,6 @@ Use emoji as stable semantic markers, not decoration. Emoji carry transportable
|
|
|
32
32
|
| `🧩` | Extension-provided surface | Extension command examples, extension section examples | Companion extensions may choose their own emoji, but `🧩` means generic extension/plugin. |
|
|
33
33
|
| `👄` | Voice reply policy | Voice reply settings row and detail card | Not a generic audio attachment marker. |
|
|
34
34
|
| `🕒` | Time injection / wall-clock context | Time injection settings row and detail card | Clock-face marker with hands; not a generic duration/progress marker. |
|
|
35
|
-
| `📌` | Proactive push / pinned behavior | Proactive push settings row and detail card | Not generic active/selected state. |
|
|
36
35
|
| `🔬` | Activity / technical detail | Activity settings row and detail card | Chooses quiet, thinking, tools, or verbose bridge activity; not a generic diagnostics marker. |
|
|
37
36
|
| `🧠` | Model thinking controls | Thinking menus and status rows | Thinking activity quotes omit this icon and their header entirely to minimize chat height. |
|
|
38
37
|
| `📎` | Attachment | Attachment summaries, queue rows for attachment-only turns | Not for thread binding. |
|
|
@@ -138,7 +137,6 @@ Rules:
|
|
|
138
137
|
Examples:
|
|
139
138
|
|
|
140
139
|
- `🟢 Active` — model detail, navigates back to model list
|
|
141
|
-
- `📌 Proactive push: On` — settings row, opens the toggle submenu
|
|
142
140
|
- `👄 Voice reply: Mirror` — settings row, opens the option list
|
|
143
141
|
|
|
144
142
|
## Boolean Toggles
|
|
@@ -199,7 +197,7 @@ Examples:
|
|
|
199
197
|
|
|
200
198
|
## Generated Prompt Buttons
|
|
201
199
|
|
|
202
|
-
A button-only assistant reply uses the standard Rich Markdown heading `☑️ **Choose an option:**`: semantic icon first, one space, bold heading text, and a final colon. Assistant-generated prompt buttons use the default app style before selection. After queue admission, edit only the selected button to its agent-configured `selected_style`: `primary` (default/blue), `success` (green), or `danger` (red). Preserve its agent-authored text and emoji, leave other choices at their default style, and always queue the selected prompt regardless of color. The callback acknowledgement remains the compatibility fallback when a client does not render button styles.
|
|
200
|
+
A button-only assistant reply uses the standard Rich Markdown heading `☑️ **Choose an option:**`: semantic icon first, one space, bold heading text, and a final colon. Every generated human-readable action label starts with the most semantically appropriate emoji, one ASCII space, then concise action text. Emoji selection is part of authoring the control, including compact label-equals-prompt forms. Emoji-free text remains a syntax-compatible fallback only when no honest semantic marker exists after considering the action, domain, and state—not merely for convenience or label pressure; genuine coordinate and symbolic spatial controls retain their established grammar. Generated non-spatial controls default to vertical full-width buttons represented as top-level matrix cells. Nested row arrays are a compact-peer exception only when every label is unmistakably short—roughly 15 visible characters or fewer including emoji and space; this is a judgment heuristic rather than a mechanical count, and any plausible ellipsis or wrapping risk returns the controls to vertical rows. Assistant-generated prompt buttons use the default app style before selection. After queue admission, edit only the selected button to its agent-configured `selected_style`: `primary` (default/blue), `success` (green), or `danger` (red). Preserve its agent-authored text and emoji, leave other choices at their default style, and always queue the selected prompt regardless of color. The callback acknowledgement remains the compatibility fallback when a client does not render button styles.
|
|
203
201
|
|
|
204
202
|
## Navigation
|
|
205
203
|
|
|
@@ -225,8 +223,9 @@ Message cards and standalone informational notices sent by the bot should start
|
|
|
225
223
|
Rules:
|
|
226
224
|
|
|
227
225
|
- Start with a bold heading or, for dialogs, a bold question.
|
|
228
|
-
- Format standalone notices as one fully bold line: relevant emoji, one space, concise sentence, and terminal period or colon.
|
|
229
|
-
-
|
|
226
|
+
- Format standalone notices as one fully bold line: relevant emoji, one space, concise sentence, and terminal period. Menu or chooser headings use the same fully bold form but end in a colon when controls or detail follow.
|
|
227
|
+
- Keep the emoji and complete sentence or heading inside the single bold span; do not bold only a fragment. A material name or phrase may receive nested italic emphasis without breaking the outer bold hierarchy—for example `<b>📡 Instance <i>Cedar</i> connected.</b>`.
|
|
228
|
+
- Apply the same hierarchy to success, progress, empty, busy, unavailable, cancellation, and failure notices.
|
|
230
229
|
- Callback alerts remain plain text because Telegram does not support rich text there, but still keep the relevant emoji and concise sentence.
|
|
231
230
|
- Setting detail cards may include an emoji in the heading, then a colon and the current value in `<code>`.
|
|
232
231
|
- Explain what the setting does and what the options mean only as much as needed.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Voice Integration
|
|
2
2
|
|
|
3
|
-
Voice messages flow through an **inbound transcription → outbound voice reply** pipeline. This document describes the bridge's role in that pipeline; provider-specific mechanics (TTS/STT backends, voice IDs, languages) are owned by voice provider extensions. This is a first-class extension surface: one companion extension can provide STT fallbacks for inbound voice/audio files and TTS fallbacks for outbound Telegram voice replies without owning a second bot polling loop.
|
|
3
|
+
Voice messages flow through an **inbound transcription → outbound voice reply** pipeline. This document describes the bridge's role in that pipeline; provider-specific mechanics (TTS/STT backends, voice IDs, languages) are owned by configured handler scripts or voice provider extensions. This is a first-class extension surface: one companion extension can provide STT fallbacks for inbound voice/audio files and TTS fallbacks for outbound Telegram voice replies without owning a second bot polling loop.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
@@ -18,7 +18,17 @@ Use the smallest path that fits the operator's available capabilities:
|
|
|
18
18
|
2. **Companion extension:** Register programmatic STT/TTS providers when installation, provider-owned settings, lifecycle integration, or zero-config reuse justifies code.
|
|
19
19
|
3. **Hybrid:** Keep explicit operator command templates first and let installed providers supply progressive fallbacks.
|
|
20
20
|
|
|
21
|
-
pi-telegram does not
|
|
21
|
+
pi-telegram does not maintain a built-in or exhaustive speech-provider catalog. Configuration agents should discover applicable Skills or trusted local executables, verify required environment variables by presence without exposing values, preserve unrelated `telegram.json` state, ensure TTS ends as OGG/Opus, and test each stage before the live Telegram path. `hidden` remains the safe and useful default: it disables only automatic voice replies, while explicit `telegram_voice` actions continue to use the configured synthesis pipeline.
|
|
22
|
+
|
|
23
|
+
### Ready-made command-template examples
|
|
24
|
+
|
|
25
|
+
The public [`llblab/skills`](https://github.com/llblab/skills) repository provides three maintained Skills with standalone scripts that can be wired directly into `telegram.json`; no additional Pi extension or local inference engine is required:
|
|
26
|
+
|
|
27
|
+
- [`groq-stt`](https://github.com/llblab/skills/tree/main/groq-stt) — Groq Whisper speech-to-text through `scripts/transcribe.sh`. It outputs plain transcript text and requires `GROQ_API_KEY` in the Pi process environment.
|
|
28
|
+
- [`mistral-stt`](https://github.com/llblab/skills/tree/main/mistral-stt) — Mistral Voxtral speech-to-text through `scripts/transcribe.sh`. It outputs plain transcript text and requires `MISTRAL_API_KEY` in the Pi process environment.
|
|
29
|
+
- [`edge-tts`](https://github.com/llblab/skills/tree/main/edge-tts) — Microsoft Edge neural text-to-speech through `scripts/say.sh`. It requires internet access but no account or API key and can write MP3 output for the outbound pipeline.
|
|
30
|
+
|
|
31
|
+
The two hosted STT options avoid running a local transcription model and offer useful free-tier capacity after provider registration; provider limits and terms may change. Install or clone the Skill repository, verify the required API-key variable by presence without printing its value, and point the matching `inboundHandlers` template at the Skill's transcription script. For Edge TTS, point an `outboundHandlers` voice pipeline at `say.sh --file - --write-media {mp3}`, then convert `{mp3}` to `{ogg}` with ffmpeg as shown in [Outbound Voice Handlers](#outbound-voice-handlers), because Telegram native voice notes require OGG/Opus. Read each linked Skill's current `SKILL.md` for its exact CLI, defaults, dependencies, and optional language/model controls.
|
|
22
32
|
|
|
23
33
|
## Voice Detection
|
|
24
34
|
|
|
@@ -548,7 +548,6 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
548
548
|
} = Bindings.createTelegramActivityBindingRuntime({
|
|
549
549
|
generation: deliveryGenerationSeed,
|
|
550
550
|
assistantOutput: {
|
|
551
|
-
isEnabled: configControls.isProactivePushEnabled,
|
|
552
551
|
authority: {
|
|
553
552
|
getPreferredTarget: proactivePushTargetGetter,
|
|
554
553
|
getFallbackChatId: proactivePushChatIdGetter,
|
|
@@ -428,6 +428,9 @@ export function createTelegramActivityBridgeRuntime(deps: {
|
|
|
428
428
|
onAssistantEvent(event) {
|
|
429
429
|
getRuntime()?.onAssistantEvent(event);
|
|
430
430
|
},
|
|
431
|
+
onAssistantMessageEnd(stopReason) {
|
|
432
|
+
getRuntime()?.onAssistantMessageEnd(stopReason);
|
|
433
|
+
},
|
|
431
434
|
onToolStart(event) {
|
|
432
435
|
getRuntime()?.onToolStart(event);
|
|
433
436
|
},
|
|
@@ -491,6 +494,7 @@ export interface TelegramActivityRuntime {
|
|
|
491
494
|
recordInputSource: (source: TelegramActivityInputSource) => void;
|
|
492
495
|
onAgentStart: (activeTelegramTarget?: TelegramActivityTarget) => void;
|
|
493
496
|
onAssistantEvent: (event: TelegramAssistantStreamEvent) => void;
|
|
497
|
+
onAssistantMessageEnd: (stopReason?: string) => void;
|
|
494
498
|
onToolStart: (event: {
|
|
495
499
|
toolCallId: string;
|
|
496
500
|
toolName: string;
|
|
@@ -677,6 +681,9 @@ export function createTelegramActivityRuntime(deps: {
|
|
|
677
681
|
}
|
|
678
682
|
if (event.type === "error") flushPendingSegment("terminal-partial");
|
|
679
683
|
},
|
|
684
|
+
onAssistantMessageEnd(stopReason) {
|
|
685
|
+
if (stopReason === "aborted") pendingAssistantSegment = undefined;
|
|
686
|
+
},
|
|
680
687
|
onToolStart(event) {
|
|
681
688
|
emit({ type: "tool-start", ...event });
|
|
682
689
|
},
|
|
@@ -741,7 +748,6 @@ export interface TelegramAssistantOutputRuntime {
|
|
|
741
748
|
}
|
|
742
749
|
|
|
743
750
|
export function createTelegramAssistantOutputRuntime<TAuthority = undefined>(deps: {
|
|
744
|
-
isEnabled: () => boolean;
|
|
745
751
|
captureAuthority?: () => TAuthority;
|
|
746
752
|
isAuthorityActive?: (authority: TAuthority) => boolean;
|
|
747
753
|
canDeliver: (event: TelegramAssistantSegmentEvent) => boolean;
|
|
@@ -761,10 +767,9 @@ export function createTelegramAssistantOutputRuntime<TAuthority = undefined>(dep
|
|
|
761
767
|
const admitted = new Set<string>();
|
|
762
768
|
const isEligibleEvent = (event: TelegramAssistantSegmentEvent): boolean =>
|
|
763
769
|
(event.source === "telegram" && event.placement === "intermediate") ||
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
deps.isEnabled());
|
|
770
|
+
event.source === "local" ||
|
|
771
|
+
event.source === "autonomous" ||
|
|
772
|
+
event.source === "unknown";
|
|
768
773
|
|
|
769
774
|
return {
|
|
770
775
|
start() {
|
|
@@ -337,7 +337,6 @@ export interface TelegramAssistantOutputBindingRuntime<TTransportStamp> {
|
|
|
337
337
|
export function createTelegramAssistantOutputBindingRuntime<
|
|
338
338
|
TTransportStamp,
|
|
339
339
|
>(deps: {
|
|
340
|
-
isEnabled: () => boolean;
|
|
341
340
|
authority: {
|
|
342
341
|
getPreferredTarget: () =>
|
|
343
342
|
| OutboundAttachments.TelegramQueuedOutboundAttachmentTurnView["target"]
|
|
@@ -364,7 +363,6 @@ export function createTelegramAssistantOutputBindingRuntime<
|
|
|
364
363
|
deps.sender,
|
|
365
364
|
);
|
|
366
365
|
const runtime = Activity.createTelegramAssistantOutputRuntime({
|
|
367
|
-
isEnabled: deps.isEnabled,
|
|
368
366
|
...authority,
|
|
369
367
|
async send(event, authority, isAuthorityActive) {
|
|
370
368
|
await deps.waitForActivityIdle?.();
|
|
@@ -1030,7 +1028,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
1030
1028
|
});
|
|
1031
1029
|
};
|
|
1032
1030
|
let observedAutomaticCompaction = false;
|
|
1033
|
-
let
|
|
1031
|
+
let agentWorkActive = false;
|
|
1034
1032
|
let terminalAssistantMessagePendingDelivery = false;
|
|
1035
1033
|
const deferredAutomaticCompactionNotices: string[] = [];
|
|
1036
1034
|
const sendCompactionNotice = async (text: string): Promise<void> => {
|
|
@@ -1098,7 +1096,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
1098
1096
|
activityVerbosityRuntime?.reset();
|
|
1099
1097
|
assistantOutputRuntime.stop();
|
|
1100
1098
|
observedAutomaticCompaction = false;
|
|
1101
|
-
|
|
1099
|
+
agentWorkActive = false;
|
|
1102
1100
|
terminalAssistantMessagePendingDelivery = false;
|
|
1103
1101
|
uiPromptActive = false;
|
|
1104
1102
|
deferredAutomaticCompactionNotices.length = 0;
|
|
@@ -1123,10 +1121,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
1123
1121
|
activityRuntime.onCompactionStart(Pi.getSessionCompactionReason(event));
|
|
1124
1122
|
compactionObserver.onSessionBeforeCompact(event, ctx);
|
|
1125
1123
|
if (shouldNotify) {
|
|
1126
|
-
if (
|
|
1127
|
-
activeTurnRuntime.has() &&
|
|
1128
|
-
terminalAssistantMessagePendingDelivery
|
|
1129
|
-
) {
|
|
1124
|
+
if (terminalAssistantMessagePendingDelivery) {
|
|
1130
1125
|
deferredAutomaticCompactionNotices.push(
|
|
1131
1126
|
Commands.TELEGRAM_COMPACTION_STARTED_MARKDOWN,
|
|
1132
1127
|
);
|
|
@@ -1161,8 +1156,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
1161
1156
|
const shouldNotify = observedAutomaticCompaction;
|
|
1162
1157
|
const deferredNotices = deferredAutomaticCompactionNotices.splice(0);
|
|
1163
1158
|
const shouldDefer =
|
|
1164
|
-
deferredNotices.length > 0 ||
|
|
1165
|
-
(activeTurnRuntime.has() && terminalAssistantMessagePendingDelivery);
|
|
1159
|
+
deferredNotices.length > 0 || terminalAssistantMessagePendingDelivery;
|
|
1166
1160
|
compactionObserver.onSessionCompactFailed(event, ctx);
|
|
1167
1161
|
if (!shouldNotify) return;
|
|
1168
1162
|
const notice = event.aborted
|
|
@@ -1176,7 +1170,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
1176
1170
|
},
|
|
1177
1171
|
async onAgentStart(event, ctx) {
|
|
1178
1172
|
if (!isSessionContextActive(ctx)) return;
|
|
1179
|
-
|
|
1173
|
+
agentWorkActive = true;
|
|
1180
1174
|
terminalAssistantMessagePendingDelivery = false;
|
|
1181
1175
|
await agentStartWithDedupReset(event, ctx);
|
|
1182
1176
|
activityRuntime.onAgentStart(activeTurnRuntime.get()?.target);
|
|
@@ -1224,6 +1218,9 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
1224
1218
|
},
|
|
1225
1219
|
onMessageEnd(event, ctx) {
|
|
1226
1220
|
if (!isSessionContextActive(ctx)) return;
|
|
1221
|
+
if (event.message.role === "assistant") {
|
|
1222
|
+
activityRuntime.onAssistantMessageEnd(event.message.stopReason);
|
|
1223
|
+
}
|
|
1227
1224
|
terminalAssistantMessagePendingDelivery =
|
|
1228
1225
|
event.message.role === "assistant" &&
|
|
1229
1226
|
event.message.stopReason !== "toolUse" &&
|
|
@@ -1241,24 +1238,26 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
1241
1238
|
if (!isSessionContextActive(ctx)) return;
|
|
1242
1239
|
uiPromptActive = false;
|
|
1243
1240
|
activityRuntime.onUiPromptEnd();
|
|
1244
|
-
if (
|
|
1241
|
+
if (agentWorkActive || lifecycle.isCompactionInProgress()) {
|
|
1242
|
+
startAgentActivityTypingLoop(ctx);
|
|
1243
|
+
}
|
|
1245
1244
|
updateStatus(ctx);
|
|
1246
1245
|
},
|
|
1247
1246
|
async onAgentEnd(event, ctx) {
|
|
1248
1247
|
if (!isSessionContextActive(ctx)) return;
|
|
1249
|
-
agentRunActive = false;
|
|
1250
1248
|
activityRuntime.onAgentEnd();
|
|
1251
1249
|
await agentLifecycleHooks.onAgentEnd(event, ctx);
|
|
1250
|
+
},
|
|
1251
|
+
async onAgentSettled(event, ctx) {
|
|
1252
|
+
if (!isSessionContextActive(ctx)) return;
|
|
1253
|
+
await agentLifecycleHooks.onAgentSettled(event, ctx);
|
|
1252
1254
|
if (deferredAutomaticCompactionNotices.length > 0) {
|
|
1253
1255
|
await waitForActiveTurnDelivery();
|
|
1254
1256
|
if (!isSessionContextActive(ctx)) return;
|
|
1255
1257
|
await flushDeferredAutomaticCompactionNotices();
|
|
1256
1258
|
}
|
|
1257
1259
|
terminalAssistantMessagePendingDelivery = false;
|
|
1258
|
-
|
|
1259
|
-
async onAgentSettled(event, ctx) {
|
|
1260
|
-
if (!isSessionContextActive(ctx)) return;
|
|
1261
|
-
await agentLifecycleHooks.onAgentSettled(event, ctx);
|
|
1260
|
+
agentWorkActive = false;
|
|
1262
1261
|
activityRuntime.onAgentSettled();
|
|
1263
1262
|
modelContextAvailabilityRuntime.reconcile();
|
|
1264
1263
|
},
|
|
@@ -335,7 +335,7 @@ export function createTelegramBusInstanceLifecycleAnnouncement(input: {
|
|
|
335
335
|
}): TelegramBusFollowerLifecycleAnnouncement {
|
|
336
336
|
return {
|
|
337
337
|
target: { ...input.target },
|
|
338
|
-
text:
|
|
338
|
+
text: `<b>📡 Instance <i>${formatTelegramBusInstanceLabel(input)}</i> ${input.state}.</b>`,
|
|
339
339
|
parseMode: "HTML",
|
|
340
340
|
};
|
|
341
341
|
}
|
|
@@ -1049,6 +1049,9 @@ export interface TelegramCommandRuntimeDeps<
|
|
|
1049
1049
|
text: string,
|
|
1050
1050
|
options?: { parseMode?: "HTML" },
|
|
1051
1051
|
) => Promise<void>;
|
|
1052
|
+
getActiveTurnReply?: () =>
|
|
1053
|
+
| ((text: string, options?: { parseMode?: "HTML" }) => Promise<void>)
|
|
1054
|
+
| undefined;
|
|
1052
1055
|
sendInteractiveMessage?: TelegramCompactConfirmationDeps["sendInteractiveMessage"];
|
|
1053
1056
|
assertExecutionCurrent?: (message: TMessage) => void;
|
|
1054
1057
|
}
|
|
@@ -1272,6 +1275,9 @@ export async function handleTelegramNextCommand(deps: {
|
|
|
1272
1275
|
text: string,
|
|
1273
1276
|
options?: { parseMode?: "HTML" },
|
|
1274
1277
|
) => Promise<void>;
|
|
1278
|
+
getActiveTurnReply?: () =>
|
|
1279
|
+
| ((text: string, options?: { parseMode?: "HTML" }) => Promise<void>)
|
|
1280
|
+
| undefined;
|
|
1275
1281
|
}): Promise<void> {
|
|
1276
1282
|
deps.clearPendingModelSwitch();
|
|
1277
1283
|
if (!deps.hasQueuedItems()) {
|
|
@@ -1282,16 +1288,19 @@ export async function handleTelegramNextCommand(deps: {
|
|
|
1282
1288
|
return;
|
|
1283
1289
|
}
|
|
1284
1290
|
if (!deps.isIdle() && deps.hasAbortHandler()) {
|
|
1291
|
+
const activeTurnReply = deps.getActiveTurnReply?.();
|
|
1285
1292
|
deps.clearFoldForDispatch();
|
|
1286
1293
|
deps.abortCurrentTurn();
|
|
1287
1294
|
deps.updateStatus();
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
"Aborted! Dispatching next queued turn.",
|
|
1292
|
-
),
|
|
1293
|
-
{ parseMode: "HTML" },
|
|
1295
|
+
const notice = formatTelegramInformationHeading(
|
|
1296
|
+
"⏩",
|
|
1297
|
+
"Operation aborted. Dispatching next queued turn.",
|
|
1294
1298
|
);
|
|
1299
|
+
if (activeTurnReply) {
|
|
1300
|
+
await activeTurnReply(notice, { parseMode: "HTML" });
|
|
1301
|
+
} else {
|
|
1302
|
+
await deps.sendTextReply(notice, { parseMode: "HTML" });
|
|
1303
|
+
}
|
|
1295
1304
|
return;
|
|
1296
1305
|
}
|
|
1297
1306
|
if (!deps.isIdle()) {
|
|
@@ -1799,6 +1808,7 @@ async function handleTelegramCommandRuntime<
|
|
|
1799
1808
|
deps.setFoldQueuedPromptsIntoHistory(false),
|
|
1800
1809
|
updateStatus: updateStatusFor(commandCtx),
|
|
1801
1810
|
sendTextReply: sendReplyFor(nextMessage),
|
|
1811
|
+
getActiveTurnReply: deps.getActiveTurnReply,
|
|
1802
1812
|
});
|
|
1803
1813
|
},
|
|
1804
1814
|
handleContinue: async (nextMessage, commandCtx) => {
|
|
@@ -105,7 +105,6 @@ export interface TelegramConfig {
|
|
|
105
105
|
assistant?: {
|
|
106
106
|
draftPreviews?: boolean;
|
|
107
107
|
rendering?: TelegramAssistantRenderingMode;
|
|
108
|
-
proactivePush?: boolean;
|
|
109
108
|
activity?: TelegramActivityVerbosity;
|
|
110
109
|
timeInjection?: TelegramTimeMode;
|
|
111
110
|
/** @deprecated use activity */
|
|
@@ -454,10 +453,32 @@ function omitTelegramRootProfileFields(config: TelegramConfig): TelegramConfig {
|
|
|
454
453
|
return sharedConfig;
|
|
455
454
|
}
|
|
456
455
|
|
|
456
|
+
function omitRetiredProactivePush(config: TelegramConfig): {
|
|
457
|
+
config: TelegramConfig;
|
|
458
|
+
changed: boolean;
|
|
459
|
+
} {
|
|
460
|
+
const assistant = config.assistant as
|
|
461
|
+
| (NonNullable<TelegramConfig["assistant"]> & { proactivePush?: unknown })
|
|
462
|
+
| undefined;
|
|
463
|
+
if (!assistant || !Object.hasOwn(assistant, "proactivePush")) {
|
|
464
|
+
return { config, changed: false };
|
|
465
|
+
}
|
|
466
|
+
const { proactivePush: _proactivePush, ...remainingAssistant } = assistant;
|
|
467
|
+
const next = { ...config };
|
|
468
|
+
if (Object.keys(remainingAssistant).length > 0) {
|
|
469
|
+
next.assistant = remainingAssistant;
|
|
470
|
+
} else {
|
|
471
|
+
delete next.assistant;
|
|
472
|
+
}
|
|
473
|
+
return { config: next, changed: true };
|
|
474
|
+
}
|
|
475
|
+
|
|
457
476
|
export function normalizeTelegramDefaultProfileConfig(config: TelegramConfig): {
|
|
458
477
|
config: TelegramConfig;
|
|
459
478
|
changed: boolean;
|
|
460
479
|
} {
|
|
480
|
+
const retiredProactivePush = omitRetiredProactivePush(config);
|
|
481
|
+
config = retiredProactivePush.config;
|
|
461
482
|
const hasLegacyRootProfile = [
|
|
462
483
|
"botToken",
|
|
463
484
|
"botUsername",
|
|
@@ -465,7 +486,9 @@ export function normalizeTelegramDefaultProfileConfig(config: TelegramConfig): {
|
|
|
465
486
|
"allowedUserId",
|
|
466
487
|
"lastUpdateId",
|
|
467
488
|
].some((field) => Object.hasOwn(config, field));
|
|
468
|
-
if (!hasLegacyRootProfile)
|
|
489
|
+
if (!hasLegacyRootProfile) {
|
|
490
|
+
return { config, changed: retiredProactivePush.changed };
|
|
491
|
+
}
|
|
469
492
|
const canonicalProfile = config.profiles?.[TELEGRAM_DEFAULT_PROFILE_NAME];
|
|
470
493
|
const legacyToken = config.botToken?.trim();
|
|
471
494
|
if (Object.hasOwn(config, "botToken") && !legacyToken) {
|
|
@@ -712,27 +735,6 @@ export function createTelegramConfigStore(
|
|
|
712
735
|
};
|
|
713
736
|
}
|
|
714
737
|
|
|
715
|
-
export function createTelegramProactivePushChecker(
|
|
716
|
-
configStore: Pick<TelegramConfigStore, "get">,
|
|
717
|
-
): () => boolean {
|
|
718
|
-
return () => configStore.get().assistant?.proactivePush ?? true;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
export function createTelegramProactivePushSetter(
|
|
722
|
-
configStore: TelegramMutableConfigStore,
|
|
723
|
-
): (enabled: boolean) => Promise<void> {
|
|
724
|
-
return async (enabled) => {
|
|
725
|
-
await loadLatestTelegramConfig(configStore);
|
|
726
|
-
const current = configStore.get();
|
|
727
|
-
const config: TelegramConfig = {
|
|
728
|
-
...current,
|
|
729
|
-
assistant: { ...current.assistant, proactivePush: enabled },
|
|
730
|
-
};
|
|
731
|
-
configStore.set(config);
|
|
732
|
-
await configStore.persist(config);
|
|
733
|
-
};
|
|
734
|
-
}
|
|
735
|
-
|
|
736
738
|
export function createTelegramDraftPreviewsChecker(
|
|
737
739
|
configStore: Pick<TelegramConfigStore, "get">,
|
|
738
740
|
): () => boolean {
|
|
@@ -1019,8 +1021,6 @@ export function createTelegramConfigControls(
|
|
|
1019
1021
|
configStore: TelegramMutableConfigStore,
|
|
1020
1022
|
) {
|
|
1021
1023
|
return {
|
|
1022
|
-
isProactivePushEnabled: createTelegramProactivePushChecker(configStore),
|
|
1023
|
-
setProactivePushEnabled: createTelegramProactivePushSetter(configStore),
|
|
1024
1024
|
areDraftPreviewsEnabled: createTelegramDraftPreviewsChecker(configStore),
|
|
1025
1025
|
setDraftPreviewsEnabled: createTelegramDraftPreviewsSetter(configStore),
|
|
1026
1026
|
getAssistantRenderingMode:
|
|
@@ -366,6 +366,7 @@ export function extractTelegramReplyContextText(
|
|
|
366
366
|
export function buildTelegramReplyContextBlock(
|
|
367
367
|
message: TelegramMediaMessage,
|
|
368
368
|
replyFiles: Pick<DownloadedTelegramFile, "path">[] = [],
|
|
369
|
+
replyOutputs: readonly string[] = [],
|
|
369
370
|
): string {
|
|
370
371
|
const from = formatTelegramUser(message.reply_to_message?.from);
|
|
371
372
|
const header = from ? `[reply|from:${from}]` : "[reply]";
|
|
@@ -379,10 +380,19 @@ export function buildTelegramReplyContextBlock(
|
|
|
379
380
|
? replyFiles.map((file) => `- /${basename(file.path)}`)
|
|
380
381
|
: replyFiles.map((file) => `- ${file.path}`);
|
|
381
382
|
const replyBlock = text ? `${header} ${text}` : header;
|
|
383
|
+
const sections = [replyBlock];
|
|
382
384
|
if (fileLines.length > 0) {
|
|
383
|
-
|
|
385
|
+
sections.push(`${attachmentHeader}\n${fileLines.join("\n")}`);
|
|
386
|
+
}
|
|
387
|
+
if (replyOutputs.length > 0) {
|
|
388
|
+
const outputHeader = `[outputs${from ? `|from:${from}` : ""}]`;
|
|
389
|
+
sections.push(
|
|
390
|
+
`${outputHeader}\n${replyOutputs.map((output) => `- ${output}`).join("\n")}`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
if (text || fileLines.length > 0 || replyOutputs.length > 0) {
|
|
394
|
+
return sections.join("\n\n");
|
|
384
395
|
}
|
|
385
|
-
if (text) return replyBlock;
|
|
386
396
|
return "";
|
|
387
397
|
}
|
|
388
398
|
|