deepseek-web-retrieval 0.6.0 → 0.8.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 (50) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +113 -53
  3. package/dist/cli.js +2427 -1813
  4. package/docs/adr/0002-shared-config-base-layer.md +43 -0
  5. package/docs/agents/claude-code.md +273 -0
  6. package/docs/agents/codex.md +50 -27
  7. package/docs/agents/opencode-v2.md +59 -33
  8. package/docs/agents/pi.md +50 -27
  9. package/docs/configuration.md +109 -0
  10. package/docs/design/config-layering-design.md +180 -0
  11. package/docs/design/installer-design.md +24 -13
  12. package/docs/hosts.jsonc +5 -1
  13. package/docs/integration.md +15 -9
  14. package/docs/research/control-matrix.md +49 -11
  15. package/docs/research/mcp-compatibility.md +6 -3
  16. package/docs/research/ori-question-sourcing.md +336 -0
  17. package/docs/research/result-quality-passthrough.md +159 -0
  18. package/docs/research/search-api-defaults.md +18 -2
  19. package/docs/research/search-inline-content.md +244 -0
  20. package/examples/opencode.global.jsonc +3 -2
  21. package/package.json +2 -1
  22. package/src/core/auth.ts +3 -0
  23. package/src/core/client.ts +26 -4
  24. package/src/core/config-file.ts +168 -0
  25. package/src/core/config-summary.ts +82 -0
  26. package/src/core/constants.ts +15 -1
  27. package/src/core/descriptions.ts +6 -2
  28. package/src/core/env.ts +102 -32
  29. package/src/core/format.ts +19 -1
  30. package/src/core/options.ts +22 -2
  31. package/src/core/search.ts +6 -0
  32. package/src/core/types.ts +11 -0
  33. package/src/installer/cli.ts +50 -16
  34. package/src/installer/config-command.ts +116 -0
  35. package/src/installer/hosts/claude-code.ts +325 -0
  36. package/src/installer/hosts/codex.ts +9 -4
  37. package/src/installer/hosts/index.ts +3 -0
  38. package/src/installer/hosts/opencode.ts +9 -5
  39. package/src/installer/hosts/pi.ts +34 -9
  40. package/src/installer/ownership.ts +54 -1
  41. package/src/installer/paths.ts +7 -1
  42. package/src/installer/state.ts +73 -20
  43. package/src/installer/types.ts +11 -1
  44. package/src/installer/uninstall.ts +4 -1
  45. package/src/mcp/auth.ts +2 -3
  46. package/src/mcp/cli.ts +4 -3
  47. package/src/mcp/serve.ts +10 -1
  48. package/src/mcp/server.ts +7 -2
  49. package/src/opencode/plugin.ts +22 -2
  50. package/src/pi/extension.ts +78 -9
