deepseek-web-retrieval 0.4.0 → 0.5.1
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 +99 -244
- package/README.md +16 -11
- package/dist/cli.js +1366 -615
- package/docs/agents/codex.md +28 -7
- package/docs/agents/opencode-v2.md +1 -1
- package/docs/agents/pi.md +88 -86
- package/docs/design/installer-design.md +72 -9
- package/docs/hosts.jsonc +2 -2
- package/docs/integration.md +14 -9
- package/docs/research/control-matrix.md +48 -13
- package/docs/research/mcp-compatibility.md +9 -4
- package/examples/pi.settings.jsonc +17 -0
- package/index.ts +2 -2
- package/package.json +35 -4
- package/src/core/auth.ts +4 -6
- package/src/core/client.ts +65 -53
- package/src/core/constants.ts +24 -24
- package/src/core/descriptions.ts +30 -6
- package/src/core/domains.ts +56 -67
- package/src/core/env.ts +112 -0
- package/src/core/format.ts +53 -50
- package/src/core/index.ts +9 -9
- package/src/core/jsonc.ts +6 -6
- package/src/core/object.ts +4 -0
- package/src/core/options.ts +20 -25
- package/src/core/search.ts +45 -44
- package/src/core/transport.ts +109 -113
- package/src/core/types.ts +47 -47
- package/src/core/url.ts +20 -20
- package/src/installer/apply.ts +256 -81
- package/src/installer/cli.ts +135 -104
- package/src/installer/detect.ts +46 -31
- package/src/installer/formats/index.ts +26 -28
- package/src/installer/formats/json.ts +81 -77
- package/src/installer/formats/rules.ts +55 -28
- package/src/installer/formats/toml.ts +88 -81
- package/src/installer/formats/value.ts +34 -30
- package/src/installer/hosts/codex.ts +100 -54
- package/src/installer/hosts/index.ts +126 -41
- package/src/installer/hosts/opencode.ts +70 -51
- package/src/installer/hosts/pi.ts +269 -75
- package/src/installer/hosts/shared.ts +19 -14
- package/src/installer/operation-lock.ts +54 -0
- package/src/installer/ownership.ts +129 -0
- package/src/installer/paths.ts +7 -5
- package/src/installer/persistence.ts +66 -0
- package/src/installer/render.ts +29 -29
- package/src/installer/revert.ts +33 -29
- package/src/installer/rulesContent.ts +24 -13
- package/src/installer/state.ts +116 -31
- package/src/installer/types.ts +74 -56
- package/src/installer/uninstall.ts +40 -17
- package/src/mcp/auth.ts +7 -8
- package/src/mcp/cli.ts +38 -20
- package/src/mcp/client.ts +35 -0
- package/src/mcp/server.ts +68 -97
- package/src/opencode/auth.ts +24 -26
- package/src/opencode/plugin.ts +99 -87
- package/src/pi/extension.ts +164 -0
- package/src/pi/settings.ts +49 -0
- package/src/mcp/config.ts +0 -112
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { canonicalPath } from "./ownership";
|
|
4
|
+
import { pruneEmptyDir } from "./revert";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Exclusive, fail-fast operation locks, distinct from persistent ownership claims.
|
|
8
|
+
* Never steal a stale lock automatically: an interrupted multi-file edit needs inspection.
|
|
9
|
+
*/
|
|
10
|
+
export function withOperationLock<T>(files: readonly string[], run: () => T): T {
|
|
11
|
+
const locks = [...new Set(files.map((file) => `${canonicalPath(file)}.deepseek-web-retrieval.lock`))].sort();
|
|
12
|
+
const acquired: string[] = [];
|
|
13
|
+
let outcome: { ok: true; value: T } | { ok: false; error: unknown };
|
|
14
|
+
try {
|
|
15
|
+
for (const lock of locks) {
|
|
16
|
+
mkdirSync(dirname(lock), { recursive: true });
|
|
17
|
+
try {
|
|
18
|
+
mkdirSync(lock, { mode: 0o700 });
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Installer operation is locked at ${lock}. Wait for the other init/uninstall to finish. ` +
|
|
23
|
+
"If it was interrupted, verify the config and install record and ensure no installer is running before removing this lock directory; retry with a new plan.",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
acquired.push(lock);
|
|
27
|
+
writeFileSync(`${lock}/owner.json`, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), {
|
|
28
|
+
mode: 0o600,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
outcome = { ok: true, value: run() };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
outcome = { ok: false, error };
|
|
34
|
+
}
|
|
35
|
+
{
|
|
36
|
+
const errors: string[] = [];
|
|
37
|
+
for (const lock of acquired.reverse()) {
|
|
38
|
+
try {
|
|
39
|
+
rmSync(lock, { recursive: true });
|
|
40
|
+
pruneEmptyDir(dirname(lock));
|
|
41
|
+
} catch (error) {
|
|
42
|
+
errors.push(`${lock}: ${String(error)}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (errors.length > 0) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`${outcome.ok ? "Operation finished" : String(outcome.error)}; could not release operation lock(s): ${errors.join("; ")}`,
|
|
48
|
+
{ cause: outcome.ok ? undefined : outcome.error },
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!outcome.ok) throw outcome.error;
|
|
53
|
+
return outcome.value;
|
|
54
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { isPlainObject } from "../core/object";
|
|
4
|
+
import { pruneEmptyDir } from "./revert";
|
|
5
|
+
import { statePathFor } from "./state";
|
|
6
|
+
import type { InstallerInput, RecordedFile, Scope } from "./types";
|
|
7
|
+
|
|
8
|
+
type ClaimedFile = Pick<RecordedFile, "file" | "format" | "shared">;
|
|
9
|
+
|
|
10
|
+
interface FileOwner {
|
|
11
|
+
readonly recordPath: string;
|
|
12
|
+
readonly scope: Scope;
|
|
13
|
+
readonly cwd: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Resolve existing symlinks, including the parents of a file not created yet. */
|
|
17
|
+
export function canonicalPath(file: string): string {
|
|
18
|
+
const absolute = resolve(file);
|
|
19
|
+
if (existsSync(absolute)) return realpathSync(absolute);
|
|
20
|
+
return join(canonicalPath(dirname(absolute)), basename(absolute));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ownerOf(input: InstallerInput): FileOwner {
|
|
24
|
+
return { recordPath: canonicalPath(statePathFor(input)), scope: input.scope, cwd: canonicalPath(input.cwd) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Put shared config claims beside the actual config, so another cwd or
|
|
29
|
+
* XDG_CONFIG_HOME cannot hide them. Project-local configs need no sidecars.
|
|
30
|
+
* The sidecar contains only the install record's location, never config values.
|
|
31
|
+
*/
|
|
32
|
+
function claimPaths(input: InstallerInput, files: readonly ClaimedFile[]): string[] {
|
|
33
|
+
return [
|
|
34
|
+
...new Set(
|
|
35
|
+
files
|
|
36
|
+
.filter((file) => file.format !== "rules")
|
|
37
|
+
.map((file) => {
|
|
38
|
+
const target = canonicalPath(file.file);
|
|
39
|
+
return join(dirname(target), `.${basename(target)}.deepseek-web-retrieval.${input.host}.owner.json`);
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readOwner(file: string): FileOwner | undefined {
|
|
46
|
+
if (!existsSync(file)) return undefined;
|
|
47
|
+
try {
|
|
48
|
+
const value: unknown = JSON.parse(readFileSync(file, "utf8"));
|
|
49
|
+
if (
|
|
50
|
+
isPlainObject(value) &&
|
|
51
|
+
typeof value.recordPath === "string" &&
|
|
52
|
+
typeof value.cwd === "string" &&
|
|
53
|
+
(value.scope === "project" || value.scope === "global")
|
|
54
|
+
) {
|
|
55
|
+
return { recordPath: value.recordPath, cwd: value.cwd, scope: value.scope };
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// An unreadable claim must not permit another install to take the file.
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`Cannot read config ownership in ${file}; repair the ownership record before retrying.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function assertOwner(input: InstallerInput, claim: string): void {
|
|
64
|
+
const owner = readOwner(claim);
|
|
65
|
+
if (owner === undefined || owner.recordPath === ownerOf(input).recordPath) return;
|
|
66
|
+
throw new Error(
|
|
67
|
+
`A ${input.host} install is already recorded at the ${owner.scope} scope (${owner.recordPath}) and claims the same config file. ` +
|
|
68
|
+
`Uninstall it first: deepseek-web-retrieval uninstall --host ${input.host} --scope ${owner.scope} --cwd ${JSON.stringify(owner.cwd)}. ` +
|
|
69
|
+
"Two install records must not edit one file.",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Read-only check used by planning, apply and uninstall, across all project roots. */
|
|
74
|
+
export function assertFileOwnership(input: InstallerInput, files: readonly ClaimedFile[]): void {
|
|
75
|
+
for (const claim of claimPaths(input, files)) assertOwner(input, claim);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Include the new metadata write in the plan preview. */
|
|
79
|
+
export function fileOwnershipNotes(input: InstallerInput, files: readonly ClaimedFile[]): string[] {
|
|
80
|
+
return claimPaths(
|
|
81
|
+
input,
|
|
82
|
+
files.filter((file) => file.shared),
|
|
83
|
+
).map(
|
|
84
|
+
(claim) =>
|
|
85
|
+
`Shared config ownership is recorded in ${claim}; uninstall releases it. Keep this local metadata with the config.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Acquire before config writes; wx prevents two different installs taking an unclaimed file. */
|
|
90
|
+
export function acquireFileOwnership(input: InstallerInput, files: readonly ClaimedFile[]): () => void {
|
|
91
|
+
const acquired: string[] = [];
|
|
92
|
+
const owner = ownerOf(input);
|
|
93
|
+
const releaseNew = () => {
|
|
94
|
+
for (const claim of acquired.reverse()) releaseClaim(input, claim);
|
|
95
|
+
};
|
|
96
|
+
try {
|
|
97
|
+
for (const claim of claimPaths(
|
|
98
|
+
input,
|
|
99
|
+
files.filter((file) => file.shared),
|
|
100
|
+
)) {
|
|
101
|
+
assertOwner(input, claim);
|
|
102
|
+
mkdirSync(dirname(claim), { recursive: true });
|
|
103
|
+
try {
|
|
104
|
+
writeFileSync(claim, `${JSON.stringify(owner)}\n`, { flag: "wx", mode: 0o600 });
|
|
105
|
+
acquired.push(claim);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
108
|
+
assertOwner(input, claim);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return releaseNew;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
releaseNew();
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function releaseClaim(input: InstallerInput, claim: string): void {
|
|
119
|
+
const owner = readOwner(claim);
|
|
120
|
+
if (owner === undefined) return;
|
|
121
|
+
assertOwner(input, claim);
|
|
122
|
+
rmSync(claim);
|
|
123
|
+
pruneEmptyDir(dirname(claim));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Release only after successful uninstall or after reverting a whole-file orphan. */
|
|
127
|
+
export function releaseFileOwnership(input: InstallerInput, files: readonly ClaimedFile[]): void {
|
|
128
|
+
for (const claim of claimPaths(input, files)) releaseClaim(input, claim);
|
|
129
|
+
}
|
package/src/installer/paths.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { join as pathJoin } from "node:path";
|
|
2
|
+
import type { FsRoots, Scope } from "./types";
|
|
2
3
|
|
|
3
|
-
/** Joins path parts with
|
|
4
|
+
/** Joins path parts with the platform separator, skipping empty segments. */
|
|
4
5
|
export function join(...parts: readonly string[]): string {
|
|
5
|
-
|
|
6
|
+
const nonEmpty = parts.filter((part) => part.length > 0);
|
|
7
|
+
return nonEmpty.length === 0 ? "" : pathJoin(...nonEmpty);
|
|
6
8
|
}
|
|
7
9
|
|
|
8
10
|
/** Config home: an explicit root wins; otherwise ~/.config. The CLI resolves XDG at the edge. */
|
|
9
11
|
export function configHome(roots: FsRoots): string {
|
|
10
|
-
return roots.configHome ?? join(roots.home, ".config")
|
|
12
|
+
return roots.configHome ?? join(roots.home, ".config");
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
/** State records live with their scope: global installs under the config home, project installs in the checkout. */
|
|
14
16
|
export function stateDir(roots: FsRoots, scope: Scope): string {
|
|
15
17
|
return scope === "global"
|
|
16
18
|
? join(configHome(roots), "deepseek-web-retrieval")
|
|
17
|
-
: join(roots.cwd, ".deepseek-web-retrieval")
|
|
19
|
+
: join(roots.cwd, ".deepseek-web-retrieval");
|
|
18
20
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
fchmodSync,
|
|
5
|
+
fsyncSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
statSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { basename, dirname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
/** Replace one file, retaining valid symlinks and modes. This is not a multi-file transaction. */
|
|
19
|
+
export function atomicWrite(file: string, text: string, mode?: number): void {
|
|
20
|
+
let target = file;
|
|
21
|
+
let existingMode: number | undefined;
|
|
22
|
+
try {
|
|
23
|
+
const entry = lstatSync(file);
|
|
24
|
+
// Resolve explicitly: never replace a dangling symlink with a new regular file.
|
|
25
|
+
if (entry.isSymbolicLink()) target = realpathSync(file);
|
|
26
|
+
existingMode = statSync(target).mode & 0o777;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
29
|
+
// A dangling link exists even though its target does not.
|
|
30
|
+
try {
|
|
31
|
+
if (lstatSync(file).isSymbolicLink()) throw new Error(`Cannot write dangling symlink ${file}`);
|
|
32
|
+
} catch (linkError) {
|
|
33
|
+
if ((linkError as NodeJS.ErrnoException).code !== "ENOENT") throw linkError;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const dir = dirname(target);
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
const temporary = join(dir, `.${basename(target)}.${randomUUID()}.tmp`);
|
|
39
|
+
let fd: number | undefined;
|
|
40
|
+
try {
|
|
41
|
+
fd = openSync(temporary, "wx", 0o600);
|
|
42
|
+
writeFileSync(fd, text);
|
|
43
|
+
// Catch incomplete/silently dropped writes before replacing the original.
|
|
44
|
+
if (readFileSync(temporary, "utf8") !== text) throw new Error(`Incomplete write to ${file}`);
|
|
45
|
+
fchmodSync(fd, mode ?? existingMode ?? 0o666 & ~process.umask());
|
|
46
|
+
fsyncSync(fd);
|
|
47
|
+
closeSync(fd);
|
|
48
|
+
fd = undefined;
|
|
49
|
+
renameSync(temporary, target);
|
|
50
|
+
syncDirectory(dir);
|
|
51
|
+
} finally {
|
|
52
|
+
if (fd !== undefined) closeSync(fd);
|
|
53
|
+
rmSync(temporary, { force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function syncDirectory(dir: string): void {
|
|
58
|
+
// Windows does not support opening directories for fsync.
|
|
59
|
+
if (process.platform === "win32") return;
|
|
60
|
+
const fd = openSync(dir, "r");
|
|
61
|
+
try {
|
|
62
|
+
fsyncSync(fd);
|
|
63
|
+
} finally {
|
|
64
|
+
closeSync(fd);
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/installer/render.ts
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
import type { InstallPlan } from "./types"
|
|
1
|
+
import type { InstallPlan } from "./types";
|
|
2
2
|
|
|
3
3
|
/** Renders a plan for review: files, ops with old→new previews, and notes. */
|
|
4
4
|
export function renderPlan(plan: InstallPlan): string {
|
|
5
|
-
const lines: string[] = []
|
|
6
|
-
lines.push(`Install plan: ${plan.host} (${plan.scope} scope)`)
|
|
7
|
-
lines.push(`Tiers: search=${plan.tiers.search}, fetch=${plan.tiers.fetch}`)
|
|
8
|
-
lines.push("")
|
|
9
|
-
lines.push("Files:")
|
|
5
|
+
const lines: string[] = [];
|
|
6
|
+
lines.push(`Install plan: ${plan.host} (${plan.scope} scope)`);
|
|
7
|
+
lines.push(`Tiers: search=${plan.tiers.search}, fetch=${plan.tiers.fetch}`);
|
|
8
|
+
lines.push("");
|
|
9
|
+
lines.push("Files:");
|
|
10
10
|
for (const edit of plan.edits) {
|
|
11
|
-
lines.push(` ${edit.file}`)
|
|
11
|
+
lines.push(` ${edit.file}`);
|
|
12
12
|
if (edit.format === "rules") {
|
|
13
|
-
lines.push(" upsert marked rules block")
|
|
14
|
-
continue
|
|
13
|
+
lines.push(" upsert marked rules block");
|
|
14
|
+
continue;
|
|
15
15
|
}
|
|
16
16
|
for (const op of edit.ops) {
|
|
17
17
|
if (op.op === "set") {
|
|
18
|
-
const was = op.previous === undefined ? "(absent)" : preview(op.previous)
|
|
19
|
-
lines.push(` set ${op.path.join(".")} = ${preview(op.value)} (was ${was})`)
|
|
18
|
+
const was = op.previous === undefined ? "(absent)" : preview(op.previous);
|
|
19
|
+
lines.push(` set ${op.path.join(".")} = ${preview(op.value)} (was ${was})`);
|
|
20
20
|
} else {
|
|
21
|
-
const count = (op.entries ?? []).length
|
|
22
|
-
const kept = op.existingCount !== undefined ? ` (${op.existingCount} existing kept)` : ""
|
|
23
|
-
lines.push(` append ${count} entr${count === 1 ? "y" : "ies"} to ${op.path.join(".")}${kept}`)
|
|
24
|
-
for (const entry of op.entries ?? []) lines.push(` + ${preview(entry)}`)
|
|
21
|
+
const count = (op.entries ?? []).length;
|
|
22
|
+
const kept = op.existingCount !== undefined ? ` (${op.existingCount} existing kept)` : "";
|
|
23
|
+
lines.push(` append ${count} entr${count === 1 ? "y" : "ies"} to ${op.path.join(".")}${kept}`);
|
|
24
|
+
for (const entry of op.entries ?? []) lines.push(` + ${preview(entry)}`);
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
if (plan.reverts && plan.reverts.length > 0) {
|
|
29
|
-
lines.push("")
|
|
30
|
-
lines.push("Reverts (artifacts from the previous init that this plan no longer wants):")
|
|
29
|
+
lines.push("");
|
|
30
|
+
lines.push("Reverts (artifacts from the previous init that this plan no longer wants):");
|
|
31
31
|
for (const revert of plan.reverts) {
|
|
32
|
-
lines.push(` ${revert.file}`)
|
|
32
|
+
lines.push(` ${revert.file}`);
|
|
33
33
|
if (revert.format === "rules") {
|
|
34
|
-
lines.push(" remove rules block")
|
|
35
|
-
continue
|
|
34
|
+
lines.push(" remove rules block");
|
|
35
|
+
continue;
|
|
36
36
|
}
|
|
37
37
|
for (const op of revert.ops) {
|
|
38
38
|
if (op.op === "set") {
|
|
@@ -40,23 +40,23 @@ export function renderPlan(plan: InstallPlan): string {
|
|
|
40
40
|
op.previous === undefined
|
|
41
41
|
? ` remove ${op.path.join(".")} (was not set before this install)`
|
|
42
42
|
: ` restore ${op.path.join(".")} = ${preview(op.previous)}`,
|
|
43
|
-
)
|
|
43
|
+
);
|
|
44
44
|
} else {
|
|
45
|
-
const count = Array.isArray(op.applied) ? op.applied.length : 0
|
|
46
|
-
lines.push(` remove ${count} appended entr${count === 1 ? "y" : "ies"} from ${op.path.join(".")}`)
|
|
45
|
+
const count = Array.isArray(op.applied) ? op.applied.length : 0;
|
|
46
|
+
lines.push(` remove ${count} appended entr${count === 1 ? "y" : "ies"} from ${op.path.join(".")}`);
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
if (plan.notes.length > 0) {
|
|
52
|
-
lines.push("")
|
|
53
|
-
lines.push("Notes:")
|
|
54
|
-
for (const note of plan.notes) lines.push(` - ${note}`)
|
|
52
|
+
lines.push("");
|
|
53
|
+
lines.push("Notes:");
|
|
54
|
+
for (const note of plan.notes) lines.push(` - ${note}`);
|
|
55
55
|
}
|
|
56
|
-
return lines.join("\n")
|
|
56
|
+
return lines.join("\n");
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function preview(value: unknown): string {
|
|
60
|
-
const text = JSON.stringify(value) ?? String(value)
|
|
61
|
-
return text.length > 100 ? `${text.slice(0, 97)}...` : text
|
|
60
|
+
const text = JSON.stringify(value) ?? String(value);
|
|
61
|
+
return text.length > 100 ? `${text.slice(0, 97)}...` : text;
|
|
62
62
|
}
|
package/src/installer/revert.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { existsSync, readFileSync, rmdirSync, unlinkSync } from "node:fs"
|
|
2
|
-
import { dirname } from "node:path"
|
|
3
|
-
import { codecFor, writeConfig } from "./formats"
|
|
4
|
-
import type { HostName, RecordedFile, RulesEdit } from "./types"
|
|
1
|
+
import { existsSync, readFileSync, rmdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { codecFor, writeConfig } from "./formats";
|
|
4
|
+
import type { HostName, RecordedFile, RulesEdit } from "./types";
|
|
5
5
|
|
|
6
6
|
/** Removes a directory only when it is empty, so pre-existing dirs with content stay. */
|
|
7
7
|
export function pruneEmptyDir(dir: string): void {
|
|
8
8
|
try {
|
|
9
|
-
rmdirSync(dir)
|
|
9
|
+
rmdirSync(dir);
|
|
10
10
|
} catch {
|
|
11
11
|
// Not empty or already gone — nothing to prune.
|
|
12
12
|
}
|
|
@@ -19,43 +19,47 @@ export function pruneEmptyDir(dir: string): void {
|
|
|
19
19
|
* Per-format work belongs to the codecs; this skeleton owns the early-outs, the
|
|
20
20
|
* deletion decisions, the pruning, and the messages.
|
|
21
21
|
*/
|
|
22
|
-
export function revertRecordedFile(
|
|
23
|
-
|
|
22
|
+
export function revertRecordedFile(
|
|
23
|
+
file: RecordedFile,
|
|
24
|
+
host: HostName,
|
|
25
|
+
opts: { label: string; whole: boolean },
|
|
26
|
+
): string[] {
|
|
27
|
+
const lines: string[] = [];
|
|
24
28
|
if (!existsSync(file.file)) {
|
|
25
|
-
lines.push(`already absent: ${file.file}${opts.label}`)
|
|
26
|
-
return lines
|
|
29
|
+
lines.push(`already absent: ${file.file}${opts.label}`);
|
|
30
|
+
return lines;
|
|
27
31
|
}
|
|
28
|
-
const codec = codecFor(file.format, host)
|
|
29
|
-
const text = readFileSync(file.file, "utf8")
|
|
32
|
+
const codec = codecFor(file.format, host);
|
|
33
|
+
const text = readFileSync(file.file, "utf8");
|
|
30
34
|
if (file.format === "rules") {
|
|
31
|
-
const edit: RulesEdit = { file: file.file, format: "rules", block: file.blockApplied ?? "" }
|
|
35
|
+
const edit: RulesEdit = { file: file.file, format: "rules", block: file.blockApplied ?? "" };
|
|
32
36
|
if (!codec.verify(text, edit)) {
|
|
33
|
-
lines.push(`no rules block${opts.label} in ${file.file}; left untouched`)
|
|
34
|
-
return lines
|
|
37
|
+
lines.push(`no rules block${opts.label} in ${file.file}; left untouched`);
|
|
38
|
+
return lines;
|
|
35
39
|
}
|
|
36
|
-
const updated = codec.revert(text, file).text
|
|
40
|
+
const updated = codec.revert(text, file).text;
|
|
37
41
|
// Empty after removal proves every line in the file was an installer block;
|
|
38
42
|
// the whole-file gate keeps a still-claimed file safe even if a future
|
|
39
43
|
// planner ever produced a partial rules revert.
|
|
40
44
|
if (opts.whole && codec.isEffectivelyEmpty(updated)) {
|
|
41
|
-
unlinkSync(file.file)
|
|
42
|
-
pruneEmptyDir(dirname(file.file))
|
|
43
|
-
lines.push(`deleted ${file.file} (installer-created, now empty)`)
|
|
45
|
+
unlinkSync(file.file);
|
|
46
|
+
pruneEmptyDir(dirname(file.file));
|
|
47
|
+
lines.push(`deleted ${file.file} (installer-created, now empty)`);
|
|
44
48
|
} else {
|
|
45
|
-
writeConfig(file.file, updated)
|
|
46
|
-
lines.push(`removed rules block${opts.label} from ${file.file}`)
|
|
49
|
+
writeConfig(file.file, updated);
|
|
50
|
+
lines.push(`removed rules block${opts.label} from ${file.file}`);
|
|
47
51
|
}
|
|
48
|
-
return lines
|
|
52
|
+
return lines;
|
|
49
53
|
}
|
|
50
|
-
const reverted = codec.revert(text, file)
|
|
54
|
+
const reverted = codec.revert(text, file);
|
|
51
55
|
if (opts.whole && file.created && codec.isEffectivelyEmpty(reverted.text)) {
|
|
52
|
-
unlinkSync(file.file)
|
|
53
|
-
pruneEmptyDir(dirname(file.file))
|
|
54
|
-
lines.push(`deleted ${file.file} (installer-created, now empty)`)
|
|
56
|
+
unlinkSync(file.file);
|
|
57
|
+
pruneEmptyDir(dirname(file.file));
|
|
58
|
+
lines.push(`deleted ${file.file} (installer-created, now empty)`);
|
|
55
59
|
} else {
|
|
56
|
-
writeConfig(file.file, reverted.text)
|
|
57
|
-
lines.push(`reverted artifacts${opts.label} in ${file.file}`)
|
|
60
|
+
writeConfig(file.file, reverted.text);
|
|
61
|
+
lines.push(`reverted artifacts${opts.label} in ${file.file}`);
|
|
58
62
|
}
|
|
59
|
-
lines.push(...reverted.notes.map((note) => ` note: ${note}`))
|
|
60
|
-
return lines
|
|
63
|
+
lines.push(...reverted.notes.map((note) => ` note: ${note}`));
|
|
64
|
+
return lines;
|
|
61
65
|
}
|
|
@@ -1,32 +1,43 @@
|
|
|
1
|
-
import type { Tier } from "./types"
|
|
1
|
+
import type { Tier } from "./types";
|
|
2
2
|
|
|
3
3
|
/** Full MCP tool ids, per the installer design's rules non-negotiables. */
|
|
4
|
-
export const MCP_TOOL_IDS = {
|
|
4
|
+
export const MCP_TOOL_IDS = {
|
|
5
|
+
search: "mcp__deepseek__deepseek_web_search",
|
|
6
|
+
fetch: "mcp__deepseek__deepseek_fetch",
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Raw server-declared tool names (no `mcp__` prefix): what Pi's extension-style tool ids
|
|
11
|
+
* and host per-server tool keys (Codex's `disabled_tools`) match on, verbatim.
|
|
12
|
+
*/
|
|
13
|
+
export const RAW_MCP_TOOL_IDS = { search: "deepseek_web_search", fetch: "deepseek_fetch" } as const;
|
|
5
14
|
|
|
6
15
|
/**
|
|
7
16
|
* Builds the rules block lines for the preferred tier: names the full tool ids,
|
|
8
17
|
* states trigger conditions rather than unconditional preference, and lists the
|
|
9
|
-
* private-network exclusion. Only
|
|
18
|
+
* private-network exclusion. Only steers capabilities whose tier is preferred.
|
|
10
19
|
* Hosts whose tools are named differently pass their own ids.
|
|
11
20
|
*/
|
|
12
|
-
export function rulesLines(
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
export function rulesLines(
|
|
22
|
+
ids: { readonly search: string; readonly fetch: string },
|
|
23
|
+
search: Tier,
|
|
24
|
+
fetch: Tier,
|
|
25
|
+
): string[] {
|
|
26
|
+
const lines: string[] = ["DeepSeek retrieval guidance:"];
|
|
27
|
+
if (search === "preferred") {
|
|
15
28
|
lines.push(
|
|
16
29
|
`- Use ${ids.search} when the task needs fresh external information: current docs, version numbers,`,
|
|
17
30
|
" changelogs, or anything you cannot know from the repository alone. Do not search for facts already",
|
|
18
31
|
" present locally.",
|
|
19
|
-
)
|
|
32
|
+
);
|
|
20
33
|
}
|
|
21
|
-
if (fetch
|
|
22
|
-
lines.push(
|
|
23
|
-
`- Use ${ids.fetch} to read a specific public web page as Markdown when its content is needed.`,
|
|
24
|
-
)
|
|
34
|
+
if (fetch === "preferred") {
|
|
35
|
+
lines.push(`- Use ${ids.fetch} to read a specific public web page as Markdown when its content is needed.`);
|
|
25
36
|
}
|
|
26
37
|
lines.push(
|
|
27
38
|
"- Never send localhost or private-network URLs to the DeepSeek tools; use local tools for those (the",
|
|
28
39
|
" server blocks private hosts by default).",
|
|
29
40
|
"- DeepSeek requests are billed per successful call; retrieve only what the task needs.",
|
|
30
|
-
)
|
|
31
|
-
return lines
|
|
41
|
+
);
|
|
42
|
+
return lines;
|
|
32
43
|
}
|