polygram 0.12.0-rc.8 → 0.12.0

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.
Files changed (46) hide show
  1. package/config.example.json +4 -3
  2. package/lib/claude-bin.js +14 -1
  3. package/lib/compaction-warn.js +59 -0
  4. package/lib/context-usage.js +93 -0
  5. package/lib/db.js +1 -1
  6. package/lib/error/classify.js +33 -10
  7. package/lib/feedback/session-feedback.js +91 -0
  8. package/lib/handlers/abort.js +87 -40
  9. package/lib/handlers/autosteer.js +4 -0
  10. package/lib/handlers/config-callback.js +25 -6
  11. package/lib/handlers/config-ui.js +39 -10
  12. package/lib/handlers/dispatcher.js +83 -0
  13. package/lib/handlers/download.js +101 -58
  14. package/lib/handlers/drop-redeliver.js +69 -0
  15. package/lib/handlers/edit-correction.js +2 -0
  16. package/lib/handlers/edit-redelivery.js +136 -0
  17. package/lib/handlers/gate-inbound.js +188 -0
  18. package/lib/handlers/questions.js +289 -0
  19. package/lib/handlers/redeliver.js +122 -0
  20. package/lib/handlers/slash-commands.js +43 -30
  21. package/lib/history-preload.js +6 -0
  22. package/lib/history.js +7 -1
  23. package/lib/model-costs.js +4 -0
  24. package/lib/process/channels-bridge-protocol.js +22 -1
  25. package/lib/process/channels-bridge.mjs +128 -7
  26. package/lib/process/channels-tool-dispatcher.js +105 -12
  27. package/lib/process/cli-process.js +1277 -70
  28. package/lib/process/hook-event-tail.js +7 -0
  29. package/lib/process/hook-settings.js +7 -0
  30. package/lib/process/process.js +22 -0
  31. package/lib/process-guard.js +57 -1
  32. package/lib/process-manager.js +120 -35
  33. package/lib/questions/questions.js +187 -0
  34. package/lib/questions/store.js +105 -0
  35. package/lib/rewind/execute.js +89 -0
  36. package/lib/rewind/fork.js +112 -0
  37. package/lib/rewind/rewind.js +174 -0
  38. package/lib/sdk/callbacks.js +165 -167
  39. package/lib/session-key.js +29 -0
  40. package/lib/telegram/album-reactions.js +50 -0
  41. package/lib/telegram/parse.js +9 -2
  42. package/lib/telegram/typing.js +17 -2
  43. package/lib/tmux/startup-gate.js +44 -14
  44. package/migrations/012-pending-questions.sql +30 -0
  45. package/package.json +1 -1
  46. package/polygram.js +224 -78
