grok-telegram-bot 2.4.0 → 2.5.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 (78) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +119 -1
  3. package/README.md +58 -15
  4. package/docs/GROUP.md +225 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +1 -1
  7. package/src/app/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +12 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +96 -15
  12. package/src/bot/bot.ts +122 -15
  13. package/src/bot/chat-controller.ts +52 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +159 -0
  17. package/src/bot/handlers/accounts.ts +7 -0
  18. package/src/bot/handlers/control.ts +85 -32
  19. package/src/bot/handlers/document.ts +31 -4
  20. package/src/bot/handlers/forum.ts +207 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +101 -21
  23. package/src/bot/handlers/photo.ts +123 -16
  24. package/src/bot/handlers/running.ts +150 -24
  25. package/src/bot/handlers/session-card.ts +13 -5
  26. package/src/bot/handlers/sessions.ts +68 -18
  27. package/src/bot/handlers/voice.ts +52 -7
  28. package/src/bot/image-return.ts +8 -5
  29. package/src/bot/menu/ephemeral.ts +13 -3
  30. package/src/bot/menu/keyboard.ts +53 -14
  31. package/src/bot/menu/refresh.ts +3 -1
  32. package/src/bot/menu/status-panel.ts +12 -6
  33. package/src/bot/permission-service.ts +19 -0
  34. package/src/bot/prompt-anchor.ts +300 -0
  35. package/src/bot/prompt-content.ts +3 -0
  36. package/src/bot/registry.ts +94 -1
  37. package/src/bot/scope.ts +94 -0
  38. package/src/bot/session-runtime.ts +647 -158
  39. package/src/bot/suggestions.ts +91 -31
  40. package/src/bot/telegram-actions.ts +440 -0
  41. package/src/bot/telegram-bots.ts +495 -0
  42. package/src/bot/telegram-io.ts +94 -10
  43. package/src/cli.ts +2 -0
  44. package/src/config.ts +201 -2
  45. package/src/forum/bind-path.ts +146 -0
  46. package/src/forum/manager.ts +651 -0
  47. package/src/forum/project-icon.ts +142 -0
  48. package/src/forum/thread.ts +16 -0
  49. package/src/forum/topic-store.ts +114 -0
  50. package/src/forum/types.ts +29 -0
  51. package/src/grok/client.ts +130 -28
  52. package/src/index.ts +205 -75
  53. package/src/projects/manager.ts +16 -3
  54. package/src/render/chunk.ts +17 -10
  55. package/src/render/hashtags.ts +5 -1
  56. package/src/render/session-comment.ts +64 -7
  57. package/src/render/telegram-bridge.ts +360 -0
  58. package/src/render/tool-call.ts +56 -37
  59. package/src/service/platform.ts +44 -7
  60. package/src/service/windows.ts +16 -4
  61. package/src/sessions/history.ts +50 -9
  62. package/src/sessions/process.ts +7 -0
  63. package/src/sessions/types.ts +2 -2
  64. package/src/stream/streamer.ts +17 -6
  65. package/scripts/analyze-jsonl.ts +0 -33
  66. package/scripts/delayed-restart.ps1 +0 -29
  67. package/scripts/probe-exit-response-shape.py +0 -77
  68. package/scripts/probe-plan-exit.py +0 -60
  69. package/scripts/probe-plan-exit2.py +0 -48
  70. package/scripts/probe-plan-fields.py +0 -41
  71. package/scripts/probe-plan-fields2.py +0 -58
  72. package/scripts/probe-plan-response-path.py +0 -48
  73. package/scripts/sample-claude-tooluse.ts +0 -21
  74. package/scripts/sample-kiro-events.ts +0 -31
  75. package/scripts/smoke-exit-plan.ts +0 -274
  76. package/scripts/smoke-exit-shapes.ts +0 -252
  77. package/scripts/smoke-import.mjs +0 -82
  78. package/scripts/smoke-import.ts +0 -73
@@ -23,10 +23,7 @@ export interface RunningSession {
23
23
  unread: number;
24
24
  /** Latest task-completion % (0–100) for this session, if known. */
25
25
  progress?: number;
26
- /**
27
- * Card comment: live step while working, chat summary when idle
28
- * ("what is happening / what was done").
29
- */
26
+ /** Card comment: last user prompt (+ last AI thinking while busy). */
30
27
  comment?: string;
31
28
  }
32
29
 
@@ -40,11 +37,31 @@ export interface SwitchResult {
40
37
  alreadyForeground: boolean;
41
38
  }
42
39
 
