polygram 0.12.0-rc.35 → 0.12.0-rc.37

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.
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Session-scoped feedback controller (0.13 D3,
5
+ * docs/0.13-channels-lifecycle-design.md §3 D3).
6
+ *
7
+ * The per-turn reactor/typing pair lives in handleMessage's closure and dies
8
+ * with the turn (ROOT C). D1 extended the turn to claude's real cycle end —
9
+ * which closed the dead-air class for PRIMARY turns — but cycles with NO
10
+ * pending turn still had zero feedback surface:
11
+ *
12
+ * - autonomous/wakeup cycles (ScheduleWakeup, fireUserMessage self-checks):
13
+ * minutes of work with nothing visible until text lands;
14
+ * - an injected follow-up picked up as its OWN next cycle: its message sat
15
+ * with no indicator while claude worked it.
16
+ *
17
+ * This controller owns those: a session-level typing loop for the cycle's
18
+ * duration, plus — when the InputLedger knows which message the cycle picked
19
+ * up — a 🤔 anchored to that message, cleared at cycle end. Inputs are the
20
+ * previously-unconsumed lifecycle edges: 'turn-start' (UPS) with no pending,
21
+ * and 'idle'/'close' as the end signals (wired via lib/sdk/callbacks.js).
22
+ *
23
+ * Per-turn feedback (reactor cascade, streamer, waiting-on-user typing pause)
24
+ * stays where it is — this controller deliberately covers only the
25
+ * no-pending gap; it never touches a session that has a head pending.
26
+ */
27
+
28
+ const { startTyping } = require('../telegram/typing');
29
+
30
+ function createSessionFeedback({
31
+ bot,
32
+ tg,
33
+ getChatIdFromKey,
34
+ getThreadIdFromKey,
35
+ botName,
36
+ typingIntervalMs = undefined, // override for tests; default = typing.js default
37
+ logEvent = () => {},
38
+ logger = console,
39
+ } = {}) {
40
+ const active = new Map(); // sessionKey → { stop, anchor: {chatId, msgId}|null }
41
+
42
+ function startAutonomousCycle(sessionKey, { anchorMsgId = null } = {}) {
43
+ if (active.has(sessionKey)) return;
44
+ const chatId = getChatIdFromKey(sessionKey);
45
+ if (!chatId || !bot) return;
46
+ const threadIdRaw = getThreadIdFromKey?.(sessionKey);
47
+ const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
48
+
49
+ const stop = startTyping({
50
+ bot, chatId,
51
+ ...(Number.isInteger(threadId) ? { threadId } : {}),
52
+ ...(typingIntervalMs ? { intervalMs: typingIntervalMs } : {}),
53
+ logger: { error: (m) => logger.error?.(`[${botName}] autonomous-typing: ${m}`) },
54
+ });
55
+
56
+ let anchor = null;
57
+ if (anchorMsgId != null) {
58
+ anchor = { chatId, msgId: Number(anchorMsgId) };
59
+ tg(bot, 'setMessageReaction', {
60
+ chat_id: chatId, message_id: anchor.msgId,
61
+ reaction: [{ type: 'emoji', emoji: '🤔' }],
62
+ }, { source: 'autonomous-cycle-anchor', botName }).catch(() => {});
63
+ }
64
+
65
+ active.set(sessionKey, { stop, anchor });
66
+ logEvent('autonomous-cycle-visuals', {
67
+ chat_id: chatId, session_key: sessionKey, state: 'start',
68
+ anchor_msg_id: anchor?.msgId ?? null,
69
+ });
70
+ }
71
+
72
+ function endCycle(sessionKey) {
73
+ const entry = active.get(sessionKey);
74
+ if (!entry) return;
75
+ active.delete(sessionKey);
76
+ try { entry.stop(); } catch { /* best-effort */ }
77
+ if (entry.anchor && bot) {
78
+ tg(bot, 'setMessageReaction', {
79
+ chat_id: entry.anchor.chatId, message_id: entry.anchor.msgId, reaction: [],
80
+ }, { source: 'autonomous-cycle-anchor-clear', botName }).catch(() => {});
81
+ }
82
+ logEvent('autonomous-cycle-visuals', {
83
+ chat_id: entry.anchor?.chatId ?? getChatIdFromKey(sessionKey),
84
+ session_key: sessionKey, state: 'end',
85
+ });
86
+ }
87
+
88
+ return { startAutonomousCycle, endCycle };
89
+ }
90
+
91
+ module.exports = { createSessionFeedback };
@@ -78,6 +78,7 @@ function createAutosteerHandlers({
78
78
  content: prompt,
79
79
  priority,
80
80
  msgId: msg.message_id,
81
+ source: 'autosteer', // 0.13 D2: ledger source — drop detection + redelivery eligibility
81
82
  });
