grok-telegram-bot 2.3.1 → 2.5.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 (88) hide show
  1. package/.env.example +64 -2
  2. package/CHANGELOG.md +156 -1
  3. package/README.md +58 -15
  4. package/docs/GROUP.md +225 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +1 -1
  7. package/src/app/accounts.ts +84 -0
  8. package/src/app/instance-lock.ts +6 -0
  9. package/src/app/lifetime-flag.ts +20 -0
  10. package/src/app/settings-store.ts +47 -8
  11. package/src/app/types.ts +30 -2
  12. package/src/app/updater.ts +38 -6
  13. package/src/app/usage.ts +204 -7
  14. package/src/bot/account-rotator.ts +10 -0
  15. package/src/bot/auth.ts +96 -15
  16. package/src/bot/bot.ts +154 -11
  17. package/src/bot/chat-controller.ts +82 -13
  18. package/src/bot/commands.ts +69 -27
  19. package/src/bot/complexity-gate.ts +69 -0
  20. package/src/bot/deps.ts +22 -0
  21. package/src/bot/group-memory.ts +159 -0
  22. package/src/bot/handlers/accounts.ts +58 -1
  23. package/src/bot/handlers/control.ts +85 -32
  24. package/src/bot/handlers/document.ts +31 -4
  25. package/src/bot/handlers/forum.ts +207 -0
  26. package/src/bot/handlers/import-session.ts +290 -0
  27. package/src/bot/handlers/menu.ts +102 -61
  28. package/src/bot/handlers/message.ts +102 -21
  29. package/src/bot/handlers/photo.ts +123 -16
  30. package/src/bot/handlers/running.ts +172 -16
  31. package/src/bot/handlers/session-card.ts +20 -0
  32. package/src/bot/handlers/sessions.ts +76 -15
  33. package/src/bot/handlers/usage.ts +118 -16
  34. package/src/bot/handlers/voice.ts +52 -7
  35. package/src/bot/image-return.ts +8 -5
  36. package/src/bot/menu/ephemeral.ts +13 -3
  37. package/src/bot/menu/keyboard.ts +54 -14
  38. package/src/bot/menu/refresh.ts +3 -1
  39. package/src/bot/menu/status-panel.ts +25 -6
  40. package/src/bot/permission-service.ts +19 -0
  41. package/src/bot/prompt-anchor.ts +300 -0
  42. package/src/bot/prompt-content.ts +7 -0
  43. package/src/bot/registry.ts +94 -1
  44. package/src/bot/scope.ts +94 -0
  45. package/src/bot/session-fork.ts +11 -0
  46. package/src/bot/session-runtime.ts +1254 -83
  47. package/src/bot/suggestions.ts +489 -0
  48. package/src/bot/telegram-actions.ts +440 -0
  49. package/src/bot/telegram-bots.ts +495 -0
  50. package/src/bot/telegram-io.ts +94 -10
  51. package/src/cli.ts +2 -0
  52. package/src/config.ts +242 -2
  53. package/src/forum/bind-path.ts +146 -0
  54. package/src/forum/manager.ts +651 -0
  55. package/src/forum/project-icon.ts +142 -0
  56. package/src/forum/thread.ts +16 -0
  57. package/src/forum/topic-store.ts +114 -0
  58. package/src/forum/types.ts +29 -0
  59. package/src/grok/client.ts +214 -37
  60. package/src/grok/plan-approval.ts +72 -0
  61. package/src/grok/session-log.ts +16 -0
  62. package/src/grok/types.ts +21 -2
  63. package/src/import/build-import.ts +132 -0
  64. package/src/import/history-readers.ts +681 -0
  65. package/src/import/list-running.ts +100 -0
  66. package/src/import/sources.ts +78 -0
  67. package/src/index.ts +315 -30
  68. package/src/projects/manager.ts +16 -3
  69. package/src/render/chunk.ts +17 -10
  70. package/src/render/diff.ts +11 -2
  71. package/src/render/file-summary.ts +31 -1
  72. package/src/render/hashtags.ts +5 -1
  73. package/src/render/markdown.ts +293 -35
  74. package/src/render/plan.ts +127 -0
  75. package/src/render/session-comment.ts +318 -0
  76. package/src/render/telegram-bridge.ts +360 -0
  77. package/src/render/tool-call-detail.ts +400 -19
  78. package/src/render/tool-call-merge.ts +115 -0
  79. package/src/render/tool-call.ts +444 -162
  80. package/src/render/truncate.ts +85 -0
  81. package/src/service/platform.ts +44 -7
  82. package/src/service/windows.ts +30 -6
  83. package/src/sessions/history.ts +98 -0
  84. package/src/sessions/process.ts +7 -0
  85. package/src/sessions/store.ts +3 -0
  86. package/src/sessions/types.ts +5 -0
  87. package/src/stream/streamer.ts +90 -15
  88. package/src/tasks/runner.ts +4 -3
@@ -1,13 +1,17 @@
1
- /**
1
+ /**
2
2
  * Format ACP tool-call updates into clear, RAW markdown blocks so they read
3
3
  * distinctly from the agent's prose and thinking. Each tool kind gets its own
4
4
  * rich detail: commands in bash blocks, diffs in diff blocks, search queries,
5
- * file paths, URLs, content previews, move/rename source+dest, delete paths.
5
+ * file paths (with offset/limit), URLs, content previews, MCP args.
6
+ *
7
+ * Grok often reports kind "other" with title/name `read_file` / `grep` / etc.
8
+ * {@link resolveToolIdentity} maps those to real display kinds so users never
9
+ * see a bare "Other" or a false "Call MCP: read_file".
6
10
  */
