@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.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # @alasano/pi-exa
2
+
3
+ <p align="center">
4
+ <img src="assets/screenshot.png" alt="pi-exa settings overlay" />
5
+ </p>
6
+
7
+ <p align="center"><em>Configure Exa tools from the settings overlay.</em></p>
8
+
9
+ ---
10
+
11
+ <p align="center">
12
+ <img src="assets/web_search_exa.png" alt="web_search_exa result preview" />
13
+ </p>
14
+
15
+ <p align="center"><em>Readable web search previews keep the UI clean while full tool output remains available to the agent.</em></p>
16
+
17
+ [Exa](https://exa.ai)-powered web search, content retrieval, answers, and agentic search tools for [pi](https://pi.dev). This package has parity with the official Exa MCP server's current search and content-retrieval tools, then adds two Pi-specific capabilities: Exa's Answer API and the newly announced Exa Agent API for long-running research workflows.
18
+
19
+ It also ships a bundled `exa-search` skill that guides the agent on when to search, when to fetch, when to use Answer, and when to escalate to Exa Agent without flooding context.
20
+
21
+ ## Prerequisites
22
+
23
+ You need an [Exa API key](https://dashboard.exa.ai/api-keys).
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pi install npm:@alasano/pi-exa
29
+ ```
30
+
31
+ ## Authentication
32
+
33
+ Set `EXA_API_KEY` in the environment before starting pi, or run:
34
+
35
+ ```sh
36
+ /exa-auth set
37
+ ```
38
+
39
+ The command stores credentials under the pi agent directory for this package only.
40
+
41
+ ## Settings
42
+
43
+ Run `/exa-settings` to open the tool settings overlay. Search, advanced search, fetch, and answer can be toggled individually. Agent lifecycle tools are toggled as one group so create/get/list/cancel/delete/events stay consistent.
44
+
45
+ Disabled tools are removed from the agent's active tool list immediately for the next turn.
46
+
47
+ ## Skill
48
+
49
+ The package includes the `exa-search` skill. It guides the agent to:
50
+
51
+ - start with lightweight search for source discovery
52
+ - use advanced search only when filters or content controls matter
53
+ - fetch selected URLs instead of dumping broad page text
54
+ - use Answer for concise sourced answers
55
+ - use the newly announced Exa Agent API for deeper multi-hop or long-running async research workflows
56
+ - retrieve the full stored Agent result after compact background completion notices
57
+
58
+ ## Workflow
59
+
60
+ Use the tools as a pipeline:
61
+
62
+ 1. `web_search_exa` for normal source discovery.
63
+ 2. `web_search_advanced_exa` when domains, dates, categories, freshness, summaries, highlights, subpages, or extracted text matter.
64
+ 3. `web_fetch_exa` for selected URLs that need full markdown content.
65
+ 4. `web_answer_exa` when a direct answer with sources is enough.
66
+ 5. `web_agent_exa` when the task is bigger than search: long-running research, multi-hop source gathering, or structured output across many sources.
67
+
68
+ Foreground Agent runs return the full Agent result to the agent. Background Agent runs return promptly, show a persistent status panel, and send a compact completion notice that tells the agent to call `web_agent_get_exa` before using detailed findings.
69
+
70
+ ## Tools (10)
71
+
72
+ ### Search, Content, And Answers
73
+
74
+ | Tool | Description |
75
+ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
76
+ | `web_search_exa` | Search the web with compact, highlight-first results for normal source discovery. |
77
+ | `web_search_advanced_exa` | Search with filters and content controls: domains, dates, categories, freshness, summaries, highlights, subpages, text extraction, and response context. |
78
+ | `web_fetch_exa` | Retrieve clean markdown/text content from selected known URLs. Defaults to bounded content so fetches stay focused. |
79
+ | `web_answer_exa` | Ask Exa's Answer API for a direct sourced answer when a result list would be too much. |
80
+
81
+ ### Exa Agent
82
+
83
+ `web_agent_exa` is the main Agent tool. Use it for high-value async workflows that need more than search/fetch/answer: long-running research, structured JSON output, or multi-hop source gathering.
84
+
85
+ | Tool | Role |
86
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------- |
87
+ | `web_agent_exa` | Create an Agent run. Supports foreground wait mode and background tracking. |
88
+ | `web_agent_get_exa` | Retrieve the full stored Agent result, including text, structured output, citation grounding, usage, and cost. |
89
+ | `web_agent_list_exa` | List recent Agent runs to find IDs or inspect statuses. |
90
+ | `web_agent_cancel_exa` | Cancel a queued or running Agent run. |
91
+ | `web_agent_delete_exa` | Delete a stored Agent run when explicitly requested. |
92
+ | `web_agent_events_exa` | Inspect stored lifecycle events or replay the event stream for debugging/progress history. |
93
+
94
+ ## Agent Modes
95
+
96
+ | Mode | Behavior |
97
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
98
+ | `mode: "wait", monitor: "stream"` | Foreground run with server-sent lifecycle events. Best default when the user needs the result in the current turn. |
99
+ | `mode: "wait", monitor: "poll"` | Foreground run using `GET /agent/runs/{id}` polling. Useful if streaming is undesirable. |
100
+ | `mode: "background"` | Returns the run ID immediately, tracks the run in a pi widget, and sends a compact follow-up when it finishes. `monitor` is ignored in background mode. |
101
+
102
+ ## Output And Context Discipline
103
+
104
+ - Search defaults to compact highlights rather than full page text.
105
+ - Advanced search returns extracted text by default; use `textMaxCharacters` for broad queries.
106
+ - Fetch is for selected known URLs, not broad discovery.
107
+ - Agent foreground/get results return full structured output and citation grounding to the agent.
108
+ - Background completion notices stay compact and instruct the agent to retrieve the full result before answering in detail.
109
+ - Ctrl+O in the UI exposes expanded previews/raw API diagnostics without changing what the agent receives.
Binary file
Binary file
@@ -0,0 +1,258 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
4
+ import { getAgentDir } from '@earendil-works/pi-coding-agent';
5
+ import { getAgentRun, isTerminalAgentStatus } from './agent';
6
+ import { AgentRunsWidget } from './agent-widget';
7
+ import { resolveApiKey } from './auth';
8
+ import type { ExaAgentRun } from './types';
9
+ import { asString, isRecord, truncateText } from './util';
10
+
11
+ const TRACKED_RUNS_PATH = join(getAgentDir(), 'state', 'extensions', 'pi-exa', 'agent-runs.json');
12
+ const DEFAULT_BACKGROUND_POLL_INTERVAL_MS = 10_000;
13
+ const BACKGROUND_FOLLOW_UP_MAX_CHARS = 3000;
14
+ const STATUS_KEY = 'pi-exa-agent';
15
+ const WIDGET_KEY = 'pi-exa-agent-runs';
16
+
17
+ export interface TrackedAgentRun {
18
+ runId: string;
19
+ query?: string;
20
+ createdAt?: string;
21
+ lastStatus?: string;
22
+ pollIntervalMs?: number;
23
+ }
24
+
25
+ function normalizeTrackedRun(value: unknown): TrackedAgentRun | undefined {
26
+ if (!isRecord(value)) return undefined;
27
+ const runId = asString(value.runId);
28
+ if (!runId) return undefined;
29
+
30
+ return {
31
+ runId,
32
+ query: asString(value.query),
33
+ createdAt: asString(value.createdAt),
34
+ lastStatus: asString(value.lastStatus),
35
+ pollIntervalMs:
36
+ typeof value.pollIntervalMs === 'number' && Number.isFinite(value.pollIntervalMs)
37
+ ? value.pollIntervalMs
38
+ : undefined,
39
+ };
40
+ }
41
+
42
+ async function readTrackedRuns(): Promise<TrackedAgentRun[]> {
43
+ try {
44
+ const raw = JSON.parse(await fs.readFile(TRACKED_RUNS_PATH, 'utf8'));
45
+ const runs = Array.isArray(raw?.runs) ? raw.runs : [];
46
+ const seen = new Set<string>();
47
+ return runs.flatMap((item: unknown) => {
48
+ const normalized = normalizeTrackedRun(item);
49
+ if (!normalized || seen.has(normalized.runId)) return [];
50
+ seen.add(normalized.runId);
51
+ return [normalized];
52
+ });
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ async function writeTrackedRuns(runs: TrackedAgentRun[]): Promise<void> {
59
+ await fs.mkdir(dirname(TRACKED_RUNS_PATH), { recursive: true });
60
+ await fs.writeFile(TRACKED_RUNS_PATH, `${JSON.stringify({ runs }, null, 2)}\n`, 'utf8');
61
+ }
62
+
63
+ function makeFollowUp(run: ExaAgentRun): string {
64
+ const terminalLabel =
65
+ run.status === 'completed'
66
+ ? 'Exa Agent background run completed'
67
+ : `Exa Agent background run ${run.status}`;
68
+ const lines = [terminalLabel, `Run ID: ${run.id}`];
69
+
70
+ if (run.stopReason) lines.push(`Stop reason: ${run.stopReason}`);
71
+ if (run.costDollars?.total !== undefined) lines.push(`Cost: $${run.costDollars.total}`);
72
+ if (run.output?.text) lines.push(`Summary: ${truncateText(run.output.text, 800)}`);
73
+
74
+ const structuredSummary = summarizeStructuredOutput(run.output?.structured);
75
+ if (structuredSummary) lines.push(`Structured: ${structuredSummary}`);
76
+
77
+ const sourceUrls = collectSourceUrls(run).slice(0, 6);
78
+ if (sourceUrls.length > 0) {
79
+ lines.push('Sources:');
80
+ lines.push(...sourceUrls.map((url) => `- ${url}`));
81
+ }
82
+
83
+ lines.push('');
84
+ lines.push('This is a compact completion notice, not the full Agent result.');
85
+ lines.push(
86
+ `Before answering with details from this run, call web_agent_get_exa with runId "${run.id}". Do not poll unless more detail is needed.`,
87
+ );
88
+
89
+ return truncateText(lines.join('\n'), BACKGROUND_FOLLOW_UP_MAX_CHARS);
90
+ }
91
+
92
+ function summarizeStructuredOutput(value: unknown): string | undefined {
93
+ if (value === undefined || value === null) return undefined;
94
+ if (Array.isArray(value)) return `array with ${value.length} item(s)`;
95
+ if (typeof value !== 'object') return truncateText(String(value), 300);
96
+
97
+ const entries = Object.entries(value as Record<string, unknown>);
98
+ const arrayEntry = entries.find(([, item]) => Array.isArray(item));
99
+ if (arrayEntry && Array.isArray(arrayEntry[1])) {
100
+ return `${arrayEntry[0]} has ${arrayEntry[1].length} item(s)`;
101
+ }
102
+
103
+ return `object with ${entries.length} field(s): ${entries
104
+ .slice(0, 8)
105
+ .map(([key]) => key)
106
+ .join(', ')}`;
107
+ }
108
+
109
+ function collectSourceUrls(run: ExaAgentRun): string[] {
110
+ const seen = new Set<string>();
111
+ const urls: string[] = [];
112
+
113
+ for (const grounding of run.output?.grounding || []) {
114
+ for (const citation of grounding.citations || []) {
115
+ if (!citation.url || seen.has(citation.url)) continue;
116
+ seen.add(citation.url);
117
+ urls.push(citation.url);
118
+ }
119
+ }
120
+
121
+ return urls;
122
+ }
123
+
124
+ export class AgentRunTracker {
125
+ private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
126
+ private widget: AgentRunsWidget | undefined;
127
+ private widgetContext: ExtensionContext | undefined;
128
+ private shuttingDown = false;
129
+
130
+ constructor(private readonly pi: ExtensionAPI) {}
131
+
132
+ async track(
133
+ run: ExaAgentRun,
134
+ options?: { pollIntervalMs?: number },
135
+ ctx?: ExtensionContext,
136
+ ): Promise<void> {
137
+ if (isTerminalAgentStatus(run.status)) return;
138
+
139
+ const runs = await readTrackedRuns();
140
+ const existing = runs.find((item) => item.runId === run.id);
141
+ const tracked: TrackedAgentRun = {
142
+ runId: run.id,
143
+ query: run.request?.query,
144
+ createdAt: run.createdAt,
145
+ lastStatus: run.status,
146
+ pollIntervalMs: options?.pollIntervalMs,
147
+ };
148
+
149
+ if (existing) {
150
+ Object.assign(existing, tracked);
151
+ } else {
152
+ runs.push(tracked);
153
+ }
154
+
155
+ await writeTrackedRuns(runs);
156
+ this.updateUi(ctx, runs);
157
+ this.startPolling(tracked, ctx);
158
+ }
159
+
160
+ async resume(ctx?: ExtensionContext): Promise<void> {
161
+ this.shuttingDown = false;
162
+ const runs = await readTrackedRuns();
163
+ this.updateUi(ctx, runs);
164
+ for (const run of runs) {
165
+ this.startPolling(run, ctx);
166
+ }
167
+ }
168
+
169
+ shutdown(ctx?: ExtensionContext): void {
170
+ this.shuttingDown = true;
171
+ for (const timer of this.timers.values()) clearTimeout(timer);
172
+ this.timers.clear();
173
+ this.updateUi(ctx, []);
174
+ }
175
+
176
+ private startPolling(run: TrackedAgentRun, ctx?: ExtensionContext): void {
177
+ if (this.shuttingDown || this.timers.has(run.runId)) return;
178
+ this.schedule(run, ctx, 0);
179
+ }
180
+
181
+ private updateUi(ctx: ExtensionContext | undefined, runs: TrackedAgentRun[]): void {
182
+ if (!ctx?.hasUI) return;
183
+
184
+ if (runs.length === 0) {
185
+ ctx.ui.setStatus(STATUS_KEY, undefined);
186
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
187
+ this.widget = undefined;
188
+ this.widgetContext = undefined;
189
+ return;
190
+ }
191
+
192
+ ctx.ui.setStatus(STATUS_KEY, `Exa Agent: ${runs.length} running`);
193
+ if (this.widget && this.widgetContext === ctx) {
194
+ this.widget.setRuns(runs);
195
+ return;
196
+ }
197
+
198
+ if (this.widget) {
199
+ this.widget.dispose();
200
+ this.widget = undefined;
201
+ this.widgetContext = undefined;
202
+ }
203
+
204
+ ctx.ui.setWidget(
205
+ WIDGET_KEY,
206
+ (tui, _theme) => {
207
+ this.widget = new AgentRunsWidget(tui, runs);
208
+ this.widgetContext = ctx;
209
+ return this.widget;
210
+ },
211
+ { placement: 'belowEditor' },
212
+ );
213
+ }
214
+
215
+ private schedule(run: TrackedAgentRun, ctx: ExtensionContext | undefined, delayMs: number): void {
216
+ if (this.shuttingDown) return;
217
+
218
+ const timer = setTimeout(() => {
219
+ this.timers.delete(run.runId);
220
+ void this.poll(run, ctx);
221
+ }, delayMs);
222
+ this.timers.set(run.runId, timer);
223
+ }
224
+
225
+ private async poll(run: TrackedAgentRun, ctx?: ExtensionContext): Promise<void> {
226
+ if (this.shuttingDown) return;
227
+
228
+ const { apiKey } = await resolveApiKey(ctx, { promptIfMissing: false });
229
+ if (!apiKey) return;
230
+
231
+ try {
232
+ const latest = await getAgentRun(apiKey, run.runId);
233
+ if (isTerminalAgentStatus(latest.status)) {
234
+ const runs = (await readTrackedRuns()).filter((item) => item.runId !== run.runId);
235
+ await writeTrackedRuns(runs);
236
+ this.updateUi(ctx, runs);
237
+ this.pi.sendUserMessage(makeFollowUp(latest), { deliverAs: 'followUp' });
238
+ return;
239
+ }
240
+
241
+ const runs = await readTrackedRuns();
242
+ const existing = runs.find((item) => item.runId === run.runId);
243
+ if (existing) {
244
+ existing.lastStatus = latest.status;
245
+ await writeTrackedRuns(runs);
246
+ this.updateUi(ctx, runs);
247
+ }
248
+ } catch {
249
+ // Keep the run tracked; transient API/network failures can be retried next interval.
250
+ }
251
+
252
+ this.schedule(
253
+ run,
254
+ ctx,
255
+ Math.max(1000, run.pollIntervalMs ?? DEFAULT_BACKGROUND_POLL_INTERVAL_MS),
256
+ );
257
+ }
258
+ }
@@ -0,0 +1,136 @@
1
+ import { truncateToWidth, visibleWidth, type Component, type TUI } from '@earendil-works/pi-tui';
2
+ import type { TrackedAgentRun } from './agent-tracker';
3
+
4
+ const GOLD_FG = '\x1b[38;2;212;162;46m';
5
+ const GREEN_FG = '\x1b[38;2;96;176;88m';
6
+ const RESET_FG = '\x1b[39m';
7
+ const SEPARATOR = ' │ ';
8
+ const MIN_INNER = 34;
9
+ const MAX_INNER = 96;
10
+
11
+ function tint(text: string, color: string): string {
12
+ return `${color}${text}${RESET_FG}`;
13
+ }
14
+
15
+ function gold(text: string): string {
16
+ return tint(text, GOLD_FG);
17
+ }
18
+
19
+ function padVisible(text: string, width: number): string {
20
+ const deficit = width - visibleWidth(text);
21
+ return deficit <= 0 ? text : `${text}${' '.repeat(deficit)}`;
22
+ }
23
+
24
+ function panelHeaderLeft(title: string): string {
25
+ return `─ ${title} `;
26
+ }
27
+
28
+ function formatElapsed(createdAt: string | undefined): string | undefined {
29
+ if (!createdAt) return undefined;
30
+ const startedAt = Date.parse(createdAt);
31
+ if (!Number.isFinite(startedAt)) return undefined;
32
+
33
+ const seconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
34
+ const minutes = Math.floor(seconds / 60);
35
+ const remainingSeconds = seconds % 60;
36
+ return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
37
+ }
38
+
39
+ function runLabel(runs: TrackedAgentRun[]): string {
40
+ if (runs.length !== 1) return `${runs.length} running`;
41
+
42
+ const run = runs[0]!;
43
+ const status = run.lastStatus || 'running';
44
+ const elapsed = formatElapsed(run.createdAt);
45
+ return elapsed ? `${status} ${elapsed}` : status;
46
+ }
47
+
48
+ function taskText(runs: TrackedAgentRun[]): string {
49
+ const first = runs[0];
50
+ const base = first?.query || '(no query)';
51
+ return runs.length > 1 ? `${base} (+${runs.length - 1} more)` : base;
52
+ }
53
+
54
+ function renderRow(label: string, content: string, contentWidth: number): string {
55
+ const labelText = tint(label, GREEN_FG);
56
+ const valueWidth = Math.max(1, contentWidth - visibleWidth(label) - visibleWidth(SEPARATOR));
57
+ return `${labelText}${SEPARATOR}${truncateToWidth(content, valueWidth, '…', true)}`;
58
+ }
59
+
60
+ function framePanel(title: string, rightText: string, bodyLine: string, inner: number): string[] {
61
+ const leftHeader = panelHeaderLeft(title);
62
+ const rightSegment = rightText ? ` ${rightText} ` : '';
63
+ const fill = Math.max(1, inner - visibleWidth(leftHeader) - visibleWidth(rightSegment));
64
+ const top =
65
+ gold('╭') +
66
+ gold(leftHeader) +
67
+ gold('─'.repeat(fill)) +
68
+ (rightSegment ? gold(rightSegment) : '') +
69
+ gold('╮');
70
+ const bottom = gold('╰') + gold('─'.repeat(inner)) + gold('╯');
71
+ const contentWidth = Math.max(8, inner - 2);
72
+ return [top, gold('│ ') + padVisible(bodyLine, contentWidth) + gold(' │'), bottom];
73
+ }
74
+
75
+ function computeInnerWidth({
76
+ title,
77
+ rightText,
78
+ bodyWidth,
79
+ maxInner,
80
+ }: {
81
+ title: string;
82
+ rightText: string;
83
+ bodyWidth: number;
84
+ maxInner: number;
85
+ }): number {
86
+ const headerWidth = visibleWidth(panelHeaderLeft(title)) + visibleWidth(` ${rightText} `) + 1;
87
+ const naturalInner = Math.max(headerWidth, bodyWidth + 2);
88
+ return Math.max(Math.min(maxInner, naturalInner), Math.min(maxInner, MIN_INNER));
89
+ }
90
+
91
+ export class AgentRunsWidget implements Component {
92
+ private runs: TrackedAgentRun[];
93
+ private readonly timer: ReturnType<typeof setInterval>;
94
+
95
+ constructor(
96
+ private readonly tui: TUI,
97
+ runs: TrackedAgentRun[],
98
+ ) {
99
+ this.runs = runs.map((run) => ({ ...run }));
100
+ this.timer = setInterval(() => this.tui.requestRender(), 1000);
101
+ }
102
+
103
+ setRuns(runs: TrackedAgentRun[]): void {
104
+ this.runs = runs.map((run) => ({ ...run }));
105
+ this.tui.requestRender();
106
+ }
107
+
108
+ render(width: number): string[] {
109
+ if (this.runs.length === 0) return [];
110
+
111
+ const safeWidth = Math.max(1, width);
112
+ const title = 'EXA AGENT';
113
+ const rightText = runLabel(this.runs);
114
+ const label = this.runs.length === 1 ? 'task' : 'tasks';
115
+ const content = taskText(this.runs).replace(/\s+/g, ' ').trim();
116
+ const naturalBodyWidth = visibleWidth(label) + visibleWidth(SEPARATOR) + visibleWidth(content);
117
+ const maxInner = Math.max(8, Math.min(safeWidth - 2, MAX_INNER));
118
+ const inner = computeInnerWidth({
119
+ title,
120
+ rightText,
121
+ bodyWidth: naturalBodyWidth,
122
+ maxInner,
123
+ });
124
+ const bodyLine = renderRow(label, content, Math.max(8, inner - 2));
125
+
126
+ return framePanel(title, rightText, bodyLine, inner).map((line) =>
127
+ truncateToWidth(line, safeWidth, '', true),
128
+ );
129
+ }
130
+
131
+ invalidate(): void {}
132
+
133
+ dispose(): void {
134
+ clearInterval(this.timer);
135
+ }
136
+ }