import type { SuperagentToolCall } from '../types'; import { parseToolArgs } from './toolWidgetUtils'; /** * Pure logic backing the search/web tool widgets (SearchWeb, GrepSearch, * GetConnectorsInfo). Ports of the parsers in the web builder's tools-ui * Components (SearchWeb.tsx, GrepSearch.tsx) so the native widgets read the * same queries, result links and match counts. No RN imports — unit-testable * in the node environment. */ // ── search_web / web_search ──────────────────────────────────────────────── export type SearchResultLink = { url: string; title?: string; }; /** * The search query, mirroring the web extractQuery: batched `queries[0] * .query_or_url` wins, then `query` / `query_or_url`, then a regex over the * raw (possibly still-streaming, unparseable) arguments string. */ export function extractSearchQuery(toolCall: SuperagentToolCall): string { const parsed = parseToolArgs(toolCall); if (parsed) { const args = (Array.isArray(parsed) ? parsed[0] : parsed) as Record | undefined; const queries = args?.queries; if (Array.isArray(queries) && queries.length > 0) { const first = queries[0] as { query_or_url?: unknown } | undefined; if (typeof first?.query_or_url === 'string') return first.query_or_url; } const fromArgs = args?.query ?? args?.query_or_url; if (typeof fromArgs === 'string' && fromArgs) return fromArgs; } const raw = typeof toolCall.arguments_string === 'string' ? toolCall.arguments_string : ''; const match = raw.match(/"(?:query|query_or_url)"\s*:\s*"([^"]*)/); return match?.[1] || ''; } /** * Result links out of the search tool output, same two passes as the web: * `` blocks yield url + following title line; otherwise fall back * to scraping unique URLs from the raw text. */ export function parseSearchResults(results: SuperagentToolCall['results']): SearchResultLink[] { if (!results) return []; const raw = typeof results === 'string' ? results : JSON.stringify(results); const found: SearchResultLink[] = []; const queryBlocks = raw.match(/]*>([\s\S]*?)<\/web_query>/g) || []; for (const block of queryBlocks) { const lines = block.split('\n'); for (let i = 0; i < lines.length; i += 1) { const line = lines[i].trim(); if (/^https?:\/\//.test(line)) { const title = lines[i + 1]?.trim(); found.push({ url: line, title: title && !title.startsWith('http') && !title.startsWith('---') ? title : undefined, }); } } } if (found.length === 0) { const urls = raw.match(/https?:\/\/[^\s<>"',)]+/g) || []; for (const url of urls) { if (!found.some((entry) => entry.url === url)) found.push({ url }); } } return found; } /** "https://www.foo.com/bar?x=1" → "foo.com/bar", capped at 60 chars (web parity). */ export function prettifyUrl(url: string): string { try { const parsed = new URL(url); const host = parsed.hostname.replace(/^www\./, ''); const path = parsed.pathname === '/' ? '' : parsed.pathname; const display = host + path; return display.length > 60 ? `${display.slice(0, 57)}...` : display; } catch { return url.length > 60 ? `${url.slice(0, 57)}...` : url; } } // ── grep ─────────────────────────────────────────────────────────────────── export type GrepCounts = { matchCount: number; fileCount: number; }; /** * Parse match/file counts from the grep tool's result text. The backend emits * a different header per output_mode (see the web GrepSearch.tsx port): * - content mode: "Found N matches in M files:" * - files_with_matches mode: "Found N files:" * - count mode: "Match counts in N files:\nfile.js:10\n..." * - no results: "No matches found" */ export function parseGrepCounts(results: SuperagentToolCall['results']): GrepCounts { if (results == null) return { matchCount: 0, fileCount: 0 }; const text = typeof results === 'string' ? results : (results as { message?: unknown })?.message; if (typeof text !== 'string') return { matchCount: 0, fileCount: 0 }; const contentMatch = text.match(/Found\s+(\d+)\s+match(?:es)?\s+in\s+(\d+)\s+files?/); if (contentMatch) { return { matchCount: parseInt(contentMatch[1], 10), fileCount: parseInt(contentMatch[2], 10) }; } const filesMatch = text.match(/Found\s+(\d+)\s+files?:/); if (filesMatch) { const fileCount = parseInt(filesMatch[1], 10); return { matchCount: fileCount, fileCount }; } const countMatch = text.match(/Match\s+counts\s+in\s+(\d+)\s+files?:/); if (countMatch) { const fileCount = parseInt(countMatch[1], 10); // Sum the per-file counts from lines like "file.js:10" (trim handles CRLF). let matchCount = 0; for (const line of text.split('\n').slice(1)) { const lineMatch = line.trim().match(/:(\d+)$/); if (lineMatch) matchCount += parseInt(lineMatch[1], 10); } return { matchCount, fileCount }; } return { matchCount: 0, fileCount: 0 }; } /** * A grep call is done when its status is terminal OR it already carries * results — during streaming the status can lag behind the payload (web parity). */ export function isGrepCallDone(toolCall: SuperagentToolCall): boolean { const status = (toolCall.status || '').toLowerCase(); if (status === 'success' || status === 'error' || status === 'failed') return true; return toolCall.results != null; } /** * The grep pattern, tolerating a still-streaming arguments string via the * same regex fallback as extractSearchQuery (the web uses parsePartialJson). */ export function getGrepPattern(toolCall: SuperagentToolCall): string { const args = parseToolArgs(toolCall); const pattern = args?.pattern; if (typeof pattern === 'string') return pattern; const raw = typeof toolCall.arguments_string === 'string' ? toolCall.arguments_string : ''; const match = raw.match(/"pattern"\s*:\s*"([^"]*)/); return match?.[1] || ''; } export function truncateDisplayText(text: string, maxLength = 50): string { if (!text || text.length <= maxLength) return text; return `${text.slice(0, maxLength)}…`; } export function pluralize(count: number, noun: string): string { return `${count} ${noun}${count === 1 ? '' : 's'}`; }