grok-telegram-bot 2.3.0 → 2.4.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 (68) hide show
  1. package/.env.example +26 -0
  2. package/CHANGELOG.md +55 -0
  3. package/package.json +1 -1
  4. package/scripts/analyze-jsonl.ts +33 -0
  5. package/scripts/delayed-restart.ps1 +29 -0
  6. package/scripts/probe-exit-response-shape.py +77 -0
  7. package/scripts/probe-plan-exit.py +60 -0
  8. package/scripts/probe-plan-exit2.py +48 -0
  9. package/scripts/probe-plan-fields.py +41 -0
  10. package/scripts/probe-plan-fields2.py +58 -0
  11. package/scripts/probe-plan-response-path.py +48 -0
  12. package/scripts/sample-claude-tooluse.ts +21 -0
  13. package/scripts/sample-kiro-events.ts +31 -0
  14. package/scripts/smoke-exit-plan.ts +274 -0
  15. package/scripts/smoke-exit-shapes.ts +252 -0
  16. package/scripts/smoke-import.mjs +82 -0
  17. package/scripts/smoke-import.ts +73 -0
  18. package/src/app/accounts.ts +84 -0
  19. package/src/app/instance-lock.ts +6 -0
  20. package/src/app/types.ts +19 -2
  21. package/src/app/updater.ts +17 -6
  22. package/src/app/usage.ts +204 -7
  23. package/src/bot/account-rotator.ts +71 -2
  24. package/src/bot/bot.ts +36 -0
  25. package/src/bot/chat-controller.ts +35 -0
  26. package/src/bot/commands.ts +2 -0
  27. package/src/bot/complexity-gate.ts +69 -0
  28. package/src/bot/deps.ts +19 -0
  29. package/src/bot/handlers/accounts.ts +55 -5
  30. package/src/bot/handlers/import-session.ts +290 -0
  31. package/src/bot/handlers/menu.ts +17 -38
  32. package/src/bot/handlers/message.ts +1 -0
  33. package/src/bot/handlers/running.ts +35 -5
  34. package/src/bot/handlers/session-card.ts +12 -0
  35. package/src/bot/handlers/sessions.ts +14 -3
  36. package/src/bot/handlers/usage.ts +118 -16
  37. package/src/bot/menu/keyboard.ts +5 -4
  38. package/src/bot/menu/status-panel.ts +19 -6
  39. package/src/bot/prompt-content.ts +4 -0
  40. package/src/bot/reauth-controller.ts +2 -2
  41. package/src/bot/session-fork.ts +11 -0
  42. package/src/bot/session-runtime.ts +831 -64
  43. package/src/bot/suggestions.ts +429 -0
  44. package/src/config.ts +41 -0
  45. package/src/grok/client.ts +106 -20
  46. package/src/grok/plan-approval.ts +72 -0
  47. package/src/grok/session-log.ts +16 -0
  48. package/src/grok/types.ts +21 -2
  49. package/src/import/build-import.ts +132 -0
  50. package/src/import/history-readers.ts +681 -0
  51. package/src/import/list-running.ts +100 -0
  52. package/src/import/sources.ts +78 -0
  53. package/src/index.ts +179 -24
  54. package/src/render/diff.ts +11 -2
  55. package/src/render/file-summary.ts +31 -1
  56. package/src/render/markdown.ts +293 -35
  57. package/src/render/plan.ts +127 -0
  58. package/src/render/session-comment.ts +261 -0
  59. package/src/render/tool-call-detail.ts +400 -19
  60. package/src/render/tool-call-merge.ts +115 -0
  61. package/src/render/tool-call.ts +405 -142
  62. package/src/render/truncate.ts +85 -0
  63. package/src/service/windows.ts +14 -2
  64. package/src/sessions/history.ts +57 -0
  65. package/src/sessions/store.ts +3 -0
  66. package/src/sessions/types.ts +5 -0
  67. package/src/stream/streamer.ts +73 -9
  68. package/src/tasks/runner.ts +4 -3
