Files
AgentHub/client/src/lib/event-summary.ts
T
nntrivi2001 c7dbaf2d9f refactor: finish the AgentHub rename and drop the MCP server
Two leftovers from the rebrand, done together because they touch the same
docs and manifests.

**Data directory.** `agent-dashboard` could not simply be renamed in the
source: it names a real directory holding the live SQLite database. Startup
now moves it once, via `migrateLegacyDataDir()` in `server/lib/claude-home.js`
— a same-filesystem rename, so it is instant even at multiple gigabytes and
cannot leave two divergent copies. `getDataDir()` stays PURE and resolves to
the legacy path until the move happens; the first version of this put the
rename inside the getter, which meant `npm run test:server` relocated the
developer's real database as a side effect. The call sits behind
`require.main === module` and ahead of the router requires, because those pull
in db.js, which opens SQLite at module load. A failed rename is silent and the
old directory keeps being used: an unfashionable directory name beats a blank
database beside an unreachable one. Same treatment for the port-discovery file
(`.agent-dashboard.json` → `.agenthub.json`, old one dropped on write).

Not renamed on purpose: the Sagi localStorage keys (renaming silently resets
everyone's prefs) and the PWA manifest `id` (changing it orphans an installed
app).

**MCP server.** 4 tool calls in 46 days across 577,132 events, against 4,556
lines of TypeScript, a 2 MB committed build artifact, and a pre-commit guard
that had already blocked commits twice. Deleted: `mcp/`, the maintainer skills
and `mcp-reviewer` agent, the build check, `plugins/agenthub/.mcp.json`,
`docs/MCP.md`, 12 npm scripts, 5 Makefile targets, and every doc section
describing it.

Kept, because they are unrelated things sharing the name: the Config
Explorer's MCP tab and its API, per-lane Playwright MCP, the agenthub-config
plugin's MCP audit, and the historical `mcp__plugin_agenthub_*` events that
analytics must keep attributing.

Verified: server 1432/1432, client 459/459, tsc clean, prettier clean. Both
directions of the migration proven — a sandbox-HOME test run leaves the legacy
directory untouched, and a real boot moves it with its contents intact.
2026-09-08 09:48:40 +07:00

