@llblab/pi-telegram 0.41.0 → 0.41.1

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
@@ -2,6 +2,13 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.41.1: Focused Operator Status Hotfix
6
+
7
+ - `Next Turn Feedback`: Replies one Pi-aligned fully bold abort notice to the aborted Telegram turn, falls back to the `/next` command for local work, preserves completed intermediate output, and suppresses pending aborted assistant text so one forced transition no longer produces a contradictory second reply.
8
+ - `Status Telemetry`: Keeps the Tokens row compact with input/output totals and moves cache read/write plus the latest cache-hit rate into a dedicated Cache row.
9
+ - `Reply Transcription`: Runs replied voice and other media through the inbound handler/provider pipeline and appends provenance-scoped outputs inside the reply context, including Guest Mode, instead of leaving quoted audio as an untranscribed attachment.
10
+ - `Information Hierarchy`: Brings access-denial and Threaded Mode chooser headings back under the common fully bold emoji-led sentence/colon contract.
11
+
5
12
  ## 0.41.0: Connected Companion Presence
6
13
 
7
14
  - `Always-On Projection`: Treats authorized Telegram connection as the public-output boundary for local, autonomous, and unclassified Pi work, always delivering completed visible blocks in order instead of exposing an independent proactive-push opt-out.
@@ -301,7 +301,7 @@ Immediate controls:
301
301
  - `/start` opens the main inline application menu.
302
302
  - `/model`, `/thinking`, `/queue`, and `/settings` are hidden shortcuts to menu sections.
303
303
  - `/compact` opens an inline confirmation dialog and then runs compaction when the bridge is idle.
304
- - `/next` dispatches the next queued turn, aborting Pi first when needed.
304
+ - `/next` dispatches the next queued turn, aborting Pi first when needed. When an active Telegram turn is aborted, its single Pi-aligned informational notice replies to a pre-abort snapshot of that turn; otherwise the command message is the fallback target. Aborted pending assistant text is not projected as a second reply, while already completed intermediate output remains visible.
305
305
  - `/abort` aborts active work while preserving queued items. Abort-history preservation is enabled only for Telegram-owned active turns; later local/non-Telegram agent starts clear stale abort-history mode so the next Telegram prompt appends instead of absorbing old queued turns as history.
306
306
  - `/stop` aborts and clears waiting Telegram queue items.
307
307
 
@@ -336,7 +336,7 @@ Assistant delivery guarantees:
336
336
  - Model-authored Markdown is the source of truth; the bridge does not pre-render assistant Markdown to HTML unless the operator selects `assistant.rendering: "html"` for compatibility.
337
337
  - Before native Rich Markdown delivery, the bridge normalizes known Bot-API-fragile source forms without changing visible meaning, including space-after-marker blockquotes and dollar-prefixed ticker atoms that Telegram may otherwise treat as unterminated math.
338
338
  - Prompt context blocks use compact metadata (`[tag|key:value]`) as the stable inbound contract. `[telegram...]` names the current surface only: owner/current turns use `[telegram]` or `[telegram|thread:<name>]`; guest-mode turns use `[telegram|guest:<group-title-or-peer-username-or-id>]`. In a private Guest Mode turn the paired owner's `from` identity is never the guest: the remote private-chat identity wins, then non-owner caller metadata, with a non-bot replied peer available only as a final identity fallback when stronger conversation evidence is absent; username falls back to the remote display name and numeric id. Reply attribution still belongs independently in `[reply|from:...]`, and a replied bot can never define or replace the current `[telegram|guest:...]` location identity. Source authors for quoted/forwarded material and their files are carried by `[reply|from:<username-or-id>]`, `[forward|from:<username-or-id>]`, and `[attachments|from:<username-or-id>]`, while plain `[attachments]` remains current-turn attachments and is ordered before reply/forward/source context. Media embedded in inbound Telegram `rich_message` blocks is downloaded like ordinary message media and stays attached to its forward-source block instead of being mislabeled as current-user material.
