@effect-x/ultimate-search 0.1.0 → 0.1.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 (39) hide show
  1. package/README.md +8 -3
  2. package/dist/cli.js +51 -51
  3. package/dist/cli.js.map +2 -2
  4. package/package.json +17 -13
  5. package/src/cli.ts +21 -0
  6. package/src/commands/fetch.ts +98 -0
  7. package/src/commands/map.ts +103 -0
  8. package/src/commands/mcp/stdio.ts +27 -0
  9. package/src/commands/mcp.ts +7 -0
  10. package/src/commands/root.ts +10 -0
  11. package/src/commands/search/dual.ts +91 -0
  12. package/src/commands/search/grok.ts +70 -0
  13. package/src/commands/search/tavily.ts +102 -0
  14. package/src/commands/search.ts +9 -0
  15. package/src/config/settings.ts +261 -0
  16. package/src/providers/firecrawl/client.ts +75 -0
  17. package/src/providers/firecrawl/schema.ts +31 -0
  18. package/src/providers/grok/client.ts +77 -0
  19. package/src/providers/grok/schema.ts +109 -0
  20. package/src/providers/tavily/client.ts +143 -0
  21. package/src/providers/tavily/schema.ts +207 -0
  22. package/src/services/dual-search.ts +150 -0
  23. package/src/services/firecrawl-fetch.ts +104 -0
  24. package/src/services/grok-search.ts +68 -0
  25. package/src/services/read-only-mcp.ts +278 -0
  26. package/src/services/tavily-extract.ts +66 -0
  27. package/src/services/tavily-map.ts +38 -0
  28. package/src/services/tavily-search.ts +40 -0
  29. package/src/services/web-fetch-schema.ts +74 -0
  30. package/src/services/web-fetch.ts +105 -0
  31. package/src/shared/cli-flags.ts +25 -0
  32. package/src/shared/command-output.ts +21 -0
  33. package/src/shared/effect.ts +52 -0
  34. package/src/shared/errors.ts +56 -0
  35. package/src/shared/output.ts +210 -0
  36. package/src/shared/provider-http-client.ts +73 -0
  37. package/src/shared/render-error.ts +101 -0
  38. package/src/shared/schema.ts +42 -0
  39. package/src/shared/tracing.ts +53 -0
