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,429 @@
1
+ /**
2
+ * Post-turn follow-up suggestions + gated self-recheck helpers.
3
+ *
4
+ * Self-recheck (once per real user turn, when enabled):
5
+ * 1. Hard-skip if no files were modified.
6
+ * 2. Quiet meta ask: AI refuses (simple / pure build / nothing to re-verify)
7
+ * or writes a focused recheck prompt.
8
+ * 3. That prompt is queued as the one-shot SELF-RECHECK turn.
9
+ * 4. Then Done + suggestions.
10
+ *
11
+ * After Done, the bot quietly asks for 1–3 short next steps as JSON with a
12
+ * "need" score (0–100). Suggestions appear as inline buttons; those at/above
13
+ * SUGGESTIONS_AUTO_APPROVE_PCT are merged into **one** auto-queued prompt
14
+ * (`1) …\n2) …`) so they run as a single turn.
15
+ */
16
+ import { InlineKeyboard } from "grammy";
17
+
18
+ /** Max suggestions shown / accepted. */
19
+ export const SUGGESTION_MAX = 3;
20
+ /** Button label budget (Telegram ~64 chars). */
21
+ const BTN_MAX = 56;
22
+
23
+ /** Marker for the automatic post-turn self-recheck prompt (once per user turn). */
24
+ export const SELF_RECHECK_MARKER = "SELF-RECHECK (automatic quality pass";
25
+
26
+ /**
27
+ * Default self-recheck body (user can override via SELF_RECHECK_PROMPT).
28
+ * Placeholders: {{USER}} = user's request, {{DONE}} = first-turn summary.
29
+ */
30
+ export const DEFAULT_SELF_RECHECK_PROMPT = [
31
+ `${SELF_RECHECK_MARKER} — once only).`,
32
+ "Do a rigorous self-review of the work just completed for the user request below.",
33
+ "",
34
+ "You are both a bugs/logic finder AND a completeness checker for related follow-ups.",
35
+ "Look for:",
36
+ "1) Bugs, regressions, incomplete steps, wrong assumptions, missing verification, edge cases.",
37
+ "2) Logical gaps the user would still need — e.g. if they asked for password reset and you",
38
+ " shipped the happy path only, also cover brute-force protection, rate limits, token expiry,",
39
+ " email enumeration, CSRF, audit logging, and similar security/ops follow-through that a",
40
+ " solid implementation of THAT feature should include (not a random new product idea).",
41
+ "",
42
+ "Rules:",
43
+ "- Prefer fixing real problems with tools when needed; do not invent unrelated features.",
44
+ "- If something is unfinished relative to the user request (or a tightly related follow-up",
45
+ " that leaves the feature incomplete/insecure), finish or fix it now.",
46
+ "- If everything checks out, briefly confirm what you verified (no long essay).",
47
+ "- Do NOT ask the user questions. Do NOT call enter_plan_mode unless truly necessary.",
48
+ "- End with an honest {progress: N%} marker for this recheck pass.",
49
+ "",
50
+ "USER'S REQUEST:",
51
+ "{{USER}}",
52
+ "",
53
+ "WHAT WAS JUST DONE (summary):",
54
+ "{{DONE}}",
55
+ ].join("\n");
56
+
57
+ export interface Suggestion {
58
+ /** Plain follow-up the user would type / the bot will submit. */
59
+ text: string;
60
+ /** How needed/critical relative to the last user prompt (0–100). */
61
+ need: number;
62
+ }
63
+
64
+ /**
65
+ * Quiet meta-prompt after a successful turn. Must produce JSON only.
66
+ * Percentage rules enforce honesty: unrelated ideas cannot score above 60.
67
+ */
68
+ export function buildSuggestionsPrompt(userText: string, assistantSnippet: string): string {
69
+ const user = clamp(userText.replace(/\s+/g, " ").trim(), 500) || "(empty)";
70
+ const did = clamp(assistantSnippet.replace(/\s+/g, " ").trim(), 600) || "(no assistant text)";
71
+ return [
72
+ "FOLLOW-UP SUGGESTIONS (meta only). Do NOT use tools. Do NOT write code. Do NOT continue the task.",
73
+ "Based ONLY on the user's last request and what was just done, propose 1 to 3 short next steps.",
74
+ "",
75
+ "USER'S LAST PROMPT:",
76
+ user,
77
+ "",
78
+ "WHAT WAS JUST DONE (summary):",
79
+ did,
80
+ "",
81
+ "For each suggestion set need = integer 0–100 = how needed/critical it is for completing or properly finishing THAT user prompt:",
82
+ "- High need (70–100): tightly related, critical next step for the same request (fix a gap, verify, finish unfinished part).",
83
+ "- Medium (40–69): related polish or natural continuation of the same task.",
84
+ "- Low (1–39): optional or weakly related.",
85
+ "- HARD RULE: if a suggestion is NOT clearly related to the user's last prompt, need MUST be ≤ 60 (never higher).",
86
+ "- Do not invent unrelated new features just to fill slots; prefer fewer high-quality items.",
87
+ "- Prefer the highest need for the single most critical related follow-up.",
88
+ "",
89
+ "Reply with ONLY a JSON array (no markdown fences, no keys other than text/need, no commentary):",
90
+ '[{"text":"short imperative follow-up the user would send","need":85}]',
91
+ "Constraints: 1–3 items; text ≤ 120 chars; plain language; no quotes wrapping the whole array.",
92
+ ].join("\n");
93
+ }
94
+
95
+ /** Parse model output into 0–3 validated suggestions. */
96
+ export function parseSuggestions(raw: string): Suggestion[] {
97
+ if (!raw?.trim()) return [];
98
+ let t = raw.trim();
99
+ // Strip accidental code fences / progress markers.
100
+ t = t.replace(/\{[\s]*progress[\s]*:[\s]*\d{1,3}\s*%?[\s]*\}/gi, "").trim();
101
+ const fence = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
102
+ if (fence) t = fence[1]!.trim();
103
+ // Extract first JSON array if prose sneaks in.
104
+ const arrMatch = /\[[\s\S]*\]/.exec(t);
105
+ if (arrMatch) t = arrMatch[0]!;
106
+
107
+ let parsed: unknown;
108
+ try {
109
+ parsed = JSON.parse(t);
110
+ } catch {
111
+ // Try line-wise objects.
112
+ const objs = [...t.matchAll(/\{[^{}]*"text"[^{}]*\}/g)].map((m) => {
113
+ try {
114
+ return JSON.parse(m[0]!) as unknown;
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ });
119
+ parsed = objs.filter(Boolean);
120
+ }
121
+
122
+ const list = Array.isArray(parsed) ? parsed : [];
123
+ const out: Suggestion[] = [];
124
+ for (const item of list) {
125
+ if (!item || typeof item !== "object") continue;
126
+ const rec = item as Record<string, unknown>;
127
+ const text = String(rec.text ?? rec.suggestion ?? rec.prompt ?? "").replace(/\s+/g, " ").trim();
128
+ if (!text || text.length < 2) continue;
129
+ let need = Number(rec.need ?? rec.pct ?? rec.percent ?? rec.score ?? 0);
130
+ if (!Number.isFinite(need)) need = 0;
131
+ need = Math.max(0, Math.min(100, Math.round(need)));
132
+ out.push({ text: text.slice(0, 120), need });
133
+ if (out.length >= SUGGESTION_MAX) break;
134
+ }
135
+ // Highest need first for auto-approve + button order.
136
+ out.sort((a, b) => b.need - a.need);
137
+ return out;
138
+ }
139
+
140
+ /** Inline keyboard for Done: one row per suggestion + optional extra rows. */
141
+ export function suggestionsKeyboard(
142
+ batchId: number,
143
+ suggestions: Suggestion[],
144
+ extra?: InlineKeyboard,
145
+ ): InlineKeyboard {
146
+ const kb = new InlineKeyboard();
147
+ suggestions.forEach((s, i) => {
148
+ const label = clamp(`${s.need}% · ${s.text}`, BTN_MAX);
149
+ kb.text(label, `sug:${batchId}:${i}`).row();
150
+ });
151
+ if (extra) {
152
+ // Append extra keyboard rows (e.g. Switch session).
153
+ const rows = (extra as unknown as { inline_keyboard?: Array<Array<{ text: string; callback_data: string }>> })
154
+ .inline_keyboard;
155
+ if (rows) {
156
+ for (const row of rows) {
157
+ for (let i = 0; i < row.length; i++) {
158
+ const b = row[i]!;
159
+ if (i === row.length - 1) kb.text(b.text, b.callback_data).row();
160
+ else kb.text(b.text, b.callback_data);
161
+ }
162
+ }
163
+ }
164
+ }
165
+ return kb;
166
+ }
167
+
168
+ /** Suggestions at/above the auto-approve threshold (need >= pct). */
169
+ export function autoApproveSuggestions(suggestions: Suggestion[], thresholdPct: number): Suggestion[] {
170
+ if (thresholdPct <= 0) return [];
171
+ const thr = Math.max(0, Math.min(100, Math.round(thresholdPct)));
172
+ return suggestions.filter((s) => s.need >= thr);
173
+ }
174
+
175
+ /**
176
+ * Merge auto-approved suggestions into a single multi-step prompt so they run
177
+ * as one agent turn: `1) …\n2) …\n3) …` (already sorted highest need first).
178
+ */
179
+ export function formatBatchedSuggestionsPrompt(suggestions: Suggestion[]): string {
180
+ if (suggestions.length === 0) return "";
181
+ if (suggestions.length === 1) return suggestions[0]!.text;
182
+ return suggestions.map((s, i) => `${i + 1}) ${s.text}`).join("\n");
183
+ }
184
+
185
+ /** Detect the quiet suggestions meta-prompt (history strip / title guard). */
186
+ export function isSuggestionsMetaPrompt(text: string): boolean {
187
+ return /^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(text.trim());
188
+ }
189
+
190
+ /** Marker for the quiet "should we recheck?" meta-prompt. */
191
+ export const SELF_RECHECK_DECISION_MARKER = "SELF-RECHECK DECISION (meta only)";
192
+
193
+ /** Detect the quiet self-recheck decision meta-prompt. */
194
+ export function isSelfRecheckDecisionPrompt(text: string): boolean {
195
+ return text.trim().startsWith(SELF_RECHECK_DECISION_MARKER);
196
+ }
197
+
198
+ /** Detect the automatic self-recheck pass (not a normal user message). */
199
+ export function isSelfRecheckPrompt(text: string): boolean {
200
+ return text.trim().startsWith(SELF_RECHECK_MARKER);
201
+ }
202
+
203
+ /** Outcome of the quiet recheck-decision turn. */
204
+ export type SelfRecheckDecision =
205
+ | { needed: false; reason?: string }
206
+ | { needed: true; prompt: string };
207
+
208
+ /**
209
+ * Quiet meta-prompt: AI either refuses recheck (simple / no value) or writes
210
+ * the focused recheck instructions that will be submitted as the next turn.
211
+ */
212
+ export function buildSelfRecheckDecisionPrompt(
213
+ userText: string,
214
+ assistantSnippet: string,
215
+ filesSummary: string,
216
+ ): string {
217
+ const user = clamp(userText.replace(/\s+/g, " ").trim(), 700) || "(empty)";
218
+ const did = clamp(assistantSnippet.replace(/\s+/g, " ").trim(), 900) || "(no assistant text)";
219
+ const files = clamp(filesSummary.replace(/\s+/g, " ").trim(), 400) || "(none)";
220
+ return [
221
+ `${SELF_RECHECK_DECISION_MARKER}. Do NOT use tools. Do NOT write code. Do NOT continue the task.`,
222
+ "Decide whether a second automatic quality pass is worth running for the work just completed.",
223
+ "",
224
+ "Set needed=false (skip recheck) when ANY of these apply:",
225
+ "- Simple task: Q&A, explanation, status, one-liner, or trivial change.",
226
+ "- Pure build / install / run / package with no non-trivial logic to re-audit.",
227
+ "- Work is clearly complete and low-risk; a re-verify would add little value.",
228
+ "- No plausible bugs, incomplete steps, or tightly related security/ops gaps.",
229
+ "",
230
+ "Set needed=true when:",
231
+ "- Non-trivial code/config changed and edge cases, regressions, or incomplete",
232
+ " follow-through are plausible (auth, multi-file logic, concurrency, data paths).",
233
+ "- A focused second pass with tools could catch real bugs or finish related gaps.",
234
+ "",
235
+ "If needed=true, write a focused recheck prompt: imperative instructions for a one-shot",
236
+ "second pass that may use tools to fix REAL issues. Do not invent unrelated features.",
237
+ "If needed=false, give a short reason.",
238
+ "",
239
+ "USER'S REQUEST:",
240
+ user,
241
+ "",
242
+ "WHAT WAS JUST DONE (summary):",
243
+ did,
244
+ "",
245
+ "FILES MODIFIED THIS TURN:",
246
+ files,
247
+ "",
248
+ "Reply with ONLY one JSON object (no markdown fences, no commentary):",
249
+ '{"needed":false,"reason":"short reason"}',
250
+ "or",
251
+ '{"needed":true,"prompt":"focused recheck / fix instructions for the agent"}',
252
+ ].join("\n");
253
+ }
254
+
255
+ /**
256
+ * Parse quiet recheck-decision JSON. Defaults to skip on empty/invalid output
257
+ * so a bad meta reply never blocks Done — except when the model clearly wrote a
258
+ * recheck body without wrapping JSON (treated as needed=true).
259
+ */
260
+ export function parseSelfRecheckDecision(raw: string): SelfRecheckDecision {
261
+ if (!raw?.trim()) return { needed: false, reason: "empty decision" };
262
+ let t = raw.trim();
263
+ // Strip trailing progress markers only (avoid eating prompt text mid-JSON).
264
+ t = t.replace(/\n?\s*\{[\s]*progress[\s]*:[\s]*\d{1,3}\s*%?[\s]*\}\s*$/gi, "").trim();
265
+ t = t.replace(/\{[\s]*progress[\s]*:[\s]*\d{1,3}\s*%?[\s]*\}/gi, "").trim();
266
+ const fence = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
267
+ if (fence) t = fence[1]!.trim();
268
+
269
+ // Soft refuse phrases (JSON or prose).
270
+ if (/\b(not needed|no recheck|skip recheck|unnecessary|not necessary|no need to recheck)\b/i.test(t)
271
+ && !/"needed"\s*:\s*true/i.test(t)) {
272
+ // Only force-skip when JSON does not explicitly set needed:true.
273
+ if (!/"needed"\s*:\s*true/i.test(raw) && !/"recheck"\s*:\s*true/i.test(raw)) {
274
+ const asJson = tryParseDecisionObject(t);
275
+ if (!asJson || asJson.needed !== true) {
276
+ return { needed: false, reason: "refused in prose" };
277
+ }
278
+ }
279
+ }
280
+
281
+ const asJson = tryParseDecisionObject(t);
282
+ if (asJson) return asJson;
283
+
284
+ // Plain imperative body (model forgot JSON) → treat as recheck prompt.
285
+ const prose = t.replace(/\s+/g, " ").trim();
286
+ if (prose.length >= 24 && !/^(ok|done|none|n\/a|skip)\b/i.test(prose)) {
287
+ return { needed: true, prompt: prose.slice(0, 4000) };
288
+ }
289
+ return { needed: false, reason: "unparseable decision" };
290
+ }
291
+
292
+ /** Best-effort extract/parse of a decision object from model text. */
293
+ function tryParseDecisionObject(t: string): SelfRecheckDecision | undefined {
294
+ // Prefer balanced-ish first object; fall back to greedy match.
295
+ let candidate = t;
296
+ const objMatch = /\{[\s\S]*\}/.exec(t);
297
+ if (objMatch) candidate = objMatch[0]!;
298
+
299
+ let parsed: unknown;
300
+ try {
301
+ parsed = JSON.parse(candidate);
302
+ } catch {
303
+ // Try smaller object if trailing junk broke parse.
304
+ const m = /\{[^{}]*"needed"[^{}]*\}/i.exec(t)
305
+ || /\{[^{}]*"recheck"[^{}]*\}/i.exec(t)
306
+ || /\{[^{}]*"prompt"[^{}]*\}/i.exec(t);
307
+ if (!m) return undefined;
308
+ try {
309
+ parsed = JSON.parse(m[0]!);
310
+ } catch {
311
+ return undefined;
312
+ }
313
+ }
314
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
315
+ const rec = parsed as Record<string, unknown>;
316
+
317
+ // Do not treat suggestion-style "need" (0–100 score) as the needed flag.
318
+ // Only needed / recheck / required (boolean-ish) control the gate.
319
+ const neededRaw = rec.needed ?? rec.recheck ?? rec.required;
320
+ const prompt = String(rec.prompt ?? rec.text ?? rec.instructions ?? rec.recheck_prompt ?? "")
321
+ .replace(/\r\n/g, "\n")
322
+ .trim();
323
+
324
+ let needed: boolean | undefined;
325
+ if (typeof neededRaw === "boolean") needed = neededRaw;
326
+ else if (typeof neededRaw === "string") {
327
+ const s = neededRaw.trim().toLowerCase();
328
+ if (/^(1|true|yes|y|needed|recheck)$/i.test(s)) needed = true;
329
+ else if (/^(0|false|no|n|skip|none)$/i.test(s)) needed = false;
330
+ } else if (typeof neededRaw === "number") {
331
+ needed = neededRaw > 0;
332
+ }
333
+
334
+ // Explicit skip / refuse keys always win.
335
+ if (rec.skip === true || rec.refuse === true) needed = false;
336
+
337
+ // Prompt-only object: model wrote instructions without a needed flag → run.
338
+ if (needed === undefined && prompt.length >= 3) needed = true;
339
+ if (needed === undefined) needed = false;
340
+
341
+ if (!needed) {
342
+ const reason = String(rec.reason ?? rec.why ?? rec.message ?? "").replace(/\s+/g, " ").trim();
343
+ return { needed: false, reason: reason || undefined };
344
+ }
345
+
346
+ // needed=true but no usable prompt → empty body; caller fills default template.
347
+ if (!prompt || prompt.length < 3) {
348
+ return { needed: true, prompt: "" };
349
+ }
350
+ return { needed: true, prompt: prompt.slice(0, 4000) };
351
+ }
352
+
353
+ /**
354
+ * Build the one-shot self-recheck turn text from an optional env template.
355
+ * Placeholders: {{USER}}, {{DONE}} (also {USER}/{DONE} for convenience).
356
+ */
357
+ export function buildSelfRecheckPrompt(
358
+ userText: string,
359
+ assistantSnippet: string,
360
+ template?: string,
361
+ ): string {
362
+ const user = clamp(userText.replace(/\s+/g, " ").trim(), 700) || "(empty)";
363
+ const did = clamp(assistantSnippet.replace(/\s+/g, " ").trim(), 900) || "(no assistant text)";
364
+ const tpl = (template?.trim() || DEFAULT_SELF_RECHECK_PROMPT).trim();
365
+ let out = tpl
366
+ .replace(/\{\{\s*USER\s*\}\}/gi, user)
367
+ .replace(/\{\{\s*DONE\s*\}\}/gi, did)
368
+ .replace(/\{USER\}/gi, user)
369
+ .replace(/\{DONE\}/gi, did);
370
+ // Ensure the marker is present so isSelfRecheckPrompt / one-shot guard work
371
+ // even if the user customized SELF_RECHECK_PROMPT and dropped it.
372
+ return ensureSelfRecheckMarker(out);
373
+ }
374
+
375
+ /**
376
+ * Turn an AI-written recheck body into a full one-shot turn (marker + context).
377
+ */
378
+ export function composeSelfRecheckTurn(
379
+ agentPrompt: string,
380
+ userText: string,
381
+ assistantSnippet: string,
382
+ ): string {
383
+ const user = clamp(userText.replace(/\s+/g, " ").trim(), 700) || "(empty)";
384
+ const did = clamp(assistantSnippet.replace(/\s+/g, " ").trim(), 900) || "(no assistant text)";
385
+ const body =
386
+ agentPrompt.trim() ||
387
+ DEFAULT_SELF_RECHECK_PROMPT
388
+ .replace(/\{\{\s*USER\s*\}\}/gi, user)
389
+ .replace(/\{\{\s*DONE\s*\}\}/gi, did)
390
+ .replace(/\{USER\}/gi, user)
391
+ .replace(/\{DONE\}/gi, did);
392
+ // Full template already has context — don't double-append USER/DONE sections.
393
+ // Only skip wrapping when the body looks like a complete recheck brief (marker
394
+ // or both a request header and a done header), not a casual mention of the words.
395
+ const looksComplete =
396
+ isSelfRecheckPrompt(body) ||
397
+ (/USER'S REQUEST:/i.test(body) && /WHAT WAS JUST DONE/i.test(body));
398
+ if (looksComplete) {
399
+ return ensureSelfRecheckMarker(body);
400
+ }
401
+ return ensureSelfRecheckMarker(
402
+ [
403
+ body,
404
+ "",
405
+ "Rules:",
406
+ "- Prefer fixing real problems with tools when needed; do not invent unrelated features.",
407
+ "- Do NOT ask the user questions. Do NOT call enter_plan_mode unless truly necessary.",
408
+ "- End with an honest {progress: N%} marker for this recheck pass.",
409
+ "",
410
+ "USER'S REQUEST:",
411
+ user,
412
+ "",
413
+ "WHAT WAS JUST DONE (summary):",
414
+ did,
415
+ ].join("\n"),
416
+ );
417
+ }
418
+
419
+ /** Prepend the self-recheck marker when missing. */
420
+ export function ensureSelfRecheckMarker(text: string): string {
421
+ const t = text.trim();
422
+ if (t.startsWith(SELF_RECHECK_MARKER)) return t;
423
+ return `${SELF_RECHECK_MARKER} — once only).\n\n${t}`;
424
+ }
425
+
426
+ function clamp(s: string, max: number): string {
427
+ if (s.length <= max) return s;
428
+ return s.slice(0, max - 1) + "\u2026";
429
+ }
package/src/config.ts CHANGED
@@ -144,6 +144,29 @@ export interface AppConfig {
144
144
  autoUpdate: boolean;
145
145
  updateCheckMs: number;
146
146
  singleInstance: boolean;
147
+ /**
148
+ * After a successful Done, ask Grok for 1–3 follow-up suggestions (JSON) and
149
+ * attach them as inline buttons on the Done message. Default true.
150
+ */
151
+ suggestionsEnabled: boolean;
152
+ /**
153
+ * Auto-queue any suggestion whose need score is ≥ this percent (0–100).
154
+ * 0 disables auto-approve (buttons only). Default 95.
155
+ * Multiple hits are merged into one numbered multi-step prompt.
156
+ */
157
+ suggestionsAutoApprovePct: number;
158
+ /**
159
+ * After a successful user turn (queue empty), optionally run one self-recheck
160
+ * pass before Done + suggestions. Skipped when no files changed or when a
161
+ * quiet AI decision refuses. Default true. Typo alias: SLEF_RECHECK.
162
+ */
163
+ selfRecheckEnabled: boolean;
164
+ /**
165
+ * Optional override for the recheck turn body when the AI decides recheck is
166
+ * needed (SELF_RECHECK_PROMPT). Supports {{USER}} and {{DONE}}. Empty → use
167
+ * the AI-written recheck prompt (or built-in default if the AI left it blank).
168
+ */
169
+ selfRecheckPrompt: string;
147
170
  }
148
171
 
149
172
  export function loadConfig(): AppConfig {
@@ -231,11 +254,29 @@ export function loadConfig(): AppConfig {
231
254
  autoUpdate: bool(process.env.AUTO_UPDATE, true),
232
255
  updateCheckMs: num(process.env.UPDATE_CHECK_MS, 3_600_000),
233
256
  singleInstance: bool(process.env.GROK_TG_SINGLE_INSTANCE, true),
257
+ // Post-turn follow-ups: default on; auto-run suggestions scoring ≥ 95%.
258
+ suggestionsEnabled: bool(process.env.SUGGESTIONS_ENABLED, true),
259
+ suggestionsAutoApprovePct: clampPct(process.env.SUGGESTIONS_AUTO_APPROVE_PCT, 95),
260
+ // One-shot post-turn self-recheck before Done/suggestions (default on).
261
+ // Accept typo SLEF_RECHECK as alias.
262
+ selfRecheckEnabled: bool(
263
+ process.env.SELF_RECHECK ?? process.env.SLEF_RECHECK,
264
+ true,
265
+ ),
266
+ selfRecheckPrompt: (process.env.SELF_RECHECK_PROMPT ?? "").trim(),
234
267
  };
235
268
 
236
269
  return cfg;
237
270
  }
238
271
 
272
+ /** Parse 0–100 percentage; blank → default. */
273
+ function clampPct(v: string | undefined, def: number): number {
274
+ if (v === undefined || v === "") return def;
275
+ const n = Number(v);
276
+ if (!Number.isFinite(n)) return def;
277
+ return Math.max(0, Math.min(100, Math.round(n)));
278
+ }
279
+
239
280
  /** Resolve the `grok` binary path. The official installer puts it in
240
281
  * ~/.grok/bin; also try common PATH locations before a bare `grok`. */
241
282
  function resolveGrokPath(explicit?: string): string {
@@ -19,19 +19,26 @@ import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
19
19
  import { PROGRESS_DIRECTIVE } from "../render/progress.js";
20
20
  import { SessionLog } from "./session-log.js";
21
21
  import { JsonRpcTransport } from "./transport.js";
22
- import type {
23
- ContentBlock,
24
- InitializeResult,
25
- JsonRpcMessage,
26
- PendingStage,
27
- PermissionOutcome,
28
- PromptResult,
29
- RequestPermissionParams,
30
- SessionNotificationParams,
31
- SessionUpdate,
32
- SubagentInfo,
33
- SubagentListUpdate,
22
+ import {
23
+ contentText,
24
+ type ContentBlock,
25
+ type InitializeResult,
26
+ type JsonRpcMessage,
27
+ type PendingStage,
28
+ type PermissionOutcome,
29
+ type PromptResult,
30
+ type RequestPermissionParams,
31
+ type SessionNotificationParams,
32
+ type SessionUpdate,
33
+ type SubagentInfo,
34
+ type SubagentListUpdate,
34
35
  } from "./types.js";
36
+ import {
37
+ autoApproveExitPlanMode,
38
+ autoSkipAskUserQuestion,
39
+ isAskUserQuestionMethod,
40
+ isPlanExitMethod,
41
+ } from "./plan-approval.js";
35
42
 
36
43
  const log = createLogger("grok:client");
37
44
 
@@ -59,6 +66,11 @@ const ACCOUNT_EXHAUSTED_RE =
59
66
  * saved login may be permitted, while same-account retries cannot help. */
60
67
  const ACCOUNT_ACCESS_DENIED_RE =
61
68
  /\b403\b|forbidden|access denied/i;
69
+ /** Process/session lifecycle failures are not evidence that the active login
70
+ * is bad. They require a session re-bind on the current process generation,
71
+ * never account rotation. */
72
+ const SESSION_LIFECYCLE_RE =
73
+ /unknown session id|grok agent is restarting|grok agent stdio exited|grok agent (?:is )?not running|agent connection (?:is )?closed|authentication required.{0,80}no auth method id provided/i;
62
74
 
63
75
  export class GrokError extends Error {
64
76
  constructor(
@@ -123,7 +135,14 @@ export function isAccountRotationError(err: Error): boolean {
123
135
  return false;
124
136
  }
125
137
 
138
+ export function isSessionLifecycleError(err: Error): boolean {
139
+ return SESSION_LIFECYCLE_RE.test(err.message);
140
+ }
141
+
126
142
  export function isTransientError(err: Error): boolean {
143
+ // Retrying the same stale session cannot recover a process-generation
144
+ // mismatch. SessionRuntime owns the immediate re-bind + one safe retry.
145
+ if (isSessionLifecycleError(err)) return false;
127
146
  // Quota exhaustion and access denial are permanent for this login — rotate,
128
147
  // never back off and retry the same credentials.
129
148
  if (isAccountRotationError(err)) return false;
@@ -265,9 +284,10 @@ export class GrokClient extends EventEmitter {
265
284
  this.availableModels = KNOWN_MODELS.map((m) => ({ modelId: m.modelId, name: m.name, description: m.description }));
266
285
  }
267
286
 
268
- async start(): Promise<void> {
287
+ async start(notifyRestarted = false): Promise<void> {
269
288
  this.stopped = false;
270
289
  await this.connect();
290
+ if (notifyRestarted) this.emit("restarted");
271
291
  }
272
292
 
273
293
  private async connect(): Promise<void> {
@@ -499,6 +519,16 @@ export class GrokClient extends EventEmitter {
499
519
  this.currentModeId = modeId;
500
520
  }
501
521
 
522
+ /** Persisted Running/Sessions card comment (current step or chat summary). */
523
+ sessionComment(sessionId: string | undefined): string | undefined {
524
+ if (!sessionId) return undefined;
525
+ return this.slog.commentFor(sessionId);
526
+ }
527
+
528
+ setSessionComment(sessionId: string, comment: string): void {
529
+ this.slog.setComment(sessionId, comment);
530
+ }
531
+
502
532
  async executeCommand(sessionId: string, command: string): Promise<unknown> {
503
533
  return this.request("_grok.dev/commands/execute", { sessionId, command });
504
534
  }
@@ -529,9 +559,7 @@ export class GrokClient extends EventEmitter {
529
559
  this.stopped = true;
530
560
  this.restartAttempts = 0;
531
561
  await this.killCurrent();
532
- this.stopped = false;
533
- await this.connect();
534
- this.emit("restarted");
562
+ await this.start(true);
535
563
  }
536
564
 
537
565
  private killCurrent(): Promise<void> {
@@ -643,8 +671,34 @@ export class GrokClient extends EventEmitter {
643
671
  // No handler: auto-approve, preferring session-scope / always options.
644
672
  const opts = (params.options as Array<{ optionId: string; name?: string; kind?: string }>) ?? [];
645
673
  result = pickAllowOption(opts);
674
+ } else if (isPlanExitMethod(method)) {
675
+ // Live method name is `_x.ai/exit_plan_mode` (leading underscore).
676
+ // Grok intercepts exit_plan_mode and reverse-requests the client to
677
+ // show a plan-approval UI. Method-not-found is reported as
678
+ // "client disconnected" and plan mode stays Active forever.
679
+ const planSnippet =
680
+ (typeof params.planContent === "string" && params.planContent) ||
681
+ (typeof params.plan_content === "string" && params.plan_content) ||
682
+ (typeof params.plan_file_path === "string" && params.plan_file_path) ||
683
+ "";
684
+ const keys = Object.keys(params || {}).slice(0, 20).join(",");
685
+ log.info(
686
+ `auto-approving plan exit via ${method}` +
687
+ (params.sessionId ? ` session=${String(params.sessionId).slice(0, 8)}` : "") +
688
+ (params.toolCallId ? ` tool=${String(params.toolCallId).slice(0, 24)}` : "") +
689
+ (planSnippet ? ` plan=${planSnippet.replace(/\s+/g, " ").slice(0, 80)}` : "") +
690
+ (keys ? ` keys=[${keys}]` : ""),
691
+ );
692
+ result = autoApproveExitPlanMode(params);
693
+ } else if (isAskUserQuestionMethod(method)) {
694
+ // No TUI question form: skip so the agent continues (prefer later Telegram UI).
695
+ log.info(`auto-skipping ${method} (no interactive question UI in Telegram bridge)`);
696
+ result = autoSkipAskUserQuestion(params);
646
697
  } else {
647
698
  // We advertise no fs/terminal capabilities, so the agent shouldn't ask.
699
+ // Log at warn — unknown reverse methods used to silently break plan exit
700
+ // when we only matched `x.ai/…` and Grok sent `_x.ai/…`.
701
+ log.warn(`unsupported client reverse-request: ${method} keys=[${Object.keys(params || {}).join(",")}]`);
648
702
  throw new GrokError(`unsupported client method: ${method}`, -32601);
649
703
  }
650
704
  this.transport?.send({ jsonrpc: "2.0", id, result });
@@ -690,10 +744,28 @@ export class GrokClient extends EventEmitter {
690
744
 
691
745
  /** Accumulate assistant text and log tool calls to the session's jsonl. */
692
746
  private recordUpdate(sessionId: string, u: SessionUpdate): void {
693
- if (u.sessionUpdate === "agent_message_chunk" && typeof u.content?.text === "string") {
694
- this.assistantBuf.set(sessionId, (this.assistantBuf.get(sessionId) ?? "") + u.content.text);
695
- } else if (u.sessionUpdate === "tool_call" && (u.title || u.kind)) {
696
- this.slog.logTool(sessionId, u.title || u.kind || "tool", "");
747
+ if (u.sessionUpdate === "agent_message_chunk") {
748
+ const t = contentText(u.content);
749
+ if (t) this.assistantBuf.set(sessionId, (this.assistantBuf.get(sessionId) ?? "") + t);
750
+ } else if (u.sessionUpdate === "tool_call" || u.sessionUpdate === "tool_call_update") {
751
+ // Prefer stable name over generic title ("Tool call").
752
+ const name =
753
+ (typeof u.name === "string" && u.name) ||
754
+ (typeof u.toolName === "string" && u.toolName) ||
755
+ (typeof u.title === "string" && u.title && !/^tool[_ ]?call$/i.test(u.title) ? u.title : "") ||
756
+ u.kind ||
757
+ "tool";
758
+ const raw = (u.rawInput || {}) as Record<string, unknown>;
759
+ const detail =
760
+ (typeof raw.path === "string" && raw.path) ||
761
+ (typeof raw.target_file === "string" && raw.target_file) ||
762
+ (typeof raw.command === "string" && raw.command) ||
763
+ (typeof raw.pattern === "string" && raw.pattern) ||
764
+ (Array.isArray(u.locations) && u.locations[0]?.path) ||
765
+ "";
766
+ if (u.sessionUpdate === "tool_call" || detail) {
767
+ this.slog.logTool(sessionId, String(name), detail ? String(detail).slice(0, 200) : "");
768
+ }
697
769
  }
698
770
  // Derive a context-usage %/token count if the update carries usage info.
699
771
  const usage = (u as { usage?: { totalTokens?: number } }).usage;
@@ -738,6 +810,20 @@ export class GrokClient extends EventEmitter {
738
810
  const marker = "User's new message:\n";
739
811
  const mi = t.lastIndexOf(marker);
740
812
  if (mi !== -1) t = t.slice(mi + marker.length);
813
+ // Strip auto-complexity steering (and legacy forced-complex wrapper).
814
+ const taskMarker = "User task:";
815
+ const ti = t.lastIndexOf(taskMarker);
816
+ if (
817
+ ti !== -1 &&
818
+ (/^COMPLEXITY \(decide yourself/i.test(t) || /^TASK COMPLEXITY:/i.test(t))
819
+ ) {
820
+ t = t.slice(ti + taskMarker.length);
821
+ }
822
+ // Never persist quiet meta-prompts as a user message title.
823
+ if (/^Session status update \(meta only\)/i.test(t.trim())) t = "";
824
+ if (/^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(t.trim())) t = "";
825
+ if (/^SELF-RECHECK DECISION \(meta only\)/i.test(t.trim())) t = "";
826
+ if (/^SELF-RECHECK \(automatic quality pass/i.test(t.trim())) t = "";
741
827
  t = t.replace(/^\([^\n)]*\)\s*\n+/, "");
742
828
  return t.trim();
743
829
  }