polygram 0.9.0-rc.5 → 0.9.0-rc.7

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.9.0-rc.5",
4
+ "version": "0.9.0-rc.7",
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",
@@ -60,6 +60,20 @@ const PATTERNS = {
60
60
  // "input...missing" or "missing...input" within a tool_use mention.
61
61
  missingToolInput: /tool[_ ]use.*(input.*missing|missing.*input)|missing tool call input|tool input required/i,
62
62
 
63
+ // Anthropic API rejected an image content block in the conversation.
64
+ // 2026-05-13: shumabit Dina DM hit this after accumulating 53 images
65
+ // over 2 weeks — every new turn 400'd with raw API JSON dumped to
66
+ // the user. Most common cause: a persisted image in the resumed
67
+ // transcript that the API now considers invalid (model snapshot
68
+ // drift, expired URL, bad base64, dimension/size cap, format the
69
+ // API stopped accepting). Recovery: reset_session — /compact has
70
+ // to load the same history so it usually fails too.
71
+ //
72
+ // Anchor on the literal Anthropic phrase to avoid false positives
73
+ // on routine "image" mentions. Tightened by requiring an
74
+ // image-failure verb co-located with "image" or "photo".
75
+ imageProcess: /(could not process|cannot process|failed to (process|load|decode)|unsupported|invalid|corrupt(?:ed)?)[^\n]{0,80}\b(image|photo)\b|\b(image|photo)\b[^\n]{0,80}(could not process|failed to (process|load|decode)|is (invalid|corrupted|unsupported))/i,
76
+
63
77
  // Idle/wall-clock timeout from polygram's pm timers, OR
64
78
  // model-side timeout. Mapped to a single class; user message is
65
79
  // identical either way.
@@ -87,6 +101,7 @@ const USER_MESSAGES = {
87
101
  contextOverflow: '📚 Conversation got too long. Send /new to start fresh.',
88
102
  roleOrdering: '⚠️ Conversation got into a tangled state. Try /new.',
89
103
  missingToolInput: '⚠️ Session history looks corrupted. Try /new.',
104
+ imageProcess: '🖼 One of the images in this conversation can\'t be re-processed by Claude — likely an older one in the history. Starting a fresh session for this chat.',
90
105
  timeout: '⏳ I went quiet too long without finishing. Try resending or simplifying.',
91
106
  format: '⚠️ Invalid request format. Try rephrasing or /new.',
92
107
  // Used both for in-flight retry attempts AND for the post-retry-failed
@@ -106,6 +121,7 @@ const AUTO_RECOVER = {
106
121
  roleOrdering: 'reset_session',
107
122
  contextOverflow: 'reset_session',
108
123
  missingToolInput: 'reset_session',
124
+ imageProcess: 'reset_session',
109
125
  };
110
126
 
111
127
  // Typed-code short-circuits — set on errors polygram throws itself
@@ -300,8 +316,45 @@ function isTransientHttpError(err) {
300
316
  return classify(err).isTransient;
301
317
  }
302
318
 
319
+ // 2026-05-13: detect the case where SDK reports result_subtype=success
320
+ // BUT the assistant text is actually an API error JSON the SDK wrapped
321
+ // as if it were the model's reply. Happens when the resumed session's
322
+ // transcript has data the API can't reload — most commonly an image
323
+ // content block. Pattern is distinctive ("API Error: <code> {...
324
+ // type:error ...}") so false positives are unlikely.
325
+ //
326
+ // When this matches:
327
+ // - The text we'd deliver to Telegram is garbage (raw JSON).
328
+ // - The Claude session is wedged — every future turn will fail the
329
+ // same way until reset.
330
+ // Returns the same shape as classify() so callers can use it uniformly,
331
+ // or null when the text looks like a legitimate assistant reply.
332
+ const WEDGED_SESSION_RE = /^API Error: \d{3}\s+\{[^}]*"type"\s*:\s*"error"/;
333
+
334
+ function detectWedgedSessionError(text) {
335
+ if (typeof text !== 'string' || text.length === 0) return null;
336
+ if (!WEDGED_SESSION_RE.test(text)) return null;
337
+ // Once we've confirmed it's a wrapped API error, run classify on the
338
+ // text — it picks up imageProcess / rateLimit / billing / etc. from
339
+ // the JSON body. classify is robust to JSON-ish input.
340
+ const cls = classify(new Error(text));
341
+ // If classify fell through to 'unknown', return a safe imageProcess
342
+ // shape — wedged sessions are most commonly image-driven and the
343
+ // recovery action (reset_session) is correct for all wedge classes.
344
+ if (cls.kind === 'unknown') {
345
+ return {
346
+ kind: 'imageProcess',
347
+ userMessage: USER_MESSAGES.imageProcess,
348
+ isTransient: false,
349
+ autoRecover: 'reset_session',
350
+ };
351
+ }
352
+ return cls;
353
+ }
354
+
303
355
  module.exports = {
304
356
  classify,
357
+ detectWedgedSessionError,
305
358
  isTransientHttpError,
306
359
  PATTERNS,
307
360
  USER_MESSAGES,
@@ -41,7 +41,13 @@ function createDispatcher({
41
41
  autoResumeTracker, // lib/db/auto-resume.js instance
42
42
  chunkMarkdownText, // lib/telegram/chunk.js
43
43
  deliverReplies, // lib/telegram/deliver.js
44
- TG_MAX_LEN = 4096,
44
+ // Raw-markdown size budget for chunkMarkdownText. Set BELOW Telegram's
45
+ // 4096 hard limit to leave headroom for HTML inflation (toTelegramHtml
46
+ // adds <b>/<i>/<code> tags + entity escapes; ~10-15% in practice).
47
+ // Polygram passes TG_CHUNK_BUDGET (default 3500). Test default keeps
48
+ // the historic 4096 for back-compat in synthetic test runs that pass
49
+ // pre-formatted text.
50
+ chunkBudget = 4096,
45
51
  // State accessors (need late binding because polygram.js mutates):
46
52
  getIsShuttingDown, // () → boolean
47
53
  logger = console,
@@ -119,7 +125,7 @@ function createDispatcher({
119
125
 
120
126
  // 4. Send the continuation reply as regular Telegram messages,
121
127
  // threaded under the original user message.
122
- const chunks = chunkMarkdownText(result.text, TG_MAX_LEN);
128
+ const chunks = chunkMarkdownText(result.text, chunkBudget);
123
129
  await deliverReplies({
124
130
  bot,
125
131
  send: (b, method, params, m) => tg(b, method, params, m),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.9.0-rc.5",
3
+ "version": "0.9.0-rc.7",
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
@@ -75,7 +75,7 @@ const { startTyping } = require('./lib/telegram/typing');
75
75
  // consumer is lib/handlers/download.js.
76
76
  const { createReactionManager, classifyToolName } = require('./lib/telegram/reactions');
77
77
  const { createMediaGroupBuffer } = require('./lib/media-group-buffer');
78
- const { classify: classifyError, isTransientHttpError } = require('./lib/error/classify');
78
+ const { classify: classifyError, detectWedgedSessionError, isTransientHttpError } = require('./lib/error/classify');
79
79
  const { createAutoResumeTracker, isAutoResumable } = require('./lib/db/auto-resume');
80
80
  const { resolveReplayWindowMs } = require('./lib/db/replay-window');
81
81
  // validateIpcFileParam moved with handleSendOverIpc to
@@ -109,6 +109,15 @@ const CLAUDE_BIN = process.env.POLYGRAM_CLAUDE_BIN
109
109
  || path.join(process.env.HOME || '', '.npm-global/bin/claude');
110
110
  const CHILD_HOME = process.env.POLYGRAM_CHILD_HOME || process.env.HOME || '';
111
111
  const TG_MAX_LEN = 4096;
112
+ // 0.9.0-rc.6: chunker budget is intentionally lower than TG_MAX_LEN to
113
+ // leave HTML headroom. toTelegramHtml converts markdown to HTML for
114
+ // parse_mode=HTML — that conversion adds <b>/<i>/<code>/<a> tags and
115
+ // entity-escapes &/</> chars, inflating length by ~10-15% for realistic
116
+ // markdown. 2026-05-11 incident proved a 4044-char chunk inflated to
117
+ // 4506 HTML chars and Telegram rejected. 3500 raw → max ~4030 HTML on
118
+ // observed inputs, with headroom for adversarial code-heavy text.
119
+ // Override via POLYGRAM_CHUNK_BUDGET if your traffic profile differs.
120
+ const TG_CHUNK_BUDGET = Number.parseInt(process.env.POLYGRAM_CHUNK_BUDGET, 10) || 3500;
112
121
  const DEFAULT_MAX_WARM_PROCS = 10;
113
122
 
114
123
  let stickerMap = {}; // name → file_id
@@ -1010,14 +1019,41 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1010
1019
 
1011
1020
  stopTyping();
1012
1021
 
1022
+ // 2026-05-13 Dina-DM incident: SDK reports result_subtype=success
1023
+ // but the "assistant text" is actually an API error JSON the SDK
1024
+ // wrapped (e.g. wedged image content block in resumed transcript).
1025
+ // Without this guard, polygram delivers the raw JSON to Telegram
1026
+ // as the bot's reply AND never resets the session — every
1027
+ // subsequent turn loops the same wedge.
1028
+ //
1029
+ // Sniff result.text for the wrapper pattern BEFORE the error/text
1030
+ // branch decisions. When detected: synthesize an Error so the
1031
+ // standard result.error path runs (classification → reset_session
1032
+ // → friendly user message → no raw-JSON delivery).
1033
+ if (!result.error && detectWedgedSessionError(result.text)) {
1034
+ const wedge = detectWedgedSessionError(result.text);
1035
+ logEvent('wedged-session-detected', {
1036
+ chat_id: chatId, session_key: sessionKey,
1037
+ kind: wedge.kind,
1038
+ text_preview: result.text.slice(0, 200),
1039
+ });
1040
+ // Promote the wrapped error to result.error so the existing
1041
+ // auto-recover + thrown-error machinery handles it uniformly.
1042
+ // Clear result.text so downstream delivery doesn't send the
1043
+ // raw JSON.
1044
+ result.error = result.text;
1045
+ result.text = '';
1046
+ }
1047
+
1013
1048
  if (result.error) {
1014
1049
  console.error(`[${label}] Error (${elapsed}s):`, result.error);
1015
1050
  reactor.setState('ERROR');
1016
1051
  // 0.8.0 Phase 2 step 8: classifier-driven auto-recovery. If
1017
1052
  // the error kind has autoRecover === 'reset_session' (i.e.
1018
- // role_ordering / context_overflow / missing_tool_input),
1019
- // tell pm to reset the session NOW so the user's NEXT
1020
- // message starts fresh — without them having to type /new.
1053
+ // role_ordering / context_overflow / missing_tool_input /
1054
+ // imageProcess), tell pm to reset the session NOW so the
1055
+ // user's NEXT message starts fresh — without them having
1056
+ // to type /new.
1021
1057
  const cls = classifyError(result.error);
1022
1058
  if (cls.autoRecover === 'reset_session') {
1023
1059
  pm.resetSession(sessionKey, { reason: cls.kind })
@@ -1253,7 +1289,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1253
1289
  // send the body as proper chunks.
1254
1290
  try { await streamer.discard(); }
1255
1291
  catch (err) { console.error(`[${label}] discard failed: ${err.message}`); }
1256
- const chunks = chunkMarkdownText(parsed.text, TG_MAX_LEN);
1292
+ const chunks = chunkMarkdownText(parsed.text, TG_CHUNK_BUDGET);
1257
1293
  const r = await deliverReplies({
1258
1294
  bot,
1259
1295
  send: (b, method, params, m) => tg(b, method, params, m),
@@ -1319,7 +1355,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1319
1355
  // 0.7.0: use markdown-aware chunker + deliverReplies primitive.
1320
1356
  // The old chunkText was newline/byte-only; chunkMarkdownText also
1321
1357
  // respects code-fence boundaries (closes + reopens across chunks).
1322
- const chunks = chunkMarkdownText(parsed.text, TG_MAX_LEN);
1358
+ const chunks = chunkMarkdownText(parsed.text, TG_CHUNK_BUDGET);
1323
1359
  await deliverReplies({
1324
1360
  bot,
1325
1361
  send: (b, method, params, m) => tg(b, method, params, m),
@@ -1990,7 +2026,7 @@ async function main() {
1990
2026
  classifyError, isAutoResumable,
1991
2027
  abortGrace, autoResumeTracker,
1992
2028
  chunkMarkdownText, deliverReplies,
1993
- TG_MAX_LEN,
2029
+ chunkBudget: TG_CHUNK_BUDGET,
1994
2030
  getIsShuttingDown: () => isShuttingDown,
1995
2031
  logger: console,
1996
2032
  }));