@oh-my-pi/pi-coding-agent 11.0.3 → 11.2.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 (143) hide show
  1. package/CHANGELOG.md +199 -49
  2. package/README.md +1 -1
  3. package/docs/config-usage.md +3 -4
  4. package/docs/sdk.md +6 -5
  5. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  6. package/examples/sdk/README.md +1 -1
  7. package/package.json +19 -11
  8. package/src/cli/args.ts +11 -94
  9. package/src/cli/config-cli.ts +1 -1
  10. package/src/cli/file-processor.ts +3 -3
  11. package/src/cli/oclif-help.ts +26 -0
  12. package/src/cli/web-search-cli.ts +148 -0
  13. package/src/cli.ts +8 -2
  14. package/src/commands/commit.ts +36 -0
  15. package/src/commands/config.ts +51 -0
  16. package/src/commands/grep.ts +41 -0
  17. package/src/commands/index/index.ts +136 -0
  18. package/src/commands/jupyter.ts +32 -0
  19. package/src/commands/plugin.ts +70 -0
  20. package/src/commands/setup.ts +39 -0
  21. package/src/commands/shell.ts +29 -0
  22. package/src/commands/stats.ts +29 -0
  23. package/src/commands/update.ts +21 -0
  24. package/src/commands/web-search.ts +50 -0
  25. package/src/commit/agentic/index.ts +3 -2
  26. package/src/commit/agentic/tools/analyze-file.ts +1 -3
  27. package/src/commit/git/errors.ts +4 -6
  28. package/src/commit/pipeline.ts +3 -2
  29. package/src/config/keybindings.ts +1 -3
  30. package/src/config/model-registry.ts +89 -162
  31. package/src/config/settings-schema.ts +10 -0
  32. package/src/config.ts +202 -132
  33. package/src/exa/mcp-client.ts +8 -41
  34. package/src/export/html/index.ts +1 -1
  35. package/src/extensibility/extensions/loader.ts +7 -10
  36. package/src/extensibility/extensions/runner.ts +5 -15
  37. package/src/extensibility/extensions/types.ts +1 -1
  38. package/src/extensibility/hooks/runner.ts +6 -9
  39. package/src/index.ts +0 -1
  40. package/src/ipy/kernel.ts +10 -22
  41. package/src/lsp/clients/biome-client.ts +4 -7
  42. package/src/lsp/clients/lsp-linter-client.ts +4 -6
  43. package/src/lsp/index.ts +5 -4
  44. package/src/lsp/utils.ts +18 -0
  45. package/src/main.ts +86 -181
  46. package/src/mcp/json-rpc.ts +2 -2
  47. package/src/mcp/transports/http.ts +12 -49
  48. package/src/modes/components/armin.ts +1 -3
  49. package/src/modes/components/assistant-message.ts +4 -4
  50. package/src/modes/components/bash-execution.ts +5 -3
  51. package/src/modes/components/branch-summary-message.ts +1 -3
  52. package/src/modes/components/compaction-summary-message.ts +1 -3
  53. package/src/modes/components/custom-message.ts +4 -5
  54. package/src/modes/components/extensions/extension-dashboard.ts +10 -16
  55. package/src/modes/components/extensions/extension-list.ts +5 -5
  56. package/src/modes/components/footer.ts +1 -4
  57. package/src/modes/components/hook-editor.ts +7 -32
  58. package/src/modes/components/hook-message.ts +4 -5
  59. package/src/modes/components/model-selector.ts +2 -2
  60. package/src/modes/components/plugin-settings.ts +16 -20
  61. package/src/modes/components/python-execution.ts +5 -5
  62. package/src/modes/components/session-selector.ts +6 -7
  63. package/src/modes/components/settings-defs.ts +49 -40
  64. package/src/modes/components/settings-selector.ts +8 -17
  65. package/src/modes/components/skill-message.ts +1 -3
  66. package/src/modes/components/status-line-segment-editor.ts +1 -3
  67. package/src/modes/components/status-line.ts +1 -3
  68. package/src/modes/components/todo-reminder.ts +5 -7
  69. package/src/modes/components/tree-selector.ts +10 -12
  70. package/src/modes/components/ttsr-notification.ts +1 -3
  71. package/src/modes/components/user-message-selector.ts +2 -4
  72. package/src/modes/components/welcome.ts +6 -18
  73. package/src/modes/controllers/event-controller.ts +1 -0
  74. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  75. package/src/modes/controllers/input-controller.ts +7 -34
  76. package/src/modes/controllers/selector-controller.ts +3 -3
  77. package/src/modes/interactive-mode.ts +27 -1
  78. package/src/modes/rpc/rpc-client.ts +2 -5
  79. package/src/modes/rpc/rpc-mode.ts +2 -2
  80. package/src/modes/theme/theme.ts +2 -6
  81. package/src/modes/types.ts +1 -0
  82. package/src/modes/utils/ui-helpers.ts +6 -1
  83. package/src/patch/index.ts +1 -4
  84. package/src/prompts/agents/explore.md +1 -0
  85. package/src/prompts/agents/frontmatter.md +2 -1
  86. package/src/prompts/agents/init.md +1 -0
  87. package/src/prompts/agents/plan.md +1 -0
  88. package/src/prompts/agents/reviewer.md +1 -0
  89. package/src/prompts/system/subagent-submit-reminder.md +2 -0
  90. package/src/prompts/system/subagent-system-prompt.md +2 -0
  91. package/src/prompts/system/subagent-user-prompt.md +8 -0
  92. package/src/prompts/system/system-prompt.md +5 -3
  93. package/src/prompts/system/web-search.md +6 -4
  94. package/src/prompts/tools/task.md +216 -163
  95. package/src/sdk.ts +11 -110
  96. package/src/session/agent-session.ts +117 -83
  97. package/src/session/auth-storage.ts +10 -51
  98. package/src/session/messages.ts +17 -3
  99. package/src/session/session-manager.ts +30 -30
  100. package/src/session/streaming-output.ts +1 -1
  101. package/src/ssh/ssh-executor.ts +6 -3
  102. package/src/task/agents.ts +2 -0
  103. package/src/task/discovery.ts +1 -1
  104. package/src/task/executor.ts +5 -10
  105. package/src/task/index.ts +43 -23
  106. package/src/task/render.ts +67 -64
  107. package/src/task/template.ts +17 -34
  108. package/src/task/types.ts +49 -22
  109. package/src/tools/ask.ts +1 -3
  110. package/src/tools/bash.ts +1 -4
  111. package/src/tools/browser.ts +5 -7
  112. package/src/tools/exit-plan-mode.ts +1 -4
  113. package/src/tools/fetch.ts +1 -3
  114. package/src/tools/find.ts +4 -3
  115. package/src/tools/gemini-image.ts +24 -55
  116. package/src/tools/grep.ts +4 -4
  117. package/src/tools/index.ts +12 -14
  118. package/src/tools/notebook.ts +1 -5
  119. package/src/tools/python.ts +4 -3
  120. package/src/tools/read.ts +2 -4
  121. package/src/tools/render-utils.ts +23 -0
  122. package/src/tools/ssh.ts +8 -12
  123. package/src/tools/todo-write.ts +1 -4
  124. package/src/tools/tool-errors.ts +1 -4
  125. package/src/tools/write.ts +1 -3
  126. package/src/utils/external-editor.ts +59 -0
  127. package/src/utils/file-mentions.ts +39 -1
  128. package/src/utils/image-convert.ts +1 -1
  129. package/src/utils/image-resize.ts +4 -4
  130. package/src/web/search/auth.ts +3 -33
  131. package/src/web/search/index.ts +73 -139
  132. package/src/web/search/provider.ts +58 -0
  133. package/src/web/search/providers/anthropic.ts +53 -14
  134. package/src/web/search/providers/base.ts +22 -0
  135. package/src/web/search/providers/codex.ts +38 -16
  136. package/src/web/search/providers/exa.ts +30 -6
  137. package/src/web/search/providers/gemini.ts +56 -20
  138. package/src/web/search/providers/jina.ts +28 -5
  139. package/src/web/search/providers/perplexity.ts +103 -36
  140. package/src/web/search/render.ts +84 -74
  141. package/src/web/search/types.ts +285 -59
  142. package/src/migrations.ts +0 -175
  143. package/src/session/storage-migration.ts +0 -173
