polygram 0.5.3 → 0.5.4
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 +60 -20
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.4",
|
|
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 ───────────────────────────────────────────────
|
|
@@ -601,6 +610,7 @@ function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
|
|
|
601
610
|
}
|
|
602
611
|
handleMessage(sessionKey, chatId, msg, bot).catch((err) => {
|
|
603
612
|
const wasAborted = isSessionRecentlyAborted(sessionKey);
|
|
613
|
+
const isReplay = msg._isReplay === true;
|
|
604
614
|
console.error(`[${sessionKey}] Error:`, err.message);
|
|
605
615
|
// Mark the row as 'failed' so boot replay doesn't re-dispatch it.
|
|
606
616
|
// Exception: aborted sessions → 'aborted' (same — not replayable).
|
|
@@ -615,8 +625,13 @@ function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
|
|
|
615
625
|
error: err.message?.slice(0, 500),
|
|
616
626
|
stack: err.stack?.split('\n').slice(0, 5).join('\n'),
|
|
617
627
|
aborted: wasAborted || undefined,
|
|
628
|
+
replay: isReplay || undefined,
|
|
618
629
|
}), 'log handler-error');
|
|
619
|
-
|
|
630
|
+
// Suppress the "Sorry, I couldn't process" reply when this turn is a
|
|
631
|
+
// boot replay — the user typed this message minutes ago and has long
|
|
632
|
+
// moved on. A late apology just adds more noise to the post-restart
|
|
633
|
+
// chat (alongside the "Это снова реплей" cascade we're fixing).
|
|
634
|
+
if (!wasAborted && !isReplay) {
|
|
620
635
|
tg(bot, 'sendMessage', {
|
|
621
636
|
chat_id: chatId,
|
|
622
637
|
text: `Sorry, I couldn't process that message. The operator has been notified.`,
|
|
@@ -1129,9 +1144,14 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1129
1144
|
|
|
1130
1145
|
// Mark the inbound row as 'dispatched' so the boot replay loop knows
|
|
1131
1146
|
// this turn started. Cleared to 'replied' (or 'failed') when done.
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1147
|
+
// Replays are pre-marked 'replay-attempted' by the boot loop and we
|
|
1148
|
+
// must NOT overwrite that — it's the one-shot guard that keeps a
|
|
1149
|
+
// failing-mid-flight replay from re-replaying on every subsequent boot.
|
|
1150
|
+
if (!msg._isReplay) {
|
|
1151
|
+
dbWrite(() => db.setInboundHandlerStatus({
|
|
1152
|
+
chat_id: chatId, msg_id: msg.message_id, status: 'dispatched',
|
|
1153
|
+
}), 'set handler_status=dispatched');
|
|
1154
|
+
}
|
|
1135
1155
|
|
|
1136
1156
|
const text = msg.text || msg.caption || '';
|
|
1137
1157
|
const threadId = msg.message_thread_id;
|
|
@@ -1426,6 +1446,16 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1426
1446
|
// at which point we flip to THINKING (🤔).
|
|
1427
1447
|
reactor.setState('QUEUED');
|
|
1428
1448
|
|
|
1449
|
+
// Mark the inbound row terminal so boot replay doesn't pick it up again.
|
|
1450
|
+
// Must fire down EVERY non-throwing exit path (early returns for error/
|
|
1451
|
+
// NO_REPLY, streamed-reply early return, regular reply at end). 0.5.4
|
|
1452
|
+
// hardened this — earlier versions only marked at the bottom of try, so
|
|
1453
|
+
// streamed replies (which return at line ~1477) left handler_status
|
|
1454
|
+
// stuck at 'dispatched' forever, causing replay loops on every restart.
|
|
1455
|
+
const markReplied = () => dbWrite(() => db.setInboundHandlerStatus({
|
|
1456
|
+
chat_id: chatId, msg_id: msg.message_id, status: 'replied',
|
|
1457
|
+
}), 'set handler_status=replied');
|
|
1458
|
+
|
|
1429
1459
|
try {
|
|
1430
1460
|
// Pass streamer + reactor as per-turn context. pm's callbacks pick
|
|
1431
1461
|
// them off entry.pendingQueue[0].context so concurrent pendings each
|
|
@@ -1441,7 +1471,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1441
1471
|
if (result.error) {
|
|
1442
1472
|
console.error(`[${label}] Error (${elapsed}s):`, result.error);
|
|
1443
1473
|
reactor.setState('ERROR');
|
|
1444
|
-
if (!result.text) return;
|
|
1474
|
+
if (!result.text) { markReplied(); return; }
|
|
1445
1475
|
} else {
|
|
1446
1476
|
// Clear the progress reaction instead of stamping 👍 — the reply
|
|
1447
1477
|
// bubble itself is the "done" signal and a permanent thumbs-up on
|
|
@@ -1450,7 +1480,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1450
1480
|
reactor.clear().catch(() => {});
|
|
1451
1481
|
}
|
|
1452
1482
|
|
|
1453
|
-
if (!result.text || result.text === 'NO_REPLY') return;
|
|
1483
|
+
if (!result.text || result.text === 'NO_REPLY') { markReplied(); return; }
|
|
1454
1484
|
|
|
1455
1485
|
const parsed = parseResponse(result.text);
|
|
1456
1486
|
const outMeta = { ...outMetaBase, sessionId: result.sessionId, costUsd: result.cost };
|
|
@@ -1474,6 +1504,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1474
1504
|
}
|
|
1475
1505
|
}
|
|
1476
1506
|
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1507
|
+
markReplied();
|
|
1477
1508
|
return;
|
|
1478
1509
|
}
|
|
1479
1510
|
// Not streamed (response too short) — fall through to normal path.
|
|
@@ -1512,11 +1543,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1512
1543
|
}
|
|
1513
1544
|
|
|
1514
1545
|
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');
|
|
1546
|
+
markReplied();
|
|
1520
1547
|
} catch (err) {
|
|
1521
1548
|
// If the user just aborted this session, silently finalise the stream
|
|
1522
1549
|
// without the scary "⚠ stream interrupted" banner. The user has already
|
|
@@ -2256,6 +2283,19 @@ async function main() {
|
|
|
2256
2283
|
}
|
|
2257
2284
|
const chatConfig = config.chats[row.chat_id];
|
|
2258
2285
|
if (!chatConfig) { skipped += 1; continue; }
|
|
2286
|
+
// Tag the reconstructed message so dispatchHandleMessage knows
|
|
2287
|
+
// (a) to suppress the "Sorry I couldn't process" error reply on
|
|
2288
|
+
// failure and (b) to flag handler-error events as replay.
|
|
2289
|
+
reconstructed._isReplay = true;
|
|
2290
|
+
// Pre-mark 'replay-attempted' so even if this attempt is killed
|
|
2291
|
+
// mid-turn by yet another restart, the next boot won't replay it
|
|
2292
|
+
// again. Replay is one-shot — handleMessage will overwrite to
|
|
2293
|
+
// 'replied' on success, or the catch will overwrite to 'failed'.
|
|
2294
|
+
// Worst case (polygram dies before either): row stays
|
|
2295
|
+
// 'replay-attempted', getReplayCandidates skips it, no loop.
|
|
2296
|
+
dbWrite(() => db.setInboundHandlerStatus({
|
|
2297
|
+
chat_id: row.chat_id, msg_id: row.msg_id, status: 'replay-attempted',
|
|
2298
|
+
}), 'set handler_status=replay-attempted');
|
|
2259
2299
|
const sessionKey = getSessionKey(row.chat_id, row.thread_id, chatConfig);
|
|
2260
2300
|
dispatchHandleMessage(sessionKey, row.chat_id, reconstructed, bot);
|
|
2261
2301
|
replayed += 1;
|