grok-telegram-bot 2.0.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 (106) hide show
  1. package/.env.example +135 -0
  2. package/CHANGELOG.md +598 -0
  3. package/LICENSE +21 -0
  4. package/README.md +644 -0
  5. package/bin/grok-tg.mjs +21 -0
  6. package/docs/INSTALL.md +153 -0
  7. package/docs/UPGRADE.md +253 -0
  8. package/docs/ops/RELEASE_CHECKLIST.md +39 -0
  9. package/package.json +74 -0
  10. package/scripts/setup.mjs +116 -0
  11. package/src/agents/catalog.ts +58 -0
  12. package/src/app/accounts.ts +162 -0
  13. package/src/app/auth-service.ts +136 -0
  14. package/src/app/grok-credentials.ts +103 -0
  15. package/src/app/instance-lock.ts +139 -0
  16. package/src/app/json-store.ts +54 -0
  17. package/src/app/reasoning.ts +30 -0
  18. package/src/app/settings-store.ts +38 -0
  19. package/src/app/stt.ts +53 -0
  20. package/src/app/types.ts +56 -0
  21. package/src/app/updater.ts +234 -0
  22. package/src/app/usage.ts +38 -0
  23. package/src/app/version.ts +41 -0
  24. package/src/bot/account-rotator.ts +52 -0
  25. package/src/bot/auth.ts +38 -0
  26. package/src/bot/bot.ts +225 -0
  27. package/src/bot/chat-controller.ts +317 -0
  28. package/src/bot/commands.ts +52 -0
  29. package/src/bot/deps.ts +67 -0
  30. package/src/bot/file-ingest.ts +190 -0
  31. package/src/bot/handlers/accounts.ts +220 -0
  32. package/src/bot/handlers/auth.ts +64 -0
  33. package/src/bot/handlers/control.ts +103 -0
  34. package/src/bot/handlers/document.ts +112 -0
  35. package/src/bot/handlers/history.ts +63 -0
  36. package/src/bot/handlers/kill.ts +54 -0
  37. package/src/bot/handlers/mcp.ts +206 -0
  38. package/src/bot/handlers/menu.ts +220 -0
  39. package/src/bot/handlers/message.ts +103 -0
  40. package/src/bot/handlers/photo.ts +123 -0
  41. package/src/bot/handlers/projects.ts +183 -0
  42. package/src/bot/handlers/running.ts +181 -0
  43. package/src/bot/handlers/session-card.ts +81 -0
  44. package/src/bot/handlers/session-kill.ts +95 -0
  45. package/src/bot/handlers/sessions.ts +148 -0
  46. package/src/bot/handlers/system.ts +51 -0
  47. package/src/bot/handlers/tasks.ts +224 -0
  48. package/src/bot/handlers/usage.ts +38 -0
  49. package/src/bot/handlers/voice.ts +55 -0
  50. package/src/bot/image-return.ts +69 -0
  51. package/src/bot/menu/ephemeral.ts +117 -0
  52. package/src/bot/menu/keyboard.ts +49 -0
  53. package/src/bot/menu/refresh.ts +13 -0
  54. package/src/bot/menu/status-panel.ts +173 -0
  55. package/src/bot/permission-service.ts +149 -0
  56. package/src/bot/prompt-content.ts +64 -0
  57. package/src/bot/prompt-retry.ts +70 -0
  58. package/src/bot/reauth-controller.ts +297 -0
  59. package/src/bot/registry.ts +186 -0
  60. package/src/bot/reply-context.ts +77 -0
  61. package/src/bot/session-fork.ts +35 -0
  62. package/src/bot/session-runtime.ts +1048 -0
  63. package/src/bot/telegram-io.ts +109 -0
  64. package/src/bot/typing.ts +35 -0
  65. package/src/bot/wizard/task-wizard.ts +214 -0
  66. package/src/cli.ts +126 -0
  67. package/src/config.ts +248 -0
  68. package/src/grok/client.ts +617 -0
  69. package/src/grok/models.ts +50 -0
  70. package/src/grok/session-log.ts +148 -0
  71. package/src/grok/transport.ts +51 -0
  72. package/src/grok/types.ts +136 -0
  73. package/src/index.ts +84 -0
  74. package/src/logger.ts +78 -0
  75. package/src/mcp/config.ts +120 -0
  76. package/src/mcp/probe.ts +218 -0
  77. package/src/mcp/types.ts +68 -0
  78. package/src/projects/manager.ts +99 -0
  79. package/src/render/chunk.ts +57 -0
  80. package/src/render/diff.ts +48 -0
  81. package/src/render/escape.ts +22 -0
  82. package/src/render/file-summary.ts +111 -0
  83. package/src/render/hashtags.ts +34 -0
  84. package/src/render/markdown.ts +130 -0
  85. package/src/render/progress-estimate.ts +63 -0
  86. package/src/render/progress.ts +80 -0
  87. package/src/render/subagent.ts +75 -0
  88. package/src/render/tool-call.ts +196 -0
  89. package/src/service/index.ts +24 -0
  90. package/src/service/linux.ts +85 -0
  91. package/src/service/macos.ts +101 -0
  92. package/src/service/platform.ts +64 -0
  93. package/src/service/types.ts +36 -0
  94. package/src/service/windows.ts +198 -0
  95. package/src/sessions/history.ts +225 -0
  96. package/src/sessions/process.ts +30 -0
  97. package/src/sessions/store.ts +133 -0
  98. package/src/sessions/tail.ts +86 -0
  99. package/src/sessions/types.ts +26 -0
  100. package/src/stream/streamer.ts +261 -0
  101. package/src/tasks/runner.ts +82 -0
  102. package/src/tasks/schedule.ts +142 -0
  103. package/src/tasks/scheduler.ts +53 -0
  104. package/src/tasks/store.ts +80 -0
  105. package/src/tasks/types.ts +33 -0
  106. package/tsconfig.json +19 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Convert standard Markdown (as produced by the agent) into Telegram
