deepseek-web-retrieval 0.6.0 → 0.7.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 (44) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/README.md +79 -10
  3. package/dist/cli.js +2203 -1826
  4. package/docs/adr/0002-shared-config-base-layer.md +43 -0
  5. package/docs/agents/codex.md +48 -27
  6. package/docs/agents/opencode-v2.md +57 -33
  7. package/docs/agents/pi.md +48 -27
  8. package/docs/design/config-layering-design.md +180 -0
  9. package/docs/design/installer-design.md +8 -0
  10. package/docs/integration.md +6 -3
  11. package/docs/research/control-matrix.md +15 -2
  12. package/docs/research/mcp-compatibility.md +4 -1
  13. package/docs/research/ori-question-sourcing.md +336 -0
  14. package/docs/research/result-quality-passthrough.md +159 -0
  15. package/docs/research/search-api-defaults.md +18 -2
  16. package/docs/research/search-inline-content.md +244 -0
  17. package/examples/opencode.global.jsonc +3 -2
  18. package/package.json +2 -1
  19. package/src/core/auth.ts +3 -0
  20. package/src/core/client.ts +26 -4
  21. package/src/core/config-file.ts +168 -0
  22. package/src/core/config-summary.ts +82 -0
  23. package/src/core/constants.ts +15 -1
  24. package/src/core/descriptions.ts +6 -2
  25. package/src/core/env.ts +99 -32
  26. package/src/core/format.ts +19 -1
  27. package/src/core/options.ts +22 -2
  28. package/src/core/search.ts +6 -0
  29. package/src/core/types.ts +11 -0
  30. package/src/installer/cli.ts +50 -16
  31. package/src/installer/config-command.ts +116 -0
  32. package/src/installer/hosts/codex.ts +5 -3
  33. package/src/installer/hosts/opencode.ts +9 -5
  34. package/src/installer/hosts/pi.ts +10 -6
  35. package/src/installer/ownership.ts +54 -1
  36. package/src/installer/paths.ts +7 -1
  37. package/src/installer/state.ts +72 -19
  38. package/src/installer/types.ts +10 -0
  39. package/src/mcp/auth.ts +2 -3
  40. package/src/mcp/cli.ts +4 -3
  41. package/src/mcp/serve.ts +10 -1
  42. package/src/mcp/server.ts +7 -2
  43. package/src/opencode/plugin.ts +22 -2
  44. package/src/pi/extension.ts +78 -9
@@ -2,6 +2,7 @@ import { FORCED_COST_NOTE, looksLikeOwnPackage } from "../detect";
2
2
  import { codecFor } from "../formats";
3
3
  import { buildRulesBlock } from "../formats/rules";
4
4
  import { isPlainObject } from "../formats/value";
5
+ import { lineageMatcher } from "../ownership";
5
6
  import { configHome, join } from "../paths";
6
7
  import { rulesLines } from "../rulesContent";
7
8
  import { appendedByPrevious } from "../state";