@@ -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 };
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Interactive-question handlers (0.12 ask feature) — the integration glue between
3
+ * the pure state machine (lib/questions/questions.js), the store
4
+ * (lib/questions/store.js), Telegram, and the bridge answer write-back.
5
+ *
6
+ * renderAsk — claude called `ask`: issue a row, send the keyboard.
7
+ * handleQuestionCallback — a `q:` button tap: validate, mutate, advance/resolve.
8
+ * tryConsumeAsAnswer — dispatcher hook: a typed message while a question is
9
+ * open (free-text "Other", or a nudge for the wrong user).
10
+ * expireQuestion — timeout sweep / cancel: answer {timedout}/{cancelled}, strip.
11
+ *
12
+ * Security (review): per-row 128-bit token in callback_data + claim-on-first-tap
13
+ * respondent authz + plain-text body (no parse_mode) for agent content.
14
+ *
15
+ * Anti-hang invariant (review-hardened): claude's `ask` tool call must be answered
16
+ * EXACTLY once and never hang. Therefore every terminal path hands the result to
17
+ * claude *first* (guarded) and only then marks the row terminal — `finalize()`. If
18
+ * the write-back THROWS, the row is LEFT pending so the timeout sweep can recover
19
+ * with {timedout} (never resolved-but-hung). If the write-back is an undelivered
20
+ * NO-OP (returns false — session gone / no live bridge), it is surfaced loudly and
21
+ * the row is still resolved (a dead session can't be delivered; the bridge's own
22
+ * 20-min ceiling backstops the rare live-proc case). A failed Telegram send is also
23
+ * hang-safe: we answer {cancelled} rather than leaving a pending row with no
24
+ * on-screen keyboard. renderAsk that throws BEFORE issuing a row answers {cancelled}
25
+ * itself (no row exists for the sweep to find); tryConsumeAsAnswer never throws out
26
+ * of the dispatcher (a store error degrades to "not an answer", never a dropped msg).
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const Q = require('../questions/questions');
32
+ const { tokensEqual } = require('../questions/store');
33
+
34
+ function createQuestionHandlers({
35
+ questions, // store (lib/questions/store.js)
36
+ tg,
37
+ bot,
38
+ botName,
39
+ logEvent = () => {},
40
+ answerQuestion, // (sessionKey, toolCallId, result) → write question_answer to the bridge
41
+ logger = console,
42
+ } = {}) {
43
+
44
+ function strip(chatId, msgId, threadId, text) {
45
+ if (msgId == null) return Promise.resolve();
46
+ return tg(bot, 'editMessageText', {
47
+ chat_id: chatId, message_id: msgId, text,
48
+ ...(threadId && { message_thread_id: threadId }),
49
+ }, { source: 'question-edit', botName })
50
+ .catch((e) => logger.error?.(`[${botName}] question strip failed: ${e.message}`));
51
+ }
52
+
53
+ async function sendCurrent(row, state) {
54
+ const view = Q.renderCurrent(state, `q:${row.id}:${row.callback_token}`);
55
+ if (!view) return null;
56
+ // PLAIN-TEXT (no parse_mode): option labels/descriptions are agent-authored.
57
+ const sent = await tg(bot, 'sendMessage', {
58
+ chat_id: row.chat_id, text: view.text, reply_markup: view.reply_markup,
59
+ ...(row.thread_id && { message_thread_id: row.thread_id }),
60
+ }, { source: 'question', botName }).catch((e) => {
61
+ logger.error?.(`[${botName}] question send failed: ${e.message}`);
62
+ return null;
63
+ });
64
+ return sent?.message_id ?? null;
65
+ }
66
+
67
+ /**
68
+ * Hand the result to claude FIRST (guarded), then mark the row terminal. On
69
+ * write-back failure: leave the row pending (the timeout sweep recovers it) and
70
+ * return false — NEVER resolved-but-hung. Returns true when claude was answered.
71
+ */
72
+ function finalize(row, result, status = 'answered') {
73
+ let delivered;
74
+ try {
75
+ delivered = answerQuestion?.(row.session_key, row.tool_call_id, result);
76
+ } catch (e) {
77
+ logger.error?.(`[${botName}] answerQuestion failed for ${row.tool_call_id}: ${e.message}`);
78
+ return false; // threw → leave the row pending; the timeout sweep recovers it
79
+ }
80
+ // A *false* return is a silent no-op (session gone / no live bridge), NOT a
81
+ // throw. Surface it loudly and still resolve: a dead session can never be
82
+ // delivered, so leaving it pending would have the 30s sweep re-strip +
83
+ // re-answer it forever. The rare live-proc-but-unwritable-bridge case is
84
+ // backstopped by the bridge's own 20-min answer ceiling.
85
+ if (delivered === false) {
86
+ logger.error?.(`[${botName}] answerQuestion undelivered (session gone / no bridge) for ${row.tool_call_id}`);
87
+ logEvent('question-answer-undelivered', { session_key: row.session_key, tool_call_id: row.tool_call_id });
88
+ }
89
+ questions.resolve(row.id, status);
90
+ return true;
91
+ }
92
+
93
+ // ── claude called ask → render the first question ──────────────────
94
+ async function renderAsk({ sessionKey, chatId, threadId = null, turnId = null, toolCallId, questions: qs }) {
95
+ let row = null;
96
+ try {
97
+ // Idempotency: a bridge retry of the same tool_call_id must not double-render.
98
+ if (questions.getByToolCallId(toolCallId)) return null;
99
+
100
+ if (!Array.isArray(qs) || qs.length === 0) {
101
+ try { answerQuestion?.(sessionKey, toolCallId, { answers: [] }); } catch (e) {
102
+ logger.error?.(`[${botName}] answerQuestion(empty) failed: ${e.message}`);
103
+ }
104
+ return null;
105
+ }
106
+ // One open question per session: cancel + unblock any prior open ask first.
107
+ const prior = questions.getOpenForSession(sessionKey);
108
+ if (prior) {
109
+ finalize(prior, { cancelled: true }, 'cancelled');
110
+ const pIds = JSON.parse(prior.message_ids_json || '[]');
111
+ if (pIds[0]) strip(prior.chat_id, pIds[0], prior.thread_id, 'This question was replaced.');
112
+ }
113
+ const state = Q.initState(qs);
114
+ row = questions.issue({
115
+ bot_name: botName, session_key: sessionKey, chat_id: chatId, thread_id: threadId,
116
+ turn_id: turnId, tool_call_id: toolCallId, questions: qs, state,
117
+ });
118
+ const msgId = await sendCurrent(row, state);
119
+ if (msgId == null) {
120
+ // Couldn't deliver the keyboard — don't strand claude on a pending row.
121
+ finalize(row, { cancelled: true, error: 'failed to deliver the question' }, 'cancelled');
122
+ logEvent('question-send-failed', { session_key: sessionKey, tool_call_id: toolCallId, phase: 'first' });
123
+ return null;
124
+ }
125
+ questions.setMessageIds(row.id, [msgId]);
126
+ logEvent('question-asked', { session_key: sessionKey, chat_id: chatId, tool_call_id: toolCallId, count: qs.length });
127
+ return row;
128
+ } catch (e) {
129
+ logger.error?.(`[${botName}] renderAsk failed for ${toolCallId}: ${e.message}`);
130
+ // Anti-hang: a throw BEFORE the row was issued (store error, etc.) leaves
131
+ // claude blocked with no row for the sweep to recover → answer {cancelled}
132
+ // now. If the row WAS issued (throw in a later step), it is pending and the
133
+ // timeout sweep will recover it with {timedout}.
134
+ if (!row) {
135
+ try { answerQuestion?.(sessionKey, toolCallId, { cancelled: true, error: 'failed to render question' }); } catch (e2) {
136
+ logger.error?.(`[${botName}] renderAsk fallback answer failed for ${toolCallId}: ${e2.message}`);
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ }
142
+
143
+ // ── a `q:<id>:<token>:<action>` button tap ─────────────────────────
144
+ async function handleQuestionCallback(ctx) {
145
+ const data = ctx.callbackQuery?.data || '';
146
+ const m = String(data).match(/^q:(\d+):([^:]+):(.+)$/);
147
+ if (!m) return;
148
+ const id = parseInt(m[1], 10);
149
+ const token = m[2];
150
+ const actionStr = m[3];
151
+
152
+ const row = questions.getById(id);
153
+ if (!row) { await ack(ctx, 'This question expired.', true); return; }
154
+ if (!tokensEqual(row.callback_token, token)) {
155
+ logEvent('question-token-mismatch', { id, from_user: ctx.from?.id });
156
+ await ack(ctx, 'Bad token.', true); return;
157
+ }
158
+ if (row.status !== 'pending') { await ack(ctx, `Already ${row.status}.`, true); return; }
159
+
160
+ // Respondent authorization: first tapper claims the question; others rejected.
161
+ const auth = questions.claimOrCheck(id, ctx.from?.id);
162
+ if (!auth.ok) {
163
+ logEvent('question-foreign-responder', { id, from_user: ctx.from?.id, owner: row.from_id });
164
+ await ack(ctx, 'This question is for someone else.', true); return;
165
+ }
166
+
167
+ const state = JSON.parse(row.state_json);
168
+ const res = Q.applyTap(state, Q.parseAction(actionStr));
169
+ const msgId = (JSON.parse(row.message_ids_json || '[]'))[0];
170
+
171
+ if (res.kind === 'reject') { await ack(ctx, res.message, true); return; }
172
+
173
+ if (res.kind === 'toggled') {
174
+ questions.updateState(id, res.state, false);
175
+ const view = Q.renderCurrent(res.state, `q:${id}:${token}`);
176
+ await tg(bot, 'editMessageReplyMarkup', {
177
+ chat_id: row.chat_id, message_id: msgId, reply_markup: view.reply_markup,
178
+ ...(row.thread_id && { message_thread_id: row.thread_id }),
179
+ }, { source: 'question-edit', botName })
180
+ .catch((e) => logger.error?.(`[${botName}] toggle re-render failed (q ${id}): ${e.message}`));
181
+ await ack(ctx);
182
+ return;
183
+ }
184
+
185
+ if (res.kind === 'awaiting-other') {
186
+ questions.updateState(id, res.state, true);
187
+ await strip(row.chat_id, msgId, row.thread_id, 'Send your answer as a message.');
188
+ await ack(ctx, 'Type your answer ↓');
189
+ return;
190
+ }
191
+
192
+ // advanced — record + receipt, then next question or finish.
193
+ await advance(ctx, row, res, false);
194
+ }
195
+
196
+ // ── a typed message while a question is open (Other / nudge) ────────
197
+ async function tryConsumeAsAnswer({ sessionKey, fromId, text }) {
198
+ try {
199
+ const row = questions.getOpenForSession(sessionKey);
200
+ if (!row) return { consumed: false };
201
+ // Only an in-progress free-text capture diverts typed messages. A question
202
+ // awaiting a BUTTON tap does not swallow ordinary chatter (review: do not eat
203
+ // every group member's message for the whole question lifetime).
204
+ if (!row.awaiting_other) return { consumed: false };
205
+ // /stop, /new and other commands are never consumed as a free-text answer.
206
+ if (/^\/(stop|new|reset|cancel|abort)\b/i.test(String(text || '').trim())) return { consumed: false };
207
+ // Identity: only the claimed owner supplies the free-text answer.
208
+ const auth = questions.claimOrCheck(row.id, fromId);
209
+ if (!auth.ok) {
210
+ tg(bot, 'sendMessage', { chat_id: row.chat_id, text: 'Please answer the open question above first.',
211
+ ...(row.thread_id && { message_thread_id: row.thread_id }) }, { source: 'question-nudge', botName })
212
+ .catch((e) => logger.error?.(`[${botName}] question nudge failed: ${e.message}`));
213
+ return { consumed: true };
214
+ }
215
+ const state = JSON.parse(row.state_json);
216
+ const res = Q.applyFreeText(state, text);
217
+ if (res.kind !== 'advanced') return { consumed: false };
218
+ await advance({ from: { id: fromId } }, row, res, true);
219
+ return { consumed: true };
220
+ } catch (e) {
221
+ // Never throw out of the message dispatcher: a store/parse error here must
222
+ // degrade to "not an answer" so the user's message still reaches normal
223
+ // dispatch instead of being silently dropped.
224
+ logger.error?.(`[${botName}] tryConsumeAsAnswer failed: ${e.message}`);
225
+ return { consumed: false };
226
+ }
227
+ }
228
+
229
+ // Does `fromId` own an open free-text ("Other") capture for this session? The
230
+ // dispatcher uses this to let the owner's typed answer bypass a group's mention
231
+ // gate — only the claimed owner, never a bystander.
232
+ function isAwaitingOtherFrom(sessionKey, fromId) {
233
+ if (fromId == null) return false;
234
+ try {
235
+ const row = questions.getOpenForSession(sessionKey);
236
+ return !!(row && row.awaiting_other && row.from_id != null && Number(row.from_id) === Number(fromId));
237
+ } catch { return false; }
238
+ }
239
+
240
+ // ── timeout sweep / external cancel ────────────────────────────────
241
+ async function expireQuestion(row, { status = 'timeout', message = 'This question timed out.' } = {}) {
242
+ const ids = JSON.parse(row.message_ids_json || '[]');
243
+ if (ids[0]) await strip(row.chat_id, ids[0], row.thread_id, message);
244
+ const result = status === 'cancelled' ? { cancelled: true } : { timedout: true };
245
+ finalize(row, result, status);
246
+ logEvent('question-expired', { session_key: row.session_key, tool_call_id: row.tool_call_id, status });
247
+ }
248
+
249
+ // shared: record an advanced result, post a receipt, then next-Q or resolve.
250
+ async function advance(ctx, row, res, fromText) {
251
+ const msgId = (JSON.parse(row.message_ids_json || '[]'))[0];
252
+ // Visible receipt on the question card — for BOTH a button tap and a typed "Other"
253
+ // answer. The typed-answer path used to skip this entirely, so a free-text answer (esp.
254
+ // the LAST question) left the card frozen on "Send your answer as a message." with no
255
+ // acknowledgment — "I answered and nothing happened" (prod: hire topic 2026-06-09).
256
+ await strip(row.chat_id, msgId, row.thread_id, `✓ ${res.receipt}`);
257
+ if (!fromText) await ack(ctx, 'Recorded'); // callback-query ack — button taps only
258
+
259
+ if (res.done) {
260
+ questions.updateState(row.id, res.state, false);
261
+ // Answer claude FIRST (guarded), THEN mark answered. If the write-back
262
+ // throws, leave the row pending → the timeout sweep recovers it.
263
+ if (!finalize(row, Q.assemble(res.state), 'answered')) {
264
+ logEvent('question-answer-writeback-failed', { session_key: row.session_key, tool_call_id: row.tool_call_id });
265
+ return;
266
+ }
267
+ logEvent('question-answered', { session_key: row.session_key, tool_call_id: row.tool_call_id });
268
+ return;
269
+ }
270
+ // next question → new message; on send failure, don't strand claude.
271
+ const nextMsgId = await sendCurrent(row, res.state);
272
+ if (nextMsgId == null) {
273
+ finalize(row, { cancelled: true, error: 'failed to deliver the next question' }, 'cancelled');
274
+ logEvent('question-send-failed', { session_key: row.session_key, tool_call_id: row.tool_call_id, phase: 'next', q_index: res.state.qIndex });
275
+ return;
276
+ }
277
+ questions.updateState(row.id, res.state, false);
278
+ questions.setMessageIds(row.id, [nextMsgId]);
279
+ }
280
+
281
+ function ack(ctx, text, alert = false) {
282
+ if (!ctx || typeof ctx.answerCallbackQuery !== 'function') return Promise.resolve();
283
+ return ctx.answerCallbackQuery(text ? { text, show_alert: alert } : undefined).catch(() => {});
284
+ }
285
+
286
+ return { renderAsk, handleQuestionCallback, tryConsumeAsAnswer, expireQuestion, isAwaitingOtherFrom };
287
+ }
288
+
289
+ module.exports = { createQuestionHandlers };
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * redeliverAsFreshTurn — the ONE redelivery tail (0.13 D4,
5
+ * docs/0.13-channels-lifecycle-design.md §3 D4+D5).
6
+ *
7
+ * Pre-0.13, "re-dispatch a message as a fresh turn" existed in five shapes
8
+ * (seam S10) — boot-replay, edit-redelivery, startup-auto-retry, auto-resume,
9
+ * compact-replay — each with different gating, one-shot, ack, and tagging
10
+ * semantics. This module is the shared tail every re-dispatch converges on:
11
+ *
12
+ * 1. once-only per (chatId, msgId) for the daemon lifetime — duplicates are
13
+ * the double-answer failure mode, hard-capped here (FIFO-bounded set);
14
+ * 2. `_isReplay` tag — no new editable row, excluded from boot replay,
15
+ * error replies suppressed (the boot-replay contract, generalized);
16
+ * 3. gate at tier 'redelivery' (D5) — abort/admin-shaped content is
17
+ * EVALUATED but never auto-re-executed (logged `input-dropped-no-redeliver`);
18
+ * skippable for same-process retries whose object already passed the
19
+ * full fresh gate this boot (startup-auto-retry);
20
+ * 4. optional one-shot DB pre-mark (the boot-replay 'replay-attempted'
21
+ * pattern: even if THIS attempt dies mid-turn, the next boot won't loop);
22
+ * 5. ack reaction (👀) — a re-dispatch must never be silent (rc.33 lesson);
23
+ * callers may suppress for deliberately-silent retries;
24
+ * 6. dispatchHandleMessage — the normal turn path.
25
+ *
26
+ * Callers (P2): boot-replay. Callers (P3): the InputLedger drop-redeliverer.
27
+ * Deliberately NOT callers: edit-redelivery (edits are legitimately repeatable
28
+ * per message — this module's once-only is drop/replay semantics; edits share
29
+ * the D5 gate upstream instead), startup-auto-retry (its error path must
30
+ * SURFACE the friendly reset reply, which the _isReplay tag would suppress;
31
+ * its msg object already passed the full fresh gate this boot), compact-replay
32
+ * (a system re-push of the operator's own recorded command — outside the
33
+ * user-message gate by design, §6.7), and auto-resume (a continuation of a
34
+ * live turn, not a redelivery).
35
+ */
36
+
37
+ const REDELIVERED_CAP = 256;
38
+
39
+ function createRedeliver({
40
+ gateInbound,
41
+ dispatchHandleMessage,
42
+ getSessionKey,
43
+ config,
44
+ db = null,
45
+ dbWrite = (fn) => fn(),
46
+ setInboundHandlerStatus = null, // override for tests; defaults to db.setInboundHandlerStatus
47
+ react = null,
48
+ bot,
49
+ logEvent = () => {},
50
+ logger = console,
51
+ } = {}) {
52
+ if (typeof dispatchHandleMessage !== 'function') throw new TypeError('redeliver: dispatchHandleMessage required');
53
+ if (typeof getSessionKey !== 'function') throw new TypeError('redeliver: getSessionKey required');
54
+
55
+ const redelivered = new Set(); // `${chatId}:${msgId}` — once-only, FIFO-bounded
56
+ const redeliveredOrder = [];
57
+
58
+ /**
59
+ * @param {object} opts
60
+ * @param {string} opts.chatId
61
+ * @param {object} opts.msg — grammy-shaped message (fresh, reconstructed, or synthetic)
62
+ * @param {string} opts.source — 'boot-replay' | 'edit' | 'drop' | 'startup-retry' (telemetry)
63
+ * @param {string|null} [opts.preMark] — handler_status to pre-mark (one-shot guard), e.g. 'replay-attempted'
64
+ * @param {boolean} [opts.gate=true] — run the D5 gate at tier 'redelivery'
65
+ * @param {boolean} [opts.ack=true] — 👀 on the redelivered message
66
+ * @returns {Promise<{ok:boolean, reason?:string}>}
67
+ */
68
+ return async function redeliverAsFreshTurn({
69
+ chatId, msg, source, preMark = null, gate = true, ack = true,
70
+ } = {}) {
71
+ if (!msg || !msg.chat || msg.message_id == null) return { ok: false, reason: 'malformed message' };
72
+ const chatConfig = config.chats[String(chatId)];
73
+ if (!chatConfig) return { ok: false, reason: 'unconfigured chat' };
74
+
75
+ const key = `${chatId}:${msg.message_id}`;
76
+ if (redelivered.has(key)) {
77
+ logEvent('redeliver-suppressed-duplicate', { chat_id: chatId, msg_id: msg.message_id, source });
78
+ return { ok: false, reason: 'already-redelivered' };
79
+ }
80
+ redelivered.add(key);
81
+ redeliveredOrder.push(key);
82
+ while (redeliveredOrder.length > REDELIVERED_CAP) redelivered.delete(redeliveredOrder.shift());
83
+
84
+ msg._isReplay = true;
85
+
86
+ // Pre-mark BEFORE the gate: the one-shot DB guard must hold even when the
87
+ // gate blocks the content — otherwise a blocked row (abort/admin-shaped)
88
+ // would stay replay-eligible and re-block on every subsequent boot.
89
+ if (preMark) {
90
+ const mark = setInboundHandlerStatus || ((args) => db?.setInboundHandlerStatus?.(args));
91
+ dbWrite(
92
+ () => mark({ chat_id: chatId, msg_id: msg.message_id, status: preMark }),
93
+ `set handler_status=${preMark}`,
94
+ );
95
+ }
96
+
97
+ if (gate) {
98
+ if (typeof gateInbound !== 'function') return { ok: false, reason: 'gate unavailable' };
99
+ const res = await gateInbound(msg, { tier: 'redelivery' });
100
+ if (res.action !== 'pass') {
101
+ logEvent('input-dropped-no-redeliver', {
102
+ chat_id: chatId, msg_id: msg.message_id, source, stage: res.stage ?? null,
103
+ });
104
+ return { ok: false, reason: res.stage || res.action };
105
+ }
106
+ }
107
+
108
+ if (ack) {
109
+ try { react?.(chatId, msg.message_id); } catch { /* best-effort — never blocks */ }
110
+ }
111
+
112
+ const threadId = msg.message_thread_id?.toString();
113
+ const sessionKey = getSessionKey(String(chatId), threadId, chatConfig);
114
+ logEvent('redelivered-as-fresh-turn', {
115
+ chat_id: chatId, msg_id: msg.message_id, source, session_key: sessionKey,
116
+ });
117
+ dispatchHandleMessage(sessionKey, String(chatId), msg, bot);
118
+ return { ok: true };
119
+ };
120
+ }
121
+
122
+ module.exports = { createRedeliver };