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,218 @@
1
+ /**
2
+ * MCP health probe — performs a real MCP `initialize` JSON-RPC handshake against
3
+ * a configured server to determine whether it actually connects, and why not.
4
+ *
5
+ * • stdio servers → spawn the command (args/env), write `initialize` to stdin,
6
+ * await a matching JSON-RPC response on stdout, then kill the process.
7
+ * • http servers → POST `initialize` to the URL (with headers); accept either
8
+ * a JSON body or an SSE `data:` line (Streamable HTTP transport).
9
+ *
10
+ * This mirrors exactly what an MCP client does on connect, so a success means
11
+ * the server is reachable and speaks MCP; a failure carries the real reason
12
+ * (command not found, timeout, HTTP status, transport error, …).
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { createLogger } from "../logger.js";
16
+ import type { McpProbeResult, McpServer } from "./types.js";
17
+
18
+ const log = createLogger("mcp:probe");
19
+
20
+ const INIT_REQUEST = {
21
+ jsonrpc: "2.0",
22
+ id: 1,
23
+ method: "initialize",
24
+ params: {
25
+ protocolVersion: "2024-11-05",
26
+ capabilities: {},
27
+ clientInfo: { name: "grok-telegram-bot", version: "1.0.0" },
28
+ },
29
+ };
30
+
31
+ export interface ProbeOptions {
32
+ timeoutMs: number;
33
+ concurrency: number;
34
+ }
35
+
36
+ /** Probe a single server. Never throws — failures are returned as results. */
37
+ export async function probeServer(server: McpServer, timeoutMs: number): Promise<McpProbeResult> {
38
+ if (server.disabled) return { name: server.name, ok: false, skipped: true, error: "disabled" };
39
+ const start = Date.now();
40
+ try {
41
+ const info = server.transport === "http" ? await probeHttp(server, timeoutMs) : await probeStdio(server, timeoutMs);
42
+ return { name: server.name, ok: true, ms: Date.now() - start, serverName: info.name, serverVersion: info.version };
43
+ } catch (e) {
44
+ return { name: server.name, ok: false, ms: Date.now() - start, error: (e as Error).message };
45
+ }
46
+ }
47
+
48
+ /** Probe many servers with bounded concurrency. Disabled servers are skipped. */
49
+ export async function probeAll(
50
+ servers: McpServer[],
51
+ opts: ProbeOptions,
52
+ onResult?: (r: McpProbeResult, done: number, total: number) => void,
53
+ ): Promise<McpProbeResult[]> {
54
+ const results: McpProbeResult[] = new Array(servers.length);
55
+ let next = 0;
56
+ let done = 0;
57
+ const total = servers.length;
58
+ const worker = async (): Promise<void> => {
59
+ for (;;) {
60
+ const i = next++;
61
+ if (i >= servers.length) return;
62
+ const r = await probeServer(servers[i]!, opts.timeoutMs);
63
+ results[i] = r;
64
+ done++;
65
+ try {
66
+ onResult?.(r, done, total);
67
+ } catch {
68
+ /* non-fatal */
69
+ }
70
+ }
71
+ };
72
+ const workers = Array.from({ length: Math.max(1, Math.min(opts.concurrency, servers.length)) }, () => worker());
73
+ await Promise.all(workers);
74
+ return results;
75
+ }
76
+
77
+ interface ServerIdent {
78
+ name?: string;
79
+ version?: string;
80
+ }
81
+
82
+ function identFrom(result: unknown): ServerIdent {
83
+ const r = result as { serverInfo?: { name?: string; version?: string } };
84
+ return { name: r?.serverInfo?.name, version: r?.serverInfo?.version };
85
+ }
86
+
87
+ /** stdio handshake: spawn, send initialize, await a JSON-RPC response. */
88
+ function probeStdio(server: McpServer, timeoutMs: number): Promise<ServerIdent> {
89
+ return new Promise<ServerIdent>((resolve, reject) => {
90
+ const cmd = server.config.command;
91
+ if (!cmd) return reject(new Error("no command configured"));
92
+ const args = Array.isArray(server.config.args) ? server.config.args.map(String) : [];
93
+ let settled = false;
94
+ let proc: ReturnType<typeof spawn>;
95
+ try {
96
+ proc = spawn(cmd, args, {
97
+ stdio: ["pipe", "pipe", "pipe"],
98
+ env: { ...process.env, ...(server.config.env ?? {}) },
99
+ windowsHide: true,
100
+ });
101
+ } catch (e) {
102
+ return reject(new Error(`spawn failed: ${(e as Error).message}`));
103
+ }
104
+
105
+ const finish = (err?: Error, ident?: ServerIdent): void => {
106
+ if (settled) return;
107
+ settled = true;
108
+ clearTimeout(timer);
109
+ try {
110
+ proc.kill();
111
+ } catch {
112
+ /* ignore */
113
+ }
114
+ if (err) reject(err);
115
+ else resolve(ident ?? {});
116
+ };
117
+
118
+ const timer = setTimeout(() => finish(new Error(`timeout after ${timeoutMs}ms (no response)`)), timeoutMs);
119
+
120
+ let stderrTail = "";
121
+ let buf = "";
122
+ proc.stdout?.setEncoding("utf-8");
123
+ proc.stdout?.on("data", (chunk: string) => {
124
+ buf += chunk;
125
+ let i: number;
126
+ while ((i = buf.indexOf("\n")) !== -1) {
127
+ const line = buf.slice(0, i).trim();
128
+ buf = buf.slice(i + 1);
129
+ if (!line) continue;
130
+ try {
131
+ const m = JSON.parse(line) as { id?: unknown; result?: unknown; error?: { message?: string } };
132
+ if (m && m.id === 1) {
133
+ if (m.error) finish(new Error(`server error: ${m.error.message ?? "unknown"}`));
134
+ else finish(undefined, identFrom(m.result));
135
+ return;
136
+ }
137
+ } catch {
138
+ /* partial / non-JSON banner line — keep reading */
139
+ }
140
+ }
141
+ });
142
+ proc.stderr?.setEncoding("utf-8");
143
+ proc.stderr?.on("data", (c: string) => {
144
+ stderrTail = (stderrTail + c).slice(-300);
145
+ });
146
+ proc.on("error", (e) => finish(new Error(`spawn failed: ${e.message}`)));
147
+ proc.on("exit", (code) => {
148
+ if (!settled) {
149
+ const tail = stderrTail.trim() ? ` — ${stderrTail.trim().split("\n").pop()}` : "";
150
+ finish(new Error(`process exited (code ${code})${tail}`));
151
+ }
152
+ });
153
+
154
+ try {
155
+ proc.stdin?.write(JSON.stringify(INIT_REQUEST) + "\n");
156
+ } catch (e) {
157
+ finish(new Error(`write failed: ${(e as Error).message}`));
158
+ }
159
+ });
160
+ }
161
+
162
+ /** HTTP handshake: POST initialize; parse JSON or an SSE `data:` payload. */
163
+ async function probeHttp(server: McpServer, timeoutMs: number): Promise<ServerIdent> {
164
+ const url = server.config.url!;
165
+ const controller = new AbortController();
166
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
167
+ try {
168
+ const res = await fetch(url, {
169
+ method: "POST",
170
+ headers: {
171
+ "Content-Type": "application/json",
172
+ Accept: "application/json, text/event-stream",
173
+ ...(server.config.headers ?? {}),
174
+ },
175
+ body: JSON.stringify(INIT_REQUEST),
176
+ signal: controller.signal,
177
+ });
178
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`.trim());
179
+ const text = await res.text();
180
+ const parsed = parseJsonOrSse(text);
181
+ if (!parsed) throw new Error("no JSON-RPC result in response");
182
+ if (parsed.error) throw new Error(`server error: ${parsed.error.message ?? "unknown"}`);
183
+ return identFrom(parsed.result);
184
+ } catch (e) {
185
+ const msg = (e as Error).name === "AbortError" ? `timeout after ${timeoutMs}ms` : (e as Error).message;
186
+ throw new Error(msg);
187
+ } finally {
188
+ clearTimeout(timer);
189
+ }
190
+ }
191
+
192
+ interface RpcEnvelope {
193
+ result?: unknown;
194
+ error?: { message?: string };
195
+ }
196
+
197
+ /** Accept a plain JSON body or SSE frames (`event: …\n data: {json}`). */
198
+ function parseJsonOrSse(text: string): RpcEnvelope | undefined {
199
+ const trimmed = text.trim();
200
+ if (!trimmed) return undefined;
201
+ try {
202
+ return JSON.parse(trimmed) as RpcEnvelope;
203
+ } catch {
204
+ /* maybe SSE */
205
+ }
206
+ for (const line of trimmed.split("\n")) {
207
+ const m = /^data:\s*(.+)$/.exec(line.trim());
208
+ if (m) {
209
+ try {
210
+ return JSON.parse(m[1]!) as RpcEnvelope;
211
+ } catch {
212
+ /* keep scanning */
213
+ }
214
+ }
215
+ }
216
+ log.debug("unparseable MCP HTTP response:", trimmed.slice(0, 120));
217
+ return undefined;
218
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Types for MCP (Model Context Protocol) server inspection & control.
3
+ *
4
+ * Grok CLI loads MCP servers from JSON config files: a global one at
5
+ * `~/.grok/settings/mcp.json` (the "default" scope used by the default agent)
6
+ * and an optional per-workspace `<cwd>/.grok/settings/mcp.json`. Each server may
7
+ * carry a `disabled` flag; toggling it enables/disables the server (applied the
8
+ * next time the agent (re)loads — i.e. after `/restart` or a new session).
9
+ */
10
+
11
+ export type McpScope = "global" | "workspace";
12
+ export type McpTransport = "http" | "stdio" | "unknown";
13
+
14
+ /** Raw server definition as stored in an mcp.json `mcpServers` entry. */
15
+ export interface McpServerConfig {
16
+ command?: string;
17
+ args?: string[];
18
+ url?: string;
19
+ headers?: Record<string, string>;
20
+ env?: Record<string, string>;
21
+ timeout?: number;
22
+ disabled?: boolean;
23
+ autoApprove?: string[];
24
+ [k: string]: unknown;
25
+ }
26
+
27
+ /** A configured server resolved from a specific config file. */
28
+ export interface McpServer {
29
+ name: string;
30
+ scope: McpScope;
31
+ /** Absolute path of the config file this server is defined in. */
32
+ configPath: string;
33
+ disabled: boolean;
34
+ transport: McpTransport;
35
+ /** Short transport descriptor for display (command or url, trimmed). */
36
+ detail: string;
37
+ config: McpServerConfig;
38
+ }
39
+
40
+ /** Result of a live connection probe (MCP `initialize` handshake). */
41
+ export interface McpProbeResult {
42
+ name: string;
43
+ ok: boolean;
44
+ /** Round-trip time in ms when ok. */
45
+ ms?: number;
46
+ /** Server-reported name/version when ok. */
47
+ serverName?: string;
48
+ serverVersion?: string;
49
+ /** Human-readable failure reason when not ok. */
50
+ error?: string;
51
+ /** True when the server was skipped because it is disabled. */
52
+ skipped?: boolean;
53
+ }
54
+
55
+ export function transportOf(c: McpServerConfig): McpTransport {
56
+ if (typeof c.url === "string" && c.url.trim()) return "http";
57
+ if (typeof c.command === "string" && c.command.trim()) return "stdio";
58
+ return "unknown";
59
+ }
60
+
61
+ export function detailOf(c: McpServerConfig): string {
62
+ if (typeof c.url === "string" && c.url.trim()) return c.url.trim();
63
+ if (typeof c.command === "string" && c.command.trim()) {
64
+ const args = Array.isArray(c.args) && c.args.length ? " " + c.args.join(" ") : "";
65
+ return (c.command + args).trim();
66
+ }
67
+ return "(no command/url)";
68
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Project manager — discovers candidate project directories under the
3
+ * configured roots, de-duplicated by name, with search and create helpers.
4
+ */
5
+ import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
6
+ import { basename, join } from "node:path";
7
+ import { createLogger } from "../logger.js";
8
+
9
+ const log = createLogger("projects");
10
+
11
+ const IGNORE = new Set([
12
+ "node_modules",
13
+ ".git",
14
+ ".history",
15
+ "dist",
16
+ "build",
17
+ "out",
18
+ ".cache",
19
+ "target",
20
+ ".venv",
21
+ "__pycache__",
22
+ ]);
23
+
24
+ export interface ProjectEntry {
25
+ name: string;
26
+ path: string;
27
+ /** Best-known "last used" time (epoch ms) — directory mtime by default,
28
+ * refined with Grok session activity by the caller. Drives freshest-first. */
29
+ lastUsed: number;
30
+ }
31
+
32
+ export class ProjectManager {
33
+ constructor(private readonly roots: string[]) {}
34
+
35
+ /** List projects, de-duplicated by (case-insensitive) name. */
36
+ list(limit = 100): ProjectEntry[] {
37
+ const byName = new Map<string, ProjectEntry>();
38
+
39
+ for (const root of this.roots) {
40
+ let children: string[];
41
+ try {
42
+ children = readdirSync(root);
43
+ } catch (e) {
44
+ log.debug(`cannot read root ${root}:`, (e as Error).message);
45
+ continue;
46
+ }
47
+ for (const child of children) {
48
+ if (IGNORE.has(child) || child.startsWith(".")) continue;
49
+ const full = join(root, child);
50
+ let mtime = 0;
51
+ try {
52
+ const st = statSync(full);
53
+ if (!st.isDirectory()) continue;
54
+ mtime = st.mtimeMs;
55
+ } catch {
56
+ continue;
57
+ }
58
+ const key = child.toLowerCase();
59
+ if (!byName.has(key)) byName.set(key, { name: child, path: full, lastUsed: mtime });
60
+ }
61
+ }
62
+
63
+ // Freshest first (directory mtime); callers may refine `lastUsed` with
64
+ // session activity and re-sort. Alphabetical as a stable tiebreak.
65
+ const out = [...byName.values()].sort(
66
+ (a, b) => b.lastUsed - a.lastUsed || a.name.localeCompare(b.name),
67
+ );
68
+ return out.slice(0, limit);
69
+ }
70
+
71
+ /** Projects whose name contains the query (case-insensitive). */
72
+ search(query: string, limit = 100): ProjectEntry[] {
73
+ const q = query.trim().toLowerCase();
74
+ if (!q) return this.list(limit);
75
+ return this.list(1000)
76
+ .filter((p) => p.name.toLowerCase().includes(q))
77
+ .slice(0, limit);
78
+ }
79
+
80
+ /** Create a new project folder under the first root and return it. */
81
+ create(name: string): ProjectEntry {
82
+ const clean = name.trim().replace(/[<>:"/\\|?*]/g, "_");
83
+ if (!clean) throw new Error("Invalid project name.");
84
+ const root = this.roots[0];
85
+ if (!root) throw new Error("No project root configured (set PROJECT_ROOTS).");
86
+ const full = join(root, clean);
87
+ if (existsSync(full)) throw new Error(`"${clean}" already exists in ${root}.`);
88
+ mkdirSync(full, { recursive: true });
89
+ return { name: clean, path: full, lastUsed: Date.now() };
90
+ }
91
+
92
+ isDirectory(path: string): boolean {
93
+ try {
94
+ return statSync(path).isDirectory();
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Split a MarkdownV2 string into Telegram-sized chunks (<= 4096 chars) without
3
+ * breaking code fences. If a split happens inside a ``` block, the block is
4
+ * closed before the boundary and reopened in the next chunk.
5
+ */
6
+ const LIMIT = 4000; // headroom under Telegram's 4096 hard limit
7
+
8
+ export function chunkMarkdown(text: string, limit = LIMIT): string[] {
9
+ if (text.length <= limit) return text.length ? [text] : [];
10
+
11
+ const lines = text.split("\n");
12
+ const chunks: string[] = [];
13
+ let current: string[] = [];
14
+ let size = 0;
15
+ let fenceLang: string | null = null; // non-null => currently inside a fence
16
+
17
+ const flush = (): void => {
18
+ if (current.length === 0) return;
19
+ let body = current.join("\n");
20
+ if (fenceLang !== null) body += "\n```"; // close dangling fence
21
+ chunks.push(body);
22
+ current = [];
23
+ size = 0;
24
+ if (fenceLang !== null) {
25
+ // Reopen the fence at the top of the next chunk.
26
+ const reopen = "```" + fenceLang;
27
+ current.push(reopen);
28
+ size = reopen.length + 1;
29
+ }
30
+ };
31
+
32
+ for (const rawLine of lines) {
33
+ const line = rawLine;
34
+ const fenceMatch = /^```(.*)$/.exec(line);
35
+
36
+ // Hard-split a single oversized line.
37
+ if (line.length + 1 > limit && fenceMatch === null) {
38
+ flush();
39
+ for (let i = 0; i < line.length; i += limit) {
40
+ chunks.push(line.slice(i, i + limit));
41
+ }
42
+ continue;
43
+ }
44
+
45
+ if (size + line.length + 1 > limit) flush();
46
+
47
+ current.push(line);
48
+ size += line.length + 1;
49
+
50
+ if (fenceMatch) {
51
+ fenceLang = fenceLang === null ? (fenceMatch[1] ?? "").trim() : null;
52
+ }
53
+ }
54
+
55
+ flush();
56
+ return chunks.filter((c) => c.trim().length > 0);
57
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Render a unified diff for a file edit as RAW markdown (a ```diff fenced
3
+ * block). Escaping/splitting is handled downstream by the markdown converter.
4
+ */
5
+ import { structuredPatch } from "diff";
6
+
7
+ export interface DiffInput {
8
+ path: string;
9
+ oldText: string | null | undefined;
10
+ newText: string | null | undefined;
11
+ maxLines: number;
12
+ }
13
+
14
+ export interface DiffResult {
15
+ block: string; // raw ```diff fenced markdown, or ""
16
+ added: number;
17
+ removed: number;
18
+ }
19
+
20
+ export function renderUnifiedDiff(input: DiffInput): DiffResult {
21
+ const oldText = input.oldText ?? "";
22
+ const newText = input.newText ?? "";
23
+ if (oldText === newText) return { block: "", added: 0, removed: 0 };
24
+
25
+ const patch = structuredPatch(input.path, input.path, oldText, newText, "", "", { context: 2 });
26
+ const lines: string[] = [];
27
+ let added = 0;
28
+ let removed = 0;
29
+
30
+ for (const hunk of patch.hunks) {
31
+ lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
32
+ for (const l of hunk.lines) {
33
+ if (l.startsWith("\\")) continue; // drop ""
34
+ if (l.startsWith("+")) added++;
35
+ else if (l.startsWith("-")) removed++;
36
+ lines.push(l);
37
+ }
38
+ }
39
+ if (lines.length === 0) return { block: "", added, removed };
40
+
41
+ let shown = lines;
42
+ let note = "";
43
+ if (lines.length > input.maxLines) {
44
+ shown = lines.slice(0, input.maxLines);
45
+ note = `\n… +${lines.length - input.maxLines} more lines`;
46
+ }
47
+ return { block: "```diff\n" + shown.join("\n") + note + "\n```", added, removed };
48
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Telegram MarkdownV2 escaping helpers.
3
+ * @see https://core.telegram.org/bots/api#markdownv2-style
4
+ */
5
+
6
+ // Characters that must be escaped in normal MarkdownV2 text.
7
+ const SPECIAL = /[_*\[\]()~`>#+\-=|{}.!\\]/g;
8
+
9
+ /** Escape text that appears in normal (non-entity) MarkdownV2 context. */
10
+ export function escapeMdV2(text: string): string {
11
+ return text.replace(SPECIAL, (c) => `\\${c}`);
12
+ }
13
+
14
+ /** Escape the body of an inline code span or code block (only ` and \). */
15
+ export function escapeCode(text: string): string {
16
+ return text.replace(/([`\\])/g, "\\$1");
17
+ }
18
+
19
+ /** Escape a URL used inside a MarkdownV2 link target. */
20
+ export function escapeUrl(url: string): string {
21
+ return url.replace(/([)\\])/g, "\\$1");
22
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * File-change summary for the turn-completion message. We classify each
3
+ * tool-call update into a file operation (created / edited / deleted / moved)
4
+ * and render a compact summary — independent of whether the turn streamed live,
5
+ * so a background session's "Done" still reports what changed.
6
+ */
7
+ import type { SessionUpdate, ToolCallContent } from "../grok/types.js";
8
+
9
+ export type FileOp = "created" | "edited" | "deleted" | "moved";
10
+
11
+ /** Tool kinds that never modify files. */
12
+ const NON_FILE = new Set(["read", "search", "fetch", "execute", "think", "other", ""]);
13
+
14
+ /** Merge priority — a stronger signal wins (deleted > moved > edited > created). */
15
+ const RANK: Record<FileOp, number> = { created: 1, edited: 2, moved: 3, deleted: 4 };
16
+
17
+ const SIGN: Record<FileOp, string> = { created: "+", edited: "~", deleted: "\u2212", moved: "\u2192" };
18
+ const ORDER: Record<FileOp, number> = { created: 0, edited: 1, deleted: 2, moved: 3 };
19
+
20
+ /** The file path + operation a tool-call update represents, or undefined. */
21
+ export function fileOpFromUpdate(u: SessionUpdate): { path: string; op: FileOp } | undefined {
22
+ const kind = (u.kind || "").toLowerCase();
23
+ if (NON_FILE.has(kind)) return undefined;
24
+
25
+ const raw = (u.rawInput || {}) as Record<string, unknown>;
26
+ const diff = findDiff(u);
27
+ const path =
28
+ str(diff?.path) || str(raw.path) || str(raw.file_path) || str(raw.filename) || pathFromTitle(u.title);
29
+ if (!path) return undefined;
30
+
31
+ if (kind === "delete") return { path, op: "deleted" };
32
+ if (kind === "move" || kind === "rename") return { path, op: "moved" };
33
+
34
+ // Edit-like: classify by whether the file had prior content.
35
+ const oldText = diff && typeof diff.oldText === "string" ? diff.oldText : str(raw.old_str ?? raw.oldStr);
36
+ const newText =
37
+ diff && typeof diff.newText === "string"
38
+ ? diff.newText
39
+ : str(raw.new_str ?? raw.newStr ?? raw.file_text ?? raw.content ?? raw.text);
40
+ const hasOld = oldText.trim().length > 0;
41
+ const hasNew = newText.trim().length > 0;
42
+ if (hasOld && !hasNew) return { path, op: "deleted" };
43
+ if (!hasOld && hasNew) return { path, op: "created" };
44
+ return { path, op: "edited" };
45
+ }
46
+
47
+ /** Combine a new op into an existing one for the same path (stronger wins). */
48
+ export function mergeFileOp(prev: FileOp | undefined, next: FileOp): FileOp {
49
+ if (!prev) return next;
50
+ return RANK[next] > RANK[prev] ? next : prev;
51
+ }
52
+
53
+ /**
54
+ * Render the file-change summary appended to a turn's completion message.
55
+ * Always returns a line — "No files modified" when nothing changed.
56
+ */
57
+ export function summarizeFileOps(ops: Map<string, FileOp>, cwd: string, maxList = 15): string {
58
+ if (ops.size === 0) return "\u{1F4C4} No files modified";
59
+
60
+ const entries = [...ops.entries()].sort(
61
+ (a, b) => ORDER[a[1]] - ORDER[b[1]] || a[0].localeCompare(b[0]),
62
+ );
63
+ const shown = entries.slice(0, maxList).map(([p, op]) => `${SIGN[op]} ${rel(cwd, p)}`);
64
+ const more = entries.length > maxList ? `\n \u2026and ${entries.length - maxList} more` : "";
65
+
66
+ return `\u{1F4DD} ${countsLine(ops)}\n ${shown.join("\n ")}${more}`;
67
+ }
68
+
69
+ /** Compact, one-line counts (no file list) — used for "other session" pings. */
70
+ export function summarizeFileOpsShort(ops: Map<string, FileOp>): string {
71
+ return ops.size === 0 ? "\u{1F4C4} No files modified" : `\u{1F4DD} ${countsLine(ops)}`;
72
+ }
73
+
74
+ /** "+2 created · ~3 edited · −1 deleted" — only the non-zero buckets. */
75
+ function countsLine(ops: Map<string, FileOp>): string {
76
+ const counts: Record<FileOp, number> = { created: 0, edited: 0, deleted: 0, moved: 0 };
77
+ for (const op of ops.values()) counts[op]++;
78
+ const parts: string[] = [];
79
+ if (counts.created) parts.push(`+${counts.created} created`);
80
+ if (counts.edited) parts.push(`~${counts.edited} edited`);
81
+ if (counts.deleted) parts.push(`\u2212${counts.deleted} deleted`);
82
+ if (counts.moved) parts.push(`\u2192${counts.moved} moved`);
83
+ return parts.join(" \u00B7 ");
84
+ }
85
+
86
+ function findDiff(u: SessionUpdate): ToolCallContent | undefined {
87
+ const blocks: ToolCallContent[] = [];
88
+ if (Array.isArray(u.content_blocks)) blocks.push(...u.content_blocks);
89
+ const content = (u as unknown as { content?: unknown }).content;
90
+ if (Array.isArray(content)) blocks.push(...(content as ToolCallContent[]));
91
+ return blocks.find((b) => b.type === "diff");
92
+ }
93
+
94
+ function pathFromTitle(title?: string): string {
95
+ if (!title) return "";
96
+ // Titles look like "Edit src/foo.ts" / "Create /abs/path" — take the last token.
97
+ const m = title.trim().match(/(\S+)\s*$/);
98
+ const tok = m?.[1] ?? "";
99
+ return /[\\/.]/.test(tok) ? tok : "";
100
+ }
101
+
102
+ /** Display a path relative to the session's cwd when it lives under it. */
103
+ function rel(cwd: string, p: string): string {
104
+ const np = p.replace(/\\/g, "/");
105
+ const c = cwd.replace(/\\/g, "/").replace(/\/+$/, "") + "/";
106
+ return np.startsWith(c) ? np.slice(c.length) : np;
107
+ }
108
+
109
+ function str(v: unknown): string {
110
+ return typeof v === "string" ? v : "";
111
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Searchable Telegram hashtags for a session's messages. Tapping a tag in
3
+ * Telegram pulls up every message that carries it, so the SAME footer is
4
+ * appended to every AI-output surface: live streams, Done/error summaries, the
5
+ * history/unread you see when switching back to a session, and live watch.
6
+ */
7
+ import { basename } from "node:path";
8
+
9
+ export interface TagInput {
10
+ projectName?: string;
11
+ cwd?: string;
12
+ sessionId?: string;
13
+ }
14
+
15
+ /** Sanitise a value into a Telegram-safe hashtag body (letters/digits/_ only). */
16
+ export function tagSafe(v: string): string {
17
+ const s = v
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, "_")
20
+ .replace(/^_+|_+$/g, "")
21
+ .slice(0, 40);
22
+ return s || "none";
23
+ }
24
+
25
+ /**
26
+ * Build the hashtag footer. `#proj_` is always present; `#sess_` is added only
27
+ * when the session id is known, so partial callers (e.g. a static /history view)
28
+ * still tag consistently. Order: project · session.
29
+ */
30
+ export function sessionHashtags(input: TagInput): string {
31
+ const tags = [`#proj_${tagSafe(input.projectName || basename(input.cwd || "") || "none")}`];
32
+ if (input.sessionId) tags.push(`#sess_${tagSafe(input.sessionId.slice(0, 8))}`);
33
+ return tags.join(" ");
34
+ }