dsh-memu-memory 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.md +8 -5
  2. package/README.md +22 -5
  3. package/README.zh.md +21 -4
  4. package/docs/contributing.md +5 -3
  5. package/docs/env-and-commands.md +13 -0
  6. package/docs/host-api.md +35 -3
  7. package/docs/memory-semantics.md +41 -1
  8. package/docs/troubleshooting.md +395 -350
  9. package/lib/activity-log.js +190 -0
  10. package/lib/backup.js +1103 -0
  11. package/lib/binding-diagnostics.js +630 -0
  12. package/lib/client.js +703 -50
  13. package/lib/curator.js +81 -37
  14. package/lib/cycle.js +3 -0
  15. package/lib/embedding-catalog.js +763 -0
  16. package/lib/embedding-download.js +407 -0
  17. package/lib/embedding-runtime.js +710 -0
  18. package/lib/engine.js +2 -2
  19. package/lib/index.js +61 -4
  20. package/lib/inject.js +195 -49
  21. package/lib/local-embedding-entry.js +145 -0
  22. package/lib/local-embedding-manager.js +249 -0
  23. package/lib/local-embedding-protocol.js +173 -0
  24. package/lib/local-embedding-worker.js +652 -0
  25. package/lib/local-vector-service.js +283 -0
  26. package/lib/memory.js +385 -45
  27. package/lib/memu-config.js +10 -22
  28. package/lib/memu.js +53 -19
  29. package/lib/paths.js +24 -0
  30. package/lib/preset-scope.js +121 -0
  31. package/lib/private.js +139 -13
  32. package/lib/rpc.js +45 -4
  33. package/lib/state.js +109 -161
  34. package/lib/tools.js +9 -5
  35. package/lib/vector-migration.js +878 -0
  36. package/lib/vector-profile.js +941 -0
  37. package/package.json +3 -2
  38. package/scripts/acceptance-local.mjs +5 -4
  39. package/scripts/chain-matrix.mjs +527 -0
  40. package/scripts/check-all.mjs +19 -0
  41. package/scripts/check-doc-drift.mjs +353 -0
  42. package/scripts/diagnose-bindings.mjs +176 -0
  43. package/scripts/local-runtime-install.mjs +142 -0
  44. package/scripts/local-runtime-verify.mjs +56 -0
  45. package/scripts/local-vector-verify.mjs +93 -0
  46. package/scripts/recall-bench.mjs +813 -0
  47. package/scripts/repetition-ab-scenarios.mjs +93 -0
  48. package/scripts/repetition-detector-snapshot.mjs +314 -0
  49. package/scripts/repetition-eval-live.mjs +360 -0
  50. package/scripts/repetition-eval.mjs +702 -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,26 @@ 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
+ logClear: '清空日志',
135
+ runLogTitle: '整理记录',
136
+ runLogEmpty: '还没有整理记录。',
116
137
  allLibraries: '全部记忆库',
117
138
  searchMemories: '搜索标题、内容或标签',
118
139
  poolReview: '待复核',