339
- - Quoted rich replies use Telegram `rich_message` blocks as the prompt-context source when available, so `[reply]` context receives rendered plain text instead of raw `InputRichMessage.markdown` fallback text.
339
+ - Quoted rich replies use Telegram `rich_message` blocks as the prompt-context source when available, so `[reply]` context receives rendered plain text instead of raw `InputRichMessage.markdown` fallback text. Replied media runs through the same inbound handlers and voice transcription providers as current-message media, with provenance-scoped `[outputs|from:…]` appended inside the reply block.
340
340
  - Long native Markdown replies are split only at Telegram Rich Message transport limits; oversized fenced code, display-math, and fully wrapped inline-formatting blocks are rewrapped per chunk so persisted Rich Markdown chunks remain structurally valid.
341
341
  - When Draft previews are enabled, streaming previews pass structurally closed assistant Markdown prefixes through to `sendRichMessageDraft` with ownership checks, voice suppression, and serialized flushes. Unclosed inline spans, links, fenced code, comments, and display-math blocks are held back until a safe boundary exists. Draft failures are recorded and the failing frame is skipped instead of degrading to raw plain-message previews, because partial Markdown can be invalid while the final message remains valid.
342
342
  - Preview flushes are serialized so older edits cannot race newer drafts; final delivery waits for active draft flushes and does not perform a post-final draft-clear call.
@@ -390,7 +390,7 @@ The bridge does not mirror arbitrary `ctx.ui.confirm/input/select/custom` prompt
390
390
 
391
391
  ## Diagnostics And Operational Behavior
392
392
 
393
- Status rendering distinguishes connected, active, dispatching, queued, tool-running, model-switching, and compacting states; the Telegram status menu gives compaction precedence over generic active or pending work. Its Tokens row mirrors Pi's terminal footer, including inline `R` cache-read tokens, `W` cache-write tokens, and `CH` for the latest assistant request's cache-read share of prompt tokens rather than a misleading cumulative-session ratio; the label remains distinct from companion-provided usage limits. Observed automatic compaction sends the same start and completion notices as the manual command without duplicating notices for command-owned compaction. If a queue mutation removes the last waiting item while Telegram-owned work still has running tools, status remains active instead of degrading to connected.
393
+ Status rendering distinguishes connected, active, dispatching, queued, tool-running, model-switching, and compacting states; the Telegram status menu gives compaction precedence over generic active or pending work. Its compact Tokens row shows only input and output totals, while the adjacent Cache row groups `R` cache-read tokens, `W` cache-write tokens, and `CH` for the latest assistant request's cache-read share of prompt tokens rather than a misleading cumulative-session ratio; the labels remain distinct from companion-provided usage limits. Observed automatic compaction sends the same start and completion notices as the manual command without duplicating notices for command-owned compaction. If a queue mutation removes the last waiting item while Telegram-owned work still has running tools, status remains active instead of degrading to connected.
394
394
 
395
395
  Queue reaction behavior, lane-tail transitions, Keep/Skip independence, multi-reaction precedence, and the Bot API reaction-removal limitation are defined in [Priority, Reactions, Keep, and Skip](#priority-reactions-keep-and-skip). Reaction changes first flush a matching delayed text or media group so the governed turn exists before mutation, and dropping marked heads cannot leave status permanently queued.
396
396
 
package/docs/inbound.md CHANGED
@@ -67,7 +67,7 @@ Built-in placeholders for text handlers:
67
67
 
68
68
  ## Media/File Handlers
69
69
 
70
- Media/file handlers keep the legacy attachment-handler behavior: downloaded files are matched by `mime`, `type`, or `match`, then each file runs the first successful matching handler. Downloaded files with `mime: "text/plain"` or any `text/*` MIME type have a built-in fail-open handler that reads UTF-8 content into `[outputs]` when no configured handler produced output. Composition is useful for pipelines such as voice transcription followed by machine translation, so the agent receives translated `[outputs]` instead of the raw STT language.
70
+ Media/file handlers keep the legacy attachment-handler behavior: downloaded files are matched by `mime`, `type`, or `match`, then each file runs the first successful matching handler. Attachments from a replied-to message use the same handler/provider pipeline independently; their `[outputs|from:…]` section remains inside that `[reply]` context after its attachment list instead of being attributed to the current message. Downloaded files with `mime: "text/plain"` or any `text/*` MIME type have a built-in fail-open handler that reads UTF-8 content into `[outputs]` when no configured handler produced output. Composition is useful for pipelines such as voice transcription followed by machine translation, so the agent receives translated `[outputs]` instead of the raw STT language.
71
71
 
72
72
  Built-in placeholders for media/file handlers:
73
73
 
@@ -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.
@@ -336,7 +336,7 @@ Contract:
336
336
  - Providers are synchronous because `/start` status text is rendered inline with the menu.
337
337
  - Return `undefined` when the line is not relevant for the active model.
338
338
  - Provider failures are isolated and skipped so optional companion status cannot break the core Telegram menu.
339
- - The bridge renders rows as `<Label>: <value>` in the same HTML status block as Status, Tokens, Cost, and Context, capitalizing the first label character for Telegram UI consistency. Tokens mirrors Pi's footer telemetry: `↑` input, `↓` output, `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. The precise Tokens label avoids conflating token telemetry with companion-provided usage-limit rows.
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.
340
340
 
341
341
  ## Updates
342
342
 
package/lib/activity.ts CHANGED
@@ -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
  },
