polygram 0.12.0-rc.9 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/config.example.json +3 -1
  2. package/lib/claude-bin.js +14 -1
  3. package/lib/compaction-warn.js +59 -0
  4. package/lib/context-usage.js +93 -0
  5. package/lib/db.js +1 -1
  6. package/lib/error/classify.js +33 -10
  7. package/lib/feedback/session-feedback.js +91 -0
  8. package/lib/handlers/abort.js +87 -40
  9. package/lib/handlers/autosteer.js +4 -0
  10. package/lib/handlers/config-callback.js +25 -6
  11. package/lib/handlers/config-ui.js +39 -10
  12. package/lib/handlers/dispatcher.js +83 -0
  13. package/lib/handlers/download.js +101 -58
  14. package/lib/handlers/drop-redeliver.js +69 -0
  15. package/lib/handlers/edit-correction.js +2 -0
  16. package/lib/handlers/edit-redelivery.js +136 -0
  17. package/lib/handlers/gate-inbound.js +188 -0
  18. package/lib/handlers/questions.js +289 -0
  19. package/lib/handlers/redeliver.js +122 -0
  20. package/lib/handlers/slash-commands.js +43 -30
  21. package/lib/history-preload.js +6 -0
  22. package/lib/history.js +7 -1
  23. package/lib/model-costs.js +4 -0
  24. package/lib/process/channels-bridge-protocol.js +22 -1
  25. package/lib/process/channels-bridge.mjs +128 -7
  26. package/lib/process/channels-tool-dispatcher.js +105 -12
  27. package/lib/process/cli-process.js +1277 -70
  28. package/lib/process/hook-event-tail.js +7 -0
  29. package/lib/process/hook-settings.js +7 -0
  30. package/lib/process/process.js +22 -0
  31. package/lib/process-guard.js +57 -1
  32. package/lib/process-manager.js +120 -35
  33. package/lib/questions/questions.js +187 -0
  34. package/lib/questions/store.js +105 -0
  35. package/lib/rewind/execute.js +89 -0
  36. package/lib/rewind/fork.js +112 -0
  37. package/lib/rewind/rewind.js +174 -0
  38. package/lib/sdk/callbacks.js +165 -167
  39. package/lib/session-key.js +29 -0
  40. package/lib/telegram/album-reactions.js +50 -0
  41. package/lib/telegram/parse.js +9 -2
  42. package/lib/telegram/typing.js +17 -2
  43. package/lib/tmux/startup-gate.js +44 -14
  44. package/migrations/012-pending-questions.sql +30 -0
  45. package/package.json +1 -1
  46. package/polygram.js +224 -78
@@ -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-7',
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 === chatConfig.model ? `✓ ${m}` : 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 === chatConfig.effort ? `✓ ${e}` : 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
- const ver = MODEL_VERSIONS_DESC[chatConfig.model] || chatConfig.model;
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
- `Model: ${chatConfig.model} (${ver})\n` +
69
- `Effort: ${chatConfig.effort}\n` +
70
- `Agent: ${chatConfig.agent}\n` +
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
  };
@@ -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
- const url = `https://api.telegram.org/file/bot${token}/${fileInfo.file_path}`;
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
- // Atomic write: temp file + rename. A crash mid-write leaves a
113
- // .tmp.* file (swept later) rather than a truncated canonical
114
- // file the EEXIST dedup branch would happily serve next time.
115
- if (fs.existsSync(localPath)) {
116
- logger.log?.(`[attach] ${chatId} ${att.kind} ${safeName} (already on disk, reusing)`);
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
- const tmpPath = `${localPath}.tmp.${process.pid}.${Date.now()}`;
119
- try {
120
- fs.writeFileSync(tmpPath, buf, { flag: 'wx' });
121
- fs.renameSync(tmpPath, localPath);
122
- } catch (e) {
123
- try { fs.unlinkSync(tmpPath); } catch {}
124
- if (e.code !== 'EEXIST') throw e;
125
- logger.log?.(`[attach] ${chatId} ${att.kind} ${safeName} (race: already on disk)`);
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
- logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (${buf.length} bytes) → ${localPath}`);
171
+
129
172
  dbWrite(() => db.markAttachmentDownloaded(att.id, {
130
- local_path: localPath, size_bytes: att.size_bytes || buf.length,
173
+ local_path: localPath, size_bytes: att.size_bytes || size,
131
174
  }), `markAttachmentDownloaded ${att.id}`);
132
- return { ...att, path: localPath, size: att.size_bytes || buf.length, error: null };
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 };