@@ -121,6 +142,8 @@ window.__ModuleLoader__.load({
121
142
  wallLimit: '仅展示最近 300 条',
122
143
  openMemu: '打开 MemU WebUI',
123
144
  saved: '已保存',
145
+ copied: '已复制',
146
+ copyFailed: '复制失败,请手动选择文本',
124
147
  confirmAction: '确认此操作?',
125
148
  relationState: '关系当前状态',
126
149
  familiarity: '熟悉度(未知可留空)',
@@ -198,7 +221,8 @@ window.__ModuleLoader__.load({
198
221
  mustRememberCap: '永久记忆软上限',
199
222
  shortTermTtlHours: '短期记忆保留(小时,0=关闭)',
200
223
  anchorTokens: '锚点 token',
201
- turnTokens: '回合 token',
224
+ turnTokens: '召回 token',
225
+ injectionTotalTokens: '注入总 token',
202
226
  dedupeTurns: '去重轮数',
203
227
  globalEveryNTurns: '全局每 N 轮',
204
228
  minUserChars: '最短用户消息',
@@ -212,9 +236,23 @@ window.__ModuleLoader__.load({
212
236
  memoryModeHint: 'local 把记忆留在本机;cloud 交给 MemU 官方后端,需要 ~/.memu/config.env 里的厂商凭据。',
213
237
  memoryBackend: '记忆后端',
214
238
  backendAgent: '本地(会话 Agent)',
215
- backendApi: '远程(API)',
216
- backendAgentHint: '由当前对话的 Agent 自己总结并写入记忆索引;不需要 MemU、Ollama 或 API Key。',
217
- backendApiHint: '把记忆写入与检索交给配置好的远程 MemU API;需要可用的服务地址和密钥。',
239
+ backendApi: '启用向量库',
240
+ backendAgentHint: '由当前对话的 Agent 在本地 JSON/索引路径中总结与写入;不启用 MemU、worker 或 embedding。',
241
+ backendApiHint: '启用向量库后,选择本地受管模型或 API embedding;MemU 仍在本地存储与检索。',
242
+ vectorSource: 'Embedding 来源',
243
+ vectorSourceLocal: '本地',
244
+ vectorSourceApi: 'API',
245
+ vectorSourceHint: '本地使用已校验的 loopback embedding worker;API 使用下方 MemU embedding 服务配置。',
246
+ managedVectorStatus: '状态',
247
+ managedVectorDownloading: '下载中',
248
+ managedVectorVerifying: '校验中',
249
+ managedVectorFailed: '失败',
250
+ managedVectorProgress: '下载进度',
251
+ managedVectorCurrent: '当前文件',
252
+ managedVectorRetry: '重试',
253
+ managedVectorRebuilding: '重建中',
254
+ managedVectorGeneration: '代际',
255
+ managedVectorNoCatalog: '模型目录暂不可用,请稍后刷新。',
218
256
  engineLatestVersions: '最新版本',
219
257
  engineLatestHint: '从 PyPI 主动查询;插件只安装声明的推荐版本,不会自动追最新。',
220
258
  engineQueryVersions: '查询最新版本',
@@ -246,7 +284,8 @@ window.__ModuleLoader__.load({
246
284
  deleteForever: '删除',
247
285
  confirmDelete: '确认删除',
248
286
  cancelDelete: '取消',
249
- descOverview: 'MemU 引擎状态、安装修复与 embedding 服务连接。',
287
+ descOverview: 'MemU / Python 引擎的安装、修复、版本与备份。',
288
+ descConnection: '选择词法或向量召回;本地向量需先分别下载运行时与模型,再切换重建。',
250
289
  descSettings: '注入频率、召回条数、衰减与自动化。',
251
290
  descLibraries: '同文件夹的对话自动共用一个记忆库;删除对话不会删除记忆。',
252
291
  descBrowser: '按库浏览、手写、修改、归档与导出记忆。',
@@ -278,6 +317,59 @@ window.__ModuleLoader__.load({
278
317
  embedSourceLocal: '本地',
279
318
  embedSourceRemote: '远程',
280
319
  embedSourceLocalHint: '本机的 OpenAI 兼容向量服务(例如 Ollama:{url},模型 {model})。DSH 自身没有 embedding 能力,所以“本地”要求你自己先把该服务跑起来;不需要厂商 API key。',
320
+ managedVector: '受管本地向量模型',
321
+ managedVectorHint: '下载固定制品到插件数据目录;选择只记录待切换模型,切换会创建新向量空间并保留旧空间。',
322
+ managedVectorDownload: '下载并校验',
323
+ managedVectorSelect: '选择待切换',
324
+ managedVectorSwitch: '切换并重建',
325
+ managedVectorRollback: '回滚上一空间',
326
+ managedVectorTest: '测试当前模型',
327
+ managedVectorActive: '当前生效',
328
+ managedVectorRequested: '待切换',
329
+ managedVectorInstalled: '已安装',
330
+ managedVectorPending: '待核验',
331
+ managedVectorUnavailable: '不可用',
332
+ managedVectorSizePending: '大小待核验',
333
+ managedVectorFailure: '切换失败',
334
+ managedVectorRuntimeHint: '没有检测到已核实的本地运行时。点「下载运行时」把目录里 pin 住的制品装进插件数据目录:不需要手动设置 DSH_MEMU_EMBED_RUNTIME_DIR,也不需要另起 Ollama。',
335
+ managedVectorRuntimeMissing: '运行时未安装',
336
+ managedVectorRuntimeInstalling: '运行时安装中',
337
+ managedVectorRuntimeInstalled: '运行时已就绪(受管)',
338
+ managedVectorRuntimeConfigured: '运行时已就绪(环境变量)',
339
+ managedVectorRuntimeFailed: '运行时安装失败',
340
+ managedVectorRuntimeUnsupported: '此平台没有已核实的运行时制品',
341
+ managedVectorRuntimeInstall: '下载运行时',
342
+ managedVectorRuntimeTitle: '运行环境',
343
+ managedVectorModelsTitle: '模型',
344
+ managedVectorReady: '已就绪',
345
+ vectorRebuildTitle: '重建进度',
346
+ vectorStagePreparing: '准备中',
347
+ vectorStagePrepared: '已准备',
348
+ vectorStageSyncing: '写入向量',
349
+ vectorStageSwitching: '切换中',
350
+ vectorStageFailed: '失败',
351
+ vectorStageCommitted: '已切换',
352
+ managedVectorCommitted: '已写入',
353
+ managedVectorSkipped: '跳过',
354
+ managedVectorFailedCount: '失败',
355
+ vectorSwitchDoneHint: '新记忆会自动写入这个向量空间;召回已切到向量路径。',
356
+ vectorAbortedHint: '重建已停止,当前向量空间未改变;再次点击可续建。',
357
+ managedVectorAborted: '已停止',
358
+ vectorRouteLabel: '召回路由',
359
+ vectorRouteLocal: '本地向量',
360
+ vectorRouteRemote: '远程向量',
361
+ vectorRouteLexical: '词法',
362
+ vectorIndexedTag: '已入向量库',
363
+ vectorIndexedMigrated: '已入向量库(迁移)',
364
+ vectorIndexedNow: '当前已入向量库',
365
+ vectorIndexedUnit: '条',
366
+ vectorSwitchWrote: '切换时写入',
367
+ managedVectorConfirmDownload: '确认下载并校验模型制品?运行时需另行下载。',
368
+ managedVectorPrerequisites: '使用顺序:安装 MemU 引擎 → 下载运行时 → 下载并选择已核验模型 → 切换并重建。准备完成前保持词法召回;不需要 Ollama 或手动环境变量。',
369
+ managedVectorSwitchBlocked: '切换前请先下载已核验的运行时,并下载、选择模型。',
370
+ managedVectorConfirmSwitch: '确认切换?这会在新空间重建向量并短暂冻结写入。',
371
+ managedVectorConfirmRollback: '确认回滚到上一向量空间?',
372
+ managedVectorRetention: '备份最多保留 3 份,较旧的完整备份会自动清理。',
281
373
  embedSourceRemoteHint: '用你自己填写的 provider / Base URL / API Key 调第三方向量服务。',
282
374
  embedKeyNotNeeded: '本地向量服务无需 API Key',
283
375
  embedKeyRemoteHint: '只有远程向量服务需要填写 API Key',
@@ -299,7 +391,8 @@ window.__ModuleLoader__.load({
299
391
  testOk: '测试通过',
300
392
  configPath: '配置文件',
301
393
  configSaved: '已保存',
302
- pageOverview: '引擎与连接',
394
+ pageOverview: 'MemU 引擎',
395
+ pageConnection: '连接与向量',
303
396
  pageSettings: '记忆设置',
304
397
  pageLibraries: '记忆库',
305
398
  pageGlobal: '全局记忆',
@@ -478,6 +571,7 @@ window.__ModuleLoader__.load({
478
571
  privateMemory: '私有记忆',
479
572
  currentLibrary: '当前记忆库',
480
573
  domain: '领域',
574
+ domainPromptHint: '决定这次对话的提示词与记忆过滤;写代码请选 Coding,角色扮演选 RP。',
481
575
  usePrivate: '使用私有记忆',
482
576
  useGlobal: '使用全局记忆',
483
577
  organize: '整理这次对话',
@@ -526,6 +620,26 @@ window.__ModuleLoader__.load({
526
620
  reviewConditions: 'Explain distinct conditions (at least 8 characters)',
527
621
  pageMemories: 'MemU Memory',
528
622
  descMemories: 'Memories, provenance and status across libraries',
623
+ pageLogs: 'Logs',
624
+ descLogs: 'Recall, writes, consolidation and vector activity; filter, copy or download',
625
+ logSearch: 'Search id / title / error',
626
+ logRefresh: 'Refresh',
627
+ logCopyAll: 'Copy all',
628
+ logDownload: 'Download',
629
+ logCopyOne: 'Copy',
630
+ logLocate: 'Open memory',
631
+ logEmpty: 'No matching log entries.',
632
+ logAllKinds: 'All kinds',
633
+ logKindRecall: 'Recall',
634
+ logKindWrite: 'Write',
635
+ logKindConsolidate: 'Consolidate',
636
+ logKindCurate: 'Curate',
637
+ logKindVector: 'Vector',
638
+ logKindError: 'Error',
639
+ logKindSystem: 'System',
640
+ logClear: 'Clear log',
641
+ runLogTitle: 'Runs',
642
+ runLogEmpty: 'No runs recorded yet.',
529
643
  allLibraries: 'All libraries',
530
644
  searchMemories: 'Search titles, content or tags',
531
645
  poolReview: 'Review',
@@ -534,6 +648,8 @@ window.__ModuleLoader__.load({
534
648
  wallLimit: 'Showing the latest 300 entries',
535
649
  openMemu: 'Open MemU WebUI',
536
650
  saved: 'Saved',
651
+ copied: 'Copied',
652
+ copyFailed: 'Copy failed; select the text manually',
537
653
  confirmAction: 'Confirm this action?',
538
654
  relationState: 'Current relationship',
539
655
  familiarity: 'Familiarity (blank = unknown)',
@@ -611,7 +727,8 @@ window.__ModuleLoader__.load({
611
727
  mustRememberCap: 'Permanent memory cap',
612
728
  shortTermTtlHours: 'Short-term TTL in hours (0=off)',
613
729
  anchorTokens: 'Anchor tokens',
614
- turnTokens: 'Turn tokens',
730
+ turnTokens: 'Recall tokens',
731
+ injectionTotalTokens: 'Injection total tokens',
615
732
  dedupeTurns: 'Dedupe turns',
616
733
  globalEveryNTurns: 'Global every N turns',
617
734
  minUserChars: 'Min user chars',
@@ -625,9 +742,23 @@ window.__ModuleLoader__.load({
625
742
  memoryModeHint: 'local keeps memory on this machine; cloud hands storage to the MemU backend and needs vendor credentials in ~/.memu/config.env.',
626
743
  memoryBackend: 'Memory backend',
627
744
  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.',
745
+ backendApi: 'Enable vector store',
746
+ backendAgentHint: 'The session agent summarizes and writes through the local JSON/index path; no MemU, worker, or embedding is enabled.',
747
+ backendApiHint: 'Enable the vector store, then choose a managed local model or API embedding; MemU storage and retrieval remain local.',
748
+ vectorSource: 'Embedding source',
749
+ vectorSourceLocal: 'Local',
750
+ vectorSourceApi: 'API',
751
+ vectorSourceHint: 'Local uses the verified loopback embedding worker; API uses the MemU embedding service configured below.',
752
+ managedVectorStatus: 'Status',
753
+ managedVectorDownloading: 'Downloading',
754
+ managedVectorVerifying: 'Verifying',
755
+ managedVectorFailed: 'Failed',
756
+ managedVectorProgress: 'Download progress',
757
+ managedVectorCurrent: 'Current file',
758
+ managedVectorRetry: 'Retry',
759
+ managedVectorRebuilding: 'Rebuilding',
760
+ managedVectorGeneration: 'Generation',
761
+ managedVectorNoCatalog: 'The model catalog is temporarily unavailable. Refresh and try again.',
631
762
  engineLatestVersions: 'Latest versions',
632
763
  engineLatestHint: 'Queried from PyPI. The plugin still installs only its declared recommended version.',
633
764
  engineQueryVersions: 'Check latest',
@@ -659,7 +790,8 @@ window.__ModuleLoader__.load({
659
790
  deleteForever: 'Delete',
660
791
  confirmDelete: 'Delete for good',
661
792
  cancelDelete: 'Cancel',
662
- descOverview: 'MemU engine status, install/repair, and the embedding service connection.',
793
+ descOverview: 'MemU / Python engine installation, repair, versions, and backups.',
794
+ descConnection: 'Choose lexical or vector recall. Local vectors require separate runtime and model downloads before switching and rebuilding.',
663
795
  descSettings: 'Injection cadence, recall depth, decay, and automation.',
664
796
  descLibraries: 'Conversations in the same folder share one library; deleting a conversation never deletes memory.',
665
797
  descBrowser: 'Browse, write, edit, archive, and export memories per library.',
@@ -691,6 +823,59 @@ window.__ModuleLoader__.load({
691
823
  embedSourceLocal: 'Local',
692
824
  embedSourceRemote: 'Remote',
693
825
  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.',
826
+ managedVector: 'Managed local vector model',
827
+ managedVectorHint: 'Download pinned artifacts into plugin data; selection only records a pending profile. Switching rebuilds a new vector space and keeps the old one.',
828
+ managedVectorDownload: 'Download and verify',
829
+ managedVectorSelect: 'Select pending',
830
+ managedVectorSwitch: 'Switch and rebuild',
831
+ managedVectorRollback: 'Rollback previous space',
832
+ managedVectorTest: 'Test active model',
833
+ managedVectorActive: 'Active',
834
+ managedVectorRequested: 'Pending switch',
835
+ managedVectorInstalled: 'Installed',
836
+ managedVectorPending: 'Pending verification',
837
+ managedVectorUnavailable: 'Unavailable',
838
+ managedVectorSizePending: 'Size pending verification',
839
+ managedVectorFailure: 'Switch failed',
840
+ 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.',
841
+ managedVectorRuntimeMissing: 'Runtime not installed',
842
+ managedVectorRuntimeInstalling: 'Installing runtime',
843
+ managedVectorRuntimeInstalled: 'Runtime ready (managed)',
844
+ managedVectorRuntimeConfigured: 'Runtime ready (environment variable)',
845
+ managedVectorRuntimeFailed: 'Runtime install failed',
846
+ managedVectorRuntimeUnsupported: 'No verified runtime artifacts for this platform',
847
+ managedVectorRuntimeInstall: 'Download runtime',
848
+ managedVectorRuntimeTitle: 'Runtime',
849
+ managedVectorModelsTitle: 'Models',
850
+ managedVectorReady: 'Ready',
851
+ vectorRebuildTitle: 'Rebuild progress',
852
+ vectorStagePreparing: 'Preparing',
853
+ vectorStagePrepared: 'Prepared',
854
+ vectorStageSyncing: 'Writing vectors',
855
+ vectorStageSwitching: 'Switching',
856
+ vectorStageFailed: 'Failed',
857
+ vectorStageCommitted: 'Switched',
858
+ managedVectorCommitted: 'Written',
859
+ managedVectorSkipped: 'Skipped',
860
+ managedVectorFailedCount: 'Failed',
861
+ vectorSwitchDoneHint: 'New memories are written into this vector space; recall now uses it.',
862
+ vectorAbortedHint: 'The rebuild stopped; the active space is unchanged. Retry to resume.',
863
+ managedVectorAborted: 'Stopped',
864
+ vectorRouteLabel: 'Recall route',
865
+ vectorRouteLocal: 'Local vectors',
866
+ vectorRouteRemote: 'Remote vectors',
867
+ vectorRouteLexical: 'Lexical',
868
+ vectorIndexedTag: 'In vector store',
869
+ vectorIndexedMigrated: 'In vector store (from rebuild)',
870
+ vectorIndexedNow: 'Currently indexed',
871
+ vectorIndexedUnit: 'items',
872
+ vectorSwitchWrote: 'Written during switch',
873
+ managedVectorConfirmDownload: 'Download and verify this model artifact? Download the runtime separately.',
874
+ 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.',
875
+ managedVectorSwitchBlocked: 'Download a verified runtime, then download and select a model before switching.',
876
+ managedVectorConfirmSwitch: 'Switch now? This rebuilds a new vector space and briefly freezes writes.',
877
+ managedVectorConfirmRollback: 'Roll back to the previous vector space?',
878
+ managedVectorRetention: 'At most 3 complete backups are kept; older complete backups are cleaned up automatically.',
694
879
  embedSourceRemoteHint: 'Call a third-party embedding service with the provider / Base URL / API Key you enter.',
695
880
  embedKeyNotNeeded: 'No API key needed for a local embedding server',
696
881
  embedKeyRemoteHint: 'Only remote embedding services require an API key',
@@ -712,7 +897,8 @@ window.__ModuleLoader__.load({
712
897
  testOk: 'Test passed',
713
898
  configPath: 'Config file',
714
899
  configSaved: 'Saved',
715
- pageOverview: 'Engine & connection',
900
+ pageOverview: 'MemU engine',
901
+ pageConnection: 'Connection & vectors',
716
902
  pageSettings: 'Memory settings',
717
903
  pageLibraries: 'Libraries',
718
904
  pageGlobal: 'Global memory',
@@ -890,6 +1076,7 @@ window.__ModuleLoader__.load({
890
1076
  privateMemory: 'Private memory',
891
1077
  currentLibrary: 'Current library',
892
1078
  domain: 'Domain',
1079
+ domainPromptHint: 'Chooses this conversation\'s prompt variant and memory filter. Pick Coding for engineering work, RP for roleplay.',
893
1080
  usePrivate: 'Use private memory',
894
1081
  useGlobal: 'Use global memory',
895
1082
  organize: 'Organize this conversation',
@@ -1138,6 +1325,35 @@ window.__ModuleLoader__.load({
1138
1325
  .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
1326
  .dshm-card-title{font-size:13.5px;font-weight:600;line-height:1.3}
1140
1327
  .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}
1328
+ /* 连接与向量:大选择卡 + 分步卡片 + 一个显眼的主操作。原来把切换按钮塞在标题栏里,
1329
+ 三个小按钮和状态标签挤在一行,既不好点也看不出主次。 */
1330
+ .dshm-choice-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px;margin-top:10px}
1331
+ .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}
1332
+ .dshm-choice:hover:not(:disabled){transform:translateY(-1px);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary,#2563eb) 42%,transparent)}
1333
+ .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)}
1334
+ .dshm-choice:disabled{opacity:.6;cursor:not-allowed}
1335
+ .dshm-choice-top{display:flex;align-items:center;gap:8px}
1336
+ .dshm-choice-title{font-size:13.5px;font-weight:650;line-height:1.35}
1337
+ .dshm-choice-hint{font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.8))}
1338
+ .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)}
1339
+ .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}
1340
+ .dshm-step-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1341
+ .dshm-step-title{font-size:12.5px;font-weight:650;line-height:1.35}
1342
+ .dshm-step-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1343
+ .dshm-model-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}
1344
+ .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}
1345
+ .dshm-model-meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:5px}
1346
+ .dshm-primary-lg{min-height:38px;padding:0 20px;font-size:13px;border-radius:11px}
1347
+ .dshm-cta-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px}
1348
+ .dshm-rebuild{border-color:color-mix(in srgb,var(--dsw-alias-brand-primary,#2563eb) 28%,transparent)}
1349
+ .dshm-rebuild-bar{height:10px;border-radius:999px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.14));overflow:hidden}
1350
+ .dshm-rebuild-bar>span{display:block;height:100%;border-radius:999px;background:var(--dsw-alias-brand-primary,#2563eb);transition:width .32s ease}
1351
+ .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))}
1352
+ .dshm-rebuild .dshm-vector-progress{display:flex;flex-direction:column;gap:6px}
1353
+ .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))}
1354
+ .dshm-rebuild progress::-webkit-progress-bar{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.14));border-radius:999px}
1355
+ .dshm-rebuild progress::-webkit-progress-value{background:var(--dsw-alias-brand-primary,#2563eb);border-radius:999px;transition:width .3s ease}
1356
+ .dshm-rebuild progress::-moz-progress-bar{background:var(--dsw-alias-brand-primary,#2563eb);border-radius:999px}
1141
1357
  .dshm-card-actions{margin-left:auto;display:flex;gap:8px;flex:none;align-items:center}
1142
1358
 
1143
1359
  .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)}
@@ -1934,7 +2150,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
1934
2150
  item.attachmentCount > 1 ? h('span', { className: 'dshm-wall-cover-count' }, '▧ ' + item.attachmentCount) : null)
1935
2151
  }
1936
2152
 
1937
- function MemoryWallPanel({ call, libraries, revision, stamp, onOpen, t }) {
2153
+ function MemoryWallPanel({ call, libraries, revision, stamp, onOpen, t, indexed = null }) {
1938
2154
  const [data, setData] = React.useState({ items: [], total: 0, errors: [] })
1939
2155
  const [libraryId, setLibraryId] = React.useState('')
1940
2156
  const [view, setView] = React.useState('memories')
@@ -2139,6 +2355,15 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2139
2355
  h(Tag, { tone: shown.source === 'imagined' ? 'warn' : (shown.source === 'user_said' ? 'info' : undefined) },
2140
2356
  enumText(t, SOURCE_LABELS, shown.source)),
2141
2357
  h(Tag, null, enumText(t, STATUS_LABELS, shown.status)),
2358
+ // 这条记忆有没有进向量库,直接写在条目上(remember 提交成功后落盘)。
2359
+ shown.vector
2360
+ ? h(Tag, { tone: 'info' }, `${t('vectorIndexedTag')}${shown.vector.model ? ' · ' + shown.vector.model : ''}${shown.vector.generation ? ' · g' + shown.vector.generation : ''}`)
2361
+ : (() => {
2362
+ // 迁移写库不碰索引,老条目没有 entry.vector;用账本判断它是否已入当前代际。
2363
+ const bucketKey = detail.item.libraryId || 'global'
2364
+ const ids = indexed && Array.isArray(indexed[bucketKey]) ? indexed[bucketKey] : null
2365
+ return ids && ids.includes(detail.item.id) ? h(Tag, { tone: 'info' }, t('vectorIndexedMigrated')) : null
2366
+ })(),
2142
2367
  shown.openLoop ? h(Tag, null, t('openLoopTag')) : null,
2143
2368
  shown.marginalized ? h(Tag, null, t('marginalizedTag')) : null)),
2144
2369
  h('button', { type: 'button', className: 'dshm-x', 'aria-label': t('close'), onClick: () => setDetail(null) }, '×')),
@@ -2240,14 +2465,15 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2240
2465
  function SettingsPage({ ctx, t, embedded, standalone = false }) {
2241
2466
  const PAGE = {
2242
2467
  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,
2468
+ connection: 1,
2469
+ settings: 2,
2470
+ folders: 3,
2471
+ libraries: 4,
2472
+ browser: 5,
2473
+ global: 6,
2474
+ prompts: 7,
2475
+ stats: 8,
2476
+ memories: 9,
2251
2477
  }
2252
2478
  const call = useRpc(ctx)
2253
2479
  const [state, setState] = React.useState(null)
@@ -2261,6 +2487,8 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2261
2487
  const [job, setJob] = React.useState(null)
2262
2488
  const [error, setError] = React.useState('')
2263
2489
  const [busy, setBusy] = React.useState(false)
2490
+ const [vectorOperation, setVectorOperation] = React.useState('')
2491
+ const [vectorError, setVectorError] = React.useState('')
2264
2492
  // Success messages need their own slot: reusing `error` would paint a good
2265
2493
  // outcome in red and make the next real failure easy to miss.
2266
2494
  const [notice, setNotice] = React.useState('')
@@ -2269,9 +2497,30 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2269
2497
  const [configNotice, setConfigNotice] = React.useState('')
2270
2498
  const [page, setPage] = React.useState(() => standalone
2271
2499
  ? (SETTINGS_PAGES.includes(String(window.location.hash || '').replace(/^#\/settings\//, ''))
2272
- ? SETTINGS_PAGES.indexOf(String(window.location.hash || '').replace(/^#\/settings\//, '')) : 8)
2500
+ ? SETTINGS_PAGES.indexOf(String(window.location.hash || '').replace(/^#\/settings\//, '')) : PAGE.memories)
2273
2501
  : 0)
2274
2502
  const pendingSettings = React.useRef({})
2503
+ // Hook order is a contract: every hook below must run on every render,
2504
+ // including the loading branch (`if (!state) return ...` further down).
2505
+ // Putting these next to the other state declarations is what keeps the
2506
+ // count stable — a hook after that early return is React error #310.
2507
+ const [logKind, setLogKind] = React.useState('')
2508
+ const [logQueryInput, setLogQueryInput] = React.useState('')
2509
+ const [logQuery, setLogQuery] = React.useState('')
2510
+ const [logRows, setLogRows] = React.useState([])
2511
+ const [logStats, setLogStats] = React.useState(null)
2512
+ const [logBusy, setLogBusy] = React.useState(false)
2513
+ const [logNotice, setLogNotice] = React.useState('')
2514
+ const loadLogs = React.useCallback(async () => {
2515
+ setLogBusy(true)
2516
+ try {
2517
+ const data = await call('getActivityLog', { limit: 300, kind: logKind || undefined, q: logQuery || undefined })
2518
+ setLogRows(Array.isArray(data?.events) ? data.events : [])
2519
+ setLogStats(data?.stats || null)
2520
+ setLogNotice('')
2521
+ } catch (error) { setLogNotice(String(error?.message || error)) } finally { setLogBusy(false) }
2522
+ }, [call, logKind, logQuery])
2523
+ React.useEffect(() => { void loadLogs() }, [loadLogs])
2275
2524
  const settingsTimer = React.useRef(null)
2276
2525
  const saveChain = React.useRef(Promise.resolve())
2277
2526
  const [resetArmed, setResetArmed] = React.useState(false)
@@ -2550,7 +2799,8 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2550
2799
  }, [refresh])
2551
2800
  // Writes we cannot hear about over the channel — a tool call, a curator job,
2552
2801
  // another host process — land through the on-disk half of the marker.
2553
- useMemoryBeat({ call, markerRef, onBeat: refresh })
2802
+ 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)
2803
+ useMemoryBeat({ call, markerRef, onBeat: refresh, force: vectorWorking })
2554
2804
  // Sizes need a directory walk; ask for them after the page has painted.
2555
2805
  React.useEffect(() => {
2556
2806
  let alive = true
@@ -2997,7 +3247,12 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2997
3247
  const memoryBackend = settings.memoryBackend === 'api' ? 'api' : 'agent'
2998
3248
  const latestVersions = (versionInfo?.versions || []).slice(0, 3)
2999
3249
  const memuConfig = state.memuConfig || { providers: [] }
3000
- const isLocalEmbed = memuConfig.source === 'local'
3250
+ const localVector = state.localVector || { catalog: [], installations: {}, vector: {} }
3251
+ const requestedModel = localVector.vector?.requested?.catalogId
3252
+ const requestedInstall = localVector.installations?.[requestedModel] || {}
3253
+ const vectorCanSwitch = localVector.runtime?.ready === true && requestedInstall.installed === true && requestedInstall.fingerprintMatches !== false
3254
+ const vectorSource = settings.vectorSource || memuConfig.source || 'local'
3255
+ const isLocalEmbed = vectorSource === 'local'
3001
3256
  const draft = configDraft || {
3002
3257
  provider: memuConfig.provider || 'openai',
3003
3258
  model: memuConfig.model || '',
@@ -3054,7 +3309,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3054
3309
  } catch (e) { setError(String(e?.message || e)) } finally { setConfigBusy(false) }
3055
3310
  }
3056
3311
  const setEmbedSource = async (source) => {
3057
- if (source === memuConfig.source) return
3312
+ if (source === vectorSource) return
3058
3313
  setConfigBusy(true)
3059
3314
  setConfigNotice('')
3060
3315
  try {
@@ -3066,6 +3321,63 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3066
3321
  await refresh()
3067
3322
  } catch (e) { setError(String(e?.message || e)) } finally { setConfigBusy(false) }
3068
3323
  }
3324
+ const managedVectorAction = async (endpoint, payload = {}) => {
3325
+ const labels = {
3326
+ downloadLocalVectorModel: 'managedVectorDownload',
3327
+ selectLocalVectorModel: 'managedVectorSelect',
3328
+ switchLocalVectorProfile: 'managedVectorSwitch',
3329
+ rollbackLocalVectorProfile: 'managedVectorRollback',
3330
+ installLocalVectorRuntime: 'managedVectorRuntimeInstall',
3331
+ }
3332
+ setBusy(true)
3333
+ setVectorOperation(labels[endpoint] || endpoint)
3334
+ setError('')
3335
+ setNotice('')
3336
+ setVectorError('')
3337
+ try {
3338
+ await call(endpoint, payload)
3339
+ setNotice(`${t('managedVectorStatus')}: ${t(labels[endpoint] || 'managedVectorStatus')}`)
3340
+ } catch (e) {
3341
+ const message = String(e?.message || e)
3342
+ setVectorError(message)
3343
+ setError(message)
3344
+ } finally {
3345
+ await refresh()
3346
+ setBusy(false)
3347
+ setVectorOperation('')
3348
+ }
3349
+ }
3350
+ const managedVectorDownload = async (id) => {
3351
+ if (!confirmAction(`download:${id}`)) return
3352
+ await managedVectorAction('downloadLocalVectorModel', { id, confirmed: true })
3353
+ }
3354
+ const managedVectorSelect = async (id) => managedVectorAction('selectLocalVectorModel', { id })
3355
+ const managedVectorSwitch = async () => {
3356
+ if (!vectorCanSwitch) { setVectorError(t('managedVectorSwitchBlocked')); return }
3357
+ if (!confirmAction('switch')) return
3358
+ await managedVectorAction('switchLocalVectorProfile', { confirmed: true })
3359
+ }
3360
+ const managedVectorRollback = async () => {
3361
+ if (!confirmAction('rollback')) return
3362
+ await managedVectorAction('rollbackLocalVectorProfile', { confirmed: true })
3363
+ }
3364
+ const managedVectorRuntimeInstall = async () => {
3365
+ if (!confirmAction('runtime')) return
3366
+ await managedVectorAction('installLocalVectorRuntime', { confirmed: true })
3367
+ }
3368
+
3369
+ const renderVectorProgress = (progress, migration = false) => {
3370
+ if (!progress) return null
3371
+ const total = Number(migration ? progress.total : progress.totalBytes) || 0
3372
+ const done = Number(migration ? progress.completed : progress.bytes) || 0
3373
+ const label = t(migration ? 'managedVectorRebuilding' : 'managedVectorProgress')
3374
+ const current = migration ? progress.current?.itemId : progress.current
3375
+ return h('div', { className: 'dshm-vector-progress' },
3376
+ h('div', { role: 'status', 'aria-live': 'polite' }, `${label}: ${done} / ${total}${migration ? ` · ${t('managedVectorGeneration')} ${progress.generation} · ${progress.stage}` : ' bytes'}`),
3377
+ h('progress', { max: total || 1, value: total > 0 ? Math.min(done, total) : undefined, 'aria-label': label }),
3378
+ current ? h('div', { style: S.muted }, `${t('managedVectorCurrent')}: ${current}`) : null)
3379
+ }
3380
+
3069
3381
  const engineStatusText = engine.status === 'ready' ? t('engineReady')
3070
3382
  : engine.status === 'installing' ? t('engineInstalling')
3071
3383
  : engine.status === 'error' ? t('engineError') : t('engineIdle')
@@ -3224,15 +3536,12 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3224
3536
  style: { ...S.ghost, color: V.danger },
3225
3537
  }, actionLabel('backup:' + b.name, t('backupDelete'))))))))),
3226
3538
 
3539
+ )
3540
+
3541
+ const connectionBlock = h(React.Fragment, null,
3227
3542
  h(Section, {
3228
3543
  title: t('memoryBackend'),
3229
3544
  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
3545
  },
3237
3546
  h('div', { style: { ...S.row, marginTop: 4 } },
3238
3547
  h('div', { className: 'dshm-nav-list is-row', role: 'tablist' },
@@ -3245,7 +3554,13 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3245
3554
  className: `dshm-nav-item${memoryBackend === value ? ' on' : ''}`,
3246
3555
  onClick: () => setBackend(value),
3247
3556
  }, label)))),
3248
- memoryBackend === 'api'
3557
+ memoryBackend === 'api' ? h(Field, { label: t('vectorSource') },
3558
+ h('div', { className: 'dshm-nav-list is-row', role: 'tablist', 'aria-label': t('vectorSource') },
3559
+ [['local', t('vectorSourceLocal')], ['api', t('vectorSourceApi')]].map(([value, label]) => h('button', {
3560
+ key: value, type: 'button', role: 'tab', title: label, 'aria-selected': vectorSource === value,
3561
+ disabled: configBusy || busy, className: `dshm-nav-item${vectorSource === value ? ' on' : ''}`, onClick: () => setEmbedSource(value),
3562
+ }, label))), h('div', { style: S.muted }, t('vectorSourceHint'))) : null,
3563
+ memoryBackend === 'api' && !isLocalEmbed
3249
3564
  ? h(React.Fragment, null,
3250
3565
  h('div', { style: S.muted }, `${t('configPath')}: ${memuConfig.path || '—'}`),
3251
3566
  h(Field, { label: t('provider') },
@@ -3275,7 +3590,178 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3275
3590
  onChange: (e) => setConfig('summaryApiKey', e.target.value),
3276
3591
  })))
3277
3592
  : null,
3278
- configNotice ? h('div', { style: { ...S.muted, marginTop: 6 } }, configNotice) : null)
3593
+ configNotice ? h('div', { style: { ...S.muted, marginTop: 6 } }, configNotice) : null,
3594
+ memoryBackend === 'api'
3595
+ ? h('div', { className: 'dshm-cta-row' },
3596
+ h(Button, { onClick: saveConfig, disabled: configBusy, primary: true, className: 'dshm-primary-lg' }, t('saveConfig')),
3597
+ h(Button, { onClick: testConfig, disabled: configBusy }, t('testConfig')),
3598
+ h(Button, { onClick: clearKey, disabled: configBusy, style: S.ghost }, t('clearKey')))
3599
+ : null),
3600
+ memoryBackend === 'api' && isLocalEmbed ? h(Section, {
3601
+ title: t('managedVector'),
3602
+ desc: t('managedVectorHint'),
3603
+ right: h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' } },
3604
+ vectorOperation ? h('span', { role: 'status', 'aria-live': 'polite' }, h(Tag, { tone: 'info' }, `${t('managedVectorStatus')}: ${t(vectorOperation)}`)) : null,
3605
+ localVector.vector?.active ? h(Tag, { tone: 'info' }, `${t('managedVectorActive')}: ${localVector.vector.active.catalogId}${localVector.vector.active.generation ? ' · g' + localVector.vector.active.generation : ''}`) : null,
3606
+ localVector.vector?.requested ? h(Tag, { tone: 'warn' }, `${t('managedVectorRequested')}: ${localVector.vector.requested.catalogId || ''}`) : null,
3607
+ h(Tag, localVector.vector?.active ? { tone: 'info' } : null,
3608
+ `${t('vectorRouteLabel')}: ${localVector.vector?.active ? (vectorSource === 'api' ? t('vectorRouteRemote') : t('vectorRouteLocal')) : t('vectorRouteLexical')}`)),
3609
+ },
3610
+ // 全页只有一个显眼的主操作。条件不满足时保持可见但禁用,并把原因写在旁边:
3611
+ // 把按钮藏起来,用户只会看到“什么都没发生”。
3612
+ h('div', { className: 'dshm-cta-row' },
3613
+ h(Button, {
3614
+ onClick: managedVectorSwitch,
3615
+ disabled: busy || !vectorCanSwitch || !localVector.vector?.requested,
3616
+ primary: true,
3617
+ className: 'dshm-primary-lg',
3618
+ title: t(vectorCanSwitch ? 'managedVectorSwitch' : 'managedVectorSwitchBlocked'),
3619
+ }, actionLabel('switch', t('managedVectorSwitch'))),
3620
+ localVector.vector?.active
3621
+ ? h(Button, { onClick: managedVectorRollback, disabled: busy, title: t('managedVectorRollback'), style: S.ghost }, actionLabel('rollback', t('managedVectorRollback')))
3622
+ : null,
3623
+ h('span', { style: { ...S.muted, color: vectorCanSwitch ? V.textDim : V.warn } },
3624
+ t(vectorCanSwitch ? 'managedVectorPrerequisites' : 'managedVectorSwitchBlocked'))),
3625
+ // 重建进度单独成块。三种终态分开渲染:committed 是“已切换”,failed 是失败,
3626
+ // preparing/syncing 才是进行中。之前把 active.lastMigration 也当进度,成功之后
3627
+ // 还会一直显示“重建中 8/8”,让人无法判断到底切没切。
3628
+ (() => {
3629
+ const activeVector = localVector.vector?.active || null
3630
+ const pending = localVector.vector?.pending
3631
+ if (!pending) {
3632
+ const last = activeVector?.lastMigration || null
3633
+ if (!activeVector) return null
3634
+ return h('div', { className: 'dshm-step dshm-rebuild' },
3635
+ h('div', { className: 'dshm-step-head' },
3636
+ h('span', { className: 'dshm-step-title' }, t('vectorRebuildTitle')),
3637
+ h(Tag, { tone: 'info' }, t('vectorStageCommitted')),
3638
+ activeVector.generation ? h(Tag, null, `${t('managedVectorGeneration')} ${activeVector.generation}`) : null,
3639
+ activeVector.fingerprint ? h('span', { style: S.muted }, String(activeVector.fingerprint).slice(0, 8)) : null),
3640
+ last ? h('div', { style: { fontSize: 12.5 } }, `${t('vectorSwitchWrote')} ${last.committed || 0}${last.total ? ' / ' + last.total : ''}`) : null,
3641
+ (() => {
3642
+ const counts = localVector.vector?.indexed
3643
+ const total = counts ? Object.values(counts).reduce((sum, ids) => sum + (Array.isArray(ids) ? ids.length : 0), 0) : null
3644
+ return total === null ? null : h('div', { style: S.muted }, `${t('vectorIndexedNow')} ${total} ${t('vectorIndexedUnit')}`)
3645
+ })(),
3646
+ h('div', { style: S.muted }, t('vectorSwitchDoneHint')))
3647
+ }
3648
+ const progress = pending.report?.progress || null
3649
+ const status = String(pending.status || '')
3650
+ const failedItems = Array.isArray(pending.error?.failedItems) ? pending.error.failedItems : []
3651
+ const total = Number(progress?.total) || 0
3652
+ const done = Number(progress?.completed) || 0
3653
+ const committed = Number(progress?.committed) || 0
3654
+ const generation = progress?.generation ?? pending.generation
3655
+ const fingerprint = progress?.fingerprint ?? pending.fingerprint
3656
+ const head = (label, tone) => h('div', { className: 'dshm-step-head' },
3657
+ h('span', { className: 'dshm-step-title' }, t('vectorRebuildTitle')),
3658
+ label ? h(Tag, tone ? { tone } : null, label) : null,
3659
+ generation ? h(Tag, null, `${t('managedVectorGeneration')} ${generation}`) : null,
3660
+ fingerprint ? h('span', { style: S.muted }, String(fingerprint).slice(0, 8)) : null)
3661
+ if (status === 'committed') {
3662
+ return h('div', { className: 'dshm-step dshm-rebuild' },
3663
+ head(t('vectorStageCommitted'), 'info'),
3664
+ h('div', { style: { fontSize: 12.5 } }, `${t('managedVectorCommitted')} ${done}${total ? ' / ' + total : ''}`),
3665
+ h('div', { style: S.muted }, t('vectorSwitchDoneHint')))
3666
+ }
3667
+ if (status === 'aborted') {
3668
+ return h('div', { className: 'dshm-step dshm-rebuild' },
3669
+ head(t('managedVectorAborted'), 'warn'),
3670
+ h('div', { style: S.muted }, t('vectorAbortedHint')))
3671
+ }
3672
+ if (status === 'failed') {
3673
+ return h('div', { className: 'dshm-step dshm-rebuild' },
3674
+ head(t('vectorStageFailed'), 'warn'),
3675
+ total ? h('div', { style: S.muted }, `${t('managedVectorCommitted')} ${committed} / ${total}`) : null,
3676
+ failedItems.length
3677
+ ? h('div', { style: { ...S.muted, color: V.danger, display: 'flex', flexDirection: 'column', gap: 2 } },
3678
+ failedItems.slice(0, 6).map(item => h('div', { key: String(item.itemId || item.libraryId) + item.code },
3679
+ `${item.itemId || item.libraryId || '?'} · ${item.code} · ${item.message || ''}`)))
3680
+ : null)
3681
+ }
3682
+ return h('div', { className: 'dshm-step dshm-rebuild' },
3683
+ head(progress ? t('vectorStageSyncing') : null, 'info'),
3684
+ renderVectorProgress(progress, true),
3685
+ h('div', { className: 'dshm-rebuild-meta' },
3686
+ committed ? h('span', null, `${t('managedVectorCommitted')} ${committed}`) : null,
3687
+ progress?.skipped ? h('span', null, `${t('managedVectorSkipped')} ${progress.skipped}`) : null,
3688
+ progress?.failed ? h('span', { style: { color: V.danger } }, `${t('managedVectorFailedCount')} ${progress.failed}`) : null,
3689
+ progress?.current?.itemId ? h('span', { style: S.muted }, String(progress.current.itemId)) : null),
3690
+ failedItems.length
3691
+ ? h('div', { style: { ...S.muted, color: V.danger, display: 'flex', flexDirection: 'column', gap: 2 } },
3692
+ failedItems.slice(0, 6).map(item => h('div', { key: String(item.itemId || item.libraryId) + item.code },
3693
+ `${item.itemId || item.libraryId || '?'} · ${item.code} · ${item.message || ''}`)))
3694
+ : null)
3695
+ })(),
3696
+ (() => {
3697
+ const failure = vectorError || localVector.vector?.pending?.error?.message || ''
3698
+ if (!failure) return null
3699
+ return h('div', { role: 'alert', style: { ...S.muted, color: V.danger, marginTop: 4 } },
3700
+ h('strong', null, `${t('managedVectorFailure')}: `), failure)
3701
+ })(),
3702
+ // 步骤 1:运行环境
3703
+ (() => {
3704
+ const runtime = localVector.runtime || {}
3705
+ const busyRuntime = ['downloading', 'verifying', 'installing'].includes(runtime.status)
3706
+ const ready = runtime.ready === true
3707
+ const label = ready
3708
+ ? (runtime.source === 'env' ? t('managedVectorRuntimeConfigured') : t('managedVectorRuntimeInstalled'))
3709
+ : runtime.status === 'unsupported' ? t('managedVectorRuntimeUnsupported')
3710
+ : runtime.status === 'failed' ? t('managedVectorRuntimeFailed')
3711
+ : busyRuntime ? t('managedVectorRuntimeInstalling') : t('managedVectorRuntimeMissing')
3712
+ const announce = busyRuntime || Boolean(runtime.error?.message)
3713
+ return h('div', { className: 'dshm-step', ...(announce ? { role: 'status', 'aria-live': 'polite' } : {}) },
3714
+ h('div', { className: 'dshm-step-head' },
3715
+ h('span', { className: 'dshm-step-title' }, '1 · ' + t('managedVectorRuntimeTitle')),
3716
+ h(Tag, ready ? { tone: 'info' } : { tone: 'warn' }, label)),
3717
+ ready
3718
+ ? h('div', { style: S.muted }, runtime.version ? `v${runtime.version}` : t('managedVectorReady'))
3719
+ : h('div', { style: S.muted }, t('managedVectorRuntimeHint')),
3720
+ !ready && !busyRuntime && runtime.status !== 'unsupported'
3721
+ ? h('div', { className: 'dshm-step-actions' },
3722
+ h(Button, { onClick: managedVectorRuntimeInstall, disabled: busy, primary: true, title: t('managedVectorRuntimeInstall') }, actionLabel('runtime', t('managedVectorRuntimeInstall'))))
3723
+ : null,
3724
+ runtime.error?.message ? h('div', { style: { ...S.muted, color: V.danger } }, runtime.error.message) : null,
3725
+ renderVectorProgress(runtime.progress))
3726
+ })(),
3727
+ // 步骤 2:模型
3728
+ h('div', { className: 'dshm-step' },
3729
+ h('div', { className: 'dshm-step-head' },
3730
+ h('span', { className: 'dshm-step-title' }, '2 · ' + t('managedVectorModelsTitle')),
3731
+ h(Tag, null, String((localVector.catalog || []).length))),
3732
+ (localVector.catalog || []).length === 0
3733
+ ? h('div', { style: S.muted }, t('managedVectorNoCatalog'))
3734
+ : h('div', { className: 'dshm-model-grid' }, (localVector.catalog || []).map(entry => {
3735
+ const install = localVector.installations?.[entry.id] || {}
3736
+ const isActive = localVector.vector?.active?.catalogId === entry.id
3737
+ const isRequested = localVector.vector?.requested?.catalogId === entry.id
3738
+ const canSelect = install.installed === true && install.fingerprintMatches !== false
3739
+ const pendingArtifact = entry.availability === 'pending' || (entry.pendingReasons || []).length > 0
3740
+ const sizeText = Number(entry.totalBytes) > 0 ? `${Math.max(1, Math.round(entry.totalBytes / 1024))} KB` : t('managedVectorSizePending')
3741
+ const statusText = install.installed ? t('managedVectorInstalled') : pendingArtifact ? t('managedVectorPending') : (install.status || entry.availability || t('managedVectorUnavailable'))
3742
+ return h('div', { key: entry.id, className: 'dshm-model' },
3743
+ h('div', null,
3744
+ h('div', { className: 'dshm-step-title' }, entry.displayName || entry.id),
3745
+ h('div', { className: 'dshm-model-meta' },
3746
+ h(Tag, null, `${entry.dimension || '?'}d`),
3747
+ h(Tag, null, sizeText),
3748
+ h(Tag, install.installed ? { tone: 'info' } : pendingArtifact ? { tone: 'warn' } : null, statusText),
3749
+ isActive ? h(Tag, { tone: 'info' }, t('managedVectorActive')) : null,
3750
+ isRequested ? h(Tag, { tone: 'warn' }, t('managedVectorRequested')) : null)),
3751
+ h('div', { className: 'dshm-step-actions' },
3752
+ !install.installed && entry.availability === 'available'
3753
+ ? h(Button, { onClick: () => managedVectorDownload(entry.id), disabled: busy, primary: true, title: t('managedVectorDownload') }, actionLabel(`download:${entry.id}`, t('managedVectorDownload')))
3754
+ : null,
3755
+ canSelect && !isActive
3756
+ ? h(Button, { onClick: () => managedVectorSelect(entry.id), disabled: busy, title: t('managedVectorSelect'), style: S.ghost }, t('managedVectorSelect'))
3757
+ : null),
3758
+ install.error?.message || (entry.pendingReasons || []).length
3759
+ ? h('div', { style: S.muted, role: 'status' }, install.error?.message || (entry.pendingReasons || []).join(';'))
3760
+ : null,
3761
+ renderVectorProgress(install.progress))
3762
+ })))
3763
+ ) : null,
3764
+
3279
3765
  )
3280
3766
 
3281
3767
  const settingsBlock = h(React.Fragment, null, h(Section, {
@@ -3292,8 +3778,9 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3292
3778
  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
3779
  h(Field, { label: t('mustRememberCap') }, h('input', { type: 'number', style: S.input, value: settings.mustRememberCap ?? 16, onChange: (e) => setSetting('mustRememberCap', Number(e.target.value)) })),
3294
3780
  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)) })),
3781
+ h(Field, { label: t('anchorTokens') }, h('input', { type: 'number', style: S.input, value: settings.anchorTokens ?? 800, onChange: (e) => setSetting('anchorTokens', Number(e.target.value)) })),
3782
+ h(Field, { label: t('turnTokens') }, h('input', { type: 'number', style: S.input, value: settings.turnTokens ?? 800, onChange: (e) => setSetting('turnTokens', Number(e.target.value)) })),
3783
+ h(Field, { label: t('injectionTotalTokens') }, h('input', { type: 'number', style: S.input, value: settings.injectionTotalTokens ?? 2000, onChange: (e) => setSetting('injectionTotalTokens', Number(e.target.value)) })),
3297
3784
  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
3785
  h(Field, { label: t('globalEveryNTurns') }, h('input', { type: 'number', style: S.input, value: settings.globalEveryNTurns ?? 3, onChange: (e) => setSetting('globalEveryNTurns', Number(e.target.value)) })),
3299
3786
  h(Field, { label: t('minUserChars') }, h('input', { type: 'number', style: S.input, value: settings.minUserChars ?? 6, onChange: (e) => setSetting('minUserChars', Number(e.target.value)) })),
@@ -3903,8 +4390,39 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3903
4390
  t('statsWindow').split('{days}').join(String(statsData.ratio.windowDays))))
