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.
Files changed (49) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +21 -5
  3. package/README.zh.md +21 -5
  4. package/docs/contributing.md +5 -3
  5. package/docs/env-and-commands.md +13 -0
  6. package/docs/host-api.md +35 -3
  7. package/docs/memory-semantics.md +32 -5
  8. package/docs/troubleshooting.md +36 -7
  9. package/lib/activity-log.js +190 -0
  10. package/lib/backup.js +1103 -0
  11. package/lib/binding-diagnostics.js +630 -0
  12. package/lib/client.js +697 -49
  13. package/lib/curator.js +81 -37
  14. package/lib/cycle.js +3 -0
  15. package/lib/embedding-catalog.js +763 -0
  16. package/lib/embedding-download.js +407 -0
  17. package/lib/embedding-runtime.js +710 -0
  18. package/lib/engine.js +2 -2
  19. package/lib/index.js +36 -0
  20. package/lib/inject.js +131 -30
  21. package/lib/local-embedding-entry.js +145 -0
  22. package/lib/local-embedding-manager.js +249 -0
  23. package/lib/local-embedding-protocol.js +173 -0
  24. package/lib/local-embedding-worker.js +652 -0
  25. package/lib/local-vector-service.js +283 -0
  26. package/lib/memory.js +385 -45
  27. package/lib/memu-config.js +10 -22
  28. package/lib/memu.js +53 -19
  29. package/lib/paths.js +24 -0
  30. package/lib/private.js +139 -13
  31. package/lib/rpc.js +45 -4
  32. package/lib/state.js +109 -161
  33. package/lib/tools.js +9 -5
  34. package/lib/vector-migration.js +878 -0
  35. package/lib/vector-profile.js +941 -0
  36. package/package.json +3 -2
  37. package/scripts/acceptance-local.mjs +5 -4
  38. package/scripts/chain-matrix.mjs +527 -0
  39. package/scripts/check-all.mjs +19 -0
  40. package/scripts/check-doc-drift.mjs +353 -0
  41. package/scripts/diagnose-bindings.mjs +176 -0
  42. package/scripts/local-runtime-install.mjs +142 -0
  43. package/scripts/local-runtime-verify.mjs +56 -0
  44. package/scripts/local-vector-verify.mjs +93 -0
  45. package/scripts/recall-bench.mjs +813 -0
  46. package/scripts/repetition-ab-scenarios.mjs +93 -0
  47. package/scripts/repetition-detector-snapshot.mjs +314 -0
  48. package/scripts/repetition-eval-live.mjs +360 -0
  49. package/scripts/repetition-eval.mjs +702 -0
