dsh-memu-memory 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +50 -5
  3. package/README.zh.md +49 -5
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +6 -4
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +13 -0
  9. package/docs/host-api.md +35 -3
  10. package/docs/memory-semantics.md +40 -11
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +395 -366
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +190 -0
  15. package/lib/backup.js +1103 -0
  16. package/lib/binding-diagnostics.js +635 -0
  17. package/lib/client.js +728 -58
  18. package/lib/consolidation-ledger.js +347 -0
  19. package/lib/consolidation-receipt.js +284 -0
  20. package/lib/consolidation-snapshot.js +274 -0
  21. package/lib/consolidation-worker.js +115 -0
  22. package/lib/curator.js +81 -37
  23. package/lib/cycle.js +43 -6
  24. package/lib/embedding-catalog.js +763 -0
  25. package/lib/embedding-download.js +407 -0
  26. package/lib/embedding-runtime.js +710 -0
  27. package/lib/engine.js +2 -2
  28. package/lib/index.js +36 -0
  29. package/lib/inject.js +232 -103
  30. package/lib/local-embedding-entry.js +145 -0
  31. package/lib/local-embedding-manager.js +267 -0
  32. package/lib/local-embedding-protocol.js +173 -0
  33. package/lib/local-embedding-worker.js +652 -0
  34. package/lib/local-vector-service.js +283 -0
  35. package/lib/memory.js +599 -57
  36. package/lib/memu-config.js +10 -22
  37. package/lib/memu.js +53 -19
  38. package/lib/mood.js +268 -39
  39. package/lib/paths.js +24 -0
  40. package/lib/preset-scope.js +1 -1
  41. package/lib/private.js +450 -29
  42. package/lib/prompts/private-consolidation.coding.md +1 -0
  43. package/lib/prompts/private-consolidation.md +1 -0
  44. package/lib/prompts/private-consolidation.mixed.md +1 -0
  45. package/lib/prompts/private-consolidation.rp.md +1 -0
  46. package/lib/prompts.js +4 -4
  47. package/lib/remote-summary.js +51 -25
  48. package/lib/rpc.js +54 -5
  49. package/lib/state.js +137 -172
  50. package/lib/tools.js +58 -7
  51. package/lib/vector-migration.js +878 -0
  52. package/lib/vector-profile.js +968 -0
  53. package/package.json +4 -2
  54. package/scripts/acceptance-local.mjs +7 -5
  55. package/scripts/active-memory-ab-live.mjs +655 -0
  56. package/scripts/active-memory-ab.mjs +1302 -0
  57. package/scripts/chain-matrix.mjs +537 -0
  58. package/scripts/check-all.mjs +29 -1
  59. package/scripts/check-doc-drift.mjs +353 -0
  60. package/scripts/diagnose-bindings.mjs +176 -0
  61. package/scripts/local-runtime-install.mjs +142 -0
  62. package/scripts/local-runtime-verify.mjs +56 -0
  63. package/scripts/local-vector-verify.mjs +93 -0
  64. package/scripts/recall-bench.mjs +813 -0
  65. package/scripts/repetition-ab-scenarios.mjs +93 -0
  66. package/scripts/repetition-detector-snapshot.mjs +314 -0
  67. package/scripts/repetition-eval-live.mjs +360 -0
  68. package/scripts/repetition-eval.mjs +702 -0
  69. package/tests/activation.test.mjs +89 -0
  70. package/tests/active-memory-ab.test.mjs +745 -0
  71. package/tests/active-memory.test.mjs +129 -0
  72. package/tests/activity-log.test.mjs +126 -0
  73. package/tests/activity-wiring.test.mjs +103 -0
  74. package/tests/appendix-b.test.mjs +212 -0
  75. package/tests/appendix-b2.test.mjs +166 -0
  76. package/tests/audit-regression.test.mjs +272 -0
  77. package/tests/audit-safety.test.mjs +227 -0
  78. package/tests/backend-compensation.test.mjs +272 -0
  79. package/tests/backup.test.mjs +793 -0
  80. package/tests/binding-diagnostics.test.mjs +720 -0
  81. package/tests/browser.test.mjs +366 -0
  82. package/tests/client-render.test.mjs +892 -0
  83. package/tests/coalesce.test.mjs +113 -0
  84. package/tests/config.test.mjs +159 -0
  85. package/tests/consolidation-credential.test.mjs +320 -0
  86. package/tests/consolidation-fork.test.mjs +357 -0
  87. package/tests/consolidation-ledger.test.mjs +298 -0
  88. package/tests/consolidation-snapshot.test.mjs +321 -0
  89. package/tests/consolidation-value.test.mjs +89 -0
  90. package/tests/context-scope.test.mjs +40 -0
  91. package/tests/cue.test.mjs +44 -0
  92. package/tests/curator-probe.test.mjs +433 -0
  93. package/tests/curator.test.mjs +150 -0
  94. package/tests/cycle.test.mjs +411 -0
  95. package/tests/disclosure.test.mjs +231 -0
  96. package/tests/doc-drift.test.mjs +222 -0
  97. package/tests/embedding-catalog.test.mjs +266 -0
  98. package/tests/embedding-download.test.mjs +391 -0
  99. package/tests/embedding-runtime.test.mjs +389 -0
  100. package/tests/emotion-chain.test.mjs +84 -0
  101. package/tests/emotion-contract.test.mjs +445 -0
  102. package/tests/engine.test.mjs +35 -0
  103. package/tests/fixtures/active-memory-ab/README.md +56 -0
  104. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  105. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  106. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  107. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  108. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  109. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  110. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  111. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  112. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  113. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  114. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  115. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  116. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  117. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  118. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  119. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  120. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  121. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  122. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  123. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  124. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  125. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  126. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  127. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  128. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  129. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  130. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  131. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  132. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  133. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  134. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  135. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  136. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  137. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  138. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  139. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  140. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  141. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  142. package/tests/graph-view.test.mjs +187 -0
  143. package/tests/graph.test.mjs +77 -0
  144. package/tests/hook-order.test.mjs +72 -0
  145. package/tests/humanlike.test.mjs +232 -0
  146. package/tests/hybrid-recall.test.mjs +92 -0
  147. package/tests/inbound.test.mjs +393 -0
  148. package/tests/inject-guide.test.mjs +364 -0
  149. package/tests/inject.test.mjs +222 -0
  150. package/tests/injection-budget-settings.test.mjs +35 -0
  151. package/tests/injection-budget.test.mjs +64 -0
  152. package/tests/injection-chain.test.mjs +191 -0
  153. package/tests/loader-register.mjs +3 -0
  154. package/tests/loader.mjs +12 -0
  155. package/tests/local-embedding-entry.test.mjs +95 -0
  156. package/tests/local-embedding-manager.test.mjs +476 -0
  157. package/tests/local-embedding-protocol.test.mjs +95 -0
  158. package/tests/local-embedding-worker.test.mjs +346 -0
  159. package/tests/managed-memory.test.mjs +536 -0
  160. package/tests/managed-runtime.test.mjs +40 -0
  161. package/tests/media.test.mjs +132 -0
  162. package/tests/migration-payload.test.mjs +42 -0
  163. package/tests/mood.test.mjs +109 -0
  164. package/tests/multiprocess.test.mjs +29 -0
  165. package/tests/new-session-gate.test.mjs +361 -0
  166. package/tests/package-shipping.test.mjs +84 -0
  167. package/tests/preset-scope.test.mjs +212 -0
  168. package/tests/prompts.test.mjs +250 -0
  169. package/tests/react-snapshot.test.mjs +23 -0
  170. package/tests/recall-bench.test.mjs +306 -0
  171. package/tests/remote.test.mjs +140 -0
  172. package/tests/rename.test.mjs +30 -0
  173. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  174. package/tests/repetition-eval-live.test.mjs +177 -0
  175. package/tests/repetition-eval.test.mjs +373 -0
  176. package/tests/salient-date.test.mjs +142 -0
  177. package/tests/settings-beat.test.mjs +221 -0
  178. package/tests/short-term.test.mjs +166 -0
  179. package/tests/sidebar-beat.test.mjs +208 -0
  180. package/tests/sidebar-refresh.test.mjs +123 -0
  181. package/tests/sidebar.test.mjs +70 -0
  182. package/tests/snippet.test.mjs +47 -0
  183. package/tests/source-kind.test.mjs +52 -0
  184. package/tests/state.test.mjs +150 -0
  185. package/tests/stats.test.mjs +112 -0
  186. package/tests/storage.test.mjs +125 -0
  187. package/tests/stubs/dsh-llm.js +5 -0
  188. package/tests/stubs/dsh-session.js +7 -0
  189. package/tests/stubs/dsh-tools.js +101 -0
  190. package/tests/tool-schema.test.mjs +73 -0
  191. package/tests/tools.test.mjs +184 -0
  192. package/tests/ui-defaults.test.mjs +57 -0
  193. package/tests/update-fields.test.mjs +138 -0
  194. package/tests/vector-migration.test.mjs +733 -0
  195. package/tests/vector-profile.test.mjs +425 -0
  196. package/tests/visibility-migration.test.mjs +152 -0
  197. package/tests/webui-auth.test.mjs +454 -0
  198. package/tests/webui.test.mjs +164 -0
  199. package/tests/windows-env.test.mjs +89 -0
