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
package/lib/memory.js CHANGED
@@ -6,16 +6,18 @@ import {
6
6
  ensureSession, getSessionBinding, memoryDbPath, globalDbPath,
7
7
  loadPrivateIndex, savePrivateIndex, loadGlobalIndex, saveGlobalIndex,
8
8
  updatePrivateIndex, updateGlobalIndex,
9
- newMemoryId, getSettings, getMemoryBackend, getActiveCue, consumeCue,
10
- getLibrary, setSessionBinding, effectiveScope, listLibrariesAll,
9
+ newMemoryId, getSettings, effectiveVectorRoute, getActiveCue, consumeCue,
10
+ getLibrary, setSessionBinding, setRecallError, effectiveScope, listLibrariesAll,
11
11
  } from './state.js'
12
- import { stamp, redactSecrets, retryStoreWrite } from './paths.js'
12
+ import { stamp, redactSecrets, retryStoreWrite, warn } from './paths.js'
13
+ import { record } from './activity-log.js'
13
14
  import { BASE_AROUSAL, moodCongruence, moodDecay } from './mood.js'
14
15
  import { commit, retrieve, listFiles, forget } from './memu.js'
15
16
  import { bumpStats } from './stats.js'
16
17
  import { recallRelations } from './graph.js'
17
18
  import { attachmentRefs } from './media.js'
18
19
  import { normalizeMemoryContext, contextVisible, sameFactContext } from './context.js'
20
+ import { assertGeneration, assertVectorWriteAllowed, captureGeneration, enrollActiveLibrary, hasActiveVectorGeneration, isVectorStateError, readVectorState, withVectorWrite } from './vector-profile.js'
19
21
 
20
22
  const HALF_LIFE_DAYS = 14
21
23
  // A short-term entry is meant to live for the length of a conversation, not a
@@ -35,6 +37,14 @@ const HABITUATION_FLOOR = 0.5
35
37
  // library with three variants of the same decision would spend the whole block on it.
36
38
  // The top hit is always kept; later picks pay lambda x max-similarity-to-what-is-in.
37
39
  const MMR_LAMBDA = 0.72
40
+ // Semantic fusion uses RRF: each route contributes a
41
+ // rank term and the merged score is normalised to the best fused hit before the
42
+ // existing mood/strength/MMR pipeline sees it. The candidate cap keeps a long
43
+ // library from turning every turn into two full scans.
44
+ const HYBRID_RRF_K = 60
45
+ const HYBRID_CANDIDATE_MIN = 12
46
+ const HYBRID_CANDIDATE_MAX = 40
47
+ const HYBRID_SEMANTIC_TIMEOUT_MS = 15_000
38
48
  const LAYERS = ['long_term', 'short_term', 'must_remember']
39
49
  // These three are read back by the injector to decide how a memory may be spoken and
40
50
  // where it may appear. An unchecked typo would silently mean "no suffix at all", so
@@ -51,6 +61,89 @@ const VISIBILITIES = STORED_VISIBILITIES
51
61
  // quote, which systematically inflated confidence in things nobody had said.
52
62
  const SOURCES = ['user_said', 'inferred', 'imagined', 'told']
53
63
 
