dsh-engram 0.3.3 → 0.3.5

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.
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-engram CLI — the from-scratch user's window into Context GC.
4
+ *
5
+ * npx dsh-engram status # what is Context GC doing right now (host + web)
6
+ * npx dsh-engram doctor # status + ranked next steps for the gaps it finds
7
+ * npx dsh-engram enable # re-run the boot-time web auto-wiring (usually a no-op)
8
+ * npx dsh-engram revert # restore the stock compaction rows everywhere —
9
+ * # run BEFORE uninstalling dsh-engram
10
+ *
11
+ * `status`/`doctor` read the durable snapshot the plugin writes at boot
12
+ * (`$DSH_HOME/engram/context-gc.status.json`) and cross-check the live preset
13
+ * files. `enable`/`revert` act on preset compositions found under the shipped
14
+ * and user preset roots (or a single file via `--file <path>`).
15
+ */
16
+ import { readFile, readdir, access } from "node:fs/promises";
17
+ import { readFileSync } from "node:fs";
18
+ import { dirname, basename, resolve, join } from "node:path";
19
+ import { homedir } from "node:os";
20
+ import { fileURLToPath } from "node:url";
21
+ import {
22
+ compactionWireStatus,
23
+ provisionWebCompaction,
24
+ revertWebCompaction,
25
+ } from "../lib/web-provision.js";
26
+ import { contextGcStatusPath, readContextGcStatus } from "../lib/status.js";
27
+
28
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
29
+
30
+ function log(line) {
31
+ process.stdout.write(`${line}\n`);
32
+ }
33
+
34
+ async function exists(path) {
35
+ try {
36
+ await access(path);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ const COMPOSITION_NAMES = ["agent.cordis.yml", "agent.json"];
44
+
45
+ async function compositionIn(dir) {
46
+ for (const name of COMPOSITION_NAMES) {
47
+ const candidate = join(dir, name);
48
+ if (await exists(candidate)) return candidate;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ /** Preset roots this CLI can act on: last-boot facts + the two well-known ones. */
54
+ async function discoverRoots(status) {
55
+ const roots = [];
56
+ const seen = new Set();
57
+ const push = (root) => {
58
+ const abs = resolve(root);
59
+ if (seen.has(abs)) return;
60
+ seen.add(abs);
61
+ roots.push(abs);
62
+ };
63
+ // Recorded directly from the last boot (authoritative for real installs).
64
+ for (const entry of status?.web?.presets ?? []) {
65
+ if (typeof entry?.path === "string") push(dirname(entry.path));
66
+ }
67
+ // Well-known roots: shipped harness + the user root.
68
+ push(resolve(REPO_ROOT, "..", "deepseek-harness/apps/cli/config/agent-presets"));
69
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh");
70
+ push(join(dshHome, ".agent-presets"));
71
+ return roots;
72
+ }
73
+
74
+ /** Discover preset compositions, shadowing duplicate ids like the roster does. */
75
+ async function discoverPresets(status) {
76
+ const seen = new Set();
77
+ const presets = [];
78
+ for (const root of await discoverRoots(status)) {
79
+ let entries = [];
80
+ try {
81
+ entries = await readdir(root, { withFileTypes: true });
82
+ } catch {
83
+ continue;
84
+ }
85
+ for (const entry of entries) {
86
+ if (!entry.isDirectory()) continue;
87
+ if (seen.has(entry.name)) continue;
88
+ const file = await compositionIn(join(root, entry.name));
89
+ if (file === undefined) continue;
90
+ seen.add(entry.name);
91
+ presets.push({ id: entry.name, path: file });
92
+ }
93
+ }
94
+ return presets;
95
+ }
96
+
97
+ function hostLine(host) {
98
+ if (host === "context-gc") return "Context GC(机械驱逐 + 重取指针)";
99
+ if (host === "default") return "DSH 默认 LLM 摘要(gcReplacesCompaction:false)";
100
+ if (host === "unavailable") return "未接管(引擎不可用)";
101
+ return "未知(尚未记录)";
102
+ }
103
+
104
+ function webLine(entry) {
105
+ const action = entry?.action ?? "?";
106
+ switch (entry?.action) {
107
+ case "swapped": return `已接管(${entry.path})`;
108
+ case "already": return `已接管 · 配置一致(${entry.path})`;
109
+ case "skipped": return `未接管 · ${entry.reason === "custom" ? "自定义/无 compaction 布局,不碰" : entry.reason ?? "跳过"}(${entry.path})`;
110
+ case "failed": return `失败 · ${entry.reason ?? "?"}(${entry.path})`;
111
+ default: return `${action} · ${entry?.path ?? "?"}`;
112
+ }
113
+ }
114
+
115
+ async function printStatus() {
116
+ const status = await readContextGcStatus();
117
+ const presets = await discoverPresets(status);
118
+
119
+ log("── dsh-engram Context GC 状态 ──────────────────────────────");
120
+ if (status === undefined) {
121
+ log(" 尚无状态文件(插件还没写过,或本机没跑过 dsh web)。");
122
+ } else {
123
+ log(` 状态快照:${new Date(status.writtenAt ?? 0).toLocaleString()}(插件上次启动时写入)`);
124
+ log(` host 平面:${hostLine(status.host)}`);
125
+ log(` web 平面:${status.web?.action === "pending" ? "启动中(agentPresets 尚未就绪)" : `上次装配 ${status.web?.action ?? "?"},${(status.web?.presets ?? []).length} 个预设`}`);
126
+ log(` 配置:autoWebCompaction=${status.configSource?.autoWebCompaction ?? "?"} · gcReplacesCompaction=${status.configSource?.gcReplacesCompaction ?? "?"} · gcNarrative=${status.configSource?.gcNarrative ?? "?"}`);
127
+ }
128
+
129
+ log("");
130
+ if (presets.length === 0) {
131
+ log(" 未发现任何 web 代理预设(shipped 根与用户根都找不到)。");
132
+ } else {
133
+ log(` ── web 预设现场(${presets.length} 个)──`);
134
+ for (const preset of presets) {
135
+ let text;
136
+ try {
137
+ text = await readFile(preset.path, "utf8");
138
+ } catch {
139
+ log(` ${preset.id}: 无法读取`);
140
+ continue;
141
+ }
142
+ const cs = compactionWireStatus(text);
143
+ const note = cs === "wired"
144
+ ? "已接管(dsh-engram/compaction 行)"
145
+ : cs === "stock"
146
+ ? "出厂布局,重启 dsh web 后自动接管"
147
+ : "自定义/无 compaction,插件不碰";
148
+ log(` ${preset.id}: ${cs} — ${note}`);
149
+ }
150
+ }
151
+ return status;
152
+ }
153
+
154
+ async function printDoctor() {
155
+ const status = await printStatus();
156
+ log("");
157
+ log("── 建议(doctor)──────────────────────────────────────────");
158
+ const prev = status?.web?.presets ?? [];
159
+ const presets = await discoverPresets(status);
160
+ const issues = [];
161
+
162
+ if (status === undefined) {
163
+ issues.push("尚无状态快照:把 dsh-engram 装进 profile 并启动一次(dsh web / dsh headless),插件会在启动时写入状态与自动装配。");
164
+ } else if (status.host !== "context-gc") {
165
+ issues.push(
166
+ `host 平面 ${status.host}:${status.host === "unavailable"
167
+ ? "引擎不可用——确认 @deepseek-ai/dsh-compaction-basic 依赖已安装(见启动日志 dsh-compaction-basic unavailable)。"
168
+ : "当前是 DSH 默认 LLM 摘要——要在 host 平面启用 Context GC,把 gcReplacesCompaction 设为 true。"}`,
169
+ );
170
+ }
171
+
172
+ const stockLeft = presets.filter((p) => {
173
+ try {
174
+ return compactionWireStatus(readFileSyncSafe(p.path)) === "stock";
175
+ } catch {
176
+ return false;
177
+ }
178
+ });
179
+ if (stockLeft.length > 0) {
180
+ issues.push(`web 平面还有 ${stockLeft.length} 个出厂布局预设未接管(${stockLeft.map((p) => p.id).join("、")}):重启 dsh web 让自动装配跑一次,或 npx dsh-engram enable。`);
181
+ }
182
+ const customLeft = presets.filter((p) => {
183
+ try {
184
+ return compactionWireStatus(readFileSyncSafe(p.path)) === "custom";
185
+ } catch {
186
+ return false;
187
+ }
188
+ });
189
+ if (customLeft.length > 0) {
190
+ issues.push(`${customLeft.length} 个预设是自定义/无 compaction 布局(${customLeft.map((p) => p.id).join("、")}),插件不会碰;需要的话手动把 compaction 组换成 dsh-engram/compaction。`);
191
+ }
192
+ if (presets.some((p) => compactionWireStatus(readFileSyncSafe(p.path)) === "wired")) {
193
+ issues.push("有预设已被接管:卸载 dsh-engram 前先 npx dsh-engram revert,避免预设留下悬空引用。");
194
+ }
195
+ if (issues.length === 0) {
196
+ log(" 一切正常:host 平面 + 全部 stock 预设都已接管 Context GC。");
197
+ return;
198
+ }
199
+ issues.forEach((line, index) => log(` ${index + 1}. ${line}`));
200
+ }
201
+
202
+ /** Read helper that never throws (used by the sync-style filtering above). */
203
+ function readFileSyncSafe(path) {
204
+ try {
205
+ return readFileSync(path, "utf8");
206
+ } catch {
207
+ return "";
208
+ }
209
+ }
210
+
211
+ function toService(preset) {
212
+ return {
213
+ async resolve() {
214
+ return { id: preset.id, path: preset.path };
215
+ },
216
+ };
217
+ }
218
+
219
+ async function runEnableRevert(command, fileFlag) {
220
+ const status = await readContextGcStatus();
221
+ let presets;
222
+ if (fileFlag !== undefined) {
223
+ const abs = resolve(fileFlag);
224
+ if (!(await exists(abs))) throw new Error(`file not found: ${abs}`);
225
+ presets = [{ id: basename(dirname(abs)), path: abs }];
226
+ } else {
227
+ presets = await discoverPresets(status);
228
+ }
229
+ if (presets.length === 0) {
230
+ log("未发现可操作预设。用 --file <path> 明确指定一个预设组合文件。");
231
+ return;
232
+ }
233
+
234
+ log(`dsh-engram: ${command} — ${presets.length} 个预设`);
235
+ let changed = 0;
236
+ for (const preset of presets) {
237
+ const service = toService(preset);
238
+ const report = command === "enable"
239
+ ? await provisionWebCompaction(service, { log })
240
+ : await revertWebCompaction(service, { log });
241
+ const entry = report.presets[0];
242
+ log(` ${preset.id}: ${entry?.action ?? report.action}${entry?.reason ? ` (${entry.reason})` : ""}`);
243
+ if (entry?.action === "swapped" || entry?.action === "reverted") changed += 1;
244
+ }
245
+ if (command === "enable" && changed > 0) log(" 下一步:重启 dsh web 让会话生效。");
246
+ if (command === "revert" && changed > 0) log(" 已全部还原为出厂 compaction 行——可以安全卸载 dsh-engram。");
247
+ }
248
+
249
+ async function main() {
250
+ const [, , rawCommand, ...rest] = process.argv;
251
+ const command = rawCommand ?? "status";
252
+ const fileFlagIndex = rest.indexOf("--file");
253
+ const fileFlag = fileFlagIndex >= 0 ? rest[fileFlagIndex + 1] : undefined;
254
+ if (command === "status") {
255
+ await printStatus();
256
+ } else if (command === "doctor") {
257
+ await printDoctor();
258
+ } else if (command === "enable" || command === "revert") {
259
+ await runEnableRevert(command, fileFlag);
260
+ } else {
261
+ throw new Error(`未知命令 "${command}"(用 status | doctor | enable | revert)`);
262
+ }
263
+ }
264
+
265
+ await main().catch((error) => {
266
+ process.stderr.write(`dsh-engram: ${String(error?.message ?? error)}\n`);
267
+ process.exitCode = 1;
268
+ });
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deprecated alias — forwards to the shipped `dsh-engram` CLI so the earlier
4
+ * `npm run web-compaction:*` scripts keep working. New code should call
5
+ * `node scripts/dsh-engram.mjs status|doctor|enable|revert`.
6
+ */
7
+ await import("./dsh-engram.mjs");
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Pre-publish self-check (runs on `npm publish` via prepublishOnly, and
3
+ * directly via `npm run prepublishOnly`).
4
+ *
5
+ * Packs the tarball, then installs it into a temp dir together with the
6
+ * optional peer packages exactly as a real profile's pnpm would resolve
7
+ * them, and finally imports BOTH the host entry (lib/index.js) and the
8
+ * client bundle (lib/client.js) from the INSTALLED artifact. Fails with a
9
+ * non-zero exit when the published fileset is missing anything the plugin
10
+ * needs to load — the tarball-only import is the genuine surface, not the
11
+ * working-tree files.
12
+ *
13
+ * Requires network access to the npm registry for the @deepseek-ai peers
14
+ * and zod; skipped cleanly on failure? — no: an unverifiable publish is
15
+ * refused.
16
+ */
17
+ import { execFileSync } from "node:child_process";
18
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { dirname, join, resolve } from "node:path";
21
+ import { fileURLToPath, pathToFileURL } from "node:url";
22
+
23
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
24
+ const tmp = mkdtempSync(join(tmpdir(), "dsh-engram-pack-"));
25
+ const PEERS = [
26
+ "@deepseek-ai/cordis@^4.0.1",
27
+ "@deepseek-ai/dsh-settings@^0.1.0-rc.7",
28
+ "@deepseek-ai/dsh-storage-domain@^0.1.0-rc.7",
29
+ "@deepseek-ai/schemastery@^3.18.1",
30
+ ];
31
+
32
+ function sh(cmd, args, cwd) {
33
+ execFileSync(cmd, args, { cwd, stdio: "inherit" });
34
+ }
35
+
36
+ /** Newest mtime (ms) of any client source file under `dir` (recursive). */
37
+ function newestSourceMtime(dir, acc = 0) {
38
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
39
+ const p = join(dir, entry.name);
40
+ if (entry.isDirectory()) {
41
+ acc = newestSourceMtime(p, acc);
42
+ } else if (/\.(ts|tsx|mjs)$/.test(entry.name)) {
43
+ acc = Math.max(acc, statSync(p).mtimeMs);
44
+ }
45
+ }
46
+ return acc;
47
+ }
48
+
49
+ /**
50
+ * 0. Fail the publish when the shipped client bundle is stale — the exact
51
+ * 0.3.4-class hazard (edit client/src, forget `npm run build:client`,
52
+ * publish an old GUI). Cheap, and saves a broken release.
53
+ */
54
+ function assertClientBundleFresh() {
55
+ const newestSrc = newestSourceMtime(join(root, "client/src"));
56
+ if (newestSrc === 0) return;
57
+ for (const bundle of ["lib/client.js", "lib/esrModel.mjs"]) {
58
+ const p = join(root, bundle);
59
+ if (!existsSync(p)) throw new Error(`${bundle} is missing — run \`npm run build:client\` before publishing`);
60
+ if (statSync(p).mtimeMs < newestSrc) {
61
+ throw new Error(`${bundle} is stale (a client/src file is newer) — run \`npm run build:client\` before publishing`);
62
+ }
63
+ }
64
+ }
65
+
66
+ try {
67
+ assertClientBundleFresh();
68
+
69
+ // 1. pack. npm publish --dry-run propagates npm_config_dry_run into this
70
+ // script's child npm, which would make the nested pack a no-op — pin
71
+ // dry-run off explicitly so the tarball is always really produced.
72
+ sh("npm", ["pack", "--dry-run=false", "--pack-destination", tmp], root);
73
+ const tarball = readdirSync(tmp).find((f) => f.endsWith(".tgz"));
74
+ if (tarball === undefined) throw new Error("no packed tarball produced");
75
+
76
+ // 2. install the artifact + peers into the temp dir (mirrors a real profile);
77
+ // --dry-run=false again because npm publish --dry-run propagates the flag
78
+ // into child npm and a no-op install would leave nothing to import.
79
+ sh("npm", ["install", "--no-audit", "--no-fund", "--dry-run=false", join(tmp, tarball), ...PEERS], tmp);
80
+
81
+ // 3. import host + client from the INSTALLED fileset (name may be scoped:
82
+ // @scope/pkg installs under node_modules/@scope/pkg)
83
+ const name = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name;
84
+ const installed = join(tmp, "node_modules", ...name.split("/"));
85
+ const manifest = JSON.parse(readFileSync(join(installed, "package.json"), "utf8"));
86
+ const host = await import(pathToFileURL(join(installed, manifest.main)).href);
87
+ // resolveConfig is a deliberate public export (settings test builds the
88
+ // real DEFAULTS from it); widen the host surface consciously, not silently.
89
+ const expected = ["apply", "inject", "name", "resolveConfig"].sort().join(",");
90
+ const actual = Object.keys(host).sort().join(",");
91
+ if (actual !== expected) {
92
+ throw new Error(`host exports mismatch: got ${actual}, want ${expected}`);
93
+ }
94
+ // 4. the client bundle is loaded by the web shell with its externals
95
+ // (react, @deepseek-ai/dsh-client-*) provided by the host — it is NOT a
96
+ // standalone-loadable module, so verify presence + size instead of import.
97
+ const clientStats = statSync(join(installed, "lib/client.js"));
98
+ if (clientStats.size < 10 * 1024) {
99
+ throw new Error(`suspiciously small client bundle: ${clientStats.size} bytes`);
100
+ }
101
+ console.log(
102
+ `✓ packed ${tarball}: host(${actual}) import OK, client bundle present (${clientStats.size} bytes)`,
103
+ );
104
+ } finally {
105
+ rmSync(tmp, { recursive: true, force: true });
106
+ }
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-engram 开发/本地链接安装依赖脚本(跨平台)。
4
+ *
5
+ * 为什么需要它:
6
+ * dsh-engram 宿主侧运行期 import:
7
+ * - zod(registry 唯一外部依赖)
8
+ * - @deepseek-ai/{dsh-settings,dsh-storage-domain,schemastery}(harness 工作区包)
9
+ * - @deepseek-ai/{dsh-compaction,dsh-compaction-basic,dsh-llm}(Context GC 引擎,
10
+ * host 侧动态导入,缺省时引擎静默不加载、DSH 沿用默认 compact)
11
+ * 当插件以 `link:` 方式进 profile 时,Node 从插件的真实路径向上解析 import,
12
+ * 这层 node_modules 不随 git 分发,换机器就会 ERR_MODULE_NOT_FOUND。本脚本
13
+ * 一条命令重建:
14
+ * - zod → 复用 harness pnpm store 的 zod@4.x,找不到则 `npm install`
15
+ * - @deepseek-ai/* → 符号链接到 harness 工作区(同 checkout 即同版本)
16
+ *
17
+ * 用法:
18
+ * node scripts/setup-links.mjs # 建立/修复依赖链接
19
+ * node scripts/setup-links.mjs --check # 只检查并打印,不写入
20
+ *
21
+ * 环境变量:
22
+ * DSH_HARNESS_DIR harness checkout 路径(默认取仓库上一级 ../deepseek-harness)
23
+ */
24
+
25
+ import { existsSync, mkdirSync, readdirSync, readlinkSync, rmdirSync, symlinkSync, unlinkSync } from "node:fs";
26
+ import { spawnSync } from "node:child_process";
27
+ import { dirname, join, resolve } from "node:path";
28
+ import { fileURLToPath, pathToFileURL } from "node:url";
29
+
30
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
31
+ const NODE_MODULES = join(REPO_ROOT, "node_modules");
32
+ const SCOPE = join(NODE_MODULES, "@deepseek-ai");
33
+ const CHECK_ONLY = process.argv.includes("--check");
34
+ const IS_WIN = process.platform === "win32";
35
+
36
+ function looksLikeHarness(dir) {
37
+ return (
38
+ existsSync(join(dir, "vendor", "schemastery", "package.json")) &&
39
+ existsSync(join(dir, "packages", "settings", "settings", "package.json"))
40
+ );
41
+ }
42
+
43
+ /** Locate the harness checkout directory. */
44
+ function findHarness() {
45
+ if (process.env.DSH_HARNESS_DIR) {
46
+ const p = resolve(process.env.DSH_HARNESS_DIR);
47
+ if (!existsSync(p)) throw new Error(`DSH_HARNESS_DIR 不存在: ${p}`);
48
+ return p;
49
+ }
50
+ // common layouts:
51
+ // harness/ + repo at harness/../deepseek-harness (sibling of repo)
52
+ // E:\deepseek-harness + E:\kototoro_demo\dsh-engram (sibling of repo's parent)
53
+ for (const candidate of [
54
+ resolve(REPO_ROOT, "..", "deepseek-harness"), // 与仓库平级
55
+ resolve(REPO_ROOT, "..", "..", "deepseek-harness"), // 与仓库父级平级
56
+ ]) {
57
+ if (looksLikeHarness(candidate)) return candidate;
58
+ }
59
+ let dir = dirname(REPO_ROOT);
60
+ for (let i = 0; i < 5; i++) {
61
+ if (looksLikeHarness(dir)) return dir;
62
+ dir = dirname(dir);
63
+ }
64
+ throw new Error(
65
+ "找不到 deepseek-harness checkout:请设置 DSH_HARNESS_DIR,或把 dsh-engram 放到 " +
66
+ "harness 兄弟目录(../deepseek-harness / ../../deepseek-harness)。",
67
+ );
68
+ }
69
+
70
+ /** Newest zod@4 package dir inside the harness pnpm store, or null. */
71
+ function findStoreZod(harness) {
72
+ const store = join(harness, "node_modules", ".pnpm");
73
+ if (!existsSync(store)) return null;
74
+ let best = null;
75
+ let bestMajor = -1;
76
+ for (const name of readdirSync(store)) {
77
+ const m = /^zod@(\d+)\./.exec(name);
78
+ if (!m) continue;
79
+ const major = Number(m[1]);
80
+ const pkgDir = join(store, name, "node_modules", "zod");
81
+ if (existsSync(join(pkgDir, "package.json")) && major > bestMajor) {
82
+ best = pkgDir;
83
+ bestMajor = major;
84
+ }
85
+ }
86
+ return best;
87
+ }
88
+
89
+ /**
90
+ * The dependency plan: label -> mount point -> source inside the harness.
91
+ * zod's source is resolved from the pnpm store (or null → npm install).
92
+ */
93
+ function buildPlan(harness) {
94
+ const zodDir = findStoreZod(harness);
95
+ const plan = [
96
+ {
97
+ label: "zod",
98
+ mount: join(NODE_MODULES, "zod"),
99
+ target: zodDir,
100
+ npmInstall: zodDir === null,
101
+ },
102
+ {
103
+ label: "@deepseek-ai/dsh-settings",
104
+ mount: join(SCOPE, "dsh-settings"),
105
+ target: join(harness, "packages", "settings", "settings"),
106
+ },
107
+ {
108
+ label: "@deepseek-ai/dsh-storage-domain",
109
+ mount: join(SCOPE, "dsh-storage-domain"),
110
+ target: join(harness, "packages", "storage", "storage-domain"),
111
+ },
112
+ {
113
+ label: "@deepseek-ai/schemastery",
114
+ mount: join(SCOPE, "schemastery"),
115
+ target: join(harness, "vendor", "schemastery"),
116
+ },
117
+ {
118
+ label: "@deepseek-ai/dsh-compaction",
119
+ mount: join(SCOPE, "dsh-compaction"),
120
+ target: join(harness, "packages", "compaction", "compaction"),
121
+ },
122
+ {
123
+ label: "@deepseek-ai/dsh-compaction-basic",
124
+ mount: join(SCOPE, "dsh-compaction-basic"),
125
+ target: join(harness, "packages", "compaction", "compaction-basic"),
126
+ },
127
+ {
128
+ label: "@deepseek-ai/dsh-llm",
129
+ mount: join(SCOPE, "dsh-llm"),
130
+ target: join(harness, "packages", "llm", "llm"),
131
+ },
132
+ ];
133
+ return plan;
134
+ }
135
+
136
+ /**
137
+ * A mount is "installed" when it is:
138
+ * - a symlink/junction pointing at the expected source, or
139
+ * - a real directory carrying a package.json (e.g. `npm install` output).
140
+ */
141
+ function isInstalled(mount, expected) {
142
+ if (existsSync(mount)) {
143
+ try {
144
+ if (resolve(readlinkSync(mount)) === resolve(expected)) return true;
145
+ } catch {
146
+ /* not a symlink — fall through */
147
+ }
148
+ if (existsSync(join(mount, "package.json"))) return true;
149
+ }
150
+ return false;
151
+ }
152
+
153
+ function ensureLink(item) {
154
+ const { label, mount, target, npmInstall } = item;
155
+ if (npmInstall && target === null) {
156
+ if (CHECK_ONLY) {
157
+ console.log(` ! ${label}: harness store 无 zod,需运行 \`npm install\``);
158
+ return;
159
+ }
160
+ console.log(` … ${label}: harness store 无 zod,执行 npm install(请稍候)`);
161
+ const npm = IS_WIN ? "npm.cmd" : "npm";
162
+ const r = spawnSync(npm, ["install", "--no-audit", "--no-fund"], {
163
+ cwd: REPO_ROOT,
164
+ stdio: "inherit",
165
+ shell: IS_WIN,
166
+ });
167
+ if (r.status !== 0) {
168
+ console.error(` ! ${label}: npm install 失败(exit ${r.status})`);
169
+ return;
170
+ }
171
+ console.log(` + ${label}: npm install 完成`);
172
+ return;
173
+ }
174
+ if (!existsSync(target)) {
175
+ console.log(` ! ${label}: 源不存在(${target}),请检查 harness checkout`);
176
+ return;
177
+ }
178
+ if (existsSync(mount)) {
179
+ if (isInstalled(mount, target)) {
180
+ console.log(` ✓ ${label} -> ${target}`);
181
+ return;
182
+ }
183
+ if (CHECK_ONLY) {
184
+ console.log(` ! ${label}: ${mount} 已存在但状态异常,运行脚本修复`);
185
+ return;
186
+ }
187
+ // 允许替换:符号链接直接 unlink;pnpm 预建的空目录用 rmdir 清掉。
188
+ try {
189
+ unlinkSync(mount);
190
+ console.log(` ~ ${label}: 替换旧链接`);
191
+ return;
192
+ } catch {
193
+ /* not a symlink */
194
+ }
195
+ try {
196
+ if (readdirSync(mount).length === 0) {
197
+ rmdirSync(mount);
198
+ console.log(` ~ ${label}: 移除空目录后重建`);
199
+ } else {
200
+ console.log(` ! ${label}: ${mount} 是含文件的真实目录,无法自动替换,请手动清理后重试`);
201
+ return;
202
+ }
203
+ } catch {
204
+ console.log(` ! ${label}: ${mount} 无法自动替换,请手动清理后重试`);
205
+ return;
206
+ }
207
+ }
208
+ if (CHECK_ONLY) {
209
+ console.log(` ! ${label}: 缺失(需创建)`);
210
+ return;
211
+ }
212
+ mkdirSync(dirname(mount), { recursive: true });
213
+ symlinkSync(target, mount, IS_WIN ? "junction" : "dir");
214
+ console.log(` + ${label} -> ${target}`);
215
+ }
216
+
217
+ try {
218
+ const harness = findHarness();
219
+ console.log(`repo : ${REPO_ROOT}`);
220
+ console.log(`harness : ${harness}`);
221
+ for (const item of buildPlan(harness)) ensureLink(item);
222
+
223
+ // Self-verify: the whole host module graph must resolve now — this is the
224
+ // exact condition `dsh web` needs at boot (no more ERR_MODULE_NOT_FOUND).
225
+ const entry = pathToFileURL(join(REPO_ROOT, "lib", "index.js")).href;
226
+ const check = spawnSync(
227
+ process.execPath,
228
+ ["--input-type=module", "-e",
229
+ `import(${JSON.stringify(entry)}).then(()=>console.log("host imports OK: " + ${JSON.stringify(entry)})).catch((e)=>{console.error("host imports FAIL: "+e.message);process.exit(1)})`],
230
+ { encoding: "utf8" },
231
+ );
232
+ const out = (check.stdout || check.stderr || "").toString().trim();
233
+ console.log(`自检 : ${out || "(无输出,exit " + check.status + ")"}`);
234
+ if (check.status !== 0) {
235
+ console.error("依赖自检未通过,请检查上方输出后重试(或不放心先跑 --check 看看)。");
236
+ process.exit(1);
237
+ }
238
+ } catch (error) {
239
+ console.error(`setup-links 失败: ${error.message}`);
240
+ process.exit(1);
241
+ }