@jarcelao/pi-exa-api 0.2.0 → 0.2.1
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 +18 -1
- package/exa-search.test.ts +58 -7
- package/extensions/exa-search.ts +44 -15
- package/package.json +1 -1
- 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
|
|
|
@@ -66,6 +66,23 @@ Fetch the content from https://example.com/article
|
|
|
66
66
|
- `summary` - AI-generated summary
|
|
67
67
|
- `maxCharacters` (optional) - Maximum characters to return (1000-100000)
|
|
68
68
|
|
|
69
|
+
### Code Context
|
|
70
|
+
|
|
71
|
+
The agent can use `exa_code_context` to find code snippets and examples from open source libraries and repositories:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
Find examples of React hooks for state management
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
It's ideal for understanding how libraries, frameworks, or programming concepts are implemented in practice.
|
|
78
|
+
|
|
79
|
+
**Parameters:**
|
|
80
|
+
|
|
81
|
+
- `query` (required) - Search query for code snippets and examples (1-2000 characters)
|
|
82
|
+
- `tokensNum` (optional) - Token limit for the response:
|
|
83
|
+
- `"dynamic"` (default) - Automatically determine optimal response length
|
|
84
|
+
- `50-100000` - Specific number of tokens (5000 is a good default, use 10000 when more context is needed)
|
|
85
|
+
|
|
69
86
|
## Development
|
|
70
87
|
|
|
71
88
|
```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,18 @@
|
|
|
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,
|
|
20
19
|
formatSize,
|
|
21
|
-
|
|
20
|
+
truncateHead,
|
|
22
21
|
} from "@mariozechner/pi-coding-agent";
|
|
22
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
23
|
+
import { Type, type Static } from "@mariozechner/pi-ai";
|
|
23
24
|
import Exa from "exa-js";
|
|
24
25
|
|
|
25
26
|
// API Key Management
|
|
@@ -29,6 +30,15 @@ function getApiKey(): string | undefined {
|
|
|
29
30
|
return key && key.length > 0 ? key : undefined;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
// Temp File Helper
|
|
34
|
+
|
|
35
|
+
async function writeTempFile(content: string): Promise<string> {
|
|
36
|
+
const tempDir = await mkdtemp(join(tmpdir(), "pi-exa-"));
|
|
37
|
+
const tempFile = join(tempDir, "output.txt");
|
|
38
|
+
await writeFile(tempFile, content, "utf8");
|
|
39
|
+
return tempFile;
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
// Type Definitions
|
|
33
43
|
|
|
34
44
|
type SearchContentType = "text" | "highlights" | "summary" | "none";
|
|
@@ -251,7 +261,14 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
251
261
|
query: Type.String({
|
|
252
262
|
description: "Natural language search query",
|
|
253
263
|
}),
|
|
254
|
-
contentType: Type.Optional(
|
|
264
|
+
contentType: Type.Optional(
|
|
265
|
+
Type.Union([
|
|
266
|
+
Type.Literal("text"),
|
|
267
|
+
Type.Literal("highlights"),
|
|
268
|
+
Type.Literal("summary"),
|
|
269
|
+
Type.Literal("none"),
|
|
270
|
+
]),
|
|
271
|
+
),
|
|
255
272
|
numResults: Type.Optional(
|
|
256
273
|
Type.Number({
|
|
257
274
|
description: "Number of results (1-100)",
|
|
@@ -312,7 +329,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
312
329
|
let result = truncation.content;
|
|
313
330
|
|
|
314
331
|
if (truncation.truncated) {
|
|
315
|
-
const tempFile = writeTempFile(output);
|
|
332
|
+
const tempFile = await writeTempFile(output);
|
|
316
333
|
result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
317
334
|
result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
318
335
|
result += ` Full output saved to: ${tempFile}]`;
|
|
@@ -338,7 +355,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
338
355
|
return new Text(text, 0, 0);
|
|
339
356
|
},
|
|
340
357
|
|
|
341
|
-
renderResult(result,
|
|
358
|
+
renderResult(result, _options, theme) {
|
|
342
359
|
const details = result.details as SearchDetails | undefined;
|
|
343
360
|
|
|
344
361
|
if (!details) {
|
|
@@ -357,7 +374,9 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
357
374
|
url: Type.String({
|
|
358
375
|
description: "URL to fetch content from",
|
|
359
376
|
}),
|
|
360
|
-
contentType: Type.Optional(
|
|
377
|
+
contentType: Type.Optional(
|
|
378
|
+
Type.Union([Type.Literal("text"), Type.Literal("highlights"), Type.Literal("summary")]),
|
|
379
|
+
),
|
|
361
380
|
maxCharacters: Type.Optional(
|
|
362
381
|
Type.Number({
|
|
363
382
|
description: "Maximum characters to return",
|
|
@@ -427,7 +446,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
427
446
|
let content = truncation.content;
|
|
428
447
|
|
|
429
448
|
if (truncation.truncated) {
|
|
430
|
-
const tempFile = writeTempFile(output);
|
|
449
|
+
const tempFile = await writeTempFile(output);
|
|
431
450
|
content += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
432
451
|
content += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
433
452
|
content += ` Full output saved to: ${tempFile}]`;
|
|
@@ -453,7 +472,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
453
472
|
return new Text(text, 0, 0);
|
|
454
473
|
},
|
|
455
474
|
|
|
456
|
-
renderResult(result,
|
|
475
|
+
renderResult(result, _options, theme) {
|
|
457
476
|
const details = result.details as FetchDetails | undefined;
|
|
458
477
|
|
|
459
478
|
if (!details) {
|
|
@@ -508,7 +527,14 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
508
527
|
throw createMissingApiKeyError();
|
|
509
528
|
}
|
|
510
529
|
|
|
511
|
-
|
|
530
|
+
// Ensure tokensNum is the correct type: number or "dynamic"
|
|
531
|
+
// The schema accepts both string and number, but the Exa API requires:
|
|
532
|
+
// - A number (e.g., 5000)
|
|
533
|
+
// - The literal string "dynamic"
|
|
534
|
+
let tokensNum: string | number = params.tokensNum ?? "dynamic";
|
|
535
|
+
if (typeof tokensNum === "string" && tokensNum !== "dynamic") {
|
|
536
|
+
tokensNum = Number(tokensNum);
|
|
537
|
+
}
|
|
512
538
|
|
|
513
539
|
let response;
|
|
514
540
|
try {
|
|
@@ -545,7 +571,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
545
571
|
let result = truncation.content;
|
|
546
572
|
|
|
547
573
|
if (truncation.truncated) {
|
|
548
|
-
const tempFile = writeTempFile(output);
|
|
574
|
+
const tempFile = await writeTempFile(output);
|
|
549
575
|
result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
550
576
|
result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
551
577
|
result += ` Full output saved to: ${tempFile}]`;
|
|
@@ -574,7 +600,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
574
600
|
return new Text(text, 0, 0);
|
|
575
601
|
},
|
|
576
602
|
|
|
577
|
-
renderResult(result,
|
|
603
|
+
renderResult(result, _options, theme) {
|
|
578
604
|
const details = result.details as CodeContextDetails | undefined;
|
|
579
605
|
|
|
580
606
|
if (!details) {
|
|
@@ -584,7 +610,10 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
584
610
|
|
|
585
611
|
const cost = details.cost ? ` • $${details.cost.total.toFixed(6)}` : "";
|
|
586
612
|
return new Text(
|
|
587
|
-
theme.fg(
|
|
613
|
+
theme.fg(
|
|
614
|
+
"success",
|
|
615
|
+
`✓ ${details.resultsCount} sources • ${details.outputTokens} tokens${cost}`,
|
|
616
|
+
),
|
|
588
617
|
0,
|
|
589
618
|
0,
|
|
590
619
|
);
|
package/package.json
CHANGED
package/tsconfig.json
CHANGED