@@ -0,0 +1,878 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import { indexDir, librariesDir, ensureDir, readJsonStrict, writeJson, stamp } from './paths.js'
5
+ import {
6
+ VECTOR_ERROR_CODES, vectorError,
7
+ stableStringify, sha256Hex, resolveProfile, assertProfileVerified, vectorDbRelPath,
8
+ resolveVectorPath, readVectorState, vectorState, activeDatabases, updateVectorStateSync,
9
+ reserveShadowGeneration, updatePendingJournal, clearPendingJournal, pendingJournal,
10
+ assertVectorWriteAllowed, captureGeneration, assertGeneration, vectorStatus, withVectorWriteFreeze, profileEnv, setRequestedProfile,
11
+ } from './vector-profile.js'
12
+
13
+ // §14.8 影子重建 / 追平 / 原子代际切换 / 回滚 / 启动恢复。
14
+ //
15
+ // 原则:
16
+ // * 绝不原地重嵌入:每次都写到一个全新的影子库路径(代际号进文件名),旧库保持只读可用。
17
+ // * 账本键 = profile fingerprint + libraryId + itemId + content hash。命中即跳过,
18
+ // 所以"再来一次 commit"不是"再来一次 embed";内容变了才算重新嵌入。
19
+ // * tombstone:从索引里消失的条目要真的从影子库里删掉并记账,断点续建不得把它复活。
20
+ // * 切换只在最后短窗口里做同步 compare + 原子写 active 指针;之前的锁外工作可以失败,
21
+ // 失败就不动 active(§14.8 第 4/7/8 条)。
22
+ // * 索引快照在追平前后必须一致;不一致说明追平已经过期,中止切换而不是切到半步状态。
23
+ //
24
+ // 依赖全部注入,便于 fixture 测试:
25
+ // listLibraries() → [{ id }] | string[]
26
+ // loadIndex(libraryId | null) → { entries: { [itemId]: entry } } (null = 全局库)
27
+ // commit({ db, libraryId, item, entry, profile, generation }) → Promise
28
+ // forget({ db, libraryId, itemId, profile, generation }) → Promise
29
+ // listFiles({ db, libraryId, profile, generation }) → Promise<string[]> (影子里已有的条目名/ id)
30
+ // smoke({ profile, generation, databases, report }) → Promise<{ ok, checks? }>
31
+ // 主代理接 state.js/memu.js 时按上面的形状包一层即可。
32
+
33
+ export const VECTOR_LEDGER_PREFIX = 'vector-ledger.'
34
+ export const VECTOR_LEDGER_SCHEMA = 1
35
+
36
+ /** 这些状态不进入向量空间:重建不得把 held 的条目重新激活(§14.8 第 3 条)。 */
37
+ export const HELD_STATUSES = Object.freeze(['archived', 'suppressed', 'expired', 'pending_review', 'superseded', 'deleted'])
38
+
39
+ export function admitEntry(entry, nowMs = Date.now()) {
40
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false
41
+ if (HELD_STATUSES.includes(String(entry.status || ''))) return false
42
+ if (entry.expiresAt && !(Date.parse(entry.expiresAt) > nowMs)) return false
43
+ return true
44
+ }
45
+
46
+ export function itemText(entry) {
47
+ if (!entry || typeof entry !== 'object') return ''
48
+ for (const key of ['text', 'content', 'body']) {
49
+ if (typeof entry[key] === 'string' && entry[key].trim()) return entry[key]
50
+ }
51
+ return ''
52
+ }
53
+
54
+ /** 内容/版本哈希:只有正文(与 id)改变才算需要重新嵌入。 */
55
+ export function contentHash(id, text, version = null) {
56
+ return sha256Hex(stableStringify({ id: String(id), text: String(text), version }))
57
+ }
58
+
59
+ /** 从插件 index 里取出"可写进向量空间"的条目(已按准入过滤,按 id 稳定排序)。 */
60
+ export function admittedItems(index, nowMs = Date.now()) {
61
+ const entries = index && typeof index.entries === 'object' && index.entries ? index.entries : {}
62
+ const items = []
63
+ for (const [id, entry] of Object.entries(entries)) {
64
+ if (!admitEntry(entry, nowMs)) continue
65
+ const text = itemText(entry)
66
+ if (!text.trim()) continue
67
+ items.push({ id, text, hash: contentHash(id, text, entry.contentVersion ?? entry.version ?? entry.updatedAt ?? null), entry: structuredClone(entry) })
68
+ }
69
+ items.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
70
+ return items
71
+ }
72
+
73
+ function scopeSnapshotDigest(items) {
74
+ return sha256Hex(stableStringify(items.map(item => [item.id, item.hash])))
75
+ }
76
+
77
+ export function ledgerFile(fingerprint, generation) {
78
+ return path.join(indexDir(), VECTOR_LEDGER_PREFIX + String(fingerprint).slice(0, 8) + '.g' + Number(generation) + '.json')
79
+ }
80
+
81
+ function emptyLedger(fingerprint, generation) {
82
+ return {
83
+ version: VECTOR_LEDGER_SCHEMA,
84
+ fingerprint,
85
+ generation,
86
+ createdAt: stamp(),
87
+ updatedAt: stamp(),
88
+ snapshot: null,
89
+ libraries: {},
90
+ }
91
+ }
92
+
93
+ export function loadLedger(fingerprint, generation) {
94
+ const file = ledgerFile(fingerprint, generation)
95
+ const result = readJsonStrict(file)
96
+ if (!result.ok) {
97
+ if (result.missing) return { ok: true, ledger: emptyLedger(fingerprint, generation) }
98
+ return { ok: false, ledger: emptyLedger(fingerprint, generation), error: { code: result.kind === 'corrupt' ? 'VECTOR_LEDGER_CORRUPT' : 'VECTOR_LEDGER_UNREADABLE', file } }
99
+ }
100
+ const value = result.value
101
+ if (!value || typeof value !== 'object' || value.fingerprint !== fingerprint || Number(value.generation) !== Number(generation)) {
102
+ // 账本对不上代际/指纹:当作空账本重建(宁可贵一点,也不复用别人的进度)
103
+ return { ok: true, ledger: emptyLedger(fingerprint, generation), restarted: true }
104
+ }
105
+ return {
106
+ ok: true,
107
+ ledger: {
108
+ version: VECTOR_LEDGER_SCHEMA,
109
+ fingerprint,
110
+ generation: Number(generation),
111
+ createdAt: value.createdAt || stamp(),
112
+ updatedAt: value.updatedAt || stamp(),
113
+ snapshot: value.snapshot || null,
114
+ libraries: value.libraries && typeof value.libraries === 'object' ? value.libraries : {},
115
+ },
116
+ }
117
+ }
118
+
119
+ export function saveLedger(ledger) {
120
+ ensureDir(indexDir())
121
+ writeJson(ledgerFile(ledger.fingerprint, ledger.generation), ledger)
122
+ return ledger
123
+ }
124
+
125
+ function ledgerBucket(ledger, key) {
126
+ const existing = ledger.libraries[key]
127
+ if (existing && typeof existing === 'object') {
128
+ return ledger.libraries[key] = {
129
+ items: existing.items && typeof existing.items === 'object' ? existing.items : {},
130
+ tombstones: existing.tombstones && typeof existing.tombstones === 'object' ? existing.tombstones : {},
131
+ snapshot: existing.snapshot || null,
132
+ }
133
+ }
134
+ const bucket = { items: {}, tombstones: {}, snapshot: null }
135
+ ledger.libraries[key] = bucket
136
+ return bucket
137
+ }
138
+
139
+ export function ledgerSummary(ledger) {
140
+ const out = { fingerprint: ledger.fingerprint, generation: ledger.generation, libraries: {}, committed: 0, tombstones: 0 }
141
+ for (const [key, bucket] of Object.entries(ledger.libraries || {})) {
142
+ const committed = Object.keys(bucket?.items || {}).length
143
+ const tombstones = Object.keys(bucket?.tombstones || {}).length
144
+ out.libraries[key] = { committed, tombstones }
145
+ out.committed += committed
146
+ out.tombstones += tombstones
147
+ }
148
+ out.snapshot = ledger.snapshot
149
+ return out
150
+ }
151
+
152
+ function fileStats(absPath) {
153
+ const buffer = fs.readFileSync(absPath)
154
+ return { bytes: buffer.length, sha256: crypto.createHash('sha256').update(buffer).digest('hex') }
155
+ }
156
+
157
+ // ---------------------------------------------------------------- 迁移器
158
+
159
+ const REQUIRED_DEPS = Object.freeze(['listLibraries', 'loadIndex', 'commit', 'forget', 'listFiles'])
160
+
161
+ /**
162
+ * createVectorMigrator({ listLibraries, loadIndex, commit, forget, listFiles, smoke?, now?, log?, maxCatchUpAttempts?, itemRetries? })
163
+ * 也接受 { deps: {...}, ... } 形式。所有副作用都经注入依赖,便于纯 fixture 测试。
164
+ */
165
+ export function createVectorMigrator(options = {}) {
166
+ const configured = options && typeof options.deps === 'object' && options.deps ? { ...options.deps, ...options } : { ...options }
167
+ const impl = {
168
+ listLibraries: configured.listLibraries,
169
+ loadIndex: configured.loadIndex,
170
+ commit: configured.commit,
171
+ forget: configured.forget,
172
+ listFiles: configured.listFiles ?? configured.list,
173
+ retrieve: typeof configured.retrieve === 'function' ? configured.retrieve : null,
174
+ smoke: typeof configured.smoke === 'function' ? configured.smoke : null,
175
+ }
176
+ for (const name of REQUIRED_DEPS) {
177
+ if (typeof impl[name] !== 'function') throw new TypeError('createVectorMigrator requires a ' + name + '(… ) dependency')
178
+ }
179
+ const now = typeof configured.now === 'function' ? configured.now : () => new Date().toISOString()
180
+ const log = typeof configured.log === 'function' ? configured.log : () => {}
181
+ const onProgress = typeof configured.onProgress === 'function' ? configured.onProgress : null
182
+ const maxCatchUpAttempts = Number.isSafeInteger(configured.maxCatchUpAttempts) && configured.maxCatchUpAttempts > 0 ? configured.maxCatchUpAttempts : 3
183
+ const itemRetries = Number.isSafeInteger(configured.itemRetries) && configured.itemRetries >= 0 ? configured.itemRetries : 1
184
+
185
+ function publishProgress(progress, { persist = true } = {}) {
186
+ const snapshot = { ...progress, updatedAt: now() }
187
+ if (persist) {
188
+ updatePendingJournal(current => {
189
+ if (current.generation !== snapshot.generation || current.fingerprint !== snapshot.fingerprint) throw vectorError(VECTOR_ERROR_CODES.STALE_GENERATION, 'progress belongs to a replaced shadow generation')
190
+ return { ...current, report: { ...(current.report || {}), progress: snapshot } }
191
+ })
192
+ }
193
+ if (onProgress) {
194
+ try {
195
+ const result = onProgress(structuredClone(snapshot))
196
+ if (result && typeof result.then === 'function') result.catch(error => log('progress callback failed: ' + (error?.message || error)))
197
+ } catch (error) {
198
+ log('progress callback failed: ' + (error?.message || error))
199
+ }
200
+ }
201
+ return snapshot
202
+ }
203
+
204
+ function idOf(value) {
205
+ const id = typeof value === 'string' ? value : value?.id
206
+ if (typeof id !== 'string' || !id || id === 'global') return null
207
+ return id
208
+ }
209
+
210
+ async function resolveLibraryIds(libraries) {
211
+ const raw = Array.isArray(libraries) ? libraries : await impl.listLibraries()
212
+ const ids = []
213
+ for (const item of raw ?? []) {
214
+ const id = idOf(item)
215
+ if (id && !ids.includes(id)) ids.push(id)
216
+ }
217
+ return ids
218
+ }
219
+
220
+ function scopesFor(databases) {
221
+ const scopes = [{ key: 'global', libraryId: null, rel: databases.global.path, db: resolveVectorPath(databases.global.path) }]
222
+ for (const [libraryId, entry] of Object.entries(databases.libraries || {})) {
223
+ scopes.push({ key: libraryId, libraryId, rel: entry.path, db: resolveVectorPath(entry.path) })
224
+ }
225
+ return scopes
226
+ }
227
+
228
+ async function captureSnapshot(scopes) {
229
+ const parts = []
230
+ for (const scope of scopes) {
231
+ const index = structuredClone(await impl.loadIndex(scope.libraryId))
232
+ const items = admittedItems(index)
233
+ parts.push({ key: scope.key, items, digest: sha256Hex(stableStringify({ index, admitted: scopeSnapshotDigest(items) })) })
234
+ }
235
+ return {
236
+ digest: sha256Hex(stableStringify(parts.map(part => [part.key, part.digest]))),
237
+ scopes: parts,
238
+ at: now(),
239
+ }
240
+ }
241
+
242
+ async function presentIds(scope, profile, generation) {
243
+ const ids = new Set()
244
+ if (!fs.existsSync(scope.db)) return ids
245
+ const listed = await impl.listFiles({ db: scope.db, libraryId: scope.libraryId, profile, generation })
246
+ if (!Array.isArray(listed) && !Array.isArray(listed?.recall_files)) throw vectorError('VECTOR_LIST_INVALID', 'shadow list returned an invalid result for ' + scope.key)
247
+ const names = Array.isArray(listed) ? listed : Array.isArray(listed?.recall_files) ? listed.recall_files : []
248
+ for (const value of names) {
249
+ if (typeof value === 'string') ids.add(value)
250
+ else if (value && typeof value === 'object') {
251
+ const name = value.name ?? value.id
252
+ if (typeof name === 'string') ids.add(name)
253
+ }
254
+ }
255
+ return ids
256
+ }
257
+
258
+ async function commitItem(scope, item, profile, generation) {
259
+ let lastError = null
260
+ for (let attempt = 0; attempt <= itemRetries; attempt++) {
261
+ try {
262
+ // Failure may still persist a row. Forget first: unchanged-description
263
+ // recommit is not proof that the backend regenerated an embedding.
264
+ if ((await presentIds(scope, profile, generation)).has(item.id)) {
265
+ await impl.forget({ db: scope.db, libraryId: scope.libraryId, itemId: item.id, profile, generation })
266
+ if ((await presentIds(scope, profile, generation)).has(item.id)) throw vectorError('VECTOR_FORGET_FAILED', 'shadow item still exists after forget: ' + item.id)
267
+ }
268
+ await impl.commit({ db: scope.db, libraryId: scope.libraryId, item, entry: item.entry ?? null, profile, generation })
269
+ if (!(await presentIds(scope, profile, generation)).has(item.id)) throw vectorError('VECTOR_READBACK_FAILED', 'committed item is missing from shadow: ' + item.id)
270
+ if (impl.retrieve) {
271
+ const result = await impl.retrieve({ db: scope.db, libraryId: scope.libraryId, itemId: item.id, item, profile, generation })
272
+ if (result == null || result === false || result.ok === false) throw vectorError('VECTOR_READBACK_FAILED', 'cannot retrieve committed shadow item: ' + item.id)
273
+ }
274
+ return { ok: true }
275
+ } catch (error) {
276
+ lastError = error
277
+ if (attempt < itemRetries) log('commit retry ' + scope.key + '/' + item.id + ' after ' + (error?.code || error?.message || error))
278
+ }
279
+ }
280
+ return { ok: false, error: lastError }
281
+ }
282
+
283
+ // -------------------------------------------------------------- prepareShadow
284
+
285
+ /**
286
+ * 为一次重建分配"全新代际 + 全新影子路径",并把 prepared journal 先落盘。
287
+ * 不写 active 指针、不碰旧库。同指纹的未完成重建默认续建(同一代际、同一路径、账本跳过
288
+ * 已嵌入条目);fresh:true 则强制换新代际(= 真正重来一遍)。
289
+ */
290
+ async function prepareShadow(stepOptions = {}) {
291
+ const profile = resolveProfile(stepOptions.profile ?? stepOptions.catalogId)
292
+ const libraryIds = await resolveLibraryIds(stepOptions.libraries)
293
+ const fresh = stepOptions.fresh === true
294
+ const reason = typeof stepOptions.reason === 'string' ? stepOptions.reason : 'switch'
295
+ const current = vectorState()
296
+ const pending = current?.pending
297
+ const resumable = !fresh && pending && pending.fingerprint === profile.fingerprint
298
+ && (pending.status === 'preparing' || pending.status === 'prepared' || pending.status === 'failed')
299
+ if (resumable) {
300
+ // 续建:同一代际,只把新出现的库补进同一个 DB 集合(同一次原子切换里生效)
301
+ const extended = updatePendingJournal(journal => {
302
+ const libraries = { ...(journal.databases?.libraries || {}) }
303
+ for (const id of libraryIds) {
304
+ if (!libraries[id]) libraries[id] = { path: vectorDbRelPath({ libraryId: id, fingerprint: journal.fingerprint, generation: journal.generation }), bytes: null, sha256: null }
305
+ }
306
+ return { ...journal, databases: { ...journal.databases, libraries } }
307
+ })
308
+ try {
309
+ ensureShadowFiles(extended.databases)
310
+ } catch (error) {
311
+ failPending(error?.code || 'VECTOR_PREPARE_FAILED', String(error?.message || error), {}, extended)
312
+ throw error
313
+ }
314
+ setRequestedProfile(profile, { reason })
315
+ log('resuming shadow generation ' + extended.generation + ' for ' + profile.catalogId)
316
+ return describeShadow(extended, true)
317
+ }
318
+ const reservation = reserveShadowGeneration({ profile, libraryIds, reason })
319
+ // 全新代际 = 全新空影子库。路径上已经有文件说明编号冲突,绝不覆盖/复用。
320
+ try {
321
+ ensureShadowFiles(reservation.databases, { refuseExisting: true })
322
+ } catch (error) {
323
+ failPending(error?.code || 'VECTOR_PREPARE_FAILED', String(error?.message || error), {}, reservation)
324
+ throw error
325
+ }
326
+ setRequestedProfile(profile, { reason })
327
+ log('prepared shadow generation ' + reservation.generation + ' for ' + profile.catalogId)
328
+ return describeShadow(pendingJournal(), false)
329
+ }
330
+
331
+ /**
332
+ * 影子库集合必须是"存在且为空"的:空 sqlite 文件是合法起点,缺文件会被误当成撕裂。
333
+ * refuseExisting=true 用于全新代际——路径上已有文件说明编号被复用,直接拒绝。
334
+ */
335
+ function ensureShadowFiles(databases, { refuseExisting = false } = {}) {
336
+ ensureDir(librariesDir())
337
+ for (const scope of scopesFor(databases)) {
338
+ ensureDir(path.dirname(scope.db))
339
+ if (fs.existsSync(scope.db)) {
340
+ if (refuseExisting) {
341
+ throw vectorError(VECTOR_ERROR_CODES.SHADOW_PATH_TAKEN, 'the reserved shadow path already exists; refusing to reuse a database from another attempt', {
342
+ path: scope.rel,
343
+ })
344
+ }
345
+ continue
346
+ }
347
+ fs.writeFileSync(scope.db, '', { flag: 'wx' })
348
+ }
349
+ }
350
+
351
+ function describeShadow(journal, resumed) {
352
+ const scopes = scopesFor(journal.databases)
353
+ return {
354
+ generation: journal.generation,
355
+ token: journal.token,
356
+ fingerprint: journal.fingerprint,
357
+ profile: journal.profile,
358
+ status: journal.status,
359
+ reason: journal.reason ?? null,
360
+ resumed: Boolean(resumed),
361
+ previousGeneration: journal.previousGeneration ?? null,
362
+ databases: {
363
+ global: scopes[0].db,
364
+ libraries: Object.fromEntries(scopes.slice(1).map(scope => [scope.key, scope.db])),
365
+ },
366
+ env: profileEnv(journal.profile),
367
+ relative: {
368
+ global: journal.databases.global.path,
369
+ libraries: Object.fromEntries(Object.entries(journal.databases.libraries || {}).map(([id, entry]) => [id, entry.path])),
370
+ },
371
+ }
372
+ }
373
+
374
+ // -------------------------------------------------------------- syncShadow
375
+
376
+ /**
377
+ * 把索引里所有准入条目写进影子库;命中账本的跳过(那已经是这一代的向量),
378
+ * 消失的条目 forget + tombstone。返回追平报告与前后快照(快照不一致 = 追平已过期)。
379
+ */
380
+ // 只报"有几条失败"没法修:把前几条的 id/错误码/消息留下来。syncShadow 与
381
+ // switchProfile 两处都要带,后者会覆盖前者的 pending.error。
382
+ function syncFailureDetail(report) {
383
+ return report.failed.slice(0, 10).map(item => ({
384
+ libraryId: item.libraryId ?? null,
385
+ itemId: item.itemId ?? null,
386
+ code: item.code ?? 'VECTOR_COMMIT_FAILED',
387
+ message: String(item.message ?? '').slice(0, 300),
388
+ }))
389
+ }
390
+
391
+ async function syncShadow(stepOptions = {}) {
392
+ const expected = pendingJournal()
393
+ try {
394
+ const report = await syncShadowImpl(stepOptions)
395
+ if (report.failed.length) {
396
+ // 只报"有几条失败"没法排查:把前几条的 id/错误码/消息留下来,日志里也打一行。
397
+ // 现场(2026-10-29)9 条失败只显示一句"未覆盖全部记忆",必须重放才知道原因。
398
+ const failedItems = syncFailureDetail(report)
399
+ log('rebuild sync failures: ' + failedItems.slice(0, 3).map(item => `${item.itemId || item.libraryId}:${item.code}:${item.message}`).join(' | '))
400
+ failPending('VECTOR_SYNC_INCOMPLETE', 'the rebuild did not cover every admitted memory; the active generation is untouched', { failed: report.failed.length, failedItems }, expected)
401
+ }
402
+ return report
403
+ } catch (error) {
404
+ const matches = expected && (stepOptions.generation == null || stepOptions.generation === expected.generation)
405
+ && (stepOptions.fingerprint == null || stepOptions.fingerprint === expected.fingerprint)
406
+ if (matches) failPending(error?.code || 'VECTOR_SYNC_FAILED', String(error?.message || error), { causeCode: error?.code ?? null }, expected)
407
+ throw error
408
+ }
409
+ }
410
+
411
+ async function syncShadowImpl(stepOptions = {}) {
412
+ const journal = pendingJournal()
413
+ if (!journal) throw vectorError('VECTOR_PENDING_MISSING', 'there is no prepared rebuild to sync')
414
+ const generation = Number.isSafeInteger(stepOptions.generation) ? stepOptions.generation : journal.generation
415
+ const fingerprint = typeof stepOptions.fingerprint === 'string' ? stepOptions.fingerprint : journal.fingerprint
416
+ if (journal.generation !== generation || journal.fingerprint !== fingerprint) {
417
+ throw vectorError('VECTOR_PENDING_MISSING', 'the prepared rebuild belongs to a different generation', { generation, fingerprint, preparedGeneration: journal.generation })
418
+ }
419
+ if (journal.status === 'committed') throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'this generation is already committed; nothing to sync')
420
+ const profile = assertProfileVerified(journal.profile)
421
+ const scopes = scopesFor(journal.databases)
422
+ if (Array.isArray(stepOptions.libraries)) {
423
+ // 只追平指定的私有库;全局库永远在同一代际里(不能漏,否则新代缺全局空间)
424
+ const wanted = new Set(stepOptions.libraries.map(idOf).filter(Boolean))
425
+ if (wanted.size) {
426
+ const libraries = Object.fromEntries(Object.entries(journal.databases.libraries || {}).filter(([id]) => wanted.has(id)))
427
+ scopes.length = 0
428
+ scopes.push(...scopesFor({ global: journal.databases.global, libraries }))
429
+ }
430
+ }
431
+ const loaded = loadLedger(fingerprint, generation)
432
+ const ledger = loaded.ledger
433
+ if (loaded.error) log('ledger unreadable (' + loaded.error.code + '); rebuilding it from the index')
434
+ const before = await captureSnapshot(scopes)
435
+ const total = before.scopes.reduce((sum, scope) => sum + scope.items.length, 0)
436
+ const report = { generation, fingerprint, committed: 0, skipped: 0, tombstoned: 0, failed: [], libraries: {}, restarted: Boolean(loaded.restarted || loaded.error) }
437
+ const progress = { stage: 'syncing', generation, fingerprint, total, completed: 0, skipped: 0, committed: 0, tombstoned: 0, failed: 0, current: null }
438
+ publishProgress(progress)
439
+ for (let index = 0; index < scopes.length; index++) {
440
+ const scope = scopes[index]
441
+ const items = before.scopes[index].items
442
+ const bucket = ledgerBucket(ledger, scope.key)
443
+ ensureDir(path.dirname(scope.db))
444
+ const present = await presentIds(scope, profile, generation)
445
+ const known = new Set(items.map(item => item.id))
446
+ for (const item of items) {
447
+ const recorded = bucket.items[item.id]
448
+ const tombstoned = bucket.tombstones[item.id]
449
+ if (recorded && recorded.hash === item.hash && present.has(item.id) && !tombstoned) {
450
+ report.skipped += 1
451
+ progress.skipped = report.skipped
452
+ progress.completed = report.committed + report.skipped
453
+ progress.current = { stage: 'skipped', libraryId: scope.key, itemId: item.id }
454
+ publishProgress(progress)
455
+ continue
456
+ }
457
+ const outcome = await commitItem(scope, item, profile, generation)
458
+ if (!outcome.ok) {
459
+ const error = outcome.error
460
+ report.failed.push({
461
+ libraryId: scope.key,
462
+ itemId: item.id,
463
+ code: error?.code || 'VECTOR_COMMIT_FAILED',
464
+ message: String(error?.message || error || 'commit failed'),
465
+ })
466
+ progress.failed = report.failed.length
467
+ progress.completed = report.committed + report.skipped + progress.failed
468
+ progress.current = { stage: 'failed', libraryId: scope.key, itemId: item.id, code: error?.code || 'VECTOR_COMMIT_FAILED' }
469
+ publishProgress(progress)
470
+ continue
471
+ }
472
+ bucket.items[item.id] = { hash: item.hash, at: now() }
473
+ delete bucket.tombstones[item.id]
474
+ report.committed += 1
475
+ progress.committed = report.committed
476
+ progress.completed = report.committed + report.skipped + progress.failed
477
+ progress.current = { stage: 'committed', libraryId: scope.key, itemId: item.id }
478
+ publishProgress(progress)
479
+ ledger.updatedAt = now()
480
+ saveLedger(ledger)
481
+ }
482
+ for (const name of present) {
483
+ if (known.has(name)) continue
484
+ try {
485
+ await impl.forget({ db: scope.db, libraryId: scope.libraryId, itemId: name, profile, generation })
486
+ } catch (error) {
487
+ report.failed.push({ libraryId: scope.key, itemId: name, code: error?.code || 'VECTOR_FORGET_FAILED', message: String(error?.message || error || 'forget failed') })
488
+ progress.failed = report.failed.length
489
+ progress.completed = report.committed + report.skipped + report.tombstoned + progress.failed
490
+ progress.current = { stage: 'failed', libraryId: scope.key, itemId: name, code: error?.code || 'VECTOR_FORGET_FAILED' }
491
+ publishProgress(progress)
492
+ continue
493
+ }
494
+ bucket.tombstones[name] = { hash: bucket.items[name]?.hash ?? null, at: now(), reason: 'absent-from-index' }
495
+ delete bucket.items[name]
496
+ report.tombstoned += 1
497
+ progress.tombstoned = report.tombstoned
498
+ progress.completed = report.committed + report.skipped + report.tombstoned + progress.failed
499
+ progress.current = { stage: 'tombstoned', libraryId: scope.key, itemId: name }
500
+ publishProgress(progress)
501
+ ledger.updatedAt = now()
502
+ saveLedger(ledger)
503
+ }
504
+ for (const [itemId, record] of Object.entries(bucket.items)) {
505
+ // 索引里已经没有它、影子库里也没有:留 tombstone,避免断点续建把它复活。
506
+ if (known.has(itemId) || present.has(itemId)) continue
507
+ bucket.tombstones[itemId] = { hash: record?.hash ?? null, at: now(), reason: 'not-present' }
508
+ delete bucket.items[itemId]
509
+ report.tombstoned += 1
510
+ progress.tombstoned = report.tombstoned
511
+ progress.completed = report.committed + report.skipped + report.tombstoned + progress.failed
512
+ progress.current = { stage: 'tombstoned', libraryId: scope.key, itemId }
513
+ publishProgress(progress)
514
+ }
515
+ const verifiedPresent = await presentIds(scope, profile, generation)
516
+ if (verifiedPresent.size !== known.size || [...known].some(id => !verifiedPresent.has(id))) {
517
+ report.failed.push({ libraryId: scope.key, code: 'VECTOR_COVERAGE_FAILED', message: 'shadow membership differs from eligible index snapshot' })
518
+ }
519
+ bucket.snapshot = { digest: before.scopes[index].digest, items: items.length, at: before.at }
520
+ report.libraries[scope.key] = { items: items.length, present: present.size, digest: before.scopes[index].digest }
521
+ ledger.updatedAt = now()
522
+ saveLedger(ledger)
523
+ }
524
+ const after = await captureSnapshot(scopes)
525
+ const stable = after.digest === before.digest
526
+ ledger.snapshot = { digest: before.digest, at: before.at, stable, libraries: report.libraries }
527
+ ledger.updatedAt = now()
528
+ saveLedger(ledger)
529
+ report.snapshot = { digest: before.digest, at: before.at, libraries: report.libraries }
530
+ report.afterDigest = after.digest
531
+ report.stable = stable
532
+ report.ledger = ledgerSummary(ledger)
533
+ progress.stage = report.failed.length ? 'failed' : 'prepared'
534
+ progress.failed = report.failed.length
535
+ progress.completed = report.committed + report.skipped + report.tombstoned + progress.failed
536
+ progress.current = null
537
+ report.progress = publishProgress(progress)
538
+ if (report.failed.length === 0) {
539
+ updatePendingJournal(current => ({ ...current, status: 'prepared', preparedAt: now(), snapshot: report.snapshot, report: { committed: report.committed, skipped: report.skipped, tombstoned: report.tombstoned, libraries: report.libraries, progress: report.progress } }))
540
+ }
541
+ return report
542
+ }
543
+
544
+ // -------------------------------------------------------------- 切换
545
+
546
+ function measureDatabases(databases) {
547
+ const stats = {}
548
+ for (const scope of scopesFor(databases)) {
549
+ let info
550
+ try {
551
+ info = fileStats(scope.db)
552
+ } catch (error) {
553
+ throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'cannot read back shadow database ' + scope.rel + ' (' + (error?.code || error?.message || error) + ')', { path: scope.rel })
554
+ }
555
+ stats[scope.rel] = info
556
+ }
557
+ return stats
558
+ }
559
+
560
+ function failPending(code, message, extra = {}, expected = null) {
561
+ let failedProgress = null
562
+ updatePendingJournal(current => {
563
+ if (expected && (current.token !== expected.token || current.generation !== expected.generation || current.fingerprint !== expected.fingerprint)) return null
564
+ if (current.status === 'committed' || current.status === 'aborted') return null
565
+ const prior = current.report?.progress || { generation: current.generation, fingerprint: current.fingerprint, total: 0, completed: 0, skipped: 0, committed: 0, tombstoned: 0, failed: 0, current: null }
566
+ failedProgress = { ...prior, stage: 'failed', failed: Math.max(1, prior.failed || 0), current: { stage: 'failed', code }, updatedAt: now() }
567
+ return { ...current, status: 'failed', error: { code, message, at: now(), ...extra }, report: { ...(current.report || {}), progress: failedProgress } }
568
+ })
569
+ if (failedProgress && onProgress) {
570
+ try { onProgress(structuredClone(failedProgress)) } catch (error) { log('progress callback failed: ' + (error?.message || error)) }
571
+ }
572
+ }
573
+
574
+ /**
575
+ * 最后短窗口:锁内只做同步 compare(token/代际/文件大小)与一次原子指针写。
576
+ * 这里没有 await,也不允许有(§14.8 第 5/7 条)。
577
+ */
578
+ function commitActiveGeneration({ profile, journal, sync, stats }) {
579
+ const committedAt = now()
580
+ return updateVectorStateSync(current => {
581
+ const pending = current.pending
582
+ if (!pending) {
583
+ throw vectorError('VECTOR_PENDING_MISSING', 'the prepared rebuild disappeared before the switch', { generation: journal.generation })
584
+ }
585
+ if (pending.token !== journal.token || pending.generation !== journal.generation || pending.fingerprint !== profile.fingerprint) {
586
+ throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'another rebuild replaced this prepared generation; refusing to switch', {
587
+ prepared: { generation: journal.generation, token: journal.token }, currentPending: { generation: pending.generation, token: pending.token },
588
+ })
589
+ }
590
+ if (pending.status !== 'prepared') throw vectorError('VECTOR_SYNC_INCOMPLETE', 'only a prepared shadow generation can be activated')
591
+ const preparedAgainst = pending.previousGeneration ?? null
592
+ const activeGeneration = current.active?.generation ?? null
593
+ if (preparedAgainst !== activeGeneration) {
594
+ throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'the active generation changed while the rebuild was prepared; refusing to switch', { activeGeneration, preparedAgainst })
595
+ }
596
+ for (const scope of scopesFor(pending.databases)) {
597
+ let info
598
+ try {
599
+ info = fs.statSync(scope.db)
600
+ } catch {
601
+ throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'a shadow database vanished before the switch: ' + scope.rel, { path: scope.rel })
602
+ }
603
+ const expected = stats[scope.rel]
604
+ if (!info.isFile() || !expected || info.size !== expected.bytes || fileStats(scope.db).sha256 !== expected.sha256) {
605
+ throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'a shadow database changed after the final sync: ' + scope.rel, { path: scope.rel, expected: expected?.bytes ?? null, actual: info.size })
606
+ }
607
+ }
608
+ const databases = {
609
+ global: { path: pending.databases.global.path, bytes: stats[pending.databases.global.path].bytes, sha256: stats[pending.databases.global.path].sha256 },
610
+ libraries: Object.fromEntries(Object.entries(pending.databases.libraries || {}).map(([libraryId, entry]) => [
611
+ libraryId, { path: entry.path, bytes: stats[entry.path]?.bytes ?? null, sha256: stats[entry.path]?.sha256 ?? null },
612
+ ])),
613
+ }
614
+ const previous = current.active ? {
615
+ generation: current.active.generation,
616
+ fingerprint: current.active.fingerprint,
617
+ catalogId: current.active.profile?.catalogId ?? null,
618
+ profile: current.active.profile,
619
+ at: current.active.updatedAt ?? null,
620
+ } : null
621
+ return {
622
+ ...current,
623
+ generation: Math.max(current.generation || 0, pending.generation),
624
+ // 切换成功后 requested 必须清空:它表示"待切换的另一个模型"。留着它,界面会
625
+ // 同时显示"当前生效 + 待切换",让人以为还有一步没做完(2026-10-30 现场)。
626
+ requested: null,
627
+ active: {
628
+ status: 'active',
629
+ generation: pending.generation,
630
+ fingerprint: profile.fingerprint,
631
+ profile,
632
+ databases,
633
+ snapshot: sync.snapshot ?? null,
634
+ lastMigration: sync.progress ?? pending.report?.progress ?? null,
635
+ previous,
636
+ reason: pending.reason ?? null,
637
+ updatedAt: committedAt,
638
+ },
639
+ pending: { ...pending, status: 'committed', databases, committedAt },
640
+ }
641
+ })
642
+ }
643
+
644
+ /**
645
+ * 完整切换:prepare(全新代际/全新影子)→ 追平(快照必须稳定)→ smoke → 原子切指针。
646
+ * 任一步失败都不动 active;同一空间已生效时直接返回 already-active(不重建、不重嵌入)。
647
+ */
648
+ async function switchProfile(stepOptions = {}) {
649
+ const profile = resolveProfile(stepOptions.profile ?? stepOptions.catalogId)
650
+ const state = vectorState()
651
+ const active = state?.active
652
+ if (stepOptions.force !== true && active && active.fingerprint === profile.fingerprint) {
653
+ const allowed = assertVectorWriteAllowed()
654
+ return {
655
+ ok: true, status: 'already-active', switched: false,
656
+ generation: allowed.generation, fingerprint: allowed.fingerprint, profile: allowed.profile,
657
+ databases: allowed.databases, env: profileEnv(allowed.profile), previous: active.previous ?? null, report: null,
658
+ }
659
+ }
660
+ const prepared = await prepareShadow({ profile, libraries: stepOptions.libraries, fresh: stepOptions.fresh === true, reason: stepOptions.reason ?? 'switch' })
661
+ let sync = await syncShadow({ generation: prepared.generation, fingerprint: prepared.fingerprint })
662
+ let attempt = 1
663
+ while (!sync.stable && attempt < maxCatchUpAttempts) {
664
+ attempt += 1
665
+ log('index moved during catch-up; re-syncing generation ' + prepared.generation + ' (attempt ' + attempt + ')')
666
+ sync = await syncShadow({ generation: prepared.generation, fingerprint: prepared.fingerprint })
667
+ }
668
+ if (!sync.stable) {
669
+ const message = 'the memory index changed while catching up; the active generation is untouched'
670
+ failPending('VECTOR_INDEX_MOVED', message, { before: sync.snapshot?.digest ?? null, after: sync.afterDigest ?? null }, prepared)
671
+ throw vectorError('VECTOR_INDEX_MOVED', message, { generation: prepared.generation, before: sync.snapshot?.digest ?? null, after: sync.afterDigest ?? null })
672
+ }
673
+ if (sync.failed.length) {
674
+ const message = 'the rebuild did not cover every admitted memory; the active generation is untouched'
675
+ failPending('VECTOR_SYNC_INCOMPLETE', message, { failed: sync.failed.length, failedItems: syncFailureDetail(sync) }, prepared)
676
+ throw vectorError('VECTOR_SYNC_INCOMPLETE', message, { generation: prepared.generation, failed: sync.failed })
677
+ }
678
+ if (impl.smoke) {
679
+ let outcome
680
+ try {
681
+ outcome = await impl.smoke({ profile, generation: prepared.generation, databases: prepared.databases, relative: prepared.relative, report: sync })
682
+ } catch (error) {
683
+ failPending('VECTOR_SMOKE_FAILED', String(error?.message || error), {}, prepared)
684
+ throw vectorError('VECTOR_SMOKE_FAILED', 'smoke failed for generation ' + prepared.generation + ': ' + (error?.message || error), { causeCode: error?.code ?? null })
685
+ }
686
+ if (!outcome || outcome.ok === false) {
687
+ failPending('VECTOR_SMOKE_FAILED', 'smoke rejected generation ' + prepared.generation, {}, prepared)
688
+ throw vectorError('VECTOR_SMOKE_FAILED', 'smoke rejected generation ' + prepared.generation, { smoke: outcome ?? null })
689
+ }
690
+ }
691
+ let next
692
+ try {
693
+ next = await withVectorWriteFreeze(async () => {
694
+ const journal = pendingJournal()
695
+ if (journal?.token !== prepared.token) throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'the prepared shadow was replaced before final verification')
696
+ const actualIds = (await resolveLibraryIds()).sort()
697
+ const expectedIds = Object.keys(journal.databases.libraries).sort()
698
+ if (stableStringify(actualIds) !== stableStringify(expectedIds)) throw vectorError('VECTOR_INDEX_MOVED', 'the library set changed after rebuild; active is untouched')
699
+ const final = await captureSnapshot(scopesFor(journal.databases))
700
+ if (final.digest !== sync.snapshot.digest) {
701
+ failPending('VECTOR_INDEX_MOVED', 'the memory index changed after smoke; active is untouched', {}, prepared)
702
+ throw vectorError('VECTOR_INDEX_MOVED', 'the memory index changed after smoke; active is untouched')
703
+ }
704
+ assertProfileVerified(profile)
705
+ const stats = measureDatabases(journal.databases)
706
+ // No await between final verified snapshot and atomic pointer swap.
707
+ return commitActiveGeneration({ profile, journal: prepared, sync, stats })
708
+ })
709
+ } catch (error) {
710
+ failPending(error?.code || 'VECTOR_SWITCH_FAILED', String(error?.message || error), {}, prepared)
711
+ throw error
712
+ }
713
+ // 第二次极短原子写:清掉 committed journal。若在此崩溃,recoverProfile 仍能从 committed 记录认出新代。
714
+ clearPendingJournal()
715
+ log('activated vector generation ' + next.active.generation + ' (' + profile.catalogId + ')')
716
+ return {
717
+ ok: true,
718
+ status: stepOptions.reason === 'rollback' ? 'rolled-back' : 'switched',
719
+ switched: true,
720
+ generation: next.active.generation,
721
+ fingerprint: next.active.fingerprint,
722
+ profile: next.active.profile,
723
+ env: profileEnv(next.active.profile),
724
+ databases: activeDatabases(),
725
+ previous: next.active.previous ?? null,
726
+ requested: next.requested ?? null,
727
+ report: {
728
+ committed: sync.committed,
729
+ skipped: sync.skipped,
730
+ tombstoned: sync.tombstoned,
731
+ libraries: sync.libraries,
732
+ snapshot: sync.snapshot,
733
+ progress: sync.progress ?? null,
734
+ },
735
+ }
736
+ }
737
+
738
+ // -------------------------------------------------------------- 回滚
739
+
740
+ /**
741
+ * 回滚不是"恢复旧指针":切换后的新写入也要回到旧代,所以走同一套重建流程,
742
+ * 把**最新 index** 重放进旧 profile 的一个全新代际里,再原子切回去(§14.8 第 9 条)。
743
+ */
744
+ async function rollbackProfile(stepOptions = {}) {
745
+ const state = vectorState()
746
+ const active = state?.active
747
+ const target = stepOptions.profile ?? stepOptions.catalogId ?? active?.previous?.profile
748
+ if (!target) throw vectorError('VECTOR_NO_ROLLBACK_TARGET', 'there is no previous generation to roll back to')
749
+ const profile = resolveProfile(target)
750
+ if (active && active.fingerprint === profile.fingerprint) {
751
+ throw vectorError('VECTOR_NO_ROLLBACK_TARGET', 'the rollback target is already the active profile', { catalogId: profile.catalogId })
752
+ }
753
+ const result = await switchProfile({
754
+ profile,
755
+ libraries: stepOptions.libraries,
756
+ fresh: stepOptions.fresh !== false,
757
+ force: true,
758
+ reason: 'rollback',
759
+ })
760
+ return { ...result, status: 'rolled-back', replayed: true, rolledBackFrom: active ? { generation: active.generation, catalogId: active.profile?.catalogId ?? null } : null }
761
+ }
762
+
763
+ // -------------------------------------------------------------- 启动恢复
764
+
765
+ /**
766
+ * 读 journal 恢复:prepared 未切 → 继续旧代(不切);committed → 认新代并清 journal;
767
+ * 任何说不清的状态(journal 与指针矛盾、库文件缺失/变小、profile 不再有效)→ 抛 typed error,
768
+ * 调用方必须退词法,不能猜最近文件,也不能回落 legacy 远端。
769
+ */
770
+ async function recoverProfile() {
771
+ const state = readVectorState({ tolerateMissing: true }).state
772
+ if (!state) return { status: 'absent', generation: null, fingerprint: null, profile: null, pending: null }
773
+ const active = state.active
774
+ const pending = state.pending
775
+ if (pending && pending.status === 'committed') {
776
+ if (!active || active.generation !== pending.generation) {
777
+ throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'the commit journal and the active pointer disagree; refusing vectors', {
778
+ pendingGeneration: pending.generation, activeGeneration: active?.generation ?? null,
779
+ })
780
+ }
781
+ clearPendingJournal()
782
+ const allowed = assertVectorWriteAllowed({ deep: true })
783
+ return { status: 'committed', recovered: true, generation: allowed.generation, fingerprint: allowed.fingerprint, profile: allowed.profile, databases: allowed.databases, pending: null }
784
+ }
785
+ if (pending && (pending.status === 'preparing' || pending.status === 'prepared')) {
786
+ const preparedAgainst = pending.previousGeneration ?? null
787
+ const activeGeneration = active?.generation ?? null
788
+ if (preparedAgainst !== activeGeneration) {
789
+ throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'a rebuild was prepared against a generation that is no longer active; refusing vectors', {
790
+ preparedAgainst, activeGeneration, pendingGeneration: pending.generation,
791
+ })
792
+ }
793
+ if (!active) return { status: 'interrupted', generation: null, fingerprint: null, profile: null, pending: { generation: pending.generation, resume: true } }
794
+ const allowed = assertVectorWriteAllowed({ deep: true })
795
+ return {
796
+ status: pending.status === 'prepared' ? 'prepared' : 'interrupted',
797
+ recovered: false,
798
+ generation: allowed.generation,
799
+ fingerprint: allowed.fingerprint,
800
+ profile: allowed.profile,
801
+ databases: allowed.databases,
802
+ pending: { generation: pending.generation, fingerprint: pending.fingerprint, token: pending.token, resume: pending.status === 'prepared', restart: pending.status === 'preparing' },
803
+ }
804
+ }
805
+ if (pending && (pending.status === 'aborted' || pending.status === 'failed')) {
806
+ clearPendingJournal()
807
+ }
808
+ if (!active) return { status: 'absent', generation: null, fingerprint: null, profile: null, pending: null }
809
+ const allowed = assertVectorWriteAllowed({ deep: true })
810
+ return {
811
+ status: 'active',
812
+ recovered: false,
813
+ generation: allowed.generation,
814
+ fingerprint: allowed.fingerprint,
815
+ profile: allowed.profile,
816
+ databases: allowed.databases,
817
+ snapshot: allowed.snapshot,
818
+ pending: null,
819
+ }
820
+ }
821
+
822
+ /** 放弃一次未切换的重建;active 指针与旧库完全不动。 */
823
+ function abortPending(reason = 'aborted by user') {
824
+ const journal = pendingJournal()
825
+ if (!journal) return { ok: true, aborted: false }
826
+ if (journal.status === 'aborted' || journal.status === 'committed') return { ok: true, aborted: false }
827
+ updatePendingJournal(current => ({ ...current, status: 'aborted', error: { code: 'VECTOR_BUILD_ABORTED', message: String(reason), at: now() } }))
828
+ return { ok: true, aborted: true, generation: journal.generation, fingerprint: journal.fingerprint }
829
+ }
830
+
831
+ function readLedger(stepOptions = {}) {
832
+ const journal = pendingJournal()
833
+ const state = vectorState()
834
+ const fingerprint = stepOptions.fingerprint ?? journal?.fingerprint ?? state?.active?.fingerprint
835
+ const generation = Number.isSafeInteger(stepOptions.generation) ? stepOptions.generation : (journal?.generation ?? state?.active?.generation)
836
+ if (!fingerprint || !Number.isSafeInteger(generation)) return null
837
+ return loadLedger(fingerprint, generation).ledger
838
+ }
839
+
840
+ return {
841
+ prepareShadow,
842
+ syncShadow,
843
+ switchProfile,
844
+ rollbackProfile,
845
+ recoverProfile,
846
+ abortPending,
847
+ status: () => vectorStatus(),
848
+ pending: () => pendingJournal(),
849
+ ledger: readLedger,
850
+ /**
851
+ * 当前生效代际里"哪些 id 已经写进向量库"(按 scope key:libraryId 或 'global')。
852
+ * 迁移写库时刻意不碰索引,所以索引里的老条目没有 entry.vector 戳;界面要回答
853
+ * "这条在不在向量库"只能查账本。
854
+ */
855
+ membership: () => {
856
+ const state = vectorState()
857
+ const active = state?.active
858
+ if (!active) return null
859
+ const ledger = readLedger({ fingerprint: active.fingerprint, generation: active.generation })
860
+ if (!ledger) return null
861
+ const out = {}
862
+ for (const scope of scopesFor(active.databases)) {
863
+ const bucket = ledger.libraries?.[scope.key]
864
+ out[scope.key] = bucket?.items && typeof bucket.items === 'object' ? Object.keys(bucket.items) : []
865
+ }
866
+ return out
867
+ },
868
+ ledgerSummary: stepOptions => {
869
+ const ledger = readLedger(stepOptions)
870
+ return ledger ? ledgerSummary(ledger) : null
871
+ },
872
+ captureGeneration,
873
+ assertGeneration,
874
+ assertVectorWriteAllowed,
875
+ admittedItems,
876
+ contentHash,
877
+ }
878
+ }