3904
4391
  : h(Empty, null, t('statsUnavailable'))))
3905
4392
 
4393
+ // Clipboard and download are the two things a person actually does with a
4394
+ // log: paste the row into an issue, or keep the file. execCommand is the
4395
+ // fallback because the standalone page can run without clipboard permission.
4396
+ const copyText = async (value) => {
4397
+ const text = String(value ?? '')
4398
+ try { await navigator.clipboard.writeText(text); return true } catch {}
4399
+ try {
4400
+ const area = document.createElement('textarea')
4401
+ area.value = text
4402
+ area.style.position = 'fixed'
4403
+ area.style.opacity = '0'
4404
+ document.body.appendChild(area)
4405
+ area.select()
4406
+ const ok = document.execCommand('copy')
4407
+ area.remove()
4408
+ return ok
4409
+ } catch { return false }
4410
+ }
4411
+ const downloadLogs = () => {
4412
+ try {
4413
+ const blob = new Blob([logRows.map(row => JSON.stringify(row)).join('\n')], { type: 'application/x-ndjson' })
4414
+ const url = URL.createObjectURL(blob)
4415
+ const link = document.createElement('a')
4416
+ link.href = url
4417
+ link.download = `dsh-memu-activity-${new Date().toISOString().slice(0, 10)}.jsonl`
4418
+ link.click()
4419
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
4420
+ } catch (error) { setLogNotice(String(error?.message || error)) }
4421
+ }
4422
+
3906
4423
  const pageDefs = [
3907
4424
  { label: t('pageOverview'), overline: 'ENGINE', desc: t('descOverview'), icon: GlyphGauge },
4425
+ { label: t('pageConnection'), overline: 'CONNECTION', desc: t('descConnection'), icon: GlyphGlobe },
3908
4426
  { label: t('pageSettings'), overline: 'BEHAVIOUR', desc: t('descSettings'), icon: GlyphSliders },
3909
4427
  { label: t('pageFolders'), overline: 'SCOPE', desc: t('descFolders'), icon: GlyphFolder },
3910
4428
  { label: t('pageLibraries'), overline: 'LIBRARIES', desc: t('descLibraries'), icon: GlyphStack },
@@ -3914,12 +4432,90 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3914
4432
  { label: t('pageStats'), overline: 'STATS', desc: t('descStats'), icon: GlyphChart },
3915
4433
  ]
