deepseek-web-retrieval 0.7.0 → 0.9.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 +20 -0
- package/README.md +100 -101
- package/dist/cli.js +526 -175
- package/docs/agents/claude-code.md +279 -0
- package/docs/agents/codex.md +9 -1
- package/docs/agents/opencode-v2.md +13 -3
- package/docs/agents/pi.md +8 -1
- package/docs/configuration.md +116 -0
- package/docs/design/installer-design.md +20 -17
- package/docs/hosts.jsonc +5 -1
- package/docs/integration.md +9 -6
- package/docs/research/control-matrix.md +34 -9
- package/docs/research/mcp-compatibility.md +2 -2
- package/docs/research/ori-question-sourcing.md +12 -1
- package/package.json +3 -2
- package/src/core/auth.ts +7 -0
- package/src/core/client.ts +89 -25
- package/src/core/config-summary.ts +1 -0
- package/src/core/constants.ts +17 -2
- package/src/core/descriptions.ts +10 -1
- package/src/core/env.ts +12 -7
- package/src/core/format.ts +25 -4
- package/src/core/options.ts +47 -0
- package/src/core/search.ts +97 -14
- package/src/core/types.ts +17 -0
- package/src/installer/apply.ts +4 -4
- package/src/installer/cli.ts +2 -2
- package/src/installer/config-command.ts +4 -3
- package/src/installer/hosts/claude-code.ts +311 -0
- package/src/installer/hosts/codex.ts +3 -14
- package/src/installer/hosts/index.ts +5 -2
- package/src/installer/hosts/opencode.ts +1 -1
- package/src/installer/hosts/pi.ts +38 -18
- package/src/installer/hosts/shared.ts +23 -0
- package/src/installer/ownership.ts +2 -2
- package/src/installer/paths.ts +2 -2
- package/src/installer/{state.ts → record.ts} +16 -16
- package/src/installer/types.ts +2 -2
- package/src/installer/uninstall.ts +8 -5
- package/src/mcp/serve.ts +1 -2
- package/src/mcp/server.ts +16 -19
- package/src/opencode/plugin.ts +15 -10
- package/src/pi/extension.ts +23 -17
- package/src/mcp/auth.ts +0 -11
|
@@ -13,8 +13,8 @@ import { buildRulesBlock } from "../formats/rules";
|
|
|
13
13
|
import { isPlainObject } from "../formats/value";
|
|
14
14
|
import { lineageMatcher } from "../ownership";
|
|
15
15
|
import { join } from "../paths";
|
|
16
|
+
import { appendedByPrevious } from "../record";
|
|
16
17
|
import { MCP_TOOL_IDS, RAW_MCP_TOOL_IDS, rulesLines } from "../rulesContent";
|
|
17
|
-
import { appendedByPrevious } from "../state";
|
|
18
18
|
import type { ConfigOp, FileEdit, HostPaths, InstallerInput, InstallPlan, InstallRecord, PlanError } from "../types";
|
|
19
19
|
import { planHeader } from "./shared";
|
|
20
20
|
|
|
@@ -37,6 +37,15 @@ function piSourceRef(packageRef: string): string {
|
|
|
37
37
|
return packageRef.startsWith("npm:") ? packageRef : `npm:${packageRef}`;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** Both Pi paths write the same rules note; one wording so the paths cannot drift. */
|
|
41
|
+
const rulesUpsertNote = (rules: string) =>
|
|
42
|
+
`Rules block upserted in ${rules}; Pi loads AGENTS.md context files from the working directory and its parents.`;
|
|
43
|
+
|
|
44
|
+
/** Forced tier, both paths: with no built-in web tool to displace, routing is de-facto exclusive. */
|
|
45
|
+
const FORCED_NO_BUILTIN_NOTE =
|
|
46
|
+
"Forced tier: Pi 1.3.0 ships no built-in web tool, so the registered tools are the only web path (de-facto). " +
|
|
47
|
+
FORCED_COST_NOTE;
|
|
48
|
+
|
|
40
49
|
export function planPi(input: InstallerInput, previous: InstallRecord | undefined): InstallPlan {
|
|
41
50
|
return input.path === "mcp" ? planPiMcp(input) : planPiExtension(input, previous);
|
|
42
51
|
}
|
|
@@ -128,7 +137,15 @@ function planPiExtension(input: InstallerInput, previous: InstallRecord | undefi
|
|
|
128
137
|
previous: typeof namespace.enableFetch === "boolean" ? namespace.enableFetch : undefined,
|
|
129
138
|
});
|
|
130
139
|
}
|
|
131
|
-
edits.push({
|
|
140
|
+
edits.push({
|
|
141
|
+
file: paths.config,
|
|
142
|
+
format: "json",
|
|
143
|
+
// Global-scope files live outside the install record's config home, so they
|
|
144
|
+
// need the owner sidecar that stops a second install (another project or
|
|
145
|
+
// XDG_CONFIG_HOME) from claiming them — the codex topology.
|
|
146
|
+
...(input.scope === "global" ? { shared: true as const } : {}),
|
|
147
|
+
ops,
|
|
148
|
+
});
|
|
132
149
|
|
|
133
150
|
notes.push(
|
|
134
151
|
"Pi resolves the packages source at session start; the first launch after apply fetches the package from npm " +
|
|
@@ -161,16 +178,11 @@ function planPiExtension(input: InstallerInput, previous: InstallRecord | undefi
|
|
|
161
178
|
format: "rules",
|
|
162
179
|
block: buildRulesBlock(input.host, rulesLines(RAW_MCP_TOOL_IDS, input.search, input.fetch)),
|
|
163
180
|
});
|
|
164
|
-
notes.push(
|
|
165
|
-
`Rules block upserted in ${paths.rules}; Pi loads AGENTS.md context files from the working directory and its parents.`,
|
|
166
|
-
);
|
|
181
|
+
notes.push(rulesUpsertNote(paths.rules));
|
|
167
182
|
}
|
|
168
183
|
|
|
169
184
|
if (forced) {
|
|
170
|
-
notes.push(
|
|
171
|
-
"Forced tier: Pi 1.3.0 ships no built-in web tool, so the registered tools are the only web path (de-facto). " +
|
|
172
|
-
FORCED_COST_NOTE,
|
|
173
|
-
);
|
|
185
|
+
notes.push(FORCED_NO_BUILTIN_NOTE);
|
|
174
186
|
}
|
|
175
187
|
|
|
176
188
|
return { host: "pi", scope: input.scope, tiers, edits, notes, errors };
|
|
@@ -234,7 +246,14 @@ function planMcpCompetitorDisables(input: InstallerInput, notes: string[], error
|
|
|
234
246
|
const edits: FileEdit[] = [];
|
|
235
247
|
if (competitors.length > 0) {
|
|
236
248
|
const ops = planCompetitorDisables(servers, competitors, notes, errors);
|
|
237
|
-
if (ops.length > 0)
|
|
249
|
+
if (ops.length > 0) {
|
|
250
|
+
edits.push({
|
|
251
|
+
file: mcpFile,
|
|
252
|
+
format: "json",
|
|
253
|
+
...(input.scope === "global" ? { shared: true as const } : {}),
|
|
254
|
+
ops,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
238
257
|
}
|
|
239
258
|
if (bystanders.length > 0) notes.push(othersStayRegisteredNote(bystanders));
|
|
240
259
|
return edits;
|
|
@@ -267,7 +286,13 @@ function planPiMcp(input: InstallerInput): InstallPlan {
|
|
|
267
286
|
const servers = loaded.value.mcpServers;
|
|
268
287
|
const existingEntry = isPlainObject(servers) ? servers.deepseek : undefined;
|
|
269
288
|
const ops: ConfigOp[] = [{ op: "set", path: ["mcpServers", "deepseek"], value: entry, previous: existingEntry }];
|
|
270
|
-
edits.push({
|
|
289
|
+
edits.push({
|
|
290
|
+
file: paths.config,
|
|
291
|
+
format: "json",
|
|
292
|
+
// Same shared-file reason as the extension path's settings.json above.
|
|
293
|
+
...(input.scope === "global" ? { shared: true as const } : {}),
|
|
294
|
+
ops,
|
|
295
|
+
});
|
|
271
296
|
|
|
272
297
|
if (isPlainObject(servers)) {
|
|
273
298
|
if (servers.deepseek !== undefined) notes.push(replacingExistingEntryNote("mcpServers.deepseek"));
|
|
@@ -292,16 +317,11 @@ function planPiMcp(input: InstallerInput): InstallPlan {
|
|
|
292
317
|
format: "rules",
|
|
293
318
|
block: buildRulesBlock(input.host, rulesLines(MCP_TOOL_IDS, input.search, input.fetch)),
|
|
294
319
|
});
|
|
295
|
-
notes.push(
|
|
296
|
-
`Rules block upserted in ${paths.rules}; Pi loads AGENTS.md context files from the working directory and its parents.`,
|
|
297
|
-
);
|
|
320
|
+
notes.push(rulesUpsertNote(paths.rules));
|
|
298
321
|
}
|
|
299
322
|
|
|
300
323
|
if (input.search === "forced" || input.fetch === "forced") {
|
|
301
|
-
notes.push(
|
|
302
|
-
"Forced tier: Pi 1.3.0 ships no built-in web tool, so the registered tools are the only web path (de-facto). " +
|
|
303
|
-
FORCED_COST_NOTE,
|
|
304
|
-
);
|
|
324
|
+
notes.push(FORCED_NO_BUILTIN_NOTE);
|
|
305
325
|
}
|
|
306
326
|
|
|
307
327
|
return { host: "pi", scope: input.scope, tiers, edits, notes, errors };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import { BOTH_CAPABILITIES_OFF, competitorSteeringNote, findCompetitorSteering } from "../detect";
|
|
2
3
|
import { codecFor, type LoadedConfig } from "../formats";
|
|
3
4
|
import type { HostPaths, InstallerInput, PlanError } from "../types";
|
|
@@ -34,3 +35,25 @@ export function planHeader(input: InstallerInput, paths: HostPaths, loaded: Load
|
|
|
34
35
|
if (steering.length > 0) notes.push(competitorSteeringNote(paths.rules, steering));
|
|
35
36
|
return { notes, errors, abort: false };
|
|
36
37
|
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The one subprocess shape the installer runs: `<command> --version`, probed
|
|
41
|
+
* for the version-drift note. A missing binary or unreadable output skips the
|
|
42
|
+
* check with a stderr note and never blocks the plan — honor or report.
|
|
43
|
+
* env is passed explicitly: runtime PATH edits (tests) must reach the lookup.
|
|
44
|
+
* The shell hop is required because npm's global install on Windows provides
|
|
45
|
+
* only a .cmd shim, which spawnSync cannot launch without a shell; the command
|
|
46
|
+
* and args are hardcoded, so it is safe.
|
|
47
|
+
*/
|
|
48
|
+
export function probeHostVersion(
|
|
49
|
+
command: string,
|
|
50
|
+
parse: (output: string) => string | undefined,
|
|
51
|
+
err: (line: string) => void,
|
|
52
|
+
): string | undefined {
|
|
53
|
+
const result = spawnSync(command, ["--version"], { timeout: 5000, encoding: "utf8", env: process.env, shell: true });
|
|
54
|
+
const version = result.status === 0 ? parse(result.stdout ?? "") : undefined;
|
|
55
|
+
if (version === undefined) {
|
|
56
|
+
err(`note: ${command} was not found on PATH or printed no version; the version-drift check was skipped`);
|
|
57
|
+
}
|
|
58
|
+
return version;
|
|
59
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { isPlainObject } from "../core/object";
|
|
4
|
+
import { recordPathFor, recordReadPath } from "./record";
|
|
4
5
|
import { pruneEmptyDir } from "./revert";
|
|
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">;
|
|
@@ -70,7 +70,7 @@ export function canonicalPath(file: string): string {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
function ownerOf(input: InstallerInput): FileOwner {
|
|
73
|
-
return { recordPath: canonicalPath(
|
|
73
|
+
return { recordPath: canonicalPath(recordPathFor(input)), scope: input.scope, cwd: canonicalPath(input.cwd) };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/**
|
package/src/installer/paths.ts
CHANGED
|
@@ -18,8 +18,8 @@ export function configHome(roots: FsRoots): string {
|
|
|
18
18
|
return roots.configHome ?? join(roots.home, ".config");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
export function
|
|
21
|
+
/** Install records live with their scope: global installs under the config home, project installs in the checkout. */
|
|
22
|
+
export function recordDir(roots: FsRoots, scope: Scope): string {
|
|
23
23
|
return scope === "global"
|
|
24
24
|
? join(configHome(roots), "deepseek-web-retrieval")
|
|
25
25
|
: join(roots.cwd, ".deepseek-web-retrieval");
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
3
|
import { z } from "zod/v4";
|
|
4
|
-
import {
|
|
4
|
+
import { recordDir } from "./paths";
|
|
5
5
|
import { atomicWrite } from "./persistence";
|
|
6
6
|
import type { FsRoots, InstallerInput, InstallRecord, Scope } from "./types";
|
|
7
7
|
|
|
8
|
-
export function
|
|
9
|
-
return join(
|
|
8
|
+
export function recordPathFor(input: InstallerInput): string {
|
|
9
|
+
return join(recordDir(input, input.scope), `install.${input.host}.json`);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/** The platform a read resolves for: injected at the CLI edge, process.platform otherwise. */
|
|
@@ -19,19 +19,19 @@ function platformOf(roots: FsRoots): NodeJS.Platform {
|
|
|
19
19
|
* home moved to %APPDATA% live under `~/.config`. Kept readable so `list` and
|
|
20
20
|
* `uninstall` keep tracking installs made by older releases.
|
|
21
21
|
*/
|
|
22
|
-
export function
|
|
22
|
+
export function legacyGlobalRecordDir(roots: FsRoots): string {
|
|
23
23
|
return join(roots.home, ".config", "deepseek-web-retrieval");
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* The file a record is read from: the canonical
|
|
27
|
+
* The file a record is read from: the canonical record path, except that a
|
|
28
28
|
* missing win32 global record falls back to the legacy `~/.config` location
|
|
29
|
-
* (see
|
|
29
|
+
* (see legacyGlobalRecordDir). Writes always target the canonical path.
|
|
30
30
|
*/
|
|
31
31
|
export function recordReadPath(input: InstallerInput, platform: NodeJS.Platform = platformOf(input)): string {
|
|
32
|
-
const canonical =
|
|
32
|
+
const canonical = recordPathFor(input);
|
|
33
33
|
if (platform !== "win32" || input.scope !== "global" || existsSync(canonical)) return canonical;
|
|
34
|
-
const legacy = join(
|
|
34
|
+
const legacy = join(legacyGlobalRecordDir(input), `install.${input.host}.json`);
|
|
35
35
|
return existsSync(legacy) ? legacy : canonical;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -49,7 +49,7 @@ const operation = z.discriminatedUnion("op", [
|
|
|
49
49
|
const recordSchema = z.object({
|
|
50
50
|
version: z.literal(1),
|
|
51
51
|
installerVersion: z.string().optional(),
|
|
52
|
-
host: z.enum(["opencode", "pi", "codex"]),
|
|
52
|
+
host: z.enum(["opencode", "pi", "codex", "claude-code"]),
|
|
53
53
|
scope: z.enum(["global", "project"]),
|
|
54
54
|
tiers: z.object({ search: tier, fetch: tier }),
|
|
55
55
|
createdAt: z.string().refine((value) => Number.isFinite(Date.parse(value))),
|
|
@@ -74,7 +74,7 @@ export class InstallRecordError extends Error {
|
|
|
74
74
|
) {
|
|
75
75
|
super(
|
|
76
76
|
`${kind === "invalid" ? "Invalid install record" : "Cannot read install record"} at ${file} (${detail}). ` +
|
|
77
|
-
"Restore a valid record from
|
|
77
|
+
"Restore a valid install record from your own copy or repair its permissions before retrying; configuration was not changed. Do not delete the install record to bypass this check.",
|
|
78
78
|
);
|
|
79
79
|
this.name = "InstallRecordError";
|
|
80
80
|
}
|
|
@@ -131,7 +131,7 @@ export function claimedByOtherScope(input: InstallerInput, files: readonly strin
|
|
|
131
131
|
* owner-only permissions regardless of umask.
|
|
132
132
|
*/
|
|
133
133
|
export function writeRecord(input: InstallerInput, record: InstallRecord): void {
|
|
134
|
-
const file =
|
|
134
|
+
const file = recordPathFor(input);
|
|
135
135
|
atomicWrite(file, `${JSON.stringify(record, null, 2)}\n`, 0o600);
|
|
136
136
|
}
|
|
137
137
|
|
|
@@ -189,14 +189,14 @@ function hasRecordFiles(dir: string): boolean {
|
|
|
189
189
|
*/
|
|
190
190
|
export function readingLegacyGlobalRecords(roots: FsRoots): boolean {
|
|
191
191
|
if (platformOf(roots) !== "win32") return false;
|
|
192
|
-
if (hasRecordFiles(
|
|
193
|
-
return hasRecordFiles(
|
|
192
|
+
if (hasRecordFiles(recordDir(roots, "global"))) return false;
|
|
193
|
+
return hasRecordFiles(legacyGlobalRecordDir(roots));
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
/** Lists install records found in both scope locations for the given roots. */
|
|
197
197
|
export function listRecords(roots: FsRoots): InstallRecord[] {
|
|
198
|
-
const global = readRecordsFromDir(
|
|
199
|
-
const project = readRecordsFromDir(
|
|
200
|
-
const legacy = readingLegacyGlobalRecords(roots) ? readRecordsFromDir(
|
|
198
|
+
const global = readRecordsFromDir(recordDir(roots, "global"), "global");
|
|
199
|
+
const project = readRecordsFromDir(recordDir(roots, "project"), "project");
|
|
200
|
+
const legacy = readingLegacyGlobalRecords(roots) ? readRecordsFromDir(legacyGlobalRecordDir(roots), "global") : [];
|
|
201
201
|
return [...global, ...legacy, ...project];
|
|
202
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";
|
|
@@ -105,7 +105,7 @@ export interface PlanError {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export interface InstallPlan {
|
|
108
|
-
/**
|
|
108
|
+
/** The plan-time install record; apply rejects a plan superseded by another operation. */
|
|
109
109
|
readonly previousRecord?: InstallRecord;
|
|
110
110
|
readonly host: HostName;
|
|
111
111
|
readonly scope: Scope;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { codecFor } from "./formats";
|
|
2
2
|
import { withOperationLock } from "./operation-lock";
|
|
3
3
|
import { assertFileOwnership, releaseFileOwnership } from "./ownership";
|
|
4
|
-
import {
|
|
4
|
+
import { recordDir } from "./paths";
|
|
5
|
+
import { claimedByOtherScope, deleteRecord, readRecord, recordPathFor } from "./record";
|
|
5
6
|
import { pruneEmptyDir, revertRecordedFile } from "./revert";
|
|
6
|
-
import { claimedByOtherScope, deleteRecord, readRecord, statePathFor } from "./state";
|
|
7
7
|
import type { InstallerInput, InstallRecord } from "./types";
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -13,7 +13,7 @@ import type { InstallerInput, InstallRecord } from "./types";
|
|
|
13
13
|
* rules blocks are removed, and files the installer created are deleted.
|
|
14
14
|
*/
|
|
15
15
|
export function uninstall(input: InstallerInput): string[] {
|
|
16
|
-
return withOperationLock([
|
|
16
|
+
return withOperationLock([recordPathFor(input)], () => {
|
|
17
17
|
const record = readRecord(input);
|
|
18
18
|
if (record === undefined) {
|
|
19
19
|
throw new Error(`No ${input.host} install is recorded for the ${input.scope} scope.`);
|
|
@@ -48,9 +48,12 @@ 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);
|
|
53
|
-
|
|
55
|
+
deleteRecord(input);
|
|
56
|
+
pruneEmptyDir(recordDir(input, input.scope));
|
|
54
57
|
lines.push(`removed install record (${input.host}, ${input.scope})`);
|
|
55
58
|
return lines;
|
|
56
59
|
}
|
package/src/mcp/serve.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
2
|
-
import { apiKeyFromEnv } from "../core/auth";
|
|
2
|
+
import { apiKeyFromEnv, MISSING_API_KEY_MESSAGE } from "../core/auth";
|
|
3
3
|
import { sharedFileOptions } from "../core/config-file";
|
|
4
4
|
import { configDeviations } from "../core/config-summary";
|
|
5
5
|
import { resolveEnvOptions } from "../core/env";
|
|
6
|
-
import { MISSING_API_KEY_MESSAGE } from "./auth";
|
|
7
6
|
import { isJsonReplacingClient } from "./client";
|
|
8
7
|
import { createDeepSeekServer, servePrecheck } from "./server";
|
|
9
8
|
|
package/src/mcp/server.ts
CHANGED
|
@@ -2,20 +2,23 @@ import type { CallToolResult, ServerContext } from "@modelcontextprotocol/server
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
3
3
|
import * as z from "zod/v4";
|
|
4
4
|
import pkg from "../../package.json";
|
|
5
|
+
import { requireApiKey } from "../core/auth";
|
|
5
6
|
import type { RetrievalClient } from "../core/client";
|
|
6
|
-
import { retrievalClient } from "../core/client";
|
|
7
|
-
import { ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV } from "../core/constants";
|
|
7
|
+
import { requestContext, retrievalClient } from "../core/client";
|
|
8
|
+
import { ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV, MAX_FETCH_CHARS, MAX_SEARCH_COUNT } from "../core/constants";
|
|
8
9
|
import {
|
|
10
|
+
FETCH_MAX_CHARS_PARAMETER,
|
|
9
11
|
FETCH_TOOL_DESCRIPTION,
|
|
10
12
|
FETCH_URL_PARAMETER,
|
|
13
|
+
SEARCH_COUNT_PARAMETER,
|
|
11
14
|
SEARCH_ORI_QUESTION_PARAMETER,
|
|
12
15
|
SEARCH_QUERY_PARAMETER,
|
|
13
16
|
SEARCH_TOOL_DESCRIPTION,
|
|
14
17
|
} from "../core/descriptions";
|
|
15
18
|
import type { Env } from "../core/env";
|
|
16
19
|
import { formatSearchResults, toStructuredResult } from "../core/format";
|
|
20
|
+
import { bothDisabledPrecheck, withPerCallCount } from "../core/options";
|
|
17
21
|
import type { ResolvedOptions } from "../core/types";
|
|
18
|
-
import { requireApiKey } from "./auth";
|
|
19
22
|
import { omitStructuredContent } from "./client";
|
|
20
23
|
|
|
21
24
|
export const MCP_SERVER_NAME = "deepseek-web-retrieval";
|
|
@@ -23,10 +26,12 @@ export const MCP_SERVER_NAME = "deepseek-web-retrieval";
|
|
|
23
26
|
const SEARCH_INPUT = z.object({
|
|
24
27
|
query: z.string().min(1).describe(SEARCH_QUERY_PARAMETER),
|
|
25
28
|
original_question: z.string().optional().describe(SEARCH_ORI_QUESTION_PARAMETER),
|
|
29
|
+
count: z.number().int().min(1).max(MAX_SEARCH_COUNT).optional().describe(SEARCH_COUNT_PARAMETER),
|
|
26
30
|
});
|
|
27
31
|
|
|
28
32
|
const FETCH_INPUT = z.object({
|
|
29
33
|
url: z.string().min(1).describe(FETCH_URL_PARAMETER),
|
|
34
|
+
maxChars: z.number().int().min(0).max(MAX_FETCH_CHARS).optional().describe(FETCH_MAX_CHARS_PARAMETER),
|
|
30
35
|
});
|
|
31
36
|
|
|
32
37
|
/**
|
|
@@ -49,15 +54,15 @@ export function createDeepSeekServer(
|
|
|
49
54
|
inputSchema: SEARCH_INPUT,
|
|
50
55
|
annotations: { readOnlyHint: true },
|
|
51
56
|
},
|
|
52
|
-
async ({ query, original_question }, ctx) => {
|
|
57
|
+
async ({ query, original_question, count }, ctx) => {
|
|
53
58
|
try {
|
|
54
|
-
const request = requestContext(config, env, ctx.mcpReq.signal);
|
|
55
|
-
const
|
|
59
|
+
const request = requestContext(config, requireApiKey(env), ctx.mcpReq.signal);
|
|
60
|
+
const outcome = await client.searchWeb(query, withPerCallCount(config.search, count), {
|
|
56
61
|
...request,
|
|
57
62
|
oriQuestion: original_question,
|
|
58
63
|
});
|
|
59
|
-
return callResult(server, ctx, formatSearchResults(results), {
|
|
60
|
-
results: results.map(toStructuredResult),
|
|
64
|
+
return callResult(server, ctx, formatSearchResults(outcome.results, outcome), {
|
|
65
|
+
results: outcome.results.map(toStructuredResult),
|
|
61
66
|
});
|
|
62
67
|
} catch (error) {
|
|
63
68
|
return toErrorResult(error);
|
|
@@ -75,9 +80,9 @@ export function createDeepSeekServer(
|
|
|
75
80
|
inputSchema: FETCH_INPUT,
|
|
76
81
|
annotations: { readOnlyHint: true },
|
|
77
82
|
},
|
|
78
|
-
async ({ url }, ctx) => {
|
|
83
|
+
async ({ url, maxChars }, ctx) => {
|
|
79
84
|
try {
|
|
80
|
-
const request = requestContext(config, env, ctx.mcpReq.signal);
|
|
85
|
+
const request = requestContext(config, requireApiKey(env), ctx.mcpReq.signal, { maxChars });
|
|
81
86
|
const page = await client.fetchDocument(url, config.fetch, request);
|
|
82
87
|
return callResult(server, ctx, page.content, {
|
|
83
88
|
url: page.url,
|
|
@@ -95,10 +100,6 @@ export function createDeepSeekServer(
|
|
|
95
100
|
return server;
|
|
96
101
|
}
|
|
97
102
|
|
|
98
|
-
function requestContext(config: ResolvedOptions, env: Env, signal: AbortSignal) {
|
|
99
|
-
return { signal, auth: { apiKey: requireApiKey(env) }, retry: config.retry };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
103
|
/**
|
|
103
104
|
* Text content always reaches the model; the structured mirror is dropped for
|
|
104
105
|
* clients that would replace the text with its serialized JSON (Codex does).
|
|
@@ -118,11 +119,7 @@ function callResult(
|
|
|
118
119
|
|
|
119
120
|
/** Returns the both-disabled message, or undefined when a tool stays enabled. */
|
|
120
121
|
export function servePrecheck(config: ResolvedOptions): string | undefined {
|
|
121
|
-
|
|
122
|
-
return (
|
|
123
|
-
`Both tools are disabled (${ENABLE_SEARCH_ENV} and ${ENABLE_FETCH_ENV} are false); ` +
|
|
124
|
-
"at least one must stay enabled."
|
|
125
|
-
);
|
|
122
|
+
return bothDisabledPrecheck(config, `${ENABLE_SEARCH_ENV} and ${ENABLE_FETCH_ENV} are false`);
|
|
126
123
|
}
|
|
127
124
|
|
|
128
125
|
function toErrorResult(error: unknown): CallToolResult {
|
package/src/opencode/plugin.ts
CHANGED
|
@@ -2,10 +2,10 @@ import { Plugin } from "@opencode/plugin";
|
|
|
2
2
|
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
|
-
import { inspectFetchTarget, retrievalClient } from "../core/client";
|
|
5
|
+
import { inspectFetchTarget, requestContext, retrievalClient } from "../core/client";
|
|
6
6
|
import { sharedFileOptions } from "../core/config-file";
|
|
7
7
|
import { configDeviations } from "../core/config-summary";
|
|
8
|
-
import { FETCH_MARKDOWN_ONLY, FETCH_OUTPUT_TRUNCATED } from "../core/descriptions";
|
|
8
|
+
import { FETCH_MARKDOWN_ONLY, FETCH_MAX_CHARS_PARAMETER, FETCH_OUTPUT_TRUNCATED } from "../core/descriptions";
|
|
9
9
|
import { pluginOptionsFromEnv } from "../core/env";
|
|
10
10
|
import { formatQualityMarker } from "../core/format";
|
|
11
11
|
import { resolveFetchTimeoutMs, resolvePluginOptions } from "../core/options";
|
|
@@ -16,6 +16,7 @@ type FetchInput = {
|
|
|
16
16
|
readonly url: string;
|
|
17
17
|
readonly format?: "markdown" | "text" | "html";
|
|
18
18
|
readonly timeout?: number;
|
|
19
|
+
readonly maxChars?: number;
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
const FETCH_INPUT = {
|
|
@@ -38,6 +39,12 @@ const FETCH_INPUT = {
|
|
|
38
39
|
description:
|
|
39
40
|
"Optional timeout in seconds (maximum: 120). The effective limit is the shorter of this and fetchTimeoutMs.",
|
|
40
41
|
},
|
|
42
|
+
maxChars: {
|
|
43
|
+
type: "integer",
|
|
44
|
+
minimum: 0,
|
|
45
|
+
maximum: 1_000_000,
|
|
46
|
+
description: FETCH_MAX_CHARS_PARAMETER,
|
|
47
|
+
},
|
|
41
48
|
},
|
|
42
49
|
required: ["url"],
|
|
43
50
|
additionalProperties: false,
|
|
@@ -89,11 +96,6 @@ export function resolveRegistration(input?: PluginOptions): Registration {
|
|
|
89
96
|
};
|
|
90
97
|
}
|
|
91
98
|
|
|
92
|
-
/** One place builds the per-request context the core expects. */
|
|
93
|
-
function requestContext(config: ResolvedOptions, apiKey: string, signal: AbortSignal, timeoutMs?: number) {
|
|
94
|
-
return { signal, auth: { apiKey }, retry: config.retry, ...(timeoutMs === undefined ? {} : { timeoutMs }) };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
99
|
async function runFetch(
|
|
98
100
|
ctx: Context,
|
|
99
101
|
config: ResolvedOptions,
|
|
@@ -135,7 +137,7 @@ async function runFetch(
|
|
|
135
137
|
const page = await client.fetchDocument(
|
|
136
138
|
target,
|
|
137
139
|
config.fetch,
|
|
138
|
-
requestContext(config, apiKey, context.signal, timeoutMs),
|
|
140
|
+
requestContext(config, apiKey, context.signal, { timeoutMs, maxChars: input.maxChars }),
|
|
139
141
|
);
|
|
140
142
|
return {
|
|
141
143
|
content: page.content,
|
|
@@ -173,12 +175,15 @@ export function createOpenCodePlugin(client: RetrievalClient = retrievalClient)
|
|
|
173
175
|
name: "DeepSeek",
|
|
174
176
|
execute: async (input, { signal }) => {
|
|
175
177
|
const apiKey = await resolveApiKey(ctx, config.auth.source);
|
|
176
|
-
const
|
|
178
|
+
const outcome = await client.searchWeb(
|
|
177
179
|
input.query,
|
|
178
180
|
config.search,
|
|
179
181
|
requestContext(config, apiKey, signal),
|
|
180
182
|
);
|
|
181
|
-
|
|
183
|
+
// OpenCode's result shape is closed, so the effective-query header
|
|
184
|
+
// has nowhere to ride on this path (documented in the guide);
|
|
185
|
+
// degradation notes still surface through thrown error messages.
|
|
186
|
+
return outcome.results.map(toOpenCodeResult);
|
|
182
187
|
},
|
|
183
188
|
});
|
|
184
189
|
if (registration.setDefault) editor.default.set("deepseek");
|
package/src/pi/extension.ts
CHANGED
|
@@ -3,14 +3,16 @@ import { Type } from "@earendil-works/pi-ai";
|
|
|
3
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
|
-
import type {
|
|
7
|
-
import { inspectFetchTarget, retrievalClient } from "../core/client";
|
|
6
|
+
import type { RetrievalClient } from "../core/client";
|
|
7
|
+
import { inspectFetchTarget, requestContext, retrievalClient } from "../core/client";
|
|
8
8
|
import { sharedFileOptions } from "../core/config-file";
|
|
9
9
|
import { configDeviations } from "../core/config-summary";
|
|
10
|
-
import { API_KEY_ENV, ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV } from "../core/constants";
|
|
10
|
+
import { API_KEY_ENV, ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV, MAX_FETCH_CHARS, MAX_SEARCH_COUNT } from "../core/constants";
|
|
11
11
|
import {
|
|
12
|
+
FETCH_MAX_CHARS_PARAMETER,
|
|
12
13
|
FETCH_TOOL_DESCRIPTION,
|
|
13
14
|
FETCH_URL_PARAMETER,
|
|
15
|
+
SEARCH_COUNT_PARAMETER,
|
|
14
16
|
SEARCH_ORI_QUESTION_PARAMETER,
|
|
15
17
|
SEARCH_QUERY_PARAMETER,
|
|
16
18
|
SEARCH_TOOL_DESCRIPTION,
|
|
@@ -18,7 +20,7 @@ import {
|
|
|
18
20
|
import type { Env } from "../core/env";
|
|
19
21
|
import { pluginOptionsFromEnv } from "../core/env";
|
|
20
22
|
import { formatSearchResults, toStructuredResult } from "../core/format";
|
|
21
|
-
import { resolvePluginOptions } from "../core/options";
|
|
23
|
+
import { bothDisabledPrecheck, resolvePluginOptions, withPerCallCount } from "../core/options";
|
|
22
24
|
import type { ResolvedOptions } from "../core/types";
|
|
23
25
|
import type { ExtensionSettings } from "./settings";
|
|
24
26
|
import { readExtensionSettings, SETTINGS_NAMESPACE } from "./settings";
|
|
@@ -30,6 +32,7 @@ const SEARCH_PARAMETERS = Type.Object({
|
|
|
30
32
|
description: `${SEARCH_ORI_QUESTION_PARAMETER} When left blank, the most recent user message in the session is sent instead, truncated to fit.`,
|
|
31
33
|
}),
|
|
32
34
|
),
|
|
35
|
+
count: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_SEARCH_COUNT, description: SEARCH_COUNT_PARAMETER })),
|
|
33
36
|
});
|
|
34
37
|
|
|
35
38
|
const SEARCH_OUTPUT = Type.Object({
|
|
@@ -48,6 +51,9 @@ const SEARCH_OUTPUT = Type.Object({
|
|
|
48
51
|
|
|
49
52
|
const FETCH_PARAMETERS = Type.Object({
|
|
50
53
|
url: Type.String({ minLength: 1, description: FETCH_URL_PARAMETER }),
|
|
54
|
+
maxChars: Type.Optional(
|
|
55
|
+
Type.Integer({ minimum: 0, maximum: MAX_FETCH_CHARS, description: FETCH_MAX_CHARS_PARAMETER }),
|
|
56
|
+
),
|
|
51
57
|
});
|
|
52
58
|
|
|
53
59
|
const FETCH_OUTPUT = Type.Object({
|
|
@@ -77,10 +83,10 @@ export function resolveExtensionOptions(env: Env = process.env, settings: Extens
|
|
|
77
83
|
|
|
78
84
|
/** Returns the both-disabled message, or undefined when a tool stays enabled. */
|
|
79
85
|
export function extensionPrecheck(config: ResolvedOptions): string | undefined {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
return bothDisabledPrecheck(
|
|
87
|
+
config,
|
|
88
|
+
`${ENABLE_SEARCH_ENV} and ${ENABLE_FETCH_ENV} are false, or the ` +
|
|
89
|
+
`${SETTINGS_NAMESPACE} settings namespace disabled them`,
|
|
84
90
|
);
|
|
85
91
|
}
|
|
86
92
|
|
|
@@ -100,13 +106,13 @@ export function searchTool(config: ResolvedOptions, client: RetrievalClient = re
|
|
|
100
106
|
// The explicit parameter wins; only when the model left it blank does
|
|
101
107
|
// the session fallback get a say.
|
|
102
108
|
const explicit = params.original_question?.trim() ? params.original_question : undefined;
|
|
103
|
-
const
|
|
104
|
-
...requestContext(config, signal),
|
|
109
|
+
const outcome = await client.searchWeb(params.query, withPerCallCount(config.search, params.count), {
|
|
110
|
+
...requestContext(config, requireApiKey(), signal ?? NEVER_ABORTS),
|
|
105
111
|
oriQuestion: explicit ?? sessionOriQuestion(ctx),
|
|
106
112
|
});
|
|
107
113
|
return {
|
|
108
|
-
content: [{ type: "text", text: formatSearchResults(results) }],
|
|
109
|
-
structuredContent: { results: results.map(toStructuredResult) },
|
|
114
|
+
content: [{ type: "text", text: formatSearchResults(outcome.results, outcome) }],
|
|
115
|
+
structuredContent: { results: outcome.results.map(toStructuredResult) },
|
|
110
116
|
details: undefined,
|
|
111
117
|
};
|
|
112
118
|
} catch (error) {
|
|
@@ -130,7 +136,11 @@ export function fetchTool(config: ResolvedOptions, client: RetrievalClient = ret
|
|
|
130
136
|
async execute(_toolCallId, params, signal): Promise<AgentToolResult> {
|
|
131
137
|
try {
|
|
132
138
|
const target = inspectFetchTarget(params.url, config.fetch);
|
|
133
|
-
const page = await client.fetchDocument(
|
|
139
|
+
const page = await client.fetchDocument(
|
|
140
|
+
target,
|
|
141
|
+
config.fetch,
|
|
142
|
+
requestContext(config, requireApiKey(), signal ?? NEVER_ABORTS, { maxChars: params.maxChars }),
|
|
143
|
+
);
|
|
134
144
|
return {
|
|
135
145
|
content: [{ type: "text", text: page.content }],
|
|
136
146
|
structuredContent: {
|
|
@@ -168,10 +178,6 @@ export default function deepSeekWebRetrieval(pi: ExtensionAPI): void {
|
|
|
168
178
|
if (config.fetch.enabled) pi.registerTool(fetchTool(config));
|
|
169
179
|
}
|
|
170
180
|
|
|
171
|
-
function requestContext(config: ResolvedOptions, signal: AbortSignal | undefined): RequestOptions {
|
|
172
|
-
return { signal: signal ?? NEVER_ABORTS, auth: { apiKey: requireApiKey() }, retry: config.retry };
|
|
173
|
-
}
|
|
174
|
-
|
|
175
181
|
/**
|
|
176
182
|
* Best-effort `ori_question` fallback for when the model left the tool input
|
|
177
183
|
* blank: the most recent user message text in the session projection, readable
|
package/src/mcp/auth.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { apiKeyFromEnv, MISSING_API_KEY_MESSAGE } from "../core/auth";
|
|
2
|
-
import type { Env } from "../core/env";
|
|
3
|
-
|
|
4
|
-
export { MISSING_API_KEY_MESSAGE };
|
|
5
|
-
|
|
6
|
-
/** Resolves the API key from the environment or fails with shared guidance. */
|
|
7
|
-
export function requireApiKey(env: Env): string {
|
|
8
|
-
const apiKey = apiKeyFromEnv(env);
|
|
9
|
-
if (!apiKey) throw new Error(MISSING_API_KEY_MESSAGE);
|
|
10
|
-
return apiKey;
|
|
11
|
-
}
|