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
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Display-only truncation helpers.
3
+ *
4
+ * Long tool outputs, diffs, and reasoning are shortened for Telegram so messages
5
+ * stay readable and under size limits. Session history / agent context is
6
+ * unaffected — these helpers never touch what is stored on disk for context.
7
+ */
8
+
9
+ /** Ellipsis used in truncated regions (single character). */
10
+ const ELLIPSIS = "\u2026";
11
+
12
+ /**
13
+ * Keep the start of `text` when longer than `max` (classic head truncate).
14
+ * Prefer {@link truncateMiddle} for long command/diff/reasoning bodies.
15
+ */
16
+ export function truncateHead(text: string, max: number): string {
17
+ if (max <= 0) return "";
18
+ if (text.length <= max) return text;
19
+ if (max === 1) return ELLIPSIS;
20
+ return text.slice(0, max - 1) + ELLIPSIS;
21
+ }
22
+
23
+ /**
24
+ * Keep both ends of a long string, cutting the middle with an explicit note.
25
+ * Head ~40% / tail ~60% of the budget so recent terminal output stays visible.
26
+ */
27
+ export function truncateMiddle(text: string, max: number): string {
28
+ if (max <= 0) return "";
29
+ if (text.length <= max) return text;
30
+ if (max <= 24) return truncateHead(text, max);
31
+
32
+ const omitted = text.length;
33
+ const marker = `\n${ELLIPSIS} (${omitted.toLocaleString("en-US")} chars total; middle omitted) ${ELLIPSIS}\n`;
34
+ const budget = max - marker.length;
35
+ if (budget < 8) return truncateHead(text, max);
36
+
37
+ const headLen = Math.max(4, Math.floor(budget * 0.4));
38
+ const tailLen = Math.max(4, budget - headLen);
39
+ // Avoid overlapping when max is only slightly under length.
40
+ if (headLen + tailLen >= text.length) return text;
41
+ return text.slice(0, headLen) + marker + text.slice(text.length - tailLen);
42
+ }
43
+
44
+ /**
45
+ * Middle-truncate a multi-line block by whole lines when possible (better for
46
+ * diffs and command output). Falls back to character middle-truncate.
47
+ */
48
+ export function truncateMiddleLines(text: string, maxLines: number, maxChars = 12_000): string {
49
+ const lines = text.split("\n");
50
+ let body: string;
51
+ if (lines.length <= maxLines) {
52
+ body = text;
53
+ } else {
54
+ const headN = Math.max(1, Math.floor(maxLines * 0.45));
55
+ const tailN = Math.max(1, maxLines - headN);
56
+ const omitted = lines.length - headN - tailN;
57
+ body = [
58
+ ...lines.slice(0, headN),
59
+ `${ELLIPSIS} (${omitted} lines omitted) ${ELLIPSIS}`,
60
+ ...lines.slice(lines.length - tailN),
61
+ ].join("\n");
62
+ }
63
+ return truncateMiddle(body, maxChars);
64
+ }
65
+
66
+ /**
67
+ * Live terminal display: keep the first output line + the last `tailLines`
68
+ * lines so long command runs update in place without spamming new blocks.
69
+ * Display-only — full text stays in the agent session / tool snapshot.
70
+ */
71
+ export function formatLiveTerminalOutput(full: string, tailLines = 12, maxChars = 3500): string {
72
+ const normalized = full.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
73
+ const lines = normalized.split("\n");
74
+ // Drop trailing empty lines for a cleaner tail.
75
+ while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
76
+ if (lines.length === 0) return "";
77
+ if (lines.length <= tailLines + 1) {
78
+ return truncateMiddle(lines.join("\n"), maxChars);
79
+ }
80
+ const first = lines[0]!;
81
+ const tail = lines.slice(-tailLines);
82
+ const omitted = lines.length - 1 - tailLines;
83
+ const body = [first, `${ELLIPSIS} (${omitted} lines omitted; live tail) ${ELLIPSIS}`, ...tail].join("\n");
84
+ return truncateMiddle(body, maxChars);
85
+ }
@@ -168,20 +168,32 @@ function entryOf(spec: LaunchSpec): string {
168
168
  }