@@ -0,0 +1,69 @@
1
+ /**
2
+ * First-prompt complexity steering (fully automatic — never asks the user).
3
+ *
4
+ * On the first user message of a fresh session the bot prepends a directive so
5
+ * Grok itself decides Simple vs Complex:
6
+ * • Simple — implement directly.
7
+ * • Complex — enter plan mode, investigate carefully, write a plan,
8
+ * exit_plan_mode (auto-approved by this bridge), implement,
9
+ * then re-review the result.
10
+ *
11
+ * No Telegram buttons, no user choice, no waiting.
12
+ */
13
+ import type { PromptInput } from "../app/types.js";
14
+
15
+ /**
16
+ * Agent-only directive. Must stay free of real `{progress: N%}` digit markers
17
+ * (history cleaner strips those). Uses the letter N only if mentioning format.
18
+ */
19
+ export const AUTO_COMPLEXITY_DIRECTIVE = [
20
+ "COMPLEXITY (decide yourself — never ask the user):",
21
+ "Silently classify this task as Simple or Complex. Do NOT ask the user which it is. Do NOT show Simple/Complex buttons or questions.",
22
+ "",
23
+ "If SIMPLE (clear path, small change, obvious fix, short answer):",
24
+ " implement or answer directly with normal care.",
25
+ "",
26
+ "If COMPLEX (ambiguity, multi-file architecture, high rework risk, unclear approach):",
27
+ " 1. Enter plan mode (enter_plan_mode) when available.",
28
+ " 2. Investigate carefully: explore the codebase, map patterns, edge cases, and risks before coding.",
29
+ " 3. Write a solid plan to the plan file; prefer investigation over speed.",
30
+ " 4. Call exit_plan_mode when ready. This Telegram bridge auto-approves plan exit",
31
+ " (there is no TUI plan popup). After exit_plan_mode succeeds, implement fully.",
32
+ " Do NOT wait for the user to \"approve a popup\" — just call exit_plan_mode and proceed.",
33
+ " 5. After implementation, re-review your work (verify correctness, edge cases, and that the plan was followed) before finishing.",
34
+ "",
35
+ "User task:",
36
+ ].join("\n");
37
+
38
+ /** Optional mode ids Grok may advertise for plan mode (best-effort only). */
39
+ export const PLAN_MODE_CANDIDATES = ["plan", "planning", "architect", "design"] as const;
40
+
41
+ /**
42
+ * Prepend the auto-complexity directive so the agent decides Simple vs Complex
43
+ * without any user interaction.
44
+ */
45
+ export function wrapAutoComplexityPrompt(input: PromptInput): PromptInput {
46
+ const body = input.text.trim() || "(see attached media / files)";
47
+ // Avoid double-wrapping if a retry/queue path already applied it.
48
+ if (body.startsWith("COMPLEXITY (decide yourself")) return input;
49
+ return {
50
+ ...input,
51
+ text: `${AUTO_COMPLEXITY_DIRECTIVE}\n${body}`,
52
+ };
53
+ }
54
+
55
+ /** Pick a plan-mode id from the agent's advertised modes, if any. */
56
+ export function pickPlanModeId(
57
+ modes: Array<{ id: string; name: string }>,
58
+ hasMode: (id: string) => boolean,
59
+ ): string | undefined {
60
+ for (const id of PLAN_MODE_CANDIDATES) {
61
+ if (hasMode(id)) return id;
62
+ }
63
+ for (const m of modes) {
64
+ if (/plan|architect|design/i.test(m.id) || /plan|architect|design/i.test(m.name)) {
65
+ return m.id;
66
+ }
67
+ }
68
+ return undefined;
69
+ }
package/src/bot/deps.ts CHANGED
@@ -10,6 +10,8 @@ import type { AppConfig } from "../config.js";
10
10
  import type { SttService } from "../app/stt.js";
11
11
  import type { UsageService } from "../app/usage.js";
12
12
  import type { ProjectEntry, ProjectManager } from "../projects/manager.js";
13
+ import type { ImportableSession } from "../import/list-running.js";
14
+ import type { ImportSourceId } from "../import/sources.js";
13
15
  import type { SessionMeta } from "../sessions/types.js";
14
16
  import type { SessionStore } from "../sessions/store.js";
15
17
  import type { TaskRunner } from "../tasks/runner.js";