3916
4434
  pageDefs.push({ label: t('pageMemories'), overline: 'MEMU', desc: t('descMemories'), icon: GlyphLayers })
4435
+ pageDefs.push({ label: t('pageLogs'), overline: 'ACTIVITY', desc: t('descLogs'), icon: GlyphText })
3917
4436
  const memoriesBlock = h(MemoryWallPanel, { call, libraries: state.libraries || [], revision: state.revision, stamp: state.stamp, t,
4437
+ indexed: localVector.vector?.indexed || null,
3918
4438
  onOpen: item => {
3919
4439
  if (item.libraryId) { setBrowseLibId(item.libraryId); setPage(PAGE.browser); void openBrowser(item.libraryId) }
3920
4440
  else setPage(PAGE.global)
3921
4441
  } })
3922
- const pageBlocks = [engineBlock, settingsBlock, foldersBlock, librariesBlock, browserBlock, globalBlock, promptsBlock, statsBlock, memoriesBlock]
4442
+
4443
+ const LOG_KIND_LABELS = {
4444
+ recall: t('logKindRecall'), write: t('logKindWrite'), consolidate: t('logKindConsolidate'),
4445
+ curate: t('logKindCurate'), vector: t('logKindVector'), error: t('logKindError'), system: t('logKindSystem'),
4446
+ }
4447
+ const logsBlock = h(React.Fragment, null,
4448
+ h(Section, {
4449
+ title: t('pageLogs'),
4450
+ desc: t('descLogs'),
4451
+ right: h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
4452
+ h(Button, {
4453
+ style: S.ghost,
4454
+ onClick: async () => {
4455
+ const ok = await copyText(logRows.map(row => JSON.stringify(row)).join('\n'))
4456
+ setLogNotice(ok ? t('copied') : t('copyFailed'))
4457
+ },
4458
+ }, t('logCopyAll')),
4459
+ h(Button, { style: S.ghost, onClick: downloadLogs }, t('logDownload')),
4460
+ h(Button, {
4461
+ style: S.ghost,
4462
+ onClick: async () => {
4463
+ try { await call('clearActivityLog'); await loadLogs() } catch (error) { setLogNotice(String(error?.message || error)) }
4464
+ },
4465
+ }, t('logClear'))),
4466
+ },
4467
+ h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 } },
4468
+ h('select', {
4469
+ style: S.input,
4470
+ 'aria-label': t('pageLogs'),
4471
+ value: logKind,
4472
+ onChange: (event) => setLogKind(event.target.value),
4473
+ },
4474
+ h('option', { value: '' }, t('logAllKinds')),
4475
+ ['recall', 'write', 'consolidate', 'curate', 'vector', 'error'].map(kind => h('option', { key: kind, value: kind }, LOG_KIND_LABELS[kind]))),
4476
+ h('input', {
4477
+ style: S.input,
4478
+ value: logQueryInput,
4479
+ placeholder: t('logSearch'),
4480
+ 'aria-label': t('logSearch'),
4481
+ onChange: (event) => setLogQueryInput(event.target.value),
4482
+ onKeyDown: (event) => { if (event.key === 'Enter') setLogQuery(logQueryInput) },
4483
+ }),
4484
+ h(Button, { onClick: () => { setLogQuery(logQueryInput); void loadLogs() }, disabled: logBusy }, t('logRefresh'))),
4485
+ logStats
4486
+ ? h('div', { style: S.muted }, `${logStats.total} / ${logStats.capped} · ${Math.max(1, Math.round((logStats.bytes || 0) / 1024))} KB`)
4487
+ : null,
4488
+ logNotice ? h('div', { style: { ...S.muted, marginTop: 6, color: V.accent } }, logNotice) : null,
4489
+ logRows.length === 0
4490
+ ? h(Empty, null, t('logEmpty'))
4491
+ : h('div', { style: S.list }, logRows.map(row => h('div', { key: row.id, style: S.item },
4492
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center' } },
4493
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center', minWidth: 0, flexWrap: 'wrap' } },
4494
+ h(Tag, { tone: row.level === 'error' ? 'warn' : undefined }, LOG_KIND_LABELS[row.kind] || row.kind),
4495
+ h('span', { style: S.muted }, new Date(row.at).toLocaleString()),
4496
+ row.sessionId ? h('span', { style: S.muted }, row.sessionId.slice(-10)) : null,
4497
+ row.libraryId ? h('span', { style: S.muted }, row.libraryId) : null),
4498
+ h('div', { style: { display: 'flex', gap: 6, flexShrink: 0 } },
4499
+ row.libraryId ? h(Button, {
4500
+ style: S.ghost,
4501
+ // 纠错入口:从日志直接回到这条记忆所在的库,按 id 搜到它再改。
4502
+ onClick: () => { setBrowseLibId(row.libraryId); setPage(PAGE.browser); void openBrowser(row.libraryId) },
4503
+ }, t('logLocate')) : null,
4504
+ h(Button, {
4505
+ style: S.ghost,
4506
+ onClick: async () => {
4507
+ const ok = await copyText(JSON.stringify(row, null, 2))
4508
+ setLogNotice(ok ? t('copied') : t('copyFailed'))
4509
+ },
4510
+ }, t('logCopyOne')))),
4511
+ h('div', { style: { marginTop: 4, whiteSpace: 'pre-wrap' } }, row.message),
4512
+ Array.isArray(row.refs) && row.refs.length
4513
+ ? h('div', { style: { ...S.muted, marginTop: 4 } }, row.refs.join(' · '))
4514
+ : null,
4515
+ row.detail
4516
+ ? h('pre', { style: { ...S.muted, margin: '6px 0 0', fontSize: 11, whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, JSON.stringify(row.detail).slice(0, 600))
4517
+ : null)))))
4518
+ const pageBlocks = [engineBlock, connectionBlock, settingsBlock, foldersBlock, librariesBlock, browserBlock, globalBlock, promptsBlock, statsBlock, memoriesBlock, logsBlock]
3923
4519
 