64
+ function managedVectorIntent() {
65
+ if (effectiveVectorRoute() !== 'managed-local') return { managed: false, route: effectiveVectorRoute(), state: null, error: null }
66
+ try {
67
+ const state = readVectorState({ tolerateMissing: true }).state
68
+ return { managed: true, route: 'managed-local', state, error: null }
69
+ } catch (error) {
70
+ return { managed: true, route: 'managed-local', state: null, error }
71
+ }
72
+ }
73
+
74
+ // 引擎调用用的路由。受管路由被选中但还没有任何代际时,MemU 的 envFor 会直接拒绝
75
+ // managed-local("managed local vector profile is not active"),写入/删除/列举都会连带
76
+ // 失败。这时引擎这一步按词法处理:索引照常写,等下一次迁移把历史补进向量库。
77
+ // 召回仍按原路由走,好让侧边栏继续显示降级原因,而不是悄悄退回词法。
78
+ function engineRoute() {
79
+ const route = effectiveVectorRoute()
80
+ return route === 'managed-local' && !hasActiveVectorGeneration() ? 'lexical' : route
81
+ }
82
+
83
+ function rememberRecallError(exec, error) {
84
+ const message = String(error?.message || error)
85
+ warn('managed vector recall degraded to lexical: ' + message)
86
+ const sessionId = sessionIdOf(exec)
87
+ if (sessionId) setRecallError(sessionId, message)
88
+ return message
89
+ }
90
+
91
+ function clearRecallError(exec) {
92
+ const sessionId = sessionIdOf(exec)
93
+ if (sessionId) setRecallError(sessionId, null)
94
+ }
95
+
96
+ async function withManagedPrivateWrite(libraryId, action) {
97
+ const intent = managedVectorIntent()
98
+ if (!intent.managed) return action()
99
+ // 选了本地向量、但下载完模型后从没跑过迁移(vector-active 只有 requested):这里
100
+ // 没有可对齐的代际,写入必须降级成索引 + 引擎,而不是把整次整理写死。召回侧一直是
101
+ // 这个口径(local-fallback),写入侧漏了同样的降级 —— 结果就是整理跑完一条没写进来。
102
+ if (!hasActiveVectorGeneration()) {
103
+ warn('managed vector write degraded to index+engine: no active vector generation; run the profile switch to enable vectors')
104
+ record({ kind: 'vector', level: 'warn', libraryId, message: '向量未激活,写入已降级为索引+引擎', detail: { reason: 'no active vector generation' } })
105
+ return action()
106
+ }
107
+ return withVectorWrite(async () => {
108
+ assertVectorWriteAllowed()
109
+ enrollActiveLibrary(libraryId)
110
+ return action()
111
+ })
112
+ }
113
+
114
+ async function withManagedGlobalWrite(action) {
115
+ const intent = managedVectorIntent()
116
+ if (!intent.managed) return action()
117
+ if (!hasActiveVectorGeneration()) {
118
+ warn('managed vector write degraded to index+engine: no active vector generation; run the profile switch to enable vectors')
119
+ record({ kind: 'vector', level: 'warn', scope: 'global', message: '向量未激活,全局写入已降级为索引+引擎', detail: { reason: 'no active vector generation' } })
120
+ return action()
121
+ }
122
+ return withVectorWrite(() => { assertVectorWriteAllowed(); return action() })
123
+ }
124
+
125
+ async function managedRecall({ exec, index, query, topK, libraryId, automatic = false, intent, retrieveImpl, byId = false }) {
126
+ const local = localRecall(index, query)
127
+ try {
128
+ if (intent.error) throw intent.error
129
+ const captured = intent.route === 'managed-local' ? captureGeneration() : null
130
+ if (byId) return { ...local, recallRoute: 'local' }
131
+ const db = captured ? (libraryId ? captured.libraries[libraryId] : captured.global) : (libraryId ? memoryDbPath(libraryId) : globalDbPath())
132
+ if (!db) throw new Error('managed vector generation has no database for library ' + libraryId)
133
+ const semantic = await retrieveImpl({ query, db, route: intent.route, timeoutMs: HYBRID_SEMANTIC_TIMEOUT_MS })
134
+ if (semantic?.parseWarning) throw new Error(semantic.parseWarning)
135
+ if (!Array.isArray(semantic?.files)) throw new Error('managed vector recall returned no file list')
136
+ if (captured) assertGeneration(captured)
137
+ const fused = fuseRecallCandidates({ local: local.files, semantic: semantic.files, index,
138
+ limit: hybridCandidateLimit(topK), automatic, sessionId: memorySessionId(exec), route: 'managed-vector-rrf' })
139
+ clearRecallError(exec)
140
+ return { ...semantic, files: fused.files, recallRoute: 'managed-vector-rrf', fusion: fused.debug }
141
+ } catch (error) {
142
+ const reason = rememberRecallError(exec, error)
143
+ return { ...local, recallRoute: 'local-fallback', errors: ['semantic recall failed: ' + reason] }
144
+ }
145
+ }
146
+
54
147
  function checkEnums({ disclosure, owner, visibility, source }) {
55
148
  if (disclosure !== undefined && !DISCLOSURES.includes(disclosure)) {
56
149
  throw new Error(`disclosure must be ${DISCLOSURES.join(', ')}`)
@@ -237,6 +330,22 @@ const RELATIVE_DAY_OFFSETS = [
237
330
  [/tomorrow/i, 1], [/明天|明日/, 1], [/tonight/i, 0], [/today/i, 0], [/今天|今晚/, 0],
238
331
  ]
239
332
 
333
+ /**
334
+ * 从标题/正文里取唯一的显式日期,作为"模型忘了填 salient_date"的兜底。
335
+ * 只接受全篇唯一的 YYYY-MM-DD;出现多个候选就不猜(宁可为空,也不把日志里的日期
336
+ * 误装成纪念日)。相对日期不在这里解析,交给 reconcileSalientDate。
337
+ */
338
+ export function deriveSalientDateFromText(title, content) {
339
+ const dates = new Set()
340
+ for (const text of [title, content]) {
341
+ for (const match of String(text || '').matchAll(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/g)) {
342
+ const normalized = normalizeSalientDate(`${match[1]}-${match[2]}-${match[3]}`)
343
+ if (normalized && isDatedSalientDate(normalized)) dates.add(normalized)
344
+ }
345
+ }
346
+ return dates.size === 1 ? [...dates][0] : null
347
+ }
348
+
240
349
  export function reconcileSalientDate(value, text, now = new Date()) {
241
350
  if (!SALIENT_SHORT.test(String(value || ''))) return value
242
351
  const body = String(text || '')
@@ -559,6 +668,108 @@ function localRecall(index, query, filter = () => true) {
559
668
  }
560
669
  }
561
670
 
671
+ /** The per-route candidate cap for a requested topK; bounded on both ends. */
672
+ export function hybridCandidateLimit(topK) {
673
+ const wanted = Math.max(1, Number(topK) || 1)
674
+ return Math.max(HYBRID_CANDIDATE_MIN, Math.min(HYBRID_CANDIDATE_MAX, wanted * 4))
675
+ }
676
+
677
+ /**
678
+ * Reciprocal-rank fusion of lexical index hits and MemU vector hits.
679
+ *
680
+ * Filtering happens before the per-route cap and before fusion: a pending review,
681
+ * an out-of-context entry or an automatic secret must not occupy a candidate slot
682
+ * and then be dropped later. Ids are canonicalised against the live index so the
683
+ * two routes cannot return the same memory twice under a prefix or a .md suffix.
684
+ *
685
+ * The returned retrievalScore is the normalised RRF share (best fused hit = 1).
686
+ * recallScore consumes that value instead of recomputing 1/(rank+1), so the
687
+ * fusion order is not silently overwritten by the old rank decay.
688
+ */
689
+ export function fuseRecallCandidates({ local = [], semantic = [], index, limit = HYBRID_CANDIDATE_MIN, automatic = false, sessionId = null, now = Date.now(), route = 'vector-rrf' } = {}) {
690
+ const entries = index?.entries || {}
691
+ const canonical = (ref) => {
692
+ const raw = String(ref || '').trim()
693
+ if (!raw) return null
694
+ try { return resolveEntryId(index, raw) } catch {
695
+ const clean = raw.replace(/\.md$/i, '')
696
+ return entries[clean] ? clean : null
697
+ }
698
+ }
699
+ const eligible = (file, route, rank) => {
700
+ const id = canonical(file?.name)
701
+ if (!id) return null
702
+ const entry = entries[id] || file?.entry || null
703
+ if (!entry) return null
704
+ if (!isMemoryEligible(entry, { automatic, now })) return null
705
+ if (!visibleToSession(entry, sessionId)) return null
706
+ if (automatic && entry.visibility === 'secret') return null
707
+ return { id, entry, file, route, rank }
708
+ }
709
+ const take = (files, route) => {
710
+ const picked = []
711
+ // One route can name the same canonical entry more than once: a caller may
712
+ // pass both `mem-x` and `mem-x.md`, and an engine can repeat a hit. Dedupe by
713
+ // canonical id before scoring and truncating, keeping the first (best) rank.
714
+ // Otherwise every duplicate adds another 1/(k+rank) and consumes a slot.
715
+ const seen = new Set()
716
+ for (const file of Array.isArray(files) ? files : []) {
717
+ const item = eligible(file, route, picked.length + 1)
718
+ if (!item || seen.has(item.id)) continue
719
+ seen.add(item.id)
720
+ picked.push(item)
721
+ if (picked.length >= limit) break
722
+ }
723
+ return picked
724
+ }
725
+ const localPicked = take(local, 'local')
726
+ const semanticPicked = take(semantic, 'semantic')
727
+ const merged = new Map()
728
+ const add = (item) => {
729
+ const current = merged.get(item.id) || { id: item.id, entry: item.entry, file: item.file, rrf: 0, bestRank: Infinity, localRank: null, semanticRank: null, routes: [] }
730
+ current.rrf += 1 / (HYBRID_RRF_K + item.rank)
731
+ current.bestRank = Math.min(current.bestRank, item.rank)
732
+ if (item.route === 'local') current.localRank = item.rank
733
+ else current.semanticRank = item.rank
734
+ if (!current.routes.includes(item.route)) current.routes.push(item.route)
735
+ // Prefer the local object when both routes carry the entry: it already has the
736
+ // lexical score and the index-backed content used below by enrichFiles.
737
+ if (item.route === 'local') current.file = item.file
738
+ merged.set(item.id, current)
739
+ }
740
+ for (const item of localPicked) add(item)
741
+ for (const item of semanticPicked) add(item)
742
+ const rows = [...merged.values()]
743
+ const maxRrf = rows.reduce((max, row) => Math.max(max, row.rrf), 0) || 1
744
+ const files = rows
745
+ .map((row) => ({
746
+ ...row.file,
747
+ name: row.id,
748
+ entry: row.entry,
749
+ retrievalScore: row.rrf / maxRrf,
750
+ fusion: { rrf: row.rrf, bestRank: row.bestRank, localRank: row.localRank, semanticRank: row.semanticRank, routes: row.routes.slice() },
751
+ }))
752
+ .sort((a, b) => b.retrievalScore - a.retrievalScore
753
+ || a.fusion.bestRank - b.fusion.bestRank
754
+ || a.name.localeCompare(b.name))
755
+ const localIds = new Set(localPicked.map((item) => item.id))
756
+ const semanticIds = new Set(semanticPicked.map((item) => item.id))
757
+ return {
758
+ files,
759
+ debug: {
760
+ route,
761
+ k: HYBRID_RRF_K,
762
+ candidateLimit: limit,
763
+ localCandidates: localPicked.length,
764
+ semanticCandidates: semanticPicked.length,
765
+ fused: files.length,
766
+ localOnly: [...localIds].filter((id) => !semanticIds.has(id)).length,
767
+ semanticOnly: [...semanticIds].filter((id) => !localIds.has(id)).length,
768
+ shared: [...localIds].filter((id) => semanticIds.has(id)).length,
769
+ },
770
+ }
771
+ }
772
+
562
773
  // Human-like recall ordering. Retrieval answers first (semantic match is still
563
774
  // the primary signal, which is why it stays a rank decay), then these biases
564
775
  // re-order within that set:
@@ -568,7 +779,10 @@ function localRecall(index, query, filter = () => true) {
568
779
  // Every factor is bounded so no bias can outrank a good semantic hit by luck.
569
780
  function recallScore(file, rank, context) {
570
781
  const entry = file.entry || {}
571
- const retrieval = 1 / (rank + 1)
782
+ // A fused file carries its own normalised RRF share. Falling back to the old
783
+ // rank decay keeps every lexical caller byte-for-byte compatible.
784
+ const fused = Number(file.retrievalScore)
785
+ const retrieval = Number.isFinite(fused) ? Math.max(0, Math.min(1, fused)) : 1 / (rank + 1)
572
786
  const strengthTerm = 0.6 + 0.4 * file.strength
573
787
  const marginal = entry.marginalized ? 0.35 : 1
574
788
 
@@ -738,7 +952,7 @@ function createdAtSpan(files) {
738
952
  return { first: stamps[0] || null, last: stamps[stamps.length - 1] || null }
739
953
  }
740
954
 
741
- export async function privateRemember({
955
+ async function privateRememberImpl({
742
956
  exec, libraryId, content, title, layer, pinReason = null, pinSource = null,
743
957
  sideEventId = null, tags = [], openLoop = false, disclosure = 'can_tell',
744
958
  owner = 'me', visibility = 'shared', source = 'inferred', confidence = 0.8,
@@ -771,8 +985,14 @@ export async function privateRemember({
771
985
  // An unreadable date is refused instead of dropped. Dropping it looked like
772
986
  // success to the caller while losing the only part of the memory it asked for:
773
987
  // a date that never fires is indistinguishable from no date at all.
774
- const normalizedSalientDate = reconcileSalientDate(normalizeSalientDate(salientDate), `${title || ''} ${content || ''}`)
775
- if (normalizedSalientDate === null && salientDate !== null && salientDate !== undefined && String(salientDate).trim()) {
988
+ let normalizedSalientDate = reconcileSalientDate(normalizeSalientDate(salientDate), `${title || ''} ${content || ''}`)
989
+ // 模型常把日期写进正文却漏填 salient_date(详情页日期栏因此为空)。只有显式传了
990
+ // 非法值才报错;什么都没传时,从文本取唯一的 YYYY-MM-DD 作为兜底。
991
+ const explicitDate = salientDate !== null && salientDate !== undefined && String(salientDate).trim()
992
+ if (normalizedSalientDate === null && !explicitDate) {
993
+ normalizedSalientDate = deriveSalientDateFromText(title, content)
994
+ }
995
+ if (normalizedSalientDate === null && explicitDate) {
776
996
  throw new Error('salient_date must be MM-DD (recurs every year) or YYYY-MM-DD (one specific day)')
777
997
  }
778
998
  content = redactSecrets(content)
@@ -885,9 +1105,24 @@ export async function privateRemember({
885
1105
  // A held (pending_review) claim stays index-only: committing it to MemU now
886
1106
  // would make a contested memory retrievable before anyone decided it. Accepting
887
1107
  // the review later is what commits it (see resolveReview → privateUpdate).
888
- const result = getMemoryBackend() === 'api' && !conflict
889
- ? await commit({ payload, db: memoryDbPath(scope.libraryId) })
890
- : { ok: true, backend: getMemoryBackend(), held: conflict ? true : undefined }
1108
+ const route = engineRoute()
1109
+ const result = (route !== 'lexical') && !conflict
1110
+ ? await commit({ payload, route, db: memoryDbPath(scope.libraryId) })
1111
+ : { ok: true, backend: route, held: conflict ? true : undefined }
1112
+ // 记下"这条进没进向量库":只有真的带代际提交成功才写,冲突暂扣/降级都不写。
1113
+ // 之前没有任何字段能回答"哪条记忆进向量库了",只能靠猜。
1114
+ if (route !== 'lexical' && !conflict && result?.ok !== false && !result?.parseWarning) {
1115
+ const active = readVectorState({ tolerateMissing: true }).state?.active
1116
+ if (active) {
1117
+ entry.vector = {
1118
+ route,
1119
+ model: active.profile?.catalogId || null,
1120
+ generation: active.generation,
1121
+ fingerprint: String(active.fingerprint || '').slice(0, 8),
1122
+ at: stamp(),
1123
+ }
1124
+ }
1125
+ }
891
1126
  // Re-read after the await: a curator job or another turn may have written in
892
1127
  // the meantime, and saving the pre-await snapshot would erase it.
893
1128
  await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
@@ -918,7 +1153,7 @@ export async function privateRemember({
918
1153
  // writes the index directly instead of going through privateRemember: the pin cap,
919
1154
  // the short-term deadline and the "no duplicate text" check would otherwise turn a
920
1155
  // faithful restore into a pile of downgrades.
921
- export async function importMemories({ libraryId, memories, maxFiles = 2000, now = Date.now() }) {
1156
+ async function importMemoriesImpl({ libraryId, memories, maxFiles = 2000, now = Date.now() }) {
922
1157
  const library = getLibrary(libraryId)
923
1158
  if (!library) throw new Error('library not found')
924
1159
  const list = Array.isArray(memories) ? memories : []
@@ -1048,7 +1283,9 @@ export async function importMemories({ libraryId, memories, maxFiles = 2000, now
1048
1283
  // committed there too or they would only show up in the browser. Best effort: a
1049
1284
  // failed commit must not roll back a restore that already passed validation.
1050
1285
  let engineWarning = null
1051
- if (getMemoryBackend() === "api") {
1286
+ const route = engineRoute()
1287
+ const managed = route === 'managed-local'
1288
+ if (route !== 'lexical') {
1052
1289
  try {
1053
1290
  await commit({
1054
1291
  payload: {
@@ -1061,9 +1298,10 @@ export async function importMemories({ libraryId, memories, maxFiles = 2000, now
1061
1298
  resource: [],
1062
1299
  user: {},
1063
1300
  },
1064
- db: memoryDbPath(libraryId),
1301
+ route, db: memoryDbPath(libraryId),
1065
1302
  })
1066
1303
  } catch (error) {
1304
+ if (managed || isVectorStateError(error)) throw error
1067
1305
  engineWarning = String(error?.message || error)
1068
1306
  }
1069
1307
  }
@@ -1078,30 +1316,30 @@ export async function importMemories({ libraryId, memories, maxFiles = 2000, now
1078
1316
  bumpStats(libraryId, 'written', entries.length)
1079
1317
  return { imported: entries.length, ids: entries.map((item) => item.id), idMap, counts, examples, engineWarning }
1080
1318
  }
1081
- export async function privateRecall({ exec, query, topK, relevanceThreshold, purpose = 'explicit' }) {
1082
- // Reads must not materialize a library; only the explicit new-session choice
1083
- // (or a user-initiated write) is allowed to create one.
1319
+ export async function privateRecall({ exec, query, topK, relevanceThreshold, purpose = 'explicit', retrieveImpl = retrieve }) {
1084
1320
  const binding = peekBinding(exec)
1085
1321
  if (!binding) throw new Error('no bound session')
1086
- // An opt-out is not a missing binding. Reporting both as "no bound session" sent
1087
- // the reader looking for a broken session when the conversation simply has no
1088
- // library to read.
1089
1322
  if (!binding.libraryId) throw new Error('this conversation has no memory library')
1090
1323
  assertScopeEnabled(binding)
1091
1324
  const settings = getSettings()
1092
- // The caller may hand in its own settings snapshot (injection carries one for the
1093
- // turn); falling back to the stored defaults keeps tool-initiated reads identical.
1094
1325
  const threshold = relevanceThreshold === undefined ? settings.relevanceThreshold : relevanceThreshold
1095
1326
  let index = loadPrivateIndex(binding.libraryId)
1096
1327
  const byId = /^(?:g?mem)[-_][a-z0-9_-]+(?:\.md)?$/i.test(String(query || '').trim())
1097
- const result = getMemoryBackend() === 'api' && !byId
1098
- ? await retrieve({ query, db: memoryDbPath(binding.libraryId) })
1099
- : localRecall(index, query)
1328
+ const route = effectiveVectorRoute(settings)
1329
+ const wanted = Number(topK || settings.privateTopK || 4)
1330
+ const result = route === 'lexical' || byId
1331
+ ? { ...localRecall(index, query), recallRoute: 'local' }
1332
+ : await managedRecall({ exec, index, query, topK: wanted, libraryId: binding.libraryId, automatic: purpose === 'automatic', intent: managedVectorIntent(), retrieveImpl, byId })
1100
1333
  const current = peekBinding(exec)
1101
1334
  if (!current || current.libraryId !== binding.libraryId) throw new Error('memory scope changed during recall; retry')
1102
1335
  assertScopeEnabled(current)
1103
1336
  index = loadPrivateIndex(binding.libraryId)
1104
1337
  const enriched = enrichFiles(result, index)
1338
+ // enrichFiles rebuilds the result object; carry the route/error metadata across
1339
+ // so buildRecall can report a degraded semantic leg and the tool can warn.
1340
+ if (result?.recallRoute) enriched.recallRoute = result.recallRoute
1341
+ if (result?.errors) enriched.errors = result.errors.slice()
1342
+ if (result?.fusion) enriched.fusion = result.fusion
1105
1343
  const span = createdAtSpan(Object.values(index.entries || {}).map((entry) => ({ entry })))
1106
1344
  const mood = moodDecay(binding.mood)
1107
1345
  const scored = privateWeighted(enriched, Number(topK || settings.privateTopK || 4), {
@@ -1133,15 +1371,24 @@ export async function privateRecall({ exec, query, topK, relevanceThreshold, pur
1133
1371
  return scored
1134
1372
  }
1135
1373
 
1136
- export async function privateList({ exec, libraryId }) {
1374
+ export async function privateList({ exec, libraryId, preferIndex = false }) {
1137
1375
  const scope = resolveLibrary({ exec, libraryId, readOnly: true })
1138
1376
  if (scope.binding) assertScopeEnabled(scope.binding)
1139
1377
  const index = loadPrivateIndex(scope.libraryId)
1140
1378
  const allowed = (entry) => !!entry && (!sessionIdOf(exec) || (isMemoryEligible(entry) && visibleToSession(entry, memorySessionId(exec))))
1141
- if (getMemoryBackend() === 'agent') {
1379
+ // UI 列表直接读索引:索引是权威,引擎只是一面镜子。走引擎意味着起 Python 和
1380
+ // embedding worker(秒级),归档/还原后刷新一下就卡住,这就是 2026-10-30 的现场。
1381
+ const route = preferIndex ? 'lexical' : engineRoute()
1382
+ // 引擎没有可用代际时列表照样能从索引给出答案,但必须说清为什么这里没有向量:
1383
+ // 静默返回"空库"曾被当成"记忆丢了"。
1384
+ const warning = !preferIndex && effectiveVectorRoute() !== 'lexical' && route === 'lexical'
1385
+ ? '本地向量未激活(只有 requested,尚未建成 active 代际);以下列表来自索引,向量库为空'
1386
+ : null
1387
+ if (route === 'lexical') {
1142
1388
  return {
1143
1389
  libraryId: scope.libraryId,
1144
1390
  source: 'index',
1391
+ warning,
1145
1392
  files: localIndexFiles(index).filter(file => allowed(file.entry)).map((file) => ({
1146
1393
  name: file.name,
1147
1394
  description: file.description,
@@ -1152,11 +1399,12 @@ export async function privateList({ exec, libraryId }) {
1152
1399
  parseWarning: null,
1153
1400
  }
1154
1401
  }
1155
- const result = await listFiles({ db: memoryDbPath(scope.libraryId) })
1402
+ const result = await listFiles({ route, db: memoryDbPath(scope.libraryId) })
1156
1403
  const files = Array.isArray(result?.recall_files) ? result.recall_files : []
1157
1404
  return {
1158
1405
  libraryId: scope.libraryId,
1159
1406
  source: 'engine',
1407
+ warning: null,
1160
1408
  files: files.filter(file => allowed(index.entries[file.name])).map((file) => ({
1161
1409
  ...file,
1162
1410
  entry: index.entries[file.name] || null,
@@ -1171,7 +1419,7 @@ export async function privateList({ exec, libraryId }) {
1171
1419
  // mood lens and the attachment list all change how an entry is scored, aggregated or
1172
1420
  // rendered, so a wrong value in any of them was a permanent mis-filing that could
1173
1421
  // only be undone by archiving the memory and writing it again.
1174
- export async function privateUpdate({ exec, libraryId, id, content, title, tags, status, openLoop, disclosure, confidence, layer, owner, visibility, source, salientDate, track, peak, moodValence, arousal, attachments, context }) {
1422
+ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, status, openLoop, disclosure, confidence, layer, owner, visibility, source, salientDate, track, peak, moodValence, arousal, attachments, context }) {
1175
1423
  // `source` is editable because it is a claim about provenance, and claims get
1176
1424
  // corrected: a memory the model first filed as `inferred` may turn out to be
1177
1425
  // something the user actually said. Enum-checked like the rest — a typo here
@@ -1205,19 +1453,21 @@ export async function privateUpdate({ exec, libraryId, id, content, title, tags,
1205
1453
  const conflict = findConflict(others, { ...entry, title: title ?? entry.title, content: nextContent })
1206
1454
  if (conflict) throw new Error(`content conflicts with ${conflict.id}; write a new memory for review`)
1207
1455
  }
1208
- if (nextContent !== undefined) {
1456
+ const route = engineRoute()
1457
+ const managed = route === 'managed-local'
1458
+ if ((nextContent !== undefined || managed) && entry.status !== 'pending_review' && status !== 'pending_review') {
1209
1459
  const payload = {
1210
1460
  recall_files: [{
1211
1461
  name: id_,
1212
1462
  track: entry.track || 'memory',
1213
1463
  description: clampText(title || entry.title || nextContent, 180),
1214
- content: nextContent,
1464
+ content: nextContent === undefined ? entry.content : nextContent,
1215
1465
  }],
1216
1466
  resource: [],
1217
1467
  user: {},
1218
1468
  }
1219
- if (getMemoryBackend() === 'api') {
1220
- await commit({ payload, db: memoryDbPath(scope.libraryId) })
1469
+ if (route !== 'lexical') {
1470
+ await commit({ payload, route, db: memoryDbPath(scope.libraryId) })
1221
1471
  }
1222
1472
  }
1223
1473
  const updated = await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
@@ -1307,7 +1557,7 @@ export async function privateUpdate({ exec, libraryId, id, content, title, tags,
1307
1557
  }
1308
1558
 
1309
1559
  /** Review activation and invalidation of the previous claim are one index commit. */
1310
- export async function resolveMemoryReview({ libraryId = null, id, action, resolution = 'replace', reason = '', content, title }) {
1560
+ async function resolveMemoryReviewImpl({ libraryId = null, id, action, resolution = 'replace', reason = '', content, title }) {
1311
1561
  if (!['accept', 'reject'].includes(action)) throw new Error('action must be accept or reject')
1312
1562
  if (!['replace', 'coexist'].includes(resolution)) throw new Error('resolution must be replace or coexist')
1313
1563
  if (resolution === 'coexist' && String(reason).trim().length < 8) throw new Error('coexist needs a reason describing the distinct conditions (at least 8 characters)')
@@ -1319,9 +1569,11 @@ export async function resolveMemoryReview({ libraryId = null, id, action, resolu
1319
1569
  if (action === 'accept') {
1320
1570
  if (typeof content === 'string' && content.trim()) next.content = content
1321
1571
  if (typeof title === 'string' && title.trim()) next.title = title
1322
- if (getMemoryBackend() === 'api') {
1572
+ const reviewRoute = engineRoute()
1573
+ if (reviewRoute !== 'lexical') {
1323
1574
  await commit({
1324
1575
  payload: { recall_files: [{ name: id, track: next.track || 'memory', description: clampText(next.title, 180), content: next.content }], resource: [], user: {} },
1576
+ route: reviewRoute,
1325
1577
  db: libraryId ? memoryDbPath(libraryId) : globalDbPath(),
1326
1578
  })
1327
1579
  }
@@ -1424,7 +1676,7 @@ export function privateArchive({ exec, libraryId, id }) {
1424
1676
  * is still in the index and no longer in the engine — visible in the browser, still
1425
1677
  * deletable, never recalled from a ghost row.
1426
1678
  */
1427
- export async function privateDelete({ exec, libraryId, id }) {
1679
+ async function privateDeleteImpl({ exec, libraryId, id }) {
1428
1680
  const scope = resolveLibrary({ exec, libraryId })
1429
1681
  if (scope.binding) assertScopeEnabled(scope.binding)
1430
1682
  const index = loadPrivateIndex(scope.libraryId)
@@ -1435,10 +1687,10 @@ export async function privateDelete({ exec, libraryId, id }) {
1435
1687
  if (!['archived', 'suppressed', 'expired'].includes(entry.status)) {
1436
1688
  throw new Error('only archived memories can be deleted permanently; archive it first')
1437
1689
  }
1438
- const backend = getMemoryBackend()
1690
+ const backend = engineRoute()
1439
1691
  let engine = null
1440
- if (backend === 'api') {
1441
- engine = await forget({ db: memoryDbPath(scope.libraryId), name: id_ })
1692
+ if (backend !== 'lexical') {
1693
+ engine = await forget({ route: backend, db: memoryDbPath(scope.libraryId), name: id_ })
1442
1694
  }
1443
1695
  await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
1444
1696
  if (JSON.stringify(fresh.entries[id_]) !== JSON.stringify(entry)) throw new Error('memory changed during deletion; reload and retry')
@@ -1507,6 +1759,78 @@ export function privateUnpin({ exec, id, reason }) {
1507
1759
  return { id: resolvedId, entry: updated.entries[resolvedId], remaining: Math.max(0, budget - used - 1) }
1508
1760
  }
1509
1761
 
1762
+ export async function privateRemember(args) {
1763
+ const libraryId = resolveLibrary(args).libraryId
1764
+ const result = await withManagedPrivateWrite(libraryId, () => privateRememberImpl(args))
1765
+ record({
1766
+ kind: 'write',
1767
+ scope: 'private',
1768
+ sessionId: sessionIdOf(args.exec) || null,
1769
+ libraryId,
1770
+ message: '写入 ' + (result?.id || '?') + ':' + clampText(args.title || args.content, 60) + (result?.conflict ? '(待复核)' : ''),
1771
+ detail: { id: result?.id || null, title: args.title || null, layer: args.layer || null, conflict: result?.conflict || null },
1772
+ refs: result?.id ? [result.id] : [],
1773
+ })
1774
+ return result
1775
+ }
1776
+
1777
+ export async function privateUpdate(args) {
1778
+ const libraryId = resolveLibrary(args).libraryId
1779
+ const result = await withManagedPrivateWrite(libraryId, () => privateUpdateImpl(args))
1780
+ record({
1781
+ kind: 'write',
1782
+ scope: 'private',
1783
+ sessionId: sessionIdOf(args.exec) || null,
1784
+ libraryId,
1785
+ message: '更新 ' + (result?.id || args.id || '?') + (args.content ? ':' + clampText(args.content, 60) : ''),
1786
+ detail: { id: result?.id || args.id || null, fields: Object.keys(args).filter(key => key !== 'exec' && key !== 'id') },
1787
+ refs: result?.id || args.id ? [result?.id || args.id] : [],
1788
+ })
1789
+ return result
1790
+ }
1791
+
1792
+ export async function privateDelete(args) {
1793
+ const libraryId = resolveLibrary(args).libraryId
1794
+ const result = await withManagedPrivateWrite(libraryId, () => privateDeleteImpl(args))
1795
+ record({
1796
+ kind: 'write',
1797
+ scope: 'private',
1798
+ sessionId: sessionIdOf(args.exec) || null,
1799
+ libraryId,
1800
+ message: '删除 ' + (args.id || '?'),
1801
+ detail: { id: args.id || null, deleted: result?.deleted !== false },
1802
+ refs: args.id ? [args.id] : [],
1803
+ })
1804
+ return result
1805
+ }
1806
+
1807
+ export async function importMemories(args) {
1808
+ const result = await withManagedPrivateWrite(args.libraryId, () => importMemoriesImpl(args))
1809
+ record({
1810
+ kind: 'write',
1811
+ scope: 'private',
1812
+ libraryId: args.libraryId || null,
1813
+ message: '导入 ' + (result?.imported ?? 0) + ' 条' + (result?.engineWarning ? '(引擎警告:' + clampText(result.engineWarning, 80) + ')' : ''),
1814
+ detail: { imported: result?.imported ?? 0, counts: result?.counts || null, engineWarning: result?.engineWarning || null },
1815
+ refs: Array.isArray(result?.ids) ? result.ids.slice(0, 20) : [],
1816
+ })
1817
+ return result
1818
+ }
1819
+
1820
+ export async function resolveMemoryReview(args) {
1821
+ const action = () => resolveMemoryReviewImpl(args)
1822
+ const result = args.libraryId ? await withManagedPrivateWrite(args.libraryId, action) : await withManagedGlobalWrite(action)
1823
+ record({
1824
+ kind: 'write',
1825
+ scope: args.libraryId ? 'private' : 'global',
1826
+ libraryId: args.libraryId || null,
1827
+ message: '复核 ' + (args.id || '?') + ' → ' + (args.action || '?'),
1828
+ detail: { id: args.id || null, action: args.action || null, status: result?.status || null },
1829
+ refs: args.id ? [args.id] : [],
1830
+ })
1831
+ return result
1832
+ }
1833
+
1510
1834
  export function appliesToDomain(entry, binding) {
1511
1835
  const domain = domainOf(binding)
1512
1836
  if (!entry) return domain === 'general'
@@ -1523,7 +1847,7 @@ export function appliesToDomain(entry, binding) {
1523
1847
  return false
1524
1848
  }
1525
1849
 
1526
- export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
1850
+ export async function globalRecall({ exec, query, topK, relevanceThreshold, retrieveImpl = retrieve }) {
1527
1851
  // peek, not ensure: injection has already bound any live session before a tool
1528
1852
  // can run, so a missing binding here means a read from an unbound conversation.
1529
1853
  // That fallback quietly narrows results to the `general` domain, so it is
@@ -1536,8 +1860,12 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
1536
1860
  const settings = getSettings()
1537
1861
  const threshold = relevanceThreshold === undefined ? settings.relevanceThreshold : relevanceThreshold
1538
1862
  let index = loadGlobalIndex()
1539
- const result = getMemoryBackend() === 'api'
1540
- ? await retrieve({ query, db: globalDbPath() })
1863
+ const intent = managedVectorIntent()
1864
+ const domainIndex = { ...index, entries: Object.fromEntries(Object.entries(index.entries || {})
1865
+ .filter(([, entry]) => appliesToDomain(entry, binding))) }
1866
+ const result = effectiveVectorRoute() !== 'lexical'
1867
+ ? await managedRecall({ exec, index: domainIndex, query, topK: topK || settings.globalTopK || 3,
1868
+ libraryId: null, automatic: true, intent, retrieveImpl })
1541
1869
  : localRecall(index, query, (file) => appliesToDomain(file.entry, binding))
1542
1870
  if (peeked) {
1543
1871
  const current = peekBinding(exec)
@@ -1546,6 +1874,9 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
1546
1874
  }
1547
1875
  index = loadGlobalIndex()
1548
1876
  const enriched = enrichFiles(result, index)
1877
+ if (result?.recallRoute) enriched.recallRoute = result.recallRoute
1878
+ if (result?.errors) enriched.errors = result.errors.slice()
1879
+ if (result?.fusion) enriched.fusion = result.fusion
1549
1880
  const filtered = enriched.files
1550
1881
  .filter((f) => isMemoryEligible(f.entry, { automatic: true }))
1551
1882
  .filter((f) => appliesToDomain(f.entry, binding))
@@ -1554,7 +1885,7 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
1554
1885
  // Local lexical rank is already meaningful; confidence still nudges ties, and
1555
1886
  // a cross-session fact that has been injected three turns running gets the same
1556
1887
  // wear penalty as a private one.
1557
- score: (f.lexical > 0 ? f.lexical : 1 / (i + 1))
1888
+ score: (Number.isFinite(f.retrievalScore) ? f.retrievalScore : f.lexical > 0 ? f.lexical : 1 / (i + 1))
1558
1889
  * (0.85 + 0.15 * Number(f.entry.confidence ?? 0.5))
1559
1890
  * habituationFactor(f.entry),
1560
1891
  }))
@@ -1567,7 +1898,7 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
1567
1898
  }
1568
1899
  }
1569
1900
 
1570
- export async function globalList({ exec, domain }) {
1901
+ export async function globalList({ exec, domain, preferIndex = false }) {
1571
1902
  const binding = peekBinding(exec)
1572
1903
  if (binding) assertScopeEnabled(binding, { layer: 'global' })
1573
1904
  const index = loadGlobalIndex()
@@ -1575,11 +1906,19 @@ export async function globalList({ exec, domain }) {
1575
1906
  // "Optional domain filter", so a caller asking for one domain must not get all.
1576
1907
  const domainFilter = (file) => (!domain || (file.entry?.domain || 'unknown') === domain)
1577
1908
  && (!sessionIdOf(exec) || (isMemoryEligible(file.entry) && appliesToDomain(file.entry, binding || { domain: 'general' })))
1578
- if (getMemoryBackend() === 'agent') {
1909
+ // 与 privateList 同一条口径:受管路由还没有 active 代际时,引擎必然抛
1910
+ // "managed local vector profile is not active";列表本身其实能答,就按索引答,
1911
+ // 并把原因带上。否则全局记忆页会闪一下红字再消失。
1912
+ const route = preferIndex ? 'lexical' : engineRoute()
1913
+ const warning = !preferIndex && effectiveVectorRoute() !== 'lexical' && route === 'lexical'
1914
+ ? '本地向量未激活(只有 requested,尚未建成 active 代际);以下列表来自索引,向量库为空'
1915
+ : null
1916
+ if (route === 'lexical') {
1579
1917
  const files = localIndexFiles(index)
1580
1918
  return {
1581
1919
  domain: domain || (binding ? domainOf(binding) : 'general'),
1582
1920
  source: 'index',
1921
+ warning,
1583
1922
  files: (domainFilter ? files.filter(domainFilter) : files).map((file) => ({
1584
1923
  name: file.name,
1585
1924
  description: file.description,
@@ -1590,7 +1929,7 @@ export async function globalList({ exec, domain }) {
1590
1929
  parseWarning: null,
1591
1930
  }
1592
1931
  }
1593
- const result = await listFiles({ db: globalDbPath() })
1932
+ const result = await listFiles({ route, db: globalDbPath() })
1594
1933
  const files = Array.isArray(result?.recall_files) ? result.recall_files : []
1595
1934
  const enriched = files.map((file) => ({
1596
1935
  ...file,
@@ -1600,6 +1939,7 @@ export async function globalList({ exec, domain }) {
1600
1939
  return {
1601
1940
  domain: domain || (binding ? domainOf(binding) : 'general'),
1602
1941
  source: 'engine',
1942
+ warning: null,
1603
1943
  files: domainFilter ? enriched.filter(domainFilter) : enriched,
1604
1944
  raw: result?.raw,
1605
1945
  parseWarning: result?.parseWarning,