diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 13:11:29 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 13:11:29 +0900 |
| commit | 8a4a624d16422467a8e85434c674bb591877e8ea (patch) | |
| tree | 54052da00bbc580742913e5c031b7cc1b160db19 /packages/tool-web-search/src/format.ts | |
| parent | d23de3254374d4d63c8e15c6ab9311c3c6f4da5b (diff) | |
| download | dispatch-8a4a624d16422467a8e85434c674bb591877e8ea.tar.gz dispatch-8a4a624d16422467a8e85434c674bb591877e8ea.zip | |
feat(tool-web-search): Firecrawl-backed web search tool
New standard tool extension with one tool web_search supporting 4 modes
(search, scrape, crawl, map) against a self-hosted Firecrawl instance.
Pure core: validateArgs (discriminated union by mode) + format* functions
+ truncateOutput. Injected edge: FirecrawlClient (injectable fetchFn/sleep/now,
AbortSignal.any for per-request timeout + caller cancellation). concurrencySafe
true, capabilities network. 38 tests, zero vi.mock.
Live-verified: umans-glm-5.2 called web_search → real Firecrawl results (also
the first live Umans API call).
Diffstat (limited to 'packages/tool-web-search/src/format.ts')
| -rw-r--r-- | packages/tool-web-search/src/format.ts | 111 |
1 files changed, 111 insertions, 0 deletions
diff --git a/packages/tool-web-search/src/format.ts b/packages/tool-web-search/src/format.ts new file mode 100644 index 0000000..cfc9aa0 --- /dev/null +++ b/packages/tool-web-search/src/format.ts @@ -0,0 +1,111 @@ +/** + * Pure formatters for the web_search tool — input → output, no I/O. + * + * These mirror the proven opencode Firecrawl tool's formatting, isolated + * (not imported) per the isolation-over-DRY rule. Tested directly with + * zero mocks. + */ + +/** A single search hit from Firecrawl's `/search` endpoint. */ +export interface SearchHit { + readonly title?: string; + readonly url?: string; + readonly description?: string; + readonly markdown?: string; +} + +/** One page from a completed crawl (`/crawl` status `data`). */ +export interface CrawlPage { + readonly markdown?: string; + readonly metadata?: { + readonly title?: string; + readonly sourceURL?: string; + readonly url?: string; + }; +} + +/** The scrape response payload (`/scrape` `data`). */ +export interface ScrapeResult { + readonly data?: { + readonly markdown?: string; + readonly metadata?: { readonly title?: string }; + }; +} + +/** + * Truncate output to `cap` characters with a trailing notice, identical in + * spirit to tool-shell. Duplication across features is the intended trade. + */ +export function truncateOutput(output: string, cap: number): string { + if (output.length <= cap) { + return output; + } + const truncated = output.slice(0, cap); + return `${truncated}\n\n[Output truncated: exceeded ${cap} characters]`; +} + +/** + * Format search hits as `### title\nurl\n\ndescription` (+ optional markdown), + * joined by `---` separators. Empty → `"No results found."`. + */ +export function formatSearchResults(data: readonly SearchHit[] | null | undefined): string { + if (!data || data.length === 0) { + return "No results found."; + } + const parts: string[] = []; + for (const r of data) { + const title = r.title ?? "(no title)"; + const url = r.url ?? ""; + const description = r.description ?? ""; + let section = `### ${title}\n${url}\n\n${description}`; + if (r.markdown) { + section += `\n\n${r.markdown}`; + } + parts.push(section); + } + return parts.join("\n\n---\n\n"); +} + +/** + * Format a scrape response as `# title\n\nmarkdown`, omitting the header when + * the title is absent. + */ +export function formatScrapeResult(json: ScrapeResult): string { + const md = json.data?.markdown ?? ""; + const title = json.data?.metadata?.title; + if (title) { + return `# ${title}\n\n${md}`; + } + return md; +} + +/** + * Format crawled pages as `## title\nurl\n\nmarkdown` each, joined by `---`. + * Empty → `"No pages crawled."`. + */ +export function formatCrawlResults(data: readonly CrawlPage[] | null | undefined): string { + if (!data || data.length === 0) { + return "No pages crawled."; + } + const parts: string[] = []; + for (const page of data) { + const title = page.metadata?.title ?? "(no title)"; + const url = page.metadata?.sourceURL ?? page.metadata?.url ?? ""; + let section = `## ${title}\n${url}`; + if (page.markdown) { + section += `\n\n${page.markdown}`; + } + parts.push(section); + } + return parts.join("\n\n---\n\n"); +} + +/** + * Format discovered links as a bullet list. Empty → `"No links found."`. + */ +export function formatMapResults(links: readonly string[] | null | undefined): string { + if (!links || links.length === 0) { + return "No links found."; + } + return links.map((l) => `- ${l}`).join("\n"); +} |