@@ -0,0 +1,168 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { posix, win32 } from "node:path";
4
+ import { CONFIG_KEYS } from "./config-summary";
5
+ import type { Env } from "./env";
6
+ import { isPlainObject } from "./object";
7
+ import type { PluginOptions } from "./types";
8
+
9
+ /** The user-scope shared config file: one file, under the user config home (userConfigHome). */
10
+ export const SHARED_CONFIG_DIR = "deepseek-web-retrieval";
11
+ export const SHARED_CONFIG_FILE = "config.json";
12
+
13
+ /**
14
+ * The shared file's vocabulary: API-behavior and host-sensitive plugin option
15
+ * keys only, derived from the ordered config vocabulary (CONFIG_KEYS) so the
16
+ * file, the startup summary, and the config command can never drift apart.
17
+ * Values pass through raw — validation and clamping stay in
18
+ * resolvePluginOptions so every channel feeds one pipeline. Host-integration
19
+ * keys are deliberately absent (see HOST_INTEGRATION_KEYS).
20
+ */
21
+ export const SHARED_CONFIG_KEYS = CONFIG_KEYS;
22
+
23
+ const SHARED_KEY_SET = new Set<string>(SHARED_CONFIG_KEYS);
24
+
25
+ /**
26
+ * Host-integration keys describe the host's tool landscape, not the DeepSeek
27
+ * call, so they stay in host-native config; the shared file rejects them.
28
+ */
29
+ const HOST_INTEGRATION_KEYS = new Set<string>([
30
+ "searchEnabled",
31
+ "fetchEnabled",
32
+ "setDefaultWebsearch",
33
+ "fetchOverrideWebfetch",
34
+ "fetchRegisterTool",
35
+ "apiKeySource",
36
+ ]);
37
+
38
+ /**
39
+ * Joins with the separators of the platform being resolved, not the host's —
40
+ * identical to `join` in production (the platform is process.platform), but
41
+ * deterministic when tests inject win32 on another host.
42
+ */
43
+ function joinFor(platform: NodeJS.Platform, ...parts: readonly string[]): string {
44
+ return (platform === "win32" ? win32 : posix).join(...parts);
45
+ }
46
+
47
+ /**
48
+ * The inputs every config-location resolution shares, bundled so they travel
49
+ * as one (the installer's FsRoots is the precedent for this shape). Every
50
+ * function below accepts a partial and fills the machine's real values for
51
+ * anything omitted.
52
+ */
53
+ export interface UserConfigContext {
54
+ readonly env: Env;
55
+ readonly home: string;
56
+ readonly platform: NodeJS.Platform;
57
+ }
58
+
59
+ function resolveContext(context: Partial<UserConfigContext> = {}): UserConfigContext {
60
+ return {
61
+ env: context.env ?? process.env,
62
+ home: context.home ?? homedir(),
63
+ platform: context.platform ?? process.platform,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * The canonical user config home — the one resolution every consumer of the
69
+ * per-user config location shares (the shared config file, the installer's
70
+ * global state records, and the OpenCode global config).
71
+ *
72
+ * A non-empty XDG_CONFIG_HOME wins on every platform: power users and CI pin it
73
+ * deliberately, Windows included. Otherwise win32 uses the conventional Roaming
74
+ * root — %APPDATA% from the environment, `<home>\AppData\Roaming` when unset —
75
+ * and every other platform keeps the XDG default `<home>/.config`. Pure so
76
+ * tests inject env, home, and platform directly.
77
+ */
78
+ export function userConfigHome(context: Partial<UserConfigContext>): string {
79
+ const { env, home, platform } = resolveContext(context);
80
+ const xdg = env.XDG_CONFIG_HOME;
81
+ if (xdg) return xdg;
82
+ if (platform === "win32") return env.APPDATA || joinFor(platform, home, "AppData", "Roaming");
83
+ return joinFor(platform, home, ".config");
84
+ }
85
+
86
+ /** The directory this tool owns inside the user config home (see userConfigHome). */
87
+ export function userConfigDir(context: Partial<UserConfigContext>): string {
88
+ const { env, home, platform } = resolveContext(context);
89
+ return joinFor(platform, userConfigHome({ env, home, platform }), SHARED_CONFIG_DIR);
90
+ }
91
+
92
+ /** The shared file's location on this machine; see userConfigHome for the rules. */
93
+ export function configFilePath(context: Partial<UserConfigContext>): string {
94
+ const { env, home, platform } = resolveContext(context);
95
+ return joinFor(platform, userConfigDir({ env, home, platform }), SHARED_CONFIG_FILE);
96
+ }
97
+
98
+ /**
99
+ * What reading the shared config file produced: the accepted options plus the
100
+ * problems as structured data. `readFileOptions` prints the warnings; other
101
+ * consumers (the config command) replay them through their own channel.
102
+ */
103
+ export interface FileOptionsReport {
104
+ readonly options: PluginOptions;
105
+ /** One entry per problem the runtime would warn about on stderr. */
106
+ readonly warnings: readonly string[];
107
+ /** False only when the file is absent — the normal, silent case. */
108
+ readonly exists: boolean;
109
+ }
110
+
111
+ /**
112
+ * Reads the shared config file synchronously (the Pi settings reader's
113
+ * convention — adapters resolve once at startup) and reports what happened.
114
+ * Absence is normal and silent; an unreadable or unparseable file and every
115
+ * rejected key produce one warning each and are otherwise dropped, so a bad
116
+ * file never blocks startup.
117
+ */
118
+ export function readFileOptionsReport(file: string): FileOptionsReport {
119
+ let text: string;
120
+ try {
121
+ text = readFileSync(file, "utf8");
122
+ } catch (error) {
123
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
124
+ return { options: {}, warnings: [], exists: false };
125
+ }
126
+ return { options: {}, warnings: [`cannot read ${file} — file ignored`], exists: true };
127
+ }
128
+
129
+ let parsed: unknown;
130
+ try {
131
+ parsed = JSON.parse(text);
132
+ } catch {
133
+ return { options: {}, warnings: [`${file} is not valid JSON — file ignored`], exists: true };
134
+ }
135
+ if (!isPlainObject(parsed)) {
136
+ return { options: {}, warnings: [`${file} is not a JSON object — file ignored`], exists: true };
137
+ }
138
+
139
+ const options: Record<string, unknown> = {};
140
+ const warnings: string[] = [];
141
+ for (const [key, value] of Object.entries(parsed)) {
142
+ if (SHARED_KEY_SET.has(key)) options[key] = value;
143
+ else if (HOST_INTEGRATION_KEYS.has(key))
144
+ warnings.push(`"${key}" is a host-integration key (host config only) — ignored`);
145
+ else warnings.push(`unknown key "${key}" — ignored`);
146
+ }
147
+ return { options, warnings, exists: true };
148
+ }
149
+
150
+ /**
151
+ * Reads the shared config file and warns about problems on stderr (stdout is
152
+ * the MCP server's JSON-RPC channel). See readFileOptionsReport for the
153
+ * structured shape; this is the production entry adapters use.
154
+ */
155
+ export function readFileOptions(file: string): PluginOptions {
156
+ const report = readFileOptionsReport(file);
157
+ for (const warning of report.warnings) warn(warning);
158
+ return report.options;
159
+ }
160
+
161
+ /** The production entry point: compute the path for this machine and read it. */
162
+ export function sharedFileOptions(context: Partial<UserConfigContext> = {}): PluginOptions {
163
+ return readFileOptions(configFilePath(context));
164
+ }
165
+
166
+ function warn(line: string): void {
167
+ console.error(`deepseek-web-retrieval config: ${line}`);
168
+ }
@@ -0,0 +1,82 @@
1
+ import { resolvePluginOptions } from "./options";
2
+ import type { ResolvedOptions } from "./types";
3
+
4
+ /**
5
+ * The ordered config vocabulary: every diffable knob's config key and where it
6
+ * lives in ResolvedOptions. The single source three surfaces share — the
7
+ * startup deviation line's key order (via configSummaryValues), the config
8
+ * command's listing, and the shared file's accepted keys (SHARED_CONFIG_KEYS
9
+ * derives from it) — so adding a knob is one edit here.
10
+ */
11
+ const VOCABULARY = [
12
+ { key: "count", read: (o: ResolvedOptions) => o.search.count },
13
+ { key: "maxResults", read: (o: ResolvedOptions) => o.search.maxResults },
14
+ { key: "minRerank", read: (o: ResolvedOptions) => o.search.minRerank },
15
+ { key: "minAuthority", read: (o: ResolvedOptions) => o.search.minAuthority },
16
+ { key: "allowUnknownAuthority", read: (o: ResolvedOptions) => o.search.allowUnknownAuthority },
17
+ { key: "blockedDomains", read: (o: ResolvedOptions) => o.search.blockedDomains },
18
+ { key: "includeDomains", read: (o: ResolvedOptions) => o.search.includeDomains },
19
+ { key: "language", read: (o: ResolvedOptions) => o.search.language },
20
+ { key: "searchLocalization", read: (o: ResolvedOptions) => o.search.localization },
21
+ { key: "maxSummaryChars", read: (o: ResolvedOptions) => o.search.maxSummaryChars },
22
+ { key: "searchTimeoutMs", read: (o: ResolvedOptions) => o.search.timeoutMs },
23
+ { key: "fetchMaxChars", read: (o: ResolvedOptions) => o.fetch.maxChars },
24
+ { key: "fetchAllowedDomains", read: (o: ResolvedOptions) => o.fetch.allowedDomains },
25
+ { key: "fetchBlockedDomains", read: (o: ResolvedOptions) => o.fetch.blockedDomains },
26
+ { key: "fetchTimeoutMs", read: (o: ResolvedOptions) => o.fetch.timeoutMs },
27
+ { key: "fetchBlockPrivateHosts", read: (o: ResolvedOptions) => o.fetch.blockPrivateHosts },
28
+ { key: "maxRetries", read: (o: ResolvedOptions) => o.retry.maxRetries },
29
+ { key: "retryBaseDelayMs", read: (o: ResolvedOptions) => o.retry.baseDelayMs },
30
+ ] as const;
31
+
32
+ /** The config keys in vocabulary order; the shared config file accepts exactly these. */
33
+ export const CONFIG_KEYS: readonly string[] = VOCABULARY.map((entry) => entry.key);
34
+
35
+ /**
36
+ * Renders only the knobs whose effective value differs from the built-in
37
+ * defaults, as space-separated `key=value` tokens in the config vocabulary.
38
+ * Host-integration fields (the tool `enabled` flags, `apiKeySource`) are
39
+ * excluded — they are not in the vocabulary; tool visibility is the startup
40
+ * tools line's job and the key source is host-native config. Empty diff means
41
+ * empty string.
42
+ */
43
+ export function configDeviations(resolved: ResolvedOptions): string {
44
+ const defaults = configSummaryValues(resolvePluginOptions({}));
45
+ const effective = configSummaryValues(resolved);
46
+ const tokens: string[] = [];
47
+ for (const key of Object.keys(effective)) {
48
+ const value = effective[key];
49
+ if (sameValue(value, defaults[key])) continue;
50
+ tokens.push(`${key}=${formatConfigValue(value, "length")}`);
51
+ }
52
+ return tokens.join(" ");
53
+ }
54
+
55
+ /**
56
+ * Flattens the diffable resolved fields onto their config-vocabulary names,
57
+ * in vocabulary order (Object key insertion order preserves it).
58
+ */
59
+ export function configSummaryValues(options: ResolvedOptions): Record<string, unknown> {
60
+ const values: Record<string, unknown> = {};
61
+ for (const entry of VOCABULARY) values[entry.key] = entry.read(options);
62
+ return values;
63
+ }
64
+
65
+ /** How arrays render: lengths for the noise-constrained startup line, entries for the human CLI. */
66
+ export type ConfigArrayStyle = "length" | "join";
67
+
68
+ /** One value renderer for both surfaces; `undefined` renders as "unset". */
69
+ export function formatConfigValue(value: unknown, arrays: ConfigArrayStyle): string {
70
+ if (Array.isArray(value)) {
71
+ if (arrays === "length") return String(value.length);
72
+ return value.length > 0 ? value.join(", ") : "[]";
73
+ }
74
+ return value === undefined ? "unset" : String(value);
75
+ }
76
+
77
+ function sameValue(value: unknown, fallback: unknown): boolean {
78
+ if (Array.isArray(value) && Array.isArray(fallback)) {
79
+ return value.length === fallback.length && value.every((entry, index) => entry === fallback[index]);
80
+ }
81
+ return value === fallback;
82
+ }
@@ -8,6 +8,11 @@ export const FETCH_ENDPOINT = "https://api.deepseek.com/v1/tools/fetch";
8
8
  * results client-side by score or authority — thresholds stay opt-in.
9
9
  */
10
10
  export const DEFAULT_SEARCH_COUNT = 10;
11
+ /**
12
+ * The fallback only when neither knob is set; whenever `count` is explicitly
13
+ * configured, an unset `maxResults` follows the effective count instead (see
14
+ * resolveSearchOptions) — the two are coupled in one direction, not independent.
15
+ */
11
16
  export const DEFAULT_MAX_RESULTS = 10;
12
17
  export const DEFAULT_MIN_RERANK = 0;
13
18
  export const DEFAULT_MIN_AUTHORITY = 0;
@@ -19,13 +24,21 @@ export const MIN_TIMEOUT_MS = 1000;
19
24
  export const MAX_TIMEOUT_MS = 120000;
20
25
 
21
26
  export const MAX_QUERY_CHARS = 128;
27
+ /** DeepSeek documents ori_question as 1-128 characters; longer values are clamped, not rejected. */
28
+ export const MAX_ORI_QUESTION_CHARS = 128;
22
29
  export const MAX_URL_CHARS = 8192;
23
30
  export const MAX_ERROR_BODY_CHARS = 2000;
24
31
  export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
25
32
  export const FETCH_TRUNCATION_MARKER = "\n\n[content truncated]";
26
33
 
27
34
  export const DEFAULT_API_KEY_SOURCE = "auto";
28
- export const DEFAULT_MAX_RETRIES = 0;
35
+ /**
36
+ * 2, not 0: a transient failure inside a tool call is cheaper to retry here than
37
+ * to leave to the model, whose only fallback is re-issuing the whole billed tool
38
+ * call — the same trade-off behind OpenAI/Anthropic SDKs defaulting to 2; see
39
+ * docs/research/search-api-defaults.md.
40
+ */
41
+ export const DEFAULT_MAX_RETRIES = 2;
29
42
  export const MAX_RETRIES = 5;
30
43
  export const DEFAULT_RETRY_BASE_DELAY_MS = 250;
31
44
  export const MAX_RETRY_DELAY_MS = 10000;
@@ -34,3 +47,4 @@ export const MAX_RETRY_DELAY_MS = 10000;
34
47
  export const API_KEY_ENV = "DEEPSEEK_API_KEY";
35
48
  export const ENABLE_SEARCH_ENV = "DEEPSEEK_ENABLE_SEARCH";
36
49
  export const ENABLE_FETCH_ENV = "DEEPSEEK_ENABLE_FETCH";
50
+ export const SEARCH_LOCALIZATION_ENV = "DEEPSEEK_SEARCH_LOCALIZATION";
@@ -16,7 +16,7 @@ export const SEARCH_RANKED_FILTERED =
16
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, and after:/before: with a year or date (e.g. after:2025-06-01) to filter by publish time; the full query must stay within 128 characters.";
20
20
 
21
21
  /**
22
22
  * The full tool descriptions, composed from the fact fragments. Every host
@@ -38,6 +38,10 @@ export const FETCH_TOOL_DESCRIPTION =
38
38
 
39
39
  /** Parameter descriptions shared by every adapter's input schema for the two tools. */
40
40
  export const SEARCH_QUERY_PARAMETER =
41
- "Search query, 128 characters or fewer including operators (site:, -site:, quoted phrases, lang:).";
41
+ "Search query, 128 characters or fewer including operators (site:, -site:, quoted phrases, lang:, after:, before:).";
42
+
43
+ export const SEARCH_ORI_QUESTION_PARAMETER =
44
+ "The user's original question or task that triggered this search, in the user's own words rather than a " +
45
+ "reformulated query. Optional; 1-128 characters, and longer values are truncated.";
42
46
 
43
47
  export const FETCH_URL_PARAMETER = "Absolute http:// or https:// URL to fetch. Credentials are rejected.";
package/src/core/env.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV } from "./constants";
1
+ import { API_KEY_ENV, ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV, SEARCH_LOCALIZATION_ENV } from "./constants";
2
+ import { normalizeDomain } from "./domains";
2
3
  import { resolvePluginOptions } from "./options";
