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 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.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
- // 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 ───────────────────────────────────────────────
@@ -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
- if (!wasAborted) {
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
- dbWrite(() => db.setInboundHandlerStatus({
1133
- chat_id: chatId, msg_id: msg.message_id, status: 'dispatched',
1134
- }), 'set handler_status=dispatched');
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
- // 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');
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;