@jarcelao/pi-exa-api 0.2.0 → 0.2.2
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 +21 -1
- package/exa-search.test.ts +58 -7
- package/extensions/exa-search.ts +65 -27
- package/package.json +2 -2
- package/tsconfig.json +2 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-exa-api
|
|
2
2
|
|
|
3
|
-
Web search
|
|
3
|
+
Web search, content fetching, and code context for [pi](https://pi.dev) via the [Exa API](https://exa.ai/).
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -10,6 +10,9 @@ Install as a pi package:
|
|
|
10
10
|
pi install npm:@jarcelao/pi-exa-api
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
> [!NOTE]
|
|
14
|
+
> This extension is compatible with `pi-coding-agent` v0.69.0
|
|
15
|
+
|
|
13
16
|
## Configuration
|
|
14
17
|
|
|
15
18
|
Set your Exa API key as an environment variable before starting pi:
|
|
@@ -66,6 +69,23 @@ Fetch the content from https://example.com/article
|
|
|
66
69
|
- `summary` - AI-generated summary
|
|
67
70
|
- `maxCharacters` (optional) - Maximum characters to return (1000-100000)
|
|
68
71
|
|
|
72
|
+
### Code Context
|
|
73
|
+
|
|
74
|
+
The agent can use `exa_code_context` to find code snippets and examples from open source libraries and repositories:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Find examples of React hooks for state management
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
It's ideal for understanding how libraries, frameworks, or programming concepts are implemented in practice.
|
|
81
|
+
|
|
82
|
+
**Parameters:**
|
|
83
|
+
|
|
84
|
+
- `query` (required) - Search query for code snippets and examples (1-2000 characters)
|
|
85
|
+
- `tokensNum` (optional) - Token limit for the response:
|
|
86
|
+
- `"dynamic"` (default) - Automatically determine optimal response length
|
|
87
|
+
- `50-100000` - Specific number of tokens (5000 is a good default, use 10000 when more context is needed)
|
|
88
|
+
|
|
69
89
|
## Development
|
|
70
90
|
|
|
71
91
|
```bash
|
package/exa-search.test.ts
CHANGED
|
@@ -298,6 +298,7 @@ describe("Fetch Result Formatting", () => {
|
|
|
298
298
|
|
|
299
299
|
it("should format highlights", () => {
|
|
300
300
|
const result = {
|
|
301
|
+
title: "Test Page",
|
|
301
302
|
url: "https://example.com",
|
|
302
303
|
highlights: ["Key point 1", "Key point 2"],
|
|
303
304
|
};
|
|
@@ -311,6 +312,7 @@ describe("Fetch Result Formatting", () => {
|
|
|
311
312
|
|
|
312
313
|
it("should format summary", () => {
|
|
313
314
|
const result = {
|
|
315
|
+
title: "Test Page",
|
|
314
316
|
url: "https://example.com",
|
|
315
317
|
summary: "This page is about...",
|
|
316
318
|
};
|
|
@@ -324,9 +326,9 @@ describe("Fetch Result Formatting", () => {
|
|
|
324
326
|
const result = {
|
|
325
327
|
url: "https://example.com",
|
|
326
328
|
text: "Content only",
|
|
327
|
-
};
|
|
329
|
+
} as { title?: string; url: string; text: string };
|
|
328
330
|
|
|
329
|
-
const formatted = formatFetchResult(result, "text");
|
|
331
|
+
const formatted = formatFetchResult(result as Parameters<typeof formatFetchResult>[0], "text");
|
|
330
332
|
expect(formatted).toContain("https://example.com");
|
|
331
333
|
expect(formatted).not.toContain("Title:");
|
|
332
334
|
});
|
|
@@ -485,6 +487,60 @@ describe("Code Context Parameter Validation", () => {
|
|
|
485
487
|
});
|
|
486
488
|
});
|
|
487
489
|
|
|
490
|
+
describe("tokensNum Type Coercion", () => {
|
|
491
|
+
// Helper function that mirrors the type coercion logic in exa_code_context execute
|
|
492
|
+
function coerceTokensNum(tokensNum: string | number | undefined): string | number {
|
|
493
|
+
const value = tokensNum ?? "dynamic";
|
|
494
|
+
if (typeof value === "string" && value !== "dynamic") {
|
|
495
|
+
return Number(value);
|
|
496
|
+
}
|
|
497
|
+
return value;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
it("should return 'dynamic' when tokensNum is undefined", () => {
|
|
501
|
+
const result = coerceTokensNum(undefined);
|
|
502
|
+
expect(result).toBe("dynamic");
|
|
503
|
+
expect(typeof result).toBe("string");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("should preserve 'dynamic' string", () => {
|
|
507
|
+
const result = coerceTokensNum("dynamic");
|
|
508
|
+
expect(result).toBe("dynamic");
|
|
509
|
+
expect(typeof result).toBe("string");
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("should preserve numeric tokens", () => {
|
|
513
|
+
const result = coerceTokensNum(5000);
|
|
514
|
+
expect(result).toBe(5000);
|
|
515
|
+
expect(typeof result).toBe("number");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("should coerce numeric string to number", () => {
|
|
519
|
+
const result = coerceTokensNum("5000");
|
|
520
|
+
expect(result).toBe(5000);
|
|
521
|
+
expect(typeof result).toBe("number");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("should coerce various numeric strings", () => {
|
|
525
|
+
expect(coerceTokensNum("1000")).toBe(1000);
|
|
526
|
+
expect(coerceTokensNum("10000")).toBe(10000);
|
|
527
|
+
expect(coerceTokensNum("3000")).toBe(3000);
|
|
528
|
+
expect(typeof coerceTokensNum("5000")).toBe("number");
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("should handle boundary token values", () => {
|
|
532
|
+
expect(coerceTokensNum(50)).toBe(50);
|
|
533
|
+
expect(coerceTokensNum(100000)).toBe(100000);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("should not modify actual numbers", () => {
|
|
537
|
+
const originalNumber = 7500;
|
|
538
|
+
const result = coerceTokensNum(originalNumber);
|
|
539
|
+
expect(result).toBe(originalNumber);
|
|
540
|
+
expect(result).toBe(7500);
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
488
544
|
describe("Extension Registration", () => {
|
|
489
545
|
function createMockExtensionAPI() {
|
|
490
546
|
const tools: unknown[] = [];
|
|
@@ -622,7 +678,6 @@ describe("Extension Registration", () => {
|
|
|
622
678
|
mockResult,
|
|
623
679
|
{ expanded: false, isPartial: false },
|
|
624
680
|
mockTheme,
|
|
625
|
-
{},
|
|
626
681
|
);
|
|
627
682
|
|
|
628
683
|
expect(rendered.text).toContain("Test Page");
|
|
@@ -654,7 +709,6 @@ describe("Extension Registration", () => {
|
|
|
654
709
|
mockResult,
|
|
655
710
|
{ expanded: false, isPartial: false },
|
|
656
711
|
mockTheme,
|
|
657
|
-
{},
|
|
658
712
|
);
|
|
659
713
|
|
|
660
714
|
expect(rendered.text).toContain("Fetched");
|
|
@@ -688,7 +742,6 @@ describe("Extension Registration", () => {
|
|
|
688
742
|
mockResult,
|
|
689
743
|
{ expanded: false, isPartial: false },
|
|
690
744
|
mockTheme,
|
|
691
|
-
{},
|
|
692
745
|
);
|
|
693
746
|
|
|
694
747
|
expect(rendered.text).toContain("502 sources");
|
|
@@ -722,7 +775,6 @@ describe("Extension Registration", () => {
|
|
|
722
775
|
mockResult,
|
|
723
776
|
{ expanded: false, isPartial: false },
|
|
724
777
|
mockTheme,
|
|
725
|
-
{},
|
|
726
778
|
);
|
|
727
779
|
|
|
728
780
|
expect(rendered.text).toContain("100 sources");
|
|
@@ -751,7 +803,6 @@ describe("Extension Registration", () => {
|
|
|
751
803
|
mockResult,
|
|
752
804
|
{ expanded: false, isPartial: false },
|
|
753
805
|
mockTheme,
|
|
754
|
-
{},
|
|
755
806
|
);
|
|
756
807
|
|
|
757
808
|
expect(rendered.text).toContain("Some code context");
|
package/extensions/exa-search.ts
CHANGED
|
@@ -9,17 +9,19 @@
|
|
|
9
9
|
* Also registers the /exa-status command to check API key configuration.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
12
15
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
13
|
-
import { Text } from "@mariozechner/pi-tui";
|
|
14
|
-
import { Type, type Static } from "@sinclair/typebox";
|
|
15
|
-
import { StringEnum } from "@mariozechner/pi-ai";
|
|
16
16
|
import {
|
|
17
|
-
truncateHead,
|
|
18
17
|
DEFAULT_MAX_BYTES,
|
|
19
18
|
DEFAULT_MAX_LINES,
|
|
19
|
+
defineTool,
|
|
20
20
|
formatSize,
|
|
21
|
-
|
|
21
|
+
truncateHead,
|
|
22
22
|
} from "@mariozechner/pi-coding-agent";
|
|
23
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
24
|
+
import { Type, type Static } from "typebox";
|
|
23
25
|
import Exa from "exa-js";
|
|
24
26
|
|
|
25
27
|
// API Key Management
|
|
@@ -29,6 +31,15 @@ function getApiKey(): string | undefined {
|
|
|
29
31
|
return key && key.length > 0 ? key : undefined;
|
|
30
32
|
}
|
|
31
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
|
+
|
|
32
43
|
// Type Definitions
|
|
33
44
|
|
|
34
45
|
type SearchContentType = "text" | "highlights" | "summary" | "none";
|
|
@@ -251,7 +262,14 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
251
262
|
query: Type.String({
|
|
252
263
|
description: "Natural language search query",
|
|
253
264
|
}),
|
|
254
|
-
contentType: Type.Optional(
|
|
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
|
+
),
|
|
255
273
|
numResults: Type.Optional(
|
|
256
274
|
Type.Number({
|
|
257
275
|
description: "Number of results (1-100)",
|
|
@@ -259,9 +277,10 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
259
277
|
),
|
|
260
278
|
});
|
|
261
279
|
|
|
262
|
-
pi.registerTool(
|
|
263
|
-
|
|
264
|
-
|
|
280
|
+
pi.registerTool(
|
|
281
|
+
defineTool({
|
|
282
|
+
name: "exa_search",
|
|
283
|
+
label: "Exa Search",
|
|
265
284
|
description:
|
|
266
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.",
|
|
267
286
|
parameters: ExaSearchParams,
|
|
@@ -312,7 +331,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
312
331
|
let result = truncation.content;
|
|
313
332
|
|
|
314
333
|
if (truncation.truncated) {
|
|
315
|
-
const tempFile = writeTempFile(output);
|
|
334
|
+
const tempFile = await writeTempFile(output);
|
|
316
335
|
result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
317
336
|
result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
318
337
|
result += ` Full output saved to: ${tempFile}]`;
|
|
@@ -338,7 +357,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
338
357
|
return new Text(text, 0, 0);
|
|
339
358
|
},
|
|
340
359
|
|
|
341
|
-
renderResult(result,
|
|
360
|
+
renderResult(result, _options, theme) {
|
|
342
361
|
const details = result.details as SearchDetails | undefined;
|
|
343
362
|
|
|
344
363
|
if (!details) {
|
|
@@ -349,7 +368,9 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
349
368
|
const cost = details.cost ? ` • $${details.cost.total.toFixed(6)}` : "";
|
|
350
369
|
return new Text(theme.fg("success", `✓ ${details.numResults} results${cost}`), 0, 0);
|
|
351
370
|
},
|
|
352
|
-
|
|
371
|
+
})
|
|
372
|
+
);
|
|
373
|
+
|
|
353
374
|
|
|
354
375
|
// Register exa_fetch tool
|
|
355
376
|
|
|
@@ -357,7 +378,9 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
357
378
|
url: Type.String({
|
|
358
379
|
description: "URL to fetch content from",
|
|
359
380
|
}),
|
|
360
|
-
contentType: Type.Optional(
|
|
381
|
+
contentType: Type.Optional(
|
|
382
|
+
Type.Union([Type.Literal("text"), Type.Literal("highlights"), Type.Literal("summary")]),
|
|
383
|
+
),
|
|
361
384
|
maxCharacters: Type.Optional(
|
|
362
385
|
Type.Number({
|
|
363
386
|
description: "Maximum characters to return",
|
|
@@ -365,9 +388,10 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
365
388
|
),
|
|
366
389
|
});
|
|
367
390
|
|
|
368
|
-
pi.registerTool(
|
|
369
|
-
|
|
370
|
-
|
|
391
|
+
pi.registerTool(
|
|
392
|
+
defineTool({
|
|
393
|
+
name: "exa_fetch",
|
|
394
|
+
label: "Exa Fetch",
|
|
371
395
|
description:
|
|
372
396
|
"Fetch and extract content from a specific URL using Exa. Can return full text, highlights, or AI-generated summary.",
|
|
373
397
|
parameters: ExaFetchParams,
|
|
@@ -427,7 +451,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
427
451
|
let content = truncation.content;
|
|
428
452
|
|
|
429
453
|
if (truncation.truncated) {
|
|
430
|
-
const tempFile = writeTempFile(output);
|
|
454
|
+
const tempFile = await writeTempFile(output);
|
|
431
455
|
content += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
432
456
|
content += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
433
457
|
content += ` Full output saved to: ${tempFile}]`;
|
|
@@ -453,7 +477,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
453
477
|
return new Text(text, 0, 0);
|
|
454
478
|
},
|
|
455
479
|
|
|
456
|
-
renderResult(result,
|
|
480
|
+
renderResult(result, _options, theme) {
|
|
457
481
|
const details = result.details as FetchDetails | undefined;
|
|
458
482
|
|
|
459
483
|
if (!details) {
|
|
@@ -469,7 +493,8 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
469
493
|
|
|
470
494
|
return new Text(theme.fg("success", `✓ Fetched${cost}`), 0, 0);
|
|
471
495
|
},
|
|
472
|
-
|
|
496
|
+
})
|
|
497
|
+
);
|
|
473
498
|
|
|
474
499
|
// Register exa_code_context tool
|
|
475
500
|
|
|
@@ -489,9 +514,10 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
489
514
|
),
|
|
490
515
|
});
|
|
491
516
|
|
|
492
|
-
pi.registerTool(
|
|
493
|
-
|
|
494
|
-
|
|
517
|
+
pi.registerTool(
|
|
518
|
+
defineTool({
|
|
519
|
+
name: "exa_code_context",
|
|
520
|
+
label: "Exa Code Context",
|
|
495
521
|
description:
|
|
496
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.",
|
|
497
523
|
parameters: ExaCodeContextParams,
|
|
@@ -508,7 +534,14 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
508
534
|
throw createMissingApiKeyError();
|
|
509
535
|
}
|
|
510
536
|
|
|
511
|
-
|
|
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
|
+
}
|
|
512
545
|
|
|
513
546
|
let response;
|
|
514
547
|
try {
|
|
@@ -545,7 +578,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
545
578
|
let result = truncation.content;
|
|
546
579
|
|
|
547
580
|
if (truncation.truncated) {
|
|
548
|
-
const tempFile = writeTempFile(output);
|
|
581
|
+
const tempFile = await writeTempFile(output);
|
|
549
582
|
result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
550
583
|
result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
551
584
|
result += ` Full output saved to: ${tempFile}]`;
|
|
@@ -574,7 +607,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
574
607
|
return new Text(text, 0, 0);
|
|
575
608
|
},
|
|
576
609
|
|
|
577
|
-
renderResult(result,
|
|
610
|
+
renderResult(result, _options, theme) {
|
|
578
611
|
const details = result.details as CodeContextDetails | undefined;
|
|
579
612
|
|
|
580
613
|
if (!details) {
|
|
@@ -584,12 +617,17 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
584
617
|
|
|
585
618
|
const cost = details.cost ? ` • $${details.cost.total.toFixed(6)}` : "";
|
|
586
619
|
return new Text(
|
|
587
|
-
theme.fg(
|
|
620
|
+
theme.fg(
|
|
621
|
+
"success",
|
|
622
|
+
`✓ ${details.resultsCount} sources • ${details.outputTokens} tokens${cost}`,
|
|
623
|
+
),
|
|
588
624
|
0,
|
|
589
625
|
0,
|
|
590
626
|
);
|
|
591
627
|
},
|
|
592
|
-
|
|
628
|
+
})
|
|
629
|
+
);
|
|
630
|
+
|
|
593
631
|
|
|
594
632
|
// Register /exa-status command
|
|
595
633
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarcelao/pi-exa-api",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Web search and content fetching for pi via the Exa API",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"@mariozechner/pi-coding-agent": "*",
|
|
36
36
|
"@mariozechner/pi-tui": "*",
|
|
37
|
-
"
|
|
37
|
+
"typebox": "*"
|
|
38
38
|
},
|
|
39
39
|
"pi": {
|
|
40
40
|
"extensions": [
|
package/tsconfig.json
CHANGED