@jarcelao/pi-exa-api 0.2.2 → 0.3.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/README.md CHANGED
@@ -11,7 +11,7 @@ pi install npm:@jarcelao/pi-exa-api
11
11
  ```
12
12
 
13
13
  > [!NOTE]
14
- > This extension is compatible with `pi-coding-agent` v0.69.0
14
+ > This extension is tested up to `pi-coding-agent` v0.74.0
15
15
 
16
16
  ## Configuration
17
17
 
@@ -0,0 +1,12 @@
1
+ /**
2
+ * API Key Management
3
+ */
4
+
5
+ /**
6
+ * Get the Exa API key from environment variables.
7
+ * @returns The API key if set and non-empty, undefined otherwise.
8
+ */
9
+ export function getApiKey(): string | undefined {
10
+ const key = process.env.EXA_API_KEY;
11
+ return key && key.length > 0 ? key : undefined;
12
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Content type mapping utilities
3
+ */
4
+
5
+ import type { SearchContentType, FetchContentType } from "./types.ts";
6
+
7
+ /**
8
+ * Map search content type string to Exa API contents option.
9
+ */
10
+ export function mapSearchContentType(
11
+ contentType?: SearchContentType,
12
+ ): { text?: true; highlights?: true; summary?: true } | undefined {
13
+ switch (contentType) {
14
+ case "text":
15
+ return { text: true };
16
+ case "highlights":
17
+ return { highlights: true };
18
+ case "summary":
19
+ return { summary: true };
20
+ case "none":
21
+ return undefined;
22
+ default:
23
+ return { highlights: true };
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Map fetch content type string to Exa API contents option.
29
+ */
30
+ export function mapFetchContentType(
31
+ contentType?: FetchContentType,
32
+ ): { text?: true; highlights?: true; summary?: true } | undefined {
33
+ switch (contentType) {
34
+ case "text":
35
+ return { text: true };
36
+ case "highlights":
37
+ return { highlights: true };
38
+ case "summary":
39
+ return { summary: true };
40
+ default:
41
+ return { text: true };
42
+ }
43
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Error creation utilities
3
+ */
4
+
5
+ /**
6
+ * Create an error for missing API key configuration.
7
+ */
8
+ export function createMissingApiKeyError(): Error {
9
+ return new Error(
10
+ "Exa API key not configured. Set EXA_API_KEY environment variable before starting pi.",
11
+ );
12
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Result formatting utilities
3
+ */
4
+
5
+ import { keyHint } from "@earendil-works/pi-coding-agent";
6
+ import type { Theme } from "@earendil-works/pi-coding-agent";
7
+
8
+ import type {
9
+ ExaSearchResult,
10
+ ExaSearchResponse,
11
+ CodeContextResponse,
12
+ FetchContentType,
13
+ } from "./types.ts";
14
+
15
+ /**
16
+ * Format search results into a readable string.
17
+ */
18
+ export function formatSearchResults(response: ExaSearchResponse): string {
19
+ const { results, costDollars } = response;
20
+ const lines: string[] = [];
21
+
22
+ for (let i = 0; i < results.length; i++) {
23
+ const result = results[i];
24
+ lines.push(`--- Result ${i + 1} ---`);
25
+ lines.push(`Title: ${result.title}`);
26
+ lines.push(`URL: ${result.url}`);
27
+
28
+ if (result.publishedDate) {
29
+ lines.push(`Published: ${result.publishedDate}`);
30
+ }
31
+ if (result.author) {
32
+ lines.push(`Author: ${result.author}`);
33
+ }
34
+
35
+ if (result.highlights && result.highlights.length > 0) {
36
+ lines.push("Highlights:");
37
+ for (const highlight of result.highlights) {
38
+ lines.push(` • ${highlight}`);
39
+ }
40
+ }
41
+
42
+ if (result.text) {
43
+ const preview = result.text.slice(0, 500);
44
+ lines.push(`Text: ${preview}${result.text.length > 500 ? "..." : ""}`);
45
+ }
46
+
47
+ if (result.summary) {
48
+ lines.push(`Summary: ${result.summary}`);
49
+ }
50
+
51
+ lines.push("");
52
+ }
53
+
54
+ if (costDollars) {
55
+ lines.push(`Cost: $${costDollars.total.toFixed(6)}`);
56
+ }
57
+
58
+ return lines.join("\n");
59
+ }
60
+
61
+ /**
62
+ * Format a fetch result into a readable string.
63
+ */
64
+ export function formatFetchResult(result: ExaSearchResult, contentType: FetchContentType): string {
65
+ const lines: string[] = [];
66
+
67
+ if (result.title) {
68
+ lines.push(`Title: ${result.title}`);
69
+ }
70
+ lines.push(`URL: ${result.url}`);
71
+ lines.push("");
72
+
73
+ switch (contentType) {
74
+ case "text":
75
+ if (result.text) {
76
+ lines.push(result.text);
77
+ }
78
+ break;
79
+ case "highlights":
80
+ if (result.highlights && result.highlights.length > 0) {
81
+ lines.push("Highlights:");
82
+ for (const h of result.highlights) {
83
+ lines.push(` • ${h}`);
84
+ }
85
+ }
86
+ break;
87
+ case "summary":
88
+ if (result.summary) {
89
+ lines.push("Summary:");
90
+ lines.push(result.summary);
91
+ }
92
+ break;
93
+ }
94
+
95
+ return lines.join("\n");
96
+ }
97
+
98
+ /**
99
+ * Parse cost dollars from either a JSON string or object.
100
+ */
101
+ export function parseCostDollars(costDollars: string | { total: number }): { total: number } {
102
+ if (typeof costDollars === "string") {
103
+ return JSON.parse(costDollars);
104
+ }
105
+ return costDollars;
106
+ }
107
+
108
+ /**
109
+ * Format a preview of tool output for TUI display.
110
+ * Shows up to 10 lines when collapsed, full output when expanded.
111
+ */
112
+ export function formatToolOutputPreview(
113
+ result: { content: Array<{ type: string; text?: string }> },
114
+ options: { expanded: boolean },
115
+ theme: Theme,
116
+ ): string {
117
+ const textBlocks = result.content
118
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
119
+ .map((c) => c.text || "");
120
+
121
+ if (textBlocks.length === 0) {
122
+ return "";
123
+ }
124
+
125
+ const output = textBlocks.join("\n");
126
+ const lines = output.split("\n");
127
+
128
+ // Trim trailing empty lines
129
+ let end = lines.length;
130
+ while (end > 0 && lines[end - 1] === "") {
131
+ end--;
132
+ }
133
+ const trimmedLines = lines.slice(0, end);
134
+
135
+ const maxLines = options.expanded ? trimmedLines.length : 10;
136
+ const displayLines = trimmedLines.slice(0, maxLines);
137
+ const remaining = trimmedLines.length - maxLines;
138
+
139
+ let text = displayLines.map((line) => theme.fg("toolOutput", line)).join("\n");
140
+
141
+ if (remaining > 0) {
142
+ text += `\n${theme.fg("muted", `... (${remaining} more lines, ${keyHint("app.tools.expand", "to expand")})`)}`;
143
+ }
144
+
145
+ return text;
146
+ }
147
+
148
+ /**
149
+ * Format code context response into a readable string.
150
+ */
151
+ export function formatCodeContextResult(response: CodeContextResponse): string {
152
+ const lines: string[] = [];
153
+
154
+ lines.push(`Query: ${response.query}`);
155
+ lines.push(`Results: ${response.resultsCount} sources`);
156
+ lines.push(`Output tokens: ${response.outputTokens}`);
157
+ lines.push("");
158
+ lines.push("--- Code Context ---");
159
+ lines.push("");
160
+ lines.push(response.response);
161
+ lines.push("");
162
+
163
+ const cost = parseCostDollars(response.costDollars);
164
+ lines.push(`Cost: $${cost.total.toFixed(6)}`);
165
+
166
+ return lines.join("\n");
167
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * exa-search Extension
3
+ *
4
+ * Registers three tools for web search, content fetching, and code context using the Exa API:
5
+ * - exa_search: Natural language web search
6
+ * - exa_fetch: Fetch and extract content from URLs
7
+ * - exa_code_context: Search for code snippets and examples from open source repos
8
+ *
9
+ * Also registers the /exa-status command to check API key configuration.
10
+ */
11
+
12
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+
14
+ import { getApiKey } from "./api-key.ts";
15
+
16
+ import { createExaSearchTool } from "./tools/search.ts";
17
+ import { createExaFetchTool } from "./tools/fetch.ts";
18
+ import { createExaCodeContextTool } from "./tools/code-context.ts";
19
+
20
+ export default function exaSearchExtension(pi: ExtensionAPI): void {
21
+ pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
22
+ const hasKey = !!getApiKey();
23
+ if (!hasKey) {
24
+ ctx.ui.notify("Exa API key not configured. Set EXA_API_KEY to enable search.", "warning");
25
+ }
26
+ });
27
+
28
+ // Register tools
29
+ pi.registerTool(createExaSearchTool());
30
+ pi.registerTool(createExaFetchTool());
31
+ pi.registerTool(createExaCodeContextTool());
32
+
33
+ // Register /exa-status command
34
+ pi.registerCommand("exa-status", {
35
+ description: "Check Exa API key configuration status",
36
+ handler: async (_args: string, ctx: ExtensionContext) => {
37
+ const configured = !!getApiKey();
38
+ ctx.ui.notify(
39
+ configured
40
+ ? "Exa API key: configured via EXA_API_KEY"
41
+ : "Exa API key: not configured. Set EXA_API_KEY environment variable.",
42
+ configured ? "info" : "warning",
43
+ );
44
+ },
45
+ });
46
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Exa Code Context tool definition
3
+ */
4
+
5
+ import type { Theme } from "@earendil-works/pi-coding-agent";
6
+ import { defineTool } from "@earendil-works/pi-coding-agent";
7
+ import { Type, type Static } from "typebox";
8
+
9
+ import { formatCodeContextResult, parseCostDollars } from "../formatters.ts";
10
+ import type { CodeContextDetails, CodeContextResponse } from "../types.ts";
11
+ import {
12
+ requireApiKey,
13
+ truncateAndSave,
14
+ renderToolCall,
15
+ renderToolResult,
16
+ formatCost,
17
+ EXA_CONTEXT_API_URL,
18
+ } from "./shared.ts";
19
+
20
+ // Tool parameter schema
21
+ export const ExaCodeContextParams = Type.Object({
22
+ query: Type.String({
23
+ description: "Search query to find relevant code snippets and examples",
24
+ }),
25
+ tokensNum: Type.Optional(
26
+ Type.Union([
27
+ Type.String({
28
+ description: 'Token limit: "dynamic" for automatic sizing',
29
+ }),
30
+ Type.Number({
31
+ description: "Token limit: 50-100000 (5000 is a good default)",
32
+ }),
33
+ ]),
34
+ ),
35
+ });
36
+
37
+ type CodeContextParams = Static<typeof ExaCodeContextParams>;
38
+
39
+ /**
40
+ * Create the exa_code_context tool definition.
41
+ */
42
+ export function createExaCodeContextTool() {
43
+ return defineTool({
44
+ name: "exa_code_context",
45
+ label: "Exa Code Context",
46
+ description:
47
+ "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.",
48
+ parameters: ExaCodeContextParams,
49
+
50
+ async execute(
51
+ _toolCallId: string,
52
+ params: CodeContextParams,
53
+ _signal: AbortSignal | undefined,
54
+ _onUpdate: unknown,
55
+ _ctx: unknown,
56
+ ) {
57
+ const apiKey = requireApiKey();
58
+
59
+ // Ensure tokensNum is the correct type: number or "dynamic"
60
+ let tokensNum: string | number = params.tokensNum ?? "dynamic";
61
+ if (typeof tokensNum === "string" && tokensNum !== "dynamic") {
62
+ tokensNum = Number(tokensNum);
63
+ }
64
+
65
+ let response: CodeContextResponse;
66
+ try {
67
+ const httpResponse = await fetch(EXA_CONTEXT_API_URL, {
68
+ method: "POST",
69
+ headers: {
70
+ "Content-Type": "application/json",
71
+ "x-api-key": apiKey,
72
+ },
73
+ body: JSON.stringify({
74
+ query: params.query,
75
+ tokensNum,
76
+ }),
77
+ });
78
+
79
+ if (!httpResponse.ok) {
80
+ const errorText = await httpResponse.text();
81
+ throw new Error(`HTTP ${httpResponse.status}: ${errorText}`);
82
+ }
83
+
84
+ response = (await httpResponse.json()) as CodeContextResponse;
85
+ } catch (error) {
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ throw new Error(`Exa Context API error: ${message}`);
88
+ }
89
+
90
+ const output = formatCodeContextResult(response);
91
+ const result = await truncateAndSave(output);
92
+ const cost = parseCostDollars(response.costDollars);
93
+
94
+ return {
95
+ content: [{ type: "text", text: result }],
96
+ details: {
97
+ query: params.query,
98
+ resultsCount: response.resultsCount,
99
+ outputTokens: response.outputTokens,
100
+ cost,
101
+ } as CodeContextDetails,
102
+ };
103
+ },
104
+
105
+ renderCall(args: CodeContextParams, theme: Theme) {
106
+ const desc = `${args.tokensNum ?? "dynamic"} tokens`;
107
+ return renderToolCall("exa_code_context", args.query, desc, theme);
108
+ },
109
+
110
+ renderResult(result, options, theme, context) {
111
+ const details = result.details as CodeContextDetails | undefined;
112
+ const cost = details ? formatCost(details.cost) : "";
113
+ const header = details
114
+ ? theme.fg(
115
+ "success",
116
+ `✓ ${details.resultsCount} sources • ${details.outputTokens} tokens${cost}`,
117
+ )
118
+ : "";
119
+ return renderToolResult(header, result, options, theme, context);
120
+ },
121
+ });
122
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Exa Fetch tool definition
3
+ */
4
+
5
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
6
+ import { defineTool } from "@earendil-works/pi-coding-agent";
7
+ import { Type, type Static } from "typebox";
8
+ import Exa from "exa-js";
9
+
10
+ import { mapFetchContentType } from "../content-types.ts";
11
+ import { formatFetchResult } from "../formatters.ts";
12
+ import type { FetchContentType, FetchDetails, ExaSearchResult } from "../types.ts";
13
+ import {
14
+ requireApiKey,
15
+ truncateAndSave,
16
+ renderToolCall,
17
+ renderToolResult,
18
+ formatCost,
19
+ } from "./shared.ts";
20
+
21
+ // Tool parameter schema
22
+ export const ExaFetchParams = Type.Object({
23
+ url: Type.String({
24
+ description: "URL to fetch content from",
25
+ }),
26
+ contentType: Type.Optional(
27
+ Type.Union([Type.Literal("text"), Type.Literal("highlights"), Type.Literal("summary")]),
28
+ ),
29
+ maxCharacters: Type.Optional(
30
+ Type.Number({
31
+ description: "Maximum characters to return",
32
+ }),
33
+ ),
34
+ });
35
+
36
+ type FetchParams = Static<typeof ExaFetchParams>;
37
+
38
+ /**
39
+ * Create the exa_fetch tool definition.
40
+ */
41
+ export function createExaFetchTool() {
42
+ return defineTool({
43
+ name: "exa_fetch",
44
+ label: "Exa Fetch",
45
+ description:
46
+ "Fetch and extract content from a specific URL using Exa. Can return full text, highlights, or AI-generated summary.",
47
+ parameters: ExaFetchParams,
48
+
49
+ async execute(
50
+ _toolCallId: string,
51
+ params: FetchParams,
52
+ _signal: AbortSignal | undefined,
53
+ _onUpdate: unknown,
54
+ _ctx: ExtensionContext,
55
+ ) {
56
+ const apiKey = requireApiKey();
57
+ const exa = new Exa(apiKey);
58
+
59
+ const contentsOptions: {
60
+ text?: true;
61
+ highlights?: true;
62
+ summary?: true;
63
+ maxCharacters?: number;
64
+ } = {};
65
+
66
+ const mappedContent = mapFetchContentType(params.contentType as FetchContentType | undefined);
67
+ if (mappedContent?.text) contentsOptions.text = true;
68
+ if (mappedContent?.highlights) contentsOptions.highlights = true;
69
+ if (mappedContent?.summary) contentsOptions.summary = true;
70
+ if (params.maxCharacters) {
71
+ contentsOptions.maxCharacters = Math.max(1000, Math.min(100000, params.maxCharacters));
72
+ }
73
+
74
+ let response;
75
+ try {
76
+ response = await exa.getContents(params.url, contentsOptions);
77
+ } catch (error) {
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ throw new Error(`Exa API error: ${message}`);
80
+ }
81
+
82
+ if (!response.results || response.results.length === 0) {
83
+ return {
84
+ content: [{ type: "text", text: "No content found at this URL." }],
85
+ details: { url: params.url, cost: response.costDollars } as FetchDetails,
86
+ };
87
+ }
88
+
89
+ const result = response.results[0] as ExaSearchResult;
90
+ const output = formatFetchResult(result, (params.contentType ?? "text") as FetchContentType);
91
+ const content = await truncateAndSave(output);
92
+
93
+ return {
94
+ content: [{ type: "text", text: content }],
95
+ details: {
96
+ url: params.url,
97
+ title: result.title,
98
+ cost: response.costDollars,
99
+ } as FetchDetails,
100
+ };
101
+ },
102
+
103
+ renderCall(args: FetchParams, theme: Theme) {
104
+ const desc = args.contentType ?? "text";
105
+ return renderToolCall("exa_fetch", args.url, desc, theme);
106
+ },
107
+
108
+ renderResult(result, options, theme, context) {
109
+ const details = result.details as FetchDetails | undefined;
110
+ const cost = details ? formatCost(details.cost) : "";
111
+ const header = details
112
+ ? details.title
113
+ ? theme.fg("success", `✓ ${details.title}${cost}`)
114
+ : theme.fg("success", `✓ Fetched${cost}`)
115
+ : "";
116
+ return renderToolResult(header, result, options, theme, context);
117
+ },
118
+ });
119
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Exa Search tool definition
3
+ */
4
+
5
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
6
+ import { defineTool } from "@earendil-works/pi-coding-agent";
7
+ import { Type, type Static } from "typebox";
8
+ import Exa from "exa-js";
9
+
10
+ import { mapSearchContentType } from "../content-types.ts";
11
+ import { formatSearchResults } from "../formatters.ts";
12
+ import type { SearchContentType, SearchDetails, ExaSearchResult } from "../types.ts";
13
+ import {
14
+ requireApiKey,
15
+ truncateAndSave,
16
+ renderToolCall,
17
+ renderToolResult,
18
+ formatCost,
19
+ } from "./shared.ts";
20
+
21
+ // Tool parameter schema
22
+ export const ExaSearchParams = Type.Object({
23
+ query: Type.String({
24
+ description: "Natural language search query",
25
+ }),
26
+ contentType: Type.Optional(
27
+ Type.Union([
28
+ Type.Literal("text"),
29
+ Type.Literal("highlights"),
30
+ Type.Literal("summary"),
31
+ Type.Literal("none"),
32
+ ]),
33
+ ),
34
+ numResults: Type.Optional(
35
+ Type.Number({
36
+ description: "Number of results (1-100)",
37
+ }),
38
+ ),
39
+ });
40
+
41
+ type SearchParams = Static<typeof ExaSearchParams>;
42
+
43
+ /**
44
+ * Create the exa_search tool definition.
45
+ */
46
+ export function createExaSearchTool() {
47
+ return defineTool({
48
+ name: "exa_search",
49
+ label: "Exa Search",
50
+ description:
51
+ "Search the web using Exa's neural search API. Best for factual queries, research, and finding relevant web content. Use highlights mode by default for token efficiency.",
52
+ parameters: ExaSearchParams,
53
+
54
+ async execute(
55
+ _toolCallId: string,
56
+ params: SearchParams,
57
+ _signal: AbortSignal | undefined,
58
+ _onUpdate: unknown,
59
+ _ctx: ExtensionContext,
60
+ ) {
61
+ const apiKey = requireApiKey();
62
+ const numResults = Math.max(1, Math.min(100, params.numResults ?? 10));
63
+ const exa = new Exa(apiKey);
64
+
65
+ const contents = mapSearchContentType(params.contentType as SearchContentType | undefined);
66
+ const searchOptions: {
67
+ numResults: number;
68
+ contents?: { text?: true; highlights?: true; summary?: true };
69
+ } = { numResults };
70
+
71
+ if (contents) {
72
+ searchOptions.contents = contents;
73
+ }
74
+
75
+ let response;
76
+ try {
77
+ response = await exa.search(params.query, searchOptions);
78
+ } catch (error) {
79
+ const message = error instanceof Error ? error.message : String(error);
80
+ throw new Error(`Exa API error: ${message}`);
81
+ }
82
+
83
+ const output = formatSearchResults({
84
+ results: response.results as ExaSearchResult[],
85
+ costDollars: response.costDollars as { total: number } | undefined,
86
+ });
87
+
88
+ const result = await truncateAndSave(output);
89
+
90
+ return {
91
+ content: [{ type: "text", text: result }],
92
+ details: {
93
+ query: params.query,
94
+ numResults: response.results.length,
95
+ cost: response.costDollars,
96
+ } as SearchDetails,
97
+ };
98
+ },
99
+
100
+ renderCall(args: SearchParams, theme: Theme) {
101
+ const desc = `${args.numResults ?? 10} results • ${args.contentType ?? "highlights"}`;
102
+ return renderToolCall("exa_search", args.query, desc, theme);
103
+ },
104
+
105
+ renderResult(result, options, theme, context) {
106
+ const details = result.details as SearchDetails | undefined;
107
+ const cost = details ? formatCost(details.cost) : "";
108
+ const header = details ? theme.fg("success", `✓ ${details.numResults} results${cost}`) : "";
109
+ return renderToolResult(header, result, options, theme, context);
110
+ },
111
+ });
112
+ }