@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-exa-api
2
2
 
3
- Web search and content fetching for [pi](https://pi.dev) via the [Exa API](https://exa.ai/).
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
@@ -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");
@@ -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
- writeTempFile,
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(StringEnum(["text", "highlights", "summary", "none"] as const)),
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
- name: "exa_search",
264
- label: "Exa Search",
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, { expanded: _expanded, isPartial: _isPartial }, theme, _context) {
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(StringEnum(["text", "highlights", "summary"] as const)),
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
- name: "exa_fetch",
370
- label: "Exa Fetch",
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, { expanded: _expanded, isPartial: _isPartial }, theme, _context) {
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
- name: "exa_code_context",
494
- label: "Exa Code Context",
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
- const tokensNum = params.tokensNum ?? "dynamic";
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, { expanded: _expanded, isPartial: _isPartial }, theme, _context) {
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("success", `✓ ${details.resultsCount} sources • ${details.outputTokens} tokens${cost}`),
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.0",
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
- "@sinclair/typebox": "*"
37
+ "typebox": "*"
38
38
  },
39
39
  "pi": {
40
40
  "extensions": [
package/tsconfig.json CHANGED
@@ -8,8 +8,9 @@
8
8
  "skipLibCheck": true,
9
9
  "noEmit": true,
10
10
  "resolveJsonModule": true,
11
+ "allowImportingTsExtensions": true,
11
12
  "types": ["node"]
12
13
  },
13
- "include": ["*.ts"],
14
+ "include": ["*.ts", "extensions/**/*.ts"],
14
15
  "exclude": ["node_modules"]
15
16
  }