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.
- package/CHANGELOG.md +23 -1
- package/README.md +113 -53
- package/dist/cli.js +2427 -1813
- package/docs/adr/0002-shared-config-base-layer.md +43 -0
- package/docs/agents/claude-code.md +273 -0
- package/docs/agents/codex.md +50 -27
- package/docs/agents/opencode-v2.md +59 -33
- package/docs/agents/pi.md +50 -27
- package/docs/configuration.md +109 -0
- package/docs/design/config-layering-design.md +180 -0
- package/docs/design/installer-design.md +24 -13
- package/docs/hosts.jsonc +5 -1
- package/docs/integration.md +15 -9
- package/docs/research/control-matrix.md +49 -11
- package/docs/research/mcp-compatibility.md +6 -3
- package/docs/research/ori-question-sourcing.md +336 -0
- package/docs/research/result-quality-passthrough.md +159 -0
- package/docs/research/search-api-defaults.md +18 -2
- package/docs/research/search-inline-content.md +244 -0
- package/examples/opencode.global.jsonc +3 -2
- package/package.json +2 -1
- package/src/core/auth.ts +3 -0
- package/src/core/client.ts +26 -4
- package/src/core/config-file.ts +168 -0
- package/src/core/config-summary.ts +82 -0
- package/src/core/constants.ts +15 -1
- package/src/core/descriptions.ts +6 -2
- package/src/core/env.ts +102 -32
- package/src/core/format.ts +19 -1
- package/src/core/options.ts +22 -2
- package/src/core/search.ts +6 -0
- package/src/core/types.ts +11 -0
- package/src/installer/cli.ts +50 -16
- package/src/installer/config-command.ts +116 -0
- package/src/installer/hosts/claude-code.ts +325 -0
- package/src/installer/hosts/codex.ts +9 -4
- package/src/installer/hosts/index.ts +3 -0
- package/src/installer/hosts/opencode.ts +9 -5
- package/src/installer/hosts/pi.ts +34 -9
- package/src/installer/ownership.ts +54 -1
- package/src/installer/paths.ts +7 -1
- package/src/installer/state.ts +73 -20
- package/src/installer/types.ts +11 -1
- package/src/installer/uninstall.ts +4 -1
- package/src/mcp/auth.ts +2 -3
- package/src/mcp/cli.ts +4 -3
- package/src/mcp/serve.ts +10 -1
- package/src/mcp/server.ts +7 -2
- package/src/opencode/plugin.ts +22 -2
- 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
|
+
}
|
package/src/core/constants.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|
package/src/core/descriptions.ts
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
putNumber(options, "
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
putDomains(options, "
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
putFalseOnly(options, "
|
|
71
|
-
putFalseOnly(options, "
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
putNumber(options, "
|
|
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
|
-
|
|
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(
|
|
96
|
-
|
|
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(
|
|
100
|
-
|
|
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 {
|
package/src/core/format.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/core/options.ts
CHANGED
|
@@ -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
|
|
40
|
-
|
|
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
|
}
|
package/src/core/search.ts
CHANGED
|
@@ -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 {
|