@@ -44,8 +45,10 @@ export function planOpenCode(input: InstallerInput, previous: InstallRecord | un
44
45
 
45
46
  const plugins = Array.isArray(loaded.value.plugins) ? loaded.value.plugins : [];
46
47
  // Precisely ours: plugin packages a tracked init appended, plus this init's ref.
47
- // Without a record nothing is excused — an untracked same-name plugin (a fork
48
- // registers the same tool ids and would collide) must be removed first.
48
+ // Refs compare by package name (samePackageName), so a pinned ref excuses the
49
+ // unpinned lineage a previous init wrote and vice versa. Without a record
50
+ // nothing is excused — an untracked same-name plugin (a fork registers the
51
+ // same tool ids and would collide) must be removed first.
49
52
  const trackedPackages = new Set(
50
53
  appendedByPrevious(previous, ["plugins"])
51
54
  .filter((entry): entry is { package: string } => isPlainObject(entry) && typeof entry.package === "string")
@@ -54,9 +57,10 @@ export function planOpenCode(input: InstallerInput, previous: InstallRecord | un
54
57
  const pluginPackages = plugins.filter(
55
58
  (entry): entry is { package: string } => isPlainObject(entry) && typeof entry.package === "string",
56
59
  );
57
- const excused = previous === undefined ? new Set<string>() : new Set<string>([...trackedPackages, input.packageRef]);
60
+ const excusedRefs = previous === undefined ? [] : [...trackedPackages, input.packageRef];
61
+ const isExcusedRef = lineageMatcher(excusedRefs);
58
62
  const foreignOwn = pluginPackages.filter(
59
- (entry) => looksLikeOwnPackage(entry.package) && !excused.has(entry.package),
63
+ (entry) => looksLikeOwnPackage(entry.package) && !isExcusedRef(entry.package),
60
64
  );
61
65
  if (foreignOwn.length > 0) {
62
66
  errors.push({
@@ -65,7 +69,7 @@ export function planOpenCode(input: InstallerInput, previous: InstallRecord | un
65
69
  "write. Remove it (or run uninstall if it came from a previous init), then retry.",
66
70
  });
67
71
  }
68
- if (previous !== undefined && pluginPackages.some((entry) => excused.has(entry.package))) {
72
+ if (previous !== undefined && pluginPackages.some((entry) => isExcusedRef(entry.package))) {
69
73
  notes.push(
70
74
  "A previous init wrote the plugins entry; re-applying keeps it the only entry (a changed package ref or tier " +
71
75
  "options replace it, an unchanged one is a no-op) and the original snapshots carry over.",
@@ -11,6 +11,7 @@ import {
11
11
  import { codecFor } from "../formats";
12
12
  import { buildRulesBlock } from "../formats/rules";
13
13
  import { isPlainObject } from "../formats/value";
14
+ import { lineageMatcher } from "../ownership";
14
15
  import { join } from "../paths";
15
16
  import { MCP_TOOL_IDS, RAW_MCP_TOOL_IDS, rulesLines } from "../rulesContent";
16
17
  import { appendedByPrevious } from "../state";
@@ -60,14 +61,17 @@ function planPiExtension(input: InstallerInput, previous: InstallRecord | undefi
60
61
  const packages = Array.isArray(loaded.value.packages) ? loaded.value.packages : [];
61
62
  const source = piSourceRef(input.packageRef);
62
63
  // Precisely ours: what a tracked init appended, plus what this init would write.
63
- // Only a tracked init can excuse a same-name source — without a record, even an
64
+ // Sources compare by package name (samePackageName), so a pinned source
65
+ // excuses the unpinned lineage a previous init wrote and vice versa. Only a
66
+ // tracked init can excuse a same-name source — without a record, even an
64
67
  // exact match is an untracked install this installer must not adopt silently.
65
68
  const trackedSources = appendedByPrevious(previous, ["packages"]).filter(
66
69
  (entry): entry is string => typeof entry === "string",
67
70
  );
68
- const excused = previous === undefined ? new Set<string>() : new Set<string>([...trackedSources, source]);
69
71
  const sources = packages.filter((entry): entry is string => typeof entry === "string");
70
- const foreignOwn = sources.filter((entry) => looksLikeOwnPackage(entry) && !excused.has(entry));
72
+ const excusedSources = previous === undefined ? [] : [...trackedSources, source];
73
+ const isExcusedSource = lineageMatcher(excusedSources);
74
+ const foreignOwn = sources.filter((entry) => looksLikeOwnPackage(entry) && !isExcusedSource(entry));
71
75
  if (foreignOwn.length > 0) {
72
76
  errors.push({
73
77
  message:
@@ -75,7 +79,7 @@ function planPiExtension(input: InstallerInput, previous: InstallRecord | undefi
75
79
  "write. Remove it (pi remove <source>, or uninstall if it came from a previous init), then retry.",
76
80
  });
77
81
  }
78
- if (previous !== undefined && sources.some((entry) => excused.has(entry))) {
82
+ if (previous !== undefined && sources.some((entry) => isExcusedSource(entry))) {
79
83
  notes.push(
80
84
  "A previous init wrote the packages entry; re-applying reuses it (an unchanged source deduplicates, a changed " +
81
85
  "--package ref replaces the old source) and the original snapshots carry over.",
@@ -87,9 +91,9 @@ function planPiExtension(input: InstallerInput, previous: InstallRecord | undefi
87
91
  // Pi has no per-package kill switch in settings.json, so a web-capable
88
92
  // package or extension source cannot be excluded from the plan — refuse the
89
93
  // tier instead of promising routing that is not actually forced.
90
- const ownSources = new Set<string>([...trackedSources, source]);
94
+ const isOwnSource = lineageMatcher([...trackedSources, source]);
91
95
  const unexcludable = [...collectSources(loaded.value.packages), ...collectSources(loaded.value.extensions)].filter(
92
- (entry) => !ownSources.has(entry) && looksWebCapable(entry, undefined),
96
+ (entry) => !isOwnSource(entry) && looksWebCapable(entry, undefined),
93
97
  );
94
98
  if (unexcludable.length > 0) {
95
99
  errors.push({
@@ -2,11 +2,60 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSyn
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { isPlainObject } from "../core/object";
4
4
  import { pruneEmptyDir } from "./revert";
5
- import { statePathFor } from "./state";
5
+ import { recordReadPath, statePathFor } from "./state";
6
6
  import type { InstallerInput, RecordedFile, Scope } from "./types";
7
7
 
8
8
  type ClaimedFile = Pick<RecordedFile, "file" | "format" | "shared">;
9
9
 
10
+ /**
11
+ * True when a ref's final `@` segment is a version spec — it starts with a digit
12
+ * (`name@<version>`, `npm:name@<version>`) — and not an npm scope
13
+ * (`@scope/name`, whose only `@` leads the string) or a dist-tag (`name@canary`).
14
+ */
15
+ function hasVersionSuffix(ref: string): boolean {
16
+ const at = ref.lastIndexOf("@");
17
+ return at > 0 && /^[0-9]/.test(ref.slice(at + 1));
18
+ }
19
+
20
+ function stripVersionSuffix(ref: string): string {
21
+ const at = ref.lastIndexOf("@");
22
+ return hasVersionSuffix(ref) ? ref.slice(0, at) : ref;
23
+ }
24
+
25
+ /**
26
+ * Package-ref identity, the one comparison for "is this entry our package":
27
+ * two refs are the same package when they are equal after stripping a trailing
28
+ * `@version` suffix, so a pinned ref (`deepseek-web-retrieval@<version>`,
29
+ * `npm:deepseek-web-retrieval@<version>`) still matches its lineage
30
+ * (`deepseek-web-retrieval`). Forks, dist-tags, and lookalike names compare
31
+ * unequal. Everywhere a recorded or configured ref is matched against this
32
+ * install's ref — orphan detection, upgrade detection — goes through this
33
+ * helper instead of exact string equality.
34
+ */
35
+ export function samePackageName(a: string, b: string): boolean {
36
+ return stripVersionSuffix(a) === stripVersionSuffix(b);
37
+ }
38
+
39
+ /**
40
+ * A lineage-membership predicate over an excused/own ref set: the candidate
41
+ * matches when it is the same package as any listed ref (version suffixes
42
+ * stripped via samePackageName — a pinned or hand-edited ref still matches its
43
+ * lineage). Both host modules build their set (previous init's tracked entries
44
+ * plus this run's ref) and ask the same two questions with this one predicate.
45
+ */
46
+ export function lineageMatcher(refs: readonly string[]): (candidate: string) => boolean {
47
+ return (candidate: string) => refs.some((ref) => samePackageName(ref, candidate));
48
+ }
49
+
50
+ /**
51
+ * The `--pin` rule: append the running CLI's version to the ref unless its
52
+ * final `@` segment already is a version (`name` → `name@<version>`; a ref
53
+ * already ending in a version keeps it).
54
+ */
55
+ export function pinPackageRef(ref: string, version: string): string {
56
+ return hasVersionSuffix(ref) ? ref : `${ref}@${version}`;
57
+ }
58
+
10
59
  interface FileOwner {
11
60
  readonly recordPath: string;
12
61
  readonly scope: Scope;
@@ -63,6 +112,10 @@ function readOwner(file: string): FileOwner | undefined {
63
112
  function assertOwner(input: InstallerInput, claim: string): void {
64
113
  const owner = readOwner(claim);
65
114
  if (owner === undefined || owner.recordPath === ownerOf(input).recordPath) return;
115
+ // A win32 sidecar written before the config home moved to %APPDATA% names the
116
+ // legacy record location; when that is where this input's record is read
117
+ // from (see recordReadPath), it still identifies this same install lineage.
118
+ if (owner.recordPath === canonicalPath(recordReadPath(input))) return;
66
119
  throw new Error(
67
120
  `A ${input.host} install is already recorded at the ${owner.scope} scope (${owner.recordPath}) and claims the same config file. ` +
68
121
  `Uninstall it first: deepseek-web-retrieval uninstall --host ${input.host} --scope ${owner.scope} --cwd ${JSON.stringify(owner.cwd)}. ` +
@@ -7,7 +7,13 @@ export function join(...parts: readonly string[]): string {
7
7
  return nonEmpty.length === 0 ? "" : pathJoin(...nonEmpty);
8
8
  }
9
9
 
10
- /** Config home: an explicit root wins; otherwise ~/.config. The CLI resolves XDG at the edge. */
10
+ /**
11
+ * Config home: an explicit root wins; otherwise `~/.config`. The CLI edge now
12
+ * supplies the platform-aware explicit root (see userConfigHome in
13
+ * core/config-file: an honored XDG on every platform, `%APPDATA%` or
14
+ * `<home>\AppData\Roaming` on win32), so the `.config` fallback applies only to
15
+ * callers that construct roots directly without going through the CLI.
16
+ */
11
17
  export function configHome(roots: FsRoots): string {
12
18
  return roots.configHome ?? join(roots.home, ".config");
13
19
  }
@@ -3,12 +3,38 @@ import { isAbsolute, join } from "node:path";
3
3
  import { z } from "zod/v4";
4
4
  import { stateDir } from "./paths";
5
5
  import { atomicWrite } from "./persistence";
6
- import type { FsRoots, InstallerInput, InstallRecord } from "./types";
6
+ import type { FsRoots, InstallerInput, InstallRecord, Scope } from "./types";
7
7
 
8
8
  export function statePathFor(input: InstallerInput): string {
9
9
  return join(stateDir(input, input.scope), `install.${input.host}.json`);
10
10
  }
11
11
 
12
+ /** The platform a read resolves for: injected at the CLI edge, process.platform otherwise. */
13
+ function platformOf(roots: FsRoots): NodeJS.Platform {
14
+ return roots.platform ?? process.platform;
15
+ }
16
+
17
+ /**
18
+ * Windows-only read compatibility: global records written before the config
19
+ * home moved to %APPDATA% live under `~/.config`. Kept readable so `list` and
20
+ * `uninstall` keep tracking installs made by older releases.
21
+ */
22
+ export function legacyGlobalStateDir(roots: FsRoots): string {
23
+ return join(roots.home, ".config", "deepseek-web-retrieval");
24
+ }
25
+
26
+ /**
27
+ * The file a record is read from: the canonical state path, except that a
28
+ * missing win32 global record falls back to the legacy `~/.config` location
29
+ * (see legacyGlobalStateDir). Writes always target the canonical path.
30
+ */
31
+ export function recordReadPath(input: InstallerInput, platform: NodeJS.Platform = platformOf(input)): string {
32
+ const canonical = statePathFor(input);
33
+ if (platform !== "win32" || input.scope !== "global" || existsSync(canonical)) return canonical;
34
+ const legacy = join(legacyGlobalStateDir(input), `install.${input.host}.json`);
35
+ return existsSync(legacy) ? legacy : canonical;
36
+ }
37
+
12
38
  const keyPath = z.array(z.string().min(1)).min(1);
13
39
  const tier = z.enum(["optional", "preferred", "forced", "off"]);
14
40
  const operation = z.discriminatedUnion("op", [
@@ -86,7 +112,7 @@ function readRecordFile(file: string, host: string, scope: string): InstallRecor
86
112
  }
87
113
 
88
114
  export function readRecord(input: InstallerInput): InstallRecord | undefined {
89
- return readRecordFile(statePathFor(input), input.host, input.scope);
115
+ return readRecordFile(recordReadPath(input), input.host, input.scope);
90
116
  }
91
117
 
92
118
  /**
@@ -123,27 +149,54 @@ export function appendedByPrevious(record: InstallRecord | undefined, path: read
123
149
  }
124
150
 
125
151
  export function deleteRecord(input: InstallerInput): void {
126
- const file = statePathFor(input);
152
+ // Win32 legacy records are removed where they were found, so uninstalling a
153
+ // pre-APPDATA install clears its tracking instead of a canonical file that
154
+ // was never written.
155
+ const file = recordReadPath(input);
127
156
  if (existsSync(file)) rmSync(file);
128
157
  }
129
158
 
130
- /** Lists install records found in both scope locations for the given roots. */
131
- export function listRecords(roots: FsRoots): InstallRecord[] {
159
+ /** Reads every valid record in one scope's directory; an absent directory is empty. */
160
+ function readRecordsFromDir(dir: string, scope: Scope): InstallRecord[] {
132
161
  const records: InstallRecord[] = [];
133
- for (const scope of ["global", "project"] as const) {
134
- const dir = stateDir(roots, scope);
135
- let names: string[];
136
- try {
137
- names = readdirSync(dir);
138
- } catch (error) {
139
- if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
140
- throw new InstallRecordError(dir, "unreadable", (error as NodeJS.ErrnoException).code ?? "list failed");
141
- }
142
- for (const name of names) {
143
- if (!name.startsWith("install.") || !name.endsWith(".json")) continue;
144
- const record = readRecordFile(join(dir, name), name.slice("install.".length, -".json".length), scope);
145
- if (record !== undefined) records.push(record);
146
- }
162
+ let names: string[];
163
+ try {
164
+ names = readdirSync(dir);
165
+ } catch (error) {
166
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return records;
167
+ throw new InstallRecordError(dir, "unreadable", (error as NodeJS.ErrnoException).code ?? "list failed");
168
+ }
169
+ for (const name of names) {
170
+ if (!name.startsWith("install.") || !name.endsWith(".json")) continue;
171
+ const record = readRecordFile(join(dir, name), name.slice("install.".length, -".json".length), scope);
172
+ if (record !== undefined) records.push(record);
147
173
  }
148
174
  return records;
149
175
  }
176
+
177
+ function hasRecordFiles(dir: string): boolean {
178
+ try {
179
+ return readdirSync(dir).some((name) => name.startsWith("install.") && name.endsWith(".json"));
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * True when the global records must be read from the legacy win32 `~/.config`
187
+ * location: win32, nothing recorded under the canonical config home, and
188
+ * pre-%APPDATA% records still present there. `list` says so when this holds.
189
+ */
190
+ export function readingLegacyGlobalRecords(roots: FsRoots): boolean {
191
+ if (platformOf(roots) !== "win32") return false;
192
+ if (hasRecordFiles(stateDir(roots, "global"))) return false;
193
+ return hasRecordFiles(legacyGlobalStateDir(roots));
194
+ }
195
+
196
+ /** Lists install records found in both scope locations for the given roots. */
197
+ export function listRecords(roots: FsRoots): InstallRecord[] {
198
+ const global = readRecordsFromDir(stateDir(roots, "global"), "global");
199
+ const project = readRecordsFromDir(stateDir(roots, "project"), "project");
200
+ const legacy = readingLegacyGlobalRecords(roots) ? readRecordsFromDir(legacyGlobalStateDir(roots), "global") : [];
201
+ return [...global, ...legacy, ...project];
202
+ }
@@ -13,6 +13,11 @@ export interface FsRoots {
13
13
  readonly cwd: string;
14
14
  /** Explicit config home; the CLI edge resolves XDG once and passes it down. */
15
15
  readonly configHome?: string;
16
+ /**
17
+ * Platform for the win32-aware reads (legacy record fallback); the CLI edge
18
+ * passes it down like configHome. Readers default to process.platform.
19
+ */
20
+ readonly platform?: NodeJS.Platform;
16
21
  }
17
22
 
18
23
  /** The files a host edit touches for a given scope. */
@@ -37,6 +42,11 @@ export interface InstallerInput {
37
42
  readonly home: string;
38
43
  readonly cwd: string;
39
44
  readonly configHome?: string;
45
+ /**
46
+ * Platform for the win32-aware reads (legacy record fallback); see FsRoots.
47
+ * Injected at the CLI edge, absent when callers construct inputs directly.
48
+ */
49
+ readonly platform?: NodeJS.Platform;
40
50
  /**
41
51
  * Integration mechanism for hosts with more than one (Pi: the native
42
52
  * extension, or the MCP server). Undefined means the host's default path.
package/src/mcp/auth.ts CHANGED
@@ -1,8 +1,7 @@
1
- import { apiKeyFromEnv } from "../core/auth";
2
- import { API_KEY_ENV } from "../core/constants";
1
+ import { apiKeyFromEnv, MISSING_API_KEY_MESSAGE } from "../core/auth";
3
2
  import type { Env } from "../core/env";
4
3
 
5
- export const MISSING_API_KEY_MESSAGE = `DeepSeek API key not found. Set ${API_KEY_ENV} in the MCP server's env configuration.`;
4
+ export { MISSING_API_KEY_MESSAGE };
6
5
 
7
6
  /** Resolves the API key from the environment or fails with shared guidance. */
8
7
  export function requireApiKey(env: Env): string {
package/src/mcp/cli.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // Any argument means a human ran the CLI; MCP clients spawn this bin with no
3
- // arguments for the stdio server. The installer is imported statically and the
4
- // MCP server dynamically, so init/uninstall never load — or depend on — the
5
- // server's dependency graph, including when recovering from a bad server setup.
3
+ // arguments for the stdio server. Each side is imported dynamically, and only
4
+ // the side being routed to, so init/uninstall never load — or depend on — the
5
+ // server's dependency graph (and the server never loads the installer),
6
+ // including when recovering from a bad server setup.
6
7
  if (process.argv.length > 2) {
7
8
  const { runInstallerCli } = await import("../installer/cli");
8
9
  process.exit(await runInstallerCli(process.argv.slice(2)));
package/src/mcp/serve.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
2
2
  import { apiKeyFromEnv } from "../core/auth";
3
+ import { sharedFileOptions } from "../core/config-file";
4
+ import { configDeviations } from "../core/config-summary";
3
5
  import { resolveEnvOptions } from "../core/env";
4
6
  import { MISSING_API_KEY_MESSAGE } from "./auth";
5
7
  import { isJsonReplacingClient } from "./client";
@@ -14,7 +16,9 @@ export function serveMcp(): void {
14
16
  process.exit(1);
15
17
  }
16
18
 
17
- const config = resolveEnvOptions(env);
19
+ // Defaults < shared config file < environment: the server reads the file
20
+ // itself, so hosts that forward only DEEPSEEK_API_KEY still honor it.
21
+ const config = resolveEnvOptions(env, sharedFileOptions({ env }));
18
22
  const precheck = servePrecheck(config);
19
23
  if (precheck) {
20
24
  console.error(precheck);
@@ -45,6 +49,11 @@ export function serveMcp(): void {
45
49
  .filter(Boolean)
46
50
  .join(", ");
47
51
  console.error(`deepseek-web-retrieval MCP server on stdio (${tools})`);
52
+ // Deviations only, so the line stays short while still showing every
53
+ // non-default knob the layered resolution produced; nothing deviating
54
+ // means no line at all.
55
+ const deviations = configDeviations(config);
56
+ if (deviations) console.error(`config: ${deviations}`);
48
57
 
49
58
  process.on("SIGINT", () => void handle.close());
50
59
  process.on("SIGTERM", () => void handle.close());
package/src/mcp/server.ts CHANGED
@@ -8,6 +8,7 @@ import { ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV } from "../core/constants";
8
8
  import {
9
9
  FETCH_TOOL_DESCRIPTION,
10
10
  FETCH_URL_PARAMETER,
11
+ SEARCH_ORI_QUESTION_PARAMETER,
11
12
  SEARCH_QUERY_PARAMETER,
12
13
  SEARCH_TOOL_DESCRIPTION,
13
14
  } from "../core/descriptions";
@@ -21,6 +22,7 @@ export const MCP_SERVER_NAME = "deepseek-web-retrieval";
21
22
 
22
23
  const SEARCH_INPUT = z.object({
23
24
  query: z.string().min(1).describe(SEARCH_QUERY_PARAMETER),
25
+ original_question: z.string().optional().describe(SEARCH_ORI_QUESTION_PARAMETER),
24
26
  });
25
27
 
26
28
  const FETCH_INPUT = z.object({
@@ -47,10 +49,13 @@ export function createDeepSeekServer(
47
49
  inputSchema: SEARCH_INPUT,
48
50
  annotations: { readOnlyHint: true },
49
51
  },
50
- async ({ query }, ctx) => {
52
+ async ({ query, original_question }, ctx) => {
51
53
  try {
52
54
  const request = requestContext(config, env, ctx.mcpReq.signal);
53
- const results = await client.searchWeb(query, config.search, request);
55
+ const results = await client.searchWeb(query, config.search, {
56
+ ...request,
57
+ oriQuestion: original_question,
58
+ });
54
59
  return callResult(server, ctx, formatSearchResults(results), {
55
60
  results: results.map(toStructuredResult),
56
61
  });
@@ -3,7 +3,11 @@ import type { Context } from "@opencode/plugin/promise/plugin";
3
3
  import type { ToolContext } from "@opencode/plugin/promise/tool";
4
4
  import type { RetrievalClient } from "../core/client";
5
5
  import { inspectFetchTarget, retrievalClient } from "../core/client";
6
+ import { sharedFileOptions } from "../core/config-file";
7
+ import { configDeviations } from "../core/config-summary";
6
8
  import { FETCH_MARKDOWN_ONLY, FETCH_OUTPUT_TRUNCATED } from "../core/descriptions";
9
+ import { pluginOptionsFromEnv } from "../core/env";
10
+ import { formatQualityMarker } from "../core/format";
7
11
  import { resolveFetchTimeoutMs, resolvePluginOptions } from "../core/options";
8
12
  import type { PluginOptions, ResolvedOptions, SearchResult } from "../core/types";
9
13
  import { resolveApiKey } from "./auth";
@@ -58,10 +62,14 @@ interface OpenCodeSearchResult {
58
62
 
59
63
  /** Maps the core's neutral SearchResult onto the shape OpenCode expects. */
60
64
  function toOpenCodeResult(result: SearchResult): OpenCodeSearchResult {
65
+ // OpenCode's result shape is closed and only `content` reaches the model,
66
+ // so the quality marker rides at the end of the content string.
67
+ const marker = formatQualityMarker(result);
68
+ const content = marker ? (result.content ? `${result.content} ${marker}` : marker) : result.content;
61
69
  return {
62
70
  url: result.url,
63
71
  ...(result.title ? { title: result.title } : {}),
64
- ...(result.content ? { content: result.content } : {}),
72
+ ...(content ? { content } : {}),
65
73
  ...(result.published !== undefined ? { time: { published: result.published } } : { time: {} }),
66
74
  };
67
75
  }
@@ -143,8 +151,20 @@ export function createOpenCodePlugin(client: RetrievalClient = retrievalClient)
143
151
  return Plugin.define({
144
152
  id: "deepseek-web-retrieval",
145
153
  async setup(ctx) {
146
- const config = resolvePluginOptions(ctx.options);
154
+ // Defaults < shared config file < DEEPSEEK_* environment < opencode.json
155
+ // options: the host layer keeps winning exactly as before — the change
156
+ // is that the file and the environment now configure this host too.
157
+ const config = resolvePluginOptions({
158
+ ...sharedFileOptions(),
159
+ ...pluginOptionsFromEnv(process.env),
160
+ ...ctx.options,
161
+ });
147
162
  const registration = resolveRegistration(ctx.options);
163
+ // Deviations only, printed once at setup: one stderr line so a
164
+ // multi-layer resolution is visible; nothing deviating means no line at
165
+ // all (same contract as the MCP server).
166
+ const deviations = configDeviations(config);
167
+ if (deviations) console.error(`deepseek-web-retrieval config: ${deviations}`);
148
168
 
149
169
  if (config.search.enabled) {
150
170
  await ctx.websearch.transform((editor) => {
@@ -1,14 +1,17 @@
1
1
  import { homedir } from "node:os";
2
2
  import { Type } from "@earendil-works/pi-ai";
3
- import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import type { AgentToolResult, ExtensionAPI, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
4
4
  import { defineTool } from "@earendil-works/pi-coding-agent";
5
5
  import { apiKeyFromEnv } from "../core/auth";
6
6
  import type { RequestOptions, RetrievalClient } from "../core/client";
7
7
  import { inspectFetchTarget, retrievalClient } from "../core/client";
8
+ import { sharedFileOptions } from "../core/config-file";
9
+ import { configDeviations } from "../core/config-summary";
8
10
  import { API_KEY_ENV, ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV } from "../core/constants";
9
11
  import {
10
12
  FETCH_TOOL_DESCRIPTION,
11
13
  FETCH_URL_PARAMETER,
14
+ SEARCH_ORI_QUESTION_PARAMETER,
12
15
  SEARCH_QUERY_PARAMETER,
13
16
  SEARCH_TOOL_DESCRIPTION,
14
17
  } from "../core/descriptions";
@@ -22,6 +25,11 @@ import { readExtensionSettings, SETTINGS_NAMESPACE } from "./settings";
22
25
 
23
26
  const SEARCH_PARAMETERS = Type.Object({
24
27
  query: Type.String({ minLength: 1, description: SEARCH_QUERY_PARAMETER }),
28
+ original_question: Type.Optional(
29
+ Type.String({
30
+ description: `${SEARCH_ORI_QUESTION_PARAMETER} When left blank, the most recent user message in the session is sent instead, truncated to fit.`,
31
+ }),
32
+ ),
25
33
  });
26
34
 
27
35
  const SEARCH_OUTPUT = Type.Object({
@@ -31,6 +39,9 @@ const SEARCH_OUTPUT = Type.Object({
31
39
  title: Type.Optional(Type.String()),
32
40
  content: Type.Optional(Type.String()),
33
41
  published: Type.Optional(Type.Number()),
42
+ rerankScore: Type.Optional(Type.Number()),
43
+ authority: Type.Optional(Type.Number()),
44
+ siteName: Type.Optional(Type.String()),
34
45
  }),
35
46
  ),
36
47
  });
@@ -50,14 +61,15 @@ const FETCH_OUTPUT = Type.Object({
50
61
  const NEVER_ABORTS = new AbortController().signal;
51
62
 
52
63
  /**
53
- * Resolves the extension's options: the shared DEEPSEEK_* environment
54
- * pipeline, with the settings namespace layered on top. Settings outrank the
55
- * environment because the installer wrote them as the explicit install-time
56
- * intent (the off tier lands there — the installer cannot write the shell
57
- * environment).
64
+ * Resolves the extension's options: defaults < shared config file < the shared
65
+ * DEEPSEEK_* environment pipeline < the settings namespace. The file's path
66
+ * follows the passed environment (XDG_CONFIG_HOME), so the function stays
67
+ * injectable. Settings outrank both lower layers because the installer wrote
68
+ * them as the explicit install-time intent (the off tier lands there — the
69
+ * installer cannot write the shell environment).
58
70
  */
59
71
  export function resolveExtensionOptions(env: Env = process.env, settings: ExtensionSettings = {}): ResolvedOptions {
60
- const options: Record<string, unknown> = { ...pluginOptionsFromEnv(env) };
72
+ const options: Record<string, unknown> = { ...sharedFileOptions({ env }), ...pluginOptionsFromEnv(env) };
61
73
  if (settings.enableSearch === false) options.searchEnabled = false;
62
74
  if (settings.enableFetch === false) options.fetchEnabled = false;
63
75
  return resolvePluginOptions(options);
@@ -83,9 +95,15 @@ export function searchTool(config: ResolvedOptions, client: RetrievalClient = re
83
95
  outputSchema: SEARCH_OUTPUT,
84
96
  annotations: { readOnlyHint: true, openWorldHint: true },
85
97
  exposure: "direct",
86
- async execute(_toolCallId, params, signal): Promise<AgentToolResult> {
98
+ async execute(_toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult> {
87
99
  try {
88
- const results = await client.searchWeb(params.query, config.search, requestContext(config, signal));
100
+ // The explicit parameter wins; only when the model left it blank does
101
+ // the session fallback get a say.
102
+ const explicit = params.original_question?.trim() ? params.original_question : undefined;
103
+ const results = await client.searchWeb(params.query, config.search, {
104
+ ...requestContext(config, signal),
105
+ oriQuestion: explicit ?? sessionOriQuestion(ctx),
106
+ });
89
107
  return {
90
108
  content: [{ type: "text", text: formatSearchResults(results) }],
91
109
  structuredContent: { results: results.map(toStructuredResult) },
@@ -141,6 +159,11 @@ export default function deepSeekWebRetrieval(pi: ExtensionAPI): void {
141
159
  const config = resolveExtensionOptions(process.env, readExtensionSettings(homedir(), process.cwd()));
142
160
  const precheck = extensionPrecheck(config);
143
161
  if (precheck) throw new Error(precheck);
162
+ // Deviations only, printed once at registration: one stderr line so a
163
+ // multi-layer resolution is visible where Pi surfaces extension output;
164
+ // nothing deviating means no line at all (same contract as the MCP server).
165
+ const deviations = configDeviations(config);
166
+ if (deviations) console.error(`deepseek-web-retrieval config: ${deviations}`);
144
167
  if (config.search.enabled) pi.registerTool(searchTool(config));
145
168
  if (config.fetch.enabled) pi.registerTool(fetchTool(config));
146
169
  }
@@ -149,6 +172,52 @@ function requestContext(config: ResolvedOptions, signal: AbortSignal | undefined
149
172
  return { signal: signal ?? NEVER_ABORTS, auth: { apiKey: requireApiKey() }, retry: config.retry };
150
173
  }
151
174
 
175
+ /**
176
+ * Best-effort `ori_question` fallback for when the model left the tool input
177
+ * blank: the most recent user message text in the session projection, readable
178
+ * synchronously at call time. Best-effort is the contract — Pi's runtime may
179
+ * call `execute` without a context (the fifth argument is typed non-optional
180
+ * but `wrapToolDefinition` passes `ctx ?? ctxFactory?.()`), and any surprise
181
+ * (missing manager, thrown error, odd shape) must degrade to "no fallback"
182
+ * rather than fail the search itself. Length handling stays in the core
183
+ * client, which trims and clamps to the API's 128-char budget.
184
+ */
185
+ function sessionOriQuestion(ctx: ExtensionToolContext | undefined): string | undefined {
186
+ try {
187
+ const messages = ctx?.sessionManager?.buildSessionProjection()?.messages;
188
+ if (!Array.isArray(messages)) return undefined;
189
+ for (let i = messages.length - 1; i >= 0; i--) {
190
+ const text = userMessageText(messages[i]);
191
+ if (text !== undefined) return text;
192
+ }
193
+ return undefined;
194
+ } catch {
195
+ return undefined;
196
+ }
197
+ }
198
+
199
+ /** The trimmed text of a user message's string or `text`-block content, if any. */
200
+ function userMessageText(message: unknown): string | undefined {
201
+ if (typeof message !== "object" || message === null) return undefined;
202
+ const { role, content } = message as { role?: unknown; content?: unknown };
203
+ if (role !== "user") return undefined;
204
+ const text =
205
+ typeof content === "string"
206
+ ? content
207
+ : Array.isArray(content)
208
+ ? content
209
+ .map((part) =>
210
+ typeof part === "object" && part !== null && (part as { type?: unknown }).type === "text"
211
+ ? (part as { text?: unknown }).text
212
+ : undefined,
213
+ )
214
+ .filter((part): part is string => typeof part === "string")
215
+ .join(" ")
216
+ : undefined;
217
+ const trimmed = text?.trim();
218
+ return trimmed ? trimmed : undefined;
219
+ }
220
+
152
221
  function requireApiKey(): string {
153
222
  const key = apiKeyFromEnv();
154
223
  if (key) return key;