@oh-my-pi/pi-coding-agent 17.2.12 → 17.2.13

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 (134) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/{CHANGELOG-k9ghy5sn.md → CHANGELOG-d8xh7keh.md} +57 -0
  3. package/dist/cli.js +3069 -3047
  4. package/dist/types/advisor/delta-split.d.ts +24 -0
  5. package/dist/types/advisor/runtime.d.ts +2 -2
  6. package/dist/types/async/job-manager.d.ts +8 -1
  7. package/dist/types/cli/update-cli.d.ts +53 -1
  8. package/dist/types/config/keybindings.d.ts +10 -0
  9. package/dist/types/config/model-resolver.d.ts +15 -2
  10. package/dist/types/config/settings-schema.d.ts +4 -0
  11. package/dist/types/discovery/agents-md.d.ts +10 -1
  12. package/dist/types/eval/runner-cache.d.ts +12 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +12 -3
  14. package/dist/types/extensibility/extensions/types.d.ts +15 -4
  15. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +4 -1
  16. package/dist/types/lib/xai-http.d.ts +0 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +8 -5
  18. package/dist/types/modes/components/agent-hub-renderer.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/types.d.ts +4 -0
  20. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -4
  21. package/dist/types/modes/interactive-mode.d.ts +17 -8
  22. package/dist/types/modes/types.d.ts +7 -10
  23. package/dist/types/modes/utils/hotkeys-markdown.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +2 -0
  25. package/dist/types/session/agent-session.d.ts +22 -3
  26. package/dist/types/session/messages.d.ts +20 -0
  27. package/dist/types/session/retry-fallback-chains.d.ts +13 -0
  28. package/dist/types/session/session-advisors.d.ts +1 -1
  29. package/dist/types/session/session-history-format.d.ts +10 -0
  30. package/dist/types/session/session-maintenance.d.ts +1 -1
  31. package/dist/types/session/session-tools.d.ts +24 -5
  32. package/dist/types/session/turn-recovery.d.ts +34 -5
  33. package/dist/types/slash-commands/types.d.ts +5 -1
  34. package/dist/types/task/executor.d.ts +1 -1
  35. package/dist/types/tools/approval.d.ts +7 -0
  36. package/dist/types/tools/todo.d.ts +14 -15
  37. package/dist/types/tools/write.d.ts +2 -2
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/dist/types/vibe/runtime.d.ts +1 -1
  40. package/dist/types/web/parallel.d.ts +1 -0
  41. package/dist/types/web/search/providers/brave.d.ts +8 -3
  42. package/dist/types/web/search/providers/codex.d.ts +6 -0
  43. package/dist/types/web/search/providers/firecrawl.d.ts +3 -2
  44. package/dist/types/web/search/providers/jina.d.ts +3 -3
  45. package/dist/types/web/search/providers/parallel.d.ts +1 -0
  46. package/dist/types/web/search/providers/perplexity.d.ts +4 -0
  47. package/dist/types/web/search/providers/tinyfish.d.ts +2 -0
  48. package/package.json +13 -13
  49. package/src/advisor/delta-split.ts +98 -0
  50. package/src/advisor/runtime.ts +321 -69
  51. package/src/async/job-manager.ts +14 -3
  52. package/src/cli/plugin-cli.ts +30 -2
  53. package/src/cli/update-cli.ts +259 -24
  54. package/src/config/keybindings.ts +52 -9
  55. package/src/config/model-resolver.ts +19 -3
  56. package/src/config/settings-schema.ts +5 -0
  57. package/src/cursor.ts +10 -5
  58. package/src/discovery/agents-md.ts +61 -23
  59. package/src/eval/jl/kernel.ts +2 -20
  60. package/src/eval/py/kernel.ts +2 -20
  61. package/src/eval/rb/kernel.ts +2 -20
  62. package/src/eval/runner-cache.ts +41 -0
  63. package/src/exec/non-interactive-env.ts +14 -3
  64. package/src/extensibility/extensions/loader.ts +5 -2
  65. package/src/extensibility/extensions/runner.ts +184 -66
  66. package/src/extensibility/extensions/types.ts +26 -2
  67. package/src/extensibility/extensions/wrapper.ts +13 -7
  68. package/src/extensibility/plugins/marketplace/manager.ts +6 -2
  69. package/src/hindsight/client.ts +1 -1
  70. package/src/lib/xai-http.ts +0 -4
  71. package/src/lsp/client.ts +2 -0
  72. package/src/lsp/servers.ts +1 -1
  73. package/src/mcp/tool-bridge.ts +15 -6
  74. package/src/modes/components/agent-hub-renderer.ts +9 -3
  75. package/src/modes/components/agent-hub.ts +2 -1
  76. package/src/modes/components/status-line/component.ts +58 -6
  77. package/src/modes/components/status-line/segments.ts +12 -1
  78. package/src/modes/components/status-line/types.ts +1 -0
  79. package/src/modes/components/user-message.ts +20 -5
  80. package/src/modes/controllers/event-controller.ts +48 -0
  81. package/src/modes/controllers/extension-ui-controller.ts +14 -7
  82. package/src/modes/controllers/input-controller.ts +25 -6
  83. package/src/modes/interactive-mode.ts +315 -129
  84. package/src/modes/rpc/rpc-frame.ts +13 -5
  85. package/src/modes/theme/tui-adapters.ts +4 -5
  86. package/src/modes/types.ts +13 -7
  87. package/src/modes/utils/hotkeys-markdown.ts +10 -6
  88. package/src/prompts/system/system-prompt.md +1 -1
  89. package/src/registry/persisted-agents.ts +43 -8
  90. package/src/sdk.ts +139 -8
  91. package/src/session/agent-session-types.ts +2 -0
  92. package/src/session/agent-session.ts +66 -10
  93. package/src/session/messages.ts +98 -28
  94. package/src/session/retry-fallback-chains.ts +14 -0
  95. package/src/session/session-advisors.ts +32 -15
  96. package/src/session/session-history-format.ts +15 -1
  97. package/src/session/session-maintenance.ts +8 -8
  98. package/src/session/session-manager.ts +6 -2
  99. package/src/session/session-tools.ts +321 -184
  100. package/src/session/turn-recovery.ts +225 -47
  101. package/src/slash-commands/builtin-modes.ts +41 -12
  102. package/src/slash-commands/types.ts +5 -1
  103. package/src/task/executor.ts +92 -45
  104. package/src/task/structured-subagent.ts +5 -5
  105. package/src/tools/approval.ts +44 -10
  106. package/src/tools/fetch.ts +21 -2
  107. package/src/tools/image-gen.ts +6 -8
  108. package/src/tools/todo.ts +70 -26
  109. package/src/tools/tts.ts +3 -2
  110. package/src/tools/write.ts +7 -3
  111. package/src/utils/local-date.ts +13 -0
  112. package/src/utils/tools-manager.ts +2 -2
  113. package/src/vibe/runtime.ts +22 -14
  114. package/src/web/kagi.ts +91 -34
  115. package/src/web/parallel.ts +11 -2
  116. package/src/web/scrapers/crates-io.ts +2 -2
  117. package/src/web/scrapers/discogs.ts +2 -2
  118. package/src/web/scrapers/docs-rs.ts +2 -2
  119. package/src/web/scrapers/github.ts +2 -2
  120. package/src/web/scrapers/musicbrainz.ts +1 -2
  121. package/src/web/scrapers/pubmed.ts +2 -2
  122. package/src/web/scrapers/sec-edgar.ts +2 -2
  123. package/src/web/search/providers/brave.ts +121 -46
  124. package/src/web/search/providers/codex.ts +88 -12
  125. package/src/web/search/providers/exa.ts +45 -10
  126. package/src/web/search/providers/firecrawl.ts +53 -11
  127. package/src/web/search/providers/gemini.ts +139 -27
  128. package/src/web/search/providers/jina.ts +48 -25
  129. package/src/web/search/providers/parallel.ts +23 -9
  130. package/src/web/search/providers/perplexity.ts +24 -7
  131. package/src/web/search/providers/searxng.ts +77 -1
  132. package/src/web/search/providers/tavily.ts +23 -22
  133. package/src/web/search/providers/tinyfish.ts +44 -10
  134. package/src/web/search/providers/xai.ts +85 -14
