@jarcelao/pi-exa-api 0.2.2 → 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.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Exa Search 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 { mapSearchContentType } from "../content-types.ts";
23
+ import { formatSearchResults, formatToolOutputPreview } from "../formatters.ts";
24
+ import type { SearchContentType, SearchDetails, ExaSearchResult } from "../types.ts";
25
+
26
+ // Tool parameter schema
27
+ export const ExaSearchParams = Type.Object({
28
+ query: Type.String({
29
+ description: "Natural language search query",
30
+ }),
31
+ contentType: Type.Optional(
32
+ Type.Union([
33
+ Type.Literal("text"),
34
+ Type.Literal("highlights"),
35
+ Type.Literal("summary"),
36
+ Type.Literal("none"),
37
+ ]),
38
+ ),
39
+ numResults: Type.Optional(
40
+ Type.Number({
41
+ description: "Number of results (1-100)",
42
+ }),
43
+ ),
44
+ });
45
+
46
+ type SearchParams = Static<typeof ExaSearchParams>;
47
+
48
+ /**
49
+ * Create the exa_search tool definition.
50
+ */
51
+ export function createExaSearchTool() {
52
+ return defineTool({
53
+ name: "exa_search",
54
+ label: "Exa Search",
55
+ description:
56
+ "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.",
57
+ parameters: ExaSearchParams,
58
+
59
+ async execute(
60
+ _toolCallId: string,
61
+ params: SearchParams,
62
+ _signal: AbortSignal | undefined,
63
+ _onUpdate: unknown,
64
+ _ctx: ExtensionContext,
65
+ ) {
66
+ const apiKey = getApiKey();
67
+ if (!apiKey) {
68
+ throw createMissingApiKeyError();
69
+ }
70
+
71
+ const numResults = Math.max(1, Math.min(100, params.numResults ?? 10));
72
+ const exa = new Exa(apiKey);
73
+
74
+ const contents = mapSearchContentType(params.contentType as SearchContentType | undefined);
75
+ const searchOptions: {
76
+ numResults: number;
77
+ contents?: { text?: true; highlights?: true; summary?: true };
78
+ } = { numResults };
79
+
80
+ if (contents) {
81
+ searchOptions.contents = contents;
82
+ }
83
+
84
+ let response;
85
+ try {
86
+ response = await exa.search(params.query, searchOptions);
87
+ } catch (error) {
88
+ const message = error instanceof Error ? error.message : String(error);
89
+ throw new Error(`Exa API error: ${message}`);
90
+ }
91
+
92
+ let output = formatSearchResults({
93
+ results: response.results as ExaSearchResult[],
94
+ costDollars: response.costDollars as { total: number } | undefined,
95
+ });
96
+
97
+ const truncation = truncateHead(output, {
98
+ maxLines: DEFAULT_MAX_LINES,
99
+ maxBytes: DEFAULT_MAX_BYTES,
100
+ });
101
+
102
+ let result = truncation.content;
103
+
104
+ if (truncation.truncated) {
105
+ const tempDir = await mkdtemp(join(tmpdir(), "pi-exa-"));
106
+ const tempFile = join(tempDir, "output.txt");
107
+ await writeFile(tempFile, output, "utf8");
108
+ result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
109
+ result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
110
+ result += ` Full output saved to: ${tempFile}]`;
111
+ }
112
+
113
+ return {
114
+ content: [{ type: "text", text: result }],
115
+ details: {
116
+ query: params.query,
117
+ numResults: response.results.length,
118
+ cost: response.costDollars,
119
+ } as SearchDetails,
120
+ };
121
+ },
122
+
123
+ renderCall(args: SearchParams, theme: Theme) {
124
+ const preview = args.query.length > 50 ? args.query.slice(0, 50) + "..." : args.query;
125
+ const desc = `${args.numResults ?? 10} results • ${args.contentType ?? "highlights"}`;
126
+ const text =
127
+ theme.fg("toolTitle", theme.bold("exa_search ")) +
128
+ theme.fg("muted", preview) +
129
+ theme.fg("dim", ` ${desc}`);
130
+ return new Text(text, 0, 0);
131
+ },
132
+
133
+ renderResult(result, options, theme, context) {
134
+ const details = result.details as SearchDetails | undefined;
135
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
136
+
137
+ let header = "";
138
+ if (details) {
139
+ const cost = details.cost ? ` • $${details.cost.total.toFixed(6)}` : "";
140
+ header = theme.fg("success", `✓ ${details.numResults} results${cost}`);
141
+ }
142
+
143
+ const preview = formatToolOutputPreview(result, options, theme);
144
+ text.setText(preview ? `${header}\n${preview}` : header);
145
+ return text;
146
+ },
147
+ });
148
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Type definitions for Exa API extension
3
+ */
4
+
5
+ // Content type options
6
+ export type SearchContentType = "text" | "highlights" | "summary" | "none";
7
+ export type FetchContentType = "text" | "highlights" | "summary";
8
+
9
+ // Search result types
10
+ export interface ExaSearchResult {
11
+ title: string;
12
+ url: string;
13
+ publishedDate?: string | null;
14
+ author?: string | null;
15
+ text?: string;
16
+ highlights?: string[];
17
+ summary?: string;
18
+ }
19
+
20
+ export interface ExaSearchResponse {
21
+ results: ExaSearchResult[];
22
+ costDollars?: { total: number };
23
+ }
24
+
25
+ // Tool detail types
26
+ export interface SearchDetails {
27
+ query: string;
28
+ numResults: number;
29
+ cost?: { total: number };
30
+ }
31
+
32
+ export interface FetchDetails {
33
+ url: string;
34
+ title?: string;
35
+ cost?: { total: number };
36
+ }
37
+
38
+ export interface CodeContextDetails {
39
+ query: string;
40
+ resultsCount: number;
41
+ outputTokens: number;
42
+ cost?: { total: number };
43
+ }
44
+
45
+ // Code context API response type
46
+ export interface CodeContextResponse {
47
+ requestId: string;
48
+ query: string;
49
+ response: string;
50
+ resultsCount: number;
51
+ costDollars: string | { total: number };
52
+ searchTime: number;
53
+ outputTokens: number;
54
+ }
55
+
56
+ // Exa search options type
57
+ export interface SearchOptions {
58
+ numResults: number;
59
+ contents?: { text?: true; highlights?: true; summary?: true };
60
+ }
61
+
62
+ // Exa getContents options type
63
+ export interface GetContentsOptions {
64
+ text?: true;
65
+ highlights?: true;
66
+ summary?: true;
67
+ maxCharacters?: number;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jarcelao/pi-exa-api",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Web search and content fetching for pi via the Exa API",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -12,7 +12,7 @@
12
12
  "type": "git",
13
13
  "url": "git+https://github.com/jarcelao/pi-exa-api.git"
14
14
  },
15
- "main": "./extensions/exa-search.ts",
15
+ "main": "./extensions/index.ts",
16
16
  "scripts": {
17
17
  "test": "vitest",
18
18
  "test:run": "vitest run",
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { getApiKey, createMissingApiKeyError } from "../extensions/index.ts";
3
+
4
+ describe("API Key Management", () => {
5
+ afterEach(() => {
6
+ delete process.env.EXA_API_KEY;
7
+ });
8
+
9
+ it("should return undefined when EXA_API_KEY is not set", () => {
10
+ delete process.env.EXA_API_KEY;
11
+ expect(getApiKey()).toBeUndefined();
12
+ });
13
+
14
+ it("should return API key when EXA_API_KEY is set", () => {
15
+ const testKey = "test-api-key-123";
16
+ process.env.EXA_API_KEY = testKey;
17
+ expect(getApiKey()).toBe(testKey);
18
+ });
19
+
20
+ it("should treat empty string as not configured", () => {
21
+ process.env.EXA_API_KEY = "";
22
+ expect(getApiKey()).toBeUndefined();
23
+ });
24
+
25
+ it("should throw descriptive error when API key is missing", () => {
26
+ const error = createMissingApiKeyError();
27
+ expect(error.message).toContain("EXA_API_KEY");
28
+ expect(error.message).toContain("environment variable");
29
+ });
30
+ });
31
+
32
+ describe("Error Handling", () => {
33
+ it("should create descriptive missing API key error", () => {
34
+ const error = createMissingApiKeyError();
35
+ expect(error.message).toContain("Exa API key");
36
+ expect(error.message).toContain("EXA_API_KEY");
37
+ expect(error.message).toContain("environment variable");
38
+ });
39
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mapSearchContentType, mapFetchContentType } from "../extensions/index.ts";
3
+
4
+ describe("ContentType Mapping (Search)", () => {
5
+ it('should map "text" to contents.text', () => {
6
+ const result = mapSearchContentType("text");
7
+ expect(result).toEqual({ text: true });
8
+ });
9
+
10
+ it('should map "highlights" to contents.highlights', () => {
11
+ const result = mapSearchContentType("highlights");
12
+ expect(result).toEqual({ highlights: true });
13
+ });
14
+
15
+ it('should map "summary" to contents.summary', () => {
16
+ const result = mapSearchContentType("summary");
17
+ expect(result).toEqual({ summary: true });
18
+ });
19
+
20
+ it('should map "none" to undefined (metadata only)', () => {
21
+ const result = mapSearchContentType("none");
22
+ expect(result).toBeUndefined();
23
+ });
24
+
25
+ it("should default to highlights when not specified", () => {
26
+ const result = mapSearchContentType(undefined);
27
+ expect(result).toEqual({ highlights: true });
28
+ });
29
+ });
30
+
31
+ describe("ContentType Mapping (Fetch)", () => {
32
+ it('should map "text" to contents.text', () => {
33
+ const result = mapFetchContentType("text");
34
+ expect(result).toEqual({ text: true });
35
+ });
36
+
37
+ it('should map "highlights" to contents.highlights', () => {
38
+ const result = mapFetchContentType("highlights");
39
+ expect(result).toEqual({ highlights: true });
40
+ });
41
+
42
+ it('should map "summary" to contents.summary', () => {
43
+ const result = mapFetchContentType("summary");
44
+ expect(result).toEqual({ summary: true });
45
+ });
46
+
47
+ it("should default to text for fetch when not specified", () => {
48
+ const result = mapFetchContentType(undefined);
49
+ expect(result).toEqual({ text: true });
50
+ });
51
+ });
@@ -0,0 +1,161 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import exaSearchExtension from "../extensions/index.ts";
3
+
4
+ function createMockExtensionAPI() {
5
+ const tools: unknown[] = [];
6
+ const commands: Map<string, unknown> = new Map();
7
+ const eventHandlers: Map<string, unknown[]> = new Map();
8
+
9
+ return {
10
+ registerTool: (tool: unknown) => tools.push(tool),
11
+ registerCommand: (name: string, command: unknown) => commands.set(name, command),
12
+ on: (event: string, handler: unknown) => {
13
+ const handlers = eventHandlers.get(event) || [];
14
+ handlers.push(handler);
15
+ eventHandlers.set(event, handlers);
16
+ },
17
+ getTools: () => tools,
18
+ getCommand: (name: string) => commands.get(name),
19
+ getEventHandlers: (event: string) => eventHandlers.get(event) || [],
20
+ findTool: (name: string) => tools.find((t: unknown) => (t as { name: string }).name === name),
21
+ };
22
+ }
23
+
24
+ function setup() {
25
+ const api = createMockExtensionAPI();
26
+ exaSearchExtension(api as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI);
27
+ return api;
28
+ }
29
+
30
+ const mockTheme = { fg: (_name: string, text: string) => text };
31
+
32
+ describe("Extension Registration", () => {
33
+ it("should register all three tools with correct names and labels", () => {
34
+ const api = setup();
35
+ const tools = api.getTools();
36
+ expect(tools).toHaveLength(3);
37
+
38
+ const expected = [
39
+ { name: "exa_search", label: "Exa Search" },
40
+ { name: "exa_fetch", label: "Exa Fetch" },
41
+ { name: "exa_code_context", label: "Exa Code Context" },
42
+ ];
43
+
44
+ for (const { name, label } of expected) {
45
+ const tool = api.findTool(name) as { name: string; label: string; execute: unknown };
46
+ expect(tool).toBeDefined();
47
+ expect(tool.name).toBe(name);
48
+ expect(tool.label).toBe(label);
49
+ expect(typeof tool.execute).toBe("function");
50
+ }
51
+ });
52
+
53
+ it("should register /exa-status command", () => {
54
+ const api = setup();
55
+ const cmd = api.getCommand("exa-status") as { description: string; handler: Function };
56
+ expect(cmd).toBeDefined();
57
+ expect(cmd.description).toContain("API key");
58
+ expect(typeof cmd.handler).toBe("function");
59
+ });
60
+
61
+ it("should register session_start event handler", () => {
62
+ const api = setup();
63
+ expect(api.getEventHandlers("session_start")).toHaveLength(1);
64
+ });
65
+ });
66
+
67
+ describe("exa_fetch renderResult", () => {
68
+ it("should display title and cost when both present", () => {
69
+ const api = setup();
70
+ const tool = api.findTool("exa_fetch") as { renderResult: Function };
71
+ const rendered = tool.renderResult(
72
+ {
73
+ content: [],
74
+ details: { url: "https://example.com", title: "Test Page", cost: { total: 0.000123 } },
75
+ },
76
+ { expanded: false, isPartial: false },
77
+ mockTheme,
78
+ { lastComponent: undefined },
79
+ );
80
+ expect(rendered.text).toContain("Test Page");
81
+ expect(rendered.text).toContain("$0.000123");
82
+ });
83
+
84
+ it("should display Fetched when no title", () => {
85
+ const api = setup();
86
+ const tool = api.findTool("exa_fetch") as { renderResult: Function };
87
+ const rendered = tool.renderResult(
88
+ { content: [], details: { url: "https://example.com", cost: { total: 0.000456 } } },
89
+ { expanded: false, isPartial: false },
90
+ mockTheme,
91
+ { lastComponent: undefined },
92
+ );
93
+ expect(rendered.text).toContain("Fetched");
94
+ expect(rendered.text).toContain("$0.000456");
95
+ });
96
+
97
+ it("should display empty string when no details", () => {
98
+ const api = setup();
99
+ const tool = api.findTool("exa_fetch") as { renderResult: Function };
100
+ const rendered = tool.renderResult(
101
+ { content: [] },
102
+ { expanded: false, isPartial: false },
103
+ mockTheme,
104
+ { lastComponent: undefined },
105
+ );
106
+ expect(rendered.text).toBe("");
107
+ });
108
+ });
109
+
110
+ describe("exa_code_context renderResult", () => {
111
+ it("should display stats and cost when details present", () => {
112
+ const api = setup();
113
+ const tool = api.findTool("exa_code_context") as { renderResult: Function };
114
+ const rendered = tool.renderResult(
115
+ {
116
+ content: [],
117
+ details: {
118
+ query: "React hooks",
119
+ resultsCount: 502,
120
+ outputTokens: 4805,
121
+ cost: { total: 1.0 },
122
+ },
123
+ },
124
+ { expanded: false, isPartial: false },
125
+ mockTheme,
126
+ { lastComponent: undefined },
127
+ );
128
+ expect(rendered.text).toContain("502 sources");
129
+ expect(rendered.text).toContain("4805 tokens");
130
+ expect(rendered.text).toContain("$1.000000");
131
+ });
132
+
133
+ it("should display stats without cost when cost missing", () => {
134
+ const api = setup();
135
+ const tool = api.findTool("exa_code_context") as { renderResult: Function };
136
+ const rendered = tool.renderResult(
137
+ {
138
+ content: [],
139
+ details: { query: "Express middleware", resultsCount: 100, outputTokens: 2000 },
140
+ },
141
+ { expanded: false, isPartial: false },
142
+ mockTheme,
143
+ { lastComponent: undefined },
144
+ );
145
+ expect(rendered.text).toContain("100 sources");
146
+ expect(rendered.text).toContain("2000 tokens");
147
+ expect(rendered.text).not.toContain("$");
148
+ });
149
+
150
+ it("should fall back to content text when no details", () => {
151
+ const api = setup();
152
+ const tool = api.findTool("exa_code_context") as { renderResult: Function };
153
+ const rendered = tool.renderResult(
154
+ { content: [{ type: "text", text: "Some code context output here" }] },
155
+ { expanded: false, isPartial: false },
156
+ mockTheme,
157
+ { lastComponent: undefined },
158
+ );
159
+ expect(rendered.text).toContain("Some code context");
160
+ });
161
+ });