@@ -0,0 +1,278 @@
1
+ import { Effect, Layer, Option, Schema } from "effect";
2
+ import { McpServer, Tool, Toolkit } from "effect/unstable/ai";
3
+ import { UltimateSearchConfig } from "../config/settings";
4
+ import {
5
+ GrokSearchInput,
6
+ GrokSearchResultSchema,
7
+ } from "../providers/grok/schema";
8
+ import { GrokProviderClient } from "../providers/grok/client";
9
+ import {
10
+ FetchContentFormatSchema,
11
+ TavilyMapBreadthSchema,
12
+ TavilyMapDepthSchema,
13
+ TavilyMapInput,
14
+ TavilyMapLimitSchema,
15
+ TavilyMapResponseSchema,
16
+ TavilySearchDepthSchema,
17
+ TavilySearchInput,
18
+ TavilySearchResponseSchema,
19
+ TavilySearchTopicSchema,
20
+ TavilyTimeRangeSchema,
21
+ } from "../providers/tavily/schema";
22
+ import { TavilyProviderClient } from "../providers/tavily/client";
23
+ import { FirecrawlProviderClient } from "../providers/firecrawl/client";
24
+ import { DualSearch, DualSearchInput, DualSearchResultSchema } from "./dual-search";
25
+ import { FirecrawlFetch } from "./firecrawl-fetch";
26
+ import { GrokSearch } from "./grok-search";
27
+ import { TavilyMap } from "./tavily-map";
28
+ import { TavilyExtract } from "./tavily-extract";
29
+ import { TavilySearch } from "./tavily-search";
30
+ import { WebFetch } from "./web-fetch";
31
+ import { WebFetchInput, WebFetchResultSchema } from "./web-fetch-schema";
32
+ import { RenderedErrorSchema, renderStructuredError } from "../shared/render-error";
33
+ import { absoluteUrlStringSchema, trimmedNonEmptyStringSchema } from "../shared/schema";
34
+
35
+ const optionalTrimmedTextField = (message: string) =>
36
+ Schema.optional(trimmedNonEmptyStringSchema(message));
37
+
38
+ const toOption = <A>(value: A | undefined) =>
39
+ value === undefined ? Option.none<A>() : Option.some(value);
40
+
41
+ const SearchGrokParametersSchema = Schema.Struct({
42
+ query: trimmedNonEmptyStringSchema("query must be a non-empty string"),
43
+ platform: optionalTrimmedTextField("platform must be a non-empty string"),
44
+ model: optionalTrimmedTextField("model must be a non-empty string"),
45
+ });
46
+
47
+ const SearchTavilyParametersSchema = Schema.Struct({
48
+ query: trimmedNonEmptyStringSchema("query must be a non-empty string"),
49
+ depth: Schema.optional(TavilySearchDepthSchema),
50
+ maxResults: Schema.optional(
51
+ Schema.Int.pipe(
52
+ Schema.check(Schema.isBetween({ minimum: 1, maximum: 20 })),
53
+ Schema.annotate({
54
+ message: "maxResults must be an integer between 1 and 20",
55
+ }),
56
+ ),
57
+ ),
58
+ topic: Schema.optional(TavilySearchTopicSchema),
59
+ timeRange: Schema.optional(TavilyTimeRangeSchema),
60
+ includeAnswer: Schema.optional(Schema.Boolean),
61
+ });
62
+
63
+ const SearchDualParametersSchema = Schema.Struct({
64
+ query: trimmedNonEmptyStringSchema("query must be a non-empty string"),
65
+ platform: optionalTrimmedTextField("platform must be a non-empty string"),
66
+ model: optionalTrimmedTextField("model must be a non-empty string"),
67
+ depth: Schema.optional(TavilySearchDepthSchema),
68
+ maxResults: Schema.optional(
69
+ Schema.Int.pipe(
70
+ Schema.check(Schema.isBetween({ minimum: 1, maximum: 20 })),
71
+ Schema.annotate({
72
+ message: "maxResults must be an integer between 1 and 20",
73
+ }),
74
+ ),
75
+ ),
76
+ topic: Schema.optional(TavilySearchTopicSchema),
77
+ timeRange: Schema.optional(TavilyTimeRangeSchema),
78
+ includeAnswer: Schema.optional(Schema.Boolean),
79
+ });
80
+
81
+ const FetchParametersSchema = Schema.Struct({
82
+ url: absoluteUrlStringSchema("url must be an absolute URL"),
83
+ depth: Schema.optional(TavilySearchDepthSchema),
84
+ format: Schema.optional(FetchContentFormatSchema),
85
+ });
86
+
87
+ const MapParametersSchema = Schema.Struct({
88
+ url: absoluteUrlStringSchema("url must be an absolute URL"),
89
+ depth: Schema.optional(TavilyMapDepthSchema),
90
+ breadth: Schema.optional(TavilyMapBreadthSchema),
91
+ limit: Schema.optional(TavilyMapLimitSchema),
92
+ instructions: optionalTrimmedTextField("instructions must be a non-empty string"),
93
+ });
94
+
95
+ const searchGrokTool = Tool.make("search_grok", {
96
+ description: "Run the Grok-backed search flow used by the CLI.",
97
+ parameters: SearchGrokParametersSchema,
98
+ success: GrokSearchResultSchema,
99
+ failure: RenderedErrorSchema,
100
+ failureMode: "return",
101
+ })
102
+ .annotate(Tool.Title, "Search Grok")
103
+ .annotate(Tool.Readonly, true)
104
+ .annotate(Tool.Destructive, false)
105
+ .annotate(Tool.Idempotent, true);
106
+
107
+ const searchTavilyTool = Tool.make("search_tavily", {
108
+ description: "Run the Tavily-backed search flow used by the CLI.",
109
+ parameters: SearchTavilyParametersSchema,
110
+ success: TavilySearchResponseSchema,
111
+ failure: RenderedErrorSchema,
112
+ failureMode: "return",
113
+ })
114
+ .annotate(Tool.Title, "Search Tavily")
115
+ .annotate(Tool.Readonly, true)
116
+ .annotate(Tool.Destructive, false)
117
+ .annotate(Tool.Idempotent, true);
118
+
119
+ const searchDualTool = Tool.make("search_dual", {
120
+ description: "Run Grok and Tavily concurrently with the CLI's dual-search orchestration.",
121
+ parameters: SearchDualParametersSchema,
122
+ success: DualSearchResultSchema,
123
+ failure: RenderedErrorSchema,
124
+ failureMode: "return",
125
+ })
126
+ .annotate(Tool.Title, "Search Dual")
127
+ .annotate(Tool.Readonly, true)
128
+ .annotate(Tool.Destructive, false)
129
+ .annotate(Tool.Idempotent, true);
130
+
131
+ const fetchTool = Tool.make("fetch", {
132
+ description: "Fetch a page with Tavily-first and FireCrawl fallback, matching the CLI.",
133
+ parameters: FetchParametersSchema,
134
+ success: WebFetchResultSchema,
135
+ failure: RenderedErrorSchema,
136
+ failureMode: "return",
137
+ })
138
+ .annotate(Tool.Title, "Fetch")
139
+ .annotate(Tool.Readonly, true)
140
+ .annotate(Tool.Destructive, false)
141
+ .annotate(Tool.Idempotent, true);
142
+
143
+ const mapTool = Tool.make("map", {
144
+ description: "Map a site's reachable URLs with Tavily, matching the CLI.",
145
+ parameters: MapParametersSchema,
146
+ success: TavilyMapResponseSchema,
147
+ failure: RenderedErrorSchema,
148
+ failureMode: "return",
149
+ })
150
+ .annotate(Tool.Title, "Map")
151
+ .annotate(Tool.Readonly, true)
152
+ .annotate(Tool.Destructive, false)
153
+ .annotate(Tool.Idempotent, true);
154
+
155
+ export const readOnlyMcpToolkit = Toolkit.make(
156
+ searchGrokTool,
157
+ searchTavilyTool,
158
+ searchDualTool,
159
+ fetchTool,
160
+ mapTool,
161
+ );
162
+
163
+ export const readOnlyMcpToolNames = [
164
+ "search_grok",
165
+ "search_tavily",
166
+ "search_dual",
167
+ "fetch",
168
+ "map",
169
+ ] as const;
170
+
171
+ const readOnlyMcpToolkitLayer = readOnlyMcpToolkit.toLayer(
172
+ Effect.gen(function* () {
173
+ const grokSearch = yield* GrokSearch;
174
+ const tavilySearch = yield* TavilySearch;
175
+ const dualSearch = yield* DualSearch;
176
+ const webFetch = yield* WebFetch;
177
+ const tavilyMap = yield* TavilyMap;
178
+
179
+ return readOnlyMcpToolkit.of({
180
+ search_grok: Effect.fn("ReadOnlyMcp.searchGrok")(function* (input) {
181
+ const request = yield* GrokSearchInput.decodeEffect({
182
+ query: input.query,
183
+ platform: toOption(input.platform),
184
+ model: toOption(input.model),
185
+ }).pipe(Effect.orDie);
186
+
187
+ return yield* grokSearch.search(request).pipe(Effect.mapError(renderStructuredError));
188
+ }),
189
+ search_tavily: Effect.fn("ReadOnlyMcp.searchTavily")(function* (input) {
190
+ const request = yield* TavilySearchInput.decodeEffect({
191
+ query: input.query,
192
+ searchDepth: toOption(input.depth),
193
+ topic: toOption(input.topic),
194
+ timeRange: toOption(input.timeRange),
195
+ maxResults: toOption(input.maxResults),
196
+ includeAnswer: input.includeAnswer ?? false,
197
+ }).pipe(Effect.orDie);
198
+
199
+ return yield* tavilySearch.search(request).pipe(Effect.mapError(renderStructuredError));
200
+ }),
201
+ search_dual: Effect.fn("ReadOnlyMcp.searchDual")(function* (input) {
202
+ const request = yield* DualSearchInput.decodeEffect({
203
+ query: input.query,
204
+ platform: toOption(input.platform),
205
+ model: toOption(input.model),
206
+ searchDepth: toOption(input.depth),
207
+ topic: toOption(input.topic),
208
+ timeRange: toOption(input.timeRange),
209
+ maxResults: toOption(input.maxResults),
210
+ includeAnswer: input.includeAnswer ?? false,
211
+ }).pipe(Effect.orDie);
212
+
213
+ return yield* dualSearch.search(request).pipe(Effect.mapError(renderStructuredError));
214
+ }),
215
+ fetch: Effect.fn("ReadOnlyMcp.fetch")(function* (input) {
216
+ const request = yield* WebFetchInput.decodeEffect({
217
+ urls: [input.url],
218
+ depth: input.depth ?? "basic",
219
+ format: input.format ?? "markdown",
220
+ }).pipe(Effect.orDie);
221
+
222
+ return yield* webFetch.fetch(request).pipe(
223
+ Effect.flatMap((result) => Schema.decodeUnknownEffect(WebFetchResultSchema)(result)),
224
+ Effect.mapError(renderStructuredError),
225
+ );
226
+ }),
227
+ map: Effect.fn("ReadOnlyMcp.map")(function* (input) {
228
+ const request = yield* TavilyMapInput.decodeEffect({
229
+ url: input.url,
230
+ depth: toOption(input.depth),
231
+ breadth: toOption(input.breadth),
232
+ limit: toOption(input.limit),
233
+ instructions: toOption(input.instructions),
234
+ }).pipe(Effect.orDie);
235
+
236
+ return yield* tavilyMap.map(request).pipe(Effect.mapError(renderStructuredError));
237
+ }),
238
+ });
239
+ }),
240
+ );
241
+
242
+ const readOnlyProviderLayer = Layer.mergeAll(
243
+ FirecrawlProviderClient.layer,
244
+ GrokProviderClient.layer,
245
+ TavilyProviderClient.layer,
246
+ ).pipe(Layer.provideMerge(UltimateSearchConfig.layer));
247
+
248
+ const grokSearchLayer = GrokSearch.layer.pipe(Layer.provideMerge(readOnlyProviderLayer));
249
+
250
+ const tavilySearchLayer = TavilySearch.layer.pipe(Layer.provideMerge(readOnlyProviderLayer));
251
+
252
+ const tavilyMapLayer = TavilyMap.layer.pipe(Layer.provideMerge(readOnlyProviderLayer));
253
+
254
+ const tavilyExtractLayer = TavilyExtract.layer.pipe(Layer.provideMerge(readOnlyProviderLayer));
255
+
256
+ const firecrawlFetchLayer = FirecrawlFetch.layer.pipe(Layer.provideMerge(readOnlyProviderLayer));
257
+
258
+ const webFetchLayer = WebFetch.layer.pipe(
259
+ Layer.provideMerge(firecrawlFetchLayer),
260
+ Layer.provideMerge(tavilyExtractLayer),
261
+ );
262
+
263
+ const dualSearchLayer = DualSearch.layer.pipe(
264
+ Layer.provideMerge(grokSearchLayer),
265
+ Layer.provideMerge(tavilySearchLayer),
266
+ );
267
+
268
+ export const readOnlyMcpServicesLayer = Layer.mergeAll(
269
+ grokSearchLayer,
270
+ tavilySearchLayer,
271
+ dualSearchLayer,
272
+ webFetchLayer,
273
+ tavilyMapLayer,
274
+ );
275
+
276
+ export const readOnlyMcpRegistrationLayer = Layer.effectDiscard(
277
+ McpServer.registerToolkit(readOnlyMcpToolkit),
278
+ ).pipe(Layer.provideMerge(readOnlyMcpToolkitLayer));
@@ -0,0 +1,66 @@
1
+ import { Effect, Layer, ServiceMap } from "effect";
2
+ import { TavilyProviderClient } from "../providers/tavily/client";
3
+ import { buildTavilyExtractRequest } from "../providers/tavily/schema";
4
+ import { ProviderContentError, type UltimateSearchError } from "../shared/errors";
5
+ import type { ServicesReturns } from "../shared/effect";
6
+ import type { FetchedPage, WebFetchInput } from "./web-fetch-schema";
7
+
8
+ const normalizeContent = (content: string | null | undefined) => {
9
+ const text = content?.trim() ?? "";
10
+ return text.length > 0 ? text : null;
11
+ };
12
+
13
+ export class TavilyExtract extends ServiceMap.Service<
14
+ TavilyExtract,
15
+ {
16
+ readonly extract: (
17
+ input: WebFetchInput,
18
+ ) => Effect.Effect<ReadonlyArray<FetchedPage>, UltimateSearchError, never>;
19
+ }
20
+ >()("TavilyExtract") {
21
+ static readonly layer = Layer.effect(
22
+ TavilyExtract,
23
+ Effect.gen(function* () {
24
+ const provider = yield* TavilyProviderClient;
25
+
26
+ const extract: TavilyExtract.Methods["extract"] = Effect.fn("TavilyExtract.extract")(
27
+ function* (input): TavilyExtract.Returns<"extract"> {
28
+ const response = yield* provider.extract(buildTavilyExtractRequest(input));
29
+ const results = response.results.flatMap((item) => {
30
+ const rawContent = normalizeContent(item.raw_content);
31
+
32
+ if (rawContent === null) {
33
+ return [];
34
+ }
35
+
36
+ return [
37
+ {
38
+ url: item.url,
39
+ title: item.title,
40
+ raw_content: rawContent,
41
+ } satisfies FetchedPage,
42
+ ];
43
+ });
44
+
45
+ if (results.length === 0) {
46
+ return yield* new ProviderContentError({
47
+ provider: "tavily",
48
+ message: "Tavily returned no extractable content.",
49
+ });
50
+ }
51
+
52
+ return results;
53
+ },
54
+ );
55
+
56
+ return TavilyExtract.of({
57
+ extract,
58
+ });
59
+ }),
60
+ );
61
+ }
62
+
63
+ export declare namespace TavilyExtract {
64
+ export type Methods = ServiceMap.Service.Shape<typeof TavilyExtract>;
65
+ export type Returns<key extends keyof Methods, R = never> = ServicesReturns<Methods[key], R>;
66
+ }
@@ -0,0 +1,38 @@
1
+ import { Effect, Layer, ServiceMap } from "effect";
2
+ import { TavilyProviderClient } from "../providers/tavily/client";
3
+ import {
4
+ buildTavilyMapRequest,
5
+ TavilyMapInput,
6
+ type TavilyMapResponse,
7
+ } from "../providers/tavily/schema";
8
+ import type { UltimateSearchError } from "../shared/errors";
9
+ import type { ServicesReturns } from "../shared/effect";
10
+
11
+ export class TavilyMap extends ServiceMap.Service<
12
+ TavilyMap,
13
+ {
14
+ readonly map: (
15
+ input: TavilyMapInput,
16
+ ) => Effect.Effect<TavilyMapResponse, UltimateSearchError, never>;
17
+ }
18
+ >()("TavilyMap") {
19
+ static readonly layer = Layer.effect(
20
+ TavilyMap,
21
+ Effect.gen(function* () {
22
+ const provider = yield* TavilyProviderClient;
23
+
24
+ const map: TavilyMap.Methods["map"] = Effect.fn("TavilyMap.map")(function* (input) {
25
+ return yield* provider.map(buildTavilyMapRequest(input));
26
+ });
27
+
28
+ return TavilyMap.of({
29
+ map,
30
+ });
31
+ }),
32
+ );
33
+ }
34
+
35
+ export declare namespace TavilyMap {
36
+ export type Methods = ServiceMap.Service.Shape<typeof TavilyMap>;
37
+ export type Returns<key extends keyof Methods, R = never> = ServicesReturns<Methods[key], R>;
38
+ }
@@ -0,0 +1,40 @@
1
+ import { Effect, Layer, ServiceMap } from "effect";
2
+ import { TavilyProviderClient } from "../providers/tavily/client";
3
+ import {
4
+ buildTavilySearchRequest,
5
+ TavilySearchInput,
6
+ type TavilySearchResponse,
7
+ } from "../providers/tavily/schema";
8
+ import type { UltimateSearchError } from "../shared/errors";
9
+ import type { ServicesReturns } from "../shared/effect";
10
+
11
+ export class TavilySearch extends ServiceMap.Service<
12
+ TavilySearch,
13
+ {
14
+ readonly search: (
15
+ input: TavilySearchInput,
16
+ ) => Effect.Effect<TavilySearchResponse, UltimateSearchError, never>;
17
+ }
18
+ >()("TavilySearch") {
19
+ static readonly layer = Layer.effect(
20
+ TavilySearch,
21
+ Effect.gen(function* () {
22
+ const provider = yield* TavilyProviderClient;
23
+
24
+ const search: TavilySearch.Methods["search"] = Effect.fn("TavilySearch.search")(
25
+ function* (input): TavilySearch.Returns<"search"> {
26
+ return yield* provider.search(buildTavilySearchRequest(input));
27
+ },
28
+ );
29
+
30
+ return TavilySearch.of({
31
+ search,
32
+ });
33
+ }),
34
+ );
35
+ }
36
+
37
+ export declare namespace TavilySearch {
38
+ export type Methods = ServiceMap.Service.Shape<typeof TavilySearch>;
39
+ export type Returns<key extends keyof Methods, R = never> = ServicesReturns<Methods[key], R>;
40
+ }
@@ -0,0 +1,74 @@
1
+ import { Schema } from "effect";
2
+ import {
3
+ FetchContentFormatSchema,
4
+ TavilyExtractDepthSchema,
5
+ type FetchContentFormat,
6
+ } from "../providers/tavily/schema";
7
+ import { SearchProvider } from "../shared/errors";
8
+ import { absoluteUrlStringSchema } from "../shared/schema";
9
+
10
+ export const FetchBackendSchema = Schema.Literals(["tavily", "firecrawl"] as const);
11
+
12
+ export type FetchBackend = typeof FetchBackendSchema.Type;
13
+
14
+ export class WebFetchInput extends Schema.Class<WebFetchInput>("WebFetchInput")({
15
+ urls: Schema.NonEmptyArray(
16
+ absoluteUrlStringSchema("url must be an absolute URL"),
17
+ ),
18
+ depth: TavilyExtractDepthSchema,
19
+ format: FetchContentFormatSchema,
20
+ }) {
21
+ static decodeEffect = Schema.decodeUnknownEffect(WebFetchInput);
22
+ }
23
+
24
+ export interface FetchedPage {
25
+ readonly url: string;
26
+ readonly title?: string | null | undefined;
27
+ readonly raw_content: string;
28
+ }
29
+
30
+ export const FetchedPageSchema = Schema.Struct({
31
+ url: Schema.String,
32
+ title: Schema.optional(Schema.NullOr(Schema.String)),
33
+ raw_content: Schema.String,
34
+ });
35
+
36
+ export interface FallbackReason {
37
+ readonly type: string;
38
+ readonly provider: typeof SearchProvider.Type;
39
+ readonly message: string;
40
+ }
41
+
42
+ export const FallbackReasonSchema = Schema.Struct({
43
+ type: Schema.String,
44
+ provider: SearchProvider,
45
+ message: Schema.String,
46
+ });
47
+
48
+ export interface FetchFallback {
49
+ readonly from: "tavily";
50
+ readonly to: "firecrawl";
51
+ readonly reason: FallbackReason;
52
+ }
53
+
54
+ export const FetchFallbackSchema = Schema.Struct({
55
+ from: Schema.Literal("tavily"),
56
+ to: Schema.Literal("firecrawl"),
57
+ reason: FallbackReasonSchema,
58
+ });
59
+
60
+ export interface WebFetchResult {
61
+ readonly backend: FetchBackend;
62
+ readonly format: FetchContentFormat;
63
+ readonly results: ReadonlyArray<FetchedPage>;
64
+ readonly fallback?: FetchFallback | undefined;
65
+ }
66
+
67
+ export const WebFetchResultSchema = Schema.Struct({
68
+ backend: FetchBackendSchema,
69
+ format: FetchContentFormatSchema,
70
+ results: Schema.NonEmptyArray(FetchedPageSchema),
71
+ fallback: Schema.optional(FetchFallbackSchema),
72
+ });
73
+
74
+ export type WebFetchFormat = FetchContentFormat;
@@ -0,0 +1,105 @@
1
+ import { Effect, Layer, Result, ServiceMap } from "effect";
2
+ import {
3
+ ConfigValidationError,
4
+ ProviderContentError,
5
+ ProviderDecodeError,
6
+ ProviderRequestError,
7
+ ProviderResponseError,
8
+ type UltimateSearchError,
9
+ } from "../shared/errors";
10
+ import type { ServicesReturns } from "../shared/effect";
11
+ import { FirecrawlFetch } from "./firecrawl-fetch";
12
+ import type {
13
+ FallbackReason,
14
+ WebFetchInput,
15
+ WebFetchResult,
16
+ } from "./web-fetch-schema";
17
+ import { TavilyExtract } from "./tavily-extract";
18
+
19
+ const summarizeFallbackReason = (error: UltimateSearchError): FallbackReason => {
20
+ if (
21
+ error instanceof ConfigValidationError ||
22
+ error instanceof ProviderRequestError ||
23
+ error instanceof ProviderResponseError ||
24
+ error instanceof ProviderDecodeError ||
25
+ error instanceof ProviderContentError
26
+ ) {
27
+ return {
28
+ type: error._tag,
29
+ provider: error.provider,
30
+ message: error.message,
31
+ };
32
+ }
33
+
34
+ return {
35
+ type: "UnknownError",
36
+ provider: "shared",
37
+ message: String(error),
38
+ };
39
+ };
40
+
41
+ const resolveDoubleFailure = (
42
+ primary: UltimateSearchError,
43
+ fallback: UltimateSearchError,
44
+ ): UltimateSearchError =>
45
+ fallback instanceof ConfigValidationError &&
46
+ !(primary instanceof ConfigValidationError)
47
+ ? primary
48
+ : fallback;
49
+
50
+ export class WebFetch extends ServiceMap.Service<
51
+ WebFetch,
52
+ {
53
+ readonly fetch: (
54
+ input: WebFetchInput,
55
+ ) => Effect.Effect<WebFetchResult, UltimateSearchError, never>;
56
+ }
57
+ >()("WebFetch") {
58
+ static readonly layer = Layer.effect(
59
+ WebFetch,
60
+ Effect.gen(function* () {
61
+ const tavilyExtract = yield* TavilyExtract;
62
+ const firecrawlFetch = yield* FirecrawlFetch;
63
+
64
+ const fetch: WebFetch.Methods["fetch"] = Effect.fn("WebFetch.fetch")(
65
+ function* (input): WebFetch.Returns<"fetch"> {
66
+ const tavilyAttempt = yield* Effect.result(tavilyExtract.extract(input));
67
+
68
+ if (Result.isSuccess(tavilyAttempt)) {
69
+ return {
70
+ backend: "tavily",
71
+ format: input.format,
72
+ results: tavilyAttempt.success,
73
+ } satisfies WebFetchResult;
74
+ }
75
+
76
+ const firecrawlAttempt = yield* Effect.result(firecrawlFetch.fetch(input));
77
+
78
+ if (Result.isSuccess(firecrawlAttempt)) {
79
+ return {
80
+ backend: "firecrawl",
81
+ format: input.format,
82
+ results: firecrawlAttempt.success,
83
+ fallback: {
84
+ from: "tavily",
85
+ to: "firecrawl",
86
+ reason: summarizeFallbackReason(tavilyAttempt.failure),
87
+ },
88
+ } satisfies WebFetchResult;
89
+ }
90
+
91
+ return yield* resolveDoubleFailure(tavilyAttempt.failure, firecrawlAttempt.failure);
92
+ },
93
+ );
94
+
95
+ return WebFetch.of({
96
+ fetch,
97
+ });
98
+ }),
99
+ );
100
+ }
101
+
102
+ export declare namespace WebFetch {
103
+ export type Methods = ServiceMap.Service.Shape<typeof WebFetch>;
104
+ export type Returns<key extends keyof Methods, R = never> = ServicesReturns<Methods[key], R>;
105
+ }
@@ -0,0 +1,25 @@
1
+ import { Option, Schema } from "effect";
2
+ import { Flag } from "effect/unstable/cli";
3
+
4
+ export const optionalTrimmedTextFlag = (name: string, description: string) =>
5
+ Flag.optional(
6
+ Flag.string(name).pipe(Flag.withSchema(Schema.Trim), Flag.withDescription(description)),
7
+ ).pipe(Flag.map((value) => Option.filter(value, (text) => text.length > 0)));
8
+
9
+ export const optionalChoiceFlag = <A extends string>(
10
+ name: string,
11
+ choices: ReadonlyArray<A>,
12
+ description: string,
13
+ ) => Flag.optional(Flag.choice(name, choices).pipe(Flag.withDescription(description)));
14
+
15
+ export const optionalIntegerFlag = (name: string, description: string) =>
16
+ Flag.optional(Flag.integer(name).pipe(Flag.withDescription(description)));
17
+
18
+ export const optionalIntegerFlagWithSchema = <A>(
19
+ name: string,
20
+ schema: Schema.Codec<A, number>,
21
+ description: string,
22
+ ) =>
23
+ Flag.optional(
24
+ Flag.integer(name).pipe(Flag.withSchema(schema), Flag.withDescription(description)),
25
+ );
@@ -0,0 +1,21 @@
1
+ import { Effect, Option } from "effect";
2
+ import { CliOutput, type OutputMode, resolveOutputMode } from "./output";
3
+
4
+ export interface CommandOutput {
5
+ readonly human: string;
6
+ readonly llm: unknown;
7
+ }
8
+
9
+ export const runCommandWithOutput = <E, R>(
10
+ selectedMode: Option.Option<OutputMode>,
11
+ buildOutput: (mode: OutputMode) => Effect.Effect<CommandOutput, E, R>,
12
+ ) =>
13
+ Effect.gen(function* () {
14
+ const cliOutput = yield* CliOutput;
15
+ const mode = resolveOutputMode(selectedMode, cliOutput.defaultMode);
16
+ const output = yield* buildOutput(mode).pipe(
17
+ Effect.tapError((error) => cliOutput.logError(error, mode)),
18
+ );
19
+
20
+ yield* cliOutput.writeOutput(output, mode);
21
+ });