@gajae-code/coding-agent 0.5.4 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/cli/web-search-cli.d.ts +12 -0
  3. package/dist/types/commands/rlm.d.ts +10 -0
  4. package/dist/types/commands/web-search.d.ts +54 -0
  5. package/dist/types/config/keybindings.d.ts +10 -0
  6. package/dist/types/config/model-profiles.d.ts +2 -1
  7. package/dist/types/config/model-registry.d.ts +3 -0
  8. package/dist/types/config/models-config-schema.d.ts +3 -0
  9. package/dist/types/config/settings-schema.d.ts +61 -3
  10. package/dist/types/edit/notebook.d.ts +3 -0
  11. package/dist/types/eval/py/executor.d.ts +3 -0
  12. package/dist/types/eval/py/kernel.d.ts +3 -1
  13. package/dist/types/eval/py/runtime.d.ts +9 -1
  14. package/dist/types/exec/bash-executor.d.ts +4 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  16. package/dist/types/extensibility/custom-tools/wrapper.d.ts +1 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  18. package/dist/types/extensibility/extensions/wrapper.d.ts +1 -0
  19. package/dist/types/gjc-runtime/launch-tmux.d.ts +6 -0
  20. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +14 -0
  21. package/dist/types/gjc-runtime/tmux-common.d.ts +6 -0
  22. package/dist/types/gjc-runtime/tmux-gc.d.ts +3 -3
  23. package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -0
  24. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +18 -0
  25. package/dist/types/goals/state.d.ts +1 -1
  26. package/dist/types/goals/tools/goal-tool.d.ts +2 -0
  27. package/dist/types/main.d.ts +11 -0
  28. package/dist/types/modes/components/custom-editor.d.ts +4 -2
  29. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +12 -0
  30. package/dist/types/modes/components/model-selector.d.ts +5 -2
  31. package/dist/types/modes/components/status-line.d.ts +4 -1
  32. package/dist/types/modes/controllers/input-controller.d.ts +3 -0
  33. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  34. package/dist/types/modes/print-mode.d.ts +6 -0
  35. package/dist/types/modes/rpc/rpc-client.d.ts +21 -0
  36. package/dist/types/modes/rpc/rpc-socket-security.d.ts +7 -0
  37. package/dist/types/modes/rpc/rpc-types.d.ts +13 -0
  38. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +2 -0
  39. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +1 -0
  40. package/dist/types/rlm/artifacts.d.ts +9 -0
  41. package/dist/types/rlm/complete-research-tool.d.ts +35 -0
  42. package/dist/types/rlm/data-context.d.ts +6 -0
  43. package/dist/types/rlm/index.d.ts +35 -0
  44. package/dist/types/rlm/notebook.d.ts +12 -0
  45. package/dist/types/rlm/preset.d.ts +23 -0
  46. package/dist/types/rlm/python-tool.d.ts +16 -0
  47. package/dist/types/rlm/report.d.ts +14 -0
  48. package/dist/types/rlm/types.d.ts +37 -0
  49. package/dist/types/sdk.d.ts +7 -0
  50. package/dist/types/session/agent-session.d.ts +21 -0
  51. package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -1
  52. package/dist/types/tools/browser/attach.d.ts +19 -3
  53. package/dist/types/tools/browser/registry.d.ts +15 -0
  54. package/dist/types/tools/browser/render.d.ts +3 -0
  55. package/dist/types/tools/browser.d.ts +18 -1
  56. package/dist/types/tools/computer/render.d.ts +17 -0
  57. package/dist/types/tools/computer.d.ts +465 -0
  58. package/dist/types/tools/index.d.ts +24 -1
  59. package/dist/types/tools/job.d.ts +13 -0
  60. package/dist/types/tools/tool-timeouts.d.ts +5 -0
  61. package/dist/types/web/search/index.d.ts +32 -2
  62. package/dist/types/web/search/providers/base.d.ts +22 -0
  63. package/dist/types/web/search/providers/xai.d.ts +64 -0
  64. package/dist/types/web/search/types.d.ts +11 -3
  65. package/package.json +7 -7
  66. package/src/cli/web-search-cli.ts +123 -8
  67. package/src/cli.ts +2 -0
  68. package/src/commands/rlm.ts +19 -0
  69. package/src/commands/web-search.ts +66 -0
  70. package/src/config/keybindings.ts +11 -0
  71. package/src/config/model-profiles.ts +11 -3
  72. package/src/config/model-registry.ts +55 -1
  73. package/src/config/models-config-schema.ts +1 -0
  74. package/src/config/settings-schema.ts +67 -1
  75. package/src/edit/notebook.ts +6 -2
  76. package/src/eval/py/executor.ts +8 -1
  77. package/src/eval/py/kernel.ts +9 -4
  78. package/src/eval/py/runtime.ts +153 -32
  79. package/src/exec/bash-executor.ts +10 -4
  80. package/src/extensibility/custom-tools/types.ts +2 -0
  81. package/src/extensibility/custom-tools/wrapper.ts +2 -0
  82. package/src/extensibility/extensions/types.ts +2 -0
  83. package/src/extensibility/extensions/wrapper.ts +1 -0
  84. package/src/gjc-runtime/launch-tmux.ts +129 -1
  85. package/src/gjc-runtime/session-state-sidecar.ts +61 -1
  86. package/src/gjc-runtime/tmux-common.ts +26 -2
  87. package/src/gjc-runtime/tmux-gc.ts +40 -27
  88. package/src/gjc-runtime/tmux-sessions.ts +13 -1
  89. package/src/gjc-runtime/ultragoal-runtime.ts +340 -18
  90. package/src/goals/runtime.ts +4 -3
  91. package/src/goals/state.ts +1 -1
  92. package/src/goals/tools/goal-tool.ts +16 -3
  93. package/src/internal-urls/docs-index.generated.ts +13 -9
  94. package/src/main.ts +28 -3
  95. package/src/modes/components/custom-editor.ts +13 -4
  96. package/src/modes/components/custom-model-preset-wizard.ts +293 -0
  97. package/src/modes/components/hook-selector.ts +1 -1
  98. package/src/modes/components/model-selector.ts +72 -29
  99. package/src/modes/components/skill-message.ts +62 -8
  100. package/src/modes/components/status-line.ts +13 -1
  101. package/src/modes/controllers/input-controller.ts +60 -11
  102. package/src/modes/controllers/selector-controller.ts +39 -0
  103. package/src/modes/interactive-mode.ts +1 -1
  104. package/src/modes/print-mode.ts +14 -4
  105. package/src/modes/rpc/rpc-client.ts +250 -80
  106. package/src/modes/rpc/rpc-mode.ts +6 -12
  107. package/src/modes/rpc/rpc-socket-security.ts +103 -0
  108. package/src/modes/rpc/rpc-types.ts +10 -0
  109. package/src/modes/shared/agent-wire/command-dispatch.ts +7 -0
  110. package/src/modes/shared/agent-wire/command-validation.ts +1 -0
  111. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  112. package/src/modes/shared/agent-wire/unattended-session.ts +9 -0
  113. package/src/modes/utils/hotkeys-markdown.ts +4 -2
  114. package/src/modes/utils/ui-helpers.ts +2 -2
  115. package/src/prompts/goals/goal-continuation.md +1 -0
  116. package/src/prompts/goals/goal-mode-active.md +1 -0
  117. package/src/prompts/system/rlm-report-command.md +1 -0
  118. package/src/prompts/system/rlm-research.md +23 -0
  119. package/src/prompts/tools/bash.md +23 -2
  120. package/src/prompts/tools/browser.md +7 -3
  121. package/src/prompts/tools/computer.md +74 -0
  122. package/src/prompts/tools/goal.md +3 -0
  123. package/src/prompts/tools/job.md +9 -1
  124. package/src/prompts/tools/web-search.md +7 -0
  125. package/src/rlm/artifacts.ts +60 -0
  126. package/src/rlm/complete-research-tool.ts +163 -0
  127. package/src/rlm/data-context.ts +26 -0
  128. package/src/rlm/index.ts +339 -0
  129. package/src/rlm/notebook.ts +108 -0
  130. package/src/rlm/preset.ts +76 -0
  131. package/src/rlm/python-tool.ts +68 -0
  132. package/src/rlm/report.ts +70 -0
  133. package/src/rlm/types.ts +40 -0
  134. package/src/sdk.ts +12 -0
  135. package/src/session/agent-session.ts +48 -3
  136. package/src/slash-commands/builtin-registry.ts +17 -0
  137. package/src/tools/bash-allowed-prefixes.ts +84 -1
  138. package/src/tools/bash.ts +80 -13
  139. package/src/tools/browser/attach.ts +103 -3
  140. package/src/tools/browser/registry.ts +176 -2
  141. package/src/tools/browser/render.ts +9 -1
  142. package/src/tools/browser.ts +33 -0
  143. package/src/tools/computer/render.ts +78 -0
  144. package/src/tools/computer.ts +640 -0
  145. package/src/tools/index.ts +41 -1
  146. package/src/tools/job.ts +88 -5
  147. package/src/tools/json-tree.ts +42 -29
  148. package/src/tools/renderers.ts +2 -0
  149. package/src/tools/tool-timeouts.ts +1 -0
  150. package/src/web/search/index.ts +27 -2
  151. package/src/web/search/provider.ts +16 -1
  152. package/src/web/search/providers/base.ts +22 -0
  153. package/src/web/search/providers/xai.ts +511 -0
  154. package/src/web/search/render.ts +7 -0
  155. package/src/web/search/types.ts +11 -1
