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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/error/classify.js +53 -0
- package/lib/handlers/dispatcher.js +8 -2
- package/package.json +1 -1
- package/polygram.js +43 -7
|
@@ -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.
|
|
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",
|
package/lib/error/classify.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
1020
|
-
// message starts fresh — without them having
|
|
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,
|
|
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,
|
|
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
|
-
|
|
2029
|
+
chunkBudget: TG_CHUNK_BUDGET,
|
|
1994
2030
|
getIsShuttingDown: () => isShuttingDown,
|
|
1995
2031
|
logger: console,
|
|
1996
2032
|
}));
|