package/lib/memory.js CHANGED
@@ -6,16 +6,19 @@ 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'
13
- import { BASE_AROUSAL, moodCongruence, moodDecay } from './mood.js'
12
+ import { stamp, redactSecrets, retryStoreWrite, warn } from './paths.js'
13
+ import { scopeGenerationOf } from './consolidation-ledger.js'
14
+ import { record } from './activity-log.js'
15
+ import { BASE_AROUSAL, derivedValence, dominanceBias, emotionActive, maxShare, moodCongruence, moodDecay, normalizeEmotion, overlap, readMood } from './mood.js'
14
16
  import { commit, retrieve, listFiles, forget } from './memu.js'
15
17
  import { bumpStats } from './stats.js'
16
18
  import { recallRelations } from './graph.js'
17
19
  import { attachmentRefs } from './media.js'
18
20
  import { normalizeMemoryContext, contextVisible, sameFactContext } from './context.js'
21
+ import { assertGeneration, assertVectorWriteAllowed, captureGeneration, enrollActiveLibrary, hasActiveVectorGeneration, isVectorStateError, readVectorState, withVectorWrite } from './vector-profile.js'
19
22
 
20
23
  const HALF_LIFE_DAYS = 14
21
24
  // A short-term entry is meant to live for the length of a conversation, not a
@@ -35,6 +38,14 @@ const HABITUATION_FLOOR = 0.5
35
38
  // library with three variants of the same decision would spend the whole block on it.
36
39
  // The top hit is always kept; later picks pay lambda x max-similarity-to-what-is-in.
37
40
  const MMR_LAMBDA = 0.72
41
+ // Semantic fusion uses RRF: each route contributes a
42
+ // rank term and the merged score is normalised to the best fused hit before the
43
+ // existing mood/strength/MMR pipeline sees it. The candidate cap keeps a long
44
+ // library from turning every turn into two full scans.
45
+ const HYBRID_RRF_K = 60
46
+ const HYBRID_CANDIDATE_MIN = 12
47
+ const HYBRID_CANDIDATE_MAX = 40
48
+ const HYBRID_SEMANTIC_TIMEOUT_MS = 15_000
38
49
  const LAYERS = ['long_term', 'short_term', 'must_remember']
39
50
  // These three are read back by the injector to decide how a memory may be spoken and
40
51
  // where it may appear. An unchecked typo would silently mean "no suffix at all", so
@@ -51,6 +62,141 @@ const VISIBILITIES = STORED_VISIBILITIES
51
62
  // quote, which systematically inflated confidence in things nobody had said.
52
63
  const SOURCES = ['user_said', 'inferred', 'imagined', 'told']
53
64
 
