@f5xc-salesdemos/xcsh 17.4.2 → 17.4.4

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "17.4.2",
4
+ "version": "17.4.4",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.16.1",
48
48
  "@mozilla/readability": "^0.6",
49
- "@f5xc-salesdemos/xcsh-stats": "17.4.2",
50
- "@f5xc-salesdemos/pi-agent-core": "17.4.2",
51
- "@f5xc-salesdemos/pi-ai": "17.4.2",
52
- "@f5xc-salesdemos/pi-natives": "17.4.2",
53
- "@f5xc-salesdemos/pi-tui": "17.4.2",
54
- "@f5xc-salesdemos/pi-utils": "17.4.2",
49
+ "@f5xc-salesdemos/xcsh-stats": "17.4.4",
50
+ "@f5xc-salesdemos/pi-agent-core": "17.4.4",
51
+ "@f5xc-salesdemos/pi-ai": "17.4.4",
52
+ "@f5xc-salesdemos/pi-natives": "17.4.4",
53
+ "@f5xc-salesdemos/pi-tui": "17.4.4",
54
+ "@f5xc-salesdemos/pi-utils": "17.4.4",
55
55
  "@sinclair/typebox": "^0.34",
56
56
  "@xterm/headless": "^6.0",
57
57
  "ajv": "^8.18",
@@ -2,7 +2,7 @@ import * as os from "node:os";
2
2
  import * as path from "node:path";
3
3
  import { ThinkingLevel } from "@f5xc-salesdemos/pi-agent-core";
4
4
  import { TERMINAL } from "@f5xc-salesdemos/pi-tui";
5
- import { formatDuration, formatNumber, getProjectDir, relativePathWithinRoot } from "@f5xc-salesdemos/pi-utils";
5
+ import { formatDuration, formatNumber, relativePathWithinRoot } from "@f5xc-salesdemos/pi-utils";
6
6
  import { theme } from "../../../modes/theme/theme";
7
7
  import { shortenPath } from "../../../tools/render-utils";