@@ -0,0 +1,511 @@
1
+ /**
2
+ * xAI Web/X Search Provider
3
+ *
4
+ * Uses xAI's Responses API with the built-in web_search and x_search tools.
5
+ * Endpoint: POST https://api.x.ai/v1/responses
6
+ */
7
+ import type { AuthStorage } from "@gajae-code/ai";
8
+ import { $env } from "@gajae-code/utils";
9
+ import type { SearchCitation, SearchResponse, SearchSource, SearchUsage } from "../../../web/search/types";
10
+ import { SearchProviderError } from "../../../web/search/types";
11
+ import type { SearchParams } from "./base";
12
+ import { SearchProvider } from "./base";
13
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
14
+
15
+ const DEFAULT_BASE_URL = "https://api.x.ai/v1";
16
+ const DEFAULT_MODEL = "grok-4.3";
17
+ const DEFAULT_NUM_RESULTS = 10;
18
+ const MAX_WEB_DOMAINS = 5;
19
+ const MAX_X_HANDLES = 20;
20
+ const XAI_SEARCH_MODES = ["web", "x", "web_and_x"] as const;
21
+
22
+ const RECENCY_DAYS: Record<NonNullable<XaiSearchParams["recency"]>, number> = {
23
+ day: 1,
24
+ week: 7,
25
+ month: 30,
26
+ year: 365,
27
+ };
28
+
29
+ export type XaiSearchMode = (typeof XAI_SEARCH_MODES)[number];
30
+
31
+ export interface XaiSearchParams {
32
+ query: string;
33
+ system_prompt?: string;
34
+ num_results?: number;
35
+ max_output_tokens?: number;
36
+ temperature?: number;
37
+ recency?: "day" | "week" | "month" | "year";
38
+ xai_search_mode?: XaiSearchMode;
39
+ allowed_domains?: string[];
40
+ excluded_domains?: string[];
41
+ allowed_x_handles?: string[];
42
+ excluded_x_handles?: string[];
43
+ from_date?: string;
44
+ to_date?: string;
45
+ enable_image_understanding?: boolean;
46
+ enable_image_search?: boolean;
47
+ enable_video_understanding?: boolean;
48
+ no_inline_citations?: boolean;
49
+ signal?: AbortSignal;
50
+ authStorage: AuthStorage;
51
+ sessionId?: string;
52
+ }
53
+
54
+ interface XaiAuth {
55
+ bearer: string;
56
+ mode: "api_key" | "oauth";
57
+ }
58
+
59
+ interface PreparedXaiTools {
60
+ tools: Array<Record<string, unknown>>;
61
+ include?: string[];
62
+ }
63
+
64
+ function asTrimmed(value: string | undefined): string | undefined {
65
+ if (!value) return undefined;
66
+ const trimmed = value.trim();
67
+ return trimmed.length > 0 ? trimmed : undefined;
68
+ }
69
+
70
+ function getModel(): string {
71
+ return asTrimmed($env.PI_XAI_WEB_SEARCH_MODEL) ?? asTrimmed($env.XAI_WEB_SEARCH_MODEL) ?? DEFAULT_MODEL;
72
+ }
73
+
74
+ function getBaseUrl(): string {
75
+ return asTrimmed($env.XAI_SEARCH_BASE_URL) ?? DEFAULT_BASE_URL;
76
+ }
77
+
78
+ function responsesEndpoint(): string {
79
+ return `${getBaseUrl().replace(/\/+$/, "")}/responses`;
80
+ }
81
+
82
+ async function resolveXaiAuth(
83
+ authStorage: AuthStorage,
84
+ sessionId: string | undefined,
85
+ model: string,
86
+ signal: AbortSignal | undefined,
87
+ ): Promise<XaiAuth | null> {
88
+ const credentialSessionId = sessionId ?? `xai-search:${crypto.randomUUID()}`;
89
+ const bearer = await authStorage.getApiKey("xai", credentialSessionId, {
90
+ baseUrl: getBaseUrl(),
91
+ modelId: model,
92
+ signal,
93
+ });
94
+ if (!bearer) return null;
95
+
96
+ // getApiKey records the selected credential type for session-scoped calls.
97
+ // Do not call getOAuthAccess here: when an API-key credential wins, resolving
98
+ // OAuth solely for labelling would refresh/record the wrong credential.
99
+ const selectedType = authStorage.getSessionCredentialType("xai", credentialSessionId);
100
+ return { bearer, mode: selectedType === "oauth" ? "oauth" : "api_key" };
101
+ }
102
+
103
+ function normalizeDomain(value: string): string {
104
+ const trimmed = value.trim();
105
+ if (!trimmed) return "";
106
+ const withoutWildcard = trimmed.replace(/^\*\./, "");
107
+ try {
108
+ const url = new URL(
109
+ /^[a-z][a-z0-9+.-]*:\/\//i.test(withoutWildcard) ? withoutWildcard : `https://${withoutWildcard}`,
110
+ );
111
+ return url.hostname.toLowerCase();
112
+ } catch {
113
+ return withoutWildcard.split("/")[0]?.toLowerCase() ?? "";
114
+ }
115
+ }
116
+
117
+ function normalizeXHandle(value: string): string {
118
+ return value.trim().replace(/^@+/, "");
119
+ }
120
+
121
+ function normalizeList(
122
+ values: string[] | undefined,
123
+ label: string,
124
+ max: number,
125
+ normalize: (value: string) => string,
126
+ ): string[] | undefined {
127
+ if (!values) return undefined;
128
+ const out: string[] = [];
129
+ const seen = new Set<string>();
130
+ for (const value of values) {
131
+ if (typeof value !== "string") continue;
132
+ const normalized = normalize(value);
133
+ if (!normalized || seen.has(normalized)) continue;
134
+ seen.add(normalized);
135
+ out.push(normalized);
136
+ }
137
+ if (out.length > max) {
138
+ throw new SearchProviderError("xai", `xAI ${label} supports at most ${max} entries`, 400);
139
+ }
140
+ return out.length > 0 ? out : undefined;
141
+ }
142
+
143
+ function assertNotBoth(
144
+ leftName: string,
145
+ left: unknown[] | undefined,
146
+ rightName: string,
147
+ right: unknown[] | undefined,
148
+ ): void {
149
+ if (left?.length && right?.length) {
150
+ throw new SearchProviderError("xai", `xAI ${leftName} cannot be set together with ${rightName}`, 400);
151
+ }
152
+ }
153
+
154
+ function formatUtcDate(date: Date): string {
155
+ return date.toISOString().slice(0, 10);
156
+ }
157
+
158
+ function dateDaysAgo(days: number): string {
159
+ const date = new Date();
160
+ date.setUTCDate(date.getUTCDate() - days);
161
+ return formatUtcDate(date);
162
+ }
163
+
164
+ function xDateRange(params: { recency?: XaiSearchParams["recency"]; fromDate?: string; toDate?: string }): {
165
+ fromDate?: string;
166
+ toDate?: string;
167
+ } {
168
+ const fromDate =
169
+ asTrimmed(params.fromDate) ?? (params.recency ? dateDaysAgo(RECENCY_DAYS[params.recency]) : undefined);
170
+ const toDate = asTrimmed(params.toDate) ?? (params.recency ? formatUtcDate(new Date()) : undefined);
171
+ return { fromDate, toDate };
172
+ }
173
+
174
+ function prepareXaiTools(params: {
175
+ xaiSearchMode?: XaiSearchMode;
176
+ recency?: XaiSearchParams["recency"];
177
+ allowedDomains?: string[];
178
+ excludedDomains?: string[];
179
+ allowedXHandles?: string[];
180
+ excludedXHandles?: string[];
181
+ fromDate?: string;
182
+ toDate?: string;
183
+ enableImageUnderstanding?: boolean;
184
+ enableImageSearch?: boolean;
185
+ enableVideoUnderstanding?: boolean;
186
+ noInlineCitations?: boolean;
187
+ }): PreparedXaiTools {
188
+ if (params.xaiSearchMode && !(XAI_SEARCH_MODES as readonly string[]).includes(params.xaiSearchMode)) {
189
+ throw new SearchProviderError("xai", `Invalid xAI search mode: ${params.xaiSearchMode}`, 400);
190
+ }
191
+ const allowedDomains = normalizeList(params.allowedDomains, "allowed_domains", MAX_WEB_DOMAINS, normalizeDomain);
192
+ const excludedDomains = normalizeList(params.excludedDomains, "excluded_domains", MAX_WEB_DOMAINS, normalizeDomain);
193
+ const allowedXHandles = normalizeList(params.allowedXHandles, "allowed_x_handles", MAX_X_HANDLES, normalizeXHandle);
194
+ const excludedXHandles = normalizeList(
195
+ params.excludedXHandles,
196
+ "excluded_x_handles",
197
+ MAX_X_HANDLES,
198
+ normalizeXHandle,
199
+ );
200
+ assertNotBoth("allowed_domains", allowedDomains, "excluded_domains", excludedDomains);
201
+ assertNotBoth("allowed_x_handles", allowedXHandles, "excluded_x_handles", excludedXHandles);
202
+
203
+ const hasWebOnlyOptions = Boolean(
204
+ allowedDomains?.length || excludedDomains?.length || params.enableImageSearch === true,
205
+ );
206
+ const hasXOnlyOptions = Boolean(
207
+ allowedXHandles?.length ||
208
+ excludedXHandles?.length ||
209
+ asTrimmed(params.fromDate) ||
210
+ asTrimmed(params.toDate) ||
211
+ params.enableVideoUnderstanding === true,
212
+ );
213
+ const mode =
214
+ params.xaiSearchMode ?? (hasWebOnlyOptions && hasXOnlyOptions ? "web_and_x" : hasXOnlyOptions ? "x" : "web");
215
+
216
+ if (mode === "web" && hasXOnlyOptions) {
217
+ throw new SearchProviderError("xai", "xAI X Search options require xai_search_mode='x' or 'web_and_x'", 400);
218
+ }
219
+ if (mode === "x" && hasWebOnlyOptions) {
220
+ throw new SearchProviderError("xai", "xAI Web Search options require xai_search_mode='web' or 'web_and_x'", 400);
221
+ }
222
+
223
+ const tools: Array<Record<string, unknown>> = [];
224
+ if (mode === "web" || mode === "web_and_x") {
225
+ const tool: Record<string, unknown> = { type: "web_search" };
226
+ const filters: Record<string, unknown> = {};
227
+ if (allowedDomains) filters.allowed_domains = allowedDomains;
228
+ if (excludedDomains) filters.excluded_domains = excludedDomains;
229
+ if (Object.keys(filters).length > 0) tool.filters = filters;
230
+ if (params.enableImageUnderstanding !== undefined)
231
+ tool.enable_image_understanding = params.enableImageUnderstanding;
232
+ if (params.enableImageSearch !== undefined) tool.enable_image_search = params.enableImageSearch;
233
+ tools.push(tool);
234
+ }
235
+ if (mode === "x" || mode === "web_and_x") {
236
+ const tool: Record<string, unknown> = { type: "x_search" };
237
+ if (allowedXHandles) tool.allowed_x_handles = allowedXHandles;
238
+ if (excludedXHandles) tool.excluded_x_handles = excludedXHandles;
239
+ const { fromDate, toDate } = xDateRange({
240
+ recency: params.recency,
241
+ fromDate: params.fromDate,
242
+ toDate: params.toDate,
243
+ });
244
+ if (fromDate) tool.from_date = fromDate;
245
+ if (toDate) tool.to_date = toDate;
246
+ if (params.enableImageUnderstanding !== undefined)
247
+ tool.enable_image_understanding = params.enableImageUnderstanding;
248
+ if (params.enableVideoUnderstanding !== undefined)
249
+ tool.enable_video_understanding = params.enableVideoUnderstanding;
250
+ tools.push(tool);
251
+ }
252
+
253
+ return { tools, include: params.noInlineCitations ? ["no_inline_citations"] : undefined };
254
+ }
255
+
256
+ export function buildXaiRequestBody(params: {
257
+ query: string;
258
+ systemPrompt: string;
259
+ model: string;
260
+ maxOutputTokens?: number;
261
+ temperature?: number;
262
+ recency?: XaiSearchParams["recency"];
263
+ xaiSearchMode?: XaiSearchMode;
264
+ allowedDomains?: string[];
265
+ excludedDomains?: string[];
266
+ allowedXHandles?: string[];
267
+ excludedXHandles?: string[];
268
+ fromDate?: string;
269
+ toDate?: string;
270
+ enableImageUnderstanding?: boolean;
271
+ enableImageSearch?: boolean;
272
+ enableVideoUnderstanding?: boolean;
273
+ noInlineCitations?: boolean;
274
+ }): Record<string, unknown> {
275
+ const prepared = prepareXaiTools(params);
276
+ const body: Record<string, unknown> = {
277
+ model: params.model,
278
+ input: [
279
+ { role: "system", content: params.systemPrompt },
280
+ { role: "user", content: params.query },
281
+ ],
282
+ tools: prepared.tools,
283
+ };
284
+ if (prepared.include) body.include = prepared.include;
285
+ if (params.temperature !== undefined) body.temperature = params.temperature;
286
+ if (params.maxOutputTokens !== undefined) body.max_output_tokens = params.maxOutputTokens;
287
+ return body;
288
+ }
289
+
290
+ function textFromResponse(json: any): string | undefined {
291
+ if (typeof json?.output_text === "string" && json.output_text.trim().length > 0) return json.output_text;
292
+ const chunks: string[] = [];
293
+ for (const item of json?.output ?? []) {
294
+ for (const content of item?.content ?? []) {
295
+ if (typeof content?.text === "string" && content.text.length > 0) chunks.push(content.text);
296
+ }
297
+ }
298
+ return chunks.join("\n").trim() || undefined;
299
+ }
300
+
301
+ function pushCitation(out: SearchCitation[], rawUrl: unknown, rawTitle: unknown, rawText: unknown): void {
302
+ if (typeof rawUrl !== "string") return;
303
+ const url = rawUrl.trim();
304
+ if (!url) return;
305
+ const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
306
+ out.push({
307
+ url,
308
+ title: title && !/^\d+$/.test(title) ? title : url,
309
+ citedText: typeof rawText === "string" && rawText.trim() ? rawText : undefined,
310
+ });
311
+ }
312
+
313
+ function collectCitationAnnotations(annotations: unknown, out: SearchCitation[]): void {
314
+ if (!Array.isArray(annotations)) return;
315
+ for (const annotation of annotations) {
316
+ if (!annotation || typeof annotation !== "object") continue;
317
+ const ann = annotation as Record<string, any>;
318
+ if (ann.type !== "url_citation") continue;
319
+ const citation = ann.url_citation && typeof ann.url_citation === "object" ? ann.url_citation : ann;
320
+ pushCitation(out, citation.url ?? citation.uri, citation.title, citation.text ?? citation.quote ?? ann.text);
321
+ }
322
+ }
323
+
324
+ function collectTopLevelCitations(citations: unknown, out: SearchCitation[]): void {
325
+ if (!Array.isArray(citations)) return;
326
+ for (const citation of citations) {
327
+ if (typeof citation === "string") {
328
+ pushCitation(out, citation, undefined, undefined);
329
+ continue;
330
+ }
331
+ if (!citation || typeof citation !== "object") continue;
332
+ const record = citation as Record<string, unknown>;
333
+ pushCitation(out, record.url ?? record.uri, record.title, record.text ?? record.quote ?? record.snippet);
334
+ }
335
+ }
336
+
337
+ export function parseXaiCitations(json: any): SearchCitation[] {
338
+ const citations: SearchCitation[] = [];
339
+ for (const item of json?.output ?? []) {
340
+ for (const content of item?.content ?? []) {
341
+ collectCitationAnnotations(content?.annotations, citations);
342
+ }
343
+ }
344
+ collectTopLevelCitations(json?.citations, citations);
345
+
346
+ const seen = new Set<string>();
347
+ return citations.filter(citation => {
348
+ if (seen.has(citation.url)) return false;
349
+ seen.add(citation.url);
350
+ return true;
351
+ });
352
+ }
353
+
354
+ function toSources(citations: SearchCitation[], limit: number): SearchSource[] {
355
+ return citations.slice(0, limit).map(citation => ({
356
+ title: citation.title || citation.url,
357
+ url: citation.url,
358
+ snippet: citation.citedText,
359
+ }));
360
+ }
361
+
362
+ function numericUsage(record: unknown, ...keys: string[]): number | undefined {
363
+ if (!record || typeof record !== "object") return undefined;
364
+ const values = record as Record<string, unknown>;
365
+ for (const key of keys) {
366
+ const value = values[key];
367
+ if (typeof value === "number") return value;
368
+ }
369
+ return undefined;
370
+ }
371
+
372
+ function positiveUsage(record: unknown, ...keys: string[]): number | undefined {
373
+ const value = numericUsage(record, ...keys);
374
+ return value && value > 0 ? value : undefined;
375
+ }
376
+
377
+ function parseUsage(json: any): SearchUsage | undefined {
378
+ const usage = json?.usage;
379
+ const toolUsage =
380
+ usage?.server_side_tool_usage_details ??
381
+ usage?.server_side_tool_usage ??
382
+ json?.server_side_tool_usage_details ??
383
+ json?.server_side_tool_usage;
384
+ if ((!usage || typeof usage !== "object") && (!toolUsage || typeof toolUsage !== "object")) return undefined;
385
+
386
+ const parsed: SearchUsage = {
387
+ inputTokens: typeof usage?.input_tokens === "number" ? usage.input_tokens : undefined,
388
+ outputTokens: typeof usage?.output_tokens === "number" ? usage.output_tokens : undefined,
389
+ totalTokens: typeof usage?.total_tokens === "number" ? usage.total_tokens : undefined,
390
+ searchRequests: positiveUsage(toolUsage, "web_search_calls", "SERVER_SIDE_TOOL_WEB_SEARCH"),
391
+ xSearchRequests: positiveUsage(toolUsage, "x_search_calls", "SERVER_SIDE_TOOL_X_SEARCH"),
392
+ imageSearchRequests: positiveUsage(toolUsage, "image_search_calls", "SERVER_SIDE_TOOL_IMAGE_SEARCH"),
393
+ imageUnderstandingRequests: positiveUsage(toolUsage, "view_image_calls", "SERVER_SIDE_TOOL_VIEW_IMAGE"),
394
+ videoUnderstandingRequests: positiveUsage(
395
+ toolUsage,
396
+ "video_understanding_calls",
397
+ "SERVER_SIDE_TOOL_VIEW_VIDEO",
398
+ "SERVER_SIDE_TOOL_VIEW_X_VIDEO",
399
+ ),
400
+ };
401
+
402
+ return Object.values(parsed).some(value => value !== undefined) ? parsed : undefined;
403
+ }
404
+
405
+ /** Execute xAI web/X search through the Responses API search tools. */
406
+ export async function searchXai(params: XaiSearchParams): Promise<SearchResponse> {
407
+ const model = getModel();
408
+ const auth = await resolveXaiAuth(params.authStorage, params.sessionId, model, params.signal);
409
+ if (!auth) {
410
+ throw new SearchProviderError(
411
+ "xai",
412
+ "xAI search credentials not found. Set XAI_API_KEY or login with 'gjc /login xai'.",
413
+ 401,
414
+ );
415
+ }
416
+
417
+ const response = await fetch(responsesEndpoint(), {
418
+ method: "POST",
419
+ headers: {
420
+ Authorization: `Bearer ${auth.bearer}`,
421
+ "Content-Type": "application/json",
422
+ },
423
+ body: JSON.stringify(
424
+ buildXaiRequestBody({
425
+ query: params.query,
426
+ systemPrompt: params.system_prompt ?? "Use web search to answer accurately and cite sources.",
427
+ model,
428
+ maxOutputTokens: params.max_output_tokens,
429
+ temperature: params.temperature,
430
+ recency: params.recency,
431
+ xaiSearchMode: params.xai_search_mode,
432
+ allowedDomains: params.allowed_domains,
433
+ excludedDomains: params.excluded_domains,
434
+ allowedXHandles: params.allowed_x_handles,
435
+ excludedXHandles: params.excluded_x_handles,
436
+ fromDate: params.from_date,
437
+ toDate: params.to_date,
438
+ enableImageUnderstanding: params.enable_image_understanding,
439
+ enableImageSearch: params.enable_image_search,
440
+ enableVideoUnderstanding: params.enable_video_understanding,
441
+ noInlineCitations: params.no_inline_citations,
442
+ }),
443
+ ),
444
+ signal: withHardTimeout(params.signal),
445
+ });
446
+
447
+ const text = await response.text();
448
+ if (!response.ok) {
449
+ const classified = classifyProviderHttpError("xai", response.status, text);
450
+ if (classified) throw classified;
451
+ throw new SearchProviderError("xai", `xAI search API error (${response.status}): ${text}`, response.status);
452
+ }
453
+
454
+ let json: any;
455
+ try {
456
+ json = text ? JSON.parse(text) : {};
457
+ } catch {
458
+ throw new SearchProviderError("xai", "xAI search API returned invalid JSON", 502);
459
+ }
460
+ const citations = parseXaiCitations(json);
461
+ if (citations.length === 0) {
462
+ throw new SearchProviderError("xai", "xAI web search returned no citations", 424);
463
+ }
464
+
465
+ const limit = params.num_results ?? DEFAULT_NUM_RESULTS;
466
+ return {
467
+ provider: "xai",
468
+ answer: textFromResponse(json),
469
+ sources: toSources(citations, limit),
470
+ citations,
471
+ usage: parseUsage(json),
472
+ model: typeof json.model === "string" ? json.model : model,
473
+ requestId: typeof json.id === "string" ? json.id : undefined,
474
+ authMode: auth.mode,
475
+ };
476
+ }
477
+
478
+ /** Search provider for xAI web and X search. */
479
+ export class XaiProvider extends SearchProvider {
480
+ readonly id = "xai";
481
+ readonly label = "xAI";
482
+
483
+ isAvailable(authStorage: AuthStorage): boolean {
484
+ return authStorage.hasAuth("xai");
485
+ }
486
+
487
+ search(params: SearchParams): Promise<SearchResponse> {
488
+ return searchXai({
489
+ query: params.query,
490
+ system_prompt: params.systemPrompt,
491
+ num_results: params.numSearchResults ?? params.limit,
492
+ max_output_tokens: params.maxOutputTokens,
493
+ temperature: params.temperature,
494
+ recency: params.recency,
495
+ xai_search_mode: params.xaiSearchMode,
496
+ allowed_domains: params.allowedDomains,
497
+ excluded_domains: params.excludedDomains,
498
+ allowed_x_handles: params.allowedXHandles,
499
+ excluded_x_handles: params.excludedXHandles,
500
+ from_date: params.fromDate,
501
+ to_date: params.toDate,
502
+ enable_image_understanding: params.enableImageUnderstanding,
503
+ enable_image_search: params.enableImageSearch,
504
+ enable_video_understanding: params.enableVideoUnderstanding,
505
+ no_inline_citations: params.noInlineCitations,
506
+ signal: params.signal,
507
+ authStorage: params.authStorage,
508
+ sessionId: params.sessionId,
509
+ });
510
+ }
511
+ }
@@ -144,6 +144,13 @@ export function renderSearchResult(
144
144
  if (response.usage.outputTokens !== undefined) usageParts.push(`out ${response.usage.outputTokens}`);
145
145
  if (response.usage.totalTokens !== undefined) usageParts.push(`total ${response.usage.totalTokens}`);
146
146
  if (response.usage.searchRequests !== undefined) usageParts.push(`search ${response.usage.searchRequests}`);
147
+ if (response.usage.xSearchRequests !== undefined) usageParts.push(`x ${response.usage.xSearchRequests}`);
148
+ if (response.usage.imageSearchRequests !== undefined)
149
+ usageParts.push(`image ${response.usage.imageSearchRequests}`);
150
+ if (response.usage.imageUnderstandingRequests !== undefined)
151
+ usageParts.push(`view ${response.usage.imageUnderstandingRequests}`);
152
+ if (response.usage.videoUnderstandingRequests !== undefined)
153
+ usageParts.push(`video ${response.usage.videoUnderstandingRequests}`);
147
154
  if (usageParts.length > 0)
148
155
  metaLines.push(`${theme.fg("muted", "Usage:")} ${theme.fg("text", usageParts.join(theme.sep.dot))}`);
149
156
  }
