@jarcelao/pi-exa-api 0.1.2 → 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 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
@@ -2,7 +2,6 @@
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:
@@ -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).toBeGreaterThanOrEqual(2);
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", () => {
@@ -1,9 +1,10 @@
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
  */
@@ -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 { getApiKey, mapSearchContentType, mapFetchContentType, formatSearchResults, formatFetchResult, createMissingApiKeyError };
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
- return {
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
- return {
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", "✓ ") + theme.fg("accent", details.title), 0, 0);
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
- return new Text(theme.fg("muted", "Done"), 0, 0);
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,17 +1,17 @@
1
1
  {
2
2
  "name": "@jarcelao/pi-exa-api",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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",