8
8
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
@@ -115,7 +115,7 @@ const pathSegment: StatusLineSegment = {
115
115
  render(ctx) {
116
116
  const opts = ctx.options.path ?? {};
117
117
 
118
- let pwd = getProjectDir();
118
+ let pwd = ctx.cwd;
119
119
 
120
120
  if (opts.stripWorkPrefix !== false) {
121
121
  pwd = stripDisplayRoot(pwd);
@@ -19,6 +19,7 @@ export type RGB = readonly [number, number, number];
19
19
  export interface SegmentContext {
20
20
  session: AgentSession;
21
21
  width: number;
22
+ cwd: string;
22
23
  options: StatusLineSegmentOptions;
23
24
  planMode: {
24
25
  enabled: boolean;
@@ -1,13 +1,14 @@
1
1
  import * as fs from "node:fs";
2
2
  import type { AssistantMessage } from "@f5xc-salesdemos/pi-ai";
3
3
  import { type Component, truncateToWidth, visibleWidth } from "@f5xc-salesdemos/pi-tui";
4
- import { formatCount, getProjectDir } from "@f5xc-salesdemos/pi-utils";
4
+ import { formatCount, getShellPwd } from "@f5xc-salesdemos/pi-utils";
5
5
  import { $ } from "bun";
6
6
  import { settings } from "../../config/settings";
7
7
  import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema";
8
8
  import { theme } from "../../modes/theme/theme";
9
9
  import type { AgentSession } from "../../session/agent-session";
10
10
  import { calculatePromptTokens } from "../../session/compaction/compaction";
11
+ import type { EventBus } from "../../utils/event-bus";
11
12
  import * as git from "../../utils/git";
12
13
  import { queryGitStatus } from "../../utils/gitstatus";
13
14
  import { sanitizeStatusText } from "../shared";
@@ -52,6 +53,7 @@ export class StatusLineComponent implements Component {
52
53
  #cachedBranch: string | null | undefined = undefined;
53
54
  #cachedBranchRepoId: string | null | undefined = undefined;
54
55
  #gitWatcher: fs.FSWatcher | null = null;
56
+ #cwdUnsubscribe: (() => void) | null = null;
55
57
  #onBranchChange: (() => void) | null = null;
56
58
  #onStatusChanged: (() => void) | null = null;
57
59
  #autoCompactEnabled: boolean = true;
@@ -130,13 +132,22 @@ export class StatusLineComponent implements Component {
130
132
  this.#setupGitWatcher();
131
133
  }
132
134
 
135
+ watchCwd(eventBus: EventBus): void {
136
+ this.#cwdUnsubscribe?.();
137
+ this.#cwdUnsubscribe = eventBus.on("cwd:changed", () => {
138
+ this.#invalidateGitCaches();
139
+ this.#setupGitWatcher();
140
+ this.#onStatusChanged?.();
141
+ });
142
+ }
143
+
133
144
  #setupGitWatcher(): void {
134
145
  if (this.#gitWatcher) {
135
146
  this.#gitWatcher.close();
136
147
  this.#gitWatcher = null;
137
148
  }
138
149
 
139
- const gitHeadPath = git.repo.resolveSync(getProjectDir())?.headPath ?? null;
150
+ const gitHeadPath = git.repo.resolveSync(getShellPwd())?.headPath ?? null;
140
151
  if (!gitHeadPath) return;
141
152
 
142
153
  try {
@@ -156,6 +167,10 @@ export class StatusLineComponent implements Component {
156
167
  this.#gitWatcher.close();
157
168
  this.#gitWatcher = null;
158
169
  }
170
+ if (this.#cwdUnsubscribe) {
171
+ this.#cwdUnsubscribe();
172
+ this.#cwdUnsubscribe = null;
173
+ }
159
174
  }
160
175
 
161
176
  invalidate(): void {
@@ -168,7 +183,7 @@ export class StatusLineComponent implements Component {
168
183
  this.#cachedPrContext = undefined;
169
184
  }
170
185
  #getCurrentBranch(): string | null {
171
- const head = git.head.resolveSync(getProjectDir());
186
+ const head = git.head.resolveSync(getShellPwd());
172
187
  const gitHeadPath = head?.headPath ?? null;
173
188
  if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === gitHeadPath) {
174
189
  return this.#cachedBranch;
@@ -189,7 +204,7 @@ export class StatusLineComponent implements Component {
189
204
  if (this.#defaultBranch === undefined) {
190
205
  this.#defaultBranch = "main";
191
206
  (async () => {
192
- const resolved = await git.branch.default(getProjectDir());
207
+ const resolved = await git.branch.default(getShellPwd());
193
208
  if (resolved) {
194
209
  this.#defaultBranch = resolved;
195
210
  if (this.#onBranchChange) {
@@ -220,7 +235,7 @@ export class StatusLineComponent implements Component {
220
235
  (async () => {
221
236
  try {
222
237
  // Prefer gitstatusd daemon (10x faster than git CLI)
223
- const gsResult = await queryGitStatus(getProjectDir());
238
+ const gsResult = await queryGitStatus(getShellPwd());
224
239
  if (gsResult) {
225
240
  this.#cachedGitStatus = {
226
241
  staged: gsResult.staged,
@@ -234,7 +249,7 @@ export class StatusLineComponent implements Component {
234
249
  };
235
250
  } else {
236
251
  // Fallback to git CLI
237
- const summary = await git.status.summary(getProjectDir());
252
+ const summary = await git.status.summary(getShellPwd());
238
253
  this.#cachedGitStatus = summary
239
254
  ? { ...summary, conflicted: 0, ahead: 0, behind: 0, stashes: 0, action: "" }
240
255
  : null;
@@ -284,7 +299,7 @@ export class StatusLineComponent implements Component {
284
299
  };
285
300
  try {
286
301
  // Requires `gh repo set-default` to be configured; fails gracefully if not
287
- const result = await $`gh pr view --json number,url`.quiet().nothrow();
302
+ const result = await $`gh pr view --json number,url`.cwd(getShellPwd()).quiet().nothrow();
288
303
  if (result.exitCode !== 0) {
289
304
  setCachedPr(null);
290
305
  return;
@@ -368,6 +383,7 @@ export class StatusLineComponent implements Component {
368
383
  return {
369
384
  session: this.session,
370
385
  width,
386
+ cwd: getShellPwd(),
371
387
  options: this.#resolveSettings().segmentOptions ?? {},
372
388
  planMode: this.#planModeStatus,
373
389
  usageStats,
@@ -408,6 +408,10 @@ export class InteractiveMode implements InteractiveModeContext {
408
408
  this.ui.requestRender();
409
409
  });
410
410
 
411
+ if (this.#eventBus) {
412
+ this.statusLine.watchCwd(this.#eventBus);
413
+ }
414
+
411
415
  // Initial top border update
412
416
  this.updateEditorTopBorder();
413
417
  }
@@ -0,0 +1,120 @@
1
+ import { SearchProviderError } from "./types";
2
+
3
+ export interface WebSearchError {
4
+ field?: string;
5
+ constraint?: string;
6
+ userMessage: string;
7
+ status?: number;
8
+ raw: unknown;
9
+ }
10
+
11
+ const FIELD_PATTERN = /tools\.\d+\.web_search_\d+\.([A-Za-z_][A-Za-z_0-9.]*?):\s*(.+?)(?:\n|$)/;
12
+
13
+ const LITELLM_NOISE_PATTERNS: RegExp[] = [
14
+ /No fallback model group found for original model_group=[^\n]*/gi,
15
+ /Fallbacks=\[[^\]]*\][^\n]*/gi,
16
+ /Available Model Group Fallbacks=[^\n]*/gi,
17
+ /claude-haiku-[0-9a-z.-]+/gi,
18
+ /claude-[a-z0-9-]+-\d+/gi,
19
+ /model_group=[^\s,]+/gi,
20
+ ];
21
+
22
+ function scrubLitellmNoise(text: string): string {
23
+ let cleaned = text;
24
+ for (const pattern of LITELLM_NOISE_PATTERNS) {
25
+ cleaned = cleaned.replace(pattern, "");
26
+ }
27
+ return cleaned
28
+ .replace(/\n{2,}/g, "\n")
29
+ .replace(/\s{2,}/g, " ")
30
+ .trim();
31
+ }
32
+
33
+ function fieldHint(field: string): string | undefined {
34
+ if (field === "user_location.country") {
35
+ return "user_location.country must be an ISO 3166-1 alpha-2 code (e.g. US, JP, GB)";
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ function extractRawMessage(input: unknown): string {
41
+ if (typeof input === "string") return input;
42
+ if (input instanceof SearchProviderError) return input.message;
43
+ if (input instanceof Error) return input.message;
44
+ if (input && typeof input === "object") {
45
+ const body = (input as { error?: { message?: unknown } }).error;
46
+ if (body && typeof body.message === "string") return body.message;
47
+ }
48
+ return "";
49
+ }
50
+
51
+ function tryParseJsonString(raw: string): unknown | undefined {
52
+ const trimmed = raw.trim();
53
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return undefined;
54
+ try {
55
+ return JSON.parse(trimmed);
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ function matchFieldPattern(text: string): RegExpMatchArray | null {
62
+ return text.match(FIELD_PATTERN);
63
+ }
64
+
65
+ export function parseWebSearchError(input: unknown): WebSearchError {
66
+ if (input instanceof SearchProviderError) {
67
+ if (input.status === 401 || input.status === 403) {
68
+ return {
69
+ status: input.status,
70
+ userMessage: `Web search authorization failed (${input.status}). Check API key or base URL.`,
71
+ raw: input,
72
+ };
73
+ }
74
+ if (input.status === 404) {
75
+ return {
76
+ status: input.status,
77
+ userMessage: "Web search returned 404 (model or endpoint not found).",
78
+ raw: input,
79
+ };
80
+ }
81
+ }
82
+
83
+ let bodyForFieldScan: unknown = input;
84
+ if (typeof input === "string") {
85
+ const parsed = tryParseJsonString(input);
86
+ if (parsed !== undefined) bodyForFieldScan = parsed;
87
+ }
88
+
89
+ const rawMessage = extractRawMessage(bodyForFieldScan);
90
+
91
+ if (rawMessage.length === 0) {
92
+ return {
93
+ userMessage: "web_search failed — no error detail available.",
94
+ raw: input,
95
+ };
96
+ }
97
+
98
+ const match = matchFieldPattern(rawMessage);
99
+ if (match) {
100
+ const field = match[1];
101
+ const constraint = match[2].trim();
102
+ const hint = fieldHint(field);
103
+ const userMessage = hint ? `web_search error: ${hint}.` : `web_search error: ${field} — ${constraint}.`;
104
+ return { field, constraint, userMessage, raw: input };
105
+ }
106
+
107
+ if (input instanceof Error && !(input instanceof SearchProviderError)) {
108
+ return {
109
+ userMessage: `web_search transport error: ${input.message}`,
110
+ raw: input,
111
+ };
112
+ }
113
+
114
+ const scrubbed = scrubLitellmNoise(rawMessage);
115
+ const userMessage =
116
+ scrubbed.length > 0
117
+ ? `web_search failed — ${scrubbed}`
118
+ : "web_search failed — upstream error (details suppressed).";
119
+ return { userMessage, raw: input };
120
+ }
@@ -21,6 +21,8 @@ import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { ty
21
21
  import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" };
22
22
  import type { ToolSession } from "../../tools";
23
23
  import { formatAge } from "../../tools/render-utils";
24
+ import { parseWebSearchError } from "./errors";
25
+ import { normalizeUserLocation, validateWebSearchParams, type WebSearchParams } from "./params";
24
26
  import { getSearchProvider, resolveProviderChain, type SearchProvider } from "./provider";
25
27
  import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
26
28
  import type { SearchProviderId, SearchResponse } from "./types";
@@ -28,26 +30,52 @@ import { SearchProviderError } from "./types";
28
30
 
29
31
  /** Web search tool parameters schema */
30
32
  export const webSearchSchema = Type.Object({
31
- query: Type.String({ description: "Search query" }),
33
+ query: Type.String({ description: "Search query (non-empty, whitespace-only is rejected)" }),
32
34
  recency: Type.Optional(
33
35
  StringEnum(["day", "week", "month", "year"], {
34
- description: "Recency filter (Brave, Perplexity)",
36
+ description: "Recency filter. Exhaustive enum — one of: day, week, month, year.",
35
37
  }),
36
38
  ),
37
- limit: Type.Optional(Type.Number({ description: "Max results to return" })),
38
- max_tokens: Type.Optional(Type.Number({ description: "Maximum output tokens" })),
39
- temperature: Type.Optional(Type.Number({ description: "Sampling temperature" })),
40
- num_search_results: Type.Optional(Type.Number({ description: "Number of search results to retrieve" })),
41
- allowed_domains: Type.Optional(Type.Array(Type.String(), { description: "Only return results from these domains" })),
42
- blocked_domains: Type.Optional(Type.Array(Type.String(), { description: "Exclude results from these domains" })),
43
- max_uses: Type.Optional(Type.Number({ description: "Maximum number of web searches per request" })),
39
+ limit: Type.Optional(
40
+ Type.Number({
41
+ description:
42
+ "Post-processing cap on the number of sources surfaced in the tool result. Positive integer. Controls output verbosity; see num_search_results for backend fetch count.",
43
+ }),
44
+ ),
45
+ max_tokens: Type.Optional(Type.Number({ description: "Maximum output tokens. Positive integer." })),
46
+ temperature: Type.Optional(Type.Number({ description: "Sampling temperature between 0 and 2." })),
47
+ num_search_results: Type.Optional(
48
+ Type.Number({
49
+ description:
50
+ "Number of sources the backend should fetch. Positive integer. Controls provider fetch count; see limit for output-side trimming.",
51
+ }),
52
+ ),
53
+ allowed_domains: Type.Optional(
54
+ Type.Array(Type.String(), {
55
+ description: "Only return results from these domains. Entries must be non-empty strings.",
56
+ }),
57
+ ),
58
+ blocked_domains: Type.Optional(
59
+ Type.Array(Type.String(), {
60
+ description: "Exclude results from these domains. Entries must be non-empty strings.",
61
+ }),
62
+ ),
63
+ max_uses: Type.Optional(
64
+ Type.Number({ description: "Maximum number of web searches per request. Positive integer." }),
65
+ ),
44
66
  user_location: Type.Optional(
45
67
  Type.Object(
46
68
  {
47
69
  type: Type.Literal("approximate"),
48
70
  city: Type.Optional(Type.String()),
49
71
  region: Type.Optional(Type.String()),
50
- country: Type.Optional(Type.String()),
72
+ country: Type.Optional(
73
+ Type.String({
74
+ minLength: 2,
75
+ maxLength: 2,
76
+ description: "ISO 3166-1 alpha-2 country code (e.g. US, JP, GB)",
77
+ }),
78
+ ),
51
79
  timezone: Type.Optional(Type.String()),
52
80
  },
53
81
  { description: "Approximate user location for localized results" },
@@ -96,9 +124,9 @@ function formatProviderError(error: unknown, provider: SearchProvider): string {
96
124
  }
97
125
  return `${getSearchProvider(error.provider).label} authorization failed (${error.status}). Check API key or base URL.`;
98
126
  }
99
- return error.message;
127
+ return parseWebSearchError(error).userMessage;
100
128
  }
101
- if (error instanceof Error) return error.message;
129
+ if (error instanceof Error) return parseWebSearchError(error).userMessage;
102
130
  return `Unknown error from ${provider.label}`;
103
131
  }
104
132
 
@@ -168,6 +196,19 @@ async function executeSearch(
168
196
  _toolCallId: string,
169
197
  params: SearchQueryParams,
170
198
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
199
+ const validation = validateWebSearchParams(params as WebSearchParams);
200
+ if (!validation.valid) {
201
+ const message = `web_search invalid parameter: ${validation.error}`;
202
+ return {
203
+ content: [{ type: "text" as const, text: `Error: ${message}` }],
204
+ details: { response: { provider: "none", sources: [] }, error: message },
205
+ };
206
+ }
207
+ const normalizedLocation = normalizeUserLocation(params.user_location);
208
+ const normalizedParams: SearchQueryParams = normalizedLocation
209
+ ? { ...params, user_location: normalizedLocation }
210
+ : params;
211
+ params = normalizedParams;
171
212
  const hasAnthropicOnlyParams =
172
213
  params.allowed_domains?.length || params.blocked_domains?.length || params.max_uses || params.user_location;
173
214
  const effectiveProvider =
@@ -0,0 +1,129 @@
1
+ export type WebSearchRecency = "day" | "week" | "month" | "year";
2
+
3
+ export interface WebSearchUserLocation {
4
+ type: "approximate";
5
+ city?: string;
6
+ region?: string;
7
+ country?: string;
8
+ timezone?: string;
9
+ }
10
+
11
+ export interface WebSearchParams {
12
+ query: string;
13
+ recency?: WebSearchRecency;
14
+ limit?: number;
15
+ max_tokens?: number;
16
+ temperature?: number;
17
+ num_search_results?: number;
18
+ max_uses?: number;
19
+ allowed_domains?: string[];
20
+ blocked_domains?: string[];
21
+ user_location?: WebSearchUserLocation;
22
+ }
23
+
24
+ export type ValidationResult = { valid: true } | { valid: false; error: string };
25
+
26
+ const RECENCY_VALUES: readonly WebSearchRecency[] = ["day", "week", "month", "year"] as const;
27
+ const ISO_ALPHA2 = /^[A-Za-z]{2}$/;
28
+
29
+ function fail(error: string): ValidationResult {
30
+ return { valid: false, error };
31
+ }
32
+
33
+ function isPositiveInteger(value: number): boolean {
34
+ return Number.isInteger(value) && value > 0;
35
+ }
36
+
37
+ function validatePositiveInteger(name: string, value: number | undefined): ValidationResult | null {
38
+ if (value === undefined) return null;
39
+ if (!isPositiveInteger(value)) {
40
+ return fail(`${name} must be a positive integer (received ${value})`);
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function validateDomainList(name: string, list: string[] | undefined): ValidationResult | null {
46
+ if (list === undefined) return null;
47
+ for (const entry of list) {
48
+ if (typeof entry !== "string" || entry.trim().length === 0) {
49
+ return fail(`${name} must not contain empty or whitespace-only entries`);
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function validateUserLocation(loc: WebSearchUserLocation | undefined): ValidationResult | null {
56
+ if (loc === undefined) return null;
57
+ if (loc.type !== "approximate") {
58
+ return fail(`user_location.type must be "approximate" (received "${String(loc.type)}")`);
59
+ }
60
+ if (loc.country !== undefined) {
61
+ if (typeof loc.country !== "string" || !ISO_ALPHA2.test(loc.country)) {
62
+ return fail(
63
+ `user_location.country must be an ISO 3166-1 alpha-2 code (e.g. "US", "JP", "GB") (received "${String(loc.country)}")`,
64
+ );
65
+ }
66
+ }
67
+ if (loc.city !== undefined && (typeof loc.city !== "string" || loc.city.trim().length === 0)) {
68
+ return fail(`user_location.city must be a non-empty string`);
69
+ }
70
+ if (loc.region !== undefined && (typeof loc.region !== "string" || loc.region.trim().length === 0)) {
71
+ return fail(`user_location.region must be a non-empty string`);
72
+ }
73
+ if (loc.timezone !== undefined && (typeof loc.timezone !== "string" || loc.timezone.trim().length === 0)) {
74
+ return fail(`user_location.timezone must be a non-empty string`);
75
+ }
76
+ return null;
77
+ }
78
+
79
+ export function validateWebSearchParams(params: WebSearchParams): ValidationResult {
80
+ if (typeof params.query !== "string" || params.query.trim().length === 0) {
81
+ return fail("query must be a non-empty string");
82
+ }
83
+
84
+ if (params.recency !== undefined && !RECENCY_VALUES.includes(params.recency)) {
85
+ return fail(
86
+ `recency must be one of ${RECENCY_VALUES.map(v => `"${v}"`).join(", ")} (received "${String(params.recency)}")`,
87
+ );
88
+ }
89
+
90
+ const positiveIntegerFields: [string, number | undefined][] = [
91
+ ["num_search_results", params.num_search_results],
92
+ ["limit", params.limit],
93
+ ["max_tokens", params.max_tokens],
94
+ ["max_uses", params.max_uses],
95
+ ];
96
+ for (const [name, value] of positiveIntegerFields) {
97
+ const result = validatePositiveInteger(name, value);
98
+ if (result) return result;
99
+ }
100
+
101
+ if (params.temperature !== undefined) {
102
+ if (typeof params.temperature !== "number" || params.temperature < 0 || params.temperature > 2) {
103
+ return fail(`temperature must be a number between 0 and 2 (received ${params.temperature})`);
104
+ }
105
+ }
106
+
107
+ for (const [name, list] of [
108
+ ["allowed_domains", params.allowed_domains],
109
+ ["blocked_domains", params.blocked_domains],
110
+ ] as const) {
111
+ const result = validateDomainList(name, list);
112
+ if (result) return result;
113
+ }
114
+
115
+ const locResult = validateUserLocation(params.user_location);
116
+ if (locResult) return locResult;
117
+
118
+ return { valid: true };
119
+ }
120
+
121
+ export function normalizeUserLocation(loc: WebSearchUserLocation | undefined): WebSearchUserLocation | undefined {
122
+ if (loc === undefined) return undefined;
123
+ const next: WebSearchUserLocation = { type: loc.type };
124
+ if (loc.city !== undefined) next.city = loc.city;
125
+ if (loc.region !== undefined) next.region = loc.region;
126
+ if (loc.timezone !== undefined) next.timezone = loc.timezone;
127
+ if (loc.country !== undefined) next.country = loc.country.toUpperCase();
128
+ return next;
129
+ }