@oh-my-pi/pi-coding-agent 1.337.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.
Files changed (224) hide show
  1. package/CHANGELOG.md +1228 -0
  2. package/README.md +1041 -0
  3. package/docs/compaction.md +403 -0
  4. package/docs/custom-tools.md +541 -0
  5. package/docs/extension-loading.md +1004 -0
  6. package/docs/hooks.md +867 -0
  7. package/docs/rpc.md +1040 -0
  8. package/docs/sdk.md +994 -0
  9. package/docs/session-tree-plan.md +441 -0
  10. package/docs/session.md +240 -0
  11. package/docs/skills.md +290 -0
  12. package/docs/theme.md +637 -0
  13. package/docs/tree.md +197 -0
  14. package/docs/tui.md +341 -0
  15. package/examples/README.md +21 -0
  16. package/examples/custom-tools/README.md +124 -0
  17. package/examples/custom-tools/hello/index.ts +20 -0
  18. package/examples/custom-tools/question/index.ts +84 -0
  19. package/examples/custom-tools/subagent/README.md +172 -0
  20. package/examples/custom-tools/subagent/agents/planner.md +37 -0
  21. package/examples/custom-tools/subagent/agents/reviewer.md +35 -0
  22. package/examples/custom-tools/subagent/agents/scout.md +50 -0
  23. package/examples/custom-tools/subagent/agents/worker.md +24 -0
  24. package/examples/custom-tools/subagent/agents.ts +156 -0
  25. package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
  26. package/examples/custom-tools/subagent/commands/implement.md +10 -0
  27. package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
  28. package/examples/custom-tools/subagent/index.ts +1002 -0
  29. package/examples/custom-tools/todo/index.ts +212 -0
  30. package/examples/hooks/README.md +56 -0
  31. package/examples/hooks/auto-commit-on-exit.ts +49 -0
  32. package/examples/hooks/confirm-destructive.ts +59 -0
  33. package/examples/hooks/custom-compaction.ts +116 -0
  34. package/examples/hooks/dirty-repo-guard.ts +52 -0
  35. package/examples/hooks/file-trigger.ts +41 -0
  36. package/examples/hooks/git-checkpoint.ts +53 -0
  37. package/examples/hooks/handoff.ts +150 -0
  38. package/examples/hooks/permission-gate.ts +34 -0
  39. package/examples/hooks/protected-paths.ts +30 -0
  40. package/examples/hooks/qna.ts +119 -0
  41. package/examples/hooks/snake.ts +343 -0
  42. package/examples/hooks/status-line.ts +40 -0
  43. package/examples/sdk/01-minimal.ts +22 -0
  44. package/examples/sdk/02-custom-model.ts +49 -0
  45. package/examples/sdk/03-custom-prompt.ts +44 -0
  46. package/examples/sdk/04-skills.ts +44 -0
  47. package/examples/sdk/05-tools.ts +90 -0
  48. package/examples/sdk/06-hooks.ts +61 -0
  49. package/examples/sdk/07-context-files.ts +36 -0
  50. package/examples/sdk/08-slash-commands.ts +42 -0
  51. package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
  52. package/examples/sdk/10-settings.ts +38 -0
  53. package/examples/sdk/11-sessions.ts +48 -0
  54. package/examples/sdk/12-full-control.ts +95 -0
  55. package/examples/sdk/README.md +154 -0
  56. package/package.json +81 -0
  57. package/src/cli/args.ts +246 -0
  58. package/src/cli/file-processor.ts +72 -0
  59. package/src/cli/list-models.ts +104 -0
  60. package/src/cli/plugin-cli.ts +650 -0
  61. package/src/cli/session-picker.ts +41 -0
  62. package/src/cli.ts +10 -0
  63. package/src/commands/init.md +20 -0
  64. package/src/config.ts +159 -0
  65. package/src/core/agent-session.ts +1900 -0
  66. package/src/core/auth-storage.ts +236 -0
  67. package/src/core/bash-executor.ts +196 -0
  68. package/src/core/compaction/branch-summarization.ts +343 -0
  69. package/src/core/compaction/compaction.ts +742 -0
  70. package/src/core/compaction/index.ts +7 -0
  71. package/src/core/compaction/utils.ts +154 -0
  72. package/src/core/custom-tools/index.ts +21 -0
  73. package/src/core/custom-tools/loader.ts +248 -0
  74. package/src/core/custom-tools/types.ts +169 -0
  75. package/src/core/custom-tools/wrapper.ts +28 -0
  76. package/src/core/exec.ts +129 -0
  77. package/src/core/export-html/index.ts +211 -0
  78. package/src/core/export-html/template.css +781 -0
  79. package/src/core/export-html/template.html +54 -0
  80. package/src/core/export-html/template.js +1185 -0
  81. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  82. package/src/core/export-html/vendor/marked.min.js +6 -0
  83. package/src/core/hooks/index.ts +16 -0
  84. package/src/core/hooks/loader.ts +312 -0
  85. package/src/core/hooks/runner.ts +434 -0
  86. package/src/core/hooks/tool-wrapper.ts +99 -0
  87. package/src/core/hooks/types.ts +773 -0
  88. package/src/core/index.ts +52 -0
  89. package/src/core/mcp/client.ts +158 -0
  90. package/src/core/mcp/config.ts +154 -0
  91. package/src/core/mcp/index.ts +45 -0
  92. package/src/core/mcp/loader.ts +68 -0
  93. package/src/core/mcp/manager.ts +181 -0
  94. package/src/core/mcp/tool-bridge.ts +148 -0
  95. package/src/core/mcp/transports/http.ts +316 -0
  96. package/src/core/mcp/transports/index.ts +6 -0
  97. package/src/core/mcp/transports/stdio.ts +252 -0
  98. package/src/core/mcp/types.ts +220 -0
  99. package/src/core/messages.ts +189 -0
  100. package/src/core/model-registry.ts +317 -0
  101. package/src/core/model-resolver.ts +393 -0
  102. package/src/core/plugins/doctor.ts +59 -0
  103. package/src/core/plugins/index.ts +38 -0
  104. package/src/core/plugins/installer.ts +189 -0
  105. package/src/core/plugins/loader.ts +338 -0
  106. package/src/core/plugins/manager.ts +672 -0
  107. package/src/core/plugins/parser.ts +105 -0
  108. package/src/core/plugins/paths.ts +32 -0
  109. package/src/core/plugins/types.ts +190 -0
  110. package/src/core/sdk.ts +760 -0
  111. package/src/core/session-manager.ts +1128 -0
  112. package/src/core/settings-manager.ts +443 -0
  113. package/src/core/skills.ts +437 -0
  114. package/src/core/slash-commands.ts +248 -0
  115. package/src/core/system-prompt.ts +439 -0
  116. package/src/core/timings.ts +25 -0
  117. package/src/core/tools/ask.ts +211 -0
  118. package/src/core/tools/bash-interceptor.ts +120 -0
  119. package/src/core/tools/bash.ts +250 -0
  120. package/src/core/tools/context.ts +32 -0
  121. package/src/core/tools/edit-diff.ts +475 -0
  122. package/src/core/tools/edit.ts +208 -0
  123. package/src/core/tools/exa/company.ts +59 -0
  124. package/src/core/tools/exa/index.ts +64 -0
  125. package/src/core/tools/exa/linkedin.ts +59 -0
  126. package/src/core/tools/exa/logger.ts +56 -0
  127. package/src/core/tools/exa/mcp-client.ts +368 -0
  128. package/src/core/tools/exa/render.ts +196 -0
  129. package/src/core/tools/exa/researcher.ts +90 -0
  130. package/src/core/tools/exa/search.ts +337 -0
  131. package/src/core/tools/exa/types.ts +168 -0
  132. package/src/core/tools/exa/websets.ts +248 -0
  133. package/src/core/tools/find.ts +261 -0
  134. package/src/core/tools/grep.ts +555 -0
  135. package/src/core/tools/index.ts +202 -0
  136. package/src/core/tools/ls.ts +140 -0
  137. package/src/core/tools/lsp/client.ts +605 -0
  138. package/src/core/tools/lsp/config.ts +147 -0
  139. package/src/core/tools/lsp/edits.ts +101 -0
  140. package/src/core/tools/lsp/index.ts +804 -0
  141. package/src/core/tools/lsp/render.ts +447 -0
  142. package/src/core/tools/lsp/rust-analyzer.ts +145 -0
  143. package/src/core/tools/lsp/types.ts +463 -0
  144. package/src/core/tools/lsp/utils.ts +486 -0
  145. package/src/core/tools/notebook.ts +229 -0
  146. package/src/core/tools/path-utils.ts +61 -0
  147. package/src/core/tools/read.ts +240 -0
  148. package/src/core/tools/renderers.ts +540 -0
  149. package/src/core/tools/task/agents.ts +153 -0
  150. package/src/core/tools/task/artifacts.ts +114 -0
  151. package/src/core/tools/task/bundled-agents/browser.md +71 -0
  152. package/src/core/tools/task/bundled-agents/explore.md +82 -0
  153. package/src/core/tools/task/bundled-agents/plan.md +54 -0
  154. package/src/core/tools/task/bundled-agents/reviewer.md +59 -0
  155. package/src/core/tools/task/bundled-agents/task.md +53 -0
  156. package/src/core/tools/task/bundled-commands/architect-plan.md +10 -0
  157. package/src/core/tools/task/bundled-commands/implement-with-critic.md +11 -0
  158. package/src/core/tools/task/bundled-commands/implement.md +11 -0
  159. package/src/core/tools/task/commands.ts +213 -0
  160. package/src/core/tools/task/discovery.ts +208 -0
  161. package/src/core/tools/task/executor.ts +367 -0
  162. package/src/core/tools/task/index.ts +388 -0
  163. package/src/core/tools/task/model-resolver.ts +115 -0
  164. package/src/core/tools/task/parallel.ts +38 -0
  165. package/src/core/tools/task/render.ts +232 -0
  166. package/src/core/tools/task/types.ts +99 -0
  167. package/src/core/tools/truncate.ts +265 -0
  168. package/src/core/tools/web-fetch.ts +2370 -0
  169. package/src/core/tools/web-search/auth.ts +193 -0
  170. package/src/core/tools/web-search/index.ts +537 -0
  171. package/src/core/tools/web-search/providers/anthropic.ts +198 -0
  172. package/src/core/tools/web-search/providers/exa.ts +302 -0
  173. package/src/core/tools/web-search/providers/perplexity.ts +195 -0
  174. package/src/core/tools/web-search/render.ts +182 -0
  175. package/src/core/tools/web-search/types.ts +180 -0
  176. package/src/core/tools/write.ts +99 -0
  177. package/src/index.ts +176 -0
  178. package/src/main.ts +464 -0
  179. package/src/migrations.ts +135 -0
  180. package/src/modes/index.ts +43 -0
  181. package/src/modes/interactive/components/armin.ts +382 -0
  182. package/src/modes/interactive/components/assistant-message.ts +86 -0
  183. package/src/modes/interactive/components/bash-execution.ts +196 -0
  184. package/src/modes/interactive/components/bordered-loader.ts +41 -0
  185. package/src/modes/interactive/components/branch-summary-message.ts +42 -0
  186. package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
  187. package/src/modes/interactive/components/custom-editor.ts +122 -0
  188. package/src/modes/interactive/components/diff.ts +147 -0
  189. package/src/modes/interactive/components/dynamic-border.ts +25 -0
  190. package/src/modes/interactive/components/footer.ts +381 -0
  191. package/src/modes/interactive/components/hook-editor.ts +117 -0
  192. package/src/modes/interactive/components/hook-input.ts +64 -0
  193. package/src/modes/interactive/components/hook-message.ts +96 -0
  194. package/src/modes/interactive/components/hook-selector.ts +91 -0
  195. package/src/modes/interactive/components/model-selector.ts +247 -0
  196. package/src/modes/interactive/components/oauth-selector.ts +120 -0
  197. package/src/modes/interactive/components/plugin-settings.ts +479 -0
  198. package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
  199. package/src/modes/interactive/components/session-selector.ts +204 -0
  200. package/src/modes/interactive/components/settings-selector.ts +453 -0
  201. package/src/modes/interactive/components/show-images-selector.ts +45 -0
  202. package/src/modes/interactive/components/theme-selector.ts +62 -0
  203. package/src/modes/interactive/components/thinking-selector.ts +64 -0
  204. package/src/modes/interactive/components/tool-execution.ts +675 -0
  205. package/src/modes/interactive/components/tree-selector.ts +866 -0
  206. package/src/modes/interactive/components/user-message-selector.ts +159 -0
  207. package/src/modes/interactive/components/user-message.ts +18 -0
  208. package/src/modes/interactive/components/visual-truncate.ts +50 -0
  209. package/src/modes/interactive/components/welcome.ts +183 -0
  210. package/src/modes/interactive/interactive-mode.ts +2516 -0
  211. package/src/modes/interactive/theme/dark.json +101 -0
  212. package/src/modes/interactive/theme/light.json +98 -0
  213. package/src/modes/interactive/theme/theme-schema.json +308 -0
  214. package/src/modes/interactive/theme/theme.ts +998 -0
  215. package/src/modes/print-mode.ts +128 -0
  216. package/src/modes/rpc/rpc-client.ts +527 -0
  217. package/src/modes/rpc/rpc-mode.ts +483 -0
  218. package/src/modes/rpc/rpc-types.ts +203 -0
  219. package/src/utils/changelog.ts +99 -0
  220. package/src/utils/clipboard.ts +265 -0
  221. package/src/utils/fuzzy.ts +108 -0
  222. package/src/utils/mime.ts +30 -0
  223. package/src/utils/shell.ts +276 -0
  224. package/src/utils/tools-manager.ts +274 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Anthropic Web Search Provider