82
83
  if (!ok) return { autosteered: false };
83
84
 
@@ -86,6 +87,9 @@ function createAutosteerHandlers({
86
87
  chat_id: chatId, msg_id: msg.message_id,
87
88
  text_len: prompt?.length ?? 0,
88
89
  priority,
90
+ // 0.13 P1: per-event backend. The 14d fold/drop investigation had to
91
+ // reconstruct the cli-vs-sdk split by joining chats — never again.
92
+ backend: typeof pm.getBackend === 'function' ? pm.getBackend(sessionKey) : null,
89
93
  });
90
94
  return { autosteered: true, priority };
91
95
  }
@@ -23,7 +23,7 @@ const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
23
23
  // polygram passes the alias (opus / sonnet / haiku) and lets claude
24
24
  // resolve. Bump on Claude release.
25
25
  const MODEL_VERSIONS_DESC = {
26
- opus: 'claude-opus-4-7',
26
+ opus: 'claude-opus-4-8',
27
27
  sonnet: 'claude-sonnet-4-6',
28
28
  haiku: 'claude-haiku-4-5',
29
29
  };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Drop-redeliverer (0.13 D2 → D4 glue, docs/0.13-channels-lifecycle-design.md §3 D2).
5
+ *
6
+ * Consumes CliProcess's 'input-dropped' event — a ledgered input that was
7
+ * confirmed dropped (never seen at pickup, never acknowledged via
8
+ * consumed_turn_ids, not superseded, with the ack contract observed in the
9
+ * cycle) — and redelivers it ONCE through the unified D4 tail.
10
+ *
11
+ * Eligibility (design's redelivery constraints):
12
+ * - `primary` and `autosteer` sources only: both reconstruct from the
13
+ * inbound DB row (recordInbound persisted the raw message), so the
14
+ * redelivered turn re-formats through the NORMAL prompt path — no stored
15
+ * prompt text, no double-formatting, events stay content-free (L13).
16
+ * - `edit-fold` / `system` / `inject` park as telemetry
17
+ * (input-dropped-no-redeliver): an edit correction has its own
18
+ * re-delivery path, and system pushes are never auto-re-executed.
19
+ *
20
+ * The D4 tail then enforces: once-only, _isReplay, the D5 gate at tier
21
+ * 'redelivery' (an abort/admin-shaped drop is never auto-re-executed), the
22
+ * visible 👀 ack, and dispatch. Supersession was already decided ledger-side.
23
+ */
24
+
25
+ function createDropRedeliverer({ db, redeliver, logEvent = () => {}, logger = console } = {}) {
26
+ if (typeof redeliver !== 'function') throw new TypeError('drop-redeliver: redeliver required');
27
+
28
+ return async function onInputDropped(sessionKey, payload = {}) {
29
+ try {
30
+ const { chatId, msgId, source, turnId } = payload;
31
+ if (source !== 'primary' && source !== 'autosteer') {
32
+ logEvent('input-dropped-no-redeliver', {
33
+ chat_id: chatId ?? null, msg_id: msgId ?? null, source: source ?? null,
34
+ turn_id: turnId ?? null, reason: 'source-not-redeliverable',
35
+ });
36
+ return;
37
+ }
38
+ if (msgId == null) {
39
+ logEvent('input-dropped-no-redeliver', {
40
+ chat_id: chatId ?? null, source, turn_id: turnId ?? null, reason: 'no-msg-id',
41
+ });
42
+ return;
43
+ }
44
+ const row = db.getMessage(String(chatId), Number(msgId));
45
+ if (!row) {
46
+ logEvent('input-dropped-no-redeliver', {
47
+ chat_id: chatId, msg_id: msgId, source, turn_id: turnId ?? null, reason: 'no-db-row',
48
+ });
49
+ return;
50
+ }
51
+ // Reconstruct the boot-replay way: enough of a grammy Message for the
52
+ // normal prompt/attachment path to re-run from the persisted row.
53
+ const reconstructed = {
54
+ chat: { id: Number(chatId), type: String(chatId).startsWith('-') ? 'supergroup' : 'private' },
55
+ message_id: Number(msgId),
56
+ from: { id: row.user_id, first_name: row.user },
57
+ text: row.text || '',
58
+ date: Math.floor((row.ts || Date.now()) / 1000),
59
+ ...(row.thread_id && { message_thread_id: Number(row.thread_id) }),
60
+ ...(row.reply_to_id && { reply_to_message: { message_id: row.reply_to_id } }),
61
+ };
62
+ await redeliver({ chatId: String(chatId), msg: reconstructed, source: 'drop' });
63
+ } catch (err) {
64
+ logger.error?.(`[drop-redeliver] ${err?.message || err}`);
65
+ }
66
+ };
67
+ }
68
+
69
+ module.exports = { createDropRedeliverer };
@@ -59,6 +59,8 @@ function createEditCorrectionInjector({
59
59
  const ok = pm.injectUserMessage(sessionKey, {
60
60
  content: `[edit] I corrected my previous message — it now reads: ${newText}`,
61
61
  priority: 'next',
62
+ msgId: editedMsg.message_id,
63
+ source: 'edit-fold', // 0.13 D2: ledgered (telemetry; edits have their own redelivery path)
62
64
  });
63
65
  if (!ok) {
64
66
  logger.error?.(`[${chatConfig.name || chatId}] edit-correction inject failed`);
@@ -40,82 +40,97 @@ function createEditRedelivery({
40
40
  // botUsername / mentionRe arrive at CALL time — see @returns. Constructing with them threw
41
41
  // `ReferenceError: mentionRe is not defined` at boot (rc.34): the factory runs in main() where
42
42
  // those createBot locals don't exist. The edited_message handler passes the live values.
43
+ //
44
+ // 0.13 D5 (spec §5 as written): the in-flight interlock is per-(chatId,msgId),
45
+ // not per-session. A re-edit of the SAME message while its re-dispatch runs
46
+ // folds via inject; an edit of a DIFFERENT message proceeds as its own
47
+ // redelivery (dispatchHandleMessage autosteers it naturally if a turn is in
48
+ // flight — through the formatted-prompt path, not a hand-built string).
49
+ const redeliveredAt = new Map(); // `${chatId}:${msgId}` → ts
50
+ const INTERLOCK_TTL_MS = 10 * 60 * 1000;
51
+
43
52
  return function maybePostTurnEdit(editedMsg, oldText, botUsername, mentionRe = null) {
44
- try {
45
- if (!editedMsg?.chat) return false;
46
- const chatId = editedMsg.chat.id.toString();
47
- const chatConfig = config.chats[chatId];
48
- if (!chatConfig) return false;
53
+ try {
54
+ if (!editedMsg?.chat) return false;
55
+ const chatId = editedMsg.chat.id.toString();
56
+ const chatConfig = config.chats[chatId];
57
+ if (!chatConfig) return false;
49
58
 
50
- // Per-chat / bot-level opt-out (shared with the mid-turn injector). Default on.
51
- const optOut = chatConfig.editCorrection != null
52
- ? chatConfig.editCorrection === false
53
- : config.bot?.editCorrection === false;
54
- if (optOut) return false;
59
+ // Per-chat / bot-level opt-out (shared with the mid-turn injector). Default on.
60
+ const optOut = chatConfig.editCorrection != null
61
+ ? chatConfig.editCorrection === false
62
+ : config.bot?.editCorrection === false;
63
+ if (optOut) return false;
55
64
 
56
- const newText = editedMsg.text || editedMsg.caption || '';
57
- if (!newText) return false; // blanked / media-only → nothing to act on
58
- // Changed-guard: skip metadata-only edits (link-preview load fires edited_message too). The
59
- // caller MUST have read oldText before recordInbound overwrote the row; null = unknown → proceed.
60
- if (oldText != null && oldText === newText) return false;
65
+ const newText = editedMsg.text || editedMsg.caption || '';
66
+ if (!newText) return false; // blanked / media-only → nothing to act on
67
+ // Changed-guard: skip metadata-only edits (link-preview load fires edited_message too). The
68
+ // caller MUST have read oldText before recordInbound overwrote the row; null = unknown → proceed.
69
+ if (oldText != null && oldText === newText) return false;
61
70
 
62
- const threadId = editedMsg.message_thread_id?.toString() || null;
63
- const sessionKey = getSessionKey(chatId, threadId, chatConfig);
71
+ const threadId = editedMsg.message_thread_id?.toString() || null;
72
+ const sessionKey = getSessionKey(chatId, threadId, chatConfig);
64
73
 
65
- // Interlock: a turn is in flight (most likely OUR own re-run, whose _isReplay row reads
66
- // not-live so the mid-turn injector skipped it and fell through to here). Fold the re-edit
67
- // via inject instead of spawning a SECOND re-dispatch turn for the same message.
68
- const proc = pm?.get?.(sessionKey);
69
- if (proc?.inFlight) {
70
- pm.injectUserMessage?.(sessionKey, {
71
- content: `[edit] I edited my message again — it now reads: ${newText}`,
72
- priority: 'next',
73
- msgId: editedMsg.message_id,
74
- });
75
- logEvent('edit-redelivery-folded', { chat_id: chatId, session_key: sessionKey, msg_id: editedMsg.message_id });
76
- return false;
77
- }
74
+ // Interlock (per-message, 0.13 D5): only a re-edit of a message whose OWN
75
+ // re-dispatch is still running folds via inject pre-0.13 this was
76
+ // per-session (any in-flight turn folded any edit, and it injected
77
+ // BEFORE the gate; the gate now runs upstream in the edited_message
78
+ // handler, so every path through here is already gated).
79
+ const proc = pm?.get?.(sessionKey);
80
+ const interlockKey = `${chatId}:${editedMsg.message_id}`;
81
+ const lastRedeliveredAt = redeliveredAt.get(interlockKey) || 0;
82
+ if (proc?.inFlight && (Date.now() - lastRedeliveredAt) < INTERLOCK_TTL_MS && lastRedeliveredAt > 0) {
83
+ pm.injectUserMessage?.(sessionKey, {
84
+ content: `[edit] I edited my message again — it now reads: ${newText}`,
85
+ priority: 'next',
86
+ msgId: editedMsg.message_id,
87
+ source: 'edit-fold', // 0.13 D2
88
+ });
89
+ logEvent('edit-redelivery-folded', { chat_id: chatId, session_key: sessionKey, msg_id: editedMsg.message_id });
90
+ return false;
91
+ }
78
92
 
79
- // GATE on the REAL edited message (its real from / new text / its own real reply_to, if any).
80
- // NOT the synthetic — a self-reply_to would trip shouldHandle.repliesToOtherUser and drop a
81
- // paired user editing an un-mentioned message in a mention-gated group.
82
- if (!shouldHandle(editedMsg, chatConfig, botUsername)) return false;
93
+ // GATE on the REAL edited message (its real from / new text / its own real reply_to, if any).
94
+ // NOT the synthetic — a self-reply_to would trip shouldHandle.repliesToOtherUser and drop a
95
+ // paired user editing an un-mentioned message in a mention-gated group.
96
+ if (!shouldHandle(editedMsg, chatConfig, botUsername)) return false;
83
97
 
84
- // Acknowledge immediately: a silent edit produces no new bubble, so show it registered before
85
- // claude's reply lands (the rc.33 lesson). Best-effort; never blocks the re-dispatch.
86
- try { react?.(chatId, editedMsg.message_id); } catch { /* best-effort */ }
98
+ // Acknowledge immediately: a silent edit produces no new bubble, so show it registered before
99
+ // claude's reply lands (the rc.33 lesson). Best-effort; never blocks the re-dispatch.
100
+ try { react?.(chatId, editedMsg.message_id); } catch { /* best-effort */ }
87
101
 
88
- // Synthetic turn: NEW text in the body, OLD text in the reply_to so claude sees the change.
89
- // reply_to_message carries `from` + `text` so resolveReplyTo takes the telegram branch and
90
- // renders the OLD text — NOT db.getMessage (which now holds the overwritten new text).
91
- const cleanNew = mentionRe ? newText.replace(mentionRe, '').trim() : newText.trim();
92
- const synthetic = {
93
- chat: editedMsg.chat,
102
+ // Synthetic turn: NEW text in the body, OLD text in the reply_to so claude sees the change.
103
+ // reply_to_message carries `from` + `text` so resolveReplyTo takes the telegram branch and
104
+ // renders the OLD text — NOT db.getMessage (which now holds the overwritten new text).
105
+ const cleanNew = mentionRe ? newText.replace(mentionRe, '').trim() : newText.trim();
106
+ const synthetic = {
107
+ chat: editedMsg.chat,
108
+ message_id: editedMsg.message_id,
109
+ from: editedMsg.from,
110
+ text: cleanNew,
111
+ date: editedMsg.date,
112
+ ...(threadId && { message_thread_id: Number(threadId) }),
113
+ reply_to_message: {
94
114
  message_id: editedMsg.message_id,
95
115
  from: editedMsg.from,
96
- text: cleanNew,
116
+ text: oldText || '',
97
117
  date: editedMsg.date,
98
- ...(threadId && { message_thread_id: Number(threadId) }),
99
- reply_to_message: {
100
- message_id: editedMsg.message_id,
101
- from: editedMsg.from,
102
- text: oldText || '',
103
- date: editedMsg.date,
104
- },
105
- _isReplay: true, // no new editable row, not replay-eligible, error reply suppressed
106
- };
107
- dispatchHandleMessage(sessionKey, chatId, synthetic, bot);
108
- logEvent('edit-redelivered', {
109
- chat_id: chatId, session_key: sessionKey, msg_id: editedMsg.message_id,
110
- old_len: (oldText || '').length, new_len: newText.length,
111
- });
112
- return true;
113
- } catch (e) {
114
- // Never throw out of the edited_message handler.
115
- logger.error?.(`[edit-redelivery] ${e.message}`);
116
- return false;
117
- }
118
- };
118
+ },
119
+ _isReplay: true, // no new editable row, not replay-eligible, error reply suppressed
120
+ };
121
+ redeliveredAt.set(interlockKey, Date.now());
122
+ dispatchHandleMessage(sessionKey, chatId, synthetic, bot);
123
+ logEvent('edit-redelivered', {
124
+ chat_id: chatId, session_key: sessionKey, msg_id: editedMsg.message_id,
125
+ old_len: (oldText || '').length, new_len: newText.length,
126
+ });
127
+ return true;
128
+ } catch (e) {
129
+ // Never throw out of the edited_message handler.
130
+ logger.error?.(`[edit-redelivery] ${e.message}`);
131
+ return false;
132
+ }
133
+ };
119
134
  }
120
135
 
121
136
  module.exports = { createEditRedelivery };
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * gateInbound — the ONE intake gate (0.13 D5, docs/0.13-channels-lifecycle-design.md §3 D4+D5).
5
+ *
6
+ * Pre-0.13, polygram had four gate depths (seam S11): the fresh-message chain in
7
+ * dispatchRegularMessage, edit-redelivery's bare shouldHandle, the mid-turn edit
8
+ * injector's none, and boot-replay's none. The divergences were themselves bugs:
9
+ * an edit to "/stop" was injected into the very turn it tried to kill; an edit
10
+ * during a free-text "Other" capture never became the answer; any group member's
11
+ * bare "stop" aborted others' turns pre-gate.
12
+ *
13
+ * Every entry point now runs the same ordered chain, with a tier flag declaring
14
+ * per stage whether it EVALUATES, EXECUTES side effects, or is SKIPPED:
15
+ *
16
+ * stage | fresh | edit | redelivery
17
+ * -----------------|------------------|------------------|--------------------------
18
+ * abort | eval + execute* | eval + execute* | eval, never exec → blocked
19
+ * admin / pair | eval + dispatch | eval + dispatch | eval, never exec → blocked
20
+ * rewind | eval + execute | skip | skip
21
+ * question-consume | eval + execute | eval + execute | skip (already consumed once)
22
+ * shouldHandle | evaluate | evaluate | evaluate
23
+ * final | dispatch | return 'pass' | return 'pass'
24
+ *
25
+ * * identity-gated: DM ‖ paired ‖ @mention ‖ reply-to-bot. Closes the
26
+ * pre-existing bystander-abort hole (abort ran before shouldHandle with
27
+ * zero identity checks — any group member's "stop" killed the in-flight
28
+ * turn) BEFORE the edit tier gains abort semantics.
29
+ *
30
+ * Return shape: { action: 'dispatched'|'handled'|'blocked'|'pass', stage?, reason? }
31
+ * dispatched — handed to dispatchHandleMessage (fresh final, admin stage)
32
+ * handled — a stage consumed it (abort executed, question answered, rewind)
33
+ * blocked — gate dropped it (caller logs; redelivery callers emit no-redeliver)
34
+ * pass — edit/redelivery tiers: caller owns the next step
35
+ *
36
+ * Late-bound deps are getters (botUsername/mentionRe are assigned after bot
37
+ * init; rewind/question handlers are wired late in main()) — the established
38
+ * `let x = null; wired in main()` pattern, made explicit.
39
+ */
40
+
41
+ const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context|compact)(\s|$)/;
42
+ const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
43
+
44
+ function createGateInbound({
45
+ config,
46
+ getBotUsername,
47
+ getMentionRe,
48
+ pairings = null,
49
+ isAbortRequest,
50
+ handleAbortIfRequested,
51
+ getRewindHandler = () => null,
52
+ isRewindCommand = () => false,
53
+ getQuestionHandlers = () => null,
54
+ shouldHandle,
55
+ getSessionKey,
56
+ dispatchHandleMessage,
57
+ bot,
58
+ botName,
59
+ logEvent = () => {},
60
+ logger = console,
61
+ } = {}) {
62
+ if (typeof shouldHandle !== 'function') throw new TypeError('gateInbound: shouldHandle required');
63
+ if (typeof dispatchHandleMessage !== 'function') throw new TypeError('gateInbound: dispatchHandleMessage required');
64
+ if (typeof getSessionKey !== 'function') throw new TypeError('gateInbound: getSessionKey required');
65
+
66
+ /**
67
+ * The abort identity gate: is this sender plausibly addressing the BOT
68
+ * (vs a teammate)? DM ‖ paired ‖ @mention ‖ reply-to-bot. Mirrors the
69
+ * shouldHandle signals but is evaluated BEFORE shouldHandle because abort
70
+ * (deliberately) outranks the mention gate for addressed senders.
71
+ */
72
+ function isAddressedIdentity(msg, chatId) {
73
+ if (msg.chat?.type === 'private') return true;
74
+ const botUsername = getBotUsername?.() || '';
75
+ const text = msg.text || msg.caption || '';
76
+ if (botUsername && text.includes(`@${botUsername}`)) return true;
77
+ if (botUsername && msg.reply_to_message?.from?.username === botUsername) return true;
78
+ if (pairings && msg.from?.id
79
+ && pairings.hasLivePairing({ bot_name: botName, user_id: msg.from.id, chat_id: chatId })) {
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+
85
+ return async function gateInbound(msg, { tier = 'fresh' } = {}) {
86
+ const chatId = msg.chat.id.toString();
87
+ const chatConfig = config.chats[chatId];
88
+ if (!chatConfig) return { action: 'blocked', stage: 'chat', reason: 'unconfigured chat' };
89
+
90
+ const mentionRe = getMentionRe?.();
91
+ const rawText = msg.text || '';
92
+ const cleanText = mentionRe ? rawText.replace(mentionRe, '').trim() : rawText.trim();
93
+ const threadId = msg.message_thread_id?.toString();
94
+ const sessionKey = getSessionKey(chatId, threadId, chatConfig);
95
+
96
+ // ── abort ────────────────────────────────────────────────────────────
97
+ if (typeof isAbortRequest === 'function' && isAbortRequest(cleanText)) {
98
+ if (tier === 'redelivery') {
99
+ // An auto-redelivered abort would execute in a context the user never
100
+ // intended (their original "stop" targeted work long since settled).
101
+ return { action: 'blocked', stage: 'abort', reason: 'abort-shaped content is never auto-re-executed' };
102
+ }
103
+ if (!isAddressedIdentity(msg, chatId)) {
104
+ logEvent('abort-identity-blocked', {
105
+ chat_id: chatId, msg_id: msg.message_id, user_id: msg.from?.id ?? null, tier,
106
+ });
107
+ return { action: 'blocked', stage: 'abort', reason: 'abort from unaddressed sender' };
108
+ }
109
+ const handled = await handleAbortIfRequested(msg, chatId, chatConfig, cleanText);
110
+ if (handled) return { action: 'handled', stage: 'abort' };
111
+ // The predicate matched but the handler declined (defensive) — fall through.
112
+ }
113
+
114
+ // ── admin command / pair claim ───────────────────────────────────────
115
+ const botAllowsCommands = !!config.bot?.allowConfigCommands;
116
+ const isAdminCmd = botAllowsCommands && ADMIN_CMD_RE.test(cleanText);
117
+ const isPairClaim = PAIR_CLAIM_RE.test(cleanText);
118
+ if (isAdminCmd || isPairClaim) {
119
+ if (tier === 'redelivery') {
120
+ return { action: 'blocked', stage: 'admin', reason: 'admin/pair-shaped content is never auto-re-executed' };
121
+ }
122
+ msg.text = cleanText;
123
+ // 0.13 D5: through the dispatcher wrapper — the admin path gains
124
+ // handler-error events, the in-flight counter, and terminal
125
+ // handler_status on throw (pre-P2 it called bare handleMessage and
126
+ // errors bubbled to grammy's bot.catch with the row left 'dispatched').
127
+ dispatchHandleMessage(sessionKey, chatId, msg, bot);
128
+ return { action: 'dispatched', stage: 'admin' };
129
+ }
130
+
131
+ // ── /rewind ── fresh only (an edited or replayed /rewind is nonsensical) ──
132
+ if (tier === 'fresh') {
133
+ const rewindHandler = getRewindHandler?.();
134
+ if (rewindHandler && isRewindCommand(cleanText)) {
135
+ try {
136
+ // Operator identity: explicit operatorUserId, else the admin user — a PRIVATE
137
+ // adminChatId equals that user's Telegram id. A group adminChatId (negative) is
138
+ // not a user id → never matches a positive sender id → default-deny.
139
+ const opId = config.bot?.operatorUserId;
140
+ const adminChatId = config.bot?.adminChatId;
141
+ const operatorUid = opId != null ? Number(opId) : (adminChatId != null ? Number(adminChatId) : null);
142
+ const isOperatorIdentity = operatorUid != null && msg.from?.id != null && Number(msg.from.id) === operatorUid;
143
+ const paired = pairings && msg.from?.id
144
+ ? pairings.hasLivePairing({ bot_name: botName, user_id: msg.from.id, chat_id: chatId })
145
+ : false;
146
+ const accessMode = chatConfig?.rewindAccess === 'paired' ? 'paired' : 'operator';
147
+ const rewindSafe = msg.chat?.type === 'private' || chatConfig?.isolateTopics === true;
148
+ const r = await rewindHandler.tryConsume({
149
+ sessionKey, chatId, threadId, msg, cleanText,
150
+ botUsername: getBotUsername?.() || '',
151
+ rewindSafe, isOperatorIdentity, paired, accessMode,
152
+ });
153
+ if (r.consumed) return { action: 'handled', stage: 'rewind' };
154
+ } catch (err) {
155
+ // The text IS a recognized /rewind — on an internal error, consume it
156
+ // anyway; falling through would send "/rewind" to claude as a prompt.
157
+ logger.error?.(`[${botName}] rewind tryConsume failed: ${err?.message || err}`);
158
+ return { action: 'handled', stage: 'rewind', reason: 'consumed-on-error' };
159
+ }
160
+ }
161
+ }
162
+
163
+ // ── question-consume / ownsOpenOther ── fresh + edit; redelivery skips
164
+ // (a replayed row already had its question-capture moment when fresh)
165
+ const questionHandlers = tier !== 'redelivery' ? getQuestionHandlers?.() : null;
166
+ const ownsOpenOther = questionHandlers
167
+ ? questionHandlers.isAwaitingOtherFrom(sessionKey, msg.from?.id)
168
+ : false;
169
+
170
+ // ── shouldHandle (mention/pairing gate) ── all tiers
171
+ if (!ownsOpenOther && !shouldHandle(msg, chatConfig, getBotUsername?.() || '')) {
172
+ return { action: 'blocked', stage: 'shouldHandle' };
173
+ }
174
+ if (getBotUsername?.()) msg.text = cleanText;
175
+
176
+ if (questionHandlers) {
177
+ const r = await questionHandlers.tryConsumeAsAnswer({ sessionKey, fromId: msg.from?.id, text: cleanText });
178
+ if (r.consumed) return { action: 'handled', stage: 'question-consume' };
179
+ }
180
+
181
+ // ── final ──
182
+ if (tier !== 'fresh') return { action: 'pass', sessionKey, chatId, cleanText };
183
+ dispatchHandleMessage(sessionKey, chatId, msg, bot);
184
+ return { action: 'dispatched' };
185
+ };
186
+ }
187
+
188
+ module.exports = { createGateInbound, ADMIN_CMD_RE, PAIR_CLAIM_RE };