polygram 0.6.15 β†’ 0.7.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/polygram.js CHANGED
@@ -32,6 +32,9 @@ const { parseBotArg, parseDbArg, filterConfigToBot } = require('./lib/config-sco
32
32
  const { createStore: createPairingsStore, parseTtl: parsePairingTtl } = require('./lib/pairings');
33
33
  const { transcribe: transcribeVoice, isVoiceAttachment } = require('./lib/voice');
34
34
  const { createStreamer } = require('./lib/stream-reply');
35
+ const { chunkMarkdownText } = require('./lib/telegram-chunk');
36
+ const { deliverReplies } = require('./lib/deliver');
37
+ const { announce, shouldAnnounce } = require('./lib/announces');
35
38
  const { isAbortRequest } = require('./lib/abort-detector');
36
39
  const { startTyping } = require('./lib/typing-indicator');
37
40
  const { redactBotToken } = require('./lib/net-errors');
@@ -786,6 +789,9 @@ function errorReplyText(err) {
786
789
  if (/Process (exited|killed)/i.test(msg)) {
787
790
  return 'πŸ’₯ Something crashed on my end. Try again.';
788
791
  }
792
+ if (/error_during_execution/i.test(msg)) {
793
+ return 'πŸ’₯ Something went wrong mid-stream. Try again.';
794
+ }
789
795
  const reason = msg.split('\n')[0].slice(0, 120);
790
796
  return `Hit a snag: ${reason || 'unknown error'}. Try resending.`;
791
797
  }
@@ -906,22 +912,6 @@ function parseResponse(text) {
906
912
  return { text: trimmed, sticker: null, stickerLabel: null, reaction: null };
907
913
  }
908
914
 
909
- // ─── Reply chunking ─────────────────────────────────────────────────
910
-
911
- function chunkText(text, maxLen = TG_MAX_LEN) {
912
- if (text.length <= maxLen) return [text];
913
- const chunks = [];
914
- let remaining = text;
915
- while (remaining.length > 0) {
916
- if (remaining.length <= maxLen) { chunks.push(remaining); break; }
917
- let splitAt = remaining.lastIndexOf('\n', maxLen);
918
- if (splitAt < maxLen * 0.3) splitAt = maxLen;
919
- chunks.push(remaining.slice(0, splitAt));
920
- remaining = remaining.slice(splitAt).replace(/^\n/, '');
921
- }
922
- return chunks;
923
- }
924
-
925
915
  // ─── Cron/IPC send ─────────────────────────────────────────────────
926
916
 
927
917
  // Allowlist of Telegram Bot API methods external callers (cron) may invoke.
@@ -1660,11 +1650,18 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1660
1650
  });
1661
1651
 
1662
1652
  const botCfg = config.bot || {};
1653
+ // 0.7.0: per-chat / per-bot link-preview opt-out (port from OpenClaw).
1654
+ // chat-level wins over bot-level. Default (both undefined) preserves
1655
+ // Telegram's native auto-preview behavior.
1656
+ const linkPreview = chatConfig.linkPreview != null
1657
+ ? chatConfig.linkPreview
1658
+ : botCfg.linkPreview;
1663
1659
  const outMetaBase = {
1664
1660
  source: 'bot-reply-stream',
1665
1661
  botName: BOT_NAME,
1666
1662
  model: chatConfig.model,
1667
1663
  effort: chatConfig.effort,
1664
+ ...(linkPreview === false ? { linkPreview: false } : {}),
1668
1665
  };
1669
1666
 
1670
1667
  // Streaming is unconditional as of 0.4.0 β€” matches OpenClaw's model and
@@ -1707,6 +1704,21 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1707
1704
  throw err;
1708
1705
  }
1709
1706
  },
1707
+ deleteMessage: async (messageId) => {
1708
+ // 0.7.0: route preview-discard through tg() so the action is
1709
+ // visible in the events log and gets the same retry/network
1710
+ // protections as other API calls. Failure is non-fatal: stale
1711
+ // bubble at chat top is acceptable when the actual reply has
1712
+ // already been redelivered as fresh chunks.
1713
+ try {
1714
+ await tg(bot, 'deleteMessage', {
1715
+ chat_id: chatId, message_id: messageId,
1716
+ }, { source: 'bot-reply-stream-discard', botName: BOT_NAME });
1717
+ } catch (err) {
1718
+ console.error(`[${label}] discard preview failed: ${err.message}`);
1719
+ throw err;
1720
+ }
1721
+ },
1710
1722
  minChars: botCfg.streamMinChars,
1711
1723
  throttleMs: botCfg.streamThrottleMs,
1712
1724
  logger: { error: (m) => console.error(`[${label}] ${m}`) },
