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/client.js CHANGED
@@ -8,7 +8,7 @@ window.__ModuleLoader__.load({
8
8
  const exports = module.exports
9
9
 
10
10
  const NS = 'dsh-memu-memory'
11
- const SETTINGS_PAGES = ['engine', 'settings', 'folders', 'libraries', 'browser', 'global', 'prompts', 'stats', 'memories']
11
+ const SETTINGS_PAGES = ['engine', 'connection', 'settings', 'folders', 'libraries', 'browser', 'global', 'prompts', 'stats', 'memories', 'logs']
12
12
  const EMPTY_SESSIONS = Object.freeze({ byId: Object.freeze({}), ids: Object.freeze([]) })
13
13
  const TAB_ID = `${NS}:memory`
14
14
  const TAB_KIND = 'dsh-memu-memory'
@@ -37,11 +37,12 @@ window.__ModuleLoader__.load({
37
37
  // panes therefore cost about one cheap RPC per second — the beat must never
38
38
  // grow a payload (AGENTS §7.4).
39
39
  const BEAT_MS = 2500
40
- function useMemoryBeat({ call, markerRef, onBeat }) {
40
+ function useMemoryBeat({ call, markerRef, onBeat, force = false, intervalMs = BEAT_MS }) {
41
41
  React.useEffect(() => {
42
42
  const id = setInterval(async () => {
43
43
  if (typeof document !== "undefined" && document.visibilityState === "hidden") return
44
44
  try {
45
+ if (force) { await onBeat(); return }
45
46
  const res = await call("getSidebarState", { revisionOnly: true })
46
47
  // null means nothing has been painted yet; the mount fetch owns that case.
47
48
  if (markerRef.current === null) return
@@ -51,11 +52,11 @@ window.__ModuleLoader__.load({
51
52
  const result = await onBeat()
52
53
  if (result !== false && markerRef.current === previous) markerRef.current = next
53
54
  } catch {}
54
- }, BEAT_MS)
55
+ }, intervalMs)
55
56
  // Never hold the host process open for a poll (matters in tests too).
56
57
  if (id && typeof id.unref === "function") id.unref()
57
58
  return () => clearInterval(id)
58
- }, [call, markerRef, onBeat])
59
+ }, [call, markerRef, onBeat, force, intervalMs])
59
60
  React.useEffect(() => {
60
61
  if (typeof document === "undefined" || typeof document.addEventListener !== "function") return undefined
61
62
  const onVisible = () => {
@@ -113,6 +114,27 @@ window.__ModuleLoader__.load({
113
114
  reviewConditions: '说明不同适用条件(至少 8 字)',
114
115
  pageMemories: 'MemU 记忆',
115
116
  descMemories: '跨库记忆、来源与状态',
117
+ pageLogs: '日志',
118
+ descLogs: '召回、写入、整理与向量的活动记录,可筛选、复制、下载',
119
+ logSearch: '搜索 id / 标题 / 错误',
120
+ logRefresh: '刷新',
121
+ logCopyAll: '复制全部',
122
+ logDownload: '下载',
123
+ logCopyOne: '复制',
124
+ logLocate: '定位记忆',
125
+ logEmpty: '没有匹配的日志。',
126
+ logAllKinds: '全部类型',
127
+ logKindRecall: '召回',
128
+ logKindWrite: '写入',
129
+ logKindConsolidate: '整理',
130
+ logKindCurate: '提炼',
131
+ logKindVector: '向量',
132
+ logKindError: '错误',
133
+ logKindSystem: '系统',
134
+ logKindCheck: '主动检查',
135
+ logClear: '清空日志',
136
+ runLogTitle: '整理记录',
137
+ runLogEmpty: '还没有整理记录。',
116
138
  allLibraries: '全部记忆库',
117
139
  searchMemories: '搜索标题、内容或标签',
118
140
  poolReview: '待复核',
@@ -121,6 +143,8 @@ window.__ModuleLoader__.load({
121
143
  wallLimit: '仅展示最近 300 条',
122
144
  openMemu: '打开 MemU WebUI',
123
145
  saved: '已保存',
146
+ copied: '已复制',
147
+ copyFailed: '复制失败,请手动选择文本',
124
148
  confirmAction: '确认此操作?',
125
149
  relationState: '关系当前状态',
126
150
  familiarity: '熟悉度(未知可留空)',
@@ -198,7 +222,8 @@ window.__ModuleLoader__.load({
198
222
  mustRememberCap: '永久记忆软上限',
199
223
  shortTermTtlHours: '短期记忆保留(小时,0=关闭)',
200
224
  anchorTokens: '锚点 token',
201
- turnTokens: '回合 token',
225
+ turnTokens: '召回 token',
226
+ injectionTotalTokens: '注入总 token',
202
227
  dedupeTurns: '去重轮数',
203
228
  globalEveryNTurns: '全局每 N 轮',
204
229
  minUserChars: '最短用户消息',
@@ -212,9 +237,23 @@ window.__ModuleLoader__.load({
212
237
  memoryModeHint: 'local 把记忆留在本机;cloud 交给 MemU 官方后端,需要 ~/.memu/config.env 里的厂商凭据。',
213
238
  memoryBackend: '记忆后端',
214
239
  backendAgent: '本地(会话 Agent)',
215
- backendApi: '远程(API)',
216
- backendAgentHint: '由当前对话的 Agent 自己总结并写入记忆索引;不需要 MemU、Ollama 或 API Key。',
217
- backendApiHint: '把记忆写入与检索交给配置好的远程 MemU API;需要可用的服务地址和密钥。',
240
+ backendApi: '启用向量库',
241
+ backendAgentHint: '由当前对话的 Agent 在本地 JSON/索引路径中总结与写入;不启用 MemU、worker 或 embedding。',
242
+ backendApiHint: '启用向量库后,选择本地受管模型或 API embedding;MemU 仍在本地存储与检索。',
243
+ vectorSource: 'Embedding 来源',
244
+ vectorSourceLocal: '本地',
245
+ vectorSourceApi: 'API',
246
+ vectorSourceHint: '本地使用已校验的 loopback embedding worker;API 使用下方 MemU embedding 服务配置。',
247
+ managedVectorStatus: '状态',
248
+ managedVectorDownloading: '下载中',
249
+ managedVectorVerifying: '校验中',
250
+ managedVectorFailed: '失败',
251
+ managedVectorProgress: '下载进度',
252
+ managedVectorCurrent: '当前文件',
253
+ managedVectorRetry: '重试',
254
+ managedVectorRebuilding: '重建中',
255
+ managedVectorGeneration: '代际',
256
+ managedVectorNoCatalog: '模型目录暂不可用,请稍后刷新。',
218
257
  engineLatestVersions: '最新版本',
219
258
  engineLatestHint: '从 PyPI 主动查询;插件只安装声明的推荐版本,不会自动追最新。',
220
259
  engineQueryVersions: '查询最新版本',
@@ -246,7 +285,8 @@ window.__ModuleLoader__.load({
246
285
  deleteForever: '删除',
247
286
  confirmDelete: '确认删除',
248
287
  cancelDelete: '取消',
249
- descOverview: 'MemU 引擎状态、安装修复与 embedding 服务连接。',
288
+ descOverview: 'MemU / Python 引擎的安装、修复、版本与备份。',
289
+ descConnection: '选择词法或向量召回;本地向量需先分别下载运行时与模型,再切换重建。',
250
290
  descSettings: '注入频率、召回条数、衰减与自动化。',
251
291
  descLibraries: '同文件夹的对话自动共用一个记忆库;删除对话不会删除记忆。',
252
292
  descBrowser: '按库浏览、手写、修改、归档与导出记忆。',
@@ -278,6 +318,59 @@ window.__ModuleLoader__.load({
278
318
  embedSourceLocal: '本地',
279
319
  embedSourceRemote: '远程',
280
320
  embedSourceLocalHint: '本机的 OpenAI 兼容向量服务(例如 Ollama:{url},模型 {model})。DSH 自身没有 embedding 能力,所以“本地”要求你自己先把该服务跑起来;不需要厂商 API key。',
321
+ managedVector: '受管本地向量模型',
322
+ managedVectorHint: '下载固定制品到插件数据目录;选择只记录待切换模型,切换会创建新向量空间并保留旧空间。',
323
+ managedVectorDownload: '下载并校验',
324
+ managedVectorSelect: '选择待切换',
325
+ managedVectorSwitch: '切换并重建',
326
+ managedVectorRollback: '回滚上一空间',
327
+ managedVectorTest: '测试当前模型',
328
+ managedVectorActive: '当前生效',
329
+ managedVectorRequested: '待切换',
330
+ managedVectorInstalled: '已安装',
331
+ managedVectorPending: '待核验',
332
+ managedVectorUnavailable: '不可用',
333
+ managedVectorSizePending: '大小待核验',
334
+ managedVectorFailure: '切换失败',
335
+ managedVectorRuntimeHint: '没有检测到已核实的本地运行时。点「下载运行时」把目录里 pin 住的制品装进插件数据目录:不需要手动设置 DSH_MEMU_EMBED_RUNTIME_DIR,也不需要另起 Ollama。',
336
+ managedVectorRuntimeMissing: '运行时未安装',
337
+ managedVectorRuntimeInstalling: '运行时安装中',
338
+ managedVectorRuntimeInstalled: '运行时已就绪(受管)',
339
+ managedVectorRuntimeConfigured: '运行时已就绪(环境变量)',
340
+ managedVectorRuntimeFailed: '运行时安装失败',
341
+ managedVectorRuntimeUnsupported: '此平台没有已核实的运行时制品',
342
+ managedVectorRuntimeInstall: '下载运行时',
343
+ managedVectorRuntimeTitle: '运行环境',
344
+ managedVectorModelsTitle: '模型',
345
+ managedVectorReady: '已就绪',
346
+ vectorRebuildTitle: '重建进度',
347
+ vectorStagePreparing: '准备中',
348
+ vectorStagePrepared: '已准备',
349
+ vectorStageSyncing: '写入向量',
350
+ vectorStageSwitching: '切换中',
351
+ vectorStageFailed: '失败',
352
+ vectorStageCommitted: '已切换',
353
+ managedVectorCommitted: '已写入',
354
+ managedVectorSkipped: '跳过',
355
+ managedVectorFailedCount: '失败',
356
+ vectorSwitchDoneHint: '新记忆会自动写入这个向量空间;召回已切到向量路径。',
357
+ vectorAbortedHint: '重建已停止,当前向量空间未改变;再次点击可续建。',
358
+ managedVectorAborted: '已停止',
359
+ vectorRouteLabel: '召回路由',
360
+ vectorRouteLocal: '本地向量',
361
+ vectorRouteRemote: '远程向量',
362
+ vectorRouteLexical: '词法',
363
+ vectorIndexedTag: '已入向量库',
364
+ vectorIndexedMigrated: '已入向量库(迁移)',
365
+ vectorIndexedNow: '当前已入向量库',
366
+ vectorIndexedUnit: '条',
367
+ vectorSwitchWrote: '切换时写入',
368
+ managedVectorConfirmDownload: '确认下载并校验模型制品?运行时需另行下载。',
369
+ managedVectorPrerequisites: '使用顺序:安装 MemU 引擎 → 下载运行时 → 下载并选择已核验模型 → 切换并重建。准备完成前保持词法召回;不需要 Ollama 或手动环境变量。',
370
+ managedVectorSwitchBlocked: '切换前请先下载已核验的运行时,并下载、选择模型。',
371
+ managedVectorConfirmSwitch: '确认切换?这会在新空间重建向量并短暂冻结写入。',
372
+ managedVectorConfirmRollback: '确认回滚到上一向量空间?',
373
+ managedVectorRetention: '备份最多保留 3 份,较旧的完整备份会自动清理。',
281
374
  embedSourceRemoteHint: '用你自己填写的 provider / Base URL / API Key 调第三方向量服务。',
282
375
  embedKeyNotNeeded: '本地向量服务无需 API Key',
283
376
  embedKeyRemoteHint: '只有远程向量服务需要填写 API Key',
@@ -299,7 +392,8 @@ window.__ModuleLoader__.load({
299
392
  testOk: '测试通过',
300
393
  configPath: '配置文件',
301
394
  configSaved: '已保存',
302
- pageOverview: '引擎与连接',
395
+ pageOverview: 'MemU 引擎',
396
+ pageConnection: '连接与向量',
303
397
  pageSettings: '记忆设置',
304
398
  pageLibraries: '记忆库',
305
399
  pageGlobal: '全局记忆',
@@ -478,6 +572,7 @@ window.__ModuleLoader__.load({
478
572
  privateMemory: '私有记忆',
479
573
  currentLibrary: '当前记忆库',
480
574
  domain: '领域',
575
+ domainPromptHint: '决定这次对话的提示词与记忆过滤;写代码请选 Coding,角色扮演选 RP。',
481
576
  usePrivate: '使用私有记忆',
482
577
  useGlobal: '使用全局记忆',
483
578
  organize: '整理这次对话',
@@ -526,6 +621,27 @@ window.__ModuleLoader__.load({
526
621
  reviewConditions: 'Explain distinct conditions (at least 8 characters)',
527
622
  pageMemories: 'MemU Memory',
528
623
  descMemories: 'Memories, provenance and status across libraries',
624
+ pageLogs: 'Logs',
625
+ descLogs: 'Recall, writes, consolidation and vector activity; filter, copy or download',
626
+ logSearch: 'Search id / title / error',
627
+ logRefresh: 'Refresh',
628
+ logCopyAll: 'Copy all',
629
+ logDownload: 'Download',
630
+ logCopyOne: 'Copy',
631
+ logLocate: 'Open memory',
632
+ logEmpty: 'No matching log entries.',
633
+ logAllKinds: 'All kinds',
634
+ logKindRecall: 'Recall',
635
+ logKindWrite: 'Write',
636
+ logKindConsolidate: 'Consolidate',
637
+ logKindCurate: 'Curate',
638
+ logKindVector: 'Vector',
639
+ logKindError: 'Error',
640
+ logKindSystem: 'System',
641
+ logKindCheck: 'Check',
642
+ logClear: 'Clear log',
643
+ runLogTitle: 'Runs',
644
+ runLogEmpty: 'No runs recorded yet.',
529
645
  allLibraries: 'All libraries',
530
646
  searchMemories: 'Search titles, content or tags',
531
647
  poolReview: 'Review',
@@ -534,6 +650,8 @@ window.__ModuleLoader__.load({
534
650
  wallLimit: 'Showing the latest 300 entries',
535
651
  openMemu: 'Open MemU WebUI',
536
652
  saved: 'Saved',
653
+ copied: 'Copied',
654
+ copyFailed: 'Copy failed; select the text manually',
537
655
  confirmAction: 'Confirm this action?',
538
656
  relationState: 'Current relationship',
539
657
  familiarity: 'Familiarity (blank = unknown)',
@@ -611,7 +729,8 @@ window.__ModuleLoader__.load({
611
729
  mustRememberCap: 'Permanent memory cap',
612
730
  shortTermTtlHours: 'Short-term TTL in hours (0=off)',
613
731
  anchorTokens: 'Anchor tokens',
614
- turnTokens: 'Turn tokens',
732
+ turnTokens: 'Recall tokens',
733
+ injectionTotalTokens: 'Injection total tokens',
615
734
  dedupeTurns: 'Dedupe turns',
616
735
  globalEveryNTurns: 'Global every N turns',
617
736
  minUserChars: 'Min user chars',
@@ -625,9 +744,23 @@ window.__ModuleLoader__.load({
625
744
  memoryModeHint: 'local keeps memory on this machine; cloud hands storage to the MemU backend and needs vendor credentials in ~/.memu/config.env.',
626
745
  memoryBackend: 'Memory backend',
627
746
  backendAgent: 'Local (session agent)',
628
- backendApi: 'Remote (API)',
629
- backendAgentHint: 'The conversation agent summarizes and writes memories into the local index; no MemU, Ollama, or API key is needed.',
630
- backendApiHint: 'Memory writes and retrieval go through the configured remote MemU API; it needs a reachable endpoint and key.',
747
+ backendApi: 'Enable vector store',
748
+ backendAgentHint: 'The session agent summarizes and writes through the local JSON/index path; no MemU, worker, or embedding is enabled.',
749
+ backendApiHint: 'Enable the vector store, then choose a managed local model or API embedding; MemU storage and retrieval remain local.',
750
+ vectorSource: 'Embedding source',
751
+ vectorSourceLocal: 'Local',
752
+ vectorSourceApi: 'API',
753
+ vectorSourceHint: 'Local uses the verified loopback embedding worker; API uses the MemU embedding service configured below.',
754
+ managedVectorStatus: 'Status',
755
+ managedVectorDownloading: 'Downloading',
756
+ managedVectorVerifying: 'Verifying',
757
+ managedVectorFailed: 'Failed',
758
+ managedVectorProgress: 'Download progress',
759
+ managedVectorCurrent: 'Current file',
760
+ managedVectorRetry: 'Retry',
761
+ managedVectorRebuilding: 'Rebuilding',
762
+ managedVectorGeneration: 'Generation',
763
+ managedVectorNoCatalog: 'The model catalog is temporarily unavailable. Refresh and try again.',
631
764
  engineLatestVersions: 'Latest versions',
632
765
  engineLatestHint: 'Queried from PyPI. The plugin still installs only its declared recommended version.',
633
766
  engineQueryVersions: 'Check latest',
@@ -659,7 +792,8 @@ window.__ModuleLoader__.load({
659
792
  deleteForever: 'Delete',
660
793
  confirmDelete: 'Delete for good',
661
794
  cancelDelete: 'Cancel',
662
- descOverview: 'MemU engine status, install/repair, and the embedding service connection.',
795
+ descOverview: 'MemU / Python engine installation, repair, versions, and backups.',
796
+ descConnection: 'Choose lexical or vector recall. Local vectors require separate runtime and model downloads before switching and rebuilding.',
663
797
  descSettings: 'Injection cadence, recall depth, decay, and automation.',
664
798
  descLibraries: 'Conversations in the same folder share one library; deleting a conversation never deletes memory.',
665
799
  descBrowser: 'Browse, write, edit, archive, and export memories per library.',
@@ -691,6 +825,59 @@ window.__ModuleLoader__.load({
691
825
  embedSourceLocal: 'Local',
692
826
  embedSourceRemote: 'Remote',
693
827
  embedSourceLocalHint: 'An OpenAI-compatible embedding server on this machine (for example Ollama: {url}, model {model}). DSH has no embedding capability of its own, so "local" still requires you to run that service; no vendor API key is needed.',
828
+ managedVector: 'Managed local vector model',
829
+ managedVectorHint: 'Download pinned artifacts into plugin data; selection only records a pending profile. Switching rebuilds a new vector space and keeps the old one.',
830
+ managedVectorDownload: 'Download and verify',
831
+ managedVectorSelect: 'Select pending',
832
+ managedVectorSwitch: 'Switch and rebuild',
833
+ managedVectorRollback: 'Rollback previous space',
834
+ managedVectorTest: 'Test active model',
835
+ managedVectorActive: 'Active',
836
+ managedVectorRequested: 'Pending switch',
837
+ managedVectorInstalled: 'Installed',
838
+ managedVectorPending: 'Pending verification',
839
+ managedVectorUnavailable: 'Unavailable',
840
+ managedVectorSizePending: 'Size pending verification',
841
+ managedVectorFailure: 'Switch failed',
842
+ managedVectorRuntimeHint: 'No verified local runtime was found. Download runtime fetches the pinned artifacts from the catalog into plugin data; no DSH_MEMU_EMBED_RUNTIME_DIR and no Ollama needed.',
843
+ managedVectorRuntimeMissing: 'Runtime not installed',
844
+ managedVectorRuntimeInstalling: 'Installing runtime',
845
+ managedVectorRuntimeInstalled: 'Runtime ready (managed)',
846
+ managedVectorRuntimeConfigured: 'Runtime ready (environment variable)',
847
+ managedVectorRuntimeFailed: 'Runtime install failed',
848
+ managedVectorRuntimeUnsupported: 'No verified runtime artifacts for this platform',
849
+ managedVectorRuntimeInstall: 'Download runtime',
850
+ managedVectorRuntimeTitle: 'Runtime',
851
+ managedVectorModelsTitle: 'Models',
852
+ managedVectorReady: 'Ready',
853
+ vectorRebuildTitle: 'Rebuild progress',
854
+ vectorStagePreparing: 'Preparing',
855
+ vectorStagePrepared: 'Prepared',
856
+ vectorStageSyncing: 'Writing vectors',
857
+ vectorStageSwitching: 'Switching',
858
+ vectorStageFailed: 'Failed',
859
+ vectorStageCommitted: 'Switched',
860
+ managedVectorCommitted: 'Written',
861
+ managedVectorSkipped: 'Skipped',
862
+ managedVectorFailedCount: 'Failed',
863
+ vectorSwitchDoneHint: 'New memories are written into this vector space; recall now uses it.',
864
+ vectorAbortedHint: 'The rebuild stopped; the active space is unchanged. Retry to resume.',
865
+ managedVectorAborted: 'Stopped',
866
+ vectorRouteLabel: 'Recall route',
867
+ vectorRouteLocal: 'Local vectors',
868
+ vectorRouteRemote: 'Remote vectors',
869
+ vectorRouteLexical: 'Lexical',
870
+ vectorIndexedTag: 'In vector store',
871
+ vectorIndexedMigrated: 'In vector store (from rebuild)',
872
+ vectorIndexedNow: 'Currently indexed',
873
+ vectorIndexedUnit: 'items',
874
+ vectorSwitchWrote: 'Written during switch',
875
+ managedVectorConfirmDownload: 'Download and verify this model artifact? Download the runtime separately.',
876
+ managedVectorPrerequisites: 'Install the MemU engine → download the runtime → download and select a verified model → switch and rebuild. Keep lexical recall until ready; no Ollama or manual environment variable is needed.',
877
+ managedVectorSwitchBlocked: 'Download a verified runtime, then download and select a model before switching.',
878
+ managedVectorConfirmSwitch: 'Switch now? This rebuilds a new vector space and briefly freezes writes.',
879
+ managedVectorConfirmRollback: 'Roll back to the previous vector space?',
880
+ managedVectorRetention: 'At most 3 complete backups are kept; older complete backups are cleaned up automatically.',
694
881
  embedSourceRemoteHint: 'Call a third-party embedding service with the provider / Base URL / API Key you enter.',
695
882
  embedKeyNotNeeded: 'No API key needed for a local embedding server',
696
883
  embedKeyRemoteHint: 'Only remote embedding services require an API key',
@@ -712,7 +899,8 @@ window.__ModuleLoader__.load({
712
899
  testOk: 'Test passed',
713
900
  configPath: 'Config file',
714
901
  configSaved: 'Saved',
715
- pageOverview: 'Engine & connection',
902
+ pageOverview: 'MemU engine',
903
+ pageConnection: 'Connection & vectors',
716
904
  pageSettings: 'Memory settings',
717
905
  pageLibraries: 'Libraries',
718
906
  pageGlobal: 'Global memory',
@@ -890,6 +1078,7 @@ window.__ModuleLoader__.load({
890
1078
  privateMemory: 'Private memory',
891
1079
  currentLibrary: 'Current library',
892
1080
  domain: 'Domain',
1081
+ domainPromptHint: 'Chooses this conversation\'s prompt variant and memory filter. Pick Coding for engineering work, RP for roleplay.',
893
1082
  usePrivate: 'Use private memory',
894
1083
  useGlobal: 'Use global memory',
895
1084
  organize: 'Organize this conversation',
@@ -1138,6 +1327,35 @@ window.__ModuleLoader__.load({
1138
1327
  .dshm-card-ico{width:32px;height:32px;flex:none;border-radius:10px;display:grid;place-items:center;color:var(--dsw-alias-brand-primary,#2563eb);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#2563eb) 12%,transparent)}
1139
1328
  .dshm-card-title{font-size:13.5px;font-weight:600;line-height:1.3}
1140
1329
  .dshm-card-sub{margin-top:3px;font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.8));max-width:60ch}
1330
+ /* 连接与向量:大选择卡 + 分步卡片 + 一个显眼的主操作。原来把切换按钮塞在标题栏里,
1331
+ 三个小按钮和状态标签挤在一行,既不好点也看不出主次。 */
1332
+ .dshm-choice-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px;margin-top:10px}
1333
+ .dshm-choice{position:relative;display:flex;flex-direction:column;gap:5px;padding:14px 16px;border:1px solid var(--dsw-alias-border-l1,#dfe1e6);border-radius:14px;background:var(--dsw-alias-bg-layer-1,rgba(127,127,127,.04));color:inherit;font:inherit;text-align:left;cursor:pointer;transition:border-color .18s ease,box-shadow .18s ease,background .18s ease,transform .18s ease}
1334
+ .dshm-choice:hover:not(:disabled){transform:translateY(-1px);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary,#2563eb) 42%,transparent)}
1335
+ .dshm-choice.on{border-color:var(--dsw-alias-brand-primary,#2563eb);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#2563eb) 8%,transparent);box-shadow:inset 0 0 0 1px var(--dsw-alias-brand-primary,#2563eb)}
1336
+ .dshm-choice:disabled{opacity:.6;cursor:not-allowed}
1337
+ .dshm-choice-top{display:flex;align-items:center;gap:8px}
1338
+ .dshm-choice-title{font-size:13.5px;font-weight:650;line-height:1.35}
1339
+ .dshm-choice-hint{font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.8))}
1340
+ .dshm-choice-mark{width:18px;height:18px;flex:none;border-radius:50%;display:grid;place-items:center;font-size:11px;font-weight:700;color:var(--dsw-alias-label-primary-inverted,#fff);background:var(--dsw-alias-brand-primary,#2563eb)}
1341
+ .dshm-step{border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.16));border-radius:14px;padding:14px 16px;background:var(--dsw-alias-bg-layer-1,rgba(127,127,127,.04));display:flex;flex-direction:column;gap:8px}
1342
+ .dshm-step-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1343
+ .dshm-step-title{font-size:12.5px;font-weight:650;line-height:1.35}
1344
+ .dshm-step-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1345
+ .dshm-model-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}
1346
+ .dshm-model{border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.16));border-radius:14px;padding:14px 16px;background:var(--dsw-alias-bg-layer-1,rgba(127,127,127,.04));display:flex;flex-direction:column;gap:8px}
1347
+ .dshm-model-meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:5px}
1348
+ .dshm-primary-lg{min-height:38px;padding:0 20px;font-size:13px;border-radius:11px}
1349
+ .dshm-cta-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px}
1350
+ .dshm-rebuild{border-color:color-mix(in srgb,var(--dsw-alias-brand-primary,#2563eb) 28%,transparent)}
1351
+ .dshm-rebuild-bar{height:10px;border-radius:999px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.14));overflow:hidden}
1352
+ .dshm-rebuild-bar>span{display:block;height:100%;border-radius:999px;background:var(--dsw-alias-brand-primary,#2563eb);transition:width .32s ease}
1353
+ .dshm-rebuild-meta{display:flex;align-items:center;gap:12px;flex-wrap:wrap;font-size:11.5px;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.8))}
1354
+ .dshm-rebuild .dshm-vector-progress{display:flex;flex-direction:column;gap:6px}
1355
+ .dshm-rebuild progress{width:100%;height:12px;border-radius:999px;appearance:none;-webkit-appearance:none;border:0;overflow:hidden;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.14))}
1356
+ .dshm-rebuild progress::-webkit-progress-bar{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.14));border-radius:999px}
1357
+ .dshm-rebuild progress::-webkit-progress-value{background:var(--dsw-alias-brand-primary,#2563eb);border-radius:999px;transition:width .3s ease}
1358
+ .dshm-rebuild progress::-moz-progress-bar{background:var(--dsw-alias-brand-primary,#2563eb);border-radius:999px}
1141
1359
  .dshm-card-actions{margin-left:auto;display:flex;gap:8px;flex:none;align-items:center}
1142
1360
 
1143
1361
  .dshm-root input:not([type=checkbox]):not([type=radio]):focus,.dshm-root textarea:focus,.dshm-root select:focus{border-color:var(--dsw-alias-brand-primary,#2563eb);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary,#2563eb) 18%,transparent)}
@@ -1549,6 +1767,20 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
1549
1767
  return enumText(t, DOMAIN_LABELS, raw)
1550
1768
  }
1551
1769
 
1770
+ // 情绪构成的短标签:同样的 valence 可能来自怒,也可能来自哀,一个数字分不出来。
1771
+ // 四轴固定且术语极短,就地映射,不为四个轴各加一个 i18n key。
1772
+ const EMOTION_TAG_LABELS = { joy: '喜', anger: '怒', sorrow: '哀', fear: '惧' }
1773
+ function emotionTagText(emotion) {
1774
+ if (!emotion || typeof emotion !== 'object') return ''
1775
+ return Object.entries(EMOTION_TAG_LABELS)
1776
+ .map(([axis, label]) => ({ label, share: Number(emotion[axis]) || 0 }))
1777
+ .filter((item) => item.share >= 0.05)
1778
+ .sort((a, b) => b.share - a.share)
1779
+ .slice(0, 2)
1780
+ .map((item) => `${item.label}${item.share.toFixed(1)}`)
1781
+ .join('/')
1782
+ }
1783
+
1552
1784
  // Tags are labels, not decoration — except when the label is a warning. A memory
1553
1785
  // marked "never say out loud" has to look different from one that can be said,
1554
1786
  // otherwise the badge is just more grey text to skim past.
@@ -1934,7 +2166,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
1934
2166
  item.attachmentCount > 1 ? h('span', { className: 'dshm-wall-cover-count' }, '▧ ' + item.attachmentCount) : null)
1935
2167
  }
1936
2168
 
1937
- function MemoryWallPanel({ call, libraries, revision, stamp, onOpen, t }) {
2169
+ function MemoryWallPanel({ call, libraries, revision, stamp, onOpen, t, indexed = null }) {
1938
2170
  const [data, setData] = React.useState({ items: [], total: 0, errors: [] })
1939
2171
  const [libraryId, setLibraryId] = React.useState('')
1940
2172
  const [view, setView] = React.useState('memories')
@@ -2139,6 +2371,15 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2139
2371
  h(Tag, { tone: shown.source === 'imagined' ? 'warn' : (shown.source === 'user_said' ? 'info' : undefined) },
2140
2372
  enumText(t, SOURCE_LABELS, shown.source)),
2141
2373
  h(Tag, null, enumText(t, STATUS_LABELS, shown.status)),
2374
+ // 这条记忆有没有进向量库,直接写在条目上(remember 提交成功后落盘)。
2375
+ shown.vector
2376
+ ? h(Tag, { tone: 'info' }, `${t('vectorIndexedTag')}${shown.vector.model ? ' · ' + shown.vector.model : ''}${shown.vector.generation ? ' · g' + shown.vector.generation : ''}`)
2377
+ : (() => {
2378
+ // 迁移写库不碰索引,老条目没有 entry.vector;用账本判断它是否已入当前代际。
2379
+ const bucketKey = detail.item.libraryId || 'global'
2380
+ const ids = indexed && Array.isArray(indexed[bucketKey]) ? indexed[bucketKey] : null
2381
+ return ids && ids.includes(detail.item.id) ? h(Tag, { tone: 'info' }, t('vectorIndexedMigrated')) : null
2382
+ })(),
2142
2383
  shown.openLoop ? h(Tag, null, t('openLoopTag')) : null,
2143
2384
  shown.marginalized ? h(Tag, null, t('marginalizedTag')) : null)),
2144
2385
  h('button', { type: 'button', className: 'dshm-x', 'aria-label': t('close'), onClick: () => setDetail(null) }, '×')),
@@ -2240,14 +2481,15 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2240
2481
  function SettingsPage({ ctx, t, embedded, standalone = false }) {
2241
2482
  const PAGE = {
2242
2483
  overview: 0,
2243
- settings: 1,
2244
- folders: 2,
2245
- libraries: 3,
2246
- browser: 4,
2247
- global: 5,
2248
- prompts: 6,
2249
- stats: 7,
2250
- memories: 8,
2484
+ connection: 1,
2485
+ settings: 2,
2486
+ folders: 3,
2487
+ libraries: 4,
2488
+ browser: 5,
2489
+ global: 6,
2490
+ prompts: 7,
2491
+ stats: 8,
2492
+ memories: 9,
2251
2493
  }
2252
2494
  const call = useRpc(ctx)
2253
2495
  const [state, setState] = React.useState(null)
@@ -2261,6 +2503,8 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2261
2503
  const [job, setJob] = React.useState(null)
2262
2504
  const [error, setError] = React.useState('')
2263
2505
  const [busy, setBusy] = React.useState(false)
2506
+ const [vectorOperation, setVectorOperation] = React.useState('')
2507
+ const [vectorError, setVectorError] = React.useState('')
2264
2508
  // Success messages need their own slot: reusing `error` would paint a good
2265
2509
  // outcome in red and make the next real failure easy to miss.
2266
2510
  const [notice, setNotice] = React.useState('')
@@ -2269,9 +2513,30 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2269
2513
  const [configNotice, setConfigNotice] = React.useState('')
2270
2514
  const [page, setPage] = React.useState(() => standalone
2271
2515
  ? (SETTINGS_PAGES.includes(String(window.location.hash || '').replace(/^#\/settings\//, ''))
2272
- ? SETTINGS_PAGES.indexOf(String(window.location.hash || '').replace(/^#\/settings\//, '')) : 8)
2516
+ ? SETTINGS_PAGES.indexOf(String(window.location.hash || '').replace(/^#\/settings\//, '')) : PAGE.memories)
2273
2517
  : 0)
2274
2518
  const pendingSettings = React.useRef({})
2519
+ // Hook order is a contract: every hook below must run on every render,
2520
+ // including the loading branch (`if (!state) return ...` further down).
2521
+ // Putting these next to the other state declarations is what keeps the
2522
+ // count stable — a hook after that early return is React error #310.
2523
+ const [logKind, setLogKind] = React.useState('')
2524
+ const [logQueryInput, setLogQueryInput] = React.useState('')
2525
+ const [logQuery, setLogQuery] = React.useState('')
2526
+ const [logRows, setLogRows] = React.useState([])
2527
+ const [logStats, setLogStats] = React.useState(null)
2528
+ const [logBusy, setLogBusy] = React.useState(false)
2529
+ const [logNotice, setLogNotice] = React.useState('')
2530
+ const loadLogs = React.useCallback(async () => {
2531
+ setLogBusy(true)
2532
+ try {
2533
+ const data = await call('getActivityLog', { limit: 300, kind: logKind || undefined, q: logQuery || undefined })
2534
+ setLogRows(Array.isArray(data?.events) ? data.events : [])
2535
+ setLogStats(data?.stats || null)
2536
+ setLogNotice('')
2537
+ } catch (error) { setLogNotice(String(error?.message || error)) } finally { setLogBusy(false) }
2538
+ }, [call, logKind, logQuery])
2539
+ React.useEffect(() => { void loadLogs() }, [loadLogs])
2275
2540
  const settingsTimer = React.useRef(null)
2276
2541
  const saveChain = React.useRef(Promise.resolve())
2277
2542
  const [resetArmed, setResetArmed] = React.useState(false)
@@ -2550,7 +2815,8 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2550
2815
  }, [refresh])
2551
2816
  // Writes we cannot hear about over the channel — a tool call, a curator job,
2552
2817
  // another host process — land through the on-disk half of the marker.
2553
- useMemoryBeat({ call, markerRef, onBeat: refresh })
2818
+ const vectorWorking = !!vectorOperation || ['downloading', 'verifying', 'installing'].includes(state?.localVector?.runtime?.status || '') || Object.values(state?.localVector?.installations || {}).some(item => ['downloading', 'verifying'].includes(item.status)) || ['preparing', 'prepared', 'syncing', 'switching'].includes(state?.localVector?.vector?.pending?.status)
2819
+ useMemoryBeat({ call, markerRef, onBeat: refresh, force: vectorWorking })
2554
2820
  // Sizes need a directory walk; ask for them after the page has painted.
2555
2821
  React.useEffect(() => {
2556
2822
  let alive = true
@@ -2997,7 +3263,12 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2997
3263
  const memoryBackend = settings.memoryBackend === 'api' ? 'api' : 'agent'
2998
3264
  const latestVersions = (versionInfo?.versions || []).slice(0, 3)
2999
3265
  const memuConfig = state.memuConfig || { providers: [] }
3000
- const isLocalEmbed = memuConfig.source === 'local'
3266
+ const localVector = state.localVector || { catalog: [], installations: {}, vector: {} }
3267
+ const requestedModel = localVector.vector?.requested?.catalogId
3268
+ const requestedInstall = localVector.installations?.[requestedModel] || {}
3269
+ const vectorCanSwitch = localVector.runtime?.ready === true && requestedInstall.installed === true && requestedInstall.fingerprintMatches !== false
3270
+ const vectorSource = settings.vectorSource || memuConfig.source || 'local'
3271
+ const isLocalEmbed = vectorSource === 'local'
3001
3272
  const draft = configDraft || {
3002
3273
  provider: memuConfig.provider || 'openai',
3003
3274
  model: memuConfig.model || '',
@@ -3054,7 +3325,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3054
3325
  } catch (e) { setError(String(e?.message || e)) } finally { setConfigBusy(false) }
3055
3326
  }
3056
3327
  const setEmbedSource = async (source) => {
3057
- if (source === memuConfig.source) return
3328
+ if (source === vectorSource) return
3058
3329
  setConfigBusy(true)
3059
3330
  setConfigNotice('')
3060
3331
  try {
@@ -3066,6 +3337,63 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3066
3337
  await refresh()
3067
3338
  } catch (e) { setError(String(e?.message || e)) } finally { setConfigBusy(false) }
3068
3339
  }
3340
+ const managedVectorAction = async (endpoint, payload = {}) => {
3341
+ const labels = {
3342
+ downloadLocalVectorModel: 'managedVectorDownload',
3343
+ selectLocalVectorModel: 'managedVectorSelect',
3344
+ switchLocalVectorProfile: 'managedVectorSwitch',
3345
+ rollbackLocalVectorProfile: 'managedVectorRollback',
3346
+ installLocalVectorRuntime: 'managedVectorRuntimeInstall',
3347
+ }
3348
+ setBusy(true)
3349
+ setVectorOperation(labels[endpoint] || endpoint)
3350
+ setError('')
3351
+ setNotice('')
3352
+ setVectorError('')
3353
+ try {
3354
+ await call(endpoint, payload)
3355
+ setNotice(`${t('managedVectorStatus')}: ${t(labels[endpoint] || 'managedVectorStatus')}`)
3356
+ } catch (e) {
3357
+ const message = String(e?.message || e)
3358
+ setVectorError(message)
3359
+ setError(message)
3360
+ } finally {
3361
+ await refresh()
3362
+ setBusy(false)
3363
+ setVectorOperation('')
3364
+ }
3365
+ }
3366
+ const managedVectorDownload = async (id) => {
3367
+ if (!confirmAction(`download:${id}`)) return
3368
+ await managedVectorAction('downloadLocalVectorModel', { id, confirmed: true })
3369
+ }
3370
+ const managedVectorSelect = async (id) => managedVectorAction('selectLocalVectorModel', { id })
3371
+ const managedVectorSwitch = async () => {
3372
+ if (!vectorCanSwitch) { setVectorError(t('managedVectorSwitchBlocked')); return }
3373
+ if (!confirmAction('switch')) return
3374
+ await managedVectorAction('switchLocalVectorProfile', { confirmed: true })
3375
+ }
3376
+ const managedVectorRollback = async () => {
3377
+ if (!confirmAction('rollback')) return
3378
+ await managedVectorAction('rollbackLocalVectorProfile', { confirmed: true })
3379
+ }
3380
+ const managedVectorRuntimeInstall = async () => {
3381
+ if (!confirmAction('runtime')) return
3382
+ await managedVectorAction('installLocalVectorRuntime', { confirmed: true })
3383
+ }
3384
+
3385
+ const renderVectorProgress = (progress, migration = false) => {
3386
+ if (!progress) return null
3387
+ const total = Number(migration ? progress.total : progress.totalBytes) || 0
3388
+ const done = Number(migration ? progress.completed : progress.bytes) || 0
3389
+ const label = t(migration ? 'managedVectorRebuilding' : 'managedVectorProgress')
3390
+ const current = migration ? progress.current?.itemId : progress.current
3391
+ return h('div', { className: 'dshm-vector-progress' },
3392
+ h('div', { role: 'status', 'aria-live': 'polite' }, `${label}: ${done} / ${total}${migration ? ` · ${t('managedVectorGeneration')} ${progress.generation} · ${progress.stage}` : ' bytes'}`),
3393
+ h('progress', { max: total || 1, value: total > 0 ? Math.min(done, total) : undefined, 'aria-label': label }),
3394
+ current ? h('div', { style: S.muted }, `${t('managedVectorCurrent')}: ${current}`) : null)
3395
+ }
3396
+
3069
3397
  const engineStatusText = engine.status === 'ready' ? t('engineReady')
3070
3398
  : engine.status === 'installing' ? t('engineInstalling')
3071
3399
  : engine.status === 'error' ? t('engineError') : t('engineIdle')
@@ -3224,15 +3552,12 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3224
3552
  style: { ...S.ghost, color: V.danger },
3225
3553
  }, actionLabel('backup:' + b.name, t('backupDelete'))))))))),
3226
3554
 
3555
+ )
3556
+
3557
+ const connectionBlock = h(React.Fragment, null,
3227
3558
  h(Section, {
3228
3559
  title: t('memoryBackend'),
3229
3560
  desc: memoryBackend === 'api' ? t('backendApiHint') : t('backendAgentHint'),
3230
- right: memoryBackend === 'api'
3231
- ? h('div', { style: { display: 'flex', gap: 8 } },
3232
- h(Button, { onClick: saveConfig, disabled: configBusy, primary: true }, t('saveConfig')),
3233
- h(Button, { onClick: testConfig, disabled: configBusy }, t('testConfig')),
3234
- h(Button, { onClick: clearKey, disabled: configBusy }, t('clearKey')))
3235
- : null,
3236
3561
  },
3237
3562
  h('div', { style: { ...S.row, marginTop: 4 } },
3238
3563
  h('div', { className: 'dshm-nav-list is-row', role: 'tablist' },
@@ -3245,7 +3570,13 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3245
3570
  className: `dshm-nav-item${memoryBackend === value ? ' on' : ''}`,
3246
3571
  onClick: () => setBackend(value),
3247
3572
  }, label)))),
3248
- memoryBackend === 'api'
3573
+ memoryBackend === 'api' ? h(Field, { label: t('vectorSource') },
3574
+ h('div', { className: 'dshm-nav-list is-row', role: 'tablist', 'aria-label': t('vectorSource') },
3575
+ [['local', t('vectorSourceLocal')], ['api', t('vectorSourceApi')]].map(([value, label]) => h('button', {
3576
+ key: value, type: 'button', role: 'tab', title: label, 'aria-selected': vectorSource === value,
3577
+ disabled: configBusy || busy, className: `dshm-nav-item${vectorSource === value ? ' on' : ''}`, onClick: () => setEmbedSource(value),
3578
+ }, label))), h('div', { style: S.muted }, t('vectorSourceHint'))) : null,
3579
+ memoryBackend === 'api' && !isLocalEmbed
3249
3580
  ? h(React.Fragment, null,
3250
3581
  h('div', { style: S.muted }, `${t('configPath')}: ${memuConfig.path || '—'}`),
3251
3582
  h(Field, { label: t('provider') },
@@ -3275,7 +3606,178 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3275
3606
  onChange: (e) => setConfig('summaryApiKey', e.target.value),
3276
3607
  })))
3277
3608
  : null,
3278
- configNotice ? h('div', { style: { ...S.muted, marginTop: 6 } }, configNotice) : null)
3609
+ configNotice ? h('div', { style: { ...S.muted, marginTop: 6 } }, configNotice) : null,
3610
+ memoryBackend === 'api'
3611
+ ? h('div', { className: 'dshm-cta-row' },
3612
+ h(Button, { onClick: saveConfig, disabled: configBusy, primary: true, className: 'dshm-primary-lg' }, t('saveConfig')),
3613
+ h(Button, { onClick: testConfig, disabled: configBusy }, t('testConfig')),
3614
+ h(Button, { onClick: clearKey, disabled: configBusy, style: S.ghost }, t('clearKey')))
3615
+ : null),
3616
+ memoryBackend === 'api' && isLocalEmbed ? h(Section, {
3617
+ title: t('managedVector'),
3618
+ desc: t('managedVectorHint'),
3619
+ right: h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' } },
3620
+ vectorOperation ? h('span', { role: 'status', 'aria-live': 'polite' }, h(Tag, { tone: 'info' }, `${t('managedVectorStatus')}: ${t(vectorOperation)}`)) : null,
3621
+ localVector.vector?.active ? h(Tag, { tone: 'info' }, `${t('managedVectorActive')}: ${localVector.vector.active.catalogId}${localVector.vector.active.generation ? ' · g' + localVector.vector.active.generation : ''}`) : null,
3622
+ localVector.vector?.requested ? h(Tag, { tone: 'warn' }, `${t('managedVectorRequested')}: ${localVector.vector.requested.catalogId || ''}`) : null,
3623
+ h(Tag, localVector.vector?.active ? { tone: 'info' } : null,
3624
+ `${t('vectorRouteLabel')}: ${localVector.vector?.active ? (vectorSource === 'api' ? t('vectorRouteRemote') : t('vectorRouteLocal')) : t('vectorRouteLexical')}`)),
3625
+ },
3626
+ // 全页只有一个显眼的主操作。条件不满足时保持可见但禁用,并把原因写在旁边:
3627
+ // 把按钮藏起来,用户只会看到“什么都没发生”。
3628
+ h('div', { className: 'dshm-cta-row' },
3629
+ h(Button, {
3630
+ onClick: managedVectorSwitch,
3631
+ disabled: busy || !vectorCanSwitch || !localVector.vector?.requested,
3632
+ primary: true,
3633
+ className: 'dshm-primary-lg',
3634
+ title: t(vectorCanSwitch ? 'managedVectorSwitch' : 'managedVectorSwitchBlocked'),
3635
+ }, actionLabel('switch', t('managedVectorSwitch'))),
3636
+ localVector.vector?.active
3637
+ ? h(Button, { onClick: managedVectorRollback, disabled: busy, title: t('managedVectorRollback'), style: S.ghost }, actionLabel('rollback', t('managedVectorRollback')))
3638
+ : null,
3639
+ h('span', { style: { ...S.muted, color: vectorCanSwitch ? V.textDim : V.warn } },
3640
+ t(vectorCanSwitch ? 'managedVectorPrerequisites' : 'managedVectorSwitchBlocked'))),
3641
+ // 重建进度单独成块。三种终态分开渲染:committed 是“已切换”,failed 是失败,
3642
+ // preparing/syncing 才是进行中。之前把 active.lastMigration 也当进度,成功之后
3643
+ // 还会一直显示“重建中 8/8”,让人无法判断到底切没切。
3644
+ (() => {
3645
+ const activeVector = localVector.vector?.active || null
3646
+ const pending = localVector.vector?.pending
3647
+ if (!pending) {
3648
+ const last = activeVector?.lastMigration || null
3649
+ if (!activeVector) return null
3650
+ return h('div', { className: 'dshm-step dshm-rebuild' },
3651
+ h('div', { className: 'dshm-step-head' },
3652
+ h('span', { className: 'dshm-step-title' }, t('vectorRebuildTitle')),
3653
+ h(Tag, { tone: 'info' }, t('vectorStageCommitted')),
3654
+ activeVector.generation ? h(Tag, null, `${t('managedVectorGeneration')} ${activeVector.generation}`) : null,
3655
+ activeVector.fingerprint ? h('span', { style: S.muted }, String(activeVector.fingerprint).slice(0, 8)) : null),
3656
+ last ? h('div', { style: { fontSize: 12.5 } }, `${t('vectorSwitchWrote')} ${last.committed || 0}${last.total ? ' / ' + last.total : ''}`) : null,
3657
+ (() => {
3658
+ const counts = localVector.vector?.indexed
3659
+ const total = counts ? Object.values(counts).reduce((sum, ids) => sum + (Array.isArray(ids) ? ids.length : 0), 0) : null
3660
+ return total === null ? null : h('div', { style: S.muted }, `${t('vectorIndexedNow')} ${total} ${t('vectorIndexedUnit')}`)
3661
+ })(),
3662
+ h('div', { style: S.muted }, t('vectorSwitchDoneHint')))
3663
+ }
3664
+ const progress = pending.report?.progress || null
3665
+ const status = String(pending.status || '')
3666
+ const failedItems = Array.isArray(pending.error?.failedItems) ? pending.error.failedItems : []
3667
+ const total = Number(progress?.total) || 0
3668
+ const done = Number(progress?.completed) || 0
3669
+ const committed = Number(progress?.committed) || 0
3670
+ const generation = progress?.generation ?? pending.generation
3671
+ const fingerprint = progress?.fingerprint ?? pending.fingerprint
3672
+ const head = (label, tone) => h('div', { className: 'dshm-step-head' },
3673
+ h('span', { className: 'dshm-step-title' }, t('vectorRebuildTitle')),
3674
+ label ? h(Tag, tone ? { tone } : null, label) : null,
3675
+ generation ? h(Tag, null, `${t('managedVectorGeneration')} ${generation}`) : null,
3676
+ fingerprint ? h('span', { style: S.muted }, String(fingerprint).slice(0, 8)) : null)
3677
+ if (status === 'committed') {
3678
+ return h('div', { className: 'dshm-step dshm-rebuild' },
3679
+ head(t('vectorStageCommitted'), 'info'),
3680
+ h('div', { style: { fontSize: 12.5 } }, `${t('managedVectorCommitted')} ${done}${total ? ' / ' + total : ''}`),
3681
+ h('div', { style: S.muted }, t('vectorSwitchDoneHint')))
3682
+ }
3683
+ if (status === 'aborted') {
3684
+ return h('div', { className: 'dshm-step dshm-rebuild' },
3685
+ head(t('managedVectorAborted'), 'warn'),
3686
+ h('div', { style: S.muted }, t('vectorAbortedHint')))
3687
+ }
3688
+ if (status === 'failed') {
3689
+ return h('div', { className: 'dshm-step dshm-rebuild' },
3690
+ head(t('vectorStageFailed'), 'warn'),
3691
+ total ? h('div', { style: S.muted }, `${t('managedVectorCommitted')} ${committed} / ${total}`) : null,
3692
+ failedItems.length
3693
+ ? h('div', { style: { ...S.muted, color: V.danger, display: 'flex', flexDirection: 'column', gap: 2 } },
3694
+ failedItems.slice(0, 6).map(item => h('div', { key: String(item.itemId || item.libraryId) + item.code },
3695
+ `${item.itemId || item.libraryId || '?'} · ${item.code} · ${item.message || ''}`)))
3696
+ : null)
3697
+ }
3698
+ return h('div', { className: 'dshm-step dshm-rebuild' },
3699
+ head(progress ? t('vectorStageSyncing') : null, 'info'),
3700
+ renderVectorProgress(progress, true),
3701
+ h('div', { className: 'dshm-rebuild-meta' },
3702
+ committed ? h('span', null, `${t('managedVectorCommitted')} ${committed}`) : null,
3703
+ progress?.skipped ? h('span', null, `${t('managedVectorSkipped')} ${progress.skipped}`) : null,
3704
+ progress?.failed ? h('span', { style: { color: V.danger } }, `${t('managedVectorFailedCount')} ${progress.failed}`) : null,
3705
+ progress?.current?.itemId ? h('span', { style: S.muted }, String(progress.current.itemId)) : null),
3706
+ failedItems.length
3707
+ ? h('div', { style: { ...S.muted, color: V.danger, display: 'flex', flexDirection: 'column', gap: 2 } },
3708
+ failedItems.slice(0, 6).map(item => h('div', { key: String(item.itemId || item.libraryId) + item.code },
3709
+ `${item.itemId || item.libraryId || '?'} · ${item.code} · ${item.message || ''}`)))
3710
+ : null)
3711
+ })(),
3712
+ (() => {
3713
+ const failure = vectorError || localVector.vector?.pending?.error?.message || ''
3714
+ if (!failure) return null
3715
+ return h('div', { role: 'alert', style: { ...S.muted, color: V.danger, marginTop: 4 } },
3716
+ h('strong', null, `${t('managedVectorFailure')}: `), failure)
3717
+ })(),
3718
+ // 步骤 1:运行环境
3719
+ (() => {
3720
+ const runtime = localVector.runtime || {}
3721
+ const busyRuntime = ['downloading', 'verifying', 'installing'].includes(runtime.status)
3722
+ const ready = runtime.ready === true
3723
+ const label = ready
3724
+ ? (runtime.source === 'env' ? t('managedVectorRuntimeConfigured') : t('managedVectorRuntimeInstalled'))
3725
+ : runtime.status === 'unsupported' ? t('managedVectorRuntimeUnsupported')
3726
+ : runtime.status === 'failed' ? t('managedVectorRuntimeFailed')
3727
+ : busyRuntime ? t('managedVectorRuntimeInstalling') : t('managedVectorRuntimeMissing')
3728
+ const announce = busyRuntime || Boolean(runtime.error?.message)
3729
+ return h('div', { className: 'dshm-step', ...(announce ? { role: 'status', 'aria-live': 'polite' } : {}) },
3730
+ h('div', { className: 'dshm-step-head' },
3731
+ h('span', { className: 'dshm-step-title' }, '1 · ' + t('managedVectorRuntimeTitle')),
3732
+ h(Tag, ready ? { tone: 'info' } : { tone: 'warn' }, label)),
3733
+ ready
3734
+ ? h('div', { style: S.muted }, runtime.version ? `v${runtime.version}` : t('managedVectorReady'))
3735
+ : h('div', { style: S.muted }, t('managedVectorRuntimeHint')),
3736
+ !ready && !busyRuntime && runtime.status !== 'unsupported'
3737
+ ? h('div', { className: 'dshm-step-actions' },
3738
+ h(Button, { onClick: managedVectorRuntimeInstall, disabled: busy, primary: true, title: t('managedVectorRuntimeInstall') }, actionLabel('runtime', t('managedVectorRuntimeInstall'))))
3739
+ : null,
3740
+ runtime.error?.message ? h('div', { style: { ...S.muted, color: V.danger } }, runtime.error.message) : null,
3741
+ renderVectorProgress(runtime.progress))
3742
+ })(),
3743
+ // 步骤 2:模型
3744
+ h('div', { className: 'dshm-step' },
3745
+ h('div', { className: 'dshm-step-head' },
3746
+ h('span', { className: 'dshm-step-title' }, '2 · ' + t('managedVectorModelsTitle')),
3747
+ h(Tag, null, String((localVector.catalog || []).length))),
3748
+ (localVector.catalog || []).length === 0
3749
+ ? h('div', { style: S.muted }, t('managedVectorNoCatalog'))
3750
+ : h('div', { className: 'dshm-model-grid' }, (localVector.catalog || []).map(entry => {
3751
+ const install = localVector.installations?.[entry.id] || {}
3752
+ const isActive = localVector.vector?.active?.catalogId === entry.id
3753
+ const isRequested = localVector.vector?.requested?.catalogId === entry.id
3754
+ const canSelect = install.installed === true && install.fingerprintMatches !== false
3755
+ const pendingArtifact = entry.availability === 'pending' || (entry.pendingReasons || []).length > 0
3756
+ const sizeText = Number(entry.totalBytes) > 0 ? `${Math.max(1, Math.round(entry.totalBytes / 1024))} KB` : t('managedVectorSizePending')
3757
+ const statusText = install.installed ? t('managedVectorInstalled') : pendingArtifact ? t('managedVectorPending') : (install.status || entry.availability || t('managedVectorUnavailable'))
3758
+ return h('div', { key: entry.id, className: 'dshm-model' },
3759
+ h('div', null,
3760
+ h('div', { className: 'dshm-step-title' }, entry.displayName || entry.id),
3761
+ h('div', { className: 'dshm-model-meta' },
3762
+ h(Tag, null, `${entry.dimension || '?'}d`),
3763
+ h(Tag, null, sizeText),
3764
+ h(Tag, install.installed ? { tone: 'info' } : pendingArtifact ? { tone: 'warn' } : null, statusText),
3765
+ isActive ? h(Tag, { tone: 'info' }, t('managedVectorActive')) : null,
3766
+ isRequested ? h(Tag, { tone: 'warn' }, t('managedVectorRequested')) : null)),
3767
+ h('div', { className: 'dshm-step-actions' },
3768
+ !install.installed && entry.availability === 'available'
3769
+ ? h(Button, { onClick: () => managedVectorDownload(entry.id), disabled: busy, primary: true, title: t('managedVectorDownload') }, actionLabel(`download:${entry.id}`, t('managedVectorDownload')))
3770
+ : null,
3771
+ canSelect && !isActive
3772
+ ? h(Button, { onClick: () => managedVectorSelect(entry.id), disabled: busy, title: t('managedVectorSelect'), style: S.ghost }, t('managedVectorSelect'))
3773
+ : null),
3774
+ install.error?.message || (entry.pendingReasons || []).length
3775
+ ? h('div', { style: S.muted, role: 'status' }, install.error?.message || (entry.pendingReasons || []).join(';'))
3776
+ : null,
3777
+ renderVectorProgress(install.progress))
3778
+ })))
3779
+ ) : null,
3780
+
3279
3781
  )
3280
3782
 
3281
3783
  const settingsBlock = h(React.Fragment, null, h(Section, {
@@ -3288,17 +3790,22 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3288
3790
  h(Field, { label: t('autoInject') },
3289
3791
  h('select', { style: S.input, value: settings.autoInject || 'smart', onChange: (e) => setSetting('autoInject', e.target.value) },
3290
3792
  ['smart', 'anchor-only', 'per-turn', 'off'].map((v) => h('option', { key: v, value: v }, enumText(t, INJECT_LABELS, v))))),
3291
- h(Field, { label: t('privateTopK') }, h('input', { type: 'number', style: S.input, value: settings.privateTopK ?? 4, onChange: (e) => setSetting('privateTopK', Number(e.target.value)) })),
3292
- h(Field, { label: t('globalTopK') }, h('input', { type: 'number', style: S.input, value: settings.globalTopK ?? 3, onChange: (e) => setSetting('globalTopK', Number(e.target.value)) })),
3293
- h(Field, { label: t('mustRememberCap') }, h('input', { type: 'number', style: S.input, value: settings.mustRememberCap ?? 16, onChange: (e) => setSetting('mustRememberCap', Number(e.target.value)) })),
3793
+ // 下面这些 ?? 回落值必须与 lib/state.js 的 DEFAULT_SETTINGS 一致:浏览器 bundle 不能
3794
+ // import 服务端模块,这里是客户端唯一的一份副本。它只在首次 getState 回来之前显示,
3795
+ // 但用户恰好在这段窗口里点「保存」就会把回落值持久化,等于悄悄改掉默认值。
3796
+ // 一致性由 tests/ui-defaults.test.mjs 守着。
3797
+ h(Field, { label: t('privateTopK') }, h('input', { type: 'number', style: S.input, value: settings.privateTopK ?? 5, onChange: (e) => setSetting('privateTopK', Number(e.target.value)) })),
3798
+ h(Field, { label: t('globalTopK') }, h('input', { type: 'number', style: S.input, value: settings.globalTopK ?? 5, onChange: (e) => setSetting('globalTopK', Number(e.target.value)) })),
3799
+ h(Field, { label: t('mustRememberCap') }, h('input', { type: 'number', style: S.input, value: settings.mustRememberCap ?? 12, onChange: (e) => setSetting('mustRememberCap', Number(e.target.value)) })),
3294
3800
  h(Field, { label: t('shortTermTtlHours') }, h('input', { type: 'number', min: 0, style: S.input, value: settings.shortTermTtlHours ?? 72, onChange: (e) => setSetting('shortTermTtlHours', Number(e.target.value)) })),
3295
- h(Field, { label: t('anchorTokens') }, h('input', { type: 'number', style: S.input, value: settings.anchorTokens ?? 700, onChange: (e) => setSetting('anchorTokens', Number(e.target.value)) })),
3296
- h(Field, { label: t('turnTokens') }, h('input', { type: 'number', style: S.input, value: settings.turnTokens ?? 1200, onChange: (e) => setSetting('turnTokens', Number(e.target.value)) })),
3297
- h(Field, { label: t('dedupeTurns') }, h('input', { type: 'number', style: S.input, value: settings.dedupeTurns ?? 8, onChange: (e) => setSetting('dedupeTurns', Number(e.target.value)) })),
3298
- h(Field, { label: t('globalEveryNTurns') }, h('input', { type: 'number', style: S.input, value: settings.globalEveryNTurns ?? 3, onChange: (e) => setSetting('globalEveryNTurns', Number(e.target.value)) })),
3801
+ h(Field, { label: t('anchorTokens') }, h('input', { type: 'number', style: S.input, value: settings.anchorTokens ?? 800, onChange: (e) => setSetting('anchorTokens', Number(e.target.value)) })),
3802
+ h(Field, { label: t('turnTokens') }, h('input', { type: 'number', style: S.input, value: settings.turnTokens ?? 800, onChange: (e) => setSetting('turnTokens', Number(e.target.value)) })),
3803
+ h(Field, { label: t('injectionTotalTokens') }, h('input', { type: 'number', style: S.input, value: settings.injectionTotalTokens ?? 2000, onChange: (e) => setSetting('injectionTotalTokens', Number(e.target.value)) })),
3804
+ h(Field, { label: t('dedupeTurns') }, h('input', { type: 'number', style: S.input, value: settings.dedupeTurns ?? 16, onChange: (e) => setSetting('dedupeTurns', Number(e.target.value)) })),
3805
+ h(Field, { label: t('globalEveryNTurns') }, h('input', { type: 'number', style: S.input, value: settings.globalEveryNTurns ?? 6, onChange: (e) => setSetting('globalEveryNTurns', Number(e.target.value)) })),
3299
3806
  h(Field, { label: t('minUserChars') }, h('input', { type: 'number', style: S.input, value: settings.minUserChars ?? 6, onChange: (e) => setSetting('minUserChars', Number(e.target.value)) })),
3300
3807
  h(Field, { label: t('relevanceThreshold') }, h('input', { type: 'number', step: '0.05', min: 0, max: 1, style: S.input, value: settings.relevanceThreshold ?? 0.4, onChange: (e) => setSetting('relevanceThreshold', Number(e.target.value)) })),
3301
- h(Field, { label: t('randomCueChance') }, h('input', { type: 'number', step: '0.01', min: 0, max: 1, style: S.input, value: settings.randomCueChance ?? 0.05, onChange: (e) => setSetting('randomCueChance', Number(e.target.value)) })),
3808
+ h(Field, { label: t('randomCueChance') }, h('input', { type: 'number', step: '0.01', min: 0, max: 1, style: S.input, value: settings.randomCueChance ?? 0.02, onChange: (e) => setSetting('randomCueChance', Number(e.target.value)) })),
3302
3809
  h(Field, { label: t('autoInstall') }, h('input', { type: 'checkbox', checked: settings.autoInstallEngine !== false, onChange: (e) => setSetting('autoInstallEngine', e.target.checked) })),
3303
3810
  h(Field, { label: t('newSessionPrompt') }, h('input', { type: 'checkbox', checked: settings.newSessionPromptDismissed !== true, onChange: (e) => setSetting('newSessionPromptDismissed', !e.target.checked) })),
3304
3811
  h(Field, { label: t('habitsInjection') }, h('input', { type: 'checkbox', checked: settings.habitsInjection !== false, onChange: (e) => setSetting('habitsInjection', e.target.checked) })),
@@ -3308,7 +3815,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3308
3815
  names: settings.inboundToolNames || [], onChange: values => setSetting('inboundToolNames', values),
3309
3816
  })),
3310
3817
  h(Field, { label: t('sleepConsolidation') }, h('input', { type: 'checkbox', checked: settings.sleepConsolidation === true, onChange: (e) => setSetting('sleepConsolidation', e.target.checked) })),
3311
- h(Field, { label: t('sleepAfterIdleMinutes') }, h('input', { type: 'number', min: 1, style: S.input, value: settings.sleepAfterIdleMinutes ?? 20, onChange: (e) => setSetting('sleepAfterIdleMinutes', Number(e.target.value)) })),
3818
+ h(Field, { label: t('sleepAfterIdleMinutes') }, h('input', { type: 'number', min: 1, style: S.input, value: settings.sleepAfterIdleMinutes ?? 3, onChange: (e) => setSetting('sleepAfterIdleMinutes', Number(e.target.value)) })),
3312
3819
  h(Field, { label: t('autoCurateEveryHours') }, h('input', { type: 'number', min: 0, style: S.input, value: settings.autoCurateEveryHours ?? 0, onChange: (e) => setSetting('autoCurateEveryHours', Number(e.target.value)) })),
3313
3820
  h(Field, { label: t('autoCurateSessionLimit') }, h('input', { type: 'number', min: 1, max: 30, style: S.input, value: settings.autoCurateSessionLimit ?? 8, onChange: (e) => setSetting('autoCurateSessionLimit', Number(e.target.value)) })),
3314
3821
  h(Field, { label: t('purgeArchivedAfterDays') }, h('input', { type: 'number', min: 0, style: S.input, value: settings.purgeArchivedAfterDays ?? 0, onChange: (e) => setSetting('purgeArchivedAfterDays', Number(e.target.value)) })),
@@ -3536,6 +4043,9 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3536
4043
  const set = (key, value) => setBrowseDraft((prev) => ({
3537
4044
  ...prev, [f.id]: { ...(prev[f.id] || {}), [key]: value },
3538
4045
  }))
4046
+ // 有构成就讲构成;没有就回到旧的单值标签,老条目照旧可读。
4047
+ const moodText = emotionTagText(f.emotion)
4048
+ || (typeof f.moodValence === 'number' && f.moodValence !== 0 ? f.moodValence.toFixed(2) : '')
3539
4049
  return h('div', { key: f.id, style: S.item },
3540
4050
  h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center' } },
3541
4051
  h('div', { style: S.row },
@@ -3564,8 +4074,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3564
4074
  f.salientDate
3565
4075
  ? h(Tag, null, `${/^\d{4}-\d{2}-\d{2}$/.test(f.salientDate) ? t('dueDateTag') : t('anniversaryTag')} ${f.salientDate}`)
3566
4076
  : null,
3567
- typeof f.moodValence === 'number' && f.moodValence !== 0
3568
- ? h(Tag, null, `${t('moodTag')} ${f.moodValence.toFixed(2)}`) : null,
4077
+ moodText ? h(Tag, null, `${t('moodTag')} ${moodText}`) : null,
3569
4078
  typeof f.accuracy === 'number' && typeof f.confidence === 'number' && f.accuracy <= 0.6 && f.accuracy < f.confidence - 0.1
3570
4079
  ? h(Tag, { tone: 'warn' }, `${t('accuracyTag')} ${f.accuracy}`) : null,
3571
4080
  h(Tag, null, enumText(t, STATUS_LABELS, f.status || 'active'))),
@@ -3903,8 +4412,39 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3903
4412
  t('statsWindow').split('{days}').join(String(statsData.ratio.windowDays))))
3904
4413
  : h(Empty, null, t('statsUnavailable'))))
3905
4414
 
4415
+ // Clipboard and download are the two things a person actually does with a
4416
+ // log: paste the row into an issue, or keep the file. execCommand is the
4417
+ // fallback because the standalone page can run without clipboard permission.
4418
+ const copyText = async (value) => {
4419
+ const text = String(value ?? '')
4420
+ try { await navigator.clipboard.writeText(text); return true } catch {}
4421
+ try {
4422
+ const area = document.createElement('textarea')
4423
+ area.value = text
4424
+ area.style.position = 'fixed'
4425
+ area.style.opacity = '0'
4426
+ document.body.appendChild(area)
4427
+ area.select()
4428
+ const ok = document.execCommand('copy')
4429
+ area.remove()
4430
+ return ok
4431
+ } catch { return false }
4432
+ }
4433
+ const downloadLogs = () => {
4434
+ try {
4435
+ const blob = new Blob([logRows.map(row => JSON.stringify(row)).join('\n')], { type: 'application/x-ndjson' })
4436
+ const url = URL.createObjectURL(blob)
4437
+ const link = document.createElement('a')
4438
+ link.href = url
4439
+ link.download = `dsh-memu-activity-${new Date().toISOString().slice(0, 10)}.jsonl`
4440
+ link.click()
4441
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
4442
+ } catch (error) { setLogNotice(String(error?.message || error)) }
4443
+ }
4444
+
3906
4445
  const pageDefs = [
3907
4446
  { label: t('pageOverview'), overline: 'ENGINE', desc: t('descOverview'), icon: GlyphGauge },
4447
+ { label: t('pageConnection'), overline: 'CONNECTION', desc: t('descConnection'), icon: GlyphGlobe },
3908
4448
  { label: t('pageSettings'), overline: 'BEHAVIOUR', desc: t('descSettings'), icon: GlyphSliders },
3909
4449
  { label: t('pageFolders'), overline: 'SCOPE', desc: t('descFolders'), icon: GlyphFolder },
3910
4450
  { label: t('pageLibraries'), overline: 'LIBRARIES', desc: t('descLibraries'), icon: GlyphStack },
@@ -3914,12 +4454,90 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3914
4454
  { label: t('pageStats'), overline: 'STATS', desc: t('descStats'), icon: GlyphChart },
3915
4455
  ]
3916
4456
  pageDefs.push({ label: t('pageMemories'), overline: 'MEMU', desc: t('descMemories'), icon: GlyphLayers })
4457
+ pageDefs.push({ label: t('pageLogs'), overline: 'ACTIVITY', desc: t('descLogs'), icon: GlyphText })
3917
4458
  const memoriesBlock = h(MemoryWallPanel, { call, libraries: state.libraries || [], revision: state.revision, stamp: state.stamp, t,
4459
+ indexed: localVector.vector?.indexed || null,
3918
4460
  onOpen: item => {
3919
4461
  if (item.libraryId) { setBrowseLibId(item.libraryId); setPage(PAGE.browser); void openBrowser(item.libraryId) }
3920
4462
  else setPage(PAGE.global)
3921
4463
  } })
3922
- const pageBlocks = [engineBlock, settingsBlock, foldersBlock, librariesBlock, browserBlock, globalBlock, promptsBlock, statsBlock, memoriesBlock]
4464
+
4465
+ const LOG_KIND_LABELS = {
4466
+ recall: t('logKindRecall'), write: t('logKindWrite'), consolidate: t('logKindConsolidate'),
4467
+ curate: t('logKindCurate'), vector: t('logKindVector'), error: t('logKindError'), system: t('logKindSystem'), check: t('logKindCheck'),
4468
+ }
4469
+ const logsBlock = h(React.Fragment, null,
4470
+ h(Section, {
4471
+ title: t('pageLogs'),
4472
+ desc: t('descLogs'),
4473
+ right: h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
4474
+ h(Button, {
4475
+ style: S.ghost,
4476
+ onClick: async () => {
4477
+ const ok = await copyText(logRows.map(row => JSON.stringify(row)).join('\n'))
4478
+ setLogNotice(ok ? t('copied') : t('copyFailed'))
4479
+ },
4480
+ }, t('logCopyAll')),
4481
+ h(Button, { style: S.ghost, onClick: downloadLogs }, t('logDownload')),
4482
+ h(Button, {
4483
+ style: S.ghost,
4484
+ onClick: async () => {
4485
+ try { await call('clearActivityLog'); await loadLogs() } catch (error) { setLogNotice(String(error?.message || error)) }
4486
+ },
4487
+ }, t('logClear'))),
4488
+ },
4489
+ h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 } },
4490
+ h('select', {
4491
+ style: S.input,
4492
+ 'aria-label': t('pageLogs'),
4493
+ value: logKind,
4494
+ onChange: (event) => setLogKind(event.target.value),
4495
+ },
4496
+ h('option', { value: '' }, t('logAllKinds')),
4497
+ ['recall', 'write', 'consolidate', 'curate', 'vector', 'error', 'check'].map(kind => h('option', { key: kind, value: kind }, LOG_KIND_LABELS[kind]))),
4498
+ h('input', {
4499
+ style: S.input,
4500
+ value: logQueryInput,
4501
+ placeholder: t('logSearch'),
4502
+ 'aria-label': t('logSearch'),
4503
+ onChange: (event) => setLogQueryInput(event.target.value),
4504
+ onKeyDown: (event) => { if (event.key === 'Enter') setLogQuery(logQueryInput) },
4505
+ }),
4506
+ h(Button, { onClick: () => { setLogQuery(logQueryInput); void loadLogs() }, disabled: logBusy }, t('logRefresh'))),
4507
+ logStats
4508
+ ? h('div', { style: S.muted }, `${logStats.total} / ${logStats.capped} · ${Math.max(1, Math.round((logStats.bytes || 0) / 1024))} KB`)
4509
+ : null,
4510
+ logNotice ? h('div', { style: { ...S.muted, marginTop: 6, color: V.accent } }, logNotice) : null,
4511
+ logRows.length === 0
4512
+ ? h(Empty, null, t('logEmpty'))
4513
+ : h('div', { style: S.list }, logRows.map(row => h('div', { key: row.id, style: S.item },
4514
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center' } },
4515
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center', minWidth: 0, flexWrap: 'wrap' } },
4516
+ h(Tag, { tone: row.level === 'error' ? 'warn' : undefined }, LOG_KIND_LABELS[row.kind] || row.kind),
4517
+ h('span', { style: S.muted }, new Date(row.at).toLocaleString()),
4518
+ row.sessionId ? h('span', { style: S.muted }, row.sessionId.slice(-10)) : null,
4519
+ row.libraryId ? h('span', { style: S.muted }, row.libraryId) : null),
4520
+ h('div', { style: { display: 'flex', gap: 6, flexShrink: 0 } },
4521
+ row.libraryId ? h(Button, {
4522
+ style: S.ghost,
4523
+ // 纠错入口:从日志直接回到这条记忆所在的库,按 id 搜到它再改。
4524
+ onClick: () => { setBrowseLibId(row.libraryId); setPage(PAGE.browser); void openBrowser(row.libraryId) },
4525
+ }, t('logLocate')) : null,
4526
+ h(Button, {
4527
+ style: S.ghost,
4528
+ onClick: async () => {
4529
+ const ok = await copyText(JSON.stringify(row, null, 2))
4530
+ setLogNotice(ok ? t('copied') : t('copyFailed'))
4531
+ },
4532
+ }, t('logCopyOne')))),
4533
+ h('div', { style: { marginTop: 4, whiteSpace: 'pre-wrap' } }, row.message),
4534
+ Array.isArray(row.refs) && row.refs.length
4535
+ ? h('div', { style: { ...S.muted, marginTop: 4 } }, row.refs.join(' · '))
4536
+ : null,
4537
+ row.detail
4538
+ ? h('pre', { style: { ...S.muted, margin: '6px 0 0', fontSize: 11, whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, JSON.stringify(row.detail).slice(0, 600))
4539
+ : null)))))
4540
+ const pageBlocks = [engineBlock, connectionBlock, settingsBlock, foldersBlock, librariesBlock, browserBlock, globalBlock, promptsBlock, statsBlock, memoriesBlock, logsBlock]
3923
4541
 
3924
4542
  // Settings uses a left nav with a sliding indicator (the sidebar keeps its
3925
4543
  // top segments). The indicator is one absolutely-positioned element moved by
@@ -4089,6 +4707,26 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4089
4707
  .catch((e) => setError(String(e?.message || e)))
4090
4708
  }, [state?.binding?.scopeKey, sessionsState.byId, sessionId, call, refresh])
4091
4709
 
4710
+ // 整理记录:点击后显示在按钮下方,替代原来那行"已在后台子会话里整理"的提示。
4711
+ // 数据来自活动日志(getActivityLog),所以整理进行中能看到"已开始",结束后能看到
4712
+ // 完成/超时/失败和写入的记录,刷新页面也不会丢。
4713
+ // Hook 必须在该组件的每个渲染路径上都执行:`if (!sessionId) return ...` 在其后,
4714
+ // 所以这三个 hook 只能放在那行提前 return 之前(否则就是 React #310)。
4715
+ const [runLog, setRunLog] = React.useState([])
4716
+ const loadRunLog = React.useCallback(async () => {
4717
+ if (!sessionId) return
4718
+ try {
4719
+ const data = await call('getActivityLog', { sessionId, limit: 12 })
4720
+ setRunLog(Array.isArray(data?.events) ? data.events : [])
4721
+ } catch {}
4722
+ }, [call, sessionId])
4723
+ React.useEffect(() => { void loadRunLog() }, [loadRunLog])
4724
+ React.useEffect(() => {
4725
+ if (!busy || !sessionId) return undefined
4726
+ const timer = setInterval(() => { void loadRunLog() }, 1500)
4727
+ return () => clearInterval(timer)
4728
+ }, [busy, sessionId, loadRunLog])
4729
+
4092
4730
  if (!sessionId) return h('div', { style: S.tab }, t('loading'))
4093
4731
  const binding = state?.binding
4094
4732
  const summary = sessionsState.byId?.[sessionId]
@@ -4131,12 +4769,9 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4131
4769
  setNotice('')
4132
4770
  try {
4133
4771
  const result = await call('generatePrivate', { sessionId })
4134
- setNotice(result?.ok
4135
- ? (result.backend === 'api'
4136
- ? t('organizedRemote').split('{count}').join(String(result.count ?? 0))
4137
- : t('organizedAgent'))
4138
- : (result?.error || ''))
4139
- } catch (e) { setError(String(e?.message || e)) } finally { setBusy(false) }
4772
+ // 失败不再走顶部提示:活动日志里有完整原因和 fork id,面板就在下面。
4773
+ if (result?.ok !== true && result?.error) setError(result.error)
4774
+ } catch (e) { setError(String(e?.message || e)) } finally { setBusy(false); void loadRunLog() }
4140
4775
  }
4141
4776
  const startCuration = async () => {
4142
4777
  if (selected.length === 0) return
@@ -4155,13 +4790,24 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4155
4790
  } catch (e) { setError(String(e?.message || e)) }
4156
4791
  }
4157
4792
 
4793
+ const RUN_KIND_LABELS = {
4794
+ recall: t('logKindRecall'), write: t('logKindWrite'), consolidate: t('logKindConsolidate'),
4795
+ curate: t('logKindCurate'), vector: t('logKindVector'), error: t('logKindError'), system: t('logKindSystem'),
4796
+ }
4797
+ const renderRunRow = (row) => h('div', { key: row.id, style: S.item },
4798
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
4799
+ h(Tag, { tone: row.level === 'error' ? 'warn' : undefined }, RUN_KIND_LABELS[row.kind] || row.kind),
4800
+ h('span', { style: S.muted }, new Date(row.at).toLocaleTimeString())),
4801
+ h('div', { style: { marginTop: 2, whiteSpace: 'pre-wrap', fontSize: 12 } }, row.message))
4802
+
4158
4803
  const bindingBlock = !binding
4159
4804
  ? h(Section, { title: t('noBinding') },
4160
4805
  h(Button, { onClick: init, disabled: busy || !summary?.cwd, primary: true }, t('initMemory')),
4161
4806
  // A library is a folder, so an unset workspace is a real blocker, not a
4162
4807
  // detail to discover by clicking.
4163
4808
  !summary?.cwd ? h('div', { style: { ...S.muted, marginTop: 8 } }, t('needsWorkspaceFirst')) : null)
4164
- : h('div', { style: S.section },
4809
+ : h(React.Fragment, null,
4810
+ h('div', { style: S.section },
4165
4811
  // Both layers off in the folder: say so, instead of showing two dead
4166
4812
  // checkboxes the user cannot explain.
4167
4813
  effective && !effective.enabled
@@ -4192,7 +4838,15 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4192
4838
  ['general', ...(state?.domains || [])].filter((d, i, list) => list.indexOf(d) === i)
4193
4839
  .map((d) => h('option', { key: d, value: d }, domainText(t, d))))),
4194
4840
  h('div', { style: { display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' } },
4195
- h(Button, { onClick: organize, disabled: busy, primary: true }, t('organize'))))
4841
+ h(Button, { onClick: organize, disabled: busy, primary: true }, t('organize'))),
4842
+ h('div', { style: { marginTop: 12 } },
4843
+ h('div', { style: { ...S.muted, marginBottom: 4, fontWeight: 600 } }, t('runLogTitle')),
4844
+ runLog.length === 0
4845
+ ? h('div', { style: S.muted }, t('runLogEmpty'))
4846
+ : h('div', { style: S.list }, runLog.slice(0, 8).map(renderRunRow)))),
4847
+ // 作用域放在控制卡之后:它是一组低频字段,先让用户看到记忆开关和整理入口。
4848
+ h(ContextScope, { binding, sessionId, call, t })
4849
+ )
4196
4850
 
4197
4851
  const renderPrivateItem = (f) => h('div', { key: f.id, style: S.item },
4198
4852
  h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8 } },
@@ -4298,7 +4952,6 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4298
4952
  }, p.label)))),
4299
4953
  error ? h('div', { style: { ...S.muted, padding: '8px 12px 0', color: V.danger } }, error) : null,
4300
4954
  notice ? h('div', { style: { ...S.muted, padding: '8px 12px 0' } }, notice) : null,
4301
- h(ContextScope, { binding, sessionId, call, t }),
4302
4955
  h('div', { style: S.tabBody }, pages[tabPage]?.node || null))
4303
4956
  }
4304
4957
 
@@ -4331,6 +4984,10 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4331
4984
  const [libraryId, setLibraryId] = React.useState('')
4332
4985
  const [newName, setNewName] = React.useState('')
4333
4986
  const [dontShow, setDontShow] = React.useState(false)
4987
+ // The domain decides both the prompt variant and the memory filter. It
4988
+ // used to live only in the sidebar, which is exactly the switch people
4989
+ // forget after starting a coding session in an RP workspace.
4990
+ const [domain, setDomain] = React.useState('rp')
4334
4991
  const [busy, setBusy] = React.useState(false)
4335
4992
  const [error, setError] = React.useState('')
4336
4993
 
@@ -4390,11 +5047,13 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4390
5047
  sessionId: current,
4391
5048
  cwd: summaryCwd || '',
4392
5049
  libraries: (st.libraries || []).filter((l) => l.scopeKey !== '__default__'),
5050
+ domains: Array.isArray(st.domains) && st.domains.length ? st.domains : ['rp', 'coding', 'general', 'mixed'],
4393
5051
  folder: st.folder || null,
4394
5052
  defaultChoice: CARD_OF[String(st.settings?.newSessionDefault || 'both')] || 'both',
4395
5053
  })
4396
5054
  setChoice(CARD_OF[String(st.settings?.newSessionDefault || 'both')] || 'both')
4397
5055
  setPick('folder')
5056
+ setDomain('rp')
4398
5057
  setLibraryId((st.libraries || []).find((l) => l.scopeKey !== '__default__')?.id || '')
4399
5058
  } catch (e) {
4400
5059
  if (!cancelled) setError(String(e?.message || e))
@@ -4442,6 +5101,9 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4442
5101
  // Always write both switches, so the sidebar reflects the card and not
4443
5102
  // a policy default.
4444
5103
  await call('setSessionScope', { sessionId: prompt.sessionId, ...layers })
5104
+ // The domain is written in the same confirm as the memory layers, so
5105
+ // the prompt cannot leave the new conversation on the old default.
5106
+ await call('setDomain', { sessionId: prompt.sessionId, domain })
4445
5107
  decided.current.add(prompt.sessionId)
4446
5108
  setPrompt(null)
4447
5109
  } catch (e) {
@@ -4504,6 +5166,14 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4504
5166
  },
4505
5167
  h('div', { style: { ...S.muted, lineHeight: 1.6 } }, t('memoryPromptBody')),
4506
5168
  error ? h('div', { style: { color: V.danger, fontSize: 12, marginTop: 8 } }, error) : null,
5169
+ h('div', { style: { marginTop: 12 } },
5170
+ h(Field, { label: t('domain'), hint: t('domainPromptHint') },
5171
+ h('select', {
5172
+ style: { ...S.input, flex: 1 },
5173
+ value: domain,
5174
+ disabled: busy,
5175
+ onChange: (e) => setDomain(e.target.value),
5176
+ }, (prompt.domains || []).map((d) => h('option', { key: d, value: d }, domainText(t, d)))))),
4507
5177
  h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 } },
4508
5178
  cards.map((card) => h('button', {
4509
5179
  key: card.id,