@jarcelao/pi-exa-api 0.2.2 → 0.3.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/README.md +1 -1
- package/extensions/api-key.ts +12 -0
- package/extensions/content-types.ts +43 -0
- package/extensions/errors.ts +12 -0
- package/extensions/formatters.ts +167 -0
- package/extensions/index.ts +66 -0
- package/extensions/temp-file.ts +19 -0
- package/extensions/tools/code-context.ts +163 -0
- package/extensions/tools/fetch.ts +154 -0
- package/extensions/tools/search.ts +148 -0
- package/extensions/types.ts +68 -0
- package/package.json +2 -2
- package/tests/api-key.test.ts +39 -0
- package/tests/content-type-mapping.test.ts +51 -0
- package/tests/extension-registration.test.ts +161 -0
- package/tests/formatting.test.ts +216 -0
- package/exa-search.test.ts +0 -851
- package/extensions/exa-search.ts +0 -646
package/extensions/exa-search.ts
DELETED
|
@@ -1,646 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* exa-search Extension
|
|
3
|
-
*
|
|
4
|
-
* Registers three tools for web search, content fetching, and code context using the Exa API:
|
|
5
|
-
* - exa_search: Natural language web search
|
|
6
|
-
* - exa_fetch: Fetch and extract content from URLs
|
|
7
|
-
* - exa_code_context: Search for code snippets and examples from open source repos
|
|
8
|
-
*
|
|
9
|
-
* Also registers the /exa-status command to check API key configuration.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
16
|
-
import {
|
|
17
|
-
DEFAULT_MAX_BYTES,
|
|
18
|
-
DEFAULT_MAX_LINES,
|
|
19
|
-
defineTool,
|
|
20
|
-
formatSize,
|
|
21
|
-
truncateHead,
|
|
22
|
-
} from "@mariozechner/pi-coding-agent";
|
|
23
|
-
import { Text } from "@mariozechner/pi-tui";
|
|
24
|
-
import { Type, type Static } from "typebox";
|
|
25
|
-
import Exa from "exa-js";
|
|
26
|
-
|
|
27
|
-
// API Key Management
|
|
28
|
-
|
|
29
|
-
function getApiKey(): string | undefined {
|
|
30
|
-
const key = process.env.EXA_API_KEY;
|
|
31
|
-
return key && key.length > 0 ? key : undefined;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Temp File Helper
|
|
35
|
-
|
|
36
|
-
async function writeTempFile(content: string): Promise<string> {
|
|
37
|
-
const tempDir = await mkdtemp(join(tmpdir(), "pi-exa-"));
|
|
38
|
-
const tempFile = join(tempDir, "output.txt");
|
|
39
|
-
await writeFile(tempFile, content, "utf8");
|
|
40
|
-
return tempFile;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Type Definitions
|
|
44
|
-
|
|
45
|
-
type SearchContentType = "text" | "highlights" | "summary" | "none";
|
|
46
|
-
type FetchContentType = "text" | "highlights" | "summary";
|
|
47
|
-
|
|
48
|
-
interface SearchDetails {
|
|
49
|
-
query: string;
|
|
50
|
-
numResults: number;
|
|
51
|
-
cost?: { total: number };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
interface FetchDetails {
|
|
55
|
-
url: string;
|
|
56
|
-
title?: string;
|
|
57
|
-
cost?: { total: number };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface CodeContextDetails {
|
|
61
|
-
query: string;
|
|
62
|
-
resultsCount: number;
|
|
63
|
-
outputTokens: number;
|
|
64
|
-
cost?: { total: number };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
interface CodeContextResponse {
|
|
68
|
-
requestId: string;
|
|
69
|
-
query: string;
|
|
70
|
-
response: string;
|
|
71
|
-
resultsCount: number;
|
|
72
|
-
costDollars: string | { total: number };
|
|
73
|
-
searchTime: number;
|
|
74
|
-
outputTokens: number;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Content Type Mapping
|
|
78
|
-
|
|
79
|
-
function mapSearchContentType(
|
|
80
|
-
contentType?: SearchContentType,
|
|
81
|
-
): { text?: true; highlights?: true; summary?: true } | undefined {
|
|
82
|
-
switch (contentType) {
|
|
83
|
-
case "text":
|
|
84
|
-
return { text: true };
|
|
85
|
-
case "highlights":
|
|
86
|
-
return { highlights: true };
|
|
87
|
-
case "summary":
|
|
88
|
-
return { summary: true };
|
|
89
|
-
case "none":
|
|
90
|
-
return undefined;
|
|
91
|
-
default:
|
|
92
|
-
return { highlights: true };
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function mapFetchContentType(
|
|
97
|
-
contentType?: FetchContentType,
|
|
98
|
-
): { text?: true; highlights?: true; summary?: true } | undefined {
|
|
99
|
-
switch (contentType) {
|
|
100
|
-
case "text":
|
|
101
|
-
return { text: true };
|
|
102
|
-
case "highlights":
|
|
103
|
-
return { highlights: true };
|
|
104
|
-
case "summary":
|
|
105
|
-
return { summary: true };
|
|
106
|
-
default:
|
|
107
|
-
return { text: true };
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Result Formatting
|
|
112
|
-
|
|
113
|
-
interface ExaSearchResult {
|
|
114
|
-
title: string;
|
|
115
|
-
url: string;
|
|
116
|
-
publishedDate?: string | null;
|
|
117
|
-
author?: string | null;
|
|
118
|
-
text?: string;
|
|
119
|
-
highlights?: string[];
|
|
120
|
-
summary?: string;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
interface ExaSearchResponse {
|
|
124
|
-
results: ExaSearchResult[];
|
|
125
|
-
costDollars?: { total: number };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function formatSearchResults(response: ExaSearchResponse): string {
|
|
129
|
-
const { results, costDollars } = response;
|
|
130
|
-
const lines: string[] = [];
|
|
131
|
-
|
|
132
|
-
for (let i = 0; i < results.length; i++) {
|
|
133
|
-
const result = results[i];
|
|
134
|
-
lines.push(`--- Result ${i + 1} ---`);
|
|
135
|
-
lines.push(`Title: ${result.title}`);
|
|
136
|
-
lines.push(`URL: ${result.url}`);
|
|
137
|
-
|
|
138
|
-
if (result.publishedDate) {
|
|
139
|
-
lines.push(`Published: ${result.publishedDate}`);
|
|
140
|
-
}
|
|
141
|
-
if (result.author) {
|
|
142
|
-
lines.push(`Author: ${result.author}`);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (result.highlights && result.highlights.length > 0) {
|
|
146
|
-
lines.push("Highlights:");
|
|
147
|
-
for (const highlight of result.highlights) {
|
|
148
|
-
lines.push(` • ${highlight}`);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (result.text) {
|
|
153
|
-
const preview = result.text.slice(0, 500);
|
|
154
|
-
lines.push(`Text: ${preview}${result.text.length > 500 ? "..." : ""}`);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (result.summary) {
|
|
158
|
-
lines.push(`Summary: ${result.summary}`);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
lines.push("");
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (costDollars) {
|
|
165
|
-
lines.push(`Cost: $${costDollars.total.toFixed(6)}`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return lines.join("\n");
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function formatFetchResult(result: ExaSearchResult, contentType: FetchContentType): string {
|
|
172
|
-
const lines: string[] = [];
|
|
173
|
-
|
|
174
|
-
if (result.title) {
|
|
175
|
-
lines.push(`Title: ${result.title}`);
|
|
176
|
-
}
|
|
177
|
-
lines.push(`URL: ${result.url}`);
|
|
178
|
-
lines.push("");
|
|
179
|
-
|
|
180
|
-
switch (contentType) {
|
|
181
|
-
case "text":
|
|
182
|
-
if (result.text) {
|
|
183
|
-
lines.push(result.text);
|
|
184
|
-
}
|
|
185
|
-
break;
|
|
186
|
-
case "highlights":
|
|
187
|
-
if (result.highlights && result.highlights.length > 0) {
|
|
188
|
-
lines.push("Highlights:");
|
|
189
|
-
for (const h of result.highlights) {
|
|
190
|
-
lines.push(` • ${h}`);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
break;
|
|
194
|
-
case "summary":
|
|
195
|
-
if (result.summary) {
|
|
196
|
-
lines.push("Summary:");
|
|
197
|
-
lines.push(result.summary);
|
|
198
|
-
}
|
|
199
|
-
break;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return lines.join("\n");
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function parseCostDollars(costDollars: string | { total: number }): { total: number } {
|
|
206
|
-
if (typeof costDollars === "string") {
|
|
207
|
-
return JSON.parse(costDollars);
|
|
208
|
-
}
|
|
209
|
-
return costDollars;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function formatCodeContextResult(response: CodeContextResponse): string {
|
|
213
|
-
const lines: string[] = [];
|
|
214
|
-
|
|
215
|
-
lines.push(`Query: ${response.query}`);
|
|
216
|
-
lines.push(`Results: ${response.resultsCount} sources`);
|
|
217
|
-
lines.push(`Output tokens: ${response.outputTokens}`);
|
|
218
|
-
lines.push("");
|
|
219
|
-
lines.push("--- Code Context ---");
|
|
220
|
-
lines.push("");
|
|
221
|
-
lines.push(response.response);
|
|
222
|
-
lines.push("");
|
|
223
|
-
|
|
224
|
-
const cost = parseCostDollars(response.costDollars);
|
|
225
|
-
lines.push(`Cost: $${cost.total.toFixed(6)}`);
|
|
226
|
-
|
|
227
|
-
return lines.join("\n");
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Error Creation
|
|
231
|
-
|
|
232
|
-
function createMissingApiKeyError(): Error {
|
|
233
|
-
return new Error(
|
|
234
|
-
"Exa API key not configured. Set EXA_API_KEY environment variable before starting pi.",
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Exports
|
|
239
|
-
|
|
240
|
-
export {
|
|
241
|
-
getApiKey,
|
|
242
|
-
mapSearchContentType,
|
|
243
|
-
mapFetchContentType,
|
|
244
|
-
formatSearchResults,
|
|
245
|
-
formatFetchResult,
|
|
246
|
-
formatCodeContextResult,
|
|
247
|
-
parseCostDollars,
|
|
248
|
-
createMissingApiKeyError,
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
252
|
-
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
253
|
-
const hasKey = !!getApiKey();
|
|
254
|
-
if (!hasKey) {
|
|
255
|
-
ctx.ui.notify("Exa API key not configured. Set EXA_API_KEY to enable search.", "warning");
|
|
256
|
-
}
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
// Register exa_search tool
|
|
260
|
-
|
|
261
|
-
const ExaSearchParams = Type.Object({
|
|
262
|
-
query: Type.String({
|
|
263
|
-
description: "Natural language search query",
|
|
264
|
-
}),
|
|
265
|
-
contentType: Type.Optional(
|
|
266
|
-
Type.Union([
|
|
267
|
-
Type.Literal("text"),
|
|
268
|
-
Type.Literal("highlights"),
|
|
269
|
-
Type.Literal("summary"),
|
|
270
|
-
Type.Literal("none"),
|
|
271
|
-
]),
|
|
272
|
-
),
|
|
273
|
-
numResults: Type.Optional(
|
|
274
|
-
Type.Number({
|
|
275
|
-
description: "Number of results (1-100)",
|
|
276
|
-
}),
|
|
277
|
-
),
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
pi.registerTool(
|
|
281
|
-
defineTool({
|
|
282
|
-
name: "exa_search",
|
|
283
|
-
label: "Exa Search",
|
|
284
|
-
description:
|
|
285
|
-
"Search the web using Exa's neural search API. Best for factual queries, research, and finding relevant web content. Use highlights mode by default for token efficiency.",
|
|
286
|
-
parameters: ExaSearchParams,
|
|
287
|
-
|
|
288
|
-
async execute(
|
|
289
|
-
_toolCallId: string,
|
|
290
|
-
params: Static<typeof ExaSearchParams>,
|
|
291
|
-
_signal: AbortSignal | undefined,
|
|
292
|
-
_onUpdate: unknown,
|
|
293
|
-
_ctx: ExtensionContext,
|
|
294
|
-
) {
|
|
295
|
-
const apiKey = getApiKey();
|
|
296
|
-
if (!apiKey) {
|
|
297
|
-
throw createMissingApiKeyError();
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const numResults = Math.max(1, Math.min(100, params.numResults ?? 10));
|
|
301
|
-
const exa = new Exa(apiKey);
|
|
302
|
-
|
|
303
|
-
const contents = mapSearchContentType(params.contentType as SearchContentType | undefined);
|
|
304
|
-
const searchOptions: {
|
|
305
|
-
numResults: number;
|
|
306
|
-
contents?: { text?: true; highlights?: true; summary?: true };
|
|
307
|
-
} = { numResults };
|
|
308
|
-
|
|
309
|
-
if (contents) {
|
|
310
|
-
searchOptions.contents = contents;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
let response;
|
|
314
|
-
try {
|
|
315
|
-
response = await exa.search(params.query, searchOptions);
|
|
316
|
-
} catch (error) {
|
|
317
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
318
|
-
throw new Error(`Exa API error: ${message}`);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
let output = formatSearchResults({
|
|
322
|
-
results: response.results as ExaSearchResult[],
|
|
323
|
-
costDollars: response.costDollars as { total: number } | undefined,
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
const truncation = truncateHead(output, {
|
|
327
|
-
maxLines: DEFAULT_MAX_LINES,
|
|
328
|
-
maxBytes: DEFAULT_MAX_BYTES,
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
let result = truncation.content;
|
|
332
|
-
|
|
333
|
-
if (truncation.truncated) {
|
|
334
|
-
const tempFile = await writeTempFile(output);
|
|
335
|
-
result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
336
|
-
result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
337
|
-
result += ` Full output saved to: ${tempFile}]`;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return {
|
|
341
|
-
content: [{ type: "text", text: result }],
|
|
342
|
-
details: {
|
|
343
|
-
query: params.query,
|
|
344
|
-
numResults: response.results.length,
|
|
345
|
-
cost: response.costDollars,
|
|
346
|
-
} as SearchDetails,
|
|
347
|
-
};
|
|
348
|
-
},
|
|
349
|
-
|
|
350
|
-
renderCall(args, theme) {
|
|
351
|
-
const preview = args.query.length > 50 ? args.query.slice(0, 50) + "..." : args.query;
|
|
352
|
-
const desc = `${args.numResults ?? 10} results • ${args.contentType ?? "highlights"}`;
|
|
353
|
-
const text =
|
|
354
|
-
theme.fg("toolTitle", theme.bold("exa_search ")) +
|
|
355
|
-
theme.fg("muted", preview) +
|
|
356
|
-
theme.fg("dim", ` ${desc}`);
|
|
357
|
-
return new Text(text, 0, 0);
|
|
358
|
-
},
|
|
359
|
-
|
|
360
|
-
renderResult(result, _options, theme) {
|
|
361
|
-
const details = result.details as SearchDetails | undefined;
|
|
362
|
-
|
|
363
|
-
if (!details) {
|
|
364
|
-
const text = result.content[0];
|
|
365
|
-
return new Text(text?.type === "text" ? text.text.slice(0, 60) : "", 0, 0);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
const cost = details.cost ? ` • $${details.cost.total.toFixed(6)}` : "";
|
|
369
|
-
return new Text(theme.fg("success", `✓ ${details.numResults} results${cost}`), 0, 0);
|
|
370
|
-
},
|
|
371
|
-
})
|
|
372
|
-
);
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
// Register exa_fetch tool
|
|
376
|
-
|
|
377
|
-
const ExaFetchParams = Type.Object({
|
|
378
|
-
url: Type.String({
|
|
379
|
-
description: "URL to fetch content from",
|
|
380
|
-
}),
|
|
381
|
-
contentType: Type.Optional(
|
|
382
|
-
Type.Union([Type.Literal("text"), Type.Literal("highlights"), Type.Literal("summary")]),
|
|
383
|
-
),
|
|
384
|
-
maxCharacters: Type.Optional(
|
|
385
|
-
Type.Number({
|
|
386
|
-
description: "Maximum characters to return",
|
|
387
|
-
}),
|
|
388
|
-
),
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
pi.registerTool(
|
|
392
|
-
defineTool({
|
|
393
|
-
name: "exa_fetch",
|
|
394
|
-
label: "Exa Fetch",
|
|
395
|
-
description:
|
|
396
|
-
"Fetch and extract content from a specific URL using Exa. Can return full text, highlights, or AI-generated summary.",
|
|
397
|
-
parameters: ExaFetchParams,
|
|
398
|
-
|
|
399
|
-
async execute(
|
|
400
|
-
_toolCallId: string,
|
|
401
|
-
params: Static<typeof ExaFetchParams>,
|
|
402
|
-
_signal: AbortSignal | undefined,
|
|
403
|
-
_onUpdate: unknown,
|
|
404
|
-
_ctx: ExtensionContext,
|
|
405
|
-
) {
|
|
406
|
-
const apiKey = getApiKey();
|
|
407
|
-
if (!apiKey) {
|
|
408
|
-
throw createMissingApiKeyError();
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const exa = new Exa(apiKey);
|
|
412
|
-
|
|
413
|
-
const contentsOptions: {
|
|
414
|
-
text?: true;
|
|
415
|
-
highlights?: true;
|
|
416
|
-
summary?: true;
|
|
417
|
-
maxCharacters?: number;
|
|
418
|
-
} = {};
|
|
419
|
-
|
|
420
|
-
const mappedContent = mapFetchContentType(params.contentType as FetchContentType | undefined);
|
|
421
|
-
if (mappedContent?.text) contentsOptions.text = true;
|
|
422
|
-
if (mappedContent?.highlights) contentsOptions.highlights = true;
|
|
423
|
-
if (mappedContent?.summary) contentsOptions.summary = true;
|
|
424
|
-
if (params.maxCharacters) {
|
|
425
|
-
contentsOptions.maxCharacters = Math.max(1000, Math.min(100000, params.maxCharacters));
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
let response;
|
|
429
|
-
try {
|
|
430
|
-
response = await exa.getContents(params.url, contentsOptions);
|
|
431
|
-
} catch (error) {
|
|
432
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
433
|
-
throw new Error(`Exa API error: ${message}`);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (!response.results || response.results.length === 0) {
|
|
437
|
-
return {
|
|
438
|
-
content: [{ type: "text", text: "No content found at this URL." }],
|
|
439
|
-
details: { url: params.url, cost: response.costDollars } as FetchDetails,
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const result = response.results[0] as ExaSearchResult;
|
|
444
|
-
let output = formatFetchResult(result, (params.contentType ?? "text") as FetchContentType);
|
|
445
|
-
|
|
446
|
-
const truncation = truncateHead(output, {
|
|
447
|
-
maxLines: DEFAULT_MAX_LINES,
|
|
448
|
-
maxBytes: DEFAULT_MAX_BYTES,
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
let content = truncation.content;
|
|
452
|
-
|
|
453
|
-
if (truncation.truncated) {
|
|
454
|
-
const tempFile = await writeTempFile(output);
|
|
455
|
-
content += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
456
|
-
content += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
457
|
-
content += ` Full output saved to: ${tempFile}]`;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
return {
|
|
461
|
-
content: [{ type: "text", text: content }],
|
|
462
|
-
details: {
|
|
463
|
-
url: params.url,
|
|
464
|
-
title: result.title,
|
|
465
|
-
cost: response.costDollars,
|
|
466
|
-
} as FetchDetails,
|
|
467
|
-
};
|
|
468
|
-
},
|
|
469
|
-
|
|
470
|
-
renderCall(args, theme) {
|
|
471
|
-
const urlPreview = args.url.length > 40 ? args.url.slice(0, 40) + "..." : args.url;
|
|
472
|
-
const desc = args.contentType ?? "text";
|
|
473
|
-
const text =
|
|
474
|
-
theme.fg("toolTitle", theme.bold("exa_fetch ")) +
|
|
475
|
-
theme.fg("muted", urlPreview) +
|
|
476
|
-
theme.fg("dim", ` ${desc}`);
|
|
477
|
-
return new Text(text, 0, 0);
|
|
478
|
-
},
|
|
479
|
-
|
|
480
|
-
renderResult(result, _options, theme) {
|
|
481
|
-
const details = result.details as FetchDetails | undefined;
|
|
482
|
-
|
|
483
|
-
if (!details) {
|
|
484
|
-
const text = result.content[0];
|
|
485
|
-
return new Text(text?.type === "text" ? text.text.slice(0, 60) : "", 0, 0);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
const cost = details.cost ? ` • $${details.cost.total.toFixed(6)}` : "";
|
|
489
|
-
|
|
490
|
-
if (details.title) {
|
|
491
|
-
return new Text(theme.fg("success", `✓ ${details.title}${cost}`), 0, 0);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
return new Text(theme.fg("success", `✓ Fetched${cost}`), 0, 0);
|
|
495
|
-
},
|
|
496
|
-
})
|
|
497
|
-
);
|
|
498
|
-
|
|
499
|
-
// Register exa_code_context tool
|
|
500
|
-
|
|
501
|
-
const ExaCodeContextParams = Type.Object({
|
|
502
|
-
query: Type.String({
|
|
503
|
-
description: "Search query to find relevant code snippets and examples",
|
|
504
|
-
}),
|
|
505
|
-
tokensNum: Type.Optional(
|
|
506
|
-
Type.Union([
|
|
507
|
-
Type.String({
|
|
508
|
-
description: 'Token limit: "dynamic" for automatic sizing',
|
|
509
|
-
}),
|
|
510
|
-
Type.Number({
|
|
511
|
-
description: "Token limit: 50-100000 (5000 is a good default)",
|
|
512
|
-
}),
|
|
513
|
-
]),
|
|
514
|
-
),
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
pi.registerTool(
|
|
518
|
-
defineTool({
|
|
519
|
-
name: "exa_code_context",
|
|
520
|
-
label: "Exa Code Context",
|
|
521
|
-
description:
|
|
522
|
-
"Search for code snippets and examples from open source libraries and repositories. Use this to find working code examples that help understand how libraries, frameworks, or concepts are implemented.",
|
|
523
|
-
parameters: ExaCodeContextParams,
|
|
524
|
-
|
|
525
|
-
async execute(
|
|
526
|
-
_toolCallId: string,
|
|
527
|
-
params: Static<typeof ExaCodeContextParams>,
|
|
528
|
-
_signal: AbortSignal | undefined,
|
|
529
|
-
_onUpdate: unknown,
|
|
530
|
-
_ctx: ExtensionContext,
|
|
531
|
-
) {
|
|
532
|
-
const apiKey = getApiKey();
|
|
533
|
-
if (!apiKey) {
|
|
534
|
-
throw createMissingApiKeyError();
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// Ensure tokensNum is the correct type: number or "dynamic"
|
|
538
|
-
// The schema accepts both string and number, but the Exa API requires:
|
|
539
|
-
// - A number (e.g., 5000)
|
|
540
|
-
// - The literal string "dynamic"
|
|
541
|
-
let tokensNum: string | number = params.tokensNum ?? "dynamic";
|
|
542
|
-
if (typeof tokensNum === "string" && tokensNum !== "dynamic") {
|
|
543
|
-
tokensNum = Number(tokensNum);
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
let response;
|
|
547
|
-
try {
|
|
548
|
-
const httpResponse = await fetch("https://api.exa.ai/context", {
|
|
549
|
-
method: "POST",
|
|
550
|
-
headers: {
|
|
551
|
-
"Content-Type": "application/json",
|
|
552
|
-
"x-api-key": apiKey,
|
|
553
|
-
},
|
|
554
|
-
body: JSON.stringify({
|
|
555
|
-
query: params.query,
|
|
556
|
-
tokensNum,
|
|
557
|
-
}),
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
if (!httpResponse.ok) {
|
|
561
|
-
const errorText = await httpResponse.text();
|
|
562
|
-
throw new Error(`HTTP ${httpResponse.status}: ${errorText}`);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
response = (await httpResponse.json()) as CodeContextResponse;
|
|
566
|
-
} catch (error) {
|
|
567
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
568
|
-
throw new Error(`Exa Context API error: ${message}`);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
let output = formatCodeContextResult(response);
|
|
572
|
-
|
|
573
|
-
const truncation = truncateHead(output, {
|
|
574
|
-
maxLines: DEFAULT_MAX_LINES,
|
|
575
|
-
maxBytes: DEFAULT_MAX_BYTES,
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
let result = truncation.content;
|
|
579
|
-
|
|
580
|
-
if (truncation.truncated) {
|
|
581
|
-
const tempFile = await writeTempFile(output);
|
|
582
|
-
result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
583
|
-
result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
584
|
-
result += ` Full output saved to: ${tempFile}]`;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
const cost = parseCostDollars(response.costDollars);
|
|
588
|
-
|
|
589
|
-
return {
|
|
590
|
-
content: [{ type: "text", text: result }],
|
|
591
|
-
details: {
|
|
592
|
-
query: params.query,
|
|
593
|
-
resultsCount: response.resultsCount,
|
|
594
|
-
outputTokens: response.outputTokens,
|
|
595
|
-
cost,
|
|
596
|
-
} as CodeContextDetails,
|
|
597
|
-
};
|
|
598
|
-
},
|
|
599
|
-
|
|
600
|
-
renderCall(args, theme) {
|
|
601
|
-
const preview = args.query.length > 50 ? args.query.slice(0, 50) + "..." : args.query;
|
|
602
|
-
const desc = `${args.tokensNum ?? "dynamic"} tokens`;
|
|
603
|
-
const text =
|
|
604
|
-
theme.fg("toolTitle", theme.bold("exa_code_context ")) +
|
|
605
|
-
theme.fg("muted", preview) +
|
|
606
|
-
theme.fg("dim", ` ${desc}`);
|
|
607
|
-
return new Text(text, 0, 0);
|
|
608
|
-
},
|
|
609
|
-
|
|
610
|
-
renderResult(result, _options, theme) {
|
|
611
|
-
const details = result.details as CodeContextDetails | undefined;
|
|
612
|
-
|
|
613
|
-
if (!details) {
|
|
614
|
-
const text = result.content[0];
|
|
615
|
-
return new Text(text?.type === "text" ? text.text.slice(0, 60) : "", 0, 0);
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
const cost = details.cost ? ` • $${details.cost.total.toFixed(6)}` : "";
|
|
619
|
-
return new Text(
|
|
620
|
-
theme.fg(
|
|
621
|
-
"success",
|
|
622
|
-
`✓ ${details.resultsCount} sources • ${details.outputTokens} tokens${cost}`,
|
|
623
|
-
),
|
|
624
|
-
0,
|
|
625
|
-
0,
|
|
626
|
-
);
|
|
627
|
-
},
|
|
628
|
-
})
|
|
629
|
-
);
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
// Register /exa-status command
|
|
633
|
-
|
|
634
|
-
pi.registerCommand("exa-status", {
|
|
635
|
-
description: "Check Exa API key configuration status",
|
|
636
|
-
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
637
|
-
const configured = !!getApiKey();
|
|
638
|
-
ctx.ui.notify(
|
|
639
|
-
configured
|
|
640
|
-
? "Exa API key: configured via EXA_API_KEY"
|
|
641
|
-
: "Exa API key: not configured. Set EXA_API_KEY environment variable.",
|
|
642
|
-
configured ? "info" : "warning",
|
|
643
|
-
);
|
|
644
|
-
},
|
|
645
|
-
});
|
|
646
|
-
}
|