package/lib/bindings.ts CHANGED
@@ -1218,6 +1218,9 @@ export function registerTelegramLifecycleRuntimeHooks({
1218
1218
  },
1219
1219
  onMessageEnd(event, ctx) {
1220
1220
  if (!isSessionContextActive(ctx)) return;
1221
+ if (event.message.role === "assistant") {
1222
+ activityRuntime.onAssistantMessageEnd(event.message.stopReason);
1223
+ }
1221
1224
  terminalAssistantMessagePendingDelivery =
1222
1225
  event.message.role === "assistant" &&
1223
1226
  event.message.stopReason !== "toolUse" &&
package/lib/commands.ts CHANGED
@@ -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
- await deps.sendTextReply(
1289
- formatTelegramInformationHeading(
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) => {
package/lib/media.ts CHANGED
@@ -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
- return `${replyBlock}\n\n${attachmentHeader}\n${fileLines.join("\n")}`;
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
 
package/lib/routing.ts CHANGED
@@ -107,8 +107,9 @@ function appendTelegramSourceAttachmentSection(
107
107
  text: string,
108
108
  from: string | undefined,
109
109
  files: Pick<Media.DownloadedTelegramFile, "path">[],
110
+ outputs: readonly string[] = [],
110
111
  ): string {
111
- if (files.length === 0) return text;
112
+ if (files.length === 0 && outputs.length === 0) return text;
112
113
  const dirs = [...new Set(files.map((file) => dirname(file.path)))];
113
114
  const sameDir = dirs.length === 1;
114
115
  const source = from ? `|from:${from}` : "";
@@ -118,8 +119,15 @@ function appendTelegramSourceAttachmentSection(
118
119
  const items = sameDir
119
120
  ? files.map((file) => `/${basename(file.path)}`)
120
121
  : files.map((file) => file.path);
121
- const prefix = text.length > 0 ? `${text}\n\n` : "";
122
- return `${prefix}${header}\n${items.map((item) => `- ${item}`).join("\n")}`;
122
+ const sections = text ? [text] : [];
123
+ if (items.length > 0) {
124
+ sections.push(`${header}\n${items.map((item) => `- ${item}`).join("\n")}`);
125
+ }
126
+ if (outputs.length > 0) {
127
+ const outputHeader = `[outputs${source}]`;
128
+ sections.push(`${outputHeader}\n${outputs.map((output) => `- ${output}`).join("\n")}`);
129
+ }
130
+ return sections.join("\n\n");
123
131
  }
124
132
 
125
133
  function getContextCwd(ctx: unknown): string | undefined {
@@ -269,7 +277,7 @@ function getTelegramRoutableThreadRecords(
269
277
 
270
278
  function formatTelegramAllTabMenuChooserText(command: string): string {
271
279
  return [
272
- "<b>🧵 Choose target thread</b>",
280
+ "<b>🧵 Choose target thread:</b>",
273
281
  "",
274
282
  `You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.`,
275
283
  "Select the Pi thread that should handle it:",
@@ -332,7 +340,7 @@ function buildTelegramUnboundRerouteRestoreChooserMarkup(
332
340
 
333
341
  function formatTelegramUnboundRerouteRestoreChooserText(): string {
334
342
  return [
335
- "<b>🧵 Replace/restore Telegram thread</b>",
343
+ "<b>🧵 Replace/restore Telegram thread:</b>",
336
344
  "",
337
345
  "Choose the Pi instance to move to this new Telegram thread:",
338
346
  ].join("\n");
@@ -340,7 +348,7 @@ function formatTelegramUnboundRerouteRestoreChooserText(): string {
340
348
 
341
349
  function formatTelegramUnboundTopicGuidance(): string {
342
350
  return [
343
- "⚠️ <b>New thread is not a Pi instance</b>",
351
+ "<b>⚠️ New thread is not a Pi instance.</b>",
344
352
  "",
345
353
  "To create a bound Telegram tab:",
346
354
  "<code>1.</code> Start another Pi instance in your terminal.",
@@ -358,7 +366,7 @@ function formatTelegramUnboundRerouteChooserText(
358
366
  options: { includeGuidance?: boolean } = {},
359
367
  ): string {
360
368
  const rerouteText = [
361
- "🧵 <b>Choose target thread</b>",
369
+ "<b>🧵 Choose target thread:</b>",
362
370
  "",
363
371
  "Your message is still in this Telegram thread.",
364
372
  "Select the Pi thread that should handle it:",
@@ -1877,6 +1885,18 @@ export function createTelegramInboundRouteRuntime<
1877
1885
  getPromptTemplateCommands,
1878
1886
  persistConfig: deps.configStore.persist,
1879
1887
  sendTextReply: deps.sendTextReply,
1888
+ getActiveTurnReply: () => {
1889
+ const activeTurn = deps.activeTurnRuntime.get();
1890
+ if (!activeTurn) return undefined;
1891
+ return async (text, options) => {
1892
+ await deps.sendTextReply(
1893
+ activeTurn.chatId,
1894
+ activeTurn.replyToMessageId,
1895
+ text,
1896
+ { target: activeTurn.target, parseMode: options?.parseMode },
1897
+ );
1898
+ };
1899
+ },
1880
1900
  sendInteractiveMessage: deps.sendInteractiveMessage,
1881
1901
  recordRuntimeEvent: deps.recordRuntimeEvent,
1882
1902
  });
@@ -2265,6 +2285,11 @@ export function createTelegramInboundRouteRuntime<
2265
2285
  )
2266
2286
  : [];
2267
2287
  assertExecutionCurrent();
2288
+ const processedReply =
2289
+ replyFiles.length > 0
2290
+ ? await deps.inboundHandlerRuntime.process(replyFiles, "", ctx)
2291
+ : undefined;
2292
+ assertExecutionCurrent();
2268
2293
  const files = await Media.downloadTelegramMessageFiles([guestMsg], {
2269
2294
  downloadFile: deps.downloadFile,
2270
2295
  });
@@ -2285,7 +2310,8 @@ export function createTelegramInboundRouteRuntime<
2285
2310
  sourceContext = appendTelegramSourceAttachmentSection(
2286
2311
  replyBlock,
2287
2312
  replyPeer,
2288
- replyFiles,
2313
+ processedReply?.promptFiles ?? replyFiles,
2314
+ processedReply?.handlerOutputs,
2289
2315
  );
2290
2316
  }
2291
2317
  const promptText = Turns.buildTelegramTurnPrompt({
package/lib/status.ts CHANGED
@@ -1494,17 +1494,22 @@ function buildUsageSummary(stats: TelegramUsageStats): string | undefined {
1494
1494
  const tokenParts: string[] = [];
1495
1495
  if (stats.totalInput) tokenParts.push(`↑${formatTokens(stats.totalInput)}`);
1496
1496
  if (stats.totalOutput) tokenParts.push(`↓${formatTokens(stats.totalOutput)}`);
1497
+ return tokenParts.length > 0 ? tokenParts.join(" ") : undefined;
1498
+ }
1499
+
1500
+ function buildCacheSummary(stats: TelegramUsageStats): string | undefined {
1501
+ const cacheParts: string[] = [];
1497
1502
  if (stats.totalCacheRead)
1498
- tokenParts.push(`R${formatTokens(stats.totalCacheRead)}`);
1503
+ cacheParts.push(`R${formatTokens(stats.totalCacheRead)}`);
1499
1504
  if (stats.totalCacheWrite)
1500
- tokenParts.push(`W${formatTokens(stats.totalCacheWrite)}`);
1505
+ cacheParts.push(`W${formatTokens(stats.totalCacheWrite)}`);
1501
1506
  if (
1502
1507
  (stats.totalCacheRead > 0 || stats.totalCacheWrite > 0) &&
1503
1508
  stats.latestCacheHitRate !== undefined
1504
1509
  ) {
1505
- tokenParts.push(`CH${stats.latestCacheHitRate.toFixed(1)}%`);
1510
+ cacheParts.push(`CH${stats.latestCacheHitRate.toFixed(1)}%`);
1506
1511
  }
1507
- return tokenParts.length > 0 ? tokenParts.join(" ") : undefined;
1512
+ return cacheParts.length > 0 ? cacheParts.join(" ") : undefined;
1508
1513
  }
1509
1514
 
1510
1515
  function buildCostSummary(
@@ -1557,10 +1562,14 @@ export function buildStatusHtml(
1557
1562
  ),
1558
1563
  ];
1559
1564
  const usageSummary = buildUsageSummary(stats);
1565
+ const cacheSummary = buildCacheSummary(stats);
1560
1566
  const costSummary = buildCostSummary(stats, usesSubscription);
1561
1567
  if (usageSummary) {
1562
1568
  lines.push(buildStatusRow("Tokens", usageSummary));
1563
1569
  }
1570
+ if (cacheSummary) {
1571
+ lines.push(buildStatusRow("Cache", cacheSummary));
1572
+ }
1564
1573
  if (costSummary) {
1565
1574
  lines.push(buildStatusRow("Cost", costSummary));
1566
1575
  }
package/lib/turns.ts CHANGED
@@ -468,8 +468,17 @@ export function createTelegramPromptTurnRuntimeBuilder<
468
468
  )
469
469
  : [];
470
470
  if (firstMessage) deps.assertExecutionCurrent?.(firstMessage);
471
+ const processedReply =
472
+ deps.processAttachments && replyFiles.length > 0
473
+ ? await deps.processAttachments(replyFiles, "", ctx as TContext)
474
+ : undefined;
475
+ if (firstMessage) deps.assertExecutionCurrent?.(firstMessage);
471
476
  const replyContext = firstMessage
472
- ? buildTelegramReplyContextBlock(firstMessage, replyFiles)
477
+ ? buildTelegramReplyContextBlock(
478
+ firstMessage,
479
+ processedReply?.promptFiles ?? replyFiles,
480
+ processedReply?.handlerOutputs,
481
+ )
473
482
  : "";
474
483
  const forwardEntries = messages.flatMap((message) => {
475
484
  const context = extractTelegramForwardContextText(
package/lib/updates.ts CHANGED
@@ -895,7 +895,7 @@ const TELEGRAM_UNAUTHORIZED_DENIAL_COPY = "Access denied.";
895
895
 
896
896
  function formatTelegramUnauthorizedDenial(format: "plain" | "html"): string {
897
897
  return format === "html"
898
- ? `🚫 <b>${TELEGRAM_UNAUTHORIZED_DENIAL_COPY}</b>`
898
+ ? `<b>🚫 ${TELEGRAM_UNAUTHORIZED_DENIAL_COPY}</b>`
899
899
  : `🚫 ${TELEGRAM_UNAUTHORIZED_DENIAL_COPY}`;
900
900
  }
901
901
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.41.0",
3
+ "version": "0.41.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"