3924
4520
  // Settings uses a left nav with a sliding indicator (the sidebar keeps its
3925
4521
  // top segments). The indicator is one absolutely-positioned element moved by
@@ -4040,9 +4636,14 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4040
4636
  // The conversation picker belongs to the Global tab only, so its (slow)
4041
4637
  // session listing is deferred until that tab is actually opened.
4042
4638
  React.useEffect(() => {
4639
+ // Below, a render without a session returns before `pages` is built; an
4640
+ // effect scheduled by that render still runs, and reading `pages` from it
4641
+ // is a temporal-dead-zone error ("Cannot access 'pages' before
4642
+ // initialization") shown inside the sidebar. No session, no panel — leave.
4643
+ if (!sessionId) return
4043
4644
  if (pages[tabPage]?.key !== 'global' || sessionsLoaded || !loaded) return
4044
4645
  void loadSidebarSessions()
4045
- }, [tabPage, sessionsLoaded, loaded, loadSidebarSessions])
4646
+ }, [sessionId, tabPage, sessionsLoaded, loaded, loadSidebarSessions])
4046
4647
 
4047
4648
  React.useEffect(() => { void refresh() }, [refresh])
4048
4649
  // Instant path: another pane of this window wrote a memory (browser add /
@@ -4084,6 +4685,26 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4084
4685
  .catch((e) => setError(String(e?.message || e)))
