@alexion42/pi-web-search 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/tasks/tasks-019e595f-0b95-7b09-9237-a0c6fbbda360.json +4 -0
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/TOOLS.md +103 -0
- package/activity.ts +101 -0
- package/banner.png +0 -0
- package/code-search.ts +107 -0
- package/exa.ts +520 -0
- package/extract.ts +342 -0
- package/github-api.ts +196 -0
- package/github-extract.ts +634 -0
- package/index.ts +885 -0
- package/package.json +46 -0
- package/pdf-extract.ts +192 -0
- package/pi-web-fetch-demo.mp4 +0 -0
- package/rsc-extract.ts +338 -0
- package/search.ts +49 -0
- package/storage.ts +71 -0
- package/test/pdf-extract.test.mjs +95 -0
- package/types.ts +20 -0
- package/utils.ts +44 -0
package/exa.ts
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { activityMonitor } from "./activity.js";
|
|
5
|
+
import type { ExtractedContent } from "./extract.js";
|
|
6
|
+
import type { SearchOptions, SearchResponse } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const EXA_ANSWER_URL = "https://api.exa.ai/answer";
|
|
9
|
+
const EXA_SEARCH_URL = "https://api.exa.ai/search";
|
|
10
|
+
const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
|
|
11
|
+
const CONFIG_PATH = join(homedir(), ".pi", "web-search.json");
|
|
12
|
+
const USAGE_PATH = join(homedir(), ".pi", "exa-usage.json");
|
|
13
|
+
|
|
14
|
+
const MONTHLY_LIMIT = 1000;
|
|
15
|
+
const WARNING_THRESHOLD = 800;
|
|
16
|
+
|
|
17
|
+
interface WebSearchConfig {
|
|
18
|
+
exaApiKey?: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ExaUsage {
|
|
22
|
+
month: string;
|
|
23
|
+
count: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ExaAnswerResponse {
|
|
27
|
+
answer?: string;
|
|
28
|
+
citations?: Array<{ url?: string; title?: string; text?: string; publishedDate?: string }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ExaSearchResponse {
|
|
32
|
+
results?: Array<{
|
|
33
|
+
title?: string;
|
|
34
|
+
url?: string;
|
|
35
|
+
publishedDate?: string;
|
|
36
|
+
author?: string;
|
|
37
|
+
text?: string;
|
|
38
|
+
highlights?: unknown;
|
|
39
|
+
highlightScores?: number[];
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ExaMcpRpcResponse {
|
|
44
|
+
result?: {
|
|
45
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
46
|
+
isError?: boolean;
|
|
47
|
+
};
|
|
48
|
+
error?: {
|
|
49
|
+
code?: number;
|
|
50
|
+
message?: string;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ExaSearchResult = SearchResponse | { exhausted: true } | null;
|
|
55
|
+
|
|
56
|
+
export interface ExaSearchOptions extends SearchOptions {
|
|
57
|
+
includeContent?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type McpParsedResult = { title: string; url: string; content: string };
|
|
61
|
+
|
|
62
|
+
let cachedConfig: WebSearchConfig | null = null;
|
|
63
|
+
let warnedMonth: string | null = null;
|
|
64
|
+
|
|
65
|
+
function loadConfig(): WebSearchConfig {
|
|
66
|
+
if (cachedConfig) return cachedConfig;
|
|
67
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
68
|
+
cachedConfig = {};
|
|
69
|
+
return cachedConfig;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
|
73
|
+
try {
|
|
74
|
+
cachedConfig = JSON.parse(raw) as WebSearchConfig;
|
|
75
|
+
return cachedConfig;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
78
|
+
throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeApiKey(value: unknown): string | null {
|
|
83
|
+
if (typeof value !== "string") return null;
|
|
84
|
+
const normalized = value.trim();
|
|
85
|
+
return normalized.length > 0 ? normalized : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getApiKey(): string | null {
|
|
89
|
+
return normalizeApiKey(process.env.EXA_API_KEY) ?? normalizeApiKey(loadConfig().exaApiKey);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getCurrentMonth(): string {
|
|
93
|
+
return new Date().toISOString().slice(0, 7);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeUsage(raw: unknown): ExaUsage {
|
|
97
|
+
const month = getCurrentMonth();
|
|
98
|
+
if (!raw || typeof raw !== "object") return { month, count: 0 };
|
|
99
|
+
const data = raw as { month?: unknown; count?: unknown };
|
|
100
|
+
const parsedMonth = typeof data.month === "string" ? data.month : month;
|
|
101
|
+
const parsedCount = typeof data.count === "number" && Number.isFinite(data.count) ? data.count : 0;
|
|
102
|
+
if (parsedMonth !== month) return { month, count: 0 };
|
|
103
|
+
return { month: parsedMonth, count: Math.max(0, Math.floor(parsedCount)) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readUsage(): ExaUsage {
|
|
107
|
+
if (!existsSync(USAGE_PATH)) return { month: getCurrentMonth(), count: 0 };
|
|
108
|
+
const raw = readFileSync(USAGE_PATH, "utf-8");
|
|
109
|
+
try {
|
|
110
|
+
return normalizeUsage(JSON.parse(raw));
|
|
111
|
+
} catch (err) {
|
|
112
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
113
|
+
throw new Error(`Failed to parse ${USAGE_PATH}: ${message}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeUsage(usage: ExaUsage): void {
|
|
118
|
+
const dir = join(homedir(), ".pi");
|
|
119
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
120
|
+
writeFileSync(USAGE_PATH, JSON.stringify(usage, null, 2) + "\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function reserveRequestBudget(): { exhausted: true } | null {
|
|
124
|
+
const usage = readUsage();
|
|
125
|
+
|
|
126
|
+
if (usage.count >= MONTHLY_LIMIT) {
|
|
127
|
+
return { exhausted: true };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const nextCount = usage.count + 1;
|
|
131
|
+
if (nextCount >= WARNING_THRESHOLD && warnedMonth !== usage.month) {
|
|
132
|
+
warnedMonth = usage.month;
|
|
133
|
+
console.error(`Exa usage warning: ${nextCount}/${MONTHLY_LIMIT} monthly requests used.`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
writeUsage({ month: usage.month, count: nextCount });
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function requestSignal(signal?: AbortSignal): AbortSignal {
|
|
141
|
+
const timeout = AbortSignal.timeout(60000);
|
|
142
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function recencyToStartDate(filter: string): string {
|
|
146
|
+
const now = new Date();
|
|
147
|
+
const offsets: Record<string, number> = {
|
|
148
|
+
day: 1,
|
|
149
|
+
week: 7,
|
|
150
|
+
month: 30,
|
|
151
|
+
year: 365,
|
|
152
|
+
};
|
|
153
|
+
const days = offsets[filter] ?? 0;
|
|
154
|
+
return new Date(now.getTime() - days * 86400000).toISOString();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function mapDomainFilter(domainFilter: string[] | undefined): { includeDomains?: string[]; excludeDomains?: string[] } {
|
|
158
|
+
if (!domainFilter?.length) return {};
|
|
159
|
+
const includeDomains = domainFilter
|
|
160
|
+
.filter(d => !d.startsWith("-") && d.trim().length > 0)
|
|
161
|
+
.map(d => d.trim());
|
|
162
|
+
const excludeDomains = domainFilter
|
|
163
|
+
.filter(d => d.startsWith("-"))
|
|
164
|
+
.map(d => d.slice(1).trim())
|
|
165
|
+
.filter(Boolean);
|
|
166
|
+
return {
|
|
167
|
+
...(includeDomains.length ? { includeDomains } : {}),
|
|
168
|
+
...(excludeDomains.length ? { excludeDomains } : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeHighlights(value: unknown): string[] {
|
|
173
|
+
if (!Array.isArray(value)) return [];
|
|
174
|
+
return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildAnswerFromSearchResults(results: ExaSearchResponse["results"]): string {
|
|
178
|
+
if (!results?.length) return "";
|
|
179
|
+
const parts: string[] = [];
|
|
180
|
+
for (let i = 0; i < results.length; i++) {
|
|
181
|
+
const item = results[i];
|
|
182
|
+
if (!item?.url) continue;
|
|
183
|
+
const highlights = normalizeHighlights(item.highlights);
|
|
184
|
+
const content = highlights.length > 0
|
|
185
|
+
? highlights.join(" ")
|
|
186
|
+
: typeof item.text === "string" ? item.text.trim().slice(0, 1000) : "";
|
|
187
|
+
if (!content) continue;
|
|
188
|
+
const sourceTitle = item.title || `Source ${i + 1}`;
|
|
189
|
+
parts.push(`${content}\nSource: ${sourceTitle} (${item.url})`);
|
|
190
|
+
}
|
|
191
|
+
return parts.join("\n\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function mapResults(results: ExaSearchResponse["results"] | ExaAnswerResponse["citations"]): SearchResponse["results"] {
|
|
195
|
+
if (!Array.isArray(results)) return [];
|
|
196
|
+
const mapped: SearchResponse["results"] = [];
|
|
197
|
+
for (let i = 0; i < results.length; i++) {
|
|
198
|
+
const item = results[i];
|
|
199
|
+
if (!item?.url) continue;
|
|
200
|
+
mapped.push({
|
|
201
|
+
title: item.title || `Source ${i + 1}`,
|
|
202
|
+
url: item.url,
|
|
203
|
+
snippet: "",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return mapped;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function mapInlineContent(results: ExaSearchResponse["results"]): ExtractedContent[] {
|
|
210
|
+
if (!results?.length) return [];
|
|
211
|
+
return results
|
|
212
|
+
.filter((r): r is NonNullable<ExaSearchResponse["results"]>[number] & { url: string; text: string } =>
|
|
213
|
+
!!r?.url && typeof r.text === "string" && r.text.length > 0)
|
|
214
|
+
.map(r => ({
|
|
215
|
+
url: r.url,
|
|
216
|
+
title: r.title || "",
|
|
217
|
+
content: r.text,
|
|
218
|
+
error: null,
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function callExaMcp(
|
|
223
|
+
toolName: string,
|
|
224
|
+
args: Record<string, unknown>,
|
|
225
|
+
signal?: AbortSignal,
|
|
226
|
+
): Promise<string> {
|
|
227
|
+
const response = await fetch(EXA_MCP_URL, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: {
|
|
230
|
+
"Content-Type": "application/json",
|
|
231
|
+
"Accept": "application/json, text/event-stream",
|
|
232
|
+
},
|
|
233
|
+
body: JSON.stringify({
|
|
234
|
+
jsonrpc: "2.0",
|
|
235
|
+
id: 1,
|
|
236
|
+
method: "tools/call",
|
|
237
|
+
params: {
|
|
238
|
+
name: toolName,
|
|
239
|
+
arguments: args,
|
|
240
|
+
},
|
|
241
|
+
}),
|
|
242
|
+
signal: requestSignal(signal),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (!response.ok) {
|
|
246
|
+
const errorText = await response.text();
|
|
247
|
+
throw new Error(`Exa MCP error ${response.status}: ${errorText.slice(0, 300)}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const body = await response.text();
|
|
251
|
+
const dataLines = body.split("\n").filter(line => line.startsWith("data:"));
|
|
252
|
+
|
|
253
|
+
let parsed: ExaMcpRpcResponse | null = null;
|
|
254
|
+
for (const line of dataLines) {
|
|
255
|
+
const payload = line.slice(5).trim();
|
|
256
|
+
if (!payload) continue;
|
|
257
|
+
try {
|
|
258
|
+
const candidate = JSON.parse(payload) as ExaMcpRpcResponse;
|
|
259
|
+
if (candidate?.result || candidate?.error) {
|
|
260
|
+
parsed = candidate;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!parsed) {
|
|
268
|
+
try {
|
|
269
|
+
const candidate = JSON.parse(body) as ExaMcpRpcResponse;
|
|
270
|
+
if (candidate?.result || candidate?.error) {
|
|
271
|
+
parsed = candidate;
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!parsed) {
|
|
278
|
+
throw new Error("Exa MCP returned an empty response");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (parsed.error) {
|
|
282
|
+
const code = typeof parsed.error.code === "number" ? ` ${parsed.error.code}` : "";
|
|
283
|
+
const message = parsed.error.message || "Unknown error";
|
|
284
|
+
throw new Error(`Exa MCP error${code}: ${message}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (parsed.result?.isError) {
|
|
288
|
+
const message = parsed.result.content
|
|
289
|
+
?.find(item => item.type === "text" && typeof item.text === "string")
|
|
290
|
+
?.text?.trim();
|
|
291
|
+
throw new Error(message || "Exa MCP returned an error");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const text = parsed.result?.content
|
|
295
|
+
?.find(item => item.type === "text" && typeof item.text === "string" && item.text.trim().length > 0)
|
|
296
|
+
?.text;
|
|
297
|
+
|
|
298
|
+
if (!text) {
|
|
299
|
+
throw new Error("Exa MCP returned empty content");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return text;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function parseMcpResults(text: string): McpParsedResult[] | null {
|
|
306
|
+
const blocks = text.split(/(?=^Title: )/m).filter(block => block.trim().length > 0);
|
|
307
|
+
const parsed = blocks.map(block => {
|
|
308
|
+
const title = block.match(/^Title: (.+)/m)?.[1]?.trim() ?? "";
|
|
309
|
+
const url = block.match(/^URL: (.+)/m)?.[1]?.trim() ?? "";
|
|
310
|
+
let content = "";
|
|
311
|
+
const textStart = block.indexOf("\nText: ");
|
|
312
|
+
if (textStart >= 0) {
|
|
313
|
+
content = block.slice(textStart + 7).trim();
|
|
314
|
+
} else {
|
|
315
|
+
const hlMatch = block.match(/\nHighlights:\s*\n/);
|
|
316
|
+
if (hlMatch?.index != null) {
|
|
317
|
+
content = block.slice(hlMatch.index + hlMatch[0].length).trim();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
content = content.replace(/\n---\s*$/, "").trim();
|
|
321
|
+
return { title, url, content };
|
|
322
|
+
}).filter(result => result.url.length > 0);
|
|
323
|
+
return parsed.length > 0 ? parsed : null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function buildAnswerFromMcpResults(results: McpParsedResult[]): string {
|
|
327
|
+
if (results.length === 0) return "";
|
|
328
|
+
const parts: string[] = [];
|
|
329
|
+
for (let i = 0; i < results.length; i++) {
|
|
330
|
+
const result = results[i];
|
|
331
|
+
const snippet = result.content.replace(/\s+/g, " ").trim().slice(0, 500);
|
|
332
|
+
if (!snippet) continue;
|
|
333
|
+
const sourceTitle = result.title || `Source ${i + 1}`;
|
|
334
|
+
parts.push(`${snippet}\nSource: ${sourceTitle} (${result.url})`);
|
|
335
|
+
}
|
|
336
|
+
return parts.join("\n\n");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function mapMcpInlineContent(results: McpParsedResult[]): ExtractedContent[] {
|
|
340
|
+
return results
|
|
341
|
+
.filter(result => result.content.length > 0)
|
|
342
|
+
.map(result => ({
|
|
343
|
+
url: result.url,
|
|
344
|
+
title: result.title,
|
|
345
|
+
content: result.content,
|
|
346
|
+
error: null,
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function buildMcpQuery(query: string, options: ExaSearchOptions): string {
|
|
351
|
+
const parts = [query];
|
|
352
|
+
if (options.domainFilter?.length) {
|
|
353
|
+
for (const d of options.domainFilter) {
|
|
354
|
+
parts.push(d.startsWith("-") ? `-site:${d.slice(1)}` : `site:${d}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (options.recencyFilter) {
|
|
358
|
+
const now = new Date();
|
|
359
|
+
switch (options.recencyFilter) {
|
|
360
|
+
case "day": parts.push("past 24 hours"); break;
|
|
361
|
+
case "week": parts.push("past week"); break;
|
|
362
|
+
case "month": parts.push(`${now.toLocaleString("en", { month: "long" })} ${now.getFullYear()}`); break;
|
|
363
|
+
case "year": parts.push(String(now.getFullYear())); break;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return parts.join(" ");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function searchWithExaMcp(query: string, options: ExaSearchOptions = {}): Promise<SearchResponse | null> {
|
|
370
|
+
const enrichedQuery = buildMcpQuery(query, options);
|
|
371
|
+
const activityId = activityMonitor.logStart({ type: "api", query: enrichedQuery });
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const text = await callExaMcp(
|
|
375
|
+
"web_search_exa",
|
|
376
|
+
{
|
|
377
|
+
query: enrichedQuery,
|
|
378
|
+
numResults: options.numResults ?? 5,
|
|
379
|
+
livecrawl: "fallback",
|
|
380
|
+
type: "auto",
|
|
381
|
+
contextMaxCharacters: options.includeContent ? 50000 : 3000,
|
|
382
|
+
},
|
|
383
|
+
options.signal,
|
|
384
|
+
);
|
|
385
|
+
const parsedResults = parseMcpResults(text);
|
|
386
|
+
activityMonitor.logComplete(activityId, 200);
|
|
387
|
+
|
|
388
|
+
if (!parsedResults) return null;
|
|
389
|
+
|
|
390
|
+
const response: SearchResponse = {
|
|
391
|
+
answer: buildAnswerFromMcpResults(parsedResults),
|
|
392
|
+
results: parsedResults.map((result, index) => ({
|
|
393
|
+
title: result.title || `Source ${index + 1}`,
|
|
394
|
+
url: result.url,
|
|
395
|
+
snippet: "",
|
|
396
|
+
})),
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
if (options.includeContent) {
|
|
400
|
+
const inlineContent = mapMcpInlineContent(parsedResults);
|
|
401
|
+
if (inlineContent.length > 0) response.inlineContent = inlineContent;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return response;
|
|
405
|
+
} catch (err) {
|
|
406
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
407
|
+
if (message.toLowerCase().includes("abort")) {
|
|
408
|
+
activityMonitor.logComplete(activityId, 0);
|
|
409
|
+
} else {
|
|
410
|
+
activityMonitor.logError(activityId, message);
|
|
411
|
+
}
|
|
412
|
+
throw err;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function isExaAvailable(): boolean {
|
|
417
|
+
if (getApiKey()) {
|
|
418
|
+
const usage = readUsage();
|
|
419
|
+
return usage.count < MONTHLY_LIMIT;
|
|
420
|
+
}
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function hasExaApiKey(): boolean {
|
|
425
|
+
return !!getApiKey();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function searchWithExa(query: string, options: ExaSearchOptions = {}): Promise<ExaSearchResult> {
|
|
429
|
+
const apiKey = getApiKey();
|
|
430
|
+
if (!apiKey) {
|
|
431
|
+
return searchWithExaMcp(query, options);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const budget = reserveRequestBudget();
|
|
435
|
+
if (budget) return budget;
|
|
436
|
+
|
|
437
|
+
const useSearch = options.includeContent
|
|
438
|
+
|| !!options.recencyFilter
|
|
439
|
+
|| !!options.domainFilter?.length
|
|
440
|
+
|| !!(options.numResults && options.numResults !== 5);
|
|
441
|
+
|
|
442
|
+
const activityId = activityMonitor.logStart({ type: "api", query });
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
if (!useSearch) {
|
|
446
|
+
const response = await fetch(EXA_ANSWER_URL, {
|
|
447
|
+
method: "POST",
|
|
448
|
+
headers: {
|
|
449
|
+
"x-api-key": apiKey,
|
|
450
|
+
"Content-Type": "application/json",
|
|
451
|
+
},
|
|
452
|
+
body: JSON.stringify({
|
|
453
|
+
query,
|
|
454
|
+
text: true,
|
|
455
|
+
}),
|
|
456
|
+
signal: requestSignal(options.signal),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
if (!response.ok) {
|
|
460
|
+
const errorText = await response.text();
|
|
461
|
+
throw new Error(`Exa API error ${response.status}: ${errorText.slice(0, 300)}`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const data = await response.json() as ExaAnswerResponse;
|
|
465
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
466
|
+
return {
|
|
467
|
+
answer: data.answer || "",
|
|
468
|
+
results: mapResults(data.citations),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const startDate = options.recencyFilter ? recencyToStartDate(options.recencyFilter) : null;
|
|
473
|
+
const domainFilters = mapDomainFilter(options.domainFilter);
|
|
474
|
+
const response = await fetch(EXA_SEARCH_URL, {
|
|
475
|
+
method: "POST",
|
|
476
|
+
headers: {
|
|
477
|
+
"x-api-key": apiKey,
|
|
478
|
+
"Content-Type": "application/json",
|
|
479
|
+
},
|
|
480
|
+
body: JSON.stringify({
|
|
481
|
+
query,
|
|
482
|
+
type: "auto",
|
|
483
|
+
numResults: options.numResults ?? 5,
|
|
484
|
+
...domainFilters,
|
|
485
|
+
...(startDate ? { startPublishedDate: startDate } : {}),
|
|
486
|
+
contents: {
|
|
487
|
+
text: options.includeContent ? true : { maxCharacters: 3000 },
|
|
488
|
+
highlights: true,
|
|
489
|
+
},
|
|
490
|
+
}),
|
|
491
|
+
signal: requestSignal(options.signal),
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
if (!response.ok) {
|
|
495
|
+
const errorText = await response.text();
|
|
496
|
+
throw new Error(`Exa API error ${response.status}: ${errorText.slice(0, 300)}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const data = await response.json() as ExaSearchResponse;
|
|
500
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
501
|
+
|
|
502
|
+
const mapped: SearchResponse = {
|
|
503
|
+
answer: buildAnswerFromSearchResults(data.results),
|
|
504
|
+
results: mapResults(data.results),
|
|
505
|
+
};
|
|
506
|
+
if (options.includeContent) {
|
|
507
|
+
const inlineContent = mapInlineContent(data.results);
|
|
508
|
+
if (inlineContent.length > 0) mapped.inlineContent = inlineContent;
|
|
509
|
+
}
|
|
510
|
+
return mapped;
|
|
511
|
+
} catch (err) {
|
|
512
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
513
|
+
if (message.toLowerCase().includes("abort")) {
|
|
514
|
+
activityMonitor.logComplete(activityId, 0);
|
|
515
|
+
} else {
|
|
516
|
+
activityMonitor.logError(activityId, message);
|
|
517
|
+
}
|
|
518
|
+
throw err;
|
|
519
|
+
}
|
|
520
|
+
}
|