c7dbaf2d9f
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.
657 lines
29 KiB
TypeScript
657 lines
29 KiB
TypeScript
/**
|
||
* @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;
|
||
}
|