4085
4686
  }, [state?.binding?.scopeKey, sessionsState.byId, sessionId, call, refresh])
4086
4687
 
4688
+ // 整理记录:点击后显示在按钮下方,替代原来那行"已在后台子会话里整理"的提示。
4689
+ // 数据来自活动日志(getActivityLog),所以整理进行中能看到"已开始",结束后能看到
4690
+ // 完成/超时/失败和写入的记录,刷新页面也不会丢。
4691
+ // Hook 必须在该组件的每个渲染路径上都执行:`if (!sessionId) return ...` 在其后,
4692
+ // 所以这三个 hook 只能放在那行提前 return 之前(否则就是 React #310)。
4693
+ const [runLog, setRunLog] = React.useState([])
4694
+ const loadRunLog = React.useCallback(async () => {
4695
+ if (!sessionId) return
4696
+ try {
4697
+ const data = await call('getActivityLog', { sessionId, limit: 12 })
4698
+ setRunLog(Array.isArray(data?.events) ? data.events : [])
4699
+ } catch {}
4700
+ }, [call, sessionId])
4701
+ React.useEffect(() => { void loadRunLog() }, [loadRunLog])
4702
+ React.useEffect(() => {
4703
+ if (!busy || !sessionId) return undefined
4704
+ const timer = setInterval(() => { void loadRunLog() }, 1500)
4705
+ return () => clearInterval(timer)
4706
+ }, [busy, sessionId, loadRunLog])
4707
+
4087
4708
  if (!sessionId) return h('div', { style: S.tab }, t('loading'))
