@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 +0 -1
- package/README.md +18 -2
- package/exa-search.test.ts +395 -3
- package/extensions/exa-search.ts +216 -20
- package/package.json +7 -7
- package/tsconfig.json +2 -1
package/LICENSE.md
CHANGED
package/README.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# pi-exa-api
|
|
2
2
|
|
|
3
|
-
Web search
|
|
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
|
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,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).
|
|
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", () => {
|
package/extensions/exa-search.ts
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
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
|
*/
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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",
|
|
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
|
-
|
|
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
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Web search and content fetching for pi via the Exa API",
|
|
5
|
-
"
|
|
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": "^
|
|
25
|
+
"exa-js": "^2.10.1"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^25.5.0",
|
package/tsconfig.json
CHANGED