@jarcelao/pi-exa-api 0.1.2 → 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/LICENSE.md CHANGED
@@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
-
package/README.md CHANGED
@@ -1,7 +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/).
4
-
3
+ Web search, content fetching, and code context for [pi](https://pi.dev) via the [Exa API](https://exa.ai/).
5
4
 
6
5
  ## Installation
7
6
 
@@ -67,6 +66,23 @@ Fetch the content from https://example.com/article
67
66
  - `summary` - AI-generated summary
68
67
  - `maxCharacters` (optional) - Maximum characters to return (1000-100000)
69
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
+
70
86
  ## Development
71
87
 
72
88
  ```bash
@@ -5,6 +5,8 @@ import exaSearchExtension, {
5
5
  mapFetchContentType,
6
6
  formatSearchResults,
7
7
  formatFetchResult,
8
+ formatCodeContextResult,
9
+ parseCostDollars,
8
10
  createMissingApiKeyError,
9
11
  } from "./extensions/exa-search.ts";
10
12
  import {
@@ -284,8 +286,19 @@ describe("Fetch Result Formatting", () => {
284
286
  expect(formatted).toContain("Full page content here");
285
287
  });
286
288
 
289
+ it("should include cost in fetch details", () => {
290
+ const details = {
291
+ url: "https://example.com",
292
+ title: "Test Page",
293
+ cost: { total: 0.000123 },
294
+ };
295
+ expect(details.cost).toBeDefined();
296
+ expect(details.cost?.total).toBe(0.000123);
297
+ });
298
+
287
299
  it("should format highlights", () => {
288
300
  const result = {
301
+ title: "Test Page",
289
302
  url: "https://example.com",
290
303
  highlights: ["Key point 1", "Key point 2"],
291
304
  };
@@ -299,6 +312,7 @@ describe("Fetch Result Formatting", () => {
299
312
 
300
313
  it("should format summary", () => {
301
314
  const result = {
315
+ title: "Test Page",
302
316
  url: "https://example.com",
303
317
  summary: "This page is about...",
304
318
  };
@@ -312,9 +326,9 @@ describe("Fetch Result Formatting", () => {
312
326
  const result = {
313
327
  url: "https://example.com",
314
328
  text: "Content only",
315
- };
329
+ } as { title?: string; url: string; text: string };
316
330
 
317
- const formatted = formatFetchResult(result, "text");
331
+ const formatted = formatFetchResult(result as Parameters<typeof formatFetchResult>[0], "text");
318
332
  expect(formatted).toContain("https://example.com");
319
333
  expect(formatted).not.toContain("Title:");
320
334
  });
@@ -329,6 +343,204 @@ describe("Error Handling", () => {
329
343
  });
330
344
  });
331
345
 
346
+ describe("parseCostDollars", () => {
347
+ it("should parse JSON string costDollars", () => {
348
+ const costString = '{"total":0.007,"search":{"neural":0.007}}';
349
+ const parsed = parseCostDollars(costString);
350
+ expect(parsed).toEqual({ total: 0.007, search: { neural: 0.007 } });
351
+ expect(parsed.total).toBe(0.007);
352
+ });
353
+
354
+ it("should pass through object costDollars unchanged", () => {
355
+ const costObject = { total: 1.5 };
356
+ const parsed = parseCostDollars(costObject);
357
+ expect(parsed).toEqual({ total: 1.5 });
358
+ });
359
+
360
+ it("should handle various cost string formats", () => {
361
+ expect(parseCostDollars('{"total":0}').total).toBe(0);
362
+ expect(parseCostDollars('{"total":123.456}').total).toBe(123.456);
363
+ expect(parseCostDollars({ total: 99.9 }).total).toBe(99.9);
364
+ });
365
+ });
366
+
367
+ describe("Code Context Result Formatting", () => {
368
+ it("should format code context response with string costDollars", () => {
369
+ const response = {
370
+ requestId: "req_12345",
371
+ query: "how to use React hooks for state management",
372
+ response: "## useState Example\n\n```javascript\nconst [count, setCount] = useState(0);\n```",
373
+ resultsCount: 502,
374
+ costDollars: '{"total":0.007,"search":{"neural":0.007}}',
375
+ searchTime: 1.234,
376
+ outputTokens: 4805,
377
+ };
378
+
379
+ const formatted = formatCodeContextResult(response);
380
+ expect(formatted).toContain("Query: how to use React hooks for state management");
381
+ expect(formatted).toContain("Results: 502 sources");
382
+ expect(formatted).toContain("Output tokens: 4805");
383
+ expect(formatted).toContain("--- Code Context ---");
384
+ expect(formatted).toContain("## useState Example");
385
+ expect(formatted).toContain("const [count, setCount] = useState(0);");
386
+ expect(formatted).toContain("Cost: $0.007000");
387
+ });
388
+
389
+ it("should format code context response with object costDollars", () => {
390
+ const response = {
391
+ requestId: "req_67890",
392
+ query: "test query",
393
+ response: "Some code examples...",
394
+ resultsCount: 10,
395
+ costDollars: { total: 1.5 },
396
+ searchTime: 0.5,
397
+ outputTokens: 1000,
398
+ };
399
+
400
+ const formatted = formatCodeContextResult(response);
401
+ expect(formatted).toContain("Cost: $1.500000");
402
+ });
403
+
404
+ it("should include query in formatted output", () => {
405
+ const response = {
406
+ requestId: "req_abc",
407
+ query: "Express.js middleware authentication",
408
+ response: "Some code examples...",
409
+ resultsCount: 100,
410
+ costDollars: '{"total":0.5}',
411
+ searchTime: 0.5,
412
+ outputTokens: 2000,
413
+ };
414
+
415
+ const formatted = formatCodeContextResult(response);
416
+ expect(formatted).toContain("Query: Express.js middleware authentication");
417
+ });
418
+
419
+ it("should format cost correctly with decimals", () => {
420
+ const response = {
421
+ requestId: "req_xyz",
422
+ query: "test query",
423
+ response: "response content",
424
+ resultsCount: 10,
425
+ costDollars: '{"total":0.123456}',
426
+ searchTime: 0.1,
427
+ outputTokens: 500,
428
+ };
429
+
430
+ const formatted = formatCodeContextResult(response);
431
+ expect(formatted).toContain("Cost: $0.123456");
432
+ });
433
+
434
+ it("should display results count and output tokens", () => {
435
+ const response = {
436
+ requestId: "req_test",
437
+ query: "pandas dataframe operations",
438
+ response: "Code examples here",
439
+ resultsCount: 150,
440
+ costDollars: '{"total":0.75}',
441
+ searchTime: 0.8,
442
+ outputTokens: 3500,
443
+ };
444
+
445
+ const formatted = formatCodeContextResult(response);
446
+ expect(formatted).toContain("Results: 150 sources");
447
+ expect(formatted).toContain("Output tokens: 3500");
448
+ });
449
+ });
450
+
451
+ describe("Code Context Parameter Validation", () => {
452
+ it("should accept valid params with query only", () => {
453
+ const params = { query: "React hooks examples" };
454
+ expect(params.query).toBeTruthy();
455
+ expect(params.query.trim()).not.toBe("");
456
+ });
457
+
458
+ it("should accept valid params with dynamic tokens", () => {
459
+ const params = {
460
+ query: "Express middleware",
461
+ tokensNum: "dynamic" as const,
462
+ };
463
+ expect(typeof params.query).toBe("string");
464
+ expect(params.tokensNum).toBe("dynamic");
465
+ });
466
+
467
+ it("should accept valid params with numeric tokens", () => {
468
+ const params = {
469
+ query: "Next.js configuration",
470
+ tokensNum: 5000,
471
+ };
472
+ expect(params.tokensNum).toBeGreaterThanOrEqual(50);
473
+ expect(params.tokensNum).toBeLessThanOrEqual(100000);
474
+ });
475
+
476
+ it("should accept token counts in valid range", () => {
477
+ const validTokenCounts = [50, 1000, 5000, 10000, 50000, 100000];
478
+ for (const count of validTokenCounts) {
479
+ expect(count).toBeGreaterThanOrEqual(50);
480
+ expect(count).toBeLessThanOrEqual(100000);
481
+ }
482
+ });
483
+
484
+ it("should accept query strings up to 2000 characters", () => {
485
+ const longQuery = "a".repeat(2000);
486
+ expect(longQuery.length).toBe(2000);
487
+ });
488
+ });
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
+
332
544
  describe("Extension Registration", () => {
333
545
  function createMockExtensionAPI() {
334
546
  const tools: unknown[] = [];
@@ -356,7 +568,7 @@ describe("Extension Registration", () => {
356
568
  exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
357
569
 
358
570
  const tools = api.getTools();
359
- expect(tools.length).toBeGreaterThanOrEqual(2);
571
+ expect(tools.length).toBe(3);
360
572
 
361
573
  const exaSearchTool = tools.find((t: unknown) => (t as { name: string }).name === "exa_search");
362
574
  expect(exaSearchTool).toBeDefined();
@@ -375,6 +587,30 @@ describe("Extension Registration", () => {
375
587
  expect((exaFetchTool as { label: string }).label).toBe("Exa Fetch");
376
588
  });
377
589
 
590
+ it("should register exa_code_context tool", () => {
591
+ const api = createMockExtensionAPI();
592
+ exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
593
+
594
+ const tools = api.getTools();
595
+ const codeContextTool = tools.find(
596
+ (t: unknown) => (t as { name: string }).name === "exa_code_context",
597
+ );
598
+ expect(codeContextTool).toBeDefined();
599
+ expect((codeContextTool as { name: string }).name).toBe("exa_code_context");
600
+ expect((codeContextTool as { label: string }).label).toBe("Exa Code Context");
601
+ });
602
+
603
+ it("should have execute function on exa_code_context tool", () => {
604
+ const api = createMockExtensionAPI();
605
+ exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
606
+
607
+ const tools = api.getTools();
608
+ const codeContextTool = tools.find(
609
+ (t: unknown) => (t as { name: string }).name === "exa_code_context",
610
+ ) as { execute: unknown };
611
+ expect(typeof codeContextTool?.execute).toBe("function");
612
+ });
613
+
378
614
  it("should register /exa-status command", () => {
379
615
  const api = createMockExtensionAPI();
380
616
  exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
@@ -415,6 +651,162 @@ describe("Extension Registration", () => {
415
651
  ) as { execute: unknown };
416
652
  expect(typeof exaFetchTool?.execute).toBe("function");
417
653
  });
654
+
655
+ it("should display cost in exa_fetch renderResult", () => {
656
+ const api = createMockExtensionAPI();
657
+ exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
658
+
659
+ const tools = api.getTools();
660
+ const exaFetchTool = tools.find(
661
+ (t: unknown) => (t as { name: string }).name === "exa_fetch",
662
+ ) as { renderResult: Function };
663
+
664
+ const mockTheme = {
665
+ fg: (name: string, text: string) => text,
666
+ };
667
+
668
+ const mockResult = {
669
+ content: [{ type: "text", text: "test" }],
670
+ details: {
671
+ url: "https://example.com",
672
+ title: "Test Page",
673
+ cost: { total: 0.000123 },
674
+ },
675
+ };
676
+
677
+ const rendered = exaFetchTool.renderResult(
678
+ mockResult,
679
+ { expanded: false, isPartial: false },
680
+ mockTheme,
681
+ );
682
+
683
+ expect(rendered.text).toContain("Test Page");
684
+ expect(rendered.text).toContain("$0.000123");
685
+ });
686
+
687
+ it("should display cost without title in exa_fetch renderResult", () => {
688
+ const api = createMockExtensionAPI();
689
+ exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
690
+
691
+ const tools = api.getTools();
692
+ const exaFetchTool = tools.find(
693
+ (t: unknown) => (t as { name: string }).name === "exa_fetch",
694
+ ) as { renderResult: Function };
695
+
696
+ const mockTheme = {
697
+ fg: (name: string, text: string) => text,
698
+ };
699
+
700
+ const mockResult = {
701
+ content: [{ type: "text", text: "test" }],
702
+ details: {
703
+ url: "https://example.com",
704
+ cost: { total: 0.000456 },
705
+ },
706
+ };
707
+
708
+ const rendered = exaFetchTool.renderResult(
709
+ mockResult,
710
+ { expanded: false, isPartial: false },
711
+ mockTheme,
712
+ );
713
+
714
+ expect(rendered.text).toContain("Fetched");
715
+ expect(rendered.text).toContain("$0.000456");
716
+ });
717
+
718
+ it("should display stats and cost in exa_code_context renderResult", () => {
719
+ const api = createMockExtensionAPI();
720
+ exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
721
+
722
+ const tools = api.getTools();
723
+ const codeContextTool = tools.find(
724
+ (t: unknown) => (t as { name: string }).name === "exa_code_context",
725
+ ) as { renderResult: Function };
726
+
727
+ const mockTheme = {
728
+ fg: (name: string, text: string) => text,
729
+ };
730
+
731
+ const mockResult = {
732
+ content: [{ type: "text", text: "test" }],
733
+ details: {
734
+ query: "React hooks",
735
+ resultsCount: 502,
736
+ outputTokens: 4805,
737
+ cost: { total: 1.0 },
738
+ },
739
+ };
740
+
741
+ const rendered = codeContextTool.renderResult(
742
+ mockResult,
743
+ { expanded: false, isPartial: false },
744
+ mockTheme,
745
+ );
746
+
747
+ expect(rendered.text).toContain("502 sources");
748
+ expect(rendered.text).toContain("4805 tokens");
749
+ expect(rendered.text).toContain("$1.000000");
750
+ });
751
+
752
+ it("should display exa_code_context renderResult without cost", () => {
753
+ const api = createMockExtensionAPI();
754
+ exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
755
+
756
+ const tools = api.getTools();
757
+ const codeContextTool = tools.find(
758
+ (t: unknown) => (t as { name: string }).name === "exa_code_context",
759
+ ) as { renderResult: Function };
760
+
761
+ const mockTheme = {
762
+ fg: (name: string, text: string) => text,
763
+ };
764
+
765
+ const mockResult = {
766
+ content: [{ type: "text", text: "test" }],
767
+ details: {
768
+ query: "Express middleware",
769
+ resultsCount: 100,
770
+ outputTokens: 2000,
771
+ },
772
+ };
773
+
774
+ const rendered = codeContextTool.renderResult(
775
+ mockResult,
776
+ { expanded: false, isPartial: false },
777
+ mockTheme,
778
+ );
779
+
780
+ expect(rendered.text).toContain("100 sources");
781
+ expect(rendered.text).toContain("2000 tokens");
782
+ expect(rendered.text).not.toContain("$");
783
+ });
784
+
785
+ it("should handle exa_code_context renderResult without details", () => {
786
+ const api = createMockExtensionAPI();
787
+ exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
788
+
789
+ const tools = api.getTools();
790
+ const codeContextTool = tools.find(
791
+ (t: unknown) => (t as { name: string }).name === "exa_code_context",
792
+ ) as { renderResult: Function };
793
+
794
+ const mockTheme = {
795
+ fg: (name: string, text: string) => text,
796
+ };
797
+
798
+ const mockResult = {
799
+ content: [{ type: "text", text: "Some code context output here" }],
800
+ };
801
+
802
+ const rendered = codeContextTool.renderResult(
803
+ mockResult,
804
+ { expanded: false, isPartial: false },
805
+ mockTheme,
806
+ );
807
+
808
+ expect(rendered.text).toContain("Some code context");
809
+ });
418
810
  });
419
811
 
420
812
  describe("Tool Execute Validation", () => {
@@ -1,22 +1,26 @@
1
1
  /**
2
2
  * exa-search Extension
3
3
  *
4
- * Registers two tools for web search and content fetching using the Exa API:
4
+ * Registers three tools for web search, content fetching, and code context using the Exa API:
5
5
  * - exa_search: Natural language web search
6
6
  * - exa_fetch: Fetch and extract content from URLs
7
+ * - exa_code_context: Search for code snippets and examples from open source repos
7
8
  *
8
9
  * Also registers the /exa-status command to check API key configuration.
9
10
  */
10
11
 
12
+ import { mkdtemp, writeFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
11
15
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
12
- import { Text } from "@mariozechner/pi-tui";
13
- import { Type, type Static } from "@sinclair/typebox";
14
16
  import {
15
- truncateHead,
16
17
  DEFAULT_MAX_BYTES,
17
18
  DEFAULT_MAX_LINES,
18
19
  formatSize,
20
+ truncateHead,
19
21
  } from "@mariozechner/pi-coding-agent";
22
+ import { Text } from "@mariozechner/pi-tui";
23
+ import { Type, type Static } from "@mariozechner/pi-ai";
20
24
  import Exa from "exa-js";
21
25
 
22
26
  // API Key Management
@@ -26,6 +30,15 @@ function getApiKey(): string | undefined {
26
30
  return key && key.length > 0 ? key : undefined;
27
31
  }
28
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
+
29
42
  // Type Definitions
30
43
 
31
44
  type SearchContentType = "text" | "highlights" | "summary" | "none";
@@ -40,6 +53,24 @@ interface SearchDetails {
40
53
  interface FetchDetails {
41
54
  url: string;
42
55
  title?: string;
56
+ cost?: { total: number };
57
+ }
58
+
59
+ interface CodeContextDetails {
60
+ query: string;
61
+ resultsCount: number;
62
+ outputTokens: number;
63
+ cost?: { total: number };
64
+ }
65
+
66
+ interface CodeContextResponse {
67
+ requestId: string;
68
+ query: string;
69
+ response: string;
70
+ resultsCount: number;
71
+ costDollars: string | { total: number };
72
+ searchTime: number;
73
+ outputTokens: number;
43
74
  }
44
75
 
45
76
  // Content Type Mapping
@@ -170,6 +201,31 @@ function formatFetchResult(result: ExaSearchResult, contentType: FetchContentTyp
170
201
  return lines.join("\n");
171
202
  }
172
203
 
204
+ function parseCostDollars(costDollars: string | { total: number }): { total: number } {
205
+ if (typeof costDollars === "string") {
206
+ return JSON.parse(costDollars);
207
+ }
208
+ return costDollars;
209
+ }
210
+
211
+ function formatCodeContextResult(response: CodeContextResponse): string {
212
+ const lines: string[] = [];
213
+
214
+ lines.push(`Query: ${response.query}`);
215
+ lines.push(`Results: ${response.resultsCount} sources`);
216
+ lines.push(`Output tokens: ${response.outputTokens}`);
217
+ lines.push("");
218
+ lines.push("--- Code Context ---");
219
+ lines.push("");
220
+ lines.push(response.response);
221
+ lines.push("");
222
+
223
+ const cost = parseCostDollars(response.costDollars);
224
+ lines.push(`Cost: $${cost.total.toFixed(6)}`);
225
+
226
+ return lines.join("\n");
227
+ }
228
+
173
229
  // Error Creation
174
230
 
175
231
  function createMissingApiKeyError(): Error {
@@ -180,7 +236,16 @@ function createMissingApiKeyError(): Error {
180
236
 
181
237
  // Exports
182
238
 
183
- export { getApiKey, mapSearchContentType, mapFetchContentType, formatSearchResults, formatFetchResult, createMissingApiKeyError };
239
+ export {
240
+ getApiKey,
241
+ mapSearchContentType,
242
+ mapFetchContentType,
243
+ formatSearchResults,
244
+ formatFetchResult,
245
+ formatCodeContextResult,
246
+ parseCostDollars,
247
+ createMissingApiKeyError,
248
+ };
184
249
 
185
250
  export default function exaSearchExtension(pi: ExtensionAPI): void {
186
251
  pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
@@ -248,10 +313,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
248
313
  response = await exa.search(params.query, searchOptions);
249
314
  } catch (error) {
250
315
  const message = error instanceof Error ? error.message : String(error);
251
- return {
252
- content: [{ type: "text", text: `Exa API error: ${message}` }],
253
- details: { query: params.query, numResults: 0 } as SearchDetails,
254
- };
316
+ throw new Error(`Exa API error: ${message}`);
255
317
  }
256
318
 
257
319
  let output = formatSearchResults({
@@ -267,8 +329,10 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
267
329
  let result = truncation.content;
268
330
 
269
331
  if (truncation.truncated) {
332
+ const tempFile = await writeTempFile(output);
270
333
  result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
271
- result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).]`;
334
+ result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
335
+ result += ` Full output saved to: ${tempFile}]`;
272
336
  }