40
+ export interface ChatControllerOpts {
41
+ /** Forum topic thread — all runtimes post into this topic. */
42
+ messageThreadId?: number;
43
+ /** Settings key (`chatId` or `chatId:t{thread}`). Defaults to String(chatId). */
44
+ settingsKey?: string;
45
+ /** Fixed project path (forum topics): never switch away via project picker. */
46
+ fixedCwd?: string;
47
+ fixedProjectName?: string;
48
+ }
49
+
50
+ /** Optional Telegram bridge services attached to every runtime in this chat. */
51
+ export type ChatBridgeServices = SessionRuntime["bridge"];
52
+
43
53
  export class ChatController {
44
54
  private readonly runtimes: SessionRuntime[] = [];
45
55
  private fg: SessionRuntime | undefined;
46
56
  private readonly lastRead = new Map<string, number>();
47
57
  private restored = false;
58
+ readonly settingsKey: string;
59
+ readonly messageThreadId: number | undefined;
60
+ readonly fixedCwd: string | undefined;
61
+ readonly fixedProjectName: string | undefined;
62
+
63
+ /** Telegram bridge (forum / memory / sibling bots); set by the registry. */
64
+ bridge?: ChatBridgeServices;
48
65
 
49
66
  constructor(
50
67
  private readonly api: Api,
@@ -56,14 +73,22 @@ export class ChatController {
56
73
  private readonly refresh: (chatId: number) => void,
57
74
  private readonly notifyActivity: (busy: boolean) => void,
58
75
  private readonly getRotator?: () => AccountRotator | undefined,
59
- ) {}
76
+ opts?: ChatControllerOpts,
77
+ ) {
78
+ this.messageThreadId = opts?.messageThreadId;
79
+ this.settingsKey = opts?.settingsKey ?? String(chatId);
80
+ this.fixedCwd = opts?.fixedCwd;
81
+ this.fixedProjectName = opts?.fixedProjectName;
82
+ }
60
83
 
61
84
  /** The current foreground runtime (created/restored lazily). */
62
85
  foreground(): SessionRuntime {
63
86
  this.ensureRestored();
64
87
  if (!this.fg) {
65
- const s = this.settings.get(this.chatId);
66
- const rt = this.create({ cwd: s.projectPath ?? this.cfg.workspace, projectName: s.projectName, sessionId: s.sessionId });
88
+ const s = this.settings.getKey(this.settingsKey);
89
+ const cwd = this.fixedCwd ?? s.projectPath ?? this.cfg.workspace;
90
+ const name = this.fixedProjectName ?? s.projectName;
91
+ const rt = this.create({ cwd, projectName: name, sessionId: s.sessionId });
67
92
  this.runtimes.push(rt);
68
93
  this.fg = rt;
69
94
  }
@@ -279,7 +304,7 @@ export class ChatController {
279
304
  return this.runtimes.find((r) => r.sessionId === sessionId)?.taskProgress;
280
305
  }
281
306
 
282
- /** Live step / chat summary for a controlled session id. */
307
+ /** Last user prompt (+ thinking when busy) for a controlled session id. */
283
308
  commentFor(sessionId?: string): string | undefined {
284
309
  if (!sessionId) return undefined;
285
310
  this.ensureRestored();
@@ -301,27 +326,31 @@ export class ChatController {
301
326
  private ensureRestored(): void {
302
327
  if (this.restored) return;
303
328
  this.restored = true;
304
- const s = this.settings.get(this.chatId);
329
+ const s = this.settings.getKey(this.settingsKey);
305
330
  const seen = new Set<string>();
306
331
  for (const cs of s.controlledSessions ?? []) {
307
332
  if (!cs.sessionId || seen.has(cs.sessionId)) continue; // never restore the same session twice
308
333
  seen.add(cs.sessionId);
334
+ // Forum topics only restore sessions for the fixed project path.
335
+ if (this.fixedCwd && normPath(cs.projectPath) !== normPath(this.fixedCwd)) continue;
309
336
  this.runtimes.push(this.create({ cwd: cs.projectPath, projectName: cs.projectName, sessionId: cs.sessionId }));
310
337
  }
311
338
  // Lazy project switches persist projectPath without a sessionId. If the
312
339
  // saved project is not among controlled sessions, recreate an unbound FG
313
340
  // so a restart lands on the project the user last chose.
314
- if (s.projectPath) {
315
- const key = normPath(s.projectPath);
341
+ const homePath = this.fixedCwd ?? s.projectPath;
342
+ const homeName = this.fixedProjectName ?? s.projectName;
343
+ if (homePath) {
344
+ const key = normPath(homePath);
316
345
  const hasProject = this.runtimes.some((r) => normPath(r.cwd) === key);
317
346
  if (!hasProject) {
318
- this.runtimes.push(this.create({ cwd: s.projectPath, projectName: s.projectName }));
347
+ this.runtimes.push(this.create({ cwd: homePath, projectName: homeName, sessionId: s.sessionId }));
319
348
  }
320
349
  }
321
350
  if (this.runtimes.length > 0) {
322
351
  let fg = this.runtimes.find((r) => r.sessionId && r.sessionId === s.foregroundSessionId);
323
- if (!fg && s.projectPath) {
324
- const key = normPath(s.projectPath);
352
+ if (!fg && homePath) {
353
+ const key = normPath(homePath);
325
354
  fg = this.runtimes.find((r) => normPath(r.cwd) === key);
326
355
  }
327
356
  fg = fg ?? this.runtimes[0]!;
@@ -365,10 +394,15 @@ export class ChatController {
365
394
  }
366
395
 
367
396
  private create(init: { cwd: string; projectName?: string; sessionId?: string }): SessionRuntime {
368
- const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings, init);
397
+ const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings, {
398
+ ...init,
399
+ messageThreadId: this.messageThreadId,
400
+ settingsKey: this.settingsKey,
401
+ });
369
402
  rt.onStateChange = () => this.refresh(this.chatId);
370
403
  rt.onActivity = (busy) => this.notifyActivity(busy);
371
404
  rt.accountRotator = this.getRotator?.();
405
+ rt.bridge = this.bridge;
372
406
  // A logical fork (auto-fork-on-error / lost-session recovery) swaps the
373
407
  // runtime's session id in place — re-persist the controlled list with the
374
408
  // new id and treat the fresh session as already-seen.
@@ -415,15 +449,15 @@ export class ChatController {
415
449
  seen.add(r.sessionId);
416
450
  controlled.push({ sessionId: r.sessionId, projectPath: r.cwd, projectName: r.projectName });
417
451
  }
418
- this.settings.update(this.chatId, {
452
+ this.settings.updateKey(this.settingsKey, {
419
453
  controlledSessions: controlled,
420
454
  foregroundSessionId: this.fg?.sessionId,
421
455
  // Keep the single-session restore fields aligned with the foreground so
422
456
  // the pinned status panel and a fresh restore never show a project that
423
457
  // belongs to a different (previously-foreground) session.
424
458
  sessionId: this.fg?.sessionId,
425
- projectPath: this.fg?.cwd,
426
- projectName: this.fg?.projectName,
459
+ projectPath: this.fixedCwd ?? this.fg?.cwd,
460
+ projectName: this.fixedProjectName ?? this.fg?.projectName,
427
461
  });
428
462
  }
429
463
  }
@@ -1,31 +1,61 @@
1
1
  /**
2
2
  * Bot command definitions (for the Telegram command menu) and help text.
3
+ *
4
+ * Private chats get the full list (sorted by workflow). Groups/forum topics get
5
+ * a shorter list with cancel/menu first — reply keyboards are unreliable there.
3
6
  */
7
+
8
+ /** Full command menu (private chats + default). Order = Telegram "/" menu order. */
4
9
  export const COMMANDS: { command: string; description: string }[] = [
10
+ // Core control
5
11
  { command: "start", description: "Welcome, menu & status panel" },
6
- { command: "menu", description: "Show the menu keyboard" },
7
- { command: "projects", description: "Projects: list / search <q> / open <path> / new <name>" },
8
- { command: "sessions", description: "List/resume sessions (active first) \u00b7 /sessions <q>" },
12
+ { command: "menu", description: "Open the menu" },
13
+ { command: "cancel", description: "Stop the current turn" },
14
+ { command: "stop", description: "Stop the current turn (alias of /cancel)" },
15
+ { command: "status", description: "Current session, project & queue" },
16
+ { command: "new", description: "New session in current project" },
17
+ // Sessions
18
+ { command: "running", description: "Sessions this chat controls" },
19
+ { command: "sessions", description: "List/resume sessions · /sessions <q>" },
9
20
  { command: "active", description: "Sessions running now on the PC" },
10
- { command: "running", description: "Sessions this chat controls \u2014 switch between them" },
11
- { command: "import", description: "Import a Kiro/OpenCode/Claude/Codex /running session" },
12
- { command: "killall", description: "Kill all active sessions on the PC" },
13
- { command: "mcp", description: "Inspect & toggle MCP servers \u00b7 health-check" },
14
- { command: "tasks", description: "Manage scheduled tasks" },
15
- { command: "newtask", description: "Create a scheduled task" },
16
21
  { command: "history", description: "Show recent conversation history" },
17
- { command: "new", description: "Start a fresh session here" },
18
- { command: "status", description: "Current session, project & queue" },
19
- { command: "usage", description: "Account & context usage" },
20
- { command: "btw", description: "Run ASAP (now if idle, else next): /btw <text>" },
22
+ { command: "import", description: "Import Kiro/OpenCode/Claude/Codex session" },
23
+ // Project
24
+ { command: "projects", description: "Projects: list / search / open / new" },
25
+ // Queue
26
+ { command: "btw", description: "Run ASAP: /btw <text>" },
21
27
  { command: "flush", description: "Send queued follow-ups now" },
22
28
  { command: "queue", description: "Show queued follow-ups" },
23
- { command: "cancel", description: "Stop the current turn" },
24
- { command: "unwatch", description: "Stop following a live session" },
29
+ // Account
30
+ { command: "accounts", description: "Switch between saved Grok accounts" },
31
+ { command: "reauth", description: "Sign in to Grok (login or import)" },
32
+ { command: "usage", description: "Account & context usage" },
33
+ // System / tools
34
+ { command: "mcp", description: "Inspect & toggle MCP servers" },
35
+ { command: "tasks", description: "Manage scheduled tasks" },
36
+ { command: "newtask", description: "Create a scheduled task" },
37
+ { command: "killall", description: "Kill all active sessions on the PC" },
25
38
  { command: "model", description: "Switch model: /model <id>" },
26
39
  { command: "restart", description: "Restart the Grok agent" },
27
- { command: "reauth", description: "Sign in to Grok (login or import)" },
28
- { command: "accounts", description: "Switch between saved Grok accounts" },
40
+ { command: "unwatch", description: "Stop following a live session" },
41
+ { command: "help", description: "Show help" },
42
+ ];
43
+
44
+ /**
45
+ * Group / forum command menu — keep short; cancel & menu first so topics can
46
+ * stop a turn without the private reply-keyboard bar.
47
+ */
48
+ export const GROUP_COMMANDS: { command: string; description: string }[] = [
49
+ { command: "cancel", description: "Stop the current turn" },
50
+ { command: "stop", description: "Stop the current turn" },
51
+ { command: "menu", description: "Open topic / group menu" },
52
+ { command: "status", description: "Session, project & queue" },
53
+ { command: "new", description: "New session in this topic/project" },
54
+ { command: "running", description: "Sessions this topic controls" },
55
+ { command: "sessions", description: "List / resume sessions" },
56
+ { command: "btw", description: "Queue or run: /btw <text>" },
57
+ { command: "flush", description: "Run queued follow-ups now" },
58
+ { command: "model", description: "Switch model: /model <id>" },
29
59
  { command: "help", description: "Show help" },
30
60
  ];
31
61
 
@@ -37,18 +67,28 @@ export const HELP_TEXT = [
37
67
  "\u2022 Just send a message to chat with Grok in the current project.",
38
68
  "\u2022 While Grok is working, anything you send is queued and runs",
39
69
  " automatically when the current turn finishes.",
70
+ "\u2022 Persistent bar (private): \u2630 Menu \u00B7 \u{1F195} New session \u00B7 \u{1F9ED} Running \u00B7 \u23F9 Stop.",
71
+ "\u2022 New session: /new or the bar button (not the inline Menu message).",
72
+ "\u2022 In groups / forum topics: use /cancel or Menu \u2192 \u23F9 Stop",
73
+ " (the persistent bar is unreliable in topics; use /new or topic menu).",
74
+ "",
75
+ "COMMANDS (core)",
76
+ "/menu \u2014 open the menu",
77
+ "/cancel or /stop \u2014 stop the current turn",
78
+ "/status \u2014 session, project and queue",
79
+ "/new \u2014 new session in the current project (also bar: \u{1F195} New session)",
80
+ "",
81
+ "COMMANDS (sessions)",
82
+ "/running \u2014 sessions this chat/topic controls",
83
+ "/sessions \u2014 resume a recent Grok session",
84
+ "/active \u2014 attach to a session running on the PC",
85
+ "/history \u2014 latest messages of the current session",
86
+ "/import \u2014 import a /running session from another agent",
40
87
  "",
41
- "COMMANDS",
42
- "/projects \u2014 choose which folder Grok works in",
43
- "/sessions \u2014 resume one of your recent Grok sessions",
44
- "/active \u2014 attach to a session currently running on the PC",
45
- "/import \u2014 import a /running session from Kiro, OpenCode, Claude, or Codex",
46
- "/history \u2014 show the latest messages of the current session",
47
- "/new \u2014 start a brand-new session in the current project",
48
- "/btw <text> \u2014 run it now if idle, otherwise right after the current task",
88
+ "COMMANDS (more)",
89
+ "/projects \u2014 choose which folder Grok works in (private chats)",
90
+ "/btw <text> \u2014 run now if idle, else right after the current task",
49
91
  "/flush \u2014 run queued follow-ups immediately",
50
- "/cancel \u2014 stop the current turn",
51
- "/status \u2014 show session, project and queue size",
52
- "/reauth \u2014 sign in to Grok (grok login, or import an existing login)",
53
- "/accounts \u2014 switch between saved Grok accounts",
92
+ "/reauth \u2014 sign in to Grok",
93
+ "/accounts \u2014 switch saved Grok accounts",
54
94
  ].join("\n");
package/src/bot/deps.ts CHANGED
@@ -18,6 +18,7 @@ import type { TaskRunner } from "../tasks/runner.js";
18
18
  import type { TaskStore } from "../tasks/store.js";
19
19
  import type { StatusPanel } from "./menu/status-panel.js";
20
20
  import type { Ephemeral } from "./menu/ephemeral.js";
21
+ import type { ForumManager } from "../forum/manager.js";
21
22
  import type { RuntimeRegistry } from "./registry.js";
22
23
  import type { TaskWizard } from "./wizard/task-wizard.js";
23
24
 
@@ -38,6 +39,8 @@ export interface BotDeps {
38
39
  stt: SttService;
39
40
  usage: UsageService;
40
41
  accounts: AccountManager;
42
+ /** Present when TOPIC_GROUP_ID is configured. */
43
+ forum?: ForumManager;
41
44
  }
42
45
 
43
46
  /** Caches the last project list shown per chat for callback resolution. */
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Local "group memory" search across forum topics, session metadata, and
3
+ * recent session history. Telegram bots have no general message-search API,
4
+ * so this indexes what the bridge already stores on disk.
5
+ */
6
+ import { join } from "node:path";
7
+ import { readHistory } from "../sessions/history.js";
8
+ import type { SessionMeta } from "../sessions/types.js";
9
+ import type { SessionStore } from "../sessions/store.js";
10
+ import type { ForumTopicBinding } from "../forum/types.js";
11
+
12
+ export type MemoryHitKind = "topic" | "session" | "history";
13
+
14
+ export interface MemoryHit {
15
+ kind: MemoryHitKind;
16
+ title: string;
17
+ snippet: string;
18
+ score: number;
19
+ path?: string;
20
+ sessionId?: string;
21
+ threadId?: number;
22
+ }
23
+
24
+ export interface GroupMemorySearchOpts {
25
+ query: string;
26
+ limit?: number;
27
+ sessionsDir: string;
28
+ store: SessionStore;
29
+ topics?: ForumTopicBinding[];
30
+ /** Max sessions whose JSONL tails are scanned. */
31
+ maxSessions?: number;
32
+ }
33
+
34
+ /** Tokenize a query into lowercase alphanumeric tokens (min length 2). */
35
+ export function tokenizeQuery(query: string): string[] {
36
+ return query
37
+ .toLowerCase()
38
+ .split(/[^a-z0-9_./\\-]+/i)
39
+ .map((t) => t.trim())
40
+ .filter((t) => t.length >= 2)
41
+ .slice(0, 12);
42
+ }
43
+
44
+ /** Score how well `haystack` matches tokens (0 if no token hits). */
45
+ export function scoreTokens(haystack: string, tokens: string[]): number {
46
+ if (!tokens.length || !haystack) return 0;
47
+ const h = haystack.toLowerCase();
48
+ let score = 0;
49
+ let hits = 0;
50
+ for (const t of tokens) {
51
+ if (!h.includes(t)) continue;
52
+ hits++;
53
+ // Prefer whole-word-ish hits slightly.
54
+ const re = new RegExp(`(?:^|[^a-z0-9])${escapeReg(t)}(?:[^a-z0-9]|$)`, "i");
55
+ score += re.test(h) ? 3 : 1;
56
+ // Density bonus for short fields.
57
+ if (h.length < 120) score += 1;
58
+ }
59
+ if (hits === 0) return 0;
60
+ // All-token bonus.
61
+ if (hits === tokens.length) score += 4;
62
+ return score;
63
+ }
64
+
65
+ /**
66
+ * Search topics + session store + recent history. Pure ranking over provided
67
+ * store; safe to call on the main bot thread (capped I/O).
68
+ */
69
+ export function searchGroupMemory(opts: GroupMemorySearchOpts): MemoryHit[] {
70
+ const tokens = tokenizeQuery(opts.query);
71
+ if (tokens.length === 0) return [];
72
+ const limit = Math.max(1, Math.min(20, opts.limit ?? 8));
73
+ const maxSessions = opts.maxSessions ?? 40;
74
+ const hits: MemoryHit[] = [];
75
+
76
+ for (const t of opts.topics ?? []) {
77
+ const hay = [t.name, t.projectPath ?? "", t.kind, t.sessionId ?? ""].join("\n");
78
+ const score = scoreTokens(hay, tokens);
79
+ if (score <= 0) continue;
80
+ hits.push({
81
+ kind: "topic",
82
+ title: t.name,
83
+ snippet: t.projectPath ? `path: ${t.projectPath}` : `kind: ${t.kind}`,
84
+ score: score + 2, // slight boost for topic map
85
+ path: t.projectPath ?? undefined,
86
+ threadId: t.threadId,
87
+ sessionId: t.sessionId,
88
+ });
89
+ }
90
+
91
+ let metas: SessionMeta[] = [];
92
+ try {
93
+ metas = opts.store.list(maxSessions);
94
+ } catch {
95
+ metas = [];
96
+ }
97
+
98
+ for (const m of metas) {
99
+ const hay = [m.title, m.comment ?? "", m.cwd, m.sessionId].join("\n");
100
+ const score = scoreTokens(hay, tokens);
101
+ if (score > 0) {
102
+ hits.push({
103
+ kind: "session",
104
+ title: m.title || m.sessionId.slice(0, 8),
105
+ snippet: clamp(
106
+ [m.comment, m.cwd].filter(Boolean).join(" · ") || m.sessionId,
107
+ 220,
108
+ ),
109
+ score,
110
+ path: m.cwd || undefined,
111
+ sessionId: m.sessionId,
112
+ });
113
+ }
114
+
115
+ // History tail (cheap): few entries, short text.
116
+ if (m.historyBytes <= 0) continue;
117
+ try {
118
+ const path = join(opts.sessionsDir, `${m.sessionId}.jsonl`);
119
+ const entries = readHistory(path, 12);
120
+ for (const e of entries) {
121
+ if (!e.text?.trim()) continue;
122
+ const s = scoreTokens(e.text, tokens);
123
+ if (s <= 0) continue;
124
+ hits.push({
125
+ kind: "history",
126
+ title: `${e.role} · ${(m.title || m.sessionId.slice(0, 8)).slice(0, 40)}`,
127
+ snippet: clamp(e.text.replace(/\s+/g, " ").trim(), 220),
128
+ score: s,
129
+ path: m.cwd || undefined,
130
+ sessionId: m.sessionId,
131
+ });
132
+ }
133
+ } catch {
134
+ /* ignore unreadable logs */
135
+ }
136
+ }
137
+
138
+ hits.sort((a, b) => b.score - a.score || a.kind.localeCompare(b.kind));
139
+ // Dedupe near-identical snippets.
140
+ const seen = new Set<string>();
141
+ const out: MemoryHit[] = [];
142
+ for (const h of hits) {
143
+ const key = `${h.kind}|${h.sessionId ?? h.threadId ?? ""}|${h.snippet.slice(0, 80)}`;
144
+ if (seen.has(key)) continue;
145
+ seen.add(key);
146
+ out.push(h);
147
+ if (out.length >= limit) break;
148
+ }
149
+ return out;
150
+ }
151
+
152
+ function clamp(s: string, max: number): string {
153
+ if (s.length <= max) return s;
154
+ return s.slice(0, max - 1) + "\u2026";
155
+ }
156
+
157
+ function escapeReg(s: string): string {
158
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
159
+ }
@@ -294,6 +294,13 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
294
294
  await deps.acp.start(true).catch(() => {});
295
295
  throw e;
296
296
  }
297
+ // Remember preferred account for this chat/topic scope (model/reasoning peers).
298
+ try {
299
+ const { resolveScope } = await import("../scope.js");
300
+ resolveScope(ctx, deps).rt.setPreferredAccountId(id);
301
+ } catch {
302
+ /* non-fatal */
303
+ }
297
304
  const note = (await deps.usage.isLoggedIn())
298
305
  ? `\u2705 Now signed in as ${meta.label}. Your next message runs on this account.`
299
306
  : `\u26A0\uFE0F Switched to ${meta.label}, but no usable login is active. ${UNSUPPORTED_LOGIN_HELP}`;
@@ -1,28 +1,41 @@
1
1
  /**
2
- * Control commands: /start /help /status /new /cancel /btw /flush.
2
+ * Control commands: /start /help /status /new /cancel /stop /btw /flush /menu.
3
+ *
4
+ * Slash messages are deleted instantly by bot.ts middleware; handlers post
5
+ * bot status messages so the user always sees the bot is alive (CLI can be slow).
3
6
  */
4
- import type { Bot } from "grammy";
7
+ import type { Bot, Context } from "grammy";
5
8
  import { basename } from "node:path";
6
9
  import { textPrompt } from "../../app/types.js";
7
10
  import type { BotDeps } from "../deps.js";
8
11
  import { HELP_TEXT } from "../commands.js";
9
12
  import { compactKeyboard } from "../menu/keyboard.js";
10
13
  import { refreshMenu } from "../menu/refresh.js";
14
+ import { adoptUserPrompt } from "../prompt-anchor.js";
11
15
  import { extractReplyContext } from "../reply-context.js";
16
+ import { resolveScope } from "../scope.js";
12
17
  import { openMainMenu } from "./menu.js";
13
18
 
14
19
  export function registerControl(bot: Bot, deps: BotDeps): void {
15
20
  bot.command("start", async (ctx) => {
16
- const rt = deps.registry.get(ctx.chat.id);
21
+ const scope = resolveScope(ctx, deps);
17
22
  const agent = deps.acp.agentInfo;
23
+ const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
18
24
  const lines = [
19
25
  "\u{1F44B} Welcome! I bridge Telegram to Grok Build over ACP.",
20
26
  agent?.name ? `Connected to ${agent.name} ${agent.version ?? ""}`.trim() : "",
21
27
  "",
22
- "Tap \u2630 Menu for everything. A live status panel appears while I work",
23
- "(\u2630 Menu \u2192 Status shows it anytime). Just send a message to start.",
28
+ isGroup || scope.isForum
29
+ ? "In groups / topics: /menu for controls, /cancel or /stop to halt a turn."
30
+ : "Bar: \u2630 Menu \u00B7 \u{1F195} New session \u00B7 \u{1F9ED} Running \u00B7 \u23F9 Stop. Live status panel while I work",
31
+ isGroup || scope.isForum
32
+ ? "Just send a message in this topic to start."
33
+ : "(\u2630 Menu \u2192 Status shows it anytime). Just send a message to start.",
24
34
  ].filter(Boolean);
25
- await ctx.reply(lines.join("\n"), { reply_markup: compactKeyboard() });
35
+ await ctx.reply(lines.join("\n"), {
36
+ reply_markup: compactKeyboard(),
37
+ ...scope.threadExtra,
38
+ });
26
39
  await deps.statusPanel.refresh(ctx.chat.id);
27
40
  });
28
41
 
@@ -32,71 +45,111 @@ export function registerControl(bot: Bot, deps: BotDeps): void {
32
45
  });
33
46
 
34
47
  bot.command("help", async (ctx) => {
35
- await ctx.reply(HELP_TEXT);
48
+ const scope = resolveScope(ctx, deps);
49
+ await ctx.reply(HELP_TEXT, scope.threadExtra);
36
50
  });
37
51
 
38
52
  bot.command("status", async (ctx) => {
39
- const rt = deps.registry.get(ctx.chat.id);
53
+ const scope = resolveScope(ctx, deps);
54
+ const rt = scope.rt;
40
55
  const lines = [
41
56
  "\u{1F4CA} Status",
57
+ scope.isForum ? `Topic: ${scope.projectName ?? "topic"}` : "",
42
58
  `Project: ${rt.projectName ?? (basename(rt.cwd) || rt.cwd)}`,
43
59
  `Folder: ${rt.cwd}`,
44
60
  `Session: ${rt.sessionId ?? "(none yet)"}`,
61
+ `Model: ${rt.model || "default"}`,
62
+ `Reasoning: ${rt.reasoning}`,
45
63
  `State: ${rt.isBusy ? "\u23F3 working" : "\u2705 idle"}`,
46
64
  `Queued follow-ups: ${rt.queueLength}`,
47
- ];
65
+ ].filter(Boolean);
48
66
  const subagents = deps.registry.subagentSummaryForChat(ctx.chat.id);
49
67
  if (subagents) lines.push(`Subagents: ${subagents}`);
50
- await ctx.reply(lines.join("\n"));
68
+ await ctx.reply(lines.join("\n"), scope.threadExtra);
51
69
  });
52
70
 
53
71
  bot.command("new", async (ctx) => {
54
- const rt = deps.registry.get(ctx.chat.id);
72
+ const scope = resolveScope(ctx, deps);
73
+ // Instant feedback before ACP session/new (can be slow on cold CLI).
74
+ // Same copy as bar 🆕 New and inline m:new.
75
+ await ctx.reply("\u2728 Creating new session\u2026", scope.threadExtra).catch(() => {});
55
76
  try {
56
- await deps.registry.controller(ctx.chat.id).addNew(rt.cwd, rt.projectName);
57
- await refreshMenu(ctx, deps, `\u2728 New session started in ${rt.projectName ?? rt.cwd}`);
77
+ await scope.controller.addNew(scope.rt.cwd, scope.rt.projectName);
78
+ await refreshMenu(ctx, deps, `\u2728 New session in ${scope.rt.projectName ?? scope.rt.cwd}`);
58
79
  } catch (err) {
59
- await ctx.reply(`\u274C Could not start session: ${(err as Error).message}`);
80
+ await ctx.reply(`\u274C ${(err as Error).message}`, scope.threadExtra);
60
81
  }
61
82
  });
62
83
 
63
- bot.command("cancel", async (ctx) => {
64
- const rt = deps.registry.get(ctx.chat.id);
65
- const cancelled = await rt.cancel();
66
- await ctx.reply(cancelled ? "\u23F9 Cancelling current turn\u2026" : "Nothing is running.");
67
- });
84
+ const cancelTurn = async (ctx: Context): Promise<void> => {
85
+ const scope = resolveScope(ctx, deps);
86
+ const cancelled = await scope.rt.cancel();
87
+ await ctx.reply(
88
+ cancelled ? "\u23F9 Cancelling current turn\u2026" : "Nothing is running.",
89
+ scope.threadExtra,
90
+ );
91
+ };
92
+
93
+ bot.command("cancel", cancelTurn);
94
+ bot.command("stop", cancelTurn);
68
95
 
69
96
  bot.command("btw", async (ctx) => {
70
97
  const text = (ctx.match || "").toString().trim();
71
98
  if (!text) {
72
- await ctx.reply("Usage: /btw <something for the agent to do — now if idle, otherwise next>");
99
+ const scope = resolveScope(ctx, deps);
100
+ await ctx.reply(
101
+ "Usage: /btw <something for the agent to do — now if idle, otherwise next>",
102
+ scope.threadExtra,
103
+ );
73
104
  return;
74
105
  }
75
- const rt = deps.registry.get(ctx.chat.id);
76
- // Run it right away when idle; otherwise queue it to run automatically the
77
- // moment the current turn finishes (can't interrupt an in-flight agent turn).
78
- const outcome = await rt.submit(textPrompt(text, undefined, extractReplyContext(ctx)));
106
+ const scope = resolveScope(ctx, deps);
107
+ const userMsgId = ctx.message?.message_id;
108
+ const anchor = await adoptUserPrompt(deps.api, {
109
+ chatId: ctx.chat.id,
110
+ text,
111
+ // Command middleware already deleted the slash message; re-delete is a no-op.
112
+ userMessageIds: userMsgId !== undefined ? [userMsgId] : [],
113
+ messageThreadId: scope.threadExtra.message_thread_id,
114
+ projectName: scope.rt.projectName,
115
+ prefix: "\u{1F4DD} /btw",
116
+ });
117
+ // Fall back to userMsgId if anchor send failed (message may already be gone).
118
+ const outcome = await scope.rt.submit(
119
+ textPrompt(text, anchor?.replyTo ?? userMsgId, extractReplyContext(ctx), {
120
+ promptId: anchor?.promptId,
121
+ }),
122
+ );
79
123
  if (outcome === "queued") {
124
+ const extra: Record<string, unknown> = { ...scope.threadExtra };
125
+ if (anchor?.replyTo !== undefined) {
126
+ extra.reply_parameters = {
127
+ message_id: anchor.replyTo,
128
+ allow_sending_without_reply: true,
129
+ };
130
+ }
80
131
  await ctx.reply(
81
- `\u{1F4E5} Queued (position ${rt.queueLength}) \u2014 it'll run automatically as soon as the current task finishes.`,
132
+ `\u{1F4E5} Queued (position ${scope.rt.queueLength}) \u2014 it'll run automatically as soon as the current task finishes.`,
133
+ extra,
82
134
  );
83
- } else {
84
- await ctx.reply("\u25B6\uFE0F On it\u2026");
85
135
  }
86
136
  });
87
137
 
88
138
  bot.command("flush", async (ctx) => {
89
- const rt = deps.registry.get(ctx.chat.id);
139
+ const scope = resolveScope(ctx, deps);
140
+ const rt = scope.rt;
90
141
  if (rt.queueLength === 0) {
91
- await ctx.reply("Queue is empty.");
142
+ await ctx.reply("Queue is empty.", scope.threadExtra);
92
143
  return;
93
144
  }
94
145
  if (rt.isBusy) {
95
- await ctx.reply(`\u23F3 ${rt.queueLength} queued \u2014 they'll run automatically when the current turn ends.`);
146
+ await ctx.reply(
147
+ `\u23F3 ${rt.queueLength} queued \u2014 they'll run automatically when the current turn ends.`,
148
+ scope.threadExtra,
149
+ );
96
150
  return;
97
151
  }
98
- // Idle: drain the queue by submitting an empty trigger that flushes.
99
- await ctx.reply("\u25B6\uFE0F Running queued follow-ups\u2026");
152
+ await ctx.reply("\u25B6\uFE0F Running queued follow-ups\u2026", scope.threadExtra);
100
153
  const drained = rt.drainQueueToPrompt();
101
154
  if (drained) await rt.submit(drained);
102
155
  });