@f5xc-salesdemos/xcsh 17.2.0 → 17.4.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "17.2.0",
4
+ "version": "17.4.0",
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.2.0",
50
- "@f5xc-salesdemos/pi-agent-core": "17.2.0",
51
- "@f5xc-salesdemos/pi-ai": "17.2.0",
52
- "@f5xc-salesdemos/pi-natives": "17.2.0",
53
- "@f5xc-salesdemos/pi-tui": "17.2.0",
54
- "@f5xc-salesdemos/pi-utils": "17.2.0",
49
+ "@f5xc-salesdemos/xcsh-stats": "17.4.0",
50
+ "@f5xc-salesdemos/pi-agent-core": "17.4.0",
51
+ "@f5xc-salesdemos/pi-ai": "17.4.0",
52
+ "@f5xc-salesdemos/pi-natives": "17.4.0",
53
+ "@f5xc-salesdemos/pi-tui": "17.4.0",
54
+ "@f5xc-salesdemos/pi-utils": "17.4.0",
55
55
  "@sinclair/typebox": "^0.34",
56
56
  "@xterm/headless": "^6.0",
57
57
  "ajv": "^8.18",
@@ -4,13 +4,14 @@
4
4
  * Handles `xcsh q`/`xcsh web-search` subcommands for testing web search providers.
5
5
  */
6
6
 
7
- import { APP_NAME } from "@f5xc-salesdemos/pi-utils";
7
+ import { buildAnthropicSearchHeaders, buildAnthropicUrl, findAnthropicAuth } from "@f5xc-salesdemos/pi-ai";
8
+ import { $env, APP_NAME } from "@f5xc-salesdemos/pi-utils";
8
9
  import chalk from "chalk";
9
10
  import { initTheme, theme } from "../modes/theme/theme";
10
11
  import { runSearchQuery, type SearchQueryParams } from "../web/search/index";
11
12
  import { SEARCH_PROVIDER_ORDER } from "../web/search/provider";
12
13
  import { renderSearchResult } from "../web/search/render";
13
- import type { SearchProviderId } from "../web/search/types";
14
+ import type { SearchProviderId, SearchResponse } from "../web/search/types";
14
15
 
