@modusensus/dsh-mneme 0.7.31 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +65 -27
  2. package/lib/api.js +139 -6
  3. package/lib/client.js +369 -32
  4. package/lib/commands.js +36 -5
  5. package/lib/config.js +101 -14
  6. package/lib/dream/decisions.js +147 -14
  7. package/lib/dream/sleep.js +84 -34
  8. package/lib/dream.js +250 -60
  9. package/lib/embedding.js +22 -2
  10. package/lib/index.js +68 -37
  11. package/lib/inject.js +54 -13
  12. package/lib/lang.js +168 -0
  13. package/lib/local-embedder.js +23 -9
  14. package/lib/mirror.js +17 -14
  15. package/lib/reranker.js +23 -8
  16. package/lib/runtime/adopt.js +176 -0
  17. package/lib/runtime/closure.js +172 -0
  18. package/lib/runtime/download.js +247 -0
  19. package/lib/runtime/layout.js +235 -0
  20. package/lib/runtime/loader.js +249 -0
  21. package/lib/runtime/provision.js +237 -0
  22. package/lib/runtime/tarball.js +130 -0
  23. package/lib/runtime/verify.js +191 -0
  24. package/lib/scope.js +83 -0
  25. package/lib/service.js +374 -55
  26. package/lib/settings.js +43 -2
  27. package/lib/store.js +126 -13
  28. package/lib/summarize.js +108 -35
  29. package/lib/tools.js +205 -12
  30. package/package.json +7 -2
  31. package/runtime-manifest.json +596 -0
  32. package/scripts/build-runtime-manifest.mjs +217 -0
  33. package/scripts/mneme-runtime.mjs +277 -0
  34. package/src/api.js +139 -6
  35. package/src/commands.js +36 -5
  36. package/src/config.js +101 -14
  37. package/src/dream/decisions.js +147 -14
  38. package/src/dream/sleep.js +84 -34
  39. package/src/dream.js +250 -60
  40. package/src/embedding.js +22 -2
  41. package/src/index.js +68 -37
  42. package/src/inject.js +54 -13
  43. package/src/lang.js +168 -0
  44. package/src/local-embedder.js +23 -9
  45. package/src/mirror.js +17 -14
  46. package/src/reranker.js +23 -8
  47. package/src/runtime/adopt.js +176 -0
  48. package/src/runtime/closure.js +172 -0
  49. package/src/runtime/download.js +247 -0
  50. package/src/runtime/layout.js +235 -0
  51. package/src/runtime/loader.js +249 -0
  52. package/src/runtime/provision.js +237 -0
  53. package/src/runtime/tarball.js +130 -0
  54. package/src/runtime/verify.js +191 -0
  55. package/src/scope.js +83 -0
  56. package/src/service.js +374 -55
  57. package/src/settings.js +43 -2
  58. package/src/store.js +126 -13
  59. package/src/summarize.js +108 -35
  60. package/src/tools.js +205 -12
  61. package/test/api.test.js +175 -9
  62. package/test/client.test.js +152 -10
  63. package/test/commands.test.js +40 -3
  64. package/test/config.test.js +17 -1
  65. package/test/dream-candidate-hybrid.test.js +281 -0
  66. package/test/dream.test.js +172 -0
  67. package/test/embedding-ready.test.js +196 -0
  68. package/test/helpers/runtime-source.js +60 -0
  69. package/test/helpers/tar-builder.js +80 -0
  70. package/test/inject.test.js +130 -1
  71. package/test/lang.test.js +87 -0
  72. package/test/quality-filter.test.js +68 -0
  73. package/test/reasoning-effort.test.js +106 -15
  74. package/test/reindex-backfill.test.js +62 -0
  75. package/test/runtime-adopt.test.js +206 -0
  76. package/test/runtime-cli.test.js +89 -0
  77. package/test/runtime-closure.test.js +193 -0
  78. package/test/runtime-download.test.js +302 -0
  79. package/test/runtime-layout.test.js +163 -0
  80. package/test/runtime-loader.test.js +237 -0
  81. package/test/runtime-manifest.test.js +158 -0
  82. package/test/runtime-provision.test.js +101 -0
  83. package/test/runtime-tarball.test.js +82 -0
  84. package/test/runtime-verify.test.js +214 -0
  85. package/test/scope-retrieval.test.js +197 -0
  86. package/test/scope-storage.test.js +247 -0
  87. package/test/scope-strict.test.js +165 -0
  88. package/test/service.test.js +76 -0
  89. package/test/sleep-action-set.test.js +422 -0
  90. package/test/store.test.js +14 -0
  91. package/test/summarize.test.js +224 -0
  92. package/test/tools.test.js +30 -5
  93. package/test/webServer-optional.test.js +34 -1
