polygram 0.12.0-rc.9 → 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 +3 -1
  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,89 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+ const { buildFork } = require('./fork');
8
+
9
+ // claude's transcript path for a session: ~/.claude/projects/<cwd '/'→'-'>/<id>.jsonl.
10
+ // CRITICAL (Finding A): mangle the RAW cwd, EXACTLY as cli-process.js's resume check does
11
+ // (`resolvedCwd.replace(/\//g,'-')`, no realpath). The fork is written to this path and the
12
+ // resume pre-check looks for it at this path — if the two diverge (one realpaths a symlinked
13
+ // cwd, the other doesn't) the resume misses the fork and claude starts a fresh empty session:
14
+ // a SILENT full-context wipe. Keeping both raw keeps them in lockstep; a genuinely symlinked
15
+ // cwd then fails cleanly at the read step (transcript unreadable) instead of wiping.
16
+ function transcriptPathFor(cwd, sessionId) {
17
+ return path.join(os.homedir(), '.claude', 'projects', String(cwd).replace(/\//g, '-'), `${sessionId}.jsonl`);
18
+ }
19
+
20
+ /**
21
+ * The 0.13 /rewind executor (P2/P3). Forks the live session transcript to before the target
22
+ * message, points the session at the fork (so the next message resumes the rewound
23
+ * conversation), kills the live proc, and deletes the bot's now-orphaned outbound messages.
24
+ * Copy-only + fail-safe: a fork failure leaves the original session fully intact.
25
+ *
26
+ * @param {object} deps { db, pm, tg, bot, botName, logEvent, logger, buildForkImpl }
27
+ * @returns {(req) => Promise<{ok:boolean,error?:string,droppedCount?:number}>}
28
+ */
29
+ function createRewindExecutor({ db, pm, tg, bot, botName = 'bot', logEvent = () => {}, logger = console, buildForkImpl = buildFork } = {}) {
30
+ // P3: delete the bot's own outbound messages sent after M (it can always delete its own).
31
+ async function deleteBotMessagesAfter({ chatId, threadId, afterMsgId }) {
32
+ let rows;
33
+ try {
34
+ const sql = `SELECT msg_id FROM messages WHERE chat_id = ? AND direction = 'out' AND status = 'sent' AND msg_id > ?`
35
+ + (threadId ? ` AND thread_id = ?` : ` AND thread_id IS NULL`) + ` ORDER BY msg_id`;
36
+ const params = threadId ? [String(chatId), Number(afterMsgId), String(threadId)] : [String(chatId), Number(afterMsgId)];
37
+ rows = db.raw.prepare(sql).all(...params);
38
+ } catch (e) { logger.error?.(`[${botName}] rewind cleanup query failed: ${e.message}`); return 0; }
39
+ let deleted = 0;
40
+ for (const r of rows || []) {
41
+ try {
42
+ await tg(bot, 'deleteMessage', { chat_id: chatId, message_id: r.msg_id }, { source: 'rewind-cleanup', botName });
43
+ deleted++;
44
+ } catch { /* already gone / older than 48h — Telegram won't delete; skip */ }
45
+ }
46
+ return deleted;
47
+ }
48
+
49
+ return async function executeRewind(req) {
50
+ const row = db.getSession(req.sessionKey);
51
+ if (!row || !row.claude_session_id || !row.cwd) {
52
+ return { ok: false, error: 'no live session to rewind' };
53
+ }
54
+ const transcriptPath = transcriptPathFor(row.cwd, row.claude_session_id);
55
+ const newId = crypto.randomUUID();
56
+
57
+ const fork = buildForkImpl({ transcriptPath, targetMsgId: req.target.msg_id, newSessionId: newId });
58
+ if (!fork.ok) return { ok: false, error: fork.error }; // original untouched
59
+
60
+ // Point the session at the fork → the next message lazy-resumes the rewound conversation.
61
+ try {
62
+ db.upsertSession({ ...row, claude_session_id: newId });
63
+ } catch (e) {
64
+ try { fs.unlinkSync(fork.forkPath); } catch {}
65
+ logger.error?.(`[${botName}] rewind id-swap failed: ${e.message}`);
66
+ return { ok: false, error: 'failed to record the rewind' };
67
+ }
68
+ // Drop the live proc (it holds the OLD session); next inbound message respawns on the fork.
69
+ // A kill failure (Finding E) is NOT silent: the DB already points at the fork, but the old
70
+ // proc may survive holding the old id (a two-transcript split), so surface a warning rather
71
+ // than report a clean success the operator would trust blindly.
72
+ let warning;
73
+ try { await pm.kill(req.sessionKey, 'rewind'); }
74
+ catch (e) {
75
+ warning = 'the previous session may still be running — send a new message to confirm the rewind took';
76
+ logger.error?.(`[${botName}] rewind kill: ${e.message}`);
77
+ logEvent('rewind-kill-failed', { session_key: req.sessionKey, error: e.message });
78
+ }
79
+
80
+ let droppedCount = 0;
81
+ try { droppedCount = await deleteBotMessagesAfter({ chatId: req.chatId, threadId: req.threadId, afterMsgId: req.target.msg_id }); }
82
+ catch (e) { logger.error?.(`[${botName}] rewind cleanup failed: ${e.message}`); }
83
+
84
+ logEvent('rewind-executed', { session_key: req.sessionKey, new_id: newId, target_msg_id: req.target.msg_id, dropped_turns: fork.droppedTurns });
85
+ return { ok: true, droppedCount, ...(warning && { warning }) };
86
+ };
87
+ }
88
+
89
+ module.exports = { createRewindExecutor, transcriptPathFor };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ /**
7
+ * Build a rewound FORK of a claude session transcript (0.13 /rewind P2; the mechanism P0.6
8
+ * validated). Keep the prefix up to — not including — the user turn carrying `targetMsgId`,
9
+ * rewrite the in-file `sessionId` to `newSessionId` on every line, and write a new
10
+ * `<newSessionId>.jsonl` next to the original (mode 0o600). NEVER touches the original.
11
+ *
12
+ * Fail-safe by construction — any anomaly returns `{ ok:false, error }`, never a partial/
13
+ * corrupt fork:
14
+ * - transcript unreadable / not valid JSONL
15
+ * - target not found (scrolled out OR compacted away — the locate-miss IS the compaction
16
+ * guard: a target older than a compaction boundary no longer carries its msg_id wrapper)
17
+ * - target is already the conversation start
18
+ * - the cut point is mid-tool-call (a tool_use in the prefix without its tool_result)
19
+ *
20
+ * A clean prefix needs no `parentUuid` reconciliation — a prefix of a backward-linked chain
21
+ * is self-consistent (verified in the P0.6 spike).
22
+ *
23
+ * @param {object} args
24
+ * @param {string} args.transcriptPath
25
+ * @param {string|number} args.targetMsgId — TG msg_id of the user message to rewind to.
26
+ * @param {string} args.newSessionId
27
+ * @param {object} [io] { fsImpl } — inject a fake fs for tests.
28
+ * @returns {{ ok:true, forkPath:string, droppedTurns:number } | { ok:false, error:string }}
29
+ */
30
+ function buildFork({ transcriptPath, targetMsgId, newSessionId }, { fsImpl = fs } = {}) {
31
+ if (!transcriptPath || !newSessionId || targetMsgId == null) {
32
+ return { ok: false, error: 'buildFork: transcriptPath, targetMsgId, newSessionId required' };
33
+ }
34
+ let raw;
35
+ try { raw = fsImpl.readFileSync(transcriptPath, 'utf8'); }
36
+ catch (e) { return { ok: false, error: `transcript unreadable: ${e.code || e.message}` }; }
37
+
38
+ const lines = String(raw).split('\n').filter((l) => l.trim());
39
+ let objs;
40
+ try { objs = lines.map((l) => JSON.parse(l)); }
41
+ catch { return { ok: false, error: 'transcript is not valid JSONL' }; }
42
+
43
+ // The channel wrapper lives in the user turn's content as a PLAIN string (the parsed value,
44
+ // not JSON-escaped) — search that, not JSON.stringify (which escapes the inner quotes).
45
+ const contentText = (o) => {
46
+ const c = o && o.message ? o.message.content : null;
47
+ if (typeof c === 'string') return c;
48
+ if (Array.isArray(c)) return c.map((b) => (b && b.type === 'text' ? b.text : '')).join(' ');
49
+ return '';
50
+ };
51
+ // Match the channel ENVELOPE's OWN msg_id — never a `<reply_to msg_id="X">` echoed inside a
52
+ // later turn's body (Finding B: silent-failure-hunter). In the parsed content only the outer
53
+ // bridge envelope keeps an unescaped `<channel …>`; the inner reply_to/telegram tags are
54
+ // escaped to `&lt;…&gt;`. The envelope's own closing `>` stops `[^>]*` before any embedded
55
+ // reply_to, so this matches the envelope whose msg_id IS the target and nothing else. Without
56
+ // this anchor, a target compacted away while a later turn still quotes it in reply_to would
57
+ // false-match the reply turn → a silent cut at the WRONG point, defeating the not-found guard.
58
+ const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
59
+ const targetRe = new RegExp(`<channel[^>]*\\bmsg_id="${escapeRe(targetMsgId)}"`);
60
+ const isChannelUser = (o) => o && o.type === 'user' && /<channel[^>]*\bmsg_id="/.test(contentText(o));
61
+ const cutIdx = objs.findIndex((o) => o && o.type === 'user' && targetRe.test(contentText(o)));
62
+ if (cutIdx < 0) {
63
+ return { ok: false, error: "couldn't find that message in the conversation (it may have scrolled out of memory)" };
64
+ }
65
+
66
+ const prefix = objs.slice(0, cutIdx);
67
+ if (!prefix.some(isChannelUser)) {
68
+ return { ok: false, error: "that's already the start of the conversation" };
69
+ }
70
+
71
+ // Clean-boundary check: no tool_use in the prefix left without its matching tool_result.
72
+ const openTools = new Set();
73
+ for (const o of prefix) {
74
+ const blocks = Array.isArray(o.message?.content) ? o.message.content : [];
75
+ for (const b of blocks) {
76
+ if (b && b.type === 'tool_use' && b.id) openTools.add(b.id);
77
+ if (b && b.type === 'tool_result' && b.tool_use_id) openTools.delete(b.tool_use_id);
78
+ }
79
+ }
80
+ if (openTools.size > 0) {
81
+ return { ok: false, error: 'that point is mid-tool-call — pick the message just before or after' };
82
+ }
83
+
84
+ // Rewrite sessionId on EVERY kept line (the resume id must match the filename + in-file id,
85
+ // else claude's ghost-session guard drops it and starts fresh = a silent full wipe).
86
+ const forked = prefix.map((o) => {
87
+ if (o && typeof o === 'object' && 'sessionId' in o) o.sessionId = newSessionId;
88
+ return JSON.stringify(o);
89
+ });
90
+
91
+ const dir = path.dirname(transcriptPath);
92
+ const forkPath = path.join(dir, `${newSessionId}.jsonl`);
93
+ // Atomic write (Finding C): a direct write to the live resume path can leave a TRUNCATED
94
+ // <id>.jsonl if interrupted (disk-full, crash, signal) — which claude then resumes as
95
+ // partial/empty context, or whose half-line trips the ghost-session guard into a full wipe.
96
+ // Write a temp sibling, then rename (atomic on the same fs) so the resume path only ever sees
97
+ // a complete file. The temp is a dotfile (won't match claude's `<sessionId>.jsonl` glob) and
98
+ // is cleaned on failure; a crash between write and rename leaves only a harmless orphan dot-tmp.
99
+ const tmpPath = path.join(dir, `.${newSessionId}.jsonl.tmp`);
100
+ try {
101
+ fsImpl.writeFileSync(tmpPath, forked.join('\n') + '\n', { mode: 0o600 });
102
+ fsImpl.renameSync(tmpPath, forkPath);
103
+ } catch (e) {
104
+ try { fsImpl.unlinkSync(tmpPath); } catch { /* nothing to clean */ }
105
+ return { ok: false, error: `couldn't write the fork: ${e.code || e.message}` };
106
+ }
107
+
108
+ const droppedTurns = objs.slice(cutIdx).filter(isChannelUser).length;
109
+ return { ok: true, forkPath, droppedTurns };
110
+ }
111
+
112
+ module.exports = { buildFork };
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `/rewind` command — P1 (0.13). Reply to a message with `/rewind` to rewind the
5
+ * conversation to just before it. P1 ships the plumbing: detect → gate (operator +
6
+ * message-ownership, per the 2026-06-09 security review) → defer to turn-end → confirm.
7
+ * The actual transcript fork is the **injected `executeRewind`** — P2 provides the real
8
+ * one (see docs/0.13-rewind-design.md, B-safe). P0.6 proved the fork mechanism works.
9
+ *
10
+ * Scope boundary the confirmation states out loud: a rewind reverts the CONVERSATION,
11
+ * not real-world side-effects (Drive files, Sheets, emails already created persist).
12
+ */
13
+
14
+ // `/rewind` or `/rewind@botname`, alone on the line (no args).
15
+ const REWIND_RE = /^\/rewind(?:@\w+)?\s*$/i;
16
+
17
+ // Backstop for a deferred rewind whose proc emits neither 'idle' nor 'close'. Above the 30-min
18
+ // absolute turn cap so a legitimately long in-flight turn finishes (emitting 'idle') first.
19
+ const DEFER_TIMEOUT_MS = 31 * 60 * 1000;
20
+
21
+ function isRewindCommand(text) {
22
+ return REWIND_RE.test(String(text || '').trim());
23
+ }
24
+
25
+ /**
26
+ * Validate a `/rewind` request. Returns { ok, reason }. Gate order:
27
+ * 1. must reply-to a message (the rewind target M)
28
+ * 2. the chat must be rewind-SAFE — a DM or an isolate-topics group. A shared (non-isolated)
29
+ * group runs ONE session across all topics, so a rewind there would blast every topic and
30
+ * user; refuse it. (Caller computes `rewindSafe`.)
31
+ * 3. access policy: the operator/admin is always allowed; any *paired* user is allowed ONLY
32
+ * when the chat opted in with `rewindAccess: 'paired'`. Default ('operator') is operator-only
33
+ * — NOT "any paired user" (the security-review degradation when operatorUserId was unset).
34
+ * 4. M must be the sender's OWN message or one of the bot's own bubbles — never another user's
35
+ * (else an allowed user could rewind to anyone's message).
36
+ *
37
+ * @param {object} a
38
+ * @param {object} a.msg
39
+ * @param {string} a.botUsername
40
+ * @param {boolean} a.rewindSafe chat is a DM OR an isolateTopics group (caller computes)
41
+ * @param {boolean} a.isOperatorIdentity sender is operatorUserId or the admin user (caller computes)
42
+ * @param {boolean} a.paired sender has a live pairing in this chat
43
+ * @param {'operator'|'paired'} [a.accessMode='operator']
44
+ */
45
+ function gateRewindRequest({ msg, botUsername, rewindSafe, isOperatorIdentity, paired, accessMode = 'operator' } = {}) {
46
+ const reply = msg?.reply_to_message;
47
+ if (!reply) return { ok: false, reason: 'reply to the message you want to rewind to, then send /rewind' };
48
+ if (!rewindSafe) {
49
+ return { ok: false, reason: "rewind isn't available in a shared group chat — turn on per-topic isolation (isolateTopics) so a rewind only affects one topic, not everyone" };
50
+ }
51
+ const allowed = !!isOperatorIdentity || (accessMode === 'paired' && !!paired);
52
+ if (!allowed) {
53
+ return { ok: false, reason: accessMode === 'paired' ? 'only paired members can rewind this chat' : 'only the operator can rewind this chat' };
54
+ }
55
+ const fromId = reply.from?.id;
56
+ const isBotMsg = !!botUsername && reply.from?.username === botUsername;
57
+ const isOwnMsg = fromId != null && msg.from?.id != null && Number(fromId) === Number(msg.from.id);
58
+ if (!isBotMsg && !isOwnMsg) {
59
+ return { ok: false, reason: 'you can only rewind to your own messages or mine' };
60
+ }
61
+ return { ok: true };
62
+ }
63
+
64
+ function previewOf(text) {
65
+ const first = String(text || '').split('\n')[0].trim();
66
+ return first.length > 60 ? `${first.slice(0, 57)}…` : (first || '(no text)');
67
+ }
68
+
69
+ /**
70
+ * @param {object} deps
71
+ * @param {object} deps.pm — ProcessManager (uses pm.get(sessionKey) + proc 'idle')
72
+ * @param {Function} deps.tg — tg(bot, method, params, meta) sender
73
+ * @param {object} deps.bot
74
+ * @param {string} deps.botName
75
+ * @param {(req) => Promise<{ok:boolean,error?:string,droppedCount?:number}>} deps.executeRewind
76
+ * — the transcript fork (P2). Injected; P1 wires a stub.
77
+ * @param {Function} [deps.logEvent]
78
+ * @param {object} [deps.logger]
79
+ */
80
+ function createRewindHandler({ pm, tg, bot, botName = 'bot', executeRewind, logEvent = () => {}, logger = console } = {}) {
81
+ if (typeof executeRewind !== 'function') throw new TypeError('createRewindHandler: executeRewind required');
82
+
83
+ function ack(chatId, threadId, text) {
84
+ return tg(bot, 'sendMessage', { chat_id: chatId, text, ...(threadId && { message_thread_id: threadId }) },
85
+ { source: 'rewind', botName })
86
+ .catch((e) => logger.error?.(`[${botName}] rewind ack failed: ${e.message}`));
87
+ }
88
+
89
+ async function run(req) {
90
+ let result;
91
+ try {
92
+ result = await executeRewind(req);
93
+ } catch (e) {
94
+ logger.error?.(`[${botName}] rewind execute threw for ${req.sessionKey}: ${e.message}`);
95
+ result = { ok: false, error: e.message };
96
+ }
97
+ if (result && result.ok) {
98
+ const n = result.droppedCount;
99
+ await ack(req.chatId, req.threadId,
100
+ `⏪ Rewound to: «${previewOf(req.target.text)}»` + (n != null ? ` — ${n} message(s) dropped.` : '.') +
101
+ (result.warning ? `\n⚠️ ${result.warning}` : '') +
102
+ `\nNote: anything I already created (files, Sheets, emails) still exists — say the word to reverse it.`);
103
+ logEvent('rewind-done', { session_key: req.sessionKey, target_msg_id: req.target.msg_id });
104
+ } else {
105
+ await ack(req.chatId, req.threadId, `↩️ couldn't rewind — ${(result && result.error) || 'unknown error'}`);
106
+ logEvent('rewind-failed', { session_key: req.sessionKey, target_msg_id: req.target.msg_id, error: result && result.error });
107
+ }
108
+ }
109
+
110
+ // Defer to turn-end: a rewind kills+resumes the session, so it must never run mid-turn. If a
111
+ // turn is in flight, run on the proc's next 'idle'. If the proc is torn down first — 'close'
112
+ // or 'session-reset' (kill / LRU evict / bridge disconnect / model change), none of which
113
+ // emit 'idle' — tell the operator instead of leaving them hanging after the "queued" ack
114
+ // (Finding D). DEFER_TIMEOUT_MS backstops a proc that somehow emits neither (it sits above
115
+ // the 30-min absolute turn cap, so a legitimately long turn still completes first).
116
+ function schedule(req) {
117
+ const proc = pm && typeof pm.get === 'function' ? pm.get(req.sessionKey) : null;
118
+ if (proc && proc.inFlight) {
119
+ let settled = false;
120
+ const cleanup = () => {
121
+ clearTimeout(timer);
122
+ proc.removeListener('idle', onIdle);
123
+ proc.removeListener('close', onDead);
124
+ proc.removeListener('session-reset', onDead);
125
+ };
126
+ const onIdle = () => { if (settled) return; settled = true; cleanup(); run(req).catch(() => {}); };
127
+ const onDead = () => {
128
+ if (settled) return; settled = true; cleanup();
129
+ ack(req.chatId, req.threadId, "↩️ couldn't rewind — the session ended before the turn finished. Reply /rewind again.");
130
+ logEvent('rewind-deferred-lost', { session_key: req.sessionKey, target_msg_id: req.target.msg_id });
131
+ };
132
+ const timer = setTimeout(() => {
133
+ if (settled) return; settled = true; cleanup();
134
+ ack(req.chatId, req.threadId, "↩️ couldn't rewind — timed out waiting for the current turn to finish. Reply /rewind again.");
135
+ logEvent('rewind-deferred-timeout', { session_key: req.sessionKey, target_msg_id: req.target.msg_id });
136
+ }, DEFER_TIMEOUT_MS);
137
+ if (timer.unref) timer.unref();
138
+ proc.once('idle', onIdle);
139
+ proc.once('close', onDead);
140
+ proc.once('session-reset', onDead);
141
+ return 'deferred';
142
+ }
143
+ setImmediate(() => { run(req).catch(() => {}); });
144
+ return 'now';
145
+ }
146
+
147
+ /**
148
+ * Dispatcher hook. Returns { consumed }. Consumes any `/rewind` (valid → queued, invalid →
149
+ * the operator is told why) so it never starts a normal turn.
150
+ */
151
+ async function tryConsume({ sessionKey, chatId, threadId = null, msg, cleanText, botUsername, rewindSafe, isOperatorIdentity, paired, accessMode }) {
152
+ if (!isRewindCommand(cleanText)) return { consumed: false };
153
+ const gate = gateRewindRequest({ msg, botUsername, rewindSafe, isOperatorIdentity, paired, accessMode });
154
+ if (!gate.ok) {
155
+ await ack(chatId, threadId, `↩️ ${gate.reason}`);
156
+ return { consumed: true };
157
+ }
158
+ const reply = msg.reply_to_message;
159
+ const req = {
160
+ sessionKey, chatId, threadId,
161
+ target: { msg_id: reply.message_id, text: reply.text || reply.caption || '', ts: reply.date },
162
+ };
163
+ const when = schedule(req);
164
+ await ack(chatId, threadId, when === 'deferred'
165
+ ? '⏳ Rewind queued — I’ll run it the moment the current turn finishes.'
166
+ : '⏪ Rewinding…');
167
+ logEvent('rewind-requested', { session_key: sessionKey, target_msg_id: req.target.msg_id, when });
168
+ return { consumed: true };
169
+ }
170
+
171
+ return { tryConsume };
172
+ }
173
+
174
+ module.exports = { isRewindCommand, gateRewindRequest, previewOf, createRewindHandler };