3
+ *
4
+ * Uses Claude's built-in web_search_20250305 tool to search the web.
5
+ * Returns synthesized answers with citations and source metadata.
6
+ */
7
+
8
+ import { buildAnthropicHeaders, buildAnthropicUrl, findAnthropicAuth, getEnv } from "../auth.js";
9
+ import type {
10
+ AnthropicApiResponse,
11
+ AnthropicAuthConfig,
12
+ AnthropicCitation,
13
+ WebSearchCitation,
14
+ WebSearchResponse,
15
+ WebSearchSource,
16
+ } from "../types.js";
17
+
18
+ const DEFAULT_MODEL = "claude-sonnet-4-5-20250514";
19
+ const DEFAULT_MAX_TOKENS = 4096;
20
+
21
+ export interface AnthropicSearchParams {
22
+ query: string;
23
+ system_prompt?: string;
24
+ max_tokens?: number;
25
+ num_results?: number;
26
+ }
27
+
28
+ /** Get model from env or use default */
29
+ async function getModel(): Promise<string> {
30
+ return (await getEnv("ANTHROPIC_SEARCH_MODEL")) ?? DEFAULT_MODEL;
31
+ }
32
+
33
+ /** Call Anthropic API with web search */
34
+ async function callWebSearch(
35
+ auth: AnthropicAuthConfig,
36
+ model: string,
37
+ query: string,
38
+ systemPrompt?: string,
39
+ maxTokens?: number,
40
+ ): Promise<AnthropicApiResponse> {
41
+ const url = buildAnthropicUrl(auth);
42
+ const headers = buildAnthropicHeaders(auth);
43
+
44
+ // Build system blocks
45
+ const systemBlocks: Array<{ type: string; text: string; cache_control?: { type: string } }> = [];
46
+
47
+ if (auth.isOAuth) {
48
+ // OAuth requires Claude Code identity with cache_control
49
+ systemBlocks.push({
50
+ type: "text",
51
+ text: "You are a helpful AI assistant with web search capabilities.",
52
+ cache_control: { type: "ephemeral" },
53
+ });
54
+ }
55
+
56
+ if (systemPrompt) {
57
+ systemBlocks.push({
58
+ type: "text",
59
+ text: systemPrompt,
60
+ ...(auth.isOAuth ? { cache_control: { type: "ephemeral" } } : {}),
61
+ });
62
+ }
63
+
64
+ const body: Record<string, unknown> = {
65
+ model,
66
+ max_tokens: maxTokens ?? DEFAULT_MAX_TOKENS,
67
+ messages: [{ role: "user", content: query }],
68
+ tools: [{ type: "web_search_20250305", name: "web_search" }],
69
+ };
70
+
71
+ if (systemBlocks.length > 0) {
72
+ body.system = systemBlocks;
73
+ }
74
+
75
+ const response = await fetch(url, {
76
+ method: "POST",
77
+ headers,
78
+ body: JSON.stringify(body),
79
+ });
80
+
81
+ if (!response.ok) {
82
+ const errorText = await response.text();
83
+ throw new Error(`Anthropic API error (${response.status}): ${errorText}`);
84
+ }
85
+
86
+ return response.json() as Promise<AnthropicApiResponse>;
87
+ }
88
+
89
+ /** Parse page_age string into seconds (e.g., "2 days ago", "3h ago", "1 week ago") */
90
+ function parsePageAge(pageAge: string | null | undefined): number | undefined {
91
+ if (!pageAge) return undefined;
92
+
93
+ const match = pageAge.match(/^(\d+)\s*(s|sec|second|m|min|minute|h|hour|d|day|w|week|mo|month|y|year)s?\s*(ago)?$/i);
94
+ if (!match) return undefined;
95
+
96
+ const value = parseInt(match[1], 10);
97
+ const unit = match[2].toLowerCase();
98
+
99
+ const multipliers: Record<string, number> = {
100
+ s: 1,
101
+ sec: 1,
102
+ second: 1,
103
+ m: 60,
104
+ min: 60,
105
+ minute: 60,
106
+ h: 3600,
107
+ hour: 3600,
108
+ d: 86400,
109
+ day: 86400,
110
+ w: 604800,
111
+ week: 604800,
112
+ mo: 2592000,
113
+ month: 2592000,
114
+ y: 31536000,
115
+ year: 31536000,
116
+ };
117
+
118
+ return value * (multipliers[unit] ?? 86400);
119
+ }
120
+
121
+ /** Parse API response into unified WebSearchResponse */
122
+ function parseResponse(response: AnthropicApiResponse): WebSearchResponse {
123
+ const answerParts: string[] = [];
124
+ const searchQueries: string[] = [];
125
+ const sources: WebSearchSource[] = [];
126
+ const citations: WebSearchCitation[] = [];
127
+
128
+ for (const block of response.content) {
129
+ if (block.type === "server_tool_use" && block.name === "web_search") {
130
+ // Intermediate search query
131
+ if (block.input?.query) {
132
+ searchQueries.push(block.input.query);
133
+ }
134
+ } else if (block.type === "web_search_tool_result" && block.content) {
135
+ // Search results
136
+ for (const result of block.content) {
137
+ if (result.type === "web_search_result") {
138
+ sources.push({
139
+ title: result.title,
140
+ url: result.url,
141
+ snippet: result.encrypted_content,
142
+ publishedDate: result.page_age ?? undefined,
143
+ ageSeconds: parsePageAge(result.page_age),
144
+ });
145
+ }
146
+ }
147
+ } else if (block.type === "text" && block.text) {
148
+ // Synthesized answer with citations
149
+ answerParts.push(block.text);
150
+ if (block.citations) {
151
+ for (const c of block.citations as AnthropicCitation[]) {
152
+ citations.push({
153
+ url: c.url,
154
+ title: c.title,
155
+ citedText: c.cited_text,
156
+ });
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ return {
163
+ provider: "anthropic",
164
+ answer: answerParts.join("\n\n") || undefined,
165
+ sources,
166
+ citations: citations.length > 0 ? citations : undefined,
167
+ searchQueries: searchQueries.length > 0 ? searchQueries : undefined,
168
+ usage: {
169
+ inputTokens: response.usage.input_tokens,
170
+ outputTokens: response.usage.output_tokens,
171
+ searchRequests: response.usage.server_tool_use?.web_search_requests,
172
+ },
173
+ model: response.model,
174
+ requestId: response.id,
175
+ };
176
+ }
177
+
178
+ /** Execute Anthropic web search */
179
+ export async function searchAnthropic(params: AnthropicSearchParams): Promise<WebSearchResponse> {
180
+ const auth = await findAnthropicAuth();
181
+ if (!auth) {
182
+ throw new Error(
183
+ "No Anthropic credentials found. Set ANTHROPIC_API_KEY or configure OAuth in ~/.pi/agent/auth.json",
184
+ );
185
+ }
186
+
187
+ const model = await getModel();
188
+ const response = await callWebSearch(auth, model, params.query, params.system_prompt, params.max_tokens);
189
+
190
+ const result = parseResponse(response);
191
+
192
+ // Apply num_results limit if specified
193
+ if (params.num_results && result.sources.length > params.num_results) {
194
+ result.sources = result.sources.slice(0, params.num_results);
195
+ }
196
+
197
+ return result;
198
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Exa Web Search Provider
3
+ *
4
+ * High-quality neural search via Exa MCP.
5
+ * Returns structured search results with optional content extraction.
6
+ */
7
+
8
+ import * as os from "node:os";
9
+ import type { WebSearchResponse, WebSearchSource } from "../types.js";
10
+
11
+ const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
12
+
13
+ export interface ExaSearchParams {
14
+ query: string;
15
+ num_results?: number;
16
+ type?: "neural" | "keyword" | "auto";
17
+ include_domains?: string[];
18
+ exclude_domains?: string[];
19
+ start_published_date?: string;
20
+ end_published_date?: string;
21
+ }
22
+
23
+ /** Parse a .env file and return key-value pairs */
24
+ async function parseEnvFile(filePath: string): Promise<Record<string, string>> {
25
+ const result: Record<string, string> = {};
26
+ try {
27
+ const file = Bun.file(filePath);
28
+ if (!(await file.exists())) return result;
29
+
30
+ const content = await file.text();
31
+ for (const line of content.split("\n")) {
32
+ const trimmed = line.trim();
33
+ if (!trimmed || trimmed.startsWith("#")) continue;
34
+
35
+ const eqIndex = trimmed.indexOf("=");
36
+ if (eqIndex === -1) continue;
37
+
38
+ const key = trimmed.slice(0, eqIndex).trim();
39
+ let value = trimmed.slice(eqIndex + 1).trim();
40
+
41
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
42
+ value = value.slice(1, -1);
43
+ }
44
+
45
+ result[key] = value;
46
+ }
47
+ } catch {
48
+ // Ignore read errors
49
+ }
50
+ return result;
51
+ }
52
+
53
+ /** Find EXA_API_KEY from environment or .env files */
54
+ export async function findApiKey(): Promise<string | null> {
55
+ // 1. Check environment variable
56
+ if (process.env.EXA_API_KEY) {
57
+ return process.env.EXA_API_KEY;
58
+ }
59
+
60
+ // 2. Check .env in current directory
61
+ const localEnv = await parseEnvFile(`${process.cwd()}/.env`);
62
+ if (localEnv.EXA_API_KEY) {
63
+ return localEnv.EXA_API_KEY;
64
+ }
65
+
66
+ // 3. Check ~/.env
67
+ const homeEnv = await parseEnvFile(`${os.homedir()}/.env`);
68
+ if (homeEnv.EXA_API_KEY) {
69
+ return homeEnv.EXA_API_KEY;
70
+ }
71
+
72
+ return null;
73
+ }
74
+
75
+ /** Parse SSE response format */
76
+ function parseSSE(text: string): unknown {
77
+ const lines = text.split("\n");
78
+ for (const line of lines) {
79
+ if (line.startsWith("data: ")) {
80
+ const data = line.slice(6).trim();
81
+ if (data === "[DONE]") continue;
82
+ try {
83
+ return JSON.parse(data);
84
+ } catch {
85
+ // Try next line
86
+ }
87
+ }
88
+ }
89
+ // Fallback: try parsing entire response as JSON
90
+ try {
91
+ return JSON.parse(text);
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ interface MCPCallResponse {
98
+ result?: {
99
+ content?: Array<{ type: string; text?: string }>;
100
+ };
101
+ error?: {
102
+ code: number;
103
+ message: string;
104
+ };
105
+ }
106
+
107
+ interface ExaSearchResult {
108
+ title?: string;
109
+ url?: string;
110
+ author?: string;
111
+ publishedDate?: string;
112
+ text?: string;
113
+ highlights?: string[];
114
+ }
115
+
116
+ interface ExaSearchResponse {
117
+ results?: ExaSearchResult[];
118
+ costDollars?: { total: number };
119
+ searchTime?: number;
120
+ }
121
+
122
+ /** Call Exa MCP API */
123
+ async function callExaMCP(apiKey: string, toolName: string, args: Record<string, unknown>): Promise<MCPCallResponse> {
124
+ const url = `${EXA_MCP_URL}?exaApiKey=${encodeURIComponent(apiKey)}&tools=${encodeURIComponent(toolName)}`;
125
+
126
+ const body = {
127
+ jsonrpc: "2.0",
128
+ id: Math.random().toString(36).slice(2),
129
+ method: "tools/call",
130
+ params: {
131
+ name: toolName,
132
+ arguments: args,
133
+ },
134
+ };
135
+
136
+ const response = await fetch(url, {
137
+ method: "POST",
138
+ headers: {
139
+ "Content-Type": "application/json",
140
+ Accept: "application/json, text/event-stream",
141
+ },
142
+ body: JSON.stringify(body),
143
+ });
144
+
145
+ if (!response.ok) {
146
+ const errorText = await response.text();
147
+ throw new Error(`Exa MCP error (${response.status}): ${errorText}`);
148
+ }
149
+
150
+ const text = await response.text();
151
+ const result = parseSSE(text);
152
+
153
+ if (!result) {
154
+ throw new Error("Failed to parse Exa MCP response");
155
+ }
156
+
157
+ return result as MCPCallResponse;
158
+ }
159
+
160
+ /** Parse MCP response content into ExaSearchResponse */
161
+ function parseMCPContent(content: Array<{ type: string; text?: string }>): ExaSearchResponse | null {
162
+ for (const block of content) {
163
+ if (block.type === "text" && block.text) {
164
+ // Try to parse as JSON first
165
+ try {
166
+ return JSON.parse(block.text) as ExaSearchResponse;
167
+ } catch {
168
+ // Parse markdown format
169
+ return parseExaMarkdown(block.text);
170
+ }
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+
176
+ /** Parse Exa markdown format into ExaSearchResponse */
177
+ function parseExaMarkdown(text: string): ExaSearchResponse | null {
178
+ const results: ExaSearchResult[] = [];
179
+ const lines = text.split("\n");
180
+ let currentResult: Partial<ExaSearchResult> | null = null;
181
+
182
+ for (const line of lines) {
183
+ const trimmed = line.trim();
184
+
185
+ // Match result header: ## Title
186
+ if (trimmed.startsWith("## ")) {
187
+ if (currentResult?.title) {
188
+ results.push(currentResult as ExaSearchResult);
189
+ }
190
+ currentResult = { title: trimmed.slice(3).trim() };
191
+ continue;
192
+ }
193
+
194
+ if (!currentResult) continue;
195
+
196
+ // Match URL: **URL:** ...
197
+ if (trimmed.startsWith("**URL:**")) {
198
+ currentResult.url = trimmed.slice(8).trim();
199
+ continue;
200
+ }
201
+
202
+ // Match Author: **Author:** ...
203
+ if (trimmed.startsWith("**Author:**")) {
204
+ currentResult.author = trimmed.slice(11).trim();
205
+ continue;
206
+ }
207
+
208
+ // Match Published Date: **Published Date:** ...
209
+ if (trimmed.startsWith("**Published Date:**")) {
210
+ currentResult.publishedDate = trimmed.slice(19).trim();
211
+ continue;
212
+ }
213
+
214
+ // Match Text: **Text:** ...
215
+ if (trimmed.startsWith("**Text:**")) {
216
+ currentResult.text = trimmed.slice(9).trim();
217
+ }
218
+ }
219
+
220
+ // Add last result
221
+ if (currentResult?.title) {
222
+ results.push(currentResult as ExaSearchResult);
223
+ }
224
+
225
+ if (results.length === 0) return null;
226
+
227
+ return { results };
228
+ }
229
+
230
+ /** Calculate age in seconds from ISO date string */
231
+ function dateToAgeSeconds(dateStr: string | undefined): number | undefined {
232
+ if (!dateStr) return undefined;
233
+ try {
234
+ const date = new Date(dateStr);
235
+ if (Number.isNaN(date.getTime())) return undefined;
236
+ return Math.floor((Date.now() - date.getTime()) / 1000);
237
+ } catch {
238
+ return undefined;
239
+ }
240
+ }
241
+
242
+ /** Execute Exa web search */
243
+ export async function searchExa(params: ExaSearchParams): Promise<WebSearchResponse> {
244
+ const apiKey = await findApiKey();
245
+ if (!apiKey) {
246
+ throw new Error("EXA_API_KEY not found. Set it in environment or .env file.");
247
+ }
248
+
249
+ const args: Record<string, unknown> = {
250
+ query: params.query,
251
+ num_results: params.num_results ?? 10,
252
+ type: params.type ?? "auto",
253
+ text: true, // Include text for richer results
254
+ highlights: true,
255
+ };
256
+
257
+ if (params.include_domains?.length) {
258
+ args.include_domains = params.include_domains;
259
+ }
260
+ if (params.exclude_domains?.length) {
261
+ args.exclude_domains = params.exclude_domains;
262
+ }
263
+ if (params.start_published_date) {
264
+ args.start_published_date = params.start_published_date;
265
+ }
266
+ if (params.end_published_date) {
267
+ args.end_published_date = params.end_published_date;
268
+ }
269
+
270
+ const response = await callExaMCP(apiKey, "web_search", args);
271
+
272
+ if (response.error) {
273
+ throw new Error(`Exa MCP error: ${response.error.message}`);
274
+ }
275
+
276
+ const exaResponse = response.result?.content ? parseMCPContent(response.result.content) : null;
277
+
278
+ // Convert to unified WebSearchResponse
279
+ const sources: WebSearchSource[] = [];
280
+
281
+ if (exaResponse?.results) {
282
+ for (const result of exaResponse.results) {
283
+ if (!result.url) continue;
284
+ sources.push({
285
+ title: result.title ?? result.url,
286
+ url: result.url,
287
+ snippet: result.text ?? result.highlights?.join(" "),
288
+ publishedDate: result.publishedDate,
289
+ ageSeconds: dateToAgeSeconds(result.publishedDate),
290
+ author: result.author,
291
+ });
292
+ }
293
+ }
294
+
295
+ // Apply num_results limit if specified
296
+ const limitedSources = params.num_results ? sources.slice(0, params.num_results) : sources;
297
+
298
+ return {
299
+ provider: "exa",
300
+ sources: limitedSources,
301
+ };
302
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Perplexity Web Search Provider
3
+ *
4
+ * Supports both sonar (fast) and sonar-pro (comprehensive) models.
5
+ * Returns synthesized answers with citations and related questions.
6
+ */
7
+
8
+ import * as os from "node:os";
9
+ import type {
10
+ PerplexityRequest,
11
+ PerplexityResponse,
12
+ WebSearchCitation,
13
+ WebSearchResponse,
14
+ WebSearchSource,
15
+ } from "../types.js";
16
+
17
+ const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions";
18
+
19
+ export interface PerplexitySearchParams {
20
+ query: string;
21
+ model?: "sonar" | "sonar-pro";
22
+ system_prompt?: string;
23
+ search_recency_filter?: "day" | "week" | "month" | "year";
24
+ search_domain_filter?: string[];
25
+ search_context_size?: "low" | "medium" | "high";
26
+ return_related_questions?: boolean;
27
+ num_results?: number;
28
+ }
29
+
30
+ /** Parse a .env file and return key-value pairs */
31
+ async function parseEnvFile(filePath: string): Promise<Record<string, string>> {
32
+ const result: Record<string, string> = {};
33
+ try {
34
+ const file = Bun.file(filePath);
35
+ if (!(await file.exists())) return result;
36
+
37
+ const content = await file.text();
38
+ for (const line of content.split("\n")) {
39
+ const trimmed = line.trim();
40
+ if (!trimmed || trimmed.startsWith("#")) continue;
41
+
42
+ const eqIndex = trimmed.indexOf("=");
43
+ if (eqIndex === -1) continue;
44
+
45
+ const key = trimmed.slice(0, eqIndex).trim();
46
+ let value = trimmed.slice(eqIndex + 1).trim();
47
+
48
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
49
+ value = value.slice(1, -1);
50
+ }
51
+
52
+ result[key] = value;
53
+ }
54
+ } catch {
55
+ // Ignore read errors
56
+ }
57
+ return result;
58
+ }
59
+
60
+ /** Find PERPLEXITY_API_KEY from environment or .env files */
61
+ export async function findApiKey(): Promise<string | null> {
62
+ // 1. Check environment variable
63
+ if (process.env.PERPLEXITY_API_KEY) {
64
+ return process.env.PERPLEXITY_API_KEY;
65
+ }
66
+
67
+ // 2. Check .env in current directory
68
+ const localEnv = await parseEnvFile(`${process.cwd()}/.env`);
69
+ if (localEnv.PERPLEXITY_API_KEY) {
70
+ return localEnv.PERPLEXITY_API_KEY;
71
+ }
72
+
73
+ // 3. Check ~/.env
74
+ const homeEnv = await parseEnvFile(`${os.homedir()}/.env`);
75
+ if (homeEnv.PERPLEXITY_API_KEY) {
76
+ return homeEnv.PERPLEXITY_API_KEY;
77
+ }
78
+
79
+ return null;
80
+ }
81
+
82
+ /** Call Perplexity API */
83
+ async function callPerplexity(apiKey: string, request: PerplexityRequest): Promise<PerplexityResponse> {
84
+ const response = await fetch(PERPLEXITY_API_URL, {
85
+ method: "POST",
86
+ headers: {
87
+ Authorization: `Bearer ${apiKey}`,
88
+ "Content-Type": "application/json",
89
+ },
90
+ body: JSON.stringify(request),
91
+ });
92
+
93
+ if (!response.ok) {
94
+ const errorText = await response.text();
95
+ throw new Error(`Perplexity API error (${response.status}): ${errorText}`);
96
+ }
97
+
98
+ return response.json() as Promise<PerplexityResponse>;
99
+ }
100
+
101
+ /** Calculate age in seconds from ISO date string */
102
+ function dateToAgeSeconds(dateStr: string | undefined): number | undefined {
103
+ if (!dateStr) return undefined;
104
+ try {
105
+ const date = new Date(dateStr);
106
+ if (Number.isNaN(date.getTime())) return undefined;
107
+ return Math.floor((Date.now() - date.getTime()) / 1000);
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ }
112
+
113
+ /** Parse API response into unified WebSearchResponse */
114
+ function parseResponse(response: PerplexityResponse): WebSearchResponse {
115
+ const answer = response.choices[0]?.message?.content ?? "";
116
+
117
+ // Build sources by matching citations to search_results
118
+ const sources: WebSearchSource[] = [];
119
+ const citations: WebSearchCitation[] = [];
120
+
121
+ const citationUrls = response.citations ?? [];
122
+ const searchResults = response.search_results ?? [];
123
+
124
+ for (const url of citationUrls) {
125
+ const searchResult = searchResults.find((r) => r.url === url);
126
+ sources.push({
127
+ title: searchResult?.title ?? url,
128
+ url,
129
+ snippet: searchResult?.snippet,
130
+ publishedDate: searchResult?.date,
131
+ ageSeconds: dateToAgeSeconds(searchResult?.date),
132
+ });
133
+ citations.push({
134
+ url,
135
+ title: searchResult?.title ?? url,
136
+ });
137
+ }
138
+
139
+ return {
140
+ provider: "perplexity",
141
+ answer: answer || undefined,
142
+ sources,
143
+ citations: citations.length > 0 ? citations : undefined,
144
+ relatedQuestions: response.related_questions,
145
+ usage: {
146
+ inputTokens: response.usage.prompt_tokens,
147
+ outputTokens: response.usage.completion_tokens,
148
+ totalTokens: response.usage.total_tokens,
149
+ },
150
+ model: response.model,
151
+ requestId: response.id,
152
+ };
153
+ }
154
+
155
+ /** Execute Perplexity web search */
156
+ export async function searchPerplexity(params: PerplexitySearchParams): Promise<WebSearchResponse> {
157
+ const apiKey = await findApiKey();
158
+ if (!apiKey) {
159
+ throw new Error("PERPLEXITY_API_KEY not found. Set it in environment or .env file.");
160
+ }
161
+
162
+ const messages: PerplexityRequest["messages"] = [];
163
+ if (params.system_prompt) {
164
+ messages.push({ role: "system", content: params.system_prompt });
165
+ }
166
+ messages.push({ role: "user", content: params.query });
167
+
168
+ const request: PerplexityRequest = {
169
+ model: params.model ?? "sonar",
170
+ messages,
171
+ // Default to true for related questions (unlike original which hardcoded false)
172
+ return_related_questions: params.return_related_questions ?? true,
173
+ };
174
+
175
+ // Add optional parameters
176
+ if (params.search_recency_filter) {
177
+ request.search_recency_filter = params.search_recency_filter;
178
+ }
179
+ if (params.search_domain_filter && params.search_domain_filter.length > 0) {
180
+ request.search_domain_filter = params.search_domain_filter;
181
+ }
182
+ if (params.search_context_size) {
183
+ request.search_context_size = params.search_context_size;
184
+ }
185
+
186
+ const response = await callPerplexity(apiKey, request);
187
+ const result = parseResponse(response);
188
+
189
+ // Apply num_results limit if specified
190
+ if (params.num_results && result.sources.length > params.num_results) {
191
+ result.sources = result.sources.slice(0, params.num_results);
192
+ }
193
+
194
+ return result;
195
+ }