3
4
  import type { PluginOptions, ResolvedOptions } from "./types";
4
5
 
@@ -21,6 +22,7 @@ const NUMBER_ENV = {
21
22
 
22
23
  const TEXT_ENV = {
23
24
  language: "DEEPSEEK_SEARCH_LANGUAGE",
25
+ searchLocalization: SEARCH_LOCALIZATION_ENV,
24
26
  } as const;
25
27
 
26
28
  const DOMAINS_ENV = {
@@ -38,51 +40,95 @@ const FALSE_ONLY_ENV = {
38
40
  fetchEnabled: ENABLE_FETCH_ENV,
39
41
  } as const;
40
42
 
43
+ /** Every option key mapped onto its `DEEPSEEK_*` variable — the inverse view of the maps above. */
44
+ export const OPTION_ENV_BY_KEY: Readonly<Record<string, string>> = {
45
+ ...NUMBER_ENV,
46
+ ...TEXT_ENV,
47
+ ...DOMAINS_ENV,
48
+ ...FALSE_ONLY_ENV,
49
+ };
50
+
51
+ /**
52
+ * Every option's `DEEPSEEK_*` variable name, in map order — the names a host that
53
+ * isolates its children from the parent environment (Codex) must forward for
54
+ * shell-set options to reach the server. `DEEPSEEK_API_KEY` is deliberately
55
+ * absent: it is not an option variable, and installers compose it separately.
56
+ */
57
+ export const OPTION_ENV_VARS: readonly string[] = Object.values(OPTION_ENV_BY_KEY);
58
+
41
59
  /**
42
60
  * Maps `DEEPSEEK_*` environment variables onto the shared plugin option keys and
43
61
  * resolves them through the shared pipeline, so defaults and clamping stay
44
62
  * identical across every adapter. Invalid values are dropped and the shared
45
- * default applies.
63
+ * default applies; `envOptionsReport` is where those drops become visible.
64
+ * `fileOptions` (the shared config file) layer beneath the environment: for
65
+ * the same key the environment wins.
46
66
  */
47
- export function resolveEnvOptions(env: Env = process.env): ResolvedOptions {
48
- return resolvePluginOptions(pluginOptionsFromEnv(env));
67
+ export function resolveEnvOptions(env: Env = process.env, fileOptions?: PluginOptions): ResolvedOptions {
68
+ return resolvePluginOptions({ ...fileOptions, ...pluginOptionsFromEnv(env) });
49
69
  }
50
70
 
51
- export function pluginOptionsFromEnv(env: Env): PluginOptions {
52
- const options: Record<string, unknown> = {};
71
+ /**
72
+ * What reading the `DEEPSEEK_*` environment produced, plus the problems as
73
+ * structured data. Startup paths stay silent (they use pluginOptionsFromEnv);
74
+ * surfaces whose job is visibility — the config command — replay these
75
+ * warnings through their own channel.
76
+ */
77
+ export interface EnvOptionsReport {
78
+ readonly options: PluginOptions;
79
+ /** One entry per set-but-unusable `DEEPSEEK_*` variable: unknown names and values that had to be dropped. */
80
+ readonly warnings: readonly string[];
81
+ }
53
82
 
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);
83
+ /** The options layer of the environment, with every drop reported instead of silent. */
84
+ export function envOptionsReport(env: Env = process.env): EnvOptionsReport {
85
+ const options: Record<string, unknown> = {};
86
+ const warnings: string[] = [];
87
+
88
+ putNumber(options, "count", env, warnings);
89
+ putNumber(options, "maxResults", env, warnings);
90
+ putNumber(options, "minRerank", env, warnings);
91
+ putNumber(options, "minAuthority", env, warnings);
92
+ putFalseOnly(options, "allowUnknownAuthority", env, warnings);
93
+ putDomains(options, "blockedDomains", env, warnings);
94
+ putDomains(options, "includeDomains", env, warnings);
61
95
  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);
96
+ putText(options, "searchLocalization", env);
97
+ putNumber(options, "maxSummaryChars", env, warnings);
98
+ putNumber(options, "searchTimeoutMs", env, warnings);
99
+
100
+ putNumber(options, "fetchMaxChars", env, warnings);
101
+ putDomains(options, "fetchAllowedDomains", env, warnings);
102
+ putDomains(options, "fetchBlockedDomains", env, warnings);
103
+ putNumber(options, "fetchTimeoutMs", env, warnings);
104
+ putFalseOnly(options, "fetchBlockPrivateHosts", env, warnings);
105
+ putFalseOnly(options, "searchEnabled", env, warnings);
106
+ putFalseOnly(options, "fetchEnabled", env, warnings);
107
+
108
+ putNumber(options, "maxRetries", env, warnings);
109
+ putNumber(options, "retryBaseDelayMs", env, warnings);
110
+
111
+ const known = new Set(OPTION_ENV_VARS);
112
+ for (const key of Object.keys(env)) {
113
+ if (!key.startsWith("DEEPSEEK_") || key === API_KEY_ENV || known.has(key)) continue;
114
+ if (!(env[key] ?? "").trim()) continue;
115
+ warnings.push(`unknown DEEPSEEK_* variable ${key} — ignored`);
116
+ }
117
+ return { options, warnings };
118
+ }
75
119
 
76
- return options;
120
+ export function pluginOptionsFromEnv(env: Env): PluginOptions {
121
+ return envOptionsReport(env).options;
77
122
  }
78
123
 
79
- function putNumber(target: Record<string, unknown>, key: keyof typeof NUMBER_ENV, env: Env): void {
124
+ function putNumber(target: Record<string, unknown>, key: keyof typeof NUMBER_ENV, env: Env, warnings: string[]): void {
80
125
  const value = env[NUMBER_ENV[key]];
81
126
  if (value === undefined) return;
82
127
  const trimmed = value.trim();
83
128
  if (!trimmed) return;
84
129
  const parsed = Number(trimmed);
85
130
  if (Number.isFinite(parsed)) target[key] = parsed;
131
+ else warnings.push(`${NUMBER_ENV[key]} is not a number ("${trimmed}") — ignored`);
86
132
  }
87
133
 
88
134
  function putText(target: Record<string, unknown>, key: keyof typeof TEXT_ENV, env: Env): void {
@@ -92,18 +138,42 @@ function putText(target: Record<string, unknown>, key: keyof typeof TEXT_ENV, en
92
138
  if (trimmed) target[key] = trimmed;
93
139
  }
94
140
 
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;
141
+ function putFalseOnly(
142
+ target: Record<string, unknown>,
143
+ key: keyof typeof FALSE_ONLY_ENV,
144
+ env: Env,
145
+ warnings: string[],
146
+ ): void {
147
+ const name = FALSE_ONLY_ENV[key];
148
+ const value = env[name];
149
+ if (value === undefined || !value.trim()) return;
150
+ if (isFalseValue(value)) {
151
+ target[key] = false;
152
+ return;
153
+ }
154
+ warnings.push(`${name}="${value.trim()}" changes nothing — only false/0/no/off turns the switch off`);
97
155
  }
98
156
 
99
- function putDomains(target: Record<string, unknown>, key: keyof typeof DOMAINS_ENV, env: Env): void {
100
- const value = env[DOMAINS_ENV[key]];
157
+ function putDomains(
158
+ target: Record<string, unknown>,
159
+ key: keyof typeof DOMAINS_ENV,
160
+ env: Env,
161
+ warnings: string[],
162
+ ): void {
163
+ const name = DOMAINS_ENV[key];
164
+ const value = env[name];
101
165
  if (value === undefined || !value.trim()) return;
102
166
  const domains = value
103
167
  .split(",")
104
168
  .map((domain) => domain.trim())
105
169
  .filter((domain) => domain.length > 0);
170
+ for (const domain of domains) {
171
+ if (normalizeDomain(domain) === undefined) {
172
+ warnings.push(`${name} entry "${domain}" is not a domain — ignored`);
173
+ }
174
+ }
106
175
  if (domains.length > 0) target[key] = domains;
176
+ else warnings.push(`${name} contains no domains — ignored`);
107
177
  }
108
178
 
109
179
  function isFalseValue(value: string | undefined): boolean {
@@ -95,7 +95,8 @@ export function formatSearchResults(results: readonly SearchResult[]): string {
95
95
  return results
96
96
  .map((result, index) => {
97
97
  const title = result.title?.trim() || result.url;
98
- const lines = [`${index + 1}. [${escapeBrackets(title)}](${result.url})`];
98
+ const marker = formatQualityMarker(result);
99
+ const lines = [`${index + 1}. [${escapeBrackets(title)}](${result.url})${marker ? ` ${marker}` : ""}`];
99
100
  if (result.content) {
100
101
  lines.push(` ${result.content.replaceAll("\n", " ")}`);
101
102
  }
@@ -107,6 +108,20 @@ export function formatSearchResults(results: readonly SearchResult[]): string {
107
108
  .join("\n\n");
108
109
  }
109
110
 
111
+ /**
112
+ * Renders the quality fields DeepSeek reports as one bracketed token on the
113
+ * result's heading line; the text channel is the only one every host delivers
114
+ * to the model. Authority -1 means unknown and stays in the structured shape
115
+ * only, so it never wastes text.
116
+ */
117
+ export function formatQualityMarker(result: SearchResult): string {
118
+ const parts: string[] = [];
119
+ if (result.rerankScore !== undefined) parts.push(`rerank ${result.rerankScore}`);
120
+ if (result.authority !== undefined && result.authority >= 0) parts.push(`authority ${result.authority}`);
121
+ if (result.siteName) parts.push(result.siteName);
122
+ return parts.length > 0 ? `[${parts.join(" · ")}]` : "";
123
+ }
124
+
110
125
  /**
111
126
  * Renders one search result as the structured mirror of its text form; hosts
112
127
  * that read structuredContent (every JSON-replacing client excepted) get this
@@ -118,6 +133,9 @@ export function toStructuredResult(result: SearchResult) {
118
133
  ...(result.title ? { title: result.title } : {}),
119
134
  ...(result.content ? { content: result.content } : {}),
120
135
  ...(result.published !== undefined ? { published: result.published } : {}),
136
+ ...(result.rerankScore !== undefined ? { rerankScore: result.rerankScore } : {}),
137
+ ...(result.authority !== undefined ? { authority: result.authority } : {}),
138
+ ...(result.siteName ? { siteName: result.siteName } : {}),
121
139
  };
122
140
  }
123
141
 
@@ -1,3 +1,4 @@
1
+ import { isIP } from "node:net";
1
2
  import {
2
3
  DEFAULT_API_KEY_SOURCE,
3
4
  DEFAULT_FETCH_MAX_CHARS,
@@ -35,15 +36,21 @@ export function resolvePluginOptions(input?: PluginOptions): ResolvedOptions {
35
36
  }
36
37
 
37
38
  export function resolveSearchOptions(input?: PluginOptions): SearchOptions {
39
+ const count = clampInt(input?.count, DEFAULT_SEARCH_COUNT, 1, 50);
38
40
  return {
39
- count: clampInt(input?.count, DEFAULT_SEARCH_COUNT, 1, 50),
40
- maxResults: clampInt(input?.maxResults, DEFAULT_MAX_RESULTS, 0, 50),
41
+ count,
42
+ // Constraint: maxResults follows count only when the user left it alone —
43
+ // an explicit maxResults still caps as before. count never follows
44
+ // maxResults: fetching more than the cap returns wastes nothing visible,
45
+ // while returning fewer than were fetched silently discards results.
46
+ maxResults: clampInt(input?.maxResults, Number.isFinite(input?.count) ? count : DEFAULT_MAX_RESULTS, 0, 50),
41
47
  minRerank: finiteNumber(input?.minRerank, DEFAULT_MIN_RERANK),
42
48
  minAuthority: finiteNumber(input?.minAuthority, DEFAULT_MIN_AUTHORITY),
43
49
  allowUnknownAuthority: input?.allowUnknownAuthority !== false,
44
50
  blockedDomains: normalizeDomains(input?.blockedDomains),
45
51
  includeDomains: normalizeDomains(input?.includeDomains),
46
52
  language: optionalText(input?.language),
53
+ localization: localizationValue(input?.searchLocalization),
47
54
  maxSummaryChars: clampInt(input?.maxSummaryChars, DEFAULT_MAX_SUMMARY_CHARS, 0, 1_000_000),
48
55
  timeoutMs: clampInt(input?.searchTimeoutMs, DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
49
56
  enabled: input?.searchEnabled !== false,
@@ -93,6 +100,19 @@ function optionalText(value: unknown): string | undefined {
93
100
  return trimmed ? trimmed : undefined;
94
101
  }
95
102
 
103
+ /**
104
+ * Constraint: only a valid IPv4 dotted-quad (four 0-255 octets) or IPv6
105
+ * address (including "::" compression) survives; anything else drops to
106
+ * undefined so the request omits `localization` entirely. This is the
107
+ * enforcement point no matter which config channel delivered the value.
108
+ */
109
+ function localizationValue(value: unknown): string | undefined {
110
+ const text = optionalText(value);
111
+ // isIP is the platform's strict parser; its one lax spot is IPv6 zone IDs
112
+ // ("%eth0"), which name a local link, not a geolocation — reject those.
113
+ return text !== undefined && !text.includes("%") && isIP(text) !== 0 ? text : undefined;
114
+ }
115
+
96
116
  function finiteNumber(value: unknown, fallback: number): number {
97
117
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
98
118
  }
@@ -67,11 +67,17 @@ export function processResults(raw: readonly DeepSeekSearchResult[], options: Se
67
67
  const content = truncate(optionalText(item.summary) ?? optionalText(item.snippet) ?? "", options.maxSummaryChars);
68
68
  const published = parsePublished(item.date);
69
69
  const title = optionalText(item.title);
70
+ const rerankScore = typeof rerank === "number" && Number.isFinite(rerank) ? rerank : undefined;
71
+ const authorityScore = typeof authority === "number" && Number.isFinite(authority) ? authority : undefined;
72
+ const siteName = optionalText(item.site_name);
70
73
  results.push({
71
74
  url,
72
75
  ...(title ? { title } : {}),
73
76
  ...(content ? { content } : {}),
74
77
  ...(published !== undefined ? { published } : {}),
78
+ ...(rerankScore !== undefined ? { rerankScore } : {}),
79
+ ...(authorityScore !== undefined ? { authority: authorityScore } : {}),
80
+ ...(siteName ? { siteName } : {}),
75
81
  });
76
82
  }
77
83
 
package/src/core/types.ts CHANGED
@@ -13,6 +13,12 @@ export interface SearchOptions {
13
13
  readonly blockedDomains: readonly string[];
14
14
  readonly includeDomains: readonly string[];
15
15
  readonly language?: string;
16
+ /**
17
+ * Deployment-level only — never a per-call tool parameter. A valid IPv4 or
18
+ * IPv6 address sent as DeepSeek's optional `localization` to prefer
19
+ * geo-localized results; invalid values are dropped at option resolution.
20
+ */
21
+ readonly localization?: string;
16
22
  readonly maxSummaryChars: number;
17
23
  readonly timeoutMs: number;
18
24
  readonly enabled: boolean;
@@ -46,6 +52,7 @@ export interface ResolvedOptions {
46
52
  export interface DeepSeekSearchResult {
47
53
  readonly rerank_score?: number;
48
54
  readonly authority?: number;
55
+ readonly site_name?: string | null;
49
56
  readonly title?: string | null;
50
57
  readonly url?: string | null;
51
58
  readonly snippet?: string | null;
@@ -59,6 +66,10 @@ export interface SearchResult {
59
66
  readonly title?: string;
60
67
  readonly content?: string;
61
68
  readonly published?: number;
69
+ readonly rerankScore?: number;
70
+ /** Faithful to the wire: -1 means unknown authority. */
71
+ readonly authority?: number;
72
+ readonly siteName?: string;
62
73
  }
63
74
 
64
75
  export interface FetchedPage {