@@ -6,12 +6,14 @@
6
6
  * Returns synthesized answers with web search sources.
7
7
  */
8
8
  import * as os from "node:os";
9
- import { readSseData } from "@oh-my-pi/pi-utils";
9
+ import { readSseJson } from "@oh-my-pi/pi-utils";
10
10
  import packageJson from "../../../../package.json" with { type: "json" };
11
11
  import { getAgentDbPath, getConfigDirPaths } from "../../../config";
12
12
  import { AgentStorage } from "../../../session/agent-storage";
13
- import type { WebSearchResponse, WebSearchSource } from "../../../web/search/types";
14
- import { WebSearchProviderError } from "../../../web/search/types";
13
+ import type { SearchResponse, SearchSource } from "../../../web/search/types";
14
+ import { SearchProviderError } from "../../../web/search/types";
15
+ import type { SearchParams } from "./base";
16
+ import { SearchProvider } from "./base";
15
17
 
16
18
  const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
17
19
  const CODEX_RESPONSES_PATH = "/codex/responses";
@@ -21,6 +23,7 @@ const DEFAULT_INSTRUCTIONS =
21
23
  "You are a helpful assistant with web search capabilities. Search the web to answer the user's question accurately and cite your sources.";
22
24
 
23
25
  export interface CodexSearchParams {
26
+ signal?: AbortSignal;
24
27
  query: string;
25
28
  system_prompt?: string;
26
29
  num_results?: number;
@@ -173,15 +176,15 @@ function buildCodexHeaders(accessToken: string, accountId: string): Record<strin
173
176
  * @param query - Search query from the user
174
177
  * @param options - Search options including system prompt and context size
175
178
  * @returns Parsed response with answer, sources, and usage
176
- * @throws {WebSearchProviderError} If the API request fails
179
+ * @throws {SearchProviderError} If the API request fails
177
180
  */
178
- async function callCodexWebSearch(
181
+ async function callCodexSearch(
179
182
  auth: { accessToken: string; accountId: string },
180
183
  query: string,
181
- options: { systemPrompt?: string; searchContextSize?: "low" | "medium" | "high" },
184
+ options: { signal?: AbortSignal; systemPrompt?: string; searchContextSize?: "low" | "medium" | "high" },
182
185
  ): Promise<{
183
186
  answer: string;
184
- sources: WebSearchSource[];
187
+ sources: SearchSource[];
185
188
  model: string;
186
189
  requestId: string;
187
190
  usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
@@ -217,21 +220,21 @@ async function callCodexWebSearch(
217
220
 
218
221
  if (!response.ok) {
219
222
  const errorText = await response.text();
220
- throw new WebSearchProviderError("codex", `Codex API error (${response.status}): ${errorText}`, response.status);
223
+ throw new SearchProviderError("codex", `Codex API error (${response.status}): ${errorText}`, response.status);
221
224
  }
222
225
 
223
226
  if (!response.body) {
224
- throw new WebSearchProviderError("codex", "Codex API returned no response body", 500);
227
+ throw new SearchProviderError("codex", "Codex API returned no response body", 500);
225
228
  }
226
229
 
227
230
  // Parse SSE stream
228
231
  const answerParts: string[] = [];
229
- const sources: WebSearchSource[] = [];
232
+ const sources: SearchSource[] = [];
230
233
  let model = DEFAULT_MODEL;
231
234
  let requestId = "";
232
235
  let usage: { inputTokens: number; outputTokens: number; totalTokens: number } | undefined;
233
236
 
234
- for await (const rawEvent of readSseData<Record<string, unknown>>(response.body)) {
237
+ for await (const rawEvent of readSseJson<Record<string, unknown>>(response.body, options.signal)) {
235
238
  const eventType = typeof rawEvent.type === "string" ? rawEvent.type : "";
236
239
  if (!eventType) continue;
237
240
 
@@ -288,11 +291,11 @@ async function callCodexWebSearch(
288
291
  } else if (eventType === "error") {
289
292
  const code = (rawEvent as { code?: string }).code ?? "";
290
293
  const message = (rawEvent as { message?: string }).message ?? "Unknown error";
291
- throw new WebSearchProviderError("codex", `Codex error (${code}): ${message}`, 500);
294
+ throw new SearchProviderError("codex", `Codex error (${code}): ${message}`, 500);
292
295
  } else if (eventType === "response.failed") {
293
296
  const resp = (rawEvent as { response?: { error?: { message?: string } } }).response;
294
297
  const errorMessage = resp?.error?.message ?? "Request failed";
295
- throw new WebSearchProviderError("codex", `Codex request failed: ${errorMessage}`, 500);
298
+ throw new SearchProviderError("codex", `Codex request failed: ${errorMessage}`, 500);
296
299
  }
297
300
  }
298
301
 
@@ -312,7 +315,7 @@ async function callCodexWebSearch(
312
315
  * @returns Search response with synthesized answer, sources, and usage
313
316
  * @throws {Error} If no Codex OAuth credentials are configured
314
317
  */
315
- export async function searchCodex(params: CodexSearchParams): Promise<WebSearchResponse> {
318
+ export async function searchCodex(params: CodexSearchParams): Promise<SearchResponse> {
316
319
  const auth = await findCodexAuth();
317
320
  if (!auth) {
318
321
  throw new Error(
@@ -320,7 +323,7 @@ export async function searchCodex(params: CodexSearchParams): Promise<WebSearchR
320
323
  );
321
324
  }
322
325
 
323
- const result = await callCodexWebSearch(auth, params.query, {
326
+ const result = await callCodexSearch(auth, params.query, {
324
327
  systemPrompt: params.system_prompt,
325
328
  searchContextSize: params.search_context_size ?? "high",
326
329
  });
@@ -352,7 +355,26 @@ export async function searchCodex(params: CodexSearchParams): Promise<WebSearchR
352
355
  * Checks if Codex web search is available.
353
356
  * @returns True if valid OAuth credentials exist for openai-codex
354
357
  */
355
- export async function hasCodexWebSearch(): Promise<boolean> {
358
+ export async function hasCodexSearch(): Promise<boolean> {
356
359
  const auth = await findCodexAuth();
357
360
  return auth !== null;
358
361
  }
362
+
363
+ /** Search provider for OpenAI Codex web search. */
364
+ export class CodexProvider extends SearchProvider {
365
+ readonly id = "codex";
366
+ readonly label = "Codex";
367
+
368
+ isAvailable(): Promise<boolean> {
369
+ return Promise.resolve(hasCodexSearch());
370
+ }
371
+
372
+ search(params: SearchParams): Promise<SearchResponse> {
373
+ return searchCodex({
374
+ signal: params.signal,
375
+ query: params.query,
376
+ system_prompt: params.systemPrompt,
377
+ num_results: params.numSearchResults ?? params.limit,
378
+ });
379
+ }
380
+ }
@@ -5,8 +5,11 @@
5
5
  * Returns structured search results with optional content extraction.
6
6
  */
7
7
  import { getEnvApiKey } from "@oh-my-pi/pi-ai";
8
- import type { WebSearchResponse, WebSearchSource } from "../../../web/search/types";
9
- import { WebSearchProviderError } from "../../../web/search/types";
8
+ import { findApiKey as findExaKey } from "../../../exa/mcp-client";
9
+ import type { SearchResponse, SearchSource } from "../../../web/search/types";
10
+ import { SearchProviderError } from "../../../web/search/types";
11
+ import type { SearchParams } from "./base";
12
+ import { SearchProvider } from "./base";
10
13
 
11
14
  const EXA_API_URL = "https://api.exa.ai/search";
12
15
 
@@ -79,7 +82,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
79
82
 
80
83
  if (!response.ok) {
81
84
  const errorText = await response.text();
82
- throw new WebSearchProviderError("exa", `Exa API error (${response.status}): ${errorText}`, response.status);
85
+ throw new SearchProviderError("exa", `Exa API error (${response.status}): ${errorText}`, response.status);
83
86
  }
84
87
 
85
88
  return response.json() as Promise<ExaSearchResponse>;
@@ -98,7 +101,7 @@ function dateToAgeSeconds(dateStr: string | null | undefined): number | undefine
98
101
  }
99
102
 
100
103
  /** Execute Exa web search */
101
- export async function searchExa(params: ExaSearchParams): Promise<WebSearchResponse> {
104
+ export async function searchExa(params: ExaSearchParams): Promise<SearchResponse> {
102
105
  const apiKey = getEnvApiKey("exa");
103
106
  if (!apiKey) {
104
107
  throw new Error("EXA_API_KEY not found. Set it in environment or .env file.");
@@ -106,8 +109,8 @@ export async function searchExa(params: ExaSearchParams): Promise<WebSearchRespo
106
109
 
107
110
  const response = await callExaSearch(apiKey, params);
108
111
 
109
- // Convert to unified WebSearchResponse
110
- const sources: WebSearchSource[] = [];
112
+ // Convert to unified SearchResponse
113
+ const sources: SearchSource[] = [];
111
114
 
112
115
  if (response.results) {
113
116
  for (const result of response.results) {
@@ -132,3 +135,24 @@ export async function searchExa(params: ExaSearchParams): Promise<WebSearchRespo
132
135
  requestId: response.requestId,
133
136
  };
134
137
  }
138
+
139
+ /** Search provider for Exa. */
140
+ export class ExaProvider extends SearchProvider {
141
+ readonly id = "exa";
142
+ readonly label = "Exa";
143
+
144
+ isAvailable(): boolean {
145
+ try {
146
+ return !!findExaKey();
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ search(params: SearchParams): Promise<SearchResponse> {
153
+ return searchExa({
154
+ query: params.query,
155
+ num_results: params.numSearchResults ?? params.limit,
156
+ });
157
+ }
158
+ }
@@ -8,8 +8,10 @@
8
8
  import { refreshGoogleCloudToken } from "@oh-my-pi/pi-ai";
9
9
  import { getAgentDbPath, getConfigDirPaths } from "../../../config";
10
10
  import { AgentStorage } from "../../../session/agent-storage";
11
- import type { WebSearchCitation, WebSearchResponse, WebSearchSource } from "../../../web/search/types";
12
- import { WebSearchProviderError } from "../../../web/search/types";
11
+ import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types";
12
+ import { SearchProviderError } from "../../../web/search/types";
13
+ import type { SearchParams } from "./base";
14
+ import { SearchProvider } from "./base";
13
15
 
14
16
  const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
15
17
  const ANTIGRAVITY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
@@ -41,6 +43,10 @@ export interface GeminiSearchParams {
41
43
  query: string;
42
44
  system_prompt?: string;
43
45
  num_results?: number;
46
+ /** Maximum output tokens. */
47
+ max_output_tokens?: number;
48
+ /** Sampling temperature (0–1). Lower = more focused/factual. */
49
+ temperature?: number;
44
50
  }
45
51
 
46
52
  /** OAuth credential stored in agent.db */
@@ -67,7 +73,7 @@ interface GeminiAuth {
67
73
  * Checks google-antigravity first (daily sandbox, more quota), then google-gemini-cli (prod).
68
74
  * @returns OAuth credential with access token and project ID, or null if none found
69
75
  */
70
- async function findGeminiAuth(): Promise<GeminiAuth | null> {
76
+ export async function findGeminiAuth(): Promise<GeminiAuth | null> {
71
77
  const configDirs = getConfigDirPaths("", { project: false });
72
78
  const expiryBuffer = 5 * 60 * 1000; // 5 minutes
73
79
  const now = Date.now();
@@ -191,16 +197,18 @@ interface CloudCodeResponseChunk {
191
197
  * @param query - Search query from the user
192
198
  * @param systemPrompt - Optional system prompt
193
199
  * @returns Parsed response with answer, sources, and usage
194
- * @throws {WebSearchProviderError} If the API request fails
200
+ * @throws {SearchProviderError} If the API request fails
195
201
  */
196
202
  async function callGeminiSearch(
197
203
  auth: GeminiAuth,
198
204
  query: string,
199
205
  systemPrompt?: string,
206
+ maxOutputTokens?: number,
207
+ temperature?: number,
200
208
  ): Promise<{
201
209
  answer: string;
202
- sources: WebSearchSource[];
203
- citations: WebSearchCitation[];
210
+ sources: SearchSource[];
211
+ citations: SearchCitation[];
204
212
  searchQueries: string[];
205
213
  model: string;
206
214
  usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
@@ -209,7 +217,7 @@ async function callGeminiSearch(
209
217
  const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
210
218
  const headers = auth.isAntigravity ? ANTIGRAVITY_HEADERS : GEMINI_CLI_HEADERS;
211
219
 
212
- const requestBody = {
220
+ const requestBody: Record<string, unknown> = {
213
221
  project: auth.projectId,
214
222
  model: DEFAULT_MODEL,
215
223
  request: {
@@ -231,6 +239,17 @@ async function callGeminiSearch(
231
239
  requestId: `search-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
232
240
  };
233
241
 
242
+ if (maxOutputTokens !== undefined || temperature !== undefined) {
243
+ const generationConfig: Record<string, number> = {};
244
+ if (maxOutputTokens !== undefined) {
245
+ generationConfig.maxOutputTokens = maxOutputTokens;
246
+ }
247
+ if (temperature !== undefined) {
248
+ generationConfig.temperature = temperature;
249
+ }
250
+ (requestBody.request as Record<string, unknown>).generationConfig = generationConfig;
251
+ }
252
+
234
253
  const response = await fetch(url, {
235
254
  method: "POST",
236
255
  headers: {
@@ -244,7 +263,7 @@ async function callGeminiSearch(
244
263
 
245
264
  if (!response.ok) {
246
265
  const errorText = await response.text();
247
- throw new WebSearchProviderError(
266
+ throw new SearchProviderError(
248
267
  "gemini",
249
268
  `Gemini Cloud Code API error (${response.status}): ${errorText}`,
250
269
  response.status,
@@ -252,13 +271,13 @@ async function callGeminiSearch(
252
271
  }
253
272
 
254
273
  if (!response.body) {
255
- throw new WebSearchProviderError("gemini", "Gemini API returned no response body", 500);
274
+ throw new SearchProviderError("gemini", "Gemini API returned no response body", 500);
256
275
  }
257
276
 
258
277
  // Parse SSE stream
259
278
  const answerParts: string[] = [];
260
- const sources: WebSearchSource[] = [];
261
- const citations: WebSearchCitation[] = [];
279
+ const sources: SearchSource[] = [];
280
+ const citations: SearchCitation[] = [];
262
281
  const searchQueries: string[] = [];
263
282
  const seenUrls = new Set<string>();
264
283
  let model = DEFAULT_MODEL;
@@ -388,7 +407,7 @@ async function callGeminiSearch(
388
407
  * @returns Search response with synthesized answer, sources, and citations
389
408
  * @throws {Error} If no Gemini OAuth credentials are configured
390
409
  */
391
- export async function searchGemini(params: GeminiSearchParams): Promise<WebSearchResponse> {
410
+ export async function searchGemini(params: GeminiSearchParams): Promise<SearchResponse> {
392
411
  const auth = await findGeminiAuth();
393
412
  if (!auth) {
394
413
  throw new Error(
@@ -396,7 +415,13 @@ export async function searchGemini(params: GeminiSearchParams): Promise<WebSearc
396
415
  );
397
416
  }
398
417
 
399
- const result = await callGeminiSearch(auth, params.query, params.system_prompt);
418
+ const result = await callGeminiSearch(
419
+ auth,
420
+ params.query,
421
+ params.system_prompt,
422
+ params.max_output_tokens,
423
+ params.temperature,
424
+ );
400
425
 
401
426
  let sources = result.sources;
402
427
 
@@ -416,11 +441,22 @@ export async function searchGemini(params: GeminiSearchParams): Promise<WebSearc
416
441
  };
417
442
  }
418
443
 
419
- /**
420
- * Checks if Gemini web search is available.
421
- * @returns True if valid OAuth credentials exist for google-gemini-cli or google-antigravity
422
- */
423
- export async function hasGeminiWebSearch(): Promise<boolean> {
424
- const auth = await findGeminiAuth();
425
- return auth !== null;
444
+ /** Search provider for Google Gemini web search. */
445
+ export class GeminiProvider extends SearchProvider {
446
+ readonly id = "gemini";
447
+ readonly label = "Gemini";
448
+
449
+ isAvailable() {
450
+ return findGeminiAuth().then(Boolean);
451
+ }
452
+
453
+ search(params: SearchParams): Promise<SearchResponse> {
454
+ return searchGemini({
455
+ query: params.query,
456
+ system_prompt: params.systemPrompt,
457
+ num_results: params.numSearchResults ?? params.limit,
458
+ max_output_tokens: params.maxOutputTokens,
459
+ temperature: params.temperature,
460
+ });
461
+ }
426
462
  }
@@ -6,8 +6,10 @@
6
6
  */
7
7
 
8
8
  import { getEnvApiKey } from "@oh-my-pi/pi-ai";
9
- import type { WebSearchResponse, WebSearchSource } from "../../../web/search/types";
10
- import { WebSearchProviderError } from "../../../web/search/types";
9
+ import type { SearchResponse, SearchSource } from "../../../web/search/types";
10
+ import { SearchProviderError } from "../../../web/search/types";
11
+ import type { SearchParams } from "./base";
12
+ import { SearchProvider } from "./base";
11
13
 
12
14
  const JINA_SEARCH_URL = "https://s.jina.ai";
13
15
 
@@ -41,7 +43,7 @@ async function callJinaSearch(apiKey: string, query: string): Promise<JinaSearch
41
43
 
42
44
  if (!response.ok) {
43
45
  const errorText = await response.text();
44
- throw new WebSearchProviderError("jina", `Jina API error (${response.status}): ${errorText}`, response.status);
46
+ throw new SearchProviderError("jina", `Jina API error (${response.status}): ${errorText}`, response.status);
45
47
  }
46
48
 
47
49
  const data = (await response.json()) as unknown;
@@ -49,14 +51,14 @@ async function callJinaSearch(apiKey: string, query: string): Promise<JinaSearch
49
51
  }
50
52
 
51
53
  /** Execute Jina web search. */
52
- export async function searchJina(params: JinaSearchParams): Promise<WebSearchResponse> {
54
+ export async function searchJina(params: JinaSearchParams): Promise<SearchResponse> {
53
55
  const apiKey = findApiKey();
54
56
  if (!apiKey) {
55
57
  throw new Error("JINA_API_KEY not found. Set it in environment or .env file.");
56
58
  }
57
59
 
58
60
  const response = await callJinaSearch(apiKey, params.query);
59
- const sources: WebSearchSource[] = [];
61
+ const sources: SearchSource[] = [];
60
62
 
61
63
  for (const result of response) {
62
64
  if (!result?.url) continue;
@@ -74,3 +76,24 @@ export async function searchJina(params: JinaSearchParams): Promise<WebSearchRes
74
76
  sources: limitedSources,
75
77
  };
76
78
  }
79
+
80
+ /** Search provider for Jina Reader. */
81
+ export class JinaProvider extends SearchProvider {
82
+ readonly id = "jina";
83
+ readonly label = "Jina";
84
+
85
+ isAvailable() {
86
+ try {
87
+ return !!findApiKey();
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ search(params: SearchParams): Promise<SearchResponse> {
94
+ return searchJina({
95
+ query: params.query,
96
+ num_results: params.numSearchResults ?? params.limit,
97
+ });
98
+ }
99
+ }
@@ -7,21 +7,34 @@
7
7
 
8
8
  import { getEnvApiKey } from "@oh-my-pi/pi-ai";
9
9
  import type {
10
+ PerplexityMessageOutput,
10
11
  PerplexityRequest,
11
12
  PerplexityResponse,
12
- WebSearchCitation,
13
- WebSearchResponse,
14
- WebSearchSource,
13
+ SearchCitation,
14
+ SearchResponse,
15
+ SearchSource,
15
16
  } from "../../../web/search/types";
16
- import { WebSearchProviderError } from "../../../web/search/types";
17
+ import { SearchProviderError } from "../../../web/search/types";
18
+ import type { SearchParams } from "./base";
19
+ import { SearchProvider } from "./base";
17
20
 
18
21
  const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions";
19
22
 
23
+ const DEFAULT_MAX_TOKENS = 8192;
24
+ const DEFAULT_TEMPERATURE = 0.2;
25
+ const DEFAULT_NUM_SEARCH_RESULTS = 10;
26
+
20
27
  export interface PerplexitySearchParams {
21
28
  query: string;
22
29
  system_prompt?: string;
23
- search_recency_filter?: "day" | "week" | "month" | "year";
30
+ search_recency_filter?: "hour" | "day" | "week" | "month" | "year";
24
31
  num_results?: number;
32
+ /** Maximum output tokens. Defaults to 4096. */
33
+ max_tokens?: number;
34
+ /** Sampling temperature (0–1). Lower = more focused/factual. Defaults to 0.2. */
35
+ temperature?: number;
36
+ /** Number of search results to retrieve. Defaults to 10. */
37
+ num_search_results?: number;
25
38
  }
26
39
 
27
40
  /** Find PERPLEXITY_API_KEY from environment or .env files (also checks PPLX_API_KEY) */
@@ -42,7 +55,7 @@ async function callPerplexity(apiKey: string, request: PerplexityRequest): Promi
42
55
 
43
56
  if (!response.ok) {
44
57
  const errorText = await response.text();
45
- throw new WebSearchProviderError(
58
+ throw new SearchProviderError(
46
59
  "perplexity",
47
60
  `Perplexity API error (${response.status}): ${errorText}`,
48
61
  response.status,
@@ -53,7 +66,7 @@ async function callPerplexity(apiKey: string, request: PerplexityRequest): Promi
53
66
  }
54
67
 
55
68
  /** Calculate age in seconds from ISO date string */
56
- function dateToAgeSeconds(dateStr: string | undefined): number | undefined {
69
+ function dateToAgeSeconds(dateStr: string | null | undefined): number | undefined {
57
70
  if (!dateStr) return undefined;
58
71
  try {
59
72
  const date = new Date(dateStr);
@@ -64,30 +77,49 @@ function dateToAgeSeconds(dateStr: string | undefined): number | undefined {
64
77
  }
65
78
  }
66
79
 
67
- /** Parse API response into unified WebSearchResponse */
68
- function parseResponse(response: PerplexityResponse): WebSearchResponse {
69
- const answer = response.choices[0]?.message?.content ?? "";
80
+ function messageContentToText(content: PerplexityMessageOutput["content"]): string {
81
+ if (!content) return "";
82
+ if (typeof content === "string") return content;
83
+ return content.map(chunk => (chunk.type === "text" ? chunk.text : "")).join("");
84
+ }
85
+
86
+ /** Parse API response into unified SearchResponse */
87
+ function parseResponse(response: PerplexityResponse): SearchResponse {
88
+ const messageContent = response.choices[0]?.message?.content ?? null;
89
+ const answer = messageContentToText(messageContent);
70
90
 
71
91
  // Build sources by matching citations to search_results
72
- const sources: WebSearchSource[] = [];
73
- const citations: WebSearchCitation[] = [];
92
+ const sources: SearchSource[] = [];
93
+ const citations: SearchCitation[] = [];
74
94
 
75
95
  const citationUrls = response.citations ?? [];
76
96
  const searchResults = response.search_results ?? [];
77
97
 
78
- for (const url of citationUrls) {
79
- const searchResult = searchResults.find(r => r.url === url);
80
- sources.push({
81
- title: searchResult?.title ?? url,
82
- url,
83
- snippet: searchResult?.snippet,
84
- publishedDate: searchResult?.date,
85
- ageSeconds: dateToAgeSeconds(searchResult?.date),
86
- });
87
- citations.push({
88
- url,
89
- title: searchResult?.title ?? url,
90
- });
98
+ if (citationUrls.length > 0) {
99
+ for (const url of citationUrls) {
100
+ const searchResult = searchResults.find(r => r.url === url);
101
+ sources.push({
102
+ title: searchResult?.title ?? url,
103
+ url,
104
+ snippet: searchResult?.snippet,
105
+ publishedDate: searchResult?.date ?? undefined,
106
+ ageSeconds: dateToAgeSeconds(searchResult?.date),
107
+ });
108
+ citations.push({
109
+ url,
110
+ title: searchResult?.title ?? url,
111
+ });
112
+ }
113
+ } else {
114
+ for (const searchResult of searchResults) {
115
+ sources.push({
116
+ title: searchResult.title ?? searchResult.url,
117
+ url: searchResult.url,
118
+ snippet: searchResult.snippet,
119
+ publishedDate: searchResult.date ?? undefined,
120
+ ageSeconds: dateToAgeSeconds(searchResult.date),
121
+ });
122
+ }
91
123
  }
92
124
 
93
125
  return {
@@ -95,37 +127,46 @@ function parseResponse(response: PerplexityResponse): WebSearchResponse {
95
127
  answer: answer || undefined,
96
128
  sources,
97
129
  citations: citations.length > 0 ? citations : undefined,
98
- relatedQuestions: response.related_questions,
99
- usage: {
100
- inputTokens: response.usage.prompt_tokens,
101
- outputTokens: response.usage.completion_tokens,
102
- totalTokens: response.usage.total_tokens,
103
- },
130
+ usage: response.usage
131
+ ? {
132
+ inputTokens: response.usage.prompt_tokens,
133
+ outputTokens: response.usage.completion_tokens,
134
+ totalTokens: response.usage.total_tokens,
135
+ }
136
+ : undefined,
104
137
  model: response.model,
105
138
  requestId: response.id,
106
139
  };
107
140
  }
108
141
 
109
142
  /** Execute Perplexity web search */
110
- export async function searchPerplexity(params: PerplexitySearchParams): Promise<WebSearchResponse> {
143
+ export async function searchPerplexity(params: PerplexitySearchParams): Promise<SearchResponse> {
111
144
  const apiKey = findApiKey();
112
145
  if (!apiKey) {
113
146
  throw new Error("PERPLEXITY_API_KEY not found. Set it in environment or .env file.");
114
147
  }
115
148
 
149
+ const systemPrompt = params.system_prompt;
116
150
  const messages: PerplexityRequest["messages"] = [];
117
- if (params.system_prompt) {
118
- messages.push({ role: "system", content: params.system_prompt });
151
+ if (systemPrompt) {
152
+ messages.push({ role: "system", content: systemPrompt });
119
153
  }
120
154
  messages.push({ role: "user", content: params.query });
121
155
 
122
156
  const request: PerplexityRequest = {
123
157
  model: "sonar-pro",
124
158
  messages,
125
- return_related_questions: false,
159
+ max_tokens: params.max_tokens ?? DEFAULT_MAX_TOKENS,
160
+ temperature: params.temperature ?? DEFAULT_TEMPERATURE,
161
+ search_mode: "web",
162
+ num_search_results: params.num_search_results ?? DEFAULT_NUM_SEARCH_RESULTS,
126
163
  web_search_options: {
127
- search_context_size: "high",
164
+ search_type: "pro",
165
+ search_context_size: "medium",
128
166
  },
167
+ enable_search_classifier: true,
168
+ reasoning_effort: "medium",
169
+ language_preference: "en",
129
170
  };
130
171
 
131
172
  if (params.search_recency_filter) {
@@ -142,3 +183,29 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise<
142
183
 
143
184
  return result;
144
185
  }
186
+
187
+ /** Search provider for Perplexity. */
188
+ export class PerplexityProvider extends SearchProvider {
189
+ readonly id = "perplexity";
190
+ readonly label = "Perplexity";
191
+
192
+ isAvailable() {
193
+ try {
194
+ return !!findApiKey();
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ search(params: SearchParams): Promise<SearchResponse> {
201
+ return searchPerplexity({
202
+ query: params.query,
203
+ temperature: params.temperature,
204
+ max_tokens: params.maxOutputTokens,
205
+ num_search_results: params.numSearchResults,
206
+ system_prompt: params.systemPrompt,
207
+ search_recency_filter: params.recency,
208
+ num_results: params.limit,
209
+ });
210
+ }
211
+ }