@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.
- package/README.md +65 -27
- package/lib/api.js +139 -6
- package/lib/client.js +369 -32
- package/lib/commands.js +36 -5
- package/lib/config.js +101 -14
- package/lib/dream/decisions.js +147 -14
- package/lib/dream/sleep.js +84 -34
- package/lib/dream.js +250 -60
- package/lib/embedding.js +22 -2
- package/lib/index.js +68 -37
- package/lib/inject.js +54 -13
- package/lib/lang.js +168 -0
- package/lib/local-embedder.js +23 -9
- package/lib/mirror.js +17 -14
- package/lib/reranker.js +23 -8
- package/lib/runtime/adopt.js +176 -0
- package/lib/runtime/closure.js +172 -0
- package/lib/runtime/download.js +247 -0
- package/lib/runtime/layout.js +235 -0
- package/lib/runtime/loader.js +249 -0
- package/lib/runtime/provision.js +237 -0
- package/lib/runtime/tarball.js +130 -0
- package/lib/runtime/verify.js +191 -0
- package/lib/scope.js +83 -0
- package/lib/service.js +374 -55
- package/lib/settings.js +43 -2
- package/lib/store.js +126 -13
- package/lib/summarize.js +108 -35
- package/lib/tools.js +205 -12
- package/package.json +7 -2
- package/runtime-manifest.json +596 -0
- package/scripts/build-runtime-manifest.mjs +217 -0
- package/scripts/mneme-runtime.mjs +277 -0
- package/src/api.js +139 -6
- package/src/commands.js +36 -5
- package/src/config.js +101 -14
- package/src/dream/decisions.js +147 -14
- package/src/dream/sleep.js +84 -34
- package/src/dream.js +250 -60
- package/src/embedding.js +22 -2
- package/src/index.js +68 -37
- package/src/inject.js +54 -13
- package/src/lang.js +168 -0
- package/src/local-embedder.js +23 -9
- package/src/mirror.js +17 -14
- package/src/reranker.js +23 -8
- package/src/runtime/adopt.js +176 -0
- package/src/runtime/closure.js +172 -0
- package/src/runtime/download.js +247 -0
- package/src/runtime/layout.js +235 -0
- package/src/runtime/loader.js +249 -0
- package/src/runtime/provision.js +237 -0
- package/src/runtime/tarball.js +130 -0
- package/src/runtime/verify.js +191 -0
- package/src/scope.js +83 -0
- package/src/service.js +374 -55
- package/src/settings.js +43 -2
- package/src/store.js +126 -13
- package/src/summarize.js +108 -35
- package/src/tools.js +205 -12
- package/test/api.test.js +175 -9
- package/test/client.test.js +152 -10
- package/test/commands.test.js +40 -3
- package/test/config.test.js +17 -1
- package/test/dream-candidate-hybrid.test.js +281 -0
- package/test/dream.test.js +172 -0
- package/test/embedding-ready.test.js +196 -0
- package/test/helpers/runtime-source.js +60 -0
- package/test/helpers/tar-builder.js +80 -0
- package/test/inject.test.js +130 -1
- package/test/lang.test.js +87 -0
- package/test/quality-filter.test.js +68 -0
- package/test/reasoning-effort.test.js +106 -15
- package/test/reindex-backfill.test.js +62 -0
- package/test/runtime-adopt.test.js +206 -0
- package/test/runtime-cli.test.js +89 -0
- package/test/runtime-closure.test.js +193 -0
- package/test/runtime-download.test.js +302 -0
- package/test/runtime-layout.test.js +163 -0
- package/test/runtime-loader.test.js +237 -0
- package/test/runtime-manifest.test.js +158 -0
- package/test/runtime-provision.test.js +101 -0
- package/test/runtime-tarball.test.js +82 -0
- package/test/runtime-verify.test.js +214 -0
- package/test/scope-retrieval.test.js +197 -0
- package/test/scope-storage.test.js +247 -0
- package/test/scope-strict.test.js +165 -0
- package/test/service.test.js +76 -0
- package/test/sleep-action-set.test.js +422 -0
- package/test/store.test.js +14 -0
- package/test/summarize.test.js +224 -0
- package/test/tools.test.js +30 -5
- package/test/webServer-optional.test.js +34 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// 运行时取件编排:三档来源依次尝试(issue #131 / PR-C / PR-C 追加下载档)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独一层:面板按钮需要一段「能被单测覆盖、又不必把绝对路径交给免鉴权端点」的
|
|
4
|
+
// 逻辑。这里只做三件事——推源、收编、给结论;路由那侧只管鉴权与搬运。
|
|
5
|
+
//
|
|
6
|
+
// 为什么源不让用户输入:既有用户的 transformers 就装在 <profile>/node_modules 里,
|
|
7
|
+
// 而插件自身也在那儿(<profile>/node_modules/@modusensus/dsh-mneme/)。所以从插件
|
|
8
|
+
// 自己的位置往上三级就能推出源目录,面板不必传参,也不把「从任意目录收编」变成
|
|
9
|
+
// 一个对外能力。
|
|
10
|
+
//
|
|
11
|
+
// 为什么 ok 要同时看结构和闭包缺口:describePayload 只点三个必需包,不看闭包计划里的
|
|
12
|
+
// 必需依赖缺口。只认结构的话,「缺传递依赖、入口一 import 就炸」的收编会被面板显示成
|
|
13
|
+
// 成功——这与 CLI 的退出码用的是同一条判据,两边必须一致。
|
|
14
|
+
//
|
|
15
|
+
// 同步实现:adoptRuntime 全是同步 fs 调用。收编 3203 个文件实测约 1–3 秒(同卷硬链接),
|
|
16
|
+
// 跨卷回退成复制时会明显更久。这是用户点按钮才触发的显式动作,短暂占用事件循环可接受;
|
|
17
|
+
// 将来若成为常态路径,再考虑挪进 worker。
|
|
18
|
+
//
|
|
19
|
+
// @module dsh-mneme/runtime/provision
|
|
20
|
+
import { dirname, resolve } from "node:path";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { adoptRuntime } from "./adopt.js";
|
|
24
|
+
import { downloadRuntime } from "./download.js";
|
|
25
|
+
import { defaultRuntimeDir, describePayload, detectLibc } from "./layout.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 从插件自身位置推出宿主 node_modules。
|
|
29
|
+
*
|
|
30
|
+
* `<profile>/node_modules/@modusensus/dsh-mneme/lib/<file>` 往上是
|
|
31
|
+
* `lib` → `dsh-mneme` → `@modusensus` → `node_modules`,所以 dirname 四次。
|
|
32
|
+
* 调用方传自己的 `import.meta.url`,src/ 与 lib/ 这两种同深度布局都成立。
|
|
33
|
+
*
|
|
34
|
+
* 用 dirname 而不是 `new URL("../../..")`:后者会得到带尾部路径分隔符的目录 URL,
|
|
35
|
+
* fileURLToPath 之后是 `...\node_modules\`,与别处 `join()` 出来的字符串不相等 ——
|
|
36
|
+
* 这个值要参与比较与拼接,形态得是干净的那种。
|
|
37
|
+
*
|
|
38
|
+
* 为什么这个推导对两种 linker 都成立:
|
|
39
|
+
* - hoisted:插件在 `<profile>/node_modules/@modusensus/dsh-mneme/`,上三级就是 profile 的
|
|
40
|
+
* node_modules,依赖都平铺在那儿;
|
|
41
|
+
* - isolated:Node 默认解析 symlink 后的真实路径,插件实际位于
|
|
42
|
+
* `<profile>/node_modules/.pnpm/<pkg>/node_modules/@modusensus/dsh-mneme/`,上三级正好是
|
|
43
|
+
* 那个虚拟 store 的 node_modules —— 而 pnpm 正是把该包的依赖放在它的同级。
|
|
44
|
+
* 两种情况都指向「装着这个插件依赖的那个 node_modules」,这正是要收编的东西。
|
|
45
|
+
* @param {string} fromModuleUrl - 调用方自己的 import.meta.url。
|
|
46
|
+
* @returns {string} 宿主 node_modules 目录。
|
|
47
|
+
*/
|
|
48
|
+
export function hostModulesDir(fromModuleUrl) {
|
|
49
|
+
const file = fileURLToPath(fromModuleUrl);
|
|
50
|
+
return dirname(dirname(dirname(dirname(file))));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 收编本 profile 已有的运行时,给出面板可直接显示、脚本也能直接 gate 的结论。
|
|
55
|
+
* @param {object} opts - 选项。
|
|
56
|
+
* @param {string} opts.hostModulesDir - 源 node_modules。
|
|
57
|
+
* @param {string} [opts.runtimeDir] - 运行时根目录;空 = 默认位置。
|
|
58
|
+
* @param {boolean} [opts.overwrite] - 目标已存在时是否覆盖(用来修一份坏掉的 payload)。
|
|
59
|
+
* @returns {{ok: boolean, status: string, reason?: string, payloadId?: string, version?: string,
|
|
60
|
+
* packages?: number, files?: number, bytes?: number, materialize?: string,
|
|
61
|
+
* missing?: string[], gaps?: string[], reasons?: string[]}}
|
|
62
|
+
*/
|
|
63
|
+
export function adoptHostRuntime({ hostModulesDir: from, runtimeDir = "", overwrite = false }) {
|
|
64
|
+
const result = adoptRuntime({
|
|
65
|
+
fromModulesDir: from,
|
|
66
|
+
// 空串必须变成 undefined:adoptRuntime 的默认值只在「未传」时才生效,
|
|
67
|
+
// 传空串会得到一个相对路径。
|
|
68
|
+
runtimeDir: runtimeDir || undefined,
|
|
69
|
+
overwrite
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// 失败路径不带 payloadDir(入口包解析不到就是这条)—— 先拦掉,别拿 undefined 当路径。
|
|
73
|
+
if (result.ok === false) {
|
|
74
|
+
return { ok: false, status: "failed", reason: result.reason };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const structural = describePayload(result.payloadDir);
|
|
78
|
+
const gaps = result.plan.gaps.map((gap) => gap.name);
|
|
79
|
+
const healthy = structural.ok && gaps.length === 0;
|
|
80
|
+
// 不健康时**也必须**带 reason:调用方(面板 / 工具 / provisionRuntime)会把它拼进给用户看的
|
|
81
|
+
// 那句话里,缺了就会显示成「收编失败:undefined」——评审指出的正是这个。
|
|
82
|
+
const why = [
|
|
83
|
+
...structural.missing.map((item) => `缺 ${item}`),
|
|
84
|
+
...gaps.map((gap) => `缺依赖 ${gap}`),
|
|
85
|
+
...structural.reasons
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
ok: healthy,
|
|
90
|
+
status: healthy ? "adopted" : "incomplete",
|
|
91
|
+
...(healthy ? {} : { reason: `收编结果不完整:${why.join(";") || "结构与闭包检查未通过"}` }),
|
|
92
|
+
payloadId: result.payloadId,
|
|
93
|
+
version: result.version,
|
|
94
|
+
packages: result.plan.packages.length,
|
|
95
|
+
files: result.totals.files,
|
|
96
|
+
bytes: result.totals.bytes,
|
|
97
|
+
materialize: result.manifest.materialize.mode,
|
|
98
|
+
missing: structural.missing,
|
|
99
|
+
gaps,
|
|
100
|
+
// reasons 里可能带绝对路径,但它只回给已鉴权的调用方(面板 / 本机 CLI);
|
|
101
|
+
// 免鉴权的 /semantic 走 publicRuntimeStatus 投影,不经过这里。
|
|
102
|
+
reasons: structural.reasons
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 读随包发布的 runtime-manifest.json。
|
|
108
|
+
*
|
|
109
|
+
* 路径:`src/runtime/` 与 `lib/runtime/` 往上两级都是包根,两种布局同深度,所以同一个相对路径
|
|
110
|
+
* 都成立。清单必须写进 package.json 的 files —— 否则装好的插件里根本没有它(这条真踩过)。
|
|
111
|
+
* 读不到返回 null:调用方据此给出「没有可下载的清单」这种可读原因,而不是抛栈。
|
|
112
|
+
* @param {string} [fromModuleUrl] - 调用方自己的 import.meta.url(测试可注入)。
|
|
113
|
+
* @returns {object|null} 清单内容。
|
|
114
|
+
*/
|
|
115
|
+
export function loadRuntimeManifest(fromModuleUrl = import.meta.url) {
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(readFileSync(new URL("../../runtime-manifest.json", fromModuleUrl), "utf8"));
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 依次尝试三档来源,给出一个统一形状的结论(面板 / CLI / agent 共用):
|
|
125
|
+
* ① 收编本机 node_modules(零网络)② 本地 .tgz 目录 ③ registry(可换镜像)
|
|
126
|
+
*
|
|
127
|
+
* 为什么三档都试、而不是先替调用方判断「本机有没有源」:那两种探测都是文件系统判断,
|
|
128
|
+
* 交给各自的实现更准,而且失败原因本身就是给人看的那句话 —— 判断逻辑重复一遍只会两边漂移。
|
|
129
|
+
*
|
|
130
|
+
* 两种来源都失败时,reason 里**两条原因都给**:用户和 agent 都需要知道「为什么收编不行」与
|
|
131
|
+
* 「为什么下载不行」,只给一条就会被迫去猜另一半。
|
|
132
|
+
* @param {object} opts - 选项。
|
|
133
|
+
* @returns {Promise<object>} {ok, strategy, ...};strategy ∈ adopt | download | none。
|
|
134
|
+
*/
|
|
135
|
+
async function provisionOnce({
|
|
136
|
+
hostModulesDir: from,
|
|
137
|
+
runtimeDir = "",
|
|
138
|
+
localTarballDir = "",
|
|
139
|
+
mirror = "",
|
|
140
|
+
overwrite = false,
|
|
141
|
+
manifest,
|
|
142
|
+
platform = process.platform,
|
|
143
|
+
arch = process.arch,
|
|
144
|
+
libc = detectLibc(platform),
|
|
145
|
+
fetchImpl,
|
|
146
|
+
onProgress
|
|
147
|
+
}) {
|
|
148
|
+
const adopted = adoptHostRuntime({ hostModulesDir: from, runtimeDir, overwrite });
|
|
149
|
+
if (adopted.ok) return { ...adopted, strategy: "adopt" };
|
|
150
|
+
|
|
151
|
+
const loaded = manifest === undefined ? loadRuntimeManifest() : manifest;
|
|
152
|
+
if (loaded === null || loaded === undefined) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
strategy: "none",
|
|
156
|
+
status: "failed",
|
|
157
|
+
reason: `没有可用的 runtime-manifest.json,无法下载;收编也失败:${adopted.reason}`,
|
|
158
|
+
adoptReason: adopted.reason
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const downloaded = await downloadRuntime({
|
|
163
|
+
manifest: loaded,
|
|
164
|
+
// 空串要变成 undefined:downloadRuntime 的默认值只在「未传」时生效,传空串会得到相对路径。
|
|
165
|
+
runtimeDir: runtimeDir || undefined,
|
|
166
|
+
localTarballDir,
|
|
167
|
+
mirror,
|
|
168
|
+
// 收编留下的是「不完整」的那份(缺件 / 缺依赖),它已经不可用:下载这一档必须覆盖它,
|
|
169
|
+
// 否则会因「目标已存在」失败 —— 用户拿到两份坏消息,却没有任何出路。
|
|
170
|
+
overwrite: overwrite || adopted.status === "incomplete",
|
|
171
|
+
platform,
|
|
172
|
+
arch,
|
|
173
|
+
libc,
|
|
174
|
+
...(fetchImpl === undefined ? {} : { fetchImpl }),
|
|
175
|
+
...(onProgress === undefined ? {} : { onProgress })
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (downloaded.ok) {
|
|
179
|
+
// 归一化:收编那一档直接给 packages / files / bytes,而下载档给的是 totals —— 不统一的话
|
|
180
|
+
// 面板与 agent 工具会读到 undefined(评审判断得对,这条是真 bug)。
|
|
181
|
+
const totals = downloaded.totals ?? {};
|
|
182
|
+
return {
|
|
183
|
+
...downloaded,
|
|
184
|
+
strategy: "download",
|
|
185
|
+
adoptReason: adopted.reason,
|
|
186
|
+
packages: totals.packages ?? 0,
|
|
187
|
+
files: totals.files ?? 0,
|
|
188
|
+
bytes: totals.bytes ?? 0
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
strategy: "none",
|
|
195
|
+
status: "failed",
|
|
196
|
+
reason: `收编失败:${adopted.reason};下载失败:${downloaded.reason}`,
|
|
197
|
+
adoptReason: adopted.reason,
|
|
198
|
+
downloadReason: downloaded.reason
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 同一运行时目录同时只允许一次取件。
|
|
204
|
+
*
|
|
205
|
+
* 面板按钮与 agent 工具可能并发调用(用户也可能连点两下),而取件是直接往**最终目录**写:
|
|
206
|
+
* 两路并发会互相覆盖成半份 payload。与其让第二次白跑一遍 200MB,不如把它接到正在进行的那次上 ——
|
|
207
|
+
* 结果一致,而且不会把目录写坏。
|
|
208
|
+
*/
|
|
209
|
+
const IN_FLIGHT = new Map();
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 取件真正会写入的那个运行时根目录 —— 绝对且归一化,用作串行化的键。
|
|
213
|
+
*
|
|
214
|
+
* 为什么不能直接拿 `options.runtimeDir` 当键:三档实现都是「空串 = 默认位置」,所以
|
|
215
|
+
* `""`、`~/.dsh/mneme/runtime`、`./runtime`、`D:\x\runtime` 与 `d:\x\runtime`
|
|
216
|
+
* 可能指向同一个目录,却得到四个不同的键 —— 同一个目录仍会被两路并发写(评审指出)。
|
|
217
|
+
* 键与写入位置必须一一对应,否则这把锁就只是看起来有用。
|
|
218
|
+
*
|
|
219
|
+
* win32 文件系统不区分大小写,键也跟着不区分;POSIX 保持原样(那边大小写是两回事)。
|
|
220
|
+
* @param {object} [options] - provisionRuntime 的选项。
|
|
221
|
+
* @returns {string} 归一化后的绝对路径。
|
|
222
|
+
*/
|
|
223
|
+
function runtimeLockKey(options) {
|
|
224
|
+
const given = String(options?.runtimeDir ?? "").trim();
|
|
225
|
+
const dir = resolve(given === "" ? defaultRuntimeDir() : given);
|
|
226
|
+
return process.platform === "win32" ? dir.toLowerCase() : dir;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function provisionRuntime(options) {
|
|
230
|
+
const key = runtimeLockKey(options);
|
|
231
|
+
const running = IN_FLIGHT.get(key);
|
|
232
|
+
if (running !== undefined) return running;
|
|
233
|
+
const task = provisionOnce(options).finally(() => IN_FLIGHT.delete(key));
|
|
234
|
+
IN_FLIGHT.set(key, task);
|
|
235
|
+
return task;
|
|
236
|
+
}
|
|
237
|
+
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// 极简 USTAR 读取 + sha512 校验(issue #131 / PR-C)
|
|
2
|
+
//
|
|
3
|
+
// 为什么自己写而不是加依赖:插件里多一个 tar 依赖,就多一份要跟着上游走的供应链与体积
|
|
4
|
+
// 成本;而这里只需要读「npm registry 产出的 .tgz」这一种固定格式(gzip + ustar/pax),
|
|
5
|
+
// node:zlib 加一个一两百行的读取器就够,而且行为完全可审计。
|
|
6
|
+
//
|
|
7
|
+
// 只做下载通道必需的事:逐个条目回调(不把整棵解压树同时留在内存里)、跳过目录与不认识的
|
|
8
|
+
// 类型、支持 ustar 的 prefix 长路径与 pax 的 path 覆盖(npm 的深层路径两者都会用到)。
|
|
9
|
+
//
|
|
10
|
+
// @module dsh-mneme/runtime/tarball
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { gunzipSync } from "node:zlib";
|
|
13
|
+
|
|
14
|
+
const BLOCK = 512;
|
|
15
|
+
/** npm tarball 把所有内容放在统一的 `package/` 前缀下。 */
|
|
16
|
+
const PACKAGE_PREFIX = "package/";
|
|
17
|
+
|
|
18
|
+
/** 读 ustar 头里的字符串字段:到 NUL 截断,去掉尾随空白。 */
|
|
19
|
+
function readString(buffer, start, length) {
|
|
20
|
+
const nul = buffer.indexOf(0, start);
|
|
21
|
+
const end = nul === -1 || nul > start + length ? start + length : nul;
|
|
22
|
+
return buffer.subarray(start, end).toString("utf8").trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 读数字字段。ustar 用八进制 + NUL/空格填充;GNU 对超大值改用 base-256(首字节最高位为 1)。
|
|
27
|
+
* 两者都认,否则遇到大文件会把它读成 0 字节而静默截断。
|
|
28
|
+
*/
|
|
29
|
+
function readNumber(buffer, start, length) {
|
|
30
|
+
if ((buffer[start] & 0x80) !== 0) {
|
|
31
|
+
let value = 0;
|
|
32
|
+
for (let i = start + 1; i < start + length; i++) value = value * 256 + buffer[i];
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
const text = readString(buffer, start, length);
|
|
36
|
+
if (text === "") return 0;
|
|
37
|
+
const value = parseInt(text, 8);
|
|
38
|
+
return Number.isFinite(value) && value >= 0 ? value : 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** pax 扩展头(typeflag 'x')的载荷是一串 `长度 key=value\n`,只需要 path。 */
|
|
42
|
+
function paxPath(data) {
|
|
43
|
+
const text = data.toString("utf8");
|
|
44
|
+
const match = /^\d+ path=(.*)$/m.exec(text);
|
|
45
|
+
return match ? match[1] : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 遍历一个未压缩的 tar 归档。
|
|
50
|
+
* @param {Buffer} buffer - tar 字节。
|
|
51
|
+
* @param {(entry: {path: string, data: Buffer}) => void} onFile - 每个普通文件回调一次。
|
|
52
|
+
* @returns {{files: number, bytes: number, skipped: string[]}} 统计;skipped 是跳过的类型。
|
|
53
|
+
*/
|
|
54
|
+
export function walkTar(buffer, onFile) {
|
|
55
|
+
const stats = { files: 0, bytes: 0, skipped: [] };
|
|
56
|
+
let offset = 0;
|
|
57
|
+
let pendingPath = null;
|
|
58
|
+
|
|
59
|
+
while (offset + BLOCK <= buffer.length) {
|
|
60
|
+
const header = buffer.subarray(offset, offset + BLOCK);
|
|
61
|
+
// 全零块 = 归档结束(正常收尾,不是错误)。
|
|
62
|
+
if (header.every((byte) => byte === 0)) break;
|
|
63
|
+
|
|
64
|
+
const name = readString(header, 0, 100);
|
|
65
|
+
const size = readNumber(header, 124, 12);
|
|
66
|
+
const typeflag = String.fromCharCode(header[156] || 0x30);
|
|
67
|
+
const prefix = readString(header, 345, 155);
|
|
68
|
+
offset += BLOCK;
|
|
69
|
+
|
|
70
|
+
const data = buffer.subarray(offset, offset + size);
|
|
71
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
72
|
+
|
|
73
|
+
// pax 扩展头:只取 path 覆盖下一条目,自身不是文件。
|
|
74
|
+
if (typeflag === "x") {
|
|
75
|
+
pendingPath = paxPath(data) ?? pendingPath;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (typeflag === "g") continue; // 全局 pax 头,本用途不需要
|
|
79
|
+
|
|
80
|
+
const full = pendingPath ?? (prefix ? `${prefix}/${name}` : name);
|
|
81
|
+
pendingPath = null;
|
|
82
|
+
|
|
83
|
+
if (typeflag === "5") continue; // 目录:由文件自身的路径隐式创建
|
|
84
|
+
if (typeflag !== "0" && typeflag !== "\0" && typeflag !== "") {
|
|
85
|
+
// 符号链接、硬链接、设备节点等:registry 产物里不该出现,如实记下而不是假装搬过来了。
|
|
86
|
+
stats.skipped.push(`${full} (typeflag=${typeflag})`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
onFile({ path: full.replace(/^\.\//, ""), data });
|
|
91
|
+
stats.files++;
|
|
92
|
+
stats.bytes += data.length;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return stats;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 遍历一个 .tgz(registry 的产物形态)。
|
|
100
|
+
* @param {Buffer} gzipped - gzip 字节。
|
|
101
|
+
* @param {(entry: {path: string, data: Buffer}) => void} onFile - 每个普通文件回调一次。
|
|
102
|
+
* @returns {{files: number, bytes: number, skipped: string[]}}
|
|
103
|
+
*/
|
|
104
|
+
export function walkTgz(gzipped, onFile) {
|
|
105
|
+
return walkTar(gunzipSync(gzipped), onFile);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 去掉 npm tarball 统一的 `package/` 前缀,得到包内相对路径。 */
|
|
109
|
+
export function stripPackagePrefix(path) {
|
|
110
|
+
return path.startsWith(PACKAGE_PREFIX) ? path.slice(PACKAGE_PREFIX.length) : path;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** sha512(base64)。与 npm `integrity` 字段的载荷同形。 */
|
|
114
|
+
export function sha512Base64(buffer) {
|
|
115
|
+
return createHash("sha512").update(buffer).digest("base64");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 校验一条 npm integrity 字符串(形如 `sha512-<base64>`)。
|
|
120
|
+
* 只认 sha512:这是 lockfile 里给我们的那一种,其它算法一律当不匹配 —— 校验失败必须是失败,
|
|
121
|
+
* 不能因为「不认识的算法」而放行。
|
|
122
|
+
* @param {Buffer} buffer - 待校验的字节。
|
|
123
|
+
* @param {string} integrity - npm integrity 字符串。
|
|
124
|
+
* @returns {boolean} 是否匹配。
|
|
125
|
+
*/
|
|
126
|
+
export function verifyIntegrity(buffer, integrity) {
|
|
127
|
+
const expected = String(integrity ?? "").trim();
|
|
128
|
+
if (!expected.startsWith("sha512-")) return false;
|
|
129
|
+
return sha512Base64(buffer) === expected.slice("sha512-".length);
|
|
130
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// 运行时验证三件套(issue #131 / PR-A)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要分三层,而不是「能 import 就算通过」:
|
|
4
|
+
//
|
|
5
|
+
// 结构 —— 入口在不在、版本对不对、平台子树对不对、闭包有没有缺口。
|
|
6
|
+
// 便宜、确定,能立刻发现「解压到一半」。
|
|
7
|
+
// 功能 —— 真的 import 入口并跑一次推理。这是唯一能证明「这份运行时在这个
|
|
8
|
+
// 环境里真的算得出来」的一层;结构全对但原生二进制与本机 ABI 不
|
|
9
|
+
// 匹配时,只有它能发现。
|
|
10
|
+
// 完整性 —— 只对「有原始产物可比对」的来源做(下载的 tarball、本地 tarball)。
|
|
11
|
+
// 收编来的目录没有原始 tarball,如实标 unverified,不假装验过。
|
|
12
|
+
//
|
|
13
|
+
// 功能验证复用 src/local-embedder.js 的 engineFactory 思路:真正干活的是注入进来
|
|
14
|
+
// 的 engine,默认实现才去 import 真实运行时。这样测试不需要网络、不需要模型,
|
|
15
|
+
// 也能把「返回了非归一化向量」「只返回一行」这类问题钉住。
|
|
16
|
+
//
|
|
17
|
+
// 默认 engine 会强制 env.allowRemoteModels = false:验证过程绝不能偷偷下载模型,
|
|
18
|
+
// 否则「验证」本身就成了一个新的网络依赖点。
|
|
19
|
+
//
|
|
20
|
+
// @module dsh-mneme/runtime/verify
|
|
21
|
+
import { pathToFileURL } from "node:url";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { TRANSFORMERS_ENTRY, describePayload } from "./layout.js";
|
|
24
|
+
|
|
25
|
+
/** 默认探针文本:中英各一条,避免只覆盖单字节语种。 */
|
|
26
|
+
export const DEFAULT_PROBE_TEXTS = ["猫咪喜欢晒太阳", "the quick brown fox"];
|
|
27
|
+
/** 默认模型,与 src/config.js 的 localEmbedModel 默认值一致。 */
|
|
28
|
+
export const DEFAULT_PROBE_MODEL = "Xenova/bge-small-zh-v1.5";
|
|
29
|
+
/** L2 归一化的容忍度(我们显式请求了 normalize: true)。 */
|
|
30
|
+
const NORM_TOLERANCE = 0.01;
|
|
31
|
+
/** 判定「退化向量」的余弦上限:两条毫不相干的文本不该几乎同向。 */
|
|
32
|
+
const DEGENERATE_COS = 0.999;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 默认 engine:真的去 import 运行时并建一个 feature-extraction 管道。
|
|
36
|
+
*
|
|
37
|
+
* 返回一个 `embed(texts) => number[][]`。之所以不返回 transformers 的 tensor,
|
|
38
|
+
* 是为了把「Tensor 形状怎么读」这种细节留在实现里,验证逻辑只看数字。
|
|
39
|
+
* @param {string} entryUrl - 入口文件的 file URL。
|
|
40
|
+
* @param {{cacheDir?: string, model?: string, dtype?: string, device?: string}} opts - 选项。
|
|
41
|
+
* @returns {Promise<(texts: string[]) => Promise<number[][]>>} 嵌入函数。
|
|
42
|
+
*/
|
|
43
|
+
export async function defaultEngine(entryUrl, { cacheDir, model = DEFAULT_PROBE_MODEL, dtype = "q8", device = "cpu" } = {}) {
|
|
44
|
+
const mod = await import(entryUrl);
|
|
45
|
+
const { env, pipeline } = mod;
|
|
46
|
+
if (cacheDir) {
|
|
47
|
+
try {
|
|
48
|
+
env.cacheDir = cacheDir;
|
|
49
|
+
} catch {
|
|
50
|
+
/* 老版本可能没有这个字段:忽略,交给下面 pipeline 自己找缓存 */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// 验证不允许触网:缓存命中就本地加载,命中不了就明确失败。
|
|
54
|
+
try {
|
|
55
|
+
env.allowRemoteModels = false;
|
|
56
|
+
} catch {
|
|
57
|
+
/* 同上 */
|
|
58
|
+
}
|
|
59
|
+
const extractor = await pipeline("feature-extraction", model, { dtype, device, cache_dir: cacheDir });
|
|
60
|
+
return async (texts) => {
|
|
61
|
+
const tensor = await extractor(texts, { pooling: "mean", normalize: true });
|
|
62
|
+
const dims = Array.from(tensor.dims ?? []);
|
|
63
|
+
const width = dims[dims.length - 1] || 0;
|
|
64
|
+
// 宽度为 0 时下面的 `i += width` 会原地打转 —— 那是**死循环**,会把调用方(乃至 DSH 主进程)
|
|
65
|
+
// 挂住。宁可明确失败:调用方按契约把失败变成 {ok:false, reason},而不是永远等不到结果。
|
|
66
|
+
if (!Number.isInteger(width) || width <= 0) {
|
|
67
|
+
throw new Error(`推理输出的维度不可用:dims=${JSON.stringify(dims)}`);
|
|
68
|
+
}
|
|
69
|
+
const rows = [];
|
|
70
|
+
for (let i = 0; i < tensor.data.length; i += width) {
|
|
71
|
+
rows.push(Array.from(tensor.data.subarray(i, i + width)));
|
|
72
|
+
}
|
|
73
|
+
return rows;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 向量模长。 */
|
|
78
|
+
function norm(row) {
|
|
79
|
+
let sum = 0;
|
|
80
|
+
for (const value of row) sum += value * value;
|
|
81
|
+
return Math.sqrt(sum);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 两行向量的余弦。 */
|
|
85
|
+
function cosine(a, b) {
|
|
86
|
+
let dot = 0;
|
|
87
|
+
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
|
|
88
|
+
return dot;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 跑功能验证:真的 import 并嵌入探针文本,然后检查结果的形状与数值性质。
|
|
93
|
+
*
|
|
94
|
+
* 任何失败都返回结果对象,不抛异常——验证本身必须是 fail-safe 的,
|
|
95
|
+
* 否则一条坏运行时会把调用方(面板、状态接口)也带崩。
|
|
96
|
+
* @param {string} dir - payload 目录。
|
|
97
|
+
* @param {object} [opts] - 选项。
|
|
98
|
+
* @returns {Promise<{ok: boolean, reason: string|null, dim: number|null, rows: number, elapsedMs: number}>}
|
|
99
|
+
*/
|
|
100
|
+
export async function verifyFunctional(dir, {
|
|
101
|
+
engine = defaultEngine,
|
|
102
|
+
probeTexts = DEFAULT_PROBE_TEXTS,
|
|
103
|
+
cacheDir,
|
|
104
|
+
model,
|
|
105
|
+
dtype,
|
|
106
|
+
device
|
|
107
|
+
} = {}) {
|
|
108
|
+
const startedAt = Date.now();
|
|
109
|
+
const fail = (reason) => ({ ok: false, reason, dim: null, rows: 0, elapsedMs: Date.now() - startedAt });
|
|
110
|
+
|
|
111
|
+
const entry = join(dir, TRANSFORMERS_ENTRY);
|
|
112
|
+
let embed;
|
|
113
|
+
try {
|
|
114
|
+
embed = await engine(pathToFileURL(entry).href, { cacheDir, model, dtype, device });
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return fail(`加载运行时失败:${error?.message ?? error}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let rows;
|
|
120
|
+
try {
|
|
121
|
+
rows = await embed(probeTexts);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return fail(`推理失败:${error?.message ?? error}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!Array.isArray(rows) || rows.length !== probeTexts.length) {
|
|
127
|
+
return fail(`返回行数不符:期望 ${probeTexts.length},实际 ${Array.isArray(rows) ? rows.length : typeof rows}`);
|
|
128
|
+
}
|
|
129
|
+
// 先逐行确认「是数组」且「全是有限数」。这一步不能省:non-finite(NaN/Infinity)会让
|
|
130
|
+
// 下面所有比较恒为 false —— `Math.abs(NaN - 1) > tol` 是 false,`Math.abs(NaN) > 阈值`
|
|
131
|
+
// 也是 false —— 于是一份坏掉的运行时会被判成「验证通过」。这是本模块最危险的失败模式
|
|
132
|
+
// (假通过),比抛异常糟得多。行不是数组时同样要先拦住,否则 .length 直接抛,
|
|
133
|
+
// 违背「任何失败都返回结果对象,不抛异常」的契约。
|
|
134
|
+
for (let i = 0; i < rows.length; i++) {
|
|
135
|
+
if (!Array.isArray(rows[i])) return fail(`第 ${i} 行不是数组(${typeof rows[i]})`);
|
|
136
|
+
if (rows[i].some((v) => !Number.isFinite(v))) return fail(`第 ${i} 行含有非有限数值`);
|
|
137
|
+
}
|
|
138
|
+
const dim = rows[0].length;
|
|
139
|
+
if (dim === 0) return fail("返回了空向量");
|
|
140
|
+
if (rows.some((row) => row.length !== dim)) return fail("各行维度不一致");
|
|
141
|
+
|
|
142
|
+
for (let i = 0; i < rows.length; i++) {
|
|
143
|
+
const length = norm(rows[i]);
|
|
144
|
+
if (Math.abs(length - 1) > NORM_TOLERANCE) {
|
|
145
|
+
return fail(`第 ${i} 行不是 L2 归一化向量(模长 ${length.toFixed(4)})`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (rows.length >= 2 && Math.abs(cosine(rows[0], rows[1])) > DEGENERATE_COS) {
|
|
149
|
+
return fail(`嵌入退化:两条不相干文本的余弦为 ${cosine(rows[0], rows[1]).toFixed(4)}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { ok: true, reason: null, dim, rows: rows.length, elapsedMs: Date.now() - startedAt };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 三层验证,任何一层失败都如实记录;结构不过就不再做功能验证(省掉一次无谓的加载)。
|
|
157
|
+
* @param {string} dir - payload 目录。
|
|
158
|
+
* @param {object} [opts] - 选项。
|
|
159
|
+
* @param {{status: string, detail?: string}} [opts.integrity] - 调用方算好的完整性结论
|
|
160
|
+
* (下载/本地 tarball 路径在解包前比对过 sha512,把结果传进来;收编路径没有可比对
|
|
161
|
+
* 的原始产物,默认 unverified)。
|
|
162
|
+
* @returns {Promise<object>} 三件套结果 + 总判定。
|
|
163
|
+
*/
|
|
164
|
+
export async function verifyPayload(dir, {
|
|
165
|
+
platform = process.platform,
|
|
166
|
+
engine,
|
|
167
|
+
probeTexts,
|
|
168
|
+
cacheDir,
|
|
169
|
+
model,
|
|
170
|
+
dtype,
|
|
171
|
+
device,
|
|
172
|
+
integrity
|
|
173
|
+
} = {}) {
|
|
174
|
+
const structural = describePayload(dir, { platform });
|
|
175
|
+
const integrityResult = integrity
|
|
176
|
+
? { ok: integrity.status === "sha512-matched", status: integrity.status, detail: integrity.detail ?? null }
|
|
177
|
+
: { ok: true, status: "unverified", detail: "该来源没有可比对的原始产物(收编);如需强校验请用 --strict 走 tarball 通道" };
|
|
178
|
+
|
|
179
|
+
const functional = structural.ok
|
|
180
|
+
? await verifyFunctional(dir, { engine, probeTexts, cacheDir, model, dtype, device })
|
|
181
|
+
: { ok: false, reason: "结构检查未通过,跳过功能验证", dim: null, rows: 0, elapsedMs: 0 };
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
dir,
|
|
185
|
+
version: structural.version,
|
|
186
|
+
ok: structural.ok && functional.ok && integrityResult.ok,
|
|
187
|
+
structural,
|
|
188
|
+
functional,
|
|
189
|
+
integrity: integrityResult
|
|
190
|
+
};
|
|
191
|
+
}
|
package/src/scope.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// v0.8.0 A1(issue #17):写入端 scope 解析。
|
|
2
|
+
//
|
|
3
|
+
// 身份模型(issue #17 的核心约定):session 只是「载体」,记忆挂在持久化身份
|
|
4
|
+
// 标签上——agent_scope = session header 的 agentPreset(跨会话稳定的 agent 模板
|
|
5
|
+
// 身份),workspace_scope = 会话工作目录(registry 反查的 canonical path 优先,
|
|
6
|
+
// header.cwd 兜底)。session_id 永不作为作用域键。
|
|
7
|
+
//
|
|
8
|
+
// 硬约束:解析失败绝不阻塞写入——任何一步取不到就落 NULL(读侧 NULL = 未标注
|
|
9
|
+
// = 全局可见),任何异常都被吞掉(registry 故障 warn 一次后降级)。
|
|
10
|
+
//
|
|
11
|
+
// scopeEnabled 关闭时 resolveWriteScope 返回 null,调用方一个字段都不该标注,
|
|
12
|
+
// 写入与去重行为和 A1 之前逐字节一致。
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} deps
|
|
16
|
+
* @param {object} [deps.ctx] 宿主上下文(可能带 workspaceRegistry;旧版 dsh 没有)
|
|
17
|
+
* @param {object} [deps.config] 已解析配置(读 scopeEnabled)
|
|
18
|
+
* @param {object} [deps.logger]
|
|
19
|
+
* @returns {(exec: object) => {agent_scope: string|null, workspace_scope: string|null}|null}
|
|
20
|
+
*/
|
|
21
|
+
export function createScopeResolver({ ctx, config, logger } = {}) {
|
|
22
|
+
let warned = false;
|
|
23
|
+
const warnOnce = (msg) => {
|
|
24
|
+
if (warned) return;
|
|
25
|
+
warned = true;
|
|
26
|
+
try {
|
|
27
|
+
logger?.warn?.(msg);
|
|
28
|
+
} catch {
|
|
29
|
+
// logger 自身故障同样不能影响写入路径
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return function resolveWriteScope(exec) {
|
|
34
|
+
if (config?.scopeEnabled !== true) return null;
|
|
35
|
+
|
|
36
|
+
let session = null;
|
|
37
|
+
try {
|
|
38
|
+
session = exec?.agent?.session ?? null;
|
|
39
|
+
} catch {
|
|
40
|
+
session = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 持久化 header(issue #17 的身份契约):session.header 是创建时冻结的
|
|
44
|
+
// SessionHeader(含 agentPreset/cwd)。⚠️ 不能先读 requestHeader()——那返回
|
|
45
|
+
// EpochHeader(请求级模型路由头,首个请求后即为真值),会把 session.header
|
|
46
|
+
// 遮蔽掉,agentPreset/cwd 双双取空(本地 web 复验 2026-09-13 实测踩中)。
|
|
47
|
+
// 仅当宿主没有 .header 属性时才降级尝试 requestHeader().config(旧形状)。
|
|
48
|
+
let header = null;
|
|
49
|
+
try {
|
|
50
|
+
header = session?.header ?? session?.requestHeader?.()?.config ?? null;
|
|
51
|
+
} catch {
|
|
52
|
+
header = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let agentScope = null;
|
|
56
|
+
const preset = header?.agentPreset;
|
|
57
|
+
if (typeof preset === "string" && preset.trim()) agentScope = preset.trim();
|
|
58
|
+
|
|
59
|
+
// workspace:registry 反查优先(成员资格 = id 命中 + header canonical cwd
|
|
60
|
+
// 校验过,path 已 realpath 归一),失败回退 header.cwd,再失败留 NULL。
|
|
61
|
+
let workspaceScope = null;
|
|
62
|
+
try {
|
|
63
|
+
const list = ctx?.workspaceRegistry?.list?.();
|
|
64
|
+
const sessionId = session?.id;
|
|
65
|
+
if (Array.isArray(list) && sessionId != null) {
|
|
66
|
+
const hit = list.find(
|
|
67
|
+
(ws) => Array.isArray(ws?.sessionIds) && ws.sessionIds.includes(sessionId)
|
|
68
|
+
);
|
|
69
|
+
if (typeof hit?.path === "string" && hit.path.trim()) workspaceScope = hit.path.trim();
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
warnOnce(
|
|
73
|
+
`[dsh-mneme] workspaceRegistry lookup failed, falling back to session header cwd: ${String(e)}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (!workspaceScope) {
|
|
77
|
+
const cwd = header?.cwd;
|
|
78
|
+
if (typeof cwd === "string" && cwd.trim()) workspaceScope = cwd.trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { agent_scope: agentScope, workspace_scope: workspaceScope };
|
|
82
|
+
};
|
|
83
|
+
}
|