4088
4709
  const binding = state?.binding
4089
4710
  const summary = sessionsState.byId?.[sessionId]
@@ -4126,12 +4747,9 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4126
4747
  setNotice('')
4127
4748
  try {
4128
4749
  const result = await call('generatePrivate', { sessionId })
4129
- setNotice(result?.ok
4130
- ? (result.backend === 'api'
4131
- ? t('organizedRemote').split('{count}').join(String(result.count ?? 0))
4132
- : t('organizedAgent'))
4133
- : (result?.error || ''))
4134
- } catch (e) { setError(String(e?.message || e)) } finally { setBusy(false) }
4750
+ // 失败不再走顶部提示:活动日志里有完整原因和 fork id,面板就在下面。
4751
+ if (result?.ok !== true && result?.error) setError(result.error)
4752
+ } catch (e) { setError(String(e?.message || e)) } finally { setBusy(false); void loadRunLog() }
4135
4753
  }
4136
4754
  const startCuration = async () => {
4137
4755
  if (selected.length === 0) return
@@ -4150,13 +4768,24 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4150
4768
  } catch (e) { setError(String(e?.message || e)) }
4151
4769
  }
4152
4770
 
4771
+ const RUN_KIND_LABELS = {
4772
+ recall: t('logKindRecall'), write: t('logKindWrite'), consolidate: t('logKindConsolidate'),
4773
+ curate: t('logKindCurate'), vector: t('logKindVector'), error: t('logKindError'), system: t('logKindSystem'),
4774
+ }
4775
+ const renderRunRow = (row) => h('div', { key: row.id, style: S.item },
4776
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
4777
+ h(Tag, { tone: row.level === 'error' ? 'warn' : undefined }, RUN_KIND_LABELS[row.kind] || row.kind),
4778
+ h('span', { style: S.muted }, new Date(row.at).toLocaleTimeString())),
4779
+ h('div', { style: { marginTop: 2, whiteSpace: 'pre-wrap', fontSize: 12 } }, row.message))
4780
+
4153
4781
  const bindingBlock = !binding
