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
@@ -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({
@@ -124,7 +128,15 @@ function planPiExtension(input: InstallerInput, previous: InstallRecord | undefi
124
128
  previous: typeof namespace.enableFetch === "boolean" ? namespace.enableFetch : undefined,
125
129
  });
126
130
  }
127
- edits.push({ file: paths.config, format: "json", ops });
131
+ edits.push({
132
+ file: paths.config,
133
+ format: "json",
134
+ // Global-scope files live outside the install record's config home, so they
135
+ // need the owner sidecar that stops a second install (another project or
136
+ // XDG_CONFIG_HOME) from claiming them — the codex topology.
137
+ ...(input.scope === "global" ? { shared: true as const } : {}),
138
+ ops,
139
+ });
128
140
 
129
141
  notes.push(
130
142
  "Pi resolves the packages source at session start; the first launch after apply fetches the package from npm " +
@@ -230,7 +242,14 @@ function planMcpCompetitorDisables(input: InstallerInput, notes: string[], error
230
242
  const edits: FileEdit[] = [];
231
243
  if (competitors.length > 0) {
232
244
  const ops = planCompetitorDisables(servers, competitors, notes, errors);
233
- if (ops.length > 0) edits.push({ file: mcpFile, format: "json", ops });
245
+ if (ops.length > 0) {
246
+ edits.push({
247
+ file: mcpFile,
248
+ format: "json",
249
+ ...(input.scope === "global" ? { shared: true as const } : {}),
250
+ ops,
251
+ });
252
+ }
234
253
  }
235
254
  if (bystanders.length > 0) notes.push(othersStayRegisteredNote(bystanders));
236
255
  return edits;
@@ -263,7 +282,13 @@ function planPiMcp(input: InstallerInput): InstallPlan {
263
282
  const servers = loaded.value.mcpServers;
264
283
  const existingEntry = isPlainObject(servers) ? servers.deepseek : undefined;
265
284
  const ops: ConfigOp[] = [{ op: "set", path: ["mcpServers", "deepseek"], value: entry, previous: existingEntry }];
266
- edits.push({ file: paths.config, format: "json", ops });
285
+ edits.push({
286
+ file: paths.config,
287
+ format: "json",
288
+ // Same shared-file reason as the extension path's settings.json above.
289
+ ...(input.scope === "global" ? { shared: true as const } : {}),
290
+ ops,
291
+ });
267
292
 
268
293
  if (isPlainObject(servers)) {
269
294
  if (servers.deepseek !== undefined) notes.push(replacingExistingEntryNote("mcpServers.deepseek"));
@@ -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", [
@@ -23,7 +49,7 @@ const operation = z.discriminatedUnion("op", [
23
49
  const recordSchema = z.object({
24
50
  version: z.literal(1),
25
51
  installerVersion: z.string().optional(),
26
- host: z.enum(["opencode", "pi", "codex"]),
52
+ host: z.enum(["opencode", "pi", "codex", "claude-code"]),
27
53
  scope: z.enum(["global", "project"]),
28
54
  tiers: z.object({ search: tier, fetch: tier }),
29
55
  createdAt: z.string().refine((value) => Number.isFinite(Date.parse(value))),
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  /** Hosts the installer supports. */
2
- export type HostName = "opencode" | "pi" | "codex";
2
+ export type HostName = "opencode" | "pi" | "codex" | "claude-code";
3
3
 
4
4
  /** Install scope, a first-class user choice. */
5
5
  export type Scope = "project" | "global";
@@ -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.
@@ -48,8 +48,11 @@ function uninstallLocked(input: InstallerInput, record: InstallRecord): string[]
48
48
  for (const file of [...record.files].reverse()) {
49
49
  lines.push(...revertRecordedFile(file, record.host, { label: "", whole: true }));
50
50
  }
51
- deleteRecord(input);
51
+ // Release claims before deleting the record: releaseClaim re-asserts lineage
52
+ // through recordReadPath, whose legacy win32 fallback needs the record file
53
+ // to still exist on disk.
52
54
  releaseFileOwnership(input, record.files);
55
+ deleteRecord(input);
53
56
  pruneEmptyDir(stateDir(input, input.scope));
54
57
  lines.push(`removed install record (${input.host}, ${input.scope})`);
55
58
  return lines;
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) => {