import { compactActivityPath, redactActivityText } from "./activity-text"; export type GenericToolStatus = "running" | "done" | "error"; export type ToolFamily = "content-search" | "file-search" | "list" | "read" | "memory" | "generic"; interface ToolField { key: | "query" | "pattern" | "glob" | "path" | "file_path" | "url" | "action" | "key" | "label" | "name" | "channel" | "chat_id" | "session_id" | "ui_summary"; value: string; } export interface GenericToolTrace { name: string; family: ToolFamily; groupKey: string; fields: ToolField[]; collectedSource: boolean; } export interface GenericToolRunItem { trace: GenericToolTrace; status: GenericToolStatus; error?: string; } export interface GenericToolPresentation { status: GenericToolStatus; label: string; detail: string; aside: string; } const CONTENT_SEARCH_TOOLS = new Set([ "grep", "rg", "ripgrep", "search_code", "search_content", "search_files_content", "find_text", ]); const FILE_SEARCH_TOOLS = new Set([ "find", "find_file", "find_files", "glob", "search_files", ]); const LIST_TOOLS = new Set(["list_dir", "list_directory", "list_files", "ls"]); const READ_TOOLS = new Set(["read", "read_file", "read_text_file"]); const MEMORY_TOOLS = new Set(["memory_search", "search_memory", "recall_memory"]); const EXCLUDED_TOOL_PREFIXES = ["mcp_"]; const EXCLUDED_TOOLS = new Set([ "apply_patch", "cli_anything_run", "edit_file", "exec", "exec_command", "execute_command", "run_cli_app", "run_command", "run_shell", "shell", "terminal", "web_fetch", "web_search", "x_search", "write_file", ]); export function parseGenericToolTrace(line: string): GenericToolTrace | null { const call = parseCall(line); if (!call || isExcludedTool(call.name)) return null; const family = toolFamily(call.name); const fields = safeFields(call.args); const collectedSource = fields.some((field) => isCollectedSourcePath(field.value)); return { name: call.name, family, groupKey: family === "generic" ? `${family}:${call.name}` : `${family}:${collectedSource ? "collected" : "workspace"}`, fields, collectedSource, }; } export function canGroupGenericToolRuns(previous: GenericToolRunItem, next: GenericToolRunItem): boolean { return previous.trace.groupKey === next.trace.groupKey; } function compactGenericToolPath(value: string): string { const normalized = redactActivityText(value).replace(/\\/g, "/"); if (isCollectedSourcePath(normalized)) { return truncateMiddle(normalized.split("/").pop() || "collected source", 64); } return compactActivityPath(normalized); } export function describeGenericToolRun(items: GenericToolRunItem[]): GenericToolPresentation { const status = aggregateStatus(items); const family = items[0]?.trace.family ?? "generic"; const name = items[0]?.trace.name ?? "tool"; const collected = items.length > 0 && items.every((item) => item.trace.collectedSource); return { status, label: activityLabel(family, status, collected, name, items), detail: activityDetail(items, family, name), aside: activityAside(items, family), }; } function parseCall(line: string): { name: string; args: unknown } | null { const match = /^([a-zA-Z0-9_.-]+)\((.*)\)$/.exec(line.trim()); if (!match) return null; const name = compactToolName(match[1]); let args: unknown; try { args = match[2].trim() ? JSON.parse(match[2]) : {}; } catch { args = {}; } return { name, args }; } function compactToolName(name: string): string { return name.toLowerCase().split(".").pop() || name.toLowerCase(); } function isExcludedTool(name: string): boolean { return EXCLUDED_TOOLS.has(name) || EXCLUDED_TOOL_PREFIXES.some((prefix) => name.startsWith(prefix)); } function toolFamily(name: string): ToolFamily { if (CONTENT_SEARCH_TOOLS.has(name)) return "content-search"; if (FILE_SEARCH_TOOLS.has(name)) return "file-search"; if (LIST_TOOLS.has(name)) return "list"; if (READ_TOOLS.has(name)) return "read"; if (MEMORY_TOOLS.has(name)) return "memory"; return "generic"; } function safeFields(args: unknown): ToolField[] { if (!args || typeof args !== "object" || Array.isArray(args)) return []; const record = args as Record; const fields: ToolField[] = []; for (const key of [ "query", "pattern", "glob", "path", "file_path", "url", "action", "key", "label", "name", "channel", "chat_id", "session_id", "ui_summary", ] as const) { const value = record[key]; if (typeof value === "string" && value.trim()) { fields.push({ key, value: value.trim() }); } } return fields; } function aggregateStatus(items: GenericToolRunItem[]): GenericToolStatus { if (items.some((item) => item.status === "error")) return "error"; if (items.some((item) => item.status === "running")) return "running"; return "done"; } function activityLabel( family: ToolFamily, status: GenericToolStatus, collected: boolean, name: string, items: GenericToolRunItem[], ): string { if (family === "content-search") { return statusCopy( status, collected ? "Reviewing sources" : "Searching files", collected ? "Reviewed sources" : "Searched files", collected ? "Could not review sources" : "Could not search files", ); } if (family === "file-search") { return statusCopy(status, "Finding files", "Found files", "Could not find files"); } if (family === "list") { return statusCopy(status, "Listing files", "Listed files", "Could not list files"); } if (family === "read") { return statusCopy( status, collected ? "Reading source" : "Reading file", collected ? "Read source" : "Read file", collected ? "Could not read source" : "Could not read file", ); } if (family === "memory") { return statusCopy(status, "Searching memory", "Searched memory", "Could not search memory"); } const action = fieldValue(items[0]?.trace, "action").toLowerCase(); switch (name) { case "generate_image": return statusCopy(status, "Generating image", "Generated image", "Could not generate image"); case "spawn": return statusCopy(status, "Delegating task", "Delegated task", "Could not delegate task"); case "message": return statusCopy(status, "Sending message", "Sent message", "Could not send message"); case "my": return action === "set" || action === "modify" ? statusCopy(status, "Updating agent settings", "Updated agent settings", "Could not update agent settings") : statusCopy(status, "Checking agent settings", "Checked agent settings", "Could not check agent settings"); case "cron": if (action === "add") return statusCopy(status, "Scheduling automation", "Scheduled automation", "Could not schedule automation"); if (action === "remove") return statusCopy(status, "Removing automation", "Removed automation", "Could not remove automation"); return statusCopy(status, "Checking automations", "Checked automations", "Could not check automations"); case "create_goal": return statusCopy(status, "Starting long task", "Started long task", "Could not start long task"); case "update_goal": return statusCopy(status, "Updating long task", "Updated long task", "Could not update long task"); // TODO(0.3.2): Remove write_stdin display compatibility after 0.3.1. case "exec_session": case "write_stdin": return statusCopy(status, "Continuing command", "Continued command", "Could not continue command"); case "list_exec_sessions": return statusCopy(status, "Checking running commands", "Checked running commands", "Could not check running commands"); case "screenshot": case "capture_screenshot": return statusCopy(status, "Capturing screenshot", "Captured screenshot", "Could not capture screenshot"); default: { const humanName = humanizeToolName(name); return statusCopy( status, `Running ${humanName}`, `Completed ${humanName}`, `Could not complete ${humanName}`, ); } } } function activityDetail(items: GenericToolRunItem[], family: ToolFamily, name: string): string { if (items.length !== 1) return ""; const trace = items[0].trace; if (family === "content-search") { return quote(fieldValue(trace, "query") || fieldValue(trace, "pattern")); } if (family === "file-search") { return compactDetail( fieldValue(trace, "glob") || fieldValue(trace, "query") || fieldValue(trace, "pattern") || fieldValue(trace, "path"), ); } if (family === "list" || family === "read") { return compactDetail(fieldValue(trace, "path") || fieldValue(trace, "file_path")); } if (family === "memory") return quote(fieldValue(trace, "query")); switch (name) { case "spawn": return safeText(fieldValue(trace, "label")); case "message": return safeText(fieldValue(trace, "channel")); case "my": return safeText(fieldValue(trace, "key")); case "cron": return safeText(fieldValue(trace, "name")); case "create_goal": return safeText(fieldValue(trace, "ui_summary")); case "update_goal": return safeText(fieldValue(trace, "action")); // TODO(0.3.2): Remove write_stdin display compatibility after 0.3.1. case "exec_session": case "write_stdin": return compactIdentifier(fieldValue(trace, "session_id")); case "screenshot": case "capture_screenshot": return ""; default: return ""; } } function activityAside(items: GenericToolRunItem[], family: ToolFamily): string { const pathCount = uniqueValues(items, ["path", "file_path"]).length; if (pathCount > 1) return `${pathCount} files`; if (items.length <= 1) return ""; if (family === "content-search" || family === "file-search" || family === "memory") { return `${items.length} searches`; } return `${items.length} actions`; } function fieldValue(trace: GenericToolTrace | undefined, key: ToolField["key"]): string { return trace?.fields.find((field) => field.key === key)?.value ?? ""; } function uniqueValues(items: GenericToolRunItem[], keys: ToolField["key"][]): string[] { const values = items.flatMap((item) => item.trace.fields) .filter((field) => keys.includes(field.key)) .map((field) => field.value); return [...new Set(values)]; } function statusCopy(status: GenericToolStatus, running: string, done: string, failed: string): string { return status === "running" ? running : status === "error" ? failed : done; } function compactDetail(value: string): string { return value ? truncateMiddle(compactGenericToolPath(value), 88) : ""; } function safeText(value: string): string { return value ? truncateMiddle(redactActivityText(value).replace(/\s+/g, " ").trim(), 88) : ""; } function quote(value: string): string { const safe = safeText(value); return safe ? `“${safe}”` : ""; } function compactIdentifier(value: string): string { const safe = safeText(value); if (safe.length <= 16) return safe; return `${safe.slice(0, 7)}…${safe.slice(-5)}`; } function humanizeToolName(name: string): string { const words = name .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .replace(/[._-]+/g, " ") .replace(/\s+/g, " ") .trim() .toLowerCase(); return words ? `${words[0].toUpperCase()}${words.slice(1)}` : "tool action"; } function isCollectedSourcePath(value: string): boolean { const normalized = value.replace(/\\/g, "/"); return normalized.includes("/.nanobot/tool-results/") || normalized.includes("/nanobot/tool-results/"); } function truncateMiddle(value: string, maxLength: number): string { if (value.length <= maxLength) return value; const head = Math.ceil((maxLength - 1) * 0.62); const tail = Math.floor((maxLength - 1) * 0.38); return `${value.slice(0, head)}…${value.slice(-tail)}`; }