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.
- package/config.example.json +4 -3
- package/lib/claude-bin.js +14 -1
- package/lib/compaction-warn.js +59 -0
- package/lib/context-usage.js +93 -0
- package/lib/db.js +1 -1
- package/lib/error/classify.js +33 -10
- package/lib/feedback/session-feedback.js +91 -0
- package/lib/handlers/abort.js +87 -40
- package/lib/handlers/autosteer.js +4 -0
- package/lib/handlers/config-callback.js +25 -6
- package/lib/handlers/config-ui.js +39 -10
- package/lib/handlers/dispatcher.js +83 -0
- package/lib/handlers/download.js +101 -58
- package/lib/handlers/drop-redeliver.js +69 -0
- package/lib/handlers/edit-correction.js +2 -0
- package/lib/handlers/edit-redelivery.js +136 -0
- package/lib/handlers/gate-inbound.js +188 -0
- package/lib/handlers/questions.js +289 -0
- package/lib/handlers/redeliver.js +122 -0
- package/lib/handlers/slash-commands.js +43 -30
- package/lib/history-preload.js +6 -0
- package/lib/history.js +7 -1
- package/lib/model-costs.js +4 -0
- package/lib/process/channels-bridge-protocol.js +22 -1
- package/lib/process/channels-bridge.mjs +128 -7
- package/lib/process/channels-tool-dispatcher.js +105 -12
- package/lib/process/cli-process.js +1277 -70
- package/lib/process/hook-event-tail.js +7 -0
- package/lib/process/hook-settings.js +7 -0
- package/lib/process/process.js +22 -0
- package/lib/process-guard.js +57 -1
- package/lib/process-manager.js +120 -35
- package/lib/questions/questions.js +187 -0
- package/lib/questions/store.js +105 -0
- package/lib/rewind/execute.js +89 -0
- package/lib/rewind/fork.js +112 -0
- package/lib/rewind/rewind.js +174 -0
- package/lib/sdk/callbacks.js +165 -167
- package/lib/session-key.js +29 -0
- package/lib/telegram/album-reactions.js +50 -0
- package/lib/telegram/parse.js +9 -2
- package/lib/telegram/typing.js +17 -2
- package/lib/tmux/startup-gate.js +44 -14
- package/migrations/012-pending-questions.sql +30 -0
- package/package.json +1 -1
- 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 `<…>`. 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 };
|