grok-telegram-bot 2.5.0 → 2.6.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.
@@ -2,6 +2,9 @@
2
2
  * Local "group memory" search across forum topics, session metadata, and
3
3
  * recent session history. Telegram bots have no general message-search API,
4
4
  * so this indexes what the bridge already stores on disk.
5
+ *
6
+ * Ranking is **relevance + recency**: newer sessions / history win over old
7
+ * matching text so "what was last worked on" does not surface stale Done notes.
5
8
  */
6
9
  import { join } from "node:path";
7
10
  import { readHistory } from "../sessions/history.js";
@@ -19,6 +22,8 @@ export interface MemoryHit {
19
22
  path?: string;
20
23
  sessionId?: string;
21
24
  threadId?: number;
25
+ /** Epoch ms for secondary sort / display (session or entry time). */
26
+ at?: number;
22
27
  }
23
28
 
24
29
  export interface GroupMemorySearchOpts {
@@ -29,6 +34,13 @@ export interface GroupMemorySearchOpts {
29
34
  topics?: ForumTopicBinding[];
30
35
  /** Max sessions whose JSONL tails are scanned. */
31
36
  maxSessions?: number;
37
+ /**
38
+ * Prefer sessions under these paths (e.g. workspace for General, then a
39
+ * project path). Earlier paths get a higher score boost.
40
+ */
41
+ preferPaths?: string[];
42
+ /** Boost history from sessions whose title/comment looks like General manager. */
43
+ preferGeneral?: boolean;
32
44
  }
33
45
 
34
46
  /** Tokenize a query into lowercase alphanumeric tokens (min length 2). */
@@ -62,72 +74,208 @@ export function scoreTokens(haystack: string, tokens: string[]): number {
62
74
  return score;
63
75
  }
64
76
 
77
+ /**
78
+ * True when the user is asking what happened *recently* (last mods, last work).
79
+ * These queries weight recency much higher than pure text match.
80
+ * Avoid bare "work"/"done" — those appear in many normal asks and would
81
+ * over-prioritize recency over relevance.
82
+ */
83
+ export function queryWantsRecency(query: string): boolean {
84
+ return /\b(last|latest|recent|newest|today|yesterday|modif(?:y|ied|ication|ications)?|changes?|changed|updated|what\s+was|what\s+did|last\s+work|recent\s+work|last\s+done)\b/i.test(
85
+ query,
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Normalize epoch seconds vs milliseconds (or ISO string) to ms.
91
+ * Returns undefined when missing/invalid (never NaN).
92
+ */
93
+ export function normalizeEpochMs(
94
+ value: string | number | undefined,
95
+ ): number | undefined {
96
+ if (value === undefined || value === null || value === "") return undefined;
97
+ let t: number;
98
+ if (typeof value === "number") {
99
+ t = value;
100
+ } else {
101
+ t = Date.parse(value);
102
+ }
103
+ if (!Number.isFinite(t) || t <= 0) return undefined;
104
+ // Seconds since epoch (~1e9) → ms. ms since epoch is ~1e12+.
105
+ if (t > 0 && t < 1e11) t *= 1000;
106
+ if (!Number.isFinite(t) || t <= 0) return undefined;
107
+ return t;
108
+ }
109
+
110
+ /**
111
+ * Recency score 0–20 from an updatedAt ISO string or epoch ms/seconds.
112
+ * <1h:20, <6h:16, <24h:12, <3d:8, <7d:4, <30d:2, older:0.
113
+ */
114
+ export function recencyBoost(updatedAt: string | number | undefined, now = Date.now()): number {
115
+ const t = normalizeEpochMs(updatedAt);
116
+ if (t === undefined) return 0;
117
+ const ageH = Math.max(0, (now - t) / 3_600_000);
118
+ if (ageH < 1) return 20;
119
+ if (ageH < 6) return 16;
120
+ if (ageH < 24) return 12;
121
+ if (ageH < 72) return 8;
122
+ if (ageH < 168) return 4;
123
+ if (ageH < 720) return 2;
124
+ return 0;
125
+ }
126
+
127
+ /** Human-ish age for snippets. */
128
+ export function formatAge(at: number | undefined, now = Date.now()): string {
129
+ if (!at || !Number.isFinite(at)) return "";
130
+ const mins = Math.max(0, Math.round((now - at) / 60_000));
131
+ if (mins < 60) return `${mins}m ago`;
132
+ const hours = Math.round(mins / 60);
133
+ if (hours < 48) return `${hours}h ago`;
134
+ const days = Math.round(hours / 24);
135
+ return `${days}d ago`;
136
+ }
137
+
65
138
  /**
66
139
  * Search topics + session store + recent history. Pure ranking over provided
67
140
  * store; safe to call on the main bot thread (capped I/O).
141
+ *
142
+ * Sort key: score (relevance + recency) DESC, then `at` DESC (newest first).
68
143
  */
69
144
  export function searchGroupMemory(opts: GroupMemorySearchOpts): MemoryHit[] {
70
145
  const tokens = tokenizeQuery(opts.query);
71
146
  if (tokens.length === 0) return [];
72
147
  const limit = Math.max(1, Math.min(20, opts.limit ?? 8));
73
148
  const maxSessions = opts.maxSessions ?? 40;
149
+ const now = Date.now();
150
+ const wantsRecent = queryWantsRecency(opts.query);
151
+ /** Multiply recency when user asks for last/recent work. */
152
+ const recencyMul = wantsRecent ? 2.5 : 1.2;
74
153
  const hits: MemoryHit[] = [];
75
154
 
76
155
  for (const t of opts.topics ?? []) {
77
156
  const hay = [t.name, t.projectPath ?? "", t.kind, t.sessionId ?? ""].join("\n");
78
157
  const score = scoreTokens(hay, tokens);
79
158
  if (score <= 0) continue;
159
+ // Topic map updatedAt is already epoch ms.
160
+ const at = normalizeEpochMs(t.updatedAt);
161
+ // Mild recency on topics (routing), not full "last work" weight.
162
+ const r = recencyBoost(at, now) * Math.min(1.2, recencyMul);
80
163
  hits.push({
81
164
  kind: "topic",
82
165
  title: t.name,
83
166
  snippet: t.projectPath ? `path: ${t.projectPath}` : `kind: ${t.kind}`,
84
- score: score + 2, // slight boost for topic map
167
+ score: score + 2 + r,
85
168
  path: t.projectPath ?? undefined,
86
169
  threadId: t.threadId,
87
170
  sessionId: t.sessionId,
171
+ at,
88
172
  });
89
173
  }
90
174
 
91
175
  let metas: SessionMeta[] = [];
92
176
  try {
177
+ // Store already returns most-recently-updated first.
93
178
  metas = opts.store.list(maxSessions);
94
179
  } catch {
95
180
  metas = [];
96
181
  }
97
182
 
183
+ const prefer = (opts.preferPaths ?? []).map((p) => p.replace(/\\/g, "/").toLowerCase());
184
+
185
+ // Per project path: which session is the newest (for "last work in X").
186
+ const newestByCwd = new Map<string, number>();
98
187
  for (const m of metas) {
188
+ const key = normPath(m.cwd);
189
+ if (!key) continue;
190
+ const t = normalizeEpochMs(m.updatedAt) ?? 0;
191
+ const prev = newestByCwd.get(key) ?? 0;
192
+ if (t > prev) newestByCwd.set(key, t);
193
+ }
194
+
195
+ for (const m of metas) {
196
+ const pathBoost = pathPreferenceBoost(m.cwd, prefer);
197
+ const generalBoost =
198
+ opts.preferGeneral &&
199
+ (/^general$/i.test(m.title || "") || /general/i.test(m.comment || ""))
200
+ ? 5
201
+ : 0;
202
+ const sessionAt = normalizeEpochMs(m.updatedAt);
203
+ const rBoost = recencyBoost(sessionAt, now) * recencyMul;
204
+ const newestBoost =
205
+ sessionAt !== undefined && newestByCwd.get(normPath(m.cwd)) === sessionAt ? 10 : 0;
206
+
99
207
  const hay = [m.title, m.comment ?? "", m.cwd, m.sessionId].join("\n");
100
- const score = scoreTokens(hay, tokens);
101
- if (score > 0) {
208
+ const base = scoreTokens(hay, tokens);
209
+ // Require text relevance — path/recency alone never invents a hit.
210
+ if (base <= 0) {
211
+ // Still scan history below if the session might contain matching tails.
212
+ }
213
+ const score = base + pathBoost + generalBoost + rBoost + newestBoost;
214
+ const age = formatAge(sessionAt, now);
215
+
216
+ if (base > 0 && Number.isFinite(score) && score > 0) {
102
217
  hits.push({
103
218
  kind: "session",
104
219
  title: m.title || m.sessionId.slice(0, 8),
105
220
  snippet: clamp(
106
- [m.comment, m.cwd].filter(Boolean).join(" · ") || m.sessionId,
107
- 220,
221
+ [age ? `[${age}]` : "", m.comment, m.cwd].filter(Boolean).join(" · ") || m.sessionId,
222
+ 240,
108
223
  ),
109
224
  score,
110
225
  path: m.cwd || undefined,
111
226
  sessionId: m.sessionId,
227
+ at: sessionAt,
112
228
  });
113
229
  }
114
230
 
115
- // History tail (cheap): few entries, short text.
231
+ // History: deeper tail for recent or path-matched sessions.
232
+ const histDepth =
233
+ rBoost >= 12 || pathBoost + generalBoost > 0 || wantsRecent ? 28 : 14;
116
234
  if (m.historyBytes <= 0) continue;
117
235
  try {
118
236
  const path = join(opts.sessionsDir, `${m.sessionId}.jsonl`);
119
- const entries = readHistory(path, 12);
120
- for (const e of entries) {
237
+ const entries = readHistory(path, histDepth);
238
+ // Prefer newer entries: history is oldest→newest; weight later indices.
239
+ const n = entries.length;
240
+ for (let i = 0; i < n; i++) {
241
+ const e = entries[i]!;
121
242
  if (!e.text?.trim()) continue;
122
- const s = scoreTokens(e.text, tokens);
123
- if (s <= 0) continue;
243
+ // Skip meta dumps that pollute "last work" answers.
244
+ if (/MANAGER CONTEXT \(auto/i.test(e.text)) continue;
245
+ if (/^COMPLEXITY \(decide yourself/i.test(e.text)) continue;
246
+ if (/TELEGRAM BRIDGE \(how to work/i.test(e.text)) continue;
247
+
248
+ const textScore = scoreTokens(e.text, tokens);
249
+ if (textScore <= 0) continue;
250
+
251
+ // Prefer entry timestamp; fall back to session updatedAt (not 0/NaN).
252
+ const entryAt = normalizeEpochMs(e.timestamp) ?? sessionAt;
253
+ const entryRecency = recencyBoost(entryAt, now) * recencyMul;
254
+ // Position boost: last entries in the tail are newest.
255
+ const posBoost = n > 1 ? Math.round((i / (n - 1)) * 6) : 3;
256
+ // Prefer user prompts + assistant Done prose for "what was last done".
257
+ const roleBoost = e.role === "user" ? 3 : e.role === "assistant" ? 3 : 0;
258
+ const s =
259
+ textScore +
260
+ pathBoost +
261
+ generalBoost +
262
+ entryRecency +
263
+ posBoost +
264
+ roleBoost +
265
+ (newestBoost > 0 ? 4 : 0);
266
+ if (!Number.isFinite(s) || s <= 0) continue;
267
+ const eAge = formatAge(entryAt, now);
124
268
  hits.push({
125
269
  kind: "history",
126
270
  title: `${e.role} · ${(m.title || m.sessionId.slice(0, 8)).slice(0, 40)}`,
127
- snippet: clamp(e.text.replace(/\s+/g, " ").trim(), 220),
271
+ snippet: clamp(
272
+ [eAge ? `[${eAge}]` : "", e.text.replace(/\s+/g, " ").trim()].filter(Boolean).join(" "),
273
+ 240,
274
+ ),
128
275
  score: s,
129
276
  path: m.cwd || undefined,
130
277
  sessionId: m.sessionId,
278
+ at: entryAt,
131
279
  });
132
280
  }
133
281
  } catch {
@@ -135,7 +283,13 @@ export function searchGroupMemory(opts: GroupMemorySearchOpts): MemoryHit[] {
135
283
  }
136
284
  }
137
285
 
138
- hits.sort((a, b) => b.score - a.score || a.kind.localeCompare(b.kind));
286
+ // Newest high scores first.
287
+ hits.sort(
288
+ (a, b) =>
289
+ b.score - a.score ||
290
+ (b.at ?? 0) - (a.at ?? 0) ||
291
+ a.kind.localeCompare(b.kind),
292
+ );
139
293
  // Dedupe near-identical snippets.
140
294
  const seen = new Set<string>();
141
295
  const out: MemoryHit[] = [];
@@ -157,3 +311,29 @@ function clamp(s: string, max: number): string {
157
311
  function escapeReg(s: string): string {
158
312
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
159
313
  }
314
+
315
+ function normPath(p: string | undefined): string {
316
+ if (!p) return "";
317
+ return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
318
+ }
319
+
320
+ /**
321
+ * Higher boost for earlier preferred paths.
322
+ * Index 0 is usually the workspace (General) — use **exact** match only so we
323
+ * do not treat every child project under Domains as "General memory".
324
+ * Later entries (project paths) allow prefix match.
325
+ */
326
+ function pathPreferenceBoost(cwd: string | undefined, prefer: string[]): number {
327
+ if (!cwd || prefer.length === 0) return 0;
328
+ const n = cwd.replace(/\\/g, "/").toLowerCase();
329
+ for (let i = 0; i < prefer.length; i++) {
330
+ const p = prefer[i]!.replace(/\\/g, "/").toLowerCase();
331
+ if (!p) continue;
332
+ const exact = n === p;
333
+ const child = i > 0 && (n === p || n.startsWith(p + "/"));
334
+ if (exact || child) {
335
+ return 8 - Math.min(6, i * 2);
336
+ }
337
+ }
338
+ return 0;
339
+ }
@@ -5,7 +5,11 @@ import type { Bot } from "grammy";
5
5
  import { createLogger } from "../../logger.js";
6
6
  import type { ForumManager } from "../../forum/manager.js";
7
7
  import type { BotDeps } from "../deps.js";
8
- import { FORUM_GENERAL_THREAD_ID, forumThreadId } from "../../forum/thread.js";
8
+ import {
9
+ FORUM_GENERAL_THREAD_ID,
10
+ forumThreadId,
11
+ outboundThreadExtra,
12
+ } from "../../forum/thread.js";
9
13
 
10
14
  const log = createLogger("forum-handler");
11
15
 
@@ -89,8 +93,7 @@ export function registerForum(bot: Bot, deps: BotDeps, forum: ForumManager): voi
89
93
  // Optional: re-run setup command for admins in the group.
90
94
  bot.command("forum_setup", async (ctx) => {
91
95
  const threadId = ctx.message?.message_thread_id;
92
- const replyOpts =
93
- threadId !== undefined ? { message_thread_id: threadId } : {};
96
+ const replyOpts = outboundThreadExtra(threadId);
94
97
  if (ctx.chat?.id !== groupId) {
95
98
  await ctx.reply("Use this command inside the configured forum group.", replyOpts).catch(() => {});
96
99
  return;
@@ -167,7 +170,11 @@ export async function resolveForumRuntime(
167
170
  .sendMessage(
168
171
  chatId,
169
172
  `\u2705 Bound to project:\n\`${result.binding.projectPath}\`${iconNote}\n\nYou can chat here now.`,
170
- { message_thread_id: tid, parse_mode: "Markdown", reply_parameters: { message_id: messageId } },
173
+ {
174
+ ...outboundThreadExtra(tid),
175
+ parse_mode: "Markdown",
176
+ reply_parameters: { message_id: messageId },
177
+ },
171
178
  )
172
179
  .catch(() => {});
173
180
  // Warm runtime; path-only message is not submitted as an agent prompt.
@@ -181,7 +188,10 @@ export async function resolveForumRuntime(
181
188
  .sendMessage(
182
189
  chatId,
183
190
  `\u2753 ${result.error}\n\n${BIND_HINT.replace(/\*\*/g, "")}`,
184
- { message_thread_id: tid, reply_parameters: { message_id: messageId } },
191
+ {
192
+ ...outboundThreadExtra(tid),
193
+ reply_parameters: { message_id: messageId },
194
+ },
185
195
  )
186
196
  .catch(() => {});
187
197
  return "handled";
@@ -194,7 +204,7 @@ export async function resolveForumRuntime(
194
204
  .sendMessage(
195
205
  chatId,
196
206
  `\u2753 This topic is not linked to a project yet.\n${BIND_HINT.replace(/\*\*/g, "")}`,
197
- { message_thread_id: tid },
207
+ outboundThreadExtra(tid),
198
208
  )
199
209
  .catch(() => {});
200
210
  return "handled";
@@ -8,22 +8,27 @@
8
8
  * rapid consecutive text messages per chat within a short debounce window
9
9
  * (`MESSAGE_BATCH_MS`) into a single prompt — one submission, one confirmation.
10
10
  *
11
- * User messages are replaced by a bot-owned prompt anchor (`#prompt_<id>`) so
12
- * the chat shows immediate life while CLI/ACP starts, and all AI replies thread
13
- * to that anchor with the same searchable tag.
11
+ * Project topics: user messages are replaced by a bot-owned prompt anchor
12
+ * (`#prompt_<id>`) so the chat shows immediate life while CLI/ACP starts.
14
13
  *
15
- * While a turn is running, the combined message is queued and runs
16
- * automatically when the current turn finishes.
17
- * (Wizard input and menu-button text are intercepted by earlier handlers.)
14
+ * General (manager): user messages are KEPT; AI replies thread to the user
15
+ * message. Each message is a NEW session (parallel), unless the user replies
16
+ * to a bot message then that session continues.
18
17
  */
19
18
  import type { Bot } from "grammy";
20
19
  import { textPrompt } from "../../app/types.js";
21
20
  import { createLogger } from "../../logger.js";
22
- import { batchKey, forumThreadId } from "../../forum/thread.js";
21
+ import {
22
+ batchKey,
23
+ forumThreadId,
24
+ isGeneralThread,
25
+ outboundThreadExtra,
26
+ } from "../../forum/thread.js";
23
27
  import type { BotDeps } from "../deps.js";
24
- import { adoptUserPrompt } from "../prompt-anchor.js";
28
+ import { adoptUserPrompt, newPromptId } from "../prompt-anchor.js";
25
29
  import { extractReplyContext } from "../reply-context.js";
26
30
  import { resolveForumRuntime } from "./forum.js";
31
+ import type { SessionRuntime } from "../session-runtime.js";
27
32
 
28
33
  const log = createLogger("message");
29
34
 
@@ -35,6 +40,10 @@ interface TextBatch {
35
40
  threadId?: number;
36
41
  /** Reference content if the burst began as a reply to another message. */
37
42
  quoted?: string;
43
+ /** Telegram message_id the user is replying to (for session follow-up). */
44
+ replyToMessageId?: number;
45
+ /** Text of the replied-to message (for #sess_ recovery). */
46
+ replyToText?: string;
38
47
  timer: NodeJS.Timeout;
39
48
  }
40
49
 
@@ -49,7 +58,6 @@ export function registerMessages(bot: Bot, deps: BotDeps): void {
49
58
  const text = ctx.message.text;
50
59
  if (!text.trim()) return;
51
60
  // Slash commands are handled by bot.command / menu — never batch as agent prompts.
52
- // (Otherwise /forum_setup and /help would also hit the debounce → agent path.)
53
61
  if (!text.includes("\n") && text.startsWith("/")) return;
54
62
 
55
63
  const chatId = ctx.chat.id;
@@ -58,7 +66,14 @@ export function registerMessages(bot: Bot, deps: BotDeps): void {
58
66
  const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
59
67
  const threadId = isForum ? forumThreadId(rawThreadId) : rawThreadId;
60
68
  const quoted = extractReplyContext(ctx);
61
- const key = batchKey(chatId, rawThreadId, isForum);
69
+ const replyToMessageId = ctx.message.reply_to_message?.message_id;
70
+ const replyToText =
71
+ ctx.message.reply_to_message?.text ?? ctx.message.reply_to_message?.caption;
72
+ // General: do not coalesce independent messages (parallel sessions).
73
+ // Still coalesce multi-part 4096 splits when reply chain is empty and rapid.
74
+ const key = isGeneralThread(threadId) && isForum
75
+ ? `${chatId}:${threadId ?? 1}:m${id}`
76
+ : batchKey(chatId, rawThreadId, isForum);
62
77
 
63
78
  const batch = batches.get(key);
64
79
  if (batch) {
@@ -66,6 +81,10 @@ export function registerMessages(bot: Bot, deps: BotDeps): void {
66
81
  batch.parts.push(text);
67
82
  batch.ids.push(id);
68
83
  if (quoted && !batch.quoted) batch.quoted = quoted;
84
+ if (replyToMessageId !== undefined && batch.replyToMessageId === undefined) {
85
+ batch.replyToMessageId = replyToMessageId;
86
+ batch.replyToText = replyToText;
87
+ }
69
88
  batch.timer = arm(key);
70
89
  return;
71
90
  }
@@ -74,6 +93,8 @@ export function registerMessages(bot: Bot, deps: BotDeps): void {
74
93
  ids: [id],
75
94
  threadId,
76
95
  quoted,
96
+ replyToMessageId,
97
+ replyToText,
77
98
  timer: arm(key),
78
99
  });
79
100
  });
@@ -85,8 +106,6 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
85
106
  if (!batch) return;
86
107
  batches.delete(key);
87
108
 
88
- // Telegram splits at 4096 chars, almost always on a line boundary, so
89
- // rejoining with a newline reconstructs the original text faithfully.
90
109
  const combined = batch.parts.join("\n").trim();
91
110
  if (!combined) return;
92
111
 
@@ -94,14 +113,16 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
94
113
  const chatId = Number(chatIdStr);
95
114
  const threadId = batch.threadId;
96
115
 
97
- // Defense-in-depth: never submit slash-only lines as agent prompts.
98
116
  if (batch.parts.length === 1 && !combined.includes("\n") && combined.startsWith("/")) {
99
117
  return;
100
118
  }
101
119
 
102
- let rt = deps.registry.get(chatId);
103
- // Forum group: topic-scoped multi-session controller (model/reasoning/running).
104
- if (deps.forum?.isActiveForumChat(chatId)) {
120
+ const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
121
+ const isGeneral = isForum && isGeneralThread(threadId);
122
+
123
+ let rt: SessionRuntime = deps.registry.get(chatId);
124
+
125
+ if (isForum && deps.forum) {
105
126
  const resolved = await resolveForumRuntime(
106
127
  deps,
107
128
  deps.forum,
@@ -111,10 +132,98 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
111
132
  batch.ids[0]!,
112
133
  );
113
134
  if (resolved === "handled" || resolved === "ignore") return;
135
+
136
+ if (isGeneral) {
137
+ // ── General manager path ─────────────────────────────────────────
138
+ const controller = deps.registry.forumController(
139
+ chatId,
140
+ forumThreadId(threadId),
141
+ resolved.rt.cwd,
142
+ resolved.rt.projectName ?? "General",
143
+ );
144
+
145
+ // Reply → continue same session (map, #sess_ on controlled runtimes, or disk).
146
+ // Fresh message → new parallel session (does not queue behind other General work).
147
+ const continueRt = await controller.resolveContinueFromReply({
148
+ replyToMessageId: batch.replyToMessageId,
149
+ replyToText: batch.replyToText,
150
+ cwd: resolved.rt.cwd,
151
+ projectName: resolved.rt.projectName ?? "General",
152
+ });
153
+
154
+ const userMsgId = batch.ids[0]!;
155
+ const replyTo = userMsgId;
156
+ // Keep user message; reply to it. No overwrite / adopt delete.
157
+
158
+ // New session: post "Starting…" FIRST (before ACP session/new) so the user
159
+ // sees life immediately. runTurn later edits it to Thinking… then streams.
160
+ // Follow-up on existing session: skip Starting (runTurn posts Thinking…).
161
+ let seedMessageId: number | undefined;
162
+ if (!continueRt) {
163
+ seedMessageId = await sendStatus(
164
+ deps,
165
+ chatId,
166
+ "Starting\u2026",
167
+ threadId,
168
+ replyTo,
169
+ );
170
+ rt = await controller.addParallel(
171
+ resolved.rt.cwd,
172
+ resolved.rt.projectName ?? "General",
173
+ );
174
+ if (seedMessageId !== undefined && rt.sessionId) {
175
+ controller.bindTelegramMessage(seedMessageId, rt.sessionId);
176
+ }
177
+ } else {
178
+ rt = continueRt;
179
+ // FG for status panel; manager setForeground keeps busy siblings streaming.
180
+ if (rt.sessionId) await controller.switchTo(rt.sessionId).catch(() => {});
181
+ }
182
+ if (rt.sessionId) controller.bindTelegramMessage(userMsgId, rt.sessionId);
183
+
184
+ try {
185
+ const outcome = await rt.submit(
186
+ textPrompt(combined, replyTo, batch.quoted, {
187
+ promptId: newPromptId(),
188
+ seedMessageId,
189
+ }),
190
+ );
191
+ if (rt.sessionId) {
192
+ controller.bindTelegramMessage(userMsgId, rt.sessionId);
193
+ if (seedMessageId !== undefined) {
194
+ controller.bindTelegramMessage(seedMessageId, rt.sessionId);
195
+ }
196
+ }
197
+ // Never show "queued" spam for new parallel sessions; only if continuing
198
+ // the same session that is already busy.
199
+ if (outcome === "queued" && continueRt) {
200
+ await send(
201
+ deps,
202
+ chatId,
203
+ `\u{1F4E5} Got it — queued as a follow-up on that thread.`,
204
+ threadId,
205
+ replyTo,
206
+ );
207
+ }
208
+ } catch (err) {
209
+ log.warn(`general submit failed chat ${chatId}: ${(err as Error).message}`);
210
+ if (seedMessageId !== undefined) {
211
+ await editStatus(deps, chatId, seedMessageId, `\u274C Couldn't start: ${(err as Error).message}`);
212
+ } else {
213
+ await send(
214
+ deps,
215
+ chatId,
216
+ `\u274C Couldn't start: ${(err as Error).message}`,
217
+ threadId,
218
+ replyTo,
219
+ );
220
+ }
221
+ }
222
+ return;
223
+ }
224
+
225
+ // ── Project / AI Chat topics (existing behavior) ─────────────────
114
226
  rt = resolved.rt;
115
- // If nothing is selected / no session yet, ensure a new session is created
116
- // on first message (ensureSession inside submit). If FG has no sessionId
117
- // after a closed session, start a fresh one for this topic.
118
227
  if (!rt.sessionId && !rt.isBusy) {
119
228
  try {
120
229
  await rt.startNewSession(rt.cwd, rt.projectName);
@@ -125,11 +234,8 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
125
234
  }
126
235
 
127
236
  const note = batch.parts.length > 1 ? ` (combined ${batch.parts.length} messages)` : "";
128
- // Hoisted so a submit failure after a successful adopt can still thread the error.
129
237
  let replyTo: number | undefined = batch.ids[0];
130
238
  try {
131
- // Instant bot anchor: user sees the prompt adopted immediately while CLI warms.
132
- // All AI output + Done reply to this message and carry #prompt_<id>.
133
239
  const anchor = await adoptUserPrompt(deps.api, {
134
240
  chatId,
135
241
  text: combined,
@@ -151,7 +257,6 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
151
257
  replyTo,
152
258
  );
153
259
  }
154
- // "ran": turn started; complexity is steered silently by the agent.
155
260
  } catch (err) {
156
261
  log.warn(`submit failed for chat ${chatId}: ${(err as Error).message}`);
157
262
  await send(
@@ -172,8 +277,7 @@ async function send(
172
277
  replyTo?: number,
173
278
  ): Promise<void> {
174
279
  try {
175
- const extra: Record<string, unknown> = {};
176
- if (threadId !== undefined) extra.message_thread_id = threadId;
280
+ const extra: Record<string, unknown> = { ...outboundThreadExtra(threadId) };
177
281
  if (replyTo !== undefined) {
178
282
  extra.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
179
283
  }
@@ -182,3 +286,39 @@ async function send(
182
286
  /* non-fatal */
183
287
  }
184
288
  }
289
+
290
+ /** Post status bubble; returns message_id for later edit (Starting… → Thinking…). */
291
+ async function sendStatus(
292
+ deps: BotDeps,
293
+ chatId: number,
294
+ text: string,
295
+ threadId?: number,
296
+ replyTo?: number,
297
+ ): Promise<number | undefined> {
298
+ try {
299
+ const extra: Record<string, unknown> = {
300
+ disable_notification: true,
301
+ ...outboundThreadExtra(threadId),
302
+ };
303
+ if (replyTo !== undefined) {
304
+ extra.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
305
+ }
306
+ const msg = await deps.api.sendMessage(chatId, text, extra);
307
+ return msg.message_id;
308
+ } catch {
309
+ return undefined;
310
+ }
311
+ }
312
+
313
+ async function editStatus(
314
+ deps: BotDeps,
315
+ chatId: number,
316
+ messageId: number,
317
+ text: string,
318
+ ): Promise<void> {
319
+ try {
320
+ await deps.api.editMessageText(chatId, messageId, text);
321
+ } catch {
322
+ /* non-fatal */
323
+ }
324
+ }
@@ -193,8 +193,11 @@ async function submit(
193
193
  quotedText: quoted,
194
194
  });
195
195
  if (outcome === "queued") {
196
+ // Omit General (1) — Bot API rejects message_thread_id=1.
196
197
  const extra: Record<string, unknown> =
197
- threadId !== undefined ? { message_thread_id: threadId } : {};
198
+ threadId !== undefined && threadId !== 1
199
+ ? { message_thread_id: threadId }
200
+ : {};
198
201
  if (anchor?.replyTo !== undefined) {
199
202
  extra.reply_parameters = {
200
203
  message_id: anchor.replyTo,
@@ -145,7 +145,10 @@ export async function sendImages(
145
145
  if (opts.replyTo !== undefined) {
146
146
  extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
147
147
  }
148
- if (opts.messageThreadId !== undefined) extra.message_thread_id = opts.messageThreadId;
148
+ // Omit General (1) — Bot API rejects message_thread_id=1.
149
+ if (opts.messageThreadId !== undefined && opts.messageThreadId !== 1) {
150
+ extra.message_thread_id = opts.messageThreadId;
151
+ }
149
152
  for (const path of paths) {
150
153
  if (sent >= opts.max) break;
151
154
  if (opts.already.has(path)) continue;