@oh-my-pi/pi-coding-agent 15.7.5 → 15.8.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 (146) hide show
  1. package/CHANGELOG.md +159 -182
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +11 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  12. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  13. package/dist/types/eval/heartbeat.d.ts +45 -0
  14. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  15. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  16. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  17. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  18. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  19. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  20. package/dist/types/lsp/index.d.ts +2 -0
  21. package/dist/types/lsp/utils.d.ts +4 -0
  22. package/dist/types/main.d.ts +5 -0
  23. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  24. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  25. package/dist/types/modes/components/custom-editor.d.ts +3 -2
  26. package/dist/types/modes/components/hook-selector.d.ts +10 -1
  27. package/dist/types/modes/components/session-selector.d.ts +32 -5
  28. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  29. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -3
  30. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  31. package/dist/types/modes/interactive-mode.d.ts +10 -3
  32. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  33. package/dist/types/modes/theme/theme.d.ts +1 -1
  34. package/dist/types/modes/types.d.ts +5 -3
  35. package/dist/types/registry/agent-registry.d.ts +1 -1
  36. package/dist/types/sdk.d.ts +2 -2
  37. package/dist/types/session/agent-session.d.ts +12 -3
  38. package/dist/types/session/history-storage.d.ts +16 -1
  39. package/dist/types/session/session-manager.d.ts +4 -0
  40. package/dist/types/task/output-manager.d.ts +6 -15
  41. package/dist/types/tools/ask.d.ts +8 -6
  42. package/dist/types/tools/eval-backends.d.ts +12 -0
  43. package/dist/types/tools/eval-render.d.ts +52 -0
  44. package/dist/types/tools/eval.d.ts +2 -35
  45. package/dist/types/tools/find.d.ts +0 -9
  46. package/dist/types/tools/index.d.ts +5 -12
  47. package/dist/types/tools/path-utils.d.ts +16 -0
  48. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  49. package/dist/types/tui/output-block.d.ts +4 -3
  50. package/dist/types/utils/clipboard.d.ts +4 -0
  51. package/dist/types/web/kagi.d.ts +76 -0
  52. package/dist/types/web/search/providers/exa.d.ts +7 -1
  53. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  54. package/examples/extensions/README.md +1 -0
  55. package/examples/extensions/thinking-note.ts +13 -0
  56. package/package.json +9 -9
  57. package/src/async/job-manager.ts +3 -3
  58. package/src/cli/args.ts +6 -2
  59. package/src/cli/claude-trace-cli.ts +783 -0
  60. package/src/cli/session-picker.ts +36 -10
  61. package/src/cli/update-cli.ts +35 -2
  62. package/src/commands/launch.ts +3 -0
  63. package/src/config/keybindings.ts +6 -0
  64. package/src/config/model-registry.ts +33 -4
  65. package/src/config/settings-schema.ts +12 -2
  66. package/src/config/settings.ts +23 -0
  67. package/src/discovery/claude-plugins.ts +7 -9
  68. package/src/discovery/claude.ts +41 -22
  69. package/src/edit/index.ts +23 -3
  70. package/src/eval/__tests__/agent-bridge.test.ts +148 -4
  71. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  72. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  73. package/src/eval/agent-bridge.ts +44 -38
  74. package/src/eval/concurrency-bridge.ts +34 -0
  75. package/src/eval/heartbeat.ts +74 -0
  76. package/src/eval/js/executor.ts +13 -9
  77. package/src/eval/js/shared/prelude.txt +20 -17
  78. package/src/eval/js/tool-bridge.ts +5 -0
  79. package/src/eval/llm-bridge.ts +20 -14
  80. package/src/eval/py/executor.ts +14 -18
  81. package/src/eval/py/prelude.py +23 -15
  82. package/src/exec/bash-executor.ts +31 -5
  83. package/src/extensibility/extensions/loader.ts +16 -18
  84. package/src/extensibility/extensions/runner.ts +22 -17
  85. package/src/extensibility/extensions/types.ts +39 -5
  86. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  87. package/src/extensibility/skills.ts +0 -1
  88. package/src/internal-urls/docs-index.generated.ts +14 -13
  89. package/src/lsp/diagnostics-ledger.ts +51 -0
  90. package/src/lsp/index.ts +9 -22
  91. package/src/lsp/utils.ts +21 -0
  92. package/src/main.ts +92 -24
  93. package/src/modes/acp/acp-agent.ts +8 -4
  94. package/src/modes/acp/acp-event-mapper.ts +54 -4
  95. package/src/modes/components/assistant-message.ts +28 -1
  96. package/src/modes/components/custom-editor.ts +19 -7
  97. package/src/modes/components/hook-selector.ts +229 -44
  98. package/src/modes/components/oauth-selector.ts +12 -6
  99. package/src/modes/components/session-selector.ts +179 -24
  100. package/src/modes/components/tool-execution.ts +36 -7
  101. package/src/modes/controllers/command-controller.ts +2 -11
  102. package/src/modes/controllers/event-controller.ts +5 -2
  103. package/src/modes/controllers/extension-ui-controller.ts +6 -4
  104. package/src/modes/controllers/input-controller.ts +19 -16
  105. package/src/modes/controllers/selector-controller.ts +61 -21
  106. package/src/modes/interactive-mode.ts +127 -16
  107. package/src/modes/rpc/rpc-mode.ts +17 -6
  108. package/src/modes/theme/theme-schema.json +30 -0
  109. package/src/modes/theme/theme.ts +39 -2
  110. package/src/modes/types.ts +7 -3
  111. package/src/modes/utils/ui-helpers.ts +5 -2
  112. package/src/prompts/system/orchestrate-notice.md +5 -3
  113. package/src/prompts/system/workflow-notice.md +2 -2
  114. package/src/prompts/tools/ask.md +2 -1
  115. package/src/prompts/tools/eval.md +6 -6
  116. package/src/prompts/tools/find.md +1 -1
  117. package/src/prompts/tools/irc.md +6 -6
  118. package/src/prompts/tools/search.md +1 -1
  119. package/src/prompts/tools/task.md +1 -1
  120. package/src/registry/agent-registry.ts +1 -1
  121. package/src/sdk.ts +85 -31
  122. package/src/session/agent-session.ts +127 -57
  123. package/src/session/history-storage.ts +56 -12
  124. package/src/session/session-manager.ts +34 -0
  125. package/src/task/output-manager.ts +40 -48
  126. package/src/task/render.ts +3 -8
  127. package/src/tools/ask.ts +74 -32
  128. package/src/tools/browser/tab-worker.ts +8 -5
  129. package/src/tools/eval-backends.ts +38 -0
  130. package/src/tools/eval-render.ts +750 -0
  131. package/src/tools/eval.ts +27 -754
  132. package/src/tools/find.ts +5 -29
  133. package/src/tools/index.ts +8 -38
  134. package/src/tools/path-utils.ts +144 -1
  135. package/src/tools/read.ts +47 -0
  136. package/src/tools/renderers.ts +1 -1
  137. package/src/tools/search.ts +2 -27
  138. package/src/tools/sqlite-reader.ts +92 -9
  139. package/src/tools/write.ts +9 -1
  140. package/src/tui/output-block.ts +5 -4
  141. package/src/utils/clipboard.ts +38 -1
  142. package/src/utils/open.ts +37 -2
  143. package/src/web/kagi.ts +168 -49
  144. package/src/web/search/providers/anthropic.ts +1 -1
  145. package/src/web/search/providers/exa.ts +20 -86
  146. package/src/web/search/providers/kagi.ts +4 -0
