dsh-memu-memory 1.1.2 → 1.1.3
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/AGENTS.md +8 -6
- package/README.md +21 -5
- package/README.zh.md +21 -5
- package/docs/contributing.md +5 -3
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +32 -5
- package/docs/troubleshooting.md +36 -7
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +630 -0
- package/lib/client.js +697 -49
- package/lib/curator.js +81 -37
- package/lib/cycle.js +3 -0
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +36 -0
- package/lib/inject.js +131 -30
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +249 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +385 -45
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/paths.js +24 -0
- package/lib/private.js +139 -13
- package/lib/rpc.js +45 -4
- package/lib/state.js +109 -161
- package/lib/tools.js +9 -5
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +941 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +5 -4
- package/scripts/chain-matrix.mjs +527 -0
- package/scripts/check-all.mjs +19 -0
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- package/scripts/repetition-eval.mjs +702 -0
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import {
|
|
5
|
+
dataDir, librariesDir, indexDir, ensureDir,
|
|
6
|
+
readJsonStrict, writeJson, withFileLock, stamp, isSafeId,
|
|
7
|
+
} from './paths.js'
|
|
8
|
+
import {
|
|
9
|
+
PROVIDER_ORDER, getEmbeddingModel, profileFingerprint, validateEmbeddingManifest,
|
|
10
|
+
} from './embedding-catalog.js'
|
|
11
|
+
|
|
12
|
+
// §14.8 向量空间身份、代际选择与写入护栏。
|
|
13
|
+
//
|
|
14
|
+
// 本模块只回答四件事:
|
|
15
|
+
// 1. 一个 profile 的身份是什么(fingerprint 直接复用 embedding-catalog 的协议,不另立一套);
|
|
16
|
+
// 2. 当前 active 代际是哪一个(单个 index/vector-active.json 原子选择 profile + DB 集合);
|
|
17
|
+
// 3. 用户"想要"的 profile(requested)与真正生效的 profile 分离;
|
|
18
|
+
// 4. 此刻允不允许写向量(护栏:未验证/未就绪/pending/撕裂一律拒绝,且拒绝必须可退词法,
|
|
19
|
+
// 绝不能悄悄回落成 legacy 远端嵌入)。
|
|
20
|
+
//
|
|
21
|
+
// 硬约束(来自 §14.8):
|
|
22
|
+
// * profile 只依赖 paths/catalog,不依赖 state.json;本模块不读 state。
|
|
23
|
+
// * 文件锁只在最后短窗口内做同步 compare + 原子写指针;整个模块没有 await 跨锁。
|
|
24
|
+
// * 所有 DB 路径都从 active manifest 的 database-set 一次性取得,避免逐个库换导致混空间。
|
|
25
|
+
// * 全局库影子也放 librariesDir():backupDatabases 只扫 libraries/*.sqlite3 与 index/*.json,
|
|
26
|
+
// 放 data 根目录会被备份漏掉(§14.9 第 6 条要求备份包含 vector active profile manifest)。
|
|
27
|
+
|
|
28
|
+
export const VECTOR_ACTIVE_FILE = 'vector-active.json'
|
|
29
|
+
export const VECTOR_ACTIVE_SCHEMA = 1
|
|
30
|
+
|
|
31
|
+
export const VECTOR_ERROR_CODES = Object.freeze({
|
|
32
|
+
PROFILE_MISSING: 'VECTOR_PROFILE_MISSING',
|
|
33
|
+
PROFILE_UNKNOWN: 'VECTOR_PROFILE_UNKNOWN',
|
|
34
|
+
PROFILE_PENDING: 'VECTOR_PROFILE_PENDING',
|
|
35
|
+
PROFILE_UNVERIFIED: 'VECTOR_PROFILE_UNVERIFIED',
|
|
36
|
+
PROFILE_FINGERPRINT_MISMATCH: 'VECTOR_PROFILE_FINGERPRINT_MISMATCH',
|
|
37
|
+
PROFILE_MANIFEST_CHANGED: 'VECTOR_PROFILE_MANIFEST_CHANGED',
|
|
38
|
+
ACTIVE_CORRUPT: 'VECTOR_ACTIVE_CORRUPT',
|
|
39
|
+
ACTIVE_UNREADABLE: 'VECTOR_ACTIVE_UNREADABLE',
|
|
40
|
+
NOT_ACTIVE: 'VECTOR_NOT_ACTIVE',
|
|
41
|
+
TORN_STATE: 'VECTOR_TORN_STATE',
|
|
42
|
+
STALE_GENERATION: 'VECTOR_STALE_GENERATION',
|
|
43
|
+
NO_GENERATION: 'VECTOR_NO_GENERATION',
|
|
44
|
+
SHADOW_PATH_TAKEN: 'VECTOR_SHADOW_PATH_TAKEN',
|
|
45
|
+
PATH_ESCAPE: 'VECTOR_PATH_ESCAPE',
|
|
46
|
+
BAD_UPDATE: 'VECTOR_BAD_UPDATE',
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 向量状态错误:带 code + fallback='lexical' + forbidLegacyRemote。
|
|
51
|
+
* 调用方一旦看到 isVectorStateError(e) 为真,就只能退化成词法召回,
|
|
52
|
+
* 不允许把同一批查询发去 legacy/远端嵌入(那会把两种向量空间混在一个库里)。
|
|
53
|
+
*/
|
|
54
|
+
export class VectorStateError extends Error {
|
|
55
|
+
constructor(code, message, extra = {}) {
|
|
56
|
+
super(message)
|
|
57
|
+
this.name = 'VectorStateError'
|
|
58
|
+
this.code = code
|
|
59
|
+
this.fallback = 'lexical'
|
|
60
|
+
this.forbidLegacyRemote = true
|
|
61
|
+
for (const [key, value] of Object.entries(extra)) if (value !== undefined) this[key] = value
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function vectorError(code, message, extra) {
|
|
66
|
+
return new VectorStateError(code, message, extra)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isVectorStateError(value) {
|
|
70
|
+
if (value instanceof VectorStateError) return true
|
|
71
|
+
return typeof value?.code === 'string' && value.code.startsWith('VECTOR_')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------- 规范化哈希
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 规范化 JSON 序列化:键排序、undefined 省略。与 embedding-catalog 内部 canonical()
|
|
78
|
+
* 同一协议(那里没有导出),保证"同一个 manifest 在任何模块里哈希都相同"。
|
|
79
|
+
*/
|
|
80
|
+
export function stableStringify(value) {
|
|
81
|
+
if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']'
|
|
82
|
+
if (value && typeof value === 'object') {
|
|
83
|
+
return '{' + Object.keys(value).sort()
|
|
84
|
+
.filter(key => value[key] !== undefined)
|
|
85
|
+
.map(key => JSON.stringify(key) + ':' + stableStringify(value[key]))
|
|
86
|
+
.join(',') + '}'
|
|
87
|
+
}
|
|
88
|
+
return JSON.stringify(value === undefined ? null : value)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function sha256Hex(value) {
|
|
92
|
+
return crypto.createHash('sha256').update(value).digest('hex')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 完整 manifest 哈希:整个 manifest 的规范化摘要(只排除自引用的 profileFingerprint)。
|
|
97
|
+
* profileFingerprint 描述"向量空间",manifestDigest 描述"制品清单本身";
|
|
98
|
+
* 两者一起才能证明"这代向量是由这份制品算出来的"。
|
|
99
|
+
*/
|
|
100
|
+
// 摘要只覆盖"会改变向量空间或制品"的字段。之前它把 displayName/summary/参数说明这些
|
|
101
|
+
// 文案也算进去:改一句文档路径就能让用户已激活的 profile 判为"manifest 已变",整个重建
|
|
102
|
+
// 白做(2026-10-30 现场)。文案不是制品身份。
|
|
103
|
+
const DIGEST_FIELDS = ['id', 'dimension', 'maxTokens', 'pooling', 'normalization', 'prefixes', 'quantization',
|
|
104
|
+
'runtime', 'sources', 'files', 'graph', 'protocol', 'preprocessing', 'windowing', 'aggregation', 'tokenizer']
|
|
105
|
+
|
|
106
|
+
export function manifestDigest(manifest) {
|
|
107
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return null
|
|
108
|
+
const subset = {}
|
|
109
|
+
for (const key of DIGEST_FIELDS) if (manifest[key] !== undefined) subset[key] = manifest[key]
|
|
110
|
+
return sha256Hex(stableStringify(subset))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 旧算法(全字段,除了它自己)。只用于兼容已经落盘的 profile。 */
|
|
114
|
+
export function legacyManifestDigest(manifest) {
|
|
115
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return null
|
|
116
|
+
const copy = {}
|
|
117
|
+
for (const key of Object.keys(manifest)) {
|
|
118
|
+
if (key === 'profileFingerprint') continue
|
|
119
|
+
copy[key] = manifest[key]
|
|
120
|
+
}
|
|
121
|
+
return sha256Hex(stableStringify(copy))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 已落盘摘要两种都认:新算法 + 旧算法,别因一句文案把用户的向量空间判废。 */
|
|
125
|
+
export function manifestDigestAccepted(stored, manifest) {
|
|
126
|
+
if (typeof stored !== 'string' || !stored) return true
|
|
127
|
+
return stored === manifestDigest(manifest) || stored === legacyManifestDigest(manifest)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 直接复用 embedding-catalog 的指纹协议(任何改变向量空间的字段都进指纹,§14.8)。 */
|
|
131
|
+
export { profileFingerprint }
|
|
132
|
+
|
|
133
|
+
export function profileShortFingerprint(profileOrEntry) {
|
|
134
|
+
const fingerprint = typeof profileOrEntry === 'string'
|
|
135
|
+
? profileOrEntry
|
|
136
|
+
: (profileOrEntry?.fingerprint || profileFingerprint(profileOrEntry))
|
|
137
|
+
return String(fingerprint).slice(0, 8)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------- catalog 注入点
|
|
141
|
+
|
|
142
|
+
// 默认只认 embedding-catalog.js 的目录。测试(以及将来多目录场景)可以换一个解析器:
|
|
143
|
+
// 指纹/校验协议不变,只是"哪个 id 对应哪份 manifest"可以注入。
|
|
144
|
+
let catalogEntryResolver = id => getEmbeddingModel(id)
|
|
145
|
+
|
|
146
|
+
export function setCatalogEntryResolver(resolver) {
|
|
147
|
+
catalogEntryResolver = typeof resolver === 'function' ? resolver : (id => getEmbeddingModel(id))
|
|
148
|
+
return catalogEntryResolver
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function resetCatalogEntryResolver() {
|
|
152
|
+
catalogEntryResolver = id => getEmbeddingModel(id)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 解析目录条目:只按稳定 id 取,未知 id 返回 null(不猜、不回退默认模型)。 */
|
|
156
|
+
export function resolveCatalogEntry(catalogId) {
|
|
157
|
+
if (typeof catalogId !== 'string' || !catalogId) return null
|
|
158
|
+
try {
|
|
159
|
+
return catalogEntryResolver(catalogId) ?? null
|
|
160
|
+
} catch {
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------- profile 构造
|
|
166
|
+
|
|
167
|
+
function primarySourceOf(entry) {
|
|
168
|
+
const sources = entry?.sources && typeof entry.sources === 'object' ? entry.sources : {}
|
|
169
|
+
for (const id of PROVIDER_ORDER) {
|
|
170
|
+
const source = sources[id]
|
|
171
|
+
if (source && typeof source.revision === 'string') return { provider: id, repo: source.repo, revision: source.revision }
|
|
172
|
+
}
|
|
173
|
+
return { provider: null, repo: null, revision: null }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 由 catalog/manifest 条目构造 profile。纯函数:不读 state、不写盘、不看当前 active。
|
|
178
|
+
* 返回的对象冻结,避免调用方改到指纹依赖的字段。
|
|
179
|
+
*/
|
|
180
|
+
export function buildProfile(entry, installation = {}) {
|
|
181
|
+
if (!entry || typeof entry !== 'object') throw vectorError(VECTOR_ERROR_CODES.PROFILE_MISSING, 'a catalog entry is required to build a vector profile')
|
|
182
|
+
// 永远按 catalog 协议现场重算:条目里存的 profileFingerprint 只是个缓存,
|
|
183
|
+
// 被改过的条目不能靠它冒充另一个向量空间。
|
|
184
|
+
const fingerprint = profileFingerprint(entry)
|
|
185
|
+
const source = primarySourceOf(entry)
|
|
186
|
+
const files = (Array.isArray(entry.files) ? entry.files : []).map(file => Object.freeze({
|
|
187
|
+
path: String(file.path), role: file.role ?? null, bytes: Number(file.bytes), sha256: String(file.sha256),
|
|
188
|
+
}))
|
|
189
|
+
const runtime = entry.runtime && typeof entry.runtime === 'object'
|
|
190
|
+
? Object.freeze({ engine: entry.runtime.engine ?? null, abi: entry.runtime.abi ?? null, version: entry.runtime.version ?? null, os: entry.runtime.os ?? null, arch: entry.runtime.arch ?? null })
|
|
191
|
+
: null
|
|
192
|
+
const quantization = entry.quantization && typeof entry.quantization === 'object'
|
|
193
|
+
? Object.freeze({ weights: entry.quantization.weights ?? null, scheme: entry.quantization.scheme ?? null })
|
|
194
|
+
: null
|
|
195
|
+
return Object.freeze({
|
|
196
|
+
schema: VECTOR_ACTIVE_SCHEMA,
|
|
197
|
+
catalogId: String(entry.id),
|
|
198
|
+
manifest: structuredClone(entry),
|
|
199
|
+
modelDir: installation.modelDir ?? entry.modelDir ?? null,
|
|
200
|
+
runtimeDir: installation.runtimeDir ?? entry.runtimeDir ?? null,
|
|
201
|
+
fingerprint,
|
|
202
|
+
short: fingerprint.slice(0, 8),
|
|
203
|
+
displayName: entry.displayName ?? String(entry.id),
|
|
204
|
+
manifestDigest: manifestDigest(entry),
|
|
205
|
+
provider: source.provider,
|
|
206
|
+
repo: source.repo,
|
|
207
|
+
revision: source.revision,
|
|
208
|
+
dimension: entry.dimension ?? null,
|
|
209
|
+
maxTokens: entry.maxTokens ?? null,
|
|
210
|
+
pooling: entry.pooling ?? null,
|
|
211
|
+
normalization: entry.normalization ?? null,
|
|
212
|
+
prefixes: entry.prefixes ? Object.freeze({ query: entry.prefixes.query ?? '', document: entry.prefixes.document ?? '' }) : null,
|
|
213
|
+
quantization,
|
|
214
|
+
runtime,
|
|
215
|
+
files: Object.freeze(files),
|
|
216
|
+
graphSha256: files.find(file => file.role === 'graph')?.sha256 ?? null,
|
|
217
|
+
tokenizerSha256: files.find(file => file.role === 'tokenizer')?.sha256 ?? null,
|
|
218
|
+
verification: Object.freeze({
|
|
219
|
+
status: entry.verification?.status ?? 'unverified',
|
|
220
|
+
method: entry.verification?.method ?? null,
|
|
221
|
+
checkedAt: entry.verification?.checkedAt ?? null,
|
|
222
|
+
}),
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 只校验 manifest 本身能不能用:pending(未核实制品)与未验证 manifest 都拒绝(§14.4 V0)。
|
|
228
|
+
* 单独导出,便于"候选模型是否可启用"这类只读判断,也便于测试直接喂一份坏 manifest。
|
|
229
|
+
*/
|
|
230
|
+
export function assertManifestUsable(entry) {
|
|
231
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
232
|
+
throw vectorError(VECTOR_ERROR_CODES.PROFILE_MISSING, 'a manifest entry is required')
|
|
233
|
+
}
|
|
234
|
+
if (entry.availability !== 'available') {
|
|
235
|
+
throw vectorError(VECTOR_ERROR_CODES.PROFILE_PENDING, String(entry.id ?? 'unknown model') + ' has no verified artifact; pending models cannot be activated', {
|
|
236
|
+
catalogId: entry.id ?? null,
|
|
237
|
+
pendingReasons: [...(entry.pendingReasons || [])],
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
if (entry.verification?.status !== 'verified') {
|
|
241
|
+
throw vectorError(VECTOR_ERROR_CODES.PROFILE_UNVERIFIED, String(entry.id ?? 'unknown model') + ' is not verified; refusing to build a vector space on it', { catalogId: entry.id ?? null })
|
|
242
|
+
}
|
|
243
|
+
const report = validateEmbeddingManifest(entry)
|
|
244
|
+
if (!report.ok) {
|
|
245
|
+
throw vectorError(VECTOR_ERROR_CODES.PROFILE_UNVERIFIED, String(entry.id ?? 'unknown model') + ' manifest is not usable: ' + report.errors.map(item => item.code).join(', '), {
|
|
246
|
+
catalogId: entry.id ?? null,
|
|
247
|
+
errors: report.errors,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 校验一个 profile 仍然被当前 catalog 认可:条目存在、指纹与完整 manifest 哈希都对得上、
|
|
255
|
+
* 制品可用(availability === 'available')、manifest 通过 validateEmbeddingManifest。
|
|
256
|
+
* 未验证 manifest 与 pending 制品在这里就被拒绝(§14.4/§14.8)。
|
|
257
|
+
*/
|
|
258
|
+
export function assertProfileVerified(profile) {
|
|
259
|
+
if (!profile || typeof profile !== 'object' || !profile.catalogId) {
|
|
260
|
+
throw vectorError(VECTOR_ERROR_CODES.PROFILE_MISSING, 'no vector profile to verify')
|
|
261
|
+
}
|
|
262
|
+
const entry = resolveCatalogEntry(profile.catalogId)
|
|
263
|
+
if (!entry) {
|
|
264
|
+
throw vectorError(VECTOR_ERROR_CODES.PROFILE_UNKNOWN, 'the catalog no longer defines ' + profile.catalogId + '; refusing to use a vector space that cannot be reproduced', { catalogId: profile.catalogId })
|
|
265
|
+
}
|
|
266
|
+
const expected = profileFingerprint(entry)
|
|
267
|
+
if (profile.fingerprint !== expected) {
|
|
268
|
+
throw vectorError(VECTOR_ERROR_CODES.PROFILE_FINGERPRINT_MISMATCH, 'profile fingerprint does not match the catalog for ' + entry.id, { expected, actual: profile.fingerprint })
|
|
269
|
+
}
|
|
270
|
+
const digest = manifestDigest(entry)
|
|
271
|
+
if (!manifestDigestAccepted(profile.manifestDigest, entry)) {
|
|
272
|
+
throw vectorError(VECTOR_ERROR_CODES.PROFILE_MANIFEST_CHANGED, 'the manifest for ' + entry.id + ' changed after this profile was recorded', { expected: digest, actual: profile.manifestDigest })
|
|
273
|
+
}
|
|
274
|
+
// The persisted manifest and derived processing fields are execution inputs,
|
|
275
|
+
// not harmless annotations. Reject tampering even if cached hashes survived.
|
|
276
|
+
// 存储的 manifest 只需在"向量空间"上仍然一致;指纹已覆盖这些字段。
|
|
277
|
+
if (!profile.manifest || profileFingerprint(profile.manifest) !== expected) {
|
|
278
|
+
throw vectorError(VECTOR_ERROR_CODES.PROFILE_MANIFEST_CHANGED, 'the stored profile manifest no longer matches the catalog for ' + entry.id)
|
|
279
|
+
}
|
|
280
|
+
const rebuilt = buildProfile(entry)
|
|
281
|
+
for (const field of ['schema', 'short', 'provider', 'repo', 'revision', 'dimension', 'maxTokens', 'pooling', 'normalization', 'prefixes', 'quantization', 'runtime', 'files', 'graphSha256', 'tokenizerSha256']) {
|
|
282
|
+
if (stableStringify(profile[field]) !== stableStringify(rebuilt[field])) throw vectorError(VECTOR_ERROR_CODES.PROFILE_MANIFEST_CHANGED, 'the stored profile processing field changed: ' + field)
|
|
283
|
+
}
|
|
284
|
+
assertManifestUsable(entry)
|
|
285
|
+
return profile
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 按 catalog id 构造并验证 profile;pending/未知/未验证一律抛 typed error。 */
|
|
289
|
+
export function profileFromCatalog(catalogId, installation = {}) {
|
|
290
|
+
const id = typeof catalogId === 'string' ? catalogId : String(catalogId ?? '')
|
|
291
|
+
const entry = resolveCatalogEntry(id)
|
|
292
|
+
if (!entry) throw vectorError(VECTOR_ERROR_CODES.PROFILE_UNKNOWN, 'unknown embedding model: ' + JSON.stringify(id), { catalogId: id })
|
|
293
|
+
assertManifestUsable(entry)
|
|
294
|
+
const profile = buildProfile(entry, installation)
|
|
295
|
+
return assertProfileVerified(profile)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 接受 profile 对象 / catalog id / manifest 条目,统一成已验证 profile。 */
|
|
299
|
+
export function resolveProfile(value) {
|
|
300
|
+
if (value && typeof value === 'object' && value.catalogId) return assertProfileVerified(value)
|
|
301
|
+
if (value && typeof value === 'object' && value.id) return profileFromCatalog(value.id)
|
|
302
|
+
return profileFromCatalog(value)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------- 路径与 DB 集合
|
|
306
|
+
|
|
307
|
+
/** 受管 DB 一律用 "libraries/<name>.<fp8>.g<gen>.sqlite3":文件名自带代际,切换=换指针。 */
|
|
308
|
+
export function vectorDbRelPath({ libraryId = null, fingerprint, generation } = {}) {
|
|
309
|
+
if (typeof fingerprint !== 'string' || !fingerprint) {
|
|
310
|
+
throw vectorError(VECTOR_ERROR_CODES.BAD_UPDATE, 'vectorDbRelPath requires a fingerprint')
|
|
311
|
+
}
|
|
312
|
+
if (!Number.isSafeInteger(generation) || generation < 1) {
|
|
313
|
+
throw vectorError(VECTOR_ERROR_CODES.BAD_UPDATE, 'vectorDbRelPath requires a positive generation')
|
|
314
|
+
}
|
|
315
|
+
const short = fingerprint.slice(0, 8)
|
|
316
|
+
const gen = 'g' + generation
|
|
317
|
+
if (libraryId === null || libraryId === undefined || libraryId === 'global') {
|
|
318
|
+
return ['libraries', 'global.' + short + '.' + gen + '.sqlite3'].join('/')
|
|
319
|
+
}
|
|
320
|
+
if (!isSafeId(libraryId)) throw vectorError(VECTOR_ERROR_CODES.PATH_ESCAPE, 'unsafe library id: ' + JSON.stringify(libraryId), { libraryId })
|
|
321
|
+
return ['libraries', libraryId + '.' + short + '.' + gen + '.sqlite3'].join('/')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 相对 dataDir 的受管路径 → 绝对路径;绝对路径与越界路径一律拒绝。 */
|
|
325
|
+
export function resolveVectorPath(relPath) {
|
|
326
|
+
if (typeof relPath !== 'string' || !relPath.trim()) {
|
|
327
|
+
throw vectorError(VECTOR_ERROR_CODES.PATH_ESCAPE, 'vector storage path is missing', { path: relPath })
|
|
328
|
+
}
|
|
329
|
+
if (path.isAbsolute(relPath)) {
|
|
330
|
+
throw vectorError(VECTOR_ERROR_CODES.PATH_ESCAPE, 'vector storage paths must be relative to the data root', { path: relPath })
|
|
331
|
+
}
|
|
332
|
+
const root = path.resolve(dataDir())
|
|
333
|
+
const abs = path.resolve(root, ...relPath.split(/[\\/]+/).filter(Boolean))
|
|
334
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
|
335
|
+
throw vectorError(VECTOR_ERROR_CODES.PATH_ESCAPE, 'vector storage path escapes the data root', { path: relPath })
|
|
336
|
+
}
|
|
337
|
+
return abs
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function vectorDbAbsPath(options) {
|
|
341
|
+
return resolveVectorPath(vectorDbRelPath(options))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** 非受管模式(尚未启用本地向量)仍走原来的库路径。 */
|
|
345
|
+
export function legacyDbPath(libraryId = null) {
|
|
346
|
+
if (libraryId === null || libraryId === undefined || libraryId === 'global') return path.join(dataDir(), 'global.sqlite3')
|
|
347
|
+
if (!isSafeId(libraryId)) throw vectorError(VECTOR_ERROR_CODES.PATH_ESCAPE, 'unsafe library id: ' + JSON.stringify(libraryId), { libraryId })
|
|
348
|
+
return path.join(librariesDir(), libraryId + '.sqlite3')
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------- active manifest
|
|
352
|
+
|
|
353
|
+
export function vectorActiveFile() {
|
|
354
|
+
return path.join(indexDir(), VECTOR_ACTIVE_FILE)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function emptyState() {
|
|
358
|
+
return { version: VECTOR_ACTIVE_SCHEMA, generation: 0, requested: null, active: null, pending: null }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function validDatabases(value) {
|
|
362
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
363
|
+
&& value.global && typeof value.global === 'object'
|
|
364
|
+
&& value.libraries && typeof value.libraries === 'object' && !Array.isArray(value.libraries)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function validActive(active) {
|
|
368
|
+
return Boolean(active) && typeof active === 'object'
|
|
369
|
+
&& active.status === 'active'
|
|
370
|
+
&& Number.isSafeInteger(active.generation) && active.generation >= 1
|
|
371
|
+
&& typeof active.fingerprint === 'string' && active.fingerprint.length > 0
|
|
372
|
+
&& active.profile && typeof active.profile === 'object'
|
|
373
|
+
&& validDatabases(active.databases)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const PENDING_STATUSES = Object.freeze(['preparing', 'prepared', 'committed', 'aborted', 'failed'])
|
|
377
|
+
|
|
378
|
+
function validPending(pending) {
|
|
379
|
+
return Boolean(pending) && typeof pending === 'object'
|
|
380
|
+
&& PENDING_STATUSES.includes(pending.status)
|
|
381
|
+
&& Number.isSafeInteger(pending.generation)
|
|
382
|
+
&& typeof pending.fingerprint === 'string'
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 受管路由被选中了,是否真的有一代可用的向量空间。
|
|
387
|
+
*
|
|
388
|
+
* 只选了模型(state.requested)或 state 文件根本没写成时,写入没有可对齐的向量代际。
|
|
389
|
+
* 调用方必须据此降级,而不是让整条写入链失败:索引 + 引擎才是权威,向量可以在下一次
|
|
390
|
+
* 重建里补回来。active 存在但损坏(缺 DB、manifest 不全)仍然算"有",那种情况必须
|
|
391
|
+
* 大声失败,不能静默分叉。
|
|
392
|
+
*/
|
|
393
|
+
export function hasActiveVectorGeneration() {
|
|
394
|
+
try {
|
|
395
|
+
return Boolean(readVectorState({ tolerateMissing: true }).state?.active)
|
|
396
|
+
} catch {
|
|
397
|
+
return false
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 读 active manifest。missing → { ok: true, state: null };
|
|
403
|
+
* 坏 JSON / 坏形状 → typed error(绝不把"读不懂"当成"没有",更不猜最近文件)。
|
|
404
|
+
*/
|
|
405
|
+
export function readVectorState({ tolerateMissing = true } = {}) {
|
|
406
|
+
const file = vectorActiveFile()
|
|
407
|
+
const result = readJsonStrict(file)
|
|
408
|
+
if (!result.ok) {
|
|
409
|
+
if (result.missing) {
|
|
410
|
+
if (tolerateMissing) return { ok: true, state: null, file }
|
|
411
|
+
throw vectorError(VECTOR_ERROR_CODES.NOT_ACTIVE, 'no vector-active manifest yet', { file })
|
|
412
|
+
}
|
|
413
|
+
if (result.kind === 'corrupt') throw vectorError(VECTOR_ERROR_CODES.ACTIVE_CORRUPT, 'vector-active.json is not valid JSON; refusing to guess a generation', { file })
|
|
414
|
+
throw vectorError(VECTOR_ERROR_CODES.ACTIVE_UNREADABLE, 'cannot read vector-active.json (' + (result.error?.code || 'io error') + ')', { file })
|
|
415
|
+
}
|
|
416
|
+
const state = result.value
|
|
417
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
|
418
|
+
throw vectorError(VECTOR_ERROR_CODES.ACTIVE_CORRUPT, 'vector-active.json is not an object', { file })
|
|
419
|
+
}
|
|
420
|
+
if (state.active !== null && state.active !== undefined && !validActive(state.active)) {
|
|
421
|
+
throw vectorError(VECTOR_ERROR_CODES.ACTIVE_CORRUPT, 'vector-active.json has an invalid active block', { file, active: state.active })
|
|
422
|
+
}
|
|
423
|
+
if (state.pending !== null && state.pending !== undefined && !validPending(state.pending)) {
|
|
424
|
+
throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'vector-active.json has an invalid pending journal', { file, pending: state.pending })
|
|
425
|
+
}
|
|
426
|
+
if (state.version !== VECTOR_ACTIVE_SCHEMA || !Number.isSafeInteger(state.generation) || state.generation < 0) throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'unsupported or invalid vector active schema/generation', { file })
|
|
427
|
+
if (state.active) {
|
|
428
|
+
const active = state.active
|
|
429
|
+
if (active.fingerprint !== active.profile.fingerprint || state.generation < active.generation) throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'active profile and generation disagree', { file })
|
|
430
|
+
const entries = [[null, active.databases.global], ...Object.entries(active.databases.libraries)]
|
|
431
|
+
for (const [libraryId, entry] of entries) {
|
|
432
|
+
if (!entry || typeof entry !== 'object' || entry.path !== vectorDbRelPath({ libraryId, fingerprint: active.fingerprint, generation: active.generation })) throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'active database mapping disagrees with its generation', { file, libraryId })
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (state.pending?.status === 'committed' && (!state.active || state.pending.generation !== state.active.generation || state.pending.fingerprint !== state.active.fingerprint)) throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'committed journal disagrees with active generation', { file })
|
|
436
|
+
return { ok: true, state: normalizeState(state), file }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function normalizeState(state) {
|
|
440
|
+
return {
|
|
441
|
+
version: VECTOR_ACTIVE_SCHEMA,
|
|
442
|
+
generation: Number.isSafeInteger(state.generation) && state.generation >= 0 ? state.generation : 0,
|
|
443
|
+
requested: state.requested && typeof state.requested === 'object' ? state.requested : null,
|
|
444
|
+
active: state.active && typeof state.active === 'object' ? state.active : null,
|
|
445
|
+
pending: state.pending && typeof state.pending === 'object' ? state.pending : null,
|
|
446
|
+
writeGuard: state.writeGuard && typeof state.writeGuard === 'object' ? state.writeGuard : { frozen: null, leases: {} },
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** 读当前 state;缺失返回 null,损坏抛 typed error。 */
|
|
451
|
+
export function vectorState() {
|
|
452
|
+
return readVectorState({ tolerateMissing: true }).state
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* 唯一写入口:文件锁内做同步 compare + 原子指针写。mutator 必须同步——
|
|
457
|
+
* 绝不允许跨 await 持有文件锁(§14.8 第 5/7 条)。
|
|
458
|
+
*/
|
|
459
|
+
export function updateVectorStateSync(mutator) {
|
|
460
|
+
if (typeof mutator !== 'function') throw vectorError(VECTOR_ERROR_CODES.BAD_UPDATE, 'updateVectorStateSync requires a mutator')
|
|
461
|
+
const file = vectorActiveFile()
|
|
462
|
+
ensureDir(indexDir())
|
|
463
|
+
return withFileLock(file, () => {
|
|
464
|
+
const result = readJsonStrict(file)
|
|
465
|
+
let current
|
|
466
|
+
if (result.ok) {
|
|
467
|
+
if (!result.value || typeof result.value !== 'object' || Array.isArray(result.value)) {
|
|
468
|
+
throw vectorError(VECTOR_ERROR_CODES.ACTIVE_CORRUPT, 'vector-active.json is not an object', { file })
|
|
469
|
+
}
|
|
470
|
+
current = readVectorState({ tolerateMissing: false }).state
|
|
471
|
+
} else if (result.missing) {
|
|
472
|
+
current = emptyState()
|
|
473
|
+
} else if (result.kind === 'corrupt') {
|
|
474
|
+
throw vectorError(VECTOR_ERROR_CODES.ACTIVE_CORRUPT, 'vector-active.json is not valid JSON; refusing to overwrite it', { file })
|
|
475
|
+
} else {
|
|
476
|
+
throw vectorError(VECTOR_ERROR_CODES.ACTIVE_UNREADABLE, 'cannot read vector-active.json', { file })
|
|
477
|
+
}
|
|
478
|
+
const next = mutator(current)
|
|
479
|
+
if (next && typeof next.then === 'function') {
|
|
480
|
+
throw vectorError(VECTOR_ERROR_CODES.BAD_UPDATE, 'updateVectorStateSync mutator must be synchronous: never hold the vector lock across an await')
|
|
481
|
+
}
|
|
482
|
+
if (!next) return current
|
|
483
|
+
const payload = normalizeState(next)
|
|
484
|
+
writeJson(file, payload)
|
|
485
|
+
return payload
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** requested 与 active 分离:用户"选了"不等于"生效了"(§14.6 第 2 条)。 */
|
|
490
|
+
export function requestedProfile() {
|
|
491
|
+
const state = vectorState()
|
|
492
|
+
const requested = state?.requested
|
|
493
|
+
if (!requested || typeof requested !== 'object') return null
|
|
494
|
+
return requested
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function setRequestedProfile(value, extra = {}) {
|
|
498
|
+
const profile = resolveProfile(value)
|
|
499
|
+
const at = stamp()
|
|
500
|
+
const next = updateVectorStateSync(state => ({
|
|
501
|
+
...state,
|
|
502
|
+
requested: { status: 'requested', catalogId: profile.catalogId, fingerprint: profile.fingerprint, profile, at, ...extra },
|
|
503
|
+
}))
|
|
504
|
+
return next.requested
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function clearRequestedProfile() {
|
|
508
|
+
const next = updateVectorStateSync(state => ({ ...state, requested: null }))
|
|
509
|
+
return next.requested
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** 当前生效的 profile;没有就返回 null(不猜、不回退默认模型)。 */
|
|
513
|
+
export function activeProfile() {
|
|
514
|
+
const state = vectorState()
|
|
515
|
+
if (!state?.active) return null
|
|
516
|
+
assertProfileVerified(state.active.profile)
|
|
517
|
+
return state.active.profile
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function activeGeneration() {
|
|
521
|
+
const state = vectorState()
|
|
522
|
+
return state?.active?.generation ?? null
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** active manifest 里的 DB 集合,一次性取得(避免多库逐个换混空间)。 */
|
|
526
|
+
export function activeDatabases() {
|
|
527
|
+
return databaseMapFor(vectorState()?.active)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function databaseMapFor(active) {
|
|
531
|
+
if (!active) return null
|
|
532
|
+
const databases = { global: resolveVectorPath(active.databases.global.path), libraries: {} }
|
|
533
|
+
for (const [libraryId, entry] of Object.entries(active.databases.libraries || {})) {
|
|
534
|
+
databases.libraries[libraryId] = resolveVectorPath(entry?.path)
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
generation: active.generation,
|
|
538
|
+
fingerprint: active.fingerprint,
|
|
539
|
+
profile: active.profile,
|
|
540
|
+
snapshot: active.snapshot ?? null,
|
|
541
|
+
global: databases.global,
|
|
542
|
+
libraries: databases.libraries,
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* 非受管模式仍可用:有 active 代际就用它的路径,否则回落 legacy 路径。
|
|
548
|
+
* 主代理把 state.js 的 memoryDbPath 指到这里即可。
|
|
549
|
+
*/
|
|
550
|
+
export function dbPathFor(libraryId = null) {
|
|
551
|
+
const state = vectorState()
|
|
552
|
+
const active = state?.active
|
|
553
|
+
if (!active) return legacyDbPath(libraryId)
|
|
554
|
+
assertProfileVerified(active.profile)
|
|
555
|
+
const entry = libraryId === null || libraryId === undefined || libraryId === 'global'
|
|
556
|
+
? active.databases.global
|
|
557
|
+
: active.databases.libraries?.[libraryId]
|
|
558
|
+
if (!entry) throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'the active generation does not contain library ' + libraryId)
|
|
559
|
+
return resolveVectorPath(entry.path)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Register a genuinely empty new library in the current space, never legacy.
|
|
564
|
+
* Call within withVectorWrite and before the first commit/index submission.
|
|
565
|
+
* loadIndex must be synchronous; by default the authoritative raw index is read.
|
|
566
|
+
* A nonempty, corrupt, quarantined or unknown-shaped index requires rebuilding.
|
|
567
|
+
*/
|
|
568
|
+
export function enrollActiveLibrary(libraryId, { loadIndex } = {}) {
|
|
569
|
+
if (!isSafeId(libraryId) || libraryId === 'global') throw vectorError(VECTOR_ERROR_CODES.PATH_ESCAPE, 'unsafe private library id: ' + JSON.stringify(libraryId))
|
|
570
|
+
let db = null
|
|
571
|
+
updateVectorStateSync(state => {
|
|
572
|
+
if (!state.active) { db = legacyDbPath(libraryId); return null }
|
|
573
|
+
const guard = liveGuard(state.writeGuard)
|
|
574
|
+
if (guard.frozen) throw vectorError('VECTOR_WRITE_FROZEN', 'cannot enroll a library during a vector generation switch')
|
|
575
|
+
if (!Object.values(guard.leases).some(lease => lease.pid === process.pid)) throw vectorError('VECTOR_WRITE_LEASE_REQUIRED', 'enrollActiveLibrary must run within withVectorWrite')
|
|
576
|
+
assertProfileVerified(state.active.profile)
|
|
577
|
+
const existing = state.active.databases.libraries[libraryId]
|
|
578
|
+
if (existing) { db = resolveVectorPath(existing.path); return null }
|
|
579
|
+
const indexFile = path.join(indexDir(), libraryId + '.json')
|
|
580
|
+
return withFileLock(indexFile, () => {
|
|
581
|
+
let index
|
|
582
|
+
if (loadIndex) index = loadIndex(libraryId)
|
|
583
|
+
else {
|
|
584
|
+
const result = readJsonStrict(indexFile)
|
|
585
|
+
if (result.ok) index = result.value
|
|
586
|
+
else if (result.missing && !fs.readdirSync(indexDir()).some(name => name.startsWith(libraryId + '.json.corrupt-'))) index = { entries: {} }
|
|
587
|
+
else throw vectorError('VECTOR_LIBRARY_REBUILD_REQUIRED', 'new library index cannot be proven empty; rebuild the vector generation', { libraryId })
|
|
588
|
+
}
|
|
589
|
+
if (index && typeof index.then === 'function') throw vectorError(VECTOR_ERROR_CODES.BAD_UPDATE, 'enrollActiveLibrary loadIndex must be synchronous')
|
|
590
|
+
if (!index || typeof index !== 'object' || !index.entries || typeof index.entries !== 'object' || Array.isArray(index.entries) || Object.keys(index.entries).length) throw vectorError('VECTOR_LIBRARY_REBUILD_REQUIRED', 'a nonempty or invalid library index requires shadow rebuilding', { libraryId })
|
|
591
|
+
const rel = vectorDbRelPath({ libraryId, fingerprint: state.active.fingerprint, generation: state.active.generation })
|
|
592
|
+
db = resolveVectorPath(rel)
|
|
593
|
+
ensureDir(path.dirname(db))
|
|
594
|
+
try { fs.writeFileSync(db, '', { flag: 'wx' }) } catch (error) {
|
|
595
|
+
throw vectorError(VECTOR_ERROR_CODES.SHADOW_PATH_TAKEN, 'new library vector path already exists or cannot be created; refusing to reuse it', { libraryId, causeCode: error?.code })
|
|
596
|
+
}
|
|
597
|
+
return { ...state, active: { ...state.active, databases: { ...state.active.databases, libraries: { ...state.active.databases.libraries, [libraryId]: { path: rel, bytes: 0, sha256: sha256Hex('') } } } } }
|
|
598
|
+
})
|
|
599
|
+
})
|
|
600
|
+
return db
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function profileDatabaseMap(libraryIds = [], profile = activeProfile()) {
|
|
604
|
+
const state = vectorState()
|
|
605
|
+
const active = state?.active
|
|
606
|
+
const out = {}
|
|
607
|
+
for (const id of libraryIds) {
|
|
608
|
+
if (active?.databases?.libraries?.[id]) out[id] = resolveVectorPath(active.databases.libraries[id].path)
|
|
609
|
+
else if (profile) out[id] = vectorDbAbsPath({ libraryId: id, fingerprint: profile.fingerprint, generation: (active?.generation ?? 0) + 1 })
|
|
610
|
+
else out[id] = legacyDbPath(id)
|
|
611
|
+
}
|
|
612
|
+
return out
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** memu 子进程需要的本地嵌入环境(不写 key 到磁盘,只在进程环境里传)。 */
|
|
616
|
+
export function profileEnv(profile, { baseUrl, token } = {}) {
|
|
617
|
+
if (!profile) return {}
|
|
618
|
+
const env = {
|
|
619
|
+
MEMU_MEMORY_MODE: 'local',
|
|
620
|
+
MEMU_EMBED_PROVIDER: 'openai',
|
|
621
|
+
MEMU_EMBED_MODEL: String(profile.catalogId || profile.model || ''),
|
|
622
|
+
MEMU_BASE_URL: String(baseUrl || '').replace(/\/$/, ''),
|
|
623
|
+
MEMU_TELEMETRY: '0',
|
|
624
|
+
DO_NOT_TRACK: '1',
|
|
625
|
+
MEMU_EVENTS_BASE_URL: '',
|
|
626
|
+
}
|
|
627
|
+
if (token) env.MEMU_API_KEY = String(token)
|
|
628
|
+
return env
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ---------------------------------------------------------------- 代际与 pending journal
|
|
632
|
+
|
|
633
|
+
export function nextGeneration(state = null) {
|
|
634
|
+
const current = state ?? vectorState()
|
|
635
|
+
const active = Number.isSafeInteger(current?.active?.generation) ? current.active.generation : 0
|
|
636
|
+
const pending = Number.isSafeInteger(current?.pending?.generation) ? current.pending.generation : 0
|
|
637
|
+
const counter = Number.isSafeInteger(current?.generation) ? current.generation : 0
|
|
638
|
+
return Math.max(active, pending, counter) + 1
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export function buildDatabaseSet({ profile, generation, libraryIds = [] }) {
|
|
642
|
+
const databases = {
|
|
643
|
+
global: { path: vectorDbRelPath({ libraryId: null, fingerprint: profile.fingerprint, generation }), bytes: null, sha256: null },
|
|
644
|
+
libraries: {},
|
|
645
|
+
}
|
|
646
|
+
for (const libraryId of libraryIds) {
|
|
647
|
+
if (!isSafeId(libraryId)) throw vectorError(VECTOR_ERROR_CODES.PATH_ESCAPE, 'unsafe library id: ' + JSON.stringify(libraryId), { libraryId })
|
|
648
|
+
databases.libraries[libraryId] = { path: vectorDbRelPath({ libraryId, fingerprint: profile.fingerprint, generation }), bytes: null, sha256: null }
|
|
649
|
+
}
|
|
650
|
+
return databases
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* 为一次影子重建分配全新代际,并把 prepared journal 先持久化。
|
|
655
|
+
* 这一步之后 DB 路径才存在,所以"每次重建都是全新路径",绝不复用上一轮的影子库
|
|
656
|
+
* (避免把一次 recommit 当成一次 reembedding,§14.8 第 2/4 条)。
|
|
657
|
+
*/
|
|
658
|
+
export function reserveShadowGeneration({ profile, libraryIds = [], reason = 'switch' } = {}) {
|
|
659
|
+
const verified = assertProfileVerified(profile)
|
|
660
|
+
const at = stamp()
|
|
661
|
+
let reserved = null
|
|
662
|
+
const state = updateVectorStateSync(current => {
|
|
663
|
+
const generation = nextGeneration(current)
|
|
664
|
+
const databases = buildDatabaseSet({ profile: verified, generation, libraryIds })
|
|
665
|
+
const token = sha256Hex(stableStringify({ generation, fingerprint: verified.fingerprint, previousGeneration: current.active?.generation ?? null, at, reason }))
|
|
666
|
+
reserved = { generation, token, databases, at }
|
|
667
|
+
return {
|
|
668
|
+
...current,
|
|
669
|
+
generation: Math.max(current.generation || 0, generation),
|
|
670
|
+
pending: {
|
|
671
|
+
status: 'preparing',
|
|
672
|
+
generation,
|
|
673
|
+
fingerprint: verified.fingerprint,
|
|
674
|
+
catalogId: verified.catalogId,
|
|
675
|
+
profile: verified,
|
|
676
|
+
previousGeneration: current.active?.generation ?? null,
|
|
677
|
+
previousFingerprint: current.active?.fingerprint ?? null,
|
|
678
|
+
databases,
|
|
679
|
+
reason,
|
|
680
|
+
token,
|
|
681
|
+
at,
|
|
682
|
+
updatedAt: at,
|
|
683
|
+
error: null,
|
|
684
|
+
report: null,
|
|
685
|
+
},
|
|
686
|
+
}
|
|
687
|
+
})
|
|
688
|
+
return { ...reserved, state }
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** pending journal 的读取与更新(都在同一把同步锁里)。 */
|
|
692
|
+
export function pendingJournal() {
|
|
693
|
+
const state = vectorState()
|
|
694
|
+
return state?.pending ?? null
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export function updatePendingJournal(mutator) {
|
|
698
|
+
let result = null
|
|
699
|
+
updateVectorStateSync(state => {
|
|
700
|
+
if (!state.pending) return null
|
|
701
|
+
const next = typeof mutator === 'function' ? mutator(state.pending) : { ...state.pending, ...mutator }
|
|
702
|
+
if (!next) return null
|
|
703
|
+
result = { ...next, updatedAt: stamp() }
|
|
704
|
+
return { ...state, pending: result }
|
|
705
|
+
})
|
|
706
|
+
return result
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export function clearPendingJournal() {
|
|
710
|
+
const next = updateVectorStateSync(state => (state.pending ? { ...state, pending: null } : null))
|
|
711
|
+
return next.pending ?? null
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ---------------------------------------------------------------- 写入护栏
|
|
715
|
+
|
|
716
|
+
function databasePaths(databases) {
|
|
717
|
+
const paths = [resolveVectorPath(databases.global.path)]
|
|
718
|
+
for (const entry of Object.values(databases.libraries || {})) paths.push(resolveVectorPath(entry?.path))
|
|
719
|
+
return paths
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* 护栏核心:此刻是否允许读/写向量。
|
|
724
|
+
* deep=true 时额外核对文件大小(启动恢复/切换前用;运行期不查,因为 active 库会正常增长)。
|
|
725
|
+
*/
|
|
726
|
+
export function assertVectorWriteAllowed({ deep = false, readOnly = false } = {}) {
|
|
727
|
+
const state = readVectorState({ tolerateMissing: false }).state
|
|
728
|
+
if (!readOnly && liveGuard(state.writeGuard).frozen) throw vectorError('VECTOR_WRITE_FROZEN', 'vector writes are paused for the final generation switch; retry shortly')
|
|
729
|
+
const active = state.active
|
|
730
|
+
if (!active) throw vectorError(VECTOR_ERROR_CODES.NOT_ACTIVE, 'no active vector generation; use lexical recall')
|
|
731
|
+
if (!validActive(active)) throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'the active vector manifest is incomplete; refusing vectors')
|
|
732
|
+
if (state.pending?.status === 'committed' && state.pending.generation !== active.generation) {
|
|
733
|
+
throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'the commit journal and the active pointer disagree about the generation; refusing vectors', {
|
|
734
|
+
pendingGeneration: state.pending.generation, activeGeneration: active.generation,
|
|
735
|
+
})
|
|
736
|
+
}
|
|
737
|
+
if (state.pending?.status === 'preparing' && state.pending.previousGeneration === null && !state.active) {
|
|
738
|
+
throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'a build was reserved before any generation became active; refusing vectors')
|
|
739
|
+
}
|
|
740
|
+
// 未验证/未知/pending 的 manifest 一律拒绝,绝不拿旧向量空间继续算。
|
|
741
|
+
assertProfileVerified(active.profile)
|
|
742
|
+
let paths
|
|
743
|
+
try {
|
|
744
|
+
paths = databasePaths(active.databases)
|
|
745
|
+
} catch (error) {
|
|
746
|
+
if (isVectorStateError(error)) throw error
|
|
747
|
+
throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'cannot resolve the active database set: ' + (error?.message || error))
|
|
748
|
+
}
|
|
749
|
+
for (let index = 0; index < paths.length; index++) {
|
|
750
|
+
let info
|
|
751
|
+
try { info = fs.statSync(paths[index]) } catch {
|
|
752
|
+
throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'a database in the active generation is missing: ' + path.basename(paths[index]), { missing: paths[index] })
|
|
753
|
+
}
|
|
754
|
+
if (!info.isFile()) {
|
|
755
|
+
throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'a database in the active generation is not a file: ' + path.basename(paths[index]), { path: paths[index] })
|
|
756
|
+
}
|
|
757
|
+
const recorded = index === 0 ? active.databases.global.bytes : Object.values(active.databases.libraries || {})[index - 1]?.bytes
|
|
758
|
+
if (deep && Number.isSafeInteger(recorded) && recorded > 0 && info.size < recorded) {
|
|
759
|
+
throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'a database in the active generation shrank below its committed size: ' + path.basename(paths[index]), {
|
|
760
|
+
path: paths[index], expected: recorded, actual: info.size,
|
|
761
|
+
})
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return {
|
|
765
|
+
generation: active.generation,
|
|
766
|
+
fingerprint: active.fingerprint,
|
|
767
|
+
profile: active.profile,
|
|
768
|
+
databases: databaseMapFor(active),
|
|
769
|
+
snapshot: active.snapshot ?? null,
|
|
770
|
+
pending: state.pending ? { status: state.pending.status, generation: state.pending.generation, fingerprint: state.pending.fingerprint } : null,
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** 回合开始捕获单代(§14.8 第 7 条);失败返回 null,调用方退词法。 */
|
|
775
|
+
export function captureGeneration() {
|
|
776
|
+
const allowed = assertVectorWriteAllowed({ readOnly: true })
|
|
777
|
+
return Object.freeze({
|
|
778
|
+
token: 'gen:' + allowed.generation + ':' + allowed.fingerprint.slice(0, 8),
|
|
779
|
+
generation: allowed.generation,
|
|
780
|
+
fingerprint: allowed.fingerprint,
|
|
781
|
+
profile: allowed.profile,
|
|
782
|
+
global: allowed.databases.global,
|
|
783
|
+
libraries: Object.freeze({ ...allowed.databases.libraries }),
|
|
784
|
+
snapshot: allowed.snapshot,
|
|
785
|
+
capturedAt: stamp(),
|
|
786
|
+
})
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export function tryCaptureGeneration() {
|
|
790
|
+
try { return captureGeneration() } catch { return null }
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* 投放前重验:代际/指纹/路径必须与捕获时完全一致,否则抛 STALE_GENERATION
|
|
795
|
+
* (in-flight 的旧代结果不得计入新代成功)。
|
|
796
|
+
*/
|
|
797
|
+
export function assertGeneration(captured) {
|
|
798
|
+
if (!captured || typeof captured !== 'object') {
|
|
799
|
+
throw vectorError(VECTOR_ERROR_CODES.NO_GENERATION, 'no captured vector generation for this turn; use lexical recall')
|
|
800
|
+
}
|
|
801
|
+
const allowed = assertVectorWriteAllowed({ readOnly: true })
|
|
802
|
+
if (allowed.generation !== captured.generation || allowed.fingerprint !== captured.fingerprint) {
|
|
803
|
+
throw vectorError(VECTOR_ERROR_CODES.STALE_GENERATION, 'the active vector generation changed during the turn; discard this result', {
|
|
804
|
+
expected: { generation: captured.generation, fingerprint: captured.fingerprint },
|
|
805
|
+
actual: { generation: allowed.generation, fingerprint: allowed.fingerprint },
|
|
806
|
+
})
|
|
807
|
+
}
|
|
808
|
+
const currentGlobal = allowed.databases.global
|
|
809
|
+
const capturedGlobal = captured.global
|
|
810
|
+
if (capturedGlobal && currentGlobal !== capturedGlobal) {
|
|
811
|
+
throw vectorError(VECTOR_ERROR_CODES.STALE_GENERATION, 'the active database set changed during the turn', { expected: capturedGlobal, actual: currentGlobal })
|
|
812
|
+
}
|
|
813
|
+
for (const [id, db] of Object.entries(captured.libraries || {})) {
|
|
814
|
+
if (db !== allowed.databases.libraries[id]) throw vectorError(VECTOR_ERROR_CODES.STALE_GENERATION, 'a captured library path no longer belongs to the active generation', { libraryId: id })
|
|
815
|
+
}
|
|
816
|
+
const currentIds = Object.keys(allowed.databases.libraries).sort().join(',')
|
|
817
|
+
const capturedIds = Object.keys(captured.libraries || {}).sort().join(',')
|
|
818
|
+
if (currentIds !== capturedIds) {
|
|
819
|
+
throw vectorError(VECTOR_ERROR_CODES.STALE_GENERATION, 'the active library set changed during the turn', { expected: capturedIds, actual: currentIds })
|
|
820
|
+
}
|
|
821
|
+
return allowed
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Capture/revalidate an explicit shadow operation; never accept an arbitrary profile/DB. */
|
|
825
|
+
export function assertMigrationProfile({ profile, db, generation, token } = {}) {
|
|
826
|
+
assertProfileVerified(profile)
|
|
827
|
+
const state = vectorState()
|
|
828
|
+
const pending = state?.pending
|
|
829
|
+
const stale = () => vectorError(VECTOR_ERROR_CODES.STALE_GENERATION, 'the migration profile or database no longer belongs to the pending generation; discard this result')
|
|
830
|
+
if (!pending || pending.status === 'committed' || pending.fingerprint !== profile.fingerprint || (generation !== undefined && pending.generation !== generation) || (token !== undefined && pending.token !== token)) throw stale()
|
|
831
|
+
if ((pending.previousGeneration ?? null) !== (state.active?.generation ?? null) || (pending.previousFingerprint ?? null) !== (state.active?.fingerprint ?? null)) throw stale()
|
|
832
|
+
const paths = databasePaths(pending.databases)
|
|
833
|
+
if (typeof db !== 'string' || !paths.includes(path.resolve(db))) throw stale()
|
|
834
|
+
return Object.freeze({ profile, db: path.resolve(db), generation: pending.generation, token: pending.token })
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** 只读状态汇总,供 RPC/UI 用;不抛错(损坏时给 status: 'torn')。 */
|
|
838
|
+
export function vectorStatus() {
|
|
839
|
+
let state
|
|
840
|
+
try {
|
|
841
|
+
state = vectorState()
|
|
842
|
+
} catch (error) {
|
|
843
|
+
return { status: 'torn', error: { code: error.code || 'VECTOR_UNKNOWN', message: String(error?.message || error) }, requested: null, active: null, pending: null }
|
|
844
|
+
}
|
|
845
|
+
if (!state) return { status: 'absent', requested: null, active: null, pending: null }
|
|
846
|
+
// 旧版本在切换成功后会留下 requested = { status: 'active' }。那不是"待切换的另一个
|
|
847
|
+
// 模型",读的时候按没有请求处理,界面才不会同时显示"当前生效 + 待切换"。
|
|
848
|
+
const requestedState = state.requested && typeof state.requested === 'object' && state.requested.status !== 'active'
|
|
849
|
+
? state.requested : null
|
|
850
|
+
let status = 'absent'
|
|
851
|
+
if (state.active) {
|
|
852
|
+
try {
|
|
853
|
+
assertVectorWriteAllowed({ readOnly: true })
|
|
854
|
+
status = 'active'
|
|
855
|
+
} catch (error) {
|
|
856
|
+
return {
|
|
857
|
+
status: 'torn',
|
|
858
|
+
error: { code: error.code || 'VECTOR_UNKNOWN', message: String(error?.message || error) },
|
|
859
|
+
requested: requestedState ? { catalogId: requestedState.catalogId, fingerprint: requestedState.fingerprint } : null,
|
|
860
|
+
active: { generation: state.active.generation, fingerprint: state.active.fingerprint, catalogId: state.active.profile?.catalogId ?? null },
|
|
861
|
+
pending: state.pending ? { status: state.pending.status, generation: state.pending.generation, fingerprint: state.pending.fingerprint } : null,
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
status,
|
|
867
|
+
requested: requestedState ? { catalogId: requestedState.catalogId, fingerprint: requestedState.fingerprint, at: requestedState.at ?? null } : null,
|
|
868
|
+
active: state.active ? {
|
|
869
|
+
generation: state.active.generation,
|
|
870
|
+
fingerprint: state.active.fingerprint,
|
|
871
|
+
catalogId: state.active.profile?.catalogId ?? null,
|
|
872
|
+
profile: state.active.profile,
|
|
873
|
+
databases: activeDatabases(),
|
|
874
|
+
previous: state.active.previous ?? null,
|
|
875
|
+
lastMigration: state.active.lastMigration ?? null,
|
|
876
|
+
updatedAt: state.active.updatedAt ?? null,
|
|
877
|
+
} : null,
|
|
878
|
+
pending: state.pending ? {
|
|
879
|
+
status: state.pending.status,
|
|
880
|
+
generation: state.pending.generation,
|
|
881
|
+
fingerprint: state.pending.fingerprint,
|
|
882
|
+
previousGeneration: state.pending.previousGeneration ?? null,
|
|
883
|
+
report: state.pending.report ?? null,
|
|
884
|
+
error: state.pending.error ?? null,
|
|
885
|
+
} : null,
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Leases are persisted under the same short synchronous manifest lock. No file
|
|
890
|
+
// lock spans the backend await; a final freeze fails fast while a writer runs.
|
|
891
|
+
function ownerAlive(owner) {
|
|
892
|
+
if (!owner || !Number.isSafeInteger(owner.pid) || owner.pid < 1) return false
|
|
893
|
+
try { process.kill(owner.pid, 0); return true } catch (error) { return error?.code !== 'ESRCH' }
|
|
894
|
+
}
|
|
895
|
+
function liveGuard(guard = {}) {
|
|
896
|
+
return {
|
|
897
|
+
frozen: ownerAlive(guard.frozen) ? guard.frozen : null,
|
|
898
|
+
leases: Object.fromEntries(Object.entries(guard.leases || {}).filter(([, lease]) => ownerAlive(lease))),
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
export async function withVectorWrite(action) {
|
|
902
|
+
if (typeof action !== 'function') throw new TypeError('withVectorWrite requires an action')
|
|
903
|
+
const token = crypto.randomUUID()
|
|
904
|
+
let generation = null
|
|
905
|
+
updateVectorStateSync(state => {
|
|
906
|
+
const guard = liveGuard(state.writeGuard)
|
|
907
|
+
if (guard.frozen) throw vectorError('VECTOR_WRITE_FROZEN', 'vector writes are paused for a generation switch; retry shortly')
|
|
908
|
+
if (state.active) generation = assertVectorWriteAllowed()
|
|
909
|
+
guard.leases[token] = { pid: process.pid, at: stamp() }
|
|
910
|
+
return { ...state, writeGuard: guard }
|
|
911
|
+
})
|
|
912
|
+
try { return await action(generation) } finally {
|
|
913
|
+
updateVectorStateSync(state => {
|
|
914
|
+
const guard = liveGuard(state.writeGuard)
|
|
915
|
+
delete guard.leases[token]
|
|
916
|
+
return { ...state, writeGuard: guard }
|
|
917
|
+
})
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
export async function withVectorWriteFreeze(action) {
|
|
921
|
+
if (typeof action !== 'function') throw new TypeError('withVectorWriteFreeze requires an action')
|
|
922
|
+
const token = crypto.randomUUID()
|
|
923
|
+
updateVectorStateSync(state => {
|
|
924
|
+
const guard = liveGuard(state.writeGuard)
|
|
925
|
+
if (guard.frozen || Object.keys(guard.leases).length) throw vectorError('VECTOR_WRITE_BUSY', 'an in-flight vector write prevents switching; retry shortly')
|
|
926
|
+
return { ...state, writeGuard: { ...guard, frozen: { pid: process.pid, token, at: stamp() } } }
|
|
927
|
+
})
|
|
928
|
+
try { return await action() } finally {
|
|
929
|
+
updateVectorStateSync(state => {
|
|
930
|
+
const guard = liveGuard(state.writeGuard)
|
|
931
|
+
if (guard.frozen?.token === token) guard.frozen = null
|
|
932
|
+
return { ...state, writeGuard: guard }
|
|
933
|
+
})
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
export const activeVectorState = vectorState
|
|
938
|
+
export const memuDbFor = dbPathFor
|
|
939
|
+
export const memuGlobalDbFor = () => dbPathFor(null)
|
|
940
|
+
export const captureVectorGeneration = captureGeneration
|
|
941
|
+
export const assertVectorGeneration = assertGeneration
|