4154
4782
  ? h(Section, { title: t('noBinding') },
4155
4783
  h(Button, { onClick: init, disabled: busy || !summary?.cwd, primary: true }, t('initMemory')),
4156
4784
  // A library is a folder, so an unset workspace is a real blocker, not a
4157
4785
  // detail to discover by clicking.
4158
4786
  !summary?.cwd ? h('div', { style: { ...S.muted, marginTop: 8 } }, t('needsWorkspaceFirst')) : null)
4159
- : h('div', { style: S.section },
4787
+ : h(React.Fragment, null,
4788
+ h('div', { style: S.section },
4160
4789
  // Both layers off in the folder: say so, instead of showing two dead
4161
4790
  // checkboxes the user cannot explain.
4162
4791
  effective && !effective.enabled
@@ -4187,7 +4816,15 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4187
4816
  ['general', ...(state?.domains || [])].filter((d, i, list) => list.indexOf(d) === i)
4188
4817
  .map((d) => h('option', { key: d, value: d }, domainText(t, d))))),
4189
4818
  h('div', { style: { display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' } },
4190
- h(Button, { onClick: organize, disabled: busy, primary: true }, t('organize'))))
4819
+ h(Button, { onClick: organize, disabled: busy, primary: true }, t('organize'))),
4820
+ h('div', { style: { marginTop: 12 } },
4821
+ h('div', { style: { ...S.muted, marginBottom: 4, fontWeight: 600 } }, t('runLogTitle')),
4822
+ runLog.length === 0
4823
+ ? h('div', { style: S.muted }, t('runLogEmpty'))
4824
+ : h('div', { style: S.list }, runLog.slice(0, 8).map(renderRunRow)))),
4825
+ // 作用域放在控制卡之后:它是一组低频字段,先让用户看到记忆开关和整理入口。
4826
+ h(ContextScope, { binding, sessionId, call, t })
4827
+ )
4191
4828
 
4192
4829
  const renderPrivateItem = (f) => h('div', { key: f.id, style: S.item },
4193
4830
  h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8 } },
@@ -4293,7 +4930,6 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4293
4930
  }, p.label)))),
4294
4931
  error ? h('div', { style: { ...S.muted, padding: '8px 12px 0', color: V.danger } }, error) : null,
4295
4932
  notice ? h('div', { style: { ...S.muted, padding: '8px 12px 0' } }, notice) : null,
4296
- h(ContextScope, { binding, sessionId, call, t }),
4297
4933
  h('div', { style: S.tabBody }, pages[tabPage]?.node || null))
4298
4934
  }
4299
4935
 
@@ -4326,6 +4962,10 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4326
4962
  const [libraryId, setLibraryId] = React.useState('')
4327
4963
  const [newName, setNewName] = React.useState('')
4328
4964
  const [dontShow, setDontShow] = React.useState(false)
4965
+ // The domain decides both the prompt variant and the memory filter. It
4966
+ // used to live only in the sidebar, which is exactly the switch people
4967
+ // forget after starting a coding session in an RP workspace.
4968
+ const [domain, setDomain] = React.useState('rp')
4329
4969
  const [busy, setBusy] = React.useState(false)
4330
4970
  const [error, setError] = React.useState('')
4331
4971
 
@@ -4385,11 +5025,13 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4385
5025
  sessionId: current,
4386
5026
  cwd: summaryCwd || '',
4387
5027
  libraries: (st.libraries || []).filter((l) => l.scopeKey !== '__default__'),
5028
+ domains: Array.isArray(st.domains) && st.domains.length ? st.domains : ['rp', 'coding', 'general', 'mixed'],
4388
5029
  folder: st.folder || null,
4389
5030
  defaultChoice: CARD_OF[String(st.settings?.newSessionDefault || 'both')] || 'both',
4390
5031
  })
4391
5032
  setChoice(CARD_OF[String(st.settings?.newSessionDefault || 'both')] || 'both')
4392
5033
  setPick('folder')
5034
+ setDomain('rp')
4393
5035
  setLibraryId((st.libraries || []).find((l) => l.scopeKey !== '__default__')?.id || '')
4394
5036
  } catch (e) {
4395
5037
  if (!cancelled) setError(String(e?.message || e))
@@ -4437,6 +5079,9 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4437
5079
  // Always write both switches, so the sidebar reflects the card and not
4438
5080
  // a policy default.
4439
5081
  await call('setSessionScope', { sessionId: prompt.sessionId, ...layers })
5082
+ // The domain is written in the same confirm as the memory layers, so
5083
+ // the prompt cannot leave the new conversation on the old default.
5084
+ await call('setDomain', { sessionId: prompt.sessionId, domain })
4440
5085
  decided.current.add(prompt.sessionId)
4441
5086
  setPrompt(null)
4442
5087
  } catch (e) {
@@ -4499,6 +5144,14 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4499
5144
  },
4500
5145
  h('div', { style: { ...S.muted, lineHeight: 1.6 } }, t('memoryPromptBody')),
4501
5146
  error ? h('div', { style: { color: V.danger, fontSize: 12, marginTop: 8 } }, error) : null,
5147
+ h('div', { style: { marginTop: 12 } },
5148
+ h(Field, { label: t('domain'), hint: t('domainPromptHint') },
5149
+ h('select', {
5150
+ style: { ...S.input, flex: 1 },
5151
+ value: domain,
5152
+ disabled: busy,
5153
+ onChange: (e) => setDomain(e.target.value),
5154
+ }, (prompt.domains || []).map((d) => h('option', { key: d, value: d }, domainText(t, d)))))),
4502
5155
  h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 } },
4503
5156
  cards.map((card) => h('button', {
4504
5157
  key: card.id,