package/src/utils/open.ts CHANGED
@@ -1,3 +1,36 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as url from "node:url";
4
+ import * as piUtils from "@oh-my-pi/pi-utils";
5
+
6
+ const URL_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
7
+
8
+ function getExistingWslLocalPath(urlOrPath: string): string | undefined {
9
+ if (
10
+ process.platform !== "linux" ||
11
+ !(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) ||
12
+ !piUtils.$which("wslview")
13
+ ) {
14
+ return undefined;
15
+ }
16
+
17
+ try {
18
+ const localPath = urlOrPath.startsWith("file://")
19
+ ? url.fileURLToPath(urlOrPath)
20
+ : URL_SCHEME_PATTERN.test(urlOrPath)
21
+ ? undefined
22
+ : path.resolve(urlOrPath);
23
+ if (!localPath || !fs.existsSync(localPath)) return undefined;
24
+
25
+ const result = Bun.spawnSync(["wslpath", "-w", localPath], { stdout: "pipe", stderr: "ignore" });
26
+ if (result.exitCode !== 0) return undefined;
27
+
28
+ return result.stdout.toString().trim() || undefined;
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
1
34
  /** Open a URL or file path in the default browser/application. Best-effort, never throws. */
2
35
  export function openPath(urlOrPath: string): void {
3
36
  let cmd: string[];
@@ -8,9 +41,11 @@ export function openPath(urlOrPath: string): void {
8
41
  case "win32":
9
42
  cmd = ["rundll32", "url.dll,FileProtocolHandler", urlOrPath];
10
43
  break;
11
- default:
12
- cmd = ["xdg-open", urlOrPath];
44
+ default: {
45
+ const wslPath = getExistingWslLocalPath(urlOrPath);
46
+ cmd = wslPath ? ["wslview", wslPath] : ["xdg-open", urlOrPath];
13
47
  break;
48
+ }
14
49
  }
15
50
  try {
16
51
  Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
package/src/web/kagi.ts CHANGED
@@ -1,42 +1,92 @@
1
+ /**
2
+ * Kagi API Client
3
+ *
4
+ * Implements the Kagi V1 Search API (POST /api/v1/search), the public-preview
5
+ * successor to the sunset V0 endpoint. Authentication is resolved exclusively
6
+ * through the shared {@link AuthStorage} broker (Bearer token), and responses
7
+ * are categorized result buckets rather than the legacy flat object array.
8
+ */
1
9
  import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
10
  import { withHardTimeout } from "./search/providers/utils";
3
11
 
4
- const KAGI_SEARCH_URL = "https://kagi.com/api/v0/search";
12
+ const KAGI_SEARCH_URL = "https://kagi.com/api/v1/search";
5
13
 
6
- interface KagiSearchResultObject {
7
- t: 0;
14
+ // ---------------------------------------------------------------------------
15
+ // Request / Response Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** V1 search request body. */
19
+ export interface KagiSearchRequest {
20
+ query: string;
21
+ /** Workflow mode: "search" | "research". */
22
+ workflow?: string;
23
+ /** Number of results (1-100). */
24
+ limit?: number;
25
+ /** Lens identifier (e.g. "news", "reddit"). */
26
+ lens?: string;
27
+ /** Time-based filters as ISO date strings (YYYY-MM-DD). */
28
+ filters?: {
29
+ after?: string;
30
+ before?: string;
31
+ };
32
+ }
33
+
34
+ /** Individual V1 result item. */
35
+ export interface KagiSearchResultItem {
8
36
  url: string;
9
37
  title: string;
10
38
  snippet?: string;
11
- published?: string;
39
+ /** ISO timestamp or relative string ("2h ago"). */
40
+ time?: string;
41
+ /** Thumbnail image. */
42
+ image?: { url: string; height?: number; width?: number };
43
+ /** Extra metadata key-value pairs. */
44
+ props?: Record<string, unknown>;
12
45
  }
13
46
 
14
- interface KagiRelatedSearchesObject {
15
- t: 1;
16
- list: string[];
47
+ /** V1 categorizes results into named buckets; only consumed buckets are typed. */
48
+ export interface KagiSearchData {
49
+ search?: KagiSearchResultItem[];
50
+ video?: KagiSearchResultItem[];
51
+ news?: KagiSearchResultItem[];
52
+ infobox?: KagiSearchResultItem[];
53
+ adjacent_question?: KagiSearchResultItem[];
54
+ related_search?: KagiSearchResultItem[];
55
+ direct_answer?: KagiSearchResultItem[];
17
56
  }
18
57
 
19
- type KagiSearchObject = KagiSearchResultObject | KagiRelatedSearchesObject;
20
-
21
- interface KagiErrorEntry {
58
+ /** V1 error entry. */
59
+ export interface KagiErrorEntry {
22
60
  code?: number;
61
+ url?: string;
62
+ message?: string;
23
63
  msg?: string;
64
+ location?: string;
24
65
  }
25
66
 
26
- interface KagiSearchResponse {
27
- meta: {
28
- id: string;
67
+ /** V1 success response. */
68
+ export interface KagiSearchResponse {
69
+ meta?: {
70
+ trace?: string;
71
+ id?: string;
72
+ ms?: number;
29
73
  };
30
- data: KagiSearchObject[];
74
+ data?: KagiSearchData;
31
75
  error?: KagiErrorEntry[];
32
76
  }
33
77
 
34
- interface KagiErrorResponse {
78
+ /** V1 error response. */
79
+ export interface KagiErrorResponse {
80
+ meta?: Record<string, unknown>;
35
81
  error?: string | KagiErrorEntry[];
36
82
  message?: string;
37
83
  detail?: string;
38
84
  }
39
85
 
86
+ // ---------------------------------------------------------------------------
87
+ // Error Handling
88
+ // ---------------------------------------------------------------------------
89
+
40
90
  export class KagiApiError extends Error {
41
91
  readonly statusCode?: number;
42
92
 
@@ -64,9 +114,11 @@ function extractKagiErrorMessage(payload: unknown): string | null {
64
114
  if (Array.isArray(record.error)) {
65
115
  for (const entry of record.error) {
66
116
  if (!entry || typeof entry !== "object") continue;
67
- const message = (entry as Record<string, unknown>).msg;
68
- if (typeof message === "string" && message.trim().length > 0) {
69
- return message.trim();
117
+ const e = entry as Record<string, unknown>;
118
+ for (const value of [e.message, e.msg]) {
119
+ if (typeof value === "string" && value.trim().length > 0) {
120
+ return value.trim();
121
+ }
70
122
  }
71
123
  }
72
124
  }
@@ -82,21 +134,26 @@ function createKagiApiError(statusCode: number, detail?: string): KagiApiError {
82
134
  }
83
135
 
84
136
  function parseKagiErrorResponse(statusCode: number, responseText: string): KagiApiError {
85
- const trimmedResponseText = responseText.trim();
86
- if (trimmedResponseText.length === 0) {
137
+ const trimmed = responseText.trim();
138
+ if (trimmed.length === 0) {
87
139
  return createKagiApiError(statusCode);
88
140
  }
89
141
 
90
142
  try {
91
- const payload = JSON.parse(trimmedResponseText) as KagiErrorResponse;
92
- return createKagiApiError(statusCode, extractKagiErrorMessage(payload) ?? trimmedResponseText);
143
+ const payload = JSON.parse(trimmed) as KagiErrorResponse;
144
+ return createKagiApiError(statusCode, extractKagiErrorMessage(payload) ?? trimmed);
93
145
  } catch {
94
- return createKagiApiError(statusCode, trimmedResponseText);
146
+ return createKagiApiError(statusCode, trimmed);
95
147
  }
96
148
  }
97
149
 
150
+ // ---------------------------------------------------------------------------
151
+ // Public API
152
+ // ---------------------------------------------------------------------------
153
+
98
154
  export interface KagiSearchOptions {
99
155
  limit?: number;
156
+ recency?: "day" | "week" | "month" | "year";
100
157
  sessionId?: string;
101
158
  signal?: AbortSignal;
102
159
  }
@@ -112,6 +169,7 @@ export interface KagiSearchResult {
112
169
  requestId: string;
113
170
  sources: KagiSearchSource[];
114
171
  relatedQuestions: string[];
172
+ answer?: string;
115
173
  }
116
174
 
117
175
  export async function findKagiApiKey(
@@ -122,11 +180,65 @@ export async function findKagiApiKey(
122
180
  return (await authStorage.getApiKey("kagi", sessionId, { signal })) ?? null;
123
181
  }
124
182
 
125
- function getAuthHeaders(apiKey: string): Record<string, string> {
126
- return {
127
- Authorization: `Bot ${apiKey}`,
128
- Accept: "application/json",
183
+ /**
184
+ * Compute a YYYY-MM-DD date string `recency` units before now, in UTC.
185
+ * UTC keeps the recency window deterministic regardless of host timezone and
186
+ * matches Kagi's date-formatted `filters.after`. Date setters handle month
187
+ * drift (Mar 31 −1mo → Feb 28/29) and leap years correctly.
188
+ */
189
+ function recencyToDate(recency: "day" | "week" | "month" | "year"): string {
190
+ const d = new Date();
191
+ switch (recency) {
192
+ case "day":
193
+ d.setUTCDate(d.getUTCDate() - 1);
194
+ break;
195
+ case "week":
196
+ d.setUTCDate(d.getUTCDate() - 7);
197
+ break;
198
+ case "month":
199
+ d.setUTCMonth(d.getUTCMonth() - 1);
200
+ break;
201
+ case "year":
202
+ d.setUTCFullYear(d.getUTCFullYear() - 1);
203
+ break;
204
+ }
205
+ const yyyy = d.getUTCFullYear();
206
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
207
+ const dd = String(d.getUTCDate()).padStart(2, "0");
208
+ return `${yyyy}-${mm}-${dd}`;
209
+ }
210
+
211
+ function buildRequestBody(query: string, options: KagiSearchOptions): KagiSearchRequest {
212
+ const req: KagiSearchRequest = {
213
+ query,
214
+ workflow: "search",
215
+ limit: options.limit,
129
216
  };
217
+
218
+ if (options.recency) {
219
+ req.filters = { after: recencyToDate(options.recency) };
220
+ }
221
+
222
+ return req;
223
+ }
224
+
225
+ /** Push every item in a result bucket as a source, with an optional title tag. */
226
+ function collectSources(sources: KagiSearchSource[], items: KagiSearchResultItem[] | undefined, tag?: string): void {
227
+ if (!items) return;
228
+ for (const item of items) {
229
+ sources.push({
230
+ title: tag ? `${tag} ${item.title}` : item.title,
231
+ url: item.url,
232
+ snippet: item.snippet,
233
+ publishedDate: item.time,
234
+ });
235
+ }
236
+ }
237
+
238
+ /** Pull a related/adjacent question from an item's props or fall back to title. */
239
+ function questionOf(item: KagiSearchResultItem): string | undefined {
240
+ const q = item.props?.question ?? item.props?.query ?? item.title;
241
+ return typeof q === "string" && q.length > 0 ? q : undefined;
130
242
  }
131
243
 
132
244
  export async function searchWithKagi(
@@ -139,45 +251,52 @@ export async function searchWithKagi(
139
251
  throw new KagiApiError("Kagi credentials not found. Set KAGI_API_KEY or login with 'omp /login kagi'.");
140
252
  }
141
253
 
142
- const requestUrl = new URL(KAGI_SEARCH_URL);
143
- requestUrl.searchParams.set("q", query);
144
- if (options.limit !== undefined) {
145
- requestUrl.searchParams.set("limit", String(options.limit));
146
- }
147
-
148
- const response = await fetch(requestUrl, {
149
- headers: getAuthHeaders(apiKey),
254
+ const response = await fetch(KAGI_SEARCH_URL, {
255
+ method: "POST",
256
+ headers: {
257
+ Authorization: `Bearer ${apiKey}`,
258
+ "Content-Type": "application/json",
259
+ Accept: "application/json",
260
+ },
261
+ body: JSON.stringify(buildRequestBody(query, options)),
150
262
  signal: withHardTimeout(options.signal),
151
263
  });
264
+
152
265
  if (!response.ok) {
153
266
  throw parseKagiErrorResponse(response.status, await response.text());
154
267
  }
155
268
 
156
269
  const payload = (await response.json()) as KagiSearchResponse;
157
270
  if (payload.error && payload.error.length > 0) {
158
- const firstError = payload.error[0];
159
- throw createKagiApiError(firstError.code ?? response.status, extractKagiErrorMessage(payload) ?? undefined);
271
+ const first = payload.error[0];
272
+ throw createKagiApiError(first.code ?? response.status, extractKagiErrorMessage(payload) ?? first.message);
160
273
  }
161
274
 
275
+ const data = payload.data;
162
276
  const sources: KagiSearchSource[] = [];
163
277
  const relatedQuestions: string[] = [];
164
278
 
165
- for (const item of payload.data) {
166
- if (item.t === 0) {
167
- sources.push({
168
- title: item.title,
169
- url: item.url,
170
- snippet: item.snippet,
171
- publishedDate: item.published ?? undefined,
172
- });
173
- } else if (item.t === 1) {
174
- relatedQuestions.push(...item.list);
175
- }
279
+ collectSources(sources, data?.search);
280
+ collectSources(sources, data?.video, "[Video]");
281
+ collectSources(sources, data?.news, "[News]");
282
+ collectSources(sources, data?.infobox, "[Info]");
283
+
284
+ for (const item of data?.adjacent_question ?? []) {
285
+ const q = questionOf(item);
286
+ if (q) relatedQuestions.push(q);
287
+ }
288
+ for (const item of data?.related_search ?? []) {
289
+ const q = questionOf(item);
290
+ if (q) relatedQuestions.push(q);
176
291
  }
177
292
 
293
+ const directAnswer = data?.direct_answer?.[0];
294
+ const answer = directAnswer ? (directAnswer.snippet ?? directAnswer.title) : undefined;
295
+
178
296
  return {
179
- requestId: payload.meta.id,
297
+ requestId: payload.meta?.trace ?? payload.meta?.id ?? "",
180
298
  sources,
181
299
  relatedQuestions,
300
+ answer,
182
301
  };
183
302
  }
@@ -255,7 +255,7 @@ export async function searchAnthropic(
255
255
  const apiKey = await params.authStorage.getApiKey("anthropic", params.sessionId, {
256
256
  signal: params.signal,
257
257
  });
258
- if (apiKey) auth = buildAnthropicAuthConfig(apiKey);
258
+ if (apiKey) auth = buildAnthropicAuthConfig(apiKey, searchBaseUrl);
259
259
  }
260
260
 
261
261
  if (!auth) {
@@ -8,7 +8,6 @@
8
8
  */
9
9
  import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
10
10
  import { settings } from "../../../config/settings";
11
- import { callExaTool, findApiKey, isSearchResponse } from "../../../exa/mcp-client";
12
11
 
13
12
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
14
13
  import { SearchProviderError } from "../../../web/search/types";
@@ -32,6 +31,12 @@ export interface ExaSearchParams {
32
31
  start_published_date?: string;
33
32
  end_published_date?: string;
34
33
  signal?: AbortSignal;
34
+ /**
35
+ * Credential source. Resolved before falling back to `EXA_API_KEY` so
36
+ * Exa works when the key is stored via the broker/auth pipeline.
37
+ */
38
+ authStorage?: AuthStorage;
39
+ sessionId?: string;
35
40
  }
36
41
 
37
42
  interface ExaSearchResult {
@@ -52,72 +57,6 @@ interface ExaSearchResponse {
52
57
  searchTime?: number;
53
58
  }
54
59
 
55
- function asRecord(value: unknown): Record<string, unknown> | null {
56
- if (typeof value !== "object" || value === null) return null;
57
- return value as Record<string, unknown>;
58
- }
59
-
60
- function parseOptionalField(section: string, label: string): string | null | undefined {
61
- const regex = new RegExp(`(?:^|\\n)${label}:\\s*([^\\n]*)`);
62
- const match = section.match(regex);
63
- if (!match) return undefined;
64
- const value = match[1].trim();
65
- return value.length > 0 ? value : null;
66
- }
67
-
68
- function parseTextField(section: string): string | null | undefined {
69
- const match = section.match(/(?:^|\n)Text:\s*([\s\S]*)$/);
70
- if (!match) return undefined;
71
- const value = match[1].trim();
72
- return value.length > 0 ? value : null;
73
- }
74
-
75
- function parseExaMcpTextPayload(payload: unknown): ExaSearchResponse | null {
76
- const root = asRecord(payload);
77
- if (!root) return null;
78
-
79
- const content = root.content;
80
- if (!Array.isArray(content)) return null;
81
-
82
- const textBlocks = content
83
- .map(item => {
84
- const part = asRecord(item);
85
- const text = typeof part?.text === "string" ? part.text : "";
86
- return text.replace(/\r\n?/g, "\n").trim();
87
- })
88
- .filter(text => text.length > 0);
89
-
90
- if (textBlocks.length === 0) return null;
91
-
92
- const sections = textBlocks
93
- .join("\n\n")
94
- .split(/\n{2,}(?=Title:\s*[^\n]*(?:\n(?:URL|Author|Published Date|Text):))/)
95
- .map(section => section.trim())
96
- .filter(section => section.startsWith("Title:"));
97
-
98
- const results: ExaSearchResult[] = [];
99
- for (const section of sections) {
100
- const title = parseOptionalField(section, "Title");
101
- const url = parseOptionalField(section, "URL");
102
- const author = parseOptionalField(section, "Author");
103
- const publishedDate = parseOptionalField(section, "Published Date");
104
- const text = parseTextField(section);
105
-
106
- if (!title && !url && !text) continue;
107
-
108
- results.push({
109
- title: title ?? undefined,
110
- url: url ?? undefined,
111
- author: author ?? undefined,
112
- publishedDate: publishedDate ?? undefined,
113
- text: text ?? undefined,
114
- });
115
- }
116
-
117
- if (results.length === 0) return null;
118
- return { results };
119
- }
120
-
121
60
  export function normalizeSearchType(type: ExaSearchParamType | undefined): ExaSearchType {
122
61
  if (!type) return "auto";
123
62
  if (type === "keyword") return "fast";
@@ -195,24 +134,17 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
195
134
  return response.json() as Promise<ExaSearchResponse>;
196
135
  }
197
136
 
198
- async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchResponse> {
199
- const response = await callExaTool("web_search_exa", { ...params }, findApiKey());
200
- if (isSearchResponse(response)) {
201
- return response as ExaSearchResponse;
202
- }
203
-
204
- const parsed = parseExaMcpTextPayload(response);
205
- if (parsed) {
206
- return parsed;
207
- }
208
-
209
- throw new Error("Exa MCP search returned unexpected response shape.");
210
- }
211
-
212
137
  /** Execute Exa web search */
213
138
  export async function searchExa(params: ExaSearchParams): Promise<SearchResponse> {
214
- const apiKey = getEnvApiKey("exa");
215
- const response = apiKey ? await callExaSearch(apiKey, params) : await callExaMcpSearch(params);
139
+ const storedKey = params.authStorage
140
+ ? await params.authStorage.getApiKey("exa", params.sessionId, { signal: params.signal })
141
+ : undefined;
142
+ const apiKey = storedKey ?? getEnvApiKey("exa");
143
+ if (!apiKey) {
144
+ throw new Error("Exa credentials not found. Set EXA_API_KEY or login with 'omp /login exa'.");
145
+ }
146
+
147
+ const response = await callExaSearch(apiKey, params);
216
148
 
217
149
  // Convert to unified SearchResponse
218
150
  const sources: SearchSource[] = [];
@@ -250,15 +182,15 @@ export class ExaProvider extends SearchProvider {
250
182
  readonly id = "exa";
251
183
  readonly label = "Exa";
252
184
 
253
- isAvailable(_authStorage: AuthStorage): boolean {
185
+ isAvailable(authStorage: AuthStorage): boolean {
254
186
  try {
255
187
  if (settings.get("exa.enabled") === false || settings.get("exa.enableSearch") === false) {
256
188
  return false;
257
189
  }
258
190
  } catch {
259
- // Settings not initialized; fall through to public MCP availability
191
+ // Settings may be unavailable before CLI initialization; credential availability is still authoritative.
260
192
  }
261
- return true;
193
+ return authStorage.hasAuth("exa");
262
194
  }
263
195
 
264
196
  search(params: SearchParams): Promise<SearchResponse> {
@@ -266,6 +198,8 @@ export class ExaProvider extends SearchProvider {
266
198
  query: params.query,
267
199
  num_results: params.numSearchResults ?? params.limit,
268
200
  signal: params.signal,
201
+ authStorage: params.authStorage,
202
+ sessionId: params.sessionId,
269
203
  });
270
204
  }
271
205
  }
@@ -19,6 +19,7 @@ const MAX_NUM_RESULTS = 40;
19
19
  export async function searchKagi(params: {
20
20
  query: string;
21
21
  num_results?: number;
22
+ recency?: SearchParams["recency"];
22
23
  signal?: AbortSignal;
23
24
  authStorage: AuthStorage;
24
25
  sessionId?: string;
@@ -30,6 +31,7 @@ export async function searchKagi(params: {
30
31
  params.query,
31
32
  {
32
33
  limit: numResults,
34
+ recency: params.recency,
33
35
  sessionId: params.sessionId,
34
36
  signal: params.signal,
35
37
  },
@@ -41,6 +43,7 @@ export async function searchKagi(params: {
41
43
  sources: toSearchSources(result.sources, numResults),
42
44
  relatedQuestions: result.relatedQuestions.length > 0 ? result.relatedQuestions : undefined,
43
45
  requestId: result.requestId,
46
+ answer: result.answer,
44
47
  };
45
48
  } catch (err) {
46
49
  if (err instanceof KagiApiError) {
@@ -67,6 +70,7 @@ export class KagiProvider extends SearchProvider {
67
70
  return searchKagi({
68
71
  query: params.query,
69
72
  num_results: params.numSearchResults ?? params.limit,
73
+ recency: params.recency,
70
74
  signal: params.signal,
71
75
  authStorage: params.authStorage,
72
76
  sessionId: params.sessionId,