polygram 0.7.1 → 0.7.2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.7.1",
4
+ "version": "0.7.2",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
package/README.md CHANGED
@@ -364,7 +364,7 @@ foreign-chat clicks are rejected. Default-deny on IPC error.
364
364
  ## Development
365
365
 
366
366
  ```bash
367
- npm test # 638 tests, 158 suites, node:test, no external services
367
+ npm test # 643 tests, 158 suites, node:test, no external services
368
368
  npm run coverage # native test coverage (Node 22+, no devDeps)
369
369
  npm start -- --bot my-bot
370
370
  npm run split-db -- --config config.json --dry-run
@@ -57,6 +57,13 @@ function createStreamer({
57
57
  let lastEditTs = 0;
58
58
  let pendingEdit = null; // timer id
59
59
  let flushPromise = null; // ongoing edit promise (for back-pressure)
60
+ // 0.7.2: msg_ids of bubbles that have been superseded by
61
+ // forceNewMessage(). The caller (polygram.js handleMessage at
62
+ // end-of-turn) reads getArchived() and issues deleteMessage on
63
+ // each — matches OpenClaw's archivedAnswerPreviews cleanup so
64
+ // the user sees only the final answer's bubble, not every
65
+ // "thinking out loud" intermediate from a tool-heavy turn.
66
+ const archived = [];
60
67
 
61
68
  // LIVE-EDIT truncation only — used during streaming when latestText
62
69
  // overshoots maxLen. The trailing "..." signals to the user that more
@@ -148,6 +155,11 @@ function createStreamer({
148
155
  if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; }
149
156
  // Don't await flushPromise — the caller has decided to start a new
150
157
  // message; whatever the old bubble shows is "done".
158
+ // 0.7.2: track the previous bubble's msgId for end-of-turn cleanup.
159
+ // Without this, every intermediate "thinking out loud" assistant
160
+ // message in a tool-heavy turn leaves a permanent bubble in the
161
+ // chat — the user wants only the final answer's bubble visible.
162
+ if (msgId != null) archived.push(msgId);
151
163
  msgId = null;
152
164
  currentText = '';
153
165
  latestText = '';
@@ -239,6 +251,11 @@ function createStreamer({
239
251
  }
240
252
  }
241
253
 
254
+ // 0.7.2: snapshot of bubble msgIds that forceNewMessage() superseded.
255
+ // Returns a copy so callers can't mutate internal state. polygram.js
256
+ // reads this at end-of-turn and issues deleteMessage on each.
257
+ function getArchived() { return archived.slice(); }
258
+
242
259
  return {
243
260
  onChunk,
244
261
  finalize,
@@ -246,6 +263,7 @@ function createStreamer({
246
263
  forceNewMessage,
247
264
  discard,
248
265
  archive,
266
+ getArchived,
249
267
  // Introspection for tests:
250
268
  get state() { return state; },
251
269
  get msgId() { return msgId; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc-client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -1664,21 +1664,34 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1664
1664
  ...(linkPreview === false ? { linkPreview: false } : {}),
1665
1665
  };
1666
1666
 
1667
+ // 0.7.2: only the FIRST bubble in a turn quotes the user's message
1668
+ // via reply_parameters. When a tool-heavy turn produces multiple
1669
+ // assistant messages (each spawning its own bubble via
1670
+ // forceNewMessage), subsequent bubbles shouldn't re-quote the user
1671
+ // — the chat would show N copies of the same quoted message stacked
1672
+ // vertically. After the first send, the flag flips and subsequent
1673
+ // initial-sends omit reply_parameters.
1674
+ let firstBubbleSent = false;
1667
1675
  // Streaming is unconditional as of 0.4.0 — matches OpenClaw's model and
1668
1676
  // eliminates the "stuck at 15min typing" complaint from the non-streaming
1669
1677
  // code path. For short responses the streamer stays idle and we fall
1670
1678
  // through to the normal send path via finalize() returning streamed=false.
1671
1679
  const streamer = createStreamer({
1672
- send: async (text) => tg(bot, 'sendMessage', {
1673
- chat_id: chatId, text,
1674
- // allow_sending_without_reply: long-running turns give the user
1675
- // plenty of time to delete their original message. Without this
1676
- // flag, Telegram rejects the reply with MESSAGE_NOT_FOUND and the
1677
- // whole streamed answer is lost. With it, the reply simply lands
1678
- // as a standalone message.
1679
- reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
1680
- ...(threadId && { message_thread_id: threadId }),
1681
- }, outMetaBase),
1680
+ send: async (text) => {
1681
+ const params = {
1682
+ chat_id: chatId, text,
1683
+ ...(threadId && { message_thread_id: threadId }),
1684
+ };
1685
+ if (!firstBubbleSent) {
1686
+ // allow_sending_without_reply: long-running turns give the user
1687
+ // plenty of time to delete their original message. Without this
1688
+ // flag, Telegram rejects the reply with MESSAGE_NOT_FOUND and the
1689
+ // whole streamed answer is lost.
1690
+ params.reply_parameters = { message_id: msg.message_id, allow_sending_without_reply: true };
1691
+ firstBubbleSent = true;
1692
+ }
1693
+ return tg(bot, 'sendMessage', params, outMetaBase);
1694
+ },
1682
1695
  edit: async (messageId, text) => {
1683
1696
  try {
1684
1697
  // Route edits through tg() so applyFormatting runs (MarkdownV2
@@ -1725,6 +1738,34 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1725
1738
  });
1726
1739
  // streamer is registered with this turn via pm.send's context (below)
1727
1740
 
1741
+ // 0.7.2: clean up bubbles superseded by forceNewMessage() — the
1742
+ // intermediate "thinking out loud" assistant messages that fired in
1743
+ // a tool-heavy turn. Without this, every tool-result cycle leaves a
1744
+ // permanent bubble in the chat (see the screenshot from the post-
1745
+ // 0.7.1 deploy where six bubbles appeared for one logical turn).
1746
+ // Matches OpenClaw's archivedAnswerPreviews end-of-turn cleanup.
1747
+ // Call AFTER finalize/discard decisions so we never delete the
1748
+ // bubble that's the final reply.
1749
+ async function cleanupArchivedBubbles() {
1750
+ const archived = streamer.getArchived?.() || [];
1751
+ if (archived.length === 0) return;
1752
+ for (const messageId of archived) {
1753
+ try {
1754
+ await tg(bot, 'deleteMessage', {
1755
+ chat_id: chatId, message_id: messageId,
1756
+ }, { source: 'bot-reply-archived-cleanup', botName: BOT_NAME });
1757
+ } catch (err) {
1758
+ // Non-fatal — message may be >48h old or already gone.
1759
+ // Operator-visible only via the events table.
1760
+ console.error(`[${label}] archived-cleanup ${messageId}: ${err.message}`);
1761
+ }
1762
+ }
1763
+ logEvent('telegram-archived-cleanup', {
1764
+ chat_id: chatId, msg_id: msg.message_id, count: archived.length,
1765
+ bot: BOT_NAME,
1766
+ });
1767
+ }
1768
+
1728
1769
  // Status reactions on the user's message: 👀 queued → 🤔 thinking →
1729
1770
  // 👨‍💻 coding / ⚡ web / 🔥 tool → 👍 done / 🤯 error. Silent (no
1730
1771
  // notifications), updates in place, one emoji per message. Uses
@@ -1853,6 +1894,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1853
1894
  if (fin.finalEditOk) {
1854
1895
  // Preview was successfully edited to the final text.
1855
1896
  // No follow-up messages needed.
1897
+ await cleanupArchivedBubbles();
1856
1898
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1857
1899
  markReplied();
1858
1900
  return;
@@ -1897,6 +1939,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1897
1939
  console.error(`[${label}] partial-delivery warning failed: ${warnErr.message}`);
1898
1940
  }
1899
1941
  }
1942
+ await cleanupArchivedBubbles();
1900
1943
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed-redeliver(${reason}, ${chunks.length} chunks${r.failed.length ? `, ${r.failed.length} failed` : ''}) | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1901
1944
  markReplied();
1902
1945
  return;