@@ -62,6 +62,7 @@ interface SearXNGResult {
62
62
  title?: string;
63
63
  url?: string;
64
64
  content?: string;
65
+ snippet?: string;
65
66
  engine?: string;
66
67
  publishedDate?: string;
67
68
  /** SearXNG sometimes uses publishedDate, sometimes just date */
@@ -76,6 +77,7 @@ interface SearXNGResponse {
76
77
  suggestions?: string[];
77
78
  corrections?: string[];
78
79
  unresponsive_engines?: Array<[string, string]>;
80
+ answers?: unknown[];
79
81
  }
80
82
 
81
83
  interface SearXNGAuth {
@@ -272,6 +274,61 @@ function stripExternalBangs(query: string): string {
272
274
  .join(" ");
273
275
  }
274
276
 
277
+ /** Extract displayable text from both legacy string answers and modern
278
+ * structured answer plugins (legacy, translations, weather). */
279
+ function extractAnswerText(answer: unknown): string | undefined {
280
+ if (typeof answer === "string") return answer.trim() || undefined;
281
+ if (!answer || typeof answer !== "object") return undefined;
282
+
283
+ const record = answer as Record<string, unknown>;
284
+ if (typeof record.answer === "string") return record.answer.trim() || undefined;
285
+
286
+ if (Array.isArray(record.translations)) {
287
+ const translations: string[] = [];
288
+ for (const item of record.translations) {
289
+ if (!item || typeof item !== "object") continue;
290
+ const text = (item as Record<string, unknown>).text;
291
+ if (typeof text === "string" && text.trim()) translations.push(text.trim());
292
+ if (translations.length === 3) break;
293
+ }
294
+ if (translations.length) return translations.join("\n");
295
+ }
296
+
297
+ if (record.current && typeof record.current === "object") {
298
+ const current = record.current as Record<string, unknown>;
299
+ if (typeof current.summary === "string" && current.summary.trim()) return current.summary.trim();
300
+ const location =
301
+ current.location && typeof current.location === "object"
302
+ ? (current.location as Record<string, unknown>).name
303
+ : undefined;
304
+ const temperature =
305
+ current.temperature && typeof current.temperature === "object"
306
+ ? (current.temperature as Record<string, unknown>)
307
+ : undefined;
308
+ const temperatureText =
309
+ temperature && (typeof temperature.val === "string" || typeof temperature.val === "number")
310
+ ? `${temperature.val}${typeof temperature.unit === "string" ? temperature.unit : ""}`
311
+ : undefined;
312
+ const condition = typeof current.condition === "string" ? current.condition : undefined;
313
+ const parts = [location, temperatureText, condition].filter(
314
+ (part): part is string => typeof part === "string" && part.trim().length > 0,
315
+ );
316
+ if (parts.length) return parts.join(": ");
317
+ }
318
+
319
+ return undefined;
320
+ }
321
+
322
+ function formatAnswers(answers: unknown[] | undefined): string | undefined {
323
+ const texts: string[] = [];
324
+ for (const answer of answers ?? []) {
325
+ const text = extractAnswerText(answer);
326
+ if (text) texts.push(text);
327
+ if (texts.length === 3) break;
328
+ }
329
+ return texts.length ? texts.join("\n\n") : undefined;
330
+ }
331
+
275
332
  /** Build the search URL and headers for a SearXNG request */
276
333
  function buildRequest(
277
334
  endpoint: string,
@@ -282,6 +339,7 @@ function buildRequest(
282
339
  categories?: string;
283
340
  engines?: string;
284
341
  language?: string;
342
+ safesearch?: 0 | 1 | 2;
285
343
  signal?: AbortSignal;
286
344
  },
287
345
  auth: SearXNGAuth | null,
@@ -308,6 +366,10 @@ function buildRequest(
308
366
  url.searchParams.set("engines", params.engines);
309
367
  }
310
368
 
369
+ if (params.safesearch !== undefined) {
370
+ url.searchParams.set("safesearch", String(params.safesearch));
371
+ }
372
+
311
373
  if (params.language) {
312
374
  url.searchParams.set("language", params.language);
313
375
  }
@@ -326,6 +388,7 @@ async function callSearXNGSearch(
326
388
  categories?: string;
327
389
  engines?: string;
328
390
  language?: string;
391
+ safesearch?: 0 | 1 | 2;
329
392
  signal?: AbortSignal;
330
393
  timeoutMs?: number;
331
394
  fetch?: FetchImpl;
@@ -372,12 +435,23 @@ export async function searchSearXNG(params: {
372
435
 
373
436
  let categories: string | undefined;
374
437
  let language: string | undefined;
438
+ let configuredSafesearch: number | undefined;
375
439
  try {
376
440
  categories = settings.get("searxng.categories") ?? undefined;
377
441
  language = settings.get("searxng.language") ?? undefined;
442
+ configuredSafesearch = settings.get("searxng.safesearch");
378
443
  } catch {
379
444
  // Settings not initialized yet
380
445
  }
446
+ if (
447
+ configuredSafesearch !== undefined &&
448
+ configuredSafesearch !== 0 &&
449
+ configuredSafesearch !== 1 &&
450
+ configuredSafesearch !== 2
451
+ ) {
452
+ throw new Error("searxng.safesearch must be 0 (off), 1 (moderate), or 2 (strict).");
453
+ }
454
+ const safesearch = configuredSafesearch;
381
455
  const configuredEngines = findEngines();
382
456
 
383
457
  // SearXNG forwards `q` to downstream engines, so build it with the shared
@@ -402,6 +476,7 @@ export async function searchSearXNG(params: {
402
476
  categories,
403
477
  engines,
404
478
  language,
479
+ safesearch,
405
480
  fetch: params.fetch,
406
481
  },
407
482
  auth,
@@ -415,7 +490,7 @@ export async function searchSearXNG(params: {
415
490
  sources.push({
416
491
  title: result.title ?? result.url,
417
492
  url: result.url,
418
- snippet: result.content?.trim() || undefined,
493
+ snippet: (result.content ?? result.snippet)?.trim() || undefined,
419
494
  publishedDate: publishedDate ?? undefined,
420
495
  ageSeconds: dateToAgeSeconds(publishedDate),
421
496
  });
@@ -435,6 +510,7 @@ export async function searchSearXNG(params: {
435
510
 
436
511
  return {
437
512
  provider: "searxng",
513
+ answer: formatAnswers(response.answers),
438
514
  sources: limitedSources,
439
515
  relatedQuestions: response.suggestions?.length ? response.suggestions : undefined,
440
516
  };
@@ -34,17 +34,10 @@ export interface TavilySearchParams {
34
34
  fetch?: FetchImpl;
35
35
  }
36
36
 
37
- interface TavilySearchResult {
38
- title?: string | null;
39
- url?: string | null;
40
- content?: string | null;
41
- published_date?: string | null;
42
- }
43
-
44
37
  interface TavilySearchResponse {
45
- answer?: string | null;
46
- results?: TavilySearchResult[];
47
- request_id?: string | null;
38
+ answer?: unknown;
39
+ results?: unknown;
40
+ request_id?: unknown;
48
41
  }
49
42
 
50
43
  function asRecord(value: unknown): Record<string, unknown> | null {
@@ -141,28 +134,36 @@ async function callTavilySearch(apiKey: string, params: TavilySearchParams): Pro
141
134
  throw new SearchProviderError("tavily", `Tavily API error (${response.status}): ${message}`, response.status);
142
135
  }
143
136
 
144
- return (await response.json()) as TavilySearchResponse;
137
+ const payload: unknown = await response.json();
138
+ return asRecord(payload) ?? {};
145
139
  }
146
140
 
147
141
  function toSearchResponse(response: TavilySearchResponse, numResults: number): SearchResponse {
148
142
  const sources: SearchSource[] = [];
149
143
 
150
- for (const result of response.results ?? []) {
151
- if (!result.url) continue;
152
- sources.push({
153
- title: result.title ?? result.url,
154
- url: result.url,
155
- snippet: result.content ?? undefined,
156
- publishedDate: result.published_date ?? undefined,
157
- ageSeconds: dateToAgeSeconds(result.published_date ?? undefined),
158
- });
144
+ if (Array.isArray(response.results)) {
145
+ for (const value of response.results) {
146
+ const result = asRecord(value);
147
+ if (!result || typeof result.url !== "string" || !result.url) continue;
148
+ const title = typeof result.title === "string" && result.title ? result.title : result.url;
149
+ const snippet = typeof result.content === "string" ? result.content : undefined;
150
+ const publishedDate = typeof result.published_date === "string" ? result.published_date : undefined;
151
+ sources.push({
152
+ title,
153
+ url: result.url,
154
+ snippet,
155
+ publishedDate,
156
+ ageSeconds: dateToAgeSeconds(publishedDate),
157
+ });
158
+ }
159
159
  }
160
160
 
161
+ const answer = typeof response.answer === "string" ? response.answer.trim() || undefined : undefined;
161
162
  return {
162
163
  provider: "tavily",
163
- answer: response.answer?.trim() || undefined,
164
+ answer,
164
165
  sources: sources.slice(0, numResults),
165
- requestId: response.request_id ?? undefined,
166
+ requestId: typeof response.request_id === "string" ? response.request_id : undefined,
166
167
  authMode: "api_key",
167
168
  };
168
169
  }
@@ -19,7 +19,7 @@ const MAX_NUM_RESULTS = 20;
19
19
  const MAX_PAGE = 10;
20
20
 
21
21
  /** TinyFish is SERP-backed: common Google-style operators pass through. */
22
- const TINYFISH_QUERY_SYNTAX: QuerySyntax = { phrases: true, negation: true, site: true, filetype: true };
22
+ const TINYFISH_QUERY_SYNTAX: QuerySyntax = { phrases: true, negation: true, filetype: true };
23
23
 
24
24
  const RECENCY_MINUTES: Record<NonNullable<SearchParams["recency"]>, number> = {
25
25
  day: 1440,
@@ -33,6 +33,8 @@ export interface TinyFishSearchParams {
33
33
  num_results?: number;
34
34
  recency?: SearchParams["recency"];
35
35
  page?: number;
36
+ include_domains?: string[];
37
+ exclude_domains?: string[];
36
38
  signal?: AbortSignal;
37
39
  timeoutMs?: number;
38
40
  fetch?: FetchImpl;
@@ -66,6 +68,12 @@ async function callTinyFishSearch(apiKey: string, params: TinyFishSearchParams):
66
68
  if (params.recency) {
67
69
  url.searchParams.set("recency_minutes", String(RECENCY_MINUTES[params.recency]));
68
70
  }
71
+ if (params.include_domains?.length) {
72
+ url.searchParams.set("include_domains", params.include_domains.join(","));
73
+ }
74
+ if (params.exclude_domains?.length) {
75
+ url.searchParams.set("exclude_domains", params.exclude_domains.join(","));
76
+ }
69
77
  if (params.num_results !== undefined) {
70
78
  url.searchParams.set("num_results", String(params.num_results));
71
79
  }
@@ -96,18 +104,35 @@ async function callTinyFishSearch(apiKey: string, params: TinyFishSearchParams):
96
104
  return (await response.json()) as TinyFishSearchResponse;
97
105
  }
98
106
 
99
- function appendTinyFishSources(sources: SearchSource[], results: readonly TinyFishSearchResult[]): void {
107
+ function appendTinyFishSources(
108
+ sources: SearchSource[],
109
+ results: readonly TinyFishSearchResult[],
110
+ seenUrls: Set<string>,
111
+ ): void {
100
112
  for (const result of results) {
101
- if (!result.url) continue;
113
+ const url = result.url?.trim();
114
+ if (!url || seenUrls.has(url)) continue;
115
+ seenUrls.add(url);
116
+ const siteName = result.site_name?.trim();
102
117
  sources.push({
103
- title: result.title ?? result.site_name ?? result.url,
104
- url: result.url,
105
- snippet: result.snippet ?? undefined,
106
- author: result.site_name ?? undefined,
118
+ title: result.title?.trim() || siteName || url,
119
+ url,
120
+ snippet: result.snippet?.replace(/\s+/g, " ").trim() || undefined,
121
+ author: siteName || undefined,
107
122
  });
108
123
  }
109
124
  }
110
125
 
126
+ /** Bare hosts from `site:` values; path constraints remain centrally post-filtered. */
127
+ function siteHosts(sites: readonly string[]): string[] {
128
+ const hosts = new Set<string>();
129
+ for (const site of sites) {
130
+ const host = site.split("/", 1)[0];
131
+ if (host) hosts.add(host);
132
+ }
133
+ return [...hosts];
134
+ }
135
+
111
136
  /** Execute TinyFish web search. */
112
137
  export async function searchTinyFish(params: SearchParams): Promise<SearchResponse> {
113
138
  const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
@@ -121,6 +146,12 @@ export async function searchTinyFish(params: SearchParams): Promise<SearchRespon
121
146
  timeoutMs: params.timeoutMs,
122
147
  fetch: params.fetch,
123
148
  };
149
+ if (parsed.hasDirectives) {
150
+ const includeDomains = siteHosts(parsed.sites);
151
+ const excludeDomains = siteHosts(parsed.excludedSites);
152
+ if (includeDomains.length > 0) tinyFishParams.include_domains = includeDomains;
153
+ if (excludeDomains.length > 0) tinyFishParams.exclude_domains = excludeDomains;
154
+ }
124
155
  const keyOrResolver: ApiKey = params.authStorage.resolver("tinyfish", {
125
156
  sessionId: params.sessionId,
126
157
  });
@@ -128,11 +159,14 @@ export async function searchTinyFish(params: SearchParams): Promise<SearchRespon
128
159
  keyOrResolver,
129
160
  async key => {
130
161
  const collected: SearchSource[] = [];
162
+ const seenUrls = new Set<string>();
131
163
  for (let page = 0; page <= MAX_PAGE && collected.length < numResults; page += 1) {
132
164
  const searchPage = await callTinyFishSearch(key, { ...tinyFishParams, page });
133
- const results = searchPage.results ?? [];
134
- appendTinyFishSources(collected, results);
135
- if (results.length < pageSize) break;
165
+ if (!Array.isArray(searchPage.results)) {
166
+ throw new Error("TinyFish Search API returned an unexpected response shape");
167
+ }
168
+ appendTinyFishSources(collected, searchPage.results, seenUrls);
169
+ if (searchPage.results.length < pageSize) break;
136
170
  }
137
171
 
138
172
  return collected.slice(0, numResults);
@@ -25,6 +25,8 @@ interface XAIUrlCitationAnnotation {
25
25
  title?: string | null;
26
26
  text?: string | null;
27
27
  cited_text?: string | null;
28
+ start_index?: number | null;
29
+ end_index?: number | null;
28
30
  }
29
31
 
30
32
  interface XAIResponseContentPart {
@@ -34,9 +36,20 @@ interface XAIResponseContentPart {
34
36
  annotations?: XAIUrlCitationAnnotation[] | null;
35
37
  }
36
38
 
39
+ interface XAIWebSearchSource {
40
+ url?: string | null;
41
+ source_website_url?: string | null;
42
+ title?: string | null;
43
+ caption?: string | null;
44
+ }
45
+
37
46
  interface XAIResponseOutputItem {
47
+ type?: string;
38
48
  content?: XAIResponseContentPart[] | null;
39
49
  annotations?: XAIUrlCitationAnnotation[] | null;
50
+ action?: { sources?: XAIWebSearchSource[] | null } | null;
51
+ sources?: XAIWebSearchSource[] | null;
52
+ results?: XAIWebSearchSource[] | null;
40
53
  }
41
54
 
42
55
  interface XAIResponsesUsage {
@@ -161,7 +174,12 @@ async function callXAIResponses(
161
174
  throwXAIResponsesError(response.status, await response.text());
162
175
  }
163
176
 
164
- return (await response.json()) as XAIResponsesResponse;
177
+ try {
178
+ return (await response.json()) as XAIResponsesResponse;
179
+ } catch (error) {
180
+ const message = error instanceof Error ? error.message : String(error);
181
+ throw new SearchProviderError("xai", `xAI Responses API returned invalid JSON: ${message}`, response.status);
182
+ }
165
183
  }
166
184
 
167
185
  function addCitationSource(
@@ -189,38 +207,77 @@ function addCitationSource(
189
207
  citedText: sourceSnippet,
190
208
  });
191
209
  }
210
+ function extractSnippetAround(
211
+ text: string | null | undefined,
212
+ start: number | null | undefined,
213
+ end: number | null | undefined,
214
+ ): string | undefined {
215
+ if (!text || typeof start !== "number" || typeof end !== "number") return undefined;
216
+ const before = Math.max(0, start - 100);
217
+ const after = Math.min(text.length, end + 100);
218
+ const snippet = text
219
+ .slice(before, after)
220
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
221
+ .trim();
222
+ if (!snippet) return undefined;
223
+ return snippet.length > 300 ? `${snippet.slice(0, 297)}...` : snippet;
224
+ }
192
225
 
193
226
  function collectAnnotationSources(
194
227
  annotations: readonly XAIUrlCitationAnnotation[] | null | undefined,
195
228
  sources: SearchSource[],
196
229
  citations: SearchCitation[],
197
230
  seenUrls: Set<string>,
231
+ contentText?: string | null,
198
232
  ): void {
199
- if (!annotations) return;
233
+ if (!Array.isArray(annotations)) return;
200
234
  for (const annotation of annotations) {
201
- if (annotation.type !== "url_citation" || !annotation.url) continue;
235
+ if (!annotation || typeof annotation !== "object") continue;
236
+ if (annotation.type !== "url_citation" || typeof annotation.url !== "string") continue;
202
237
  addCitationSource(
203
238
  sources,
204
239
  citations,
205
240
  seenUrls,
206
241
  annotation.url,
207
242
  annotation.title,
208
- annotation.cited_text ?? annotation.text,
243
+ annotation.cited_text ??
244
+ annotation.text ??
245
+ extractSnippetAround(contentText, annotation.start_index, annotation.end_index),
209
246
  );
210
247
  }
211
248
  }
212
249
 
250
+ function collectWebSearchSources(
251
+ item: XAIResponseOutputItem,
252
+ sources: SearchSource[],
253
+ citations: SearchCitation[],
254
+ seenUrls: Set<string>,
255
+ ): void {
256
+ if (item.type !== "web_search_call") return;
257
+ for (const group of [item.action?.sources, item.sources, item.results]) {
258
+ if (!Array.isArray(group)) continue;
259
+ for (const source of group) {
260
+ if (!source || typeof source !== "object") continue;
261
+ const url = source.url ?? source.source_website_url;
262
+ if (typeof url !== "string") continue;
263
+ addCitationSource(sources, citations, seenUrls, url, source.title ?? source.caption);
264
+ }
265
+ }
266
+ }
267
+
213
268
  function parseAnswer(response: XAIResponsesResponse): string | undefined {
214
269
  const topLevelText = response.output_text?.trim();
215
270
  if (topLevelText) return topLevelText;
216
271
 
217
272
  const answerParts: string[] = [];
218
- for (const item of response.output ?? []) {
219
- for (const part of item.content ?? []) {
273
+ const output = Array.isArray(response.output) ? response.output : [];
274
+ for (const item of output) {
275
+ if (!item || typeof item !== "object") continue;
276
+ const content = Array.isArray(item.content) ? item.content : [];
277
+ for (const part of content) {
278
+ if (!part || typeof part !== "object") continue;
220
279
  const text = part.output_text ?? part.text;
221
- if ((part.type === "output_text" || part.type === "text") && text?.trim()) {
222
- answerParts.push(text.trim());
223
- }
280
+ if (text?.trim()) answerParts.push(text.trim());
224
281
  }
225
282
  }
226
283
 
@@ -259,13 +316,23 @@ function parseResponse(response: XAIResponsesResponse, resultCap: number): Searc
259
316
  const seenUrls = new Set<string>();
260
317
 
261
318
  collectAnnotationSources(response.annotations, sources, citations, seenUrls);
262
- for (const item of response.output ?? []) {
319
+ const output = Array.isArray(response.output) ? response.output : [];
320
+ for (const item of output) {
321
+ if (!item || typeof item !== "object") continue;
263
322
  collectAnnotationSources(item.annotations, sources, citations, seenUrls);
264
- for (const part of item.content ?? []) {
265
- collectAnnotationSources(part.annotations, sources, citations, seenUrls);
323
+ const content = Array.isArray(item.content) ? item.content : [];
324
+ for (const part of content) {
325
+ if (!part || typeof part !== "object") continue;
326
+ collectAnnotationSources(part.annotations, sources, citations, seenUrls, part.output_text ?? part.text);
266
327
  }
267
328
  }
268
- for (const url of response.citations ?? []) {
329
+ for (const item of output) {
330
+ if (!item || typeof item !== "object") continue;
331
+ collectWebSearchSources(item, sources, citations, seenUrls);
332
+ }
333
+ const topLevelCitations = Array.isArray(response.citations) ? response.citations : [];
334
+ for (const url of topLevelCitations) {
335
+ if (typeof url !== "string") continue;
269
336
  addCitationSource(sources, citations, seenUrls, url);
270
337
  }
271
338
  const limited = applyResultCap(sources, citations, resultCap);
@@ -354,7 +421,11 @@ export async function searchXAI(params: SearchParams): Promise<SearchResponse> {
354
421
  signal: params.signal,
355
422
  missingKeyMessage: 'xAI credentials not found. Set XAI_API_KEY or configure an API key for provider "xai".',
356
423
  });
357
- return parseResponse(response, resultCap);
424
+ const parsed = parseResponse(response, resultCap);
425
+ if (!parsed.answer && parsed.sources.length === 0) {
426
+ throw new SearchProviderError("xai", "xAI web_search returned no answer or sources", 502);
427
+ }
428
+ return parsed;
358
429
  }
359
430
 
360
431
  /** Search provider for xAI web search. */