deepseek-web-retrieval 0.8.0 → 0.10.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 +18 -1
- package/README.md +33 -16
- package/dist/cli.js +307 -174
- package/docs/adr/0001-refuse-drift-between-plan-and-apply.md +7 -3
- package/docs/adr/0002-shared-config-base-layer.md +5 -1
- package/docs/adr/0003-need-content-per-call-boolean.md +49 -0
- package/docs/agents/claude-code.md +16 -6
- package/docs/agents/codex.md +14 -8
- package/docs/agents/domain.md +2 -2
- package/docs/agents/issue-tracker.md +5 -5
- package/docs/agents/opencode-v2.md +29 -15
- package/docs/agents/pi.md +41 -12
- package/docs/configuration.md +24 -7
- package/docs/design/config-layering-design.md +10 -5
- package/docs/design/installer-design.md +57 -25
- package/docs/design/search-content-design.md +133 -0
- package/docs/integration.md +19 -16
- package/docs/research/control-matrix.md +39 -34
- package/docs/research/mcp-compatibility.md +3 -1
- package/docs/research/ori-question-sourcing.md +31 -20
- package/docs/research/result-quality-passthrough.md +9 -7
- package/docs/research/search-api-defaults.md +4 -2
- package/docs/research/search-inline-content.md +49 -9
- package/package.json +3 -2
- package/src/core/auth.ts +7 -0
- package/src/core/client.ts +98 -27
- package/src/core/config-summary.ts +3 -0
- package/src/core/constants.ts +30 -2
- package/src/core/descriptions.ts +17 -2
- package/src/core/env.ts +7 -1
- package/src/core/format.ts +35 -5
- package/src/core/options.ts +56 -0
- package/src/core/search.ts +106 -15
- package/src/core/types.ts +29 -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 +3 -17
- package/src/installer/hosts/codex.ts +3 -17
- package/src/installer/hosts/index.ts +2 -2
- package/src/installer/hosts/opencode.ts +1 -1
- package/src/installer/hosts/pi.ts +14 -15
- 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} +15 -15
- package/src/installer/types.ts +1 -1
- package/src/installer/uninstall.ts +4 -4
- package/src/mcp/serve.ts +1 -2
- package/src/mcp/server.ts +19 -19
- package/src/opencode/plugin.ts +15 -10
- package/src/pi/extension.ts +26 -17
- package/src/mcp/auth.ts +0 -11
package/src/core/search.ts
CHANGED
|
@@ -1,25 +1,103 @@
|
|
|
1
|
+
import { MAX_QUERY_CHARS } from "./constants";
|
|
1
2
|
import { normalizeDomains } from "./domains";
|
|
2
3
|
import { truncate } from "./format";
|
|
3
4
|
import type { DeepSeekSearchResult, SearchOptions, SearchResult } from "./types";
|
|
4
5
|
import { normalizeResultUrl } from "./url";
|
|
5
6
|
|
|
7
|
+
/** The fewest body characters a degraded query keeps before operators start dropping. */
|
|
8
|
+
const MIN_BODY_CHARS = 8;
|
|
9
|
+
|
|
10
|
+
function charCount(value: string): number {
|
|
11
|
+
return Array.from(value).length;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The query string plus every adjustment made to fit it under the length limit. */
|
|
15
|
+
export interface ComposedQuery {
|
|
16
|
+
readonly query: string;
|
|
17
|
+
readonly notes: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface OperatorParts {
|
|
21
|
+
readonly blocked: readonly string[];
|
|
22
|
+
readonly include?: string;
|
|
23
|
+
readonly language?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
6
26
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* include: site:a.com OR site:b.com
|
|
10
|
-
* language: lang:zh or lang:zh,en
|
|
27
|
+
* Operators are official API surface, documented in the guide's search-syntax
|
|
28
|
+
* section (搜索语法): site:, -site:, lang:, quoted phrases, after:/before:.
|
|
11
29
|
*/
|
|
12
|
-
|
|
30
|
+
function operatorParts(options: Pick<SearchOptions, "blockedDomains" | "includeDomains" | "language">): OperatorParts {
|
|
31
|
+
const blocked = normalizeDomains(options.blockedDomains).map((domain) => `-site:${domain}`);
|
|
32
|
+
const include = normalizeDomains(options.includeDomains).map((domain) => `site:${domain}`);
|
|
33
|
+
const language = options.language?.trim().replace(/^lang:/i, "");
|
|
34
|
+
return {
|
|
35
|
+
blocked,
|
|
36
|
+
include: include.length ? include.join(" OR ") : undefined,
|
|
37
|
+
language: language ? `lang:${language}` : undefined,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function assemble(body: string, parts: OperatorParts, withInclude: boolean, withLang: boolean): string {
|
|
42
|
+
const segments = [body, ...parts.blocked];
|
|
43
|
+
if (withInclude && parts.include) segments.push(parts.include);
|
|
44
|
+
if (withLang && parts.language) segments.push(parts.language);
|
|
45
|
+
return segments.filter((segment) => segment.length > 0).join(" ");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Characters left for the query body once the kept operators and separators are paid for. */
|
|
49
|
+
function bodyBudget(parts: OperatorParts, withInclude: boolean, withLang: boolean, maxChars: number): number {
|
|
50
|
+
const fixed = [
|
|
51
|
+
...parts.blocked,
|
|
52
|
+
...(withInclude && parts.include ? [parts.include] : []),
|
|
53
|
+
...(withLang && parts.language ? [parts.language] : []),
|
|
54
|
+
].join(" ");
|
|
55
|
+
return maxChars - (fixed ? charCount(fixed) + 1 : 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Builds the wire query within the API's character budget, degrading instead of
|
|
60
|
+
* failing when operators push it over: the query body is truncated first (user
|
|
61
|
+
* configuration outranks model verbosity), the language filter drops next, the
|
|
62
|
+
* include list last; blocked domains never drop. Only when the -site:
|
|
63
|
+
* operators alone crowd out the body does the call fail. Every adjustment is
|
|
64
|
+
* reported in `notes` so the model can be shown what was actually searched.
|
|
65
|
+
*/
|
|
66
|
+
export function composeSearchQuery(
|
|
13
67
|
query: string,
|
|
14
68
|
options: Pick<SearchOptions, "blockedDomains" | "includeDomains" | "language">,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
69
|
+
maxChars: number = MAX_QUERY_CHARS,
|
|
70
|
+
): ComposedQuery {
|
|
71
|
+
const parts = operatorParts(options);
|
|
72
|
+
const body = query.trim();
|
|
73
|
+
const notes: string[] = [];
|
|
74
|
+
|
|
75
|
+
const attempt = (withInclude: boolean, withLang: boolean): string | undefined => {
|
|
76
|
+
const budget = bodyBudget(parts, withInclude, withLang, maxChars);
|
|
77
|
+
if (charCount(body) <= budget) return assemble(body, parts, withInclude, withLang);
|
|
78
|
+
if (budget >= MIN_BODY_CHARS) {
|
|
79
|
+
notes.push(`query truncated to ${budget} characters (query length limit)`);
|
|
80
|
+
return assemble(truncate(body, budget), parts, withInclude, withLang);
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
let assembled = attempt(true, true);
|
|
86
|
+
if (assembled === undefined && parts.language) {
|
|
87
|
+
notes.push("language filter dropped (query length limit)");
|
|
88
|
+
assembled = attempt(true, false);
|
|
89
|
+
}
|
|
90
|
+
if (assembled === undefined && parts.include) {
|
|
91
|
+
notes.push("includeDomains filter dropped (query length limit)");
|
|
92
|
+
assembled = attempt(false, false);
|
|
93
|
+
}
|
|
94
|
+
if (assembled === undefined) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`DeepSeek search query cannot fit ${maxChars} characters: the blockedDomains -site: ` +
|
|
97
|
+
`operators alone occupy ${charCount(parts.blocked.join(" "))} characters`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return { query: assembled, notes };
|
|
23
101
|
}
|
|
24
102
|
|
|
25
103
|
export function parsePublished(value: unknown): number | undefined {
|
|
@@ -64,7 +142,20 @@ export function processResults(raw: readonly DeepSeekSearchResult[], options: Se
|
|
|
64
142
|
}
|
|
65
143
|
|
|
66
144
|
seen.add(url);
|
|
67
|
-
|
|
145
|
+
// A need_content page body outranks the digest — the summary is only ever
|
|
146
|
+
// a proxy for the body it compresses. A zero budget drops the body so the
|
|
147
|
+
// digest renders instead.
|
|
148
|
+
const body = optionalText(item.content);
|
|
149
|
+
const digest = optionalText(item.summary) ?? optionalText(item.snippet) ?? "";
|
|
150
|
+
const fromPage = body !== undefined && options.contentMaxChars > 0;
|
|
151
|
+
const rawContent: string = fromPage && body !== undefined ? body : digest;
|
|
152
|
+
const contentBudget = fromPage ? options.contentMaxChars : options.maxSummaryChars;
|
|
153
|
+
const cutContent = truncate(rawContent, contentBudget);
|
|
154
|
+
// The ellipsis marks a summary the cap actually shortened; official
|
|
155
|
+
// summaries are typically 600-800 characters, so the 1,200 default
|
|
156
|
+
// rarely triggers it.
|
|
157
|
+
const content = cutContent && charCount(rawContent) > contentBudget ? `${cutContent}…` : cutContent;
|
|
158
|
+
const contentKind = fromPage ? "page" : "digest";
|
|
68
159
|
const published = parsePublished(item.date);
|
|
69
160
|
const title = optionalText(item.title);
|
|
70
161
|
const rerankScore = typeof rerank === "number" && Number.isFinite(rerank) ? rerank : undefined;
|
|
@@ -73,7 +164,7 @@ export function processResults(raw: readonly DeepSeekSearchResult[], options: Se
|
|
|
73
164
|
results.push({
|
|
74
165
|
url,
|
|
75
166
|
...(title ? { title } : {}),
|
|
76
|
-
...(content ? { content } : {}),
|
|
167
|
+
...(content ? { content, contentKind } : {}),
|
|
77
168
|
...(published !== undefined ? { published } : {}),
|
|
78
169
|
...(rerankScore !== undefined ? { rerankScore } : {}),
|
|
79
170
|
...(authorityScore !== undefined ? { authority: authorityScore } : {}),
|
package/src/core/types.ts
CHANGED
|
@@ -20,6 +20,14 @@ export interface SearchOptions {
|
|
|
20
20
|
*/
|
|
21
21
|
readonly localization?: string;
|
|
22
22
|
readonly maxSummaryChars: number;
|
|
23
|
+
/**
|
|
24
|
+
* Deployment-level only — never a per-call tool parameter. The budget a
|
|
25
|
+
* need_content page body is clamped to per result; the per-call boolean is
|
|
26
|
+
* `SearchRequest.needContent`.
|
|
27
|
+
*/
|
|
28
|
+
readonly contentMaxChars: number;
|
|
29
|
+
/** Deployment-level only; the timeout a need_content call uses instead of `timeoutMs`. */
|
|
30
|
+
readonly contentTimeoutMs: number;
|
|
23
31
|
readonly timeoutMs: number;
|
|
24
32
|
readonly enabled: boolean;
|
|
25
33
|
}
|
|
@@ -47,6 +55,12 @@ export interface ResolvedOptions {
|
|
|
47
55
|
readonly fetch: FetchOptions;
|
|
48
56
|
readonly auth: AuthOptions;
|
|
49
57
|
readonly retry: RetryOptions;
|
|
58
|
+
/**
|
|
59
|
+
* The API root both retrieval endpoints are composed from. Infrastructure
|
|
60
|
+
* routing, not a result-shaping knob: proxies and mock upstreams override it;
|
|
61
|
+
* anything but an http(s) URL is dropped at resolution back to the default.
|
|
62
|
+
*/
|
|
63
|
+
readonly baseUrl: string;
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
export interface DeepSeekSearchResult {
|
|
@@ -57,6 +71,8 @@ export interface DeepSeekSearchResult {
|
|
|
57
71
|
readonly url?: string | null;
|
|
58
72
|
readonly snippet?: string | null;
|
|
59
73
|
readonly summary?: string | null;
|
|
74
|
+
/** The need_content page body; an empty string when need_content is false or the content is unavailable. */
|
|
75
|
+
readonly content?: string | null;
|
|
60
76
|
readonly date?: string | null;
|
|
61
77
|
}
|
|
62
78
|
|
|
@@ -65,6 +81,8 @@ export interface SearchResult {
|
|
|
65
81
|
readonly url: string;
|
|
66
82
|
readonly title?: string;
|
|
67
83
|
readonly content?: string;
|
|
84
|
+
/** Which wire field `content` came from: a need_content page body or the summary/snippet digest. */
|
|
85
|
+
readonly contentKind?: "page" | "digest";
|
|
68
86
|
readonly published?: number;
|
|
69
87
|
readonly rerankScore?: number;
|
|
70
88
|
/** Faithful to the wire: -1 means unknown authority. */
|
|
@@ -72,6 +90,17 @@ export interface SearchResult {
|
|
|
72
90
|
readonly siteName?: string;
|
|
73
91
|
}
|
|
74
92
|
|
|
93
|
+
/** What one search call executed: the processed results plus the query actually sent. */
|
|
94
|
+
export interface SearchOutcome {
|
|
95
|
+
readonly results: readonly SearchResult[];
|
|
96
|
+
/** The query as the caller provided it, trimmed. */
|
|
97
|
+
readonly inputQuery: string;
|
|
98
|
+
/** The query sent to DeepSeek, operators included. */
|
|
99
|
+
readonly effectiveQuery: string;
|
|
100
|
+
/** One entry per degradation applied to fit the query budget; empty means nothing was adjusted. */
|
|
101
|
+
readonly notes: readonly string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
75
104
|
export interface FetchedPage {
|
|
76
105
|
readonly url: string;
|
|
77
106
|
readonly title: string;
|
package/src/installer/apply.ts
CHANGED
|
@@ -5,8 +5,8 @@ import { codecFor, writeConfig } from "./formats";
|
|
|
5
5
|
import { deepEqual, valueAt } from "./formats/value";
|
|
6
6
|
import { withOperationLock } from "./operation-lock";
|
|
7
7
|
import { acquireFileOwnership, assertFileOwnership, releaseFileOwnership } from "./ownership";
|
|
8
|
+
import { deleteRecord, readRecord, recordPathFor, writeRecord } from "./record";
|
|
8
9
|
import { pruneEmptyDir, revertRecordedFile } from "./revert";
|
|
9
|
-
import { deleteRecord, readRecord, statePathFor, writeRecord } from "./state";
|
|
10
10
|
import type { ConfigOp, FileEdit, InstallerInput, InstallPlan, InstallRecord, RecordedFile } from "./types";
|
|
11
11
|
|
|
12
12
|
/** What a successful apply reports; every failure mode throws instead. */
|
|
@@ -37,7 +37,7 @@ interface PreparedEdit {
|
|
|
37
37
|
* rendered (the plan and the record must describe one plan-time snapshot —
|
|
38
38
|
* ADR-0001), prepares every edit in memory (a malformed existing config fails
|
|
39
39
|
* before any file is written), writes the files, reverts artifacts orphaned by
|
|
40
|
-
* a tier change, verifies the result, then records
|
|
40
|
+
* a tier change, verifies the result, then records the install record. Write
|
|
41
41
|
* and verification failures restore touched files and the previous record.
|
|
42
42
|
* Incomplete recovery is reported explicitly and keeps new ownership claims
|
|
43
43
|
* so another install cannot adopt partially applied configuration.
|
|
@@ -46,7 +46,7 @@ export function applyPlan(plan: InstallPlan, input: InstallerInput): ApplyResult
|
|
|
46
46
|
if (plan.errors.length > 0) {
|
|
47
47
|
throw new Error(`refusing to apply a plan with errors: ${plan.errors.map((error) => error.message).join("; ")}`);
|
|
48
48
|
}
|
|
49
|
-
return withOperationLock([
|
|
49
|
+
return withOperationLock([recordPathFor(input)], () =>
|
|
50
50
|
withOperationLock(
|
|
51
51
|
[...plan.edits, ...(plan.reverts ?? [])].map((file) => file.file),
|
|
52
52
|
() => applyLocked(plan, input),
|
|
@@ -231,7 +231,7 @@ function rollbackApply(
|
|
|
231
231
|
try {
|
|
232
232
|
if (previous !== undefined) writeRecord(input, previous);
|
|
233
233
|
else deleteRecord(input);
|
|
234
|
-
if (previous === undefined ? existsSync(
|
|
234
|
+
if (previous === undefined ? existsSync(recordPathFor(input)) : !deepEqual(readRecord(input), previous)) {
|
|
235
235
|
throw new Error("restored install record differs from its previous state");
|
|
236
236
|
}
|
|
237
237
|
} catch (restoreError) {
|
package/src/installer/cli.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { ApplyError, applyPlan } from "./apply";
|
|
|
7
7
|
import { runConfigCommand } from "./config-command";
|
|
8
8
|
import { HOST_PLANNERS, HOST_VERSION_PROBES, planForHost } from "./hosts";
|
|
9
9
|
import { pinPackageRef } from "./ownership";
|
|
10
|
+
import { legacyGlobalRecordDir, listRecords, readingLegacyGlobalRecords } from "./record";
|
|
10
11
|
import { renderPlan } from "./render";
|
|
11
|
-
import { legacyGlobalStateDir, listRecords, readingLegacyGlobalRecords } from "./state";
|
|
12
12
|
import type { HostName, InstallerInput, Scope, Tier } from "./types";
|
|
13
13
|
import { uninstall } from "./uninstall";
|
|
14
14
|
|
|
@@ -161,7 +161,7 @@ export async function runInstallerCli(argv: readonly string[], io: CliIo = {}):
|
|
|
161
161
|
}
|
|
162
162
|
if (readingLegacyGlobalRecords({ ...roots, configHome })) {
|
|
163
163
|
out(
|
|
164
|
-
`Global install records are being read from the legacy ${
|
|
164
|
+
`Global install records are being read from the legacy ${legacyGlobalRecordDir(roots)} ` +
|
|
165
165
|
"(written before the Windows config home moved to %APPDATA%).",
|
|
166
166
|
);
|
|
167
167
|
}
|
|
@@ -68,17 +68,18 @@ export async function runConfigCommand(
|
|
|
68
68
|
const client = deps.client ?? createRetrievalClient();
|
|
69
69
|
const startedAt = Date.now();
|
|
70
70
|
try {
|
|
71
|
-
const
|
|
71
|
+
const outcome = await client.searchWeb(query, resolved.search, {
|
|
72
72
|
signal: new AbortController().signal,
|
|
73
73
|
auth: { apiKey },
|
|
74
74
|
retry: resolved.retry,
|
|
75
|
+
baseUrl: resolved.baseUrl,
|
|
75
76
|
});
|
|
76
77
|
const elapsedMs = Date.now() - startedAt;
|
|
77
78
|
io.out(
|
|
78
|
-
`search "${query.trim()}" — ${results.length} ${results.length === 1 ? "result" : "results"} in ${elapsedMs} ms`,
|
|
79
|
+
`search "${query.trim()}" — ${outcome.results.length} ${outcome.results.length === 1 ? "result" : "results"} in ${elapsedMs} ms`,
|
|
79
80
|
);
|
|
80
81
|
io.out("");
|
|
81
|
-
for (const line of formatSearchResults(results).split("\n")) io.out(line);
|
|
82
|
+
for (const line of formatSearchResults(outcome.results, outcome).split("\n")) io.out(line);
|
|
82
83
|
return 0;
|
|
83
84
|
} catch (error) {
|
|
84
85
|
io.err(error instanceof Error ? error.message : String(error));
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { existsSync } from "node:fs";
|
|
3
2
|
import { API_KEY_ENV } from "../../core/constants";
|
|
4
3
|
import { FORCED_COST_NOTE, othersStayRegisteredNote, replacingExistingEntryNote, splitCompetitors } from "../detect";
|
|
@@ -8,7 +7,7 @@ import { isPlainObject } from "../formats/value";
|
|
|
8
7
|
import { join } from "../paths";
|
|
9
8
|
import { MCP_TOOL_IDS, rulesLines } from "../rulesContent";
|
|
10
9
|
import type { ConfigOp, FileEdit, HostPaths, InstallerInput, InstallPlan, InstallRecord } from "../types";
|
|
11
|
-
import { planHeader } from "./shared";
|
|
10
|
+
import { planHeader, probeHostVersion } from "./shared";
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
13
|
* The Claude Code release this installer's facts are verified against — the lock in
|
|
@@ -24,22 +23,9 @@ function valueKind(value: unknown): string {
|
|
|
24
23
|
return Array.isArray(value) ? "an array" : `a ${typeof value}`;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
/**
|
|
28
|
-
* The one subprocess the installer runs: `claude --version`, probed for the
|
|
29
|
-
* version-drift note. A missing binary or unreadable output skips the check with
|
|
30
|
-
* a stderr note and never blocks the plan — honor or report.
|
|
31
|
-
*/
|
|
26
|
+
/** Probes `claude --version` for the version-drift note (see probeHostVersion). */
|
|
32
27
|
export function probeClaudeCodeVersion(err: (line: string) => void): string | undefined {
|
|
33
|
-
|
|
34
|
-
// shell: npm's global install on Windows provides only a claude.cmd shim,
|
|
35
|
-
// which spawnSync cannot launch without a shell; the command and args are
|
|
36
|
-
// hardcoded, so the shell hop is safe.
|
|
37
|
-
const result = spawnSync("claude", ["--version"], { timeout: 5000, encoding: "utf8", env: process.env, shell: true });
|
|
38
|
-
const version = result.status === 0 ? parseClaudeCodeVersion(result.stdout ?? "") : undefined;
|
|
39
|
-
if (version === undefined) {
|
|
40
|
-
err("note: claude was not found on PATH or printed no version; the version-drift check was skipped");
|
|
41
|
-
}
|
|
42
|
-
return version;
|
|
28
|
+
return probeHostVersion("claude", parseClaudeCodeVersion, err);
|
|
43
29
|
}
|
|
44
30
|
|
|
45
31
|
/**
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { API_KEY_ENV } from "../../core/constants";
|
|
3
2
|
import { OPTION_ENV_VARS } from "../../core/env";
|
|
4
3
|
import { FORCED_COST_NOTE, othersStayRegisteredNote, replacingExistingEntryNote, splitCompetitors } from "../detect";
|
|
@@ -8,7 +7,7 @@ import { isPlainObject } from "../formats/value";
|
|
|
8
7
|
import { join } from "../paths";
|
|
9
8
|
import { MCP_TOOL_IDS, RAW_MCP_TOOL_IDS, rulesLines } from "../rulesContent";
|
|
10
9
|
import type { ConfigOp, FileEdit, HostPaths, InstallerInput, InstallPlan, InstallRecord } from "../types";
|
|
11
|
-
import { planHeader } from "./shared";
|
|
10
|
+
import { planHeader, probeHostVersion } from "./shared";
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
13
|
* The Codex release this installer's facts are verified against — the lock in
|
|
@@ -16,22 +15,9 @@ import { planHeader } from "./shared";
|
|
|
16
15
|
*/
|
|
17
16
|
export const CODEX_TESTED_VERSION = "0.163.0";
|
|
18
17
|
|
|
19
|
-
/**
|
|
20
|
-
* The one subprocess the installer runs: `codex --version`, probed for the
|
|
21
|
-
* version-drift note. A missing binary or unreadable output skips the check with
|
|
22
|
-
* a stderr note and never blocks the plan — honor or report.
|
|
23
|
-
*/
|
|
18
|
+
/** Probes `codex --version` for the version-drift note (see probeHostVersion). */
|
|
24
19
|
export function probeCodexVersion(err: (line: string) => void): string | undefined {
|
|
25
|
-
|
|
26
|
-
// shell: npm's global install on Windows provides only a codex.cmd shim,
|
|
27
|
-
// which spawnSync cannot launch without a shell; the command and args are
|
|
28
|
-
// hardcoded, so the shell hop is safe.
|
|
29
|
-
const result = spawnSync("codex", ["--version"], { timeout: 5000, encoding: "utf8", env: process.env, shell: true });
|
|
30
|
-
const version = result.status === 0 ? parseCodexVersion(result.stdout ?? "") : undefined;
|
|
31
|
-
if (version === undefined) {
|
|
32
|
-
err("note: codex was not found on PATH or printed no version; the version-drift check was skipped");
|
|
33
|
-
}
|
|
34
|
-
return version;
|
|
20
|
+
return probeHostVersion("codex", parseCodexVersion, err);
|
|
35
21
|
}
|
|
36
22
|
|
|
37
23
|
/** Parses `codex --version` output (`codex-cli 0.163.0`) into the bare version. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { API_KEY_ENV } from "../../core/constants";
|
|
2
2
|
import { deepEqual, isPlainObject } from "../formats/value";
|
|
3
3
|
import { assertFileOwnership, fileOwnershipNotes } from "../ownership";
|
|
4
|
-
import { claimedByOtherScope, InstallRecordError, readRecord } from "../
|
|
4
|
+
import { claimedByOtherScope, InstallRecordError, readRecord } from "../record";
|
|
5
5
|
import type { HostName, InstallerInput, InstallPlan, InstallRecord, RecordedFile } from "../types";
|
|
6
6
|
import { planClaudeCode, probeClaudeCodeVersion } from "./claude-code";
|
|
7
7
|
import { planCodex, probeCodexVersion } from "./codex";
|
|
@@ -153,7 +153,7 @@ function withSecretGuard(plan: InstallPlan): InstallPlan {
|
|
|
153
153
|
{
|
|
154
154
|
message:
|
|
155
155
|
`${edit.file}: the existing value at ${op.path.join(".")} contains a credential-looking key ` +
|
|
156
|
-
`(${offender}). The installer refuses to snapshot secrets into its
|
|
156
|
+
`(${offender}). The installer refuses to snapshot secrets into its install record. Move the key to ` +
|
|
157
157
|
`environment indirection ("\${${API_KEY_ENV}}" expansion or env_vars) and re-run.`,
|
|
158
158
|
},
|
|
159
159
|
],
|
|
@@ -4,8 +4,8 @@ import { buildRulesBlock } from "../formats/rules";
|
|
|
4
4
|
import { isPlainObject } from "../formats/value";
|
|
5
5
|
import { lineageMatcher } from "../ownership";
|
|
6
6
|
import { configHome, join } from "../paths";
|
|
7
|
+
import { appendedByPrevious } from "../record";
|
|
7
8
|
import { rulesLines } from "../rulesContent";
|
|
8
|
-
import { appendedByPrevious } from "../state";
|
|
9
9
|
import type { ConfigOp, FileEdit, HostPaths, InstallerInput, InstallPlan, InstallRecord } from "../types";
|
|
10
10
|
import { planHeader } from "./shared";
|
|
11
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
|
}
|
|
@@ -169,16 +178,11 @@ function planPiExtension(input: InstallerInput, previous: InstallRecord | undefi
|
|
|
169
178
|
format: "rules",
|
|
170
179
|
block: buildRulesBlock(input.host, rulesLines(RAW_MCP_TOOL_IDS, input.search, input.fetch)),
|
|
171
180
|
});
|
|
172
|
-
notes.push(
|
|
173
|
-
`Rules block upserted in ${paths.rules}; Pi loads AGENTS.md context files from the working directory and its parents.`,
|
|
174
|
-
);
|
|
181
|
+
notes.push(rulesUpsertNote(paths.rules));
|
|
175
182
|
}
|
|
176
183
|
|
|
177
184
|
if (forced) {
|
|
178
|
-
notes.push(
|
|
179
|
-
"Forced tier: Pi 1.3.0 ships no built-in web tool, so the registered tools are the only web path (de-facto). " +
|
|
180
|
-
FORCED_COST_NOTE,
|
|
181
|
-
);
|
|
185
|
+
notes.push(FORCED_NO_BUILTIN_NOTE);
|
|
182
186
|
}
|
|
183
187
|
|
|
184
188
|
return { host: "pi", scope: input.scope, tiers, edits, notes, errors };
|
|
@@ -313,16 +317,11 @@ function planPiMcp(input: InstallerInput): InstallPlan {
|
|
|
313
317
|
format: "rules",
|
|
314
318
|
block: buildRulesBlock(input.host, rulesLines(MCP_TOOL_IDS, input.search, input.fetch)),
|
|
315
319
|
});
|
|
316
|
-
notes.push(
|
|
317
|
-
`Rules block upserted in ${paths.rules}; Pi loads AGENTS.md context files from the working directory and its parents.`,
|
|
318
|
-
);
|
|
320
|
+
notes.push(rulesUpsertNote(paths.rules));
|
|
319
321
|
}
|
|
320
322
|
|
|
321
323
|
if (input.search === "forced" || input.fetch === "forced") {
|
|
322
|
-
notes.push(
|
|
323
|
-
"Forced tier: Pi 1.3.0 ships no built-in web tool, so the registered tools are the only web path (de-facto). " +
|
|
324
|
-
FORCED_COST_NOTE,
|
|
325
|
-
);
|
|
324
|
+
notes.push(FORCED_NO_BUILTIN_NOTE);
|
|
326
325
|
}
|
|
327
326
|
|
|
328
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
|
|
|
@@ -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
|
@@ -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.`);
|
|
@@ -53,7 +53,7 @@ function uninstallLocked(input: InstallerInput, record: InstallRecord): string[]
|
|
|
53
53
|
// to still exist on disk.
|
|
54
54
|
releaseFileOwnership(input, record.files);
|
|
55
55
|
deleteRecord(input);
|
|
56
|
-
pruneEmptyDir(
|
|
56
|
+
pruneEmptyDir(recordDir(input, input.scope));
|
|
57
57
|
lines.push(`removed install record (${input.host}, ${input.scope})`);
|
|
58
58
|
return lines;
|
|
59
59
|
}
|