@@ -1761,7 +1773,16 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1761
1773
  if (result.error) {
1762
1774
  console.error(`[${label}] Error (${elapsed}s):`, result.error);
1763
1775
  reactor.setState('ERROR');
1764
- if (!result.text) { markReplied(); return; }
1776
+ // 0.6.16: pre-fix, silently markReplied()+return β€” the user got an
1777
+ // error reaction emoji on their message but no actual reply text,
1778
+ // AND 'replied' status meant boot replay didn't re-dispatch on next
1779
+ // boot. Worst-case: shutdown-killed turn (e.g. polygram upgrade
1780
+ // mid-stream) β†’ user sends "yes", sees 🀯, gets no answer ever,
1781
+ // the row is silently lost. Promote to a thrown error so
1782
+ // dispatchHandleMessage's catch correctly distinguishes shutdown
1783
+ // (β†’ 'replay-pending', boot replay retries) from runtime failure
1784
+ // (β†’ 'failed', user gets an apology with retry hint).
1785
+ if (!result.text) throw new Error(result.error);
1765
1786
  } else {
1766
1787
  // Clear the progress reaction instead of stamping πŸ‘ β€” the reply
1767
1788
  // bubble itself is the "done" signal and a permanent thumbs-up on
@@ -1770,34 +1791,89 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1770
1791
  reactor.clear().catch(() => {});
1771
1792
  }
1772
1793
 
1773
- if (!result.text || result.text === 'NO_REPLY') { markReplied(); return; }
1794
+ // 0.7.0: empty-response fallback (port from OpenClaw β€”
1795
+ // EMPTY_RESPONSE_FALLBACK at reply-CdjLMJxg.js:40323). When
1796
+ // Claude finishes WITHOUT producing any text (e.g. only tool
1797
+ // calls, or aborted before writing the assistant message), send
1798
+ // a placeholder so the user doesn't see silence with no reaction.
1799
+ // NO_REPLY is an explicit "stay silent" signal from the agent β€”
1800
+ // those still markReplied silently.
1801
+ if (result.text === 'NO_REPLY') { markReplied(); return; }
1802
+ if (!result.text) {
1803
+ try {
1804
+ await tg(bot, 'sendMessage', {
1805
+ chat_id: chatId,
1806
+ text: 'No response generated. Please try again.',
1807
+ ...(threadId && { message_thread_id: threadId }),
1808
+ reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
1809
+ }, { ...outMetaBase, source: 'empty-response-fallback' });
1810
+ } catch (err) {
1811
+ console.error(`[${label}] empty-response fallback send failed: ${err.message}`);
1812
+ }
1813
+ logEvent('telegram-empty-response-fallback', {
1814
+ chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
1815
+ });
1816
+ markReplied();
1817
+ return;
1818
+ }
1774
1819
 
1775
1820
  const parsed = parseResponse(result.text);
1776
1821
  const outMeta = { ...outMetaBase, sessionId: result.sessionId, costUsd: result.cost };
1777
1822
 
1778
- // Streamed text path: finalise the live-edit and, if the full response
1779
- // overflows Telegram's 4096 cap, send remainder as follow-up chunks.
1823
+ // 0.7.0 streamed text path: OpenClaw's preview-becomes-final flow.
1824
+ //
1825
+ // 1. flushDraft() β€” drain any pending throttled edit so the
1826
+ // bubble's visible state is up-to-date before deciding.
1827
+ // 2. finalize(parsed.text) β€” try to bring the bubble to the
1828
+ // final body. Returns rich result describing whether the
1829
+ // preview can stand as the final reply.
1830
+ // 3a. finalEditOk:true β†’ preview IS final, done.
1831
+ // 3b. overflow OR !finalEditOk β†’ discard preview, redeliver via
1832
+ // deliverReplies(chunkMarkdownText(...)). This is the path
1833
+ // that fixes msg-10794: if the live bubble couldn't render
1834
+ // the full body (size or parse error), we delete it cleanly
1835
+ // and send the proper chunks fresh at chat bottom β€” no
1836
+ // content lost, no stranded edit-failure bubble.
1780
1837
  if (parsed.text) {
1838
+ await streamer.flushDraft();
1781
1839
  const fin = await streamer.finalize(parsed.text);
1782
1840
  if (fin.streamed) {
1783
- if (parsed.text.length > TG_MAX_LEN) {
1784
- const rest = parsed.text.slice(TG_MAX_LEN - 3);
1785
- for (const chunk of chunkText(rest)) {
1786
- try {
1787
- await tg(bot, 'sendMessage', {
1788
- chat_id: chatId, text: chunk,
1789
- ...(threadId && { message_thread_id: threadId }),
1790
- }, outMeta);
1791
- } catch (err) {
1792
- console.error(`[${label}] overflow sendMessage failed: ${err.message}`);
1793
- }
1794
- }
1841
+ if (fin.finalEditOk) {
1842
+ // Preview was successfully edited to the final text.
1843
+ // No follow-up messages needed.
1844
+ console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1845
+ markReplied();
1846
+ return;
1795
1847
  }
1796
- console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1848
+ // Preview can't hold the final body (overflow OR last edit
1849
+ // failed even after our HTML→plain fallback). Delete it and
1850
+ // send the body as proper chunks.
1851
+ try { await streamer.discard(); }
1852
+ catch (err) { console.error(`[${label}] discard failed: ${err.message}`); }
1853
+ const chunks = chunkMarkdownText(parsed.text, TG_MAX_LEN);
1854
+ const r = await deliverReplies({
1855
+ bot,
1856
+ send: (b, method, params, m) => tg(b, method, params, m),
1857
+ chatId,
1858
+ threadId,
1859
+ chunks,
1860
+ replyToMessageId: msg.message_id,
1861
+ meta: outMeta,
1862
+ logger: { error: (m) => console.error(`[${label}] ${m}`) },
1863
+ });
1864
+ const reason = fin.overflow ? 'overflow' : 'edit-failed';
1865
+ logEvent('telegram-stream-redeliver', {
1866
+ chat_id: chatId, msg_id: msg.message_id,
1867
+ reason, chunks: chunks.length,
1868
+ delivered: r.sent.length, failed: r.failed.length,
1869
+ bot: BOT_NAME,
1870
+ });
1871
+ console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed-redeliver(${reason}, ${chunks.length} chunks) | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1797
1872
  markReplied();
1798
1873
  return;
1799
1874
  }
1800
- // Not streamed (response too short) β€” fall through to normal path.
1875
+ // Not streamed (response too short β€” never crossed minChars).
1876
+ // Fall through to the normal send path below.
1801
1877
  }
1802
1878
 
1803
1879
  if (parsed.reaction) {
@@ -1817,19 +1893,20 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1817
1893
  console.error(`[${label}] sendSticker failed: ${err.message}`);
1818
1894
  });