65
+ function managedVectorIntent() {
66
+ if (effectiveVectorRoute() !== 'managed-local') return { managed: false, route: effectiveVectorRoute(), state: null, error: null }
67
+ try {
68
+ const state = readVectorState({ tolerateMissing: true }).state
69
+ return { managed: true, route: 'managed-local', state, error: null }
70
+ } catch (error) {
71
+ return { managed: true, route: 'managed-local', state: null, error }
72
+ }
73
+ }
74
+
75
+ // 引擎调用用的路由。受管路由被选中但还没有任何代际时,MemU 的 envFor 会直接拒绝
76
+ // managed-local("managed local vector profile is not active"),写入/删除/列举都会连带
77
+ // 失败。这时引擎这一步按词法处理:索引照常写,等下一次迁移把历史补进向量库。
78
+ // 召回仍按原路由走,好让侧边栏继续显示降级原因,而不是悄悄退回词法。
79
+ function engineRoute() {
80
+ const route = effectiveVectorRoute()
81
+ return route === 'managed-local' && !hasActiveVectorGeneration() ? 'lexical' : route
82
+ }
83
+
84
+ function rememberRecallError(exec, error) {
85
+ const message = String(error?.message || error)
86
+ warn('managed vector recall degraded to lexical: ' + message)
87
+ const sessionId = sessionIdOf(exec)
88
+ if (sessionId) setRecallError(sessionId, message)
89
+ return message
90
+ }
91
+
92
+ function clearRecallError(exec) {
93
+ const sessionId = sessionIdOf(exec)
94
+ if (sessionId) setRecallError(sessionId, null)
95
+ }
96
+
97
+ // Serialize backend-bearing private writes across every route in this module.
98
+ // Acquire this queue BEFORE the vector lease; neither waits while holding a
99
+ // synchronous file lock. This is not a cross-process/backend transaction.
100
+ const privateWriteTails = new Map()
101
+ async function withManagedPrivateWrite(libraryId, action) {
102
+ const previous = privateWriteTails.get(libraryId) || Promise.resolve()
103
+ const running = previous.then(() => withManagedPrivateWriteImpl(libraryId, action))
104
+ const settled = running.then(() => {}, () => {})
105
+ privateWriteTails.set(libraryId, settled)
106
+ try { return await running } finally {
107
+ if (privateWriteTails.get(libraryId) === settled) privateWriteTails.delete(libraryId)
108
+ }
109
+ }
110
+
111
+ function assertCompensationResult(result, operation) {
112
+ if (!result || typeof result !== 'object' || Array.isArray(result) || result.ok === false || result.error || result.parseWarning
113
+ || (operation === 'backend compensation delete' && (!Number.isSafeInteger(result.deleted) || result.deleted < 0))) {
114
+ throw new Error(operation + ' was not acknowledged')
115
+ }
116
+ }
117
+
118
+ // The index remains authoritative. Refuse destructive repair if another index
119
+ // writer replaced the expected pre-await entry; otherwise rebuild that old row.
120
+ // No transaction spans these external calls: crashes/other processes can still
121
+ // interrupt compensation. Never describe an uncertain repair as success.
122
+ async function compensatePrivateCommit(error, { libraryId, id, route, db, expectedEntry }) {
123
+ try {
124
+ const current = loadPrivateIndex(libraryId).entries[id]
125
+ if (JSON.stringify(current ?? null) !== JSON.stringify(expectedEntry ?? null)) {
126
+ throw new Error('memory changed before backend compensation')
127
+ }
128
+ const snapshot = JSON.stringify(current)
129
+ assertCompensationResult(await forget({ route, db, name: id }), 'backend compensation delete')
130
+ if (current && !['pending_review', 'superseded'].includes(current.status)) {
131
+ assertCompensationResult(await commit({ route, db, payload: {
132
+ recall_files: [{ name: id, track: current.track || 'memory', description: clampText(current.title, 180), content: current.content }],
133
+ resource: [], user: {},
134
+ } }), 'backend compensation restore')
135
+ }
136
+ if (JSON.stringify(loadPrivateIndex(libraryId).entries[id]) !== snapshot) {
137
+ throw new Error('memory changed during backend compensation')
138
+ }
139
+ } catch (compensationError) {
140
+ const failure = new Error('private memory index write failed; backend compensation uncertain: ' + String(compensationError?.message || compensationError), { cause: error })
141
+ failure.code = 'PRIVATE_BACKEND_COMPENSATION_FAILED'
142
+ failure.compensationError = compensationError
143
+ warn(failure.message)
144
+ throw failure
145
+ }
146
+ throw error
147
+ }
148
+
149
+ async function withManagedPrivateWriteImpl(libraryId, action) {
150
+ const intent = managedVectorIntent()
151
+ if (!intent.managed) return action()
152
+ // 选了本地向量、但下载完模型后从没跑过迁移(vector-active 只有 requested):这里
153
+ // 没有可对齐的代际,写入必须降级成索引 + 引擎,而不是把整次整理写死。召回侧一直是
154
+ // 这个口径(local-fallback),写入侧漏了同样的降级 —— 结果就是整理跑完一条没写进来。
155
+ if (!hasActiveVectorGeneration()) {
156
+ warn('managed vector write degraded to index+engine: no active vector generation; run the profile switch to enable vectors')
157
+ record({ kind: 'vector', level: 'warn', libraryId, message: '向量未激活,写入已降级为索引+引擎', detail: { reason: 'no active vector generation' } })
158
+ return action()
159
+ }
160
+ return withVectorWrite(async () => {
161
+ assertVectorWriteAllowed()
162
+ enrollActiveLibrary(libraryId)
163
+ return action()
164
+ })
165
+ }
166
+
167
+ async function withManagedGlobalWrite(action) {
168
+ const intent = managedVectorIntent()
169
+ if (!intent.managed) return action()
170
+ if (!hasActiveVectorGeneration()) {
171
+ warn('managed vector write degraded to index+engine: no active vector generation; run the profile switch to enable vectors')
172
+ record({ kind: 'vector', level: 'warn', scope: 'global', message: '向量未激活,全局写入已降级为索引+引擎', detail: { reason: 'no active vector generation' } })
173
+ return action()
174
+ }
175
+ return withVectorWrite(() => { assertVectorWriteAllowed(); return action() })
176
+ }
177
+
178
+ async function managedRecall({ exec, index, query, topK, libraryId, automatic = false, intent, retrieveImpl, byId = false }) {
179
+ const local = localRecall(index, query)
180
+ try {
181
+ if (intent.error) throw intent.error
182
+ const captured = intent.route === 'managed-local' ? captureGeneration() : null
183
+ if (byId) return { ...local, recallRoute: 'local' }
184
+ const db = captured ? (libraryId ? captured.libraries[libraryId] : captured.global) : (libraryId ? memoryDbPath(libraryId) : globalDbPath())
185
+ if (!db) throw new Error('managed vector generation has no database for library ' + libraryId)
186
+ const semantic = await retrieveImpl({ query, db, route: intent.route, timeoutMs: HYBRID_SEMANTIC_TIMEOUT_MS })
187
+ if (semantic?.parseWarning) throw new Error(semantic.parseWarning)
188
+ if (!Array.isArray(semantic?.files)) throw new Error('managed vector recall returned no file list')
189
+ if (captured) assertGeneration(captured)
190
+ const fused = fuseRecallCandidates({ local: local.files, semantic: semantic.files, index,
191
+ limit: hybridCandidateLimit(topK), automatic, sessionId: memorySessionId(exec), route: 'managed-vector-rrf' })
192
+ clearRecallError(exec)
193
+ return { ...semantic, files: fused.files, recallRoute: 'managed-vector-rrf', fusion: fused.debug }
194
+ } catch (error) {
195
+ const reason = rememberRecallError(exec, error)
196
+ return { ...local, recallRoute: 'local-fallback', errors: ['semantic recall failed: ' + reason] }
197
+ }
198
+ }
199
+
54
200
  function checkEnums({ disclosure, owner, visibility, source }) {
55
201
  if (disclosure !== undefined && !DISCLOSURES.includes(disclosure)) {
56
202
  throw new Error(`disclosure must be ${DISCLOSURES.join(', ')}`)
@@ -126,6 +272,7 @@ export function resolveLibrary({ exec, libraryId, readOnly = false }) {
126
272
  }
127
273
  const binding = readOnly ? peekBinding(exec) : bindingFor(exec)
128
274
  if (!binding) throw new Error('no bound session')
275
+ assertConsolidationParent(binding)
129
276
  // A binding without a library means the conversation explicitly opted out of
130
277
  // private memory. Writing through it used to land in `index/null.json` — a
131
278
  // "successful" write that can never be recalled. Refuse with an actionable
@@ -139,8 +286,18 @@ export function resolveLibrary({ exec, libraryId, readOnly = false }) {
139
286
  // The sidebar switches (and the folder ceiling behind them) must constrain tool
140
287
  // calls too, not just injection. One gate, sourced from `effectiveScope` so the
141
288
  // folder policy is always part of the answer.
289
+ function assertConsolidationParent(binding) {
290
+ if (!binding?.consolidationFork) return
291
+ const parent = getSessionBinding(binding.parentSessionId)
292
+ if (!parent || !binding.parentScopeGeneration || scopeGenerationOf(parent) !== binding.parentScopeGeneration) {
293
+ throw new Error('DSH_MEMU_SCOPE_STALE: consolidation worker parent authorization changed')
294
+ }
295
+ }
296
+
142
297
  function assertScopeEnabled(binding, { layer = 'library' } = {}) {
143
298
  if (!binding) return
299
+ assertConsolidationParent(binding)
300
+ if (binding.consolidationFork && layer === 'global') throw new Error('DSH_MEMU_WORKER_RESTRICTED: private worker cannot access global memory')
144
301
  const scope = effectiveScope(binding)
145
302
  if (layer === 'library' && !scope.useLibrary) {
146
303
  throw new Error(scope.libraryBlocked
@@ -237,6 +394,22 @@ const RELATIVE_DAY_OFFSETS = [
237
394
  [/tomorrow/i, 1], [/明天|明日/, 1], [/tonight/i, 0], [/today/i, 0], [/今天|今晚/, 0],
238
395
  ]
239
396
 
397
+ /**
398
+ * 从标题/正文里取唯一的显式日期,作为"模型忘了填 salient_date"的兜底。
399
+ * 只接受全篇唯一的 YYYY-MM-DD;出现多个候选就不猜(宁可为空,也不把日志里的日期
400
+ * 误装成纪念日)。相对日期不在这里解析,交给 reconcileSalientDate。
401
+ */
402
+ export function deriveSalientDateFromText(title, content) {
403
+ const dates = new Set()
404
+ for (const text of [title, content]) {
405
+ for (const match of String(text || '').matchAll(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/g)) {
406
+ const normalized = normalizeSalientDate(`${match[1]}-${match[2]}-${match[3]}`)
407
+ if (normalized && isDatedSalientDate(normalized)) dates.add(normalized)
408
+ }
409
+ }
410
+ return dates.size === 1 ? [...dates][0] : null
411
+ }
412
+
240
413
  export function reconcileSalientDate(value, text, now = new Date()) {
241
414
  if (!SALIENT_SHORT.test(String(value || ''))) return value
242
415
  const body = String(text || '')
@@ -559,6 +732,108 @@ function localRecall(index, query, filter = () => true) {
559
732
  }
560
733
  }
561
734
 
735
+ /** The per-route candidate cap for a requested topK; bounded on both ends. */
736
+ export function hybridCandidateLimit(topK) {
737
+ const wanted = Math.max(1, Number(topK) || 1)
738
+ return Math.max(HYBRID_CANDIDATE_MIN, Math.min(HYBRID_CANDIDATE_MAX, wanted * 4))
739
+ }
740
+
741
+ /**
742
+ * Reciprocal-rank fusion of lexical index hits and MemU vector hits.
743
+ *
744
+ * Filtering happens before the per-route cap and before fusion: a pending review,
745
+ * an out-of-context entry or an automatic secret must not occupy a candidate slot
746
+ * and then be dropped later. Ids are canonicalised against the live index so the
747
+ * two routes cannot return the same memory twice under a prefix or a .md suffix.
748
+ *
749
+ * The returned retrievalScore is the normalised RRF share (best fused hit = 1).
750
+ * recallScore consumes that value instead of recomputing 1/(rank+1), so the
751
+ * fusion order is not silently overwritten by the old rank decay.
752
+ */
753
+ export function fuseRecallCandidates({ local = [], semantic = [], index, limit = HYBRID_CANDIDATE_MIN, automatic = false, sessionId = null, now = Date.now(), route = 'vector-rrf' } = {}) {
754
+ const entries = index?.entries || {}
755
+ const canonical = (ref) => {
756
+ const raw = String(ref || '').trim()
757
+ if (!raw) return null
758
+ try { return resolveEntryId(index, raw) } catch {
759
+ const clean = raw.replace(/\.md$/i, '')
760
+ return entries[clean] ? clean : null
761
+ }
762
+ }
763
+ const eligible = (file, route, rank) => {
764
+ const id = canonical(file?.name)
765
+ if (!id) return null
766
+ const entry = entries[id] || file?.entry || null
767
+ if (!entry) return null
768
+ if (!isMemoryEligible(entry, { automatic, now })) return null
769
+ if (!visibleToSession(entry, sessionId)) return null
770
+ if (automatic && entry.visibility === 'secret') return null
771
+ return { id, entry, file, route, rank }
772
+ }
773
+ const take = (files, route) => {
774
+ const picked = []
775
+ // One route can name the same canonical entry more than once: a caller may
776
+ // pass both `mem-x` and `mem-x.md`, and an engine can repeat a hit. Dedupe by
777
+ // canonical id before scoring and truncating, keeping the first (best) rank.
778
+ // Otherwise every duplicate adds another 1/(k+rank) and consumes a slot.
779
+ const seen = new Set()
780
+ for (const file of Array.isArray(files) ? files : []) {
781
+ const item = eligible(file, route, picked.length + 1)
782
+ if (!item || seen.has(item.id)) continue
783
+ seen.add(item.id)
784
+ picked.push(item)
785
+ if (picked.length >= limit) break
786
+ }
787
+ return picked
788
+ }
789
+ const localPicked = take(local, 'local')
790
+ const semanticPicked = take(semantic, 'semantic')
791
+ const merged = new Map()
792
+ const add = (item) => {
793
+ const current = merged.get(item.id) || { id: item.id, entry: item.entry, file: item.file, rrf: 0, bestRank: Infinity, localRank: null, semanticRank: null, routes: [] }
794
+ current.rrf += 1 / (HYBRID_RRF_K + item.rank)
795
+ current.bestRank = Math.min(current.bestRank, item.rank)
796
+ if (item.route === 'local') current.localRank = item.rank
797
+ else current.semanticRank = item.rank
798
+ if (!current.routes.includes(item.route)) current.routes.push(item.route)
799
+ // Prefer the local object when both routes carry the entry: it already has the
800
+ // lexical score and the index-backed content used below by enrichFiles.
801
+ if (item.route === 'local') current.file = item.file
802
+ merged.set(item.id, current)
803
+ }
804
+ for (const item of localPicked) add(item)
805
+ for (const item of semanticPicked) add(item)
806
+ const rows = [...merged.values()]
807
+ const maxRrf = rows.reduce((max, row) => Math.max(max, row.rrf), 0) || 1
808
+ const files = rows
809
+ .map((row) => ({
810
+ ...row.file,
811
+ name: row.id,
812
+ entry: row.entry,
813
+ retrievalScore: row.rrf / maxRrf,
814
+ fusion: { rrf: row.rrf, bestRank: row.bestRank, localRank: row.localRank, semanticRank: row.semanticRank, routes: row.routes.slice() },
815
+ }))
816
+ .sort((a, b) => b.retrievalScore - a.retrievalScore
817
+ || a.fusion.bestRank - b.fusion.bestRank
818
+ || a.name.localeCompare(b.name))
819
+ const localIds = new Set(localPicked.map((item) => item.id))
820
+ const semanticIds = new Set(semanticPicked.map((item) => item.id))
821
+ return {
822
+ files,
823
+ debug: {
824
+ route,
825
+ k: HYBRID_RRF_K,
826
+ candidateLimit: limit,
827
+ localCandidates: localPicked.length,
828
+ semanticCandidates: semanticPicked.length,
829
+ fused: files.length,
830
+ localOnly: [...localIds].filter((id) => !semanticIds.has(id)).length,
831
+ semanticOnly: [...semanticIds].filter((id) => !localIds.has(id)).length,
832
+ shared: [...localIds].filter((id) => semanticIds.has(id)).length,
833
+ },
834
+ }
835
+ }
836
+
562
837
  // Human-like recall ordering. Retrieval answers first (semantic match is still
563
838
  // the primary signal, which is why it stays a rank decay), then these biases
564
839
  // re-order within that set:
@@ -568,15 +843,25 @@ function localRecall(index, query, filter = () => true) {
568
843
  // Every factor is bounded so no bias can outrank a good semantic hit by luck.
569
844
  function recallScore(file, rank, context) {
570
845
  const entry = file.entry || {}
571
- const retrieval = 1 / (rank + 1)
846
+ // A fused file carries its own normalised RRF share. Falling back to the old
847
+ // rank decay keeps every lexical caller byte-for-byte compatible.
848
+ const fused = Number(file.retrievalScore)
849
+ const retrieval = Number.isFinite(fused) ? Math.max(0, Math.min(1, fused)) : 1 / (rank + 1)
572
850
  const strengthTerm = 0.6 + 0.4 * file.strength
573
851
  const marginal = entry.marginalized ? 0.35 : 1
574
852
 
575
853
  // Fading affect bias: what pulls is the tint the memory still carries today, not
576
854
  // the one it was written with. Old grievances quieten down; old fondness does not.
577
- const moodBias = context.moodEnabled
578
- ? 1 + 0.3 * moodCongruence(fadedValence(entry, context.now), context.moodValence)
579
- : 1
855
+ //
856
+ // Two paths, one decision: when both sides carry a composition, compare the
857
+ // compositions (how much of the present shape the memory shares). Otherwise fall
858
+ // back to the legacy signed-valence congruence. Entries written before this model
859
+ // existed have no composition and must keep working unchanged.
860
+ const moodBias = !context.moodEnabled
861
+ ? 1
862
+ : (context.emotion && entry.emotion
863
+ ? 1 + 0.3 * affectFadeFactor(entry, context.now) * (2 * overlap(entry.emotion, context.emotion) - 1)
864
+ : 1 + 0.3 * moodCongruence(fadedValence(entry, context.now), context.moodValence))
580
865
  const peak = entry.peak === true ? 1 : Math.max(0, Number(entry.arousal || 0))
581
866
  const end = context.lastCreatedAt && file.createdAtIdentity === context.lastCreatedAt ? 1 : 0
582
867
  const peakEnd = 1 + 0.25 * Math.max(peak, end)
@@ -588,13 +873,33 @@ function recallScore(file, rank, context) {
588
873
  // this asks "does its loudness match the room right now?" — different questions that
589
874
  // only look like duplicates.
590
875
  const arousalTerm = context.moodEnabled ? arousalBias(entry, context) : 1
876
+ // Dominance asks a third question: who was steering. Two equally sad memories pull
877
+ // differently depending on whether the present state is "I can fix this" or "nothing
878
+ // I do matters". Bounded to +-10% so it can only reorder near-ties.
879
+ const dominanceTerm = context.moodEnabled ? dominanceBias(entry.dominance, context.dominance) : 1
591
880
  // An open loop is an unfinished thread — a promise, a question, a plan nobody
592
881
  // closed. Those are what a person brings up unprompted, so they get a nudge and not
593
882
  // a trump card: 1.25 still loses to a clearly better semantic hit.
594
883
  const openLoopBias = entry.openLoop ? 1.25 : 1
595
884
  const habituation = habituationFactor(entry, context.now)
596
885
 
597
- return retrieval * strengthTerm * marginal * moodBias * peakEnd * primacy * openLoopBias * habituation * arousalTerm
886
+ return retrieval * strengthTerm * marginal * moodBias * peakEnd * primacy * openLoopBias * habituation * arousalTerm * dominanceTerm
887
+ }
888
+
889
+ /**
890
+ * How much of a memory's original tint is still there, 0..1.
891
+ *
892
+ * Same curve as `fadedValence`, but returned as a bare factor so the composition path
893
+ * applies the *fading* to the whole comparison instead of decaying the shares (shares
894
+ * sum to 1, so decaying them proportionally would change nothing).
895
+ */
896
+ function affectFadeFactor(entry, now = Date.now()) {
897
+ const valence = entry.emotion ? derivedValence(entry.emotion) : Number(entry.moodValence || 0)
898
+ if (!valence) return 0
899
+ const updated = Date.parse(entry.updatedAt || entry.createdAt || '') || now
900
+ const days = Math.max(0, (now - updated) / 86_400_000)
901
+ const halfLife = valence < 0 ? FAB_HALF_LIFE_DAYS.negative : FAB_HALF_LIFE_DAYS.positive
902
+ return Math.pow(0.5, days / halfLife)
598
903
  }
599
904
 
600
905
  /**
@@ -608,9 +913,12 @@ function recallScore(file, rank, context) {
608
913
  function arousalBias(entry, context) {
609
914
  const present = Number(context.arousal == null ? BASE_AROUSAL : context.arousal)
610
915
  const drive = Math.max(-0.35, Math.min(0.65, present - BASE_AROUSAL))
916
+ // A composed entry can be *very* angry while its legacy valence projection looks
917
+ // mild, so the largest share counts as intensity alongside the stored arousal.
611
918
  const intensity = Math.max(
612
919
  entry.peak === true ? 1 : 0,
613
920
  Math.min(1, Math.max(0, Number(entry.arousal || 0))),
921
+ Math.min(1, maxShare(entry.emotion)),
614
922
  Math.min(1, Math.abs(Number(entry.moodValence || 0)) * 0.8),
615
923
  )
616
924
  return 1 + 0.5 * drive * (intensity - 0.5)
@@ -644,7 +952,9 @@ function privateWeighted(result, max, context = {}) {
644
952
  const ctx = {
645
953
  moodEnabled: false,
646
954
  moodValence: 0,
955
+ emotion: null,
647
956
  arousal: BASE_AROUSAL,
957
+ dominance: 0,
648
958
  firstCreatedAt: null,
649
959
  lastCreatedAt: null,
650
960
  sessionId: null,
@@ -738,11 +1048,66 @@ function createdAtSpan(files) {
738
1048
  return { first: stamps[0] || null, last: stamps[stamps.length - 1] || null }
739
1049
  }
740
1050
 
741
- export async function privateRemember({
1051
+ /**
1052
+ * Decide what an entry's affect actually is, and which tier answered.
1053
+ *
1054
+ * Order: the model said it; the conversation is in a mood and inheritance is allowed;
1055
+ * the memory's own text reads as emotional; nothing. `emotionSource` keeps "neutral"
1056
+ * and "no idea" distinguishable — they drive the same recall multiplier, but only one
1057
+ * of them means the write path worked.
1058
+ *
1059
+ * Engineering facts stay neutral on purpose. A bug being hard to fix is not the
1060
+ * project feeling sad, and letting the turn's mood leak into a technical entry is how
1061
+ * "the deployment failed and we were furious" becomes a fact about the deployment.
1062
+ */
1063
+ function resolveEntryEmotion({ emotion, dominance, emotionSource, owner, content, binding }) {
1064
+ const providedDominance = Number(dominance)
1065
+ const explicitDominance = dominance !== null && dominance !== undefined && Number.isFinite(providedDominance)
1066
+ ? Math.max(-1, Math.min(1, providedDominance))
1067
+ : null
1068
+ // Three states, and they are not the same thing: omitted = inherit (a caller that
1069
+ // says nothing wants the turn's context), null = explicitly neutral (the model looked
1070
+ // and found nothing), an object = the model's reading. Collapsing null into "omitted"
1071
+ // would silently reinstate inherited mood on a memory the model called neutral.
1072
+ const explicitNeutral = emotion === null
1073
+ const fromModel = emotion !== undefined && emotion !== null
1074
+ let resolved = null
1075
+ let sourceName = 'unknown'
1076
+ let inheritedDominance = null
1077
+ if (fromModel) {
1078
+ resolved = normalizeEmotion(emotion)
1079
+ sourceName = 'model'
1080
+ } else if (!explicitNeutral && owner !== 'about_project') {
1081
+ const sessionMood = binding && binding.mood ? binding.mood : null
1082
+ if (sessionMood && sessionMood.emotion) {
1083
+ resolved = normalizeEmotion(sessionMood.emotion)
1084
+ sourceName = resolved ? 'session' : 'unknown'
1085
+ const sessionDominance = Number(sessionMood.dominance)
1086
+ if (Number.isFinite(sessionDominance)) inheritedDominance = Math.max(-1, Math.min(1, sessionDominance))
1087
+ }
1088
+ if (resolved === null && sourceName === 'unknown') {
1089
+ const read = readMood(content)
1090
+ if (read.emotion) {
1091
+ resolved = read.emotion
1092
+ sourceName = 'content'
1093
+ inheritedDominance = read.dominance
1094
+ }
1095
+ }
1096
+ }
1097
+ return {
1098
+ emotion: resolved,
1099
+ dominance: explicitDominance === null ? (inheritedDominance === null ? 0 : inheritedDominance) : explicitDominance,
1100
+ source: explicitNeutral ? 'model' : (emotionSource || sourceName),
1101
+ neutral: explicitNeutral,
1102
+ }
1103
+ }
1104
+
1105
+ async function privateRememberImpl({
742
1106
  exec, libraryId, content, title, layer, pinReason = null, pinSource = null,
743
1107
  sideEventId = null, tags = [], openLoop = false, disclosure = 'can_tell',
744
1108
  owner = 'me', visibility = 'shared', source = 'inferred', confidence = 0.8,
745
1109
  moodValence = null, arousal = null, peak = false, salientDate = null,
1110
+ emotion = undefined, dominance = undefined, emotionSource = null,
746
1111
  attachments, track = 'memory',
747
1112
  context,
748
1113
  }) {
@@ -771,8 +1136,14 @@ export async function privateRemember({
771
1136
  // An unreadable date is refused instead of dropped. Dropping it looked like
772
1137
  // success to the caller while losing the only part of the memory it asked for:
773
1138
  // 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()) {
1139
+ let normalizedSalientDate = reconcileSalientDate(normalizeSalientDate(salientDate), `${title || ''} ${content || ''}`)
1140
+ // 模型常把日期写进正文却漏填 salient_date(详情页日期栏因此为空)。只有显式传了
1141
+ // 非法值才报错;什么都没传时,从文本取唯一的 YYYY-MM-DD 作为兜底。
1142
+ const explicitDate = salientDate !== null && salientDate !== undefined && String(salientDate).trim()
1143
+ if (normalizedSalientDate === null && !explicitDate) {
1144
+ normalizedSalientDate = deriveSalientDateFromText(title, content)
1145
+ }
1146
+ if (normalizedSalientDate === null && explicitDate) {
776
1147
  throw new Error('salient_date must be MM-DD (recurs every year) or YYYY-MM-DD (one specific day)')
777
1148
  }
778
1149
  content = redactSecrets(content)
@@ -816,6 +1187,12 @@ export async function privateRemember({
816
1187
  && (item.attachments || []).some(attachment => media.some(candidate => candidate.sha256 === attachment.sha256)))
817
1188
  : null
818
1189
  const id = previousHabit?.[0] || newMemoryId('mem')
1190
+ // A malformed share is refused here rather than clamped later: a negative or
1191
+ // non-finite composition is a caller bug, and repairing it silently would keep the
1192
+ // bug alive while the entry looks fine.
1193
+ if (emotion !== null && emotion !== undefined) normalizeEmotion(emotion)
1194
+ if (dominance !== null && dominance !== undefined && !Number.isFinite(Number(dominance))) throw new Error('dominance must be a number between -1 and 1')
1195
+ const resolvedAffect = resolveEntryEmotion({ emotion, dominance, owner, content: String(content), binding })
819
1196
  const entry = {
820
1197
  ...(previousHabit?.[1] || {}),
821
1198
  track,
@@ -848,9 +1225,18 @@ export async function privateRemember({
848
1225
  // Retrieval lens + resurface metadata. When the agent does not state a mood,
849
1226
  // the current session read is stored instead, so a memory written during a
850
1227
  // hard conversation is tinted by it without the model having to name it.
851
- moodValence: moodValence === null || moodValence === undefined
852
- ? (binding?.mood ? Number(binding.mood.valence) || 0 : 0)
853
- : Math.max(-1, Math.min(1, Number(moodValence) || 0)),
1228
+ // `moodValence` stays as the legacy projection of the four shares; the shares
1229
+ // themselves are what the new recall path compares.
1230
+ moodValence: resolvedAffect.emotion
1231
+ ? derivedValence(resolvedAffect.emotion)
1232
+ : (resolvedAffect.neutral
1233
+ ? 0
1234
+ : (moodValence === null || moodValence === undefined
1235
+ ? (binding?.mood ? Number(binding.mood.valence) || 0 : 0)
1236
+ : Math.max(-1, Math.min(1, Number(moodValence) || 0)))),
1237
+ emotion: resolvedAffect.emotion,
1238
+ dominance: resolvedAffect.dominance,
1239
+ emotionSource: resolvedAffect.source,
854
1240
  arousal: arousal === null || arousal === undefined
855
1241
  ? (binding?.mood ? Number(binding.mood.arousal) || 0.35 : 0.35)
856
1242
  : Math.max(0, Math.min(1, Number(arousal) || 0)),
@@ -885,9 +1271,26 @@ export async function privateRemember({
885
1271
  // A held (pending_review) claim stays index-only: committing it to MemU now
886
1272
  // would make a contested memory retrievable before anyone decided it. Accepting
887
1273
  // 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 }
1274
+ const route = engineRoute()
1275
+ const db = route !== 'lexical' && !conflict ? memoryDbPath(scope.libraryId) : null
1276
+ const result = db
1277
+ ? await commit({ payload, route, db })
1278
+ : { ok: true, backend: route, held: conflict ? true : undefined }
1279
+ try {
1280
+ // 记下"这条进没进向量库":只有真的带代际提交成功才写,冲突暂扣/降级都不写。
1281
+ // 之前没有任何字段能回答"哪条记忆进向量库了",只能靠猜。
1282
+ if (route !== 'lexical' && !conflict && result?.ok !== false && !result?.parseWarning) {
1283
+ const active = readVectorState({ tolerateMissing: true }).state?.active
1284
+ if (active) {
1285
+ entry.vector = {
1286
+ route,
1287
+ model: active.profile?.catalogId || null,
1288
+ generation: active.generation,
1289
+ fingerprint: String(active.fingerprint || '').slice(0, 8),
1290
+ at: stamp(),
1291
+ }
1292
+ }
1293
+ }
891
1294
  // Re-read after the await: a curator job or another turn may have written in
892
1295
  // the meantime, and saving the pre-await snapshot would erase it.
893
1296
  await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
@@ -897,6 +1300,7 @@ export async function privateRemember({
897
1300
  if (previousHabit && JSON.stringify(fresh.entries[id]) !== JSON.stringify(previousHabit[1])) throw new Error('habit changed concurrently; recall and retry')
898
1301
  const latestConflict = findConflict(Object.fromEntries(Object.entries(fresh.entries).filter(([key]) => key !== id)), entry)
899
1302
  if (latestConflict && !conflict) {
1303
+ if (db) throw new Error('memory conflict changed during write; retry for review')
900
1304
  entry.status = 'pending_review'
901
1305
  entry.conflictingWith = latestConflict.id
902
1306
  entry.reviewReason = 'concurrent conflicting memory; review required'
@@ -907,6 +1311,10 @@ export async function privateRemember({
907
1311
  }
908
1312
  fresh.entries[id] = entry
909
1313
  }))
1314
+ } catch (error) {
1315
+ if (db) await compensatePrivateCommit(error, { libraryId: scope.libraryId, id, route, db, expectedEntry: previousHabit?.[1] || null })
1316
+ throw error
1317
+ }
910
1318
  if (!previousHabit) bumpStats(scope.libraryId, 'written')
911
1319
  if (layer === 'must_remember' && pinSource === 'side_event') {
912
1320
  consumeCue(sessionIdOf(exec), sideEventId)
@@ -918,7 +1326,7 @@ export async function privateRemember({
918
1326
  // writes the index directly instead of going through privateRemember: the pin cap,
919
1327
  // the short-term deadline and the "no duplicate text" check would otherwise turn a
920
1328
  // faithful restore into a pile of downgrades.
921
- export async function importMemories({ libraryId, memories, maxFiles = 2000, now = Date.now() }) {
1329
+ async function importMemoriesImpl({ libraryId, memories, maxFiles = 2000, now = Date.now() }) {
922
1330
  const library = getLibrary(libraryId)
923
1331
  if (!library) throw new Error('library not found')
924
1332
  const list = Array.isArray(memories) ? memories : []
@@ -1048,7 +1456,9 @@ export async function importMemories({ libraryId, memories, maxFiles = 2000, now
1048
1456
  // committed there too or they would only show up in the browser. Best effort: a
1049
1457
  // failed commit must not roll back a restore that already passed validation.
1050
1458
  let engineWarning = null
1051
- if (getMemoryBackend() === "api") {
1459
+ const route = engineRoute()
1460
+ const managed = route === 'managed-local'
1461
+ if (route !== 'lexical') {
1052
1462
  try {
1053
1463
  await commit({
1054
1464
  payload: {
@@ -1061,9 +1471,10 @@ export async function importMemories({ libraryId, memories, maxFiles = 2000, now
1061
1471
  resource: [],
1062
1472
  user: {},
1063
1473
  },
1064
- db: memoryDbPath(libraryId),
1474
+ route, db: memoryDbPath(libraryId),
1065
1475
  })
1066
1476
  } catch (error) {
1477
+ if (managed || isVectorStateError(error)) throw error
1067
1478
  engineWarning = String(error?.message || error)
1068
1479
  }
1069
1480
  }
@@ -1078,38 +1489,41 @@ export async function importMemories({ libraryId, memories, maxFiles = 2000, now
1078
1489
  bumpStats(libraryId, 'written', entries.length)
1079
1490
  return { imported: entries.length, ids: entries.map((item) => item.id), idMap, counts, examples, engineWarning }
1080
1491
  }
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.
1492
+ export async function privateRecall({ exec, query, topK, relevanceThreshold, purpose = 'explicit', retrieveImpl = retrieve }) {
1084
1493
  const binding = peekBinding(exec)
1085
1494
  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
1495
  if (!binding.libraryId) throw new Error('this conversation has no memory library')
1090
1496
  assertScopeEnabled(binding)
1091
1497
  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
1498
  const threshold = relevanceThreshold === undefined ? settings.relevanceThreshold : relevanceThreshold
1095
1499
  let index = loadPrivateIndex(binding.libraryId)
1096
1500
  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)
1501
+ const route = effectiveVectorRoute(settings)
1502
+ const wanted = Number(topK || settings.privateTopK || 4)
1503
+ const result = route === 'lexical' || byId
1504
+ ? { ...localRecall(index, query), recallRoute: 'local' }
1505
+ : await managedRecall({ exec, index, query, topK: wanted, libraryId: binding.libraryId, automatic: purpose === 'automatic', intent: managedVectorIntent(), retrieveImpl, byId })
1100
1506
  const current = peekBinding(exec)
1101
1507
  if (!current || current.libraryId !== binding.libraryId) throw new Error('memory scope changed during recall; retry')
1102
1508
  assertScopeEnabled(current)
1103
1509
  index = loadPrivateIndex(binding.libraryId)
1104
1510
  const enriched = enrichFiles(result, index)
1511
+ // enrichFiles rebuilds the result object; carry the route/error metadata across
1512
+ // so buildRecall can report a degraded semantic leg and the tool can warn.
1513
+ if (result?.recallRoute) enriched.recallRoute = result.recallRoute
1514
+ if (result?.errors) enriched.errors = result.errors.slice()
1515
+ if (result?.fusion) enriched.fusion = result.fusion
1105
1516
  const span = createdAtSpan(Object.values(index.entries || {}).map((entry) => ({ entry })))
1106
1517
  const mood = moodDecay(binding.mood)
1107
1518
  const scored = privateWeighted(enriched, Number(topK || settings.privateTopK || 4), {
1108
- // Arousal is used even when valence is near neutral: a tense room with no clear
1109
- // good/bad reading still changes which memories come up.
1110
- moodEnabled: Math.abs(mood.valence) >= MOOD_MIN_STRENGTH || Math.abs(mood.arousal - BASE_AROUSAL) >= 0.1,
1519
+ // Three independent legs: a clear composition, an arousal away from rest, or a
1520
+ // clear sense of control. A null composition only closes the first one — a tense
1521
+ // room with no clear good/bad reading still changes which memories come up.
1522
+ moodEnabled: emotionActive({ emotion: mood.emotion, arousal: mood.arousal, dominance: mood.dominance }),
1111
1523
  moodValence: mood.valence,
1524
+ emotion: mood.emotion,
1112
1525
  arousal: mood.arousal,
1526
+ dominance: mood.dominance,
1113
1527
  firstCreatedAt: span.first,
1114
1528
  lastCreatedAt: span.last,
1115
1529
  relevanceThreshold: threshold,
@@ -1133,15 +1547,24 @@ export async function privateRecall({ exec, query, topK, relevanceThreshold, pur
1133
1547
  return scored
1134
1548
  }
1135
1549
 
1136
- export async function privateList({ exec, libraryId }) {
1550
+ export async function privateList({ exec, libraryId, preferIndex = false }) {
1137
1551
  const scope = resolveLibrary({ exec, libraryId, readOnly: true })
1138
1552
  if (scope.binding) assertScopeEnabled(scope.binding)
1139
1553
  const index = loadPrivateIndex(scope.libraryId)
1140
1554
  const allowed = (entry) => !!entry && (!sessionIdOf(exec) || (isMemoryEligible(entry) && visibleToSession(entry, memorySessionId(exec))))
1141
- if (getMemoryBackend() === 'agent') {
1555
+ // UI 列表直接读索引:索引是权威,引擎只是一面镜子。走引擎意味着起 Python 和
1556
+ // embedding worker(秒级),归档/还原后刷新一下就卡住,这就是 2026-10-30 的现场。
1557
+ const route = preferIndex ? 'lexical' : engineRoute()
1558
+ // 引擎没有可用代际时列表照样能从索引给出答案,但必须说清为什么这里没有向量:
1559
+ // 静默返回"空库"曾被当成"记忆丢了"。
1560
+ const warning = !preferIndex && effectiveVectorRoute() !== 'lexical' && route === 'lexical'
1561
+ ? '本地向量未激活(只有 requested,尚未建成 active 代际);以下列表来自索引,向量库为空'
1562
+ : null
1563
+ if (route === 'lexical') {
1142
1564
  return {
1143
1565
  libraryId: scope.libraryId,
1144
1566
  source: 'index',
1567
+ warning,
1145
1568
  files: localIndexFiles(index).filter(file => allowed(file.entry)).map((file) => ({
1146
1569
  name: file.name,
1147
1570
  description: file.description,
@@ -1152,11 +1575,12 @@ export async function privateList({ exec, libraryId }) {
1152
1575
  parseWarning: null,
1153
1576
  }
1154
1577
  }
1155
- const result = await listFiles({ db: memoryDbPath(scope.libraryId) })
1578
+ const result = await listFiles({ route, db: memoryDbPath(scope.libraryId) })
1156
1579
  const files = Array.isArray(result?.recall_files) ? result.recall_files : []
1157
1580
  return {
1158
1581
  libraryId: scope.libraryId,
1159
1582
  source: 'engine',
1583
+ warning: null,
1160
1584
  files: files.filter(file => allowed(index.entries[file.name])).map((file) => ({
1161
1585
  ...file,
1162
1586
  entry: index.entries[file.name] || null,
@@ -1171,7 +1595,7 @@ export async function privateList({ exec, libraryId }) {
1171
1595
  // mood lens and the attachment list all change how an entry is scored, aggregated or
1172
1596
  // rendered, so a wrong value in any of them was a permanent mis-filing that could
1173
1597
  // 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 }) {
1598
+ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, status, openLoop, disclosure, confidence, layer, owner, visibility, source, salientDate, track, peak, moodValence, arousal, emotion, dominance, attachments, context }) {
1175
1599
  // `source` is editable because it is a claim about provenance, and claims get
1176
1600
  // corrected: a memory the model first filed as `inferred` may turn out to be
1177
1601
  // something the user actually said. Enum-checked like the rest — a typo here
@@ -1193,6 +1617,8 @@ export async function privateUpdate({ exec, libraryId, id, content, title, tags,
1193
1617
  if (confidence !== undefined && !Number.isFinite(Number(confidence))) throw new Error('confidence must be a number between 0 and 1')
1194
1618
  if (moodValence !== undefined && !Number.isFinite(Number(moodValence))) throw new Error('moodValence must be a number between -1 and 1')
1195
1619
  if (arousal !== undefined && !Number.isFinite(Number(arousal))) throw new Error('arousal must be a number between 0 and 1')
1620
+ if (emotion !== undefined && emotion !== null) normalizeEmotion(emotion)
1621
+ if (dominance !== undefined && dominance !== null && !Number.isFinite(Number(dominance))) throw new Error('dominance must be a number between -1 and 1')
1196
1622
  // Throws for anything the library never received: an attachment list you cannot
1197
1623
  // correct would leave a mis-filed image attached forever.
1198
1624
  const nextAttachments = attachments === undefined ? undefined : attachmentRefs(scope.libraryId, attachments)
@@ -1205,22 +1631,29 @@ export async function privateUpdate({ exec, libraryId, id, content, title, tags,
1205
1631
  const conflict = findConflict(others, { ...entry, title: title ?? entry.title, content: nextContent })
1206
1632
  if (conflict) throw new Error(`content conflicts with ${conflict.id}; write a new memory for review`)
1207
1633
  }
1208
- if (nextContent !== undefined) {
1634
+ const route = engineRoute()
1635
+ const managed = route === 'managed-local'
1636
+ let committedDb = null
1637
+ if ((nextContent !== undefined || managed) && entry.status !== 'pending_review' && status !== 'pending_review') {
1209
1638
  const payload = {
1210
1639
  recall_files: [{
1211
1640
  name: id_,
1212
1641
  track: entry.track || 'memory',
1213
1642
  description: clampText(title || entry.title || nextContent, 180),
1214
- content: nextContent,
1643
+ content: nextContent === undefined ? entry.content : nextContent,
1215
1644
  }],
1216
1645
  resource: [],
1217
1646
  user: {},
1218
1647
  }
1219
- if (getMemoryBackend() === 'api') {
1220
- await commit({ payload, db: memoryDbPath(scope.libraryId) })
1648
+ if (route !== 'lexical') {
1649
+ const db = memoryDbPath(scope.libraryId)
1650
+ await commit({ payload, route, db })
1651
+ committedDb = db
1221
1652
  }
1222
1653
  }
1223
- const updated = await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
1654
+ let updated
1655
+ try {
1656
+ updated = await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
1224
1657
  const target = fresh.entries[id_]
1225
1658
  if (!target) throw new Error(`unknown memory: ${id_}`)
1226
1659
  if (JSON.stringify(target) !== JSON.stringify(entry)) throw new Error('memory changed concurrently; reload and retry')
@@ -1263,6 +1696,21 @@ export async function privateUpdate({ exec, libraryId, id, content, title, tags,
1263
1696
  if (peak !== undefined) target.peak = peak === true
1264
1697
  if (moodValence !== undefined) target.moodValence = Math.max(-1, Math.min(1, Number(moodValence)))
1265
1698
  if (arousal !== undefined) target.arousal = Math.max(0, Math.min(1, Number(arousal)))
1699
+ // Three states, not two: omitted keeps the stored composition, an explicit null
1700
+ // clears it (and the source becomes "we no longer know"), an object replaces it.
1701
+ // Collapsing null into "keep" would make it impossible to repair a wrong reading.
1702
+ if (emotion !== undefined) {
1703
+ if (emotion === null) {
1704
+ target.emotion = null
1705
+ target.emotionSource = 'unknown'
1706
+ target.moodValence = Number(moodValence) || 0
1707
+ } else {
1708
+ target.emotion = normalizeEmotion(emotion)
1709
+ target.emotionSource = 'model'
1710
+ target.moodValence = derivedValence(target.emotion)
1711
+ }
1712
+ }
1713
+ if (dominance !== undefined) target.dominance = dominance === null ? 0 : Math.max(-1, Math.min(1, Number(dominance)))
1266
1714
  if (nextAttachments !== undefined) target.attachments = nextAttachments
1267
1715
  // Vividness and accuracy are derived, not typed in: correcting the peak flag or
1268
1716
  // the arousal reading has to move `accuracy` too, otherwise the repair would only
@@ -1302,12 +1750,16 @@ export async function privateUpdate({ exec, libraryId, id, content, title, tags,
1302
1750
  }
1303
1751
  target.updatedAt = stamp()
1304
1752
  }))
1753
+ } catch (error) {
1754
+ if (committedDb) await compensatePrivateCommit(error, { libraryId: scope.libraryId, id: id_, route, db: committedDb, expectedEntry: entry })
1755
+ throw error
1756
+ }
1305
1757
  if (status === 'archived' && !wasArchived) bumpStats(scope.libraryId, 'archived')
1306
1758
  return { id: id_, entry: updated.entries[id_] }
1307
1759
  }
1308
1760
 
1309
1761
  /** 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 }) {
1762
+ async function resolveMemoryReviewImpl({ libraryId = null, id, action, resolution = 'replace', reason = '', content, title }) {
1311
1763
  if (!['accept', 'reject'].includes(action)) throw new Error('action must be accept or reject')
1312
1764
  if (!['replace', 'coexist'].includes(resolution)) throw new Error('resolution must be replace or coexist')
1313
1765
  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 +1771,11 @@ export async function resolveMemoryReview({ libraryId = null, id, action, resolu
1319
1771
  if (action === 'accept') {
1320
1772
  if (typeof content === 'string' && content.trim()) next.content = content
1321
1773
  if (typeof title === 'string' && title.trim()) next.title = title
1322
- if (getMemoryBackend() === 'api') {
1774
+ const reviewRoute = engineRoute()
1775
+ if (reviewRoute !== 'lexical') {
1323
1776
  await commit({
1324
1777
  payload: { recall_files: [{ name: id, track: next.track || 'memory', description: clampText(next.title, 180), content: next.content }], resource: [], user: {} },
1778
+ route: reviewRoute,
1325
1779
  db: libraryId ? memoryDbPath(libraryId) : globalDbPath(),
1326
1780
  })
1327
1781
  }
@@ -1424,7 +1878,7 @@ export function privateArchive({ exec, libraryId, id }) {
1424
1878
  * is still in the index and no longer in the engine — visible in the browser, still
1425
1879
  * deletable, never recalled from a ghost row.
1426
1880
  */
1427
- export async function privateDelete({ exec, libraryId, id }) {
1881
+ async function privateDeleteImpl({ exec, libraryId, id }) {
1428
1882
  const scope = resolveLibrary({ exec, libraryId })
1429
1883
  if (scope.binding) assertScopeEnabled(scope.binding)
1430
1884
  const index = loadPrivateIndex(scope.libraryId)
@@ -1435,10 +1889,10 @@ export async function privateDelete({ exec, libraryId, id }) {
1435
1889
  if (!['archived', 'suppressed', 'expired'].includes(entry.status)) {
1436
1890
  throw new Error('only archived memories can be deleted permanently; archive it first')
1437
1891
  }
1438
- const backend = getMemoryBackend()
1892
+ const backend = engineRoute()
1439
1893
  let engine = null
1440
- if (backend === 'api') {
1441
- engine = await forget({ db: memoryDbPath(scope.libraryId), name: id_ })
1894
+ if (backend !== 'lexical') {
1895
+ engine = await forget({ route: backend, db: memoryDbPath(scope.libraryId), name: id_ })
1442
1896
  }
1443
1897
  await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
1444
1898
  if (JSON.stringify(fresh.entries[id_]) !== JSON.stringify(entry)) throw new Error('memory changed during deletion; reload and retry')
@@ -1507,6 +1961,78 @@ export function privateUnpin({ exec, id, reason }) {
1507
1961
  return { id: resolvedId, entry: updated.entries[resolvedId], remaining: Math.max(0, budget - used - 1) }
1508
1962
  }
1509
1963
 
1964
+ export async function privateRemember(args) {
1965
+ const libraryId = resolveLibrary(args).libraryId
1966
+ const result = await withManagedPrivateWrite(libraryId, () => privateRememberImpl({ ...args, libraryId }))
1967
+ record({
1968
+ kind: 'write',
1969
+ scope: 'private',
1970
+ sessionId: sessionIdOf(args.exec) || null,
1971
+ libraryId,
1972
+ message: '写入 ' + (result?.id || '?') + ':' + clampText(args.title || args.content, 60) + (result?.conflict ? '(待复核)' : ''),
1973
+ detail: { id: result?.id || null, title: args.title || null, layer: args.layer || null, conflict: result?.conflict || null },
1974
+ refs: result?.id ? [result.id] : [],
1975
+ })
1976
+ return result
1977
+ }
1978
+
1979
+ export async function privateUpdate(args) {
1980
+ const libraryId = resolveLibrary(args).libraryId
1981
+ const result = await withManagedPrivateWrite(libraryId, () => privateUpdateImpl({ ...args, libraryId }))
1982
+ record({
1983
+ kind: 'write',
1984
+ scope: 'private',
1985
+ sessionId: sessionIdOf(args.exec) || null,
1986
+ libraryId,
1987
+ message: '更新 ' + (result?.id || args.id || '?') + (args.content ? ':' + clampText(args.content, 60) : ''),
1988
+ detail: { id: result?.id || args.id || null, fields: Object.keys(args).filter(key => key !== 'exec' && key !== 'id') },
1989
+ refs: result?.id || args.id ? [result?.id || args.id] : [],
1990
+ })
1991
+ return result
1992
+ }
1993
+
1994
+ export async function privateDelete(args) {
1995
+ const libraryId = resolveLibrary(args).libraryId
1996
+ const result = await withManagedPrivateWrite(libraryId, () => privateDeleteImpl({ ...args, libraryId }))
1997
+ record({
1998
+ kind: 'write',
1999
+ scope: 'private',
2000
+ sessionId: sessionIdOf(args.exec) || null,
2001
+ libraryId,
2002
+ message: '删除 ' + (args.id || '?'),
2003
+ detail: { id: args.id || null, deleted: result?.deleted !== false },
2004
+ refs: args.id ? [args.id] : [],
2005
+ })
2006
+ return result
2007
+ }
2008
+
2009
+ export async function importMemories(args) {
2010
+ const result = await withManagedPrivateWrite(args.libraryId, () => importMemoriesImpl(args))
2011
+ record({
2012
+ kind: 'write',
2013
+ scope: 'private',
2014
+ libraryId: args.libraryId || null,
2015
+ message: '导入 ' + (result?.imported ?? 0) + ' 条' + (result?.engineWarning ? '(引擎警告:' + clampText(result.engineWarning, 80) + ')' : ''),
2016
+ detail: { imported: result?.imported ?? 0, counts: result?.counts || null, engineWarning: result?.engineWarning || null },
2017
+ refs: Array.isArray(result?.ids) ? result.ids.slice(0, 20) : [],
2018
+ })
2019
+ return result
2020
+ }
2021
+
2022
+ export async function resolveMemoryReview(args) {
2023
+ const action = () => resolveMemoryReviewImpl(args)
2024
+ const result = args.libraryId ? await withManagedPrivateWrite(args.libraryId, action) : await withManagedGlobalWrite(action)
2025
+ record({
2026
+ kind: 'write',
2027
+ scope: args.libraryId ? 'private' : 'global',
2028
+ libraryId: args.libraryId || null,
2029
+ message: '复核 ' + (args.id || '?') + ' → ' + (args.action || '?'),
2030
+ detail: { id: args.id || null, action: args.action || null, status: result?.status || null },
2031
+ refs: args.id ? [args.id] : [],
2032
+ })
2033
+ return result
2034
+ }
2035
+
1510
2036
  export function appliesToDomain(entry, binding) {
1511
2037
  const domain = domainOf(binding)
1512
2038
  if (!entry) return domain === 'general'
@@ -1523,7 +2049,7 @@ export function appliesToDomain(entry, binding) {
1523
2049
  return false
1524
2050
  }
1525
2051
 
1526
- export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
2052
+ export async function globalRecall({ exec, query, topK, relevanceThreshold, retrieveImpl = retrieve }) {
1527
2053
  // peek, not ensure: injection has already bound any live session before a tool
1528
2054
  // can run, so a missing binding here means a read from an unbound conversation.
1529
2055
  // That fallback quietly narrows results to the `general` domain, so it is
@@ -1536,8 +2062,12 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
1536
2062
  const settings = getSettings()
1537
2063
  const threshold = relevanceThreshold === undefined ? settings.relevanceThreshold : relevanceThreshold
1538
2064
  let index = loadGlobalIndex()
1539
- const result = getMemoryBackend() === 'api'
1540
- ? await retrieve({ query, db: globalDbPath() })
2065
+ const intent = managedVectorIntent()
2066
+ const domainIndex = { ...index, entries: Object.fromEntries(Object.entries(index.entries || {})
2067
+ .filter(([, entry]) => appliesToDomain(entry, binding))) }
2068
+ const result = effectiveVectorRoute() !== 'lexical'
2069
+ ? await managedRecall({ exec, index: domainIndex, query, topK: topK || settings.globalTopK || 3,
2070
+ libraryId: null, automatic: true, intent, retrieveImpl })
1541
2071
  : localRecall(index, query, (file) => appliesToDomain(file.entry, binding))
1542
2072
  if (peeked) {
1543
2073
  const current = peekBinding(exec)
@@ -1546,6 +2076,9 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
1546
2076
  }
1547
2077
  index = loadGlobalIndex()
1548
2078
  const enriched = enrichFiles(result, index)
2079
+ if (result?.recallRoute) enriched.recallRoute = result.recallRoute
2080
+ if (result?.errors) enriched.errors = result.errors.slice()
2081
+ if (result?.fusion) enriched.fusion = result.fusion
1549
2082
  const filtered = enriched.files
1550
2083
  .filter((f) => isMemoryEligible(f.entry, { automatic: true }))
1551
2084
  .filter((f) => appliesToDomain(f.entry, binding))
@@ -1554,7 +2087,7 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
1554
2087
  // Local lexical rank is already meaningful; confidence still nudges ties, and
1555
2088
  // a cross-session fact that has been injected three turns running gets the same
1556
2089
  // wear penalty as a private one.
1557
- score: (f.lexical > 0 ? f.lexical : 1 / (i + 1))
2090
+ score: (Number.isFinite(f.retrievalScore) ? f.retrievalScore : f.lexical > 0 ? f.lexical : 1 / (i + 1))
1558
2091
  * (0.85 + 0.15 * Number(f.entry.confidence ?? 0.5))
1559
2092
  * habituationFactor(f.entry),
1560
2093
  }))
@@ -1567,7 +2100,7 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
1567
2100
  }
1568
2101
  }
1569
2102
 
1570
- export async function globalList({ exec, domain }) {
2103
+ export async function globalList({ exec, domain, preferIndex = false }) {
1571
2104
  const binding = peekBinding(exec)
1572
2105
  if (binding) assertScopeEnabled(binding, { layer: 'global' })
1573
2106
  const index = loadGlobalIndex()
@@ -1575,11 +2108,19 @@ export async function globalList({ exec, domain }) {
1575
2108
  // "Optional domain filter", so a caller asking for one domain must not get all.
1576
2109
  const domainFilter = (file) => (!domain || (file.entry?.domain || 'unknown') === domain)
1577
2110
  && (!sessionIdOf(exec) || (isMemoryEligible(file.entry) && appliesToDomain(file.entry, binding || { domain: 'general' })))
1578
- if (getMemoryBackend() === 'agent') {
2111
+ // 与 privateList 同一条口径:受管路由还没有 active 代际时,引擎必然抛
2112
+ // "managed local vector profile is not active";列表本身其实能答,就按索引答,
2113
+ // 并把原因带上。否则全局记忆页会闪一下红字再消失。
2114
+ const route = preferIndex ? 'lexical' : engineRoute()
2115
+ const warning = !preferIndex && effectiveVectorRoute() !== 'lexical' && route === 'lexical'
2116
+ ? '本地向量未激活(只有 requested,尚未建成 active 代际);以下列表来自索引,向量库为空'
2117
+ : null
2118
+ if (route === 'lexical') {
1579
2119
  const files = localIndexFiles(index)
1580
2120
  return {
1581
2121
  domain: domain || (binding ? domainOf(binding) : 'general'),
1582
2122
  source: 'index',
2123
+ warning,
1583
2124
  files: (domainFilter ? files.filter(domainFilter) : files).map((file) => ({
1584
2125
  name: file.name,
1585
2126
  description: file.description,
@@ -1590,7 +2131,7 @@ export async function globalList({ exec, domain }) {
1590
2131
  parseWarning: null,
1591
2132
  }
1592
2133
  }
1593
- const result = await listFiles({ db: globalDbPath() })
2134
+ const result = await listFiles({ route, db: globalDbPath() })
1594
2135
  const files = Array.isArray(result?.recall_files) ? result.recall_files : []
1595
2136
  const enriched = files.map((file) => ({
1596
2137
  ...file,
@@ -1600,6 +2141,7 @@ export async function globalList({ exec, domain }) {
1600
2141
  return {
1601
2142
  domain: domain || (binding ? domainOf(binding) : 'general'),
1602
2143
  source: 'engine',
2144
+ warning: null,
1603
2145
  files: domainFilter ? enriched.filter(domainFilter) : enriched,
1604
2146
  raw: result?.raw,
1605
2147
  parseWarning: result?.parseWarning,