polygram 0.5.3 → 0.5.5
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/lib/db.js +8 -5
- package/lib/telegram.js +6 -0
- package/package.json +1 -1
- package/polygram.js +75 -23
package/lib/db.js
CHANGED
|
@@ -74,10 +74,10 @@ function wrap(db) {
|
|
|
74
74
|
const insertOutboundPendingStmt = db.prepare(`
|
|
75
75
|
INSERT INTO messages (
|
|
76
76
|
chat_id, thread_id, user, text, direction, source, bot_name,
|
|
77
|
-
turn_id, session_id, status, ts, msg_id
|
|
77
|
+
turn_id, session_id, status, ts, msg_id, reply_to_id
|
|
78
78
|
) VALUES (
|
|
79
79
|
@chat_id, @thread_id, @user, @text, 'out', @source, @bot_name,
|
|
80
|
-
@turn_id, @session_id, 'pending', @ts, @pending_id
|
|
80
|
+
@turn_id, @session_id, 'pending', @ts, @pending_id, @reply_to_id
|
|
81
81
|
)
|
|
82
82
|
`);
|
|
83
83
|
|
|
@@ -198,6 +198,7 @@ function wrap(db) {
|
|
|
198
198
|
session_id: row.session_id || null,
|
|
199
199
|
ts: row.ts || Date.now(),
|
|
200
200
|
pending_id: row.pending_id,
|
|
201
|
+
reply_to_id: row.reply_to_id ?? null,
|
|
201
202
|
});
|
|
202
203
|
},
|
|
203
204
|
|
|
@@ -313,9 +314,11 @@ function wrap(db) {
|
|
|
313
314
|
|
|
314
315
|
// Find inbound messages that were being processed when polygram stopped.
|
|
315
316
|
// Scoped by bot_name via the chat_id → config mapping, so each bot only
|
|
316
|
-
// replays its own turns on boot. Scoped by olderThanMs (default
|
|
317
|
-
// so we never resurrect ancient messages
|
|
318
|
-
|
|
317
|
+
// replays its own turns on boot. Scoped by olderThanMs (default 3 min)
|
|
318
|
+
// so we never resurrect ancient messages — anything older than a few
|
|
319
|
+
// minutes is from before the user moved on, replaying it just confuses
|
|
320
|
+
// the conversation.
|
|
321
|
+
getReplayCandidates({ chatIds, olderThanMs = 3 * 60 * 1000, limit = 100 } = {}) {
|
|
319
322
|
if (!Array.isArray(chatIds) || chatIds.length === 0) return [];
|
|
320
323
|
const cutoff = Date.now() - olderThanMs;
|
|
321
324
|
const placeholders = chatIds.map(() => '?').join(',');
|
package/lib/telegram.js
CHANGED
|
@@ -114,6 +114,11 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
|
|
|
114
114
|
|
|
115
115
|
applyFormatting(method, params, meta);
|
|
116
116
|
|
|
117
|
+
// Capture which inbound this reply targets so the boot-replay dedupe
|
|
118
|
+
// (`hasOutboundReplyTo`) can match outbound→inbound. Without this every
|
|
119
|
+
// restart would re-dispatch already-answered messages.
|
|
120
|
+
const replyToId = params.reply_parameters?.message_id ?? null;
|
|
121
|
+
|
|
117
122
|
let rowId = null;
|
|
118
123
|
if (db && tracksMessage && chatId) {
|
|
119
124
|
const pendingId = nextPendingId();
|
|
@@ -128,6 +133,7 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
|
|
|
128
133
|
turn_id: meta.turnId || null,
|
|
129
134
|
session_id: meta.sessionId || null,
|
|
130
135
|
pending_id: pendingId,
|
|
136
|
+
reply_to_id: replyToId,
|
|
131
137
|
});
|
|
132
138
|
rowId = result?.lastInsertRowid ?? null;
|
|
133
139
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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
|
@@ -530,20 +530,29 @@ async function sendToProcess(sessionKey, prompt, context = {}) {
|
|
|
530
530
|
const chatConfig = config.chats[chatId];
|
|
531
531
|
const timeoutMs = (chatConfig.timeout || config.defaults.timeout) * 1000;
|
|
532
532
|
const maxTurnMs = (chatConfig.maxTurn || config.defaults?.maxTurn || 1800) * 1000;
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
533
|
+
// Hold the per-session lock across the FULL turn (write + result wait),
|
|
534
|
+
// not just the stdin write. Claude's stream-json input mode batches any
|
|
535
|
+
// user messages that arrive while a turn is in flight into the next
|
|
536
|
+
// turn — so writing pendingB's prompt while pendingA is still being
|
|
537
|
+
// worked on causes Claude to batch B+C and emit ONE result for them,
|
|
538
|
+
// leaving pendingC stuck forever (reactor stuck on 👀, reply mis-routed,
|
|
539
|
+
// 10-min idle timer eventually fires for the orphan).
|
|
540
|
+
//
|
|
541
|
+
// We tested this directly: 3 user messages written rapidly produced
|
|
542
|
+
// result#1="A" and result#2="B\nC" — pending#3 never got a result.
|
|
543
|
+
//
|
|
544
|
+
// Holding the lock across the whole turn means Claude never has more
|
|
545
|
+
// than one user message in its stdin buffer at once, so it can't batch.
|
|
546
|
+
// Cost: slight latency for back-to-back user messages — the second one
|
|
547
|
+
// waits for the first turn to finish before starting. The reactor on
|
|
548
|
+
// the queued message stays at 👀 (QUEUED) until its turn actually
|
|
549
|
+
// starts, which is the correct UX (and what the user already expects).
|
|
539
550
|
const release = await stdinLock.acquire(sessionKey);
|
|
540
|
-
let resultPromise;
|
|
541
551
|
try {
|
|
542
|
-
|
|
552
|
+
return await pm.send(sessionKey, prompt, { timeoutMs, maxTurnMs, context });
|
|
543
553
|
} finally {
|
|
544
554
|
release();
|
|
545
555
|
}
|
|
546
|
-
return resultPromise;
|
|
547
556
|
}
|
|
548
557
|
|
|
549
558
|
// ─── Message dispatch ───────────────────────────────────────────────
|
|
@@ -562,6 +571,17 @@ async function sendToProcess(sessionKey, prompt, context = {}) {
|
|
|
562
571
|
const CONCURRENT_WARN_THRESHOLD = 20;
|
|
563
572
|
const inFlightHandlers = new Map(); // sessionKey → count
|
|
564
573
|
|
|
574
|
+
// Set true by the SIGTERM/SIGINT handler. Module-scoped so the
|
|
575
|
+
// fire-and-forget catch in dispatchHandleMessage can check it: when
|
|
576
|
+
// polygram is going down, in-flight handlers reject with "Process
|
|
577
|
+
// killed" / "Process exited" but those failures aren't "real" — the
|
|
578
|
+
// next boot's replay will re-dispatch them. Suppressing the user-facing
|
|
579
|
+
// "Sorry, I couldn't process" during shutdown removes a misleading
|
|
580
|
+
// post-mortem apology that the user shouldn't have seen in the first
|
|
581
|
+
// place. (The boot replay's own _isReplay flag handles the OTHER half:
|
|
582
|
+
// suppressing the apology if the replay itself fails.)
|
|
583
|
+
let isShuttingDown = false;
|
|
584
|
+
|
|
565
585
|
// Sessions the operator just /stop'd (or natural-language "стоп"). Keyed
|
|
566
586
|
// by sessionKey → timestamp of abort. ANY pending that rejects within
|
|
567
587
|
// ABORT_GRACE_MS of the mark is considered abort-caused — its generic
|
|
@@ -601,6 +621,7 @@ function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
|
|
|
601
621
|
}
|
|
602
622
|
handleMessage(sessionKey, chatId, msg, bot).catch((err) => {
|
|
603
623
|
const wasAborted = isSessionRecentlyAborted(sessionKey);
|
|
624
|
+
const isReplay = msg._isReplay === true;
|
|
604
625
|
console.error(`[${sessionKey}] Error:`, err.message);
|
|
605
626
|
// Mark the row as 'failed' so boot replay doesn't re-dispatch it.
|
|
606
627
|
// Exception: aborted sessions → 'aborted' (same — not replayable).
|
|
@@ -615,8 +636,15 @@ function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
|
|
|
615
636
|
error: err.message?.slice(0, 500),
|
|
616
637
|
stack: err.stack?.split('\n').slice(0, 5).join('\n'),
|
|
617
638
|
aborted: wasAborted || undefined,
|
|
639
|
+
replay: isReplay || undefined,
|
|
618
640
|
}), 'log handler-error');
|
|
619
|
-
|
|
641
|
+
// Suppress the "Sorry, I couldn't process" reply when:
|
|
642
|
+
// - boot replay (user typed this minutes ago and moved on)
|
|
643
|
+
// - polygram is shutting down (the failure is "Process killed" /
|
|
644
|
+
// "Process exited" which isn't a real error — boot replay will
|
|
645
|
+
// re-dispatch it on next start)
|
|
646
|
+
// - user just /stop'd (already saw their abort acknowledgement)
|
|
647
|
+
if (!wasAborted && !isReplay && !isShuttingDown) {
|
|
620
648
|
tg(bot, 'sendMessage', {
|
|
621
649
|
chat_id: chatId,
|
|
622
650
|
text: `Sorry, I couldn't process that message. The operator has been notified.`,
|
|
@@ -1129,9 +1157,14 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1129
1157
|
|
|
1130
1158
|
// Mark the inbound row as 'dispatched' so the boot replay loop knows
|
|
1131
1159
|
// this turn started. Cleared to 'replied' (or 'failed') when done.
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1160
|
+
// Replays are pre-marked 'replay-attempted' by the boot loop and we
|
|
1161
|
+
// must NOT overwrite that — it's the one-shot guard that keeps a
|
|
1162
|
+
// failing-mid-flight replay from re-replaying on every subsequent boot.
|
|
1163
|
+
if (!msg._isReplay) {
|
|
1164
|
+
dbWrite(() => db.setInboundHandlerStatus({
|
|
1165
|
+
chat_id: chatId, msg_id: msg.message_id, status: 'dispatched',
|
|
1166
|
+
}), 'set handler_status=dispatched');
|
|
1167
|
+
}
|
|
1135
1168
|
|
|
1136
1169
|
const text = msg.text || msg.caption || '';
|
|
1137
1170
|
const threadId = msg.message_thread_id;
|
|
@@ -1426,6 +1459,16 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1426
1459
|
// at which point we flip to THINKING (🤔).
|
|
1427
1460
|
reactor.setState('QUEUED');
|
|
1428
1461
|
|
|
1462
|
+
// Mark the inbound row terminal so boot replay doesn't pick it up again.
|
|
1463
|
+
// Must fire down EVERY non-throwing exit path (early returns for error/
|
|
1464
|
+
// NO_REPLY, streamed-reply early return, regular reply at end). 0.5.4
|
|
1465
|
+
// hardened this — earlier versions only marked at the bottom of try, so
|
|
1466
|
+
// streamed replies (which return at line ~1477) left handler_status
|
|
1467
|
+
// stuck at 'dispatched' forever, causing replay loops on every restart.
|
|
1468
|
+
const markReplied = () => dbWrite(() => db.setInboundHandlerStatus({
|
|
1469
|
+
chat_id: chatId, msg_id: msg.message_id, status: 'replied',
|
|
1470
|
+
}), 'set handler_status=replied');
|
|
1471
|
+
|
|
1429
1472
|
try {
|
|
1430
1473
|
// Pass streamer + reactor as per-turn context. pm's callbacks pick
|
|
1431
1474
|
// them off entry.pendingQueue[0].context so concurrent pendings each
|
|
@@ -1441,7 +1484,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1441
1484
|
if (result.error) {
|
|
1442
1485
|
console.error(`[${label}] Error (${elapsed}s):`, result.error);
|
|
1443
1486
|
reactor.setState('ERROR');
|
|
1444
|
-
if (!result.text) return;
|
|
1487
|
+
if (!result.text) { markReplied(); return; }
|
|
1445
1488
|
} else {
|
|
1446
1489
|
// Clear the progress reaction instead of stamping 👍 — the reply
|
|
1447
1490
|
// bubble itself is the "done" signal and a permanent thumbs-up on
|
|
@@ -1450,7 +1493,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1450
1493
|
reactor.clear().catch(() => {});
|
|
1451
1494
|
}
|
|
1452
1495
|
|
|
1453
|
-
if (!result.text || result.text === 'NO_REPLY') return;
|
|
1496
|
+
if (!result.text || result.text === 'NO_REPLY') { markReplied(); return; }
|
|
1454
1497
|
|
|
1455
1498
|
const parsed = parseResponse(result.text);
|
|
1456
1499
|
const outMeta = { ...outMetaBase, sessionId: result.sessionId, costUsd: result.cost };
|
|
@@ -1474,6 +1517,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1474
1517
|
}
|
|
1475
1518
|
}
|
|
1476
1519
|
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1520
|
+
markReplied();
|
|
1477
1521
|
return;
|
|
1478
1522
|
}
|
|
1479
1523
|
// Not streamed (response too short) — fall through to normal path.
|
|
@@ -1512,11 +1556,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1512
1556
|
}
|
|
1513
1557
|
|
|
1514
1558
|
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1515
|
-
|
|
1516
|
-
// pick it up again on restart.
|
|
1517
|
-
dbWrite(() => db.setInboundHandlerStatus({
|
|
1518
|
-
chat_id: chatId, msg_id: msg.message_id, status: 'replied',
|
|
1519
|
-
}), 'set handler_status=replied');
|
|
1559
|
+
markReplied();
|
|
1520
1560
|
} catch (err) {
|
|
1521
1561
|
// If the user just aborted this session, silently finalise the stream
|
|
1522
1562
|
// without the scary "⚠ stream interrupted" banner. The user has already
|
|
@@ -2130,10 +2170,9 @@ async function main() {
|
|
|
2130
2170
|
// replay picks it up. Prevents "Sorry, I couldn't process that message"
|
|
2131
2171
|
// from showing on every restart.
|
|
2132
2172
|
const SHUTDOWN_DRAIN_MS = 30_000;
|
|
2133
|
-
let shuttingDown = false;
|
|
2134
2173
|
const shutdown = async () => {
|
|
2135
|
-
if (
|
|
2136
|
-
|
|
2174
|
+
if (isShuttingDown) return;
|
|
2175
|
+
isShuttingDown = true;
|
|
2137
2176
|
console.log('\nShutting down...');
|
|
2138
2177
|
// 1. Stop accepting new inbound first so nothing new queues behind the drain.
|
|
2139
2178
|
if (bot && bot._stop) bot._stop();
|
|
@@ -2256,6 +2295,19 @@ async function main() {
|
|
|
2256
2295
|
}
|
|
2257
2296
|
const chatConfig = config.chats[row.chat_id];
|
|
2258
2297
|
if (!chatConfig) { skipped += 1; continue; }
|
|
2298
|
+
// Tag the reconstructed message so dispatchHandleMessage knows
|
|
2299
|
+
// (a) to suppress the "Sorry I couldn't process" error reply on
|
|
2300
|
+
// failure and (b) to flag handler-error events as replay.
|
|
2301
|
+
reconstructed._isReplay = true;
|
|
2302
|
+
// Pre-mark 'replay-attempted' so even if this attempt is killed
|
|
2303
|
+
// mid-turn by yet another restart, the next boot won't replay it
|
|
2304
|
+
// again. Replay is one-shot — handleMessage will overwrite to
|
|
2305
|
+
// 'replied' on success, or the catch will overwrite to 'failed'.
|
|
2306
|
+
// Worst case (polygram dies before either): row stays
|
|
2307
|
+
// 'replay-attempted', getReplayCandidates skips it, no loop.
|
|
2308
|
+
dbWrite(() => db.setInboundHandlerStatus({
|
|
2309
|
+
chat_id: row.chat_id, msg_id: row.msg_id, status: 'replay-attempted',
|
|
2310
|
+
}), 'set handler_status=replay-attempted');
|
|
2259
2311
|
const sessionKey = getSessionKey(row.chat_id, row.thread_id, chatConfig);
|
|
2260
2312
|
dispatchHandleMessage(sessionKey, row.chat_id, reconstructed, bot);
|
|
2261
2313
|
replayed += 1;
|