polygram 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.3",
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
@@ -15,6 +15,8 @@
15
15
  "attachmentConcurrency": 6,
16
16
  "queueWarnThreshold": 20,
17
17
  "replayWindowMs": 180000,
18
+ "_comment_chrome": "Opt-in to Claude Code's Chrome-extension browser-automation integration. Default false. Requires the 'Claude in Chrome' extension installed in the daemon-user's Chrome browser AND a live GUI session (Chrome must be running). See https://code.claude.com/docs/en/chrome. Per-chat override via `config.chats.<id>.chrome`.",
19
+ "chrome": false,
18
20
  "_comment_pairedChatDefaults": "When a user sends /pair <CODE> from a private chat that isn't in config.chats yet, polygram auto-creates a chat entry using these defaults (merged over top-level `defaults`). Leave out `cwd` at your peril — without it, auto-onboarded DMs have no working directory and pairing will fail.",
19
21
  "pairedChatDefaults": {
20
22
  "agent": "admin",
@@ -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.3",
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
@@ -640,6 +640,19 @@ let pm = null; // ProcessManager, created in main()
640
640
 
641
641
  function spawnClaude(sessionKey, ctx) {
642
642
  const { chatConfig, existingSessionId, label, chatId } = ctx;
643
+ // 0.7.3: Claude Code's Chrome-extension integration (browser
644
+ // automation via the "Claude in Chrome" extension) is OPT-IN and
645
+ // NOT enabled by default in `claude`. Polygram lets chats turn it
646
+ // on via `config.chats.<id>.chrome: true` (chat-level wins) or
647
+ // `config.bot.chrome: true` (per-bot default). When opting in, the
648
+ // extension must be installed in the daemon-user's Chrome and the
649
+ // user must have a live Aqua session (so Chrome is running). Falls
650
+ // back to --no-chrome for chats that don't opt in (matches our
651
+ // pre-0.7.3 default — defensive against any "enabled by default"
652
+ // that might have been set in claude's persistent state).
653
+ const wantChrome = chatConfig.chrome != null
654
+ ? chatConfig.chrome === true
655
+ : config.bot?.chrome === true;
643
656
  const args = [
644
657
  '-p',
645
658
  '--input-format', 'stream-json',
@@ -648,7 +661,7 @@ function spawnClaude(sessionKey, ctx) {
648
661
  '--model', chatConfig.model || config.defaults.model,
649
662
  '--effort', chatConfig.effort || config.defaults.effort,
650
663
  '--permission-mode', 'bypassPermissions',
651
- '--no-chrome',
664
+ wantChrome ? '--chrome' : '--no-chrome',
652
665
  ];
653
666
  if (chatConfig.agent) args.push('--agent', chatConfig.agent);
654
667
  if (existingSessionId) args.push('--resume', existingSessionId);
@@ -1664,21 +1677,34 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1664
1677
  ...(linkPreview === false ? { linkPreview: false } : {}),
1665
1678
  };
1666
1679
 
1680
+ // 0.7.2: only the FIRST bubble in a turn quotes the user's message
1681
+ // via reply_parameters. When a tool-heavy turn produces multiple
1682
+ // assistant messages (each spawning its own bubble via
1683
+ // forceNewMessage), subsequent bubbles shouldn't re-quote the user
1684
+ // — the chat would show N copies of the same quoted message stacked
1685
+ // vertically. After the first send, the flag flips and subsequent
1686
+ // initial-sends omit reply_parameters.
1687
+ let firstBubbleSent = false;
1667
1688
  // Streaming is unconditional as of 0.4.0 — matches OpenClaw's model and
1668
1689
  // eliminates the "stuck at 15min typing" complaint from the non-streaming
1669
1690
  // code path. For short responses the streamer stays idle and we fall
1670
1691
  // through to the normal send path via finalize() returning streamed=false.
1671
1692
  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),
1693
+ send: async (text) => {
1694
+ const params = {
1695
+ chat_id: chatId, text,
1696
+ ...(threadId && { message_thread_id: threadId }),
1697
+ };
1698
+ if (!firstBubbleSent) {
1699
+ // allow_sending_without_reply: long-running turns give the user
1700
+ // plenty of time to delete their original message. Without this
1701
+ // flag, Telegram rejects the reply with MESSAGE_NOT_FOUND and the
1702
+ // whole streamed answer is lost.
1703
+ params.reply_parameters = { message_id: msg.message_id, allow_sending_without_reply: true };
1704
+ firstBubbleSent = true;
1705
+ }
1706
+ return tg(bot, 'sendMessage', params, outMetaBase);
1707
+ },
1682
1708
  edit: async (messageId, text) => {
1683
1709
  try {
1684
1710
  // Route edits through tg() so applyFormatting runs (MarkdownV2
@@ -1725,6 +1751,34 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1725
1751
  });
1726
1752
  // streamer is registered with this turn via pm.send's context (below)
1727
1753
 
1754
+ // 0.7.2: clean up bubbles superseded by forceNewMessage() — the
1755
+ // intermediate "thinking out loud" assistant messages that fired in
1756
+ // a tool-heavy turn. Without this, every tool-result cycle leaves a
1757
+ // permanent bubble in the chat (see the screenshot from the post-
1758
+ // 0.7.1 deploy where six bubbles appeared for one logical turn).
1759
+ // Matches OpenClaw's archivedAnswerPreviews end-of-turn cleanup.
1760
+ // Call AFTER finalize/discard decisions so we never delete the
1761
+ // bubble that's the final reply.
1762
+ async function cleanupArchivedBubbles() {
1763
+ const archived = streamer.getArchived?.() || [];
1764
+ if (archived.length === 0) return;
1765
+ for (const messageId of archived) {
1766
+ try {
1767
+ await tg(bot, 'deleteMessage', {
1768
+ chat_id: chatId, message_id: messageId,
1769
+ }, { source: 'bot-reply-archived-cleanup', botName: BOT_NAME });
1770
+ } catch (err) {
1771
+ // Non-fatal — message may be >48h old or already gone.
1772
+ // Operator-visible only via the events table.
1773
+ console.error(`[${label}] archived-cleanup ${messageId}: ${err.message}`);
1774
+ }
1775
+ }
1776
+ logEvent('telegram-archived-cleanup', {
1777
+ chat_id: chatId, msg_id: msg.message_id, count: archived.length,
1778
+ bot: BOT_NAME,
1779
+ });
1780
+ }
1781
+
1728
1782
  // Status reactions on the user's message: 👀 queued → 🤔 thinking →
1729
1783
  // 👨‍💻 coding / ⚡ web / 🔥 tool → 👍 done / 🤯 error. Silent (no
1730
1784
  // notifications), updates in place, one emoji per message. Uses
@@ -1853,6 +1907,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1853
1907
  if (fin.finalEditOk) {
1854
1908
  // Preview was successfully edited to the final text.
1855
1909
  // No follow-up messages needed.
1910
+ await cleanupArchivedBubbles();
1856
1911
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1857
1912
  markReplied();
1858
1913
  return;
@@ -1897,6 +1952,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1897
1952
  console.error(`[${label}] partial-delivery warning failed: ${warnErr.message}`);
1898
1953
  }
1899
1954
  }
1955
+ await cleanupArchivedBubbles();
1900
1956
  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
1957
  markReplied();
1902
1958
  return;