deepseek-web-retrieval 0.3.0 → 0.5.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +96 -230
  2. package/README.md +53 -25
  3. package/dist/cli.js +1358 -617
  4. package/docs/agents/codex.md +32 -11
  5. package/docs/agents/opencode-v2.md +5 -5
  6. package/docs/agents/pi.md +92 -90
  7. package/docs/design/installer-design.md +72 -9
  8. package/docs/hosts.jsonc +2 -2
  9. package/docs/integration.md +14 -9
  10. package/docs/research/control-matrix.md +48 -13
  11. package/docs/research/mcp-compatibility.md +9 -4
  12. package/docs/research/search-api-defaults.md +309 -0
  13. package/examples/pi.settings.jsonc +17 -0
  14. package/index.ts +2 -2
  15. package/package.json +35 -4
  16. package/src/core/auth.ts +4 -6
  17. package/src/core/client.ts +65 -53
  18. package/src/core/constants.ts +30 -24
  19. package/src/core/descriptions.ts +30 -6
  20. package/src/core/domains.ts +56 -67
  21. package/src/core/env.ts +112 -0
  22. package/src/core/format.ts +53 -50
  23. package/src/core/index.ts +9 -9
  24. package/src/core/jsonc.ts +6 -6
  25. package/src/core/object.ts +4 -0
  26. package/src/core/options.ts +21 -26
  27. package/src/core/search.ts +45 -44
  28. package/src/core/transport.ts +109 -113
  29. package/src/core/types.ts +47 -47
  30. package/src/core/url.ts +20 -20
  31. package/src/installer/apply.ts +256 -81
  32. package/src/installer/cli.ts +135 -104
  33. package/src/installer/detect.ts +46 -31
  34. package/src/installer/formats/index.ts +26 -28
  35. package/src/installer/formats/json.ts +81 -77
  36. package/src/installer/formats/rules.ts +55 -28
  37. package/src/installer/formats/toml.ts +88 -81
  38. package/src/installer/formats/value.ts +34 -30
  39. package/src/installer/hosts/codex.ts +100 -54
  40. package/src/installer/hosts/index.ts +126 -41
  41. package/src/installer/hosts/opencode.ts +70 -51
  42. package/src/installer/hosts/pi.ts +269 -75
  43. package/src/installer/hosts/shared.ts +19 -14
  44. package/src/installer/operation-lock.ts +54 -0
  45. package/src/installer/ownership.ts +129 -0
  46. package/src/installer/paths.ts +7 -5
  47. package/src/installer/persistence.ts +66 -0
  48. package/src/installer/render.ts +29 -29
  49. package/src/installer/revert.ts +33 -29
  50. package/src/installer/rulesContent.ts +24 -13
  51. package/src/installer/state.ts +116 -31
  52. package/src/installer/types.ts +74 -56
  53. package/src/installer/uninstall.ts +40 -17
  54. package/src/mcp/auth.ts +7 -8
  55. package/src/mcp/cli.ts +38 -20
  56. package/src/mcp/client.ts +35 -0
  57. package/src/mcp/server.ts +68 -97
  58. package/src/opencode/auth.ts +24 -26
  59. package/src/opencode/plugin.ts +99 -87
  60. package/src/pi/extension.ts +164 -0
  61. package/src/pi/settings.ts +49 -0
  62. package/src/mcp/config.ts +0 -124
@@ -1,9 +1,8 @@
1
- import { FETCH_ENDPOINT, MAX_QUERY_CHARS, SEARCH_ENDPOINT } from "./constants"
2
- import { assertFetchHostPolicy } from "./domains"
3
- import { formatFetchedContent } from "./format"
4
- import { buildQuery, processResults } from "./search"
5
- import { postJson } from "./transport"
6
- import { parseFetchUrl } from "./url"
1
+ import { FETCH_ENDPOINT, MAX_QUERY_CHARS, SEARCH_ENDPOINT } from "./constants";
2
+ import { assertFetchHostPolicy } from "./domains";
3
+ import { formatFetchedContent } from "./format";
4
+ import { buildQuery, processResults } from "./search";
5
+ import { postJson } from "./transport";
7
6
  import type {
8
7
  DeepSeekSearchResult,
9
8
  FetchedDocument,
@@ -13,30 +12,33 @@ import type {
13
12
  RetryOptions,
14
13
  SearchOptions,
15
14
  SearchResult,
16
- } from "./types"
15
+ } from "./types";
16
+ import { parseFetchUrl } from "./url";
17
17
 
