polygram 0.38.2 → 0.38.3

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/polygram.js CHANGED
@@ -121,7 +121,7 @@ const { createRedeliver } = require('./lib/handlers/redeliver');
121
121
  const { classifyReplay, executeReplayPlan } = require('./lib/handlers/replay-disposition');
122
122
  const { createDropRedeliverer } = require('./lib/handlers/drop-redeliver');
123
123
  const { createSessionFeedback } = require('./lib/feedback/session-feedback');
124
- const { createSlashCommands } = require('./lib/handlers/slash-commands');
124
+ const { createSlashCommands, normalizeCompactCommand } = require('./lib/handlers/slash-commands');
125
125
  const {
126
126
  buildCodexReconciliationView,
127
127
  createHandleCodexReconciliationCallback,
@@ -1398,7 +1398,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1398
1398
  // /pair-code /pairings /unpair /pair. Returns true when handled;
1399
1399
  // caller short-circuits.
1400
1400
  if (await dispatchSlashCommand({
1401
- text, sessionKey, chatId, threadIdStr, chatConfig,
1401
+ text, msgId: msg.message_id, sessionKey, chatId, threadIdStr, chatConfig,
1402
1402
  cmdUser, cmdUserId, label, sendReply,
1403
1403
  })) return;
1404
1404
 
@@ -1511,7 +1511,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1511
1511
  console.error(`[${label}] failed to notify user of skipped attachments: ${err.message}`);
1512
1512
  logEvent('attachment-skip-notice-failed', {
1513
1513
  chat_id: chatId, msg_id: msg.message_id,
1514
- error: err.message?.slice(0, 200),
1514
+ error_class: err.name || 'Error',
1515
1515
  rejected_count: rejected.length,
1516
1516
  });
1517
1517
  }
@@ -1793,7 +1793,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1793
1793
  // Network errors can embed the request URL, which carries the
1794
1794
  // bot token — redact before persisting, matching rich-edit.js
1795
1795
  // and api.js's handling of the same error class.
1796
- api_error: redactBotToken(err.message)?.slice(0, 200),
1796
+ api_error_code: err.code || err.name || 'error',
1797
+ error_len: err.message?.length ?? 0,
1797
1798
  bot: BOT_NAME,
1798
1799
  });
1799
1800
  throw err;
@@ -2453,7 +2454,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2453
2454
  logEvent('wedged-session-detected', {
2454
2455
  chat_id: chatId, session_key: sessionKey,
2455
2456
  kind: wedge.kind,
2456
- text_preview: result.text.slice(0, 200),
2457
+ // The wrapper's classification is the forensic signal; the wrapped
2458
+ // payload is provider content and stays out of the row.
2459
+ text_len: result.text.length,
2457
2460
  });
2458
2461
  // Promote the wrapped error to result.error so the existing
2459
2462
  // auto-recover + thrown-error machinery handles it uniformly.
@@ -2474,6 +2477,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2474
2477
  // to type /new.
2475
2478
  const cls = classifyError(result.error);
2476
2479
  if (cls.autoRecover === 'reset_session') {
2480
+ // The process that owns any open question is going away with the
2481
+ // session. Retirement closes the row and drops what is held exactly
2482
+ // for it — that part runs synchronously, so starting it here is enough
2483
+ // to have happened before the reset. Only the card edit it leaves
2484
+ // behind is awaited, and the reset must not wait on Telegram.
2485
+ questionHandlers?.retireSession(sessionKey)
2486
+ ?.catch((err) => console.error(`[${label}] question retire failed: ${err.message}`));
2477
2487
  pm.resetSession(sessionKey, { reason: cls.kind })
2478
2488
  .catch((err) => console.error(`[${label}] auto-reset failed: ${err.message}`));
2479
2489
  logEvent('auto-recover', {
@@ -2648,7 +2658,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2648
2658
  reactor.setState('ERROR');
2649
2659
  logEvent('telegram-empty-response-fallback-failed', {
2650
2660
  chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
2651
- error: err.message?.slice(0, 200),
2661
+ error_class: err.name || 'Error',
2652
2662
  });
2653
2663
  throw new Error(`empty-response fallback send failed: ${err.message}`);
2654
2664
  }
@@ -2737,7 +2747,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2737
2747
  logEvent('canned-reply-suppressed', {
2738
2748
  chat_id: chatId,
2739
2749
  msg_id: msg.message_id,
2740
- original: sanitized.original,
2750
+ // The suppressed reply is provider content; its size is the signal.
2751
+ original_len: sanitized.original?.length ?? 0,
2741
2752
  backend: result?.backend || null,
2742
2753
  });
2743
2754
  parsed.text = sanitized.text;
@@ -3535,7 +3546,7 @@ function createBot(token) {
3535
3546
  bot: BOT_NAME,
3536
3547
  update_id: updateId,
3537
3548
  msg_id: msgId,
3538
- error: err.message?.slice(0, 300),
3549
+ error_class: err.name || 'Error',
3539
3550
  });
3540
3551
  });
