polygram 0.12.0-rc.30 → 0.12.0-rc.32
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/lib/history-preload.js +6 -0
- package/lib/history.js +7 -1
- package/lib/process/channels-bridge-protocol.js +4 -0
- package/lib/process/channels-bridge.mjs +37 -10
- package/lib/process/channels-tool-dispatcher.js +46 -7
- package/lib/process/cli-process.js +46 -8
- package/lib/process/process.js +13 -0
- package/lib/process-guard.js +57 -1
- package/lib/process-manager.js +55 -9
- package/lib/questions/store.js +6 -1
- package/lib/rewind/execute.js +89 -0
- package/lib/rewind/fork.js +112 -0
- package/lib/rewind/rewind.js +174 -0
- package/package.json +1 -1
- package/polygram.js +46 -0
package/lib/history-preload.js
CHANGED
|
@@ -69,6 +69,7 @@ function makeSessionStartHook({
|
|
|
69
69
|
db,
|
|
70
70
|
chatId,
|
|
71
71
|
threadId = null,
|
|
72
|
+
isolateTopics = false,
|
|
72
73
|
allowedChatIds = null,
|
|
73
74
|
limit = DEFAULT_PRELOAD_LIMIT,
|
|
74
75
|
since = DEFAULT_PRELOAD_SINCE,
|
|
@@ -93,6 +94,7 @@ function makeSessionStartHook({
|
|
|
93
94
|
rows = history.recent(db, {
|
|
94
95
|
chatId: String(chatId),
|
|
95
96
|
threadId: threadId ?? null,
|
|
97
|
+
scopeThread: isolateTopics === true, // cross-topic bleed fix (null = General only)
|
|
96
98
|
limit,
|
|
97
99
|
since,
|
|
98
100
|
includeOutbound: true,
|
|
@@ -170,6 +172,7 @@ function buildHistoryBlock({
|
|
|
170
172
|
db,
|
|
171
173
|
chatId,
|
|
172
174
|
threadId = null,
|
|
175
|
+
isolateTopics = false,
|
|
173
176
|
excludeMsgId = null,
|
|
174
177
|
limit = DEFAULT_PRELOAD_LIMIT,
|
|
175
178
|
since = DEFAULT_PRELOAD_SINCE,
|
|
@@ -181,6 +184,9 @@ function buildHistoryBlock({
|
|
|
181
184
|
rows = history.recent(db, {
|
|
182
185
|
chatId: String(chatId),
|
|
183
186
|
threadId: threadId ?? null,
|
|
187
|
+
// isolateTopics → scope to THIS thread (null = General only); else the
|
|
188
|
+
// chat-wide session legitimately spans all topics. (cross-topic bleed fix)
|
|
189
|
+
scopeThread: isolateTopics === true,
|
|
184
190
|
limit,
|
|
185
191
|
since,
|
|
186
192
|
includeOutbound: true,
|
package/lib/history.js
CHANGED
|
@@ -56,11 +56,17 @@ function fts5Escape(query) {
|
|
|
56
56
|
.join(' ');
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function recent(db, { chatId, threadId = null, limit = 20, since = null, includeOutbound = true, allowedChatIds = null } = {}) {
|
|
59
|
+
function recent(db, { chatId, threadId = null, scopeThread = false, limit = 20, since = null, includeOutbound = true, allowedChatIds = null } = {}) {
|
|
60
60
|
const clamped = clampLimit(limit);
|
|
61
61
|
let sql = 'SELECT * FROM messages WHERE chat_id = ?';
|
|
62
62
|
const params = [String(chatId)];
|
|
63
63
|
if (threadId) { sql += ' AND thread_id = ?'; params.push(String(threadId)); }
|
|
64
|
+
// scopeThread: a per-topic (isolateTopics) session must see ONLY its own thread —
|
|
65
|
+
// and a null thread means the General topic, i.e. thread_id IS NULL, NOT "all
|
|
66
|
+
// threads". Without this the General topic preloads the whole chat and bleeds
|
|
67
|
+
// other topics' context (2026-06-09 cross-topic incident). The history-skill CLI
|
|
68
|
+
// keeps the old "null threadId = all threads" default (scopeThread=false).
|
|
69
|
+
else if (scopeThread) { sql += ' AND thread_id IS NULL'; }
|
|
64
70
|
if (!includeOutbound) sql += ` AND direction = 'in'`;
|
|
65
71
|
const sinceMs = parseSinceMs(since);
|
|
66
72
|
if (sinceMs) { sql += ' AND ts >= ?'; params.push(Date.now() - sinceMs); }
|
|
@@ -121,6 +121,10 @@ const ToolAckMessageSchema = z.object({
|
|
|
121
121
|
tool_call_id: ToolCallId,
|
|
122
122
|
ok: z.boolean(),
|
|
123
123
|
error: z.string().optional(),
|
|
124
|
+
// 0.13: the delivered Telegram message_id, surfaced back to claude so it can
|
|
125
|
+
// `edit_message` that bubble for progressive status. Present on a successful
|
|
126
|
+
// `reply`/`edit_message` ack; absent on errors / re-acks.
|
|
127
|
+
message_id: z.union([z.number(), z.string()]).nullish(),
|
|
124
128
|
}).passthrough();
|
|
125
129
|
|
|
126
130
|
// 0.12 interactive questions: carries the user's answer back for an `ask` tool
|
|
@@ -108,19 +108,22 @@ function awaitToolAck(toolCallId) {
|
|
|
108
108
|
})
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
function resolveToolAck(toolCallId, ok, error) {
|
|
111
|
+
function resolveToolAck(toolCallId, ok, error, messageId) {
|
|
112
112
|
const p = pendingToolCalls.get(toolCallId)
|
|
113
113
|
if (!p) return
|
|
114
114
|
pendingToolCalls.delete(toolCallId)
|
|
115
115
|
clearTimeout(p.timer)
|
|
116
|
-
|
|
116
|
+
// 0.13: resolve with the delivered message_id so the CallTool handler can hand
|
|
117
|
+
// it back to claude (for edit_message). null when the daemon didn't carry one.
|
|
118
|
+
ok ? p.resolve({ message_id: messageId ?? null }) : p.reject(new Error(error || 'daemon rejected delivery'))
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
// ─── 0.12 interactive questions: `ask` blocks for the user's answer ──
|
|
120
|
-
// Separate from tool_ack: a question can take MINUTES (the daemon-side
|
|
121
|
-
// question timeout resolves it with {timedout}
|
|
122
|
+
// Separate from tool_ack: a question can take MINUTES (the daemon-side 30-min
|
|
123
|
+
// question timeout — aligned to the turn absolute cap — resolves it with {timedout}
|
|
124
|
+
// before this hard ceiling, which sits just above as the last-resort backstop).
|
|
122
125
|
const pendingQuestions = new Map() // tool_call_id → { resolve, timer }
|
|
123
|
-
const QUESTION_ANSWER_TIMEOUT_MS =
|
|
126
|
+
const QUESTION_ANSWER_TIMEOUT_MS = 32 * 60 * 1000
|
|
124
127
|
|
|
125
128
|
function awaitQuestionAnswer(toolCallId) {
|
|
126
129
|
return new Promise((resolve) => {
|
|
@@ -235,7 +238,7 @@ function handleDaemonMessage(msg) {
|
|
|
235
238
|
break
|
|
236
239
|
|
|
237
240
|
case 'tool_ack':
|
|
238
|
-
resolveToolAck(msg.tool_call_id, msg.ok, msg.error)
|
|
241
|
+
resolveToolAck(msg.tool_call_id, msg.ok, msg.error, msg.message_id)
|
|
239
242
|
break
|
|
240
243
|
|
|
241
244
|
case 'question_answer':
|
|
@@ -301,7 +304,9 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
301
304
|
description: 'Send a message back to the originating Telegram chat. ' +
|
|
302
305
|
'chat_id MUST match the chat_id from the inbound <channel> tag. ' +
|
|
303
306
|
'turn_id MUST echo the turn_id from the inbound <channel> tag (when present) ' +
|
|
304
|
-
'so concurrent turns route their replies correctly.'
|
|
307
|
+
'so concurrent turns route their replies correctly. ' +
|
|
308
|
+
'Returns {ok, message_id}: keep the message_id to update that bubble in place ' +
|
|
309
|
+
'with `edit_message` (progressive status on long tasks).',
|
|
305
310
|
inputSchema: {
|
|
306
311
|
type: 'object',
|
|
307
312
|
properties: {
|
|
@@ -312,6 +317,25 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
312
317
|
},
|
|
313
318
|
required: ['chat_id', 'text'],
|
|
314
319
|
},
|
|
320
|
+
}, {
|
|
321
|
+
// 0.13: edit a message already sent via `reply`, in place — the progressive-
|
|
322
|
+
// status primitive. Update one bubble instead of sending several.
|
|
323
|
+
name: 'edit_message',
|
|
324
|
+
description: 'Edit a message you previously sent via `reply`, in place. Use this for ' +
|
|
325
|
+
'progressive status on a long task: send a short status with `reply`, take the ' +
|
|
326
|
+
'returned message_id, then `edit_message` it as you make progress (ending with ' +
|
|
327
|
+
'the final answer). Keep status in PLAIN LANGUAGE — never tool names like Bash/Edit. ' +
|
|
328
|
+
'One bubble only (no chunking); for long content use `reply` instead.',
|
|
329
|
+
inputSchema: {
|
|
330
|
+
type: 'object',
|
|
331
|
+
properties: {
|
|
332
|
+
chat_id: { type: 'string', description: 'Echo of chat_id from inbound channel meta.' },
|
|
333
|
+
turn_id: { type: 'string', description: 'Echo of turn_id from inbound channel meta.' },
|
|
334
|
+
message_id: { type: 'number', description: 'The message_id returned by the `reply` tool call you want to update.' },
|
|
335
|
+
text: { type: 'string', description: 'New full message body (markdown ok) — replaces the old text.' },
|
|
336
|
+
},
|
|
337
|
+
required: ['chat_id', 'message_id', 'text'],
|
|
338
|
+
},
|
|
315
339
|
}, {
|
|
316
340
|
// 0.12 interactive questions: ask the Telegram user a multiple-choice question
|
|
317
341
|
// as tap-to-answer inline buttons. USE THIS instead of any interactive menu —
|
|
@@ -355,7 +379,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
355
379
|
})
|
|
356
380
|
|
|
357
381
|
mcp.setRequestHandler(CallToolRequestSchema, async req => {
|
|
358
|
-
if (req.params.name !== 'reply' && req.params.name !== 'ask') {
|
|
382
|
+
if (req.params.name !== 'reply' && req.params.name !== 'ask' && req.params.name !== 'edit_message') {
|
|
359
383
|
return { content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }], isError: true }
|
|
360
384
|
}
|
|
361
385
|
const toolCallId = randomUUID()
|
|
@@ -385,8 +409,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
|
|
|
385
409
|
args: req.params.arguments,
|
|
386
410
|
}) + '\n')
|
|
387
411
|
try {
|
|
388
|
-
await ackP
|
|
389
|
-
|
|
412
|
+
const ack = await ackP
|
|
413
|
+
// Return {ok, message_id} as JSON so claude can read the delivered bubble's
|
|
414
|
+
// id and `edit_message` it later (progressive status). For a plain reply with
|
|
415
|
+
// no id (solo sticker/reaction) message_id is null.
|
|
416
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message_id: ack?.message_id ?? null }) }] }
|
|
390
417
|
} catch (err) {
|
|
391
418
|
return { content: [{ type: 'text', text: `delivery failed: ${err.message}` }], isError: true }
|
|
392
419
|
}
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* - lib/telegram/deliver.js for the actual sendMessage loop
|
|
10
10
|
* - bot.api.sendPhoto/Document for file attachments
|
|
11
11
|
*
|
|
12
|
-
* The dispatcher returns `{ok: boolean, error?: string}` —
|
|
13
|
-
* relays this to the bridge as tool_ack, which surfaces to Claude as
|
|
14
|
-
* tool's return value (`
|
|
12
|
+
* The dispatcher returns `{ok: boolean, error?: string, message_id?: number}` —
|
|
13
|
+
* CliProcess relays this to the bridge as tool_ack, which surfaces to Claude as
|
|
14
|
+
* the tool's return value (`{ok:true, message_id}` on success so claude can
|
|
15
|
+
* `edit_message` that bubble; error message on failure).
|
|
15
16
|
*
|
|
16
17
|
* Decoupled from polygram.js: factory takes {bot, send, chunkText, logger}
|
|
17
18
|
* — same shape SDK callbacks already use — so it can be tested with fakes
|
|
@@ -125,11 +126,43 @@ function createChannelsToolDispatcher({
|
|
|
125
126
|
|| require('../telegram/process-agent-reply').processAndDeliverAgentText;
|
|
126
127
|
|
|
127
128
|
return async function channelsToolDispatcher(call) {
|
|
128
|
-
const { sessionKey, chatId, threadId, toolName, text, files, sourceMsgId, maxOutboundFileBytes } = call;
|
|
129
|
+
const { sessionKey, chatId, threadId, toolName, text, files, sourceMsgId, maxOutboundFileBytes, messageId } = call;
|
|
130
|
+
|
|
131
|
+
// 0.13: `edit_message` edits a previously-sent bubble in place — the
|
|
132
|
+
// progressive-status primitive. claude gets the message_id from the
|
|
133
|
+
// `reply` tool's result (returned below) and edits it as work proceeds.
|
|
134
|
+
// A single edit is one bubble (no chunking); the `send` wrapper already
|
|
135
|
+
// does markdown→HTML and tolerates "message is not modified".
|
|
136
|
+
if (toolName === 'edit_message') {
|
|
137
|
+
if (!chatId) return { ok: false, error: 'edit_message.chat_id missing' };
|
|
138
|
+
if (messageId == null) return { ok: false, error: 'edit_message.message_id missing' };
|
|
139
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
140
|
+
return { ok: false, error: 'edit_message.text missing or empty' };
|
|
141
|
+
}
|
|
142
|
+
// Same agent-text hygiene as reply, minus chunking/delivery: strip inline
|
|
143
|
+
// [sticker:…]/[react:…] markers and intercept canned strings so neither
|
|
144
|
+
// leaks as literal text into the edited bubble.
|
|
145
|
+
let clean = text;
|
|
146
|
+
try { const p = parseResponse(text); if (p && typeof p.text === 'string') clean = p.text; } catch { /* keep raw */ }
|
|
147
|
+
try { const s = sanitizeAssistantReply(clean); if (s && typeof s.text === 'string') clean = s.text; } catch { /* keep */ }
|
|
148
|
+
if (clean.length === 0) return { ok: false, error: 'edit_message.text empty after sanitize' };
|
|
149
|
+
if (clean.length > maxChunkLen) {
|
|
150
|
+
return { ok: false, error: `edit text too long (${clean.length} > ${maxChunkLen}); a message edit is a single bubble — use reply for long content` };
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const params = { chat_id: chatId, message_id: messageId, text: clean };
|
|
154
|
+
if (threadId) params.message_thread_id = threadId;
|
|
155
|
+
await send(bot, 'editMessageText', params, { source: 'channels-tool-dispatcher', sessionKey, toolName });
|
|
156
|
+
return { ok: true, message_id: messageId };
|
|
157
|
+
} catch (err) {
|
|
158
|
+
logger.error?.(`[channels-tool-dispatcher] ${sessionKey} edit_message failed: ${err.message}`);
|
|
159
|
+
return { ok: false, error: err.message };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
129
162
|
|
|
130
163
|
if (toolName !== 'reply') {
|
|
131
|
-
//
|
|
132
|
-
//
|
|
164
|
+
// `reply` + `edit_message` route here. `react` (and any future tool)
|
|
165
|
+
// is not yet implemented (Decision #10 / 0.13 follow-on).
|
|
133
166
|
return { ok: false, error: `unsupported tool: ${toolName}` };
|
|
134
167
|
}
|
|
135
168
|
|
|
@@ -171,6 +204,12 @@ function createChannelsToolDispatcher({
|
|
|
171
204
|
return { ok: false, error: `delivered ${dr.sent?.length || 0} of ${totalChunks} chunks; failed: ${failedDetail}` };
|
|
172
205
|
}
|
|
173
206
|
|
|
207
|
+
// 0.13: surface the FIRST delivered bubble's message_id so claude can
|
|
208
|
+
// edit_message it for progressive status. deliver.js pushes numeric ids;
|
|
209
|
+
// tolerate the {message_id} object shape too. null for solo sticker/reaction.
|
|
210
|
+
const firstSent = dr && Array.isArray(dr.sent) ? dr.sent.find(x => x != null) : null;
|
|
211
|
+
const replyMessageId = (firstSent && typeof firstSent === 'object') ? firstSent.message_id : firstSent;
|
|
212
|
+
|
|
174
213
|
// File attachments — sent as separate messages AFTER the text.
|
|
175
214
|
// Photos for image MIMEs, Documents for everything else (matches
|
|
176
215
|
// the official Telegram channels plugin behavior).
|
|
@@ -241,7 +280,7 @@ function createChannelsToolDispatcher({
|
|
|
241
280
|
failedAttachments.map(f => `${f.path} (${f.error})`).join('; '),
|
|
242
281
|
};
|
|
243
282
|
}
|
|
244
|
-
return { ok: true };
|
|
283
|
+
return { ok: true, message_id: replyMessageId ?? null };
|
|
245
284
|
} catch (err) {
|
|
246
285
|
logger.error?.(`[channels-tool-dispatcher] ${sessionKey} dispatch failed: ${err.message}`);
|
|
247
286
|
return { ok: false, error: err.message };
|
|
@@ -281,6 +281,7 @@ class CliProcess extends Process {
|
|
|
281
281
|
// doesn't re-invoke the dispatcher → duplicate TG send. Set is bounded
|
|
282
282
|
// to RECENT_TOOL_CALL_LIMIT entries via FIFO eviction.
|
|
283
283
|
this.recentToolCallIds = new Set();
|
|
284
|
+
this.recentToolCallResults = new Map(); // tool_call_id → message_id (0.13: replay on re-ACK)
|
|
284
285
|
this.recentToolCallOrder = []; // FIFO bound
|
|
285
286
|
// Review F#17: per-pattern last-fired timestamp for the mid-turn dialog
|
|
286
287
|
// watchdog. Dedups within MID_TURN_DEDUP_WINDOW_MS so a lingering dialog
|
|
@@ -695,6 +696,20 @@ class CliProcess extends Process {
|
|
|
695
696
|
'as normal — only the FINAL user-visible message needs to go through',
|
|
696
697
|
'the reply tool.',
|
|
697
698
|
'',
|
|
699
|
+
'### Staying responsive on a long task',
|
|
700
|
+
'',
|
|
701
|
+
'The user cannot see you working — no live typing reaches them. For any task',
|
|
702
|
+
'that takes more than a few seconds, send a SHORT status first via `reply`',
|
|
703
|
+
'(it returns a `message_id`), then call `mcp__polygram-bridge__edit_message`',
|
|
704
|
+
'with that `message_id` to update the SAME bubble as you make progress,',
|
|
705
|
+
'finishing with the result. One evolving message beats silence or a flood of',
|
|
706
|
+
'new ones.',
|
|
707
|
+
'',
|
|
708
|
+
'Write status in PLAIN, friendly language about what you are doing FOR THE',
|
|
709
|
+
'USER — never tool names or mechanics. Say "Checking your config now…", not',
|
|
710
|
+
'"Running Bash" or "Calling Read". If the final answer is long, send it as a',
|
|
711
|
+
'fresh `reply` rather than an edit (an edit is one single message bubble).',
|
|
712
|
+
'',
|
|
698
713
|
// TEMPORARY mitigation (2026-06-08 Shumabit@UMI wedge): AskUserQuestion opens
|
|
699
714
|
// a blocking TUI selection widget the channel can't answer → the session
|
|
700
715
|
// parks until manually Esc'd. REMOVE this whole rule when the rich
|
|
@@ -1007,7 +1022,9 @@ class CliProcess extends Process {
|
|
|
1007
1022
|
this.logger.warn?.(
|
|
1008
1023
|
`[${this.label}] channels: duplicate tool_call_id=${msg.tool_call_id} — re-ACKing without dispatch`,
|
|
1009
1024
|
);
|
|
1010
|
-
|
|
1025
|
+
// 0.13: replay the cached message_id so a retried reply keeps its edit handle
|
|
1026
|
+
// (re-ACKing without it would null the handle → progressive status silently breaks).
|
|
1027
|
+
this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: true, message_id: this.recentToolCallResults.get(msg.tool_call_id) ?? null });
|
|
1011
1028
|
return;
|
|
1012
1029
|
}
|
|
1013
1030
|
|
|
@@ -1041,15 +1058,15 @@ class CliProcess extends Process {
|
|
|
1041
1058
|
// an isError ack). Window-based so legit repeat sends eventually pass.
|
|
1042
1059
|
if (msg.name === 'reply' && typeof args.text === 'string' && args.chat_id != null) {
|
|
1043
1060
|
const dedupKey = this._buildContentDedupKey(args.chat_id, args.text);
|
|
1044
|
-
const
|
|
1061
|
+
const entry = this.recentContentHashes.get(dedupKey); // { expiry, message_id }
|
|
1045
1062
|
const nowDedup = Date.now();
|
|
1046
1063
|
// Evict stale entries opportunistically (avoids unbounded growth).
|
|
1047
1064
|
if (this.recentContentHashes.size > 64) {
|
|
1048
|
-
for (const [k,
|
|
1049
|
-
if (
|
|
1065
|
+
for (const [k, e] of this.recentContentHashes) {
|
|
1066
|
+
if (e.expiry < nowDedup) this.recentContentHashes.delete(k);
|
|
1050
1067
|
}
|
|
1051
1068
|
}
|
|
1052
|
-
if (
|
|
1069
|
+
if (entry && entry.expiry > nowDedup) {
|
|
1053
1070
|
this.logger.warn?.(
|
|
1054
1071
|
`[${this.label}] channels: duplicate content within ${this.contentDedupWindowMs}ms ` +
|
|
1055
1072
|
`(new tool_call_id=${msg.tool_call_id}, hash=${dedupKey.slice(-12)}) — re-ACKing without dispatch`,
|
|
@@ -1059,7 +1076,9 @@ class CliProcess extends Process {
|
|
|
1059
1076
|
chat_id: args.chat_id,
|
|
1060
1077
|
window_ms: this.contentDedupWindowMs,
|
|
1061
1078
|
});
|
|
1062
|
-
|
|
1079
|
+
// 0.13: replay the ORIGINAL bubble's message_id so a retried identical reply
|
|
1080
|
+
// keeps its edit handle (the slow-ack-retry case progressive status targets).
|
|
1081
|
+
this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: true, message_id: entry.message_id ?? null });
|
|
1063
1082
|
return;
|
|
1064
1083
|
}
|
|
1065
1084
|
}
|
|
@@ -1111,6 +1130,7 @@ class CliProcess extends Process {
|
|
|
1111
1130
|
toolName: msg.name,
|
|
1112
1131
|
text: args.text,
|
|
1113
1132
|
files: args.files,
|
|
1133
|
+
messageId: args.message_id, // 0.13: edit_message target bubble
|
|
1114
1134
|
sessionCwd: this.sessionCwd, // P0 #2: dispatcher uses this to allowlist file roots
|
|
1115
1135
|
maxOutboundFileBytes: this.maxOutboundFileBytes, // backend/chat-derived upload cap
|
|
1116
1136
|
});
|
|
@@ -1119,18 +1139,22 @@ class CliProcess extends Process {
|
|
|
1119
1139
|
return;
|
|
1120
1140
|
}
|
|
1121
1141
|
|
|
1122
|
-
|
|
1142
|
+
// 0.13: carry the delivered message_id back so the bridge hands it to claude
|
|
1143
|
+
// (reply → edit_message progressive status).
|
|
1144
|
+
this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: !!result?.ok, error: result?.error, message_id: result?.message_id });
|
|
1123
1145
|
|
|
1124
1146
|
// P1 #7: remember the tool_call_id so duplicates re-ACK without dispatch.
|
|
1125
1147
|
// Only cache on SUCCESS — failed calls should be retryable (transient TG
|
|
1126
1148
|
// outage etc).
|
|
1127
1149
|
if (result?.ok && msg.tool_call_id) {
|
|
1128
1150
|
this.recentToolCallIds.add(msg.tool_call_id);
|
|
1151
|
+
this.recentToolCallResults.set(msg.tool_call_id, result.message_id ?? null); // 0.13: for re-ACK replay
|
|
1129
1152
|
this.recentToolCallOrder.push(msg.tool_call_id);
|
|
1130
1153
|
// FIFO eviction at cap
|
|
1131
1154
|
while (this.recentToolCallOrder.length > RECENT_TOOL_CALL_LIMIT) {
|
|
1132
1155
|
const evicted = this.recentToolCallOrder.shift();
|
|
1133
1156
|
this.recentToolCallIds.delete(evicted);
|
|
1157
|
+
this.recentToolCallResults.delete(evicted);
|
|
1134
1158
|
}
|
|
1135
1159
|
}
|
|
1136
1160
|
|
|
@@ -1138,7 +1162,9 @@ class CliProcess extends Process {
|
|
|
1138
1162
|
// NEW tool_call_id still dedups. TTL-based via expiry timestamp.
|
|
1139
1163
|
if (result?.ok && msg.name === 'reply' && typeof args.text === 'string' && args.chat_id != null) {
|
|
1140
1164
|
const dedupKey = this._buildContentDedupKey(args.chat_id, args.text);
|
|
1141
|
-
|
|
1165
|
+
// 0.13: store the delivered message_id alongside the expiry so a deduped retry
|
|
1166
|
+
// can replay it (keeps claude's edit handle for progressive status).
|
|
1167
|
+
this.recentContentHashes.set(dedupKey, { expiry: Date.now() + this.contentDedupWindowMs, message_id: result.message_id ?? null });
|
|
1142
1168
|
}
|
|
1143
1169
|
|
|
1144
1170
|
// Review #16 + C9: only record the reply for pending-turn resolution when
|
|
@@ -1706,6 +1732,18 @@ class CliProcess extends Process {
|
|
|
1706
1732
|
return { live: backgroundShell, count: shellCount };
|
|
1707
1733
|
}
|
|
1708
1734
|
|
|
1735
|
+
/**
|
|
1736
|
+
* LRU eviction pin (0.12.0 spec). Cached read of `_bgWorkSince` — the idle bg-work
|
|
1737
|
+
* watchdog state maintained by `_pollBackgroundWork` on the ≤5s pong tick. Non-null ⟺ a
|
|
1738
|
+
* detached background shell has been observed while idle. No time cap: a job that runs for
|
|
1739
|
+
* hours stays pinned (elapsed time can't tell "slow-but-progressing" from "stuck"). Cheap,
|
|
1740
|
+
* sync — safe to call from `_evictLRU`.
|
|
1741
|
+
* @returns {boolean}
|
|
1742
|
+
*/
|
|
1743
|
+
hasActiveBackgroundWork() {
|
|
1744
|
+
return this._bgWorkSince !== null;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1709
1747
|
/**
|
|
1710
1748
|
* Stall-watchdog for detached background work (0.12.0 background-work
|
|
1711
1749
|
* lifecycle, shumorobot Music 7h frozen-Chrome download). Runs on the
|
package/lib/process/process.js
CHANGED
|
@@ -160,6 +160,19 @@ class Process extends EventEmitter {
|
|
|
160
160
|
return false;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Does this session have a DETACHED background job running (a `run_in_background`
|
|
165
|
+
* shell that outlives the dispatch turn)? Used by ProcessManager._evictLRU to PIN
|
|
166
|
+
* the session — skip it for eviction the same way `inFlight` is skipped — so a live
|
|
167
|
+
* job isn't silently killed under budget pressure. Default: no signal → false.
|
|
168
|
+
* Backends that can detect detached shells (cli) override this. Must be cheap + sync.
|
|
169
|
+
*
|
|
170
|
+
* @returns {boolean}
|
|
171
|
+
*/
|
|
172
|
+
hasActiveBackgroundWork() {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
163
176
|
/**
|
|
164
177
|
* Push priority='now' style steer (rare; legacy of OpenClaw shape).
|
|
165
178
|
* Hot-path-safe.
|
package/lib/process-guard.js
CHANGED
|
@@ -193,7 +193,60 @@ function _makeUnhandledRejectionHandler(opts) {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
/**
|
|
196
|
-
*
|
|
196
|
+
* Swallow EPIPE/EIO on the process's own stdout/stderr so a broken pipe during
|
|
197
|
+
* shutdown can't become an uncaughtException.
|
|
198
|
+
*
|
|
199
|
+
* rc.50 stopped the re-entrant LOOP (the handler no longer re-throws), but the
|
|
200
|
+
* write errors themselves still surface: when `launchctl kickstart` destroys the
|
|
201
|
+
* tmux pane, in-flight `console.log`/`console.error` calls hit a now-dead pty.
|
|
202
|
+
* Because stdout/stderr are TTYs, those writes throw EIO **synchronously** — each
|
|
203
|
+
* unguarded throw becomes an uncaughtException. Observed live on the rc.29→rc.30
|
|
204
|
+
* restart (2026-06-08): 100 `write EIO` rows then a circuit-breaker panic-exit on
|
|
205
|
+
* every deploy, interrupting the graceful drain.
|
|
206
|
+
*
|
|
207
|
+
* This guards BOTH delivery paths:
|
|
208
|
+
* (a) sync: wraps `write()` to drop EPIPE/EIO throws (TTY case — the real one);
|
|
209
|
+
* (b) async: attaches an `error` listener for the pipe case (errors arrive as
|
|
210
|
+
* events). Genuine, non-EPIPE/EIO errors still surface unchanged.
|
|
211
|
+
*
|
|
212
|
+
* @returns {{ uninstall: function() }}
|
|
213
|
+
*/
|
|
214
|
+
function guardStdio({ streams = [process.stdout, process.stderr] } = {}) {
|
|
215
|
+
const guarded = streams.filter(Boolean);
|
|
216
|
+
const isBrokenPipe = (err) => err && (err.code === 'EPIPE' || err.code === 'EIO');
|
|
217
|
+
const onError = (err) => { if (isBrokenPipe(err)) return; throw err; };
|
|
218
|
+
const restores = [];
|
|
219
|
+
|
|
220
|
+
for (const s of guarded) {
|
|
221
|
+
s.on?.('error', onError);
|
|
222
|
+
restores.push(() => s.off?.('error', onError));
|
|
223
|
+
|
|
224
|
+
if (typeof s.write === 'function' && !s.__polygramStdioGuarded) {
|
|
225
|
+
const origWrite = s.write;
|
|
226
|
+
s.write = function guardedWrite(...args) {
|
|
227
|
+
try {
|
|
228
|
+
return origWrite.apply(this, args);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (!isBrokenPipe(err)) throw err;
|
|
231
|
+
// Pane is gone — nothing to write to. Invoke the write callback (the
|
|
232
|
+
// last arg, if any) so callers awaiting it don't hang, and report
|
|
233
|
+
// backpressure (false) instead of throwing to uncaughtException.
|
|
234
|
+
const cb = args[args.length - 1];
|
|
235
|
+
if (typeof cb === 'function') { try { cb(err); } catch { /* ignore */ } }
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
s.__polygramStdioGuarded = true;
|
|
240
|
+
restores.push(() => { s.write = origWrite; delete s.__polygramStdioGuarded; });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { uninstall() { for (const r of restores) r(); } };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Convenience: install both handlers in one call (plus the stdio guard, so the
|
|
249
|
+
* shutdown broken-pipe writes never reach the uncaughtException handler).
|
|
197
250
|
* @returns {{ uninstall: function() }}
|
|
198
251
|
*/
|
|
199
252
|
function installSafetyHandlers(opts) {
|
|
@@ -201,10 +254,12 @@ function installSafetyHandlers(opts) {
|
|
|
201
254
|
const onRejection = _makeUnhandledRejectionHandler(opts);
|
|
202
255
|
process.on('uncaughtException', onException);
|
|
203
256
|
process.on('unhandledRejection', onRejection);
|
|
257
|
+
const stdio = guardStdio();
|
|
204
258
|
return {
|
|
205
259
|
uninstall() {
|
|
206
260
|
process.off('uncaughtException', onException);
|
|
207
261
|
process.off('unhandledRejection', onRejection);
|
|
262
|
+
stdio.uninstall();
|
|
208
263
|
},
|
|
209
264
|
};
|
|
210
265
|
}
|
|
@@ -235,6 +290,7 @@ module.exports = {
|
|
|
235
290
|
claimPidFile,
|
|
236
291
|
releasePidFile,
|
|
237
292
|
installSafetyHandlers,
|
|
293
|
+
guardStdio,
|
|
238
294
|
_makeUncaughtHandler,
|
|
239
295
|
_makeUnhandledRejectionHandler,
|
|
240
296
|
};
|
package/lib/process-manager.js
CHANGED
|
@@ -245,16 +245,36 @@ class ProcessManager {
|
|
|
245
245
|
const newCost = newProc.cost;
|
|
246
246
|
|
|
247
247
|
while (this.totalCost + newCost > this.budget) {
|
|
248
|
-
const evicted = this._evictLRU();
|
|
249
|
-
if (
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
248
|
+
const evicted = this._evictLRU(); // skips inFlight + background-job-pinned
|
|
249
|
+
if (evicted) continue;
|
|
250
|
+
// _evictLRU freed nothing. Policy C — split by WHY:
|
|
251
|
+
if (this._hasPinnedSession()) {
|
|
252
|
+
// A DURABLE blocker (live background job) holds a slot. Don't park on it (could be
|
|
253
|
+
// ~an hour) and don't kill it. The budget caps RSS, not correctness — so treat it as
|
|
254
|
+
// SOFT: spawn over budget + warn; the operator reclaims by /reset-ing a chat.
|
|
255
|
+
const pinned = this._pinnedSessionKeys();
|
|
256
|
+
this._logEvent('lru-overflow-pinned', {
|
|
257
|
+
active: this.procs.size,
|
|
258
|
+
totalCost: this.totalCost,
|
|
259
|
+
budget: this.budget,
|
|
260
|
+
newCost,
|
|
261
|
+
pinned,
|
|
262
|
+
});
|
|
263
|
+
this.logger.warn?.(
|
|
264
|
+
`[pm] budget ${this.budget} exceeded (~${this.totalCost + newCost}): all free slots hold ` +
|
|
265
|
+
`live background jobs [${pinned.join(', ')}]. Spawning over limit — /reset one of those ` +
|
|
266
|
+
`chats to reclaim memory.`,
|
|
267
|
+
);
|
|
268
|
+
break; // soft overflow — spawn anyway
|
|
269
|
+
}
|
|
270
|
+
// No pin — the blockers are all in-flight TURNS (transient, finish in seconds). Keep the
|
|
271
|
+
// existing behavior: park briefly for a slot rather than needlessly overflow.
|
|
272
|
+
await this._awaitLruSlot();
|
|
273
|
+
if (this._shuttingDown) {
|
|
274
|
+
try { await newProc.kill('shutdown'); } catch {}
|
|
275
|
+
throw new Error('shutdown');
|
|
257
276
|
}
|
|
277
|
+
// Loop again — budget may have freed up.
|
|
258
278
|
}
|
|
259
279
|
|
|
260
280
|
this._wireCallbacks(newProc);
|
|
@@ -278,8 +298,12 @@ class ProcessManager {
|
|
|
278
298
|
_evictLRU() {
|
|
279
299
|
let oldest = null;
|
|
280
300
|
let oldestKey = null;
|
|
301
|
+
let pinnedSkipped = 0;
|
|
281
302
|
for (const [k, p] of this.procs.entries()) {
|
|
282
303
|
if (p.inFlight) continue;
|
|
304
|
+
// PIN: a session with a live detached background job is NOT evictable — killing it
|
|
305
|
+
// would silently drop the job (and its report-back wakeup). Skip like inFlight.
|
|
306
|
+
if (p.hasActiveBackgroundWork()) { pinnedSkipped++; continue; }
|
|
283
307
|
if (!oldest || (p.lastUsedTs || 0) < (oldest.lastUsedTs || 0)) {
|
|
284
308
|
oldest = p;
|
|
285
309
|
oldestKey = k;
|
|
@@ -290,6 +314,7 @@ class ProcessManager {
|
|
|
290
314
|
active: this.procs.size,
|
|
291
315
|
totalCost: this.totalCost,
|
|
292
316
|
budget: this.budget,
|
|
317
|
+
pinnedSkipped,
|
|
293
318
|
});
|
|
294
319
|
return false;
|
|
295
320
|
}
|
|
@@ -297,12 +322,33 @@ class ProcessManager {
|
|
|
297
322
|
session_key: oldestKey,
|
|
298
323
|
cost: oldest.cost,
|
|
299
324
|
backend: oldest.backend,
|
|
325
|
+
pinnedSkipped,
|
|
300
326
|
});
|
|
301
327
|
oldest.kill('evict').catch(() => {});
|
|
302
328
|
this.procs.delete(oldestKey);
|
|
303
329
|
return true;
|
|
304
330
|
}
|
|
305
331
|
|
|
332
|
+
/**
|
|
333
|
+
* A DURABLE eviction blocker: a non-inFlight session holding a slot because it has a live
|
|
334
|
+
* background job (vs an inFlight TURN, which is transient and frees in seconds). Used to
|
|
335
|
+
* split park-vs-overflow when _evictLRU can free nothing.
|
|
336
|
+
*/
|
|
337
|
+
_hasPinnedSession() {
|
|
338
|
+
for (const p of this.procs.values()) {
|
|
339
|
+
if (!p.inFlight && p.hasActiveBackgroundWork()) return true;
|
|
340
|
+
}
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
_pinnedSessionKeys() {
|
|
345
|
+
const keys = [];
|
|
346
|
+
for (const [k, p] of this.procs.entries()) {
|
|
347
|
+
if (!p.inFlight && p.hasActiveBackgroundWork()) keys.push(k);
|
|
348
|
+
}
|
|
349
|
+
return keys;
|
|
350
|
+
}
|
|
351
|
+
|
|
306
352
|
async _awaitLruSlot() {
|
|
307
353
|
return new Promise((resolve, reject) => {
|
|
308
354
|
const timer = setTimeout(() => {
|
package/lib/questions/store.js
CHANGED
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
const { newToken, tokensEqual } = require('../approvals/store');
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// Option A (2026-06-09): don't expire a question before the turn that's blocking on
|
|
15
|
+
// it can. A blocking `ask` can live at most the 30-min turn ABSOLUTE cap
|
|
16
|
+
// (DEFAULT_TURN_ABSOLUTE_MS) — the keep-alive resets the idle cap but not the absolute
|
|
17
|
+
// — so align here. The user answers any time within the turn's life, not an arbitrary
|
|
18
|
+
// 8-min window. (Truly-unbounded "answer hours later" needs the non-blocking redesign.)
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
15
20
|
|
|
16
21
|
function createQuestionStore(rawDb, now = () => Date.now()) {
|
|
17
22
|
const insertStmt = rawDb.prepare(`
|
|
@@ -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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.12.0-rc.
|
|
3
|
+
"version": "0.12.0-rc.32",
|
|
4
4
|
"description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
|
|
5
5
|
"main": "lib/ipc/client.js",
|
|
6
6
|
"bin": {
|
package/polygram.js
CHANGED
|
@@ -58,6 +58,8 @@ const { createBuildSdkOptions } = require('./lib/sdk/build-options');
|
|
|
58
58
|
const { createSdkCallbacks } = require('./lib/sdk/callbacks');
|
|
59
59
|
const { createQuestionStore } = require('./lib/questions/store');
|
|
60
60
|
const { createQuestionHandlers } = require('./lib/handlers/questions');
|
|
61
|
+
const { isRewindCommand, createRewindHandler } = require('./lib/rewind/rewind');
|
|
62
|
+
const { createRewindExecutor } = require('./lib/rewind/execute');
|
|
61
63
|
const { createTranscribeVoiceAttachments } = require('./lib/handlers/voice');
|
|
62
64
|
const { createDownloadAttachments } = require('./lib/handlers/download');
|
|
63
65
|
const { createHandleConfigCallback } = require('./lib/handlers/config-callback');
|
|
@@ -337,6 +339,9 @@ function formatPrompt(msg, sessionCtx, attachments = [], { sessionKey = null } =
|
|
|
337
339
|
db,
|
|
338
340
|
chatId,
|
|
339
341
|
threadId: threadId || null,
|
|
342
|
+
// Per-topic sessions must only preload their OWN thread — else a message
|
|
343
|
+
// in one topic gets fed other topics' history (2026-06-09 cross-topic bleed).
|
|
344
|
+
isolateTopics: chatConfig?.isolateTopics === true,
|
|
340
345
|
excludeMsgId: msg.message_id,
|
|
341
346
|
logger: console,
|
|
342
347
|
});
|
|
@@ -649,6 +654,7 @@ let handleApprovalCallback = null;
|
|
|
649
654
|
// 0.12 interactive questions — assigned in main() once db.raw + pm exist; the
|
|
650
655
|
// createSdkCallbacks onQuestionAsked closure + the callback router read it late.
|
|
651
656
|
let questionHandlers = null;
|
|
657
|
+
let rewindHandler = null; // 0.13 /rewind (P1); late-bound, assigned in main() after pm exists
|
|
652
658
|
let resolveApprovalWaiter = null;
|
|
653
659
|
let startApprovalSweeper = null;
|
|
654
660
|
let cancelAllWaiters = null;
|
|
@@ -1862,6 +1868,37 @@ function createBot(token) {
|
|
|
1862
1868
|
const threadId = msg.message_thread_id?.toString();
|
|
1863
1869
|
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
1864
1870
|
|
|
1871
|
+
// 0.13 /rewind: the operator replies `/rewind` to a message to rewind the conversation to
|
|
1872
|
+
// before it. A command, so it bypasses the mention gate. Gated in the handler: rewind-safe
|
|
1873
|
+
// chat (DM or isolateTopics group) → operator/admin identity (or any paired user only when
|
|
1874
|
+
// rewindAccess='paired') → message-ownership. P1 detects/gates/defers; P2 runs the fork.
|
|
1875
|
+
// Defensive: never drop a message.
|
|
1876
|
+
if (rewindHandler && isRewindCommand(cleanText)) {
|
|
1877
|
+
try {
|
|
1878
|
+
// Operator identity: explicit operatorUserId, else the admin user — a PRIVATE adminChatId
|
|
1879
|
+
// equals that user's Telegram id. A group adminChatId (negative) is not a user id → never
|
|
1880
|
+
// matches a positive sender id → default-deny (no valid operator → /rewind refused).
|
|
1881
|
+
const opId = config.bot?.operatorUserId;
|
|
1882
|
+
const adminChatId = config.bot?.adminChatId;
|
|
1883
|
+
const operatorUid = opId != null ? Number(opId) : (adminChatId != null ? Number(adminChatId) : null);
|
|
1884
|
+
const isOperatorIdentity = operatorUid != null && msg.from?.id != null && Number(msg.from.id) === operatorUid;
|
|
1885
|
+
const paired = pairings && msg.from?.id
|
|
1886
|
+
? pairings.hasLivePairing({ bot_name: BOT_NAME, user_id: msg.from.id, chat_id: chatId })
|
|
1887
|
+
: false;
|
|
1888
|
+
const accessMode = chatConfig?.rewindAccess === 'paired' ? 'paired' : 'operator';
|
|
1889
|
+
// Rewind-safe: a DM (single session) OR a per-topic-isolated group. A shared group session
|
|
1890
|
+
// would blast every topic/user on rewind — refuse.
|
|
1891
|
+
const rewindSafe = msg.chat?.type === 'private' || chatConfig?.isolateTopics === true;
|
|
1892
|
+
const r = await rewindHandler.tryConsume({ sessionKey, chatId, threadId, msg, cleanText, botUsername, rewindSafe, isOperatorIdentity, paired, accessMode });
|
|
1893
|
+
if (r.consumed) return;
|
|
1894
|
+
} catch (err) {
|
|
1895
|
+
// The text IS a recognized /rewind command — on an internal error, consume it anyway
|
|
1896
|
+
// (Finding I). Falling through would send "/rewind" to claude as a normal prompt.
|
|
1897
|
+
console.error(`[${BOT_NAME}] rewind tryConsume failed: ${err?.message || err}`);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1865
1902
|
// 0.12 interactive questions: the owner's free-text "Other" answer arrives
|
|
1866
1903
|
// WITHOUT an @mention, so in a mention-gated group shouldHandle would reject
|
|
1867
1904
|
// it and the "Other" flow would silently dead-end. Let the claimed owner's
|
|
@@ -2295,6 +2332,15 @@ async function main() {
|
|
|
2295
2332
|
}
|
|
2296
2333
|
} catch (e) { console.error(`[${BOT_NAME}] question sweep: ${e.message}`); }
|
|
2297
2334
|
}, 30_000).unref?.();
|
|
2335
|
+
|
|
2336
|
+
// 0.13 /rewind: detect + operator/ownership gate + turn-end defer + confirm (P1), backed by
|
|
2337
|
+
// the copy-only transcript-fork executor (P2/P3: fork → repoint the session → kill → delete
|
|
2338
|
+
// orphaned bot messages). channels/cli only; the fork mechanism was proven in P0.6.
|
|
2339
|
+
// See docs/0.13-rewind-design.md.
|
|
2340
|
+
const executeRewind = createRewindExecutor({ db, pm, tg, bot, botName: BOT_NAME, logEvent, logger: console });
|
|
2341
|
+
rewindHandler = createRewindHandler({
|
|
2342
|
+
pm, tg, bot, botName: BOT_NAME, logEvent, logger: console, executeRewind,
|
|
2343
|
+
});
|
|
2298
2344
|
buildSdkOptions = createBuildSdkOptions({
|
|
2299
2345
|
config,
|
|
2300
2346
|
botName: BOT_NAME,
|