657 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file event-summary.ts
* @description Produces a short, human-readable summary of a DashboardEvent
* for the top of the expanded EventDetail panel. Purely data-driven - parses
* `tool_input` / `tool_response` and extracts the most useful facts. Returns
* null for events where a summary would add nothing (e.g. unknown tools with
* empty payloads).
*
* ## Output
* The single public entry point, {@link buildEventSummary}, returns an
* {@link EventSummary} — an `{ icon, headline, bullets }` triple. The icon is an
* emoji chosen per event/tool kind, the headline is a one-line description, and
* the bullets are optional supporting facts (diff stats, line counts, error
* flags). It returns null only when there is genuinely nothing to show.
*
* ## How it differs from event-grouping.ts
* `event-grouping.ts` produces the *collapsed* one-line title for a timeline
* row. This file produces the *expanded* detail summary and therefore also
* parses `tool_response` (not just `tool_input`) to report on outcomes — how
* many lines a command printed, how many hunks an edit touched, how many matches
* a search found, and so on.
*
* ## Event shape
* Each helper reads a {@link DashboardEvent}. Relevant fields: `event_type`
* (lifecycle name), `tool_name` (present only on tool events), and `data` — a
* JSON *string* whose parsed form usually holds `tool_input` (arguments) and
* `tool_response` (result). All parsing is defensive: malformed JSON, missing
* fields, and unexpected types degrade to a smaller summary or null rather than
* throwing.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `./types`
*
* ## Public surface
* - `EventSummary` — exported API; see TSDoc on the symbol for behavior.
* - `buildEventSummary` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **EventSummary**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **buildEventSummary**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import type { DashboardEvent } from "./types";
/** Rendered result of {@link buildEventSummary}: an emoji icon, a one-line
* headline, and zero or more supporting detail lines shown underneath it. */
export type EventSummary = {
/** Single emoji representing the event kind (tool-specific or lifecycle). */
icon: string;
/** Primary one-line description, e.g. "Edited SessionDetail.tsx". */
headline: string;
/** Secondary detail lines (diff stats, line counts, error flags, …); may be empty. */
bullets: string[];
};
// ════════════════════════════════════════════════════════════════════════════
// Small value / formatting helpers
// ════════════════════════════════════════════════════════════════════════════
/** Coerces an unknown value to a string, returning "" for non-strings.
* @param v Any value pulled out of a parsed payload.
* @returns The value when it's a string, otherwise "".
*/
function str(v: unknown): string {
return typeof v === "string" ? v : "";
}
/** Narrows an unknown value to a plain object (not null, not an array).
* @param v Any value (typically from `JSON.parse`).
* @returns The value typed as a record, or null when it isn't a plain object.
*/
function obj(v: unknown): Record<string, unknown> | null {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
/** Compact two-segment path label (parent dir + filename), mirroring the helper
* of the same name in event-grouping.ts. Falls back to the sole segment.
* @param path An absolute or relative path (POSIX or Windows separators).
* @returns The last two segments joined with "/", or the single segment.
* @example shortPath("/repo/src/lib/x.ts") // "lib/x.ts"
*/
function shortPath(path: string): string {
const parts = path.split(/[/\\]/).filter(Boolean);
if (parts.length <= 1) return parts[0] ?? path;
return parts.slice(-2).join("/");
}
/** Truncates `text` to `max` chars, appending an ellipsis when it was clipped.
* @param text The string to clamp.
* @param max Maximum length before truncation (exclusive of the "...").
* @returns The original string, or its first `max` chars followed by "...".
*/
function trunc(text: string, max: number): string {
return text.length > max ? text.slice(0, max) + "..." : text;
}
/** Parses `event.data` (JSON) into a plain object, or null on empty/invalid data.
* @param event The event whose JSON `data` string should be decoded.
* @returns The parsed payload object, or null when data is absent, invalid JSON,
* or not a plain object.
*/
function parseData(event: DashboardEvent): Record<string, unknown> | null {
if (!event.data) return null;
try {
const v = JSON.parse(event.data);
// Reuse `obj` so arrays / primitives (never valid payloads here) yield null.
return obj(v);
} catch {
// Malformed JSON — no summary data available.
return null;
}
}
// ════════════════════════════════════════════════════════════════════════════
// Tool-response analyzers
// ════════════════════════════════════════════════════════════════════════════
/** Counts hunks and +/- lines in an Edit tool response's `structuredPatch`.
*
* Claude Code's Edit/NotebookEdit responses include a `structuredPatch`: an
* array of hunks, each with a `lines: string[]` where every entry is prefixed
* by " ", "+", or "-" (unified-diff style). This tallies additions / removals
* so the summary can show "3 hunks · +12 −4".
* @param structuredPatch The `tool_response.structuredPatch` value (untyped).
* @returns `{ hunks, added, removed }`; all zero when the input isn't an array.
* @example
* countHunks([{ lines: ["+new", "-old", " ctx"] }]) // { hunks:1, added:1, removed:1 }
*/
function countHunks(structuredPatch: unknown): { hunks: number; added: number; removed: number } {
// Non-array (missing / failed patch) → nothing to count.
if (!Array.isArray(structuredPatch)) return { hunks: 0, added: 0, removed: 0 };
let added = 0;
let removed = 0;
for (const raw of structuredPatch) {
const r = obj(raw);
// Skip malformed hunks that lack a `lines` array.
if (!r || !Array.isArray(r.lines)) continue;
for (const line of r.lines) {
if (typeof line !== "string") continue;
// Leading "+" = added line, leading "-" = removed line; " " = context.
if (line.startsWith("+")) added++;
else if (line.startsWith("-")) removed++;
}
}
// Hunk count is simply the number of patch entries.
return { hunks: structuredPatch.length, added, removed };
}
/** Finds the nearest function/class/const definition line surrounding an
* Edit hunk, so the summary can show "Inside: function foo(...)".
*
* Scans the patch's lines for the first one matching a definition-like shape.
* The pattern intentionally requires a leading space (`^\s+`) so it matches
* *context* lines (unchanged surroundings) rather than the "+"/"-" changed
* lines — the enclosing definition is usually context, not the edit itself. It
* recognizes JS/TS `function` / `const|let|var` / `name = (`, Python `def`, and
* `class` across languages.
* @param structuredPatch The `tool_response.structuredPatch` value (untyped).
* @returns The trimmed definition line, or null when none is found.
*/
function firstEnclosingContext(structuredPatch: unknown): string | null {
// Look for a context line that looks like a function/class/const definition.
if (!Array.isArray(structuredPatch)) return null;
const defPattern =
/^\s+(?:function\s+\w+|def\s+\w+|class\s+\w+|(?:const|let|var)\s+\w+|\w+\s*=\s*\()/;
for (const raw of structuredPatch) {
const r = obj(raw);
if (!r || !Array.isArray(r.lines)) continue;
for (const line of r.lines) {
// First matching line wins; trim the diff indentation for display.
if (typeof line === "string" && defPattern.test(line)) {
return line.trim();
}
}
}
return null;
}
/** Counts lines in `text` (empty string counts as 0, not 1).
*
* Splits on LF or CRLF. The empty-string guard matters: `"".split(/\n/)`
* returns `[""]` (length 1), which would wrongly report an empty output as
* "1 line".
* @param text The text whose lines to count.
* @returns The number of newline-separated lines, or 0 for empty input.
*/
function lineCount(text: string): number {
if (!text) return 0;
return text.split(/\r?\n/).length;
}
/** Formats a millisecond duration as "Nms" / "N.Ns" / "Nm Ns", for TurnDuration events.
*
* Three tiers keep the label readable at any scale: raw milliseconds under a
* second, one-decimal seconds under a minute, and "Xm Ys" beyond that.
* @param ms A non-negative duration in milliseconds.
* @returns A compact human-readable duration string.
* @example formatDuration(450) // "450ms"
* @example formatDuration(1500) // "1.5s"
* @example formatDuration(95000) // "1m 35s"
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`;
}
/**
* Builds the icon/headline/bullets summary shown at the top of the expanded
* EventDetail panel. Dispatches first on `event_type` for lifecycle events
* (Stop, TurnDuration, Compaction, Notification, SessionStart/End, APIError),
* then on `tool_name` for tool events - parsing `data`'s `tool_input`/
* `tool_response` to surface tool-specific facts (diff stats for Edit, line
* counts for Read/Write, match counts for Grep/Glob, etc.).
* @param event The raw event to summarize.
* @returns An {@link EventSummary}, or null when there's no tool name and no
* recognized event type to build anything useful from.
*/
export function buildEventSummary(event: DashboardEvent): EventSummary | null {
const data = parseData(event);
// ── Non-tool events first ──────────────────────────────────────────
// Lifecycle events have no tool payload; each maps to a fixed icon / headline
// plus any incidental detail carried in `data`.
// Stop / SubagentStop: a turn (or subagent turn) ended. Optionally note the
// stop-hook flag and the first line of the last assistant message.
if (event.event_type === "Stop" || event.event_type === "SubagentStop") {
const stopHookActive = data?.stop_hook_active === true;
const msg = str(data?.last_assistant_message);
const bullets: string[] = [];
if (stopHookActive) bullets.push("stop hook active");
// Only the first line keeps the bullet compact for multi-line messages.
if (msg) bullets.push(`Last message: ${trunc(msg.split(/\r?\n/)[0] ?? "", 80)}`);
return {
icon: "🛑",
headline: event.event_type === "SubagentStop" ? "Subagent turn ended" : "Turn ended",
bullets,
};
}
// TurnDuration: synthetic event carrying how long the turn took, in ms.
if (event.event_type === "TurnDuration") {
const durationMs = typeof data?.durationMs === "number" ? data.durationMs : null;
return {
icon: "⏱️",
headline: durationMs != null ? `Turn took ${formatDuration(durationMs)}` : "Turn finished",
bullets: [],
};
}
// Compaction: the transcript was summarized to reclaim context window.
if (event.event_type === "Compaction") {
return {
icon: "🗜️",
headline: "Transcript compacted",
bullets: ["Token usage reset for the following turn"],
};
}
// Notification: a user-facing message from the agent; show text + optional type.
if (event.event_type === "Notification") {
const msg = str(data?.message);
const type = str(data?.notification_type);
return {
icon: "🔔",
headline: msg ? `Notification: ${trunc(msg, 80)}` : "Notification",
bullets: type ? [`Type: ${type}`] : [],
};
}
// SessionStart / SessionEnd: bracket a session; surface its source and model
// when the payload includes them. The `.filter(Boolean)` drops empty bullets.
if (event.event_type === "SessionStart" || event.event_type === "SessionEnd") {
const source = str(data?.source);
const model = str(data?.model);
return {
icon: event.event_type === "SessionStart" ? "🎬" : "🏁",
headline: event.event_type === "SessionStart" ? "Session started" : "Session ended",
bullets: [source && `Source: ${source}`, model && `Model: ${model}`].filter(
Boolean
) as string[],
};
}
// APIError: a provider / API failure was recorded for this event.
if (event.event_type === "APIError") {
return {
icon: "⚠️",
headline: "API error recorded",
bullets: [],
};
}
// ── Tool events ────────────────────────────────────────────────────
// Past the lifecycle branches, only tool events remain. Without a tool_name
// there's nothing to summarize.
const tool = event.tool_name;
if (!tool) return null;
// Both may be null: `tool_input` is the arguments, `tool_response` the result.
const input = obj(data?.tool_input);
const response = obj(data?.tool_response);
// MCP tools - generic summary. Any "mcp__" tool is summarized structurally,
// pulling the most relevant field out of the call args and the response.
if (tool.startsWith("mcp__")) {
const headline = humanizeMcp(tool);
const bullets: string[] = [];
if (input) {
// Surface the most identifying call argument, if any.
const top = firstStringField(input, ["title", "query", "q", "url", "name", "id"]);
if (top) bullets.push(`Called with: ${trunc(top, 80)}`);
}
if (response) {
// Prefer a recognizable response field; otherwise report its field count.
const resTop = firstStringField(response, ["title", "name", "state", "status", "url"]);
if (resTop) bullets.push(`Response: ${trunc(resTop, 80)}`);
else bullets.push(`Returned ${Object.keys(response).length} fields`);
}
return { icon: "🧩", headline, bullets };
}
// ── Native tools ───────────────────────────────────────────────────
// Each case builds an icon + headline + outcome bullets from that tool's
// specific input / response shape.
switch (tool) {
case "Bash":
case "PowerShell": {
// Report the command plus stdout/stderr line counts and interruption.
const cmd = str(input?.command);
const desc = str(input?.description);
const stdout = str(response?.stdout);
const stderr = str(response?.stderr);
const interrupted = response?.interrupted === true;
const bullets: string[] = [];
if (desc) bullets.push(`"${desc}"`);
if (stdout) bullets.push(`${lineCount(stdout)} lines stdout`);
if (stderr) bullets.push(`${lineCount(stderr)} lines stderr`);
// Distinguish "empty stderr" (we have a response) from "unknown" (no
// response yet) — only claim "no stderr" once some output / response exists.
else if (stdout || response) bullets.push("no stderr");
if (interrupted) bullets.push("⚠ interrupted");
return {
icon: "💻",
// Headline leads with the binary, then the full (clamped) command.
headline: cmd ? `Ran ${trunc(firstWord(cmd), 40)}: ${trunc(cmd, 80)}` : `${tool} call`,
bullets,
};
}
case "Edit":
case "NotebookEdit": {
// Summarize the diff: enclosing definition, hunk / line counts, and whether
// it was a global replace_all.
const path = str(input?.file_path);
const { hunks, added, removed } = countHunks(response?.structuredPatch);
const ctx = firstEnclosingContext(response?.structuredPatch);
const replaceAll = input?.replace_all === true;
const bullets: string[] = [];
if (ctx) bullets.push(`Inside: ${trunc(ctx, 80)}`);
// Pluralize "hunk" and show the "+added −removed" tally.
if (hunks > 0) bullets.push(`${hunks} hunk${hunks === 1 ? "" : "s"} · +${added} −${removed}`);
if (replaceAll) bullets.push("replace_all mode");
return {
icon: "✏️",
headline: path ? `Edited ${shortPath(path)}` : `${tool} call`,
bullets,
};
}
case "Write": {
// Report the size of the written content in lines and bytes.
const path = str(input?.file_path);
const content = str(input?.content);
const bullets: string[] = [];
if (content) bullets.push(`${lineCount(content)} lines · ${content.length} bytes`);
return {
icon: "📝",
headline: path ? `Wrote ${shortPath(path)}` : `Write call`,
bullets,
};
}
case "Read": {
// Note whether a partial range (offset/limit) or the full file was read,
// plus how many lines came back.
const path = str(input?.file_path);
const offset = input?.offset;
const limit = input?.limit;
const bullets: string[] = [];
if (offset != null || limit != null) {
// A range read — describe whichever bounds were provided.
const parts: string[] = [];
if (offset != null) parts.push(`offset ${offset}`);
if (limit != null) parts.push(`limit ${limit}`);
bullets.push(`Range: ${parts.join(", ")}`);
} else {
bullets.push("Full file");
}
// Read responses come back as a raw string of file contents.
if (typeof response === "string") {
bullets.push(`${lineCount(response)} lines returned`);
}
return {
icon: "📖",
headline: path ? `Read ${shortPath(path)}` : "Read call",
bullets,
};
}
case "Grep": {
// Headline the search pattern (+ scope); bullet the match count.
const pattern = str(input?.pattern);
const path = str(input?.path);
const bullets: string[] = [];
const matchCount = countGrepMatches(response);
// Pluralize "match" / "matches" based on the count.
if (matchCount != null) bullets.push(`${matchCount} match${matchCount === 1 ? "" : "es"}`);
return {
icon: "🔍",
headline: pattern
? `Searched "${trunc(pattern, 50)}"${path ? ` in ${shortPath(path)}` : ""}`
: "Grep call",
bullets,
};
}
case "Glob": {
// Headline the glob pattern; bullet how many files matched.
const pattern = str(input?.pattern);
const bullets: string[] = [];
const fileCount = countFiles(response);
if (fileCount != null) bullets.push(`${fileCount} file${fileCount === 1 ? "" : "s"}`);
return {
icon: "🗂️",
headline: pattern ? `Listed files matching "${pattern}"` : "Glob call",
bullets,
};
}
case "WebFetch": {
// Headline the fetched host; bullet the extraction prompt and response size.
const url = str(input?.url);
const prompt = str(input?.prompt);
const bullets: string[] = [];
if (prompt) bullets.push(`Prompt: ${trunc(prompt, 80)}`);
if (typeof response === "string") bullets.push(`${lineCount(response)} lines returned`);
let host = "";
try {
host = new URL(url).host;
} catch {
// Malformed / relative URL — show the raw string instead of the host.
host = url;
}
return {
icon: "🌐",
headline: url ? `Fetched ${host}` : "WebFetch call",
bullets,
};
}
case "Task":
case "Agent": {
// Detail the spawned subagent's kind, task, and output size.
const subtype = str(input?.subagent_type);
const desc = str(input?.description);
const bullets: string[] = [];
if (subtype) bullets.push(`Subagent: ${subtype}`);
if (desc) bullets.push(`Description: ${trunc(desc, 80)}`);
if (typeof response === "string") bullets.push(`${lineCount(response)} lines output`);
return { icon: "🤖", headline: `Spawned subagent`, bullets };
}
case "TaskCreate": {
// A task was created — headline its description.
const d = str(input?.description);
return {
icon: "✅",
headline: d ? `Created task: ${trunc(d, 80)}` : "TaskCreate",
bullets: [],
};
}
case "TaskUpdate": {
// A task was updated — prefer its description, falling back to its id.
const d = str(input?.description) || str(input?.id);
return {
icon: "🔄",
headline: d ? `Updated task: ${trunc(d, 80)}` : "TaskUpdate",
bullets: [],
};
}
case "AskUserQuestion": {
// Headline the first question's text from the `questions` array.
const qs = Array.isArray(input?.questions) ? input?.questions : null;
const first = qs && qs.length > 0 ? obj(qs[0]) : null;
const q = first ? str(first.question) : "";
return {
icon: "❓",
headline: q ? `Asked: "${trunc(q, 80)}"` : "Asked user",
bullets: [],
};
}
case "ScheduleWakeup": {
// Headline the delay; bullet the reason when supplied.
const delay = input?.delaySeconds;
const reason = str(input?.reason);
return {
icon: "😴",
headline: typeof delay === "number" ? `Scheduled wakeup in ${delay}s` : "Scheduled wakeup",
bullets: reason ? [`Reason: ${trunc(reason, 80)}`] : [],
};
}
default: {
// Unknown native tool - minimal summary. With neither input nor response
// there is nothing worth showing, so return null (no summary card).
if (!input && !response) return null;
return {
icon: "🔧",
headline: `${tool} call`,
// At least report how many input fields were passed.
bullets: input ? [`${Object.keys(input).length} input fields`] : [],
};
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// MCP name + field extraction helpers
// ════════════════════════════════════════════════════════════════════════════
/** Turns an `mcp__server__tool_name` tool name into "Server · tool name" for
* the MCP-tool summary headline (duplicates the dedupe/casing logic in
* event-grouping.ts's `humanizeMcpServer`, kept local to avoid a cross-import).
* @param toolName A namespaced MCP tool name, e.g. "mcp__github__list_issues".
* @returns "Server · tool name" (e.g. "Github · list issues"), or the raw name
* when it doesn't have the expected 3+ `__`-separated segments.
* @example humanizeMcp("mcp__claude_ai_Slack__send_message") // "Slack · send message"
*/
function humanizeMcp(toolName: string): string {
const parts = toolName.split("__").filter(Boolean);
// Not a well-formed MCP name — return it untouched.
if (parts.length < 3) return toolName;
const rawServer = parts[1] ?? "";
// Everything past the server is the (possibly multi-word) action.
const rest = parts.slice(2).join(" ");
// Reuse the same server-humanization logic as elsewhere: split, dedupe, last token.
const tokens = rawServer.split(/[_-]+/).filter(Boolean);
const dedup: string[] = [];
for (const t of tokens) if (dedup[dedup.length - 1] !== t) dedup.push(t);
const last = dedup[dedup.length - 1] ?? rawServer;
// Capitalize only when the token is all-lowercase (preserves "GitLab" etc.).
const server = last.toLowerCase() === last ? last.charAt(0).toUpperCase() + last.slice(1) : last;
// Normalize the action to lowercase spaced words.
const toolPart = rest.replace(/_+/g, " ").trim().toLowerCase();
return `${server} · ${toolPart}`;
}
/** Extracts the first whitespace-delimited token of a shell command (the binary).
* @param command The full command string.
* @returns The first non-whitespace run, or the original string if none.
* @example firstWord(" npm run build") // "npm"
*/
function firstWord(command: string): string {
const m = command.trim().match(/^(\S+)/);
return m ? (m[1] ?? command) : command;
}
/** Returns the first non-empty string value found in `obj` among `priority`
* keys, in order - used to surface the most relevant MCP call/response field.
* @param obj The object to inspect (a parsed input or response).
* @param priority Keys to try, most-preferred first.
* @returns The first matching non-empty string, or null when none match.
*/
function firstStringField(obj: Record<string, unknown>, priority: string[]): string | null {
for (const k of priority) {
const v = obj[k];
if (typeof v === "string" && v.length > 0) return v;
}
return null;
}
/** Best-effort match count from a Grep tool response, checking a few
* known response shapes (array, `.matches`, `.files`, `.count`, `.numFiles`).
*
* Grep results have varied over time and by output mode, so each known shape is
* probed in turn and the first that fits wins.
* @param response The `tool_response` value (untyped).
* @returns The match / file count, or null when no known shape applies.
*/
function countGrepMatches(response: unknown): number | null {
if (!response) return null;
// Plain array of matches.
if (Array.isArray(response)) return response.length;
const r = obj(response);
if (!r) return null;
// Object wrappers seen across Grep output modes.
if (Array.isArray(r.matches)) return r.matches.length;
if (Array.isArray(r.files)) return r.files.length;
if (typeof r.count === "number") return r.count;
if (typeof r.numFiles === "number") return r.numFiles;
return null;
}
/** Best-effort file count from a Glob tool response (array, `.files`, or `.paths`).
* @param response The `tool_response` value (untyped).
* @returns The number of matched files, or null when no known shape applies.
*/
function countFiles(response: unknown): number | null {
if (Array.isArray(response)) return response.length;
const r = obj(response);
if (!r) return null;
if (Array.isArray(r.files)) return r.files.length;
if (Array.isArray(r.paths)) return r.paths.length;
return null;
}