@alasano/pi-exa 0.0.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.
@@ -0,0 +1,289 @@
1
+ import {
2
+ ExaApiError,
3
+ exaDelete,
4
+ exaGet,
5
+ exaPost,
6
+ exaRawRequest,
7
+ parseResponseBody,
8
+ } from './client';
9
+ import type {
10
+ ExaAgentEvent,
11
+ ExaAgentEventListResponse,
12
+ ExaAgentRun,
13
+ ExaAgentRunListResponse,
14
+ ExaDeletedAgentRun,
15
+ JsonObject,
16
+ } from './types';
17
+ import { isRecord } from './util';
18
+
19
+ export const TERMINAL_AGENT_STATUSES = ['completed', 'failed', 'cancelled'] as const;
20
+
21
+ export interface ExaAgentCreateRequest {
22
+ query: string;
23
+ systemPrompt?: string;
24
+ input?: {
25
+ data?: JsonObject[];
26
+ exclusion?: JsonObject[];
27
+ };
28
+ outputSchema?: JsonObject;
29
+ effort?: string;
30
+ previousRunId?: string;
31
+ metadata?: Record<string, string>;
32
+ }
33
+
34
+ export interface ExaAgentPaginationParams {
35
+ limit?: number;
36
+ cursor?: string;
37
+ [key: string]: string | number | undefined;
38
+ }
39
+
40
+ export interface ExaAgentEventReplayParams {
41
+ lastEventId?: string;
42
+ }
43
+
44
+ export interface AgentPollOptions {
45
+ pollIntervalMs: number;
46
+ timeoutMs: number;
47
+ signal?: AbortSignal;
48
+ onPoll?: (run: ExaAgentRun) => void;
49
+ }
50
+
51
+ export interface AgentPollResult {
52
+ run: ExaAgentRun;
53
+ timedOut: boolean;
54
+ }
55
+
56
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
57
+ return new Promise((resolve, reject) => {
58
+ if (signal?.aborted) {
59
+ reject(signal.reason ?? new Error('Operation aborted.'));
60
+ return;
61
+ }
62
+
63
+ const timeout = setTimeout(resolve, ms);
64
+ signal?.addEventListener(
65
+ 'abort',
66
+ () => {
67
+ clearTimeout(timeout);
68
+ reject(signal.reason ?? new Error('Operation aborted.'));
69
+ },
70
+ { once: true },
71
+ );
72
+ });
73
+ }
74
+
75
+ function parseSsePart(part: string): ExaAgentEvent | undefined {
76
+ const event: Partial<ExaAgentEvent> = {};
77
+ const dataLines: string[] = [];
78
+
79
+ for (const line of part.split('\n')) {
80
+ if (!line || !line.includes(':')) continue;
81
+ const [field, ...rest] = line.split(':');
82
+ const value = rest.join(':').replace(/^ /, '');
83
+
84
+ if (field === 'id') event.id = value;
85
+ if (field === 'event') event.event = value;
86
+ if (field === 'data') dataLines.push(value);
87
+ }
88
+
89
+ if (!event.event || dataLines.length === 0) return undefined;
90
+
91
+ const data = dataLines.join('\n');
92
+ try {
93
+ const parsed = JSON.parse(data);
94
+ event.data = isRecord(parsed) ? parsed : { value: parsed };
95
+ } catch {
96
+ event.data = { value: data };
97
+ }
98
+
99
+ return event as ExaAgentEvent;
100
+ }
101
+
102
+ export function isTerminalAgentStatus(status: unknown): boolean {
103
+ return TERMINAL_AGENT_STATUSES.includes(status as (typeof TERMINAL_AGENT_STATUSES)[number]);
104
+ }
105
+
106
+ export function getRunIdFromAgentEvent(event: ExaAgentEvent): string | undefined {
107
+ if (typeof event.data.id === 'string') return event.data.id;
108
+ if (isRecord(event.data.run) && typeof event.data.run.id === 'string') return event.data.run.id;
109
+ if (typeof event.data.runId === 'string') return event.data.runId;
110
+ return undefined;
111
+ }
112
+
113
+ export async function createAgentRun(
114
+ apiKey: string,
115
+ request: ExaAgentCreateRequest,
116
+ signal?: AbortSignal,
117
+ ): Promise<ExaAgentRun> {
118
+ return exaPost<ExaAgentRun>(apiKey, '/agent/runs', request, signal);
119
+ }
120
+
121
+ export async function getAgentRun(
122
+ apiKey: string,
123
+ runId: string,
124
+ signal?: AbortSignal,
125
+ ): Promise<ExaAgentRun> {
126
+ return exaGet<ExaAgentRun>(apiKey, `/agent/runs/${encodeURIComponent(runId)}`, undefined, signal);
127
+ }
128
+
129
+ export async function listAgentRuns(
130
+ apiKey: string,
131
+ params: ExaAgentPaginationParams,
132
+ signal?: AbortSignal,
133
+ ): Promise<ExaAgentRunListResponse> {
134
+ return exaGet<ExaAgentRunListResponse>(apiKey, '/agent/runs', params, signal);
135
+ }
136
+
137
+ export async function cancelAgentRun(
138
+ apiKey: string,
139
+ runId: string,
140
+ signal?: AbortSignal,
141
+ ): Promise<ExaAgentRun> {
142
+ return exaPost<ExaAgentRun>(
143
+ apiKey,
144
+ `/agent/runs/${encodeURIComponent(runId)}/cancel`,
145
+ undefined,
146
+ signal,
147
+ );
148
+ }
149
+
150
+ export async function deleteAgentRun(
151
+ apiKey: string,
152
+ runId: string,
153
+ signal?: AbortSignal,
154
+ ): Promise<ExaDeletedAgentRun> {
155
+ return exaDelete<ExaDeletedAgentRun>(apiKey, `/agent/runs/${encodeURIComponent(runId)}`, signal);
156
+ }
157
+
158
+ export async function listAgentRunEvents(
159
+ apiKey: string,
160
+ runId: string,
161
+ params: ExaAgentPaginationParams,
162
+ signal?: AbortSignal,
163
+ ): Promise<ExaAgentEventListResponse> {
164
+ return exaGet<ExaAgentEventListResponse>(
165
+ apiKey,
166
+ `/agent/runs/${encodeURIComponent(runId)}/events`,
167
+ params,
168
+ signal,
169
+ );
170
+ }
171
+
172
+ async function* streamAgentEventsFromResponse(response: Response): AsyncGenerator<ExaAgentEvent> {
173
+ if (!response.body) throw new Error('No response body for Exa Agent event stream.');
174
+
175
+ const reader = response.body.getReader();
176
+ const decoder = new TextDecoder();
177
+ let buffer = '';
178
+ let finished = false;
179
+
180
+ try {
181
+ while (true) {
182
+ const { done, value } = await reader.read();
183
+ if (done) {
184
+ finished = true;
185
+ break;
186
+ }
187
+
188
+ buffer += decoder.decode(value, { stream: true });
189
+ const parts = buffer.split('\n\n');
190
+ buffer = parts.pop() ?? '';
191
+
192
+ for (const part of parts) {
193
+ const event = parseSsePart(part);
194
+ if (event) yield event;
195
+ }
196
+ }
197
+
198
+ if (buffer.trim()) {
199
+ const event = parseSsePart(buffer.trim());
200
+ if (event) yield event;
201
+ }
202
+ } finally {
203
+ if (!finished) await reader.cancel().catch(() => undefined);
204
+ reader.releaseLock();
205
+ }
206
+ }
207
+
208
+ export async function* streamAgentRunEvents(
209
+ apiKey: string,
210
+ request: ExaAgentCreateRequest,
211
+ signal?: AbortSignal,
212
+ ): AsyncGenerator<ExaAgentEvent> {
213
+ const response = await exaRawRequest(apiKey, '/agent/runs', {
214
+ method: 'POST',
215
+ body: request,
216
+ headers: { Accept: 'text/event-stream' },
217
+ signal,
218
+ });
219
+
220
+ if (!response.ok) {
221
+ const responseBody = await parseResponseBody(response);
222
+ throw new ExaApiError(
223
+ `Exa API request failed: ${
224
+ isRecord(responseBody) && typeof responseBody.message === 'string'
225
+ ? responseBody.message
226
+ : `${response.status} ${response.statusText}`
227
+ }`,
228
+ response.status,
229
+ responseBody,
230
+ );
231
+ }
232
+
233
+ if (!response.body) throw new Error('No response body for Exa Agent event stream.');
234
+
235
+ yield* streamAgentEventsFromResponse(response);
236
+ }
237
+
238
+ export async function* replayAgentRunEvents(
239
+ apiKey: string,
240
+ runId: string,
241
+ params: ExaAgentEventReplayParams = {},
242
+ signal?: AbortSignal,
243
+ ): AsyncGenerator<ExaAgentEvent> {
244
+ const headers: Record<string, string> = { Accept: 'text/event-stream' };
245
+ if (params.lastEventId) headers['Last-Event-ID'] = params.lastEventId;
246
+
247
+ const response = await exaRawRequest(apiKey, `/agent/runs/${encodeURIComponent(runId)}/events`, {
248
+ method: 'GET',
249
+ headers,
250
+ signal,
251
+ });
252
+
253
+ if (!response.ok) {
254
+ const responseBody = await parseResponseBody(response);
255
+ throw new ExaApiError(
256
+ `Exa API request failed: ${
257
+ isRecord(responseBody) && typeof responseBody.message === 'string'
258
+ ? responseBody.message
259
+ : `${response.status} ${response.statusText}`
260
+ }`,
261
+ response.status,
262
+ responseBody,
263
+ );
264
+ }
265
+
266
+ yield* streamAgentEventsFromResponse(response);
267
+ }
268
+
269
+ export async function pollAgentRunUntilFinished(
270
+ apiKey: string,
271
+ runId: string,
272
+ options: AgentPollOptions,
273
+ ): Promise<AgentPollResult> {
274
+ const start = Date.now();
275
+ let run = await getAgentRun(apiKey, runId, options.signal);
276
+ options.onPoll?.(run);
277
+
278
+ while (!isTerminalAgentStatus(run.status)) {
279
+ if (Date.now() - start >= options.timeoutMs) {
280
+ return { run, timedOut: true };
281
+ }
282
+
283
+ await sleep(options.pollIntervalMs, options.signal);
284
+ run = await getAgentRun(apiKey, runId, options.signal);
285
+ options.onPoll?.(run);
286
+ }
287
+
288
+ return { run, timedOut: false };
289
+ }
@@ -0,0 +1,66 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { getAgentDir, type ExtensionContext } from '@earendil-works/pi-coding-agent';
4
+ import { asString, isRecord } from './util';
5
+
6
+ const CREDENTIALS_FILENAME = 'credentials.json';
7
+
8
+ interface ExaCredentials {
9
+ apiKey?: string;
10
+ }
11
+
12
+ export function getCredentialFilePath() {
13
+ return join(getAgentDir(), 'extensions', 'pi-exa', CREDENTIALS_FILENAME);
14
+ }
15
+
16
+ export async function readCredentials(): Promise<ExaCredentials> {
17
+ try {
18
+ const raw = await fs.readFile(getCredentialFilePath(), 'utf8');
19
+ const parsed = JSON.parse(raw);
20
+ if (!isRecord(parsed)) return {};
21
+ return { apiKey: asString(parsed.apiKey) };
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
26
+
27
+ export async function writeCredentials(apiKey: string): Promise<void> {
28
+ const filePath = getCredentialFilePath();
29
+ await fs.mkdir(dirname(filePath), { recursive: true });
30
+ await fs.writeFile(filePath, JSON.stringify({ apiKey }, null, 2), { mode: 0o600 });
31
+ await fs.chmod(filePath, 0o600).catch(() => undefined);
32
+ }
33
+
34
+ export async function clearCredentials(): Promise<void> {
35
+ await fs.rm(getCredentialFilePath(), { force: true }).catch(() => undefined);
36
+ }
37
+
38
+ export async function resolveApiKey(
39
+ ctx?: ExtensionContext,
40
+ options?: { promptIfMissing?: boolean },
41
+ ): Promise<{ apiKey?: string; source: 'env' | 'credentials' | 'none' }> {
42
+ const envApiKey = asString(process.env.EXA_API_KEY);
43
+ if (envApiKey) return { apiKey: envApiKey, source: 'env' };
44
+
45
+ const credentials = await readCredentials();
46
+ if (credentials.apiKey) return { apiKey: credentials.apiKey, source: 'credentials' };
47
+
48
+ const promptIfMissing = options?.promptIfMissing ?? false;
49
+ if (promptIfMissing && ctx?.hasUI) {
50
+ const shouldSetKey = await ctx.ui.confirm(
51
+ 'Exa API key required',
52
+ 'No EXA_API_KEY env var or pi-exa credential is configured. Would you like to set one now?',
53
+ );
54
+ if (!shouldSetKey) return { source: 'none' };
55
+
56
+ const keyInput = await ctx.ui.input('Exa API key', 'exa_...');
57
+ const apiKey = asString(keyInput);
58
+ if (!apiKey) return { source: 'none' };
59
+
60
+ await writeCredentials(apiKey);
61
+ ctx.ui.notify('Exa API key saved for pi-exa', 'info');
62
+ return { apiKey, source: 'credentials' };
63
+ }
64
+
65
+ return { source: 'none' };
66
+ }
@@ -0,0 +1,146 @@
1
+ import { isRecord } from './util';
2
+
3
+ export const EXA_API_BASE_URL = 'https://api.exa.ai';
4
+
5
+ export type ExaEndpoint =
6
+ | '/search'
7
+ | '/contents'
8
+ | '/answer'
9
+ | '/agent/runs'
10
+ | `/agent/runs/${string}`;
11
+
12
+ export type ExaQueryParams = Record<string, string | number | boolean | undefined>;
13
+
14
+ export type ExaMethod = 'GET' | 'POST' | 'DELETE';
15
+
16
+ export interface ExaRequestOptions {
17
+ method?: ExaMethod;
18
+ body?: unknown;
19
+ query?: ExaQueryParams;
20
+ headers?: Record<string, string>;
21
+ signal?: AbortSignal;
22
+ }
23
+
24
+ export class ExaApiError extends Error {
25
+ constructor(
26
+ message: string,
27
+ readonly status?: number,
28
+ readonly responseBody?: unknown,
29
+ ) {
30
+ super(message);
31
+ this.name = 'ExaApiError';
32
+ }
33
+ }
34
+
35
+ export async function parseResponseBody(response: Response): Promise<unknown> {
36
+ const text = await response.text();
37
+ if (!text) return undefined;
38
+
39
+ try {
40
+ return JSON.parse(text);
41
+ } catch {
42
+ return text;
43
+ }
44
+ }
45
+
46
+ function errorMessage(body: unknown, fallback: string): string {
47
+ if (isRecord(body)) {
48
+ const message = body.message ?? body.error;
49
+ if (typeof message === 'string' && message.trim()) return message;
50
+ }
51
+ return fallback;
52
+ }
53
+
54
+ function buildUrl(endpoint: ExaEndpoint, query?: ExaQueryParams): string {
55
+ const url = new URL(`${EXA_API_BASE_URL}${endpoint}`);
56
+ for (const [key, value] of Object.entries(query || {})) {
57
+ if (value !== undefined) url.searchParams.set(key, String(value));
58
+ }
59
+ return url.toString();
60
+ }
61
+
62
+ export async function exaRawRequest(
63
+ apiKey: string,
64
+ endpoint: ExaEndpoint,
65
+ options: ExaRequestOptions = {},
66
+ ): Promise<Response> {
67
+ const headers: Record<string, string> = {
68
+ 'x-api-key': apiKey,
69
+ 'x-exa-integration': 'pi-exa',
70
+ ...options.headers,
71
+ };
72
+
73
+ let body: BodyInit | undefined;
74
+ if (options.body !== undefined) {
75
+ headers['Content-Type'] = headers['Content-Type'] ?? 'application/json';
76
+ body = JSON.stringify(options.body);
77
+ }
78
+
79
+ return fetch(buildUrl(endpoint, options.query), {
80
+ method: options.method ?? 'POST',
81
+ headers: {
82
+ ...headers,
83
+ },
84
+ body,
85
+ signal: options.signal,
86
+ });
87
+ }
88
+
89
+ export async function exaRequest<TResponse>(
90
+ apiKey: string,
91
+ endpoint: ExaEndpoint,
92
+ options: ExaRequestOptions = {},
93
+ ): Promise<TResponse> {
94
+ const response = await exaRawRequest(apiKey, endpoint, options);
95
+
96
+ const responseBody = await parseResponseBody(response);
97
+
98
+ if (!response.ok) {
99
+ throw new ExaApiError(
100
+ `Exa API request failed: ${errorMessage(responseBody, `${response.status} ${response.statusText}`)}`,
101
+ response.status,
102
+ responseBody,
103
+ );
104
+ }
105
+
106
+ return responseBody as TResponse;
107
+ }
108
+
109
+ export async function exaGet<TResponse>(
110
+ apiKey: string,
111
+ endpoint: ExaEndpoint,
112
+ query?: ExaQueryParams,
113
+ signal?: AbortSignal,
114
+ ): Promise<TResponse> {
115
+ return exaRequest<TResponse>(apiKey, endpoint, { method: 'GET', query, signal });
116
+ }
117
+
118
+ export async function exaPost<TResponse>(
119
+ apiKey: string,
120
+ endpoint: ExaEndpoint,
121
+ body: unknown,
122
+ signal?: AbortSignal,
123
+ headers?: Record<string, string>,
124
+ ): Promise<TResponse> {
125
+ return exaRequest<TResponse>(apiKey, endpoint, { method: 'POST', body, signal, headers });
126
+ }
127
+
128
+ export async function exaDelete<TResponse>(
129
+ apiKey: string,
130
+ endpoint: ExaEndpoint,
131
+ signal?: AbortSignal,
132
+ ): Promise<TResponse> {
133
+ return exaRequest<TResponse>(apiKey, endpoint, { method: 'DELETE', signal });
134
+ }
135
+
136
+ export function formatToolError(error: unknown): string {
137
+ if (error instanceof ExaApiError) {
138
+ if (error.status === 401 || error.status === 403) {
139
+ return `${error.message}\n\nSet EXA_API_KEY before starting pi or run /exa-auth set.`;
140
+ }
141
+ return error.message;
142
+ }
143
+
144
+ if (error instanceof Error) return error.message;
145
+ return String(error);
146
+ }