/** * `dench search` — live web access through the Dench Cloud Gateway. * * The gateway exposes Exa Search at three routes; we mirror them as * three subcommands and authenticate with either `DENCH_API_KEY` or a * paid-workspace gateway key resolved from the active agent session by * the top-level dispatcher. * * Subcommands: * search "" [--num-results N] [--type auto|fast|deep|deep-reasoning|neural] * [--category news|company|people|...] * [--include-domains a.com,b.com] [--exclude-domains c.com] * [--start-published-date ISO] [--end-published-date ISO] * [--max-chars 800] [--json] * search contents [...] [--max-chars 4000] [--summary "..."] [--json] * search answer "" [--json] * * `--category people` and `--category company` upgrade the SAME search rather * than being separate subcommands: those categories hit Exa's dedicated entity * indices, so the results are structured records and are rendered as such. * * Filter support is PER-CATEGORY and measured, not guessed: people search * cannot use domain or published-date filters, while company search uses * domain filters happily and only loses the date ones. See * cli/lib/exa-search-constraints.ts for the observed behaviour. This CLI * refuses an unusable flag; the agent-side equivalent * (src/workflows/exa-web-steps.ts) strips it instead — that file explains why * the two surfaces deliberately differ. */ import { assertNoUnknownFlags, getFlag } from "./lib/cli-args"; import { formatUsdCompact, parseExaCompanyResults } from "./lib/exa-companies"; import { currentRole, parseExaPeopleResults, personFullName, } from "./lib/exa-people"; import { unsupportedParamsForCategory } from "./lib/exa-search-constraints"; import { normalizeGatewayBaseUrl } from "./lib/gateway-url"; type SearchCliContext = { args: string[]; jsonOutput: boolean; bearerToken?: string; gatewayBaseUrl?: string; }; class SearchCliError extends Error {} function parsePositiveInt( name: string, raw: string | undefined, ): number | undefined { if (raw === undefined) return undefined; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) { throw new SearchCliError(`Invalid ${name} value: ${raw}`); } return Math.floor(parsed); } function splitCsv(raw: string | undefined): string[] | undefined { if (raw === undefined) return undefined; const parts = raw .split(",") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); return parts.length > 0 ? parts : undefined; } const ALLOWED_TYPES = new Set([ "auto", "neural", "fast", "deep", "deep-reasoning", "instant", ]); const ALLOWED_CATEGORIES = new Set([ "company", "research paper", "news", "personal site", "financial report", "people", ]); /** CLI flag → the Exa request param it sets. */ const FLAG_TO_EXA_PARAM: Record = { "--include-domains": "includeDomains", "--exclude-domains": "excludeDomains", "--start-published-date": "startPublishedDate", "--end-published-date": "endPublishedDate", }; /** * Refuse flags that do not work for the requested category. The list is * per-category and measured, not guessed — domain filters are unusable for * people but genuinely work for company. */ function assertFlagsSupportedForCategory( category: string | undefined, provided: string[], ): void { const unsupported = new Set(unsupportedParamsForCategory(category)); const offenders = provided.filter((flag) => unsupported.has(FLAG_TO_EXA_PARAM[flag] ?? ""), ); if (offenders.length === 0) return; throw new SearchCliError( `${offenders.join(", ")} has no effect on ${category} search — the ` + "search either rejects it or silently ignores it, so the results would not " + "match what you asked for. (Unsupported for " + `${category}: ${[...unsupported].join(", ")}.) ` + "Put those constraints in the query text instead, e.g. " + (category === "company" ? '`dench search "Swiss fintech founded after 2020" --category company`.' : '`dench search "engineers at Stripe in Dublin" --category people`.'), ); } function resolveGatewayBaseUrl( ctx?: Pick, ): string { if (ctx?.gatewayBaseUrl?.trim()) { return normalizeGatewayBaseUrl(ctx.gatewayBaseUrl.trim()); } const explicit = process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim(); if (explicit) return normalizeGatewayBaseUrl(explicit); // Local dev: the gateway listens on :8787 (matches gateway/src/index.ts // and the dev-server skill). Anything that smells like localhost in // DENCH_HOST routes to the local gateway too so `dench --host // http://localhost:3000 search ...` does the right thing. const host = process.env.DENCH_HOST?.trim(); if (host && /localhost|127\.0\.0\.1/.test(host)) { return "http://localhost:8787"; } return "https://gateway.merseoriginals.com"; } function requireBearerToken( ctx?: Pick, ): string { const bearerToken = ctx?.bearerToken?.trim() || process.env.DENCH_API_KEY?.trim(); if (!bearerToken) { throw new SearchCliError( "No Dench gateway credential found. Set DENCH_API_KEY or run `dench signin` for a paid workspace.", ); } return bearerToken; } async function callGateway( ctx: SearchCliContext, path: "/v1/search" | "/v1/search/contents" | "/v1/search/answer", body: Record, ): Promise { const bearerToken = requireBearerToken(ctx); const baseUrl = resolveGatewayBaseUrl(ctx); const response = await fetch(`${baseUrl}${path}`, { method: "POST", headers: { "content-type": "application/json", Authorization: `Bearer ${bearerToken}`, }, body: JSON.stringify(body), }); if (!response.ok) { let detail = ""; try { detail = await response.text(); } catch { // ignore } const truncated = detail.length > 500 ? `${detail.slice(0, 500)}…` : detail; throw new SearchCliError( `Gateway ${path} returned ${response.status}: ${truncated || response.statusText}`, ); } return response.json(); } // ──────────────────────────────────────────────────────────────────────────── // Output formatting // ──────────────────────────────────────────────────────────────────────────── function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } function asObject(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; } function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function trimSnippet(text: string, maxChars: number): string { const collapsed = text.replace(/\s+/g, " ").trim(); if (collapsed.length <= maxChars) return collapsed; return `${collapsed.slice(0, maxChars - 1)}…`; } function formatHostname(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return url; } } function formatSearchResults(payload: unknown, limit?: number): string { const root = asObject(payload); if (!root) return "(no results)"; const results = asArray(root.results); const lines: string[] = []; if (results.length === 0) { lines.push("(no results)"); return lines.join("\n"); } const cap = limit !== undefined ? Math.min(results.length, limit) : results.length; for (let i = 0; i < cap; i++) { const result = asObject(results[i]); if (!result) continue; const title = asString(result.title) ?? "(untitled)"; const url = asString(result.url) ?? ""; const host = formatHostname(url); const published = asString(result.publishedDate); const author = asString(result.author); const meta = [host, published, author].filter(Boolean).join(" · "); const snippet = asString(result.text) ?? (Array.isArray(result.highlights) ? result.highlights .filter((h): h is string => typeof h === "string") .join(" ") : undefined) ?? asString(result.summary); lines.push(`${i + 1}. ${title}`); if (url) lines.push(` ${url}`); if (meta) lines.push(` ${meta}`); if (snippet) lines.push(` ${trimSnippet(snippet, 240)}`); lines.push(""); } return lines.join("\n").trimEnd(); } /** * Render Exa people results from their structured `entities`, not page text. * Each line is name · current title · company · location, so a list-building * run is readable without `--json`. */ function formatPeopleResults(payload: unknown, limit?: number): string { const hits = parseExaPeopleResults(payload); const lines: string[] = []; if (hits.length === 0) { lines.push("(no people found)"); return lines.join("\n"); } const cap = limit !== undefined ? Math.min(hits.length, limit) : hits.length; for (let i = 0; i < cap; i++) { const hit = hits[i]; const role = currentRole(hit.person); const name = personFullName(hit.person) ?? hit.title ?? "(unnamed)"; const roleLine = [role?.title, role?.company?.name] .filter(Boolean) .join(" · "); lines.push(`${i + 1}. ${name}`); if (roleLine) lines.push(` ${roleLine}`); if (hit.person.location) lines.push(` ${hit.person.location}`); if (hit.url) lines.push(` ${hit.url}`); lines.push(""); } return lines.join("\n").trimEnd(); } /** * Render Exa company results from their structured `entities`. One line of * firmographics per company so an account-list run is readable without * `--json`. */ function formatCompanyResults(payload: unknown, limit?: number): string { const hits = parseExaCompanyResults(payload); const lines: string[] = []; if (hits.length === 0) { lines.push("(no companies found)"); return lines.join("\n"); } const cap = limit !== undefined ? Math.min(hits.length, limit) : hits.length; for (let i = 0; i < cap; i++) { const { company, url, title } = hits[i]; const hq = company.headquarters; const facts = [ company.foundedYear ? `founded ${company.foundedYear}` : null, company.headcount ? `${company.headcount.toLocaleString()} employees` : null, [hq?.city, hq?.country].filter(Boolean).join(", ") || null, ].filter(Boolean); const money = [ company.financials?.fundingTotal ? `raised ${formatUsdCompact(company.financials.fundingTotal)}` : null, company.financials?.fundingLatestRound?.name ? `latest ${company.financials.fundingLatestRound.name}` : null, company.financials?.revenueAnnual ? `revenue ${formatUsdCompact(company.financials.revenueAnnual)}` : null, ].filter(Boolean); lines.push(`${i + 1}. ${company.name ?? title ?? "(unnamed)"}`); if (url) lines.push(` ${url}`); if (facts.length > 0) lines.push(` ${facts.join(" · ")}`); if (money.length > 0) lines.push(` ${money.join(" · ")}`); if (company.description) { lines.push(` ${trimSnippet(company.description, 200)}`); } lines.push(""); } return lines.join("\n").trimEnd(); } function formatContentsResults(payload: unknown): string { const root = asObject(payload); if (!root) return "(no contents)"; const results = asArray(root.results); const lines: string[] = []; if (results.length === 0) { return "(no contents)"; } for (let i = 0; i < results.length; i++) { const result = asObject(results[i]); if (!result) continue; const title = asString(result.title) ?? "(untitled)"; const url = asString(result.url) ?? ""; lines.push(`${i + 1}. ${title}`); if (url) lines.push(` ${url}`); const summary = asString(result.summary); if (summary) { lines.push(" Summary:"); lines.push(...indent(trimSnippet(summary, 1200), " ")); } const text = asString(result.text); if (text) { lines.push(" Content:"); lines.push(...indent(trimSnippet(text, 2000), " ")); } lines.push(""); } return lines.join("\n").trimEnd(); } function formatAnswer(payload: unknown): string { const root = asObject(payload); if (!root) return "(no answer)"; const answer = asString(root.answer) ?? ""; const citations = asArray(root.citations); const lines: string[] = []; if (answer) { lines.push(answer.trim()); lines.push(""); } if (citations.length > 0) { lines.push("Citations:"); for (let i = 0; i < citations.length; i++) { const citation = asObject(citations[i]); if (!citation) continue; const title = asString(citation.title) ?? "(untitled)"; const url = asString(citation.url) ?? ""; lines.push(` ${i + 1}. ${title}`); if (url) lines.push(` ${url}`); } } return lines.join("\n").trimEnd() || "(no answer)"; } function indent(text: string, prefix: string): string[] { return text.split("\n").map((line) => `${prefix}${line}`); } function out(ctx: SearchCliContext, payload: unknown, formatted: string): void { if (ctx.jsonOutput) { console.log(JSON.stringify(payload, null, 2)); return; } console.log(formatted); } // ──────────────────────────────────────────────────────────────────────────── // Subcommands // ──────────────────────────────────────────────────────────────────────────── async function runSearchSubcommand(ctx: SearchCliContext): Promise { const numResults = parsePositiveInt( "--num-results", getFlag(ctx.args, "--num-results"), ); const type = getFlag(ctx.args, "--type"); if (type !== undefined && !ALLOWED_TYPES.has(type)) { throw new SearchCliError( `Invalid --type: ${type}. Allowed: ${[...ALLOWED_TYPES].join(", ")}`, ); } const category = getFlag(ctx.args, "--category"); if (category !== undefined && !ALLOWED_CATEGORIES.has(category)) { throw new SearchCliError( `Invalid --category: ${category}. Allowed: ${[...ALLOWED_CATEGORIES].join(", ")}`, ); } const rawIncludeDomains = getFlag(ctx.args, "--include-domains"); const rawExcludeDomains = getFlag(ctx.args, "--exclude-domains"); const startPublishedDate = getFlag(ctx.args, "--start-published-date"); const endPublishedDate = getFlag(ctx.args, "--end-published-date"); assertFlagsSupportedForCategory( category, [ rawIncludeDomains !== undefined ? "--include-domains" : null, rawExcludeDomains !== undefined ? "--exclude-domains" : null, startPublishedDate !== undefined ? "--start-published-date" : null, endPublishedDate !== undefined ? "--end-published-date" : null, ].filter((flag): flag is string => flag !== null), ); const includeDomains = splitCsv(rawIncludeDomains); const excludeDomains = splitCsv(rawExcludeDomains); const maxChars = parsePositiveInt("--max-chars", getFlag(ctx.args, "--max-chars")) ?? 800; assertNoUnknownFlags(ctx.args, "search"); const queryParts = ctx.args.slice(); ctx.args.length = 0; const query = queryParts.join(" ").trim(); if (!query) { throw new SearchCliError( 'Usage: dench search "" [--num-results N] [--type auto|fast|deep|deep-reasoning] [--category ...] [--include-domains a.com,b.com] [--exclude-domains c.com] [--start-published-date ISO] [--end-published-date ISO] [--max-chars 800] [--json]', ); } // `people` and `company` hit Exa's dedicated entity indices, which return // structured records rather than pages. Full page text adds nothing there — // the typed fields are the point — so contents are trimmed to highlights // and the output is rendered from the entities instead of snippets. const isEntitySearch = category === "people" || category === "company"; const body: Record = { query, numResults: numResults ?? 10, type: type ?? "auto", contents: isEntitySearch ? { highlights: { maxCharacters: 200 } } : { text: { maxCharacters: maxChars }, highlights: { maxCharacters: 200 }, }, }; if (category) body.category = category; if (includeDomains) body.includeDomains = includeDomains; if (excludeDomains) body.excludeDomains = excludeDomains; if (startPublishedDate) body.startPublishedDate = startPublishedDate; if (endPublishedDate) body.endPublishedDate = endPublishedDate; const payload = await callGateway(ctx, "/v1/search", body); const rendered = category === "people" ? formatPeopleResults(payload, numResults) : category === "company" ? formatCompanyResults(payload, numResults) : formatSearchResults(payload, numResults); out(ctx, payload, rendered); } async function runContentsSubcommand(ctx: SearchCliContext): Promise { const maxChars = parsePositiveInt( "--max-chars", getFlag(ctx.args, "--max-chars"), ); const summary = getFlag(ctx.args, "--summary"); const urls = ctx.args.slice(); ctx.args.length = 0; if (urls.length === 0) { throw new SearchCliError( 'Usage: dench search contents [...] [--max-chars 4000] [--summary "..."] [--json]', ); } if (urls.length > 10) { throw new SearchCliError( `Too many URLs (${urls.length}); the gateway accepts at most 10 per call.`, ); } const body: Record = { urls, text: maxChars ? { maxCharacters: maxChars } : true, highlights: { maxCharacters: 200 }, }; if (summary) body.summary = { query: summary }; const payload = await callGateway(ctx, "/v1/search/contents", body); out(ctx, payload, formatContentsResults(payload)); } async function runAnswerSubcommand(ctx: SearchCliContext): Promise { const queryParts = ctx.args.slice(); ctx.args.length = 0; const query = queryParts.join(" ").trim(); if (!query) { throw new SearchCliError('Usage: dench search answer "" [--json]'); } const payload = await callGateway(ctx, "/v1/search/answer", { query, text: true, stream: false, }); out(ctx, payload, formatAnswer(payload)); } function searchHelp(): void { console.log(searchHelpText()); } export function searchHelpText(): string { return `Usage: dench search Live web access through the Dench Cloud Gateway. Auths with DENCH_API_KEY or the active \`dench signin\` agent session. Agent sessions require a paid workspace. Search the web (default subcommand): dench search "" [--num-results N] [--type auto|fast|deep|deep-reasoning|neural] [--category news|company|people|...] [--include-domains a.com,b.com] [--exclude-domains c.com] [--start-published-date ISO] [--end-published-date ISO] [--max-chars 800] [--json] Find people or companies (--category people|company): Those two categories search dedicated indices and print STRUCTURED records instead of page snippets — people give name, current title, company, location, work history and education; companies give domain, description, founded year, headcount, HQ, total funding, latest round and revenue. Neither returns emails or phone numbers. Put every constraint in the query text: role, seniority, skill, company, industry, location, funding stage and founding year all work. Similarity queries work well for companies. dench search "senior ML engineers at fintech companies" --category people dench search "VP Engineering AI infrastructure SF" --category people dench search "fintech companies in Switzerland" --category company dench search "companies like Notion" --category company Filter support differs by category: --include-domains/--exclude-domains work with --category company but NOT with --category people, and published-date filters work with neither. Passing one that does not apply is refused rather than silently ignored. Deep-read one or more URLs: dench search contents [...] [--max-chars 4000] [--summary "..."] [--json] One-shot synthesised answer with citations: dench search answer "" [--json] Environment variables: DENCH_API_KEY Optional when a dench signin agent session is active. DENCH_GATEWAY_URL Override the gateway base URL. GATEWAY_URL Same, used when DENCH_GATEWAY_URL is unset. DENCH_HOST If it points at localhost, defaults to http://localhost:8787 instead of production. Exit codes: 0 success, non-zero on gateway / network errors.`; } // ──────────────────────────────────────────────────────────────────────────── // Public entry point // ──────────────────────────────────────────────────────────────────────────── export async function runSearchCommand(ctx: SearchCliContext): Promise { const sub = ctx.args[0]; if (!sub || sub === "help" || sub === "--help" || sub === "-h") { searchHelp(); return; } if (sub === "contents") { ctx.args.shift(); await runContentsSubcommand(ctx); return; } if (sub === "answer") { ctx.args.shift(); await runAnswerSubcommand(ctx); return; } // No explicit subcommand — treat the rest of the argv as the query. await runSearchSubcommand(ctx); }