grok-telegram-bot 2.3.1 → 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 (67) hide show
  1. package/.env.example +26 -0
  2. package/CHANGELOG.md +37 -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 +10 -0
  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 +51 -1
  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/session-fork.ts +11 -0
  41. package/src/bot/session-runtime.ts +740 -58
  42. package/src/bot/suggestions.ts +429 -0
  43. package/src/config.ts +41 -0
  44. package/src/grok/client.ts +91 -16
  45. package/src/grok/plan-approval.ts +72 -0
  46. package/src/grok/session-log.ts +16 -0
  47. package/src/grok/types.ts +21 -2
  48. package/src/import/build-import.ts +132 -0
  49. package/src/import/history-readers.ts +681 -0
  50. package/src/import/list-running.ts +100 -0
  51. package/src/import/sources.ts +78 -0
  52. package/src/index.ts +179 -24
  53. package/src/render/diff.ts +11 -2
  54. package/src/render/file-summary.ts +31 -1
  55. package/src/render/markdown.ts +293 -35
  56. package/src/render/plan.ts +127 -0
  57. package/src/render/session-comment.ts +261 -0
  58. package/src/render/tool-call-detail.ts +400 -19
  59. package/src/render/tool-call-merge.ts +115 -0
  60. package/src/render/tool-call.ts +405 -142
  61. package/src/render/truncate.ts +85 -0
  62. package/src/service/windows.ts +14 -2
  63. package/src/sessions/history.ts +57 -0
  64. package/src/sessions/store.ts +3 -0
  65. package/src/sessions/types.ts +5 -0
  66. package/src/stream/streamer.ts +73 -9
  67. package/src/tasks/runner.ts +4 -3
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);
@@ -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}`);
@@ -7,7 +7,7 @@ import { type Bot, type Context, InlineKeyboard } from "grammy";
7
7
  import type { RunningSession, SwitchResult } from "../chat-controller.js";
8
8
  import type { BotDeps } from "../deps.js";
9
9
  import type { HistoryEntry } from "../../sessions/types.js";
10
- import { jsonlMtimeMs, readFirstPrompt } from "../../sessions/history.js";
10
+ import { jsonlMtimeMs, readFirstPrompt, readLastCardSummary } from "../../sessions/history.js";
11
11
  import { progressBar } from "../../render/progress.js";
12
12
  import { refreshMenu } from "../menu/refresh.js";
13
13
  import { sendMarkdownDoc } from "../telegram-io.js";
@@ -49,27 +49,47 @@ function cleanPrompt(raw: string): string {
49
49
  }
50
50
 
51
51
  /** Build a rich card (plain text, no MarkdownV2) + buttons for one controlled
52
- * session: Switch / History / Close. */
52
+ * session: Switch / History / Close.
53
+ *
54
+ * The comment line always answers "what is happening / what was solved":
55
+ * busy → live current step (tool / thinking / working on …)
56
+ * idle → last-turn outcome (assistant result + files), never bare import noise
57
+ */
53
58
  function buildRunningCard(s: RunningSession, deps: BotDeps, now: number): { text: string; kb: InlineKeyboard } {
54
59
  const dot = s.foreground ? "\u25B6\uFE0F" : s.busy ? "\u{1F7E0}" : "\u26AA";
55
60
  const state = s.foreground ? "foreground" : s.busy ? "working" : "idle";
56
61
 
57
62
  let when = "new";
58
- let prompt = "";
63
+ let historySummary = "";
64
+ let firstPrompt = "";
59
65
  if (s.sessionId) {
60
66
  const path = deps.store.jsonlPath(s.sessionId);
61
67
  const mtime = jsonlMtimeMs(path);
62
68
  if (mtime) when = timeAgo(now - mtime);
63
- prompt = cleanPrompt(readFirstPrompt(path));
69
+ // Last assistant outcome beats first-prompt import-confirm noise.
70
+ historySummary = readLastCardSummary(path);
71
+ firstPrompt = cleanPrompt(readFirstPrompt(path));
72
+ if (/session import complete/i.test(firstPrompt)) firstPrompt = "";
64
73
  }
65
74
 
75
+ const diskComment = s.sessionId ? deps.store.get(s.sessionId)?.comment?.trim() : undefined;
76
+ // Order: live runtime comment → persisted last-turn summary → history tail → first prompt.
77
+ const comment =
78
+ (s.comment && s.comment.trim()) ||
79
+ diskComment ||
80
+ historySummary ||
81
+ firstPrompt;
82
+
66
83
  const meta = [when, state];
67
84
  if (s.busy) meta.push("\u23F3");
68
85
  if (s.unread > 0) meta.push(`${s.unread} \u{1F4EC} unread`);
69
86
 
87
+ const commentLabel = s.busy ? "\u23F3" : "\u{1F4AC}";
70
88
  const lines = [
71
89
  `${dot} ${s.projectName}`,
72
- prompt ? `\u{1F4AC} \u201C${trunc(prompt, 120)}\u201D` : "\u{1F4AC} (no messages yet)",
90
+ comment
91
+ ? `${commentLabel} ${trunc(comment, 200)}`
92
+ : "\u{1F4AC} (no messages yet)",
73
93
  `\u{1F552} ${meta.join(" \u00B7 ")}`,
74
94
  ];
75
95
  if (s.progress !== undefined) lines.push(`\u{1F4C8} ${progressBar(s.progress)}`);
@@ -171,6 +191,16 @@ async function deliverSwitch(ctx: Context, deps: BotDeps, res: SwitchResult): Pr
171
191
  if (!res.busy && res.rt.lastTurnSummary) {
172
192
  await ctx.reply(res.rt.lastTurnSummary);
173
193
  }
194
+
195
+ // Re-show post-turn suggestions generated while this session was in the
196
+ // background (or if the user missed the Done notify). Buttons stay wired to
197
+ // the same batch ids so taps still submit the follow-up.
198
+ if (!res.busy) {
199
+ const sug = res.rt.peekPendingSuggestions();
200
+ if (sug) {
201
+ await ctx.reply(sug.text, { reply_markup: sug.markup }).catch(() => {});
202
+ }
203
+ }
174
204
  }
175
205
 
176
206
  function fmtEntry(e: HistoryEntry): string {
@@ -10,6 +10,7 @@ import { InlineKeyboard } from "grammy";
10
10
  import { basename } from "node:path";
11
11
  import { progressBar } from "../../render/progress.js";
12
12
  import type { SessionMeta } from "../../sessions/types.js";
13
+ // Note: callers may pass `comment` from runtime or history (last-turn outcome).
13
14
 
14
15
  export interface SessionCardExtras {
15
16
  /** Context-usage %, when the session is loaded in the current ACP process. */
@@ -22,6 +23,11 @@ export interface SessionCardExtras {
22
23
  selfPid?: number;
23
24
  /** Latest task-completion % (0–100) for this session, if this chat runs it. */
24
25
  progress?: number;
26
+ /**
27
+ * Live step (while working) or chat summary (when idle). Overrides
28
+ * `m.comment` when provided by the controlling chat runtime.
29
+ */
30
+ comment?: string;
25
31
  }
26
32
 
27
33
  export interface SessionCard {
@@ -34,9 +40,15 @@ export function buildSessionCard(m: SessionMeta, extra: SessionCardExtras = {}):
34
40
  const dot = m.active ? "\u{1F7E2}" : "\u26AA";
35
41
  const state = m.active ? `running${m.lockPid ? ` \u00B7 pid ${m.lockPid}` : ""}` : "idle";
36
42
  const proj = m.cwd ? basename(m.cwd) : "(no project)";
43
+ const comment = (extra.comment || m.comment || "").trim();
37
44
 
38
45
  const lines = [`${dot} ${m.title}`, `\u{1F4C1} ${proj}`];
39
46
  if (m.cwd) lines.push(` ${m.cwd}`);
47
+ // Always surface "what is happening / what was done" when we have it.
48
+ if (comment) {
49
+ const icon = m.active || typeof extra.progress === "number" ? "\u23F3" : "\u{1F4AC}";
50
+ lines.push(`${icon} ${comment.length > 160 ? comment.slice(0, 159) + "\u2026" : comment}`);
51
+ }
40
52
  lines.push(`\u{1F552} updated ${relTime(m.updatedAt)} \u00B7 created ${relTime(m.createdAt)}`);
41
53
  const ctx = typeof extra.contextPct === "number" ? ` \u00B7 \u{1F9E0} ctx ${Math.round(extra.contextPct)}%` : "";
42
54
  lines.push(`\u{1F4CA} ${state} \u00B7 \u{1F4DC} history ${humanSize(m.historyBytes)}${ctx}`);
@@ -10,7 +10,7 @@
10
10
  import { type Bot, type Context, InlineKeyboard } from "grammy";
11
11
  import { basename } from "node:path";
12
12
  import type { BotDeps } from "../deps.js";
13
- import { readHistory } from "../../sessions/history.js";
13
+ import { readHistory, readLastCardSummary } from "../../sessions/history.js";
14
14
  import type { SessionMeta } from "../../sessions/types.js";
15
15
  import { refreshMenu } from "../menu/refresh.js";
16
16
  import { showHistory } from "./history.js";
@@ -52,8 +52,19 @@ async function renderSessionPage(ctx: Context, deps: BotDeps, page: number): Pro
52
52
 
53
53
  for (const m of slice) {
54
54
  const contextPct = deps.acp.metadataFor(m.sessionId)?.contextUsagePercentage;
55
- const progress = deps.registry.controller(ctx.chat!.id).progressFor(m.sessionId);
56
- const { text, keyboard } = buildSessionCard(m, { contextPct, selfPid: deps.acp.pid, progress });
55
+ const ctrl = deps.registry.controller(ctx.chat!.id);
56
+ const progress = ctrl.progressFor(m.sessionId);
57
+ // Live runtime → persisted comment → last assistant outcome from history.
58
+ const comment =
59
+ ctrl.commentFor(m.sessionId) ||
60
+ m.comment ||
61
+ readLastCardSummary(deps.store.jsonlPath(m.sessionId));
62
+ const { text, keyboard } = buildSessionCard(m, {
63
+ contextPct,
64
+ selfPid: deps.acp.pid,
65
+ progress,
66
+ comment,
67
+ });
57
68
  await deps.ephemeral.reply(ctx, text, { reply_markup: keyboard });
58
69
  }
59
70