3541
3552
 
@@ -3875,7 +3886,7 @@ async function main() {
3875
3886
  }
3876
3887
  } catch (err) {
3877
3888
  console.error(`[secret-sweep] FAILED (${trigger}): ${err.message}`);
3878
- try { db.logEvent('secret-sweep-failed', { trigger, error: err.message }); } catch {}
3889
+ try { db.logEvent('secret-sweep-failed', { trigger, error_class: err.name || 'Error' }); } catch {}
3879
3890
  }
3880
3891
  };
3881
3892
  if (secretSweepCfg.enabled) {
@@ -4046,6 +4057,20 @@ async function main() {
4046
4057
  answerQuestion: (sk, tc, result) => pm.answerQuestion(sk, tc, result),
4047
4058
  logger: console,
4048
4059
  });
4060
+ // A question whose answer was held in memory cannot be completed by this
4061
+ // process: the exact value went with the one that died. Cancel those rows
4062
+ // here — before replay, redelivery, provider recovery, polling or any other
4063
+ // path can act on them — so none is ever answered from a marker.
4064
+ // A failure here is fatal on purpose. Rows this could not cancel would meet
4065
+ // replay and inbound traffic with an answer no live process can complete, so
4066
+ // startup stops instead of admitting messages behind an open fence.
4067
+ try {
4068
+ await questionHandlers.reconcileMarkedQuestionsAtBoot(questionStore.listOpen(BOT_NAME));
4069
+ } catch (e) {
4070
+ console.error(`[${BOT_NAME}] question boot reconciliation failed; refusing to admit traffic: ${e.message}`);
4071
+ throw e;
4072
+ }
4073
+
4049
4074
  // Resolve expired questions with {timedout} so claude never hangs on an ignored ask.