@@ -42,6 +44,10 @@ export interface BotDeps {
42
44
  export class MenuCache {
43
45
  private readonly projectLists = new Map<number, ProjectEntry[]>();
44
46
  private readonly sessionLists = new Map<number, { metas: SessionMeta[]; heading: string }>();
47
+ private readonly importLists = new Map<
48
+ number,
49
+ { sourceId: ImportSourceId; sessions: ImportableSession[] }
50
+ >();
45
51
 
46
52
  setProjects(chatId: number, list: ProjectEntry[]): void {
47
53
  this.projectLists.set(chatId, list);
@@ -64,4 +70,17 @@ export class MenuCache {
64
70
  getSessions(chatId: number): { metas: SessionMeta[]; heading: string } | undefined {
65
71
  return this.sessionLists.get(chatId);
66
72
  }
73
+
74
+ /** Cache foreign sessions shown by Import session (callback index → session). */
75
+ setImportSessions(chatId: number, sourceId: ImportSourceId, sessions: ImportableSession[]): void {
76
+ this.importLists.set(chatId, { sourceId, sessions });
77
+ }
78
+
79
+ getImportSessions(chatId: number): { sourceId: ImportSourceId; sessions: ImportableSession[] } | undefined {
80
+ return this.importLists.get(chatId);
81
+ }
82
+
83
+ getImportSession(chatId: number, index: number): ImportableSession | undefined {
84
+ return this.importLists.get(chatId)?.sessions[index];
85
+ }
67
86
  }
@@ -11,6 +11,7 @@ import { type Bot, type Context, InlineKeyboard } from "grammy";
11
11
  import { AuthService } from "../../app/auth-service.js";
12
12
  import type { StoredAccount } from "../../app/accounts.js";
13
13
  import { UNSUPPORTED_LOGIN_HELP } from "../../app/grok-credentials.js";
14
+ import { formatCliBillingLines } from "../../app/usage.js";
14
15
  import { createLogger } from "../../logger.js";
15
16
  import type { BotDeps } from "../deps.js";
16
17
 
@@ -48,12 +49,35 @@ async function view(deps: BotDeps, note?: string): Promise<{ text: string; keybo
48
49
  } else {
49
50
  lines.push("\u{1F7E2} Signed in (identity unknown).", "");
50
51
  }
52
+ // Live Grok CLI monthly quota for the host login (same source as OmniRoute-style clients).
53
+ if (loggedIn) {
54
+ const { billing, error } = await deps.usage.cliBilling().catch((e) => ({
55
+ billing: undefined,
56
+ error: (e as Error).message,
57
+ }));
58
+ if (billing) {
59
+ lines.push(...formatCliBillingLines(billing), "");
60
+ } else if (error) {
61
+ lines.push(`\u{1F4B3} Grok CLI quota: unavailable (${error})`, "");
62
+ }
63
+ }
51
64
  if (list.length === 0) {
52
65
  lines.push("No saved accounts yet.", "", "Save the current login below, or sign in via /reauth.");
53
66
  } else {
54
67
  for (const a of list) {
55
68
  lines.push(accountLine(a, a.id === active));
56
- if (a.warning) lines.push(" \u2514 Skipped by auto-rotate after an account access or quota error.");
69
+ const usage = deps.accounts.formatUsageLine(a);
70
+ if (usage) lines.push(` \u2514 \u{1F4CA} ${usage}`);
71
+ else lines.push(" \u2514 \u{1F4CA} No recorded usage yet");
72
+ if (a.warning) {
73
+ lines.push(
74
+ ` \u2514 \u26A0\uFE0F Skipped by auto-rotate: ${a.warning.reason.slice(0, 80)}${a.warning.reason.length > 80 ? "\u2026" : ""}`,
75
+ );
76
+ }
77
+ }
78
+ const totals = summarizeUsage(list);
79
+ if (totals) {
80
+ lines.push("", `\u{1F4CA} All saved accounts: ${totals}`);
57
81
  }
58
82
  }
59
83
  const rotate = deps.accounts.autoRotateEnabled();
@@ -86,6 +110,32 @@ function trim(s: string, n = 22): string {
86
110
  return s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
87
111
  }
88
112
 
113
+ /** Aggregate usage across saved accounts for the footer line. */
114
+ function summarizeUsage(list: StoredAccount[]): string {
115
+ let turns = 0;
116
+ let credits = 0;
117
+ let used = 0;
118
+ for (const a of list) {
119
+ const u = a.usage;
120
+ if (!u) continue;
121
+ if (u.turns > 0 || u.credits > 0) used++;
122
+ turns += u.turns || 0;
123
+ credits += u.credits || 0;
124
+ }
125
+ if (used === 0 && turns === 0 && credits === 0) return "";
126
+ const parts: string[] = [];
127
+ if (turns > 0) parts.push(`${turns} turn${turns === 1 ? "" : "s"}`);
128
+ if (credits > 0) parts.push(`${fmtNum(credits)} credits total`);
129
+ parts.push(`${used}/${list.length} accounts used`);
130
+ return parts.join(" \u00B7 ");
131
+ }
132
+
133
+ function fmtNum(n: number): string {
134
+ if (!Number.isFinite(n)) return String(n);
135
+ if (Number.isInteger(n)) return n.toLocaleString("en-US");
136
+ return n.toFixed(2);
137
+ }
138
+
89
139
  export async function showAccounts(ctx: Context, deps: BotDeps): Promise<void> {
90
140
  const { text, keyboard } = await view(deps);
91
141
  await deps.ephemeral.open(ctx);
@@ -205,9 +255,9 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
205
255
  // Import reuses the live auth.json — just re-bind the agent headlessly.
206
256
  try {
207
257
  await deps.acp.stopAndWait();
208
- await deps.acp.start();
258
+ await deps.acp.start(true);
209
259
  } catch (e) {
210
- await deps.acp.start().catch(() => {});
260
+ await deps.acp.start(true).catch(() => {});
211
261
  return void rerender(ctx, deps, `\u26A0\uFE0F Imported, but re-bind failed: ${(e as Error).message}`);
212
262
  }
213
263
  let note = `\u2705 Imported the current login${res.label ? ` (${res.label})` : ""}.`;
@@ -239,9 +289,9 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
239
289
  try {
240
290
  meta = await deps.accounts.switchTo(id);
241
291
  // 3) Start agent; it authenticates headlessly with cached_token.
242
- await deps.acp.start();
292
+ await deps.acp.start(true);
243
293
  } catch (e) {
244
- await deps.acp.start().catch(() => {});
294
+ await deps.acp.start(true).catch(() => {});
245
295
  throw e;
246
296
  }
247
297
  const note = (await deps.usage.isLoggedIn())
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Import session — pick a sibling bot (Kiro / OpenCode / Claude / Codex), list
3
+ * its /running (controlled) sessions, and import one into this chat as a new
4
+ * Grok session primed with the full transcript (nothing lost).
5
+ *
6
+ * Flow:
7
+ * Menu → Import session → source → running session cards → Import
8
+ */
9
+ import { basename, join } from "node:path";
10
+ import { type Bot, type Context, InlineKeyboard } from "grammy";
11
+ import { textPrompt } from "../../app/types.js";
12
+ import { buildImportPackage } from "../../import/build-import.js";
13
+ import { listRunningFromSource, type ImportableSession } from "../../import/list-running.js";
14
+ import {
15
+ getImportSource,
16
+ IMPORT_SOURCES,
17
+ sourceAvailable,
18
+ type ImportSourceId,
19
+ } from "../../import/sources.js";
20
+ import { createLogger } from "../../logger.js";
21
+ import type { BotDeps } from "../deps.js";
22
+ import { IMPORT_CONFIRM_PROMPT } from "../session-fork.js";
23
+ import { refreshMenu } from "../menu/refresh.js";
24
+
25
+ const log = createLogger("import-session");
26
+
27
+ const CARD_LIMIT = 20;
28
+
29
+ /** Open the source picker (menu entry / /import). */
30
+ export async function showImportSources(ctx: Context, deps: BotDeps): Promise<void> {
31
+ await deps.ephemeral.open(ctx);
32
+ const kb = new InlineKeyboard();
33
+ for (const src of IMPORT_SOURCES) {
34
+ const ok = sourceAvailable(src);
35
+ const mark = ok ? "" : " \u26A0";
36
+ kb.text(`${labelEmoji(src.id)} ${src.label}${mark}`, `imp:src:${src.id}`).row();
37
+ }
38
+ kb.text("\u2716 Cancel", "imp:cancel");
39
+ await deps.ephemeral.reply(
40
+ ctx,
41
+ [
42
+ "\u{1F4E5} Import session",
43
+ "",
44
+ "Choose the source tool. You will then see its /running sessions",
45
+ "and can import one into Grok with full conversation context.",
46
+ ].join("\n"),
47
+ { reply_markup: kb },
48
+ );
49
+ }
50
+
51
+ /** List /running sessions for a source bot. */
52
+ export async function showImportRunning(
53
+ ctx: Context,
54
+ deps: BotDeps,
55
+ sourceId: ImportSourceId,
56
+ ): Promise<void> {
57
+ const src = getImportSource(sourceId);
58
+ if (!src) {
59
+ await ctx.reply("Unknown source.");
60
+ return;
61
+ }
62
+ await deps.ephemeral.open(ctx);
63
+
64
+ if (!sourceAvailable(src)) {
65
+ await deps.ephemeral.reply(
66
+ ctx,
67
+ `\u26A0\uFE0F ${src.label} bot root not found:\n\`${src.botRoot}\``,
68
+ );
69
+ return;
70
+ }
71
+
72
+ const chatId = ctx.chat!.id;
73
+ let sessions = listRunningFromSource(src, chatId);
74
+ // If this Telegram chat id has no controlled sessions there, union all chats
75
+ // on that bot (same human often uses one chat id everywhere).
76
+ if (sessions.length === 0) sessions = listRunningFromSource(src);
77
+
78
+ if (sessions.length === 0) {
79
+ const kb = new InlineKeyboard().text("\u25C0 Sources", "imp:back").text("\u2716 Cancel", "imp:cancel");
80
+ await deps.ephemeral.reply(
81
+ ctx,
82
+ [
83
+ `\u{1F4E5} ${src.label} \u2014 no /running sessions`,
84
+ "",
85
+ "That bot has no controlled sessions in its settings right now.",
86
+ "Open a session there first (Project / message), then try Import again.",
87
+ ].join("\n"),
88
+ { reply_markup: kb },
89
+ );
90
+ return;
91
+ }
92
+
93
+ deps.menuCache.setImportSessions(chatId, sourceId, sessions);
94
+ const shown = sessions.slice(0, CARD_LIMIT);
95
+ await deps.ephemeral.reply(
96
+ ctx,
97
+ `\u{1F4E5} ${src.label} /running \u2014 ${sessions.length} session(s). Tap Import on a card:`,
98
+ );
99
+
100
+ const now = Date.now();
101
+ for (let i = 0; i < shown.length; i++) {
102
+ const s = shown[i]!;
103
+ const { text, kb } = buildImportCard(s, i, now);
104
+ await deps.ephemeral.reply(ctx, text, { reply_markup: kb });
105
+ }
106
+ if (sessions.length > shown.length) {
107
+ await deps.ephemeral.reply(ctx, `\u2026and ${sessions.length - shown.length} more (not shown).`);
108
+ }
109
+ const nav = new InlineKeyboard().text("\u25C0 Sources", "imp:back").text("\u2716 Cancel", "imp:cancel");
110
+ await deps.ephemeral.reply(ctx, "Pick a session above, or go back.", { reply_markup: nav });
111
+ }
112
+
113
+ /** Perform the import into a new Grok /running session. */
114
+ export async function doImportSession(ctx: Context, deps: BotDeps, index: number): Promise<void> {
115
+ const chatId = ctx.chat!.id;
116
+ const cached = deps.menuCache.getImportSessions(chatId);
117
+ const session = cached?.sessions[index];
118
+ if (!session || !cached) {
119
+ await ctx.reply("That import list expired \u2014 open Import session again.");
120
+ return;
121
+ }
122
+ const src = getImportSource(cached.sourceId);
123
+ if (!src) {
124
+ await ctx.reply("Unknown source.");
125
+ return;
126
+ }
127
+
128
+ await deps.ephemeral.clear(chatId).catch(() => {});
129
+ await ctx.reply(
130
+ `\u23F3 Importing from ${src.label} \u2026\n\`${session.sessionId.slice(0, 12)}\` \u00B7 ${session.projectName ?? (basename(session.cwd || "") || "project")}`,
131
+ );
132
+
133
+ const cwd = resolveImportCwd(session, deps, chatId);
134
+ const projectName = session.projectName || (cwd ? basename(cwd) : "imported");
135
+ const importsDir = join(deps.cfg.dataDir, "imports");
136
+
137
+ let pkg;
138
+ try {
139
+ pkg = buildImportPackage(src, { ...session, cwd }, importsDir);
140
+ } catch (e) {
141
+ log.error("buildImportPackage failed:", (e as Error).message);
142
+ await ctx.reply(`\u274C Could not read source history: ${(e as Error).message}`);
143
+ return;
144
+ }
145
+
146
+ if (pkg.entryCount === 0) {
147
+ await ctx.reply(
148
+ [
149
+ `\u26A0\uFE0F No history entries found on disk for this session.`,
150
+ `Session id: \`${session.sessionId}\``,
151
+ `I will still open a Grok session in the project, but context may be empty.`,
152
+ `Transcript archive: \`${pkg.transcriptPath}\``,
153
+ ].join("\n"),
154
+ );
155
+ }
156
+
157
+ try {
158
+ const rt = await deps.registry.controller(chatId).addImport(cwd, projectName, pkg.priming);
159
+ // Flush priming into the live Grok session immediately so context is not
160
+ // waiting on the user's next free-form message.
161
+ void rt.submit(textPrompt(IMPORT_CONFIRM_PROMPT));
162
+
163
+ const lines = [
164
+ `\u2705 Imported into Grok /running`,
165
+ `\u{1F4E5} ${src.label} \u2192 Grok`,
166
+ `\u{1F4C1} ${projectName}`,
167
+ cwd ? ` ${cwd}` : "",
168
+ `\u{1F4DC} ${pkg.entryCount} history entries \u00B7 ${humanSize(pkg.transcriptChars)} transcript`,
169
+ pkg.truncatedInline
170
+ ? `\u2139\uFE0F Full transcript is on disk (inline slice was capped); Grok will use the file if needed.`
171
+ : `\u2705 Full transcript inlined into the first Grok turn.`,
172
+ `\u{1F4C4} ${pkg.transcriptPath}`,
173
+ rt.sessionId ? `\u{1F194} new Grok session ${rt.sessionId.slice(0, 8)}` : "",
174
+ ``,
175
+ `Grok is loading the context now. After the short confirmation, send your next instruction to continue.`,
176
+ ].filter(Boolean);
177
+
178
+ await refreshMenu(ctx, deps, `\u{1F4E5} Imported ${src.label} \u00B7 ${projectName}`);
179
+ await ctx.reply(lines.join("\n"));
180
+ } catch (e) {
181
+ log.error("import failed:", (e as Error).message);
182
+ await ctx.reply(
183
+ `\u274C Import failed: ${(e as Error).message}\nTranscript (if written): \`${pkg.transcriptPath}\``,
184
+ );
185
+ }
186
+ }
187
+
188
+ export function registerImportSession(bot: Bot, deps: BotDeps): void {
189
+ bot.command("import", (ctx) => showImportSources(ctx, deps));
190
+
191
+ bot.callbackQuery("imp:cancel", async (ctx) => {
192
+ await ctx.answerCallbackQuery();
193
+ await ctx.deleteMessage().catch(() => {});
194
+ });
195
+
196
+ bot.callbackQuery("imp:back", async (ctx) => {
197
+ await ctx.answerCallbackQuery();
198
+ await deps.ephemeral.clear(ctx.chat!.id).catch(() => {});
199
+ await showImportSources(ctx, deps);
200
+ });
201
+
202
+ bot.callbackQuery(/^imp:src:(kiro|opencode|claude|codex)$/, async (ctx) => {
203
+ await ctx.answerCallbackQuery();
204
+ await deps.ephemeral.clear(ctx.chat!.id).catch(() => {});
205
+ await showImportRunning(ctx, deps, ctx.match![1] as ImportSourceId);
206
+ });
207
+
208
+ bot.callbackQuery(/^imp:go:(\d+)$/, async (ctx) => {
209
+ await ctx.answerCallbackQuery({ text: "Importing\u2026" });
210
+ await doImportSession(ctx, deps, Number(ctx.match![1]));
211
+ });
212
+ }
213
+
214
+ // ── cards / helpers ──────────────────────────────────────────────────────────
215
+
216
+ function buildImportCard(
217
+ s: ImportableSession,
218
+ index: number,
219
+ now: number,
220
+ ): { text: string; kb: InlineKeyboard } {
221
+ const proj = s.projectName || (s.cwd ? basename(s.cwd) : "(no project)");
222
+ const when = s.updatedAt ? timeAgo(now - Date.parse(s.updatedAt)) : "unknown";
223
+ const title = cleanTitle(s.title);
224
+ const hist =
225
+ s.historyBytes > 0 ? humanSize(s.historyBytes) : s.historyPath ? "0 B" : "no history file";
226
+
227
+ const lines = [
228
+ `\u{1F4E5} ${proj}`,
229
+ title ? `\u{1F4AC} \u201C${trunc(title, 120)}\u201D` : "\u{1F4AC} (no title)",
230
+ s.cwd ? `\u{1F4C1} ${s.cwd}` : "\u{1F4C1} (no cwd recorded)",
231
+ `\u{1F552} ${when} \u00B7 \u{1F4DC} ${hist}`,
232
+ `\u{1F194} ${s.sessionId.length > 16 ? s.sessionId.slice(0, 12) + "\u2026" : s.sessionId}`,
233
+ ];
234
+
235
+ const kb = new InlineKeyboard().text("\u{1F4E5} Import into Grok", `imp:go:${index}`);
236
+ return { text: lines.join("\n"), kb };
237
+ }
238
+
239
+ function resolveImportCwd(session: ImportableSession, deps: BotDeps, chatId: number): string {
240
+ if (session.cwd) return session.cwd;
241
+ // No cwd on the source session — stay on this chat's current project.
242
+ try {
243
+ return deps.registry.get(chatId).cwd || deps.cfg.workspace;
244
+ } catch {
245
+ return deps.cfg.workspace;
246
+ }
247
+ }
248
+
249
+ function labelEmoji(id: ImportSourceId): string {
250
+ switch (id) {
251
+ case "kiro":
252
+ return "\u{1F3AF}";
253
+ case "opencode":
254
+ return "\u26A1";
255
+ case "claude":
256
+ return "\u{1F9E0}";
257
+ case "codex":
258
+ return "\u{1F4D6}";
259
+ }
260
+ }
261
+
262
+ function cleanTitle(raw: string): string {
263
+ let t = (raw || "").trim().replace(/^\([^)]*\)\s*/, "");
264
+ const marker = "User's new message:";
265
+ const i = t.lastIndexOf(marker);
266
+ if (i !== -1) t = t.slice(i + marker.length);
267
+ return t.replace(/\s+/g, " ").trim();
268
+ }
269
+
270
+ function trunc(s: string, n: number): string {
271
+ return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
272
+ }
273
+
274
+ function timeAgo(ms: number): string {
275
+ if (!Number.isFinite(ms) || ms < 0) return "unknown";
276
+ const s = Math.round(ms / 1000);
277
+ if (s < 45) return "just now";
278
+ const m = Math.round(s / 60);
279
+ if (m < 60) return `${m}m ago`;
280
+ const h = Math.round(m / 60);
281
+ if (h < 24) return `${h}h ago`;
282
+ return `${Math.round(h / 24)}d ago`;
283
+ }
284
+
285
+ function humanSize(bytes: number): string {
286
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
287
+ if (bytes < 1024) return `${bytes} B`;
288
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
289
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
290
+ }
@@ -1,16 +1,19 @@
1
1
  /**
2
2
  * Menu handler — maps the persistent reply-keyboard buttons (matched by emoji
3
- * prefix for stateful ones) to actions, and provides inline submenus for Agent
4
- * (real Grok modes), Reasoning, and Model. Changing a value re-renders the
5
- * keyboard so its labels always reflect the current state.
3
+ * prefix for stateful ones) to actions, and provides inline submenus for
4
+ * Reasoning and Model. Changing a value re-renders the keyboard so its labels
5
+ * always reflect the current state.
6
+ *
7
+ * The Agent picker was removed: Grok has no useful headless agent switch, and
8
+ * plan mode is entered automatically when the agent judges a task complex.
6
9
  */
7
10
  import { type Bot, type Context, InlineKeyboard } from "grammy";
8
11
  import { reasoningLabel } from "../../app/reasoning.js";
9
- import { listAgents } from "../../agents/catalog.js";
10
12
  import { REASONING_LEVELS, type ReasoningEffort } from "../../app/types.js";
11
13
  import type { BotDeps } from "../deps.js";
12
14
  import { BAR_LABELS, compactKeyboard, mainMenuInline, MENU_BTN, RUNNING_BTN, STOP_BTN } from "../menu/keyboard.js";
13
15
  import { refreshMenu } from "../menu/refresh.js";
16
+ import { showImportSources } from "./import-session.js";
14
17
  import { showKillConfirm } from "./kill.js";
15
18
  import { showMcp } from "./mcp.js";
16
19
  import { showProjects } from "./projects.js";
@@ -20,13 +23,12 @@ import { showSessions } from "./sessions.js";
20
23
  import { showTasks } from "./tasks.js";
21
24
  import { showUsage } from "./usage.js";
22
25
 
23
- /** Open the full inline menu, showing the current agent/model/reasoning. */
26
+ /** Open the full inline menu, showing the current model/reasoning. */
24
27
  export async function openMainMenu(ctx: Context, deps: BotDeps): Promise<void> {
25
28
  await deps.ephemeral.open(ctx);
26
29
  const rt = deps.registry.get(ctx.chat!.id);
27
30
  await deps.ephemeral.reply(ctx, "\u2699\uFE0F Menu", {
28
31
  reply_markup: mainMenuInline({
29
- agent: rt.agent || "default",
30
32
  model: rt.model || "default",
31
33
  reasoning: reasoningLabel(rt.reasoning),
32
34
  }),
@@ -52,16 +54,6 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
52
54
  // Inline menu actions.
53
55
  bot.callbackQuery(/^m:(\w+)$/, (ctx) => dispatchMenu(ctx, deps, ctx.match![1]!));
54
56
 
55
- // ── Agent (real Grok modes) ─────────────────────────────────────────────
56
- bot.callbackQuery(/^agent:set:(\d+)$/, async (ctx) => {
57
- const mode = deps.acp.availableModes[Number(ctx.match![1])];
58
- if (!mode) return void ctx.answerCallbackQuery({ text: "Expired, tap Agent again." });
59
- // Answer before ACP so a slow setMode never expires the callback query.
60
- await ctx.answerCallbackQuery({ text: `\u{1F916} Agent: ${mode.name}` });
61
- await deps.registry.get(ctx.chat!.id).setAgentPref(mode.id);
62
- await confirmUi(ctx, deps);
63
- });
64
-
65
57
  // ── Reasoning ──────────────────────────────────────────────────────────────
66
58
  bot.callbackQuery(/^reason:(minimal|low|medium|high|max)$/, async (ctx) => {
67
59
  const level = ctx.match![1] as ReasoningEffort;
@@ -113,12 +105,19 @@ async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promis
113
105
  case "sessions":
114
106
  await ctx.answerCallbackQuery();
115
107
  return showSessions(ctx, deps);
108
+ case "import":
109
+ await ctx.answerCallbackQuery();
110
+ return showImportSources(ctx, deps);
116
111
  case "tasks":
117
112
  await ctx.answerCallbackQuery();
118
113
  return showTasks(ctx, deps);
119
114
  case "agent":
120
- await ctx.answerCallbackQuery();
121
- return showAgentMenu(ctx, deps);
115
+ // Legacy callback from old keyboards.
116
+ await ctx.answerCallbackQuery({
117
+ text: "Agent menu removed — Grok picks sub-agents automatically",
118
+ show_alert: true,
119
+ });
120
+ return openMainMenu(ctx, deps);
122
121
  case "model":
123
122
  await ctx.answerCallbackQuery();
124
123
  return showModelMenu(ctx, deps);
@@ -178,26 +177,6 @@ async function confirmUi(ctx: Context, deps: BotDeps): Promise<void> {
178
177
  await openMainMenu(ctx, deps); // reopen so the new value is visible
179
178
  }
180
179
 
181
- async function showAgentMenu(ctx: Context, deps: BotDeps): Promise<void> {
182
- const rt = deps.registry.get(ctx.chat!.id);
183
- await ensureReady(ctx, rt);
184
- await deps.ephemeral.open(ctx);
185
- const modes = deps.acp.availableModes.slice(0, 60);
186
- if (modes.length === 0) {
187
- // Grok has no headless agent switch; surface the sub-agents it can delegate
188
- // to (built-ins + any custom ones in ~/.grok/user-settings.json) as info.
189
- const subs = listAgents(rt.cwd).map((s) => s.name);
190
- const info = subs.length
191
- ? `Grok sub-agents available: ${subs.join(", ")}.\nGrok delegates to these automatically during a turn (via its task/delegate tools) — there's no headless agent switch. Use \u{1F9E9} Model to change the model.`
192
- : "Grok delegates to built-in sub-agents automatically in headless mode; there's no agent to select. Use \u{1F9E9} Model to change the model.";
193
- await deps.ephemeral.reply(ctx, `Current agent: ${rt.agent || "default"}\n${info}`);
194
- return;
195
- }
196
- const kb = new InlineKeyboard();
197
- modes.forEach((m, i) => kb.text(`${m.id === rt.agent ? "\u2713 " : ""}${m.name}`, `agent:set:${i}`).row());
198
- await deps.ephemeral.reply(ctx, `Current agent: ${rt.agent || "default"}\nChoose an agent:`, { reply_markup: kb });
199
- }
200
-
201
180
  async function showReasoningMenu(ctx: Context, deps: BotDeps): Promise<void> {
202
181
  const rt = deps.registry.get(ctx.chat!.id);
203
182
  await deps.ephemeral.open(ctx);
@@ -88,6 +88,7 @@ async function flush(deps: BotDeps, batches: Map<number, TextBatch>, chatId: num
88
88
  `\u{1F4E5} Queued (position ${rt.queueLength})${note} \u2014 I'm still working on the previous task. It'll run next.`,
89
89
  );
90
90
  }
91
+ // "ran": turn started; complexity is steered silently by the agent.
91
92
  } catch (err) {
92
93
  log.warn(`submit failed for chat ${chatId}: ${(err as Error).message}`);
93
94
  await send(deps, chatId, `\u274C Couldn't start your message: ${(err as Error).message}`);