@jarcelao/pi-exa-api 0.1.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 +22 -0
- package/README.md +88 -0
- package/exa-search.test.ts +459 -0
- package/extensions/exa-search.ts +441 -0
- package/package.json +37 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +9 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jericho Renniel Arcelao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# pi-exa-api
|
|
2
|
+
|
|
3
|
+
Web search and content fetching for [pi](https://pi.dev) via the [Exa API](https://exa.ai/).
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
Install as a pi package:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pi install npm:@jarcelao/pi-exa-api@0.1.0
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Configuration
|
|
15
|
+
|
|
16
|
+
Set your Exa API key as an environment variable before starting pi:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
export EXA_API_KEY="your-api-key-here"
|
|
20
|
+
pi
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or add to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.) for persistence.
|
|
24
|
+
|
|
25
|
+
### Check Configuration
|
|
26
|
+
|
|
27
|
+
Run the `/exa-status` command in pi to verify your API key is configured:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
/exa-status
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
### Web Search
|
|
36
|
+
|
|
37
|
+
The agent can use `exa_search` to find information on the web:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
Search for recent developments in quantum computing
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Parameters:**
|
|
44
|
+
|
|
45
|
+
- `query` (required) - Natural language search query
|
|
46
|
+
- `contentType` (optional) - Type of content to retrieve:
|
|
47
|
+
- `highlights` (default) - Key excerpts from each result
|
|
48
|
+
- `text` - Full text content (may be truncated)
|
|
49
|
+
- `summary` - AI-generated summary
|
|
50
|
+
- `none` - Metadata only (title, URL, date, author)
|
|
51
|
+
- `numResults` (optional) - Number of results (1-100, default: 10)
|
|
52
|
+
|
|
53
|
+
### Fetch URL Content
|
|
54
|
+
|
|
55
|
+
The agent can use `exa_fetch` to extract content from a specific URL:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
Fetch the content from https://example.com/article
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Parameters:**
|
|
62
|
+
|
|
63
|
+
- `url` (required) - URL to fetch
|
|
64
|
+
- `contentType` (optional) - Type of content:
|
|
65
|
+
- `text` (default) - Full page text
|
|
66
|
+
- `highlights` - Key excerpts
|
|
67
|
+
- `summary` - AI-generated summary
|
|
68
|
+
- `maxCharacters` (optional) - Maximum characters to return (1000-100000)
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Install dependencies
|
|
74
|
+
npm install
|
|
75
|
+
|
|
76
|
+
# Run tests
|
|
77
|
+
npm test
|
|
78
|
+
|
|
79
|
+
# Run linting
|
|
80
|
+
npm run lint
|
|
81
|
+
|
|
82
|
+
# Format code
|
|
83
|
+
npm run format
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import exaSearchExtension, {
|
|
3
|
+
getApiKey,
|
|
4
|
+
mapSearchContentType,
|
|
5
|
+
mapFetchContentType,
|
|
6
|
+
formatSearchResults,
|
|
7
|
+
formatFetchResult,
|
|
8
|
+
createMissingApiKeyError,
|
|
9
|
+
} from "./extensions/exa-search.ts";
|
|
10
|
+
import {
|
|
11
|
+
truncateHead,
|
|
12
|
+
DEFAULT_MAX_BYTES,
|
|
13
|
+
DEFAULT_MAX_LINES,
|
|
14
|
+
formatSize,
|
|
15
|
+
} from "@mariozechner/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
describe("API Key Management", () => {
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
delete process.env.EXA_API_KEY;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should return undefined when EXA_API_KEY is not set", () => {
|
|
23
|
+
delete process.env.EXA_API_KEY;
|
|
24
|
+
expect(getApiKey()).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should return API key when EXA_API_KEY is set", () => {
|
|
28
|
+
const testKey = "test-api-key-123";
|
|
29
|
+
process.env.EXA_API_KEY = testKey;
|
|
30
|
+
expect(getApiKey()).toBe(testKey);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should treat empty string as not configured", () => {
|
|
34
|
+
process.env.EXA_API_KEY = "";
|
|
35
|
+
expect(getApiKey()).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should throw descriptive error when API key is missing", () => {
|
|
39
|
+
const error = createMissingApiKeyError();
|
|
40
|
+
expect(error.message).toContain("EXA_API_KEY");
|
|
41
|
+
expect(error.message).toContain("environment variable");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("ContentType Mapping (Search)", () => {
|
|
46
|
+
it('should map "text" to contents.text', () => {
|
|
47
|
+
const result = mapSearchContentType("text");
|
|
48
|
+
expect(result).toEqual({ text: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should map "highlights" to contents.highlights', () => {
|
|
52
|
+
const result = mapSearchContentType("highlights");
|
|
53
|
+
expect(result).toEqual({ highlights: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should map "summary" to contents.summary', () => {
|
|
57
|
+
const result = mapSearchContentType("summary");
|
|
58
|
+
expect(result).toEqual({ summary: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should map "none" to undefined (metadata only)', () => {
|
|
62
|
+
const result = mapSearchContentType("none");
|
|
63
|
+
expect(result).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should default to highlights when not specified", () => {
|
|
67
|
+
const result = mapSearchContentType(undefined);
|
|
68
|
+
expect(result).toEqual({ highlights: true });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("ContentType Mapping (Fetch)", () => {
|
|
73
|
+
it('should map "text" to contents.text', () => {
|
|
74
|
+
const result = mapFetchContentType("text");
|
|
75
|
+
expect(result).toEqual({ text: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should map "highlights" to contents.highlights', () => {
|
|
79
|
+
const result = mapFetchContentType("highlights");
|
|
80
|
+
expect(result).toEqual({ highlights: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should map "summary" to contents.summary', () => {
|
|
84
|
+
const result = mapFetchContentType("summary");
|
|
85
|
+
expect(result).toEqual({ summary: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should default to text for fetch when not specified", () => {
|
|
89
|
+
const result = mapFetchContentType(undefined);
|
|
90
|
+
expect(result).toEqual({ text: true });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("Parameter Validation", () => {
|
|
95
|
+
it("should accept valid search params (query only)", () => {
|
|
96
|
+
const params = { query: "test query" };
|
|
97
|
+
expect(params.query).toBeTruthy();
|
|
98
|
+
expect(params.query.trim()).not.toBe("");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should accept valid search params (all options)", () => {
|
|
102
|
+
const params = {
|
|
103
|
+
query: "test",
|
|
104
|
+
contentType: "highlights" as const,
|
|
105
|
+
numResults: 10,
|
|
106
|
+
};
|
|
107
|
+
expect(params.query).toBeTruthy();
|
|
108
|
+
expect(["text", "highlights", "summary", "none"].includes(params.contentType)).toBe(true);
|
|
109
|
+
expect(params.numResults).toBeGreaterThanOrEqual(1);
|
|
110
|
+
expect(params.numResults).toBeLessThanOrEqual(100);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should accept valid fetch params", () => {
|
|
114
|
+
const params = {
|
|
115
|
+
url: "https://example.com",
|
|
116
|
+
contentType: "text" as const,
|
|
117
|
+
maxCharacters: 5000,
|
|
118
|
+
};
|
|
119
|
+
expect(params.url).toBeTruthy();
|
|
120
|
+
expect(["text", "highlights", "summary"].includes(params.contentType)).toBe(true);
|
|
121
|
+
expect(params.maxCharacters).toBeGreaterThan(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should reject fetch params with invalid contentType", () => {
|
|
125
|
+
const invalidContentTypes = ["none", "invalid", "full"];
|
|
126
|
+
for (const ct of invalidContentTypes) {
|
|
127
|
+
expect(["text", "highlights", "summary"].includes(ct as string)).toBe(false);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("Output Truncation", () => {
|
|
133
|
+
it("should not truncate short output", () => {
|
|
134
|
+
const text = "Short text";
|
|
135
|
+
const result = truncateHead(text);
|
|
136
|
+
expect(result.truncated).toBe(false);
|
|
137
|
+
expect(result.content).toBe(text);
|
|
138
|
+
expect(result.outputLines).toBe(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("should truncate by line count", () => {
|
|
142
|
+
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i}`);
|
|
143
|
+
const text = lines.join("\n");
|
|
144
|
+
const result = truncateHead(text, { maxLines: 10 });
|
|
145
|
+
expect(result.truncated).toBe(true);
|
|
146
|
+
expect(result.outputLines).toBe(10);
|
|
147
|
+
expect(result.totalLines).toBe(100);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should truncate by byte size", () => {
|
|
151
|
+
const text = "x".repeat(10000);
|
|
152
|
+
const result = truncateHead(text, { maxBytes: 1000 });
|
|
153
|
+
expect(result.truncated).toBe(true);
|
|
154
|
+
expect(result.outputBytes).toBeLessThanOrEqual(1000);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("should respect DEFAULT_MAX_LINES constant", () => {
|
|
158
|
+
expect(DEFAULT_MAX_LINES).toBe(2000);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("should respect DEFAULT_MAX_BYTES constant", () => {
|
|
162
|
+
expect(DEFAULT_MAX_BYTES).toBe(50 * 1024);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("formatSize should format bytes correctly", () => {
|
|
166
|
+
expect(formatSize(500)).toBe("500B");
|
|
167
|
+
expect(formatSize(1024)).toBe("1.0KB");
|
|
168
|
+
expect(formatSize(1024 * 50)).toBe("50.0KB");
|
|
169
|
+
expect(formatSize(1024 * 1024)).toBe("1.0MB");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("Search Result Formatting", () => {
|
|
174
|
+
it("should format basic search results", () => {
|
|
175
|
+
const response = {
|
|
176
|
+
results: [
|
|
177
|
+
{
|
|
178
|
+
title: "Test Article",
|
|
179
|
+
url: "https://example.com/article",
|
|
180
|
+
publishedDate: "2024-01-15",
|
|
181
|
+
author: "John Doe",
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const formatted = formatSearchResults(response);
|
|
187
|
+
expect(formatted).toContain("Test Article");
|
|
188
|
+
expect(formatted).toContain("https://example.com/article");
|
|
189
|
+
expect(formatted).toContain("2024-01-15");
|
|
190
|
+
expect(formatted).toContain("John Doe");
|
|
191
|
+
expect(formatted).toContain("--- Result 1 ---");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("should format results with highlights", () => {
|
|
195
|
+
const response = {
|
|
196
|
+
results: [
|
|
197
|
+
{
|
|
198
|
+
title: "Test",
|
|
199
|
+
url: "https://example.com",
|
|
200
|
+
highlights: ["First highlight", "Second highlight"],
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const formatted = formatSearchResults(response);
|
|
206
|
+
expect(formatted).toContain("Highlights:");
|
|
207
|
+
expect(formatted).toContain("First highlight");
|
|
208
|
+
expect(formatted).toContain("Second highlight");
|
|
209
|
+
expect(formatted).toContain(" • First highlight");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("should format results with summary", () => {
|
|
213
|
+
const response = {
|
|
214
|
+
results: [
|
|
215
|
+
{
|
|
216
|
+
title: "Test",
|
|
217
|
+
url: "https://example.com",
|
|
218
|
+
summary: "This is a summary",
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const formatted = formatSearchResults(response);
|
|
224
|
+
expect(formatted).toContain("Summary: This is a summary");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("should include cost information", () => {
|
|
228
|
+
const response = {
|
|
229
|
+
results: [{ title: "Test", url: "https://example.com" }],
|
|
230
|
+
costDollars: { total: 0.000123 },
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const formatted = formatSearchResults(response);
|
|
234
|
+
expect(formatted).toContain("Cost: $0.000123");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("should handle null publishedDate and author", () => {
|
|
238
|
+
const response = {
|
|
239
|
+
results: [
|
|
240
|
+
{
|
|
241
|
+
title: "Test",
|
|
242
|
+
url: "https://example.com",
|
|
243
|
+
publishedDate: null,
|
|
244
|
+
author: null,
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const formatted = formatSearchResults(response);
|
|
250
|
+
expect(formatted).toContain("Title: Test");
|
|
251
|
+
expect(formatted).not.toContain("Published:");
|
|
252
|
+
expect(formatted).not.toContain("Author:");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("should truncate long text preview", () => {
|
|
256
|
+
const longText = "a".repeat(600);
|
|
257
|
+
const response = {
|
|
258
|
+
results: [
|
|
259
|
+
{
|
|
260
|
+
title: "Test",
|
|
261
|
+
url: "https://example.com",
|
|
262
|
+
text: longText,
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const formatted = formatSearchResults(response);
|
|
268
|
+
expect(formatted).toContain("a".repeat(500));
|
|
269
|
+
expect(formatted).toContain("...");
|
|
270
|
+
expect(formatted).not.toContain("a".repeat(501));
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("Fetch Result Formatting", () => {
|
|
275
|
+
it("should format text content", () => {
|
|
276
|
+
const result = {
|
|
277
|
+
title: "Page Title",
|
|
278
|
+
url: "https://example.com",
|
|
279
|
+
text: "Full page content here",
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const formatted = formatFetchResult(result, "text");
|
|
283
|
+
expect(formatted).toContain("Title: Page Title");
|
|
284
|
+
expect(formatted).toContain("Full page content here");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("should format highlights", () => {
|
|
288
|
+
const result = {
|
|
289
|
+
url: "https://example.com",
|
|
290
|
+
highlights: ["Key point 1", "Key point 2"],
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const formatted = formatFetchResult(result, "highlights");
|
|
294
|
+
expect(formatted).toContain("Highlights:");
|
|
295
|
+
expect(formatted).toContain("Key point 1");
|
|
296
|
+
expect(formatted).toContain("Key point 2");
|
|
297
|
+
expect(formatted).toContain(" • Key point 1");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("should format summary", () => {
|
|
301
|
+
const result = {
|
|
302
|
+
url: "https://example.com",
|
|
303
|
+
summary: "This page is about...",
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const formatted = formatFetchResult(result, "summary");
|
|
307
|
+
expect(formatted).toContain("Summary:");
|
|
308
|
+
expect(formatted).toContain("This page is about...");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("should not include title if not provided", () => {
|
|
312
|
+
const result = {
|
|
313
|
+
url: "https://example.com",
|
|
314
|
+
text: "Content only",
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const formatted = formatFetchResult(result, "text");
|
|
318
|
+
expect(formatted).toContain("https://example.com");
|
|
319
|
+
expect(formatted).not.toContain("Title:");
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("Error Handling", () => {
|
|
324
|
+
it("should create descriptive missing API key error", () => {
|
|
325
|
+
const error = createMissingApiKeyError();
|
|
326
|
+
expect(error.message).toContain("Exa API key");
|
|
327
|
+
expect(error.message).toContain("EXA_API_KEY");
|
|
328
|
+
expect(error.message).toContain("environment variable");
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("Extension Registration", () => {
|
|
333
|
+
function createMockExtensionAPI() {
|
|
334
|
+
const tools: unknown[] = [];
|
|
335
|
+
const commands: Map<string, unknown> = new Map();
|
|
336
|
+
const eventHandlers: Map<string, unknown[]> = new Map();
|
|
337
|
+
|
|
338
|
+
const api = {
|
|
339
|
+
registerTool: (tool: unknown) => tools.push(tool),
|
|
340
|
+
registerCommand: (name: string, command: unknown) => commands.set(name, command),
|
|
341
|
+
on: (event: string, handler: unknown) => {
|
|
342
|
+
const handlers = eventHandlers.get(event) || [];
|
|
343
|
+
handlers.push(handler);
|
|
344
|
+
eventHandlers.set(event, handlers);
|
|
345
|
+
},
|
|
346
|
+
getTools: () => tools,
|
|
347
|
+
getCommands: () => Array.from(commands.entries()),
|
|
348
|
+
getEventHandlers: (event: string) => eventHandlers.get(event) || [],
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
return api;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
it("should register exa_search tool", () => {
|
|
355
|
+
const api = createMockExtensionAPI();
|
|
356
|
+
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
357
|
+
|
|
358
|
+
const tools = api.getTools();
|
|
359
|
+
expect(tools.length).toBeGreaterThanOrEqual(2);
|
|
360
|
+
|
|
361
|
+
const exaSearchTool = tools.find((t: unknown) => (t as { name: string }).name === "exa_search");
|
|
362
|
+
expect(exaSearchTool).toBeDefined();
|
|
363
|
+
expect((exaSearchTool as { name: string }).name).toBe("exa_search");
|
|
364
|
+
expect((exaSearchTool as { label: string }).label).toBe("Exa Search");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("should register exa_fetch tool", () => {
|
|
368
|
+
const api = createMockExtensionAPI();
|
|
369
|
+
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
370
|
+
|
|
371
|
+
const tools = api.getTools();
|
|
372
|
+
const exaFetchTool = tools.find((t: unknown) => (t as { name: string }).name === "exa_fetch");
|
|
373
|
+
expect(exaFetchTool).toBeDefined();
|
|
374
|
+
expect((exaFetchTool as { name: string }).name).toBe("exa_fetch");
|
|
375
|
+
expect((exaFetchTool as { label: string }).label).toBe("Exa Fetch");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("should register /exa-status command", () => {
|
|
379
|
+
const api = createMockExtensionAPI();
|
|
380
|
+
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
381
|
+
|
|
382
|
+
const commands = api.getCommands();
|
|
383
|
+
const exaStatusCmd = commands.find(([name]) => name === "exa-status");
|
|
384
|
+
expect(exaStatusCmd).toBeDefined();
|
|
385
|
+
expect(exaStatusCmd![0]).toBe("exa-status");
|
|
386
|
+
expect((exaStatusCmd![1] as { description: string }).description).toContain("API key");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("should register session_start event handler", () => {
|
|
390
|
+
const api = createMockExtensionAPI();
|
|
391
|
+
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
392
|
+
|
|
393
|
+
const handlers = api.getEventHandlers("session_start");
|
|
394
|
+
expect(handlers.length).toBe(1);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("should have execute function on exa_search tool", () => {
|
|
398
|
+
const api = createMockExtensionAPI();
|
|
399
|
+
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
400
|
+
|
|
401
|
+
const tools = api.getTools();
|
|
402
|
+
const exaSearchTool = tools.find(
|
|
403
|
+
(t: unknown) => (t as { name: string }).name === "exa_search",
|
|
404
|
+
) as { execute: unknown };
|
|
405
|
+
expect(typeof exaSearchTool?.execute).toBe("function");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("should have execute function on exa_fetch tool", () => {
|
|
409
|
+
const api = createMockExtensionAPI();
|
|
410
|
+
exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
|
|
411
|
+
|
|
412
|
+
const tools = api.getTools();
|
|
413
|
+
const exaFetchTool = tools.find(
|
|
414
|
+
(t: unknown) => (t as { name: string }).name === "exa_fetch",
|
|
415
|
+
) as { execute: unknown };
|
|
416
|
+
expect(typeof exaFetchTool?.execute).toBe("function");
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe("Tool Execute Validation", () => {
|
|
421
|
+
it("should validate search parameters structure", () => {
|
|
422
|
+
const searchParams = {
|
|
423
|
+
query: "test query",
|
|
424
|
+
contentType: "highlights" as const,
|
|
425
|
+
numResults: 10,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
expect(typeof searchParams.query).toBe("string");
|
|
429
|
+
expect(searchParams.query.trim().length).toBeGreaterThan(0);
|
|
430
|
+
expect(["text", "highlights", "summary", "none"].includes(searchParams.contentType)).toBe(true);
|
|
431
|
+
expect(searchParams.numResults).toBeGreaterThanOrEqual(1);
|
|
432
|
+
expect(searchParams.numResults).toBeLessThanOrEqual(100);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("should validate fetch parameters structure", () => {
|
|
436
|
+
const fetchParams = {
|
|
437
|
+
url: "https://example.com",
|
|
438
|
+
contentType: "text" as const,
|
|
439
|
+
maxCharacters: 5000,
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
expect(typeof fetchParams.url).toBe("string");
|
|
443
|
+
expect(fetchParams.url.length).toBeGreaterThan(0);
|
|
444
|
+
expect(["text", "highlights", "summary"].includes(fetchParams.contentType)).toBe(true);
|
|
445
|
+
expect(fetchParams.maxCharacters).toBeGreaterThan(0);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("should handle getContents options mapping", () => {
|
|
449
|
+
const url = "https://example.com";
|
|
450
|
+
const options = {
|
|
451
|
+
text: true,
|
|
452
|
+
maxCharacters: 10000,
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
expect(url).toBeTruthy();
|
|
456
|
+
expect(options.text).toBe(true);
|
|
457
|
+
expect(options.maxCharacters).toBeGreaterThan(0);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exa-search Extension
|
|
3
|
+
*
|
|
4
|
+
* Registers two tools for web search and content fetching using the Exa API:
|
|
5
|
+
* - exa_search: Natural language web search
|
|
6
|
+
* - exa_fetch: Fetch and extract content from URLs
|
|
7
|
+
*
|
|
8
|
+
* Also registers the /exa-status command to check API key configuration.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
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
|
+
import {
|
|
15
|
+
truncateHead,
|
|
16
|
+
DEFAULT_MAX_BYTES,
|
|
17
|
+
DEFAULT_MAX_LINES,
|
|
18
|
+
formatSize,
|
|
19
|
+
} from "@mariozechner/pi-coding-agent";
|
|
20
|
+
import Exa from "exa-js";
|
|
21
|
+
|
|
22
|
+
// API Key Management
|
|
23
|
+
|
|
24
|
+
function getApiKey(): string | undefined {
|
|
25
|
+
const key = process.env.EXA_API_KEY;
|
|
26
|
+
return key && key.length > 0 ? key : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Type Definitions
|
|
30
|
+
|
|
31
|
+
type SearchContentType = "text" | "highlights" | "summary" | "none";
|
|
32
|
+
type FetchContentType = "text" | "highlights" | "summary";
|
|
33
|
+
|
|
34
|
+
interface SearchDetails {
|
|
35
|
+
query: string;
|
|
36
|
+
numResults: number;
|
|
37
|
+
cost?: { total: number };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface FetchDetails {
|
|
41
|
+
url: string;
|
|
42
|
+
title?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Content Type Mapping
|
|
46
|
+
|
|
47
|
+
function mapSearchContentType(
|
|
48
|
+
contentType?: SearchContentType,
|
|
49
|
+
): { text?: true; highlights?: true; summary?: true } | undefined {
|
|
50
|
+
switch (contentType) {
|
|
51
|
+
case "text":
|
|
52
|
+
return { text: true };
|
|
53
|
+
case "highlights":
|
|
54
|
+
return { highlights: true };
|
|
55
|
+
case "summary":
|
|
56
|
+
return { summary: true };
|
|
57
|
+
case "none":
|
|
58
|
+
return undefined;
|
|
59
|
+
default:
|
|
60
|
+
return { highlights: true };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mapFetchContentType(
|
|
65
|
+
contentType?: FetchContentType,
|
|
66
|
+
): { text?: true; highlights?: true; summary?: true } | undefined {
|
|
67
|
+
switch (contentType) {
|
|
68
|
+
case "text":
|
|
69
|
+
return { text: true };
|
|
70
|
+
case "highlights":
|
|
71
|
+
return { highlights: true };
|
|
72
|
+
case "summary":
|
|
73
|
+
return { summary: true };
|
|
74
|
+
default:
|
|
75
|
+
return { text: true };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Result Formatting
|
|
80
|
+
|
|
81
|
+
interface ExaSearchResult {
|
|
82
|
+
title: string;
|
|
83
|
+
url: string;
|
|
84
|
+
publishedDate?: string | null;
|
|
85
|
+
author?: string | null;
|
|
86
|
+
text?: string;
|
|
87
|
+
highlights?: string[];
|
|
88
|
+
summary?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface ExaSearchResponse {
|
|
92
|
+
results: ExaSearchResult[];
|
|
93
|
+
costDollars?: { total: number };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatSearchResults(response: ExaSearchResponse): string {
|
|
97
|
+
const { results, costDollars } = response;
|
|
98
|
+
const lines: string[] = [];
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < results.length; i++) {
|
|
101
|
+
const result = results[i];
|
|
102
|
+
lines.push(`--- Result ${i + 1} ---`);
|
|
103
|
+
lines.push(`Title: ${result.title}`);
|
|
104
|
+
lines.push(`URL: ${result.url}`);
|
|
105
|
+
|
|
106
|
+
if (result.publishedDate) {
|
|
107
|
+
lines.push(`Published: ${result.publishedDate}`);
|
|
108
|
+
}
|
|
109
|
+
if (result.author) {
|
|
110
|
+
lines.push(`Author: ${result.author}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (result.highlights && result.highlights.length > 0) {
|
|
114
|
+
lines.push("Highlights:");
|
|
115
|
+
for (const highlight of result.highlights) {
|
|
116
|
+
lines.push(` • ${highlight}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (result.text) {
|
|
121
|
+
const preview = result.text.slice(0, 500);
|
|
122
|
+
lines.push(`Text: ${preview}${result.text.length > 500 ? "..." : ""}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (result.summary) {
|
|
126
|
+
lines.push(`Summary: ${result.summary}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
lines.push("");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (costDollars) {
|
|
133
|
+
lines.push(`Cost: $${costDollars.total.toFixed(6)}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatFetchResult(result: ExaSearchResult, contentType: FetchContentType): string {
|
|
140
|
+
const lines: string[] = [];
|
|
141
|
+
|
|
142
|
+
if (result.title) {
|
|
143
|
+
lines.push(`Title: ${result.title}`);
|
|
144
|
+
}
|
|
145
|
+
lines.push(`URL: ${result.url}`);
|
|
146
|
+
lines.push("");
|
|
147
|
+
|
|
148
|
+
switch (contentType) {
|
|
149
|
+
case "text":
|
|
150
|
+
if (result.text) {
|
|
151
|
+
lines.push(result.text);
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
case "highlights":
|
|
155
|
+
if (result.highlights && result.highlights.length > 0) {
|
|
156
|
+
lines.push("Highlights:");
|
|
157
|
+
for (const h of result.highlights) {
|
|
158
|
+
lines.push(` • ${h}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
case "summary":
|
|
163
|
+
if (result.summary) {
|
|
164
|
+
lines.push("Summary:");
|
|
165
|
+
lines.push(result.summary);
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Error Creation
|
|
174
|
+
|
|
175
|
+
function createMissingApiKeyError(): Error {
|
|
176
|
+
return new Error(
|
|
177
|
+
"Exa API key not configured. Set EXA_API_KEY environment variable before starting pi.",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Exports
|
|
182
|
+
|
|
183
|
+
export { getApiKey, mapSearchContentType, mapFetchContentType, formatSearchResults, formatFetchResult, createMissingApiKeyError };
|
|
184
|
+
|
|
185
|
+
export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
186
|
+
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
187
|
+
const hasKey = !!getApiKey();
|
|
188
|
+
if (!hasKey) {
|
|
189
|
+
ctx.ui.notify("Exa API key not configured. Set EXA_API_KEY to enable search.", "warning");
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Register exa_search tool
|
|
194
|
+
|
|
195
|
+
const ExaSearchParams = Type.Object({
|
|
196
|
+
query: Type.String({
|
|
197
|
+
description: "Natural language search query",
|
|
198
|
+
}),
|
|
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
|
+
),
|
|
207
|
+
numResults: Type.Optional(
|
|
208
|
+
Type.Number({
|
|
209
|
+
description: "Number of results (1-100)",
|
|
210
|
+
}),
|
|
211
|
+
),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
pi.registerTool({
|
|
215
|
+
name: "exa_search",
|
|
216
|
+
label: "Exa Search",
|
|
217
|
+
description:
|
|
218
|
+
"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.",
|
|
219
|
+
parameters: ExaSearchParams,
|
|
220
|
+
|
|
221
|
+
async execute(
|
|
222
|
+
_toolCallId: string,
|
|
223
|
+
params: Static<typeof ExaSearchParams>,
|
|
224
|
+
_signal: AbortSignal | undefined,
|
|
225
|
+
_onUpdate: unknown,
|
|
226
|
+
_ctx: ExtensionContext,
|
|
227
|
+
) {
|
|
228
|
+
const apiKey = getApiKey();
|
|
229
|
+
if (!apiKey) {
|
|
230
|
+
throw createMissingApiKeyError();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const numResults = Math.max(1, Math.min(100, params.numResults ?? 10));
|
|
234
|
+
const exa = new Exa(apiKey);
|
|
235
|
+
|
|
236
|
+
const contents = mapSearchContentType(params.contentType as SearchContentType | undefined);
|
|
237
|
+
const searchOptions: {
|
|
238
|
+
numResults: number;
|
|
239
|
+
contents?: { text?: true; highlights?: true; summary?: true };
|
|
240
|
+
} = { numResults };
|
|
241
|
+
|
|
242
|
+
if (contents) {
|
|
243
|
+
searchOptions.contents = contents;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let response;
|
|
247
|
+
try {
|
|
248
|
+
response = await exa.search(params.query, searchOptions);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
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
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let output = formatSearchResults({
|
|
258
|
+
results: response.results as ExaSearchResult[],
|
|
259
|
+
costDollars: response.costDollars as { total: number } | undefined,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const truncation = truncateHead(output, {
|
|
263
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
264
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
let result = truncation.content;
|
|
268
|
+
|
|
269
|
+
if (truncation.truncated) {
|
|
270
|
+
result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
271
|
+
result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).]`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
content: [{ type: "text", text: result }],
|
|
276
|
+
details: {
|
|
277
|
+
query: params.query,
|
|
278
|
+
numResults: response.results.length,
|
|
279
|
+
cost: response.costDollars,
|
|
280
|
+
} as SearchDetails,
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
renderCall(args, theme) {
|
|
285
|
+
const preview = args.query.length > 50 ? args.query.slice(0, 50) + "..." : args.query;
|
|
286
|
+
const desc = `${args.numResults ?? 10} results • ${args.contentType ?? "highlights"}`;
|
|
287
|
+
const text =
|
|
288
|
+
theme.fg("toolTitle", theme.bold("exa_search ")) +
|
|
289
|
+
theme.fg("muted", preview) +
|
|
290
|
+
theme.fg("dim", ` ${desc}`);
|
|
291
|
+
return new Text(text, 0, 0);
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
renderResult(result, { expanded: _expanded }, theme) {
|
|
295
|
+
const details = result.details as SearchDetails | undefined;
|
|
296
|
+
|
|
297
|
+
if (!details) {
|
|
298
|
+
const text = result.content[0];
|
|
299
|
+
return new Text(text?.type === "text" ? text.text.slice(0, 60) : "", 0, 0);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const cost = details.cost ? ` • $${details.cost.total.toFixed(6)}` : "";
|
|
303
|
+
return new Text(theme.fg("success", `✓ ${details.numResults} results${cost}`), 0, 0);
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Register exa_fetch tool
|
|
308
|
+
|
|
309
|
+
const ExaFetchParams = Type.Object({
|
|
310
|
+
url: Type.String({
|
|
311
|
+
description: "URL to fetch content from",
|
|
312
|
+
}),
|
|
313
|
+
contentType: Type.Optional(
|
|
314
|
+
Type.Union([Type.Literal("text"), Type.Literal("highlights"), Type.Literal("summary")]),
|
|
315
|
+
),
|
|
316
|
+
maxCharacters: Type.Optional(
|
|
317
|
+
Type.Number({
|
|
318
|
+
description: "Maximum characters to return",
|
|
319
|
+
}),
|
|
320
|
+
),
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
pi.registerTool({
|
|
324
|
+
name: "exa_fetch",
|
|
325
|
+
label: "Exa Fetch",
|
|
326
|
+
description:
|
|
327
|
+
"Fetch and extract content from a specific URL using Exa. Can return full text, highlights, or AI-generated summary.",
|
|
328
|
+
parameters: ExaFetchParams,
|
|
329
|
+
|
|
330
|
+
async execute(
|
|
331
|
+
_toolCallId: string,
|
|
332
|
+
params: Static<typeof ExaFetchParams>,
|
|
333
|
+
_signal: AbortSignal | undefined,
|
|
334
|
+
_onUpdate: unknown,
|
|
335
|
+
_ctx: ExtensionContext,
|
|
336
|
+
) {
|
|
337
|
+
const apiKey = getApiKey();
|
|
338
|
+
if (!apiKey) {
|
|
339
|
+
throw createMissingApiKeyError();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const exa = new Exa(apiKey);
|
|
343
|
+
|
|
344
|
+
const contentsOptions: {
|
|
345
|
+
text?: true;
|
|
346
|
+
highlights?: true;
|
|
347
|
+
summary?: true;
|
|
348
|
+
maxCharacters?: number;
|
|
349
|
+
} = {};
|
|
350
|
+
|
|
351
|
+
const mappedContent = mapFetchContentType(params.contentType as FetchContentType | undefined);
|
|
352
|
+
if (mappedContent?.text) contentsOptions.text = true;
|
|
353
|
+
if (mappedContent?.highlights) contentsOptions.highlights = true;
|
|
354
|
+
if (mappedContent?.summary) contentsOptions.summary = true;
|
|
355
|
+
if (params.maxCharacters) {
|
|
356
|
+
contentsOptions.maxCharacters = Math.max(1000, Math.min(100000, params.maxCharacters));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let response;
|
|
360
|
+
try {
|
|
361
|
+
response = await exa.getContents(params.url, contentsOptions);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
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
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (!response.results || response.results.length === 0) {
|
|
371
|
+
return {
|
|
372
|
+
content: [{ type: "text", text: "No content found at this URL." }],
|
|
373
|
+
details: { url: params.url } as FetchDetails,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const result = response.results[0] as ExaSearchResult;
|
|
378
|
+
let output = formatFetchResult(result, (params.contentType ?? "text") as FetchContentType);
|
|
379
|
+
|
|
380
|
+
const truncation = truncateHead(output, {
|
|
381
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
382
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
let content = truncation.content;
|
|
386
|
+
|
|
387
|
+
if (truncation.truncated) {
|
|
388
|
+
content += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
389
|
+
content += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).]`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
content: [{ type: "text", text: content }],
|
|
394
|
+
details: {
|
|
395
|
+
url: params.url,
|
|
396
|
+
title: result.title,
|
|
397
|
+
} as FetchDetails,
|
|
398
|
+
};
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
renderCall(args, theme) {
|
|
402
|
+
const urlPreview = args.url.length > 40 ? args.url.slice(0, 40) + "..." : args.url;
|
|
403
|
+
const desc = args.contentType ?? "text";
|
|
404
|
+
const text =
|
|
405
|
+
theme.fg("toolTitle", theme.bold("exa_fetch ")) +
|
|
406
|
+
theme.fg("muted", urlPreview) +
|
|
407
|
+
theme.fg("dim", ` ${desc}`);
|
|
408
|
+
return new Text(text, 0, 0);
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
renderResult(result, { expanded: _expanded }, theme) {
|
|
412
|
+
const details = result.details as FetchDetails | undefined;
|
|
413
|
+
|
|
414
|
+
if (!details) {
|
|
415
|
+
const text = result.content[0];
|
|
416
|
+
return new Text(text?.type === "text" ? text.text.slice(0, 60) : "", 0, 0);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (details.title) {
|
|
420
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", details.title), 0, 0);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return new Text(theme.fg("muted", "Done"), 0, 0);
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// Register /exa-status command
|
|
428
|
+
|
|
429
|
+
pi.registerCommand("exa-status", {
|
|
430
|
+
description: "Check Exa API key configuration status",
|
|
431
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
432
|
+
const configured = !!getApiKey();
|
|
433
|
+
ctx.ui.notify(
|
|
434
|
+
configured
|
|
435
|
+
? "Exa API key: configured via EXA_API_KEY"
|
|
436
|
+
: "Exa API key: not configured. Set EXA_API_KEY environment variable.",
|
|
437
|
+
configured ? "info" : "warning",
|
|
438
|
+
);
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jarcelao/pi-exa-api",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Web search and content fetching for pi via the Exa API",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package"
|
|
7
|
+
],
|
|
8
|
+
"main": "./extensions/exa-search.ts",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "vitest",
|
|
11
|
+
"test:run": "vitest run",
|
|
12
|
+
"lint": "oxlint",
|
|
13
|
+
"lint:fix": "oxlint --fix",
|
|
14
|
+
"format": "oxfmt",
|
|
15
|
+
"format:check": "oxfmt --check"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"exa-js": "^1.1.1"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^25.5.0",
|
|
22
|
+
"oxfmt": "^0.41.0",
|
|
23
|
+
"oxlint": "^1.56.0",
|
|
24
|
+
"typescript": "^5.9.3",
|
|
25
|
+
"vitest": "^4.1.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
29
|
+
"@mariozechner/pi-tui": "*",
|
|
30
|
+
"@sinclair/typebox": "*"
|
|
31
|
+
},
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./extensions"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"types": ["node"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["*.ts"],
|
|
14
|
+
"exclude": ["node_modules"]
|
|
15
|
+
}
|