7
11
  import type { SessionUpdate } from "../grok/types.js";
8
12
  import { renderUnifiedDiff } from "./diff.js";
9
13
  import {
10
- normalizeKind,
14
+ resolveToolIdentity,
11
15
  extractPath,
12
16
  extractDestPath,
13
17
  extractSearchQuery,
@@ -16,28 +20,40 @@ import {
16
20
  extractCommand,
17
21
  extractContent,
18
22
  extractFilters,
23
+ extractReadRange,
24
+ extractToolOutput,
25
+ extractToolName,
26
+ formatArgLines,
19
27
  truncate,
20
28
  collectContent,
21
29
  gatherPaths,
22
30
  PREVIEW_MAX,
23
31
  CONTENT_PREVIEW_MAX,
32
+ OUTPUT_PREVIEW_MAX,
33
+ OUTPUT_PREVIEW_LINES,
34
+ type ToolDisplayKind,
24
35
  } from "./tool-call-detail.js";
36
+ import { formatLiveTerminalOutput, truncateMiddle, truncateMiddleLines } from "./truncate.js";
25
37
 
26
38
  const KIND_ICON: Record<string, string> = {
27
- read: "\u{1F4D6}",
28
- edit: "\u270F\uFE0F",
29
- write: "\u{1F4DD}",
39
+ read: "\u{1F4D6}", // 📖
40
+ edit: "\u270F\uFE0F", // ✏️
41
+ write: "\u{1F4DD}", // 📝
30
42
  create: "\u{1F4DD}",
31
- execute: "\u{1F4BB}",
32
- search: "\u{1F50E}",
33
- delete: "\u{1F5D1}\uFE0F",
34
- move: "\u{1F4E6}",
43
+ execute: "\u{1F4BB}", // 💻
44
+ search: "\u{1F50E}", // 🔎
45
+ delete: "\u{1F5D1}\uFE0F", // 🗑️
46
+ move: "\u{1F4E6}", // 📦
35
47
  rename: "\u{1F4E6}",
36
- fetch: "\u{1F310}",
48
+ fetch: "\u{1F310}", // 🌐
37
49
  web_search: "\u{1F310}",
38
50
  web_fetch: "\u{1F310}",
39
- think: "\u{1F4AD}",
40
- other: "\u{1F527}",
51
+ list: "\u{1F4C1}", // 📁
52
+ todo: "\u2705", // ✅
53
+ think: "\u{1F4AD}", // 💭
54
+ image: "\u{1F5BC}\uFE0F", // 🖼️
55
+ mcp: "\u{1F9E9}", // 🧩
56
+ other: "\u{1F527}", // 🔧
41
57
  };
42
58
 
43
59
  const STATUS_ICON: Record<string, string> = {
@@ -54,39 +70,74 @@ export interface ToolFormatOptions {
54
70
 
55
71
  /** Returns a RAW markdown block describing the tool call, or "" to skip. */
56
72
  export function formatToolCall(u: SessionUpdate, opts: ToolFormatOptions): string {
57
- const kind = normalizeKind(u.kind);
58
- const raw = (u.rawInput || {}) as Record<string, unknown>;
73
+ const raw = flattenRawInput(u);
74
+ // Inject path from ACP locations when rawInput is thin.
75
+ if (u.locations?.[0]?.path && !raw.path && !raw.target_file && !raw.file_path) {
76
+ raw.path = u.locations[0].path;
77
+ if (u.locations[0].line != null && raw.start_line == null) raw.start_line = u.locations[0].line;
78
+ }
79
+ const id = resolveToolIdentity(u, raw);
59
80
  const status = u.status ? (STATUS_ICON[u.status] ?? "") : "";
60
81
  const tail = status ? " " + status : "";
61
82
 
62
- // Skill load
63
- if (kind !== "edit" && kind !== "delete" && kind !== "move" && kind !== "write" && kind !== "create") {
83
+ // Plan mode enter/exit — surface clearly (exit failures used to look opaque).
84
+ const planCard = formatPlanModeTool(u, raw, id.toolName, tail);
85
+ if (planCard) return planCard;
86
+
87
+ // Skill load (SKILL.md path) — not a normal edit.
88
+ if (id.kind !== "edit" && id.kind !== "delete" && id.kind !== "move" && id.kind !== "write" && id.kind !== "create") {
64
89
  const skill = detectSkill(u, raw);
65
90
  if (skill) return "\u{1F4DA} **Loaded skill: " + skill + "**" + tail;
66
91
  }
67
92
 
68
- // MCP / extension tool call
69
- const mcp = detectMcp(u, raw, kind);
70
- if (mcp) {
71
- const label = mcp.server ? "Call MCP " + mcp.server + ": " + mcp.method : "Call MCP: " + mcp.method;
72
- let out = "\u{1F9E9} **" + label + "**" + tail;
73
- const argPreview = mcpArgPreview(raw);
74
- if (argPreview) out += "\n" + fence(argPreview) + "\n";
93
+ // Real MCP (namespaced) — badge + rich method-specific detail when possible.
94
+ if (id.isMcp) {
95
+ const label = id.mcpServer
96
+ ? `\u{1F9E9} **MCP ${id.mcpServer} \u00B7 ${id.mcpMethod || id.toolName}**${tail}`
97
+ : `\u{1F9E9} **MCP: ${id.mcpMethod || id.toolName}**${tail}`;
98
+ // Prefer rich formatter for known methods (read/search/…); keep MCP header.
99
+ if (id.kind !== "mcp" && id.kind !== "other") {
100
+ const body = formatByKind(id.kind, u, raw, "", opts, id.mcpMethod || id.toolName);
101
+ return label + "\n" + body;
102
+ }
103
+ let out = label;
104
+ const args = formatArgLines(raw);
105
+ if (args) out += "\n" + fence(truncateMiddle(args, PREVIEW_MAX)) + "\n";
106
+ const result = extractToolOutput(u);
107
+ if (result) {
108
+ out +=
109
+ "\n**Output:**\n" +
110
+ fence(truncateMiddleLines(result, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX)) +
111
+ "\n";
112
+ }
75
113
  return out;
76
114
  }
77
115
 
116
+ return formatByKind(id.kind, u, raw, tail, opts, id.toolName);
117
+ }
118
+
119
+ function formatByKind(
120
+ kind: ToolDisplayKind | string,
121
+ u: SessionUpdate,
122
+ raw: Record<string, unknown>,
123
+ tail: string,
124
+ opts: ToolFormatOptions,
125
+ toolName: string,
126
+ ): string {
78
127
  switch (kind) {
79
128
  case "execute":
80
- return formatExecute(raw, tail);
129
+ return formatExecute(u, raw, tail, toolName);
81
130
  case "edit":
82
- return formatEdit(u, raw, tail, opts);
131
+ return formatEdit(u, raw, tail, opts, toolName);
83
132
  case "write":
84
133
  case "create":
85
- return formatWrite(kind, raw, tail);
134
+ return formatWrite(u, kind, raw, tail);
86
135
  case "read":
87
- return formatRead(raw, tail);
136
+ return formatRead(u, raw, tail, toolName);
137
+ case "list":
138
+ return formatList(u, raw, tail, toolName);
88
139
  case "search":
89
- return formatSearch(raw, tail);
140
+ return formatSearch(u, raw, tail, toolName);
90
141
  case "delete":
91
142
  return formatDelete(raw, tail);
92
143
  case "move":
@@ -94,37 +145,106 @@ export function formatToolCall(u: SessionUpdate, opts: ToolFormatOptions): strin
94
145
  return formatMove(kind, raw, tail);
95
146
  case "fetch":
96
147
  case "web_fetch":
97
- return formatFetch(raw, tail);
148
+ return formatFetch(u, raw, tail);
98
149
  case "web_search":
99
- return formatWebSearch(raw, tail);
150
+ return formatWebSearch(u, raw, tail);
151
+ case "todo":
152
+ return formatTodo(raw, tail, toolName);
153
+ case "think":
154
+ return formatThink(raw, tail, toolName, u);
155
+ case "image":
156
+ return formatImage(raw, tail, toolName);
100
157
  default:
101
- return formatGeneric(u, raw, tail, kind);
158
+ return formatGeneric(u, raw, tail, kind, toolName);
102
159
  }
103
160
  }
104
161
 
105
- // ---- helpers for code fences (avoid backtick-in-template-literal issues) ----
162
+ // ---- helpers for code fences / path labels ----
163
+
164
+ /**
165
+ * File paths in **bold** break Telegram MarkdownV2 (Windows `\`, dots, long
166
+ * session paths). Always put paths in inline code; keep only the verb bold.
167
+ * ✏️ **Edit** `C:\Users\…\plan.md`
168
+ */
169
+ function pathCode(path: string | undefined | null, fallback = "file"): string {
170
+ const p = (path || "").trim() || fallback;
171
+ // Inline code cannot contain raw backticks unescaped in our MD pipeline;
172
+ // replace rare ` in paths so the span stays closed.
173
+ return "`" + p.replace(/`/g, "'") + "`";
174
+ }
175
+
176
+ /** `**Edit** \`path\`` style header fragment (no leading emoji). */
177
+ function boldVerbPath(verb: string, path: string | undefined | null, fallback = "file"): string {
178
+ return "**" + verb + "** " + pathCode(path, fallback);
179
+ }
106
180
 
107
- /** Wrap text in a fenced code block with optional language. */
181
+ /** Fence that lengthens itself when the body contains backticks (avoids MD break). */
108
182
  function fence(text: string, lang?: string): string {
109
- const marker = "```";
183
+ let tickLen = 3;
184
+ const runs = text.match(/`+/g);
185
+ if (runs) {
186
+ const max = Math.max(...runs.map((r) => r.length));
187
+ if (max >= tickLen) tickLen = max + 1;
188
+ }
189
+ const marker = "`".repeat(tickLen);
110
190
  return marker + (lang || "") + "\n" + text + "\n" + marker;
111
191
  }
112
192
 
193
+ /** Merge rawInput with nested arguments/input objects Grok sometimes uses. */
194
+ function flattenRawInput(u: SessionUpdate): Record<string, unknown> {
195
+ const raw = { ...((u.rawInput || {}) as Record<string, unknown>) };
196
+ for (const key of ["arguments", "input", "args", "parameters"]) {
197
+ const nested = raw[key];
198
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
199
+ for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
200
+ if (raw[k] === undefined) raw[k] = v;
201
+ }
202
+ }
203
+ }
204
+ return raw;
205
+ }
206
+
113
207
  // ---- per-kind formatters ----
114
208
 
115
- function formatExecute(raw: Record<string, unknown>, tail: string): string {
209
+ function formatExecute(
210
+ u: SessionUpdate,
211
+ raw: Record<string, unknown>,
212
+ tail: string,
213
+ toolName: string,
214
+ ): string {
116
215
  const cmd = extractCommand(raw);
117
- const cwd = strOf(raw.cwd);
118
- const title = "Run command" + (cwd ? " in " + truncate(cwd, 80) : "");
119
- let out = "\u{1F4BB} **" + title + "**" + tail;
120
- if (cmd) out += "\n" + fence(truncate(cmd, PREVIEW_MAX), "bash") + "\n";
216
+ const cwd = strOf(raw.cwd) || strOf(raw.working_directory) || strOf(raw.workingDirectory);
217
+ let out = "\u{1F4BB} **Run command**" + tail;
218
+ if (cwd) out += " in " + pathCode(truncate(cwd, 80));
219
+ if (toolName && toolName !== "execute" && toolName !== "shell" && toolName !== "bash") {
220
+ out += `\n tool: \`${toolName}\``;
221
+ }
222
+ if (cmd) out += "\n" + fence(truncateMiddle(cmd, PREVIEW_MAX), "bash") + "\n";
223
+ else {
224
+ // No command field — dump args so the user still sees what ran.
225
+ const args = formatArgLines(raw);
226
+ if (args) out += "\n" + fence(truncateMiddle(args, PREVIEW_MAX)) + "\n";
227
+ }
228
+ // Display: first output line + live tail (one block, updated in place via upsert).
229
+ // Full stdout remains in the agent session / merged tool snapshot.
230
+ const result = extractToolOutput(u);
231
+ if (result) {
232
+ const live = formatLiveTerminalOutput(result, 12, OUTPUT_PREVIEW_MAX);
233
+ out += "\n**Output:**\n" + fence(live) + "\n";
234
+ }
121
235
  return out;
122
236
  }
123
237
 
124
- function formatEdit(u: SessionUpdate, raw: Record<string, unknown>, tail: string, opts: ToolFormatOptions): string {
238
+ function formatEdit(
239
+ u: SessionUpdate,
240
+ raw: Record<string, unknown>,
241
+ tail: string,
242
+ opts: ToolFormatOptions,
243
+ toolName: string,
244
+ ): string {
125
245
  const path = extractPath(raw);
126
- const title = "Edit " + (path || "file");
127
- let out = "\u270F\uFE0F **" + title + "**" + tail;
246
+ let out = "\u270F\uFE0F " + boldVerbPath("Edit", path) + tail;
247
+ if (toolName && !/edit|replace/i.test(toolName)) out += `\n tool: \`${toolName}\``;
128
248
  if (opts.showDiffs) {
129
249
  const diff = buildEditDiff(u, raw, opts.diffMaxLines);
130
250
  if (diff && diff.block) {
@@ -135,50 +255,105 @@ function formatEdit(u: SessionUpdate, raw: Record<string, unknown>, tail: string
135
255
  return out;
136
256
  }
137
257
 
138
- function formatWrite(kind: string, raw: Record<string, unknown>, tail: string): string {
258
+ function formatWrite(u: SessionUpdate, kind: string, raw: Record<string, unknown>, tail: string): string {
139
259
  const path = extractPath(raw);
140
260
  const verb = kind === "create" ? "Create" : "Write";
141
- let out = "\u{1F4DD} **" + verb + " " + (path || "file") + "**" + tail;
142
- const content = extractContent(raw);
261
+ let out = "\u{1F4DD} " + boldVerbPath(verb, path) + tail;
262
+ const content = extractContent(raw) || extractToolOutput(u);
143
263
  if (content) {
144
- out += "\n" + fence(truncate(content, CONTENT_PREVIEW_MAX), detectLang(path)) + "\n";
264
+ out += "\n" + fence(truncateMiddleLines(content, OUTPUT_PREVIEW_LINES, CONTENT_PREVIEW_MAX), detectLang(path)) + "\n";
145
265
  }
146
266
  return out;
147
267
  }
148
268
 
149
- function formatRead(raw: Record<string, unknown>, tail: string): string {
150
- const path = extractPath(raw);
151
- const lines = strOf(raw.start_line) || strOf(raw.line);
152
- const offset = strOf(raw.offset);
153
- const limit = strOf(raw.limit);
154
- let title = "Read " + (path || "file");
269
+ function formatRead(
270
+ u: SessionUpdate,
271
+ raw: Record<string, unknown>,
272
+ tail: string,
273
+ toolName: string,
274
+ ): string {
275
+ const path = extractPath(raw, u);
276
+ const range = extractReadRange(raw);
155
277
  const parts: string[] = [];
156
- if (lines) parts.push("line " + lines);
157
- if (offset) parts.push("offset " + offset);
158
- if (limit) parts.push("limit " + limit);
159
- if (parts.length) title += " (" + parts.join(", ") + ")";
160
- return "\u{1F4D6} **" + title + "**" + tail;
278
+ if (range.startLine) parts.push("line " + range.startLine);
279
+ if (range.offset) parts.push("offset " + range.offset);
280
+ if (range.limit) parts.push("limit " + range.limit);
281
+ if (range.pages) parts.push("pages " + range.pages);
282
+ let out = "\u{1F4D6} " + boldVerbPath("Read", path) + tail;
283
+ if (parts.length) out += " (" + parts.join(", ") + ")";
284
+ if (toolName && toolName !== "read" && toolName !== "read_file") {
285
+ out += `\n tool: \`${toolName}\``;
286
+ }
287
+ // Extra range detail lines for clarity when many fields present.
288
+ if (parts.length >= 2) {
289
+ out += "\n " + parts.join(" \u00B7 ");
290
+ }
291
+ const body = extractToolOutput(u);
292
+ if (body) {
293
+ out += "\n" + fence(truncateMiddleLines(body, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX), detectLang(path)) + "\n";
294
+ }
295
+ return out;
161
296
  }
162
297
 
163
- function formatSearch(raw: Record<string, unknown>, tail: string): string {
298
+ function formatList(
299
+ u: SessionUpdate,
300
+ raw: Record<string, unknown>,
301
+ tail: string,
302
+ toolName: string,
303
+ ): string {
304
+ const path =
305
+ extractPath(raw) ||
306
+ extractSearchPath(raw) ||
307
+ strOf(raw.target_directory) ||
308
+ strOf(raw.targetDirectory) ||
309
+ ".";
310
+ let out = "\u{1F4C1} " + boldVerbPath("List", truncate(path, 120), ".") + tail;
311
+ if (toolName) out += `\n tool: \`${toolName}\``;
312
+ const filters = extractFilters(raw);
313
+ if (filters.include) out += "\n include: `" + filters.include.replace(/`/g, "'") + "`";
314
+ if (filters.exclude) out += "\n exclude: `" + filters.exclude.replace(/`/g, "'") + "`";
315
+ const body = extractToolOutput(u);
316
+ if (body) {
317
+ out += "\n" + fence(truncateMiddleLines(body, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX)) + "\n";
318
+ }
319
+ return out;
320
+ }
321
+
322
+ function formatSearch(
323
+ u: SessionUpdate,
324
+ raw: Record<string, unknown>,
325
+ tail: string,
326
+ toolName: string,
327
+ ): string {
164
328
  const query = extractSearchQuery(raw);
165
329
  const path = extractSearchPath(raw);
166
330
  const filters = extractFilters(raw);
167
- let title = "Search";
168
- if (query) title += ": " + truncate(query, 120);
169
- else if (path) title += " " + path;
170
- let out = "\u{1F50E} **" + title + "**" + tail;
171
- if (path && !query.includes(path)) out += "\n \u{1F4C2} in: " + truncate(path, 100);
172
- if (filters.include) out += "\n \u{1F4C1} include: " + filters.include;
173
- if (filters.exclude) out += "\n \u{1F6AB} exclude: " + filters.exclude;
174
- if (raw.case_sensitive !== undefined)
331
+ const isGlob = /glob/i.test(toolName) || (!!filters.include && !query);
332
+ const verb = isGlob ? "Glob" : "Search";
333
+ let out = "\u{1F50E} **" + verb + "**" + tail;
334
+ if (query) out += " " + pathCode(truncate(query, 120), "query");
335
+ else if (path) out += " " + pathCode(truncate(path, 120));
336
+ if (toolName && toolName !== "search" && toolName !== "grep") {
337
+ out += `\n tool: \`${toolName}\``;
338
+ }
339
+ if (path && query) out += "\n \u{1F4C2} in: " + pathCode(truncate(path, 100));
340
+ if (filters.include) out += "\n \u{1F4C1} include: " + pathCode(filters.include);
341
+ if (filters.exclude) out += "\n \u{1F6AB} exclude: " + pathCode(filters.exclude);
342
+ if (raw.case_sensitive !== undefined) {
175
343
  out += "\n case-sensitive: " + (raw.case_sensitive ? "yes" : "no");
344
+ }
345
+ if (raw.multiline !== undefined) out += "\n multiline: " + (raw.multiline ? "yes" : "no");
346
+ if (raw.type) out += "\n type: " + String(raw.type);
347
+ const hits = extractToolOutput(u);
348
+ if (hits) {
349
+ out += "\n" + fence(truncateMiddleLines(hits, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX)) + "\n";
350
+ }
176
351
  return out;
177
352
  }
178
353
 
179
354
  function formatDelete(raw: Record<string, unknown>, tail: string): string {
180
355
  const path = extractPath(raw);
181
- return "\u{1F5D1}\uFE0F **Delete " + (path || "file") + "**" + tail;
356
+ return "\u{1F5D1}\uFE0F " + boldVerbPath("Delete", path) + tail;
182
357
  }
183
358
 
184
359
  function formatMove(kind: string, raw: Record<string, unknown>, tail: string): string {
@@ -186,17 +361,26 @@ function formatMove(kind: string, raw: Record<string, unknown>, tail: string): s
186
361
  const dst = extractDestPath(raw);
187
362
  const verb = kind === "rename" ? "Rename" : "Move";
188
363
  if (src && dst) {
189
- return "\u{1F4E6} **" + verb + "**" + tail + "\n \u{1F4C4} " + truncate(src, 100) + "\n \u27A1\uFE0F " + truncate(dst, 100);
364
+ return (
365
+ "\u{1F4E6} **" +
366
+ verb +
367
+ "**" +
368
+ tail +
369
+ "\n \u{1F4C4} " +
370
+ pathCode(truncate(src, 100)) +
371
+ "\n \u27A1\uFE0F " +
372
+ pathCode(truncate(dst, 100))
373
+ );
190
374
  }
191
- return "\u{1F4E6} **" + verb + " " + (src || dst || "file") + "**" + tail;
375
+ return "\u{1F4E6} " + boldVerbPath(verb, src || dst) + tail;
192
376
  }
193
377
 
194
- function formatFetch(raw: Record<string, unknown>, tail: string): string {
378
+ function formatFetch(u: SessionUpdate, raw: Record<string, unknown>, tail: string): string {
195
379
  const url = extractUrl(raw);
196
380
  const method = strOf(raw.method) || strOf(raw.verb) || "GET";
197
- let title = "Fetch URL";
198
- if (url) title = "Fetch " + truncate(url, 200);
199
- let out = "\u{1F310} **" + title + "**" + tail;
381
+ let out = url
382
+ ? "\u{1F310} **Fetch** " + pathCode(truncate(url, 200), "URL") + tail
383
+ : "\u{1F310} **Fetch URL**" + tail;
200
384
  if (method && method !== "GET") out += "\n method: " + method;
201
385
  const headers = raw.headers;
202
386
  if (headers && typeof headers === "object") {
@@ -205,26 +389,146 @@ function formatFetch(raw: Record<string, unknown>, tail: string): string {
205
389
  }
206
390
  const body = strOf(raw.body) || strOf(raw.data);
207
391
  if (body) out += "\n body: " + truncate(body, 200);
392
+ const result = extractToolOutput(u);
393
+ if (result) {
394
+ out += "\n" + fence(truncateMiddleLines(result, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX)) + "\n";
395
+ }
208
396
  return out;
209
397
  }
210
398
 
211
- function formatWebSearch(raw: Record<string, unknown>, tail: string): string {
399
+ function formatWebSearch(u: SessionUpdate, raw: Record<string, unknown>, tail: string): string {
212
400
  const query = extractSearchQuery(raw) || extractUrl(raw);
213
- const count = strOf(raw.count) || strOf(raw.num) || strOf(raw.num_results);
214
- let title = "Web search";
215
- if (query) title += ": " + truncate(query, 150);
216
- let out = "\u{1F310} **" + title + "**" + tail;
401
+ const count = strOf(raw.count) || strOf(raw.num) || strOf(raw.num_results) || numStr(raw.num_results);
402
+ let out = "\u{1F310} **Web search**" + tail;
403
+ if (query) out += " " + pathCode(truncate(query, 150), "query");
217
404
  if (count) out += "\n results: " + count;
405
+ const result = extractToolOutput(u);
406
+ if (result) {
407
+ out += "\n" + fence(truncateMiddleLines(result, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX)) + "\n";
408
+ }
409
+ return out;
410
+ }
411
+
412
+ function formatTodo(raw: Record<string, unknown>, tail: string, toolName: string): string {
413
+ let out = "\u2705 **Todos**" + tail;
414
+ if (toolName) out += `\n tool: \`${toolName}\``;
415
+ const args = formatArgLines(raw);
416
+ if (args) out += "\n" + fence(truncateMiddle(args, PREVIEW_MAX)) + "\n";
417
+ return out;
418
+ }
419
+
420
+ function formatThink(
421
+ raw: Record<string, unknown>,
422
+ tail: string,
423
+ toolName: string,
424
+ u: SessionUpdate,
425
+ ): string {
426
+ const desc =
427
+ strOf(raw.description) ||
428
+ strOf(raw.prompt) ||
429
+ strOf(raw.message) ||
430
+ strOf(raw.task) ||
431
+ u.title ||
432
+ toolName ||
433
+ "subagent";
434
+ let out = "\u{1F4AD} **Delegate / think**" + tail + "\n " + truncate(desc, 300);
435
+ if (toolName) out += `\n tool: \`${toolName}\``;
436
+ const args = formatArgLines(raw);
437
+ if (args) out += "\n" + fence(truncateMiddle(args, PREVIEW_MAX)) + "\n";
438
+ return out;
439
+ }
440
+
441
+ function formatImage(raw: Record<string, unknown>, tail: string, toolName: string): string {
442
+ const path = extractPath(raw) || strOf(raw.image) || strOf(raw.output);
443
+ let out = "\u{1F5BC}\uFE0F **Image**" + tail;
444
+ if (toolName) out += `\n tool: \`${toolName}\``;
445
+ if (path) out += "\n " + pathCode(truncate(path, 200));
446
+ const prompt = strOf(raw.prompt);
447
+ if (prompt) out += "\n prompt: " + truncate(prompt, 200);
448
+ return out;
449
+ }
450
+
451
+ /**
452
+ * Enter/exit plan mode tools — show status + failure reason (e.g. client
453
+ * disconnected when the bridge did not answer the plan-approval reverse-request).
454
+ */
455
+ function formatPlanModeTool(
456
+ u: SessionUpdate,
457
+ raw: Record<string, unknown>,
458
+ toolName: string,
459
+ tail: string,
460
+ ): string | undefined {
461
+ const name = (toolName || u.title || "").toLowerCase();
462
+ const variant = String(raw.variant ?? raw.action ?? "").toLowerCase();
463
+ const isEnter =
464
+ name.includes("enter_plan") ||
465
+ name === "plan: enter" ||
466
+ variant.includes("enterplan");
467
+ const isExit =
468
+ name.includes("exit_plan") ||
469
+ name === "plan: exit" ||
470
+ variant.includes("exitplan");
471
+ if (!isEnter && !isExit) return undefined;
472
+
473
+ const icon = isExit ? "\u{1F4CB}" : "\u{1F4D1}";
474
+ const label = isExit ? "Plan: Exit" : "Plan: Enter";
475
+ let out = `${icon} **${label}**${tail}`;
476
+ if (variant) out += `\n variant: \`${String(raw.variant ?? raw.action)}\``;
477
+ const result = extractToolOutput(u);
478
+ if (result) {
479
+ // Prefer a short, visible failure/success reason over a huge dump.
480
+ const short = truncateMiddle(result.trim(), 500);
481
+ out += "\n" + fence(short) + "\n";
482
+ } else if ((u.status || "").toLowerCase() === "failed") {
483
+ out += "\n \u274C Plan approval failed (bridge should auto-approve exit).";
484
+ }
218
485
  return out;
219
486
  }
220
487
 
221
- function formatGeneric(u: SessionUpdate, raw: Record<string, unknown>, tail: string, kind: string): string {
488
+ function formatGeneric(
489
+ u: SessionUpdate,
490
+ raw: Record<string, unknown>,
491
+ tail: string,
492
+ kind: string,
493
+ toolName: string,
494
+ ): string {
222
495
  const icon = KIND_ICON[kind] ?? KIND_ICON.other;
223
496
  const path = extractPath(raw);
224
- const title = u.title || (path ? capitalize(kind) + " " + path : capitalize(kind));
225
- let out = icon + " **" + title + "**" + tail;
226
- const desc = strOf(raw.description) || strOf(raw.message) || strOf(raw.prompt);
227
- if (desc) out += "\n " + truncate(desc, 300);
497
+ const cmd = extractCommand(raw);
498
+ const query = extractSearchQuery(raw);
499
+ // Never show a bare "Other" / "Tool call" — prefer real tool name. Paths/queries
500
+ // go in inline code (not bold) so Windows paths cannot break MarkdownV2.
501
+ let label =
502
+ (toolName && !/^tool[_ ]?call$/i.test(toolName) ? toolName : "") ||
503
+ (u.title && !/^other$/i.test(u.title.trim()) && !/^tool[_ ]?call$/i.test(u.title.trim())
504
+ ? u.title.trim()
505
+ : "") ||
506
+ (path ? capitalize(kind !== "other" ? kind : "use") : "") ||
507
+ (cmd ? "Run command" : "") ||
508
+ (query ? "Search" : "") ||
509
+ (kind && kind !== "other" ? capitalize(kind) : "") ||
510
+ (toolName || "Tool");
511
+ if (/^other$/i.test(label) || /^tool[_ ]?call$/i.test(label)) {
512
+ label = toolName && !/^tool[_ ]?call$/i.test(toolName) ? toolName : "Tool";
513
+ }
514
+ // Titles that accidentally embed a path stay short: drop path from bold label.
515
+ if (path && label.includes(path)) {
516
+ label = label.replace(path, "").replace(/\s+/g, " ").trim() || capitalize(kind !== "other" ? kind : "Tool");
517
+ }
518
+
519
+ let out = icon + " **" + label + "**" + tail;
520
+ if (toolName && toolName !== label) out += `\n tool: \`${toolName}\``;
521
+ if (path) out += "\n \u{1F4C4} " + pathCode(truncate(path, 120));
522
+ if (cmd) out += "\n" + fence(truncateMiddle(cmd, PREVIEW_MAX), "bash") + "\n";
523
+ else if (query) out += "\n query: " + pathCode(truncate(query, 150), "query");
524
+ else if (!path && !cmd) {
525
+ const args = formatArgLines(raw);
526
+ if (args) out += "\n" + fence(truncateMiddle(args, PREVIEW_MAX)) + "\n";
527
+ }
528
+ const result = extractToolOutput(u);
529
+ if (result) {
530
+ out += "\n" + fence(truncateMiddleLines(result, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX)) + "\n";
531
+ }
228
532
  return out;
229
533
  }
230
534
 
@@ -235,7 +539,7 @@ function buildEditDiff(u: SessionUpdate, raw: Record<string, unknown>, maxLines:
235
539
  const diffBlock = blocks.find((b) => b.type === "diff");
236
540
  if (diffBlock) {
237
541
  return renderUnifiedDiff({
238
- path: strOf(diffBlock.path) || strOf(raw.path) || "file",
542
+ path: strOf(diffBlock.path) || extractPath(raw) || "file",
239
543
  oldText: typeof diffBlock.oldText === "string" ? diffBlock.oldText : "",
240
544
  newText: typeof diffBlock.newText === "string" ? diffBlock.newText : "",
241
545
  maxLines,
@@ -244,11 +548,16 @@ function buildEditDiff(u: SessionUpdate, raw: Record<string, unknown>, maxLines:
244
548
  const oldStr = strOf(raw.old_str) || strOf(raw.oldStr) || strOf(raw.old_string) || strOf(raw.find);
245
549
  const newStr = strOf(raw.new_str) || strOf(raw.newStr) || strOf(raw.new_string) || strOf(raw.replace);
246
550
  if (oldStr || newStr) {
247
- return renderUnifiedDiff({ path: strOf(raw.path) || "file", oldText: oldStr, newText: newStr, maxLines });
551
+ return renderUnifiedDiff({
552
+ path: extractPath(raw) || "file",
553
+ oldText: oldStr,
554
+ newText: newStr,
555
+ maxLines,
556
+ });
248
557
  }
249
558
  const content = strOf(raw.file_text) || strOf(raw.content) || strOf(raw.text);
250
559
  if (content) {
251
- return renderUnifiedDiff({ path: strOf(raw.path) || "file", oldText: "", newText: content, maxLines });
560
+ return renderUnifiedDiff({ path: extractPath(raw) || "file", oldText: "", newText: content, maxLines });
252
561
  }
253
562
  return undefined;
254
563
  }
@@ -258,62 +567,52 @@ function buildEditDiff(u: SessionUpdate, raw: Record<string, unknown>, maxLines:
258
567
  function detectLang(path: string): string {
259
568
  const ext = (path.split(".").pop() || "").toLowerCase();
260
569
  const MAP: Record<string, string> = {
261
- ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
262
- py: "python", go: "go", rs: "rust", java: "java",
263
- c: "c", cpp: "cpp", h: "c", hpp: "cpp",
264
- cs: "csharp", rb: "ruby", php: "php", swift: "swift",
265
- kt: "kotlin", scala: "scala", sh: "bash", bash: "bash",
266
- sql: "sql", html: "html", css: "css", scss: "scss",
267
- json: "json", yaml: "yaml", yml: "yaml", xml: "xml",
268
- md: "markdown", toml: "toml", ini: "ini", cfg: "ini",
269
- vue: "vue", svelte: "svelte", dart: "dart", lua: "lua",
270
- r: "r", pl: "perl", ps1: "powershell",
570
+ ts: "typescript",
571
+ tsx: "tsx",
572
+ js: "javascript",
573
+ jsx: "jsx",
574
+ py: "python",
575
+ go: "go",
576
+ rs: "rust",
577
+ java: "java",
578
+ c: "c",
579
+ cpp: "cpp",
580
+ h: "c",
581
+ hpp: "cpp",
582
+ cs: "csharp",
583
+ rb: "ruby",
584
+ php: "php",
585
+ swift: "swift",
586
+ kt: "kotlin",
587
+ scala: "scala",
588
+ sh: "bash",
589
+ bash: "bash",
590
+ sql: "sql",
591
+ html: "html",
592
+ css: "css",
593
+ scss: "scss",
594
+ json: "json",
595
+ yaml: "yaml",
596
+ yml: "yaml",
597
+ xml: "xml",
598
+ md: "markdown",
599
+ toml: "toml",
600
+ ini: "ini",
601
+ cfg: "ini",
602
+ vue: "vue",
603
+ svelte: "svelte",
604
+ dart: "dart",
605
+ lua: "lua",
606
+ r: "r",
607
+ pl: "perl",
608
+ ps1: "powershell",
271
609
  };
272
610
  return MAP[ext] || "";
273
611
  }
274
612
 
275
- // ---- MCP helpers ----
276
-
277
- /** Compact one-line-per-key preview of an MCP call's arguments. */
278
- function mcpArgPreview(raw: Record<string, unknown>): string {
279
- const SKIP = new Set(["tool_name", "toolName", "name", "tool", "type", "_meta"]);
280
- const lines: string[] = [];
281
- for (const [key, val] of Object.entries(raw)) {
282
- if (SKIP.has(key)) continue;
283
- let s: string;
284
- if (typeof val === "string") s = val;
285
- else if (typeof val === "number" || typeof val === "boolean") s = String(val);
286
- else {
287
- try { s = JSON.stringify(val); } catch { s = String(val); }
288
- }
289
- if (s.length > 200) s = s.slice(0, 199) + "\u2026";
290
- lines.push(key + ": " + s);
291
- }
292
- return truncate(lines.join("\n"), PREVIEW_MAX);
293
- }
613
+ // ---- skill ----
294
614
 
295
- /** Built-in Grok tools that must never be labelled as MCP calls. */
296
- const BUILTIN_TOOLS = new Set([
297
- "read", "write", "shell", "grep", "glob", "web_fetch", "web_search", "fs_read",
298
- "fs_write", "fs_replace", "fs_search", "execute_bash", "report_issue", "use_aws",
299
- "todo_list", "introspect", "knowledge", "thinking", "summary", "subagent",
300
- "edit", "create", "delete", "move", "rename", "execute", "search", "fetch",
301
- ]);
302
- /** Tool kinds that are first-class file/shell operations (never MCP). */
303
- const FILE_KINDS = new Set([
304
- "read", "edit", "execute", "search", "delete", "move", "write", "create",
305
- "rename", "fetch", "web_fetch", "web_search",
306
- ]);
307
- /** `.../skills/<name>/SKILL.md` - the signature of loading a skill. */
308
615
  const SKILL_RE = /[\\/]skills[\\/]([^\\/]+)[\\/]SKILL\.md$/i;
309
- /** Namespaced MCP tool-name shapes - [, server, method]. */
310
- const MCP_NS = [
311
- /^@([a-z0-9._-]+)[/_]{1,3}(.+)$/i,
312
- /^([a-z0-9.-]+)___(.+)$/i,
313
- /^([a-z0-9.-]+)__(.+)$/i,
314
- /^([a-z0-9.-]+)\/(.+)$/i,
315
- /^([a-z0-9-]+)\.(.+)$/i,
316
- ];
317
616
 
318
617
  function detectSkill(u: SessionUpdate, raw: Record<string, unknown>): string | undefined {
319
618
  for (const p of gatherPaths(u, raw)) {
@@ -323,34 +622,17 @@ function detectSkill(u: SessionUpdate, raw: Record<string, unknown>): string | u
323
622
  return undefined;
324
623
  }
325
624
 
326
- function detectMcp(
327
- u: SessionUpdate,
328
- raw: Record<string, unknown>,
329
- kind: string,
330
- ): { server?: string; method: string } | undefined {
331
- const name = mcpToolName(u, raw);
332
- if (!name) return undefined;
333
- for (const re of MCP_NS) {
334
- const m = re.exec(name);
335
- if (m) return { server: m[1]!, method: m[2]! };
336
- }
337
- if (!BUILTIN_TOOLS.has(name.toLowerCase()) && !FILE_KINDS.has(kind)) {
338
- return { method: name };
339
- }
340
- return undefined;
341
- }
342
-
343
- function mcpToolName(u: SessionUpdate, raw: Record<string, unknown>): string {
344
- const explicit = strOf(raw.tool_name) || strOf(raw.toolName) || strOf(raw.name) || strOf(raw.tool);
345
- if (explicit) return explicit;
346
- const t = (u.title || "").trim();
347
- return /^[@a-z0-9._/-]+$/i.test(t) && !t.includes(":") ? t : "";
348
- }
349
-
350
625
  function capitalize(s: string): string {
351
626
  return s.length ? s[0]!.toUpperCase() + s.slice(1) : s;
352
627
  }
353
628
 
354
629
  function strOf(v: unknown): string {
355
630
  return typeof v === "string" ? v : "";
356
- }
631
+ }
632
+
633
+ function numStr(v: unknown): string {
634
+ return typeof v === "number" && Number.isFinite(v) ? String(v) : "";
635
+ }
636
+
637
+ // Re-export for tests / step-from-tool helpers.
638
+ export { extractToolName, resolveToolIdentity };