273
337
 
274
338
  return {
@@ -291,7 +355,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
291
355
  return new Text(text, 0, 0);
292
356
  },
293
357
 
294
- renderResult(result, { expanded: _expanded }, theme) {
358
+ renderResult(result, _options, theme) {
295
359
  const details = result.details as SearchDetails | undefined;
296
360
 
297
361
  if (!details) {
@@ -361,16 +425,13 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
361
425
  response = await exa.getContents(params.url, contentsOptions);
362
426
  } catch (error) {
363
427
  const message = error instanceof Error ? error.message : String(error);
364
- return {
365
- content: [{ type: "text", text: `Exa API error: ${message}` }],
366
- details: { url: params.url } as FetchDetails,
367
- };
428
+ throw new Error(`Exa API error: ${message}`);
368
429
  }
369
430
 
370
431
  if (!response.results || response.results.length === 0) {
371
432
  return {
372
433
  content: [{ type: "text", text: "No content found at this URL." }],
373
- details: { url: params.url } as FetchDetails,
434
+ details: { url: params.url, cost: response.costDollars } as FetchDetails,
374
435
  };
375
436
  }
376
437
 
@@ -385,8 +446,10 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
385
446
  let content = truncation.content;
386
447
 
387
448
  if (truncation.truncated) {
449
+ const tempFile = await writeTempFile(output);
388
450
  content += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
389
- content += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).]`;
451
+ content += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
452
+ content += ` Full output saved to: ${tempFile}]`;
390
453
  }
391
454
 
392
455
  return {
@@ -394,6 +457,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
394
457
  details: {
395
458
  url: params.url,
396
459
  title: result.title,
460
+ cost: response.costDollars,
397
461
  } as FetchDetails,
398
462
  };
399
463
  },
@@ -408,7 +472,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
408
472
  return new Text(text, 0, 0);
409
473
  },
410
474
 
411
- renderResult(result, { expanded: _expanded }, theme) {
475
+ renderResult(result, _options, theme) {
412
476
  const details = result.details as FetchDetails | undefined;
413
477
 
414
478
  if (!details) {
@@ -416,11 +480,143 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
416
480
  return new Text(text?.type === "text" ? text.text.slice(0, 60) : "", 0, 0);
417
481
  }
418
482
 
483
+ const cost = details.cost ? ` • $${details.cost.total.toFixed(6)}` : "";
484
+
419
485
  if (details.title) {
420
- return new Text(theme.fg("success", "✓ ") + theme.fg("accent", details.title), 0, 0);
486
+ return new Text(theme.fg("success", `✓ ${details.title}${cost}`), 0, 0);
487
+ }
488
+
489
+ return new Text(theme.fg("success", `✓ Fetched${cost}`), 0, 0);
490
+ },
491
+ });
492
+
493
+ // Register exa_code_context tool
494
+
495
+ const ExaCodeContextParams = Type.Object({
496
+ query: Type.String({
497
+ description: "Search query to find relevant code snippets and examples",
498
+ }),
499
+ tokensNum: Type.Optional(
500
+ Type.Union([
501
+ Type.String({
502
+ description: 'Token limit: "dynamic" for automatic sizing',
503
+ }),
504
+ Type.Number({
505
+ description: "Token limit: 50-100000 (5000 is a good default)",
506
+ }),
507
+ ]),
508
+ ),
509
+ });
510
+
511
+ pi.registerTool({
512
+ name: "exa_code_context",
513
+ label: "Exa Code Context",
514
+ description:
515
+ "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.",
516
+ parameters: ExaCodeContextParams,
517
+
518
+ async execute(
519
+ _toolCallId: string,
520
+ params: Static<typeof ExaCodeContextParams>,
521
+ _signal: AbortSignal | undefined,
522
+ _onUpdate: unknown,
523
+ _ctx: ExtensionContext,
524
+ ) {
525
+ const apiKey = getApiKey();
526
+ if (!apiKey) {
527
+ throw createMissingApiKeyError();
421
528
  }
422
529
 
423
- return new Text(theme.fg("muted", "Done"), 0, 0);
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
+ }
538
+
539
+ let response;
540
+ try {
541
+ const httpResponse = await fetch("https://api.exa.ai/context", {
542
+ method: "POST",
543
+ headers: {
544
+ "Content-Type": "application/json",
545
+ "x-api-key": apiKey,
546
+ },
547
+ body: JSON.stringify({
548
+ query: params.query,
549
+ tokensNum,
550
+ }),
551
+ });
552
+
553
+ if (!httpResponse.ok) {
554
+ const errorText = await httpResponse.text();
555
+ throw new Error(`HTTP ${httpResponse.status}: ${errorText}`);
556
+ }
557
+
558
+ response = (await httpResponse.json()) as CodeContextResponse;
559
+ } catch (error) {
560
+ const message = error instanceof Error ? error.message : String(error);
561
+ throw new Error(`Exa Context API error: ${message}`);
562
+ }
563
+
564
+ let output = formatCodeContextResult(response);
565
+
566
+ const truncation = truncateHead(output, {
567
+ maxLines: DEFAULT_MAX_LINES,
568
+ maxBytes: DEFAULT_MAX_BYTES,
569
+ });
570
+
571
+ let result = truncation.content;
572
+
573
+ if (truncation.truncated) {
574
+ const tempFile = await writeTempFile(output);
575
+ result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
576
+ result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
577
+ result += ` Full output saved to: ${tempFile}]`;
578
+ }
579
+
580
+ const cost = parseCostDollars(response.costDollars);
581
+
582
+ return {
583
+ content: [{ type: "text", text: result }],
584
+ details: {
585
+ query: params.query,
586
+ resultsCount: response.resultsCount,
587
+ outputTokens: response.outputTokens,
588
+ cost,
589
+ } as CodeContextDetails,
590
+ };
591
+ },
592
+
593
+ renderCall(args, theme) {
594
+ const preview = args.query.length > 50 ? args.query.slice(0, 50) + "..." : args.query;
595
+ const desc = `${args.tokensNum ?? "dynamic"} tokens`;
596
+ const text =
597
+ theme.fg("toolTitle", theme.bold("exa_code_context ")) +
598
+ theme.fg("muted", preview) +
599
+ theme.fg("dim", ` ${desc}`);
600
+ return new Text(text, 0, 0);
601
+ },
602
+
603
+ renderResult(result, _options, theme) {
604
+ const details = result.details as CodeContextDetails | undefined;
605
+
606
+ if (!details) {
607
+ const text = result.content[0];
608
+ return new Text(text?.type === "text" ? text.text.slice(0, 60) : "", 0, 0);
609
+ }
610
+
611
+ const cost = details.cost ? ` • $${details.cost.total.toFixed(6)}` : "";
612
+ return new Text(
613
+ theme.fg(
614
+ "success",
615
+ `✓ ${details.resultsCount} sources • ${details.outputTokens} tokens${cost}`,
616
+ ),
617
+ 0,
618
+ 0,
619
+ );
424
620
  },
425
621
  });
426
622
 
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@jarcelao/pi-exa-api",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Web search and content fetching for pi via the Exa API",
5
- "author": "Jericho Renniel Arcelao <jerichoarcelao@gmail.com>",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "homepage": "https://github.com/jarcelao/pi-exa-api",
6
9
  "license": "MIT",
10
+ "author": "Jericho Renniel Arcelao <jerichoarcelao@gmail.com>",
7
11
  "repository": {
8
12
  "type": "git",
9
13
  "url": "git+https://github.com/jarcelao/pi-exa-api.git"
10
14
  },
11
- "homepage": "https://github.com/jarcelao/pi-exa-api",
12
- "keywords": [
13
- "pi-package"
14
- ],
15
15
  "main": "./extensions/exa-search.ts",
16
16
  "scripts": {
17
17
  "test": "vitest",
@@ -22,7 +22,7 @@
22
22
  "format:check": "oxfmt --check"
23
23
  },
24
24
  "dependencies": {
25
- "exa-js": "^1.1.1"
25
+ "exa-js": "^2.10.1"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^25.5.0",
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
  }