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 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 30 min)
317
- // so we never resurrect ancient messages after a long outage.
318
- getReplayCandidates({ chatIds, olderThanMs = 30 * 60 * 1000, limit = 100 } = {}) {
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",
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
- // Per-session stdin lock orders the write step, not the result-wait.
534
- // pm.send's Promise executor writes stdin synchronously, so as soon as
535
- // pm.send returns (not resolves returns), the stdin write has
536
- // happened. We release the lock right after that and await the result
537
- // OUTSIDE the lock otherwise one long turn would serialise the whole
538
- // session, which is what we're trying to escape.
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
- resultPromise = pm.send(sessionKey, prompt, { timeoutMs, maxTurnMs, context });
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
- if (!wasAborted) {
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
- dbWrite(() => db.setInboundHandlerStatus({
1133
- chat_id: chatId, msg_id: msg.message_id, status: 'dispatched',
1134
- }), 'set handler_status=dispatched');
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
- // Success: mark the inbound row 'replied' so boot replay doesn't
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 (shuttingDown) return;
2136
- shuttingDown = true;
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;