Files
feynman/.feynman/vendor-overrides/pi-web-access/exa.ts
2026-03-31 11:02:07 -07:00

148 lines
4.0 KiB
TypeScript

import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { activityMonitor } from "./activity.js";
import type { SearchOptions, SearchResponse, SearchResult } from "./perplexity.js";
const EXA_API_URL = "https://api.exa.ai/search";
const CONFIG_PATH = join(homedir(), ".pi", "web-search.json");
interface WebSearchConfig {
exaApiKey?: string;
}
interface ExaSearchResult {
title?: string;
url?: string;
text?: string;
highlights?: string[];
summary?: string;
}
let cachedConfig: WebSearchConfig | null = null;
function loadConfig(): WebSearchConfig {
if (cachedConfig) return cachedConfig;
if (existsSync(CONFIG_PATH)) {
try {
cachedConfig = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as WebSearchConfig;
return cachedConfig;
} catch {
cachedConfig = {};
}
} else {
cachedConfig = {};
}
return cachedConfig;
}
function getApiKey(): string {
const config = loadConfig();
const key = process.env.EXA_API_KEY || config.exaApiKey;
if (!key) {
throw new Error(
"Exa API key not found. Either:\n" +
` 1. Create ${CONFIG_PATH} with { "exaApiKey": "your-key" }\n` +
" 2. Set EXA_API_KEY environment variable\n" +
"Get a key from the Exa dashboard."
);
}
return key;
}
function toSnippet(result: ExaSearchResult): string {
if (Array.isArray(result.highlights) && result.highlights.length > 0) {
return result.highlights.join(" ");
}
if (typeof result.summary === "string" && result.summary.trim()) {
return result.summary.trim();
}
if (typeof result.text === "string" && result.text.trim()) {
return result.text.trim().slice(0, 400);
}
return "";
}
function formatAnswer(results: SearchResult[]): string {
return results
.map((result, index) => {
const snippet = result.snippet ? `\n${result.snippet}` : "";
return `${index + 1}. ${result.title}\n${result.url}${snippet}`;
})
.join("\n\n");
}
export function isExaAvailable(): boolean {
const config = loadConfig();
return Boolean(process.env.EXA_API_KEY || config.exaApiKey);
}
export async function searchWithExa(query: string, options: SearchOptions = {}): Promise<SearchResponse> {
const activityId = activityMonitor.logStart({ type: "api", query });
const apiKey = getApiKey();
const numResults = Math.min(options.numResults ?? 5, 20);
const includeDomains = options.domainFilter?.filter((entry) => !entry.startsWith("-")) ?? [];
const excludeDomains = options.domainFilter?.filter((entry) => entry.startsWith("-")).map((entry) => entry.slice(1)) ?? [];
const requestBody: Record<string, unknown> = {
query,
type: "auto",
numResults,
contents: {
highlights: {
numSentences: 3,
},
},
};
if (includeDomains.length > 0) {
requestBody.includeDomains = includeDomains;
}
if (excludeDomains.length > 0) {
requestBody.excludeDomains = excludeDomains;
}
try {
const response = await fetch(EXA_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
body: JSON.stringify(requestBody),
signal: options.signal,
});
if (!response.ok) {
activityMonitor.logComplete(activityId, response.status);
throw new Error(`Exa API error ${response.status}: ${(await response.text()).slice(0, 300)}`);
}
const data = await response.json() as { results?: ExaSearchResult[] };
const results = (Array.isArray(data.results) ? data.results : [])
.slice(0, numResults)
.map((result, index) => ({
title: result.title?.trim() || `Source ${index + 1}`,
url: result.url?.trim() || "",
snippet: toSnippet(result),
}))
.filter((result) => result.url.length > 0);
activityMonitor.logComplete(activityId, response.status);
return {
answer: formatAnswer(results),
results,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.toLowerCase().includes("abort")) {
activityMonitor.logComplete(activityId, 0);
} else {
activityMonitor.logError(activityId, message);
}
throw error;
}
}