1819
1895
  } else if (parsed.text) {
1820
- const chunks = chunkText(parsed.text);
1821
- for (let i = 0; i < chunks.length; i++) {
1822
- const params = {
1823
- chat_id: chatId, text: chunks[i],
1824
- ...(threadId && { message_thread_id: threadId }),
1825
- };
1826
- if (i === 0) params.reply_parameters = { message_id: msg.message_id };
1827
- try {
1828
- await tg(bot, 'sendMessage', params, outMeta);
1829
- } catch (err) {
1830
- console.error(`[${label}] sendMessage failed (chunk ${i + 1}/${chunks.length}): ${err.message}`);
1831
- }
1832
- }
1896
+ // 0.7.0: use markdown-aware chunker + deliverReplies primitive.
1897
+ // The old chunkText was newline/byte-only; chunkMarkdownText also
1898
+ // respects code-fence boundaries (closes + reopens across chunks).
1899
+ const chunks = chunkMarkdownText(parsed.text, TG_MAX_LEN);
1900
+ await deliverReplies({
1901
+ bot,
1902
+ send: (b, method, params, m) => tg(b, method, params, m),
1903
+ chatId,
1904
+ threadId,
1905
+ chunks,
1906
+ replyToMessageId: msg.message_id,
1907
+ meta: outMeta,
1908
+ logger: { error: (m) => console.error(`[${label}] ${m}`) },
1909
+ });
1833
1910
  }
1834
1911
 
1835
1912
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
@@ -2468,6 +2545,38 @@ async function main() {
2468
2545
  const head = entry.pendingQueue?.[0];
2469
2546
  const r = head?.context?.reactor;
2470
2547
  if (r) r.setState(classifyToolName(toolName));
2548
+ // 0.7.0 (Phase J): opt-in subagent announce. When Claude uses
2549
+ // the Task tool to spawn a subagent, post a brief informational
2550
+ // message to the chat so the user knows a heavier turn is in
2551
+ // progress. Off by default (per-bot or per-chat
2552
+ // `announceSubagents: true` opts in). Per-chat debounce 30s
2553
+ // prevents announce-storms in tool-heavy turns.
2554
+ const chatCfg = config.chats[entry.chatId] || {};
2555
+ const optIn = chatCfg.announceSubagents != null
2556
+ ? chatCfg.announceSubagents
2557
+ : config.bot?.announceSubagents;
2558
+ if (toolName === 'Task' && optIn === true) {
2559
+ if (shouldAnnounce(entry.chatId)) {
2560
+ announce({
2561
+ send: (b, method, params, m) => tg(b, method, params, m),
2562
+ bot, chatId: entry.chatId,
2563
+ threadId: head?.context?.threadId ?? null,
2564
+ text: 'πŸ€– Spawning subagent…',
2565
+ meta: { botName: BOT_NAME, source: 'subagent-announce' },
2566
+ logger: { error: (m) => console.error(`[${entry.label}] ${m}`) },
2567
+ });
2568
+ }
2569
+ }
2570
+ },
2571
+ // 0.7.0 (Phase F): each new top-level assistant message gets its
2572
+ // own bubble. When Claude emits text, then tool_use, then more
2573
+ // text in a NEW assistant message (typical of tool-heavy turns),
2574
+ // the previous bubble's content stays visible as a "thinking out
2575
+ // loud" intermediate; the new message starts fresh below.
2576
+ onAssistantMessageStart: (sessionKey, entry) => {
2577
+ const head = entry.pendingQueue?.[0];
2578
+ const s = head?.context?.streamer;
2579
+ if (s) s.forceNewMessage();
2471
2580
  },
2472
2581
  // Fires after a graceful /model or /effort drain has actually
2473
2582
  // swapped to the new settings. Post a confirmation back to the