15
16
  export interface SearchCommandArgs {
16
17
  query: string;
@@ -18,6 +19,8 @@ export interface SearchCommandArgs {
18
19
  recency?: "day" | "week" | "month" | "year";
19
20
  limit?: number;
20
21
  expanded: boolean;
22
+ synthesize: boolean;
23
+ synthesizeModel?: string;
21
24
  }
22
25
 
23
26
  const PROVIDERS: Array<SearchProviderId | "auto"> = ["auto", ...SEARCH_PROVIDER_ORDER];
@@ -36,6 +39,7 @@ export function parseSearchArgs(args: string[]): SearchCommandArgs | undefined {
36
39
  const result: SearchCommandArgs = {
37
40
  query: "",
38
41
  expanded: true,
42
+ synthesize: true,
39
43
  };
40
44
 
41
45
  const positional: string[] = [];
@@ -50,6 +54,10 @@ export function parseSearchArgs(args: string[]): SearchCommandArgs | undefined {
50
54
  result.limit = Number.parseInt(args[++i], 10);
51
55
  } else if (arg === "--compact") {
52
56
  result.expanded = false;
57
+ } else if (arg === "--no-synthesize") {
58
+ result.synthesize = false;
59
+ } else if (arg === "--model") {
60
+ result.synthesizeModel = args[++i];
53
61
  } else if (!arg.startsWith("-")) {
54
62
  positional.push(arg);
55
63
  }
@@ -62,6 +70,54 @@ export function parseSearchArgs(args: string[]): SearchCommandArgs | undefined {
62
70
  return result;
63
71
  }
64
72
 
73
+ async function synthesizeResponse(
74
+ query: string,
75
+ searchResponse: SearchResponse,
76
+ model?: string,
77
+ ): Promise<string | undefined> {
78
+ const auth = await findAnthropicAuth();
79
+ if (!auth) return undefined;
80
+
81
+ const sourcesContext = searchResponse.sources
82
+ .map((s, i) => `[${i + 1}] ${s.title}\n ${s.url}${s.snippet ? `\n ${s.snippet}` : ""}`)
83
+ .join("\n");
84
+
85
+ const citationsContext =
86
+ searchResponse.citations?.map((c, i) => `[${i + 1}] ${c.title}: "${c.citedText}"`).join("\n") ?? "";
87
+
88
+ const synthesisPrompt = `Based on the following web search results for the query "${query}", provide a comprehensive, well-structured answer. Cite sources by number.
89
+
90
+ ## Search Results
91
+ ${searchResponse.answer ? `### Initial Answer\n${searchResponse.answer}\n` : ""}
92
+ ### Sources
93
+ ${sourcesContext}
94
+ ${citationsContext ? `\n### Citations\n${citationsContext}` : ""}
95
+
96
+ Provide a thorough answer with key facts, numbers, and source citations.`;
97
+
98
+ const url = buildAnthropicUrl(auth);
99
+ const headers = buildAnthropicSearchHeaders(auth);
100
+ const selectedModel = model ?? $env.ANTHROPIC_SEARCH_MODEL ?? "claude-haiku-4-5";
101
+
102
+ const response = await fetch(url, {
103
+ method: "POST",
104
+ headers,
105
+ body: JSON.stringify({
106
+ model: selectedModel,
107
+ max_tokens: 4096,
108
+ messages: [{ role: "user", content: synthesisPrompt }],
109
+ }),
110
+ });
111
+
112
+ if (!response.ok) return undefined;
113
+
114
+ const data = (await response.json()) as { content?: Array<{ type: string; text?: string }> };
115
+ return data.content
116
+ ?.filter(b => b.type === "text" && b.text)
117
+ .map(b => b.text)
118
+ .join("\n\n");
119
+ }
120
+
65
121
  export async function runSearchCommand(cmd: SearchCommandArgs): Promise<void> {
66
122
  if (!cmd.query) {
67
123
  process.stderr.write(`${chalk.red("Error: Query is required")}\n`);
@@ -104,6 +160,18 @@ export async function runSearchCommand(cmd: SearchCommandArgs): Promise<void> {
104
160
  const width = Math.max(60, process.stdout.columns ?? 100);
105
161
  process.stdout.write(`${component.render(width).join("\n")}\n`);
106
162
 
163
+ if (cmd.synthesize && result.details?.response && result.details.response.sources.length > 0) {
164
+ try {
165
+ process.stderr.write(chalk.dim("\nSynthesizing response...\n"));
166
+ const synthesized = await synthesizeResponse(cmd.query, result.details.response, cmd.synthesizeModel);
167
+ if (synthesized) {
168
+ process.stdout.write(`\n${chalk.bold("Synthesized Answer:")}\n\n${synthesized}\n`);
169
+ }
170
+ } catch {
171
+ process.stderr.write(chalk.dim("Synthesis unavailable — showing raw search results only.\n"));
172
+ }
173
+ }
174
+
107
175
  if (result.details?.error) {
108
176
  process.exitCode = 1;
109
177
  }
@@ -124,6 +192,8 @@ ${chalk.bold("Options:")}
124
192
  --recency <value> Recency filter (Brave/Perplexity): ${RECENCY_OPTIONS.join(", ")}
125
193
  -l, --limit <n> Max results to return
126
194
  --compact Render condensed output
195
+ --no-synthesize Skip synthesis step (raw search only)
196
+ --model <name> Model for synthesis (default: ANTHROPIC_SEARCH_MODEL or claude-haiku-4-5)
127
197
  -h, --help Show this help
128
198
 
129
199
  ${chalk.bold("Examples:")}
@@ -23,6 +23,8 @@ export default class Search extends Command {
23
23
  recency: Flags.string({ description: "Recency filter", options: RECENCY }),
24
24
  limit: Flags.integer({ char: "l", description: "Max results to return" }),
25
25
  compact: Flags.boolean({ description: "Render condensed output" }),
26
+ "no-synthesize": Flags.boolean({ description: "Skip synthesis step (raw search only)" }),
27
+ model: Flags.string({ description: "Model for synthesis (default: claude-haiku-4-5)" }),
26
28
  };
27
29
 
28
30
  async run(): Promise<void> {
@@ -35,6 +37,8 @@ export default class Search extends Command {
35
37
  recency: flags.recency as SearchCommandArgs["recency"],
36
38
  limit: flags.limit,
37
39
  expanded: !flags.compact,
40
+ synthesize: !flags["no-synthesize"],
41
+ synthesizeModel: flags.model,
38
42
  };
39
43
 
40
44
  await runSearchCommand(cmd);
@@ -1313,6 +1313,15 @@ export const SETTINGS_SCHEMA = {
1313
1313
  default: true,
1314
1314
  ui: { tab: "tools", label: "Web Search", description: "Enable the web_search tool for web searching" },
1315
1315
  },
1316
+ "web_search.verbose": {
1317
+ type: "boolean",
1318
+ default: false,
1319
+ ui: {
1320
+ tab: "tools",
1321
+ label: "Web Search Verbose",
1322
+ description: "Show detailed search panels (sources, metadata, tokens). When disabled, uses compact output.",
1323
+ },
1324
+ },
1316
1325
 
1317
1326
  "browser.enabled": {
1318
1327
  type: "boolean",
@@ -168,9 +168,10 @@ async function executeSearch(
168
168
  _toolCallId: string,
169
169
  params: SearchQueryParams,
170
170
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
171
- const hasDomainFilter = params.allowed_domains?.length || params.blocked_domains?.length;
171
+ const hasAnthropicOnlyParams =
172
+ params.allowed_domains?.length || params.blocked_domains?.length || params.max_uses || params.user_location;
172
173
  const effectiveProvider =
173
- hasDomainFilter && (!params.provider || params.provider === "auto") ? "anthropic" : params.provider;
174
+ hasAnthropicOnlyParams && (!params.provider || params.provider === "auto") ? "anthropic" : params.provider;
174
175
  const providers =
175
176
  effectiveProvider && effectiveProvider !== "auto"
176
177
  ? (await getSearchProvider(effectiveProvider).isAvailable())
@@ -191,6 +192,7 @@ async function executeSearch(
191
192
  for (const provider of providers) {
192
193
  lastProvider = provider;
193
194
  try {
195
+ const searchStart = performance.now();
194
196
  const response = await provider.search({
195
197
  query: params.query.replace(/202\d/g, String(new Date().getFullYear())), // LUL
196
198
  limit: params.limit,
@@ -204,6 +206,7 @@ async function executeSearch(
204
206
  maxUses: params.max_uses,
205
207
  userLocation: params.user_location,
206
208
  });
209
+ response.durationMs = performance.now() - searchStart;
207
210
 
208
211
  const text = formatForLLM(response);
209
212
 
@@ -27,6 +27,7 @@ import { SearchProvider } from "./base";
27
27
 
28
28
  const DEFAULT_MODEL = "claude-haiku-4-5";
29
29
  const DEFAULT_MAX_TOKENS = 4096;
30
+ const DEFAULT_MAX_USES = 8;
30
31
  const WEB_SEARCH_TOOL_NAME = "web_search";
31
32
  const WEB_SEARCH_TOOL_TYPE = "web_search_20250305";
32
33
 
@@ -132,7 +133,7 @@ async function callSearch(
132
133
  };
133
134
  if (toolConfig?.allowed_domains?.length) tool.allowed_domains = toolConfig.allowed_domains;
134
135
  if (toolConfig?.blocked_domains?.length) tool.blocked_domains = toolConfig.blocked_domains;
135
- if (toolConfig?.max_uses) tool.max_uses = toolConfig.max_uses;
136
+ tool.max_uses = toolConfig?.max_uses ?? DEFAULT_MAX_USES;
136
137
  if (toolConfig?.user_location) tool.user_location = toolConfig.user_location;
137
138
 
138
139
  const body: Record<string, unknown> = {
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { Component } from "@f5xc-salesdemos/pi-tui";
8
8
  import { Text, visibleWidth, wrapTextWithAnsi } from "@f5xc-salesdemos/pi-tui";
9
+ import { Settings } from "../../config/settings";
9
10
  import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
10
11
  import type { Theme } from "../../modes/theme/theme";
11
12
  import {
@@ -25,6 +26,14 @@ import { CachedOutputBlock } from "../../tui/output-block";
25
26
  import { getSearchProvider } from "./provider";
26
27
  import type { SearchResponse } from "./types";
27
28
 
29
+ function getVerboseSetting(): boolean {
30
+ try {
31
+ return Settings.instance.get("web_search.verbose");
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
28
37
  const MAX_COLLAPSED_ANSWER_LINES = PREVIEW_LIMITS.COLLAPSED_LINES;
29
38
  const MAX_EXPANDED_ANSWER_LINES = PREVIEW_LIMITS.EXPANDED_LINES;
30
39
  const MAX_ANSWER_LINE_LEN = TRUNCATE_LENGTHS.LINE;
@@ -92,6 +101,20 @@ export function renderSearchResult(
92
101
  return renderFallbackText(rawText, options.expanded, theme);
93
102
  }
94
103
 
104
+ const verbose = getVerboseSetting();
105
+ if (!verbose) {
106
+ const searches = response.usage?.searchRequests ?? 1;
107
+ const plural = searches !== 1 ? "es" : "";
108
+ const dur =
109
+ response.durationMs !== undefined
110
+ ? response.durationMs >= 1000
111
+ ? `${Math.round(response.durationMs / 1000)}s`
112
+ : `${Math.round(response.durationMs)}ms`
113
+ : "";
114
+ const line = ` \u23BF Did ${searches} search${plural}${dur ? ` in ${dur}` : ""}`;
115
+ return new Text(theme.fg("dim", line), 0, 0);
116
+ }
117
+
95
118
  const sources = Array.isArray(response.sources) ? response.sources : [];
96
119
  const sourceCount = sources.length;
97
120
  const citations = Array.isArray(response.citations) ? response.citations : [];
@@ -288,6 +311,10 @@ export function renderSearchCall(
288
311
  theme: Theme,
289
312
  ): Component {
290
313
  const query = truncateToWidth(args.query ?? "", 80);
314
+ const verbose = getVerboseSetting();
315
+ if (!verbose) {
316
+ return new Text(`${theme.fg("text", `Web Search("${query}")`)}`, 0, 0);
317
+ }
291
318
  const text = renderStatusLine({ icon: "pending", title: "Web Search", description: query }, theme);
292
319
  return new Text(text, 0, 0);
293
320
  }
@@ -94,6 +94,8 @@ export interface SearchResponse {
94
94
  requestId?: string;
95
95
  /** Authentication mode used by the provider (e.g. oauth, api-key) */
96
96
  authMode?: string;
97
+ /** Wall-clock duration of the search request in milliseconds */
98
+ durationMs?: number;
97
99
  }
98
100
 
99
101
  /** Provider-specific error with optional HTTP status */