18
- export { DeepSeekApiError } from "./transport"
18
+ export { DeepSeekApiError } from "./transport";
19
19
 
20
20
  /** The shorter of the configured and per-call timeout always wins. */
21
21
  function effectiveTimeout(configuredMs: number, perCallMs: number | undefined): number {
22
- return perCallMs === undefined ? configuredMs : Math.min(configuredMs, perCallMs)
22
+ return perCallMs === undefined ? configuredMs : Math.min(configuredMs, perCallMs);
23
23
  }
24
24
 
25
25
  interface SearchResponse {
26
- readonly results?: unknown
26
+ readonly results?: unknown;
27
27
  }
28
28
 
29
29
  interface FetchResponse {
30
- readonly title?: unknown
31
- readonly content?: unknown
30
+ readonly title?: unknown;
31
+ readonly content?: unknown;
32
32
  }
33
33
 
34
34
  export interface RequestOptions {
35
- readonly signal: AbortSignal
36
- readonly auth: RequestAuth
37
- readonly retry: RetryOptions
35
+ /** Optional per-request HTTP dependency; factories bind this once for an adapter. */
36
+ readonly fetchImpl?: typeof fetch;
37
+ readonly signal: AbortSignal;
38
+ readonly auth: RequestAuth;
39
+ readonly retry: RetryOptions;
38
40
  /** Per-call cap in milliseconds; the shorter of this and the configured timeout wins. */
39
- readonly timeoutMs?: number
41
+ readonly timeoutMs?: number;
40
42
  }
41
43
 
42
44
  export async function searchWeb(
@@ -44,23 +46,23 @@ export async function searchWeb(
44
46
  options: SearchOptions,
45
47
  request: RequestOptions,
46
48
  ): Promise<SearchResult[]> {
47
- const trimmed = query.trim()
48
- if (!trimmed) throw new Error("DeepSeek search query must not be empty")
49
+ const trimmed = query.trim();
50
+ if (!trimmed) throw new Error("DeepSeek search query must not be empty");
49
51
  if (Array.from(trimmed).length > MAX_QUERY_CHARS) {
50
- throw new Error(`DeepSeek search query must be ${MAX_QUERY_CHARS} characters or fewer`)
52
+ throw new Error(`DeepSeek search query must be ${MAX_QUERY_CHARS} characters or fewer`);
51
53
  }
52
54
 
53
- const builtQuery = buildQuery(trimmed, options)
54
- const builtQueryLength = Array.from(builtQuery).length
55
+ const builtQuery = buildQuery(trimmed, options);
56
+ const builtQueryLength = Array.from(builtQuery).length;
55
57
  if (builtQueryLength > MAX_QUERY_CHARS) {
56
58
  throw new Error(
57
59
  `DeepSeek search query must be ${MAX_QUERY_CHARS} characters or fewer after applying ` +
58
60
  `blockedDomains, includeDomains, and language ` +
59
61
  `(built query is ${builtQueryLength} characters)`,
60
- )
62
+ );
61
63
  }
62
64
 
63
- const timeout = effectiveTimeout(options.timeoutMs, request.timeoutMs)
65
+ const timeout = effectiveTimeout(options.timeoutMs, request.timeoutMs);
64
66
  const payload = await postJson<SearchResponse>(
65
67
  SEARCH_ENDPOINT,
66
68
  {
@@ -69,43 +71,38 @@ export async function searchWeb(
69
71
  need_content: false,
70
72
  },
71
73
  "DeepSeek Search",
72
- { signal: request.signal, auth: request.auth, retry: request.retry, timeoutMs: timeout },
73
- )
74
+ { ...request, timeoutMs: timeout },
75
+ );
74
76
 
75
77
  if (!Array.isArray(payload.results)) {
76
- throw new Error("DeepSeek Search returned a response without a results array")
78
+ throw new Error("DeepSeek Search returned a response without a results array");
77
79
  }
78
- return processResults(payload.results as DeepSeekSearchResult[], options)
80
+ return processResults(payload.results as DeepSeekSearchResult[], options);
79
81
  }
80
82
 