3
+ * MarkdownV2, with correct escaping and graceful handling of code blocks,
4
+ * headings, lists, quotes, links and inline styles.
5
+ */
6
+ import { escapeCode, escapeMdV2, escapeUrl } from "./escape.js";
7
+
8
+ const FENCE = /```([^\n`]*)\n([\s\S]*?)```/g;
9
+
10
+ /** Main entry: returns a MarkdownV2-safe string. */
11
+ export function toTelegramMarkdown(src: string): string {
12
+ let out = "";
13
+ let last = 0;
14
+ let m: RegExpExecArray | null;
15
+ FENCE.lastIndex = 0;
16
+
17
+ while ((m = FENCE.exec(src)) !== null) {
18
+ out += renderTextBlock(src.slice(last, m.index));
19
+ const lang = (m[1] ?? "").trim();
20
+ const code = (m[2] ?? "").replace(/\n$/, "");
21
+ out += "```" + lang + "\n" + escapeCode(code) + "\n```\n";
22
+ last = FENCE.lastIndex;
23
+ }
24
+ out += renderTextBlock(src.slice(last));
25
+
26
+ return out.replace(/\n{3,}/g, "\n\n").trim();
27
+ }
28
+
29
+ function renderTextBlock(text: string): string {
30
+ if (!text) return "";
31
+ return text
32
+ .split("\n")
33
+ // Drop stray orphan backtick lines (` or ``) left by an unbalanced/partial
34
+ // code fence — they otherwise render as a broken-looking lone "`". A real
35
+ // fence is ``` (3+) and is handled by the FENCE pass, so it's never seen here.
36
+ .filter((line) => !/^\s*`{1,2}\s*$/.test(line))
37
+ .map((line) => renderLine(line))
38
+ .join("\n");
39
+ }
40
+
41
+ function renderLine(line: string): string {
42
+ // Heading -> bold
43
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
44
+ if (heading) return "*" + renderInline((heading[2] ?? "").replace(/#+\s*$/, "").trim()) + "*";
45
+
46
+ // Horizontal rule
47
+ if (/^\s*([-*_])\1{2,}\s*$/.test(line)) return "\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014";
48
+
49
+ // Blockquote (keep '>' literal so Telegram renders the quote)
50
+ const quote = /^>\s?(.*)$/.exec(line);
51
+ if (quote) return ">" + renderInline(quote[1] ?? "");
52
+
53
+ // Unordered list
54
+ const ul = /^(\s*)[-*+]\s+(.*)$/.exec(line);
55
+ if (ul) return (ul[1] ?? "") + "\u2022 " + renderInline(ul[2] ?? "");
56
+
57
+ // Ordered list
58
+ const ol = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(line);
59
+ if (ol) return (ol[1] ?? "") + (ol[2] ?? "") + "\\. " + renderInline(ol[3] ?? "");
60
+
61
+ return renderInline(line);
62
+ }
63
+
64
+ /** Render inline markdown spans into MarkdownV2. */
65
+ function renderInline(text: string): string {
66
+ let out = "";
67
+ let i = 0;
68
+ const n = text.length;
69
+
70
+ while (i < n) {
71
+ const c = text[i]!;
72
+ const next = text[i + 1];
73
+
74
+ // Inline code
75
+ if (c === "`") {
76
+ const end = text.indexOf("`", i + 1);
77
+ if (end !== -1) {
78
+ out += "`" + escapeCode(text.slice(i + 1, end)) + "`";
79
+ i = end + 1;
80
+ continue;
81
+ }
82
+ }
83
+
84
+ // Bold ** ** or __ __
85
+ if ((c === "*" && next === "*") || (c === "_" && next === "_")) {
86
+ const marker = c + c;
87
+ const end = text.indexOf(marker, i + 2);
88
+ if (end !== -1 && end > i + 2) {
89
+ out += "*" + renderInline(text.slice(i + 2, end)) + "*";
90
+ i = end + 2;
91
+ continue;
92
+ }
93
+ }
94
+
95
+ // Strikethrough ~~ ~~
96
+ if (c === "~" && next === "~") {
97
+ const end = text.indexOf("~~", i + 2);
98
+ if (end !== -1 && end > i + 2) {
99
+ out += "~" + renderInline(text.slice(i + 2, end)) + "~";
100
+ i = end + 2;
101
+ continue;
102
+ }
103
+ }
104
+
105
+ // Italic * * (single)
106
+ if (c === "*") {
107
+ const end = text.indexOf("*", i + 1);
108
+ if (end !== -1 && end > i + 1) {
109
+ out += "_" + renderInline(text.slice(i + 1, end)) + "_";
110
+ i = end + 1;
111
+ continue;
112
+ }
113
+ }
114
+
115
+ // Link [text](url)
116
+ if (c === "[") {
117
+ const link = /^\[([^\]]*)\]\(([^)\s]+)\)/.exec(text.slice(i));
118
+ if (link) {
119
+ out += "[" + renderInline(link[1] ?? "") + "](" + escapeUrl(link[2] ?? "") + ")";
120
+ i += link[0].length;
121
+ continue;
122
+ }
123
+ }
124
+
125
+ out += escapeMdV2(c);
126
+ i += 1;
127
+ }
128
+
129
+ return out;
130
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Bot-side FALLBACK task-progress estimate.
3
+ *
4
+ * The primary progress signal is the `{progress: N%}` marker the agent is asked
5
+ * to emit (see PROGRESS_DIRECTIVE). But that marker is only an *instruction* the
6
+ * model can ignore — weaker/free models and long, tool-heavy turns frequently
7
+ * never emit one, leaving the bar empty for the whole turn. This module gives
8
+ * the bot a way to show a live, advancing bar anyway, derived ONLY from real,
9
+ * observable work signals (never random):
10
+ *
11
+ * • completed tool calls — each is concrete progress, weighted most
12
+ * • streamed prose chars — the agent explaining / answering
13
+ * • streamed thinking chars — reasoning volume (weighted least)
14
+ * • elapsed time — a small, slow contribution so a quiet turn still creeps
15
+ *
16
+ * The estimate is monotonic by construction (every input only grows during a
17
+ * turn) and asymptotically capped well below 100 while running, so the bar never
18
+ * claims "done" on its own — the caller pushes 100 only when the turn actually
19
+ * completes. The agent's own marker, when present, always takes precedence.
20
+ */
21
+
22
+ /** Observable, monotonically-increasing signals collected during one turn. */
23
+ export interface ActivitySignals {
24
+ /** Number of tool calls / subagent transitions shown this turn. */
25
+ toolCalls: number;
26
+ /** Characters of agent prose streamed this turn. */
27
+ outputChars: number;
28
+ /** Characters of agent thinking streamed this turn. */
29
+ thoughtChars: number;
30
+ /** Milliseconds since the turn started. */
31
+ elapsedMs: number;
32
+ }
33
+
34
+ /** Hard ceiling for the fallback while a turn is still running. The agent (or
35
+ * turn completion) is the only thing allowed to take the bar to 100. */
36
+ export const FALLBACK_RUNNING_CAP = 90;
37
+
38
+ /** Minimum shown once *any* work signal is present (so the bar never sits at 0
39
+ * while the agent is clearly busy). */
40
+ const FALLBACK_FLOOR = 5;
41
+
42
+ /** Controls how quickly the asymptotic curve approaches the cap. Larger = slower. */
43
+ const CURVE_K = 6;
44
+
45
+ /**
46
+ * Map real work signals to a 0–FALLBACK_RUNNING_CAP estimate via a saturating
47
+ * curve `cap * (1 - e^(-units/K))`. Tool calls dominate because each is a
48
+ * discrete, completed step; text volume and elapsed time add gentle, diminishing
49
+ * contributions so a turn that's only thinking still advances slowly.
50
+ */
51
+ export function estimateProgress(s: ActivitySignals): number {
52
+ const units =
53
+ Math.max(0, s.toolCalls) * 1.0 +
54
+ Math.max(0, s.outputChars) / 400 +
55
+ Math.max(0, s.thoughtChars) / 1500 +
56
+ Math.max(0, s.elapsedMs) / 30_000;
57
+
58
+ if (units <= 0) return 0;
59
+
60
+ const raw = FALLBACK_RUNNING_CAP * (1 - Math.exp(-units / CURVE_K));
61
+ const clamped = Math.min(FALLBACK_RUNNING_CAP, Math.max(FALLBACK_FLOOR, raw));
62
+ return Math.round(clamped);
63
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Task-progress support: parse the `{progress: N%}` marker the agent appends to
3
+ * its messages, strip it from the visible text, and render a green loading bar.
4
+ *
5
+ * The agent is asked (see PROGRESS_DIRECTIVE) to end each message with a marker
6
+ * like `{progress: 65%}`. The bot extracts the latest value, removes the marker
7
+ * so it never shows raw, and renders a 0–100% bar (filled = 🟩, empty = ⬜) on
8
+ * the live message, in session cards, and in the pinned status panel.
9
+ */
10
+
11
+ /** Matches a complete marker: `{progress: 65%}`, `{ progress:65 }`, etc. */
12
+ const PROGRESS_RE = /\{\s*progress\s*:\s*(\d{1,3})\s*%?\s*\}/gi;
13
+ /** Matches a trailing, not-yet-closed marker mid-stream (e.g. `…{progress: 6`). */
14
+ const PARTIAL_TAIL_RE = /\{\s*progress\b[^}]*$/i;
15
+
16
+ const FILLED = "\u{1F7E9}"; // 🟩
17
+ const EMPTY = "\u2B1C"; // ⬜
18
+ const SEGMENTS = 10;
19
+
20
+ /**
21
+ * The instruction appended to prompts so the agent emits a progress marker.
22
+ *
23
+ * IMPORTANT for maintainers:
24
+ * - The ONLY brace token may be the literal `{progress: N%}` with the letter
25
+ * `N` (never a digit). A digit inside braces would be parsed by PROGRESS_RE
26
+ * as a real value AND would break the exact-string strip in
27
+ * `sessions/history.ts` (`cleanStoredText`).
28
+ * - Keep this string "tidy-idempotent": no trailing spaces on any line, no run
29
+ * of 3+ newlines, and no trailing whitespace at the end. `cleanStoredText`
30
+ * runs `extractProgress` (which calls `tidy()`) before stripping the
31
+ * directive by exact match, so any whitespace `tidy()` would rewrite must
32
+ * not appear here, or the directive leaks into history/previews.
33
+ */
34
+ export const PROGRESS_DIRECTIVE = [
35
+ "PROGRESS REPORTING IS MANDATORY ON EVERY SINGLE MESSAGE YOU SEND \u2014 NO EXCEPTIONS.",
36
+ "Rule 1 (format): finish EVERY message with a task-completion marker on its own final line, in EXACTLY this format, with nothing at all after it: {progress: N%}",
37
+ "N is a plain integer from 0 to 100 (no decimals, no ranges, no math). The marker is the very last thing in the message: no text, punctuation, spaces, emoji, backticks, or code fences may follow it, and it must NEVER be placed inside a code block or quote.",
38
+ "Rule 2 (frequency): emit the marker on your FIRST message, on EVERY intermediate message, after EVERY tool call or group of tool calls, around any subagent delegation, and on your FINAL message. Short replies, plans, questions, acknowledgements, clarifications, errors, and tool-only or status updates are NOT exempt \u2014 if you output any text at all, it ends with the marker. Do not batch it only into the last message.",
39
+ "Rule 3 (compute it for real, never random): before each message, decompose the overall task into the concrete steps it actually needs (understand the request, read the relevant files, each separate edit, run the build/tests, fix failures, verify) and set N = round(100 * completed_steps / total_steps), re-estimated fresh from the real current state each time.",
40
+ "Rule 4 (be granular and honest): start low (about 5 to 15 on your first message; use 0 only when literally nothing is started yet), then climb in realistic increments that mirror real progress. Do NOT jump from a low number straight to a high one, and do NOT keep repeating the same number across messages while work is clearly advancing.",
41
+ "Rule 5 (monotonic): within one task the number must NEVER decrease \u2014 each marker is greater than or equal to the previous one you emitted.",
42
+ "Rule 6 (terminal): report 100 ONLY when the entire task is fully complete and verified with nothing left to do. While ANY work, fix, verification, question, or follow-up remains, cap the number at 99.",
43
+ "The client parses this marker, removes it from the visible text, and renders it as a live progress bar, so its presence on every message and the accuracy of the number both matter.",
44
+ ].join("\n");
45
+
46
+ export interface ProgressExtract {
47
+ /** Latest progress value found (0–100), or undefined if none. */
48
+ value?: number;
49
+ /** The input text with all progress markers removed. */
50
+ cleaned: string;
51
+ }
52
+
53
+ /** Pull the latest `{progress: N%}` value out of `text` and strip every marker
54
+ * (plus any trailing half-streamed marker so it never flashes raw). */
55
+ export function extractProgress(text: string): ProgressExtract {
56
+ let value: number | undefined;
57
+ let cleaned = text.replace(PROGRESS_RE, (_m, digits: string) => {
58
+ const v = Math.max(0, Math.min(100, Number.parseInt(digits, 10)));
59
+ if (Number.isFinite(v)) value = v; // keep the LAST occurrence (most recent)
60
+ return "";
61
+ });
62
+ cleaned = cleaned.replace(PARTIAL_TAIL_RE, "");
63
+ return { value, cleaned: tidy(cleaned) };
64
+ }
65
+
66
+ /** Tidy whitespace left behind by a removed marker. */
67
+ function tidy(s: string): string {
68
+ return s
69
+ .replace(/[ \t]+\n/g, "\n") // trailing spaces on lines
70
+ .replace(/\n{3,}/g, "\n\n") // collapse blank-line runs
71
+ .replace(/\s+$/g, ""); // trailing whitespace/newlines
72
+ }
73
+
74
+ /** A 10-segment green progress bar, e.g. `🟩🟩🟩🟩🟩⬜⬜⬜⬜⬜ 50%` (✅ at 100%). */
75
+ export function progressBar(pct: number): string {
76
+ const v = Math.max(0, Math.min(100, Math.round(pct)));
77
+ const filled = Math.round((v / 100) * SEGMENTS);
78
+ const bar = FILLED.repeat(filled) + EMPTY.repeat(SEGMENTS - filled);
79
+ return `${bar} ${v}%${v >= 100 ? " \u2705" : ""}`;
80
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Render subagent ("crew") activity into short, readable status lines so the
3
+ * user can see what's happening while the main agent waits on its subagents.
4
+ */
5
+ import type { PendingStage, SubagentInfo } from "../grok/types.js";
6
+
7
+ const STATUS_ICON: Record<string, string> = {
8
+ working: "\u{1F3C3}", // 🏃 running
9
+ running: "\u{1F3C3}",
10
+ pending: "\u23F3",
11
+ queued: "\u23F3",
12
+ completed: "\u2705",
13
+ done: "\u2705",
14
+ terminated: "\u2705",
15
+ failed: "\u274C",
16
+ error: "\u274C",
17
+ cancelled: "\u23F9",
18
+ };
19
+
20
+ function trunc(s: string, n: number): string {
21
+ return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
22
+ }
23
+
24
+ /** Normalize a subagent's status type to a short, stable key. */
25
+ export function statusKey(s: SubagentInfo): string {
26
+ return (s.status?.type || "working").toLowerCase();
27
+ }
28
+
29
+ /** A one-line label for a subagent (no leading icon). */
30
+ export function subagentLabel(s: SubagentInfo): string {
31
+ const name = s.sessionName || s.agentName || s.sessionId.slice(0, 8);
32
+ const role = s.role || s.agentName;
33
+ return role && role !== name ? `${name} (${role})` : name;
34
+ }
35
+
36
+ /**
37
+ * A markdown block announcing a subagent's status transition, or "" to skip.
38
+ * `kind`: "start" the first time it appears, otherwise from its status.
39
+ */
40
+ export function renderSubagentTransition(s: SubagentInfo, kind: "start" | "status"): string {
41
+ const key = statusKey(s);
42
+ const label = subagentLabel(s);
43
+ if (kind === "start") {
44
+ const q = s.initialQuery ? `\n \u2197 ${trunc(s.initialQuery.trim(), 140)}` : "";
45
+ return `\u{1F916} Subagent **${label}** started${q}`;
46
+ }
47
+ const icon = STATUS_ICON[key] ?? "\u{1F916}";
48
+ const verb =
49
+ key === "terminated" || key === "completed" || key === "done"
50
+ ? "finished"
51
+ : key === "failed" || key === "error"
52
+ ? "failed"
53
+ : key;
54
+ const msg = s.status?.message && !/^running$/i.test(s.status.message) ? ` \u2014 ${trunc(s.status.message, 80)}` : "";
55
+ return `${icon} Subagent **${label}** ${verb}${msg}`;
56
+ }
57
+
58
+ /** A compact summary for the status panel, e.g. "🤖 2 running · 1 pending". */
59
+ export function subagentSummary(subagents: SubagentInfo[], pending: PendingStage[]): string | undefined {
60
+ const running = subagents.filter((s) => {
61
+ const k = statusKey(s);
62
+ return k === "working" || k === "running" || k === "pending" || k === "queued";
63
+ }).length;
64
+ const pend = pending.length;
65
+ if (running === 0 && pend === 0) return undefined;
66
+ const parts: string[] = [];
67
+ if (running > 0) parts.push(`${running} running`);
68
+ if (pend > 0) parts.push(`${pend} pending`);
69
+ return `\u{1F916} ${parts.join(" \u00B7 ")}`;
70
+ }
71
+
72
+ /** True when a status type means the subagent is still active. */
73
+ export function isActiveStatus(key: string): boolean {
74
+ return key === "working" || key === "running" || key === "pending" || key === "queued";
75
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Format ACP tool-call updates into clear, RAW markdown blocks so they read
3
+ * distinctly from the agent's prose and thinking. Commands appear in a `bash`
4
+ * block, file edits as a `diff` block.
5
+ */
6
+ import type { SessionUpdate, ToolCallContent } from "../grok/types.js";
7
+ import { renderUnifiedDiff } from "./diff.js";
8
+
9
+ const KIND_ICON: Record<string, string> = {
10
+ read: "\u{1F4D6}",
11
+ edit: "\u270F\uFE0F",
12
+ execute: "\u{1F4BB}",
13
+ search: "\u{1F50E}",
14
+ delete: "\u{1F5D1}\uFE0F",
15
+ move: "\u{1F4E6}",
16
+ fetch: "\u{1F310}",
17
+ think: "\u{1F4AD}",
18
+ other: "\u{1F527}",
19
+ };
20
+
21
+ const STATUS_ICON: Record<string, string> = {
22
+ pending: "",
23
+ in_progress: "\u23F3",
24
+ completed: "\u2705",
25
+ failed: "\u274C",
26
+ };
27
+
28
+ export interface ToolFormatOptions {
29
+ showDiffs: boolean;
30
+ diffMaxLines: number;
31
+ }
32
+
33
+ /** Returns a RAW markdown block describing the tool call, or "" to skip. */
34
+ export function formatToolCall(u: SessionUpdate, opts: ToolFormatOptions): string {
35
+ const kind = (u.kind || "other").toLowerCase();
36
+ const raw = (u.rawInput || {}) as Record<string, unknown>;
37
+ const status = u.status ? (STATUS_ICON[u.status] ?? "") : "";
38
+ const tail = status ? ` ${status}` : "";
39
+
40
+ // Skill load — reading a `.../skills/<name>/SKILL.md`. Don't treat edits/
41
+ // deletes of a SKILL.md (skill authoring) as a "load".
42
+ if (kind !== "edit" && kind !== "delete" && kind !== "move") {
43
+ const skill = detectSkill(u, raw);
44
+ if (skill) return `\u{1F4DA} **Loaded skill: ${skill}**${tail}`;
45
+ }
46
+
47
+ // MCP / extension tool call → "Call MCP <server>: <method>" (or "Call MCP:
48
+ // <tool>" when the call carries no server name).
49
+ const mcp = detectMcp(u, raw, kind);
50
+ if (mcp) {
51
+ const label = mcp.server ? `Call MCP ${mcp.server}: ${mcp.method}` : `Call MCP: ${mcp.method}`;
52
+ return `\u{1F9E9} **${label}**${tail}`;
53
+ }
54
+
55
+ const icon = KIND_ICON[kind] ?? KIND_ICON.other;
56
+ const title = u.title || titleFromRaw(kind, raw);
57
+
58
+ let out = `${icon} **${title}**${tail}`;
59
+
60
+ if (kind === "execute") {
61
+ const cmd = strOf(raw.command ?? raw.cmd);
62
+ if (cmd) out += "\n```bash\n" + cmd + "\n```";
63
+ }
64
+
65
+ if (kind === "edit" && opts.showDiffs) {
66
+ const diff = buildEditDiff(u, raw, opts.diffMaxLines);
67
+ if (diff && diff.block) {
68
+ const stat = `${diff.added > 0 ? "+" + diff.added : ""}${diff.removed > 0 ? " -" + diff.removed : ""}`.trim();
69
+ out += `${stat ? ` (${stat})` : ""}\n${diff.block}`;
70
+ }
71
+ }
72
+
73
+ return out;
74
+ }
75
+
76
+ /** Built-in Grok tools that must never be labelled as MCP calls. */
77
+ const BUILTIN_TOOLS = new Set([
78
+ "read", "write", "shell", "grep", "glob", "web_fetch", "web_search", "fs_read",
79
+ "fs_write", "fs_replace", "fs_search", "execute_bash", "report_issue", "use_aws",
80
+ "todo_list", "introspect", "knowledge", "thinking", "summary", "subagent",
81
+ ]);
82
+ /** Tool kinds that are first-class file/shell operations (never MCP). */
83
+ const FILE_KINDS = new Set(["read", "edit", "execute", "search", "delete", "move"]);
84
+ /** `.../skills/<name>/SKILL.md` — the signature of loading a skill. */
85
+ const SKILL_RE = /[\\/]skills[\\/]([^\\/]+)[\\/]SKILL\.md$/i;
86
+ /** Namespaced MCP tool-name shapes → [, server, method]. */
87
+ const MCP_NS = [
88
+ /^@([a-z0-9._-]+)[/_]{1,3}(.+)$/i, // @server/method · @server___method
89
+ /^([a-z0-9.-]+)___(.+)$/i, // server___method
90
+ /^([a-z0-9.-]+)__(.+)$/i, // server__method
91
+ /^([a-z0-9.-]+)\/(.+)$/i, // server/method
92
+ /^([a-z0-9-]+)\.(.+)$/i, // server.method
93
+ ];
94
+
95
+ /** The skill name if this tool call loads a `SKILL.md`, else undefined. */
96
+ function detectSkill(u: SessionUpdate, raw: Record<string, unknown>): string | undefined {
97
+ for (const p of gatherPaths(u, raw)) {
98
+ const m = SKILL_RE.exec(p);
99
+ if (m) return m[1];
100
+ }
101
+ return undefined;
102
+ }
103
+
104
+ /** The MCP server + method this call targets, if it looks like an MCP/external
105
+ * tool. Built-in file/shell tools return undefined. */
106
+ function detectMcp(
107
+ u: SessionUpdate,
108
+ raw: Record<string, unknown>,
109
+ kind: string,
110
+ ): { server?: string; method: string } | undefined {
111
+ const name = mcpToolName(u, raw);
112
+ if (!name) return undefined;
113
+ for (const re of MCP_NS) {
114
+ const m = re.exec(name);
115
+ if (m) return { server: m[1]!, method: m[2]! };
116
+ }
117
+ // Bare external tool: not a built-in, and not a file/shell operation.
118
+ if (!BUILTIN_TOOLS.has(name.toLowerCase()) && !FILE_KINDS.has(kind)) {
119
+ return { method: name };
120
+ }
121
+ return undefined;
122
+ }
123
+
124
+ /** Best-effort tool name from the raw input or a tool-name-like title. */
125
+ function mcpToolName(u: SessionUpdate, raw: Record<string, unknown>): string {
126
+ const explicit = strOf(raw.tool_name) || strOf(raw.toolName) || strOf(raw.name) || strOf(raw.tool);
127
+ if (explicit) return explicit;
128
+ const t = (u.title || "").trim();
129
+ // Use the title only when it reads like a tool identifier (no spaces, not a
130
+ // "file:line" read title like "SKILL.md:1").
131
+ return /^[@a-z0-9._/-]+$/i.test(t) && !t.includes(":") ? t : "";
132
+ }
133
+
134
+ /** Collect every file path referenced by a tool call (incl. nested ops/diffs). */
135
+ function gatherPaths(u: SessionUpdate, raw: Record<string, unknown>): string[] {
136
+ const out: string[] = [];
137
+ const add = (v: unknown): void => {
138
+ if (typeof v === "string" && v) out.push(v);
139
+ };
140
+ add(raw.path);
141
+ add(raw.file_path);
142
+ add(raw.filename);
143
+ add(raw.file);
144
+ if (Array.isArray(raw.operations)) {
145
+ for (const op of raw.operations) {
146
+ if (op && typeof op === "object") add((op as Record<string, unknown>).path);
147
+ }
148
+ }
149
+ for (const b of collectContent(u)) add(b.path);
150
+ return out;
151
+ }
152
+
153
+ function buildEditDiff(u: SessionUpdate, raw: Record<string, unknown>, maxLines: number) {
154
+ const blocks = collectContent(u);
155
+ const diffBlock = blocks.find((b) => b.type === "diff");
156
+ if (diffBlock) {
157
+ return renderUnifiedDiff({
158
+ path: strOf(diffBlock.path) || strOf(raw.path) || "file",
159
+ oldText: typeof diffBlock.oldText === "string" ? diffBlock.oldText : "",
160
+ newText: typeof diffBlock.newText === "string" ? diffBlock.newText : "",
161
+ maxLines,
162
+ });
163
+ }
164
+ const oldStr = strOf(raw.old_str ?? raw.oldStr);
165
+ const newStr = strOf(raw.new_str ?? raw.newStr);
166
+ if (oldStr || newStr) {
167
+ return renderUnifiedDiff({ path: strOf(raw.path) || "file", oldText: oldStr, newText: newStr, maxLines });
168
+ }
169
+ const content = strOf(raw.file_text ?? raw.content ?? raw.text);
170
+ if (content) {
171
+ return renderUnifiedDiff({ path: strOf(raw.path) || "file", oldText: "", newText: content, maxLines });
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ function titleFromRaw(kind: string, raw: Record<string, unknown>): string {
177
+ const path = strOf(raw.path ?? raw.file_path ?? raw.filename);
178
+ if (path) return `${capitalize(kind)} ${path}`;
179
+ return capitalize(kind);
180
+ }
181
+
182
+ function collectContent(u: SessionUpdate): ToolCallContent[] {
183
+ const out: ToolCallContent[] = [];
184
+ if (Array.isArray(u.content_blocks)) out.push(...u.content_blocks);
185
+ const content = (u as unknown as { content?: unknown }).content;
186
+ if (Array.isArray(content)) out.push(...(content as ToolCallContent[]));
187
+ return out;
188
+ }
189
+
190
+ function strOf(v: unknown): string {
191
+ return typeof v === "string" ? v : "";
192
+ }
193
+
194
+ function capitalize(s: string): string {
195
+ return s.length ? s[0]!.toUpperCase() + s.slice(1) : s;
196
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Selects the right service controller for the current platform.
3
+ */
4
+ import { detectPlatform } from "./platform.js";
5
+ import { linuxController } from "./linux.js";
6
+ import { macosController } from "./macos.js";
7
+ import type { ServiceController } from "./types.js";
8
+ import { windowsController } from "./windows.js";
9
+
10
+ export { buildLaunchSpec } from "./platform.js";
11
+ export type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
12
+
13
+ export function getController(): ServiceController {
14
+ switch (detectPlatform()) {
15
+ case "windows":
16
+ return windowsController;
17
+ case "linux":
18
+ return linuxController;
19
+ case "macos":
20
+ return macosController;
21
+ default:
22
+ throw new Error(`Unsupported platform: ${process.platform}`);
23
+ }
24
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Linux service controller — installs a systemd *user* service so no sudo is
3
+ * required. `loginctl enable-linger` is attempted so the bot runs at boot even
4
+ * before you log in.
5
+ */
6
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
7
+ import { homedir, userInfo } from "node:os";
8
+ import { join } from "node:path";
9
+ import { runSafe } from "./platform.js";
10
+ import type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
11
+
12
+ const UNIT = "grok-telegram-bot.service";
13
+
14
+ function unitPath(): string {
15
+ return join(homedir(), ".config", "systemd", "user", UNIT);
16
+ }
17
+
18
+ export const linuxController: ServiceController = {
19
+ platform: "linux",
20
+
21
+ async install(spec) {
22
+ mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
23
+ mkdirSync(spec.logsDir, { recursive: true });
24
+ writeFileSync(unitPath(), unitFile(spec), "utf-8");
25
+
26
+ runSafe("systemctl", ["--user", "daemon-reload"]);
27
+ const en = runSafe("systemctl", ["--user", "enable", "--now", UNIT]);
28
+ if (!en.ok) return fail(`systemctl enable failed: ${en.out}`);
29
+ const linger = runSafe("loginctl", ["enable-linger", userInfo().username]);
30
+ const note = linger.ok ? " Boot-without-login enabled (linger)." : " (run `loginctl enable-linger` for boot-without-login)";
31
+ return ok(`Installed and started systemd user service "${UNIT}".${note}`);
32
+ },
33
+
34
+ async uninstall() {
35
+ runSafe("systemctl", ["--user", "disable", "--now", UNIT]);
36
+ rmSync(unitPath(), { force: true });
37
+ runSafe("systemctl", ["--user", "daemon-reload"]);
38
+ return ok(`Removed systemd user service "${UNIT}".`);
39
+ },
40
+
41
+ async start() {
42
+ const r = runSafe("systemctl", ["--user", "start", UNIT]);
43
+ return r.ok ? ok("Started.") : fail(r.out);
44
+ },
45
+
46
+ async stop() {
47
+ const r = runSafe("systemctl", ["--user", "stop", UNIT]);
48
+ return r.ok ? ok("Stopped.") : fail(r.out);
49
+ },
50
+
51
+ async status() {
52
+ const r = runSafe("systemctl", ["--user", "status", UNIT, "--no-pager"]);
53
+ return ok(r.out.trim() || "No status.");
54
+ },
55
+ };
56
+
57
+ function unitFile(spec: LaunchSpec): string {
58
+ const exec = `${spec.nodePath} ${spec.args.join(" ")}`;
59
+ const env = Object.entries(spec.env ?? {}).map(([k, v]) => `Environment=${k}=${v}`);
60
+ return [
61
+ "[Unit]",
62
+ `Description=${spec.displayName}`,
63
+ "After=network-online.target",
64
+ "Wants=network-online.target",
65
+ "",
66
+ "[Service]",
67
+ "Type=simple",
68
+ `WorkingDirectory=${spec.cwd}`,
69
+ ...env,
70
+ `ExecStart=${exec}`,
71
+ "Restart=always",
72
+ "RestartSec=5",
73
+ "",
74
+ "[Install]",
75
+ "WantedBy=default.target",
76
+ "",
77
+ ].join("\n");
78
+ }
79
+
80
+ function ok(message: string): ServiceResult {
81
+ return { ok: true, message };
82
+ }
83
+ function fail(message: string): ServiceResult {
84
+ return { ok: false, message };
85
+ }