@@ -16,6 +16,7 @@ export type SearchProviderId =
16
16
  | "perplexity"
17
17
  | "gemini"
18
18
  | "codex"
19
+ | "xai"
19
20
  | "tavily"
20
21
  | "parallel"
21
22
  | "kagi"
@@ -46,6 +47,7 @@ export const CONFIGURABLE_SEARCH_PROVIDER_IDS = [
46
47
  "perplexity",
47
48
  "gemini",
48
49
  "codex",
50
+ "xai",
49
51
  "tavily",
50
52
  "parallel",
51
53
  "kagi",
@@ -94,8 +96,16 @@ export interface SearchCitation {
94
96
  export interface SearchUsage {
95
97
  inputTokens?: number;
96
98
  outputTokens?: number;
97
- /** Anthropic: number of web search requests made */
99
+ /** Number of web search requests made */
98
100
  searchRequests?: number;
101
+ /** Number of xAI X Search requests made */
102
+ xSearchRequests?: number;
103
+ /** Number of image search requests made */
104
+ imageSearchRequests?: number;
105
+ /** Number of image-understanding/view-image requests made */
106
+ imageUnderstandingRequests?: number;
107
+ /** Number of video-understanding requests made */
108
+ videoUnderstandingRequests?: number;
99
109
  /** Perplexity: combined token count */
100
110
  totalTokens?: number;
101
111
  }