81
- export async function fetchPage(
82
- url: URL,
83
- options: FetchOptions,
84
- request: RequestOptions,
85
- ): Promise<FetchedPage> {
86
- // Enforced here so direct core consumers cannot bypass the policy. Adapters
87
- // may also pre-check to reject before a permission prompt.
88
- assertFetchHostPolicy(url, options)
89
- const timeout = effectiveTimeout(options.timeoutMs, request.timeoutMs)
90
- const payload = await postJson<FetchResponse>(
91
- FETCH_ENDPOINT,
92
- { url: url.href },
93
- "DeepSeek Fetch",
94
- { signal: request.signal, auth: request.auth, retry: request.retry, timeoutMs: timeout },
95
- )
96
- const title = typeof payload.title === "string" ? payload.title.trim() : ""
97
- const content = typeof payload.content === "string" ? payload.content : ""
83
+ export async function fetchPage(url: URL, options: FetchOptions, request: RequestOptions): Promise<FetchedPage> {
84
+ // A URL object is parsed, not necessarily safe: direct core callers must pass
85
+ // the same protocol, credentials, length and host checks as string callers.
86
+ const target = parseFetchUrl(url.href);
87
+ assertFetchHostPolicy(target, options);
88
+ const timeout = effectiveTimeout(options.timeoutMs, request.timeoutMs);
89
+ const payload = await postJson<FetchResponse>(FETCH_ENDPOINT, { url: target.href }, "DeepSeek Fetch", {
90
+ ...request,
91
+ timeoutMs: timeout,
92
+ });
93
+ const title = typeof payload.title === "string" ? payload.title.trim() : "";
94
+ const content = typeof payload.content === "string" ? payload.content : "";
98
95
  if (!content.trim()) {
99
- throw new Error("DeepSeek Fetch returned no page content (the page may be empty or unavailable)")
96
+ throw new Error("DeepSeek Fetch returned no page content (the page may be empty or unavailable)");
100
97
  }
101
- return { url: url.href, title, content }
98
+ return { url: target.href, title, content };
102
99
  }
103
100
 
104
101
  /** Parses the URL and enforces the host policy in one call, returning the fetch target. */
105
102
  export function inspectFetchTarget(url: string, options: FetchOptions): URL {
106
- const target = parseFetchUrl(url)
107
- assertFetchHostPolicy(target, options)
108
- return target
103
+ const target = parseFetchUrl(url);
104
+ assertFetchHostPolicy(target, options);
105
+ return target;
109
106
  }
110
107
 
111
108
  /**
@@ -117,13 +114,28 @@ export async function fetchDocument(
117
114
  options: FetchOptions,
118
115
  request: RequestOptions,
119
116
  ): Promise<FetchedDocument> {
120
- if (typeof url !== "string" && !(url instanceof URL)) throw new Error('"url" must be a string or URL')
121
- const page = await fetchPage(url instanceof URL ? url : parseFetchUrl(url), options, request)
122
- const formatted = formatFetchedContent(page.title, page.content, options.maxChars)
117
+ if (typeof url !== "string" && !(url instanceof URL)) throw new Error('"url" must be a string or URL');
118
+ const page = await fetchPage(url instanceof URL ? url : parseFetchUrl(url), options, request);
119
+ const formatted = formatFetchedContent(page.title, page.content, options.maxChars);
123
120
  return {
124
121
  url: page.url,
125
122
  title: page.title,
126
123
  content: formatted.content,
127
124
  truncated: formatted.truncated,
128
- }
125
+ };
129
126
  }
127
+
128
+ /** One client per adapter/test; transport overrides never mutate process globals. */
129
+ export function createRetrievalClient(dependencies: { readonly fetchImpl?: typeof fetch } = {}) {
130
+ const bind = (request: RequestOptions): RequestOptions => ({ ...request, ...dependencies });
131
+ return {
132
+ searchWeb: (query: string, options: SearchOptions, request: RequestOptions) =>
133
+ searchWeb(query, options, bind(request)),
134
+ fetchPage: (url: URL, options: FetchOptions, request: RequestOptions) => fetchPage(url, options, bind(request)),
135
+ fetchDocument: (url: string | URL, options: FetchOptions, request: RequestOptions) =>
136
+ fetchDocument(url, options, bind(request)),
137
+ };
138
+ }
139
+
140
+ export type RetrievalClient = ReturnType<typeof createRetrievalClient>;
141
+ export const retrievalClient = createRetrievalClient();
@@ -1,30 +1,36 @@
1
- export const SEARCH_ENDPOINT = "https://api.deepseek.com/v1/tools/search"
2
- export const FETCH_ENDPOINT = "https://api.deepseek.com/v1/tools/fetch"
1
+ export const SEARCH_ENDPOINT = "https://api.deepseek.com/v1/tools/search";
2
+ export const FETCH_ENDPOINT = "https://api.deepseek.com/v1/tools/fetch";
3
3
 
4
- export const DEFAULT_SEARCH_COUNT = 10
5
- export const DEFAULT_MAX_RESULTS = 5
6
- export const DEFAULT_MIN_RERANK = 1.5
7
- export const DEFAULT_MIN_AUTHORITY = 2
8
- export const DEFAULT_MAX_SUMMARY_CHARS = 1200
4
+ /**
5
+ * Search defaults are benchmarked against surveyed AI-native search APIs and
6
+ * DeepSeek's own factory defaults; see docs/research/search-api-defaults.md.
7
+ * Result caps cluster at 10 across vendors, and no surveyed vendor filters
8
+ * results client-side by score or authority — thresholds stay opt-in.
9
+ */
10
+ export const DEFAULT_SEARCH_COUNT = 10;
11
+ export const DEFAULT_MAX_RESULTS = 10;
12
+ export const DEFAULT_MIN_RERANK = 0;
13
+ export const DEFAULT_MIN_AUTHORITY = 0;
14
+ export const DEFAULT_MAX_SUMMARY_CHARS = 1200;
9
15
 
10
- export const DEFAULT_FETCH_MAX_CHARS = 16000
11
- export const DEFAULT_TIMEOUT_MS = 30000
12
- export const MIN_TIMEOUT_MS = 1000
13
- export const MAX_TIMEOUT_MS = 120000
16
+ export const DEFAULT_FETCH_MAX_CHARS = 16000;
17
+ export const DEFAULT_TIMEOUT_MS = 30000;
18
+ export const MIN_TIMEOUT_MS = 1000;
19
+ export const MAX_TIMEOUT_MS = 120000;
14
20
 
15
- export const MAX_QUERY_CHARS = 128
16
- export const MAX_URL_CHARS = 8192
17
- export const MAX_ERROR_BODY_CHARS = 2000
18
- export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
19
- export const FETCH_TRUNCATION_MARKER = "\n\n[content truncated]"
21
+ export const MAX_QUERY_CHARS = 128;
22
+ export const MAX_URL_CHARS = 8192;
23
+ export const MAX_ERROR_BODY_CHARS = 2000;
24
+ export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
25
+ export const FETCH_TRUNCATION_MARKER = "\n\n[content truncated]";
20
26
 
21
- export const DEFAULT_API_KEY_SOURCE = "auto"
22
- export const DEFAULT_MAX_RETRIES = 0
23
- export const MAX_RETRIES = 5
24
- export const DEFAULT_RETRY_BASE_DELAY_MS = 250
25
- export const MAX_RETRY_DELAY_MS = 10000
27
+ export const DEFAULT_API_KEY_SOURCE = "auto";
28
+ export const DEFAULT_MAX_RETRIES = 0;
29
+ export const MAX_RETRIES = 5;
30
+ export const DEFAULT_RETRY_BASE_DELAY_MS = 250;
31
+ export const MAX_RETRY_DELAY_MS = 10000;
26
32
 
27
33
  /** The single source for every DEEPSEEK_* environment variable name. */
28
- export const API_KEY_ENV = "DEEPSEEK_API_KEY"
29
- export const ENABLE_SEARCH_ENV = "DEEPSEEK_ENABLE_SEARCH"
30
- export const ENABLE_FETCH_ENV = "DEEPSEEK_ENABLE_FETCH"
34
+ export const API_KEY_ENV = "DEEPSEEK_API_KEY";
35
+ export const ENABLE_SEARCH_ENV = "DEEPSEEK_ENABLE_SEARCH";
36
+ export const ENABLE_FETCH_ENV = "DEEPSEEK_ENABLE_FETCH";
@@ -4,16 +4,40 @@
4
4
  */
5
5
 
6
6
  export const FETCH_SERVICE_LIMITS =
7
- "DeepSeek does not render JavaScript and cannot reach private networks; PDFs, images, and other binary files are unsupported."
7
+ "DeepSeek does not render JavaScript and cannot reach private networks; PDFs, images, and other binary files are unsupported.";
8
8
 
9
- export const FETCH_OUTPUT_TRUNCATED = "Output is truncated to the configured size."
9
+ export const FETCH_OUTPUT_TRUNCATED = "Output is truncated to the configured size.";
10
10
 
11
- export const FETCH_MARKDOWN_ONLY = 'Only format "markdown" is supported.'
11
+ export const FETCH_MARKDOWN_ONLY = 'Only format "markdown" is supported.';
12
12
 
13
13
  export const SEARCH_RANKED_FILTERED =
14
- "Results are ranked by relevance and source authority, deduplicated by URL, and capped at the configured maximum."
14
+ "Results are ranked by relevance and source authority, deduplicated by URL, and capped at the configured maximum.";
15
15
 
16
- export const SEARCH_EMPTY_IS_NORMAL = "An empty result list is normal, not an error."
16
+ export const SEARCH_EMPTY_IS_NORMAL = "An empty result list is normal, not an error.";
17
17
 
18
18
  export const SEARCH_QUERY_OPERATORS =
19
- "Use site:/-site: operators in the query to include or exclude domains; the full query must stay within 128 characters."
19
+ "Use site:/-site: operators in the query to include or exclude domains; the full query must stay within 128 characters.";
20
+
21
+ /**
22
+ * The full tool descriptions, composed from the fact fragments. Every host
23
+ * adapter registers its tools with these verbatim — the MCP server and the Pi
24
+ * extension must never drift on what a tool does.
25
+ */
26
+ export const SEARCH_TOOL_DESCRIPTION =
27
+ "Search the web with DeepSeek's in-house search engine and return a short, filtered result " +
28
+ "list (title, URL, summary). " +
29
+ `${SEARCH_RANKED_FILTERED} ` +
30
+ `${SEARCH_EMPTY_IS_NORMAL} ` +
31
+ `${SEARCH_QUERY_OPERATORS} ` +
32
+ "To read a full page, pass a result URL to the deepseek_fetch tool.";
33
+
34
+ export const FETCH_TOOL_DESCRIPTION =
35
+ "Fetch one web page as clean Markdown through DeepSeek's Fetch API. " +
36
+ `${FETCH_SERVICE_LIMITS} ` +
37
+ `${FETCH_OUTPUT_TRUNCATED}`;
38
+
39
+ /** Parameter descriptions shared by every adapter's input schema for the two tools. */
40
+ export const SEARCH_QUERY_PARAMETER =
41
+ "Search query, 128 characters or fewer including operators (site:, -site:, quoted phrases, lang:).";
42
+
43
+ export const FETCH_URL_PARAMETER = "Absolute http:// or https:// URL to fetch. Credentials are rejected.";
@@ -1,30 +1,25 @@
1
- import { isIP } from "node:net"
2
- import type { FetchOptions } from "./types"
1
+ import { isIP } from "node:net";
2
+ import type { FetchOptions } from "./types";
3
3
 
4
4
  export function normalizeDomain(value: string): string | undefined {
5
- const trimmed = value.trim()
6
- if (!trimmed || /\s/.test(trimmed)) return undefined
7
- const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
8
- if (hasScheme && !/^https?:\/\//i.test(trimmed)) return undefined
5
+ const trimmed = value.trim();
6
+ if (!trimmed || /\s/.test(trimmed)) return undefined;
7
+ const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed);
8
+ if (hasScheme && !/^https?:\/\//i.test(trimmed)) return undefined;
9
9
 
10
- let hostname: string
10
+ let hostname: string;
11
11
  try {
12
12
  // Parsing with a synthetic scheme also strips paths, ports, and userinfo.
13
- hostname = new URL(hasScheme ? trimmed : `http://${trimmed}`).hostname
13
+ hostname = new URL(hasScheme ? trimmed : `http://${trimmed}`).hostname;
14
14
  } catch {
15
- return undefined
15
+ return undefined;
16
16
  }
17
- return normalizeHostname(hostname)
17
+ return normalizeHostname(hostname);
18
18
  }
19
19
 
20
20
  /** Lowercases a URL hostname and removes brackets and trailing root-label dots. */
21
21
  export function normalizeHostname(value: string): string {
22
- return value
23
- .trim()
24
- .toLowerCase()
25
- .replace(/^\[/, "")
26
- .replace(/\]$/, "")
27
- .replace(/\.+$/, "")
22
+ return value.trim().toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.+$/, "");
28
23
  }
29
24
 
30
25
  /**
@@ -32,21 +27,21 @@ export function normalizeHostname(value: string): string {
32
27
  * but not `notexample.com` or `example.com.evil.test`.
33
28
  */
34
29
  export function domainMatches(hostname: string, domain: string): boolean {
35
- const host = normalizeHostname(hostname)
36
- const target = normalizeHostname(domain)
37
- if (!host || !target) return false
38
- return host === target || host.endsWith(`.${target}`)
30
+ const host = normalizeHostname(hostname);
31
+ const target = normalizeHostname(domain);
32
+ if (!host || !target) return false;
33
+ return host === target || host.endsWith(`.${target}`);
39
34
  }
40
35
 
41
36
  export function normalizeDomains(values: unknown): string[] {
42
- if (!Array.isArray(values)) return []
43
- const domains = new Set<string>()
37
+ if (!Array.isArray(values)) return [];
38
+ const domains = new Set<string>();
44
39
  for (const value of values) {
45
- if (typeof value !== "string") continue
46
- const domain = normalizeDomain(value)
47
- if (domain) domains.add(domain)
40
+ if (typeof value !== "string") continue;
41
+ const domain = normalizeDomain(value);
42
+ if (domain) domains.add(domain);
48
43
  }
49
- return [...domains]
44
+ return [...domains];
50
45
  }
51
46
 
52
47
  /**
@@ -55,9 +50,9 @@ export function normalizeDomains(values: unknown): string[] {
55
50
  * to a remote service and fails early instead of producing a confusing 404.
56
51
  */
57
52
  export function isPrivateHostname(value: string): boolean {
58
- const host = normalizeHostname(value)
59
- if (!host) return true
60
- if (host === "localhost" || host === "local") return true
53
+ const host = normalizeHostname(value);
54
+ if (!host) return true;
55
+ if (host === "localhost" || host === "local") return true;
61
56
  if (
62
57
  host.endsWith(".localhost") ||
63
58
  host.endsWith(".local") ||
@@ -65,48 +60,43 @@ export function isPrivateHostname(value: string): boolean {
65
60
  host.endsWith(".lan") ||
66
61
  host.endsWith(".home.arpa")
67
62
  ) {
68
- return true
63
+ return true;
69
64
  }
70
65
 
71
- const version = isIP(host)
72
- if (version === 4) return isPrivateIpv4(host)
73
- if (version === 6) return isPrivateIpv6(host)
74
- return false
66
+ const version = isIP(host);
67
+ if (version === 4) return isPrivateIpv4(host);
68
+ if (version === 6) return isPrivateIpv6(host);
69
+ return false;
75
70
  }
76
71
 
77
72
  export function assertFetchHostPolicy(
78
73
  url: URL,
79
74
  options: Pick<FetchOptions, "allowedDomains" | "blockedDomains" | "blockPrivateHosts">,
80
75
  ): void {
81
- const host = normalizeHostname(url.hostname)
76
+ const host = normalizeHostname(url.hostname);
82
77
 
83
78
  if (options.blockPrivateHosts && isPrivateHostname(host)) {
84
79
  throw new Error(
85
80
  `Fetch blocked for ${host}: local/private addresses are not reachable by DeepSeek. ` +
86
81
  "Set fetchBlockPrivateHosts=false only if you really need to send the URL.",
87
- )
82
+ );
88
83
  }
89
84
 
90
- const blocked = options.blockedDomains.find((domain) => domainMatches(host, domain))
85
+ const blocked = options.blockedDomains.find((domain) => domainMatches(host, domain));
91
86
  if (blocked) {
92
- throw new Error(`Fetch blocked for ${host}: matched fetchBlockedDomains entry "${blocked}"`)
87
+ throw new Error(`Fetch blocked for ${host}: matched fetchBlockedDomains entry "${blocked}"`);
93
88
  }
94
- if (
95
- options.allowedDomains.length > 0 &&
96
- !options.allowedDomains.some((domain) => domainMatches(host, domain))
97
- ) {
98
- throw new Error(
99
- `Fetch not allowed for ${host}: add it to fetchAllowedDomains (exact host or subdomain)`,
100
- )
89
+ if (options.allowedDomains.length > 0 && !options.allowedDomains.some((domain) => domainMatches(host, domain))) {
90
+ throw new Error(`Fetch not allowed for ${host}: add it to fetchAllowedDomains (exact host or subdomain)`);
101
91
  }
102
92
  }
103
93
 
104
94
  function isPrivateIpv4(host: string): boolean {
105
- const parts = host.split(".").map((part) => Number(part))
95
+ const parts = host.split(".").map((part) => Number(part));
106
96
  if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
107
- return true
97
+ return true;
108
98
  }
109
- const [a, b] = parts
99
+ const [a, b] = parts;
110
100
  return (
111
101
  a === 0 ||
112
102
  a === 10 ||
@@ -117,37 +107,36 @@ function isPrivateIpv4(host: string): boolean {
117
107
  (a === 192 && b === 168) ||
118
108
  (a === 198 && (b === 18 || b === 19)) ||
119
109
  a >= 224
120
- )
110
+ );
121
111
  }
122
112
 
123
113
  function isPrivateIpv6(host: string): boolean {
124
- const value = host.toLowerCase()
125
- if (value === "::" || value === "::1") return true
126
- if (value.startsWith("fc") || value.startsWith("fd")) return true
127
- if (/^fe[89ab]/.test(value)) return true
114
+ const value = host.toLowerCase();
115
+ if (value === "::" || value === "::1") return true;
116
+ if (value.startsWith("fc") || value.startsWith("fd")) return true;
117
+ if (/^fe[89ab]/.test(value)) return true;
128
118
 
129
119
  // IPv4-mapped IPv6, e.g. ::ffff:127.0.0.1. URL parsers normalize the dotted
130
120
  // form to hex (`::ffff:7f00:1`), so both representations must be handled.
131
- const dottedMapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1]
132
- if (dottedMapped) return isPrivateIpv4(dottedMapped)
121
+ const dottedMapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
122
+ if (dottedMapped) return isPrivateIpv4(dottedMapped);
133
123
 
134
- const mapped = ipv4FromIpv6Hextets(value, /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
135
- if (mapped) return isPrivateIpv4(mapped)
124
+ const mapped = ipv4FromIpv6Hextets(value, /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
125
+ if (mapped) return isPrivateIpv4(mapped);
136
126
 
137
127
  // IPv4-compatible IPv6, e.g. ::127.0.0.1, which URL parsers normalize to
138
128
  // ::7f00:1.
139
- const compatible = ipv4FromIpv6Hextets(value, /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
140
- if (compatible) return isPrivateIpv4(compatible)
129
+ const compatible = ipv4FromIpv6Hextets(value, /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
130
+ if (compatible) return isPrivateIpv4(compatible);
141
131
 
142
- return false
132
+ return false;
143
133
  }
144
134
 
145
135
  function ipv4FromIpv6Hextets(value: string, pattern: RegExp): string | undefined {
146
- const match = value.match(pattern)
147
- if (!match) return undefined
148
- const high = Number.parseInt(match[1], 16)
149
- const low = Number.parseInt(match[2], 16)
150
- if (!Number.isFinite(high) || !Number.isFinite(low)) return undefined
151
- return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`
136
+ const match = value.match(pattern);
137
+ if (!match) return undefined;
138
+ const high = Number.parseInt(match[1], 16);
139
+ const low = Number.parseInt(match[2], 16);
140
+ if (!Number.isFinite(high) || !Number.isFinite(low)) return undefined;
141
+ return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
152
142
  }
153
-
@@ -0,0 +1,112 @@
1
+ import { ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV } from "./constants";
2
+ import { resolvePluginOptions } from "./options";
3
+ import type { PluginOptions, ResolvedOptions } from "./types";
4
+
5
+ /** The environment an adapter reads its key and options from. */
6
+ export type Env = Readonly<Record<string, string | undefined>>;
7
+
8
+ /** Each supported option mapped onto its DEEPSEEK_* variable, grouped by value type. */
9
+ const NUMBER_ENV = {
10
+ count: "DEEPSEEK_SEARCH_COUNT",
11
+ maxResults: "DEEPSEEK_SEARCH_MAX_RESULTS",
12
+ minRerank: "DEEPSEEK_SEARCH_MIN_RERANK",
13
+ minAuthority: "DEEPSEEK_SEARCH_MIN_AUTHORITY",
14
+ maxSummaryChars: "DEEPSEEK_SEARCH_MAX_SUMMARY_CHARS",
15
+ searchTimeoutMs: "DEEPSEEK_SEARCH_TIMEOUT_MS",
16
+ fetchMaxChars: "DEEPSEEK_FETCH_MAX_CHARS",
17
+ fetchTimeoutMs: "DEEPSEEK_FETCH_TIMEOUT_MS",
18
+ maxRetries: "DEEPSEEK_MAX_RETRIES",
19
+ retryBaseDelayMs: "DEEPSEEK_RETRY_BASE_DELAY_MS",
20
+ } as const;
21
+
22
+ const TEXT_ENV = {
23
+ language: "DEEPSEEK_SEARCH_LANGUAGE",
24
+ } as const;
25
+
26
+ const DOMAINS_ENV = {
27
+ blockedDomains: "DEEPSEEK_SEARCH_BLOCKED_DOMAINS",
28
+ includeDomains: "DEEPSEEK_SEARCH_INCLUDE_DOMAINS",
29
+ fetchAllowedDomains: "DEEPSEEK_FETCH_ALLOWED_DOMAINS",
30
+ fetchBlockedDomains: "DEEPSEEK_FETCH_BLOCKED_DOMAINS",
31
+ } as const;
32
+
33
+ /** Shared defaults are true; only an explicit false value disables them. */
34
+ const FALSE_ONLY_ENV = {
35
+ allowUnknownAuthority: "DEEPSEEK_SEARCH_ALLOW_UNKNOWN_AUTHORITY",
36
+ fetchBlockPrivateHosts: "DEEPSEEK_FETCH_BLOCK_PRIVATE_HOSTS",
37
+ searchEnabled: ENABLE_SEARCH_ENV,
38
+ fetchEnabled: ENABLE_FETCH_ENV,
39
+ } as const;
40
+
41
+ /**
42
+ * Maps `DEEPSEEK_*` environment variables onto the shared plugin option keys and
43
+ * resolves them through the shared pipeline, so defaults and clamping stay
44
+ * identical across every adapter. Invalid values are dropped and the shared
45
+ * default applies.
46
+ */
47
+ export function resolveEnvOptions(env: Env = process.env): ResolvedOptions {
48
+ return resolvePluginOptions(pluginOptionsFromEnv(env));
49
+ }
50
+
51
+ export function pluginOptionsFromEnv(env: Env): PluginOptions {
52
+ const options: Record<string, unknown> = {};
53
+
54
+ putNumber(options, "count", env);
55
+ putNumber(options, "maxResults", env);
56
+ putNumber(options, "minRerank", env);
57
+ putNumber(options, "minAuthority", env);
58
+ putFalseOnly(options, "allowUnknownAuthority", env);
59
+ putDomains(options, "blockedDomains", env);
60
+ putDomains(options, "includeDomains", env);
61
+ putText(options, "language", env);
62
+ putNumber(options, "maxSummaryChars", env);
63
+ putNumber(options, "searchTimeoutMs", env);
64
+
65
+ putNumber(options, "fetchMaxChars", env);
66
+ putDomains(options, "fetchAllowedDomains", env);
67
+ putDomains(options, "fetchBlockedDomains", env);
68
+ putNumber(options, "fetchTimeoutMs", env);
69
+ putFalseOnly(options, "fetchBlockPrivateHosts", env);
70
+ putFalseOnly(options, "searchEnabled", env);
71
+ putFalseOnly(options, "fetchEnabled", env);
72
+
73
+ putNumber(options, "maxRetries", env);
74
+ putNumber(options, "retryBaseDelayMs", env);
75
+
76
+ return options;
77
+ }
78
+
79
+ function putNumber(target: Record<string, unknown>, key: keyof typeof NUMBER_ENV, env: Env): void {
80
+ const value = env[NUMBER_ENV[key]];
81
+ if (value === undefined) return;
82
+ const trimmed = value.trim();
83
+ if (!trimmed) return;
84
+ const parsed = Number(trimmed);
85
+ if (Number.isFinite(parsed)) target[key] = parsed;
86
+ }
87
+
88
+ function putText(target: Record<string, unknown>, key: keyof typeof TEXT_ENV, env: Env): void {
89
+ const value = env[TEXT_ENV[key]];
90
+ if (value === undefined) return;
91
+ const trimmed = value.trim();
92
+ if (trimmed) target[key] = trimmed;
93
+ }
94
+
95
+ function putFalseOnly(target: Record<string, unknown>, key: keyof typeof FALSE_ONLY_ENV, env: Env): void {
96
+ if (isFalseValue(env[FALSE_ONLY_ENV[key]])) target[key] = false;
97
+ }
98
+
99
+ function putDomains(target: Record<string, unknown>, key: keyof typeof DOMAINS_ENV, env: Env): void {
100
+ const value = env[DOMAINS_ENV[key]];
101
+ if (value === undefined || !value.trim()) return;
102
+ const domains = value
103
+ .split(",")
104
+ .map((domain) => domain.trim())
105
+ .filter((domain) => domain.length > 0);
106
+ if (domains.length > 0) target[key] = domains;
107
+ }
108
+
109
+ function isFalseValue(value: string | undefined): boolean {
110
+ const trimmed = value?.trim().toLowerCase();
111
+ return trimmed === "false" || trimmed === "0" || trimmed === "no" || trimmed === "off";
112
+ }