4050
4075
  setInterval(() => {
4051
4076
  try {
@@ -4059,7 +4084,10 @@ async function main() {
4059
4084
  // the copy-only transcript-fork executor (P2/P3: fork → repoint the session → kill → delete
4060
4085
  // orphaned bot messages). channels/cli only; the fork mechanism was proven in P0.6.
4061
4086
  // See docs/0.13-rewind-design.md.
4062
- const executeRewind = createRewindExecutor({ db, pm, tg, bot, botName: BOT_NAME, logEvent, logger: console });
4087
+ const executeRewind = createRewindExecutor({
4088
+ db, pm, tg, bot, botName: BOT_NAME, logEvent, logger: console,
4089
+ retireQuestionSession: (sessionKey) => questionHandlers?.retireSession(sessionKey),
4090
+ });
4063
4091
  rewindHandler = createRewindHandler({
4064
4092
  pm, tg, bot, botName: BOT_NAME, logEvent, logger: console, executeRewind,
4065
4093
  });
@@ -4407,6 +4435,7 @@ async function main() {
4407
4435
  handleAbortIfRequested = createHandleAbort({
4408
4436
  pm, bot, tg, logEvent, isAbortRequest,
4409
4437
  markSessionAborted, clearAutosteeredReactions, getSessionKey,
4438
+ retireQuestionSession: (sessionKey) => questionHandlers?.retireSession(sessionKey),
4410
4439
  botName: BOT_NAME, logger: console,
4411
4440
  });
4412
4441
  autosteer = createAutosteerHandlers({
@@ -4514,6 +4543,7 @@ async function main() {
4514
4543
  modelVersionsDesc: MODEL_VERSIONS_DESC, saveConfig,
4515
4544
  resolveRuntimeView: resolveSessionRuntimeView,
4516
4545
  botName: BOT_NAME, logEvent, logger: console,
4546
+ retireQuestionSession: (sessionKey) => questionHandlers?.retireSession(sessionKey),
4517
4547
  });
4518
4548
  console.log('[polygram] using SDK ProcessManager');
4519
4549
 
@@ -4575,19 +4605,19 @@ async function main() {
4575
4605
  // provider; ordinary stops keep the existing answer-and-await behavior.
4576
4606
  try {
4577
4607
  const openQuestions = questionStore.listOpen?.(BOT_NAME) || [];
4578
- if (continuationAuthorized) {
4579
- for (const row of openQuestions) {
4580
- questionHandlers.beginShutdownDisposition(row, {
4581
- message: 'Bot is restarting — this question was cancelled. It may be re-asked in a moment.',
4582
- });
4583
- }
4584
- } else {
4585
- for (const row of openQuestions) {
4586
- // eslint-disable-next-line no-await-in-loop
4587
- await questionHandlers.expireQuestion(row, {
4588
- status: 'cancelled',
4589
- message: 'Bot is restarting — this question was cancelled. It may be re-asked in a moment.',
4590
- }).catch(() => {});
4608
+ const shutdownNotice = 'Bot is restarting — this question was cancelled. It may be re-asked in a moment.';
4609
+ // Each row is disposed on its own: one that throws must not take the
4610
+ // rows after it, nor the drop of everything held in memory, with it.
4611
+ for (const row of openQuestions) {
4612
+ try {
4613
+ if (continuationAuthorized) {
4614
+ questionHandlers.beginShutdownDisposition(row, { message: shutdownNotice });
4615
+ } else {
4616
+ // eslint-disable-next-line no-await-in-loop
4617
+ await questionHandlers.expireQuestion(row, { status: 'cancelled', message: shutdownNotice });
4618
+ }
4619
+ } catch (err) {
4620
+ console.error(`[shutdown] question ${row.id} disposition failed: ${err.message}`);
4591
4621
  }
4592
4622
  }
4593
4623
  if (openQuestions.length) {
@@ -4595,6 +4625,11 @@ async function main() {
4595
4625
  }
4596
4626
  } catch (err) {
4597
4627
  console.error(`[shutdown] question expiry failed: ${err.message}`);
4628
+ } finally {
4629
+ // Nothing exact survives the process on purpose, whatever any single row
4630
+ // did on the way out.
4631
+ try { questionHandlers.discardAll(); }
4632
+ catch (err) { console.error(`[shutdown] question context drop failed: ${err.message}`); }
4598
4633
  }
4599
4634
 
4600
4635
  // 1.6 Album siblings still buffered (arrived <flushMs before shutdown,
@@ -4687,6 +4722,7 @@ async function main() {
4687
4722
  awaitIngressSettlement,
4688
4723
  awaitHandlerSettlement,
4689
4724
  settlementTimeoutMs: SHUTDOWN_DRAIN_MS,
4725
+ continuationAuthorized,
4690
4726
  qualificationExpectation,
4691
4727
  });
4692
4728
  if (qualificationExpectation !== undefined) {
@@ -4799,7 +4835,7 @@ async function main() {
4799
4835
  logEvent('shutdown-persistence-fallback', {
4800
4836
  bot: BOT_NAME,
4801
4837
  replay_marked: fallback.replayMarked,
4802
- error: err.message?.slice(0, 200),
4838
+ error_class: err.name || 'Error',
4803
4839
  });
4804
4840
  } catch (fallbackErr) {
4805
4841
  console.error(
@@ -5286,18 +5322,17 @@ async function main() {
5286
5322
  console.error(`[replay] boot replay failed: ${err.message}`);
5287
5323
  }
5288
5324
 
5289
- // rc.61 + rc.65: handle compact-command events that were never
5290
- // paired with a compact-boundary (deploy / crash interrupted them
5291
- // before the SDK processed). rc.65 changes behaviour from
5292
- // "post a 'please retry' message and ask the user" to
5293
- // "silently retry by re-pushing the same /compact text to a
5294
- // freshly-spawned (resumed) Query." Same pattern as boot-replay
5295
- // for inbound messages — recovery should be invisible.
5325
+ // Handle compact-command events that were never paired with a
5326
+ // compact-boundary (a deploy or crash interrupted them before the SDK
5327
+ // processed them). Recovery re-pushes the same /compact line into a
5328
+ // freshly-spawned (resumed) session, invisibly — the same pattern as
5329
+ // boot-replay for inbound messages.
5296
5330
  //
5297
5331
  // Requirements for a silent retry:
5298
- // 1. The compact-command event was logged with full `text`
5299
- // (rc.65+ does this; pre-rc.65 events have only text_len —
5300
- // we fall back to the old "please retry" message for those).
5332
+ // 1. The command line is recoverable — from the inbound message row the
5333
+ // event points at, or from an older event that still stored it — and
5334
+ // it normalizes to a real /compact command. Otherwise the user gets
5335
+ // the "please retry" message instead.
5301
5336
  // 2. The chat is still configured (config.chats[chat_id] exists).
5302
5337
  // 3. The session has a saved claude_session_id we can resume.
5303
5338
  //
@@ -5325,9 +5360,13 @@ async function main() {
5325
5360
  const threadId = o.thread_id ? Number(o.thread_id) : null;
5326
5361
  const savedSessionId = getClaudeSessionId(db, o.session_key);
5327
5362
 
5328
- // Silent retry path: only when we have BOTH the original text
5329
- // (rc.65+) AND a session_id to resume into.
5330
- if (o.text && savedSessionId) {
5363
+ // Silent retry path: only when the stored line is provably a compact
5364
+ // command AND there is a session_id to resume into. The line comes back
5365
+ // from storage, so it is normalized and validated before it is fired at
5366
+ // a live session — a mention-suffixed command is accepted in its
5367
+ // normalized form, anything else is refused and surfaced instead.
5368
+ const replayCommand = normalizeCompactCommand(o.text);
5369
+ if (replayCommand && savedSessionId) {
5331
5370
  try {
5332
5371
  const entry = await pm.getOrSpawn(
5333
5372
  o.session_key,
@@ -5339,7 +5378,7 @@ async function main() {
5339
5378
  if (!entry || typeof entry.fireUserMessage !== 'function') {
5340
5379
  throw new Error('Process.fireUserMessage not available');
5341
5380
  }
5342
- const ok = entry.fireUserMessage(o.text);
5381
+ const ok = entry.fireUserMessage(replayCommand);
5343
5382
  if (!ok) {
5344
5383
  throw new Error('fireUserMessage refused (closed or empty content)');
5345
5384
  }
@@ -5348,8 +5387,7 @@ async function main() {
5348
5387
  thread_id: o.thread_id,
5349
5388
  session_key: o.session_key,
5350
5389
  original_ts: o.ts,
5351
- text_len: o.text.length,
5352
- user: o.user,
5390
+ text_len: replayCommand.length,
5353
5391
  user_id: o.user_id,
5354
5392
  });
5355
5393
  replayed += 1;
@@ -5360,9 +5398,9 @@ async function main() {
5360
5398
  }
5361
5399
  }
5362
5400
 
5363
- // Fallback: surface the legacy "please retry" message. Only
5364
- // happens for pre-rc.65 events (no `text` field) or when
5365
- // the silent-retry spawn failed.
5401
+ // Fallback: surface the "please retry" message. Happens when the hint
5402
+ // could not be recovered, when the stored line is not a compact
5403
+ // command, or when the silent retry failed.
5366
5404
  try {
5367
5405
  await tg(bot, 'sendMessage', {
5368
5406
  chat_id: o.chat_id,
@@ -5374,9 +5412,8 @@ async function main() {
5374
5412
  thread_id: o.thread_id,
5375
5413
  session_key: o.session_key,
5376
5414
  original_ts: o.ts,
5377
- user: o.user,
5378
5415
  user_id: o.user_id,
5379
- reason: o.text ? 'spawn-failed' : 'pre-rc65-event-no-text',
5416
+ reason: replayCommand ? 'spawn-failed' : (o.text ? 'not-a-compact-command' : 'no-recoverable-text'),
5380
5417
  });
5381
5418
  surfacedFallback += 1;
5382
5419
  } catch (err) {