polygram 0.8.0-rc.65 → 0.8.0-rc.67

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.8.0-rc.65",
4
+ "version": "0.8.0-rc.67",
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 plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
package/lib/db.js CHANGED
@@ -381,7 +381,22 @@ function wrap(db) {
381
381
  AND json_extract(detail_json, '$.session_key') = ?
382
382
  LIMIT 1
383
383
  `).get(c.id, c.session_key);
384
- if (!boundary) orphans.push(c);
384
+ if (boundary) continue;
385
+ // rc.66: also skip if a previous boot has already handled
386
+ // this orphan (silent replay via compact-replay event, OR
387
+ // surface-fallback via compact-failed-restart event). Both
388
+ // of those record `original_ts` in their detail_json
389
+ // matching the original compact-command's ts. Without this
390
+ // dedupe, every subsequent deploy re-surfaces / re-replays
391
+ // the same orphan (annoying noise).
392
+ const handled = db.prepare(`
393
+ SELECT id FROM events
394
+ WHERE kind IN ('compact-replay', 'compact-failed-restart')
395
+ AND json_extract(detail_json, '$.original_ts') = ?
396
+ LIMIT 1
397
+ `).get(c.ts);
398
+ if (handled) continue;
399
+ orphans.push(c);
385
400
  }
386
401
  return orphans;
387
402
  },
@@ -165,4 +165,59 @@ function parseResponse(text, { stickerMap = {}, emojiToSticker = {} } = {}) {
165
165
  };
166
166
  }
167
167
 
168
- module.exports = { parseResponse, STICKER_TAG_RE, STICKER_TAG_INLINE_RE, REACT_TAG_RE, REACT_TAG_INLINE_RE };
168
+ /**
169
+ * rc.67: streamer-side pre-processor.
170
+ *
171
+ * The streamer (lib/stream-reply.js) writes the FIRST chunk of a turn to
172
+ * Telegram as soon as text crosses minChars. The DB row for that bubble
173
+ * snapshots whatever was sent, and editMessageText calls don't update
174
+ * the row — so if the agent emitted `[sticker:working]` in the first
175
+ * chunk, the messages.text column captures it verbatim and the bubble
176
+ * shows the literal tag until parseResponse + streamer.finalize clean it
177
+ * up at turn end. That cleanup is fragile in three ways:
178
+ * - parseResponse returns the tag verbatim if stickerMap[name] is
179
+ * falsy (unknown sticker name OR map not loaded). Then
180
+ * finalize sees parsed.text === currentText and takes the no-op
181
+ * branch (stream-reply.js:286-289), bubble untouched.
182
+ * - The turn never reaches `result` (interrupt, transient error,
183
+ * hung query) → onResult never fires, no parseResponse, no edit.
184
+ * - The final edit fails inside the HTML→plain fallback in
185
+ * lib/telegram.js without surfacing telegram-edit-failed.
186
+ *
187
+ * stripInlineTags fixes the leak architecturally: applied at chunk-time
188
+ * (via createStreamer's `transformText` hook), the bubble + DB row never
189
+ * carry a recognised tag in the first place. parseResponse remains the
190
+ * canonical extractor — it surfaces stickers/reactions for outbound
191
+ * dispatch — but its `text` output is now a no-op compared to what the
192
+ * streamer already showed the user.
193
+ *
194
+ * Output is intentionally identical to `parseResponse(text, deps).text`
195
+ * for the same input, modulo `parseResponse`'s leading-trim of the
196
+ * fully-resolved final text. (Streaming text could legitimately end on
197
+ * a partial-token whitespace; we match the per-line right-trim and
198
+ * triple-blank-line collapse but don't touch the outer edges so a
199
+ * mid-stream "Done. " stays "Done." after right-trim — fine — but a
200
+ * legitimate intentional-leading-newline stays.)
201
+ */
202
+ function stripInlineTags(text, { stickerMap = {} } = {}) {
203
+ if (text == null) return '';
204
+ let cleaned = String(text).replace(STICKER_TAG_INLINE_RE, (match, name) => {
205
+ return stickerMap[name] ? '' : match;
206
+ });
207
+ cleaned = cleaned.replace(REACT_TAG_INLINE_RE, () => '');
208
+ return cleaned
209
+ .split('\n')
210
+ .map((line) => line.replace(/[ \t]+$/g, ''))
211
+ .join('\n')
212
+ .replace(/\n{3,}/g, '\n\n')
213
+ .trim();
214
+ }
215
+
216
+ module.exports = {
217
+ parseResponse,
218
+ stripInlineTags,
219
+ STICKER_TAG_RE,
220
+ STICKER_TAG_INLINE_RE,
221
+ REACT_TAG_RE,
222
+ REACT_TAG_INLINE_RE,
223
+ };
@@ -55,6 +55,20 @@ function createStreamer({
55
55
  schedule = setTimeout,
56
56
  cancel = clearTimeout,
57
57
  logger = console,
58
+ // rc.67: pre-processor applied to every chunk before send/edit. polygram
59
+ // passes stripInlineTags(...) so [sticker:NAME] / [react:EMOJI] never
60
+ // reach the bubble or the messages.text DB row. Default identity keeps
61
+ // existing tests + non-polygram callers untouched.
62
+ //
63
+ // Why here (streamer) and not in polygram's send callback: the streamer
64
+ // owns currentText/latestText state used by finalize's no-op-edit
65
+ // optimisation. If pre-processing only happened in send/edit closures,
66
+ // the streamer's internal state would carry raw text and finalize's
67
+ // body-vs-currentText comparison would still fire spurious edits.
68
+ // Applying transformText here means the WHOLE state machine sees clean
69
+ // text — finalize correctly takes the no-op branch when the bubble is
70
+ // already final.
71
+ transformText = null,
58
72
  // rc.44: by default, KEEP intermediate text bubbles when
59
73
  // forceNewMessage transitions to a fresh bubble for a new
60
74
  // top-level assistant message. These are NOT "thinking" tokens
@@ -110,8 +124,23 @@ function createStreamer({
110
124
  return s.slice(0, maxLen - 3) + '...';
111
125
  }
112
126
 
127
+ // rc.67: scrub recognised inline tags BEFORE the streamer commits text
128
+ // to its state machine. Identity when no transformer was configured.
129
+ // Defensive: if transformText throws, fall back to the raw text rather
130
+ // than swallow the chunk — log via injected logger.
131
+ function applyTransform(text) {
132
+ if (!transformText) return text;
133
+ try {
134
+ return transformText(text);
135
+ } catch (err) {
136
+ logger.error?.(`[stream] transformText threw, falling back to raw: ${err.message}`);
137
+ return text;
138
+ }
139
+ }
140
+
113
141
  async function onChunk(text) {
114
142
  if (state === 'finalized') return;
143
+ text = applyTransform(text);
115
144
  latestText = text;
116
145
 
117
146
  // idle: not yet sent the initial message. Only fire the initial send
@@ -269,7 +298,12 @@ function createStreamer({
269
298
 
270
299
  // live → finalize.
271
300
  state = 'finalized';
272
- let body = finalText ?? latestText;
301
+ // rc.67: defense-in-depth even if a caller passes raw text to
302
+ // finalize, transformText scrubs it before the bubble's last edit.
303
+ // Apply to BOTH branches (explicit finalText AND fallback to
304
+ // latestText) so the comparison `body === currentText` is always
305
+ // apples-to-apples (currentText was already transformed in onChunk).
306
+ let body = applyTransform(finalText ?? latestText);
273
307
  if (errorSuffix) body = `${body}\n\n⚠️ ${errorSuffix}`;
274
308
 
275
309
  // If body overflows the single-message cap, the caller needs to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.65",
3
+ "version": "0.8.0-rc.67",
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
@@ -1377,10 +1377,24 @@ const stdinLock = createAsyncLock();
1377
1377
  // mimicking the format as plain text. Without the new branch the
1378
1378
  // placeholder was rendered verbatim in the chat instead of swapped for
1379
1379
  // the actual sticker.
1380
- const { parseResponse: parseResponseImpl } = require('./lib/parse-response');
1380
+ const {
1381
+ parseResponse: parseResponseImpl,
1382
+ stripInlineTags: stripInlineTagsImpl,
1383
+ } = require('./lib/parse-response');
1381
1384
  function parseResponse(text) {
1382
1385
  return parseResponseImpl(text, { stickerMap, emojiToSticker });
1383
1386
  }
1387
+ // rc.67: pre-processor for the streamer. Strips recognised inline
1388
+ // `[sticker:NAME]` and any `[react:EMOJI]` tags BEFORE the chunk is
1389
+ // committed to the bubble + DB row, so the user never sees a literal
1390
+ // tag even when the turn-end finalize path doesn't manage to clean it
1391
+ // (interrupt, error, hung query, edit failure, or the stickerMap-miss
1392
+ // no-op branch). parseResponse continues to surface the same tags in
1393
+ // `parsed.stickers[]` / `parsed.reactions[]` for outbound dispatch via
1394
+ // sendInlineStickers / sendInlineReactions.
1395
+ function stripInlineTagsForStreamer(text) {
1396
+ return stripInlineTagsImpl(text, { stickerMap });
1397
+ }
1384
1398
 
1385
1399
  // ─── Cron/IPC send ─────────────────────────────────────────────────
1386
1400
 
@@ -2572,6 +2586,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2572
2586
  // code path. For short responses the streamer stays idle and we fall
2573
2587
  // through to the normal send path via finalize() returning streamed=false.
2574
2588
  const streamer = createStreamer({
2589
+ // rc.67: pre-process every chunk to strip recognised
2590
+ // [sticker:NAME] / [react:EMOJI] tags BEFORE the bubble or DB row
2591
+ // captures them. See stripInlineTagsForStreamer above.
2592
+ transformText: stripInlineTagsForStreamer,
2575
2593
  send: async (text) => {
2576
2594
  const params = {
2577
2595
  chat_id: chatId, text,