grok-telegram-bot 2.0.0 → 2.1.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.
- package/CHANGELOG.md +46 -0
- package/package.json +1 -1
- package/src/bot/session-runtime.ts +50 -30
- package/src/grok/client.ts +5 -1
- package/src/render/tool-call-detail.ts +170 -0
- package/src/render/tool-call.ts +259 -99
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,52 @@ The latest section is published verbatim as the GitHub Release notes by
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [2.1.0] - 2026-07-10
|
|
13
|
+
|
|
14
|
+
The **"show me everything"** release — the bot now streams rich, real-time detail
|
|
15
|
+
for every tool the agent calls, so you can see exactly what's happening: which
|
|
16
|
+
files are being read, edited (with diffs), created, deleted or moved, which
|
|
17
|
+
searches run (pattern + scope + filters), which URLs are fetched, which shell
|
|
18
|
+
commands execute, and which MCP tools are invoked — each with its completion
|
|
19
|
+
status (✅ / ❌ / ⏳).
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **🔍 Rich tool-call detail for every kind.** Previously most tool calls showed
|
|
24
|
+
only a bare icon + title line. Now each kind gets its own formatted detail:
|
|
25
|
+
- **Search** — query/pattern, search path (📂), include/exclude filters
|
|
26
|
+
(📁/🚫), case-sensitivity flag.
|
|
27
|
+
- **Read** — file path + line/offset/limit when present.
|
|
28
|
+
- **Edit** — file path + unified diff block with `+added / -removed` count.
|
|
29
|
+
- **Write / Create** — file path + content preview with automatic language
|
|
30
|
+
detection for syntax highlighting (TypeScript, Python, Go, Rust, etc.).
|
|
31
|
+
- **Delete** — the file being removed.
|
|
32
|
+
- **Move / Rename** — source path (📄) → destination path (➡️).
|
|
33
|
+
- **Execute** — the full command in a `bash` code block + working directory.
|
|
34
|
+
- **Fetch / web_fetch** — URL, HTTP method, headers, and body preview.
|
|
35
|
+
- **Web search** — query string + result count.
|
|
36
|
+
- **MCP calls** — server + method + a compact argument preview.
|
|
37
|
+
- **Generic / unknown** — description or message extracted from raw input.
|
|
38
|
+
- **✅ Status visibility for completed tool calls.** `tool_call_update`
|
|
39
|
+
notifications carrying `completed` or `failed` status are now shown (previously
|
|
40
|
+
they were silently deduped away because they shared the `toolCallId` of the
|
|
41
|
+
initial `tool_call`). You now see the final ✅ or ❌ for each tool action,
|
|
42
|
+
including diffs that arrive only in the completion update.
|
|
43
|
+
- **🧩 New `tool-call-detail.ts` module** — shared extractors for paths, search
|
|
44
|
+
queries, URLs, commands, file content, filters, and destination paths, with a
|
|
45
|
+
`normalizeKind()` that maps common aliases (`bash` → `execute`, `grep` →
|
|
46
|
+
`search`, `rename` → `move`, etc.) to canonical kinds.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- **`formatToolCall` rewritten** from a single switch to per-kind formatter
|
|
51
|
+
functions, each producing rich RAW markdown. Uses string concatenation instead
|
|
52
|
+
of template literals to avoid backtick-in-fence escaping issues.
|
|
53
|
+
- **`session-runtime.ts` dedup logic** refined: initial `tool_call` messages are
|
|
54
|
+
deduped by `toolCallId` (no duplicate); `tool_call_update` with `completed` /
|
|
55
|
+
`failed` status is shown once (keyed by `toolCallId:done`); `pending` /
|
|
56
|
+
`in_progress` updates are skipped unless they carry new `content_blocks`.
|
|
57
|
+
|
|
12
58
|
## [2.0.0] - 2026-07-09
|
|
13
59
|
|
|
14
60
|
The **Grok Build** release — the bot now drives the official **xAI Grok Build
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "grok-telegram-bot",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Control the official Grok Build CLI from Telegram over the Agent Client Protocol (ACP). Sign in with your xAI account, switch projects, resume sessions, stream responses with diffs, queue follow-ups, manage multiple sign-ins, and run 24/7 as a cross-platform background service.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SessionRuntime
|
|
2
|
+
* SessionRuntime — binds one Telegram chat to one Grok ACP session and drives
|
|
3
3
|
* the prompt/stream lifecycle, typing indicator, follow-up queue, live watch,
|
|
4
4
|
* and per-chat preferences (project, agent, model, reasoning). State persists
|
|
5
5
|
* to the settings store so it survives restarts.
|
|
@@ -54,7 +54,7 @@ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
|
|
|
54
54
|
const RESUME_INSTRUCTION =
|
|
55
55
|
"Your previous response was interrupted by a transient service error (the model stream was throttled), " +
|
|
56
56
|
"so your last turn did not finish. Continue from exactly where you stopped and complete the response. " +
|
|
57
|
-
"Do NOT repeat any file edits, commands, or other tool calls you already completed
|
|
57
|
+
"Do NOT repeat any file edits, commands, or other tool calls you already completed — their results are " +
|
|
58
58
|
"already in this conversation. If you had already fully answered, just briefly conclude.";
|
|
59
59
|
|
|
60
60
|
export class SessionRuntime {
|
|
@@ -82,7 +82,7 @@ export class SessionRuntime {
|
|
|
82
82
|
/** Subagent sessionId -> last status key shown this turn (dedupe). */
|
|
83
83
|
private subagentShown = new Map<string, string>();
|
|
84
84
|
private turnStartedAt = 0;
|
|
85
|
-
/** Count of completed (non-cancelled) turns this session
|
|
85
|
+
/** Count of completed (non-cancelled) turns this session — shown in /usage. */
|
|
86
86
|
private turnCount = 0;
|
|
87
87
|
/** Telegram message id of the current turn's prompt, so replies thread to it. */
|
|
88
88
|
private turnReplyTo: number | undefined;
|
|
@@ -93,7 +93,7 @@ export class SessionRuntime {
|
|
|
93
93
|
private watcher: TailWatcher | undefined;
|
|
94
94
|
/** True when the active watch is a transient "follow" of this session's own
|
|
95
95
|
* in-flight turn (started on switch) rather than an explicit /watch of
|
|
96
|
-
* another session
|
|
96
|
+
* another session — follow-watches are auto-stopped when a new turn streams. */
|
|
97
97
|
private watchIsFollow = false;
|
|
98
98
|
private rebindPending = false;
|
|
99
99
|
private sessionLive = false;
|
|
@@ -159,7 +159,7 @@ export class SessionRuntime {
|
|
|
159
159
|
return this.lastCompletion;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
/** Latest task-completion % (0
|
|
162
|
+
/** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
|
|
163
163
|
get taskProgress(): number | undefined {
|
|
164
164
|
return this.progress;
|
|
165
165
|
}
|
|
@@ -174,8 +174,8 @@ export class SessionRuntime {
|
|
|
174
174
|
this.changed();
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
/** Searchable hashtag footer for this session (project
|
|
178
|
-
* reasoning)
|
|
177
|
+
/** Searchable hashtag footer for this session (project В· session В· model В·
|
|
178
|
+
* reasoning) — appended to every AI-output surface for this session. */
|
|
179
179
|
get tags(): string {
|
|
180
180
|
return this.hashtags();
|
|
181
181
|
}
|
|
@@ -189,7 +189,7 @@ export class SessionRuntime {
|
|
|
189
189
|
if (value) {
|
|
190
190
|
// A turn was started here and is still in flight, but its streamer was
|
|
191
191
|
// finalized when we went background. Recreate it and let onUpdate feed
|
|
192
|
-
// the remaining chunks/thoughts/tools just like a normal live turn
|
|
192
|
+
// the remaining chunks/thoughts/tools just like a normal live turn — we
|
|
193
193
|
// own the agent's session/update events, so no tail-watch is needed.
|
|
194
194
|
if (this.busy && !this.streamer) {
|
|
195
195
|
// Any transient follow-watch of this session is now superseded.
|
|
@@ -234,7 +234,7 @@ export class SessionRuntime {
|
|
|
234
234
|
this.stopWatch();
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
//
|
|
237
|
+
// в”Ђв”Ђ sessions в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
|
|
238
238
|
|
|
239
239
|
async startNewSession(cwd: string, projectName?: string): Promise<void> {
|
|
240
240
|
if (this.busy) await this.cancel();
|
|
@@ -314,7 +314,7 @@ export class SessionRuntime {
|
|
|
314
314
|
return true;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
-
//
|
|
317
|
+
// в”Ђв”Ђ preferences в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
|
|
318
318
|
|
|
319
319
|
async setModelPref(modelId: string): Promise<{ ok: boolean; error?: string }> {
|
|
320
320
|
// Persist the choice always; only talk to Grok when a session is live in
|
|
@@ -378,7 +378,7 @@ export class SessionRuntime {
|
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
-
//
|
|
381
|
+
// в”Ђв”Ђ prompting в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
|
|
382
382
|
|
|
383
383
|
async submit(input: PromptInput): Promise<"ran" | "queued"> {
|
|
384
384
|
await this.ensureSession();
|
|
@@ -425,7 +425,7 @@ export class SessionRuntime {
|
|
|
425
425
|
// The session genuinely can't be reloaded (its exclusive lock is held,
|
|
426
426
|
// or its log/metadata is gone). Never silently drop the conversation:
|
|
427
427
|
// fork a linked continuation primed with the recent transcript so the
|
|
428
|
-
// thread survives
|
|
428
|
+
// thread survives — including any question the agent had just asked.
|
|
429
429
|
// forkFromLostSession() only throws if the agent is fully down, in which
|
|
430
430
|
// case we leave rebindPending set so the next message retries cleanly.
|
|
431
431
|
await this.forkFromLostSession(this.sessionId);
|
|
@@ -438,7 +438,7 @@ export class SessionRuntime {
|
|
|
438
438
|
/** Reload a persisted session, retrying flaky failures with a short backoff.
|
|
439
439
|
* Returns true once loaded, false after the attempts are exhausted. */
|
|
440
440
|
private async rebindWithRetries(sessionId: string, attempts = 4): Promise<boolean> {
|
|
441
|
-
const delays = [400, 1200, 3000]; //
|
|
441
|
+
const delays = [400, 1200, 3000]; // ≈4.6s total before giving up
|
|
442
442
|
for (let i = 0; i < attempts; i++) {
|
|
443
443
|
try {
|
|
444
444
|
await this.acp.loadSession(sessionId, this.cwd);
|
|
@@ -518,7 +518,7 @@ export class SessionRuntime {
|
|
|
518
518
|
if (resumed) final = resumed;
|
|
519
519
|
const streamedOutput = this.streamer?.hasOutput ?? false;
|
|
520
520
|
// On a successful, non-cancelled turn, top the fallback bar up to 100 (a
|
|
521
|
-
// no-op when the agent reported its own progress
|
|
521
|
+
// no-op when the agent reported its own progress — its value is kept).
|
|
522
522
|
if (final.result && !this.cancelled) this.streamer?.completeFallback();
|
|
523
523
|
if (this.streamer) await this.streamer.finalize();
|
|
524
524
|
if (this.foreground) await this.sendTurnImages();
|
|
@@ -527,7 +527,7 @@ export class SessionRuntime {
|
|
|
527
527
|
// the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
|
|
528
528
|
const canPing = this.foreground || this.cfg.notifyOtherSessions;
|
|
529
529
|
// A background session about to run a queued follow-up shouldn't ping its
|
|
530
|
-
// interim "Done"
|
|
530
|
+
// interim "Done" — only the final, queue-empty turn announces completion.
|
|
531
531
|
const hasQueued = this.queue.length > 0;
|
|
532
532
|
const switchKb = this.switchKeyboard();
|
|
533
533
|
if (final.result && !this.cancelled) this.turnCount++;
|
|
@@ -594,11 +594,11 @@ export class SessionRuntime {
|
|
|
594
594
|
}
|
|
595
595
|
|
|
596
596
|
/**
|
|
597
|
-
* True when a prompt failure is attributable to an exhausted context window
|
|
597
|
+
* True when a prompt failure is attributable to an exhausted context window —
|
|
598
598
|
* either the error message says so, or this session's last-known context
|
|
599
599
|
* usage is at/above the configured fork threshold. Such failures won't clear
|
|
600
600
|
* by retrying the same oversized prompt (throttling on a near-full session
|
|
601
|
-
* surfaces as a plain "-32603
|
|
601
|
+
* surfaces as a plain "-32603 … throttled"), so the session must be compacted
|
|
602
602
|
* by forking a fresh, smaller continuation.
|
|
603
603
|
*/
|
|
604
604
|
private isContextRelatedFailure(error: Error): boolean {
|
|
@@ -663,7 +663,7 @@ export class SessionRuntime {
|
|
|
663
663
|
/**
|
|
664
664
|
* Auto-rotate-on-give-up. When a turn has failed (retries exhausted, auto-fork
|
|
665
665
|
* didn't recover it) and nothing was streamed, cycle through the OTHER saved
|
|
666
|
-
* accounts once
|
|
666
|
+
* accounts once — switching login + restarting the agent, then retrying the
|
|
667
667
|
* same prompt on a fresh session for each. The first account that succeeds
|
|
668
668
|
* wins and stays active; if every account fails we return a single combined
|
|
669
669
|
* error listing what each one reported. Bounded to ONE pass (no infinite
|
|
@@ -719,14 +719,14 @@ export class SessionRuntime {
|
|
|
719
719
|
errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
|
|
720
720
|
}
|
|
721
721
|
|
|
722
|
-
// One full cycle done and still failing
|
|
722
|
+
// One full cycle done and still failing — stop with a combined report.
|
|
723
723
|
const combined = new Error(`Tried ${targets.length + 1} account(s), all failed:\n${errors.join("\n")}`);
|
|
724
724
|
return { error: combined, attempts: last.attempts };
|
|
725
725
|
}
|
|
726
726
|
|
|
727
727
|
/**
|
|
728
728
|
* Run the prompt, retrying *transient* agent errors (e.g. "high volume of
|
|
729
|
-
* traffic" / -32603) with an exponential backoff (6s
|
|
729
|
+
* traffic" / -32603) with an exponential backoff (6s в†’ 12s в†’ 24s в†’ 48s в†’ 60s,
|
|
730
730
|
* then give up). The real error is shown to the user on every failed attempt.
|
|
731
731
|
*
|
|
732
732
|
* We only retry while the turn has produced **no streamed output** (so tools
|
|
@@ -748,7 +748,7 @@ export class SessionRuntime {
|
|
|
748
748
|
const error = err as Error;
|
|
749
749
|
const canRecover = !this.cancelled && !(this.streamer?.hasOutput ?? false);
|
|
750
750
|
// A context-exhausted session won't recover by retrying the same
|
|
751
|
-
// oversized prompt
|
|
751
|
+
// oversized prompt — skip the backoff and let auto-fork compact it now.
|
|
752
752
|
const forkInstead = canRecover && this.cfg.autoForkOnError && this.isContextRelatedFailure(error);
|
|
753
753
|
const willRetry =
|
|
754
754
|
attempt <= delays.length &&
|
|
@@ -784,8 +784,8 @@ export class SessionRuntime {
|
|
|
784
784
|
* The pre-stream paths (retry / auto-fork / account-rotate) all bail once any
|
|
785
785
|
* output exists, because re-sending the original prompt would re-execute the
|
|
786
786
|
* tools that already ran (duplicate/destructive side effects). Instead we ask
|
|
787
|
-
* the SAME session to CONTINUE from where it stopped
|
|
788
|
-
* any completed tool results are already in history, so nothing is repeated
|
|
787
|
+
* the SAME session to CONTINUE from where it stopped — its partial reply and
|
|
788
|
+
* any completed tool results are already in history, so nothing is repeated —
|
|
789
789
|
* using the same exponential backoff so a throttle has time to clear. The
|
|
790
790
|
* open streamer keeps appending, so the reply is completed in place.
|
|
791
791
|
*
|
|
@@ -800,7 +800,7 @@ export class SessionRuntime {
|
|
|
800
800
|
if (!(this.streamer?.hasOutput ?? false)) return undefined;
|
|
801
801
|
if (!isTransientError(final.error)) return undefined;
|
|
802
802
|
// A context-full session won't recover by continuing (it'll just throttle
|
|
803
|
-
// again each attempt)
|
|
803
|
+
// again each attempt) — don't burn the backoff; surface the error so the
|
|
804
804
|
// user can fork/compact. Resume targets transient throttles on a session
|
|
805
805
|
// that still has headroom.
|
|
806
806
|
if (this.isContextRelatedFailure(final.error)) return undefined;
|
|
@@ -878,7 +878,7 @@ export class SessionRuntime {
|
|
|
878
878
|
const meta = this.contextInfo();
|
|
879
879
|
const ctx = meta?.contextUsagePercentage;
|
|
880
880
|
const ctxStr = ctx !== undefined ? ` \u00B7 ctx ${ctx.toFixed(0)}%` : "";
|
|
881
|
-
// Credits consumed this turn
|
|
881
|
+
// Credits consumed this turn — only shown when Grok actually reports it
|
|
882
882
|
// (not part of ACP today; degrades to nothing rather than guessing).
|
|
883
883
|
const credits = meta?.credits;
|
|
884
884
|
const creditStr = credits !== undefined ? ` \u00B7 \u{1FA99} ${fmtCredits(credits)}` : "";
|
|
@@ -898,7 +898,7 @@ export class SessionRuntime {
|
|
|
898
898
|
return `\u{1F4E8} From other session ${this.sessionTag()}\n${summary}${shortFiles}\n\n${tags}`;
|
|
899
899
|
}
|
|
900
900
|
|
|
901
|
-
/** "[project
|
|
901
|
+
/** "[project · 1a2b3c4d]" — identifies which background session a ping is from. */
|
|
902
902
|
private sessionTag(): string {
|
|
903
903
|
const name = this.projectName || basename(this.cwd) || "session";
|
|
904
904
|
const id = this.sessionId ? ` \u00B7 ${this.sessionId.slice(0, 8)}` : "";
|
|
@@ -962,9 +962,29 @@ export class SessionRuntime {
|
|
|
962
962
|
}
|
|
963
963
|
if (kind === "tool_call" || kind === "tool_call_update") {
|
|
964
964
|
if (!this.cfg.showToolCalls) return;
|
|
965
|
-
const id = update.toolCallId ||
|
|
966
|
-
|
|
967
|
-
|
|
965
|
+
const id = update.toolCallId || "";
|
|
966
|
+
const status = (update.status || "").toLowerCase();
|
|
967
|
+
|
|
968
|
+
if (kind === "tool_call_update") {
|
|
969
|
+
// Skip "in_progress"/"pending" duplicates of an already-shown initial call.
|
|
970
|
+
if (status === "pending" || status === "in_progress") {
|
|
971
|
+
const hasNewContent =
|
|
972
|
+
Array.isArray(update.content_blocks) && update.content_blocks.length > 0;
|
|
973
|
+
if (!hasNewContent) return;
|
|
974
|
+
}
|
|
975
|
+
// For completed/failed: show once (so the user sees the final status).
|
|
976
|
+
const doneKey = (id || update.title || "") + ":done";
|
|
977
|
+
if (status === "completed" || status === "failed") {
|
|
978
|
+
if (this.shownToolIds.has(doneKey)) return;
|
|
979
|
+
this.shownToolIds.add(doneKey);
|
|
980
|
+
}
|
|
981
|
+
} else {
|
|
982
|
+
// Initial tool_call: dedupe by id to avoid double-showing.
|
|
983
|
+
const shownKey = id || `tool_call:${update.title ?? ""}`;
|
|
984
|
+
if (this.shownToolIds.has(shownKey)) return;
|
|
985
|
+
this.shownToolIds.add(shownKey);
|
|
986
|
+
}
|
|
987
|
+
|
|
968
988
|
const md = formatToolCall(update, {
|
|
969
989
|
showDiffs: this.cfg.showEditDiffs,
|
|
970
990
|
diffMaxLines: this.cfg.diffMaxLines,
|
|
@@ -1019,7 +1039,7 @@ export class SessionRuntime {
|
|
|
1019
1039
|
.map((e) => {
|
|
1020
1040
|
const icon = WATCH_ICON[e.role] ?? "\u2022";
|
|
1021
1041
|
if (e.role === "tool") return `${icon} ${e.tool ? `\`${e.tool}\`` : "tool"}`;
|
|
1022
|
-
const text = e.text.length > WATCH_ENTRY_MAX ? e.text.slice(0, WATCH_ENTRY_MAX) + "
|
|
1042
|
+
const text = e.text.length > WATCH_ENTRY_MAX ? e.text.slice(0, WATCH_ENTRY_MAX) + " …" : e.text;
|
|
1023
1043
|
return `${icon} ${text}`;
|
|
1024
1044
|
})
|
|
1025
1045
|
.filter(Boolean)
|
package/src/grok/client.ts
CHANGED
|
@@ -161,8 +161,12 @@ export class GrokClient extends EventEmitter {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
private async connect(): Promise<void> {
|
|
164
|
-
|
|
164
|
+
// `--always-approve` is a `grok agent` option (not `grok agent stdio`),
|
|
165
|
+
// so it must come before the `stdio` subcommand. `--no-auto-update` was
|
|
166
|
+
// removed in grok 0.2.x and causes exit code 2.
|
|
167
|
+
const args = ["agent"];
|
|
165
168
|
if (this.opts.trustAllTools) args.push("--always-approve");
|
|
169
|
+
args.push("stdio");
|
|
166
170
|
|
|
167
171
|
log.info(`spawning: ${this.opts.grokCliPath} ${args.join(" ")}`);
|
|
168
172
|
const env = { ...process.env };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-call-detail.ts
|
|
3
|
+
*
|
|
4
|
+
* Rich detail extractors for specific tool kinds: search queries, file reads,
|
|
5
|
+
* URLs, writes/creates content previews, move/rename source+dest, delete paths,
|
|
6
|
+
* web search queries, and MCP argument previews.
|
|
7
|
+
*
|
|
8
|
+
* Each function returns a RAW markdown string (code blocks, etc.) appended after
|
|
9
|
+
* the tool-call icon + title line.
|
|
10
|
+
*/
|
|
11
|
+
import type { SessionUpdate, ToolCallContent } from "../grok/types.js";
|
|
12
|
+
|
|
13
|
+
/** Max chars to show for search queries, command previews, etc. */
|
|
14
|
+
export const PREVIEW_MAX = 600;
|
|
15
|
+
/** Max chars for file content preview on write/create. */
|
|
16
|
+
export const CONTENT_PREVIEW_MAX = 1000;
|
|
17
|
+
|
|
18
|
+
/** Normalize a tool-call kind string to a canonical lowercase value. */
|
|
19
|
+
export function normalizeKind(kind: string | undefined): string {
|
|
20
|
+
const k = (kind || "other").toLowerCase().trim();
|
|
21
|
+
// Map common variants to canonical kinds.
|
|
22
|
+
const ALIASES: Record<string, string> = {
|
|
23
|
+
bash: "execute",
|
|
24
|
+
shell: "execute",
|
|
25
|
+
command: "execute",
|
|
26
|
+
terminal: "execute",
|
|
27
|
+
grep: "search",
|
|
28
|
+
glob: "search",
|
|
29
|
+
find: "search",
|
|
30
|
+
ripgrep: "search",
|
|
31
|
+
"web_search": "web_search",
|
|
32
|
+
"web_fetch": "fetch",
|
|
33
|
+
url: "fetch",
|
|
34
|
+
http: "fetch",
|
|
35
|
+
request: "fetch",
|
|
36
|
+
rename: "move",
|
|
37
|
+
copy: "move",
|
|
38
|
+
mkdir: "create",
|
|
39
|
+
touch: "create",
|
|
40
|
+
};
|
|
41
|
+
return ALIASES[k] ?? k;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Extract the primary file path from a tool-call raw input. */
|
|
45
|
+
export function extractPath(raw: Record<string, unknown>): string {
|
|
46
|
+
return (
|
|
47
|
+
strOf(raw.path) ||
|
|
48
|
+
strOf(raw.file_path) ||
|
|
49
|
+
strOf(raw.filename) ||
|
|
50
|
+
strOf(raw.file) ||
|
|
51
|
+
strOf(raw.filePath) ||
|
|
52
|
+
""
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Extract a secondary/destination path (for moves, renames, copies). */
|
|
57
|
+
export function extractDestPath(raw: Record<string, unknown>): string {
|
|
58
|
+
return (
|
|
59
|
+
strOf(raw.new_path) ||
|
|
60
|
+
strOf(raw.newPath) ||
|
|
61
|
+
strOf(raw.destination) ||
|
|
62
|
+
strOf(raw.dest) ||
|
|
63
|
+
strOf(raw.to) ||
|
|
64
|
+
strOf(raw.target_path) ||
|
|
65
|
+
strOf(raw.targetPath) ||
|
|
66
|
+
""
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Extract search query / pattern from various raw input shapes. */
|
|
71
|
+
export function extractSearchQuery(raw: Record<string, unknown>): string {
|
|
72
|
+
return (
|
|
73
|
+
strOf(raw.pattern) ||
|
|
74
|
+
strOf(raw.query) ||
|
|
75
|
+
strOf(raw.search) ||
|
|
76
|
+
strOf(raw.regex) ||
|
|
77
|
+
strOf(raw.glob) ||
|
|
78
|
+
strOf(raw.term) ||
|
|
79
|
+
strOf(raw.q) ||
|
|
80
|
+
""
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Extract the search path/scope if present. */
|
|
85
|
+
export function extractSearchPath(raw: Record<string, unknown>): string {
|
|
86
|
+
return (
|
|
87
|
+
strOf(raw.path) ||
|
|
88
|
+
strOf(raw.directory) ||
|
|
89
|
+
strOf(raw.dir) ||
|
|
90
|
+
strOf(raw.scope) ||
|
|
91
|
+
strOf(raw.cwd) ||
|
|
92
|
+
strOf(raw.folder) ||
|
|
93
|
+
""
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Extract a URL from a fetch/web request. */
|
|
98
|
+
export function extractUrl(raw: Record<string, unknown>): string {
|
|
99
|
+
return (
|
|
100
|
+
strOf(raw.url) ||
|
|
101
|
+
strOf(raw.uri) ||
|
|
102
|
+
strOf(raw.link) ||
|
|
103
|
+
strOf(raw.endpoint) ||
|
|
104
|
+
""
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Extract command string from an execute/shell call. */
|
|
109
|
+
export function extractCommand(raw: Record<string, unknown>): string {
|
|
110
|
+
return strOf(raw.command) || strOf(raw.cmd) || strOf(raw.shell_command) || "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Extract file content for write/create operations. */
|
|
114
|
+
export function extractContent(raw: Record<string, unknown>): string {
|
|
115
|
+
return (
|
|
116
|
+
strOf(raw.content) ||
|
|
117
|
+
strOf(raw.file_text) ||
|
|
118
|
+
strOf(raw.text) ||
|
|
119
|
+
strOf(raw.data) ||
|
|
120
|
+
""
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Extract include/exclude filters from a search call. */
|
|
125
|
+
export function extractFilters(raw: Record<string, unknown>): { include?: string; exclude?: string } {
|
|
126
|
+
const include = strOf(raw.include) || strOf(raw.glob) || strOf(raw.file_pattern) || strOf(raw.type);
|
|
127
|
+
const exclude = strOf(raw.exclude) || strOf(raw.ignore);
|
|
128
|
+
const out: { include?: string; exclude?: string } = {};
|
|
129
|
+
if (include) out.include = include;
|
|
130
|
+
if (exclude) out.exclude = exclude;
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Truncate text to max chars with ellipsis. */
|
|
135
|
+
export function truncate(text: string, max: number): string {
|
|
136
|
+
if (text.length <= max) return text;
|
|
137
|
+
return text.slice(0, max - 1) + "\u2026";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Collect all content blocks (diffs, text, etc.) from a tool update. */
|
|
141
|
+
export function collectContent(u: SessionUpdate): ToolCallContent[] {
|
|
142
|
+
const out: ToolCallContent[] = [];
|
|
143
|
+
if (Array.isArray(u.content_blocks)) out.push(...u.content_blocks);
|
|
144
|
+
const content = (u as unknown as { content?: unknown }).content;
|
|
145
|
+
if (Array.isArray(content)) out.push(...(content as ToolCallContent[]));
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Collect every file path referenced by a tool call. */
|
|
150
|
+
export function gatherPaths(u: SessionUpdate, raw: Record<string, unknown>): string[] {
|
|
151
|
+
const out: string[] = [];
|
|
152
|
+
const add = (v: unknown): void => {
|
|
153
|
+
if (typeof v === "string" && v) out.push(v);
|
|
154
|
+
};
|
|
155
|
+
add(raw.path);
|
|
156
|
+
add(raw.file_path);
|
|
157
|
+
add(raw.filename);
|
|
158
|
+
add(raw.file);
|
|
159
|
+
if (Array.isArray(raw.operations)) {
|
|
160
|
+
for (const op of raw.operations) {
|
|
161
|
+
if (op && typeof op === "object") add((op as Record<string, unknown>).path);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const b of collectContent(u)) add(b.path);
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function strOf(v: unknown): string {
|
|
169
|
+
return typeof v === "string" ? v : "";
|
|
170
|
+
}
|
package/src/render/tool-call.ts
CHANGED
|
@@ -1,19 +1,41 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
2
|
* Format ACP tool-call updates into clear, RAW markdown blocks so they read
|
|
3
|
-
* distinctly from the agent's prose and thinking.
|
|
4
|
-
*
|
|
3
|
+
* distinctly from the agent's prose and thinking. Each tool kind gets its own
|
|
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
6
|
*/
|
|
6
|
-
import type { SessionUpdate
|
|
7
|
+
import type { SessionUpdate } from "../grok/types.js";
|
|
7
8
|
import { renderUnifiedDiff } from "./diff.js";
|
|
9
|
+
import {
|
|
10
|
+
normalizeKind,
|
|
11
|
+
extractPath,
|
|
12
|
+
extractDestPath,
|
|
13
|
+
extractSearchQuery,
|
|
14
|
+
extractSearchPath,
|
|
15
|
+
extractUrl,
|
|
16
|
+
extractCommand,
|
|
17
|
+
extractContent,
|
|
18
|
+
extractFilters,
|
|
19
|
+
truncate,
|
|
20
|
+
collectContent,
|
|
21
|
+
gatherPaths,
|
|
22
|
+
PREVIEW_MAX,
|
|
23
|
+
CONTENT_PREVIEW_MAX,
|
|
24
|
+
} from "./tool-call-detail.js";
|
|
8
25
|
|
|
9
26
|
const KIND_ICON: Record<string, string> = {
|
|
10
27
|
read: "\u{1F4D6}",
|
|
11
28
|
edit: "\u270F\uFE0F",
|
|
29
|
+
write: "\u{1F4DD}",
|
|
30
|
+
create: "\u{1F4DD}",
|
|
12
31
|
execute: "\u{1F4BB}",
|
|
13
32
|
search: "\u{1F50E}",
|
|
14
33
|
delete: "\u{1F5D1}\uFE0F",
|
|
15
34
|
move: "\u{1F4E6}",
|
|
35
|
+
rename: "\u{1F4E6}",
|
|
16
36
|
fetch: "\u{1F310}",
|
|
37
|
+
web_search: "\u{1F310}",
|
|
38
|
+
web_fetch: "\u{1F310}",
|
|
17
39
|
think: "\u{1F4AD}",
|
|
18
40
|
other: "\u{1F527}",
|
|
19
41
|
};
|
|
@@ -32,77 +54,275 @@ export interface ToolFormatOptions {
|
|
|
32
54
|
|
|
33
55
|
/** Returns a RAW markdown block describing the tool call, or "" to skip. */
|
|
34
56
|
export function formatToolCall(u: SessionUpdate, opts: ToolFormatOptions): string {
|
|
35
|
-
const kind = (u.kind
|
|
57
|
+
const kind = normalizeKind(u.kind);
|
|
36
58
|
const raw = (u.rawInput || {}) as Record<string, unknown>;
|
|
37
59
|
const status = u.status ? (STATUS_ICON[u.status] ?? "") : "";
|
|
38
|
-
const tail = status ?
|
|
60
|
+
const tail = status ? " " + status : "";
|
|
39
61
|
|
|
40
|
-
// Skill load
|
|
41
|
-
|
|
42
|
-
if (kind !== "edit" && kind !== "delete" && kind !== "move") {
|
|
62
|
+
// Skill load
|
|
63
|
+
if (kind !== "edit" && kind !== "delete" && kind !== "move" && kind !== "write" && kind !== "create") {
|
|
43
64
|
const skill = detectSkill(u, raw);
|
|
44
|
-
if (skill) return
|
|
65
|
+
if (skill) return "\u{1F4DA} **Loaded skill: " + skill + "**" + tail;
|
|
45
66
|
}
|
|
46
67
|
|
|
47
|
-
// MCP / extension tool call
|
|
48
|
-
// <tool>" when the call carries no server name).
|
|
68
|
+
// MCP / extension tool call
|
|
49
69
|
const mcp = detectMcp(u, raw, kind);
|
|
50
70
|
if (mcp) {
|
|
51
|
-
const label = mcp.server ?
|
|
52
|
-
|
|
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";
|
|
75
|
+
return out;
|
|
53
76
|
}
|
|
54
77
|
|
|
55
|
-
|
|
56
|
-
|
|
78
|
+
switch (kind) {
|
|
79
|
+
case "execute":
|
|
80
|
+
return formatExecute(raw, tail);
|
|
81
|
+
case "edit":
|
|
82
|
+
return formatEdit(u, raw, tail, opts);
|
|
83
|
+
case "write":
|
|
84
|
+
case "create":
|
|
85
|
+
return formatWrite(kind, raw, tail);
|
|
86
|
+
case "read":
|
|
87
|
+
return formatRead(raw, tail);
|
|
88
|
+
case "search":
|
|
89
|
+
return formatSearch(raw, tail);
|
|
90
|
+
case "delete":
|
|
91
|
+
return formatDelete(raw, tail);
|
|
92
|
+
case "move":
|
|
93
|
+
case "rename":
|
|
94
|
+
return formatMove(kind, raw, tail);
|
|
95
|
+
case "fetch":
|
|
96
|
+
case "web_fetch":
|
|
97
|
+
return formatFetch(raw, tail);
|
|
98
|
+
case "web_search":
|
|
99
|
+
return formatWebSearch(raw, tail);
|
|
100
|
+
default:
|
|
101
|
+
return formatGeneric(u, raw, tail, kind);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
57
104
|
|
|
58
|
-
|
|
105
|
+
// ---- helpers for code fences (avoid backtick-in-template-literal issues) ----
|
|
59
106
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
107
|
+
/** Wrap text in a fenced code block with optional language. */
|
|
108
|
+
function fence(text: string, lang?: string): string {
|
|
109
|
+
const marker = "```";
|
|
110
|
+
return marker + (lang || "") + "\n" + text + "\n" + marker;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- per-kind formatters ----
|
|
64
114
|
|
|
65
|
-
|
|
115
|
+
function formatExecute(raw: Record<string, unknown>, tail: string): string {
|
|
116
|
+
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";
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatEdit(u: SessionUpdate, raw: Record<string, unknown>, tail: string, opts: ToolFormatOptions): string {
|
|
125
|
+
const path = extractPath(raw);
|
|
126
|
+
const title = "Edit " + (path || "file");
|
|
127
|
+
let out = "\u270F\uFE0F **" + title + "**" + tail;
|
|
128
|
+
if (opts.showDiffs) {
|
|
66
129
|
const diff = buildEditDiff(u, raw, opts.diffMaxLines);
|
|
67
130
|
if (diff && diff.block) {
|
|
68
|
-
const stat =
|
|
69
|
-
out +=
|
|
131
|
+
const stat = (diff.added > 0 ? "+" + diff.added : "") + (diff.removed > 0 ? " -" + diff.removed : "");
|
|
132
|
+
out += (stat.trim() ? " (" + stat.trim() + ")" : "") + "\n" + diff.block;
|
|
70
133
|
}
|
|
71
134
|
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatWrite(kind: string, raw: Record<string, unknown>, tail: string): string {
|
|
139
|
+
const path = extractPath(raw);
|
|
140
|
+
const verb = kind === "create" ? "Create" : "Write";
|
|
141
|
+
let out = "\u{1F4DD} **" + verb + " " + (path || "file") + "**" + tail;
|
|
142
|
+
const content = extractContent(raw);
|
|
143
|
+
if (content) {
|
|
144
|
+
out += "\n" + fence(truncate(content, CONTENT_PREVIEW_MAX), detectLang(path)) + "\n";
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
72
148
|
|
|
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");
|
|
155
|
+
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;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function formatSearch(raw: Record<string, unknown>, tail: string): string {
|
|
164
|
+
const query = extractSearchQuery(raw);
|
|
165
|
+
const path = extractSearchPath(raw);
|
|
166
|
+
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)
|
|
175
|
+
out += "\n case-sensitive: " + (raw.case_sensitive ? "yes" : "no");
|
|
73
176
|
return out;
|
|
74
177
|
}
|
|
75
178
|
|
|
179
|
+
function formatDelete(raw: Record<string, unknown>, tail: string): string {
|
|
180
|
+
const path = extractPath(raw);
|
|
181
|
+
return "\u{1F5D1}\uFE0F **Delete " + (path || "file") + "**" + tail;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function formatMove(kind: string, raw: Record<string, unknown>, tail: string): string {
|
|
185
|
+
const src = extractPath(raw);
|
|
186
|
+
const dst = extractDestPath(raw);
|
|
187
|
+
const verb = kind === "rename" ? "Rename" : "Move";
|
|
188
|
+
if (src && dst) {
|
|
189
|
+
return "\u{1F4E6} **" + verb + "**" + tail + "\n \u{1F4C4} " + truncate(src, 100) + "\n \u27A1\uFE0F " + truncate(dst, 100);
|
|
190
|
+
}
|
|
191
|
+
return "\u{1F4E6} **" + verb + " " + (src || dst || "file") + "**" + tail;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function formatFetch(raw: Record<string, unknown>, tail: string): string {
|
|
195
|
+
const url = extractUrl(raw);
|
|
196
|
+
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;
|
|
200
|
+
if (method && method !== "GET") out += "\n method: " + method;
|
|
201
|
+
const headers = raw.headers;
|
|
202
|
+
if (headers && typeof headers === "object") {
|
|
203
|
+
const hs = JSON.stringify(headers);
|
|
204
|
+
if (hs !== "{}") out += "\n headers: " + truncate(hs, 200);
|
|
205
|
+
}
|
|
206
|
+
const body = strOf(raw.body) || strOf(raw.data);
|
|
207
|
+
if (body) out += "\n body: " + truncate(body, 200);
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function formatWebSearch(raw: Record<string, unknown>, tail: string): string {
|
|
212
|
+
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;
|
|
217
|
+
if (count) out += "\n results: " + count;
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function formatGeneric(u: SessionUpdate, raw: Record<string, unknown>, tail: string, kind: string): string {
|
|
222
|
+
const icon = KIND_ICON[kind] ?? KIND_ICON.other;
|
|
223
|
+
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);
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---- diff building ----
|
|
232
|
+
|
|
233
|
+
function buildEditDiff(u: SessionUpdate, raw: Record<string, unknown>, maxLines: number) {
|
|
234
|
+
const blocks = collectContent(u);
|
|
235
|
+
const diffBlock = blocks.find((b) => b.type === "diff");
|
|
236
|
+
if (diffBlock) {
|
|
237
|
+
return renderUnifiedDiff({
|
|
238
|
+
path: strOf(diffBlock.path) || strOf(raw.path) || "file",
|
|
239
|
+
oldText: typeof diffBlock.oldText === "string" ? diffBlock.oldText : "",
|
|
240
|
+
newText: typeof diffBlock.newText === "string" ? diffBlock.newText : "",
|
|
241
|
+
maxLines,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
const oldStr = strOf(raw.old_str) || strOf(raw.oldStr) || strOf(raw.old_string) || strOf(raw.find);
|
|
245
|
+
const newStr = strOf(raw.new_str) || strOf(raw.newStr) || strOf(raw.new_string) || strOf(raw.replace);
|
|
246
|
+
if (oldStr || newStr) {
|
|
247
|
+
return renderUnifiedDiff({ path: strOf(raw.path) || "file", oldText: oldStr, newText: newStr, maxLines });
|
|
248
|
+
}
|
|
249
|
+
const content = strOf(raw.file_text) || strOf(raw.content) || strOf(raw.text);
|
|
250
|
+
if (content) {
|
|
251
|
+
return renderUnifiedDiff({ path: strOf(raw.path) || "file", oldText: "", newText: content, maxLines });
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---- language detection ----
|
|
257
|
+
|
|
258
|
+
function detectLang(path: string): string {
|
|
259
|
+
const ext = (path.split(".").pop() || "").toLowerCase();
|
|
260
|
+
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",
|
|
271
|
+
};
|
|
272
|
+
return MAP[ext] || "";
|
|
273
|
+
}
|
|
274
|
+
|
|
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
|
+
}
|
|
294
|
+
|
|
76
295
|
/** Built-in Grok tools that must never be labelled as MCP calls. */
|
|
77
296
|
const BUILTIN_TOOLS = new Set([
|
|
78
297
|
"read", "write", "shell", "grep", "glob", "web_fetch", "web_search", "fs_read",
|
|
79
298
|
"fs_write", "fs_replace", "fs_search", "execute_bash", "report_issue", "use_aws",
|
|
80
299
|
"todo_list", "introspect", "knowledge", "thinking", "summary", "subagent",
|
|
300
|
+
"edit", "create", "delete", "move", "rename", "execute", "search", "fetch",
|
|
81
301
|
]);
|
|
82
302
|
/** Tool kinds that are first-class file/shell operations (never MCP). */
|
|
83
|
-
const FILE_KINDS = new Set([
|
|
84
|
-
|
|
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. */
|
|
85
308
|
const SKILL_RE = /[\\/]skills[\\/]([^\\/]+)[\\/]SKILL\.md$/i;
|
|
86
|
-
/** Namespaced MCP tool-name shapes
|
|
309
|
+
/** Namespaced MCP tool-name shapes - [, server, method]. */
|
|
87
310
|
const MCP_NS = [
|
|
88
|
-
/^@([a-z0-9._-]+)[/_]{1,3}(.+)$/i,
|
|
89
|
-
/^([a-z0-9.-]+)___(.+)$/i,
|
|
90
|
-
/^([a-z0-9.-]+)__(.+)$/i,
|
|
91
|
-
/^([a-z0-9.-]+)\/(.+)$/i,
|
|
92
|
-
/^([a-z0-9-]+)\.(.+)$/i,
|
|
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,
|
|
93
316
|
];
|
|
94
317
|
|
|
95
|
-
/** The skill name if this tool call loads a `SKILL.md`, else undefined. */
|
|
96
318
|
function detectSkill(u: SessionUpdate, raw: Record<string, unknown>): string | undefined {
|
|
97
319
|
for (const p of gatherPaths(u, raw)) {
|
|
98
320
|
const m = SKILL_RE.exec(p);
|
|
99
|
-
if (m) return m[1]
|
|
321
|
+
if (m) return m[1]!;
|
|
100
322
|
}
|
|
101
323
|
return undefined;
|
|
102
324
|
}
|
|
103
325
|
|
|
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
326
|
function detectMcp(
|
|
107
327
|
u: SessionUpdate,
|
|
108
328
|
raw: Record<string, unknown>,
|
|
@@ -114,83 +334,23 @@ function detectMcp(
|
|
|
114
334
|
const m = re.exec(name);
|
|
115
335
|
if (m) return { server: m[1]!, method: m[2]! };
|
|
116
336
|
}
|
|
117
|
-
// Bare external tool: not a built-in, and not a file/shell operation.
|
|
118
337
|
if (!BUILTIN_TOOLS.has(name.toLowerCase()) && !FILE_KINDS.has(kind)) {
|
|
119
338
|
return { method: name };
|
|
120
339
|
}
|
|
121
340
|
return undefined;
|
|
122
341
|
}
|
|
123
342
|
|
|
124
|
-
/** Best-effort tool name from the raw input or a tool-name-like title. */
|
|
125
343
|
function mcpToolName(u: SessionUpdate, raw: Record<string, unknown>): string {
|
|
126
344
|
const explicit = strOf(raw.tool_name) || strOf(raw.toolName) || strOf(raw.name) || strOf(raw.tool);
|
|
127
345
|
if (explicit) return explicit;
|
|
128
346
|
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
347
|
return /^[@a-z0-9._/-]+$/i.test(t) && !t.includes(":") ? t : "";
|
|
132
348
|
}
|
|
133
349
|
|
|
134
|
-
|
|
135
|
-
|
|
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;
|
|
350
|
+
function capitalize(s: string): string {
|
|
351
|
+
return s.length ? s[0]!.toUpperCase() + s.slice(1) : s;
|
|
188
352
|
}
|
|
189
353
|
|
|
190
354
|
function strOf(v: unknown): string {
|
|
191
355
|
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
|
-
}
|
|
356
|
+
}
|