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
|
@@ -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
|
-
//
|
|
48
|
-
//
|
|
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
|
|
60
|
+
const excusedRefs = previous === undefined ? [] : [...trackedPackages, input.packageRef];
|
|
61
|
+
const isExcusedRef = lineageMatcher(excusedRefs);
|
|
58
62
|
const foreignOwn = pluginPackages.filter(
|
|
59
|
-
(entry) => looksLikeOwnPackage(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) =>
|
|
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
|
-
//
|
|
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
|
|
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) =>
|
|
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
|
|
94
|
+
const isOwnSource = lineageMatcher([...trackedSources, source]);
|
|
91
95
|
const unexcludable = [...collectSources(loaded.value.packages), ...collectSources(loaded.value.extensions)].filter(
|
|
92
|
-
(entry) => !
|
|
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({
|
|
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)
|
|
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({
|
|
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)}. ` +
|
package/src/installer/paths.ts
CHANGED
|
@@ -7,7 +7,13 @@ export function join(...parts: readonly string[]): string {
|
|
|
7
7
|
return nonEmpty.length === 0 ? "" : pathJoin(...nonEmpty);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
/**
|
|
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
|
}
|
package/src/installer/state.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
/**
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
}
|
package/src/installer/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
4
|
-
//
|
|
5
|
-
// server's dependency graph
|
|
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
|
-
|
|
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,
|
|
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
|
});
|
package/src/opencode/plugin.ts
CHANGED
|
@@ -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
|
-
...(
|
|
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
|
-
|
|
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) => {
|