polygram 0.17.11 → 0.18.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.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/lib/error/classify.js +14 -0
  3. package/lib/error/net.js +8 -0
  4. package/lib/handlers/config-callback.js +44 -18
  5. package/lib/handlers/config-ui.js +29 -2
  6. package/lib/handlers/dispatcher.js +54 -1
  7. package/lib/handlers/drop-redeliver.js +19 -0
  8. package/lib/handlers/should-handle.js +52 -0
  9. package/lib/media-group-buffer.js +29 -0
  10. package/lib/ops/auth-disabled-gate.js +43 -0
  11. package/lib/ops/heartbeat.js +56 -0
  12. package/lib/process/channels-tool-dispatcher.js +12 -1
  13. package/lib/sdk/build-options.js +10 -2
  14. package/lib/sdk/callbacks.js +2 -1
  15. package/lib/secret-detect.js +13 -1
  16. package/lib/session-key.js +7 -16
  17. package/lib/telegram/api.js +12 -1
  18. package/lib/telegram/chunk.js +20 -6
  19. package/lib/telegram/display-hint.js +47 -12
  20. package/lib/telegram/format.js +10 -1
  21. package/lib/telegram/process-agent-reply.js +5 -3
  22. package/lib/telegram/rich-edit.js +94 -0
  23. package/lib/telegram/rich.js +397 -0
  24. package/lib/telegram/streamer.js +157 -11
  25. package/package.json +2 -1
  26. package/polygram.js +260 -44
  27. package/lib/async-lock.js +0 -49
  28. package/lib/claude-bin.js +0 -246
  29. package/lib/compaction-warn.js +0 -59
  30. package/lib/context-usage.js +0 -93
  31. package/lib/process/channels-bridge-protocol.js +0 -199
  32. package/lib/process/channels-bridge-server.js +0 -274
  33. package/lib/process/channels-bridge.mjs +0 -477
  34. package/lib/process/cli-process.js +0 -4029
  35. package/lib/process/factory.js +0 -215
  36. package/lib/process/hook-event-tail.js +0 -162
  37. package/lib/process/hook-settings.js +0 -181
  38. package/lib/process/polygram-hook-append.js +0 -71
  39. package/lib/process/process.js +0 -215
  40. package/lib/process/sdk-process.js +0 -880
  41. package/lib/process-guard.js +0 -296
  42. package/lib/process-manager.js +0 -628
  43. package/lib/tmux/log-tail.js +0 -334
  44. package/lib/tmux/orphan-sweep.js +0 -79
  45. package/lib/tmux/poll-scheduler.js +0 -110
  46. package/lib/tmux/startup-gate.js +0 -250
  47. package/lib/tmux/tmux-runner.js +0 -412
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Markdown → Telegram Bot API Rich Message blocks.
3
+ *
4
+ * Companion to format.js's markdown→HTML pipeline, not a replacement:
5
+ * this only runs when needsRichRendering() finds a construct (task list,
6
+ * table, <details>, heading) that the plain HTML pipeline can't express
7
+ * natively, AND the chat has opted in (config.richText). Everything else
8
+ * stays on the existing toTelegramHtml() path.
9
+ *
10
+ * Block text fields carry plain strings only. Inline bold, italic, and
11
+ * links are flattened rather than mapped to an unverified nested schema.
12
+ * This keeps formatting safe while still supporting headings, tables,
13
+ * checklists, and collapsible details.
14
+ *
15
+ * Bot API limits are enforced by Telegram itself:
16
+ * ≤32,768 chars, ≤500 blocks, ≤16 nesting levels, ≤50 media, ≤20 table
17
+ * columns.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const { Marked } = require('marked');
23
+ const { getTopicConfig } = require('../session-key');
24
+
25
+ // ─── Per-chat opt-in resolution ──────────────────────────────────────
26
+ //
27
+ // Match the standard per-chat override precedence: topic, chat, active
28
+ // bot, then defaults. `config.bot` is already filtered to the active bot
29
+ // during startup.
30
+ function resolveRichTextEnabled(config, chatId, threadId = null) {
31
+ if (!config) return false;
32
+ const chat = config.chats?.[String(chatId)] || null;
33
+ const topicCfg = (chat && threadId != null) ? getTopicConfig(chat, String(threadId)) : null;
34
+ const pick = (v) => (typeof v === 'boolean' ? v : undefined);
35
+ const resolved = pick(topicCfg?.richText)
36
+ ?? pick(chat?.richText)
37
+ ?? pick(config.bot?.richText)
38
+ ?? pick(config.defaults?.richText);
39
+ return resolved === true;
40
+ }
41
+
42
+ // ─── Content-adaptive gate ───────────────────────────────────────────
43
+ //
44
+ // Rich rendering uses different typography than plain prose (Telegram's
45
+ // own client renders it as a document, not a chat bubble) — sending
46
+ // ordinary prose through it makes it look oversized with no Bot API knob
47
+ // to correct it. So rich is
48
+ // reserved for content that actually contains a structural construct the
49
+ // plain pipeline can't express: task lists, tables, <details>, headings.
50
+ // False negatives are always safe (falls through to the existing,
51
+ // well-tested toTelegramHtml path); false positives risk the oversized-
52
+ // prose problem, so keep these patterns specific, not broad.
53
+ //
54
+ // Regexes below are deliberately simple/anchored (single quantified
55
+ // groups, no nested-quantifier ambiguity) to avoid catastrophic
56
+ // backtracking on adversarial input — the content they scan is agent
57
+ // output that can be influenced by a user asking the agent to echo a
58
+ // crafted string. See tests/rich.test.js for the ReDoS regression guard.
59
+
60
+ const TASK_ITEM_RE = /^[ \t]{0,3}[-*+][ \t]+\[[ xX]\][ \t]+/m;
61
+ const TABLE_ROW_RE = /^[ \t]{0,3}\|?[ \t]*:?-{2,}:?[ \t]*\|/m;
62
+ const DETAILS_RE = /<details[ \t>]/i;
63
+ const HEADING_RE = /^#{1,6}[ \t]+\S/m;
64
+ const FENCE_LINE_RE = /^[ \t]{0,3}(`{3,}|~{3,})/;
65
+
66
+ // Code-fence content renders literally, so heading, checklist, and table
67
+ // syntax inside a fence must not influence the rich-rendering decision.
68
+ // Track fences with a line scan to keep the gate easy to reason about on
69
+ // adversarial input.
70
+ function stripFencedCodeBlocks(markdown) {
71
+ if (markdown.indexOf('`') === -1 && markdown.indexOf('~') === -1) return markdown;
72
+ const lines = markdown.split('\n');
73
+ const out = [];
74
+ // A CommonMark closing fence uses the opener's character, is at least
75
+ // as long, and has only trailing whitespace. Preserve the opener so a
76
+ // marker with trailing content cannot expose fenced syntax to the gate.
77
+ let fence = null; // { char, len } while inside a fence, else null
78
+ for (const line of lines) {
79
+ const m = FENCE_LINE_RE.exec(line);
80
+ if (m) {
81
+ const marker = m[1];
82
+ const char = marker[0];
83
+ const len = marker.length;
84
+ const trailing = line.slice(m[0].length);
85
+ if (!fence) {
86
+ fence = { char, len };
87
+ out.push('');
88
+ continue;
89
+ }
90
+ if (char === fence.char && len >= fence.len && trailing.trim() === '') {
91
+ fence = null;
92
+ out.push('');
93
+ continue;
94
+ }
95
+ // Same-or-different marker mid-fence with trailing content (or a
96
+ // shorter/mismatched marker) — still inside the block; scope it
97
+ // out like any other fenced line, don't treat as a toggle.
98
+ out.push(fence ? '' : line);
99
+ continue;
100
+ }
101
+ out.push(fence ? '' : line);
102
+ }
103
+ return out.join('\n');
104
+ }
105
+
106
+ function needsRichRendering(markdown) {
107
+ if (typeof markdown !== 'string' || markdown.length === 0) return false;
108
+ const scoped = stripFencedCodeBlocks(markdown);
109
+ return TASK_ITEM_RE.test(scoped)
110
+ || TABLE_ROW_RE.test(scoped)
111
+ || DETAILS_RE.test(scoped)
112
+ || HEADING_RE.test(scoped);
113
+ }
114
+
115
+ // ─── marked instance for block-level tokenization ────────────────────
116
+ //
117
+ // Use a dedicated lexer because format.js's instance has an HTML-string
118
+ // renderer and does not expose a reusable token tree. GFM is required for
119
+ // task-list items and tables.
120
+ const _lexer = new Marked({ gfm: true, breaks: false });
121
+
122
+ function plainTextOf(tokens) {
123
+ // Flatten inline tokens (bold/italic/link/code-span/text) to their
124
+ // plain text content — see module doc for why. marked's Tokens carry
125
+ // `.text` on leaf nodes; walk recursively for nested inline tokens
126
+ // (e.g. bold-wrapping-a-link) so nothing is silently dropped.
127
+ if (!Array.isArray(tokens)) return '';
128
+ let out = '';
129
+ for (const t of tokens) {
130
+ if (t.type === 'text' || t.type === 'codespan' || t.type === 'escape') {
131
+ out += t.text ?? '';
132
+ } else if (t.type === 'link' || t.type === 'image') {
133
+ out += plainTextOf(t.tokens) || t.text || t.href || '';
134
+ } else if (t.type === 'br') {
135
+ out += '\n';
136
+ } else if (Array.isArray(t.tokens)) {
137
+ out += plainTextOf(t.tokens);
138
+ } else if (typeof t.text === 'string') {
139
+ out += t.text;
140
+ } else if (typeof t.raw === 'string') {
141
+ out += t.raw;
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+
147
+ const DETAILS_BLOCK_RE = /<details[^>]*>(?:\s*<summary[^>]*>([\s\S]*?)<\/summary>)?([\s\S]*?)<\/details>/i;
148
+ const DETAILS_OPEN_RE = /<details[^>]*>/i;
149
+ const DETAILS_CLOSE_RE = /<\/details>/i;
150
+ const SUMMARY_TAG_RE = /<summary[^>]*>([\s\S]*?)<\/summary>/i;
151
+ const HTML_TAG_STRIP_RE = /<[^>]+>/g;
152
+ const DEFAULT_DETAILS_SUMMARY = 'Details';
153
+
154
+ // Convert one marked block-level token into zero or more RichBlock
155
+ // objects. Returns an array (a single markdown token can occasionally
156
+ // need zero blocks — e.g. a bare 'space' token — or, in principle,
157
+ // more than one).
158
+ function blockFromToken(token) {
159
+ switch (token.type) {
160
+ case 'paragraph': {
161
+ const text = plainTextOf(token.tokens);
162
+ if (!text.trim()) return [];
163
+ return [{ type: 'paragraph', text }];
164
+ }
165
+ case 'heading': {
166
+ const size = Math.min(6, Math.max(1, token.depth || 1));
167
+ return [{ type: 'heading', text: plainTextOf(token.tokens), size }];
168
+ }
169
+ case 'code': {
170
+ return [{ type: 'pre', text: token.text || '' }];
171
+ }
172
+ case 'blockquote': {
173
+ const blocks = blocksFromTokens(token.tokens);
174
+ if (!blocks.length) return [];
175
+ return [{ type: 'blockquote', blocks }];
176
+ }
177
+ case 'list': {
178
+ const items = (token.items || []).map((item, i) => listItemFromToken(item, token, i)).filter(Boolean);
179
+ if (!items.length) return [];
180
+ return [{ type: 'list', items }];
181
+ }
182
+ case 'table': {
183
+ const cellAlignment = (column) => {
184
+ const align = token.align?.[column];
185
+ return align === 'center' || align === 'right' ? align : 'left';
186
+ };
187
+ const headerRow = (token.header || []).map((cell, column) => ({
188
+ text: plainTextOf(cell.tokens),
189
+ is_header: true,
190
+ align: cellAlignment(column),
191
+ valign: 'top',
192
+ }));
193
+ const rows = (token.rows || []).map((row) =>
194
+ row.map((cell, column) => ({
195
+ text: plainTextOf(cell.tokens),
196
+ align: cellAlignment(column),
197
+ valign: 'top',
198
+ })));
199
+ const cells = [headerRow, ...rows];
200
+ return [{ type: 'table', cells }];
201
+ }
202
+ case 'hr': {
203
+ return [{ type: 'divider' }];
204
+ }
205
+ case 'html': {
206
+ // Only <details> is handled specially (it's an explicit gate
207
+ // trigger, DETAILS_RE above); any other raw HTML block is NOT
208
+ // passed through verbatim (that would reopen exactly the
209
+ // injection surface escapeHtml closes in format.js) — it's
210
+ // rendered as a plain paragraph with tags stripped, matching
211
+ // the plain-text-only invariant this module holds throughout.
212
+ const m = DETAILS_BLOCK_RE.exec(token.raw || token.text || '');
213
+ if (m) {
214
+ const summary = (m[1] || '').replace(HTML_TAG_STRIP_RE, '').trim();
215
+ const innerMarkdown = (m[2] || '').trim();
216
+ const blocks = innerMarkdown ? blocksFromTokens(_lexer.lexer(innerMarkdown)) : [];
217
+ return [{
218
+ type: 'details',
219
+ summary: summary || DEFAULT_DETAILS_SUMMARY,
220
+ blocks: blocks.length ? blocks : [{ type: 'paragraph', text: '' }],
221
+ is_open: false,
222
+ }];
223
+ }
224
+ const stripped = (token.text || token.raw || '').replace(HTML_TAG_STRIP_RE, '').trim();
225
+ if (!stripped) return [];
226
+ return [{ type: 'paragraph', text: stripped }];
227
+ }
228
+ case 'space':
229
+ return [];
230
+ default: {
231
+ // Unknown token type — fall back to its flattened text rather
232
+ // than dropping content silently.
233
+ const text = plainTextOf(token.tokens) || token.text || '';
234
+ if (!text.trim()) return [];
235
+ return [{ type: 'paragraph', text }];
236
+ }
237
+ }
238
+ }
239
+
240
+ function listItemFromToken(item, listToken, index) {
241
+ const { inline, blockTokens } = splitListItemTokens(item.tokens);
242
+ const leaderText = plainTextOf(inline);
243
+ const nestedBlocks = blocksFromTokens(blockTokens);
244
+ const blocks = [];
245
+ if (leaderText.trim() || !nestedBlocks.length) blocks.push({ type: 'paragraph', text: leaderText });
246
+ blocks.push(...nestedBlocks);
247
+
248
+ const out = { blocks };
249
+ if (item.task) {
250
+ out.has_checkbox = true;
251
+ if (item.checked) out.is_checked = true;
252
+ } else if (listToken.ordered) {
253
+ out.value = (typeof listToken.start === 'number' ? listToken.start : 1) + index;
254
+ }
255
+ return out;
256
+ }
257
+
258
+ // Mirrors format.js's splitItemTokens (BLOCK_TYPES boundary) — a list
259
+ // item's tokens are a leading inline run followed by any block-level
260
+ // tokens (nested list, code block, etc). Kept separate from format.js's
261
+ // copy because rich blocks need nested block arrays rather than HTML.
262
+ const LIST_ITEM_BLOCK_TYPES = new Set(['list', 'blockquote', 'code', 'table', 'paragraph', 'space', 'html', 'hr']);
263
+
264
+ function splitListItemTokens(tokens) {
265
+ const inline = [];
266
+ const blockTokens = [];
267
+ let crossed = false;
268
+ for (const tok of tokens || []) {
269
+ if (LIST_ITEM_BLOCK_TYPES.has(tok.type)) {
270
+ crossed = true;
271
+ blockTokens.push(tok);
272
+ } else if (crossed) {
273
+ blockTokens.push({ type: 'paragraph', tokens: [tok] });
274
+ } else {
275
+ inline.push(tok);
276
+ }
277
+ }
278
+ return { inline, blockTokens };
279
+ }
280
+
281
+ // GFM tokenizes <details>...</details> as a SINGLE 'html' token only when
282
+ // there's no blank line inside it; a blank line (very common in
283
+ // agent-authored content — a summary line, then a blank line, then the
284
+ // body) splits it into separate sibling tokens: an 'html' open token, the
285
+ // nested content as normal block tokens, and an 'html' close token. This
286
+ // scans the token STREAM (not a single token) for that open/close span so
287
+ // details blocks with real paragraph content inside render correctly
288
+ // rather than getting silently split into a stray open-tag paragraph +
289
+ // unrelated nested blocks + a stray close-tag paragraph.
290
+ function blocksFromTokens(tokens) {
291
+ const out = [];
292
+ const list = tokens || [];
293
+ let i = 0;
294
+ while (i < list.length) {
295
+ const t = list[i];
296
+ const raw = t.raw || t.text || '';
297
+ if (t.type === 'html' && DETAILS_OPEN_RE.test(raw) && !DETAILS_CLOSE_RE.test(raw)) {
298
+ const summaryMatch = SUMMARY_TAG_RE.exec(raw);
299
+ const summary = summaryMatch ? summaryMatch[1].replace(HTML_TAG_STRIP_RE, '').trim() : '';
300
+ const inner = [];
301
+ let j = i + 1;
302
+ for (; j < list.length; j++) {
303
+ const tj = list[j];
304
+ const rawj = tj.raw || tj.text || '';
305
+ if (tj.type === 'html' && DETAILS_CLOSE_RE.test(rawj)) break;
306
+ inner.push(tj);
307
+ }
308
+ const nestedBlocks = blocksFromTokens(inner);
309
+ out.push({
310
+ type: 'details',
311
+ summary: summary || DEFAULT_DETAILS_SUMMARY,
312
+ blocks: nestedBlocks.length ? nestedBlocks : [{ type: 'paragraph', text: '' }],
313
+ is_open: false,
314
+ });
315
+ i = j + 1; // skip past the close token (or to the end if never closed)
316
+ continue;
317
+ }
318
+ out.push(...blockFromToken(t));
319
+ i++;
320
+ }
321
+ return out;
322
+ }
323
+
324
+ /**
325
+ * toTelegramRichBlocks(markdown, opts) -> { blocks: RichBlock[], usedRich: boolean }
326
+ *
327
+ * opts.partial (boolean, default false) — streaming mode: when true, the
328
+ * last top-level block is held back rather than emitted. Growing text
329
+ * may have cut it off mid-structure (an unclosed table,
330
+ * a dangling list item). The caller re-calls this on every chunk, so the
331
+ * held-back tail simply appears (complete) on a later call once the
332
+ * source text has moved past it. Only applies when there's more than one
333
+ * top-level block — a single in-progress block is emitted as-is rather
334
+ * than producing an empty array (better to show a resizing paragraph
335
+ * than nothing).
336
+ */
337
+ function toTelegramRichBlocks(markdown, opts = {}) {
338
+ const usedRich = needsRichRendering(markdown);
339
+ if (!usedRich) return { blocks: [], usedRich: false };
340
+
341
+ const tokens = _lexer.lexer(markdown);
342
+ let blocks = blocksFromTokens(tokens);
343
+
344
+ if (opts.partial && blocks.length > 1) {
345
+ blocks = blocks.slice(0, -1);
346
+ }
347
+
348
+ return { blocks, usedRich: true };
349
+ }
350
+
351
+ // ─── Error classification ────────────────────────────────────────────
352
+ //
353
+ // Capability errors (endpoint missing) latch
354
+ // rich off permanently for this process; content errors (this payload
355
+ // specifically) fall back once, never latch. An error matching NEITHER
356
+ // must be treated as transient and fall through to the existing retry
357
+ // path in api.js.
358
+
359
+ // Unsupported new endpoints normally return 404. The existing
360
+ // editMessageText endpoint can instead reject its `rich_message`
361
+ // parameter with an unknown/unsupported-field response, so both shapes
362
+ // identify a missing rich-message capability.
363
+ const RICH_CAPABILITY_ERR_RE = /method\s*["']?(?:sendRichMessage|sendRichMessageDraft|editMessageText)["']?\s*not found|no such method|unknown method|method not found|(?:unknown|unsupported|unrecognized)\s+(?:parameter|field)s?\s*["']?rich_message["']?|rich_message\s*["']?\s*(?:is\s+)?(?:unknown|unsupported|unrecognized|not\s+supported)/i;
364
+
365
+ function errorMessage(err) {
366
+ if (!err) return '';
367
+ return String(err.description || err.message || err.error_message || err);
368
+ }
369
+
370
+ function isRichCapabilityError(err) {
371
+ if (!err) return false;
372
+ const msg = errorMessage(err);
373
+ if (RICH_CAPABILITY_ERR_RE.test(msg)) return true;
374
+ // A 404 is sufficient because these checks only wrap rich-message calls.
375
+ const status = err.error_code ?? err.status ?? err.statusCode;
376
+ return status === 404;
377
+ }
378
+
379
+ const RICH_CONTENT_ERR_RE = /RICH_MESSAGE_[A-Z_]+|can'?t parse (?:input)?rich\s*block|rich message.*(?:too (?:many|long)|invalid)/i;
380
+
381
+ function isRichContentError(err) {
382
+ if (!err) return false;
383
+ return RICH_CONTENT_ERR_RE.test(errorMessage(err));
384
+ }
385
+
386
+ module.exports = {
387
+ needsRichRendering,
388
+ toTelegramRichBlocks,
389
+ resolveRichTextEnabled,
390
+ isRichCapabilityError,
391
+ isRichContentError,
392
+ errorMessage,
393
+ // exported for tests / introspection, not part of the public contract
394
+ _plainTextOf: plainTextOf,
395
+ _blocksFromTokens: blocksFromTokens,
396
+ _stripFencedCodeBlocks: stripFencedCodeBlocks,
397
+ };
@@ -84,15 +84,37 @@ function createStreamer({
84
84
  // (only final bubble visible) for partner-facing chats that
85
85
  // prefer terse output.
86
86
  preserveIntermediateBubbles = true,
87
+ // Optional progressive rich-message rendering. A null callback keeps
88
+ // the streamer on its plain-text path.
89
+ //
90
+ // toRichPayload(text, { partial }) -> { blocks, usedRich } | null
91
+ // Called on every flush/finalize with the CURRENT latestText/finalText
92
+ // (untruncated — rich content is never char-truncated with a "..."
93
+ // the way plain live-preview text is; partial:true tells it to hold
94
+ // back a possibly-incomplete trailing block, see lib/telegram/rich.js).
95
+ // usedRich:false (or a null return) means "not rich this tick" — the
96
+ // streamer falls through to the existing plain-text path unchanged.
97
+ // The callback may return a different result on each call when live
98
+ // configuration or capability state changes.
99
+ toRichPayload = null,
100
+ // Rich messages have a larger Bot API character cap than plain
101
+ // messages, so rich edits and finalization must use this limit.
102
+ richMaxLen = 32768,
103
+ // Fires for each successful plain-to-rich transition. Caller wires this
104
+ // to instrumentation for the visible bubble-shape change.
105
+ onRichUpgrade = () => {},
87
106
  } = {}) {
88
107
  throttleMs = Math.max(THROTTLE_FLOOR_MS, throttleMs);
89
108
  let state = 'idle'; // 'idle' | 'live' | 'finalized'
90
109
  let msgId = null;
91
- let currentText = ''; // what's on screen right now (truncated to maxLen)
110
+ let currentText = ''; // what's on screen right now (truncated to maxLen) — plain-path only
111
+ let currentRichJson = null; // JSON.stringify of the last-sent rich blocks, or null when not in rich mode
92
112
  let latestText = ''; // latest we've been told about
93
113
  let lastEditTs = 0;
94
114
  let pendingEdit = null; // timer id
95
- let flushPromise = null; // ongoing edit promise (for back-pressure)
115
+ let flushPromise = null; // serialized flush chain (for back-pressure)
116
+ let flushQueued = false; // latest text changed while a flush was active
117
+ let bubbleGeneration = 0; // invalidates state commits from superseded bubbles
96
118
  // 0.7.2: msg_ids of bubbles that have been superseded by
97
119
  // forceNewMessage(). The caller (polygram.js handleMessage at
98
120
  // end-of-turn) reads getArchived() and issues deleteMessage on
@@ -121,7 +143,12 @@ function createStreamer({
121
143
  // which preserves all content without any byte-cut.
122
144
  function truncateForLive(s) {
123
145
  if (s.length <= maxLen) return s;
124
- return s.slice(0, maxLen - 3) + '...';
146
+ // Back off one unit if the cut would split a surrogate pair (emoji /
147
+ // astral chars) — a lone high surrogate renders as � in the live bubble.
148
+ let cut = maxLen - 3;
149
+ const cc = s.charCodeAt(cut - 1);
150
+ if (cc >= 0xD800 && cc <= 0xDBFF) cut -= 1;
151
+ return s.slice(0, cut) + '...';
125
152
  }
126
153
 
127
154
  // rc.67: scrub recognised inline tags BEFORE the streamer commits text
@@ -180,24 +207,103 @@ function createStreamer({
180
207
  pendingEdit = schedule(flush, delay);
181
208
  }
182
209
 
210
+ // Decide the rich-vs-plain payload for the CURRENT latestText/finalText.
211
+ // Shared by flush() and finalize() so the "only attempt rich under the
212
+ // rich char cap" guard and the upgrade-event firing live in one place.
213
+ function resolveRichPayload(text, { partial }) {
214
+ if (!toRichPayload) return null;
215
+ if (text.length > richMaxLen) {
216
+ // Edge case, not fully engineered (documented limitation): a
217
+ // mid-stream reply that's ALREADY past the rich cap while still
218
+ // growing. Rather than flap into a truncated/ellipsis rich render
219
+ // (which has no real analog — rich has no "..." convention), skip
220
+ // rich for this tick; finalize()'s own overflow check (against
221
+ // richMaxLen) is what actually decides discard-and-redeliver-plain
222
+ // once streaming ends.
223
+ return null;
224
+ }
225
+ let payload;
226
+ try {
227
+ payload = toRichPayload(text, { partial });
228
+ } catch (err) {
229
+ logger.error?.(`[stream] toRichPayload threw, falling back to plain: ${err.message}`);
230
+ return null;
231
+ }
232
+ return payload && payload.usedRich ? payload : null;
233
+ }
234
+
183
235
  async function flush() {
184
236
  pendingEdit = null;
185
237
  if (state !== 'live' || msgId == null) return;
186
- const next = truncateForLive(latestText);
187
- if (next === currentText) return;
238
+
239
+ if (flushPromise) {
240
+ flushQueued = true;
241
+ return flushPromise;
242
+ }
243
+
244
+ const run = (async () => {
245
+ do {
246
+ flushQueued = false;
247
+ await flushOnce();
248
+ } while (flushQueued && state === 'live' && msgId != null);
249
+ })();
250
+ flushPromise = run;
251
+ try {
252
+ await run;
253
+ } finally {
254
+ if (flushPromise === run) flushPromise = null;
255
+ }
256
+ }
257
+
258
+ async function flushOnce() {
259
+ if (state !== 'live' || msgId == null) return;
260
+ const editMsgId = msgId;
261
+ const generation = bubbleGeneration;
262
+ const sourceText = latestText;
263
+
264
+ const richPayload = resolveRichPayload(sourceText, { partial: true });
265
+ if (richPayload) {
266
+ const json = JSON.stringify(richPayload.blocks);
267
+ if (json === currentRichJson) return; // no structural change since last edit
268
+ const wasRich = currentRichJson !== null;
269
+ lastEditTs = clock();
270
+ try {
271
+ // Keep the source beside its blocks so a content-error fallback
272
+ // can render the same Markdown through the plain-text path.
273
+ const editResult = await edit(editMsgId, {
274
+ rich: true, blocks: richPayload.blocks, sourceText,
275
+ });
276
+ if (generation !== bubbleGeneration || msgId !== editMsgId) return;
277
+ // A rich editor can resolve successfully after sending the same
278
+ // source as plain fallback. Track what actually landed.
279
+ const wentRich = editResult == null || editResult.wentRich !== false;
280
+ if (wentRich) {
281
+ currentRichJson = json;
282
+ if (!wasRich) onRichUpgrade();
283
+ } else {
284
+ currentText = sourceText;
285
+ currentRichJson = null;
286
+ }
287
+ } catch (err) {
288
+ logger.error(`[stream] rich edit failed: ${err.message}`);
289
+ }
290
+ return;
291
+ }
292
+
293
+ const next = truncateForLive(sourceText);
294
+ if (currentRichJson === null && next === currentText) return;
188
295
  lastEditTs = clock();
189
- currentText = next;
190
296
  try {
191
- flushPromise = edit(msgId, currentText);
192
- await flushPromise;
297
+ await edit(editMsgId, next);
298
+ if (generation !== bubbleGeneration || msgId !== editMsgId) return;
299
+ currentText = next;
300
+ currentRichJson = null;
193
301
  } catch (err) {
194
302
  // Non-fatal — maybe 429 or transient. Log and keep going; next
195
303
  // chunk will retry. The HTML→plain fallback in lib/telegram.js
196
304
  // already handles the most common cause (parse error from
197
305
  // truncate cutting mid-tag).
198
306
  logger.error(`[stream] edit failed: ${err.message}`);
199
- } finally {
200
- flushPromise = null;
201
307
  }
202
308
  }
203
309
 
@@ -237,9 +343,12 @@ function createStreamer({
237
343
  }
238
344
  msgId = null;
239
345
  currentText = '';
346
+ currentRichJson = null; // new bubble starts plain — see toRichPayload doc
240
347
  latestText = '';
241
348
  state = 'idle';
242
349
  lastEditTs = 0;
350
+ flushQueued = false;
351
+ bubbleGeneration += 1;
243
352
  }
244
353
 
245
354
  // 0.7.0: delete the current bubble via the injected deleteMessage
@@ -306,6 +415,40 @@ function createStreamer({
306
415
  let body = applyTransform(finalText ?? latestText);
307
416
  if (errorSuffix) body = `${body}\n\n⚠️ ${errorSuffix}`;
308
417
 
418
+ // Finalization evaluates the complete body, so no trailing block is
419
+ // held back as potentially incomplete.
420
+ const richPayload = resolveRichPayload(body, { partial: false });
421
+ if (richPayload) {
422
+ // Rich finalization uses the rich-message cap; applying the smaller
423
+ // plain-message cap would unnecessarily redeliver valid rich bodies.
424
+ if (body.length > richMaxLen) {
425
+ return { streamed: true, msgId, finalText: body, finalEditOk: false, overflow: true };
426
+ }
427
+ const json = JSON.stringify(richPayload.blocks);
428
+ if (json === currentRichJson) {
429
+ return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
430
+ }
431
+ const wasRich = currentRichJson !== null;
432
+ try {
433
+ const editResult = await edit(msgId, { rich: true, blocks: richPayload.blocks, sourceText: body });
434
+ // A fallback still delivered the final content, but as plain text.
435
+ const wentRich = editResult == null || editResult.wentRich !== false;
436
+ if (wentRich) {
437
+ currentRichJson = json;
438
+ if (!wasRich) onRichUpgrade();
439
+ } else {
440
+ currentText = body;
441
+ currentRichJson = null;
442
+ }
443
+ return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
444
+ } catch (err) {
445
+ logger.error(`[stream] final rich edit failed: ${err.message}`);
446
+ return { streamed: true, msgId, finalText: body, finalEditOk: false, overflow: false };
447
+ }
448
+ }
449
+
450
+ // Plain finalization uses the regular message cap.
451
+ //
309
452
  // If body overflows the single-message cap, the caller needs to
310
453
  // discard this bubble and redeliver via chunks. Don't try to edit.
311
454
  if (body.length > maxLen) {
@@ -317,13 +460,14 @@ function createStreamer({
317
460
  // without redelivering. If it fails (e.g. parse error after our
318
461
  // wrapper exhausts its retry, or a 5xx), caller should discard
319
462
  // and redeliver — the bubble's content is unreliable.
320
- if (body === currentText) {
463
+ if (currentRichJson === null && body === currentText) {
321
464
  // Already correct — no edit needed.
322
465
  return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
323
466
  }
324
467
  try {
325
468
  await edit(msgId, body);
326
469
  currentText = body;
470
+ currentRichJson = null;
327
471
  return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
328
472
  } catch (err) {
329
473
  logger.error(`[stream] final edit failed: ${err.message}`);
@@ -348,6 +492,8 @@ function createStreamer({
348
492
  get state() { return state; },
349
493
  get msgId() { return msgId; },
350
494
  get currentText() { return currentText; },
495
+ get isRichMode() { return currentRichJson !== null; },
496
+ get currentRichBlocks() { return currentRichJson ? JSON.parse(currentRichJson) : null; },
351
497
  };
352
498
  }
353
499
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.17.11",
3
+ "version": "0.18.0",
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": {
@@ -63,6 +63,7 @@
63
63
  "dependencies": {
64
64
  "@anthropic-ai/claude-agent-sdk": "0.2.123",
65
65
  "@modelcontextprotocol/sdk": "^1.29.0",
66
+ "@shumkov/orchestra": "^0.4.0",
66
67
  "better-sqlite3": "^12.9.0",
67
68
  "grammy": "^1.42.0",
68
69
  "markdown-it": "^14.1.1",