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.
- package/config.example.json +3 -1
- 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
|
@@ -23,7 +23,7 @@ const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
|
23
23
|
// polygram passes the alias (opus / sonnet / haiku) and lets claude
|
|
24
24
|
// resolve. Bump on Claude release.
|
|
25
25
|
const MODEL_VERSIONS_DESC = {
|
|
26
|
-
opus: 'claude-opus-4-
|
|
26
|
+
opus: 'claude-opus-4-8',
|
|
27
27
|
sonnet: 'claude-sonnet-4-6',
|
|
28
28
|
haiku: 'claude-haiku-4-5',
|
|
29
29
|
};
|
|
@@ -31,19 +31,23 @@ const MODEL_VERSIONS_DESC = {
|
|
|
31
31
|
/**
|
|
32
32
|
* Build the inline keyboard for /model + /effort.
|
|
33
33
|
* show = 'model' | 'effort' | 'all'
|
|
34
|
-
* The current value gets a ✓ prefix.
|
|
34
|
+
* The current value gets a ✓ prefix. `topicConfig` (per-topic overrides, or
|
|
35
|
+
* null for the chat-level card) wins over chatConfig so the ✓ matches what a
|
|
36
|
+
* topic actually runs — mirrors the spawn-path precedence (topic > chat).
|
|
35
37
|
*/
|
|
36
|
-
function buildConfigKeyboard(chatConfig, show = 'all') {
|
|
38
|
+
function buildConfigKeyboard(chatConfig, show = 'all', topicConfig = null) {
|
|
39
|
+
const model = (topicConfig && topicConfig.model) || chatConfig.model;
|
|
40
|
+
const effort = (topicConfig && topicConfig.effort) || chatConfig.effort;
|
|
37
41
|
const rows = [];
|
|
38
42
|
if (show === 'model' || show === 'all') {
|
|
39
43
|
rows.push(MODEL_OPTIONS.map((m) => ({
|
|
40
|
-
text: m ===
|
|
44
|
+
text: m === model ? `✓ ${m}` : m,
|
|
41
45
|
callback_data: `cfg:model:${m}`,
|
|
42
46
|
})));
|
|
43
47
|
}
|
|
44
48
|
if (show === 'effort' || show === 'all') {
|
|
45
49
|
rows.push(EFFORT_OPTIONS.map((e) => ({
|
|
46
|
-
text: e ===
|
|
50
|
+
text: e === effort ? `✓ ${e}` : e,
|
|
47
51
|
callback_data: `cfg:effort:${e}`,
|
|
48
52
|
})));
|
|
49
53
|
}
|
|
@@ -60,14 +64,39 @@ function buildConfigKeyboard(chatConfig, show = 'all') {
|
|
|
60
64
|
* @param {(db, sessionKey) => string|null} deps.getClaudeSessionId
|
|
61
65
|
*/
|
|
62
66
|
function createFormatConfigInfoText({ pm, db, getClaudeSessionId } = {}) {
|
|
63
|
-
return function formatConfigInfoText(chatConfig, show, sessionKey) {
|
|
67
|
+
return function formatConfigInfoText(chatConfig, show, sessionKey, topicConfig = null) {
|
|
64
68
|
const alive = pm.has(sessionKey) && !pm.get(sessionKey).closed;
|
|
65
|
-
|
|
69
|
+
// Per-topic overrides win over chat-level for the displayed values,
|
|
70
|
+
// mirroring the spawn path (polygram.js: topicConfig.agent ||
|
|
71
|
+
// chatConfig.agent). Pre-fix the card always read chat-level, so a topic's
|
|
72
|
+
// /model showed the WRONG agent — shumorobot Music topic (thread 3) showed
|
|
73
|
+
// "Agent: shumabit" instead of its music-curation:music-curator override
|
|
74
|
+
// (2026-06-03). topicConfig defaults to null (chat-level) for callers with
|
|
75
|
+
// no active topic.
|
|
76
|
+
const model = (topicConfig && topicConfig.model) || chatConfig.model;
|
|
77
|
+
const effort = (topicConfig && topicConfig.effort) || chatConfig.effort;
|
|
78
|
+
const agent = (topicConfig && topicConfig.agent) || chatConfig.agent;
|
|
79
|
+
const ver = MODEL_VERSIONS_DESC[model] || model;
|
|
66
80
|
const sess = getClaudeSessionId(db, sessionKey)?.slice(0, 8) || 'new';
|
|
81
|
+
// Running vs configured: cli can't hot-swap model/effort, so a /model or
|
|
82
|
+
// /effort change is PENDING until the session reloads (on the next message).
|
|
83
|
+
// Show the truth — the live proc's spawn-time value (proc.model/proc.effort)
|
|
84
|
+
// vs the configured one — so the card never claims a model the session
|
|
85
|
+
// isn't actually running (the "says opus, runs sonnet" confusion). SDK
|
|
86
|
+
// applies live (its proc value tracks config) so no drift line ever shows.
|
|
87
|
+
const proc = alive ? pm.get(sessionKey) : null;
|
|
88
|
+
const runModel = proc && proc.model;
|
|
89
|
+
const runEffort = proc && proc.effort;
|
|
90
|
+
const modelLine = (runModel && runModel !== model)
|
|
91
|
+
? `Model: ${runModel} (running) → ${model} (pending — applies on your next message)`
|
|
92
|
+
: `Model: ${model} (${ver})`;
|
|
93
|
+
const effortLine = (runEffort && runEffort !== effort)
|
|
94
|
+
? `Effort: ${runEffort} (running) → ${effort} (pending — applies on your next message)`
|
|
95
|
+
: `Effort: ${effort}`;
|
|
67
96
|
const head =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
`Agent: ${
|
|
97
|
+
`${modelLine}\n` +
|
|
98
|
+
`${effortLine}\n` +
|
|
99
|
+
`Agent: ${agent}\n` +
|
|
71
100
|
`Process: ${alive ? 'warm' : 'cold'}\n` +
|
|
72
101
|
`Session: ${sess}`;
|
|
73
102
|
|
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
|
|
25
25
|
const CONCURRENT_WARN_THRESHOLD_DEFAULT = 20;
|
|
26
26
|
|
|
27
|
+
// Startup auto-retry (option a, 2026-06-04): a short breath before silently
|
|
28
|
+
// re-dispatching a message whose first attempt died in the dev-channels startup
|
|
29
|
+
// gate (TMUX_SESSION_GONE). Long enough that a host under momentary load isn't
|
|
30
|
+
// hammered with a back-to-back respawn, short enough that a transient flake
|
|
31
|
+
// still recovers fast enough to feel instant to the user.
|
|
32
|
+
const STARTUP_RETRY_DELAY_MS = 1500;
|
|
33
|
+
|
|
27
34
|
function createDispatcher({
|
|
28
35
|
config,
|
|
29
36
|
db,
|
|
@@ -48,6 +55,9 @@ function createDispatcher({
|
|
|
48
55
|
// the historic 4096 for back-compat in synthetic test runs that pass
|
|
49
56
|
// pre-formatted text.
|
|
50
57
|
chunkBudget = 4096,
|
|
58
|
+
// Delay before a silent startup auto-retry re-dispatches (TMUX_SESSION_GONE).
|
|
59
|
+
// Injected so tests can drive it to 0; production uses STARTUP_RETRY_DELAY_MS.
|
|
60
|
+
startupRetryDelayMs = STARTUP_RETRY_DELAY_MS,
|
|
51
61
|
// State accessors (need late binding because polygram.js mutates):
|
|
52
62
|
getIsShuttingDown, // () → boolean
|
|
53
63
|
logger = console,
|
|
@@ -178,6 +188,26 @@ function createDispatcher({
|
|
|
178
188
|
aborted: wasAborted || undefined,
|
|
179
189
|
replay: isReplay || undefined,
|
|
180
190
|
});
|
|
191
|
+
// Startup-gate death (claude exited during spawn / the dialog gate timed
|
|
192
|
+
// out) of a likely-aged RESUMED session — the persisted claude_session_id
|
|
193
|
+
// can't be resumed cleanly (shumorobot general chat 2026-06-01→03: a
|
|
194
|
+
// week-old session renders claude's "Resume from summary?" dialog whose
|
|
195
|
+
// /compact resume exits code 0 → TMUX_SESSION_GONE → the chat re-resumes
|
|
196
|
+
// the same dead id on every message, stuck for days). Poison-clear so the
|
|
197
|
+
// NEXT message spawns a FRESH session — same recovery the auto-resume path
|
|
198
|
+
// does for BRIDGE_DISCONNECTED below. clearSessionId is a no-op DELETE when
|
|
199
|
+
// there's no row (a genuine fresh-spawn failure), so this is safe; and
|
|
200
|
+
// unlike an in-process recursive retry it never reuses a closed instance.
|
|
201
|
+
if ((err.code === 'TMUX_SESSION_GONE' || err.code === 'CHANNELS_DIALOG_TIMEOUT')
|
|
202
|
+
&& typeof db.clearSessionId === 'function') {
|
|
203
|
+
dbWrite(
|
|
204
|
+
() => db.clearSessionId(sessionKey),
|
|
205
|
+
`clearSessionId: poisoned by ${err.code} on startup`,
|
|
206
|
+
);
|
|
207
|
+
logEvent('session-reset-after-startup-gate', {
|
|
208
|
+
chat_id: chatId, session_key: sessionKey, msg_id: msg?.message_id, code: err.code,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
181
211
|
// rc.55: surface replay failures with a meaningful message.
|
|
182
212
|
// Pre-rc.55 any boot-replay turn that failed for ANY reason
|
|
183
213
|
// was silently dropped. The rc.51-onward boot-replay path is
|
|
@@ -197,6 +227,35 @@ function createDispatcher({
|
|
|
197
227
|
// - shutting down ("Process killed" isn't a real error),
|
|
198
228
|
// - user just /stop'd (already saw their abort ack).
|
|
199
229
|
if (!wasAborted && !isReplay && !isShuttingDown) {
|
|
230
|
+
// Startup auto-retry (option a, 2026-06-04). TMUX_SESSION_GONE = claude
|
|
231
|
+
// exited INSIDE the startup gate, before the dev-channels channel went
|
|
232
|
+
// live — so the user's message was NEVER delivered to claude. That makes
|
|
233
|
+
// a re-send idempotent BY CONSTRUCTION (unlike a mid-turn drop, where
|
|
234
|
+
// claude might still be slowly processing). The session_id was just
|
|
235
|
+
// poison-cleared above, so re-dispatching the SAME message spawns a FRESH
|
|
236
|
+
// session and delivers it. Silent: a transient startup flake (recurs
|
|
237
|
+
// ~once/9h on the channels backend) never reaches the user — instead of
|
|
238
|
+
// the "🔄 reset it, resend" papercut, polygram just retries. One-shot
|
|
239
|
+
// (_startupRetried) so a host that genuinely can't start claude surfaces
|
|
240
|
+
// the friendly reset reply (below) after EXACTLY one retry, never a loop.
|
|
241
|
+
// Scoped to TMUX_SESSION_GONE only: CHANNELS_DIALOG_TIMEOUT is a real
|
|
242
|
+
// blocking dialog (usage-limit / permission) a retry would just re-hit,
|
|
243
|
+
// so it keeps its "please resend" copy.
|
|
244
|
+
if (err.code === 'TMUX_SESSION_GONE' && !msg._startupRetried) {
|
|
245
|
+
logEvent('startup-auto-retry', {
|
|
246
|
+
chat_id: chatId, session_key: sessionKey, msg_id: msg?.message_id,
|
|
247
|
+
});
|
|
248
|
+
// Re-dispatch a COPY carrying the one-shot marker — never mutate the
|
|
249
|
+
// caller's msg (the boot-replay path shares/re-reads it). unref the
|
|
250
|
+
// best-effort timer so a pending retry can't pin the daemon alive
|
|
251
|
+
// (the Telegram long-poll already keeps the loop running).
|
|
252
|
+
const retryMsg = { ...msg, _startupRetried: true };
|
|
253
|
+
setTimeout(
|
|
254
|
+
() => dispatchHandleMessage(sessionKey, chatId, retryMsg, bot),
|
|
255
|
+
startupRetryDelayMs,
|
|
256
|
+
).unref?.();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
200
259
|
// rc.54: auto-resume on 300s no-activity timeout. The
|
|
201
260
|
// resume turn itself runs through sendToProcess directly
|
|
202
261
|
// (not handleMessage), so its errors don't re-enter this
|
|
@@ -224,6 +283,29 @@ function createDispatcher({
|
|
|
224
283
|
chat_id: chatId, session_key: sessionKey, msg_id: msg.message_id,
|
|
225
284
|
error: resumeErr?.message?.slice(0, 200),
|
|
226
285
|
});
|
|
286
|
+
// Music topic incident (2026-06-01): a channels session whose
|
|
287
|
+
// context grew large enough to auto-/compact on resume loses its
|
|
288
|
+
// MCP bridge binding on EVERY resume ("no MCP server configured"),
|
|
289
|
+
// so the resumed turn re-detaches (BRIDGE_DISCONNECTED) and lands
|
|
290
|
+
// here. The persisted claude_session_id is then poisoned — every
|
|
291
|
+
// future message (manual resend OR post-cooldown auto-resume)
|
|
292
|
+
// re-resumes it and re-detaches, an endless "🔌 please resend"
|
|
293
|
+
// loop. Break it: drop the session row so the NEXT message spawns
|
|
294
|
+
// a FRESH session (no --resume). Gated on the ORIGINAL error being
|
|
295
|
+
// a bridge-detach AND auto-resume having failed — a one-off bridge
|
|
296
|
+
// crash that resumes cleanly takes the .then() path above and
|
|
297
|
+
// keeps its context; only a session that re-detaches on resume is
|
|
298
|
+
// treated as poison. We lose the poisoned conversation's history,
|
|
299
|
+
// but that session can't complete a turn anyway.
|
|
300
|
+
if (err.code === 'BRIDGE_DISCONNECTED' && typeof db.clearSessionId === 'function') {
|
|
301
|
+
dbWrite(
|
|
302
|
+
() => db.clearSessionId(sessionKey),
|
|
303
|
+
'clearSessionId: poisoned by bridge-detach on resume',
|
|
304
|
+
);
|
|
305
|
+
logEvent('session-reset-after-bridge-detach', {
|
|
306
|
+
chat_id: chatId, session_key: sessionKey, msg_id: msg.message_id,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
227
309
|
const fallbackText = errorReplyText(err);
|
|
228
310
|
if (fallbackText) {
|
|
229
311
|
tg(bot, 'sendMessage', {
|
|
@@ -266,4 +348,5 @@ function createDispatcher({
|
|
|
266
348
|
module.exports = {
|
|
267
349
|
createDispatcher,
|
|
268
350
|
CONCURRENT_WARN_THRESHOLD_DEFAULT,
|
|
351
|
+
STARTUP_RETRY_DELAY_MS,
|
|
269
352
|
};
|
package/lib/handlers/download.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
const fs = require('fs');
|
|
25
25
|
const path = require('path');
|
|
26
26
|
const { redactBotToken } = require('../error/net');
|
|
27
|
-
const { MAX_FILE_BYTES } = require('../attachments');
|
|
27
|
+
const { MAX_FILE_BYTES, resolveFileCaps } = require('../attachments');
|
|
28
28
|
|
|
29
29
|
const ATTACHMENT_DOWNLOAD_CONCURRENCY_DEFAULT = 6;
|
|
30
30
|
|
|
@@ -60,76 +60,119 @@ function createDownloadAttachments({
|
|
|
60
60
|
} catch { /* fall through to refetch */ }
|
|
61
61
|
}
|
|
62
62
|
try {
|
|
63
|
+
// Inbound per-file cap is BACKEND-derived: 20 MB on cloud Telegram
|
|
64
|
+
// (Telegram's own getFile ceiling), 2 GB with the local Bot API server.
|
|
65
|
+
// rc.15: previously hardcoded to MAX_FILE_BYTES (20 MB), which rejected
|
|
66
|
+
// large lossless tracks even when the local server could handle them.
|
|
67
|
+
const cap = resolveFileCaps({ localApi: !!config.bot?.apiRoot }).inBytes;
|
|
68
|
+
|
|
63
69
|
const fileInfo = await bot.api.getFile(att.file_id);
|
|
64
70
|
if (!fileInfo?.file_path) throw new Error('no file_path from getFile');
|
|
65
|
-
|
|
66
|
-
const res = await fetchImpl(url);
|
|
67
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
68
|
-
// Three-layer size enforcement, in order of cheapness:
|
|
69
|
-
// 1. Content-Length header — fail-fast before reading body.
|
|
70
|
-
// 2. Streaming accumulator — abort the moment cumulative byte
|
|
71
|
-
// count crosses the cap. Defends against attackers omitting
|
|
72
|
-
// Content-Length: pre-cap the whole body could pin RSS.
|
|
73
|
-
// 3. Final post-buffer check — defense in depth.
|
|
74
|
-
const cl = parseInt(res.headers.get('content-length') || '0', 10);
|
|
75
|
-
if (cl > MAX_FILE_BYTES) {
|
|
76
|
-
throw new Error(`content-length ${cl} exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
77
|
-
}
|
|
78
|
-
let total = 0;
|
|
79
|
-
const chunks = [];
|
|
80
|
-
if (res.body && typeof res.body.getReader === 'function') {
|
|
81
|
-
const reader = res.body.getReader();
|
|
82
|
-
while (true) {
|
|
83
|
-
const { done, value } = await reader.read();
|
|
84
|
-
if (done) break;
|
|
85
|
-
total += value.byteLength;
|
|
86
|
-
if (total > MAX_FILE_BYTES) {
|
|
87
|
-
try { await reader.cancel(); } catch {}
|
|
88
|
-
throw new Error(`stream ${total}+ bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
89
|
-
}
|
|
90
|
-
chunks.push(value);
|
|
91
|
-
}
|
|
92
|
-
} else {
|
|
93
|
-
// Fallback for runtimes without WHATWG streams (shouldn't fire
|
|
94
|
-
// on Node 22+).
|
|
95
|
-
const ab = await res.arrayBuffer();
|
|
96
|
-
if (ab.byteLength > MAX_FILE_BYTES) {
|
|
97
|
-
throw new Error(`body ${ab.byteLength} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
98
|
-
}
|
|
99
|
-
chunks.push(new Uint8Array(ab));
|
|
100
|
-
total = ab.byteLength;
|
|
101
|
-
}
|
|
102
|
-
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
|
|
103
|
-
if (buf.length > MAX_FILE_BYTES) {
|
|
104
|
-
throw new Error(`body ${buf.length} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
105
|
-
}
|
|
71
|
+
|
|
106
72
|
const safeName = sanitizeFilename(att.name);
|
|
107
73
|
// Embed file_unique_id so two attachments with the same msg_id+name
|
|
108
74
|
// (album, resend) can't silently overwrite each other.
|
|
109
75
|
const uniq = att.file_unique_id ? `-${att.file_unique_id}` : '';
|
|
110
76
|
const localName = `${msg.message_id}${uniq}-${safeName}`;
|
|
111
77
|
const localPath = path.join(chatDir, localName);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
78
|
+
|
|
79
|
+
let size;
|
|
80
|
+
|
|
81
|
+
if (path.isAbsolute(fileInfo.file_path)) {
|
|
82
|
+
// ── Local Bot API server ────────────────────────────────────────
|
|
83
|
+
// rc.15: in `--local` mode getFile returns a LOCAL ABSOLUTE PATH —
|
|
84
|
+
// the server has already downloaded the file to its own disk. The
|
|
85
|
+
// previous code built a cloud URL (https://api.telegram.org/file/...)
|
|
86
|
+
// and HTTP-fetched it, which is nonsensical for a local path and
|
|
87
|
+
// failed every inbound file once apiRoot was set. Instead, link the
|
|
88
|
+
// file into the inbox directly (no HTTP, no buffering a 2 GB file
|
|
89
|
+
// through RAM). A hardlink is instant and shares the inode, so it
|
|
90
|
+
// survives the server pruning its own copy; fall back to a byte copy
|
|
91
|
+
// across filesystems.
|
|
92
|
+
const srcStat = fs.statSync(fileInfo.file_path);
|
|
93
|
+
if (srcStat.size > cap) {
|
|
94
|
+
throw new Error(`file ${srcStat.size} exceeds per-file cap ${cap}`);
|
|
95
|
+
}
|
|
96
|
+
if (fs.existsSync(localPath)) {
|
|
97
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (already on disk, reusing)`);
|
|
98
|
+
} else {
|
|
99
|
+
try {
|
|
100
|
+
fs.linkSync(fileInfo.file_path, localPath);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (e.code === 'EEXIST') {
|
|
103
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
|
|
104
|
+
} else if (e.code === 'EXDEV') {
|
|
105
|
+
fs.copyFileSync(fileInfo.file_path, localPath); // cross-device fallback
|
|
106
|
+
} else {
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
size = srcStat.size;
|
|
112
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (${size} bytes, local-api) → ${localPath}`);
|
|
117
113
|
} else {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
114
|
+
// ── Cloud Telegram ──────────────────────────────────────────────
|
|
115
|
+
// getFile returns a RELATIVE path; download it over HTTPS with the
|
|
116
|
+
// three-layer size guard (header → streaming accumulator → final).
|
|
117
|
+
const url = `https://api.telegram.org/file/bot${token}/${fileInfo.file_path}`;
|
|
118
|
+
const res = await fetchImpl(url);
|
|
119
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
120
|
+
const cl = parseInt(res.headers.get('content-length') || '0', 10);
|
|
121
|
+
if (cl > cap) {
|
|
122
|
+
throw new Error(`content-length ${cl} exceeds per-file cap ${cap}`);
|
|
126
123
|
}
|
|
124
|
+
let total = 0;
|
|
125
|
+
const chunks = [];
|
|
126
|
+
if (res.body && typeof res.body.getReader === 'function') {
|
|
127
|
+
const reader = res.body.getReader();
|
|
128
|
+
while (true) {
|
|
129
|
+
const { done, value } = await reader.read();
|
|
130
|
+
if (done) break;
|
|
131
|
+
total += value.byteLength;
|
|
132
|
+
if (total > cap) {
|
|
133
|
+
try { await reader.cancel(); } catch {}
|
|
134
|
+
throw new Error(`stream ${total}+ bytes exceeds per-file cap ${cap}`);
|
|
135
|
+
}
|
|
136
|
+
chunks.push(value);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// Fallback for runtimes without WHATWG streams (shouldn't fire
|
|
140
|
+
// on Node 22+).
|
|
141
|
+
const ab = await res.arrayBuffer();
|
|
142
|
+
if (ab.byteLength > cap) {
|
|
143
|
+
throw new Error(`body ${ab.byteLength} bytes exceeds per-file cap ${cap}`);
|
|
144
|
+
}
|
|
145
|
+
chunks.push(new Uint8Array(ab));
|
|
146
|
+
total = ab.byteLength;
|
|
147
|
+
}
|
|
148
|
+
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
|
|
149
|
+
if (buf.length > cap) {
|
|
150
|
+
throw new Error(`body ${buf.length} bytes exceeds per-file cap ${cap}`);
|
|
151
|
+
}
|
|
152
|
+
// Atomic write: temp file + rename. A crash mid-write leaves a
|
|
153
|
+
// .tmp.* file (swept later) rather than a truncated canonical
|
|
154
|
+
// file the EEXIST dedup branch would happily serve next time.
|
|
155
|
+
if (fs.existsSync(localPath)) {
|
|
156
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (already on disk, reusing)`);
|
|
157
|
+
} else {
|
|
158
|
+
const tmpPath = `${localPath}.tmp.${process.pid}.${Date.now()}`;
|
|
159
|
+
try {
|
|
160
|
+
fs.writeFileSync(tmpPath, buf, { flag: 'wx' });
|
|
161
|
+
fs.renameSync(tmpPath, localPath);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
164
|
+
if (e.code !== 'EEXIST') throw e;
|
|
165
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
size = buf.length;
|
|
169
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (${size} bytes) → ${localPath}`);
|
|
127
170
|
}
|
|
128
|
-
|
|
171
|
+
|
|
129
172
|
dbWrite(() => db.markAttachmentDownloaded(att.id, {
|
|
130
|
-
local_path: localPath, size_bytes: att.size_bytes ||
|
|
173
|
+
local_path: localPath, size_bytes: att.size_bytes || size,
|
|
131
174
|
}), `markAttachmentDownloaded ${att.id}`);
|
|
132
|
-
return { ...att, path: localPath, size: att.size_bytes ||
|
|
175
|
+
return { ...att, path: localPath, size: att.size_bytes || size, error: null };
|
|
133
176
|
} catch (err) {
|
|
134
177
|
// Don't drop the attachment silently — push it through with the
|
|
135
178
|
// failure noted. buildAttachmentTags renders this as
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Drop-redeliverer (0.13 D2 → D4 glue, docs/0.13-channels-lifecycle-design.md §3 D2).
|
|
5
|
+
*
|
|
6
|
+
* Consumes CliProcess's 'input-dropped' event — a ledgered input that was
|
|
7
|
+
* confirmed dropped (never seen at pickup, never acknowledged via
|
|
8
|
+
* consumed_turn_ids, not superseded, with the ack contract observed in the
|
|
9
|
+
* cycle) — and redelivers it ONCE through the unified D4 tail.
|
|
10
|
+
*
|
|
11
|
+
* Eligibility (design's redelivery constraints):
|
|
12
|
+
* - `primary` and `autosteer` sources only: both reconstruct from the
|
|
13
|
+
* inbound DB row (recordInbound persisted the raw message), so the
|
|
14
|
+
* redelivered turn re-formats through the NORMAL prompt path — no stored
|
|
15
|
+
* prompt text, no double-formatting, events stay content-free (L13).
|
|
16
|
+
* - `edit-fold` / `system` / `inject` park as telemetry
|
|
17
|
+
* (input-dropped-no-redeliver): an edit correction has its own
|
|
18
|
+
* re-delivery path, and system pushes are never auto-re-executed.
|
|
19
|
+
*
|
|
20
|
+
* The D4 tail then enforces: once-only, _isReplay, the D5 gate at tier
|
|
21
|
+
* 'redelivery' (an abort/admin-shaped drop is never auto-re-executed), the
|
|
22
|
+
* visible 👀 ack, and dispatch. Supersession was already decided ledger-side.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
function createDropRedeliverer({ db, redeliver, logEvent = () => {}, logger = console } = {}) {
|
|
26
|
+
if (typeof redeliver !== 'function') throw new TypeError('drop-redeliver: redeliver required');
|
|
27
|
+
|
|
28
|
+
return async function onInputDropped(sessionKey, payload = {}) {
|
|
29
|
+
try {
|
|
30
|
+
const { chatId, msgId, source, turnId } = payload;
|
|
31
|
+
if (source !== 'primary' && source !== 'autosteer') {
|
|
32
|
+
logEvent('input-dropped-no-redeliver', {
|
|
33
|
+
chat_id: chatId ?? null, msg_id: msgId ?? null, source: source ?? null,
|
|
34
|
+
turn_id: turnId ?? null, reason: 'source-not-redeliverable',
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (msgId == null) {
|
|
39
|
+
logEvent('input-dropped-no-redeliver', {
|
|
40
|
+
chat_id: chatId ?? null, source, turn_id: turnId ?? null, reason: 'no-msg-id',
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const row = db.getMessage(String(chatId), Number(msgId));
|
|
45
|
+
if (!row) {
|
|
46
|
+
logEvent('input-dropped-no-redeliver', {
|
|
47
|
+
chat_id: chatId, msg_id: msgId, source, turn_id: turnId ?? null, reason: 'no-db-row',
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Reconstruct the boot-replay way: enough of a grammy Message for the
|
|
52
|
+
// normal prompt/attachment path to re-run from the persisted row.
|
|
53
|
+
const reconstructed = {
|
|
54
|
+
chat: { id: Number(chatId), type: String(chatId).startsWith('-') ? 'supergroup' : 'private' },
|
|
55
|
+
message_id: Number(msgId),
|
|
56
|
+
from: { id: row.user_id, first_name: row.user },
|
|
57
|
+
text: row.text || '',
|
|
58
|
+
date: Math.floor((row.ts || Date.now()) / 1000),
|
|
59
|
+
...(row.thread_id && { message_thread_id: Number(row.thread_id) }),
|
|
60
|
+
...(row.reply_to_id && { reply_to_message: { message_id: row.reply_to_id } }),
|
|
61
|
+
};
|
|
62
|
+
await redeliver({ chatId: String(chatId), msg: reconstructed, source: 'drop' });
|
|
63
|
+
} catch (err) {
|
|
64
|
+
logger.error?.(`[drop-redeliver] ${err?.message || err}`);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { createDropRedeliverer };
|
|
@@ -59,6 +59,8 @@ function createEditCorrectionInjector({
|
|
|
59
59
|
const ok = pm.injectUserMessage(sessionKey, {
|
|
60
60
|
content: `[edit] I corrected my previous message — it now reads: ${newText}`,
|
|
61
61
|
priority: 'next',
|
|
62
|
+
msgId: editedMsg.message_id,
|
|
63
|
+
source: 'edit-fold', // 0.13 D2: ledgered (telemetry; edits have their own redelivery path)
|
|
62
64
|
});
|
|
63
65
|
if (!ok) {
|
|
64
66
|
logger.error?.(`[${chatConfig.name || chatId}] edit-correction inject failed`);
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Post-turn edit re-delivery (0.12.0). When the user edits a Telegram message AFTER claude's turn
|
|
5
|
+
* has finished, re-dispatch the edited message as a NEW turn so claude acts on the change — the
|
|
6
|
+
* "an edit is just a message" model. The mid-turn case (turn still in flight) stays with the
|
|
7
|
+
* existing injector (lib/handlers/edit-correction.js); this is the post-turn path.
|
|
8
|
+
*
|
|
9
|
+
* Spec: docs/0.12.0-edit-redelivery-spec.md (twice-reviewed). Key correctness points the review
|
|
10
|
+
* surfaced:
|
|
11
|
+
* - Convey the change via reply_to carrying the OLD text (the caller captures it before
|
|
12
|
+
* recordInbound overwrites the row) — replying to the live row would quote the NEW text, so
|
|
13
|
+
* claude would see no before/after and couldn't tell it's an edit.
|
|
14
|
+
* - GATE on the REAL edited message, NOT the synthetic: a self-reply_to trips shouldHandle's
|
|
15
|
+
* `repliesToOtherUser` and drops paired users in mention-gated groups.
|
|
16
|
+
* - The synthetic is `_isReplay`-tagged → no new editable row, never replay-eligible, error
|
|
17
|
+
* reply suppressed.
|
|
18
|
+
* - A re-edit while our re-run is in flight FOLDS via inject (the interlock) rather than
|
|
19
|
+
* starting a second turn.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} deps
|
|
22
|
+
* @param {object} deps.pm ProcessManager (get(sessionKey).inFlight, injectUserMessage)
|
|
23
|
+
* @param {object} deps.config
|
|
24
|
+
* @param {Function} deps.getSessionKey (chatId, threadId, chatConfig) => sessionKey
|
|
25
|
+
* @param {Function} deps.shouldHandle (msg, chatConfig, botUsername) => boolean — the real gate
|
|
26
|
+
* @param {Function} deps.dispatchHandleMessage (sessionKey, chatId, msg, bot) => void
|
|
27
|
+
* @param {object} deps.bot
|
|
28
|
+
* @param {Function} [deps.react] (chatId, msgId) => void|Promise — on-edit acknowledgment
|
|
29
|
+
* @param {Function} [deps.logEvent]
|
|
30
|
+
* @param {object} [deps.logger]
|
|
31
|
+
* @returns {(editedMsg, oldText, botUsername, mentionRe?) => boolean} true when a fresh turn was
|
|
32
|
+
* dispatched. botUsername / mentionRe are passed at CALL time (not construction): they are
|
|
33
|
+
* resolved asynchronously via getMe and live in the createBot scope, so capturing them in the
|
|
34
|
+
* factory (built in main()) would both be out of scope and freeze the empty initial values.
|
|
35
|
+
*/
|
|
36
|
+
function createEditRedelivery({
|
|
37
|
+
pm, config, getSessionKey, shouldHandle, dispatchHandleMessage, bot,
|
|
38
|
+
react, logEvent = () => {}, logger = console,
|
|
39
|
+
} = {}) {
|
|
40
|
+
// botUsername / mentionRe arrive at CALL time — see @returns. Constructing with them threw
|
|
41
|
+
// `ReferenceError: mentionRe is not defined` at boot (rc.34): the factory runs in main() where
|
|
42
|
+
// those createBot locals don't exist. The edited_message handler passes the live values.
|
|
43
|
+
//
|
|
44
|
+
// 0.13 D5 (spec §5 as written): the in-flight interlock is per-(chatId,msgId),
|
|
45
|
+
// not per-session. A re-edit of the SAME message while its re-dispatch runs
|
|
46
|
+
// folds via inject; an edit of a DIFFERENT message proceeds as its own
|
|
47
|
+
// redelivery (dispatchHandleMessage autosteers it naturally if a turn is in
|
|
48
|
+
// flight — through the formatted-prompt path, not a hand-built string).
|
|
49
|
+
const redeliveredAt = new Map(); // `${chatId}:${msgId}` → ts
|
|
50
|
+
const INTERLOCK_TTL_MS = 10 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
return function maybePostTurnEdit(editedMsg, oldText, botUsername, mentionRe = null) {
|
|
53
|
+
try {
|
|
54
|
+
if (!editedMsg?.chat) return false;
|
|
55
|
+
const chatId = editedMsg.chat.id.toString();
|
|
56
|
+
const chatConfig = config.chats[chatId];
|
|
57
|
+
if (!chatConfig) return false;
|
|
58
|
+
|
|
59
|
+
// Per-chat / bot-level opt-out (shared with the mid-turn injector). Default on.
|
|
60
|
+
const optOut = chatConfig.editCorrection != null
|
|
61
|
+
? chatConfig.editCorrection === false
|
|
62
|
+
: config.bot?.editCorrection === false;
|
|
63
|
+
if (optOut) return false;
|
|
64
|
+
|
|
65
|
+
const newText = editedMsg.text || editedMsg.caption || '';
|
|
66
|
+
if (!newText) return false; // blanked / media-only → nothing to act on
|
|
67
|
+
// Changed-guard: skip metadata-only edits (link-preview load fires edited_message too). The
|
|
68
|
+
// caller MUST have read oldText before recordInbound overwrote the row; null = unknown → proceed.
|
|
69
|
+
if (oldText != null && oldText === newText) return false;
|
|
70
|
+
|
|
71
|
+
const threadId = editedMsg.message_thread_id?.toString() || null;
|
|
72
|
+
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
73
|
+
|
|
74
|
+
// Interlock (per-message, 0.13 D5): only a re-edit of a message whose OWN
|
|
75
|
+
// re-dispatch is still running folds via inject — pre-0.13 this was
|
|
76
|
+
// per-session (any in-flight turn folded any edit, and it injected
|
|
77
|
+
// BEFORE the gate; the gate now runs upstream in the edited_message
|
|
78
|
+
// handler, so every path through here is already gated).
|
|
79
|
+
const proc = pm?.get?.(sessionKey);
|
|
80
|
+
const interlockKey = `${chatId}:${editedMsg.message_id}`;
|
|
81
|
+
const lastRedeliveredAt = redeliveredAt.get(interlockKey) || 0;
|
|
82
|
+
if (proc?.inFlight && (Date.now() - lastRedeliveredAt) < INTERLOCK_TTL_MS && lastRedeliveredAt > 0) {
|
|
83
|
+
pm.injectUserMessage?.(sessionKey, {
|
|
84
|
+
content: `[edit] I edited my message again — it now reads: ${newText}`,
|
|
85
|
+
priority: 'next',
|
|
86
|
+
msgId: editedMsg.message_id,
|
|
87
|
+
source: 'edit-fold', // 0.13 D2
|
|
88
|
+
});
|
|
89
|
+
logEvent('edit-redelivery-folded', { chat_id: chatId, session_key: sessionKey, msg_id: editedMsg.message_id });
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// GATE on the REAL edited message (its real from / new text / its own real reply_to, if any).
|
|
94
|
+
// NOT the synthetic — a self-reply_to would trip shouldHandle.repliesToOtherUser and drop a
|
|
95
|
+
// paired user editing an un-mentioned message in a mention-gated group.
|
|
96
|
+
if (!shouldHandle(editedMsg, chatConfig, botUsername)) return false;
|
|
97
|
+
|
|
98
|
+
// Acknowledge immediately: a silent edit produces no new bubble, so show it registered before
|
|
99
|
+
// claude's reply lands (the rc.33 lesson). Best-effort; never blocks the re-dispatch.
|
|
100
|
+
try { react?.(chatId, editedMsg.message_id); } catch { /* best-effort */ }
|
|
101
|
+
|
|
102
|
+
// Synthetic turn: NEW text in the body, OLD text in the reply_to so claude sees the change.
|
|
103
|
+
// reply_to_message carries `from` + `text` so resolveReplyTo takes the telegram branch and
|
|
104
|
+
// renders the OLD text — NOT db.getMessage (which now holds the overwritten new text).
|
|
105
|
+
const cleanNew = mentionRe ? newText.replace(mentionRe, '').trim() : newText.trim();
|
|
106
|
+
const synthetic = {
|
|
107
|
+
chat: editedMsg.chat,
|
|
108
|
+
message_id: editedMsg.message_id,
|
|
109
|
+
from: editedMsg.from,
|
|
110
|
+
text: cleanNew,
|
|
111
|
+
date: editedMsg.date,
|
|
112
|
+
...(threadId && { message_thread_id: Number(threadId) }),
|
|
113
|
+
reply_to_message: {
|
|
114
|
+
message_id: editedMsg.message_id,
|
|
115
|
+
from: editedMsg.from,
|
|
116
|
+
text: oldText || '',
|
|
117
|
+
date: editedMsg.date,
|
|
118
|
+
},
|
|
119
|
+
_isReplay: true, // no new editable row, not replay-eligible, error reply suppressed
|
|
120
|
+
};
|
|
121
|
+
redeliveredAt.set(interlockKey, Date.now());
|
|
122
|
+
dispatchHandleMessage(sessionKey, chatId, synthetic, bot);
|
|
123
|
+
logEvent('edit-redelivered', {
|
|
124
|
+
chat_id: chatId, session_key: sessionKey, msg_id: editedMsg.message_id,
|
|
125
|
+
old_len: (oldText || '').length, new_len: newText.length,
|
|
126
|
+
});
|
|
127
|
+
return true;
|
|
128
|
+
} catch (e) {
|
|
129
|
+
// Never throw out of the edited_message handler.
|
|
130
|
+
logger.error?.(`[edit-redelivery] ${e.message}`);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { createEditRedelivery };
|