@@ -0,0 +1,176 @@
1
+ // 收编宿主已有的运行时(issue #131 / PR-A)
2
+ //
3
+ // 场景:老用户(以及本机)profile 里已经装好了整套 transformers + onnxruntime,
4
+ // 而且它就是能跑的。删依赖之前先把这套「收编」进插件自管的目录,老用户的本地
5
+ // 嵌入就不会因为后续的 prune 而断掉——这是分两步发版里第一步的全部意义。
6
+ //
7
+ // 两条实现约束:
8
+ //
9
+ // 1) 同卷硬链接、跨卷回退复制。收编的源和目标通常都在用户家目录(同卷),
10
+ // 硬链接是瞬时的、不额外占盘;一旦跨卷 linkSync 会抛 EXDEV,则逐个文件回退复制。
11
+ //
12
+ // 2) 不跟随符号链接。pnpm 的 isolated 布局用符号链接指向虚拟store,跟随它会把
13
+ // 宿主 store 里的东西拖进来、还会让目录层级失真。这里选择跳过并如实记进
14
+ // warnings,让上层的验证去发现「收编不完整」,而不是悄悄产出一份半对的运行时。
15
+ //
16
+ // @module dsh-mneme/runtime/adopt
17
+ import { copyFileSync, existsSync, linkSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { DEFAULT_MAX_PACKAGES, planClosure } from "./closure.js";
20
+ import { defaultRuntimeDir, nodeModulesDir, payloadDir, payloadId, runtimeManifestPath } from "./layout.js";
21
+
22
+ /** 清单版本:将来字段变化时用来判断怎么读。 */
23
+ export const RUNTIME_MANIFEST_VERSION = 1;
24
+
25
+ /**
26
+ * 把一个包目录物化到目标位置。跳过嵌套的 `node_modules`——那些包在闭包计划里
27
+ * 是独立条目,会各自落到自己的相对位置;不跳就会重复搬一遍。
28
+ * @param {object} opts - 选项。
29
+ * @param {Function} [opts.link] - 硬链接实现,默认 fs.linkSync;测试用来注入失败。
30
+ * @returns {{files: number, bytes: number, linked: number, copied: number, symlinks: number, linkError: string|null}}
31
+ */
32
+ export function materializePackage({ srcDir, destDir, warnings = [], link = linkSync }) {
33
+ const stats = { files: 0, bytes: 0, linked: 0, copied: 0, symlinks: 0, linkError: null };
34
+
35
+ const walk = (src) => {
36
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
37
+ // 嵌套 node_modules 由闭包计划单独负责;跳过以免重复搬运。
38
+ if (entry.isDirectory() && entry.name === "node_modules") continue;
39
+ const full = join(src, entry.name);
40
+ const dest = join(destDir, full.slice(srcDir.length + 1));
41
+ if (entry.isSymbolicLink()) {
42
+ stats.symlinks++;
43
+ warnings.push(`跳过符号链接:${full}`);
44
+ continue;
45
+ }
46
+ if (entry.isDirectory()) {
47
+ mkdirSync(dest, { recursive: true });
48
+ walk(full);
49
+ continue;
50
+ }
51
+ if (!entry.isFile()) continue;
52
+ mkdirSync(join(dest, ".."), { recursive: true });
53
+ try {
54
+ link(full, dest);
55
+ stats.linked++;
56
+ } catch (error) {
57
+ // 跨卷(EXDEV)、权限、文件系统不支持硬链接:逐个文件回退复制。
58
+ // 这里只记录原因(返回给调用方),不往 warnings 里塞——warnings 是「按文件」
59
+ // 的事件(例如跳过符号链接),而「为什么整体变成复制」是「按整次收编」的事实,
60
+ // 去重与呈现由 adoptRuntime 负责,否则每个包都会重复记一条。
61
+ if (stats.linkError === null) {
62
+ stats.linkError = `${error?.code ?? ""} ${error?.message ?? error}`.trim();
63
+ }
64
+ copyFileSync(full, dest);
65
+ stats.copied++;
66
+ }
67
+ stats.files++;
68
+ stats.bytes += statSync(full).size;
69
+ }
70
+ };
71
+
72
+ mkdirSync(destDir, { recursive: true });
73
+ walk(srcDir);
74
+ return stats;
75
+ }
76
+
77
+ /**
78
+ * 从一份现成的 `node_modules` 收编出一份自管运行时,并写下清单。
79
+ * @param {object} opts - 选项。
80
+ * @param {string} opts.fromModulesDir - 源 node_modules(通常是宿主的)。
81
+ * @param {string} [opts.runtimeDir] - 运行时根目录。
82
+ * @param {string} [opts.platform] - 目标平台。
83
+ * @param {string} [opts.arch] - 目标架构。
84
+ * @param {string} [opts.procedure] - 来源标记(adopted / downloaded / manual)。
85
+ * @param {boolean} [opts.overwrite] - 目标已存在时是否覆盖。
86
+ * @param {number} [opts.maxPackages] - 闭包条目上限。
87
+ * @param {Function} [opts.link] - 硬链接实现,默认 fs.linkSync;测试用来注入失败以覆盖回退分支
88
+ * (与 src/local-embedder.js 的 engineFactory 同一思路:把不可复现的环境差异变成可注入的依赖)。
89
+ * @returns {object} 结果;失败时 ok=false 且带 reason,不抛异常。
90
+ */
91
+ export function adoptRuntime({
92
+ fromModulesDir,
93
+ runtimeDir = defaultRuntimeDir(),
94
+ platform = process.platform,
95
+ arch = process.arch,
96
+ procedure = "adopted",
97
+ overwrite = false,
98
+ maxPackages,
99
+ link
100
+ }) {
101
+ const plan = planClosure({ rootModulesDir: fromModulesDir, platform, arch, maxPackages });
102
+ const entryPackage = plan.packages[0];
103
+ if (entryPackage === undefined) {
104
+ return { ok: false, reason: plan.gaps[0]?.reason ?? "入口包未解析到", plan };
105
+ }
106
+
107
+ const version = entryPackage.version ?? "unknown";
108
+ const id = payloadId({ version, platform, arch });
109
+ const dir = payloadDir(runtimeDir, id);
110
+
111
+ if (existsSync(dir) && !overwrite) {
112
+ return { ok: false, reason: `目标已存在:${dir}(需要覆盖请传 overwrite: true)`, payloadDir: dir, payloadId: id, plan };
113
+ }
114
+
115
+ // 截断的闭包必须拒绝。maxPackages 截断会静默丢掉传递依赖,而结构检查(describePayload)
116
+ // 只看必需包、版本和入口文件 —— 于是一份残缺的 payload 会被判为可用、被 loader 选中,
117
+ // 直到 import 原生模块时才炸,且错误现场离真正的原因很远。宁可在物化前明确失败。
118
+ if (plan.truncated) {
119
+ return {
120
+ ok: false,
121
+ reason: `依赖闭包被截断(上限 ${maxPackages ?? DEFAULT_MAX_PACKAGES} 个包),拒绝收编;请提高 maxPackages 后重试`,
122
+ payloadDir: dir,
123
+ payloadId: id,
124
+ plan
125
+ };
126
+ }
127
+
128
+ // 覆盖前必须先清空目标目录。不清的话,linkSync 对已存在的目标路径抛 EEXIST,
129
+ // 于是每个文件都落到复制分支:物化方式会假报成 "copy"、警告会错说「硬链接不可用」、
130
+ // 整份闭包真实占盘,而且上一次闭包留下的残留文件永远不会被清掉。
131
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
132
+
133
+ const warnings = [];
134
+ const totals = { files: 0, bytes: 0, linked: 0, copied: 0, symlinks: 0 };
135
+ let linkError = null;
136
+ try {
137
+ for (const pkg of plan.packages) {
138
+ const stats = materializePackage({
139
+ srcDir: pkg.srcDir,
140
+ destDir: join(nodeModulesDir(dir), pkg.rel),
141
+ warnings,
142
+ link
143
+ });
144
+ for (const key of Object.keys(totals)) totals[key] += stats[key];
145
+ if (linkError === null && stats.linkError !== null) linkError = stats.linkError;
146
+ }
147
+ } catch (error) {
148
+ // 契约是「失败时 ok=false 且带 reason,不抛」。物化到一半失败更要清干净:半份 payload 会被
149
+ // 结构检查看成「差不多能用」,然后在 import 原生模块时才炸,错误现场离原因很远。
150
+ rmSync(dir, { recursive: true, force: true });
151
+ return { ok: false, reason: `物化失败:${String(error?.message ?? error)}` };
152
+ }
153
+ // 物化方式如实记进清单:收编「本该不占盘却占了盘」时,用户和面板都能看到原因。
154
+ const mode = totals.copied === 0 ? "hardlink" : totals.linked === 0 ? "copy" : "mixed";
155
+ if (linkError !== null) warnings.push(`硬链接不可用,已回退为复制:${linkError}`);
156
+
157
+ const manifest = {
158
+ manifestVersion: RUNTIME_MANIFEST_VERSION,
159
+ payloadId: id,
160
+ entry: plan.entry,
161
+ version,
162
+ platform,
163
+ arch,
164
+ procedure,
165
+ createdAt: new Date().toISOString(),
166
+ materialize: { mode, linkError },
167
+ packages: plan.packages.map((p) => ({ name: p.name, version: p.version, rel: p.rel, optional: p.optional })),
168
+ gaps: plan.gaps,
169
+ skipped: plan.skipped,
170
+ warnings
171
+ };
172
+ mkdirSync(dir, { recursive: true });
173
+ writeFileSync(runtimeManifestPath(dir), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
174
+
175
+ return { ok: true, payloadDir: dir, payloadId: id, version, plan, manifest, totals };
176
+ }
@@ -0,0 +1,172 @@
1
+ // 依赖闭包遍历 + 平台过滤(issue #131 / PR-A)
2
+ //
3
+ // 用途:adopt(从宿主 node_modules 收编)与 export(导出给别的机器)要回答的是
4
+ // 同一个问题——「一份能跑的运行时,到底需要哪些包,各自该落到哪个位置」。
5
+ //
6
+ // 两个刻意的设计:
7
+ //
8
+ // 1) 按源布局镜像,不压平。入口 transformers.node.mjs 内部是裸导入
9
+ // (`import sharp from "sharp"`、`import * as ONNX_NODE from "onnxruntime-node"`),
10
+ // 靠 Node 逐级向上查找解析。只要目标目录的嵌套结构与源一致,解析行为就一致;
11
+ // 压平会把「同一个包的两个版本分别落在不同层」这类情形悄悄改成另一种结果。
12
+ // 所以 rel 保留 `a/node_modules/b` 这种层级。
13
+ //
14
+ // 2) 跳过 peerDependencies。peer 由宿主提供(例如 @deepseek-ai/* 那些服务),
15
+ // 插件自管的运行时不该把宿主的实现复制进来——复制了反而会和宿主的实例分叉。
16
+ //
17
+ // @module dsh-mneme/runtime/closure
18
+ import { existsSync } from "node:fs";
19
+ import { dirname, join, relative, sep } from "node:path";
20
+ import { readJsonSafe } from "./layout.js";
21
+
22
+ /** 闭包条目数上限:纯属防呆,命中即截断并如实上报,不静默丢包。 */
23
+ export const DEFAULT_MAX_PACKAGES = 400;
24
+
25
+ /**
26
+ * 包是否匹配当前平台。npm 用 package.json 的 os / cpu / libc 字段声明平台,
27
+ * 支持 `["win32"]` 正向匹配与 `["!darwin"]` 反向排除两种写法。
28
+ * 字段缺省表示「不限平台」。
29
+ *
30
+ * libc(glibc / musl)与前两者不同:**只有确实知道本机 libc 时才据此过滤**。
31
+ * 传 null(拿不到证据)就一律放行 —— 反过来「拿不到就当 musl」会把 glibc 变体滤掉,
32
+ * 装出一份必然 import 不起来的运行时,而本仓库没有 Linux 机器能验证这条路径。
33
+ * 少省十几兆,换「不会静默装错」。
34
+ * @param {any} manifest - 包的 package.json(或清单条目)。
35
+ * @param {string} platform - process.platform 取值。
36
+ * @param {string} arch - process.arch 取值。
37
+ * @param {string|null} [libc] - 本机 libc;null/缺省 = 不据此过滤。
38
+ * @returns {boolean} 是否匹配。
39
+ */
40
+ export function matchesPlatform(manifest, platform, arch, libc = null) {
41
+ const check = (values, actual) => {
42
+ if (!Array.isArray(values) || values.length === 0) return true;
43
+ const negated = values.filter((v) => typeof v === "string" && v.startsWith("!"));
44
+ const positive = values.filter((v) => typeof v === "string" && !v.startsWith("!"));
45
+ // 反向排除优先:命中任一 `!x` 即不匹配(与 npm 的语义一致)。
46
+ if (negated.some((v) => v.slice(1) === actual)) return false;
47
+ if (positive.length === 0) return true;
48
+ return positive.includes(actual);
49
+ };
50
+ if (!check(manifest?.os, platform) || !check(manifest?.cpu, arch)) return false;
51
+ if (libc === null || libc === undefined) return true;
52
+ return check(manifest?.libc, libc);
53
+ }
54
+
55
+ /**
56
+ * 按 Node 的解析规则,从 fromDir 出发找 name 的包目录,但**不越出 rootModulesDir**。
57
+ *
58
+ * Node 的规则是:从所在目录逐级向上,每级查 `<级>/node_modules/<name>`。所以对
59
+ * `root/@scope/pkg` 而言,候选依次是 `root/@scope/pkg/node_modules/...`、
60
+ * `root/@scope/node_modules/...`、`root/node_modules/...`(这一级是
61
+ * node_modules 套 node_modules,通常不存在),最后到 `dirname(root)/node_modules/...`
62
+ * ——正好就是 `root/<name>`。因此循环要允许走到 root 的上一级再停。
63
+ * @param {string} fromDir - 从哪个包目录出发解析。
64
+ * @param {string} name - 依赖名。
65
+ * @param {string} rootModulesDir - 允许查找的边界 node_modules 目录。
66
+ * @returns {string|null} 包目录绝对路径,找不到返回 null。
67
+ */
68
+ export function resolvePackageDir(fromDir, name, rootModulesDir) {
69
+ const limit = dirname(rootModulesDir);
70
+ let dir = fromDir;
71
+ for (;;) {
72
+ const candidate = join(dir, "node_modules", name);
73
+ if (existsSync(join(candidate, "package.json"))) return candidate;
74
+ if (dir === limit || dir === dirname(dir)) return null;
75
+ dir = dirname(dir);
76
+ }
77
+ }
78
+
79
+ /** 转成 manifest 里使用的、与平台无关的相对路径(始终用 `/`)。 */
80
+ function toRel(root, dir) {
81
+ return relative(root, dir).split(sep).join("/");
82
+ }
83
+
84
+ /**
85
+ * 计算一份运行时的依赖闭包。
86
+ *
87
+ * 只走 dependencies 与 optionalDependencies:
88
+ * - 必需依赖缺失 → 记进 gaps(让调用方能如实报「这份闭包不完整」);
89
+ * - 可选依赖缺失 → 静默跳过(这正是平台分片包在别的平台上的正常形态);
90
+ * - 平台不匹配的包 → 记进 skipped(导出给别的机器时,跨平台的残留不会被带走)。
91
+ * @param {object} opts - 选项。
92
+ * @param {string} opts.rootModulesDir - 源 node_modules 目录。
93
+ * @param {string} [opts.entry] - 入口包名。
94
+ * @param {string} [opts.platform] - 目标平台。
95
+ * @param {string} [opts.arch] - 目标架构。
96
+ * @param {number} [opts.maxPackages] - 条目上限。
97
+ * @returns {{rootModulesDir: string, entry: string, platform: string, arch: string,
98
+ * packages: {name: string, version: string|null, rel: string, srcDir: string, optional: boolean}[],
99
+ * gaps: {name: string, from: string, reason: string}[],
100
+ * skipped: {name: string, rel: string, reason: string}[], truncated: boolean}}
101
+ */
102
+ export function planClosure({
103
+ rootModulesDir,
104
+ entry = "@huggingface/transformers",
105
+ platform = process.platform,
106
+ arch = process.arch,
107
+ maxPackages = DEFAULT_MAX_PACKAGES
108
+ } = {}) {
109
+ const packages = [];
110
+ const gaps = [];
111
+ const skipped = [];
112
+ const seen = new Set();
113
+
114
+ const entryDir = resolvePackageDir(rootModulesDir, entry, rootModulesDir);
115
+ if (entryDir === null) {
116
+ gaps.push({ name: entry, from: ".", reason: "入口包不存在" });
117
+ return { rootModulesDir, entry, platform, arch, packages, gaps, skipped, truncated: false };
118
+ }
119
+
120
+ // BFS:队列里放已被解析出来的包目录。用真实目录去重,避免软链接/重复边导致来回走。
121
+ const queue = [{ dir: entryDir, rel: toRel(rootModulesDir, entryDir), optional: false }];
122
+ let truncated = false;
123
+
124
+ while (queue.length > 0) {
125
+ const current = queue.shift();
126
+ if (seen.has(current.dir)) continue;
127
+ seen.add(current.dir);
128
+
129
+ const manifest = readJsonSafe(join(current.dir, "package.json"));
130
+ if (manifest === undefined) {
131
+ gaps.push({ name: current.rel, from: current.rel, reason: "package.json 读不到" });
132
+ continue;
133
+ }
134
+ // 入口包本身不做平台过滤:它是用户明确要的那一个。
135
+ if (current.rel !== toRel(rootModulesDir, entryDir) && !matchesPlatform(manifest, platform, arch)) {
136
+ skipped.push({ name: manifest.name ?? current.rel, rel: current.rel, reason: "平台不匹配" });
137
+ continue;
138
+ }
139
+ if (packages.length >= maxPackages) {
140
+ truncated = true;
141
+ break;
142
+ }
143
+ packages.push({
144
+ name: manifest.name ?? current.rel,
145
+ version: manifest.version ?? null,
146
+ rel: current.rel,
147
+ srcDir: current.dir,
148
+ optional: current.optional
149
+ });
150
+
151
+ const required = Object.keys(manifest.dependencies ?? {});
152
+ const optional = Object.keys(manifest.optionalDependencies ?? {});
153
+ for (const name of [...required, ...optional]) {
154
+ const isOptional = optional.includes(name);
155
+ const dir = resolvePackageDir(current.dir, name, rootModulesDir);
156
+ if (dir === null) {
157
+ // 可选依赖缺失是正常形态(平台分片包在别的平台就是这样),不报警。
158
+ if (!isOptional) gaps.push({ name, from: current.rel, reason: "必需依赖未找到" });
159
+ continue;
160
+ }
161
+ const rel = toRel(rootModulesDir, dir);
162
+ // 不允许越出源树:越出说明解析跑到了宿主其它位置,这份闭包就不是自包含的。
163
+ if (rel.startsWith("..")) {
164
+ gaps.push({ name, from: current.rel, reason: "解析结果越出源 node_modules" });
165
+ continue;
166
+ }
167
+ queue.push({ dir, rel, optional: isOptional });
168
+ }
169
+ }
170
+
171
+ return { rootModulesDir, entry, platform, arch, packages, gaps, skipped, truncated };
172
+ }
@@ -0,0 +1,247 @@
1
+ // 运行时取件(issue #131 / PR-C)
2
+ //
3
+ // 三档来源,按可用性依次尝试。第一档「收编本机 node_modules」在 adopt.js;这里管后两档:
4
+ // ② 本地 .tgz 目录 —— 给「某个包网络下不到」的情况(本机就有一份 82MB 的 vendor tarball);
5
+ // ③ registry(可换镜像)—— 有网络时的常规路径。
6
+ //
7
+ // 三条硬规矩:
8
+ // 1. **先校验 sha512 再落盘**。不匹配就不写、整次判失败。没有「先信任再校验」这种中间态。
9
+ // 2. **tar 条目路径必须落在该包目录内**。registry 与本地 tarball 都是外来输入,`../` 这类
10
+ // 路径遍历要在这里挡住 —— 这是信任边界上的校验,不是美化。
11
+ // 3. **任何一步失败都清掉半份 payload**。结构检查只看必需包与入口,半份 payload 可能
12
+ // 「看起来能用」,然后在 import 原生模块时才炸,错误现场离真正的原因很远。
13
+ //
14
+ // @module dsh-mneme/runtime/download
15
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { dirname, join, sep } from "node:path";
17
+ import { RUNTIME_MANIFEST_VERSION } from "./adopt.js";
18
+ import { REQUIRED_PACKAGES, defaultRuntimeDir, detectLibc, nodeModulesDir, payloadDir, payloadId, runtimeManifestPath } from "./layout.js";
19
+ import { matchesPlatform } from "./closure.js";
20
+ import { stripPackagePrefix, verifyIntegrity, walkTgz } from "./tarball.js";
21
+
22
+ const DEFAULT_ATTEMPTS = 3;
23
+ // 单次请求的总超时。连接僵住(对端不回也不断)时,没有它就会永远等着 —— 用户看到的是一个
24
+ // 卡死的按钮,而没有任何错误可看。给得宽松:几百 MB 的包在慢网下也要能下完。
25
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
26
+
27
+ /**
28
+ * 把 registry 地址换成镜像地址:只换前缀,保留 `/name/-/file.tgz` 这段路径。
29
+ * 换不了(不是 registry.npmjs.org 的地址)就原样返回 —— 清单里写死的地址优先。
30
+ * @param {string} url - 清单里的地址。
31
+ * @param {string} mirror - 镜像前缀,空 = 不换。
32
+ * @returns {string} 实际要取的地址。
33
+ */
34
+ export function applyMirror(url, mirror) {
35
+ const base = String(mirror ?? "").trim().replace(/\/+$/, "");
36
+ if (base === "") return url;
37
+ // 只换 registry.npmjs.org 的地址:别的来源(自建 registry、file:、清单里已是镜像的地址)一律
38
+ // 原样返回。这里曾经用 indexOf(...) 直接加偏移量拼字符串 —— 地址里没有那个域名时 indexOf 返回
39
+ // -1,slice 出的是垃圾名字,把一条本来可用的地址改坏。
40
+ const prefix = "https://registry.npmjs.org/";
41
+ const marker = url.indexOf("/-/");
42
+ if (marker === -1 || !url.startsWith(prefix)) return url;
43
+ const name = url.slice(prefix.length, marker);
44
+ return name === "" ? url : `${base}/${name}${url.slice(marker)}`;
45
+ }
46
+
47
+ /**
48
+ * 本地 tarball 目录里的候选文件名。用 npm 的约定(`<basename>-<version>.tgz`),
49
+ * 这样
50
+ pm pack` 出来的东西直接丢进目录就能用,不需要改名字。
51
+ * @param {string} dir - 目录。
52
+ * @param {{name: string, version: string}} pkg - 包。
53
+ * @returns {string} 候选路径。
54
+ */
55
+ export function localTarballPath(dir, pkg) {
56
+ const base = pkg.name.startsWith("@") ? pkg.name.slice(pkg.name.indexOf("/") + 1) : pkg.name;
57
+ return join(dir, `${base}-${pkg.version}.tgz`);
58
+ }
59
+
60
+ /**
61
+ * 取一个 .tgz 的字节。失败重试;已经收到部分字节时用 `Range` **续传**而不是从头再来
62
+ * —— 82MB 的 onnxruntime-node 在移动网络上重来一次的代价太大。
63
+ * @param {string} url - 地址。
64
+ * @param {object} [opts] - 选项。
65
+ * @returns {Promise<{buffer: Buffer, attempts: number, resumed: boolean}>}
66
+ */
67
+ export async function fetchTarball(url, { attempts = DEFAULT_ATTEMPTS, fetchImpl = fetch, onProgress, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
68
+ let lastError = null;
69
+ let chunks = [];
70
+ let received = 0;
71
+
72
+ for (let attempt = 1; attempt <= attempts; attempt++) {
73
+ try {
74
+ const options = received > 0 ? { headers: { Range: `bytes=${received}-` } } : {};
75
+ // AbortSignal.timeout 是**总时长**上限,不是空闲超时:宁可给得宽松,也不要掐断正常的慢速下载。
76
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) options.signal = AbortSignal.timeout(timeoutMs);
77
+ const res = await fetchImpl(url, Object.keys(options).length > 0 ? options : undefined);
78
+ if (res.status === 206) {
79
+ // 续传被接受:已有字节保留,接着追加。
80
+ } else if (res.ok) {
81
+ // 服务端不支持续传(或首次请求):从头来。
82
+ chunks = [];
83
+ received = 0;
84
+ } else {
85
+ throw new Error(`HTTP ${res.status}`);
86
+ }
87
+
88
+ if (res.body?.getReader === undefined) {
89
+ const whole = Buffer.from(await res.arrayBuffer());
90
+ // 206 的响应体只是**剩余**那段,必须追加;200 才是完整的一份 —— 覆盖会丢掉前半段,
91
+ // 于是 sha512 校验必挂(而这次失败看起来像「来源不可信」,与真实原因不符)。
92
+ if (res.status === 206) {
93
+ chunks.push(whole);
94
+ received += whole.length;
95
+ } else {
96
+ chunks = [whole];
97
+ received = whole.length;
98
+ }
99
+ } else {
100
+ const reader = res.body.getReader();
101
+ for (;;) {
102
+ const { done, value } = await reader.read();
103
+ if (done) break;
104
+ const chunk = Buffer.from(value);
105
+ chunks.push(chunk);
106
+ received += chunk.length;
107
+ // 事件带 type:同一个回调同时承载「字节级」与「包级」两种进度,不标类型调用方就只能猜。
108
+ onProgress?.({ type: "bytes", received, attempt });
109
+ }
110
+ }
111
+ return { buffer: Buffer.concat(chunks, received), attempts: attempt, resumed: attempt > 1 };
112
+ } catch (error) {
113
+ lastError = error;
114
+ }
115
+ }
116
+ throw new Error(`取件失败(试了 ${attempts} 次):${lastError?.message ?? lastError}`);
117
+ }
118
+
119
+ /** 解一个 .tgz 到该包的目录里;条目路径必须落在目录内。 */
120
+ function extractInto(buffer, pkgDir, skipped) {
121
+ const stats = { files: 0, bytes: 0 };
122
+ const guard = pkgDir.endsWith(sep) ? pkgDir : `${pkgDir}${sep}`;
123
+ const walked = walkTgz(buffer, (entry) => {
124
+ const target = join(pkgDir, stripPackagePrefix(entry.path));
125
+ // 越界即整次失败:把外来归档里的 ../ 写出去是不可接受的。
126
+ if (!target.startsWith(guard)) throw new Error(`归档里有越出包目录的路径:${entry.path}`);
127
+ mkdirSync(dirname(target), { recursive: true });
128
+ writeFileSync(target, entry.data);
129
+ stats.files++;
130
+ stats.bytes += entry.data.length;
131
+ });
132
+ for (const item of walked.skipped) skipped.push(item);
133
+ return stats;
134
+ }
135
+
136
+ /**
137
+ * 按清单把运行时取回来并落盘(第二、三档来源)。
138
+ * @param {object} opts - 选项。
139
+ * @param {object} opts.manifest - runtime-manifest.json 的内容。
140
+ * @param {string} [opts.runtimeDir] - 运行时根目录。
141
+ * @param {string} [opts.localTarballDir] - 本地 .tgz 目录(有就用,优先于网络)。
142
+ * @param {string} [opts.mirror] - registry 镜像前缀。
143
+ * @param {boolean} [opts.overwrite] - 目标已存在时是否覆盖。
144
+ * @returns {Promise<object>} 与 adoptRuntime 同形的结果;失败时 ok=false 且带 reason,不抛。
145
+ */
146
+ export async function downloadRuntime({
147
+ manifest,
148
+ platform = process.platform,
149
+ arch = process.arch,
150
+ libc = detectLibc(platform),
151
+ runtimeDir = defaultRuntimeDir(),
152
+ localTarballDir = "",
153
+ mirror = "",
154
+ overwrite = false,
155
+ attempts = DEFAULT_ATTEMPTS,
156
+ fetchImpl = fetch,
157
+ onProgress
158
+ }) {
159
+ // v2 清单是**平台无关**的:里面是所有平台的包(各带 os / cpu / libc),这里按本机平台过滤。
160
+ // 这样一份随包发布的静态清单就覆盖 win32 / darwin / linux × x64 / arm64 —— 不需要
161
+ // 「谁在哪个系统上跑一次生成脚本」,也就不会让 macOS 用户卡在「清单里没有我的平台」。
162
+ const all = Array.isArray(manifest?.packages) ? manifest.packages : null;
163
+ if (all === null) return { ok: false, reason: "运行时清单不可用(缺少 packages 列表)" };
164
+
165
+ const wanted = all.filter((pkg) => matchesPlatform(pkg, platform, arch, libc));
166
+ const missingRequired = REQUIRED_PACKAGES.filter((name) => !wanted.some((pkg) => pkg.rel === name));
167
+ if (missingRequired.length > 0) {
168
+ // 目标平台在清单里就缺必需包时,现在失败好过装出一份「结构看着全、import 原生模块才炸」的
169
+ // 运行时 —— 那时错误现场离原因已经很远。
170
+ return { ok: false, reason: `清单里 ${platform}-${arch} 缺少必需包:${missingRequired.join("、")}` };
171
+ }
172
+
173
+ const id = payloadId({ version: manifest.transformersVersion, platform, arch });
174
+
175
+ const dir = payloadDir(runtimeDir, id);
176
+ if (existsSync(dir) && !overwrite) {
177
+ return {
178
+ ok: false,
179
+ reason: `目标已存在:${dir}(需要覆盖请传 overwrite: true)`,
180
+ payloadDir: dir,
181
+ payloadId: id
182
+ };
183
+ }
184
+ // 覆盖前先清空:否则上一次的残留文件会让结构检查看到「完整」,掩盖这一次的缺件。
185
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
186
+
187
+ const totals = { packages: 0, files: 0, bytes: 0, fromLocal: 0, fromNetwork: 0 };
188
+ const warnings = [];
189
+ const packages = [];
190
+ const skipped = [];
191
+
192
+ try {
193
+ for (const pkg of wanted) {
194
+ const local = localTarballDir ? localTarballPath(localTarballDir, pkg) : "";
195
+ const useLocal = local !== "" && existsSync(local);
196
+ const url = applyMirror(pkg.tarball, mirror);
197
+ const buffer = useLocal ? readFileSync(local) : (await fetchTarball(url, { attempts, fetchImpl, onProgress })).buffer;
198
+ if (useLocal) totals.fromLocal++;
199
+ else totals.fromNetwork++;
200
+
201
+ if (!verifyIntegrity(buffer, pkg.integrity)) {
202
+ // 校验失败必须停在这里:写下去就等于把「来源不可信」变成「已装好」。
203
+ throw new Error(`${pkg.name}@${pkg.version} 的 sha512 与清单不符(来源:${useLocal ? local : url})`);
204
+ }
205
+
206
+ const stats = extractInto(buffer, join(nodeModulesDir(dir), pkg.rel), skipped);
207
+ totals.files += stats.files;
208
+ totals.bytes += stats.bytes;
209
+ totals.packages++;
210
+ packages.push({ name: pkg.name, version: pkg.version, rel: pkg.rel, optional: pkg.optional === true });
211
+ onProgress?.({ type: "package", package: pkg.name, done: totals.packages, total: wanted.length });
212
+ }
213
+ } catch (error) {
214
+ // 半份 payload 比没有更糟:清掉,让「没装成」这件事在磁盘上也成立。
215
+ rmSync(dir, { recursive: true, force: true });
216
+ return { ok: false, reason: String(error?.message ?? error) };
217
+ }
218
+
219
+ const written = {
220
+ manifestVersion: RUNTIME_MANIFEST_VERSION,
221
+ payloadId: id,
222
+ entry: manifest.entry ?? "@huggingface/transformers",
223
+ version: manifest.transformersVersion,
224
+ platform,
225
+ arch,
226
+ procedure: "downloaded",
227
+ // 每个 tarball 都与清单里的 sha512 比对过 —— 这是下载档独有的、可如实声明的来源完整性。
228
+ integrity: { status: "verified", checked: packages.length, detail: "每个 tarball 的 sha512 与 runtime-manifest.json 一致" },
229
+ createdAt: new Date().toISOString(),
230
+ materialize: { mode: "download", linkError: null },
231
+ sources: { mirror: mirror || null, localTarballDir: localTarballDir || null, counts: { local: totals.fromLocal, network: totals.fromNetwork } },
232
+ packages,
233
+ gaps: [],
234
+ skipped,
235
+ warnings
236
+ };
237
+ mkdirSync(dir, { recursive: true });
238
+ writeFileSync(runtimeManifestPath(dir), `${JSON.stringify(written, null, 2)}\n`, "utf8");
239
+ return {
240
+ ok: true,
241
+ payloadDir: dir,
242
+ payloadId: id,
243
+ version: manifest.transformersVersion,
244
+ totals,
245
+ manifest: written
246
+ };
247
+ }