@jarcelao/pi-exa-api 0.2.1 → 0.3.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/README.md +3 -0
- package/extensions/api-key.ts +12 -0
- package/extensions/content-types.ts +43 -0
- package/extensions/errors.ts +12 -0
- package/extensions/formatters.ts +167 -0
- package/extensions/index.ts +66 -0
- package/extensions/temp-file.ts +19 -0
- package/extensions/tools/code-context.ts +163 -0
- package/extensions/tools/fetch.ts +154 -0
- package/extensions/tools/search.ts +148 -0
- package/extensions/types.ts +68 -0
- package/package.json +3 -3
- package/tests/api-key.test.ts +39 -0
- package/tests/content-type-mapping.test.ts +51 -0
- package/tests/extension-registration.test.ts +161 -0
- package/tests/formatting.test.ts +216 -0
- package/exa-search.test.ts +0 -851
- package/extensions/exa-search.ts +0 -637
package/README.md
CHANGED
|
@@ -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 "@mariozechner/pi-coding-agent";
|
|
6
|
+
import type { Theme } from "@mariozechner/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,66 @@
|
|
|
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 "@mariozechner/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
import { getApiKey } from "./api-key.ts";
|
|
15
|
+
import { createMissingApiKeyError } from "./errors.ts";
|
|
16
|
+
import { mapSearchContentType, mapFetchContentType } from "./content-types.ts";
|
|
17
|
+
import {
|
|
18
|
+
formatSearchResults,
|
|
19
|
+
formatFetchResult,
|
|
20
|
+
formatCodeContextResult,
|
|
21
|
+
parseCostDollars,
|
|
22
|
+
} from "./formatters.ts";
|
|
23
|
+
|
|
24
|
+
import { createExaSearchTool } from "./tools/search.ts";
|
|
25
|
+
import { createExaFetchTool } from "./tools/fetch.ts";
|
|
26
|
+
import { createExaCodeContextTool } from "./tools/code-context.ts";
|
|
27
|
+
|
|
28
|
+
// Re-export all utilities for testing
|
|
29
|
+
export {
|
|
30
|
+
getApiKey,
|
|
31
|
+
createMissingApiKeyError,
|
|
32
|
+
mapSearchContentType,
|
|
33
|
+
mapFetchContentType,
|
|
34
|
+
formatSearchResults,
|
|
35
|
+
formatFetchResult,
|
|
36
|
+
formatCodeContextResult,
|
|
37
|
+
parseCostDollars,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default function exaSearchExtension(pi: ExtensionAPI): void {
|
|
41
|
+
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
42
|
+
const hasKey = !!getApiKey();
|
|
43
|
+
if (!hasKey) {
|
|
44
|
+
ctx.ui.notify("Exa API key not configured. Set EXA_API_KEY to enable search.", "warning");
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Register tools
|
|
49
|
+
pi.registerTool(createExaSearchTool());
|
|
50
|
+
pi.registerTool(createExaFetchTool());
|
|
51
|
+
pi.registerTool(createExaCodeContextTool());
|
|
52
|
+
|
|
53
|
+
// Register /exa-status command
|
|
54
|
+
pi.registerCommand("exa-status", {
|
|
55
|
+
description: "Check Exa API key configuration status",
|
|
56
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
57
|
+
const configured = !!getApiKey();
|
|
58
|
+
ctx.ui.notify(
|
|
59
|
+
configured
|
|
60
|
+
? "Exa API key: configured via EXA_API_KEY"
|
|
61
|
+
: "Exa API key: not configured. Set EXA_API_KEY environment variable.",
|
|
62
|
+
configured ? "info" : "warning",
|
|
63
|
+
);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temp file utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Write content to a temporary file.
|
|
11
|
+
* @param content - The content to write
|
|
12
|
+
* @returns The path to the created temporary file
|
|
13
|
+
*/
|
|
14
|
+
export async function writeTempFile(content: string): Promise<string> {
|
|
15
|
+
const tempDir = await mkdtemp(join(tmpdir(), "pi-exa-"));
|
|
16
|
+
const tempFile = join(tempDir, "output.txt");
|
|
17
|
+
await writeFile(tempFile, content, "utf8");
|
|
18
|
+
return tempFile;
|
|
19
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exa Code Context tool definition
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_MAX_BYTES,
|
|
11
|
+
DEFAULT_MAX_LINES,
|
|
12
|
+
defineTool,
|
|
13
|
+
formatSize,
|
|
14
|
+
truncateHead,
|
|
15
|
+
} from "@mariozechner/pi-coding-agent";
|
|
16
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
17
|
+
import { Type, type Static } from "typebox";
|
|
18
|
+
|
|
19
|
+
import { getApiKey } from "../api-key.ts";
|
|
20
|
+
import { createMissingApiKeyError } from "../errors.ts";
|
|
21
|
+
import {
|
|
22
|
+
formatCodeContextResult,
|
|
23
|
+
formatToolOutputPreview,
|
|
24
|
+
parseCostDollars,
|
|
25
|
+
} from "../formatters.ts";
|
|
26
|
+
import type { CodeContextDetails, CodeContextResponse } from "../types.ts";
|
|
27
|
+
|
|
28
|
+
// Tool parameter schema
|
|
29
|
+
export const ExaCodeContextParams = Type.Object({
|
|
30
|
+
query: Type.String({
|
|
31
|
+
description: "Search query to find relevant code snippets and examples",
|
|
32
|
+
}),
|
|
33
|
+
tokensNum: Type.Optional(
|
|
34
|
+
Type.Union([
|
|
35
|
+
Type.String({
|
|
36
|
+
description: 'Token limit: "dynamic" for automatic sizing',
|
|
37
|
+
}),
|
|
38
|
+
Type.Number({
|
|
39
|
+
description: "Token limit: 50-100000 (5000 is a good default)",
|
|
40
|
+
}),
|
|
41
|
+
]),
|
|
42
|
+
),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
type CodeContextParams = Static<typeof ExaCodeContextParams>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create the exa_code_context tool definition.
|
|
49
|
+
*/
|
|
50
|
+
export function createExaCodeContextTool() {
|
|
51
|
+
return defineTool({
|
|
52
|
+
name: "exa_code_context",
|
|
53
|
+
label: "Exa Code Context",
|
|
54
|
+
description:
|
|
55
|
+
"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.",
|
|
56
|
+
parameters: ExaCodeContextParams,
|
|
57
|
+
|
|
58
|
+
async execute(
|
|
59
|
+
_toolCallId: string,
|
|
60
|
+
params: CodeContextParams,
|
|
61
|
+
_signal: AbortSignal | undefined,
|
|
62
|
+
_onUpdate: unknown,
|
|
63
|
+
_ctx: unknown,
|
|
64
|
+
) {
|
|
65
|
+
const apiKey = getApiKey();
|
|
66
|
+
if (!apiKey) {
|
|
67
|
+
throw createMissingApiKeyError();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Ensure tokensNum is the correct type: number or "dynamic"
|
|
71
|
+
// The schema accepts both string and number, but the Exa API requires:
|
|
72
|
+
// - A number (e.g., 5000)
|
|
73
|
+
// - The literal string "dynamic"
|
|
74
|
+
let tokensNum: string | number = params.tokensNum ?? "dynamic";
|
|
75
|
+
if (typeof tokensNum === "string" && tokensNum !== "dynamic") {
|
|
76
|
+
tokensNum = Number(tokensNum);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let response: CodeContextResponse;
|
|
80
|
+
try {
|
|
81
|
+
const httpResponse = await fetch("https://api.exa.ai/context", {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: {
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
"x-api-key": apiKey,
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
query: params.query,
|
|
89
|
+
tokensNum,
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!httpResponse.ok) {
|
|
94
|
+
const errorText = await httpResponse.text();
|
|
95
|
+
throw new Error(`HTTP ${httpResponse.status}: ${errorText}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
response = (await httpResponse.json()) as CodeContextResponse;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
101
|
+
throw new Error(`Exa Context API error: ${message}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let output = formatCodeContextResult(response);
|
|
105
|
+
|
|
106
|
+
const truncation = truncateHead(output, {
|
|
107
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
108
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
let result = truncation.content;
|
|
112
|
+
|
|
113
|
+
if (truncation.truncated) {
|
|
114
|
+
const tempDir = await mkdtemp(join(tmpdir(), "pi-exa-"));
|
|
115
|
+
const tempFile = join(tempDir, "output.txt");
|
|
116
|
+
await writeFile(tempFile, output, "utf8");
|
|
117
|
+
result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
118
|
+
result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
119
|
+
result += ` Full output saved to: ${tempFile}]`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const cost = parseCostDollars(response.costDollars);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
content: [{ type: "text", text: result }],
|
|
126
|
+
details: {
|
|
127
|
+
query: params.query,
|
|
128
|
+
resultsCount: response.resultsCount,
|
|
129
|
+
outputTokens: response.outputTokens,
|
|
130
|
+
cost,
|
|
131
|
+
} as CodeContextDetails,
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
renderCall(args: CodeContextParams, theme: Theme) {
|
|
136
|
+
const preview = args.query.length > 50 ? args.query.slice(0, 50) + "..." : args.query;
|
|
137
|
+
const desc = `${args.tokensNum ?? "dynamic"} tokens`;
|
|
138
|
+
const text =
|
|
139
|
+
theme.fg("toolTitle", theme.bold("exa_code_context ")) +
|
|
140
|
+
theme.fg("muted", preview) +
|
|
141
|
+
theme.fg("dim", ` ${desc}`);
|
|
142
|
+
return new Text(text, 0, 0);
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
renderResult(result, options, theme, context) {
|
|
146
|
+
const details = result.details as CodeContextDetails | undefined;
|
|
147
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
148
|
+
|
|
149
|
+
let header = "";
|
|
150
|
+
if (details) {
|
|
151
|
+
const cost = details.cost ? ` • $${details.cost.total.toFixed(6)}` : "";
|
|
152
|
+
header = theme.fg(
|
|
153
|
+
"success",
|
|
154
|
+
`✓ ${details.resultsCount} sources • ${details.outputTokens} tokens${cost}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const preview = formatToolOutputPreview(result, options, theme);
|
|
159
|
+
text.setText(preview ? `${header}\n${preview}` : header);
|
|
160
|
+
return text;
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exa Fetch tool definition
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_MAX_BYTES,
|
|
11
|
+
DEFAULT_MAX_LINES,
|
|
12
|
+
defineTool,
|
|
13
|
+
formatSize,
|
|
14
|
+
truncateHead,
|
|
15
|
+
} from "@mariozechner/pi-coding-agent";
|
|
16
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
17
|
+
import { Type, type Static } from "typebox";
|
|
18
|
+
import Exa from "exa-js";
|
|
19
|
+
|
|
20
|
+
import { getApiKey } from "../api-key.ts";
|
|
21
|
+
import { createMissingApiKeyError } from "../errors.ts";
|
|
22
|
+
import { mapFetchContentType } from "../content-types.ts";
|
|
23
|
+
import { formatFetchResult, formatToolOutputPreview } from "../formatters.ts";
|
|
24
|
+
import type { FetchContentType, FetchDetails, ExaSearchResult } from "../types.ts";
|
|
25
|
+
|
|
26
|
+
// Tool parameter schema
|
|
27
|
+
export const ExaFetchParams = Type.Object({
|
|
28
|
+
url: Type.String({
|
|
29
|
+
description: "URL to fetch content from",
|
|
30
|
+
}),
|
|
31
|
+
contentType: Type.Optional(
|
|
32
|
+
Type.Union([Type.Literal("text"), Type.Literal("highlights"), Type.Literal("summary")]),
|
|
33
|
+
),
|
|
34
|
+
maxCharacters: Type.Optional(
|
|
35
|
+
Type.Number({
|
|
36
|
+
description: "Maximum characters to return",
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
type FetchParams = Static<typeof ExaFetchParams>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create the exa_fetch tool definition.
|
|
45
|
+
*/
|
|
46
|
+
export function createExaFetchTool() {
|
|
47
|
+
return defineTool({
|
|
48
|
+
name: "exa_fetch",
|
|
49
|
+
label: "Exa Fetch",
|
|
50
|
+
description:
|
|
51
|
+
"Fetch and extract content from a specific URL using Exa. Can return full text, highlights, or AI-generated summary.",
|
|
52
|
+
parameters: ExaFetchParams,
|
|
53
|
+
|
|
54
|
+
async execute(
|
|
55
|
+
_toolCallId: string,
|
|
56
|
+
params: FetchParams,
|
|
57
|
+
_signal: AbortSignal | undefined,
|
|
58
|
+
_onUpdate: unknown,
|
|
59
|
+
_ctx: ExtensionContext,
|
|
60
|
+
) {
|
|
61
|
+
const apiKey = getApiKey();
|
|
62
|
+
if (!apiKey) {
|
|
63
|
+
throw createMissingApiKeyError();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const exa = new Exa(apiKey);
|
|
67
|
+
|
|
68
|
+
const contentsOptions: {
|
|
69
|
+
text?: true;
|
|
70
|
+
highlights?: true;
|
|
71
|
+
summary?: true;
|
|
72
|
+
maxCharacters?: number;
|
|
73
|
+
} = {};
|
|
74
|
+
|
|
75
|
+
const mappedContent = mapFetchContentType(params.contentType as FetchContentType | undefined);
|
|
76
|
+
if (mappedContent?.text) contentsOptions.text = true;
|
|
77
|
+
if (mappedContent?.highlights) contentsOptions.highlights = true;
|
|
78
|
+
if (mappedContent?.summary) contentsOptions.summary = true;
|
|
79
|
+
if (params.maxCharacters) {
|
|
80
|
+
contentsOptions.maxCharacters = Math.max(1000, Math.min(100000, params.maxCharacters));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let response;
|
|
84
|
+
try {
|
|
85
|
+
response = await exa.getContents(params.url, contentsOptions);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
+
throw new Error(`Exa API error: ${message}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!response.results || response.results.length === 0) {
|
|
92
|
+
return {
|
|
93
|
+
content: [{ type: "text", text: "No content found at this URL." }],
|
|
94
|
+
details: { url: params.url, cost: response.costDollars } as FetchDetails,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const result = response.results[0] as ExaSearchResult;
|
|
99
|
+
let output = formatFetchResult(result, (params.contentType ?? "text") as FetchContentType);
|
|
100
|
+
|
|
101
|
+
const truncation = truncateHead(output, {
|
|
102
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
103
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
let content = truncation.content;
|
|
107
|
+
|
|
108
|
+
if (truncation.truncated) {
|
|
109
|
+
const tempDir = await mkdtemp(join(tmpdir(), "pi-exa-"));
|
|
110
|
+
const tempFile = join(tempDir, "output.txt");
|
|
111
|
+
await writeFile(tempFile, output, "utf8");
|
|
112
|
+
content += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
113
|
+
content += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
114
|
+
content += ` Full output saved to: ${tempFile}]`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
content: [{ type: "text", text: content }],
|
|
119
|
+
details: {
|
|
120
|
+
url: params.url,
|
|
121
|
+
title: result.title,
|
|
122
|
+
cost: response.costDollars,
|
|
123
|
+
} as FetchDetails,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
renderCall(args: FetchParams, theme: Theme) {
|
|
128
|
+
const urlPreview = args.url.length > 40 ? args.url.slice(0, 40) + "..." : args.url;
|
|
129
|
+
const desc = args.contentType ?? "text";
|
|
130
|
+
const text =
|
|
131
|
+
theme.fg("toolTitle", theme.bold("exa_fetch ")) +
|
|
132
|
+
theme.fg("muted", urlPreview) +
|
|
133
|
+
theme.fg("dim", ` ${desc}`);
|
|
134
|
+
return new Text(text, 0, 0);
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
renderResult(result, options, theme, context) {
|
|
138
|
+
const details = result.details as FetchDetails | undefined;
|
|
139
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
140
|
+
|
|
141
|
+
let header = "";
|
|
142
|
+
if (details) {
|
|
143
|
+
const cost = details.cost ? ` • $${details.cost.total.toFixed(6)}` : "";
|
|
144
|
+
header = details.title
|
|
145
|
+
? theme.fg("success", `✓ ${details.title}${cost}`)
|
|
146
|
+
: theme.fg("success", `✓ Fetched${cost}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const preview = formatToolOutputPreview(result, options, theme);
|
|
150
|
+
text.setText(preview ? `${header}\n${preview}` : header);
|
|
151
|
+
return text;
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|