169
169
 
170
170
  function vbsLauncher(spec: LaunchSpec): string {
171
+ // Forever-restart loop: wait for the bot process to exit, pause, relaunch.
172
+ // Prevents silent death when the node process crashes. GROK_TG_SUPERVISED=1
173
+ // tells the bot a supervisor will relaunch it (auto-update exits cleanly).
171
174
  const cmd = `""${spec.nodePath}"" ${spec.args.map((a) => `""${a}""`).join(" ")}`;
172
175
  return [
173
176
  'Set sh = CreateObject("WScript.Shell")',
174
177
  `sh.CurrentDirectory = "${spec.cwd}"`,
175
- `sh.Run "${cmd}", 0, False`,
178
+ 'Set env = sh.Environment("PROCESS")',
179
+ 'env("GROK_TG_SUPERVISED") = "1"',
180
+ "Do",
181
+ ` sh.Run "${cmd}", 0, True`,
182
+ " WScript.Sleep 3000",
183
+ "Loop",
176
184
  ].join("\r\n");
177
185
  }
178
186
 
179
187
  function killScript(entry: string): string {
180
188
  const safe = entry.replace(/'/g, "''");
189
+ // Kill the forever-restart VBS host *before* node, otherwise the 3s loop
190
+ // respawns the bot right after "stop". Targets our service / Startup launchers.
181
191
  return [
192
+ `$vbs = Get-CimInstance Win32_Process -Filter "Name='wscript.exe' OR Name='cscript.exe'" | Where-Object { $_.CommandLine -like '*GrokTelegramBot.vbs*' -or $_.CommandLine -like '*run-service.vbs*' };`,
193
+ `$vbs | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue };`,
182
194
  `$p = Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*${safe}*' };`,
183
195
  `$p | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue };`,
184
- `"killed " + (@($p).Count)`,
196
+ `"killed " + (@($p).Count) + " node, " + (@($vbs).Count) + " vbs"`,
185
197
  ].join(" ");
186
198
  }
187
199
 
@@ -46,6 +46,48 @@ export function jsonlMtimeMs(jsonlPath: string): number {
46
46
  }
47
47
  }
48
48
 
