@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,52 @@
1
+ import { Effect } from "effect";
2
+ /**
3
+ * Derives the return type for a service method, strictly preserving the
4
+ * method's full type signature (success, error, and context).
5
+ *
6
+ * Unlike RPC return helpers (where transport contracts typically do not carry a
7
+ * context channel and `R` is introduced separately), service interfaces already
8
+ * define the full `Effect.Effect<A, E, R>` — including dependencies. The
9
+ * implementation must conform exactly; any extra dependency is a compile error,
10
+ * not something to patch in via a generic.
11
+ *
12
+ * @typeParam T - The service method type (from `ServiceMap.Service.Shape<typeof MyService>[key]`)
13
+ * @typeParam R - Optional additional context requirements to merge with the method's context
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { Effect, ServiceMap } from "effect"
18
+ *
19
+ * export class MyService extends ServiceMap.Service<MyService, {
20
+ * readonly list: () => Effect.Effect<Items, MyError>
21
+ * readonly data: Effect.Effect<Data, MyError>
22
+ * }>()("MyService") {}
23
+ *
24
+ * export declare namespace MyService {
25
+ * export type Methods = ServiceMap.Service.Shape<typeof MyService>
26
+ * export type Returns<key extends keyof Methods, R = never> = ServicesReturns<Methods[key], R>
27
+ * }
28
+ *
29
+ * const list: MyService.Methods['list'] = Effect.fn('list')(
30
+ * function* (): MyService.Returns<'list'> {
31
+ * // return type is locked to Effect.Effect<Items, MyError, never>
32
+ * ...
33
+ * }
34
+ * )
35
+ *
36
+ * // With additional context
37
+ * const listWithLogger: MyService.Methods['list'] = Effect.fn('list')(
38
+ * function* (): MyService.Returns<'list', Logger> {
39
+ * // return type is Effect.Effect<Items, MyError, Logger>
40
+ * const logger = yield* Logger
41
+ * ...
42
+ * }
43
+ * )
44
+ * ```
45
+ */
46
+ export type ServicesReturns<T, R = never> = T extends (
47
+ ...args: any
48
+ ) => Effect.Effect<infer A, infer E, infer R0>
49
+ ? Effect.fn.Return<A, E, R0 | R>
50
+ : T extends Effect.Effect<infer A, infer E, infer R0>
51
+ ? Effect.fn.Return<A, E, R0 | R>
52
+ : T;
@@ -0,0 +1,56 @@
1
+ import { Schema } from "effect";
2
+
3
+ export const SearchProvider = Schema.Literals(["shared", "grok", "tavily", "firecrawl"]);
4
+
5
+ export class ConfigValidationError extends Schema.TaggedErrorClass<ConfigValidationError>()(
6
+ "ConfigValidationError",
7
+ {
8
+ provider: SearchProvider,
9
+ message: Schema.String,
10
+ details: Schema.optional(Schema.Array(Schema.String)),
11
+ cause: Schema.optional(Schema.Unknown),
12
+ },
13
+ ) {}
14
+
15
+ export class ProviderRequestError extends Schema.TaggedErrorClass<ProviderRequestError>()(
16
+ "ProviderRequestError",
17
+ {
18
+ provider: SearchProvider,
19
+ message: Schema.String,
20
+ },
21
+ ) {}
22
+
23
+ export class ProviderResponseError extends Schema.TaggedErrorClass<ProviderResponseError>()(
24
+ "ProviderResponseError",
25
+ {
26
+ provider: SearchProvider,
27
+ message: Schema.String,
28
+ status: Schema.Number,
29
+ body: Schema.String,
30
+ cause: Schema.optional(Schema.Unknown),
31
+ },
32
+ ) {}
33
+
34
+ export class ProviderContentError extends Schema.TaggedErrorClass<ProviderContentError>()(
35
+ "ProviderContentError",
36
+ {
37
+ provider: SearchProvider,
38
+ message: Schema.String,
39
+ },
40
+ ) {}
41
+
42
+ export class ProviderDecodeError extends Schema.TaggedErrorClass<ProviderDecodeError>()(
43
+ "ProviderDecodeError",
44
+ {
45
+ provider: SearchProvider,
46
+ message: Schema.String,
47
+ cause: Schema.optional(Schema.Unknown),
48
+ },
49
+ ) {}
50
+
51
+ export type UltimateSearchError =
52
+ | ConfigValidationError
53
+ | ProviderContentError
54
+ | ProviderRequestError
55
+ | ProviderResponseError
56
+ | ProviderDecodeError;
@@ -0,0 +1,210 @@
1
+ import { Config, Effect, Layer, Logger, Option, Schema, ServiceMap, Console } from "effect";
2
+ import { Flag } from "effect/unstable/cli";
3
+ import {
4
+ ConfigValidationError,
5
+ ProviderContentError,
6
+ ProviderDecodeError,
7
+ ProviderRequestError,
8
+ ProviderResponseError,
9
+ } from "./errors";
10
+ import { renderStructuredError } from "./render-error";
11
+
12
+ const stringify = (value: unknown) => JSON.stringify(value, null, 2);
13
+ const maxHumanErrorBodyLength = 500;
14
+
15
+ export const OutputModeSchema = Schema.Literals(["human", "llm"] as const);
16
+ export type OutputMode = typeof OutputModeSchema.Type;
17
+
18
+ export const outputFlag = Flag.optional(
19
+ Flag.choice("output", OutputModeSchema.literals).pipe(
20
+ Flag.withDescription("Output mode: human for readable text, llm for structured JSON."),
21
+ ),
22
+ );
23
+
24
+ const trimTrailingWhitespace = (text: string) => text.trimEnd();
25
+
26
+ const truncate = (text: string, maxLength: number) =>
27
+ text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}...`;
28
+
29
+ const configErrorDetails = (error: ConfigValidationError): Array<string> => {
30
+ const details = Array.isArray(error.details)
31
+ ? error.details.filter((detail) => detail.length > 0)
32
+ : [];
33
+
34
+ if (details.length > 0) {
35
+ return details;
36
+ }
37
+
38
+ if (error.cause instanceof Error && error.cause.message.length > 0) {
39
+ return [error.cause.message];
40
+ }
41
+
42
+ if (typeof error.cause === "string" && error.cause.length > 0) {
43
+ return [error.cause];
44
+ }
45
+
46
+ return [];
47
+ };
48
+
49
+ export const writeJsonStdout = (value: unknown) => Console.log(stringify(value));
50
+ export const renderJsonText = (value: unknown) => stringify(value);
51
+
52
+ const defaultOutputModeConfig = Config.string("AGENT").pipe(
53
+ Config.withDefault(""),
54
+ Config.map((agent): OutputMode => (agent.trim().length > 0 ? "llm" : "human")),
55
+ );
56
+
57
+ export const resolveOutputMode = (
58
+ selected: Option.Option<OutputMode>,
59
+ fallback: OutputMode,
60
+ ): OutputMode => Option.getOrElse(selected, () => fallback);
61
+
62
+ const makeCliOutput = (defaultMode: OutputMode) =>
63
+ CliOutput.of({
64
+ defaultMode,
65
+ writeOutput: ({ human, llm }, mode = defaultMode) =>
66
+ mode === "llm" ? writeJsonStdout(llm) : Console.log(human),
67
+ logError: (error, mode = defaultMode) => logCliError(error, mode),
68
+ });
69
+
70
+ const renderHumanError = (error: unknown) => {
71
+ if (error instanceof ConfigValidationError) {
72
+ const details = configErrorDetails(error);
73
+ const lines = [`Configuration error (${error.provider})`, error.message];
74
+
75
+ if (details.length > 0) {
76
+ lines.push("", "What to fix:");
77
+ lines.push(...details.map((detail) => `- ${detail}`));
78
+ }
79
+
80
+ return trimTrailingWhitespace(lines.join("\n"));
81
+ }
82
+
83
+ if (error instanceof ProviderRequestError) {
84
+ return trimTrailingWhitespace([`Request failed (${error.provider})`, error.message].join("\n"));
85
+ }
86
+
87
+ if (error instanceof ProviderContentError) {
88
+ return trimTrailingWhitespace(
89
+ [`Provider returned no usable content (${error.provider})`, error.message].join("\n"),
90
+ );
91
+ }
92
+
93
+ if (error instanceof ProviderResponseError) {
94
+ const lines = [
95
+ `Provider response error (${error.provider})`,
96
+ error.message,
97
+ `HTTP status: ${error.status}`,
98
+ ];
99
+ const body = error.body.trim();
100
+
101
+ if (body.length > 0) {
102
+ lines.push(`Response body: ${truncate(body, maxHumanErrorBodyLength)}`);
103
+ }
104
+
105
+ return trimTrailingWhitespace(lines.join("\n"));
106
+ }
107
+
108
+ if (error instanceof ProviderDecodeError) {
109
+ return trimTrailingWhitespace(
110
+ [`Provider decode error (${error.provider})`, error.message].join("\n"),
111
+ );
112
+ }
113
+
114
+ if (error instanceof Error) {
115
+ return trimTrailingWhitespace([error.name, error.message].join("\n"));
116
+ }
117
+
118
+ return `Unknown error\n${String(error)}`;
119
+ };
120
+
121
+ const cliErrorLogMessageTag = "UltimateSearchCliError";
122
+
123
+ interface CliErrorLogMessage {
124
+ readonly _tag: typeof cliErrorLogMessageTag;
125
+ readonly mode: OutputMode;
126
+ readonly error: unknown;
127
+ }
128
+
129
+ const isCliErrorLogMessage = (value: unknown): value is CliErrorLogMessage =>
130
+ typeof value === "object" &&
131
+ value !== null &&
132
+ "_tag" in value &&
133
+ value._tag === cliErrorLogMessageTag &&
134
+ "mode" in value &&
135
+ (value.mode === "human" || value.mode === "llm") &&
136
+ "error" in value;
137
+
138
+ const normalizeLogMessage = (message: unknown): unknown =>
139
+ Array.isArray(message) && message.length === 1 ? message[0] : message;
140
+
141
+ const renderGenericLogMessage = (message: unknown): string => {
142
+ const normalized = normalizeLogMessage(message);
143
+
144
+ if (Array.isArray(normalized)) {
145
+ return normalized.map((item) => renderGenericLogMessage(item)).join(" ");
146
+ }
147
+
148
+ if (typeof normalized === "string") {
149
+ return normalized;
150
+ }
151
+
152
+ if (normalized instanceof Error) {
153
+ return trimTrailingWhitespace([normalized.name, normalized.message].join("\n"));
154
+ }
155
+
156
+ try {
157
+ return stringify(normalized);
158
+ } catch {
159
+ return String(normalized);
160
+ }
161
+ };
162
+
163
+ const formatCliLogMessage = (message: unknown): string => {
164
+ const normalized = normalizeLogMessage(message);
165
+
166
+ if (isCliErrorLogMessage(normalized)) {
167
+ return normalized.mode === "llm"
168
+ ? stringify({ error: renderStructuredError(normalized.error) })
169
+ : renderHumanError(normalized.error);
170
+ }
171
+
172
+ return renderGenericLogMessage(normalized);
173
+ };
174
+
175
+ export const logCliError = (error: unknown, mode: OutputMode) =>
176
+ Effect.logError({
177
+ _tag: cliErrorLogMessageTag,
178
+ mode,
179
+ error,
180
+ } satisfies CliErrorLogMessage);
181
+
182
+ const cliConsoleLogger = Logger.withConsoleError(
183
+ Logger.make((options) => formatCliLogMessage(options.message)),
184
+ );
185
+
186
+ export const cliLoggerLayer = Logger.layer([cliConsoleLogger, Logger.tracerLogger]);
187
+
188
+ export class CliOutput extends ServiceMap.Service<
189
+ CliOutput,
190
+ {
191
+ readonly defaultMode: OutputMode;
192
+ readonly writeOutput: (
193
+ output: {
194
+ readonly human: string;
195
+ readonly llm: unknown;
196
+ },
197
+ mode?: OutputMode,
198
+ ) => Effect.Effect<void>;
199
+ readonly logError: (error: unknown, mode?: OutputMode) => Effect.Effect<void>;
200
+ }
201
+ >()("CliOutput") {
202
+ static readonly layer = Layer.effect(
203
+ CliOutput,
204
+ Effect.map(defaultOutputModeConfig.asEffect(), makeCliOutput),
205
+ );
206
+
207
+ static layerForMode(mode: OutputMode) {
208
+ return Layer.succeed(CliOutput, makeCliOutput(mode));
209
+ }
210
+ }
@@ -0,0 +1,73 @@
1
+ import { Effect } from "effect";
2
+ import { HttpClient, HttpClientError, type HttpClientResponse } from "effect/unstable/http";
3
+ import { ProviderRequestError, ProviderResponseError } from "./errors";
4
+
5
+ export type ProviderHttpClientName = "shared" | "grok" | "tavily" | "firecrawl";
6
+
7
+ export const mapProviderRequestError = (
8
+ provider: ProviderHttpClientName,
9
+ error: unknown,
10
+ fallback: string,
11
+ ) =>
12
+ new ProviderRequestError({
13
+ provider,
14
+ message: error instanceof Error && error.message.length > 0 ? error.message : fallback,
15
+ });
16
+
17
+ export const makeProviderHttpClient = (
18
+ client: HttpClient.HttpClient,
19
+ ): HttpClient.HttpClient.With<HttpClientError.HttpClientError, never> =>
20
+ client.pipe(
21
+ HttpClient.filterStatusOk,
22
+ HttpClient.retryTransient({
23
+ retryOn: "errors-and-responses",
24
+ times: 2,
25
+ }),
26
+ ) as HttpClient.HttpClient.With<HttpClientError.HttpClientError, never>;
27
+
28
+ export const catchProviderHttpError =
29
+ (
30
+ provider: ProviderHttpClientName,
31
+ requestErrorMessage: string,
32
+ responseErrorMessage: (status: number) => string,
33
+ ) =>
34
+ <A>(
35
+ effect: Effect.Effect<A, HttpClientError.HttpClientError, never>,
36
+ ): Effect.Effect<A, ProviderRequestError | ProviderResponseError, never> =>
37
+ effect.pipe(
38
+ Effect.catchTag(
39
+ "HttpClientError",
40
+ (error): Effect.Effect<never, ProviderRequestError | ProviderResponseError, never> => {
41
+ const response = error.response;
42
+
43
+ if (response === undefined) {
44
+ return Effect.fail(mapProviderRequestError(provider, error, requestErrorMessage));
45
+ }
46
+
47
+ return response.text.pipe(
48
+ Effect.catch(() => Effect.succeed("")),
49
+ Effect.flatMap((body) =>
50
+ Effect.fail(
51
+ new ProviderResponseError({
52
+ provider,
53
+ message: responseErrorMessage(response.status),
54
+ status: response.status,
55
+ body,
56
+ cause: error,
57
+ }),
58
+ ),
59
+ ),
60
+ );
61
+ },
62
+ ),
63
+ );
64
+
65
+ export const decodeJsonResponse = <A, E1, E2>(
66
+ response: HttpClientResponse.HttpClientResponse,
67
+ decode: (value: unknown) => Effect.Effect<A, E1, never>,
68
+ mapDecodeError: (error: unknown) => E2,
69
+ ): Effect.Effect<A, E2, never> =>
70
+ response.json.pipe(
71
+ Effect.mapError(mapDecodeError),
72
+ Effect.flatMap((json) => decode(json).pipe(Effect.mapError(mapDecodeError))),
73
+ );
@@ -0,0 +1,101 @@
1
+ import { Config } from "effect";
2
+ import { Schema } from "effect";
3
+ import {
4
+ ConfigValidationError,
5
+ ProviderDecodeError,
6
+ ProviderRequestError,
7
+ ProviderResponseError,
8
+ } from "./errors";
9
+
10
+ export interface RenderedError {
11
+ readonly type: string;
12
+ readonly provider?: string;
13
+ readonly message: string;
14
+ readonly details?: ReadonlyArray<string>;
15
+ readonly status?: number;
16
+ readonly body?: string;
17
+ }
18
+
19
+ export const RenderedErrorSchema = Schema.Struct({
20
+ type: Schema.String,
21
+ provider: Schema.optional(Schema.String),
22
+ message: Schema.String,
23
+ details: Schema.optional(Schema.Array(Schema.String)),
24
+ status: Schema.optional(Schema.Number),
25
+ body: Schema.optional(Schema.String),
26
+ });
27
+
28
+ const configErrorDetails = (error: ConfigValidationError): Array<string> => {
29
+ const details = Array.isArray(error.details)
30
+ ? error.details.filter((detail) => detail.length > 0)
31
+ : [];
32
+
33
+ if (details.length > 0) {
34
+ return details;
35
+ }
36
+
37
+ if (error.cause instanceof Config.ConfigError && error.cause.message.length > 0) {
38
+ return [error.cause.message];
39
+ }
40
+
41
+ if (error.cause instanceof Error && error.cause.message.length > 0) {
42
+ return [error.cause.message];
43
+ }
44
+
45
+ if (typeof error.cause === "string" && error.cause.length > 0) {
46
+ return [error.cause];
47
+ }
48
+
49
+ return [];
50
+ };
51
+
52
+ export const renderStructuredError = (error: unknown): RenderedError => {
53
+ if (error instanceof ConfigValidationError) {
54
+ const details = configErrorDetails(error);
55
+
56
+ return {
57
+ type: error._tag,
58
+ provider: error.provider,
59
+ message: error.message,
60
+ ...(details.length > 0 ? { details } : {}),
61
+ };
62
+ }
63
+
64
+ if (error instanceof ProviderRequestError) {
65
+ return {
66
+ type: error._tag,
67
+ provider: error.provider,
68
+ message: error.message,
69
+ };
70
+ }
71
+
72
+ if (error instanceof ProviderResponseError) {
73
+ return {
74
+ type: error._tag,
75
+ provider: error.provider,
76
+ message: error.message,
77
+ status: error.status,
78
+ body: error.body,
79
+ };
80
+ }
81
+
82
+ if (error instanceof ProviderDecodeError) {
83
+ return {
84
+ type: error._tag,
85
+ provider: error.provider,
86
+ message: error.message,
87
+ };
88
+ }
89
+
90
+ if (error instanceof Error) {
91
+ return {
92
+ type: error.name,
93
+ message: error.message,
94
+ };
95
+ }
96
+
97
+ return {
98
+ type: "UnknownError",
99
+ message: String(error),
100
+ };
101
+ };
@@ -0,0 +1,42 @@
1
+ import { Effect, Option, Schema, SchemaIssue, SchemaTransformation } from "effect";
2
+
3
+ const stripTrailingSlashes = (value: string) => value.replace(/\/+$/u, "");
4
+
5
+ export const trimmedNonEmptyStringSchema = (message: string) =>
6
+ Schema.Trim.pipe(Schema.decodeTo(Schema.NonEmptyString), Schema.annotate({ message }));
7
+
8
+ export const optionalTrimmedNonEmptyStringFromStringSchema = Schema.Trim.pipe(
9
+ Schema.decodeTo(
10
+ Schema.Option(Schema.NonEmptyString),
11
+ SchemaTransformation.transform({
12
+ decode: (value) => (value.length === 0 ? Option.none<string>() : Option.some(value)),
13
+ encode: (value) => Option.getOrElse(value, () => ""),
14
+ }),
15
+ ),
16
+ );
17
+
18
+ export const absoluteUrlStringSchema = (message: string) =>
19
+ Schema.Trim.pipe(
20
+ Schema.decodeTo(
21
+ Schema.String,
22
+ SchemaTransformation.transformOrFail({
23
+ decode: (value) =>
24
+ Effect.try({
25
+ try: () => stripTrailingSlashes(new URL(value).toString()),
26
+ catch: () => new SchemaIssue.InvalidValue(Option.some(value), { message }),
27
+ }),
28
+ encode: (value) => Effect.succeed(value),
29
+ }),
30
+ ),
31
+ );
32
+
33
+ export const optionalAbsoluteUrlStringFromStringSchema = (message: string) =>
34
+ Schema.Trim.pipe(
35
+ Schema.decodeTo(
36
+ Schema.Option(absoluteUrlStringSchema(message)),
37
+ SchemaTransformation.transform({
38
+ decode: (value) => (value.length === 0 ? Option.none<string>() : Option.some(value)),
39
+ encode: (value) => Option.getOrElse(value, () => ""),
40
+ }),
41
+ ),
42
+ );
@@ -0,0 +1,53 @@
1
+ import { Cause, Duration, Effect, Fiber, Layer, Logger, LogLevel, Tracer } from "effect";
2
+ import { CurrentLoggers } from "effect/Logger";
3
+ import { MinimumLogLevel } from "effect/References";
4
+
5
+ export const TracingLayer = Layer.unwrap(
6
+ Effect.gen(function* () {
7
+ const logLevel = yield* MinimumLogLevel;
8
+
9
+ if (LogLevel.isLessThan("Trace", logLevel)) {
10
+ return Layer.empty;
11
+ }
12
+
13
+ return tracerLayer;
14
+ }),
15
+ );
16
+
17
+ const tracerLayer = Effect.gen(function* () {
18
+ const loggers = yield* CurrentLoggers;
19
+ const tracer = yield* Tracer.Tracer;
20
+ const fiber = Fiber.getCurrent()!;
21
+
22
+ const log = (message: string, time: bigint) => {
23
+ const date = new Date(Number(time / BigInt(1e6)));
24
+ const options: Logger.Options<string> = {
25
+ message,
26
+ fiber,
27
+ date,
28
+ logLevel: "Trace",
29
+ cause: Cause.empty,
30
+ };
31
+
32
+ loggers.forEach((logger) => {
33
+ logger.log(options);
34
+ });
35
+ };
36
+
37
+ return Tracer.make({
38
+ span(options) {
39
+ const span = tracer.span(options);
40
+ log(`${options.name}: started`, options.startTime);
41
+ const originalEnd = span.end;
42
+
43
+ span.end = (endTime, cause) => {
44
+ const duration = Duration.nanos(endTime - span.status.startTime);
45
+ log(`${options.name}: completed. Took ${Duration.format(duration)}`, endTime);
46
+
47
+ return originalEnd.call(span, endTime, cause);
48
+ };
49
+
50
+ return span;
51
+ },
52
+ });
53
+ }).pipe(Layer.effect(Tracer.Tracer));