@jarcelao/pi-exa-api 0.1.1 → 0.2.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/LICENSE.md +0 -1
- package/README.md +1 -2
- package/exa-search.test.ts +342 -1
- package/extensions/exa-search.ts +195 -28
- package/package.json +9 -2
package/LICENSE.md
CHANGED
package/README.md
CHANGED
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Web search and content fetching for [pi](https://pi.dev) via the [Exa API](https://exa.ai/).
|
|
4
4
|
|
|
5
|
-
|
|
6
5
|
## Installation
|
|
7
6
|
|
|
8
7
|
Install as a pi package:
|
|
9
8
|
|
|
10
9
|
```bash
|
|
11
|
-
pi install npm:@jarcelao/pi-exa-api
|
|
10
|
+
pi install npm:@jarcelao/pi-exa-api
|
|
12
11
|
```
|
|
13
12
|
|
|
14
13
|
## Configuration
|
package/exa-search.test.ts
CHANGED
|
@@ -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,6 +286,16 @@ 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 = {
|
|
289
301
|
url: "https://example.com",
|
|
@@ -329,6 +341,150 @@ describe("Error Handling", () => {
|
|
|
329
341
|
});
|
|
330
342
|
});
|
|
331
343
|
|
|
344
|
+
describe("parseCostDollars", () => {
|
|
345
|
+
it("should parse JSON string costDollars", () => {
|
|
346
|
+
const costString = '{"total":0.007,"search":{"neural":0.007}}';
|
|
347
|
+
const parsed = parseCostDollars(costString);
|
|
348
|
+
expect(parsed).toEqual({ total: 0.007, search: { neural: 0.007 } });
|
|
349
|
+
expect(parsed.total).toBe(0.007);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("should pass through object costDollars unchanged", () => {
|
|
353
|
+
const costObject = { total: 1.5 };
|
|
354
|
+
const parsed = parseCostDollars(costObject);
|
|
355
|
+
expect(parsed).toEqual({ total: 1.5 });
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("should handle various cost string formats", () => {
|
|
359
|
+
expect(parseCostDollars('{"total":0}').total).toBe(0);
|
|
360
|
+
expect(parseCostDollars('{"total":123.456}').total).toBe(123.456);
|
|
361
|
+
expect(parseCostDollars({ total: 99.9 }).total).toBe(99.9);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe("Code Context Result Formatting", () => {
|
|
366
|
+
it("should format code context response with string costDollars", () => {
|
|
367
|
+
const response = {
|
|
368
|
+
requestId: "req_12345",
|
|
369
|
+
query: "how to use React hooks for state management",
|
|
370
|
+
response: "## useState Example\n\n```javascript\nconst [count, setCount] = useState(0);\n```",
|
|
371
|
+
resultsCount: 502,
|
|
372
|
+
costDollars: '{"total":0.007,"search":{"neural":0.007}}',
|
|
373
|
+
searchTime: 1.234,
|
|
374
|
+
outputTokens: 4805,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const formatted = formatCodeContextResult(response);
|
|
378
|
+
expect(formatted).toContain("Query: how to use React hooks for state management");
|
|
379
|
+
expect(formatted).toContain("Results: 502 sources");
|
|
380
|
+
expect(formatted).toContain("Output tokens: 4805");
|
|
381
|
+
expect(formatted).toContain("--- Code Context ---");
|
|
382
|
+
expect(formatted).toContain("## useState Example");
|
|
383
|
+
expect(formatted).toContain("const [count, setCount] = useState(0);");
|
|
384
|
+
expect(formatted).toContain("Cost: $0.007000");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("should format code context response with object costDollars", () => {
|
|
388
|
+
const response = {
|
|
389
|
+
requestId: "req_67890",
|
|
390
|
+
query: "test query",
|
|
391
|
+
response: "Some code examples...",
|
|
392
|
+
resultsCount: 10,
|
|
393
|
+
costDollars: { total: 1.5 },
|
|
394
|
+
searchTime: 0.5,
|
|
395
|
+
outputTokens: 1000,
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const formatted = formatCodeContextResult(response);
|
|
399
|
+
expect(formatted).toContain("Cost: $1.500000");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("should include query in formatted output", () => {
|
|
403
|
+
const response = {
|
|
404
|
+
requestId: "req_abc",
|
|
405
|
+
query: "Express.js middleware authentication",
|
|
406
|
+
response: "Some code examples...",
|
|
407
|
+
resultsCount: 100,
|
|
408
|
+
costDollars: '{"total":0.5}',
|
|
409
|
+
searchTime: 0.5,
|
|
410
|
+
outputTokens: 2000,
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const formatted = formatCodeContextResult(response);
|
|
414
|
+
expect(formatted).toContain("Query: Express.js middleware authentication");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("should format cost correctly with decimals", () => {
|
|
418
|
+
const response = {
|
|
419
|
+
requestId: "req_xyz",
|
|
420
|
+
query: "test query",
|
|
421
|
+
response: "response content",
|
|
422
|
+
resultsCount: 10,
|
|
423
|
+
costDollars: '{"total":0.123456}',
|
|
424
|
+
searchTime: 0.1,
|
|
425
|
+
outputTokens: 500,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const formatted = formatCodeContextResult(response);
|
|
429
|
+
expect(formatted).toContain("Cost: $0.123456");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("should display results count and output tokens", () => {
|
|
433
|
+
const response = {
|
|
434
|
+
requestId: "req_test",
|
|
435
|
+
query: "pandas dataframe operations",
|
|
436
|
+
response: "Code examples here",
|
|
437
|
+
resultsCount: 150,
|
|
438
|
+
costDollars: '{"total":0.75}',
|
|
439
|
+
searchTime: 0.8,
|
|
440
|
+
outputTokens: 3500,
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const formatted = formatCodeContextResult(response);
|
|
444
|
+
expect(formatted).toContain("Results: 150 sources");
|
|
445
|
+
expect(formatted).toContain("Output tokens: 3500");
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe("Code Context Parameter Validation", () => {
|
|
450
|
+
it("should accept valid params with query only", () => {
|
|
451
|
+
const params = { query: "React hooks examples" };
|
|
452
|
+
expect(params.query).toBeTruthy();
|
|
453
|
+
expect(params.query.trim()).not.toBe("");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("should accept valid params with dynamic tokens", () => {
|
|
457
|
+
const params = {
|
|
458
|
+
query: "Express middleware",
|
|
459
|
+
tokensNum: "dynamic" as const,
|
|
460
|
+
};
|
|
461
|
+
expect(typeof params.query).toBe("string");
|
|
462
|
+
expect(params.tokensNum).toBe("dynamic");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("should accept valid params with numeric tokens", () => {
|
|
466
|
+
const params = {
|
|
467
|
+
query: "Next.js configuration",
|
|
468
|
+
tokensNum: 5000,
|
|
469
|
+
};
|
|
470
|
+
expect(params.tokensNum).toBeGreaterThanOrEqual(50);
|
|
471
|
+
expect(params.tokensNum).toBeLessThanOrEqual(100000);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("should accept token counts in valid range", () => {
|
|
475
|
+
const validTokenCounts = [50, 1000, 5000, 10000, 50000, 100000];
|
|
476
|
+
for (const count of validTokenCounts) {
|
|
477
|
+
expect(count).toBeGreaterThanOrEqual(50);
|
|
478
|
+
expect(count).toBeLessThanOrEqual(100000);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("should accept query strings up to 2000 characters", () => {
|
|
483
|
+
const longQuery = "a".repeat(2000);
|
|
484
|
+
expect(longQuery.length).toBe(2000);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
332
488
|
describe("Extension Registration", () => {
|
|
333
489
|
function createMockExtensionAPI() {
|
|
334
490
|
const tools: unknown[] = [];
|
|
@@ -356,7 +512,7 @@ describe("Extension Registration", () => {
|
|
|
356
512
|
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
357
513
|
|
|
358
514
|
const tools = api.getTools();
|
|
359
|
-
expect(tools.length).
|
|
515
|
+
expect(tools.length).toBe(3);
|
|
360
516
|
|
|
361
517
|
const exaSearchTool = tools.find((t: unknown) => (t as { name: string }).name === "exa_search");
|
|
362
518
|
expect(exaSearchTool).toBeDefined();
|
|
@@ -375,6 +531,30 @@ describe("Extension Registration", () => {
|
|
|
375
531
|
expect((exaFetchTool as { label: string }).label).toBe("Exa Fetch");
|
|
376
532
|
});
|
|
377
533
|
|
|
534
|
+
it("should register exa_code_context tool", () => {
|
|
535
|
+
const api = createMockExtensionAPI();
|
|
536
|
+
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
537
|
+
|
|
538
|
+
const tools = api.getTools();
|
|
539
|
+
const codeContextTool = tools.find(
|
|
540
|
+
(t: unknown) => (t as { name: string }).name === "exa_code_context",
|
|
541
|
+
);
|
|
542
|
+
expect(codeContextTool).toBeDefined();
|
|
543
|
+
expect((codeContextTool as { name: string }).name).toBe("exa_code_context");
|
|
544
|
+
expect((codeContextTool as { label: string }).label).toBe("Exa Code Context");
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("should have execute function on exa_code_context tool", () => {
|
|
548
|
+
const api = createMockExtensionAPI();
|
|
549
|
+
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
550
|
+
|
|
551
|
+
const tools = api.getTools();
|
|
552
|
+
const codeContextTool = tools.find(
|
|
553
|
+
(t: unknown) => (t as { name: string }).name === "exa_code_context",
|
|
554
|
+
) as { execute: unknown };
|
|
555
|
+
expect(typeof codeContextTool?.execute).toBe("function");
|
|
556
|
+
});
|
|
557
|
+
|
|
378
558
|
it("should register /exa-status command", () => {
|
|
379
559
|
const api = createMockExtensionAPI();
|
|
380
560
|
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
@@ -415,6 +595,167 @@ describe("Extension Registration", () => {
|
|
|
415
595
|
) as { execute: unknown };
|
|
416
596
|
expect(typeof exaFetchTool?.execute).toBe("function");
|
|
417
597
|
});
|
|
598
|
+
|
|
599
|
+
it("should display cost in exa_fetch renderResult", () => {
|
|
600
|
+
const api = createMockExtensionAPI();
|
|
601
|
+
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
602
|
+
|
|
603
|
+
const tools = api.getTools();
|
|
604
|
+
const exaFetchTool = tools.find(
|
|
605
|
+
(t: unknown) => (t as { name: string }).name === "exa_fetch",
|
|
606
|
+
) as { renderResult: Function };
|
|
607
|
+
|
|
608
|
+
const mockTheme = {
|
|
609
|
+
fg: (name: string, text: string) => text,
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const mockResult = {
|
|
613
|
+
content: [{ type: "text", text: "test" }],
|
|
614
|
+
details: {
|
|
615
|
+
url: "https://example.com",
|
|
616
|
+
title: "Test Page",
|
|
617
|
+
cost: { total: 0.000123 },
|
|
618
|
+
},
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const rendered = exaFetchTool.renderResult(
|
|
622
|
+
mockResult,
|
|
623
|
+
{ expanded: false, isPartial: false },
|
|
624
|
+
mockTheme,
|
|
625
|
+
{},
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
expect(rendered.text).toContain("Test Page");
|
|
629
|
+
expect(rendered.text).toContain("$0.000123");
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("should display cost without title in exa_fetch renderResult", () => {
|
|
633
|
+
const api = createMockExtensionAPI();
|
|
634
|
+
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
635
|
+
|
|
636
|
+
const tools = api.getTools();
|
|
637
|
+
const exaFetchTool = tools.find(
|
|
638
|
+
(t: unknown) => (t as { name: string }).name === "exa_fetch",
|
|
639
|
+
) as { renderResult: Function };
|
|
640
|
+
|
|
641
|
+
const mockTheme = {
|
|
642
|
+
fg: (name: string, text: string) => text,
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
const mockResult = {
|
|
646
|
+
content: [{ type: "text", text: "test" }],
|
|
647
|
+
details: {
|
|
648
|
+
url: "https://example.com",
|
|
649
|
+
cost: { total: 0.000456 },
|
|
650
|
+
},
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const rendered = exaFetchTool.renderResult(
|
|
654
|
+
mockResult,
|
|
655
|
+
{ expanded: false, isPartial: false },
|
|
656
|
+
mockTheme,
|
|
657
|
+
{},
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
expect(rendered.text).toContain("Fetched");
|
|
661
|
+
expect(rendered.text).toContain("$0.000456");
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("should display stats and cost in exa_code_context renderResult", () => {
|
|
665
|
+
const api = createMockExtensionAPI();
|
|
666
|
+
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
667
|
+
|
|
668
|
+
const tools = api.getTools();
|
|
669
|
+
const codeContextTool = tools.find(
|
|
670
|
+
(t: unknown) => (t as { name: string }).name === "exa_code_context",
|
|
671
|
+
) as { renderResult: Function };
|
|
672
|
+
|
|
673
|
+
const mockTheme = {
|
|
674
|
+
fg: (name: string, text: string) => text,
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
const mockResult = {
|
|
678
|
+
content: [{ type: "text", text: "test" }],
|
|
679
|
+
details: {
|
|
680
|
+
query: "React hooks",
|
|
681
|
+
resultsCount: 502,
|
|
682
|
+
outputTokens: 4805,
|
|
683
|
+
cost: { total: 1.0 },
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
const rendered = codeContextTool.renderResult(
|
|
688
|
+
mockResult,
|
|
689
|
+
{ expanded: false, isPartial: false },
|
|
690
|
+
mockTheme,
|
|
691
|
+
{},
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
expect(rendered.text).toContain("502 sources");
|
|
695
|
+
expect(rendered.text).toContain("4805 tokens");
|
|
696
|
+
expect(rendered.text).toContain("$1.000000");
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it("should display exa_code_context renderResult without cost", () => {
|
|
700
|
+
const api = createMockExtensionAPI();
|
|
701
|
+
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
702
|
+
|
|
703
|
+
const tools = api.getTools();
|
|
704
|
+
const codeContextTool = tools.find(
|
|
705
|
+
(t: unknown) => (t as { name: string }).name === "exa_code_context",
|
|
706
|
+
) as { renderResult: Function };
|
|
707
|
+
|
|
708
|
+
const mockTheme = {
|
|
709
|
+
fg: (name: string, text: string) => text,
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
const mockResult = {
|
|
713
|
+
content: [{ type: "text", text: "test" }],
|
|
714
|
+
details: {
|
|
715
|
+
query: "Express middleware",
|
|
716
|
+
resultsCount: 100,
|
|
717
|
+
outputTokens: 2000,
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const rendered = codeContextTool.renderResult(
|
|
722
|
+
mockResult,
|
|
723
|
+
{ expanded: false, isPartial: false },
|
|
724
|
+
mockTheme,
|
|
725
|
+
{},
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
expect(rendered.text).toContain("100 sources");
|
|
729
|
+
expect(rendered.text).toContain("2000 tokens");
|
|
730
|
+
expect(rendered.text).not.toContain("$");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("should handle exa_code_context renderResult without details", () => {
|
|
734
|
+
const api = createMockExtensionAPI();
|
|
735
|
+
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
736
|
+
|
|
737
|
+
const tools = api.getTools();
|
|
738
|
+
const codeContextTool = tools.find(
|
|
739
|
+
(t: unknown) => (t as { name: string }).name === "exa_code_context",
|
|
740
|
+
) as { renderResult: Function };
|
|
741
|
+
|
|
742
|
+
const mockTheme = {
|
|
743
|
+
fg: (name: string, text: string) => text,
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
const mockResult = {
|
|
747
|
+
content: [{ type: "text", text: "Some code context output here" }],
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
const rendered = codeContextTool.renderResult(
|
|
751
|
+
mockResult,
|
|
752
|
+
{ expanded: false, isPartial: false },
|
|
753
|
+
mockTheme,
|
|
754
|
+
{},
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
expect(rendered.text).toContain("Some code context");
|
|
758
|
+
});
|
|
418
759
|
});
|
|
419
760
|
|
|
420
761
|
describe("Tool Execute Validation", () => {
|
package/extensions/exa-search.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* exa-search Extension
|
|
3
3
|
*
|
|
4
|
-
* Registers
|
|
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
|
*/
|
|
@@ -11,11 +12,13 @@
|
|
|
11
12
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
12
13
|
import { Text } from "@mariozechner/pi-tui";
|
|
13
14
|
import { Type, type Static } from "@sinclair/typebox";
|
|
15
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
14
16
|
import {
|
|
15
17
|
truncateHead,
|
|
16
18
|
DEFAULT_MAX_BYTES,
|
|
17
19
|
DEFAULT_MAX_LINES,
|
|
18
20
|
formatSize,
|
|
21
|
+
writeTempFile,
|
|
19
22
|
} from "@mariozechner/pi-coding-agent";
|
|
20
23
|
import Exa from "exa-js";
|
|
21
24
|
|
|
@@ -40,6 +43,24 @@ interface SearchDetails {
|
|
|
40
43
|
interface FetchDetails {
|
|
41
44
|
url: string;
|
|
42
45
|
title?: string;
|
|
46
|
+
cost?: { total: number };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface CodeContextDetails {
|
|
50
|
+
query: string;
|
|
51
|
+
resultsCount: number;
|
|
52
|
+
outputTokens: number;
|
|
53
|
+
cost?: { total: number };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface CodeContextResponse {
|
|
57
|
+
requestId: string;
|
|
58
|
+
query: string;
|
|
59
|
+
response: string;
|
|
60
|
+
resultsCount: number;
|
|
61
|
+
costDollars: string | { total: number };
|
|
62
|
+
searchTime: number;
|
|
63
|
+
outputTokens: number;
|
|
43
64
|
}
|
|
44
65
|
|
|
45
66
|
// Content Type Mapping
|
|
@@ -170,6 +191,31 @@ function formatFetchResult(result: ExaSearchResult, contentType: FetchContentTyp
|
|
|
170
191
|
return lines.join("\n");
|
|
171
192
|
}
|
|
172
193
|
|
|
194
|
+
function parseCostDollars(costDollars: string | { total: number }): { total: number } {
|
|
195
|
+
if (typeof costDollars === "string") {
|
|
196
|
+
return JSON.parse(costDollars);
|
|
197
|
+
}
|
|
198
|
+
return costDollars;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function formatCodeContextResult(response: CodeContextResponse): string {
|
|
202
|
+
const lines: string[] = [];
|
|
203
|
+
|
|
204
|
+
lines.push(`Query: ${response.query}`);
|
|
205
|
+
lines.push(`Results: ${response.resultsCount} sources`);
|
|
206
|
+
lines.push(`Output tokens: ${response.outputTokens}`);
|
|
207
|
+
lines.push("");
|
|
208
|
+
lines.push("--- Code Context ---");
|
|
209
|
+
lines.push("");
|
|
210
|
+
lines.push(response.response);
|
|
211
|
+
lines.push("");
|
|
212
|
+
|
|
213
|
+
const cost = parseCostDollars(response.costDollars);
|
|
214
|
+
lines.push(`Cost: $${cost.total.toFixed(6)}`);
|
|
215
|
+
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
}
|
|
218
|
+
|
|
173
219
|
// Error Creation
|
|
174
220
|
|
|
175
221
|
function createMissingApiKeyError(): Error {
|
|
@@ -180,7 +226,16 @@ function createMissingApiKeyError(): Error {
|
|
|
180
226
|
|
|
181
227
|
// Exports
|
|
182
228
|
|
|
183
|
-
export {
|
|
229
|
+
export {
|
|
230
|
+
getApiKey,
|
|
231
|
+
mapSearchContentType,
|
|
232
|
+
mapFetchContentType,
|
|
233
|
+
formatSearchResults,
|
|
234
|
+
formatFetchResult,
|
|
235
|
+
formatCodeContextResult,
|
|
236
|
+
parseCostDollars,
|
|
237
|
+
createMissingApiKeyError,
|
|
238
|
+
};
|
|
184
239
|
|
|
185
240
|
export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
186
241
|
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
@@ -196,14 +251,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
196
251
|
query: Type.String({
|
|
197
252
|
description: "Natural language search query",
|
|
198
253
|
}),
|
|
199
|
-
contentType: Type.Optional(
|
|
200
|
-
Type.Union([
|
|
201
|
-
Type.Literal("text"),
|
|
202
|
-
Type.Literal("highlights"),
|
|
203
|
-
Type.Literal("summary"),
|
|
204
|
-
Type.Literal("none"),
|
|
205
|
-
]),
|
|
206
|
-
),
|
|
254
|
+
contentType: Type.Optional(StringEnum(["text", "highlights", "summary", "none"] as const)),
|
|
207
255
|
numResults: Type.Optional(
|
|
208
256
|
Type.Number({
|
|
209
257
|
description: "Number of results (1-100)",
|
|
@@ -248,10 +296,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
248
296
|
response = await exa.search(params.query, searchOptions);
|
|
249
297
|
} catch (error) {
|
|
250
298
|
const message = error instanceof Error ? error.message : String(error);
|
|
251
|
-
|
|
252
|
-
content: [{ type: "text", text: `Exa API error: ${message}` }],
|
|
253
|
-
details: { query: params.query, numResults: 0 } as SearchDetails,
|
|
254
|
-
};
|
|
299
|
+
throw new Error(`Exa API error: ${message}`);
|
|
255
300
|
}
|
|
256
301
|
|
|
257
302
|
let output = formatSearchResults({
|
|
@@ -267,8 +312,10 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
267
312
|
let result = truncation.content;
|
|
268
313
|
|
|
269
314
|
if (truncation.truncated) {
|
|
315
|
+
const tempFile = writeTempFile(output);
|
|
270
316
|
result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
271
|
-
result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})
|
|
317
|
+
result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
318
|
+
result += ` Full output saved to: ${tempFile}]`;
|
|
272
319
|
}
|
|
273
320
|
|
|
274
321
|
return {
|
|
@@ -291,7 +338,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
291
338
|
return new Text(text, 0, 0);
|
|
292
339
|
},
|
|
293
340
|
|
|
294
|
-
renderResult(result, { expanded: _expanded }, theme) {
|
|
341
|
+
renderResult(result, { expanded: _expanded, isPartial: _isPartial }, theme, _context) {
|
|
295
342
|
const details = result.details as SearchDetails | undefined;
|
|
296
343
|
|
|
297
344
|
if (!details) {
|
|
@@ -310,9 +357,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
310
357
|
url: Type.String({
|
|
311
358
|
description: "URL to fetch content from",
|
|
312
359
|
}),
|
|
313
|
-
contentType: Type.Optional(
|
|
314
|
-
Type.Union([Type.Literal("text"), Type.Literal("highlights"), Type.Literal("summary")]),
|
|
315
|
-
),
|
|
360
|
+
contentType: Type.Optional(StringEnum(["text", "highlights", "summary"] as const)),
|
|
316
361
|
maxCharacters: Type.Optional(
|
|
317
362
|
Type.Number({
|
|
318
363
|
description: "Maximum characters to return",
|
|
@@ -361,16 +406,13 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
361
406
|
response = await exa.getContents(params.url, contentsOptions);
|
|
362
407
|
} catch (error) {
|
|
363
408
|
const message = error instanceof Error ? error.message : String(error);
|
|
364
|
-
|
|
365
|
-
content: [{ type: "text", text: `Exa API error: ${message}` }],
|
|
366
|
-
details: { url: params.url } as FetchDetails,
|
|
367
|
-
};
|
|
409
|
+
throw new Error(`Exa API error: ${message}`);
|
|
368
410
|
}
|
|
369
411
|
|
|
370
412
|
if (!response.results || response.results.length === 0) {
|
|
371
413
|
return {
|
|
372
414
|
content: [{ type: "text", text: "No content found at this URL." }],
|
|
373
|
-
details: { url: params.url } as FetchDetails,
|
|
415
|
+
details: { url: params.url, cost: response.costDollars } as FetchDetails,
|
|
374
416
|
};
|
|
375
417
|
}
|
|
376
418
|
|
|
@@ -385,8 +427,10 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
385
427
|
let content = truncation.content;
|
|
386
428
|
|
|
387
429
|
if (truncation.truncated) {
|
|
430
|
+
const tempFile = writeTempFile(output);
|
|
388
431
|
content += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
389
|
-
content += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})
|
|
432
|
+
content += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
433
|
+
content += ` Full output saved to: ${tempFile}]`;
|
|
390
434
|
}
|
|
391
435
|
|
|
392
436
|
return {
|
|
@@ -394,6 +438,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
394
438
|
details: {
|
|
395
439
|
url: params.url,
|
|
396
440
|
title: result.title,
|
|
441
|
+
cost: response.costDollars,
|
|
397
442
|
} as FetchDetails,
|
|
398
443
|
};
|
|
399
444
|
},
|
|
@@ -408,7 +453,7 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
408
453
|
return new Text(text, 0, 0);
|
|
409
454
|
},
|
|
410
455
|
|
|
411
|
-
renderResult(result, { expanded: _expanded }, theme) {
|
|
456
|
+
renderResult(result, { expanded: _expanded, isPartial: _isPartial }, theme, _context) {
|
|
412
457
|
const details = result.details as FetchDetails | undefined;
|
|
413
458
|
|
|
414
459
|
if (!details) {
|
|
@@ -416,11 +461,133 @@ export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
|
416
461
|
return new Text(text?.type === "text" ? text.text.slice(0, 60) : "", 0, 0);
|
|
417
462
|
}
|
|
418
463
|
|
|
464
|
+
const cost = details.cost ? ` • $${details.cost.total.toFixed(6)}` : "";
|
|
465
|
+
|
|
419
466
|
if (details.title) {
|
|
420
|
-
return new Text(theme.fg("success",
|
|
467
|
+
return new Text(theme.fg("success", `✓ ${details.title}${cost}`), 0, 0);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return new Text(theme.fg("success", `✓ Fetched${cost}`), 0, 0);
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// Register exa_code_context tool
|
|
475
|
+
|
|
476
|
+
const ExaCodeContextParams = Type.Object({
|
|
477
|
+
query: Type.String({
|
|
478
|
+
description: "Search query to find relevant code snippets and examples",
|
|
479
|
+
}),
|
|
480
|
+
tokensNum: Type.Optional(
|
|
481
|
+
Type.Union([
|
|
482
|
+
Type.String({
|
|
483
|
+
description: 'Token limit: "dynamic" for automatic sizing',
|
|
484
|
+
}),
|
|
485
|
+
Type.Number({
|
|
486
|
+
description: "Token limit: 50-100000 (5000 is a good default)",
|
|
487
|
+
}),
|
|
488
|
+
]),
|
|
489
|
+
),
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
pi.registerTool({
|
|
493
|
+
name: "exa_code_context",
|
|
494
|
+
label: "Exa Code Context",
|
|
495
|
+
description:
|
|
496
|
+
"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
|
+
parameters: ExaCodeContextParams,
|
|
498
|
+
|
|
499
|
+
async execute(
|
|
500
|
+
_toolCallId: string,
|
|
501
|
+
params: Static<typeof ExaCodeContextParams>,
|
|
502
|
+
_signal: AbortSignal | undefined,
|
|
503
|
+
_onUpdate: unknown,
|
|
504
|
+
_ctx: ExtensionContext,
|
|
505
|
+
) {
|
|
506
|
+
const apiKey = getApiKey();
|
|
507
|
+
if (!apiKey) {
|
|
508
|
+
throw createMissingApiKeyError();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const tokensNum = params.tokensNum ?? "dynamic";
|
|
512
|
+
|
|
513
|
+
let response;
|
|
514
|
+
try {
|
|
515
|
+
const httpResponse = await fetch("https://api.exa.ai/context", {
|
|
516
|
+
method: "POST",
|
|
517
|
+
headers: {
|
|
518
|
+
"Content-Type": "application/json",
|
|
519
|
+
"x-api-key": apiKey,
|
|
520
|
+
},
|
|
521
|
+
body: JSON.stringify({
|
|
522
|
+
query: params.query,
|
|
523
|
+
tokensNum,
|
|
524
|
+
}),
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
if (!httpResponse.ok) {
|
|
528
|
+
const errorText = await httpResponse.text();
|
|
529
|
+
throw new Error(`HTTP ${httpResponse.status}: ${errorText}`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
response = (await httpResponse.json()) as CodeContextResponse;
|
|
533
|
+
} catch (error) {
|
|
534
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
535
|
+
throw new Error(`Exa Context API error: ${message}`);
|
|
421
536
|
}
|
|
422
537
|
|
|
423
|
-
|
|
538
|
+
let output = formatCodeContextResult(response);
|
|
539
|
+
|
|
540
|
+
const truncation = truncateHead(output, {
|
|
541
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
542
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
let result = truncation.content;
|
|
546
|
+
|
|
547
|
+
if (truncation.truncated) {
|
|
548
|
+
const tempFile = writeTempFile(output);
|
|
549
|
+
result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
550
|
+
result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
551
|
+
result += ` Full output saved to: ${tempFile}]`;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const cost = parseCostDollars(response.costDollars);
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
content: [{ type: "text", text: result }],
|
|
558
|
+
details: {
|
|
559
|
+
query: params.query,
|
|
560
|
+
resultsCount: response.resultsCount,
|
|
561
|
+
outputTokens: response.outputTokens,
|
|
562
|
+
cost,
|
|
563
|
+
} as CodeContextDetails,
|
|
564
|
+
};
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
renderCall(args, theme) {
|
|
568
|
+
const preview = args.query.length > 50 ? args.query.slice(0, 50) + "..." : args.query;
|
|
569
|
+
const desc = `${args.tokensNum ?? "dynamic"} tokens`;
|
|
570
|
+
const text =
|
|
571
|
+
theme.fg("toolTitle", theme.bold("exa_code_context ")) +
|
|
572
|
+
theme.fg("muted", preview) +
|
|
573
|
+
theme.fg("dim", ` ${desc}`);
|
|
574
|
+
return new Text(text, 0, 0);
|
|
575
|
+
},
|
|
576
|
+
|
|
577
|
+
renderResult(result, { expanded: _expanded, isPartial: _isPartial }, theme, _context) {
|
|
578
|
+
const details = result.details as CodeContextDetails | undefined;
|
|
579
|
+
|
|
580
|
+
if (!details) {
|
|
581
|
+
const text = result.content[0];
|
|
582
|
+
return new Text(text?.type === "text" ? text.text.slice(0, 60) : "", 0, 0);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const cost = details.cost ? ` • $${details.cost.total.toFixed(6)}` : "";
|
|
586
|
+
return new Text(
|
|
587
|
+
theme.fg("success", `✓ ${details.resultsCount} sources • ${details.outputTokens} tokens${cost}`),
|
|
588
|
+
0,
|
|
589
|
+
0,
|
|
590
|
+
);
|
|
424
591
|
},
|
|
425
592
|
});
|
|
426
593
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarcelao/pi-exa-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Web search and content fetching for pi via the Exa API",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
7
7
|
],
|
|
8
|
+
"homepage": "https://github.com/jarcelao/pi-exa-api",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "Jericho Renniel Arcelao <jerichoarcelao@gmail.com>",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/jarcelao/pi-exa-api.git"
|
|
14
|
+
},
|
|
8
15
|
"main": "./extensions/exa-search.ts",
|
|
9
16
|
"scripts": {
|
|
10
17
|
"test": "vitest",
|
|
@@ -15,7 +22,7 @@
|
|
|
15
22
|
"format:check": "oxfmt --check"
|
|
16
23
|
},
|
|
17
24
|
"dependencies": {
|
|
18
|
-
"exa-js": "^
|
|
25
|
+
"exa-js": "^2.10.1"
|
|
19
26
|
},
|
|
20
27
|
"devDependencies": {
|
|
21
28
|
"@types/node": "^25.5.0",
|