49
+ /**
50
+ * Best-effort card blurb from the tail of a session log: last assistant prose
51
+ * (what was solved), else last user prompt. Skips import-confirm noise.
52
+ */
53
+ export function readLastCardSummary(jsonlPath: string, maxEntries = 30): string {
54
+ const entries = readHistory(jsonlPath, maxEntries);
55
+ if (entries.length === 0) return "";
56
+ // Walk newest → oldest for a useful assistant conclusion.
57
+ for (let i = entries.length - 1; i >= 0; i--) {
58
+ const e = entries[i]!;
59
+ if (e.role === "assistant" && e.text.trim()) {
60
+ const t = cleanCardProse(e.text);
61
+ if (t.length >= 20) return t;
62
+ }
63
+ }
64
+ for (let i = entries.length - 1; i >= 0; i--) {
65
+ const e = entries[i]!;
66
+ if (e.role === "user" && e.text.trim()) {
67
+ const t = cleanCardProse(e.text);
68
+ if (t && !/session import complete/i.test(t)) return t;
69
+ }
70
+ }
71
+ return "";
72
+ }
73
+
74
+ function cleanCardProse(raw: string, max = 200): string {
75
+ let t = extractProgress(raw).cleaned;
76
+ t = t.replace(/```[\s\S]*?```/g, " ");
77
+ t = t.replace(/^COMPLEXITY \(decide yourself[\s\S]*?User task:\s*/i, "");
78
+ t = t.replace(/^TASK COMPLEXITY:[\s\S]*?User task:\s*/i, "");
79
+ if (/^Session status update \(meta only\)/i.test(t.trim())) return "";
80
+ t = t.replace(/\s+/g, " ").trim();
81
+ if (t.length <= max) return t;
82
+ // Prefer the ending (conclusions).
83
+ if (t.length > max + 40) {
84
+ const tail = t.slice(-max + 1);
85
+ const sp = tail.indexOf(" ");
86
+ return "\u2026" + (sp > 0 && sp < 30 ? tail.slice(sp + 1) : tail);
87
+ }
88
+ return t.slice(0, max - 1) + "\u2026";
89
+ }
90
+
49
91
  /** The first user prompt in a session log (read from the start), or "". */
50
92
  export function readFirstPrompt(jsonlPath: string, maxBytes = 256 * 1024): string {
51
93
  let size: number;
@@ -163,6 +205,21 @@ function cleanStoredText(text: string): string {
163
205
  let t = extractProgress(text).cleaned;
164
206
  if (t.includes(PROGRESS_DIRECTIVE)) t = t.split(PROGRESS_DIRECTIVE).join("").trim();
165
207
  if (t.includes(IMAGE_OUTPUT_DIRECTIVE)) t = t.split(IMAGE_OUTPUT_DIRECTIVE).join("").trim();
208
+ // Strip first-prompt auto-complexity steering (and legacy forced-complex wrapper)
209
+ // so history / cards show the real user task, not bot plumbing.
210
+ const taskMarker = "User task:";
211
+ const ti = t.lastIndexOf(taskMarker);
212
+ if (
213
+ ti !== -1 &&
214
+ (/^COMPLEXITY \(decide yourself/i.test(t) || /^TASK COMPLEXITY:/i.test(t))
215
+ ) {
216
+ t = t.slice(ti + taskMarker.length).trim();
217
+ }
218
+ // Drop removed/quiet meta-prompts if they landed in history.
219
+ if (/^Session status update \(meta only\)/i.test(t.trim())) t = "";
220
+ if (/^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(t.trim())) t = "";
221
+ if (/^SELF-RECHECK DECISION \(meta only\)/i.test(t.trim())) t = "";
222
+ if (/^SELF-RECHECK \(automatic quality pass/i.test(t.trim())) t = "";
166
223
  return t;
167
224
  }
168
225
 
@@ -17,6 +17,7 @@ interface RawSessionJson {
17
17
  created_at?: string;
18
18
  updated_at?: string;
19
19
  session_created_reason?: string;
20
+ comment?: string;
20
21
  }
21
22
 
22
23
  interface RawLock {
@@ -93,6 +94,7 @@ export class SessionStore {
93
94
  /* no history yet */
94
95
  }
95
96
 
97
+ const comment = (raw.comment || "").trim();
96
98
  return {
97
99
  sessionId,
98
100
  cwd: raw.cwd || "",
@@ -103,6 +105,7 @@ export class SessionStore {
103
105
  lockPid,
104
106
  active,
105
107
  historyBytes,
108
+ comment: comment || undefined,
106
109
  };
107
110
  }
108
111
 
@@ -13,6 +13,11 @@ export interface SessionMeta {
13
13
  active: boolean;
14
14
  /** Size of the .jsonl history in bytes (proxy for conversation length). */
15
15
  historyBytes: number;
16
+ /**
17
+ * Short status line for cards: current step while working, or chat summary
18
+ * when idle (persisted by the bot after turns).
19
+ */
20
+ comment?: string;
16
21
  }
17
22
 
18
23
  export type HistoryRole = "user" | "assistant" | "tool" | "system";
@@ -15,15 +15,19 @@ import { chunkMarkdown } from "../render/chunk.js";
15
15
  import { toTelegramMarkdown } from "../render/markdown.js";
16
16
  import { extractProgress, progressBar } from "../render/progress.js";
17
17
  import { estimateProgress } from "../render/progress-estimate.js";
18
+ import { truncateMiddle } from "../render/truncate.js";
18
19
  import { safeEdit, safeSend } from "../bot/telegram-io.js";
19
20
 
20
21
  const SOFT_LIMIT = 3500;
21
- const THINK_TAIL = 500;
22
+ /** Display budget for a thinking block (middle-truncated; session context keeps all). */
23
+ const THINK_DISPLAY_MAX = 2800;
22
24
 
23
25
  type SegKind = "out" | "think" | "tool";
24
26
  interface Seg {
25
27
  kind: SegKind;
26
28
  text: string;
29
+ /** When set, later tool updates replace this segment instead of appending. */
30
+ toolId?: string;
27
31
  }
28
32
 
29
33
  export class ResponseStreamer {
@@ -44,6 +48,11 @@ export class ResponseStreamer {
44
48
  private toolCalls = 0;
45
49
  private outChars = 0;
46
50
  private thoughtChars = 0;
51
+ /**
52
+ * Active plan board (ACP sessionUpdate "plan"). Always rendered just above
53
+ * the progress bar when set — done / in-progress / pending steps.
54
+ */
55
+ private planMarkdown: string | undefined;
47
56
 
48
57
  constructor(
49
58
  private readonly api: Api,
@@ -134,6 +143,10 @@ export class ResponseStreamer {
134
143
  this.schedule();
135
144
  }
136
145
 
146
+ /**
147
+ * Append a one-shot tool card (no live updates). Prefer {@link upsertTool}
148
+ * for ACP tool calls that stream progress/output under a stable toolCallId.
149
+ */
137
150
  addTool(rawMarkdown: string): void {
138
151
  if (!rawMarkdown) return;
139
152
  this.toolCalls += 1;
@@ -141,6 +154,45 @@ export class ResponseStreamer {
141
154
  this.schedule();
142
155
  }
143
156
 
157
+ /**
158
+ * Insert or replace a tool card keyed by toolCallId so one command/edit stays
159
+ * a single Telegram block that auto-updates (no spam of new code sections).
160
+ * Full tool results remain in the agent session; this is display-only.
161
+ */
162
+ /** Replace the live plan board (or clear with empty/undefined). */
163
+ setPlan(markdown: string | undefined): void {
164
+ const next = markdown?.trim() ? markdown.trim() : undefined;
165
+ if (next === this.planMarkdown) return;
166
+ this.planMarkdown = next;
167
+ this.schedule();
168
+ }
169
+
170
+ upsertTool(toolId: string | undefined, rawMarkdown: string): void {
171
+ if (!rawMarkdown) return;
172
+ const id = (toolId || "").trim();
173
+ if (id) {
174
+ // Replace any existing segment with this id (newest first; includes rare
175
+ // sealed-region matches so we don't keep stale text in the segs model).
176
+ for (let i = this.segs.length - 1; i >= 0; i--) {
177
+ const s = this.segs[i]!;
178
+ if (s.kind === "tool" && s.toolId === id) {
179
+ if (s.text === rawMarkdown) return;
180
+ s.text = rawMarkdown;
181
+ // If the card lives only in a sealed bubble, also ensure a live copy
182
+ // so the user sees the latest output on the current message.
183
+ if (i < this.sealedIdx) {
184
+ this.segs.push({ kind: "tool", text: rawMarkdown, toolId: id });
185
+ }
186
+ this.schedule();
187
+ return;
188
+ }
189
+ }
190
+ }
191
+ this.toolCalls += 1;
192
+ this.segs.push({ kind: "tool", text: rawMarkdown, toolId: id || undefined });
193
+ this.schedule();
194
+ }
195
+
144
196
  get hasOutput(): boolean {
145
197
  return this.liveId !== undefined || this.segs.some((s) => s.text.trim().length > 0);
146
198
  }
@@ -182,13 +234,15 @@ export class ResponseStreamer {
182
234
  await this.sealOverflow();
183
235
  const base = this.captureProgress(renderSegs(this.segs.slice(this.sealedIdx)));
184
236
  this.applyFallback();
185
- // Never send an empty / progress-only bubble. The bar is appended only to
186
- // real streamed content; the live status panel shows the standalone bar.
187
- if (!base.trim()) return;
188
- // The live (still-streaming) bubble carries the hashtag footer AND a fresh
189
- // progress bar at the bottom (sealed bubbles below get neither bar).
190
- const parts: string[] = [base];
237
+ // Never send an empty / progress-only bubble. Plan alone is allowed so the
238
+ // board is visible as soon as the agent publishes steps.
239
+ if (!base.trim() && !this.planMarkdown) return;
240
+ // Live bubble: body → plan (always above progress) → progress bar → footer.
241
+ const parts: string[] = [];
242
+ if (base.trim()) parts.push(base);
243
+ if (this.planMarkdown) parts.push(this.planMarkdown);
191
244
  if (this.progress !== undefined) parts.push(progressBar(this.progress));
245
+ if (parts.length === 0) return;
192
246
  const src = `${parts.join("\n\n")}${this.footerSuffix()}`;
193
247
  const rendered = toTelegramMarkdown(src);
194
248
  const chunks = chunkMarkdown(rendered);
@@ -255,7 +309,17 @@ function renderSegs(segs: Seg[]): string {
255
309
  function quoteThought(text: string): string {
256
310
  const t = text.trim();
257
311
  if (!t) return "";
258
- const short = t.length > THINK_TAIL ? "…" + t.slice(-THINK_TAIL) : t;
312
+ // Keep both ends of long reasoning so early investigation is not lost in the UI.
313
+ // Truncation is display-only — the agent session retains every thought token.
314
+ // Neutralize fence markers and half-open emphasis so thinking never breaks
315
+ // MarkdownV2 parsing of the surrounding live message.
316
+ const safe = t
317
+ .replace(/```+/g, "'''")
318
+ .replace(/\*\*/g, "")
319
+ .replace(/__/g, "")
320
+ .replace(/~~/g, "");
321
+ const short = truncateMiddle(safe, THINK_DISPLAY_MAX);
259
322
  const lines = short.split("\n");
260
- return lines.map((l, i) => (i === 0 ? `> 💭 *thinking:* ${l}` : `> ${l}`)).join("\n");
323
+ // Plain "thinking:" (no nested *bold*) — nested markers break mid-stream.
324
+ return lines.map((l, i) => (i === 0 ? `> \u{1F4AD} thinking: ${l}` : `> ${l}`)).join("\n");
261
325
  }
@@ -6,7 +6,7 @@
6
6
  import type { Api } from "grammy";
7
7
  import { basename } from "node:path";
8
8
  import type { GrokClient } from "../grok/client.js";
9
- import type { SessionUpdate } from "../grok/types.js";
9
+ import { contentText, type SessionUpdate } from "../grok/types.js";
10
10
  import { createLogger } from "../logger.js";
11
11
  import { sendMarkdownDoc } from "../bot/telegram-io.js";
12
12
  import type { Task } from "./types.js";
@@ -29,8 +29,9 @@ export class TaskRunner {
29
29
 
30
30
  const listener = (sid: string, u: SessionUpdate): void => {
31
31
  if (sid !== sessionId) return;
32
- if (u.sessionUpdate === "agent_message_chunk" && typeof u.content?.text === "string") {
33
- text += u.content.text;
32
+ if (u.sessionUpdate === "agent_message_chunk") {
33
+ const t = contentText(u.content);
34
+ if (t) text += t;
34
35
  } else if (u.sessionUpdate === "tool_call") {
35
36
  const id = u.toolCallId || u.title || String(tools);
36
37
  if (!seen.has(id)) {